基于Python的校园一卡通消费行为分析:清洗、可视化与聚类
2026/9/16 15:42:11 网站建设 项目流程

简介:面向校园消费数据分析场景的Python设计与分析项目,围绕学生消费记录与个人信息表,完成数据清洗、信息关联、食堂就餐情况分析以及学生消费行为分析等完整流程,适合数据分析初学者、课程设计或相关课题参考。资源包共30个文件,以5个Python脚本为核心,配合8个CSV数据表、9张结果图片、1份Word分析报告及工程配置文件,压缩包大小118.44MB。目前已有739人学习浏览,内容覆盖缺失值与异常数据清洗、多表关联、早中晚各食堂就餐人数占比、工作日与非工作日就餐时间、不同性别及各专业消费对比等关键方法,并附有可视化结果图与分析报告。整体目录结构清晰,从原始数据到最终分析结论均有对应脚本和输出,便于读者按步骤理解与复用。

1. 校园消费行为分析的设计起点:从一卡通流水到行为指标

在校园信息化场景里,学生一卡通每天产生大量交易流水,食堂、超市、开水房的记录不断堆积。真正动手分析这类数据时,最先遇到的瓶颈往往不是模型不会跑,而是设计问题:按什么粒度定义消费、用哪些指标衡量行为异动、如何区分“消费高”和“活跃度高”。这个项目的核心,是把一卡通流水做清洗、聚合和特征设计,再用 Python 完成可视化与聚类分析,输出一张可直接解释的学生消费行为表。适合需要做校园数据分析、学工研判或 Python 数据项目练手的开发者阅读;用过 pandas 但未系统性做过行为分析的人收获最大。

2. 用 Python 搭建校园消费行为数据集:清洗、聚合与特征设计

一卡通原始流水通常以 CSV 格式从卡务系统导出,常见字段包括学号、交易时间、商户名称、交易类型、交易金额、卡内余额。字段口径在不同学校之间差异不小,有的系统把退款记为负数,有的把补助金记为充值。设计分析的第一步,是把这些原始字段转成稳定、可复用的行为变量。

2.1 定义数据字典与初始加载方式

先确认数据字典,再写代码,顺序不能反过来。常见做法是向卡务系统要一份字段说明;要不到的时候,就用df.head(20)抽样打印,人工推断字段含义。一个典型的流水样例结构如下:

字段示例说明
student_id2023081101学生学号,统一按字符串处理
trade_time2024-03-04 11:32:05交易发生时间
merchant食堂-第一餐厅消费场所
trade_type消费消费/充值/退款/补助
amount8.50交易金额,正数表示进账
balance12.30交易后卡内余额

加载代码:

import pandas as pd df = pd.read_csv( "card_trade.csv", parse_dates=["trade_time"], encoding="utf-8" ) df["student_id"] = df["student_id"].astype(str) print(df.info())

逻辑说明:parse_datestrade_time直接变成 datetime 类型,后续提取小时、星期都不用手动转换;student_id转成字符串,是因为学号不参与数值运算,且部分学号以 0 开头,按数值读入会丢失前导零。encoding参数用来规避 Windows 下从 Excel 导数据常见的中文乱码,如果报UnicodeDecodeError,把utf-8改成gbk再试。

读取时建议用usecols只选需要的列,例如usecols=["student_id", "trade_time", "merchant", "amount"],可以把几万行的读入时间缩短到秒级。balance这个字段,如果没有做余额序列分析的需求,可以先不加载,减少内存占用。

2.2 时间字段与金额字段的清洗规则

清洗集中在时间和金额两条线上。金额字段的常见异常包括退款产生的负数、单笔金额过高、以及充值与消费记录混在同一张表里;时间字段则需要从trade_time中拆出hourweekdaymonth,供后续的时段分析和周规律分析使用。

我一般先按trade_type过滤消费记录,再对金额做区间限制:

consum = df[df["trade_type"] == "消费"].copy() consum = consum[consum["amount"] > 0] consum = consum[(consum["amount"] > 0.1) & (consum["amount"] < 50)] consum["hour"] = consum["trade_time"].dt.hour consum["weekday"] = consum["trade_time"].dt.dayofweek consum["month"] = consum["trade_time"].dt.month

