资讯动态

机器学习多因子选股模型:从因子暴露到滚动训练与回测的工程链路

发布时间:2026/10/3 9:50:59 来源:尧图企业网站定制
简介本资源面向计算机、人工智能及金融工程方向的学生与量化爱好者提供一套基于机器学习方法构建多因子选股模型的完整项目源码与文档说明适合作为毕业设计、课程实践或量化策略入门参考。包内共38个文件以15个Python脚本为核心覆盖单因子测试、因子共线性分析、等权重线性模型及SVR、LSTM、XGBoost、随机森林、AdaBoost等多种baseline模型的回测实现另含10份PDF研报、7张因子分类图示、1份docx说明与1份ipynb探索笔记压缩包约14.71MB。项目展示了从因子筛选、特征标签构建到交易逻辑确定与回测结果分析的完整流程最优随机森林模型累计收益约60%经择时风控后最大回撤控制在9%左右夏普率约0.9。目前已有464人学习代码均经测试可运行便于读者复现实验、理解多因子建模思路并快速搭建自己的选股回测框架。1. 多因子选股模型从因子暴露到机器学习打分一条能跑通的工程链路做量化选股的人迟早会撞上一个尴尬手工拼的几个因子估值、动量、质量、波动在样本内看着漂亮一上实盘就钝化。问题往往不在因子本身而在“怎么把多个因子合成一个打分”。等权加总太粗暴IC加权又对因子失效反应迟钝于是很多人转向机器学习方法构建多因子选股模型。这个标题讲的正是这件事用机器学习把一堆因子暴露映射成对未来收益的预测再据此排序选股。它适合有 Python 基础、懂一点 pandas、想从“因子堆叠”升级到“模型合成”的从业者。源代码和文档说明的价值在于它把数据清洗、因子构造、标签对齐、滚动训练、回测评估这条链路固化下来让你不用每次从零搭。下面按“先立住原理、再动手复现、最后讲坑”的顺序拆开讲中间给可抄的代码和参数。2. 因子、标签与机器学习选股的基本盘2.1 多因子模型到底在拟合什么传统多因子模型比如 Fama-French 那一套本质是线性回归股票收益对若干因子暴露做回归得到因子收益率和残差。机器学习方法构建多因子选股模型换了个思路——不再假设因子和未来收益是线性关系而是把每个股票在每个调仓日的因子暴露拼成一个特征向量把未来一段时间的收益或收益排名当作标签训练一个回归或分类模型。模型输出的分数就是这只股票的预期强度按分数从高到低选前 N 只。这里有个容易混淆的点因子暴露是“截面”概念同一天不同股票之间可比而标签是“时序”概念需要未来收益。所以数据组织必须是“日期 × 股票 × 因子”的三维面板训练时按日期切分绝不能随机打乱否则未来信息泄漏回测收益会虚高到离谱。常见做法是每个调仓周期比如每月末取一个截面把所有股票的因子和标签堆成一行行样本再按时间顺序滚动训练。2.2 因子池的选取与预处理因子池不用贪多先覆盖几个大类估值EP、BP、动量20日、60日收益、质量ROE、毛利率、波动20日波动率、流动性换手率。每个因子在截面上要做标准化z-score和去极值MAD 或分位数裁剪否则量纲差异会让树模型偏向大数值因子线性模型系数也会被带偏。import pandas as pd import numpy as np def winsorize_mad(series, n5): MAD 去极值中位数 ± n 倍绝对中位差 med series.median() mad (series - med).abs().median() upper med n * 1.4826 * mad lower med - n * 1.4826 * mad return series.clip(lower, upper) def standardize_cross_section(df, factor_cols): 按日期做截面标准化 for col in factor_cols: df[col] df.groupby(date)[col].transform( lambda x: (winsorize_mad(x) - winsorize_mad(x).mean()) / winsorize_mad(x).std() ) return df逻辑说明winsorize_mad用中位数和 MAD 而不是均值和标准差是因为因子分布常有厚尾均值和标准差本身就被极值污染。standardize_cross_section按date分组做 transform保证同一天内标准化不跨日混算。参数n5是经验值n 越小裁剪越狠因子信息损失越多一般 3 到 5 之间试。注意标准化要在去极值之后做顺序反了极值会把标准差撑大正常值被压缩。2.3 标签构造与调仓周期对齐标签是未来收益常见两种未来 20 日收益率或未来 20 日收益的截面排名百分位。前者是回归标签后者是排序标签对极端值更稳健。构造时用shift(-20)取未来收益但要注意停牌和涨跌停——停牌期间没有价格未来收益算不出来这些样本要剔除涨跌停当天买不进卖不出实盘不可交易回测里最好也过滤掉。def build_label(df, price_colclose, horizon20): 构造未来 horizon 日收益率标签 df df.sort_values([stock, date]) df[future_ret] df.groupby(stock)[price_col].transform( lambda x: x.shift(-horizon) / x - 1 ) # 截面排名百分位0 到 1 df[label] df.groupby(date)[future_ret].rank(pctTrue) return df逻辑说明groupby(stock)保证 shift 在每只股票内部做不会跨股票串行。rank(pctTrue)把收益转成 0 到 1 的百分位模型学的是相对排序不是绝对收益这对选股更直接。参数horizon20对应月度调仓如果做周度调仓改成 5。注意shift(-horizon)会产生末尾 horizon 天的 NaN训练前要 dropna但别把整段数据删没了按日期过滤。3. 用 LightGBM 跑通滚动训练与打分3.1 为什么选树模型而不是 LSTM多因子选股的数据量其实不大A 股 5000 只股票10 年数据月度截面也就 60 万个样本左右特征几十个。这个规模下 LSTM 容易过拟合训练慢调参玄学。LightGBM 在表格数据上是稳妥选择训练快、对缺失值友好、特征重要性可解释、不容易被量纲带偏。常见做法是先用 LightGBM 建立基线如果确实有序列依赖比如因子暴露的时序动量再考虑加时序特征或换模型。我一般会先跑 LightGBM看 IC 和分层收益再决定要不要上更复杂的结构。3.2 滚动训练窗口与防泄漏切分滚动训练的核心是用过去 T 个月的数据训练预测下一个月然后窗口向前滑动。绝不能随机划分训练集和测试集因为同一只股票相邻月份的因子高度相关随机划分等于把测试集信息漏进训练集。常见做法是扩展窗口expanding或滚动窗口rolling。扩展窗口从最早数据开始每次加一个月滚动窗口固定长度比如 36 个月丢掉太老的数据。from lightgbm import LGBMRegressor import numpy as np def rolling_train_predict(df, feature_cols, label_collabel, train_months36, retrain_freq1): 滚动训练与预测返回带预测分数的 DataFrame dates sorted(df[date].unique()) preds [] for i in range(train_months, len(dates), retrain_freq): train_dates dates[i - train_months:i] test_date dates[i] train df[df[date].isin(train_dates)].dropna(subsetfeature_cols [label_col]) test df[df[date] test_date].dropna(subsetfeature_cols) if len(train) 1000 or len(test) 0: continue model LGBMRegressor( n_estimators300, learning_rate0.05, max_depth5, num_leaves31, subsample0.8, colsample_bytree0.8, reg_alpha0.1, reg_lambda0.1, random_state42 ) model.fit(train[feature_cols], train[label_col]) test test.copy() test[score] model.predict(test[feature_cols]) preds.append(test[[date, stock, score]]) return pd.concat(preds, ignore_indexTrue)逻辑说明train_months36是滚动窗口长度36 个月约三年覆盖一个完整市场周期的一部分太短模型不稳太长则包含过时市场状态。retrain_freq1表示每月重训计算量大但适应快如果因子更新慢可以改成 3 个月重训一次。max_depth5和num_leaves31是控制复杂度的关键树太深容易记住噪声。subsample和colsample_bytree做行和列采样进一步防过拟合。reg_alpha和reg_lambda是 L1/L2 正则因子多的时候调大一点。3.3 预测分数转持仓与回测口径模型输出的是分数不是持仓。要把分数转成组合常见做法是每个调仓日选分数最高的 N 只等权买入持有到下一个调仓日。回测时要扣交易成本双边千分之几还要考虑涨跌停无法成交。评估指标看 IC信息系数、ICIR、分层收益单调性、最大回撤。def backtest_topn(preds, price_df, top_n50, cost0.002): 按分数选前 top_n 等权持有计算净值 preds preds.sort_values([date, score], ascending[True, False]) holdings preds.groupby(date).head(top_n) # 计算每期收益下期价格 / 本期价格 - 1 price_pivot price_df.pivot(indexdate, columnsstock, valuesclose) dates sorted(holdings[date].unique()) nav [1.0] for i in range(len(dates) - 1): cur, nxt dates[i], dates[i 1] stocks holdings[holdings[date] cur][stock].tolist() ret (price_pivot.loc[nxt, stocks] / price_pivot.loc[cur, stocks] - 1).mean() nav.append(nav[-1] * (1 ret - cost)) return pd.Series(nav, indexdates)逻辑说明head(top_n)取每个日期分数最高的 N 只。收益用下期收盘价除以上期收盘价等权平均。cost0.002是双边交易成本估计实际要按佣金和冲击成本调。注意这里没处理停牌和涨跌停实盘回测要加过滤条件否则收益会偏乐观。nav序列就是净值曲线可以进一步算年化、夏普、最大回撤。4. 避坑与排查多因子机器学习选股最常见的五个翻车点4.1 未来函数藏在预处理里现象回测 IC 高达 0.15实盘一上就亏。原因标准化或去极值用了全样本统计量比如用整段数据的均值和标准差做 z-score测试集的信息漏进了训练集。解决所有预处理必须在训练窗口内 fit再 transform 到测试集。代码里standardize_cross_section按日期分组做天然避免跨期泄漏但如果你先对全样本算均值再标准化就中招了。4.2 标签对齐错位一天现象模型分数和未来收益相关性异常高但换个月份就崩。原因shift(-horizon)的 horizon 和调仓周期没对齐比如用 T 日因子预测 T1 到 T20 收益但实际调仓在 T1 开盘价格用了 T 日收盘差了一天。解决明确调仓时点因子用 T 日收盘后可见的数据标签用 T1 开盘到 T21 开盘的收益价格口径统一。4.3 树模型特征重要性骗人现象某个因子重要性排第一但去掉它模型表现没变化。原因树模型的特征重要性基于分裂次数或增益因子之间高度相关时重要性会被分散或随机分配给其中一个。解决看 permutation importance 或直接做消融实验每次去掉一个因子看 IC 变化。相关性高的因子比如 20 日动量和 60 日动量可以只留一个或者做 PCA 降维。4.4 滚动窗口太短导致模型震荡现象每月重训但每月选出的股票重合度很低换手率爆炸。原因训练窗口太短模型学到的模式不稳定市场风格一变就翻脸。解决窗口拉长到 36 到 48 个月或者用扩展窗口。同时降低模型复杂度max_depth降到 3 到 4num_leaves降到 15 左右。换手率控制在每月 30% 以内比较舒服。4.5 回测没扣停牌和涨跌停现象回测年化 30%实盘年化 5%。原因回测里买入了停牌股或涨停板实盘根本买不进。解决调仓日过滤掉停牌股成交量为 0和涨停股收盘价等于涨停价跌停股也卖不出持仓里如果有跌停要顺延到下一个交易日。这些过滤条件会让回测收益下降但更接近实盘。5. 进阶技巧用 IC 加权融合多模型与因子中性化模型跑通之后想再往上走一步有两个方向值得试。一是多模型融合LightGBM、XGBoost、线性回归各训一个按滚动 IC 加权平均分数。IC 加权比等权更稳因为不同模型在不同市场状态下表现有差异。二是因子中性化把因子对行业和市值做回归取残差作为新因子这样选出来的股票不会过度集中在某个行业或小市值上。中性化之后 IC 可能略降但组合的行业暴露和市值暴露更可控回撤通常更小。def ic_weighted_ensemble(pred_dict, label_df, window12): 按滚动 IC 加权融合多个模型的预测分数 # pred_dict: {model_name: DataFrame(date, stock, score)} # label_df: DataFrame(date, stock, label) merged None for name, pred in pred_dict.items(): tmp pred.merge(label_df, on[date, stock], howinner) tmp[model] name merged pd.concat([merged, tmp], ignore_indexTrue) # 计算每个模型每个日期的截面 IC ic merged.groupby([date, model]).apply( lambda x: x[score].corr(x[label], methodspearman) ).reset_index(nameic) # 滚动平均 IC 作为权重 ic[weight] ic.groupby(model)[ic].transform( lambda x: x.rolling(window, min_periods3).mean() ) ic[weight] ic[weight].clip(lower0) # 归一化权重后加权分数 weight_sum ic.groupby(date)[weight].transform(sum) ic[weight] ic[weight] / weight_sum result merged.merge(ic[[date, model, weight]], on[date, model]) result[weighted_score] result[score] * result[weight] return result.groupby([date, stock])[weighted_score].sum().reset_index()逻辑说明先用 Spearman 秩相关算每个模型每天的 IC因为选股看排序秩相关比皮尔逊更合适。window12是滚动 12 期平均 IC太短权重波动大太长反应慢。clip(lower0)把负 IC 的模型权重压到 0避免拖后腿。最后按日期归一化权重加权求和。这个融合方式比简单等权稳但计算量翻倍适合在单模型调好之后再做。因子中性化的代码不复杂核心是用statsmodels或sklearn对行业哑变量和市值对数做回归取残差。注意行业分类要用调仓日当时可得的分类不能用未来的行业变更信息。中性化之后重新训练模型对比 IC 和分层收益如果 IC 降太多说明因子本身和行业高度绑定中性化可能不划算。我自己踩过最深的坑是标签对齐和预处理泄漏这两个问题让回测和实盘差距能到 20 个点以上。后来养成习惯每次跑完回测先手动检查几个调仓日的因子值和标签确认没有用到未来数据再看收益。这个笨办法比任何复杂验证都管用。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