做时间序列预测,尤其是想用 Python 预测股票走势时,RNN 几乎是最先跳到脑子里的模型。可问题也恰恰出在这里:网上讲 RNN 原理的教程一抓一大把,但大多停在 MNIST 或正弦波;真到了股价数据,你会发现自己连数据格式都对不上。这篇文章是一次相对完整的 RNN 股票预测实战记录,从数据获取、滑动窗口构造、LSTM 训练到回测评估,把每个环节的取舍和踩坑一五一十写出来。适合有一定 Python 和深度学习基础、想跑通一整个金融时序预测项目的读者,看完可以直接照着思路在自己的数据上动手,少走一些我当年踩过的弯路。
1. 为什么是 RNN:股价预测的核心序列建模思路
1.1 RNN 凭什么处理股票这种时间序列
做金融时间序列建模,最关键的一点是认清数据的结构。股价数据是典型的带时间戳的序列:今天的收盘价不是独立生成的,它和昨天的价格、前天的成交量、过去一段时间的趋势都有关系。普通的前馈网络在处理这种数据时,会把每个时间点当作独立的输入,模型完全没有“先后”的概念。RNN 不一样,它把输入看成一个序列,t 时刻的隐藏状态 h_t 由当前输入 x_t 和前一个隐藏状态 h_{t-1} 一起计算,相当于网络内部维护了一个流动的“记忆”。正因为这种参数共享与状态传递,RNN 在处理时间序列时天然拥有顺序感和上下文建模能力。股票预测任务里,我们最想要的恰恰就是这个:让模型根据过去 N 天的模式去推断未来。这就是为什么这类任务首选 RNN 而不是普通全连接网络的原因。
1.2 从 RNN 到 LSTM/GRU:长期依赖问题
不过这里有一个很现实的问题:普通的 RNN,理论上可以捕捉长期依赖,但在实际训练中会因为反向传播路径过长而出现梯度消失。简单说就是,序列太长时,早期时刻的信息在传向后期时被一层层压缩,几乎什么都剩不下。而 LSTM 通过遗忘门、输入门、输出门三个门控,再加上一条贯穿所有时间步的细胞状态通道,让信息能够相对无损地流动,从而解决梯度消失问题。GRU 是 LSTM 的精简版,把门控合并成两个,参数更少,训练更快。就我的实际体验来说,在日线级别的股票数据上,LSTM 和 GRU 的差距往往没有想象中那么大,但 LSTM 的稳定性和可参考案例都更多,所以实战中我默认从 LSTM 起步。你完全可以把 GRU 当作对照实验跑一下,看看训练时间是否值得那点准确率变化。
1.3 先给预期踩一脚刹车:股价预测的正确心态
在动手前,我特别想先踩一脚刹车。股票价格受宏观政策、公司基本面、市场情绪甚至突发消息影响,历史价格只是其中一个可观测视角。RNN 能学习的只是“价格模式在历史上如何演化”,它不可能预知明天会不会突然出现一只黑天鹅。因此,把这个项目定位成“用深度学习做时序建模练习,顺便检验 RNN 对金融数据的拟合能力”更合适。这样你就不会因为某一天预测偏差大而怀疑代码写错,也不会产生模型能稳定盈利的错觉。后面我们做回测也只当作模型评估手段,而不是投资建议。抱着这种心态,你才能真正把注意力放到特征构造、样本切分和防止数据泄漏这些技术问题上去。
2. 环境准备与数据预处理:动手前先扫雷
2.1 环境与依赖怎么准备
先说环境。我目前常用的一套组合是:Python 3.10,pandas 2.x,numpy 1.24+,matplotlib,scikit-learn,以及 TensorFlow 2.x。TensorFlow 自带 Keras,模型搭建直接走 Keras 的 Sequential API 就行,不需要额外装单独框架。安装时用 pip 一次性解决:
pip install numpy pandas matplotlib scikit-learn tensorflow如果你的机器没有独立显卡,也不用慌。日线级别的股票数据样本量通常只有几千条,用 CPU 训练一个两层的 LSTM,几分钟到十几分钟就能跑完一轮完整的训练。反而是很多人在 conda 和 pip 混用、TensorFlow 与 NumPy 版本不匹配上浪费了大量时间。我建议新建一个干净的虚拟环境再装,别在全局环境里硬来。
2.2 数据获取:公开接口与本地 CSV 双方案
做股价预测,第一步是拿数据。比较常见的方式有两种:一是通过公开数据接口拉取行情,二是直接读取本地 CSV。不管哪种,字段里至少要包含日期、开盘价、最高价、最低价、收盘价和成交量。下面这个函数,假设你已经把目标标的保存成 CSV,列名是 date, open, high, low, close, volume:
import pandas as pd def load_daily_data(path): df = pd.read_csv(path, parse_dates=["date"]) df = df[["date", "open", "high", "low", "close", "volume"]] df = df.sort_values("date").reset_index(drop=True) return df如果你希望直接走接口拉取,用公开行情库也一样,只需要把返回结果整理成上面的格式。补充一个容易被忽略的点:一定要选择复权后的数据。因为股票发生除权除息时,原始价格会出现一个看似巨大的跳空,这并非真实交易行为,会让 RNN 错误地把这种跳空当成有效模式。前复权是把历史价格按当前价格水平做调整,适合回测和建模。实际应用中,我一般直接用前复权日线数据。
2.3 清洗与标准化:别把未来数据泄漏到训练集
拿到数据以后,第一步是检查缺失值和无交易日期。A股市场有停牌和节假日,如果你的数据是按自然日展开的,中间会出现空行。最简单做法是删除缺失行;如果你的窗口较长,也可以用向前填充来补出连续交易日序列。需要注意:填充前先确认是“缺失”还是“停牌导致的空缺”,如果是长期停牌,直接填充会污染特征。日线建模一般不需要补休市日,所以直接删除缺失行更干净。
标准化这一步非常关键,而且特别容易踩坑。LSTM 的激活函数对输入范围比较敏感,如果直接把几千点的股价塞进去,数值会很大,训练初期很容易震荡。我习惯用 MinMaxScaler 把特征缩放到 [-1,1] 区间。不过最大的坑在于:scaler 千万不能对全部数据一起 fit。假设你用全量数据算最大值和最小值,那测试集的信息就已经提前暴露给了训练过程,这种“未来信息泄漏”会让评测结果虚高。正确做法是只对训练集 fit,然后再 transform 验证集和测试集。下面这段代码体现了这个思路:
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(-1, 1)) scaled_train = scaler.fit_transform(train_features) scaled_val = scaler.transform(val_features) scaled_test = scaler.transform(test_features)如果你用的是收益率特征,标准化时还要注意:停牌或无交易的日子里收益率会出现 0 值,这通常代表“价格没变”,属于有效信息,不要顺手删掉。整个数据流程里,我反复提醒自己只有一句话:任何计算都不能用到“当前时间点之后”的数据,scaler 是第一个高发区,后面特征工程里还有第二个。
3. 特征工程与滑动窗口样本生成
3.1 到底该给模型喂什么特征
股价建模中最容易走极端的地方就是特征工程。一来就堆几十个指标的情况我见过很多,MACD、KDJ、RSI、布林带、成交量均线全上,结果模型过拟合严重,验证集上越来越差。我的思路是从少到多,先保证核心特征干净、稳定,再逐步验证加特征是否真的能提升验证集表现。基准特征我会选以下这些:
- 收盘价、开盘价、最高价、最低价:价格本身,其中收盘价最重要。
- 成交量:反映市场参与度,和价格配合能提供不少信息。
- 简单收益率或对数收益率:比原始价格更平稳,可以缓解股价非平稳带来的训练不稳定。
- 最近 N 日简单移动平均:为模型提供短期趋势背景。
注意不要用手工添加的特征去“剧透”未来,比如用了未来 5 日才知道的均线当特征。时刻检查每个特征在第 t 天是否只用到 t 日及之前的信息。这个检查习惯比模型本身更能决定项目成败。
3.2 滑动窗口怎么切,窗口长度怎么定
RNN 输入是一段连续序列,因此要把原始表数据切成窗口样本。假设目标变量是下一交易日的收益率,窗口长度设为 30 天,那么第 1 到第 30 天的特征构成第一个样本,第 31 天的收益率是标签;第 2 到第 31 天的特征构成第二个样本,第 32 天的收益率是标签,以此类推。这个滑动窗口的作用,相当于告诉模型:“根据过去一个月的数据,推断明天”。
import numpy as np def create_sequences(features, target, window=30): X, y = [], [] for i in range(window, len(features)): X.append(features[i - window:i]) y.append(target[i]) return np.array(X), np.array(y)window 怎么选?我个人的经验是:日线数据一般 20 到 60 之间比较合理。窗口太短,模型看不到足够的趋势;窗口太长,除了计算量上升,还可能把很久以前已经失效的形态也塞进来,噪声大于信息。可以先从 30 开始,把它当成超参数,在验证集上比较不同窗口的效果。注意这里的 target[i] 就是第 i 个时间点的未来收益率,因为我们在构造数据时用了 shift 方式把未来值挪到了当前行。
3.3 训练集、验证集、测试集的“安全切分”
金融时间序列的样本切分和普通机器学习完全不同。不能像图像分类那样随机打乱,因为 RNN 的每个样本原本就是连续切片,你一旦 shuffle,等于让模型看到未来时段的信息,测试分数会变得非常好看,但实盘毫无意义。正确做法是按时间顺序切分:例如前 70% 训练、中间 15% 验证、最后 15% 测试。验证集用来早停和超参数选择,测试集最后只碰一次,避免反复在测试集上调参数导致“测试集也被污染”。
这时候又有一个细节:训练、验证、测试三个集合的 scaler 拟合只能以训练集为准,这点在 2.3 里已经强调过。我在第一次做这个项目时,就是先对整个数据集做了 MinMax,再切窗口,结果回测时发现规律全对,但一到实盘就失调。后来才意识到,最大值和最小值是从全量数据里偷来的,未来价格早就被模型“看见”了。另一个可选做法是:每个集合独立生成窗口样本。虽然会舍弃集合开头几十个样本,但能避免跨集合样本纠缠,对验证和测试更安全。
4. LSTM 模型搭建与训练全流程(Python 代码实战)
4.1 模型结构设计:两层 LSTM 加 Dropout
接下来进入正题。我用 Keras Sequential 搭一个两层 LSTM。结构大概是:第一层 LSTM 64 个单元,返回每个时间步的隐藏状态,因为后面还要接第二层 LSTM;第二层 LSTM 32 个单元,只返回最后一个时间步的输出;中间加一个 Dropout(0.2) 防过拟合;最后接一个 Dense 层输出预测值。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout def build_lstm_model(window, n_features): model = Sequential([ LSTM(64, input_shape=(window, n_features), return_sequences=True), Dropout(0.2), LSTM(32, return_sequences=False), Dropout(0.2), Dense(16, activation="relu"), Dense(1) ]) model.compile(optimizer="adam", loss="mse", metrics=["mae"]) return model为什么第一层要 return_sequences=True?因为堆叠 LSTM 时,第一层应该把完整的时间步序列传给第二层,否则第二层拿不到时间结构。最后一层用线性激活输出,因为我们要回归预测连续收益率。如果想要预测涨跌方向,可以改成 Dense(1, activation='sigmoid') + binary_crossentropy,目标则改成 0/1 标签。
4.2 训练配置:早停和学习率衰减是标配
训练这类小规模时序模型,我一直推荐配合 EarlyStopping 和 ReduceLROnPlateau。EarlyStopping 根据验证集损失自动停止训练,防止模型已经过拟合还继续跑;ReduceLROnPlateau 在验证集损失连续几个 epoch 不下降时把学习率调低,帮助模型走出局部震荡。
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau callbacks = [ EarlyStopping(monitor="val_loss", patience=10, 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=100, batch_size=32, callbacks=callbacks, verbose=1 )关于 batch_size 和 epochs:日线样本通常几千条,batch_size=32 足够。epochs 设 100,实际可能几十轮就被早停兜住。学习率直接用 Adam 默认的 0.001,如果你发现损失剧烈震荡,再把初始学习率调低到 1e-4。损失函数我默认用 MSE,如果数据里有比较刺眼的异常值,换成 Huber loss 会更稳,它对异常点的惩罚没那么激进。
4.3 完整可跑的 Python 训练代码
下面给一份相对完整的训练流程,把前面几步串起来。这段代码可以当作你自己的项目模板。这里的做法是:特征里保留价格和成交量,同时加入一个收益率列作为特征;预测目标则用“下一交易日收益率”,这样能直接评估模型的方向判断能力,也方便后面做回测。
import numpy as np import pandas as pd from sklearn.preprocessing import MinMaxScaler from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau # 1. 加载数据 df = pd.read_csv("stock_demo.csv", parse_dates=["date"]) df = df[["date", "open", "high", "low", "close", "volume"]] df = df.sort_values("date").reset_index(drop=True) # 2. 构造特征与目标:预测“下一交易日的收益率” data = df[["open", "high", "low", "close", "volume"]].copy() data["ret"] = data["close"].pct_change().fillna(0) data["ret_future"] = data["ret"].shift(-1) data = data.dropna() feature_cols = ["open", "high", "low", "close", "volume", "ret"] target_col = "ret_future" # 3. 按时间顺序切分 n = len(data) train_end = int(n * 0.7) val_end = int(n * 0.85) train_df = data.iloc[:train_end] val_df = data.iloc[train_end:val_end] test_df = data.iloc[val_end:] # 4. 标准化特征,target 保持原始收益率 scaler = MinMaxScaler(feature_range=(-1, 1)) X_train_raw = scaler.fit_transform(train_df[feature_cols]) y_train = train_df[target_col].values X_val_raw = scaler.transform(val_df[feature_cols]) y_val = val_df[target_col].values X_test_raw = scaler.transform(test_df[feature_cols]) y_test = test_df[target_col].values # 5. 滑动窗口生成样本 WINDOW = 30 def create_sequences(features, target, window=WINDOW): X, y = [], [] for i in range(window, len(features)): X.append(features[i - window:i]) y.append(target[i]) return np.array(X), np.array(y) X_train, y_train = create_sequences(X_train_raw, y_train) X_val, y_val = create_sequences(X_val_raw, y_val) X_test, y_test = create_sequences(X_test_raw, y_test) # 6. 模型构建与训练 model = Sequential([ LSTM(64, input_shape=(WINDOW, len(feature_cols)), return_sequences=True), Dropout(0.2), LSTM(32, return_sequences=False), Dropout(0.2), Dense(16, activation="relu"), Dense(1) ]) model.compile(optimizer="adam", loss="mse", metrics=["mae"]) model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=32, callbacks=[ EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True), ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=5, min_lr=1e-6) ], verbose=1 ) model.save("lstm_stock_model.h5")这段代码里有几个细节值得说一下。第一,我没有让模型直接回归原始收盘价,而是把预测目标设置成“下一交易日收益率”。因为原始价格序列带有明显趋势,直接回归会让模型更容易学到“今天价格等于昨天价格”这种偷懒规则,导致预测曲线看起来像是把真实曲线往后平移了一天。使用收益率目标之后,目标序列平稳很多,模型被迫去学价格波动模式。第二,训练结束后,最好保存模型和 scaler,后续预测时才能保证一致的特征处理口径。我还会把 history 的 val_loss 画出来,判断早停后模型是否还有优化空间。
5. 预测结果分析、回测与常见坑
5.1 评估指标:RMSE、MAE 与方向准确率
模型训练完,最关心的是它在没见过的测试数据上到底表现如何。因为目标已经是收益率,我们可以直接在收益率刻度上算误差和方向准确率。下面的代码假设你已经得到 y_test 和 pred:
from sklearn.metrics import mean_squared_error, mean_absolute_error import numpy as np pred = model.predict(X_test).flatten() rmse = np.sqrt(mean_squared_error(y_test, pred)) mae = mean_absolute_error(y_test, pred) direction_acc = np.mean(np.sign(y_test) == np.sign(pred)) print("RMSE:", rmse) print("MAE:", mae) print("Direction Accuracy:", direction_acc)RMSE 衡量误差大小,MAE 更抗异常值,方向准确率则直接反映模型对“涨/跌”的判断能力。就股票预测而言,方向准确率往往比数值精度更有现实意义:就算你预测的涨幅偏小,只要方向一直对,策略就有跟踪价值。如果方向准确率稳定在 52%~55% 以上,在扣除交易成本后可能还有统计优势;低于 50% 则说明模型还没比抛硬币强。我一般先看方向准确率,再看 RMSE,这两个指标组合起来能更快暴露模型在“判断方向”和“估计幅度”上的短板。
5.2 简单回测模拟:只当模型体检,别当赚钱机器
评估指标是一回事,我更推荐把预测结果放到一个最简单的模拟策略里看收益曲线。假设模型预测下一天的收益率大于 0,就持有标的,否则空仓观察。为了避免把问题复杂化,这里直接使用测试集里的真实收益率 y_test 和预测收益率 pred,只观察方向是否有效:
import numpy as np test_ret = y_test position = np.where(pred > 0, 1, 0) # 简单策略:预测上涨就持有,预测下跌就空仓 strategy_ret = test_ret * position # 对比:一直持有的收益曲线 buy_hold_ret = test_ret strategy_nav = (1 + strategy_ret).cumprod() buy_hold_nav = (1 + buy_hold_ret).cumprod()注意,这里完全忽略了交易手续费、滑点、涨跌停无法成交等因素,所以并不是一套可直接上线的策略逻辑。我通常把这段代码当成“模型方向判断能力的体检”:如果策略净值没有明显超过买入持有,就应该回到特征和模型结构上去找原因,而不是修改回测规则让数字变好看。真实回测需要更完整的撮合框架,但那已经是另一个话题了。
5.3 实战里最大的几个坑
| 现象 | 根本原因 | 解决思路 |
|---|---|---|
| 预测曲线比真实曲线滞后一天 | 模型学到“下一时刻≈当前值” | 预测目标改用收益率或差分,避免直接回归原始价格 |
| 训练集效果神好,测试集崩坏 | 未来数据泄漏或过拟合 | 正确切分时间序列、scaler 只 fit 训练集、加早停 |
| 损失曲线震荡剧烈 | 学习率过高、特征量纲差异大 | 降低学习率,检查标准化,减少特征数量 |
| 模型输出接近常数 | 窗口信息不足或标签不平衡 | 调长窗口、用分类建模、检查标签分布 |
第一个问题,滞后现象,是所有做股价回归预测时最经典的症状。当你把预测曲线和真实曲线画在一起,会看到预测走势像真实走势向右平移了一天。原因很简单:RNN 直接学习原始价格时,最容易拟合出的规律是“今天收盘价等于昨天收盘价”,因为市场变化大多数情况相对较小,这个偷懒的解误差极低。要打破它,就得引导模型去预测变化量,这就是我在 4.3 里把目标设成收益率的原因。
第二个问题,数据泄漏,往往发生在你不知道的地方。除了 scaler 要单独 fit,还有一个隐蔽风险:技术指标计算时如果用到了未来窗口数据,比如在第 t 天计算“未来 5 日均线”,那模型就提前看到了未来。所有特征都必须在计算时保证只使用到当前时间点为止的数据。切窗口后也不能跨集合打乱。只要测试集整体比训练集更早或更晚,都会在评估时给出虚假信心。防止数据泄漏应该作为第一优先级检查项。
第三个问题,模型在训练集上表现极好、测试集崩塌,就是过拟合。股票数据本身信噪比非常低,模型容量一大就容易把噪声也背下来。我一般先压到单层 LSTM 加 Dropout 跑一个 baseline,再逐步加层;如果验证集 loss 不下降,加隐藏单元没有意义。早停在金融时序里几乎是必须的。
第四个问题,模型输出接近常数,常见于目标序列高度不平衡或特征没有区分度。比如某段行情几乎全是上涨,模型把所有预测都拉成正数,表面上方向准确率很高,但遇到震荡行情就失灵。这时候可以检查标签分布,使用分类建模并设置合理阈值,或者在损失函数里对不同样本加权。
5.4 把代码封装成可复用模块
项目做完以后,如果只是跑通了一版就扔,下次换数据又得从头整理。我习惯把数据加载、特征构建、窗口生成、模型构建、训练评估拆成几个函数,放到一个文件里。最终你会得到一个类似这样的模块接口:
class StockRNNPipeline: def __init__(self, window=30, lstm_units=64): self.window = window self.lstm_units = lstm_units self.scaler = MinMaxScaler(feature_range=(-1, 1)) def load_data(self, path): ... def prepare(self, df): ... def build_model(self, n_features): ... def fit(self, X_train, y_train, X_val, y_val): ... def predict(self, X): ... def evaluate(self, X_test, y_test): ...封装的好处是,你可以把窗口、LSTM 单元数、层数都变成参数,然后在验证集上做几组对比实验。比如窗口 30 vs 60,单层 LSTM vs 双层 LSTM,预测收益率 vs 预测涨跌方向。把每一次实验的 RMSE、方向准确率和回测净值记录下来,你会对自己手上这份数据的规律有更清楚的认识。我自己的经验是,这种探索过程带给你的收获,远比直接用一个固定模型大得多。
6. 超参数与实验设计:多跑几组才有底
6.1 窗口长度、隐藏单元与层数到底怎么定
很多人喜欢问“到底用多少窗口、几个隐藏单元?”说实话,这个问题没有标准答案,只有相对合适的范围。我的建议是设计一组对照实验,而不是靠感觉定。以日线数据为例,固定其他设置,只改窗口长度:30、45、60;再固定窗口改 LSTM 单元数:32、64、128。每组跑完之后记录 val_loss、方向准确率和训练时间。窗口过小模型学不到趋势,窗口过大训练样本数量减少,序列中的噪声也会变多。单元数则和样本量挂钩,几千条日线数据用 64 到 128 已经足够,往上加只会拖慢训练并增加过拟合风险。我实际跑下来的经验是,窗口 30 加单元 64 通常是一个很稳的起点,再想提升就得靠特征和标签上的改进,而不是死磕模型宽度。
6.2 回归和涨跌分类,该选哪个
我一直建议把预测问题同时用两种方式建模,看哪一种更符合你的业务目标。回归模型预测收益率数值,优点是信息量大,缺点是在噪声高的时候数值不稳;分类模型预测未来一天涨跌方向,优点是更贴近实际交易决策,缺点是把幅度信息丢掉了。做法上,分类目标可以定义为:若未来一日收益率大于 0 则标签为 1,否则为 0。模型输出层改成 sigmoid,损失函数用 binary_crossentropy,评估指标用准确率。很多情况下,分类模型的方向准确率更稳健,也更方便设置交易信号。我在实际项目里经常两个模型都跑,然后在回测阶段对比哪个更能支撑策略逻辑。
6.3 模型保存与持续预测的小提醒
训练完成后的模型除了保存权重,还要把 scaler 保存下来。预测新一天的数据时,取出最近 30 天的特征序列,用同一个 scaler 做 transform,经过相同窗口顺序输入模型。增量预测最需要警惕的是:scaler 和模型窗口必须与训练时一致,否则输出没有任何意义。此外,不要把历史预测结果反复加入训练数据,除非你能保证新数据标签已经真实生成,并且仍按照时间顺序划分验证集。我在实际应用中,通常每周重新跑一遍完整流程,而不是频繁在线更新,既省时又能避免模型被短期噪声带偏。
最后说一点个人体会。RNN 股票预测这个项目,最容易被误解的地方就是“训练一个模型然后等着赚钱”。我做完这一整套之后最大的感受是:真正有价值的部分不是模型最终跑出来的数字,而是你被逼着去理解时间序列的平稳性、数据泄漏、特征时效、回测偏差这些概念。如果你能顺利用 Python 把 RNN 用在股票数据上,并且看得懂为什么预测曲线会滞后、为什么回测结果不可靠,那这个项目就已经值回票价了。后续你可以往两个方向扩展:一是引入更多真正有意义的基本面和资金流特征,二是尝试 Transformer 这类更现代的序列模型。但无论模型怎么变,数据准备和评判习惯永远是第一位的。