☰
随机森林多因子模型量化选股实战:从因子工程到回测避坑指南
2026/10/10 2:51:29 网站建设 项目流程

简介:一份完整的量化选股策略实现方案,围绕随机森林与多因子模型展开,从单因子测试、因子有效性评估与共线性诊断,到特征工程、多模型对比与回测验证,链路清晰,适合具备Python基础的量化研究者或策略开发人员参考。压缩包共46个文件、大小约19.92MB,主要包含15个Python脚本、10份金融工程/人工智能选股方向的PDF研报、7张可视化结果图、1个Notebook,以及备份与说明文档等,可对应因子分析、模型训练、结果输出和策略解读等环节。资源目前已有121人学习下载。方案系统性比较了SVR、LSTM、GBDT、随机森林与Adaboost等模型,最终采用随机森林完成预测,在风险机制控制下累计收益约60%、最大回撤控制在9%以内,风险调整收益达0.9;代码附有运行批处理、README与备份文件,便于复现实验并深入理解多因子选股的完整流程。

1. 随机森林与多因子模型:量化选股从想法到可跑通的起点

拿随机森林做量化选股,是很多个人研究者和初级量化工程师绕不开的一站:多因子模型负责提供候选特征,随机森林负责把因子之间的非线性关系抓出来,最终输出一个可排序的预测分。这个组合最吸引人的地方在于,它不像深度学习那样需要海量数据和昂贵算力,也能显著改善传统线性打分模型对因子交互作用“看不见”的问题。我最初用这个方案跑通第一版月度调仓策略时,最直观的感受是:代码量不大,但把数据清洗、标签构造、训练切分和回测对齐这几件事做对,比调参重要得多。本文就按这个落地路径展开,适合会 Python、有行情数据但还没跑通完整选股策略的人。

2. 多因子特征体系搭建:选因子、处理数据、给模型喂干净样本

随机森林吃得下脏数据,但吃不下“逻辑混乱”的数据。很多教程一上来就调模型参数,实际上选股策略的收益天花板在因子层面就定死了。这一章先把因子体系从零搭起来,再做预处理和信息系数检验,最后才轮到模型。

2.1 因子池怎么搭:四类主流因子与相关性取舍

多因子模型的核心假设是:股票截面收益可以被一组共同因子解释。常见做法是覆盖估值、成长、质量、动量与波动四类因子,每类取几个有代表性的,而不是贪多。我一般按下面的表来选初始池:

| 因子类别 | 代表因子 | 计算方式 | 理论上与未来收益的关系 | | 估值 | PE、PB、PS | 市值 / 净利润、市值 / 净资产、市值 / 营收 | 低估值往往有修复空间 | | 成长 | 营收同比增速、净利润同比增速 | 最新报告期与去年同期对比 | 高增长驱动盈利预期上修 | | 质量 | ROE、毛利率、经营现金流 / 营收 | 净利润 / 净资产、毛利 / 营收 | 盈利能力强的公司更抗风险 | | 动量与波动 | 过去20日收益、过去60日年化波动率、换手率 | 收益直接计算,波动率用日收益标准差年化 | 动量延续与反转并存,波动率低的更稳 |

这里最关键的一条经验是:同一类别内部的因子不要全塞。例如PE、PB虽然含义不同,但相关性经常在0.7以上,随机森林虽然不像线性回归那么怕多重共线性,但冗余特征会分散特征重要性,让模型学到的信号变模糊。我一般每类选2到3个,总量控制在12到20个。因子太多时,后续做特征筛选的成本也会明显上升。

另一个容易忽略的点是因子方向。比如估值因子,直接用PE可能因负值或极端值产生噪声,常见做法是用PE的倒数(EP,盈利收益率)参与计算,这样数值单调且更稳定。动量因子也建议区分短期(5日、20日)和中期(60日),因为A股里短期反转比中长期动量更常见,混在一起会互相干扰。

因子池定好后,不要急着进模型。先跑一遍数据对齐,确认每只股票在每个调仓日都能取到因子值,缺数据的股票要么用行业均值填充,要么直接剔除。填充策略我倾向于直接剔除,因为选股本身就是在全市场里挑,没必要为缺失数据引入额外噪声。

