只要你碰过数据,就一定会撞上脏数据。缺了字段的表格、重复发货的记录、日期格式歪七扭八的Excel、明明写着“1,234”却算不了数的金额……这些都是劝退新手的日常。我用pandas做数据清洗入门已经很多年,最深的体会是:清洗不是苦力活,而是一套有规律可循的“问诊”流程。这篇文章就把最常用的几板斧拆开讲清楚。读完你会发现,所谓告别脏数据,其实就是把“缺失、重复、异常、格式”这四类问题,用正确的方法逐个击破。
如果你刚开始学Python,或者已经能写点代码但一面对真实数据就无从下手,那么这篇文章就是为你准备的。这里没有复杂的理论,只有拿来就能用的pandas操作、判断规则,以及我在实际操作中踩过的坑。
1. 数据清洗究竟在洗什么
1.1 脏数据最常见的四种面孔
我把平时遇到的脏数据分成四类,每一类都见过无数个变种。
第一类是缺失。数据表里出现空白单元格、NaN、NULL,看起来只是一个空格,背后可能代表完全不同的情况。有的是用户没填,有的是系统没取到,有的是导入时被格式吞掉了。缺失会导致统计结果偏倚,尤其是在做汇总和建模的时候,一个关键字段缺了一半,分析结果基本没法信。
第二类是重复。同一个客户在表里出现两次、同一笔订单被重复记录,或者明明是同一个人,却因为姓名里多了一个空格被当成了两条数据。重复会直接放大计数类指标,比如把成交订单数从1000虚增到1100。
第三类是异常。年龄写成300,销量写成负数,金额突然出现一个十个亿的极值。这类值不一定是输入错误,也可能代表真实的极端情况,但如果不处理,画图和建模都会被它带偏。所以清洗时要把异常值“拎出来”单独看,而不是一股脑删掉。
第四类是格式混乱。大小写不统一、中文英文标点混用、日期一会儿“2024-01-01”一会儿“2024/02/10”一会儿“20240301”,金额里带“¥”带逗号。这类问题最容易让新手崩溃,因为数据看起来是“能读的”,但程序就是无法正确处理。
这四类问题经常同时出现。我接手过一张从系统里导出的订单表,既有客户名首尾带着空格导致去重失效,又有下单日期三种格式并存,还有金额列被Excel自动加上了千分位逗号。当时第一反应是“这表没救了”,后来按分类逐项处理,发现其实都是固定套路。
1.2 清洗前的第一件事:把数据“看透”
拿到任何数据集,我都不急着动手清洗,先做三件事:安装好pandas、读取数据、快速浏览全貌。很多人一上来就写一堆清洗代码,结果连数据长什么样都没弄清楚,很容易把有效数据误删。
安装环境很简单:
pip install pandas然后读取文件。常见场景是CSV和Excel:
import pandas as pd df = pd.read_csv('原始数据.csv') # 如果编码出问题,可以试试 encoding='utf-8' 或 encoding='gbk' # df = pd.read_excel('原始数据.xlsx')读取之后,我会依次执行下面几个命令,把它们当成“体检报告”:
# 看前几行,确认字段是否读进来了 print(df.head()) # 看整体规模、列名、每列数据类型、非空数量 print(df.info()) # 看数值列的统计量:均值、分位数、最值 print(df.describe()) # 看每一列的数据类型 print(df.dtypes)这几个命令看起来基础,但非常出效果。df.info()会直接告诉你哪一列缺了多少非空值,哪一列被读成了object而不是数值类型;df.describe()能帮你快速扫一眼是否有离谱的最小值或最大值;df.head()则能直观看到日期、金额到底长什么鬼样子。
我有一个习惯:每次清洗前先把原始数据复制一份备份,用一个带时间戳的文件名存起来。因为清洗本质上是做减法,删错了还能回头找,没有备份就只能重新导数据,缘分就此结束。
2. 缺失值处理:先想清楚“空”是什么意思
2.1 定位缺失值和判断严重程度
pandas里判断缺失值用isnull()或isna(),两个完全等价。最常用的是下面这个组合:
# 统计每列缺失数量 print(df.isnull().sum()) # 统计每列缺失比例 print(df.isnull().mean())我的习惯是先看缺失比例,再决定要不要处理。缺失数量容易误导,比如一张1万行的表,某列缺失500条,看起来只有500,但如果是1千行的表,缺失率就很高了。按比例看才靠谱。
判断缺失严重程度的标准,我一般是这么掌握的:
- 缺失比例低于5%,可以通过填充或删除少量行解决。
- 缺失比例在5%到30%,需要认真想填充策略。
- 缺失比例超过30%甚至50%,这列信息基本不可靠,除非业务上非常关键,否则直接丢弃更干净。
举个例子,某渠道活动表里“参与人数”缺失了80%,我花大力气用各种方法填充,最后模型效果依然很差,因为填充出来的值本来就是噪声。后来我学乖了,刚开始就把这种列排除掉,只保留真正可用的字段。
还要注意“空”的表现形式不止NaN一种。有时候数据里写的是“未知”、“N/A”、“null”字符串,它们在pandas眼里不是空值,却应该被当成缺失处理。我通常会在清洗前把这类统一替换成pd.NA或np.nan:
df = df.replace(['未知', 'N/A', 'null', ''], pd.NA)这一步看似简单,却能避免后期一大堆莫名的统计错误。
2.2 dropna 与 fillna:删还是补
缺失值的处理无非两种:删掉,或者补上一个值。两者没有绝对好坏,关键是看业务含义。
dropna()负责删除缺失值。它有几个参数值得注意:
# 删除包含任何缺失值的行 df.dropna() # 删除整行都缺失才删 df.dropna(how='all') # 只针对指定列检查缺失 df.dropna(subset=['订单号', '金额']) # 不修改原df,需要重新赋值 df_clean = df.dropna(subset=['金额'])代码里出现dropna()之后马上打印df.head()却发现数据没变,这种问题我已经见太多了。原因很简单:pandas默认不修改原数据,必须重新赋值或者加上inplace=True。我强烈建议新手养成“清洗结果赋给新变量”的习惯,比如叫df_clean,因为原数据保留着,做错了还有退路。
fillna()负责填充缺失值,用法也灵活:
# 用固定值填充 df['金额'] = df['金额'].fillna(0) # 用数值列均值填充 df['金额'] = df['金额'].fillna(df['金额'].mean()) # 用数值列中位数填充,推荐 df['金额'] = df['金额'].fillna(df['金额'].median()) # 向上/向下填充,适合时间序列 df['价格'] = df['价格'].ffill() # 前向填充 df['价格'] = df['价格'].bfill() # 后向填充需要注意,在比较新的pandas版本里,fillna(method='ffill')这种写法已经被弃用了,直接使用ffill()和bfill()更规范,也更容易读。
具体用哪种,我认为要看缺失值本身的性质。比如用户下单金额缺失,如果这条订单本身存在,那金额大概率也是合理的,用中位数填充是一种常见的兜底方案;但如果一条评论的点赞数缺失,那它很可能就是0,直接填0才符合业务逻辑。没有一成不变的规则,只有“哪种补法更接近真实情况”的选择。
2.3 填充策略和踩过的坑
我在处理缺失值时,总结了几条特别实用的经验。
第一,均值和中位数不能随便选。如果数据分布比较对称,均值没问题;如果分布严重偏斜,比如收入字段,少数高收入会把均值拉得很高,这时用均值填充,等于给那些缺失用户塞了一个根本不属于他们的高值。我一般先看df['列名'].skew(),偏斜明显就选median()。
第二,不要让被填充的列“看起来太干净”。如果缺失比例非常高,填充完以后会显著改变数据分布。比如某个字段缺失了40%,我用均值填充,结果这个字段的标准差会大幅缩小,后续聚类或回归都会受影响。对付这种情况,我会额外加一列标记“该条记录是否缺失”,保留“这里有洞”的信息,方便建模时用。
df['金额_是否缺失'] = df['金额'].isna().astype(int) df['金额'] = df['金额'].fillna(df['金额'].median())第三,分组填充往往比全局填充更合理。举个实际例子,订单表里不同地区的客单价差异很大,全局中位数可能跟某地区的真实水平完全不同。正确做法是按地区分组,再在组内填充:
df['金额'] = df.groupby('地区')['金额'].transform(lambda x: x.fillna(x.median()))这个写法很常用,也是我清洗数据时的常规操作。
第四,时间序列里的缺失不要乱用中位数。今天的气温缺了,用明天和后天的均值来填没问题,但用上个月同一天的值来填大概率是错的。这时候ffill()和bfill()才是正道,不过要注意边界缺失:如果序列开头就缺失一截,ffill()会无能为力,还得结合业务判断。
3. 重复值和异常值:两类容易被忽视的污染源
3.1 重复值识别与去重细节
重复值不会像缺失值那样直接给你一个NaN,它藏在数据里,表面看着一模一样,统计时却悄悄翻倍。我在Excel里曾经吃过亏:两张表合并后没有去重,结果“销售总额”竟然比实际多了将近一倍。
先用duplicated()看看有多少重复:
# 统计完全重复的行数 print(df.duplicated().sum()) # 查看重复行 print(df[df.duplicated()])df.duplicated()返回一个布尔序列,True表示该行与之前的行重复。如果只想看重复的行,上面的写法很常用。
去重用drop_duplicates():
# 默认删除所有列完全重复的行,保留第一条 df_clean = df.drop_duplicates() # 依据指定列去重,比如按订单号 df_clean = df.drop_duplicates(subset=['订单号']) # 保留重复行中最后一条 df_clean = df.drop_duplicates(subset=['订单号'], keep='last') # 把完全重复的所有行都删掉 df_clean = df.drop_duplicates(keep=False)这里有个关键点:subset决定了“重复”的判定标准。订单表里同一笔订单可能对应多条商品明细,按整行去重没问题;但如果想筛选“同一个用户在同一小时下了两单”,就要指定客户和下单时间两个字段来去重。
我还遇到过一种隐蔽重复:两条数据肉眼看起来不一样,实际上主键一样,只是其中一个字段多了个空格。比如客户姓名“张三”和“张三 ”是两个字符串,但本质是同一个人。所以去重之前,最好先做一轮文本规范化,把首尾空格去掉、大小写统一,再把需要去重的字段处理好,否则drop_duplicates()很可能漏掉。
3.2 异常值识别方法
异常值比重复值更需要判断力。识别异常值主要有两条路:业务规则和统计规则。
业务规则最直接。比如年龄字段,正常范围是0到120;订单金额字段,正常范围是大于0;库存数量,不可能是负数。凡是超出合理业务边界的,都算异常。这种规则写起来简单:
df[df['年龄'] > 120] df[df['金额'] < 0]统计规则适合“没有明显边界”的数据。最经典的是IQR(四分位距)方法:把数据从小到大排列,取25%分位数Q1和75%分位数Q3,IQR=Q3-Q1,凡是小于Q1-1.5IQR或大于Q3+1.5IQR的点,都视为离群值。代码实现如下:
Q1 = df['金额'].quantile(0.25) Q3 = df['金额'].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 1.5 * IQR upper = Q3 + 1.5 * IQR outlier_mask = (df['金额'] < lower) | (df['金额'] > upper) print(df[outlier_mask])另一个常用方法是3σ原则,适用于近似正态分布的数据:
mean = df['金额'].mean() std = df['金额'].std() lower = mean - 3 * std upper = mean + 3 * std outlier_mask = (df['金额'] < lower) | (df['金额'] > upper)我个人的习惯是优先用业务规则,再辅以IQR。因为真实数据很多不是正态分布,3σ在偏态分布下会把大量正常高值误判成异常,尤其像销售额、点击量这类长尾分布,高值本来就是正常业务,不该被一刀切。
3.3 异常值处理方式
发现异常值之后,先别急着删。我会先做一件事:把异常值对应的原始记录打印出来,人工看一眼。
有的异常是明显的录入错误,比如年龄“300”,大概率是手滑,改正成本很低;有的是单位不统一,比如金额列大部分是“元”,突然出现一条“万元”口径的数据,那要做的不是删除,而是统一单位;还有的是真实且极端的事件,比如一次大型促销产生了超高金额订单,删掉就等于把重要业务信号删了。
处理异常值常用三种方式。
第一种是删除异常行。适合确认错误且数量占比很小的情况。
df_clean = df[~outlier_mask]第二种是修正异常值。比如明确知道某字段只能大于0,那负数代表数据异常,可以替换成NaN,然后再走缺失值处理流程:
df.loc[df['金额'] < 0, '金额'] = pd.NA第三种是截尾/clip,把超过上下限的数值压到边界值。这样做的好处是保留记录,同时避免极端值影响统计。
df['金额'] = df['金额'].clip(lower=lower, upper=upper)clip()很适合处理那种“值其实合法,但极值会影响模型”的场景。不过截尾之后,数据的方差会变小,用的时候要清楚这一变化。
我曾经分析某次活动的订单数据,发现几个金额特别大的订单把客单价直接拉高了3倍,最初我以为只是脏数据,差点删掉。后来一查,原来是同一个大客户批量采购。如果真删了,几百万业绩就没了。所以异常值的核心原则永远是:先弄清楚来源,再决定去留。
4. 格式规范:让字符串、数字、日期恢复秩序
4.1 文本清洗的常用套路
文本字段最典型的坑是“看着正常,一比较就出事”。比如客户姓名“张三”和“张 三”,或者“”和“,这类字符肉眼难分,却会让按客户聚合的结果一分为二。
文本清洗的第一步是去掉首尾空格:
df['客户姓名'] = df['客户姓名'].str.strip()如果还需要去掉中间多余空格,可以用正则替换:
df['客户姓名'] = df['客户姓名'].str.replace(r'\s+', ' ', regex=True)统一大小写也很常见,尤其是英文邮箱、城市名、国家代码:
df['邮箱'] = df['邮箱'].str.lower() df['城市'] = df['城市'].str.upper()把“中国”“china”“China”统一成一种写法,往往需要先做映射,再替换。比如:
city_map = { 'beijing': '北京', '北京': '北京', 'peking': '北京', } df['城市'] = df['城市'].str.strip().str.lower().map(city_map)不过map()遇到映射表之外的键会变成NaN,更好的方式是用replace():
df['城市'] = df['城市'].str.strip().replace(city_map)文本里还经常混着数字和字母,比如手机号“138 1234 5678”、身份证号里带空格。可以用正则把非数字字符去掉:
df['手机号'] = df['手机号'].str.replace(r'\D', '', regex=True)这一行是我日常用得最多的正则,简单粗暴,效果很好。如果你还不熟悉正则,先记住几个常用的就够:\d表示数字,\D表示非数字,\s表示空白,\s+表示多个空白。清洗文本时,这几个足以对付大部分情况。
4.2 数值转换与千分位/货币符号处理
数值字段经常被读成字符串,最典型的就是金额。Excel导出的数据里,“1,234.56”带着千分位逗号,前面还可能顶着“¥”或“RMB”,pandas读进来后成了object类型,没法直接求和。
遇到这种字段,我一般先做正则替换,再转数值:
df['金额'] = df['金额'].astype(str).str.replace(r'[¥,\s]', '', regex=True) df['金额'] = pd.to_numeric(df['金额'], errors='coerce')第二行里的pd.to_numeric()是转换数值的万能函数,errors='coerce'表示转换失败时赋值为NaN,而不是直接报错。这一步非常重要:真实数据总会有几条“调皮”记录,不让它报错,让异常值变成NaN,然后走缺失值处理流程,整体更可控。
如果一列里有“元”“万元”这种单位混杂的情况,直接转数值会得到一堆NaN和错误值。我习惯先分拣单位,再分别转换:
mask_w = df['金额'].astype(str).str.contains('万') df.loc[mask_w, '金额数值'] = df.loc[mask_w, '金额'].astype(str).str.replace('万', '', regex=True).astype(float) * 10000 df.loc[~mask_w, '金额数值'] = pd.to_numeric(df.loc[~mask_w, '金额'].astype(str), errors='coerce')虽然代码看起来繁琐,但遇到真实数据时非常管用。不是所有源系统都那么规范,这样的“脏乱差”才是常态。
还要提醒一句:astype('int')只适合整数列,如果数据里有小数、NaN、字符串,都会报错。在中途转换时,我更倾向于使用pd.to_numeric,因为它能更好地处理异常,而不是把整个清洗流程打断。
4.3 日期时间解析与计算
日期时间字段是格式重灾区。同一张表里,“2024-01-05”“2024/02/10”“20240301”同时出现都是正常的。pandas的pd.to_datetime()能自动识别多种常见格式:
df['下单日期'] = pd.to_datetime(df['下单日期'], errors='coerce')errors='coerce'在这里等于“识别不了的变成NaT(缺失时间)”,后续可以再单独排查。
如果日期格式非常特殊,比如“2024年1月5日”或者“Jan 5, 2024”,可以指定format参数,转换效率更高,也避免误判:
df['下单日期'] = pd.to_datetime(df['下单日期'], format='%Y-%m-%d')日期转换后,很多计算就方便了。比如算配送时长:
df['配送天数'] = (df['签收日期'] - df['下单日期']).dt.days按月提取、按周提取:
df['下单月份'] = df['下单日期'].dt.to_period('M') df['下单周数'] = df['下单日期'].dt.isocalendar().week日期字段的难题之一是Excel里的“日期序列号”,比如43510代表某一天。直接读出来是整数,必须用特定起点转换。一个常见技巧是:
df['日期'] = pd.to_datetime('1899-12-30') + pd.to_timedelta(df['Excel序列号'], unit='D')这个技巧看着冷门,但处理从Excel导入的数据时经常用到,我第一次遇到时花了好几个小时才反应过来。
5. 一个完整案例:订单表清洗实操
5.1 模拟一份脏订单表
前面讲了那么多具体方法,可能有点散。这里我构造一张典型的脏订单表,把常用清洗步骤串成一条完整的流程。你也可以直接把代码抄下来,套在自己的数据上试试。
先造数据,故意制造缺失、重复、格式混乱:
import pandas as pd import numpy as np df = pd.DataFrame({ '订单号': ['A001', 'A002', 'A003', 'A003', 'A004', 'A005', 'A006'], '客户姓名': ['张伟', ' 李娜 ', np.nan, '李娜', '王强', '赵敏', '赵敏'], '下单日期': ['2024-01-05', '2024/02/10', '20240301', '2024-01-01', '2024-02-15', '2024-03-02', np.nan], '金额': ['¥1,234.00', '¥56.00', '999', np.nan, '12,345.67', '¥8.00', '¥0.00'], '城市': ['北京', '上海', '广州', '上海', '北京', ' 深圳 ', '深圳'], '备注': ['正常', '包邮', '', '缺货', '退款', '正常', '活动'] }) print(df)输出预览后,你会看到:客户姓名有空格和缺失,日期有三种格式,金额是带着符号和千分位的字符串,城市列有空格,订单号A003重复了两次。这就是典型的“处理前”状态。
5.2 清洗步骤拆解
我按顺序处理,每一步都解释目的。
第一步,规范列名,去掉首尾空格,顺便把列名统一成小写。列名是后续代码的基础,先处理能省不少麻烦:
df.columns = df.columns.str.strip()第二步,处理客户姓名。先去掉空格,再把缺失值替换成“未知客户”:
df['客户姓名'] = df['客户姓名'].str.strip() df['客户姓名'] = df['客户姓名'].fillna('未知客户')第三步,统一日期格式。用to_datetime处理,识别不了的时间直接变成NaT:
df['下单日期'] = pd.to_datetime(df['下单日期'], errors='coerce')处理完以后,你会发现第三行的“20240301”可能被正确识别,也可能是NaT,这取决于pandas能否自动推断。为了保证这种紧凑格式能被识别,我会这样做:
df['下单日期'] = pd.to_datetime(df['下单日期'], format='mixed', errors='coerce')format='mixed'是pandas 2.0之后支持的参数,允许同一列多种日期格式共存。如果你用的版本较老,可以拆开分别转换,再combine_first()合并。
第四步,处理金额。先去掉“¥”和逗号,再转数值,转换失败的行自动变成NaN,后续走缺失值流程:
df['金额'] = df['金额'].astype(str).str.replace(r'[¥,\s]', '', regex=True) df['金额'] = pd.to_numeric(df['金额'], errors='coerce')第五步,规范城市字段,去掉空格:
df['城市'] = df['城市'].str.strip()第六步,处理备注。空白字符串没有信息,统一填“无备注”:
df['备注'] = df['备注'].replace('', '无备注') df['备注'] = df['备注'].fillna('无备注')第七步,去除重复。这里按订单号去重,重复的A003只保留第一条:
df_clean = df.drop_duplicates(subset=['订单号'], keep='first')第八步,处理剩余缺失值。订单日期缺失的那一行,如果实在无法推断,我选择删除;金额缺失的也删除,因为金额是核心分析字段,无法合理填充:
df_clean = df_clean.dropna(subset=['下单日期', '金额'])5.3 清洗效果验证
清洗完不是直接拿去用,先验证一遍结果。我一般检查三件事:缺失、重复、数据类型。
print(df_clean.info()) print('缺失值统计:') print(df_clean.isnull().sum()) print('重复值数量:', df_clean.duplicated(subset=['订单号']).sum()) print('清洗后数据形状:', df_clean.shape) print(df_clean)打印出来的效果大致是:所有列的非空数量都等于总行数,下单日期和金额变成datetime64和float64类型,订单号没有重复。这时候数据才算是“干净到可以用了”。
我每次清洗完,还会额外做一个“统计校验”:对比清洗前后总金额、记录数、唯一用户数有没有合理变化。比如去重前A003有两条,去重后少一条,那总金额也应该相应变化;如果清洗后总金额比原始金额少了几倍,就要回去检查是不是误删了有效数据。这一步看起来很笨,却是防止“清得太狠”的最有效手段。
我在实际中还有一个经验:清洗流程要尽量封装成函数或单独一个脚本,不要每次打开Jupyter Notebook重敲一遍。因为同样的脏数据每个月都会来一遍,把清洗逻辑写成clean_order_data()函数,下次直接调用,能省大量重复劳动。等你不满足于单表清洗,再往后可以研究数据透视、合并去重、分组聚合,前提始终是先把数据弄干净。数据干净了,后面的分析才值得谈。