ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

GARCH-LSTM混合模型实现时间序列预测实战详解

GARCH-LSTM混合模型实现时间序列预测实战详解

从做时间序列预测那天起,我就一直在琢磨一个问题:为什么单独用一个统计模型或者单独用一个神经网络,总觉得差点意思?直到我真正把 GARCH 和 LSTM 拼在一起,才体会到什么叫“优势互补”。GARCH 抓波动率聚集,LSTM 抓非线性残余模式,两者结合出来的预测效果,确实比单独用任何一个都要稳。这篇文章把我在“基于 GARCH-LSTM 模型的混合方法进行时间序列预测研究附Python代码”这个项目里的完整思路、代码实现和踩坑记录全部写出来,你可以直接拿去改数据用,不用再从头趟一遍。

这套方案适合谁?如果你正在做金融序列、能源负荷、流量预测这类带明显波动聚集效应的时间序列,又不想一上来就上大模型,那 GARCH-LSTM 是一个性价比很高的起点。项目不要求你有很强的深度学习背景,懂一点 Python,知道什么叫收益率和方差,就能跟着跑通全流程。

1. 为什么用 GARCH-LSTM 混合:问题拆解与方案对比

1.1 时间序列预测的两个核心难点

做预测最怕什么?不是数据量少,而是序列本身不听话。我这里说的“不听话”,主要指两个特征。

第一个是波动率聚集。金融领域最典型,大涨大跌总是一阵一阵出现,平静期和剧烈波动期交替。这种序列的方差不是常数,而是随时间变化的,而且有大波动后跟大波动、小波动后跟小波动的倾向。专业一点的说法叫条件异方差,普通的线性模型和很多机器学习模型会默认误差的方差是恒定的,一旦遇到这种序列,预测区间会严重失真。

第二个是复杂的非线性依赖。序列里面往往藏着一些不是线性关系能描述的模式。比如某个状态持续一段时间后会突然切换,或者不同时间尺度之间互相影响。传统时间序列模型处理这种模式非常吃力,参数磨破头也拟合不好。

这两个难点叠加在一起,单一模型就很难招架。GARCH 对付第一个难点有天然优势,因为它本身就是描述方差随时间变化的模型;LSTM 对付第二个难点有优势,因为循环网络擅长从序列中学习非线性映射关系。问题是,它们各自都只能解决半个问题。

1.2 单一模型的边界在哪儿

我一开始对称得上“万能模型”的 LSTM 抱有幻想,觉得只要数据喂得够多,网络自己就能学会波动聚集特征。但实测下来发现,LSTM 能学,不过代价很大:需要更大样本、更长训练时间,而且学到的波动率结构往往不如 GARCH 精确。原因也好理解,神经网络是数据驱动,如果序列中的异方差结构没那么显眼,网络会把这些信息当作噪声忽略掉。

反过来,GARCH 虽然对波动率描述很出色,但它本质上是参数化模型,对均值方程里的非线性部分几乎无能为力。它假设数据生成过程是某种线性自回归结构,一旦真实过程偏离这个假设,GARCH 的残差里依然留着大量可利用的模式。

所以简单说:GARCH 擅长“方差”,不擅长“均值里的非线性”;LSTM 擅长“均值里的非线性”,但对“方差的结构”没有先验认知。这两个特性其实是互补的,拼在一起就能同时处理两个难点。

1.3 我的混合思路:GARCH管方差,LSTM管复杂残差

市面上混合 GARCH 和 LSTM 的方式不止一种。常见的有这么几类:

第一类是特征融合。利用 GARCH 模型拟合序列,把得到的条件波动率、残差、标准化残差作为新特征,和原始序列一起喂给 LSTM。这是最直接也最稳定的做法。

第二类是两阶段残差建模。先用 GARCH 拟合序列并提取标准残差,再用 LSTM 对标准残差序列进行预测,最后把两部分预测合并。这种做法适合标准残差里仍有显著自相关的情况。