2.2 因子预处理的三个固定动作:去极值、标准化、中性化

原始因子值直接喂给随机森林会带来两个问题:极端值会在决策树分裂时形成虚假节点,量纲不一致会让特征重要性出现偏移。虽然树模型对量纲不敏感,但去极值和标准化仍然要做,原因不是模型需要,而是为了控制单只股票对分裂点的影响。

import numpy as np import pandas as pd def winsorize_series(s, method="mad", n=5): # 去极值:MAD 法,n 为倍数阈值 if method == "mad": median = s.median() mad = (s - median).abs().median() * 1.4826 upper = median + n * mad lower = median - n * mad else: # 分位数法,上下限取 1% 与 99% upper = s.quantile(0.99) lower = s.quantile(0.01) return s.clip(lower, upper) def standardize_series(s): # 标准化:Z-Score,结果近似标准正态分布 return (s - s.mean()) / s.std() # 示例:对单个横截面日期的 PE 因子做处理 factor_data = pd.Series([20.0, 25.0, 15.0, 300.0, -50.0, 18.0, 22.0]) cleaned = winsorize_series(factor_data, method="mad", n=5) standardized = standardize_series(cleaned)

这段代码里,winsorize_series用 MAD(绝对中位差)替代标准差,因为标准差本身会被极端值拉大,MAD 更稳健。1.4826 是把 MAD 调整到与标准差同尺度的常数。n=5表示超过中位数5倍MAD的值会被截断,对A股日截面数据够用。分位数法更直接粗暴,适合因子分布特别偏的时候,比如换手率因子。标准化这里用的 Z-Score,注意一定要按“同一调仓日”的截面做,不能把历史所有值混在一起算,否则混入了未来信息。

中性化这一步很多人会跳掉,但对选股效果影响很大。常见做法是做行业中性化和市值中性化:把因子值对行业哑变量和市值对数做线性回归,取残差作为新因子值。为什么要这样做?因为模型很容易学会“买小市值”“买某个行业”这类捷径,而不是真正的因子信号,回测好看但实盘换手和冲击成本会让你很难受。

import statsmodels.api as sm def neutralize(factor, industry_dummies, log_mktcap): # 因子对行业与市值做回归,残差即为中性化后的因子 X = pd.concat([industry_dummies, log_mktcap], axis=1) X = sm.add_constant(X) model = sm.OLS(factor, X).fit() return model.resid

中性化代码逻辑很直接:把行业哑变量和市值对数拼成自变量矩阵,对因子做最小二乘回归,残差代表剔除了行业和市值影响后的纯因子信号。statsmodels这里只用来出残差,不关心回归系数。注意回归前检查行业哑变量是否有全零列,有些行业在当天没有样本,会直接导致回归矩阵病态。

2.3 用信息系数速检因子:别把无效特征送进随机森林

因子池建好并清洗后,需要先做一次“体检”。最常用的指标是信息系数(IC),即每个调仓日因子值与下期收益的截面Spearman相关系数。IC均值衡量因子整体预测能力,IC标准差衡量稳定性,IR(IC均值除以标准差)则用来横向对比因子优劣。

from scipy.stats import spearmanr def calc_ic(factor_values, forward_returns): # 每个调仓日算一个截面 IC,这里 factor_values 与 forward_returns 是同一天同一批股票 ic, _ = spearmanr(factor_values, forward_returns) return ic # 假设 factor_panel 是 MultiIndex [date, stock] 的因子值,return_panel 是下期收益 ic_series = [] for dt in factor_panel.index.get_level_values(0).unique(): fac = factor_panel.xs(dt, level=0)["factor_value"] ret = return_panel.xs(dt, level=0)["forward_return"] ic_series.append(calc_ic(fac, ret)) ic_mean = np.mean(ic_series) ic_ir = ic_mean / np.std(ic_series)