逻辑说明:amount > 0排除退款和金额异常为负的记录;0.1元的下限用来剔除系统产生的 0 元流水;50元上限是经验值,覆盖绝大多数食堂档口和超市订单。如果分析对象包含健身房或培训缴费,这类大额消费应单独建表,而不是与日常餐饮混在一起。hourweekdaymonth三列生成之后,后面的聚合和可视化都直接引用这三列,无需反复解析时间。

这里有个细节:过滤条件建议一次性写成组合布尔数组,避免在多步链式赋值时触发SettingWithCopyWarning。上面的写法里copy()隔离了dfconsum,后续新增列不会影响原始加载帧,也不会出现赋值不生效的隐性问题。

如果原始数据覆盖了寒暑假,建议先把假期记录剔掉。常见做法是把固定的日期区间维护成一个节假日配置表,例如寒假 1 月 15 日到 2 月 20 日、暑假 6 月 25 日到 8 月 30 日,每年单独调整;否则假期内的极低消费会把整体日均拉低,直接影响后续聚类特征。

2.3 面向行为分析的特征设计:聚合窗口与口径

特征设计的核心是决定“以谁为粒度、以什么窗口聚合”。通常以学生为粒度,把每个学生的消费行为压缩成一行特征记录。特征分成三类:总体消费水平、时段偏好、场所偏好。总消费金额一样的学生,可能是每天规律吃饭的“稳定型”,也可能是集中几天高额消费的“波动型”,只有同时保留金额与活跃天数,才能区分这两种情况。

daily = consum.groupby(["student_id"]).agg( total_amount=("amount", "sum"), total_count=("amount", "count"), avg_amount=("amount", "mean"), ) daily["days_active"] = consum.groupby("student_id")["trade_time"].nunique() daily["avg_daily"] = daily["total_amount"] / daily["days_active"] df_breakfast = consum[consum["hour"].between(6, 9)].groupby("student_id").size() daily["breakfast_ratio"] = daily.index.map(df_breakfast).fillna(0) / daily["total_count"]

逻辑说明:days_active是活跃天数,来自trade_timenunique,避免把同一时间点的两条记录重复计算;avg_daily用总金额除以活跃天数,比直接除以 30 天更能反映在校期间的日均水平。breakfast_ratio用 6 点到 9 点之间的消费次数除以总消费次数,衡量早餐习惯。早餐窗口可以按学校作息调整为 7 点到 9 点,参数影响的是特征的业务含义,需要与使用方对齐后再固定,不要中途更换。

注意:聚合窗口的口径要在项目开始时写进数据字典。比如“日均消费”是除以自然天还是活跃天数,不同口径算出的排名可能相差 20% 以上,建模之前不要再切换。

3. 校园消费行为的可视化探索:消费时段与金额分布的 Python 分析

数据清洗完成之后,建议先把关键维度可视化,再进入建模环节。可视化的价值不只是出图,而是快速检验前面设计的指标是否符合校园生活的实际规律,比如饭点高峰是否存在、周末消费是否出现时段后移、各年级之间是否有肉眼可见的差异。

3.1 用热力图观察一周消费时段分布

把交易记录按“小时”和“星期”交叉聚合,生成一张消费量热力图。这张图能同时回答三个问题:一天内的消费高峰在哪里、工作日和周末的形态差异有多大、是否存在夜间消费人群。环境里如果还没装 seaborn,先执行pip install seaborn matplotlib一行即可。

import seaborn as sns import matplotlib.pyplot as plt heat_data = consum.pivot_table( index="hour", columns="weekday", values="amount", aggfunc="count", fill_value=0, ) heat_data = heat_data.reindex(range(24), fill_value=0) sns.heatmap(heat_data, cmap="YlOrRd", annot=False, linewidths=0.5) plt.yticks(range(0, 24), [f"{h}:00" for h in range(0, 24)]) plt.show()

