14 缺失数据处理

本章会用到的数据

  • 公开下载A 股季度估值指标。本章使用 2019—2024 年末的市净率、市值和股息率。

  • 这些数据能做什么:比较均值、K 近邻和 MICE 等缺失值处理方法。

  • 分析时注意:插补方法只能根据训练数据确定,不能先查看验证或最终测试数据。

  • 判断方法:人为遮住一部分已知值,再比较不同方法能否较好恢复这些值。

【课堂核心】2.5 学时学习安排

学习内容 分钟
动机与先修 15
缺失机制 35
插补与推断边界 30
方法比较 10
形成性检查与反馈 15
独立遮蔽任务 30
答案、判读与小结 15

核心问题:为何“数据清洗”是分析中最耗时也最关键的一步?

在理想世界中,我们拥有完美、完整的数据集。但在现实中,几乎所有真实世界的经济和金融数据集都存在缺失值 (Missing Values)

  • 一个交易策略的回测表现,可能因为错误处理了财报数据中的一个缺失值而天差地别。
  • 一个宏观经济预测模型,可能因为无法处理调查数据中的缺失项而产生巨大偏差。

今天,我们将系统学习如何科学地应对这个无处不在的挑战。

数据科学的冰山

一个成功的量化分析项目,其工作量分布往往像一座冰山。

数据科学步骤冰山模型 此图用冰山比喻数据科学项目的工作量,其中模型构建只是水面上的尖端,而数据清洗和准备是水下巨大的基础。 工作量的 80% 模型构建 & 分析 (你所看到的) 数据清洗 & 准备 (你没看到的) (缺失值处理)

缺失值处理,正是这座冰山水下最庞大、最关键的部分。

本章的学习路线图

我们的目标是让大家成为处理缺失数据的专家。

本章学习路线图 一个包含四个步骤的路线图:1. 理解为何缺失, 2. 掌握简单方法, 3. 精通高级方法, 4. 动手实践。 1. 理解“为何” 剖析三种核心 缺失机制 2. 掌握“简单方法” 学习删除法、均值填充 及其严重缺陷 3. 精通“高级方法” 深入学习MICE 和KNN填充 4. 动手“实践” 在Python中 比较不同方法

我们的工具箱

我们将使用可复现版本的 Python 科学计算库完成今天的任务。

pandas

  • 核心数据结构(DataFrame)
  • 数据读取、操作、清洗

numpy

  • 高性能数值计算
  • 科学计算的基础

matplotlib

  • 数据可视化
  • 创建高质量图表

scikit-learn

  • 机器学习瑞士军刀
  • 提供简单和高级的数据填充工具 (imputers)

对这些库的基本了解将非常有帮助。

一切始于一个不完美的数据集

想象一下,我们正在为一家投资公司分析几家上市公司的财务指标,数据如 表 1 所示。

表 1: 我们的初始数据存在明显的“洞”
数据 每股股价 账面市值比 资产回报率
1 20.170 0.358 0.015
2 NaN 0.220 0.036
3 14.400 0.296 0.046
4 10.090 0.290 0.036
5 17.900 NaN 0.024

数据2的股价和数据5的账面市值比是缺失的。这在现实中极为常见,原因多种多样:数据录入错误、公司未披露、数据库合并失败等。

数据缺失的“谱系”:并非所有缺失都生而平等

在统计学上,我们根据数据缺失的产生机制,将其严格分为三类。理解你面对的是哪一类,至关重要。

\(R\) 为缺失指示,\(Y=(Y_{obs},Y_{mis})\) 为观测与未观测部分,\(X\) 为完全观测协变量:

  • MCAR:\(P(R\mid Y_{obs},Y_{mis},X)=P(R)\)
  • MAR:\(P(R\mid Y_{obs},Y_{mis},X)=P(R\mid Y_{obs},X)\)
  • MNAR:给定 \((Y_{obs},X)\) 后,缺失概率仍依赖 \(Y_{mis}\) 或未观测因素。

这些是联合缺失模式的假设,通常不能仅由观测数据判别;必须结合业务机制、外部信息与敏感性分析。

数据缺失的三种机制 展示三种数据缺失机制:MCAR, MAR, 和 MNAR,并用箭头表示它们之间的关系和复杂程度。 MCAR 完全随机缺失 与全部数据值无关 MAR 随机缺失 依赖已观测信息需业务论证 MNAR 非随机缺失 依赖未观测因素需敏感性分析 复杂性与处理难度增加 ⟶

机制一:完全随机缺失 (MCAR)

完全随机缺失 (Missing Completely at Random, MCAR)\(P(R\mid Y_{obs},Y_{mis},X)=P(R)\):缺失概率与观测值、未观测值和完全观测协变量均无关。

  • 直观理解:缺失的发生是纯粹的“坏运气”,就像随机抛硬币决定是否删除一个数据点。
  • 金融例子:一位研究助理在手动录入公司财报数据时,不小心打翻了咖啡,随机污染了表格中的几个单元格,导致这些数据无法读取。这些数据的缺失与公司的任何基本面都毫无关系。
MCAR 概念图 一个表格数据,其中一些单元格被随机的“污渍”覆盖,表示数据的缺失与数据内容无关。 MCAR: 缺失是纯粹的偶然事件 股票A股票B 股票C股票D 价格成交量 波动率市盈率 10.5150.2 1.2M 0.8M 0.250.31 1522

MCAR 仍不能单独保证所有估计都无偏

如果数据确实是MCAR,那么处理起来相对简单。

  • 条件性结论:在 MCAR、原抽样设计与分析模型等条件成立时,完整案例对相应完整数据估计目标通常保持一致/无偏,但效率下降。
  • 边界:非线性目标、抽样权重、模型错设或不同估计对象下,不能只凭 MCAR 宣布无偏。
  • 警示:然而,在经济和金融领域,真正的MCAR非常罕见。我们绝不能轻易假设数据是MCAR。

机制二:随机缺失 (MAR)

随机缺失 (Missing at Random, MAR)\(P(R\mid Y_{obs},Y_{mis},X)=P(R\mid Y_{obs},X)\):给定已观测信息后,缺失概率不再依赖未观测值。

  • 直观理解:缺失不是完全随机的,我们可以从其他已知信息中找到一些关于“为什么会缺失”的线索。
  • 金融例子:系统迁移时,旧系统客户是否缺少收入字段只由已观测的迁移批次与渠道决定;给定这些字段后,缺失不再依赖未观测收入本身。
MAR 概念图 一张迁移记录表,其中已观测迁移批次影响收入字段是否缺失。 MAR: 缺失依赖于其他已知信息 姓名:张三 迁移批次:批次 B 年收入:[未填写] 已观测批次决定 缺失概率

MAR 是需要业务论证的工作假设

MAR 不能由观测数据单独验证,也不能无来源地称为金融数据中“最常见”的机制。

  • 完整案例:是否有效取决于缺失变量、条件模型与估计对象。
    • 若响应缺失只依赖已纳入且完全观测的 \(X\),正确设定的 \(E[Y\mid X]\) 斜率仍可能一致。
    • 边际均值或分布通常改变,且效率下降。
  • MICE:MAR 本身不保证无偏。
    • 有效推断还要求缺失模型与分析模型正确、协变量重叠、插补与目标相容,并用 proper imputation 和 Rubin rules。
    • KNN 与单次确定性插补主要服务预测,不自动获得 Rubin-rule 推断保证。

机制三:非随机缺失 (MNAR)

非随机缺失 (Missing Not at Random, MNAR) 指:在给定已观测信息后,缺失概率仍依赖未观测值或其他未观测因素

  • 自我遮蔽例子:缺失取决于该变量自身未观测值,是 MNAR 的一种常见情形,但不是 MNAR 的全部定义。
  • 金融例子
    1. 表现最差的基金选择不向数据库报告其月度回报,以掩盖其糟糕的业绩。
    2. 负债率极高的公司可能选择不披露某些关键的债务指标。
