☰
机器学习多因子选股全流程:从因子处理到LightGBM模型落地与避坑
2026/10/14 5:48:36 网站建设 项目流程

简介:一份面向量化研究初学者与金融科技专业学生的多因子选股模型实践资源,以机器学习方法为主线,覆盖单因子测试、因子共线性分析、特征标签构建、多模型回测与择时风控流程。资源内实证结果显示,最优随机森林模型累计收益约60%,经择时策略控制后最大回撤约9%,夏普率约0.9,可作为毕业设计或量化课程项目的参考方案。资源包共38个文件,主体为15个Python脚本,包含因子搜索、单因子测试、随机森林、SVR、LSTM、XGBoost、AdaBoost等模型实现;另附10份PDF研究资料、7张因子分类图、数据探索ipynb笔记及README文档,整体约14.71MB,便于按模块对照学习。此外,该资源已获得468人学习使用。读者可借助源码与文档,完整复现从因子筛选、模型训练到回测分析的全过程,理解机器学习在量化选股中的实际应用方法,也能为相关赛题或项目提供可直接参考的代码框架与思路。

1. 机器学习多因子模型:为什么传统打分法开始不够用

做股票多因子选股的人,大多经历过这个阶段:先用IC加权、IR加权把几十个因子线性打分排序,调完一版觉得"已经到头了",但实盘一跑,超额收益还是在衰减。问题往往不在因子库,而在组合方式——因子的非线性交互、不同市场状态下的动态权重,线性叠加表达不了。基于机器学习方法构建多因子选股模型,本质上就是把"人手工定权重"换成"模型自动学权重和交互关系",它解决的是传统多因子框架的表达能力上限问题,而不是因子本身的有效性问题。这篇笔记面向正在做量化投研、想把机器学习落到选股流程里的从业者,讲清楚从原始行情到预测值、再到组合构建的完整管线,以及最容易翻车的几个细节。

2. 因子数据准备:从行情表到训练样本的完整管线

2.1 因子池怎么搭:基本面、量价、预期类因子如何取舍

机器学习的输入是因子矩阵,因子池的构建直接决定模型的上限。实践中常见做法是把因子分成三个来源:基本面因子包括营收增速、ROE、毛利率变化等,更新频率低但信息独立;量价因子包括动量、反转、波动率、换手率、资金流等,更新频率高、截面差异大,是机器学习模型里贡献预测力最多的部分;预期类因子包括分析师评级调整、业绩预告方向等,数据源贵且覆盖度参差不齐。我一般会按"数据可得性优先、逻辑独立性优先"来建池,初期先用量价因子加核心基本面因子,保证每个因子在截面上至少有多年历史、覆盖超过八成股票,再逐步引入预期类因子。因子数量控制在20到50个之间比较合理,太少模型学不到交互,太多则容易引入噪音和过拟合。

因子的暴露度要关注行业和市值偏差。比如换手率因子天然和小市值股票相关性高,直接进模型会导致模型隐式学会"买小市值"而不是"买高换手",这在风格切换时会吃大亏。处理办法是在标准化之前对因子做行业市值中性化,或者至少把行业、市值本身作为特征喂给模型,让它自己学这个偏好。中性化的实现不复杂,用横截面回归把因子对行业哑变量和对数市值回归,取残差作为中性化后的因子值即可。

2.2 标签构造:未来N日收益做标签的三种常用口径

监督学习必须有标签,多因子选股里最直接的标签就是"未来N日的收益"。N的取值要和调仓频率匹配:日频调仓用未来1日或5日,周频调仓用未来5日或10日,月频调仓用未来20日。较短的标签能产生更多训练样本,但噪音也更大;较长标签信号更平稳,但样本量显著减少。实际操作中我会同时构造3个标签,分别做3个模型,最终用它们在调仓日的预测值做加权集成,避免单一标签窗口带来的偏颇。

