ARTICLE DETAIL

资讯详情

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

Python基于DFM模型的学生消费行为分析:从数据清洗到预测落地

Python基于DFM模型的学生消费行为分析:从数据清洗到预测落地 简介面向校园消费数据分析场景这份基于DFM模型的学生消费行为分析资源适合Python数据分析初学者及高校项目开发者。内容围绕学生消费与食堂运营两大主题利用DFM模型、K-Means聚类和层次分析法构建学生消费细分模型覆盖工作日/休息日、午餐/晚餐等维度既识别不同群体消费特点也为学校判定经济状况及食堂优化提供参考。压缩包共26个文件含3个Python脚本、5个CSV数据文件、8张PNG可视化图、Word分析报告及Markdown运行说明整体20.78MB目录结构清晰便于按步骤复现。已有553人学习。下载后可运行源码、查看样例数据与聚类图表借助文档理解特征选择与调参思路快速迁移到同类校园消费分析项目中。1. Python基于DFM模型的学生消费行为分析先搞清要解决什么问题如果你手里只有一张校园一卡通流水表领导让你“看看学生消费有没有异常、下个月食堂备餐备多少、哪些学生可能需要资助”那么你真正要做的不是画几张饼图而是建立一套能持续跑批的分析流程。Python基于DFM模型的学生消费行为分析核心就是把一卡通流水转成时间序列特征再通过DFMDemand Forecast Model需求预测模型去拟合“消费趋势 周期 噪声”的规律从而做预测、识别突变和分群。这个方向适合高校信息化部门、智慧校园项目组和数据岗的从业者。反直觉的结论是模型本身不复杂真正让项目翻车的全是数据口径和特征定义问题。2. 从流水到日消费序列数据清洗的三个口径与聚合脚本2.1 拿到一卡通流水表先问三个问题再做分析一卡通流水表在不同学校的命名和字段差异很大但核心字段就四个学号、交易时间、交易金额、商户类别。不要急着写模型先确认三个口径否则后面全是脏数据。第一交易时间是字符串还是时间戳很多系统导出的时间是2024-09-01 12:33:05这种字符串直接to_datetime解析时如果存在2024/09/01混用格式解析会报错或产生 NaT。第二金额字段是否有负数退款、圈存、补助发放都可能记成负值或独立类型如果不区分聚合出的“日消费总额”会出现负数后续特征全是错的。第三商户类别是否归一同一个食堂在不同校区可能叫“一食堂”“学一食堂”“清真食堂”需要先做映射表否则食堂消费占比特征就是假的。这三个问题我建议在项目第一天就和后勤/信息中心确认清楚而不是等建模时发现不对再回炉。常见做法是拉一个字段说明文档哪怕只有一页纸也能省出两三天排错时间。2.2 清洗脚本把脏流水变成干净的日消费序列下面是一段我常用的清洗流程脚本直接处理上面提到的三类问题。假设原始数据是card_flow.csv字段为student_id, trade_time, amount, merchant_category。import pandas as pd import numpy as np df pd.read_csv(card_flow.csv, encodingutf-8) # 1. 时间解析先统一成字符串再强制转时间戳 df[trade_time] df[trade_time].astype(str).str.strip() df[trade_time] pd.to_datetime(df[trade_time], errorscoerce) df df.dropna(subset[trade_time]) # 解析失败的时间直接丢弃 # 2. 金额清洗退款通常记为负数且金额绝对值较小 # 这里先保留所有记录后续用类型字段区分 # 如果原表没有交易类型字段按金额正负拆开 df[is_refund] df[amount] 0 df[amount_abs] df[amount].abs() # 3. 过滤明显异常单笔超过200元且不是补助/退款的记录要标记出来 amount_lower, amount_upper 0.01, 200.0 df df[(df[amount_abs] amount_lower) (df[amount_abs] amount_upper)] # 4. 商户归一把不同叫法的食堂归并成一个大类 merchant_map { 一食堂: 食堂, 学一食堂: 食堂, 清真食堂: 食堂, 超市: 商超, 小卖部: 商超, 水果店: 商超, 图书馆: 其他, 校医院: 其他 } df[merchant_group] df[merchant_category].map(merchant_map).fillna(其他) # 5. 按天聚合得到每个学生每天的总消费 df[day] df[trade_time].dt.date daily df.groupby([student_id, day]).agg( daily_amount(amount_abs, sum), # 当天总消费 daily_count(amount_abs, count), # 当天消费次数 meal_ratio(amount_abs, lambda x: # 当天食堂消费占比 x.loc[df.loc[x.index, merchant_group] 食堂].sum() / x.sum() if x.sum() 0 else 0) ).reset_index() # 6. 补全缺失日期没有消费记录的天数要补0 all_students daily[student_id].unique() date_range pd.date_range(daily[day].min(), daily[day].max(), freqD) full_index pd.MultiIndex.from_product([all_students, date_range], names[student_id, day]) daily daily.set_index([student_id, day]).reindex(full_index, fill_value0) daily daily.reset_index()逻辑说明第 1 步处理时间格式混用问题errorscoerce会把解析失败变成 NaT随后统一丢弃避免后续聚合报错。第 2 步不急着删退款记录因为退款本身是消费行为的信号但需要单独标记。第 4 步的商户映射表是特征工程的地基如果这里不做归一后面算食堂占比特征时会被“一食堂”“学一食堂”这种同义不同名搞乱。第 6 步补全缺失日期是很多新手容易漏的——学生某天没刷卡不代表没消费但如果不补 0时间序列就是不连续的DFM 模型拟合出的趋势会失真。参数说明金额上下限0.01, 200是按普通高校食堂消费水平设定的如果你处理的学校包含校外实习、研究生补助场景上限要提到 500 或更高。补全日期后数据量会膨胀好几倍后续特征计算务必用groupby而不是循环否则性能会很难看。2.3 为什么必须按天聚合而不是按周或按小时学生消费行为最明显的周期是“天”和“周”工作日三餐规律周末消费后移、金额整体下降。按小时聚合太稀疏大部分时段为零模型很难学到稳定模式按周聚合又会把周末效应抹平丢失“周三突然不消费”这类异常信号。所以标准做法是先把流水按student_id day聚合生成日消费序列再在这个序列上构造周特征、月特征。这个中间产物也是后面所有模型的输入建议保存成daily_flow.csv后续每次跑批不用重新解析原始流水。3. 构造消费行为特征DFM 模型真正吃的是特征不是流水3.1 基础特征金额、频次、时段缺一不可很多分析报告只算“月均消费”这是远远不够的。DFM 模型需要从金额、频次、时段三个维度刻画行为因为同样是月均 800 元一个天天食堂三餐的学生和一个每周只去超市囤货的学生消费画像完全不同。我一般会在日聚合表上构造以下基础特征# 在 daily 表基础上生成学生级特征 def build_base_features(daily_df): features [] for sid, g in daily_df.groupby(student_id): feats { student_id: sid, avg_daily_amount: g[daily_amount].mean(), # 日均消费 std_daily_amount: g[daily_amount].std(), # 日消费波动 avg_daily_count: g[daily_count].mean(), # 日均刷卡次数 meal_ratio_avg: g[meal_ratio].mean(), # 平均食堂占比 zero_days_ratio: (g[daily_amount] 0).mean(), # 零消费天数占比 } features.append(feats) return pd.DataFrame(features) student_base build_base_features(daily)逻辑说明avg_daily_amount是核心特征但单看它不够std_daily_amount能识别那些消费忽高忽低的学生zero_days_ratio是识别“长时间不消费”的关键指标正常在校生这个值一般低于 0.3如果超过 0.5要么是校外消费为主要么是数据漏采。meal_ratio_avg则直接反映学生的就餐习惯食堂占比高的学生通常消费更稳定。参数说明zero_days_ratio的阈值不是固定的寒暑假期间整体会升高跑批时要把日期范围限定在学期内或者单独生成term_flag字段否则寒暑假会把特征带偏。3.2 稳定性特征为什么“消费熵”比均值更能反映学生状态基础特征只能描述平均水平但 DFM 模型要预测的是“接下来一周会不会有异常”这时候稳定性特征更重要。我常用两个一个是消费变异系数另一个是消费熵。消费熵的计算方式是把一个学生一个月的日消费金额离散成 10 个区间统计每个区间的概率然后算-sum(p * log(p))。熵越高说明消费分布越分散、行为越不稳定熵越低说明消费集中在固定区间行为规律性强。def build_stability_features(daily_df, window_days30): stability_feats [] for sid, g in daily_df.groupby(student_id): # 取最近 window_days 天的数据避免全量计算被历史数据拉平 recent g.tail(window_days) if len(recent) 14: # 数据不足的学生跳过 continue # 变异系数标准差 / 均值消除金额水平的影响 cv_amount recent[daily_amount].std() / (recent[daily_amount].mean() 1e-6) # 消费熵金额分成10个箱 hist, _ np.histogram(recent[daily_amount], bins10, range(0, 200)) probs hist / (hist.sum() 1e-6) probs probs[probs 0] entropy -np.sum(probs * np.log(probs)) # 连续零消费最大天数 zero_mask (recent[daily_amount] 0).astype(int) max_zero_streak 0 cur_streak 0 for v in zero_mask: if v 1: cur_streak 1 max_zero_streak max(max_zero_streak, cur_streak) else: cur_streak 0 stability_feats.append({ student_id: sid, cv_amount: cv_amount, consumption_entropy: entropy, max_zero_streak: max_zero_streak, weekend_weekday_diff: recent[recent[day].dt.weekday 5][daily_amount].mean() - recent[recent[day].dt.weekday 5][daily_amount].mean() }) return pd.DataFrame(stability_feats) student_stability build_stability_features(daily)逻辑说明变异系数和消费熵解决的是同一个问题的两个侧面——变异系数看波动幅度熵看分布分散度。max_zero_streak是识别“失联式消费”的信号这个特征在资助评估场景中很有用连续 5 天以上零消费且没有请假记录的学生值得重点核查。weekend_weekday_diff如果为负且绝对值很大说明学生周末几乎不消费可能存在离校兼职或回家的情况。参数说明window_days30是经验值窗口太短7 天特征抖动太大太长90 天对最近的行为变化不敏感。bins10和金额范围(0, 200)需要根据你的数据分布调整先画一个全局金额分布直方图再定。3.3 特征宽表落地把三块特征合成一个表基础特征和稳定性特征计算完之后用student_id合并成一张宽表这就是 DFM 模型的直接输入。feature_table student_base.merge(student_stability, onstudent_id, howinner) feature_table.to_csv(student_feature_table.csv, indexFalse)这一步没什么技术含量但要注意 merge 之后的行数要和原始学生数核对一遍。常见坑是build_stability_features里跳过了数据不足的学生导致合并后少人另一个坑是 CSV 里student_id如果是数字读出来是 int另一张表里却是字符串merge 时静默产生笛卡尔积。统一在 merge 前强制astype(str)最稳妥。4. 建模与调参DFM 需求预测模型的落地路径与参数优先级4.1 模型选型为什么用趋势分解加回归而不是一上来就上黑匣子DFM 模型的核心假设是学生消费行为 长期趋势 周周期 节假日扰动 随机噪声。这个假设非常贴合校园消费场景因为食堂消费天然具备周期性。为什么不建议用 LSTM 或 XGBoost 黑匣子第一单个学生的有效数据通常只有一两年样本量撑不起复杂模型第二学生工作处、后勤的老师需要听懂你的结论你告诉他们“这是个 18 层神经网络判定的”没法落到资助认定和备餐决策上。第三DFM 的可解释性让你能直接回答“这个学生为什么被标为异常”——因为他的消费趋势在过去三周下降了 40%。落地时我用的不是某个固定的库而是一个组合先用statsmodels做 STL 趋势分解再用Prophet或简单线性回归拟合预测未来 7 天。Prophet 的好处是内置了周周期和节假日效应不用自己手工构造周期变量。4.2 最小可跑通代码预测未来 7 天的日消费序列下面这段代码对单个学生进行预测实际项目中要对全体学生循环执行建议用groupby配合apply并行化。from prophet import Prophet import pandas as pd def predict_student_consumption(sid, daily_df, forecast_days7): # 提取单个学生的日消费序列 sdf daily_df[daily_df[student_id] sid].copy() sdf sdf.rename(columns{day: ds, daily_amount: y}) sdf[ds] pd.to_datetime(sdf[ds]) # 过滤掉全零时间段比如寒暑假 sdf sdf[sdf[y] 0] # 初始化 Prophet设置周周期和假期参数 model Prophet( yearly_seasonalityFalse, weekly_seasonalityTrue, daily_seasonalityFalse, changepoint_prior_scale0.05, seasonality_prior_scale10.0 ) model.add_country_holidays(CN) # 识别国内法定假日 model.fit(sdf) # 生成未来7天日期框架并预测 future model.make_future_dataframe(periodsforecast_days, freqD) forecast model.predict(future) recent forecast.tail(forecast_days)[[ds, yhat, yhat_lower, yhat_upper]] # 计算最近30天的实际均值用于判断预测是否明显偏离 recent_actual_mean sdf[y].tail(30).mean() recent[anomaly_score] (recent[yhat] - recent_actual_mean) / (recent_actual_mean 1e-6) return recent sample_forecast predict_student_consumption(20230001, daily) print(sample_forecast.head())逻辑说明yearly_seasonalityFalse是因为一学年的时间跨度不足以稳定估计年度周期weekly_seasonalityTrue是必需的周一到周五和周末的消费差异非常大。预测结果中的yhat_lower和yhat_upper是置信区间当实际消费值跌出这个区间时就是一个明显的异常信号。参数说明changepoint_prior_scale控制趋势变化的敏感度默认 0.05 适合大多数场景如果发现预测结果过于平滑无法捕捉“开学前两周消费突增”这种变化可以调大到 0.1如果预测曲线抖动太厉害调小到 0.01。seasonality_prior_scale默认 10.0控制周期效应的强度当食堂因装修临时停业造成周期性消失时这个参数要适当调低。4.3 参数调节优先级窗口长度、突变阈值、节假日前置DFM 模型调参时不要漫无目的地调按这个优先级来参数默认值调节方向触发条件历史数据窗口90 天窗口越长趋势越稳越短对突变越敏感学生刚入学或刚换校区时用 30 天异常判定阈值均值 ± 2σ2σ 太严3σ 太松用于资助筛查时用 1.5σ用于日常预警用 2.5σ节假日掩码调休日覆盖不完整时预测会显著偏低每个学期初要更新一次金额上限200 元过低会漏掉校外消费场景研究生群体调到 500这里有一个关键经验节假日前一天和后一天的消费模式差异极大。比如国庆前最后一天学生可能大量囤货消费金额是平时的 2 倍以上但 Prophet 的节假日模型默认只放当天需要手动在数据里加一个pre_holiday列把节前一天标记为假期否则预测会在放假当天出现明显低估。4.4 从单学生预测到全校批量跑批的性能问题循环几百个学生调 Prophet 是可以的但上万学生逐个 fit 会很慢。常见做法是抽样 50 个学生调完参数后用Prophet.make_future_dataframe结合parallel方式跑全量。更快的替代方案是用滚动均值加周周期修正做轻量预测只有消费波动超过阈值的学生才进入 Prophet 全量预测流程。我先用groupby加上tail(7).mean()做初筛预测误差超过 30% 的学生才进入慢速精确预测。这样能在保证覆盖异常样本的前提下把全校 2 万学生的预测时间控制在 10 分钟以内。5. 学生消费行为分析的避坑指南5 个常见问题与排查5.1 助学金发放日被误判为“消费暴涨”现象模型在每月 15 号左右把很多学生的消费标为异常异常方向是“偏高”。原因助学补助发放当天部分学生会集中充值饭卡或去超市消费单日金额确实会高于均值。这从数据上不假但从业务上不是需要预警的异常——这是正常的收入冲击。解决在特征表里增加一个subsidy_day_flag字段标记发放日当天及后一天的数据建模时把它作为一个外部回归变量传给 Prophetadd_regressor。这样模型学会了“有补助的日子消费应该高”就不会再误报。5.2 寒暑假数据导致趋势被拉平现象模型预测开学后的日均消费明显偏低和实际差 30% 以上。原因直接拿全学年数据喂给模型7 月和 8 月的零消费记录占了一大半模型的周周期会被稀释开学期间的消费回升难以被准确拟合。解决训练数据窗口要么限定在学期内手动排除寒暑假日期段要么在 Prophet 里把寒暑假作为自定义假期传入。推荐前者因为操场、图书馆、超市在寒暑假的开放时间完全不同这些商户变化不是 DFM 模型能预测的。5.3 退款和圈存流水混在金额里把日消费算成负数现象某学生某天日消费金额是 -50 元特征表的avg_daily_amount变成负数无法解释。原因一卡通系统里食堂退费、超市退款、线上圈存都会被记为“交易流水”部分学校的导出接口没有区分交易类型。解决回到源头看有没有trans_type字段没有的话强制约定金额为正才算消费负值单独统计成refund_amount特征。不要想着“反正金额绝对值不大过滤掉就行”退款频率本身也能反映行为——频繁退费的学生可能经常买错东西或者存在代刷行为。5.4 学生学号在两张表里类型不一致merge 后行数爆炸现象特征表合并后行数从 2 万变成 4 万而且很多student_id是 NaN。原因一张表里学号是字符串前导零如20230001另一张表里是整数merge 时 pandas 不会自动统一类型导致同一学号没匹配上产生笛卡尔积。解决所有涉及合并的键一律在读取后立即astype(str)并检查是否有空格。这是个老掉牙的坑但每次项目都会有人踩。5.5 预测结果整体被低估却找不到明显原因现象全校学生的预测日消费普遍比实际低 15% 左右单看每个学生曲线又看不出问题。原因校区里有部分商户使用独立收款系统比如新开的面包店、咖啡机、自助售货机它们不经过一卡通。也就是说你拿到的数据整体低估了学生的真实消费。解决用总充值金额做校准。如果一卡通系统能统计每月充值总额用“充值总额 / 当月天数”折算出一个全校平均日消费再和模型预测值对比偏差超过 10% 时在预测结果上乘以一个固定的系数补偿。这个方法不完美但比“假装数据完整”要可靠。6. 结果验证与进阶用法从预测到消费分群画像6.1 用滚动验证而不是一次性划分训练集DFM 模型最常见的验证错误是随机划分训练集和测试集这会让未来数据泄露到训练集里。正确做法是滚动预测对每个学生用前 90 天预测后 7 天然后整体往后挪 7 天重复 4 次把预测值和实际值对比计算 MAPE平均绝对百分比误差。MAPE 低于 20% 说明模型对这个学生是有效的高于 40% 的学生要单独标记他们的消费行为可能本来就不规律。def rolling_validation(sid, daily_df, horizon7, window90): sdf daily_df[daily_df[student_id] sid].sort_values(day) sdf sdf[sdf[daily_amount] 0] errors [] for start in range(0, len(sdf) - window - horizon, horizon): train sdf.iloc[start:start window] test sdf.iloc[start window:start window horizon] model Prophet(weekly_seasonalityTrue, yearly_seasonalityFalse) model.fit(train.rename(columns{day: ds, daily_amount: y})) pred model.predict(test[[day]].rename(columns{day: ds})) mape (pred[yhat].values - test[daily_amount].values).__abs__().mean() / (test[daily_amount].mean() 1e-6) errors.append(mape) return sid, np.mean(errors)参数说明window90是经验值学校开学后 90 天的数据基本能覆盖一个完整的行为模式horizon7对应一周预测周期和食堂备餐的采购周期相匹配。6.2 消费分群画像把预测误差和学生特征放在一起看模型预测完不能只丢出一堆数字最终交付要落到画像上。用特征宽表和预测误差做一次 KMeans 分群我通常分 4 类稳定食堂型日均消费高、熵低、误差小、校外消费型零消费天数多、周末差异大、波动敏感型变异系数大、误差高、低消费型日均金额低且稳定。from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler cluster_cols [avg_daily_amount, cv_amount, consumption_entropy, zero_days_ratio] scaler StandardScaler() feature_scaled scaler.fit_transform(feature_table[cluster_cols]) feature_table[cluster] KMeans(n_clusters4, random_state42, n_init10).fit_predict(feature_scaled)逻辑说明zero_days_ratio和avg_daily_amount放在一起能区分“低消费”和“校外消费”——两类学生日均金额可能差不多但零消费天数占比差异巨大。KMeans 前必须做标准化否则金额特征的量纲会完全主导聚类结果。6.3 进阶技巧每周滚动重训并保存特征快照最终给业务方的不只是一次性分析而是一套能持续跑批的流程。我养成的习惯是每周日晚上 10 点重跑一次全量特征计算和预测把当天的特征宽表保存一份带日期后缀的 CSV。这样下个月有人问“这个学生上个月什么情况”时我能翻出当时的特征快照而不是从原始流水重新算一遍。这个习惯帮我省了很多次“翻旧账”的解释成本。另一个进阶方向是把预测误差作为学生状态变化的触发器。当某个学生的预测误差连续两周超过 40% 时自动把他的信息推给学生工作处。这在技术上只是多写一个定时任务和条件判断但实际业务价值往往比模型本身更大——因为你把模型结果接入了业务动作。参数记录也很重要。每次调整阈值或窗口后把参数和对应的 MAPE 变化记在一个 Markdown 文件里哪怕只是几行也能避免三个月后忘记了当初为什么把 σ 从 2 调成 1.5。没人喜欢写文档但这个习惯能让你在领导追问“这个数怎么来的”时不慌不忙地翻开当时的记录。希望这个方案能帮你少踩几个坑。本文还有配套的精品资源点击获取
返回列表