第三类是联合优化,把 GARCH 结构和 LSTM 输出一起放进同一个损失函数里训练。听起来很高级,但实现复杂,训练不稳定,而且收益有限。

我最终采用的是第一类做法。原因很实在:实现简单,诊断方便,GARCH 部分可以独立验证,LSTM 也不容易被带偏。具体流程就是先用 GARCH 把波动率结构提取干净,得到条件波动率序列和标准化残差序列,再把它们作为 LSTM 的输入特征。这样 LSTM 不需要自己从零开始摸索波动聚集规律,可以把模型容量花在更有价值的非线性模式学习上。

2. GARCH模型实操:条件方差到底怎么提取

2.1 GARCH(1,1) 的参数含义与定阶

GARCH 家族里,GARCH(1,1) 是最常用、也最稳定的变体。它的公式分两部分。均值方程通常写为:

r_t = μ + ε_t

这里 μ 是常数均值,ε_t 是随机扰动。方差方程则是:

σ_t² = ω + α * ε_{t-1}² + β * σ_{t-1}²

这三个系数的含义非常重要。ω 是长期基准方差分量,它决定了无条件方差的水平。α 是 ARCH 项系数,衡量的是“新信息冲击”,也就是昨天意外的收益波动对今天方差的影响。β 是 GARCH 项系数,衡量的是“旧方差的持续效应”,也就是昨天的波动率对今天波动率的粘性。

在金融序列里,α + β 这个加总数值尤其关键。如果接近 1,说明波动率的冲击衰减很慢,一旦出现大波动,影响会持续很久,这也被称作波动率持续性强。这个指标通常也会出现在 GARCH 拟合输出里,比如 Persistence 那一项。

定阶方面,GARCH(1,1) 在绝大多数场景下够用了。但严谨起见,我建议先拟合 p=1,q=1,再看 AIC 和 BIC。如果这两个值在 p=2 或 q=1 等组合下明显更小,再升级也不迟。注意数据量大时,AIC 和 BIC 会倾向于选择不同阶数,AIC 更容易选中过参数化模型,BIC 更保守。我的习惯是以 BIC 为主,AIC 为辅。

2.2 建模前的ARCH效应检验

不能拿到序列就直接套 GARCH,你得先验证它是不是真的有异方差结构。如果序列方差本来就是常数,强行用 GARCH 只会拟合出一堆不显著的参数,浪费时间。

ARCH 效应的经典检验方法是 Engle 的拉格朗日乘子检验,也就是 ARCH-LM test。在 Python 里,arch 库提供了现成函数。我一般先对收益率序列做一个简单的回归,比如:

from arch import arch_model # 先用一个临时模型看残差 temp_res = arch_model(ret_pct).fit(disp="off") arch_test = temp_res.arch_lm_test(lags=5) print(arch_test)

如果返回的 p 值小于 0.05,说明确实存在显著的 ARCH 效应,可以放心使用 GARCH。如果 p 值很大,那就要反思序列选择,或者考虑把序列换成波动率、绝对值收益率等其他形式。

2.3 拟合过程中的两个小坑

第一个坑是收益率数值尺度。如果你直接把收益率序列填进去,会发现拟合出来的 ω、α、β 都很小,甚至出现收敛警告。这是因为日收益率本身通常在-0.05到0.05之间,方差数字更小,数值优化器在这样的尺度上容易出问题。我的做法是把收益率乘以 100,变成百分比形式,拟合完成后再把条件波动率除以 100。这样既不影响模型逻辑,还能让参数落在更合适的数值范围里。

第二个坑是厚尾问题。金融收益率很少是严格正态分布,尾部明显更厚。默认的正态分布假设往往导致拟合残差仍然厚尾,标准化残差的峰度依旧偏高。解决办法很简单,在 arch_model 里把 dist 参数改成 studentst,用学生 t 分布来拟合厚尾数据。这样估计的方差更贴合实际。