第一种口径是简单未来收益,代码里最常见,但要注意极端值对回归模型的影响。第二种口径是未来收益的截面排名,也就是把T日所有股票的标签都转成0到1之间的百分位排名,这样模型学的是"排序"而不是"绝对收益",对市场整体涨跌不敏感,也更贴近选股的真实目标。第三种口径是风险调整后收益,用未来N日收益除以同期波动率,适合对回撤敏感的稳健型组合。

import pandas as pd import numpy as np def make_labels(df, close_col='close', window=5, method='rank'): """ df: 长表,包含股票代码、日期、收盘价 window: 标签窗口 method: raw / rank / risk_adj """ df = df.sort_values(['stock_code', 'trade_date']).copy() # 未来window日收益,注意shift负数取未来值 df['future_return'] = df.groupby('stock_code')[close_col].shift(-window) / df[close_col] - 1.0 if method == 'rank': # 按交易日期分组,对截面做排名 df['label'] = df.groupby('trade_date')['future_return'].rank(pct=True) elif method == 'risk_adj': # 用过去20日收益波动率做分母 df['hist_vol'] = df.groupby('stock_code')[close_col].transform( lambda x: x.pct_change().rolling(20).std() ) df['label'] = df['future_return'] / df['hist_vol'] else: df['label'] = df['future_return'] # 未来收益的最后一期是NaN,直接丢弃 df = df.dropna(subset=['label']) return df

shfit(-window) 取的是未来数据,这正是标签的定义方式;但如果整个数据集里混合了不同日期,groupby排序必须正确,否则标签会串到别的股票上。rank方法输出接近均匀分布,回归模型拟合起来更平稳,不会因为某一个月全市场暴涨导致标签整体偏移。risk_adj方法要额外注意:hist_vol 计算时 rolling(20) 用的是历史窗口,不会引入未来信息,这是安全的。

2.3 截面标准化与去极值:机器学习输入的前置处理

因子原始值的量纲差异很大,市盈率是几十到几百,换手率是0.01到0.3,直接灌进机器学习模型会让树模型的分裂点偏向量纲大的特征,虽然树模型对单调变换不敏感,但正则化和特征重要性的解释性会变差。统一做法是:每个交易日截面上,先对因子值做去极值处理,再做z-score标准化。

去极值常用MAD法(绝对中位差法)或分位数截断法。MAD法的逻辑是:计算截面中位数,再计算每个值到中位数的绝对偏差的中位数,把偏离超过若干倍MAD的值拉回边界,而不是直接删除。这样保留了样本数量,也抑制了极端值的影响。分位数截断法更简单粗暴:把上下1%或5%分位以外的值统一赋值为分位数值。

def winsorize_and_standardize(df, factor_cols, group_col='trade_date', method='mad', std_method='zscore'): """ 逐截面去极值 + 标准化 注意:这里展示的是全样本处理,训练时应在训练集内计算统计量 """ df = df.copy() for col in factor_cols: if method == 'mad': # 按交易日分组计算MAD去极值 def mad_clip(x): median = x.median() mad = (x - median).abs().median() upper = median + 5 * max(mad, 1e-8) lower = median - 5 * max(mad, 1e-8) return x.clip(lower, upper) df[col] = df.groupby(group_col)[col].transform(mad_clip) else: # 分位数截断:上下1% def quantile_clip(x): lower = x.quantile(0.01) upper = x.quantile(0.99) return x.clip(lower, upper) df[col] = df.groupby(group_col)[col].transform(quantile_clip) # z-score标准化 df[col] = df.groupby(group_col)[col].transform( lambda x: (x - x.mean()) / (x.std() + 1e-8) ) return df

MAD法里 5 倍是一个经验阈值,数据越干净可以放宽到 6 到 8 倍,数据源有异常报价时建议收紧到 3 到 4 倍。去极值必须在标准化之前做,否则极端值会拉大标准差,导致标准化后的值仍然集中在两端。这里最关键的是:所有统计量必须在训练集上计算,再应用到验证集和测试集。如果直接对全样本做 groupby transform,验证集数据的信息已经通过均值和方差渗透进了训练过程,这就是典型的未来函数泄漏,后面避坑章节再展开讲。

