☰
LSTM股票指数预测:从时间序列特征工程到防数据泄露的PyTorch实战
2026/10/9 5:47:03 网站建设 项目流程

简介:面向计算机相关专业学生及项目实战学习者的Python神经网络资源,基于LSTM模型对股票指数(如上证综指、道琼斯)进行预测,解决时间序列预测场景中的建模与代码实现问题。该资源为作者大三期末大作业,经导师指导并获99分高分,代码完整可运行,非常适合课程设计、期末大作业及毕业设计参考。压缩包共10个文件,包含3个Jupyter Notebook源码(分别针对上证综指、道琼斯等指数)、1个训练好的best_model.pth权重文件、2个Markdown说明文档以及配置文件等,整体仅1.65MB,结构清晰、轻量易用。已有163人学习下载。通过该资源可掌握LSTM在金融时序预测中的完整流程:数据预处理、模型构建、训练评估与预测,代码注释详细,稍作修改即可迁移至其他股票数据,是快速上手神经网络项目的实用范例。

1. LSTM预测股票指数:先别问“准不准”,问“数据怎么喂”

做股票指数预测的Python项目,十个人里有八个是先跑通一个LSTM模型,然后对着预测曲线感叹“真像那么回事”,最后实盘一用就翻车。问题往往不在LSTM本身,而在数据泄露和评价方式——你用当天的收盘价预测当天走势,或者用未来数据做过归一化,训练集的损失能降到0.01,样本外照样一塌糊涂。这个领域的入门门槛其实很低:会Python、装好PyTorch、拉一段指数历史行情,就能搭出一个可运行的LSTM预测项目;但要把“预测曲线”变成“有决策参考价值的输出”,需要把数据切分、特征构造、模型输入输出这几件事做对,否则你拿到的只是一个会画图的随机数发生器。

这篇笔记按照我实际做这个方向时的推进路径来写:从指数数据的获取与预处理、LSTM网络结构的PyTorch实现、训练参数配置,到样本外验证和滚动预测技巧。每一段都给可以直接复制的代码和参数说明,也会把容易踩的坑标出来。适合刚入门深度学习、想拿金融时序数据练手的新手,也适合做过分类或图像模型、但对时间序列预测不熟的从业者。

2. 指数数据与特征工程:把K线变成LSTM能吃的时间序列

2.1 选数据源与拉取上证指数日线数据

做股票指数预测首先要有干净、连续、够长的历史数据。A股指数里上证指数、沪深300、中证500都是常用标的,我一般用akshare拉数据——免费、国内直连稳定、接口相对简单,不需要注册Token。安装akshare之后一行代码就能拿日线。

import akshare as ak import pandas as pd # 拉取上证指数日线,前复权 df = ak.stock_zh_index_daily(symbol="sh000001") df.columns = ["date", "open", "close", "high", "low", "volume"] df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) print(df.head()) print(df.tail()) print(f"总行数: {len(df)}")

这段代码里stock_zh_index_daily返回的是未复权指数点位数据,指数本身没有拆股分红问题,所以不需要像个股那样纠结复权因子。日期列要转成datetime类型并且按升序排列——LSTM按时间步读取序列,顺序错了后面的窗口切分全部错位。拉下来先看head和tail,确认数据覆盖年限和有没有断档,我见过有人把2024年的数据排在2015年前面,模型照样训练完成,但结果毫无意义。数据量方面,上证指数从1990年至今有8000多根日线,对LSTM来说完全够用。

2.2 构造特征:收盘价之外还要加什么

裸的收盘价序列可以做预测,但效果通常很差,因为单变量序列里信息量太少。我一般会加上几个常见技术指标,比如5日与20日均线之差(反映短期趋势)、当日涨跌幅、成交量对数的滑动均值。选特征的原则是:只用t时刻及其之前的信息,绝不能用t+1及以后的数据。下面这段代码把所有特征构造逻辑放在shift和rolling里,确保每个时间步的特征都是历史可见的。

