ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LightGBM量化策略实战:从因子工程到回测的完整链路

LightGBM量化策略实战:从因子工程到回测的完整链路 简介这是一套面向Python入门者、希望快速接触量化投资与机器学习交叉领域的教学型代码包围绕LightGBM模型构建完整的选股与回测流程。资源共20个文件以5个py脚本、6张png图表、3个zbak备份、2个txt清单及docx文档为主压缩包约5.59MB结构清晰便于按模块阅读。内容涵盖数据采集、特征工程、模型训练与历史回测四个环节最终输出每日建议持仓及累计收益率、最大回撤、夏普比率三项核心指标并配有柱状图、K线图、最大回撤等可视化结果帮助读者直观理解策略表现。已有104人学习适合作为量化投资入门的第一份可运行示例在理解代码逻辑与市场风险的前提下动手实践逐步扩展证券池与特征维度。1. 用 LightGBM 做量化策略从因子到回测一条能跑通的工程链路很多人第一次听到「基于 LightGBM 模型的量化投资策略实现与回测分析」脑子里浮现的是调个库、跑个fit、画条净值曲线就完事。真上手才发现翻车点根本不在模型本身而在数据对齐、标签构造、回测撮合这三件「脏活」上。LightGBM 是个梯度提升框架直方图算法加 Leaf-wise 生长训练快、内存省、对缺失值和高维稀疏特征友好这些特性天然贴合量化里「因子多、样本少、噪声大」的场景。这篇笔记讲的就是怎么把 LightGBM 塞进一条完整的量化链路从原始行情到因子矩阵从标签设计到滚动训练再到一个不骗自己的回测框架。适合已经会写 Python、懂点 pandas但还没把机器学习策略真正跑出样本外的朋友。下面所有代码都是最小可复现骨架参数怎么调、哪里容易踩坑我会逐段说清楚。2. 因子工程与标签构造LightGBM 吃什么样的数据2.1 为什么量化场景偏爱 LightGBM先把选型理由讲透不然后面调参全是玄学。量化选股或择时的输入通常是一张「样本 × 因子」的宽表因子动辄几十上百个包含动量、波动率、估值、资金流等彼此还高度相关。线性模型扛不住这种非线性交互深度模型又容易在几千到几万条样本上过拟合。LightGBM 的直方图分裂把连续特征离散成桶训练复杂度大幅下降Leaf-wise 生长配合num_leaves控制能在有限样本下找到有区分度的切分feature_fraction和bagging_fraction两个随机化参数本质上是在做特征和样本层面的正则这对噪声极大的金融数据非常关键。另一个常被忽略的点是缺失值处理。财务因子经常有停牌、未披露导致的空值LightGBM 不需要你填均值它会把缺失单独分到一个分支反而保留了「缺失本身可能是信息」这一层。常见做法是保留 NaN让模型自己学而不是无脑fillna(0)——后者会把「没数据」和「真实为零」混为一谈这是血泪经验。2.2 因子矩阵的构建与对齐数据对齐是第一个大坑。日频策略里因子必须在「你能拿到它的那一刻」才可用否则就是未来函数。比如用当日收盘价算的动量最早只能在次日开盘用。下面这段代码演示一个最小因子构建流程核心是shift的时机。import pandas as pd import numpy as np # df: 长表列含 date, code, close, volume, pe def build_factors(df): df df.sort_values([code, date]).copy() g df.groupby(code, group_keysFalse) # 动量过去20日收益率注意用 shift(1) 保证 T 日只用 T-1 及之前的数据 df[mom_20] g[close].apply(lambda s: s.shift(1) / s.shift(21) - 1) # 波动率过去20日收益率标准差 ret g[close].apply(lambda s: s.pct_change()) df[vol_20] ret.groupby(df[code]).apply( lambda s: s.shift(1).rolling(20).std() ).reset_index(level0, dropTrue) # 换手代理成交量相对20日均量 df[vol_ratio] g[volume].apply( lambda s: s.shift(1) / s.shift(1).rolling(20).mean() ) # 估值因子直接用当日可得值但同样要 shift(1) 表示次日才用 df[pe_ttm] g[pe].apply(lambda s: s.shift(1)) return df逻辑说明所有因子统一shift(1)含义是「T 日收盘后计算、T1 日可用于交易」。参数上20 日是经验窗口短了噪声大、长了反应慢可以做成多窗口5/10/20/60让模型自己选。groupby后apply在数据量大时较慢生产环境建议用transform或向量化写法这里为了可读性保留。标签构造同样关键。分类任务常用「未来 N 日收益是否超过阈值」回归任务直接预测未来收益。分类更稳因为阈值把极端值截断了。标签必须和因子严格错位因子在 T 日标签是 T1 到 TN 的收益。# 标签未来5日收益二分类涨过1%记1 df[fwd_ret] g[close].apply(lambda s: s.shift(-5) / s.shift(-1) - 1) df[label] (df[fwd_ret] 0.01).astype(int) # 最后5行标签为 NaN训练前必须丢掉 df df.dropna(subset[label])注意shift(-5)会引入未来数据这是标签该有的样子但绝不能出现在因子里。很多人把因子和标签写在一个函数里一不小心就串了建议因子和标签分两个函数、两个 DataFrame合并前各自检查一遍。2.3 训练集、验证集、测试集的时间切分量化数据不能随机切分否则同一只股票相邻日期的样本会同时出现在训练和测试里造成信息泄露回测虚高得离谱。正确做法是按时间切前 70% 训练中间 15% 验证调参最后 15% 测试。更严谨的是滚动窗口每训练一段就向前推一段模拟真实上线过程。dates np.sort(df[date].unique()) n len(dates) train_end dates[int(n * 0.7)] valid_end dates[int(n * 0.85)] train df[df[date] train_end] valid df[(df[date] train_end) (df[date] valid_end)] test df[df[date] valid_end] feat_cols [mom_20, vol_20, vol_ratio, pe_ttm] print(train.shape, valid.shape, test.shape)参数说明切分比例不是死的样本少时训练段可以拉到 80%但验证段不能省否则调参没有依据。feat_cols要显式列出避免把fwd_ret、label这类泄露列混进去。这一步做完数据侧才算干净。3. LightGBM 训练与调参让模型在样本外站得住3.1 最小训练脚本与关键参数数据备好训练本身反而简单。下面是一个二分类的最小脚本重点是参数怎么设、为什么这么设。import lightgbm as lgb from sklearn.metrics import roc_auc_score params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, max_depth: -1, min_data_in_leaf: 100, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, verbose: -1, seed: 42, } dtrain lgb.Dataset(train[feat_cols], labeltrain[label]) dvalid lgb.Dataset(valid[feat_cols], labelvalid[label], referencedtrain) model lgb.train( params, dtrain, num_boost_round1000, valid_sets[dvalid], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)], ) pred model.predict(test[feat_cols]) print(test auc:, roc_auc_score(test[label], pred))逻辑说明early_stopping(50)表示验证集 AUC 连续 50 轮不提升就停这是防过拟合的第一道闸。min_data_in_leaf100是金融场景的关键参数样本噪声大叶子节点样本太少必然记住噪声这个值宁大勿小。lambda_l2给叶子权重加 L2 正则进一步压过拟合。参数逐个说learning_rate0.05 是速度和精度的折中调到 0.01 更稳但轮数翻倍num_leaves31 对应约 5 层满二叉树样本上万可以试 63样本几千就压到 15feature_fraction和bagging_fraction低于 1 才有正则效果0.7~0.9 是常用区间。AUC 在量化里能到 0.55 以上就算有信号别指望 0.8那是泄露了。3.2 用交叉验证稳住参数选择单次验证集切分有运气成分时间序列交叉验证更可靠。思路是滚动地「训练一段、验证下一段」。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) aucs [] for tr_idx, va_idx in tscv.split(train): tr, va train.iloc[tr_idx], train.iloc[va_idx] dtr lgb.Dataset(tr[feat_cols], labeltr[label]) dva lgb.Dataset(va[feat_cols], labelva[label], referencedtr) m lgb.train(params, dtr, num_boost_round1000, valid_sets[dva], callbacks[lgb.early_stopping(50), lgb.log_evaluation(0)]) aucs.append(roc_auc_score(va[label], m.predict(va[feat_cols]))) print(cv auc mean:, np.mean(aucs), std:, np.std(aucs))参数说明n_splits5是折中太多折每折训练样本不足。看的是mean和std均值高但方差大说明参数不稳宁可要均值略低、方差小的。这一步跑完你才对模型在样本外的真实水平有底。3.3 特征重要性与因子筛选LightGBM 自带特征重要性分 split 次数和 gain 两种。量化里更看 gain因为它反映特征对损失的贡献。imp pd.DataFrame({ feat: feat_cols, gain: model.feature_importance(gain), }).sort_values(gain, ascendingFalse) print(imp)如果某个因子 gain 长期垫底可以考虑剔除减少过拟合面。但别频繁删因子在不同市场阶段作用不同删太狠会丢掉阶段性信号。常见做法是保留全部靠feature_fraction让模型每轮随机选特征天然做了筛选。4. 回测分析别让净值曲线骗了你4.1 从预测值到持仓信号模型输出的是概率要转成持仓。最简单是阈值法概率大于 0.55 买入小于 0.45 卖出中间不动。更平滑的是按概率排序选 Top N。test test.copy() test[score] model.predict(test[feat_cols]) # 每日按 score 排序选前 20% 作为持仓 def pick(group): group group.sort_values(score, ascendingFalse) k max(1, int(len(group) * 0.2)) group[pos] 0 group.iloc[:k, group.columns.get_loc(pos)] 1 return group test test.groupby(date, group_keysFalse).apply(pick)逻辑说明groupby(date)保证每天独立选股k是持仓数量。参数上 20% 是经验值资金量大就调小资金量小调大。pos1表示等权持有实盘还要考虑手续费和滑点。4.2 一个不骗人的回测循环回测最容易骗自己的地方是「用当日收盘价成交」。真实情况是你 T 日收盘后拿到信号T1 日开盘才能下单。下面这个循环严格按这个时序。test test.sort_values([date, code]) daily_ret [] prev_pos {} for date, day in test.groupby(date): # 当日收益 昨日持仓 × 今日收益 if prev_pos: r 0 for code, w in prev_pos.items(): row day[day[code] code] if len(row): r w * row[fwd_ret].values[0] / 5 # 近似日收益 daily_ret.append((date, r)) # 今日信号作为明日持仓 held day[day[pos] 1] if len(held): prev_pos {c: 1.0 / len(held) for c in held[code]} else: prev_pos {} bt pd.DataFrame(daily_ret, columns[date, ret]).set_index(date) bt[nav] (1 bt[ret]).cumprod() print(bt[nav].iloc[-1])逻辑说明prev_pos存的是昨日决定的持仓今日才产生收益这个错位是回测可信的前提。fwd_ret / 5是把 5 日收益粗略摊到日频严谨做法是直接用日收益标签重训。手续费没算实盘要在每次调仓时扣掉双边千分之一到千分之三。4.3 回测指标怎么读光看净值不够要拆成几个指标一起看。指标含义健康区间参考年化收益净值年化因策略而异别只看这个最大回撤峰值到谷底最大跌幅越小越好超过 30% 要警惕夏普比率超额收益 / 波动1 以上算可用2 以上优秀胜率盈利天数占比50% 上下正常配合盈亏比看换手率调仓频率太高会被手续费吃光注意夏普高但换手极高的策略扣掉成本后往往归零。回测里一定要把手续费和滑点加进去否则就是自欺欺人。5. 避坑与排查那些让回测虚高的细节5.1 现象回测 AUC 0.7实盘一塌糊涂原因因子或标签里混入了未来数据。最常见的是用当日收盘价算的因子没shift或者标签窗口和因子窗口重叠。解决写一个检查函数对每个因子算它和未来收益的相关性如果高得离谱比如 0.5 以上基本就是泄露。5.2 现象训练集表现完美验证集崩了原因过拟合。num_leaves太大、min_data_in_leaf太小、树太深。解决先把min_data_in_leaf提到 200 以上num_leaves压到 15learning_rate降到 0.02再看验证集。金融数据信噪比低模型越简单越稳。5.3 现象回测净值一路向上几乎没有回撤原因幸存者偏差或数据对齐错误。比如只用了现在还存活的股票或者停牌日没剔除。解决用包含退市股票的全样本停牌日因子置 NaN 让模型处理回测时停牌不能交易。5.4 现象每次调参结果差异很大原因随机种子敏感样本量不足。解决固定seed用时间序列交叉验证看均值和方差方差大说明样本不够考虑拉长历史或减少因子数量。5.5 现象模型预测值分布极端全在 0 或 1 附近原因标签不平衡或学习率过高。解决检查正负样本比例必要时用is_unbalance或scale_pos_weight降低学习率增加轮数。6. 滚动训练与实盘衔接让策略活过下一个季度模型不是训一次就完事。市场风格会变因子有效性会衰减所以生产环境用的是滚动训练每隔一段时间比如每月用最新数据重新训练替换旧模型。下面是一个滚动框架的骨架。def walk_forward(df, feat_cols, train_months24, retrain_freqM): results [] dates pd.to_datetime(df[date]) months sorted(dates.dt.to_period(M).unique()) for i in range(train_months, len(months)): train_start months[i - train_months] train_end months[i - 1] test_month months[i] tr df[(dates.dt.to_period(M) train_start) (dates.dt.to_period(M) train_end)] te df[dates.dt.to_period(M) test_month] if len(tr) 1000 or len(te) 0: continue dtr lgb.Dataset(tr[feat_cols], labeltr[label]) m lgb.train(params, dtr, num_boost_round500) te te.copy() te[score] m.predict(te[feat_cols]) results.append(te) return pd.concat(results)逻辑说明train_months24表示用过去两年数据训练预测下一个月逐月滚动。参数上训练窗口太短模型不稳太长又跟不上市场变化两年是常见起点。retrain_freq可以改成周频但训练成本上升。这个框架跑出来的才是接近实盘的样本外表现。实盘衔接还有两件事一是模型版本管理每次训练的模型、参数、特征列表都要存档出问题能回溯二是监控跟踪每日预测分布和实际收益的相关性一旦连续走弱就触发人工检查。我自己的习惯是每周看一眼特征重要性的排序有没有剧烈变化如果某个因子突然从末尾跳到头部多半是数据源出了问题而不是市场变了。最后说个具体技巧把 LightGBM 的预测值做横截面标准化每天减均值除标准差再转持仓。这样能消除市场整体涨跌对信号的影响让策略更纯粹地赚选股的钱。这一步在震荡市里效果尤其明显希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进