MNAR 概念图 一张基金业绩报告,其中表现最差的基金回报率数据被隐藏,表示缺失与值本身相关。 MNAR: 缺失本身就是信息 基金回报率 基金A+15% 基金B+8% 基金C+2% 基金D ? (因业绩太差 选择不报告)

MNAR是统计建模的“禁区”

如果数据是MNAR,任何标准的缺失数据处理方法都可能失效并产生严重误导。

  • 核心挑战:给定已观测信息后,缺失概率仍依赖未观测值或未观测因素。观测协变量或许仍能预测缺失值,但仅凭观测数据不能保证识别缺失机制或消除选择偏差。
  • 处理思路:MNAR 无法只由已观测数据检验或识别。需要显式选择模型、模式混合模型、外部信息或预先声明的 delta 边界/敏感性分析;缺失指示变量只能帮助预测或描述,不能单独修复选择偏差。

总结:缺失机制决定处理策略

机制 缺失概率依赖于… 金融例子 合适的处理方法
MCAR 数据录入员的随机失误 依估计目标、样本量与效率权衡完整案例或折内插补
MAR 已观测信息 迁移批次决定收入字段是否缺失 统计推断需 proper MI 与 Rubin rules;预测任务在每折训练窗比较单次插补器
MNAR 给定观测信息后仍依赖未观测值/因素 差基金不报告回报 选择/模式混合模型与 delta 敏感性;指示变量不能识别机制

简单方法 1:直接删除 (Listwise Deletion)

这是最简单粗暴的方法:只要一行数据中任何一个变量是缺失的,就将整行数据(整个观测样本)从数据集中删除。

直接删除法图示 一个数据表格,其中包含缺失值的两行被红色叉号标记并变暗,表示它们被删除。 IDVar AVar BVar C 120.10.350.01 2NaN0.220.03 314.40.290.04 417.9NaN0.02

直接删除法的优缺点

优点:

  • 实现极其简单。
  • 在 MCAR、抽样与分析模型等条件成立时,对相应估计目标通常保持一致,但精度下降。

缺点:

  • 严重损失信息:如果多个变量都有一些缺失,可能会删除大部分数据。
  • 条件性后果:完整案例是否偏离目标取决于缺失变量、条件模型与估计对象。若响应只依赖已纳入且完全观测的 \(X\) 而缺失,正确设定的 \(E[Y\mid X]\) 斜率仍可能一致;边际均值/分布通常改变且效率下降。

结论:是否删除取决于估计目标、缺失机制与模式、样本量、抽样设计和精度要求,不能由固定缺失比例单独决定。

简单方法 2:单变量填充 (Simple Imputation)

这种方法试图保留样本量,用一个“合理”的估计值来填补空缺。

  • 对于连续变量:用该变量所有非缺失值均值中位数来填充。
  • 对于分类变量:用该变量所有非缺失值众数(出现频率最高的类别)来填充。
  • 对于时间序列数据:用上一个时间点的值来填充(Last Observation Carried Forward, LOCF)。

均值填充的机制

均值填充法图示 一个数列,其中的缺失值被该数列的平均值所替代。 原始数据列 10 20 NaN 30 均值 = (10+20+30)/3 = 20 填充后数据列 10 20 20 30

均值/中位数填充是“美丽的毒药”

虽然均值填充保留了样本量,但它引入了新的、更隐蔽的问题。

  1. 人为制造了尖峰:大量原本分散的缺失值被强制设定为同一个值(均值),在数据分布的中心制造出一个不自然的尖峰。
  2. 低估了真实方差:填充进去的值没有任何变异性,这会人为地降低整个变量的方差。
  3. 改变变量关系:确定性单值插补通常压缩被插补变量的离散度;协方差和相关性的变化方向与大小仍取决于缺失机制、联合分布和插补模型,不能一概写成向下偏。

可视化警告:均值填充如何扭曲数据分布

图 1 比较当前固定实验中的完整分布与均值填充分布。

代码
# 为“可视化警告:均值填充如何扭曲数据分布”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“可视化警告:均值填充如何扭曲数据分布”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“可视化警告:均值填充如何扭曲数据分布”导入 `seaborn` 并绑定 `sns`,用于编码当前任务的统计分布或分组关系。
import seaborn as sns

# 启用白底网格主题,使原始分布与均值填充分布的形状差异清晰可比。
sns.set_style("whitegrid")
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC'] # 用来正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号

# 固定随机种子,使正态分布与后续 MCAR 遮蔽位置可重复。
np.random.seed(42)
# 抽取均值 100、标准差 20 的 1000 个完整值,作为插补前真值分布。
original_data = np.random.normal(loc=100, scale=20, size=1000)

# 复制完整样本后注入 MCAR 缺失,保留原始数组作为真实值参照。
data_with_missing = original_data.copy()
# 无放回抽取 300 个位置制造 MCAR 缺失,保留 70% 观测值。
missing_indices = np.random.choice(1000, 300, replace=False)
# 在指定索引写入缺失值,构造可与真值对照的插补恢复实验。
data_with_missing[missing_indices] = np.nan

# 仅用已观测值计算均值,作为单次填补的固定替代值。
mean_val = np.nanmean(data_with_missing)
# 提取 `imputed_data` 的插补结果,用于与原始非缺失分布和离散程度比较。
imputed_data = np.nan_to_num(data_with_missing, nan=mean_val)

# 创建共享纵轴的双面板,直接比较原始分布与均值填补后的集中堆积。
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5), sharey=True)

# 绘制原始数据
sns.histplot(original_data, bins=30, kde=True, color='skyblue', ax=ax1)
# 固定 `ax1.axvline(np.mean(original_data), color` 的业务取值与顺序,作为“可视化警告:均值填充如何扭曲数据分布”的可复算输入。
ax1.axvline(np.mean(original_data), color='red', linestyle='--', label=f'真实均值: {np.mean(original_data):.2f}')
# 用图题概括“可视化警告:均值填充如何扭曲数据分布”的比较对象与当前计算结果。
ax1.set_title(f'真实数据分布\n(标准差 = {np.std(original_data):.2f})', fontsize=14)
# 为“可视化警告:均值填充如何扭曲数据分布”,将纵轴标为“频数”,明确纵向编码的变量。
ax1.set_ylabel('频数')
# 显示“可视化警告:均值填充如何扭曲数据分布”图例,使颜色或线型与比较对象一一对应。
ax1.legend()

# 绘制填充后数据
sns.histplot(imputed_data, bins=30, kde=True, color='salmon', ax=ax2)
# 固定 `ax2.axvline(mean_val, color` 的业务取值与顺序,作为“可视化警告:均值填充如何扭曲数据分布”的可复算输入。
ax2.axvline(mean_val, color='red', linestyle='--', label=f'填充均值: {mean_val:.2f}')
# 用图题概括“可视化警告:均值填充如何扭曲数据分布”的比较对象与当前计算结果。
ax2.set_title(f'均值填充后的数据分布\n(标准差 = {np.std(imputed_data):.2f})', fontsize=14)
# 显示“可视化警告:均值填充如何扭曲数据分布”图例,使颜色或线型与比较对象一一对应。
ax2.legend()

