📑 本页目录(点开跳转)
05 · 缺失不是一种东西
⏱ 70 分钟 | ⭐⭐ 填错了,偏差就直接进模型了
🎯 一句话
df.fillna(df.mean()) 是这份教程里最贵的一行代码。
缺失有三种成因完全不同的类型,需要三种完全不同的处理。
用错了,你不是「损失了一点信息」——
你是亲手造了一个偏差,然后把它喂进了模型。
🧨 一、三种成因:全看「这个值为什么没有」
问一句话就够了:这一行的这个字段,是【谁】决定了它不出现?
① MCAR 完全随机缺失
缺不缺 和 任何东西 都无关
例:网络丢包、随机抽样只调查了 30% 的人、设备随机故障
⭐ 现实中非常罕见。大多数被当成 MCAR 的其实不是
② MAR 随机缺失(准确说是「条件随机」)
缺不缺 只取决于【你已经观测到的其他字段】
例:老年用户填邮箱的比例低 —— 取决于「年龄」,而年龄你有
某个渠道的埋点没上报某字段 —— 取决于「渠道」,而渠道你有
③ MNAR 非随机缺失
缺不缺 取决于【这个值本身】,或取决于【你没观测到的东西】
例:高收入的人不愿意填收入
病重的病人没能做完那项检查
系统在高负载时丢日志 —— 而"高负载"恰恰是故障样本的特征 💀
⭐⭐ MAR 里的那个 R 骗了所有人。 MAR 不是「随机」,是「在你已经看到的东西的条件下随机」。 这个区别决定了:MAR 下正确的方法能把偏差修回来,MNAR 下修不回来。
三者最本质的区别,是「能不能从数据里验证」:
| 缺失取决于 | 能从数据里验证吗 | 正确处理后能无偏吗 | |
|---|---|---|---|
| MCAR | 什么都不取决于 | ✅ 可以证伪(见下面的检验) | ✅ |
| MAR | 已观测的字段 | ⚠️ 不能证明,只能证伪 MCAR | ✅ |
| MNAR | 缺失的值本身 / 未观测的东西 | ❌ 原则上不可能 💀 | ❌ 任何填充都有偏 |
💀 为什么 MAR 和 MNAR 无法用数据区分: 区分它们需要知道「缺掉的那些值长什么样」—— 而那正是缺掉的东西。 所以判断机制这件事,永远是业务判断,不是统计判断。 数据能帮你做的只有一件:排除 MCAR。
🧪 二、实跑:同一份数据,七种处理,系数偏差差 40 倍
真实模型是 y = 2.0 + 1.5·x1 − 0.8·x2 + ε,n = 20000,
x1(会缺失,比如「月收入」)和 x2(完整观测,比如「注册天数」)的相关系数 0.60,
三种机制各造出约 30% 的缺失。
import numpy as np
rng = np.random.default_rng(42)
N, B1, B2 = 20000, 1.5, -0.8
x2 = rng.normal(0, 1, N)
x1 = 0.6 * x2 + np.sqrt(1 - 0.36) * rng.normal(0, 1, N)
e = rng.normal(0, 1, N)
y = 2.0 + B1 * x1 + B2 * x2 + e
sig = lambda z: 1 / (1 + np.exp(-z))
MECH = {"MCAR": np.full(N, 0.30),
"MAR": sig(-1.05 + 1.6 * x2), # 只依赖【已观测的】x2
"MNAR": sig(-1.30 + 1.2 * x1 + 1.2 * e)} # 依赖它自己 + y 里说不清的那部分
def ols(X, yy):
A = np.column_stack([np.ones(len(yy)), X])
return np.linalg.lstsq(A, yy, rcond=None)[0]
def compare(m):
obs, out = ~m, {}
out["完整数据(上帝视角)"] = ols(np.column_stack([x1, x2]), y)
xm = x1.copy(); xm[m] = x1[obs].mean()
out["均值填充"] = ols(np.column_stack([xm, x2]), y)
out["均值填充+缺失指示"] = ols(np.column_stack([xm, x2, m.astype(float)]), y)
out["整行删除"] = ols(np.column_stack([x1[obs], x2[obs]]), y[obs])
a = ols(x2[obs].reshape(-1, 1), x1[obs])
xr = x1.copy(); xr[m] = a[0] + a[1] * x2[m]
out["回归填充(只用x2)"] = ols(np.column_stack([xr, x2]), y)
res = x1[obs] - (a[0] + a[1] * x2[obs])
xs = x1.copy(); xs[m] = a[0] + a[1] * x2[m] + rng.normal(0, res.std(), m.sum())
out["随机回归填充(只用x2)"] = ols(np.column_stack([xs, x2]), y)
b = ols(np.column_stack([x2[obs], y[obs]]), x1[obs]) # ⭐ 填充模型里带上 y
pred = b[0] + b[1] * x2 + b[2] * y
r2 = (x1[obs] - pred[obs]).std()
xy = x1.copy(); xy[m] = pred[m] + rng.normal(0, r2, m.sum())
out["随机回归填充(x2+y)"] = ols(np.column_stack([xy, x2]), y)
return out
for k, p in MECH.items():
print(k, {n: round(b[1], 4) for n, b in compare(rng.random(N) < p).items()})
MAR 的结果(真值 b1 = 1.500,b2 = −0.800,缺失率 32.9%):
| 处理方式 | b1 |
偏差 | b2 |
偏差 |
|---|---|---|---|---|
| 完整数据(上帝视角) | +1.4994 | −0.0% | −0.8002 | −0.000 |
| 均值填充 💀 | +1.2417 | −17.2% | −0.2676 | +0.532 |
| 均值填充 + 缺失指示 | +1.3318 | −11.2% | −0.4536 | +0.346 |
| 整行删除 | +1.4970 | −0.2% | −0.7945 | +0.006 |
| 回归填充(只用 x2) | +1.4970 | −0.2% | −0.7890 | +0.011 |
| 随机回归填充(只用 x2) ⚠️ | +0.9981 | −33.5% | −0.4994 | +0.301 |
| 随机回归填充(x2 + y) ⭐ | +1.4900 | −0.7% | −0.7894 | +0.011 |
MNAR 的结果(缺失率 29.6%):
| 处理方式 | b1 |
偏差 | b2 |
偏差 |
|---|---|---|---|---|
| 均值填充 | +1.0560 | −29.6% | −0.2861 | +0.514 |
| 均值填充 + 缺失指示 | +1.2077 | −19.5% | −0.5414 | +0.259 |
| 整行删除 ⭐ | +1.3601 | −9.3% | −0.7980 | +0.002 |
| 回归填充(只用 x2) | +1.3601 | −9.3% | −0.6480 | +0.152 |
⭐⭐ 这两张表里有三个反直觉的结论,值得单独记住:
① 偏差会传染给你根本没动过的特征。 均值填充只碰了
x1,却把x2的系数从 −0.80 打到 −0.27(错了 66%)—— 比x1自己的 17% 严重得多。因为填充改变的是相关结构,而不只是那一列。② 看起来最"讲究"的方法,偏得最厉害。 随机回归填充恢复了方差(填充后
std = 1.004,原始1.003), 结果b1偏了 −33.5%,比无脑均值填充还糟一倍。 原因只有一个:填充模型里没有y。 加上y之后立刻回到 −0.7%。③ 同一个方法,MAR 下无偏、MNAR 下有偏。 整行删除在 MAR 下是 −0.2%,在 MNAR 下是 −9.3%—— 而你无法从数据里分辨自己处在哪一种。
为什么「填充模型必须带上 y」:填充的目的不是「猜一个像样的数」,
而是在给定所有已知信息(包括标签)的条件下,从 x1 的后验分布里抽一个样本。
把 y 排除在外,等于对模型宣布:「这些行的 x1 和 y 没关系」——
于是斜率被系统性地拉平。
⚠️ 但这里有个巨大的陷阱:填充模型用了
y,而推理时你没有y。 这条只适用于估计系数 / 做统计推断。 做预测建模时,正确的做法是:不填,改用原生支持缺失的模型 + 缺失指示。
🩸 三、均值填充的五条罪状
① 压缩方差 x1 的 std:1.003 → 0.781(缺 33% 时)
→ 所有依赖尺度的东西全错:标准误、置信区间、正则化强度
② 稀释相关 corr(x1, x2):0.599 → 0.376
→ 特征之间的关系被抹平,模型学到的结构是假的
③ 造一个假尖峰 在分布正中间堆起 33% 的样本
→ 树模型会在均值两侧各切一刀,学出一个不存在的规律 ⚠️
④ 抹掉「缺失」这条信息 填完之后,谁也不知道哪些是原始值哪些是编的
⑤ 训练/推理不一致的高发地 均值必须来自【训练折】,
用全量算就是泄漏,线上重新算就是漂移 ⭐
⭐ 均值填充唯一的优点是「不会报错」。 它在教程里流行,是因为它让代码往下走 —— 而不是因为它对。
🔦 四、缺失本身是信息
这一节可能是全章唯一"立刻能涨指标"的部分。
实跑(信贷违约,40000 样本,收入字段 MNAR 缺失):
收入缺失率 30.4%
缺失组违约率 23.98%
非缺失组违约率 13.71% ⭐ 1.75 倍
| 做法 | 测试集 AUC |
|---|---|
| 均值填充,不带缺失指示 | 0.6540 |
| 均值填充 + 缺失指示 ⭐ | 0.6825(+0.0285) |
| 填 −999(树模型式哨兵值)+ 逻辑回归 ⚠️ | 0.6292(比什么都不做还差) |
| 整行删除后训练,在全量人群上评估 | 0.6539 |
⭐
+0.0285的 AUC,来自一列 0/1,成本一行代码。 一句可以直接执行的规则:任何缺失率 > 1% 的字段,自动生成一列_is_missing。⚠️ 哨兵值
-999那一行必须看懂: 它对树模型是可行的(树能切一刀把 −999 单独分成一支), 对线性模型 / 神经网络是灾难(这里 AUC 从 0.654 掉到 0.629)—— 因为线性模型会认为「−999 比 0 小很多,所以风险线性地低很多」。 同一个技巧,换个模型族就从可行变成有害。⚠️ 缺失指示的反向坑:它可能就是泄漏。 如果一个字段「在预测时刻必然缺失、在标签发生后才被填上」—— 比如「实际还款日期」在申请时当然是空的 —— 那么
_is_missing就是标签的一个副本(第 14 章的代理变量)。 加指示之前必须问:这个字段是什么时候被写进去的?
🛑 读到这里可以停 —— 前半章讲完了(约 23 分钟)。 后半章还有:能从数据里查出什么:一个可以直接跑的机制诊断 · MNAR 怎么办:你修不好它,但你能量化它 · 怎么判断属于哪一类:五个业务追问 · 方法选择与五条工程铁律 · 事故复盘:一行
fillna(mean),4100 万的坏账 回来的时候不用重读,直接从下一节接着看就行。
🔬 五、能从数据里查出什么:一个可以直接跑的机制诊断
思路:用「已观测到的东西」去预测「这一行会不会缺」,看能预测到什么程度。
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
def missing_mechanism_probe(X_obs, is_missing, y=None):
"""X_obs: 其他【完整观测】的特征;is_missing: 目标字段的缺失指示;y: 标签。
返回两个 AUC:只用特征预测缺失,以及加上标签之后预测缺失。"""
def auc(M):
lr = LogisticRegression(max_iter=2000).fit(M, is_missing)
return roc_auc_score(is_missing, lr.predict_proba(M)[:, 1])
a1 = auc(X_obs)
a2 = auc(np.column_stack([X_obs, y])) if y is not None else None
return a1, a2 # ⭐ a2 明显大于 a1 → MNAR 的强信号
# 实跑结果(同一份数据,三种机制各造 30% 缺失):
# 只用特征 加上标签
# MCAR 0.505 0.506 → 与「完全随机」一致
# MAR 0.829 0.829 → 缺失能被已观测特征解释,加 y 无增益
# MNAR 0.642 0.837 ⭐⭐ 加上 y 之后暴涨 0.195 → 强烈提示 MNAR
⭐⭐ 这个诊断的读法:
AUC ≈ 0.5→ 可以按 MCAR 处理(但仍不能排除 MNAR,因为 MNAR 依赖的是你看不见的值)。AUC明显 > 0.5 且加y无增益 → 像 MAR,把那些解释力强的特征放进填充模型。 加上y之后AUC明显上涨 → 缺失和标签直接相关,按 MNAR 对待。 ⚠️但它永远只能证伪,不能证明。 一个
AUC = 0.51的字段, 完全可能是「高收入的人不填收入」而其他字段恰好都测不出收入 —— 那是彻头彻尾的 MNAR。 代码只能给证据,判断还是得靠「这个值为什么没有」。
🕳️ 六、MNAR 怎么办:你修不好它,但你能量化它
MNAR 的定义就是「缺的那部分和留下的那部分,在给定 X 之后长得不一样」, 而所有填充方法的前提恰恰是「长得一样」。 所以:
❌ 不存在一个"更高级的填充算法"能解决 MNAR
MICE、KNN 填充、深度学习填充 —— 全都假设 MAR
✅ 能做的只有三件事:
① 把 MNAR 变成 MAR:去收集那个解释缺失的变量 ⭐⭐ 最根本
② 做敏感性分析:报告一个区间,而不是一个数 ⭐ 最实用
③ 对缺失机制显式建模(选择模型 / Heckman)——依赖强假设,慎用
② 长什么样:delta 敏感性分析(实跑,MNAR,真值 b1 = 1.500):
在条件均值填充的基础上,人为给缺失值加一个偏移 delta("缺的人比预测值高/低多少"):
delta |
−1.00 | −0.50 | −0.25 | 0.00 | +0.25 | +0.50 |
|---|---|---|---|---|---|---|
估出的 b1 |
0.381 | 0.859 | 1.126 | 1.359 | 1.515 | 1.575 |
| 偏差 | −74.6% | −42.8% | −24.9% | −9.4% | +1.0% | +5.0% |
⭐ 敏感性分析的价值不在于找到"对的 delta"——数据永远不会告诉你它是多少。 价值在于你能说出这句话: 「只要不填的人的收入比模型预测值低不超过 0.25 个标准差,结论就不变。」 这是一句可以拿去汇报、可以让业务方去论证的话。 而「我做了填充」不是一句话,是一个隐藏假设。
① 更值钱:与其纠结怎么填,不如去问「为什么缺」,然后把那个原因变成一列数据。
收入缺失 → 加一列「是否有社保记录」「申请渠道」「是否自雇」
检查缺失 → 加一列「就诊科室」「入院时的分诊等级」
日志缺失 → 加一列「当时的机器负载」「SDK 版本」「网络类型」 ⭐
⭐ 只要新加的这几列能"解释掉"缺失,MNAR 就变成了 MAR,
而 MAR 是有正确解法的。
这是数据工作里少见的一种情况:花钱采一列新数据,
比换十种算法都管用。
🧭 七、怎么判断属于哪一类:五个业务追问
① 是「没发生」还是「没记录」? ⭐⭐ 最常见的误判
0 单用户的"客单价"不是缺失,是【不存在】
→ 不该填,该给这批人一个单独的分支或单独的模型
② 谁决定了它不出现:系统 / 用户 / 采集端 / 第三方?
③ 那个决定,和这个值本身有关吗?
"用户嫌麻烦没填" → 可能 MCAR/MAR
"填了会显得不好看所以没填" → MNAR
④ 那个决定,和标签有关吗?
"系统高负载时丢日志",而故障恰好发生在高负载 → 严重 MNAR 💀
⑤ 推理时这个字段也会缺吗?缺失率一样吗? ⭐⭐
训练集缺 5%、线上缺 40% —— 这不是缺失问题,是【分布漂移】
([模型上线之后 05](../模型上线之后/05-该监控什么.html))
| 场景 | 机制 | 该怎么做 |
|---|---|---|
| 埋点某渠道没上报该字段 | MAR(依赖「渠道」) | 渠道放进填充模型,或按渠道分层 |
| 第三方数据源只覆盖部分城市 | 不是缺失 ⭐ | 覆盖与否是两个人群,分层或分模型 |
| 高收入不填收入 | MNAR | 缺失指示 + 敏感性分析 + 找辅助变量 |
| 新用户没有历史行为 | 不是缺失 | 「无历史」是真实状态,单独一档 |
| 高负载时丢日志 | MNAR 💀 | 把负载采集下来,转成 MAR |
🧰 八、方法选择与五条工程铁律
| 方法 | 什么时候用 | 什么时候会坏 |
|---|---|---|
| 原生支持缺失的模型 ⭐⭐ | LightGBM / XGBoost,预测任务首选 | 需要可解释系数时不适用 |
| 缺失指示 + 任意填充 | 几乎永远该加 | 缺失「事后才知道」时会泄漏 ⚠️ |
| 整行删除 | MCAR/MAR 且缺失率低(< 5%) | MNAR 下有偏;缺失率高时样本大量流失 |
| 多重填充(MICE) | 要做统计推断、要置信区间 | 假设 MAR;预测场景不适用 |
| 均值/中位数填充 | 缺失率 < 1% 且确认无所谓时 | 其余所有情况 💀 |
| 常数哨兵值(−999) | 只对树模型 | 线性模型 / NN 上是灾难(实跑 0.654 → 0.629) |
工程铁律:
① 填充统计量只能从【训练折】算,并放进 pipeline 里
→ 用全量算 = 泄漏([第 14 章]),CV 里每折重算才对 ⭐
② 训练和推理必须用同一份填充代码 + 同一份统计量(不是"同样的逻辑")
③ 缺失率必须在【填充之前】采集进监控 ⭐⭐ —— 这条是下面那个事故的核心
④ 缺失率漂移是最早的故障信号:它比业务指标早十几个小时
⑤ 任何缺失率 > 1% 的字段,自动生成 `_is_missing` 列
💀 九、事故复盘:一行 fillna(mean),4100 万的坏账
发生了什么
系统:某消费金融的额度审批模型
特征:公积金月缴存额(来自第三方数据源 A)
⚠️ 数据源 A 只覆盖 6 个一线城市。其他城市这一列全是 NULL。
训练集里这个字段的缺失率:47%
而数据管道里有这么一行"通用清洗":
df = df.fillna(df.mean())
于是:所有非一线城市的申请人,公积金被填成了
【一线城市在职人群的平均值:2180.00 元】
为什么没被发现
💀 最狠的一条:缺失率监控是【全绿】的
因为 fillna 发生在管道里,数据流到监控那一步时,缺失率已经是 0 ⭐⭐
✅ 整体 AUC 0.762,和上一版持平
✅ 特征重要性里「公积金」排第 3 —— 看起来非常合理
✅ 分布监控也是绿的(均值没变,因为填的就是均值)💀
💀 模型实际学到的是:
「公积金 = 2180.00 恰好」→ 低风险
而一线城市样本整体违约率本来就低
= 模型把「不在一线城市」学成了「有一份不错的工作」
代价
分城市线拆开(事后才做):
一线城市 AUC 0.781 (正常)
非一线城市 AUC 0.583 ⚠️ 接近随机
上线 9 周:
非一线城市通过率比预期高 19 个百分点
M3+ 逾期率:非一线 4.7% vs 一线 1.6% (2.9 倍)
期间非一线放款 3.8 亿元,事后测算多批了约 2.6 万笔本该拒的
预计净坏账增量 ≈ 4100 万元
发现方式:第 9 周资金方(银行)做贷后审计,问了一句
「为什么 47% 的申请人公积金缴存额精确等于 2180.00 元?」⭐
—— 一个人肉眼看出来的,不是任何一条规则
修复:改成「缺失即缺失」+ 缺失指示 + 按覆盖情况分层重训 = 3 周
非一线 AUC 0.583 → 0.694,其中约 0.071 直接来自缺失指示那一列
该补什么
| 缺失 | 补上之后 |
|---|---|
| 填充发生在监控之前 | 缺失率必须在填充前采集;fillna 之后的监控毫无意义 ⭐⭐ |
管道里有全局无条件 fillna |
禁止;填充必须是逐字段的显式决策,并写进契约 |
| 没有缺失指示 | 缺失率 > 1% 的字段自动生成 _is_missing(本例里它值 0.071 AUC)⭐ |
| 把「数据源不覆盖」当成缺失 | ⭐⭐ 覆盖与不覆盖是两个人群,该分层建模,而不是填一个数把它们抹平 |
| 只看整体 AUC | 按数据源覆盖情况分组看指标(0.781 vs 0.583) |
| 最高频单值占比没监控 | 一个连续字段有 47% 的行取同一个值 —— 第 4 章那条规则会当场报警 |
💀 两条通用教训: ① 当填充发生在监控之前,你的缺失率监控监控的是填充逻辑,不是数据。 这不是监控失灵,是监控被装在了错误的位置。 ② 一个缺失率 47% 的字段,不是「有点脏的特征」,是两个人群的分界线。 第一反应不该是「怎么填」,而是 「缺的这批人和不缺的那批人,是不是本来就是两种人?」
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 第 4 章 脏数据的十种形态 | 哨兵值先转成 NULL,才轮到这一章;「最高频单值占比」能抓住本章的事故 |
| 第 7 章 异常值是错还是真 | 填充造出的假尖峰会被异常检测当成"最正常的一批" |
| 第 14 章 数据泄漏的七种来源 | 填充统计量必须逐折算;缺失指示可能就是代理变量 |
| 模型上线之后 05 该监控什么 | 缺失率漂移是最早的输入层信号,比业务指标早十几小时 |
| 机器学习与深度学习基础 16 特征工程基础 | 填充在整个特征流水线里的位置 |
| 机器学习的数学原理 13 EM与高斯混合 | 「填充 = 从后验分布抽样」的原理版:EM 就是「缺失/隐变量」的正式框架,那一章把「缺失值填补」明写成它的应用之一 |
| 机器学习的数学原理 01 最大似然 | 上面那句「必须带上 y」的根:选损失函数 = 选你对噪声的信念,所以填充模型也必须建成「以其他特征和 y 为条件」的那个分布 |
| Kaggle 竞赛方法论 01 数据策略与特征工程 | 竞赛里缺失处理的实操套路 |
✅ 检查点
- MCAR / MAR / MNAR 各自取决于什么?MAR 里的 "R" 为什么骗人?
- 三种机制里,哪些能从数据里验证、哪些不能?为什么 MAR 和 MNAR 原则上无法区分?数据唯一能帮你做的是什么?
- MAR 实跑里,均值填充把
b1和b2分别打偏了多少?为什么说「偏差会传染给你没动过的特征」? - 随机回归填充恢复了方差,为什么反而偏了 −33.5%?加上什么之后回到 −0.7%?为什么这条不适用于预测建模?
- 整行删除在 MAR 和 MNAR 下的偏差分别是多少?这个对比说明了什么?
- 均值填充的五条罪状是什么?它唯一的优点是什么?
- 缺失指示带来了多少 AUC?为什么
-999对树模型可行、对线性模型是灾难(给出实跑数字)?缺失指示什么时候反而是泄漏? - 机制诊断里三种机制的 AUC 分别是多少?「加上
y之后 AUC 暴涨」意味着什么?这个诊断的根本局限是什么? - MNAR 能做的三件事是什么?delta 敏感性分析的价值到底在哪?
- 那个 4100 万的事故:字段是什么、缺失率多少、被填成了什么、为什么缺失率监控是全绿的、分城市 AUC 是多少、发现方式是什么、修复后缺失指示贡献了多少?
👀 答案
- MCAR:缺不缺和任何东西都无关;MAR:只取决于已观测到的其他字段(老年用户少填邮箱 → 取决于年龄,而年龄你有);MNAR:取决于这个值本身或你没观测到的东西(高收入不填收入、高负载丢日志)。"R" 骗人是因为⭐⭐MAR 不是「随机」,是「在你已经看到的东西的条件下随机」——这个区别决定了 MAR 能修回来、MNAR 修不回来。
- MCAR 可以证伪(用已观测特征预测缺失,AUC 明显 > 0.5 就不是 MCAR);MAR 不能被证明,只能证伪 MCAR;MNAR 原则上不可能从数据验证。因为区分 MAR 和 MNAR 需要知道缺掉的那些值长什么样,而那正是缺掉的东西。数据唯一能做的是排除 MCAR,判断机制永远是业务判断。
b1从 1.500 → 1.2417(−17.2%);b2从 −0.800 → −0.2676(错了 66%,偏差 +0.532)。均值填充只碰了x1,却把没动过的x2打得更惨 —— ⭐因为填充改变的是特征之间的相关结构,而不只是那一列。- 因为填充模型里没有
y:把y排除在外等于告诉模型「这些行的x1和y没关系」,于是这些行把x1–y的关系稀释掉,斜率被系统性拉平。加上y之后从 −33.5% 回到 −0.7%。不适用于预测建模是因为⚠️推理时你没有y;预测场景正确的做法是不填,改用原生支持缺失的模型 + 缺失指示。 - MAR 下 −0.2%,MNAR 下 −9.3%。说明⭐⭐同一个方法在两种机制下一个无偏一个有偏,而你无法从数据里分辨自己处在哪一种 —— 这就是为什么必须靠业务判断。
- ①压缩方差(std 1.003 → 0.781)②稀释相关(corr 0.599 → 0.376)③在分布正中间造一个假尖峰(树模型会围着它切两刀)④抹掉「缺失」这条信息⑤训练/推理不一致的高发地(均值必须来自训练折)。唯一的优点是⭐它不会报错 —— 它流行是因为它让代码往下走,不是因为它对。
- 缺失指示把 AUC 从 0.6540 提到 0.6825,+0.0285,成本是一列 0/1。
-999对树模型可行是因为树能切一刀把它单独分成一支;对线性模型是灾难是因为模型会认为「−999 比 0 小很多所以风险线性地低很多」,实跑 0.654 → 0.629,比什么都不做还差。当字段「在预测时刻必然缺失、标签发生后才被填上」(比如实际还款日期),缺失指示就是标签的副本 = 代理变量泄漏。 - MCAR 0.505 / MAR 0.829 / MNAR 用特征 0.642、加 y 后 0.837。加
y后暴涨(+0.195)说明缺失和标签直接相关,按 MNAR 对待。根本局限:⭐它只能证伪,不能证明 ——AUC = 0.51完全可能是彻头彻尾的 MNAR,只是其他字段恰好测不出那个值。 - ①⭐⭐把 MNAR 变成 MAR:去采集那个解释缺失的变量(社保记录、分诊等级、机器负载),花钱采一列新数据比换十种算法管用;②⭐敏感性分析:报区间不报点估计;③对缺失机制显式建模(Heckman/选择模型),依赖强假设。敏感性分析的价值不是找到「对的 delta」(数据永远不会告诉你),而是能说出「只要不填的人比预测值低不超过 0.25 个标准差,结论就不变」——这是一句可以拿去汇报的话,而「我做了填充」只是一个隐藏假设。
- 字段是公积金月缴存额,来自只覆盖 6 个一线城市的第三方数据源,缺失率 47%,被管道里一行
df.fillna(df.mean())填成了 2180.00 元。缺失率监控全绿是因为💀⭐⭐填充发生在管道里,数据流到监控那一步时缺失率已经是 0 —— 监控被装在了错误的位置;分布监控也绿,因为填的就是均值。分城市 AUC:一线 0.781,非一线 0.583(接近随机),整体 0.762 完全正常。发现方式:第 9 周资金方审计时问「为什么 47% 的申请人公积金精确等于 2180.00 元」,一个人肉眼看出来的。代价:非一线通过率高 19pp、M3+ 逾期 4.7% vs 1.6%、放款 3.8 亿、多批约 2.6 万笔、坏账增量约 4100 万。修复后非一线 AUC 0.583 → 0.694,其中约 0.071 直接来自缺失指示那一列。
🛑 可以停在这里
⚡ 走神救援
⭐⭐缺失有三种成因,需要三种处理:MCAR(缺不缺和任何东西无关,现实中罕见)、MAR(只取决于已观测的其他字段)、MNAR(取决于这个值本身或你没观测到的东西)。⭐⭐MAR 里的 "R" 骗人——它不是「随机」,是「在你已看到的东西的条件下随机」。💀MAR 和 MNAR 原则上无法用数据区分,因为区分它们需要知道缺掉的值长什么样,而那正是缺掉的东西;数据唯一能做的是排除 MCAR,判断机制永远是业务判断。实跑(真值 b1=1.500、b2=−0.800、缺 33%):MAR 下均值填充 b1=1.2417(−17.2%),而没动过的 b2 从 −0.800 被打到 −0.2676(错 66%)⭐——偏差会传染给你根本没碰的特征,因为填充改变的是相关结构;整行删除和回归填充都只偏 −0.2%。⚠️最反直觉的一条:随机回归填充恢复了方差(std 1.004 vs 原始 1.003),却偏了 −33.5%,比无脑均值填充还糟一倍——原因只有一个,填充模型里没有 y;加上 y 立刻回到 −0.7%。但预测建模不能用这招,因为推理时你没有 y——预测场景的正解是不填,用原生支持缺失的模型 + 缺失指示。MNAR 下:均值填充 −29.6%,整行删除 −9.3%(同一个方法在 MAR 下是 −0.2%)⭐⭐——而你无法分辨自己在哪一种。均值填充五条罪状:压缩方差(1.003→0.781)、稀释相关(0.599→0.376)、在分布正中间造假尖峰、抹掉缺失这条信息、训练推理不一致高发地;⭐它唯一的优点是不会报错。⭐缺失本身是信息:实跑信贷违约,缺失组违约率 23.98% vs 非缺失 13.71%(1.75 倍),加一列 0/1 缺失指示 AUC 0.6540 → 0.6825(+0.0285);⚠️填 −999 对树模型可行、对线性模型是灾难(0.654 → 0.629,比不做还差);⚠️反向坑:若字段是「标签发生后才被填上」的,缺失指示就是代理变量泄漏。机制诊断:用已观测特征预测「这行会不会缺」——MCAR 0.505 / MAR 0.829 / MNAR 用特征 0.642 但加上 y 暴涨到 0.837⭐⭐「加 y 后 AUC 暴涨」是 MNAR 的强信号;但它只能证伪不能证明。MNAR 只能做三件事:⭐⭐把 MNAR 变成 MAR(去采集解释缺失的那个变量:社保记录、分诊等级、机器负载)、delta 敏感性分析(实跑 delta 从 −1.0 到 +0.5,b1 从 0.381 到 1.575)、显式建模。⭐敏感性分析的价值不是找到对的 delta,而是能说出「只要低不超过 0.25 个标准差结论就不变」——这是可汇报的,而「我做了填充」只是个隐藏假设。判断五问:是「没发生」还是「没记录」(0 单用户的客单价不是缺失是不存在)、谁决定的、那个决定和值本身有关吗、和标签有关吗(高负载丢日志而故障恰在高负载 💀)、推理时缺失率一样吗(训练缺 5% 线上缺 40% 是漂移不是缺失)。💀事故:额度模型的「公积金月缴存额」来自只覆盖 6 个一线城市的第三方源,缺失率 47%,被管道里一行
df.fillna(df.mean())填成 2180.00 元 —— 模型于是把「不在一线城市」学成了「有份不错的工作」。⭐⭐缺失率监控全绿,因为填充发生在监控之前,数据流到监控时缺失率已经是 0;分布监控也绿(填的就是均值);整体 AUC 0.762 正常,分城市拆开才看到一线 0.781、非一线 0.583(接近随机)。9 周里非一线通过率高 19pp、M3+ 逾期 4.7% vs 1.6%、放款 3.8 亿、多批约 2.6 万笔、坏账增量约 4100 万;发现方式是资金方审计时肉眼问「为什么 47% 的人公积金精确等于 2180.00 元」。修复后非一线 AUC 0.583 → 0.694,其中 0.071 来自缺失指示那一列。两条通用教训:填充发生在监控之前时,你的缺失率监控监控的是填充逻辑不是数据;缺失率 47% 的字段不是「有点脏的特征」,是两个人群的分界线——第一反应不该是「怎么填」,而是「缺的这批人和不缺的那批人,是不是本来就是两种人」。五条工程铁律:填充统计量只从训练折算并放进 pipeline、训练推理同一份代码同一份统计量、⭐⭐缺失率必须在填充前采集、缺失率漂移是最早的故障信号、任何缺失率 > 1% 的字段自动生成_is_missing。
下一节 👉 06-重复与实体解析.md