4、因子计算与标准化:因子计算公式、去极值、标准化(Z-score)、中性化处理

好,咱们进入正题。因子算出来了,原始数据摆在那,能直接用吗?不能。我早年刚入行时,就吃过这个亏——把原始因子值直接扔进模型,结果回测曲线漂亮得不像话,实盘一跑就崩。后来才明白,原始因子里藏着大量噪声和偏差,必须经过一套标准化的“洗菜”流程。

说白了,因子标准化就是给数据“洗澡”。洗掉脏东西(极值),统一度量衡(Z-score),再剔除不该有的偏见(中性化)。这一套做完,因子才能用。

4.1 因子计算公式:先搞清楚你在算什么

因子计算本身不复杂,但容易出错。我见过最离谱的,是把市盈率的倒数算成了市净率。嗯,这里要注意,因子公式一定要和业务逻辑对应上

常见的因子公式就那么几类:

  • 估值类:比如 EP( earnings yield )= 1 / PE。为什么用 EP 而不是 PE?因为 PE 的分布太偏了,EP 更接近正态。
  • 动量类:比如过去 12 个月收益率(剔除最近 1 个月)。这个公式里有个坑——要不要复权?我个人习惯用后复权价格,不然分红会扭曲动量信号。
  • 质量类:比如 ROE = 净利润 / 净资产。注意分母用期初还是期末?我建议用期初,不然新上市的公司数据会出问题。

举个例子,一个简单的动量因子计算:

# 假设 df 是日频价格数据,包含复权收盘价
df['momentum'] = df['close'].pct_change(252)  # 年化收益率
# 剔除最近 20 个交易日,避免短期反转干扰
df['momentum_clean'] = df['close'].pct_change(20, 252)  # 这个写法不对
# 正确做法:用 shift 偏移
df['momentum'] = df['close'].pct_change(252) - df['close'].pct_change(20)

你看,公式写错一个参数,结果就完全不一样。我在项目中遇到过,有人把 shift 和 pct_change 的周期搞反了,回测出来动量因子居然是负收益。排查了两天才找到原因。

我的小技巧:写因子公式时,先用一小段数据手动验算。Excel 算一遍,Python 算一遍,对得上再上全量数据。这一步能省下后面 80% 的 debug 时间。

4.2 去极值:别让异常值毁了你的因子

因子数据里经常有极端值。比如某只股票因为停牌,复牌后一天涨了 500%,动量因子直接爆表。这种极值如果不处理,Z-score 标准化时会把正常数据压成“一坨”,你想想看,那还有啥区分度?

去极值的方法,我常用的有三种:

方法 原理 适用场景
MAD 法 基于中位数绝对偏差,稳健性强 因子分布不对称时首选
百分位法 直接截断 1% 和 99% 分位数 简单粗暴,适合快速验证
标准差法 超过均值 ±3σ 的截断 因子近似正态分布时可用

我个人最常用的是 MAD 法。为什么?因为中位数不受极值影响,比均值稳健得多。我曾经用标准差法处理一个偏态分布的因子,结果截断后数据反而更奇怪了。

def winsorize_mad(series, n=5):
    """
    基于 MAD 的去极值
    n=5 表示超过 5 倍 MAD 的视为极值
    """
    median = series.median()
    mad = (series - median).abs().median()
    upper = median + n * mad
    lower = median - n * mad
    return series.clip(lower, upper)
注意:去极值不是删除数据,而是截断。删除数据会改变样本量,影响后续的截面回归。我曾经犯过这个错,删了 5% 的极值,结果中性化回归的系数全变了。

4.3 标准化(Z-score):让因子站在同一起跑线

去完极值,接下来就是标准化。Z-score 是最常用的方法:

Z = (X - μ) / σ

标准化之后,因子均值为 0,标准差为 1。这样不同量纲的因子才能放在一起比较。比如市盈率倒数(EP)和动量因子,一个在 0.01 量级,一个在 0.5 量级,不标准化的话,动量因子会主导模型。