# 将图题限定到当前 MCAR 生成实验,避免外推为一般偏差定理。
plt.suptitle('当前 MCAR 生成实验:均值填充压缩离散度并制造中心尖峰', fontsize=16)
# 为“可视化警告:均值填充如何扭曲数据分布”,压缩子图留白,避免标题和坐标标签相互遮挡。
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
# 显示原始、缺失与均值填充后的分布,检查填充值在均值处堆积及方差被压缩的失真。
plt.show()
左右密度图比较原始分布与均值填充;右图在均值位置形成尖峰且分布收窄,说明方差被低估。
图 1: 当前固定种子、30% MCAR 遮蔽实验中,均值填充(右图)压缩标准差并在填充值处制造尖峰。

解读固定遮蔽实验

在这一次固定种子、30% MCAR 遮蔽的生成实验中,右图实跑标准差低于完整样本,且在填充均值处形成尖峰。

  • 这是描述性变化,不是统计显著性检验。
  • 它不能推出所有 MAR/MNAR 场景的偏差方向。
  • 金融风险模型必须结合缺失机制、估计目标与敏感性分析判断影响。

进阶方法:利用变量间的关系进行条件插补

简单填充的核心缺陷在于它只利用了缺失变量自身的信息(即它的均值),而忽略了数据集中其他变量提供的丰富信息。

核心思想:如果账面市值比缺失,但我们知道这家公司的市盈率、资产回报率和行业类别,我们或许可以预测出它最可能的账面市值比。

这就引出了基于模型的填充方法

高级方法 1:多重插补链式方程 (MICE)

  • 框架:MICE(Multivariate Imputation by Chained Equations)用链式条件模型处理多变量缺失。
  • 更新:在每轮迭代中,按当前模型依次更新或抽样各变量的候选值。
  • 完整推断:生成多个随机插补数据集,分别估计模型,再按 Rubin rules 合并参数与方差。
  • 边界:迭代次数增加不等于候选值必然更准确。

MICE 算法的直观步骤 (1/4)

假设变量 \(X_1\)\(X_2\) 有缺失值,而 \(X_3\) 是完整的。

第1步:初步猜测

首先,用最简单的均值填充法,为 \(X_1\)\(X_2\) 的缺失位置提供一个临时的初始值。现在我们有了一个“完整”但质量不高的数据集。

MICE 步骤1: 初步猜测 一个数据矩阵,其中有缺失的单元格被均值填充。 MICE 步骤 1: 初始均值填充 原始数据 X1X2X3 ? ? 均值填充 临时完整数据 X1X2X3 X̄1 X̄2

MICE 算法的直观步骤 (2/4)

  1. \(X_1\) 中的临时均值重新设为缺失。
  2. \(X_2\)(临时填充)和 \(X_3\) 为自变量,拟合 \(X_1 \sim f(X_2, X_3)\)
  3. 按当前条件模型为缺失位置生成候选更新值。

边界:单次确定性演示可用点预测;proper multiple imputation 需随机抽样并生成多个完成数据集。

MICE 步骤2: 更新X1 图示X2和X3被用作预测变量来填充X1的缺失值。 MICE 步骤 2: 将 X1 作为目标进行回归 回归模型 X1 ~ f(X2, X3) X2, X3 新 X1 值

MICE 算法的直观步骤 (3/4)

  1. \(X_2\) 中的临时均值重新设为缺失。
  2. 更新后\(X_1\)\(X_3\) 为自变量,拟合 \(X_2 \sim g(X_1, X_3)\)
  3. 按当前条件模型生成 \(X_2\) 的候选更新值。

边界:该更新继承模型设定、重叠与前一步候选值的不确定性。

MICE 步骤3: 更新X2 图示更新后的X1和X3被用作预测变量来填充X2的缺失值。 MICE 步骤 3: 将 X2 作为目标进行回归 回归模型 X2 ~ g(X1, X3) 新 X1, X3 新 X2 值

MICE 算法的直观步骤 (4/4)

  • 一个循环:先更新 \(X_1\),再更新 \(X_2\)
  • 停止条件:达到预设迭代次数,或链近似稳定。
  • 结果依赖:MAR 等机制假设、插补模型设定与随机抽样方案。
  • 必须监测:链间比较、插补分布与敏感性。
  • 不能推出:近似稳定不保证插补准确、模型相容或推断有效。

高级方法 2:K-近邻填充 (KNN Imputation)

KNN Imputation 的思想非常直观:物以类聚,人以群分

要填充一个样本A的缺失值,可以这样做:

  1. 寻找邻居:在数据集中,根据所有未缺失的变量,找到与样本A最相似的K个样本(即它的“邻居”)。
  2. 取长补短:用这K个邻居在该缺失变量上的平均值(或中位数),来填充样本A的缺失值。

KNN填充的可视化解释

假设我们想填充红色数据点在“资产回报率”上的缺失值。

代码
# 为“KNN填充的可视化解释”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“KNN填充的可视化解释”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“KNN填充的可视化解释”,从 `sklearn.neighbors` 导入 `NearestNeighbors`,查找 KNN 插补所依赖的邻近观测。
from sklearn.neighbors import NearestNeighbors
# 为“KNN填充的可视化解释”导入 `seaborn` 并绑定 `sns`,用于编码当前任务的统计分布或分组关系。
import seaborn as sns

# 启用白底网格主题,突出 KNN 距离连线与候选邻居位置。
sns.set_style("whitegrid")
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC'] # 用来正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号

# 固定随机种子,生成两维相关点云作为 KNN 填补的几何机制输入。
np.random.seed(0)
# 在 0—10 方形区域均匀抽取 50 个二维点,作为最近邻几何底图。
X = np.random.rand(50, 2) * 10
# 给第二维加入与第一维相关的扰动,形成 KNN 邻域可见的二维结构。
X[:, 1] = X[:, 1] + 0.5 * X[:, 0] + np.random.randn(50) * 0.5

# 固定第一维已知、第二维缺失的目标点,明确最近邻检索只使用可观测坐标。
point_with_missing = np.array([[5, np.nan]])
# 保留全部其他点作为邻居候选集,待距离搜索选出最近三点。
all_other_points = X

# 固定五个邻居,准备在第一维空间检索局部参照样本。
k = 5
# 建立最近邻搜索器 `nn`,固定邻居数量以演示 KNN 插补机制。
nn = NearestNeighbors(n_neighbors=k)
# 用其余样本的第一特征建立最近邻索引,随后为缺失点寻找距离最小的参照观测。
nn.fit(all_other_points[:, 0].reshape(-1, 1))
# 取得 `distances, indices` 的最近邻结果,用相邻观测解释 KNN 插补值来源。
distances, indices = nn.kneighbors(point_with_missing[:, 0].reshape(-1, 1))

# 取得 `neighbors` 的最近邻结果,用相邻观测解释 KNN 插补值来源。
neighbors = all_other_points[indices.flatten()]
# 对五个最近邻的完整第二维取均值,得到缺失坐标的 KNN 插补值。
imputed_value = np.mean(neighbors[:, 1])
# 固定 `imputed_point` 的数值分量,作为“KNN填充的可视化解释”的可手算输入。
imputed_point = np.array([[5, imputed_value]])

# 创建 KNN 几何图画布,展示待填补点、邻居和局部连线。
plt.figure(figsize=(10, 7))

# 绘制所有点
plt.scatter(all_other_points[:, 0], all_other_points[:, 1], c='lightblue', label='完整数据点', s=60, alpha=0.8, edgecolors='black')
# 突出邻居
plt.scatter(neighbors[:, 0], neighbors[:, 1], c='orange', s=120, label=f'K={k}个最近邻', marker='*')
# 绘制填充后的点
plt.scatter(imputed_point[:, 0], imputed_point[:, 1], c='red', s=200, label='填充后的数据点', marker='X', edgecolors='black', linewidth=2)

# 连接到邻居
for neighbor in neighbors:
    # 从待填补点向当前最近邻画虚线,逐条展示局部参照集合。
    plt.plot([imputed_point[0,0], neighbor[0]], [imputed_point[0,1], neighbor[1]], 'k--', alpha=0.5)

