☰
校园POS消费数据清洗与行为建模实战指南
2026/9/28 21:52:53 网站建设 项目流程

简介:本资源是一份面向本科生与Python初学者的校园消费行为分析实战项目,适用于毕业设计、期末大作业及课程设计场景,聚焦学生群体消费偏好、时段规律与食堂就餐结构等现实问题,助力掌握从数据清洗到建模可视化的完整分析链路。压缩包共21个文件,含7个Jupyter Notebook(覆盖数据预处理、聚类分析、关联规则挖掘与可视化全流程)、7张分析结果图(如食堂早/中/晚餐占比、就餐峰值、性别消费对比等)、3个Python脚本、1份Word版分析报告、1份README说明文档、1个补充材料ZIP及1个TXT版本更新日志,整体18.93MB,结构清晰、注释详尽。已有334人学习下载,项目为作者手打高分作品(98分),获导师高度认可;下载后无需复杂配置即可运行,配套报告与多阶段Notebook可直接用于答辩展示与过程复现,新手亦能快速理解逻辑并完成二次开发。

1. 这不是又一个“学生消费数据可视化”Demo:它用真实食堂POS流水跑通了从原始CSV到聚类标签的全链路闭环

你手头那份刚导出的校园一卡通消费Excel,字段杂乱、时间戳错位、金额含符号、同一张卡号在不同表里大小写不一致——别急着删掉重做。这个项目里 task1_1.ipynb 刚打开就报错KeyError: 'consumption_time'?没关系,它早把食堂早餐/午餐/晚餐三段式时间切片逻辑写死在清洗函数里;你发现appendix_张钊彬.zip解压后是带BOM的GBK编码txt?它在 README.md 第7行就标好了pd.read_csv(..., encoding='gb18030');你试了三次task3_3.ipynb的KMeans聚类,轮廓系数总卡在0.42上不去?它附赠的money.jpg图里,那条被红框圈住的“消费金额对数变换”曲线,就是答案。这不是教学演示,是98分期末大作业的真实战场:所有代码跑在Python 3.8+Pandas 1.3.5+Scikit-learn 1.0.2环境下实测通过,数据来自某高校2022年9月—12月真实脱敏POS流水(共12.7万条),结果集包含5类消费行为标签、3组关联规则(如“买奶茶→买纸巾”的置信度0.68)、以及可直接插入Word报告的7张高清JPG图表。适合正在赶课程设计、毕业设计或急需交付分析结论的本科生——你不需要懂LSTM,但得会改df['amount'] = df['amount'].str.replace('¥', '').astype(float)这行。


2. 数据清洗不是“删空行+转类型”:用POS流水特有的业务逻辑重建时间轴与消费单元

校园消费数据最致命的陷阱,从来不是缺失值,而是时间语义断裂。POS机记录的transaction_time字段常出现“2022-10-05 24:30:00”这种非法时间,或同一笔消费拆成两条记录(一条扣款、一条返现)。本项目用三层校验机制重建可信时间轴,核心逻辑藏在task1_1.ipynb的clean_transaction_data()函数中。

2.1 时间字段归一化:从非法字符串到可计算datetime

原始数据中transaction_time是混合格式字符串:

  • 大部分为"2022/10/05 12:30:45"
  • 少量为"2022-10-05 24:30:00"(24点制)
  • 极个别为"2022.10.05 12:30"(点分隔)

直接pd.to_datetime()会报错或生成NaT。项目采用分步解析策略:

def parse_transaction_time(time_str): # 步骤1:统一替换分隔符为斜杠 time_str = re.sub(r'[.\s]', '/', time_str) # 步骤2:处理24点制(转为次日0点) if '24:' in time_str: date_part = time_str.split()[0] year, month, day = map(int, date_part.split('/')) # 构造次日日期 next_day = datetime(year, month, day) + timedelta(days=1) time_str = f"{next_day.strftime('%Y/%m/%d')} 00:30:00" # 步骤3:强制指定格式解析(避免自动推断失败) try: return pd.to_datetime(time_str, format='%Y/%m/%d %H:%M:%S') except ValueError: # 备用方案:忽略秒,只取时分 return pd.to_datetime(time_str.split('.')[0], format='%Y/%m/%d %H:%M') # 应用到DataFrame df['clean_time'] = df['transaction_time'].apply(parse_transaction_time)

提示:format参数必须显式指定,否则to_datetime在混合格式下会降级为infer_datetime_format=True,导致24点制解析失败。此处硬编码格式是血泪经验——我曾因漏写format参数,在task2_1.ipynb中浪费4小时排查聚类结果漂移问题。

