☰
基于RNN的股票时间序列预测:从数据预处理到GRU模型实战
2026/10/20 7:01:53 网站建设 项目流程

1. 为什么用RNN做股票时间序列预测

1.1 从"看K线"到"喂数据"的思维转变

很多人第一次接触股票预测,脑子里想的都是"明天涨还是跌"。但真到了写代码这一步,你会发现这个问题没法直接回答——因为股票价格不是孤立的一个数字,它是一串前后关联的数据点。今天的收盘价和昨天、前天、甚至上个月的价格都有关系,这种"前后依赖"的特性,正是时间序列数据的核心特征。

传统机器学习方法比如线性回归、SVM,处理这类数据时有个天然短板:它们假设样本之间是独立的。你把过去30天的价格打平成一个向量喂进去,模型根本不知道第1天和第2天谁在前谁在后。而RNN(循环神经网络)的设计初衷就是处理序列数据,它内部有一个"记忆单元",每一步的计算都会把上一步的隐藏状态带进来。用大白话说,RNN看数据是"按顺序看"的,而不是"一把抓"。

我最初做这个项目的时候,试过用简单的全连接网络预测收盘价,结果模型在训练集上表现还行,一到测试集就崩了。后来换成RNN结构,同样的特征、同样的数据量,测试集上的误差直接降了将近40%。这不是说RNN有多神,而是它的结构天然适配时间序列的"顺序依赖"。

1.2 股票预测到底在预测什么

这里得先把目标说清楚。股票时间序列预测不是一个单一任务,它至少包含几种不同的目标:

  • 价格回归:预测未来某一天的收盘价、开盘价或最高最低价,输出是一个连续数值。
  • 涨跌分类:预测明天是涨还是跌,输出是一个二分类标签。
  • 多步预测:预测未来N天的价格序列,输出是一个向量。

这个项目做的是第一种——价格回归。为什么选这个?因为回归任务的损失函数(比如MSE)能给出连续的误差反馈,模型训练过程更稳定,也更容易观察收敛情况。分类任务虽然看起来更"实用",但股票涨跌本身噪声极大,二分类的准确率很难稳定超过55%,做出来容易让人失望。

提示:如果你是第一次做时间序列项目,建议从单变量价格回归入手,不要一上来就搞多变量多步预测,否则调试成本会高得让你怀疑人生。

1.3 RNN、LSTM、GRU怎么选

TensorFlow里实现循环神经网络,最常用的三种结构是SimpleRNN、LSTM和GRU。我三个都跑过,说说实际感受:

结构参数量训练速度长序列表现适用场景
SimpleRNN最少最快差(梯度消失严重)序列长度<20的简单任务
LSTM最多最慢好序列长度>50,需要长期记忆
GRU中等中等较好大多数场景的默认选择

SimpleRNN的问题在于梯度消失。当序列长度超过20步,早期时间步的梯度传到后面基本就没了,模型学不到长期依赖。股票数据我一般用60天作为窗口,这个长度下SimpleRNN基本废掉。

LSTM通过门控机制(输入门、遗忘门、输出门)解决了梯度消失问题,但参数量大,训练慢。GRU是LSTM的简化版,把三个门合并成两个(重置门和更新门),参数量少了约25%,效果在大多数任务上和LSTM差不多。

我的建议是:先用GRU跑通流程,如果效果不理想再换LSTM。这个项目最终用的也是GRU,因为它在我的数据集上收敛更快,验证集误差和LSTM差距在1%以内。

2. 数据准备与特征工程

2.1 数据获取与清洗

股票数据来源很多,我用的是某开源金融数据接口(具体名字不提,避免广告嫌疑),拉取某只股票过去5年的日线数据。数据字段包括:日期、开盘价、最高价、最低价、收盘价、成交量。

拿到数据后第一件事不是急着建模,而是检查数据质量。我踩过的坑包括:

  • 停牌日缺失:股票停牌期间没有交易数据,日期不连续。处理方式是直接用前一天的收盘价填充,或者直接删除这些日期。
  • 除权除息导致的价格跳变:分红送股后价格会突然"跳水",这不是真实下跌。必须使用前复权数据,否则模型会学到错误的模式。
  • 异常值:比如某天成交量突然放大100倍,可能是数据接口错误。我用3倍标准差法识别并修正了这些点。