# 将图题设为“K-近邻 (KNN) 填充的直观解释”,直接说明当前图形的比较目的。
plt.title('K-近邻 (KNN) 填充的直观解释', fontsize=16)
# 将横轴标为“账面市值比(已知)”,明确横向编码的变量。
plt.xlabel('账面市值比(已知)', fontsize=12)
# 将纵轴标为“资产回报率 (部分缺失)”,明确纵向编码的变量。
plt.ylabel('资产回报率 (部分缺失)', fontsize=12)
# 显示“KNN填充的可视化解释”图例,使颜色或线型与比较对象一一对应。
plt.legend(fontsize=12)
# 为“KNN填充的可视化解释”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 显示缺失点、候选样本与最近邻连线,检查 KNN 填充值来自局部相似观测而非总体均值。
plt.show()
二维散点中空缺样本由附近观测的颜色或位置估计;连线强调 KNN 只借用局部邻居信息。
图 2: KNN填充:利用“邻居”的信息来填补空缺。

KNN 填充值解读

基于已知的“账面市值比”找到 5 个最近邻,再用这些邻居的资产回报率均值填充红色点;结果依赖距离尺度与 \(K\) 的选择。

单次 IterativeImputer vs. KNN:建模边界

特性 单次 IterativeImputer KNN Imputer
基本原理 逐列用其余变量回归,当前设置只生成一个确定性完成数据集 基于样本距离与邻域均值
假设 默认 BayesianRidge 刻画数值变量的线性条件关系;非线性须另换估计器并验证 距离、尺度与局部相似性具有任务含义
计算成本 较高(需要多次迭代训练模型) 中等(需要计算距离矩阵)
输入边界 当前实现用于数值输入;类别变量须先按语义编码,缩放与编码均在训练折确定 数值距离特征;尺度/权重同样只能在训练折确定

单次 IterativeImputer vs. KNN:验证与推断

特性 单次 IterativeImputer KNN Imputer
比较原则 与中位数、KNN 在同一遮蔽实验和下游时间外任务比较,不预设更准确 同窗比较误差、稳定性与下游指标
推断边界 单次 sample_posterior=False 不是 proper multiple imputation;统计推断另需多个随机完成数据集与 Rubin rules 不提供 Rubin-rule 多重插补推断

两种实现都只是候选工具:预测任务以折内遮蔽恢复和下游样本外指标选型;统计推断须另行建立与分析目标相容的 proper multiple imputation。

实战演练:在Python中处理缺失金融数据

理论讲完了,现在我们进入实战环节。我们将使用scikit-learn库来演示简单和高级的填充方法,并直观地比较它们的效果。

步骤1:创建并观察我们的数据集

首先,我们创建一个模拟金融数据集,并让 B/M 缺失只依赖最终数据中始终可见的 pps,构造一个受控 MAR 教学机制。

代码
# 为“步骤1:创建并观察我们的数据集”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“步骤1:创建并观察我们的数据集”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np

# 固定随机种子,使 MAR 与 MCAR 机制演示的完整基准数据可复算。
np.random.seed(42)
# 为 `data` 建立包含 3 项的键值记录。
data = {
    'pps': np.random.normal(20, 5, 1000),      # 每股股价
    'book_to_market_ratio': np.random.uniform(0.1, 1.5, 1000),   # 账面市值比
    'roa': np.random.normal(0.05, 0.02, 1000)  # 资产回报率
}
# 将三项完整特征整理为插补前的真值参照表。
df_orig = pd.DataFrame(data)
# 创造变量间的关联:bm不仅是随机的,还与pps和roa相关
df_orig['book_to_market_ratio'] = df_orig['book_to_market_ratio'] + df_orig['pps'] * 0.01 + df_orig['roa'] * 2

# 让 B/M 缺失概率依赖已观测每股股价 `pps`,构造受控 MAR 近似机制。
# 股价(pps)越高的公司,其账面市值比(book_to_market_ratio)越可能缺失
# 我们使用sigmoid函数来创建一个平滑的概率
missing_prob = 1 / (1 + np.exp(-(df_orig['pps'] - 25)))
# 按给定概率抽取 `is_missing` 的 0/1 标签,固定“步骤1:创建并观察我们的数据集”的分类结果。
is_missing = np.random.binomial(1, missing_prob.values, size=1000).astype(bool)
# 深拷贝原始机制数据,隔离后续缺失注入与填补操作,保留未修改分布作为比较基准。
df = df_orig.copy()
# 按与每股股价 `pps` 相关的缺失概率遮蔽账面市值比,构造 MAR 情景。
df.loc[is_missing, 'book_to_market_ratio'] = np.nan

# 断言 MAR 驱动变量在最终观测表中始终可见,避免联合遮蔽把机制改成混合机制。
assert df['pps'].notna().all(), 'MAR 驱动变量 pps 必须保持完全观测'

# 标出注入受控 MAR 缺失后的前五行。
print('数据集的前5行 (包含缺失值NaN):')
# 输出含缺失值数据前五行,定位账面市值比的空缺位置。
print(df.head())
# 报告账面市值比的缺失占比,核对 MAR 遮蔽强度。
print(f'\n账面市值比(book_to_market_ratio)的缺失比例: {df["book_to_market_ratio"].isnull().sum() / len(df):.2%}')
# 报告驱动变量完整性,证明当前 B/M 遮蔽只依赖观测量。
print(f'MAR驱动变量每股股价(pps)的缺失比例: {df["pps"].isnull().sum() / len(df):.2%}')

# 保存原始的bm列,用于后续比较
original_book_to_market_for_plot = df_orig.loc[df['book_to_market_ratio'].isnull(), 'book_to_market_ratio']
数据集的前5行 (包含缺失值NaN):
         pps  book_to_market_ratio       roa
0  22.483571              0.646943  0.043816
1  19.308678              0.509396  0.034957
2  23.238443              1.336154  0.056383
3  27.615149                   NaN  0.076809
4  18.829233              0.357506  0.012497

账面市值比(book_to_market_ratio)的缺失比例: 16.20%
MAR驱动变量每股股价(pps)的缺失比例: 0.00%

这是一个机制已知的受控 MAR 示例;它只解释“缺失概率依赖观测协变量”,不能识别真实业务数据的自然缺失机制。

步骤2:使用简单均值填充

现在,我们使用 SimpleImputer 来执行均值填充。这个过程分为两步:.fit()(学习)和 .transform()(应用)。

代码
# 为“步骤2:使用简单均值填充”,从 `sklearn.impute` 导入`SimpleImputer` 用于按指定统计量填补缺失值。
from sklearn.impute import SimpleImputer

# 建立逐列均值填充器,作为不利用变量间关系的简单基准。
mean_imputer = SimpleImputer(strategy='mean')

# 用完整机制表的已观测值逐列估计均值,并生成单次均值填补结果。
#    Transform: 学习到的均值被用来填充NaN
df_mean_imputed = pd.DataFrame(mean_imputer.fit_transform(df), columns=df.columns)

# 标出均值填充后的前五行。
print('使用均值填充后的数据集的前5行:')
# 输出均值填充后的前五行,确认缺失单元已被训练样本均值替代。
print(df_mean_imputed.head())
# 检查均值填充后账面市值比是否已无缺失值。
print(f'\n填充后账面市值比(book_to_market_ratio)是否还有缺失值? {df_mean_imputed["book_to_market_ratio"].isnull().sum() == 0}')
使用均值填充后的数据集的前5行:
         pps  book_to_market_ratio       roa