逻辑说明:indexhour表示每一行对应一天中的某个小时,columnsweekday表示周一到周日,valuesamountaggfunc设为count,实际统计的是交易笔数。fill_value=0让没有交易的格子显示为 0 而不是 NaN。reindex(range(24), fill_value=0)这一步不能省,因为原始数据里某些凌晨时段可能一行记录都没有,不补行的话热力图会缺行,图像结构直接错位。

annot=False适合全量数据,交易笔数上万时格子里写数字会糊成一片;如果只统计单个餐厅几百条记录,可以改成annot=True并设置fmt="d"显示整数。cmap选择YlOrRd是因为深色代表高交易量,颜色语义直观。看图的重点是找“深色块”:正常校园数据会在 7 到 8 点、11 到 13 点、17 到 19 点出现三条纵向色带,周末这三条带会整体后移一小时以上,这是判断校园作息节奏最直接的证据。

3.2 金额分布与箱线图:定位高消费与离群区间

单笔消费金额分布能反映食堂起步价和校园物价水平。食堂订单通常集中在几元到十几元,开水房订单只有几角钱,两种场所混在一起时,不管直方图还是箱线图都需要限制坐标范围才能看清主体分布。

plt.figure(figsize=(10, 4)) plt.subplot(1, 2, 1) consum["amount"].hist(bins=30, range=(0, 50), edgecolor="white") plt.xlabel("amount/yuan") plt.subplot(1, 2, 2) sns.boxplot(data=consum, x="amount", showfliers=False) plt.xlim(0, 30) plt.show()

逻辑说明:直方图的bins=30range=(0, 50)把 0 到 50 元切成 30 个桶,既能看到主峰位置,也能看出长尾;不设置range的话,个别大额记录会把横轴拉伸到几百元,主体分布会被压成一条线。箱线图单独使用时会画出大量离群点,showfliers=False先隐藏它们,再通过xlim(0, 30)聚焦分布主体。

注意:箱线图隐藏离群点只是为了看清 25% 分位到 75% 分位的盒子,不代表数据被删除。落在 50 元以上的离群记录要回到原始数据逐条核对,通常需要确认是不是多人拼单、自助餐计费或数据录入错误。

如果把 50 元上限临时放宽到 200 元,能看到一条很稀疏的长尾。真正的离群区间要交给业务方确认,例如某月出现连续多笔 80 元的消费,学生处会反馈是否来自校外机构在食堂包场;单纯从统计上把这些记录删掉,属于数据分析里常见的“技术正确、业务失真”。

3.3 不同学生群体的消费画像对比

特征表daily与学工系统的基础信息表关联后,可以做最基础的群体对比。学生信息表通常包含student_idgradecollege等字段,关联前先对student_id去重,防止一个学生多行导致人数虚高。

student_info = pd.read_csv("student_info.csv", dtype={"student_id": str}) profile = daily.reset_index().merge( student_info[["student_id", "grade"]], on="student_id", how="left" ) grade_group = profile.groupby("grade").agg( avg_daily_mean=("avg_daily", "mean"), breakfast_ratio_mean=("breakfast_ratio", "mean"), student_count=("student_id", "count"), ) print(grade_group.round(3))

逻辑说明:reset_index先把daily的索引student_id还原成普通列,再与student_info按学号左连接,这样没有基础信息的学生也能保留在结果里。groupby("grade")后分别取avg_dailybreakfast_ratio的均值,得到每个年级的平均日消费和早餐比例;student_count用于确认每个年级的样本量,避免个别只有几个人的年级干扰判断。

一次典型的输出长这样:

年级人数日均消费均值(元)早餐比例均值
2021级15618.350.19
2022级14921.420.24
2023级16122.800.27

如果 2021 级人数明显偏少,不能直接下结论说高年级消费下降,而要检查是不是毕业年级离校导致活跃天数偏少。把grade字段换成college或宿舍区,同样的代码可以快速产出不同维度的对比表,适合做成固定周报。

4. 校园消费行为的群体分析:Python 下的 RFM 设计与 KMeans 聚类

