🏠 总目录📚 本教程 07 · 异常值:错还是真
📑 本页目录(点开跳转)

07 · 异常值是错还是真

48 分钟 | ⭐ 删掉真实的极端值,等于把风控模型的信号删了


🎯 一句话

「异常值」这个词把两种完全相反的东西混成了一个。 一种是录入错误(该修该删),一种是真实的极端事件(恰恰是你最想要的信号)。 这一章不教你怎么"检测异常值"—— 检测很简单, 难的是检测出来之后那个判断:这条到底是错的,还是真的。


🧨 一、先把两种东西分开

同样是「金额 = 8,400,000」这一行 —— 它可能是下面两种完全相反的东西:

情况 A:录入错误 情况 B:真实极端事件
单位写错了(元 ↔ 分) 这是个大客户
多按了两个 0 这是一笔团伙套现
默认值 999999 混进来 这是双十一那天
传感器坏了返回 -9999 这是一次真实的系统雪崩
该做的:修正 / 删除 / 回溯上游 该做的:留着,而且要重点留着
⭐ 它常常正是标签为 1 的那一批

💀 这是本章唯一需要记住的事在风控、故障预测、医疗、异常检测这些场景里,「异常值」和「正样本」是同一批数据。 一个无脑的 3σ 清洗 步骤,删的不是噪声,是你的全部信号。

为什么这个错误特别容易犯:因为「去异常值」在几乎所有数据清洗教程里都被写成 一个默认步骤,而不是一个需要论证的决策。它长得像 dropna() 一样人畜无害。


🔍 二、判断「错还是真」的五个问题

检测方法只负责把可疑的行捞出来,捞出来之后按这个顺序问:

   ① 它在物理上/业务上可能吗?
      · 年龄 187 岁、订单金额为负、经纬度在海里 → 一定是错
      · 单笔 840 万、连续 300 次点击     → 可能是真的
      ⭐ 只有这一条能给出确定答案,先用它

   ② 同一行的其他字段自洽吗?          ⭐⭐ 最有用的一条
      · 金额 840 万,但商品是"9.9 元包邮" → 错
      · 金额 840 万,商户类目是"大宗贸易" → 真

   ③ 换个单位/量纲就正常了吗?
      · 除以 100 之后落回正常范围 → 单位混用(分 vs 元)
      · 正好是 32767 / 65535 / 2147483647 → 溢出截断

   ④ 它在时间上是孤立的还是成片的?
      · 孤立一条 → 更像录入错误
      · 某一天集中出现几百条 → 上游变更、口径变了、或真的出事了
      ⭐ 成片出现基本不是"异常值",是数据事故或真实事件

   ⑤ 它和标签的关系是什么?            ⭐⭐ 建模时最关键的一条
      · 极端值里的正样本率远高于总体 → 这是信号,绝对不能删
      · 极端值里正负样本比例和总体一样 → 大概率是噪声

第 ⑤ 条应该写成一段代码,在任何清洗动作之前跑一次。 它只要三行,却能拦住本章那个 380 万的事故。

② 和 ⑤ 落成代码长这样

import numpy as np

def outlier_label_profile(x, y, q=0.999):
    """极端值里的正样本率 vs 总体正样本率。
    x: 待检查的数值特征   y: 0/1 标签"""
    x, y = np.asarray(x, float), np.asarray(y, int)
    thr = np.nanquantile(x, q)
    hi = x > thr
    base = y.mean()
    ext = y[hi].mean() if hi.sum() else float("nan")
    return {
        "阈值": round(float(thr), 2),
        "极端值条数": int(hi.sum()),
        "总体正样本率": round(float(base), 5),
        "极端值正样本率": round(float(ext), 5),
        # ⭐ 这个倍数 > 3 就说明极端值是信号,任何"删除"都要重新论证
        "富集倍数": round(float(ext / base), 2) if base > 0 else float("nan"),
    }

# ⭐ 规矩:清洗脚本里每一处会丢弃数据的地方,都要先打印这个 profile

🛠️ 三、四种检测方法与各自的失效场景

先说结论,再看实测:

