
简介面向量化投资研究者与学生的一站式机器学习选股策略资源完整覆盖因子分析到模型构建全链路从单因子测试流程、因子池评估报告到多重共线性诊断与因子组合优化并系统对比支持向量回归、长短期记忆网络、梯度提升决策树、随机森林及自适应增强等模型。随机森林方案在严苛回测中实现累计收益约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日累计涨幅跟随趋势有争议量价turnover20日平均换手率低换手偏好规模mktcap总市值对数小市值偏好隐含这里有个容易忽略的问题mktcap本身不是主动选出来的因子但很多财务因子和市值天然相关放进特征矩阵之后随机森林会自动捕捉到规模信息。你需要观察它在特征重要性里的位置——如果它排到第一说明策略实际上是在做小市值轮动。这时候要做市值中性化或者干脆把它从特征里去掉。我一般保留它但要求回测时把净值曲线和市值因子做回归剥离确认超额收益不是单纯贡献给规模风险暴露。因子计算要统一时间戳。我的习惯是财务类因子用最新报告期数据量价类因子用截至t日收盘的数据。所有因子在t日生成未来能不能拿到这个问题在这一步不考虑——那是在标签构造阶段才需要警惕的事。2.2 数据清洗三件套去极值、缺失值与标准化因子原始值直接进模型会出问题。PE、PS这类比值因子的长尾极重一只亏损股的PE可能是-300不处理就是一根噪声尖刺换手率和市值分布也明显右偏。标准做法是先在截面上去极值再填缺失最后做标准化。import pandas as pd import numpy as np def mad_winsorize(df, col, n3): # 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倍绝对偏差以内n3是常用设置改成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_colindustry): # 用行业哑变量解释因子残差就是剥离行业影响的部分 dummies pd.get_dummies(df[industry_col], prefixind, drop_firstTrue).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_estimators300, max_depth5, min_samples_leaf50, random_state42, n_jobs-1 ) model.fit(X_train, y_train) importance pd.Series(model.feature_importances_, indexfeatures) print(importance.sort_values(ascendingFalse))这段代码做了什么先用dropna把带缺失的行删掉再用loc保证y和X对齐按位置顺序切分确保训练集全部早于验证集RandomForestRegressor参数里n_estimators300是起步值max_depth5限制树深度min_samples_leaf50强制叶子至少有50个样本。最后输出特征重要性排序用来做第一轮因子体检。参数怎么调max_depth设太大会让单棵树记住个股噪声太小又欠拟合A股单月股票样本一般在3000到5000支min_samples_leaf50已经比较保守。n_estimators后面调参阶段再画学习曲线看现在300起步足够看到重要性排序的趋势。提示dropna会删掉一整行含缺失的样本。如果某个因子大面积缺失先回数据清洗环节处理不要依赖模型内部的缺失值容忍能力。4. 从因子表到股票名单完整策略实现的四个步骤4.1 特征与标签矩阵合并让模型吃上“截面历史”的数据格式随机森林不接收你那套“一张宽表包含所有周期”的原始数据它只认规整的样本-特征矩阵。这一步要把行情价格、财务因子、标签合并成按日期排序的长表每行代表某个股票某个交易日的观测。# factor_panel: 每个股票每个交易日的因子快照已做清洗和中性化 # close: 日收盘价序列index为(stock_id, date) def build_label(close_df, horizon20): 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, horizon20) # 因子面板和标签按股票日期对齐避免错位合并 df factor_panel.merge(label, on[stock_id, date], howleft) df df.dropna(subset[ret_fwd]) df df.sort_values([date, stock_id]).reset_index(dropTrue)这段代码做了什么shift(-horizon)把未来第20天收盘价搬到今天这一行除以今天的价格得到未来收益率。关键是按stock_id分组shift否则同一交易日不同股票的收盘价会互相错位。merge用howleft保留因子面板全部行最后dropna删掉数据表末尾那些还看不到未来价格的样本——这一步必须有因为最近horizon天的观测天然缺少标签留在训练集里就是脏数据。实战里我再强调一次horizon直接设成和调仓周期一致。月度调仓就用20个交易日。如果标签是20日收益、实际每5天调一次仓模型学到的东西和你的操作节奏完全对不上回测结果没有参考价值。4.2 滚动训练与时间切分杜绝前视偏差的关键动作机器学习里默认的train_test_split会随机打乱样本这是量化选股最致命的误用。股票数据有强时间依赖随机打乱后训练集里混进了大量验证集日期的数据模型等于提前看了答案。正确做法是严格按照时间顺序切分并且每期滚动训练。from sklearn.model_selection import TimeSeriesSplit X df[features] y df[ret_fwd] # gap20训练集和验证集之间留出20天避免标签跨期信息重叠 tscv TimeSeriesSplit(n_splits4, gap20) for fold, (train_idx, val_idx) in enumerate(tscv.split(X)): model RandomForestRegressor( n_estimators200, max_depth5, min_samples_leaf50, random_state42, n_jobs-1 ) model.fit(X.iloc[train_idx], y.iloc[train_idx]) pred model.predict(X.iloc[val_idx]) # 记录每个fold的预测值和真实值回头统一做分组评估TimeSeriesSplit的n_splits4把整个时序分成5段训练集依次扩展验证集始终在训练集之后。gap20是sklearn新版本支持的参数在训练集和验证集之间空出20天。为什么需要gap标签是未来20天收益如果训练集最后一天和验证集第一天紧紧相邻训练集的标签已经偷偷覆盖到了验证集的起点信息重叠过重。这个细节很多教程不写但影响极大。我早期做这个策略时直接随机切分训练集R²做到0.2还很得意换成时间序切分后R²变成负数才发现之前一直在偷看答案。这条教训直接改掉了我后面所有流程——凡是涉及时间序列先问一句“这个切分有没有把未来漏进过去”。4.3 选股落地预测全市场、排序取Top N、等权买入模型训练好之后剩下的事情是把预测输出变成可执行的股票名单。常见做法是每个调仓日生成下一期组合对该交易日全部股票计算因子跑一遍模型预测按预测值从大到小排序取前N支等权买入持有到下一个调仓日重复。def select_portfolio(model, factor_latest, top_n30): # 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更简洁。等权分配weight1/30容易实现也容易解释。要不要用预测值本身做权重我试过效果不稳定预测值集中在某个窄区间时权重差异很小反而是等权更稳。这里有一个参数细节top_n要和资金规模、股票池匹配。5000万以下资金30只够分散上亿资金至少50到100只不然单票仓位过重。调仓频率同样要注意用20日标签的模型调仓周期别缩到5天否则持有天数远小于标签周期策略在交易逻辑上自相矛盾。5. 实战避坑随机森林选股策略必踩的五个坑与排查办法这一章全是我实际跑策略时踩过、也帮别人排查过的坑每一条按“现象、原因、解决”拆开你可以对照自己的回测报告一条条查。5.1 训练集指标一片大好实盘却接连回撤先查标签泄漏现象训练集、验证集R²都能到0.1以上分组回测净值曲线很漂亮一上实盘连续几周跑输基准完全无法解释。原因大多数情况是标签泄漏。随机切分是其中之一但更隐蔽的是因子本身用了未来信息。比如用当天收盘后才知道的数据去预测当天收益或者动量因子的计算窗口覆盖了标签区间都属于数据前瞻。解决把每个特征的生成时间和标签区间画在一张时间轴上逐一核对最笨也最可靠。特征计算截止到t日收盘标签是t1到t20日收益中间不能有任何重叠。如果你发现自己把“当日涨幅”放进特征、又拿“当日收益”当标签那就是标准的答案跑进了题目。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_estimatorsn, max_depth5, min_samples_leaf50, oob_scoreTrue, random_state42, 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日收盘时能不能算出来任何回测结论先问是否依赖某一段特定行情。这两条习惯帮我少踩了很多坑希望帮到你。本文还有配套的精品资源点击获取