跳至正文
MODULE 03特征工程与因子挖掘5 讲 · FACTOR RESEARCH
LECTURE 12共 31 讲预计阅读 45 分钟

第 12 讲:因子预处理工业级四步法 ​

本讲核心目标:
掌握买方机构因子特征入库前不可或缺的“工业级四步清洗法”:MAD(中位数绝对偏差)鲁棒去极值、行业中位数分层填补、截面无偏 Z-Score 标准化,以及加权最小二乘法(WLS)行业与对数市值残差正交化;从高斯分布分位数严格推导 MAD 系数 1.4826 的数学来源;严格证明 WLS 加权正交条件 XTWϵ^=0 的数学性质。


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)鲁棒去极值 ​

传统 3σ 均值去极值的致命缺陷: ​

传统统计学常采用 μ±3σ 截断。但在真实金融数据中,离群值本身就会严重拉大样本均值 μ 并剧烈膨胀样本标准差 σ!极端异常值的存在使得 μ 和 σ 被污染,导致真正的异常点根本无法被有效识别(Masking Effect)。

鲁棒 MAD 数学定义: ​

取横截面样本的中位数 m,计算所有样本相对中位数距离的中位数(Median Absolute Deviation, MAD):

m=median(x),MAD=median(|x−m|)xiclipped=clip(xi,m−3×1.4826×MAD,m+3×1.4826×MAD)

核心系数 1.4826 的数学推导: ​

为什么在标准正态分布下要乘以 1.4826?
若随机变量 X∼N(0,σ2),其对称中位数为 0。绝对值 |X| 的中位数满足:

P(|X|≤MAD)=0.5⟹P(−MAD≤X≤MAD)=0.5

根据标准正态分布的对称性:

Φ(MADσ)−Φ(−MADσ)=2Φ(MADσ)−1=0.5⟹Φ(MADσ)=0.75

查标准正态分布分位数表可知:Φ−1(0.75)≈0.67449。因此:

MADσ≈0.67449⟹σ≈10.67449×MAD≈1.4826×MAD

即在正态分布基准下,1.4826×MAD 是样本标准差 σ 的无偏鲁棒估计(Consistent Estimator),且完全不受极值污染!


2.2 步骤二:行业中位数分层填补(Industry-Stratified Imputation) ​

当某股票因子缺失时(例如新上市股票缺乏历史波动率,或周期股净利润为负导致某些对数指标缺失):

  • 买方原则:采用同行业中位数填补(因为同行业股票往往具有相似的商业特征与估值倍数);
  • 若整个行业全量缺失,采用全市场中位数兜底;
  • 未知行业必须显式标记为独立的 ind_未知 类别,严禁将未知随意归入“综合”行业。

2.3 步骤三:横截面无偏标准化(Cross-Sectional Z-Score) ​

为了使不同维度的特征(如动量、ROE、换手率)具备可比性,在交易日截面上执行 Z-Score 转换:

zi=xi−x¯sx,其中 sx=1N−1∑i=1N(xi−x¯)2

转换后的因子在横截面上严格满足均值为 0、方差为 1。


2.4 步骤四:加权最小二乘法(WLS)行业与市值残差正交化 ​

设计矩阵构造: ​

设全市场截面有 N 只股票,包含对数流通市值向量 ln⁡(FloatCap)∈RN 和 J 个互斥行业哑变量矩阵 I∈{0,1}N×J。设计矩阵中不再添加常数截距项(避免与行业列触发虚拟变量陷阱):

X=[ln⁡(FloatCap),I:,1,I:,2,…,I:,J]∈RN×(1+J)

为什么不能直接用普通 OLS?Breusch-Pagan 与 White 异方差检验 ​

