本章会用到的数据
- 上市公司财务报表(约 340 MB):用于练习 PCA 与下一年度 EPS 预测。
- 代码会在首次运行时把文件下载到
data/course/,以后直接读取已下载的副本。
- 我们只使用预测时点之前已经披露的财务信息,并把较晚年份留作最终测试。
【课堂核心】3 学时学习安排(1/2)
| 动机与先修 |
20 |
| PCA 直觉与目标 |
25 |
| 定义与选维 |
20 |
| 形成性检查 |
20 |
【课堂核心】3 学时学习安排(2/2)
| 示例输入 |
10 |
| PCA 投影 |
10 |
| 模型与评估 |
15 |
| 独立任务 |
40 |
| 反馈与小结 |
20 |
【可选拓展】:未列入本表的全部特征图、更多主成分与推导安排课后。
欢迎来到第12章:主成分分析
本章学习目标
主题:主成分分析(Principal Component Analysis, PCA)是一种无监督降维方法。
学习结果:完成本章后,你将能够:
量化投资的困境:信号过载
预测股票回报是金融界永恒的圣杯。在量化投资中,我们已经发现了成百上千个潜在的投资“信号”或“因子”。
- 价值 (市盈率, 市净率…)
- 动量 (过去12个月回报…)
- 质量 (ROA, ROE…)
- 波动率 (Beta, IV…)
- …还有成百上千个
核心问题是: 这么多的信号,真的是越多越好吗?
挑战 1: 维度灾难
直接将所有信号作为模型特征,会遇到严重的问题:维度灾难 (Curse of Dimensionality)。
- 覆盖成本急升:若每个特征轴都按相同分辨率划分,网格单元数与维度呈指数增长;维持同等覆盖密度所需样本也会迅速增加。
- 计算成本因算法而异:部分运算随特征数近似线性或多项式增长,不能把所有高维算法的运行时间都概括为指数增长。
- 过拟合是条件风险:当样本相对不足、模型容量过大或正则化不充分时,高维特征更容易放大噪声;它不是增加一个特征后的必然结果。
挑战 2: 多重共线性
定义:设计矩阵列之间的精确或近似线性依赖。
线索不等于定义:高两两相关只是线索;两两相关不高时,多变量线性组合仍可能共线。
后果:在给定规格下,放大系数方差并使解释不稳定。
例如: 市盈率 (P/E), 市净率 (P/B), 市销率 (P/S) 都反映了公司的“价值”维度。
后果: 导致模型参数的估计极其不稳定,难以解释模型的真正驱动因素。
解决方案: 主成分分析 (PCA)
PCA 提供了一个优雅的解决方案,其核心思想是:数据降维 (Dimensionality Reduction)。
将大量相关的原始变量,转换为少数几个互不相关的新变量,即主成分 (Principal Components)。
我们的路线图
直观理解:从一个简单例子开始
- 指标 1:市销率(Price-to-Sales, x1)。
- 指标 2:市盈率(Price-to-Earnings, x2)。
- 设定:两个指标高度相关,数据点分布在二维平面上。
数据点的分布揭示了主要变化方向
- 坐标:标准化后的市销率 x1 与市盈率 x2。
- 核心观察:数据点沿某个方向呈现最强变化,而非随机铺开。
代码
# 为“数据点的分布揭示了主要变化方向”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“数据点的分布揭示了主要变化方向”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 固定随机种子,使相关估值指标的 PCA 几何机制示例可重复。
np.random.seed(42)
# 创建两个相关的变量
X1 = np.random.randn(20)
# 令第二维等于 0.8 倍第一维再加噪声,形成 PCA 可识别的斜向点云。
X2 = X1 * 0.8 + np.random.randn(20) * 0.5
# 提取 `X` 作为“数据点的分布揭示了主要变化方向”的特征矩阵,明确模型可见的输入列。
X = np.vstack((X1, X2)).T
# 对二维指标逐列去均值并除以标准差,使 PCA 不受原量纲支配。
X_std = (X - X.mean(axis=0)) / X.std(axis=0)
# 为“数据点的分布揭示了主要变化方向”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(11, 5))
# 将标准化估值指标画成点云,检查两维是否存在共同变化方向。
plt.scatter(X_std[:, 0], X_std[:, 1], c='#0d6efd', s=60, alpha=0.8, edgecolors='w', label='原始数据点 (估值指标)')
# 将横轴标为“标准化市销率 (x1)”,明确横向编码的变量。
plt.xlabel('标准化市销率 (x1)', fontsize=16)
# 将纵轴标为“标准化市盈率 (x2)”,明确纵向编码的变量。
plt.ylabel('标准化市盈率 (x2)', fontsize=16)
# 将图题设为“数据分布揭示了主要变化方向”,直接说明当前图形的比较目的。
plt.title('数据分布揭示了主要变化方向', fontsize=14)
# 在 `0` 处添加水平参考线,标出“两个相关变量的散点图”的基准或阈值。
plt.axhline(0, color='grey', lw=0.5)
# 在 `0` 处添加垂直参考线,标出“两个相关变量的散点图”的基准或阈值。
plt.axvline(0, color='grey', lw=0.5)
# 为“数据点的分布揭示了主要变化方向”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 取得当前坐标轴对象,以便继续设置图形比例与样式。
plt.gca().set_aspect('equal', adjustable='box')
# 显示“数据点的分布揭示了主要变化方向”图例,使颜色或线型与比较对象一一对应。
plt.legend()
# 显示二维点云及主变化方向,检查样本沿正斜率方向的离散程度明显大于正交方向。
plt.show()
PCA的目标:找到捕捉最大方差的新坐标轴
PCA的任务就是找到一个新的坐标系。
- 第一主成分 (PC1): 是穿过数据点云、能最大化数据投影方差的那个轴。它代表了数据中最重要的变化方向。
代码
# 为“PCA的目标:找到捕捉最大方差的新坐标轴”,从 `sklearn.decomposition` 导入`PCA` 用于估计主成分方向并投影样本。
from sklearn.decomposition import PCA
# 建立保留两个方向的 PCA,用于从标准化点云估计完整正交基。
pca = PCA(n_components=2)
# 从 `X_std` 的协方差结构估计 `pca` 的主成分方向。
pca.fit(X_std)
# 从 PCA 结果提取 `components`(主成分方向),用于解释投影方向与载荷。
components = pca.components_
# 提取 `explained_variance_` 的特征值尺度方差,用于按投影标准差缩放主成分方向;它不是归一化解释比例。
explained_variance = pca.explained_variance_
# 为“PCA的目标:找到捕捉最大方差的新坐标轴”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(10, 3.5))
# 叠加主成分前先画出原始点云,作为方向比较的参照。
plt.scatter(X_std[:, 0], X_std[:, 1], c='#0d6efd', s=60, alpha=0.8, edgecolors='w', label='原始数据点')
# 将横轴标为“标准化市销率 (x1)”,明确横向编码的变量。
plt.xlabel('标准化市销率 (x1)', fontsize=12)
# 将纵轴标为“标准化市盈率 (x2)”,明确纵向编码的变量。
plt.ylabel('标准化市盈率 (x2)', fontsize=12)
# 将图题设为“PCA找到了捕捉最大方差的新坐标轴”,直接说明当前图形的比较目的。
plt.title('PCA找到了捕捉最大方差的新坐标轴', fontsize=18)
plt.tick_params(labelsize=16) # 放大刻度以保证投影视距下可读
# 在 `0` 处添加水平参考线,标出“第一主成分捕捉了最大的方差”的基准或阈值。
plt.axhline(0, color='grey', lw=0.5)
# 在 `0` 处添加垂直参考线,标出“第一主成分捕捉了最大的方差”的基准或阈值。
plt.axvline(0, color='grey', lw=0.5)
# 为“PCA的目标:找到捕捉最大方差的新坐标轴”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 取得当前坐标轴对象,以便继续设置图形比例与样式。
plt.gca().set_aspect('equal', adjustable='box')
# 按第一主成分方差的平方根设定箭头长度,使方向与解释尺度同时可见。
arrow_len_pc1 = np.sqrt(explained_variance[0]) * 3
# 取得第一主成分单位向量,绘制最大方差方向。
pc1_vec = components[0, :]
# 从 PCA 载荷矩阵取出第一主成分单位向量,作为最大样本方差方向的箭头坐标。
plt.arrow(0, 0, pc1_vec[0] * arrow_len_pc1, pc1_vec[1] * arrow_len_pc1,
# 指定 `head_width` 为箭头头部宽度,细化“第一主成分捕捉了最大的方差”的输出。
head_width=0.2, head_length=0.3, fc='#dc3545', ec='#dc3545', lw=2,
# 将 `label` 设为“第一主成分 (PC1)”,明确“第一主成分捕捉了最大的方差”的显示语义。
label='第一主成分 (PC1)', zorder=3)
# 显示“PCA的目标:找到捕捉最大方差的新坐标轴”图例,使颜色或线型与比较对象一一对应。
plt.legend()
# 显示点云与 PC1 箭头,核对第一主成分沿数据最分散方向而非原坐标轴。
plt.show()
…然后找到与PC1正交的次要方向
- 第二主成分 (PC2): 与PC1正交(垂直),并捕捉剩余方差中最大的部分。
代码
# 为“...然后找到与PC1正交的次要方向”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(10, 6))
# 以同一批标准化样本为底图,核对两个主成分的正交方向。
plt.scatter(X_std[:, 0], X_std[:, 1], c='#0d6efd', s=60, alpha=0.8, edgecolors='w', label='原始数据点')
# 将横轴标为“标准化市销率 (x1)”,明确横向编码的变量。
plt.xlabel('标准化市销率 (x1)', fontsize=12)
# 将纵轴标为“标准化市盈率 (x2)”,明确纵向编码的变量。
plt.ylabel('标准化市盈率 (x2)', fontsize=12)
# 将图题设为“PCA找到了捕捉最大方差的新坐标轴”,直接说明当前图形的比较目的。
plt.title('PCA找到了捕捉最大方差的新坐标轴', fontsize=14)
# 在 `0` 处添加水平参考线,标出“第二主成分与第一主成分正交”的基准或阈值。
plt.axhline(0, color='grey', lw=0.5)
# 在 `0` 处添加垂直参考线,标出“第二主成分与第一主成分正交”的基准或阈值。
plt.axvline(0, color='grey', lw=0.5)
# 为“...然后找到与PC1正交的次要方向”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 取得当前坐标轴对象,以便继续设置图形比例与样式。
plt.gca().set_aspect('equal', adjustable='box')
# 按 PC1 解释方差设定第一支箭头长度,供与 PC2 的正交方向比较。
arrow_len_pc1 = np.sqrt(explained_variance[0]) * 3
# 取得第一主成分单位向量,绘制最大方差方向。
pc1_vec = components[0, :]
# 取出第一主成分载荷向量,绘制最大方差方向作为正交比较基准。
plt.arrow(0, 0, pc1_vec[0] * arrow_len_pc1, pc1_vec[1] * arrow_len_pc1,
# 指定 `head_width` 为箭头头部宽度,细化“第二主成分与第一主成分正交”的输出。
head_width=0.2, head_length=0.3, fc='#dc3545', ec='#dc3545', lw=2,
# 将 `label` 设为“第一主成分 (PC1)”,明确“第二主成分与第一主成分正交”的显示语义。
label='第一主成分 (PC1)', zorder=3)
# 用第二主成分标准差的两倍确定次轴箭头长度。
arrow_len_pc2 = np.sqrt(explained_variance[1]) * 3
# 取得与第一主成分正交的第二主成分单位向量。
pc2_vec = components[1, :]
# 取出第二主成分载荷向量,绘制与 PC1 正交的剩余方差方向。
plt.arrow(0, 0, pc2_vec[0] * arrow_len_pc2, pc2_vec[1] * arrow_len_pc2,
# 指定 `head_width` 为箭头头部宽度,细化“第二主成分与第一主成分正交”的输出。
head_width=0.2, head_length=0.3, fc='#fd7e14', ec='#fd7e14', lw=2,
# 将 `label` 设为“第二主成分 (PC2)”,明确“第二主成分与第一主成分正交”的显示语义。
label='第二主成分 (PC2)', zorder=3)
# 显示“...然后找到与PC1正交的次要方向”图例,使颜色或线型与比较对象一一对应。
plt.legend()
# 显示 PC1 与 PC2 两支箭头,核对方向正交且第一方向解释的离散程度更大。
plt.show()
降维的实现:将数据投影到PC1上
如果我们想把二维数据降为一维,最理想的方式就是将所有数据点投影 (Project)到第一主成分 (PC1) 这个新轴上。
- 原始的二维坐标
(x1, x2) 就变成了一个新的一维坐标 z1。
- 这个
z1 就是我们降维后的新特征,它最大程度地保留了原始数据的核心信息(方差)。
代码
# 将标准化样本转换为主成分得分,取得每个点在 PC1 轴上的一维坐标。
X_projected = pca.transform(X_std)
# 将第一主成分得分逆变换为 `X_reconstructed_pc1`,取得原坐标系中的投影点。
X_reconstructed_pc1 = pca.inverse_transform(np.c_[X_projected[:,0], np.zeros_like(X_projected[:,0])])
# 绘制标准化点云、PC1 轴及每个样本到轴上的正交投影线。
# 为“降维的实现:将数据投影到PC1上”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(11, 2.8))
# 以 `X_std[:, 0]` 为横轴、`X_std[:, 1]` 为纵轴绘制散点,展示“降维就是将数据点投影到最重要的主成分上”。
plt.scatter(X_std[:, 0], X_std[:, 1], alpha=0.5, label='原始数据点', c='#0d6efd', s=60, edgecolors='w')
# 将横轴标为“标准化市销率 (x1)”,明确横向编码的变量。
plt.xlabel('标准化市销率 (x1)', fontsize=16)
# 将纵轴标为“标准化市盈率 (x2)”,明确纵向编码的变量。
plt.ylabel('标准化市盈率 (x2)', fontsize=16)
# 将图题设为“降维的实现:将数据点投影到第一主成分”,直接说明当前图形的比较目的。
plt.title('降维的实现:将数据点投影到第一主成分', fontsize=18)
plt.tick_params(labelsize=16) # 统一主成分图的刻度字号
# 在 `0` 处添加水平参考线,标出“降维就是将数据点投影到最重要的主成分上”的基准或阈值。
plt.axhline(0, color='grey', lw=0.5)
# 在 `0` 处添加垂直参考线,标出“降维就是将数据点投影到最重要的主成分上”的基准或阈值。
plt.axvline(0, color='grey', lw=0.5)
# 为“降维的实现:将数据投影到PC1上”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 取得当前坐标轴对象,以便继续设置图形比例与样式。
plt.gca().set_aspect('equal', adjustable='box')
# 绘制PC1轴
line_range = np.array([-4, 4])
# 生成穿过样本均值的第一主成分直线,承载正交投影示意。
pc1_line = line_range[:, np.newaxis] * components[0, :]
# 绘制第一主成分轴,用作原始点与投影点的共同参照。
plt.plot(pc1_line[:, 0], pc1_line[:, 1], color='#dc3545', lw=2.5, label='第一主成分轴 (PC1)')
# 绘制投影点和投影线
plt.scatter(X_reconstructed_pc1[:, 0], X_reconstructed_pc1[:, 1],
# 用绿色方形标记第一主成分上的投影点,与原始蓝色散点区分。
marker='s', c='green', s=50, label='投影后的数据点 (z1)', zorder=3)
# 逐个样本连接原始坐标与 PC1 投影坐标,显式画出几何投影距离。
for i in range(X_std.shape[0]):
# 以原始点和投影点的第一维为横坐标,绘制两点之间的连接线。
plt.plot([X_std[i, 0], X_reconstructed_pc1[i, 0]],
# 以同一对点的第二维为纵坐标,保证线段端点一一对应。
[X_std[i, 1], X_reconstructed_pc1[i, 1]],
# 为“降维的实现:将数据投影到PC1上”,把投影连接线设为灰色虚线,并用 0.8 线宽弱化其视觉层级。
'--', color='gray', lw=0.8)
# 显示“降维的实现:将数据投影到PC1上”图例,使颜色或线型与比较对象一一对应。
plt.legend()
# 显示原点、PC1 轴与每个样本的正交投影,检查二维信息如何压缩为一维坐标。
plt.show()
几何解释:坐标系旋转
- 变换:将原坐标轴
(x1, x2) 旋转到 (PC1, PC2)。
- 目标:让数据方差在新坐标系中集中到少数轴上。
- 几何本质:PCA 可视为一次坐标系旋转。
PCA的数学定义
现在,让我们将直觉转化为严谨的数学语言。
假设我们有 \(m\) 个经过标准化(均值为0,方差为1)的原始变量 \(x_1, x_2, \dots, x_m\)。
PCA旨在找到 \(m\) 个新的变量 \(z_1, z_2, \dots, z_m\),即主成分。这些主成分具有三个至关重要的特性。
特性 1: 线性组合
每个主成分都是原始变量的线性组合。
\[ \large{z_i = v_{i1}x_1 + v_{i2}x_2 + \dots + v_{im}x_m = \mathbf{v}_i^T \mathbf{x}} \]
其中 \(\mathbf{v}_i\) 是一个权重向量,称为载荷 (loading)。
特性 2: 相互正交
所有主成分之间互不相关 (协方差为0)。
\[ \large{\text{Cov}(z_i, z_j) = 0, \quad \text{for } i \neq j} \]
在样本协方差矩阵及当前标准化口径下,保留的主成分彼此正交,因此回归设计矩阵中的线性共线性可被缓解;删减主成分也会丢失信息,不能称为“完美解决”。
特性 3: 方差递减
主成分按其解释的方差大小降序排列。
\[ \large{\text{Var}(z_1) \ge \text{Var}(z_2) \ge \dots \ge \text{Var}(z_m)} \]
\(z_1\) 捕捉了特征矩阵中最大的样本方差,\(z_2\) 次之。方差不是预测信息的同义词:低方差方向仍可能承载与目标变量高度相关的信号。
第1步:数据标准化
在进行任何计算之前,数据标准化是至关重要的一步。
\[ \large{x_j' = \frac{x_j - \mu_j}{\sigma_j}} \]
为什么本章先标准化?
- 可选口径:PCA 可直接分析协方差矩阵,也可标准化后等价分析相关矩阵。
- 本章选择:财务变量量纲不同,因此先标准化,避免单位和尺度机械主导载荷。
- 例外边界:若研究问题希望大方差变量获得更大权重,可不标准化,但必须说明口径。
第2步:计算协方差矩阵
- 数据矩阵:标准化后记为 \(\mathbf{X}\in\mathbb{R}^{n\times m}\)。
- 计算起点:样本协方差矩阵 \(\mathbf{C}\)。
\[ \large{\mathbf{C} = \frac{1}{n-1} \mathbf{X}^T \mathbf{X}} \]
这是一个 \(m \times m\) 的对称矩阵,其中第 \((i, j)\) 个元素表示原始特征 \(x_i\) 和 \(x_j\) 之间的协方差。
第3步:特征值分解
线性代数的知识告诉我们,对于一个对称矩阵 \(\mathbf{C}\),我们可以对其进行特征值分解 (Eigen-decomposition)。
\[ \large{\mathbf{C} \mathbf{v} = \lambda \mathbf{v}} \]
- \(\mathbf{v}\) 是矩阵 \(\mathbf{C}\) 的特征向量 (Eigenvector) \(\rightarrow\) 主成分的方向 (载荷)
- \(\lambda\) 是对应的特征值 (Eigenvalue) \(\rightarrow\) 主成分解释的方差
实际计算:奇异值分解 (SVD)
在数值计算上,奇异值分解 (Singular Value Decomposition, SVD) 是一种更稳定、更通用的方法。
SVD可以将任何 \(n \times m\) 的数据矩阵 \(\mathbf{X}\) 分解为三个矩阵的乘积:
\[ \large{\mathbf{X}_{n \times m} = \mathbf{U}_{n \times n} \mathbf{\Sigma}_{n \times m} \mathbf{V}^T_{m \times m}} \]
SVD与PCA的直接联系
SVD的计算结果与PCA所需的信息有直接的对应关系:
- 主成分方向: \(\mathbf{V}\) 矩阵的列向量就是主成分的载荷向量。
- 主成分得分: \(\mathbf{U\Sigma}\) 的乘积给出了降维后的新数据。
- 解释的方差: 奇异值 \(\sigma_i\) 的平方与总样本数 \(n-1\) 之比,等于对应主成分解释的方差 (\(\lambda_i = \frac{\sigma_i^2}{n-1}\))。
如何使用PCA进行降维
我们已经知道如何得到全部 \(m\) 个主成分,但我们的目标是降维,即只保留前 \(k\) 个主成分 (\(k < m\))的过程如下:
- 对数据矩阵 \(\mathbf{X}\) 进行SVD分解。
- 选择维度 \(k\):这是降维中最关键的决策。
- 取 \(\mathbf{U}\) 的前 \(k\) 列, \(\mathbf{\Sigma}\) 的左上角 \(k \times k\) 部分。
- 计算降维后的数据 \(\mathbf{Z}^*_{n \times k} = \mathbf{U}_k \mathbf{\Sigma}_k\)。
核心决策:如何选择维度 k?
最常用的方法是累计解释方差比率 (Cumulative Explained Variance Ratio)。
第 \(i\) 个主成分解释的方差比率为:
\[ \large{\text{Ratio}_i = \frac{\lambda_i}{\sum_{j=1}^m \lambda_j}} \]
我们计算前 \(k\) 个主成分的累计比率,并设定一个阈值(如90%)。
\[ \large{\text{Cumulative Ratio}(k) = \sum_{i=1}^k \text{Ratio}_i} \]
我们选择最小的 \(k\),使得这个累计比率达到我们的要求。
检查:解释方差高等于预测更好吗?
某 PCA 规格保留 95% 的 \(X\) 方差,但验证 MSE 高于无 PCA Ridge。能否说 PCA 改善了预测?独立作答并写出下一步路由。
反馈:无监督目标不能替代任务损失
- 答案:不能;保留 \(X\) 方差不保证保留与 \(Y\) 相关的方向。
- 下一步:回到 机制案例,在相同扩展窗比较 PCA-Ridge、Ridge 与训练均值。
- 隔离规则:测试期不参与选择 \(k\)。
可视化工具:碎石图 (Scree Plot)
碎石图是一种非常有用的可视化工具,用于辅助选择 \(k\)。
- 横轴: 主成分的序号 (1, 2, …, m)。
- 纵轴: 每个主成分解释的方差。
我们通常会寻找图形中的“肘部” (Elbow Point)。“肘部”之后的主成分解释的方差迅速减小,可以认为是“碎石”,包含的信息较少,可以考虑丢弃。
机制案例:PCA 与模拟类 EPS 响应
接下来,我们用一个完整的 Python 机制案例比较保留全部特征与仅保留 PC1 的预测结果。
目标:预测同一模拟机制生成的类每股收益响应(EPS-like outcome);本例没有时间轴,采用固定种子的随机留出,不代表未来 EPS 预测。
数据:三个模拟特征 (pps, bm, roa) 与一个模拟类 EPS 响应。
流程:
- 探索性数据分析 (EDA)
- 数据预处理与标准化
- 执行PCA,并分析主成分
- 降维并训练两个回归模型(原始 vs. PCA)
- 比较模型在测试集上的表现
步骤 0:生成并切分机制数据
- 用途:保留生成数据,只用于解释 PCA 的几何直觉和代码用法。
- 经验边界:不是中国市场经验验证,也不支持“PCA 必然改善预测”。
- 后续证据:公开数据的时间外验证与完整练习答案见章末。
代码
# 为“步骤 0:机制模拟(不是公开经验验证)”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“步骤 0:机制模拟(不是公开经验验证)”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“步骤 0:机制模拟(不是公开经验验证)”,从 `sklearn.model_selection` 导入`train_test_split` 用于把样本随机拆成两个互斥子集。
from sklearn.model_selection import train_test_split
# 为“步骤 0:机制模拟(不是公开经验验证)”,导入 `StandardScaler`,供 Pipeline 在每个训练子集内按特征列估计均值与尺度。
from sklearn.preprocessing import StandardScaler
# 为“步骤 0:机制模拟(不是公开经验验证)”,从 `sklearn.linear_model` 导入`LinearRegression` 用于拟合普通最小二乘线性回归。
from sklearn.linear_model import LinearRegression
# 为“步骤 0:机制模拟(不是公开经验验证)”,从 `sklearn.metrics` 导入`mean_squared_error` 用于计算真实值与预测值的均方误差。
from sklearn.metrics import mean_squared_error
# 为“步骤 0:机制模拟(不是公开经验验证)”,从 `sklearn.decomposition` 导入`PCA` 用于估计主成分方向并投影样本。
from sklearn.decomposition import PCA
# 为“步骤 0:机制模拟(不是公开经验验证)”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“步骤 0:机制模拟(不是公开经验验证)”导入 `seaborn` 并绑定 `sns`,用于编码当前任务的统计分布或分组关系。
import seaborn as sns
# 固定随机状态并设定机制样本规模,构造 PCA 不保证预测改进的比较案例。
# 确保结果可复现
np.random.seed(42)
# 固定样本数与特征数,界定“步骤 0:机制模拟(不是公开经验验证)”的机制演示规模。
n_samples = 500
# 生成共同潜在因子,构造三个相关财务特征的共享变化来源。
latent_factor = np.random.randn(n_samples) * 2
# 由共同潜在因子生成 ROA、B/M 与价格代理三项相关特征,并分别叠加噪声。
# roa: Return on Assets (与因子正相关)
roa = 0.6 * latent_factor + np.random.randn(n_samples) * 0.5
# bm: Book-to-Market (价值因子,也与因子正相关)
bm = 0.5 * latent_factor + np.random.randn(n_samples) * 0.6
# pps: Price per Share(仅表示当前模拟机制中的每股价格水平,与潜在因子弱相关)
pps = 0.2 * latent_factor + np.random.randn(n_samples) * 0.8
# 由潜在因子、ROA 和噪声生成类 EPS 响应,保留已知结构供 PCA 机制比较。
simulated_eps_like = 1.2 * latent_factor + 0.8 * roa + np.random.randn(n_samples) * 0.4
# 将三项机制特征与类 EPS 响应按行组成表格,固定后续切分字段顺序。
data = pd.DataFrame({
# 保存机制生成的 ROA 特征。
'roa': roa,
# 保存机制生成的账面市值比特征。
'bm': bm,
# 保存机制生成的每股股价特征。
'pps': pps,
# 保存由潜在因子、ROA 与噪声生成的模拟类 EPS 响应。
'simulated_eps_like': simulated_eps_like
# 完成三项模拟特征与类 EPS 响应表,仅用于演示 PCA 的相关结构机制。
})
# 用固定随机状态随机划分机制训练集和测试集,保证两种回归规格使用同一留出样本。
training_data, testing_data = train_test_split(data, test_size=0.3, random_state=42)
# 报告机制数据已按固定随机状态划分为训练集与测试集。
print("模拟数据已生成并分割。")
步骤 1: 探索性数据分析 (EDA)
在建模之前,先了解我们的数据。
代码
# 显示训练数据的基本统计信息
training_data.describe().round(2)
训练数据摘要统计
| count |
350.00 |
350.00 |
350.00 |
350.00 |
| mean |
0.04 |
0.05 |
0.04 |
0.03 |
| std |
1.27 |
1.12 |
0.90 |
3.35 |
| min |
-3.10 |
-2.98 |
-3.27 |
-8.72 |
| 25% |
-0.77 |
-0.66 |
-0.51 |
-2.26 |
| 50% |
0.02 |
-0.00 |
0.06 |
0.02 |
| 75% |
0.86 |
0.76 |
0.64 |
2.33 |
| max |
3.90 |
2.81 |
2.72 |
9.65 |
EDA: 检查特征间的相关性
热力图展示两两相关,只能筛查线性冗余线索,不能单独诊断多重共线性。
代码
# 为“EDA: 检查特征间的相关性”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(7, 4.25))
# 把 `training_data.corr()` 编码为热图色块,比较“特征相关性热力图”中的成对关系。
sns.heatmap(training_data.corr(), annot=True, cmap='vlag', fmt='.2f', linewidths=.5)
# 将图题设为“特征与目标变量的相关性矩阵”,直接说明当前图形的比较目的。
plt.title('特征与目标变量的相关性矩阵')
# 显示特征与目标的相关矩阵,检查三项特征间冗余及其与响应变量的线性关联。
plt.show()
观察:训练集 corr(roa,bm)≈0.79 仅提示线性冗余;共线性须在训练折用秩、条件数或 VIF 核对。
步骤 2: 数据准备与标准化
- 分离特征(X)和目标变量(y)。
- 对本章异量纲财务特征按预先声明口径标准化;若研究目标希望大方差变量获得更大权重,也可不标准化并说明理由。
重要: 我们在训练集上fit_transform,但在测试集上只transform,以防数据泄露。
代码
# 分离训练样本中的三项模拟特征,避免类 EPS 响应进入输入矩阵。
X_train = training_data.drop('simulated_eps_like', axis=1)
# 提取训练样本的类 EPS 响应,供原始特征与 PCA 回归共同拟合。
y_train = training_data['simulated_eps_like']
# 提取 `X_test` 作为“步骤 2: 数据准备与标准化”的特征矩阵,明确模型可见的输入列。
X_test = testing_data.drop('simulated_eps_like', axis=1)
# 提取随机留出样本的类 EPS 响应,按同一观测顺序评价两个模型。
y_test = testing_data['simulated_eps_like']
# 创建只由训练样本逐特征拟合的缩放器。
scaler_X = StandardScaler()
# 为“步骤 2: 数据准备与标准化”,逐特征估计训练样本均值与标准差,并转换训练特征。
X_train_scaled = scaler_X.fit_transform(X_train)
# 用确定的训练样本逐特征统计量转换测试特征。
X_test_scaled = scaler_X.transform(X_test)
# 报告“数据标准化完成。训练集shape:”中的 `X_train_scaled`,核对“步骤 2: 数据准备与标准化”的样本形状。
print("数据标准化完成。训练集shape:", X_train_scaled.shape)
数据标准化完成。训练集shape: (350, 3)
步骤 3: 执行PCA并分析
我们首先创建一个包含所有3个主成分的PCA对象,来分析每个成分解释的方差。
代码
# 创建并拟合PCA对象
pca_all = PCA(n_components=3)
# 从 `X_train_scaled` 的协方差结构估计 `pca_all` 的主成分方向。
pca_all.fit(X_train_scaled)
# 获取每个主成分解释的方差比率
variance_explained = pca_all.explained_variance_ratio_
# 报告每个主成分的解释方差比例,比较单轴信息贡献。
print(f'各主成分解释的方差比率: {np.round(variance_explained, 3)}')
# 报告累计解释方差,判断保留多少主成分可覆盖主要信息。
print(f'累计解释方差: {np.round(np.cumsum(variance_explained), 3)}')
各主成分解释的方差比率: [0.71 0.22 0.07]
累计解释方差: [0.71 0.93 1. ]
可视化解释方差 (碎石图)
代码
# 为“可视化解释方差 (碎石图)”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(8, 5))
# 绘制三个主成分各自的解释方差比柱形,柱高表示单个方向保留的信息比例。
plt.bar(range(1, 4), variance_explained, color='skyblue', alpha=0.9, label='单个PC方差')
# 叠加累计解释方差折线,展示保留多个主成分后的总信息比例。
plt.plot(range(1, 4), np.cumsum(variance_explained), 'ro-', label='累计方差')
# 将横轴标为“主成分序号”,明确横向编码的变量。
plt.xlabel('主成分序号')
# 将纵轴标为“解释的方差比率”,明确纵向编码的变量。
plt.ylabel('解释的方差比率')
# 将图题设为“各主成分解释的方差比率(碎石图)”,直接说明当前图形的比较目的。
plt.title('各主成分解释的方差比率(碎石图)')
# 查询当前自动生成的横轴刻度位置与标签;本句不修改刻度设置。
plt.xticks()
# 为“可视化解释方差 (碎石图)”,限定纵轴显示范围,避免比例差异误导比较。
plt.ylim(0, 1.1)
# 显示“可视化解释方差 (碎石图)”图例,使颜色或线型与比较对象一一对应。
plt.legend()
# 为“可视化解释方差 (碎石图)”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(axis='x', linestyle='')
# 显示单项与累计解释方差,检查 PC1 是否已保留主要变异以及追加成分的边际信息。
plt.show()
观察:PC1 保留了多数 \(X\) 方差,但这里的“多数信息”只指特征协方差结构,不代表保留了多数目标相关信息。
解读主成分的构成 (载荷)
载荷向量(pca_all.components_)告诉我们每个主成分是如何由原始特征构成的。
代码
# 将变量名与主成分载荷矩阵对齐,便于解释每个方向的构成。
loadings = pd.DataFrame(
# 把 PCA 方向矩阵的转置作为 `载荷表` 的行数据,使特征与主成分载荷对齐。
pca_all.components_.T,
# 用 PC1、PC2、PC3 命名载荷表三列,使系数对应具体主成分。
columns=['PC1', 'PC2', 'PC3'],
# 用原始特征名作为载荷表行名,使每个系数可映射回财务变量。
index=X_train.columns
)
# 输出三位小数载荷矩阵,比较各财务变量对 PC1—PC3 的贡献方向与大小。
print(loadings.round(3))
解读:
- PC1:
roa和bm有很高的正载荷。PC1可以被解释为一个综合的“价值/质量”因子。
- PC2:
pps 有很高的正载荷;在当前模拟样本与符号方向下,PC2 只可称为“每股价格水平代理/价格方向”。pps 不是动量(需确定窗口的滞后收益路径),也不是规模(通常需市值口径)。
步骤 4: 执行降维
为检验“保留多数 \(X\) 方差是否足以改善预测”,本机制案例刻意设定 \(k=1\),只保留最大方差方向 PC1。
代码
# 创建一个新的PCA对象,目标维度为1
pca_k1 = PCA(n_components=1)
# 在训练数据上拟合并转换
X_train_pca = pca_k1.fit_transform(X_train_scaled)
# 在测试数据上进行转换
X_test_pca = pca_k1.transform(X_test_scaled)
# 报告“降维前shape:”中的 `X_train_scaled`,核对“步骤 4: 执行降维”的样本形状。
print("降维前shape:", X_train_scaled.shape)
# 报告“降维后shape:”中的 `X_train_pca`,核对“步骤 4: 执行降维”的样本形状。
print("降维后shape:", X_train_pca.shape)
降维前shape: (350, 3)
降维后shape: (350, 1)
步骤 5: 模型训练与比较
我们将训练两个线性回归模型:
ols_model: 使用降维前的3个特征。
ols_pca_model: 使用降维后的1个主成分。
代码
# 模型1: 基于原始3个特征
ols_model = LinearRegression()
# 用训练期三个标准化特征估计 OLS 截距与系数,作为未降维预测基准。
ols_model.fit(X_train_scaled, y_train)
# 模型2: 基于PCA降维后的1个特征
ols_pca_model = LinearRegression()
# 用训练期 PC1 得分估计一元线性回归的截距与斜率;主成分方向已由前一步 PCA 确定。
ols_pca_model.fit(X_train_pca, y_train)
# 报告原始特征回归与 PCA 回归均已在同一训练期拟合。
print('两个模型训练完成。')
步骤 6: 模型评估
我们分别计算两个模型在测试集上的均方误差 (Mean Squared Error, MSE)。这是衡量模型泛化能力的关键指标。
代码
# 用三特征 OLS 对标准化测试特征生成预测,作为未降维基准。
test_predictions = ols_model.predict(X_test_scaled)
# 计算 `test_mse`(均方误差),量化“步骤 6: 模型评估”的模型表现。
test_mse = mean_squared_error(y_test, test_predictions)
# 用已拟合的 PC1 回归模型生成测试集预测数组,供计算降维模型的 MSE。
test_pca_predictions = ols_pca_model.predict(X_test_pca)
# 计算 `test_pca_mse`(均方误差),量化“步骤 6: 模型评估”的模型表现。
test_pca_mse = mean_squared_error(y_test, test_pca_predictions)
# 打印三特征 OLS 的测试 MSE,作为同一切分下的未降维基准。
print(f'原始模型 (3个特征) - 测试集MSE: {test_mse:.4f}')
# 打印仅用 PC1 的回归测试 MSE,与三特征基准按相同样本比较。
print(f'PCA模型 (1个特征) - 测试集MSE: {test_pca_mse:.4f}')
原始模型 (3个特征) - 测试集MSE: 0.8851
PCA模型 (1个特征) - 测试集MSE: 1.8247
结果解读:一次教学切分中的结果
下面的比较表由当前代码直接生成,表中数值以实际运行结果为准。
代码
# 汇总当前固定种子下的两种测试 MSE 与 PC1 解释方差比,保证展示值和模型输出同源。
pca_comparison_table = pd.DataFrame({'评估口径': ['固定种子、随机留出'], '三特征 OLS 测试 MSE': [test_mse], '仅 PC1 OLS 测试 MSE': [test_pca_mse], 'PC1 解释方差比': [pca_k1.explained_variance_ratio_[0]]})
# 将同源计算结果统一显示到六位小数,便于核对模型比较与解释方差。
pca_comparison_table.round(6)
本次结果
- 比较:仅用 PC1 的 OLS 测试 MSE 高于三特征 OLS,尽管 PC1 保留了多数 \(X\) 方差。
- 机制:PCA 不使用响应变量;在这一实现中,PC1 丢弃的低方差方向仍含目标相关信息。
- 证据边界:单次随机留出只说明当前固定机制与种子,不能证明稳定优劣。
可视化模型表现:预测值 vs. 真实值
一个好的模型,其预测值应该紧密分布在真实值周围(下图中的45度线)。
代码
# 创建 `fig, axes` 画布,承载“两个模型在测试集上的预测表现”的并排视觉比较。
fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharey=True, sharex=True)
# 汇总真实值与两组预测的共同边界 `max_val`,让两幅散点图使用同一尺度。
max_val = max(y_test.max(), test_predictions.max(), test_pca_predictions.max())
# 汇总真实值与两组预测的共同边界 `min_val`,让两幅散点图使用同一尺度。
min_val = min(y_test.min(), test_predictions.min(), test_pca_predictions.min())
# 原始模型
axes[0].scatter(y_test, test_predictions, alpha=0.6, edgecolors='w')
# 绘制曲线元素,编码 `axes` 与 `min_val` 与 `max_val` 的关系,服务于“两个模型在测试集上的预测表现”的视觉比较。
axes[0].plot([min_val, max_val], [min_val, max_val], 'r--', lw=2)
# 用图题概括“可视化模型表现:预测值 vs. 真实值”的比较对象与当前计算结果。
axes[0].set_title(f'原始模型 (3特征)\nTest MSE: {test_mse:.3f}')
# 将横轴标为“真实值 (Actual)”,明确横向编码的变量。
axes[0].set_xlabel('真实值 (Actual)')
# 将纵轴标为“预测值 (Predicted)”,明确纵向编码的变量。
axes[0].set_ylabel('预测值 (Predicted)')
# 为“可视化模型表现:预测值 vs. 真实值”添加辅助网格,便于比较位置、斜率或组间差异。
axes[0].grid(True)
# PCA模型
axes[1].scatter(y_test, test_pca_predictions, alpha=0.6, edgecolors='w', c='green')
# 绘制曲线元素,编码 `axes` 与 `min_val` 与 `max_val` 的关系,服务于“两个模型在测试集上的预测表现”的视觉比较。
axes[1].plot([min_val, max_val], [min_val, max_val], 'r--', lw=2)
# 用图题概括“可视化模型表现:预测值 vs. 真实值”的比较对象与当前计算结果。
axes[1].set_title(f'PCA模型 (1特征)\nTest MSE: {test_pca_mse:.3f}')
# 将横轴标为“真实值 (Actual)”,明确横向编码的变量。
axes[1].set_xlabel('真实值 (Actual)')
# 为“可视化模型表现:预测值 vs. 真实值”添加辅助网格,便于比较位置、斜率或组间差异。
axes[1].grid(True)
# 将图题设为“预测值 vs. 真实值 (测试集)”,直接说明当前图形的比较目的。
plt.suptitle('预测值 vs. 真实值 (测试集)', fontsize=16)
# 为“可视化模型表现:预测值 vs. 真实值”,压缩子图留白,避免标题和坐标标签相互遮挡。
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
# 显示原特征模型与 PC1 模型的测试预测对真实值散点,比较两者偏离 45 度线的程度。
plt.show()
案例结论:PCA是否有益必须验证
这个案例只展示一组条件性结果:
- 通过降维,我们丢弃了一部分信息。这可能导致模型在训练集上的拟合度下降。
- 被丢弃的信息可能是噪音,也可能包含关键预测信号。
- 本次随机留出中
test_pca_mse > test_mse:PC1 丢弃了目标相关信息;这仍不足以证明 PCA 在其他样本或机制中必然更差。应在训练期内选择主成分数,并用重复或嵌套验证及最终测试期与基准模型比较。
正确表述:PCA 会改变偏差—方差权衡,但不保证改善预测。若低方差方向承载目标信号,PCA 反而可能显著恶化样本外表现。
PCA的局限性
尽管PCA非常强大,但它并非万能的。
- 1. 线性假设
-
PCA假设数据中的主要关系是线性的。对于高度非线性的数据结构,效果不佳。
- 2. 对离群值敏感
-
由于依赖方差计算,异常值会极大地影响主成分的方向。
- 3. 可解释性下降
-
主成分是原始特征的线性组合,其业务含义有时不如原始特征直观。
拓展思考:PCA是无监督的
PCA有一个重要的特点:它是一种无监督方法。
在寻找主成分时,PCA只考虑了特征变量X内部的方差结构,完全没有利用目标变量y的信息。
如果我们的最终目标是预测,那么有没有一种方法可以在降维的同时,就考虑到与y的相关性呢?
PLS:一种“有监督”的降维方法
偏最小二乘回归 (Partial Least Squares, PLS) 就是答案。
PCA vs. PLS:如何选择?
| 监督性 |
无监督 (不考虑 y) |
有监督 (考虑 y) |
| 目标 |
最大化 X 的方差 |
最大化 X 和 y 的协方差 |
| 应用 |
特征提取、数据可视化、去噪 |
预测建模,尤其是当特征多、共线性强、样本少时 |
| 结果 |
成分之间严格正交 |
成分之间严格正交 |
可检验选择
- PCA:适合提出无监督结构摘要。
- PLS:把目标信息纳入成分构造。
- 不可预先断言:两者谁的预测误差更低。
- 比较方法:在每个训练折内调参,并以同一最终测试窗口与基线比较。
阶段小结
核心问题:量化投资中的“信号过载”导致维度灾难和多重共线性。
PCA的方案:通过寻找数据中方差最大的方向(主成分),将高维相关数据转换为低维不相关数据。
数学核心:PCA的本质是计算数据协方差矩阵的特征值分解,通常通过更稳健的SVD算法实现。
关键实践:本章对异量纲财务变量预先选择训练折内标准化;是否标准化与维度 k 都应由研究口径和验证证据决定。
核心价值:PCA可压缩相关特征并降低计算量;它是否减轻过拟合或改善预测,是需要与无 PCA 基线比较的经验问题。
拓展视野:PLS是一种有监督的降维方法,其相对 PCA 的预测优势必须通过折内调参与最终测试比较验证。
独立练习:PCA 是否改善预测?
流程 A:标准化 → Ridge。
流程 B:标准化 → PCA → Ridge。
候选选择:只在训练期用时间序列交叉验证选主成分数和惩罚强度。
最终评估:只开启一次最终测试期。
报告训练期、验证期和测试期范围,以及训练均值预测的测试 MSE。
报告 PCA 的主成分数、累计解释方差、测试 MSE 与相对基线改善。
检查载荷稳定性;不得把主成分解释为已识别的经济因果因子。
提交暂停点:降维候选先最终测试
提交点时说明、PCA-Ridge/Ridge/训练均值的逐窗 MSE、载荷稳定性和不采用条件。PCA 与缩放必须逐折拟合;提交后进入答案。
完整答案(1/4):公开点时 EPS 面板
任务固定为:在目标年 6 月 30 日,用当时已披露的历史年报预测该目标年的最终基本 EPS。八家公司只用于课程最小验证,不外推到全 A 股。
代码
from pathlib import Path
from urllib.request import urlretrieve # 复用本章隐藏设置单元安装的浏览器标识下载器
from sklearn.impute import SimpleImputer # 在每个训练窗估计缺失中位数
from sklearn.linear_model import Ridge # 建立有 PCA 与无 PCA 的同级模型
from sklearn.pipeline import Pipeline # 把插补、缩放、PCA 和回归锁进折内流程
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择财务报表文件。
financial_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/financial_statement.h5'), Path('C:/qiufei/data/stock/financial_statement.h5'), Path('data/course/financial_statement.h5')] if candidate_path.exists()), Path('data/course/financial_statement.h5'))
if not financial_path.exists():
financial_path.parent.mkdir(parents=True, exist_ok=True)
urlretrieve('https://assets.qiufei.site/data/stock/financial_statement.h5', financial_path)
pca_companies = ['600276.XSHG', '600009.XSHG', '600585.XSHG', '603259.XSHG', '002415.XSHE', '600104.XSHG', '600019.XSHG', '600406.XSHG'] # 确定八家公司
pca_quarters = [f'{year}q4' for year in range(2005, 2025)] # 覆盖延迟披露标签所需的历史年度 Q4 版本
pca_columns = ['order_book_id', 'quarter', 'info_date', 'total_assets', 'total_liabilities', 'operating_revenue', 'net_profit_parent_company', 'basic_earnings_per_share'] # 确定输入字段
pca_company_frames = [pd.read_hdf(financial_path, key='financial_data', where=f'order_book_id == "{company}"', columns=pca_columns) for company in pca_companies] # 逐公司选择性读取必要列
pca_reports = pd.concat(pca_company_frames, ignore_index=True).query('quarter in @pca_quarters').copy() # 只保留确定范围内的年度 Q4 报告版本
pca_reports['info_date'] = pd.to_datetime(pca_reports['info_date']) # 统一披露日类型
pca_reports['fiscal_year'] = pca_reports['quarter'].str[:4].astype(int) # 提取财年用于时间对齐
pca_target_versions = pca_reports.rename(columns={'fiscal_year': 'target_year', 'info_date': 'target_info_date', 'basic_earnings_per_share': 'target_eps'}).copy() # 保留公司财年的全部标签披露版本
pca_targets = pca_target_versions.sort_values(['order_book_id', 'target_year', 'target_info_date']).drop_duplicates(['order_book_id', 'target_year'], keep='last') # 最终版本只作为确定后的事后评分目标
代码
pca_grid = pca_targets[['order_book_id', 'target_year']].query('2010 <= target_year <= 2024').copy() # 为延迟披露的历史验证折保留足够训练年份
pca_grid['decision_date'] = pd.to_datetime(pca_grid['target_year'].astype(str) + '-06-30') # 确定每年六月末预测时点
pca_candidates = pca_grid.merge(pca_reports, on='order_book_id', how='left').query('fiscal_year < target_year and info_date <= decision_date').copy() # 只列举当时已披露的历史年报
pca_features_at_decision = pca_candidates.sort_values(['order_book_id', 'target_year', 'info_date', 'fiscal_year']).drop_duplicates(['order_book_id', 'target_year'], keep='last') # 选择预测时点最新可得版本
pca_target_values = pca_targets[['order_book_id', 'target_year', 'target_info_date', 'target_eps']].copy() # 建立仅供确定后评分的最终目标表
pca_panel = pca_features_at_decision.merge(pca_target_values, on=['order_book_id', 'target_year'], how='inner') # 合并点时特征与最终评分目标
pca_panel['log_assets'] = np.log(pca_panel['total_assets']) # 构造对数规模
pca_panel['debt_ratio'] = pca_panel['total_liabilities'] / pca_panel['total_assets'] # 构造负债率
pca_panel['log_revenue'] = np.log(pca_panel['operating_revenue'].clip(lower=1)) # 构造对数收入
pca_panel['roa'] = pca_panel['net_profit_parent_company'] / pca_panel['total_assets'] # 构造资产收益率
pca_panel['log_profit_scale'] = np.sign(pca_panel['net_profit_parent_company']) * np.log1p(np.abs(pca_panel['net_profit_parent_company'])) # 构造带符号盈利规模
pca_feature_names = ['log_assets', 'debt_ratio', 'log_revenue', 'roa', 'log_profit_scale'] # 确定五项 PCA 输入
pca_panel = pca_panel.replace([np.inf, -np.inf], np.nan).dropna(subset=['target_eps']).sort_values(['target_year', 'order_book_id']) # 清除无效目标并保持时间顺序
assert pca_panel['info_date'].le(pca_panel['decision_date']).all(), 'PCA 特征晚于预测时点' # 阻止点时特征泄漏
print({'文件': financial_path.name, '样本期': (pca_panel.target_year.min(), pca_panel.target_year.max()), '公司年': len(pca_panel), '特征': pca_feature_names}) # 输出输入说明
{'文件': 'financial_statement.h5', '样本期': (2010, 2024), '公司年': 112, '特征': ['log_assets', 'debt_ratio', 'log_revenue', 'roa', 'log_profit_scale']}
标签数据版本:每个拟合与选择截止日独立确定
代码
def build_pca_label_snapshot(feature_panel, cutoff_date): # 为指定历史截止日重建当时可得的标签版本
cutoff = pd.Timestamp(cutoff_date) # 统一拟合或选择截止日类型
label_candidates = pca_target_versions.query('target_info_date <= @cutoff').copy() # 排除截止日后披露或修订的标签
available_labels = label_candidates.sort_values(['order_book_id', 'target_year', 'target_info_date']).drop_duplicates(['order_book_id', 'target_year'], keep='last') # 选择截止日前最后可得版本
feature_columns = [column for column in feature_panel.columns if column not in ['target_info_date', 'target_eps']] # 防止最终评分标签进入历史数据版本
snapshot = feature_panel[feature_columns].merge(available_labels[['order_book_id', 'target_year', 'target_info_date', 'target_eps']], on=['order_book_id', 'target_year'], how='inner') # 合并时点一致的特征与标签
snapshot = snapshot.dropna(subset=['target_eps']).sort_values(['target_year', 'order_book_id']) # 只保留当时已披露的完整标签
assert snapshot['target_info_date'].le(cutoff).all(), 'PCA 标签晚于指定截止日' # 阻止未来标签或修订进入历史评估
assert not snapshot.duplicated(['order_book_id', 'target_year']).any(), 'PCA 标签数据版本公司年不唯一' # 强制每个公司年仅一个当时版本
snapshot.attrs['label_flow'] = {'截止日': cutoff.date(), '候选版本': len(label_candidates), '截止日前唯一公司年': len(available_labels), '完整行': len(snapshot)} # 保存可复查的样本数量变化
return snapshot # 返回指定截止日可实现的标签面板
完整答案(2/4):年度扩展窗口候选比较结果
- 披露滞后:固定数据版本中的年度 Q4 标签约滞后三年披露。
- 验证年:2018—2020;各年在当年 6 月 30 日拟合,分别到 2021—2023 年 6 月 30 日才评分。
- 时间边界:全部验证证据均早于最终模型选择日。
- 候选全集:三个 Ridge 惩罚,以及
2/3/4 个主成分与三个惩罚的全部组合。
代码
pca_validation_years = [2018, 2019, 2020] # 只使用能在 2023 最终确定前取得标签的验证年
ridge_alphas = [0.1, 1.0, 10.0] # 事先确定 Ridge 惩罚候选
pca_component_counts = [2, 3, 4] # 事先确定 PCA 维数候选
pca_candidate_configs = [{'family': 'Ridge', 'name': f'Ridge(a={alpha})', 'alpha': alpha, 'components': None} for alpha in ridge_alphas] # 建立无 PCA 候选
pca_candidate_configs += [{'family': 'PCA-Ridge', 'name': f'PCA(k={components},a={alpha})', 'alpha': alpha, 'components': components} for components in pca_component_counts for alpha in ridge_alphas] # 建立完整 PCA 候选
pca_validation_records = [] # 收集逐窗候选和训练均值基线
pca_validation_flows = [] # 收集每折拟合与选择时点的标签样本数量变化
pca_validation_inputs = [] # 缓存各折已按时点确定的训练与验证行
for validation_year in pca_validation_years: # 逐年执行扩展窗口验证
pca_fit_cutoff = pd.Timestamp(f'{validation_year}-06-30') # 在当前目标年预测日确定候选拟合信息
pca_selection_cutoff = pd.Timestamp(f'{validation_year + 3}-06-30') # 按公开数据版本约三年披露滞后定义评分截止日
pca_training_snapshot = build_pca_label_snapshot(pca_panel, pca_fit_cutoff) # 重建拟合截止日前标签版本
pca_selection_snapshot = build_pca_label_snapshot(pca_panel, pca_selection_cutoff) # 重建选择截止日前标签版本
pca_training_rows = pca_training_snapshot.query('target_year < @validation_year').copy() # 只使用更早且拟合时已知的公司年
pca_validation_rows = pca_selection_snapshot.query('target_year == @validation_year').copy() # 只用选择截止日前已披露的验证标签
assert len(pca_training_rows) > 0 and len(pca_validation_rows) > 0, 'PCA 训练折或验证折为空' # 先阻止空表让后续时点断言真空通过
assert pca_training_rows['target_info_date'].le(pca_fit_cutoff).all(), 'PCA 训练折含晚披露标签' # 执行逐折拟合时点断言
assert pca_validation_rows['target_info_date'].le(pca_selection_cutoff).all(), 'PCA 验证折含晚披露标签' # 执行逐折选择时点断言
pca_training_flow = pca_training_snapshot.attrs['label_flow'] # 读取拟合截止日的标签样本数量变化
pca_selection_flow = pca_selection_snapshot.attrs['label_flow'] # 读取选择截止日的标签样本数量变化
pca_validation_total = pca_panel['target_year'].eq(validation_year).sum() # 统计该验证年最终评分池规模
pca_validation_flows.append({'验证年': validation_year, '拟合截止日': pca_fit_cutoff.date(), '拟合候选版本': pca_training_flow['候选版本'], '拟合唯一公司年': pca_training_flow['截止日前唯一公司年'], '完整训练行': len(pca_training_rows), '选择截止日': pca_selection_cutoff.date(), '选择候选版本': pca_selection_flow['候选版本'], '验证可用行': len(pca_validation_rows), '验证排除行': pca_validation_total - len(pca_validation_rows), '验证标签最晚披露': pca_validation_rows['target_info_date'].max().date()}) # 记录逐折标签版本、排除与可得日
mean_predictions = np.repeat(pca_training_rows['target_eps'].mean(), len(pca_validation_rows)) # 构造训练均值预测
pca_validation_records.append({'model': '训练均值', 'family': 'Baseline', 'fold': validation_year, 'mse': mean_squared_error(pca_validation_rows['target_eps'], mean_predictions)}) # 保存基线比较结果
pca_validation_inputs.append({'fold': validation_year, 'training_rows': pca_training_rows, 'validation_rows': pca_validation_rows}) # 交给下一代码块复用同一确定样本
代码
for fold_input in pca_validation_inputs: # 在已确定的各折样本上拟合完整候选集
validation_year = fold_input['fold'] # 读取当前验证年
pca_training_rows = fold_input['training_rows'] # 读取拟合截止日确定的训练行
pca_validation_rows = fold_input['validation_rows'] # 读取选择截止日确定的验证行
for config in pca_candidate_configs: # 对完整事先确定集合使用同一时间窗
steps = [('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler())] # 只在当前训练折逐特征估计插补与缩放统计量
steps += [('pca', PCA(n_components=config['components'], random_state=42))] if config['components'] else [] # 仅 PCA 候选增加折内降维
candidate_pipeline = Pipeline(steps + [('ridge', Ridge(alpha=config['alpha']))]).fit(pca_training_rows[pca_feature_names], pca_training_rows['target_eps']) # 拟合当前候选
fold_predictions = candidate_pipeline.predict(pca_validation_rows[pca_feature_names]) # 用候选流程对当前验证窗的具名财务特征生成预测
pca_validation_records.append({'model': config['name'], 'family': config['family'], 'fold': validation_year, 'mse': mean_squared_error(pca_validation_rows['target_eps'], fold_predictions)}) # 保存逐窗 MSE
代码
pca_validation_table = pd.DataFrame(pca_validation_records) # 形成完整候选比较结果
display(pca_validation_table.query("family == 'PCA-Ridge' and model.str.startswith('PCA(k=2')", engine='python').pivot(index='model', columns='fold', values='mse').round(4)) # 输出 k=2 的三组惩罚候选
PCA-Ridge 候选比较结果:k=3
代码
display(pca_validation_table.query("family == 'PCA-Ridge' and model.str.startswith('PCA(k=3')", engine='python').pivot(index='model', columns='fold', values='mse').round(4)) # 展示三主成分候选的逐折误差
PCA-Ridge 候选比较结果:k=4
代码
display(pca_validation_table.query("family == 'PCA-Ridge' and model.str.startswith('PCA(k=4')", engine='python').pivot(index='model', columns='fold', values='mse').round(4)) # 展示四主成分候选的逐折误差
Ridge 与基线候选比较结果
代码
display(pca_validation_table.query("family in ['Ridge', 'Baseline']").pivot(index='model', columns='fold', values='mse').round(4)) # 输出全部无 PCA 候选与训练均值基线
标签时点与样本数量变化检查
代码
display(pd.DataFrame(pca_validation_flows))
完整答案(3/4):确定规格、载荷稳定性与最终测试
代码
pca_validation_means = pca_validation_table.query("family in ['Ridge', 'PCA-Ridge']").groupby(['family', 'model'])['mse'].mean() # 只用验证窗汇总两类候选
best_ridge_name = pca_validation_means.loc['Ridge'].idxmin() # 确定无 PCA 规格
best_pca_name = pca_validation_means.loc['PCA-Ridge'].idxmin() # 确定 PCA-Ridge 规格
best_ridge_config = next(config for config in pca_candidate_configs if config['name'] == best_ridge_name) # 读取确定 Ridge 参数
best_pca_config = next(config for config in pca_candidate_configs if config['name'] == best_pca_name) # 读取确定 PCA 参数
pca_loadings_by_fold = [] # 收集确定 PCA 规格的逐折载荷
for validation_year in pca_validation_years: # 复算每个训练窗的 PCA 载荷
pca_fit_cutoff = pd.Timestamp(f'{validation_year}-06-30') # 恢复该折拟合时点
pca_training_rows = build_pca_label_snapshot(pca_panel, pca_fit_cutoff).query('target_year < @validation_year').copy() # 使用拟合日当时可得的训练标签
loading_pipeline = Pipeline([('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()), ('pca', PCA(n_components=best_pca_config['components'], random_state=42)), ('ridge', Ridge(alpha=best_pca_config['alpha']))]).fit(pca_training_rows[pca_feature_names], pca_training_rows['target_eps']) # 只由当前训练折逐特征拟合缩放统计量与确定规格
pca_loadings_by_fold.append(loading_pipeline.named_steps['pca'].components_) # 保存主成分载荷
loading_stability = [float(np.abs(np.sum(pca_loadings_by_fold[index] * pca_loadings_by_fold[index - 1], axis=1)).mean()) for index in range(1, len(pca_loadings_by_fold))] # 计算相邻折同序主成分绝对余弦
print({'确定Ridge': best_ridge_name, '确定PCA-Ridge': best_pca_name, '相邻折平均载荷稳定性': np.round(loading_stability, 4).tolist()}) # 输出确定与稳定性证据
{'确定Ridge': 'Ridge(a=0.1)', '确定PCA-Ridge': 'PCA(k=2,a=0.1)', '相邻折平均载荷稳定性': [0.9999, 0.9993]}
代码
pca_final_fit_cutoff = pd.Timestamp('2023-06-30') # 在 2024 最终测试预测日前一年确定候选与训练信息
assert max(pd.Timestamp(f'{year + 3}-06-30') for year in pca_validation_years) <= pca_final_fit_cutoff, 'PCA 验证标签尚未到选择截止日' # 保证延迟披露的选模证据在模型选择日前可得
pca_development_snapshot = build_pca_label_snapshot(pca_panel, pca_final_fit_cutoff) # 重建最终拟合截止日可得的标签版本
pca_development_rows = pca_development_snapshot.query('target_year < 2024').copy() # 只使用模型选择日已披露且早于最终测试年的开发标签
pca_testing_rows = pca_panel.query('target_year == 2024').copy() # 仅在确定后的下一年打开最终评分标签
assert len(pca_development_rows) > 0 and len(pca_testing_rows) > 0, 'PCA 最终开发期或最终测试期为空' # 在拟合与评分前明确验证两窗有样本
ridge_pipeline = Pipeline([('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()), ('ridge', Ridge(alpha=best_ridge_config['alpha']))]).fit(pca_development_rows[pca_feature_names], pca_development_rows['target_eps']) # 只由最终开发样本逐特征拟合插补、缩放与确定 Ridge
final_pca_pipeline = Pipeline([('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()), ('pca', PCA(n_components=best_pca_config['components'], random_state=42)), ('ridge', Ridge(alpha=best_pca_config['alpha']))]).fit(pca_development_rows[pca_feature_names], pca_development_rows['target_eps']) # 只由最终开发样本逐特征拟合缩放、PCA 与确定 Ridge
sealed_baseline_predictions = np.repeat(pca_development_rows['target_eps'].mean(), len(pca_testing_rows)) # 构造同窗训练均值基线
sealed_ridge_predictions = ridge_pipeline.predict(pca_testing_rows[pca_feature_names]) # 生成无 PCA 最终测试预测
sealed_pca_predictions = final_pca_pipeline.predict(pca_testing_rows[pca_feature_names]) # 生成 PCA-Ridge 最终测试预测
sealed_pca_mse = mean_squared_error(pca_testing_rows['target_eps'], sealed_pca_predictions) # 计算 PCA-Ridge 最终测试 MSE
sealed_results = pd.DataFrame({'模型': ['训练均值', best_ridge_name, best_pca_name], '最终测试MSE': [mean_squared_error(pca_testing_rows['target_eps'], sealed_baseline_predictions), mean_squared_error(pca_testing_rows['target_eps'], sealed_ridge_predictions), sealed_pca_mse]}) # 汇总同窗结果
sealed_results['相对训练均值改善'] = 1 - sealed_results['最终测试MSE'] / sealed_results.loc[sealed_results['模型'].eq('训练均值'), '最终测试MSE'].iloc[0] # 计算相对基线改善
fitted_pca = final_pca_pipeline.named_steps['pca'] # 读取开发期 PCA 而不重拟合测试期
print({'训练期': (int(pca_development_rows.target_year.min()), int(pca_development_rows.target_year.max())), '模型选择日': pca_final_fit_cutoff.date(), **pca_development_snapshot.attrs['label_flow'], '最终开发行': len(pca_development_rows), '最终测试期': 2024, '确定后最终标签用途': '仅一次性评分', '累计解释方差': round(float(fitted_pca.explained_variance_ratio_.sum()), 4)}) # 报告真实样本数量变化、窗口与解释方差
display(sealed_results.round(4)) # 输出三类同窗最终测试证据
{'训练期': (2010, 2020), '模型选择日': datetime.date(2023, 6, 30), '截止日': datetime.date(2023, 6, 30), '候选版本': 118, '截止日前唯一公司年': 118, '完整行': 80, '最终开发行': 80, '最终测试期': 2024, '确定后最终标签用途': '仅一次性评分', '累计解释方差': 0.8891}
完整答案(4/4):判读与结果不理想时如何解释
- 增量价值判读:若 PCA-Ridge 未同时战胜无 PCA Ridge 与训练均值,只能写“当前固定公司、点时特征与最终测试期没有证明 PCA 带来增量预测价值”。
- 解释方差边界:累计解释方差高只表示保留较多 \(X\) 方差。
- 载荷边界:稳定性低时,不给主成分贴固定经济标签。
- 测试隔离:所有测试结果保持一次性,不反馈到候选选择。
- 误读返回:若把解释方差当作预测门槛,回看 结果解读 与 案例边界 后重写。
本章小结
- 能在训练折内完成插补、缩放、PCA 与 Ridge,并与无 PCA Ridge、训练均值同窗比较。
- 维度 \(k\) 只按开发期任务损失与预设 tie-break 确定。
- 高解释方差不等于预测价值,低载荷稳定性时不贴固定经济标签。
- 下一章进入聚类,继续区分无监督几何目标与外部业务价值。