import numpy as np df_feat = df.copy() # 基础特征 df_feat["ret"] = df_feat["close"].pct_change() # 当日收益率 df_feat["log_vol"] = np.log(df_feat["volume"] + 1) # 成交量取对数 # 均线类特征 df_feat["ma5"] = df_feat["close"].rolling(5).mean() df_feat["ma20"] = df_feat["close"].rolling(20).mean() df_feat["ma_diff"] = df_feat["ma5"] - df_feat["ma20"] # 波动率特征:过去5日收益率的标准差 df_feat["volatility"] = df_feat["ret"].rolling(5).std() # 标签:未来5个交易日的累计收益率(预测目标) df_feat["label"] = ( df_feat["close"].shift(-5) / df_feat["close"] - 1.0 ) # 删除空值 df_feat = df_feat.replace([np.inf, -np.inf], np.nan).dropna().reset_index(drop=True) features = ["close", "ret", "log_vol", "ma_diff", "volatility"] print(df_feat[["date"] + features + ["label"]].tail())

这里的关键点是label用了shift(-5)——它把未来第5天的收盘价与当前收盘价做比值,减去1得到未来5日收益率。shift(-5)在构建标签时是合法的,因为训练时标签本来就来自未来,但特征列绝不能shift未来值。很多入门项目把预测目标设成“下一天收盘价”,直接回归价格本身,这会导致模型对绝对价格水平敏感,换一个指数或跨年份数据后效果剧烈波动。改成预测未来5日累计收益率,相当于让模型做“方向+幅度”的回归,数值范围稳定在-0.1到0.1之间,对LSTM的激活函数更友好。

注意dropna()删掉了开头20行缺失均线的数据和末尾5行缺失标签的数据,这是数据量缩减最常见的原因。如果发现序列变短了,不用慌,看下df_feat的行数和原数据的差值是否等于25左右。

2.3 窗口切分与归一化:时间序列不能随机打乱

LSTM的输入是(batch, seq_len, input_size)形状的三维张量。seq_len代表用过去多少个交易日预测未来,我一般取20,也就是用过去一个月的日线特征预测未来一周的方向。切分方式采用滑动窗口,窗口之间可以有重叠,这能显著增加训练样本数。归一化这里有个大坑:如果对整个数据集做fit_transform,相当于用了未来数据的均值和标准差,这属于数据泄露。正确做法是只用训练集的统计量去归一化训练集、验证集和测试集。

from sklearn.preprocessing import StandardScaler SEQ_LEN = 20 def create_sequences(data, seq_len): xs, ys = [], [] for i in range(len(data) - seq_len - 4): # 减去seq_len和label的5日步长 x = data.iloc[i : i + seq_len][features].values y = data.iloc[i + seq_len]["label"] xs.append(x) ys.append(y) return np.array(xs, dtype=np.float32), np.array(ys, dtype=np.float32) # 切分:前70%训练,后15%验证,最后15%测试(按时间顺序) train_size = int(len(df_feat) * 0.7) val_size = int(len(df_feat) * 0.15) train_df = df_feat.iloc[:train_size] val_df = df_feat.iloc[train_size : train_size + val_size] test_df = df_feat.iloc[train_size + val_size :] # 用训练集拟合scaler scaler = StandardScaler() scaler.fit(train_df[features]) train_scaled = pd.DataFrame( scaler.transform(train_df[features]), columns=features ) val_scaled = pd.DataFrame( scaler.transform(val_df[features]), columns=features ) test_scaled = pd.DataFrame( scaler.transform(test_df[features]), columns=features ) X_train, y_train = create_sequences(train_scaled, SEQ_LEN) X_val, y_val = create_sequences(val_scaled, SEQ_LEN) X_test, y_test = create_sequences(test_scaled, SEQ_LEN) print(f"X_train.shape: {X_train.shape}, y_train.shape: {y_train.shape}") print(f"X_val.shape: {X_val.shape}, y_test.shape: {y_test.shape}")

