news 2026/10/2 15:27:52

随机森林+多因子选股:从因子构建到回测的量化策略实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
随机森林+多因子选股:从因子构建到回测的量化策略实战

简介:这份资源面向量化投资初学者与机器学习爱好者,提供一套基于随机森林与多因子模型的完整选股策略实现方案,帮助读者理解从因子筛选到收益预测的全流程。压缩包共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_on='date', right_on='ann_date', direction='backward') result.append(merged) return pd.concat(result, ignore_index=True) 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, drop=True) # 换手率因子直接取 turnover df['turnover_20'] = df.groupby('code')['turnover'].rolling(20).mean().reset_index(0, drop=True) factor_cols = ['ep', 'bp', 'ret_20', 'ret_60', 'roe', 'gross_margin', 'vol_20', 'turnover_20'] df = df.dropna(subset=factor_cols)

逻辑说明:merge_asof按公告日期向后对齐,确保每个交易日只能看到已经公告的财务数据。动量因子用pct_change计算,波动率用滚动标准差。参数上,20 日和 60 日是我常用的窗口,短窗口捕捉近期趋势,长窗口过滤噪声。换手率取 20 日均值,避免单日异常值干扰。

2.2 去极值与标准化:让因子在同一量纲下说话

因子原始值量纲差异大,EP 可能是 0.05,ROE 可能是 15%,直接丢进模型会让数值大的因子主导分裂。必须做去极值和标准化。去极值我一般用 MAD 法,中位数加减 3 倍 MAD 之外的值截断。标准化用 z-score,按横截面(每个交易日)做,而不是全样本,避免引入未来信息。

def mad_winsorize(series, n=3): 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按横截面做去极值,n=3是经验值,太大起不到截断作用,太小会砍掉有效信息。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(shuffle=True),否则未来数据泄露,回测夏普能到 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_state=42, n_jobs=-1) grid = GridSearchCV(rf, param_grid, cv=3, scoring='neg_mean_squared_error', verbose=1) 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 并配合shuffle=False。更严谨的做法是用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], rotation=45) 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', ascending=False) group['layer'] = pd.qcut(group['pred'], 5, labels=False) return group.groupby('layer')['future_ret'].mean() layer_ret = test.groupby('date').apply(layer_return).groupby('layer').mean() print(layer_ret)

逻辑说明:qcut按预测值分 5 等份,layer=0是预测最高组。groupby('date').apply保证每天独立分层。最后按层求均值,看单调性。参数上,5 组是常用,也可以分 10 组看更细。

4.2 组合构建:等权买入 Top 组,月度调仓

回测组合我一般买 Top 组等权,每月调仓一次。交易成本按双边千分之三算。注意停牌、涨停无法买入的情况要剔除。下面代码计算净值曲线。

def backtest(test, top_layer=0, cost=0.003): test = test.copy() test['layer'] = test.groupby('date')['pred'].transform( lambda x: pd.qcut(x, 5, labels=False, duplicates='drop') ) 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_layer=0买最高组,cost=0.003是双边千三。注意这是简化回测,实盘要考虑冲击成本和滑点。

4.3 绩效评估:夏普比率、最大回撤、换手率

回测完必须看三个指标:年化收益、夏普比率、最大回撤。夏普比率用日收益均值除以标准差再乘 sqrt(252)。最大回撤用累计净值算。换手率看每期调仓比例。

