第 12 讲:因子预处理工业级四步法
本讲核心目标:
掌握买方机构因子特征入库前不可或缺的“工业级四步清洗法”:MAD(中位数绝对偏差)鲁棒去极值、行业中位数分层填补、截面无偏 Z-Score 标准化,以及加权最小二乘法(WLS)行业与对数市值残差正交化;从高斯分布分位数严格推导 MAD 系数 1.4826 的数学来源;严格证明 WLS 加权正交条件的数学性质。
1. 为什么原始单因子绝不能直接送入模型?
在前面的课程中,我们从量价和基本面中挖掘出了各类原始指标(Raw Factors)。然而,若将这些原始指标直接喂给线性打分模型、机器学习或优化器,会引发严重的数据灾难:
原始因子未清洗的四大致命隐患
├── 1. 厚尾极端离群值 (Fat-tail Outliers):
│ ├── 典型案例: 某重组股单日暴涨 300%,或微利股 PE 暴增至 10,000 倍
│ └── 毁灭后果: 单个离群点的平方误差将主导整个 OLS 线性回归,破坏全市场参数估计!
├── 2. 行业与板块 Beta 污染 (Industry Bias):
│ └── 毁灭后果: 所谓“高盈利”因子实际上选出来的全是大金融与白酒,模型在做单边行业赌博!
├── 3. 市值风格隐蔽共线性 (Size Bias):
│ └── 毁灭后果: 因子天然偏爱微盘股,策略在回测中展示虚高超额,实盘遭遇流动性踩踏!
└── 4. 异构量纲不一致 (Scale Inconsistency):
└── 毁灭后果: 动量因子在 [-0.5, 0.5] 之间,而成交额因子在 [1e7, 1e10] 之间,无法等权合成!2. 工业级四步法的数学推导与边界
2.1 步骤一:中位数绝对偏差(MAD)鲁棒去极值
传统 均值去极值的致命缺陷:
传统统计学常采用
鲁棒 MAD 数学定义:
取横截面样本的中位数
核心系数 1.4826 的数学推导:
为什么在标准正态分布下要乘以 1.4826?
若随机变量
根据标准正态分布的对称性:
查标准正态分布分位数表可知:
即在正态分布基准下,
2.2 步骤二:行业中位数分层填补(Industry-Stratified Imputation)
当某股票因子缺失时(例如新上市股票缺乏历史波动率,或周期股净利润为负导致某些对数指标缺失):
- 买方原则:采用同行业中位数填补(因为同行业股票往往具有相似的商业特征与估值倍数);
- 若整个行业全量缺失,采用全市场中位数兜底;
- 未知行业必须显式标记为独立的
ind_未知类别,严禁将未知随意归入“综合”行业。
2.3 步骤三:横截面无偏标准化(Cross-Sectional Z-Score)
为了使不同维度的特征(如动量、ROE、换手率)具备可比性,在交易日截面上执行 Z-Score 转换:
转换后的因子在横截面上严格满足均值为 0、方差为 1。
2.4 步骤四:加权最小二乘法(WLS)行业与市值残差正交化
设计矩阵构造:
设全市场截面有
为什么不能直接用普通 OLS?Breusch-Pagan 与 White 异方差检验
根据华泰证券多因子系列之一(2016)研究,金融截面回归的核心前提是满足同方差假定
- Breusch-Pagan 检验: 通过辅助回归
,构造拉格朗日乘数统计量 。若统计量显著大于临界值,则拒绝同方差假设; - White 检验: 在辅助回归中进一步引入自变量平方项与交叉乘积项:
构造统计量 。在 A 股日频与月频截面上,同方差假设几乎 100% 被拒绝! - 异方差对 OLS 的致命冲击:
- 虽然 OLS 参数估计量依然无偏,但不再是最小方差最佳线性无偏估计量(BLUE);
- 参数标准误严重低估,导致 t 统计量虚高膨胀,将无预测力的微盘噪声误判为高度显著的 Alpha;
- 因此,Barra 与华泰体系必须采用加权最小二乘法(WLS),以个股流通市值平方根(
)为加权矩阵 ,彻底消除异方差扭曲!
WLS 回归目标函数:
其中权重矩阵
WLS 加权正交条件的严格数学证明:
对目标函数求偏导并令其为 0:
正规方程解得:
定义回归残差向量为纯净特异质因子
证明完毕! 这表明:
- 特异质残差因子
与对数市值在加权意义下协方差严格为 0; - 在任意行业内部,加权残差和严格为 0(
); - 重要辨析:WLS 保证的是加权正交(
),并不保证无权相关系数为 0。机构之所以采用 WLS,是为了确保大市值权重股的风险暴露被更彻底地清洗掉!
3. 动手实操:工业级因子预处理流水线实现
下面的 Python 代码严格遵循机构级买方规范,纯 NumPy 向量化实现 MAD 去极值、行业中位数填补、Z-Score 与 WLS 残差正交化,并自动化断言
在项目根目录运行 PYTHONPATH=tutorial python3 -m examples.lecture_12_factor_preprocessing_pipeline。依赖安装、共享模块和数据契约见项目说明。
"""截面预处理:MAD、行业中位数填充、Z-Score、WLS 残差。
WLS 用 NumPy 求解 sqrt(W)X beta ≈ sqrt(W)y,无额外统计包依赖。
本实现要求行业已知;未知行业应由上游显式编码成单独类别。
"""
import numpy as np
class AshareFactorPreprocessor:
@staticmethod
def winsorize_mad(series: np.ndarray, n_sigmas: float = 3.0) -> np.ndarray:
values = np.asarray(series, dtype=float).copy()
values[~np.isfinite(values)] = np.nan
if not np.isfinite(values).any():
raise ValueError("截面没有有效因子值")
med = np.nanmedian(values)
mad = np.nanmedian(np.abs(values - med))
# MAD=0 时保留有限值,不凭空构造尺度;后续标准化会拒绝常数截面。
if mad == 0:
return values
return np.clip(values, med - n_sigmas * 1.4826 * mad, med + n_sigmas * 1.4826 * mad)
@staticmethod
def impute_industry_median(series: np.ndarray, industries: np.ndarray) -> np.ndarray:
values = np.asarray(series, dtype=float).copy()
groups = np.asarray(industries)
if values.ndim != 1 or groups.shape != values.shape:
raise ValueError("行业和因子必须为等长向量")
if any(v is None or (isinstance(v, float) and np.isnan(v)) for v in groups):
raise ValueError("未知行业请显式编码为独立类别")
valid = np.isfinite(values)
if not valid.any():
raise ValueError("全截面缺失,不能填充")
fallback = np.median(values[valid])
for industry in np.unique(groups):
members = groups == industry
observed = values[members & valid]
median = np.median(observed) if len(observed) else fallback
values[members & ~valid] = median
return values
@staticmethod
def standardize_zscore(series: np.ndarray) -> np.ndarray:
values = np.asarray(series, dtype=float)
if not np.isfinite(values).all():
raise ValueError("标准化前须完成缺失值填充")
std = values.std(ddof=0)
if std <= 1e-12:
raise ValueError("常数截面无可排序信号")
return (values - values.mean()) / std
def neutralize_factor_wls(self, raw_factor, log_mcap, industry_dummies, weights):
y = np.asarray(raw_factor, dtype=float)
cap = np.asarray(log_mcap, dtype=float)
industries = np.asarray(industry_dummies, dtype=float)
weights = np.asarray(weights, dtype=float)
if y.ndim != 1 or cap.shape != y.shape or weights.shape != y.shape:
raise ValueError("因子、市值、权重必须为等长向量")
if industries.ndim != 2 or industries.shape[0] != len(y):
raise ValueError("行业矩阵行数必须等于股票数")
if not np.isfinite(industries).all() or not np.all(np.isin(industries, [0, 1])) or not np.all(industries.sum(axis=1) == 1):
raise ValueError("每只股票必须对应一个已知行业哑变量")
# 保留全部行业虚拟变量,不再增加截距;删除当前截面不存在的行业列。
industries = industries[:, industries.any(axis=0)]
X = np.column_stack([cap, industries])
valid = np.isfinite(y) & np.isfinite(X).all(axis=1) & np.isfinite(weights) & (weights > 0)
if valid.sum() <= X.shape[1]:
raise ValueError("有效样本不足,不能进行中性化")
Xv, yv, wv = X[valid], y[valid], weights[valid]
sqrt_w = np.sqrt(wv)
beta, _, rank, _ = np.linalg.lstsq(Xv * sqrt_w[:, None], yv * sqrt_w, rcond=None)
if rank < Xv.shape[1]:
raise ValueError("中性化设计矩阵秩不足")
residuals = np.full(len(y), np.nan)
residuals[valid] = yv - Xv @ beta
# 正交条件是 X.T W residual = 0,不是无权相关系数必为 0。
return residuals
def transform(self, raw_factor, log_mcap, industries, weights):
"""按明确顺序执行四步;返回残差及各步数据,便于审计。"""
winsorized = self.winsorize_mad(raw_factor)
imputed = self.impute_industry_median(winsorized, industries)
standardized = self.standardize_zscore(imputed)
_, codes = np.unique(industries, return_inverse=True)
dummies = np.eye(codes.max() + 1)[codes]
residuals = self.neutralize_factor_wls(standardized, log_mcap, dummies, weights)
return residuals, dict(winsorized=winsorized, imputed=imputed,
standardized=standardized, industry_dummies=dummies)
if __name__ == '__main__':
rng = np.random.default_rng(42)
n = 500
log_mcap = rng.normal(10, 1.2, n)
industries = np.arange(n) % 5
weights = np.exp(log_mcap / 2) # 研究权重选择,不宣称等于误差方差倒数
raw = .5 * log_mcap + rng.normal(size=n)
raw[:4] = [500., -300., np.nan, np.inf]
residuals, steps = AshareFactorPreprocessor().transform(raw, log_mcap, industries, weights)
X = np.column_stack([log_mcap, steps['industry_dummies']])
assert np.isfinite(residuals).all()
np.testing.assert_allclose(X.T @ (weights * residuals), 0., atol=1e-7)
print('四步预处理完成;缺失值已填充,WLS 加权正交条件通过。')
print('无权市值相关系数(不要求为零):', np.corrcoef(residuals, log_mcap)[0, 1])核心运行输出与数学验证:
四步预处理完成;缺失值已填充,WLS 加权正交条件通过。
无权市值相关系数(不要求为零): 0.005045665433964128- 数值精度验证:
- 代码通过
np.testing.assert_allclose(X.T @ (weights * residuals), 0., atol=1e-7)严密断言加权正交条件的成立;
- 代码通过
- 缺失与离群值自愈:
- 故意注入的 500.0(超大极值)、-300.0、
np.nan与np.inf被流水线平稳修复和截断,全流程零崩溃。
- 故意注入的 500.0(超大极值)、-300.0、
4. 本讲小结与课后动手作业
核心知识图谱梳理:
- MAD 去极值:基于正态分布一致性推导出
,具备极高的抗污染鲁棒性; - 分层中位数填补:按当前可见行业分层填补缺失值,避免全局中位数引发行业特征漂移;
- Z-Score 截面标准化:统一不同特征的量纲与尺度,为多因子合成奠定基础;
- WLS 行业市值中性化:数学证明
,彻底剥离市值与行业风格贝塔。
课后动手实训:
实操任务:
- 打开
tutorial/examples/lecture_12_factor_preprocessing_pipeline.py。- 将权重参数
weights替换为全 1 向量(weights = np.ones(n)),观察此时 WLS 退化为普通最小二乘法(OLS)。- 运行后观察无权市值相关系数(
np.corrcoef(residuals, log_mcap)[0, 1])是否会精确降为 0.000000?思考为什么加权与无权正交不可兼得?