0  22.483571              0.646943  0.043816
1  19.308678              0.509396  0.034957
2  23.238443              1.336154  0.056383
3  27.615149              1.096417  0.076809
4  18.829233              0.357506  0.012497

填充后账面市值比(book_to_market_ratio)是否还有缺失值? True

步骤3:单次迭代插补演示

接下来使用 IterativeImputer 演示一次链式迭代插补。当前 sample_posterior=False 且只生成一个完成数据集,因此它不是完整的 MICE 多重插补,也不能按 Rubin rules 给出合并推断。

代码
# 为“步骤3:单次迭代插补演示”,启用 scikit-learn 的实验性迭代插补器接口。
from sklearn.experimental import enable_iterative_imputer
# 为“步骤3:单次迭代插补演示”,从 `sklearn.impute` 导入 `IterativeImputer`,拟合缺失值填补器。
from sklearn.impute import IterativeImputer

# 建立单次确定性 IterativeImputer,明确其不生成多重插补数据集与 Rubin 合并推断。
#    max_iter=10: 迭代10轮
#    random_state=0: 保证结果可以复现
iterative_imputer = IterativeImputer(max_iter=10, random_state=0)

# 在同一机制表上执行单次确定性迭代填补,并保留字段名用于分布比较。
df_iterative_imputed = pd.DataFrame(iterative_imputer.fit_transform(df), columns=df.columns)

# 标出单次确定性迭代插补后的前五行。
print('使用单次确定性迭代插补后的数据集的前5行:')
# 输出单次迭代插补后的前五行,检查预测式填充值与其他变量共同变化。
print(df_iterative_imputed.head())
# 检查单次迭代插补后账面市值比是否已无缺失值。
print(f'\n填充后账面市值比(book_to_market_ratio)是否还有缺失值? {df_iterative_imputed["book_to_market_ratio"].isnull().sum() == 0}')
使用单次确定性迭代插补后的数据集的前5行:
         pps  book_to_market_ratio       roa
0  22.483571              0.646943  0.043816
1  19.308678              0.509396  0.034957
2  23.238443              1.336154  0.056383
3  27.615149              1.308694  0.076809
4  18.829233              0.357506  0.012497

填充后账面市值比(book_to_market_ratio)是否还有缺失值? True

条件性比较:检查填充结果

哪种方法更好?眼见为实。我们将比较原始真实值、均值填充值和单次迭代填充值的分布。

代码
# 提取被填充的值
mean_imputed_values = df_mean_imputed.loc[is_missing, 'book_to_market_ratio']
# 提取 `iterative_imputed_values` 的插补结果,用于与原始非缺失分布和离散程度比较。
iterative_imputed_values = df_iterative_imputed.loc[is_missing, 'book_to_market_ratio']
# 从未破坏的真值表提取被遮蔽位置,作为插补恢复误差的对照。
original_values = df_orig.loc[is_missing, 'book_to_market_ratio']

# 为“条件性比较:检查填充结果”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(12, 7))

# 累计分布不会因均值填充产生大量相同值而出现不可读的密度尖峰。
sns.ecdfplot(original_values, label='原始真实值', color='#2C7BB6', linewidth=3)
sns.ecdfplot(mean_imputed_values, label='均值填充值', color='#D7191C', linewidth=3, linestyle='--')
sns.ecdfplot(iterative_imputed_values, label='单次迭代插补值', color='#1B9E77', linewidth=3, linestyle='-.')

# 少量极端插补值会把主体分布压成一条线;图中明确只放大 1%—99% 区间。
all_comparison_values = pd.concat([original_values, mean_imputed_values, iterative_imputed_values])
display_lower, display_upper = all_comparison_values.quantile([0.01, 0.99])
plt.xlim(display_lower, display_upper)
outside_count = int(((all_comparison_values < display_lower) | (all_comparison_values > display_upper)).sum())
plt.text(0.99, 0.96, f'放大显示 1%—99% 区间;区间外 {outside_count} 个值',
         transform=plt.gca().transAxes, ha='right', va='top', fontsize=11)

# 将图题设为“不同方法填充值的累计分布”,直接说明当前图形的比较目的。
plt.title('不同方法填充值的累计分布', fontsize=16)
# 将横轴标为“账面市值比 (book_to_market_ratio) 值”,明确横向编码的变量。
plt.xlabel('账面市值比 (book_to_market_ratio) 值', fontsize=12)
# 纵轴表示小于等于当前横轴取值的样本比例。
plt.ylabel('累计样本比例', fontsize=12)
# 显示“条件性比较:检查填充结果”图例,使颜色或线型与比较对象一一对应。
plt.legend(fontsize=12)
# 为“条件性比较:检查填充结果”,压缩子图留白,避免标题和坐标标签相互遮挡。
plt.tight_layout()
# 呈现多种插补值的累计分布,检查方法间的位置与离散程度差异。
plt.show()
横轴为插补后的变量值、纵轴为累计样本比例;均值填充形成陡直阶梯,单次迭代插补保留更宽的样本分布。
图 3: 本次生成数据中,单次迭代插补与均值插补的累计分布比较

结果分析:一次生成实验的边界

图 3 中我们可以清晰地看到:

  • 均值填充(红色):正如理论所预测的,它在数据均值附近制造了一个巨大的、不自然的尖峰,完全没有捕捉到数据的真实分布形态。
  • 单次迭代插补(绿色):在当前人为生成机制下更接近被遮蔽值的分布;这不证明对真实财报、MNAR 数据或模型错设情形仍然更优。

量化分析:标准差的对比

视觉效果很震撼,我们再用数字来证实。

代码
# 提取原始完整 B/M 列,作为方差保持程度的未缺失参照。
book_to_market_original_full = df_orig['book_to_market_ratio']
# 提取 `book_to_market_mean_imputed_full` 的插补结果,用于与原始非缺失分布和离散程度比较。
book_to_market_mean_imputed_full = df_mean_imputed['book_to_market_ratio']
# 提取 `book_to_market_iterative_imputed_full` 的插补结果,用于与原始非缺失分布和离散程度比较。
book_to_market_iterative_imputed_full = df_iterative_imputed['book_to_market_ratio']

# 计算原始 B/M 标准差,作为比较各填补方法分布压缩程度的基准。
std_original = book_to_market_original_full.std()
# 计算 `std_mean`(标准差),概括“量化分析:标准差的对比”中的样本特征。
std_mean = book_to_market_mean_imputed_full.std()
# 计算 `std_iterative`(标准差),概括“量化分析:标准差的对比”中的样本特征。
std_iterative = book_to_market_iterative_imputed_full.std()

# 打印原始 B/M 标准差,随后与均值、KNN 和迭代填补结果并列核对。
print(f"原始数据的标准差: {std_original:.4f}")
# 报告均值填充后的标准差及其相对收缩幅度。
print(f"均值填充后标准差: {std_mean:.4f} (被人为降低了 { (1 - std_mean/std_original):.2%} )")
# 报告单次迭代插补后的标准差,只用于描述性比较。
print(f"单次迭代插补后标准差: {std_iterative:.4f} (仅报告差异,不作优劣宣称)")
原始数据的标准差: 0.4115
均值填充后标准差: 0.3808 (被人为降低了 7.44% )
单次迭代插补后标准差: 0.3857 (仅报告差异,不作优劣宣称)

结论:不能仅凭分布图或标准差宣布某方法“完胜”。严肃分析应先讨论缺失机制,在每个训练窗口内拟合插补器,并用遮蔽实验、下游样本外指标和敏感性分析比较;若做统计推断,完整 MICE 还需多个随机插补数据集与 Rubin rules。

检查:缺失比例能否单独决定删除?

  1. 缺失率为 3% 时,能否仅凭比例决定完整案例删除?
  2. IterativeImputer(sample_posterior=False) 是否等于 proper multiple imputation?

