卷 II · 贴上名字CH 08深度 8/23

缺失值不是零,每种填法都是一个假设

上一卷的列里,每一格必须有一个值——连续内存不允许「空着」。而真实数据里到处是空着的格子:没填的表单、坏掉的传感器、上一章那种「对不上」。pandas 用 NaN 表示这件事,代价是 NaN 会传染、会让相等失效、会让整数列变成浮点。这一章要说的是:处理缺失值没有「正确做法」,只有三个不同的假设,而每个假设的账单都能算出来。

丢 39.3% 的行相关系数掉 16.0%NaN 不等于 NaN

▷ 先猜一下

一张 10000 行、10 列的表。每个格子独立地有 5% 的概率缺失——总共只有约 5% 的格子是空的。你写下 df.dropna()

问:还剩多少行?

A 大约 9500 行。缺了 5%,丢 5% 的行 B 大约 8000 行。会多丢一些,但不至于太夸张 C 大约 6000 行。丢掉将近四成 D 大约 500 行。几乎全丢了

先看 NaN 这个记号本身

NaN(Not a Number)不是 pandas 发明的,它是 IEEE 754 浮点标准里的一个特殊值,本来用来表示 0/0 这类无定义的运算结果。pandas 借它来表示「这里没有数据」。借得很方便,但也借来了它的两条怪脾气:

import numpy as np
np.nan == np.nan          # False    ← 它和自己都不相等
np.nan is np.nan          # True     ← 但它是同一个对象

第一条的后果是:你不能用 == 找缺失值。df[df.x == np.nan] 永远返回空表。必须用 df.x.isna()。这是 pandas 新手最常写错的一行。

第二条怪脾气更重要,而且 numpy 和 pandas 在这里选择相反

数据:[1.0, NaN, 3.0]

  np.array([...]).sum()      nan          ← numpy:传染
  np.array([...]).mean()     nan

  pd.Series([...]).sum()     4.0          ← pandas:默认跳过
  pd.Series([...]).mean()    2.0          ← 分母是 2,不是 3

numpy 认为「有一个数不知道,那和就不知道」——这是数学上诚实的答案。pandas 认为「用户多半想要那些知道的数的和」——这是实用主义的答案。两个都合理,危险在于你不知道自己在用哪一个。

特别注意那个均值:2.0 的分母是 2,不是 3。如果那个缺失值代表的其实是「这个用户当天消费为 0」,pandas 给你的就是一个系统性偏高的均值。skipna=True 这个默认值,本身就是一个假设。

假设一:丢掉——代价大得超乎直觉

回到开头的问题。答案是 6073 行,丢掉了 39.3%

算一下就明白了:一行要被保留,需要它的十个格子全都不缺。每个格子有 95% 的概率不缺,十个格子同时不缺的概率是 0.95¹⁰ = 59.9%。本机实测保留 60.7%,和理论值对得上(本机这批数据实际缺失率是 4.88%,略低于 5%)。

10000 行 × 10 列,每格 5% 缺失
  实际缺失的格子            4.88%
  dropna 之后剩下           6073 行
  丢掉                      39.3%
  理论值 0.95^10            59.9%

「只缺了一点点」在列数多的时候会累乘成一场灾难。20 列的话,0.95²⁰ = 35.8%——三分之二的行没了。而这还是最好的情况:上面假设缺失是随机的,如果缺失有规律(比如「高收入人群更不愿填收入」),dropna 丢掉的就不是随机的一批人,剩下的数据会系统性地偏

所以 dropna() 的两个参数值得记住:

df.dropna(subset=["金额"])           # 只在乎这一列,别的列缺就缺
df.dropna(thresh=8)                  # 至少有 8 列不缺就留下

假设二:用均值填——均值不动,别的全动

最常见的填法是 fillna(mean)。它有一个非常诱人的性质:填完之后,这一列的均值一点没变。本机上那一列缺了 503 个,填之前均值 99.9206,填之后 99.9206——一位不差。

但均值是唯一不变的东西。别的都在缩:

一列 10000 个数,缺 503 个,用均值填

  均值    99.9206  →  99.9206       不变
  标准差  14.9796  →  14.5980       缩了 2.55%
  理论值  1 − √0.95 = 2.53%          ✓ 对得上

为什么?因为你往数据里塞了 503 个完全没有偏离均值的点。它们把方差往下拉,拉的比例正好是 √(1−p)。这不是实现细节,是算术的必然。

