4、异常值检测与处理:基于统计的方法(Z-score、IQR)、基于距离的方法(LOF)、基于密度的方法(DBSCAN)、向量异常值的影响

异常值检测,说白了就是在一堆数据里找出那些「格格不入」的点。做向量数据清洗时,这一步我从来不敢跳过。为什么?因为几个异常向量就能把整个模型的训练带偏,你辛辛苦苦调参,结果全白费了。

我个人习惯把异常值检测方法分成三类:统计的、距离的、密度的。每种方法都有自己的脾气,选对了事半功倍,选错了…嗯,我踩过不少坑,下面一个个说。

核心观点:没有万能的方法。Z-score 适合正态分布数据,IQR 适合偏态分布,LOF 和 DBSCAN 适合复杂结构数据。关键是根据数据特征选对工具。
异常值检测方法体系 异常值检测方法 统计方法 Z-score IQR 距离方法 LOF 密度方法 DBSCAN 选择建议 正态分布 → Z-score | 偏态分布 → IQR 局部异常 → LOF | 任意形状簇 → DBSCAN

4.1 基于统计的方法:Z-score 与 IQR

统计方法是最直观的。你想想看,如果数据服从正态分布,那绝大多数点都会集中在均值附近。离均值太远的点,自然就是异常。

4.1.1 Z-score 方法

Z-score 的计算公式很简单:Z = (x - μ) / σ。它衡量的是数据点偏离均值多少个标准差。

我记得有一次做传感器数据清洗,采集到的温度值里混了几个 200°C 的读数——正常范围明明是 20-40°C。用 Z-score 一算,这些点的 Z 值都超过 10 了,妥妥的异常。

经验阈值:通常取 |Z| > 3 作为异常判定标准。如果样本量小(< 100),可以放宽到 2.5;样本量大(> 1000),可以收紧到 3.5。
import numpy as np

def zscore_detection(data, threshold=3):
    """Z-score 异常检测"""
    mean = np.mean(data)
    std = np.std(data)
    z_scores = np.abs((data - mean) / std)
    outliers = np.where(z_scores > threshold)[0]
    return outliers, z_scores

# 示例:检测向量中的异常值
vector = np.array([1.2, 1.5, 1.3, 1.4, 1.6, 15.2, 1.3, 1.5])
outliers, scores = zscore_detection(vector)
print(f"异常索引: {outliers}")  # [5]
print(f"异常Z值: {scores[outliers]}")  # [~12.3]
注意:Z-score 对异常值本身很敏感。因为异常值会拉偏均值和标准差,导致部分异常被「掩盖」。我曾经遇到过这种情况——数据里有 5% 的异常,结果 Z-score 只检出 2%。这时候可以考虑用中位数和 MAD(中位数偏差)替代。

4.1.2 IQR 方法

IQR(四分位距)方法不依赖正态分布假设,鲁棒性更好。它用 Q1(25% 分位数)和 Q3(75% 分位数)来定义正常范围。

判定规则:小于 Q1 - 1.5 * IQR 或大于 Q3 + 1.5 * IQR 的点视为异常。

def iqr_detection(data, multiplier=1.5):
    """IQR 异常检测"""
    q1 = np.percentile(data, 25)
    q3 = np.percentile(data, 75)
    iqr = q3 - q1
    
    lower_bound = q1 - multiplier * iqr
    upper_bound = q3 + multiplier * iqr
    
    outliers = np.where((data < lower_bound) | (data > upper_bound))[0]
    return outliers, (lower_bound, upper_bound)

# 示例
vector = np.array([1.2, 1.5, 1.3, 1.4, 1.6, 15.2, 1.3, 1.5])
outliers, bounds = iqr_detection(vector)
print(f"异常索引: {outliers}")  # [5]
print(f"正常范围: [{bounds[0]:.2f}, {bounds[1]:.2f}]")
方法 适用场景 优点 缺点
Z-score 正态分布数据 计算简单,有统计意义 对异常值敏感,要求正态性
IQR 偏态分布、有离群点 鲁棒性强,不依赖分布 对尾部数据不敏感

4.2 基于距离的方法:LOF(局部异常因子)

统计方法处理一维数据还行,但到了高维向量空间就力不从心了。这时候我一般用 LOF。

LOF 的核心思想:比较一个点周围的「密度」和它邻居的「密度」。如果这个点比邻居稀疏得多,那它就是异常。

为什么会这样?你想想看,正常点周围应该有很多同类,密度高;异常点孤零零的,密度低。LOF 值越大,异常可能性越高。

LOF 解读:
  • LOF ≈ 1:密度和邻居差不多,正常点
  • LOF > 1:比邻居稀疏,可能是异常
  • LOF < 1:比邻居密集,可能是簇内点
通常取 LOF > 1.5 或 2 作为异常阈值。
from sklearn.neighbors import LocalOutlierFactor