请分别说明判断依据。

反馈:机制、目标与插补口径共同决定

  • 删除决策:不能只看缺失比例,还取决于估计目标、缺失机制与模式、样本量和精度要求。
  • 方法边界:确定性单次迭代插补不等于 \(m>1\) 的随机插补加 Rubin rules。
  • 回看:机制见 相关内容;单次插补与 proper MI 的边界见 相关内容

课堂讨论:什么时候不应该填充数据?

我们已经学习了如何填充数据,但一个更深刻的问题是:是否应该填充?

请思考一个具体的例子,在什么情况下,对缺失数据进行任何形式的填充都可能是一个错误,甚至会误导我们的分析?

你的缺失数据处理清单

  1. [侦察] 检查每个变量的缺失比例。df.isnull().sum() / len(df)
  2. [思考] 深入思考缺失的可能机制。是MCAR, MAR, 还是MNAR?这是最重要的一步。
  3. [决策]
    • 如果是MNAR:停止把标准插补当作识别方案;报告不可识别性,使用专门选择/模式混合模型,并做 delta 敏感性分析。缺失指示变量只可作为预测辅助。
    • 如果可辩护为 MCAR:按估计目标、抽样设计、缺失模式、样本量与效率损失决定是否使用完整案例;不套用固定比例阈值。
    • 如果可辩护为 MAR:在训练窗口内比较简单与高级插补,并检查可识别条件、重叠与模型相容性。
    • 如果机制不确定:并列报告可辩护的 MAR 分析与事先确定 MNAR delta/选择模型敏感性范围,不得假定高级插补已经解决选择偏差。

你的缺失数据处理清单:执行与验证

  1. [执行] 预测任务可把单次 IterativeImputer(sample_posterior=False)KNNImputer 作为折内候选;统计推断需要生成多个随机插补数据集并用 Rubin rules 合并的 proper multiple imputation。
  2. [验证] 同时报告遮蔽恢复误差、下游时间外指标、分布与尾部诊断、MNAR 敏感性;KDE 只能作辅助,统计推断还需 proper multiple imputation 与 Rubin rules。

阶段小结:缺失机制与工具边界

  1. 缺失机制是第一性原理:处理缺失数据的第一步永远是思考其背后的机制(MCAR, MAR, MNAR)。这将指导你所有后续的选择。
  2. 简单方法也有条件:确定性均值/中位数插补通常压缩被插补变量的离散度,但关系量偏差方向依赖机制与联合分布;完整案例在可辩护的 MCAR 等条件下可能有效,却会损失效率。两者都须围绕估计目标验证,不能套用无条件结论。
  3. 按假设选择工具:单次 IterativeImputer、KNN 与完整 MICE 的目标不同,没有一种方法保证偏差更小;需要结合缺失机制、预测任务和敏感性分析比较。
  4. 实践出真知:工具的选择最终要服务于你的研究目的。始终要通过可视化等手段,检查填充后的数据是否保留了合理的分布形态。

独立练习:遮蔽实验与决策

  • 固定输入stock/valuation_factors_quarterly_15_years.h5valuation_factors 键。
  • 公司与时期:指定的 8 家公司,严格取 2019—2024 各年最后一个观测日。
  • 字段与目标:用 B/M、市值、股息率预测下一年末 B/M。
  • 候选方法:中位数、KNN 与单次 IterativeImputer,并保留缺失指示变量。
  • 防泄漏约束:插补器只在当前训练折拟合,并在同一时间外验证窗比较。

先报告自然缺失边界:本次固定公司与字段在建模面板中的原始缺失率实跑为 0%。因此下列 20% 遮蔽只是一项人为 MCAR 还原基准,不能证明方法处理自然 MAR 或 MNAR 时有效。

独立练习:机制与稳健性检查

提交前完成三项同口径检查:

  1. 以训练折中位数作为简单基线。
  2. 让 B/M 缺失概率只依赖已观测 log_market_cap,完成受控 MAR 遮蔽。
  3. 以单次迭代插补为 MAR 参考,对缺失 B/M 预设 delta={-0.10,0,+0.10} 的 MNAR 模式混合敏感性。

三项均报告恢复 MAE、适用机制与失败解释;人工遮蔽不能识别自然缺失机制。

练习:先完成再查看答案:缺失机制与遮蔽方案

  • 提交内容:机制假设、受控遮蔽、三种折内插补器、简单基线、MAR 检查与 MNAR 敏感性。
  • 重做条件:任何插补器在全样本上预拟合。
  • 完成后:进入答案页核对。

完整答案(1/4):固定输入与年度面板

该 HDF5 键是 Fixed 格式,不能用 where/columns 下推;代码记录这一物理限制,读取后立即按固定公司与字段缩减。

代码
from pathlib import Path  # 管理固定公开估值数据版本路径
from urllib.request import urlretrieve  # 复用本章隐藏设置单元安装的浏览器标识下载器
import time  # 记录三种插补方法的运行时间
import numpy as np  # 处理对数变换与确定性遮蔽
import pandas as pd  # 构造公司年度面板与结果表
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择估值因子文件。
valuation_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/valuation_factors_quarterly_15_years.h5'), Path('C:/qiufei/data/stock/valuation_factors_quarterly_15_years.h5'), Path('data/course/valuation_factors_quarterly_15_years.h5')] if candidate_path.exists()), Path('data/course/valuation_factors_quarterly_15_years.h5'))
if not valuation_path.exists():
    valuation_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/stock/valuation_factors_quarterly_15_years.h5', valuation_path)
valuation_key = 'valuation_factors'  # 固定 HDF5 数据键
company_pool = ['600276.XSHG', '600009.XSHG', '600585.XSHG', '603259.XSHG', '002415.XSHE', '600104.XSHG', '600019.XSHG', '600406.XSHG']  # 确定八家公司
raw_valuations = pd.read_hdf(valuation_path, key=valuation_key)  # Fixed 键必须物理读取后再立即筛选
valuation_columns = ['book_to_market_ratio_lf', 'market_cap', 'dividend_yield_ttm']  # 确定建模字段
valuation_panel = raw_valuations.loc[raw_valuations.index.get_level_values('order_book_id').isin(company_pool), valuation_columns].reset_index()  # 立即缩减为固定公司与字段
valuation_panel['year'] = valuation_panel['date'].dt.year  # 从观测日提取时间外切分年份
valuation_panel = valuation_panel.query('2019 <= year <= 2024').sort_values(['order_book_id', 'year', 'date'])  # 限定数据版本实际覆盖期并排序
annual_valuations = valuation_panel.groupby(['order_book_id', 'year'], as_index=False).tail(1).copy()  # 每个公司年度保留最后观测日
annual_valuations['log_market_cap'] = np.log(annual_valuations['market_cap'])  # 将市值转为稳定的对数尺度
annual_valuations['next_bm'] = annual_valuations.groupby('order_book_id')['book_to_market_ratio_lf'].shift(-1)  # 对齐下一年 B/M 目标
imputation_panel = annual_valuations.dropna(subset=['next_bm']).copy()  # 删除没有下一年目标的 2024 特征行
assert set(company_pool) == set(imputation_panel['order_book_id'].unique()), '固定公司池不完整'  # 缺公司时明确停止
print({'文件': valuation_path.name, '键': valuation_key, '格式': 'Fixed', '公司数': imputation_panel['order_book_id'].nunique(), '样本期': (imputation_panel['year'].min(), imputation_panel['year'].max())})  # 输出输入说明
print(imputation_panel[valuation_columns].isna().mean().rename('缺失率'))  # 输出三字段原始缺失率
print(imputation_panel.groupby('year').size().rename('公司年度样本数'))  # 输出逐年样本数量变化
{'文件': 'valuation_factors_quarterly_15_years.h5', '键': 'valuation_factors', '格式': 'Fixed', '公司数': 8, '样本期': (2019, 2023)}
book_to_market_ratio_lf    0.0
market_cap                 0.0
dividend_yield_ttm         0.0
Name: 缺失率, dtype: float64
year
2019    8
2020    8
2021    8
2022    8
2023    8
Name: 公司年度样本数, dtype: int64

