☰
随机森林多因子选股实战:从因子合成到回测避坑
2026/10/2 10:27:42 网站建设 项目流程

简介:这份资源面向量化投资与机器学习方向的学习者与研究者,提供一套基于随机森林与多因子模型的完整选股策略实现方案。内容围绕因子分析框架展开,涵盖单因子测试、因子有效性筛选、多重共线性诊断与因子组合优化,并系统比较了支持向量回归、LSTM、梯度提升决策树、随机森林及自适应增强等多种收益预测模型,最终在风险控制机制下取得约60%累计收益率、最大回撤控制在9%以内、风险调整后收益达0.9的实证结果。资源包共46个文件,包含15个Python脚本、10份PDF研报与说明文档、7张因子分类可视化图片及若干备份与配置文件,压缩包约19.92MB,代码模块经过测试验证,运行稳定。目前已有121人学习下载,适合希望深入理解多因子选股流程、复现机器学习预测模型并掌握因子评估方法的读者参考。

1. 从一份能跑通的量化选股代码说起:随机森林加多因子到底怎么落地

很多人第一次接触量化选股,都是从「多因子模型」这四个字开始的。Fama-French 三因子、Barra 风险模型、动量反转、估值成长,这些名词听着专业,真到写代码的时候却容易卡在第一步:因子算出来了,怎么组合?线性加权?等权打分?还是干脆扔给机器学习?我见过太多人把因子 IC 算得漂漂亮亮,一到样本外就崩,问题往往出在「因子合成」这一步用了拍脑袋的权重。这份资源给的思路很直接——用随机森林做非线性因子合成,把多因子选股从「人工配权」推进到「模型学习权重」。它适合两类人:一类是已经会算因子、但组合环节还在用等权或简单回归的从业者;另一类是学过随机森林、想找一个真实金融场景练手的算法工程师。整份代码包围绕 A 股日频数据展开,从因子预处理、标签构造、模型训练到回测评价,链路是完整的,不是那种只给一个model.fit()的玩具。

2. 因子工程与标签构造:随机森林吃进去的到底是什么

2.1 多因子池的选取与横截面标准化

随机森林对输入特征的尺度不敏感,但这不代表因子可以不做处理。金融数据里最常见的坑是量纲不统一和极端值污染。比如换手率因子和市值因子放一起,前者在 0 到 30 之间波动,后者可能是几百亿的数值,虽然树模型分裂时只看排序,但极端值会影响分裂点的选择,尤其是当样本里混入了刚上市的新股或者重组复牌的票。

常见做法是每个横截面(每个交易日)单独做去极值和标准化。去极值用 MAD 法,标准化用 z-score,这样处理后的因子在同一交易日内可比,跨日也相对稳定。代码里这一步封装成了一个独立的预处理函数,我把它拆出来给你看:

import numpy as np import pandas as pd def winsorize_mad(series, n=5): """MAD 去极值:中位数加减 n 倍 MAD""" median = series.median() mad = (series - median).abs().median() if mad == 0: return series upper = median + n * 1.4826 * mad lower = median - n * 1.4826 * mad return series.clip(lower, upper) def standardize_cross_section(df, factor_cols): """按交易日做横截面标准化""" df = df.copy() for col in factor_cols: df[col] = df.groupby('trade_date')[col].transform( lambda x: winsorize_mad(x) ) df[col] = df.groupby('trade_date')[col].transform( lambda x: (x - x.mean()) / x.std() if x.std() != 0 else 0 ) return df

逻辑说明:winsorize_mad里的 1.4826 是正态分布下 MAD 与标准差的一致估计系数,这个数不是随便写的,它让 MAD 在正态假设下能还原出标准差。standardize_cross_section按trade_date分组做 transform,保证每个交易日的因子分布独立处理,避免未来信息泄露。参数n=5是经验值,n 越小去极值越激进,一般 3 到 5 之间,因子分布厚尾严重时用 3。

2.2 标签构造:别让未来函数混进来

监督学习做选股,标签通常是未来一段时间的收益率。这里最容易翻车的地方是「未来函数」——用 T 日收盘后的因子去预测 T 日到 T+N 日的收益,看起来没问题,但如果你在 T 日收盘时因子还没更新(比如用了 T 日收盘价算的动量),实际上你是在用 T 日的信息预测 T 日开盘到 T+N 日开盘的收益,中间差了一个交易日。

资源里的做法是:因子用 T-1 日及之前的数据计算,标签用 T 日开盘买入、T+N 日开盘卖出的收益。这样因子和标签在时间上严格错开。标签构造代码大致是这样:

def build_label(df, forward_days=5): """构造未来 N 日开盘到开盘的收益率标签""" df = df.sort_values(['stock_code', 'trade_date']) df['next_open'] = df.groupby('stock_code')['open'].shift(-1) df['forward_open'] = df.groupby('stock_code')['open'].shift(-1 - forward_days) df['label'] = df['forward_open'] / df['next_open'] - 1 return df

