15  特征工程基础

15.1 问题、先修与学习目标

特征工程不是将数据机械地『变得好看』,而是把原始观测转换为与决策问题相容、可被模型识别的信息。在 A 股截面中,市值的单位可能是亿元,股息率是小数,市盈率还可能由于亏损而为负。如果不先处理尺度、极端值、类别与信息时点,模型的数学目标与金融问题就会脱节。

本章先修是线性代数中的内积、特征值与正交基,概率统计中的均值、方差和分位数,以及 Pandas 的分组和时间索引。学完后,读者应能够:

  • 推导标准化、Min–Max 缩放、分位数缩尾与 PCA 的核心公式;

  • 比较这些变换的不变性、异常值敏感性和适用边界;

  • 在标签可得日约束下实现平滑的时间 OOF 目标编码,并说明公司分组隔离与时间隔离的差异;

  • 从载荷和解释方差比解读主成分,而不只是调用 PCA.fit_transform()

  • 识别预处理泄漏和类别不均衡,选择与业务损失相匹配的评价指标。

本章用下表明确正式目标的活动与评价证据。

表 15.1: 第十五章目标—活动—核心题映射
正式目标 正文活动 核心题与答案证据
标准化、Min–Max、缩尾与 PCA 公式 小节 15.3.1小节 15.3.2小节 15.5.1 练习 15.1、15.2 与 15.5 的手工计算
比较变换边界 小节 15.3 练习 15.5 的未来超界和尾部截断审计
标签时点安全的 OOF 目标编码 小节 15.4 练习 15.3 的折级非法标签断言、公司交集与错误对照
PCA 载荷与解释方差 小节 15.5 练习 15.2 的成分数、载荷与特征值核验
泄漏、不均衡与评价 小节 15.2.1小节 15.6 练习 15.1 与 15.4 的信息边界和类权重决策

表 15.1 汇总本节的计算或审计结果,解释时应遵循正文给出的口径与限制。

15.2 真实案例与时点安全样本

本章使用本地 valuation_factors_quarterly_15_years.h5stock_basic_data.h5。估值文件的主键是『证券—日期』两层索引,但文件是 HDF5 fixed 布局,不支持 where 与列投影。因此 列表 15.1 必须先读取该单表,紧接着只保留所需列和期间。这是数据布局的限制,不是选择性读取的理想状态。

列表 15.1: 读取本地估值与公司基础数据
import platform  # 依操作系统统一选择教材数据根目录
from pathlib import Path  # 以跨平台路径对象组合文件位置
import numpy as np  # 提供对数、有限值判断和线性代数运算
import pandas as pd  # 处理证券—季度面板数据
DATA_ROOT = 'C:/qiufei/data' if platform.system() == 'Windows' else '/home/ubuntu/r2_data_mount/data'  # 按项目规范定位数据根目录
STOCK_ROOT = Path(DATA_ROOT) / 'stock'  # 将后续读取限定在股票数据子目录
valuation_raw = pd.read_hdf(STOCK_ROOT / 'valuation_factors_quarterly_15_years.h5', key='valuation_factors')  # 读取 fixed 格式估值表
valuation_columns = ['pe_ratio_ttm', 'pb_ratio_lf', 'ps_ratio_ttm', 'dividend_yield_ttm', 'market_cap']  # 只保留本章需要的估值特征
valuation_panel = valuation_raw.loc[:, valuation_columns].reset_index()  # 立即缩减内存占用并恢复显式主键
del valuation_raw  # 释放不再使用的 41 列原始表
basic_columns = ['order_book_id', 'symbol', 'province', 'citics_2019_l1_name']  # 定义公司地域与行业字段
company_basic = pd.read_hdf(STOCK_ROOT / 'stock_basic_data.h5', key='stock_basic_info', columns=basic_columns)  # 选择性读取公司字段

为避免把其他地区公司冒充为长三角样本,列表 15.2 依基础表的省份字段显式筛选上海、江苏、浙江和安徽。每一行显式保留特征观测日 feature_date、结果观测日 target_date 与标签可得日 label_available_date。市值是当日市场观测,因此本例取 label_available_date = target_date;若目标来自财报,则应改用实际披露日。任务仍称『下一季度』,所以只保留两个观测日的自然月差严格等于 3 的配对,不能把缺数造成的 6 个月或 15 个月跨度混入同一标签。每个特征日期下,市值变化率位于同期截面前五分位的公司被标记为正类;这个定义形成约 20% 的不均衡分类任务,但『截面前五分位』只是排序标签,不是统计显著性结论。

易混淆概念辨析:市值变化率不等于股票收益率

这里的比率由相邻观测季度的总市值计算。总市值同时受价格、总股本以及送转、增发、回购等公司行动影响,也未计入现金分红;当前数据又没有逐期股份数量与公司行动调整链。因此本章只把它称为『下一季度市值变化率』,不能解释为个股持有期收益、相对基准超额收益或投资者财富增长。