3. 模型训练与参数调优:为什么LightGBM是默认首选

3.1 模型选型逻辑:把问题拆成"排序问题"而非"收益预测"

选模型之前先想清楚一个底层问题:多因子选股模型到底在学什么。我的答案是排序。我们最终关心的是预测值排名靠前的股票在未来一段时间的表现是否优于排名靠后的股票,而不是预测值和真实收益的绝对误差有多小。想明白这一点,模型选型的思路就清晰了:不一定要用复杂的深度模型,梯度提升树族(LightGBM、XGBoost、CatBoost)在这个问题上性价比最高,它们既自动处理因子间的非线性交互,又自带特征重要性输出,能反哺因子筛选。逻辑回归和线性回归适合做基线,能验证因子线性部分还有多少预测力;深度模型如MLP、Transformer需要大量数据和较长的调参周期,在因子数量不过百、样本量不过百万的场景下通常并不比梯度提升树强。

LightGBM作为默认首选,还有一个工程化理由:支持 categorical 特征直接传入,比如行业代码,不用做独热编码;训练速度快,支持 GPU 加速;原生接口对缺失值有专门处理路径,量化数据里停牌导致的缺失值很常见,这个能力很实用。

3.2 训练集/验证集划分:时序分组与防止未来函数

截面数据的训练集和验证集划分不能随机抽样。随机抽样会把同一时期的数据分散到训练和验证里,导致验证集"偷看"了同期的市场风格信息,评估结果虚高。正确的做法是按时序切分:比如用 2020 年 1 月到 2023 年 12 月做训练,2024 年全年做验证,2025 年至今做样本外测试。更严谨一点可以用滚动窗口的 walk-forward 方式,但这个放到最后一章讲。

划分时还注意一点:如果有停牌股票,停牌期间没有行情数据,这些股票在标签窗口内可能缺失未来收益。简单丢弃少数可以,但停牌率高的时候,建议单独加一个"是否停牌"的二值特征,让模型学着处理这种缺失状态。

from sklearn.model_selection import TimeSeriesSplit import lightgbm as lgb # 按时序划分,不shuffle train_end = '2023-12-31' valid_end = '2024-12-31' train_data = df[df['trade_date'] <= train_end] valid_data = df[(df['trade_date'] > train_end) & (df['trade_date'] <= valid_end)] test_data = df[df['trade_date'] > valid_end] feature_cols = [c for c in df.columns if c.startswith('factor_')] + ['industry_code', 'log_mv'] label_col = 'label' # 用LightGBM原生数据集封装,categorical_feature指定分类特征 train_set = lgb.Dataset(train_data[feature_cols], label=train_data[label_col], categorical_feature=['industry_code']) valid_set = lgb.Dataset(valid_data[feature_cols], label=valid_data[label_col], categorical_feature=['industry_code'])

TimeSeriesSplit 在交叉验证场景里很有用,但如果样本的时间跨度比较长,直接用固定日期切分更直观,也更容易做滚动验证。注意 categorical_feature 里传入的行业代码必须是整数编码,不能是字符串,否则 LightGBM 会报类型错误。

3.3 LightGBM核心参数:能直接抄作业的配置

LightGBM 参数不是越多越好,实际预测力由一组核心参数决定。我的常用基线配置如下:objective 用 regression,因为标签是连续值;learning_rate 设 0.03 到 0.05,配合较大的 n_estimators;num_leaves 是树模型复杂度最重要的控制旋钮,对因子数据建议控制在 16 到 31 之间,叶子太多会直接过拟合噪音因子;min_child_samples 设 50 到 100,相当于限定每个叶子上的样本数下限,是抗过拟合最有效的参数之一。

