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