ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pandas实战:校园一卡通消费数据清洗与可视化分析

Pandas实战:校园一卡通消费数据清洗与可视化分析 简介这份Python学生校园消费行为设计分析资源专为Python数据分析初学者与校园信息化研究者打造覆盖校园一卡通消费数据从清洗、关联到分析的可执行流程。压缩包共30个文件以5个Python脚本为核心配合8份CSV数据、5个XML工程配置、9张PNG可视化图表和1份Word分析报告整体118.44MB结构清晰便于直接运行和二次复用。内容上既包含缺失值处理、异常时间点剔除、按项目需求筛选字段的数据清洗代码也有人信息表与消费记录表的关联逻辑同时针对早、中、晚各食堂就餐人数占比、工作日与非工作日就餐时间展开统计并进一步分析不同性别学生的消费特点、各专业不同性别的人均消费差异最终用可视化图表与分析报告完整呈现结论。对于正在做课程设计、毕业设计或想掌握pandas数据处理流程的读者这套资源提供了可参照的脚本与思路。目前已有739人学习下载适合作为实际项目的起步模板。1. 从一摞CSV到一份能用的校园消费分析报告拿到校园一卡通消费数据时很多人的第一反应是直接开跑groupby但真实数据处理根本不是这样。这套Python学生校园消费行为分析脚本核心不是画几张饼图而是把“时间戳里的食堂高峰”“性别与专业维度下的消费差异”“工作日的就餐节奏变化”这类问题拆成可复现的代码流程。task1_x1.py负责处理原始表结构task2_x1.py聚焦行为指标task3_x1.py输出可视化结果一套数据下来你得到的不仅是png图片还有一张关联好的宽表。适合刚接触pandas实战、又不想只对着Kaggle数据集练手的同学也适合需要给校内信息化项目做轻量数据探索的从业者。2. 数据清洗与信息关联pandas里的取舍逻辑2.1 先看data1.csv和data2.csv长什么样拿到压缩包里的data1.csv和data2.csv建议第一步不要写任何处理逻辑直接打印结构。常见做法是先用pd.read_csv()把两张表读进来再分别输出列名和前五行确认主键字段。流程大约是这样import pandas as pd consume pd.read_csv(data1.csv, encodinggbk) student pd.read_csv(data2.csv, encodinggbk) print(consume.shape, consume.columns.tolist()) print(student.shape, student.columns.tolist()) print(consume.head()) print(student.head())encodinggbk是很多校园系统导出CSV时常见的编码方式如果读出来乱码换utf-8或gb18030再试。第二步看字段消费记录表一般有学号、消费时间、消费金额、食堂编号学生信息表一般有学号、性别、专业。两表靠学号关联这是后面所有分析的入口。2.2 缺失值不是一删了之要看列的方向摘要里说“去除缺失值所在列的数据”实际操作时要先区分“行缺失”和“列缺失”。如果某一列超过一半都是NaN那这列对模型没有正向贡献直接drop是合理的如果只是某几行缺值且该行恰好是分析对象的唯一记录删掉会丢失信息需要判断是填充还是剔除。# 查看每列缺失比例 missing_ratio consume.isnull().mean().sort_values(ascendingFalse) print(missing_ratio) # 删除缺失比例过高的列 consume consume.drop(columns[remark]) # 假设remark列缺失超过50% # 删除关键字段缺失的行 consume consume.dropna(subset[student_id, consume_time, amount])参数说明isnull().mean()计算每一列的缺失占比sort_values(ascendingFalse)把最严重的排在前面。dropna(subset...)只针对指定列做行删除比全表dropna()安全得多。这里注意顺序先删列再删行避免先删行导致某些整列变为全空干扰后面的缺失率判断。2.3 异常时间点的筛选别让凌晨的刷卡搅乱趋势校园消费数据里最典型的异常是凌晨的机器测试交易和跨天结算流水。分析食堂早中晚餐占比时这些记录会让时间段的划分失真。我一般会先看消费时间的分布范围再决定保留哪一段。consume[consume_time] pd.to_datetime(consume[consume_time]) print(consume[consume_time].dt.hour.value_counts().sort_index()) # 只保留6点到23点之间的消费记录 consume consume[(consume[consume_time].dt.hour 6) (consume[consume_time].dt.hour 23)]dt.hour提取小时字段后做value_counts()能直观看到哪些小时段的刷卡次数异常。如果凌晨两点有几百笔记录多半是测试数据。过滤条件用6和23把跨天结算的边界卡在外部避免把23:59的记录算到第二天。边界值可以根据实际分布调整但如果项目没有给出业务说明默认过滤掉深夜时段更稳妥。2.4 两张表的信息关联merge的三种常见坑信息关联用pd.merge()但有三类问题在实际数据里几乎每次都会遇到一是两张表的学号字段类型不一致一边是int一边是str二是同一学号在消费表里出现多次合并后行数膨胀三是学生表里有少量重复学号导致一对多变多对多。# 统一学号字段为字符串去掉空白 consume[student_id] consume[student_id].astype(str).str.strip() student[student_id] student[student_id].astype(str).str.strip() # 学生表按学号去重保留第一条 student student.drop_duplicates(subsetstudent_id) # 左连接保留消费记录的全部行 df pd.merge(consume, student, onstudent_id, howleft) print(df.shape) print(df.isnull().sum())merge里howleft表示以左边的消费表为准右边学生表匹配不上就填NaN这样不会丢失任何一笔消费记录。drop_duplicates(subsetstudent_id)是防止学生表存在重复注册信息导致笛卡尔积。合并之后要再检查一次空值如果性别或专业大量为NaN说明两表的学号规则不一致常见做法是回到源数据里看前缀差异比如学号有的是2021开头、有的是21开头。3. 食堂就餐分析从时间段占比到工作日节奏3.1 早中晚餐的划分不能拍脑袋“分析早、中、晚各大食堂就餐人数占比”听上去简单但划分标准不同结论完全不同。一刀切用6-10、10-15、15-21是粗糙做法。我一般先画出全天24小时的刷卡频次分布再去定边界。设定如下hour df[consume_time].dt.hour def meal_type(h): if 6 h 10: return breakfast elif 10 h 15: return lunch elif 15 h 22: return dinner else: return other df[meal] hour.map(meal_type) # 各食堂各餐次人数占比 meal_canteen df.groupby([canteen_id, meal]).size().reset_index(namecount) meal_canteen[pct] meal_canteen.groupby(meal)[count].transform(lambda x: x / x.sum()) print(meal_canteen)参数说明map(meal_type)把小时映射到餐次字段比apply快。reset_index(namecount)把分组后的计数列显式命名为count。transform(lambda x: x / x.sum())是在每个餐次内部计算各食堂占比而不是占总记录的比例。要特别注意transform返回的是与原分组等长的数组所以能直接赋给新列pct。之后可以用透视表把占比拉宽方便后续绘图pivot meal_canteen.pivot_table(indexcanteen_id, columnsmeal, valuespct, aggfuncsum) print(pivot.round(4))pivot_table比pivot更适合处理可能存在重复聚合的情况aggfuncsum保证即使同一食堂同一餐次有多条记录也会被加总。输出后你会看到哪个食堂在午餐时段拿走了最大份额这比单纯看总流水更能反映食堂的实际负荷。3.2 工作日与非工作日的就餐时间差异判断工作日不能只看周一到周五还要考虑节假日和调休。但数据包里没有节假日表时我一般用weekday()粗分然后单独验证周六日的分布是否合理。df[weekday] df[consume_time].dt.weekday # 0周一 df[is_workday] df[weekday] 5 # 对比工作日和非工作日的分小时消费次数 hourly df.groupby([is_workday, df[consume_time].dt.hour]).size().reset_index(namecount) workday hourly[hourly[is_workday] True] weekend hourly[hourly[is_workday] False] print(workday.sort_values(count, ascendingFalse).head(10)) print(weekend.sort_values(count, ascendingFalse).head(10))这里groupby([is_workday, df[consume_time].dt.hour])直接把布尔值和小时列混在groupby里pandas会各自分组再配对输出。观察两边的top时段工作日通常集中在7:30-8:00、11:30-12:30、17:30-18:30三个波峰而周末的上午峰会明显后移午餐波峰拉长。如果周末和工作的峰形几乎一样说明“周末”标签可能包含了调休或者在数据采集周期内遇到了考试周这种业务判断在后续报告中要单独说明。3.3 用就餐人数占比交叉验证流水只算刷卡次数不如同时看每餐的人均消费。如果某个食堂午餐次数占比高但流水占比低可能它的产品结构偏向低价主食反之则是高价套餐主导。meal_flow df.groupby([meal, canteen_id]).agg( total_amount(amount, sum), total_count(amount, count), avg_per_meal(amount, mean) ).reset_index() print(meal_flow.sort_values(total_amount, ascendingFalse).head(10))agg里用元组格式(列名, 聚合函数)这是新版pandas推荐的写法一行定义一个输出列。total_count对amount做count而非size可以顺便排除掉金额为NaN的记录。这个表可以直接导出成CSV作为task1的交付物之一。4. 消费行为分析与群体画像分组聚合代替拍脑袋结论4.1 本月人均刷卡频次和人均消费“本月”在数据里可能表现为若干月份混杂所以先提取月份字段再过滤。人均刷卡频次的定义也有两种一是总刷卡次数除以总人数二是只统计有消费记录的活跃人数。两种口径在报告中要写清楚否则换个人来看数据会得出不同结论。df[month] df[consume_time].dt.to_period(M) active_students df[student_id].nunique() monthly_user df.groupby([month, student_id]).agg( freq(consume_time, count), total_spend(amount, sum) ).reset_index() monthly_summary monthly_user.groupby(month).agg( avg_freq(freq, mean), avg_spend(total_spend, mean), active_users(student_id, nunique) ).reset_index() print(monthly_summary)这段代码先做“人-月”汇总再做整体汇总是为了避免直接对明细表groupby(month)时把同一个人的多笔消费当成多个样本来平均。dt.to_period(M)把时间戳转成月份周期排序和分组都方便。mean()在这里是“按人平均”分母是当月活跃学生数。如果要算“全员平均”需要把学生总人数作为外部变量带入不能用消费表直接算。4.2 性别维度的消费特点median比mean更稳定不同性别群体的消费特点最值得看的是分布而不是均值。校园消费中极小额的充值退款或大额医务室支出会让均值失真所以我习惯同时输出均值和分位数。gender_stats df.groupby(gender).agg( count(student_id, count), avg_per_transaction(amount, mean), median_per_transaction(amount, median), total_spend(amount, sum), unique_students(student_id, nunique) ).reset_index() # 人均总消费 gender_stats[per_capita] gender_stats[total_spend] / gender_stats[unique_students] print(gender_stats.round(2))count统计的是消费记录数unique_students统计的是去重后的人数两者相除就是人均消费再用mean和median做交叉验证。如果两性别的avg_per_transaction差别不大但per_capita差别大说明差异来自刷卡频次而非单笔金额反过来则说明食堂定价策略或消费习惯不同。后续报告中最好把这种“结构归因”写出来而不是简单说“男生更爱花钱”。4.3 各专业不同性别的人均消费计算这个分析要在专业维度下再拆性别维度属于典型的“复合分组”。代码上可以一次groupby完成也可以先算明细再用pivot_table透视major_gender df.groupby([major, gender]).agg( spend(amount, sum), people(student_id, nunique) ).reset_index() major_gender[per_capita] major_gender[spend] / major_gender[people] pivot major_gender.pivot_table(indexmajor, columnsgender, valuesper_capita, aggfuncmean) pivot[gap_ratio] (pivot.iloc[:, 1] - pivot.iloc[:, 0]) / pivot.iloc[:, 0] print(pivot.round(2))pivot_table的indexmajor让专业作为行columnsgender把性别展开成列gap_ratio计算两个性别的人均差异比例。这里有一个容易踩的坑如果某个专业只有一种性别的学生有消费记录gap_ratio会产生NaN需要在报告里标为“样本不足”不要让读报告的人误以为是零差异。4.4 消费频次分层找出沉默用户和高频用户除了人均指标还可以给每个人按月度消费次数打标签这样能发现“一个月只刷两次”的沉睡用户和“每天刷五次”的高频用户。常见的做法是用pd.cut做分箱monthly_user[freq_label] pd.cut( monthly_user[freq], bins[0, 5, 15, 30, 100], labels[low, medium, high, extreme] ) label_dist monthly_user.groupby([month, freq_label]).size().unstack(fill_value0) print(label_dist)bins边界和labels一一对应注意bins是五个数字会切出四个区间。unstack(fill_value0)把标签列变成宽表每个格子里是人数。分箱边界需要根据实际数据的分布调整不要照搬别人的阈值先用describe()看freq的分位数再定边界更合理。5. 结果可视化与自动化报告把分析变成一张能交代的图5.1 matplotlib绘图的三个关键配置task3_x1.py到task3_x5.py输出的是png图片说明这套流程对最终图表有硬性要求。可视化最容易翻车的地方不是画图本身而是出图前的环境配置。中文字体、负号显示、画布尺寸这三件事不先处理后面所有图都会变成方框。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC] plt.rcParams[axes.unicode_minus] False plt.rcParams[figure.dpi] 150 plt.rcParams[savefig.bbox] tightfont.sans-serif按顺序指定回退字体Linux服务器没有SimHei时会自动尝试后面几个。axes.unicode_minusFalse解决坐标轴负号显示成方块的问题。savefig.bboxtight让保存时自动裁剪空白边距这个参数在输出多子图时尤其有用。如果是在Jupyter里跑还需要加一行%matplotlib inline但在脚本里就不用。5.2 四种分析图表的选型与布局task3系列png文件名带X1到X7通常对应不同的分析视角。我建议用一张总览图把核心结论放在一起四宫格布局最稳左上画各食堂分时段人数占比的堆叠条形图右上画工作日与非工作日的24小时消费曲线左下画不同性别的人均消费分布右下画专业-性别交互热力图。一个可落地的堆叠图写法如下import matplotlib.pyplot as plt import numpy as np # canteen_pivot 来自3.1节的结果行是食堂列是餐次 canteen_pivot pivot.fillna(0) canteen_pivot[[breakfast, lunch, dinner]].plot( kindbar, stackedTrue, figsize(10, 6), colormapSet3 ) plt.title(各食堂早中晚餐人数占比) plt.ylabel(占比) plt.xticks(rotation0) plt.legend(title餐次) plt.tight_layout() plt.savefig(result/meal_canteen_ratio.png)stackedTrue让三个餐次在一根柱子上堆叠显示高度代表总占比。colormapSet3是色盲友好的配色避免默认色块在印刷后难以区分。tight_layout()自动调整子图间距savefig前必须先调用它否则图例和坐标轴容易重叠。5.3 把多张图合成一张大图并标注结论当分析要点较多时单图拼接不如用subplots统一管理。下面这个写法适合需要把4张图放进同一张报表的场合fig, axes plt.subplots(2, 2, figsize(14, 10)) # 子图1分时段消费曲线 hourly_workday.plot(axaxes[0, 0], title工作日分时消费次数) hourly_weekend.plot(axaxes[0, 0]) # 子图2各食堂流水占比 axes[0, 1].pie(meal_flow_head[total_amount], labelsmeal_flow_head[canteen_id], autopct%.1f%%) axes[0, 1].set_title(食堂流水占比) # 子图3性别消费箱线图 df_per_user.boxplot(columnper_capita, bygender, axaxes[1, 0]) axes[1, 0].set_title(性别-人均消费分布) # 子图4专业热度表 im axes[1, 1].imshow(major_pivot, cmapYlOrRd, aspectauto) axes[1, 1].set_xticks(range(major_pivot.shape[1])) axes[1, 1].set_xticklabels(major_pivot.columns) axes[1, 1].set_yticks(range(major_pivot.shape[0])) axes[1, 1].set_yticklabels(major_pivot.index) fig.colorbar(im, axaxes[1, 1]) axes[1, 1].set_title(专业-性别人均消费热力图) plt.suptitle(校园消费行为综合分析) plt.tight_layout() plt.savefig(result/overview_report.png) plt.close()axes[0, 0]是二维坐标索引代表第1行第1列的子图。autopct%.1f%%在饼图上显示一位小数的百分比但饼图不适合超过六类的分组食堂数量多时改为条形图更清晰。boxplot接收的per_capita列必须先按人聚合不能直接用消费明细。imshow的aspectauto让热力图格子随画布拉伸避免专业名太长导致图片变形。5.4 保存前的两个自检细节出图前我会先跑一遍“图像审查”第一每个图都要有标题和坐标轴标签不能裸图第二图内文字不能重叠如果重叠就调figsize或xticks(rotation45)。plt.close()在脚本循环出图时一定要写不然打开几十个画布对象会吃掉大量内存后续脚本越跑越卡。另外保存路径先os.makedirs(result, exist_okTrue)避免因为目录不存在而直接报错中断整个分析流程。5.5 让分析结论可复现的最后一公里报告docx里的结论最好来自to_csv导出的中间结果而不是手工抄数字。所有关键统计表统一走df.to_csv(fresult/{name}.csv, indexFalse)图表文件名也按task3_X1.png这样的约定命名这样下次跑数据时直接覆盖旧文件不会产生“代码和报告对不上”的情况。csv的encodingutf-8-sig是给Excel用户准备的用普通utf-8导出的csv在Windows Excel里打开会乱码这个小细节能省掉不少沟通成本。最终把文字结论写在csv旁边或者用openpyxl把多个表写进一个xlsx工作簿交付时只需要给一个目录即可。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进