单变量与多变量时间序列预测:LSTM建模实战指南

发布时间:2026/9/15 6:15:49
单变量与多变量时间序列预测:LSTM建模实战指南 简介面向时间序列预测学习者与研究者的MATLAB算法示例包同时覆盖多变量与单变量场景集成了深度学习与浅层学习两类方法的预测流程。压缩包共8个文件核心为1个MATLAB脚本配合说明文档与6个Excel数据表整体大小仅153KB结构紧凑便于快速上手。数据表涵盖混沌激光、全球冰量、交通事故、水痘病例、国际航空旅客、太阳黑子等典型序列分别体现混沌、趋势、季节性与周期性特征可用来对比长短期记忆网络等深度学习模型与传统浅层模型在不同数据形态下的表现。直接运行脚本即可复现预测结果也可基于现有接口替换数据或算法模块扩展到其他数据集或改进模型适合课程设计、算法评测与论文实验场景。目前已有1841人学习下载整体轻量实用适合作为时序预测入门参考。1. 多变量和单变量时间序列预测先理清你在预测什么一个常见的翻车现场拿到两年的日销量数据把日期列删掉直接把销量喂给 LSTM训练集损失一路往下掉一到验证集上就横向漂移。这不是模型不够深而是你根本没有区分自己在做的是单变量预测还是多变量预测。单变量只依赖自身历史比如“用过去 14 天的销量预测下一天销量”多变量则引入外部驱动因素比如温度、促销标志、竞品价格用多维输入去逼近一个或多个输出。两者的数据处理、窗口构造、模型输入维度和评估方式完全不同混在一起做只会得到“看起来有道理上线就崩”的结果。这篇博文会从两种预测的边界讲起给出可直接复现的 Python 代码、关键的参数设置和我在实践中踩过的坑面向至少两年以上经验的工程师也兼顾刚入门的新手。2. 单变量与多变量的本质差异和建模选型2.1 时间序列预测中“变量”到底指什么在进入代码之前要先明确统计学习里对“变量”的定义。时间序列预测中的单变量指的是目标序列只受自身历史影响形式化表达为y(t) f(y(t-1), y(t-2), ..., y(t-p)) epsilon即用目标序列的 p 个历史滞后项作为特征。ARIMA、指数平滑这类传统统计模型天然就是单变量框架因为它们的核心假设是“序列自身的自相关结构包含未来信息”。多变量预测引入外生变量形式变为y(t) f(x1(t), x2(t), ..., xk(t), y(t-1), y(t-2), ..., y(t-p))这里的 x 可以是同时刻的外生变量比如当天的天气也可以是滞后外生变量比如前一天的油价。注意一个容易混淆的点多变量预测的“输出”是什么常见有两种一种是有多个目标序列同时预测比如预测多个股票的价格另一种是只有一个目标序列但输入是多维的。后者在实际工程中更常见通常叫做“多变量输入、单步输出”。本篇文章后面的代码主要围绕“多变量输入、预测单变量目标”来写。这直接影响你如何设计模型最后一层的神经元数量和 loss 函数——是回归单点用 MSE还是多点头脑用均方差矩阵。建模前第一件事是画一张数据字典标出哪些列是外生变量哪些列是目标变量以及它们的滞后阶数。2.2 滞后阶数与滑动窗口单变量和多变量都绕不开的预处理无论是 LSTM、Transformer 还是 XGBoost落到工程上第一步都是把原始序列切成监督学习格式。单变量的监督化很简单用 shift 函数制造滞后列import pandas as pd def create_univariate_windows(data, window): 将单变量序列转为监督学习所需的 (X, y) 格式 data: pd.Series, 目标序列 window: int, 滞后窗口大小 df pd.DataFrame({y: data}) for i in range(1, window 1): df[flag_{i}] df[y].shift(i) df df.dropna() X df[[flag_{i} for i in range(window, 0, -1)]].values y df[y].values return X, y # 示例100 个时间点窗口 7 import numpy as np data np.sin(np.linspace(0, 10, 100)) np.random.normal(0, 0.1, 100) X, y create_univariate_windows(data, 7) print(X.shape, y.shape) # (93, 7) (93,)这段代码里的shift(i)产生向后平移 i 个时间步的列。注意我构造列时用了range(window, 0, -1)这是为了让第 1 列是最近的滞后lag_7 在shift(7)中表示 t-7但在 DataFrame 里顺序需要翻转保证 LSTM 输入时时间顺序是从旧到新。如果你不关心顺序结果可能一致但当你使用 CNN 或 Transformer 时输入特征沿着时间维度的顺序会影响感受野所以养成这个习惯。多变量的窗口构造需要同时处理目标序列和外生序列。一个典型场景是你有 5 个外生特征希望用过去 7 天的所有特征预测今天的销售。代码上要分别构造滞后再沿着特征维做 concatdef create_multivariate_windows(data, window): data: pd.DataFrame, 每列是一个特征第一列是目标变量 window: int, 滞后窗口 cols [] for i in range(window, -1, -1): shifted data.shift(i) shifted.columns [f{col}_t-{i} for col in data.columns] cols.append(shifted) df pd.concat(cols, axis1) df df.dropna() # 目标变量是当前时刻t-0的目标列 y df[target_t-0].values # 删除目标列在当前时刻的值保留所有历史特征 feature_cols [c for c in df.columns if not c.startswith(target_t-0)] X df[feature_cols].values return X, y这个函数里我引入了t-0的特指目标变量在t-0时是我们要预测的 y而外生变量在t-0时作为输入特征。这里有个关键取舍如果外生变量在未来是可预知的比如“明天是否为法定节假日”那么你可以合法地将t-0的外生特征放入输入。如果外生变量也是不可知的滚动预测比如明天的天气本身要被预测则输入中必须去掉所有t-0的外生列否则会产生泄漏。我在实际项目中见过多次因为含了未来外生变量导致线上评估虚高的情况。2.3 传统模型与深度模型在两类任务上的边界统计类模型ARIMA、Prophet至今仍是单变量预测的强基线。它们的好处是训练快、可解释性强坏处是难以吸收外生变量尤其当外生变量是类别型或存在非线性交互时。XGBoost 可以天然处理多变量输入它在特征工程充分的情况下往往比 LSTM 效果更好尤其是数据量小于几万条时。但 XGBoost 做时序预测时必须手工构造滞后特征而且它不会学习到时间维度的“周期性转移不变性”这意味着你输入窗口大小是 7模型就只能看到过去 7 天看不到更久远的季节性模式。LSTM 以及它的变体GRU、Seq2Seq之所以成为深度学习时间序列预测的主流在于门控机制可以在窗口之外记忆长期依赖。对于多变量预测LSTM 的输入尺寸是(batch, timesteps, features)它天然接受每个时间步上的多维特征且通过 hidden state 跨时间步传递信息。实测下来当数据量达到 10 万级以上且外生变量与目标存在非线性延迟关系比如下雨的影响滞后 2 天体现在销量上LSTM 通常优于手工滞后特征的 XGBoost。但要注意 LSTM 对输入尺度极其敏感后面会专门讲归一化。选型上我的建议是少于 5000 条数据且纯单变量先跑 ARIMA 和 Prophet 作 baseline。需要解释特征重要性用 XGBoost。长序列1000 个时间步且有明显的长期季节性和外生变量直接上 LSTM 或 GRU。多步预测时避免“递归多步法”用“直接多步法”或者用 Sequence to Sequence 结构。3. 用 PyTorch 搭建单变量 LSTM 预测的最小可运行工程3.1 数据标准化与训练集/验证集切分的正确姿势时序预测的切分绝对不能随机 shuffle否则验证集里会出现训练集没见过却相邻的未来样本会造成数据泄漏。我一般按时间顺序切分前 80% 的训练后 20% 的验证并且验证集必须紧邻训练集末尾。标准化的最佳时机是“先切分再 fit”用训练集的统计量转换验证集。这就避免验证集的信息渗透到训练预处理阶段。from sklearn.preprocessing import MinMaxScaler def scale_train_val(train, val): 用时序切分后的训练集 fit scaler再 transform 验证集 scaler MinMaxScaler(feature_range(0, 1)) train_scaled scaler.fit_transform(train.reshape(-1, 1)).flatten() val_scaled scaler.transform(val.reshape(-1, 1)).flatten() return train_scaled, val_scaled, scaler单变量 LSTM 的输入维度是(batch, window, 1)即使只有一个特征也要保持第三维为 1这是 PyTorch 的nn.LSTM明确要求的格式。3.2 模型定义与训练循环下面是一套可以直接搬走的代码我习惯把训练循环写成函数而不是用 PyTorch Lightning因为调试时更直观。代码里的注释包含参数设计理由。import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset class LSTMPredictor(nn.Module): def __init__(self, input_size, hidden_size, num_layers, output_size): super(LSTMPredictor, self).__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue ) self.linear nn.Linear(hidden_size, output_size) def forward(self, x): # x: (batch, window, features) lstm_out, _ self.lstm(x) # (batch, window, hidden) out self.linear(lstm_out[:, -1, :]) # 取最后一个时间步 return out def train_epoch(model, loader, loss_fn, 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 loss_fn(pred.squeeze(), y_batch) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(loader) # 构造数据 device torch.device(cuda if torch.cuda.is_available() else cpu) window 14 X_train, y_train create_univariate_windows(train_scaled, window) X_val, y_val create_univariate_windows(val_scaled, window) X_train torch.tensor(X_train, dtypetorch.float32).unsqueeze(-1) y_train torch.tensor(y_train, dtypetorch.float32) # unsqueeze(-1) 将 (batch, window) 变成 (batch, window, 1) train_loader DataLoader(TensorDataset(X_train, y_train), batch_size64, shuffleFalse) # 时序数据 shuffle 必须设为 False否则窗口顺序被打乱训练无意义 model LSTMPredictor(input_size1, hidden_size64, num_layers2, output_size1).to(device) loss_fn nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) for epoch in range(200): loss train_epoch(model, train_loader, loss_fn, optimizer, device) if epoch % 20 0: print(fEpoch {epoch}: loss{loss:.6f})关于clip_grad_normLSTM 训练中经常遇到梯度爆炸尤其是长序列。设 max_norm1.0 能防止 loss 变成 NaN代价是训练会慢一点。batch_firstTrue代表输入的形状是(batch, seq, feature)这是 PyTorch 2.x 的推荐写法很直观。batch_size64这个值在数据量不大时没问题但窗口越长batch 内每个样本的记忆状态长度越大显存占用上升8GB 显存建议降到 32。num_layers2的经验是一层不够拟合复杂序列三层以上在数据量不够时往往过拟合。你可以先用两层跑基线再用验证集判断是否增加。3.3 多变量输入的关键改动点从单变量扩展到多变量模型代码要改动的很小但数据处理部分的差异是实质性的。首先构造输入时不再只含目标滞后而是把所有特征窗口拼接起来。关键参数是input_size它等于外生变量数 11 是目标自己。下面这段代码基于前面 2.2 节的多变量窗口函数假设外生特征 3 列# 假设 data 是 DataFrame包含 target, temp, holiday, price data[target] data[sales] # 先标准化每个特征拆开来 fit_transform避免不同量纲的列被混进来 from sklearn.preprocessing import MinMaxScaler scalers {} scaled_cols [] for col in [target, temp, holiday, price]: scaler MinMaxScaler(feature_range(0, 1)) # 注意 fit 只用在训练段 train_part data[col].values[:train_len].reshape(-1, 1) scaler.fit(train_part) scaled_col scaler.transform(data[col].values.reshape(-1, 1)).flatten() scaled_cols.append(scaled_col) scalers[col] scaler df_scaled pd.DataFrame({target: scaled_cols[0], temp: scaled_cols[1], holiday: scaled_cols[2], price: scaled_cols[3]}) # 构造窗口 X, y create_multivariate_windows(df_scaled, window7) # 输入形状检查 print(X.shape) # (样本数, 窗口*(特征数1)) # 重新变形为 LSTM 需要的 3D 格式 # 注意 create_multivariate_windows 返回的 X 是二维的需要自己 reshape num_features 4 # target 3 外生 X X.reshape((-1, window, num_features))这段代码里有几个容易被忽略的点。先拆列 fit 再标准化是必要操作如果你对整张 DataFrame 做fit_transform不同列之间的相对尺度会被 MinMaxScaler 压缩到同一范围内这看起来无害但后续如果你要反归一化 target 列你就找不到单独的 scaler 了。另外holiday是二元列它其实不需要标准化但 MinMaxScaler 对它不会造成伤害。由于我是先缩放再画窗口目标列在 t-0 时刻的值仍在输入中明确它的去留取决于你是否使用 t-0 的外生信息。4. 多步预测与模型评估反向传播之外的陷阱4.1 多步预测的两种常见模式很多业务场景要求的不是“预测明天”而是“预测未来 14 天”。这会把问题从单输出变成多输出。两种常见实现路径递归策略和直接策略。递归多步预测模型每次输出一个值再把它拼接到输入窗口末尾继续预测下一步。问题在于误差会逐步累积。假设单步预测误差是 5%递归预测第 14 步的实际误差大概在 20% 到 60% 之间。直接多步预测让模型输出未来多个时间点的值即把输出层维度设为预测步长 H。这种做法规避了误差累积但会固定预测窗口长度无法灵活调整。工程上为了兼顾两者通常用 Sequence to SequenceEncoder-Decoder但训练复杂度大得多。我在实际项目中在预测步长小于 7 时使用直接多输出大于 7 时使用 seq2seq。直接多输出 LSTM 的实现很简单只需要修改输出层。原来的output_size从 1 改为 Hloss 函数改为逐个时间步的 MSE 平均class LSTMMultiStep(nn.Module): def __init__(self, input_size, hidden_size, forecast_horizon): super(LSTMMultiStep, self).__init__() self.lstm nn.LSTM(input_size, hidden_size, batch_firstTrue) self.linear nn.Linear(hidden_size, forecast_horizon) def forward(self, x): lstm_out, _ self.lstm(x) # 取最后一个时间步的 hidden输出 H 个值 return self.linear(lstm_out[:, -1, :])训练时 y 的形状是(batch, H)需要将它从原始的“每一天一个标签”改造成“每段窗口后接 H 个值”。这部分工程细节容易出错因为 pandas 的窗口函数通常返回单个滞后目标你需要额外构造def create_multistep_labels(data, window, horizon): X, y [], [] for i in range(len(data) - window - horizon 1): X.append(data[i:iwindow]) y.append(data[iwindow:iwindowhorizon]) return np.array(X), np.array(y)这里i表示窗口起始位置data[iwindow:iwindowhorizon]是紧随窗口后的 H 个目标值。注意最后一个可用的窗口起始位置是len(data) - window - horizon否则 y 会越界。这是我排查过很多次的地方一旦出现“验证集 loss 比训练集小”的诡异现象十有八九是 y 序列的偏移错了。4.2 评估指标的选择RMSE、MAE、MAPE 的适用边界时间序列预测的评估指标不能只看测试集总损失。我习惯同时输出三个指标因为它们的侧重点不同指标表达式适用场景弊端RMSEsqrt(mean((y_true - y_pred)^2))大误差敏感适合金融损益对异常值惩罚过重MAEmean(abs(y_true - y_pred))平稳序列期望直观解释无法体现波动惩罚MAPEmean(abs((y_true-y_pred)/y_true)) * 100业务汇报百分数直观当 y_true0 时无定义小分母放大误差在多变量预测中我还额外关注“分位区间覆盖率”尤其是预测销售库存场景。用 LSTM 直接输出的点预测往往过于乐观真实的波动在节假日前后会急剧放大。一个轻量化的做法是训练多个种子不同随机初始化用多次预测的均值作为点预测标准差作为置信区间。这样能直接量化模型的不确定性。工程代价只是训练 N 次模型推理时并行执行。4.3 反归一化中容易踩的坑反归一化看起来简单但有一个常见错误用验证期间的 MinMaxScaler 还原预测值而不是用训练集那个 scaler。如果你对验证集单独做了 fit验证集的转换结果跟训练集的量纲可能不一致最终 RMSE 会失真。正确做法是始终使用训练集 fit 好的 scalerpreds_inv scaler.inverse_transform(preds.reshape(-1, 1)).flatten() y_true_inv scaler.inverse_transform(y_true.reshape(-1, 1)).flatten() rmse np.sqrt(np.mean((y_true_inv - preds_inv) ** 2))另外注意如果你的目标列经过了 log1p 变换再缩放反归一化时顺序必须反过来。通常我会把预处理流程封装成一个管道以管线输出经过逆变换后的最终预测确保验证指标计算发生在原始尺度上。5. 用特征工程和外生变量提升多变量预测的鲁棒性5.1 衍生时间特征把日历知识编码给模型LSTM 本身不知道“星期几”“是否月末”“第几周”这些信息要么通过外生变量传入要么在特征里手工构造。经过多个项目的观察加了一组时间特征后验证集 MAPE 通常下降 5%~15%。常见做法是构造正弦/余弦编码而不是整数编码因为整数编码比如 1-7 表示周一到周日会带来不必要的线性关系而正弦余弦可以表达周期性距离。例如def add_time_features(df, dt_col): df[day_of_week] df[dt_col].dt.dayofweek df[hour] df[dt_col].dt.hour df[day_sin] np.sin(2 * np.pi * df[day_of_week] / 7) df[day_cos] np.cos(2 * np.pi * df[day_of_week] / 7) df[hour_sin] np.sin(2 * np.pi * df[hour] / 24) df[hour_cos] np.cos(2 * np.pi * df[hour] / 24) return df这些列加入 LSTM 输入时注意它们和销量数据的尺度差异。day_sin的范围在 [-1, 1]但销量如果标准化到 [0,1]它们可以直接拼接。如果销量是原始值上百万你需要重新归一化所以这些特征否则 LSTM 在反向传播时损失会被大尺度列主导导致小尺度列的梯度失效。5.2 滚动滞后统计量在窗口外补充长期记忆LSTM 的窗口长度如果不覆盖季节性周期比如年度销量有 365 天季节性即使 LSTM 有 hidden state反向传播梯度在长程依赖上依然衰减。工程上补一个步骤在窗口特征之外额外加入滚动统计量比如过去 7 天的平均值、过去 30 天标准差。这些统计量不是喂给 LSTM 的时间步而是拼在每步的特征里。也就是把原始序列的窗口内特征和滚动统计量做 concat。df[rolling_mean_7] df[target].rolling(7).mean() df[rolling_std_30] df[target].rolling(30).std()注意这里有个泄漏风险如果滚动均值用了包含当前时刻 t 的数据那么预测 t1 时这个特征是已知的预测 t 本身就是目标属于合法信息。但如果滚动窗口是“过去 7 天”且包括 t那么你就偷看了 t 时刻的真实值。操作上必须对rolling窗口做 shift(1)确保只含历史df[rolling_mean_7] df[target].shift(1).rolling(7).mean() df[rolling_std_30] df[target].shift(1).rolling(30).std()这个 shift 常常被忽略带来实测量级很小的虚拟提升隐蔽性很强。5.3 早停与模型快照别把验证集调到过拟合时间序列模型训练到某个 epoch 后往往开始过拟合训练集噪声。业内最标准的做法是早停Early Stopping。关键参数是 patience在验证集 loss 停止下降后继续等几个 epoch。我把这视为“最后一次收敛确认”。使用 PyTorch 的ReduceLROnPlateau配合早停是一个很稳的组合。下面是一个实际工作常用的模板early_stop_epochs 15 best_val_loss float(inf) epochs_no_improve 0 for epoch in range(300): train_loss train_epoch(...) val_loss evaluate(model, val_loader, loss_fn, device) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_model.pth) epochs_no_improve 0 else: epochs_no_improve 1 if epochs_no_improve early_stop_epochs: print(fEarly stopping at epoch {epoch}) breakpatience15是我比较喜欢的值设太大浪费算力设太小容易在 loss 平台期误停。另外早停之后要用保存的best_model.pth加载回模型而不是直接用最后一次迭代的权重。很多新手踩过这个坑训练循环结束返回的是最终权重而最佳权重在中间某个 epoch。5.4 多步预测中的“隐藏状态重置”与序列边界LSTM 训练有两种策略跨 batch 传递 hidden state 与不传递。前者用于连续长序列后者用于窗口独立样本。在时间序列预测里如果你每个 sample 是一个完整的窗口batch 内部样本的时间顺序被打乱即便 shuffleFalsebatch 之间也可能切断了时间连续性此时 hidden state 不能跨 batch 保存否则模型会偷偷利用未来信息。PyTorch 默认这样做没问题但如果你使用「连续序列切割」法训练就必须在每个 batch 开头显式地model.lstm.reset_hidden()。这个细节直接关系到验证集评估的公平性。我见过不少模型调试到深夜发现 loss 不降原因是把时间顺序和 batch 顺序搞混了。用最后的早停和统计特征技巧收尾把可复现性做到位这篇文章的主线就完整了。本文还有配套的精品资源点击获取