subsample 和 colsample_bytree 建议都开,取 0.8 左右,增加随机性可以增强泛化。正则化方面,reg_alpha(L1)和 reg_lambda(L2)建议从较小的值开始搜索,比如 0.1 到 1.0。早停轮数设 100,以验证集上的 l2 损失作为早停依据。

params = { 'objective': 'regression', 'metric': 'l2', 'learning_rate': 0.03, 'num_leaves': 31, 'max_depth': -1, 'min_child_samples': 80, 'subsample': 0.8, 'subsample_freq': 1, 'colsample_bytree': 0.8, 'reg_alpha': 0.5, 'reg_lambda': 1.0, 'n_estimators': 2000, 'verbose': -1, 'seed': 42 } model = lgb.train( params, train_set, num_boost_round=2000, valid_sets=[valid_set], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)] )

n_estimators 设为 2000 但配合早停,实际上会在一两百棵树附近停止,这是正确的使用方式。手动调参时先固定 learning_rate 和 n_estimators 的组合,调 num_leaves 和 min_child_samples,再调采样率,最后调正则化,顺序不要颠倒。还有一个容易忽略的点:seed 必须固定,否则同一份代码跑两次结果不同,后面做参数实验时没法对比。

4. 从模型输出到组合构建:预测值如何变成仓位

4.1 预测值的排序意义:把回归输出当打分用

模型训练完成后,对最新一个交易日的所有股票输出预测值,这个预测值的绝对大小没有意义,有意义的是截面上的排序。把回归输出当作打分来看,配合一个明确的调仓频率使用。常见做法是:每个调仓日(比如每周五收盘后),用最新因子值生成预测,取排名前 5% 或前 10% 的股票等权买入,持有到下一个调仓日再重新计算。

这个环节最常见的错误是人为主观干预排名结果——"这个预测值太高的股票我不敢买",一旦干预,整个模型验证链条就断了。宁可相信模型并用回测去修正,也不要凭感觉改排名。如果某些预测结果确实和业务常识冲突,把冲突当作信号去检查因子数据是否有异常,而不是直接删掉股票。

4.2 分层回测与IC分析:验证模型是否真的有效

模型好不好,不是看训练集损失有多低,而是看两个指标:分层收益的单调性和预测值与真实值的秩相关(IC)。分层回测的逻辑是:每个调仓日把股票按预测值分成 5 组或 10 组,计算每一组在下一持有期的平均收益。如果从第 1 层到第 10 层的收益呈现单调递增或递减,说明模型有真实的排序能力;如果中间层乱跳、只有极端两层出收益,说明模型大概率只在边缘样本上有效。

IC 分析更细致一些,计算每个截面上预测值与未来收益的 Spearman 秩相关,再对时间序列求均值和标准差,得到 IC 均值和 ICIR。IC 均值在 0.03 到 0.05 之间是量化选股里一个不错的水平,ICIR(IC均值除以IC标准差)大于 0.3 说明稳定性可接受。

def layer_backtest(pred_df, return_df, n_layers=5): """ pred_df: pivot表,行=日期,列=股票代码,值=预测值 return_df: 同期未来收益pivot表 """ results = {} for dt in pred_df.index: pred_row = pred_df.loc[dt].dropna() ret_row = return_df.loc[dt].reindex(pred_row.index) # 按预测值排序后分n层 ranks = pred_row.rank(pct=True) layer = (ranks * n_layers).astype(int).clip(0, n_layers - 1) for i in range(n_layers): mask = (layer == i) results.setdefault(i, []).append(ret_row[mask].mean()) # 汇总每层收益 summary = pd.Series({k: np.nanmean(v) for k, v in results.items()}) # 计算多空收益(最顶层 - 最底层) spread = summary[n_layers - 1] - summary[0] return summary, spread

