4. 异常值处理:别让“离群点”毁了你的模型

数据清洗做到这一步,前面缺失值、重复值都处理完了。但还有个“隐形杀手”——异常值。说白了,就是那些跟大多数数据画风完全不一样的点。比如全班考80分,突然冒出个0分或者满分,这俩就得打个问号。

我个人习惯,拿到数据第一件事不是建模,而是先画个箱线图扫一眼。为什么?因为异常值对均值、方差这些统计量影响太大了。你想想看,一个100人的收入数据集,突然混进去一个马云的级别,均值直接起飞,模型学到的全是偏差。

4.1 异常值识别:三把“照妖镜”

识别异常值的方法不少,但最常用的就三种。我按使用频率排个序,你照着用就行。

4.1.1 Z-score(标准分数法)

Z-score 的原理很简单:计算每个数据点距离均值有几个标准差。通常认为,Z-score 绝对值大于3的点就是异常值。

公式长这样:

Z = (x - μ) / σ

其中 μ 是均值,σ 是标准差。Z > 3 或 Z < -3,就标记为异常。

我在项目中遇到过一个问题:如果数据本身不是正态分布,Z-score 的效果会打折扣。比如收入数据,天生就是右偏的,用 Z-score 会漏掉很多真正的异常。

我的建议:Z-score 适合数据近似正态分布的场景。如果数据偏态严重,先做对数变换再用 Z-score,或者直接换 IQR 方法。

Python 实现也很简单:

import numpy as np
from scipy import stats

data = [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10, 100]
z_scores = np.abs(stats.zscore(data))
outliers = np.where(z_scores > 3)
print(f"异常值索引: {outliers}")
# 输出: 索引11(值100)是异常

4.1.2 IQR(四分位距法)

这个方法我用的最多。它不依赖数据分布,鲁棒性很强。原理是:

  1. 计算第一四分位数 Q1(25%分位)和第三四分位数 Q3(75%分位)
  2. 计算 IQR = Q3 - Q1
  3. 定义上下界:下界 = Q1 - 1.5 * IQR,上界 = Q3 + 1.5 * IQR
  4. 超出上下界的点就是异常值

为什么是1.5倍?这是统计学上的经验值。如果你觉得太宽松,可以收紧到1.2倍;如果数据波动大,可以放宽到2倍。我一般先按1.5倍跑一遍,看看结果再调整。

注意:IQR 方法对样本量有要求。数据太少(比如不到20条),算出来的分位数可能不准。我曾经在一个只有15条样本的小数据集上用过 IQR,结果把正常值也标成了异常,后来改成人工审核才解决。

代码实现:

def detect_outliers_iqr(data):
    Q1 = np.percentile(data, 25)
    Q3 = np.percentile(data, 75)
    IQR = Q3 - Q1
    lower_bound = Q1 - 1.5 * IQR
    upper_bound = Q3 + 1.5 * IQR
    outliers = [x for x in data if x < lower_bound or x > upper_bound]
    return outliers, lower_bound, upper_bound

data = [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10, 100]
outliers, lb, ub = detect_outliers_iqr(data)
print(f"异常值: {outliers}, 下界: {lb}, 上界: {ub}")
# 输出: 异常值: [100], 下界: 7.5, 上界: 17.5

4.1.3 箱线图(Box Plot)

箱线图是 IQR 的可视化版本。它把数据的分布、中位数、四分位数、异常值全画在一张图上,一目了然。

我每次做数据探索,第一件事就是画箱线图。为什么?因为肉眼比算法更敏感。有时候算法没标出来的点,你一看图就觉得不对劲。

用 Matplotlib 画箱线图:

import matplotlib.pyplot as plt

data = [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10, 100]
plt.boxplot(data)
plt.title("箱线图 - 异常值检测")
plt.ylabel("数值")
plt.show()
# 图中100这个点会显示为一个小圆圈,远离箱体

三种方法对比一下:

方法 适用场景 优点 缺点
Z-score 近似正态分布 计算简单,有统计意义 对偏态分布不友好
IQR 任意分布 鲁棒性强,不受极端值影响 样本量太小时不稳定
箱线图 可视化探索 直观,可同时看多个变量 无法自动化批量处理
异常值识别与处理全流程 原始数据 Z-score 法 IQR 法 箱线图 是否异常? 是 否 异常值处理 保留正常值 删除 修正 封顶 清洗后数据 💡 说明: 三种识别方法可单独使用,也可组合使用。建议先用箱线图做可视化探索,再用 IQR 或 Z-score 做定量判断。

4.2 异常值处理策略:删、改、封

识别出异常值之后,怎么处理?我总结了三种策略:删除、修正、封顶。没有绝对的好坏,关键看场景。

4.2.1 删除异常值

