
简介量化投资中基于GRU的股票收益率预测模型任务指南面向具备机器学习尤其是循环神经网络基础、关注量化金融建模的研究生与科研工作者以Python和PyTorch为工具解决利用10×4时序数据预测股票未来收益率的建模问题。任务描述详细数据已预处理完毕并分为训练、验证与测试集可直接按步骤完成数据读取与模型构建。资源包仅含1个PDF文件大小约160KB内容涵盖从数据读取、模型构建到训练评估的完整说明并附有清晰的任务步骤与评分标准。该PDF具体拆解了x_train、y_train等数据集加载方式GRU层、批标准化层和全连接层设计以及训练函数中的MSE损失、Adam优化器与batch_size1000的DataLoader配置同时说明了逐epoch打印训练和验证损失的要求。测试阶段以RankIC预测值与真实值的Spearman相关系数作为评价指标并给出提交文档规范和评分权重已有71人学习/下载适合作为课程项目作业或研究案例的完整工作流参考。1. GRU股票收益率预测模型为什么我劝你先试 GRU 而不是 LSTM量化投资里最常被问到的问题就是“GRU 怎么用来预测股票收益率”。这个方向我做过不止一版早期用 LSTM参数多、训练慢日线数据只有几千条很容易过拟合换成 GRU 之后同样的数据参数量少了约三分之一收敛明显加快预测效果反而更稳。GRU 是门控循环单元比 LSTM 少一个门专为时间序列设计在收益率预测这种短序列、高噪声场景下非常合适。这篇文章讲的是一条完整落地路径从 tushare 拉行情、计算收益率和技术指标特征到用 PyTorch 搭建 GRU 模型、训练验证再到回测前必须避开的坑。适合有 Python 基础、想自己搭量化预测模型的从业者。你不需要多深的数学功底但需要能跑通基础 PyTorch 环境照着复现你能得到一组可解释的预测结果而不是一个黑匣子。2. 从行情到训练样本数据清洗、收益率标签与时间序列切分2.1 用 tushare 拉日线行情复权、停牌与收益率计算做股票收益率预测第一步不是建模而是把行情数据弄干净。常见做法是用 tushare 的 pro 接口拉日线注册后拿到 token 就能用。我一般用pro.daily()拿基础日线再配合复权因子做前复权处理避免分红、送股造成的价格跳空。指数数据可以直接拉个股数据建议用pro_bar()或手动乘复权因子。import pandas as pd import numpy as np import tushare as ts ts.set_token(你的token) # 在 tushare.pro 注册后获取 pro ts.pro_api() df pro.daily( ts_code600519.SH, start_date20180101, end_date20241231 ) df df.sort_values(trade_date).reset_index(dropTrue) df[trade_date] pd.to_datetime(df[trade_date])这段代码按交易日升序排列trade_date转成 datetime 方便后面按时间切分。注意 tushare 返回的数据是倒序的不排序的话后面构造滑窗样本会把时间顺序彻底搞乱。参数里ts_code对应股票代码start_date和end_date是闭区间覆盖你需要的时间段即可。数据里通常还带着vol、amount后面做成交量类特征会用到。拿到原始行情后先处理缺失值和不连续交易日。停牌日 tushare 不返回记录直接 dropna 即可但要小心如果某只股票停牌很久复牌后的价格跳空会被模型误读为正常波动最好把停牌超过一定天数的样本直接剔除。2.2 构造训练样本技术指标特征、未来 5 日标签与归一化收益率预测的标签不是“涨/跌”这种分类标签而是未来 N 日的对数收益率。我通常预测未来 5 日累计对数收益率因为它比单日收益噪声小又比 20 日更容易被 GRU 捕获。特征方面除了当日收益还叠加均线、标准差、RSI 等常见技术指标让模型有时间结构可学。df[ret_1] np.log(df[close] / df[close].shift(1)) for w in (5, 10, 20): df[fma{w}] df[close].rolling(w).mean() df[fstd{w}] df[close].rolling(w).std() def calc_rsi(close, n14): diff close.diff() up diff.clip(lower0).rolling(n).mean() down (-diff.clip(upper0)).rolling(n).mean() rsi 100 - 100 / (1 up / down.replace(0, np.nan)) return rsi df[rsi14] calc_rsi(df[close]) df[label] np.log(df[close].shift(-5) / df[close]) df df.replace([np.inf, -np.inf], np.nan).dropna().reset_index(dropTrue)ret_1是当日对数收益率ma5/10/20是不同周期的均线std是滚动标准差rsi14是相对强弱指标。这里的关键是labelclose.shift(-5)取的是未来第 5 天的收盘价除以今天收盘价再取对数就是持有 5 天的对数收益。训练时这个标签没问题但回测时有个大坑——后文避坑章节会专门讲。replace([np.inf, -np.inf], np.nan).dropna()把除零产生的无穷值统一清掉。务必保留这行尤其是计算 RSI 时连续同向行情里 down 可能为 0不处理会污染整个特征矩阵。2.3 按时间切分训练/验证/测试集为什么不能随机 shuffle切分是量化建模里最容易翻车的一步。很多人在 sklearn 里习惯了train_test_split(random_state42)直接把时间序列随机打散——这是对金融数据的误用。收益率样本之间存在先后依赖随机切分会让模型“偷看”未来信息。正确做法是严格按时间顺序切。from sklearn.preprocessing import StandardScaler train_end int(len(df) * 0.7) val_end int(len(df) * 0.9) feature_cols [ret_1, ma5, ma10, ma20, std5, std10, std20, rsi14] scaler StandardScaler().fit(df.iloc[:train_end][feature_cols]) X scaler.transform(df[feature_cols]) y df[label].values X_train, y_train X[:train_end - 20], y[20:train_end] X_val, y_val X[train_end - 20:val_end], y[train_end:val_end]两个细节容易踩坑。第一scaler只对训练段做fit验证集和测试集只transform否则会把验证段的均值方差泄漏进训练导致验证结果虚高。第二切分起点不是 0而是 20——因为后面构造滑窗样本时每个样本需要往前看 20 个交易日前 20 行是造不出完整样本的。时间切好后我建议再检查一下中段和末段的收益分布。如果牛市段全在训练集、熊市段全在测试集模型天然吃亏量化里不追求随机均匀但要清楚知道验证集覆盖了什么市场状态否则模型表现差时你都不知道是模型问题还是行情问题。3. 用 PyTorch 搭 GRU 模型模型结构、数据加载器与训练管线3.1 为什么选 GRU 而不是 LSTM参数量、收敛速度与记忆能力很多教程一上来就上 LSTM但日线级别的收益率预测序列长度通常只有 2060 个交易日LSTM 的记忆优势根本发挥不出来。GRU 把 LSTM 的输入门、遗忘门、输出门合并成两个门重置门和更新门参数量更少过拟合风险更低训练速度更快。在几百个样本的训练集上这个差异非常明显LSTM 可能跑 30 轮才收敛GRU 十几轮就稳定了。GRU 也不是没有短板。它对超参数更敏感尤其是隐层维度hidden_size和层数num_layers。我一般用hidden_size32、num_layers2起步别一上来就 128 维两三层那是给文本模型用的配置在几百行股票数据上只会过拟合。3.2 用 Dataset 写滑窗采样器索引对齐与 batch 组织模型需要的是形如(batch, seq_len, input_size)的输入也就是每个样本包含连续 20 个交易日的特征。PyTorch 里用Dataset封装滑窗逻辑最方便关键是把索引对齐搞清楚。import torch from torch.utils.data import Dataset, DataLoader class StockDataset(Dataset): def __init__(self, X, y, seq_len20): self.X torch.tensor(X, dtypetorch.float32) self.y torch.tensor(y, dtypetorch.float32) self.seq_len seq_len def __len__(self): return len(self.X) - self.seq_len def __getitem__(self, idx): x self.X[idx: idx self.seq_len] y self.y[idx self.seq_len] return x, y train_ds StockDataset(X_train, y_train, seq_len20) val_ds StockDataset(X_val, y_val, seq_len20) train_loader DataLoader(train_ds, batch_size128, shuffleFalse) val_loader DataLoader(val_ds, batch_size128, shuffleFalse)__len__返回len(X) - seq_len是因为最后seq_len行无法形成完整样本。__getitem__里x取idx到idxseq_len的区间y则取窗口后一天的值也就是预测“第 21 天开始的未来 5 日收益”。注意这里shuffleFalse训练时也保持时间顺序防止相邻样本被打乱后模型学到“未来模式”。如果你的机器支持 CUDA可以在训练循环前加一行设备判断device torch.device(cuda if torch.cuda.is_available() else cpu)CPU 也能跑这个小模型只是慢一些但要注意同一份代码在 CPU 和 GPU 上的浮点结果会略有差异这是正常现象不必纠结。3.3 GRU 模型与前向传播取最后一个时间步还是取全部模型结构很直接GRU 编码序列取最后一个时间步的隐状态过一层全连接回归。也可以把 GRU 每个时间步的输出都过全连接再池化但在收益率预测这种短序列任务上最后一个隐状态已经包含了足够的信息多了反而容易过拟合。import torch.nn as nn class GRUModel(nn.Module): def __init__(self, input_size, hidden_size32, num_layers2, dropout0.2): super().__init__() self.gru nn.GRU( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout, ) self.fc nn.Linear(hidden_size, 1) def forward(self, x): out, _ self.gru(x) last out[:, -1, :] # (batch, hidden_size) return self.fc(last).squeeze(-1)batch_firstTrue让输入形状是(batch, seq_len, input_size)这比默认的(seq_len, batch, input_size)直观。out是 GRU 每个时间步的输出形状为(batch, seq_len, hidden_size)取[:, -1, :]就是最后一个时间步的隐状态。_是最后一个时间步的隐状态向量这里没用它因为我们取的是out的最后一行两者等价。dropout0.2只在num_layers 1时生效它加在多层 GRU 的层间不是加在输出层后面。如果只有一层 GRU这个参数会被忽略别以为它自动生效了。3.4 损失函数与优化器MSE、AdamW 与学习率调度收益率预测是回归任务损失函数选 MSE 或 MAE 都行。MSE 对异常值敏感更容易让模型关注极端行情MAE 更稳健但梯度恒定训练后期收敛慢。我习惯先用 MSE如果发现验证集被个别暴涨暴跌样本支配再切 MAE。import torch.optim as optim model GRUModel(input_sizelen(feature_cols)) optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) criterion nn.MSELoss()AdamW 比 Adam 多了权重衰减解耦配合weight_decay1e-4能有效抑制过拟合。学习率从 1e-3 起步用余弦退火在 50 个 epoch 内逐渐降到接近 0。T_max要和总 epoch 数匹配如果你只训练 30 轮T_max就改成 30否则学习率降不到底模型后期容易在小范围内震荡。4. 训练与评估损失曲线、早停机制与预测结果解读4.1 训练循环与梯度裁剪控制 GRU 梯度爆炸GRU 在时间步上做循环梯度会沿时间反向传播序列稍长就容易爆炸。虽然我们只用 20 步但训练初期损失较大时梯度范数可能冲到几十。用clip_grad_norm_把梯度范数限制在 1.0 以内是常见做法能避免训练 loss 突然跳到 NaN。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 for xb, yb in loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss loss.item() * len(xb) return total_loss / len(loader.dataset)每个 batch 里依次做清空梯度、前向计算、算损失、反向传播、裁剪梯度、更新参数。clip_grad_norm_的1.0是 max_norm即梯度范数超过 1.0 就整体缩放回 1.0方向不变步长变小。这个值不是固定死如果你发现训练初期 loss 下降太慢可以试着放到 5.0。4.2 早停与模型保存用验证集 loss 判断而不是训练 loss训练 loss 持续下降、验证 loss 开始回升是过拟合的标准信号。早停就是在验证 loss 连续若干轮不创新低时停止训练并回滚到验证 loss 最低那轮的权重。best_val float(inf) patience, wait 15, 0 best_state None for epoch in range(100): train_loss train_one_epoch(...) model.eval() val_loss 0.0 with torch.no_grad(): for xb, yb in val_loader: xb, yb xb.to(device), yb.to(device) val_loss criterion(model(xb), yb).item() * len(xb) val_loss / len(val_ds) if val_loss best_val: best_val val_loss best_state {k: v.clone() for k, v in model.state_dict().items()} wait 0 else: wait 1 if wait patience: break scheduler.step()patience15表示验证 loss 连续 15 轮没有更低就停。best_state保存的是 val loss 最低时的权重快照clone()是为了防止后续优化器更新把变量带走。很多新手在早停时不保存best_state训练完直接拿最后一轮权重去测结果验证集上表现最好的阶段被错过了。这是从 LSTM 时代就常见的坑GRU 也一样。4.3 用 Rank IC、IC 与方向准确率评估预测质量损失函数低不代表预测有价值。MSE 衡量数值贴近程度但量化选股更关心排序——预测收益高的股票是否真的涨得多。Rank IC 就是 Spearman 秩相关系数取值范围 -1 到 1正且越大说明模型排序能力越强。日频选股模型里 Rank IC 绝对值能稳定在 0.03 以上就已经有实盘参考价值了。from scipy.stats import spearmanr def calc_rank_ic(model, loader, device): model.eval() preds, trues [], [] with torch.no_grad(): for xb, yb in loader: xb xb.to(device) preds.extend(model(xb).cpu().numpy()) trues.extend(yb.numpy()) return spearmanr(trues, preds).correlation train_ic calc_rank_ic(model, train_loader, device) val_ic calc_rank_ic(model, val_loader, device)Rank IC 比 MSE 更能反映模型在横截面上的区分能力。配合方向准确率预测涨跌方向与真实方向一致的样本占比可以避免“数值预测偏平但方向全对”或“数值贴近但排序混乱”的偏差。如果训练集 Rank IC 是 0.1验证集却掉到 0.01模型多半是过拟合了。4.4 画损失曲线和预测对比图横坐标稀疏化与纵轴对齐训练完成后画两张图一张是训练/验证损失曲线一张是验证集预测值与真实值的对比散点。损失曲线能直观看到早停点散点图能看到预测值的分布范围是否合理是否出现系统性偏移。import matplotlib.pyplot as plt plt.figure(figsize(10, 4)) plt.plot(train_losses, labeltrain) plt.plot(val_losses, labelval) plt.legend() plt.grid(True) plt.xticks(range(0, len(train_losses), 5)) # 每 5 轮一个刻度 plt.show()plt.xticks(range(0, len(train_losses), 5))是必须的一步。如果你的 matplotlib 版本较新默认刻度会自动适配但在老版本上100 个 epoch 的横坐标会挤成一片黑这就是网上常说的“python 画图横坐标太密集”问题。散点图建议把预测值和真实值都过一遍np.clip去掉极端值再画否则个别暴涨暴跌样本会让坐标轴拉伸看不出整体分布。5. 量化建模要避开的 5 个坑数据穿越、标签错位与特征漂移5.1 数据穿越训练集和验证集都很漂亮实盘一塌糊涂现象训练集 Rank IC 0.12验证集 Rank IC 0.09看起来非常稳定模型放到每天实盘却几乎不赚钱甚至反向。原因数据穿越也叫未来函数。最常见的三个来源scaler.fit用了全样本的均值和方差特征里包含未来信息切分后没有按时间顺序组织 batch。任何一步都会让模型“偷看”未来训练时表现自然好。解决严格按时间顺序切分scaler只 fit 训练段检查每个特征的构造逻辑确认只用当日及之前的数据训练和验证的DataLoader都保持shuffleFalse。做一个简单测试把标签整体向后平移 3 天再训练如果模型 Rank IC 没有明显下降说明模型学的是滞后信息大概率有数据穿越。5.2 标签与预测错位IC 高得离谱先检查索引现象验证集 Rank IC 高达 0.4但预测值和真实值画出来明显错位了一天方向对但数值延迟。原因Dataset.__getitem__里 x 和 y 的索引不对齐。比如我早期写代码时x 取idx: idxseq_leny 却取y[idx]相当于用“窗口内最后一天”去预测“窗口内第一天”等于把答案提前暴露给了模型。解决打印第一个样本验证对齐关系。x[-1]应该对应y[idxseq_len]前一天的特征y 是 x 结束之后那一天的未来收益。写个断言sample_x, sample_y train_ds[0] assert train_ds.y[train_ds.seq_len] sample_y如果断言失败索引逻辑必然有问题。这是血泪经验每一步索引都值得停下来验证别急着往下跑。5.3 收盘价标签与次日开盘价成交回测收益要打折扣现象模型预测“明天会涨”你用今天收盘价买入回测收益漂亮实盘却拿不到。原因股票当天收盘后你才能算出收盘价特征但 A 股收盘后无法以收盘价成交。等第二天开盘价格已经跳空。如果标签用的是“未来 5 日累计对数收益”回测里默认以当天收盘价买入是用了一个无法实现的成交价。解决回测时把买入价改成次日开盘价或者更严格地用次日 VWAP。另一个办法是直接构造“次日开盘买入、第 6 日开盘卖出”的标签让模型预测的是一个可交易的收益区间。这会让预测难度上升但至少回测结果不会骗人。5.4 忘了 model.eval()验证集 loss 虚高误导早停现象验证 loss 一直震荡不下早停永远触发不了模型训练到后期训练 loss 极低、验证 loss 却反弹。原因训练循环里写了model.train()评估循环里没写model.eval()。带 dropout 的模型在验证时dropout 层仍在随机丢弃节点预测结果带上随机噪声验证 loss 自然偏高且不稳定。解决评估前必须model.eval()并且用torch.no_grad()包住推理循环。反过来说如果你忘了从eval切回train训练也会出问题——dropout 不生效模型变成确定性网络。每次循环开头先写清楚状态切换这个习惯能省下大量排查时间。5.5 特征分布漂移scaler 统计量老化导致预测退化现象模型上线前几个月预测正常之后预测值整体偏移Rank IC 持续走低。原因市场特征分布是漂移的。你用 2018 到 2023 的数据算出均值和方差做标准化到了 2024 年波动率上升价格中枢变化旧 scaler 的统计量已经不适应新数据。解决每隔一段时间滚动重训不只是重训模型也要重新计算 scaler 的统计量。常见做法是每季度滚动一次窗口用过去 250 个交易日的特征分布做标准化再用同样长度的窗口重训模型。如果你发现预测值的均值和你实际观察到的收益均值系统性偏离超过一个标准差大概率就是 scaler 该更新了。6. 从训练到实盘前模型落盘、特征偏移检查与 ONNX 导出模型训练完不等于工作结束落盘和验证还有最后三件事要做。第一把最优权重和 scaler 参数一起保存。best_state里存的是权重scaler 的均值和方差得单独存否则推理时你会忘记当初是用什么分布做的标准化。torch.save(best_state, gru_stock.pth) torch.save({mean: scaler.mean_, scale: scaler.scale_}, scaler.pth)torch.save可以存任意 Python 对象字典尤其方便。恢复时先加载 scaler再加载模型权重顺序别反了。第二检查特征偏移。把验证集最后 20 行和最新真实数据拼接分别过同一个 scaler对比特征均值。如果你的特征ret_1在新数据上的均值是 0.002而训练集是 0.0005说明市场波动环境变了模型上一轮学到的东西未必适用。这个检查我习惯做成自动化脚本每天收盘后跑一次指标超过阈值就触发重训提醒。第三如果模型要走实盘建议导出 ONNX。PyTorch 动态图的推理速度在实盘里够用但和 C 或 Java 交易系统对接时ONNX 格式更通用。导出前把模型设成 eval 态用固定长度输入跑一次torch.onnx.export就行。注意 GRU 的动态时间步在 ONNX 里会被固定成你导出时的seq_len如果以后想改序列长度需要重新导出。model.eval() dummy torch.randn(1, 20, len(feature_cols)) torch.onnx.export( model, dummy, gru_stock.onnx, input_names[seq], output_names[pred], dynamic_axes{seq: {0: batch}}, )dynamic_axes让 batch 维度保持可变seq和feature维度固定。ONNX 导出的模型在 CPU 上跑得比 PyTorch 原版稍快更重要的是解耦了训练框架部署时不用在交易机器上装完整 PyTorch。做完这三步模型才算真正“能用”。我自己最深的教训是模型文件丢了可以再训但 scaler 参数丢了整个推理管线就会悄悄失真而且你不会立刻发现直到某天预测值整体漂了才发现是标准化出了问题。所以落盘时权重和 scaler 永远放一起。量化预测没有一劳永逸的模型滚动重训、偏移检测、版本管理缺一不可。这套流程你照着走一遍至少能把 GRU 收益率预测从“实验玩具”推到“可信基线”的位置。希望帮到你。本文还有配套的精品资源点击获取