Caloogy 文章
English

因子工程:从原始数据到可用信号

日期
2026 年 1 月 1 日
系列
量化
阅读
4 分钟
主题
QuantFactor engineeringWinsorizeZscore

一句话版本

一个想法(比如"利润增长快的公司股价会涨")要变成一个能用的因子,中间要经过一条流水线:取数(注意时点)→ 计算原始值 → 处理缺失 → 去极值 → 标准化 → 中性化(去掉行业和市值的影响)→ 再标准化。 每一步都在回答一个问题:"这个信号里,有多少是我真正想要的信息,有多少是噪声或者其他东西的影子?"跳过任何一步,回测结果都可能被少数极端值、某个行业或者市值效应主导,而你以为发现的是一个新因子。

免责

本文仅为技术科普,不构成任何投资建议。文中代码使用模拟数据。


第一层:通俗理解

1.1 洗菜、切菜、去骨

食材买回来不能直接下锅:要洗掉泥沙(缺失和错误数据)、切掉坏的部分(极端值)、切成统一大小(标准化)、去掉不想要的味道(中性化)。

因子工程就是做菜前的备料。备料没做好,后面的回测再精致也没用。

1.2 一个例子

想法:"市盈率低的股票(便宜)未来收益更高。"

直接拿市盈率排序会遇到这些问题:

问题 例子
极端值 某公司利润接近零,市盈率高达 5000 倍
负值 亏损公司的市盈率是负数,意义完全不同
行业差异 银行天生市盈率低,科技公司天生市盈率高。按市盈率排序,等于在"买银行、卖科技",这是行业押注,不是"便宜"效应
市值相关 小公司可能系统性地更便宜,于是"价值"和"规模"混在一起
时点 公司 3 月份公布去年的年报,如果回测里 1 月 1 日就用了这份数据,就偷看了未来

第二层:流水线的每一步

2.1 取数:时点数据(point-in-time)

最隐蔽也最致命的错误是前视偏差:在回测的某一天,用了那一天还不可能知道的数据。

常见陷阱 正确做法
用财报的"报告期"(12 月 31 日)作为可用日期 用公告日,甚至公告日后一天
财报后来被修订,数据库里只保留了修订后的数字 使用保存了历史版本的"时点数据库"
指数成分股用的是今天的名单 用当时的成分股名单(否则引入幸存者偏差,见《回测的七宗罪》)

2.2 去极值(winsorize)

把超出合理范围的值"拉回"边界,而不是删除。常用的边界:

方法 边界
分位数法 第 1% 和第 99% 分位
标准差法 均值 ± 3 倍标准差(但标准差本身被极端值放大)
MAD 法 中位数 ± 3 × 1.4826 × MAD(中位数绝对偏差),对极端值最稳健

1.4826 这个系数让 MAD 在正态分布下与标准差一致。

2.3 缺失值

2.4 标准化

每一期(每个交易日或每个月)在横截面上做 z-score:

zi=xi−xˉσx.z_i = \frac{x_i - \bar{x}}{\sigma_x}.

这样不同时期、不同因子之间可以比较,也便于合成多个因子。也可以用排序百分位代替,对分布形状更不敏感。

2.5 中性化

用回归去掉不想要的暴露。每一期做一次横截面回归:

xi=∑kγk Dik+δln⁡(市值i)+εi,x_i = \sum_k \gamma_k \, D_{ik} + \delta \ln(\text{市值}_i) + \varepsilon_i,

DikD_{ik} 是行业哑变量(股票 ii 属于行业 kk 时为 1)。残差 εi\varepsilon_i 就是中性化后的因子:它和行业、市值都不相关,剩下的才是"同行业、同规模的股票之间,谁更便宜"。


第三层:代码实现

3.1 一条完整的流水线

下面的模拟数据里,原始因子故意混入了行业效应和市值效应,并且有厚尾分布和缺失值:

import numpy as np, pandas as pd
rng = np.random.default_rng(0)
N = 500
df = pd.DataFrame({
    "industry": rng.choice(["科技", "金融", "消费", "能源"], N),
    "mcap": np.exp(rng.normal(23, 1.2, N)),
})
ind_bias = df["industry"].map({"科技": 0.8, "金融": -0.5, "消费": 0.1, "能源": -0.3})
df["raw"] = ind_bias + 0.4 * np.log(df["mcap"]) + rng.standard_t(3, N)  # 原始因子:混有行业和市值
df.loc[rng.choice(N, 15, replace=False), "raw"] = np.nan

def winsorize_mad(x, k=3):
    med = x.median(); mad = (x - med).abs().median() * 1.4826
    return x.clip(med - k * mad, med + k * mad)

def zscore(x):
    return (x - x.mean()) / x.std()

def neutralize(y, df):
    X = pd.get_dummies(df["industry"], dtype=float)
    X["lnmcap"] = np.log(df["mcap"])
    m = y.notna()
    beta, *_ = np.linalg.lstsq(X[m].values, y[m].values, rcond=None)
    out = y.copy(); out[m] = y[m] - X[m].values @ beta
    return out

x = df["raw"]
x = x.fillna(x.groupby(df["industry"]).transform("median"))   # 行业中位数填充
x = winsorize_mad(x)
x = zscore(x)
x = neutralize(x, df)
x = zscore(x)
df["factor"] = x
print("原始因子与 ln市值 相关:", round(df["raw"].corr(np.log(df["mcap"])), 3))
print("处理后与 ln市值 相关:", round(df["factor"].corr(np.log(df["mcap"])), 3))
print("处理后各行业均值:", df.groupby("industry")["factor"].mean().round(3).to_dict())
print("原始峰度:", round(df["raw"].kurt(), 2), "处理后峰度:", round(df["factor"].kurt(), 2))

输出:

原始因子与 ln市值 相关: 0.266
处理后与 ln市值 相关: -0.0
处理后各行业均值: {'消费': -0.0, '科技': -0.0, '能源': 0.0, '金融': 0.0}
原始峰度: 4.07 处理后峰度: 1.28

(-0.0 是浮点数在四舍五入后的显示,表示极接近零。)

3.2 解读

指标 处理前 处理后 含义
与市值的相关 0.27 0 市值效应被剥离
各行业均值 差异很大 全部为 0 行业效应被剥离
峰度(越大尾部越厚) 4.07 1.28 极端值被控制

3.3 在真实的面板数据上

真实数据是"日期 × 股票"的面板,每一步都要按日期分组做,绝不能把所有日期混在一起计算均值和标准差(那会用到未来的信息):

def process_one_date(g):
    x = g["raw"]
    x = x.fillna(x.groupby(g["industry"]).transform("median"))
    x = zscore(winsorize_mad(x))
    return zscore(neutralize(x, g))

panel["factor"] = panel.groupby("date", group_keys=False).apply(process_one_date)

3.4 常见错误清单

错误 后果
在整个样本上计算均值、标准差、分位数 前视偏差
用财报报告期而不是公告日 前视偏差,回测收益虚高
不做中性化 你以为的"新因子"可能只是行业或市值的影子
过度中性化(把所有已知因子都去掉) 可能把真正的信号也去掉了
删除极端值而不是缩尾 改变了样本,而且极端值往往集中在某些股票上
先中性化再去极值 极端值会扭曲中性化回归

总结

步骤 目的
时点取数 避免前视偏差
缺失值处理 同行业中位数,不用未来数据
去极值(MAD) 防止少数极端值主导
标准化 跨时期、跨因子可比
中性化 剥离行业、市值等不想要的暴露
按日期分组 每一步都只用当时可知的横截面信息