ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python学生校园消费行为分析实战:从一卡通数据到随机森林异常识别

Python学生校园消费行为分析实战:从一卡通数据到随机森林异常识别 简介这是一份基于Python的学生校园消费行为分析期末大作业建模项目面向高校Python数据分析与数据挖掘课程学习者解决从数据清洗、特征工程到行为建模的完整流程问题。项目围绕校园一卡通消费记录覆盖数据预处理、消费水平与时段分析、聚类或预测建模帮助学生在短时间内形成可运行、可解释的分析方案。压缩包共6个文件含5个Python脚本与1个项目数据包脚本按任务拆分覆盖数据加载、特征统计、模型训练与可视化输出数据包提供实验数据整体4.89MB部署简单。工程内多个任务脚本形成完整链路下载后无需修改即可依次运行输出结果与图表适合期末时间紧张的学生。已有618人次学习既可作期末提交项目也可作同类分析的代码参考实用性强。1. 期末大作业“学生校园消费行为分析”到底要交付什么从一卡通流水到一份能讲的报告期末周最慌的不是考试是老师甩过来一个“基于Python的学生校园消费行为分析”的建模题附带一个.zip压缩包里面是一份陌生的一卡通流水数据。你心想这不就是画几张折线图、算个均值再凑个模型真动手才发现光是把“食堂刷了多少钱”解读成“这个学生的消费习惯”中间就隔着数据清洗、特征工程、模型选型和可视化的四道坎。这篇笔记就按我当年交大作业的思路把整套流程拆开讲清楚数据是什么样、特征怎么造、模型怎么选、评估指标怎么看以及期末评审最容易挑刺的五个坑。这篇文章适合两类人一类是要交期末大作业、想做出“能跑通、能解释、能复现”完整项目的在校生另一类是刚接触结构化数据建模、想用一份真实感强的校园数据练手Python与sklearn的初学者。读完你能照着代码一步步复现也能在答辩时把“为什么这么建模”说圆。这套东西背后用到的就是最常见的结构化数据建模套路不涉及深度学习一台普通笔记本完全跑得动。2. 先摸清一卡通流水的底细字段、业务含义与可建模的三种问题2.1 数据从哪来一卡通流水的典型字段结构与读取方式期末大作业里最常见的消费数据是学校一卡通系统导出的刷卡流水。它和你后来在公司里接触的交易订单表非常像核心字段一般就这么几个学号或卡号、交易时间、交易金额、交易地点、商户类型。商户类型通常包括食堂、超市、水房、浴室、图书馆打印、校医院买药等有的学校还会单独标记“补助消费”或“退费”。我一般拿到数据先不急着分析而是把文件读进来看一眼字段类型和数据量再确认时间范围覆盖了多久。读取CSV文件本身并不难但要注意编码问题学校机房导出的CSV经常是GBK编码直接用pd.read_csv读会乱码。这一步翻车的人不在少数。import pandas as pd # 读取一卡通流水学校导出的CSV常见编码是gbkutf-8读不了就换这个 df pd.read_csv(campus_card.csv, encodinggbk, parse_dates[trade_time]) # 先看数据规模和字段类型再决定下一步 print(df.shape) print(df.dtypes) print(df.head())parse_dates[trade_time]会把交易时间列解析成datetime类型后面做时段分析、周几判断都依赖这一步。编码报错时把gbk换成gb18030这是GBK的超集兼容性更好。数据量方面一个万人规模的学校一学期的流水通常在几十万到上百万行Pandas处理这种量级毫无压力。2.2 描述性分析先算清楚“平均消费水平”和“时段热力”建模之前必须做描述性分析这一步既是为了让你对数据有手感也是期末报告里“现状分析”板块的素材。很多同学上来就调包跑模型结果连数据里有多少条负金额都没发现后面全盘翻车。我习惯先看金额分布再按小时聚合看消费时段。食堂消费有明显的三餐波峰如果数据里有深夜高频消费或凌晨消费那可能包含便利店、水房或自动售货机流水这些场景的消费规律和食堂完全不同。# 金额描述统计先别过滤看看有没有负数和极端大额 print(df[amount].describe()) # 按小时统计消费笔数和金额画出时段热力 df[hour] df[trade_time].dt.hour hour_stats df.groupby(hour)[amount].agg([count, sum]) print(hour_stats)describe()输出里如果min是负数说明流水里包含退费记录如果max异常大比如单笔上千元那可能是充值记录混进来了后续清洗时要单独处理。按小时聚合的输出是第一张可以直接放进报告里的表早餐时段的笔数高峰、午餐时段的金额高峰、晚间的长尾消费这三条信息就能撑起半页“校园消费时空特征”的分析。2.3 把业务问题翻译成建模问题分类、预测还是聚类描述性分析做完数据在你眼里已经不再是一堆行而是一幅“学生们几点在哪儿花多少钱”的画面。但期末大作业要求的“建模”环节不能只停在描述性统计。你需要把“分析消费行为”落到一个具体的建模任务上。我复盘过很多份成绩不错的大作业发现大家选的建模方向基本集中在三类消费水平分级把学生按消费金额聚类成高、中、低三档、异常消费识别找出消费行为与多数人差异明显的样本、消费趋势预测用前几周数据预测下一周消费总额。三类问题用的算法不同适合的人群也不同。我的建议是选“异常消费识别”作为主建模任务。理由有两个第一它天然是二分类问题逻辑清晰期末答辩时一句“我要找出消费行为异常的学生”就能说明白第二它能把“精确率、准确率、召回率”这几个评估指标全部用上这正是这类大作业的主要评分点。消费水平分级作为辅助分析留在描述性统计部分趋势预测作为扩展方向提一句即可不要贪多。3. 特征工程把“刷卡流水”改造成模型能吃的特征宽表3.1 数据清洗退费记录、重复流水和极端金额是第一道坎建模圈有句话叫“Garbage in, garbage out”但期末大作业里更常见的坑是数据里混着退费记录和重复流水你却没发现。退费会让某一天的消费金额出现负值直接影响均值、方差这些统计特征而同一秒内同一卡号同一金额的重复记录往往是POS机重传导致。清洗不是把脏数据删了就完事而是要先想清楚这条记录为什么脏。负金额的退费记录如果直接删除会丢掉“该学生发生过退费”这一信息更好的做法是单独建一个退费次数字段重复流水则可以直接去重因为它没有任何业务含义。我一般会分两步处理。# 第一步退费记录单独统计不进特征计算 df df.copy() df[is_refund] (df[amount] 0).astype(int) refund_stats df.groupby(card_id)[is_refund].sum().rename(refund_count) # 第二步重复流水去重同一秒同金额视为POS机重传 df df.drop_duplicates(subset[card_id, trade_time, amount]) # 第三步充值类大额记录剔除按实际情况调整阈值 df df[(df[amount] 0) (df[amount] 200)]这三步做完再跑一遍describe()金额分布应该变得稳定。注意阈值这地方是玄学200元这个上限不是拍脑袋定的我是先画了金额分布直方图发现绝大多数消费在0.5到80元之间大额记录基本集中在月初和月中判断是充值流水才定的这个值。你的数据如果食堂有自助餐按次缴费阈值再往上调。3.2 时间特征从“几点刷卡”到“作息规律”清洗完成后进入特征工程环节这才是整个大作业真正拉开差距的地方。一卡通流水最值钱的信息不是“花了多少钱”而是“在什么时间花钱”。时间特征做得好建模效果立刻不一样。我从时间戳里主要提取五类特征星期几、是否周末、是否处于三餐时段、当天首笔消费时间、当天消费次数。三餐时段特征尤其重要因为正常学生的消费时间是聚拢在7-8点、11-13点、17-19点这三个区间内的。如果某个学生的消费时间大量分布在这些区间之外说明他的作息与校园主流节奏偏离这类样本往往就是异常识别模型要找的目标。# 从交易时间提取星期和时段特征 df[weekday] df[trade_time].dt.weekday df[is_weekend] df[weekday].isin([5, 6]).astype(int) # 三餐时段早7-8点午11-13点晚17-19点 meal_hours set([7, 8, 11, 12, 13, 17, 18, 19]) df[is_meal] df[hour].isin(meal_hours).astype(int) # 每天首笔消费时间反映起床规律 first_trade_time df.sort_values(trade_time).groupby([card_id, df[trade_time].dt.date])[hour].first()首笔消费时间是一个很微妙的特征天天早上7点出现在食堂的学生和每天中午12点才第一次刷卡的学生生活方式差异很大。这个特征对后续“作息规律度”的计算是基础也是报告里一个可以拿出来讲的故事点。3.3 消费习惯特征均值、波动、寂静日与消费多样性时间特征做好后还需要按人聚合把流水变成“每人一行”的特征宽表。这一环节的核心是把消费行为抽象成几个可解释的统计量。我常用的特征包括日均消费金额、消费金额标准差、消费金额变异系数标准差除以均值、日均消费次数、一周内零消费天数、三餐时段的消费占比。其中变异系数是最体现“行为稳定性”的指标。两个学生日均消费都是30元一个雷打不动每顿10元另一个顿顿下馆子或者经常不吃饭后者的变异系数会明显更高。这个特征在异常识别模型里通常排进重要度前三。user_feat df.groupby(card_id).agg( total_days(trade_time, lambda x: x.dt.normalize().nunique()), total_amount(amount, sum), avg_amount(amount, mean), std_amount(amount, std), total_count(amount, count), meal_ratio(is_meal, mean) ).reset_index() # 变异系数 标准差 / 均值衡量消费波动 user_feat[cv_amount] user_feat[std_amount] / user_feat[avg_amount] user_feat[avg_daily] user_feat[total_amount] / user_feat[total_days]meal_ratio的取值逻辑是每笔流水的is_meal求平均得到一个0到1之间的比例反映这个人有多少次消费发生在三餐时段。正常住校生的该值通常在0.6以上如果某个学生只有0.2要么是数据里混入了大量水房流水要么就是这个人的消费行为确实异于常人。3.4 特征表的构建顺序先算特征再打标签顺序不能反特征都造好之后需要把退费次数、首笔消费时间这些零散信息合并成一张完整的宽表。这一步有一个极其关键的顺序问题必须先基于历史数据算特征再基于未来结果打标签顺序不能反。以异常消费识别为例如果我把“这个学生是否在校”作为标签那打标签的依据应该是整学期数据而特征只允许使用前几周的数据这样才能模拟真实场景。很多大作业里特征和标签来自同一段数据导致模型效果虚高答辩时老师一追问就露馅。正确做法是把时间切成两段前80%的时间用于特征计算后20%用于打标签。# 按时间切分前80%时间窗口计算特征 split_time df[trade_time].quantile(0.8) train_df df[df[trade_time] split_time] label_df df[df[trade_time] split_time] # 训练集特征基于前80%数据 train_feat train_df.groupby(card_id).agg( avg_amount(amount, mean), count(amount, count) ).reset_index() # 标签基于后20%数据假设周消费超过三倍中位数的记为异常 weekly label_df.groupby([card_id, label_df[trade_time].dt.date])[amount].sum() label weekly.groupby(card_id).mean().rename(weekly_avg) threshold label.median() * 3 label (label threshold).astype(int).rename(label) final train_feat.merge(label, oncard_id)这种时间切分法是最原始的防泄漏手段。quantile(0.8)是按时间戳取的阈值确保前80%严格早于后20%。标签的定义可以灵活调整但你必须在报告里明确写出阈值怎么来的这比模型准确率本身更能体现你的建模素养。4. 建模与调参用随机森林做异常消费识别让三个指标互相制约4.1 为什么选随机森林而不是朴素贝叶斯或逻辑回归结构化数据上的分类任务我第一反应永远是随机森林而不是热词里常见的贝叶斯算法。原因有三第一随机森林不需要对特征做标准化消费金额几百、比例特征0到1、次数几十量纲差距很大如果用逻辑回归或KNN必须先做标准化第二消费特征之间高度相关比如日均消费金额和总消费次数强相关、变异系数和标准差强相关随机森林对这种共线性不敏感第三feature_importances_直接输出各特征的重要性排序期末报告里那张“特征重要度条形图”就有着落了。朴素贝叶斯在文本分类上表现好但拿到消费行为这类数值特征上它默认特征条件独立而“日均消费”和“总金额”显然不独立硬用的话效果往往很勉强。逻辑回归可以做但你需要手动处理特征共线性和异常值对大作业来说工作量更大。4.2 训练集与测试集划分禁止随机打乱按时间切分第3章末尾已经用时间切分造好了特征表训练测试划分也遵循同样原则。有的同学为了图方便在特征表上直接train_test_split(shuffleTrue)这在期末大作业里属于明显的设计缺陷你的目标是用过去预测未来随机打乱等价于让模型同时看到过去和未来。from sklearn.model_selection import train_test_split X final.drop(columns[card_id, label]) y final[label] # shuffleFalse 是关键严格按时间顺序划分 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, shuffleFalse )shuffleFalse会让前80%样本作为训练集、后20%作为测试集前提是你的特征表已经按时间排序。检查方法很简单打印X_train里最大时间戳和X_test里最小时间戳确认前者小于后者即可。另外还要留意类别不平衡问题如果异常样本只占5%随机森林的默认策略会把几乎所有样本判成正常类需要在模型里加class_weightbalanced。4.3 精确率、准确率、召回率建议多少值合适三个指标如何配合看这是期末答辩几乎必问的问题也是热搜里被搜烂的关键词精确率、准确率、召回率建议多少值合适。我先给结论别死盯准确率异常识别场景主要看召回率同时用精确率牵制它。设想一个极端情况数据里99%是正常学生1%是异常。模型把所有学生都判成正常准确率是99%听起来非常漂亮但异常一个都没找出来这个模型毫无价值。召回率衡量的是“真正的异常里找到了多少”精确率衡量的是“模型报出来的异常里有多少是真的”。当召回率低、精确率高时说明模型过于保守漏掉了很多异常当召回率过高、精确率过低时模型会变成“宁可错杀一千”报告里全是误报。from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report model RandomForestClassifier( n_estimators300, max_depth10, min_samples_leaf5, class_weightbalanced, random_state42 ) model.fit(X_train, y_train) y_pred model.predict(X_test) print(classification_report(y_test, y_pred, digits4))precision显示精确率recall显示召回率f1-score是两者的调和平均。对期末大作业来说能跑出“精确率0.6以上、召回率0.7以上、F1在0.6以上”的结果就已经很有说服力了毕竟这只是校园场景的有限特征不是Kaggle竞赛。你真正需要做的是在报告里解释清楚阈值是如何在误报和漏报之间权衡的。4.4 超参数调优用GridSearchCV替代“凭感觉试”随机森林的超参数不算多但每个都直接影响模型表现。n_estimators控制树的数量太小容易欠拟合太大徒增训练时间max_depth控制树的深度太深过拟合到训练集的噪声min_samples_leaf是叶子节点最少样本数调大能明显抑制过拟合。期末大作业里用网格搜索把这几个参数跑一遍比手动试错高效得多。from sklearn.model_selection import GridSearchCV param_grid { n_estimators: [200, 300, 500], max_depth: [8, 10, 12], min_samples_leaf: [3, 5, 8] } grid GridSearchCV( RandomForestClassifier(class_weightbalanced, random_state42), param_grid, cv3, scoringrecall, n_jobs-1, verbose1 ) grid.fit(X_train, y_train) print(Best params:, grid.best_params_) print(Best recall:, grid.best_score_)我用scoringrecall而不是默认的accuracy是因为这个任务的目标是优先把异常找出来模型宁可多报几个正常样本也不能漏掉真正的异常。cv3表示三层交叉验证n_jobs-1让所有CPU核并行跑。300棵树、深度10、最小叶子5这种组合通常是个不错的起点最终以grid.best_estimator_在测试集上的表现为准。5. 期末建模避坑指南5个我踩过的坑按“现象→原因→解决”排好5.1 时间泄漏模型在测试集上效果极好一部署就崩现象训练集F1高达0.95测试集也表现优秀你觉得大功告成。但仔细一看特征里包含“整学期日均消费”和“整学期消费次数”而标签也是基于整学期数据打的。模型在训练时已经偷偷看到了未来信息测试集上的高分是假象。原因特征计算和标签生成用了同一时间段的数据属于典型的“标签泄漏”。解决严格按第3.4节的时间切分法操作用前80%时间计算特征、后20%时间生成标签并在报告里写明两次时间的边界。5.2 类别不平衡下的“准确率99%”陷阱现象分类报告里准确率高达98%但召回率只有0.05打印混淆矩阵发现模型把所有样本都判成了正常类。原因异常样本占比过低模型学到的决策边界偏向多数类。解决先用class_weightbalanced给少数类加权重如果效果还不行再用SMOTE做少数类过采样。判断模型好坏不要看准确率要看F1和召回率。5.3 非饭点消费被系统性低估现象模型对食堂消费识别得很好但对夜间便利店、浴室、水房消费的异常识别效果差。原因我在第3.2节把所有特征都建立在“三餐时段”这个假设上非饭点场景本身就不是这个逻辑。解决按商户类型分组分别计算各场景的消费频率后再合并成特征而不是只看总体金额。你可以在特征表里加一列“非饭点消费笔数占比”把长尾场景单独建模或降权处理。5.4 图表报告和代码对不上老师当场复跑翻车现象报告里画的“周消费分布图”很漂亮但老师要求现场跑一遍脚本发现输出完全对不上。原因你画图时手动改过数据或调过坐标范围但脚本里没有同步修改复现时自然对不上。解决做一次“从原始数据到最终图表”的端到端跑通把中间产物清洗后数据、特征表、预测结果都落盘保存图表代码里直接读取落盘数据杜绝手动改数。5.5 老师追问“为什么这个学生是异常”黑匣子答不上来现象模型预测出某个学生是异常消费但老师说“请解释一下依据”你看着随机森林的预测结果说不出所以然。原因随机森林本身是黑匣子模型只能给总体特征重要性难以解释单条样本的预测路径。解决用model.predict_proba(X_test)输出异常概率再结合feature_importances_里的前三个关键特征来叙述比如该学生非饭点消费占比0.8、变异系数超过3.5、日均消费仅为全校中位数的1/10这三个特征共同把他推向了异常侧。能说出这种话答辩就已经赢了。6. 追加一个加分技巧用7日滑窗特征做“消费异常波动”预警期末大作业拿到基础分容易想加分就得展示一点超出课内范围的东西。我最推荐的方向是给特征表增加“7日滑动窗口特征”它能让模型从“这个人整体消费习惯是否异常”升级到“这个人最近一周的消费是否突然偏离他自己的基线”。这比纯粹的学生间横向对比更有实用价值也更像是真实业务场景里的做法。# 按人和日期聚合日消费总额 daily df.groupby([card_id, df[trade_time].dt.date])[amount].sum().reset_index() daily.columns [card_id, date, amount] daily daily.sort_values([card_id, date]) # 对每个学生单独算7日滑窗均值和标准差shift(1)避免用到当天数据 daily[ma7_mean] daily.groupby(card_id)[amount].transform( lambda x: x.rolling(7, min_periods3).mean().shift(1) ) daily[ma7_std] daily.groupby(card_id)[amount].transform( lambda x: x.rolling(7, min_periods3).std().shift(1) ) # 当日消费偏离个人基线的程度 daily[deviation] (daily[amount] - daily[ma7_mean]) / daily[ma7_std]shift(1)是这个代码块里最容易漏的一步。它的目的是排除当天数据只用过去7天计算基线否则模型在预测当天时用到了当天的信息又回到了时间泄漏的老路上。min_periods3允许学生前几周只有3天消费记录就启动窗口避免因为数据稀疏产生大量空值。把这些按天计算的ma7_mean、ma7_std、deviation再按学生聚合取均值、最大值、最后一周的均值合并进第3章的特征宽表你的模型相当于多了一组“个人纵向前后对比”的视角。运行起来后你会发现之前基于全体学生对比不好发现的“连续多日不消费”“一周消费突然翻倍”这类异常在这个视角下会清晰很多。这个滑窗技巧也是结构化数据建模里最通用的时间序列特征工程手法不管以后做风控还是做用户增长都用得上。我自己的习惯是每次模型跑完都把deviation最大的10个学生单独打印出来逐个看他们的原始流水。这既是验证特征合理性最直接的手段也是报告里最生动的案例分析素材。这个习惯帮我在不止一次答辩里救过场。希望帮到你。本文还有配套的精品资源点击获取
返回列表