完整答案(2/4):三种折内插补器

  • 遮蔽设计:每折只在训练期可见 B/M 中固定随机遮蔽 20%(至少 1 个),属于 MCAR 基准。
  • 共同设置:三种方法都保留缺失指示变量;单次迭代插补不称为完整 MICE。
  • KNN 距离:业务特征等权,使用训练折 Z-score。
  • 防泄漏:均值与标准差只在训练折拟合,验证窗复用变换,插补后还原原量纲。
代码
from sklearn.experimental import enable_iterative_imputer  # 启用 sklearn 的实验性迭代插补接口
from sklearn.impute import SimpleImputer, KNNImputer, IterativeImputer  # 比较中位数、邻居与单次链式插补
from sklearn.linear_model import Ridge  # 建立稳定的小样本预测模型
from sklearn.metrics import mean_absolute_error  # 导入平均绝对误差,评价连续目标的预测偏差
from sklearn.pipeline import make_pipeline  # 约束缩放与回归仅在训练折拟合
from sklearn.preprocessing import StandardScaler  # 在每个训练折逐特征估计均值与标准差
imputation_features = ['book_to_market_ratio_lf', 'log_market_cap', 'dividend_yield_ttm']  # 固定三项特征顺序
代码
class FoldScaledKNNImputer:  # 把折内尺度拟合、邻居搜索与原量纲还原封装为单一候选
    def fit(self, feature_matrix):  # 只从当前训练折学习尺度、邻居库与指示器列
        feature_array = np.asarray(feature_matrix, dtype=float)  # 固定数值矩阵与列顺序
        self.scaler_ = StandardScaler().fit(feature_array)  # 仅用训练折逐特征估计均值和标准差
        scaled_features = self.scaler_.transform(feature_array)  # 在无量纲空间建立邻居距离
        self.imputer_ = KNNImputer(n_neighbors=3, add_indicator=False).fit(scaled_features)  # 确定三邻居规则
        self.indicator_indices_ = np.flatnonzero(np.isnan(feature_array).any(axis=0))  # 确定训练折出现缺失的指示器列
        return self  # 返回已确定候选以复用验证窗转换
    def transform(self, feature_matrix):  # 用训练折参数转换训练或验证特征
        feature_array = np.asarray(feature_matrix, dtype=float)  # 保持输入列与训练说明一致
        scaled_features = self.scaler_.transform(feature_array)  # 不从验证窗重估尺度
        scaled_imputed = self.imputer_.transform(scaled_features)  # 在确定无量纲空间搜索邻居并插补
        original_scale_imputed = self.scaler_.inverse_transform(scaled_imputed)  # 将三个业务特征还原到原量纲
        missing_indicators = np.isnan(feature_array[:, self.indicator_indices_]).astype(float)  # 按确定列追加缺失指示器
        return np.column_stack([original_scale_imputed, missing_indicators])  # 完成内容原量纲特征与稳定指示器
代码
imputer_factories = {  # 确定三种候选插补器及其超参数
    # 注册带缺失指示器的中位数插补器工厂。
    '中位数': lambda: SimpleImputer(strategy='median', add_indicator=True),
    # 注册三邻居 KNN 插补器工厂。
    'KNN(折内Z-score距离)': lambda: FoldScaledKNNImputer(),
    # 注册固定种子的十轮单次迭代插补器工厂。
    '单次迭代': lambda: IterativeImputer(max_iter=10, random_state=42, sample_posterior=False, add_indicator=True)
}
validation_years = [2021, 2022, 2023]  # 确定三个连续时间外验证年

完整答案(3/4):扩展窗口与还原检查

代码
def evaluate_imputation_fold(validation_year, method_name, imputer_factory):  # 在一个扩展窗口内完成遮蔽、插补与预测
    training_rows = imputation_panel.query('year < @validation_year').copy()  # 仅使用验证年之前的公司年度
    validation_rows = imputation_panel.query('year == @validation_year').copy()  # 固定下一年时间外验证窗
    assert len(training_rows) > 0 and len(validation_rows) > 0, '扩展窗口为空'  # 样本数量变化异常时明确停止
    training_features = training_rows[imputation_features].copy()  # 复制训练特征以实施人工遮蔽
    observed_positions = np.flatnonzero(training_features['book_to_market_ratio_lf'].notna().to_numpy())  # 定位原本可见 B/M
    random_generator = np.random.default_rng(42 + validation_year)  # 为每折固定且可复算的遮蔽序列
    masked_positions = random_generator.choice(observed_positions, size=max(1, round(len(observed_positions) * 0.2)), replace=False)  # 遮蔽训练期 20% 可见 B/M
    masked_truth = training_features.iloc[masked_positions, 0].to_numpy()  # 保存遮蔽真值供还原评估
    training_features.iloc[masked_positions, 0] = np.nan  # 仅在训练折制造 MCAR 遮蔽
    start_time = time.perf_counter()  # 从拟合前开始记录运行时间
    fitted_imputer = imputer_factory().fit(training_features)  # 插补器只观察当前训练折
    imputed_training = fitted_imputer.transform(training_features)  # 转换训练特征并附缺失指示变量
    imputed_validation = fitted_imputer.transform(validation_rows[imputation_features])  # 用训练折参数转换验证特征
    prediction_model = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(imputed_training, training_rows['next_bm'])  # 只由当前训练折逐特征估计缩放统计量并拟合 Ridge
    validation_predictions = prediction_model.predict(imputed_validation)  # 生成下一年时间外 B/M 预测
    restored_bm = imputed_training[masked_positions, 0]  # 取回人工遮蔽位置的插补值
    elapsed_seconds = time.perf_counter() - start_time  # 记录完整插补与预测耗时
    return {'fold': validation_year, 'method': method_name, 'train_n': len(training_rows), 'valid_n': len(validation_rows), 'masked_mae': mean_absolute_error(masked_truth, restored_bm), 'oos_mae': mean_absolute_error(validation_rows['next_bm'], validation_predictions), 'bm_mean_shift': float(np.nanmean(restored_bm) - np.mean(masked_truth)), 'bm_std_shift': float(np.nanstd(restored_bm) - np.std(masked_truth)), 'seconds': elapsed_seconds}  # 返回完整折级指标

完整答案(4/4):执行三种插补

代码
imputation_results = [evaluate_imputation_fold(year, name, factory) for year in validation_years for name, factory in imputer_factories.items()]  # 完整运行三年乘三种方法
imputation_results_table = pd.DataFrame(imputation_results)  # 汇总逐折运行输出
baseline_rows = []  # 收集训练中位数和完整行 Ridge 基线
for validation_year in validation_years:  # 对同一时间外窗口计算简单基线
    training_rows = imputation_panel.query('year < @validation_year').dropna(subset=imputation_features + ['next_bm']).copy()  # 完整行训练样本
    validation_rows = imputation_panel.query('year == @validation_year').dropna(subset=imputation_features + ['next_bm']).copy()  # 完整行验证样本
    median_predictions = np.repeat(training_rows['next_bm'].median(), len(validation_rows))  # 训练期目标中位数基线
    complete_model = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(training_rows[imputation_features], training_rows['next_bm'])  # 只由完整行训练样本逐特征缩放并拟合 Ridge 基线
    complete_predictions = complete_model.predict(validation_rows[imputation_features])  # 生成完整行基线预测
    baseline_rows.append({'fold': validation_year, 'median_oos_mae': mean_absolute_error(validation_rows['next_bm'], median_predictions), 'complete_row_oos_mae': mean_absolute_error(validation_rows['next_bm'], complete_predictions)})  # 保存同窗基线指标
