☰
基于Python的学生校园消费行为分析全流程方案
2026/10/3 2:43:14 网站建设 项目流程

简介:这份基于Python的学生校园消费行为分析项目,定位为Python期末大作业/课程设计的高分范例,适合计算机相关专业学生完成数据分析类作业或进行项目实战练习。项目围绕学生校园消费数据展开,包含DFM模型分析思路,可帮助学习者掌握数据清洗、特征分析与结果可视化的完整流程。资源包共8个文件,主要包括3个Python源码文件(如模型定义、数据初始化、核心分析)、1个数据集压缩包、1份说明文档(docx)、1个文本说明及Git配置和项目说明文件,整体大小约10.07MB。已有123人学习下载,代码经过本地编译调试可运行,并附有结果集与文档,便于对照验证。对需要完成类似大作业或提升数据分析实战能力的学习者,是一份难度适中、内容完整且能直接使用的参考资料。

1. 基于Python的学生校园消费行为分析:一堆流水能回答什么

一张校园一卡通流水表,几十万行,字段只有学号、交易时间、商户名称、交易金额,看起来平平无奇。但这几列整理清楚之后,能回答的问题远比想象中多:谁长期只在一个食堂吃饭、谁经常夜间消费、谁的日均支出波动剧烈。标题里这个项目——“基于Python的学生校园消费行为分析:源码+数据+结果集”,本质就是把一卡通流水做成一套完整分析方案:清洗数据、构造特征、聚类分群、输出可视化结果集。它不需要特殊硬件,pandas、matplotlib、scikit-learn 三件套就能跑通,非常适合做课程设计、毕业设计,或者作为数据分析入门的第一个完整项目。下面我按自己平时做这类项目的顺序来讲:数据集长什么样、每一步代码怎么写、参数怎么调、哪里最容易翻车。

2. 数据清洗与字段规整:把流水整理成能下钻的数据集

拿到“学生校园消费行为分析”这个项目时,第一步一定不是急着算均值、画图,而是先看原始数据长什么样。一卡通流水的字段通常不多,脏数据的种类却很固定,把这部分处理干净,后面特征工程才不会反复返工。

2.1 校园消费数据集的结构:字段与统计口径

绝大多数校园消费分析的数据集来自一卡通POS机导出的流水,每条记录代表一次刷卡消费。常见字段构成如下:

字段名示例值说明
student_id2022010101学号,分析的基本单位
trade_time2024-03-18 12:03:22交易时间,精确到秒
merchant第一食堂二楼商户名称,不同设备导出规则差异大
amount-8.50交易金额,负值代表退款

拿到数据后第一步不是直接算统计量,而是检查每个字段的缺失率、重复行、金额分布。很多项目包里的原始CSV的编码是GBK或GB2312,用pandas读取时要指定encoding,否则一打开就报 UnicodeDecodeError。这里我给出一段通用的读取与体检代码:

import pandas as pd # 优先尝试 utf-8,失败再回退到 gbk,校园一卡通导出文件两种编码都很常见 try: df = pd.read_csv("data/card_flow.csv", encoding="utf-8") except UnicodeDecodeError: df = pd.read_csv("data/card_flow.csv", encoding="gbk") print("原始行数:", len(df)) print("字段列表:", df.columns.tolist()) print("缺失统计:\n", df.isna().sum()) print("金额描述:\n", df["amount"].describe())

这段代码的逻辑是把“读取文件”和“数据体检”合在一起:先确认编码,再确认行数和缺失情况。isna().sum()能直接看到哪些字段有空值,describe()则暴露金额的边界——比如最小值是负数,说明存在退款单;最大值是几千甚至上万,说明混入了异常数据。看到一个几百元以上的单笔食堂消费,就要回查原始记录,而不是直接拿去做均值。

列名也需要统一。每个学校的一卡通系统导出的列名都不一样,有的叫xh、JYSJ、XMMC,有的叫card_no、deal_time、shop_name。我会先做一次字段重命名,把列名收敛成一套固定命名,后面所有代码都基于这套命名写。