方法 适用 💀 什么时候会骗你
近似正态、单峰、无极端污染 偏态分布(金额、时长、播放量)几乎全错;掩蔽效应:异常值自己把 σ 抬高,把自己藏起来 ⭐⭐
IQR (1.5×) 比 3σ 稳健,仍假设大致对称 长尾分布上会把 7~10% 的正常数据判成异常
MAD / robust z 抗污染最好的单变量方法 同样怕重尾;对多峰分布无意义
孤立森林等无监督 多维、说不清规则时 contamination 是你拍的,它只会照着这个比例给你挑出那么多;不给理由,无法判断"错还是真"
业务规则 ⭐ 最可靠 覆盖不全,且要人来写、要维护

实测(10,000 条对数正态"交易额",人为混入 5 条"单位写错乘 100")

方法 上界 标出条数 其中真的是错误
10,747 9 5
IQR ×1.5 1,650 773 💀 5
MAD 787 💀 5
先取对数再 3σ 8,043 17 5
孤立森林 (contamination=0.001) 10 5

💀 IQR 那一行是重点:这份数据里一条错误都没有的时候, IQR 依然会把 7.71% 的正常交易判成"异常"。 而先取对数之后,同一份干净数据只有 0.33% 被判为异常。 对偏态数据,「换个尺度」比「换个算法」有用得多。

3σ 的掩蔽效应,实测更吓人(同一份数据,逐步增加录入错误的条数):

混入错误数 算出来的 σ 3σ 上界 抓到几个
0(干净) 864 3,248
5 3,347 10,747 5 / 5
20 2,300 7,634 18 / 20
50 6,864 21,530 39 / 50 💀

掩蔽效应(masking)一句话异常值参与了阈值的计算,所以异常值越多,阈值被抬得越高,越抓不住。 干净数据的 3σ 上界是 3,248;混进 50 条错误后变成 21,530(涨了 6.6 倍), 有 11 条错误就这样被自己抬高的阈值放过去了。 所以只要用均值和标准差,就必须迭代:删一轮、重算、再删一轮。 或者干脆改用中位数和 MAD(它们不被极端值带偏)。

能跑的对比代码

import numpy as np
from sklearn.ensemble import IsolationForest

def detect(x, contamination=0.001):
    """四种方法各标一遍,返回布尔掩码,方便直接对比谁标了谁"""
    x = np.asarray(x, float)
    out = {}
    mu, sd = x.mean(), x.std()
    out["3sigma"] = x > mu + 3 * sd

    q1, q3 = np.percentile(x, [25, 75])
    out["iqr"] = x > q3 + 1.5 * (q3 - q1)

    med = np.median(x)
    mad = np.median(np.abs(x - med)) + 1e-12
    out["mad"] = 0.6745 * (x - med) / mad > 3.5     # ⭐ 中位数版,不怕污染

    lx = np.log(np.clip(x, 1e-9, None))             # ⭐ 偏态数据先换尺度
    out["log3sigma"] = lx > lx.mean() + 3 * lx.std()

    iso = IsolationForest(contamination=contamination,
                          random_state=0).fit(x.reshape(-1, 1))
    out["iforest"] = iso.predict(x.reshape(-1, 1)) == -1
    return out

def sigma_clip(x, k=3, rounds=5):
    """⭐ 破解掩蔽效应:迭代地删、重算,直到不再变化"""
    x = np.asarray(x, float)
    keep = np.ones(len(x), bool)
    for _ in range(rounds):
        mu, sd = x[keep].mean(), x[keep].std()
        new = np.abs(x - mu) <= k * sd
        if (new == keep).all():
            break
        keep = new
    return ~keep      # True = 被判为异常

# 实测:10000 条对数正态 + 5 条"乘了100"的录入错误
#   3sigma 标 9 条 / IQR 标 773 条(!) / MAD 标 787 条 / log+3sigma 标 17 条
#   —— 干净数据上 IQR 也会标 7.71%,取对数后降到 0.33%

⚠️ 孤立森林的 contamination 不是"它检测出来的比例",是"你要求它挑出来的比例"。 你填 0.05,它一定会给你 5%,哪怕数据完全干净。 它是一个排序器,不是一个判定器 —— 只能用它的分数排序,然后人去看 Top-N。


💀 四、事故复盘:那个被删掉的 380 万

