简介:面向校园消费场景的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"], errors="coerce"),再丢掉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]再跑一遍同样的 groupby,img 里的 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, columns=cols) 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类默认就是init="k-means++",所以代码里不写也生效,但写了显式意图。
选这个模型还因为样本量在十几万条流水聚合后通常只有几千个学生,K-Means 复杂度 O(nkt) 完全够用;如果去掉聚合直接用原始流水聚类,会被学生消费次数不均带偏,所以必须先构造 DFM。
3.2 确定聚类数:轮廓系数和业务解释
聚类数不能只靠“分成四类好讲故事”后补理由。我一般先算 k=2 到 k=8 的轮廓系数,再结合每类人数占比做最终决策。轮廓系数大于 0 说明类内距离小于类间距离,0.25 以上才有分群价值。
from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score for k in range(2, 8): km = KMeans(n_clusters=k, init="k-means++", n_init=10, random_state=42) labels = km.fit_predict(X_scaled) s_score = silhouette_score(X_scaled, labels) print(f"k={k}, inertia={km.inertia_:.2f}, silhouette={s_score:.4f}")逻辑说明:fit_predict既做训练又返回每个样本的类别标签;silhouette_score需要同样本量的标签,不能只传模型;inertia_是类内平方和 SSE,手肘法看它是否出现拐点。轮廓系数和 SSE 要一起看,SSE 持续下降但轮廓系数突然掉到 0.2 以下,说明多出来的簇只是在把同一群学生切碎。
参数说明:n_init=10表示用 10 个随机中心组合分别训练,最终返回 SSE 最小的那一组;random_state=42是为了在课程答辩时能复现同样的结果。实际运行下来,这份消费数据在 k=4 时效果最均衡:轮廓系数在 0.35 左右,四类人数分别是 15%、33%、29%、23%,没有出现只有 3 个人的碎群;k=5 时轮廓系数会掉到 0.3 以下,并且分出一个“每餐都吃面包”的异常群,解释成本高。
| 类别人数占比 | 消费天数均值 | 消费笔数均值 | 月金额均值 | 早餐占比 |
|---|---|---|---|---|
| 34% | 22 | 45 | 620 | 0.28 |
| 26% | 10 | 16 | 230 | 0.12 |
| 24% | 27 | 60 | 980 | 0.35 |
| 16% | 6 | 8 | 90 | 0.05 |
上面这张表是我在本地跑出的示例,具体数值以你的数据为准,但它能解释模型输出的结构:占比最小的类是“低活跃低消费”,中间两类分别是“普通三餐型”和“高消费型”,剩下的是“饮食规律但金额不高”的群体。
3.3 建模与画像统计
确定 k=4 后,正式训练并落标签:
km_final = KMeans(n_clusters=4, init="k-means++", n_init=20, random_state=42) 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_init=20比选 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(column=col, by="cluster", ax=ax) ax.set_title(f"{col} by cluster") plt.suptitle("") plt.savefig("img/3-1.png", dpi=200, bbox_inches="tight")参数说明:savefig的dpi决定图片清晰度,课程设计报告插入 200 DPI 足够;bbox_inches="tight"会把图片裁剪到图表周围,避免大量留白。拿到 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, on="student_id", how="left") cross = pd.crosstab(merged["cluster"], merged["is_subsidized"], normalize="index") print(cross.round(3))逻辑说明:pd.crosstab(normalize="index")按行归一化,每一行加起来等于 1,可以直接看某个聚类中有多少比例的学生拿国家助学金。如果低消费类的补助比例显著高于其他类,说明 D/F/M 分群和经济状况存在相关性;如果刚好相反,说明需要剔除学生兼职在校外吃饭带来的“低食堂消费高生活费”的反例。
参数说明:how="left"保留了所有学生,即使 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", ascending=False))逻辑说明:price_score从菜品平均单价转换,单价越低分数越高;sales_score_norm用极差标准化,销量最高的窗口得 1 分;efficiency_score用高峰笔数占比直接做分数,占比越高说明窗口在高峰越拥挤,反而应该扣分或引导分流。这里的w顺序必须和判断矩阵的指标顺序一致,否则权重张冠李戴。
表格输出后可以整理成这样的运营建议:
| 窗口 | 综合分 | 建议 |
|---|---|---|
| w03 | 0.78 | 保持价格但增加备餐量,高峰分流需求大 |
| w01 | 0.71 | 价格偏高且效率低,可考虑套餐组合 |
| w04 | 0.42 | 高峰效率过低,调整出餐流程或开放预约取餐 |
| w02 | 0.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 python=3.10再 pip install,但要注意 pandas 和 scikit-learn 不要装到 base 环境,避免版本冲突。
5.2 坑点记录
第一个坑是读取 consume.csv 时中文编码报错。学校导出的 CSV 很多是 GBK,直接用 pandas 默认 UTF-8 读会直接抛UnicodeDecodeError,解决办法是pd.read_csv("data/consume.csv", encoding="gbk")或encoding="utf-8-sig"。
第二个坑是 Linux 下 matplotlib 画中文标题出现方框。项目里 img 的图片如果是在服务器上画的,需要先设置中文字体:
import matplotlib matplotlib.rcParams["font.sans-serif"] = ["Noto Sans CJK SC", "SimHei"] matplotlib.rcParams["axes.unicode_minus"] = False第三个坑是轮廓系数和手肘法结论冲突。比如 k=4 轮廓系数最高,但 SSE 累积贡献率只有 72%,此时不能只依赖轮廓系数,还要看第 3 章的人群占比是否出现小于 5% 的碎群。我一般以“SSE 拐点 + 轮廓系数 > 0.3 + 人群占比 >= 10%”三个条件同时满足为准。
5.3 快速验证单个函数
写模型前先对特征构造函数做冒烟测试,确认每条流水只归属一个学生和一个日期:
from analysis import build_dfm sample = pd.read_csv("data/consume.csv", nrows=5000, 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(),再看聚类中心的变化,避免把上一个学期的卡消费习惯直接套到下一届学生头上。
本文还有配套的精品资源,点击获取