这段代码按调仓日循环,计算每个日期的截面IC,最后得到IC均值和IR。经验阈值:IC均值绝对值在0.03以上、IR在0.3以上,这个因子才值得进入模型。如果IC均值接近0,很可能因子本身无效或方向定义反了,送进随机森林只会增加噪声。我见过不少人跳过这步直接训练,结果特征重要性前几名的因子在样本外完全失效,根源就是入模前没有做IC筛选。

还要注意一个细节:IC计算用的是Spearman秩相关,不是Pearson线性相关,因为秩相关能捕捉单调关系而非线性关系。这恰好又和随机森林的决策树机制对上了——树模型本质是找特征空间上的分段一致关系,用秩相关的标准去初筛因子,逻辑上更自洽。

3. 随机森林在选股里的落地:从原理到训练配置

因子准备就绪后,核心问题变成:随机森林在选股里扮演什么角色?它和传统打分模型有什么本质区别?这一章先讲清原理,再给出标签构造、训练配置和特征重要性输出的完整代码。

3.1 为什么是随机森林而不是线性模型

传统多因子选股有两个主流路线:打分法(加权求和后排序)和线性回归法(以未来收益为因变量拟合因子权重)。这两个的局限在于,它们默认因子与未来收益的关系是线性的、可加的。现实是:市盈率因子在成长行业和周期行业中的含义完全不同,动量因子在牛市和熊市里的预测方向可能反转,这些交互效应线性模型很难表达。

随机森林的优势恰好在这里:决策树天然支持特征间的非线性交互,每棵树在不同分支上用不同因子做分割,等价于自动发现“在某个行业条件下,某个因子更有效”这样的规则。另外,随机森林对异常值和缺失值不敏感,输出特征重要性可以直接回答“模型到底靠什么赚钱”这个合规问题。

对比其他非线性模型,XGBoost和LightGBM通常能在精度上超过随机森林,但更容易过拟合,调参成本高出一大截。对于个人研究和中小型团队的第一版策略,随机森林是性价比最高的起点。

随机森林在选股里有一个常被忽略的细节:它适合排序,不适合做精确收益预测。决策树的输出是叶节点均值,本质上是分段常数,预测值天然被离散化,但排序的稳定性足够用于选股。换句话说,不要期望随机森林预测“这只股票下月涨5%”,而要期望“这只股票的预测分数进入全市场前30,并且这个排名有区分度”。

3.2 标签构造与样本切分:预测排序而非预测收益

标签设计直接决定策略目标。最常见的做法是把下期收益分成五层,将最上层20%标记为1,其余为0,模型变成分类器。另一种做法是直接用下期收益做回归标签,最后输出预测值排序。我推荐后者,原因有两个:分类标签会丢失层内差异,且20%分界线附近样本的噪声会被放大;回归标签加权排序后,更贴近实际选股的“买入预期收益最高的一批”这一目标。

# 假设 dt 为调仓日,close 为复权收盘价,持有期 20 个交易日 def build_label(df_close, horizon=20): # forward_return: 未来 horizon 日收益 df_fwd = df_close.shift(-horizon) / df_close - 1.0 return df_fwd # 示例:df 以 [date, stock] 为索引,含有因子列和未来收益列 df["forward_ret"] = df.groupby("stock")["close"].transform( lambda x: x.shift(-20) / x - 1.0 )

这段代码用groupby("stock")保证每只股票单独计算未来收益,shift(-20)表示取20个交易日后收盘价与现在的比。注意这里的关键坑:必须在股票内shift,不能在整体DataFrame上shift,否则会把前一只股票的未来收益错配到后一只股票上。

样本切分要强调“滚动窗口”而不是随机打散。选股数据是强时间序列,如果随机抽样训练集和验证集,训练时会用到未来数据,模型在样本外表现会被严重高估。我通常按时间切分:训练集用过去60个月(约1250个交易日),验证集用接下来6个月,然后每月滚动。

