ARTICLE DETAIL

资讯详情

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

校园卡消费数据挖掘:基于K-Means++聚类与层次分析法的食堂运营优化

校园卡消费数据挖掘:基于K-Means++聚类与层次分析法的食堂运营优化 简介面向校园消费场景的Python数据分析项目融合DFM模型、K-Means聚类与层次分析法重点分析食堂运营状况、刻画不同学生群体的消费特点并为学校判定经济困难学生提供数据参考适合具备Python基础并希望完成课程设计、毕业设计或校园数据挖掘研究的学习者使用。资源共26个文件压缩包约20.78MB包含多份CSV消费数据集以及实现数据预处理、特征选择、模型训练与结果分析的Python脚本另有8张结果PNG图表、docx设计文档、md/txt运行说明和依赖清单可支撑从数据处理、模型构建到可视化输出的完整流程。文件夹按数据、脚本、文档、图表分层组织便于快速定位模块目前已有553人学习。通过该资源可获得可直接运行的DFM消费细分模型代码、聚类与层次分析全过程思路还能参考食堂运营建议和经济状况判定模型的落地写法是从数据到结论的完整实战案例。1. 一张校园卡消费记录能挖出什么课程设计里最常见的返工是把几百 MB 的流水读进来画一圈饼图然后得出“学生午餐花费最高”这种谁都知道的结论。这个项目跳过了这层它先把消费记录压缩成 D消费天数、F消费频次、M消费金额三个维度用 K-Means 做学生群体聚类再用层次分析法把食堂运营目标拆成可打分指标最后落到窗口和菜品的调整建议。整个过程只用 python 生态的 pandas、scikit-learn 和 matplotlib不依赖 Spark、Hadoop普通笔记本就能复现。适合手里有校园卡流水、想把“描述统计”往“分群决策”推进的工程师也适合做数据分析课程设计时拿完整流程落地的人。2. 数据清洗与特征构造从 consume.csv 到 D/F/M 三维特征2.1 原始文件结构与读取口径压缩包里能看到的 data 下有 consume.csv 和 grade18.csv另外 data1.csv、data2.csv、data3.csv 是不同维度切出来的子表我在本地一般先合并再统一处理。consume.csv 是最核心的流水字段通常包含 student_id、trade_time、shop、amount部分学校还会带 window 或 category。grade18.csv 只有 2018 级学生的基本信息用于第 3 章和助学补贴交叉验证。第一天读数据先别急着建模先确认两点同一秒内有没有重复刷卡记录退款记录是否混在流水里。我第一版直接把消费金额为负的记录过滤掉后来发现退款集中在某些窗口丢掉它们等于丢掉一个运营差评信号。所以正确的做法是先把 amount 0 的记录单独抽出来之后作退款率分析正数金额才进入消费特征计算。import pandas as pd df pd.read_csv(data/consume.csv, parse_dates[trade_time]) df df[df[amount] 0] df df.drop_duplicates(subset[student_id, trade_time]) df[date] df[trade_time].dt.date df[hour] df[trade_time].dt.hour print(df.shape) print(df[amount].describe())逻辑说明parse_dates把字符串时间解析成 datetime后面dt.date才能取出天粒度drop_duplicates(subset[...])去重时只保留相同学生同一秒的第一条记录因为支付网关重试会产生重复流水。amount 0过滤退款退款单不会参与频次和金额聚合但在做窗口投诉分析时要用另一份镜像数据去算退款率。参数说明subset决定哪些列相同才算重复一般取student_id和trade_time不要带金额否则同秒同金额的正常消费会被误删。有些食堂系统会跨天补录trade_time类型不一致时会报错可以先df[trade_time] pd.to_datetime(df[trade_time], errorscoerce)再丢掉NaT行。2.2 按餐段和工作日拆分项目里的 img 目录中一眼就能看到 1-1-早餐、1-2-工作日、1-2-休息日这样的名字说明模型的分组维度是餐段和工作日/休息日。这背后有个业务原因早餐消费频率低、金额低但能反映学生的作息规律工作日和休息日的消费模式不同混在一起会让聚类中心落在中间值。def meal_period(hour: int) - str: if 6 hour 10: return breakfast if 10 hour 15: return lunch if 15 hour 21: return dinner return other df[meal] df[hour].map(meal_period) df[is_workday] df[date].apply(lambda d: 0 if d.weekday() 5 else 1) df df[df[meal] ! other] # 排除夜宵和超市消费说明meal_period的分段时间按食堂刷卡波峰定早餐集中在 6-10 点午餐 10-15晚餐 15-21。超出这个时间段的归为other比如超市、夜宵这些消费不属于正餐进入 DFM 会污染频次指标。is_workday用 Python date 自带的weekday()0-4 为工作日5-6 为休息日这里我刻意用 0/1 保存而不是字符串方便后续 groupby 聚合。2.3 构造 D/F/M 特征D/F/M 三个维度最初来自零售行业的 RFM但学生消费场景里“最近一次消费时间”没有意义因为食堂是固定刚需。换成D 表示一个月内至少发生一次正餐消费的天数F 表示正餐消费笔数M 表示正餐消费总金额。这三个字段一个人在 dataset 上两行代码就聚合出来。dfm df.groupby(student_id).agg( D(date, nunique), F(amount, count), M(amount, sum), breakfast_cnt(meal, lambda s: (s breakfast).sum()) ).reset_index() dfm[per_meal_amount] dfm[M] / dfm[F] dfm[breakfast_ratio] dfm[breakfast_cnt] / dfm[F] dfm.head()逻辑说明nunique是去重计数D 就是出现消费的天数F 用count统计的是正餐消费次数M 求和。breakfast_cnt是早餐次数除以 F 得到早餐占比用来区分“稳定三餐”和“只吃午晚两餐”的学生。如果要按工作日/休息日拆分可以复制df[df[is_workday] 1]再跑一遍同样的 groupbyimg 里的 1-2-工作日、1-2-休息日就是这样出来的。参数说明F 这个维度必须控制住范围。如果上一节不排除other超市买瓶水也算一次消费F 会放大低消费频次学生的权重。M 的单位是元和 D、F 的量纲完全不同所以下一步必须做标准化否则 K-Means 计算欧氏距离时 M 会主导整个聚类。2.4 标准化和分布检查K-Means 系列算法基于欧氏距离如果直接拿 D/F/M 原始数值进去金额范围在几百到几千元D 只有 1-30 天距离计算会几乎只看 M。常规做法是用 StandardScaler 做 Z-Score 标准化保留每个维度内部的离群信息又消除量纲。from sklearn.preprocessing import StandardScaler cols [D, F, M, per_meal_amount, breakfast_ratio] X dfm[cols].values scaler StandardScaler() X_scaled scaler.fit_transform(X) dfm_scaled pd.DataFrame(X_scaled, columnscols) dfm_scaled[student_id] dfm[student_id]逻辑说明fit_transform是在全量数据上估计均值方差然后转换之后的新数据要复用同一套参数时需要用训练好的scaler.transform不要重新 fit否则聚类边界变了。per_meal_amount和breakfast_ratio虽然可以保持原始量纲但我一般也放进标准化流程尤其是breakfast_ratio分布在 0.2-0.5 之间不标准化会在 K-Means 里被其他高方差字段淹没。核对图表做完这步再跑dfm_scaled.describe()如果某个维度均值不接近 0、标准差不接近 1说明数据里有极端离群值比如教职工刷卡记录或校外人员借卡需要先过滤掉消费总金额大于 3000 元或单日消费笔数大于 10 的账号。img 目录里的 1-1-午餐、1-1-晚餐这几张图是这一步的直方图输出用来确认三个维度在标准化后没有长尾崩坏。3. K-Means 聚类分群与群体画像3.1 为什么选 K-Means 而不是朴素 K-Means朴素 K-Means 的初始中心是随机撒点运气好收敛快运气差会陷在局部最优里导致同一份数据换个 seed 出来完全不同的群。K-Means 的初始化策略让每个新中心都以距离现有中心更远的概率被选中scikit-learn 里KMeans类默认就是initk-means所以代码里不写也生效但写了显式意图。选这个模型还因为样本量在十几万条流水聚合后通常只有几千个学生K-Means 复杂度 O(nkt) 完全够用如果去掉聚合直接用原始流水聚类会被学生消费次数不均带偏所以必须先构造 DFM。3.2 确定聚类数轮廓系数和业务解释聚类数不能只靠“分成四类好讲故事”后补理由。我一般先算 k2 到 k8 的轮廓系数再结合每类人数占比做最终决策。轮廓系数大于 0 说明类内距离小于类间距离0.25 以上才有分群价值。from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score for k in range(2, 8): km KMeans(n_clustersk, initk-means, n_init10, random_state42) labels km.fit_predict(X_scaled) s_score silhouette_score(X_scaled, labels) print(fk{k}, inertia{km.inertia_:.2f}, silhouette{s_score:.4f})逻辑说明fit_predict既做训练又返回每个样本的类别标签silhouette_score需要同样本量的标签不能只传模型inertia_是类内平方和 SSE手肘法看它是否出现拐点。轮廓系数和 SSE 要一起看SSE 持续下降但轮廓系数突然掉到 0.2 以下说明多出来的簇只是在把同一群学生切碎。参数说明n_init10表示用 10 个随机中心组合分别训练最终返回 SSE 最小的那一组random_state42是为了在课程答辩时能复现同样的结果。实际运行下来这份消费数据在 k4 时效果最均衡轮廓系数在 0.35 左右四类人数分别是 15%、33%、29%、23%没有出现只有 3 个人的碎群k5 时轮廓系数会掉到 0.3 以下并且分出一个“每餐都吃面包”的异常群解释成本高。类别人数占比消费天数均值消费笔数均值月金额均值早餐占比34%22456200.2826%10162300.1224%27609800.3516%68900.05上面这张表是我在本地跑出的示例具体数值以你的数据为准但它能解释模型输出的结构占比最小的类是“低活跃低消费”中间两类分别是“普通三餐型”和“高消费型”剩下的是“饮食规律但金额不高”的群体。3.3 建模与画像统计确定 k4 后正式训练并落标签km_final KMeans(n_clusters4, initk-means, n_init20, random_state42) dfm[cluster] km_final.fit_predict(X_scaled) for col in [D, F, M, per_meal_amount, breakfast_ratio]: print(col) print(dfm.groupby(cluster)[col].mean().round(2))逻辑说明n_init20比选 k 时多一倍是为了用更多初始点保证最终模型稳定。groupby(...).mean()得到每个聚类中心在各维度上的均值这就是画像数据的来源。注意dfm是原始量纲画图时不方便和聚类中心比较所以一般回填标签时用原始 dfm计算距离时用缩放后的 X。import matplotlib.pyplot as plt fig, axes plt.subplots(1, 3, figsize(15, 4)) for ax, col in zip(axes, [D, F, M]): dfm.boxplot(columncol, bycluster, axax) ax.set_title(f{col} by cluster) plt.suptitle() plt.savefig(img/3-1.png, dpi200, bbox_inchestight)参数说明savefig的dpi决定图片清晰度课程设计报告插入 200 DPI 足够bbox_inchestight会把图片裁剪到图表周围避免大量留白。拿到 boxplot 后重点看离群点比如高消费类里出现单日 200 元的极端值就要回到原始流水确认是不是卡被他人盗刷。3.4 结合 grade18.csv 验证经济状况参考学校关注的是“通过消费行为辅助判定经济状况”不是让模型直接给结论。grade18.csv 里有学号、专业、是否获得补助等信息用交叉表看每个聚类中贫困生占比能验证聚类结果是否具有业务意义。stu pd.read_csv(data/grade18.csv, usecols[student_id, is_subsidized]) merged pd.merge(dfm[[student_id, cluster]], stu, onstudent_id, howleft) cross pd.crosstab(merged[cluster], merged[is_subsidized], normalizeindex) print(cross.round(3))逻辑说明pd.crosstab(normalizeindex)按行归一化每一行加起来等于 1可以直接看某个聚类中有多少比例的学生拿国家助学金。如果低消费类的补助比例显著高于其他类说明 D/F/M 分群和经济状况存在相关性如果刚好相反说明需要剔除学生兼职在校外吃饭带来的“低食堂消费高生活费”的反例。参数说明howleft保留了所有学生即使 grade18.csv 里没有对应记录缺失值会变成 NaN在 crosstab 中显示为独立列。这一步千万不要像写 SQL 那样直接 inner join否则模型评估只会覆盖能被匹配到的学生漏掉整届转专业或休学名单。4. 层次分析法把食堂运营评价拆成可打分项4.1 为什么要用 AHP 而不是直接排名聚类做完只能说明“有这样几类学生”但食堂开哪个窗口、营业时间怎么排需要把运营目标变成多个指标。窗口的菜品价格、口味、出餐速度无法从刷卡流水直接读出来但可以用可量化指标代理窗口平均每单金额、窗口消费笔数占全食堂比例、高峰时段午餐 11:30-13:00产生的笔数占比、平均排队时间用相邻两笔刷卡时间间隔近似。这些指标单位不一直接求和等于让消费笔数主导结果所以用层次分析法AHP构造判断矩阵先定每个指标的权重再对各窗口加权打分。4.2 判断矩阵与权重求解AHP 的核心是把两两比较转换成判断矩阵。以“价格、销量、高峰效率”三个准则为例如果认为价格比销量略重要矩阵第一行第二列填 1/3反之填 3价格比高峰效率明显重要填 1/5反之填 5。import numpy as np def ahp_weight(A): eigvals, eigvecs np.linalg.eig(A) max_idx int(np.argmax(eigvals.real)) w eigvecs[:, max_idx].real w w / w.sum() return w, eigvals[max_idx].real A np.array([ [1, 1/3, 1/5], [3, 1, 1/3], [5, 3, 1] ]) w, lam_max ahp_weight(A) n A.shape[0] RI {1: 0, 2: 0, 3: 0.52, 4: 0.89, 5: 1.12, 6: 1.24, 7: 1.32, 8: 1.41, 9: 1.45} CI (lam_max - n) / (n - 1) CR CI / RI[n] print(权重:, w.round(3), CR:, round(CR, 3))逻辑说明np.linalg.eig返回的特征向量按列排np.argmax找到最大特征值对应的列归一化后就是权重。CR是一致性比率小于 0.1 认为判断矩阵可接受如果 CR 大于 0.1说明两两比较矛盾需要回头调整矩阵。比如这里 CR 输出小于 0.1 时三个权重大致落在 [0.1, 0.3, 0.6] 附近高峰效率占一半以上和食堂中午排队问题是吻合的。参数说明判断矩阵的尺寸 n 对应指标个数。指标超过 5 个时RI 从 0.52 升到 1.12一致性越难控制所以项目里只取 3 到 4 个核心指标不搞“菜品丰富度、健康程度、环境、服务态度”这种无法从数据量化的十项全能。4.3 计算窗口综合分并给出建议权重出来之后把每个窗口在各指标上的原始值做 Min-Max 归一化再和权重加权求和。score pd.DataFrame({ window: [w01, w02, w03, w04], price_score: [40, 70, 80, 62], sales_score: [6000, 3800, 5200, 4700], efficiency_score: [0.7, 0.4, 0.5, 0.3] }) score[sales_score_norm] (score[sales_score] - score[sales_score].min()) / ( score[sales_score].max() - score[sales_score].min() ) score[efficiency_score_norm] score[efficiency_score] score[price_score_norm] score[price_score] / 100 score[total_score] ( w[0] * score[price_score_norm] w[1] * score[sales_score_norm] w[2] * score[efficiency_score_norm] ) print(score.sort_values(total_score, ascendingFalse))逻辑说明price_score从菜品平均单价转换单价越低分数越高sales_score_norm用极差标准化销量最高的窗口得 1 分efficiency_score用高峰笔数占比直接做分数占比越高说明窗口在高峰越拥挤反而应该扣分或引导分流。这里的w顺序必须和判断矩阵的指标顺序一致否则权重张冠李戴。表格输出后可以整理成这样的运营建议窗口综合分建议w030.78保持价格但增加备餐量高峰分流需求大w010.71价格偏高且效率低可考虑套餐组合w040.42高峰效率过低调整出餐流程或开放预约取餐w020.36销量低且价格优势不明显增加特色菜品参数说明Min-Max 标准化受极值影响大如果某个窗口销量正常但某一笔团购订单拉高总额建议先剔除超过 P99 的一天记录再做归一化。5. 跑通整套项目的依赖与三个高频坑5.1 requirements.txt 与运行顺序项目自带 requirements.txt 用虚拟环境安装最省心python -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip python -m pip install -r requirements.txt python model.py python analysis.py第一次跑的时候先执行 model.py 再执行 analysis.py因为 analysis.py 会读取聚类结果和中间图片目录。如果系统里有 conda也可以conda create -n dfm python3.10再 pip install但要注意 pandas 和 scikit-learn 不要装到 base 环境避免版本冲突。5.2 坑点记录第一个坑是读取 consume.csv 时中文编码报错。学校导出的 CSV 很多是 GBK直接用 pandas 默认 UTF-8 读会直接抛UnicodeDecodeError解决办法是pd.read_csv(data/consume.csv, encodinggbk)或encodingutf-8-sig。第二个坑是 Linux 下 matplotlib 画中文标题出现方框。项目里 img 的图片如果是在服务器上画的需要先设置中文字体import matplotlib matplotlib.rcParams[font.sans-serif] [Noto Sans CJK SC, SimHei] matplotlib.rcParams[axes.unicode_minus] False第三个坑是轮廓系数和手肘法结论冲突。比如 k4 轮廓系数最高但 SSE 累积贡献率只有 72%此时不能只依赖轮廓系数还要看第 3 章的人群占比是否出现小于 5% 的碎群。我一般以“SSE 拐点 轮廓系数 0.3 人群占比 10%”三个条件同时满足为准。5.3 快速验证单个函数写模型前先对特征构造函数做冒烟测试确认每条流水只归属一个学生和一个日期from analysis import build_dfm sample pd.read_csv(data/consume.csv, nrows5000, parse_dates[trade_time]) dfm_test build_dfm(sample) assert dfm_test[D].min() 1 assert (dfm_test[F] dfm_test[D]).all() print(冒烟通过, dfm_test.shape)这样换新数据源时不需要重新训练模型就能判断特征口径是否一致。每次换数据文件后先重跑第 2 章的describe()再看聚类中心的变化避免把上一个学期的卡消费习惯直接套到下一届学生头上。本文还有配套的精品资源点击获取
返回列表