☰
RNN到LSTM股票预测实战:从时序原理到Python实现
2026/10/22 11:03:44 网站建设 项目流程

我最早接触RNN,就是因为在给一只模拟标的的历史行情做趋势识别时,发现普通的前馈网络根本抓不住“数据顺序”里隐含的信息——昨天涨了今天又涨,这串顺序本身就带着规律。后来把循环结构引进来,模型才开始真正“记住”前面的走势,预测的曲线才像样。这篇笔记就是把我从RNN原理到股票预测实战这整条链路梳理出来,代码全用Python写成,目标是让你读完能跑通一版自己的模型,理解每一步在做什么,以及那些文档里不会明说但实测踩过的坑。

适合哪类人看?如果你已经会用Python,见过一点Keras或PyTorch,但还没系统把循环神经网络用在时序数据上;或者你试过股票预测但效果一直飘忽不定,这篇文章都值得花半小时过一遍。我会尽量把“为什么这样设计”讲透,而不是只丢一个能跑的脚本。

1. 为什么RNN天然适合股票这类时序数据

先回到一个朴素的问题:股票行情本质上是什么?是一串按时间排列的观测值——开盘价、收盘价、成交量、各种技术指标。这串数据有两个明显特征:一是时间上的连续性,今天的状态和昨天有关;二是顺序本身携带信息,把序列倒过来,含义就变了。

传统神经网络,比如常见的全连接网络,处理一条样本时把所有特征一股脑塞进模型,特征之间没有先后概念。你可以把训练集里每行的顺序打乱,模型照样学,因为它根本不关心行与行之间的关系。这在图像分类、推荐系统里没问题,但放到股票预测里就浪费了最关键的维度:时间依赖。

RNN的思路是在网络结构里加了一条“隐藏状态”的传递链。它每看到一个时间步的数据,都会更新一个内部向量,这个向量里压缩了“到目前为止我看到过的所有信息”,然后在每个时间步都输出一个结果。通俗点讲,RNN处理序列时像一个人从头到尾读句子,每读一个词都会更新自己对整句话的理解,而不是把每个词孤立地拿去做判断。

这里要强调一点:RNN不是专门为股票发明的。它在自然语言处理领域先证明了价值——一句话里每个词的语义要结合上下文才能确定。但同一套机制放到价格序列上,逻辑是完全相通的:某一天的涨跌幅,要结合前面几天的趋势、成交量变化,才能判断它是一次普通波动,还是趋势反转的启动信号。

所以做股票预测时选RNN,不是因为它“高级”,而是因为它的归纳偏置和时序数据的结构刚好契合。模型的记忆机制、顺序处理方式,天然就是为这种数据设计的。

1.1 从全连接到循环结构的关键差异

我们用一张小的对比来理解。普通网络处理某一天的数据时,不考虑任何历史信息,只根据当天的特征直接输出预测。比如输入5个特征(涨跌幅、成交量、换手率等),输出明天的涨跌概率。模型学到的是一条静态的映射关系,这个规律在全时间范围内都成立,和历史无关。

RNN则多了一条内部通道。它在t时刻更新隐藏状态h时,公式大致是这样:

h_t = tanh(W_h * h_{t-1} + W_x * x_t + b)

隐藏状态h_t既受当前输入x_t影响,又受上一个隐藏状态h_{t-1}影响。于是信息在时间维度上逐层传递,输出预测时用的不只是当天数据,而是“到当天为止整个序列的浓缩表示”。这就像你看盘时不会只盯今天一分钟的K线,而是要结合过去几天的走势一起判断——RNN把这个思维过程数学化了。

正是这个结构差异,让RNN在捕捉短中期趋势、识别连续上涨下跌后的惯性效应、根据放量缩量判断行情性质这些场景里,比普通网络更有潜力。当然,潜力归潜力,能不能把潜力兑现成效果,全看后面数据准备和训练的水平。

1.2 为什么经典的RNN训练困难:梯度消失与梯度爆炸

