☰
Python校园一卡通消费行为分析:KMeans聚类与可视化实战
2026/10/12 12:17:42 网站建设 项目流程

简介:这是一份面向高校学生与数据分析初学者的Python校园消费行为分析完整项目包,适用于毕业设计、期末大作业与课程设计场景,帮助读者从零完成数据采集、清洗、分析与可视化全流程。资源共21个文件,以7个ipynb交互式笔记、3个py脚本、7张jpg结果图为主,另含docx分析报告、md说明文档与txt附录,压缩包约18.93MB,代码附有注释,新手也能看懂。项目围绕食堂就餐占比、就餐峰值、不同性别消费对比等维度展开,涵盖聚类分析、关联规则与预测模型构建思路,并配有完整结果集与报告模板,可直接部署运行。目前已有336人学习下载,适合希望掌握Python数据处理与分析技术、了解消费行为模型构建与报告撰写技巧的读者参考借鉴。

1. 从一份校园一卡通流水说起:学生消费行为分析到底在分析什么

手里拿到一份校园一卡通流水,字段通常长这样:学号、姓名、性别、年级、专业、交易时间、交易金额、商户名称、消费类型、卡余额。数据量不大,一个几千人的学校,一学期也就几十万到上百万条。很多同学第一反应是直接上groupby求个均值,画个柱状图就交差。但真正做过的人知道,这份数据里藏着的问题远比"人均月消费多少"复杂:有人一天只吃两顿,有人一天刷六次卡,有人月初猛花月末吃土,有人几乎不在食堂消费却在超市高频小额支出。这些模式靠一个均值根本看不出来。

这篇要讲的就是用 Python 把这份流水从原始表变成可解释的行为画像:先做数据清洗和特征构造,再用聚类把学生分成几类消费模式,最后用可视化把结论落到图表上。适合手里有校园消费数据、想做出有说服力分析结果的学生和刚入行的数据分析从业者。不需要机器学习背景,但需要会基本的 pandas 操作。整套流程跑通大概两三个小时,代码量在三百行以内。

2. 数据清洗与特征构造:把流水变成一人一行的分析表

2.1 先搞清楚原始数据的几个典型脏法

校园一卡通数据看着规整,实际打开经常遇到这几类问题。第一,交易时间字段格式不统一,有的是2024-03-15 12:30:45,有的是2024/3/15 12:30,还有的干脆是 Excel 序列号(比如45366.521)。第二,金额字段带货币符号或者千分位逗号,1,234.50直接读进来是字符串。第三,存在退款记录,金额为负,如果不处理会拉低消费均值。第四,同一笔交易可能因为系统同步问题出现重复行。第五,部分学生整个学期只有个位数交易记录,可能是休学或者数据缺失,聚类时会把噪声放大。

清洗的核心原则是:先统一类型,再处理异常,最后构造特征。不要一上来就删行,先标记出来,后面按需过滤。

import pandas as pd import numpy as np # 读取原始流水,注意编码,校园系统导出常见 gbk df = pd.read_csv("raw_consume.csv", encoding="gbk") # 统一时间格式:先尝试标准解析,失败的用 Excel 序列号转换 def parse_time(val): try: return pd.to_datetime(val) except Exception: # Excel 序列号:从 1899-12-30 起算的天数 return pd.Timestamp("1899-12-30") + pd.to_timedelta(float(val), unit="D") df["交易时间"] = df["交易时间"].apply(parse_time) # 金额清洗:去掉逗号和货币符号,转 float df["交易金额"] = ( df["交易金额"].astype(str) .str.replace(",", "", regex=False) .str.replace("¥", "", regex=False) .astype(float) ) # 标记退款和重复 df["is_refund"] = df["交易金额"] < 0 df["is_dup"] = df.duplicated(subset=["学号", "交易时间", "交易金额"], keep=False) print(df[["交易时间", "交易金额", "is_refund", "is_dup"]].describe())

这段代码的关键点在于parse_time函数做了兜底:标准格式解析失败时按 Excel 序列号处理。参数上,unit="D"表示按天计算,如果你的数据精确到秒,需要改成unit="s"并调整基准时间。金额清洗用str.replace而不是pd.to_numeric(errors="coerce"),是因为后者遇到货币符号会直接变 NaN,丢失信息。is_dup用keep=False标记所有重复行而不只是保留第一条,方便你后续决定是去重还是人工核查。

2.2 构造真正有区分度的行为特征

