☰
pandas数据清洗与处理实战:从缺失值到异常值完整指南
2026/10/14 7:24:32 网站建设 项目流程

做数据分析的人大多有这种体会:拿到一份数据,真正花在建模上的时间反而少,大部分精力都被数据清洗与处理吞掉了。pandas作为Python生态里最常用的结构化数据处理库,几乎每天都要跟缺失值、重复行、错乱格式打交道。这篇文章我会结合一份模拟电商订单数据,把用pandas做数据清洗与处理的完整流程拆开来讲:从数据体检、缺失值处理,到重复值和异常值排查,再到类型规范和文本清洗,最后把一些常见报错和性能优化的坑一并梳理。无论你是刚接触数据分析的新手,还是已经在写pandas脚本但总被数据质量折腾的工程师,这篇的内容应该都值得过一遍。

先说这份模拟数据的构成。我随手构造了一个订单表,字段包括订单号、用户ID、下单时间、支付时间、订单金额、收货省份、配送状态、商品类别等,数据量大概两千多行。别小看这么小的规模,常用的脏数据问题它一个都没落下:存在部分订单号重复、订单金额出现空值、支付时间缺失、用户姓名里有前后空格和大小写不一致,还有几笔金额明显异常的大单。后面所有的代码示例,都会基于这份数据来讲。

1. 数据清洗的整体思路与准备

1.1 先想清楚:为什么要花力气做数据清洗

很多新手容易忽略一个点:数据清洗不是可有可无的预处理,而是决定分析结果可信度的关键步骤。如果你直接用带缺失值的列去算均值,统计口径的不同会直接改变结果;如果同一笔订单在数据里出现两次,那么所有按订单维度的汇总都会翻倍;如果把金额列当成字符串去排序,那么"99"会排在"1000"前面,这种错误往往最隐蔽,排查起来也最费时间。

我通常用两个场景跟同事解释为什么要先清洗再分析。第一个场景是做月度销售汇总。订单金额如果存在缺失值,直接用列求和,结果会偏小;可是如果顺手把所有含缺失值的行都删掉,又可能损失正常样本,导致月度趋势失真。第二个场景是训练用户复购模型。脏标签对模型的影响非常直接,如果上游订单表里有重复记录,目标变量里本来是"1"的样本可能被多条重复数据稀释,模型学到的东西就会走偏。所以清洗本身不是单纯的技术动作,而是业务理解和数据判断的集合。

清洗也不是一次性动作。我自己的习惯是:拿到新数据先做一次全面体检,然后按缺失、重复、异常、类型、文本、日期的顺序逐项处理,每处理完一个阶段就重新看一眼整体情况,确保没有引入新的问题。这样看似多花了一点时间,实际上比最后返工节省得多。

1.2 动手之前:环境准备和数据读取方式

本文所有示例基于pandas 2.x版本,建议在虚拟环境里安装依赖。基础环境其实很轻,只需要pandas和numpy,如果后面要用到Z-score异常检测,再补一个scipy就够用。

pip install pandas numpy scipy

安装完之后,先把数据读进来。大多数真实业务数据逃不出CSV、Excel和数据库三种来源,这里我用CSV举例。读取时最容易踩坑的是编码问题,去年我处理过一批从Windows导出的订单表,文件编码是GBK,直接用默认UTF-8读取,整张表直接乱码。稳妥的做法是打开之前先确认编码,或者用带encoding参数的方式读取:

import pandas as pd import numpy as np df = pd.read_csv('order_data.csv', encoding='utf-8-sig')

数据读进来之后,我做的第一件事不是急着清洗,而是体检。所谓体检,就是快速了解这个数据集的规模、字段类型、缺失情况和大体分布。通常我会依次执行下面几个命令:

# 查看行数和列数 print(df.shape) # 查看列名、非空值数量和字段类型 df.info() # 查看前5行,感受一下数据长什么样 df.head() # 数值字段的基础统计 df.describe() # 缺失值数量 df.isnull().sum()

df.shape能告诉你数据规模,避免后面写出处理超大数据的低效代码。df.info()是我最依赖的一步,它会输出每一列的非空值数量、数据类型和内存占用,一眼就能定位出哪些列可能有缺失、哪些列类型明显不对。df.describe()适合扫一眼数值列的最小值、最大值、分位数,初步感受有没有离谱的极值。把这些信息组合起来,基本就能列出一份清洗清单了。