发生了什么

   系统:小额消费信贷的交易反欺诈模型
   规模:日均 240 万笔交易,欺诈率约 0.12%(每天约 2,900 笔)
   管道:特征工程前有一步"去异常值",对 12 个金额类特征做 3σ 处理

一次例行重构里,有人把这一步从「截断到 3σ 边界」改成了「删除整行」—— 理由写在 PR 描述里,看起来还挺合理:"截断会造成边界堆积,影响分布,直接删更干净。"

金额特征的 3σ 上界是 4.3 万元。而真实欺诈的金额分布是这样的:

欺诈类型 占欺诈笔数 金额中位数 超过 4.3 万的比例
盗刷 71% 1.8 万 6%
团伙套现 17% 6.9 万 31%
其他 12% 0.9 万 2%

结果:训练集里的欺诈样本从 8.7 万条降到 6.1 万条, 其中单笔 ≥5 万的大额欺诈只剩下 4%

为什么没被发现(三条,每条都很典型)

   ① 被删的行只占总样本的 0.31%
      —— 任何"删除率"类的粗粒度检查都不会响
      💀 因为分母是 240 万笔正常交易,而欺诈本来就只有 0.12%

   ② 离线 AUC 从 0.947 → 0.941(-0.006)
      —— 落在周与周之间的正常波动区间内,没人多看一眼
      💀 AUC 是全局排序指标,对"最右边那一小撮"极不敏感 ⭐

   ③ 灰度期两周,大额欺诈一天只有几十笔
      —— 样本量根本不足以在两周内看出差别

代价

   上线 5 周后风控月报才发现异常:

   单笔 ≥5 万元的欺诈漏放金额
     上线前:月均  41 万元
     上线后:月均 306 万元
     累计多损失 ≈ 380 万元

   ⭐ 而同期【整体拦截率】只从 2.31% 掉到 2.24%(-0.07 pp)
      —— 在所有告警阈值之内。
      因为大额欺诈只占【笔数】的 1.7%,却占了【金额】的 43%。

💀 这个事故的本质口径错配。 模型按「笔数」优化和监控,损失按「金额」结算。 被删掉的那批样本,在笔数口径下是 0.31% 的噪声, 在金额口径下是 43% 的风险敞口

该补什么

