本章会用到的数据
公开下载:A 股季度估值指标。本章使用 2019—2024 年末的市净率、市值和股息率。
这些数据能做什么:比较均值、K 近邻和 MICE 等缺失值处理方法。
分析时注意:插补方法只能根据训练数据确定,不能先查看验证或最终测试数据。
判断方法:人为遮住一部分已知值,再比较不同方法能否较好恢复这些值。
【课堂核心】2.5 学时学习安排
| 动机与先修 |
15 |
| 缺失机制 |
35 |
| 插补与推断边界 |
30 |
| 方法比较 |
10 |
| 形成性检查与反馈 |
15 |
| 独立遮蔽任务 |
30 |
| 答案、判读与小结 |
15 |
核心问题:为何“数据清洗”是分析中最耗时也最关键的一步?
在理想世界中,我们拥有完美、完整的数据集。但在现实中,几乎所有真实世界的经济和金融数据集都存在缺失值 (Missing Values)。
- 一个交易策略的回测表现,可能因为错误处理了财报数据中的一个缺失值而天差地别。
- 一个宏观经济预测模型,可能因为无法处理调查数据中的缺失项而产生巨大偏差。
今天,我们将系统学习如何科学地应对这个无处不在的挑战。
数据科学的冰山
一个成功的量化分析项目,其工作量分布往往像一座冰山。
缺失值处理,正是这座冰山水下最庞大、最关键的部分。
本章的学习路线图
我们的目标是让大家成为处理缺失数据的专家。
一切始于一个不完美的数据集
想象一下,我们正在为一家投资公司分析几家上市公司的财务指标,数据如 表 1 所示。
数据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)
完全随机缺失 (Missing Completely at Random, MCAR) 指 \(P(R\mid Y_{obs},Y_{mis},X)=P(R)\):缺失概率与观测值、未观测值和完全观测协变量均无关。
- 直观理解:缺失的发生是纯粹的“坏运气”,就像随机抛硬币决定是否删除一个数据点。
- 金融例子:一位研究助理在手动录入公司财报数据时,不小心打翻了咖啡,随机污染了表格中的几个单元格,导致这些数据无法读取。这些数据的缺失与公司的任何基本面都毫无关系。
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 不能由观测数据单独验证,也不能无来源地称为金融数据中“最常见”的机制。
- 完整案例:是否有效取决于缺失变量、条件模型与估计对象。
- 若响应缺失只依赖已纳入且完全观测的 \(X\),正确设定的 \(E[Y\mid X]\) 斜率仍可能一致。
- 边际均值或分布通常改变,且效率下降。
- MICE:MAR 本身不保证无偏。
- 有效推断还要求缺失模型与分析模型正确、协变量重叠、插补与目标相容,并用 proper imputation 和 Rubin rules。
- KNN 与单次确定性插补主要服务预测,不自动获得 Rubin-rule 推断保证。
机制三:非随机缺失 (MNAR)
非随机缺失 (Missing Not at Random, MNAR) 指:在给定已观测信息后,缺失概率仍依赖未观测值或其他未观测因素。
- 自我遮蔽例子:缺失取决于该变量自身未观测值,是 MNAR 的一种常见情形,但不是 MNAR 的全部定义。
- 金融例子:
- 表现最差的基金选择不向数据库报告其月度回报,以掩盖其糟糕的业绩。
- 负债率极高的公司可能选择不披露某些关键的债务指标。
MNAR是统计建模的“禁区”
如果数据是MNAR,任何标准的缺失数据处理方法都可能失效并产生严重误导。
- 核心挑战:给定已观测信息后,缺失概率仍依赖未观测值或未观测因素。观测协变量或许仍能预测缺失值,但仅凭观测数据不能保证识别缺失机制或消除选择偏差。
- 处理思路:MNAR 无法只由已观测数据检验或识别。需要显式选择模型、模式混合模型、外部信息或预先声明的 delta 边界/敏感性分析;缺失指示变量只能帮助预测或描述,不能单独修复选择偏差。
总结:缺失机制决定处理策略
| MCAR |
无 |
数据录入员的随机失误 |
依估计目标、样本量与效率权衡完整案例或折内插补 |
| MAR |
已观测信息 |
迁移批次决定收入字段是否缺失 |
统计推断需 proper MI 与 Rubin rules;预测任务在每折训练窗比较单次插补器 |
| MNAR |
给定观测信息后仍依赖未观测值/因素 |
差基金不报告回报 |
选择/模式混合模型与 delta 敏感性;指示变量不能识别机制 |
简单方法 1:直接删除 (Listwise Deletion)
这是最简单粗暴的方法:只要一行数据中任何一个变量是缺失的,就将整行数据(整个观测样本)从数据集中删除。
直接删除法的优缺点
优点:
- 实现极其简单。
- 在 MCAR、抽样与分析模型等条件成立时,对相应估计目标通常保持一致,但精度下降。
缺点:
- 严重损失信息:如果多个变量都有一些缺失,可能会删除大部分数据。
- 条件性后果:完整案例是否偏离目标取决于缺失变量、条件模型与估计对象。若响应只依赖已纳入且完全观测的 \(X\) 而缺失,正确设定的 \(E[Y\mid X]\) 斜率仍可能一致;边际均值/分布通常改变且效率下降。
结论:是否删除取决于估计目标、缺失机制与模式、样本量、抽样设计和精度要求,不能由固定缺失比例单独决定。
简单方法 2:单变量填充 (Simple Imputation)
这种方法试图保留样本量,用一个“合理”的估计值来填补空缺。
- 对于连续变量:用该变量所有非缺失值的均值或中位数来填充。
- 对于分类变量:用该变量所有非缺失值的众数(出现频率最高的类别)来填充。
- 对于时间序列数据:用上一个时间点的值来填充(Last Observation Carried Forward, LOCF)。
均值填充的机制
均值/中位数填充是“美丽的毒药”
虽然均值填充保留了样本量,但它引入了新的、更隐蔽的问题。
- 人为制造了尖峰:大量原本分散的缺失值被强制设定为同一个值(均值),在数据分布的中心制造出一个不自然的尖峰。
- 低估了真实方差:填充进去的值没有任何变异性,这会人为地降低整个变量的方差。
- 改变变量关系:确定性单值插补通常压缩被插补变量的离散度;协方差和相关性的变化方向与大小仍取决于缺失机制、联合分布和插补模型,不能一概写成向下偏。
可视化警告:均值填充如何扭曲数据分布
图 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()
解读固定遮蔽实验
在这一次固定种子、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 算法的直观步骤 (2/4)
- 将 \(X_1\) 中的临时均值重新设为缺失。
- 以 \(X_2\)(临时填充)和 \(X_3\) 为自变量,拟合 \(X_1 \sim f(X_2, X_3)\)。
- 按当前条件模型为缺失位置生成候选更新值。
边界:单次确定性演示可用点预测;proper multiple imputation 需随机抽样并生成多个完成数据集。
MICE 算法的直观步骤 (3/4)
- 将 \(X_2\) 中的临时均值重新设为缺失。
- 以更新后的 \(X_1\) 和 \(X_3\) 为自变量,拟合 \(X_2 \sim g(X_1, X_3)\)。
- 按当前条件模型生成 \(X_2\) 的候选更新值。
边界:该更新继承模型设定、重叠与前一步候选值的不确定性。
MICE 算法的直观步骤 (4/4)
- 一个循环:先更新 \(X_1\),再更新 \(X_2\)。
- 停止条件:达到预设迭代次数,或链近似稳定。
- 结果依赖:MAR 等机制假设、插补模型设定与随机抽样方案。
- 必须监测:链间比较、插补分布与敏感性。
- 不能推出:近似稳定不保证插补准确、模型相容或推断有效。
高级方法 2:K-近邻填充 (KNN Imputation)
KNN Imputation 的思想非常直观:物以类聚,人以群分。
要填充一个样本A的缺失值,可以这样做:
- 寻找邻居:在数据集中,根据所有未缺失的变量,找到与样本A最相似的K个样本(即它的“邻居”)。
- 取长补短:用这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 填充值解读
基于已知的“账面市值比”找到 5 个最近邻,再用这些邻居的资产回报率均值填充红色点;结果依赖距离尺度与 \(K\) 的选择。
单次 IterativeImputer vs. KNN:建模边界
| 基本原理 |
逐列用其余变量回归,当前设置只生成一个确定性完成数据集 |
基于样本距离与邻域均值 |
| 假设 |
默认 BayesianRidge 刻画数值变量的线性条件关系;非线性须另换估计器并验证 |
距离、尺度与局部相似性具有任务含义 |
| 计算成本 |
较高(需要多次迭代训练模型) |
中等(需要计算距离矩阵) |
| 输入边界 |
当前实现用于数值输入;类别变量须先按语义编码,缩放与编码均在训练折确定 |
数值距离特征;尺度/权重同样只能在训练折确定 |
单次 IterativeImputer vs. KNN:验证与推断
| 比较原则 |
与中位数、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 中我们可以清晰地看到:
- 均值填充(红色):正如理论所预测的,它在数据均值附近制造了一个巨大的、不自然的尖峰,完全没有捕捉到数据的真实分布形态。
- 单次迭代插补(绿色):在当前人为生成机制下更接近被遮蔽值的分布;这不证明对真实财报、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。
检查:缺失比例能否单独决定删除?
- 缺失率为 3% 时,能否仅凭比例决定完整案例删除?
IterativeImputer(sample_posterior=False) 是否等于 proper multiple imputation?
请分别说明判断依据。
反馈:机制、目标与插补口径共同决定
- 删除决策:不能只看缺失比例,还取决于估计目标、缺失机制与模式、样本量和精度要求。
- 方法边界:确定性单次迭代插补不等于 \(m>1\) 的随机插补加 Rubin rules。
- 回看:机制见 相关内容;单次插补与 proper MI 的边界见 相关内容。
课堂讨论:什么时候不应该填充数据?
我们已经学习了如何填充数据,但一个更深刻的问题是:是否应该填充?
请思考一个具体的例子,在什么情况下,对缺失数据进行任何形式的填充都可能是一个错误,甚至会误导我们的分析?
你的缺失数据处理清单
- [侦察] 检查每个变量的缺失比例。
df.isnull().sum() / len(df)
- [思考] 深入思考缺失的可能机制。是MCAR, MAR, 还是MNAR?这是最重要的一步。
- [决策]
- 如果是MNAR:停止把标准插补当作识别方案;报告不可识别性,使用专门选择/模式混合模型,并做 delta 敏感性分析。缺失指示变量只可作为预测辅助。
- 如果可辩护为 MCAR:按估计目标、抽样设计、缺失模式、样本量与效率损失决定是否使用完整案例;不套用固定比例阈值。
- 如果可辩护为 MAR:在训练窗口内比较简单与高级插补,并检查可识别条件、重叠与模型相容性。
- 如果机制不确定:并列报告可辩护的 MAR 分析与事先确定 MNAR delta/选择模型敏感性范围,不得假定高级插补已经解决选择偏差。
你的缺失数据处理清单:执行与验证
- [执行] 预测任务可把单次
IterativeImputer(sample_posterior=False) 或 KNNImputer 作为折内候选;统计推断需要生成多个随机插补数据集并用 Rubin rules 合并的 proper multiple imputation。
- [验证] 同时报告遮蔽恢复误差、下游时间外指标、分布与尾部诊断、MNAR 敏感性;KDE 只能作辅助,统计推断还需 proper multiple imputation 与 Rubin rules。
阶段小结:缺失机制与工具边界
- 缺失机制是第一性原理:处理缺失数据的第一步永远是思考其背后的机制(MCAR, MAR, MNAR)。这将指导你所有后续的选择。
- 简单方法也有条件:确定性均值/中位数插补通常压缩被插补变量的离散度,但关系量偏差方向依赖机制与联合分布;完整案例在可辩护的 MCAR 等条件下可能有效,却会损失效率。两者都须围绕估计目标验证,不能套用无条件结论。
- 按假设选择工具:单次
IterativeImputer、KNN 与完整 MICE 的目标不同,没有一种方法保证偏差更小;需要结合缺失机制、预测任务和敏感性分析比较。
- 实践出真知:工具的选择最终要服务于你的研究目的。始终要通过可视化等手段,检查填充后的数据是否保留了合理的分布形态。
独立练习:遮蔽实验与决策
- 固定输入:
stock/valuation_factors_quarterly_15_years.h5 的 valuation_factors 键。
- 公司与时期:指定的 8 家公司,严格取 2019—2024 各年最后一个观测日。
- 字段与目标:用 B/M、市值、股息率预测下一年末 B/M。
- 候选方法:中位数、KNN 与单次
IterativeImputer,并保留缺失指示变量。
- 防泄漏约束:插补器只在当前训练折拟合,并在同一时间外验证窗比较。
先报告自然缺失边界:本次固定公司与字段在建模面板中的原始缺失率实跑为 0%。因此下列 20% 遮蔽只是一项人为 MCAR 还原基准,不能证明方法处理自然 MAR 或 MNAR 时有效。
独立练习:机制与稳健性检查
提交前完成三项同口径检查:
- 以训练折中位数作为简单基线。
- 让 B/M 缺失概率只依赖已观测
log_market_cap,完成受控 MAR 遮蔽。
- 以单次迭代插补为 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)) # 输出逐折三方法完整结果
完整答案(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))
完整答案(4/4c):简单基线
代码
display(baseline_results.round(5)) # 输出训练中位数和完整行基线
完整答案(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恢复结果
完整答案(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)) # 展示不可识别假设改变时的结果范围
- 可以说:某方法在人工 MCAR 基准或受控 MAR 遮蔽中的还原误差较低。
- 不能说:已识别自然缺失机制;原始三字段没有自然缺失。
- MNAR 表含义:只呈现 delta 假设下的敏感范围。
- 范围边界:统计推断与其他公司不在实验范围;未胜简单基线必须保留。
本章小结
- 能用 \(R,Y_{obs},Y_{mis},X\) 区分 MCAR/MAR/MNAR,并说明观测数据不能单独验证机制。
- 删除、单次插补、proper MI 与敏感性分析由目标、机制、模型与精度共同决定,不由固定缺失率决定。
- 当前遮蔽实验不识别真实缺失机制;未胜简单基线也必须保留。
- 下一章进入文本表示,继续把所有预处理限定在训练折并检查失败输入。