简介:用于 Python 毕业设计的学生校园消费行为分析项目包,面向需要完成数据挖掘、数据可视化或个人消费场景调研题目的高校学生及开发者。项目围绕校园消费场景,覆盖数据清洗、学生表与消费记录关联、食堂就餐人数与时间分布、不同性别与专业的人均消费及刷卡频次等分析环节,从原始数据到分析结论形成完整链路,可在毕设选题、课程报告或数据分析练习中直接套用。压缩包共 30 个文件,整包约 21.28MB,其中 py 脚本承担各阶段处理与统计任务,csv 文件存有原始数据表,png 图片保存量化的可视化结果,docx 为配套分析报告,结构清晰便于按步骤查阅。已有 1067 人浏览学习,可作为同类选题的参考方向之一。读者可获得可直接运行的 Python 代码、可复用的数据清洗与关联思路、分析结果的图表与文字报告,也可借助其中食堂场景和消费视角快速理解项目流程,在答辩讲解、论文撰写及后续功能扩展中省去从零搭建环境的成本。
1. 学生校园消费行为:Python毕设真正难的不是模型,是数据源
跟风选 Python 做学生校园消费行为分析,最大的翻车点不在算法,而在数据源。校园一卡通流水属于敏感数据,绝大多数情况下拿不到真实记录,所以一套完整可复现的毕设路径是:先用模拟器生成贴近真实作息的一卡通流水,再做清洗与特征工程,最后用 RFM、KMeans 和关联规则把「谁在乱花钱」「夜宵党有什么共性」变成可视化的图表。本文按一个学期、400 名学生、约 22 万条流水这个规模来组织,覆盖环境配置、数据生成、特征构建、建模验证与答辩演示,新手可以照着跑完,熟手可以直接偷参数。适合正在做毕业设计但卡在数据获取和后期建模的同学,也适合想了解消费行为分析完整流程的从业者。
2. 搭出Python项目骨架:环境配置、目录约定与最小可跑项目
2.1 python数据分析与可视化的环境怎么选:先定版本再装依赖
python 安装教程和 pycharm 配置 python 环境这类入门文章很多,毕设真正卡人的是依赖版本互相打架。我的建议是 Python 3.10 或 3.11,不要追 3.13 最新版,pandas、scikit-learn 这类库在新版本上的二进制包偶尔会慢半拍。虚拟环境用 venv 就够,常见做法是python -m venv .venv,不必上 conda——你答辩时老师的机器上不一定有 conda,但任何一台装了 Python 的机器都能跑 venv。如果是在实验室 Linux 服务器上跑,同样是python3 -m venv,不需要单独编译源码。
依赖清单建议直接写进 requirements.txt,方便答辩前在新机器上复现:
cat > requirements.txt <<'EOF' pandas>=2.0 numpy>=1.24 matplotlib>=3.7 pyecharts>=2.0 scikit-learn>=1.3 mlxtend>=0.22 openpyxl>=3.1 EOF参数说明:pandas 2.0 之后 copy-on-write 行为有变化,网上很多老教程的切片写法会报警告;pyecharts 2.x 和 1.x 的 import 路径完全不同,网上大量教程还在用from pyecharts import Bar,那是 1.x 写法,装 2.x 后直接报错,认准from pyecharts.charts import Bar就不会混。mlxtend 是关联规则 Apriori 的库,装它主要是为了最后一章那个加分项。openpyxl 是 pandas 读写 Excel 用的,有的学校要求最终报告附 csv 或 xlsx 附件,提前装上省得答辩前临时补。
2.2 数据目录约定:为什么 data/raw 必须跟代码分离
第一次做这种项目的人最容易把 csv 和脚本堆在一个文件夹里,跑几天之后自己都分不清哪个是原始数据哪个是中间结果。我一般按这个结构组织:
mkdir -p campus_consumption/{data/{raw,processed},scripts,notebooks,output}对应的目录职责:data/raw放模拟生成的一卡通流水原始文件,生成一次就不再手改;data/processed放清洗后的宽表,例如按学号聚合的 RFM 特征表;scripts放所有可重复执行的 .py 文件;notebooks放探索性分析的草稿;output放图表和报告。
这个约定有个实际好处:答辩时老师问「这个结果怎么来的」,你只需要说「删掉 processed,跑一遍 scripts 里的 build 脚本,从 raw 到最终图表全部重建」。如果数据和代码缠在一起,你很难说出这句话。注意 Windows 的 cmd 和 PowerShell 不认 bash 的花括号展开,逐条 mkdir 就行,这不是玄学,是 shell 差异。
2.3 最小验证命令:跑通后再写业务代码
装完依赖先别急着写分析,用一条命令确认环境是活的:
python -c "import pandas, sklearn, pyecharts, mlxtend; print('env ok')"能打印env ok再往下走。这一步能省下大量排查时间——很多时候数据脚本写好了才发现 pyecharts 没装上,回头装完版本又变了。如果 import 阶段就报错,优先看是不是同时存在多个 Python 解释器,常见做法是pip -V看路径,确认和python指向同一个环境。
3. 数据从哪来:用模拟脚本生成一卡通消费流水,别指望爬虫
3.1 为什么常见做法是模拟数据而不是 python 爬虫
很多同学拿到题目第一反应是写 python 爬虫去抓数据,但实际上校园一卡通流水属于个人敏感数据,爬虫拿不到,硬抓还可能涉及隐私问题。毕设数据最常见的来源有两个:一是学校提供的脱敏真实数据,二是自己构造模拟数据。后者更通用,因为大部分学校不会为了一个毕设单独出数据。所谓模拟不是乱造,而是把真实校园消费的三个规律写进生成器:三餐时段集中、食堂金额集中在 8-15 元区间、周末作息整体后移。生成器固定随机种子后,每次跑出的数据一致,论文里能写「可复现实验」。
3.2 模拟脚本的实现:400人、一个学期、约22万条流水
下面这个脚本是我常用版本的简化,生成 2025 年春季学期约 100 天的流水:
import numpy as np import pandas as pd rng = np.random.default_rng(42) # 固定随机种子,保证可复现 students = [f"2023{i:04d}" for i in range(1, 401)] canteens = ["一食堂", "二食堂", "清真食堂", "风味食堂"] markets = ["超市A", "超市B"] others = ["水控", "奶茶铺"] records = [] balance = {sid: 200.0 for sid in students} # 每人初始余额 # 工作日三餐窗口:早饭 07:00-08:30,午饭 11:00-13:00,晚饭 17:00-19:00 def time_slot(date, rng): wd = date.weekday() if wd >= 5: # 周末早饭整体后移,午饭窗口拉宽 r = rng.random() if r < 0.3: return 7 + rng.uniform(1.0, 2.0) # 周末早饭 08:00-09:00 if r < 0.7: return 11 + rng.uniform(0.5, 3.0) # 周末午饭 11:30-14:00 return 17 + rng.uniform(0.0, 2.5) # 晚饭 else: r = rng.random() if r < 0.25: return 7 + rng.uniform(0.0, 1.5) # 早饭 if r < 0.60: return 11 + rng.uniform(0.0, 2.0) # 午饭 if r < 0.90: return 17 + rng.uniform(0.0, 2.0) # 晚饭 return 20 + rng.uniform(0.0, 2.0) # 夜宵 for day in pd.date_range("2025-03-01", "2025-07-10"): for sid in students: n = int(rng.integers(3, 8)) # 每人每天 3~7 笔 for _ in range(n): hour = time_slot(day, rng) ts = day + pd.Timedelta(hours=hour, minutes=int(rng.uniform(0, 59))) if ts.hour < 6: # 深夜窗口尽量避免生成在凌晨 continue # 商户选择:白天正餐偏食堂,夜宵偏奶茶/超市 if ts.hour < 10: merchant = rng.choice(canteens) elif ts.hour < 15: merchant = rng.choice(canteens) elif ts.hour < 20: merchant = rng.choice(list(canteens) + ["超市A", "超市B"]) else: merchant = rng.choice(["奶茶铺", "超市A", "超市B"]) # 金额分布:食堂用对数正态,超市用均匀,水控固定 if "食堂" in merchant: amount = round(float(rng.lognormal(mean=2.2, sigma=0.4)), 2) elif merchant in ["超市A", "超市B"]: amount = round(float(rng.uniform(2.0, 50.0)), 2) elif merchant == "奶茶铺": amount = round(float(rng.uniform(8.0, 20.0)), 2) else: amount = round(float(rng.uniform(3.0, 8.0)), 2) if balance[sid] < amount: balance[sid] = 200.0 # 余额不足就重新充值,模拟圈存 balance[sid] -= amount records.append([sid, ts.strftime("%Y-%m-%d %H:%M:%S"), merchant, amount, round(balance[sid], 2)]) df = pd.DataFrame(records, columns=["student_id", "trade_time", "merchant", "amount", "balance"]) df.to_csv("data/raw/consumption.csv", index=False)参数说明:rng = np.random.default_rng(42)是关键。没有这一行,每次运行产生的数据都不同,后面所有分析结果都不可复现,答辩前重跑一遍结果变了是血泪教训。金额部分食堂用lognormal(mean=2.2, sigma=0.4),模拟出的中位数在 9 元左右,贵菜和便宜菜都少,符合食堂定价;超市用均匀分布 2-50 元;水控固定 3-8 元。time_slot函数把消费时间压缩在工作日三餐和周末后移的窗口里,否则均匀生成的时间根本不像校园数据。约 22 万条流水就是这个脚本的量级,跑完检查一下df.shape。
3.3 让模拟数据不像假数据的 3 个细节
第一,别把时间精确到秒还整整齐齐。上面的脚本用int(rng.uniform(0, 59))打散秒数,真实一卡通的刷卡时间就是乱的。第二,控制夜宵比例。夜宵占比设定在 10% 左右,如果超过 20%,聚类时会出现一个全是夜宵党的簇,答辩时容易被质疑数据失真。第三,余额要联动。每笔消费后余额递减,余额不足时充值 200,这样导出的 balance 字段有时间趋势,做数据校验时能多一层「像真的」的证据。用一句话说:模拟数据的目标不是随机,是让每个统计口径下看起来都符合常识。
4. 清洗与特征工程:从一卡通流水到 RFM 消费宽表
4.1 先洗数据:时间解析、去重、负数和异常金额
模拟数据虽然干净,但清洗这一步还是要走完整流程,因为你论文里要写「数据预处理」这一节,而且真实数据比模拟数据脏得多。常见清洗步骤按顺序做:
import pandas as pd df = pd.read_csv("data/raw/consumption.csv") df["trade_time"] = pd.to_datetime(df["trade_time"]) # 1. 精确重复:同人、同秒、同商户、同金额,判定为重复导入 df = df.drop_duplicates(subset=["student_id", "trade_time", "merchant", "amount"]) # 2. 退款记录单独处理:金额为负,不能直接丢,先聚合再看 refund = df[df["amount"] < 0] df = df[df["amount"] >= 0] # 3. 异常金额:单笔超过 150 元的消费多半是圈存或误刷,标出来看占比 df["is_abnormal"] = df["amount"] > 150逻辑说明:先转时间类型,否则后续按小时聚合会很痛苦。drop_duplicates的 subset 里不要只放 trade_time,因为同一秒不同人刷卡是完全正常的,必须加上学号、商户、金额三个条件。退款记录在真实数据里常见,模拟数据里没有,但你把处理逻辑写在代码里,论文里就有话可说——「对退款按学号和商户做净值合并,避免 RFM 的 M 值被退款污染」。最后一步是体检不是删除,先看异常占比,超过 1% 再决定要不要剔。
4.2 RFM 特征计算:R、F、M 各自的口径
RFM 是消费行为分析的标配,分别对应最近一次消费距今多少天、消费了多少次、累计花了多少钱。计算口径有一个关键决定:参考日是哪天。常见做法是取数据集的最后一天加 1 天作为参考日,这样 R 值最小是 1,不会出现 0 导致分箱困难。
ref_date = df["trade_time"].max().normalize() + pd.Timedelta(days=1) last = df.groupby("student_id")["trade_time"].max().reset_index() last["recency"] = (ref_date - last["trade_time"]).dt.days freq = df.groupby("student_id").size().reset_index(name="frequency") monet = df.groupby("student_id")["amount"].sum().reset_index(name="monetary") rfm = last[["student_id", "recency"]].merge(freq, on="student_id").merge(monet, on="student_id") rfm["recency_score"] = pd.qcut(rfm["recency"], 4, labels=[4, 3, 2, 1], duplicates="drop") rfm["frequency_score"] = pd.qcut(rfm["frequency"].rank(method="first"), 4, labels=[1, 2, 3, 4]) rfm["monetary_score"] = pd.qcut(rfm["monetary"].rank(method="first"), 4, labels=[1, 2, 3, 4])参数说明:R 和 F、M 的分箱方向相反——R 越小越好,所以标签从 4 到 1;F 和 M 越大越好,标签从 1 到 4。duplicates="drop"是必须写的,因为recency是离散的天数,很多人同一天最后消费,分位数边界会重复,不处理就报Bin edges must be unique。F 和 M 的分布严重右偏,直接用 qcut 大概率报错或分出不均匀的箱,所以我先.rank(method="first")再分箱,这是比duplicates="drop"更稳的做法——也是 pandas 里最常见的隐藏坑之一,比 python 语法错误更隐蔽。
4.3 扩展特征:把「消费行为」从 RFM 扩到一张宽表
RFM 只有三个维度,做聚类偏单薄。我一般会在 RFM 基础上再加一组行为特征,让聚类结果更可解释:
feat = rfm[["student_id", "recency", "frequency", "monetary"]].copy() # 每笔平均金额 feat["avg_amount"] = df.groupby("student_id")["amount"].mean().values # 活跃天数 feat["active_days"] = df.groupby("student_id")["trade_time"].apply(lambda s: s.dt.normalize().nunique()).values # 三餐划分:早饭 6-9 点,午饭 11-13 点,晚饭 17-19 点,其余算夜宵 hour = df["trade_time"].dt.hour df["meal"] = pd.cut(hour, bins=[0, 6, 9, 11, 13, 17, 19, 24], labels=["night", "breakfast", "morning", "lunch", "afternoon", "dinner", "night"]) breakfast_cnt = df[df["meal"] == "breakfast"].groupby("student_id").size() lunch_cnt = df[df["meal"] == "lunch"].groupby("student_id").size() dinner_cnt = df[df["meal"] == "dinner"].groupby("student_id").size() feat["breakfast_cnt"] = breakfast_cnt.reindex(feat["student_id"]).fillna(0).values feat["lunch_cnt"] = lunch_cnt.reindex(feat["student_id"]).fillna(0).values feat["dinner_cnt"] = dinner_cnt.reindex(feat["student_id"]).fillna(0).values # 商户种类数 feat["diversity"] = df.groupby("student_id")["merchant"].nunique().values # 周末消费占比 df["is_weekend"] = df["trade_time"].dt.weekday >= 5 weekend_ratio = df.groupby("student_id")["is_weekend"].mean() feat["weekend_ratio"] = weekend_ratio.reindex(feat["student_id"]).fillna(0).values逻辑说明:reindex(...).fillna(0)很关键。某些学生可能一次早饭都没吃过,groupby 结果里没有这个学号,直接.values会让特征对不上行。用feat["student_id"]做索引对齐,缺失补 0,宽表就不会错位。pd.cut的 bins 左闭右开,所以 morning 和 afternoon 这两个中间段是留给「上午加餐」和「下午茶」的,后面聚类可能用不上,但论文里多两个维度没坏处。diversity 是去重商户数,它能区分「天天同一食堂」和「到处换窗口」的人,后者往往消费更有计划。这张宽表就是后面 KMeans 的输入,先feat.to_csv("data/processed/feature_wide.csv", index=False)存下来。
5. 消费行为可视化与聚类建模:PyECharts 出图,轮廓系数定 K
5.1 用 PyECharts 把三餐时段画出来
python数据分析与可视化这一节,我首选 PyECharts 而不是 matplotlib,原因只有一个:render()生成 HTML,答辩时可以放大、悬停、翻页,老师看得舒服,你也少解释两分钟。按小时聚合消费次数后画折线图,一眼就能看出三餐峰:
import pandas as pd from pyecharts.charts import Line from pyecharts import options as opts df = pd.read_csv("data/raw/consumption.csv") df["trade_time"] = pd.to_datetime(df["trade_time"]) hourly = df.groupby(df["trade_time"].dt.hour)["amount"].sum().sort_index() line = (Line() .add_xaxis(hourly.index.tolist()) .add_yaxis("消费金额(元)", [round(v, 2) for v in hourly.values]) .set_global_opts(title_opts=opts.TitleOpts(title="24小时消费金额分布"), xaxis_opts=opts.AxisOpts(name="小时"), yaxis_opts=opts.AxisOpts(name="金额"))) line.render("output/hourly_line.html")参数说明:按小时聚合时用amount.sum(),如果想看笔数分布就换成.size()。图中 7-9 点、11-13 点、17-19 点三个峰越明显,说明模拟数据越成功,这页放在论文里比放一堆描述性统计表格有说服力。PyECharts 的 HTML 不受中文字体影响,但如果你同时用 matplotlib 存 PNG,必须设置中文字体,否则图上是方块字,这个问题每年答辩都会坑一批人。
5.2 KMeans 聚类:先标准化,再用轮廓系数选 K
聚类这一步是全文里最容易被老师追问的地方,问题集中在两个:K 为什么取 4,结果稳不稳定。先跑下面的选参脚本:
import pandas as pd from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score feat = pd.read_csv("data/processed/feature_wide.csv", index_col="student_id") X = feat.drop(columns=["recency_score", "frequency_score", "monetary_score"], errors="ignore") X_scaled = StandardScaler().fit_transform(X) best_k, best_score = 2, -1 for k in range(2, 8): km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(X_scaled) score = silhouette_score(X_scaled, km.labels_) print(f"k={k}, silhouette={score:.3f}") if score > best_score: best_k, best_score = k, score print(f"best_k={best_k}, best_score={best_score:.3f}")参数说明:StandardScaler不做不行。RFM 的 monetary 是几百,recency 是个位数,不标准化的话聚类距离几乎被金额字段垄断。n_init=10是 KMeans 的稳定开关,默认值在新版 sklearn 里已经是 10,但显式写出来是在告诉答辩老师「我处理过随机初始化问题」。random_state=42必须固定,否则同一个 K 每次跑出来簇都不一样,答辩时翻车概率很大。轮廓系数的经验值是 0.3 以上可接受,0.4 算不错,校园消费数据 0.35-0.45 都正常,不用追求 0.7。
确定 K 后,把聚类结果合并回宽表,按簇做均值对比,给每簇取个名字:
km = KMeans(n_clusters=best_k, n_init=10, random_state=42).fit(X_scaled) feat["cluster"] = km.labels_ summary = feat.groupby("cluster")[["recency", "frequency", "monetary", "avg_amount", "diversity", "weekend_ratio"]].mean().round(2)逻辑说明:summary里哪个簇的 frequency 和 monetary 都高、recency 小,就是「规律高消费」人群;哪个簇 diversity 低、breakfast_cnt 高,是「食堂忠实用户」;周末占比高的簇可以叫「周末宅宿舍型」。簇命名写在论文里非常加分,比单纯说「簇 0、簇 1」直观得多,老师一看就知道你理解自己的聚类结果。
5.3 关联规则:Apriori 把「人+天」变成购物篮
关联规则是消费行为分析的加分项,不是必需项,但做了以后论文的方法论章节立刻厚一层。核心思路是把一个学生一天内的消费地点集合当成一个购物篮,比如{一食堂, 奶茶铺, 水控},然后挖「食堂→奶茶铺」这种共现关系。
from mlxtend.frequent_patterns import apriori, association_rules from mlxtend.preprocessing import TransactionEncoder df = pd.read_csv("data/raw/consumption.csv") df["trade_time"] = pd.to_datetime(df["trade_time"]) df["date"] = df["trade_time"].dt.date baskets = df.groupby(["student_id", "date"])["merchant"].apply(lambda s: list(set(s))).tolist() te = TransactionEncoder() te_ary = te.fit(baskets).transform(baskets) basket_df = pd.DataFrame(te_ary, columns=te.columns_) frequent = apriori(basket_df, min_support=0.03, use_colnames=True) rules = association_rules(frequent, metric="confidence", min_threshold=0.3) rules = rules.sort_values("lift", ascending=False)参数说明:min_support 取 0.03 意味着「这个组合至少出现在 3% 的天次里」,因为校园里同时去食堂和超市的人很多,support 设太高会什么都挖不出来——这是调参最容易翻车的地方。metric="confidence"是看条件概率,lift大于 1 才说明两个地点有正向关联,比如「奶茶铺→超市B」的 lift 是 1.6,意思是买过奶茶的人再去超市B的概率比平均水平高 60%。这个结论看起来简单,但论文里能讲成一个完整的故事。
6. 答辩演示前验证模型:三个能拿出手的验证手段
演示之前,我会花一小时做三件事,直接决定老师信不信你的模型。第一件,把轮廓系数选 K 的循环结果画成折线图,每簇人数和均值表放到 PPT 里。老师问到「K 为什么是 4」,你指着图说 k=4 时轮廓系数最高、四簇人数是 152/118/76/54、没有出现某个簇只有 3 个人的极端情况,问题就过去了。第二件,随机抽 3 个学号,把他们的消费时间列成表格,检查工作日三餐是否明显、周末是否整体后移。这页能让老师相信你的数据和结论之间有真实联系。
第三件事,是留一个「后悔药」脚本。我会写一个scripts/demo.py,里面串好读数据、跑聚类、生成全部图表和一张 HTML 报告的命令。答辩现场如果网络不好、依赖坏了或者某个环节报错,双击跑这个脚本就能重新生成所有结果。这不是炫技,是翻车之后的口头禅。最后说一个我自己的教训:第一次做这个题目时没固定 KMeans 的 random_state,答辩前一晚重跑,簇成员全变了,吓得连夜重出图。从那以后,所有脚本开头第一件事就是写随机种子,数据生成、划分、聚类三个地方各固定一个,互不干扰。希望帮到你——数据分析和建模可以慢慢调,但可复现这条底线,一开始就要守住。
本文还有配套的精品资源,点击获取