做数据分析这些年,我越来越确认一件事:真正耗时间的从来不是跑模型,而是数据清洗。Pandas几乎每个玩Python数据分析的人都在用,但很多人会陷入两种极端——一种把DataFrame当成Excel的Python版,只会筛筛选选;另一种上来就聊机器学习,结果连read_csv都读不利索。这篇文章不聊花哨算法,就踏踏实实走一遍从数据清洗到可视化的完整流程,拿一份业务订单数据举例,把每一步为什么这样做、踩过什么坑都讲清楚。
这份数据的典型结构大概是:订单号、客户ID、下单日期、商品分类、数量、单价、金额、支付方式、收货地区。字段不多,但脏起来要命:日期格式五花八门、金额里带逗号和"元"字、客户ID大小写混用、分类字段一会儿叫"数码"一会儿叫"数码类"。应对这些问题,核心工具就是Pandas。读完这篇文章,你应该能独立完成从原始数据到可读图表的完整分析链路,同时理解哪些操作是惯例,哪些操作要结合业务场景灵活调整。
注意:以下代码均基于Pandas 2.x版本,旧版本里个别API写法有差异,但整体思路完全通用。
1. 拿到数据先别急着跑分析:容量、口径与唯一性核验
我见过不少新手拿到Excel文件后,第一件事就是df.head()看两眼,然后直接开写pivot_table。这个习惯非常危险。真实业务数据和教科书里那些干净示例完全不同,必须先把数据当成一个"陌生病灶"来体检,体检完再决定怎么动刀。
1.1 用shape和info给数据"称重"
拿到数据的第一步,我先执行三个命令:
import pandas as pd df = pd.read_excel("order_data.xlsx") print(df.shape) df.info(verbose=True) df.head(10).Tshape告诉你数据集大概规模,是3万行乘9列,还是300行乘15列,这直接决定后续处理策略。info()会显示每一列的非空计数和dtype,这两样东西能立刻暴露数据有没有缺值、有没有类型错误。
举个例子,某个字段显示为object,但里面存的其实是一堆数字字符串或日期字符串,说明这一列的类型从一开始就是错的,后面必须转。另一个高频信号是:某一列非空计数明显小于总行数,这就是缺失值的入口。
我当时处理订单数据时,shape显示(16324, 9),但info()一跑出来就发现"客户ID"非空只有15832,"金额"列虽然全部非空,但dtype是object——这就是典型的两个坑:一列缺数据,一列存脏类型。
再看head(10).T,把前10行转置打印,列名和值横过来排列,人眼扫一遍就知道哪些列看着正常、哪些列混入了奇怪文本。这一步成本极低,但对建立"这堆数据大概是什么状况"的整体直觉很有帮助。
1.2 空值、唯一性与业务口径的第一轮体检
在动手清洗之前,我一定会先确认三件事:每一列的业务含义是什么、哪些列应该具有唯一性、哪些字段存在业务口径歧义。
判断唯一性有个很实用的办法,用nunique()查唯一值数量,再结合业务常识去推断:
print(df["订单号"].nunique()) # 应该等于总行数 print(df["客户ID"].nunique()) print(df["收货地区"].unique())订单号一列如果有重复,要分情况:可能是同一个人下单被录了两次,也可能是换单号的拆分订单。而客户ID的唯一值数量直接暗示复购情况,也提醒你之后做用户维度分析时要不要先去重。
再啰嗦一句:永远不要相信字段名。系统里的"下单日期"可能是订单创建时间,也可能是支付成功时间,这个差异会直接影响你对转化周期、销售节奏的理解。拿到数据后最好和业务方确认一次口径;如果确认不了,至少要取一下min和max时间范围,看看是否落在合理的业务区间内。这一步没做好,后面所有按日期聚合的结论都可能跑偏。
2. 数据清洗实战:缺失值、重复记录与脏字符串的收拾策略
清洗这块,我习惯按"缺、重、脏、错"四类问题从左到右处理:先处理缺失,再去重,然后收拾字符串,最后修类型。顺序是有讲究的。比如先fillna再drop_duplicates,和先drop_duplicates再fillna,结果可能不一样;前者可能在去重时保留了填充值,后者可能把填充后的行也当重复删掉了,所以固定顺序能减少踩坑。
2.1 缺失值处理:先判断缺失机制,再决定策略
缺失值不是无脑dropna()或者fillna(0)就完事。关键要先判断这个值为什么缺失。一般分三种:随机缺失、完全随机缺失、以及业务逻辑导致的结构性缺失。
判断方法很简单:按某个业务维度分组后看缺失率有没有明显差异。
miss_rate = df.groupby("支付方式")["金额"].apply(lambda x: x.isna().mean()) print(miss_rate)如果"货到付款"组金额缺失率特别高,那说明这组数据的缺失是业务规则造成的:货到付款订单在支付完成前,后端不会写入金额字段。这种场景下,删掉或填0都会扭曲后续分析。更合理的做法是显式加上一个"订单状态"标签列,把结构性缺失标记出来,后续分析时单独处理或者排除。
常见的填充策略,我整理成一个决策表:
| 情况 | 推荐做法 |
|---|---|
| 缺失率低于2%的随机缺失 | 直接dropna()该列 |
| 缺失率较高但字段是关键特征 | 用中位数或众数填充,并新增"是否缺失"标记列 |
| 业务逻辑导致的结构性缺失 | 单独设状态列,不强行填充 |
| 时间序列数据的缺口 | 先resample(),再看是否用前向/后向填充 |
顺带提一句,fillna(0)不是不能用,但必须确认"0"在业务上有没有意义。金额填0问题不大,但数量填0就会严重污染后面的平均单价计算,这点要特别留意。
2.2 去重:drop_duplicates的坑与正确姿势
去重看着简单,坑全在"按什么去重"。如果直接df.drop_duplicates(),默认是整行所有列完全相同才算重复,这对真实业务数据往往没什么用——同一个订单可能只是备注字段不同,本质上还是重复记录。
正确做法是先明确主键,再按主键去重:
df_dedup = df.drop_duplicates(subset=["订单号"])但这里有个更隐蔽的坑:如果这份数据是订单明细表,同一个订单号下有多行商品明细,那唯一键应该是"订单号+商品分类"的组合,此时盲目按"订单号"去重会把明细行删掉,绝对是灾难。
去重前请花三秒钟想清楚:这份数据是主表还是明细表?主键是什么?业务上出现重复的合理原因是什么?以我个人的习惯,为了让去重操作可追溯,我会先统计去重前后行数差异,并打印出重复行样本,确认这些行确实是冗余而不是业务上合理存在的数据,再执行删除。
2.3 字符串脏数据清理:strip、replace与str处理的实战组合
真实业务数据里,最脏的永远是字符串列。常见问题包括:
- 前后空格:"北京 "、"上海市 "
- 全角空格、制表符乱入
- 单位混在数值里:"128元"、"1,280"
- 分类口径不统一:"数码"、"数码类"、"数码产品"
我的处理思路是:先统一、再拆分、后检查。
df["收货地区"] = df["收货地区"].astype(str).str.replace(r"\s+", "", regex=True) df["收货地区"] = df["收货地区"].str.replace("\u3000", "", regex=False)第一行干掉所有半角空格和制表符,第二行处理全角空格。value_counts()是清洗阶段的"照妖镜",一跑出来就能看到那些孤零零的脏分类名:
print(df["商品分类"].value_counts())处理分类字段时,我一般维护一个映射字典,把同义分类归并:
cat_map = { "数码": "数码", "数码类": "数码", "数码产品": "数码", "手机配件": "数码", } df["商品分类"] = df["商品分类"].map(lambda x: cat_map.get(x, x))这里有个细节要记住:map里的lambda如果遇到NaN,cat_map.get(x, x)会原样返回NaN,不会报错,比先fillna再map更安全。输出后再次value_counts(),确认分类数收敛到了预设范围内,这次清洗才算完成。
2.4 类型修复:金额和时间标准化的两种经典手段
金额列里混着逗号和"元"字的字符串时,两步走:先清洗符号,再转float。
df["金额"] = df["金额"].astype(str).str.replace(",", "", regex=False) df["金额"] = df["金额"].str.replace("元", "", regex=False) df["金额"] = pd.to_numeric(df["金额"], errors="coerce")errors="coerce"的意思是转换失败的地方置为NaN,不会导致整列报错,同时还能反过来帮你定位那些格式异常的原始值。转换后我习惯再用isna()筛出来看一眼,确认是哪些奇怪的脏值没洗掉。
时间列的处理更考验耐心。真实数据里日期格式可能是"2024/01/01"、"2024-01-01"、"2024.1.1"混着来。Pandas 2.x的to_datetime支持format="mixed"参数,会自动尝试多种格式:
df["下单日期"] = pd.to_datetime(df["下单日期"], format="mixed", dayfirst=False)如果你还在用旧版本,稳妥的做法是先把所有日期统一成字符串格式,再逐类解析。日期处理最怕的是静默错误——比如把1月2日解析成2月1日。所以解析后一定要做一次月份分布检查:
df["下单月份"] = df["下单日期"].dt.month print(df["下单月份"].value_counts().sort_index())看12个月的比例是否符合业务常识。如果某个本该平淡的月份数据量异常暴涨,极大概率就是日期解析出了问题,要立刻回头查原始格式。
3. 清洗之后的编排:分组聚合、透视表与自定义计算的协作
清洗干净不等于分析完成。这个阶段的核心任务,是把业务问题翻译成Pandas操作。很多人卡在这一步,不是不会用API,而是不知道哪种场景该用groupby、哪种该用pivot_table、什么时候该上apply。这一节把这三类手段放一起讲。
3.1 分组聚合:groupby与agg的正确配合方式
最常用的操作是按维度分组后聚合数值。我习惯用agg一次性搞定多个统计量,而不是逐个写:
result = df.groupby("商品分类").agg( 订单量=("订单号", "count"), 总金额=("金额", "sum"), 平均金额=("金额", "mean"), ) result = result.reset_index()这里的关键在于:如果你只写df.groupby("商品分类")["金额"].sum(),拿到的是一个Series,列名混乱,后续做图表和导出都麻烦。而agg配合命名元组的写法,输出直接就是格式规整的DataFrame,一列一个名字,后面想画图想merge都很顺手。
实操中我还会用pd.Grouper做时间维度分组,比如按周统计各类目销售额:
df["下单日期"] = pd.to_datetime(df["下单日期"]) weekly = df.groupby([pd.Grouper(key="下单日期", freq="W"), "商品分类"])["金额"].sum().reset_index()pd.Grouper和groupby配合,能同时处理时间维度和普通维度,比先set_index再resample灵活得多,尤其适合多维度时间序列分析。
3.2 透视表:pivot_table是业务分析里的高级翻译器
pivot_table比groupby更接近Excel透视表的思维方式,做"行乘列乘值"的交叉汇总非常顺手。核心参数是index、columns、values、aggfunc:
pivot = pd.pivot_table( df, index="收货地区", columns="支付方式", values="金额", aggfunc="sum", fill_value=0, )这张表输出的是"各地区乘各支付方式的成交金额",看业务结构一眼就明白。要注意的是,pivot_table默认会把所有数值列都聚合一遍,如果当前数据里除了金额还有数量字段,最好显式传values=["金额"],否则结果里会混入无关数字列,白白增加排查成本。
另一个特别常见的场景是同时看两个维度的汇总,比如"月份乘支付方式":
month_pivot = pd.pivot_table( df, index=df["下单日期"].dt.to_period("M"), columns="支付方式", values="金额", aggfunc="sum", )这种表和groupby的区别是什么?pivot_table更适合行、列两级交叉展示,groupby更适合扁平化的明细汇总。实际项目里我会先groupby出明细结果用于数据核对,再用pivot_table生成面向汇报的交叉表,两者互为补充。
3.3 apply、transform与向量化:计算效率的取舍
新手经常写apply,因为它最直白。但Pandas性能优化的第一法则是:能向量化就向量化。简单单列计算直接写df["新列"] = df["col"] + 1,不要用apply。
什么时候apply是必要的?当计算逻辑依赖多列、且不容易拆成向量化表达式时。比如根据金额和数量判断订单是否需要人工审核:
def need_review(row): if row["金额"] > 5000 and row["数量"] > 10: return "Y" return "N" df["是否审核"] = df.apply(need_review, axis=1)这段代码可读性很好,几万行的数据量性能也完全够用。真正要小心的是在几千万行数据上滥用apply,那种场景优先改用向量化条件组合:
df["是否审核"] = "N" df.loc[(df["金额"] > 5000) & (df["数量"] > 10), "是否审核"] = "Y"两种写法结果一致,但后者在大数据集上几乎快一个数量级。我的经验是:3万行以下随便用apply,体感不到差别;百万行往上就开始有明显卡顿,这时候必须优先向量化。
transform也值得单独拎出来说。它和agg的区别在于:agg把多个值压缩成一个标量,transform保留原有行数,把分组计算结果广播回每一行。比如我想算每个分类销售额占全体的百分比,又想保留原始明细行,用transform最合适:
df["分类总金额"] = df.groupby("商品分类")["金额"].transform("sum") df["分类占比"] = df["金额"] / df["分类总金额"]这种写法能一笔算出"每一行金额在所属分类里的贡献占比",是分组明细计算里的常用技巧。
3.4 别忽略索引:reset_index、set_index与merge的细节
清洗和聚合做完,接下来八成要合并多个DataFrame。merge之前一定要想清楚连接键,以及连接后会不会产生行数膨胀。
merged = pd.merge(user_df, order_df, on="客户ID", how="left")默认leftjoin时,合并前后行数应该保持不变。如果从订单维度合并用户信息,合并后订单行数却变多了,说明用户表里客户ID存在重复,需要先去重再合并。这个检查我每次都会做,因为行数膨胀是所有merge事故里最隐蔽的一种,稍不注意就会让下游统计全部出错。
另外,groupby之后的结果索引是多级索引,很多人不习惯,直接用reset_index(inplace=True)把它拍平就行。多级索引在透视表场景里很有用,但在写CSV、做后续merge或喂给机器学习模型时会带来一堆不必要的麻烦。通用做法是能拍平就拍平,别为了炫技保留层级索引。
4. 可视化环节:Pandas内置绘图与Matplotlib的配合套路
分析做完了,剩下的最后一步是可视化。这个环节最容易出两个问题:一是图表花哨但不清晰,二是为了画图绕了一大圈代码,反而把分析思路搞乱。我自己定下的原则是:先能用最少的代码画出来,再用细节调整让图能把业务结论讲清楚。
4.1 一行代码画基础图:df.plot()能覆盖80%日常需求
Pandas的plot方法底层是Matplotlib封装,好处是直接对着DataFrame就能画。最常见的三种图:
# 折线图:时间趋势 weekly.plot(x="下单日期", y="总金额", figsize=(10, 5)) # 柱状图:分类对比 result.plot(kind="bar", x="商品分类", y="总金额", figsize=(10, 5)) # 饼图:占比结构 df["支付方式"].value_counts().plot(kind="pie", autopct="%.1f%%", figsize=(6, 6))为什么说能覆盖80%?因为数据分析报告里九成以上的图形需求就是趋势、对比、占比和分布,Pandas自带的bar、line、pie、hist、box确实是够用的。省下的那些麻烦,等你真正需要对坐标轴精细调控时,再引入Matplotlib也不迟。
4.2 会用到Matplotlib的场景:多子图、多折线与刻度细节
Pandas画图的短板在于精细控制和多图组合。比如想把金额分布直方图和不同支付方式的箱线图放在上下两个子图里:
import matplotlib.pyplot as plt fig, axes = plt.subplots(2, 1, figsize=(10, 8)) df["金额"].hist(bins=30, ax=axes[0]) df.boxplot(column="金额", by="支付方式", ax=axes[1]) plt.tight_layout() plt.show()这种组合图用Pandas自带的plot方法没法一步到位,但拆开看,每一张仍是Pandas的绘图家族,只是配合了Matplotlib的axes对象。理解这个协作模式后,你会发现画图其实不冲突:Pandas提供便捷,Matplotlib提供控制权。
另一个高频需求是按分组画多条折线。经典写法是先透视再plot:
pivot_df = df.pivot_table( index="下单日期", columns="商品分类", values="金额", aggfunc="sum", ).resample("W").sum() pivot_df.plot(figsize=(12, 6))这种"透视表配合plot"的写法,是画多条时间序列最优雅的方式。不用手动循环构造line对象,一条plot()就把所有分类的周趋势全部画出来,图例会按列名自动生成。
4.3 可视化实战里的三个高频坑:中文乱码、图例位置和刻度密度
第一个坑是中文乱码。默认Matplotlib字体不支持中文显示,最简单的方式是全局设置:
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "WenQuanYi Zen Hei"] plt.rcParams["axes.unicode_minus"] = False不设置的话,坐标轴和标题里的中文会变成一个个方框,图表再漂亮也没法看。axes.unicode_minus设为False是为了让负号正常显示,这个细节很容易漏。
第二个坑是图例、刻度标签挤成一团。常见解法是调大figsize、旋转x轴标签。Pandas的plot方法里可以直接传rot参数:
result.plot(x="商品分类", y="总金额", kind="bar", rot=45, figsize=(10, 5))对于时间轴,还可以配合autofmt_xdate()自动旋转日期刻度,或者手动指定刻度显示密度:
plt.gca().xaxis.set_major_locator(plt.MaxNLocator(10))第三个坑是异常值把整个图压扁了。比如金额列有少量几百万的大单,其他大多数只有几百块,此时直方图根本看不出分布形态。这种情况要么先做截断,要么用箱线图观察分位点,要么把y轴改成对数刻度。对数刻度在处理重尾数据时特别好用:
plt.yscale("log")4.4 让图表反过来帮你发现清洗漏掉的问题
图表不只是最终展示工具,它还是数据质量检查的第二道防线。我每次清洗完都会把关键字段的分布图画一遍,用来做"视觉化验收"。
比如画完金额分布直方图,如果发现某个不该出现的尖峰,就要怀疑有没有订单状态没筛干净。再比如画完各地区的销售额柱子,某地区数值明显脱离常识,就回去翻该地区下单日期范围,确认是不是混入了测试账号的数据。数据清洗和可视化从来不是两个孤立阶段,而是在一个循环里互相校验的两道工序。
最后分享一条我坚持很久的习惯:每次清洗都会把关键操作脚本保存下来,而不是把清洗后的数据直接覆盖原始文件。Pandas操作一旦出错,回退成本很高,尤其是inplace=True用多了以后,数据被静默修改根本无从察觉。我会在脚本里给关键步骤加注释,把清洗规则和维护日期写清楚,半年后回看还能立刻知道当初为什么这样处理。
数据分析这条路,真正拉开差距的往往不是你会多少个复杂的API,而是面对一坨脏数据时能不能稳住心态,按部就班地把问题拆成"缺、重、脏、错"四类逐个击破。先把这套基本功练扎实,远比急着学那些花哨模型更值。