Python+聚类+AHP:一卡通消费数据驱动的食堂用户画像与决策实践
简介面向校园消费场景的数据分析项目资源包适合Python数据分析学习者与高校后勤/学工相关研究人员。项目围绕学生消费行为与食堂运营状况展开基于DFM模型结合K-Means聚类与层次分析法对早中晚餐、工作日与休息日等多元化消费数据进行清洗与特征选择最终完成学生群体细分和经济状况判定可辅助食堂运营决策。压缩包共26个文件约20.78MB包含3个Python脚本、5个CSV数据集、8张结果图表、1份DOCX分析报告、程序运行说明及配置文件代码、数据、文档三者配套目录结构清晰便于按模块复现。已有552人学习下载适合作为课程设计、毕业设计或竞赛练手的参考资料。通过该资源可掌握DFM模型的应用方式、聚类与层次分析法的落地写法并获取一份可扩展的学生消费细分建模参考方案对开展类似校园数据分析课题具有实用价值。1. 刷卡流水里藏着食堂管理的答案拿到一卡通数据的第一反应往往是统计每天营业额然后再画一张折线图了事。但把三个月的流水按人切开后会发现真正值钱的信息在消费结构里谁经常不吃早饭、谁周末也泡在学校、谁每顿饭都固定在同一窗口。这个基于 Python 的 DFM 模型其实就是 Data-Feature-Model 的三层流程先把流水加工成特征再用 K-Means 聚类找到人群最后用层次分析法给群体打综合分。它适合数据分析师、高校信息化人员也适合想用真实业务场景练手的中级 Python 工程师。环境不需要大数据框架pandas 加 scikit-learn 就能跑起来requirements.txt 列得清清楚楚。2. DFM 第一步消费流水清洗与特征工程很多人拿到 consume.csv 后直接按学号求和然后丢给 KMeans结果聚类出来的群体和食堂运营对不上。原因很简单原始流水是一行一个事件聚类需要一个样本一个标签中间必须把事件流压缩成人的行为特征。DFM 里的 Data 阶段任务不是画图而是把时间、窗口、金额拆成能吃进模型的数值特征。2.1 原始数据结构和合并策略项目里的 consume.csv 是逐笔消费流水字段一般包括学号、交易时间、窗口名称、金额grade18.csv 提供年级和院系。data1.csv、data3.csv、data2.csv 大概率是不同清洗阶段导出的中间表实际模型读取的主力是前面两份。下面这张字段表在项目里最常见字段示例值在建模中的作用student_id20210101关联基础信息聚合主键trade_time2023-05-12 12:23:41提取餐次、工作日/休息日属性canteen二食堂一楼计算窗口黏性amount9.50金额统计主项grade2021级分层验证用不直接入特征import pandas as pd consume pd.read_csv(data/consume.csv, parse_dates[trade_time]) student pd.read_csv(data/grade18.csv) df consume.merge(student, onstudent_id, howleft) # 去掉退款与明显异常金额 df df[(df[amount] 0) (df[amount] 100)]amount 100是为了排除一卡通充值和误刷整百的记录。退款记录在这里直接过滤而不是用负金额参加聚合。如果某些学校把食堂和超市放在同一张流水里还要先按窗口名称或商户编码把餐饮行为过滤出来这一步漏掉的话聚类结果会被经常买饮料的人带偏。过滤后的数据不要急着聚合先检查每个学生的有效记录数低于 5 条的用户参与聚类没有统计意义通常直接剔除。2.2 餐次标记与时间特征一卡通交易的时间戳含有强信号。一个每天 11:45 出现在窗口的人作息规律一个经常 18:30 后才来的人可能晚自习后顺路吃饭。常见的做法是把一天切成早餐、午餐、晚餐和夜宵四段切分阈值需要和学校作息对齐机械地固定成 7/12/18 会误分类很多边缘记录。def mark_meal(df): hour df[trade_time].dt.hour df[meal] 夜宵 df.loc[(hour 6) (hour 10), meal] 早餐 df.loc[(hour 10) (hour 15), meal] 午餐 df.loc[(hour 16) (hour 20), meal] 晚餐 return df df mark_meal(df) df[date] df[trade_time].dt.date df[is_weekend] df[trade_time].dt.dayofweek.isin([5, 6]).astype(int)午餐区间设到 15:00 而不是 13:00是为了把第 5 节才有空的学生保留在午餐群体里。is_weekend用 5 和 6 判断是因为 Python 的dayofweek里周一为 0周六为 5。如果学校有调休安排节假日换课会打乱这个字段需要再用教务日历做一张补丁表。标记完成后抽查一个学生的流水按餐次分组打印时间分布确认没有出现大量跨时段记录。2.3 按人聚合消费特征特征工程最容易踩的坑是分母。直接对金额求平均会把没消费的日期排除导致高估日均支出所以要先按学号和日期做一层日级聚合再按学号聚合回人的特征。这一步做扎实后续聚类和层次分析才不会失真。daily (df.groupby([student_id, date]) .agg(day_amount(amount, sum), day_cnt(trade_time, size)) .reset_index()) person (daily.groupby(student_id) .agg(avg_day_amount(day_amount, mean), meal_times(day_cnt, mean))) rate (df.groupby(student_id) .apply(lambda g: pd.Series({ breakfast_rate: (g[meal] 早餐).mean(), weekend_share: g[is_weekend].mean(), window_share: g[canteen].value_counts(normalizeTrue).iloc[0]})) features person.join(rate)avg_day_amount是日消费均值分母是实际有消费的天数更适合描述“每顿饭花多少”meal_times是平均每天刷几笔反映就餐频率breakfast_rate是早餐记录占总记录的比例注意这是按“笔数”算的不是按“天数”算的两种口径会导致完全不同的分布选一种后要固定不变。window_share取的是这个学生流水里出现次数最多的窗口占比用来度量消费黏性。这段代码如果在十万行以上跑得偏慢可以把groupby.apply换成先对meal做 pivot_table 再 merge速度能提升一个数量级。聚合完成之后检查features.describe()如果某个特征的中位数和均值差了两倍以上就要考虑先做 log1p 平滑否则后续 K-Means 的中心点会被少数人拽走。3. K-Means 聚类把学生群体分开的代码实现K-Means 本身不算新技术但在这个项目里用 K-Means 而不是原始 K-Means是有具体理由的。普通 K-Means 随机初始化中心点数据一旦包含日均消费 80 元以上的离群学生中心点可能每次都落在不同位置导致同一份数据跑十次出十种分法。K-Means 通过按距离平方加权的概率选择初始中心能让初始中心彼此远离配合n_init10后聚类稳定性明显提升。3.1 标准化、聚类与 K 值选择消费特征的量纲差异太大日均金额可能是 5 到 50早餐率则是 0 到 1。如果不做归一化欧氏距离几乎由金额决定早餐率等于没进模型。这里用StandardScaler做 z-score 标准化因为它对离群点的容忍度优于 MinMax而消费流水恰好又常有极端值。from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score import numpy as np feature_cols [avg_day_amount, meal_times, breakfast_rate, weekend_share, window_share] X features[feature_cols].fillna(0) scaler StandardScaler() X_scaled scaler.fit_transform(X) ks range(2, 7) scores [] for k in ks: model KMeans(n_clustersk, initk-means, n_init10, max_iter500, random_state42) labels model.fit_predict(X_scaled) scores.append(silhouette_score(X_scaled, labels)) best_k ks[int(np.argmax(scores))] final_model KMeans(n_clustersbest_k, initk-means, n_init10, max_iter500, random_state42) final_labels final_model.fit_predict(X_scaled) features[cluster] final_labelsn_init10的含义是让每个 K 值下用 10 组不同的初始中心分别跑最后保留目标函数最小的那组。max_iter500是单次迭代上限对几十万行、十维以内的数据通常几十步就收敛不需要刻意调大。random_state42保证结果可复现否则每次运行所得群体标签顺序会变给后续写报告带来麻烦。轮廓系数在这个项目中通常在 0.25 到 0.45 之间。如果低于 0.2先检查特征里是否有完全相关的两列如果高于 0.55要怀疑是不是同一个人的消费记录被重复计算导致簇之间靠重复样本硬分开。除了轮廓系数还可以把每个 K 值下模型里的inertia_画成折线图拐点出现的位置和轮廓系数峰值越接近选出来的 K 越可靠。3.2 确定 K4 后的群体画像项目里最终选择 K4对应四种具有明确业务含义的人群。下面的中心点数值只是示意口径实际运行时会随学校和时间窗口变化但观察指标的逻辑是一致的簇日均金额元日均餐次早餐率周末占比业务标签018.62.10.620.48规律健康型133.52.80.240.67高消费型212.21.60.180.39节约型322.43.40.550.52高频大众型看表的顺序不是“哪个金额最高哪个就值得关注”而是同时看早餐率和周末占比。比如高消费型早餐率只有 0.24说明他们有相当比例的人把早餐和午餐合并了食堂早餐档口在这类人所在的宿舍区可以收缩规模。节约型早餐率 0.18周末占比 0.39接下来第 4 章的经济关注线就要重点落在这一簇。3.3 把聚类结果还原成业务语言聚类完成后一定要用scaler.inverse_transform(final_model.cluster_centers_)把中心点还原到原始量纲否则数字再漂亮食堂经理也看不懂 1.3 个标准差是什么意思。还原后找几个该簇的典型学生拉出他们的原始流水逐条看一下确认分群是否符合常识。项目里 img 目录下的 1-1-早餐.png、1-2-工作日.png、3-1.png 就是这样生成的探索图用来在评审时帮非技术角色理解模型质量。如果发现某个簇里同时出现几乎每天只吃一顿饭和一天吃四顿饭的人说明这组特征对“消费频次”的区分度不够需要把meal_times换成“每周平均消费天数”再跑一次。4. 层次分析法给群体画像打分经济状况与消费习惯建模聚类给出的标签只有类别差异没有程度差异。学校学生处需要的不是“第二簇是节约型”而是“这类学生的经济压力有多大需不需要重点关注”。同一个节约型群体里有人是因为有意识控制开支有人是因为生活费有限仅靠聚类无法区分。项目引入层次分析法来解决这个问题把聚类的特征映射成一个可解释的加权指数让“经济状况”这个模糊概念变成可比较的数值。4.1 为什么在这个场景里选 AHP数据驱动的替代方案很多比如直接跑逻辑回归或者随机森林但这类模型需要有标注的“困难生”历史数据大多数学校没有一份干净可信的标注集。层次分析法则不需要历史标注它只需要管理者对指标两两比较给出相对重要性。食堂关心窗口黏性学生处关心月消费总额财务处关心日均餐次AHP 可以把这些来自不同角色的判断统一成一个权重向量。和熵权法相比AHP 的权重来自业务经验而不是数据方差解释起来不需要绕弯评审会上更容易被接受。4.2 判断矩阵与一致性检验以下用 4 个准则构造消费能力指数日均金额、日均餐次、早餐率、周末占比。判断矩阵第一行[1, 3, 2, 5]表示日均金额比日均餐次稍微重要比早餐率介于稍微与明显重要之间比周末占比强烈重要。具体比例从 1 到 9 的标度3 表示稍微重要5 表示明显重要。import numpy as np def ahp_weight(matrix): arr np.array(matrix, dtypefloat) n arr.shape[0] # 几何平均近似计算权重 weights np.prod(arr, axis1) ** (1 / n) weights weights / weights.sum() # 计算最大特征值和一致性比率 aw arr weights lambda_max (aw / weights).mean() ci (lambda_max - n) / (n - 1) cr ci / 0.89 # 4阶矩阵的RI值为0.89 return weights, lambda_max, cr matrix [ [1, 3, 2, 5], [1/3, 1, 1/2, 2], [1/2, 2, 1, 3], [1/5, 1/2, 1/3, 1], ] weights, lam, cr ahp_weight(matrix) print(权重:, np.round(weights, 4)) print(CR:, round(cr, 4))ci / 0.89里的 0.89 是 4 阶随机一致性指标 RI 的查表值如果是 3 阶矩阵RI 是 0.585 阶是 1.12。CR 小于 0.1 代表判断矩阵没有明显矛盾这份代码接受的判断矩阵。如果 CR 大于 0.1就说明某个比较关系有冲突比如既说日均金额比餐次重要又让餐次的权重实际高过金额。修改矩阵时不要只动数字要记录是谁提出这个比较结果方便评审时追溯。矩阵阶数123456RI000.580.891.121.244.3 综合得分与学生经济关注线得到权重后把特征 z-score 化再加权求和得到每个学生的economic_index。这里不能直接用原始金额和权重相乘因为金额的量纲天然比早餐率大会导致权重失去意义。有了指数后再叠加聚类结果做判定只有落在节约型簇、且economic_index低于整体 25% 分位的学生才进入重点关注名单。from scipy.stats import zscore for col in [avg_day_amount, meal_times, breakfast_rate, weekend_share]: features[col _z] zscore(features[col].fillna(0)) economic_index (weights[0] * features[avg_day_amount_z] weights[1] * features[meal_times_z] weights[2] * features[breakfast_rate_z] weights[3] * features[weekend_share_z]) features[economic_index] economic_index low_cut economic_index.quantile(0.25) features[need_attention] ((features[cluster] 2) (economic_index low_cut)).astype(int)need_attention是作为参考意见不是最终结论。真正提交给学校前还要把need_attention1的学生与勤工助学系统、餐饮补贴记录做匹配。如果一个学生长期在食堂消费但金额很低、早餐记录也少同时没有任何资助记录才值得辅导员线下了解。这样既保留了数据模型的客观性也避免了单纯依赖分数造成误判。批次预测时可以通过新计算出的need_attention占比环比发现是否有新增关注群体比重新跑一遍聚类更快。5. 从模型到决策食堂运营优化与模型稳定性验证最后这步是把训练好的模型用起来并确保它在下一学期还能继续工作。项目里的 analysis.py 已经完成画像图但真正给后勤部门看的时候要输出的是“几点哪个食堂拥堵”和“哪个窗口该增加出餐位”这类动词化建议。5.1 用模型预测新流水新数据来了以后不要重新训练先调用已保存的 scaler 和 model 做批次预测。这样可以保证新旧学生的经济指数在同一尺度可比。new_data pd.read_csv(data/data3.csv) new_scaled scaler.transform(new_data[feature_cols].fillna(0)) new_labels final_model.predict(new_scaled)scaler.transform用的是训练时的均值和标准差不是新数据自己的这点一定要确认。如果新手对归一化概念不熟很容易在预测时调用fit_transform导致新旧样本尺度不一致。5.2 食堂运营的三个可直接落地建议从午餐高峰时段分布看11:30 到 12:30 的窗口排队压力集中在高频大众型群体上对比 img 目录里的 2-1.png 和 2-2.png 可以发现周末高消费型群体的消费时段明显后移。针对这类“周末晚起”人群早餐档口可以延迟到 10:00 收档同时增加早午餐套餐。对节约型群体食堂可以提供 8 元档固定套餐用低价高饱腹组合替换目前的散称菜。对规律健康型群体保留现有清淡窗口他们的消费黏性高价格调整要谨慎。5.3 模型漂移监控如果每隔半年跑一次轮廓系数发现从 0.36 降到 0.28 以下说明校园消费结构变了需要重新聚类。常见触发原因是新宿舍区启用或外卖平台进入校园。监控脚本周期的常见配置是每月一次只算新样本的轮廓系数不重新训练。同时检查每个簇人数占比当某一簇占比从 25% 漂移到 40%就要在例会上提出重新训练。如果环境还没有装好先安装 Python 3.8 以上版本再执行pip install -r requirements.txt注意 scikit-learn 版本不要低于 1.2否则initk-means的行为在一些版本上有差异。运行项目根目录下的model.py可以一次性输出聚类标签和 AHP 权重analysis.py负责生成 img 目录里的 1-1-午餐、2-1 等对比图。本文还有配套的精品资源点击获取