列表 15.2: 构造长三角估值面板与未来目标
yrd_provinces = ['上海市', '江苏省', '浙江省', '安徽省']  # 依行政区划明确长三角样本边界
yrd_companies = company_basic.loc[company_basic['province'].isin(yrd_provinces)].copy()  # 保留基础表中的长三角公司
feature_panel = valuation_panel.loc[valuation_panel['date'] >= '2021-01-01'].merge(yrd_companies, on='order_book_id', how='inner', validate='many_to_one')  # 合并估值与静态公司信息
feature_panel['feature_date'] = pd.to_datetime(feature_panel['date'])  # 将当前季度末明确命名为特征信息时点
feature_panel = feature_panel.sort_values(['order_book_id', 'feature_date']).copy()  # 保证每家公司的时间顺序正确
company_quarter_groups = feature_panel.groupby('order_book_id', observed=True)  # 建立公司内的相邻观测配对
feature_panel['target_date'] = company_quarter_groups['feature_date'].shift(-1)  # 保存未来结果对应的真实观测日
feature_panel['target_market_cap'] = company_quarter_groups['market_cap'].shift(-1)  # 保存目标日市值而不提前丢失血缘
feature_panel['label_available_date'] = feature_panel['target_date']  # 市值标签在目标市场观测日收盘后可得
feature_panel['horizon_months'] = (feature_panel['target_date'].dt.year - feature_panel['feature_date'].dt.year).mul(12).add(feature_panel['target_date'].dt.month - feature_panel['feature_date'].dt.month)  # 计算自然月预测跨度
prediction_horizon_audit = feature_panel['horizon_months'].dropna().astype('int64').value_counts().sort_index().rename_axis('间隔月数').to_frame('筛选前样本数')  # 保留缺失季度造成的不规则跨度证据
is_strict_next_quarter = feature_panel['horizon_months'].eq(3)  # 只接受自然月差严格为三个月的相邻季度观测
feature_panel = feature_panel.loc[is_strict_next_quarter].copy()  # 排除 6 月、15 月等不等长预测期
feature_panel['next_quarter_market_cap_change_rate'] = feature_panel['target_market_cap'].div(feature_panel['market_cap']).sub(1)  # 在统一三个月期限上计算市值变化率
date_top_quintile = feature_panel.groupby('feature_date', observed=True)['next_quarter_market_cap_change_rate'].transform('quantile', 0.8)  # 以同期截面前五分位定义分类阈值
feature_panel['is_top_quintile'] = feature_panel['next_quarter_market_cap_change_rate'].ge(date_top_quintile).astype('int8')  # 生成约占截面五分之一的二元目标
feature_panel['industry'] = feature_panel['citics_2019_l1_name'].fillna('未知行业')  # 为缺失行业保留可审计类别
feature_panel['log_market_cap'] = np.log(feature_panel['market_cap'].where(feature_panel['market_cap'] > 0))  # 用对数缓和市值右偏
feature_panel = feature_panel.replace([np.inf, -np.inf], np.nan).dropna(subset=['next_quarter_market_cap_change_rate'])  # 明确清理无穷值和无法定义的目标

表 15.2 先展示原始相邻观测的跨度分布,再标明只有三个月且市值变化率可计算的样本进入目标面板。这样可以区分『下一次可观察季度』和口径固定的『下一季度』,也不会把目标市值缺失静默混入保留数。

prediction_horizon_audit['进入目标面板数'] = 0  # 先把非三个月跨度明确记录为未采用
prediction_horizon_audit.loc[3, '进入目标面板数'] = len(feature_panel)  # 记录三个月且市值变化率可计算的样本
prediction_horizon_audit['排除数'] = prediction_horizon_audit['筛选前样本数'].sub(prediction_horizon_audit['进入目标面板数'])  # 量化跨度或目标缺失造成的剔除规模
assert feature_panel['horizon_months'].eq(3).all()  # 防止不规则季度缺口重新进入下一季度任务
prediction_horizon_audit  # 展示三个月与其他跨度的保留和剔除数量
表 15.2: 严格三个月预测期的样本筛选审计
筛选前样本数 进入目标面板数 排除数
间隔月数
3 23712 21089 2623
6 1976 0 1976
15 1976 0 1976

15.2.1 时间留出和变换的信息集

设信息集 \(ℐ_t\) 包含决策时点 \(t\) 之前已经形成的特征和标签。对任意拟合型变换 \(T_{θ}\),安全条件不仅是训练行的 feature_date 早于测试日,还要求每条拟合标签满足 label_available_date < t。测试集首个信息时点是 2025 年第一个有效季度末;特征日在此前、但标签到该日才形成的边界样本必须 purge。分割规模、purge 数量与非法标签断言见 表 15.3

