☰
ARIMA+LSTM组合预测上证指数:Python实战与避坑指南
2026/9/28 5:35:24 网站建设 项目流程

简介:基于ARIMA与LSTM的上证指数预测MATLAB代码包,面向金融时序分析与量化入门用户,适合本科及以上学习者用于课程设计、算法验证或科研扩展。方案针对新版MATLAB中ARIMA报错做了适配,保留LSTM对时间序列非线性特征的建模优势,代码注释完整,附带000001.csv、沪深300等指数数据,替换数据即可迁移到其他指数预测场景。压缩包共19个文件,含10个m脚本、5个csv行情数据、2个mat变量、1个xls表格及1个asv备份文件,整体仅3.23MB,便于快速调试。提供MSE、RMSE、MBE、MAE和R²等误差评估模块,可一键输出预测精度指标;同时给出ARIMA与LSTM训练、预测和对比流程,方便扩展更多金融时间序列实验。目前已有198人学习下载。

1. 把 ARIMA+LSTM 组合真正用在上证指数预测上,难点不在模型

沪深指数的日线收盘价看起来有趋势、有波动,但单独拿 ARIMA 或者 LSTM 去预测,结果通常都很难看:ARIMA 对非线性特征基本无能为力,LSTM 虽然能捕捉局部形态,却容易把噪声当成规律。把两者拼起来,让 ARIMA 负责剥离线性趋势和周期,让 LSTM 吃 ARIMA 剩下的残差,是这类预测项目里最多见也最稳妥的做法。这篇笔记按“数据齐全”的项目形态来讲:一份沪深指数日线数据、一条能从头跑通的 Python 链路,先跑 ARIMA 部分,再挂 LSTM,最后给出组合预测主流程和几个绕不开的坑。

2. 先跑通 ARIMA:把沪深指数里的线性结构剥出来

ARIMA 三个字母分别对应自回归、差分、移动平均,它最适合处理的是一类带有明显趋势和短期自相关的序列。上证指数收盘价就属于这种:今天和昨天的价格高度相关,长期看又有漂移。问题在于,这种序列通常不是平稳的,直接建模会让统计量失真。所以落地时的第一步不是急着拟合,而是先做平稳性检验,再决定差分阶数,最后才谈 p 和 q 的取值。

2.1 数据清单与 ADF 检验:先确认这份数据能不能用

拿到一份沪深指数数据集,先把列名和顺序核对清楚。最常见的格式是 date、open、high、low、close、volume,按日期升序排列。数据齐全不代表可以直接用,常见麻烦有两个:日期乱序,以及除权除息后产生的缺失值。建议读进来之后先排序,再确认缺失情况。

import pandas as pd from statsmodels.tsa.stattools import adfuller df = pd.read_csv( "data/index.csv", parse_dates=["date"], index_col="date" ).sort_index() close = df["close"].dropna() print("缺失值数量:", df.isnull().sum().sum()) print(close.head()) adf_result = adfuller(close, autolag="AIC") print("ADF p值:", adf_result[1])

逻辑说明:先按日期排序,避免乱序数据在差分时引入虚假的跳跃;dropna()只丢掉缺失的价格点,不做填充,因为沪深指数的复权缺口如果靠前向填充去补,等于人为造出连续平台。adfuller的autolag="AIC"让函数自动选择滞后阶数,返回结果的第二个元素就是 p 值。p 值大于 0.05 说明序列存在单位根,需要差分;小于 0.05 则可以直接进入定阶环节。

参数说明:金融日线序列一般 d=1,也就是做一阶差分。沪深指数的对数价格通常是一阶单整,几乎不需要二阶差分。如果你在 ADF 检验时发现一阶差分后 p 值仍然不显著,先检查数据是否混入了停牌日、是否把非交易日当作 0 填充,而不是急着加大差分阶数。

2.2 定阶与拟合:用 AIC 把 p、q 选出来,然后取残差

statsmodels 的 ARIMA 接口要求传入(p, d, q)。d 已经确定为 1,p 和 q 用遍历的方式选。范围控制在 0 到 5 就够了,再大的阶数在日线数据上几乎没有业务意义,只会让模型多记几根 K 线的噪声。

from statsmodels.tsa.arima.model import ARIMA best_aic = float("inf") best_order = None for p in range(6): for q in range(6): try: model = ARIMA(close_train, order=(p, 1, q)).fit() if model.aic < best_aic: best_aic = model.aic best_order = (p, 1, q) except Exception: continue print("最优order:", best_order, "AIC:", best_aic) final_model = ARIMA(close_train, order=best_order).fit() fitted_values = final_model.fittedvalues resid = (close_train - fitted_values).dropna()