RNN结构看着合理,实践中却有个著名的老大难问题:梯度消失。隐藏状态在时间维度上不断传递,反向传播时误差信号要从最后一个时间步一路传回第一个时间步。这一路上梯度要反复乘以权重矩阵,如果权重矩阵的最大奇异值小于1,梯度会指数级萎缩。传到前几个时间步时,梯度几乎归零,于是模型根本学不到“很久以前”的信息。

用生活化的例子说:你让一个人回忆三个月前某天的看盘感觉,他可能完全想不起来。普通RNN就是这样,短时记忆还行,长时记忆基本是摆设。

梯度爆炸则相反,权重矩阵的最大奇异值大于1时,梯度指数级增大,训练过程直接发散,loss变成NaN。这些问题催生了两个重要变体:长短期记忆网络(LSTM)和门控循环单元(GRU)。

LSTM在普通循环结构上加了三个门:遗忘门、输入门、输出门。遗忘门决定多少历史信息要丢掉,输入门决定多少新信息要写入,输出门决定当前状态有多少要输出。通过这种机制,LSTM可以选择性地保持长期记忆,梯度传递路径上也多了一条“直通车道”,大大减轻了梯度消失问题。

GRU是LSTM的精简版,把三个门合并成两个:更新门和重置门。参数量更少,训练更快,在很多任务上效果和LSTM接近。做股票预测时,如果数据量不算大,GRU往往是个不错的平衡点——不牺牲太多效果,但训练稳定性和速度都好一些。

2. 股票预测任务的建模方式与数据准备

模型结构再厉害,数据没准备好也白搭。股票预测实战里,最大的工作量其实不在模型,在数据。我见过太多人一上来就写模型,结果训练集和测试集处理有漏洞,评估指标好看得离谱,实盘一测完全不是那么回事,问题就出在数据泄漏。

2.1 预测目标怎么定:回归还是分类

先想清楚一个问题:股票预测的“预测”到底指什么。常见的做法有两种。

第一种是回归:输入过去N天的序列,预测未来第M天的收盘价或收益率。好处是输出直观,直接出一个数值;缺点是价格本身有随机游走的成分,模型很难精确回归到真实价格,评估时MSE经常会让人怀疑人生。

第二种是分类:预测未来第M天涨还是跌,或者涨跌幅是否超过某个阈值。比如二分类就是“涨”或“跌”,三分类可以加上“横盘”。这种方式把问题从“猜准数字”简化为“猜对方向”,更贴近真实交易的决策需求——你真正需要知道的不是明天的精确价格,而是该买入、卖出还是观望。

我个人的经验是:第一次做实战练手,分类任务更容易跑通、评估也直观。先做二分类验证整个链路没问题,再升级成回归任务或同时输出方向和幅度。两种建模方式在代码层面的差异就是最后一层激活函数和损失函数:回归用线性输出加MSE损失,分类用sigmoid加交叉熵损失。

2.2 数据源选择与预处理要点

在这里我用的是开源行情接口抓取日线数据。代码示意如下,演示用的标的和日期都可以替换:

import pandas as pd import numpy as np # 从开源行情接口拉取日线数据(示例代码,按你自己的数据源调整) df = pd.read_csv('stock_demo_daily.csv', parse_dates=['date']) df = df[['date', 'open', 'high', 'low', 'close', 'volume']].sort_values('date') df['returns'] = df['close'].pct_change() df['log_returns'] = np.log1p(df['returns']) df = df.dropna().reset_index(drop=True)

拿到原始数据后,有几件事必须处理:

  • 复权问题。如果做长周期训练,除权除息会让价格出现跳空,模型会把这种缺口当成真实的价格变动信号。建议使用前复权数据,把历史价格调整到当前口径。
  • 缺失值。停牌日、数据源漏抓都会产生空值,最简单的做法是直接删除,或者用前值填充。不建议用均值填充,那会把没有交易的日子伪装成正常行情。
  • 异常跳动。瞬时涨跌超过正常范围的样本要检查原因,如果确实是数据错误,宁可删掉也不要留着误导模型。