清洗完之后,原始流水是"一人多行",聚类需要"一人一行"。常见的做法是直接对消费金额求均值、求和、计数,但这三个特征区分度很低——大部分学生的月消费总额都集中在两三千,均值都在十几块。真正能拉开差距的是时间维度和频次维度的特征。

我一般会构造这几类:消费频次(日均交易笔数)、消费时段分布(早餐/午餐/晚餐/夜宵各占比)、消费场所偏好(食堂占比 vs 超市占比)、消费稳定性(日消费金额的变异系数)、月末效应(月末五天消费占全月比例)。这些特征背后对应的是真实行为差异:早餐占比高说明作息规律,超市占比高说明可能不在食堂吃正餐,变异系数大说明消费不稳定。

# 过滤退款和重复,只保留正常消费 df_clean = df[(~df["is_refund"]) & (~df["is_dup"])].copy() # 提取时间维度 df_clean["日期"] = df_clean["交易时间"].dt.date df_clean["小时"] = df_clean["交易时间"].dt.hour df_clean["是否月末"] = df_clean["交易时间"].dt.day >= 25 # 定义时段:早餐 6-9,午餐 10-13,晚餐 16-19,夜宵 20-23 def meal_period(h): if 6 <= h < 10: return "早餐" elif 10 <= h < 14: return "午餐" elif 16 <= h < 20: return "晚餐" elif 20 <= h < 24: return "夜宵" return "其他" df_clean["时段"] = df_clean["小时"].apply(meal_period) # 按学号聚合 agg = df_clean.groupby("学号").agg( 总消费=("交易金额", "sum"), 交易笔数=("交易金额", "count"), 日均笔数=("日期", "nunique"), 消费标准差=("交易金额", "std"), 消费均值=("交易金额", "mean"), ).reset_index() # 日均笔数修正为 总笔数/活跃天数 agg["日均笔数"] = agg["交易笔数"] / agg["日均笔数"] # 时段占比 period_pivot = df_clean.pivot_table( index="学号", columns="时段", values="交易金额", aggfunc="sum", fill_value=0 ) period_ratio = period_pivot.div(period_pivot.sum(axis=1), axis=0) period_ratio.columns = [f"{c}占比" for c in period_ratio.columns] # 月末消费占比 month_end = df_clean.groupby("学号").apply( lambda x: x[x["是否月末"]]["交易金额"].sum() / x["交易金额"].sum() ).rename("月末占比") # 合并所有特征 features = agg.merge(period_ratio, on="学号", how="left").merge( month_end, on="学号", how="left" ) features["变异系数"] = features["消费标准差"] / features["消费均值"] features = features.fillna(0) print(features.shape) print(features.head())

这里有几个参数需要根据你的数据调整。时段划分的边界不是固定的,有的学校早餐到 9 点半,有的夜宵到凌晨 1 点,需要看实际交易时间分布来定。是否月末用 25 号作为分界是常见做法,但如果你发现学生消费高峰在 28 号之后,可以改成 28。变异系数用标准差除以均值,比单纯看标准差更合理,因为它消除了消费水平高低的影响——一个月花 5000 的学生标准差 200,和一个月花 1000 的学生标准差 200,稳定性完全不同。

提示:groupby.apply在数据量大时比较慢,如果流水超过百万行,建议改用向量化方式计算月末占比,比如先标记再分组求和。

3. 用 KMeans 做消费模式聚类:选几个类、怎么解释结果

3.1 特征标准化和 K 值选择

特征构造完之后,直接扔进 KMeans 会出问题。总消费的量级是几千,早餐占比是 0 到 1 之间的小数,欧氏距离会被大数值特征主导,聚类结果基本只反映消费总额。所以必须先做标准化。常见做法是 Z-score 标准化(StandardScaler),把每个特征变成均值 0、方差 1。如果你的特征里有明显的偏态分布(比如总消费右偏严重),可以先做对数变换再标准化。

K 值选择是另一个绕不开的问题。教科书上说看肘部法则,但实际数据里肘部经常不明显。我的做法是同时看三个指标:轮廓系数(越高越好)、Calinski-Harabasz 指数(越高越好)、以及每个类的样本量是否均衡。如果某个类只有十几个人,那这个类大概率是噪声,没有解释价值。校园消费数据一般分 3 到 5 类比较合理,太少区分度不够,太多解释成本高。

