数据是乱的,但咱们手里的活不能乱。真正在业务里跑Pandas,最先遇到的不是那些花哨算法,而是重复行、异常值这种“脏东西”。今天咱们就静下心来,把这两个高频问题掰开揉碎,讲一套能直接用到生产环境的标准流程。

一、应用场景:什么时候你需要清洗数据

先说重复值。最常见的就是从数据库导出的明细表,因为一次查询重试、多个接口拼接、甚至上游表本身就有重复键,导致同样一行数据出现好几次。如果你不去重,后面的汇总统计、透视表、建模训练全都会偏。比如算订单金额总和,重复的订单会被算两遍,老板一看数字翻倍,那就尴尬了。

再说异常值。异常值不一定是错误,也可能是真实的极端情况。比如电商平台突然来了一笔十万元的大订单,这在统计上可能把平均值拉得很高,让你误以为整体客单价提升了。又比如传感器数据里某个点突变成负值,那基本就是采集故障。异常值处理得好不好,直接影响你对业务规律的理解。生产环境里,清洗这一步做得扎实,后面分析才靠谱。

1.1 重复值的典型场景

重复值分两种:一种是整行完全一样,另一种是某几个关键列重复,比如订单号一样但收货地址不同。实际业务里,第二种更常见。你需要根据业务规则决定,到底按哪几列判断重复。

1.2 异常值的典型场景

异常值也分两类:一类是明显违反常识的错误值,比如年龄是负数、温度是1000度;另一类是统计意义上的离群点,比如大部分用户下单金额在几十到几百元,突然冒出一个一万多的。前者要直接修正或删除,后者要结合业务判断是否保留。

二、处理重复值:基础但容易踩坑

去重在Pandas里就两个方法:duplicated()用来找重复,drop_duplicates()用来删重复。别看它们简单,用不好会出大问题。

2.1 查找重复:duplicated()

duplicated()会为每一行返回一个布尔值,True表示这一行是重复出现的行,而且默认情况下第一个出现的不算重复,后面的才算。咱们先看一段代码。

# 技术栈:Python + Pandas
import pandas as pd

# 构造一份订单数据,故意放几行重复
df = pd.DataFrame({
    '订单号': ['A001', 'A001', 'A002', 'A003', 'A003', 'A003'],
    '客户':   ['张三', '张三', '李四', '王五', '王五', '王五'],
    '金额':   [100, 100, 200, 300, 300, 300]
})

# 查看哪些行是重复行
print(df.duplicated())

# 输出结果:
# 0    False
# 1     True
# 2    False
# 3    False
# 4     True
# 5     True
# dtype: bool

注意,第0行和第1行完全一样,duplicated()把第0行标记为False,第1行标记为True。第3、4、5三行重复,第3行是第一个出现,标记False,后面两行是True。这样设计的目的是让你能通过布尔筛选,把重复的行抓出来看看。

2.2 删除重复:drop_duplicates() 的用法细节

drop_duplicates()是真正干活的。它有3个关键参数:subset、keep、inplace。

  • subset:指定按哪几列判断重复。不传的话,必须整行所有列都一样才算重复。
  • keep:控制保留哪一个。- keep='first'是默认值,保留第一个;keep='last'保留最后一个;keep=False一个都不留。
  • inplace:是否在原DataFrame上直接改。生产环境我强烈建议不要用inplace=True,因为不经意在函数里把原数据改了,后面的逻辑全会被影响。还是用返回值更好。

咱们用真实一点的例子来演示。

# 技术栈:Python + Pandas
import pandas as pd

df = pd.DataFrame({
    '订单号': ['A001', 'A001', 'A002', 'A003', 'A003'],
    '金额':   [100, 200, 200, 300, 500],
    '日期':   ['2025-01-01', '2025-01-01', '2025-01-02', '2025-01-03', '2025-01-03']
})

# 只按订单号去重,保留第一个出现的记录
df_unique = df.drop_duplicates(subset=['订单号'], keep='first')

print(df_unique)
# 输出:
#   订单号  金额        日期
# 0  A001  100  2025-01-01
# 2  A002  200  2025-01-02
# 3  A003  300  2025-01-03

这里要注意,A001订单出现了两次,金额不一样。按订单号去重后,保留了第一行,第二行被干掉了。如果业务上要求保留最新的一条,那就得用keep='last'。所以去重前先想清楚:哪几列能唯一标识一条记录,以及该保留哪一条。

