☰
MSCNN+BiLSTM+Attention在招商银行股价预测中的应用与实践
2026/10/22 5:14:20 网站建设 项目流程

1. 为什么用MSCNN+Bilstm+Attention这个组合

先聊一个很多初学者都会遇到的现象:拿单层的LSTM跑股票收盘价预测,训练集上损失曲线漂亮得像教科书一样,放到测试集上却发现预测曲线比真实走势"慢半拍"——涨的时候预测还在横盘,跌的时候预测还在高位。这并不是代码写错了,而是单一结构对金融时序的刻画能力确实有天花板。

我当初做这个招商银行A股预测项目时,也是先跑了几个基线模型,然后才决定用MSCNN+Bilstm+Attention的组合。先说结论:这个组合之所以值得用,是因为它把三类不同性质的时序特征提取任务分给了三个模块。

第一个模块是多尺度卷积(MSCNN,Multi-Scale CNN)。它的职责是捕捉不同时间跨度的局部模式。股票日线数据里,3天的短期动量、7天到10天的波段形态、20天左右的中期趋势,往往同时影响着后续走势。如果只用单一卷积核尺寸,等于强迫模型只用一种"观察窗口"看行情,容易顾此失彼。多尺度卷积的做法是用几组不同尺寸的卷积核并行扫描同一段序列,再把特征拼起来,相当于同时用短线、中线、长线三种视角去看同一段历史。

第二个模块是双向LSTM(Bidirectional LSTM)。它解决的是上下文依赖问题。很多人问:预测未来走势,看过去就够了,为什么要用双向结构?关键在于,模型在训练时看到的是一段完整的已知序列,某个时间点的状态不仅受它之前的数据影响,也受它之后的数据影响。比如"放量突破"这种形态,如果后面没有持续放量,这个突破的有效性会大打折扣。双向LSTM能在编码阶段就让每个时间步同时获得两侧的信息,对形态的确认更稳健。

第三个模块是Attention注意力机制。它解决的是"历史序列中哪些时刻最重要"的问题。LSTM虽然能记住长期信息,但把所有时间步的隐状态平均使用,等于认为5天前的行情和50天前的行情对明天的预测同等重要——这显然不合理。Attention会给每个时间步学习一个权重,模型自动把注意力集中在与当前预测最相关的历史片段上。在金融数据里,这种"自动挑选关键交易日"的能力非常实用,比如模型会倾向于关注前一次同级别回调的末端、放量启动的首日等关键节点。

三个模块的组合逻辑可以概括成一句话:先用多尺度卷积提取局部特征、压缩序列长度,再用双向LSTM建模双向上下文依赖,最后用Attention对时间步加权输出。这个顺序基本是这套结构在时序任务上的标准用法,也是后面所有代码和数据能跑通的核心基础。

2. 招商银行数据的准备与预处理:前复权、停牌与滑窗

2.1 数据源与复权问题

项目里用的是招商银行在A股的历史日线数据,直接可以从常见的行情数据接口拿到,或使用压缩包内已整理好的数据文件。这里必须强调一个新手最容易忽略的问题:分红除权。

招商银行属于每年定期分红的大盘蓝筹股,股价会因为除权除息产生"跳空缺口"。如果不做复权处理,模型会看到一个莫名其妙的断崖下跌,然后试图去拟合这个根本不存在的"下跌"。所以正式建模之前,务必确认数据是前复权价格。

前复权的逻辑是以最新价格为基准,把历史价格统一向下调整,让价格序列从图形上看是连续的,消除了除权缺口对技术形态的破坏。拿到数据后可以先画一下收盘价曲线,如果序列里有明显的突兀跳空,先检查是不是除权导致的。压缩包里的数据如果已经做过前复权,直接使用即可,但这里建议大家养成自己核对的习惯。

2.2 样本构造:滑窗逻辑

模型预测的任务定义是:用过去一段时间的历史数据,预测未来某个时刻的收盘价或涨跌幅。我采用的是滑动窗口方式构造样本。