2.3 构造输入序列:滑动窗口的正确姿势

RNN吃的是序列,所以需要把一维的价格数据切成长度为window_size的窗口样本。假设窗口长度是60,也就是用过去60个交易日的数据预测未来的结果,那第61天的数据就构成第一个完整样本,第62天开始是第二个样本,以此类推。

这块有个细节容易踩坑:滑动窗口切分数据时,要做成监督学习格式。输入x是过去60天的特征序列,标签y是对应的未来结果。关键是对齐方式——如果用第t天的信息预测第t+M天的结果,标签应该是从第t+M天取值,而不是第t+1天。

我实际用的特征不只收盘价,常用组合包括:

  • 收益率(日收益率、5日滚动收益率)
  • 成交量变化率
  • 日内振幅(high / low 的对数差)
  • 短期均线和长期均线的差值(近似趋势强度)

特征不是越多越好,每加一个特征就多一层噪声传导。我试过加了十多个技术指标后效果反而变差的情况,后来精简到五六个关键特征才稳定下来。

3. 归一化、训练集划分与避免未来数据泄漏

数据准备好了,下一个环节很容易出错:归一化。很多新手在整段数据上做MinMaxScaler,然后切训练测试集,这其实已经泄漏了未来信息。缩放器的最大最小值是从全部数据算出来的,测试集的信息通过这些统计量“注入”了训练过程,评估结果会虚高。

正确的做法是:先用训练集的数据拟合缩放器(fit),再用这个训练好的缩放器去转换训练集、验证集和测试集(transform)。这样缩放器的统计信息只来自训练集,测试集的数据在对模型“可见”之前不做任何处理。

from sklearn.preprocessing import MinMaxScaler from sklearn.model_selection import train_test_split feature_cols = ['returns', 'log_returns', 'volume_ratio', 'amplitude'] X_raw = df[feature_cols].values y_raw = df['label'].values # 提前构造好的标签列 # 先切分,再归一化!顺序不能反 X_train_raw, X_temp_raw, y_train, y_temp = train_test_split( X_raw, y_raw, test_size=0.3, shuffle=False ) scaler = MinMaxScaler() X_train_scaled = scaler.fit_transform(X_train_raw) X_temp_scaled = scaler.transform(X_temp_raw)

注意这里用了shuffle=False,这是第二个关键点。股票数据在做训练集切分时,绝对不能随机打乱。随机打乱等于把未来的样本混进训练集,模型在训练时“偷看”了后面发生的事情,课测试集却只能按时间顺序走,评估自然失真。正确做法是按时间顺序切分:前70%训练,后30%按时间依次作为验证和测试。

用时间顺序切分的代价是训练集和测试集的分布可能有差异——比如前一年是震荡市,后半年是单边上涨——模型带内表现可能差一些。这恰恰是真实场景:你训练模型时用的就是过去,预测的就是未来,未来的分布从来不会和过去完全一样。所以宁可带内效果保守一点,也要保证评估过程不泄漏。

窗口切分这步我也贴出代码,因为边界非常容易算错:

def create_sequences(x, y, window_size): xs, ys = [], [] for i in range(window_size, len(x)): xs.append(x[i - window_size:i, :]) ys.append(y[i]) return np.array(xs), np.array(ys) WINDOW = 60 X_train, y_train = create_sequences(X_train_scaled, y_train, WINDOW) X_temp, y_temp = create_sequences(X_temp_scaled, y_temp, WINDOW) # 再从X_temp中按时间切出验证集和测试集 split = int(len(X_temp) * 0.5) X_val, y_val = X_temp[:split], y_temp[:split] X_test, y_test = X_temp[split:], y_temp[split:]

这里create_sequences的输出形状是(samples, window_size, features),正好对应Keras里LSTM的输入格式,不用再做reshape。窗口长度WINDOW选60,是因为股票日线数据一年大约250个交易日,60天大概一个季度,这个尺度能覆盖一段中等趋势,又不至于长到让模型承担太多噪声。窗口太短会漏掉中期趋势,太长则把远古信息当新信息用,反而干扰判断。

