单变量与多变量时间序列预测:LSTM建模实战指南
2026/9/15 6:15:38 网站建设 项目流程

简介:面向时间序列预测学习者与研究者的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[f'lag_{i}'] = df['y'].shift(i) df = df.dropna() X = df[[f'lag_{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 天的所有特征预测今天的销售。代码上要分别构造滞后,再沿着特征维做 concat:

def 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, axis=1) 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_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) 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_norm=1.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, dtype=torch.float32).unsqueeze(-1) y_train = torch.tensor(y_train, dtype=torch.float32) # unsqueeze(-1) 将 (batch, window) 变成 (batch, window, 1) train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=64, shuffle=False) # 时序数据 shuffle 必须设为 False,否则窗口顺序被打乱,训练无意义 model = LSTMPredictor(input_size=1, hidden_size=64, num_layers=2, output_size=1).to(device) loss_fn = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) for epoch in range(200): loss = train_epoch(model, train_loader, loss_fn, optimizer, device) if epoch % 20 == 0: print(f'Epoch {epoch}: loss={loss:.6f}')

关于clip_grad_norm,LSTM 训练中经常遇到梯度爆炸,尤其是长序列。设 max_norm=1.0 能防止 loss 变成 NaN,代价是训练会慢一点。batch_first=True代表输入的形状是(batch, seq, feature),这是 PyTorch 2.x 的推荐写法,很直观。batch_size=64这个值在数据量不大时没问题,但窗口越长,batch 内每个样本的记忆状态长度越大,显存占用上升,8GB 显存建议降到 32。num_layers=2的经验是:一层不够拟合复杂序列,三层以上在数据量不够时往往过拟合。你可以先用两层跑基线,再用验证集判断是否增加。

3.3 多变量输入的关键改动点

从单变量扩展到多变量,模型代码要改动的很小,但数据处理部分的差异是实质性的。首先构造输入时不再只含目标滞后,而是把所有特征窗口拼接起来。关键参数是input_size,它等于外生变量数 + 1(1 是目标自己)。下面这段代码基于前面 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, window=7) # 输入形状检查 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 Sequence(Encoder-Decoder),但训练复杂度大得多。我在实际项目中,在预测步长小于 7 时使用直接多输出,大于 7 时使用 seq2seq。

直接多输出 LSTM 的实现很简单,只需要修改输出层。原来的output_size从 1 改为 H,loss 函数改为逐个时间步的 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_first=True) 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:i+window]) y.append(data[i+window:i+window+horizon]) return np.array(X), np.array(y)

这里i表示窗口起始位置,data[i+window:i+window+horizon]是紧随窗口后的 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_true=0 时无定义,小分母放大误差

在多变量预测中,我还额外关注“分位区间覆盖率”,尤其是预测销售库存场景。用 LSTM 直接输出的点预测往往过于乐观,真实的波动在节假日前后会急剧放大。一个轻量化的做法是训练多个种子(不同随机初始化),用多次预测的均值作为点预测,标准差作为置信区间。这样能直接量化模型的不确定性。工程代价只是训练 N 次模型,推理时并行执行。

4.3 反归一化中容易踩的坑

反归一化看起来简单,但有一个常见错误:用验证期间的 MinMaxScaler 还原预测值,而不是用训练集那个 scaler。如果你对验证集单独做了 fit,验证集的转换结果跟训练集的量纲可能不一致,最终 RMSE 会失真。正确做法是始终使用训练集 fit 好的 scaler:

preds_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 的数据,那么预测 t+1 时这个特征是已知的,预测 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(f'Early stopping at epoch {epoch}') break

patience=15是我比较喜欢的值,设太大浪费算力,设太小容易在 loss 平台期误停。另外,早停之后要用保存的best_model.pth加载回模型,而不是直接用最后一次迭代的权重。很多新手踩过这个坑:训练循环结束返回的是最终权重,而最佳权重在中间某个 epoch。

5.4 多步预测中的“隐藏状态重置”与序列边界

LSTM 训练有两种策略:跨 batch 传递 hidden state 与不传递。前者用于连续长序列,后者用于窗口独立样本。在时间序列预测里,如果你每个 sample 是一个完整的窗口,batch 内部样本的时间顺序被打乱(即便 shuffle=False,batch 之间也可能切断了时间连续性),此时 hidden state 不能跨 batch 保存,否则模型会偷偷利用未来信息。PyTorch 默认这样做没问题,但如果你使用「连续序列切割」法训练,就必须在每个 batch 开头显式地model.lstm.reset_hidden()。这个细节直接关系到验证集评估的公平性。我见过不少模型调试到深夜发现 loss 不降,原因是把时间顺序和 batch 顺序搞混了。

用最后的早停和统计特征技巧收尾,把可复现性做到位,这篇文章的主线就完整了。

本文还有配套的精品资源,点击获取

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

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

立即咨询