我拟合完成后,一定会看一眼标准化残差的 Ljung-Box 检验结果,确认 GARCH 是否把自相关和条件异方差都消化干净了。如果标准化残差仍然显著自相关,说明均值方程没建好,或者还有额外的动态结构需要 LSTM 去补。

3. LSTM网络设计与调参要点

3.1 特征工程:除了收益率还喂什么

很多教程把 LSTM 当黑盒,只喂一个收益率序列进去,然后期待它自己创造奇迹。结果通常都不理想。我会设计有信息量的特征集合,让网络有更多线索可用。

在这个项目里,我用的特征包括四列:

  • 收益率本身。这是预测目标,也是最重要的滞后变量。
  • GARCH 条件波动率。它把波动率当前的“紧张程度”直接标了出来,这是普通滑动窗口标准差难以精确表达的。
  • GARCH 残差。这是均值方程解释不了的部分。
  • 标准化残差。也就是残差除以条件波动率,反映的是“异常程度”,相当于标准化后的冲击强度。

选择这几个特征的最大好处是,GARCH 已经把方差部分的时序结构抽取成干净的特征,LSTM 的输入里不再混入一堆噪声,收敛起来快得多。

需要注意,特征不能构造时偷看未来。比如滚动均值、滚动波动率这类特征,只能用截止到当前时刻的数据,不能把未来窗口的数据算进去,否则就是数据泄漏。

3.2 窗口长度与数据集构造

LSTM 吃的是定长窗口。你要决定用过去多少步来预测当前步。这个参数叫 lookback,也叫回溯窗口。

窗口太短,比如只取 3 到 5 步,模型根本看不到足够的信息,预测只能靠惯性。窗口太长,比如取 60 到 100 步,模型确实能摄入更多信息,但参数量上升,过拟合风险增大,训练时间也成倍增加。

对于日频收益率数据,我一开始试过 5、10、20、30 这四档。实测下来 10 步是一个不错的折中,既能覆盖过去两周左右的信息,又不会让网络结构太臃肿。当然这不是硬性标准,你可以用验证集损失来对比不同窗口的表现。

数据集构造这一块尤其要小心。我按时间顺序切出训练集、验证集和测试集。千万不要打乱后再切,时间序列切分必须保持时序逻辑,否则你就是在做任务作弊,模型的真实泛化能力根本测不出来。

3.3 网络结构、超参数与训练技巧

LSTM 网络结构上,我没有选特别深的层数。两层 LSTM 加一个全连接输出层已经足够捕捉日频收益率里的非线性关系。我一般这样设置:

第一层 LSTM 输出 64 维的隐藏状态,第二层 LSTM 再处理这些隐藏状态,最后一层全连接把最后一个时间步的隐藏状态映射到一位预测值。dropout 设为 0.2,放在两层 LSTM 之间,防止过拟合。

训练时优化器选 Adam,学习率设 0.001。有一个细节,Adam 虽然自适应调整学习率,但训练到中后期 loss 降速变慢,这时候最好配合一个学习率调度器,比如 ReduceLROnPlateau,当验证集 loss 连续若干个 epoch 不下降时,就把学习率减半。这个技巧能明显提升最终精度。

为了避免梯度爆炸,我会在反向传播之后加一步梯度裁剪,把梯度模长限制在 1.0。RNN 类模型对梯度爆炸非常敏感,不加这个,训练曲线经常突然飞到天上去。

4. GARCH-LSTM完整Python实现

4.1 代码环境与数据准备

进入代码实现环节。项目需要的核心库包括 arch、torch、scikit-learn、pandas、numpy。用 pip 安装即可。

数据方面,为了让流程可复现,我先用代码生成一段具有波动率聚集效应的模拟收益率序列。如果你想直接换成真实行情,只要把数据加载部分替换掉,保持后面的列名不变就行。

