🏠 总目录📚 本教程 05 · 缺失不是一种东西
📑 本页目录(点开跳转)

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 = 20000x1(会缺失,比如「月收入」)和 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.500b2 = −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 排除在外,等于对模型宣布:「这些行的 x1y 没关系」—— 于是斜率被系统性地拉平。

⚠️ 但这里有个巨大的陷阱:填充模型用了 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 数据策略与特征工程 竞赛里缺失处理的实操套路

✅ 检查点

  1. MCAR / MAR / MNAR 各自取决于什么?MAR 里的 "R" 为什么骗人?
  2. 三种机制里,哪些能从数据里验证、哪些不能?为什么 MAR 和 MNAR 原则上无法区分?数据唯一能帮你做的是什么?
  3. MAR 实跑里,均值填充把 b1b2 分别打偏了多少?为什么说「偏差会传染给你没动过的特征」?
  4. 随机回归填充恢复了方差,为什么反而偏了 −33.5%?加上什么之后回到 −0.7%?为什么这条不适用于预测建模?
  5. 整行删除在 MAR 和 MNAR 下的偏差分别是多少?这个对比说明了什么?
  6. 均值填充的五条罪状是什么?它唯一的优点是什么?
  7. 缺失指示带来了多少 AUC?为什么 -999 对树模型可行、对线性模型是灾难(给出实跑数字)?缺失指示什么时候反而是泄漏?
  8. 机制诊断里三种机制的 AUC 分别是多少?「加上 y 之后 AUC 暴涨」意味着什么?这个诊断的根本局限是什么?
  9. MNAR 能做的三件事是什么?delta 敏感性分析的价值到底在哪?
  10. 那个 4100 万的事故:字段是什么、缺失率多少、被填成了什么、为什么缺失率监控是全绿的、分城市 AUC 是多少、发现方式是什么、修复后缺失指示贡献了多少?
👀 答案
  1. MCAR:缺不缺和任何东西都无关;MAR:只取决于已观测到的其他字段(老年用户少填邮箱 → 取决于年龄,而年龄你有);MNAR:取决于这个值本身你没观测到的东西(高收入不填收入、高负载丢日志)。"R" 骗人是因为⭐⭐MAR 不是「随机」,是「在你已经看到的东西的条件下随机」——这个区别决定了 MAR 能修回来、MNAR 修不回来。
  2. MCAR 可以证伪(用已观测特征预测缺失,AUC 明显 > 0.5 就不是 MCAR);MAR 不能被证明,只能证伪 MCARMNAR 原则上不可能从数据验证。因为区分 MAR 和 MNAR 需要知道缺掉的那些值长什么样,而那正是缺掉的东西。数据唯一能做的是排除 MCAR,判断机制永远是业务判断。
  3. b1 从 1.500 → 1.2417(−17.2%)b2 从 −0.800 → −0.2676(错了 66%,偏差 +0.532)。均值填充只碰了 x1,却把没动过的 x2 打得更惨 —— ⭐因为填充改变的是特征之间的相关结构,而不只是那一列
  4. 因为填充模型里没有 y:把 y 排除在外等于告诉模型「这些行的 x1y 没关系」,于是这些行把 x1–y 的关系稀释掉,斜率被系统性拉平。加上 y 之后从 −33.5% 回到 −0.7%。不适用于预测建模是因为⚠️推理时你没有 y;预测场景正确的做法是不填,改用原生支持缺失的模型 + 缺失指示
  5. MAR 下 −0.2%,MNAR 下 −9.3%。说明⭐⭐同一个方法在两种机制下一个无偏一个有偏,而你无法从数据里分辨自己处在哪一种 —— 这就是为什么必须靠业务判断。
  6. 压缩方差(std 1.003 → 0.781)②稀释相关(corr 0.599 → 0.376)③在分布正中间造一个假尖峰(树模型会围着它切两刀)④抹掉「缺失」这条信息训练/推理不一致的高发地(均值必须来自训练折)。唯一的优点是⭐它不会报错 —— 它流行是因为它让代码往下走,不是因为它对。
  7. 缺失指示把 AUC 从 0.6540 提到 0.6825,+0.0285,成本是一列 0/1。-999 对树模型可行是因为树能切一刀把它单独分成一支;对线性模型是灾难是因为模型会认为「−999 比 0 小很多所以风险线性地低很多」,实跑 0.654 → 0.629,比什么都不做还差。当字段「在预测时刻必然缺失、标签发生后才被填上」(比如实际还款日期),缺失指示就是标签的副本 = 代理变量泄漏
  8. MCAR 0.505 / MAR 0.829 / MNAR 用特征 0.642、加 y 后 0.837。加 y 后暴涨(+0.195)说明缺失和标签直接相关,按 MNAR 对待。根本局限:⭐它只能证伪,不能证明 —— AUC = 0.51 完全可能是彻头彻尾的 MNAR,只是其他字段恰好测不出那个值。
  9. ①⭐⭐把 MNAR 变成 MAR:去采集那个解释缺失的变量(社保记录、分诊等级、机器负载),花钱采一列新数据比换十种算法管用;②⭐敏感性分析:报区间不报点估计;③对缺失机制显式建模(Heckman/选择模型),依赖强假设。敏感性分析的价值不是找到「对的 delta」(数据永远不会告诉你),而是能说出「只要不填的人比预测值低不超过 0.25 个标准差,结论就不变」——这是一句可以拿去汇报的话,而「我做了填充」只是一个隐藏假设。
  10. 字段是公积金月缴存额,来自只覆盖 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

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