☰
Python基于DFM模型的学生消费行为分析:从数据清洗到预测落地
2026/10/2 14:00:33 网站建设 项目流程

简介:面向校园消费数据分析场景,这份基于DFM模型的学生消费行为分析资源,适合Python数据分析初学者及高校项目开发者。内容围绕学生消费与食堂运营两大主题,利用DFM模型、K-Means++聚类和层次分析法,构建学生消费细分模型,覆盖工作日/休息日、午餐/晚餐等维度,既识别不同群体消费特点,也为学校判定经济状况及食堂优化提供参考。压缩包共26个文件,含3个Python脚本、5个CSV数据文件、8张PNG可视化图、Word分析报告及Markdown运行说明,整体20.78MB,目录结构清晰,便于按步骤复现。已有553人学习。下载后可运行源码、查看样例数据与聚类图表,借助文档理解特征选择与调参思路,快速迁移到同类校园消费分析项目中。

1. Python基于DFM模型的学生消费行为分析:先搞清要解决什么问题

如果你手里只有一张校园一卡通流水表,领导让你“看看学生消费有没有异常、下个月食堂备餐备多少、哪些学生可能需要资助”,那么你真正要做的不是画几张饼图,而是建立一套能持续跑批的分析流程。Python基于DFM模型的学生消费行为分析,核心就是把一卡通流水转成时间序列特征,再通过DFM(Demand 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", encoding="utf-8") # 1. 时间解析:先统一成字符串,再强制转时间戳 df["trade_time"] = df["trade_time"].astype(str).str.strip() df["trade_time"] = pd.to_datetime(df["trade_time"], errors="coerce") 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(), freq="D") 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_value=0) daily = daily.reset_index()

逻辑说明:第 1 步处理时间格式混用问题,errors="coerce"会把解析失败变成 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_days=30): 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"], bins=10, 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_days=30是经验值,窗口太短(7 天)特征抖动太大,太长(90 天)对最近的行为变化不敏感。bins=10和金额范围(0, 200)需要根据你的数据分布调整,先画一个全局金额分布直方图再定。

3.3 特征宽表落地:把三块特征合成一个表

基础特征和稳定性特征计算完之后,用student_id合并成一张宽表,这就是 DFM 模型的直接输入。

feature_table = student_base.merge(student_stability, on="student_id", how="inner") feature_table.to_csv("student_feature_table.csv", index=False)

这一步没什么技术含量,但要注意 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_days=7): # 提取单个学生的日消费序列 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_seasonality=False, weekly_seasonality=True, daily_seasonality=False, changepoint_prior_scale=0.05, seasonality_prior_scale=10.0 ) model.add_country_holidays("CN") # 识别国内法定假日 model.fit(sdf) # 生成未来7天日期框架并预测 future = model.make_future_dataframe(periods=forecast_days, freq="D") 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_seasonality=False是因为一学年的时间跨度不足以稳定估计年度周期;weekly_seasonality=True是必需的,周一到周五和周末的消费差异非常大。预测结果中的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字段,标记发放日当天及后一天的数据,建模时把它作为一个外部回归变量传给 Prophet(add_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, horizon=7, window=90): 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_seasonality=True, yearly_seasonality=False) 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)

参数说明:window=90是经验值,学校开学后 90 天的数据基本能覆盖一个完整的行为模式;horizon=7对应一周预测周期,和食堂备餐的采购周期相匹配。

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_clusters=4, random_state=42, n_init=10).fit_predict(feature_scaled)

逻辑说明:zero_days_ratio和avg_daily_amount放在一起能区分“低消费”和“校外消费”——两类学生日均金额可能差不多,但零消费天数占比差异巨大。KMeans 前必须做标准化,否则金额特征的量纲会完全主导聚类结果。

6.3 进阶技巧:每周滚动重训并保存特征快照

最终给业务方的不只是一次性分析,而是一套能持续跑批的流程。我养成的习惯是每周日晚上 10 点重跑一次全量特征计算和预测,把当天的特征宽表保存一份带日期后缀的 CSV。这样下个月有人问“这个学生上个月什么情况”时,我能翻出当时的特征快照,而不是从原始流水重新算一遍。这个习惯帮我省了很多次“翻旧账”的解释成本。

另一个进阶方向是把预测误差作为学生状态变化的触发器。当某个学生的预测误差连续两周超过 40% 时,自动把他的信息推给学生工作处。这在技术上只是多写一个定时任务和条件判断,但实际业务价值往往比模型本身更大——因为你把模型结果接入了业务动作。

参数记录也很重要。每次调整阈值或窗口后,把参数和对应的 MAPE 变化记在一个 Markdown 文件里,哪怕只是几行,也能避免三个月后忘记了当初为什么把 σ 从 2 调成 1.5。没人喜欢写文档,但这个习惯能让你在领导追问“这个数怎么来的”时不慌不忙地翻开当时的记录。希望这个方案能帮你少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询