# 统一列名,避免后续每个脚本里都要处理别名 df = df.rename(columns={ "学号": "student_id", "xh": "student_id", "交易时间": "trade_time", "JYSJ": "trade_time", "商户": "merchant", "XMMC": "merchant", "金额": "amount", "JYJE": "amount", })

这段代码不复杂,但很值得做。列名统一之后,清洗、特征、聚类三个阶段可以共用同一套字段名,不然每次重新读数据都要翻一遍原始字典,效率很低。我的做法是干脆单独建一个preprocess.py,把读取和重命名固定住,后续所有脚本都from preprocess import load_data。

2.2 清洗代码:缺失值、重复流水与时间解析

清洗的核心目标有三个:剔除无法定位到人的记录、剔除重复流水、把时间列解析成可计算的时间戳。缺学号的记录无法归到任何一个学生,只能删;时间解析失败的记录无法做时段统计,也只能删——不能为它们填一个凭空的值。

# 删除缺失学号的记录,消费行为分析必须以人为主体 before = len(df) df = df.dropna(subset=["student_id"]) # 时间列统一为 datetime,解析失败的行会在 ts 列变成 NaT df["ts"] = pd.to_datetime(df["trade_time"], errors="coerce") df = df.dropna(subset=["ts"]) print(f"删除缺失后: {before} -> {len(df)}") # 完全重复的流水行,通常是网络重传或重复导入导致,直接去重 df = df.drop_duplicates(subset=["student_id", "ts", "merchant", "amount"]) print(f"去重后剩余: {len(df)}")

参数的关键点有两个:errors="coerce"让无法解析的时间变成NaT而不是抛异常中断整个脚本,给后续dropna留出判断空间;drop_duplicates的subset必须同时包含时间、商户、金额四个字段,只按单字段去重会误删真实消费记录。比如一个学生中午在同一食堂刷两次是可能的,但同样学号、同样时间、同样金额、同样商户出现两次,基本可以断定是重复导入。

再做一次金额异常查验。这一步不一定删数据,但必须看见异常:

print("金额小于0的行数:", (df["amount"] < 0).sum()) print("金额等于0的行数:", (df["amount"] == 0).sum()) print("单笔大于200元的行数:", (df["amount"] > 200).sum())

金额小于0的是退款,先单独摘出来,不计入后面的消费行为特征。金额等于0的记录对行为分析没有信息量,可以直接剔。单笔超过200元的要特别警惕——食堂单笔消费上百元并不是绝对不可能,可能是帮同学代刷,这类离群值是否保留,取决于你要分析的是个人消费水平还是集体消费规律。

2.3 时间与商户字段预处理:把字典之外的信息补上

流水里通常只有商户名称,没有“食堂”“超市”“浴室”这种类别字段,需要自己补。我一般维护一个关键词映射表,把商户名归类到消费场景,这比逐条手工标注快得多,而且归类口径可以反复调整。

# 关键词映射:商户名包含哪个关键词就归到对应类别 category_rules = [ ("食堂|餐厅|面馆", "canteen"), ("超市|便利店|小卖部", "store"), ("浴室|开水|洗衣", "service"), ("医务室|药店", "medical"), ] def map_category(merchant_name): for kw, cat in category_rules: if kw in merchant_name: return cat return "other" df["category"] = df["merchant"].map(map_category) print(df["category"].value_counts())

这段代码的价值在于把模糊的商户名变成了可聚合的消费类别。规则要按学校实际情况改,有的学校食堂叫“学一餐厅”“学二食堂”,有的叫“风味餐厅”“清真食堂”,关键词覆盖不全的商户会落入other,后续统计时关注other占比,如果超过10%,说明规则漏得太多,需要往回补词。

到这里,清洗阶段基本完成,数据已经是“一行一记录、记录可定位到人、时间可计算、商户可归类”的状态。这一步不产生任何分析结论,但它决定了后面所有特征计算是否正确。

3. 特征工程:把流水聚成学生维度的消费行为表

流水是按“次”组织的,而行为分析是按“人”组织的。特征工程的目标很明确:把几十万行流水折叠成一张宽表,每一行代表一个学生,每一列代表这个学生的一种消费行为特征。这张表才是后续聚类和结果集的基础。