以招商银行日线数据为例,假设窗口长度设为seq_len=60,意思是取过去60个交易日的特征序列,预测第61天的收盘价。滑窗从第60天开始,每滑动1天生成一个样本。这样整个数据集能生成总交易日数 - seq_len个样本。

特征列的选择上,最基础也最有效的是OHLCV(开盘价、最高价、最低价、收盘价、成交量),可以再加上一些衍生技术指标,比如涨跌幅、换手率等。我实测下来,对预测稳定性帮助最大的是不把原始价格直接喂给模型,而是转换成一阶差分或涨跌幅序列。原因也很简单:原始价格序列非平稳性很强,直接回归容易让模型学到一个"复制上一日收盘价"的偷懒方案,看似损失很低,实际没有任何预测价值。

2.3 数据划分:时序不能随机打乱

分类任务里做随机划分没问题,但时序预测任务里绝对不能用随机打乱的方式划分训练集和测试集,否则会造成严重的数据泄漏——训练集里的数据可能比测试集要预测的日期更晚,等于拿"未来"训练模型去预测"过去"。

我采用的切分方式是:按时间顺序,前80%作为训练集,中间10%作为验证集,最后10%作为测试集。验证集用于早停和调参,测试集只在最终评估时使用一次,防止模型在测试集上反复调参导致过拟合。

2.4 标准化:Scaler只能在训练集上拟合

数据标准化使用的是MinMaxScaler,把特征压缩到[0, 1]区间。这里有个容易踩的坑:Scaler必须先fit在训练集上,再transform训练集、验证集和测试集,不能对全集先做归一化再划分。

如果先对全集做归一化,再划分训练和测试,测试集的数据分布信息其实已经进入训练过程了(scaler的min/max是从全集算出来的),这属于隐式数据泄漏,会让测试指标好看一些,但真实场景下根本做不到。

from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler() train_scaled = scaler.fit_transform(train_data) val_scaled = scaler.transform(val_data) test_scaled = scaler.transform(test_data)

提示:预测目标如果是收盘价,建议对目标值也做一次scaler变换;如果是涨跌幅,可以不归一化,用原始涨跌幅回归即可。涨跌幅数值本身已经很小,且通常落在[-0.1, 0.1]区间,直接回归能省去反归一化的步骤。

3. MSCNN_Bilstm_Attention模型的关键实现

3.1 三个核心模块的PyTorch实现思路

模型结构并不复杂,重点是理解每个组件的数据维度变化。这里用PyTorch实现思路来说明。

输入形状为(batch, seq_len, feature_dim),即一次训练传入的批量样本,每条样本包含60个交易日的若干维特征。

第一阶段是多尺度卷积。卷积操作是作用在序列维度上的,所以需要把seq_len当作卷积操作的"宽度",把特征维度当作"通道"。多尺度可以设计三组并行的一维卷积,卷积核尺寸分别设为3、5、7,输出通道数统一(比如32个)。

import torch.nn as nn class MSCNN(nn.Module): def __init__(self, seq_len, feature_dim, out_channels=32): super().__init__() self.conv1 = nn.Conv1d(feature_dim, out_channels, kernel_size=3, padding=1) self.conv2 = nn.Conv1d(feature_dim, out_channels, kernel_size=5, padding=2) self.conv3 = nn.Conv1d(feature_dim, out_channels, kernel_size=7, padding=3) self.relu = nn.ReLU() def forward(self, x): # x: (batch, seq_len, feature_dim) -> (batch, feature_dim, seq_len) x = x.transpose(1, 2) c1 = self.relu(self.conv1(x)) c2 = self.relu(self.conv2(x)) c3 = self.relu(self.conv3(x)) out = torch.cat([c1, c2, c3], dim=1) # (batch, 96, seq_len) return out.transpose(1, 2) # (batch, seq_len, 96)