numeric_features = ['pe_ratio_ttm', 'pb_ratio_lf', 'ps_ratio_ttm', 'dividend_yield_ttm', 'log_market_cap']  # 统一数值特征顺序
required_features = numeric_features + ['industry', 'order_book_id', 'feature_date', 'target_date', 'label_available_date', 'is_top_quintile']  # 保留建模字段和三类时点
model_panel = feature_panel.dropna(subset=numeric_features).loc[:, required_features].sort_values(['feature_date', 'order_book_id']).reset_index(drop=True)  # 获得可复现且保留标签血缘的建模样本
test_information_start = model_panel.loc[model_panel['feature_date'] >= pd.Timestamp('2025-01-01'), 'feature_date'].min()  # 以首个2025年有效截面定义测试决策时点
assert pd.notna(test_information_start)  # 保证本地数据确实提供可评估的未来留出期
is_historical_feature = model_panel['feature_date'] < test_information_start  # 标识测试时点前已经形成的候选特征
is_label_known_before_test = model_panel['label_available_date'] < test_information_start  # 标识测试决策前严格可得的结果
is_training_row = is_historical_feature & is_label_known_before_test  # 按标签可得日 purge 最终训练集
is_test_row = model_panel['feature_date'] >= test_information_start  # 保留测试时点及之后的统一期限样本
purged_holdout_boundary_count = int((is_historical_feature & ~is_label_known_before_test).sum())  # 统计因结果尚不可得而延迟进入训练的行
training_panel = model_panel.loc[is_training_row].copy()  # 只保留测试时点前已知标签的历史样本
test_panel = model_panel.loc[is_test_row].copy()  # 保留未来特征及其事后评价标签
holdout_illegal_label_count = int(training_panel['label_available_date'].ge(test_information_start).sum())  # 计算最终拟合端的非法标签数
assert holdout_illegal_label_count == 0  # 用可执行条件阻止测试期结果进入最终训练
split_audit = pd.DataFrame({'特征起始日': [training_panel['feature_date'].min(), test_panel['feature_date'].min()], '特征结束日': [training_panel['feature_date'].max(), test_panel['feature_date'].max()], '最大标签可得日': [training_panel['label_available_date'].max(), test_panel['label_available_date'].max()], '样本数': [len(training_panel), len(test_panel)], '边界 purge 数': [purged_holdout_boundary_count, 0], '非法训练标签数': [holdout_illegal_label_count, 0], '正类比例': [training_panel['is_top_quintile'].mean(), test_panel['is_top_quintile'].mean()]}, index=['最终训练集', '时间留出集'])  # 同时审计时点、规模、标签边界与不均衡程度
split_audit  # 展示最终训练和留出样本的可执行审计证据
表 15.3: 按标签可得日 purge 的最终训练与时间留出审计
特征起始日 特征结束日 最大标签可得日 样本数 边界 purge 数 非法训练标签数 正类比例
最终训练集 2021-03-31 2024-09-30 2024-12-31 12912 1914 0 0.199969
时间留出集 2025-03-31 2025-09-30 2025-12-31 5798 0 0 0.200759

15.3 数值变换:尺度、极端值与分箱

15.3.1 标准化与归一化

对训练样本 \(x_1,…,x_n\)StandardScaler 使用总体方差约定。先考虑非退化特征,即 \(\sigma>0\)

\[ \mu=\frac{1}{n}\sum_{i=1}^{n}x_i,\qquad \sigma^2=\frac{1}{n}\sum_{i=1}^{n}(x_i-\mu)^2,\qquad z_i=\frac{x_i-\mu}{\sigma}. \tag{15.1}\]

\(\sigma>0\) 时,由 式 15.1 可直接验证 \(∑_i z_i/n=0\)\(∑_i z_i^2/n=1\)。若训练列为常数,则 \(\sigma=0\),这个分式本身没有定义,也不能声称变换后方差为 1;scikit-learn 的 StandardScaler 会把该列的缩放因子设为 1,使训练值变为 0,实践中仍应把它标记为零方差特征并考虑删除。线性模型的正则化惩罚、距离模型和 PCA 都对尺度敏感,因此通常需要标准化。但标准化不会消除偏度,也不会自动抑制异常值。

当训练极差满足 \(x_{\max}>x_{\min}\) 时,Min–Max 变换见 式 15.2

\[ x_i^{\ast}=\frac{x_i-x_{\min}}{x_{\max}-x_{\min}}. \tag{15.2}\]

它把训练极值映射到 \([0,1]\),但测试值超出训练范围时,\(x_i^{∗}\) 完全可以小于 0 或大于 1。若 \(x_{\max}=x_{\min}\),公式分母为零;scikit-learn 的 MinMaxScaler 会把训练常数列映射到目标区间下界,但这不赋予该列区分样本的能力,故应在特征审计中单列并考虑删除。所以『归一化后永远位于 \([0,1]\)』是对非退化拟合样本的描述,不是对未来数据或常数特征的保证。

15.3.2 缩尾的机制与边界

给定由训练集估计的分位数 \(q_{\alpha}\)\(q_{1-α}\),缩尾变换如 式 15.3 所示:

\[ \widetilde{x}=\min\{q_{1-\alpha},\max(q_{\alpha},x)\}. \tag{15.3}\]

缩尾保留样本数,不等于删除异常值;它也不能修复数据错误。对市盈率这类因盈利接近零而出现极端值的比率,缩尾常比 Z 分数删除更稳健,但必须披露截断比例并做敏感性检验。本例的历史边界与测试超界比例见 表 15.4

lower_bounds = training_panel[numeric_features].quantile(0.01)  # 只用训练样本估计下界
upper_bounds = training_panel[numeric_features].quantile(0.99)  # 只用训练样本估计上界
training_winsorized = training_panel[numeric_features].clip(lower=lower_bounds, upper=upper_bounds, axis='columns')  # 用历史边界处理训练特征
test_winsorized = test_panel[numeric_features].clip(lower=lower_bounds, upper=upper_bounds, axis='columns')  # 不重估参数地处理未来特征
winsor_audit = pd.DataFrame({'1% 下界': lower_bounds, '99% 上界': upper_bounds, '训练截断比例': training_panel[numeric_features].lt(lower_bounds).mean().add(training_panel[numeric_features].gt(upper_bounds).mean()), '测试超界比例': test_panel[numeric_features].lt(lower_bounds).mean().add(test_panel[numeric_features].gt(upper_bounds).mean())})  # 比较两个时段的尾部压力
winsor_audit.round(4)  # 展示可追溯的截断边界和比例
表 15.4: 仅用训练期估计的 1% 与 99% 缩尾边界
1% 下界 99% 上界 训练截断比例 测试超界比例
pe_ratio_ttm -324.8354 556.8332 0.0201 0.0366
pb_ratio_lf 0.6338 22.7513 0.0201 0.0264
ps_ratio_ttm 0.1776 41.3909 0.0201 0.0176
dividend_yield_ttm 0.0000 0.0735 0.0101 0.0103
log_market_cap 21.0250 25.7704 0.0201 0.0131

