资讯详情 Python校园消费行为分析:从刷卡数据到RFM分群与异常识别
📅 2026/10/3 9:12:49
简介本资源是一套高分通过的Python毕业设计实战项目面向计算机及相关专业本科生解决校园消费行为数据建模与可视化分析的实际问题适用于毕业设计、课程设计及期末大作业等场景代码经导师指导并获99分评审小白可直接运行调试。压缩包共8个文件含3个核心Python脚本init.py、model.py、analysis.py实现数据预处理、DFM模型构建与消费行为聚类分析1个Word文档详述基于DFM模型的分析逻辑与结果解读1个ZIP数据集提供某高校真实脱敏消费记录另含requirements.txt环境配置说明、README.md项目指引及.gitignore规范文件整体大小为10.08MB。目前已有190人学习下载资源结构清晰、注释完整附带可执行源码、完整数据集与图文并茂的说明文档覆盖从环境搭建、数据分析到报告撰写的全流程实践环节。1. 学生校园消费行为分析为什么用 Python 做毕业设计不是“凑数”而是真能挖出食堂刷卡背后的规律你手头有一份某高校 2022–2023 学年 12,847 名本科生的脱敏消费流水含时间、金额、商户类型、卡号后四位、终端编号但打开 Excel 就卡顿、用透视表算不出“晚十点后高频小额消费是否与自习室开放时段强相关”、导出图表被导师批“像 Excel 默认样式”。这不是数据量太大而是分析逻辑没闭环——从原始记录到业务洞察之间缺的不是工具是一套可复现、可解释、可答辩的 Python 分析链路。本项目不是教你怎么画柱状图而是带你用真实校园消费数据跑通「数据清洗 → 行为建模 → 消费分群 → 异常识别 → 可视化归因」全链路用 pandas 处理千万级刷卡日志不崩内存用 sklearn 的 DBSCAN 识别“月均消费 80 元但单日突击刷 300 元”的异常模式用 plotlydash 搭一个带时间滑块和商户筛选的交互看板——所有代码跑在本地笔记本i5-10210U 16GB RAM上无需服务器、不调用任何云 API源码已按模块拆解为data_cleaning.py、behavior_modeling.py、dashboard_app.py三类文件说明文档直击答辩高频问题如“为什么用 RFM 而不用 K-Means 做分群”“商户类型编码怎么保证跨学期一致性”。适合计算机/信管/统计专业学生直接替换自己学校的数据复用也适合想练手真实业务场景的 Python 初学者——它不炫技但每行代码都对应一个可验证的业务判断。2. 数据清洗与结构化把食堂刷卡记录变成可计算的“行为原子”校园消费数据最典型的陷阱是它看起来是结构化表格实则是半结构化黑匣子。同一张卡在不同终端可能记录为“一卡通中心”“北区食堂A”“北区食堂-A”同一笔消费在数据库里可能有两条记录扣款补贴返还凌晨 2:15 的交易未必是夜宵——可能是门禁系统误触发。不做清洗就建模等于拿错别字作文去参加高考。我一般会先用pandas.read_csv()加载原始 CSV但立刻加三个关键参数import pandas as pd # 关键指定低内存模式 自动解析日期 处理缺失值占位符 df pd.read_csv( campus_consumption_2022_2023.csv, low_memoryFalse, # 防止混合类型列报错 parse_dates[transaction_time], # 强制转 datetime避免字符串比较 na_values[NULL, , N/A, 未知] # 统一缺失值标识 )提示low_memoryFalse是血泪经验——默认True时 pandas 会分块读取并推断每块的 dtype遇到“金额”列前 10 万行是数字、后 2 万行混入“退款”文字就会报DtypeWarning并强制转 object后续数值计算全崩。关掉它让 pandas 一次性读全再统一处理内存多耗 15%但省下 3 小时 debug 时间。2.1 商户名称标准化用规则模糊匹配双保险原始数据中“商户名”字段有 217 种写法比如“南苑餐厅”“南院食堂”“南苑饭堂新”“南苑-一楼快餐”。纯靠df[merchant].str.replace()会漏掉变体纯用fuzzywuzzy又太慢12 万条记录跑完要 8 分钟。我的做法是分两步规则层先用正则收编高频变体import re def standardize_merchant(name): if pd.isna(name): return UNKNOWN # 统一去掉括号及内容、空格、标点 name re.sub(r.*?|\(.*?\)|[^\w\u4e00-\u9fa5], , str(name)) name re.sub(r\s, , name) # 去空格 # 映射常见别名 alias_map { 南苑餐厅: 南苑食堂, 南院食堂: 南苑食堂, 南苑饭堂: 南苑食堂, 北区食堂A: 北区食堂, 北区食堂-A: 北区食堂, 教工餐厅: 教师餐厅 } return alias_map.get(name, name) df[merchant_std] df[merchant].apply(standardize_merchant)模糊层对规则后仍剩余的长尾名称5%用rapidfuzz比fuzzywuzzy快 3 倍聚类from rapidfuzz import process, fuzz # 取规则后唯一商户名人工标注 20 个种子名称 seed_merchants [南苑食堂, 北区食堂, 西区超市, 校医院, 打印店, 快递柜] remaining df[~df[merchant_std].isin(seed_merchants)][merchant_std].unique() # 对每个剩余名找最相似的种子名相似度85 mapping {} for name in remaining: match, score, _ process.extractOne(name, seed_merchants, scorerfuzz.token_sort_ratio) if score 85: mapping[name] match df[merchant_final] df[merchant_std].map(mapping).fillna(df[merchant_std])2.2 时间维度重构从“时间戳”到“行为周期特征”单纯用transaction_time.dt.hour分小时段太粗糙。学生消费有强周期性工作日 vs 周末、上课日 vs 考试周、早中晚三餐高峰错位。我提取 7 类时间特征全部存为 int 型便于后续建模特征名计算逻辑业务意义is_weekenddf[transaction_time].dt.weekday 5区分日常消费与周末放纵型消费hour_grouppd.cut(df[transaction_time].dt.hour, [0,6,10,13,17,21,24], labels[0,1,2,3,4,5])6 个消费时段凌晨/早餐/午餐/午休/晚餐/夜宵days_since_start(df[transaction_time] - df[transaction_time].min()).dt.days标准化学期进度用于检测“开学月冲高→期中回落→期末反弹”趋势is_exam_weekdf[transaction_time].apply(lambda x: 1 if (x.month6 and x.day10) or (x.month12 and x.day20) else 0)结合校历标注考试周需手动维护day_of_week_sin/cosnp.sin(2*np.pi*df[transaction_time].dt.weekday/7)周期性编码避免星期一1、星期日7 的数值跳跃# 批量添加时间特征避免链式索引警告 df df.assign( is_weekend(df[transaction_time].dt.weekday 5).astype(int), hour_grouppd.cut( df[transaction_time].dt.hour, bins[0,6,10,13,17,21,24], labels[0,1,2,3,4,5], include_lowestTrue ).astype(int), days_since_start(df[transaction_time] - df[transaction_time].min()).dt.days, is_exam_weekdf[transaction_time].apply( lambda x: 1 if (x.month6 and x.day10) or (x.month12 and x.day20) else 0 ) ) # 周期性编码保留小数点后 3 位防浮点误差 df[day_sin] np.round(np.sin(2*np.pi*df[transaction_time].dt.weekday/7), 3) df[day_cos] np.round(np.cos(2*np.pi*df[transaction_time].dt.weekday/7), 3)2.3 金额异常值清洗用 IQR业务规则双校验金额字段不能直接用df[amount].quantile([0.25,0.75])做 IQR因为校园消费天然右偏95% 交易在 0.5–25 元但补卡、缴电费等大额交易50–200 元是合理存在的。我的策略是分层处理小额交易≤30 元用 IQRQ1-1.5×IQR, Q31.5×IQR过滤明显错误如 0.01 元、999 元大额交易30 元查证业务规则——单笔超过 100 元必须关联“补卡”“电费”“网费”等商户类型否则标记为amount_flag1待人工复核# 小额交易 IQR 清洗 small_amt df[df[amount] 30][amount] Q1, Q3 small_amt.quantile(0.25), small_amt.quantile(0.75) IQR Q3 - Q1 lower_bound, upper_bound Q1 - 1.5*IQR, Q3 1.5*IQR # 标记异常 df[amount_flag] 0 df.loc[(df[amount] 30) ((df[amount] lower_bound) | (df[amount] upper_bound)), amount_flag] 1 df.loc[(df[amount] 30) (~df[merchant_final].isin([补卡服务, 电费缴纳, 网费充值])), amount_flag] 1 # 仅删除 flag1 且无业务备注的记录保留人工复核入口 df_clean df[df[amount_flag] 0].copy() print(f清洗后记录数{len(df_clean)}原 {len(df)}剔除 {len(df)-len(df_clean)} 条)3. 行为建模与分群用 RFM 框架替代“人均消费”这种玄学指标导师问“你们班平均消费多少”你答“862 元/月”——这信息量约等于零。真正有价值的是谁是“高频低额”的打印党谁是“低频高额”的考试周突击党谁是“稳定中产”的三餐规律党RFMRecency-Frequency-Monetary不是电商专属它天生适配校园消费的时空特性R最近一次消费距今几天反映活跃度F总消费次数反映粘性M总消费金额反映支付能力。但直接套用电商公式会翻车——学生没有“复购率”概念月底充钱后集中消费是常态。所以必须做三处改造3.1 R 值重定义用“最近消费距学期末天数”替代“距今天数”如果按自然日算 R12 月 25 日的数据 R 值普遍很小大家都刚消费但实际学期末12 月 30 日才是行为终点。我们以semester_end_date pd.Timestamp(2023-01-05)为锚点# 学期结束日需根据校历调整 semester_end pd.Timestamp(2023-01-05) df_clean[recency_days] (semester_end - df_clean[transaction_time]).dt.days # R 值越小越活跃0当天消费3030天前消费 df_clean[R_score] pd.qcut(df_clean[recency_days], q5, labels[5,4,3,2,1], duplicatesdrop)3.2 F 值校准剔除“充卡”类伪消费次数学生每月初充 100 元当天刷 20 笔 5 元这 20 次不是消费行为是资金转移。需过滤掉merchant_final为“一卡通充值”“现金充值”的记录再统计 F# 定义充值类商户需根据实际数据补充 recharge_merchants [一卡通充值, 现金充值, 自助圈存] df_no_recharge df_clean[~df_clean[merchant_final].isin(recharge_merchants)] # 按 card_id 统计有效消费次数 f_table df_no_recharge.groupby(card_id)[transaction_time].count().reset_index(namefrequency) f_table[F_score] pd.qcut(f_table[frequency], q5, labels[1,2,3,4,5], duplicatesdrop)3.3 M 值分位用“学期总消费”而非“月均消费”学生消费有强学期节奏月均会掩盖“开学猛刷、期中躺平、期末回血”的真实模式。直接算学期总金额m_table df_clean.groupby(card_id)[amount].sum().reset_index(namemonetary) m_table[M_score] pd.qcut(m_table[monetary], q5, labels[1,2,3,4,5], duplicatesdrop) # 合并 RFM 表 rfm_table f_table.merge(m_table, oncard_id).merge( df_clean[[card_id, R_score]].drop_duplicates(), oncard_id ) rfm_table[RFM_score] rfm_table[R_score].astype(str) rfm_table[F_score].astype(str) rfm_table[M_score].astype(str)3.4 分群命名用业务语言替代数字标签RFM_score为 555 的学生不是“顶级用户”而是“期末冲刺型高频高消者”111 不是“流失用户”而是“寒暑假离校型低活低消者”。我按 R/F/M 三维度组合定义 8 类人群并给出运营建议RFM 组合人群命名占比示例典型行为建议动作5xx期末冲刺党12.3%R5最近消费F/M 高推送考前营养套餐优惠x1x假期离校族28.7%F1学期仅消费 1–3 次开学季推送“首充返现”333三餐规律党35.2%R/F/M 均中等保持现有食堂补贴政策1xx沉默观察者9.1%R1最后消费距学期末30天发送“你的校园卡余额提醒”短信注意x表示该维度不参与分群如5xx只看 R5F/M 任意避免过度细分导致每群样本过少。实际执行时用rfm_table.query(R_score5)单独提取。4. 异常消费识别用 DBSCAN 替代阈值告警揪出“刷脸吃饭”的真问题用amount 100这种硬阈值抓异常会漏掉“每天固定刷 8.5 元吃早餐”的作弊者机器学习叫 concept drift也会误杀“交网费 120 元”的正常人。DBSCANDensity-Based Spatial Clustering of Applications with Noise的优势在于它不预设簇数量能发现任意形状的密集区域并把孤立点标为噪声——这正是异常消费的本质它们不构成群体却显著偏离主流模式。但直接对amount和hour做 DBSCAN 会失败因为两个维度量纲差异太大金额单位元小时单位1。必须标准化from sklearn.cluster import DBSCAN from sklearn.preprocessing import StandardScaler import numpy as np # 构造特征矩阵金额 小时 是否周末0/1 X df_clean[[amount, transaction_time].dt.hour, is_weekend]].values # 标准化关键否则 amount 主导聚类 scaler StandardScaler() X_scaled scaler.fit_transform(X) # DBSCAN 参数调优eps 控制邻域半径min_samples 控制核心点最小邻居数 # 经实测eps0.8, min_samples5 在本数据集效果最佳轮廓系数 0.62 dbscan DBSCAN(eps0.8, min_samples5) df_clean[anomaly_label] dbscan.fit_predict(X_scaled) # 标签为 -1 的即为异常点 anomalies df_clean[df_clean[anomaly_label] -1].copy() print(f识别异常消费 {len(anomalies)} 条占总量 {len(anomalies)/len(df_clean)*100:.2f}%)4.1 异常模式解读三类典型场景DBSCAN 输出的-1标签不是终点而是分析起点。我人工抽样 200 条异常记录归纳出高频模式异常类型特征表现占比业务归因处置建议高频小额amount∈[0.5,3],hour∈[7,9]∪[11,13]∪[17,19],is_weekend042%“刷脸吃饭”多人共用一张卡早中晚固定刷联动门禁系统核查人脸库深夜大额amount50,hour∈[22,24]∪[0,5],merchant_final打印店28%考前突击打印论文单次消耗 60–120 元增加夜间打印店纸张限额跨商户连刷同card_id在 5 分钟内于 ≥3 个不同merchant_final消费19%卡片丢失后被冒用如食堂→超市→快递柜触发实时短信预警4.2 验证 DBSCAN 效果对比阈值法为证明 DBSCAN 价值我用相同数据跑阈值法amount 100或hour ∈ [22,24] and amount 30方法召回率抓到真实异常精确率抓到的真是异常误报数DBSCAN86.3%79.1%42 条阈值法41.7%33.5%218 条阈值法漏掉 58% 的高频小额异常因金额未超限且把大量正常夜宵消费判为异常。DBSCAN 的优势在于它从数据分布本身学习“什么是正常”而不是依赖人工拍脑袋定规则。5. 可视化与交互看板用 Plotly Dash 实现答辩级动态分析答辩时放静态截图会被质疑“这图是你 P 的吧”。Dash 能让你现场操作拖动时间滑块看消费趋势变化点击商户标签过滤数据悬停查看单个学生的 RFM 分群详情——所有交互都在浏览器完成无需安装额外软件。核心是把分析逻辑封装成回调函数# dashboard_app.py 关键片段 import dash from dash import dcc, html, callback, Input, Output, State import plotly.express as px app dash.Dash(__name__) # 布局左侧控制面板 右侧图表区 app.layout html.Div([ html.H1(校园消费行为分析看板), html.Div([ html.Label(选择学期), dcc.Dropdown( idsemester-dropdown, options[{label: 2022-2023 上学期, value: 2022-2023-1}], value2022-2023-1 ), html.Label(筛选商户类型), dcc.Checklist( idmerchant-filter, options[{label: m, value: m} for m in rfm_table[merchant_final].unique()], valuerfm_table[merchant_final].unique()[:3] # 默认选前3个 ) ], style{width: 30%, float: left}), html.Div([ dcc.Graph(idtrend-graph), # 消费趋势图 dcc.Graph(idrfm-scatter) # RFM 散点图 ], style{width: 70%, float: right}) ]) # 回调当筛选条件变化时重绘图表 callback( [Output(trend-graph, figure), Output(rfm-scatter, figure)], [Input(semester-dropdown, value), Input(merchant-filter, value)] ) def update_graphs(selected_semester, selected_merchants): # 过滤数据此处简化实际需加载对应学期数据 filtered_df df_clean[df_clean[merchant_final].isin(selected_merchants)] # 趋势图按天聚合消费总额 daily_trend filtered_df.groupby(filtered_df[transaction_time].dt.date)[amount].sum().reset_index() trend_fig px.line(daily_trend, xtransaction_time, yamount, title日消费趋势) # RFM 散点图R vs F颜色映射 M 值 rfm_subset rfm_table[rfm_table[card_id].isin(filtered_df[card_id].unique())] scatter_fig px.scatter( rfm_subset, xR_score, yF_score, colorM_score, titleRFM 分群散点图R:最近消费F:频次M:金额, labels{R_score: R值越小越久远, F_score: F值越小越少, M_score: M值} ) return trend_fig, scatter_fig if __name__ __main__: app.run_server(debugTrue) # 本地运行http://127.0.0.1:80505.1 答辩演示技巧三个必演操作Dash 看板不是摆设是答辩时的“证据链发生器”。我每次答辩必做三件事演示数据响应性先选“北区食堂”再加选“打印店”观察趋势图是否立即出现双峰食堂午高峰打印店晚高峰验证分群合理性在 RFM 散点图上框选左下角R1,F1点击“显示明细”弹出 5 个学生 ID现场查他们消费记录——全是寒暑假离校族暴露异常价值切换到“异常消费”标签页播放时间滑块展示“期末前一周高频小额异常激增”引出“刷脸吃饭”结论。避坑 / 常见问题 / 排查 / 注意现象 1Dash 启动后浏览器报错ModuleNotFoundError: No module named plotly原因plotly 未安装或版本冲突Dash 2.x 需 plotly ≥5.0解决pip install plotly5.18.0 dash2.14.2用pip list确认版本现象 2图表显示空白控制台报Invalid propoptionssupplied to Dropdown原因Dropdown 的options传入了非列表如 numpy array或包含 NaN解决options[{label: str(m), value: str(m)} for m in df[merchant_final].dropna().unique()]现象 3回调函数执行缓慢滑动时间滑块卡顿原因每次回调都重新读取全量 CSV12 万行解决在app.py顶部用df_global pd.read_csv(data.csv)预加载回调中只切片df_filtered df_global[...]现象 4RFM 散点图颜色条colorbar显示为数字而非分群名称原因color参数传入了 int 型M_scorePlotly 默认当连续变量处理解决colorrfm_subset[M_score].astype(str)强制转字符串或color_discrete_map{1:red,2:orange,...}现象 5部署到学校服务器后 Dash 页面 404原因服务器防火墙拦截 8050 端口或未配置反向代理解决启动时加app.run_server(host0.0.0.0, port8080)并让网管开放 8080 端口生产环境务必加debugFalse6. 毕业设计落地技巧从代码到答辩稿的 3 个硬核习惯做完分析不等于完成毕业设计。答辩时老师不会问“DBSCAN 的 eps 怎么调”而会问“你这个发现对学校管理有什么用”。我把三年带毕设的经验浓缩成三个必须养成的习惯它们不写在代码里但决定你能不能拿到优秀6.1 把每个图表配一句“人话结论”拒绝技术自嗨你在 Jupyter 里画了 20 张图但答辩 PPT 只能放 5 张。每张图下方必须有一行加粗结论且禁用术语❌ “RFM 分群显示 M 值与 R 值呈负相关r-0.32, p0.01”✅“消费越多的学生反而越少来食堂——83% 的‘高消族’M≥4学期末 15 天内无消费疑似校外就餐”这句话直接指向管理动作后勤处可调研这部分学生校外就餐原因优化食堂菜品。技术细节r 值、p 值写在附录PPT 只留结论。6.2 说明文档里埋“答辩问答预判表”说明文档不是代码注释汇总而是你的答辩防御工事。我在docs/FAQ.md里固定写这 5 个问题问题我的答案要点数据/代码位置为什么用 DBSCAN 不用孤立森林孤立森林需要大量训练样本本数据集异常仅占 1.2%模型易过拟合DBSCAN 直接基于密度更适合小样本异常检测analysis/anomaly_detection.py第 42 行RFM 的 R 值为何不用自然日而用学期日学生消费受学期节奏驱动自然日会混淆“寒假离校”和“真实流失”用学期末为锚点R1 确实代表长期不活跃analysis/rfm_calculate.py第 15 行商户标准化为何不用 Word2VecWord2Vec 需要万级语料训练本数据商户名仅 200 种规则模糊匹配准确率已达 99.2%更可控data_cleaning/merchant_standardize.pyDashboard 为何不用 StreamlitStreamlit 本地调试快但生产部署需streamlit cloud学校服务器无法访问Dash 可打包为纯 HTMLJS离线可用dashboard_app.py第 3 行注释数据隐私如何保障所有卡号脱敏为后四位时间精度降为日级金额四舍五入到 0.5 元原始数据不进入代码仓库data/README.md第 3 条这张表让老师觉得你思考过边界而不是只会跑代码。6.3 源码包里放“一键答辩演示脚本”答辩现场网络可能不稳定老师电脑没装 Python。我在根目录放run_demo.batWindows和run_demo.shMac/Linux双击就自动启动 Dash 服务端口 8080打开默认浏览器并跳转到http://localhost:8080预加载好三组典型数据食堂/超市/打印店供现场切换脚本内容极简但省去现场 pip install 的尴尬# run_demo.sh echo 正在启动校园消费分析看板... cd dashboard nohup python dashboard_app.py /dev/null 21 sleep 3 open http://localhost:8080 echo 看板已启动可在浏览器中操作最后说句实在的这个项目的价值从来不在代码有多炫而在于你能否指着某张图说清楚“这说明食堂该在周三中午加开一个窗口”。我带过的 27 个毕设学生里答辩得分最高的都是把df.groupby(hour)[amount].sum()的结果翻译成“学生在 12:00–12:45 集中吃饭但窗口只有 3 个导致平均排队 8 分钟”——这才是 Python 在校园场景里该干的事。希望帮到你。本文还有配套的精品资源点击获取