可视化确认整体规律后,进入项目核心环节:群体分析。这里的思路是先通过 RFM 框架把多维行为压缩成三个核心指标,再做聚类,让数据自己说出存在哪几类消费人群,而不是预设标签。

4.1 校园场景下的 RFM 指标设计

RFM 是零售分析里的经典框架,R 表示最近一次消费时间,F 表示消费频率,M 表示消费金额。校园场景不能直接照搬电商口径,因为校内消费是刚需高频行为,最近一次消费距离都集中在几天内,直接用原始值区分度很差。

指标电商口径校园消费口径
R最近一次购买距今的天数最近一次校内消费距今的天数
F购买次数(按订单)30 天内的交易次数
M购买总金额30 天内的消费总金额

计算时先确定观察窗口,一般取数据截至日往前推 30 天:

end_date = consum["trade_time"].max() rdf = consum.groupby("student_id")["trade_time"].max().reset_index() rdf["recency"] = (end_date - rdf["trade_time"]).dt.days window_start = end_date - pd.Timedelta(days=30) fdf = ( consum[consum["trade_time"] > window_start] .groupby("student_id")["amount"] .count() .rename("frequency") .reset_index() ) mdf = ( consum[consum["trade_time"] > window_start] .groupby("student_id")["amount"] .sum() .rename("monetary") .reset_index() )

逻辑说明:end_date用全量数据的最大时间戳,保证所有学生的 R 值都是相对同一个截止点计算的。recency是最后一次消费距end_date的天数,数值越小代表越活跃。F 和 M 都限制在最近 30 天窗口内,是为了让不同入学时间、不同离校状态的学生处于可比状态。

30 天窗口不是唯一选择。按自然月滚动计算适合做月度报表,学期内窗口能覆盖整学期的消费积累。窗口越长,高频学生的 F 值方差越大;窗口越短,对节假日越敏感。刚开始跑通流程时先用 30 天,后续再和业务方确定固定的“月度窗口”定义。

4.2 数据标准化与 KMeans 聚类的参数选择

把三个指标合并成 RFM 表后,不能直接喂给 KMeans。monetary的数值范围可能是recency的几十倍,距离计算会被金额主导,最常用的处理是用StandardScaler做 z-score 标准化。

from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler rfm = rdf.merge(fdf, on="student_id").merge(mdf, on="student_id") features = rfm[["recency", "frequency", "monetary"]].copy() scaler = StandardScaler() X = scaler.fit_transform(features) for k in range(2, 7): model = KMeans(n_clusters=k, random_state=42, n_init=10) model.fit(X) print(k, round(model.inertia_, 2)) best_k = 3 model = KMeans(n_clusters=best_k, random_state=42, n_init=10) model.fit(X)

逻辑说明:StandardScaler把每个特征变换成均值 0、方差 1 的分布,消除量纲差异。循环从k=2试到k=6并打印每个 k 对应的inertia,也就是样本到所属簇中心距离的平方和。k 增大时inertia必然下降,下降幅度明显变缓的位置就是候选 k;例如 2 到 3 降了 30%,3 到 4 只降 5%,那么best_k=3是合适取值。

random_state=42固定随机种子保证可复现,n_init=10让算法用 10 组不同初始中心各跑一次取最优。如果样本量超过五万,可以把n_init降到 4 或 5 换取时间。聚类完成后不要只看inertia,还可以配合silhouette_score辅助验证,但校园项目里最重要的标准是簇画像能不能在业务上解释清楚。

4.3 聚类结果的可解释性:画像输出与交叉验证

聚类完成后,把每个簇的特征均值还原成业务语言。最直接的方式是输出分簇统计表,再算每个簇的人数占比。

rfm["cluster"] = model.labels_ cluster_profile = rfm.groupby("cluster").agg( avg_recency=("recency", "mean"), avg_frequency=("frequency", "mean"), avg_monetary=("monetary", "mean"), size=("student_id", "count"), ) cluster_profile["share"] = ( cluster_profile["size"] / cluster_profile["size"].sum() ) print(cluster_profile.round(2))

