
量化投资这几年已经从机构策略室慢慢扩散到了个人开发者圈子里AI 选股成了很多程序员跨界做投资研究的第一站。我最近在刷 Datawhale 开源量化投资学习指南第十期正好是“基于 LightGBM 的量化选股”这一期内容很接地气把机器学习模型怎么直接应用在选股这件事上拆得很清楚。要说实话我一开始也迷信过神经网络但真正上手后才发现在“表格型特征 样本噪声大 解释性要求高”的量化场景里LightGBM 反而更容易出效果训练快、能处理缺失值、可以直接做排序学习而且带着特征重要性让我能看清模型到底在“看什么”。这篇文章不是简单复刻指南原文而是把 Datawhale 这一期的核心思路和我自己实践过的完整方案整合到一块从模型选型、排序目标、特征工程、数据切分、代码实现到回测避坑一次讲透。适合已经会点 Python 和 pandas、想系统了解机器学习选股的朋友文章里出现的代码可以直接改改数据源和字段名后跑起来。1. 整体设计思路为什么是 LightGBM 而不是别的模型1.1 量化选股的本质是一场“排序比赛”量化选股的核心是在每个调仓周期内从全市场几千只股票里挑出大概率跑赢市场的品种。注意这里的关键词是“跑赢”不是“预测准”这意味着我们并不需要模型预测出某只股票明天涨 2.3% 还是 3.1%只需要它能分清楚相对强弱关系让排在前面的一批股票整体强于排在后面的一批。这个区别在建模时非常重要。很多新手一上来就把选股当成回归问题训练模型拟合未来收益率然后每天按预测值排序。这样做的问题在于收益率标签本身噪声很大极端值和停牌数据会干扰连续数值拟合模型稍有偏差排序结果就面目全非。如果把问题改成排序任务让模型用 pairwise 或 listwise 的损失函数去感知“谁应该排在谁前面”对噪声的容忍度会明显提高。LightGBM 原生支持排序目标参数objectivelambdarank这是它特别适合选股的核心原因。排序学习本来就是为搜索和推荐设计的而选股本质上也是在“全市场股票”这个列表里做排序推荐两者问题的结构是一致的。1.2 为什么不用 XGBoost 或神经网络我之前也写过 XGBoost 版本的选股模型整体流程差不多但 LightGBM 在量化场景里有几个更舒服的优势训练速度更快。LightGBM 使用基于直方图的决策树算法在几百万行的特征数据上迭代极快调参实验的成本低很多一天能跑几十组参数组合。对特征尺度不敏感。收益率、市值、财务比率这些字段量纲差异非常大GBDT 系列模型不像神经网络那样需要严格做标准化数据管道能省掉一个环节。原生支持类别特征。行业代码、是否 ST、是否次新股这类离散字段可以直接喂给模型不用做 one-hot 编码否则维度会被撑爆训练速度和泛化能力都会受影响。神经网络当然也有它的价值比如对复杂非线性关系的表征能力更强但金融数据本身信噪比很低模型越复杂越容易把噪声当规律。我的习惯是把 LightGBM 当主力模型先把选股流程跑通再用神经网络或者其他模型做交叉验证而不是一上来就上深度学习。1.3 完整流程分四段数据、特征、标签、训练整个量化选股项目如果拆开看大致是四个环节数据采集、特征构建、标签定义、模型训练与排序输出。很多人一提起机器学习选股就盯着模型精度实际上真正决定成败的是前三个环节模型反而是最成熟、最不需要“折腾”的部分。我自己的顺序是先把数据统一成“股票-日期”的宽表结构然后构建多头特征集再严格按可交易时点定义标签最后才把数据交到 LightGBM 里做排序训练。这四个环节里数据对齐和标签定义最容易被忽视但它们又恰恰是“回测很好、实盘失效”的头号凶手后面我会重点展开。2. 核心概念拆解Rank 目标、树个数与关键参数2.1 用 lambdarank 来做选股而不是回归LightGBM 默认的objective是回归但这在选股场景里并不是最优解。我建议直接使用objectivelambdarank这是 LambdaMART 算法的实现它把梯度提升树和 Learning to Rank 思路结合在一起。算法会对样本两两配对如果一对样本的排序错了就给一个更高的梯度惩罚让模型逐步学会把高收益标签的股票推到更靠前的位置。落到量化选股上我们需要做一次概念映射每个交易日的全体股票是一个 query股票相当于待排序的文档未来一段时间收益的排名可以作为相关性标签模型最终输出一个分数按照分数排序后分数最高的那一批股票就是我们想买入的候选池。这样做的好处是训练目标与实际交易逻辑保持一致。传统回归模型关心的是“收益率预测值是多少”但最终真正使用的只是排序结果中间可能因为某个极值样本就把排序带偏。排序学习从源头上绕开了这个问题。2.2 ndcg 与 xendcg排序效果应该看什么排序学习的评估指标最常用的是 NDCG它在计算时会根据排序位置给不同权重排在第一位的股票被选中概率显然最高所以它的贡献应该大于排在第二十位的。NDCG 对位置权重做了归一化处理最终只能得到 0 到 1 之间的数值越接近 1 说明排序越接近理想状态。在 LightGBM 的排序目标里有些人会用到名为 xendcg 的损失函数它核心思想是更专注于列表头部位置的排序质量关注 top 位置的命中精度。实际使用的时候xendcg 对样本分组和 label 的要求比较严格一旦设置不当训练可能直接报错。我个人的建议是主线项目先老老实实用objectivelambdarank加metricndcg把整体流程跑通确认特征和标签都没有问题后再考虑测试 xendcg 这类更像“top 10 选股准确率”的损失目标。选股最终只关心头部位置所以这类损失函数确实有研究价值但没必要在项目初期就陷入损失函数的细节里。2.3 树个数、学习率、叶子数先盯住这几个旋钮很多朋友搜索“lightgbm 的树个数”说明大家建模时最担心的就是模型复杂度。树个数也就是n_estimators或num_boost_round我一般不从固定值开始而是把它设得足够大然后配合 early stopping 让模型自己决定什么时机停止。常见做法是设 1000 到 2000 轮学习率设为 0.05 左右如果学习率更低树个数需要相应增加否则模型容易欠拟合。几个关键超参数的实践经验大概是这样的learning_rate推荐从 0.05 起步训练速度允许时可以降到 0.02收益是精度小幅上升代价是训练时间变长和过拟合风险增加。num_leavesLightGBM 的核心复杂度因子默认 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_state42这种设置。树模型虽然不像深度网络那样随机性极强但如果不固定种子验证集评估指标会有抖动你很难判断一次调参到底是真有效还是纯属随机波动。3. 特征工程与数据切分细节3.1 从哪找数据传统 5 类指标怎么落库数据是量化项目的底座常用数据获取方式有 akshare、tushare、baostock 等开源或低成本数据接口。这些数据源能覆盖日线行情、基本面财务数据、行业分类、指数成分等常见字段对个人研究完全够用不需要一开始就去买昂贵的机构级数据库。我记得热搜里有一条是“通达信量化选股公式实战:如何用 5 个关键指标筛选潜力股”。实际上很多人就是通过通达信这类行情软件来手写选股公式的比如要求 PE 低于某个值、ROE 高于某个值、均线呈多头排列。这种做法的本质是人工构造一棵规则决策树只有几条硬性条件简单直接但无法穷尽股票上涨背后的复杂非线性关系。我们在 LightGBM 项目里完全可以借鉴这种思路把通达信公式里常用的那几类指标扩展成特征宽表估值类用 PE、PB、PS盈利类用 ROE、毛利率、净利率成长类用营收同比增速、净利润同比增速交易行为类用换手率、量比趋势类用 20 日均线偏离度、MACD 柱状图变化等。通达信公式帮我们解决了“特征从哪来”的问题LightGBM 则更像一个自动优化版决策树它不要求你手工写寻优条件而是从几十个甚至上百个特征中去寻找最佳分裂方式和特征组合。传统公式仍然有存在意义适合做第一道过滤把明显有退市风险或基本面恶化的股票剔掉LightGBM 再在剩余股票池里做精细化排序。3.2 标签怎么打才不“穿”未来标签定义和特征对齐是整个数据管道中最容易出错的地方。第一次做的时候我直接用了当天收益率当标签逻辑听起来没问题但实际操作中就会踩坑假设你准备在 T 日收盘后根据模型决定 T1 日的持仓那么标签应该代表“T1 日买入并持有到未来某一天能获得多少收益”而不是拿 T 日当天的收益来训练。正确的标签构造方式应该是特征使用 T 日及之前的数据目标是未来持有 N 个交易日的收益。以 20 日持仓为例标签等于T20日收盘价 / T1日开盘价 - 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(dropTrue) # 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_keysFalse).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(pctTrue)这里需要注意代码里直接用close计算label会引入“用未来收盘价对照当前收盘价”的近似误差实际项目最好使用后复权价格并且在 T1 日开盘买入的假设下严格应该用开盘价做收益计算。这个简化版本是为了先把流程打通实战时要按自己的交易规则细化。特征层面我们只放了几个量价指标做演示真实项目建议把财务指标、行业分类、指数成分、情绪类特征一起并进来。特征越多越要关注缺失值比例和特征重要性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( objectivelambdarank, metricndcg, boosting_typegbdt, n_estimators1500, learning_rate0.05, num_leaves31, min_data_in_leaf50, subsample0.8, subsample_freq1, colsample_bytree0.8, reg_lambda1.0, random_state42, ) model.fit( train_df[features], train_df[label_rank], grouptrain_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(pctTrue) # 每天取分数排名前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 日的收益率当标签”一部分特征和标签天然就存在逻辑重叠模型回测时相当于偷看了答案。还有一类错误在做数据对齐时出现比如把 T1 日才发布的财报公告错误地对应到了 T 日的记录上。财务数据的发布时间和财报期是错位的如果直接用财报中的 ROE 对应到当前日期等于在财报还没发布时就提前使用了它。处理方式是把所有基本面数据按“公告日期”对齐而不是按“报告期”对齐。排查特征泄露有一个很实用的办法在特征表里加入一列随机噪声作为特征看看模型是否给它很高的特征重要性。如果重要性排名靠前说明特征管道可能有泄漏导致模型靠一些不该看见的信息做判断。这个方法简单有效值得每次建模前都测一遍。5.3 模型分数抖动导致换手率过高就算模型本身没有问题还有一个实盘经常遇到的情况每天选出来的股票池变化太大今天涨停买入、明天跌停卖不出去频繁换手直接把收益吃光。LightGBM 模型对输入特征变化非常敏感尤其当某些短期量价特征波动较大时今天排第 5 的股票明天可能直接跌出前 100 名。解决思路有几个一是加长持仓周期把 5 日调仓改成 20 日调仓降低换手率二是在选股池基础上增加“重叠缓冲”只有股票连续两天或三天都进入 top 20% 才真正纳入持仓降低噪声干扰三是增加一个排名变化阈值旧持仓只要没有跌出 top 30%就不强行调出降低交易频次。如果代码里发现某次买入后第二天必须卖出建议先别急着骂模型回去看看特征是不是短期波动太大。我自己遇到过类似问题最后把一批 5 日动量和换手率特征做了降频平滑换手率一下就降下来了。5.4 不同时间窗口训练出来的模型不稳定怎么办很多朋友训练时会发现换一段训练窗口后特征重要性排序完全变了选出来的股票池也不稳定。这种情况一方面可能是数据本身噪声大另一方面也可能是没有固定随机种子或者训练窗口里的市场风格切换太剧烈。建议先固定随机种子把模型随机性降到最低。然后可以尝试boosting_typedart。Dart 和普通 GBDT 不太一样它在每轮迭代时会随机丢弃一部分已生成的树迫使模型结构更稳健对缓解过拟合和窗口切换带来的不稳定性有一定帮助。如果模型还是反复横跳最直接的办法是集成用最近两到三年和最近一年分别训练两个 LightGBM 模型最终把它们的排序分数按一定权重相加。这相当于用多个模型对同一个股票池打分能抵消单次模型对单一时间窗口的偏差比反复调参更有效。5.5 和传统指标结合别把自己困在模型里LightGBM 再强也只是工具最终决策还是要回到投资逻辑。我在实践中最推崇的做法是用传统规则先做第一层过滤再用 LightGBM 做第二层精细排序。比如先用通达信公式或简单企业筛选把 ST、上市不足 60 天、日均成交额过低、基本面明显恶化的股票剔除掉然后再把剩余股票交给模型打分排序。这样做既降低了模型学习到垃圾规则的几率也让最终选出的股票池流动性更好、更可交易。如果要跟着 Datawhale 指南继续往后面学下一个自然延伸方向是把 LightGBM 输出的分数本身当作一个 alpha 因子和其他因子一起做组合再通过因子加权和风险约束构建一个风险收益更均衡的投资组合。这一层就不再是单模型的选股问题而是整个因子组合体系的搭建也是我个人觉得量化投资里最有意思的部分。结尾先把数据管道做干净再谈模型调优我的体会是基于 LightGBM 做量化选股真正重要的往往不是模型参数而是数据管道是否干净、标签定义是否合理、验证方式是否贴近实盘。模型再花哨只要有一个未来的特征漏进来回测曲线再漂亮也都是镜花水月。先从几百行代码跑通全流程再逐步增加特征和调优这才是把 Datawhale 这类开源指南真正消化成自己能力的路径。最后再分享一个小技巧项目起步阶段不要一上来就追求超高收益先保证每天选出的股票池逻辑稳定、可解释、换手可控。把回测改成滚动前瞻形式连续观察半年以上你大概率能避开很多新手都会踩的坑。