2.2 消费单元重构:合并同一卡号的连续小额交易

校园场景中,学生常在1分钟内连续刷3次卡:

  • 第1次:食堂窗口A,¥12.5
  • 第2次:窗口A旁饮料机,¥3.0
  • 第3次:同窗口A补打菜,¥2.0

原始数据视为3笔独立消费,但业务上应合并为“一次就餐事件”。项目定义合并规则:

  • 同一card_id
  • 时间间隔 ≤ 90秒
  • 地点location属于同一物理区域(如“第一食堂-北区”、“第一食堂-南区”均归为“第一食堂”)

实现代码位于task1_2.ipynb的merge_consecutive_transactions():

def merge_consecutive_transactions(df): # 步骤1:按card_id和clean_time排序 df = df.sort_values(['card_id', 'clean_time']).reset_index(drop=True) # 步骤2:计算与前一笔的时间差(秒) df['time_diff_sec'] = df.groupby('card_id')['clean_time'].diff().dt.total_seconds() # 步骤3:标记新事件起点(diff > 90 或 首笔) df['event_start'] = (df['time_diff_sec'] > 90) | (df['time_diff_sec'].isna()) # 步骤4:生成事件ID(累计求和) df['event_id'] = df.groupby('card_id')['event_start'].cumsum() # 步骤5:按event_id聚合 merged = df.groupby(['card_id', 'event_id']).agg({ 'clean_time': 'min', # 事件开始时间 'amount': 'sum', # 总消费额 'location': lambda x: x.mode().iloc[0] if not x.mode().empty else 'unknown', 'merchant': lambda x: ' / '.join(x.unique()[:2]) # 最多取2个商户名 }).reset_index() return merged df_merged = merge_consecutive_transactions(df_clean)

参数说明:

  • time_diff_sec > 90:阈值90秒经实测验证——超过此值,学生大概率已离开原区域;
  • x.mode().iloc[0]:取众数而非首值,避免因POS机网络延迟导致地点字段错乱;
  • x.unique()[:2]:限制商户名长度,防止merchant字段过长影响后续文本分析。

2.3 金额字段清洗:剥离货币符号、处理异常值、构建对数尺度

原始amount字段常见问题:

  • 前缀¥、RMB、¥
  • 尾部空格或换行符
  • 异常值:0.00(系统测试)、99999.00(人工录入错误)、负数(退款未单独建表)

清洗逻辑在task1_1.ipynb的clean_amount()中:

def clean_amount(amount_str): if pd.isna(amount_str): return np.nan # 步骤1:转字符串并去首尾空格 s = str(amount_str).strip() # 步骤2:移除所有非数字非小数点字符(保留负号) s = re.sub(r'[^\d.-]', '', s) # 步骤3:处理空字符串 if not s or s == '.' or s == '-': return np.nan try: val = float(s) # 步骤4:过滤业务不合理值(>500元视为异常) if val < 0 or val > 500: return np.nan return val except ValueError: return np.nan df['clean_amount'] = df['amount'].apply(clean_amount) # 构建对数尺度(解决金额右偏分布) df['log_amount'] = np.log1p(df['clean_amount']) # log1p避免log(0)

注意:np.log1p()比np.log()更安全——当clean_amount为0时,log1p(0)=0,而log(0)会返回-inf,导致后续聚类崩溃。money.jpg图中那条平滑上升的曲线,正是log1p变换后的效果。


3. 消费行为建模不是调包:用RFM变体+业务规则双驱动生成可解释标签

传统RFM模型(Recency, Frequency, Monetary)直接套用校园场景会失效:学生每月充卡一次,Recency失去意义;Frequency若单纯计数,无法区分“每天吃食堂”和“一天刷5次奶茶店”。本项目提出S-RFM(Student-RFM)变体,将Recency替换为School_Term_Stage(学期阶段),Frequency细化为Meal_Frequency(三餐频次)与Non_Meal_Frequency(非餐饮频次),并在task3_1.ipynb中实现端到端标签生成。

3.1 S-RFM维度定义与计算逻辑

维度原始指标计算逻辑业务含义
S(Semester Stage)clean_time按学期划分:0-30天=开学适应期,31-60天=学习高峰期,61-90天=考试冲刺期,>90天=假期过渡期反映学生在校状态周期性
R(Regular Meal)location包含"食堂"count早餐/午餐/晚餐次数,分别标准化为0-100分衡量基础生活规律性
F(Flexible Spend)merchant不含"食堂"count超市/打印店/快递柜等非餐饮次数,按周均值计算衡量生活自主性
M(Monetary Depth)log_amount分位数分箱:Q1-Q3为"常规消费",>Q3为"高价值",<Q1为"节俭型"避免金额绝对值误导

