ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Python的学生校园消费行为分析全流程方案

基于Python的学生校园消费行为分析全流程方案 简介这份基于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, encodingutf-8) except UnicodeDecodeError: df pd.read_csv(data/card_flow.csv, encodinggbk) 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], errorscoerce) df df.dropna(subset[ts]) print(f删除缺失后: {before} - {len(df)}) # 完全重复的流水行通常是网络重传或重复导入导致直接去重 df df.drop_duplicates(subset[student_id, ts, merchant, amount]) print(f去重后剩余: {len(df)})参数的关键点有两个errorscoerce让无法解析的时间变成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( indexstudent_id, columnsmeal, valuesamount, aggfuncsum, fill_value0 ).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(食堂|餐厅, regexTrue) canteen_sum consume.groupby(student_id)[amount].sum() feat[canteen_ratio] canteen_sum / feat[total_amount]这里fill_value0很关键——如果某个学生从没有夜间消费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为0canteen_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_clustersk, n_init10, random_state42) model.fit(X_scaled) inertia.append(model.inertia_) plt.plot(range(2, 8), inertia, markero) plt.xlabel(k) plt.ylabel(inertia) plt.savefig(output/elbow.png, dpi150) print(各K值inertia:, inertia)n_init10的意思是每个K值用10组不同的初始中心跑10次取最优结果。这个参数必须设置不设的话不同机器上聚类结果会抖动KMeans 的初始化带有随机性只跑一次很看运气。固定random_state42是为了结果可复现否则每次运行结果集都不一样项目没法交付。肘部曲线的拐点通常是4或5具体看数据。消费分群选4类的典型含义是高消费高活跃型、中等消费常规型、低消费节俭型、夜间消费偏好型。如果某簇样本量太小比如不到总人数的5%说明K选大了往回调一档。4.3 打标签与结果集落盘CSV 与图表输出选定K后重新训练给每个学生打标签并输出分群汇总表和完整结果集k 4 final_model KMeans(n_clustersk, n_init10, random_state42) 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, encodingutf-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(kindbar, axax, titlecol) plt.tight_layout() plt.savefig(output/cluster_compare.png, dpi150)matplotlib.use(Agg)是第三步关键在“不弹窗直接保存文件”。在Jupyter Notebook里可以不用但在服务器或命令行跑脚本时必须写不然会报no display。中文字体设置放在绘图前unicode_minus不加的话坐标轴上的负号会显示成乱码方块。结果集的图表部分可以整理成这样的清单文件内容格式student_cluster_result.csv学生ID、特征、聚类标签CSVutf-8-sigelbow.png肘部法则选K曲线PNGcluster_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 :langzh查看系统装了哪些中文字体再写对应的字体名。这五条里退款单和商户名不统一几乎每个项目都会遇到另外三条出现的概率也不低。建议把清洗和检查脚本做成一个check.py每次拿到新数据先跑一遍看到异常再往下走而不是等结果出来再回头查。6. 进阶用滑动窗口识别消费突变给结果集加一个异常维度聚类结果集已经回答了“学生属于哪个消费群体”如果还想再往下挖一层可以给结果集追加“消费异常”维度用来标记消费金额突然显著偏离个人常态的日期。这类突变背后往往是代刷、平台充值、重复扣款或者特殊事件值得单独查看。实现思路是把每个学生的日消费金额按时间排序计算一个7日滑动窗口的均值和标准差当某天的消费金额高于窗口均值加上1.5倍标准差时就标记为异常。# 先按人和日期排序滑动窗口依赖有序数据 daily consume.groupby([student_id, day], as_indexFalse)[amount].sum() daily daily.sort_values([student_id, day]) daily[avg7] daily.groupby(student_id)[amount].transform( lambda x: x.rolling(7, min_periods3).mean() ) daily[std7] daily.groupby(student_id)[amount].transform( lambda x: x.rolling(7, min_periods3).std() ) # 高于均值1.5倍标准差且标准差有效时才判定异常 daily[anomaly] ( (daily[amount] daily[avg7] 1.5 * daily[std7]) (daily[std7] 0) )min_periods3的意思是窗口不足7天时只要有3天数据也算让开学初期也有参考基准std7 0排除了那些每天消费金额完全相同、标准差为0的学生这类学生没出现过波动谈不上异常。1.5倍标准差是经验阈值校园数据通常取1.5到2之间。阈值越大标出来的单子越少但越确信项目里我一般先跑1.5倍把结果导出成 CSV 人工抽查几十条确认误标率后再决定是否收紧。验证异常标记是否正确有一个很朴素的方法把异常日期连同当天的商户明细一起导出人工看那几笔消费发生了什、在哪个商户、金额是否真实。比如同样标记“异常”一笔是晚上10点在超市买了80元日用品一笔是凌晨在食堂连刷三次后者的解释权重远高于前者。有了这个维度原本的聚类结果集就从“静态画像”变成了“画像加预警”项目报告的含金量也会高出一截。这套流程做到最后你会发现自己收获的不只是一份源码和结果集而是一条稳定的分析路径任何一份一卡通流水拿过来都能在半天内清洗、成表、分群、查异常。我现在的习惯是每次分析前先备份原始数据所有清洗步骤都通过脚本可重演聚类剧本参数固定结果集文件名带上日期这样哪怕后续和老师确认口径后要重跑也不会把旧结果和新结果混在一起。希望这些细节能帮你在做“基于Python的学生校园消费行为分析”时少踩几个坑把更多时间花在解释结论而不是排错上。本文还有配套的精品资源点击获取
返回列表