简介:面向测量误差时间序列预测需求,提供一套基于MATLAB的长短期记忆网络(LSTM)完整实现,适用于高校本科及以上阶段的科研与工程场景,也可拓展至碳排放量等连续序列的预测任务。压缩包仅1.24MB,共含8个文件,以3个M脚本为主体(涵盖主程序、误差指标MSE/RMSE/MBE/MAE计算、拟合优度R2评估),同时提供Excel与CSV格式的训练测试数据、MAT格式的样例数据集,以及2张结果图像,便于直接复现与结果核验。目前已有137人学习浏览,代码注释完整、模块划分清晰,从数据导入、模型构建到误差评估形成闭环。尤其适合希望快速上手LSTM、需要完整可运行模板的读者,可基于现有脚本修改数据或网络结构,扩展至其他时间序列预测问题。
1. 测量误差预测为什么值得交给 LSTM:从一条误差曲线说起
在计量器具的周期核查里,最难受的不是误差超差,而是你明知道它在下一次校准前会漂到哪里,却只能按线性回归去猜。测量误差预测这个需求很具体:手头有一段按天或按批记录的误差序列,想预报未来一个核查周期的误差范围,从而决定设备是继续使用、缩短核查周期还是提前调修。传统做法要么做一元线性拟合,要么用指数平滑,但碰上误差带上没有固定斜率、偶尔还带滞后回环的序列,这两种方法几乎必翻车。LSTM 的优势在于它不要求你先给出误差模型的形式,漂移、周期性、缓慢回弹都可以由网络自己刻画。这篇文章面向手里已有误差数据和 LSTM 基础代码、但还没把预测结果真正跑稳的工程师,从数据组织、模型搭建、参数调节到验证一条线讲透。
LSTM 不是黑匣子,它只是在没有先验公式的前提下,自己从历史误差里学依赖关系。它解决的是“公式不知道怎么写、但数据里有规律”的那类问题。计量校准、设备寿命预测、传感器漂移补偿,场景不同,数据组织方式几乎一样。下面开始把代码落到地面上。
2. 分窗、差分与归一化:把误差序列变成 LSTM 能吃的三维数组
LSTM 的输入形状是固定的三维数组,也就是(样本数, 时间步, 特征数)。不管你的 Excel 里存的是几台设备、几个月的误差记录,进模型前都要先落成这个结构。很多人的 LSTM 时间序列预测 Python 代码跑不通,问题根本不在模型,而在数据组织。这一章做三件事:判断能否直接喂原始误差,把序列切成窗口,再完成安全的归一化。
2.1 先判断误差序列是否需要差分:用 ADF 检验说话
测量误差经常不是平稳序列。电子秤每天用标准砝码测一次,误差每天记一条,可能随着温度、湿度、蠕变慢慢漂。这种带漂移的序列直接进 LSTM,网络需要额外花大量容量去拟合“当前绝对水平”,反而学不好局部变化。
常见做法是先做一次单位根检验。用 statsmodels 的 ADF 检验,p 值小于 0.05 就认为序列平稳,可以直接建模;p 值大于 0.05,通常先做一阶差分。差分后的序列变成“今天的误差相对昨天变化了多少”,LSTM 学的是一个增量模式。
import numpy as np from statsmodels.tsa.stattools import adfuller # err_series: 一维数组,按时间顺序排列的误差记录 result = adfuller(err_series) print("p值:", result[1]) if result[1] > 0.05: # 非平稳,做一阶差分 err_diff = np.diff(err_series, n=1) print("已做一阶差分,差分后长度:", len(err_diff)) else: err_diff = err_series.copy()差分不是必须的。如果你的 seq_len 足够长,LSTM 能通过窗口内的相对变化抵消掉一部分水平漂移,效果上差异偶尔不大。但差分有一个实打实的好处:让归一化的范围更稳定。原始误差从 0.01 mm 漂到 0.35 mm,MinMaxScaler 的上下界会被历史极值绑架,差分后数据的均值通常贴近 0,缩放不容易失真。
提示:做了差分,预测出来的是误差增量。还原时要用最后一条原始误差值和增量累加回去,很多人在这一步把尺度搞丢,预测结果大得离谱。
2.2 滑动窗口切分:窗口长度和预测步长要分开定义
误差预测的训练样本靠滑动窗口生成。假设序列长度是 N,窗口长度为 seq_len,预测步长为 horizon,那么样本数量是 N - seq_len - horizon + 1。窗口每向右滑动一步,生成一个样本,前 seq_len 个点做输入,第 seq_len + horizon 个点做标签。
这里要注意 horizon 是待预测点与窗口末端的距离。你预测下一核查周期的误差值,horizon 就按几个核查间隔算,不是按自然日算。
import numpy as np def make_windows(values, seq_len=24, horizon=1): X, y = [], [] length = len(values) if length < seq_len + horizon: raise ValueError("序列长度不够,至少需要 seq_len + horizon") for i in range(length - seq_len - horizon + 1): X.append(values[i : i + seq_len]) y.append(values[i + seq_len + horizon - 1]) return np.array(X), np.array(y).reshape(-1, 1)这段代码把输入输出都做成 numpy 数组。X 的形状是(样本数, seq_len),后面 reshape 成(样本数, seq_len, 1) 就能喂给 PyTorch。y 的 reshape 成(样本数, 1) 是为了和模型输出做 MSE 损失计算时形状对齐。
参数说明:seq_len 是“用过去多少个误差点做输入”,horizon 是“往后预测第几个点”。如果你希望预测未来 3 个点而不是 1 个点,可以把 horizon 设成 3,但那样样本数会变少,且每个标签是单点误差而不是误差区间。想要预测未来一段误差范围,更好的做法是保留 horizon=1,然后做滚动预测,下面第 6 章会展开。
2.3 归一化必须只 fit 训练段,验证和测试段只能 transform
误差预测里最常见的数据泄漏,就是先把整条序列喂给 MinMaxScaler,再切训练集和验证集。这样验证集的最大最小值已经参与了缩放参数的确定,验证集损失会虚低,部署到新数据时一测就现原形。
正确做法是先按时间顺序切分,再 fit 训练段,用同一组参数转换验证段和测试段。
from sklearn.preprocessing import MinMaxScaler # 按时间顺序切分,不能用随机切分 split_index = int(len(err_diff) * 0.7) val_index = int(len(err_diff) * 0.85) train_raw = err_diff[:split_index] val_raw = err_diff[split_index:val_index] test_raw = err_diff[val_index:] scaler = MinMaxScaler(feature_range=(0, 1)) # 在训练段上 fit train_scaled = scaler.fit_transform(train_raw.reshape(-1, 1)).ravel() # 验证和测试段只能 transform val_scaled = scaler.transform(val_raw.reshape(-1, 1)).ravel() test_scaled = scaler.transform(test_raw.reshape(-1, 1)).ravel()为什么验证段只能 transform?因为 MinMaxScaler 记录的 min 和 max 如果来自训练段,验证段里超过该范围的值会被截断成 0 或 1,这个截断行为本身就是一种已知边界。新设备在线预测时,拿到的误差值大概率不会和训练分布一模一样,你需要提前知道模型在边界外输入上会给出什么反应。
如果你的误差序列里有明显尖峰异常值,MinMaxScaler 会被少数极端值压扁,大部分数据缩到 0.1 到 0.3 之间,模型很难分辨。这时候改 RobustScaler,它按中位数和四分位距缩放,对尖峰不那么敏感,只是区间不再是严格的 0 到 1。
把窗口切分和归一化串起来,你就有了标准的训练数据结构。下一步是把这些数组喂给 PyTorch 的 LSTM 模型,进入真正的建模环节。
3. PyTorch LSTM 模型实现:从源码到可跑通的训练脚本
网上能查到的 PyTorch LSTM 源码并不复杂,真正需要你改的是输入 shape、输出怎么取,以及训练循环里如何处理误差序列的时间属性。测量误差预测属于单变量时间序列,输入特征数是 1,输出也是 1。下面的代码按“单层起步、双层备用”的思路给出,避免一上来就把模型架得太大,小数据上反而学不动。
3.1 模型定义:为什么取最后一个时间步的输出再接全连接
LSTM 在 PyTorch 里的标准用法是把整个窗口过一遍网络,返回每个时间步的隐状态 out 或最后一个时间步的隐状态。测量误差预测里,你只用最后一个时间步的隐状态,因为它综合了整段窗口的信息,再接一个线性层映射到误差值。
import torch import torch.nn as nn class LSTMErrorPredictor(nn.Module): def __init__(self, input_size=1, hidden_size=64, num_layers=1, output_size=1): super().__init__() self.lstm = nn.LSTM( input_size, hidden_size, num_layers, batch_first=True ) self.regressor = nn.Linear(hidden_size, output_size) def forward(self, x): # x shape: (batch_size, seq_len, input_size) out, _ = self.lstm(x) # out shape: (batch_size, seq_len, hidden_size) last = out[:, -1, :] return self.regressor(last)参数说明:batch_first=True 表示输入和输出的 batch 维度放在第一维,这样 PyTorch 里的张量形状和你的窗口数组一致,不容易搞混。hidden_size 是 LSTM 隐状态的宽度,决定网络能记录的中间信息量。num_layers 是堆叠层数,误差预测数据量通常不大,先设 1 层,第 4 章会讲什么时候需要加到 2 层。
前向传播里,out[:, -1, :] 取的是最后一个时间步所有 hidden 单元的隐状态,这是一个长度为 hidden_size 的向量。接线性层后输出 1 个数,就是预测的误差值下标。这里没有加激活函数,因为回归任务最后一步就是线性输出,加了 tanh 或 ReLU 反而限制输出范围。
3.2 DataLoader 组装:时间序列样本要不要打乱
窗口切分出来的样本存在大量重叠,相邻样本的输入窗口几乎一样。如果用 shuffle=True 随机打乱,训练数据里相邻样本的标签会同时出现在不同 batch 中,模型容易记住局部模式而不是学到迁移性规律。误差预测场景我一般把 shuffle 设为 False,让模型按时间顺序见到样本,训练曲线虽然更震荡,但验证集的泛化表现更真实。
from torch.utils.data import TensorDataset, DataLoader X_train, y_train = make_windows(train_scaled, seq_len=24, horizon=1) X_val, y_val = make_windows(val_scaled, seq_len=24, horizon=1) train_dataset = TensorDataset( torch.tensor(X_train, dtype=torch.float32).reshape(-1, 24, 1), torch.tensor(y_train, dtype=torch.float32).reshape(-1, 1) ) val_dataset = TensorDataset( torch.tensor(X_val, dtype=torch.float32).reshape(-1, 24, 1), torch.tensor(y_val, dtype=torch.float32).reshape(-1, 1) ) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=False) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)reshape 成 (-1, 24, 1) 这步很关键。make_windows 返回的 X_train 形状是(样本数, 24),PyTorch 的 LSTM 要求三维输入,所以补上最后一个维度 1,代表特征数。如果后续加了温度、湿度传感器数据作为额外特征,把这里改成拼接后的多维数组即可,LSTM 的 input_size 要对应改。
3.3 训练循环:损失函数、优化器和梯度裁剪
误差预测的损失函数我常用 MSE,因为它对大的预测偏差惩罚更重,有利于把超差风险压下来。但如果训练集里存在孤立异常值,MSE 会被这几个点带偏,验证曲线怎么调都下不去,这时换成 L1Loss 更稳。
import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = LSTMErrorPredictor(hidden_size=64, num_layers=1).to(device) criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", patience=5, factor=0.5) def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss = 0.0 for X_batch, y_batch in loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) optimizer.zero_grad() pred = model(X_batch) loss = criterion(pred, y_batch) loss.backward() # 梯度裁剪,防止误差尖峰把梯度推爆 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * X_batch.size(0) return total_loss / len(loader.dataset) def validate(model, loader, criterion, device): model.eval() total_loss = 0.0 with torch.no_grad(): for X_batch, y_batch in loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) pred = model(X_batch) loss = criterion(pred, y_batch) total_loss += loss.item() * X_batch.size(0) return total_loss / len(loader.dataset) for epoch in range(100): train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss = validate(model, val_loader, criterion, device) scheduler.step(val_loss) if (epoch + 1) % 10 == 0: print(f"Epoch {epoch+1:03d} | train {train_loss:.6f} | val {val_loss:.6f}")梯度裁剪这一行值得单独说明。测量误差序列偶尔会出现单个极端的跳变,比如某天标准砝码没放稳,误差值飙到正常水平的 10 倍。虽然归一化能把极值压到 1 附近,但反向传播的梯度仍然可能因为这个问题变得很大,导致训练发散。clip_grad_norm_ 把梯度的 L2 范数限制在 1.0 以内,损失不至于一崩到底。
训练一段时间后,如果 val_loss 持续不下降,ReduceLROnPlateau 会把学习率自动减半。误差预测这类小数据回归任务,学习率从 1e-3 起步是合理的,不要太早降到 1e-4,否则收敛速度慢得让人失去耐心。
4. 序列长度、隐藏层维数、学习率:三个超参数的调整规范
模型能在机器上跑起来之后,真正的工程量在参数调整。误差预测场景没有银弹,但有一套合理的调整顺序,能帮你少走弯路。我一般先定 seq_len,再定 hidden_size,最后调学习率。顺序反了,常常调半天也分不清是哪个参数导致验证集不稳定。
4.1 序列长度:以误差变化的一个完整周期做基准
seq_len 是整个模型里最有物理含义的参数。如果设备的误差漂移有一个主周期,比如一周内温度变化引起传感器零点漂移,那么 seq_len 至少覆盖这个周期的 2 到 3 倍。只用 7 天数据,模型看到半周期就做预测,相当于是拿正弦波前半段外推后半段,能蒙对才有鬼。
窗口太短的直接症状是预测曲线明显滞后于真实曲线,形状像把输入窗口末尾的值平移了一下。窗口太长则会把很多年前的误差模式也卷进来,如果设备中间经历过维修或换元件,长窗口反而引入过期信息。
# 假设误差记录间隔为1天,主漂移周期约7天 seq_len = 21 # 7天周期的3倍 horizon = 1一个粗略但实用的规则:先按“每天一条误差记录”去数主周期天数,取周期数的 2 到 3 倍作为 seq_len。如果数据不足 3 个周期,只能把 seq_len 降到周期数的 1.5 倍,同时接受更高的预测不确定性。
4.2 隐藏层维度:从 32 起步,不要一上来就 256
隐藏层维度过大,在样本量只有几百条的误差序列上几乎必然过拟合。训练损失一直降,验证损失拐头向上,就是模型开始记忆训练样本噪声了。以单变量误差序列的复杂度,32 或 64 通常够用,数据量超过 2000 条或误差模式确实复杂时再试 128。
num_layers 也是同样逻辑。单层 LSTM 已经能捕捉时间依赖,第二层能学更高层的抽象,但参数量翻倍,训练数据量不变时表现反而更差。我建议 num_layers 默认 1,只有当你确认单层模型在验证集上欠拟合、训练损失始终下不去时才加第二层。
4.3 学习率:用曲线形态判断,而不是靠经验值硬套
学习率太大会出现训练损失先降后反弹,原因是优化器在最优点附近震荡。太小则训练损失下降缓慢,100 个 epoch 后验证损失还没到平台。误差预测这类小任务,我习惯先用 1e-3 跑 50 个 epoch,如果训练损失在前 10 个 epoch 内没有明显下降,就增大到 3e-3;如果训练损失剧烈震荡,就降到 1e-4。
# 一个完整的参数推荐表,按调整顺序排列 # seq_len: 主周期长度 * 2~3,短板时取1.5倍 # hidden_size: 先从32或64试起,过拟合就减到16,欠拟合才加 # num_layers: 默认1,仅欠拟合时加到2 # learning_rate: 1e-3起步,震荡就减半,下降慢就加到3e-3 # batch_size: 32,样本少于200时降到16batch_size 对误差序列训练的影响容易被忽略。样本量大时,大 batch 能加速收敛,但在几百条误差数据上,batch_size 太大等于每个 batch 都近似全量数据,训练变慢且容易陷入局部最优。少于 200 个样本时,batch_size 设为 16,让模型有更多更新步数。
模型训练完成后,你手里会有一组参数和对应的验证集误差。但训练损失低不代表预测可信,最后一个关键环节是验证预测质量,也就是第 6 章的内容。先别急着把模型接进业务,接下来看看那些最容易让人反复返工的坑。
5. 误差预测踩坑清单:数据泄漏、输出退化和验证集失真
这一章是给已经能跑通代码、但预测结果总在业务上站不住脚的情况准备的。每条按现象、原因、解决三个层次写,方便对照排查。
5.1 现象:训练损失一路下降,验证损失从第一个 epoch 就开始乱跳
出现这个现象,第一时间检查归一化是否在切分之前就 fit 了。如果 MinMaxScaler 对全量序列求 min 和 max,验证段的值其实已经被训练段的尺度信息“剧透”了,训练损失自然漂亮,但验证损失反映的是真实分布外输入,所以乱跳。
原因就是归一化参数泄漏。解决方式很简单:把 scaler.fit_transform(train_raw) 和 scaler.transform(val_raw) 两行严格分开,并且训练阶段导出的 scaler 要在模型部署时一并保存。我见过不止一次模型训练和在线预测效果天差地别,最后发现是线上用的 scaler 是用全量数据重新算的。
5.2 现象:模型预测值始终稳定在误差序列均值附近,预测曲线接近一条水平线
输出退化是 LSTM 回归任务里最常见的失败模式。模型没有学到误差的动态变化,只学会了输出一个期望值,因为这样能最小化训练集上的均方误差。尤其当误差序列本身信噪比低、随机噪声占主导时,最优策略就是输出均值。
解决方式有两个方向。一是把损失函数换成 L1Loss,它对偏离均值的大误差惩罚更柔和,模型有更大动力去学习模式而不是规避风险。另一个方向是检查特征里是否真的包含有效信号,把误差序列按滞后几阶的自相关系数算一下,如果自相关系数在滞后 1 到 3 步时都接近 0,说明纯误差序列里几乎学不到东西,需要补充温度、湿度等外生特征。
5.3 现象:验证集上单步预测误差很小,多步滚动预测却很快发散
这个问题常被误判为模型训练不够。实际上是你的验证方式只测了“已知前 24 个真实误差,预测第 25 个”的能力,而业务需要的是“预测未来 7 个点”,这 7 个点里的第 2 个点输入里已经包含了一个预测值,误差一路累积下去自然发散。
解决方式是分别报告单步预测误差和滚动多步预测误差。第 6 章给出的滚动预测函数,就是为了让验证过程贴近真实使用场景。不要在单步预测误差低时就宣布模型可用,这会成为上线后最大的后悔药坑位。
5.4 现象:网络保存时只存了 state_dict,换台机器再跑结果对不上
模型权重文件只包含参数,不包含你用过的 scaler。部署到新环境做在线误差预测时,如果只加载 model.pt 而忘记加载 scaler,输入误差值没有经过同样的缩放,输出误差自然错得离谱。
解决方式是把 scaler 和模型一起保存,要么用 pickle 单存 scaler,要么用一个 dict 把模型权重、scaler、seq_len、horizon 打包保存。PyTorch 官方推荐的做法是把这些元信息一并写入 checkpoint,避免模型文件单独存在的时候丢失预处理配置。我习惯在训练脚本里把 scaler 存成单独文件,训练和预测脚本都从同一个配置文件读参数,少折腾。
6. 验证 LSTM 是否真正有效:残差分析和滚动预测
训练完模型先别急着看验证集 RMSE,第一步是画残差。残差的均值应该接近 0,标准差应该比原始误差序列的标准差小,否则说明模型没有提供新信息。
def rolling_predict(model, test_scaled, seq_len, horizon=1): model.eval() history = test_scaled[:seq_len].copy() preds = [] with torch.no_grad(): for i in range(seq_len, len(test_scaled) - horizon + 1): x = torch.tensor( history[-seq_len:].reshape(1, seq_len, 1), dtype=torch.float32 ).to(device) pred = model(x).item() preds.append(pred) # 用真实值推进历史,注意不要用预测值自我递归 history = np.append(history, test_scaled[i]) return np.array(preds) preds_scaled = rolling_predict(model, test_scaled, seq_len=24) preds_real = scaler.inverse_transform(preds_scaled.reshape(-1, 1)).ravel() actuals_real = scaler.inverse_transform(test_scaled[24:].reshape(-1, 1)).ravel() residual = actuals_real - preds_real print("残差均值: {:.4f}".format(np.mean(residual))) print("残差标准差: {:.4f}".format(np.std(residual))) print("原始误差标准差: {:.4f}".format(np.std(actuals_real)))这段代码里最关键的是 history 推进方式。滚动预测时,每预测一个新点,就把对应时间步的真实误差值追加进 history,而不是把上一步的预测值当作输入。如果使用自我递归,误差会逐步累积,得到的验证结果看起来“发散得很厉害”,但这并不完全代表模型不行,它只是暴露了误差累积效应。真实业务如果必须在缺少真实值的场景下做多步预测,那就要如实接受自我递归的累积误差,并把预测区间定得更宽。
残差均值如果离 0 比较远,说明模型存在系统性偏差,比如总是低估误差的增长速度。这时可以检查是否因为差分还原时把初始值加错位置。残差标准差如果接近甚至大于原始误差标准差,说明模型学到的规律比“拿均值预测”好不了太多,这时候与其继续调 LSTM,不如回头补外生传感器特征。
我自己的习惯是,每次训练结束都把残差画出来,并且把残差均值、标准差连同模型参数一起记入实验记录。这样隔两周再看模型,能立刻判断当时到底有没有值得部署的进步。预测方向这条路,真正有用的不是模型结构多么花哨,而是数据组织、参数校验和验证方法这三样基本功。希望这次梳理的这套流程,能帮你在自己的误差数据上少走两趟弯路。
本文还有配套的精品资源,点击获取