padding的目的是让卷积操作不改变时间步的长度,这样后续可以继续接BiLSTM而不需要对序列长度做额外处理。如果不做padding,序列长度会缩短,BiLSTM的输入维度也需要相应调整。

卷积输出通道数为out_channels * 3,所以BiLSTM的输入特征维度就是96。双向LSTM层数我设置为2层,隐藏单元数取64。由于是双向,每个时间步输出的特征维度是hidden_size * 2 = 128。

Attention层的实现有点讲究。常见的做法是拿BiLSTM最后一个时间步的隐状态当query,对所有时间步的隐状态做注意力加权。但金融预测里我更推荐直接用可学习的注意力权重,不依赖query向量,让模型自己学每个历史交易日的重要性。

class Attention(nn.Module): def __init__(self, hidden_size): super().__init__() self.hidden_size = hidden_size self.W = nn.Linear(hidden_size * 2, 1, bias=False) def forward(self, lstm_output): # lstm_output: (batch, seq_len, hidden_size*2) attn_scores = self.W(lstm_output).squeeze(-1) # (batch, seq_len) attn_weights = torch.softmax(attn_scores, dim=1) context = torch.bmm(attn_weights.unsqueeze(1), lstm_output).squeeze(1) return context, attn_weights

加权后的context向量就是整段历史序列的浓缩表示,再接一个全连接层,输出一个标量作为下一日收盘价的预测值。

3.2 训练配置与超参选择

训练过程中的超参数直接影响最终指标,这里给出我实测后比较稳定的一组配置:

超参数值说明
窗口长度 seq_len60约一个季度交易日数
batch_size32实测64时收敛略慢
初始学习率1e-3Adam优化器
卷积输出通道32三组合计96
BiLSTM隐藏层64双向后为128
BiLSTM层数2再深容易过拟合
训练轮数80配合早停
早停patience10验证集loss连续10轮不降则停止

Loss函数选择上,预测收盘价回归任务用MSELoss是最直接的。也有人用HuberLoss(平滑L1),对异常值鲁棒性更好,但实测差别很小。早期我会在损失函数里叠加一个涨跌方向的辅助损失,让模型同时优化"数值准确"和"方向准确",效果有一定提升,但会引入额外的超参数,增加调试成本。

3.3 训练循环里的日志与保存

训练时除了记录loss,建议每个epoch末在验证集上计算MAE和涨跌准确率,这两个指标能更直观反映模型的真实水平。模型保存建议用torch.save(model.state_dict(), "best_model.pth"),保存验证集表现最好的那一份,而不是最后一轮的结果——因为早停前的最后一轮权重往往不是最优的。

best_val_mae = float("inf") for epoch in range(max_epochs): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() pred = model(batch_x) loss = criterion(pred, batch_y) loss.backward() optimizer.step() val_mae = evaluate(model, val_loader) if val_mae < best_val_mae: best_val_mae = val_mae torch.save(model.state_dict(), "best_model.pth")

实际训练中,招商银行这种价格波动相对平缓的股票,通常在30到50轮内就能见到验证集指标趋于稳定,如果超过80轮还在缓慢下降,大概率是过拟合或学习率过小。

4. 评估指标解读:MAE、MSE、MAPE与涨跌准确率

4.1 四个指标各自回答什么问题

模型训练完,压缩包里提供的评价脚本会输出四个指标:MAE、MSE、MAPE、涨跌准确率。这四个指标各有侧重,不能只看某一个。

MAE(平均绝对误差)回答的是"预测值与真实收盘价平均偏离多少钱"。比如招商银行股价在30到40元区间,MAE如果是0.5元,意味着平均偏差5毛钱。MAE的优点是直观、单位与股价一致,缺点是容易受少数极端预测影响,但它不像MSE那样会被极端值过度放大。

MSE(均方误差)因为对误差取了平方,大的误差会被显著放大。如果测试集上MSE明显比MAE的平方大很多,说明存在个别预测值严重偏离真实值的样本,需要检查是不是某些极端行情日(比如放量暴涨暴跌日)让模型"失手"了。

