缺失值不是零,每种填法都是一个假设
上一卷的列里,每一格必须有一个值——连续内存不允许「空着」。而真实数据里到处是空着的格子:没填的表单、坏掉的传感器、上一章那种「对不上」。pandas 用 NaN 表示这件事,代价是 NaN 会传染、会让相等失效、会让整数列变成浮点。这一章要说的是:处理缺失值没有「正确做法」,只有三个不同的假设,而每个假设的账单都能算出来。
一张 10000 行、10 列的表。每个格子独立地有 5% 的概率缺失——总共只有约 5% 的格子是空的。你写下 df.dropna()。
问:还剩多少行?
先看 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% 后 dropna | 0.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查不出x为NULL的行——一个每年都会坑到人的经典。 - 推荐系统里的「没打分」。用户没给一部电影打分,是「没看过」还是「不喜欢」?把它当 0 填,模型会以为所有没看过的电影都被讨厌。整个隐式反馈推荐领域,就是在处理这一个问题。
「先 fillna(0) 或者 fillna(mean) 把缺失值处理掉,再开始正经分析。」
这句话里的「处理掉」是个幻觉。缺失值不会被处理掉,它只会被换成一个你编的数。换完之后,数据看起来干净了,但你已经悄悄往数据里注入了一个假设,而且下游没有任何一步会告诉你这个假设是错的。
代价是可以量的:均值填充让标准差缩 2.55%、相关系数掉 16.0%;填 0 更糟——如果那一列是收入,你把「不知道」变成了「一分钱没有」,均值直接被拉低。
判据:填之前先问一句「这些格子为什么是空的」。如果答不上来,至少做两件成本极低的事:把 isna() 存成一列(万一缺失有信息,你没扔掉它),以及把填充这一步放进 Pipeline(第 19 章会讲为什么——用全量数据算出来的均值去填训练集,是一种真实的数据泄漏)。
正确答案是 C:6073 行,丢掉 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,没有任何提示。