尧图精选

Python校园一卡通消费行为分析:从数据清洗到聚类实战

🕒 发布时间:2026/10/1 7:23:58 📁 来源:尧图网络
简介面向Python数据分析学习者与校园信息化研究者这是一套基于Python的学生校园消费行为分析项目源码。项目定位为个人课程大作业源码经本地编译调试可稳定运行评审分达95分以上难度适中内容获助教审定适合作为数据采集、清洗、可视化与模式识别全流程的实战参考。代码通过分析学生食堂消费、校园卡等记录运用Pandas、Matplotlib、Seaborn等工具完成探索性分析与图表绘制并引入聚类分析、关联规则挖掘识别消费规律。包体共21个文件压缩包22.04MB包含4个py源码文件、4个csv数据集、9个png结果图及3个jpg演示图等以及可选的Django商城项目压缩包。源码、数据、结果目录分区清晰便于按模块对照学习目前已有249人学习下载下载后可获得可直接运行的完整代码、已清洗的数据集、可视化展示结果以及从数据预处理到消费模式识别的完整可复现方案对理解Python数据分析全链路很有帮助。1. 基于Python的校园消费行为分析到底要处理什么数据、产出什么结论基于Python的学生校园消费行为分析拆开看就是一个标准的数据全流程落地项目拿校园一卡通的消费流水当数据集用Python源码把清洗、特征工程、聚类、可视化依次走通最后产出一份能指导食堂档口排班和商户运营调度的结论。校园里每刷一笔卡系统就记下一行流水谁、几点、在哪、花了多少、余额剩多少这些字段凑在一起恰好构成了最干净的消费行为样本。这个方向适合两类人一类是刚学完pandas和sklearn、想找个真实业务练手的学生另一类是学校信息中心或后勤部门的工程师想把一卡通数据从“查余额黑匣子”变成每周能看的经营报表。它能回答的问题很具体食堂早中晚的高峰窗口到底怎么切、哪个档口留不住人、哪些学生的消费节奏和整体偏离很大。下面我按自己跑这类项目的顺序把从原始流水到最终报告的全过程拆开讲。2. 一卡通流水的清洗与字段归一化先看懂这台“数据黑匣子”里存了什么2.1 原始流水长什么样字段口径要在一开始定死一卡通流水表看着简单真正打开导出文件时才会发现字段名五花八门有叫“交易时间”的有叫“日期时分秒”拆成两列的还有金额字段带着正负号混在一起存的。拿到数据的第一步不是跑模型而是把字段口径确认清楚否则后面每一步都会返工。常见字段是下面这组我一般会在项目里先建一个字典做统一命名字段名常见别名说明student_id学号、card_no学生身份主键注意脱敏需求trade_time交易时间、消费日期字符串类型原始格式不一定统一merchant_id商户号、pos_no商户编码可能缺失merchant_name商户名称、消费地点自由文本噪声很大amount消费金额、交易金额可能包含负数和零元balance余额、卡余额可作为反作弊校验字段trade_type交易类型、业务类型消费、退款、圈存、补贴等拿到原始CSV后我习惯先跑一段“摸底”代码把每列的缺失率、类型、取值范围扫一遍而不是急着画图。这一步能拦住很多后面才会爆的雷。import pandas as pd # 读取原始流水校园系统导出的CSV老爱用gbk编码先加errors参数兜底 df pd.read_csv(data/raw/card_trade.csv, encodinggbk, errorsignore) # 统一列名硬编码映射表是最笨也最稳的做法 col_map { 学号: student_id, 卡号: student_id, 交易日期: trade_time, 消费时间: trade_time, 商户名称: merchant_name, 消费金额: amount, 交易金额: amount, 卡余额: balance, } df.rename(columnscol_map, inplaceTrue) # 扫一遍每个字段的缺失率和类型后面排错就靠这张表 summary pd.DataFrame({ dtype: df.dtypes.astype(str), null_count: df.isnull().sum(), null_ratio: df.isnull().mean().round(4), sample: df.iloc[0].astype(str), }) print(summary)这段代码做的事情就是把一张“不知道里面有什么”的表变成一份字段体检单。dtype那一列尤其重要因为原始导出文件里学号经常是数字格式前导零被吃掉后面和学院班级表做关联时就会对不上。null_ratio一眼能看出哪些字段是半空的比如merchant_id缺失率有三分之一以上那说明很多流水只记了商户名没记编码这种事在老旧食堂POS机上非常常见。2.2 负数金额、退款流水和系统拆账怎么区分第一次跑统计的时候我发现日均消费金额比预期高了一大截细查才发现问题不在学生而在流水里有大量负数。校园一卡通的负数流水来源大概有三类学生主动退款、POS机冲正网络抖动导致交易取消后自动反向记账、以及系统自动拆账比如先冻结一笔定额再逐笔扣款扣完退余额。如果不把这三种识别出来金额统计马上被污染。最靠谱的识别顺序是先看有没有trade_type字段有就直接用它过滤没有的话用“金额为负 时间窗 商户”的启发式规则去猜。我一般是先把可疑记录捞出来看占比和分布再决定是删是留。# 先把金额转成数值原始数据里偶尔会把金额列读成字符串 df[amount] pd.to_numeric(df[amount], errorscoerce) # 负数记录单独捞出来看不要急着删 negative df[df[amount] 0].copy() print(f负数记录占比: {len(negative) / len(df) * 100:.2f}%) # 检查同一学号同一天内是否有金额正负抵消的成对记录这是退款/冲正的特征 df[trade_date] pd.to_datetime(df[trade_time], errorscoerce).dt.date dup_check ( df.groupby([student_id, trade_date, merchant_name])[amount] .agg([count, sum]) .reset_index() ) suspicious dup_check[ (dup_check[count] 2) (dup_check[sum].abs() 0.01) ] print(f疑似成对冲销记录: {len(suspicious)} 组)这里最关键的是pd.to_numeric的errorscoerce它能保证脏字符串转成NaN而不是中断报错。成对冲销的逻辑是同一个学生在同一天同一个商户一笔正一笔负金额绝对值相同合计趋近于零这种基本可判定为退款或冲正。处理策略我一般保留正的那一笔把负的标记为“已撤销”而不是直接删掉因为后续做频次统计时撤销记录如果还留在里面会让这个学生看起来“多来了一次食堂”。2.3 商户名称与消费地点的清洗别名映射绕不开商户名是自由文本脏得最厉害。同一个一食堂一楼流水里可能有“一食堂一层”“一食1F”“第一食堂01号窗”三种写法。如果不做归一化后面所有按商户聚合的图表都会碎成一地。这块没有黑科技就是用一张别名映射表慢慢补把它当数据资产维护。# 别名映射表实际项目里会越补越全建议单独存成csv维护 alias_map { 一食堂一层: 一食堂1F, 一食1F: 一食堂1F, 第一食堂01号窗: 一食堂1F, 二食堂二楼: 二食堂2F, 二食2F: 二食堂2F, 清真窗口: 清真食堂, 西点房: 面包房, } # 先精确映射 df[merchant_clean] df[merchant_name].map(alias_map) # 没命中的再按包含关系兜底比如一食堂三个字出现在任意位置 mask df[merchant_clean].isnull() df.loc[mask, merchant_clean] df.loc[mask, merchant_name].str.extract( r(一食堂|二食堂|清真|面包房), expandFalse ) # 最后剩下的空值统一标为未知商户 df[merchant_clean] df[merchant_clean].fillna(未知商户) # 打印清洗前后商户数量对比验证归一化效果 print(清洗前商户数:, df[merchant_name].nunique()) print(清洗后商户数:, df[merchant_clean].nunique())map做精确匹配str.extract做模糊兜底两层下来基本能覆盖八成以上。less打印清洗前后的商户数量是很直观的验收方式如果归一化有效唯一值数量会明显下降。这里踩过的坑是不要一上来就用正则硬匹配全表因为有些商户名是“一食堂一楼东侧水吧”这种包含“一食堂”但不是同一业态强行归过去会让后面做档口类型分析时出现滑稽的“食堂卖奶茶”错位。3. 从流水到行为特征早餐时段划分、日消费稳定性和食堂依赖度3.1 时间特征把消费时间切成早中晚并处理跨天归属消费行为分析不能只看金额更要看“什么时候花”。校园消费的时段特征极其明显早餐集中在6点到9点午餐从10点半开始排到13点半晚餐16点半到19点半之后是夜宵档。我一般把一天切五个时段最后再单独统计凌晨时段作为异常消费线索。跨天归属是个很容易忽略的细节凌晨零点到五点之间的消费应该归属到前一天还是当天我的做法是统一归到前一天——因为凌晨刷卡的场景基本是前一晚的延续比如刷夜自习后的夜宵或者通宵超市购物。如果按自然日切这些记录会被算成“新一天早起”会污染早餐时段的统计。import numpy as np # 把trade_time转成datetime类型这一步在任何时间分析之前都必须做 df[trade_dt] pd.to_datetime(df[trade_time], errorscoerce) df[hour] df[trade_dt].dt.hour df[minute] df[trade_dt].dt.minute df[time_min] df[hour] * 60 df[minute] # 定义时段边界单位是分钟避免直接做字符串比较 def assign_period(t): if t 5 * 60: return 凌晨 if t 9 * 60: return 早餐 if t 13 * 60 30: return 午餐 if t 19 * 60 30: return 晚餐 if t 23 * 60: return 夜宵 return 深夜 df[period] df[time_min].apply(assign_period) # 跨天修正凌晨00:00-04:59的消费归属到前一天 trade_date df[trade_dt].dt.date df[biz_date] np.where( df[period] 凌晨, trade_date - pd.Timedelta(days1), trade_date, ) df[biz_date] pd.to_datetime(df[biz_date])assign_period用分钟数做判断是为了避免在字符串上做大小比较——那种写法容易因为“9:00”和“09:00”格式不一致而翻车。biz_date是修正后的业务日期后面所有按天聚合都应该用这一列而不是原始日期。凌晨时段单独拎出来还有一个价值它本身就是异常行为线索比如有人连续一周凌晨在同一商户消费说明作息可能严重不规律。3.2 消费金额与频次日消费、标准差、周末效应这些指标怎么构建时段切好之后就要开始构造“能代表一个人消费习惯”的特征向量。这一步的思路和量化交易策略代码里做因子那套一脉相承把原始流水压缩成每个学生一行、每列一个数字的特征表。常用特征有四类消费频次、金额水平、金额波动和行为规律性。金额波动用标准差可能受极端值影响太大我一般用MAD绝对偏差中位数来刻画。规律性则用信息熵衡量这个学生一周七天的消费分布是否均匀——均匀意味着作息规律集中则说明有特殊节奏。# 按学生聚合构造核心消费特征 agg_daily ( df.groupby([student_id, biz_date]) .agg( day_amount(amount, sum), day_count(amount, count), ) .reset_index() ) # 在“每人每天”的基础上再聚合到“每人” student_feat ( agg_daily.groupby(student_id) .agg( total_amount(day_amount, sum), total_count(day_count, sum), avg_daily_amount(day_amount, mean), std_daily_amount(day_amount, std), avg_daily_count(day_count, mean), active_days(biz_date, nunique), ) .reset_index() ) # 用MAD替代标准差抵抗某天突然充值大额消费的干扰 from scipy.stats import median_abs_deviation mad_by_student ( agg_daily.groupby(student_id)[day_amount] .apply(lambda x: median_abs_deviation(x, scalenormal)) .reset_index() ) student_feat student_feat.merge(mad_by_student, onstudent_id, suffixes(, _mad)) print(student_feat.head())mean和std是最基础的但MAD这列才是真正体现工程经验的地方因为校园消费里有大量“平时吃食堂、周末出去聚餐”的学生标准差会被周末那两笔大额聚餐顶得很高反而盖过了平时稳定的部分。scalenormal的参数别省它让MAD在正态假设下和标准差同尺度方便后面和std列对比。3.3 食堂偏好和商户依赖度把地点字段编码成特征金额和时间只能看出“花多少、何时花”看不出“在哪花”。食堂偏好是很强的一个特征有的学生基本只在清真食堂活动有的学生三餐分散在三个食堂加面包房。这种偏好可以用两个指标表达top1商户消费占比以及消费地点的熵。# 每名学生各商户的消费占比 merchant_pivot ( df.groupby([student_id, merchant_clean])[amount] .sum() .reset_index() ) merchant_pivot[pct] merchant_pivot.groupby(student_id)[amount].transform(lambda x: x / x.sum()) # 取每个学生占比最高的商户 top1 ( merchant_pivot.sort_values(pct, ascendingFalse) .groupby(student_id) .head(1) .rename(columns{merchant_clean: top_merchant, pct: top_merchant_pct}) ) # 地点熵衡量消费地点分散程度 entropy ( merchant_pivot.groupby(student_id)[pct] .apply(lambda x: -(x * np.log(x)).sum()) .reset_index() .rename(columns{pct: merchant_entropy}) ) student_feat student_feat.merge(top1[[student_id, top_merchant, top_merchant_pct]], onstudent_id, howleft) student_feat student_feat.merge(entropy, onstudent_id, howleft)top_merchant_pct等于1说明这个学生几乎只在一个地方消费地点熵低熵高则说明消费分散。这两个特征对后面聚类时识别“生活单一型”和“社交活跃型”很有帮助。需要注意排序时用了head(1)而不是idxmax因为groupby后idxmax遇到重复最大值时可能返回多行head(1)直接取排序后的第一条行为更可预期。4. 用K-Means聚类做消费画像并让聚类结果可解释、可呈现4.1 特征标准化与PCA降维先保证聚类不是“玄学”特征表里各列量纲完全不同total_amount是几百到几千的大数merchant_entropy是0到3的小数。直接丢进K-Means距离会被金额特征主导熵这个维度基本不起作用。所以标准化是必须的一步不是可选项。我一般用StandardScaler因为它保留特征的分布形状。标准化之后还有个问题可视化时人眼只能看二维或三维而我们的特征表有十列左右。这时候PCA降维到二维不是为了丢掉信息而是为了画图检查聚类结果是否真的分得开。真正做模型输入时我仍然用标准化后的全量特征只用PCA做验证。from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA # 选出参与聚类的数值列排除student_id等主键和业务上不需要的字段 feat_cols [ avg_daily_amount, std_daily_amount, avg_daily_count, active_days, top_merchant_pct, merchant_entropy, ] X student_feat[feat_cols].fillna(0) scaler StandardScaler() X_scaled scaler.fit_transform(X) # PCA降到两维只用于可视化检查不用于真正的聚类输入 pca PCA(n_components2, random_state42) X_pca pca.fit_transform(X_scaled) print(前两个主成分累计解释方差比:, pca.explained_variance_ratio_.sum().round(3))fillna(0)在这里是有意为之因为有些学生可能没有商户名记录top_merchant_pct是空值填0代表“无偏好”而不是“出错”。explained_variance_ratio_是检验降维质量的关键指标如果两个主成分解释了不到七成方差说明数据维度太高画出来的散点图会叠成一团看聚类效果就得换个思路比如用TSNE或者直接不降维只依赖轮廓系数。4.2 轮廓系数选K并验证聚类结果的稳定性K-Means最痛苦的就是K怎么定。聚类不是回归没有标准答案但我还是坚持用轮廓系数做一次客观筛选而不是凭感觉拍一个K出来。轮廓系数取值范围是-1到1越接近1说明样本离自己簇中心近、离别的簇远聚类效果越好。在实际校园数据里轮廓系数能到0.25到0.35就已经算清晰了毕竟人的消费行为本身是连续谱强行分类必然有重叠。from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score # 尝试K2到K7记录轮廓系数选峰值或肘部 sil_scores {} models {} for k in range(2, 8): km KMeans(n_clustersk, n_init10, random_state42) labels km.fit_predict(X_scaled) sil_scores[k] silhouette_score(X_scaled, labels) models[k] km best_k max(sil_scores, keysil_scores.get) print(各K的轮廓系数:, sil_scores) print(最优K:, best_k)n_init10是照sklearn新版本的默认建议写的避免K-Means陷入局部最优。需要提醒的是轮廓系数本身也会偏心它偏好紧凑的球状簇而真实用户分群往往是长条状的所以不要只盯数字还要看每个簇的业务含义能不能解释。如果K3的轮廓系数是0.31、K4是0.28但K4分出的“深夜活跃型”在业务上有明确意义我宁可选K4。4.3 画像落库与结果导出给每类群体一个可读的标签选好K之后把聚类结果写回特征表再对每个簇做一次描述性统计人工给簇起名字。这部分是将统计结果转译成业务语言的最后一公里也是最见功夫的部分。我遇到过的典型校园数据大概能分出四类规律三餐型、高消费活跃型、节俭低频型和夜宵依赖型。# 用最优K重新拟合打标写回 final_model models[best_k] student_feat[cluster] final_model.predict(X_scaled) # 按簇聚合输出每类的特征均值用来人工命名 cluster_profile ( student_feat.groupby(cluster)[feat_cols] .mean() .round(3) ) print(cluster_profile) # 导出画像明细供后续做周报和数据复核 student_feat.to_csv(output/student_profile_clustered.csv, indexFalse, encodingutf-8-sig)cluster_profile这张表是给“不是搞算法的人”看的他们只能从标签里看出“第一类消费频次高、标准差低”而你要告诉他们这是“规律三餐型”。给簇命名不需要遵循什么规范但要保证含义清晰、不会被误读比如“高消费活跃型”比“cluster 0”强一百倍。导出时用utf-8-sig编码是给Excel兼容性留的后路直接utf-8导出的CSV用Excel打开会乱码这是个很小但特别常见的坑。5. 校园消费分析里最容易翻车的五个问题排错记录与血泪经验5.1 POS机重打导致同一笔消费出现两次频次被虚高现象统计结果里某学生某天在同一个窗口消费了18次金额完全相同这显然不符合常识。原因是老旧POS机在小票打印失败时会触发“重打”操作重打不一定真的再扣一次钱但流水表里会多出一行记录。解决用“同学生、同商户、同金额、时间间隔小于60秒”作为判定条件把重复记录抽出来人工复核。我一般在数据处理管线的最前面做这一步去重因为这属于单表内的数据质量修正越早做影响越小。# 按关键字段排序用相邻时间差识别重打记录 df df.sort_values([student_id, merchant_name, amount, trade_dt]) df[prev_dt] df.groupby([student_id, merchant_name, amount])[trade_dt].shift(1) df[time_gap] (df[trade_dt] - df[prev_dt]).dt.total_seconds() # 时间差在60秒以内的视为重打打标签后过滤 dup_mask (df[time_gap] 60) df[time_gap].notnull() print(f疑似重打记录数: {dup_mask.sum()}) df_clean df[~dup_mask].copy()5.2 退款流水没过滤日均消费整体虚高现象一位同学买了饭又退掉会先记12元再记-12元。如果直接把两笔都加进“消费总额”他的真实消费被算成24元翻了一倍。原因是只看了金额列没看业务类型。解决负金额不能无脑删也不能无脑留。我的做法是把负数流水先分类能匹配到同一天同一商户正负相抵的对账删除匹配不上的单独标记至少不让它混进正向消费统计。第二步是重算日均消费对比过滤前后的均值变化如果过滤前后差超过百分之五说明流水质量有问题要回头检查是不是还有别的冲正类型没盖住。5.3 一卡通系统换过厂商时间字段格式不统一现象上个月导出的是“2025-03-01 08:31:22”这个月变成“2025/3/1 8:31”甚至有的行是“2025-03-01T08:31:22”。pd.to_datetime处理前两种没问题遇到ISO格式也能识别但一旦混着秒级缺失比如只有小时分钟就直接解析失败。解决统一入口层做解析不信任任何默认格式。先强制转字符串再尝试多个格式列表解析不了的记录单独写进error_rows.csv而不是静默丢弃。这一步的意义是让数据校验可见否则后面聚合时看到的永远是“有些学生数据少”但不知道该去哪里找丢失的记录。5.4 商户名归一化之后人工复核发现还有别名残差现象清洗后merchant_clean里依然有一批“未知商户”占比超过百分之十所有按商户做的热力图都缺了一块。原因是别名映射表是手工维护的而现实中的商户名是自由输入总有一些没见过的写法。解决把“未知商户”单独抽出来做一次频次排序你会发现前十几个高频值就已经覆盖了绝大部分。把这些值补进映射表然后重跑清洗流程。我建议把映射表单独存成CSV每次迭代只追加不删改三个月后这就是一份很值钱的商户名资产。5.5 凌晨夜宵被算进第二天早餐早餐时段统计被污染现象凌晨1点的消费被归入当天早上早餐时段的人数突增人均早餐金额却暴跌。原因是按自然日切日期没有做跨天修正。解决就是第三章里那个biz_date的逻辑把0点到5点的消费归属到前一天。这个修正看起来只是“往前挪了一天”但它直接影响周末效应分析——比如周五晚上刷到凌晨的学生如果不做修正会被错误地统计成“周六早起吃早餐”整个周末的消费规律就失真了。6. 把分析结果做成可复现的周报输出HTML与Excel结果表并验证特征的月度稳定性做到这一步项目已经从“能跑出来”变成“能持续用”了。我最后的落地产物通常是一个多sheet的Excel报表第一页是整体消费总览包含日均消费、时段分布、食堂热力排名第二页是聚类画像说明第三页是明细数据。整套流程用一段主脚本串联每周一早上跑一次输出当天可用的周报。# 多sheet导出excel_writer只需创建一次多个to_excel共用 with pd.ExcelWriter(output/consumption_weekly_report.xlsx, engineopenpyxl) as writer: df_daily_summary.to_excel(writer, sheet_name消费总览, indexFalse) cluster_profile.to_excel(writer, sheet_name人群画像, indexTrue) student_feat.to_excel(writer, sheet_name学生明细, indexFalse)稳定性验证是每周跑批里容易被忽略的一环聚类出的四类人群占比如果这个月突然变了要先问是数据口径变了还是真实行为变了而不是直接改报告结论。我的习惯是保存上一周的人群占比表每周对比一次偏差偏差超过五个百分点就拉明细排查。这个项目做到最后你会发现真正费时间的不是聚类算法而是前面那两章的数据清洗和口径统一。我总结一条自己的教训任何校园消费分析先花六成精力把流水表里的脏数据收拾干净后面所有模型和图表都会顺。希望这个方案的思路和代码能帮到你照着这个流程走一遍你对pandas、特征工程和聚类的理解会比看十遍教程都扎实。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →