做数据分析这一行,最耗时间的往往不是建模,也不是画图,而是数据清洗。我接过不少项目,真正花在写分析代码之前的时间,经常占到整个项目的一半以上。原因很简单,真实世界里的数据几乎都是“脏”的:字段缺漏、格式错乱、重复记录、离谱异常、文本里夹着乱七八糟的空格和符号。如果拿着这些数据直接跑分析,轻则结果偏差,重则整个结论报废。而 pandas 就是这一阶段最趁手的工具,没有之一。这篇内容我打算把日常做清洗与处理时最常用的一套方法完整拆开讲一遍,从整体思路到具体操作,再到踩坑记录,全部覆盖。适合刚入门想系统梳理 pandas 清洗能力的朋友,也适合做过一些分析但总感觉流程不够规范、想要把清洗步骤沉淀成方法论的同学。我会尽量用真实项目里的场景来说明,而不是罗列 API 文档。
1. 数据清洗的整体思路:拿到脏数据先看什么
1.1 从数据概况开始:shape、info、describe
很多人拿到数据的第一步就是直接df.head(),这没什么问题,但 head 只能让你看到前几行的样子,对整体数据质量完全没有概念。我习惯性的第一动作是执行三个方法,按顺序来:
df.shape df.info() df.describe()shape告诉你这个表有多少行多少列,先有一个体量概念。比如 10 万行数据里有 3 万行缺失,和处理 1000 行里 30 行缺失是完全不同的策略。info()的信息量更大,它会列出每一列的名称、非空个数、数据类型。我一般会在这一步发现两个问题:第一,哪些列的非空数量明显少于总行数,缺失率一眼就能看出来;第二,某些列的类型明显不对,比如“年龄”列显示为 object 而不是 int64,说明里面有文本混进去了,或者有缺失值被读成了字符串“NaN”。
describe()则是针对数值型列输出均值、标准差、最小值、四分位数和最大值。这里最重要的判断点是 min 和 max。比如某个“客单价”列的最小值是 -1,明显不合理;某个“年龄”列的最大值是 999,很可能是默认值或异常录入。看到这些,你就知道接下来清洗的重点该放在哪里了。
1.2 脏数据的常见类型与清洗优先级
我习惯把脏数据归成四类:缺失值、重复值、异常值、格式问题。缺失值和异常值通常对分析结果影响最大,重复值会导致统计口径膨胀,格式问题则会让分组、连接这类操作直接报错或产生错误匹配。
清洗优先级上,我建议先解决“能不能跑通”的问题,再解决“结果准不准”的问题。所以一般来说顺序是:先处理列名和数据类型,让数据结构稳定下来;再去重、处理缺失值;最后做异常值识别和文本清洗。如果一开始就花大量时间纠结异常值,结果发现列名里有个隐藏空格导致 merge 失败,那就很浪费时间了。
还有一点要养成习惯:清洗之前先备份原始数据。我通常是df_raw = df.copy()存一份,然后所有清洗操作都在新对象上做。pandas 里很多方法默认返回新对象,但某些操作会有 inplace 参数,一旦用不好就会把原数据改掉。备份是对自己最基本的保护。
2. 缺失值处理:别急着删,先判断机制
2.1 缺失值的探测与统计
缺失值在 pandas 里统一用NaN表示,对于 object 类型还可能读成None。探测缺失值的标准方法是:
df.isna().sum() df.isna().mean() # 缺失占比isna()会把整个 DataFrame 变成一个布尔矩阵,sum()按列统计缺失数,mean()得到缺失比例。只要缺失比例超过 30% 的列,就要认真思考这列还有没有保留的必要。但这里有一个细节:不同列的缺失含义可能完全不同。
比如“客户投诉内容”这一列,如果客户没有投诉,缺失是很正常的,不代表数据有问题;但“订单金额”这一列如果缺失,那就是重大瑕疵,一定要补或删。所以处理缺失值之前,必须结合业务含义去判断“缺失”本身是不是一种有效状态。如果缺失代表“未发生”,那通常不需要填充,甚至可以把它变成一个分类值“无”;如果缺失代表“记录丢失”,那才需要考虑删除或插值。
2.2 删除、填充、插值的适用场景
处理缺失值的策略可以分成三大类:删除、填充、插值。每类都有自己的适用场景。
删除:当缺失值占比较低(通常 5% 以下),且缺失行在整个数据集中是随机分布时,直接dropna()是最省事的选择。但如果缺失行集中在某一特征子集上,比如某个渠道来的数据全部没有某个字段,那删除会导致严重的选择偏差,千万不能一刀切。
填充:用统计值填充是常见做法。数值型列可以用均值、中位数、众数填充;分类型列可以用众数填充,也可以单独填一个“未知”类别。需要注意的是,均值对异常值敏感,如果列里有极端值,用均值填充会扭曲分布,这时候中位数更稳。我自己在填充之前都会先看一眼分布,再决定用哪个统计量。
插值:适用于时间序列或有序数据。比如某天传感器数据没采集到,用前后两天的平均值去插值,往往比全局均值更合理。pandas 提供interpolate()方法,默认是线性插值,也可以按时间索引做更复杂的插值。它处理的是“中间漏掉”的点,而不是连续一大片缺失。
2.3 实际案例:用 fillna 和 interpolate 处理时间序列
我模拟一个场景:某门店每天记录客流量,目标分析一周内的客流趋势,但中间恰好有三天数据缺失。如果直接用均值填那三天,会抹平趋势;如果直接 drop 那三天,时间轴就断了。更好的办法是插值。
import pandas as pd df = pd.DataFrame({ 'date': pd.date_range('2024-01-01', periods=7, freq='D'), 'visitors': [120, 135, None, None, None, 150, 160] }) df['visitors'] = df['visitors'].interpolate(method='linear')线性插值的结果是 141.67、146.67、148.33 这样平滑过渡的值,比用整列均值 141.25 更符合“客流渐进变化”的直觉。如果数据是每天几乎固定值的类型,用method='pad'做前向填充会更合适;如果数据是明显周期性波动,那就要考虑method='time'配合时间索引做插值了。
这里还要提一个容易忽略的点:interpolate()默认只在有前后值的位置插值,如果序列开头或结尾就是 NaN,它补不了。这种情况我会先ffill()或bfill()处理边界,再做内部插值。实际操作时,把缺失机制搞清楚比选哪个方法更重要。
3. 重复值与异常值:去重不是简单 drop_duplicates
3.1 重复行的判断与精准去重
drop_duplicates()是最常用的去重方法,但很多人没注意到它可以接收subset参数。默认情况下它会根据所有列判断是否完全相同,这在真实数据里很少派上用场。更常见的场景是:同一笔订单被导出了多次,但导出的时间戳不同,导致整行并不完全一样。这时候你需要根据业务主键去重,比如订单号、用户 ID + 日期。
df.drop_duplicates(subset=['order_id'], keep='first')keep参数控制保留哪一条:'first'保留第一次出现的记录,'last'保留最后一次出现,False则直接删除所有重复行。选择哪个取决于业务逻辑。比如同一订单被修改过多次,我可能想保留最后一次更新的版本,那就要keep='last';如果是同一用户重复注册产生的多条记录,我想保留最早注册的那条,就keep='first'。
另外,判断重复时还有一个隐蔽的坑:字符串字段里带空格会导致“看起来一样实际上不一样”。比如“张三”和“张 三”、“订单A”和“订单A ”,drop_duplicates会认为它们是不同的。所以去重前先做列清洗,把空格统一去掉,否则白干。
3.2 异常值检测的几种实用方法:IQR、Z-score、业务规则
异常值不是绝对错误,而是“与绝大多数数据显著不同”的值。处理它们之前要先区分两种来源:一种是真实发生的极端事件,比如大促期间的销售额暴增,这种必须保留;另一种是录入错误、设备故障、单位写错等,这种需要纠正或剔除。所以我不建议直接删异常值,而是先标记出来,结合业务确认。
常用的检测方法有三种:
IQR 方法:基于四分位距,把小于Q1 - 1.5 * IQR或大于Q3 + 1.5 * IQR的值视为异常。它不需要数据服从正态分布,比较不容易被极端值本身带偏,是我最常用的方法。
Q1 = df['revenue'].quantile(0.25) Q3 = df['revenue'].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 1.5 * IQR upper = Q3 + 1.5 * IQR df['is_outlier'] = (df['revenue'] < lower) | (df['revenue'] > upper)Z-score 方法:计算每个值与均值的标准差倍数,通常绝对值大于 3 就认为是异常。这个方法适合数据近似正态分布的情况,但如果数据本身偏态严重,Z-score 会把大量正常值误标为异常。
业务规则:这是最可靠也最容易被忽略的方法。比如年龄必须在 0~120 之间、温度必须在 -40 到 60 之间、订单金额必须大于 0。用业务规则圈定合法范围,超出范围直接列为异常,比任何统计方法都直观。我在实际项目里通常是先列业务规则,再辅以 IQR 或 Z-score 做二次排查。
对于检测出来的异常,如果确认是录入错误,可以考虑用中位数或前后有效值修正;如果确认是真实极端值,就保留,但可以在后续建模时单独处理。我之前处理过一份销售数据,“单笔订单金额”最大的一条是十万元,而其他数据大多在几百块,结果一查是某客户批发了大量商品,真实事件,直接剔除反而会把真实的业务峰值弄丢。
4. 数据类型转换与文本清洗:格式是清洗的重灾区
4.1 类型不匹配的坑:字符串数字、时间对象、分类数据
数据类型问题里最常见的三种:数字被存成字符串、日期被存成字符串、分类被存成数值。前者会让排序和计算乱套,中者会让时间筛选失效,后者会误导统计口径。
数字变成字符串,通常是因为原始导入时列里有带单位的内容,比如“100元”“80kg”。这时候需要先提取数字部分再astype(float)。
df['price'] = df['price'].str.replace('元', '').astype(float)日期列的处理要更小心。pd.to_datetime()有errors参数,默认'raise',遇到无法解析的值会直接报错;可以设成'coerce',让无法解析的变成NaT,便于后续统一处理。
df['order_time'] = pd.to_datetime(df['order_time'], errors='coerce')分类列的典型问题是类别被编码成 0/1/2 但不一定有顺序含义。一个简单的办法是用astype('category')显式声明类别,并在去重或 groupby 时留意observed参数。如果想把数字编码还原成文本,可以建一个映射字典replace。
还有一种隐蔽的坑:Excel 导出的数据里,数字可能是“文本型数字”,直接和数值列做运算会报错。解决办法是先pd.to_numeric(df['col'], errors='coerce'),转不过来的自动变 NaN,再做缺失处理。
4.2 文本列的规范化:strip、replace、正则提取
文本清洗的核心是让同一类内容表达一致。比如“手机号”列里有的带空格,有的带“-”,有的前缀是 86。如果直接去重或者 join,会把这些当成不同值,导致统计失真。我一般按以下顺序做文本规范化:
- 去除首尾空白:
df['col'].str.strip() - 统一大小写:对于英文内容用
.str.lower()或.str.upper() - 去除或替换中间多余符号:
.str.replace(),比如把中文逗号换成英文逗号 - 用正则提取关键信息:
.str.extract()或.str.findall()
正则不是必须背得很熟,但几个常用模式建议记一下:\d+提取数字、\w+提取字母数字、[\u4e00-\u9fa5]+提取中文、匹配手机号可以用1[3-9]\d{9}。我在处理用户地址列时经常需要把“省市区”拆出来,正则 +extract就非常高效。
df['province'] = df['address'].str.extract(r'([\u4e00-\u9fa5]+?(?:省|自治区))')如果文本里有多余的 HTML 标签、网页符号,也有成熟的清洗流程:先用正则去掉<.*?>,再替换 等常见转义符。但不管怎么做,清洗完都要抽样检查,不要盲目相信正则一次到位。
5. 重命名、索引重构与列操作:清洗的“基建工程”
5.1 列名清洗与统一规范
列名看起来是小问题,但不统一会让后续代码写得很痛苦。比如同一个文件里有的列叫“客户名称”,有的叫“客户”,有的叫“客户名”,如果是从多个数据源拼接起来的,groupby 的时候根本分不到一起。我的做法是第一步就把列名统一成小写英文字母加下划线风格,或者统一成中文业务名词,二选一。
df.columns = df.columns.str.strip() df.columns = df.columns.str.replace(' ', '_') df.columns = df.columns.str.lower()如果列名带特殊字符,比如“客单价(元)”,可以考虑用df.rename(columns={'客单价(元)': 'price'})直接改掉。统一列名不仅是为了美观,更重要的是让后面的代码可以按固定名称批量操作,而不是到处硬编码不确定的列名。
5.2 索引重置与排序
清洗过程中经常要筛选行、删除行、合并表,DataFrame 的索引会变得很乱。比如删掉几行之后,索引会留下空洞,groupby后索引又变成分组键。此时用reset_index(drop=True)可以重新生成 0 到 n-1 的整齐索引,drop=True表示不把原索引保留为列。
df = df.dropna().reset_index(drop=True)排序也是处理数据时常用的一环。sort_values()可以按一列或多列排序,注意ascending列表要和列名一一对应。另外,如果排序列是字符串数字,必须先转类型再排序,否则 “10” 会排在 “2” 前面,这是一个非常隐蔽的坑。
5.3 列的选择、拆分与合并:pd.concat、pd.merge 的实际用法
清洗过程中经常需要从一个大表里挑出需要的列,可以用df[['col1', 'col2']],或者用df.filter(regex='order')做模糊筛选。拆分列是另一个高频操作,比如“省份+城市”存在一个字段里,可以用.str.split()配合expand=True拆成两列。
df[['province', 'city']] = df['region'].str.split('-', expand=True)合并表时,我优先推荐pd.merge(),因为它支持按键匹配,且能明确 on、how 参数。最常用的how='left'表示以左表为基准保留全部记录,右表只能匹配上的列。这个操作在清洗阶段特别适合“补齐维度信息”,比如把用户表里的优惠券信息合并到订单表上。要注意的是,merge之前两边的关联列类型必须一致,否则会出现明明有值却匹配不上的问题。
pd.concat()则更适合纵向拼接结构相同的表,比如把多个月份的数据放在一起分析。用ignore_index=True可以重新生成连续索引。如果拼接的列不完全一致,默认会填充 NaN,可以用join='inner'只保留共有的列,或者用join_axes指定对齐方式,不过后者已经不怎么建议用了。纵向拼接最常见的坑是列名有微小差异导致结果出现大量 NaN,所以拼接前最好统一列名。
6. 实操流程串联:一个完整的小案例
6.1 模拟一份带病数据
理论讲再多,不如跑一遍整体流程。我模拟一份用户订单数据,里面包含了绝大多数常见问题:缺失值、重复行、异常值、文本脏数据、类型错误、列名不规范。清洗目标:得到一份干净、可用于后续按城市和品类统计销售额的表。
import pandas as pd df = pd.DataFrame({ ' 用户id ': [1, 2, 3, 4, 5, 6, 7], '订单金额(元)': [150, None, '200元', 999999, 80, 150, 200], '城市': [' 北京', '上海', '上海', '广州', ' 北京', '北京', '广州'], '日期': ['2024-01-01', '2024/01/02', '2024-01-03', '2024-01-04', None, '2024-01-06', '2024-01-07'], '品类': ['电子产品', '日用品', '电子产品', '日用品', '电子产品', '日用品', '电子产品'] })这份数据有 7 行,但问题很多:列名带空格、订单金额有字符串、有缺失、有一个 999999 的异常值、日期格式不统一、有缺失、城市有前导空格,还有一条“用户id=2”和“用户id=7”的金额、城市、日期、品类其实是不同的,但商户想按金额去重,那两行 150 元但城市和品类都不同就不是重复。我故意放了两条订单金额相同的记录,却因为其他字段不同而不能贸然去重。这里我加一条重复记录,让后续去重更直观。
df = pd.concat([df, pd.DataFrame([{' 用户id ': 8, '订单金额(元)': 150, '城市': ' 北京', '日期': '2024-01-01', '品类': '电子产品'}])], ignore_index=True)6.2 清洗流水线实现
我按照之前说的优先级顺序来写流水线代码。第一步先重置索引,因为 concat 后索引已经乱了。
df = df.reset_index(drop=True)然后统一列名:
df.columns = df.columns.str.strip().str.replace(' ', '_').str.replace('订单金额(元)', '订单金额')处理类型和缺失:订单金额列要去掉“元”字转成浮点数,日期统一成 datetime,城市要去空格。
df['订单金额'] = df['订单金额'].astype(str).str.replace('元', '').astype(float) df['城市'] = df['城市'].str.strip() df['日期'] = pd.to_datetime(df['日期'], errors='coerce')此时再看数据:
df.info()发现日期列有一个 NaT,订单金额列有一个 NaN(原本空的那个)。接着做异常值筛查:金额大于 3 倍中位数的 999999 是明显异常,我认为它是录入错误,但不想直接删,先标记。
median_amount = df['订单金额'].median() df['订单金额_异常'] = df['订单金额'] > median_amount * 3然后处理重复:根据“用户id + 城市 + 订单金额”判断,保留第一次出现的记录。
df = df.drop_duplicates(subset=['用户id', '城市', '订单金额'], keep='first')最后填缺失值:日期用前向填充,金额用中位数填充,或者直接删除极少量的缺失行。这里我选择删除缺失日期的那一行,因为只有一行;金额缺失的那一行用中位数填充。
df['日期'] = df['日期'].fillna(method='ffill') # 但这里注意开头缺失问题,实际用bfill或填充兜底 df['订单金额'] = df['订单金额'].fillna(median_amount)6.3 清洗前后对比与输出
清洗完成后,我打印清洗前后的内存大小、行数、缺失值数量,再把关键结果列提取出来。通常我会用.info()和.isna().sum()来验证清洗是否成功。
print('清洗后缺失值数量:') print(df.isna().sum()) print('清洗后异常值标记数量:', df['订单金额_异常'].sum()) print(df[['用户id', '订单金额', '城市', '日期', '品类']].head(10))从最终输出可以看到:列名干净、日期统一为 datetime 类型、城市一致、重复行被去掉、异常值被标记、缺失值被填充。这份数据就能直接进入 groupby 聚合了。整个流程一步步没有一步是花哨技巧,但跑起来非常顺手。很多人觉得数据清洗麻烦,是因为没有把流程固定下来,每次遇到新数据都像第一次一样乱摸。我的建议是形成一套自己的“清洗流水线”模板,每次把数据往里面一套,再针对特殊情况修正,能省非常多时间。
7. 常见问题与排查技巧实录
7.1 SettingWithCopyWarning:这个警告必须重视
几乎所有用 pandas 做过筛选的人都被这个警告烦过。它出现的原因是:你对一个视图(view)进行了赋值操作,pandas 不确定这是不是你想修改原始 DataFrame。长期忽视它会导致一种很致命的情况——你以为改了数据,实际操作没生效,或者更改了副本,原数据没变。
最稳妥的解决办法是:任何需要修改的中间结果都用.copy()显式复制。比如:
sub_df = df[df['城市'] == '北京'].copy() sub_df['新列'] = 1虽然.copy()会占一点内存,但换来的是行为确定。如果你确实想修改原表,也建议先复制到新变量改名再改,而不是直接在筛选结果上操作。这个习惯能让你少浪费大量调试时间。
7.2 内存占用过高:分块读取与类型压缩
数据分析中经常遇到几个 GB 的 CSV 文件,直接用pd.read_csv()读取会把内存吃爆。我的经验是先用nrows=1000抽样读取,看列名和粗糙类型,再用dtype参数指定列类型,避免把数值列读成 object。
df_chunk = pd.read_csv('big_data.csv', chunksize=10000) result_parts = [] for chunk in df_chunk: # 逐块清洗或过滤 part = chunk[chunk['订单金额'] > 100] result_parts.append(part) result = pd.concat(result_parts, ignore_index=True)分块读取时每个块单独清洗,再拼起来。另外可以通过pd.to_numeric(..., downcast='integer')或把 object 列转为 category 来大幅度降低内存。我之前处理过一个日志数据,光是把几十列重复文本变成 category,内存就从 4 GB 降到 800 MB,效果立竿见影。
7.3 编码、科学计数法与隐藏字符
读取外部数据时,编码是经常出问题的一环。utf-8是默认,但 Windows 下常见的 Excel 导出文件可能是gbk或gb2312。读取时直接指定:
df = pd.read_csv('数据.csv', encoding='gbk')如果无法确定编码,可以用errors='ignore'或先用文本工具打开看一眼,不要猜错后疯狂报错。另一个常见问题是科学计数法:很大的数值在展示时变成1.23e+05,容易让人误以为是文本。解决办法是在read_csv时用converters转成字符串,或者读取后astype(str).str.replace('.0', '')。
隐藏字符则是指那些看不见的零宽空格、不间断空格,它们经常混在从网页复制来的数据里。可以用df['col'].str.contains(r'\s')检查,然后用.str.replace(r'\s+', '', regex=True)清理。更彻底的方案是读取时把sep设置成\s+,但要注意别把正常空格也一并吞掉。
7.4 我的个人排查顺序
如果在清洗过程中发现结果不符合预期,我会按以下顺序排查:
- 先检查
info()和dtypes,看是否有列类型异常; - 再检查
isna().sum()和duplicated().sum(),确认缺失和重复处理是否符合预期; - 接着检查
nunique(),看分类列是否出现了预想不到的新值,比如“未知”、“nan”、“NULL”这些字符串; - 最后检查数据边界,用
max()和min()看是否有异常极值。
这个顺序基本覆盖了 80% 的坑。如果还是找不到原因,那就把清洗的每一步中间结果都输出一遍,哪里断了就哪里改,别嫌麻烦。
做数据清洗这件事,最大的成就感其实来自于最后那一下:看着一份满是问题的原始数据,在自己的处理下变成干净整洁的表格,后续分析做起来顺畅无比。我踩过太多次“跳过清洗直接建模,最后回头补锅”的坑,现在无论多急的项目,都会老老实实把这几个步骤走完。另外一个很有效的习惯是给清洗过程写一个函数,输入原始 DataFrame,输出清洗后的结果,每次换数据只要改里面的字段映射即可。这样做不仅方便重复使用,也方便团队其他人接手。希望这篇内容能让你在处理脏数据时更有底气,少走一些我已经替大家走过的弯路。