2.3 实际案例:订单表去重

生产环境中,订单表往往有很多列,咱们做的是“按业务主键去重,但保留各列最新值”。比如同一订单被重复同步,但收货地址可能被修改过。这时直接用drop_duplicates就不够灵活了,需要先排序,再去重。

# 技术栈:Python + Pandas
import pandas as pd

# 模拟上游同步导致重复
df = pd.DataFrame({
    '订单号': ['SO001', 'SO001', 'SO002', 'SO002'],
    '状态':   ['已支付', '已发货', '已下单', '已关闭'],
    '更新时间': ['2025-06-01 08:00:00', '2025-06-01 12:30:00',
              '2025-06-02 09:00:00', '2025-06-02 18:00:00']
})

# 按订单号和更新时间排序,让最新的记录排在后面
df_sorted = df.sort_values('更新时间')

# 按订单号去重,保留最后一个(即最新状态)
df_deduplicated = df_sorted.drop_duplicates(subset=['订单号'], keep='last')

print(df_deduplicated)
# 输出:
#    订单号 状态        更新时间
# 1  SO001  已发货  2025-06-01 12:30:00
# 3  SO002  已关闭  2025-06-02 18:00:00

这就是生产里最常见的“保留最新状态”的去重姿势。

三、处理异常值:别急着删

遇到异常值,第一反应不应该是删行,而是先搞清楚它为什么异常。删数据是最简单,但也最容易把有效信息丢掉的办法。通常会按这个顺序走:先发现,再判断,最后处理。

3.1 怎么发现异常值

最直观的方法是看统计描述。

# 技术栈:Python + Pandas
import pandas as pd

df = pd.DataFrame({'订单金额': [50, 80, 120, 150, 100, 200, 10000, -30, 90, 110]})

# 查看均值、标准差、四分位数
print(df.describe())
# 输出:
#         订单金额
# count   10.000000
# mean   1057.000000
# std    3132.848085
# min     -30.000000
# 25%      85.000000
# 50%     105.000000
# 75%     162.500000
# max   10000.000000

一眼就能看出两个问题:最小值是负数,肯定不对;最大值10000,比其他数据大了一个量级。这时候就能用统计方法把它们标出来。

3.2 用IQR方法识别异常值

IQR(四分位距)是常用的一种方法。它认为超出Q1 - 1.5 * IQR和Q3 + 1.5 * IQR范围的点是异常值。IQR的好处是抗干扰,不受极端值影响,所以很适合初筛。

# 技术栈:Python + Pandas
import pandas as pd

df = pd.DataFrame({'订单金额': [50, 80, 120, 150, 100, 200, 10000, -30, 90, 110]})

# 计算四分位数
q1 = df['订单金额'].quantile(0.25)
q3 = df['订单金额'].quantile(0.75)
iqr = q3 - q1

# 确定上下限
lower = q1 - 1.5 * iqr
upper = q3 + 1.5 * iqr

print(f'正常范围:{lower:.1f} ~ {upper:.1f}')
# 输出:正常范围: -6.2 ~ 253.7

# 标记异常值
df['是否异常'] = (df['订单金额'] < lower) | (df['订单金额'] > upper)

print(df)
# 输出:
#    订单金额  是否异常
# 0     50  False
# 1     80  False
# 2    120  False
# 3    150  False
# 4    100  False
# 5    200  False
# 6  10000   True
# 7    -30   True
# 8     90  False
# 9    110  False

这样一下就找出来了,-30肯定不合理,10000在这个数据里也显得异常。但注意,IQR只是一个统计规则,不是说异常就一定错,只是提示你“这数据需要被仔细看”。

3.3 用Z-Score识别异常值

Z-Score是另一个常用方法,它算的是每个点和均值的距离有几个标准差。一般绝对值超过3就认为是异常。Z-Score适合数据比较对称、接近正态分布的场景。如果数据本身偏态很强,IQR会更稳妥。

# 技术栈:Python + Pandas
import pandas as pd

df = pd.DataFrame({'订单金额': [50, 80, 120, 150, 100, 200, 250, 180, 90, 110]})

# 计算Z-Score
mean = df['订单金额'].mean()
std = df['订单金额'].std()
df['z_score'] = (df['订单金额'] - mean) / std