分组数越大,每组股票越少,收益均值受个别股票影响越大。建议当股票池在 1000 只以上时用 10 层,500 只左右时用 5 层。分层回测里使用的收益必须是调仓日之后实际可获得的收益,即信号在 T 日收盘生成,T+1 日起持有,收益从 T+1 到 T+1+N。直接拿 T 日到 T+N 的收益会导致前视偏差,这一点要格外小心。

5. 多因子模型落地避坑:数据泄漏、过拟合与换手率失控

5.1 未来函数泄漏:标准化和去极值必须只在训练集内做

现象:训练集 IC 很高,验证集 IC 也还行,但样本外实盘预测一塌糊涂。原因:预处理时对全样本计算了均值、方差、中位数、分位数,验证集和测试集的信息在预处理阶段就渗入了训练过程。解决:把预处理流程封装成"先 fit 训练集、再 transform 全部数据集"的标准 sklearn 模式。具体来说,去极值的 MAD 阈值和标准化的均值和方差都只从训练集样本中计算,然后把这些统计量固定下来,应用到后续所有数据上。注意这里训练集也应该逐截面计算统计量,但只基于训练集时间范围内的数据。

class FactorProcessor: def __init__(self, clip_std=5): self.clip_std = clip_std self.stats_ = {} def fit(self, df, factor_cols, group_col='trade_date'): # 只从训练集数据中学习每个因子的MAD和均值方差 for col in factor_cols: median = df.groupby(group_col)[col].transform('median') mad = (df[col] - median).abs().groupby(df[group_col]).transform('median') mean = df.groupby(group_col)[col].transform('mean') std = df.groupby(group_col)[col].transform('std') self.stats_[col] = { 'median': median, 'mad': mad, 'mean': mean, 'std': std } return self def transform(self, df, factor_cols, group_col='trade_date'): # 用训练集学到的统计量做变换 df = df.copy() for col in factor_cols: s = self.stats_[col] # 依据训练集统计量的MAD去极值 df[col] = df[col].clip( s['median'][df[group_col]] - self.clip_std * s['mad'][df[group_col]], s['median'][df[group_col]] + self.clip_std * s['mad'][df[group_col]] ) # 依据训练集统计量的z-score标准化 df[col] = (df[col] - s['mean'][df[group_col]]) / (s['std'][df[group_col]] + 1e-8) return df

上面对 transform 的写法简化了,实际实现可以用 groupby transform 时生成的索引对齐统计量。但核心思想要记住:预处理统计量像模型参数一样,只能在训练集上估计。很多时候回测异常的好,第一件事就该检查是不是这里出了问题。

5.2 过拟合信号:训练集IC远高于验证集时要当心

现象:训练集上 IC 均值 0.08,验证集 IC 均值只有 0.02,分层回测里训练集单调性漂亮得不行,验证集却很杂乱。原因:模型把训练集里的噪音当作信号记住了,典型的过拟合。解决:首先降低 num_leaves,从 31 降到 15 或 7,看验证集损失是否反而下降;其次调大 min_child_samples,从 80 调到 200 甚至 500;最后加强正则化,reg_alpha 和 reg_lambda 往上加。如果调完验证集 IC 仍然上不来,说明因子池本身在验证集时间段内失效了,不是调参能解决的,需要回头检视因子逻辑,而不是继续堆模型复杂度。

另一个有效手段是直接用验证集 IC 作为早停的监控指标,而不是用 l2 损失。LightGBM 支持自定义 feval 函数,写一个计算验证集 Spearman 相关性的函数,早停时机就变成了"验证集排序能力不再提升",更贴合选股目标。

5.3 换手率失控:预测分布漂移导致的频繁调仓

现象:模型回测收益不错,但组合调仓换手率超过单边 30%,加入交易成本后收益被吃掉一大半。原因:有几种——因子本身噪音大导致预测排名每期跳动;模型过拟合导致对微小因子变化过度反应;或者是没有做调仓缓冲。解决:常见做法是加"换手率惩罚"或者"预测值平滑"。预测值平滑最直接:取当前预测值与上一期预测值的加权平均,比如新预测值权重 0.7,上一期预测值 0.3,能显著降低排名翻转的概率。还可以在组合构建时增加一个"最少持有期"约束,即新入选的股票至少要持有两期才能被替换,人为降低调仓频率。