这段代码有两点必须说明。第一,切分必须按时间顺序,不能用train_test_split(random_state=42)随机切分——时间序列一旦随机打乱,训练集里混进了未来数据,验证集和测试集的评估结果都会虚高。第二,create_sequences里range(len(data) - seq_len - 4)最后的减4,是因为label是未来5日收益率,窗口末尾已经是i + seq_len,标签则要取到i + seq_len + 5的位置,保险起见减掉4个样本防止越界。这里留一个自行验证的小作业:把减4改成减5,观察X和y的行数是否仍然对齐。

3. LSTM模型结构设计:从PyTorch源码到指数预测的输入输出

3.1 两层LSTM加全连接层:为什么不用单层

股票指数预测场景下,单层LSTM的表示能力有限,难以同时捕捉短期波动和中期趋势。常见做法是堆两层LSTM,第一层输出完整序列供第二层消费,第二层只取最后时间步的隐藏状态送入全连接层做回归。下面是我在这个项目里常用的模型定义,代码可以直接存成lstm_model.py。

import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size=5, hidden_size=64, num_layers=2, output_size=1, dropout=0.2): super(LSTMPredictor, self).__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout, ) self.fc = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, output_size), ) def forward(self, x): # x shape: (batch, seq_len, input_size) out, _ = self.lstm(x) # out: (batch, seq_len, hidden_size) last_out = out[:, -1, :] # 取最后一个时间步 y_pred = self.fc(last_out) return y_pred

batch_first=True让输入维度更直观,第一维是batch_size,第二维是seq_len,第三维是特征数。nn.LSTM内部默认的hidden_state和cell_state是一组全零张量,对短期序列来说冷启动影响不大,但如果预测超长序列,建议用可学习的初始状态。out[:, -1, :]这一步是关键——LSTM在每个时间步都有输出,但只有最后一个时间步整合了全部历史信息,用来做回归是最自然的。全连接层最后没有接激活函数,因为我们是回归任务,预测的是一个带符号的收益率,输出范围可能是负的,ReLU或Sigmoid都会限制输出区间。

3.2 独热编码与嵌入层:这个场景不需要

我看到不少LSTM股票预测项目在模型前加了一个Embedding层,理由是把“星期几”“月份”等时间特征嵌入。这个做法本身没错,但对于指数预测这种以价格和成交量为主特征的回归任务,多一个嵌入层往往带来的是过拟合而不是精度提升。原因在于:交易日历特征(周一、周五、月初、月末)对A股指数的解释力很弱,远弱于均线差和波动率。所以我通常不加Embedding,而是把全部精力放在数值特征的构造和归一化上。如果确实想加入时间特征,更稳妥的方式是构造“距月初天数”和“星期几”两个数值特征,直接拼进特征矩阵,而不是走嵌入。

# 可选:添加时间特征(数值型) df_feat["day_of_week"] = df_feat["date"].dt.dayofweek df_feat["day_of_month"] = df_feat["date"].dt.day df_feat["month"] = df_feat["date"].dt.month features_enhanced = features + ["day_of_week", "day_of_month", "month"]

这里真正要提醒的是:加入这些特征后,验证集和测试集上的表现可能会变差。这不是模型坏了,而是这些特征本身就是弱信号,扩大了输入维度后,LSTM需要更多的数据才能拟合到有意义的相关性。遇到这种情况,果断退回只用原始5个特征,不要恋战。

3.3 损失函数与评估指标:回归任务的“看着准”陷阱

训练LSTM回归模型最常见的损失函数是MSE(均方误差)。但股票预测里,MSE低不代表预测有用——如果一个模型永远预测“收益率接近0”,它的MSE可能很低,却完全没有交易价值。我一般在训练时用MSE做损失,在验证时额外计算方向准确率(即预测值与真实值符号是否一致)。方向准确率才是这个项目应该关注的“业务指标”。

def directional_accuracy(y_true, y_pred, threshold=0.0): y_true = np.asarray(y_true).flatten() y_pred = np.asarray(y_pred).flatten() pred_dir = (y_pred > threshold).astype(int) true_dir = (y_true > threshold).astype(int) return float((pred_dir == true_dir).mean())

