最近在补一门数据分析实战课,每周课后都会留一道固定编号的作业,从 1 排到 N。这周轮到“课后作业4”,题面发下来只有三行字:“读取 order_data.csv,完成数据清洗,回答三个业务问题,至少输出四张图,附一份简短报告。”第一次看到这种题面,我第一反应是“就这?”真正动手之后才发现,三行字背后全是坑。
这篇就来聊聊我做这道作业的全过程:怎么拆解题面、怎么处理脏数据、怎么选图、以及交作业前一晚改掉的几处硬伤。如果你也在学 pandas 做数据分析,或者正在补一门类似的实战课,这篇应该能帮你少走不少弯路。里面涉及的代码都是我自己实际跑过的,踩坑的地方我会重点标出来。
1. 题面只有三行字,但隐藏要求不止十一条
1.1 先把题面完整拆开看
作业4的原题我是这样抄下来的:“读取 order_data.csv,完成数据清洗,回答三个业务问题,至少输出四张图,附一份简短报告。”没了,就这么多。很多同学拿到题面第一反应是打开 Jupyter 直接写代码,我这次没这么做,因为我前几周已经被现实教育过:看起来简单的作业,评分点往往藏在老师没写出来的地方。
举几个具体的例子。作业1的时候,我只做了描述性统计,没有写任何结论,被批“分析不完整”;作业2写了结论,但没写清洗依据,被批“处理过程不可复现”;作业3更惨,代码全部跑通,图却都没有标题,被批“成果不可读”。每一次都被扣得明明白白,所以这次我给自己定了一条规矩:动手写代码之前,先把题面里每一个词都拆清楚。
拆题面这事,听起来像形式主义,实际上是在逼自己明确交付物。比如“完成数据清洗”至少包含三件事:处理缺失值、处理重复值、处理异常值。每件事背后还要有一个“为什么这样做”的理由,因为老师批的从来不只是结果,更是决策过程。拆完之后我发现,三行字背后其实藏着一长串隐性的验收标准。
1.2 把模糊要求翻译成可验收清单
我花了一个小时,把题面翻译成一份可验收清单,翻译规则是这样的:题面里的每个动词,都对应一个具体的交付动作;每个名词,都对应一个具体的检查对象。举个例子,“完成数据清洗”翻译过来就是“df 中不存在缺失值、不存在重复行、不存在明显异常值”,而且要能在报告里写出每一步处理的原因。
最终的清单长这样:
- 数据读取:能复现、不乱码、字段类型正确
- 数据清洗:缺失值、重复值、异常值三种问题都有明确处理记录
- 业务问题:至少三个,每个问题都有结论句,不能只扔统计数字
- 可视化:至少四张图,每张图都有标题、坐标轴标签、结论说明
- 报告结构:包含问题定义、处理过程、结论三部分
- 代码质量:notebook 从上到下能一次性跑通
翻译完之后,我意识到题面虽然只有三行,但评分点拆出来至少有十一处。这也是我这次作业最核心的经验:先花时间拆题,比多写一百行代码都值。因为很多同学不是不会写代码,而是不知道老师想看什么,最后做了一堆工作却答非所问。
1.3 答非所问的教训:作业2让我长记性
再说一个真实教训。作业2的时候,我当时觉得题目太简单,为了显得自己有水平,花了两天时间写了一个线性回归模型,还做了交叉验证,自认为非常高级。结果分数低得离谱,后来看了优秀作业才发现,老师想看到的是“每个变量的分布情况”和“分组对比”,而我整个 notebook 都在讲模型拟合,连最基本的描述性分析都没做完整。
这道作业4我吸取了教训,一开始就把“业务问题”明明白白写出来。我定的是这三个:销售额最高的商品类目是哪个、哪些城市的客单价更高、订单量随时间呈现出什么趋势。所有分析都围绕这三个问题展开,不跑偏。这样写报告的时候,每一个图表都能对应到一个问题,老师批改的时候也轻松,分数自然不会差。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与数据加载:别让第一步就翻车
2.1 版本不一致带来的连锁反应
先说环境。我的电脑是 Windows,用的 Anaconda 自带的 Python 3.10,pandas 版本 2.0.3,matplotlib 版本 3.7.2。版本这个东西,平时感觉不到存在,一旦出问题就非常麻烦。pandas 2.x 里 DataFrame.append 被移除了,很多网上的老教程还在用 df.append(),复制过去直接报 AttributeError。
所以做任何数据分析项目,我建议第一行代码先打印版本号,让自己心里有底:
python复制import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
print(pd.__version__)
print(matplotlib.__version__)
这一步的作用不只是“报个平安”,更是为了后面排查问题时少一个变量。我做作业的时候就遇到过 groupby 行为在新旧版本里不一致的情况,确认版本后百度查错都更有方向。如果课程有统一的运行环境,最好保持一致,省得本地跑得好好的,老师那边一跑就崩。
2.2 读取 CSV 时绕不开的编码问题
数据文件是 order_data.csv,大概 10 万行,6 个字段:order_id、user_id、order_date、category、price、city。第一次读取我直接用了默认参数:
python复制df = pd.read_csv('order_data.csv')
结果直接报 UnicodeDecodeError。后来我试了 gbk、gb2312 都不行,最后发现文件大部分是 gbk 编码,但里面混了少量 utf-8 字符,单用 gbk 也会在特定行报错。最后的解法是加一个容错参数:
python复制df = pd.read_csv('order_data.csv', encoding='gbk', encoding_errors='replace')
这里解释一下 encoding_errors='replace' 的含义:遇到解析不了的字节就替换成特殊字符,不会中断整个文件读取。代价是可能会有少量字段变成乱码,所以我读完之后立刻用 df.sample(10) 抽查了几个字段,确保没有明显的乱码。如果你遇到的文件不是 gbk 而是别的编码,可以用 chardet 先检测,不过大部分课程数据都逃不出 gbk 和 utf-8 这两个。
2.3 不要只看 head(),四件套先跑一遍
读进来之后,很多人习惯先 head() 看五行就开干,我建议把四件套先跑一遍:shape、info、describe、nunique。这四行代码能最快告诉你数据到底长什么样。
python复制print(df.shape)
df.info()
print(df.describe())
print(df.nunique())
shape看规模,知道自己在处理多大的数据量info看有没有 null,以及每一列的数据类型describe看数值列的分布,负值、极大值一眼就能发现nunique看唯一值数量,能发现一些“看起来像分类变量其实是连续变量”的列
我检查后发现,order_date 那一列被读成了 object 类型,price 列里混了“¥”符号,等于说后面所有工作还没开始就埋了三颗雷。head 看不到这些,因为前 5 行恰好都是正常数据。所以这里我的建议是:head 只适合快速确认列名,要了解数据全貌,必须跑 info 和 describe。
3. 清洗环节:作业里 70% 的分数都藏在这里
3.1 缺失值处理:每一列单独做决定
清洗是整个作业里耗时最长、也最容易被扣分的部分。我的原则很简单:每一列单独做决定,不搞一刀切。先用 df.isna().sum() 看看缺失情况,我这边的情况是 order_id 有 12 个缺失、category 有 150 个缺失、price 有 8 个缺失、city 有 423 个缺失。
接下来每一列的处理逻辑是这样:order_id 缺失没法补,订单号是唯一标识,填充任何值都会造假,只能删掉这 12 行。price 缺失也只有 8 行,直接删除,对整体影响可以忽略。category 缺失填“未知”,因为这是一个分类变量,用占位值填充可以保留样本量,不影响整体趋势判断。city 缺失我先查了一下是否集中在某些时间段,发现是随机缺失,而且占比不到 1%,所以同样用“未知”填充。
我最后把处理理由写进了报告,原话大概是:“缺失比例低于 1% 且属于随机缺失,删除不会引入明显偏差;分类变量缺失后填充占位值,保持样本量稳定。”这样写,老师一眼就能看出你不是随手 dropna,而是真的从数据角度想过每个决策。这个“写理由”的习惯,是这次作业最值钱的收获。
3.2 重复值与异常值:先定义后动手
重复值我用 df.duplicated().sum() 检查,发现有 37 行完全重复。这里有个细节:duplicated 默认把第一次出现的行视为不重复,第二次出现才标 True,所以 sum() 的结果就是要删除的行数,直接 df.drop_duplicates(inplace=True) 就行。但要注意,如果业务上允许同一个订单号有多条明细,就不能按整行去重,得先想清楚重复的定义再动手。
异常值那边我发现了三类问题:price 有负数、quantity 有 0、有单价超过 10 万的极端值。负数明显是录入错误,直接用条件过滤掉。数量为 0 的订单没有分析意义,删除。极端高价我没有急着删,而是用 df.loc[df['price'] > 100000] 查了一下,发现集中在几个特定类目,确认是真实存在的高端商品,保留了下来。
这里我想强调一个容易被忽略的点:异常值的处理不能只看数值大小,要看业务含义。同类目里价格差 10 倍可能是异常,但跨类目比较时,珠宝和日用品的价格本来就不在一个量级。所以我的做法是:先按列看分布,再按类目分组看离群情况,最后才决定是删、是修、还是保留。
3.3 数据类型转换:字符串数字的隐形雷区
清洗的第三件事是处理 dtype。order_date 读进来是字符串,要转成 datetime 格式,后面才能按月、按季度聚合:
python复制df['order_date'] = pd.to_datetime(df['order_date'])
price 列里混了“¥”符号,需要先清理再转数值:
python复制df['price'] = df['price'].astype(str).str.replace('¥', '', regex=False).astype(float)
这里要提醒一句:如果列里既有“¥”又有普通数字,直接 astype(float) 会报错,必须先统一成字符串再替换。另外 replace 默认支持正则,如果你只是想去掉一个普通字符,最好加 regex=False,否则遇到某些特殊字符时结果会完全出乎意料。我一开始没加这个参数,结果有一行的价格变成了 NaN,排查了半天才发现是正则把连续数字也一并处理了。
还有一个小经验:转换完成后,用 df[['price', 'quantity']].dtypes 复查一遍,确认都是 float 或 int。很多人做完清洗就急着画图,结果图上纵轴全是乱码,回头才发现列类型压根没转对。
4. 探索性分析:先问问题,再让数据回答
4.1 单变量分析是感知数据的第一步
清洗完成后的 df 大概 9.9 万行。我给自己定的分析节奏是:先单变量、再双变量、最后分组对比。单变量阶段会看每个关键列的分布:df['category'].value_counts()、df['price'].describe()、df['city'].value_counts().head(10)。
这一步的目的不是出结论,而是感知数据。比如我看了 price 的分布后发现存在明显右偏,少数高价订单把均值拉得很高,后面做任何均值类分析时都得留个心眼:均值可能不代表典型水平。再看 category 的分布,发现订单量最大的三个类目占了一半以上,说明数据本身就有明显的头部效应。
单变量分析还有一个作用:帮自己发现前面清洗时漏掉的问题。我在这一步发现 city 列里有两个值其实是同一个城市的旧称和新称,nunique 没查出来,但 value_counts 一眼就看出来了。这种问题非常典型,处理方式是在清洗阶段用 df['city'] = df['city'].replace({'旧称': '新称'}) 合并,而不是留在分析阶段硬着头皮做。
4.2 分组对比要搞清楚聚合口径
三个业务问题里,两个都是分组对比类问题。第一个“销售额最高的类目”,我先算出每行订单的销售额,再按类目求和:
python复制df['sales'] = df['price'] * df['quantity']
category_sales = df.groupby('category')['sales'].sum().sort_values(ascending=False)
print(category_sales.head(10))
第二个“哪些城市客单价更高”,这里就要特别注意聚合口径了。客单价是“总销售额 / 订单数”,不是“销售额均值”。如果直接用 df.groupby('city')['sales'].mean(),得到的是每行明细的平均金额,而一个订单可能包含多行商品明细,这两个数含义完全不同。
python复制city_avg = df.groupby('city')['sales'].mean().sort_values(ascending=False)
print(city_avg.head(10))
所以做分组对比时,先想清楚聚合口径是 sum 还是 mean,是订单维度还是商品维度。口径错了,结论全错,这是作业里最容易丢分的地方。月度趋势那边也是一样,先把订单日期提取成月份,再按月求和:
python复制df['month'] = df['order_date'].dt.to_period('M')
monthly_sales = df.groupby('month')['sales'].sum()
4.3 相关性分析别把 id 一起算进去
第三个业务问题我选了“价格和销量之间有没有关系”,于是做了相关性矩阵。这一步的坑比想象的多,我第一次直接把整个 df 丢进了 corr(),结果 order_id 这种数值型 ID 也被算进去了,形成了一个看似高相关、实际毫无意义的数值。
正确做法是先选出有业务含义的数值列,再算相关性:
python复制num_cols = ['price', 'quantity', 'sales']
print(df[num_cols].corr())
结果 price 和 quantity 的相关性只有 -0.08,基本可以认为没有线性关系。这个结论本身不惊艳,但它是数据告诉我的,而且我能在报告里写出完整的验证过程,这比一个“拍脑袋”的结论可信得多。相关性分析这里还有一个延伸建议:如果后续想加强深度,可以按类目分组再算一次相关系数,很多全局弱相关的关系,在特定分组里会呈现完全不同的模式。
5. 可视化与报告输出:图不是画出来就完事
5.1 图表选型:什么图配什么问题
四张图我选的是:类目销售额横向条形图、城市客单价柱状图、月度销售额折线图、价格分布直方图。选图逻辑其实很简单,不同的图是为了回答不同的问题,用下面这个表可以快速对照:
| 要回答的问题 | 推荐图表 | 选择理由 |
|---|---|---|
| 类目之间销售额大小对比 | 横向条形图 | 类目多,横向条形图更容易读类目名 |
| 城市客单价排名 | 柱状图 | 城市数量少,纵向柱状图对比清晰 |
| 销售额随时间的变化 | 折线图 | 突出趋势方向和波动 |
| 价格分布是否偏态 | 直方图 | 直观看到集中趋势和长尾 |
画图的时候,我习惯统一设置一个视觉基线:figsize 统一用 (10, 6)、标题字号 14、刻度字号 12、加浅色网格线、每个图表都有明确的标题和坐标轴标签。有人觉得这些是小事,但老师批改几十份作业的时候,版面干净、标签清楚的图天然会加分。
5.2 中文乱码:绕不开又必须解决
用 matplotlib 画图,中文乱码几乎是必踩的坑。默认字体里没有中文字体,标题写“销售额”输出就是一个个方块。标准解法是在画图前设置字体:
python复制plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
第一行指定 sans-serif 字体列表优先用 SimHei,第二行是让负号正常显示,因为改完字体后负号特别容易变成方块。如果你在 Windows 上,SimHei 一般直接可用;在 Linux 上需要先安装中文字体,比如 Noto Sans CJK SC,然后设置 plt.rcParams['font.sans-serif'] = ['Noto Sans CJK SC']。
另外一个容易忽略的点:rcParams 设置要放在所有画图代码之前,最好放在导入库之后的第一格。如果把设置写在一张已经画好的图后面,前面的图不会自动修复,必须重新运行整个 notebook。我那天就因为设置放晚了,白白多跑了三次。
5.3 报告结构:老师真正看的是什么
最后是报告。我的报告结构分三块:问题定义、处理过程、结论。问题定义就是把老师的三行题面翻译成三个业务问题,告诉老师“我是带着什么问题来看这份数据的”。处理过程是把清洗环节的每一次决策和理由写清楚,包括删了多少行、填了什么值、为什么这么做。结论部分每张图配两句文字,第一句描述现象,第二句给出业务判断。
比如月度趋势那张图,我写的是:“从月度趋势看,销售额在 6 月出现明显峰值,推测与平台促销活动有关,建议进一步核对 6 月的订单来源和类目结构。”这种写法的好处是,老师看到的是“你理解了数据”,而不是“你跑了一段代码”。报告最后我还加了一小节“结论的局限性”,比如客单价分析没有排除退款订单、缺失的城市用“未知”填充可能导致偏差等,这部分反而被老师圈出来表扬了。
6. 复盘:交出去前我改掉的五处硬伤
6.1 三个反复出现的低级错误
先说低级错误,这三个我几乎每次做数据处理都会犯,这次也不例外。第一个是链式赋值:df[df['price'] > 0]['price'] = ...,pandas 会报警告,而且赋值可能不生效,正确写法是 df.loc[df['price'] > 0, 'price'] = ...。第二个是 groupby 之后忘了 reset_index(),导致后面想 merge 的时候 key 怎么都对不上。第三个是画图之前忘了检查索引,比如 value_counts() 的结果索引是类目名,直接 plot 没问题,但想按特定顺序画图时,必须先 sort_values() 或 reindex()。
这三个错误每次都要花不少时间查,而且查的时候往往想不起自己刚才到底改了什么。后来我把这几条写在自己的 check 笔记里,交作业前逐条过一遍,效率提高很多。你可能觉得这种错误很低级,但正是在赶 deadline 的高压状态下,低级错误才最容易混进代码里。
6.2 时间都花在了哪里
实话实说,这次作业我实际花了大概 9 个小时:拆题 1 小时、清洗 3 小时、分析 2 小时、画图和写报告 2 小时、排版复核 1 小时。其中最大的一次返工发生在分析阶段——我最初选了“价格区间和订单量的关系”作为第三个问题,做了一半发现数据在这个维度上太稀疏,根本支撑不起结论,临时换成了相关性分析,多花了 1 个多小时。
这个教训是:动手分析之前,先想清楚数据能不能回答这个问题。简单验证方式是先跑一下 df['price'].quantile([0.25, 0.5, 0.75, 0.9]),看看数据在每个区间的样本量够不够,再决定问题方向。如果某个分组的样本量只有几十条,画出来的图会很丑,结论也没有说服力,这时候换个问法或换个维度,反而能让整份作业质量提升一大截。
6.3 留给下一次作业的检查清单
最后整理几条这次作业沉淀下来的经验,下一次我打算直接照做,也分享给你参考:
- 拿到题面先拆评分点,把“完成清洗”翻译成具体动作,逐一核对再动手
- 所有清洗动作都留一个操作前的记录,比如删除前打印 affected rows,方便报告里引用
- 每个结论都要能指向一个具体的图表或数值,反过来每张图也要能支撑一个结论
- 交作业前至少跑一次 Kernel -> Restart & Run All,确认 notebook 从头到尾能顺序执行
- 预留至少 30 分钟检查格式:标题编号、图注、结论是否清晰可见
说实话,做完作业4最大的感受是,数据分析的作业不是“写完代码”就结束,而是“讲清楚一个故事”才算完。代码解决的是怎么算的问题,报告解决的是为什么这个结果可信的问题。后者才是这道作业真正想练的能力,也恰恰是很多人交作业时最容易忽略的部分。
如果硬要说有什么可以立竿见影的建议,那就是交作业前多问自己一句:不看代码,只看报告,别人能不能复述出你发现了什么。反正我每次这么一问,总会发现还有东西没写清楚。