3.1 特征选型:哪些行为值得构造

校园消费分析最常用的特征可以分成四组:消费水平、消费频度、消费时段、消费结构。

消费水平用总金额、日均金额、单均金额描述;消费频度用消费天数、日均笔数描述;消费时段用早餐、午餐、晚餐、夜宵的消费占比描述;消费结构用食堂消费占比、超市消费占比描述。这四组特征拼在一起,基本能覆盖“这个人怎么吃饭、在哪里花钱、生活节奏如何”三个维度。

我不建议一开始就构造几十个特征。特征越多,聚类越难解释,很多特征之间高度相关,比如总金额和日均金额就强相关。做这个项目时,先把核心特征控制在10个以内,聚类结果出来后再按需要加特征,比一次堆满更可控。

3.2 特征计算代码:pandas 分组聚合一次搞定

用groupby按学号聚合,一次性算出核心特征,这是整个过程里性价比最高的一段代码:

consume = df[df["amount"] > 0].copy() # 只看正向消费,退款单独分析 consume["day"] = consume["ts"].dt.date consume["hour"] = consume["ts"].dt.hour # 根据小时划分餐段:边界用左闭右开,避免整点被分到两个时段 def meal_type(h): if 6 <= h < 10: return "breakfast" if 10 <= h < 15: return "lunch" if 15 <= h < 21: return "dinner" return "night" # 21点后统一算夜间消费 consume["meal"] = consume["hour"].map(meal_type) g = consume.groupby("student_id") feat = pd.DataFrame({ "total_amount": g["amount"].sum(), # 总消费金额 "total_cnt": g["amount"].count(), # 总消费次数 "active_days": g["day"].nunique(), # 活跃消费天数 "avg_amount": g["amount"].mean(), # 单均消费金额 }) # 日均值用“金额/活跃天数”,而不是“金额/总天数”,避免把不在校的周末算进去 feat["daily_amount"] = feat["total_amount"] / feat["active_days"] feat["daily_cnt"] = feat["total_cnt"] / feat["active_days"]

餐段划分的边界参数直接决定时段特征的口径:6 <= h < 10表示早晨6点到9点59分都算早餐,10点整切到午餐。为什么不用h <= 6这类写法?因为整点必须只属于一个时段,闭区间会造成一条10点的记录同时算早餐和午餐。

接着算时段占比和食堂依赖度:

# 餐段金额占比:按人×餐段两维聚合 meal_tab = consume.pivot_table( index="student_id", columns="meal", values="amount", aggfunc="sum", fill_value=0 ).add_prefix("meal_amount_") feat = feat.join(meal_tab) feat["night_ratio"] = feat["meal_amount_night"] / feat["total_amount"] # 食堂消费占比:商户名含“食堂/餐厅”归类为食堂 consume["is_canteen"] = consume["merchant"].str.contains("食堂|餐厅", regex=True) canteen_sum = consume.groupby("student_id")["amount"].sum() feat["canteen_ratio"] = canteen_sum / feat["total_amount"]

这里fill_value=0很关键——如果某个学生从没有夜间消费,pivot_table会在该列填0而不是NaN,避免后续除法直接算出无穷值。canteen_ratio的计算依赖第2章的商户关键词映射,关键词不覆盖的食堂会被漏成other,所以第2章我才特意提醒监控other占比。

3.3 特征检查:分布、缺失与口径确认

特征算完之后不要急着聚类,先做一次分布检查,看看有没有哪列明显不对:

print(feat.describe().round(2)) print("空值统计:\n", feat.isna().sum())

这一步通常能发现两类问题:一类是除数为0产生的inf——比如某个学生的total_amount为0,canteen_ratio就是无穷;另一类是某列方差为0,所有学生取值一样,这种特征对聚类没有区分度,应该删掉。

特征字典建议保存下来,既是给项目写说明文档用的,也是后面调整口径的依据:

特征名计算口径典型含义
total_amount正向消费金额求和消费总水平
active_days有消费记录的天数在校活跃度
daily_amount总金额/活跃天数日均消费水平
avg_amount总金额/消费笔数单笔消费习惯
night_ratio夜间金额/总金额夜宵偏好
canteen_ratio食堂金额/总金额食堂依赖度