2. 缺失值处理:识别、判断与填充实操

2.1 把缺失值的位置、数量和业务属性一起看

缺失值是最常见也最容易被误处理的数据问题。很多教程一上来就讲fillna怎么用,但我建议先做一次完整的缺失值盘点,否则你都不知道自己在填什么。

# 缺失数量 missing = df.isna().sum() # 缺失占比 missing_pct = missing / len(df) * 100 # 拼成一张表,只看有缺失的字段 miss_info = pd.DataFrame({ '缺失数量': missing, '缺失占比': missing_pct.round(2) }).sort_values('缺失占比', ascending=False) print(miss_info[miss_info['缺失数量'] > 0])

在我们这份模拟数据里,执行完大概会看到订单金额缺失十几条、支付时间缺失四十多条、收货省份有少量缺失。只看数量还不够,缺失率结合业务字段一起看才有意义。比如"用户ID"如果缺失率超过5%,那这个表的主键可能都有问题,绝对不能简单填充;但如果只是"支付时间"缺失,而"下单时间"是完整的,那可能是部分订单还没支付,这时候直接删行或填一个固定值都不合适,需要结合业务定义。

另外提一句,isnull和isna在pandas里是等价的,用哪个纯属个人习惯。我写得快时会顺手打isnull,但讲解时更倾向用isna,因为NaN这个命名已经被大家接受了,读起来更顺。

2.2 先判断缺失机制,再决定删除还是填充

判断缺失机制听起来很学术,其实就是三个问题:缺失是随机的吗?缺失跟这个字段本身有关系吗?缺失跟别的字段有关系吗?

举例来说,如果支付时间的缺失集中在某种特定支付方式的订单里,说明缺失和业务过程有关,不是随机发生,这时候直接删除缺失行会让样本整体偏向另一种支付方式。如果订单金额的缺失只出现在测试订单里,那删除掉反而更干净。如果缺失纯粹是数据传输过程中的丢字段,那就可以放心用填充来处理。

基于这个判断,我一般的决策逻辑是这样的:

  • 缺失占比超过50%的字段,除非有强业务原因,否则优先考虑整列删除,因为填出来的值可信度太低,后续分析用了反而误导。
  • 缺失占比很低且样本量足够时,直接删除缺失行是简单且有效的办法,尤其当你只是做描述性统计。
  • 缺失占比介于两者之间,或字段本身是分析核心字段时,再考虑填充。

填充也要区分情况。数值列不要上来就填均值,尤其是当数据存在明显异常值或右偏分布时,均值会被极端值拉高,中位数更稳。类别列的填充思路不太一样,用众数填充最常见,但如果你发现缺失部分本质上代表"未知"或"未发生",也可以在业务解释时说明清楚。时间列则优先看前后顺序,比如用相邻记录填充,或者按业务规则找一个替代值。

2.3 数值列、类别列和时间列的填充代码示例

下面这一段就是我处理缺失值的常用代码组合,按字段类型分别处理:

# 数值列:用中位数填充订单金额 df['order_amount'] = df['order_amount'].fillna(df['order_amount'].median()) # 类别列:用众数填充配送状态 df['delivery_status'] = df['delivery_status'].fillna(df['delivery_status'].mode()[0]) # 时间列:支付时间缺失时,按下单时间替换(业务上可解释) df['pay_time'] = df['pay_time'].fillna(df['order_time'])

订单金额用中位数填充,是因为金额分布往往右偏,个别大额订单会拉动均值,中位数更能代表典型订单水平。配送状态用众数,是因为多数订单都属于同一类状态,用一个最常见值补上,对后续分组统计的影响最小。支付时间缺失时,直接用下单时间替换,是假设"支付时间未知但下单时间可信",这个假设如果不符合业务,就不要这么写。

再次提醒,fillna返回的是处理后的新DataFrame或Series,你必须要重新赋值,或者用inplace参数。我习惯显式赋值,因为可读性更好,也避免链式操作带来的隐患。填充完成后,记得再跑一次缺失值检查:

df.isna().sum()

看到缺失列全部归零,这一步才算真正结束。我自己踩过不少次"填充了但没有重新赋值"的坑,表面上看代码执行了,实际数据根本没变,这也是新手最容易碰到的迷惑问题。

3. 重复值与异常值:两处最容易被忽视的坑

3.1 重复值识别与去重:整行去重还是按关键字段去重

