ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python校园消费行为分析:从流水清洗到K-Means聚类

Python校园消费行为分析:从流水清洗到K-Means聚类

简介:数据分析是挖掘业务规律的基础工具,而聚类分析则是将海量行为数据转化为人群标签的常用方法。无论是电商交易、校园一卡通流水,还是其他行为日志,数据分析流程都遵循清洗、聚合、可视化、建模的通用路径。本文从数据清洗与字段对齐讲起,聚焦pandas处理重复记录与异常值、matplotlib绘制消费时段与金额分布图,再引入K-Means聚类构建学生消费画像,并回答“哪类学生是消费主力”等业务问题。该方法不依赖复杂分布式环境,仅用Python基础库即可完成统计分析与人群分层,适用于课程设计、毕业设计或快速产出分析报告等场景。最终,通过轮廓系数选择K值、特征标准化与PCA降维验证,将聚类编号翻译为可解释的消费群体标签,让数据分析真正闭环到决策建议。

1. 一份学生消费流水的毕业设计,难点不在建模而在还原业务

很多同学拿到“学生校园消费行为分析”这个题目,第一反应是找现成的开源数据集,或者直接往机器学习模型上靠。实际上手才发现,校园一卡通流水更像一个黑匣子:每一条记录只有卡号、时间、金额、商户名称,没有性别、年级、专业这些维度,就算想分人群,也无从下手。这套基于Python的学生校园消费行为分析方案,解决的是从原始流水到结论落地的完整链路:数据清洗和字段对齐怎么做,统计图表怎么和业务解释对上,聚类结果怎么翻译成“哪类学生是消费主力”这类可答辩的结论。它适合正在做数据分析方向课设、毕设的学生,也适合想快速把一卡通数据变成一份完整分析报告的新手。整份资源包含两个数据表、三组Python脚本和一批可视化的结果图,按任务拆开看,每一步都有明确的输入与输出。

2. 方案总体设计:两份数据表、三组脚本与全链路交付物

拿到压缩包之后,第一步不是看代码,而是把文件清单和任务线对应起来。该资源的文件结构其实非常清晰:data1.csv与data2.csv是输入数据,task1_*.py负责数据清洗,task2_*.py负责消费统计与可视化,task3_*.py负责特征工程与聚类画像,最后result目录里存放全部图表和中间结果。下面按模块说明。

2.1 数据集语义还原:data1.csv 与 data2.csv 的字段对应关系

这份资源没有提供字段字典,但按校园消费场景的惯例和脚本中对列的引用方式,可以还原两个表的组织方式。data1.csv是一卡通消费流水明细,通常包含学生编号、消费时间、消费金额、商户类型或窗口编号;data2.csv是学生基础信息表,包含学生编号、性别、年级、院系、宿舍楼区域等属性。两份表通过学生编号字段关联,属于典型的事实表加维度表结构。

用pandas做关联前的第一步,一定是检查两个表的键是否唯一。有的校园卡系统里同一学生编号对应多张卡,有的基础信息表存在一人多条记录的情况,直接merge会让流水行数膨胀,后面的聚合统计全部失真。建议先对基础信息表按学生编号去重,保留一条最新记录,再做连接。另一个容易忽略的点是编码格式,一卡通系统导出的 CSV 经常是 GBK 或 GB2312 编码,直接pd.read_csv()读进来全是乱码,一般用encoding='gbk'或encoding='utf-8-sig'来回试。

2.2 三个任务的职责划分:从清洗到画像的递进关系

任务一对应task1_X1.py、task1_X2.py,产出task1_X.csv与task1_1_X.csv。前者是清洗后的流水明细表,后者是学生维度的聚合表,这一步解决“数据能不能直接用来分析”的问题。常见的清洗动作包括:删除同一学生同一分钟同一金额的重复消费记录、过滤金额为负数的退款和充值记录、将时间字段统一为 datetime 类型、处理缺失的商户类型。

任务二对应task2_X1.py,产出task2_X1.png至task2_X5.png五张图,分别覆盖消费时段分布、金额分布、商户类型消费笔数排行、每月消费总额趋势、人均消费金额分布。这五张图对应答辩时最常被问的“你发现了什么规律”,每一张图都要能讲出一个业务结论,例如“午餐时段消费笔数明显高于早餐和晚餐”,而不是单纯堆图。我在拆这个任务时发现,画图本身不难,难的是让图表的纵轴、图例、标题能直接回答一个问题,所以每张图我都固定好输出文件名,方便对照结果集核对。