import pandas as pd import numpy as np # 假设df是从接口拉取的数据 df = df[['date', 'open', 'high', 'low', 'close', 'volume']].copy() df['date'] = pd.to_datetime(df['date']) df = df.sort_values('date').reset_index(drop=True) # 检查缺失 print(df.isnull().sum()) # 前向填充停牌日 df = df.fillna(method='ffill') # 异常值处理:收盘价超过3倍标准差的点用中位数替换 close_mean = df['close'].mean() close_std = df['close'].std() df.loc[np.abs(df['close'] - close_mean) > 3 * close_std, 'close'] = df['close'].median()

2.2 归一化:为什么必须做,怎么做

股票价格动辄几十上百,而成交量可能是几百万。如果不做归一化,模型在计算梯度时会被大数值主导,小数值特征几乎不起作用。更严重的是,不同股票价格量级不同,归一化后模型才能泛化。

我试过两种归一化方法:

  • Min-Max归一化:把数据缩放到[0,1]区间。公式是(x - min) / (max - min)。优点是简单直观,缺点是如果未来出现比训练集最大值还大的价格,归一化后会超出[0,1],模型没见过这种情况。
  • Z-Score标准化:把数据变成均值为0、标准差为1的分布。公式是(x - mean) / std。对异常值更鲁棒,但输出范围不固定。

这个项目用的是Min-Max,因为股票价格有明确的上下界(涨跌停限制),而且我习惯把预测结果反归一化后直接和真实价格对比,Min-Max的反变换更直观。

注意:归一化的参数(min、max)必须只用训练集计算,然后用同样的参数去变换验证集和测试集。如果用了全量数据计算min/max,就造成了数据泄露,测试结果会虚高。

2.3 滑动窗口:把时间序列变成监督学习问题

RNN需要输入是三维张量:(样本数, 时间步长, 特征数)。原始数据是一维序列,必须通过滑动窗口切分成多个样本。

假设我用过去60天的收盘价预测第61天的收盘价,滑动窗口的逻辑是:

  • 样本1:第1-60天 → 预测第61天
  • 样本2:第2-61天 → 预测第62天
  • 样本3:第3-62天 → 预测第63天
  • ...

这样切分后,每个样本的输入是60个连续价格,标签是第61个价格。

def create_sequences(data, window_size): X, y = [], [] for i in range(len(data) - window_size): X.append(data[i:i+window_size]) y.append(data[i+window_size]) return np.array(X), np.array(y) WINDOW_SIZE = 60 X, y = create_sequences(scaled_data, WINDOW_SIZE) print(X.shape) # (样本数, 60, 1) print(y.shape) # (样本数,)

窗口大小怎么选?我试过20、40、60、90、120。20太短,模型学不到趋势;120太长,参数量暴增且容易过拟合。60天大约对应3个月的交易日,覆盖了一个季度财报周期,效果最稳。这个参数没有绝对标准,建议用验证集误差来调。

2.4 训练集、验证集、测试集划分

时间序列数据绝对不能随机划分。因为随机打乱后,未来的数据可能跑到训练集里,模型在训练时"偷看"了未来信息,测试结果毫无意义。

正确做法是按时间顺序切分:

  • 训练集:前70%的数据
  • 验证集:中间15%的数据
  • 测试集:最后15%的数据
train_size = int(len(X) * 0.7) val_size = int(len(X) * 0.15) X_train, y_train = X[:train_size], y[:train_size] X_val, y_val = X[train_size:train_size+val_size], y[train_size:train_size+val_size] X_test, y_test = X[train_size+val_size:], y[train_size+val_size:]

这样切分后,测试集是时间上最靠后的数据,模拟了"用历史预测未来"的真实场景。

3. 模型搭建与训练细节

3.1 TensorFlow中的GRU层配置

TensorFlow 2.x用Keras API搭建模型非常方便。核心代码就几行:

import tensorflow as tf from tensorflow.keras import layers, models model = models.Sequential([ layers.GRU(64, return_sequences=True, input_shape=(WINDOW_SIZE, 1)), layers.Dropout(0.2), layers.GRU(32, return_sequences=False), layers.Dropout(0.2), layers.Dense(16, activation='relu'), layers.Dense(1) ]) model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss='mse', metrics=['mae']) model.summary()