MAPE(平均绝对百分比误差)回答的是"预测偏差占真实股价的百分比"。它把不同价格水平的误差统一到了百分比口径,更便于跨股票、跨区间比较。需要注意:当真实收盘价接近0时MAPE会爆炸,A股股票没有0元的情况,所以这个指标在股价预测里基本可用。招商银行股价波动相对温和,MAPE一般来说能控制在3%以内算不错。

涨跌准确率回答的是"预测的涨跌方向与实际涨跌方向一致的占比"。这是最贴近交易视角的指标。回归模型虽然重点关注数值误差,但从实际应用角度看,方向判对了才有意义——方向错0.1元与错1元,损失的性质完全不同。

4.2 我对这些指标的实测解读

跑完测试集后,我得到的典型结果是:MAE在0.3元到0.6元之间,MAPE在1%到2%之间,涨跌准确率在50%到57%之间。

第一次跑完这个项目的人,看到MAC和MSE会觉得"挺好",但看到涨跌准确率55%左右又会觉得"就这?"。这里我说说自己的理解:日线级别的股价预测,方向准确率能稳定在55%以上,已经比随机猜测有明显优势了。原因很简单,A股次日涨跌本质上接近随机游走,能被模型捕捉到的规律非常有限。不要指望模型打出70%以上的日线涨跌准确率,凡是能做到这个水平的,要么是使用了未来数据,要么是样本外测试出了问题。

指标之间还有一个有趣的关系:MAE和MAPE好不一定代表涨跌准确率高。模型完全可以做到数值误差不大但方向经常错,尤其在收盘价预测值紧贴真实值上下小幅波动时,方向对错几乎是"抛硬币"。反过来,方向准确率高的模型,数值误差未必最小。所以我最终建议以涨跌准确率为主导指标,MAE作为辅助,这是从实际使用角度出发的选择。

4.3 归一化与反归一化的坑

如果预测目标是收盘价,训练时对目标列做了MinMax归一化,那评估时一定要把预测结果反归一化回原始价格,再计算MAE和MAPE,否则算出来的指标毫无意义。这个坑非常常见,特别是把训练代码和评估代码分开写的时候。

pred_price = scaler_y.inverse_transform(pred.reshape(-1, 1)) true_price = scaler_y.inverse_transform(y.reshape(-1, 1)) mae = mean_absolute_error(true_price, pred_price)

压缩包里如果代码和数据都准备好了,直接运行就能看到指标输出,这是项目最方便的地方。但建议大家在看完结果后,自己动手改一改评估逻辑,人为构造几个极端误差样本,看看四个指标各自怎么变化,这样能对指标特性建立直觉。

5. 测试集上的预测效果与残差分析

5.1 预测曲线的"滞后感"问题

测试集上把真实收盘价和预测值画在一起,很多人会注意到一个问题:预测曲线总是比真实曲线"慢半拍",涨跌拐点都延后了一两天。

这不是MSCNN_Bilstm_Attention模型独有的问题,而是所有以历史窗口回归未来值的模型都会遇到的共性现象。原因在于,模型在训练时看到的历史窗口与未来真实值之间存在相关关系,而模型学到的往往是一个"近似平滑外推"的策略。当行情发生剧烈反转时,模型还在根据过去几天的惯性做判断,自然显得迟钝。

缓解滞后感的思路有几个。最常见的是把预测目标从"第t+1天的收盘价"改成"未来5天的平均收盘价"或"第t+N天的涨跌幅",让模型不再死磕单日精确值,而是预测一个更平滑的目标。我试过把目标改为"未来3日涨跌幅",测试集涨跌准确率没有显著提升,但预测曲线的滞后感确实减轻了。

5.2 残差分布怎么看