# 标记异常,阈值取3
df['是否异常'] = df['z_score'].abs() > 3

print(df)
# 输出:
#    订单金额  z_score  是否异常
# 0     50 -1.173487  False
# 1     80 -0.474090  False
# 2    120  0.225307  False
# 3    150  0.735111  False
# 4    100 -0.188379  False
# 5    200  1.629033  False
# 6    250  2.618531  False
# 7    180  1.247128  False
# 8     90 -0.331234  False
# 9    110 -0.045523  False

这段数据没有超过3的,所以没有异常。如果某个值非常大,Z-Score就会跑到3以上。这个方法的缺点就是均值、标准差本身会受极端值影响,所以有时候异常值反而把自己的分数拉低了。生产环境可以把这两种方法结合着用。

3.4 异常值的处理策略:修、换、删

找到异常值之后,有三个方向:修正、替换、删除。

  • 修正:如果是明显错误,比如负金额、日期格式不对,按照业务规则改成合理的值。比如金额小于0,直接用0替代。
  • 替换:用同一列的均值、中位数或边界值替换。比如某个指标因为传感器抖动出现了一个999,可以把它替换成正常范围的上限值。用clip方法最方便。
  • 删除:如果异常值占比极小,而且确实是录入错误,对整体分析没帮助,那就删掉。但删之前一定要留个备份,或者记下删除的行数。

先看看替换示例。

# 技术栈:Python + Pandas
import pandas as pd

df = pd.DataFrame({'订单金额': [50, 80, 120, 150, 100, 200, 10000, -30, 90, 110]})

# 设置合理上下限,比如3%到97%分位数
lower = df['订单金额'].quantile(0.03)
upper = df['订单金额'].quantile(0.97)

# 用clip把超过边界的值截断到边界
df['订单金额_清理后'] = df['订单金额'].clip(lower=lower, upper=upper)

print(df)
# 输出:
#    订单金额  订单金额_清理后
# 0     50       50.0
# 1     80       80.0
# 2    120      120.0
# 3    150      150.0
# 4    100      100.0
# 5    200      200.0
# 6  10000     2170.0
# 7    -30       17.0
# 8     90       90.0
# 9    110      110.0

看到没,10000被压到了2170,-30被抬到了17。这样做比直接删行稳,保留了“这行数据存在”这个事实,只是把数值拉回合理范围。

四、生产级流程:把规则打包成函数

前面都是碎片操作,生产环境需要的是可复用、可维护的流程。我们把这些步骤整合成一个清洗函数,输入一个DataFrame,输出一个清理后的DataFrame,并且每一步都有日志记录。

4.1 设计清洗管线

一个标准的清洗流程应该是这样的:先处理重复值,再处理异常值。因为先去重可以减少后续计算量,而且重复行里的异常值可能会干扰分位数计算,先去掉更干净。

4.2 完整示例代码

下面是一个比较完整的清洗函数,它支持传入要去重的列名、要检查异常值的数值列、以及处理方式。

# 技术栈:Python + Pandas
import pandas as pd
import numpy as np

def data_cleaning_pipeline(df, key_cols=None, numeric_cols=None, abnormal_method='iqr'):
    """
    生产级数据清洗流程

    参数:
    df: DataFrame 原始数据
    key_cols: list 按哪些列去重
    numeric_cols: list 需要做异常值处理的数值列
    abnormal_method: str 异常值检测方法,可选 'iqr' 或 'zscore'
    """
    # 复制一份,避免修改原数据
    clean_df = df.copy()

    # ========== 第一步:去重 ==========
    before_dup = len(clean_df)
    if key_cols is not None:
        clean_df = clean_df.drop_duplicates(subset=key_cols, keep='first')
    else:
        clean_df = clean_df.drop_duplicates(keep='first')
    after_dup = len(clean_df)
    print(f'去重复:{before_dup}行 -> {after_dup}行,删除{before_dup - after_dup}行')

    # ========== 第二步:异常值处理 ==========
    if numeric_cols is not None:
        for col in numeric_cols:
            # 先去掉该列中的空值,空值单独处理
            series = clean_df[col].dropna()

            if abnormal_method == 'iqr':
                q1 = series.quantile(0.25)
                q3 = series.quantile(0.75)
                iqr = q3 - q1
                lower = q1 - 1.5 * iqr
                upper = q3 + 1.5 * iqr
                mask = (clean_df[col] < lower) | (clean_df[col] > upper)
            else:  # z-score
                mean = series.mean()
                std = series.std()
                # 防止标准差为0
                if std == 0:
                    print(f'列 {col} 的标准差为0,跳过异常检测')
                    continue
                z = (clean_df[col] - mean) / std
                mask = z.abs() > 3

            count = mask.sum()
            if count > 0:
                print(f'列 {col} 发现 {count} 个异常值,使用上下限截断处理')
                # 这里统一采用截断方式,避免删除过多行
                lower_val = series.quantile(0.05)
                upper_val = series.quantile(0.95)
                clean_df.loc[mask, col] = clean_df.loc[mask, col].clip(lower=lower_val, upper=upper_val)

    return clean_df


