做数据分析的人基本都听过一句话:真正耗时间的不是建模,不是可视化,而是清洗数据。在我接手的很多项目里,数据清洗往往占掉六到八成的工期。Excel 手工清洗虽然方便,但不可复现、容易点错、一遇到几十万行就直接卡死;而 Python 生态里的 pandas 正是为解决这类问题而生。这篇内容写给刚接触 Python 数据清洗的入门读者,我会用 pandas 把缺失值、重复值、格式混乱、异常值这些最常见的脏数据问题逐个拆开,每一步都给出可直接照抄的代码和判断思路。
1. 哪些数据算"脏"?先给问题画像
1.1 脏数据的五张常见面孔
很多人一听到"脏数据"就以为只是空值,其实数据清洗面对的问题远不止这一种。我习惯于先把脏数据分成五类,这样拿到任何一张表都能快速定位问题:
第一类是缺失值,表现为单元格为 None、NaN 或空字符串,常见原因是录入遗漏、字段可选、系统导出异常。第二类是重复值,同一条记录出现多次,可能是多次点击提交、系统重试或者导入时重复拼接。第三类是格式混乱,同一字段里混着"2024/1/5"、"2024年1月5日"、"20240105"这类不同写法,或者手机号里有空格、括号、横线。第四类是异常值,比如年龄填成 200、单价出现负数、销售额突然多一位数,这类值虽然存在但不合逻辑,会严重拉偏统计结果。第五类是逻辑矛盾,比如下单日期晚于发货日期,性别字段写着"男"却又记录了"怀孕状态",这类问题最隐蔽,单看一列发现不了,必须跨列比对。
你可能已经发现了,前四类问题用 pandas 配合一两行代码就能快速暴露,而第五类需要你对业务有足够的理解。所以数据清洗从来不只是技术活,它要求你清楚每条记录背后到底在表达什么。
1.2 为什么明明清洗简单,却总容易翻车
数据清洗上手确实不难,难的是"尺度"和"顺序"。
先说尺度。缺失值一多,很多人习惯直接 dropna() 全删掉,结果样本量砍半,后面的统计全变了味;反过来说,如果什么都不做,带着一堆 NaN 去做均值、建模,很多库会直接报错或者悄悄忽略,最终结果跟真实情况南辕北辙。清洗的核心目标是在"保留信息"和"去除噪音"之间找一个平衡点,而这一点必须在动手之前就想清楚。
再说顺序。数据清洗是有先后逻辑的:通常先做结构层面的处理(列名标准化、类型转换),再做行级处理(去重、缺失、异常)。如果一上来就删缺失值,后面做类型转换时可能因为格式问题再次报错;如果先做类型转换,再发现数据有重复需要删除,又得重新处理一遍。我在实战里的固定顺序是:备份原始数据、调整列名和类型、去重、缺失值处理、格式统一、异常值识别。这篇文章后面的案例会按这个顺序演示。
2. 环境准备:先搞清楚你要操作的对象
2.1 安装与导入 pandas
pandas 是 Python 的第三方库,安装方式很简单:
pip install pandas如果你的环境里同时用着多个 Python 版本,建议用虚拟环境管理依赖,避免把基础环境搞乱。导入时有一个约定俗成的别名:
import pandas as pd从此整个项目里都用 pd 来调用它的功能。这一步几乎是你未来每一次数据分析的固定开场。
2.2 Series 和 DataFrame:一张表、一列、一个单元格
很多入门教程会把 Series 和 DataFrame 讲得很抽象,我的理解方式比较简单:DataFrame 就是一张表,Series 就是这张表里的某一列。
比如 df["城市"] 取出来的是一个 Series,它由索引(index)和值(value)组成;df[["城市", "金额"]] 取出来的是一个 DataFrame,因为它包含多列。索引有点像 Excel 里的行号,但它可以不是数字,可以是日期、字符串,甚至多层结构。
知道这个概念为什么重要?因为 pandas 的很多操作会告诉你"返回一个 Series 还是 DataFrame",两者能不能直接赋值、能不能作为新列加入原表,结果都不一样。比如我用 df["金额"] = df["数量"] * df["单价"],左侧是 Series,右侧也是 Series,对齐索引后就能生成新列;如果某天你发现赋完值整列都是 NaN,多半是两侧索引对不上,而不是计算错了。
2.3 拿到数据后先别急着洗,先摸清底细
拿到任何一份新数据,我建议先做三件事,而不是马上去写清洗代码:
df = pd.read_csv("订单数据.csv") # 1. 看整体结构和缺失情况 df.info() # 2. 看前几行,直观感受字段内容 df.head() # 3. 看数值型列的大致分布 df.describe()df.info() 会列出每列的名称、非空数量、数据类型,任何一行的非空数量小于总行数,就说明这一列存在缺失。df.describe() 则输出均值、标准差、最小值、分位数、最大值,一眼就能看出金额这种数值列是不是有离谱的极端值。
这一步花不了半分钟,但对后面所有决策都有帮助。比如我发现某个字段类型显示 object,但里面全是日期,那就要做类型转换;发现某一列的非空数量明显少于其他列,就要重点排查缺失原因。
3. 缺失值处理:先判断漏了什么,再决定怎么补
3.1 用 isna() 快速定位缺失区域
定位缺失值最基础的是 isna(),配合 sum() 可以直接统计每列缺失数量:
df.isna().sum()如果还想看缺失比例,除以总行数即可:
df.isna().mean().round(4) # 缺失占比,结果保留四位小数有时候我们还想知道缺失集中在哪些行、和哪些列相关,可以使用如下方式抽样查看:
df[df.isna().any(axis=1)].head()any(axis=1) 表示横向检查每一行,只要这一行中任意一个字段缺失,就把它筛出来。这个操作比单纯统计每列缺失数量更能帮助你理解缺失结构的分布——例如,缺失总是集中在某几天、某个店铺或者某种订单类型。
3.2 三种处理策略:删除、填充、插值
处理缺失值没有银弹,只有三种常用策略:删除、填充、插值。
删除适用于缺失比例很低,或者缺失值集中在某个对分析无关紧要的字段。直接用:
df_clean = df.dropna(subset=["订单号"])subset 指定只根据"订单号"这一列来判断是否删行,比整表 dropna() 安全得多。整表 dropna() 要求一行中所有字段都非空,否则就删除,对数据量偏小的数据集来说太奢侈了。
填充是给缺失单元格一个替代值,最常见的填充值是常量、均值、中位数或众数:
df["好评率"] = df["好评率"].fillna(df["好评率"].mean()) # 数值用均值 df["店铺等级"] = df["店铺等级"].fillna("未知") # 文本用占位值 df["发货日期"] = df["发货日期"].fillna(method="ffill") # 时间序列用前向填充前向填充(method="ffill")在时间序列里非常实用——如果某天没记录发货日期,就沿用上一条的发货日期,这比强行填均值更符合业务逻辑。
插值介于填充和计算之间,适合数值型且有顺序的数据。interpolate() 默认使用线性插值,相当于在缺失位置补一个前后值之间的中间点:
df["温度"] = df["温度"].interpolate()注意,插值不等于真实值,如果你只是做探索性分析还可以接受,但要拿去做精确报表或建模,就得非常谨慎地评估它可能引入的偏差。
3.3 我选择处理策略的标准
拿真实数据练习过几次之后,我给自己定了一个简单标准:先问三句话——这个字段是否必要?缺失比例有多高?缺失是否随机?
必要字段(订单号、用户ID)不允许缺失,真有缺失就考虑删除该行;非必要字段但有业务含义,可以填充;缺失比例超过 70% 的字段,除非分析必需,否则我倾向于直接弃用这一列。
另外,缺失是否为随机也很关键。如果发现缺失值总是出现在特定条件下(比如某个渠道的注册用户普遍缺手机号),那说明不是随机缺失,填充均值意义不大,更合理的做法是把"是否缺失"本身变成一个特征,或者单独分析缺失组的特征。这种思考一般的数据清洗教程不会讲,但实战里特别有用。
4. 重复数据清理:别让同一条记录算两遍
4.1 duplicated 和 drop_duplicates 的基础用法
重复数据最典型的危害,是对账时虚增金额、统计用户数时重复计数。先找出哪些行是重复的:
df.duplicated().sum()duplicated() 返回一个布尔型 Series,对每一行判断其是否与之前某行完全重复。注意它的默认逻辑是"后面的行和前面的行比较",所以哪怕完全相同的两行,第一行并不会被打上 True,第二行才会。
删除重复行:
df_clean = df.drop_duplicates()默认行为是保留第一次出现的行,把后续重复行删掉。这个操作等价于 Excel 里的"删除重复项",但 pandas 能提供更多控制。
4.2 重复的判断标准要自己定义
真正需要警惕的是,很多时候"重复"并不是所有字段完全相同。比如用户上传了两条订单,中间只有备注字段不同,其他全一样;又比如两个系统中记录同一个用户,一条留了手机号,另一条没留。这时候就不能用默认的全字段去重,必须指定关键字段:
df_clean = df.drop_duplicates(subset=["用户ID", "订单号"], keep="first")keep 参数有三个常见选择:
- keep="first":保留第一次出现的记录;
- keep="last":保留最后一次出现的记录;
- keep=False:所有重复记录全部删除,一条都不留。
我曾处理过一份商品快照数据,同一商品在多个时间点被采集,业务上只需要最新状态,于是用 keep="last" 按商品ID去重,保留了最后一次快照,效果非常干净。
4.3 重复值不一定要删,有时要聚合并保留版本
有一种情况值得多讲两句:如果你发现重复记录其实代表的是"同一主键下的多次状态变更",那直接去重会丢信息。更合适的做法是保留所有记录,但在分析时按主键聚合。
例如同一订单有多条物流状态变化记录,你想统计订单数,就按订单号去重;想统计物流节点耗时,就保留全部记录并按时间排序。这听起来很简单,但我见过不少新人一看到重复就条件反射去删,完全不考虑业务语义,等做到后面发现信息不够了才回来补救,白白浪费半天时间。
所以我每次去重前都会先想一个问题:这份数据是"一次记录代表一个事实",还是"多次记录代表一个过程"。前者该去重,后者该保留。
5. 格式清洗:把乱七八糟的字段变成统一格式
5.1 字符串清洗三件套
格式脏乱最典型的表现是同一列里混着肉眼几乎看不出来的差异。最常见的三个问题是:字符串前后有空格、大小写不一致、同一种事物有多个写法。我常用的方法是 strip()、replace() 和正则表达式提取。
先去掉字符串首尾空格:
df["收货地址"] = df["收货地址"].str.strip()统一大小写,例如把城市名统一成小写再比较:
df["城市"] = df["城市"].str.lower()替换特定字符串:
df["手机号"] = df["手机号"].str.replace("-", "", regex=False) df["手机号"] = df["手机号"].str.replace(" ", "", regex=False)这里我特意加了 regex=False,目的是告诉 pandas 把 - 当作普通字符而不是正则表达式来处理,运行更快,语义也更清晰。
如果数据里混杂了"北京朝阳区"、"北京市朝阳区",而且你只想保留"北京",就需要正则表达式:
df["城市"] = df["地址明细"].str.extract(r"([\u4e00-\u9fa5]+?省|[\u4e00-\u9fa5]+?市|[\u4e00-\u9fa5]+?自治区)")正则表达式是格式清洗里最强大的工具,但也最容易写错。我的经验是先在小样本上用 str.extract 反复测试,确定匹配正确后再整列执行,绝不上来就对几十万行数据跑正则。
5.2 日期时间统一:to_datetime 与 errors 参数
日期格式混乱是很多数据分析项目的噩梦。pandas 提供的 pd.to_datetime() 能自动识别多种常见格式:
df["下单日期"] = pd.to_datetime(df["下单日期"])它能自动解析"2024-01-05"、"2024/1/5"、"20240105"、"2024年1月5日"等多种写法。但真实数据总有意外,比如某个单元格写着"2024-13-05",解析会直接报错,导致整列转换失败。
此时可以用 errors="coerce":
df["下单日期"] = pd.to_datetime(df["下单日期"], errors="coerce")coerce 会把无法解析的内容转换为 NaT(缺失时间),这样至少能保证转换不中断,再结合 isna() 定位出异常值,决定是手动修正还是删除。这个参数是我处理时间字段时的救命稻草。
5.3 类型转换与 astype 的坑
类型转换最常见的方式是 astype(),但它有几个必须注意的坑。
第一,如果某列含有缺失值,直接 .astype(int) 会报错,因为 NaN 无法转换成整数。可以先填一个哨兵值再转换,或者用 pandas 新增的可空类型 Int64(注意是大写):
df["数量"] = df["数量"].astype("Int64")第二,含有百分号或千分位逗号的字符串,直接 astype(float) 同样报错。必须先清理符号:
df["增长率"] = df["增长率"].str.replace("%", "", regex=False).astype(float) / 100 df["金额"] = df["金额"].str.replace(",", "", regex=False).astype(float)第三,转换后一定要校验类型是否如预期,用 df.dtypes 而不是凭感觉。类型转换一旦出错,后面的分组、求和、绘图都会连锁出错。
6. 异常值识别:数据清洗里的"守门员"
6.1 用 describe() 和箱线图快速发现极端值
异常值的问题在于它不是一定错,可能是一笔真实的大额订单,也可能是录入时多按了一个键。第一步要做的是"发现"。
df.describe() 里 max 和 min 是最容易暴露异常的位置。如果收入的 min 是负数,年龄的 max 是 200,单价突然比 75% 分位数高出一百倍,那基本可以断定存在异常值。
更直观的是画箱线图:
import matplotlib.pyplot as plt df["金额"].plot(kind="box") plt.show()箱线图能直接显示四分位数区间和上下边缘,超出边缘的点会单独画出来。我通常先用箱线图把所有数值列都快速扫一遍,再针对可疑列做定量判断。
6.2 IQR 法识别数值型异常值
IQR 法是最常用、也最容易被解释的异常值识别方法,它的原理很简单:先找第一四分位数(Q1)和第三四分位数(Q3),两者相减得到四分位距 IQR,然后把低于 Q1 - 1.5 * IQR 或高于 Q3 + 1.5 * IQR 的点视为异常值。
用 pandas 实现如下:
Q1 = df["金额"].quantile(0.25) Q3 = df["金额"].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR df_outlier = df[(df["金额"] < lower_bound) | (df["金额"] > upper_bound)]df_outlier 就是识别出的异常值子集。计算出上下界后,下一步不是直接删,而是查看这些记录的具体内容,判断是录入错误还是真实极端情况。比如电商大促期间单笔金额是平日几十倍,这种极端值应该保留而不是删除;而年龄 200 岁这种逻辑上不可能的值,则必须修正或剔除。
6.3 异常值不等于错误值,分情况处理
处理异常值我给自己定了一个原则:先看业务背景,再谈是否删除。
错误值:年龄 200、负数价格、明显多一位数的订单金额,这类直接修改或删除。
极端值:收入分布右偏时,高收入人群会天然拉高均值,这类不应该删,但分析时可以考虑取对数或使用中位数描述集中趋势。
批次差异:某些来源的数据整体偏高,另一些整体偏低,混合在一起会被误判为异常。此时需要先按来源分组,再在每组内判断异常值,否则会把一组里的正常高值全部误杀。
异常值的处理没有标准答案,但下手前多看一眼记录内容,通常能避免 80% 的误删。
7. 综合实战:一份订单数据的清洗全流程
7.1 先列问题清单再动手
为了演示完整流程,我构造了一份模拟订单数据,包含订单号、用户ID、下单日期、商品名称、数量、单价、金额、收货城市、订单状态这几列。拿到手之后我的问题清单如下:
- 订单号列有空值,且有重复;
- 下单日期混有"2024-01-05"、"2024/1/5"、"20240105"三种格式;
- 商品名称首尾有空格,大小写不统一;
- 单价列中有 "12.5元" 这样的文本;
- 金额列里出现了负数,可能是退款订单混在一起,也可能真是错误值;
- 收货城市列存在将"北京市""北京"两种写法混用的情况。
这些问题的发现路径很简单:df.info() 看缺失和类型,df.head() 看格式差异,df.describe() 看数值范围,再用 df["字段名"].value_counts().head(20) 看分类字段的内容分布。
7.2 清洗流水线:按顺序执行每个步骤
完整代码我整理成了一条流水线,按顺序执行,你拿到自己的数据后替换列名即可。
import pandas as pd df = pd.read_csv("订单数据.csv") df_backup = df.copy() # 先备份,避免误操作后无法回溯 # 1. 列名标准化:去掉首尾空格并统一小写 df.columns = [col.strip().lower() for col in df.columns] # 2. 删除订单号完全缺失的行 df = df.dropna(subset=["订单号"]) # 3. 订单号去重 df = df.drop_duplicates(subset=["订单号"], keep="first") # 4. 日期统一 df["下单日期"] = pd.to_datetime(df["下单日期"], errors="coerce") # 5. 字符串清理 df["商品名称"] = df["商品名称"].str.strip() df["收货城市"] = df["收货城市"].str.replace("市", "", regex=False) # 6. 单价清理并转数值 df["单价"] = df["单价"].str.replace("元", "", regex=False).astype(float) # 7. 异常值检查:只看金额小于0的记录 negative_orders = df[df["金额"] < 0] print(negative_orders.head())步骤 3 里我用 keep="first" 保留第一次出现的订单号;步骤 5 里把"北京市"替换成"北京",后续统计城市分布就统一了;步骤 6 里去掉了单位"元"再转浮点数,这样单价才能参与计算。
7.3 清洗前后对比,确认效果而非盲目信任
清洗结束后一定要做对比,确认每一步都产生了预期效果。我常用的对比指标包括总记录数、缺失比例、重复数、日期范围、金额均值等:
print("清洗前总行数:", len(df_backup)) print("清洗后总行数:", len(df)) print("清洗前缺失数量:", df_backup["订单号"].isna().sum()) print("清洗后缺失数量:", df["订单号"].isna().sum()) print("清洗前重复订单数:", df_backup["订单号"].duplicated().sum()) print("清洗后重复订单数:", df["订单号"].duplicated().sum())这个环节容易被忽略,但它其实是数据清洗里最重要的一步:清洗脚本写得好不好,不看代码写得多漂亮,而看前后指标有没有如预期变化。如果删除重复后行数没变化,很可能是判断重复的 key 选错了;如果日期字段清洗后缺失值反而暴增,说明很多日期格式没被解析出来,要先回去修正解析规则。
8. 我踩过的坑和自用习惯
8.1 永远先备份原始数据
我给所有项目的第一个要求都是:在清洗第一步立即执行 df_backup = df.copy(),之后所有清洗操作都基于副本进行,原始数据始终只读。
为什么要这样?因为清洗是一个不可逆过程。你删除了某几行,后面发现其实不该删,如果原始数据还在,随时可以重新读入再清洗一次;如果直接在原表上操作,等发现问题时可能已经覆盖了原始结果,只能重新导出。看似多占了一点内存,实际是在为自己买保险。
8.2 显式使用 copy(),别做链式赋值的受害者
pandas 有一个著名的陷阱叫链式赋值,典型写法是这样的:
df[df["金额"] < 0]["状态"] = "异常"这行代码有时能运行,有时不仅运行不了,还会抛出警告,更糟的是它可能只改了副本而没改动原始 DataFrame。我建议所有类似的列修改都改成 .loc 方式:
df.loc[df["金额"] < 0, "状态"] = "异常".loc 基于索引定位,既明确又可靠。如果确实需要先筛选子集再改数据,也要显式对子集做 copy(),例如 df_sub = df[df["金额"] < 0].copy(),然后对 df_sub 操作,最后再合并回去。
8.3 apply 不是万能的:优先使用向量化操作
刚入门 pandas 时,我遇到复杂逻辑第一反应就是 apply,后来才发现很多场景可以用向量化写法简化。比如根据金额区间打标签,新手可能写成:
def label_amount(x): if x > 1000: return "高" elif x > 100: return "中" else: return "低" df["金额等级"] = df["金额"].apply(label_amount)更简洁的是用 np.select 或 pandas.cut:
import numpy as np conditions = [df["金额"] > 1000, df["金额"] > 100] choices = ["高", "中"] df["金额等级"] = np.select(conditions, choices, default="低")apply 的优势是灵活、容易理解,但性能远不如 pandas 内置的向量化函数。数据量小时无所谓,等到几百万行时差距会非常明显。我的习惯是:能不用 apply 就不用,先用 pandas 内置方法解决,解决不了再上 apply。
8.4 列名里的隐藏空格是排查半天找不到原因的元凶之一
有一个坑我至少踩过三次:读入的 CSV 里列名自带看不见的空格,比如"订单号 ",然后 df["订单号"] 一运行就报 KeyError,检查数据列名看起来又完全正常。
解决办法是在读取时就做好列名处理:
df = pd.read_csv("订单数据.csv") df.columns = [col.strip() for col in df.columns]如果你怀疑列名有隐藏字符但看不清,可以用 repr() 打印出来:
print([repr(col) for col in df.columns])它会把空格、换行等特殊字符显示出来,问题当场暴露。这类问题看起来小,却可能耗掉你半个小时的排查时间。
8.5 清洗脚本要写成可复跑的样子
最后一个习惯,也是我认为最重要的一点:不要在一个临时脚本里反复手动操作,而是把所有清洗步骤写成可重复执行的函数或脚本,并用清晰的分步注释标明每一步的意图。
我常用的组织形式是:
def clean_orders(df): df = df.copy() df.columns = [col.strip().lower() for col in df.columns] df = df.dropna(subset=["订单号"]) df = df.drop_duplicates(subset=["订单号"], keep="first") df["下单日期"] = pd.to_datetime(df["下单日期"], errors="coerce") df["单价"] = df["单价"].str.replace("元", "", regex=False).astype(float) return df df_clean = clean_orders(df)这样做的好处是:数据更新后只要重新调用一次,全部清洗逻辑自动执行,不用重新点了半天 Excel;团队协作时,同事也能通过代码审查你的清洗逻辑,而不是靠口口相传"你记得把那几个坏行删掉"。
我在实际项目中养成的一个习惯,是每次清洗后顺手把关键指标打印出来,并写入一份简单的清洗日志,标注日期、删除了多少行、填充了多少缺失值、修正了什么格式。这样做短期看有点繁琐,长期却很省心——一个月后回看数据,你还能清晰知道这份数据经历了什么处理,而不是对着结果一脸茫然。
数据清洗是数据分析里最不性感、却最有性价比的环节。pandas 把大多数脏数据问题压缩成了一行或几行代码,但真正让清洗可靠的,是你对业务的理解、对每一步操作的验证,以及随时能回退的保险意识。希望这份入门指南能帮你少走一点弯路,早点从"洗数据"的泥潭里爬出来,把精力放到真正有价值的数据分析上。