建议评估时把(真实值 - 预测值)的残差序列画个直方图,观察是否存在显著的偏态。如果残差集中在0轴左侧,说明模型系统性低估股价;集中在右侧,则是系统性高估。招商银行长期历史走势整体偏震荡上行,模型往往在牛市长段里持续低估、在熊市阴跌段里持续高估,这种"趋势方向上的滞后"本质上还是单日预测的局限性。

5.3 注意力权重的可视化尝试

MSCNN_Bilstm_Attention相比普通LSTM最大的好处,是Attention权重可以把模型的"注意力分配"变成可解释的信息。我做过一个简单实验:选取测试集某一天的预测,把Attention权重画出来,看模型重点参考了哪些历史交易日。

结果很有意思,在股价经历一段平台整理后放量突破时,模型的Attention权重往往集中在突破前几天,而不是均匀分布在60天的窗口上。这说明Attention确实学到了"关键节点"的概念,而不只是把历史信息平均地压缩进隐状态。这个可视化过程虽然不影响指标,但对理解模型行为很有帮助,也能在后续汇报中增加说服力。

6. 直接跑代码前必看的三个坑

6.1 环境版本:PyTorch、CUDA与随机种子

压缩包里的代码对PyTorch版本的依赖不算苛刻,但有几个点需要确认。第一,torch.backends.cudnn.deterministic相关设置最好打开,并固定随机种子,否则每次运行结果都会略有差异。第二,如果使用GPU,要注意本机CUDA版本与PyTorch的匹配度,驱动装不上时直接用CPU跑完全没问题,数据量不算大,CPU跑完整个训练流程可能只要几分钟。

import torch import numpy as np import random def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)

6.2 数据文件的路径与格式

项目里的数据文件路径是相对路径,直接把压缩包解压后运行主脚本即可,但如果把代码和数据拆开放,记得检查pd.read_csv()的路径是否正确。另外数据文件里如果有非交易日空行、NaN值,需要先剔除,否则会影响滑窗构造的样本质量。

6.3 重新训练时的数据顺序

如果你想用自己的数据替换招商银行的数据,或者换一只股票跑,最需要注意的仍然是先按时间排序,再划分训练集和测试集。很多新手拿到数据直接按默认顺序切分,如果原始数据源恰好是按时间倒序排列,测试集就会在训练集之前,训练出的模型在测试集上表现会"看起来很好",因为测试集的日期比训练集更早,模型相当于在重温历史。这个错误最隐蔽,也最致命。

7. 从"跑通代码"到"理解模型"的三步进阶

如果只是把压缩包里的代码运行一遍获得指标,这个项目的价值只发挥了一半。我建议按下面三步继续深入:

第一步,把模型结构里的每一行代码看懂,对照Tensor的维度变化一步步推演。可以在网络前向传播里插入一些打印语句,把每层的输出形状打出来,这样能快速理解batch, seq_len, feature_dim到最终标量输出之间发生了什么。

第二步,做简单的消融实验。把Attention去掉、把双向LSTM改成单向、把多尺度卷积改成单卷积核,分别跑一遍训练和测试,记录四个指标的变化。我自己做这个实验时的结论是:去掉Attention对涨跌准确率的影响最明显,掉1到2个百分点;去掉多尺度卷积对MAE的影响更明显。消融实验做下来,你对每个模块的价值会建立非常实在的认知,这比读十篇论文都管用。

第三步,把数据换成另一只股票(比如另一只银行股或者一只波动更大的成长股),看看模型表现如何。不同波动特征的股票对模型的考验完全不同,这个过程能帮你理解哪些能力是模型结构带来的,哪些只是特定数据下的运气。

我实际跑下来最大的体会是,这类模型在金融时序上的预测能力边界非常清晰:它擅长捕捉均值回归类的短线波动,对趋势加速段的预测价值有限,对消息驱动的跳空几乎无能为力。模型做出来的预测结果,更适合作为一种分析参考工具,而不是自动化交易信号。这一点想清楚了,你对模型评估结果的心态会稳定很多,也更容易从后续实验中获得真正的技术积累。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询