逻辑说明:model.labels_是每个学生对应的簇编号,赋值给rfm后就能按簇聚合。avg_recency小、avg_frequencyavg_monetary高的一类,可命名为“高频高消费”;avg_recency大、avg_frequency低的类别,属于“活跃度下降型”。每个簇的命名不需要自动生成,靠人根据均值特征来归纳,结论更可信。

仅看均值不够,建议补充打印每个簇在三个特征上的最小值与最大值,避免个别极端学生把簇均值拉偏。人数占比上,如果某个簇占比低于 5%,就要考虑它到底是独立群体,还是离群记录聚成的噪声簇;噪声簇不参与后续名单输出,直接标记为“样本量过小,暂不研判”。

5. 进阶:异动识别与名单输出的 Python 落地技巧

建模之后,项目真正交付的不只是数字,而是能进入工作流的成果。这章把聚类结果落到两个具体动作上:识别消费异动学生、输出可读名单。

5.1 用双窗口对比计算消费异动因子

相比直接对比两个时间段的总额,用日均更公平。这里取最近 7 天作为观察窗口,往前推 23 天作为基线窗口,两个窗口长度不一致,因此先转换成日均再计算变化比例。

end_date = consum["trade_time"].max() recent_mask = consum["trade_time"] > end_date - pd.Timedelta(days=7) baseline_mask = ( (consum["trade_time"] > end_date - pd.Timedelta(days=30)) & (consum["trade_time"] <= end_date - pd.Timedelta(days=7)) ) recent_daily = ( consum[recent_mask].groupby("student_id")["amount"].sum() / 7 ).rename("recent_daily") baseline_daily = ( consum[baseline_mask].groupby("student_id")["amount"].sum() / 23 ).rename("baseline_daily") anomaly = recent_daily.to_frame().join(baseline_daily, how="left") anomaly["change_ratio"] = anomaly["recent_daily"] / anomaly["baseline_daily"] anomaly = anomaly[anomaly["change_ratio"] > 1.5].dropna()

逻辑说明:recent_daily是最近 7 天的日均消费,baseline_daily是此前 23 天的日均消费,change_ratio大于 1.5 表示近一周消费水平比基线高出 50% 以上。阈值 1.5 是可调参数,学工场景下先按 top 5% 学生数反推阈值,比硬编码合理。dropna剔除基线窗口无消费记录的样本,这类样本无法计算变化比例,需要单独归入“新入校”名单。

如果某个学生 7 天内完全没有消费记录,也要单独输出一个列表,这通常是离校、请假或长期未使用校园卡的信号,与“消费上升”是两类完全不同的关注方向。

5.2 名单输出与隐私处理的细节

最后把聚类标签和异动因子合并到一张表,输出给使用方。使用方只应该看到学号、类别标签和异动方向,不建议在 CSV 里附带消费金额明细。

cluster_name = {0: "稳定型", 1: "波动型", 2: "高活跃型"} report = rfm[["student_id", "cluster"]].merge( anomaly[["change_ratio"]], on="student_id", how="left" ) report["cluster_label"] = report["cluster"].map(cluster_name) report["change_ratio"] = report["change_ratio"].fillna(0).round(2) report.to_csv("consumption_analysis_result.csv", index=False, encoding="utf-8-sig")

输出文件的核心字段如下:

字段说明
student_id学生学号
cluster_label群体标签,如“稳定型”“波动型”
change_ratio近 7 天日均相对基线的变化倍数

逻辑说明:encoding="utf-8-sig"会让生成的 CSV 在 Excel 中直接打开不乱码,这是 Python 写中文数据文件时最常被忽略的参数。cluster_name由 4.3 的簇画像人工映射,这里假设聚类数为 3;字段只保留标签,不保留消费明细,降低数据泄露风险。

提示:名单使用方如果需要核查明细,应走原有学工系统查询流程,不要在 CSV 里附带卡内余额、具体商户等敏感字段。

拿到异动名单后按学院拆分,再和课程表、节假日等日历数据做交叉比对,就能区分出正常波动还是需要关注的情况。阈值和窗口参数直接放在脚本顶部,换学期时只需调整两个数字即可重新出表。

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

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

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

立即咨询