def performance(nav, freq=252): 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(f'Annual 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 日收盘后能拿到的数据,预测 T+1 到 T+20 的收益。换手率用 T 日及之前的数据。

5.3 现象:模型在验证集 MSE 很低,但分层回测单调性差

原因:MSE 优化的是绝对误差,但选股需要的是排序能力。低 MSE 不代表高 Rank IC。 解决:训练时可以用 Rank IC 作为早停指标,或者直接优化排序损失。我一般看验证集 Rank IC 是否大于 0.03。

5.4 现象:每次调参结果差异大,夏普忽高忽低

原因:随机森林的random_state没固定,或者样本量太小。 解决:固定random_state=42,增加训练数据时间跨度。如果数据量少,用TimeSeriesSplit做交叉验证,取平均。

5.5 现象:Top 组收益高,但换手率 500%,交易成本吃掉大半收益

原因:预测值日间波动大,导致持仓频繁变动。 解决:对预测值做平滑,比如用 5 日均值排序;或者设置缓冲区,只有预测值排名跌出前 10% 才卖出。

6. 进阶技巧:用滚动训练和因子正交提升稳健性

6.1 滚动窗口训练:让模型跟上市场风格切换

固定训练集有个问题:市场风格变了,模型还停留在旧规律。我后来改成滚动训练,每 60 个交易日重新训练一次,用过去 3 年数据。这样模型能捕捉到最近的因子有效性变化。代码上就是按日期循环,每次取窗口内数据训练,预测下一期。

def rolling_train(df, factor_cols, window=750, step=60): dates = sorted(df['date'].unique()) preds = [] for i in range(window, len(dates), step): train_dates = dates[i-window:i] test_dates = dates[i:i+step] train = df[df['date'].isin(train_dates)] test = df[df['date'].isin(test_dates)] rf = RandomForestRegressor(n_estimators=200, max_depth=8, min_samples_leaf=20, random_state=42, 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)

逻辑说明:window=750约 3 年交易日,step=60约 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_intercept=True。注意正交化要在横截面标准化之后做,否则量纲影响回归。

6.3 一个验证技巧:用 Rank IC 衰减曲线判断因子有效期

训练完模型,我习惯画 Rank IC 衰减曲线:计算预测值和未来 1 到 20 日收益的 Rank IC,看衰减速度。如果 5 日 IC 就掉到 0.01 以下,说明模型只适合超短线。如果 20 日还有 0.04,说明适合月度调仓。这个技巧帮我避免了很多次“回测好看但持有期错配”的翻车。

def rank_ic_decay(df, pred_col='pred', max_lag=20): ic_list = [] for lag in range(1, max_lag+1): 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'], method='spearman') ).mean() ic_list.append(ic) return ic_list ic_decay = rank_ic_decay(test) for i, ic in enumerate(ic_decay[:10]): print(f'Lag {i+1}: Rank IC = {ic:.4f}')

逻辑说明:spearman相关系数就是 Rank IC。按日期分组算,再取均值。参数上,max_lag=20对应 20 日持有期。如果 IC 衰减到 0 以下,说明预测方向反了,需要检查标签构造。

这套框架我跑了三年多,最大的教训是:随机森林不是黑匣子,因子逻辑和标签构造比模型调参重要十倍。每次翻车回头看,都是数据对齐或者切分出了问题,而不是树不够多。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 15:27:26

C++模板元编程实战:编译期训练线性回归模型

把“模板编译期机器学习”这六个字放在一起&#xff0c;很多人第一反应是&#xff1a;这怕不是两个词拼错了&#xff1f;模板元编程是用来搞泛型编程的&#xff0c;机器学习是要跑在GPU和数据流上的&#xff0c;怎么能在编译期完成&#xff1f;C模板元编程确实有一个非常硬核的…

作者头像 李华
网站建设 2026/10/2 15:26:35

Agent从Demo到生产:工具调用、记忆管理、并发与可观测四道坎

1. 从Demo到生产&#xff1a;Agent落地为什么总在同一个地方翻车做Agent项目的人大概都经历过这个循环&#xff1a;花两天搭出一个Demo&#xff0c;接上LLM、挂几个工具、跑通一个订机票或者查天气的流程&#xff0c;演示给团队看的时候效果惊艳&#xff0c;大家觉得这事成了。…

作者头像 李华
网站建设 2026/10/2 15:22:09

基于多时段动态电价的电动汽车有序充电优化与Matlab实现

1. 为什么"有序"是电动汽车充电绕不开的问题 先说一个我最近实际碰到的场景。小区地下车库装了30个交流充电桩&#xff0c;每台额定功率7kW。最初大家都很佛系——下班插上&#xff0c;第二天满电开走&#xff0c;一切都好。结果入冬后的某个晚上&#xff0c;物业群突…

作者头像 李华
网站建设 2026/10/2 15:22:07

Beelink Strix Halo实战:2.5GbE内网传输294MB/s,迷你主机也能跑满带宽

手里这台 Beelink Strix Halo 迷你主机&#xff0c;系统装完之后我干的第一件事不是跑分&#xff0c;而是把一个叫 halogen-flash-server 的轻量级分发自建服务翻出来&#xff0c;直接在局域网里搭了个高速镜像点。折腾了大半天&#xff0c;最后稳定拿到 294 MB/s 的持续传输…

作者头像 李华