15.3.3 分箱

等宽分箱保持数值距离,却可能在偏态数据中产生空箱;等频分箱力求使每箱样本量接近,却可能把数值非常接近的公司分到不同箱,并列值也可能破坏严格等频。一旦分箱边界参与了模型评估,它们也必须只从训练集估计。对投资分组而言,同期截面分位数常有明确的经济含义;对预测流水线而言,则应将分箱器放入 Pipeline。

15.4 类别特征与无泄漏目标编码

One-Hot 编码将 \(K\) 个类别映射为 \(K\) 个稀疏指示变量。它不强加类别顺序,适合低至中等基数。高基数时,特征维数和内存会迅速增长,这时才有理由考虑目标编码。

对类别 \(c\),训练子集中有 \(n_c\) 个观测、目标均值为 \(\bar y_c\),全局均值为 \(\bar y\)。平滑目标编码定义为

\[ \operatorname{TE}_{\alpha}(c)=\frac{n_c\bar y_c+\alpha\bar y}{n_c+\alpha},\qquad \alpha>0. \tag{15.4}\]

式 15.4\(\alpha\) 控制类别均值向全局均值收缩的强度。当 \(n_c→∞\) 时编码趋近类别均值;当 \(n_c\) 很小时,它被收缩到全局均值。然而,平滑并不自动消除泄漏。如果第 \(i\) 行的 \(y_i\) 参与了自己的编码,特征就已间接『看见』答案。

列表 15.3 封装平滑规则。主流程按验证信息日做扩展窗口 OOF:每折先取特征日更早的候选行,再 purge 其中 label_available_date 不早于验证日的标签。表 15.5 对每折报告候选数、purge 数和非法标签数;测试映射只由最终训练集中在首个测试信息时点前已经可得的标签估计。未见行业回退到同一安全拟合集的全局均值。

列表 15.3: 平滑目标编码映射的拟合函数
def fit_smoothed_target_mapping(categories, outcomes, smoothing_strength):  # 仅根据当前拟合折学习类别统计量
    '''返回类别平滑映射和用于未见类别的全局均值。'''
    encoding_frame = pd.DataFrame({'category': categories.to_numpy(), 'outcome': outcomes.to_numpy()})  # 显式对齐类别与目标的行顺序
    global_mean = encoding_frame['outcome'].mean()  # 估计小样本类别的收缩中心
    category_stats = encoding_frame.groupby('category', observed=True)['outcome'].agg(['count', 'mean'])  # 统计类别样本量和条件均值
    smoothed_mapping = (category_stats['count'].mul(category_stats['mean']).add(smoothing_strength * global_mean)).div(category_stats['count'].add(smoothing_strength))  # 实现 @eq-ch15-smoothed-target-encoding
    return smoothed_mapping, global_mean  # 同时交付已见与未见类别的规则
encoding_validation_dates = np.sort(training_panel['feature_date'].unique())[-3:]  # 选择历史末端三个截面演示时间 OOF
training_oof_encoding = pd.Series(index=training_panel.index, dtype='float64')  # 预留与原训练索引一致的 OOF 容器
encoding_fold_rows = []  # 收集折级标签可得性证据
for validation_information_time in encoding_validation_dates:  # 逐个历史决策时点重估类别映射
    is_fit_candidate = training_panel['feature_date'] < validation_information_time  # 先建立特征日在验证日前的候选拟合集
    is_fit_label_known = training_panel['label_available_date'] < validation_information_time  # 再要求候选标签当时已经可得
    fold_fit = training_panel.loc[is_fit_candidate & is_fit_label_known]  # purge 尚未形成结果的历史特征行
    fold_validation = training_panel.loc[training_panel['feature_date'] == validation_information_time]  # 保持完整同期截面作为 OOF 验证集
    illegal_label_count = int(fold_fit['label_available_date'].ge(validation_information_time).sum())  # 计算拟合端的非法未来标签数
    assert illegal_label_count == 0 and len(fold_fit) > 0  # 要求每折有可用历史且非法标签严格为零
    fold_mapping, fold_prior = fit_smoothed_target_mapping(fold_fit['industry'], fold_fit['is_top_quintile'], 20.0)  # 仅从当时已知标签学习映射
    training_oof_encoding.loc[fold_validation.index] = fold_validation['industry'].map(fold_mapping).fillna(fold_prior)  # 对当前未来截面生成 OOF 编码
    purged_label_count = int((is_fit_candidate & ~is_fit_label_known).sum())  # 量化仅比较特征日会误纳入的标签
    encoding_fold_rows.append({'验证信息日': validation_information_time, '候选拟合数': int(is_fit_candidate.sum()), '标签 purge 数': purged_label_count, '最终拟合数': len(fold_fit), '非法标签数': illegal_label_count, 'OOF 编码数': len(fold_validation)})  # 保存折级审计