train_dates = df.index.get_level_values("date") < "2023-01-01" valid_dates = (df.index.get_level_values("date") >= "2023-01-01") & \ (df.index.get_level_values("date") < "2023-07-01") X_train = df.loc[train_dates, feature_cols] y_train = df.loc[train_dates, "forward_ret"] X_valid = df.loc[valid_dates, feature_cols] y_valid = df.loc[valid_dates, "forward_ret"]

这样切分后,训练集和验证集完全无时间重叠,模型在验证集上的表现才接近实盘预期。如果数据量足够,还可以把训练集长度往上加到120个月,但要注意市场风格漂移,太老的数据可能和当前市场环境脱节,这个矛盾到第6章再展开。

3.3 训练配置与特征重要性输出:参数设到哪才不过拟合

随机森林的超参数大多是经验值,但几个关键参数的设定逻辑是固定的。选股场景下,我一般这样设:

| 参数 | 推荐值 | 设定理由 | | n_estimators | 300 | 足够收敛,继续加树收益很小、耗时线性增长 | | max_depth | 6 到 8 | 限制单棵树复杂度,强制捕捉中低阶交互 | | min_samples_leaf | 50 到 100 | 防止叶节点过细,避免单只股票主导一条规则 | | max_features | sqrt | 每棵树的特征抽样数,增加树间多样性 | | oob_score | True | 用袋外样本做内部评估,免费得到泛化估计 | | n_jobs | -1 | 利用全部CPU核心,批量训练时省时间 |

max_depth和min_samples_leaf是防过拟合最核心的两个参数。选股数据噪声极高,交易收益里真正可以被因子解释的部分可能不到5%,树一旦长得太深就会去拟合噪声。min_samples_leaf=50意味着一个叶节点至少要包含50个样本才被允许产生,这等于给模型加了一个平滑项。

from sklearn.ensemble import RandomForestRegressor from sklearn.inspection import permutation_importance model = RandomForestRegressor( n_estimators=300, max_depth=7, min_samples_leaf=60, max_features="sqrt", oob_score=True, n_jobs=-1, random_state=42, ) model.fit(X_train, y_train) print("OOB Score:", model.oob_score_) importance_df = pd.DataFrame({ "feature": feature_cols, "importance": model.feature_importances_, }).sort_values("importance", ascending=False) print(importance_df.head(10))

这段代码完成随机森林训练、OOB评估和特征重要性输出三步。oob_score_对于回归模型输出的是R平方,在选股场景下通常不高(0.02到0.1都正常),因为这个预测目标本身噪声极大,不要追求高R方,而是重点看特征重要性的排布是否合理。如果重要性第一名的特征贡献超过0.3,要警惕模型过度依赖单一信号,这通常意味着因子池不够分散或中性化没做干净。

有个细节值得说:random_state=42固定下来,策略结果才可以复现。但随机森林的随机性不仅来自数据抽样,还来自特征抽选,所以即使固定random_state,不同月份滚动训练出的特征重要性也会有波动,这是正常的。如果波动过大,可以适当调大min_samples_leaf和max_features,让模型更保守。

训练完成后,不要急着拿去选股,先看一眼特征重要性的前几名是否与第2章的IC检验结果一致。如果IC表现很差的因子排到重要性前列,说明预处理环节出了问题或存在因子泄漏,这时回头查数据比继续调参更有价值。

4. 策略实现全流程:用随机森林打分并完成月度调仓回测

模型训练好只是中间态,策略落地需要把预测转成一篮子股票和一条净值曲线。这一章是全部代码最密集的部分,重点在数据对齐和回测主循环的正确性。

4.1 数据对齐与未来函数:回测前先钉死时间线

量化回测里最隐蔽的坑是未来函数。具体到随机森林选股,有两个高发位置:一是因子值用了调仓日当天收盘后才知道的数据,却在当天开盘执行买入;二是持有期收益计算时包含了买入当天的收益,实际买入发生在T+1日。

我的做法是把时间线钉死为三段:T日收盘后计算因子和模型预测,T+1日开盘按预测排序买入,T+20日(月度调仓)收盘卖出。所有因子值用T日及之前的数据计算,持有期收益从T+1日开盘价算到T+20日收盘价。

