☰
机器学习多因子选股模型:从因子暴露到滚动训练与回测的工程链路
2026/10/3 9:50:55 网站建设 项目流程

简介:本资源面向计算机、人工智能及金融工程方向的学生与量化爱好者,提供一套基于机器学习方法构建多因子选股模型的完整项目源码与文档说明,适合作为毕业设计、课程实践或量化策略入门参考。包内共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, n=5): """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,保证同一天内标准化,不跨日混算。参数n=5是经验值,n 越小裁剪越狠,因子信息损失越多;一般 3 到 5 之间试。注意标准化要在去极值之后做,顺序反了极值会把标准差撑大,正常值被压缩。

2.3 标签构造与调仓周期对齐

标签是未来收益,常见两种:未来 20 日收益率,或未来 20 日收益的截面排名百分位。前者是回归标签,后者是排序标签,对极端值更稳健。构造时用shift(-20)取未来收益,但要注意停牌和涨跌停——停牌期间没有价格,未来收益算不出来,这些样本要剔除;涨跌停当天买不进卖不出,实盘不可交易,回测里最好也过滤掉。

def build_label(df, price_col='close', horizon=20): """构造未来 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(pct=True) return df

逻辑说明:groupby('stock')保证 shift 在每只股票内部做,不会跨股票串行。rank(pct=True)把收益转成 0 到 1 的百分位,模型学的是相对排序,不是绝对收益,这对选股更直接。参数horizon=20对应月度调仓,如果做周度调仓改成 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_col='label', train_months=36, retrain_freq=1): """滚动训练与预测,返回带预测分数的 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(subset=feature_cols + [label_col]) test = df[df['date'] == test_date].dropna(subset=feature_cols) if len(train) < 1000 or len(test) == 0: continue model = LGBMRegressor( n_estimators=300, learning_rate=0.05, max_depth=5, num_leaves=31, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=0.1, random_state=42 ) 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_index=True)

逻辑说明:train_months=36是滚动窗口长度,36 个月约三年,覆盖一个完整市场周期的一部分;太短模型不稳,太长则包含过时市场状态。retrain_freq=1表示每月重训,计算量大但适应快;如果因子更新慢,可以改成 3 个月重训一次。max_depth=5和num_leaves=31是控制复杂度的关键,树太深容易记住噪声。subsample和colsample_bytree做行和列采样,进一步防过拟合。reg_alpha和reg_lambda是 L1/L2 正则,因子多的时候调大一点。

3.3 预测分数转持仓与回测口径

模型输出的是分数,不是持仓。要把分数转成组合,常见做法是每个调仓日选分数最高的 N 只,等权买入,持有到下一个调仓日。回测时要扣交易成本,双边千分之几,还要考虑涨跌停无法成交。评估指标看 IC(信息系数)、ICIR、分层收益单调性、最大回撤。

def backtest_topn(preds, price_df, top_n=50, cost=0.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(index='date', columns='stock', values='close') 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, index=dates)

逻辑说明:head(top_n)取每个日期分数最高的 N 只。收益用下期收盘价除以上期收盘价,等权平均。cost=0.002是双边交易成本估计,实际要按佣金和冲击成本调。注意这里没处理停牌和涨跌停,实盘回测要加过滤条件,否则收益会偏乐观。nav序列就是净值曲线,可以进一步算年化、夏普、最大回撤。

4. 避坑与排查:多因子机器学习选股最常见的五个翻车点

4.1 未来函数藏在预处理里

现象:回测 IC 高达 0.15,实盘一上就亏。原因:标准化或去极值用了全样本统计量,比如用整段数据的均值和标准差做 z-score,测试集的信息漏进了训练集。解决:所有预处理必须在训练窗口内 fit,再 transform 到测试集。代码里standardize_cross_section按日期分组做,天然避免跨期泄漏,但如果你先对全样本算均值再标准化,就中招了。

4.2 标签对齐错位一天

现象:模型分数和未来收益相关性异常高,但换个月份就崩。原因:shift(-horizon)的 horizon 和调仓周期没对齐,比如用 T 日因子预测 T+1 到 T+20 收益,但实际调仓在 T+1 开盘,价格用了 T 日收盘,差了一天。解决:明确调仓时点,因子用 T 日收盘后可见的数据,标签用 T+1 开盘到 T+21 开盘的收益,价格口径统一。

4.3 树模型特征重要性骗人

现象:某个因子重要性排第一,但去掉它模型表现没变化。原因:树模型的特征重要性基于分裂次数或增益,因子之间高度相关时,重要性会被分散或随机分配给其中一个。解决:看 permutation importance 或直接做消融实验,每次去掉一个因子看 IC 变化。相关性高的因子(比如 20 日动量和 60 日动量)可以只留一个,或者做 PCA 降维。

4.4 滚动窗口太短导致模型震荡

现象:每月重训,但每月选出的股票重合度很低,换手率爆炸。原因:训练窗口太短,模型学到的模式不稳定,市场风格一变就翻脸。解决:窗口拉长到 36 到 48 个月,或者用扩展窗口。同时降低模型复杂度,max_depth降到 3 到 4,num_leaves降到 15 左右。换手率控制在每月 30% 以内比较舒服。

4.5 回测没扣停牌和涨跌停

现象:回测年化 30%,实盘年化 5%。原因:回测里买入了停牌股或涨停板,实盘根本买不进。解决:调仓日过滤掉停牌股(成交量为 0)和涨停股(收盘价等于涨停价),跌停股也卖不出,持仓里如果有跌停要顺延到下一个交易日。这些过滤条件会让回测收益下降,但更接近实盘。

5. 进阶技巧:用 IC 加权融合多模型与因子中性化

模型跑通之后,想再往上走一步,有两个方向值得试。一是多模型融合:LightGBM、XGBoost、线性回归各训一个,按滚动 IC 加权平均分数。IC 加权比等权更稳,因为不同模型在不同市场状态下表现有差异。二是因子中性化:把因子对行业和市值做回归,取残差作为新因子,这样选出来的股票不会过度集中在某个行业或小市值上。中性化之后 IC 可能略降,但组合的行业暴露和市值暴露更可控,回撤通常更小。

def ic_weighted_ensemble(pred_dict, label_df, window=12): """按滚动 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'], how='inner') tmp['model'] = name merged = pd.concat([merged, tmp], ignore_index=True) # 计算每个模型每个日期的截面 IC ic = merged.groupby(['date', 'model']).apply( lambda x: x['score'].corr(x['label'], method='spearman') ).reset_index(name='ic') # 滚动平均 IC 作为权重 ic['weight'] = ic.groupby('model')['ic'].transform( lambda x: x.rolling(window, min_periods=3).mean() ) ic['weight'] = ic['weight'].clip(lower=0) # 归一化权重后加权分数 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,因为选股看排序,秩相关比皮尔逊更合适。window=12是滚动 12 期平均 IC,太短权重波动大,太长反应慢。clip(lower=0)把负 IC 的模型权重压到 0,避免拖后腿。最后按日期归一化权重,加权求和。这个融合方式比简单等权稳,但计算量翻倍,适合在单模型调好之后再做。

因子中性化的代码不复杂,核心是用statsmodels或sklearn对行业哑变量和市值对数做回归,取残差。注意行业分类要用调仓日当时可得的分类,不能用未来的行业变更信息。中性化之后重新训练模型,对比 IC 和分层收益,如果 IC 降太多说明因子本身和行业高度绑定,中性化可能不划算。

我自己踩过最深的坑是标签对齐和预处理泄漏,这两个问题让回测和实盘差距能到 20 个点以上。后来养成习惯:每次跑完回测,先手动检查几个调仓日的因子值和标签,确认没有用到未来数据,再看收益。这个笨办法比任何复杂验证都管用。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询