4、单因子构建方法:截面排序法、分位数分组法、回归法(Fama-MacBeth)、因子值的标准化与中性化处理
聊到单因子构建,我脑子里第一个蹦出来的念头就是——这活儿看着简单,做起来全是坑。
你想想看,我们做量化投资,第一步就是找到能预测收益的因子。但因子本身是原始数据,不能直接用。你得把它加工成能放进模型里的样子。这个过程,说白了就是单因子构建。
我个人习惯把单因子构建分成四个层次:排序、分组、回归、以及最后的清洗。咱们一个一个来拆。
4.1 截面排序法:最直观的因子处理
截面排序法,是所有方法里最朴素的。你拿到一个因子,比如市盈率PE,在每个时间截面上,把所有股票按PE从小到大排个序。然后呢?排名本身就是因子值。
为什么这么做?因为原始因子值可能分布很歪。比如有些股票PE是负的,有些是几千倍。直接拿原始值做回归,结果会被极端值牵着鼻子走。排序之后,所有股票都变成了1到N的整数,干净利落。
- 排序方向要明确:是越大越好,还是越小越好?
- 通常用升序排列,排名越小代表因子值越小
- 如果因子是负向指标(比如PE越高越差),记得取倒数或者反向排序
我在项目中遇到过一个问题:有些股票因子值完全相同,比如停牌股。这时候排序会出现并列排名。处理方式有两种——要么取平均排名,要么随机打乱。我个人建议用平均排名,更稳定。
# Python示例:截面排序法
import pandas as pd
import numpy as np
def cross_sectional_rank(factor_df, date_col='date', asset_col='asset', factor_col='factor'):
"""
截面排序法:每个时间截面对因子进行排序
"""
df = factor_df.copy()
# 按日期分组,对因子值进行排序
df['rank'] = df.groupby(date_col)[factor_col].rank(method='average')
# 可选:将排名归一化到[0,1]区间
df['rank_pct'] = df.groupby(date_col)['rank'].transform(
lambda x: (x - 1) / (len(x) - 1)
)
return df
4.2 分位数分组法:从排序到分组
排序法虽然简单,但有个问题:排名是连续的,你很难说排名第100和第101的股票到底有多大区别。这时候,分位数分组法就派上用场了。
说白了,就是把所有股票按因子值分成若干组。最常用的是五分组(quintile)和十分组(decile)。每组内的股票被视为「同类」,组间的差异才是我们关注的。
分组之后,我们通常会做两件事:
- 多空组合收益:做多最高分组,做空最低分组,看这个多空组合的收益曲线
- 单调性检验:从第1组到第5组,收益是不是单调递增或递减?如果不是,说明因子可能有问题
# Python示例:分位数分组法
def quantile_grouping(factor_df, n_groups=5, date_col='date', factor_col='factor'):
"""
分位数分组法:每个截面分成n_groups组
"""
df = factor_df.copy()
df['group'] = df.groupby(date_col)[factor_col].transform(
lambda x: pd.qcut(x, q=n_groups, labels=False, duplicates='drop')
)
# 注意:duplicates='drop' 处理因子值相同的情况
return df
4.3 回归法(Fama-MacBeth):从分组到回归
分组法虽然直观,但它有个硬伤:你没法控制其他因子的影响。比如你发现低PE的股票收益好,但低PE的股票可能同时是小市值股票。到底是PE的作用,还是市值的作用?
这时候,Fama-MacBeth回归法就登场了。这个方法分两步走:
- 第一步(截面回归):在每个时间点t,用当期因子对下一期收益做截面回归。得到每个因子在每个时间点的系数。
- 第二步(时间序列平均):把所有时间点的系数取平均,做t检验,看系数是否显著不为零。
为什么这个方法好?因为它同时解决了两个问题:
- 截面回归可以加入多个因子,实现「控制变量」的效果
- 时间序列平均和t检验,给出了统计显著性,而不是拍脑袋说「这个因子有效」
# Python示例:Fama-MacBeth回归(简化版)
import statsmodels.api as sm
def fama_macbeth_regression(panel_data, factor_cols, return_col='ret_1m'):
"""
Fama-MacBeth两步法
panel_data: 面板数据,包含日期、股票、因子、未来收益
"""
dates = panel_data['date'].unique()
coef_list = []
# 第一步:每个时间点做截面回归
for date in dates:
cross_section = panel_data[panel_data['date'] == date].dropna()
if len(cross_section) < 30: # 样本太少就跳过
continue
X = sm.add_constant(cross_section[factor_cols])
y = cross_section[return_col]
model = sm.OLS(y, X).fit()
coef_list.append(model.params)
# 第二步:时间序列平均
coef_df = pd.DataFrame(coef_list)
results = {}
for col in coef_df.columns:
mean_coef = coef_df[col].mean()
t_stat = coef_df[col].mean() / (coef_df[col].std() / np.sqrt(len(coef_df)))
results[col] = {'mean': mean_coef, 't_stat': t_stat}
return results
4.4 因子值的标准化与中性化处理
因子构建的最后一步,也是最容易被忽视的一步:标准化和中性化。
4.4.1 标准化:让因子站在同一起跑线
不同因子的量纲不一样。PE是几十到几百,换手率是百分之几,市值是几十亿到几万亿。如果不做标准化,你没法比较哪个因子「更有效」。
最常用的标准化方法是Z-score:
# Z-score标准化
def zscore_standardize(factor_series):
return (factor_series - factor_series.mean()) / factor_series.std()
标准化之后,所有因子的均值都是0,标准差都是1。这样你就能直接比较回归系数的大小了。
4.4.2 中性化:剔除你不想要的「杂质」
中性化处理,是量化里最见功力的地方。说白了,就是剔除因子中你不想要的那部分信息。
最常见的两种中性化:
- 市值中性化:很多因子天然和市值相关。比如低波动因子,大市值股票波动本来就小。如果你不做市值中性化,你分不清收益是来自低波动还是大市值。
- 行业中性化:不同行业的因子分布差异很大。比如银行股的PE天然就低,科技股的PE天然就高。不做行业中性化,低PE因子可能只是在选行业,而不是在选股票。
# 市值中性化示例
def market_cap_neutralize(factor_series, log_market_cap):
"""
对因子做市值中性化
回归:factor ~ log_market_cap,取残差
"""
X = sm.add_constant(log_market_cap)
model = sm.OLS(factor_series, X).fit()
residual = model.resid
return residual
# 行业中性化示例
def industry_neutralize(factor_series, industry_dummies):
"""
对因子做行业中性化
回归:factor ~ industry_dummies,取残差
"""
X = sm.add_constant(industry_dummies)
model = sm.OLS(factor_series, X).fit()
residual = model.resid
return residual
4.5 本章知识体系总览
说了这么多,我画了一张图帮你理清思路。单因子构建的四个方法,其实是一个从简单到复杂、从粗糙到精细的递进关系。
这张图里,从左到右代表方法的复杂度递增。截面排序法最简单,但信息损失也最大。Fama-MacBeth回归最复杂,但能控制多个变量。标准化和中性化是最后的「清洗」步骤,任何因子在进入模型之前都应该过这一关。
- 初筛阶段:用截面排序法快速看因子方向
- 验证阶段:用分位数分组法做多空组合检验
- 精细建模:用Fama-MacBeth回归做多因子分析
- 最终清洗:标准化 + 市值/行业中性化
嗯,单因子构建这部分就聊到这儿。记住一句话:因子构建不是目的,找到真正能预测收益的信号才是。所有的方法和技巧,都是为了让信号更干净、更可靠。