3.1 为什么未来函数是股票预测的头号大敌

“未来函数”这个词在技术分析里是指某些指标在计算时用了未来数据,导致信号出现得比真实行情早。机器学习里对应的情况就是信息泄漏。除了归一化泄漏和时间顺序切分问题,还有一种隐藏得很深的泄漏:特征计算时用了窗口内的未来值。

比如你想算5日均线作为特征,如果一不小心用包含当天在内的未来5天数据算均值,然后把结果对齐到当天,那这条特征就已经“看到了未来”。在构造特征时,所有技术指标的计算必须严格保证某一天的特征只用到当天及之前的数据。用Pandas的rolling方法时,默认窗口包含当前行,方向是向后的,一般没问题;但如果你用shift(-k)这种操作去取未来数据构造特征,就闯了大祸。

我踩过这个坑:构造滚动收益率特征时,用了未来的收盘价去算,结果模型在训练集上准确率高得离谱,测试集上一塌糊涂。后来逐条检查特征才发现,有一列的shift方向搞反了。

所以每次构造特征,建议搞一个自查习惯:取一条样本,手工按原始数据算一遍特征值,再和特征工程产生的值核对。时间步的边界,永远是“只能看过去,不能看未来”。

4. 基于Keras搭建LSTM预测模型

到这一步,数据已经变成了模型能吃的监督学习样本。接下来搭建LSTM模型。我用TensorFlow的Keras接口,上手快,调试方便,对小规模的股票预测任务足够用。

4.1 模型结构与参数选择的思路

先给一个我实测效果不错的基线模型结构:

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, GRU, Dense, Dropout model = Sequential([ LSTM(units=64, return_sequences=True, input_shape=(WINDOW, X_train.shape[2])), Dropout(0.2), LSTM(units=32, return_sequences=False), Dropout(0.2), Dense(units=32, activation='relu'), Dense(units=1, activation='sigmoid') ]) model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy']) model.summary()

结构上有几个决策点值得展开说。

第一层LSTM用了64个单元并且return_sequences=True,意思是这一层的输出保留整个时间序列,喂给第二层LSTM再做一轮时间建模。两层的设计让模型可以先捕捉局部短期模式,再在此基础上提炼更抽象的特征,对小窗口数据效果更稳。

Dropout层的作用是防止过拟合。股票数据的信噪比非常低,模型很容易把噪声当作规律死记硬背,训练集loss一路降,测试集一塌糊涂。Dropout=0.2在日线数据上是个常用起点,太大模型欠拟合,太小防不住过拟合。

最后一层用sigmoid,输出一个0到1之间的概率值,表示“预测未来M天上涨的概率”。分类阈值默认0.5,但在实际数据集上,正负样本往往不平衡,后面可以单独调阈值。

4.2 训练配置与回调机制

训练时我强烈建议加EarlyStopping和ReduceLROnPlateau两个回调。EarlyStopping监控验证集loss,如果连续若干轮不改善就提前停止,避免无效训练浪费时间;ReduceLROnPlateau在验证集loss进入平台期时自动降低学习率,让模型在小步长下继续精细化收敛。

from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau callbacks = [ EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True), ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6) ] history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=200, batch_size=64, callbacks=callbacks, verbose=1 )

batch_size=64在日线数据上够用。如果数据量小,可以调小到32,让每批梯度估计更稳定;数据量大就调到128甚至256,加速训练。epochs给一个上限200,配合EarlyStopping实际训练轮数通常在30到60轮之间就能收敛。

训练完之后,保存模型是个好习惯:

model.save('stock_lstm_model.h5')

顺便说一下,训练过程里最好盯着loss曲线的形态看。正常的曲线是训练loss和验证loss一起平稳下降,最后验证loss稳定在一个区间。如果验证loss先降后升,说明开始过拟合了——EarlyStopping会兜底,但你还是应该观察这个形态,它帮你判断当前特征集和模型容量的匹配程度。

