简介:面向时间序列预测任务的一套深度学习模型示例集合,覆盖LSTM、双向LSTM、CNN+LSTM、堆叠式LSTM、ConvLSTM、Encoder-Decoder LSTM等主流结构,重点演示如何根据不同的预测需求构造输入输出数据的形状,并配置合适的网络参数来训练模型。所有源码都是课程大作业原创,经过运行验证,且配有超详细中文注释,适合计算机、数据科学、人工智能等专业的学生作为课程设计、毕业设计或入门进阶的参考资料。整个压缩包共有39个文件,包括24个Python脚本、13个模型代码包和2个说明文档,体积仅68KB,部署和阅读都很轻量。目前该包已有645人学习浏览,内容覆盖单变量与多变量、单步与多步预测的多种组合,每种模型同时提供单独的.py文件和zip包,便于直接对照模板进行修改和复用。对于需要快速搭建时间序列预测基线模型的开发者来说,这套示例提供了一条从数据构造、模型设计到结果验证的完整参考路径。
1. 从课程大作业到生产方案:LSTM+CNN+堆叠LSTM到底在解决什么问题
时间序列预测是深度学习入门时最容易“感觉学会了、一换数据就崩”的方向。单拿一个LSTM去拟合正弦波,效果很好;换成带趋势、带季节性、带噪声的电力负荷或水文径流预报数据,纯LSTM经常出现延迟一个周期、峰值削平、长序列遗忘的问题。标题里的LSTM+CNN+堆叠式LSTM组合,恰好是应对这三类问题的经典结构:CNN负责从原始序列里抽取局部特征,LSTM负责建立时间依赖,堆叠式LSTM再往上层抽象出更长的跨时间步规律。这个方案很适合课程大作业或毕设里需要的“模型有深度、代码有注释、结果能解释”的原创实现。选型上不是炫技,而是“如果只用一个网络结构,哪个更可能在你手上的数据上保住精度”。这套组合的定位是:在网络规模和训练难度之间,找一个普通CPU或单卡GPU能跑完、代码量在500行以内、超参数不敏感度较高的平衡点。
2. 模型架构拆解:CNN提特征、LSTM提时序、堆叠增加深度
LSTM+CNN+堆叠式LSTM这个标题里有三个独立技术名词,组合顺序不是随意的。要理解这条架构链路,得先拆清楚每一层到底在干什么、输入和输出的张量形状如何变化、参数在哪一层开始爆炸。
2.1 为什么CNN要在LSTM前面:一维卷积处理时间序列的语义
常见做法是把一维CNN放在LSTM之前,用卷积核沿时间维度扫过输入序列。假设原始输入形状为(batch_size, seq_len, num_features),经过Conv1d之后,特征维被映射到新的通道数,时间维被压缩或保持不变。这样做的第一个理由是局部模式提取:时间序列里的尖峰、跳变、周期性片段,本质上是局部窗口内的数值组合。kernel_size=3的卷积核只看邻近的三个时间步,等价于在原序列上做了一个可学习的加权滑动平均;kernel_size=5则覆盖更长局部,类似对“一个完整周期的前半段”做模式识别。
第二理由是降维和感受野。堆叠LSTM的时间开销随seq_len线性增长,当序列长度到达几百个时间步时,训练速度会明显下降。在LSTM之前先用stride=2的一维卷积,把序列长度折半,后面的LSTM层计算的步数也减半,训练时间几乎线性下降。有效感受野则通过两层卷积叠加:第一层kernel=3,第二层kernel=3,两层堆叠后等价于原序列上覆盖5个时间步的范围,不需要一次性加大卷积核,减少了参数量。处理水文径流预报这类数据时,这种做法尤其有效:径流序列日尺度的自相关性一般在3到7天,两层kernel=3的卷积正好覆盖这个范围。
还有一点经常被忽略:CNN在这里起到的是“特征变换器”的作用,输出的每个时间步都包含该时刻前后窗口的信息。这意味着后面LSTM每一个时间步的输入,不再是孤立的原始值,而是一个局部上下文聚合向量。具体到一个典型的时间序列预测python实现里,张量形状的变化是这样的:输入[64, 48, 4](64个样本,48个时间步,4个特征),先permute成[64, 4, 48]送入Conv1d,经过Conv1d(4, 32, kernel_size=3, padding=1)后得到[64, 32, 48],再permute回[64, 48, 32]给LSTM。对初学者来说,最容易在这一步把batch、sequence、channel三个维度搞混,出错信息多半是Expected 3D tensor, got 4D或size mismatch。
2.2 单向LSTM、双向LSTM与堆叠式LSTM的适用边界
LSTM本身分单向和双向。预测任务里,预测t+1时刻只能看到t时刻及之前的数据,所以绝大多数回归型时间序列预测用的是单向LSTM。双向LSTM在分类任务(如EEG或故障诊断)里效果好,因为整个序列已经采集完成,t时刻的表示可以同时参考前后文。但多步预测如果强行用双向LSTM,本质上泄漏了未来信息,训练指标很漂亮,一到线上滚动预测就崩,这个坑在课程大作业里频繁出现。
堆叠式LSTM,也叫Stacked LSTM或深层LSTM,指把多个LSTM层按顺序串联:第一层的隐藏状态序列作为第二层的输入序列,而不是把第一层最后一个时间步的输出直接接全连接层。这种堆叠带来的收益在数学上可以解释为抽象的层次化:第一层LSTM的隐藏单元负责捕获短期波动,比如日内周期性、相邻时刻的相关结构;第二层或第三层在上一层的隐藏状态序列基础上,捕获更慢的变化,相当于对“波动的波动”建模。
| 结构 | 参数量(大致) | 适合的任务 | 训练难度 | 典型场景 |
|---|---|---|---|---|
| 单层LSTM | 低 | 简单平稳序列 | 低 | 正弦波、价格预测入门demo |
| 双层堆叠LSTM | 中 | 带季节性和趋势的数据 | 中 | 电力负荷、访问量预测 |
| CNN+LSTM | 中 | 多变量、多通道时间序列 | 中 | 水文径流预报、传感器故障 |
| CNN+堆叠LSTM | 中高 | 长序列+局部模式+多尺度趋势 | 高 | 汇率、气象、交通流 |
对照上表,标题里的组合是CNN+堆叠LSTM,适用面在“长序列且存在局部形态特征”的数据上。如果手里的数据量很少,比如只有几百个时间步,堆叠到3层LSTM大概率过拟合;此时可以去掉堆叠,用单层LSTM加CNN,效果反而稳。
2.3 堆叠LSTM的两种实现方式:num_layers参数与手动堆叠
PyTorch的nn.LSTM自带num_layers参数,设置nn.LSTM(input_size, hidden_size, num_layers=2, batch_first=True)就得到了一个两层堆叠LSTM。这是最简洁的写法,也是课程大作业源码里最常见的封装。但num_layers内部并不会自动做任何特殊优化,它的行为等价于手动串联两个LSTM层,第一层的output(即每个时间步的隐藏状态序列)全部传入第二层,第一层的h_n和c_n被丢弃。只有最后一个LSTM层输出的最后一个时间步的隐藏状态会被用于预测。
手动堆叠的优势在于可调试性和灵活性。例如可以在两层LSTM之间插入Dropout,或者把第一层的h_n拿出来做辅助损失。代码结构大致是:
class StackedLSTMBranch(nn.Module): def __init__(self, input_size, hidden_size, num_layers=2, dropout=0.2): super().__init__() self.layers = nn.ModuleList() for i in range(num_layers): in_size = input_size if i == 0 else hidden_size self.layers.append(nn.LSTM(in_size, hidden_size, batch_first=True)) self.dropout = nn.Dropout(dropout) def forward(self, x): # x: [batch, seq_len, input_size] for i, layer in enumerate(self.layers): output, (h_n, c_n) = layer(x) x = output if i < len(self.layers) - 1: x = self.dropout(x) return output # 最后一个LSTM层的完整输出序列这里的dropout只作用于层与层之间,不作用于最后一个层的输出,这是PyTorch官方实现中dropout参数的行为,手动堆叠时同样应该遵循这个边界。参数选择上,hidden_size在32到128之间通常是多数时间序列预测任务的甜点区,再大并不会带来精度提升,反而会放大LSTM训练时的梯度爆炸概率。
3. Python实现:数据预处理与模型搭建的最小可运行代码
这份“python源码+超详细注释”的课设方案,核心价值在于能直接运行、能改参数、能看懂每个张量在做什么。下面给出一套完整的PyTorch实现路径,从原始CSV到训练完成,全部代码加起来不超过300行。
3.1 时间序列滑窗切分与归一化的正确顺序
时间序列预测的数据准备比图像分类麻烦,因为样本不是独立的:第i个样本的末尾和第i+1个样本的开头是重叠的。常见做法是用固定长度的滑窗在原始序列上滑动,窗口长度in_steps决定模型一次能看多长的历史,预测步长out_steps决定往后预测多远。
import numpy as np def create_sequences(data, in_steps=48, out_steps=1): """ 把连续时间序列切成 (X, y) 样本对 data: 2D数组,形状为 [total_timesteps, num_features] X 的形状: [num_samples, in_steps, num_features] y 的形状: [num_samples, out_steps, num_features] 或 [num_samples, out_steps] """ X, y = [], [] for i in range(len(data) - in_steps - out_steps + 1): X.append(data[i:i + in_steps]) y.append(data[i + in_steps:i + in_steps + out_steps]) return np.array(X), np.array(y)out_steps大于1时,y保持三维数组,最后接nn.Linear(hidden_size, out_steps)直接预测out_steps个连续值,这种做法叫直接多步预测(Direct Multi-step),比递归多步预测误差累积小。数据分割顺序必须是“先拆训练集和测试集,再分别做归一化”,如果先对全量数据fit_transform再切分,测试集的均值和方差就泄漏到了训练过程中,得到的验证指标乐观得没有参考价值。正确的做法是对训练集调用MinMaxScaler的fit,再用同一套min和scale参数去transform测试集。归一化范围建议用(-1, 1)而不是(0, 1):LSTM的默认激活函数是tanh,输出范围正好是(-1, 1),输入和激活的范围一致能加快收敛。
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(-1, 1)) train_scaled = scaler.fit_transform(train_df) test_scaled = scaler.transform(test_df)3.2 带中文注释的CNN+堆叠LSTM完整模型定义
模型的整体前向逻辑是:一维卷积提取局部特征 → 第一层LSTM建立时间依赖 → 第二层LSTM抽象更高层时序规律 → 全连接层输出预测值。卷积层的输出要经过permute把特征维和序列维交换,这是这类源码里注释最密集、新手最容易写错的地方。
import torch import torch.nn as nn class CNNStackedLSTM(nn.Module): """CNN + 堆叠式LSTM 时间序列预测模型""" def __init__(self, num_features, seq_len, hidden_size=64, num_layers=2, cnn_channels=32, kernel_size=3, out_steps=1): super().__init__() # 一维卷积:把特征维从 num_features 映射到 cnn_channels # padding=1 且 kernel_size=3 时,卷积不改变序列长度 self.conv1 = nn.Conv1d(in_channels=num_features, out_channels=cnn_channels, kernel_size=kernel_size, padding=1) self.relu = nn.ReLU() # 可选的第二个卷积层,进一步增大感受野 self.conv2 = nn.Conv1d(in_channels=cnn_channels, out_channels=cnn_channels, kernel_size=kernel_size, padding=1) # 堆叠LSTM:num_layers=2 表示两层LSTM串联 # batch_first=True 使输入维度为 [batch, seq_len, features] self.lstm = nn.LSTM(input_size=cnn_channels, hidden_size=hidden_size, num_layers=num_layers, batch_first=True) self.fc = nn.Linear(hidden_size, out_steps) def forward(self, x): # 输入 x: [batch, seq_len, num_features] # Conv1d 期望 [batch, channels, length],需要交换维度 x = x.permute(0, 2, 1) # [batch, num_features, seq_len] x = self.conv1(x) x = self.relu(x) x = self.conv2(x) x = self.relu(x) # 换回 [batch, seq_len, channels] 送给LSTM x = x.permute(0, 2, 1) # [batch, seq_len, cnn_channels] lstm_out, _ = self.lstm(x) # lstm_out: [batch, seq_len, hidden_size] # 取最后一个时间步的隐藏状态作为特征 last_step = lstm_out[:, -1, :] # [batch, hidden_size] output = self.fc(last_step) # [batch, out_steps] return output关键参数与改动方式:num_features是输入变量个数,比如水文径流预报中同时输入降雨、蒸发、入流、出流4个变量,这个值就是4;预测目标如果只是径流这一列,就设置模型的输出层之前先用Linear把hidden_size压缩到1。seq_len是滑窗长度,值越大模型能看到越长的历史,但训练时间线性增长;cnn_channels影响卷积层提取特征的容量,可以先保持32不动,只有当训练损失收敛过慢时再调大。kernel_size的取值建议跟着数据的周期走:日数据有明显7天周期(星期效应),设kernel_size=7直接覆盖一个完整周期,效果往往比kernel_size=3好20%到30%,但参数量和计算量也随之增加。
3.3 训练循环与损失计算:回归任务不要踩分类的坑
时间序列预测本质是回归任务,损失函数首选MSELoss,误差反向传播时对大误差的惩罚更大,模型会更努力地去拟合峰值和谷值。如果你的测试指标里MAPE(平均绝对百分比误差)比RMSE更受关注,可以考虑SmoothL1Loss(Huber损失),它对离群点更鲁棒,训练前期下降更平稳。
model = CNNStackedLSTM(num_features=4, seq_len=48, hidden_size=64, num_layers=2, out_steps=1) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5 ) for epoch in range(200): model.train() epoch_loss = 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() pred = model(batch_x) loss = criterion(pred, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() epoch_loss += loss.item() * batch_x.size(0) avg_train_loss = epoch_loss / len(train_loader.dataset) # 每个epoch结束后评估验证集 model.eval() val_loss = 0.0 with torch.no_grad(): for batch_x, batch_y in val_loader: pred = model(batch_x) val_loss += criterion(pred, batch_y).item() * batch_x.size(0) avg_val_loss = val_loss / len(val_loader.dataset) scheduler.step(avg_val_loss) if (epoch + 1) % 20 == 0: print(f"Epoch {epoch+1:3d} | Train Loss: {avg_train_loss:.6f} | Val Loss: {avg_val_loss:.6f}")clip_grad_norm_在这里是必需品而不是可选优化:LSTM本身对梯度范数非常敏感,时间步长超过50时梯度爆炸的概率显著增加,clip后训练稳定性明显改善。max_norm取1.0是经验值,范围在0.5到5之间都可以,取太小会导致收敛变慢,取太大会失去防护意义。ReduceLROnPlateau的patience=5表示连续5个epoch验证损失不下降时学习率减半,这是时间序列任务里比固定衰减策略更省心的做法,不需要手动调衰减步长。
4. 超参数设置与训练:学习率、批量大小、隐藏单元数怎么定
很多人拿到一份有注释的python源码,最关心的问题并不是网络结构,而是“这些参数为什么要这么设”。这一节集中回答这个问题,给出一套可以直接套用的参数表,以及判断参数是否需要调整的依据。
4.1 核心超参数速查表与调整方向
| 参数 | 推荐初始值 | 取值范围 | 对模型的影响 | 调整信号 |
|---|---|---|---|---|
learning_rate | 0.001 | 0.0001 ~ 0.01 | 训练收敛速度与稳定性 | loss震荡或长时间不降时降低 |
batch_size | 32或64 | 8 ~ 256 | 梯度估计噪声与收敛平滑度 | GPU占用率过低可增大 |
hidden_size | 64 | 32 ~ 128 | 模型容量与拟合上限 | 验证loss下降慢且训练loss低时增大 |
num_layers | 2 | 1 ~ 3 | 抽象层级与过拟合风险 | 数据量大可加层,小数据保持2以内 |
seq_len | 48 | 12 ~ 200 | 记忆长度与训练速度 | 数据周期明显时设为周期的整数倍 |
cnn_channels | 32 | 16 ~ 128 | 局部特征提取容量 | 特征多时可适当增加 |
dropout | 0.2 | 0 ~ 0.5 | 过拟合抑制 | 训练loss低但验证loss高时增加 |
learning_rate=0.001是Adam优化器在大多数回归任务上的默认甜点;从0.001开始,如果前10个epoch内训练loss几乎不下降,降到0.0005或0.0003再试。batch_size对LSTM的影响比CNN更敏感:批量太大(比如256以上)会让梯度方向趋于“平均”,模型对异常尖峰事件的响应更钝,在预测峰值偏低的场景里表现差;批量太小则训练震荡厉害。课程大作业的典型数据量在几千到几万行之间,32或64是平衡点。
hidden_size的调整信号比较明确:当训练loss在下滑但验证loss停滞在较高水平时,优先怀疑模型容量不足,把hidden_size从64调到128或cnn_channels从32调到64。反过来,如果训练loss和验证loss差距很大(训练loss接近0,验证loss很高),这是过拟合信号,优先增加dropout,而不是减少hidden_size,因为减少hidden会让整体特征表达力下降,精度损失较大。
4.2 LSTM的时间步长度选择:周期对齐比“越长越好”更重要
一个常见误区是seq_len越大,模型能看到的历史越长,效果一定更好。实际上LSTM对超过一定长度的历史记忆能力衰减得很快,更长的输入只会增加训练成本和不相关噪声。正确的做法是先针对数据做周期分析,把seq_len设成周期的整数倍或至少覆盖一个完整周期。
from scipy.signal import periodogram def estimate_period(data, sample_rate=1): """通过功率谱密度估计时间序列的主周期""" freqs, power = periodogram(data, fs=sample_rate, detrend='linear') dominant_freq = freqs[np.argmax(power[1:]) + 1] if dominant_freq > 0: return int(round(1 / dominant_freq)) return None period = estimate_period(train_df['value'].values) seq_len = period * 2 # 覆盖两个周期,给模型足够上下文这段代码通过periodogram计算序列的功率谱,找到功率最大的频率,换算成周期。日尺度的电力负荷数据通常能算出7或24的周期,水文径流数据在未受人工调节时可能检测出几天乃至几十天的主周期。seq_len设为周期的1到2倍是通用规则:太短(不足半个周期)时模型只看到局部片段,无法建立周期性规律;太长(超过3个周期)时噪声占主导,LSTM的注意力被分散。
4.3 训练中怎么判断模型是否在学习:loss曲线诊断
训练过程中打印的loss数值不是用来“看大小”的,而是用来“看形状”的。把训练loss和验证loss画在同一张图上,基本可以诊断出90%的调参问题。训练loss持续下降、验证loss先降后升,这是过拟合的典型曲线,解决办法是增加dropout、增大weight_decay(L2正则)或提前停止(Early Stopping)。训练loss和验证loss都趋于平缓且数值接近,说明模型容量已经饱和,再增加epoch数不会带来提升,此时应该观察验证loss是否达到预期精度,如果不够,回头调结构和特征。
# 伪代码:早停机制 best_val_loss = float('inf') patience_counter = 0 for epoch in range(max_epochs): train_loss = train_one_epoch(model, train_loader) val_loss = evaluate(model, val_loader) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pth') patience_counter = 0 else: patience_counter += 1 if patience_counter >= 15: print(f"Early stop at epoch {epoch}") break早停是时间序列预测里性价比最高的正则化手段,比Dropout更容易发现过拟合的临界点。patience取10到20之间,太小会在validation loss暂时波动时误停,太大则失去减少训练时间的作用。保存best_model.pth的做法比保存最后一个epoch好得多:LSTM训练后期经常在最优验证点附近来回震荡,最后几步反而可能过拟合。验证时直接load_state_dict读入最优权重,而不是使用代码结束时的模型参数,这一点在课程大作业答辩演示精度的环节非常关键。
5. 结果评估与进阶:从“loss很低”到“预测可信”
epoch跑完、loss图也画出来了,接下来要验证的是“这套源码在你的数据上是否真的解决了时间序列预测问题”。单一使用loss值作为评价指标会被数据尺度误导,尤其是使用MinMaxScaler归一化后的loss特征范围只有0到4,肉眼无法判断“0.002”是好还是坏。
5.1 反归一化与误差指标计算
训练时模型输出的是归一化后的值,评估时必须先反归一化再计算指标,否则RMSE和MAE的数值会远小于真实量纲,得出模型精度很高的错觉。常用的多指标组合是RMSE(对大误差敏感)、MAE(对平均误差敏感)、MAPE(百分比量纲,但要求标签没有0值)和R²(判断模型是否优于直接用均值预测)。
def evaluate_metrics(y_true, y_pred): """y_true和y_pred均为反归一化后的真实尺度数组""" rmse = np.sqrt(np.mean((y_true - y_pred) ** 2)) mae = np.mean(np.abs(y_true - y_pred)) mape = np.mean(np.abs((y_true - y_pred) / (y_true + 1e-8))) * 100 ss_res = np.sum((y_true - y_pred) ** 2) ss_tot = np.sum((y_true - np.mean(y_true)) ** 2) r2 = 1 - ss_res / (ss_tot + 1e-8) return rmse, mae, mape, r2注意MAPE的1e-8并不是随意加的:预测值是连续数值,很可能恰好接近0(尤其是归一化后可取负值),不加保护会直接产生NaN。对存在接近0标签的水文径流数据,MAPE会输出一个异常大的值,这不是模型出了问题,而是指标本身在这些数据点上失效,此时建议改用SMAPE(对称平均绝对百分比误差)。判断预测整体水平时,R² 高于0.8说明模型明显优于“拿历史均值做预测”的基准线,低于0.5则要警惕模型只学到了趋势、没有学到波动规律。
5.2 多步预测的误差衰减诊断
out_steps大于1时,把未来各个预测步的RMSE按步数排序画出来,能看到一条明显递增的曲线——这是多步预测的正常现象。每一步预测都建立在上一步的预测值上,误差会累积传递。真正需要警惕的是误差在某一步突然跳升,例如预测第1步RMSE是10,第2步变成15,第3步直接变成40,这说明第2步的输出在数值上处于一个不稳定区域。处理手段有两种:第一种是把序列输出改为“分步训练”(每个步长单独一个输出头,所有输出头共享LSTM特征提取部分),这种Multi-Head结构在PyTorch里很好实现,本质是把线性输出层从一层改成并行的连续值;
class MultiHeadOutput(nn.Module): """多步预测:每个预测步长独立全连接头""" def __init__(self, hidden_size, out_steps): super().__init__() self.heads = nn.ModuleList([ nn.Linear(hidden_size, 1) for _ in range(out_steps) ]) def forward(self, lstm_last_step): # 输入: [batch, hidden_size] outs = [head(lstm_last_step) for head in self.heads] return torch.cat(outs, dim=1) # [batch, out_steps]这样做的好处是每一步的误差不再强制共享同一组权重,某一步数值得分位点偏离时不会拖累其他步。第二种是引入残差连接:让模型预测的是“下一时刻与当前时刻的差值”而不是直接预测下一时刻的绝对值。对非平稳序列,差分目标往往比原始值更接近正态分布,模型拟合难度显著降低。残差方法在课程大作业和实际项目里的成功率都高于直接预测绝对值,但代价是需要额外的diff和cumsum还原逻辑。
多模型集成是最后一个不增加源码复杂度的精度提升手段:同一个CNN+堆叠LSTM结构,分别用不同的seed初始化训练5次,取预测均值作为最终输出。由于LSTM的权重初始化对结果影响很大,不同seed的模型错误方向很可能互不相关,5次平均基本能稳定提升RMSE约5%到10%,而且代码改动量只有5行左右。如果时间充裕,把这个集成策略写到源码的predict.py里,答辩时展示的效果会明显好过单一模型。
本文还有配套的精品资源,点击获取