baseline_results = pd.DataFrame(baseline_rows)  # 形成逐折基线表

完整答案(4/4a):逐折完整输出

代码
fold_results_display = imputation_results_table.rename(columns={'fold': '验证年', 'method': '方法', 'train_n': '训练', 'valid_n': '验证', 'masked_mae': '遮蔽MAE', 'oos_mae': '窗外MAE', 'bm_mean_shift': '均值移位', 'bm_std_shift': '标准差移位', 'seconds': '秒'})  # 缩短投影表头并保留全部九列原始含义
display(fold_results_display.round(5))  # 输出逐折三方法完整结果
表 2: 固定八家公司三种插补法的扩展窗口实跑结果
验证年 方法 训练 验证 遮蔽MAE 窗外MAE 均值移位 标准差移位
0 2021 中位数 16 8 0.47417 0.17886 -0.26376 -0.59620 0.00485
1 2021 KNN(折内Z-score距离) 16 8 0.15933 0.16849 -0.15933 -0.19511 0.00383
2 2021 单次迭代 16 8 0.52654 0.17886 -0.10517 -0.59566 0.00771
3 2022 中位数 24 8 0.40881 0.23131 -0.18311 -0.47070 0.00388
4 2022 KNN(折内Z-score距离) 24 8 0.03074 0.12044 0.01699 -0.01846 0.00345
5 2022 单次迭代 24 8 0.44676 0.23018 0.01483 -0.46893 0.00763
6 2023 中位数 32 8 0.46974 0.10883 -0.40407 -0.63561 0.00370
7 2023 KNN(折内Z-score距离) 32 8 0.12938 0.19955 -0.11745 -0.12841 0.00344
8 2023 单次迭代 32 8 0.18522 0.18458 -0.12040 -0.21015 0.00964

完整答案(4/4b):均值、最差折与耗时

代码
imputation_summary = imputation_results_table.groupby('method').agg(
    mean_masked_mae=('masked_mae', 'mean'), mean_oos_mae=('oos_mae', 'mean'),
    worst_oos_mae=('oos_mae', 'max'), total_seconds=('seconds', 'sum')
).rename_axis('方法').rename(columns={
    'mean_masked_mae': '平均遮蔽误差', 'mean_oos_mae': '平均时间外误差',
    'worst_oos_mae': '最差时间外误差', 'total_seconds': '总耗时(秒)'
})
display(imputation_summary.round(5))
表 3: 三种插补法的跨折均值、最差折与总耗时
平均遮蔽误差 平均时间外误差 最差时间外误差 总耗时(秒)
方法
KNN(折内Z-score距离) 0.10649 0.16283 0.19955 0.01072
中位数 0.45091 0.17300 0.23131 0.01243
单次迭代 0.38617 0.19787 0.23018 0.02499

完整答案(4/4c):简单基线

代码
display(baseline_results.round(5))  # 输出训练中位数和完整行基线
表 4: 训练中位数与完整行 Ridge 基线
fold median_oos_mae complete_row_oos_mae
0 2021 0.49567 0.19042
1 2022 0.54569 0.12259
2 2023 0.46523 0.16251

完整答案(4/4d):受控 MAR 遮蔽

为区分机制,额外令高市值公司更容易缺失 B/M。缺失概率只依赖已观测的 log_market_cap,因此这是受控 MAR 恢复实验,不是自然缺失识别。

代码
mar_training = imputation_panel.query('year < 2023').copy()  # 固定2023年前记录为MAR训练样本
mar_features = mar_training[imputation_features].copy()  # 复制原始完整特征用于遮蔽
mar_probability = (mar_features['log_market_cap'].rank(pct=True) * 0.35).to_numpy()  # 令缺失概率仅依赖已观测市值排名
mar_random = np.random.default_rng(2026)  # 固定MAR遮蔽随机序列
mar_mask = mar_random.uniform(size=len(mar_features)) < mar_probability  # 按观测协变量生成缺失指示
mar_truth = mar_features.loc[mar_mask, 'book_to_market_ratio_lf'].copy()  # 保存人工遮蔽位置真值
mar_features.loc[mar_mask, 'book_to_market_ratio_lf'] = np.nan  # 只遮蔽B/M目标特征
mar_rows = []  # 收集三种方法的还原误差
for method_name, imputer_factory in imputer_factories.items():  # 在同一MAR遮蔽上比较候选方法
    mar_fitted_imputer = imputer_factory().fit(mar_features)  # 只用当前训练窗确定尺度、权重与插补参数
    mar_imputed = mar_fitted_imputer.transform(mar_features)  # 用同一确定变换还原遮蔽值并保持原量纲
    mar_rows.append({'method': method_name, 'masked_n': int(mar_mask.sum()), 'mar_recovery_mae': mean_absolute_error(mar_truth, mar_imputed[mar_mask, 0])})  # 计算遮蔽位置MAE
display(pd.DataFrame(mar_rows).round(5))  # 报告受控MAR恢复结果
表 5: 高市值驱动的受控MAR遮蔽还原误差
method masked_n mar_recovery_mae
0 中位数 5 0.16502
1 KNN(折内Z-score距离) 5 0.04790
2 单次迭代 5 0.08233

完整答案(4/4e):MNAR delta 敏感性

MNAR 不能由观测数据识别。以下模式混合敏感性只回答:若缺失公司的真实 B/M 系统性比 MAR 插补值高或低 0.10,结论会怎样变化?

代码
reference_imputer = IterativeImputer(max_iter=10, random_state=42, sample_posterior=False).fit(mar_features)  # 以单次迭代插补建立MAR参考
reference_values = reference_imputer.transform(mar_features)[:, 0]  # 提取参考B/M插补值
delta_rows = []  # 收集不同不可识别偏移下的误差
for delta_bm in [-0.10, 0.00, 0.10]:  # 预设悲观、中性和乐观模式偏移
    adjusted_values = reference_values[mar_mask] + delta_bm  # 只调整缺失组的潜在B/M
    delta_rows.append({'delta_bm': delta_bm, 'recovery_mae': mean_absolute_error(mar_truth, adjusted_values), 'adjusted_mean': adjusted_values.mean()})  # 量化结论对MNAR偏移的敏感度
display(pd.DataFrame(delta_rows).round(5))  # 展示不可识别假设改变时的结果范围
表 6: MNAR模式混合delta敏感性边界
delta_bm recovery_mae adjusted_mean
0 -0.1 0.08911 0.10184
1 0.0 0.08233 0.20184
2 0.1 0.17777 0.30184
  • 可以说:某方法在人工 MCAR 基准或受控 MAR 遮蔽中的还原误差较低。
  • 不能说:已识别自然缺失机制;原始三字段没有自然缺失。
  • MNAR 表含义:只呈现 delta 假设下的敏感范围。
  • 范围边界:统计推断与其他公司不在实验范围;未胜简单基线必须保留。

本章小结

  • 能用 \(R,Y_{obs},Y_{mis},X\) 区分 MCAR/MAR/MNAR,并说明观测数据不能单独验证机制。
  • 删除、单次插补、proper MI 与敏感性分析由目标、机制、模型与精度共同决定,不由固定缺失率决定。
  • 当前遮蔽实验不识别真实缺失机制;未胜简单基线也必须保留。
  • 下一章进入文本表示,继续把所有预处理限定在训练折并检查失败输入。