简介:一份面向学生消费行为分析与数据挖掘场景的完整Python资料包,基于DFM模型并结合K-Means++聚类与层次分析法,聚焦食堂运营状况分析、学生群体消费特征建模以及经济状况辅助判定等实际需求。压缩包共包含26个文件,整体大小约20.78MB,主要文件类型涵盖Python脚本、CSV数据文件、PNG可视化图表以及Markdown/Word说明文档,其中脚本负责数据处理与建模,CSV保存原始消费数据,PNG直观呈现分析结果,文档解释方法思路与运行流程,依赖列表一应俱全。目前已有553人学习下载,适合数据科学初学者、高校项目实践者以及需要参考消费细分模型方法的开发者。通过该资源可掌握从数据整理、特征选择、指标权重确定到聚类建模的完整分析流程,具体涉及消费数据清洗与融合、DFM行为分析、K-Means++群体划分等关键步骤,并理解如何将细分结果应用于学校经济状况评估,同时配套图表与文档显著降低了学习门槛,可直接借鉴其思路开展类似项目。
1. DFM 不是新算法:把学生消费面板拆成可解释的因子,才是第一步
只看“月消费总额”,你最多把学生分成两类:花得多和花得少。这种分法既解释不了为什么某个食堂档口周中爆单、周末冷清,也看不出某类学生连续三周超市消费下降之后学业状态出现波动的规律。想回答这类问题,得把一卡通流水里的多个消费维度同时拆开,而不是先加总成一根线。DFM 动态因子模型做的就是这个事:把几十个消费类目的周频面板提炼成两三个共同因子,既保留时序上的惯性,又给后续聚类、预警和运营分析提供稳定输入。这篇笔记面向做 Python 数据分析与可视化、刚接手校园消费数据的从业者,目标是从数据口径、特征面板、因子拟合到结果验证完整跑通。
2. 先立住模型:DFM 在消费数据里拆什么,凭什么比 PCA 和黑盒模型稳
2.1 动态因子模型的建模假设:可观测列 = 共同因子 + 个体噪声
动态因子模型最早是从宏观经济指标里长出来的,用在学生消费数据上,数学结构并不复杂。假设你手里有一张面板:行是“学生 × 周”,列是 C 个消费类目,比如早餐、午餐、晚餐、超市、开水、浴室、电费。DFM 认为这些可观测列不是彼此独立的,它们都由少数 k 个共同因子驱动,再加上每个类目自己的特质噪声。
用文字把公式写出来就是:某个学生某周的某个消费类目,等于该类目在共同因子上的载荷乘以因子得分,再加上一个只属于该类目的随机扰动。因子得分是拿不到台面上的潜变量,但每个学生的消费节奏、消费结构都体现在这 k 个因子里面。所谓“动态”,指的是因子自身还有时间上的惯性,这周的因子得分和上周有关,通常用一阶自回归来描述:今天的消费习惯会延续到下周,不会被完全重置。
这一点是和静态 PCA 拉开差距的地方。PCA 只看横截面协方差,把矩阵降维就收工,它不关心这周的因子得分和上周有没有连续关系。DFM 把时序自相关显式建模进去,所以对“消费行为随周变化”这类问题更贴切。Python 落地时如果不引入复杂的贝叶斯工具,常见的做法是用 PCA 先做横截面因子提取,再用卡尔曼滤波对因子序列做平滑。这个两步法虽然不是教科书里最纯粹的 DFM,但工程上稳定、可解释,也方便后面接聚类和预警。
2.2 什么时候选 DFM:三个信号和一张决策表
先看三个信号,满足两条以上再上 DFM,否则不如用简单工具。
信号一:数据是长面板,不是一次性问卷。每个学生有连续十几周甚至几十周的消费记录,周与周之间明显相关。这种数据适合 DFM;如果只有一次横截面调查,直接 PCA 就够了。
信号二:你要的是可解释因子,不是端到端预测成绩。DFM 拆出来的因子能对应业务含义,比如基础消费、弹性消费、波动节奏。你要是只管预测准确率,对黑匣子完全无所谓,那 LSTM 或梯度提升树更直接,不必绕一圈做因子。
信号三:消费类目之间有明显的同涨同跌。早餐和午餐是一起走的,食堂和超市往往是互补的,这类协同变化靠 DFM 能收敛成少数因子;如果所有列之间相关性都很弱,因子分析拆出来的只是数学产物,没有业务意义。
| 方法 | 是否建模时序 | 输出可解释性 | 数据量要求 | 常见落点 |
|---|---|---|---|---|
| PCA | 否 | 中,载荷可读 | 低 | 快速降维、预处理 |
| KMeans | 否 | 中,靠聚类画像 | 低 | 因子之后做分群 |
| LSTM | 是 | 低,黑匣子 | 高,需要长序列 | 预测、异常检测 |
| DFM 两步法 | 是 | 高,因子载荷清楚 | 中 | 行为拆解、画像、预警输入 |
这张表是我做选型时反复看的。多数校园消费数据其实只有几千个学生、几十周,LSTM 很容易过拟合,PCA 又丢掉时间信息,DFM 正好卡在中间。切忌一上来就堆模型,先拿一周的数据去跑一遍相关矩阵,如果看到一半以上类目两两相关系数超过 0.3,DFM 就值得试。
2.3 消费行为场景里因子怎么命名:水平因子、结构因子、波动因子
模型拆完因子以后,命名是真正见功力的地方。第一因子通常会在食堂、开水、电费这些基础类目上有很高的载荷,它捕捉的是“日常必需消费水平”,可以叫基础水平因子;第二因子往往在超市、外卖、文娱类目上载荷突出,对应“非必需弹性消费”,叫弹性消费因子;第三因子如果载荷符号在不同类目间正负分化,比如食堂消费上升、超市消费下降,它反映的是消费结构的此消彼长,叫结构漂移因子。
这里要强调一个容易被忽略的步骤:因子旋转。PCA 裸出来的载荷矩阵经常是“一团浆糊”,每个类目在多个因子上都有中等载荷,很难命名。用方差最大化旋转即 varimax 之后,载荷会趋向两极分化,每个类目主要落在一个因子上,命名就顺了。sklearn 的 factor_analyzer 库里有现成的 Rotator,几行代码就能做。做旋转不会改变因子解释的总方差,但会显著提升业务侧的接受度。
命名做完,模型才算立住了。否则你拿到手的就是三列数字,连自己都说不清第三因子是什么,后续分群、预警全是空中楼阁。
3. 数据准备:把一卡通流水做成周频面板,三个口径参数先定好
3.1 清理边界:消费、退款、充值和补助金怎么分账
一卡通流水通常包含消费、退款、充值、补助发放、转账等记录。很多新手直接把所有记录揉在一起算金额,结果退款的负值把超市消费抵消了,充值的大额正数让早餐消费变得毫无存在感。我的习惯是先把支付类型拆干净,只保留“消费”和“退款”两类进入行为分析,充值、补助、转账一律剔除,因为它们反映的不是消费行为,而是资金操作。
import pandas as pd df = pd.read_csv("card_flow.csv", parse_dates=["trade_time"]) print(df["pay_type"].value_counts()) # 只保留真实商品/服务消费,退款保留为负值 mask = df["pay_type"].isin(["消费", "退款"]) consume = df[mask].copy() consume["amount"] = consume.apply( lambda r: -abs(r["amount"]) if r["pay_type"] == "退款" else abs(r["amount"]), axis=1, )这段代码的逻辑说明:退款记录本来就有正负号差异,但不同学校的一卡通系统给号规则不统一,有的退款存的是负数,有的存的是正数加类型标签,所以先用 abs 取绝对值再按类型统一赋符号,避免脏数据把后续聚合带偏。筛选完类型之后,还可以顺手把 amount 为零的记录删掉,零金额消费通常是测试数据或系统异常,留在面板里只会拉低因子信噪比。参数上没有太多可调项,核心是 mask 的取值列表里到底放哪些支付类型,每个学校不一样,先 value_counts 看清楚再写死。
3.2 构造(学生 × 周 × 类目)面板:pivot 与缺失周补齐
清洗完流水,下一步是把它旋转成面板。这里我建议以“周”为时间粒度,而不是天。天粒度数据太稀疏,一个学生一天不一定三类都消费,因子模型会遇到大量结构性缺失;月粒度又太粗,两周内的行为突变会被平均掉。周粒度是学生消费分析里最常用的折中。
# 以周一为一周起点,生成周标签 consume["week"] = consume["trade_time"].dt.to_period("W-MON").astype(str) panel = consume.pivot_table( index=["student_id", "week"], columns="category", values="amount", aggfunc="sum", fill_value=0, ).reset_index() # 把缺失周补全成 0,保证每个学生有同样长的时序 weeks = sorted(panel["week"].unique()) students = panel["student_id"].unique() full_index = pd.MultiIndex.from_product( [students, weeks], names=["student_id", "week"] ) panel = panel.set_index(["student_id", "week"]).reindex(full_index, fill_value=0).reset_index()这里有两个参数必须解释清楚。第一个是 to_period 里的 W-MON,它把每周起点定在周一;如果你的数据里周消费节奏在周五和周一差异很大,换用别的起点会把消费结构搅浑。第二个是 reindex 补零,很多学生某周完全没去食堂,这一周不应该从面板里消失,否则后续卡尔曼平滑会把缺测当断点。补零会让因子在个别周上失真,但比断档好处理,后面用平滑窗口可以缓解。
3.3 数值稳定性:类目合并与最小有效天数
类目列如果太碎,模型会很难看。有些类目一周只发生两三次,均值很小但方差极大,标准化之后会被当成噪声因子。我一般先把消费金额极低、覆盖率极低的类目合并成一个“其他”列,再把开水、浴室这类同质消费合并。合并原则是业务可解释优先,不是机械地把金额相近的放一起。
还需要给每个学生每周定义一个“有效消费天数”。如果一个学生一周只来学校一天,他的周聚合金额天然只有别人的三分之一,这不是行为差异而是出勤差异。DFM 对这种个体差异会给出一个虚假因子,看起来像“消费水平低”,实际是“在校天数少”。
# 有效消费天数:一周内有消费记录的天数 consume["date"] = consume["trade_time"].dt.date active_days = ( consume.groupby(["student_id", "week"])["date"] .nunique() .rename("active_days") ) panel = panel.merge(active_days, on=["student_id", "week"], how="left") panel["active_days"] = panel["active_days"].fillna(0) # 剔除在校时间过短的周,减少出勤噪声 panel = panel[panel["active_days"] >= 3].copy()有效天数这个参数值得单独调。我一般定 3 天作为下限,低于 3 天说明这周学生基本没在校活动,消费数据不能代表正常行为。阈值定高了会删掉太多样本,定低了又放进出勤噪声,可以先看 active_days 的分布再定。这段代码在后续拟合中很重要,因为它决定了一个学生每周是否进入因子计算。补零对齐和有效天数筛选两个步骤顺序不能反,先筛选再补零,否则那些被剔除的周会重新以零值回到面板里。
4. 拟合 DFM 并输出消费分群:因子数、载荷和聚类参数一次说清
4.1 两步法拟合:标准化、PCA 提因子、卡尔曼平滑
拟合之前先明确特征列。把 student_id 和 week 从特征里拿掉,剩下的类目列全部进入标准化。标准化在这里比 PCA 更关键,因为电费金额可能是几百,开水费只有几块,不标准化的话第一因子必然被电费主导。
from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA import numpy as np features = [c for c in panel.columns if c not in ["student_id", "week", "active_days"]] scaler = StandardScaler() Xs = scaler.fit_transform(panel[features].values) k = 3 pca = PCA(n_components=k) factor_matrix = pca.fit_transform(Xs) print("方差解释率:", pca.explained_variance_ratio_) # 载荷矩阵:因子得分对原始变量的回归系数 B = np.linalg.lstsq(factor_matrix, Xs, rcond=None)[0] loadings = pd.DataFrame( B.T, index=features, columns=[f"factor_{i + 1}" for i in range(k)], ) print(loadings.round(3))这段代码说明三点。第一,PCA 的 factor_matrix 拿到的就是共同因子的估计,每行对应一个“学生 × 周”的因子得分;第二,载荷矩阵用最小二乘回归求,而不是直接读 pca.components_,原因是 components_ 给出的是主成分方向,和标准化变量的回归系数在量纲上有差别,用 lstsq 直接回归得到的载荷更容易和图解释对齐;第三,k 先试 3,后面根据解释率和业务命名再调。拟合完因子后,因子矩阵直接丢给聚类还带噪声,建议先做一步卡尔曼平滑,把周与周之间的毛刺滤掉。
from pykalman import KalmanFilter f1 = factor_matrix[:, 0] kf = KalmanFilter( initial_state_mean=0, n_dim_obs=1, n_dim_state=1, transition_matrices=[[0.9]], observation_matrices=[[1.0]], ) kf = kf.em(f1, n_iter=15) smoothed, _ = kf.smooth(f1) factor_matrix[:, 0] = smoothed.ravel()pykalman 的 em 方法会自动估计观测噪声和转移噪声协方差,transition_matrices 里的 0.9 是状态方程的初始系数,代表因子序列的惯性。如果数据周数短、波动大,可以把 0.9 降到 0.8,平滑力度更大。这里提醒一句:卡尔曼平滑是对因子序列做,不是对原始消费列做,很多教程在这里翻车,把平滑用在原始数据上,结果类目之间的相关结构被破坏了。
4.2 因子数量怎么定:碎石图、累计解释率和业务校验
因子数量是 DFM 最像玄学的地方,但不能拍脑袋。先用一个小循环把前 8 个主成分的特征值打印出来。
pca_all = PCA(n_components=8).fit(Xs) ev = pca_all.explained_variance_ for i, v in enumerate(ev, start=1): print(f"主成分 {i}: 特征值 {v:.3f}, 累计解释率 {ev[:i].sum() / ev.sum():.3f}")我在实际项目里的判断顺序是:特征值大于 1 的主成分数量作为上限;累计解释率提到 70% 到 80% 作为参考;再回去看载荷矩阵能不能命名。三个条件冲突时,以业务命名优先。比如特征值大于 1 的有 5 个,但第 5 个因子的载荷在所有类目上都是 0.2 左右的均匀值,说明它只是残差因子,宁可只取 3 个。金融领域有个通用的 Horn 平行分析法,但在学生消费数据上数据量不够大时不太稳定,我通常不推荐直接套用。
业务校验这一步很关键。取 3 个因子后,去看载荷矩阵里每个类目的最大载荷落在了哪个因子,然后试着给因子起名字。如果三类因子分别对应食堂、超市、结构变化,模型可用;如果某个因子的高载荷都是洗浴、开水这类低频小金额,说明标准化把小类的噪声放大了,要把该类目合并掉重跑。因子数 k 的调整不是一个单向过程,经常要在预处理和 k 之间来回几次。
4.3 因子变成消费分群:KMeans 加轮廓系数
因子拆完,消费分群就可以做了。分群对象是学生个体,先把每个学生的周因子得分按时间取均值,得到一个学生 × 因子的矩阵,再在这个矩阵上做 KMeans。因为因子是标准化的,天然消除了类目金额量纲,聚类距离比直接用原始消费列靠谱得多。
from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score student_factor = ( panel[["student_id", "week"]] .assign( factor_1=factor_matrix[:, 0], factor_2=factor_matrix[:, 1], factor_3=factor_matrix[:, 2], ) .groupby("student_id")[["factor_1", "factor_2", "factor_3"]] .mean() ) for n in [3, 4, 5, 6]: km = KMeans(n_clusters=n, n_init=10, random_state=42) labels = km.fit_predict(student_factor) print(f"n={n}, 轮廓系数={silhouette_score(student_factor, labels):.3f}") km = KMeans(n_clusters=4, n_init=10, random_state=42) student_factor["cluster"] = km.fit_predict(student_factor) print(student_factor.groupby("cluster")[["factor_1", "factor_2", "factor_3"]].mean())轮廓系数的用法要说清楚:它衡量的是“同类紧凑、异类分离”,取值范围 -1 到 1,一般 0.3 以上就可以接受。但轮廓系数会随样本量增大而趋稳,不要单纯追求最高值,结合聚类人数是否均衡一起看。如果某个聚类只有几十人,对应的是一群极端消费个体,这种群单独成立更有利于运营定向分析,不要硬拆散。
聚类出来以后,我给每一类打标签:比如 cluster 0 因子均值整体偏高,就是高消费稳定型;cluster 1 弹性因子高但基础因子低,是节省基础消费但愿意在超市花钱的群体;cluster 2 三类因子都低,可能是低活跃型。标签只是给业务方看的说法,真正的行为结构还得回到原始类目上做交叉验证。我会把聚类的学生 id 提取出来,去算他们在食堂、超市的真实周均金额,确认画像和原始数据一致。
4.4 因子与时间的关系:分群的动态走势图
分群是一个静态结果,但消费行为是动态的。做完聚类以后,我会把每个聚类的因子均值按周展开,画三条曲线看趋势。这一步看似只是可视化,实际上是验证因子有没有抓到“动态”的关键。如果某个群的基础水平因子在第 5 周到第 8 周稳步上升,而它在食堂类目上的原始金额也确实同步上升,说明因子得分和业务趋势对得上;如果因子曲线和原始数据方向相反,那多半是符号问题,需要检查 PCA 分解时因子方向是否翻转了。
import matplotlib.pyplot as plt plot_df = ( panel[["student_id", "week"]] .assign(factor_1=factor_matrix[:, 0]) .merge(student_factor[["cluster"]], left_on="student_id", right_index=True) .groupby(["week", "cluster"])["factor_1"] .mean() .unstack() ) plot_df.plot(title="不同分群的基础水平因子周变化") plt.xticks(rotation=45) plt.show()绘图代码不复杂,但有一点值得注意:周标签是字符串,需要先转成周期类型再排序,否则按字典序排列容易把第 10 周排到第 2 周前面。这个问题我在项目里遇到过两次,第一次没发现,画出来的曲线到处跳,后来检查坐标轴才发现是排序错位。两张图并排看,因子曲线和原始金额趋势一致,整个分析链路才真正闭环。
5. 避坑:拟合 DFM 最容易翻车的四个地方
5.1 第一因子永远被“总消费”绑架,分群只剩金额高低
现象:拆出来的第一因子和每个学生每周的消费总额几乎完全正相关,载荷矩阵里所有类目的第一因子载荷都是正数且数值相近,聚类结果退化成“高消费、中消费、低消费”三档,完全没体现行为结构。
原因:DFM 找第一个因子时天然会倾向于共同变异最大的方向,而消费金额大小是最大的共同变异来源。这不是模型错了,是它先回答了“谁花得多”,没有回答“怎么花”。如果业务目标是结构分析,只取 3 个因子时第一因子会把其他两个因子的解释空间吃掉。
解决:把“金额”这个方向先拿掉。常见做法是每个学生每周的消费金额除以他自己的周均总金额,做标准化后再进模型,或者直接把月度总消费作为协变量回归掉,用残差进入 DFM。也可以把 KMeans 聚类时排除第一因子,只用第二、第三因子,这样分群会更侧重结构差异。我一般会保留第一因子做整体水平描述,聚类只用结构因子和波动因子,避免金额档次主导分群。
5.2 寒暑假断档,因子在开学那几周像过山车
现象:每年二月底和九月初,基础消费因子出现断崖式下降然后直线反弹,聚类结果里出现一个“异常突变型”群体,但查看原始数据会发现这些学生只是寒暑假不在校,行为本身没有突变。
原因:周频面板里假期周大量补零,reindex 把这些零值当成真实消费数据。DFM 把这些全零周识别为“极度低消费”,开学第一周又恢复,自然造成假突变。卡尔曼平滑有一定缓冲作用,但补零的比例太高时平滑也救不回来。
解决:把寒暑假周直接从面板里剔除,而不是补零。筛选条件用学校校历而不是自然月,因为有些学校寒假跨 1 月和 2 月,按自然月裁剪会误伤开学周。如果业务上一定要保留假期前后对比,那就把假期周留一个 dummy 变量进模型,让模型显式学习断档效应,而不是让因子在一个扭曲的序列上硬撑。
5.3 小金额类目标准化后被放大,因子载荷全是噪声
现象:第 3、4 个因子在“开水费”“洗浴费”这些低金额类目上载荷很大,而食堂、超市这类主力消费在第二因子上几乎没有正载荷。聚类轮廓系数看着挺高,但分群结果换一周数据就变。
原因:StandardScaler 按列标准化,这类小金额类目方差本来就小,标准化后数值反而被放大到和食堂一个量级。DFM 只能看到这种相对关系,会把这种统计噪声当成一个共享因子。这个问题在原始金额分布偏斜严重时尤其明显。
解决:先按类目做覆盖率过滤,周覆盖率低于 30% 的直接合并进“其他”。再对每个类目做对数变换,log1p 可以压缩大类目方差,同时保留零值的处理能力。最后再看载荷矩阵,如果某个因子所有载荷绝对值的均值低于 0.3,直接降 k 重跑。不要迷信标准化后的模型输出,随时回头核对原始金额分布。
5.4 不同批次拟合因子方向相反,聚类标签对不上
现象:同样的数据,换一个 random_state 重跑 PCA,或者换了一批新增消费记录,loadings 里 factor_1 的符号整体翻转,之前的高消费群体变成负因子得分。聚类标签也跟着左右互换,业务方对照检查时完全对不上。
原因:PCA 和因子模型天然存在符号可识别性问题,因子方向和载荷方向同时翻转,模型拟合结果完全等价,但业务语义就反了。这不是 bug,是模型的固有属性,不做符号约束就永远有这层隐患。
解决:固定符号锚点。取一个业务上含义明确的类目,比如“早餐”,保证这个类目在 factor_1 上的载荷为正,如果重跑后载荷为负,就把该因子和对应载荷整体乘 -1。这样不管重跑多少次,因子的业务方向都保持一致。更进一步,保存第一次拟合的载荷矩阵作为标准参考,随后每次都用 Procrustes 旋转对齐新载荷,符号和旋转问题一次性解决。
6. 滚动重拟合才是后悔药:验证因子和分群稳不稳,再上线
模型拟合完最怕的事,是明天导入了新一周的数据,重跑一遍,因子结构变了,聚类标签对不上。前一天的画像和今天的画像完全不兼容,业务方质疑你交出去的是不是同一套系统。解决这个问题的办法不是不重跑,而是做一个滚动重拟合验证:每次训练窗口滑动四周,重新估计载荷和聚类,计算载荷矩阵的余弦相似度以及聚类的 ARI 指标,以此衡量模型稳定性。
from sklearn.metrics import adjusted_rand_score weeks_sorted = sorted(panel["week"].unique()) def cosine_sim(a, b): return (a @ b) / (np.linalg.norm(a) * np.linalg.norm(b)) prev_loadings = None prev_labels = None for i in range(4, len(weeks_sorted)): train_weeks = weeks_sorted[i - 4 : i] sub = panel[panel["week"].isin(train_weeks)] Xs = StandardScaler().fit_transform(sub[features].values) F = PCA(n_components=k).fit_transform(Xs) B = np.linalg.lstsq(F, Xs, rcond=None)[0] # 固定符号:保证早餐类目在 factor_1 上为正 if B[features.index("早餐"), 0] < 0: B[:, 0] = -B[:, 0] if prev_loadings is not None: sim = cosine_sim(B[:, :2].ravel(), prev_loadings.ravel()) print(f"week={train_weeks[-1]}, loadings_similarity={sim:.3f}") prev_loadings = B[:, :2].copy()这个验证技巧是我吃过一次亏才总结出来的。当时我把全量数据拟合好的因子直接上线,第二周数据进来,聚类簇数没变,但第三因子整体反向,业务报告里的“高消费学生”突然变成了负因子得分。后来固定了符号锚点,并做滚动相似度检查,再也没有出过这种尴尬。
稳定的标准不用定太高,载荷余弦相似度稳定在 0.9 以上就可以接受,聚类的调整兰德指数保持在 0.7 以上说明分群结构没有大改。如果相似度连续两周跌破 0.8,就不要自动更新模型,先把新数据里的异常周找出来,多半是节假日或临时封校导致的结构性变化。
整套流程跑下来,我最大的体会是:DFM 的价值不在模型多复杂,而在你能不能给三个因子讲出让人信服的故事。数据口径、周频参数、因子数量、载荷符号,每一个环节都可能让结论彻底反转。这也是为什么我一直强调要保留原始消费类目的中间结果,画图、回查、对齐,别让标准化以后的矩阵成为唯一的黑匣子。希望这篇笔记帮你少踩几个坑。
本文还有配套的精品资源,点击获取