assert training_panel['label_available_date'].lt(test_information_start).all()  # 最终测试映射不得吸收测试时点才形成的标签
full_mapping, training_prior = fit_smoothed_target_mapping(training_panel['industry'], training_panel['is_top_quintile'], 20.0)  # 用安全最终训练集估计测试映射
test_target_encoding = test_panel['industry'].map(full_mapping).fillna(training_prior)  # 不使用任何留出期目标修正映射
encoding_fold_audit = pd.DataFrame(encoding_fold_rows)  # 形成可执行的折级审计表
assert encoding_fold_audit['非法标签数'].eq(0).all()  # 防止未来改动放宽标签可得日条件
encoding_fold_audit  # 展示每折 purge 后非法标签数均为零
表 15.5: 按标签可得日 purge 的时间 OOF 目标编码审计
验证信息日 候选拟合数 标签 purge 数 最终拟合数 非法标签数 OOF 编码数
0 2022-06-30 7569 1623 5946 0 1687
1 2023-03-31 9256 0 9256 0 1752
2 2024-09-30 11008 0 11008 0 1904

易混淆概念辨析:公司隔离不等于时间隔离

GroupKFold 防止同一公司跨折,却不能单独模拟逐季上线。表 15.6 因而只做公司交集检查,不参与上面的时间 OOF 编码,也不为测试集学习映射。示范输入仍限定为最终安全训练集,所以不会吸收首个测试信息时点尚不可得的标签。

from sklearn.model_selection import GroupKFold  # 将同一公司全部历史观测绑定到同一侧
group_splitter = GroupKFold(n_splits=5)  # 建立公司隔离示范而非时间回测
group_isolation_rows = []  # 收集公司交集和测试时点标签边界
for fold_number, (fit_positions, validation_positions) in enumerate(group_splitter.split(training_panel, training_panel['is_top_quintile'], groups=training_panel['order_book_id']), start=1):  # 逐折检查公司集合
    fit_companies = set(training_panel.iloc[fit_positions]['order_book_id'])  # 提取公司隔离拟合侧
    validation_companies = set(training_panel.iloc[validation_positions]['order_book_id'])  # 提取公司隔离验证侧
    company_overlap_count = len(fit_companies.intersection(validation_companies))  # 计算同一公司跨侧数量
    unavailable_label_count = int(training_panel.iloc[fit_positions]['label_available_date'].ge(test_information_start).sum())  # 检查是否吸收测试边界未来标签
    group_isolation_rows.append({'折': fold_number, '公司交集数': company_overlap_count, '测试时点不可得标签数': unavailable_label_count})  # 保存教学示范证据
group_isolation_audit = pd.DataFrame(group_isolation_rows)  # 汇总五折隔离结果
assert group_isolation_audit[['公司交集数', '测试时点不可得标签数']].eq(0).all().all()  # 同时阻止公司重叠和未来标签
group_isolation_audit  # 展示 GroupKFold 的有限用途和安全输入边界
表 15.6: 仅用于公司隔离教学的 GroupKFold 审计
公司交集数 测试时点不可得标签数
0 1 0 0
1 2 0 0
2 3 0 0
3 4 0 0
4 5 0 0

15.5 PCA:从最大方差到载荷解释

15.5.1 中心化、协方差与特征分解

设缩尾并标准化后的训练矩阵为 \(Z∈ℝ^{n×p}\),每列样本均值为 0。其样本协方差矩阵为

\[ S=\frac{1}{n-1}Z^{\mathsf T}Z. \tag{15.5}\]

式 15.5 使用样本自由度 \(n-1\) 估计标准化特征之间的协方差。第一主成分寻找单位向量 \(w_1\) 使投影方差最大,如 式 15.6 所示:

\[ \max_{w_1} \operatorname{Var}(Zw_1) =\max_{w_1}w_1^{\mathsf T}Sw_1, \qquad \text{s.t. }w_1^{\mathsf T}w_1=1. \tag{15.6}\]

构造拉格朗日函数 \(L(w,λ)=w^{\mathsf T}Sw-λ(w^{\mathsf T}w-1)\),对 \(w\) 求导可得 式 15.7

\[ \frac{\partial L}{\partial w}=2Sw-2\lambda w=0 \quad\Longrightarrow\quad Sw=\lambda w. \tag{15.7}\]

因此最优方向是 \(S\) 最大特征值对应的特征向量。后续主成分在与先前方向正交的约束下递归求解。若 \(λ_1≥…≥λ_p≥0\),前 \(k\) 个主成分的累计解释方差比由 式 15.8 给出:

\[ R_k=\frac{\sum_{j=1}^{k}\lambda_j}{\sum_{j=1}^{p}\lambda_j}. \tag{15.8}\]

载荷 \(w_{mj}\) 是特征 \(m\) 在第 \(j\) 个主成分方向上的系数,得分则是 \(t_{ij}=z_i^{\mathsf T}w_j\)。特征向量的正负号可整体翻转,因而不应把单个载荷的符号解读为稳定的因果方向。表 15.7表 15.8 分别展示载荷和完整方差分解。