from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score, calinski_harabasz_score # 选取用于聚类的特征列 feature_cols = [ "总消费", "日均笔数", "变异系数", "月末占比", "早餐占比", "午餐占比", "晚餐占比", "夜宵占比" ] X = features[feature_cols].values # 对数变换处理右偏,加 1 避免 log(0) X_log = np.log1p(np.abs(X)) * np.sign(X) scaler = StandardScaler() X_scaled = scaler.fit_transform(X_log) # 遍历 K 值 for k in range(2, 8): km = KMeans(n_clusters=k, random_state=42, n_init=10) labels = km.fit_predict(X_scaled) sil = silhouette_score(X_scaled, labels) ch = calinski_harabasz_score(X_scaled, labels) sizes = np.bincount(labels) print(f"K={k} 轮廓系数={sil:.3f} CH={ch:.0f} 各类样本={sizes}")

n_init=10表示用不同初始质心跑 10 次取最优,避免陷入局部最优。random_state=42保证结果可复现。对数变换那行np.log1p(np.abs(X)) * np.sign(X)是为了处理可能的负值(虽然消费特征一般不会有负),如果你的数据确认全正,直接np.log1p(X)就行。看输出的时候,不要只盯着轮廓系数最高的那个 K,还要看样本分布。如果 K=4 时轮廓系数 0.42 但有一个类只有 8 个人,K=3 时轮廓系数 0.38 但三类各占 30%、40%、30%,那选 K=3 更稳。

3.2 给每个类贴上可解释的标签

聚类本身只给出 0、1、2 这样的编号,真正有价值的是把每个类的特征均值拿出来对比,然后用人话描述它。比如某个类的特征是:总消费高、日均笔数多、早餐占比高、变异系数低——这就是"规律高消费型",可能是家庭条件好且作息规律的学生。另一个类:总消费低、日均笔数少、夜宵占比高、变异系数大——这是"低频不规律型",可能经常点外卖或者作息混乱。

# 假设选定 K=4 km_final = KMeans(n_clusters=4, random_state=42, n_init=10) features["类别"] = km_final.fit_predict(X_scaled) # 查看每个类的特征均值 cluster_profile = features.groupby("类别")[feature_cols].mean().round(2) cluster_profile["人数"] = features.groupby("类别").size() print(cluster_profile) # 按总消费排序后重命名类别,方便解释 order = cluster_profile["总消费"].sort_values(ascending=False).index label_map = { order[0]: "高消费规律型", order[1]: "中消费稳定型", order[2]: "低消费波动型", order[3]: "低频夜宵型", } features["消费标签"] = features["类别"].map(label_map) print(features["消费标签"].value_counts())

标签命名没有标准答案,关键是让看报告的人一眼能理解。我一般会从两个维度组合:消费水平(高/中/低)+ 行为特征(规律/波动/夜宵/早餐)。如果某个类的特征特别突出,比如月末占比超过 0.5,那就叫"月末集中型"。命名之后一定要回头验证:随机抽几个该类的学生,看他们的原始流水是不是符合标签描述。如果发现标签和实际行为对不上,说明特征构造或者 K 值有问题,需要回去调整。

注意:KMeans 对异常值敏感,如果特征里有极端值(比如某个学生总消费是均值十倍),标准化后仍然会拉偏质心。建议在聚类前用 IQR 方法识别并剔除极端值,或者改用 KMedoids 这类对异常值更鲁棒的算法。

4. 可视化与结果输出:让分析结论能直接放进报告

4.1 四张图讲清楚消费模式差异

分析结果最终要落到图上。我一般会出四张图:第一张是各消费类型的占比饼图或者环形图,展示人群分布;第二张是雷达图,对比不同类别在六个维度上的差异;第三张是消费时段热力图,横轴是小时、纵轴是星期几,颜色深浅表示交易频次;第四张是箱线图,对比各类别的日消费金额分布。这四张图基本能覆盖"谁在消费、什么时候消费、消费多少、消费稳不稳定"四个问题。