逻辑说明:AIC 在衡量拟合优度的同时惩罚参数数量,所以它不会一味追求复杂模型。fittedvalues是模型用训练段历史数据逐步预测得到的样本内拟合值,它和close_train在索引上是一一对应的。残差用close_train减fitted_values得到,这一步产生的残差序列就是下一步 LSTM 的输入。

参数说明:这里的close_train只取时序的前 85% 左右,最后一段留作测试。p、q 的范围从 0 到 5,加上 d=1,组合数量只有 36 个,遍历时间可以接受。如果你用的 statsmodels 是 0.12 之前的旧版本,接口在statsmodels.tsa.arima_model,新版本统一走statsmodels.tsa.arima.model,两者不能混用。

残差提取这里有一个经常被忽略的细节:fittedvalues在序列起始几个点可能是 NaN,因为差分后没有足够的前置数据。直接减会造成索引错位,所以dropna()必须放在减完之后。等残差序列干净了,再确认它的长度和close_train对齐,这一步我在后面避坑章节会重点展开。

3. LSTM 吃残差:python 里的时间序列预处理与最小模型

ARIMA 留下来的残差理论上应该是白噪声,但 AIC 定阶只能保证整体线性结构被剥离,不代表局部非线性模式不存在。LSTM 在这条链路里的角色就是再扫一遍残差,LSTM 做时间序列预测时,python 实现上最核心的两个动作是构造滑动窗口和监督信号的归一化处理。

3.1 归一化与滑动窗口:给 LSTM 造样本

LSTM 的输入形状是(样本数, 时间步, 特征数)。残差是单变量序列,所以特征数为 1。滑动窗口长度我一般取 20 个交易日,对应一个月的交易节奏。

import numpy as np from sklearn.preprocessing import MinMaxScaler resid_train = resid.values.reshape(-1, 1) scaler = MinMaxScaler(feature_range=(-1, 1)) resid_scaled = scaler.fit_transform(resid_train) def build_sequences(data, seq_len=20): X, y = [], [] for i in range(seq_len, len(data)): X.append(data[i - seq_len:i, 0]) y.append(data[i, 0]) return np.array(X), np.array(y) X_seq, y_seq = build_sequences(resid_scaled, seq_len=20) X_seq = X_seq.reshape(X_seq.shape[0], X_seq.shape[1], 1)

逻辑说明:循环从seq_len开始取样本,每个样本包含最近 20 个交易日的残差,目标变量是第 21 天的残差值。reshape把每一条样本变成(20, 1),对应“20 个时间步、1 个特征”。

参数说明:feature_range=(-1, 1)比(0, 1)更合适,因为残差有正有负,映射到对称区间能让 LSTM 的 tanh 激活函数更早进入有效梯度区。窗口长度 20 是把月内节奏和滞后效应折中之后的选择;窗口太短,模型只看得到一周的波动,窗口太长,沪深指数的噪声占比会拖慢收敛。训练时不建议打开 shuffle,时间序列一旦被打乱,LSTM 学到的就不再是顺序依赖,而是乱序中的伪规律。

3.2 PyTorch 最小实现:一个 LSTM 层一个全连接

模型结构不需要复杂。单层 LSTM 加一层全连接,隐藏单元 32,dropout 0.2,足够在残差这种低频信噪比序列上做拟合。网络层数加多之后,训练集的 loss 会降得更快,但验证集几乎必然反弹。

import torch import torch.nn as nn class ResidLSTM(nn.Module): def __init__(self, input_size=1, hidden_size=32, num_layers=1, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size, hidden_size, num_layers, batch_first=True, dropout=dropout ) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): out, _ = self.lstm(x) return self.fc(out[:, -1, :]) model = ResidLSTM() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) criterion = nn.MSELoss()

逻辑说明:out的形状是(batch, seq_len, hidden_size),取out[:, -1, :]表示拿最后一个时间步的隐藏状态作为整个窗口的压缩表达,再接全连接输出一个标量预测值。对照torch.nn.LSTM的源码看,这一步是最容易看明白的地方:LSTM 返回的out是所有时间步的隐藏状态序列,只有最后一个时间步包含了“看到第 20 天为止”的全部信息。

参数说明:hidden_size 32 对日线残差预测够用。调整顺序应该是先试 seq_len,再试 hidden_size,最后才动 num_layers。lr 0.001 是 Adam 的常见起点,残差序列本身值域已经在 -1 到 1 之间,不需要做学习率预热。