def lof_detection(data, contamination=0.1, n_neighbors=20):
    """LOF 异常检测"""
    lof = LocalOutlierFactor(
        n_neighbors=n_neighbors,
        contamination=contamination
    )
    # -1 表示异常,1 表示正常
    labels = lof.fit_predict(data)
    outliers = np.where(labels == -1)[0]
    # 获取 LOF 分数(负值,值越大越异常)
    scores = -lof.negative_outlier_factor_
    return outliers, scores

# 示例:二维向量异常检测
np.random.seed(42)
normal = np.random.randn(100, 2) * 0.5
outlier = np.array([[5, 5], [4.5, 4.8], [5.2, 4.7]])
data = np.vstack([normal, outlier])

outliers, scores = lof_detection(data)
print(f"检测到 {len(outliers)} 个异常点")
print(f"异常点索引: {outliers[-3:]}")  # 最后三个是注入的异常
参数调优:n_neighbors 一般取 10-30。太小了容易误判,太大了会丢失局部性。我习惯先设 20,然后根据结果微调。

4.3 基于密度的方法:DBSCAN

DBSCAN 本来是个聚类算法,但它的副产品——噪声点——恰好就是异常值。我个人觉得这是最优雅的方法之一,因为它不需要预先指定簇的数量。

DBSCAN 有两个关键参数:eps(邻域半径)和 min_samples(最小样本数)。如果一个点周围 eps 半径内少于 min_samples 个点,它就被标记为噪声。

from sklearn.cluster import DBSCAN

def dbscan_detection(data, eps=0.5, min_samples=5):
    """DBSCAN 异常检测"""
    db = DBSCAN(eps=eps, min_samples=min_samples)
    labels = db.fit_predict(data)
    # -1 表示噪声(异常)
    outliers = np.where(labels == -1)[0]
    return outliers, labels

# 示例
np.random.seed(42)
normal = np.random.randn(200, 2) * 0.3
outlier = np.random.randn(5, 2) * 2 + [3, 3]
data = np.vstack([normal, outlier])

outliers, labels = dbscan_detection(data, eps=0.4, min_samples=5)
print(f"检测到 {len(outliers)} 个异常点")
print(f"簇数量: {len(set(labels)) - (1 if -1 in labels else 0)}")
避坑指南:我曾经在一个高维向量数据集上直接用 DBSCAN,结果所有点都被标记为噪声。为什么?因为高维空间里「维数灾难」导致距离度量失效。解决办法是先降维(PCA/t-SNE),或者用余弦距离代替欧氏距离。

4.4 向量异常值的影响

聊完检测方法,咱们得说说为什么这事这么重要。向量里的异常值到底会造成什么后果?

我总结了三类影响,你看看是不是深有体会:

影响类型 具体表现 严重程度
统计量扭曲 均值、方差被拉偏,标准化失效 高
模型训练偏差 梯度被异常点主导,收敛到错误方向 极高
相似度计算失真 余弦相似度、欧氏距离被异常维度支配 中

举个例子。假设你有一个 100 维的向量,其中 99 维都是 0.1 左右,但有一维是 100。计算余弦相似度时,这一维会完全主导结果。你想想看,两个向量在其他 99 维上完全一致,就因为这一维不同,相似度就变成 0.01 了——这合理吗?

处理方法对比:
  • 删除:简单粗暴,适合异常比例小(< 5%)的情况
  • 截尾:用分位数边界替换异常值,适合保留样本量
  • 插补:用均值/中位数/邻近值填充,适合后续需要完整数据
  • 变换:取对数或 Box-Cox 变换,压缩异常值的影响
def handle_outliers(vector, method='clip', lower_percentile=1, upper_percentile=99):
    """处理向量中的异常值"""
    if method == 'clip':
        lower = np.percentile(vector, lower_percentile)
        upper = np.percentile(vector, upper_percentile)
        return np.clip(vector, lower, upper)
    elif method == 'remove':
        outliers, _ = iqr_detection(vector)
        return np.delete(vector, outliers)
    elif method == 'median_fill':
        outliers, _ = iqr_detection(vector)
        median = np.median(vector)
        cleaned = vector.copy()
        cleaned[outliers] = median
        return cleaned
    else:
        raise ValueError("不支持的方法")

# 示例
vector = np.array([1.2, 1.5, 1.3, 1.4, 1.6, 15.2, 1.3, 1.5])
print(f"原始: {vector}")
print(f"截尾: {handle_outliers(vector, 'clip')}")
print(f"删除: {handle_outliers(vector, 'remove')}")
print(f"中位数填充: {handle_outliers(vector, 'median_fill')}")
我的建议:处理异常值没有银弹。我一般先做可视化(箱线图、散点图),了解数据分布后再选方法。对于向量数据,我推荐先用 IQR 做初步筛选,再用 LOF 或 DBSCAN 做精细检测。两步走,既快又准。

嗯,异常值检测这块就聊到这儿。记住一点:检测方法选对了,后续的清洗工作就轻松一半。别偷懒,多试试不同方法,找到最适合你数据的那一个。