卷 III · 备料CH 09工位 09/24

Step 3(15%):缺失值的六种做法与它们的代价

工位 STEP 3.1 / 3.2 选择数据 · 清洗数据 15% 评分员在找:你有没有比较过几种做法并给出选择理由,以及有没有说明缺失为什么缺——「用均值填了」是一句话,「因为该列缺失非随机所以用分组填补」是一段分。

Step 3 占 15%,是四步准备工作里最重的一块,也是实际耗时最长的一步。KDD 的原话把它定义为「data cleaning and pre-processing:removing noise if appropriate, collecting the necessary information to model or account for noise, deciding on strategies for handling missing data fields」。注意 deciding on strategies 是复数——这一步的分不在「你填了」,在「你在几种做法之间做过选择」。

Step 3.2MCAR / MAR / MNAR六种填补代表性

先问「为什么缺」,再问「怎么填」

统计学把缺失机制分成三类。这不是学术洁癖——三类的处理方式不同,而选错的后果是隐性的(模型照样跑,结论悄悄错了)。

机制意思示例数据里的例子能怎么办
MCAR
完全随机缺失
缺不缺和任何东西都无关,纯粹是运气doctors 缺 36 格(7.4%):随机抽的行被抹掉最好办。删行或任意填补都不引入偏差,只损失效率
MAR
随机缺失
缺失概率取决于其他观测到的列假设「偏远地区更容易缺医生数据」——缺失可由 urban 预测用其他列建模填补(分组填补、KNN、回归填补)可以做到无偏
MNAR
非随机缺失
缺失概率取决于它自己那个没被观测到的值literacy 缺 55 格(11.3%):本来就低的更容易缺(低于 62% 的有 34% 概率缺,其余只有 3.5%)最麻烦。任何填补都会有偏,只能承认偏差方向并在报告里声明

MNAR 的后果具体到能算出来:示例数据里,观测到的 literacy 平均值是 70.79。但因为低识字率地区更容易缺,这个平均值是被系统性抬高的。如果你用它去填补缺失值,等于把「本该很低」的格子填成了「偏高」——模型会低估这些地区的风险,而这些地区恰恰是你要找的那些。

◆ 怎么判断是不是 MNAR:一个五分钟的检查

你无法直接验证 MNAR(缺的值看不见),但可以做一个很有说服力的间接检查:造一个「是否缺失」的指示列,然后比较缺失组与非缺失组在其他列上的差异。

df['literacy_missing'] = df['literacy'].isna()
print(df.groupby('literacy_missing')[['gdp_pc', 'urban', 'water', 'u5mr']].mean())

如果两组在别的列上明显不同(比如缺失组的人均 GDP 系统性更低),那就至少是 MAR,而且很可能是 MNAR。把这张两行对照表放进 3.2,配一句:

缺失机制检验:literacy 缺失组(n=55)与非缺失组(n=432)在
人均 GDP(均值 X vs Y)与城镇化率(X vs Y)上存在系统性差异,
说明该列缺失至少为 MAR,且鉴于识字率与这些指标高度相关
(见 2.3 相关矩阵),亦可能为 MNAR。因此:① 不采用整行删除,
② 采用按 region 分组的中位数填补,③ 保留 literacy_missing
指示列作为特征,使模型可利用「缺失本身携带的信息」。
局限性:若为 MNAR,填补值仍偏高,模型将低估低识字率地区的
风险,此偏差方向在 8.4 中讨论。

第 ③ 条那个指示列是个便宜的好招:既然「缺失」本身有信息(缺的往往是差的),那就把这个信息显式交给模型,而不是让填补值把它抹掉。

六种做法,各跑一遍

下面这台不是示意图。它对每种策略都真的重新清洗一次数据、重新划分、重新训练一个逻辑回归(分类)和一个线性回归(预测 u5mr),然后报告测试集指标:

三个结论值得记住,因为它们和直觉相反:

① 整行删除的代价不是精度,是代表性

删掉三列中任一列有缺失的行,480 行变 387 行——少 93 行,19.4%。RMSE 从 6.42 涨到 7.27,R² 从 0.815 掉到 0.729,是六种做法里最差的。

但精度只是表面损失。真正的问题是:被删掉的那 93 行不是随机的。literacy 的缺失偏向低识字率地区,所以「整行删除」等于系统性地把最贫困、最可能未达标的地区请出了样本。剩下的模型看起来照样能跑,但它已经不认识穷地区了——而那正是这个项目要找的对象。

这件事在报告里的正确说法:「listwise deletion 会在 MNAR 情形下引入选择偏差,且本项目的目标群体正是易缺失群体,故不予采用。」

② 填 0 是错的,即使它「看起来无害」

填 0 的 RMSE 是 6.74,比中位数填补差 0.32。原因很简单:「女性识字率 = 0%」是一个真实存在但完全错误的取值,模型会当真,并据此认为那些地区极端落后。