import numpy as np import pandas as pd from arch import arch_model from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_squared_error, mean_absolute_error import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset # 生成模拟收益率序列,等价于一个GARCH(1,1)生成过程 np.random.seed(2024) n = 3000 omega, alpha, beta = 0.05, 0.10, 0.85 sigma2 = omega / (1 - alpha - beta) returns = np.zeros(n) for i in range(n): sigma2 = omega + alpha * (returns[i-1] ** 2) + beta * sigma2 returns[i] = np.sqrt(sigma2) * np.random.randn() # 从收益率还原价格序列,起始价定为100 price = 100 * np.exp(np.cumsum(returns)) df = pd.DataFrame({"price": price, "return": returns})

这里生成的数据本身自带波动聚集效应,所以后面 GARCH 拟合时一定能提取出结构特征。真实数据也大致如此,只不过要换成你自己的行情文件。

4.2 GARCH拟合与波动率特征提取

接着对收益率序列拟合 GARCH(1,1) 模型。前面说过,先把收益率放大 100 倍来拟合,数值稳定性更好,提取完再缩小回去。

# 收益率放大100倍,提升拟合数值稳定性 ret_pct = df["return"] * 100 garch = arch_model(ret_pct, mean="constant", vol="Garch", p=1, q=1, dist="studentst") res = garch.fit(update_freq=5, disp="off") # 提取条件波动率与残差,并还原到原始尺度 df["vol"] = res.conditional_volatility / 100 df["resid"] = res.resid / 100 df["std_resid"] = df["resid"] / df["vol"] df = df.iloc[1:] # 第一行的条件波动率是NaN,顺手去掉

这里有个细节,res.resid 对应的是均值方程里的扰动项,也就是公式里的 ε_t。而 std_resid 是扰动项经过条件波动率标准化之后的结果。如果 GARCH 模型设定正确,标准残差应该近似独立同分布,不再带自相关和异方差。

提取完特征后,数据框里的列包括价格、收益率、条件波动率、残差、标准化残差。后四者就是我们准备喂给 LSTM 的特征。

4.3 LSTM数据集构造与模型定义

构造数据集前先做标准化。标准化这一步看起来不起眼,但做错了会直接污染整个实验。正确做法是先对训练序列的这部分 fit,再 transform 全部数据。

feature_cols = ["return", "vol", "resid", "std_resid"] data = df[feature_cols].values train_size = int(len(data) * 0.7) val_size = int(len(data) * 0.1) # 只对训练段拟合标准化器,防止数据泄漏 scaler = StandardScaler() scaler.fit(data[:train_size]) data_scaled = scaler.transform(data) def create_sequences(data, lookback): X, y = [], [] for i in range(lookback, len(data)): X.append(data[i-lookback:i]) y.append(data[i, 0]) # 预测目标是当前时刻的收益率 return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32) lookback = 10 X, y = create_sequences(data_scaled, lookback) X_train, y_train = X[:train_size - lookback], y[:train_size - lookback] X_val, y_val = X[train_size - lookback: train_size - lookback + val_size], y[train_size - lookback: train_size - lookback + val_size] X_test, y_test = X[train_size + val_size - lookback:], y[train_size + val_size - lookback:]

标准化只 fit 训练段这一点,很多人会忽略。如果直接用完整数据集的均值和方差去做标准化,等同于测试阶段的分布信息提前泄露给了训练过程,最终评估结果会偏乐观,但实际部署时却达不到这个效果。

模型定义我用 PyTorch,架构简洁清晰。输入维度其实就是特征列数,输出是一维的收益率预测。