标准差缩 2.55% 也许你还能忍。真正的损失在别处:均值填充会杀掉相关性。本机造了两列真实相关的数据,然后把其中一列挖掉 30%:

做法相关系数说明
完整数据(真值)0.8658基准
挖掉 30% 后 dropna0.8671几乎没损失(因为缺失是随机的)
挖掉 30% 后均值填充0.7272↓ 16.0%

道理和上面一样:那 30% 被填成同一个常数,它们和另一列完全不相关(一个常数和任何东西的相关都是 0),于是把整体的相关系数按比例拉向 0。

这就是这一章的核心判断:均值填充保护的是「均值」这一个统计量,代价是所有涉及「变化」的统计量——方差、相关、回归系数——全部被系统性地拉向零。而后者恰恰是你建模时真正关心的东西。

假设三:把「缺失」本身当成信息

第三种做法在实际项目里往往最有效,但教科书里讲得最少:不填,把「这里缺了」变成一列新特征。

df["收入_缺失"] = df["收入"].isna().astype(int)     # 加一列标记
df["收入"] = df["收入"].fillna(df["收入"].median())  # 再填个数占位

为什么有用?因为缺失常常不是随机的。「没填收入的人」本身就是一个有信息量的群体。把这个信息扔掉(无论是丢行还是填均值),等于主动放弃了一个可能很强的特征。

pandas 也提供了一个更干净的容器,让缺失能和整数共存:

pd.Series([1, 2, 3])              dtype  int64
pd.Series([1, 2, None])           dtype  float64      ← 整数列被迫变浮点
pd.Series([1, 2, None], dtype="Int64")   dtype Int64  ← 大写 I,可空整数

第二行是一个经典的意外:往整数列里放一个缺失值,整列会变成浮点——因为 NaN 是浮点数,而 numpy 的 int64 里没有位置放它。副作用是 ID 号会变成 1001.0 这样,拼字符串时后面拖着个小数点。Int64(注意大写)用一个单独的掩码记录缺失,整数还是整数。

假设写法它相信的是账单
丢掉dropna()缺失是随机的,剩下的行有代表性10 列各缺 5% → 丢 39.3% 的行
填均值fillna(mean)缺的那些就是「普通值」标准差 −2.55%,相关系数 −16.0%
填中位数/前值fillna(median)ffill同上,但对离群值更稳;时序里假设「没变」同样压方差;ffill 会把停牌价一路带下去
当成信息加一列 isna() 标记缺失本身有规律多一列;如果真是随机缺失,白加
建模填补IterativeImputer能用别的列推出这一列贵,而且必须放进 Pipeline——见第 18 章
✎ 术语正名

统计学把缺失分成三类,名字很拗口但区别是实打实的:

  • 完全随机缺失(MCAR):缺不缺和任何东西都无关,比如仪器随机丢包。这时 dropna 只损失样本量,不产生偏。
  • 随机缺失(MAR):缺失和其他已观测的列有关,比如「年轻人更少填收入」。这时用别的列建模填补是有依据的。
  • 非随机缺失(MNAR):缺失和那个没观测到的值本身有关,比如「收入特别高的人不填收入」。这一类没有任何统计方法能救,只能靠领域知识或者补充采集。

值得记住的是:你没法从数据本身分辨这三类。数据只告诉你哪里缺了,不告诉你为什么缺。所以「缺失值怎么处理」永远是一个需要领域知识的问题,不是一个能自动化的步骤。

⌨ 自己跑一遍

那个 39.3% 值得亲手跑一次,它比任何论证都有说服力:

import numpy as np, pandas as pd

rng = np.random.default_rng(8)
n, k, p = 10000, 10, 0.05
data = rng.normal(100, 15, size=(n, k))
data[rng.random((n, k)) < p] = np.nan
df = pd.DataFrame(data, columns=[f"f{i}" for i in range(k)])

print("缺失格子 %.2f%%" % (df.isna().to_numpy().mean() * 100))   # 4.88%
print("剩下     ", len(df.dropna()))                             # 6073
print("丢掉     %.1f%%" % ((1 - len(df.dropna()) / n) * 100))     # 39.3%
print("理论     %.1f%%" % ((1 - p) ** k * 100))                   # 59.9% 保留

col = df["f0"]
filled = col.fillna(col.mean())
print("均值 %.4f → %.4f" % (col.mean(), filled.mean()))          # 不变
print("标准差 %.4f → %.4f(缩 %.2f%%)"
      % (col.std(), filled.std(), (1 - filled.std() / col.std()) * 100))