from sklearn.decomposition import PCA  # 使用 SVD 稳定求解主成分
from sklearn.preprocessing import StandardScaler  # 消除估值比率与市值的量纲差异
pca_scaler = StandardScaler()  # 创建只在训练期拟合的尺度变换
training_standardized = pca_scaler.fit_transform(training_winsorized)  # 估计并应用训练均值与标准差
test_standardized = pca_scaler.transform(test_winsorized)  # 使用历史参数变换未来数据
pca_model = PCA()  # 保留所有成分以审计完整方差分解
training_scores = pca_model.fit_transform(training_standardized)  # 仅在训练期学习正交方向
test_scores = pca_model.transform(test_standardized)  # 将测试期投影到固定的历史基底
loading_table = pd.DataFrame(pca_model.components_.T, index=numeric_features, columns=[f'PC{component_number}' for component_number in range(1, len(numeric_features) + 1)])  # 将特征向量按原始特征排列
loading_table.round(3)  # 展示各主成分的载荷结构
表 15.7: 基于真实长三角估值数据的 PCA 载荷
PC1 PC2 PC3 PC4 PC5
pe_ratio_ttm 0.289 -0.295 0.907 -0.083 -0.004
pb_ratio_lf 0.600 0.058 -0.150 0.212 0.754
ps_ratio_ttm 0.581 0.084 -0.115 0.494 -0.631
dividend_yield_ttm -0.362 0.598 0.365 0.597 0.147
log_market_cap 0.296 0.738 0.091 -0.590 -0.108
sample_covariance = np.cov(training_standardized, rowvar=False, ddof=1)  # 按 @eq-ch15-covariance-matrix 计算样本协方差
manual_eigenvalues = np.linalg.eigvalsh(sample_covariance)[::-1]  # 利用对称矩阵求解并降序排列特征值
variance_table = pd.DataFrame({'手工特征值': manual_eigenvalues, 'sklearn 特征值': pca_model.explained_variance_, '解释方差比': pca_model.explained_variance_ratio_, '累计解释比': pca_model.explained_variance_ratio_.cumsum()}, index=[f'PC{component_number}' for component_number in range(1, len(numeric_features) + 1)])  # 对照手工推导与库实现
assert np.allclose(manual_eigenvalues, pca_model.explained_variance_)  # 验证公式与 sklearn 结果数值一致
variance_table.round(4)  # 输出选择成分数所需的方差证据
表 15.8: PCA 特征值、解释方差比与累计比例
手工特征值 sklearn 特征值 解释方差比 累计解释比
PC1 1.9265 1.9265 0.3853 0.3853
PC2 1.1041 1.1041 0.2208 0.6061
PC3 0.8984 0.8984 0.1797 0.7857
PC4 0.6256 0.6256 0.1251 0.9108
PC5 0.4458 0.4458 0.0892 1.0000

图 15.1 将前两个主成分与载荷放在同一图中。点表示公司—季度观测,箭头表示原始特征沿两个成分的方向。载荷必须与定义、分布和具体样本联合解读,不是自动命名的『价值因子』。

import matplotlib.pyplot as plt  # 绘制由真实数据拟合得到的 PCA 结果
plt.rcParams['font.family'] = ['Source Han Serif SC']  # 按项目规范使用思源宋体
plt.rcParams['axes.unicode_minus'] = False  # 保证中文字体下负号正确显示
display_positions = np.linspace(0, len(training_scores) - 1, min(1200, len(training_scores)), dtype=int)  # 等距抽取点仅用于避免图形遮挡
figure, axis = plt.subplots(figsize=(8, 6))  # 创建可容纳散点和载荷的画布
axis.scatter(training_scores[display_positions, 0], training_scores[display_positions, 1], s=10, alpha=0.18, color='#2C3E50', label='训练期观测')  # 展示历史样本的主成分得分
arrow_scale = 3.0  # 将单位载荷缩放到散点图可见范围
for feature_name, first_loading, second_loading in zip(numeric_features, pca_model.components_[0], pca_model.components_[1]):  # 逐一标注原始特征的投影方向
    axis.arrow(0, 0, first_loading * arrow_scale, second_loading * arrow_scale, color='#E3120B', width=0.012, head_width=0.10, length_includes_head=True)  # 用箭头表示特征载荷
    axis.text(first_loading * arrow_scale * 1.12, second_loading * arrow_scale * 1.12, feature_name, fontsize=8, color='#E3120B')  # 将特征名紧邻对应箭头
axis.axhline(0, color='#8E9EAA', linewidth=0.8)  # 添加第二主成分的零基准
axis.axvline(0, color='#8E9EAA', linewidth=0.8)  # 添加第一主成分的零基准
axis.set_xlabel(f'PC1({pca_model.explained_variance_ratio_[0]:.1%})')  # 在横轴同时披露解释方差比
axis.set_ylabel(f'PC2({pca_model.explained_variance_ratio_[1]:.1%})')  # 在纵轴同时披露解释方差比
axis.set_title('长三角上市公司估值特征 PCA')  # 说明图形对象和方法
axis.legend(frameon=False)  # 以简洁图例标识样本点
plt.tight_layout()  # 避免载荷文本与边界重叠
plt.show()  # 将拟合结果交给 Quarto 渲染
图 15.1: 长三角估值特征的前两个主成分与载荷

适用边界

PCA 最大化的是无监督方差,不是样本外收益的可预测性。高方差可能来自市场泡沫、会计口径差异或错误值。在监督学习中,成分数必须放在交叉验证内选择;主成分也不能解释为因果因子。

15.6 不均衡不是数据清洗问题

截面前五分位正类约占 20%,因此全部预测为 0 就可以获得约 80% 准确率,却没有识别前五分位样本的能力。平衡类权重通常设为

