简介:这是一套面向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_keys=False) # 动量:过去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(level=0, drop=True) # 换手代理:成交量相对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 日收盘后计算、T+1 日可用于交易」。参数上,20 日是经验窗口,短了噪声大、长了反应慢,可以做成多窗口(5/10/20/60)让模型自己选。groupby后apply在数据量大时较慢,生产环境建议用transform或向量化写法,这里为了可读性保留。
标签构造同样关键。分类任务常用「未来 N 日收益是否超过阈值」,回归任务直接预测未来收益。分类更稳,因为阈值把极端值截断了。标签必须和因子严格错位:因子在 T 日,标签是 T+1 到 T+N 的收益。
# 标签:未来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], label=train["label"]) dvalid = lgb.Dataset(valid[feat_cols], label=valid["label"], reference=dtrain) model = lgb.train( params, dtrain, num_boost_round=1000, 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_leaf=100是金融场景的关键参数,样本噪声大,叶子节点样本太少必然记住噪声,这个值宁大勿小。lambda_l2给叶子权重加 L2 正则,进一步压过拟合。
参数逐个说:learning_rate0.05 是速度和精度的折中,调到 0.01 更稳但轮数翻倍;num_leaves31 对应约 5 层满二叉树,样本上万可以试 63,样本几千就压到 15;feature_fraction和bagging_fraction低于 1 才有正则效果,0.7~0.9 是常用区间。AUC 在量化里能到 0.55 以上就算有信号,别指望 0.8,那是泄露了。
3.2 用交叉验证稳住参数选择
单次验证集切分有运气成分,时间序列交叉验证更可靠。思路是滚动地「训练一段、验证下一段」。
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5) 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], label=tr["label"]) dva = lgb.Dataset(va[feat_cols], label=va["label"], reference=dtr) m = lgb.train(params, dtr, num_boost_round=1000, 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_splits=5是折中,太多折每折训练样本不足。看的是mean和std,均值高但方差大说明参数不稳,宁可要均值略低、方差小的。这一步跑完,你才对模型在样本外的真实水平有底。
3.3 特征重要性与因子筛选
LightGBM 自带特征重要性,分 split 次数和 gain 两种。量化里更看 gain,因为它反映特征对损失的贡献。
imp = pd.DataFrame({ "feat": feat_cols, "gain": model.feature_importance("gain"), }).sort_values("gain", ascending=False) 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", ascending=False) 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_keys=False).apply(pick)逻辑说明:groupby("date")保证每天独立选股,k是持仓数量。参数上 20% 是经验值,资金量大就调小,资金量小调大。pos=1表示等权持有,实盘还要考虑手续费和滑点。
4.2 一个不骗人的回测循环
回测最容易骗自己的地方是「用当日收盘价成交」。真实情况是你 T 日收盘后拿到信号,T+1 日开盘才能下单。下面这个循环严格按这个时序。
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压到 15,learning_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_months=24, retrain_freq="M"): 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], label=tr["label"]) m = lgb.train(params, dtr, num_boost_round=500) te = te.copy() te["score"] = m.predict(te[feat_cols]) results.append(te) return pd.concat(results)逻辑说明:train_months=24表示用过去两年数据训练,预测下一个月,逐月滚动。参数上,训练窗口太短模型不稳,太长又跟不上市场变化,两年是常见起点。retrain_freq可以改成周频,但训练成本上升。这个框架跑出来的才是接近实盘的样本外表现。
实盘衔接还有两件事:一是模型版本管理,每次训练的模型、参数、特征列表都要存档,出问题能回溯;二是监控,跟踪每日预测分布和实际收益的相关性,一旦连续走弱就触发人工检查。我自己的习惯是每周看一眼特征重要性的排序有没有剧烈变化,如果某个因子突然从末尾跳到头部,多半是数据源出了问题,而不是市场变了。
最后说个具体技巧:把 LightGBM 的预测值做横截面标准化(每天减均值除标准差),再转持仓。这样能消除市场整体涨跌对信号的影响,让策略更纯粹地赚选股的钱。这一步在震荡市里效果尤其明显,希望帮到你。
本文还有配套的精品资源,点击获取