特征表里的每一行都要能说清楚“这个数是怎么算出来的”。项目交付或者答辩时,评审最常追问的就是口径问题,讲不清口径的特征,图再漂亮也站不住。

4. 消费聚类与结果集:KMeans 划分消费水平并落盘

特征工程完成后,手上的数据集已经从“流水明细”变成了“学生画像宽表”,下一步就是分组。这个项目里的“结果集”,核心就是聚类标签、簇特征对比表和可视化图表这三样东西。

4.1 为什么选 KMeans 而不是 DBSCAN

消费画像分群用 KMeans 是主流做法,原因是它的结果容易解释:每个簇的中心点是一组特征均值,可以直接读出“这个簇是人均多少、夜宵占比多少”。DBSCAN 虽然能识别离群点,但它的eps参数对高维特征非常敏感,调参成本高,分出的簇数也不稳定,不适合做需要交付结果集的项目。

聚类之前必须先归一化。KMeans 基于欧氏距离,如果直接用原始特征,total_amount的量纲在一万级别,night_ratio在0到1之间,距离计算时小额特征会被完全淹没,聚类结果会退化成“按总金额一刀切”。用StandardScaler把每个特征缩放到均值0、标准差1,是这一步最常见也最稳的做法。

from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans # 挑选参与聚类的特征,剔除明显冗余的列 cluster_cols = ["daily_amount", "daily_cnt", "avg_amount", "night_ratio", "canteen_ratio"] X = feat[cluster_cols].fillna(0) scaler = StandardScaler() X_scaled = scaler.fit_transform(X)

fit_transform的作用是先按训练集算出均值和标准差,再用这组参数完成缩放。注意fillna(0)要放在特征选择之后,如果某个学生的night_ratio是空值,填0等于默认他没有夜间消费,这在逻辑上是成立的;如果填的是均值,反而会引入不存在的消费行为。

4.2 选 K 值:肘部法则与结果稳定性

KMeans 需要提前指定簇数K。常见做法是用肘部法则,画出不同K值下的簇内误差平方和,找下降速度明显变缓的拐点:

import matplotlib.pyplot as plt inertia = [] for k in range(2, 8): model = KMeans(n_clusters=k, n_init=10, random_state=42) model.fit(X_scaled) inertia.append(model.inertia_) plt.plot(range(2, 8), inertia, marker="o") plt.xlabel("k") plt.ylabel("inertia") plt.savefig("output/elbow.png", dpi=150) print("各K值inertia:", inertia)

n_init=10的意思是每个K值用10组不同的初始中心跑10次,取最优结果。这个参数必须设置,不设的话不同机器上聚类结果会抖动,KMeans 的初始化带有随机性,只跑一次很看运气。固定random_state=42是为了结果可复现,否则每次运行结果集都不一样,项目没法交付。

肘部曲线的拐点通常是4或5,具体看数据。消费分群选4类的典型含义是:高消费高活跃型、中等消费常规型、低消费节俭型、夜间消费偏好型。如果某簇样本量太小,比如不到总人数的5%,说明K选大了,往回调一档。

4.3 打标签与结果集落盘:CSV 与图表输出

选定K后重新训练,给每个学生打标签,并输出分群汇总表和完整结果集:

k = 4 final_model = KMeans(n_clusters=k, n_init=10, random_state=42) feat["cluster"] = final_model.fit_predict(X_scaled) # 分群汇总:每个簇的特征均值,用于解释群含义 cluster_summary = feat.groupby("cluster")[cluster_cols].mean().round(3) print(cluster_summary) # 结果集落盘:utf-8-sig 是为了Excel打开CSV时不乱码 feat.to_csv("output/student_cluster_result.csv", encoding="utf-8-sig")

结果集文件里每一行就是一个学生的完整画像:学号、各项消费特征、聚类标签。这是整个项目最核心的交付物,答辩、写报告、后续做异常检测都靠这张表。

