股票收益预测:逻辑回归+梯度提升+LSTM多模型融合实战

发布时间:2026/10/3 8:54:42
股票收益预测:逻辑回归+梯度提升+LSTM多模型融合实战 简介这是一份融合逻辑回归、梯度提升与LSTM三类模型预测下月股票收益的量化策略项目适合金融科技、数据挖掘方向的学生用于毕业设计或课程设计参考。项目基于Python实现利用基本面与情绪特征判断收益正负每月选取预测收益为正的前20%股票建立多头仓位同时对市场指数建立空头头寸形成市场中性组合。包体共20个文件包含1个策略脚本、1个Jupyter Notebook、1份英文说明文档和1个答辩PPT另附16张组合绩效、月度标准差、beta上限分析等可视化图表压缩包约1.8MB结构清晰。目前已有115人学习下载。下载后可直接运行测试源码涵盖数据获取、特征构建、模型训练与回测评估全流程配合说明文档可快速复现多空对冲策略答辩PPT内容完整可直接用作汇报展示适合需要整份项目范例的在校学生或从业者。1. “下个月股票收益预测”为什么值得用三个模型一起做做量化预测的人大多有过这种体验单拿一个模型回测漂亮得像印钞机一上实盘就被市场按在地上摩擦。逻辑回归、梯度提升、LSTM 这三类模型放在同一个项目里不是为了炫技而是因为它们分别站在三个完全不同的视角上——线性可分的概率视角、非线性特征组合的树模型视角、以及时序依赖的循环神经网络视角。把三种模型放到同一份股票收益数据上对比和融合等于同时请了三个性格迥异的分析师来研判同一件事谁也别想只手遮天。这个项目标题里的“下个月股票收益预测”本质上是一个二分类或回归问题。多数人第一反应是拿日线收盘价直接喂给 LSTM指望网络自己“悟”出涨跌规律结果往往过拟合到怀疑人生。更稳的做法是先定义清楚你要预测的到底是什么——是下个月相对本月的收益率方向还是收益率的具体数值区间。方向问题用逻辑回归和梯度提升就能处理得很好LSTM 则更适合捕捉价格序列里的短期动量与波动聚集效应。三种模型各自输出结果后再做一致性投票或概率平均这个集成思路才是项目真正值钱的地方。适合读这篇笔记的人是已经会用 pandas 处理行情数据、但还没系统搭过“多模型对比 预测”框架的 Python 量化初学者以及想参考一份能直接改成交付物的课程设计 / 答辩项目结构的在校开发者。下面我直接按一个可复现的完整路径来讲先立住数据切分和特征工程的规矩再逐个实现三个模型最后把预测结果合并、给出评估和踩坑记录。2. 先把“下个月收益”定义清楚标签构造与特征工程的三个关键选择预测下个月收益最容易翻车的不是模型而是标签和特征。标签定义错了再好的模型也白搭特征里混入未来数据回测就是自欺欺人。2.1 标签构造用 T21 的收益方向做二分类而不是直接用回归常见做法是取每个交易日作为观察点计算该日后 21 个交易日约一个月的收益然后按正负打标。比如import pandas as pd import numpy as np df pd.read_csv(stock_daily.csv, parse_dates[date]) df df.sort_values([code, date]).reset_index(dropTrue) # 未来21日收益 df[fwd_ret] df.groupby(code)[close].transform( lambda x: x.shift(-21) / x - 1 ) # 二分类标签大于0记1否则0同时保留连续值用于回归对比 df[label_cls] (df[fwd_ret] 0).astype(int) df[label_reg] df[fwd_ret]这段代码里shift(-21)是核心它把当天的收盘价和 21 天后的收盘价对齐得到的fwd_ret就是“如果今天买入、持有 21 天后卖出”的收益。如果不用groupby而是全局shift多股票数据就会串行标签会错位。参数说明21 是基于 A 股自然月交易日的近似值。如果你做的是美股或者期货可以改成 20 或 22。换窗口大小时记得同时重新计算所有模型的评估结果不要只改这个数字不动其他部分。另一个关键点是剔除标签构造时的幸存者偏差。如果你只拿今天还在上市的股票做历史回测那些已经退市的股票就不在数据里模型学到的规律天然偏乐观。理想做法是引入历史时点的全量股票列表但多数课程项目拿不到退市数据那就至少在论文或说明文档里写清楚这个限制。2.2 特征工程把量价数据转成 17 维特征杜绝未来函数LSTM 对原始价格序列很敏感但逻辑回归和梯度提升需要手工构造特征。我一般不会把原始 open、high、low、close 直接塞给树模型因为绝对价格在不同股票间不可比必须转成比率和滞后值。# 基础特征 df[ret_1] df.groupby(code)[close].pct_change(1) df[ret_5] df.groupby(code)[close].pct_change(5) df[ret_10] df.groupby(code)[close].pct_change(10) df[vol_ratio] df[volume] / df.groupby(code)[volume].transform(mean) # 波动率 df[std_5] df.groupby(code)[ret_1].transform(lambda x: x.rolling(5).std()) df[std_10] df.groupby(code)[ret_1].transform(lambda x: x.rolling(10).std()) # 价格位置 df[high_20] df.groupby(code)[close].transform(lambda x: x.rolling(20).max()) df[low_20] df.groupby(code)[close].transform(lambda x: x.rolling(20).min()) df[pos_20] (df[close] - df[low_20]) / (df[high_20] - df[low_20]) # 移动平均偏离度 df[ma_5] df.groupby(code)[close].transform(lambda x: x.rolling(5).mean()) df[ma_20] df.groupby(code)[close].transform(lambda x: x.rolling(20).mean()) df[close_ma_ratio] df[close] / df[ma_20] - 1这里所有rolling和pct_change都只用了当前及历史数据没有用到未来信息。vol_ratio的量纲在不同股票间统一成“相对自身均值的倍数”比直接用成交量数值合理。注意pct_change(1)在第一行会产生 NaN后续 rolling 窗口计算也会在序列头部产生 NaN。训练前必须统一 dropna否则模型会报错或静默忽略导致不同模型的样本数不一致。feature_cols [ret_1, ret_5, ret_10, vol_ratio, std_5, std_10, pos_20, close_ma_ratio, open_ratio, high_ratio, low_ratio] df df.dropna(subsetfeature_cols [label_cls, label_reg])我一般会把特征控制在 15 到 20 个之间。特征太多容易让逻辑回归和梯度提升在小样本上过拟合太少则 LSTM 学不到足够的模式。上面的 8 个是骨架够跑通最小流程后面可以自己加 RSI、MACD 等衍生指标但每加一个都要在验证集上评估增量不要无脑堆。2.3 数据切分按时间顺序切绝不随机打乱股票数据是时序数据随机打乱再做 train/test split 是把未来信息泄露给模型的最快方式。正确做法是前 70% 做训练中间 15% 做验证最后 15% 做测试。# 按时间顺序切分 split_date_train df[date].quantile(0.7) split_date_valid df[date].quantile(0.85) train df[df[date] split_date_train] valid df[(df[date] split_date_train) (df[date] split_date_valid)] test df[df[date] split_date_valid] print(ftrain: {train[date].min()} ~ {train[date].max()}, size{len(train)}) print(fvalid: {valid[date].min()} ~ {valid[date].max()}, size{len(valid)}) print(ftest: {test[date].min()} ~ {test[date].max()}, size{len(test)})为什么要单独留验证集因为逻辑回归和梯度提升都有超参数LSTM 的层数和 dropout 也要调。如果你直接用测试集调参测试集就变成了训练的一部分最终评估数字一定虚高。验证集是调参用的“模拟考场”测试集只准跑一次。时序切分还有个隐藏坑相邻交易日的样本高度相关今天和明天的特征几乎一样标签也高度重叠。这会高估模型的泛化能力。严格的项目里会做“间隔 21 天抽样”来去重但作为入门方案先用时间切分守住底线即可。3. 逻辑回归先当基准线再谈预测效果逻辑回归在这个项目里不是用来直接做预测的“主角”而是用来做基线baseline的。它的价值在于如果梯度提升和 LSTM 连逻辑回归都打不过那说明特征或数据有问题不用浪费时间调复杂模型。3.1 为什么第一个模型选逻辑回归逻辑回归的假设是特征与 log-odds 之间呈线性关系。放在股票收益预测上它意味着“过去 5 天涨幅每增加 1%下个月上涨的概率提升多少”是一个固定倍数。这显然不是真实市场的样子但它能给出一个干净的、可解释的下界。它的另一个优势是天然输出概率。predict_proba给的不是“涨还是跌”的硬标签而是“上涨概率为 62%”这样的软信息后续做模型融合时需要这个概率值。梯度提升也能给概率但逻辑回归的概率更平滑、更不容易在极端区间过拟合。实现逻辑回归之前记得先对特征做标准化。树模型不怕量纲差异但逻辑回归的梯度下降对特征尺度敏感ret_1是 0.01 级别vol_ratio可能是 5 或 0.2不标准化的话训练会非常慢且容易不收敛。3.2 用 sklearn 搭出可复现的基线模型from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline X_train train[feature_cols].values y_train train[label_cls].values X_valid valid[feature_cols].values y_valid valid[label_cls].values # 逻辑回归对特征尺度敏感先标准化再训练 model_lr make_pipeline( StandardScaler(), LogisticRegression(C1.0, max_iter1000, random_state42) ) model_lr.fit(X_train, y_train) valid_prob_lr model_lr.predict_proba(X_valid)[:, 1] test_prob_lr model_lr.predict_proba(test[feature_cols].values)[:, 1]C1.0是正则化强度的倒数C 越小正则化越强。股票特征大多是弱信号我一般不会把 C 调得太小比如 0.01否则模型会偏向把几乎所有样本预测为多数类在类别不平衡时尤其明显。max_iter1000是为了避免默认 100 次迭代在标准化后仍不收敛的警告。random_state42固定随机种子保证每次运行结果一致答辩时数字可复现。校验逻辑回归是否工作正常看两个数训练集准确率和验证集准确率。如果两者都接近 60% 甚至 50%说明特征信号不足或标签本身接近随机这反而是一个需要正视的结论——下个月收益的方向预测准确率能稳定在 55% 以上已经不算差。3.3 逻辑回归的输出怎么解读给答辩听答辩时最常被问的问题是“你凭什么说逻辑回归有效”。这时不要只报准确率要报逻辑回归的系数。把标准化后的特征系数排序找出对上涨概率影响最大的前三个特征import numpy as np # 取出标准化后的系数 lr_model model_lr.named_steps[logisticregression] coefs pd.Series(lr_model.coef_[0], indexfeature_cols).sort_values() print(最负面的特征下跌信号:) print(coefs.head(3)) print(\n最正面的特征上涨信号:) print(coefs.tail(3))如果ret_10的系数显著为正说明过去 10 天涨幅大的股票下个月更可能继续涨这是动量效应如果pos_20的系数为负说明股价处于近 20 日高位时下个月反而容易回调这是反转信号。这些系数的正负和大小本身比预测准确率更有故事可讲。值得注意的是逻辑回归在类别不平衡时预测概率会整体偏保守。如果上涨样本占总样本的 45%那么概率阈值 0.5 并不一定是最优切分点。后面评估时会用 AUC 和自定义阈值来处理这个问题。4. 梯度提升用树模型抓住非线性特征组合梯度提升Gradient Boosting在这个项目里负责捕捉逻辑回归看不出的非线性交互作用。逻辑回归只能看到特征的线性加权和而梯度提升能自动组合出类似“过去 5 天涨幅大于 3% 且波动率小于 1% 时上涨概率大幅提升”这样的规则。XGBoost、LightGBM、CatBoost 都是梯度提升的工程化实现这里用 LightGBM 为例因为它在小数据集上训练极快且默认参数就能出不错的效果。4.1 为什么这里选 LightGBM 而不是 XGBoostXGBoost 和 LightGBM 的核心思想一致区别在于工程实现。LightGBM 使用基于直方图的算法把连续特征分箱后寻找最优分裂点训练速度在万级样本上几乎比 XGBoost 快一个数量级。本项目数据量几十万行以内实际上两者都能跑但 LightGBM 在时间序列场景下还有一个实用优势它原生支持类别特征且对缺失值有内置处理。另一个理由是 LightGBM 的叶子生长策略leaf-wise比 XGBoost 的 level-wise 更容易过拟合但同时也更容易在数据有真实信号时达到更低损失。对小规模股票数据我建议用较小的num_leaves比如 31来控制模型复杂度而不是用默认值 31 的同时还加很高深度的限制。4.2 训练与验证早停法选最优轮数import lightgbm as lgb # 构建 LightGBM 数据集 train_data lgb.Dataset(X_train, labely_train) valid_data lgb.Dataset(X_valid, labely_valid, referencetrain_data) params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, max_depth: 5, min_child_samples: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, verbose: -1, seed: 42, } model_gbm lgb.train( params, train_data, num_boost_round500, valid_sets[valid_data], callbacks[lgb.early_stopping(stopping_rounds50), lgb.log_evaluation(50)] ) valid_prob_gbm model_gbm.predict(X_valid) test_prob_gbm model_gbm.predict(test[feature_cols].values)这里num_boost_round500是最大迭代轮数真正生效的轮数由早停决定。early_stopping(stopping_rounds50)表示连续 50 轮验证集 AUC 不提升就停止最终模型自动保留最优轮数。learning_rate0.05是步长调小到 0.01 会让模型更稳但需要更多轮数对时间序列这种带噪数据0.05 是折中选择。num_leaves31是 Leaf-wise 树的最大叶子数。叶子数越多模型越复杂31 对应深度约 5 的完全二叉树对 15 个特征的中等规模数据足够了。feature_fraction0.8和bagging_fraction0.8是列采样和行采样相当于给树模型加随机性降低过拟合时间序列场景下也能缓解样本间自相关带来的影响。训练一个关键点是LightGBM 的Dataset对象一旦用construct前没把特征类型设好会在运行时自动转类型但速度会下降。建议确保feature_cols里全部是 float 类型不要有 object 类型混入。4.3 特征重要性树模型的“黑匣子”解释出口答辩和实际使用中树模型最被诟病的就是不可解释性。特征重要性是唯一能让人稍微看到内部的窗口。importance pd.Series( model_gbm.feature_importance(importance_typegain), indexfeature_cols ).sort_values(ascendingFalse) print(importance)gain类型的重要性衡量的是该特征在分裂时带来的平均信息增益总和。一般来说ret_10、std_5这类中周期动量与波动特征会排在最前面。如果发现vol_ratio重要性几乎为零可以把它删掉重训对比验证集 AUC 是否下降。如果不下降或微降说明它是噪音删掉能让模型更简洁。树模型在这个项目里最容易犯的错是直接用默认参数训练而不做早停。这样会导致训练轮数过多完全记住训练集的噪声验证集 AUC 可能只有 0.5 左右比逻辑回归还差。加了早停之后梯度提升通常能比逻辑回归高 2 到 4 个百分点的 AUC这就是它存在的意义。梯度提升的预测概率分布和逻辑回归有明显差异逻辑回归的概率集中在 0.35 到 0.65 之间梯度提升的概率会更均匀地铺在 0 到 1 之间。这个差异在模型融合时会成为优势因为我们希望不同模型的预测方向不完全重合。5. LSTM把 60 天价格序列压进时间步再交给记忆单元LSTM 的部分是项目里门槛最高、也最容易让新手心态爆炸的环节。它不是用来替代前两个模型的而是用来捕获“序列上下文”——比如连续几天的量价形态对下个月收益的影响这对树模型和逻辑回归来说是无形的。5.1 构造 3D 输入样本量 × 时间步 × 特征维度LSTM 的输入要求是三维张量而表格数据是二维的。我们得把每个样本从“一行特征”变成“一段长度为 60 天的特征序列”。import numpy as np import torch from torch.utils.data import TensorDataset, DataLoader def make_sequence_data(df, feature_cols, seq_len60): 把按时间排序的 df 转成 LSTM 需要的序列样本。 每个样本是 [seq_len, num_features]标签是该样本时间点之后21日的收益方向。 codes df[code].unique() X_list, y_list [], [] for code in codes: sub df[df[code] code].sort_values(date) values sub[feature_cols].values labels sub[label_cls].values for i in range(seq_len, len(sub)): X_list.append(values[i - seq_len:i]) # 过去60天特征 y_list.append(labels[i]) # 第i天的标签 return np.array(X_list), np.array(y_list) X_seq, y_seq make_sequence_data(train, feature_cols, seq_len60) print(X_seq shape:, X_seq.shape) # (样本数, 60, 特征数)这里有个重要的对齐逻辑第 i 天的标签label_cls已经是根据第 i 天之后 21 天收益打好的模型看到的输入是第 i-60 天到第 i-1 天的特征。这样在时间轴上严格没有未来信息泄露。seq_len60对应约 3 个月交易日。窗口太短比如 20 天会丢失中期趋势信息太长比如 120 天会让样本量骤减且训练极慢。60 是平衡选择。这个参数值得做实验分别用 30、60、90 跑一遍比较验证集 AUC选最稳的。注意make_sequence_data是按股票代码遍历的每个股票开头的seq_len个样本会被舍弃所以最终样本量会略小于表格模型。这是正常现象LSTM 本身就是吃序列数据的模型。5.2 PyTorch 实现两层 LSTM 加 Dropout避免一上来就过拟合下面是一个能直接跑通的最小 LSTM 分类网络import torch.nn as nn class StockLSTM(nn.Module): def __init__(self, n_features, hidden_size64, num_layers2, dropout0.3): super().__init__() self.lstm nn.LSTM( input_sizen_features, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout, ) self.fc nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, 1), ) def forward(self, x): # x: [batch_size, seq_len, n_features] out, _ self.lstm(x) # 取最后时间步的隐状态 last out[:, -1, :] # [batch_size, hidden_size] return self.fc(last).squeeze(1) model_lstm StockLSTM(n_featureslen(feature_cols), hidden_size64, num_layers2)batch_firstTrue让输入形状从(seq_len, batch, features)变成(batch, seq_len, features)和前面make_sequence_data的输出对齐。两层 LSTM 比单层能学习更高层的时序抽象但在数据量不大时两层以上基本只会增加过拟合风险。hidden_size64在这个规模下是安全默认值调大可以提升拟合能力但训练时间和内存也会涨。dropout0.3对时间序列预测非常重要。股票数据本身信噪比极低不加 dropout 的 LSTM 几乎必然在训练集上把每个噪声点都记住。Dropout在两处生效LSTM 层间和最后全连接层前。5.3 训练循环只盯验证集 AUC别被训练 loss 骗了from torch.utils.data import DataLoader, TensorDataset import torch.optim as optim X_seq_valid, y_seq_valid make_sequence_data(valid, feature_cols, seq_len60) X_test_seq, y_test_seq make_sequence_data(test, feature_cols, seq_len60) # 转为张量 X_seq_t torch.tensor(X_seq, dtypetorch.float32) y_seq_t torch.tensor(y_seq, dtypetorch.float32) X_valid_t torch.tensor(X_seq_valid, dtypetorch.float32) y_valid_t torch.tensor(y_seq_valid, dtypetorch.float32) train_loader DataLoader(TensorDataset(X_seq_t, y_seq_t), batch_size256, shuffleTrue) valid_tensor TensorDataset(X_valid_t, y_valid_t) valid_loader DataLoader(valid_tensor, batch_size256, shuffleFalse) optimizer optim.Adam(model_lstm.parameters(), lr0.001) criterion nn.BCEWithLogitsLoss() def predict_prob(model, loader): model.eval() preds [] with torch.no_grad(): for xb, _ in loader: logits model(xb) preds.append(torch.sigmoid(logits).numpy()) return np.concatenate(preds) best_auc 0 for epoch in range(50): model_lstm.train() for xb, yb in train_loader: optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() optimizer.step() if (epoch 1) % 5 0: valid_pred predict_prob(model_lstm, valid_loader) auc roc_auc_score(y_seq_valid, valid_pred) print(fepoch {epoch1}: valid AUC {auc:.4f}) if auc best_auc: best_auc auc torch.save(model_lstm.state_dict(), best_lstm.pt)BCEWithLogitsLoss是数值稳定性最好的二分类损失函数它把 sigmoid 和交叉熵合并计算避免中间log(0)的问题。模型输出的是未经过 sigmoid 的 logits预测概率时再手动套sigmoid。lr0.001是 Adam 的常用默认值如果 loss 震荡明显降到 0.0005。这里我用的是“每 5 个 epoch 看一次验证集 AUC保存最优权重”的策略比简单固定 epoch 数更稳。因为 LSTM 训练后期往往会出现验证集 AUC 先升后降的过拟合曲线最优模型通常不是最后一个 epoch 的模型。torch.save只保存权重加载时需重新实例化网络。一个常见的坑是训练 LSTM 时把shuffleTrue当成万能开关。在时序数据里shuffle 会导致同一股票的相邻序列被打散模型无法利用时间顺序信息。但这和“序列内顺序”不同——每个样本内部的 60 天顺序是保留的shuffle 的只是样本间的顺序这没有问题。真正不能做的是打乱时间切分边界我们在前面已经守住了。6. 三个模型谁说了算融合、评估与阈值选择避坑拥有三个模型的预测概率后项目才进入最容易被忽视的环节——模型融合和评估。很多人分别跑完三个模型各自报一个准确率就收工这恰恰把集成项目的价值浪费了。6.1 概率平均与投票融合什么时候用哪个最简单有效的融合方式是概率平均# 三个模型在验证集上的预测概率 valid_prob_all np.column_stack([valid_prob_lr, valid_prob_gbm, valid_prob_lstm]) # 方案一简单平均 ensemble_avg valid_prob_all.mean(axis1) # 方案二加权平均按验证集AUC做软权重 from sklearn.metrics import roc_auc_score auc_lr roc_auc_score(y_valid, valid_prob_lr) auc_gbm roc_auc_score(y_valid, valid_prob_gbm) auc_lstm roc_auc_score(y_valid, valid_prob_lstm) weights np.array([auc_lr, auc_gbm, auc_lstm]) weights / weights.sum() ensemble_wavg valid_prob_all weights概率平均适用于三个模型概率分布尺度差异不太大的情况。逻辑回归的概率集中在 0.5 附近而 LSTM 的概率可能两极分化严重直接平均会被极端值带偏。所以通常我会先看各自的predict_proba分布再决定用平均还是用排序秩平均。投票融合则适用于更严格的方向一致性场景# 用0.5阈值把概率转成方向 pred_lr (valid_prob_lr 0.5).astype(int) pred_gbm (valid_prob_gbm 0.5).astype(int) pred_lstm (valid_prob_lstm 0.5).astype(int) # 三票中至少两票看涨则预测为涨 ensemble_vote ((pred_lr pred_gbm pred_lstm) 2).astype(int)投票的本质是降低单一模型的随机噪声。如果某个模型的阈值设置不当它的投票意见会持续拖后腿所以投票前一定要先把每个模型的阈值调到验证集最优而不是默认 0.5。下一小节就讲阈值怎么选。6.2 阈值选择精确率与召回率哪一个才是你真正想要的默认阈值 0.5 在股票预测里几乎总是次优的。因为我们的标签往往不平衡而且“预测上涨但实际下跌”和“预测下跌但实际涨了”带来的心理与资金成本不对称。用验证集画出精确率—召回率曲线再选阈值from sklearn.metrics import precision_recall_curve precisions, recalls, thresholds precision_recall_curve(y_valid, ensemble_avg) # 选F1最大的阈值 f1_scores 2 * (precisions * recalls) / (precisions recalls 1e-9) best_idx np.argmax(f1_scores) best_threshold thresholds[best_idx] print(f最佳阈值: {best_threshold:.4f}, F1: {f1_scores[best_idx]:.4f})这个阈值直接用于测试集预测test_pred_final (ensemble_avg_test best_threshold).astype(int)阈值是根据验证集选出来的选完后再放到测试集上评估流程上才是干净的。如果你跳过阈值选择直接用 0.5最终看到的准确率往往接近 50%答辩时会被一句话问倒。实践中选阈值时还要考虑另一个因素回测的交易成本。即使你预测对了方向如果涨幅只有 0.5%扣掉佣金和印花税后实际是亏的。所以阈值可以再稍微调高只保留模型最笃定的上涨预测提高精确率牺牲一部分召回率。6.3 三个模型融合后 AUC 没提升五个原因逐一排查模型融合不是万能的。如果融合后的 AUC 和最优单模型差不多甚至反而更低通常落在以下五个原因里。第一三个模型预测概率高度相关。梯度提升和 LSTM 虽然在结构上不同但在同一个特征集上学到的信号可能重合度极高。排查方法是看验证集上三个概率的两两 Spearman 相关系数如果都大于 0.8融合几乎没有增益。第二LSTM 过拟合导致验证集概率失真。LSTM 调参不当会在验证集上给出极端概率 0.02 或 0.98拉低平均结果。排查方法是先单独看 LSTM 的 AUC如果明显低于逻辑回归优先修 LSTM 而不是融合。第三特征工程本身信号太弱。逻辑回归和梯度提升都只有 0.52 的 AUC 时融合最多抬到 0.54期待 0.6 是不现实的。这时候应该回去加特征而不是折腾融合权重。第四样本数量不一致。三个模型因为序列长度和缺失值处理的原因训练样本数可能不同。如果融合时直接把三组概率按行对齐但某个模型因为 dropna 丢掉了一些行对齐就错位了。必须用相同的索引做 inner join确认行数一致再拼接别用np.column_stack硬拼。第五测试集时间窗口和市场环境变化大。股票模型的时效性很强训练集在牛市、测试集在熊市再好的融合也白搭。这一点属于数据分布漂移问题可以在说明文档里如实披露这也是一份负责任的源码说明该有的态度。提示模型评估不要只盯 AUC 和准确率。下个月收益预测的核心价值在于方向判断后能不能赚到钱建议额外计算策略回测的期望收益——用预测方向做多或做空扣除交易成本后看净值曲线是否跑赢基准。这是答辩时最能打动评委的加分项。6.4 最后再补一个比较隐蔽的坑标签重叠导致的“看似很准”由于我们用的是 21 日收益作为标签相邻交易日的样本共享了大部分未来窗口。比如 1 月 1 日买入持有到 1 月底和 1 月 2 日买入持有到 2 月初两个标签背后的未来行情有约 20 天重叠。这会让验证集和测试集之间存在信息重叠评估结果偏乐观。严格的处理方法是在切分测试集时把训练集最后 21 天去掉或者在验证时做“21 日间隔抽样”。但这样做会显著减少测试样本对小项目不太现实。保守的做法是不要在说明文档里把测试集 AUC 当成未来真实收益的预测而是把它当作“相对排名参考”。如果一定要给一个可靠的收益区间就用测试集上每个预测月的单独收益做统计而不是把每天的结果全混在一起算。这也是为什么最后一章我会单独讲怎么用第一个月的真实结果做上线验证而不是让模型直接“裸奔”去实盘。7. 从回测到“敢用”滚动再训练与月度验证的落地技巧项目跑完后模型不会一劳永逸。股票市场的统计特性几个月就会变化去年有效的特征今年可能就是噪音。一个能落地的预测系统必须设计成每月自动重训。具体做法是每个月发版前用截至上月末的所有数据重新切分训练集和验证集重新训练三个模型并更新融合权重。我会用如下流程每月最后一个交易日收盘后拉取数据更新到本地数据库重新构造标签和特征特征列保持不变按时间切分最近 6 个月做验证集其余做训练集训练三个模型选出各自最优超参数树模型用早停LSTM 用验证集 AUC 早停在验证集上重新计算融合权重和最佳阈值对下个月每一天的候选股票输出预测概率并按下月阈值过滤出“只做精确率最高”那批信号。这一步的关键是自动化。我一般会写一个run_monthly_pipeline.py用 argparse 传入数据路径和输出目录配合 crontab 或 Windows 计划任务在每月 1 号自动执行。输出的 CSV 里只保留三列股票代码、预测上涨概率、是否通过阈值过滤。# run_monthly_pipeline.py 的核心骨架 import argparse parser argparse.ArgumentParser() parser.add_argument(--data_path, typestr, requiredTrue) parser.add_argument(--output_dir, typestr, requiredTrue) parser.add_argument(--month_end, typestr, requiredTrue, help格式YYYY-MM-DD) args parser.parse_args() # 主流程加载数据 - 构造特征 - 切分 - 训练 - 融合 - 输出 print(f处理截至 {args.month_end} 的数据输出到 {args.output_dir})这个脚本不需要很复杂但必须有两点保障一是每次运行都记录随机种子保证结果可复现二是把每个模型的验证集 AUC 和融合权重写入一个 JSON 日志文件方便事后追溯哪个月的权重被调成了什么样。上线验证的第一周不要只看预测方向对不对。建议记录预测概率最高的前 20 只股票的下一日实际收益画一条小样本的累计净值曲线。如果前 20 只的累计收益跑不赢沪深 300说明模型的“高置信区间”并不可靠下一期把阈值调更高宁缺毋滥。还有一个容易被忽视的细节清洗数据时要检查停牌和长时间缺失的股票。停牌复牌后会有一个大幅补跌或补涨这类异常值会让 LSTM 学到奇怪的模式。我通常会先剔除“过去 60 天内有超过 10 天无交易”的股票再送入模型这样序列数据的连续性才有保障。最后说说我自己的教训我最早做这个方向时一次性把测试集跑了十几次每次微调一点参数最终选出最好的一次结果写进报告但那其实是“拿测试集调参”的作弊操作。后来我把测试集锁死只在验证集上迭代虽然最终 AUC 比之前“最好成绩”低了两个点但真实可重复性高得多。这个习惯一直留到今天。如果你正在做类似的项目记住一句话模型融合的收益来自差异性而不是数量。三个高度相似的模型加起来还是等于一个。把逻辑回归当基线、梯度提升当非线性捕手、LSTM 当时序探测器三者各有分工融合后的结果才有说服力。希望这篇笔记能帮你在答辩和实际部署里少走几段弯路。本文还有配套的精品资源点击获取