拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于Python的校园一卡通消费行为分析:从清洗到分层结果集

简介这是一份基于Python的学生校园消费行为分析完整项目主要面向计算机专业期末大作业、课程设计及需要项目实战练习的学习者难度适中。项目以DFM模型为核心围绕高校校园消费数据展开处理、特征分析与结果展示源码经导师指导并严格调试可本地直接运行其中3个Python脚本分别对应初始化、模型构建与行为分析环节。压缩包共8个文件包括Python脚本、校园消费行为数据集zip、基于DFM模型的分析报告docx、依赖说明requirements.txt及README等整体大小10.07MB目录结构清晰。目前已有123人学习下载。读者可同时获得源码、数据集、结果集和文档既能复现消费行为分析的完整流程又能为课程设计或大作业提供可参考的代码框架与写作素材适合用来快速上手项目实践并理解分析思路。1. 学生校园消费分析到底能看到什么先从一次“贫困生认定翻车”说起我见过一个真实翻车案例某学院拿一卡通月度消费总金额倒序排直接截取最低的 10% 作为“疑似贫困生”名单。结果名单里混进了一批“修仙党”——每天只吃两顿、顿顿稀饭馒头加免费汤消费低得吓人反倒是几个真正的困难学生因为在校外做兼职、吃校外便宜盒饭校内卡上只有早餐记录月消费高不成低不就被漏掉了。这就是只盯着“总金额”看校园消费的典型坑。基于 Python 的学生校园消费行为分析本质是把一卡通流水这种结构化数据清洗成一张能解释“他平时吃什么、几点吃、在哪个窗口吃、一个月在校内吃了几天”的行为画像表再把画像聚合成分层、异常名单和后勤决策用的结果集。适合辅导员做预警、后勤做食堂排班也适合拿 Python 做课设和毕设的人照着复现并扩展下面这套流程就是围绕清洗、画像、分层、出结果集四个环节展开的。2. 从一卡通流水到干净数据集字段设计、清洗与时间特征的四个关键取舍拿到手的一卡通原始流水绝大多数是从校园卡中心导出的 Excel 或 CSV文件命名常见是“消费流水_20240301--20240331.xlsx”里面一行的含义是“某张卡在某时在某商户消费了多少钱”。这个阶段最忌讳直接跑聚合因为脏数据会让后面所有结果都不可信我在实际项目里会把源码拆成预处理、分析、导出三份脚本这一章先讲预处理部分。2.1 数据字典动手前先确认你手里的字段够不够用一卡通流水通常包含卡号、姓名、学院、交易时间、交易类型、商户名称、交易金额、卡余额少的只有卡号、时间、金额、商户这四列。在写任何一段采集代码之前我习惯先把字段名称和样例值整理成一张数据字典因为数据库导出字段经常是 team_id、temp 这种毫无意义的命名。字段原名重命名样例分析用途学号/卡号student_id2023100101聚合主键交易时间trade_time2024-03-15 12:03:22时段切分交易类型trade_type消费/充值/退款过滤非消费记录商户名称merchant一食堂_12号窗口窗口流量交易金额amount8.50消费水平卡余额balance13.20余额兜底有姓名和学院字段的话保留出来单独存一张“学生档案表”不要在每行流水里反复带姓名后面做交叉分析时再 join 回来。这个阶段还要确认一件事amount 的正负含义。不同学校的语义不一样有的系统退款是负数有的系统把退款单独列在交易类型里金额永远是正数这一点不确认清楚后面过滤时会直接翻车。我一般会在清洗脚本开头输出一份“字段完整性检查报告”把每列非空率、类型、正负样本量打出来。2.2 清洗第一步去重、过滤退款、压掉离群值常见做法是先用 pandas 读入再用三个规则清洗同卡号、同商户、同金额且时间差小于 15 秒的记录视为重复刷卡食堂高峰期连刷两次帮同学带饭很常见这种在消费行为分析里算一次trade_type 不为“消费”的过滤掉金额小于等于 0 或大于 100 的先标记再单独看因为正常校园一餐很难超过 100 元。import pandas as pd df pd.read_excel(raw_data/trade_202403.xlsx, dtype{student_id: str}) print(原始行数:, len(df)) print(df.dtypes) # 1) 去重同卡、同商户、同金额15 秒内算同一次消费 df df.sort_values(trade_time) df[dup_flag] ( (df[student_id] df[student_id].shift()) (df[merchant] df[merchant].shift()) (df[amount] df[amount].shift()) (df[trade_time] - df[trade_time].shift()).dt.seconds.le(15) ) df df[~df[dup_flag]].drop(columns[dup_flag]) # 2) 只保留消费记录充值和退款单独存档 refund df[df[amount] 0].copy() df df[df[amount] 0] # 3) 离群值单笔大于 100 不是消费多半是押金或误刷标签 outlier df[df[amount] 100].copy() df df[df[amount] 100] print(清洗后行数:, len(df))这段代码有三个参数值得说。第一个是 shift() 去重法前提是数据已经按时间排好序它只比较相邻行如果食堂人特别多、同一窗口 20 秒内连续结算两次15 秒阈值会漏判这时候可以把阈值上调到 30 秒但要注意过度合并会把“真·连续买了两份”的消费也吃掉。第二个是负数金额直接切到 refund不要简单删除退款记录在计算“月均消费”时必须排除但在分析“卡内余额异常”时却有用。第三个是 100 元这个阈值不同学校食堂价格体系差异很大最好先用 df[amount].quantile(0.999) 看实际分布再定它只是辅助过滤不是绝对标准。2.3 把时间戳切出节次早中晚夜四段的合理边界时间特征是整个消费分析里信息量最大的一列。直接用“小时”太粗用“几点几分”又太碎我一般按高校作息切成五个段早餐 06:00-09:00午餐 10:30-13:30晚餐 16:30-19:30夜宵 19:30-22:30其他时段归为“非正餐”。注意午餐不取 11:00 是因为上午第四节课 10:00 下课就有学生去食堂晚餐同理。周末和节假日的时段切分应该单独处理因为很多学生周末睡到中午会出现“早午餐合并”现象。import numpy as np df[trade_time] pd.to_datetime(df[trade_time]) df[hour] df[trade_time].dt.hour df[weekday] df[trade_time].dt.weekday # 0周一6周日 df[is_weekend] df[weekday].isin([5, 6]).astype(int) bins [0, 6, 10.5, 13.5, 16.5, 19.5, 22.5, 24] labels [凌晨, 早餐, 午餐, 午后加餐, 晚餐, 夜宵, 深夜] df[meal_period] pd.cut(df[hour], binsbins, labelslabels, rightFalse) print(df.groupby(meal_period, observedTrue).size())pd.cut 的 bins 边界是本段核心。10.5 表示 10:30 含在内13.5 表示 13:30 前都算午餐这样切完的标签不会出现“10:29 算早餐、10:30 算午餐”的跳变。hour 列保留下来是为了画 24 小时分布图meal_period 列则是给聚合分组用的。datetime 列最好在最开始统一转完后面所有脚本都复用清洗后的中间文件不然每次读入都转一次类型纯属浪费 CPU。这里还有一个容易踩的细节如果用 openpyxl 引擎读 Excel时间列偶尔会被读成字符串需要传入 format%Y-%m-%d %H:%M:%S 或者直接用 parse_dates 参数。清洗完的数据我习惯落一份 parquet 格式几十万行流水用 to_csv 一次就要卡几秒to_parquet 只需要零点几秒后面分析脚本读取速度完全不是一个量级。3. 食堂流量与就餐高峰画像按小时与窗口交叉找到校园消费节奏清洗完的数据集是“流水明细”这一章要把它升级成“行为特征”。核心问题是三连问学生几点集中吃饭哪个食堂承载了最高峰的人流不同窗口的消费时段有什么错位这些答案直接决定了后勤排班、窗口数量和“结果集”里最直观的趋势图长什么样。3.1 24 小时消费曲线先按小时聚合再按工作日/周末拆开最基础也最出效果的可视化就是 24 小时消费频次曲线。这里的聚合键要选“交易笔数”而不是“交易金额”因为高峰时段的特点是“频次激增”金额会被一餐 20 元和 8 元的差异拉偏。按工作日和周末分开画两条曲线能立刻发现校园里“没有早八的周末早餐峰消失或推迟到 10 点”这类规律。hour_freq ( df.groupby([is_weekend, hour], observedTrue) .size() .reset_index(namecnt) ) pivot_hour hour_freq.pivot(indexhour, columnsis_weekend, valuescnt) pivot_hour.columns [工作日, 周末] pivot_hour.plot(kindbar, figsize(12, 5), width0.8) plt.title(校园一卡通消费频次 24h 分布工作日 vs 周末) plt.xlabel(小时) plt.ylabel(交易笔数) plt.tight_layout() plt.savefig(output/hour_freq.png, dpi150)groupby 里的 size() 返回每组的行数不要用 count()count 会剔除 NaNsize 不会一卡通流水里不一定有缺失值但这个习惯养成后可以少踩不少坑。pivot 把 is_weekend 拆成两列画出来就是两张叠在一起的柱状图。还有一个细节bar 图只画有数据的整点凌晨 0-5 点没有记录会留空如果想让坐标轴完整显示可以先 reindex 补全 0-23 的所有小时否则肉眼看到的最低点会有视觉误导。3.2 食堂窗口交叉谁在抢占高峰谁在错峰经营只看全校园的 24 小时曲线没法回答“一食堂二楼中午排队 30 分钟、同一时间三食堂隔壁的麻辣烫窗口只有三个人”的问题。这一步要把 merchant 字段拆出“食堂名 窗口名”两个层级。最省事的做法是拿“分隔符”切开比如“一食堂-12 号窗口”按 - 切分隔切出来之后用交叉分组。df[canteen] df[merchant].str.split(_).str[0] df[window] df[merchant].str.split(_).str[1] peak_cross ( df[df[meal_period].isin([午餐, 晚餐])] .groupby([canteen, meal_period], observedTrue) .size() .reset_index(namecnt) ) cross pd.crosstab( df[window], df[meal_period], marginsTrue, normalizeindex )pd.crosstab 里的 normalizeindex 表示每一行窗口的消费时段占比归一化之后能看出哪些窗口是靠“午饭单峰”活着哪些窗口的晚餐占比明显更高比如面食窗口中午排长队、晚上却冷清而麻辣烫正好相反。这个占比在做窗口调优时比原始笔数更有用因为它消掉了窗口体量的差异小窗口和网红窗口可以放在同一把尺子上比。3.3 画图之前先清理带饭记录防住“伪高峰”前面说过 15 秒内同卡同窗口重复刷卡算一次这是去重。但真正影响时段曲线的是“帮全班带饭”的卡一张卡在 12:00 到 12:05 内连续刷了同窗口 8 次。这条记录会同时污染两个指标该窗口的消费笔数虚高、该学生的“每餐次数”变成 8。更麻烦的是带饭行为集中在午餐正点它会把午餐峰“拔得更高”看起来就像食堂中午拥堵度极其严重实际排队的只有一半人。df[minute] df[trade_time].dt.floor(min) carry_flag ( df.groupby([student_id, merchant, minute], observedTrue)[student_id] .transform(size) .ge(3) ) df[is_carry] carry_flag.astype(int) df.loc[df[is_carry] 1, carry_ratio] 1 / carry_flagtransform(size) 返回每一行所属组的总次数ge(3) 判断同一分钟内同窗口刷卡是否达 3 次及以上。carry_ratio 的作用是把这笔消费按权重摊薄比如一次带了 4 份就只按 0.25 笔计入频次统计。注意这里治标不治本真正的“一人多卡”和“一张卡全家用”无法用规则识别只能靠对食堂监控和学生访谈验证。画图前加上这个字段后高峰曲线会更接近真实人流就是做这一步付出的代价是“结果集”里多了一列 isn_carry 标记业务方第一次看会问文档里要写清楚它是做什么的。4. 消费分层结果集怎么算从“人均消费”到可解释的打分模型这是标题里“结果集”三个字最重的部分。经过清洗和时间特征加工之后我们手里有了每人每天的消费明细下一步要把一个月的流水压成一张“学生维度行为表”然后按照消费水平、活跃度、消费结构打出分最终导出分层结果集。整个过程的目标是让非技术背景的人打开 Excel 也能看懂这个人为什么被分层到“关注组”。4.1 为什么不能直接用平均金额排名三个反例直接算“月均消费金额”然后排名是最多人第一步就做的做法但它有结构性缺陷。第一忽略频次A 学生每天吃两顿每顿 8 元月消费 480 元B 学生每天吃四顿每顿 6 元月消费 720 元后者吃得更省却被当成高消费。第二忽略活跃天数一个只在校内吃 5 天的学生和吃 25 天的学生月总金额可能相同但生活状态完全不同。第三忽略消费结构同样的月金额一个主要在一食堂基本窗口消费另一个主要在校内超市买零食饮料前者更符合“简朴生活”画像。所以这套分析必须同时看金额、频次、活跃天数和消费结构四个维度。4.2 四维打分金额、频次、活跃天、低档窗口占比打分方案我习惯用百分位法。每名学生月度消费金额 M、月度消费次数 F、月度活跃天数 D、以及“基本保障窗口消费占比 S”基本保障窗口可自定义为一食堂一楼所有 10 元以下窗口。四个指标分别做分位数映射最后加权求和。stu_feat df.groupby(student_id).agg( month_amount(amount, sum), month_count(amount, size), active_days(trade_time, nunique), basic_ratio(amount, lambda x: (x 10).mean()), ).reset_index() def score_by_quantile(col, qs, reverseFalse): ranks pd.qcut(col, qqs, labelsFalse, duplicatesdrop) 1 if reverse: ranks ranks.max() - ranks return ranks # 金额、频次、活跃天数都低 - 分数高代表更需关注 stu_feat[score_amount] score_by_quantile(stu_feat[month_amount], 10, reverseTrue) stu_feat[score_count] score_by_quantile(stu_feat[month_count], 10, reverseTrue) stu_feat[score_days] score_by_quantile(stu_feat[active_days], 10, reverseTrue) # 基本窗口占比越高 - 分数高 stu_feat[score_basic] score_by_quantile(stu_feat[basic_ratio], 10, reverseFalse) stu_feat[total_score] ( stu_feat[score_amount] * 0.4 stu_feat[score_count] * 0.2 stu_feat[score_days] * 0.2 stu_feat[score_basic] * 0.2 )pd.qcut 的 q10 表示分成十档labelsFalse 让每档直接返回 0 到 9 的整数之后再加 1 变成 1-10 分。reverseTrue 用在金额、频次、活跃天数上含义是“数值越低分越高”这样四个维度的分数方向统一为“得分越高代表越需要关注”。权重 0.4/0.2/0.2/0.2 不是拍脑袋定的而是优先保证总金额的影响力又不能让它一票否决如果你所在学校食堂整体菜价高basic_ratio 的阈值 10 元要上调到 12 元。4.3 分层规则与导出关注层级怎么定打完分后按 total_score 切三层10-28 为“正常组”29-35 为“观察组”36 及以上为“关注组”。这个阈值来自我对结果集分布的观察——正常学生分散在中间段真正低消费的尾部集中在 36 分以上。这里要特别提醒自动分层只能作为参考名单不能直接当结论一定要再过一道人工复核因为 score_basic 这个维度存在误伤有的学生顿顿在便利店买面包吃账单同样低不是经济困难是生活习惯问题。stu_feat[level] pd.cut( stu_feat[total_score], bins[0, 28, 36, 100], labels[正常组, 观察组, 关注组], rightTrue, ) stu_feat.sort_values(total_score, ascendingFalse).to_csv( output/stu_level_result.csv, indexFalse, encodingutf-8-sig ) # 按学院透视 dept_cross ( stu_feat.merge(stu_info[[student_id, dept]], onstudent_id) .pivot_table(indexdept, columnslevel, valuesstudent_id, aggfunccount, fill_value0) ) dept_cross.to_excel(output/dept_level_pivot.xlsx)导出用 utf-8-sig 是为了让 Excel 直接打开 CSV 不乱码pandas 的 to_excel 没有这个参数但对中文列名用 openpyxl 引擎不存在乱码问题。pivot_table 输出的是“学院×分层级别”的人数矩阵这张表我一般会作为结果集的第一张表放进交付材料里因为学院辅导员最关心的就是“我们学院到底有多少人进了关注组”。到这一步“结果集”就已经成型了一张学生维度打分表、一张学院交叉透视表、以及前面做出来的时段曲线图。后续每多一个月的数据只要更新 raw_data 目录下的新流水重新跑一遍脚本即可。5. 学生消费行为分析的 5 个常见翻车点与排查方法这个项目的坑有一半埋在数据本身另一半是分析习惯造成的。我把这三年里真实遇到过的问题压缩成 5 条每一条都是“现象 → 原因 → 解决”的结构照着排查能省下大量反复对账的时间。5.1 晚间刷出来的“隔日记录”把活跃天数算多现象某学生的活跃天数显示 27 天但后台日志显示他中间离校一周。原因一卡通系统在夜间结算时会把凌晨 0:00-2:00 的消费记录打上“上一个自然日”的日期戳导致同一笔流水归错了天。解决每月清洗后先做一次“按学生按日去重”并且把活跃天数定义为“出现过早餐或正餐记录的自然日数”而不是单纯按日期字段区分。df[real_date] df[trade_time].dt.date # 如果 0-4 点有交易归属到前一天按系统结账日修正 shift_night df[hour].lt(4) df.loc[shift_night, real_date] ( df.loc[shift_night, trade_time] - pd.Timedelta(days1) ).dt.date这个修正不是万能的因为跨日逻辑不同学校有差异稳妥做法是先取一个月数据进行人工抽样核对看凌晨交易的归属日是否符合预期再决定要不要写这段修正。5.2 退款记录负金额捅穿了所有均值现象某生月度人均消费被算成负数或者某个窗口月销售额出现诡异低谷。原因退款流水和消费流水在源表里混排贷方金额是负数直接参与 mean、sum 聚合必然出错。解决清洗脚本里把 amount 0 的单独截流存档分析口径里永远只消费正数同时在结果集里单独给一张“退款明细表”备查而不是顺手删掉因为退款次数异常的卡往往是食堂投诉的证据。5.3 Excel 里的日期读出来全是“42xxx”数字现象read_excel 之后 trade_time 列变成一串 44900 之类的数字肉眼能看出是日期但 pandas 不认。原因源表是从业务系统里导出的“显示格式日期”底层存储是 Excel 序列号部分列被 Excel 自动识别成文本或数字。解决读入后先强制转换并检查转换失败的行。df[trade_time] pd.to_datetime( df[trade_time], errorscoerce, formatmixed ) bad_rows df[df[trade_time].isna()] print(无法解析的时间行数:, len(bad_rows))formatmixed 是让 pandas 自动尝试多种格式解析速度比指定格式慢但应对脏数据最有效。如果坏行占比超过 1%建议不要暴力丢弃而是把 bad_rows 单独导出联系数据提供方核实这批被丢弃的行往往就是那些“交易失败但扣款成功”的疑难记录。5.4 带饭和盗刷把窗口流量捧成虚假繁荣现象某窗口中午一小时笔数是隔壁的三倍实地去看排队只有两排。原因高峰期存在明显的“一卡多刷”一张卡连着刷 3-6 笔同商户同分钟内重复度极高。解决按第 3.3 节的 carry_flag 规则打标记流量分析时用摊薄后的权重折算了多少——我这里再强调一次这个参数ge(3) 的三次阈值是经验值对高职院校这种帮带现象严重的场景建议降到 2对研究生食堂建议升到 4否则误伤率会上升。5.5 分层分数算完发现关注组里混进了“只在超市消费”的人现象关注组名单里出现一个月只在超市刷了 3 次、每次都买饮料零食的学生。原因basic_ratio 设定只看 10 元以下但超市的 6 元饮料也算低消费把消费结构误判为“基本保障型”。解决给打分增加一个维度“食堂正餐占比”将商户类型区分为食堂、超市、开水房、浴室只有“食堂早餐正餐”才进入 basic_ratio 计算超市和小卖部消费全部剔除。这个商户类型字段如果原始数据里没有就得靠 merchant 名称里包含的 食堂/超市 关键词做规则映射映射表本身需要人工维护这是整个项目里最麻烦但最值得投入的数据治理工作。6. 把结果集沉淀成每周自动报告一个脚本输出全部产出项目到了收尾阶段最大的价值点不是那几张图而是让“结果集”能自动定期出现不需要每次翻开 Jupyter 手动跑。我现在的做法是写一个 report.py把前面所有阶段的输出汇总到一个带日期的输出目录并且用 Excel 多 sheet 的方式交付业务方拿到一个文件就能看完整周的学生消费情况。with pd.ExcelWriter(output/消费分析报告_202403.xlsx, engineopenpyxl) as writer: stu_feat.to_excel(writer, sheet_name学生分层结果, indexFalse) dept_cross.to_excel(writer, sheet_name学院交叉透视) hour_pivot.to_excel(writer, sheet_name24h消费频次) df.groupby(canteen).size().to_excel(writer, sheet_name食堂流量汇总) summary { 总流水行数: [len(df)], 覆盖学生数: [stu_feat[student_id].nunique()], 关注组人数: [(stu_feat[level] 关注组).sum()], 总体日均消费: [round(df[amount].mean(), 2)], } pd.DataFrame(summary).to_excel(writer, sheet_name本周概览, indexFalse)这个脚本放在 crontab 或计划任务里每周一早上跑一次前提是前一晚的流水已经导出到 raw_data 目录。每次跑完再顺手生成一份“数据质量说明”把清洗前后行数、坏行数、退款笔数写进概览 sheet这样即使没人仔细看只要概览页数字发生变化就能立刻发现上游数据出问题了。做这套东西的原则是先保证可复现再追求花哨的可视化页面我自己的习惯是清洗脚本和分析脚本分开存原始数据只读不改所有中间结果都写进带日期的子目录这样任何时候翻旧账都能对上号。希望这个从清洗到结果集的流程能帮你在自己的校园数据上少走几步弯路。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门