
简介面向校园一卡通消费数据挖掘场景这份Python项目资源提供了从CSV数据加载、预处理、KMeans聚类建模到Plotly可视化与Streamlit交互界面的完整实现源码模块划分清晰适合Python数据分析初学者、高校课程设计或消费行为分析相关学习者。资源包共24个文件约13.06MB主体包含6个.py源码文件、2份CSV原始数据、2份Markdown运行文档另有PDF说明报告、Word文档、批处理启动脚本和演示图便于直接运行、对照阅读和二次扩展。核心功能包括异常值过滤、时间特征提取、消费次数与平均消费等特征构建以及低/中/高消费群体画像与交互式图表展示覆盖了学生经济评估的常用分析流程。目前已有142人学习下载整体结构紧凑、配套文档完整是快速掌握校园消费行为分析与Python数据挖掘实践的实用资料。1. 校园消费行为分析与学生经济评估一卡通流水里到底能挖出什么手里握着全校一卡通流水数据想评估学生消费水平、识别需要重点关心的经济困难学生这是很多高校资助部门和信息中心的真实诉求。直接看总额是最容易翻车的做法——有人顿顿吃食堂有人校外点外卖同样一个月花 1200 元卡内流水完全不同。校园消费行为分析与学生经济评估这个项目就是要把原始流水清洗成“学生×月份”的行为特征表再用聚类把消费模式分成可解释的层级输出一份供人工复核的候选名单。适合三类人做毕业设计或课程设计的学生想找一份带完整源码和数据集的真实业务题目高校数据岗从业者需要一个能落地的评估框架以及刚开始接触数据分析、想从“会跑模型”进阶到“讲清业务逻辑”的从业者。2. 把流水变成特征表一卡通消费数据的清洗与聚合2.1 原始流水长什么样字段结构与脏数据识别一卡通流水导出后通常是 CSV 或 Excel一个校区一个月就是几十万行全校全年上千万行也很常见。这种量级单机 Pandas 就能处理不用上分布式。但前提是先把字段结构弄清楚。字段常见命名用途与注意点流水号transaction_id / serial_no去重主键之一注意系统重传导致重复学号student_id / emp_no关联学生档案注意教职工和测试账号混入交易时间trans_time / deal_time解析为 datetime注意格式混用金额amount / deal_amt注意正负号语义退款可能是正数加标记商户名称merchant_name / shop_name用于映射消费类型名称极其不规范流水类型trans_type / biz_type区分消费、充值、退款、挂失工本费卡内余额balance / card_bal校验数据连续性负数说明清洗顺序错了脏数据主要集中在四类重复流水、非消费流水、异常金额、非学生账号。处理顺序错了后面全错——我见过先把金额为 0 的剔掉、结果把“退款后余额为 0”的正常流水全删了的案例。import pandas as pd df pd.read_csv(campus_card.csv, encodingutf-8-sig, parse_dates[trans_time]) print(原始记录数:, len(df)) print(字段列表:, list(df.columns)) # 1 去重同一学生同一时间同一商户同一金额视为重复流水 df df.drop_duplicates(subset[student_id, trans_time, merchant_name, amount]) # 2 只保留消费类流水具体取值需对照厂商字典 df df[df[trans_type] 消费].copy()逻辑说明read_csv里encoding要看导出文件的实际编码老系统经常导出 GBKWindows 上直接读会出现乱码字段名和商户名全是“锟斤拷”时先怀疑编码而不是数据损坏。trans_type的具体取值因厂商而异有的是中文“消费”有的是数字“1”必须先df[trans_type].value_counts()看一眼分布再过滤不能凭猜。去重用四个字段联合判断比单独用流水号更稳——因为部分老系统流水号会跨年复用。接着处理金额语义。退款流水有两种常见表示一种直接用负数另一种用正数加退款标记。不处理的话退款会被当成一笔正常消费把月度总额虚高。# 3 退款统一为负有标记列的取绝对值后取负 if is_refund in df.columns: df.loc[df[is_refund] 1, amount] -df[amount].abs() elif refund_flag in df.columns: df.loc[df[refund_flag].astype(str).str.contains(退), amount] -df[amount].abs() # 4 剔除金额为0与单笔绝对值超过500的异常流水 df df[df[amount] ! 0] df df[(df[amount].abs() 500)]参数说明单笔 500 的上限是按“食堂超市文印”的校园场景拍的。如果学校有健身房、培训报名费、考试费这种高额消费500 会把正常流水误删。常见的做法是放开到 2000或者按商户大类分别设阈值。更稳妥的方案是不设硬阈值而是用分位数裁剪后面建模阶段再做清洗阶段只剔除明显异常金额为 0、超过 10000 的单笔。这个阈值应该写进配置文件不要散落在代码里。2.2 特征工程把流水聚合成“学生×月份”的行为指标评估学生经济状况核心不是某一笔消费而是长期的消费习惯。我一般按“学生×月”做一级聚合再用连续 3 个月滚动数据做特征。为什么不用天粒度学生周末外出、课程安排差异导致按天数据极其稀疏寒暑假更是整月空档。为什么不用学期粒度一学期 4 个月太长物价波动、换校区、实习实训外出都会稀释信号。核心特征如下每个都有明确的业务含义。# 商户大类映射不同学校商户名五花八门必须人工维护一份映射表 merchant_map { 第一食堂: 餐饮, 学生二食堂: 餐饮, 风味餐厅: 餐饮, 教工餐厅: 餐饮, 校园超市: 购物, 水果铺: 购物, 文印中心: 学习, 开水房: 生活, 浴室: 生活, 理发店: 生活 } df[biz_type] df[merchant_name].map(merchant_map).fillna(其他) # 生成月份标签to_period(M) 按自然月聚合跨年不乱 df[month] df[trans_time].dt.to_period(M) # 月度聚合总额、笔数、平均单笔 monthly df.groupby([student_id, month]).agg( total_amount(amount, sum), total_cnt(amount, count), avg_amount(amount, mean), ) # 餐饮消费占比单独算分母不能含退款 food (df[df[biz_type] 餐饮] .groupby([student_id, month])[amount] .sum().rename(food_amount)) monthly monthly.join(food, howleft) monthly[food_ratio] (monthly[food_amount] / monthly[total_amount]).fillna(0)逻辑说明to_period(M)生成的是 Period 对象参与groupby时按自然月聚合跨年不会把 12 月和 1 月混在一起。agg里的元组写法是 Pandas 1.0 以后的命名聚合语法如果版本旧改用pd.NamedAgg(columnamount, aggfuncsum)。餐饮占比单独计算是因为退款金额会被记成负数混在一起算会把比例拉成负值。消费地点熵用来衡量活动范围固定程度——经济状况紧张的学生通常消费场景单一地点熵偏低而消费场景过于分散的学生可能在校外有大量现金或其他支付渠道消费属于“数据盲区”。from scipy.stats import entropy # 每个学生月度各商户类型的消费占比再算熵 tab pd.crosstab( [df[student_id], df[month]], df[biz_type], valuesdf[amount], aggfuncsum ).fillna(0) venue_entropy tab.apply( lambda row: entropy(row 1e-9), axis1 ).rename(venue_entropy) monthly monthly.join(venue_entropy, howleft)参数说明entropy对 0 值敏感直接计算会出现 log(0) 返回 inf所以加了 1e-9 的平滑项。这个平滑项是我踩坑踩出来的——第一次跑结果全校一半学生熵值是 nan排查半天发现是 0 占比导致的。crosstab的行索引是[student_id, month]的 MultiIndex与monthly的索引结构一致才能直接join如果索引名对不上join会报错或产生大面积 NaN。2.3 聚合后的一致性校验特征做出来先别急着建模特征表做完第一件事不是跑模型而是做三项校验。做了这么多年数据我最大的体会是模型翻车大多不是算法问题是数据在前置环节就错了。def check_monthly(monthly, total_students12000): res {} for m in monthly.index.get_level_values(month).unique(): mdf monthly.xs(m, levelmonth) coverage mdf.index.nunique() / total_students res[m] { 覆盖学生数: mdf.index.nunique(), 覆盖率: round(coverage, 3), 月均消费: round(mdf[total_amount].mean(), 2), 月均笔数: round(mdf[total_cnt].mean(), 1), } return pd.DataFrame(res).T print(check_monthly(monthly))逻辑说明coverage表示参与了消费的学生占全校在册学生的比例。正常月份应该在 80%-95% 之间。如果某个月突然掉到 50%先怀疑导出的数据缺了商户或时间段而不是学生集体不吃饭。月均消费落在 300-3000 区间属于正常按城市物价浮动如果出现 5 元或 5 万元一定有问题。我之前遇到过一次月均消费突然暴涨查了半天发现是校庆活动给全校卡里充了补贴那一个月全被污染了于是把活动月单独标记、不进建模。另外如果流水里有余额字段可以做余额连续性校验同一学生的卡内余额应该随消费递减、随充值阶梯上升出现负数一定是因为退款的符号没处理对回头改清洗逻辑而不是后处理抹掉。3. 学生经济评估建模为什么固定阈值不靠谱聚类怎么定参数3.1 月度消费 500 元以下就是困难固定阈值为什么站不住很多学校资助工作里用的是一刀切阈值比如月消费低于 500 元就列入观察名单。这个做法在单一校区内部勉强能看一旦跨校区、跨城市就出问题食堂便宜的校区正常学生一个月饭钱 300 多食堂贵的校区省着吃也要 800。同样过一个月一个校区“低消费”比例 20%另一个校区才 3%这不是学生经济状况的差异是物价差异。还有一类更隐蔽的误判部分学生不在学校食堂吃在校外用餐或者用手机支付一卡通里只留洗澡、打水的小额记录。按固定阈值他们会被打成“极度低消费”但实际经济状况并不差。这类人的特点是消费笔数不少、单笔金额极小、餐饮占比很低、地点熵偏高。所以模型的输入不能是“绝对金额”要先做分位数裁剪消除极端值再做 Z-score 标准化把每个学生的消费指标放到全校分布里看相对位置。from sklearn.preprocessing import StandardScaler cols [total_amount, total_cnt, avg_amount, food_ratio, venue_entropy] X monthly[cols].replace([float(inf), float(-inf)], float(nan)).fillna(0) # 1 分位数裁剪抑制极端值对聚类中心的拉扯 lo, hi X.quantile([0.01, 0.99]) X_clipped X.clip(lo, hi, axis1) # 2 标准化 scaler StandardScaler() X_scaled scaler.fit_transform(X_clipped) X_scaled pd.DataFrame(X_scaled, columnscols, indexmonthly.index)参数说明分位数取 1% 和 99%比 3σ 更稳。消费数据不是正态分布右尾特别长——有人一顿饭 200有人交一次考试费 500这些用 3σ 裁不干净。裁剪必须在标准化之前做如果先标准化裁剪作用在已缩放的数值上剪掉的不是原始极端值反而容易误伤。另一个容易被忽略的点如果数据里有大量退款流水要先在清洗阶段把符号处理对否则 1% 分位会被大额负金额带偏裁剪阈值直接失效。标准化后每个特征都是均值为 0、方差为 1 的相对量。这时候“这个学生月消费低于全校 90% 的人”这句话才算有了统一度量也才能在交叉校区、交叉年级之间比较。3.2 KMeans 聚类的参数设定k 怎么选n_init 为什么必须调大聚类在这个项目里扮演的角色很明确不是直接下结论而是把高维特征压缩成可解释的类别标签再由我们来解读每个类别的业务含义。它是一个压缩工具不是一个裁决者。最常见的坑是 k 拍脑袋取 3结果第三类永远是“月消费 0 的异常学生”——这不是聚类分出来的消费层次是前面数据清洗没做干净。正确做法是肘部法先看曲线再结合业务解释定 k。from sklearn.cluster import KMeans inertia [] for k in range(2, 7): km KMeans(n_clustersk, n_init20, random_state42) km.fit(X_scaled) inertia.append(km.inertia_) for k, v in zip(range(2, 7), inertia): print(fk{k}, inertia{v:.1f})逻辑说明inertia是样本到所属簇中心的距离平方和k 增大时必然下降下降速度从陡变缓的位置就是肘点。但校园消费数据的肘点通常不锐利曲线是缓缓下滑的。所以我会把肘部法当成参考最终看簇中心的业务可解释性k3 分出的三类能不能说成“消费宽松、一般水平、消费紧凑”k4 多出来的一类是不是“非餐饮主导的低流水用户”能解释通的那个 k 才是业务方愿意认的 k。我最后通常选 3 或 4具体看学校的资助工作口径。n_init这个参数必须调大。KMeans 的初始中心是随机选的n_init表示从多少次不同的初始化里挑最优结果。默认值是 10还算能看但如果有人把它设成 1聚类结果每次跑都不一样今天出的名单和明天出的名单对不上业务部门会直接质疑项目的可靠性。固定random_state42并且在文档里写明“运行脚本前请勿改动此参数”这是血泪经验——我曾经因为没固定随机种子被业务方指着两份不一样的名单质疑了两周。3.3 解读簇中心、给聚类结果贴业务标签模型跑完聚类输出 0、1、2 三个数字这还不是结论。要把每个标签翻译成业务人员看得懂的话。centers pd.DataFrame( scaler.inverse_transform(km.cluster_centers_), columnscols ) centers[样本量] pd.Series(km.labels_).value_counts().sort_index().values print(centers.round(2))解读的方向要看特征组合是否矛盾。比如某个簇total_amount低、food_ratio高、venue_entropy低这是“食堂依赖型低消费”——学生主要在校内食堂解决三餐消费场景单一月度总支出低。这类人是经济评估最需要关注的候选人群。但另一个簇total_amount低、food_ratio低、venue_entropy高这意味着学生不怎么用卡在校外另有支付渠道这类人要标记为“数据盲区”不能直接下结论更不能进名单。这里要特别强调聚类输出的名单是候选不是结论。落地时我会生成一份说明报告对名单里的每个人附上三个关键指标的具体数值和他在同年级里的位次交给辅导员复核。这一步决定了业务方信不信你的项目——模型本身是“黑匣子”没错但解释机制必须是透明的。如果只给一个“低消费”标签没有任何佐证业务方不会用也不敢用。4. 从源码到可运行最小项目结构与关键参数调优4.1 最小源码结构四个脚本一条命令跑通一个能交付的校园消费行为分析项目源码组织要比单文件脚本更清晰。目录结构如下campus_consumption/ ├── config.yaml # 参数配置阈值、聚类数、数据路径 ├── 01_clean.py # 数据清洗去重、过滤、退款符号处理 ├── 02_features.py # 特征工程月度聚合、一致性校验 ├── 03_assess.py # 聚类评估KMeans、名单输出 ├── 04_report.py # 报告生成簇解释、人工复核表 └── data/ # 存放脱敏后的一卡通流水数据集每个脚本只做一件事参数全部集中在config.yaml。这样做的理由是换一所学校、换一个学期的数据只需要改配置不用动代码。资助工作的同事不懂 Python但他们能看懂 YAML 里的“聚类数: 3”是什么意思。# config.yaml 的关键内容 data_path: data/campus_card.csv total_students: 12000 k_clusters: 3 quantile_low: 0.01 quantile_high: 0.99 n_init: 20 random_state: 424.2 主流程代码从原始数据到评估名单import pandas as pd from sklearn.cluster import KMeans def build_features(path): df pd.read_csv(path, parse_dates[trans_time]) # 清洗逻辑复用 01_clean.py 中的函数 df clean_flow(df) monthly aggregate_monthly(df) return monthly def assess_main(config): # 1 读取原始流水并构建特征 monthly build_features(config[data_path]) # 2 构造模型输入裁剪 标准化 cols [total_amount, total_cnt, avg_amount, food_ratio, venue_entropy] X monthly[cols].fillna(0) lo, hi X.quantile([config[quantile_low], config[quantile_high]]) X_clipped X.clip(lo, hi, axis1) scaler StandardScaler() X_scaled scaler.fit_transform(X_clipped) # 3 聚类固定 n_init 和 random_state 保证结果可复现 km KMeans(n_clustersconfig[k_clusters], n_initconfig[n_init], random_stateconfig[random_state]) km.fit(X_scaled) # 4 把标签拼回特征表并还原中心点便于解读 monthly[cluster] km.labels_ centers pd.DataFrame( scaler.inverse_transform(km.cluster_centers_), columnscols ) return monthly, centers if __name__ __main__: import yaml cfg yaml.safe_load(open(config.yaml, encodingutf-8)) result, centers assess_main(cfg) result.to_excel(output/assess_list.xlsx, indexTrue)逻辑说明整个过程分成“建特征”和“跑聚类”两层。build_features内部包含清洗、月度聚合、餐饮占比计算返回的monthly是一个 MultiIndex 的 DataFrame索引是[student_id, month]。为什么要保留这个索引结构因为后续要回查某个学生某个月的明细索引不丢回查就是一次df.loc[(student_id, month)]的事情否则又要全表扫描。参数说明KMeans的n_init设成 20比默认的 10 更稳。代价是训练时间翻倍但校园数据规模撑死千万级流水聚出来的特征表也就几十万行这点耗时完全可以接受。random_state固定后任何人任何时间跑同一份数据得到的名单完全一样。这两个参数写进 config不允许在脚本里硬编码方便其他人复现。4.3 必调的三个参数k、分位数、n_init以 config.yaml 里的参数为主线实际调参的优先级如下参数默认值影响范围调整依据k_clusters3名单颗粒度几个消费层级肘部法曲线 簇中心业务解释quantile_low/high0.01 / 0.99极端值抑制力度是否包含高额商户流水n_init20结果稳定性与复现性越大越稳耗时线性增加k 是最先要定的参数。选 3输出“相对宽松 / 一般水平 / 相对紧凑”三档直接对应资助工作的三档观察名单选 4额外分出一类“非餐饮主导的低流水”对应校外消费的学生。两个都能解释但业务上的干预口径不同。分位数裁剪的参数其次。如果学校引入了高额消费场景健身房年卡、驾校报名99% 分位会被顶到很高裁剪力度不足如果还是纯食堂超市0.99 分位就能有效抑制极端值。不要两个学校用同一组裁剪参数写进 config 就是为了方便每校单独校。n_init 基本不用动固定 20 就好。除非数据量到千万行级别、跑一次要十几分钟才考虑降回 10 换取速度。5. 避坑与常见问题数据清洗、聚类、评估三层5 个真实翻车案例5.1 现象聚类结果里出现“月消费 0 元”的大簇占比超过 30%有一年跑新校区数据聚类分出来的第 0 类全是月消费 0 的学生占比高得离谱。第一反应是这批学生是不是休学了一查在册名单人都在校。后来发现这个校区的“挂失”“补卡”流水也被归在trans_type的“消费”类别里清洗时没有过滤这些工本费流水被当成消费计入导致特征计算时大量学生月度金额被多笔 0 元工本费“平均”成异常低值。原因厂商的流水类型字典里“消费”是一个大分类里面包含“消费”和“工本费”两个子类导出时子类被合并了。解决不要信trans_type的顶层分类必须看trans_type item_name的组合值分布把带“挂失”“补卡”“工本”字样的明细全部过滤。这个教训的通用版是任何一卡通系统的流水类型字典都要先value_counts()全量打一遍再动手不要凭厂商文档猜。5.2 现象同一份数据两次运行聚类名单完全不一致业务方不认账第一次交付时我把聚类脚本扔给资助中心的老师自己跑。第二天对方打电话说“名单变了”我没在意以为数据更新了。后来发现对方机器上跑出来的聚类标签和我的完全对不上——那个脚本里 KMeans 没有固定random_state每次初始化中心随机结果自然不同。原因KMeans 初始中心随机不固定种子就无法复现。解决所有聚类相关代码统一固定random_state42n_init20并把这两个参数写进 config.yaml。从那时起我交付的每个项目在文档开头都会写明“结果可复现性依赖 random_state请勿修改”。这个坑看起来小但一旦业务方对结果产生不信任后续所有分析都会被贴上“玄学”的标签很难挽回。5.3 现象评估名单里全是“月消费 800 元但从不吃食堂”的学生第一批名单出来时资助中心的老师指着名单说这几个学生根本不是经济困难天天在外面吃。查了下流水这些人一卡通里只有洗澡、打水的记录餐饮消费几乎为零。按聚类特征他们确实属于“低消费、非餐饮主导、地点熵高”的簇。原因一卡通只覆盖校内消费场景校外用餐和移动支付不在数据里。解决这类学生要单独标记为“数据盲区”聚类时要么单独分簇、要么直接排除出评估范围。落地时我加了一个规则food_ratio 0.2且total_cnt 30的学生强制不进入候选名单因为他们的流水不足以支撑消费能力的判断。这个规则不是从模型里学出来的是业务方告诉我的但它比任何调参都管用。5.4 现象SQL Server 导出的数据导入 Pandas 报“数据无效”一度以为是数据坏了接手过一个项目数据从 SQL Server 导出成 CSV 后pd.read_csv一直报类型错误字段名全是乱码。折腾了半天最后发现是导出时编码选了 ANSI里面混了中文Pandas 默认用 UTF-8 解析直接失败。原因导出工具默认编码与 Pandas 解析编码不一致加上字段名里包含中文字符低版本 Pandas 对编码错误报错信息不直观容易误判为“数据文件损坏”。解决先读取前 5 行二进制内容判断编码再显式指定。常见的做法是用chardet检测必要时直接把导出的 CSV 用文本编辑器另存为 UTF-8 再导入。这个坑不分学校老系统导出编码基本都是 GBK养成习惯第一行pd.read_csv永远带上encoding参数。5.5 现象报告里写“该生月均消费 1200 元”但实际卡内余额充足有一版报告输出后复核老师发现某个学生的月均消费 1200 元但卡内余额长期在 3000 元以上。这不符合逻辑——如果真花 1200 元余额早该见底了。查了流水这个学生每月初都会充值 2000 元但余额字段记录的是“充值后余额”被我们当成了“消费前余额”参与统计。原因卡内余额字段是时点值充值前后差异巨大直接平均没有意义。解决不把余额直接作为特征只做一致性校验。即用“上期余额 本期充值 - 本期消费 本期余额”的等式校验流水是否完整等式不成立说明有漏导的流水。从那之后评估报告里我只写“月消费金额”和“消费笔数”不写“余额充足”除非余额参与等式校验且通过。6. 进阶用月度趋势与消费结构突变把静态评估变成动态信号6.1 月度趋势突变3 个月滚动均值的差分信号聚类名单是静态的用的是历史画像但学生经济状况是动态的——家庭变故、突发支出、生活状态改变都会在流水上留下痕迹。进阶的做法是检测突变对每个学生取他连续 3 个月消费总额的滚动均值再与前 3 个月均值做差分。pivot monthly.reset_index().pivot_table( indexstudent_id, columnsmonth, valuestotal_amount, aggfuncsum ) rolling_mean pivot.rolling(3, axis1).mean() diff rolling_mean.diff(3, axis1) # diff 为负且绝对值超过该生历史标准差的2倍标记为突变这个信号的业务含义是“该生最近 3 个月消费水平明显低于其自身历史水平”比“低于全校水平”更个性化也更能说明问题。但要排除一种情况大三学生开始实习大量时间在校外卡内消费自然会降这不叫突变叫生活状态切换。所以突变信号出来之后要人工看一眼该生是否处于实习学期、是否办理了走读。6.2 语义化画像把聚类标签升级成一句人话聚类标签 0、1、2 没法直接进工作报告。我最后会在报告里生成一段语义化描述用规则把特征组合翻译成人话。比如“食堂依赖型低消费”“非餐饮主导低流水”“宽裕型多元消费”。规则很简单total_amount分位数、food_ratio、venue_entropy三个变量落在哪个区间对应哪句话。这份描述同时进入人工复核表让辅导员拿着就可以直接开展谈话不用再看原始数据。我做这个项目时最大的教训是模型再精巧不如把名单解释清楚。业务方不需要知道什么是 KMeans 惯性他们需要知道的是“这个学生为什么在上面”。把每个标签背后的指标和位次写清楚项目才真正完成。希望帮到你。本文还有配套的精品资源点击获取