简介:面向量化投资研究者与学生的一站式机器学习选股策略资源,完整覆盖因子分析到模型构建全链路:从单因子测试流程、因子池评估报告,到多重共线性诊断与因子组合优化,并系统对比支持向量回归、长短期记忆网络、梯度提升决策树、随机森林及自适应增强等模型。随机森林方案在严苛回测中实现累计收益约60%,最大回撤控制在9%以内,风险调整后收益达0.9,核心算法及评估逻辑可直接参考。压缩包共46个文件,以Python脚本为主体,涵盖因子检验、模型训练与回测模块,另附研究报告PDF、情绪/质量/价值等类型因子可视化图表及数据探索Notebook;整体约19.92MB,结构清晰便于按模块学习,适合具备Python与量化基础的读者复现与扩展。目前已有121人学习使用。
1. 量化选股为什么要搭上随机森林:因子权重不用再拍脑袋
如果你手里有二十几个因子,大概率碰到过这种事:回测参数怎么调都不稳,今天IC高的几个因子下个月集体失效,换手率一算收益全被磨平。多因子模型最麻烦的是因子权重靠算IC或者经验拍脑袋,而随机森林恰恰能跳出这个框——把“哪些因子有用、怎么组合”交给数据自己去投票。随机森林是多棵决策树做Bagging的集成模型,喂进去的正好是多因子模型构造的特征矩阵,两者接起来,就是一条能落地的量化选股策略实现路径。下面按我自己跑过的流程,从因子清洗、随机森林建模到回测验证的坑完整拆开,适合已经有行情和财务数据、想从打分选股跨到机器学习选股的从业者。
2. 多因子模型搭建:因子池、去极值与中性化的标准动作
在随机森林接手之前,因子数据必须先走一遍多因子模型的标准流程。这个环节做不好,后面模型再先进也是白搭。因子数据最常见的三个问题:长尾极值、缺失、行业偏置,分别对应去极值、填充、中性化三件事。
2.1 因子池怎么搭:估值、成长、质量与动量四类因子起步
起步阶段不需要追求因子数量,先把四类常见因子搭起来:估值、成长、质量、动量。它们数据好取、逻辑清晰、在公开研报里反复被验证过。下面是我常用的初始因子清单,每类两到三个,加起来刚好覆盖基本面、量价和规模三个维度。
| 类别 | 因子名 | 计算口径(简化) | 常见方向 |
|---|---|---|---|
| 估值 | pe | 总市值 / 净利润(TTM) | 低PE偏好 |
| 估值 | pb | 总市值 / 净资产 | 低PB偏好 |
| 估值 | ps | 总市值 / 营业收入(TTM) | 低PS偏好 |
| 质量 | roe | 净利润 / 净资产 | 高ROE偏好 |
| 质量 | roa | 净利润 / 总资产 | 高ROA偏好 |
| 成长 | sales_growth | 营业收入同比增速 | 高增长偏好 |
| 成长 | profit_growth | 净利润同比增速 | 高增长偏好 |
| 动量 | mom_20 | 过去20日累计涨幅 | 跟随趋势(有争议) |
| 量价 | turnover | 20日平均换手率 | 低换手偏好 |
| 规模 | mktcap | 总市值对数 | 小市值偏好(隐含) |
这里有个容易忽略的问题:mktcap本身不是主动选出来的因子,但很多财务因子和市值天然相关,放进特征矩阵之后随机森林会自动捕捉到规模信息。你需要观察它在特征重要性里的位置——如果它排到第一,说明策略实际上是在做小市值轮动。这时候要做市值中性化,或者干脆把它从特征里去掉。我一般保留它,但要求回测时把净值曲线和市值因子做回归剥离,确认超额收益不是单纯贡献给规模风险暴露。
因子计算要统一时间戳。我的习惯是:财务类因子用最新报告期数据,量价类因子用截至t日收盘的数据。所有因子在t日生成,未来能不能拿到这个问题在这一步不考虑——那是在标签构造阶段才需要警惕的事。
2.2 数据清洗三件套:去极值、缺失值与标准化
因子原始值直接进模型会出问题。PE、PS这类比值因子的长尾极重,一只亏损股的PE可能是-300,不处理就是一根噪声尖刺;换手率和市值分布也明显右偏。标准做法是先在截面上去极值,再填缺失,最后做标准化。
import pandas as pd import numpy as np def mad_winsorize(df, col, n=3): # MAD去极值:用中位数和绝对偏差代替均值方差,抗离群点 median = df[col].median() mad = (df[col] - median).abs().median() # 1.4826 让MAD在正态分布下等价于标准差 upper = median + n * 1.4826 * mad lower = median - n * 1.4826 * mad df[col] = df[col].clip(lower, upper) return df def fill_missing(df, col): # 截面缺失用中位数填,避免行业龙头拉偏均值 df[col] = df[col].fillna(df[col].median()) return df def zscore(df, col): df[col] = (df[col] - df[col].mean()) / df[col].std() return df这段代码做了什么:mad_winsorize用中位数和MAD把极端值截断在n倍绝对偏差以内,n=3是常用设置,改成2会更激进、改成4更保守;fill_missing用中位数而不是均值填充,防止被极端值污染;zscore做截面标准化,让随机森林训练时分裂点更好找,也让后面特征重要性的数值可比。
值得注意的是,标准化对树模型不是必须的,因为决策树只看分裂阈值。但做了之后,因子监控和IC分析的单位一致,省事。还有一个实操经验:这三步都要按截面做,也就是按date分组,对同一天的全部股票操作,不能整张表一起算。如果跨日期混在一起,早期和近期样本会互相影响,因子分布被时间趋势污染。A股市值逐年膨胀,不对齐截面的标准化等于把时间趋势当成横截面信号送进模型。
2.3 行业中性化:不做这一步会被行业偏置带偏
随机森林不关心数据里的行业分布。A股里银行、保险普遍低PB低PE,白酒、医药普遍高ROE,如果因子直接进模型,模型学到的多半是“低PB = 银行”,而不是真正的估值溢价。这样一来,选出来的组合会自然向个别行业集中。行业中性化的作用就是把这层行业解释剥掉,常用做法是用行业哑变量对因子做回归,取残差作为中性化后的新因子。
from sklearn.linear_model import LinearRegression def industry_neutralize(df, factor_col, industry_col='industry'): # 用行业哑变量解释因子,残差就是剥离行业影响的部分 dummies = pd.get_dummies(df[industry_col], prefix='ind', drop_first=True).astype(float) reg = LinearRegression().fit(dummies, df[factor_col]) resid = df[factor_col] - reg.predict(dummies) # 残差再做一次z-score,让取值回到可比范围 resid = (resid - resid.mean()) / resid.std() return resid这段代码做了什么:get_dummies把行业转成01哑变量,drop_first避免完全共线性;LinearRegression拟合出行业对因子的解释量后,残差就是剥离行业均值后的个股差异。这里用线性模型就够了,因为目的只是剔除行业偏移,不需要捕捉非线性关系。
注意一个边界:如果某个行业只有一两支股票,哑变量的系数会被单样本主导,残差基本没有意义。常见做法是给行业设最小样本阈值,比如少于10支股票就合并到“其他”。我一般直接在数据清洗阶段过滤掉这些样本,而不是放进去污染中性化结果。中性化做完后顺手检查残差与行业的相关性,接近0才算合格。
3. 随机森林在选股中的两类任务:分类和回归到底选谁
3.1 为什么是随机森林:非线性、抗噪、自带特征重要性
多因子模型传统做法是线性加权:IC加权、ICIR加权或者回归系数加权。线性模型最大的限制是因子和未来收益之间往往不是直线关系——ROE对收益的影响在中间区间最明显,极高ROE反而因为预期打满跑不出超额;换手率对收益的影响更像倒U型。随机森林用多棵决策树Bagging,每个节点按信息增益切分数据,天然能刻画这种非线性。
单棵决策树的问题是高方差:换一份样本,树的结构可能完全不同,预测波动大。Bagging的思路是同时训练多棵树,每棵树用bootstrap抽样的子集,分裂时随机抽一部分特征做候选,树和树之间的相关性被压下去,最后平均或投票,方差就降下来。这也是随机森林在几千支股票这种中等规模数据上比较好用的原因——样本量不算大、噪声却不小,恰好是集成方法的舒适区。
如果你之前用过梯度提升树(XGBoost、LightGBM)做选股,会感受到明显差别:随机森林对超参数和噪声更稳健,起步阶段不容易因为一两棵树学歪而翻车,特征重要性也更稳定。代价是上限通常不如调好的梯度提升树,但作为第一版可解释、可上线的选股模型,它足够扎实。
3.2 分类与回归怎么选:一个预测涨跌,一个预测收益
随机森林落到选股场景,有两种任务设定。分类任务把标签变成二值:未来20日收益是否为正。回归任务直接用未来20日收益率做标签。两者各有适用场景。
| 任务 | 标签 | 模型输出 | 适合场景 |
|---|---|---|---|
| 分类 | 未来收益 > 0 记为1,否则0 | 上涨概率 | 看重胜率、需要明确方向判断 |
| 回归 | 未来20日收益率 | 预测收益值 | 需要排序和权重的选股场景 |
我一般以回归为主,辅助看分类概率。回归能保留收益大小的信息:涨5%和涨0.5%对组合净值的影响完全不同,分类只标记正负,排序时容易丢掉这层信息。另外,随机森林回归算法预测的是条件均值,天然把尾部噪声平均掉,输出比线性回归稳定得多。
实际操作时还有一个折中方案:把未来20日收益转成五分位排名再做回归。这样标签既保留排序信息,又削弱极端收益的干扰。我试下来这个方案在回测里比纯回归和纯分类都稳定,推荐你把它作为默认标签构造方式。
3.3 最小可运行模型:随机森林回归的30行Python代码
这个环节先不引入复杂的滚动训练,用一份已经清洗好的因子面板跑通最小闭环。你只需要一个结构如下的DataFrame:每一行一支股票一个交易日,列包含stock_id、date、若干特征、ret_fwd20。
import pandas as pd from sklearn.ensemble import RandomForestRegressor # 特征列要和因子面板里的列名保持一致 features = ['pe', 'pb', 'roe', 'mom_20', 'turnover', 'mktcap'] X = df[features].dropna() y = df.loc[X.index, 'ret_fwd20'] # 这里是顺序切分,不是随机切分:前80%训练,后20%验证 cut = int(len(X) * 0.8) X_train, X_test = X.iloc[:cut], X.iloc[cut:] y_train, y_test = y.iloc[:cut], y.iloc[cut:] model = RandomForestRegressor( n_estimators=300, max_depth=5, min_samples_leaf=50, random_state=42, n_jobs=-1 ) model.fit(X_train, y_train) importance = pd.Series(model.feature_importances_, index=features) print(importance.sort_values(ascending=False))这段代码做了什么:先用dropna把带缺失的行删掉,再用loc保证y和X对齐;按位置顺序切分,确保训练集全部早于验证集;RandomForestRegressor参数里,n_estimators=300是起步值,max_depth=5限制树深度,min_samples_leaf=50强制叶子至少有50个样本。最后输出特征重要性排序,用来做第一轮因子体检。
参数怎么调:max_depth设太大会让单棵树记住个股噪声,太小又欠拟合;A股单月股票样本一般在3000到5000支,min_samples_leaf=50已经比较保守。n_estimators后面调参阶段再画学习曲线看,现在300起步足够看到重要性排序的趋势。
提示:dropna会删掉一整行含缺失的样本。如果某个因子大面积缺失,先回数据清洗环节处理,不要依赖模型内部的缺失值容忍能力。
4. 从因子表到股票名单:完整策略实现的四个步骤
4.1 特征与标签矩阵合并:让模型吃上“截面+历史”的数据格式
随机森林不接收你那套“一张宽表包含所有周期”的原始数据,它只认规整的样本-特征矩阵。这一步要把行情价格、财务因子、标签合并成按日期排序的长表,每行代表某个股票某个交易日的观测。
# factor_panel: 每个股票每个交易日的因子快照(已做清洗和中性化) # close: 日收盘价序列,index为(stock_id, date) def build_label(close_df, horizon=20): tmp = close_df.reset_index() # 未来第horizon天的收盘价相对今天的收益率,就是这期标签 tmp['close_fwd'] = tmp.groupby('stock_id')['close'].shift(-horizon) tmp['ret_fwd'] = tmp['close_fwd'] / tmp['close'] - 1 return tmp[['stock_id', 'date', 'ret_fwd']] label = build_label(close, horizon=20) # 因子面板和标签按股票+日期对齐,避免错位合并 df = factor_panel.merge(label, on=['stock_id', 'date'], how='left') df = df.dropna(subset=['ret_fwd']) df = df.sort_values(['date', 'stock_id']).reset_index(drop=True)这段代码做了什么:shift(-horizon)把未来第20天收盘价搬到今天这一行,除以今天的价格得到未来收益率。关键是按stock_id分组shift,否则同一交易日不同股票的收盘价会互相错位。merge用how='left'保留因子面板全部行,最后dropna删掉数据表末尾那些还看不到未来价格的样本——这一步必须有,因为最近horizon天的观测天然缺少标签,留在训练集里就是脏数据。
实战里我再强调一次:horizon直接设成和调仓周期一致。月度调仓就用20个交易日。如果标签是20日收益、实际每5天调一次仓,模型学到的东西和你的操作节奏完全对不上,回测结果没有参考价值。
4.2 滚动训练与时间切分:杜绝前视偏差的关键动作
机器学习里默认的train_test_split会随机打乱样本,这是量化选股最致命的误用。股票数据有强时间依赖,随机打乱后训练集里混进了大量验证集日期的数据,模型等于提前看了答案。正确做法是严格按照时间顺序切分,并且每期滚动训练。
from sklearn.model_selection import TimeSeriesSplit X = df[features] y = df['ret_fwd'] # gap=20:训练集和验证集之间留出20天,避免标签跨期信息重叠 tscv = TimeSeriesSplit(n_splits=4, gap=20) for fold, (train_idx, val_idx) in enumerate(tscv.split(X)): model = RandomForestRegressor( n_estimators=200, max_depth=5, min_samples_leaf=50, random_state=42, n_jobs=-1 ) model.fit(X.iloc[train_idx], y.iloc[train_idx]) pred = model.predict(X.iloc[val_idx]) # 记录每个fold的预测值和真实值,回头统一做分组评估TimeSeriesSplit的n_splits=4把整个时序分成5段,训练集依次扩展,验证集始终在训练集之后。gap=20是sklearn新版本支持的参数,在训练集和验证集之间空出20天。为什么需要gap:标签是未来20天收益,如果训练集最后一天和验证集第一天紧紧相邻,训练集的标签已经偷偷覆盖到了验证集的起点,信息重叠过重。
这个细节很多教程不写,但影响极大。我早期做这个策略时直接随机切分,训练集R²做到0.2还很得意;换成时间序切分后R²变成负数,才发现之前一直在偷看答案。这条教训直接改掉了我后面所有流程——凡是涉及时间序列,先问一句“这个切分有没有把未来漏进过去”。
4.3 选股落地:预测全市场、排序取Top N、等权买入
模型训练好之后,剩下的事情是把预测输出变成可执行的股票名单。常见做法是每个调仓日生成下一期组合:对该交易日全部股票计算因子,跑一遍模型预测,按预测值从大到小排序,取前N支等权买入,持有到下一个调仓日重复。
def select_portfolio(model, factor_latest, top_n=30): # factor_latest: 最新一个交易日的因子截面 X_all = factor_latest[features].dropna() factor_latest.loc[X_all.index, 'pred'] = model.predict(X_all) # 过滤ST、停牌、上市不满一年等基础条件,放到更早的数据清洗里 pool = factor_latest.nlargest(top_n, 'pred') weight = 1.0 / top_n portfolio = pool[['stock_id', 'date', 'pred']].copy() portfolio['weight'] = weight return portfolionlargest(top_n, 'pred')直接在DataFrame里取预测值最高的30只,比sort_values再head更简洁。等权分配weight=1/30,容易实现也容易解释。要不要用预测值本身做权重?我试过,效果不稳定:预测值集中在某个窄区间时,权重差异很小,反而是等权更稳。
这里有一个参数细节:top_n要和资金规模、股票池匹配。5000万以下资金30只够分散,上亿资金至少50到100只,不然单票仓位过重。调仓频率同样要注意:用20日标签的模型,调仓周期别缩到5天,否则持有天数远小于标签周期,策略在交易逻辑上自相矛盾。
5. 实战避坑:随机森林选股策略必踩的五个坑与排查办法
这一章全是我实际跑策略时踩过、也帮别人排查过的坑,每一条按“现象、原因、解决”拆开,你可以对照自己的回测报告一条条查。
5.1 训练集指标一片大好,实盘却接连回撤:先查标签泄漏
现象:训练集、验证集R²都能到0.1以上,分组回测净值曲线很漂亮,一上实盘连续几周跑输基准,完全无法解释。
原因:大多数情况是标签泄漏。随机切分是其中之一,但更隐蔽的是因子本身用了未来信息。比如用当天收盘后才知道的数据去预测当天收益,或者动量因子的计算窗口覆盖了标签区间,都属于数据前瞻。
解决:把每个特征的生成时间和标签区间画在一张时间轴上逐一核对,最笨也最可靠。特征计算截止到t日收盘,标签是t+1到t+20日收益,中间不能有任何重叠。如果你发现自己把“当日涨幅”放进特征、又拿“当日收益”当标签,那就是标准的答案跑进了题目。
5.2 特征重要性和金融常识完全相反:多半是没做行业中性化
现象:特征重要性里pb、mktcap排前三,但预测值越高,买入组合越集中在银行和地产,跟你最初想抓的阿尔法完全不沾边。
原因:A股行业横截面差异太大。银行低PB、白酒高ROE,模型只需要按行业切一刀就能“解释”部分收益。它学到的不是估值溢价,而是一个行业分类器。
解决:按第二章的行业中性化把因子变成残差再训练。做完后如果pb的重要性显著下降,说明它原本就是行业代理变量。此时可以考虑把行业本身作为一个特征放进去,让模型显式学习行业轮动,而不是靠因子隐式编码。
5.3 预测值挤成一团,排序接近随机:叶子太少或噪声太大
现象:所有股票预测值都落在正负0.3%以内,排序结果每期大变,分组回测完全看不出单调性。
原因:收益本身的信噪比极低。单棵树如果学得太细,就会把噪声当信号。min_samples_leaf设得太小是常见诱因;另外标签里没有去极值,个别极端涨跌样本会把分裂点带偏。
解决:把min_samples_leaf往上提到50甚至100,让每片叶子至少覆盖百分之一以上的样本;同时检查标签ret_fwd20有没有做MAD去极值。A股20日收益的尾部很重,标签不去极值,树分裂时永远会被那几只暴涨暴跌的股票吸引。
5.4 调仓换手率过高,收益全被交易成本磨平:模型太敏感
现象:回测里年化收益能到30%,把双边千分之三的成本算进去后只剩5%,换手率高到一个月把持仓换了一遍。
原因:随机森林对特征变化太敏感。每期预测值小幅波动都会改变Top 30的边界,边界附近的股票频繁进进出出。特别是动量因子权重偏高时,策略几乎在追涨杀跌。
解决:给选股加一道平滑——持仓只调整排名下降超过一定阈值的股票,或者调仓周期从10天拉到20天。更直接的办法是把换手率作为惩罚项放进验证集评估,回测里按实际成交成本扣费,不要用毛收益骗自己。我一般保存每期持仓快照,单独统计每期换手,超过30%就回头减因子数量或降低动量占比。
5.5 模型痴迷动量因子,反转行情集体翻车:树深度过深
现象:趋势上涨阶段策略收益很好,一到震荡或反转行情就连续回撤,最大回撤明显超过基准。
原因:树模型会把动量因子切分成多个阶梯区间,深度大了以后,高阶交互本质上是把“最近涨得多”的逻辑学得过于绝对、过于线性化。A股反转效应本来就不弱,动量因子在部分区间是负向的,树模型学到的分段函数容易过度外推。
解决:把max_depth限制在4到6,阻止过细的交互分裂;对动量因子先取排序分位数或者log变换,削弱绝对幅度;更彻底的做法是加入一个中周期反转因子(比如过去40日涨跌幅的反向排名)作为对冲。这个调整会让牛市阶段的收益略降,但震荡市回撤通常能明显改善。
6. 调参与回测验证:把策略做扎实的最后一道工序
6.1 参数调优:先看OOB学习曲线,再动max_depth和min_samples_leaf
调参不是玄学,但很容易变成玄学,因为参数组合太多。随机森林自带OOB袋外分数,在训练过程中用没见过的样本做评估,相当于免费送一个验证曲线。先画OOB学习曲线确定n_estimators,再动max_depth和min_samples_leaf。
scores = [] for n in range(50, 501, 50): model = RandomForestRegressor( n_estimators=n, max_depth=5, min_samples_leaf=50, oob_score=True, random_state=42, n_jobs=-1 ) model.fit(X_train, y_train) scores.append(model.oob_score_) # 用plt.plot(range(50, 501, 50), scores)画出来OOB分数是回归R²,在A股这种低信噪比数据上经常是负数或者贴近零,不要被绝对数值吓住,看的是收敛趋势:曲线在300附近走平就不用再加树,还在涨就继续加。之后再动min_samples_leaf和max_depth,方向是:验证集打不过基准就回头查特征和标签,而不是继续堆参数。
6.2 回测验证:五个指标定成败
| 指标 | 经验参考 |
|---|---|
| 年化超额收益 | 跑赢基准5个百分点以上 |
| 最大回撤 | 不超过20% |
| 夏普比率 | 大于1 |
| 单次调仓换手 | 不超过30% |
| 月度胜率 | 60%左右 |
这些数字是我做小盘组合的经验参考,不代表保证值。重点是回测必须按双边千分之三到五扣掉交易成本,别用毛收益说服自己。还要留出最近一段行情,等所有调参完成后再做一次最终验证,相当于给策略留一份后悔药。我早期最深的教训就是花大量时间调参,最后发现前视偏差一直在偷看答案。后来我给自己定了两条规矩:任何新因子进模型前,先核对它在t日收盘时能不能算出来;任何回测结论先问是否依赖某一段特定行情。这两条习惯帮我少踩了很多坑,希望帮到你。
本文还有配套的精品资源,点击获取