首页 / 资讯中心 / 文章详情

GARCH与LSTM混合建模:波动率预测的修正因子法及Python实现

GARCH与LSTM混合建模:波动率预测的修正因子法及Python实现 ★ FEATURED ARTICLE
做金融时间序列预测的人最先接触的往往是GARCH族模型——它能把波动率聚集、厚尾这些金融数据里的“老熟人”用一组概率公式讲明白。等深度学习流行之后LSTM又成为序列预测的宠儿似乎什么序列扔进去都能学出一段模式来。但真把GARCH和LSTM放到同一个项目里你会发现大多数论文写法很轻巧自己复现时却总卡在数据对齐、特征泄漏、结果不稳定这些地方。这篇文章把我自己在研究中验证过的一条路线完整拆开讲先用GARCH(1,1)把波动率的“惯性骨架”搭出来再用LSTM去修正GARCH预测与真实波动率代理之间的系统性偏差最后合成混合预测结果。整套流程附可运行的Python代码适合正在做金融时间序列、量化研究、毕业论文或者想搞懂混合模型细节的人参考。1. 为什么要把GARCH和LSTM绑在一起两条模型的边界与互补点1.1 GARCH的强项与明显不舒服的地方GARCH模型的核心假设是条件方差随时间变化且当前方差受过去残差平方和过去方差的共同影响。以最常用的GARCH(1,1)为例它把收益率序列拆成均值方程和方差方程两个部分方差方程可以写成[ \sigma_t^2 \omega \alpha \varepsilon_{t-1}^2 \beta \sigma_{t-1}^2 ]这个公式最大的价值是抓住了金融收益率的两个典型特征波动率聚集大波动后面往往跟着大波动和均值回归波动率不会无限发散。实际估计之后你会得到一个非常平滑的条件波动率序列它像一个“带惯性的天气趋势”告诉你目前处于高波动还是低波动状态。但GARCH的不舒服也很明显。它对均值方程的设定通常很简单常数均值或者低阶AR就够用这导致它几乎不处理序列里的非线性均值结构。更麻烦的是GARCH的方差方程是一个显式的参数化形式当真实波动过程里存在结构突变、星期效应、极端事件后的非线性恢复时这个参数化曲线往往跟不上实际节奏。1.2 LSTM的强项与同样明显的短板LSTM的优势是它可以不预设函数形式直接从历史窗口里学到任意非线性映射。给它一组收益率、成交量、宏观因子它就能尝试预测下一期的目标值。所以很多人在GARCH拟合之后自然地想到让LSTM来学习残差或修正项这种直觉是对的。但LSTM的短板在金融数据上特别致命它没有概率结构的约束对噪声极其敏感。日收益率信噪比很低直接拿原始收益率当输入、拿收益率当输出训练LSTM很容易学到一些只在训练集里成立的偶然模式。而且LSTM对波动率的尺度变化不敏感——金融数据里波动率可以从低位翻五倍如果输入特征没有经过合适的缩放或者没有将波动率状态显式提供给网络LSTM会把大波动段和小波动段的模式混在一起学。1.3 主流的混合范式与本文选型把GARCH和LSTM混合文献里大致有三类范式。第一类是特征注入型用GARCH估计出条件方差、标准化残差把这些作为LSTM的额外特征由LSTM直接预测收益率或波动率。这条路实现简单但GARCH的作用容易被淹没在神经网络的大量参数里。第二类是残差修正型先用GARCH拟合收益率把GARCH无法解释的残差序列交给LSTM继续建模最终预测是GARCH预测加上LSTM的残差预测。逻辑清晰但残差里往往只有噪声LSTM很容易过拟合。第三类是本文采用的修正因子型GARCH负责输出下一期条件波动率作为“基准预测”LSTM学习的是“真实波动率代理值与GARCH基准预测之间的比值”。也就是说LSTM不直接预测波动率而是预测一个围绕1左右的修正因子。这样GARCH的统计骨架被完整保留LSTM只负责修正GARCH的系统性偏差两个模型各自做最擅长的事。我选修正因子型的另一个原因是工程上更好控制。修正因子的数值范围天然稳定不会像波动率本身那样有时是0.5有时是5LSTM训练起来收敛更快输出也能用简单的上下界裁剪来约束。2. 研究框架与数据口径从收益率序列到波动率代理2.1 为什么先用模拟数据验证流程做这类混合模型我强烈建议第一步先用带GARCH效应的模拟数据把整条管道跑通再上真实数据。原因很简单模拟数据的真实波动过程是已知的你能清晰判断模型哪里出错。如果你的GARCH-LSTM流程在已知由GARCH生成的数据上都跑不出合理的预测效果那拿到真实数据上只会更糟。我生成模拟数据的思路是设定一组GARCH(1,1)参数生成一段带波动率聚集的日收益率序列。这样后续GARCH估计时理论上能大概还原出真实参数整个框架的验证逻辑是闭环的。2.2 收益率口径与真实波动率代理先统一口径。这里使用对数收益率日常计算就是价格序列取对数后差分。为了数值稳定我习惯把收益率乘以100转成“百分数收益率”。这样GARCH参数估计时不容易遇到数值收敛问题LSTM训练时梯度也更稳定。真实波动率本身不可观测需要一个代理变量。最朴素也最实用的代理是滚动标准差对过去5根K线的收益率求标准差作为当前已实现波动率的估计。虽然真实波动率可以用5分钟高频数据算得更准但对于框架验证来说日频滚动标准差完全够用。关键是必须用过去5天的数据不能混入未来信息。2.3 GARCH(1,1)的参数估计与一步预测下面的代码生成模拟收益率并用arch库拟合GARCH(1,1)然后手工构造一步预测波动率序列。import numpy as np import pandas as pd from arch import arch_model # 固定随机种子保证流程可复现 np.random.seed(42) # 生成带GARCH效应的模拟日收益率序列 n 3000 omega, alpha, beta, mu 0.00002, 0.1, 0.85, 0.0002 burn 500 z np.random.randn(n burn) eps np.zeros_like(z) sigma2 np.zeros_like(z) sigma2[0] omega / (1 - alpha - beta) for t in range(1, len(z)): sigma2[t] omega alpha * eps[t-1]**2 beta * sigma2[t-1] eps[t] np.sqrt(sigma2[t]) * z[t] ret pd.Series( mu eps, indexpd.date_range(2018-01-01, periodsn burn, freqB) ).iloc[burn:] # 转换成百分数收益率GARCH估计更稳定 ret ret * 100 # 拟合GARCH(1,1)均值方程用常数 am arch_model(ret, meanConstant, volGARCH, p1, q1, distnormal) res am.fit(dispoff) print(res.summary()) # 提取残差和条件波动率 resid_arr res.resid.values cond_vol res.conditional_volatility # 手工构造一步预测波动率序列 omega_hat res.params[omega] alpha_hat res.params[alpha[1]] beta_hat res.params[beta[1]] sigma2_hat np.zeros_like(ret) sigma2_hat[0] omega_hat / (1 - alpha_hat - beta_hat) for t in range(len(ret) - 1): sigma2_hat[t 1] omega_hat alpha_hat * resid_arr[t]**2 beta_hat * sigma2_hat[t] vol_forecast pd.Series(np.sqrt(sigma2_hat), indexret.index)这段代码里有一个关键细节arch库返回的conditional_volatility是当前期的条件波动率而我手工构造的vol_forecast序列在位置t的值对应的是对t期波动率的一步预测。为什么不用res.forecast一步到位因为在训练LSTM时我们需要为每一个时间点都准备“基于截至t的信息所得到的t1期波动率预测”循环构造更透明也方便检查对齐。2.4 样本切分的时序问题时间序列项目里最常见的错误之一是在划分训练集和测试集时打乱数据。我们这个场景极其依赖时间顺序一旦打乱LSTM会学到“偷看未来”的模式验证集上表现很好实盘或者滚动预测时一塌糊涂。统一的处理方式是按时间比例切分。比如总样本3000根70%训练、15%验证、15%测试。这样测试集永远是最后一段时间模拟的是“用历史预测未来”的真实情境。3. LSTM修正块与混合预测合成代码级拆解3.1 特征窗口怎么构造才不“穿越”构造LSTM样本的核心原则是特征窗口里的每一个值在预测时刻都必须是已知信息。我们预测的是t1期的修正因子特征窗口就取t及t之前的若干个时刻包括收益率、条件波动率、标准化残差、滚动标准差所有特征都严格截止到t。标准化残差是残差除以当期条件波动率相当于把GARCH模型已经解释掉的尺度因素去除留下相对纯净的“冲击”信号。把它放进特征窗口是想让LSTM感知当前冲击的大小级别而不被绝对收益率大小误导。下面这段代码负责组装样本from sklearn.preprocessing import StandardScaler # 特征列 FEATURE_COLS [ret, cond_vol, std_resid, rv] # 滚动标准差作为真实波动率代理 rv_series ret.rolling(5).std() frame pd.DataFrame({ ret: ret, cond_vol: cond_vol, std_resid: resid_arr / cond_vol, rv: rv_series, }) history 16 X_list, y_list, t_list [], [], [] # 遍历所有可训练时间点特征截至t预测t1 for t in range(history, len(ret) - 1): # 特征窗口t-history到t x frame.iloc[t - history : t 1][FEATURE_COLS].values # 目标t1期真实波动率代理 / t1期GARCH一步预测 y frame[rv].iloc[t 1] / vol_forecast.iloc[t 1] X_list.append(x) y_list.append(y) t_list.append(t) X np.array(X_list) # 形状: (样本数, history1, 特征数) y np.array(y_list) # 按时序切分 n_samples len(X) tr_end int(n_samples * 0.7) va_end int(n_samples * 0.85) indices np.arange(n_samples) tr_idx, va_idx, te_idx ( indices[:tr_end], indices[tr_end:va_end], indices[va_end:], )这里样本数是len(ret)-history-1比原始序列略少几百个对训练完全够用。需要注意frame[rv]前几个值因为滚动窗口没满5根会出现NaN但history起步已经是16所以不会踩中NaN区。3.2 为什么目标是修正因子而不是波动率本身如果LSTM直接预测t1期的滚动标准差它要同时学会绝对波动率水平和波动率变化模式两件事学习压力很大。修正因子的目标则简单得多[ \text{ratio}{t1} \frac{\text{RV}{t1}}{\hat{\sigma}_{t1}} ]GARCH已经把大部分波动率水平预测出来了ratio通常围绕1波动只在GARCH系统性偏低的时段明显大于1在系统性偏高的时段明显小于1。LSTM只需要抓住“GARCH什么时候会偏”这个修正信号。这相当于在考试里告诉学生题干让他只判断哪些答案要加几分而不是把整张卷子重新做一遍。对预测结果我还会做裁剪# 防止LSTM输出极端值导致混合预测爆炸 ratio_pred np.clip(ratio_pred, 0.2, 5.0)0.2和5.0是经验性上下界。在模拟数据和多数真实日收益率数据上GARCH一步预测很少偏离真实波动率5倍以上这个裁剪范围足够宽松不会干扰正常预测。3.3 训练与预测的完整代码训练前必须做特征标准化。我统一用StandardScaler量纲差异太大时LSTM收敛速度和效果都会变差。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam from sklearn.metrics import mean_squared_error, mean_absolute_error # 标准化特征 def reshape_feats(arr): return arr.reshape(-1, arr.shape[-1]) feat_scaler StandardScaler() X_tr feat_scaler.fit_transform(reshape_feats(X[tr_idx])).reshape(X[tr_idx].shape) X_va feat_scaler.transform(reshape_feats(X[va_idx])).reshape(X[va_idx].shape) X_te feat_scaler.transform(reshape_feats(X[te_idx])).reshape(X[te_idx].shape) # 标准化修正因子目标 ratio_scaler StandardScaler() y_tr ratio_scaler.fit_transform(y[tr_idx].reshape(-1, 1)).flatten() y_va ratio_scaler.transform(y[va_idx].reshape(-1, 1)).flatten() y_te ratio_scaler.transform(y[te_idx].reshape(-1, 1)).flatten() # 构建LSTM模型 model Sequential() model.add(LSTM(64, return_sequencesTrue, input_shape(history 1, len(FEATURE_COLS)))) model.add(Dropout(0.2)) model.add(LSTM(32, return_sequencesFalse)) model.add(Dropout(0.2)) model.add(Dense(16, activationrelu)) model.add(Dense(1)) model.compile(optimizerAdam(learning_rate0.001), losshuber, metrics[mae]) model.summary() history_cb model.fit( X_tr, y_tr, validation_data(X_va, y_va), epochs40, batch_size64, verbose1, )损失函数我选的是Huber loss而不是纯MSE。原因是修正因子序列里偶尔会冒出离群点MSE会给离群点极高权重导致模型为了少数极端值牺牲大多数样本的拟合。Huber loss在误差较小时表现如MSE误差较大时表现为MAE对离群值稳健很多。预测和混合合成# 测试集预测修正因子 ratio_pred_std model.predict(X_te, verbose0).flatten() ratio_pred ratio_scaler.inverse_transform(ratio_pred_std.reshape(-1, 1)).flatten() ratio_pred np.clip(ratio_pred, 0.2, 5.0) # 测试集对应的时间位置 te_t np.array(t_list)[te_idx] # GARCH基准预测: 直接使用vol_forecast[t1] garch_vol vol_forecast.iloc[te_t 1].values # 混合模型最终预测 hybrid_vol garch_vol * ratio_pred # 真实目标 true_vol rv_series.iloc[te_t 1].values这里te_t1的位置就是测试集每条样本的实际预测目标时间点。想要真正看懂混合预测怎么来的必须自己动手把te_t、te_t1和vol_forecast的索引关系对一遍任何错位都会直接变成评估指标上的灾难。3.4 为什么还要设置一个LSTM-only对照组很多人在混合模型里只和纯GARCH对比这会高估混合模型的价值。因为LSTM本身就带非线性拟合能力你还需要知道“把GARCH特征拿掉LSTM单独预测真实波动率”表现如何。LSTM-only的构造很简单特征只用收益率和滚动标准差目标直接是t1期的滚动标准差网络结构保持一致相当于换了一个输入输出口径。这样对比出来的差距才是GARCH修正信息真正贡献的部分。# LSTM-only模型: 特征只保留ret和rv FEATURE_COLS_ONLY [ret, rv] Xo_list, yo_list, to_list [], [], [] for t in range(history, len(ret) - 1): xo frame.iloc[t - history : t 1][FEATURE_COLS_ONLY].values yo rv_series.iloc[t 1] Xo_list.append(xo) yo_list.append(yo) to_list.append(t) Xo np.array(Xo_list) yo np.array(yo_list) feat_scaler_o StandardScaler() Xo_tr feat_scaler_o.fit_transform(reshape_feats(Xo[tr_idx])).reshape(Xo[tr_idx].shape) Xo_va feat_scaler_o.transform(reshape_feats(Xo[va_idx])).reshape(Xo[va_idx].shape) Xo_te feat_scaler_o.transform(reshape_feats(Xo[te_idx])).reshape(Xo[te_idx].shape) target_scaler_o StandardScaler() yo_tr target_scaler_o.fit_transform(yo[tr_idx].reshape(-1, 1)).flatten() yo_va target_scaler_o.transform(yo[va_idx].reshape(-1, 1)).flatten() yo_te target_scaler_o.transform(yo[te_idx].reshape(-1, 1)).flatten() model_direct Sequential() model_direct.add(LSTM(64, return_sequencesTrue, input_shape(history 1, len(FEATURE_COLS_ONLY)))) model_direct.add(Dropout(0.2)) model_direct.add(LSTM(32, return_sequencesFalse)) model_direct.add(Dropout(0.2)) model_direct.add(Dense(16, activationrelu)) model_direct.add(Dense(1)) model_direct.compile(optimizerAdam(learning_rate0.001), losshuber, metrics[mae]) model_direct.fit(Xo_tr, yo_tr, validation_data(Xo_va, yo_va), epochs40, batch_size64, verbose1) direct_pred_std model_direct.predict(Xo_te, verbose0).flatten() direct_vol target_scaler_o.inverse_transform(direct_pred_std.reshape(-1, 1)).flatten()4. 对比实验设计与结果检查混合模型是否真的更准4.1 基准设置我建议至少准备三个基准。第一个是纯GARCH基准也就是直接用vol_forecast[t1]作为预测。第二个是LSTM-only基准也就是上一节里直接预测真实波动率的模型。第三个可以加一个简单移动平均基准用最近20天的历史波动率作为未来预测虽然简陋但能作为保底参考。三个基准对应三种不同逻辑纯GARCH代表统计模型的上限LSTM-only代表纯深度学习路线历史均值代表无模型路线。混合模型只有在超过这三个基准时才有实际使用价值。4.2 评估指标RMSE/MAE之外还应看什么波动率预测最常用的指标是RMSE和MAE。RMSE对预测误差的大值更敏感MAE则更贴近日常平均偏差。具体评估代码很简单def evaluate(name, pred, true): rmse np.sqrt(mean_squared_error(true, pred)) mae mean_absolute_error(true, pred) print(f{name:20s} RMSE{rmse:.4f} MAE{mae:.4f}) return rmse, mae evaluate(GARCH, garch_vol, true_vol) evaluate(LSTM-only, direct_vol, true_vol) evaluate(Hybrid, hybrid_vol, true_vol)但对波动率预测RMSE和MAE有个共同问题它们对所有量级的误差一视同仁。金融里低波动时误差0.1和高波动时误差0.1的意义完全不同。更严格的学术研究里还可以看QLIKE损失它更贴近波动率预测的实际损失结构。实操中如果不追求发表论文RMSE和MAE已经能说明问题。4.3 一个典型结果怎么解读在模拟数据上固定随机种子跑完一次比较常见的走势是混合模型RMSE比纯GARCH低5%到10%比LSTM-only低1%到4%。GARCH基准往往在平稳波动段表现极好但在波动快速切换的区间出现系统性滞后LSTM-only则在平稳段容易把噪声学进去波动不大预测却乱跳混合模型继承了GARCH的稳定性同时通过修正因子把GARCH滞后的部分补回来一部分。需要特别提醒的是不同TensorFlow版本和不同随机种子下LSTM部分的绝对结果会漂移。你复现时可能看到混合模型比纯GARCH低2%也可能低12%但相对趋势通常是稳定的。所以看结果不要盯着某个精确数值而是看三个模型测试集预测曲线是否呈现合理的层级关系。4.4 这套方法在什么情况下会失效没有任何混合模型是万能的。我在实际测试中观察到修正因子型的GARCH-LSTM至少在下面几种情况下效果会明显变差。一种是波动率存在突变结构的时候。比如某段时间因为政策变化导致波动率中枢一次性跳升GARCH的参数化形式需要一段时间才能把方差方程拉到新水平而LSTM的历史窗口里也没有类似突变可学修正因子也无能为力。另一种是样本量不足的时候。LSTM至少需要几千条样本才能稳定收敛如果你的序列只有几百条混合模型大概率不如纯GARCH。还有一种是特征窗口选得不对。窗口太短LSTM看不到波动率聚集的完整节奏窗口太长又会把很久以前的模式强行套到当前引入噪声。我默认用16相当于约三周交易日的日频信息金融上比较合理但具体问题应该自己调。5. 从复现到上手六个容易踩的坑和处理经验5.1 标准化泄漏是最隐蔽的bug最常见也最致命的坑是用全量数据的均值和标准差去做标准化然后再切分训练集与测试集。这个操作会把测试集的分布信息泄露给训练过程导致验证时效果虚高。正确做法是标准化器只在训练集上fit在验证集和测试集上只transform。我在上面的代码里已经严格按这个顺序处理但自己改代码时很容易顺手对全量数据先标准化务必反复检查。5.2 随机种子和可复现性LSTM初始化权重是随机的同一个笔记本跑两次结果可能完全不一样。做研究要想复现至少设置三层随机种子Python随机数、NumPy随机数、TensorFlow全局随机数。在TensorFlow 2.x中一般这样处理import random random.seed(42) np.random.seed(42) tf.random.set_seed(42)即使这样在GPU上运行由于并行计算的非确定性结果仍可能微小浮动。写论文时最好固定CPU跑关键实验并且多跑几个种子取平均而不是单次结果定结论。5.3 时间序列千万不要随机打乱如果是图像分类打乱数据是标准操作。时间序列预测里一旦打乱训练时模型会把“未来信息”当成常规特征测试集上自然分数漂亮但一上真实数据就崩。Keras的model.fit默认不打乱数据顺序除非你手动设置shuffleTrue训练时保持False即可。批量训练时批次之间顺序也会影响最终模型这也是为什么多种子取平均很重要。5.4 GARCH均值方程的选择GARCH的均值方程我用了常数这是对日收益率最省事的选择。如果想更精细可以用AR(1)甚至ARMA结构但每加一个均值参数就多一层估计复杂度。一个实操经验是如果发现GARCH标准化残差里还存在明显的自相关再去考虑均值方程升级如果残差已经接近白噪声就不要折腾均值方程把精力留给LSTM修正块。5.5 修正因子失控怎么办我见过不少初学的代码里LSTM预测出的修正因子出现几十甚至上百的极端值混合预测直接飞上天。裁剪是一个兜底措施但只能掩盖问题。真正应该看的是修正因子在训练集上的分布如果它本身就在0.2到5之外出现很多点说明GARCH基准和真实波动率代理的匹配有问题需要先回头检查vol_forecast的构造或者在哪个位置发生了错位。5.6 理解每个超参数的物理意义再调参把LSTM隐层单元数、dropout比例、学习率当成彩票号码去随机搜索不是好的做法。初学时可以先想清楚每个参数在干什么窗口长度是波动率聚集的尺度的体现隐层单元数决定模型记忆复杂度dropout控制过拟合风险学习率决定收敛速度。我在实验里用64和32两个LSTM层是常见起步配置dropout取0.2是中等水平epochs取40是因为模拟数据上验证集损失大约在30轮以后开始平缓。真实数据上这些都要重调但调参前先理解收敛曲线总比对着网格搜索列表盲试有效。最后分享一个个人体会做GARCH-LSTM混合模型真正的难点往往不在模型结构而在数据对齐。cond_vol、vol_forecast、rv_series、ratio这四组序列哪个索引错了一位结果都会看起来合理但不正确。每跑一个实验先打印出预测点的真实时间戳和预测时间戳确认它们相差一步再去看评估指标。很多人跑出漂亮数字却无法落地基本都栽在这种看不见的对齐问题上。想继续深入可以在修正因子之外尝试同时让LSTM输出波动率方向和波动率幅度两个头或者在回归目标上叠加一个分类任务判断“是否发生波动率突变”这套框架都能自然扩展。
阅读完成 · 觉得有帮助?
咨询建站