5. 模型评估与预测结果解读

模型训练完,不能只看accuracy就收工。股票预测的特殊性在于:准确率高不代表能赚钱,方向对了但幅度错了、或盈亏比不合理,实战价值仍然有限。

5.1 多维度评估指标体系

我的习惯是同时看四组指标。

准确率(Accuracy)是最基础的,但分类阈值不同会明显影响结果。如果模型预测的正确率高过55%,在涨跌大致均衡的数据集上就比随机猜强不少。

精确率和召回率要分开看。如果任务是“预测未来M天上涨”,精确率高意味着模型喊“涨”时大部分是对的,这是买入决策关心的;召回率高意味着模型把大部分实际涨了的样本都抓出来了,这是不踏空关心的。实际交易中两者往往有取舍,你需要根据自己的策略偏好去调分类阈值。

方向准确率(Direction Accuracy)对股票特别有意义。即使回归模型预测的价格和真实价格有偏差,只要预测方向正确率高,这个模型依然有参考价值。

还有一个容易被忽略的指标:预测分布。如果模型长期输出概率值都集中在0.45到0.55之间,说明模型其实没有真正学到区分性特征,只是在不确定区域反复徘徊。这种情况下准确率即便接近55%,也是不可靠的。

5.2 用滚动预测展示模型效果

模型预测的展示方式也很讲究。不要一次性把整个测试集扔给模型预测完就画曲线,那是“上帝视角”。更真实的做法是滚动预测:从测试集最开始的WINDOW个样本出发,每次只预测后一天的涨跌,然后把真实数据“喂”回窗口,继续预测下一天。这种方式模拟了实盘里每天收盘后才能用最新数据做下一次预测的真实节奏。

import matplotlib.pyplot as plt def rolling_predict(model, X_test, y_test, initial_window=10): preds = [] for i in range(initial_window, len(X_test)): sample = X_test[i - initial_window:i + 1] # 用最近窗口 prob = model.predict(sample[np.newaxis, :, :], verbose=0)[0][0] preds.append(prob) return np.array(preds) prob_test = rolling_predict(model, X_test, y_test) pred_label = (prob_test > 0.5).astype(int) plt.figure(figsize=(12, 5)) plt.plot(y_test[10:], label='真实方向', alpha=0.7) plt.plot(pred_label, label='预测方向', alpha=0.7) plt.legend() plt.show()

画出来之后,你通常会发现:模型能捕捉一部分波动方向,但不会每个点都对。这很正常,股票预测本来就不是求全对,而是找统计优势。关键看整体方向准确率是否稳定高于50%、在趋势明显的段落里模型是否跟上了节奏。

6. 常见问题与排查技巧实录

实战过程中大家遇到的问题大同小异,我把自己踩过的坑和排查思路整理成一份速查表。

6.1 预测结果全是同一个值怎么办

这是最常见的坑。模型训练正常,loss也降了,但预测结果几乎全在同一个数值附近,分类任务则全预测为同一类。排查方向主要有三个。

检查标签是否平衡。如果上涨样本只占20%,模型只要全部预测下跌就能拿到80%准确率,这是典型的类别不平衡问题。对策是用加权交叉熵,或者对少数类样本做上采样,具体可以用class_weight参数调权重。

检查特征尺度是否合适。某些特征数值特别大,比如成交量原始数值远大于收益率的值,模型优化时梯度被大数值特征主导,学不到其他特征的信息。用MaxAbsScaler或StandardScaler处理一下,让所有特征在相近尺度。

检查序列窗口是否太短或太长。窗口极短时模型只看得到几天的信息,几乎和单日判断没区别;窗口极长时噪声掩盖信号,模型学不到稳定规律。在30到90天之间做一轮网格搜索,找到当前数据集的最优窗口。

6.2 训练集表现好但测试集崩了