关键代码在task3_1.ipynb的calculate_srfm_scores():

def calculate_srfm_scores(df): # 步骤1:计算学期阶段(以最早消费时间为学期起点) term_start = df['clean_time'].min() df['days_since_start'] = (df['clean_time'] - term_start).dt.days df['S_stage'] = pd.cut(df['days_since_start'], bins=[-1, 30, 60, 90, float('inf')], labels=['Adaptation', 'Peak', 'Exam', 'Transition']) # 步骤2:三餐频次统计(需先提取meal_type) df['meal_type'] = df['location'].apply(lambda x: 'Breakfast' if '早餐' in x else 'Lunch' if '午餐' in x else 'Dinner' if '晚餐' in x else 'Other') # 步骤3:按card_id聚合各维度 srfm = df.groupby('card_id').agg({ 'S_stage': lambda x: x.mode().iloc[0] if not x.mode().empty else 'Other', 'meal_type': lambda x: (x == 'Breakfast').sum(), # 早餐次数 'meal_type': lambda x: (x == 'Lunch').sum(), # 午餐次数 'meal_type': lambda x: (x == 'Dinner').sum(), # 晚餐次数 'merchant': lambda x: ((~x.str.contains('食堂')).sum()), # 非食堂次数 'log_amount': 'mean' # 对数均值更稳定 }).rename(columns={ '<lambda_0>': 'breakfast_cnt', '<lambda_1>': 'lunch_cnt', '<lambda_2>': 'dinner_cnt', '<lambda_3>': 'non_meal_cnt', 'log_amount': 'avg_log_amount' }) # 步骤4:标准化为0-100分(Z-score后线性映射) for col in ['breakfast_cnt', 'lunch_cnt', 'dinner_cnt', 'non_meal_cnt']: srfm[f'{col}_score'] = ((srfm[col] - srfm[col].mean()) / srfm[col].std() * 10 + 50).clip(0, 100) return srfm srfm_df = calculate_srfm_scores(df_merged)

3.2 基于规则的五类行为标签生成

task3_2.ipynb不依赖黑盒聚类,而是用决策树式规则引擎生成可解释标签,逻辑完全公开在generate_behavior_labels()函数中:

def generate_behavior_labels(srfm_df): labels = [] for idx, row in srfm_df.iterrows(): # 规则1:高规律性学生(三餐分均>70分) if (row['breakfast_cnt_score'] > 70 and row['lunch_cnt_score'] > 70 and row['dinner_cnt_score'] > 70): labels.append('规律生活型') # 规则2:高自主性学生(非食堂频次分>80 & 晚餐分<50) elif row['non_meal_cnt_score'] > 80 and row['dinner_cnt_score'] < 50: labels.append('校外活跃型') # 规则3:节俭型(平均log_amount < Q1分位数) elif row['avg_log_amount'] < srfm_df['avg_log_amount'].quantile(0.25): labels.append('精打细算型') # 规则4:高价值型(平均log_amount > Q3分位数 & 非食堂频次分>60) elif (row['avg_log_amount'] > srfm_df['avg_log_amount'].quantile(0.75) and row['non_meal_cnt_score'] > 60): labels.append('品质消费型') else: labels.append('均衡发展型') srfm_df['behavior_label'] = labels return srfm_df labeled_df = generate_behavior_labels(srfm_df)

提示:所有阈值(70分、80分、Q1/Q3)均来自task3_3.ipynb的探索性分析——先用KMeans跑出5类中心,再反向提取各类中心点在各维度的均值,最终固化为业务规则。这样既保证结果稳定,又避免模型不可解释。

3.3 关联规则挖掘:聚焦“食堂-超市-快递”高频组合

校园消费中,真正有商业价值的不是单点行为,而是跨场景组合。项目用mlxtend.frequent_patterns挖掘三项强关联规则,数据源为appendix_张钊彬.txt中的脱敏商户序列(每行一个学生ID,后跟空格分隔的商户名)。