import matplotlib.pyplot as plt import matplotlib matplotlib.rcParams["font.sans-serif"] = ["SimHei"] matplotlib.rcParams["axes.unicode_minus"] = False # 图1:各类别人数占比 fig, axes = plt.subplots(2, 2, figsize=(14, 10)) label_counts = features["消费标签"].value_counts() axes[0, 0].pie(label_counts.values, labels=label_counts.index, autopct="%1.1f%%", startangle=90) axes[0, 0].set_title("各消费类型人数占比") # 图2:雷达图对比 from matplotlib.patches import Polygon angles = np.linspace(0, 2 * np.pi, len(feature_cols), endpoint=False).tolist() angles += angles[:1] ax = fig.add_subplot(2, 2, 2, polar=True) for label in features["消费标签"].unique(): vals = features[features["消费标签"] == label][feature_cols].mean().tolist() vals += vals[:1] ax.plot(angles, vals, label=label) ax.fill(angles, vals, alpha=0.1) ax.set_xticks(angles[:-1]) ax.set_xticklabels(feature_cols, fontsize=8) ax.set_title("各类型特征雷达图") ax.legend(loc="upper right", fontsize=7) # 图3:消费时段热力图 df_clean["星期"] = pd.to_datetime(df_clean["交易时间"]).dt.dayofweek heat = df_clean.pivot_table(index="星期", columns="小时", values="交易金额", aggfunc="count", fill_value=0) im = axes[1, 0].imshow(heat.values, aspect="auto", cmap="YlOrRd") axes[1, 0].set_yticks(range(7)) axes[1, 0].set_yticklabels(["周一", "周二", "周三", "周四", "周五", "周六", "周日"]) axes[1, 0].set_xlabel("小时") axes[1, 0].set_title("消费时段热力图") plt.colorbar(im, ax=axes[1, 0]) # 图4:日消费箱线图 daily = df_clean.groupby(["学号", "日期"])["交易金额"].sum().reset_index() daily = daily.merge(features[["学号", "消费标签"]], on="学号") daily.boxplot(column="交易金额", by="消费标签", ax=axes[1, 1], rot=15) axes[1, 1].set_title("各类型日消费分布") axes[1, 1].set_xlabel("") axes[1, 1].set_ylabel("日消费金额") plt.tight_layout() plt.savefig("consume_analysis.png", dpi=150) plt.show()

中文字体那两行是必须的,否则标题和标签会显示成方块。SimHei在 Windows 上一般都有,Mac 上可能需要换成Arial Unicode MS或者PingFang SC。雷达图那里fig.add_subplot(2, 2, 2, polar=True)是在 2x2 网格的第二个位置放一个极坐标图,注意它和axes[0, 1]是重叠的,实际使用时建议单独画雷达图,不要混在 subplot 里,否则布局容易乱。热力图的aggfunc="count"统计的是交易笔数而不是金额,因为笔数更能反映人流规律。

4.2 结果集输出:CSV 加一份可读的汇总表

图是给人看的,CSV 是给后续分析用的。我一般会输出两个文件:一个是student_features.csv,包含每个学生的学号、所有特征、聚类类别和标签;另一个是cluster_summary.csv,每个类一行,包含人数、各特征均值、占比。这样别人拿到结果可以直接做透视表或者导入 BI 工具。

# 输出学生级特征表 features.to_csv("student_features.csv", index=False, encoding="utf-8-sig") # 输出类别汇总表 summary = features.groupby("消费标签").agg( 人数=("学号", "count"), 总消费均值=("总消费", "mean"), 日均笔数均值=("日均笔数", "mean"), 变异系数均值=("变异系数", "mean"), 月末占比均值=("月末占比", "mean"), 早餐占比均值=("早餐占比", "mean"), 夜宵占比均值=("夜宵占比", "mean"), ).round(3).reset_index() summary["人数占比"] = (summary["人数"] / summary["人数"].sum()).round(3) summary.to_csv("cluster_summary.csv", index=False, encoding="utf-8-sig") print(summary)

encoding="utf-8-sig"是为了 Excel 打开不乱码,比utf-8多一个 BOM 头。如果你后续要用 Python 再读这个文件,用encoding="utf-8-sig"也能正常读。汇总表里我保留了人数占比,因为报告里经常需要说"这类学生占全校的百分之多少"。所有均值保留三位小数,够用且不冗长。

5. 避坑与排查:校园消费分析里最容易翻车的五个地方

5.1 时间字段解析失败导致特征全错

现象:pd.to_datetime报错或者返回一堆NaT,后续时段划分全部落到"其他",早餐占比全是 0。原因:校园系统导出的时间格式不统一,有的带毫秒,有的用中文"年/月/日",有的干脆是 Excel 序列号。解决:不要直接用pd.to_datetime(df["时间"]),先df["时间"].head(20)看一眼实际格式,然后写一个带try-except的解析函数,把常见格式都覆盖到。如果数据量不大,最稳妥的办法是导出时就让系统管理员统一格式。

5.2 退款记录没过滤,消费均值被拉低