k 从 10 改到 20,看丢掉的比例怎么变。再把 p 从 0.05 改到 0.02——这两个旋钮的效果不对称,值得亲手感受一下。

python3 -c "print('留下 %.1f%%' % (0.95**10*100), '| 20 列:%.1f%%' % (0.95**20*100))"

最后那行连 pandas 都不用装——0.95 ** 列数 就是全部的算术。

▸ 在现实里
  • 问卷调查里的「不愿回答」。收入、年龄、政治倾向这三项的缺失几乎从来不是随机的。社会科学论文里专门有一节叫「缺失数据处理」,因为处理方式会直接改变结论——这不是技术细节,是研究设计的一部分。
  • 金融数据里的 ffill 陷阱。股票停牌期间没有价格,用前值填充看起来很合理,但这会造出一段「零波动」的假历史,让回测里的风险指标全部偏低。用同一个函数处理「周末没有交易」和「公司出事停牌」,得到的是两个完全不同性质的结果。
  • 数据库里的三值逻辑。SQL 的 NULL 和这一章的 NaN 是近亲:NULL = NULL 的结果是 UNKNOWN 而不是 TRUE,所以要用 IS NULL。这也是为什么 WHERE x != 5 查不出 xNULL 的行——一个每年都会坑到人的经典。
  • 推荐系统里的「没打分」。用户没给一部电影打分,是「没看过」还是「不喜欢」?把它当 0 填,模型会以为所有没看过的电影都被讨厌。整个隐式反馈推荐领域,就是在处理这一个问题。
✗ 这个直觉是错的

「先 fillna(0) 或者 fillna(mean) 把缺失值处理掉,再开始正经分析。」

这句话里的「处理掉」是个幻觉。缺失值不会被处理掉,它只会被换成一个你编的数。换完之后,数据看起来干净了,但你已经悄悄往数据里注入了一个假设,而且下游没有任何一步会告诉你这个假设是错的。

代价是可以量的:均值填充让标准差缩 2.55%、相关系数掉 16.0%;填 0 更糟——如果那一列是收入,你把「不知道」变成了「一分钱没有」,均值直接被拉低。

判据:填之前先问一句「这些格子为什么是空的」。如果答不上来,至少做两件成本极低的事:isna() 存成一列(万一缺失有信息,你没扔掉它),以及把填充这一步放进 Pipeline(第 19 章会讲为什么——用全量数据算出来的均值去填训练集,是一种真实的数据泄漏)。

◇ 揭晓

正确答案是 C6073 行,丢掉 39.3%

A 「缺 5% 就丢 5%」——这个直觉把「格子的缺失率」当成了「行的缺失率」。一行有十个格子,只要任何一个缺就整行被丢。要留下的概率是 0.95¹⁰,不是 0.95指数在这里,而人的直觉在这里总是线性的。 B 「大约 8000」——方向对了,量级低估了。有意思的是这个答案对应 k ≈ 4.4 列;也就是说,如果你的表只有四五列,直觉不会错得太离谱。列数越多,这个直觉错得越厉害,而真实的表常常有几十列。 D 「几乎全丢」——高估了。要丢到只剩 500 行,需要 0.95^k = 0.05,即 k ≈ 58 列。不过这个数值得记住:列数到了 58,5% 的缺失率就能让 dropna 清空你的数据集——宽表时代这不是危言耸听。
⌗ 交接单
一张有空格子的表。 一张没有空格子的表——通过丢行、填值,或者显式声明可空类型(Int64)。 pandas 自己不检查(它默认 skipna=True,安静地绕过缺失)。真正会拦你的是 sklearn:绝大多数估计器遇到 NaN 会直接抛异常——这是第 17 章那道关卡,也是唯一强制你做决定的地方。

这一章的一句话

缺失值没有「处理掉」这回事:丢行会按列数指数级地吃掉样本(10 列各缺 5% 就丢 39.3%),填均值会保住均值但把方差和相关性系统性地拉向零(−2.55%、−16.0%)——你能做的不是消除代价,是选一个你付得起的。

下一章讲 pandas 里最值钱的那个动作:groupby。它把「拆开、各算各的、再拼回去」这三步压成一行,比手写字典循环快 11.0 倍。但它也有一个和这一章直接相关的静默行为:分组键是 NaN 的行,会被默默丢掉。五行数据 groupby 之后只剩三行,总和从 15 变成 7,没有任何提示。