训练代码保持最简形式:

split_point = int(len(X_seq) * 0.9) X_train = X_seq[:split_point] y_train = y_seq[:split_point] X_val = X_seq[split_point:] y_val = y_seq[split_point:] X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.float32).unsqueeze(1) for epoch in range(50): model.train() optimizer.zero_grad() pred = model(X_train_t) loss = criterion(pred, y_train_t) loss.backward() optimizer.step() if epoch % 10 == 0: print(f"epoch {epoch}, loss {loss.item():.6f}")

逻辑说明:训练段从残差序列的最前面连续切出 90%,剩下 10% 作为验证段,不跨到原始数据集的测试段。unsqueeze(1)把目标变量从(batch,)变成(batch, 1),对齐模型输出的形状。50 个 epoch 是经验起点,沪深指数残差信噪比低,再多也不会有本质提升,反而容易记住个别极端行情。

验证段在训练循环里没有直接参与 loss 计算,它是用来判断过拟合的参照。如果验证 loss 连续十个 epoch 不降甚至上升,就把 dropout 调到 0.3,或者把隐藏单元降到 16,而不是去加训练轮数。

4. 组合预测主流程:把残差回填,串出可用的量化策略代码

ARIMA 和 LSTM 各自的训练代码跑通之后,剩下的是主流程组装。常见的错误是把两个模型的预测结果直接相加,但两者在时间点上往往没有对齐。ARIMA 预测的是未来一段连续价格,LSTM 预测的是残差的下一步,两者必须先确认对齐关系,再相加。

4.1 训练集、验证集、测试集怎么切

整个组合模型里的切分要分成两层。第一层是 ARIMA 的原始数据切分,我用前 85% 做训练,后 15% 做测试。第二层是 LSTM 的残差切分,在 ARIMA 的训练段内部再切 90/10。这样保证 LSTM 永远不会看到测试段的数据。

import pandas as pd import numpy as np df = pd.read_csv("data/index.csv", parse_dates=["date"], index_col="date") close = df["close"].dropna() split = int(len(close) * 0.85) train_close = close[:split] test_close = close[split:]

逻辑说明:这里刻意不使用随机切分。沪深指数预测是时间序列任务,随机打乱会让后续的滞后期特征泄漏到未来,回测指标立刻虚高。前 85% 和后 15% 的切分保持了时间连续性,也符合“用过去预测未来”的基本假设。

4.2 组合预测的滚动递推环节

ARIMA 部分先对测试段做一次性连续预测,LSTM 部分则需要每预测一个交易日就更新一次残差历史。之所以不能提前把所有残差算完,是因为测试段的真实残差只能随着时间推进逐步获得。

from statsmodels.tsa.arima.model import ARIMA arima_model = ARIMA(train_close, order=best_order).fit() fitted_train = arima_model.fittedvalues.dropna() resid = (train_close - fitted_train).dropna() lstm_model, scaler = train_lstm_on_resid(resid) arima_forecast_all = np.asarray( arima_model.forecast(steps=len(test_close)) ).reshape(-1) resid_history = list(resid) seq_len = 20 preds = [] for i in range(len(test_close)): last_resid = np.array(resid_history[-seq_len:]).reshape(1, seq_len, 1) scaled_resid = scaler.transform(last_resid) x_tensor = torch.tensor(scaled_resid, dtype=torch.float32) with torch.no_grad(): resid_pred = lstm_model(x_tensor).item() final_pred = arima_forecast_all[i] + resid_pred preds.append(final_pred) actual_resid = test_close.iloc[i] - arima_forecast_all[i] resid_history.append(actual_resid)

逻辑说明:arima_forecast_all是 ARIMA 从训练段结束位置出发对整段测试期的连续预测,它不会使用测试段真实价格,这是严格意义上的样本外预测。循环里的resid_history初始值是 ARIMA 训练段的残差列表,每预测完一天,就把当天真实价格减去 ARIMA 预测值得到的真实残差追加到历史里,这样下一轮 LSTM 才能看到最新的信息。

这里的代码有一个关键点:第 i 天的真实残差,计算用的是test_close.iloc[i] - arima_forecast_all[i],而不是用前一天的预测值。因为残差的历史必须反映真实发生过的预测误差,LSTM 才能在这个误差序列里找出规律。

坑在最后一步:preds目前是纯点预测,没有置信区间。如果直接把这一列当作买入信号,会把连续几天的预测误差累积当成趋势变化。下一节会专门讲验证手段,先把代码跑通再说。

5. 避坑与排查:沪深指数预测最容易翻车的 5 个点

