跳到主要内容

树模型做横截面选股

直觉

上一节我们用单变量 Rank IC 判断特征有没有效。但真实选股要同时用很多特征,而且它们之间可能是非线性关系(比如「高动量 + 低波动」才好,单独看都不够)。决策树天生擅长捕捉这种分段、交互关系;把很多棵树组合起来——随机森林GBDT——就成了横截面选股的主力模型,原因是它们对异常值鲁棒、不用标准化、还能给出特征重要性

决策树 → 随机森林 → GBDT

单棵决策树通过逐次分裂把样本空间切成叶子,使每个叶子内标签的方差最小:

分裂准则=argminj,s[L(j,s)Var(yL)+R(j,s)Var(yR)]\text{分裂准则} = \arg\min_{j,s}\left[\,|L(j,s)|\,\text{Var}(y_L) + |R(j,s)|\,\text{Var}(y_R)\,\right]
  • 随机森林:训练 BB 棵树,每棵用有放回抽样(bagging)的数据 + 每次分裂只随机抽一部分特征 → 平均预测,降低方差
  • GBDT:每棵新树去拟合上一轮的残差 ri=yiy^i\,r_i = y_i - \hat{y}_i\, → 逐步逼近,降低偏差
「人话」解释:为什么树比线性回归更适合选股?

线性回归假设「特征越大、收益越大」是条直线;但市场里多是「适度才好」——估值太低可能有坑、动量太强可能见顶。树用阈值切分(「动量在 5%~20% 之间」),能拟合这种非线性与「特征组合」关系。而且树对极端值不敏感(不像 OLS 被一个异常点带偏),这对肥尾的收益数据很重要。

可运行案例:随机森林预测 + 多空回测

用上一节的特征面板,按时间切分(前 70% 训练、后 30% 测试,绝不打乱),训练随机森林预测下月横截面收益,再在测试集上做「预测最高 10 只 − 最低 10 只」的多空组合回测。

import quant
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestRegressor

prices = pd.read_csv('/data/sp500_universe_daily.csv', parse_dates=['date']).set_index('date')
meta = pd.read_csv('/data/sp500_metadata.csv').set_index('ticker').loc[prices.columns]
dret = np.log(prices / prices.shift(1)); monthly = prices.resample('ME').last()

mom = lambda n: np.log(monthly / monthly.shift(n)).stack()
feats = {'mom_1': mom(1), 'mom_3': mom(3), 'mom_6': mom(6), 'mom_12': mom(12),
       'vol_3': (dret.rolling(63).std() * np.sqrt(252)).resample('ME').last().stack()}
F = pd.DataFrame(feats).join(meta['quality'], on='ticker')
y = monthly.pct_change().shift(-1).stack()
data = F.join(y.rename('y')).dropna()
cols = list(F.columns)

# 时间序列切分(不打乱!): 前 70% 训练, 后 30% 测试
dates = data.index.get_level_values('date').unique().sort_values()
cut = dates[int(len(dates) * 0.7)]
is_m = data.index.get_level_values('date') <= cut
Xtr, ytr = data.loc[is_m, cols], data.loc[is_m, 'y']
Xte, yte = data.loc[~is_m, cols], data.loc[~is_m, 'y']

rf = RandomForestRegressor(n_estimators=200, max_depth=4, random_state=0).fit(Xtr, ytr)
pred = pd.Series(rf.predict(Xte), index=Xte.index)

# 测试集月度 Rank IC + ICIR
ic = pred.groupby('date').apply(lambda s: s.rank().corr(yte.loc[s.index].rank()))
print(f"OOS 月均 Rank IC = {ic.mean():.3f}   ICIR = {ic.mean()/ic.std():.2f}")
imp = pd.Series(rf.feature_importances_, index=cols).sort_values(ascending=False)
print("特征重要性:", imp.round(3).to_dict())

# 多空组合: 每月做预测前 10 名 - 后 10 名
fwd = monthly.pct_change().shift(-1)        # 已实现下月收益, date × ticker
oos_dates = pred.index.get_level_values('date').unique()
ls = []
for t in oos_dates:
  g = pred.xs(t, level='date')
  top, bot = g.nlargest(10).index, g.nsmallest(10).index
  ls.append(fwd.loc[t, top].mean() - fwd.loc[t, bot].mean())
ls = pd.Series(ls, index=oos_dates).dropna()
ls_eq = (1 + ls).cumprod()
print("\n多空组合指标:")
print(quant.performance_summary(ls_eq, ls, freq=12).round(3))

plt.figure(figsize=(9, 3.5))
plt.plot(ls_eq, color='darkgreen', lw=1.4); plt.title('RF 信号多空组合净值(OOS)')
plt.ylabel('净值'); plt.tight_layout(); plt.show()

动手改一改

RandomForestRegressor 换成 GradientBoostingRegressor(n_estimators=200, max_depth=2, learning_rate=0.05),对比两者的 OOS Rank IC 与多空夏普——GBDT 通常偏差更低但对过拟合更敏感。

小结

  • 决策树用阈值切分拟合非线性、交互效应,随机森林降方差、GBDT降偏差;
  • 选股本质是横截面排序问题,看 Rank IC / ICIR 比看 R2R^2 更贴近实战;
  • 务必按时间切分训练/测试——下一节会看到,打乱切分会带来灾难性的泄漏。