# 构造一份乱糟糟的数据
df_raw = pd.DataFrame({
    '订单号': ['A001', 'A001', 'A002', 'A003', 'A003', 'A004', 'A005'],
    '金额': [100, 100, 250, -50, 300, 99999, 120],
    '数量': [2, 2, 3, 1, 3, 5, 4]
})

# 执行清洗
df_clean = data_cleaning_pipeline(
    df_raw,
    key_cols=['订单号'],
    numeric_cols=['金额', '数量']
)

# 清洗结果
print(df_clean)

这段代码在控制台会输出类似这样的结果:

去重复:7行 -> 5行,删除2行
列 金额 发现 1 个异常值,使用上下限截断处理

然后打印的DataFrame里,-50和99999会被截断到5%和95%分位数附近。这样整个流程就算跑通了。以后面对新数据,只需要调用同一个函数,传入不同参数就行。

五、技术优缺点与注意事项

5.1 优点

这套流程最大的优点就是稳。重复值处理用drop_duplicates,简单直接,性能也好。异常值用IQR或Z-Score作为初筛工具,不需要机器学习的复杂门槛。把所有逻辑封装成函数后,很容易接入定时任务或调度平台,比如每天凌晨自动清洗一次昨天的数据。

另外,Pandas处理这类问题有天然优势:代码短、写法直观、内置方法多。duplicated、clip、quantile这些方法都是经过大规模验证的,不容易出错。比起自己写循环去判断,效率高得多。

5.2 缺点和坑

再好的工具也有坑。drop_duplicates最大的坑是默认按所有列判断重复,你不指定subset,很可能一条重复都去不掉。因为两个订单的主键一样,但时间戳差了几秒,整行就不完全相等。

异常值检测的坑更多。IQR假设数据分布比较稳定,如果数据本身是偏态分布,比如收入数据,那正常的高收入人群可能也会被标成异常。Z-Score又怕极端值,极端值会把均值和标准差拉偏,导致检测失效。所以在生产环境里,不能只依赖某一种方法,最好先画个箱线图或者直方图看看分布。

clip方法虽然好用,但要注意它只截断数值,不会改变该行其他列。如果一个订单金额异常,但数量正常,截断金额后,这个订单总额可能和数量不匹配,这时需要结合业务再调整。

5.3 注意事项

第一,清洗前一定要备份原始数据。哪怕你有清洗函数,也得把原始数据留一份,否则出了问题没法追溯。

第二,每一步都要留日志。删了多少行,改了几个值,全部记录下来。生产环境最怕“默默无闻”的数据变化,出了问题排查起来很痛苦。

第三,空值要单独处理。不要和异常值混在一起。空值的处理方式可能是填充、删除或向前向后填充,和异常值逻辑完全不同。所以上面的示例里,先用dropna把空值剔除了,避免干扰分位数计算。

第四,阈值不是死的。IQR的1.5倍系数,Z-Score的3倍阈值,这些只是经验值。你可以根据业务调整,比如金额类数据,可能超过5倍标准差才算异常。一定要结合业务来定。

六、文章总结

重复值和异常值是Pandas数据清洗面对的最常见问题,但它们并不可怕。去重用duplicated和drop_duplicates,重点在于选对subset和keep;异常值用IQR或Z-Score识别,再用clip截断或删除。生产环境里,一定要把这些操作封装成函数,做好日志和备份,让清洗流程标准化、可复用。数据干净了,下游的分析和建模才敢放手去做。