参数forward_days控制预测周期,5 日对应周频调仓,20 日对应月频。周期越短,信噪比越低,随机森林越容易过拟合;周期越长,样本量越少,但信号更稳定。我一般会跑 5、10、20 三组对比,看 IC 的衰减速度再定。

2.3 因子筛选:不是越多越好

随机森林虽然能处理高维特征,但因子之间高度共线时,树的分裂会偏向那些取值连续、分裂点多的因子,导致重要性评估失真。资源里给了一个基于特征重要性的后向剔除流程:先全量因子训练一版,看重要性排序,把重要性低于阈值或者共线性过高的因子剔掉,再训练第二版。

这里有个细节:随机森林的feature_importances_是基于不纯度减少算的,对连续型因子有偏。更稳的做法是用 permutation importance,打乱某个因子的取值看模型性能掉多少。代码包里两种都给了,默认用 permutation,计算慢但更可靠。

3. 随机森林选股模型的训练与调参:从 sklearn 到样本外

3.1 为什么选随机森林而不是 XGBoost 或神经网络

这个问题我被问过很多次。XGBoost 在结构化数据上通常比随机森林强,神经网络在因子非线性交互上也有优势,但这份资源选随机森林有它的道理。第一,随机森林对超参数不敏感,n_estimators设 500 和 1000 差别不大,max_depth不限制也能跑,调参成本低。第二,随机森林的 bagging 机制天然抗过拟合,金融数据信噪比极低,XGBoost 的 boosting 容易把噪声也学进去。第三,随机森林可以输出 OOB 误差,相当于自带一个验证集,不用额外切分。

当然,随机森林的短板也明显:它不能外推,预测值永远在训练集标签的范围内。这意味着如果训练期是牛市,测试期是熊市,模型预测的收益率分布会整体偏高。资源里对此的处理是滚动训练——每 60 个交易日重新训练一次,用最近 2 到 3 年的数据,让模型跟上市场状态的变化。

3.2 训练流程与关键参数

训练脚本的核心逻辑是滚动窗口。假设回测从 2020 年初开始,第一个训练窗口用 2017 到 2019 的数据,预测 2020 年 1 月到 3 月的股票收益,然后窗口往后滚 60 天,用 2017 年 4 月到 2020 年 3 月的数据重新训练,预测下一个季度。这样每个预测点用的都是它之前的数据,没有未来信息。