几个关键参数的解释:

  • 第一层GRU的return_sequences=True:因为后面还有第二层GRU,需要保留每个时间步的输出。如果只有一层GRU,这个参数设为False。
  • Dropout(0.2):随机丢弃20%的神经元,防止过拟合。股票数据噪声大,Dropout是必须的。
  • Dense(1):输出层只有一个神经元,因为预测的是单个价格值。
  • 损失函数用MSE:回归任务的标准选择。MAE作为监控指标,因为它和价格同量纲,更直观。

3.2 训练过程中的坑与调参经验

坑1:学习率太大导致loss震荡

我一开始用默认的0.01学习率,loss曲线像心电图一样上下跳。后来降到0.001,曲线立刻平滑了。如果还不稳,可以试试0.0005。

坑2:batch_size太小训练慢,太大泛化差

batch_size我试过16、32、64、128。16太慢,一个epoch要跑好几分钟;128虽然快,但验证集loss比训练集高很多,明显过拟合。最终选了32,速度和泛化比较平衡。

坑3:epoch太多导致过拟合

股票数据信噪比低,模型很容易记住训练集的噪声。我一般设epochs=100,同时加EarlyStopping:

early_stop = tf.keras.callbacks.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=32, callbacks=[early_stop], verbose=1 )

patience=10的意思是:如果验证集loss连续10个epoch没有下降,就停止训练并恢复到最好的权重。这个策略帮我省了大量调参时间。

3.3 训练过程可视化

训练完后一定要画loss曲线,这是判断模型状态最直接的方式:

import matplotlib.pyplot as plt plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(history.history['loss'], label='Train Loss') plt.plot(history.history['val_loss'], label='Val Loss') plt.xlabel('Epoch') plt.ylabel('MSE Loss') plt.legend() plt.title('Training and Validation Loss') plt.subplot(1, 2, 2) plt.plot(history.history['mae'], label='Train MAE') plt.plot(history.history['val_mae'], label='Val MAE') plt.xlabel('Epoch') plt.ylabel('MAE') plt.legend() plt.title('Training and Validation MAE') plt.show()

健康的曲线应该是:训练loss和验证loss都下降,最后趋于平稳,两条线距离不远。如果训练loss一直降但验证loss开始上升,就是过拟合了,需要加Dropout或减少参数量。

4. 预测结果评估与反归一化

4.1 反归一化:把预测值变回真实价格

模型输出的是归一化后的数值,必须用训练集的min/max反变换回真实价格:

# 假设scaler是之前fit的MinMaxScaler predictions = model.predict(X_test) predictions_real = scaler.inverse_transform(predictions) y_test_real = scaler.inverse_transform(y_test.reshape(-1, 1))

这里有个细节:y_test的shape是(样本数,),反变换前要reshape成(样本数, 1),否则会报错。

4.2 评估指标:MSE、MAE、MAPE哪个更靠谱

我一般同时看三个指标:

  • MSE(均方误差):对大误差惩罚重,适合关注极端错误的场景。
  • MAE(平均绝对误差):直接反映预测价格和真实价格的平均差距,单位是元,最直观。
  • MAPE(平均绝对百分比误差):消除了价格量级的影响,适合比较不同股票的预测效果。
from sklearn.metrics import mean_squared_error, mean_absolute_error mse = mean_squared_error(y_test_real, predictions_real) mae = mean_absolute_error(y_test_real, predictions_real) mape = np.mean(np.abs((y_test_real - predictions_real) / y_test_real)) * 100 print(f'MSE: {mse:.4f}') print(f'MAE: {mae:.4f}') print(f'MAPE: {mape:.2f}%')

在我的测试集上,MAE大约是2.3元,MAPE约3.5%。这意味着预测价格和真实价格平均差3.5%,对于日线级别的价格预测来说,这个误差是可以接受的。

4.3 可视化对比:预测曲线vs真实曲线

数字指标不够直观,一定要画图:

plt.figure(figsize=(14, 6)) plt.plot(y_test_real, label='True Price', color='blue', alpha=0.7) plt.plot(predictions_real, label='Predicted Price', color='red', alpha=0.7) plt.xlabel('Time Step') plt.ylabel('Price') plt.legend() plt.title('Stock Price Prediction: True vs Predicted') plt.show()

如果预测曲线整体滞后于真实曲线,说明模型倾向于"跟随"而不是"领先"。这是RNN做价格预测的常见现象——它学到了趋势,但拐点处反应慢。解决办法是加入更多特征(比如成交量、技术指标),或者改用多变量输入。

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

5.1 模型预测值几乎不变怎么办

