Python校园一卡通消费行为分析:从数据清洗到KMeans分群实战
简介这是一份面向高校学生与数据分析初学者的Python校园消费行为分析完整项目包适用于毕业设计、期末大作业与课程设计场景帮助读者从零完成数据采集、清洗、分析与可视化全流程。包内共21个文件以7个ipynb交互式笔记、3个py脚本、7张jpg可视化图表为主另含docx分析报告、md说明文档与txt附录压缩包约18.93MB目录结构清晰便于按任务模块逐步学习。项目围绕食堂就餐占比、就餐峰值、不同性别消费对比等主题展开涵盖聚类分析、关联规则与预测模型构建思路代码注释详尽新手也能看懂。目前已有334人学习下载读者可借此掌握Python数据处理与分析技术理解消费行为模型的构建与应用并参考报告撰写与结果展示方法快速部署使用。1. 从一份校园一卡通流水说起学生消费行为分析到底在分析什么手里拿到一份某高校脱敏后的一卡通消费流水字段大概长这样学号、交易时间、商户名称、消费金额、交易类型、卡号。数据量不大一个校区一学期也就几十万到上百万条。很多同学第一反应是「做个可视化大屏」但真正做过的人知道这份数据能回答的问题远比画几张饼图要多哪个食堂窗口在中午 12 点后还有稳定客流、贫困生补助该发给谁、超市消费和食堂消费的比例是否异常、周末留校率能不能从刷卡记录里反推出来。基于 Python 的学生校园消费行为分析核心不是炫技而是把「消费流水」翻译成「行为标签」再翻译成「可执行的结论」。它适合三类人做课程设计想拿高分的学生、想练手完整数据分析链路的转行者、以及高校后勤或学工口想用数据说话的一线人员。整条链路是数据清洗 → 行为特征构造 → 统计分析 → 可视化 → 结果集落盘。源码、数据、结果集三件套缺一不可因为分析结论必须可复现否则就是自说自话。2. 数据从哪来、长什么样先把字段和清洗规则定死2.1 一卡通流水的典型字段与脏数据形态真实的一卡通导出文件通常是 CSV 或 Excel编码可能是 GBK也可能是 UTF-8 带 BOM。字段命名各校不同但语义高度一致。我一般先做一次字段映射把原始列名统一成英文后面所有代码只认这套标准名。原始列名示例标准列名类型说明学号 / XHstudent_idstr前导零必须保留交易时间 / JYSJtrade_timedatetime精确到秒商户名称 / SHMCmerchantstr含食堂、超市、浴室等消费金额 / JEamountfloat正为消费负为充值交易类型 / JYLXtrade_typestr消费/充值/补助卡号 / KHcard_idstr一卡多户时用于去重脏数据主要有四类金额为 0 的测试交易、时间戳落在凌晨 2 点到 5 点的异常刷卡、同一秒内同一卡号的重复记录、以及商户名称里的全角空格和不可见字符。这四类不处理后面所有统计都会偏。2.2 用 pandas 做第一轮清洗的最小代码import pandas as pd import numpy as np # 读取时直接指定编码避免中文乱码 df pd.read_csv(raw_consume.csv, encodinggbk, dtype{student_id: str, card_id: str}) # 字段重命名统一标准 df df.rename(columns{ 学号: student_id, 交易时间: trade_time, 商户名称: merchant, 消费金额: amount, 交易类型: trade_type, 卡号: card_id }) # 时间解析errorscoerce 把非法时间变成 NaT方便后续统计 df[trade_time] pd.to_datetime(df[trade_time], errorscoerce) # 去掉金额为 0、时间为空、学号为空的行 df df[(df[amount] ! 0) df[trade_time].notna() df[student_id].notna()] # 商户名称去空格和不可见字符 df[merchant] df[merchant].astype(str).str.replace(r\s, , regexTrue) # 同一卡号同一秒只保留一条 df df.drop_duplicates(subset[card_id, trade_time], keepfirst) # 只保留消费类型充值单独存一份 consume df[df[trade_type] 消费].copy() recharge df[df[trade_type] 充值].copy() print(consume.shape, recharge.shape)这段代码的关键在三个参数dtype指定学号为字符串防止001234变成1234errorscoerce让非法时间不报错而是变 NaTdrop_duplicates的subset只锁卡号和时间不锁金额因为同一秒同卡不同金额基本是重复写入。清洗完先打印行数和原始行数对比差多少心里要有数。2.3 消费类型拆分与时间粒度对齐清洗完不要急着分析先把「消费」和「充值」分开。充值记录反映的是家庭经济节奏消费记录反映的是日常行为两者混在一起算均值会失真。另外时间粒度要统一按天、按周、按小时三种粒度分别落盘后面画图和建模各取所需。consume[date] consume[trade_time].dt.date consume[hour] consume[trade_time].dt.hour consume[weekday] consume[trade_time].dt.weekday # 0 是周一 consume[week] consume[trade_time].dt.isocalendar().week # 按天聚合落一份中间结果 daily consume.groupby([student_id, date]).agg( total_amount(amount, sum), trade_count(amount, count), avg_amount(amount, mean) ).reset_index() daily.to_csv(clean_daily.csv, indexFalse, encodingutf-8-sig)encodingutf-8-sig是为了 Excel 打开不乱码这个细节很多人踩过坑。中间结果一定要落盘因为后面调参、换图、改模型都会反复用到每次重跑清洗既慢又容易引入不一致。3. 行为特征怎么构造从流水到「这个学生是谁」3.1 消费频次、客单价、消费时段三个基础维度原始流水本身没有分析价值有价值的是从流水里抽出来的行为特征。最基础的三类消费频次一段时间内刷卡次数、客单价总金额除以次数、消费时段分布早中晚夜四个时段的占比。这三类能区分出「食堂型」「超市型」「外卖型」和「夜宵型」学生。# 按学生聚合基础特征 stu_feat consume.groupby(student_id).agg( total_amount(amount, sum), total_count(amount, count), avg_amount(amount, mean), active_days(date, nunique) ).reset_index() # 消费时段占比 def hour_bucket(h): if 6 h 10: return breakfast elif 10 h 14: return lunch elif 14 h 17: return afternoon elif 17 h 21: return dinner else: return night consume[period] consume[hour].apply(hour_bucket) period_pivot consume.pivot_table( indexstudent_id, columnsperiod, valuesamount, aggfuncsum, fill_value0 ) period_ratio period_pivot.div(period_pivot.sum(axis1), axis0) period_ratio.columns [fratio_{c} for c in period_ratio.columns] stu_feat stu_feat.merge(period_ratio, onstudent_id, howleft)active_days用nunique而不是count因为一天可能刷很多次活跃天数才是真实在校天数。时段划分的边界不是死的不同学校作息不同我一般会先画一张全校刷卡小时分布图看峰值落在哪再定边界。3.2 商户偏好向量与消费多样性指数光有金额和频次不够还要知道钱花在哪。把每个学生在各商户类别的消费占比算出来就是一个偏好向量。再算一个香农熵就是消费多样性指数熵越高说明消费越分散熵越低说明越依赖某几个窗口。# 商户类别映射实际项目里用字典或映射表 merchant_type { 第一食堂: canteen, 第二食堂: canteen, 教育超市: supermarket, 浴室: bathroom, 水果店: fruit, 奶茶店: drink } consume[m_type] consume[merchant].map(merchant_type).fillna(other) # 每个学生在各类型的消费占比 type_pivot consume.pivot_table( indexstudent_id, columnsm_type, valuesamount, aggfuncsum, fill_value0 ) type_ratio type_pivot.div(type_pivot.sum(axis1), axis0) # 香农熵 def shannon_entropy(row): p row[row 0] return -(p * np.log(p)).sum() type_ratio[diversity] type_ratio.apply(shannon_entropy, axis1)fillna(other)很重要没映射上的商户不能丢否则总额对不上。熵的计算只取大于 0 的项因为 log(0) 是负无穷。这个多样性指数在贫困生识别里很有用真正困难的学生消费类别通常很集中多样性指数偏低。3.3 把特征拼成一张宽表并落盘所有特征算完后拼成一张学生维度的宽表一行一个学生一列一个特征。这张表就是后面统计、聚类、可视化的唯一输入。final stu_feat.merge(type_ratio, onstudent_id, howleft) final final.fillna(0) final.to_csv(student_features.csv, indexFalse, encodingutf-8-sig) print(final.columns.tolist()) print(final.shape)落盘前检查两件事行数是否等于唯一学号数列里有没有全 0 或全空。全 0 列通常是映射失败全空列通常是 merge 键对不上。这两类问题在宽表阶段发现比在建模阶段发现要省事得多。4. 统计与可视化让结论能被人看懂4.1 用 matplotlib 画四张必出图分析结果要给人看图比表直观。我一般固定出四张全校消费时段分布柱状图、学生消费金额箱线图、商户类别占比饼图、消费多样性指数直方图。这四张覆盖了时间、金额、类别、个体差异四个面。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False fig, axes plt.subplots(2, 2, figsize(12, 10)) # 时段分布 consume.groupby(period)[amount].sum().plot(kindbar, axaxes[0, 0], title各时段消费总额) # 金额箱线图 axes[0, 1].boxplot(final[avg_amount].dropna()) axes[0, 1].set_title(学生客单价分布) # 类别占比 type_pivot.sum().plot(kindpie, axaxes[1, 0], autopct%1.1f%%, title商户类别占比) # 多样性直方图 axes[1, 1].hist(final[diversity], bins30, title消费多样性指数分布) plt.tight_layout() plt.savefig(overview.png, dpi150)SimHei是 Windows 自带中文字体Linux 或 Mac 上要换成系统里有的中文字体否则中文全是方块。dpi150是投稿和打印都够用的分辨率再高文件太大没必要。4.2 用 pyecharts 做可交互的院系对比静态图适合放报告交互图适合自己探索。pyecharts 能快速做出可缩放的柱状图和折线图尤其适合按院系、按年级做对比。from pyecharts.charts import Bar from pyecharts import options as opts # 假设有一张 student_id 到院系的映射表 dept_map pd.read_csv(student_dept.csv, dtype{student_id: str}) merged final.merge(dept_map, onstudent_id, howleft) dept_avg merged.groupby(dept)[avg_amount].mean().sort_values() bar ( Bar() .add_xaxis(dept_avg.index.tolist()) .add_yaxis(平均客单价, dept_avg.values.round(2).tolist()) .set_global_opts( title_optsopts.TitleOpts(title各院系平均客单价对比), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate30)) ) ) bar.render(dept_avg.html)rotate30是防止院系名太长挤在一起。pyecharts 输出的是 HTML嵌到报告里或者单独打开都行。注意院系映射表要单独维护不要硬编码在分析脚本里换一届学生就得改代码。4.3 结果集落盘的三种格式与用途结果集不是只存一份 CSV。我一般落三种CSV 给人看和二次分析JSON 给前端或接口用Excel 多 sheet 给不写代码的同事。# CSV 已落补 JSON 和 Excel final.to_json(student_features.json, orientrecords, force_asciiFalse) with pd.ExcelWriter(analysis_result.xlsx) as writer: final.to_excel(writer, sheet_name学生特征, indexFalse) daily.to_excel(writer, sheet_name日消费明细, indexFalse) type_pivot.to_excel(writer, sheet_name类别偏好, indexFalse)force_asciiFalse保证 JSON 里中文正常显示。Excel 多 sheet 时每个 sheet 名不能超过 31 个字符也不能含特殊符号这个限制很多人第一次写会翻车。5. 避坑与排查那些让我重跑过整条链路的坑5.1 学号前导零丢失导致 merge 后大量空值现象宽表里一半学生的特征全是 0检查发现 merge 时对不上。原因读 CSV 时 pandas 默认把001234推断成整数1234而映射表里是字符串。解决所有涉及学号、卡号的read_csv都加dtype{student_id: str}并且 merge 前用astype(str)再确认一次类型。5.2 时间解析失败被静默丢弃现象清洗后行数比原始少了一大截但没报错。原因pd.to_datetime遇到非法格式默认报错加了errorscoerce后变成 NaT后面notna()过滤时被删掉。解决清洗时先统计 NaT 数量如果超过 1%说明时间格式不统一要回去看原始文件是不是混了多种格式而不是直接删。5.3 商户名称映射遗漏导致「other」占比过高现象饼图里 other 占了 40%明显不合理。原因商户名称里有全角空格、括号、分店后缀映射字典没覆盖。解决先value_counts()看 top 50 商户名再写映射映射后用fillna(other)兜底但 other 占比超过 10% 就要回去补映射。5.4 同一秒重复记录没去干净现象某个学生一天刷卡 200 次明显异常。原因一卡通系统在信号不好时会重传同一秒同一卡号同一金额出现多条。解决drop_duplicates的 subset 用[card_id, trade_time, amount]三个字段一起锁比只锁前两个更准。5.5 可视化中文乱码反复出现现象图上中文全是方块改了字体还是不行。原因rcParams设置必须在import matplotlib.pyplot之后、画图之前且字体名要和系统里实际安装的一致。解决用matplotlib.font_manager查系统可用中文字体Windows 用SimHeiMac 用Arial Unicode MSLinux 用WenQuanYi Micro Hei查不到就装一个。6. 进阶用 KMeans 做消费群体分群与结果验证基础统计做完后最有价值的进阶动作是分群。把学生按消费特征聚成几类比逐个看指标高效得多。我一般用 KMeans特征选avg_amount、total_count、diversity、ratio_lunch、ratio_night五个先标准化再聚类。from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score feat_cols [avg_amount, total_count, diversity, ratio_lunch, ratio_night] X final[feat_cols].fillna(0) X_scaled StandardScaler().fit_transform(X) # 用轮廓系数选 k范围 2 到 8 scores {} for k in range(2, 9): km KMeans(n_clustersk, random_state42, n_init10) labels km.fit_predict(X_scaled) scores[k] silhouette_score(X_scaled, labels) best_k max(scores, keyscores.get) print(best k:, best_k, score:, round(scores[best_k], 3)) km KMeans(n_clustersbest_k, random_state42, n_init10) final[cluster] km.fit_predict(X_scaled) # 看每类的特征均值给类起名字 print(final.groupby(cluster)[feat_cols].mean().round(2))random_state42保证每次跑结果一致n_init10是跑 10 次取最优避免陷入局部最优。轮廓系数越接近 1 越好但实际校园数据通常落在 0.3 到 0.5 之间不用追求太高。聚类完一定要看每类的特征均值给类起人话名字比如「高频低额食堂型」「低频高额超市型」「夜宵活跃型」否则聚类结果没人看得懂。验证分群是否靠谱我一般做两件事一是看每类人数占比如果某一类只有几个人说明 k 选大了二是拿分群结果和已知的贫困生名单做交叉如果困难学生集中在「低频低额」类说明特征构造方向是对的。这一步没有标准答案但能帮你判断结果能不能拿去汇报。最后说个习惯每次改完特征或参数我都会把结果集重新落一份带时间戳的版本比如student_features_20250101.csv。看起来麻烦但当你发现三天前的结论和今天对不上时这份后悔药能救你一命。希望帮到你。本文还有配套的精品资源点击获取