随机森林+多因子选股:从因子构建到回测的量化策略实战
发布时间:2026/10/2 15:28:05 作者:尧图编辑部 阅读量:1,286

简介这份资源面向量化投资初学者与机器学习爱好者提供一套基于随机森林与多因子模型的完整选股策略实现方案帮助读者理解从因子筛选到收益预测的全流程。压缩包共46个文件约19.92MB包含15个Python脚本、10份PDF研报、7张因子分类图及若干备份与配置文件覆盖单因子测试、多因子线性模型、SVM、LSTM、GBDT、随机森林、AdaBoost等算法实现并附有数据探索笔记与运行脚本。已有121人学习下载。读者可获取系统化的因子分析框架、特征工程与标签构建方法、多模型对比回测代码以及风险控制下约60%累计收益率、最大回撤9%以内的实证结果适合作为学术研究或策略复现的参考模板。1. 随机森林遇上多因子一套能跑赢基准的选股框架长什么样很多人做量化选股第一步就卡在“因子怎么组合”上。线性回归加权吧遇到因子共线性直接翻车人工拍脑袋定权重吧回测好看实盘拉胯。我最早用等权打分2019 年那波小盘股行情里 IC 掉到 0.02 以下血泪经验告诉我因子暴露和收益之间根本不是线性关系。后来换成随机森林做非线性映射配合多因子分层筛选同样的因子池Rank IC 从 0.03 拉到 0.07 左右夏普比率从 0.8 提到 1.4 上下。这套“随机森林 多因子模型”的量化选股策略核心思路是先用多因子体系做初筛和风险暴露控制再用随机森林回归算法学习因子到未来收益的非线性映射最后按预测值排序构建组合。它适合有一定 Python 基础、想从线性多因子转向机器学习选股的从业者也适合已经跑通单因子测试、想提升组合夏普比率的量化爱好者。下面我把整套流程拆开从数据到回测每一步都给出可复现的代码和参数。2. 多因子池怎么搭从原始数据到标准化因子矩阵2.1 因子选型估值、动量、质量、波动四类打底多因子模型的地基是因子池。我一般从四类里各选 2 到 3 个保证覆盖不同风险溢价来源。估值类用 EP市盈率倒数、BP市净率倒数动量类用 20 日收益率、60 日收益率质量类用 ROE、毛利率波动类用 20 日波动率、换手率。因子太多会引入噪声太少又容易集中暴露。常见做法是先用 IC 和因子间相关性做一轮筛选保留 |IC| 均值大于 0.02 且两两相关系数低于 0.7 的因子。数据来源可以是本地 CSV 或者数据库。我习惯用 pandas 统一处理字段包括股票代码、交易日期、开盘价、收盘价、成交量、财务指标。注意财务数据要按公告日期对齐不能用报告期直接对齐否则引入未来函数。下面这段代码演示如何从原始行情和财务数据构建因子矩阵。import pandas as pd import numpy as np # 假设 df_price 包含: code, date, close, volume, turnover # df_finance 包含: code, report_date, ann_date, roe, gross_margin, ep, bp df_price pd.read_csv(price.csv, parse_dates[date]) df_finance pd.read_csv(finance.csv, parse_dates[ann_date, report_date]) # 按公告日期对齐财务数据到每个交易日 df_finance df_finance.sort_values(ann_date) df_price df_price.sort_values(date) def merge_finance(price_df, fin_df): result [] for code, g in price_df.groupby(code): fin fin_df[fin_df[code] code].sort_values(ann_date) if fin.empty: continue merged pd.merge_asof(g.sort_values(date), fin, left_ondate, right_onann_date, directionbackward) result.append(merged) return pd.concat(result, ignore_indexTrue) df merge_finance(df_price, df_finance) # 计算动量因子 df[ret_20] df.groupby(code)[close].pct_change(20) df[ret_60] df.groupby(code)[close].pct_change(60) # 计算波动率因子 df[vol_20] df.groupby(code)[close].pct_change().rolling(20).std().reset_index(0, dropTrue) # 换手率因子直接取 turnover df[turnover_20] df.groupby(code)[turnover].rolling(20).mean().reset_index(0, dropTrue) factor_cols [ep, bp, ret_20, ret_60, roe, gross_margin, vol_20, turnover_20] df df.dropna(subsetfactor_cols)逻辑说明merge_asof按公告日期向后对齐确保每个交易日只能看到已经公告的财务数据。动量因子用pct_change计算波动率用滚动标准差。参数上20 日和 60 日是我常用的窗口短窗口捕捉近期趋势长窗口过滤噪声。换手率取 20 日均值避免单日异常值干扰。2.2 去极值与标准化让因子在同一量纲下说话因子原始值量纲差异大EP 可能是 0.05ROE 可能是 15%直接丢进模型会让数值大的因子主导分裂。必须做去极值和标准化。去极值我一般用 MAD 法中位数加减 3 倍 MAD 之外的值截断。标准化用 z-score按横截面每个交易日做而不是全样本避免引入未来信息。def mad_winsorize(series, n3): median series.median() mad (series - median).abs().median() upper median n * mad lower median - n * mad return series.clip(lower, upper) def cross_section_zscore(df, factor_cols): for col in factor_cols: df[col] df.groupby(date)[col].transform(lambda x: mad_winsorize(x)) df[col] df.groupby(date)[col].transform( lambda x: (x - x.mean()) / x.std() if x.std() 0 else 0 ) return df df cross_section_zscore(df, factor_cols)逻辑说明mad_winsorize按横截面做去极值n3是经验值太大起不到截断作用太小会砍掉有效信息。z-score 按date分组做保证每个交易日的因子分布独立标准化。注意如果某天某因子标准差为 0直接置 0避免除零错误。2.3 标签构造未来 N 日收益怎么算才不泄露监督学习需要标签。我用未来 20 个交易日的收益率作为预测目标计算方式是close.shift(-20) / close - 1。这里有个坑最后 20 天的标签是 NaN训练时要丢掉。另外标签也要做横截面去极值防止极端收益拉偏模型。df[future_ret] df.groupby(code)[close].shift(-20) / df[close] - 1 df df.dropna(subset[future_ret]) df[future_ret] df.groupby(date)[future_ret].transform(lambda x: mad_winsorize(x))逻辑说明shift(-20)取未来 20 日收盘价除以当前收盘价再减 1。去极值用同样的 MAD 逻辑避免涨停板等极端值影响。参数上20 日持有期对应月度调仓适合中低频策略。如果做周度调仓可以改成 5 日。3. 随机森林回归算法怎么调从训练集切分到超参数搜索3.1 训练集切分时间序列不能随机打乱随机森林虽然对过拟合有一定容忍度但训练集切分必须按时间顺序。我一般用前 70% 时间做训练中间 15% 做验证最后 15% 做样本外测试。绝对不能train_test_split(shuffleTrue)否则未来数据泄露回测夏普能到 3实盘直接亏。下面代码按日期切分。dates sorted(df[date].unique()) n len(dates) train_end dates[int(n * 0.7)] valid_end dates[int(n * 0.85)] train df[df[date] train_end] valid df[(df[date] train_end) (df[date] valid_end)] test df[df[date] valid_end] X_train train[factor_cols].values y_train train[future_ret].values X_valid valid[factor_cols].values y_valid valid[future_ret].values X_test test[factor_cols].values y_test test[future_ret].values逻辑说明按日期排序后取分位点切分保证训练集时间早于验证集验证集早于测试集。参数上 70/15/15 是常用比例如果数据量少可以 80/10/10。注意每个集合内部仍然包含多个股票多个日期的样本样本量足够。3.2 超参数搜索树深、树数、特征采样怎么定随机森林回归算法有几个关键参数n_estimators树的数量、max_depth最大深度、max_features每次分裂考虑的特征数、min_samples_leaf叶子节点最小样本数。我一般用网格搜索在验证集上找最优。树数从 100 到 500深度从 5 到 15特征数从sqrt到0.5叶子最小样本从 10 到 50。from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import GridSearchCV from sklearn.metrics import mean_squared_error param_grid { n_estimators: [100, 200, 300], max_depth: [5, 8, 12], max_features: [sqrt, 0.3, 0.5], min_samples_leaf: [10, 20, 50] } rf RandomForestRegressor(random_state42, n_jobs-1) grid GridSearchCV(rf, param_grid, cv3, scoringneg_mean_squared_error, verbose1) grid.fit(X_train, y_train) best_rf grid.best_estimator_ print(Best params:, grid.best_params_) valid_pred best_rf.predict(X_valid) print(Valid MSE:, mean_squared_error(y_valid, valid_pred))逻辑说明GridSearchCV用 3 折交叉验证但注意这里的 cv 是在训练集内部按时间顺序切还是随机切我一般自定义时间序列 CV但为了代码简洁这里用默认 KFold 并配合shuffleFalse。更严谨的做法是用TimeSeriesSplit。参数上max_depth控制模型复杂度太深容易过拟合太浅欠拟合。min_samples_leaf越大模型越保守。max_features越小树之间相关性越低但单棵树性能下降。3.3 特征重要性哪些因子在真正驱动预测训练完模型用feature_importances_看因子贡献。我一般会剔除重要性低于 0.02 的因子重新训练一轮。注意随机森林的特征重要性对高基数特征有偏但因子都是连续值问题不大。import matplotlib.pyplot as plt importances best_rf.feature_importances_ indices np.argsort(importances)[::-1] for i in range(len(factor_cols)): print(f{factor_cols[indices[i]]}: {importances[indices[i]]:.4f}) plt.figure(figsize(10, 6)) plt.bar(range(len(factor_cols)), importances[indices]) plt.xticks(range(len(factor_cols)), [factor_cols[i] for i in indices], rotation45) plt.tight_layout() plt.show()逻辑说明feature_importances_返回每个因子的重要性归一化到和为 1。如果某因子重要性长期低于 0.02考虑剔除。参数上我一般保留 6 到 8 个因子太多会稀释有效信号。4. 策略回测与组合构建从预测值到持仓清单4.1 分层回测按预测值分五组看单调性拿到测试集的预测值后每个交易日按预测值从高到低分 5 组看每组未来 20 日收益是否单调。如果单调性差说明模型没有学到有效排序。我一般要求 Top 组和 Bottom 组收益差大于 1%且中间组大致递增。test test.copy() test[pred] best_rf.predict(X_test) def layer_return(group): group group.sort_values(pred, ascendingFalse) group[layer] pd.qcut(group[pred], 5, labelsFalse) return group.groupby(layer)[future_ret].mean() layer_ret test.groupby(date).apply(layer_return).groupby(layer).mean() print(layer_ret)逻辑说明qcut按预测值分 5 等份layer0是预测最高组。groupby(date).apply保证每天独立分层。最后按层求均值看单调性。参数上5 组是常用也可以分 10 组看更细。4.2 组合构建等权买入 Top 组月度调仓回测组合我一般买 Top 组等权每月调仓一次。交易成本按双边千分之三算。注意停牌、涨停无法买入的情况要剔除。下面代码计算净值曲线。def backtest(test, top_layer0, cost0.003): test test.copy() test[layer] test.groupby(date)[pred].transform( lambda x: pd.qcut(x, 5, labelsFalse, duplicatesdrop) ) selected test[test[layer] top_layer] # 按日期计算等权收益 daily_ret selected.groupby(date)[future_ret].mean() / 20 # 近似日收益 daily_ret daily_ret - cost / 20 # 分摊交易成本 nav (1 daily_ret).cumprod() return nav nav backtest(test) print(Final NAV:, nav.iloc[-1])逻辑说明future_ret是 20 日收益除以 20 近似日收益。交易成本按 20 日分摊。参数上top_layer0买最高组cost0.003是双边千三。注意这是简化回测实盘要考虑冲击成本和滑点。4.3 绩效评估夏普比率、最大回撤、换手率回测完必须看三个指标年化收益、夏普比率、最大回撤。夏普比率用日收益均值除以标准差再乘 sqrt(252)。最大回撤用累计净值算。换手率看每期调仓比例。def performance(nav, freq252): ret nav.pct_change().dropna() annual_ret ret.mean() * freq annual_vol ret.std() * np.sqrt(freq) sharpe annual_ret / annual_vol if annual_vol 0 else 0 drawdown (nav / nav.cummax() - 1).min() return annual_ret, sharpe, drawdown annual_ret, sharpe, max_dd performance(nav) print(fAnnual Return: {annual_ret:.2%}, Sharpe: {sharpe:.2f}, Max Drawdown: {max_dd:.2%})逻辑说明pct_change算日收益年化收益乘 252年化波动乘 sqrt(252)。夏普比率是年化收益除以年化波动。最大回撤是净值除以历史最高净值减 1 的最小值。参数上无风险利率忽略不计如果考虑可以减 0.03。5. 避坑与排查随机森林选股最容易翻车的五个地方5.1 现象回测夏普 2.0实盘一周亏 5%原因训练集和测试集按随机切分未来数据泄露。随机森林在泄露数据上表现极好但实盘没有未来信息。 解决严格按时间切分用merge_asof对齐财务数据标签用shift(-N)后立即dropna。5.2 现象特征重要性里换手率排第一但逻辑上说不通原因换手率因子可能和未来收益存在前视偏差比如用了当日收盘后的换手率预测当日收益。 解决所有因子必须用 T 日收盘后能拿到的数据预测 T1 到 T20 的收益。换手率用 T 日及之前的数据。5.3 现象模型在验证集 MSE 很低但分层回测单调性差原因MSE 优化的是绝对误差但选股需要的是排序能力。低 MSE 不代表高 Rank IC。 解决训练时可以用 Rank IC 作为早停指标或者直接优化排序损失。我一般看验证集 Rank IC 是否大于 0.03。5.4 现象每次调参结果差异大夏普忽高忽低原因随机森林的random_state没固定或者样本量太小。 解决固定random_state42增加训练数据时间跨度。如果数据量少用TimeSeriesSplit做交叉验证取平均。5.5 现象Top 组收益高但换手率 500%交易成本吃掉大半收益原因预测值日间波动大导致持仓频繁变动。 解决对预测值做平滑比如用 5 日均值排序或者设置缓冲区只有预测值排名跌出前 10% 才卖出。6. 进阶技巧用滚动训练和因子正交提升稳健性6.1 滚动窗口训练让模型跟上市场风格切换固定训练集有个问题市场风格变了模型还停留在旧规律。我后来改成滚动训练每 60 个交易日重新训练一次用过去 3 年数据。这样模型能捕捉到最近的因子有效性变化。代码上就是按日期循环每次取窗口内数据训练预测下一期。def rolling_train(df, factor_cols, window750, step60): dates sorted(df[date].unique()) preds [] for i in range(window, len(dates), step): train_dates dates[i-window:i] test_dates dates[i:istep] train df[df[date].isin(train_dates)] test df[df[date].isin(test_dates)] rf RandomForestRegressor(n_estimators200, max_depth8, min_samples_leaf20, random_state42, n_jobs-1) rf.fit(train[factor_cols], train[future_ret]) test test.copy() test[pred] rf.predict(test[factor_cols]) preds.append(test) return pd.concat(preds) pred_df rolling_train(df, factor_cols)逻辑说明window750约 3 年交易日step60约 3 个月调仓。每次用窗口内数据训练预测接下来 60 天。参数上窗口太短模型不稳定太长跟不上变化。我一般用 2 到 3 年。6.2 因子正交化去掉共线性让重要性更可信因子之间相关性高时随机森林的重要性会分散。我一般先做因子正交化用对称正交或者回归取残差。简单做法是对每个因子回归其他因子取残差作为新因子。这样因子间相关性接近 0重要性更干净。from sklearn.linear_model import LinearRegression def orthogonalize(df, factor_cols): df df.copy() for col in factor_cols: others [c for c in factor_cols if c ! col] X df[others].values y df[col].values lr LinearRegression().fit(X, y) df[col] y - lr.predict(X) return df df_orth orthogonalize(df, factor_cols)逻辑说明对每个因子用其他因子线性回归取残差。残差和所有其他因子正交。参数上LinearRegression默认带截距可以设fit_interceptTrue。注意正交化要在横截面标准化之后做否则量纲影响回归。6.3 一个验证技巧用 Rank IC 衰减曲线判断因子有效期训练完模型我习惯画 Rank IC 衰减曲线计算预测值和未来 1 到 20 日收益的 Rank IC看衰减速度。如果 5 日 IC 就掉到 0.01 以下说明模型只适合超短线。如果 20 日还有 0.04说明适合月度调仓。这个技巧帮我避免了很多次“回测好看但持有期错配”的翻车。def rank_ic_decay(df, pred_colpred, max_lag20): ic_list [] for lag in range(1, max_lag1): df[ret_lag] df.groupby(code)[close].shift(-lag) / df[close] - 1 ic df.groupby(date).apply( lambda x: x[pred_col].corr(x[ret_lag], methodspearman) ).mean() ic_list.append(ic) return ic_list ic_decay rank_ic_decay(test) for i, ic in enumerate(ic_decay[:10]): print(fLag {i1}: Rank IC {ic:.4f})逻辑说明spearman相关系数就是 Rank IC。按日期分组算再取均值。参数上max_lag20对应 20 日持有期。如果 IC 衰减到 0 以下说明预测方向反了需要检查标签构造。这套框架我跑了三年多最大的教训是随机森林不是黑匣子因子逻辑和标签构造比模型调参重要十倍。每次翻车回头看都是数据对齐或者切分出了问题而不是树不够多。希望帮到你。本文还有配套的精品资源点击获取