任务三对应task3_X1.py至task3_X3.py,产出task3_X1.png至task3_X4.png四张图,是整份方案的加分项。这里用到 K-Means 聚类,先把流水聚合为每名学生的特征向量,再做标准化与聚类,最后把聚类结果投射到二维平面并给每一类人群做画像。输出结果从“统计规律”升级到“人群分层”,是区分高分毕业设计和普通作业的关键。

2.3 技术栈选型:为什么用 pandas + matplotlib + scikit-learn 就足够

整份资源基于 Python 完成,核心依赖只有pandas、numpy、matplotlib、scikit-learn四个库。相比直接上 PySpark 或深度模型,这套组合在这个场景下更合理:数据量级是单校区一学期的一卡通流水,顶多几万到几十万行,单机 pandas 完全跑得动;算法层面只需要清洗、聚合、聚类和可视化,没有复杂的分布式需求;答辩现场演示时,依赖越少越不容易翻车。另一个现实因素是,很多课程环境只预装 Anaconda,这四个库是基础包,不需要额外配环境。

如果样本量超过 50 万行,groupby和merge的速度会下降,通常的做法是用category类型优化商户字段,或者先用pyarrow引擎读 CSV,能显著减少内存占用。但这份数据集的规模远没到需要优化的程度,所以我更建议把精力放在字段语义对齐和业务结论上,而不是过度工程。

3. 任务一与任务二实现:数据清洗、统计口径与图表快速产出

这一章是整套代码里最容易被复制和改造的部分。任务一的代码解决“拿到手的数据怎么变成能用的表”,任务二的代码解决“怎么用图表把规律讲清楚”。两者连在一起,就是一份完整的探索性数据分析流程。

3.1 数据清洗代码:去重、时间解析与异常金额过滤

import pandas as pd import numpy as np # 读取流水明细与基础信息,两个文件都按 utf-8-sig 尝试,乱码时换 gbk df_log = pd.read_csv('data1.csv', encoding='utf-8-sig') df_info = pd.read_csv('data2.csv', encoding='utf-8-sig') # 基础信息表按学生编号去重,防止 merge 后流水行数膨胀 df_info = df_info.drop_duplicates(subset=['student_id']) # 删除同一学生同一分钟同一金额的重复记录(一卡通重复扣款场景) df_log = df_log.drop_duplicates( subset=['student_id', 'consume_time', 'amount'] ) # 时间列统一转为 datetime,解析失败的行会被置为 NaT df_log['consume_time'] = pd.to_datetime( df_log['consume_time'], errors='coerce' ) # 过滤无效时间与异常金额,校园消费通常在 0.5~100 元内 df_log = df_log.dropna(subset=['consume_time']) df_log = df_log[(df_log['amount'] >= 0.5) & (df_log['amount'] <= 100)] # 合并基础信息,用于后续按人群分组分析 df = pd.merge(df_log, df_info, on='student_id', how='left') df.to_csv('task1_X.csv', index=False, encoding='utf-8-sig')

这里几个参数需要重点说明。subset参数指定去重时参考的列组合,只对卡号去重太粗暴,会误删同一学生一天内的多笔真实消费,组合键能兼顾“重复扣款”和“真实多次消费”的区别。errors='coerce'让无法解析的时间变成NaT而不是抛异常中断脚本,之后再统一丢弃,这样单条脏数据不会拖垮整批处理。金额阈值 0.5 到 100 元不是拍脑袋定的,校园一卡通早餐一般 3 到 8 元,午餐晚餐 8 到 20 元,超市购物二三十元,超过 100 元的多为代缴或批量充值,不同学校差异较大,建议先用df_log['amount'].describe()看分位数再定上下限。

3.2 学生维度聚合:生成 task1_1_X.csv 的字段设计

task1_X.csv是清洗后的流水明细,行数仍然很大,直接做聚类不合适。task1_1_X.csv是把流水压缩到学生维度的聚合表,每一行代表一名学生在一个统计周期内的消费概况。核心聚合字段包括消费总次数、消费总金额、单笔平均金额、消费活跃天数,这些字段是后续所有分析的基石。

# 学生维度聚合:次数、总额、均值、活跃天数 stu = df.groupby('student_id').agg( total_count=('amount', 'count'), # 总消费笔数 total_amount=('amount', 'sum'), # 总消费金额 avg_amount=('amount', 'mean'), # 单笔平均金额 active_days=('consume_time', lambda s: s.dt.date.nunique()) # 活跃天数 ).reset_index() # 衍生字段:日均消费次数、日均消费金额、早餐/午餐/晚餐占比 stu['daily_count'] = stu['total_count'] / stu['active_days'] stu['daily_amount'] = stu['total_amount'] / stu['active_days'] stu.to_csv('task1_1_X.csv', index=False, encoding='utf-8-sig')