\[ \omega_k=\frac{n}{K n_k}, \tag{15.9}\]

式 15.9 使每个类别在加权样本总量中贡献相同。其中 \(n_k\) 是类别 \(k\) 的样本数,\(K\) 是类别数。这个权重修改了误分成本,却不会创造新信息。对罕见正类,应同时报告精确率、召回率、PR-AUC 和根据交易成本确定的阈值;ROC-AUC 可以作为排序指标,但不能单独表示罕见事件的实用性。

15.7 常见误区

  • 先变换、后拆分:即使没有用目标,测试均值、分位数和 PCA 方向也是未来信息。

  • 全样本目标编码:平滑只降低方差;按标签可得日 purge 的时间 OOF 才能避免尚未形成的结果进入映射。

  • 把缩尾当作去伪:负 PE 可能反映真实亏损,不能仅因其不符合常态分布就删除。

  • 用 PCA 解释因果:载荷描述样本协方差结构,不识别经济因果。

  • 只看准确率:类别不均衡时,高准确率可能对应零正类召回。

15.8 本章小结

数值缩放改变几何尺度,缩尾限制尾部影响,分箱用信息损失换取稳健与非线性。目标编码的关键不是 groupby().mean(),而是平滑、标签可得日 purge、时间 OOF 与样本外未见类别规则;GroupKFold 只能另行回答公司是否跨侧。PCA 由协方差矩阵的特征分解导出,必须与载荷、解释方差和稳定性诊断一起使用。所有拟合型变换和目标统计都只能在当时已知的信息集上估计;这一原则将在下一章通过 Pipeline 工程化。

15.9 分层练习

15.9.1 基础题

练习 15.1

某特征的训练值为 \([2,4,6,8]\)。按 StandardScaler 约定计算均值、方差和标准化结果;说明为何不应将测试值 100 加入参数估计。

完整解答

\(μ=(2+4+6+8)/4=5\)\(σ^2=[9+1+1+9]/4=5\)\(σ=√5\)。因此 Z 分数依次为 \([-3/√5,-1/√5,1/√5,3/√5]\),约为 \([-1.342,-0.447,0.447,1.342]\)。测试值 100 是拟真评估中的未来信息;把它加入均值和方差会改变训练样本的坐标系,使评估过于乐观。

15.9.2 进阶题

练习 15.2

对比手工 PCA 特征值与 sklearn 结果,选出累计解释方差首次达到 80% 所需的成分数,并找出 PC1 绝对载荷最大的特征。

完整解答

参考代码见 列表 15.4

列表 15.4: 练习 15.2 完整解答
required_component_count = int(np.searchsorted(pca_model.explained_variance_ratio_.cumsum(), 0.80) + 1)  # 定位累计解释比首次达标的成分数
largest_pc1_feature = loading_table['PC1'].abs().idxmax()  # 以绝对载荷衡量 PC1 的主要构成
eigenvalue_gap = np.abs(manual_eigenvalues - pca_model.explained_variance_).max()  # 检查手工特征分解与 sklearn 的最大差异
print(f'达到 80% 所需成分数:{required_component_count}')  # 报告基于真实数据的维数选择
print(f'PC1 绝对载荷最大的特征:{largest_pc1_feature}')  # 报告主要方差方向的特征来源
print(f'两种特征值的最大差异:{eigenvalue_gap:.3e}')  # 用数值误差验证数学与实现一致
达到 80% 所需成分数:4
PC1 绝对载荷最大的特征:pb_ratio_lf
两种特征值的最大差异:1.554e-15

输出中成分数由当前本地数据决定。特征值差异应仅处于浮点误差量级;若差异很大,应先检查是否忘记中心化、是否使用相同的协方差分母。

15.9.3 真实数据应用题

练习 15.3

证明时间 OOF 每折拟合端的非法标签数为 0,并核验面向测试集的映射未使用测试信息时点尚不可得的标签。另用 GroupKFold 证明公司交集为 0,说明为何这一结果不能替代时间审计;最后比较时间 OOF 与错误全样本编码对罕见行业的影响。

完整解答

分组断言和对照计算见 列表 15.5

列表 15.5: 练习 15.3 完整解答
assert encoding_fold_audit['非法标签数'].eq(0).all()  # 核验每个时间 OOF 拟合端只含当时已知标签
assert training_panel['label_available_date'].lt(test_information_start).all()  # 核验测试映射的全部标签严格早于决策时点
assert group_isolation_audit['公司交集数'].eq(0).all()  # 核验 GroupKFold 的公司隔离属性
naive_mapping, naive_prior = fit_smoothed_target_mapping(training_panel['industry'], training_panel['is_top_quintile'], 20.0)  # 构造会看见自身目标的错误对照
naive_encoding = training_panel['industry'].map(naive_mapping).fillna(naive_prior)  # 将全样本映射错误地用回同一样本
oof_covered_rows = training_oof_encoding.notna()  # 将比较限定在实际生成时间 OOF 编码的末端截面
covered_industry_counts = training_panel.loc[oof_covered_rows, 'industry'].value_counts()  # 在可比样本内识别罕见类别
rare_industry_rows = oof_covered_rows & training_panel['industry'].isin(covered_industry_counts.nsmallest(3).index)  # 选取覆盖样本最少的三个行业
encoding_difference = (training_oof_encoding.loc[rare_industry_rows] - naive_encoding.loc[rare_industry_rows]).abs().mean()  # 量化未来与自身标签进入映射的影响
oof_illegal_counts = encoding_fold_audit['非法标签数'].tolist()  # 提取可展示的时间折非法标签计数
group_overlap_counts = group_isolation_audit['公司交集数'].tolist()  # 提取公司隔离折的交集计数
print(f'时间 OOF 各折非法标签数:{oof_illegal_counts}')  # 期望每一项都为零
print(f'GroupKFold 各折公司交集数:{group_overlap_counts}')  # 只报告公司隔离证据
print(f'罕见行业中两种编码的平均绝对差:{encoding_difference:.4f}')  # 展示错误全样本映射与安全时间映射的差异
时间 OOF 各折非法标签数:[0, 0, 0]
GroupKFold 各折公司交集数:[0, 0, 0, 0, 0]
罕见行业中两种编码的平均绝对差:0.0276