这个函数统计的是预测收益率的正负号与真实收益率正负号的匹配比例。threshold可以设置成0.001之类的小正数来过滤接近零的无效预测。方向准确率超过52%在指数日线预测上已经算有统计意义,超过55%基本可以认为是特征和模型配合得当,而不是运气。训练过程中如果MSE在下降但方向准确率在50%附近徘徊,说明模型学到的是“均值回归到0”,这个模型直接放弃就好,不用浪费时间调参。实际项目里我会同时打印这两个指标,用方向准确率决定是否早停。

4. 训练与调参:20个epoch内让损失降下来的可复现配方

4.1 训练循环:手写for循环比封装Trainer更可控

PyTorch Lightning这类高级封装确实省代码,但在这个场景下我推荐手写训练循环——因为你需要精确控制每个epoch结束后的验证逻辑、早停条件和学习率调整。下面这段训练代码是完整的可运行版本。

import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset # 准备数据加载器 train_dataset = TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train)) val_dataset = TensorDataset(torch.from_numpy(X_val), torch.from_numpy(y_val)) train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=256, shuffle=False) # 初始化 model = LSTMPredictor(input_size=5, hidden_size=64, num_layers=2, dropout=0.2) optimizer = optim.Adam(model.parameters(), lr=0.001) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=3 ) criterion = nn.MSELoss() # 训练参数 EPOCHS = 50 best_val_loss = float("inf") best_model_path = "best_lstm.pth" patience_counter = 0 EARLY_STOP_PATIENCE = 8 for epoch in range(EPOCHS): model.train() train_loss_sum, train_batches = 0.0, 0 for xb, yb in train_loader: optimizer.zero_grad() y_pred = model(xb).flatten() loss = criterion(y_pred, yb) loss.backward() # 梯度裁剪,防止LSTM训练时梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss_sum += loss.item() train_batches += 1 # 验证 model.eval() val_loss_sum, val_batches = 0.0, 0 preds, trues = [], [] with torch.no_grad(): for xb, yb in val_loader: y_pred = model(xb).flatten() loss = criterion(y_pred, yb) val_loss_sum += loss.item() val_batches += 1 preds.append(y_pred.numpy()) trues.append(yb.numpy()) train_loss = train_loss_sum / train_batches val_loss = val_loss_sum / val_batches preds = np.concatenate(preds) trues = np.concatenate(trues) val_acc = directional_accuracy(trues, preds) print(f"Epoch {epoch+1:02d} | train_loss: {train_loss:.6f} | val_loss: {val_loss:.6f} | val_acc: {val_acc:.4f}") scheduler.step(val_loss) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), best_model_path) patience_counter = 0 else: patience_counter += 1 if patience_counter >= EARLY_STOP_PATIENCE: print(f"早停于 epoch {epoch+1}") break

代码里有几个参数值得细说。batch_size=128对几千条训练样本来说不算大,但LSTM本身计算量不小,太大的batch容易让模型收敛到尖锐极小值,泛化性差;shuffle=True只在训练集用,验证集保持时间顺序。学习率0.001是Adam的默认值,对应这个任务规模够用;如果损失在训练集上迟迟不降,我会把学习率降到0.0005而不是调大。clip_grad_norm_这一行很多入门代码会漏掉,LSTM反向传播经过多层时间展开后梯度范数很容易超过1,加上裁剪后训练过程会稳定很多。ReduceLROnPlateau的作用是当验证损失停止下降时把学习率减半,给模型第二次收敛机会。

4.2 必调的三个超参数:hidden_size、num_layers、dropout