推论:缺失值绝不能用 0、−1、9999、999 这类「合法的数字」表示。而这正是你在 2.4 里发现的第 ② 类问题——vaccine 里那 5 个 999 就是别人犯了这个错留下的痕迹。你现在要做的是先把它转回真缺失,再按缺失处理。不要跳过这一步直接把 999 当异常值删掉:那 5 行的其他列还是好的。

③ 最好的方法只比中位数好一点点

KNN(k=5)填补的 RMSE 是 6.38,全场最低;分组中位数 6.40;全局中位数 6.42。差距小到不值得为它写三页。

这是一个诚实但重要的结论:在这门课的语境下,填补方法的精度差异远小于「你有没有比较过并说明理由」这件事的分值。与其花三小时调 KNN 的 k,不如花二十分钟跑六种、做一张表、写三句话。

⚠ 填补的时机:这是第 13 章那个坑的入口

填补统计量(均值、中位数、KNN 的邻居)必须只从训练集计算。如果你在整份数据上算中位数再划分,测试集的信息就通过那个中位数漏进了训练过程——这叫预处理泄漏。

正确顺序:先划分 → 在训练集上 fit 填补器 → 用同一个填补器 transform 训练集和测试集。

sklearn 的 Pipeline 存在的唯一理由就是替你保证这个顺序:

from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

pipe = Pipeline([
    ('impute', SimpleImputer(strategy='median')),   # 只在 fit 时算中位数
    ('scale',  StandardScaler()),
    ('clf',    LogisticRegression(max_iter=1000)),
])
pipe.fit(X_train, y_train)          # 中位数来自 X_train
print(pipe.score(X_test, y_test))   # X_test 用的是训练集的中位数

报告里写一句「所有填补与缩放参数均在 Pipeline 内由训练集估计」,这一句在 Step 7.1 里是明确的得分点。

3.1「选择数据」不要跳过

Step 3 的第一小节是 select the data,很多人直接写「使用全部数据」就过了。它其实有三件具体的事可写:

  • 选列:哪些列进特征集,哪些不进。这里是排除泄漏列的正式场所——「rate_reporteddeaths_registered 与目标同源,排除;idrecord_date 无预测意义,排除;保留 7 个指标 + region。」
  • 选行:有没有排除某些行?比如时间范围(只用 2015 年后)、地区范围、极端异常的记录。每个排除都要给理由和数量。
  • 抽样:如果数据太大,怎么抽?BDAS 那次要写「不抽样,全量在 Spark 上处理」,这本身就是那次迭代的卖点。
⇄ 缺失填补在三条产线上怎么做
ISAS · SPSS ModelerOSAS · PythonBDAS · PySpark
发现缺失Data Audit 节点的 Quality 页签df.isna().sum()df.select([count(when(col(c).isNull(), c)).alias(c) for c in cols])
转哨兵值Filler 节点(条件 = 999 → 空)df.replace(999, np.nan)df.replace(999, None, 'vaccine')
填补Data Audit → Generate → Missing Values SuperNode,或 Filler 节点SimpleImputer(strategy='median')KNNImputer(n_neighbors=5)Imputer(strategy='median', inputCols=..., outputCols=...)Spark 只有 mean/median/mode,没有 KNN
分组填补Aggregate + Merge 回原表df.groupby('region')[c].transform(lambda s: s.fillna(s.median()))Window.partitionBy('region') + percentile_approx

跨线注意:Spark 的 Imputer 没有 KNN,所以如果你在 OSAS 用了 KNN 填补,BDAS 那次要改成 median 或手写窗口函数。这个差异本身是 Step 8 跨线对比的好素材——「因 MLlib 无 KNN 填补器,BDAS 迭代改用分组中位数,导致 RMSE 由 6.38 变为 6.40,差异可忽略。」

▣ 本章交付物 —— 报告里放什么

1. Step 3.1:选列表(含每个被排除列的理由)、选行说明、抽样说明。

2. Step 3.2 的缺失小节:① 每列缺失量与比例表;② 缺失机制检验(两组均值对照表 + 结论);③ 六种策略对照表(就是上面那个 Demo 的输出:策略/剩余行数/RMSE/R²/准确率);④ 选定策略与三句理由;⑤ 一句「填补参数仅由训练集估计」。

3. 一行局限性声明放进 8.4 的备忘:「若 literacy 为 MNAR,填补值偏高,模型将低估低识字率地区风险。」

这一章的一句话

先问「为什么缺」再问「怎么填」:整行删除在非随机缺失下会把你的目标群体删掉(480 → 387 行,R² 0.815 → 0.729),填 0 会被模型当真,而六种填补方法之间的精度差异小到不值三页——真正值分的是那张六行对照表和「填补参数只从训练集估计」这一句。

下一章处理 2.4 清单上剩下的四类:异常值、重复、不一致,以及一个会让你重新看待「3σ 法则」的现场——同一列数据,三种判据给出 21、2、29 三个答案。