活跃天数这一列是整个聚合的隐藏重点。如果只统计总次数和总额,一名每天都来食堂吃饭的学生和一名只在月初月末集中充值消费的学生可能总金额接近,但行为模式完全不同。active_days参与衍生字段计算后,daily_count和daily_amount才能真实反映消费频率,这也是后续聚类特征里区分度最高的两个变量。输出 CSV 时统一用utf-8-sig编码,这样 Excel 直接打开不会乱码,给导师看中间结果也省心。

3.3 消费时段分布图:从数据到业务解释的完整链路

时段分布是答辩时最好讲的一张图,它直接对应“学生作息规律”这个业务问题。实现上把consume_time抽取小时,按小时分组统计笔数和金额,再画成柱状图。task2_X1.png就是这张时段分布图,我按小时聚合后,通常会叠加一条金额折线,让“笔数高峰”和“金额高峰”在同一个坐标系里对比。

import matplotlib.pyplot as plt # 中文字体与负号显示设置,必须放在画图之前 plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False df['hour'] = df['consume_time'].dt.hour hour_stat = df.groupby('hour')['amount'].agg(['count', 'sum']).reset_index() fig, ax1 = plt.subplots(figsize=(10, 4)) ax1.bar(hour_stat['hour'], hour_stat['count'], color='#4C72B0', label='消费笔数') ax2 = ax1.twinx() ax2.plot(hour_stat['hour'], hour_stat['sum'], color='#C44E52', marker='o', label='消费金额') ax1.set_xlabel('消费时段(小时)') ax1.set_ylabel('交易笔数') ax2.set_ylabel('消费金额(元)') plt.title('校园消费时段分布:笔数与金额双轴对比') plt.xticks(range(0, 24)) plt.tight_layout() plt.savefig('task2_X1.png', dpi=150)

双轴图容易踩的坑是左右刻度量级差太多,导致其中一条线被压平。我的习惯是先单独看两列数据的最大最小值,如果金额最大值是笔数最大值的十倍以上,就说明两个指标不在一个量级,需要考虑对金额列做对数变换或者改用归一化后的双轴。另外plt.xticks(range(0, 24))保证横轴完整显示 0 到 23 点,不会因为某些小时没有消费数据就缺刻度。

task2_X2.png到task2_X5.png的实现思路类似:金额分布画直方图加核密度曲线,商户类型排行画水平柱状图方便读标签,月度趋势画折线图观察学期内变化,人均消费金额画箱线图看离群点。每一张图都要在答辩时对应一句结论,画完图后我一般会随手在代码注释里写上该图能支撑的业务结论,避免分析做完就忘。

4. 任务三实现:特征工程、K-Means 聚类与学生人群画像

任务三是整份方案中技术含量最高的环节,也是评审老师判定“有没有真正掌握数据分析方法”的分水岭。它输入的是任务一产出的学生维度聚合表,输出的是四张图和对应的人群画像结论。这一章重点讲特征怎么选、K 值怎么定、聚类结果怎么解释。

4.1 聚类特征构建:不是所有聚合字段都适合直接进模型

从task1_1_X.csv中能拿到总笔数、总金额、单笔均值、活跃天数、日均笔数、日均金额等字段,但不能全扔进 K-Means。总笔数和总金额高度相关,属于共线性特征,会让聚类结果过度偏向金额维度;活跃天数和日均笔数信息重复,两者保留一个即可。我最终保留三个特征:日均消费次数、日均消费金额、单笔平均金额。这三个特征分别刻画消费频率、消费总量和消费档次,业务含义互不重叠。

特征选定后,标准化是必须做的一步。K-Means 基于欧氏距离计算样本相似度,日均金额的数值范围是几十,单笔均值的范围是个位数到二十几,如果不标准化,聚类结果几乎完全由金额字段主导,消费次数的区分度会被淹没。用StandardScaler做 Z 分数标准化后,三个特征均值为 0、方差为 1,权重才平等。

4.2 K 值选择与聚类实现:轮廓系数曲线匹配 task3_X1.png

