简介:面向股票价格涨跌幅度预测,这份资源给出了一套基于长短期记忆网络(LSTM)的完整项目方案。项目以上证50ETF(510050)为研究对象,将历史交易信息作为特征输入,通过多值量化分类把预测转化为多维函数拟合问题,最终实现涨跌幅度分类预测。代码用Python编写,包含训练与预测脚本,适合机器学习初学者及量化分析入门者参考。压缩包共31个文件,大小551KB,以7个py源码为核心,附带pyc编译文件、csv数据、pkl模型、png图表,以及xml配置、drawio结构图和md说明文档,覆盖数据处理、模型训练到结果可视化完整链路。目前已有1217人浏览学习。通过源码与图表对照,可清晰理解LSTM在金融时序预测中的建模流程、特征构造与涨跌分类问题的实验设计,对复现或拓展自己的预测模型很有帮助。
1. 当你写下“本文使用了基于LSTM”这句话,你承诺了什么
在论文、技术报告和项目文档里,“本文使用了基于长短期记忆网络(LSTM)”几乎成了标配开场白。写完这句话的人通常要同时承担三个隐含承诺:数据里存在跨时间步的长依赖;模型需要具备选择性记忆能力而不是简单堆叠历史;你愿意为这套门控机制付出成倍于RNN的训练成本。LSTM通过三个门和一个细胞状态,把“该记什么、该忘什么”从人工特征工程变成可学习参数,因此时间序列预测、故障诊断、语音处理这些场景里,它至今仍是大部分从业者换Transformer之前的第一个强基线。
这篇文章的受众有两类。一类是刚拿到带时间戳的数据,想知道LSTM到底在算什么、遗忘门的输入究竟是什么、为什么自己的预测曲线总是滞后一步;另一类已经跑通过模型,但卡在单向与双向结构选择、训练验证方式这类细节上。下面的内容不做完整数学推导,按“先立住原理、再给出可执行代码、最后讲怎么验证”的顺序讲,所有代码都按能直接跑起来的颗粒度写。
2. LSTM的门控机制:遗忘门、输入门、输出门的输入输出拆解
2.1 从RNN到LSTM:梯度消失是怎么被“绕过去”的
传统RNN的循环单元只有一条状态链,前向计算是 h_t = tanh(W_h h_{t-1} + W_x x_t + b)。反向传播时,误差项从 t 时刻传回 t-k 时刻,中间要经过 k 次对 h 的雅可比矩阵连乘。只要矩阵谱半径小于1,梯度就会指数级缩小;大于1则指数级爆炸。所以RNN在序列长度超过二十步时几乎学不到远端信息,这被称为长期依赖问题。
LSTM并没有从根本上消除连乘,而是新增了一条细胞状态通道 c_t,让误差可以沿着“直通连接”从后往前回传。关键公式是 c_t = f_t ⊙ c_{t-1} + i_t ⊙ g_t,其中 f_t 是遗忘门输出。当 f_t 接近1时,梯度可以近似无损地穿过时间维度,这是LSTM有效的最核心原因,而不是什么“记忆容量更大”。
理解这一点对后续调参很重要:如果你发现自己的LSTM对长序列毫无反应,第一反应不应该是加层,而是检查初始是否设置了较大的遗忘门偏置,让细胞状态在训练初期保持“全记住”状态。
2.2 遗忘门的输入到底是什么数据
这是经常被问错的问题。遗忘门的输入不是细胞状态 c_{t-1},而是上一时刻的隐状态 h_{t-1} 与当前时刻的外部输入 x_t 拼接后的向量。计算式是:
f_t = σ(W_f · [h_{t-1}; x_t] + b_f)
其中 [h_{t-1}; x_t] 表示沿特征维拼接,σ是sigmoid函数,输出值域为(0,1)。遗忘门的作用是决定“细胞状态里哪些信息需要保留”,但它做决策的依据,来自当前观测和上一时刻输出的组合,而不是直接读取细胞状态本身。
这里有个设计逻辑:门控的本质是“决策”,而决策需要的信息是当前输入和近期状态,也就是 h_{t-1}。如果让门再去读 c_{t-1},梯度路径会多一次非线性变换,长期依赖的传播优势就被削弱了。所以常见的 PyTorch 或 TensorFlow 实现里,输入门的预激活值 W_i · [h_{t-1}; x_t] 和遗忘门的预激活值 W_f · [h_{t-1}; x_t] 在数学形式上完全一致,只是权重矩阵不同。实际使用中如果你在调试一个手写LSTM,发现遗忘门全是常数,先去检查是不是忘记把 x_t 拼进门控输入,这是最隐蔽的高频错误。
2.3 三个门与候选记忆的公式拆解
LSTM每个时间步的完整更新由四组计算完成,下面这张表把每一步的输入、激活函数和用途列清楚:
| 变量 | 计算式 | 激活函数 | 作用 |
|---|---|---|---|
| 遗忘门 f_t | W_f · [h_{t-1}; x_t] + b_f | sigmoid | 控制细胞状态 c_{t-1} 有多少被保留 |
| 输入门 i_t | W_i · [h_{t-1}; x_t] + b_i | sigmoid | 控制候选记忆有多少被写入 |
| 候选记忆 g_t | W_g · [h_{t-1}; x_t] + b_g | tanh | 生成当前步的候选信息 |
| 输出门 o_t | W_o · [h_{t-1}; x_t] + b_o | sigmoid | 控制细胞状态有多少暴露给隐状态 |
f_t乘在c_{t-1}上完成“选择性忘记”,i_t乘在g_t上完成“选择性写入”,然后输出门对更新后的细胞状态做截断:h_t = o_t ⊙ tanh(c_t)。注意输出门后面必须接一个 tanh,把细胞状态数值压缩到(-1,1)再输出。很多人手写时把这个 tanh 漏掉,导致结果发散或数值异常。
2.4 用 PyTorch 手写一个LSTM单元前向
为了看清门控的输入到底长什么样,我给出一个最小可运行的PyTorch实现:
import torch import torch.nn as nn class LSTMCell(nn.Module): def __init__(self, input_size, hidden_size): super().__init__() # 一个线性层同时算四个门的预激活值 self.fc = nn.Linear(input_size + hidden_size, 4 * hidden_size) def forward(self, x, h, c): # x: [batch, input_size] # h: [batch, hidden_size] # c: [batch, hidden_size] gates = self.fc(torch.cat([x, h], dim=1)) i, f, g, o = gates.chunk(4, dim=1) i = torch.sigmoid(i) f = torch.sigmoid(f) g = torch.tanh(g) o = torch.sigmoid(o) c = f * c + i * g h = o * torch.tanh(c) return h, c逻辑说明:torch.cat([x, h], dim=1)把输入和隐状态沿特征维拼接,得到[h_{t-1}; x_t],维度是input_size + hidden_size。线性层输出4 * hidden_size,用chunk(4, dim=1)切出输入门、遗忘门、候选记忆、输出门的预激活值。随后分别过激活函数,先更新细胞状态c,再更新隐状态h。这里和前面公式一一对应,没有任何魔法。
两点值得注意:第一,遗忘门的输入确实就是[x, h]拼接向量,不是c;第二,实际框架实现里会把四个门的矩阵计算合并成一个大线性层以提升并行效率,底层逻辑与这段代码完全等价。
2.5 落地时常见的三个误用
第一,把候选记忆g_t也用了 sigmoid。g_t必须用 tanh,因为细胞状态需要负值区间,sigmoid 只能输出正数,会丢失“抑制”信号。第二,把隐状态和细胞状态当作同一个变量在代码里复用。两者更新时机不同,把c误当成h传给下一个时间步是 debug 时的高频错误。第三,PyTorch 原生 LSTM 默认batch_first=False,输入形状是[seq_len, batch, features],如果你按直觉填成[batch, seq_len, features],要么报错要么维度莫名对不上。检查shape永远优先于怀疑模型结构。
3. 单向LSTM与双向LSTM:选型逻辑和结构差异
3.1 单向LSTM:时间方向上的因果关系约束
单向LSTM按时间顺序从 t=0 向后扫描,每一步的隐状态 h_t 只依赖当前输入和过去的信息。这个特性让它天然适合时间序列预测、在线推理、实时控制这类因果任务,因为未来信息在现实里不可得,模型不能“偷看”。
实现上,PyTorch 的nn.LSTM默认就是单向,nn.LSTM(..., bidirectional=False)。单向的参数量好算:单个门控矩阵参数量大约是4 * (input_size + hidden_size) * hidden_size,加上偏置后翻倍计算。两个方向各跑一遍。
3.2 双向LSTM:用未来的上下文换更强的表示
双向LSTM由正向层和反向层组成。正向层从序列左往右扫,反向层从右往左扫,最后在输出层将两个方向的隐状态拼接(默认merge_mode='concat'),因此 h_t 同时包含 t 之前和 t 之后的信息。这意味着双向LSTM必须等整个序列到齐才能开始推理,无法用于在线预测。
双向适合的场景,是输入本身就是完整序列的非因果任务,比如文本情感分类、命名实体识别、中文分词、信号补全。如果你做的是“根据历史预测未来”,双向结构会把未来信息泄漏进训练目标,看起来训练集指标漂亮,真实推理时大幅退化。这是时间序列预测里最容易被忽略的结构性陷阱。
3.3 选型对比表
| 维度 | 单向LSTM | 双向LSTM |
|---|---|---|
| 信息方向 | 仅过去→当前 | 过去+未来→当前 |
| 参数量 | 约 4·(input+hidden)·hidden | 约两倍 |
| 在线推理 | 支持流式 | 不支持,必须整序列输入 |
| 适用任务 | 预测、生成、实时分类 | 序列标注、翻译、文本表示 |
| 过拟合风险 | 较低 | 数据量不足时明显偏高 |
从工程角度,序列长度越长,双向结构的内存占用翻倍越明显,因为反向层需要缓存整个序列的中间状态。如果你的数据量只有几万条样本,双向未必比单向有优势,反而更容易过拟合,需要通过return_sequences层级的 dropout 控制。
3.4 用 Keras 快速切换单双向并对比参数量
Keras 里切换成本极低:
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Bidirectional, Dense, Input # 单向:32个单元 model_uni = Sequential([ Input(shape=(20, 5)), LSTM(32, return_sequences=False, dropout=0.2), Dense(1) ]) model_uni.summary() # 双向:32个单元,输出维度翻倍到64 model_bi = Sequential([ Input(shape=(20, 5)), Bidirectional(LSTM(32, return_sequences=False, merge_mode='concat')), Dense(1) ]) model_bi.summary()逻辑说明:Bidirectional内部会创建两份 LSTM 层,分别处理正序和逆序输入;merge_mode='concat'表示两个方向输出沿特征维拼接,后续 Dense 层的输入维度自动翻倍。参数说明:比较两个模型的summary()输出,双向的参数量约为单向的两倍,但实际训练时间还要考虑反向扫描的内存开销,序列长度超过200时尤其明显。
如果确定只能用历史做预测,直接选单向,把多出来的预算放在加层数或增加序列长度上,性价比更高。如果做的是数据增强式的“插值补全”,比如传感器丢点,双向的效果往往显著优于单向,因为两端信息都有。
4. 用 Python 做 LSTM 时间序列预测:最小可运行方案与调参
4.1 滑窗法构造监督学习样本
时间序列预测的第一步不是搭网络,而是把原始序列改造成“样本-标签”结构。假设有500步的单变量序列,设定回溯窗口seq_len=12、预测步horizon=1,则第 i 个样本是data[i:i+12],对应的标签是data[i+12]。样本数量为500 - 12 - 1 + 1 = 488。
import numpy as np from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Input from tensorflow.keras.callbacks import EarlyStopping def make_windows(data, seq_len=12, horizon=1): X, y = [], [] for i in range(len(data) - seq_len - horizon + 1): X.append(data[i:i+seq_len]) y.append(data[i+seq_len:i+seq_len+horizon]) return np.array(X), np.array(y) np.random.seed(0) t = np.arange(800) signal = (0.6 * np.sin(2 * np.pi * t / 40) + 0.3 * np.sin(2 * np.pi * t / 9) + np.random.randn(800) * 0.08) X, y = make_windows(signal, seq_len=12, horizon=1) split = int(len(X) * 0.8) X_train, y_train = X[:split].reshape(-1, 12, 1), y[:split] X_val, y_val = X[split:].reshape(-1, 12, 1), y[split:]逻辑说明:make_windows用固定窗口滑过整个序列,horizon=1时标签是单个值,horizon大于1时是连续未来若干步。滑窗重建后的 X 必须 reshape 成[样本数, 时间步, 特征数],LSTM 的输入是三维张量,最后一维代表每个时间步上的特征数量。单变量时为1,多变量预测时把各维度堆叠到最后一维即可。
注意:这段代码没有对数据做归一化,实际使用时如果序列量纲很大,比如日均流水在十万量级,一定要先把训练集的均值和标准差算出来,再做(x - mean) / std,验证集必须用训练集统计量,不能全量数据一起拟合,否则存在信息泄漏,验证分数会虚高。
4.2 搭建模型并训练
model = Sequential([ Input(shape=(12, 1)), LSTM(32, return_sequences=True), LSTM(16, return_sequences=False), Dense(1) ]) model.compile(optimizer='adam', loss='mse') model.fit(X_train, y_train, epochs=80, batch_size=64, validation_data=(X_val, y_val), callbacks=[EarlyStopping(monitor='val_loss', patience=8)], verbose=1)逻辑说明:两层 LSTM 堆叠能捕捉不同时间尺度上的模式,第一层return_sequences=True输出完整的时间步序列,供第二层继续处理;最后一层return_sequences=False只保留最后一个隐状态,接入全连接层输出标量。损失函数用mse,因为数值预测的任务评价标准均方误差。EarlyStopping的patience=8表示验证损失连续8轮不改善就停止训练,防止空转。
参数说明:batch_size=64在几百到几千样本范围内属于常规取值;epochs=80是上限,实际靠早停提前收敛。若数据量很大,比如超过10万样本,batch_size 可以调到256,但过大的 batch 会让梯度方向过于平滑,LSTM 容易训练成“均值预测器”,输出曲线退化为一根平线。
4.3 四个必调参数与调参信号
| 参数 | 常用值范围 | 影响 | 调整信号 |
|---|---|---|---|
| seq_len 回溯窗口 | 12~60 | 决定模型能看到多远历史 | 预测曲线滞后明显时增大 |
| hidden units | 16~128 | 代表记忆容量 | 欠拟合时增大,震荡时减小 |
| 层数 | 1~3 | 非线性表达能力 | 单层loss不降时尝试加层 |
| dropout | 0~0.4 | 防止过拟合 | 训练loss与验证loss差距大时增大 |
一个经验:时间序列预测里seq_len比hidden_units更值得先调。如果你的序列周期是24小时,seq_len至少要覆盖一个完整周期,否则模型看不到周期性,只能学线性外推。这也是很多预测曲线滞后一步的直接原因——模型学到的其实是“上一刻的值就是下一刻的近似”。
4.4 训练失败的典型现象排查
损失出现nan,优先检查学习率是否过大,adam 默认学习率在多数情况下不会炸,但数据未归一化时梯度会飞出数值范围。损失停在某个平台不下降,观察是否所有预测值收敛到序列均值,如果是,说明网络退化成常数估计,通常是因为特征尺度差异过大或seq_len太短,模型没有可用于推断的上下文。验证集很好但回测一塌糊涂,多半是验证集划分时不小心使用了未来数据,比如按序切分后对验证集做了全局归一化。
5. 用时间序列交叉验证把 LSTM 的泛化能力测准
5.1 为什么随机K折在本场景不可用
很多从业者拿train_test_split(shuffle=True)切时间序列,这是最危险的错误。随机打乱后,训练集里包含验证集未来时刻的信息,LSTM 会通过记忆这些近邻样本来压低验证损失。等到模型真正上线,未来是不可见的,性能立刻现出原形。正确做法是按时间顺序划分,且验证集永远位于训练集之后。
5.2 滚动向前验证的代码实现
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5, gap=12) for fold, (tr_idx, va_idx) in enumerate(tscv.split(X), start=1): X_train_fold = X[tr_idx].reshape(-1, 12, 1) y_train_fold = y[tr_idx] X_val_fold = X[va_idx].reshape(-1, 12, 1) y_val_fold = y[va_idx] model = Sequential([ Input(shape=(12, 1)), LSTM(32, return_sequences=False), Dense(1) ]) model.compile(optimizer='adam', loss='mse') model.fit(X_train_fold, y_train_fold, epochs=30, validation_data=(X_val_fold, y_val_fold), verbose=0) loss = model.evaluate(X_val_fold, y_val_fold, verbose=0) print(f"fold {fold}: val_loss={loss:.6f}")逻辑说明:TimeSeriesSplit按原序列顺序切分成5组,第一折用前20%训练、后20%验证;第二折用前40%训练、再后20%验证,依此类推。gap=12是时间间隔,作用是跳过训练集末尾与验证集开头紧邻的一段样本,因为 LSTM 的回溯窗口为12,如果两者之间没有间隔,训练集最后12步的标签实际上可以间接包含验证集开头的信息,产生待测泄漏。
这段代码在每个折叠里重新训练模型,能完整评估不同历史规模下模型的稳定性。如果想要更贴近生产,可以将n_splits设大,比如10,但折数越多训练次数越多,成本按倍数上涨,一般5折在数据集几千样本时性价比最好。
5.3 验证结果怎么读
如果前几折验证损失接近,后几折损失突然变小,说明模型在更长历史数据下确实学到了更稳固的模式,这是正常且积极的信号。如果折与折之间验证损失波动巨大,超过一个数量级,说明序列存在明显分布漂移或样本量不足以支撑这么深的网络,优先考虑缩短回看窗口或增大序列长度。如果损失随着折数推进单调上升,则说明数据分布随时间漂移,需要定期重训,这时应该对比“最后N个月数据训练”和“全量数据训练”哪个验证结果更可靠。还有一个没有体现在代码里的技巧:评估多步预测时,不要只算验证集的单步损失,要把模型预测的输出递归回填到输入中,连续滚动预测10步、20步再计算误差,这条曲线才是生产环境预测能力的实际反映,单步损失再低也说明不了长期稳定性。
本文还有配套的精品资源,点击获取