from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import TimeSeriesSplit def train_rf(X_train, y_train, X_test): """训练随机森林并预测""" model = RandomForestRegressor( n_estimators=500, # 树的数量,500 后边际收益递减 max_depth=8, # 限制深度,防止单棵树记住噪声 min_samples_leaf=50, # 叶子节点最少样本,金融数据噪声大,设大一点 max_features=0.3, # 每次分裂随机选 30% 特征,降低共线性影响 oob_score=True, # 开启 OOB 评估 n_jobs=-1, random_state=42 ) model.fit(X_train, y_train) pred = model.predict(X_test) return model, pred

参数说明:max_depth=8是经验值,金融因子通常 10 到 20 个,深度 6 到 10 足够捕捉交互,再深就是过拟合。min_samples_leaf=50很关键,A 股每天 3000 多只票,一个叶子节点至少 50 个样本才能保证统计意义,设太小模型会记住个别股票的噪声。max_features=0.3比默认的sqrt或log2大,因为因子数量本来就不多,0.3 大概对应 5 到 8 个因子,既能降相关性又不至于太随机。

3.3 样本外预测与组合构建

模型输出的是每只股票的预测收益率,接下来要把它转成持仓。资源里用的是分组法:每个交易日按预测收益排序,买前 10% 的股票,等权持有,持有周期和标签的forward_days一致。这样做的好处是避免了预测值绝对大小受市场整体涨跌影响的问题,只利用排序信息。

def build_portfolio(pred_df, top_pct=0.1): """按预测收益分组构建持仓""" pred_df['rank'] = pred_df.groupby('trade_date')['pred'].rank(pct=True) pred_df['position'] = (pred_df['rank'] >= 1 - top_pct).astype(int) # 等权 pred_df['weight'] = pred_df.groupby('trade_date')['position'].transform( lambda x: x / x.sum() if x.sum() > 0 else 0 ) return pred_df

top_pct=0.1对应十分组的多头组。如果你要做多空对冲,可以同时取前 10% 做多、后 10% 做空,但 A 股融券成本高,资源里默认只做多头。

4. 回测评价与常见坑:夏普比率不是唯一指标

4.1 回测框架与评价指标

资源里的回测没有用现成的框架,而是手写了一个轻量级的向量化回测。这样做的好处是逻辑透明,每一步都能 debug。核心指标包括年化收益、年化波动、夏普比率、最大回撤、换手率和 IC 均值。

def backtest(portfolio_df, benchmark_ret=0.0): """向量化回测""" portfolio_df['ret'] = portfolio_df['weight'] * portfolio_df['next_ret'] daily_ret = portfolio_df.groupby('trade_date')['ret'].sum() cum_ret = (1 + daily_ret).cumprod() annual_ret = cum_ret.iloc[-1] ** (252 / len(daily_ret)) - 1 annual_vol = daily_ret.std() * np.sqrt(252) sharpe = (annual_ret - benchmark_ret) / annual_vol if annual_vol != 0 else 0 drawdown = (cum_ret / cum_ret.cummax() - 1).min() return { 'annual_return': annual_ret, 'annual_vol': annual_vol, 'sharpe': sharpe, 'max_drawdown': drawdown }

next_ret是持仓期收益,注意要和标签构造时的收益口径一致。夏普比率的分母是年化波动,分子是超额收益,这里benchmark_ret默认 0,实际使用时应该减去无风险利率或者基准指数收益。

4.2 避坑与常见问题排查

现象一:样本内 IC 很高,样本外 IC 接近 0 甚至为负。原因通常是因子预处理时用了全样本标准化,或者标签构造有未来函数。解决方法是把预处理和标签构造都改成滚动窗口,每个时间点只用历史数据。检查方法很简单:把因子和标签的日期对齐,看因子计算截止日和标签起始日之间有没有重叠。

现象二:回测收益很高,但换手率也高得离谱,扣费后收益归零。随机森林的预测值在相邻交易日可能波动很大,导致持仓频繁变动。解决方法是给预测值做平滑,比如用 5 日移动平均,或者设置调仓阈值——只有预测排名变化超过一定幅度才调仓。资源里默认加了 0.02 的换手缓冲。

现象三:模型在某个时间段突然失效,回撤急剧放大。这通常是市场风格切换导致的。随机森林不能外推,训练期的因子收益模式在测试期变了,模型就失灵。解决方法是缩短训练窗口,或者加入市场状态变量(如波动率、流动性指标)作为额外特征,让模型能感知环境变化。

现象四:特征重要性排序每次跑都不一样。随机森林本身有随机性,random_state固定后应该一致,但如果用了n_jobs=-1并行,某些版本的 sklearn 会有微小差异。另外,如果因子之间共线性严重,重要性会在相关因子之间随机分配。解决方法是先做因子聚类,每类只保留一个代表因子。

现象五:OOB 误差很低,但实际回测很差。OOB 误差是在训练样本上算的,如果训练样本有时间上的聚集性(比如牛市样本占多数),OOB 会偏乐观。不要完全依赖 OOB,一定要留出独立的样本外时间段做验证。

5. 进阶技巧:用 IC 加权和分层抽样提升稳健性

5.1 用 IC 加权替代等权持仓

等权持仓假设模型对每只股票的预测置信度相同,但实际上随机森林对不同样本的预测方差不一样。一个改进思路是用历史 IC 对预测值加权:计算每个因子在最近 N 期的 IC 均值,用 IC 的绝对值作为因子权重,再乘到随机森林的预测上。这样做的逻辑是,近期表现好的因子应该获得更高的信任度。

def ic_weighted_signal(pred_df, factor_df, ic_window=20): """用滚动 IC 加权预测信号""" # 计算每个因子的滚动 IC ic_series = factor_df.groupby('trade_date').apply( lambda x: x[factor_cols].corrwith(x['next_ret']) ) rolling_ic = ic_series.rolling(ic_window).mean() # 用 IC 均值加权因子暴露 weighted_factor = (factor_df[factor_cols] * rolling_ic.iloc[-1]).sum(axis=1) # 融合随机森林预测和 IC 加权信号 pred_df['final_signal'] = 0.7 * pred_df['pred'] + 0.3 * weighted_factor return pred_df

ic_window=20对应约一个月的交易日,太短 IC 不稳定,太长跟不上风格切换。融合权重 0.7 和 0.3 是经验值,可以通过样本外测试调整。

5.2 分层抽样解决样本不平衡

A 股不同市值的股票波动差异很大,小市值股票收益分布厚尾,大市值股票相对平稳。如果直接全样本训练,模型会偏向小市值股票,因为它们的收益极值多,对损失函数影响大。资源里给了一个分层抽样的方案:按市值分成 5 层,每层抽相同数量的样本训练。这样模型在每个市值层都能学到规律,不会偏袒某一类。

def stratified_sample(df, n_per_layer=500): """按市值分层抽样""" df['mktcap_layer'] = pd.qcut(df['mktcap'], 5, labels=False) sampled = df.groupby(['trade_date', 'mktcap_layer']).apply( lambda x: x.sample(min(len(x), n_per_layer), random_state=42) ).reset_index(drop=True) return sampled

n_per_layer=500是每层每期的抽样数,太小模型学不够,太大失去分层意义。这个技巧在市值风格切换剧烈的年份效果尤其明显。

5.3 一个我踩过的坑

早期我做随机森林选股,图省事用了全样本标准化,回测夏普 2.5,实盘一上就亏。后来逐行检查才发现,标准化时用了全样本的均值和方差,等于把未来信息泄露到了因子里面。从那以后我每次做因子预处理,都强制走一遍「按日分组 transform」的流程,哪怕代码写起来麻烦一点。还有一个习惯是,任何回测结果出来,先不看收益,先看换手率和 IC 衰减曲线,这两个指标正常了,收益才有参考价值。希望这些经验能帮你少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询