# 复权价格处理 df["ret"] = df.groupby("stock")["close"].pct_change() # 调仓日预测用 T 日收盘可得的因子 predict_dates = pd.date_range(start="2023-01-01", end="2024-01-01", freq="ME") # T+1 开盘买入价,T+20 收盘卖出价 df["buy_price"] = df.groupby("stock")["open"].shift(-1) df["sell_price"] = df.groupby("stock")["close"].shift(-20) df["hold_return"] = df["sell_price"] / df["buy_price"] - 1.0

这里的关键是shift(-1)和shift(-20)都在股票内完成。buy_price取T+1日开盘价,sell_price取T+20日收盘价,hold_return是实际可实现的收益。如果用T日收盘价计算买入,回测收益会明显虚高,因为收盘到次日开盘之间的隔夜跳空收益本不属于策略。不要小看这一点,A股隔夜收益平均为正,算进去会让年化虚增几个百分点。

4.2 月度选股回测主循环:从模型打分到组合收益

回测主循环是策略的核心骨架。常见做法是:每个调仓日,用截至该日可用的历史数据训练(或复用已训练模型),然后对全市场股票打分,取分数最高的30只,等权持有到下一个调仓日。

def run_backtest(df, feature_cols, top_n=30, rebalance_days=None): # df: [date, stock] 索引,已含因子、预测标签前置信息 # rebalance_days: 调仓日列表 portfolio_value = 1.0 nav_curve = [] holdings = [] for i, dt in enumerate(rebalance_days): # 用之前 60 个月训练,这里为了演示直接加载预训练模型 train_df = df[df.index.get_level_values("date") < dt] train_df = train_df[train_df.index.get_level_values("date") >= dt - pd.DateOffset(months=60)] m = RandomForestRegressor( n_estimators=300, max_depth=7, min_samples_leaf=60, n_jobs=-1, random_state=42, ) m.fit(train_df[feature_cols], train_df["forward_ret"]) # 当期打分 cur_df = df.xs(dt, level="date") cur_df = cur_df.dropna(subset=feature_cols) cur_df["score"] = m.predict(cur_df[feature_cols]) # 选 top_n 并平均收益 selected = cur_df.nlargest(top_n, "score") nxt_dt = rebalance_days[i + 1] if i + 1 < len(rebalance_days) else None if nxt_dt is not None: next_df = df.xs(nxt_dt, level="date") selected = selected.join( next_df["hold_return"], how="left" ) mean_ret = selected["hold_return"].mean() portfolio_value *= 1.0 + mean_ret nav_curve.append((dt, portfolio_value, selected.index.tolist())) return nav_curve

这个主循环的逻辑是“逐月滚动训练+逐月预测”,计算成本较高,但贴合实盘。实际使用时可以做两个优化:一是每3个月才重新训练一次,中间调仓日直接复用模型预测,节省大量时间;二是训练数据用截止到调仓日的数据,避免泄漏。nlargest(top_n, "score")是选股操作的核心,这里取的是回归预测值最大的前30只。

注意一个细节:预测时cur_df要先dropna(subset=feature_cols),否则SKLearn会直接报错。如果dropna后剩下不足top_n只股票,说明因子覆盖率不够,这时要么放宽缺失容忍度,要么用行业均值填充后再预测。

4.3 绩效指标与结果解读:年化、夏普、回撤怎么看

有了净值曲线,下一步是计算常用的绩效指标,用来判断策略值不值得继续优化。

def calc_performance(nav_curve): nav_df = pd.DataFrame(nav_curve, columns=["date", "nav", "holdings"]) nav_df["ret"] = nav_df["nav"].pct_change() total_return = nav_df["nav"].iloc[-1] / nav_df["nav"].iloc[0] - 1.0 years = len(nav_df) / 12.0 annual_return = (1.0 + total_return) ** (1.0 / years) - 1.0 sharpe = nav_df["ret"].mean() / nav_df["ret"].std() * np.sqrt(12) max_drawdown = (nav_df["nav"] / nav_df["nav"].cummax() - 1.0).min() return { "annual_return": annual_return, "sharpe": sharpe, "max_drawdown": max_drawdown, "total_return": total_return, }

