Python校园一卡通消费分析:从数据清洗到聚类建模实战

发布时间:2026/9/15 3:22:17
Python校园一卡通消费分析:从数据清洗到聚类建模实战 简介一份基于Python的学生校园消费行为分析项目专为期末大作业与课程设计场景打造适合具备Python基础、希望快速完成数据分析建模任务的学习者。压缩包内共有6个文件5个Python脚本构成分析主体1个zip项目数据包提供原始数据脚本按任务模块拆分覆盖数据处理、特征分析、建模计算与结果输出等环节整体大小仅4.89MB结构清晰便于按需阅读与二次开发。项目下载后可直接运行无需调整环境或修改代码即可得到完整分析结果可作为课程设计报告、PPT演示和答辩环节的有力支撑。目前已有617人学习下载验证了其实用性读者既可直接将其作为期末大作业的交付版本也能参照脚本逻辑学习校园消费数据的分析思路与Python实现方式。1. 这份校园消费分析项目到底在分析什么拿到一张校园一卡通流水表能看出什么门道这个基于 Python 的学生校园消费行为分析项目给的其实是一套完整的「数据清洗 → 特征构造 → 聚类建模 → 业务解读」流水线task1_X.py 负责把原始刷卡记录整理成可分析的表task2_X1.py 和 task2_X2.py 分别从消费结构、时间规律两个角度做描述性统计task3_X_1.py 和 task3_X_2.py 则对应层次聚类与 K-Means 两套建模方案。对正在做期末大作业或者课程设计的人来说它的价值不在于算法多前沿而在于每步都是能直接跑通的代码且特征工程的思路能迁移到其他校园数据场景。我拆完这套代码后把关键实现和容易踩的坑整理在下面。2. 从流水到特征数据清洗与特征工程的实现细节2.1 源数据长什么样task1_X.py 做了哪些预处理原始数据通常是校园一卡通 POS 机导出的流水每一行是一次刷卡记录。常见字段包括学号、交易时间、交易金额、商户类型食堂/超市/开水房/洗衣房等。这类数据有个通病时间字段是字符串、金额存在负值退款、同一个学号可能因为补卡出现多条历史记录。task1_X.py 的核心就是把这些脏数据规整成后续能直接喂给模型的结构。import pandas as pd import numpy as np df pd.read_csv(raw_consume.csv, parse_dates[trade_time], encodingutf-8) # trade_time 直接解析成 datetime 类型避免后续字符串拼接 df df.drop_duplicates(subset[student_id, trade_time, amount]) # 同一秒同一金额的重复刷卡一般视为流水重传直接去重 df df[df[amount] 0] # 金额为负的退款记录在分析消费水平时需要剔除否则均值会被拉低 df[hour] df[trade_time].dt.hour df[weekday] df[trade_time].dt.dayofweek df[date] df[trade_time].dt.date这段代码的关键在于先去重再过滤负值顺序不能反。如果先过滤负值再去重退款记录对应的原始消费记录可能因为金额不同而保留下来造成一正一负两条记录同时存在后续统计日均消费时会把均值严重拉低。parse_dates参数直接指定时间列比读进来后再pd.to_datetime省一次内存拷贝。2.2 特征构造每个人应该用什么维度刻画原始流水不能直接进聚类模型必须把每个学生的行为压缩成固定维度的特征向量。task2_X1.py 这类文件的常见做法是按学号分组聚合出以下特征features df.groupby(student_id).agg( total_amount(amount, sum), total_count(amount, count), avg_amount(amount, mean), max_amount(amount, max), active_days(date, nunique), avg_daily_count(amount, count) / (date, nunique) ).reset_index()nunique统计活跃天数比count更能反映消费稳定性。比如一个学生一个月刷卡 30 次但集中在 3 天另一个学生每天刷一次共 30 次两者total_count相同但行为模式完全不同。avg_daily_count用 count 除以 nunique实际上是计算平均每天的刷卡频次这个特征对区分「每天规律三餐」和「偶尔集中囤货」非常有效。在这一步还要注意(amount, count) / (date, nunique)这种写法在 pandas 0.25 以上版本直接报错更稳妥的做法是先分别聚合再在结果表上用result[total_count] / result[active_days]计算。2.3 时间维度拆分工作日与周末为什么必须分开看校园消费有明显的周期波动工作日的消费高峰集中在早中晚三餐时段周末则可能出现午餐延后、外卖替代食堂的情况。如果混在一起统计聚类结果会被这种结构性差异带偏。常见做法是把数据集拆成工作日和周末两部分分别构造特征weekday_df df[df[weekday] 5] weekend_df df[df[weekday] 5] def build_features(sub_df): g sub_df.groupby(student_id)[amount] return pd.DataFrame({ avg_weekday: g.mean(), std_weekday: g.std(), count_weekday: g.count() }) feat_wd build_features(weekday_df) feat_we build_features(weekend_df)这里注意std计算时默认 ddof1如果某些学生周末只有一条消费记录标准差会是 NaN。后续建模前需要用fillna(0)填充否则 sklearn 的聚类算法会直接报错。NaN 填充为 0 的业务含义是「周末消费规律性极低」这本身也是一种特征信号。3. 消费画像的两种视角金额分层与商家偏好分析3.1 商户类型编码与偏好矩阵构建task2_X2.py 的角色通常是把商户类型做成交叉表生成每个学生的消费偏好矩阵。这里的核心操作是把「商户类型」这种类别字段转成数值型占比特征而不是简单地做 One-Hot。原因在于 One-Hot 后的 0/1 只能表示是否在该商户消费过无法体现消费占比差异。cross pd.crosstab( df[student_id], df[merchant_type], valuesdf[amount], aggfuncsum ).fillna(0) # 交叉表是学生 × 商户类型的消费总额矩阵 ratio cross.div(cross.sum(axis1), axis0) # 除以每行总和得到每个学生在各商户类型的消费占比crosstab的思路类似数据透视表但写起来更简洁。div(..., axis0)是行方向归一化这一步把绝对金额换成相对比例消除不同学生消费总量差异带来的影响。比如甲学生总消费 1000 元、食堂占 800 元乙学生总消费 2000 元、食堂占 1600 元绝对金额完全不同但食堂占比都是 80%聚类时会被归为同一类偏好。3.2 金额分层利用分位数辅助人工规则在聚类之外很多课程设计要求体现「规则分析」能力即先定阈值再分类。task2_X1.py 里常见的套路是利用分位数划分消费层级features[level] pd.qcut( features[total_amount], q4, labels[低消费, 中低消费, 中高消费, 高消费] ) # qcut 按分位数等频切分每档人数大致相同 count_by_level features[level].value_counts().sort_index()qcut和cut的区别必须搞清楚cut是按数值区间等宽切分比如 0-500、500-1000qcut是按分位数等频切分保证每组样本量接近。对于消费金额这种长尾分布明显的字段用qcut比cut合理得多——如果用cut按等宽切分高消费档可能只有几个极端值低消费档挤进大半学生分层就失去意义。分位数分层的结果可以和后续聚类结果做交叉验证如果某个聚类的平均消费金额明显落在「高消费」档说明该簇的业务定义与规则分层一致。4. 层次聚类与 K-Means 对比task3 两个脚本的建模差异4.1 数据标准化为什么层级聚类前必须做这一步task3_X_1.py 与 task3_X_2.py 分别实现层次聚类和 K-Means。两者进入模型前都要做标准化处理但使用的工具和方法不同。层次聚类基于距离矩阵如果量纲不统一——比如总金额范围是 0-5000消费次数范围是 0-100——距离计算会被金额字段主导聚类结果基本只剩金额维度在起作用。from sklearn.preprocessing import StandardScaler from scipy.cluster.hierarchy import dendrogram, linkage, fcluster feature_cols [total_amount, total_count, avg_amount, active_days] X features[feature_cols].fillna(0).values scaler StandardScaler() X_scaled scaler.fit_transform(X) # 标准化后每个特征均值 0、方差 1 Z linkage(X_scaled, methodward, metriceuclidean) # ward 方法以最小化类内方差为目标适合消费行为这种连续型变量StandardScaler的fit_transform是先计算均值和标准差再对每列做(x - mean) / std。标准化后各特征在同一量纲下比较聚类结果才不会偏向某一维。linkage的method有很多选项ward是实践中效果比较稳的——它基于离差平方和增量倾向于生成大小相近的簇。相比之下single最短距离法容易产生链式效应把一个长条串成一个簇对校园消费数据这种天然存在多个密集中心的场景并不合适。4.2 树状图定簇数与 fcluster 截断层次聚类的簇数不像 K-Means 那样靠手肘图而是通过树状图观察类间距离的跳跃点。实际操作中会用dendrogram画出合并过程然后观察最上层的纵向距离。import matplotlib.pyplot as plt plt.figure(figsize(12, 6)) dendrogram(Z, truncate_modelevel, p5) plt.axhline(y15, colorred, linestyle--) plt.xlabel(样本编号) plt.ylabel(距离) plt.tight_layout() plt.show() clusters fcluster(Z, t4, criterionmaxclust) # t4 表示强制分成 4 个簇truncate_modelevel配合p5是为了应对样本量大的情况——几千名学生如果全部画叶子节点树状图底部会挤成一团黑根本看不清结构。截断后只显示最后 5 层合并节点。红色虚线对应的纵坐标就是截断阈值可以选择在距离跳跃最大的位置画线也可以直接指定簇数。fcluster的criterionmaxclust是告诉程序按最多多少个簇来切割t4表示分成 4 类。这里需要验证一个点层次聚类分出来的簇其轮廓系数是否优于 K-Means这决定了最终报告里该采信哪个结果。4.3 K-Means 的肘部法则与最终评估task3_X_2.py 里实现 K-Means 时常见做法是先跑出 K2 到 K8 的轮廓系数再选择最优值。这里有个容易被忽视的细节K-Means 对初始质心敏感每次运行结果可能不同所以要用random_state固定随机种子否则课程设计答辩时前后两次运行结果不一致会被评委质疑代码可复现性。from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score best_k, best_score 2, -1 for k in range(2, 9): km KMeans(n_clustersk, random_state42, n_init10) labels km.fit_predict(X_scaled) score silhouette_score(X_scaled, labels) if score best_score: best_k, best_score k, score final_km KMeans(n_clustersbest_k, random_state42, n_init10) features[kmeans_label] final_km.fit_predict(X_scaled)n_init10表示每个 K 值跑 10 次初始质心取最优结果这是 sklearn 1.2 版本后的默认行为但显式写出来能让阅读代码的人明白你不是直接用了默认配置。silhouette_score的取值范围在 -1 到 1 之间大于 0.25 属于「存在明显的簇结构」低于 0.1 说明数据本身不适合当前特征和算法。计算轮廓系数需要在训练前把 K 值固定住不能直接拿final_km.labels_去评估同一个模型否则会得到偏乐观的结果。4.4 两种聚类结果的业务对照代码跑完不是终点课程设计报告要回答「这些簇分别代表什么学生」。常见做法是把聚类标签合并回原始特征表统计每个簇的特征均值簇 0 日均消费高但活跃天数少、可能是偶尔去超市囤货的学生簇 1 每笔金额低但刷卡次数多、多半是稳定吃食堂的普通学生簇 2 休息日消费占比突出、倾向外卖或校外消费。profile features.groupby(kmeans_label)[feature_cols].mean().round(2) print(profile)这一步用的是groupby加mean输出的是簇 × 特征均值矩阵。注意此时的特征列必须是fillna(0)之后的不能是原始含 NaN 的列否则mean计算时 NaN 会被跳过导致某些簇的特征均值偏低。对比层次聚类和 K-Means 的轮廓系数一般选分数更高的作为最终模型。对于校园消费场景层次聚类在树状图上更容易向非技术背景的评委解释——直接截图展示距离区间划分比纯讲 K-Means 的迭代过程直观得多。5. 画图坐标轴太密集与结果落账的技巧5.1 matplotlib 横坐标标签挤成一团怎么办跑聚类分析基本都会遇到一个搜索高频问题Python 画图横坐标太密集。特别是把每个学生按簇标签排序画散点图或柱状图时所有学生 ID 全堆在 X 轴上标签叠成黑块。网上能搜到的 python 画图横坐标太密集相关讨论核心解法无非是旋转角度、抽稀刻度、或换成索引坐标。这里给出封装好的处理方式import matplotlib.ticker as ticker fig, ax plt.subplots(figsize(14, 6)) ax.scatter(range(len(features)), features[total_amount], cfeatures[kmeans_label], cmapviridis, s12) ax.xaxis.set_major_locator(ticker.MaxNLocator(nbins12)) # 强制 X 轴最多显示 12 个刻度而不是每个样本都标一个 labels features[student_id].astype(str).values ax.set_xticklabels(labels[ax.get_xticks().astype(int)], rotation45, haright)MaxNLocator(nbins12)会把刻度数量压缩到 12 个以内但问题在于压缩后的刻度位置可能落在非整数索引上直接labels[ax.get_xticks().astype(int)]会索引越界。更稳妥的做法是手动选取索引位置tick_positions np.linspace(0, len(features)-1, 12, dtypeint)然后用ax.set_xticks(tick_positions)再绑标签。这样既避免标签重叠又保证每个坐标都有确切的样本 ID 对应。5.2 中文字体乱码的伪解法与真解法另一个高频问题是一画图中文全变方框。很多人网上搜到的方案是plt.rcParams[font.sans-serif] [SimHei]但这在 Linux 服务器上基本无效——因为 SimHei 字体根本不存在只是告诉 matplotlib 用一个不存在的字体名。正确的做法是先确认系统到底有哪些中文字体fc-list :langzh如果有 Noto Sans CJK SC则设置plt.rcParams[font.sans-serif] [Noto Sans CJK SC]。如果没有输出要先安装字体包apt install fonts-noto-cjk再删除 matplotlib 字体缓存目录~/.cache/matplotlib后重启运行。不然无论代码里写什么字体名画出来的还是方框。5.3 验证聚类结果是否真实可信聚类模型不能只看轮廓系数还要验证业务维度上的可解释性。我会把聚类结果按簇拆开分别统计每个簇的周末消费占比和食堂占比如果某个簇的周末消费占比较明显高于其他簇说明这个簇对应的是一群作息规律不同的学生。另一个验证手段是随机抽取 50 个学生人工检查他们的一卡通流水是否与聚类标签一致——这是最笨但也最有效的办法。对课程设计而言能解释每个簇的业务含义比堆叠更多算法更能打动评审老师。最后如果想把这套流程改造成实时分析只需要把pd.read_csv换成对数据库连接查询的结果集处理其余聚合与建模代码可以原样保留。坑在于数据库里的时间字段要先pd.to_datetime转类型不然dt.hour会直接抛 AttributeError。整个过程到这里就完整了每一步都有对应的脚本文件可以直接对照运行。本文还有配套的精品资源点击获取