第四章:因子构建与计算
因子构建,说白了就是把你脑子里的投资逻辑,变成一串可以计算的数字。这一步做不好,后面再牛的模型也是白搭。我见过太多人花大把时间调模型,结果因子本身就有问题——嗯,那真是白费功夫。
4.1 因子定义与公式
先聊聊因子定义。一个因子本质上就是一个数学公式,它把原始数据映射成一个数值。比如最常见的市盈率因子:
PE = 收盘价 / 每股收益
就这么简单?对,但坑在后面。我曾在项目中遇到一个情况:某只股票的每股收益是负的,PE算出来是负数。这个负值放进模型里,直接把排序搞乱了。你想想看,负的PE和正的PE放在一起,谁高谁低?
所以定义因子时,我建议你考虑三点:
- 经济含义:这个因子想捕捉什么?价值?动量?还是质量?
- 计算稳定性:极端值怎么处理?缺失值怎么办?
- 可解释性:因子值的变化能不能讲出故事?
举个例子,我常用的一个动量因子公式:
Momentum = (P_t / P_{t-12}) - 1
其中P_t是当前价格,P_{t-12}是12个月前的价格。这个公式简单,但有个问题:如果股票在12个月内退市了怎么办?我曾经踩过这个坑,后来加了一个条件判断:如果数据不足12个月,直接设为NaN。
4.2 因子计算实现(Python/Pandas)
好了,理论讲完,上代码。我个人习惯用Pandas做因子计算,因为它对时间序列和截面数据的处理非常顺手。
先看一个基础例子:计算市盈率因子。
import pandas as pd
import numpy as np
# 假设df包含股票数据
df['PE'] = df['close'] / df['eps']
# 处理负值:我习惯把负PE设为NaN
df.loc[df['PE'] < 0, 'PE'] = np.nan
# 处理极端值:超过99%分位数的值截断
upper = df['PE'].quantile(0.99)
df['PE'] = df['PE'].clip(upper=upper)
这里有个细节:为什么要把负PE设为NaN?因为负的市盈率在经济含义上很难解释。你想想看,一个亏损的公司,它的市盈率是负的,这能代表价值吗?我个人认为不能。
再来看一个稍微复杂的因子——换手率因子:
# 计算20日平均换手率
df['turnover_20d'] = df['turnover'].rolling(window=20).mean()
# 计算换手率变化
df['turnover_change'] = df['turnover_20d'] / df['turnover_20d'].shift(20) - 1
这个因子捕捉的是换手率的趋势变化。我在项目中用它来识别资金流入流出的信号。不过要注意:换手率数据经常有异常值,比如新股上市第一天换手率特别高。我建议先做一次去极值处理。
4.3 因子标准化处理
因子算出来了,但不同因子的量纲不一样。PE可能是几十,换手率可能是百分之几。直接放一起比较?不行。所以需要标准化。
Z-score标准化
Z-score是最常用的方法。公式很简单:
Z = (X - μ) / σ
其中μ是均值,σ是标准差。实现起来也简单:
from scipy import stats
# Z-score标准化
df['PE_zscore'] = (df['PE'] - df['PE'].mean()) / df['PE'].std()
# 或者直接用scipy
df['PE_zscore'] = stats.zscore(df['PE'], nan_policy='omit')
但这里有个坑:Z-score假设数据是正态分布的。如果因子有厚尾分布,Z-score的效果会打折扣。我遇到过一种情况:某个因子的分布严重偏态,Z-score之后仍然有很多极端值。后来我改用分位数标准化。
分位数标准化
分位数标准化把数据映射到0到1之间:
# 分位数标准化
df['PE_rank'] = df['PE'].rank(pct=True)
这个方法的好处是稳健。不管原始分布长什么样,标准化后都是均匀分布。但代价是丢失了数值之间的相对距离信息。比如两个股票的PE分别是10和100,分位数标准化后可能变成0.3和0.9,但实际差距是10倍。
我个人习惯:如果因子用于排序选股,用分位数标准化;如果因子用于回归模型,用Z-score。
4.4 因子中性化处理
标准化之后,因子之间可以比较了。但还有一个问题:因子值可能受到其他变量的影响。比如市值大的股票,PE通常偏低。如果不做中性化处理,你选出来的股票可能全是大盘股。
中性化的核心思想:剔除其他变量的影响,只保留因子本身的独特信息。
市值中性化
最常用的方法是回归法。把因子值对市值做回归,取残差作为中性化后的因子:
import statsmodels.api as sm
# 市值中性化
X = sm.add_constant(df['market_cap'])
y = df['PE']
model = sm.OLS(y, X, missing='drop').fit()
df['PE_neutral'] = model.resid
这个残差就是剔除市值影响后的因子值。我曾在项目中用这个方法处理动量因子,效果立竿见影——选出来的股票不再偏向小盘股了。
行业中性化
行业中性化更简单:在每个行业内对因子做标准化。
# 行业中性化:每个行业内做Z-score
df['PE_industry_neutral'] = df.groupby('industry')['PE'].transform(
lambda x: (x - x.mean()) / x.std()
)
这样做的好处是:你选出来的股票在每个行业内都是相对优秀的。不会出现全仓某个行业的情况。
4.5 因子正交化处理
最后一个话题:正交化。当你有多个因子时,它们之间可能存在相关性。比如动量因子和换手率因子,高动量的股票通常换手率也高。如果不做正交化,模型会重复计算这部分信息。
正交化的目标:把相关因子变成不相关的因子。常用的方法是施密特正交化(Gram-Schmidt)。
实现起来也不复杂:
import numpy as np
def gram_schmidt(factors):
"""
施密特正交化
factors: 因子矩阵,每列是一个因子
"""
n = factors.shape[1]
ortho = np.zeros_like(factors)
for i in range(n):
ortho[:, i] = factors[:, i]
for j in range(i):
# 减去在前一个因子上的投影
proj = np.dot(ortho[:, j], factors[:, i]) / np.dot(ortho[:, j], ortho[:, j])
ortho[:, i] -= proj * ortho[:, j]
return ortho
但说实话,我在实际项目中很少用施密特正交化。为什么?因为它对因子的顺序敏感。先正交化因子A再正交化因子B,和反过来做,结果不一样。这让人很不舒服。
我更推荐用PCA(主成分分析)来做正交化:
from sklearn.decomposition import PCA
# PCA正交化
pca = PCA(n_components=len(factor_columns))
ortho_factors = pca.fit_transform(df[factor_columns])
# 把结果转回DataFrame
ortho_df = pd.DataFrame(ortho_factors, columns=[f'ortho_{i}' for i in range(len(factor_columns))])
PCA的好处是:它找到的是方差最大的方向,而且各个主成分之间天然正交。不过代价是解释性变差——你很难说清楚第一个主成分代表什么。
小结
这一章我们聊了因子构建的完整流程:从定义公式,到Python实现,再到标准化、中性化、正交化。每一步都有坑,我也都踩过。记住一句话:因子质量决定策略上限,模型只是把因子价值兑现出来。
下一章我们会聊因子测试与评估,到时候你会看到,一个看似完美的因子,在回测中可能一败涂地。嗯,那才是真正考验人的地方。