时间 OOF 的非法标签数与最终训练集非法标签数必须全为零;GroupKFold 的公司交集也必须全为零,但后者只回答实体隔离,不能证明验证时点的标签已经可得。罕见行业中单个目标对类别均值影响更大,因此安全时间 OOF 与错误全样本编码的差异通常更明显。

15.9.4 综合挑战题

练习 15.4

对『下一季度市值变化率位于同期截面前五分位』任务计算平衡类权重。说明为何不应使用 SMOTE 后再拆分时间测试集,并给出正确顺序。

完整解答

类别频数与平衡权重的对照见 表 15.9

from sklearn.utils.class_weight import compute_class_weight  # 使用与 sklearn 分类器一致的权重定义
observed_classes = np.sort(training_panel['is_top_quintile'].unique())  # 明确训练期实际出现的类别
balanced_weights = compute_class_weight(class_weight='balanced', classes=observed_classes, y=training_panel['is_top_quintile'])  # 实现 @eq-ch15-balanced-weight
class_weight_table = pd.DataFrame({'类别': observed_classes, '样本数': training_panel['is_top_quintile'].value_counts().reindex(observed_classes).to_numpy(), '平衡权重': balanced_weights})  # 对照频数与损失权重
class_weight_table.round(3)  # 显示少数类获得更高权重的结果
表 15.9: 练习 15.4 的平衡类权重
类别 样本数 平衡权重
0 0 10330 0.625
1 1 2582 2.500

正确顺序是:先按时间留出最终测试集;然后在每个训练折内拟合预处理和任何重采样器;最后对该折的未处理验证数据评分。如果在拆分前做 SMOTE,由测试样本参与构造的合成点可能进入训练集,从而直接泄漏未来信息。

15.9.5 变换边界比较题

练习 15.5

训练值为 \([0,10,20,1000]\),未来测试值为 1500。按训练期参数计算测试值的 Z 分数和 Min–Max 值;再用训练期 25% 与 75% 分位数缩尾测试值。比较三种变换对未来超界、异常值和排序信息的处理边界,并说明对数变换为何不能替代缩尾。

完整解答

训练均值为 257.5,总体标准差约为 428.741,所以测试值的 Z 分数约为 2.898;Min–Max 值为 \((1500-0)/(1000-0)=1.5\),说明测试值不会被自动限制在 \([0,1]\)。训练期 25% 与 75% 分位数分别为 7.5 和 265,因此缩尾后的测试值为 265。

表 15.10 汇总本节的计算或审计结果,解释时应遵循正文给出的口径与限制。

boundary_training_values = pd.Series([0.0, 10.0, 20.0, 1000.0])  # 构造题设中的历史拟合样本
boundary_test_value = 1500.0  # 将未来极端值严格排除在参数估计之外
training_mean = boundary_training_values.mean()  # 按StandardScaler口径估计训练均值
training_scale = boundary_training_values.std(ddof=0)  # 按总体方差约定估计训练尺度
test_z_score = (boundary_test_value - training_mean) / training_scale  # 用历史尺度变换未来值而不截断
test_minmax = (boundary_test_value - boundary_training_values.min()) / (boundary_training_values.max() - boundary_training_values.min())  # 允许未来值超出训练区间
winsor_limits = boundary_training_values.quantile([0.25, 0.75])  # 仅用训练样本估计教学用缩尾边界
test_winsorized = np.clip(boundary_test_value, winsor_limits.loc[0.25], winsor_limits.loc[0.75])  # 将未来尾部压到历史上界
transform_boundary_table = pd.DataFrame({'变换': ['Z分数', 'Min–Max', '25%—75%缩尾'], '测试结果': [test_z_score, test_minmax, test_winsorized], '硬边界': ['无', '训练期为[0,1],未来可超界', '由训练分位数截断']})  # 并列展示三种变换的可观察差异
transform_boundary_table.round(3)  # 输出边界比较证据
表 15.10: 练习 15.5 的尺度与尾部边界比较
变换 测试结果 硬边界
0 Z分数 2.898
1 Min–Max 1.500 训练期为[0,1],未来可超界
2 25%—75%缩尾 265.000 由训练分位数截断

Z 分数和 Min–Max 都保持线性次序,却仍受训练极值影响;缩尾限制尾部影响,但会制造边界并列并改变距离。对数变换只能缓和正值右偏,对零值、负值需要另定规则,而且不会给极端值设置硬上界,所以不能当作缩尾的同义操作。