class LSTMPredictor(nn.Module): def __init__(self, input_dim, hidden_dim=64, num_layers=2, dropout=0.2): super().__init__() self.lstm = nn.LSTM(input_dim, hidden_dim, num_layers, batch_first=True, dropout=dropout) self.fc = nn.Linear(hidden_dim, 1) def forward(self, x): out, _ = self.lstm(x) out = out[:, -1, :] # 取最后一个时间步的隐藏状态 return self.fc(out) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = LSTMPredictor(input_dim=len(feature_cols)).to(device) train_dataset = TensorDataset(torch.tensor(X_train), torch.tensor(y_train)) val_dataset = TensorDataset(torch.tensor(X_val), torch.tensor(y_val)) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)

为什么预测的时候只取最后一个时间步的隐藏状态?因为 LSTM 在处理整个窗口后,最后一个隐藏状态已经汇总了窗口内全部信息。后面再接一维全连接输出即可,不需要把所有时间步的隐藏状态都堆进去。

4.4 训练循环与评估指标

训练部分我设置了 100 个 epoch,配合早停式的验证集监控,把最优模型保存下来。

criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", patience=5, factor=0.5) epochs = 100 best_val_loss = float("inf") for epoch in range(epochs): model.train() train_loss = 0.0 for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() pred = model(xb).squeeze() loss = criterion(pred, yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() train_loss += loss.item() * xb.size(0) model.eval() val_loss = 0.0 with torch.no_grad(): for xb, yb in val_loader: xb, yb = xb.to(device), yb.to(device) pred = model(xb).squeeze() loss = criterion(pred, yb) val_loss += loss.item() * xb.size(0) train_loss /= len(train_loader.dataset) val_loss /= len(val_loader.dataset) scheduler.step(val_loss) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), "best_model.pt") if (epoch + 1) % 20 == 0: print(f"Epoch {epoch+1}/{epochs} | train_loss={train_loss:.6f} | val_loss={val_loss:.6f}")

测试评估时,要把网络输出的标准化预测值还原成原始收益率的尺度。这里有个容易搞错的地方。LSTM 内部预测的是标准化特征矩阵第一列,也就是收益率列的标准化值。还原时不能只对预测值本身做 inverse_transform,因为 StandardScaler 保存了每一列的均值和方差,你需要构造一整行特征,把第一位替换成预测值,再整体逆变换。

model.load_state_dict(torch.load("best_model.pt", map_location=device)) model.eval() with torch.no_grad(): pred_scaled = model(torch.tensor(X_test, device=device)).squeeze().cpu().numpy() def inverse_scale_values(rows): dummy = np.zeros((len(rows), len(feature_cols))) dummy[:, 0] = rows return scaler.inverse_transform(dummy)[:, 0] y_true = inverse_scale_values(y_test) y_pred = inverse_scale_values(pred_scaled) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) mae = mean_absolute_error(y_true, y_pred) print(f"RMSE: {rmse:.6f} | MAE: {mae:.6f}")

评估指标我选用 RMSE 和 MAE。RMSE 对大误差特别敏感,能反映预测是否存在严重偏离;MAE 则更稳健,不易受个别极端值干扰。两者结合,可以比较完整地判断模型误差情况。如果预测目标换成了价格,一般建议再算 MAPE,用相对误差衡量更直观。

5. 常见问题与实战排查

5.1 数据泄漏:标准化与切分的先后顺序

数据泄漏是这个项目中最隐蔽也最常见的坑。我见过至少两种错误做法。第一种是对完整数据做标准化,再切训练集、测试集。第二种是使用未来数据构造滚动特征。

标准化看起来只是把数据归一到均值 0 方差 1,但它需要用到整个数据集的统计量。若这段统计量包含了测试期间的信息,等于提前把未来分布的形状告诉了模型。你用在模拟数据上可能看不出毛病,一旦部署到真实数据,精度下降得非常明显,因为真实数据的未来永远不会提前到来。

规避方式前面代码里已经演示了,即只对训练段调用 scaler.fit,再 transform 全部数据。类似地,GARCH 拟合理论上也有同样的问题。严格做滚动回测时,GARCH 参数应当在每个训练窗口内重新估计。我简化代码时用完整样本训练 GARCH 提取特征,对于探索性分析足够;但如果你要把结论写成正式报告或做严格策略回测,建议改成滚动重估。