这是新手最常遇到的问题:不管输入什么,模型输出都接近同一个值。原因通常是:

  • 学习率太小:模型还没学到东西就停了。试试把学习率调大10倍。
  • 归一化有问题:检查归一化后的数据是否都在合理范围,有没有NaN。
  • 窗口大小不合适:窗口太短,模型看不到趋势;窗口太长,梯度传不回去。
  • 数据泄露:如果归一化用了全量数据,模型可能"偷看"了测试集,导致训练时表现异常。

我的排查顺序是:先检查数据(有没有NaN、归一化是否正确),再检查模型结构(参数量是否太少),最后调学习率和窗口大小。

5.2 验证集loss比训练集高很多

这是典型的过拟合。解决方案按优先级排序:

  1. 加Dropout:在GRU层后面加Dropout(0.2~0.5)。
  2. 减少参数量:把GRU单元数从128降到64或32。
  3. 加L2正则化:在GRU层里设kernel_regularizer=tf.keras.regularizers.l2(0.01)。
  4. 早停:用EarlyStopping,别让模型跑完所有epoch。
  5. 增加数据量:如果条件允许,拉取更长时间的历史数据。

我一般先加Dropout,如果还不行就减参数量。股票数据本身噪声大,模型太复杂反而学不到真东西。

5.3 预测曲线整体滞后

前面提过,RNN预测价格时容易滞后。除了加特征,还有一个技巧:预测收益率而不是价格。

价格序列是非平稳的,均值会随时间漂移。但收益率(今天价格/昨天价格 - 1)通常是平稳的,模型更容易学到模式。预测出收益率后,再乘回昨天的价格得到预测价格。

# 计算收益率 df['return'] = df['close'].pct_change().dropna() # 用收益率做训练 # 预测时:predicted_price = yesterday_price * (1 + predicted_return)

这个方法我实测有效,滞后现象明显减轻,MAPE从3.5%降到了2.8%。

5.4 常见问题速查表

问题现象可能原因解决方法
loss不下降学习率太小、数据未归一化调大学习率、检查归一化
loss震荡学习率太大、batch_size太小降低学习率、增大batch_size
验证loss远高于训练loss过拟合加Dropout、减参数、早停
预测值几乎不变模型没学到东西检查数据、调学习率、换窗口
预测曲线滞后RNN固有特性预测收益率、加多变量特征
反归一化后数值异常scaler用错或reshape错误检查scaler是否只用训练集fit

6. 模型优化与扩展方向

6.1 多变量输入:加入成交量和技术指标

单变量(只用收盘价)的信息量有限。加入成交量、MACD、RSI等技术指标后,模型能看到更多维度:

# 计算技术指标 df['ma5'] = df['close'].rolling(5).mean() df['ma20'] = df['close'].rolling(20).mean() df['volume_change'] = df['volume'].pct_change() # 选择特征列 features = ['close', 'volume', 'ma5', 'ma20'] data = df[features].dropna().values # 归一化后切窗口 # X的shape变成 (样本数, 窗口大小, 特征数)

多变量输入时,input_shape要改成(WINDOW_SIZE, 特征数)。我试过加入5个技术指标,MAPE从3.5%降到了3.1%,提升不算巨大但稳定。

6.2 堆叠更多RNN层

两层GRU是基础配置,如果数据量足够,可以堆到三层:

model = models.Sequential([ layers.GRU(128, return_sequences=True, input_shape=(WINDOW_SIZE, 1)), layers.Dropout(0.3), layers.GRU(64, return_sequences=True), layers.Dropout(0.3), layers.GRU(32, return_sequences=False), layers.Dropout(0.2), layers.Dense(1) ])

但层数越多,过拟合风险越大。我的经验是:数据少于2000个样本时,两层就够了;超过5000个样本可以试试三层。

6.3 双向RNN与注意力机制

双向RNN(Bidirectional)会同时从正向和反向读取序列,理论上能捕捉更全面的上下文。但股票预测是单向的——你不可能用明天的数据预测今天。所以双向RNN在股票预测里要慎用,容易造成数据泄露。

注意力机制(Attention)是另一个方向。它能让模型自动关注序列中最重要的时间步,而不是平均对待所有历史。TensorFlow里可以用layers.Attention实现,但代码复杂度会上升不少。如果你的基础模型MAPE已经低于3%,再上注意力机制性价比不高。

6.4 模型保存与加载

