一句话版本
一个想法(比如"利润增长快的公司股价会涨")要变成一个能用的因子,中间要经过一条流水线:取数(注意时点)→ 计算原始值 → 处理缺失 → 去极值 → 标准化 → 中性化(去掉行业和市值的影响)→ 再标准化。 每一步都在回答一个问题:"这个信号里,有多少是我真正想要的信息,有多少是噪声或者其他东西的影子?"跳过任何一步,回测结果都可能被少数极端值、某个行业或者市值效应主导,而你以为发现的是一个新因子。
免责
本文仅为技术科普,不构成任何投资建议。文中代码使用模拟数据。
第一层:通俗理解
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:
这样不同时期、不同因子之间可以比较,也便于合成多个因子。也可以用排序百分位代替,对分布形状更不敏感。
2.5 中性化
用回归去掉不想要的暴露。每一期做一次横截面回归:
是行业哑变量(股票 属于行业 时为 1)。残差 就是中性化后的因子:它和行业、市值都不相关,剩下的才是"同行业、同规模的股票之间,谁更便宜"。
第三层:代码实现
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) | 防止少数极端值主导 |
| 标准化 | 跨时期、跨因子可比 |
| 中性化 | 剥离行业、市值等不想要的暴露 |
| 按日期分组 | 每一步都只用当时可知的横截面信息 |