K-Means 最被人诟病的地方是 K 值需要人工指定。这里不靠玄学,用轮廓系数曲线来选。轮廓系数衡量样本与自身簇的紧密度和与相邻簇的分离度,取值范围 -1 到 1,越接近 1 说明聚类效果越好。通常遍历 K 从 2 到 6,画出每个 K 对应的平均轮廓系数,取峰值或肘部对应的 K 值。

from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score features = stu[['daily_count', 'daily_amount', 'avg_amount']].copy() scaler = StandardScaler() X = scaler.fit_transform(features) # 遍历 K 值,计算轮廓系数 sil_scores = [] for k in range(2, 7): model = KMeans(n_clusters=k, n_init=10, random_state=42) labels = model.fit_predict(X) score = silhouette_score(X, labels) sil_scores.append(score) print(f'K={k}, silhouette_score={score:.4f}') # 选出轮廓系数最大的 K 值作为最终聚类数 best_k = range(2, 7)[sil_scores.index(max(sil_scores))] final_model = KMeans(n_clusters=best_k, n_init=10, random_state=42) stu['cluster'] = final_model.fit_predict(X)

n_init=10这个参数要特别说明。K-Means 的初始质心是随机的,单次运行可能陷入局部最优,n_init表示用不同随机种子跑 10 次取最优结果,能显著降低结果的随机性。random_state=42固定随机种子,保证每次运行结果一致,答辩现场重新跑一遍也不会和报告里的数字对不上。轮廓系数对 K 的选择在这个数据集上通常指向 K=3,即三类人群:高频刚需型、中频均衡型、低频随机型。

需要注意的是,轮廓系数不是越高越好。K 越大,每个簇越紧致,轮廓系数往往越高,但过细的分群失去业务解释力。如果 K=5 的轮廓系数只比 K=3 高一点点,而 K=5 的群体画像很难用一句话概括,我一般会选 K=3,因为答辩时能给每个群体起名字、讲特征,这才是聚类的目的所在。

4.3 画像解释与结果图映射:把聚类编号翻译成业务语言

聚类完成后输出task3_X1.png到task3_X4.png四张图。task3_X1.png是轮廓系数随 K 值变化的曲线,展示选 K 的过程依据;task3_X2.png是用 PCA 降维后把样本点投射到二维平面的散点图,不同聚类不同颜色,让评委直观看到分群效果;task3_X3.png是三个类别的特征均值对比柱状图,可视化每类人群在日均笔数、日均金额、单笔均值上的差异;task3_X4.png是三类人群占比饼图,配合画像结论使用。

把聚类编号翻译成业务语言时,我一般会给每类人群定义可读的名字和描述。第一类高频刚需型,日均消费次数在 3 次以上,日均金额高,单笔均值稳定在 10 到 15 元,这类学生几乎三餐都在食堂解决,消费习惯规律,是食堂最核心的客群;第二类中频均衡型,日均 1 到 2 次,金额中等,偶尔去超市和水果店,行为模式贴合大多数在校生;第三类低频随机型,日均不足 1 次,但单笔均值很高,可能习惯囤积食品或经常在校外就餐,只在特定场景使用一卡通。这三类画像直接支撑答辩时“学校可以针对不同人群制定差异化营销策略”这类建议。

聚类结果的验证也不能跳过。我会用原始流水回查每个聚类群体的月度消费趋势,确认某类人群不是某一个月异常波动造成的误分。如果某类学生的画像在学期中段突然改变,还会检查是否因为一卡通系统升级导致商户类型字段变化。这种交叉验证过程写进论文,比单纯贴聚类散点图更有说服力。

5. 常见问题与避坑指南:这几处不处理,复现结果会直接翻车

整套源码经过调试可以运行,但如果换一台电脑、换一个数据集、改一个字段名,最容易出问题的就是下面五个位置。按“现象到原因再到解决”的顺序梳理,是为了让读者不只在原数据上跑通,还能迁移到自己的数据上也能跑。

5.1 CSV 编码格式导致中文乱码

现象:pd.read_csv('data1.csv')读出来列名全是乱码,或者打印数据框时中文显示成菱形符号。原因:一卡通系统导出的文件多半是 GBK 编码,而 pandas 默认用 UTF-8 解码。解决:读取时显式指定encoding='gbk'或encoding='utf-8-sig',两种都试一下就能确定实际编码。输出文件时统一用encoding='utf-8-sig',这样 Excel 打开不会乱码,也用 BOM 头规避了再次读取时编码误判。这个坑在 Windows 环境尤其常见,matplotlib 画图时中文字体缺失的表现完全不同,需要单独设置字体。

5.2 时间字段解析失败导致整行数据被丢弃