训练好的模型要保存下来,方便后续使用:

# 保存 model.save('stock_gru_model.h5') # 加载 loaded_model = tf.keras.models.load_model('stock_gru_model.h5') predictions = loaded_model.predict(X_test)

注意:保存模型时,归一化的scaler参数也要一起保存(可以用joblib),否则加载后没法反归一化。

import joblib joblib.dump(scaler, 'scaler.pkl') # 加载时 scaler = joblib.load('scaler.pkl')

7. 实操心得与避坑清单

7.1 数据质量决定上限

我做过一个对比实验:同样的模型结构,用清洗过的数据(处理了停牌、除权、异常值)和未清洗的数据,测试集MAPE差了将近1.5个百分点。模型再牛,也救不了脏数据。所以数据清洗的时间应该占总项目时间的40%以上,这个投入绝对值得。

7.2 不要迷信单次训练结果

神经网络的初始化是随机的,同样的代码跑两次,结果可能差不少。我一般会跑5次,取验证集MAPE的中位数作为最终结果。如果5次结果方差很大,说明模型不稳定,需要调结构或加正则化。

results = [] for i in range(5): model = build_model() history = model.fit(X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=32, callbacks=[early_stop], verbose=0) pred = model.predict(X_test) mape = np.mean(np.abs((y_test_real - pred) / y_test_real)) * 100 results.append(mape) print(f'5次MAPE: {results}') print(f'中位数: {np.median(results):.2f}%')

7.3 预测窗口与交易策略的匹配

60天窗口适合日线级别的短期预测。如果你做的是周线或月线,窗口要相应放大。我试过用60天窗口预测周线数据,效果很差,因为窗口覆盖的时间跨度太短,模型看不到足够的趋势信息。

一般来说,窗口大小应该覆盖至少一个完整的价格周期。股票价格周期通常是20-60个交易日,所以窗口设在40-80之间比较合理。

7.4 别把预测当交易信号

这是最重要的一条心得。RNN预测的是价格趋势,不是买卖信号。模型说"明天价格可能是105元",不代表你应该买入。真实交易还要考虑手续费、滑点、仓位管理、止损策略。我见过太多人拿预测模型直接做交易,结果亏得一塌糊涂。

模型的价值在于辅助判断,比如:当模型预测价格明显偏离当前趋势时,提醒你关注是否有基本面变化。把它当成一个"提醒工具",而不是"印钞机"。

7.5 持续监控与再训练

股票市场风格会变。2020年有效的模型,2023年可能就失效了。我一般每季度用最新数据重新训练一次模型,并对比新旧模型在最近一个月数据上的表现。如果新模型误差明显更小,就替换;如果差不多,就继续用旧的,避免频繁切换带来的不确定性。

再训练时,训练集要包含最新的数据,但验证集和测试集也要相应后移。不能只用旧数据训练然后拿新数据测试,那样测试集就变成了"未来数据",评估结果不可信。

7.6 硬件与时间成本

这个项目在普通笔记本上就能跑。我的配置是i7处理器+16G内存+无独立显卡,训练一个两层GRU模型(约5万参数)100个epoch大约需要8-10分钟。如果数据量翻倍或者层数增加,时间会线性增长。用GPU能快3-5倍,但不是必须的。

如果训练时间太长,可以减小batch_size或者减少epochs,但要注意别牺牲太多精度。我的经验是:epochs从100降到50,MAPE可能只差0.2个百分点,但训练时间减半,性价比很高。

7.7 代码组织建议

最后说下代码结构。我习惯把项目分成几个独立模块:

  • data_loader.py:数据拉取和清洗
  • feature_engineer.py:归一化和滑动窗口
  • model.py:模型定义
  • train.py:训练和评估
  • predict.py:加载模型做预测

这样拆分的好处是,调参时只需要改model.py或train.py,不用动数据处理的代码。而且每个模块可以单独测试,排查问题更快。

我在实际项目中发现,把数据预处理和模型训练分开后,调试效率至少提升了一倍。以前改一个参数要重跑整个流程,现在数据预处理的结果可以缓存成npy文件,直接加载,省了大量时间。

# 缓存预处理结果 np.save('X_train.npy', X_train) np.save('y_train.npy', y_train) # 下次直接加载 X_train = np.load('X_train.npy') y_train = np.load('y_train.npy')

这个技巧在调参阶段特别有用,强烈建议你试试。

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

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

立即咨询