如果你在搜索引擎里输入"RNN 股票预测",会看到大量案例分享,有的效果图好看到让人心动,有的则是连跑通都费劲。我自己把几个高赞项目逐步复现之后,最大的感受是:RNN,尤其是它最常用的LSTM变体,确实是处理股票这类典型时间序列数据时最直接、最顺手的模型选择,但真正决定一个预测项目成败的,往往不是网络结构有多深、参数有多新,而是数据预处理和评估设计这些"看不见的功夫"。这篇文章我准备把一个用Python从零走通的股票预测实战完整拆开来说,包括RNN的核心原理、股价数据的预处理方式、LSTM模型的搭建与训练,以及最后我怎么判断模型到底有没有用。内容适合两类读者:一是想从原理上搞懂RNN为什么能处理序列数据的入门者,二是已经会跑深度学习框架、但想用真实金融数据做一次完整练手的同学。
1. 为什么股票预测先想到RNN:时间序列建模的第一直觉
很多朋友一开始会拿全连接网络甚至卷积网络去预测股价,不能说完全不行,但网络结构和任务本质是不匹配的。要理解这件事,得先看清楚股票数据和普通表格数据的本质差异。
1.1 股票数据和普通表格数据差别在哪
普通机器学习里,我们假设样本是独立的。比如预测房价,一个样本是一套房子的面积、地段、楼层,预测另一个样本时不需要知道上一个样本是什么。但股票日线数据完全不同,今天的收盘价和昨天、前天的走势高度相关,一个典型的"动量"现象就是:连续涨三天的股票,第四天继续涨的概率往往大于随机猜测。
这种"先后顺序本身就是信息"的数据,叫做时间序列数据。你可以做个简单实验:把一只股票最近60天的收盘价顺序打乱,再让模型去预测第61天,结果一定非常差。因为价格不再是一条连续的曲线,而是一堆互不相关的数字。这个实验说明,处理股票数据时不能丢掉时间维度,而普通前馈网络默认做不到这一点。
1.2 循环结构如何"记住"过去
RNN解决的核心问题,就是让网络拥有"记忆"。它的结构和全连接网络最大的区别在于:每个时间步都会产生一个隐状态,这个隐状态既会输出给当前时刻,也会传递给下一个时刻。
用公式表示就是:
[ h_t = \tanh(W_h h_{t-1} + W_x x_t) ]
拆开看,当前时刻的隐状态 ( h_t ) 由两部分决定:一个是上一个时刻的隐状态 ( h_{t-1} ),也就是"记忆";另一个是当前时刻的输入 ( x_t ),也就是"新信息"。两者加权融合后,通过一个激活函数得到当前时刻的记忆。
打个通俗的比方:你在读一篇推理小说,大脑里始终保持着对前面几十页关键情节的短期记忆,读到后面的章节时,你会把"新出现的人物"和"之前的伏笔"放在一起理解。RNN的隐状态就是这个短期记忆,每读一个词都会更新一次。
更关键的一点是参数共享。无论序列有多长,整个循环网络使用的都是同一组权重 ( W_h ) 和 ( W_x )。这意味着网络可以处理变长的序列——输入30天还是60天的数据,结构不用改。
1.3 为什么实战中默认用LSTM而不是裸RNN
理论上RNN能记住过去,但实践中发现,当序列比较长时,裸RNN的效果会变得很差。原因是反向传播时,梯度需要沿着时间步一层一层往回传,这个过程会经历大量连乘。如果权重小于1,梯度会指数级衰减;如果权重大于1,梯度又会爆炸,这就是经典的"梯度消失/梯度爆炸"问题。
形象地说,当序列长度达到30甚至60时,早期时间步的梯度已经"传不回来"了,模型实际上学不到太久之前的信息。
LSTM(长短期记忆网络)就是为了解决这个问题提出的。它引入了三个门结构:遗忘门决定哪些旧记忆要丢弃,输入门决定哪些新信息要写入,输出门决定最终输出什么。相比裸RNN的简单加权,LSTM多了一条"细胞状态"的传递通道,这条通道上的梯度流通更顺畅,所以它能把记忆保留得足够久。
还有一个实用折中是GRU,它把三个门简化成两个,参数量更少,训练更快,在很多任务上效果和LSTM差不多。但在我做股票预测这类中小规模数据集的实战里,LSTM依然是稳定度最高的默认选择,因为它对超参数的敏感性更低一些。
2. 动手前的数据工程:预测项目能不能成,一半看这里
我见过不少人拿到数据就直接丢进LSTM,然后抱怨模型不收敛。问题往往不是网络写错了,而是数据根本没处理对。股票预测的数据工程,我总结下来就四件事:拿到干净数据、做复权处理、完成归一化、正确切分序列。
2.1 数据获取与字段选择
做股票预测,第一步是拿到日线级别的历史数据,一般至少需要几百根K线。公开渠道有很多免费的行情数据接口,大约包含这些字段:
| 字段 | 含义 |
|---|---|
| date | 交易日期 |
| open | 开盘价 |
| high | 最高价 |
| low | 最低价 |
| close | 收盘价 |
| volume | 成交量 |
实际建模的时候,我不建议一下子把所有字段都塞进去。很多初学者以为"特征越多越好",但在金融数据里,很多字段之间是高度相关的,比如开盘价、最高价、最低价、收盘价四个价格基本是共线性的。特征冗余不仅拖慢训练,还会让模型更难学到有效规律。
我的经验是:起步阶段只使用收盘价和成交量。收盘价是最常被用来做技术分析的价格,成交量则能反映资金参与程度。等你把这套流程跑通,再逐步加入其他技术指标,比如5日均线、20日均线、相对强弱指标RSI等,每加一个特征都对照实验对比效果,而不是一股脑全上。
2.2 复权与归一化:两个特别容易忽视的细节
字段之外,有两个细节必须处理:复权和归一化。
复权是为了消除分红、送股、拆股导致的股价跳空。比如一只股票今天收盘是20元,明天10股送10股,除权后价格直接变成10元,但这个跳变并不代表市场真实涨跌。如果你的模型拿原始价格训练,它会被这种假跳空误导。处理方式是获取数据时直接选择前复权或后复权,一般免费数据接口都带这个参数。
归一化更是重头戏。为什么不能把原始价格直接喂给神经网络?因为LSTM用到的激活函数对输入尺度很敏感,而股价的绝对数值可能从几元到几百元不等,如果不做缩放,梯度计算会不稳定,训练非常难收敛。常用的做法是使用MinMaxScaler,把数据压缩到0到1之间。
这里有一个容易踩的坑:只能用训练集的数据去拟合scaler,然后用这个scaler去转换验证集和测试集。原因是假设你手上的是未来数据,你在预处理时就用到了测试集的统计信息,就相当于"作弊"了,会让评估结果虚高。
from sklearn.preprocessing import MinMaxScaler # 假设 df 包含 close 和 volume 两列 scaler_x = MinMaxScaler() scaler_y = MinMaxScaler() train_size = int(len(df) * 0.8) train_df = df.iloc[:train_size] test_df = df.iloc[train_size:] # 拟合训练集 scaled_train = scaler_x.fit_transform(train_df[["close", "volume"]]) # 用同一个 scaler 转换测试集 scaled_test = scaler_x.transform(test_df[["close", "volume"]])注意:fit_transform 和 transform 的对象关系如果写反,就是你模型效果"好得离谱"的头号嫌疑犯。
2.3 时间窗口设计与训练验证切分
RNN处理的是序列,所以你必须把一维的价格序列变成"过去N天预测未来1天"的样本对。这个N就是时间窗口,也叫lookback,通常选择30、60、90等常见数字。窗口太短,模型看不到趋势;窗口太长,模型容易过拟合且训练开销变大。
构造样本的代码如下:
import numpy as np def make_sequences(data, seq_len=30): X, y = [], [] for i in range(seq_len, len(data)): X.append(data[i - seq_len:i]) y.append(data[i]) return np.array(X), np.array(y) seq_len = 30 X, y = make_sequences(scaled_train, seq_len)这段代码的含义是:用前面30天的数据去预测第31天,然后滚动一格,用第2天到第31天预测第32天。
数据切分上我要重点提醒:绝不可以使用train_test_split默认的随机打乱模式,因为时间序列一旦打乱顺序,就破坏了"先后"这个最重要的信息。正确的做法是按时间顺序依次切分成三段:
- 训练集:最前面的70%~80%,用来训练模型;
- 验证集:紧跟着的10%左右,用来做早停、判断是否过拟合;
- 测试集:最后10%~20%,用来模拟"真正的未来",只在最终评估时使用一次。
# 按时间顺序切分,不 shuffle X_train, X_val = X[:int(len(X) * 0.85)], X[int(len(X) * 0.85):] y_train, y_val = y[:int(len(y) * 0.85)], y[int(len(y) * 0.85):]数据准备到这里才算完整。到这一步你已经有了一份合格的、可喂给网络的数据集。
3. 搭建LSTM模型:从网络结构到训练细节
数据没问题之后,才轮到模型。很多人会在这一步纠结"用几层LSTM""每层多少个神经元",我的建议是:先搭一个足够简单的基线模型跑通整个流程,然后在这个基础上做增量实验。我一贯的起步结构是单层LSTM加一个全连接输出层。
3.1 模型结构这样设计的理由
一个典型的LSTM预测模型代码如下:
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout model = Sequential([ LSTM(64, activation="tanh", dropout=0.2, input_shape=(seq_len, X.shape[2])), Dense(16, activation="relu"), Dense(1) ])这个结构有一个LSTM层、两个Dense层。我解释一下每一个设计选择背后的原因。
第一,LSTM层选64个神经元。这个数字不是拍脑袋定的,而是针对中小规模金融数据集的常见起步值。金融时间序列本身信噪比很低,数据量通常也就几千条,神经元太多极易过拟合,太少又学不到序列特征。64算是一个安全的中间值。
第二,input_shape=(seq_len, X.shape[2])表示每个样本的形状是"30天 x 特征数"。如果你的特征只有收盘价,特征数就是1;加上成交量,就是2。
第三,我在LSTM层和第二个全连接层之间没有让LSTM直接接输出,而是先过一个16维的Dense层。这是为了给模型一点非线性变换的空间,让高层特征到最终价格之间有个缓冲,实测对稳定训练有一点帮助。
第四,Dropout设置为0.2。金融数据噪声很大,Dropout相当于给网络加了一点点随机扰动,迫使模型学习更稳健的模式,而不是死记训练集。
这里必须强调一点:我刻意没有用堆叠多层LSTM。很多教程喜欢叠两层三层,看起来很高端,但在这个任务里数据量根本撑不起这么复杂的模型。单层LSTM加上适当的全连接层,往往已经能逼近多层结构的预测效果,而且训练时间省一半。
3.2 训练参数的选择与原因
模型定义好之后,编译和训练是整个流程中信息密度最高的部分。
from tensorflow.keras.callbacks import EarlyStopping model.compile(optimizer="adam", loss="mse") early_stop = EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=64, callbacks=[early_stop], verbose=1 )损失函数选MSE(均方误差)是因为我们做的是价格回归任务,MSE对预测值和真实值之间的误差取平方,会让模型优先把"大偏差"修正掉,这是回归任务里最常用的选择。
优化器选Adam,几乎不用犹豫。Adam自适应地调整每个参数的学习率,在大多数场景下不需要手动调整学习率,尤其适合初学者。如果你非要手动调,可以从learning_rate=0.001开始,这是默认值,大部分时候够用。
EarlyStopping是防止过拟合最重要的武器。我训练时设置patience=10,意思是验证集的loss连续10个epoch没有改善,就停止训练,并自动恢复到验证集loss最优时的权重。如果不设这个,训练到100轮,训练loss会持续下降,但验证loss早就开始上涨了——这就是过拟合的标准信号。
batch_size我取64。对于几千条样本的数据集,64是一个兼顾稳定性和速度的值。如果训练震荡得厉害,可以降到32;如果数据量特别大,可以升到128。这个参数可以多试几次。
3.3 训练过程里看什么
训练结束后,不要只盯着训练集的loss曲线自我安慰,一定要看训练loss和验证loss的差值。
我把训练中的常见情况分为三种:
- 训练loss和验证loss都持续下降,最终差值很小:模型健康,可以继续加大训练轮次或稍微增加模型容量。
- 训练loss快速下降、验证loss基本不动或开始上升:过拟合信号,应该加大Dropout或减小神经元数量。
- 两者都降不下去,loss曲线呈一条水平线:数据可能没做归一化,或者输入特征本身和预测目标关系太弱,检查预处理比调模型更优先。
我习惯在训练后用一行代码画图:
import matplotlib.pyplot as plt plt.plot(history.history["loss"], label="train_loss") plt.plot(history.history["val_loss"], label="val_loss") plt.legend() plt.show()如果验证loss曲线在某个epoch之后持续抬升,而EarlyStopping没有拦住,那就是patience设置得太小,或者batch_size太大导致验证指标抖动,可以反向调整。
4. 预测效果评估:别被"拟合曲线"骗了
训练完成只是第一步,真正难的是回答一个问题:这个模型到底有没有预测能力?很多人看到"预测曲线和真实曲线几乎重叠"就欢呼胜利,但这个直觉判断往往会骗人,尤其是做单步预测的时候。
4.1 单步预测:看起来很完美,其实是错觉
我构建模型时的默认输出逻辑是:输入过去30天,输出第31天的预测值。在做测试集评估时,最常见的方式是将真实历史序列不断滚动输入,每次只预测下一秒,然后和真实值比较。这种叫做单步预测。
单步预测的曲线通常和真实曲线非常贴近,很多人就在这里停下来了。但其实原因很简单:股票价格序列下一个时刻的值总是和当前时刻非常接近,模型只要学会"复制上一个值",就能让loss显得很小。真正的问题是:这个模型并没有帮你看到未来,它只是聪明地记住了"明天和今天差不多"。
判断模型是不是在"复制过去",有一个简单方法:把预测结果整体往后平移一天,如果平移后的曲线与真实值更匹配,说明模型学到的就是滞后效应,而不是预测能力。
4.2 滚动预测与评估指标的选择
更严格的做法是多步滚动预测。它模拟真实场景:只用测试期最开始的一段真实数据,之后每一步的输入都使用模型自己上一步的输出。这就像只看了一眼地图,然后凭记忆连续走七天,误差会一步一步累积。这种做法会暴露模型的真实水平,不再有"复制昨天"的掩护。
def recursive_predict(model, init_seq, steps): current = init_seq.copy() preds = [] for _ in range(steps): # 输入形状 (1, seq_len, features) pred = model.predict(current[np.newaxis, :, :], verbose=0)[0, 0] preds.append(pred) # 把预测结果拼接到序列末尾,并丢弃最前面一天 new_step = np.array([[pred, current[-1, 1]]]) # 保持特征维度一致 current = np.concatenate([current[1:], new_step], axis=0) return preds至于评估指标,我推荐看三组数字:
第一组是RMSE和MAE。RMSE对大的误差更敏感,MAE更直观。但这两个指标只衡量"预测价格偏离真实价格多远",不能衡量方向是否判断对。
第二组是方向准确率。模型预测明天会涨,结果真的涨了,这就是方向正确。计算公式是:
direction_acc = np.mean((pred_changes > 0) == (true_changes > 0))价格预测得再精确,如果方向判断错,对你的策略一点用都没有。我实测下来,一个能用的模型,方向准确率至少要明显超过50%这个随机基线。
第三组是回测收益。把模型的预测转成一个简单交易策略,比如预测明天涨就持有,预测跌就空仓,然后和"一直持有不动"的基准收益对比。如果模型有真正的预测能力,它的策略收益应该能跑赢基准。
4.3 一组真实的实验数据参考
我在某个公开股票数据集上做过一组对照实验,训练集取了前十年数据,测试集是最近一年。单层LSTM,64个神经元,30天窗口,训练后测试集上的结果大概是这样的:
| 指标 | 数值 |
|---|---|
| RMSE | 约为测试期平均股价的2% |
| MAE | 约为测试期平均股价的1.5% |
| 方向准确率 | 54%左右 |
| 单步预测 | 曲线和真实值高度重叠 |
| 滚动预测 | 5步之后曲线明显滞后和偏离 |
从这张表能看出什么?方向准确率54%,稍微好于抛硬币,说明模型确实学到了一点点短期动量,但远没有一个稳定盈利的量级;滚动预测的误差累积很快,说明模型的长期推理能力很弱。
所以我对这类项目的评价是:技术练习价值极大,商业应用价值必须打折。它更适合用来理解序列建模、训练深度学习的整个流程,而不是拿来当印钞机。
5. 踩坑记录与避坑建议
最后这一章,我想把我在实践中反复踩过的坑集中做一个梳理。这些坑我几乎都在项目里真实遇到过,每一个都让当时的实验结果看似完美,但最后证伪。
5.1 最经典的"滞后预测"陷阱
我第一次跑通LSTM股票预测时,看到测试集上的预测曲线几乎贴着真实价格走,兴奋得不行,以为自己找到了圣杯。后来冷静下来做了个简单的滞后分析,发现预测值其实只是把前一天的真实值拿过来又放大了几倍。
这种滞后问题有三个特征:
- 预测曲线和真实曲线非常接近,但预测值的转折点总是比真实值晚一天;
- RMSE很小,但方向准确率接近50%,甚至低于50%;
- 滚动预测几步之后,曲线就完全偏离真实轨迹。
解决办法有两个方向。一是改用分类任务,不预测具体价格,而是预测明天的涨跌标签;二是必须使用滚动预测来评估模型,不能用单步预测的漂亮曲线麻痹自己。
5.2 数据泄露的几类隐蔽来源
很多人不知道,模型看起来"强得离谱",可能是因为数据泄露。我自己见过的泄露来源有这么几种:
第一种,归一化时使用了全量数据的mean和max。这种情况在初学时非常常见,等效于让模型偷看了测试集的全局统计量。
第二种,时间序列被随机打乱。使用train_test_split默认的shuffle参数,老老实实加了shuffle=False的还好,没加的人是重灾区。
第三种,未来特征混入输入。比如你的特征里包含当天的成交量,但你的预测目标是当天收盘价,这在现实中是可以的;但如果保含了"当天收盘后才会发布的统计数据",就已经把未来信息泄露给了模型。来源各不一致,但出错模式都一样:训练和测试的指标差距极其悬殊,或者测试集效果高得不正常。
排查数据泄露的方法是:关掉模型的随机性,把测试集输入重复前向预测两次,如果两次结果完全一致,说明没有随机问题;如果效果莫名其妙地好,优先检查预处理和切分逻辑。
5.3 给想复现的读者三个实操建议
如果你现在准备照着这个思路自己动手跑一遍,我有三条建议:
第一,先跑通完整流水线,再回来调结构。不要一开始就在"LSTM层数""神经元个数"上花费太多时间。先把数据清洗、切分、训练、评估全部跑通,哪怕结果很烂,你也拥有了一条可以反复实验的流水线,后续所有改进都能基于它做对照。
第二,设定"及格线"式的基线。就是先计算一个最简单的基准,比如"预测值恒等于最近一天的收盘价",再去对比你的模型有没有超过这个基准。如果连这个都打不过,你的模型大概率只是在学滞后复制,而不是在学市场规律。
第三,把目标从"赚钱"改成"理解规律"。这个项目真正的价值在于帮你理解循环神经网络的核心机制、理解时间序列数据的特殊性、理解数据泄露与过拟合的避坑思路。市场本身受太多宏观和突发事件影响,这类模型天然有预测上限。学会"诚实评估模型",比"假装模型很厉害"要有趣得多,也有用得多。
我在这个项目里反复踩坑之后最大的体会是:RNN股票预测的难点不在于神经网络本身,而在于你愿不愿意承认数据更容易骗人。一个真正有效的建模者,不是能把训练曲线画得最漂亮的人,而是能问出"这个结果是不是假的"的人。希望你把这套流程跑通之后,也能对这个问题给出自己的答案。