现象:某个学生总消费是负数,或者均值明显低于同类型学生。原因:一卡通系统里退款、补助发放、卡片工本费扣款都会产生负金额记录,这些不是消费行为。解决:在清洗阶段用df["交易金额"] < 0标记退款,分析消费行为时排除。但注意,补助发放虽然是正数,也不属于消费,如果数据里有"交易类型"字段,最好按类型过滤,只保留"消费"类型。

5.3 聚类特征量纲差异大,结果只反映消费总额

现象:聚类结果里各类别的总消费差异明显,但其他特征几乎没区别。原因:没有做标准化,总消费的数值范围是几千,占比特征是 0 到 1,欧氏距离被大数值主导。解决:聚类前必须StandardScaler。如果做完标准化还是只反映总消费,检查一下是不是某个特征方差特别大,可以考虑先做对数变换或者用RobustScaler。

5.4 样本量太少的学生拉偏聚类中心

现象:某个类只有几个人,但特征均值很极端。原因:整个学期交易记录少于 10 条的学生,特征计算本身就不稳定,日均笔数可能是 0.1 这种异常值。解决:在构造特征之前,先统计每个学生的交易笔数,把少于某个阈值(比如 20 笔)的学生单独拿出来,不参与聚类,在报告里说明"因数据量不足未纳入分析"。阈值根据数据总量定,一般取总笔数的 1% 分位数。

5.5 类别标签命名和实际行为对不上

现象:把某个类命名为"早餐规律型",但抽查原始流水发现这类学生早餐交易很少。原因:特征计算有误,或者标签命名时只看了一个特征没看整体。解决:命名之前,把每个类的特征均值和整体均值做对比,找出偏离最大的两三个特征,用这几个特征组合命名。命名之后必须抽查验证,随机抽 5 个该类的学生,看他们的原始流水是否符合标签描述。对不上就回去检查特征计算逻辑。

6. 从聚类结果到干预建议:一个可复用的分析习惯

聚类跑完、图出完、CSV 导出完,很多人就停了。但校园消费分析真正的价值在于从结果里读出可行动的信息。比如"低频夜宵型"如果占比超过 15%,可能说明食堂晚餐供应时间太短或者夜宵窗口不足;"月末集中型"占比高,可能说明学生月初消费缺乏规划,学校可以考虑在月末推送消费提醒。这些结论不需要复杂的统计模型,但需要你把聚类结果和学校管理场景对上。

我自己的习惯是,每次做完聚类,都会额外算一个交叉表:消费标签 × 年级、消费标签 × 性别、消费标签 × 专业大类。很多时候你会发现某个标签在特定群体里高度集中,比如大一新生里"高消费规律型"占比明显高于大四,这可能和住宿方式、课程安排有关。这个交叉表用pd.crosstab一行就能出:

# 假设原始数据里有 年级 和 性别 字段 stu_info = df[["学号", "年级", "性别"]].drop_duplicates("学号") features_with_info = features.merge(stu_info, on="学号", how="left") # 消费标签 × 年级 交叉表 ct_grade = pd.crosstab(features_with_info["消费标签"], features_with_info["年级"], normalize="columns").round(3) print(ct_grade) # 消费标签 × 性别 ct_gender = pd.crosstab(features_with_info["消费标签"], features_with_info["性别"], normalize="columns").round(3) print(ct_gender)

normalize="columns"表示按列归一化,这样每一列加起来是 1,方便对比不同年级内部的消费类型分布。如果某个年级的"低消费波动型"占比达到 0.4,而其他年级只有 0.2,这就是一个值得深挖的信号。交叉表不需要画图,直接打印出来看数字就行,但它是从"分析结果"到"业务洞察"的关键一步。

还有一个我踩过的坑:不要试图用聚类结果去预测或者评判个体。聚类是把相似的人归到一起,它描述的是群体模式,不是个人标签。把某个学生标记为"低消费型"然后去做精准干预,既不准确也不合适。正确的用法是看群体占比和趋势,比如"本学期低消费波动型占比比上学期上升了 5 个百分点",这才是可用的信息。

最后说一个习惯:每次分析完,把特征构造的代码单独存成一个feature_engineering.py,把聚类和可视化的代码存成analysis.py。下次拿到新学期的数据,只需要改一下输入文件路径和几个时间参数,整套流程就能复用。校园消费数据的字段结构每学期基本不变,一次写好,后面就是改改参数的事。希望帮到你。

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

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

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

立即咨询