缺失 补上之后
清洗步骤没有分标签统计 任何丢弃数据的地方,先打印极端值的正样本富集倍数(本章那段代码),> 3 就必须人工确认 ⭐⭐
清洗步骤没有留痕 每个清洗规则输出「删了多少行、删了哪些行的 id」,进质量报告(第 8 章
监控只有笔数口径 金额类业务同时看金额加权的指标;损失怎么结算,指标就怎么算 ⭐
默认动作是"删除" 改成打标记(加一列 is_outlier),删不删由下游模型的训练脚本决定 ⭐⭐

最后一条是最省事、也最值钱的改法不要在清洗层做删除,只在清洗层做标记。 清洗层不知道下游要干嘛 —— 风控模型要那些极端值,销量预测模型不要。 同一份数据,两个模型的"异常值"定义就是不一样的。


🧰 五、处置手册:检测出来之后怎么办

① 确认是【错误】
  • 能修 → 修(单位换算、去掉多打的 0、查上游)
  • 不能修但字段可空 → 置为缺失,交给第 5 章处理
  • 不能修且整行不可信 → 删行,⭐ 但要记录 id
② 确认是【真实极端值】
  • 默认动作:留着 + 加一列 is_outlier
  • 模型怕重尾 → 换尺度(log / 分位数变换)
  • 个别算法怕 → 分位截断(winsorize 到 1%/99%)
  • 它自成一类 → 单独建模 / 单独一条业务规则
③ 判断不了
  • 也留着 + 标记,并把它送进抽样人工核查队列
  • ⚠️ 不要"因为不确定所以删掉" —— 那是最坏的选择
💡 三种情况的共同点:能不删就不删,删了一定要留痕。

截断(winsorize)和删除的区别值得单独强调: 截断保留了「这一条存在」和「它很大」这两个信息,只丢掉了「有多大」; 删除把三个信息一起丢了,还顺带改变了样本量和类别比例。 如果非要选一个默认动作,永远选截断,不选删除。

不同模型对极端值的敏感度差很多,这决定了你要不要处理

模型 怕不怕极端值 说明
线性回归 / 逻辑回归 很怕 平方损失被单点主导;系数会被拽走
树模型 / GBDT 基本不怕 只用序关系分裂;极端值和"很大"没区别 ⭐
KNN / K-means / PCA 很怕 全都基于距离,量纲被极端值主导
神经网络 (尤其没归一化时) 梯度爆炸、BN 统计量被带偏

💡 一个立刻能用的结论如果你用的是 GBDT,绝大多数"去异常值"的步骤可以直接删掉。 它带来的收益接近 0,带来的事故风险却是本章那种。

最后,一个必须区分的情况

⚠️ 成片出现的"异常值"不是异常值。 昨天 0 条、今天 3,000 条 —— 这不是数据质量问题, 这是上游变更、口径变更,或者真的出事了。 该做的是查第 8 章的时效性与一致性断言, 以及《模型上线之后》的漂移监控,而不是把这 3,000 条清掉。


🔗 这一章连到哪里

去哪 为什么
第 5 章 · 缺失不是一种东西 「置为缺失」之后的处理在那一章;填法选错会把偏差喂进模型
第 8 章 · 数据质量怎么量 把「极端值比例」「删除率」变成可执行断言和门禁
第 15 章 · 采样偏差 删掉极端值就是一次人为的采样偏差,后果和那一章讲的一模一样
Kaggle 01 · 数据策略与特征工程 竞赛视角对异常值更激进(分位截断随便用)—— 因为竞赛的评测集和训练集同分布,而线上不是
上线之后 06 · 数据漂移与概念漂移 「成片出现的异常值」多半是漂移或上游事故,处理方式完全不同
ML基础 16 · 特征工程基础 log 变换、分位数变换、分箱的具体做法

✅ 检查点

  1. 「异常值」被混成一个词的两种东西分别是什么?各自该怎么处理?
  2. 判断「错还是真」的五个问题是哪些?哪一条能给出确定答案?哪一条在建模时最关键?
  3. 3σ 的两个失效场景是什么?「掩蔽效应」是怎么回事,实测里混入 50 条错误后阈值变成了多少、漏掉了几条?
  4. 干净的对数正态数据上,IQR 会把百分之多少判成异常?取对数之后是多少?这说明什么?
  5. 孤立森林的 contamination 参数意味着什么?它应该被当成判定器还是排序器?
  6. 那个 380 万的事故里,被删的样本只占 0.31%,为什么后果这么大?为什么 AUC 没看出来?
  7. 事故复盘里"最省事也最值钱"的那条改法是什么?为什么清洗层不该做删除?
  8. 截断和删除的区别是什么?为什么默认该选截断?
  9. 哪类模型基本不怕极端值?这对"要不要做去异常值"意味着什么?
  10. 昨天 0 条、今天 3,000 条极端值 —— 该怎么处理?
👀 答案
  1. 录入错误(修 / 删 / 回溯上游)和真实极端事件(留着,而且要重点留着)。在风控、故障预测、医疗、异常检测里,「异常值」和「正样本」是同一批数据,无脑清洗删的是全部信号。
  2. ①物理/业务上可能吗 ②同行其他字段自洽吗 ③换单位就正常了吗 ④时间上孤立还是成片 ⑤和标签什么关系。只有 ① 能给确定答案(年龄 187 岁一定是错),所以先用它;⑤ 在建模时最关键 —— 极端值里正样本率远高于总体,就绝对不能删。
  3. 失效场景:偏态分布(金额、时长、播放量)和掩蔽效应。掩蔽效应是异常值参与了阈值计算,越多就把阈值抬得越高:干净数据 3σ 上界 3,248,混入 50 条错误后变成 21,530(涨 6.6 倍),只抓到 39/50,漏了 11 条。破解办法是迭代 sigma-clip,或改用中位数 + MAD。
  4. 7.71%;取对数后 0.33%。说明对偏态数据,「换个尺度」比「换个算法」有用得多
  5. 它是「你要求它挑出来的比例」,不是「它检测出来的比例」 —— 填 0.05 它一定给你 5%,哪怕数据全干净。所以只能当排序器(按分数排序、人看 Top-N),不能当判定器;而且它不给理由,没法判断"错还是真"。
  6. 因为口径错配:模型按「笔数」优化和监控,损失按「金额」结算。被删的大额欺诈只占笔数的 1.7%,却占金额的 43%。AUC 没看出来是因为AUC 是全局排序指标,对最右边那一小撮极不敏感 —— 只掉了 0.006(0.947→0.941),落在正常波动内;整体拦截率也只掉 0.07pp(2.31%→2.24%),在所有告警阈值之内。累计多损失约 380 万元
  7. 清洗层只做标记(加一列 is_outlier),不做删除,删不删交给下游训练脚本决定。因为清洗层不知道下游要干嘛 —— 风控模型要那些极端值,销量预测模型不要,同一份数据两个模型的"异常值"定义就不一样
  8. 截断保留了「这一条存在」和「它很大」,只丢掉「有多大」;删除把三个信息一起丢了,还改变了样本量和类别比例。所以默认永远选截断。
  9. 树模型 / GBDT 基本不怕(只用序关系分裂)。意味着用 GBDT 时绝大多数"去异常值"步骤可以直接删掉 —— 收益接近 0,事故风险却是本章那种。
  10. 不要清掉。 成片出现不是数据质量问题,是上游变更、口径变更,或者真的出事了,该去查时效性/一致性断言和漂移监控。

🛑 可以停在这里

走神救援

⭐⭐「异常值」混了两种相反的东西:录入错误(该修该删)和真实极端事件(正是你的信号)。💀在风控/故障预测/医疗/异常检测里,异常值和正样本是同一批数据,一个默认的 3σ 清洗步骤删的就是全部信号 —— 它危险就危险在长得像 dropna() 一样人畜无害。判断「错还是真」问五个问题:①物理/业务上可能吗(只有这条给确定答案)②同行其他字段自洽吗 ③换单位就正常了吗(除以 100 落回正常 = 分/元混用;正好 32767/65535 = 溢出)④孤立还是成片(成片出现的不是异常值,是上游变更或真出事了,不许清掉)⑤⭐⭐和标签什么关系 —— 极端值的正样本富集倍数 >3 就绝不能删,这三行代码该跑在每一个丢弃数据的地方之前。检测方法各有失效场景:3σ 怕偏态、更怕⭐⭐掩蔽效应(异常值参与阈值计算,越多阈值被抬得越高:干净数据上界 3,248,混进 50 条错误后变 21,530,涨 6.6 倍,只抓到 39/50)——破解靠迭代 sigma-clip 或改用中位数+MAD;💀IQR 在干净的对数正态数据上就会把 7.71% 判成异常,而先取对数再判只有 0.33% ——对偏态数据换尺度比换算法有用得多;⚠️孤立森林的 contamination 是"你要它挑多少"不是"它查出多少",它只是排序器不是判定器,而且不给理由。💀事故:反欺诈管道把 3σ「截断」改成「删除整行」,上界 4.3 万,而团伙套现类欺诈有 31% 超过它 —— 欺诈样本从 8.7 万降到 6.1 万,大额欺诈只剩 4%。没被发现是因为被删的只占总样本 0.31%AUC 只掉 0.006(全局排序指标对最右边一小撮极不敏感)、灰度两周样本量根本不够。5 周后月报才看出来:单笔 ≥5 万的漏放从 41 万/月涨到 306 万/月,累计多损失 380 万,而整体拦截率只掉 0.07pp(2.31%→2.24%),在所有阈值之内。根因是⭐口径错配 —— 模型按笔数优化,损失按金额结算:大额欺诈占笔数 1.7%、占金额 43%。改法四条,最值钱的是⭐⭐清洗层只打标记不做删除(清洗层不知道下游要干嘛,风控要极端值、销量预测不要)。处置手册:确认是错就修/置缺失/删行留 id;确认是真就留着+标记,模型怕重尾就换尺度或分位截断;判断不了也留着,⚠️「因为不确定所以删掉」是最坏的选择。⭐截断永远优于删除(截断只丢"有多大",删除连"存在"和样本量、类别比例一起改了)。最后:GBDT 基本不怕极端值(只用序关系分裂),所以用 GBDT 时大多数去异常值步骤可以直接删掉;线性模型、KNN/K-means/PCA、神经网络才真的怕。

下一节 👉 08-数据质量怎么量.md

打卡记录保存在你的浏览器里,首页能看到总进度