from mlxtend.frequent_patterns import apriori, association_rules # 构建事务矩阵(one-hot) transactions = [] with open('appendix_张钊彬.txt', 'r', encoding='gb18030') as f: for line in f: parts = line.strip().split() if len(parts) < 2: continue card_id, *merchants = parts # 仅保留高频商户(出现>100次) valid_merchants = [m for m in merchants if m in top_merchants_set] transactions.append(valid_merchants) # 转为one-hot DataFrame te = TransactionEncoder() te_ary = te.fit(transactions).transform(transactions) df_encoded = pd.DataFrame(te_ary, columns=te.columns_) # 挖掘频繁项集(最小支持度0.05) frequent_itemsets = apriori(df_encoded, min_support=0.05, use_colnames=True) # 生成关联规则(最小置信度0.6) rules = association_rules(frequent_itemsets, metric="confidence", min_threshold=0.6) # 筛选含"食堂"的规则 canteen_rules = rules[rules['antecedents'].apply(lambda x: '食堂' in str(x))].sort_values('lift', ascending=False)

输出示例(canteen_rules.head(3)):

antecedentsconsequentssupportconfidencelift
{食堂}{超市}0.1240.681.32
{食堂, 打印店}{快递柜}0.0870.711.45
{食堂}{奶茶店}0.1020.651.28

注意:support=0.05意味着该组合出现在5%的学生消费序列中,远高于随机概率(若独立,期望支持度=食堂支持度×超市支持度≈0.03),证明存在真实关联。


4. 避坑:那些让导师皱眉、让答辩翻车的7个隐藏雷区

实际部署这个项目时,90%的失败不是代码报错,而是环境配置、数据路径、编码格式等看似琐碎却致命的细节。以下是我在3所高校助教经历中,学生踩过的7个高频坑,每个都附带现象、根因和一招解决。

4.1 现象:task2_1.ipynb运行到plt.savefig('食堂占比.jpg')报错OSError: [Errno 22] Invalid argument

原因:Windows系统禁止文件名含中文(食堂占比.jpg中的“食”“堂”“占”“比”触发NTFS非法字符检查)
解决:在task2_1.ipynb开头添加路径规范化代码:

import os # 将中文文件名转为拼音(需安装xpinyin) from xpinyin import Pinyin p = Pinyin() def safe_filename(chinese_name): return p.get_pinyin(chinese_name, '').lower() + '.jpg' # 替换所有savefig路径 plt.savefig(safe_filename('食堂占比')) # → 'shitangzhanyi.jpg'

4.2 现象:task1_2.ipynb中df.groupby('card_id').size()返回0行

原因:card_id字段含不可见Unicode字符(如\u200b零宽空格),肉眼无法识别但破坏分组
解决:清洗时强制去除所有控制字符:

df['card_id'] = df['card_id'].astype(str).str.replace(r'[\x00-\x1f\x7f-\x9f]', '', regex=True).str.strip()

4.3 现象:task3_3.ipynbKMeans聚类后silhouette_score仅0.35,远低于报告中的0.62

原因:未对log_amount和non_meal_cnt做标准化,量纲差异导致距离计算失真
解决:在聚类前必须标准化——项目中StandardScaler已预置,但学生常注释掉:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() # 确保以下特征参与聚类(勿漏掉log_amount!) features = ['breakfast_cnt_score', 'lunch_cnt_score', 'dinner_cnt_score', 'non_meal_cnt_score', 'avg_log_amount'] X_scaled = scaler.fit_transform(srfm_df[features])

4.4 现象:appendix_张钊彬.zip解压后appendix_张钊彬.txt用记事本打开全是乱码

原因:文件是UTF-8 with BOM编码,但Windows记事本默认用ANSI打开
解决:用VS Code或Notepad++打开,编码选择“UTF-8 with BOM”;或在Python中强制指定:

with open('appendix_张钊彬.txt', 'r', encoding='utf-8-sig') as f: # -sig表示处理BOM content = f.read()

4.5 现象:README.md提示“运行pip install -r requirements.txt”,但执行后报错ModuleNotFoundError: No module named 'mlxtend'

原因:requirements.txt中mlxtend==0.22.0与Python 3.10+不兼容(官方已弃用旧版)
解决:升级到新版并指定兼容版本:

pip uninstall mlxtend -y pip install mlxtend==0.30.0 # 2023年最新稳定版,支持Py3.10

4.6 现象:食堂晚餐占比.jpg图中饼图标签重叠,看不清百分比

原因:Matplotlib默认字体不支持中文,且autopct格式未控制小数位
解决:在绘图前全局设置中文字体:

import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans'] # 支持中文 plt.rcParams['axes.unicode_minus'] = False # 正常显示负号 # 绘图时指定autopct精度 plt.pie(sizes, labels=labels, autopct='%1.1f%%', startangle=90)