这个组合看起来逻辑成立,真正跑起来翻车点集中在数据对齐、归一化、切分这三件事上。以下每一条都是实操中反复遇到过的现象。

5.1 ARIMA 残差和原始价格对不上

现象:LSTM 训练 loss 很低,但测试段预测曲线和真实价格错开一大截。原因:fittedvalues在序列开头有 NaN,直接拿close_train - fitted减完再传进 LSTM,等于让 LSTM 学习了一组错位的“残差”。解决:减完必须dropna(),并且打印残差的索引头部和尾部,确认它和训练段的价格索引对齐。

5.2 归一化把测试段统计量混进来

现象:测试集 MAE 很漂亮,一换到滚动回测框架里就崩。原因:在整段残差序列上先调MinMaxScaler.fit_transform,再把训练段切片。测试段的极大极小值已经被 scaler 记住,等于未来信息提前进入训练。解决:scaler.fit只作用在resid上,后续所有transform都只做转换,不再重新 fit。

5.3 ARIMA 残差不是白噪声,LSTM 吃的是垃圾

现象:LSTM 预测的残差序列仍然带有明显趋势,最终预测结果追着真实价格跑但始终慢半拍。原因:AIC 选出来的阶数只能让模型在训练段拟合好,不保证残差通过白噪声检验。解决:拟合后跑一次acorr_ljungbox,p 值小于 0.05 就直接提升 p 或 q。残差有线性结构残留时,LSTM 去学这些残留,等于重复造轮子。

from statsmodels.stats.diagnostic import acorr_ljungbox lb_pvalue = acorr_ljungbox(resid, lags=[10], return_df=True) print(lb_pvalue)

逻辑说明:Ljung-Box 检验判断的是残差在滞后 10 期以内是否还存在显著自相关。p 值大于 0.05,说明白噪声假设不能被拒绝,这时候把残差交给 LSTM 才是合理的。p 值显著时,回到定阶环节把 p 或 q 调大,重新提取残差。

5.4 时间序列被当成普通回归数据随机切分

现象:训练时 loss 抖动,验证集指标反而好过训练集。原因:数据被train_test_split或者 DataLoader 的 shuffle 打乱,相邻样本的时间依赖被破坏,模型的滞后记忆失效。解决:所有切分都按顺序进行,验证集从训练段尾部连续截取,DataLoader 设置shuffle=False。

5.5 预测曲线比真实走势晚一天

现象:预测形态和真实曲线很像,但整体往右平移,方向命中率只有 0.5 左右。原因:模型的输入是截至昨天收盘价的窗口,预测对象是今天收盘价,但今天开盘跳空会破坏这种静态关系。解决:把预测目标从“次日收盘价”改成“未来 5 日收益率”,或者把当日开盘价也拼进特征。滞后一天是金融序列预测里的系统性偏差,不是代码 bug,是目标定义的问题。

6. 验证预测效果的三个进阶手段:滚动回测、方向命中率与白噪声复盘

组合预测跑出第一版结果后,先别急着换策略,做三个验证动作。第一个动作是方向命中率,用预测值和真实值各自的涨跌方向比对,命中率稳定在 0.55 以上才算有价值,0.5 附近说明模型只是复制了昨天的方向。第二个动作是滚动回测,以 60 天为一个窗口重新训练,每 20 天往前滚动一次,把每一窗的预测拼接成一条完整的样本外预测曲线,这样能暴露数据泄漏问题。

第三个动作是残差白噪声复盘。把“最终预测价 - 真实价”重新当作一个新的残差序列,跑一次 Ljung-Box 检验,如果这份最终残差仍然有显著相关性,说明组合模型还没吃干净结构。下面是一个对比方向的参考表:

模型ARIMA 单独LSTM 单独ARIMA+LSTM
方向命中率0.480.510.57
日均绝对误差32.629.124.8

数字只是一个量级参考,真实值会随数据段变化,但观察方法不变:组合模型的提升主要来自方向命中率,而不是把绝对误差压到极小。

进阶玩法的方向是给 LSTM 输出加上分位数目标,训练时用 pinball loss 替代 MSE,输出的不再是单一预测价,而是 10% 到 90% 的置信区间。落在区间内的日子照常交易,区间之外就空仓,这比单纯预测收盘价更容易落到实盘规则里。我有一次在测试段反复调参调出了漂亮曲线,换到滚动回测才发现全是领先一期的假信号。从那以后,任何超参组合必须在滚动回测框架里跑完再定结论,这个习惯帮我避开了很多自我感动式的优化。希望帮到你。

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

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

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

立即咨询