夏普比这里乘的是根号12,因为调仓频率是月度,月度收益的标准差需要年化。最大回撤用cummax()逐日计算,这是标准做法。对月度调仓的随机森林策略,我一般看三个底线:年化收益跑赢中证500全收益指数、夏普大于0.8、最大回撤小于指数的1.2倍。如果跌幅比例异常高,要优先回去查股票池里是否混入了ST、次新股或停牌股,这类标的会拖垮组合流动性和净值平滑度。

回测结果还应该拆开看超额收益的分布,比如按年份分组统计,看策略是稳定跑赢还是靠某一年爆发。随机森林模型如果只在特定行情(比如小盘成长风格)下有效,大概率是因子池里风格因子权重太高,这在第5章会详细说。

5. 量化选股常见问题避坑:四类翻车现场与修复方案

这个方案的坑几乎都在数据层面,模型反而很少出问题。以下四条是我自己踩过、也帮别人排查过的典型事故,按“现象到原因到解决”的顺序写,方便对照。

5.1 回测收益高得离谱:未来函数在背后作祟

现象:回测年化收益超过50%,最大回撤不到10%,净值曲线平滑得像教科书,但实盘或模拟盘完全对不上,策略一变即亏损。

原因:最典型的是标签或特征里混入了未来信息。常见有三种:一是用T日收盘价计算未来收益时,未来收益区间包含了T日当天的收益,但买入发生在T+1,等于白拿了隔夜收益;二是因子值里用了“未来N日平均成交量”这类需要未来数据才能算出的量;三是训练集切分时没有严格按时间点,导致训练样本里包含了验证期的数据,模型在验证期等价于“开卷考试”。

解决:按第4.1节的时间线重写对齐逻辑,买入价使用T+1日开盘价,卖出价使用T+20日收盘价;写一个自检函数,把特征矩阵的每一列都做一遍“截至T日可得性”审查。自检方法很朴素:对每个特征,手动检查该特征在T日收盘后能否直接拿到,拿不到的一律视为未来函数并重写。

5.2 组合只赢在“还活着”的股票上:幸存者偏差排查

现象:回测里组合收益持续稳定,跑赢指数很多,但把股票池换成“当日在市”的股票后,收益消失甚至变负。

原因:股票池用的是当前时点存续的股票,没有包含历史退市股。这些退市股大多在下跌过程中持续走弱,模型很可能因为低估值或高动量选中它们,但实际上一路亏损直到退市。因为退市股被从历史数据里删掉了,回测里自然看不到这部分亏损,收益被系统性高估。

解决:构建股票池时必须用“历史当时”的上市状态。具体做法是,调仓日T只纳入“截至T日已经上市满60个交易日且未被ST、未停牌”的股票,并在后续持有期内即使股票退市也要保留权重按退市价清算。如果你拿到的数据源本身就是当前存续股票快照,建议换数据源或至少对退市风险做一次敏感性测试。

5.3 随机森林过拟合:参数没问题不代表策略没问题

现象:验证集OOB分数尚可,但滚动窗口外推后预测值分布收敛到均值附近,选出的股票和直接按动量因子排序差别不大,策略收益快速衰减。

原因:随机森林在截面数据上的过拟合不同于图像或文本场景。因子数量多、样本量有限、市场结构随时间漂移,模型很容易学到“历史噪声中的统计规律”。常见诱因是max_depth过大或min_samples_leaf过小,叶节点包含的股票太少,一条规则可能只对三五只股票有效,样本外必然失效。

解决:把min_samples_leaf提高到60到100,max_depth压到7以内;特征数量控制在15个左右,并对每个特征做第2.3节的IC筛选。另一个有效手段是“冷启动测试”:拿2018年之前的60个月数据训练,测试2019年上半年的表现,如果收益分布显著异于训练期,说明过拟合程度高,需要继续增强正则化。

5.4 高收益集中在单一行业:因子暴露与中性化漏网