hidden_size决定了LSTM记忆容量。64是稳妥起点,数据量少于5000条时不要轻易加到128以上——容量越大过拟合越快,验证集方向准确率反而会掉。num_layers我固定用2,不是1也不是3。1层学不到高阶时序依赖,3层在几千样本量下训练时间长且收益很小,2层是平衡点。dropout的取值建议在0.1到0.3之间。这里有个反直觉的经验:dropout设成0.5在很多图像分类任务里效果好,但在LSTM回归里会明显降低方向准确率,因为时间序列的局部相关性很强,过度随机丢弃会破坏序列内部的连贯信息。我通常设0.2,如果验证集方向准确率还不到50%,先把dropout降到0.1再试一轮。

4.3 训练过程排错:损失不降、验证集指标乱跳、GPU显存溢出

损失不降是最常见的现象。先看是不是数据没归一化——特征列如果有几百上千的量级(比如没取对数的成交量),LSTM的梯度更新会被大数值特征主导,损失曲线会像心电图一样上下窜。再看学习率,Adam默认0.001对大部分问题有效,但如果你的损失在0.5以上且几乎水平,试试0.005。验证集方向准确率乱跳是另一个常见问题,本质是验证集样本太少(几百条),单次batch的预测结果方差很大。解决方法是把验证集换成测试集做多次滚动评估,或者在验证时用更大的batch让统计量稳定。

GPU显存溢出一般发生在显存不足6GB的机器上,解决方式很直接:batch_size从128降到64,seq_len从20降到15,或者把hidden_size从64降到48。这几项每项都能降低约一半的显存占用。股票指数预测的数据量实际上CPU训练也能在几分钟内完成,我用M1 Mac的CPU跑50个epoch也就两三分钟,没必要执着于GPU。训练脚本里可以用torch.backends.mkl相关配置优化CPU线程数,或者干脆在数据加载时把num_workers设成0避免多进程开销。

5. 预测结果验证与踩坑:数据泄露、过拟合与“看着准其实废”

5.1 踩坑记录一:归一化时用了全局统计量,验证集指标虚高

现象:训练集方向准确率55%,验证集方向准确率58%,测试集却掉到48%,且预测曲线严重滞后于真实曲线。

原因:这个坑我在2.3节里提过——如果在切分训练集之前就对全量数据做StandardScaler.fit_transform,验证集和测试集的均值和方差已经被训练集数据“剧透”了。滞后效应的来源更隐蔽:K线数据本身有自相关性,用未来统计量归一化后,模型从归一化数值中能推断出“当前处于历史高位还是低位”,而这一点在真实预测场景里是不存在的。

解决:严格按“先切分后归一化”的流程重做。用训练集的scaler去transform验证集和测试集,并且把测试集当作“完全没见过”的数据来对待。修正后再看方向准确率,如果从58%掉到51%,恭喜你,这才是真实水平。

5.2 踩坑记录二:把预测未来5日收益率做成逐日递推,测试集指标失真

现象:测试集方向准确率能做到62%,但把模型输出画成曲线后发现,预测值几乎等于真实值平移了5天。

原因:这是LSTM股票预测项目中最常见的“伪高准确率”——你训练时的标签是shift(-5)的未来收益率,但你在测试时使用了一种不严谨的评估方式:用t时刻的特征预测t+5的收益率,然后把它当成t+1时刻的预测值来做方向判断。由于收益率本身是连续变化的,t+5的预测方向与t+1的真实方向有一定相关性,导致指标虚高。

解决:评估时必须严格对齐时间轴。预测值y_pred[i]对应的是t = i + seq_len时刻之后第5天的收益率,真实值y_test[i]也必须是同一个时间点的真实收益率。比对方向时用同一个索引位置的值做比较。如果发现“预测值领先真实值5天”,那不是未卜先知,是索引错位。

5.3 踩坑记录三:模型只学到“惯性”,涨完继续预测涨

现象:方向准确率达54%,但实际按预测做多后亏损;观察预测值和真实值的散点图发现,预测值几乎全部集中在正区间或全部集中在负区间。