最直接的方法。如果异常值数量很少(比如不到总样本的1%),直接删掉影响不大。

但要注意:

  • 不能盲目删:我曾经在一个金融风控项目里,把“交易金额异常高”的样本全删了,结果模型上线后完全识别不出大额欺诈交易。因为这些“异常”恰恰是我们要预测的目标。
  • 检查业务含义:有些异常值其实是业务上的“信号”。比如传感器故障产生的0值,和真正的0值,处理方式完全不同。

代码实现:

# 删除异常值
def remove_outliers(data, method='iqr', threshold=1.5):
    if method == 'iqr':
        Q1 = np.percentile(data, 25)
        Q3 = np.percentile(data, 75)
        IQR = Q3 - Q1
        lower = Q1 - threshold * IQR
        upper = Q3 + threshold * IQR
        return [x for x in data if lower <= x <= upper]
    elif method == 'zscore':
        z = np.abs(stats.zscore(data))
        return [data[i] for i in range(len(data)) if z[i] <= 3]

data = [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10, 100]
clean_data = remove_outliers(data, method='iqr')
print(f"删除后: {clean_data}")
# 输出: [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10]

4.2.2 修正异常值

如果异常值有业务意义,或者样本量太少不能删,那就修正。常用的修正方法:

  • 用均值/中位数替换:简单粗暴,但会降低数据方差。我一般用中位数,因为它不受异常值影响。
  • 用前后值填充:适合时间序列数据。比如某天的数据异常,用前一天或后一天的值替换。
  • 用模型预测值替换:用其他特征训练一个回归模型,预测异常点的“正常值”。这个方法最精确,但成本也最高。
经验之谈:修正异常值时,一定要在数据文档里记录。我习惯在数据集里加一列“异常标记”,标注哪些值是修正过的,修正方法是什么。这样后续分析时心里有数。

代码示例:

# 用中位数修正异常值
def fix_outliers_with_median(data):
    median = np.median(data)
    Q1 = np.percentile(data, 25)
    Q3 = np.percentile(data, 75)
    IQR = Q3 - Q1
    lower = Q1 - 1.5 * IQR
    upper = Q3 + 1.5 * IQR
    fixed = []
    for x in data:
        if x < lower or x > upper:
            fixed.append(median)
        else:
            fixed.append(x)
    return fixed

data = [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10, 100]
fixed_data = fix_outliers_with_median(data)
print(f"修正后: {fixed_data}")
# 输出: [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10, 12]

4.2.3 封顶(Winsorization)

这个方法我特别喜欢。它不删除异常值,而是把异常值“拉”到边界值。比如把超过上界的所有值都设为上界,低于下界的都设为下界。

这样做的好处是:保留了样本量,同时限制了极端值的影响。在金融风控、信用评分领域特别常用。

举个例子:

# 封顶处理
def winsorize(data, limits=(0.05, 0.05)):
    """
    limits: (下界百分位, 上界百分位)
    默认把最低5%和最高5%的数据封顶
    """
    lower_limit = np.percentile(data, limits[0] * 100)
    upper_limit = np.percentile(data, 100 - limits[1] * 100)
    capped = []
    for x in data:
        if x < lower_limit:
            capped.append(lower_limit)
        elif x > upper_limit:
            capped.append(upper_limit)
        else:
            capped.append(x)
    return capped

data = [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10, 100]
capped_data = winsorize(data, limits=(0.1, 0.1))
print(f"封顶后: {capped_data}")
# 输出: [10, 12, 12, 13, 12, 11, 14, 13, 15, 10, 10, 15]
注意:封顶的阈值需要根据业务来定。我见过有人把5%的数据都封顶了,结果模型对极端情况完全没反应。一般建议封顶比例不超过1%-5%,具体看数据分布。

4.3 三种策略怎么选?

我整理了一个决策表,你遇到异常值时可以对照着选:

场景 推荐策略 原因
异常值数量少(<1%) 删除 对整体影响小,操作简单
异常值有业务意义 修正 保留信息,避免丢失信号
样本量小,不能删 封顶或修正 保留样本,控制极端值影响
时间序列数据 修正(前后值填充) 保持时间连续性
模型对极端值敏感(如线性回归) 封顶 限制极端值对模型的影响
模型对极端值鲁棒(如树模型) 保留或封顶 树模型本身能处理异常值

嗯,这里要注意一点:异常值处理没有标准答案。同一个数据集,不同业务场景处理方式可能完全不同。我见过一个医疗项目,把“心率200”当成异常值删了,结果那是病人发病时的真实数据——删掉反而丢了关键信息。

所以我的建议是:先理解业务,再动手处理。数据清洗不是机械操作,而是带着业务理解去做判断。你想想看,如果连数据背后的故事都不清楚,怎么敢随便删改呢?