说实话,时间序列预测这个坑我踩了挺久。刚接到设备温度预测这个需求时,第一反应是用ARIMA或者指数平滑这类经典统计模型去拟合,但一旦碰到强非线性、多影响因素的序列,统计模型调起来是真的磕磕绊绊。后来换成长短期记忆网络(LSTM)来做,效果一下就拉开了。这篇文章分享我整理过的一套基于Matlab的 LSTM 时间序列预测实现代码,从数据预处理到网络搭建、从训练到评测,把这套流程里我认为最关键的东西全部写清楚。适合刚接触 LSTM、或者已经跑过 Python 版本但想在 Matlab 环境下快速完成时序预测任务的人。先说明这套代码能做什么:它能以单变量时间序列为例,自动完成数据读取、归一化、滑动窗口构造样本、LSTM 训练、测试集预测、误差指标计算和结果绘图。你手里只要有一列连续观测值,比如某设备温度、某片区用电量、某商品日销量,都可以直接套用。
1. 项目概述:LSTM时间序列预测到底在做什么
1.1 为什么时间序列预测值得用LSTM来做
传统时间序列模型的核心假设是历史模式可以线性外推。ARIMA 把序列拆成自回归项和移动平均项,本质上还是线性组合,对数据的平稳性要求也很苛刻。现实中的工业传感器数据、能耗数据、金融序列,往往带有明显的趋势、季节和异常波动,统计模型处理起来要做大量差分、分解、定阶,工程上非常琐碎。而 LSTM 的优势在于不需要手动设计滞后阶数和特征组合,直接输入一段连续窗口,模型自己学“该记住哪段历史、该忘掉哪段历史”。
我之前做温度预测时,用 ARIMA 拟合完残差里还有很强自相关,说明线性模型丢失了很多信息。换成 LSTM 之后,测试集均方误差直接降了一个数量级。当然这不是说 ARIMA 没用,而是当关系确实是非线性的时候,循环神经网络这类结构更值得尝试。LSTM 是长短期记忆网络的缩写,核心能力就是“长短期”三个字:短期记住最近几个时间点的波动,长期记住整段序列的整体趋势。序列预测场景里,这种“选择性记忆”比“一股脑全记住”更接近真实规律。
1.2 这份Matlab代码能做什么、适合谁
如果你手里是一列连续观测值,比如传感器读数、日销量、网站访问量,并且想看未来几个时间点怎么走,这套代码就是一份可以直接落地的模板。它包含完整链路:数据读取、异常值处理、归一化、滑动窗口构造样本、定义 LSTM 网络、设置训练超参数、训练模型、在时间顺序靠后的测试集上做预测、计算误差指标、绘制对比曲线。整个过程只需要 Deep Learning Toolbox,不需要额外搭建 Python 环境,对于只在 Matlab 环境里做科研或工程的人来说非常省事。
适合三类人。第一,课程设计或毕业论文里需要“LSTM 预测”作为方法的学生,先跑通代码再换自己的数据,能省下大量调环境的时间;第二,工程师想快速验证某个序列能否用 LSTM 提升精度,这份代码模块化程度够高,方便换数据、换超参数;第三,已经在 Python 里写过 LSTM、想用 Matlab 复现的人,需要特别留意两种框架在序列输入维度和训练选项上的差异,下面我会把这些差异单独指出来。
2. 原理拆解:LSTM凭什么能记住历史
2.1 从RNN到LSTM:它解决了什么问题
RNN 的设计思路是把前一个时刻的隐藏状态传给下一个时刻,让网络拥有“记忆”。但训练时梯度要沿着时间方向逐层回传,一个连乘下来很容易指数级缩小或放大,表现为梯度消失或梯度爆炸。序列一长,早期信息就传不回来,RNN 实际上只剩短期记忆。LSTM 通过引入门控机制,让梯度在一条独立的“细胞状态”通道上流动,既能保持长期信息,又能有选择地更新和遗忘。
放到 Matlab 代码里,lstmLayer(64, 'OutputMode', 'last')这一行就是在计算整个门控过程。你不需要自己写门的公式,但理解这一点对调试很有帮助:当预测效果差时,你至少知道问题可能出在“模型没记住关键历史”,而不是“序列本身没有规律”。这也是为什么我总建议新手不要一上来就堆层数,先把单层 LSTM 的行为理解透。
2.2 LSTM的三道门控开关
LSTM 内部有遗忘门、输入门、输出门。把细胞状态想象成一本笔记本:遗忘门决定哪些旧页要翻过去,输入门决定新看到的内容哪些要记进来,输出门决定当前时刻到底读出哪部分写进答案。数学上用到 sigmoid 和 tanh:sigmoid 输出 0 到 1,控制“开关”的幅度;tanh 输出 -1 到 1,提供候选内容。这个设计让网络既能带着几十步之前的趋势走,又不会被每一步的无关波动干扰主线。
举个例子,复盘股票走势时你不需要记住每一天的价格,只需要记住“当前处于什么阶段”和“最近几天的动量”。LSTM 做的就是类似的事,最后的输出门生成当前时刻的隐藏状态,也就是预测用的特征。门控机制听起来高端,但直观上就是一个“重点笔记”系统,这也是为什么它对长时间依赖比普通 RNN 可靠得多。
2.3 从窗口到预测:完整数据流与量纲意识
绝大多数单变量 LSTM 预测任务,不是把整段历史一次性塞进去,而是滑动截取长度为windowSize的窗口,让模型看这个窗口,输出窗口后紧邻的下一个值。网络实际干的事是:用过去 n 个时间点预测第 n+1 个时间点。训练时把原序列滚动生成 N 个这样的样本对,构造出一个监督学习数据集。这个过程中,XTrain是一个 cell 数组,每个元素是一个1×windowSize的矩阵;YTrain是对应的目标值。这个格式非常容易踩坑,因为 Matlab 的sequenceInputLayer要求 cell 数组,如果XTrain是普通二维矩阵,trainNetwork会直接报维度错误。
完整数据流是:原始数据 → 归一化 → 滑动窗口 → cell 数组 → LSTM 层 → 全连接层 → 回归层 → 反向传播 → 预测 → 反归一化。每一步都必须保持数值范围一致,尤其归一化。训练前用全部数据的最大最小值,预测后的输出必须先做反归一化再和真实值比较,否则误差指标会非常难看。我见过不少人把归一化后的预测值和原始量纲的实测值直接画在一起,图上一高一低,以为模型失败了,其实只是量纲没对齐。
3. 数据准备:喂给LSTM的数据必须长这样
3.1 数据读取与异常值清洗
第一步把数据从 Excel 或 CSV 里读出来。我习惯用readtable读.xlsx,再取出数值列。这里有两个坑:一是文件里如果有表头,读出来是 table,需要按列名取出数据;二是 Excel 里某些日期行可能有空单元格,readtable会自动填充NaN,NaN 会导致归一化时链路断裂,训练损失直接出问题。建议先检查isnan,对缺失点做线性插值,不要直接删除,因为时间序列的连续性对 LSTM 很重要。
data_all = readtable('data.xlsx'); % 假设数值在第二列,第一列是时间 raw = data_all{:, 2}; % 对内部的NaN做线性插值 if any(isnan(raw)) idx = find(~isnan(raw)); % 注意序列首尾的NaN无法插值,建议先删除首尾空值 raw = interp1(idx, raw(idx), 1:length(raw), 'linear'); end还要处理明显异常值。过大的毛刺会让归一化后的数据几乎被压成一条线,LSTM 学不到正常段的信息。我一般会做一次简单的 3σ 滤波:计算均值 μ 和标准差 σ,把超出 μ±3σ 的点替换成边界值,或者用前后点的均值平滑。方法糙但好用,尤其是传感器偶尔跳变的数据。
3.2 归一化:为什么必须做、怎么还原
LSTM 内部大量使用 sigmoid 和 tanh 激活函数,输入范围天然适合落在 0 到 1 或 -1 到 1。如果你把几千度的温度或几百元的原始读数直接塞进去,激活函数很容易饱和,梯度变得极小,网络几乎不学习。归一化还有一个额外好处,就是让不同量纲的数据在训练时有统一尺度。最常见的做法是 min-max 公式:
x_min = min(raw); x_max = max(raw); norm_data = (raw - x_min) / (x_max - x_min);注意保存x_min和x_max,反归一化时必须用同一对参数。如果你习惯用mapminmax,它默认按行处理且范围是 (-1,1),用法是normed = mapminmax(raw', -1, 1)',很容易因为转置问题出错。我更推荐手写 min-max,至少你可以明确知道保存了哪些参数。测试集预测结果做反归一化时,直接用y_real = y_pred * (x_max - x_min) + x_min。
关于“训练集和测试集是否分别归一化”:我建议使用整个序列的 min 和 max。时间序列预测关心相对位置和趋势,测试集使用的量纲必须和训练保持一致。严格做法是只用训练集统计 min 和 max,但对于单变量序列两者差别很小,关键在于反归一化的参数必须和归一化时完全一致。
3.3 滑动窗口与训练测试集划分
窗口长度windowSize是最直接的超参数。窗口太短,模型看不到足够历史;窗口太长,样本数变少,训练数据不足,冗余信息还会淹没关键特征。对于周期性的用电量、交通量数据,我习惯让窗口至少覆盖一个完整周期:日数据至少 24,周数据至少 7,月度季节性数据至少 12。如果不知道周期,从 10 到 50 之间多试几组,对比验证集误差。
构造样本的代码:
windowSize = 24; numSamples = length(norm_data) - windowSize; X = zeros(numSamples, windowSize); Y = zeros(numSamples, 1); for i = 1:numSamples X(i, :) = norm_data(i:i+windowSize-1); Y(i) = norm_data(i+windowSize); end划分数据集时一定按时间顺序切分,不能像分类任务那样随机打乱,否则测试集里混入“未来”信息,评估精度虚高,部署到线上立刻恶化。我用训练集占 85%、测试集占 15%:
trainLen = floor(size(X, 1) * 0.85); XTrainCell = {}; for i = 1:trainLen XTrainCell{i} = X(i, :)'; end XTestCell = {}; for i = trainLen+1:size(X, 1) XTestCell{i - trainLen} = X(i, :)'; end YTest = Y(trainLen+1:end); YTrain = Y(1:trainLen);注意XTrainCell里每个元素是1×windowSize,因为序列输入层要求每个 cell 是一个“特征数 × 时间步数”的矩阵,这里特征是 1 个所以转置成行向量。如果以后想加入多个特征,每个 cell 就是m×windowSize,这是 LSTM 预测从单变量扩展到多变量的关键改动点。我建议一开始就把这套转置逻辑写熟,后面扩展才不会乱。
4. 核心代码实现与参数解析
4.1 网络结构定义与逐层含义
构建一个适用于单变量序列单步预测的 LSTM 网络:
numHiddenUnits = 64; layers = [ sequenceInputLayer(1) lstmLayer(numHiddenUnits, 'OutputMode', 'last') dropoutLayer(0.2) fullyConnectedLayer(1) regressionLayer ];几个容易迷糊的点,我逐一拆开讲。
第一,sequenceInputLayer(1)的1是特征维度,不是序列长度。很多新手在这里写成windowSize,然后训练时维度对不上报错。第二,lstmLayer里'OutputMode', 'last'表示 LSTM 只输出最后一个时间步的隐藏状态,因为当前任务是 sequence-to-one,即一个窗口预测一个值。如果你做的是逐点预测或序列补齐,才把输出模式设成'sequence'。第三,fullyConnectedLayer(1)把 64 维隐藏状态压缩成 1 维数值,regressionLayer负责计算回归损失,也就是均方误差。
dropoutLayer(0.2)不是必须的,但数据量小的时候过拟合几乎是必然,建议保留。Matlab 的 dropout 训练时生效、预测时自动关闭,不用额外操心。如果你想把网络加深,可以在第一个lstmLayer后再接一层lstmLayer(32, 'OutputMode', 'last'),但两层结构会显著增加训练时间,数据量不够时深层结构反而更差。
4.2 训练选项核心参数解析
训练选项我一般这样配置:
options = trainingOptions('adam', ... 'MaxEpochs', 200, ... 'MiniBatchSize', 32, ... 'InitialLearnRate', 0.005, ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropFactor', 0.5, ... 'LearnRateDropPeriod', 50, ... 'GradientThreshold', 1, ... 'Shuffle', 'every-epoch', ... 'ValidationData', {XValCell, YVal}, ... 'Verbose', 1, ... 'Plots', 'training-progress');这些参数每个都影响训练行为,我按实际经验整理成下面的对照表:
| 参数 | 建议初值 | 实际意义与调整思路 |
|---|---|---|
| MaxEpochs | 200 | 遍历数据的轮数。轮数多不一定好,配合 ValidationData 看验证损失是否反弹。 |
| MiniBatchSize | 32 | 一次反向传播的样本数。调大训练快但吃内存,序列长时降到 16。 |
| InitialLearnRate | 0.005 | Adam 对学习率有一定自适应,序列任务从 0.001 到 0.01 之间试,震荡就调低。 |
| GradientThreshold | 1 | 梯度裁剪阈值,LSTM 出现梯度爆炸导致 NaN 时的急救保险。 |
| Shuffle | every-epoch | 每个 epoch 打乱样本顺序,不会破坏单条序列的内部时域连续性。 |
| ValidationData | 单独切一块 | 用来观察验证损失,能自动早停,不必每次盯控制台。 |
训练代码就一行:
net = trainNetwork(XTrainCell, YTrain, layers, options);如果设置了ValidationData,trainNetwork会自动计算验证损失,并在验证损失连续多次不下降后提前终止训练。这个机制在人工试参时非常省时间,我强烈建议打开'Plots', 'training-progress',实时看曲线比盯着一串数字直观得多。没有 GPU 的机器把 MiniBatchSize 调小,否则内存容易吃紧。
4.3 Matlab与Python框架的输入格式差异
必须把 Matlab 和 Python 框架的一个关键差异拎出来讲。PyTorch 里 LSTM 输入形状是(seq_len, batch, features),而 Matlab 的trainNetwork用 cell 数组作为序列输入,每个 cell 是一段独立样本。如果直接把二维矩阵传给sequenceInputLayer,会触发输入类型不匹配。
整个“构造样本”的过程都要围绕 cell 数组进行,预测阶段也一样:
YPred = predict(net, XTestCell, 'MiniBatchSize', 32);返回的YPred是长度等于测试样本数的向量。如果你的预测阶段报“维度不对”,先回头检查XTrainCell里每个 cell 是1×windowSize还是windowSize×1。这个错误我见过太多次,通常是因为在 Python 里写习惯了,把矩阵行当成了样本维度。
5. 预测与评估:判断模型真正靠谱
5.1 反归一化与误差指标计算
训练完成后对测试集做预测,得到的是归一化数值,必须转回原始量纲再计算误差:
YPredNorm = predict(net, XTestCell, 'MiniBatchSize', 32); YPred = YPredNorm * (x_max - x_min) + x_min; YTestReal = YTest * (x_max - x_min) + x_min;然后计算一组完整指标:
RMSE = sqrt(mean((YPred - YTestReal).^2)); MAE = mean(abs(YPred - YTestReal)); MAPE = mean(abs((YPred - YTestReal) ./ YTestReal)) * 100; SS_res = sum((YTestReal - YPred).^2); SS_tot = sum((YTestReal - mean(YTestReal)).^2); R2 = 1 - SS_res / SS_tot;RMSE 对较大误差更敏感,适合暴露“偶尔预测得很差”的情况;MAE 更稳健;MAPE 直观但测试集里有 0 时会变成无穷大,不要盲目用。R2 是拟合优度,越接近 1 说明模型解释了越多方差,不过时间序列里 R2 有时被整体趋势主导,最好结合 RMSE 一起看。我在实际报告中习惯把 RMSE 和 MAPE 都列出来,前者看绝对误差,后者看相对误差。
5.2 结果可视化
预测完一定要画图,把真实值和预测值放在同一张图里,你立刻就能看出滞后、趋势偏移、幅度失真这些数值指标看不出来的问题。
figure; plot(YTestReal, 'LineWidth', 1.5); hold on; plot(YPred, '--', 'LineWidth', 1.5); legend('真实值', 'LSTM预测值'); xlabel('测试样本序号'); ylabel('原始量纲数值'); grid on;实际项目里最常出现的 LSTM 预测图是“预测值比真实值晚半拍”,曲线整体向右偏移。这种情况下数值指标可能不算离谱,但图上一眼就能看穿。因此我建议把真实值和预测值按时间顺序画折线,不要画散点,否则滞后特征会被抹掉。想更深入就再画一张残差图,看残差是否还有规律性,如果相邻残差高度相关,说明模型漏掉了重要时间信息,单纯看 RMSE 是发现不了的。
5.3 多步滚动预测的实现思路
如果需求不是“用过去 24 小时预测下 1 小时”,而是要预测未来 24 小时,有两种常用方案。第一种是把网络最后输出维度改成目标步数,fullyConnectedLayer(horizon),训练标签用窗口后连续horizon个值。这种直接多步预测对短期有效,但误差会随步数快速累积。第二种是滚动预测:每步用最新窗口预测下一点,把预测值拼到窗口尾部,同时丢掉窗口最老的观测值,迭代直到达到目标步数。
滚动预测代码示例:
% 用最新窗口继续向前预测horizon步 current_window = norm_data(end - windowSize + 1 : end)'; horizon = 12; preds = zeros(horizon, 1); for k = 1:horizon next_val = predict(net, {current_window}); preds(k) = next_val; current_window = [current_window(2:end), next_val]; end preds_actual = preds * (x_max - x_min) + x_min;滚动预测的优点是无需改网络结构,缺点是误差会累积,预测越远越不可靠。所以我在正式报告里,对长期预测一般只展示短期步长的滚动结果,并明确注明这是迭代预测,不要当作模型直接输出的多步结果。
6. 常见问题与排错实录
6.1 训练损失不下降或下降后验证损失反弹
先检查数据是否归一化,这一步出问题的概率最高。如果原始数据量纲在几千上万,未归一化的 LSTM 损失曲线会长时间平坦或剧烈震荡,因为激活函数饱和。其次检查学习率,InitialLearnRate直接设 0.01 通常偏大,序列预测任务推荐从 0.005 开始,看到损失震荡再调低到 0.001。第三,检查样本量,一个 LSTM 至少需要几百个窗口样本,只有几十个窗口时很容易收敛到没有意义的局部解。
如果训练损失下降但验证损失一路走高,这是过拟合的典型信号。按优先级处理:先加 dropout 层,把 0.2 提到 0.3 或 0.4;再减少hiddenUnits,从 64 降到 32;然后降低 epoch 数或者让ValidationData的早停机制生效。数据量少的时候过拟合几乎是必然,所以不要盲目堆神经元数量。我自己的经验是,单变量小型序列预测里,64 个隐藏单元已经是够用的起点,超过 128 反而更容易出问题。
6.2 预测结果整体滞后怎么办
这是 LSTM 序列预测里最经典的现象:真实值上升,预测值缓慢跟上,真实值下降,预测值继续惯性走一段,画出来像一条被平滑过的曲线。原因大概率是序列本身自相关很强,模型发现“近似记住上一个值”就是最低损失策略,只要把窗口内最后一两个点加权复制出来,训练损失就能压得很低。
处理方向有三个。第一,不要直接预测原始值,改为预测差分值y_{t+1} - y_t,让模型学习“变化量”而不是“绝对水平”,实际效果明显。第二,加大窗口长度,给模型更丰富的上下文,促使它学习趋势而不是单点惯性。第三,引入额外输入特征:对于带周期性的序列,把时刻索引、星期几、滞后若干阶的特征拼进输入,给模型更多线索区分当前位置。我建议先试第一种,因为它实现成本最低,而且在单变量序列里往往就能把滞后问题压制住。
6.3 运行时NaN、内存与版本相关坑
训练时控制台突然输出 NaN,大概率是梯度爆炸,GradientThreshold设成 1 基本能遏制。如果还是 NaN,检查数据里是否有 0 值或极端异常点,0 值会让某些标准化计算出现除零,LSTM 内部也会跟着异常。
内存方面,cell 数组本身不大,但如果先构造一个几千行×几百列的大矩阵,再逐行转成 cell,会在训练前阶段占用大量内存。建议构造样本时直接生成 cell,不要先存大矩阵再二次转换。用 GPU 训练时显存不够,就把 MiniBatchSize 减半,或者把hiddenUnits调小。
版本方面,lstmLayer和trainNetwork需要 Deep Learning Toolbox,R2017b 之后的版本基本都有,但太老的版本对ValidationData、dropoutLayer的用法可能略有差异。我建议至少用 R2020a 之后的版本,上面这套代码可以直接跑通。如果换到更新的版本遇到函数被替代的提示,按 Matlab 给的替代函数名改一下即可,训练逻辑不变。
最后提一个我自己的习惯:正式训练前先不看完整结果,先用 30 个 epoch 快速跑一遍,观察损失曲线形状。如果训练损失快速下降但验证损失基本不动,说明模型容量和数据规模不匹配;如果两者都平稳下降,再放长训练到 200 个 epoch。我踩过最亏的一次坑,是直接跑满 500 个 epoch,跑到后面验证损失已经反弹回去一大截,白白浪费了三个小时。另外,训练完记得保存模型,Matlab 里直接save('lstm_net.mat', 'net'),下次读取后即使数据源文件丢了,只要还保存着x_min和x_max,也能立即对新数据做预测。细微但管用,这就是实际项目里最后那 10% 的差距。