换手率必须在回测里显式计算,不能只盯着净值曲线。计算方式很简单:每一期组合的持仓列表和上一期做对比,用对称换手率公式(新买入金额 + 新卖出金额)除以 2 倍组合市值。如果回测框架里没有换手率指标,这个回测是残缺的。

5.4 因子失效检测:截面IC衰减是常见的退出信号

现象:模型在某一段市场环境下表现好,但一段时间后 IC 持续走低,甚至出现负值。原因:因子逻辑失效或被市场充分定价,这是必然会发生的事。解决:建立因子层面的监控体系,每个因子滚动 60 个交易日的 IC 均值和 t 值每日更新,一旦 IC 均值持续低于 0.02 且 t 值不显著,就考虑从因子池移除。机器学习模型的预测贡献也可以做同样监控:用特征重要性作为权重,计算滚动窗口内模型预测值与各因子值的相关性,预测贡献度连续衰减就要触发重新训练。

我习惯的做法是维护一个简单的"模型体检报告",每次调仓日输出:截面 IC、换手率、持仓数量、行业偏离度、因子重要性 Top10 列表。这些记录攒多了以后,能明显看出哪些因子在什么市场状态下失效,也能为后续因子迭代提供依据。

6. 进阶:滚动训练、特征重要性与模型体检

前面的框架是"一次性训练、多次预测",这在市场风格变化较慢的时段还够用。要让模型跟上市场的变化,车常见做法是滚动训练。我一般设定一个训练窗口(比如过去 500 个交易日),每隔 20 个交易日从头训练一次模型,推理时只用最近训练的模型。这样既控制了训练频率,也让模型能及时适应因子有效性的迁移。滚动训练要配合上一章讲的严格时序划分:每次训练都在一个固定的验证集上检查早停,而不是每次重新切验证集,否则不同日期的模型没有可比性。

特征重要性的用途经常被低估。LightGBM 训练完直接输出 split 重要性和 gain 重要性,前者看的是特征被用作分裂点的次数,后者看的是特征带来的信息增益总和。实践中 gain 重要性更有参考价值,因为它衡量的是预测能力的实际贡献。每轮滚动训练后把 gain 重要性排名前 20 的特征记录下来,跟踪它们的排名变化:如果某个量价因子在多次滚动训练中排名持续下滑,说明它的预测力在衰减,可以考虑减仓或替换;如果某个新纳入的特征排名快速上升,说明它适配了当前市场状态,值得加大权重。

模型体检的另一个有效动作是敏感性测试。固定模型参数和数据管线不变,只改变标签窗口(比如从 5 日改成 10 日、20 日),观察分层收益和 IC 的变化是否符合业务逻辑。如果 10 日标签的 IC 合理高于 5 日标签,说明模型捕捉的是中期趋势信号;如果两者差异很小,说明模型学到的更多是短期反转。这种判断对参数方向的选择很有帮助。还可以做调仓频率敏感性测试:同一模型,分别按 1 日、5 日、10 日调仓频率做回测,比较扣除手续费后的净收益,确定在容量和收益之间的平衡点。

我自己的习惯是,每个模型都留存一份训练日志:数据版本、因子列表、参数组合、验证集 IC、换手率、当时的市场环境描述。这样半年后再回来,能清楚地知道这个模型是在什么假设下构建的,不至于面对预测失效时无从下手。最后补一句:机器学习做多因子选股不是一个一次性的建模工程,它更像是一套需要持续维护的预测系统——定期重新训练、监控因子衰减、及时替换失效因子,这些动作比模型结构本身更能决定长期表现。希望帮到你。

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

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

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

立即咨询