现象:组合年度收益很好,但拆分持仓发现超过70%的仓位集中在白酒、新能源或某个景气行业,行业轮动一结束策略立刻变脸。

原因:因子池里没有包含行业哑变量做中性化,随机森林自动学到了“过去几年某些行业的股票收益更好”这条规则。树模型不会区分“行业动量因子”和“行业偏爱”,它只知道按历史收益最优方式切分,结果就是把高权重压在少数行业上。这类策略本质不是在选股,是在赌行业轮动,而且赌得很笨。

解决:在第2.2节做中性化时,把行业哑变量和市值对数同时纳入;更直接的办法是把行业代码也作为特征喂给模型,让模型学习行业条件概率而不是硬偏好。回测时还要按行业分组统计持仓比例,设定单一行业占比不超过30%的组合约束。如果不加约束,再好的模型也白搭。

6. 特征重要性的滚动稳定性:量化选股策略能否外推的关键验证

模型上线前,我会额外跑一个很少人做的验证:检查特征重要性在滚动窗口之间的稳定性。方法很简单,把样本按时间切成多个窗口,每个窗口单独训练模型并输出特征重要性排名,然后计算相邻窗口排名的Spearman相关系数。

from scipy.stats import spearmanr def importance_stability(df, feature_cols, window_months=36, step_months=6): dates = sorted(df.index.get_level_values("date").unique()) cutoffs = pd.date_range(start=dates[0], end=dates[-1], freq=f"{step_months}ME") rank_list = [] for dt in cutoffs: train_df = df[df.index.get_level_values("date") < dt] train_df = train_df[train_df.index.get_level_values("date") >= dt - pd.DateOffset(months=window_months)] if len(train_df) < 1000: continue m = RandomForestRegressor(n_estimators=300, max_depth=7, min_samples_leaf=60, random_state=42) m.fit(train_df[feature_cols], train_df["forward_ret"]) imp = pd.Series(m.feature_importances_, index=feature_cols) rank_list.append(imp.rank(ascending=False)) rho_list = [] for i in range(len(rank_list) - 1): rho, _ = spearmanr(rank_list[i], rank_list[i + 1]) rho_list.append(rho) return np.mean(rho_list), rho_list

这个函数返回相邻窗口的特征重要性排名相关系数均值。我自己的经验值是:均值低于0.6说明因子池不稳定,模型学到的大概率是短期噪声;高于0.75则说明因子与收益的关系在中周期上相对稳定,策略外推的可信度比较高。如果相关系数很低,不要急着调参,回头查因子池是不是选入了太多短期反转类因子——这类因子在A股经常出现方向漂移,会直接把模型的“注意力”带偏。

还有一种更便宜的验证方式:每个滚动窗口结束后,记录最新窗口下预测分数前30只股票与上期前30只的重合度。重合度在40%到70%之间比较健康,太高说明换手率低、策略钝化;太低说明模型不稳定,交易成本会吃掉大部分超额收益。重合度数据可以直接从第4.2节回测循环里顺手统计,成本为零。

做完这两项验证后,我还会做一次“去掉最高重要性前两个特征”的破坏性测试:如果策略收益显著下降,说明信息确实集中在这两个特征上;如果收益几乎没有变化,说明这两个特征是冗余信号,留着只会增加过拟合风险。这个方法对有解释性要求的场景尤其有用,能直接回答风控问的“模型靠什么赚钱”。

最后说一个我自己的教训。第一版策略上线前,特征重要性排序显示市值因子排第一,但我当时没有深究原因,结果实盘跑了一个月,组合在小市值股票的拥挤交易里反复被收割。后来才反应过来,市值因子排名靠前就是因为中性化没做干净,模型实际上在赌风格,而不是选股。从那以后,我给自己定了一条规矩:特征重要性的第一名如果是不该出现的风格因子(市值、行业、Beta),就先回去修数据,而不是急着加更多特征。随机森林这个模型很宽容,但数据层面的错它会原样放大给你看,一眼都不放过。希望这些经验能帮你少走几步弯路。

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

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

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

立即咨询