第四章:因子构建与计算

因子构建,说白了就是把你脑子里的投资逻辑,变成一串可以计算的数字。这一步做不好,后面再牛的模型也是白搭。我见过太多人花大把时间调模型,结果因子本身就有问题——嗯,那真是白费功夫。

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的好处是:它找到的是方差最大的方向,而且各个主成分之间天然正交。不过代价是解释性变差——你很难说清楚第一个主成分代表什么。

我的建议:如果因子数量少(3-5个),用施密特正交化,注意调整因子顺序。如果因子数量多(10个以上),用PCA。如果因子之间相关性本来就不高,其实可以不做正交化——别为了做而做。

小结

这一章我们聊了因子构建的完整流程:从定义公式,到Python实现,再到标准化、中性化、正交化。每一步都有坑,我也都踩过。记住一句话:因子质量决定策略上限,模型只是把因子价值兑现出来。

下一章我们会聊因子测试与评估,到时候你会看到,一个看似完美的因子,在回测中可能一败涂地。嗯,那才是真正考验人的地方。


专注资料整理