量化投资这几年已经从机构策略室慢慢扩散到了个人开发者圈子里,AI 选股成了很多程序员跨界做投资研究的第一站。我最近在刷 Datawhale 开源量化投资学习指南,第十期正好是“基于 LightGBM 的量化选股”,这一期内容很接地气,把机器学习模型怎么直接应用在选股这件事上拆得很清楚。要说实话,我一开始也迷信过神经网络,但真正上手后才发现,在“表格型特征 + 样本噪声大 + 解释性要求高”的量化场景里,LightGBM 反而更容易出效果:训练快、能处理缺失值、可以直接做排序学习,而且带着特征重要性让我能看清模型到底在“看什么”。
这篇文章不是简单复刻指南原文,而是把 Datawhale 这一期的核心思路和我自己实践过的完整方案整合到一块,从模型选型、排序目标、特征工程、数据切分、代码实现到回测避坑,一次讲透。适合已经会点 Python 和 pandas、想系统了解机器学习选股的朋友,文章里出现的代码可以直接改改数据源和字段名后跑起来。
1. 整体设计思路:为什么是 LightGBM 而不是别的模型
1.1 量化选股的本质,是一场“排序比赛”
量化选股的核心,是在每个调仓周期内,从全市场几千只股票里挑出大概率跑赢市场的品种。注意这里的关键词是“跑赢”,不是“预测准”,这意味着我们并不需要模型预测出某只股票明天涨 2.3% 还是 3.1%,只需要它能分清楚相对强弱关系,让排在前面的一批股票整体强于排在后面的一批。
这个区别在建模时非常重要。很多新手一上来就把选股当成回归问题:训练模型拟合未来收益率,然后每天按预测值排序。这样做的问题在于,收益率标签本身噪声很大,极端值和停牌数据会干扰连续数值拟合,模型稍有偏差,排序结果就面目全非。如果把问题改成排序任务,让模型用 pairwise 或 listwise 的损失函数去感知“谁应该排在谁前面”,对噪声的容忍度会明显提高。
LightGBM 原生支持排序目标参数objective='lambdarank',这是它特别适合选股的核心原因。排序学习本来就是为搜索和推荐设计的,而选股本质上也是在“全市场股票”这个列表里做排序推荐,两者问题的结构是一致的。
1.2 为什么不用 XGBoost 或神经网络?
我之前也写过 XGBoost 版本的选股模型,整体流程差不多,但 LightGBM 在量化场景里有几个更舒服的优势:
- 训练速度更快。LightGBM 使用基于直方图的决策树算法,在几百万行的特征数据上迭代极快,调参实验的成本低很多,一天能跑几十组参数组合。
- 对特征尺度不敏感。收益率、市值、财务比率这些字段量纲差异非常大,GBDT 系列模型不像神经网络那样需要严格做标准化,数据管道能省掉一个环节。
- 原生支持类别特征。行业代码、是否 ST、是否次新股这类离散字段可以直接喂给模型,不用做 one-hot 编码,否则维度会被撑爆,训练速度和泛化能力都会受影响。
神经网络当然也有它的价值,比如对复杂非线性关系的表征能力更强,但金融数据本身信噪比很低,模型越复杂越容易把噪声当规律。我的习惯是把 LightGBM 当主力模型,先把选股流程跑通,再用神经网络或者其他模型做交叉验证,而不是一上来就上深度学习。
1.3 完整流程分四段:数据、特征、标签、训练
整个量化选股项目如果拆开看,大致是四个环节:数据采集、特征构建、标签定义、模型训练与排序输出。很多人一提起机器学习选股就盯着模型精度,实际上真正决定成败的是前三个环节,模型反而是最成熟、最不需要“折腾”的部分。
我自己的顺序是:先把数据统一成“股票-日期”的宽表结构,然后构建多头特征集,再严格按可交易时点定义标签,最后才把数据交到 LightGBM 里做排序训练。这四个环节里,数据对齐和标签定义最容易被忽视,但它们又恰恰是“回测很好、实盘失效”的头号凶手,后面我会重点展开。
2. 核心概念拆解:Rank 目标、树个数与关键参数
2.1 用 lambdarank 来做选股而不是回归
LightGBM 默认的objective是回归,但这在选股场景里并不是最优解。我建议直接使用objective='lambdarank',这是 LambdaMART 算法的实现,它把梯度提升树和 Learning to Rank 思路结合在一起。算法会对样本两两配对,如果一对样本的排序错了,就给一个更高的梯度惩罚,让模型逐步学会把高收益标签的股票推到更靠前的位置。
落到量化选股上,我们需要做一次概念映射:每个交易日的全体股票是一个 query;股票相当于待排序的文档;未来一段时间收益的排名可以作为相关性标签;模型最终输出一个分数,按照分数排序后,分数最高的那一批股票就是我们想买入的候选池。
这样做的好处是,训练目标与实际交易逻辑保持一致。传统回归模型关心的是“收益率预测值是多少”,但最终真正使用的只是排序结果,中间可能因为某个极值样本就把排序带偏。排序学习从源头上绕开了这个问题。
2.2 ndcg 与 xendcg:排序效果应该看什么
排序学习的评估指标最常用的是 NDCG,它在计算时会根据排序位置给不同权重:排在第一位的股票被选中概率显然最高,所以它的贡献应该大于排在第二十位的。NDCG 对位置权重做了归一化处理,最终只能得到 0 到 1 之间的数值,越接近 1 说明排序越接近理想状态。
在 LightGBM 的排序目标里,有些人会用到名为 xendcg 的损失函数,它核心思想是更专注于列表头部位置的排序质量,关注 top 位置的命中精度。实际使用的时候,xendcg 对样本分组和 label 的要求比较严格,一旦设置不当,训练可能直接报错。
我个人的建议是:主线项目先老老实实用objective='lambdarank'加metric='ndcg',把整体流程跑通,确认特征和标签都没有问题后,再考虑测试 xendcg 这类更像“top 10 选股准确率”的损失目标。选股最终只关心头部位置,所以这类损失函数确实有研究价值,但没必要在项目初期就陷入损失函数的细节里。
2.3 树个数、学习率、叶子数,先盯住这几个旋钮
很多朋友搜索“lightgbm 的树个数”,说明大家建模时最担心的就是模型复杂度。树个数也就是n_estimators或num_boost_round,我一般不从固定值开始,而是把它设得足够大,然后配合 early stopping 让模型自己决定什么时机停止。常见做法是设 1000 到 2000 轮,学习率设为 0.05 左右;如果学习率更低,树个数需要相应增加,否则模型容易欠拟合。
几个关键超参数的实践经验大概是这样的:
learning_rate:推荐从 0.05 起步,训练速度允许时可以降到 0.02,收益是精度小幅上升,代价是训练时间变长和过拟合风险增加。num_leaves:LightGBM 的核心复杂度因子,默认 31。如果数据量大、特征多,可以往上调到 64;如果数据只有几万行,建议降到 15 或 24。min_data_in_leaf:金融数据噪声大,这个值我一般设置成 50 到 200,防止模型用少数几个样本学出过于小众的模式。feature_fraction和bagging_fraction:分别对应特征采样和样本采样,每次迭代随机抽一部分,类似随机森林的做法,实测对防过拟合非常有效,常见组合是 0.8 和 0.8。lambda_l1和lambda_l2:正则化系数,建议先给lambda_l2设一个 1.0 左右的基底,遇到明显过拟合再往上加。
另外要提醒一件事:每次训练前都要固定随机种子,也就是random_state=42这种设置。树模型虽然不像深度网络那样随机性极强,但如果不固定种子,验证集评估指标会有抖动,你很难判断一次调参到底是真有效还是纯属随机波动。
3. 特征工程与数据切分细节
3.1 从哪找数据,传统 5 类指标怎么落库
数据是量化项目的底座,常用数据获取方式有 akshare、tushare、baostock 等开源或低成本数据接口。这些数据源能覆盖日线行情、基本面财务数据、行业分类、指数成分等常见字段,对个人研究完全够用,不需要一开始就去买昂贵的机构级数据库。
我记得热搜里有一条是“通达信量化选股公式实战:如何用 5 个关键指标筛选潜力股”。实际上很多人就是通过通达信这类行情软件来手写选股公式的,比如要求 PE 低于某个值、ROE 高于某个值、均线呈多头排列。这种做法的本质是人工构造一棵规则决策树,只有几条硬性条件,简单直接,但无法穷尽股票上涨背后的复杂非线性关系。
我们在 LightGBM 项目里,完全可以借鉴这种思路,把通达信公式里常用的那几类指标扩展成特征宽表:估值类用 PE、PB、PS;盈利类用 ROE、毛利率、净利率;成长类用营收同比增速、净利润同比增速;交易行为类用换手率、量比;趋势类用 20 日均线偏离度、MACD 柱状图变化等。
通达信公式帮我们解决了“特征从哪来”的问题,LightGBM 则更像一个自动优化版决策树,它不要求你手工写寻优条件,而是从几十个甚至上百个特征中去寻找最佳分裂方式和特征组合。传统公式仍然有存在意义,适合做第一道过滤,把明显有退市风险或基本面恶化的股票剔掉,LightGBM 再在剩余股票池里做精细化排序。
3.2 标签怎么打才不“穿”未来
标签定义和特征对齐是整个数据管道中最容易出错的地方。第一次做的时候,我直接用了当天收益率当标签,逻辑听起来没问题,但实际操作中就会踩坑:假设你准备在 T 日收盘后根据模型决定 T+1 日的持仓,那么标签应该代表“T+1 日买入并持有到未来某一天能获得多少收益”,而不是拿 T 日当天的收益来训练。
正确的标签构造方式应该是:特征使用 T 日及之前的数据,目标是未来持有 N 个交易日的收益。以 20 日持仓为例,标签等于T+20日收盘价 / T+1日开盘价 - 1,这里开盘买入、收盘卖出都只是近似,实际可以用后复权价做更精确的处理。
如果标签定义里混入了未来信息,模型在回测里会表现得异常优秀,但一旦到实盘就会彻底失效。这种问题专业叫“未来函数”,也叫前视偏差,是量化项目里影响最隐蔽、破坏力最强的一种错误。回测收益曲线越漂亮,越要回头检查特征和标签的时间对齐关系。
3.3 训练集、验证集、滚动窗口切分
量化数据是典型的时间序列,训练集和验证集不能随机切分。如果随机切分,相当于让模型提前看到了未来某个时间段的数据,验证集就不再具备样本外验证的意义。
我在实践中的做法是:把历史数据按时间切成三段,比如 2021 年到 2023 年做训练,2024 年做验证,最近三个月做最终测试。验证集只用来做 early stopping 和调参,测试集只做最终一次评估,绝不在测试集上反复调参,否则测试集也会被“污染”。
更稳妥的方式是 walk-forward 验证,也叫滚动前瞻验证。每半年或一个季度重新训练一次,训练窗口不断前移,用新数据更新模型,观察每个时间段样本外表现是否稳定。这样虽然比单次训练更耗时,但能更真实地反映模型在换市场环境后的实际表现。在排序学习里,每个交易日都会被当作一个 query 分组,分组信息必须正确传入,后面代码部分我会专门演示。
4. 完整实操:从安装到输出股票池
4.1 数据准备与 label 构建
我们先模拟一个标准 pandas 数据管道。为了演示方便,假设你已经有一份日线行情表,包含trade_date、ts_code、open、close、volume、amount等字段。
import pandas as pd import numpy as np # 假设 df 已经通过数据接口获取,并经过了基本面数据合并 df = df.sort_values(['ts_code', 'trade_date']).reset_index(drop=True) # 1. 计算基础量价特征,按股票分组滚动计算 def add_features(g): g['ret_5d'] = g['close'].pct_change(5) g['ret_20d'] = g['close'].pct_change(20) g['ma5'] = g['close'].rolling(5).mean() g['ma20'] = g['close'].rolling(20).mean() g['ma_gap'] = g['close'] / g['ma20'] - 1 g['vol_ratio'] = g['volume'] / g['volume'].rolling(20).mean() return g df = df.groupby('ts_code', group_keys=False).apply(add_features) # 2. 构造未来20个交易日收益率作为标签 df['label'] = df.groupby('ts_code')['close'].shift(-20) / df['close'] - 1 # 3. 删除缺失值,并转成每个交易日内的相对排名 df = df.dropna(subset=['ret_5d', 'ret_20d', 'ma5', 'ma20', 'vol_ratio', 'label']) df['label_rank'] = df.groupby('trade_date')['label'].rank(pct=True)这里需要注意,代码里直接用close计算label会引入“用未来收盘价对照当前收盘价”的近似误差,实际项目最好使用后复权价格,并且在 T+1 日开盘买入的假设下,严格应该用开盘价做收益计算。这个简化版本是为了先把流程打通,实战时要按自己的交易规则细化。
特征层面我们只放了几个量价指标做演示,真实项目建议把财务指标、行业分类、指数成分、情绪类特征一起并进来。特征越多,越要关注缺失值比例和特征重要性,LightGBM 虽然能处理缺失值,但一个字段如果缺失 70% 以上,对模型稳定性的拖累通常大于收益。
4.2 LightGBM 排序训练:分组与早停
接下来直接进入 LightGBM 排序目标训练。关键点是group参数,它告诉模型哪些样本属于同一个 query。在量化选股里,一个交易日就是一组,每组内包含当天所有参与排序的股票。
import lightgbm as lgb features = ['ret_5d', 'ret_20d', 'ma5', 'ma20', 'ma_gap', 'vol_ratio'] train_df = df[df['trade_date'] < '2024-01-01'] val_df = df[(df['trade_date'] >= '2024-01-01') & (df['trade_date'] < '2024-06-01')] train_group = train_df.groupby('trade_date').size().values val_group = val_df.groupby('trade_date').size().values model = lgb.LGBMRanker( objective='lambdarank', metric='ndcg', boosting_type='gbdt', n_estimators=1500, learning_rate=0.05, num_leaves=31, min_data_in_leaf=50, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0, random_state=42, ) model.fit( train_df[features], train_df['label_rank'], group=train_group, eval_set=[(val_df[features], val_df['label_rank'])], eval_group=[val_group], eval_at=[10, 20, 50], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)], )LGBMRanker 会自动根据 group 信息把同一天的股票组成一个列表,训练目标是让同一列表内 label 更高的股票排到更靠前的位置。这里我没有把label_rank乘以任何权重,直接传 0 到 1 的百分位排名,LightGBM 的排序目标是能接受浮点标签的。
运行后,模型会在 1500 轮内部迭代里寻找最佳迭代次数,一旦验证集 NDCG 连续 100 轮没有提升,便自动停止。eval_at=[10, 20, 50]是同时监控 top 10、top 20、top 50 位置的 NDCG 指标,这正好对应了不同股票池规模下的排序质量。
4.3 生成每日股票分数与简单回测
训练完成后,对验证集或最新测试时间段调用predict,就能得到每只股票当天的排序分数。接着在每个交易日内做一次百分位排名,取前 20% 或者前 50 只作为候选股票池。
test_df = df[df['trade_date'] >= '2024-06-01'].copy() test_df['score'] = model.predict(test_df[features]) test_df['score_rank'] = test_df.groupby('trade_date')['score'].rank(pct=True) # 每天取分数排名前20%的股票作为持仓池 selected = test_df[test_df['score_rank'] >= 0.8].copy() # 计算每个交易日内持仓池的平均收益,假设等权持有 selected['ret_1d'] = selected.groupby('ts_code')['close'].pct_change(1) daily_ret = selected.groupby('trade_date')['ret_1d'].mean() # 和全市场等权基准做对比 benchmark = test_df.groupby('trade_date')['ret_1d'].mean() strategy_nav = (1 + daily_ret).cumprod() benchmark_nav = (1 + benchmark).cumprod()这段回测做了很多简化:假设所有股票在每天都能按收盘价成交、没有手续费和滑点、没有停牌和涨跌停限制,也没有考虑资金分配和交易成本。真实回测至少还要加上手续费率和涨跌停过滤,否则策略实际收益会明显失真。
即便简化成这样,你也可以很直观看到,模型每天选出的 top 20% 股票池,它的累计净值曲线是否稳定跑赢全市场等权基准。我自己的经验是,先看曲线是否平稳上行,再看最大回撤和换手率;如果一个策略只在几天内暴涨,其他时间都是阴跌,那大概率是过拟合,而不是模型真的找到了规律。
5. 你一定会撞上的坑与排查技巧实录
5.1 回测很美好,实盘就拉胯的过拟合问题
过拟合在量化选股里几乎是不可避免的话题。模型在训练集上 NDCG 非常高,验证集上也不错,等真金白银进场后却开始连续亏损,这就是典型的样本外失效。有个很常见的操作错误:验证集 once 不 reset… 实际中,每次调参都看验证集结果,本质上是变相把验证集信息引入了模型选择,最后选的参数天然在验证集上最好,但换到未来数据精度骤降。
避免方法是做 walk-forward,把数据切成多个滚动区间,比如 2020 年训练完测 2021 年,2021 年加进来训练再测 2022 年,如此循环。一个稳定的策略应该在不同年份、不同市场风格下都不会出现极端回撤。如果某一年特别赚钱,其他年份稳定亏损,说明模型大概率学到了当年的某个临时性“规律”。
另外,early stopping 的 patience 不能设太小,例如 20 轮就停止,往往在 NDCG 还没稳定时就被打断了。我会设 100 到 200 轮,同时保持一个合理的固定迭代次数上限,避免万一验证集一直不涨时无限训练。
5.2 特征泄露是量化回测的“隐形炸弹”
特征泄露,也就是未来信息被不经意间写进了特征,是我见过最容易毁掉一个量化项目的问题,而且特别隐蔽。有一类错误是“用 T 日的收盘价计算特征,同时又用 T 日的收益率当标签”,一部分特征和标签天然就存在逻辑重叠,模型回测时相当于偷看了答案。
还有一类错误在做数据对齐时出现,比如把 T+1 日才发布的财报公告错误地对应到了 T 日的记录上。财务数据的发布时间和财报期是错位的,如果直接用财报中的 ROE 对应到当前日期,等于在财报还没发布时就提前使用了它。处理方式是把所有基本面数据按“公告日期”对齐,而不是按“报告期”对齐。
排查特征泄露有一个很实用的办法:在特征表里加入一列随机噪声作为特征,看看模型是否给它很高的特征重要性。如果重要性排名靠前,说明特征管道可能有泄漏,导致模型靠一些不该看见的信息做判断。这个方法简单有效,值得每次建模前都测一遍。
5.3 模型分数抖动导致换手率过高
就算模型本身没有问题,还有一个实盘经常遇到的情况:每天选出来的股票池变化太大,今天涨停买入、明天跌停卖不出去,频繁换手直接把收益吃光。LightGBM 模型对输入特征变化非常敏感,尤其当某些短期量价特征波动较大时,今天排第 5 的股票明天可能直接跌出前 100 名。
解决思路有几个:一是加长持仓周期,把 5 日调仓改成 20 日调仓,降低换手率;二是在选股池基础上增加“重叠缓冲”,只有股票连续两天或三天都进入 top 20% 才真正纳入持仓,降低噪声干扰;三是增加一个排名变化阈值,旧持仓只要没有跌出 top 30%,就不强行调出,降低交易频次。
如果代码里发现某次买入后第二天必须卖出,建议先别急着骂模型,回去看看特征是不是短期波动太大。我自己遇到过类似问题,最后把一批 5 日动量和换手率特征做了降频平滑,换手率一下就降下来了。
5.4 不同时间窗口训练出来的模型不稳定,怎么办
很多朋友训练时会发现,换一段训练窗口后,特征重要性排序完全变了,选出来的股票池也不稳定。这种情况一方面可能是数据本身噪声大,另一方面也可能是没有固定随机种子,或者训练窗口里的市场风格切换太剧烈。
建议先固定随机种子,把模型随机性降到最低。然后可以尝试boosting_type='dart'。Dart 和普通 GBDT 不太一样,它在每轮迭代时会随机丢弃一部分已生成的树,迫使模型结构更稳健,对缓解过拟合和窗口切换带来的不稳定性有一定帮助。
如果模型还是反复横跳,最直接的办法是集成:用最近两到三年和最近一年分别训练两个 LightGBM 模型,最终把它们的排序分数按一定权重相加。这相当于用多个模型对同一个股票池打分,能抵消单次模型对单一时间窗口的偏差,比反复调参更有效。
5.5 和传统指标结合,别把自己困在模型里
LightGBM 再强也只是工具,最终决策还是要回到投资逻辑。我在实践中最推崇的做法是用传统规则先做第一层过滤,再用 LightGBM 做第二层精细排序。比如先用通达信公式或简单企业筛选把 ST、上市不足 60 天、日均成交额过低、基本面明显恶化的股票剔除掉,然后再把剩余股票交给模型打分排序。这样做既降低了模型学习到垃圾规则的几率,也让最终选出的股票池流动性更好、更可交易。
如果要跟着 Datawhale 指南继续往后面学,下一个自然延伸方向是把 LightGBM 输出的分数本身当作一个 alpha 因子,和其他因子一起做组合,再通过因子加权和风险约束,构建一个风险收益更均衡的投资组合。这一层就不再是单模型的选股问题,而是整个因子组合体系的搭建,也是我个人觉得量化投资里最有意思的部分。
结尾:先把数据管道做干净,再谈模型调优
我的体会是,基于 LightGBM 做量化选股,真正重要的往往不是模型参数,而是数据管道是否干净、标签定义是否合理、验证方式是否贴近实盘。模型再花哨,只要有一个未来的特征漏进来,回测曲线再漂亮也都是镜花水月。先从几百行代码跑通全流程,再逐步增加特征和调优,这才是把 Datawhale 这类开源指南真正消化成自己能力的路径。
最后再分享一个小技巧:项目起步阶段不要一上来就追求超高收益,先保证每天选出的股票池逻辑稳定、可解释、换手可控。把回测改成滚动前瞻形式,连续观察半年以上,你大概率能避开很多新手都会踩的坑。