现象:pd.to_datetime报错,或者转换后大量时间变成NaT,聚合结果行数明显偏少。原因:原始数据里混入了2024/5/1 12:30、202405011230、2024-05-01 12:30:00等多种格式,部分行时间格式不统一。解决:先df['consume_time'].head(20)人工确认主流格式,然后用format参数强制指定,解析失败的行设置errors='coerce'置为NaT再统一丢弃,而不是让脚本中断。经验是任何导出的流水表里都会有少量脏时间,清洗时保留这些行会让后续按小时聚合出现幽灵数据。

5.3 聚类前忘记标准化导致分群无效

现象:聚类结果里某一类几乎占据 95% 的样本,另外两类各占极小比例,PCA 降维图看不出清晰边界。原因:没有做标准化,金额特征的数值范围远大于次数特征,欧氏距离被金额主导。解决:在fit前必须用StandardScaler或MinMaxScaler处理所有特征列。检查方式也很直接:标准化后打印每个特征的均值和方差,确认均值接近 0、方差接近 1 再进入聚类。如果你发现聚类结果和业务直觉对不上,九成是这一步漏了。

5.4 去重键设置不当导致样本量失真

现象:drop_duplicates后数据量从 10 万行骤减到 3 万行,或者反过来,重复记录没有去掉。原因:只用student_id去重会误删同一学生的多次真实消费,只按全部字段去重又无法命中同一秒重复扣款的情况。解决:组合键['student_id', 'consume_time', 'amount']是折中方案,既能过滤“一卡通终端重复扣款”产生的完全一致记录,又不会误删同一天内不同时段的正常消费。如果原始数据里有交易流水号,用流水号去重是最准确的,但这份资源的数据集没有流水号字段,组合键是最稳妥的替代方案。

5.5 matplotlib 保存图片为空白或纯色块

现象:plt.show()能看到图,但plt.savefig()出来的 PNG 是空白或全部是白底黑字的残缺图。原因:savefig默认使用后端可能与当前环境冲突,或者tight_layout没有调用导致坐标轴标签被裁剪。解决:savefig前先调用plt.tight_layout()自动调整布局,再显式指定dpi=150。如果保存的图还是空白,在脚本开头加一行import matplotlib; matplotlib.use('Agg'),强制使用无界面后端,在服务器和本地都能稳定出图。这个坑在远程调试或 Jupyter 环境切换时最常见。

6. 验收与答辩:用数据核验表和一张主线图撑住全场

方案做完不等于可以交付。我一般会按三个层次做验收:第一层是数据层,确认清洗前后行数变化、金额范围、时间跨度都有记录;第二层是代码层,保证脚本换一台机器从零跑一遍能产出完全一致的结果;第三层是业务层,每一张图都要能说出一句对应结论。三层都过了,再进入答辩准备。

数据层验收建议做一张简单的核验表。清洗前流水总数、清洗后流水总数、去重删除行数、异常金额行数、时间覆盖范围、有效学生人数,这六个数字写进论文的预处理章节,既展示工作量又方便答辩时被人追问。代码层验收最有效的方式是从原始 ZIP 解压开始,不打开任何中间 CSV,直接按顺序跑完task1、task2、task3三组脚本,比对输出的 PNG 和 CSV 与源结果集是否一致。这一步选出当时源码“经过严格调试、确保可以运行”的说法,也让你自己熟悉整个执行流程。

答辩时不要从第一行代码开始讲,评委没有耐心听你念代码。我采用的策略是准备一张主线图:左边是输入数据,中间是三个处理步骤,右边是输出结论。每一层用一句话解释,例如“第一步清洗,去掉了 1200 条重复扣款和 38 条异常金额;第二步做时段分析和商户排行,发现午餐时段消费占比超过 35%;第三步用 K-Means 把学生分成三类,其中高频刚需型只占 18% 却贡献了 31% 的消费总额”。这段话把需求、方法、结果串成一条线,两分钟内讲完,后续评委的问题基本围绕细节展开,不会出现不知道你做了什么的局面。

有一次模拟答辩,同学把聚类代码的每个循环都翻出来讲,结果被评委反问“你觉得第 30 行为什么要加那个参数”时卡住了。从那以后我再讲任何分析项目,都强制自己先总结三个数字:跑通代码要多久、清洗掉多少脏数据、聚类分出几类人。能讲清楚这三个数字,方案就立住了。希望这份从清洗到聚类的完整拆解,能帮你在自己的数据集上少走几步弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进