ARTICLE DETAIL

资讯详情

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

Python实现DFM动态因子模型:校园消费数据分群与预警分析

Python实现DFM动态因子模型:校园消费数据分群与预警分析 简介一份面向学生消费行为分析与数据挖掘场景的完整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.3DFM 就值得试。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]), axis1, )这段代码的逻辑说明退款记录本来就有正负号差异但不同学校的一卡通系统给号规则不统一有的退款存的是负数有的存的是正数加类型标签所以先用 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], columnscategory, valuesamount, aggfuncsum, fill_value0, ).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_value0).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], howleft) 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_componentsk) factor_matrix pca.fit_transform(Xs) print(方差解释率:, pca.explained_variance_ratio_) # 载荷矩阵因子得分对原始变量的回归系数 B np.linalg.lstsq(factor_matrix, Xs, rcondNone)[0] loadings pd.DataFrame( B.T, indexfeatures, columns[ffactor_{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_mean0, n_dim_obs1, n_dim_state1, transition_matrices[[0.9]], observation_matrices[[1.0]], ) kf kf.em(f1, n_iter15) 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_components8).fit(Xs) ev pca_all.explained_variance_ for i, v in enumerate(ev, start1): 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_1factor_matrix[:, 0], factor_2factor_matrix[:, 1], factor_3factor_matrix[:, 2], ) .groupby(student_id)[[factor_1, factor_2, factor_3]] .mean() ) for n in [3, 4, 5, 6]: km KMeans(n_clustersn, n_init10, random_state42) labels km.fit_predict(student_factor) print(fn{n}, 轮廓系数{silhouette_score(student_factor, labels):.3f}) km KMeans(n_clusters4, n_init10, random_state42) 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_1factor_matrix[:, 0]) .merge(student_factor[[cluster]], left_onstudent_id, right_indexTrue) .groupby([week, cluster])[factor_1] .mean() .unstack() ) plot_df.plot(title不同分群的基础水平因子周变化) plt.xticks(rotation45) 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_componentsk).fit_transform(Xs) B np.linalg.lstsq(F, Xs, rcondNone)[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(fweek{train_weeks[-1]}, loadings_similarity{sim:.3f}) prev_loadings B[:, :2].copy()这个验证技巧是我吃过一次亏才总结出来的。当时我把全量数据拟合好的因子直接上线第二周数据进来聚类簇数没变但第三因子整体反向业务报告里的“高消费学生”突然变成了负因子得分。后来固定了符号锚点并做滚动相似度检查再也没有出过这种尴尬。稳定的标准不用定太高载荷余弦相似度稳定在 0.9 以上就可以接受聚类的调整兰德指数保持在 0.7 以上说明分群结构没有大改。如果相似度连续两周跌破 0.8就不要自动更新模型先把新数据里的异常周找出来多半是节假日或临时封校导致的结构性变化。整套流程跑下来我最大的体会是DFM 的价值不在模型多复杂而在你能不能给三个因子讲出让人信服的故事。数据口径、周频参数、因子数量、载荷符号每一个环节都可能让结论彻底反转。这也是为什么我一直强调要保留原始消费类目的中间结果画图、回查、对齐别让标准化以后的矩阵成为唯一的黑匣子。希望这篇笔记帮你少踩几个坑。本文还有配套的精品资源点击获取
返回列表