简介这份资源面向计算机相关专业学生、高校教师及量化投资初学者提供一套将LightGBM与BiLSTM结合进行因子组合选取并构建量化投资策略的完整实践项目可直接用于毕业设计、课程设计、实训实验或公司项目二次开发。压缩包共1408个文件约16.18MB以636个py源码和671个pyc编译文件为主体辅以exe运行程序、nbc与nbi工程文件、csv与xlsx数据表、h5与pkl模型文件、pptx演示文档及md说明文档覆盖从数据预处理、特征工程到模型训练与验证的全流程。项目采用LightGBM完成因子初步筛选再以BiLSTM捕捉时序相关性analysis目录提供探索性分析与性能验证脚本utils目录封装数据预处理与特征工程等辅助函数data_base目录存放训练测试数据集introduction.pptx则展示整体框架与核心成果。目前已有95人学习适合希望掌握深度学习量化策略实现细节与复现思路的读者参考。1. 因子组合这件事为什么单靠一个模型总是差口气做过量化的人大概都有过这种体验手里攒了几十个候选因子估值、动量、波动、换手、资金流单因子回测看着都还行一拼到一起收益没涨多少回撤反而更大。问题往往不在因子本身而在“怎么选、怎么配、怎么给权重”。传统做法是 IC 加权或者等权简单但粗糙再进一步用线性回归又抓不住因子之间那种非线性的、时序上的耦合关系。这个标题讲的就是用 LightGBM 做因子筛选、用 BiLSTM 做时序建模把两者串成一条“选因子组合 → 建量化投资策略”的流水线。LightGBM 负责从一堆候选因子里挑出真正有增量信息的组合BiLSTM 负责在时间维度上把选出来的因子序列学成一个预测信号。它适合已经会写 Python、跑过回测、但因子组合一直调不顺的从业者也适合想把深度学习真正落到选股/择时上的新手照着复现。整套东西的核心不是模型多花哨而是“因子怎么进、信号怎么出、回测怎么验”这条链路能不能闭环。2. 用 LightGBM 做因子筛选从几十个候选里挑出能打的一组2.1 为什么选 LightGBM 而不是先上神经网络因子筛选这一步本质是一个带噪声的特征选择问题。候选因子动辄三五十个样本量在几千到几万条之间还夹杂大量缺失值和极端值。这种场景下LightGBM 有几个实打实的优势基于直方图的决策树分裂训练快原生支持缺失值不用先做一堆填充能直接输出特征重要度方便做因子归因。相比之下一上来就用神经网络做筛选参数量大、可解释性差因子一多就容易过拟合调参成本也高。常见做法是先用 LightGBM 跑一轮回归或分类拿到特征重要度gain 或 split再结合相关性做去冗余。这里要注意重要度高不代表因子一定有效它只说明这个因子在树的分裂里被用得多。真正要看的是它在样本外还稳不稳。所以我一般会做两轮一轮全量因子训练看重要度排序一轮按重要度从高到低逐步加因子观察验证集 IC 的变化找到那个“再加就没提升”的拐点。2.2 因子筛选的最小可跑代码下面这段是因子筛选的核心逻辑输入是一个 DataFrame索引是日期列是因子和未来收益标签。import lightgbm as lgb import pandas as pd import numpy as np from sklearn.model_selection import TimeSeriesSplit # df: 索引为日期列为因子 label未来N日收益 def select_factors(df, label_collabel, top_k15): factor_cols [c for c in df.columns if c ! label_col] X df[factor_cols] y df[label_col] # 时序切分避免未来信息泄露 tscv TimeSeriesSplit(n_splits5) importance_records [] for train_idx, val_idx in tscv.split(X): X_train, X_val X.iloc[train_idx], X.iloc[val_idx] y_train, y_val y.iloc[train_idx], y.iloc[val_idx] model lgb.LGBMRegressor( n_estimators300, learning_rate0.05, num_leaves31, max_depth-1, min_child_samples50, subsample0.8, colsample_bytree0.8, reg_alpha0.1, reg_lambda0.1, random_state42 ) model.fit( X_train, y_train, eval_set[(X_val, y_val)], eval_metricl2, callbacks[lgb.early_stopping(30, verboseFalse)] ) # 记录每折的 gain 重要度 imp pd.Series(model.booster_.feature_importance(importance_typegain), indexfactor_cols) importance_records.append(imp) # 多折平均降低单次切分的偶然性 avg_imp pd.concat(importance_records, axis1).mean(axis1) avg_imp avg_imp.sort_values(ascendingFalse) # 相关性去冗余重要度高的因子若与已选因子相关性过高则跳过 selected [] for fac in avg_imp.index: if len(selected) top_k: break if all(abs(df[fac].corr(df[s])) 0.85 for s in selected): selected.append(fac) return selected, avg_imp # 调用 selected_factors, importance select_factors(df, label_collabel, top_k15) print(selected_factors)这段代码有几个关键点。TimeSeriesSplit保证训练集永远在验证集之前这是量化里最容易翻车的地方用普通 KFold 会直接把未来信息漏进来回测好看得离谱实盘一塌糊涂。importance_typegain比split更稳因为它统计的是该因子带来的增益总和而不是被分裂的次数。min_child_samples50是防过拟合的样本太少的分裂直接不生成。相关性阈值 0.85 是个经验值太松去不掉冗余太紧会把互补的因子也误杀可以按自己因子池的情况微调。2.3 参数怎么调、结果怎么看LightGBM 在因子筛选场景下真正需要动的参数就那么几个。num_leaves控制树的复杂度因子筛选阶段不用太大31 到 63 之间够用太大容易把噪声也学进去。learning_rate配n_estimators一起看0.05 配 300 到 500 是常见组合配合 early stopping 基本不用手调。min_child_samples我一般设成样本量的 1% 到 2%样本多就调大。subsample和colsample_bytree设 0.8 左右增加随机性降低过拟合。结果怎么看先看重要度排序的头部因子是不是符合逻辑。如果排第一的是个你根本解释不了的因子要么是数据泄露要么是构造有问题。再看逐步加因子的 IC 曲线如果加到第 10 个因子后验证集 IC 开始走平甚至下降那 top_k 就定在 10 左右。最后一定要做样本外验证拿最近一段没参与训练的数据跑一遍重要度排序如果和训练集差异巨大说明因子不稳定得重新考虑。3. BiLSTM 建模把选出来的因子序列学成预测信号3.1 BiLSTM 在量化时序里到底强在哪因子选好了接下来是把它们按时间顺序喂给模型。普通 LSTM 只能从过去单向看而 BiLSTM 同时跑正向和反向两个 LSTM把每个时间点的前后文都编码进去。在量化场景里这意味着模型不仅知道“昨天到今天发生了什么”还能利用“明天到后天”这个方向的信息——当然这里的“反向”是在训练序列内部不是真的用了未来数据因为整个序列在预测时是已知的历史窗口。具体来说假设你用过去 60 个交易日的数据预测第 61 日的收益BiLSTM 会把这 60 天的因子序列正着跑一遍、反着跑一遍然后把两个方向的隐状态拼起来。这样每个时间点的表示都包含了它前后邻居的信息对于捕捉因子的短期反转、动量衰减这类模式特别有用。代价是参数量翻倍训练慢一些但在因子数量不多十几个的情况下完全扛得住。3.2 数据窗口构造与 BiLSTM 网络定义import torch import torch.nn as nn import numpy as np # 构造时序样本用过去 seq_len 天预测下一天 def build_sequences(factor_array, label_array, seq_len60): X, y [], [] for i in range(seq_len, len(factor_array)): X.append(factor_array[i-seq_len:i]) # shape: (seq_len, n_factors) y.append(label_array[i]) # 下一天的收益 return np.array(X), np.array(y) class BiLSTMRegressor(nn.Module): def __init__(self, n_factors, hidden_size64, num_layers2, dropout0.3): super().__init__() self.bilstm nn.LSTM( input_sizen_factors, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout ) # 双向输出维度是 hidden_size * 2 self.fc nn.Sequential( nn.Linear(hidden_size * 2, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, 1) ) def forward(self, x): # x: (batch, seq_len, n_factors) out, _ self.bilstm(x) # 取最后一个时间步的输出 last out[:, -1, :] return self.fc(last).squeeze(-1) # 使用示例 n_factors len(selected_factors) model BiLSTMRegressor(n_factorsn_factors, hidden_size64, num_layers2) X_seq, y_seq build_sequences(factor_array, label_array, seq_len60) print(X_seq.shape, y_seq.shape) # (样本数, 60, n_factors), (样本数,)build_sequences把二维的因子矩阵切成一个个长度为 60 的窗口每个窗口对应一个下一天的收益标签。BiLSTMRegressor里hidden_size64表示每个方向的 LSTM 有 64 个隐单元双向拼起来就是 128 维。num_layers2叠两层增加表达能力但层数一多就容易过拟合量化数据信噪比低两层基本是上限。dropout0.3在 LSTM 层和全连接层都加是防过拟合的关键。最后取out[:, -1, :]是因为我们要预测的是窗口结束后的下一天用最后一个时间步的表示最合理。3.3 训练循环与早停策略from torch.utils.data import TensorDataset, DataLoader def train_bilstm(model, X_train, y_train, X_val, y_val, epochs100, batch_size64, lr1e-3, patience10): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) train_ds TensorDataset(torch.FloatTensor(X_train), torch.FloatTensor(y_train)) train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleFalse) # 时序数据不打乱 optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay1e-5) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience5, factor0.5) criterion nn.MSELoss() best_val_loss float(inf) wait 0 for epoch in range(epochs): model.train() for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() # 验证 model.eval() with torch.no_grad(): val_pred model(torch.FloatTensor(X_val).to(device)) val_loss criterion(val_pred, torch.FloatTensor(y_val).to(device)).item() scheduler.step(val_loss) if val_loss best_val_loss: best_val_loss val_loss wait 0 torch.save(model.state_dict(), best_bilstm.pth) else: wait 1 if wait patience: print(fEarly stop at epoch {epoch}) break model.load_state_dict(torch.load(best_bilstm.pth)) return model这里有几个细节值得说。shuffleFalse是因为时序数据不能打乱打乱会破坏窗口之间的时间连续性。clip_grad_norm_把梯度裁剪到 1.0LSTM 训练时梯度爆炸是常事加上这个稳很多。ReduceLROnPlateau在验证损失不降时自动降学习率比手动调省心。早停的patience10意味着验证损失连续 10 轮不降就停防止在训练集上磨到过拟合。保存最佳模型而不是最后一轮是因为最后一轮往往已经过拟合了。4. 把两个模型串成策略信号生成、回测与避坑4.1 从预测值到持仓的完整链路BiLSTM 输出的是下一天的收益预测值要变成可执行的策略中间还有几步。常见做法是横截面排序每天对所有股票按预测收益从高到低排选前 10% 做多后 10% 做空或者只做多前 20%。持仓周期可以是一天也可以是五天取决于你的换手约束。回测时一定要扣掉交易成本A 股双边千三到千五是常见假设不扣成本的回测都是耍流氓。def backtest(pred_df, price_df, top_pct0.2, cost0.0015): pred_df: 每日每股预测收益index日期columns股票代码 price_df: 每日每股收盘价同结构 daily_returns [] dates pred_df.index for i in range(len(dates) - 1): today dates[i] tomorrow dates[i 1] preds pred_df.loc[today].dropna() if len(preds) 10: continue # 选预测收益最高的 top_pct n_select max(1, int(len(preds) * top_pct)) selected preds.nlargest(n_select).index # 计算次日收益 ret (price_df.loc[tomorrow, selected] / price_df.loc[today, selected] - 1) gross ret.mean() # 扣双边交易成本 net gross - cost * 2 daily_returns.append(net) return pd.Series(daily_returns, indexdates[1:len(daily_returns)1]) # 计算净值曲线和夏普 rets backtest(pred_df, price_df) nav (1 rets).cumprod() sharpe rets.mean() / rets.std() * np.sqrt(252) print(f年化夏普: {sharpe:.2f}, 累计收益: {nav.iloc[-1]-1:.2%})top_pct0.2表示选前 20% 的股票这个比例可以根据你的资金量和股票池大小调。cost0.0015是单边千分之一点五双边就是千三比较接近实际。回测里最容易忽略的是停牌和涨跌停真实交易中涨停买不进、跌停卖不出回测如果不处理这些收益会虚高。严谨的做法是加一个可交易性过滤涨停的股票次日不买入跌停的不卖出。4.2 避坑与常见问题排查现象一回测夏普很高实盘一上就亏。原因通常是数据泄露比如用了未来函数、因子计算里混入了未来信息、或者标准化时用了全样本的均值和方差。解决方法是严格按时间切分所有预处理标准化、填充都只在训练集上 fit再 transform 到验证集和测试集。现象二BiLSTM 训练损失一直降验证损失很早就开始升。这是典型的过拟合。原因可能是模型太大、训练样本太少、或者因子噪声太大。解决方法是减小hidden_size、减少num_layers、增大dropout、加 L2 正则或者干脆减少因子数量只保留 LightGBM 选出来的 top 10。现象三LightGBM 选出来的因子每次跑都不一样。原因是没有固定随机种子或者用了普通 KFold 导致切分不稳定。解决方法是在LGBMRegressor里设random_state并且用TimeSeriesSplit做时序切分多折平均重要度。现象四预测值和实际收益的 IC 很低甚至为负。原因可能是因子和标签的窗口不匹配比如用 T 日因子预测 T1 收益但因子本身包含了 T1 的信息。解决方法是仔细检查每个因子的计算逻辑确保在 T 日收盘后只能用到 T 日及之前的数据。另一个可能是标签的构造有问题比如用了未来 N 日的累计收益但没做对齐。现象五回测换手率极高成本吃掉大部分收益。原因是预测信号日频波动太大每天持仓都大变。解决方法是对预测值做平滑比如用 5 日移动平均或者限制每日调仓比例不超过 20%也可以拉长持仓周期到 5 天或 10 天。5. 进阶技巧用滚动训练和集成让策略更稳走到这一步你已经有了一个能跑的 LightGBM BiLSTM 策略。但直接拿一个固定模型去实盘风险还是大。我自己的习惯是加两层保险滚动训练和模型集成。滚动训练的意思是不要用全部历史数据训练一个模型然后一直用而是每隔一段时间比如每季度用最近 N 年的数据重新训练一次。这样模型能跟上市场结构的变化。具体做法是维护一个滚动窗口比如用过去 3 年数据训练预测未来 1 个季度然后窗口向前滑动。代码上就是在训练前按日期切片重新跑一遍 LightGBM 筛选和 BiLSTM 训练。def rolling_train(df, train_years3, retrain_freqQ): dates df.index.get_level_values(0).unique().sort_values() retrain_dates pd.date_range(dates[0], dates[-1], freqretrain_freq) all_preds [] for rd in retrain_dates: train_start rd - pd.DateOffset(yearstrain_years) train_data df.loc[train_start:rd] if len(train_data) 500: continue # 重新筛选因子 factors, _ select_factors(train_data, top_k15) # 重新训练 BiLSTM factor_arr train_data[factors].values label_arr train_data[label].values X_seq, y_seq build_sequences(factor_arr, label_arr, seq_len60) model BiLSTMRegressor(n_factorslen(factors)) model train_bilstm(model, X_seq[:-100], y_seq[:-100], X_seq[-100:], y_seq[-100:]) # 预测下一个季度 next_q rd pd.DateOffset(months3) pred_data df.loc[rd:next_q] if len(pred_data) 60: continue pred_arr pred_data[factors].values X_pred, _ build_sequences(pred_arr, np.zeros(len(pred_arr)), seq_len60) model.eval() with torch.no_grad(): preds model(torch.FloatTensor(X_pred)).numpy() all_preds.append(pd.Series(preds, indexpred_data.index[60:])) return pd.concat(all_preds)模型集成则是另一个维度。不要只训练一个 BiLSTM而是训练 5 个不同随机种子、不同网络结构的 BiLSTM把它们的预测值平均。这样能显著降低单模型的方差。LightGBM 那边也可以集成用不同num_leaves和learning_rate跑多个模型取重要度的交集作为最终因子集。验证方法上除了看夏普和最大回撤我还会做两件事。一是分年度看收益如果某一年特别差要搞清楚是市场风格变了还是模型有问题。二是看因子暴露用 Barra 那套做归因确认策略没有在某个风格因子上押注过重。如果发现收益主要来自小市值暴露那就要小心了小市值因子在 A 股历史上波动极大。最后说个我自己的教训。早期做这类策略时我总想把模型调得特别复杂觉得越复杂越厉害。后来发现LightGBM 选出来的因子往往就是那几个最朴素的——动量、波动、换手BiLSTM 的 hidden_size 从 128 降到 64 反而样本外更好。量化这行简单和稳健比花哨重要得多。每次想加新东西之前先问自己这个改动在样本外能稳定提升吗如果不能就不加。希望帮到你。本文还有配套的精品资源点击获取