重复数据在真实数据源里非常普遍。上游报表被重复查询、两张来源表叠加、合并时没有做去重,都可能留下重复记录。有重复记录时,排名统计会偏,去重计数会错,建模时的标签也会被污染。

先看整体重复情况:

# 统计完全重复的行 df.duplicated().sum()

这里统计的是全字段完全一致的行。但实际处理时更常见的是"部分字段重复",比如同一笔订单出现在了两条记录里,订单号相同,但配送状态或备注字段稍有不同。这时候你要判断哪一条是有效的。我通常会用订单号这类主键字段去看:

# 按订单号查找重复记录 df[df.duplicated(subset=['order_id'], keep=False)].sort_values('order_id')

查看完重复记录的具体内容后,再决定保留哪一条。大多数情况下保留第一条即可,也就是最早录入的那条;如果业务上明确要求保留最新状态,就把keep改成'last'。

df = df.drop_duplicates(subset=['order_id'], keep='first')

还有一种情况是重复值需要聚合而不是删除。比如同一用户在同一天下多笔订单,这在业务上不是重复数据,而是有效行为;但如果做日均订单量统计,你就不应该把同一用户的重复行为合并掉。所以删除重复行之前,一定要先问一句:这个字段的重复,在业务上真的算重复吗?

3.2 异常值识别:IQR法和Z-score法操作对照

异常值识别有很多方法,我平时最常用的是IQR四分位距法和Z-score标准化法。

IQR法的优势在于不受数据分布形态限制,尤其适合订单金额这种明显右偏的分布。它的原理很简单:取25%分位数Q1和75%分位数Q3,算出差值IQR,然后把超出Q1-1.5倍IQR到Q3+1.5倍IQR这个范围的值标记为异常。

Q1 = df['order_amount'].quantile(0.25) Q3 = df['order_amount'].quantile(0.75) IQR = Q3 - Q1 lower_limit = Q1 - 1.5 * IQR upper_limit = Q3 + 1.5 * IQR # 查看异常记录 outliers_iqr = df[(df['order_amount'] < lower_limit) | (df['order_amount'] > upper_limit)] print(outliers_iqr[['order_id', 'order_amount']])

Z-score法则是用均值和标准差来衡量某个值偏离平均水平的程度。一个值的Z-score绝对值超过3,就可以看作极端值。这个方法假设数据大致服从正态分布,适合处理身高、体重这类自然分布的数据;但订单金额这类长尾数据,Z-score会受极值影响,可能把正常的偏态置后当成异常。

mean_amount = df['order_amount'].mean() std_amount = df['order_amount'].std() df['amount_zscore'] = (df['order_amount'] - mean_amount) / std_amount outliers_z = df[df['amount_zscore'].abs() > 3] print(outliers_z[['order_id', 'order_amount', 'amount_zscore']])

我的习惯是两种方法结合着看。先用IQR筛选出明显离群的一段记录,再逐个看业务上下文判断这些记录是不是真的不合理。用统计方法圈定范围只是第一步,后面的业务判断才是处理异常值的核心。

3.3 异常值处理策略:删除、缩尾还是单独标记

异常值不一定是错误值,这个判断决定了你会用哪种策略。我处理过一份销售数据,订单金额最高的一笔比其他订单高出几百倍,初看很像异常值,后来一查,那是一个企业采购单,整批购买了几百台设备。这种情况下直接删掉,等于把最真实的业务贡献剔除掉了。

处理手段通常有三种:

  • 删除:适用于确认是录入错误、单位错乱、数据拼接出问题且占比极小的情况。
  • 缩尾:把超出上下限的值替换成上下限本身,适合不想损失样本量但又希望弱化极端值影响的情况。
  • 单独标记:新增一个"是否为异常值"的列,不改变原值,适合把这些异常值当作后续分析的特征来用。
# 单独标记异常值 df['is_amount_outlier'] = ( (df['order_amount'] < lower_limit) | (df['order_amount'] > upper_limit) ).astype(int) # 缩尾示例:把超过上限的金额压回上限 df.loc[df['order_amount'] > upper_limit, 'order_amount'] = upper_limit

要注意,缩尾这种操作本质上改变了数据分布,在做统计推断时要非常谨慎。如果你只是做描述性报表和日常监控,缩尾可以帮你排除极端值的干扰;但如果你要训练回归模型,缩尾可能会削弱模型对极端场景的感知能力。异常值标记法是最保守也最可追溯的方案,我在数据治理类项目里用得最多。

