1. 先说清楚这次作业是什么
老规矩,先把背景交代明白。这是我参加的数据分析训练营里,老师布置的第二次作业,题目给得很简单:拿到一份公开的销售数据,自己完成一次从数据清洗到可视化分析的全流程,最后输出一份能讲清楚"卖得怎么样、哪里卖得好、什么品类拖后腿"的简短报告。第一次作业还是单纯练 pandas 的语法题,第二次就变成了一个需要独立完成的迷你项目。很多人在这一步会突然卡住,不是因为代码难,而是因为不知道从哪下手。
这份数据集大约五千行,字段包含订单编号、成交时间、商品分类、销售额、成本、客户地区、支付方式等信息。看起来字段不多,但真实数据的问题一个不少:有缺失值,有重复记录,有金额格式里混着人民币符号和千分位逗号,有日期格式五花八门,甚至还有销售额为负数的脏数据。这篇文章就是我当时从拿到数据到最后交作业的完整实操记录,每一步都写了思路、代码和当时踩过的坑。
如果你也正处于"刚学完 pandas 基础语法、要做第一个完整小项目"的阶段,这篇内容可以直接当参考模板。重点不在于那份销售数据本身,而在于处理这类"半脏数据"时,一个人该按什么顺序想问题、该在哪些地方保护自己、又在哪些地方敢于做取舍。
2. 数据清洗:作业里最容易翻车的环节
2.1 先别急着清洗,把数据"看"明白
我见过太多人拿到数据后第一件事就是写dropna(),这其实是一种偷懒,也是一种风险。数据清洗的第一步不是处理,而是观察。先搞清楚每一列是什么类型、有多少非空值、取值范围是否合理,再决定怎么动手。
我当时是这么做的:
import pandas as pd df = pd.read_csv('sales_data.csv', parse_dates=['成交时间']) print(df.shape) print(df.info()) print(df.head()) print(df.describe())info()会告诉你每一列的非空数量,哪列缺得厉害一目了然。describe()能看出销售额、成本这类数值列的大致分布,比如最小值为负数、最大值离谱,这种信号都会在这里暴露出来。
有一个细节特别容易忽略:parse_dates=['成交时间']这个参数,让 pandas 在读取时就把日期列转成 datetime 类型,而不是等后面再统一处理。这个习惯帮你省下很多后续的格式转换问题。
看完之后我记录下几个关键情况:客户地区有少量缺失,大约占总量的百分之二;成本列缺失比较严重,接近百分之十五;销售额里出现了负值和一些明显异常的小数位;订单编号存在完全重复的记录。
拿到这个清单之后,才算真正进入清洗环节。
2.2 缺失值、重复值、格式错误,分步处理
清洗要按顺序来,我习惯的优先级是:先处理重复值,再处理格式,最后处理缺失值和异常值。因为如果你先删了缺失行,再发现订单编号有重复,那删重复时就拿不到完整的上下文;反过来,如果先把格式统一好,再执行缺失值操作,一些因为格式问题产生的假空值也能自动解决。
首先是直接能看到的问题:
# 查看重复记录数量 print(df.duplicated().sum()) # 删除完全重复的记录,保留第一条 df = df.drop_duplicates()这一步没什么技术含量,真正要注意的是判断"重复"的粒度。完全重复删除没问题,但如果只是订单编号相同、其他字段不同,那可能是多笔子订单,不能一删了之。所以我在作业里只处理了整行完全重复的情况,而不是单独对订单编号去重。
然后是格式清洗。销售额那一列读进来时是字符串,长这样:"¥1,299.00"。这种格式如果不处理,直接astype(float)肯定会报错,而且很容易让新手误以为是数据本身的问题,其实是自己没有先做字符串清理。
df['销售额'] = ( df['销售额'] .astype(str) .str.replace('¥', '', regex=False) .str.replace(',', '', regex=False) .astype(float) )str.replace('¥', '')把人民币符号去掉,str.replace(',', '')把千分位逗号去掉,最后再转 float。这里我特意使用regex=False,因为这两个字符本身没有正则含义,避免 pandas 去解析特殊符号,性能也更好。如果你要处理的是美元或者欧元符号,原理完全相同。
日期格式也要统一。原始数据里同一个日期列同时存在2023/1/5和2023-01-05两种写法,这在实际数据里很常见。用pd.to_datetime()统一即可:
df['成交时间'] = pd.to_datetime(df['成交时间'])这一步做完后,你就可以通过df['成交时间'].dt.month等方式访问月份、星期等信息,为后面的月度趋势分析打基础。
分类字段也得清理常见脏数据。比如商品分类里有的叫" 数码产品 ",带前后空格;有的叫"数码产品";还有的叫"数码电器"。这些不统一会让后面的groupby统计结果裂开,明明是同一类产品,却被分成两个类目,导致图表出现几乎一样但分属两类的柱子。处理方式也很简单,先去掉首尾空格,再做一次人工映射:
df['商品分类'] = df['商品分类'].str.strip() category_map = { '数码电器': '数码产品', '数码': '数码产品', '家用电器': '家电', '家电产品': '家电', } df['商品分类'] = df['商品分类'].replace(category_map)这种"字段值归并"的活没有标准答案,完全取决于你对业务的理解。但是清洗环节里,这类问题必须处理,否则后面分析出来的结论会被一些杂音带偏。建议先把唯一值列出来检查一遍,不管字段多长,都值得扫一眼。
缺失值处理是重头戏。先看缺失数量:
print(df.isna().sum())订单编号和客户地区的缺失占比很小,我直接删掉了对应的行。判断依据是:缺失比例低于百分之三,而且这些字段没有业务上的填充逻辑,强行用众数或前后值填充反而制造虚假信息。
成本列的缺失不能用删除处理,因为它占比百分之十五,删掉会损失太多样本。我的补全思路是用同类商品毛利率的中位数,反推出缺失的成本。先算出已有记录的单笔毛利率,再按分类汇总得到各类目毛利率中位数,最后对缺失成本的行执行成本 = 销售额 * (1 - 同类毛利率中位数)。
# 先算已有成本记录的毛利率 valid = df.dropna(subset=['成本']) df.loc[valid.index, '毛利率'] = 1 - valid['成本'] / valid['销售额'] # 按分类取中位数 ratio_by_cat = df.groupby('商品分类')['毛利率'].median() # 合并回原表,填充缺失成本 df = df.merge(ratio_by_cat.rename('ratio_median'), on='商品分类', how='left') mask = df['成本'].isna() df.loc[mask, '成本'] = df.loc[mask, '销售额'] * (1 - df.loc[mask, 'ratio_median']) df.drop(columns=['ratio_median'], inplace=True)这里有一个很容易被忽略的坑:因为成本缺失的那批记录本身计算不出毛利率,所以计算分类毛利率中位数时必须先把缺失成本的记录剔除出去,否则中位数会是 NaN,反推成本时全部落空。我在第一次跑的时候没注意,结果填充后的成本列依然全是空,回头检查才发现问题出在groupby时把缺失值也带了进去。
2.3 异常值要讲道理,不能乱删
异常值处理是整个清洗环节里最考验判断力的部分,也是作业展示成熟度的地方。我看到销售额有负数时,第一反应不是直接删除,而是先看一下这些负数的出现场景:订单状态、时间分布、数值大小。
查看后发现,负销售额集中在某个特定时间段,更像是测试数据或者退款记录混进来了。这种条件下删除是合理的。但如果只是因为想当然觉得"销售额不该为负"就删除,遇到真实业务里的退款场景反而会误伤数据。所以我在代码里加了一个简单的业务假设,只删销售额小于零的记录,并保留注释说明原因。
成本大于销售额这种记录也要处理,因为毛利率为负虽然可能存在,但负到离谱的就值得怀疑。我设置了一个阈值:毛利率低于负百分之五十的记录视为异常,不再参与后续分析。这里没有用复杂的统计方法,只是给了一个业务上说得通的边界,确保结论不会被极端值影响。
df = df[df['销售额'] > 0] df['毛利率'] = 1 - df['成本'] / df['销售额'] df = df[df['毛利率'] > -0.5]做完这一步,清洗阶段就算收尾了。我重新跑了一次df.info()和df.describe(),确认没有新的空值、数值范围合理、字段类型全部正确,才开始做分析。整个过程看起来不难,但实际执行时每一步都可能出小岔子,尤其是缺失值补全和格式转换的先后顺序,多踩两次坑自然就有感觉了。
3. 探索性分析:让数据自己开口说话
3.1 从三个问题出发做分析
清洗完成后,面对一张五千行的表,直接开始画图是没有章法的。我先问了自己三个业务问题:整体销售趋势在全年是如何变化的?哪些地区贡献了主要收入?不同品类的销售额和毛利结构有什么差异?
这三个问题分别对应时间维度、地域维度、品类维度,基本覆盖了"这家店卖得怎么样"的核心判断。分析代码其实很简单,重点是你要有明确的问题意识,否则就会陷入"为了画图而画图"。比如画二十张图表,每张都好看,但串不出一条完整的业务结论,作业效果反而打折扣。
按问题拆解后,我做了三个关键统计:
# 月度销售额和订单量趋势 monthly = df.resample('M', on='成交时间').agg( 销售额=('销售额', 'sum'), 订单量=('订单编号', 'count') ) # 地区销售额 Top10 region_top10 = df.groupby('客户地区')['销售额'].sum().nlargest(10) # 品类销售额与毛利率 category_stats = df.groupby('商品分类').agg( 销售额=('销售额', 'sum'), 订单量=('订单编号', 'count'), 平均毛利率=('毛利率', 'mean') ).sort_values('销售额', ascending=False)resample('M', on='成交时间')是月度聚合的便捷写法,它会自动按月份重新采样。这个函数对新手来说比较陌生,但比to_period('M').groupby()更直观,推荐直接使用。注意聚合时要明确每个字段对应的计算方式,别让 pandas 自动推断,否则遇到混合类型会得到意料之外的字段。
3.2 用表格和分组统计验证想法
分析结果出来后,我会习惯性地先看表格,再画图。因为图表适合做展示,但表格更适合做判断。比如地区销售 Top10,在表格里你能看到第一名和第十名的具体差额、每个地区的占比,这些信息在柱状图里虽然能看出来,但不如表格精确。
当时我发现一个有意思的现象:华东地区销售额最高,但毛利率排名靠后。这说明该地区销量大,但折扣力度也大,或者说该地区的热门品类本身就是低毛利产品。这个结论不能只靠销售额排行看出来,必须把销售额和毛利率放在一起对照。
品类透视表也很有价值:
pivot = pd.pivot_table( df, index='商品分类', values=['销售额', '成本', '毛利率'], aggfunc={ '销售额': 'sum', '成本': 'sum', '毛利率': 'mean' } ) pivot['毛利额'] = pivot['销售额'] - pivot['成本'] pivot = pivot.sort_values('毛利额', ascending=False)为什么用pivot_table而不是groupby?因为这里需要同时用三种不同的聚合函数,pivot_table通过传字典的方式表达更清晰,不容易在agg参数上写错。实际跑下来,手机配件类销售额排第一,但毛利额排第三,因为平均毛利率不到两成;相反,生活电器类销售额中等,但毛利额更高。这种"销售额高不等于赚得多"的结论,就是分析报告里最有信息量的部分。
探索性分析的目的不是得出一个完美结论,而是形成一条逻辑链:数据经过清洗后,按业务问题拆解,用分组统计找出值得深挖的方向,再用可视化展示结果。我当时在作业里写出了这样的句子:华东地区贡献了约百分之三十的销售额,但该地区主打的商品品类集中在手机配件,拉低了整体毛利率。这一句话背后得有数据支撑,这就是分析和拍脑袋的区别。
4. 可视化:让图表替你汇报
4.1 中文乱码与图表美化的几个坑
分析做到这一步,图表展示是重头戏。但我必须先把最容易劝退新手的"中文乱码"问题说清楚,因为这一步不解决,后面所有图都白画。
直接使用默认配置,图里的中文会变成一个个方框,这在 matplotlib 里是老问题。解决办法是设置中文字体,我通常这样写:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei'] plt.rcParams['axes.unicode_minus'] = False第一行指定使用微软雅黑或黑体,第二行解决负号显示为方框的问题。注意axes.unicode_minus这个配置也很重要,当坐标轴出现负号时,如果没有关闭 Unicode 减号,会出现一个小方块,很多新手在这卡半天。
还有另一个坑:如果你在 Jupyter Notebook 里画图,要先执行%matplotlib inline,否则图不会显示。这个细节太基础了,但真的有人忽略。如果图表只显示一行带Figure字样的对象而没有图形,十有八九就是漏了它。
关于图表的美化,我的经验是不要为了花哨而堆颜色,一张图的字体大小、图例位置、坐标轴标签这些基本功要优先保证。作业图的尺寸可以统一为figsize=(10, 6),保存时用bbox_inches='tight'防止标签被截断。
4.2 四张核心图表的实现代码
我最终提交了四张图:月度销售额趋势折线图、地区销售额 Top10 柱状图、品类销售额占比饼图、销售额分布箱线图。每张图对应一个分析问题,不重复不堆砌。
折线图用来看趋势:
fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(monthly.index, monthly['销售额'], marker='o', linewidth=2) ax.set_title('月度销售额趋势') ax.set_xlabel('月份') ax.set_ylabel('销售额(元)') ax.grid(alpha=0.3) fig.autofmt_xdate() plt.tight_layout() plt.savefig('../output/trend.png', dpi=200, bbox_inches='tight')fig.autofmt_xdate()会自动旋转日期标签,避免月份挤在一起。marker='o'给每个数据点加上圆点标记,这样实际的月度数值点一眼就能看出来,而不是只有一条光秃秃的线。
柱状图看地区差异:
fig, ax = plt.subplots(figsize=(10, 6)) bars = ax.bar(region_top10.index, region_top10.values, color='#4C72B0') ax.bar_label(bars, fmt='%.0f') ax.set_title('地区销售额 Top10') ax.tick_params(axis='x', rotation=45) plt.tight_layout() plt.savefig('../output/region_top10.png', dpi=200, bbox_inches='tight')ax.bar_label()是 matplotlib 3.4 之后新增的参数,可以在柱子上直接标注数值,省去了手动写循环加标签的麻烦。如果你用的版本比较老,就得用plt.text()循环处理,这一点要注意。
饼图看结构占比:
category_sum = category_stats['销售额'].head(6) others = category_stats['销售额'].iloc[6:].sum() merged = pd.concat([category_sum, pd.Series({'其他': others})]) fig, ax = plt.subplots(figsize=(7, 7)) ax.pie(merged, labels=merged.index, autopct='%.1f%%', startangle=90) ax.set_title('品类销售额占比') plt.tight_layout() plt.savefig('../output/category_pie.png', dpi=200, bbox_inches='tight')这里做了一个常规的合并处理:品类太多时,只突出前六名,其余统一归为"其他",避免饼图密密麻麻看不清。startangle=90让第一块从正上方开始,视觉上舒服一点。
箱线图看销售额分布:
fig, ax = plt.subplots(figsize=(10, 6)) top_categories = category_stats.head(5).index plot_data = [df.loc[df['商品分类'] == c, '销售额'] for c in top_categories] ax.boxplot(plot_data, labels=top_categories, showfliers=True) ax.set_title('Top5 品类销售额分布') ax.set_ylabel('销售额(元)') ax.tick_params(axis='x', rotation=30) plt.tight_layout() plt.savefig('../output/category_box.png', dpi=200, bbox_inches='tight')箱线图是我比较喜欢加进去的一张图,因为它能揭示平均值掩盖的信息。比如手机配件销售额总量高,但中位数可能很低,说明少数大单拉高了总额,大部分订单仍是小金额。这种观察用柱状图很难体现。
四张图全部保存到output目录后,我还做了一件事:把图片的文件名改得更直白一些,比如sales_trend_by_month.png,而不是figure1.png。这样报告里引用图片时,文件名本身就是表意的一部分,后面翻盘对照也方便。
5. 从"交作业"到"像样的小项目"
5.1 目录结构与脚本分层
很多人的第二次作业就是一个 Jupyter Notebook 从头跑到尾,我当时也差点这么交。后来想一想,既然以后要持续写代码,不如直接从这次作业开始,按一个最小项目的标准来组织文件。
我当时的目录结构长这样:
second_assignment/ ├── data/ │ └── sales_data.csv ├── scripts/ │ ├── 01_load_data.py │ ├── 02_clean_data.py │ ├── 03_analysis.py │ └── 04_visualization.py ├── output/ │ ├── sales_trend_by_month.png │ ├── region_top10.png │ ├── category_pie.png │ └── category_box.png └── requirements.txt把不同环节拆成独立脚本,不是为了让代码变多,而是让每一步都有明确的边界。01_load_data.py只负责读取原始数据,02_clean_data.py只负责输出清洗后的数据表,03_analysis.py负责计算,04_visualization.py负责出图。这样如果可视化出问题,你只需要改第四个文件,而不用在一千行的 Notebook 里找是哪一段代码出了问题。
数据文件放在data/目录,图片输出到output/目录,脚本不直接访问绝对路径,而是使用相对路径。如果你把整个目录拷贝到另一台电脑,依然能跑起来。这种目录组织的习惯,比多学十个函数都值钱。
5.2 函数封装和留痕习惯
脚本拆分之后,我顺手把每个环节的核心逻辑包装成了函数。目的是让代码具备复用性:如果下一次老师换了一份数据,只需要修改主函数里的文件路径,就能复用整套清洗和分析流程。
简单示例:
# 02_clean_data.py def load_and_clean(path): df = pd.read_csv(path, parse_dates=['成交时间']) # 清洗逻辑... df.to_parquet('../data/sales_clean.parquet', index=False) return df这里用to_parquet保存清洗后的数据,而不是to_csv。Parquet 格式有几个明显优势:压缩率高、保留数据类型、读写速度快。如果你不熟悉 parquet,继续用to_csv也可以,但要记得把清洗后的列类型保持一致,否则下次读取又得重新处理一遍。这类"数据留痕"的习惯看着不起眼,但对复盘和排查非常有帮助。
另一个我踩过的坑是直接覆盖原始数据。一开始我在脚本里执行df.to_csv('sales_data.csv'),把清洗结果直接覆盖了原文件。后来想重跑一次清洗,发现原始数据已经没了,只好重新下载。从那以后,我再也不会让任何处理步骤修改原始文件。更稳妥的做法是清洗前先执行df_clean = df.copy(),后续所有操作都在这个副本上进行。
requirements.txt也该在这次作业里建立起来。只需要一行命令生成:
pip freeze > requirements.txt这样别人拿到你的项目后,执行pip install -r requirements.txt就能复现环境。交作业评阅时也会方便很多。
6. 现场实录:踩过的坑和排查方法
6.1 典型问题速查表
这部分我把当时实际遇到的几个典型问题整理成了一张表,方便你遇到相似报错时快速对照。每一个问题都是我真实踩过的,不是从文档里抄来的。
| 现象 | 原因 | 处理办法 |
|---|---|---|
| matplotlib 图中中文变方框 | 未配置中文字体 | plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei'] |
| 坐标轴负号显示成小方块 | 未关闭 Unicode 减号 | plt.rcParams['axes.unicode_minus'] = False |
pd.to_datetime报错无法解析 | 原数据包含混合格式或脏字符 | 先查看df['成交时间'].unique(),定位异常格式,必要时加errors='coerce'再做删除 |
| 月份聚合后日期列变成索引,画图时横轴错位 | 没有意识到resample后索引是 DatetimeIndex | 画图直接用索引,或先reset_index() |
| 分组聚合结果始终不对 | groupby的 key 列里有前后空格 | 先执行.str.strip()再分组 |
| 一张图显示两行图例重叠 | 图例位置没设置合理 | 加plt.legend(loc='best'),或手动指定loc='upper left' |
| 读取 CSV 后中文变成乱码 | 文件编码不是 UTF-8 | 文件是 GBK 编码时用encoding='gbk',保存时建议用utf-8-sig |
pd.to_datetime报错是我那次作业里印象最深的问题,因为数据里除了2023/1/5和2023-01-05,还有几条记录类似2023.1.5。errors='coerce'会把解析失败的值变成 NaT,但如果你不检查就继续往下做,后面聚合时这些日期全部消失,最终结果会悄悄少一段数据。所以我的建议是:转换日期后立刻执行print(df['成交时间'].isna().sum()),确认没有新的空值。
6.2 那次让我印象最深的报错
说一个让我改了半个小时的错误。在计算毛利率时,我本来是这样写的:
df['毛利率'] = 1 - df['成本'] / df['销售额']结果发现新生成的列全是 NaN,而且没有任何报错。当时的第一反应是数据有问题,后来仔细检查才发现,问题出在成本列已经被我填充过,但填充后的数据里部分行同时存在未填充的脏值。听起来简单,实际排查时却花了很多时间,因为没有报错往往比有报错更可怕。
后来我养成了一个习惯:在做任何涉及将列相除、相乘的操作前,先检查参与运算的列是否全为数值类型、是否还有空值。用一行代码就能查清楚:
print(df[['成本', '销售额']].dtypes) print(df[['成本', '销售额']].isna().sum())这种"提前检查"看起来多了一步,实际上能省下一大段排查时间。第二个教训是:当计算结果的 NaN 数量异常时,不要急着看业务逻辑,先检查数据类型和空值,这两个原因占了大概率。
还遇到过一次让我哭笑不得的问题:因为把清洗后的结果保存成了 CSV,再读取时日期列又变回了字符串,后续按月份聚合全乱套。后来我学乖了,中间结果用 parquet 保存,或者干脆在同一个进程里完成清洗和分析,避免序列化时类型信息丢失。
7. 复盘:第二次作业真正教会我的三件事
如果让我总结这次作业最大的收获,不是学会了groupby、resample或者matplotlib,而是三个做数据项目时容易忽略的习惯。
第一,原始数据永远不动。不管清洗逻辑多完善、代码写得多自信,都不要在原文件上直接覆盖。最安全的办法是清洗前创建副本,或者把清洗结果输出到独立路径。这个习惯在真实工作中价值更大,因为一份数据往往要服务多个分析方向,谁也不想为了一个临时需求毁掉唯一的原始数据源。
第二,每个结论都要能用数据指认。当时我在报告里写"华东地区销售额最高,但毛利率偏低",评阅老师追问了一句:"这个结论是哪张表、哪一行数据支持的?"这个提问让我意识到,好的分析不是观点鲜明,而是每一个观点背后都有一串可以追溯的数字和图表。数据分析里的沟通,本质上是在教人"拿证据说话"。
第三,代码要能一键重跑。把清洗、分析、可视化拆成模块化脚本后,我每次修改完只需要执行一次python 02_clean_data.py,就能确认清洗结果没有变化,不会因为手动执行了部分单元格就漏掉某个依赖。这种"重现性"平时不起眼,但当你需要反复调整图表配色、或者换一份新数据时,它的价值一下就体现出来了。
第二次作业给我的真实体验是:它能清楚地划分出"会写 pandas"和"能用数据解决问题"这两件事之间的差距。前者只需要记住函数和参数,后者考验的是面对一团乱麻时,你能不能按一个稳妥的顺序理出头绪。这份数据清洗脚本和分析思路,后来很多次都用上了,我不觉得这是一个一次性作业,更像是一个后续所有数据项目的起手式。