图表输出方面,除了肘部图,通常还要出一张簇特征对比图,把每个簇的特征均值画成横向柱状图。做图之前要先设置中文字体,否则图上所有中文标签都会显示成方块:

import matplotlib matplotlib.use("Agg") # 服务器无桌面环境也能保存图片 plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] plt.rcParams["axes.unicode_minus"] = False # 修正负号显示 fig, axes = plt.subplots(1, len(cluster_cols), figsize=(18, 4)) for ax, col in zip(axes, cluster_cols): cluster_summary[col].plot(kind="bar", ax=ax, title=col) plt.tight_layout() plt.savefig("output/cluster_compare.png", dpi=150)

matplotlib.use("Agg")是第三步,关键在“不弹窗,直接保存文件”。在Jupyter Notebook里可以不用,但在服务器或命令行跑脚本时必须写,不然会报no display。中文字体设置放在绘图前,unicode_minus不加的话,坐标轴上的负号会显示成乱码方块。

结果集的图表部分可以整理成这样的清单:

文件内容格式
student_cluster_result.csv学生ID、特征、聚类标签CSV(utf-8-sig)
elbow.png肘部法则选K曲线PNG
cluster_compare.png各簇特征均值对比PNG

聚类结果要带着解释去看,不要只看数字。聚类标签本身只反映刷卡行为的相似性,它不等同于家庭经济水平,更不适合直接用来给学生定性。这个边界在项目报告里应当写明:行为分群是管理参考,不是身份判定。

5. 消费行为分析的常见问题与排查:五个必踩的坑

做校园消费分析项目的过程中,代码跑通只是很小一部分,真正花时间的是处理数据和解释结果。下面五条是我在这个方向上反复踩过的坑,按“现象、原因、解决”展开,希望能帮你少绕几次路。

5.1 退款单混进正向消费,人均金额被拉低

现象:算出来的日均消费金额整体偏低,部分学生的日均消费只有正常水平的一半。细查数据发现,有大量负金额记录被直接sum进了总金额。

原因:一卡通系统中的退款单金额是负数,比如食堂多扣款退回的8.5元,会以-8.50出现在流水里。直接把amount求和,退款会把消费总额抵消一部分,人均自然被拉低。

解决:在特征计算之前严格区分方向,先执行consume = df[df["amount"] > 0],把退款单单独存一个数据集用来做退款率分析,不要和正向消费混在一起。如果有人写df["amount"].abs()再求和,看起来把负数变正了,实则把退款算成了新消费,同样会污染结果。

5.2 聚类前不归一化,分群只看得到金额

现象:聚类结果出来,四个簇的总金额均值分别是3000、6000、12000、24000,而夜宵占比、食堂占比这几个特征在各簇之间几乎没差异。

原因:KMeans 的距离计算对特征量纲极度敏感。总金额是万级数字,特征是0到1的小数,距离几乎完全被金额主导,其他特征形同虚设。

解决:参与聚类的特征先过StandardScaler,让每个特征都在同一尺度上。判断是否归一化的方法很简单:直接看cluster_summary表,如果某个特征在所有簇中的均值都几乎一样,说明它没有参与区分,要么做特征选择删掉,要么检查是不是数据本身方差太小。

5.3 商户名称不统一,食堂占比算成90%

现象:某学生的食堂消费占比高达98%,另一个活跃学生只有30%,人工抽查他的流水却发现几乎每笔都在食堂刷卡。

原因:同一个食堂在流水里可能同时存在“第一食堂”“一食堂”“学一”“一食”等几种叫法,关键词映射漏掉了别名,消费被归到了other类别,食堂占比就被系统性算低。

解决:建关键词映射时要先盘点商户名的全集:consume["merchant"].value_counts()打印出来,把所有只差少数词的名称归并到一起。归并后检查other占比,如果超过10%,大概率是规则漏词,直接回补关键词后再重算。

5.4 离线POS机补传数据,消费日期整体错位

现象:某天的消费笔数在凌晨出现一个明显尖峰,而且都是前一晚的商户流水;或者某个学生的周末消费天数为0,但工作日消费天数异常多。

