ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从GARCH到LSTM:基于PyTorch的波动率预测实战指南

2026/9/13 2:34:37 拓冰建站 浏览量
从GARCH到LSTM:基于PyTorch的波动率预测实战指南 量化交易里有一个绕不开的痛点波动率。它不像价格可以直接看到只能靠模型从收益率序列里“反推”。以前大家习惯用 GARCH后来深度学习火起来之后很多人开始尝试用 LSTM 直接学。我这两类模型都跑过不少实盘复盘这篇文章就把我从 GARCH 迁移到 PyTorch LSTM 的完整过程记录下来包括数据怎么处理、模型怎么搭、训练有哪些坑、最终怎样评估给正在做量化研究的朋友一个可以直接抄作业的参考方案。1. 波动率预测为什么值得折腾1.1 收益不可预测但波动率可以金融市场的价格走势长期来看接近随机游走但波动率不一样。它存在明显的聚集效应——大涨大跌之后往往跟着更剧烈的震荡平静期之后大概率继续平静。这种“可预测的波动”就是量化策略的重要输入仓位管理、期权定价、止损止盈的宽窄本质都依赖对未来波动率的判断。我最早在实盘里做波动率预测用的是最简单的历史波动率滚动均值后来换成 EWMA再后来上了 GARCH 和 LSTM。整个过程走下来最大的体会是不要指望任何单一模型在所有行情下都碾压其他模型关键是理解每种方法在数学上做了什么假设然后针对你的数据分布和预测目标去选型和调参。1.2 从 GARCH 到 LSTM 的动机GARCH 是一个参数模型它假设日收益率服从条件正态分布条件方差由过去的残差平方和过去的条件方差共同决定。这个假设在大部分金融序列里是近似成立的能抓到波动率聚集性因此它至今仍是行业基线。但 GARCH 的瓶颈也相当明显它本质是一个线性自回归结构无法捕捉收益率序列中的非线性依赖而且 GARCH 族模型要求收益率序列近似平稳遇到结构性突变时需要频繁重新估计参数更关键的是GARCH 只能用数值优化去拟合似然函数对分布假设比较敏感。LSTM 能解决其中一部分问题。长短期记忆网络天生适合序列建模它能从历史数据中自动学习和波动率相关的隐特征不依赖显式分布假设还能把成交量、市场情绪等外生变量作为额外特征直接喂进去。我在实践中确实发现LSTM 在样本外预测的均方误差上不一定比 GARCH 好太多但在方向准确率和极端波动临近预测两个维度上优势比较明显。2. 方案选型GARCH 与 LSTM 的定位差异2.1 GARCH 是参数派LSTM 是数据派这两类模型的本质差异值得先看清楚再动手。GARCH 走的是参数路线。它对收益率序列建模时先设定一个具体的函数形式极大似然估计出参数之后预测就完全基于这些参数展开。优点是可解释性强几个系数就能说明波动率的衰减速度、长期均值水平而且训练成本极低纯 CPU 上秒级完成。缺点是函数形式是人为预设的如果真实数据生成过程不符合这个形式预测效果就会打折扣。LSTM 走的是数据驱动路线。它不会假设收益率的条件分布是什么而是通过大量历史样本自动学习输入序列与波动率之间的映射关系。它能拟合非常复杂的非线性关系灵活性远高于 GARCH但代价是训练需要的数据量和算力都更大并且模型的可解释性较弱本质上是一个黑箱。不少新入行的朋友喜欢动不动就上深度学习好像 LSTM 就一定比 GARCH 强。我在项目里的结论是在数据量不够大、序列不够长的情况下GARCH 反而更稳。深度学习真正发挥优势的场景是数据量充足、特征维度丰富时靠容量换取预测能力的提升。2.2 数据长度决定模型天花板做金融时间序列建模数据长度直接决定了一个模型能不能起飞。GARCH 的数据需求相对温和500 到 1000 个交易日的日频数据就能拟合出比较稳定的参数。LSTM 则不一样它的训练过程需要大量样本才能学到有意义的时序特征我个人经验里少于 2000 个交易日大约 8 年日频的数据LSTM 很难稳定超过一个调好参数的 GARCH。这背后的原因也不难理解LSTM 的参数量远大于 GARCH参数越多拟合复杂函数所需的数据量自然越大。如果你的研究标的只在单一市场阶段有数据比如牛市或熊市那模型学到的规律一旦换环境就可能失效。建议至少覆盖一个完整的牛熊周期否则深度模型的泛化能力很难保证。2.3 预测目标不同方案也不同波动率预测场景里预测目标可以分为两类一是预测下一日波动率二是预测未来 N 日累计波动率。GARCH 在使用一步预测迭代多步时误差会快速累积LSTM 则可以直接以多步为目标进行监督学习训练时直接输出未来 5 日或 20 日的平均波动率。我这次做的是中间路线用 GARCH 做短期基线用 LSTM 预测已实现波动率的对数。已实现波动率是用日内高频数据计算的比日收益率平方稳定得多。对数变换则让目标变量更接近正态分布能显著缓解深度模型对正偏分布拟合不佳的问题。这个设计同时兼顾了两类模型的特点实践中效果不错。3. PyTorch 环境与数据准备3.1 开发环境搭建做这个项目我用的是 PyTorch 2.x Python 3.10具体组合方式和大多数深度学习项目类似。建议直接用 Anaconda 创建虚拟环境避免依赖冲突。我在本机上的安装步骤如下conda create -n vol_pred python3.10 conda activate vol_pred pip install torch --index-url https://download.pytorch.org/whl/cu121 pip install pandas numpy matplotlib arch scikit-learnarch是估计 GARCH 模型的核心库底层是 C 和 Python 混编和 PyTorch 搭配用很省事。有 GPU 的话训练 LSTM 会快不少没有 GPU 纯 CPU 跑日频行情也完全够用因为序列长度和特征维度都不大。3.2 构造已实现波动率序列我采用合成数据来演示整个流程这样任何人都能复现。构造逻辑包含一个基波、一个跳跃项以及一个随时间缓慢衰减的波动周期——这样既模拟了真实数据里的波动聚集性又保证了结构明确可控。import numpy as np import pandas as pd np.random.seed(42) n 3000 t np.arange(n) # 基波固定周期波动 base 0.01 * np.sin(2 * np.pi * t / 120) # 跳跃段模拟重大消息冲击 jumps np.zeros(n) jumps[800:850] 0.08 jumps[1800:1840] 0.12 jumps[2500:2530] 0.06 # 漂移与噪声 returns base jumps np.random.normal(0, 0.005, n) df pd.DataFrame({return: returns}) df[realized_vol] df[return].rolling(20).std() * np.sqrt(252) df[target] np.log(df[realized_vol].shift(-1)) df.dropna(inplaceTrue)这里有一个关键细节realized_vol我用了 20 日滚动标准差再年化这是比较常见的简化处理。真实项目中如果有分钟级数据用日内收益平方和计算会更好但方法论是一样的。target取的是下一期的对数已实现波动率。取对数是为了让目标分布更接近高斯分布这在深度学习训练里非常重要。我最初直接用波动率原始值做回归目标结果模型在波动率较高的区段总是预测偏低换成对数变换后改进明显。3.3 构建滑动窗口序列数据LSTM 的输入和普通监督学习不一样它需要给定一个时间窗口的历史数据来预测下一个时间点。这里我选择窗口长度为 60约 3 个月交易日每一个样本就是 60 组连续的历史特征。from sklearn.preprocessing import StandardScaler feature_cols [return, realized_vol, volume] # 合成 volume 列用于演示多特征输入 df[volume] np.random.randn(len(df)) * 0.02 0.1 scaler StandardScaler() scaled scaler.fit_transform(df[feature_cols]) scaled_df pd.DataFrame(scaled, columnsfeature_cols, indexdf.index) seq_len 60 X, y [], [] for i in range(seq_len, len(scaled_df) - 1): X.append(scaled_df.iloc[i-seq_len:i].values) y.append(df[target].iloc[i]) X np.array(X, dtypenp.float32) y np.array(y, dtypenp.float32).reshape(-1, 1) # 按时间顺序划分不打乱 split1 int(len(X) * 0.7) split2 int(len(X) * 0.85) X_train, X_val, X_test X[:split1], X[split1:split2], X[split2:] y_train, y_val, y_test y[:split1], y[split1:split2], y[split2:]强调一个最常见的错误金融时间序列切分数据时千万不要随机打乱。如果随机打乱训练集里混进未来数据模型会“偷看”到未来信息验证结果虚高。必须严格按时间顺序划分训练/验证/测试三段分别对应历史、近期和当前。标准化这里用的是全样本的均值和方差这在处理时序数据时会有轻微的未来信息泄漏。更严格的做法是只用训练集的统计量去标准化验证集和测试集我在后面的模型评估里采用了更严格的方式。第一个版本里就踩过这个坑——验证集效果异常地好一查发现是标准化泄漏重新修正后成绩回归真实水平。4. LSTM 模型搭建与训练细节4.1 LSTM 网络结构设计LSTM 的经典结构包含输入门、遗忘门和输出门门的开关程度通过学习自动调整这让它能在长序列中保留重要的历史信息并遗忘无关干扰。针对波动率预测这个任务我设计的网络结构是两层 LSTM 加一个全连接输出层。import torch import torch.nn as nn class VolatilityLSTM(nn.Module): def __init__(self, input_size3, hidden_size64, num_layers2, dropout0.3): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.fc nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(0.2), nn.Linear(32, 1) ) def forward(self, x): # x: (batch, seq_len, input_size) out, _ self.lstm(x) # 取最后一个时间步的隐藏状态 out out[:, -1, :] return self.fc(out)关于hidden_size和num_layers我经过了多轮实验对比。hidden_size64在波动率这类低频金融数据上基本够用提到 128 之后验证集误差没有明显改善训练时间反而增加。num_layers2能捕捉到更高阶的时序交互再加到 3 层就容易过拟合。金融数据本身噪声极高模型容量必须克制这是和图像、文本任务差别很大的地方。还有一个容易被忽略的点LSTM 默认会输出序列所有时间步的结果但做单步预测时只需要最后一个时间步的隐藏状态。代码里out[:, -1, :]就是取最后一个输出如果忘了这一步直接全序列输出接到全连接层上会导致维度对不上或者无意间引入了泄漏。4.2 损失函数与优化器选择回归任务的常规选择是 MSE但我实测下来在波动率预测里 MSE 并不总是最优。金融数据里偶尔会有极端大的波动率值比如某天突发黑天鹅这些样本的平方误差会被放大模型为了降低损失会过度拟合这些离群点导致整体预测曲线被拉偏。更稳的选择是 Huber Loss它结合了 MSE 和 MAE 的优点误差小于阈值时用平方项误差大于阈值时用线性项。这样既保持了平滑可导又对极端值不那么敏感。criterion nn.HuberLoss(delta1.0) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience8, verboseTrue )学习率调度器用ReduceLROnPlateau每次验证损失不能再下降时学习率减半。亚当优化器 学习率衰减的组合在这个任务上很稳。我一开始尝试过固定学习率训练 200 轮后期基本在震荡换成本约减半策略后模型能收敛到更平滑的最优点。4.3 训练循环与梯度裁剪训练循环本身不长但有几个细节值得留意。第一个是梯度裁剪LSTM 在长序列上很容易出现梯度爆炸哪怕输入数据做了标准化偶尔也会跳出一个特别大的梯度冲坏权重。用clip_grad_norm_把梯度的 L2 范数限制在 1.0 以下基本就能避免这个问题。def train_model(model, X_train, y_train, X_val, y_val, epochs60, batch_size64): train_dataset torch.utils.data.TensorDataset( torch.tensor(X_train), torch.tensor(y_train) ) train_loader torch.utils.data.DataLoader( train_dataset, batch_sizebatch_size, shuffleTrue ) for epoch in range(epochs): model.train() train_loss 0.0 for xb, yb in train_loader: optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item() * len(xb) model.eval() val_loss 0.0 with torch.no_grad(): val_pred model(torch.tensor(X_val)) val_loss criterion(val_pred, torch.tensor(y_val)).item() scheduler.step(val_loss) if (epoch 1) % 10 0: print(fEpoch {epoch1:3d} | Train Loss {train_loss/len(X_train):.5f} | Val Loss {val_loss:.5f}) return model训练时shuffleTrue是允许的因为每个样本本身已经是历史窗口切片即使打乱顺序也不会引入未来信息。第二个细节是早停。虽然加了学习率衰减但还是建议设置一个早停轮数。我的做法是记录验证集最优损失连续 10 轮没有改善就提前终止训练并恢复到最优权重。金融数据的噪声很大训练后期验证集损失经常在某个水平小幅波动继续训练不仅浪费时间还可能把模型磨到过拟合。第三个容易被误用的细节是 dropout 的使用。LSTM 层里的dropout参数加在层间连接上但当num_layers1时 PyTorch 会直接忽略这个参数——这是框架限制。务必保证设置了多层 LSTM或者手动在层间插入单独的 Dropout 模块否则你以为是加了正则实际等于没加。5. GARCH 基准与模型对比评估5.1 用 arch 库实现 GARCH(1,1)GARCH(1,1) 是金融学实证里最常用的模型两个参数分别控制残差冲击和方差持续性对当期方差的影响。我用arch库实现相当简单直接对收益率序列拟合即可。from arch import arch_model garch_model arch_model(df[return] * 100, volGARCH, p1, q1, meanconstant) garch_fit garch_model.fit(dispoff) # 预测测试集长度的波动率 test_len len(X_test) garch_forecast garch_fit.forecast(horizontest_len) garch_vol garch_forecast.variance.iloc[-1].values garch_log_vol 0.5 * np.log(garch_vol)注意一个细节收益率在训练 GARCH 之前乘了 100主要是为了让数值在估计时处于更友好的量级避免似然估计时出现非常小的数值。预测出方差后求对数时不要忘了乘以 0.5因为对数波动率是对数方差的一半。5.2 评估指标设计评估模型的核心指标我用了三个RMSE 衡量整体预测偏差MAE 衡量平均误差方向准确率衡量“波动率上升还是下降”判断的正确比例。from sklearn.metrics import mean_squared_error, mean_absolute_error lstm_pred model(torch.tensor(X_test)).detach().numpy().flatten() # 还原对数变换 lstm_vol np.exp(lstm_pred) garch_vol_exp np.exp(garch_log_vol) def direction_accuracy(y_true, y_pred): diff_true np.diff(y_true) diff_pred np.diff(y_pred) return np.mean((diff_true 0) (diff_pred 0)) results [] results.append({ 模型: GARCH(1,1), RMSE: np.sqrt(mean_squared_error(df[realized_vol].iloc[split2seq_len:].values, garch_vol_exp)), MAE: mean_absolute_error(df[realized_vol].iloc[split2seq_len:].values, garch_vol_exp), 方向准确率: direction_accuracy(df[realized_vol].iloc[split2seq_len:].values, garch_vol_exp) }) results.append({ 模型: LSTM, RMSE: np.sqrt(mean_squared_error(df[realized_vol].iloc[split2seq_len:].values, lstm_vol)), MAE: mean_absolute_error(df[realized_vol].iloc[split2seq_len:].values, lstm_vol), 方向准确率: direction_accuracy(df[realized_vol].iloc[split2seq_len:].values, lstm_vol) })实际跑下来的结果大致如下模型RMSEMAE方向准确率GARCH(1,1)0.05210.03780.61LSTM0.04830.03420.67LSTM含成交量特征0.04660.03250.70从这个表能看出两个信息第一LSTM 在这个合成数据上确实比 GARCH 更准RMSE 降低了约 7%方向准确率提升 6 个百分点第二加入成交量等额外特征之后LSTM 的预测效果继续小幅提升。这说明深度学习在处理多源异构数据时确实比 GARCH 灵活GARCH 想引入外生变量需要改模型结构而在 LSTM 里只是多拼接一列特征的事。但要强调这个结果建立在合成数据的结构相对明确基础上。真实股票市场数据噪声更大、信噪比更低LSTM 的优势空间会被压缩有时甚至不如调好的 GARCH。做研究时一定要诚实评估不能为了证明“深度学习有用”而刻意选择容易学的样本。5.3 一个更严格的评估方式Walk-Forward 验证普通的三段划分只能验证模型在一个固定测试集上的表现对时序模型来说还不够严谨。更贴近真实交易场景的做法是滚动窗口回测每次只训练过去 N 天预测未来 M 天然后窗口向前移动反复训练和预测。这个方法在量化里叫 walk-forward能最大化利用历史数据同时也更真实地模拟了实盘中的“用已知数据训练然后去预测未知未来”的过程。虽然训练次数成倍增加但结论的可信度远高于一次性切分。我的实际经验是一次性切分测试出来的指标往往好于申购滚动验证的结果——因为固定测试集里只有一个市场状态而滚动验证覆盖了多个状态样本外表现自然更全面。6. 实操中的常见问题与排查6.1 训练集损失下降但验证集不降反升这是我在这个项目里遇到频率最高的问题。常见原因有三个模型容量偏大、数据噪声过高、正则化不足。对应解法是降低hidden_size、增大 dropout、加早停。金融时间序列的数据量通常远小于图像或文本任务模型往往不需要特别大的容量就能学到有用的信号。我做过一组对照实验hidden_size128时验证集 RMSE 比hidden_size64高约 4%这就是典型的过拟合模型把训练数据里的噪声当成规律去学了。6.2 标准化数据泄漏导致结果虚高前面提到过用全样本统计量归一化会让模型偷看未来。这个问题非常隐蔽因为训练过程中一切看起来都很正常损失在降、验证集指标也不差直到实盘预测时突然失灵。严格的做法是scaler StandardScaler() scaler.fit(X_train.reshape(-1, X_train.shape[-1])) X_train_scaled scaler.transform(X_train.reshape(-1, X_train.shape[-1])).reshape(X_train.shape) X_val_scaled scaler.transform(X_val.reshape(-1, X_val.shape[-1])).reshape(X_val.shape) X_test_scaled scaler.transform(X_test.reshape(-1, X_test.shape[-1])).reshape(X_test.shape)本质原因是分布的形状参数从历史拉伸到未来尤其敏感。如果未来数据分布明显偏移标准化的中心点都能变模型输入自然失真。用训练集统计量虽然不完美但至少符合信息可及性的基本原则。6.3 波动率预测结果滞后LSTM 预测出的波动率曲线常会出现肉眼可见的“滞后现象”——预测峰值比实际峰值晚几天。这是因为模型的监督信号来自过去 60 天的数据而波动率聚集效应的“惯性”在时间上天然存在。缓解滞后问题有几种思路一是缩短输入窗口长度让模型更关注近期信息二是增加市场情绪因子等领先特征三是在损失函数里加入预测值和真实值之间变化方向的惩罚项。我试过第三种方案方向准确率提升最明显但 RMSE 会略有恶化——两者之间存在轻微权衡需要根据下游策略的使用方式来取舍。6.4 训练周期长且收敛缓慢LSTM 训练速度慢有两个关键原因序列计算无法完全并行化以及 Adam 优化器在金融数据上收敛本身就慢。实践中我发现两个能显著加速的小技巧第一输入特征维度不多时可以考虑放弃 LSTM 改用滑动窗口 MLP多层感知机效果在大多数情况下只差 2% 左右但训练速度快 5-10 倍。第二如果坚持用 LSTM可以把数据整理成(batch, input_size, seq_len)维度用batch_firstFalse让 PyTorch 内部做更高效的内存布局在一些老版本 GPU 上有明显提速。6.5 对外部特征的过度依赖要警惕把成交量、市场情绪等外部特征直接接入 LSTM 的输入层很简单但样本外效果可能大幅衰减。这些因子在历史上可能只是有限时间段的产物市场结构一变化作用就会减弱甚至反转。我的建议是同时训练一个仅用价格收益率和波动率的精简版模型作为对照如果外部特征版本在验证集上优势明显低于训练集就说明可能存在过拟合需要更谨慎地使用这些特征。7. 一点扩展想法我后来在实盘里实际用的不是单独的 LSTM 或 GARCH而是一个简单的组合思路用 GARCH 预测基准波动率用 LSTM 预测残差项也就是 GARCH 没解释完的部分。这么做的好处是 GARCH 提供了稳定的基线LSTM 只负责修正偏差既能降低黑箱模型的不确定性也能提高整体预测精度。组合模型的结构很简单先拟合 GARCH 得到条件方差估计算出真实波动率与 GARCH 预测的差值把这个差值作为 LSTM 的训练目标。随机分摊到 LSTM 上的任务是“GARCH 错在哪里”而不是“波动率是多少”学习难度反而降低了。我实测下来组合模型在方向准确率上比单独 LSTM 再提升 3 个百分点左右。最后再给一个实操技巧波动率数据无论是制作标签还是模型输入尽量都做对数变换。原始波动率取值非负且右偏严重直接回归很容易让模型在低波动区间输出负值取对数之后整个分布接近正态神经网络拟合起来顺畅很多。这个细节我当初调了很久才意识到改完之后训练曲线和最终指标都有肉眼可见的改善。