但这里有个细节:均值和标准差用哪个截面算?

  • 截面标准化:每个时间点,用当期所有股票的均值和标准差。这是最常用的,能保证每个时间截面上因子分布一致。
  • 时序标准化:每只股票,用其历史均值和标准差。这个用得少,因为会引入未来信息(除非你严格用滚动窗口)。

我建议用截面标准化。原因很简单——我们做的是截面选股,不是时序预测。每个时间点上,我们希望因子能区分出哪些股票好、哪些差。

def zscore_cross_sectional(series):
    """
    截面 Z-score 标准化
    注意:已经去极值后再做
    """
    mean = series.mean()
    std = series.std()
    return (series - mean) / std
避坑指南:我曾经在标准化时忘了剔除 ST 股和新股。结果 ST 股的因子值被压到 -3 以下,看起来是“强烈卖出信号”,但实际上 ST 股本身流动性差,根本卖不出去。所以标准化前,一定要先做样本筛选。

4.4 中性化处理:剔除你不想要的偏见

标准化之后,因子就干净了吗?不一定。你想想看,市值大的股票是不是天然 ROE 更高?行业不同,市盈率是不是天然有差异?这些“偏见”如果不剔除,因子选出来的股票可能只是大市值或某个行业的,而不是真正有 alpha 的。

中性化处理,就是把这些偏见剔除掉。最常用的方法是回归残差法

  1. 把因子值作为因变量,需要中性化的变量(如市值、行业)作为自变量。
  2. 做线性回归,得到残差。
  3. 残差就是中性化后的因子值。

举个例子,对动量因子做市值和行业中性化:

import statsmodels.api as sm

def neutralize_factor(factor, market_cap, industry_dummies):
    """
    因子中性化
    factor: 原始因子值(已标准化)
    market_cap: 对数市值
    industry_dummies: 行业哑变量(one-hot 编码)
    """
    X = pd.concat([market_cap, industry_dummies], axis=1)
    X = sm.add_constant(X)  # 加截距项
    model = sm.OLS(factor, X).fit()
    residual = model.resid
    return residual

做完中性化之后,因子值和市值、行业的相关性应该接近于 0。你可以用相关系数矩阵验证一下。

我的经验:中性化不是越多越好。有些人恨不得把 50 个变量都中性化掉,结果因子被“洗”得干干净净,啥信号都没了。我一般只中性化市值和行业,最多再加一个换手率。记住,中性化的目的是剔除噪声,不是剔除信号。

4.5 完整流程:一张图说清楚

好了,上面讲了四个步骤:计算、去极值、标准化、中性化。它们不是孤立的,而是一条流水线。我画了一张流程图,帮你理清逻辑:

因子标准化完整流程 步骤1 因子计算 步骤2 去极值(MAD/百分位) 步骤3 Z-score 标准化 步骤4 中性化(市值+行业) ✅ 干净因子 原始数据 注意: 每一步都依赖上一步 顺序不能颠倒

你看,这个流程是串行的。先算因子,再去极值,然后标准化,最后中性化。顺序不能乱。我见过有人先标准化再去极值,结果极值被标准化后“隐藏”在 ±3 以内,根本发现不了。

4.6 实战中的几个坑

最后,分享几个我踩过的坑,希望能帮你省点时间:

  • 坑一:中性化时忘记加截距项。不加截距,残差的均值不为 0,会导致因子整体偏移。我有一回回测收益异常高,查了半天发现是截距项漏了。
  • 坑二:去极值和标准化顺序搞反。先标准化再去极值,极值会被“压缩”到正常范围内,但实际上它还是异常值。正确的做法是先截断,再标准化。
  • 坑三:中性化变量选择过多。有些人把 20 个行业、市值、换手率、波动率全加进去,结果因子残差几乎全是噪声。记住,中性化是“剔除偏见”,不是“剔除所有信息”。
总结一下:因子标准化不是可选项,是必选项。不做标准化的因子,就像没洗过的菜——看着能吃,但吃了可能拉肚子。去极值、Z-score、中性化,这三步做完,你的因子才算真正“干净”。

好了,这一章的内容就到这。下一章我们会聊因子组合的构建方法,到时候这些标准化后的因子就能派上用场了。


公众号:蓝海资料掘金营,微信deep3321