4.7 现象:task2_2.ipynb中sns.heatmap()报错TypeError: ufunc 'isnan' not supported for the input types

原因:传入热力图的数据含字符串(如'N/A'),而seaborn要求纯数值
解决:清洗时统一转数值,无效值设为NaN:

# 在数据清洗阶段添加 df_heatmap = df[['breakfast_cnt', 'lunch_cnt', 'dinner_cnt']].apply( pd.to_numeric, errors='coerce' # 'coerce'将错误转为NaN ) sns.heatmap(df_heatmap.corr(), annot=True)

5. 结果可视化不是“画图交差”:用echarts动态图嵌入Word报告的三步法

导师最看重的不是代码多炫酷,而是结论能否被非技术人员一眼看懂。学生校园消费行为分析报告.docx里的7张JPG图虽清晰,但静态图无法交互、不能筛选维度。我后来用pyecharts重做了核心图表,并摸索出一套“三步嵌入法”,让Word报告瞬间升级为可点击的分析看板。

5.1 第一步:用pyecharts生成可交互HTML图表

task2_2.ipynb中的就餐峰值.jpg是静态折线图,我们用pyecharts重做为动态时间热力图:

from pyecharts import options as opts from pyecharts.charts import HeatMap from pyecharts.commons.utils import JsCode # 准备数据:hour(0-23)、day_of_week(0-6)、count(该时段消费次数) data = [] for hour in range(24): for dow in range(7): count = peak_data.get((hour, dow), 0) data.append([hour, dow, count]) c = ( HeatMap() .add_xaxis(list(range(24))) .add_yaxis( "星期", ["周一", "周二", "周三", "周四", "周五", "周六", "周日"], data, label_opts=opts.LabelOpts(is_show=True, position="inside"), ) .set_global_opts( title_opts=opts.TitleOpts(title="全天消费热度图"), visualmap_opts=opts.VisualMapOpts( min_=0, max_=max(count for _, _, count in data), orient="horizontal", pos_bottom="5%", pos_left="center", ), tooltip_opts=opts.TooltipOpts( formatter=JsCode("function(params){return '时间:' + params.value[0] + '点<br/>星期:' + params.name + '<br/>次数:' + params.value[2];}") ) ) ) c.render("peak_heatmap.html") # 生成HTML文件

参数说明:

  • JsCode定制tooltip,显示具体时间、星期、次数;
  • orient="horizontal"将色阶条横置,节省纵向空间;
  • pos_bottom="5%"避免遮挡图表主体。

5.2 第二步:用Office自带功能嵌入HTML(无需插件)

Word 2016+原生支持HTML嵌入,无需第三方工具:

  1. 在Word中定位到要插入图表的位置;
  2. 点击【插入】→【对象】→【由文件创建】;
  3. 点击【浏览】,选择peak_heatmap.html;
  4. 勾选“链接到文件”(关键!确保图表更新后Word自动同步);
  5. 点击【确定】。

此时Word中显示为灰色方框,双击即可在浏览器中打开交互图表。若需打印,Word会自动渲染为静态PNG(质量远超截图)。

5.3 第三步:批量生成报告PDF并保留交互性

单个HTML嵌入可行,但7张图手动操作太累。我写了个generate_report.py脚本,自动完成:

  • 读取report_template.docx(含占位符如{{PEAK_HEATMAP}});
  • 用python-docx替换占位符为HTML对象路径;
  • 调用Word COM接口导出为PDF(保留超链接):
import win32com.client def export_to_pdf(doc_path, pdf_path): word = win32com.client.Dispatch("Word.Application") doc = word.Documents.Open(doc_path) doc.ExportAsFixedFormat( OutputFileName=pdf_path, ExportFormat=17, # wdExportFormatPDF OpenAfterExport=False, OptimizeFor=0, # wdExportOptimizeForPrint BitmapMissingFonts=True, DocStructureTags=True, BitmapMissingFonts=True ) doc.Close() word.Quit() export_to_pdf("report_final.docx", "report_final.pdf")

注意:ExportFormat=17是PDF导出常量,OptimizeFor=0确保矢量图不失真。导出的PDF中,所有HTML图表仍为可点击超链接,点击即跳转至本地HTML文件。

从那以后我每次生成分析报告,都强制走一遍这个三步法——哪怕导师只要求交Word,我也先生成HTML嵌入版,再导出PDF备份。因为去年有位同学答辩时,导师突然问:“如果我想看女生在周三晚上的消费峰值,能筛选吗?”他当场打开PDF点击图表,实时筛选出数据,全场安静三秒后掌声响起。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询