这就是过拟合的典型症状。对策优先从正则化入手:加大Dropout到0.3或0.4,减小模型容量(LSTM单元数减半),增加训练数据量(比如用15分钟级别数据代替日线数据——不过计算量会涨)。

另外还有一种隐性原因:分布漂移。股票市场存在明显的风格切换周期,前半段是趋势市,后半段是震荡市,模型在训练集上学的规律到了测试集就不适用了。这种情况下单靠调模型解决不了,要做滚动窗口重新训练,比如每60天就用最近250天的数据重训一次模型,保持模型对“近期规律”的敏感度。

6.3 训练速度慢和loss不下降

如果模型在CPU上训练很慢,可以考虑减少窗口长度、减小LSTM单元数,或者换用GRU替换LSTM。GRU参数量少,数据规模不大时训练快不少,效果基本持平。

loss不下降则先检查学习率设置。默认的adam学习率0.001可能偏大或偏小,可以试试调到0.0005或0.0001。另外检查input_shape是否正确,有时候是数据形状不对导致训练过程走错方向,我自己就曾因为特征维度写成1而LC弧了半天。

6.4 股票预测实战中的缺失值、复权和实盘偏差

从数据源取到的数据偶尔有停牌导致的缺失交易日。如果简单用前值填充,模型会以为这个价格维持了多天,产生虚假的“横盘”信号。建议的做法是删除这些缺失的交易日,让序列按实际交易日连续排布。

复权问题上,做长周期训练时前复权数据的价格和当日真实价格有差异,但收益率序列不受影响。如果模型预测目标是收益率或方向,前复权完全够用;如果非要预测真实价格,你得在预测结果上手动做复权逆变换,这里细节多,建议第一次先绕开。

实盘和回测还有交易成本、涨跌停限制等现实约束,回测里漂亮的结果到了实盘会缩水一截。这不是模型本身的错,而是评估和现实的差距本来就大。

7. 从基线模型到可用的增强方向

基线模型跑通之后,有几个方向可以进一步提升,按性价比排序说说。

加入更多特征通常是第一选择。比如加入技术指标(RSI、MACD、布林带宽度)、大盘指数数据、板块联动数据、北向资金流向等外生变量。注意这些数据的时间对齐问题——你是用当日收盘后的信息预测次日,那所有特征都必须是当日收盘前已知的。

改用多步预测。单步预测只预测明天,多步预测一次预测未来5天,这对交易系统的实操意义更大。实现上只需要把标签改成5天后的方向,调整对应的时间对齐方式即可。

尝试Attention机制。标准的LSTM把序列压缩成一个固定向量,Attention则让模型在解码时能回看原始序列的关键位置。在股票数据上,Attention往往能帮模型聚焦在“起涨点”“放量日”这些关键节点上,效果提升肉眼可见。

集成多个模型也是稳定输出的一招。训练5个不同随机种子的LSTM模型,对预测概率取平均,方差会明显下降。股票数据噪声大,单模型输出很不稳定,集成后虽然准确率不一定大幅提升,但预测序列的平滑度好很多。

最后分享一点实操体会

RNN不是我碰过的所有模型里最“花哨”的,但处理时序数据时,它把“顺序”保留下来这件事,天然就比其他网络结构多了层合理性。实操下来最大的心得倒不是调参数,而是“先把数据整明白再谈模型”——特征泄漏、窗口对齐、训练测试划分,这些问题不解决,模型再复杂也白搭。

我踩过不少坑,最有价值的一条经验是:永远默认自己会出错,所以每一步都留验证。特征构造完手工抽查,数据划分完检查索引,训练完看曲线不只看分数,预测完画图不只是看准确率。这套流程帮我在各种预测任务里省下的时间,远比多花那几个小时多得多。

如果你准备动手跑一版,建议先下载一只熟悉的模拟标的日线数据,窗口60、特征五六个、LSTM两层、训练加早停,先跑通再慢慢往里面加东西。等你能把预测方向稳定做到五成五以上,再回头调参也好、加特征也好,心里就有底了。

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

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

立即咨询