4. 类型规范化与文本清洗:从源头减少返工

4.1 类型转换:别让金额列躲在object里

经常有数据从Excel或业务系统导出后,金额列因为混入"¥"符号、千位分隔符或空白字符串,被pandas读成了object类型。object类型在求和、排序、取分位数时走的是字符串逻辑,根本处理不了数值比较。先看一眼当前类型:

print(df.dtypes)

如果看到订单金额列是object,我推荐用pd.to_numeric而不是astype来处理:

df['order_amount'] = pd.to_numeric(df['order_amount'], errors='coerce')

errors='coerce'的作用是把无法解析的内容变成NaN,这样你后续还能通过缺失值流程去处理它们。如果换成astype(float),一旦遇到"12,000"这种带逗号的字符串,会直接抛出异常让整个程序中断。用小成本容错机制把错误拦截下来,再统一处理,比让程序崩掉再回头改数据要好得多。

顺便提一个容易混淆的点:如果想要整数列,而且这一列里有缺失值,不要用astype('int64'),它遇到NaN时会报错。pandas提供了可空整数类型,比如Int64,它可以在保留整数的同时接受缺失值。这个细节在处理用户ID、年龄、参与次数这类字段时很常用。

4.2 文本清洗:空格、大小写与登记格式统一

文本字段的脏,主要来自手工录入和不同系统的格式差异。用户名前后有空格、大小写混乱,电话里有横杠、括号或空格,地址里混入全角字符,这些情况都特别常见,也特别影响后续的匹配和分组。

处理思路就是先统一格式再数值化。下面这段代码覆盖了日常最常见的几个操作:

# 去掉首尾空格,并把姓名统一成首字母大写 df['user_name'] = df['user_name'].str.strip().str.title() # 电话字段只保留数字,去掉非数字字符 df['phone'] = df['phone'].str.replace(r'\D+', '', regex=True) # 省份统一去掉末尾的"省"字,方便跟其他表做匹配 df['province'] = df['province'].str.replace('省', '', regex=False) # 地址中的连续空白压缩成一个空格 df['address'] = df['address'].str.replace(r'\s+', ' ', regex=True)

字符串方法调用后返回的是一个新序列,必须重新赋值回去,这是新手最容易忽略的操作。正则表达式里,\D代表非数字字符,配合replace可以把电话里的横杠、空格、括号一并清掉。再提醒一句:如果你处理的是国际长途或者需要保留座机分机号,直接清掉所有非数字字符可能把分机号也弄丢,要提前确认业务上到底需要保留哪些信息。

4.3 日期时间规范化:从字符串到可计算的时间字段

日期字段如果只当字符串处理,很多分析都做不了。比如统计每月支付总额、计算下单到支付的时间间隔,都需要先把字符串解析成真正的datetime类型。

df['order_time'] = pd.to_datetime(df['order_time'], format='%Y-%m-%d %H:%M:%S') df['pay_time'] = pd.to_datetime(df['pay_time'], format='%Y-%m-%d %H:%M:%S', errors='coerce')

转换完成后,就可以通过dt访问器提取各种时间特征,也可以直接做时间差计算:

# 提取年份和月份 df['order_year'] = df['order_time'].dt.year df['order_month'] = df['order_time'].dt.month # 计算下单到支付的间隔,单位:分钟 df['pay_interval'] = (df['pay_time'] - df['order_time']).dt.total_seconds() / 60

pd.to_datetime的format参数值得多说一句。如果数据源格式很标准,不写format也能解析;但只要存在多种格式混用的情况,比如一部分是"2024-01-01 12:30:00",另一部分是"2024/1/1 12:30",pandas默认解析有可能会出错,或者费时去猜。提前指定format,解析速度更快,也更不容易出现静默错误。实在担心解析不了,就加上errors='coerce',把坏值变成NaT,之后统一排查。

5. 常见问题与性能优化实录

5.1 我遇到过几个典型报错与排查思路

跑数据清洗时,报错信息是常有的事,但大部分坑都是可以提前避开的。下面这几个是我这些年遇到最多、也最容易被搜索引擎绕晕的问题。

第一个是SettingWithCopyWarning。出现这个警告,通常是因为你先用布尔索引筛选了一个子DataFrame,然后在这个子集上直接赋值。pandas会提示你操作可能没有作用于原始数据。解决办法很简单:要么在筛选后用.copy()复制一份,要么直接用df.loc[条件, 列]这种方式赋值。