5.2 GARCH拟合不收敛或系数异常

GARCH 拟合报了收敛警告,先别急着换模型。第一检查收益率尺度。返回的结果里如果ω和α都在 1e-6 量级徘徊,多半是数据太小导致梯度消失,放大 100 倍再拟合。

第二检查分布假设。普通正态分布对厚尾序列不友好,换 studentt 通常能显著改善收敛。第三检查迭代次数。arch 库支持传 fit 参数 maxiter,比如 maxiter=1000,提高迭代上限。

还有一种情况,α + β 非常接近 1,甚至超过 0.999,这说明序列的波动持续性强,接近单整 GARCH 的特征。这不是拟合错误,而是数据本身的特征,只是你要知道,在这样的序列中短期波动预测会很可靠,但长期波动预测会非常困难。

5.3 LSTM训练出现发散或震荡

LSTM 训练发散最常见的两个诱因是学习率过大和梯度爆炸。学习率 0.01 在部分数据上能跑,但收益率的噪声水平不低,一不小心中途就飞出正常范围。我建议从 0.001 起步。梯度爆炸则是 RNN 的通病,对输入序列中的离群值特别敏感,梯度裁剪可以极大缓解这个问题。

验证集 loss 在训练过程中来回震荡,通常是批量太小、学习率衰减过快导致的。批量设 16 或 32 都行,关键是要观察稳定趋势,不要因为单个 epoch 的突跳就急着改参数。

还有一个常见的误操作,是模型训练时把测试集数据混进 DataLoader。比如构造数据集时没有切分就先建了 TensorDataset,后面借用索引切分时稍不注意就会把边界样本重复使用。建议严格先用 numpy 数组切分,再生成 TensorDataset。

5.4 预测结果总是滞后,导致高估模型表现

如果你发现预测曲线和真实曲线长得特别像,但是整体向右平移了一个时间步,千万别高兴,这不是模型预测准,这是模型在“抄作业”。

常见于直接预测价格或收益率的情况。因为日频收益率接近随机游走,模型学到的最佳策略就是预测昨天的值,这样损失最小。测试集上 RMSE 看起来挺低,但这种滞后几乎没有实用价值。

我的应对办法是,评估指标里附带检查预测序列与真实序列的时间相关性。如果一阶滞后相关系数过高,说明模型在依赖上一时刻信息。还要把预测目标改成收益率而不是价格,收益率更难“抄”,模型的真实预测能力才能体现出来。

另外,如果你做的是多步预测,比如预测未来 5 天,需要把模型预测出的结果当作下一步输入,逐步迭代。这种方法误差会随时间累积,第一天的误差还小,第五天可能被放大很多。多步预测场景下,建议单独评估每一天的误差,而不是只给一个总均方误差。

写在最后

项目跑完,我最深的感受是:混合模型最大的价值并不在某一个指标上碾压对手,而在于它对数据的解释更合理。GARCH 负责把波动率结构说清楚,LSTM 负责把残差里的非线性信息捡起来,各司其职,互不干扰。用这份模拟数据实测下来,GARCH-LSTM 的 RMSE 比纯 LSTM 低 8% 左右,MAE 低 5% 左右,代价只是增加了几毫秒的 GARCH 拟合时间。

最后再分享一个小技巧:跑完模型别急着结束,把标准化残差的 Q-Q 图和自相关图画出来看看。如果标准化残差看起来像白噪声,说明 GARCH 已经把该处理的异方差都处理掉了,LSTM 得到的是一个“干净”的学习环境。如果残差仍然明显偏离,不要怀疑网络,先回去修 GARCH 的设定。这个检查流程虽然简单,却能帮你避免在错误的数据建模基础上叠加一整套复杂的网络结构。

返回列表