原因:LSTM学到了一个最简单的规律——最近几天上涨,未来5天大概率继续上涨;最近几天下跌,未来5天大概率继续下跌。这在震荡市里表现尚可,但在趋势反转节点上(比如连跌后的反弹、连涨后的回落),预测方向会持续错误。因为LSTM的短期记忆天然偏向捕捉惯性,反转信号(比如MA5下穿MA20)在特征空间里表达得很弱,模型权衡后选择了“随大流”。

解决:在特征中加入“反转类”指标,比如RSI(相对强弱指标)或KDJ的J值,这类指标在超买超卖区间对反转有较强指示。代码层面可以在特征列表里追加rsi_14列,用talib库一行算出。还有一个效果更好的办法:把训练标签从“未来5日收益率”改成“未来5日收益率减去过去5日收益率”,让模型学习“相对动量变化”而非绝对方向。

# 反转特征:RSI-14 try: import talib df_feat["rsi_14"] = talib.RSI(df_feat["close"], timeperiod=14) except ImportError: # 手写简版RSI delta = df_feat["close"].diff() gain = delta.clip(lower=0).rolling(14).mean() loss = (-delta.clip(upper=0)).rolling(14).mean() rs = gain / (loss + 1e-9) df_feat["rsi_14"] = 100.0 - (100.0 / (1.0 + rs)) features_with_rsi = features + ["rsi_14"]

5.4 踩坑记录四:加载模型做推理时忘记eval模式,预测结果抖动

现象:同一个测试样本,多次运行推理得到不同的预测值,且预测值抖动幅度达到几十个基点。

原因:模型定义里有dropout层,而dropout在训练和推理时的行为不同——训练时随机丢弃部分神经元,推理时应当关闭丢弃行为。如果在推理时没有调用model.eval(),dropout层会继续随机遮蔽神经元,导致每次前向传播结果不同。

解决:加载模型后,在推理前调用model.eval(),并用with torch.no_grad():包裹推理代码,这样既能关闭dropout的随机性,也能省去梯度计算开销。写成固定范式:

model = LSTMPredictor(input_size=5) model.load_state_dict(torch.load("best_lstm.pth")) model.eval() with torch.no_grad(): y_pred = model(torch.from_numpy(X_test[:10]))

6. 从源码到实盘:滚动训练、置信区间与回测的那些坑

跑通了单个模型的训练和测试,只能算完成这个项目的30%。真正让预测结果具备可信度的是滚动训练——每天开盘前用截至昨天的数据重新训练或增量更新模型,然后预测未来5日收益率。这里有一个实用技巧:不要每次全量重训,那样代价大且不稳定;更稳妥的做法是每周一用最近500个交易日的数据重训一次,其他交易日只加载最新权重做推理。如果数据源当天更新延迟,宁可跳过这次预测,也不要用昨天的数据冒充今天的数据。

预测结果的可信度评估方面,我推荐一个简单有效的做法:多次用不同随机种子训练模型,得到一组预测分布,取标准差作为不确定性估计。如果5个模型的预测方向一致,说明信号稳定;如果方向各半,说明当前市场状态处于模型的能力边界之外,这类预测应该被过滤掉而不是硬着头皮使用。我把这个逻辑封装成ensemble_predict函数,每次推理返回预测均值、标准差和一致方向投票数,实战中只采用投票数不低于4的结果。

千万不要把回测结果直接等同于未来收益。这个方向最深的坑在于回测中你看到了“事后诸葛亮”的完美曲线,误以为预测模型有效,但实盘里交易滑点、涨跌停无法成交、停牌数据缺失都会让收益大打折扣。我自己的习惯是:任何LSTM预测结果都要经过至少3个月的纸面跟踪——记录每日预测方向、真实方向和预测置信度,月底统计方向准确率与平均收益。如果纸面跟踪的方向准确率无法稳定超过52%,就继续回到特征构造和数据的层面去迭代,而不是去调模型结构。这个领域没有银弹,LSTM能做的只是从历史数据中提取统计规律,它不保证未来会重演,但不重演的每一天都在提醒你:把注意力放在数据、特征和评估流程上,比追求模型复杂度更有回报。希望帮到你。

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

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

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

立即咨询