根据华泰证券多因子系列之一(2016)研究,金融截面回归的核心前提是满足同方差假定 Var(ui∣X)=σ2。然而在 A 股全市场截面上,由于微盘股投机波动远高于大盘股,残差项具有剧烈的异方差性(Heteroscedasticity):

  1. Breusch-Pagan 检验: 通过辅助回归 u^i2=α0+∑k=1KαkXi,k+vi,构造拉格朗日乘数统计量 LM=nRu2∼χ2(K)。若统计量显著大于临界值,则拒绝同方差假设;
  2. White 检验: 在辅助回归中进一步引入自变量平方项与交叉乘积项:u^i2=α0+∑k=1KαkXi,k+∑k=1KγkXi,k2+∑j<kδjkXi,jXi,k+vi构造统计量 LM=nRu2∼χ2(K(K+3)2)。在 A 股日频与月频截面上,同方差假设几乎 100% 被拒绝!
  3. 异方差对 OLS 的致命冲击:
    • 虽然 OLS 参数估计量依然无偏,但不再是最小方差最佳线性无偏估计量(BLUE);
    • 参数标准误严重低估,导致 t 统计量虚高膨胀,将无预测力的微盘噪声误判为高度显著的 Alpha;
    • 因此,Barra 与华泰体系必须采用加权最小二乘法(WLS),以个股流通市值平方根(FloatCap)为加权矩阵 W,彻底消除异方差扭曲!

WLS 回归目标函数: ​

minβ∑i=1Nwi(yi−xiTβ)2⟺minβ(y−Xβ)TW(y−Xβ)

其中权重矩阵 W=diag(w1,…,wN),买方机构通常采用**股票流通市值的平方根(Capi)**作为加权权重。

WLS 加权正交条件的严格数学证明: ​

对目标函数求偏导并令其为 0:

∂∂β[(y−Xβ)TW(y−Xβ)]=−2XTW(y−Xβ)=0

正规方程解得:

β^=(XTWX)−1XTWy

定义回归残差向量为纯净特异质因子 ϵ^=y−Xβ^。将解代回正规方程:

XTWϵ^=XTW(y−Xβ^)≡0

证明完毕! 这表明:

  1. 特异质残差因子 ϵ^ 与对数市值在加权意义下协方差严格为 0;
  2. 在任意行业内部,加权残差和严格为 0(∑i∈Industryjwiϵ^i=0);
  3. 重要辨析:WLS 保证的是加权正交(XTWϵ^=0),并不保证无权相关系数为 0。机构之所以采用 WLS,是为了确保大市值权重股的风险暴露被更彻底地清洗掉!

3. 动手实操:工业级因子预处理流水线实现 ​

下面的 Python 代码严格遵循机构级买方规范,纯 NumPy 向量化实现 MAD 去极值、行业中位数填补、Z-Score 与 WLS 残差正交化,并自动化断言 XTWϵ^≈0。

在项目根目录运行 PYTHONPATH=tutorial python3 -m examples.lecture_12_factor_preprocessing_pipeline。依赖安装、共享模块和数据契约见项目说明。

py
"""截面预处理: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
  1. 数值精度验证:
    • 代码通过 np.testing.assert_allclose(X.T @ (weights * residuals), 0., atol=1e-7) 严密断言加权正交条件的成立;
  2. 缺失与离群值自愈:
    • 故意注入的 500.0(超大极值)、-300.0、np.nan 与 np.inf 被流水线平稳修复和截断,全流程零崩溃。

4. 本讲小结与课后动手作业 ​

核心知识图谱梳理: ​

  1. MAD 去极值:基于正态分布一致性推导出 1.4826×MAD,具备极高的抗污染鲁棒性;
  2. 分层中位数填补:按当前可见行业分层填补缺失值,避免全局中位数引发行业特征漂移;
  3. Z-Score 截面标准化:统一不同特征的量纲与尺度,为多因子合成奠定基础;
  4. WLS 行业市值中性化:数学证明 XTWϵ^=0,彻底剥离市值与行业风格贝塔。

课后动手实训: ​

实操任务:

  1. 打开 tutorial/examples/lecture_12_factor_preprocessing_pipeline.py。
  2. 将权重参数 weights 替换为全 1 向量(weights = np.ones(n)),观察此时 WLS 退化为普通最小二乘法(OLS)。
  3. 运行后观察无权市值相关系数(np.corrcoef(residuals, log_mcap)[0, 1])是否会精确降为 0.000000?思考为什么加权与无权正交不可兼得?

QUANTML · Quantitative Research Handbook