简介:这是一份围绕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_clusters=4, init='k-means++', n_init=10, max_iter=300, random_state=42 ) labels = km.fit_predict(X_scaled) student_features['cluster'] = labels| 参数 | 值 | 作用 |
|---|---|---|
| n_clusters | 4 | 分群数,由肘部法则与轮廓系数确定 |
| init | k-means++ | 优化初始质心选择,避免局部最优 |
| n_init | 10 | 从 10 组不同初始质心出发取最优 |
| random_state | 42 | 固定随机种子,保证结果可复现 |
| max_iter | 300 | 最大迭代轮数,几千样本足够收敛 |
其中 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_clusters=k, init='k-means++', n_init=10, random_state=42 ).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', ascending=False))惯性是样本到所属质心距离的平方和,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.csv,img 目录是跑出来的图表,根目录三个 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.py:DFM因子提取与聚类的核心实现
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_factors=2): # 自动选取数值列,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_components=n_factors, random_state=42) factors = pca.fit_transform(X_std) print('因子解释方差占比:', pca.explained_variance_ratio_) return factors, scaler, pca def cluster_students(factors, n_clusters=4): km = KMeans( n_clusters=n_clusters, init='k-means++', n_init=10, random_state=42 ) 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_only=True) return profile def export_charts(profile, output_dir='img'): # 各簇日均消费条形图 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', dpi=150) 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'], normalize='columns' ) return crossmatplotlib.use('Agg') 是服务器环境必备设置。本地 Jupyter 里跑可以去掉这行,但写成脚本用命令行运行时,不设置 Agg 会直接报「无法找到显示设备」。dpi=150 是兼顾清晰度和文件大小的经验值,论文插图够用。另外 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_state=42,并显式设置 n_init=10。这个习惯要养成,不只是这个项目,任何涉及随机初始化的模型都应该固定种子。
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,算出来 CR=0.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'], normalize='columns' ) print(cross.round(3))轮廓系数大于 0.3 是可接受下限,0.5 以上才算清晰。CH 分数没有绝对阈值,它更适合横向比较不同 K 值下的相对优劣,数值越大说明簇间离散度相对簇内离散度越高。交叉统计则验证业务合理性:如果某个簇里大四学生占比明显偏高,而这个簇的特征是周末几乎不消费,那这个簇大概率对应「校外实习或租房」群体,与业务直觉吻合。
我在实际跑这类项目时还有一个习惯:把聚类结果按学号回写到原始 consume.csv,重新计算每个簇在早餐、午餐、晚餐三个窗口的真实消费分布,与 1-1-早餐.png、1-1-午餐.png、1-1-晚餐.png 逐张核对。如果某个簇的画像和它在原始流水里的表现对不上,那一定是特征设计或聚类 K 值出了问题,要回头查,不能直接写进报告。从那以后我每次做消费分群,都强制走一遍「特征设计 → 因子提取 → 聚类 → 内部指标 → 原始流水回验」这五步,不在中间任何一步被好看的图表糊弄过去。希望这份拆解和资源能帮你在做类似校园消费分析项目时少走几段弯路。
本文还有配套的精品资源,点击获取