# 避免警告的正确写法 sub_df = df[df['order_amount'].isna()].copy() # 或直接使用 loc df.loc[df['order_amount'].isna(), 'order_amount'] = 0

第二个是ValueError: cannot reindex from a duplicate axis。这个问题多发生在两个DataFrame做合并或索引对齐时,原因是其中某个对象存在重复索引。我处理时一般先跑df.index.is_unique看索引是否唯一,不唯一就reset_index,再去做合并。

第三个是赋值长度不匹配。比如你试图把一整列新计算的值赋给一个已有列,但两边长度不一致,pandas会直接报错。这种情况通常是因为中间有dropna或筛选改变了行数。我的排查思路是检查df.shape和计算结果的长度,看是哪个步骤把样本量改变了。

第四个问题是内存不足。如果数据量到几百万行,内存压力会很明显。解决办法不是换机器,而是从读入阶段就控制字段数量和数据精度,下面一节会专门讲。

5.2 数据量变大时的性能优化技巧

pandas在小数据量下怎么用都比较快,但到了真实业务环境,几百万行的表很常见。性能优化的核心就一句话:能向量化就不循环,能低精度就不高精度,能少读列就少读列。

读CSV时就可以指定需要的字段和类型:

df = pd.read_csv( 'order_data.csv', usecols=['order_id', 'order_amount', 'pay_time'], dtype={'order_id': 'int32'} )

只读取分析需要的列,能显著减少内存占用。因为CSV里的每一列都要解析,如果原表有几十个字段,而你只需要其中几个,把全部字段读进来再删,既慢又浪费内存。

字段类型的优化也有可观的收益。比如订单ID如果实际上是整数,就没必要用int64,int32往往够用。省内存的效果在大表上很明显。类别型字段也可以用category类型压缩存储:

df['province'] = df['province'].astype('category')

还有一个常见误区是到处用for循环逐行处理数据。pandas的向量化操作远超循环效率,能用str、dt、apply批量操作就尽量别写逐行遍历。如果实在需要复杂逻辑,也建议先用向量化生成辅助列,再对少量剩余行做循环。

5.3 沉淀一套可复用的清洗流程函数

项目做多了以后,你会发现清洗步骤其实非常固定。与其每次新数据来了都重写一遍,不如把常用流程封装成一个函数。我平时在项目里会写类似这样的清洗函数:

def clean_order_data(raw_df): res = raw_df.copy() # 按订单号去除重复 res = res.drop_duplicates(subset=['order_id'], keep='first') # 金额转数值,缺失值用中位数填充 res['order_amount'] = pd.to_numeric(res['order_amount'], errors='coerce') res['order_amount'] = res['order_amount'].fillna(res['order_amount'].median()) # 日期规范化 res['order_time'] = pd.to_datetime(res['order_time'], errors='coerce') res['pay_time'] = pd.to_datetime(res['pay_time'], errors='coerce') # 文本字段统一 res['user_name'] = res['user_name'].str.strip().str.title() res['phone'] = res['phone'].str.replace(r'\D+', '', regex=True) return res

把这个函数放在项目的utils.py里,以后拿到新订单数据,直接一行调用就完成了大部分基础清洗。函数的参数还可以继续扩展,比如允许调用方自定义填充方式、日期格式、去重字段,让复用性更强。

我还会在清洗过程中记录每一步做了哪些删除和填充,原因是什么。最直接的方式是在项目目录或者数据文件旁边放一个清洗说明文本,或者直接在代码里写注释。这样不仅方便自己后期回溯,也能让接手的同事更快理解数据经过了哪些改动。数据清洗本身不难,难的是让这些清洗逻辑对业务可解释、对后续分析可复用。

说回我自己的习惯,现在拿到一份陌生数据,我基本不会急着去fillna或dropna,而是先把info和describe打出来,把缺失率、异常值、类型问题看一遍再动手。清洗过程里每一步都会保留一个中间版本,这样即使后面的处理逻辑牵出前面的问题,找回数据也很容易。pandas的清洗能力很强大,但真正值钱的其实是你对数据的判断——该删该填、该留该改,不是函数决定的,是业务场景决定的。这个道理,我是在好几个项目里踩了坑之后才真正想明白的,分享出来,希望你能比我更早意识到。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询