
简介这是一份围绕DFM模型开展学生消费行为分析的Python项目资料适合数据分析、数据挖掘方向的学习者与相关课题研究者。项目从学生校园消费数据出发结合K-Means聚类与层次分析法构建学生消费细分模型并针对食堂运营、学生经济状况判定给出可参考的分析思路。资源共26个文件核心包括Python源码model.py、analysis.py等、实验数据CSV、可视化结果PNG、运行说明文本与依赖配置压缩包整体约20.78MB结构清晰便于按模块查阅。当前已有553人学习下载。通过该项目可掌握DFM模型的落地流程、特征选择方法、聚类结果解读以及从数据清洗到结论输出的完整项目组织方式适合初学者模仿练习与进阶者参考建模思路。1. 基于DFM模型的学生消费行为分析从流水到结论的建模链路这份基于 DFM 模型的学生消费行为分析项目解决的是高校场景里一个非常实际的问题校园一卡通攒了几十万条消费流水食堂经理想知道哪个窗口该加菜辅导员想知道哪些学生可能经济困难——从流水到结论中间那套建模流程才是这份 Python 项目真正值钱的地方。它用动态因子模型DFM从多维消费特征里提取公共因子再用 K-Means 把学生分成不同消费群体最后用层次分析法AHP给分群结果做经济状况评分形成一条能从数据走到建议的完整链路。适合正在做数据分析课程设计、需要复现完整建模流程的从业者和学生也适合想了解消费分群落地细节的校园信息化工程师。2. DFM模型的输入准备消费流水如何变成可建模的特征矩阵2.1 从 consume.csv 到日维度聚合时间字段与缺失值处理一卡通系统导出的 consume.csv 常见结构是学号、交易时间、消费金额、消费窗口早餐/午餐/晚餐、商户编号偶尔带补贴字段。这里的第一步不是直接跑模型而是先把流水按「学号 日期」聚合成日维度记录原因很简单模型需要的特征是「一个人平均怎么吃」不是「某一次刷了多少钱」。import pandas as pd import numpy as np # 读取原始流水trade_time 直接解析成 datetime 类型 consume pd.read_csv(data/consume.csv, parse_dates[trade_time]) # 从时间戳里拆出日期、小时两个维度 consume[date] consume[trade_time].dt.date consume[hour] consume[trade_time].dt.hour # 按学号日期聚合总金额、消费次数、平均单笔金额 daily consume.groupby([student_id, date]).agg( total_amount(amount, sum), cnt(amount, count), avg_amount(amount, mean) ).reset_index() # 标记异常单日消费超过 200 元或低于 1 元 outlier_mask (daily[total_amount] 200) | (daily[total_amount] 1) print(异常记录数:, outlier_mask.sum())这里的 agg 命名聚合写法是 pandas 1.x 之后的推荐风格比旧版传字典的方式更不容易把列名写错。total_amount 是后续所有特征的地基cnt 反映就餐频次avg_amount 能暴露「一天只刷一笔且金额特别大」的异常行为比如帮室友带饭、刷错卡。过滤阈值 200 元不是拍脑袋高校食堂一天正常消费上限在 80 元左右200 元已经足够宽只卡极端离群点。2.2 特征设计日均消费、餐次结构、日期波动三个方向日维度数据聚合好之后还要再往上一层生成每个学生的稳定特征才能喂给 DFM 做因子提取。我一般会从三个方向设计特征消费水平类、餐次结构类、波动类。下面是这套资源里用到的特征清单对应 data1.csv、data2.csv、data3.csv 里预聚合好的字段特征名计算方式业务含义avg_month_total月消费总额的均值月均消费水平avg_daily_amount月总额 / 有消费的天数日均消费强度avg_meal_cnt月总笔数 / 有消费的天数就餐频次breakfast_ratio早餐金额 / 总金额餐次结构wk_amount工作日日均消费平时消费水平we_amount休息日日均消费周末消费水平wk_we_diff工作日均值 − 休息日均值周末离校识别# 按学生按月聚合再用月记录生成稳定特征 consume[month] consume[trade_time].dt.month monthly consume.groupby([student_id, month]).agg( month_total(amount, sum), month_days(date, nunique), month_cnt(amount, count) ).reset_index() # 月均消费、日均消费、月均就餐次数 student_features pd.DataFrame({ avg_month_total: monthly.groupby(student_id)[month_total].mean(), avg_daily_amount: monthly.groupby(student_id)[month_total].mean() / monthly.groupby(student_id)[month_days].mean(), avg_meal_cnt: monthly.groupby(student_id)[month_cnt].mean() }) # 早餐占比早餐总金额 / 全部消费总金额没有早餐记录补 0 bf_sum consume[consume[meal_type] 早餐].groupby(student_id)[amount].sum() total_sum consume.groupby(student_id)[amount].sum() student_features[breakfast_ratio] (bf_sum / total_sum).fillna(0)这里有个关键点餐次占比这类比率特征要和金额特征分开标准化否则量纲差异会把聚类结果带偏。DFM 之所以适合这个场景是因为它假设这些观测特征背后存在少数公共因子比如「整体消费水平」「就餐规律性」先把 6 个原始特征压成 2 个因子再做聚类能显著降低多维噪声的影响。2.3 数据分割工作日与休息日为什么要分开看很多学生的消费习惯在周末会变样有的周末回家吃饭有的周末在校外消费一卡通流水直接缺一块。如果不把工作日和休息日分开统计周末缺失会被平均进日均值得到的是一个「半真半假」的中间数。# 周一至周五为 1周六周日为 0 consume[is_weekday] np.where( consume[trade_time].dt.dayofweek 5, 1, 0 ) # 分别统计工作日、休息日的日均消费 weekday_stats consume[consume[is_weekday] 1].groupby( student_id)[amount].mean() weekend_stats consume[consume[is_weekday] 0].groupby( student_id)[amount].mean() # 用索引对齐写入pandas 会自动匹配学号 student_features student_features.set_index(student_id) student_features[wk_amount] weekday_stats student_features[we_amount] weekend_stats student_features[wk_we_diff] (weekday_stats - weekend_stats).fillna(0)wk_we_diff 这个差值特征是识别「周末离校型」学生的利器。正常在校生周末与平时差异不大差值接近 0周末回家的学生休息日均值明显偏低差值为正且较大。这个特征在后续聚类里往往能单独分出一类人对应资源里 1-2-工作日.png 和 1-2-休息日.png 两张图展示的分布差异。3. K-Means 与层次分析法学生分群和经济状况评分的两条主线3.1 K-Means 初始化逻辑为什么用 k-means 而不是默认随机K-Means 对初始质心敏感默认的随机初始化可能把质心全部选到同一片密集区域导致收敛到局部最优。K-Means 的核心做法是第一个质心随机选之后的每一个质心以「距离已有质心越远概率越大」的方式选取从源头降低局部最优的概率这是项目里为什么明确写 K-Means 而不是普通 K-Means 的原因。from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler # 只取数值型特征列统一标准化到 0 均值 1 方差 feat_cols student_features.select_dtypes(include[np.number]).columns X student_features[feat_cols].values scaler StandardScaler() X_scaled scaler.fit_transform(X) # 固定 random_state 和非负初始化方式 km KMeans( n_clusters4, initk-means, n_init10, max_iter300, random_state42 ) labels km.fit_predict(X_scaled) student_features[cluster] labels参数值作用n_clusters4分群数由肘部法则与轮廓系数确定initk-means优化初始质心选择避免局部最优n_init10从 10 组不同初始质心出发取最优random_state42固定随机种子保证结果可复现max_iter300最大迭代轮数几千样本足够收敛其中 random_state 对课程设计和论文场景尤其重要。不固定它每次运行分群标签都可能不同评审或答辩时无法复现这是最常见的翻车点。n_init 在 sklearn 0.23 之后默认值从 10 变成了 1如果你没有显式设置等于只跑了一次初始化。3.2 聚类数 K 的选择肘部法则与轮廓系数的配合K 是 K-Means 里唯一要先拍板的核心参数。项目里的 2-1.png、2-2.png 就是不同 K 值下的聚类结果对比图。常见做法是先用肘部法则圈定范围再用轮廓系数确定最终值。from sklearn.metrics import silhouette_score # 尝试 K 从 2 到 8记录惯性和轮廓系数 results [] for k in range(2, 9): tmp KMeans( n_clustersk, initk-means, n_init10, random_state42 ).fit(X_scaled) sil silhouette_score(X_scaled, tmp.labels_) results.append({ k: k, inertia: tmp.inertia_, silhouette: sil }) results_df pd.DataFrame(results) print(results_df.sort_values(silhouette, ascendingFalse))惯性是样本到所属质心距离的平方和K 增大时惯性必然下降但下降速度会在某个 K 之后明显变缓这个拐点就是肘部。轮廓系数取值在 -1 到 1 之间大于 0.3 说明分群结构可接受0.5 以上说明分群明显。两个指标配合使用的经验法则是先看肘部图排除明显过小或过大的 K再在候选 K 里取轮廓系数最高的那个。千万不能只看惯性因为它会一味偏向更大的 K分出的群可能没有业务意义。3.3 层次分析法打分判断矩阵、一致性检验与权重计算分群完成之后项目要求输出「经济状况参考意见」。这个环节用层次分析法把日均消费、餐次结构、周末波动这些指标作为准则层构造两两比较的判断矩阵算出权重再对每个聚类中心加权打分。import numpy as np # 判断矩阵日均消费 / 餐次规律 / 周末波动 # 1 表示同等重要3 表示前者比后者稍微重要 A np.array([ [1, 3, 5], [1/3, 1, 3], [1/5, 1/3, 1] ]) # 特征向量法求权重 eig_vals, eig_vecs np.linalg.eig(A) max_idx np.argmax(eig_vals.real) max_eig eig_vals.real[max_idx] w np.abs(eig_vecs[:, max_idx].real) w w / w.sum() print(权重向量:, w) # 一致性检验CR 0.1 才可接受 n A.shape[0] CI (max_eig - n) / (n - 1) RI np.array([0, 0, 0.58, 0.90, 1.12, 1.24, 1.32, 1.41, 1.45]) CR CI / RI[n - 1] print(CI:, CI, CR:, CR)判断矩阵里的 3 和 5 是主观设定的相对重要程度这里默认「消费水平 餐次规律 周末波动」对应到项目目标里「经济状况判断优先看日均消费」的直觉。CR 小于 0.1 说明判断矩阵的一致性可以接受如果 CR 超了说明矩阵里出现了类似「A 比 B 重要、B 比 C 重要、但 C 比 A 重要」的矛盾要回去调整数值不能硬用结果往下走。4. 代码链路拆解init.py、model.py、analysis.py 各自负责什么先给一份文件职责对照表方便你拿到压缩包后知道先看哪个、后跑哪个。这份资源里 data 目录下放了 consume.csv、grade18.csv 和 data1.csv、data2.csv、data3.csvimg 目录是跑出来的图表根目录三个 Python 文件构成主流程文件职责运行时机init.py依赖检查、数据文件校验、路径约定最先运行model.py因子提取、聚类、层次分析权重计算核心流程analysis.py分群画像统计、图表输出、结论落盘最后运行requirements.txt依赖库及版本锁定pip install 时4.1 init.py环境检查与数据路径约定init.py 在整个项目里承担的是「前置检查」角色。它做的事情很朴素确认依赖库是否安装、检查数据文件是否存在、约定路径常量避免后面 model.py 和 analysis.py 因为文件缺失或路径硬编码而翻车。配套的 requirements.txt 里锁定了 pandas、numpy、scikit-learn、matplotlib 这几个核心库。# init.py 核心逻辑示意 import os import sys REQUIRED_PKGS [pandas, numpy, sklearn, matplotlib] DATA_DIR data OUTPUT_DIR img def check_environment(): for pkg in REQUIRED_PKGS: try: __import__(pkg) print(f[OK] {pkg} 已安装) except ImportError: print(f[ERROR] 缺少依赖: {pkg}) sys.exit(1) def check_data_files(): needed [consume.csv, grade18.csv, data1.csv, data2.csv, data3.csv] for fname in needed: path os.path.join(DATA_DIR, fname) if not os.path.exists(path): raise FileNotFoundError(f缺少数据文件: {path}) if __name__ __main__: check_environment() check_data_files()这段代码的价值不在技术难度而在于它把「运行前要确认的事」从人的记忆里搬进了代码。拿到这包资源的第一时间先跑一遍 init.py机器会告诉你缺什么依赖、缺哪个文件不用对着报错信息逐行猜。程序运行说明.md 里写的启动步骤本质上就是 init.py 里这套检查流程的文字版。4.2 model.pyDFM因子提取与聚类的核心实现model.py 是整份资源的主脑承担两件事第一用 PCA 分解近似实现 DFM 的公共因子提取第二在因子得分上做 K-Means 聚类。严格意义上的动态因子模型需要时序状态空间估计课程设计场景里更常见的做法是用 PCA 的思想近似先标准化特征再做主成分分解取前几个主成分作为公共因子。from sklearn.decomposition import PCA from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler import numpy as np def extract_factors(features_df, n_factors2): # 自动选取数值列student_id 如果不在索引里就先剔除 feat_cols features_df.select_dtypes(include[np.number]).columns X features_df[feat_cols].values scaler StandardScaler() X_std scaler.fit_transform(X) # PCA 近似实现 DFM 的公共因子提取 pca PCA(n_componentsn_factors, random_state42) factors pca.fit_transform(X_std) print(因子解释方差占比:, pca.explained_variance_ratio_) return factors, scaler, pca def cluster_students(factors, n_clusters4): km KMeans( n_clustersn_clusters, initk-means, n_init10, random_state42 ) labels km.fit_predict(factors) return labels, km这里有个取舍要讲清楚真正的 DFM 是带时序结构的每个学生是一条消费时间序列因子载荷会随时间变化用 PCA 近似牺牲了时序动态性换来了稳定性和可复现性对一次性截面聚类来说足够。如果后续想升级可以换 statsmodels 的 DynamicFactor 类做真动态因子估计但数据量少于 300 天时估计结果反而不如 PCA 稳定这是实践中容易忽视的边界。4.3 analysis.py分群画像、图表输出与结论落盘analysis.py 负责把 model.py 的输出翻译成业务语言。具体来说计算每个聚类的特征均值生成分群画像表格输出图表到 img 目录再与 grade18.csv 里的年级信息做交叉统计落地第 3 章层次分析打分的结论。import matplotlib matplotlib.use(Agg) # 命令行无界面环境也能出图 import matplotlib.pyplot as plt def profile_clusters(features_df, labels): df features_df.copy() df[cluster] labels profile df.groupby(cluster).mean(numeric_onlyTrue) return profile def export_charts(profile, output_dirimg): # 各簇日均消费条形图 fig, ax plt.subplots(figsize(8, 5)) ax.bar(profile.index.astype(str), profile[avg_daily_amount]) ax.set_title(各簇日均消费对比) ax.set_xlabel(簇编号) ax.set_ylabel(日均消费元) fig.tight_layout() fig.savefig(f{output_dir}/2-1.png, dpi150) plt.close(fig) def cross_with_grade(labels, grade18_df): # 与年级信息交叉每个簇里各年级的占比 merged grade18_df[[student_id, grade]].copy() merged[cluster] labels cross pd.crosstab( merged[grade], merged[cluster], normalizecolumns ) return crossmatplotlib.use(Agg) 是服务器环境必备设置。本地 Jupyter 里跑可以去掉这行但写成脚本用命令行运行时不设置 Agg 会直接报「无法找到显示设备」。dpi150 是兼顾清晰度和文件大小的经验值论文插图够用。另外 cluster 列回填之后cluster 为 0 到 3 的每个分组都可以单独画餐次分布对应资源里 1-1-早餐.png、1-1-午餐.png、1-1-晚餐.png 三张图。5. 避坑指南消费行为分析中我踩过的五个典型问题5.1 聚类结果每次运行都不一样现象同一份数据、同一段代码连续跑三次 model.py三次的簇标签和轮廓系数都不同后面分析没法交代。原因KMeans 里没固定 random_state初始质心是随机的每次迭代路径不同。另一层原因是 sklearn 0.23 之后 n_init 默认值降为 1等价于只跑了一次随机初始化。解决所有 KMeans 和 PCA 调用都显式传入 random_state42并显式设置 n_init10。这个习惯要养成不只是这个项目任何涉及随机初始化的模型都应该固定种子。5.2 消费金额右偏严重聚类把所有人分进同一类现象设置簇数为 4结果其中一个簇占了 90% 的学生另外三个簇都是零星的极端值分群没有业务区分度。原因消费金额是典型的右偏分布大部分学生日均消费集中在 15 到 30 元少数超过 50 元。不处理长尾直接聚类质心会被密集区拉走极端值被孤立成小簇。解决对金额特征先做 log1p 变换再标准化或者用分位数特征替代均值比如用 P50 和 P90 作为特征天然抗离群点。我在这个项目里通常两种都试比较轮廓系数后选更稳的那组。5.3 周末结构性缺失把日均消费拉低现象某学生工作日日均消费 32 元但聚合到月维度后日均只有 18 元模型把他归入低消费组和实际明显不符。原因他没有周末消费流水聚合时周末零消费天数被算进了分母把均值拉低了。这是消费数据的「结构性缺失」不是随机缺失不能用简单填充处理。解决按第 2.3 节的做法把工作日和休息日分开统计计算日均时用「有消费记录的天数」做分母而不是用日历天数。wk_we_diff 特征同时承担了识别这类学生的任务。5.4 层次分析法 CR 超过 0.1现象判断矩阵按直觉填了 1、3、5算出来 CR0.08 或 0.12一改某个比值 CR 还可能直接飙到 0.2。原因判断矩阵阶数越高对应的随机一致性指标 RI 越大对矛盾越敏感。三阶矩阵 RI 是 0.58五阶变成 1.12同样的偏差在五阶矩阵里很可能不可接受。解决准则层控制在 5 个指标以内超过就合并同类项。如果 CR 超了优先检查「传递性矛盾」比如 A 比 B 重要3、B 比 C 重要3那么 A 比 C 至少该填 3 到 5不能填 1/3。5.5 经济状况判定被单一特征带偏现象用总消费金额排序来认定困难学生结果把「天天吃食堂但只点最便宜窗口」的省吃俭用型和「校外吃得多、刷卡少」的通勤型学生混在了一起。原因单一特征无法区分「消费能力低」和「消费意愿低」。前者是真的没钱后者是校外消费占比高一卡通流水只反映了部分真实消费。解决把餐次结构特征和周末差异特征一起进入模型。判断经济状况时优先看食堂内消费占比高、金额偏低的学生这类学生经济压力特征更明显。这也是资源里为什么要同时看 1-1-早餐/午餐/晚餐、1-2-工作日/休息日这些图的原因单看一张都会得出片面结论。6. 验证分群结果轮廓系数、CH分数与交叉统计的实操检查单模型跑完不等于可以写结论最后一步是验证。我常用的验证组合是「两个内部指标 一个外部交叉验证」。from sklearn.metrics import silhouette_score, calinski_harabasz_score # 内部指标轮廓系数与 CH 分数 sil silhouette_score(X_scaled, labels) ch calinski_harabasz_score(X_scaled, labels) print(f轮廓系数: {sil:.3f}, CH分数: {ch:.1f}) # 外部交叉各年级在每个簇中的占比 cross pd.crosstab( grade18[grade], student_features[cluster], normalizecolumns ) print(cross.round(3))轮廓系数大于 0.3 是可接受下限0.5 以上才算清晰。CH 分数没有绝对阈值它更适合横向比较不同 K 值下的相对优劣数值越大说明簇间离散度相对簇内离散度越高。交叉统计则验证业务合理性如果某个簇里大四学生占比明显偏高而这个簇的特征是周末几乎不消费那这个簇大概率对应「校外实习或租房」群体与业务直觉吻合。我在实际跑这类项目时还有一个习惯把聚类结果按学号回写到原始 consume.csv重新计算每个簇在早餐、午餐、晚餐三个窗口的真实消费分布与 1-1-早餐.png、1-1-午餐.png、1-1-晚餐.png 逐张核对。如果某个簇的画像和它在原始流水里的表现对不上那一定是特征设计或聚类 K 值出了问题要回头查不能直接写进报告。从那以后我每次做消费分群都强制走一遍「特征设计 → 因子提取 → 聚类 → 内部指标 → 原始流水回验」这五步不在中间任何一步被好看的图表糊弄过去。希望这份拆解和资源能帮你在做类似校园消费分析项目时少走几段弯路。本文还有配套的精品资源点击获取