原因:部分离线POS机并不是实时上传,而是第二天补传。如果表里只有一个上传时间字段而没有业务发生时间,这类记录会被算到错误日期,按天统计活跃天数时直接出错。

解决:先检查凌晨0点到2点的笔数占比,如果异常高,翻原始导出记录里是否有“业务时间”或“流水时间”字段。存在就优先用业务时间;不存在,就要和学校信息化部门确认数据同步机制,明确延时窗口后再决定是用上传时间还是做时间修正。这个坑最容易在写报告时才暴露,特征是活跃天数异常低。

5.5 中文图表里的字全部变成方块

现象:聚类对比图保存后,标题和坐标轴的中文全部显示为黑色小方块,坐标轴的负号变成一条竖线。

原因:matplotlib 默认字体不含中文,也没有处理 Unicode 负号,中文直接渲染失败。

解决:绘图代码开头固定加三行:matplotlib.use("Agg")、plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"]、plt.rcParams["axes.unicode_minus"] = False。服务器上没有中文字体时,可以换成Noto Sans CJK SC,或者先执行fc-list :lang=zh查看系统装了哪些中文字体,再写对应的字体名。

这五条里,退款单和商户名不统一几乎每个项目都会遇到,另外三条出现的概率也不低。建议把清洗和检查脚本做成一个check.py,每次拿到新数据先跑一遍,看到异常再往下走,而不是等结果出来再回头查。

6. 进阶:用滑动窗口识别消费突变,给结果集加一个异常维度

聚类结果集已经回答了“学生属于哪个消费群体”,如果还想再往下挖一层,可以给结果集追加“消费异常”维度,用来标记消费金额突然显著偏离个人常态的日期。这类突变背后往往是代刷、平台充值、重复扣款或者特殊事件,值得单独查看。

实现思路是:把每个学生的日消费金额按时间排序,计算一个7日滑动窗口的均值和标准差,当某天的消费金额高于窗口均值加上1.5倍标准差时,就标记为异常。

# 先按人和日期排序,滑动窗口依赖有序数据 daily = consume.groupby(["student_id", "day"], as_index=False)["amount"].sum() daily = daily.sort_values(["student_id", "day"]) daily["avg7"] = daily.groupby("student_id")["amount"].transform( lambda x: x.rolling(7, min_periods=3).mean() ) daily["std7"] = daily.groupby("student_id")["amount"].transform( lambda x: x.rolling(7, min_periods=3).std() ) # 高于均值+1.5倍标准差,且标准差有效时才判定异常 daily["anomaly"] = ( (daily["amount"] > daily["avg7"] + 1.5 * daily["std7"]) & (daily["std7"] > 0) )

min_periods=3的意思是窗口不足7天时,只要有3天数据也算,让开学初期也有参考基准;std7 > 0排除了那些每天消费金额完全相同、标准差为0的学生,这类学生没出现过波动,谈不上异常。1.5倍标准差是经验阈值,校园数据通常取1.5到2之间。阈值越大,标出来的单子越少但越确信;项目里我一般先跑1.5倍,把结果导出成 CSV 人工抽查几十条,确认误标率后再决定是否收紧。

验证异常标记是否正确,有一个很朴素的方法:把异常日期连同当天的商户明细一起导出,人工看那几笔消费发生了什、在哪个商户、金额是否真实。比如同样标记“异常”,一笔是晚上10点在超市买了80元日用品,一笔是凌晨在食堂连刷三次,后者的解释权重远高于前者。有了这个维度,原本的聚类结果集就从“静态画像”变成了“画像加预警”,项目报告的含金量也会高出一截。

这套流程做到最后,你会发现自己收获的不只是一份源码和结果集,而是一条稳定的分析路径:任何一份一卡通流水拿过来,都能在半天内清洗、成表、分群、查异常。我现在的习惯是每次分析前先备份原始数据,所有清洗步骤都通过脚本可重演,聚类剧本参数固定,结果集文件名带上日期,这样哪怕后续和老师确认口径后要重跑,也不会把旧结果和新结果混在一起。希望这些细节能帮你在做“基于Python的学生校园消费行为分析”时少踩几个坑,把更多时间花在解释结论而不是排错上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询