做数据分析的人基本都有同感:拿到手的数据,十有八九是不干净的。尤其是从Excel、业务系统或者爬虫接口里导出来的表,缺失值、异常值、重复值往往同时扎堆出现,这时候Python数据分析的三板斧就派上用场了——NumPy负责底层数值计算,Pandas负责表格级处理,而数据清洗就是其中最磨人、也最出成果的一步。
在系列的前两讲里,我们已经把NumPy数组操作和Pandas的DataFrame/Series基础过了一遍。这一讲专门讲数据清洗:缺失值怎么填、异常值怎么查、重复值怎么去,以及类型转换、文本清洗这些经常被忽略的细节。不管你是刚入门还在啃文档,还是已经在用Pandas处理日常报表,这篇文章都值得花十分钟看完。很多公司在数据分析笔试面试里,数据清洗都是必考项,原因很简单——真实业务里的数据,永远不会像教程里那么干净。
1. 为什么数据清洗值得单独开一讲
1.1 数据清洗在数据分析流程中的角色
很多人学数据分析,一上来就盯着建模、可视化,觉得那才是“高光时刻”。但实际工作中,数据清洗往往占到整个项目40%到80%的时间。不是夸张,你去问任何一个数据工程师或者数据分析师,他都会告诉你:报表延迟、结果对不上、模型效果差,八成问题出在数据质量上。
数据清洗在整条链路里的位置,正好是“数据获取”和“分析建模”之间的缓冲带。数据从数据库、Excel、CSV、API接口进来之后,不会直接变成能用的DataFrame——里面可能有空单元格、有文本里混着的数字、有改了格式的日期,还有重复录入的行。这些都要在进入分析之前处理掉,否则后面做的透视表、趋势图、机器学习模型,全是建立在沙子上。
1.2 数据问题从哪里来
数据脏不是偶然的,它有固定的来源。我梳理了几类最常见的场景:
- 业务系统导出:ERP、CRM这类系统导出的数据,经常有空值表示“未填写”,比如客户没有填性别、订单备注为空。
- 多表拼接产生:用merge或者Excel的VLOOKUP拼接数据时,匹配不上的字段会产生NaN。
- 人为录入错误:手输的表格里,手机号可能多一位、日期可能写成“2024/1/1”和“2024-01-01”两种格式共存。
- 爬虫采集:网页上有些字段缺失,爬下来就是空值,或者带了多余的空格和HTML标签。
- 系统Bug或埋点缺失:埋点统计漏掉了某些用户行为,这种缺失往往是大面积的。
这些问题的共同点是:你没法靠肉眼去逐行排查,必须用系统性的方法去发现和处理。这也是Pandas擅长的地方。
1.3 清洗的整体思路:先定位、再评估、后处理
我的习惯是固定走四步:定位、评估、处理、验证。不要一上来就调用dropna()或者fillna(),先搞清楚数据到底哪里出了问题。
- 定位:用df.info()、df.isnull().sum()、df.duplicated().sum()这些方法,把缺失、重复、类型异常都摸清楚。
- 评估:每一项数据问题影响多大?是5%的缺失还是50%的缺失?是整列都异常还是只有几行?
- 处理:针对不同情况选择删除、填充、转换或者保留。
- 验证:处理完之后,再跑一遍检查代码,确认问题被解决,同时确认没有引入新的问题。
这套流程看起来简单,但能帮你避免很多“修好了这个,又搞坏了那个”的尴尬。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缺失值处理:先看清坑在哪,再决定怎么填
2.1 缺失值的识别与统计
在Pandas里,缺失值默认用NaN(Not a Number)表示,它来自NumPy的float类型。一组数据里只要混入了NaN,这一列就会被自动推断成float,这也是新手最容易困惑的地方——为什么年龄明明是整数,打印出来却带小数点?
识别缺失值最常用的方法是isnull()和isna(),两者完全等价,看你习惯用哪个。配合sum()就能统计每一列的缺失数量:
python复制import pandas as pd
import numpy as np
df = pd.DataFrame({
"订单号": ["A001", "A002", "A003", "A004"],
"金额": [1200, np.nan, 800, 1500],
"用户ID": ["U01", "U02", np.nan, "U03"]
})
print(df.isnull().sum())
输出结果很直观:金额有1个缺失,用户ID有1个缺失。如果数据量大,这个方法比肉眼扫Excel高效太多了。
还有一个常用方法是info(),它会显示每一列的非空数量、数据类型和内存占用。数据量上百万时,还要注意用memory_usage()去看内存占用有没有超标。很多公司笔试里会问“如何高效检查大数据集的缺失值”,标准答案就是这两招。
2.2 删除还是填充:先回答三个问题
看到缺失值,不要条件反射地删除。删除意味着丢失信息,如果缺失比例很高,删完之后样本量不够,分析结果就没有统计意义了。我在处理之前会先问自己三个问题:
- 缺失比例是多少?低于5%可以考虑删除,超过20%就要慎重,通常选择填充或单独处理。
- 缺失是随机的吗?如果某个字段只在特定条件下缺失,比如“退单金额”只在退单记录里有值,那缺失本身就是一种业务信息,不能随便填。
- 这一列后续分析用得上吗?如果用不上,直接删掉整列更省事。
这三个问题问完,处理方向基本就清楚了。随机缺失且比例低,删行;非随机缺失,保留缺失标志或者填充;字段废了,删列。
删除操作用dropna(),它有几个关键参数:axis控制删行还是删列,how="any"表示只要有一个缺失就删,how="all"表示整行或整列全缺失才删。另外thresh参数很实用,它表示“至少有多少个非空值才保留”,比如thresh=3表示一行里至少有3个非空值才保留。
python复制# 删除任何包含缺失值的行
df.dropna(axis=0, how="any", inplace=True)
# 删除完全为空的列
df.dropna(axis=1, how="all", inplace=True)
# 每行至少保留3个非空值
df.dropna(thresh=3, inplace=True)
2.3 填充策略实测
当决定保留数据时,就需要填充。最直接的是fillna(),可以填固定值,也可以填统计量。比如订单金额列缺失,可以用整列的平均值或中位数填;年龄缺失,用众数填可能更合适。
python复制# 用固定值填充
df["金额"] = df["金额"].fillna(0)
# 用中位数填充
df["金额"] = df["金额"].fillna(df["金额"].median())
# 用众数填充
df["年龄"] = df["年龄"].fillna(df["年龄"].mode()[0])
这里要注意,中位数对异常值不敏感,比均值更稳健。如果数据里有极端的金额,均值会被拉高,用均值填充会让缺失值看起来失真。
还有一种面向时间序列的填充方式:前向填充ffill()和后向填充bfill()。比如股票价格、日活数据这类按时间排序的指标,缺失往往表示“这一时刻没采集到”,用最近一个有效值填充是合理的。
python复制# 前向填充:用上一个非空值填充
df["价格"] = df["价格"].ffill()
# 后向填充:用下一个非空值填充
df["价格"] = df["价格"].bfill()
如果数据是单调趋势型,还可以用interpolate()做线性插值,它会根据前后两点估算中间值。不过这个方法要慎用,只适合数值型、有序数据。
2.4 缺失值处理的几个坑
我只说三个自己踩过的坑。
第一,NaN和None不是一回事。NaN是浮点数,None是Python对象。在Pandas里两者通常都会被识别为缺失值,但在某些操作里会出问题,比如hash、groupby、写入数据库的时候。最稳妥的方式是统一用pd.isna()去判断。
第二,布尔判断里NaN的表现很反直觉。df[df["金额"] == np.nan]这样写是查不出任何数据的,因为NaN不等于任何值,包括它自己。必须用isna()来筛选。
第三,fillna(inplace=True)虽然有,但我建议少用。更推荐赋值式写法,比如df["列"] = df["列"].fillna(...),这样原数据不会被意外改掉,逻辑也更清晰。
3. 异常值处理:别急着删,先搞清它是噪声还是宝藏
3.1 异常值的两种定义
异常值(Outlier)比缺失值更难处理,因为它不能靠“是否为空”来判断。所谓异常,是指严重偏离正常范围的值。但“正常范围”本身就是个需要界定的概念。
我习惯把异常值分成两种。一种是“业务异常”,比如用户年龄是200岁、订单金额是负数,这明显违反业务规则,基本可以确定是录入错误。另一种是“统计异常”,比如大家月薪都在8000到15000,突然出现一个月薪50万的,可能是真实的高管数据,也可能是录入错误——这时候就需要结合业务背景来判断。
这两种异常的处理方式完全不同。业务异常几乎可以直接修正或删除;统计异常则要谨慎,因为它可能是有价值的信息。
3.2 三种检测手段
第一步永远是describe()。它能快速输出数值列的均值、标准差、最小值、四分位数、最大值,一眼就能看出量纲和分布是否合理。
python复制print(df.describe())
如果发现某列的最大值比75%分位数高出几十倍,那基本可以肯定存在异常值。更严谨的方法有两个:IQR法和Z-score法。
IQR法,即四分位距法,利用箱线图的原理。计算Q1(25%分位)和Q3(75%分位),IQR = Q3 - Q1,正常值范围一般在[Q1 - 1.5 * IQR, Q3 + 1.5 * IQR]之间,超出这个范围的就是异常值。这个方法不要求数据服从正态分布,适用范围广。
python复制Q1 = df["金额"].quantile(0.25)
Q3 = df["金额"].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
outliers = df[(df["金额"] < lower) | (df["金额"] > upper)]
Z-score法,即标准化得分法,将数据减去均值后除以标准差。根据经验法则,Z-score绝对值大于3的值一般被视为异常。这个方法适合数据近似正态分布的场景。
python复制from scipy import stats
z_scores = np.abs(stats.zscore(df["金额"]))
outliers = df[z_scores > 3]
如果你不想额外引入SciPy,也可以手动算:z = (df["金额"] - df["金额"].mean()) / df["金额"].std()。注意,如果数据本身已经包含异常值,均值和标准差会被污染,这时IQR法更稳健。
3.3 处理方案与业务判断
检测出来之后怎么处理,不能一刀切。我列个表供大家参考:
| 场景 | 建议处理方式 | 说明 |
|---|---|---|
| 录入错误(年龄200岁) | 修正或删除 | 如果无法确认正确值,直接删除 |
| 统计异常但业务真实(高管薪资50万) | 保留 | 删除会丢失真实信息 |
| 对模型影响很大 | 封顶/缩尾处理 | 超出上下限的值替换为边界值 |
| 异常比例很低(<1%) | 删除 | 对整体分布影响可忽略 |
封顶处理,也叫缩尾处理,是我比较推荐的一种方式。它对所有超出正常范围的值不做删除,而是统一替换为上下限边界值,这样既保留了样本量,又削弱了异常对统计量的冲击。
python复制df["金额"] = df["金额"].clip(lower=lower, upper=upper)
clip()方法在Pandas里直接可用,非常方便。但要注意,封顶处理会改变数据本身的分布,如果后面要做精细的统计推断,需要备注清楚这一步。
4. 重复值处理:一网打尽,但要小心误杀
4.1 duplicated与drop_duplicates用法
重复值处理相对简单,但坑也不少。最常见的原因是数据源里同一行记录被导出了多次,比如订单表被重复追加、接口返回了重复页面。
检测重复行用duplicated(),返回一个布尔Series,标记每一行是否是重复行。默认情况下,第一次出现的行标记为False,后续完全相同的行标记为True。要直接删除重复行,用drop_duplicates()。
python复制df.duplicated().sum() # 统计重复行数
df = df.drop_duplicates() # 删除完全重复的行
drop_duplicates()有几个参数值得记住。subset参数用于指定按哪些列判断重复;keep参数控制保留哪一行,默认是"first"保留第一次出现的行,"last"保留最后一次,也可以传False表示全部删除。
python复制# 按订单号判断重复,保留第一条
df.drop_duplicates(subset=["订单号"], keep="first", inplace=True)
4.2 按部分列去重的业务场景
我遇到过很多业务场景,不是整行重复,而是在某几个关键字段上重复。比如一个用户一天内下了多笔订单,但系统Bug导致同一订单被记录了两遍,这时候就不能按整行去重,要按“订单号+用户ID”去重。
更典型的例子是会员表:同一用户因为换绑手机号生成了两条记录,但身份证号是唯一的。这时候应该按身份证号去重,保留最新状态的那条记录。
热搜词里有一条描述得非常准确:“如果指定两列的值均相同,则取第一条数据即可。”翻译成代码就是:
python复制df.drop_duplicates(subset=["用户ID", "订单号"], keep="first", inplace=True)
这个写法在电商订单处理、广告点击日志去重中非常高频。很多人忽略的是,subset传入的是一个列表,可以传多列。多列去重的逻辑是:只有所有指定列的值都相同,才判定为重复。
4.3 重复值背后的业务陷阱
去重时最大的风险是误杀。有些场景下,多行数据看着像重复,其实是正常业务记录。
比如一个用户一个月内在同一家店下了三笔相同金额的订单,如果按“用户ID+金额”去重,就会把真实有效的三笔订单删成一笔。所以设计去重规则时,必须保证判断字段的组合能唯一标识一条记录。
另一个容易忽略的问题是时间字段。如果你的数据里有时间戳,两行数据完全相同但时间不同,建议保留,因为时间本身就有业务含义。此外,去重之前最好先弄清数据的生成逻辑,是主数据表还是流水表——主数据表天然不应该有重复,流水表则可能允许重复。
我现在的习惯是:清理之前先把重复的样本打印出来看一眼,比如df[df.duplicated(keep=False)],确认重复的形态和原因,再决定怎么处理。千万不要直接一句drop_duplicates()跑完就交差。
5. 类型转换与文本清洗:隐藏的坑
5.1 数据类型检查与转换
数据清洗如果只处理缺失值和异常值,往往会漏掉一类问题:类型错误。表面上看格式没问题,实际却无法参与运算,这是最气人的。
检查类型用df.dtypes,一列可能是int64、float64、object、datetime64等。最常见的坑是“数字被存成了object字符串”,比如金额列里有“1,200”这种带千分位的文本,或者手机号因为位数太长被Excel转成了科学计数法后导入,变成了一串乱码。
这时候用astype()强制转换,但如果文本里有逗号、空格这些杂质,直接转换会报错。正确做法是先用字符串方法清理,再用to_numeric()转换。
python复制# 去掉千分位逗号,再转数值
df["金额"] = df["金额"].str.replace(",", "").astype(float)
# 更稳健的方式:用to_numeric,遇到错误转为NaN,再统一处理
df["金额"] = pd.to_numeric(df["金额"].str.replace(",", ""), errors="coerce")
errors="coerce"很重要,它会把无法转换的字符串变成NaN,不会让程序直接崩溃。后续再用前面的填充策略处理这些新出现的缺失值。
5.2 时间日期类型处理
日期类型是另一个重灾区。同一个Excel文件里,“2024/1/1”和“2024-01-01”可能同时存在,导入Pandas后还可能变成字符串。处理方案是统一用pd.to_datetime()转换。
python复制df["日期"] = pd.to_datetime(df["日期"], format="mixed")
format="mixed"表示自动识别混合格式,这在Pandas 2.0之后支持得比较好。转换之后,就能用dt.year、dt.month等方法提取年月日,或者用日期做排序、差分、透视表,这些操作在字符串状态下根本无法高效完成。
如果日期列里有空值,to_datetime()会自动生成NaT(Not a Time),它的性质类似NaN。后续用fillna()填充日期时要格外小心,填充一个错误的日期比保留空值更可怕,我建议日期的缺失直接用删除行处理,除非你能从其他字段推断出正确日期。
5.3 文本清洗三板斧
文本字段的清洗也很常见,尤其是从网页爬来的数据,经常带空格、换行符、大小写不统一、特殊符号。我总结了三板斧:strip()去除首尾空白、lower()/upper()统一大小写、replace()和正则替换特殊字符。
python复制df["城市"] = df["城市"].str.strip()
df["姓名"] = df["姓名"].str.strip().str.replace(" ", "")
df["邮箱"] = df["邮箱"].str.lower()
再复杂一点的需求,比如从“广东省深圳市南山区”里提取“深圳”,或者从一段文本里提取所有手机号,就要用正则表达式配合str.extract(),这属于进阶玩法。但基本功还是先保证空格、大小写、编码这类问题被处理干净。
Text清洗结束后,别忘了再用df["列"].unique()看一眼去重后的取值列表,确认没有隐藏的杂值。
6. 综合实战:一份销售订单表的清洗全流程
6.1 业务背景与数据预览
光讲方法不落地,永远学不会。我拿一份模拟的销售订单数据,把前面所有知识点串起来走一遍。假设数据是从Excel导出的,文件叫sales_orders.xlsx,包含订单号、用户ID、下单日期、所在城市、商品单价、购买数量、订单金额、备注八列。
读取Excel时,如果提示缺少引擎,先执行pip install pandas openpyxl,openpyxl是Pandas读取.xlsx文件的后端依赖。
python复制import pandas as pd
import numpy as np
df = pd.read_excel("sales_orders.xlsx")
print(df.shape)
print(df.head())
这是清洗之前的第一件事:了解形状和数据长什么样。shape输出(1000, 8),说明有1000行8列。接下来做全面体检:
python复制print(df.info())
print(df.isnull().sum())
print(df.duplicated().sum())
体检结果模拟如下:订单金额有35个缺失,城市有12个缺失,下单日期有5个缺失,备注有200个缺失(这个字段本身可以为空,不处理),完全重复行有3行。
6.2 分步清洗操作
第一步,处理重复值。订单表里订单号理论上唯一,所以按订单号去重就行,保留第一条:
python复制df = df.drop_duplicates(subset=["订单号"], keep="first")
第二步,处理日期。把下单日期转成标准datetime类型,缺失的行直接删掉,因为没有可靠信息可以推断:
python复制df["下单日期"] = pd.to_datetime(df["下单日期"], errors="coerce")
df = df.dropna(subset=["下单日期"])
第三步,处理订单金额。先补充订单金额的缺失值。这里我用中位数填充,因为订单金额的分布可能偏态,受大额订单影响,均值不稳健。同时检查异常值——单价和数量的乘积应该等于订单金额,如果不一致,就是原始问题:
python复制df["订单金额"] = df["订单金额"].fillna(df["订单金额"].median())
# 用IQR检测订单金额异常
Q1 = df["订单金额"].quantile(0.25)
Q3 = df["订单金额"].quantile(0.75)
IQR = Q3 - Q1
upper = Q3 + 3 * IQR # 业务上允许更宽松的阈值
df.loc[df["订单金额"] > upper, "订单金额"] = upper
注意我这里用了3倍IQR作为上限,而不是默认的1.5倍。原因是销售数据的大额订单可能是真实的,比如B端大客户采购,定得太严容易误杀。
第四步,处理文本字段。去掉城市列的前后空格,统一商品名称的大小写,删除备注列里无意义的纯空格内容:
python复制df["城市"] = df["城市"].str.strip()
df["商品"] = df["商品"].str.strip().str.upper()
df["备注"] = df["备注"].str.strip()
第五步,处理类型问题。用户ID如果是以文本形式存储的,直接保留字符串;购买数量如果被读成了float,可以转成int;金额统一用to_numeric确保没杂质:
python复制df["购买数量"] = df["购买数量"].astype(int)
df["订单金额"] = pd.to_numeric(df["订单金额"], errors="coerce")
6.3 清洗后的验证
处理完不能直接收工,要再做一遍验证,确认所有步骤没有引入新问题:
python复制print(df.isnull().sum().sum()) # 期望为0
print(df.duplicated().sum()) # 期望为0
print(df.dtypes) # 检查类型
print(df.describe()) # 检查数值分布
验证通过之后,可以导出清洗后的数据:
python复制df.to_csv("sales_orders_clean.csv", index=False, encoding="utf-8-sig")
encoding="utf-8-sig"是为了让Excel打开CSV不乱码,这个细节很多人都忽略过。
7. 高频问题与排查技巧
7.1 环境安装与版本匹配
很多新手卡在第一步:Pandas装不上。其实核心就是几条命令的事,不用慌。
bash复制pip install pandas
pip install numpy
pip install openpyxl
如果只想装一个,pandas会自动把numpy作为依赖带上来。read_excel需要openpyxl,记得单独装。conda用户可以直接conda install pandas,效果一样。
版本兼容问题确实很烦人。比如有报错信息是runtimeerror: numpy was built with baseline optimizations,通常意味着numpy和pandas版本不匹配,或者numpy的构建版本与当前Python环境冲突,解决办法就是升级numpy到和pandas匹配的版本:
bash复制pip install --upgrade numpy pandas
另一个常见报错是importerror: numba needs numpy 2.4 or less. got numpy 2.5,这是numba和numpy版本不兼容导致的。解决办法是把numpy降级到2.4或以下,或者升级numba到支持numpy 2.5的版本:
bash复制pip install "numpy<2.5"
pip install --upgrade numba
python3.10用户注意,pandas 1.5.3及以上版本支持较好,推荐直接用最新的pandas 2.x。如果装不上,多半是pip版本太老,先更新pip再试,也就是执行python -m pip install --upgrade pip。
7.2 常见运行错误速查
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
| No module named 'pandas' | pandas未安装 | pip install pandas |
| No module named 'openpyxl' | 缺少Excel读取后端 | pip install openpyxl |
| ValueError: cannot convert float NaN to integer | 列中有NaN,无法转int | 先fillna再astype |
| TypeError: unsupported operand type(s) | 类型混杂,比如字符串参与运算 | 用to_numeric转换 |
| KeyError: '列名' | 列名不存在或大小写不匹配 | df.columns查看实际列名 |
这些错误我在带新人时几乎每天都能见到。关键不是记住报错原文,而是养成一个习惯:跑任何一步处理之前,先用info()、dtypes、isnull().sum()确认数据状态。
7.3 关于数据清洗的几点体会
最后说点个人经验。刚开始学Pandas时,我也喜欢把自己埋在一堆API里,今天学groupby,明天学pivot_table,结果真到了处理数据的时候还是手忙脚乱。后来我意识到,对大部分实际任务来说,高频操作就那十几个:读取、查看、清洗、筛选、分组、聚合、合并、导出。与其追求面面俱到,不如把最常用的操作练到肌肉记忆。
数据清洗尤其如此。很多工具函数不需要背,遇到问题再查文档就行,但处理思路一定要清晰:缺失值怎么判断、异常值用什么标准、重复值按什么字段判定、文本里有什么杂质。这些方法论是跨数据、跨行业通用的。把这一讲里的流程自己跑一遍,比看十遍文档都管用。
另外提醒一句,处理任何数据之前最好先备份原始文件。我见过太多人清洗到一半发现逻辑错了,想退回原始数据,结果原始文件已经被覆盖了。复制一份留底,成本几乎为零,却能在关键时刻救你一命。
