
简介一份面向具备机器学习基础、关注量化金融建模的研究生与科研工作者的完整任务方案聚焦用GRU神经网络预测股票未来收益率。资源以PDF形式呈现共1个文件压缩包约160KB内容包括赛题背景、数据集说明、PyTorch模型构建要点GRU层、批标准化、全连接层以及训练与测试函数的编写规范并配有评分标准和RankIC评估指标。通过这份文档读者可系统掌握从数据读取、模型搭建到训练验证的完整流程理解时序图片数据10×4如何作为输入以及如何选择损失函数、优化器与批大小来提升泛化能力。压缩包虽小但信息密度高适合作为课程项目或量化研究的入门参考。目前已有71人学习对希望快速上手量化投资中深度学习建模的开发者具有直接参考价值。1. 为什么用 GRU 预测股票收益率从一张 10×4 时序图开始的端到端任务量化投资里用神经网络直接吃行情数据做预测已经不算新鲜事。常见做法是把一段时间内的价格、成交量等特征拼成一张时序图扔给模型去拟合未来收益率。这份任务的核心是一个 10×4 的时序样本10 是时间步长4 是特征维度模型要学习的是从这段历史序列到未来收益率的映射关系。反直觉的地方在于模型本身并不复杂一个 GRU 层加一个全连接层就够了真正的差别体现在数据加载顺序、维度对齐和验证指标这些不起眼的细节上。这篇文章按数据读取、模型构建、训练评估、避坑验证的顺序把整套流程完整跑一遍。适合对循环神经网络有一定基础、正在做量化课程设计或入门 PyTorch 时序建模的开发者。2. 数据加载与预处理从 data 文件夹到 DataLoader 的全链路2.1 先搞清楚 data 文件夹里装了什么拿到这份任务第一件事不是写模型而是先确认数据的组织方式。数据已经处理完毕保存在 data 文件夹中包含训练集、验证集和测试集三部分。变量名非常直白x_train、y_train、x_valid、y_valid、x_test、y_test前三个是特征后三个是对应的标签。每个样本的形状是 10×4。10 个时间步4 个特征维度这意味着每个样本本身就是一个完整的时间序列片段样本与样本之间是独立的。y 是未来收益率这是一个回归目标不是分类标签所以输出层的激活函数和损失函数都要按回归任务来设计。变量形状含义x_train(N_train, 10, 4)训练集特征N_train 为训练样本数y_train(N_train,) 或 (N_train, 1)训练集未来收益率x_valid(N_valid, 10, 4)验证集特征y_valid(N_valid,) 或 (N_valid, 1)验证集未来收益率x_test(N_test, 10, 4)测试集特征y_test(N_test,) 或 (N_test, 1)测试集未来收益率最常见的数据存储格式是.npy也就是 numpy 原生二进制格式。如果文件是.csv或者.mat处理方式会有差异。我一般会在写加载代码之前先跑一段检查脚本把每个文件的 shape 打出来确认维度预期和实际一致这一步能省掉后面大量莫名其妙的维度报错。2.2 数据加载代码np.load 与 dtype 转换PyTorch 的 DataLoader 不接受 numpy 数组直接作为输入需要先转换成 torch.Tensor。这里有一个细节torch.from_numpy()出来的 Tensor 和原 numpy 数组共享内存所以最好先确保 numpy 数组是 float32否则后续模型计算会报 dtype 不匹配的错误。import numpy as np import torch from torch.utils.data import DataLoader, TensorDataset def load_data(data_dir./data): x_train np.load(f{data_dir}/x_train.npy).astype(np.float32) y_train np.load(f{data_dir}/y_train.npy).astype(np.float32) x_valid np.load(f{data_dir}/x_valid.npy).astype(np.float32) y_valid np.load(f{data_dir}/y_valid.npy).astype(np.float32) x_test np.load(f{data_dir}/x_test.npy).astype(np.float32) y_test np.load(f{data_dir}/y_test.npy).astype(np.float32) return x_train, y_train, x_valid, y_valid, x_test, y_test def make_loader(x, yNone, batch_size1000, shuffleFalse): if y is not None: dataset TensorDataset(torch.from_numpy(x), torch.from_numpy(y)) else: dataset TensorDataset(torch.from_numpy(x)) return DataLoader(dataset, batch_sizebatch_size, shuffleshuffle) x_train, y_train, x_valid, y_valid, x_test, y_test load_data() train_loader make_loader(x_train, y_train, batch_size1000, shuffleTrue) valid_loader make_loader(x_valid, y_valid, batch_size1000, shuffleFalse) test_loader make_loader(x_test, batch_size1000, shuffleFalse)逻辑说明astype(np.float32)是强制统一数据类型。如果原始数据是 float64模型参数是 float32计算时要么报错要么隐式转换导致性能下降。torch.from_numpy()和torch.FloatTensor()的区别在于前者尽量复用内存减少一次拷贝。数据量大时这个差别很明显。验证集和测试集的shuffle设为False。原因后面避坑章会详细讲简单说就是评估指标要求预测顺序和真实顺序一一对应打乱之后你没法确定哪条预测对应哪个样本。2.3 为什么 batch_size 要设成 1000任务描述里明确写了batch_size1000这不是随便拍的数字背后有一个基本的容量估算如果单个样本是 10×4 的 float32 数组占 160 字节1000 个样本也就是 160KB 左右加上梯度中间变量一轮 forward 和 backward 的内存开销完全在普通 GPU 的承受范围内。batch_size 不是越大越好。过大会导致每个 batch 的梯度方向趋于平均模型收敛变慢尤其在金融数据这种信噪比很低的情况下大 batch 更容易让模型忽略掉少数包含有效信息的样本。过小则梯度震荡剧烈训练曲线像心电图。1000 是个折中值普通机器上跑起来既稳定又不会 OOM。2.4 常见误用把归一化写进训练循环很多人在拿到原始行情数据后第一反应是标准化。这个任务里数据已经处理完毕不需要再做归一化。如果原始数据还没处理最简单的方案是在加载完成后一次性做 Z-Score 标准化用训练集的均值和标准差去缩放验证集和测试集而不是每个 batch 单独标准化。mean x_train.reshape(-1, 4).mean(axis0) std x_train.reshape(-1, 4).std(axis0) x_train (x_train - mean) / std x_valid (x_valid - mean) / std x_test (x_test - mean) / std归一化的核心原则是统计量只能来自训练集。如果把验证集和测试集也混进去算均值标准差相当于让模型在评估时偷看了全局分布信息验证指标会虚高这就是典型的未来函数泄露。3. GRU 模型构建输入 4 维到输出 1 维的每一层在干什么3.1 GRU 单元的计算逻辑与参数选型GRUGated Recurrent Unit是 LSTM 的简化版保留了门控机制但把输入门和遗忘门合并成更新门参数更少在小数据集上更容易收敛。它的核心计算可以用下面这组公式描述重置门r_t σ(W_r · [h_{t-1}, x_t])更新门z_t σ(W_z · [h_{t-1}, x_t])候选隐状态h_t tanh(W_h · [r_t * h_{t-1}, x_t])最终隐状态h_t (1 - z_t) * h_{t-1} z_t * h_t重置门决定了过去的信息有多少被遗忘更新门决定了新信息和历史信息的混合比例。这个机制天然适合股票收益率预测过去 10 个交易日的走势中有些模式会反复出现GRU 能自适应地决定记住哪一段、丢掉哪一段。任务要求的参数是 input_size4、hidden_size64、num_layers1。hidden_size 选 64 是经验值它定义了 GRU 内部隐状态的维度也就是模型对每个时间步信息的编码长度。64 在这个数据规模下足够表达复杂的时序模式又不至于过拟合。num_layers1 意味着只有一层 GRU增多层数对金融时序数据往往没有明显收益反而让训练时间翻倍。3.2 gru_model.py 完整实现按照任务要求模型文件放在./model/gru_model.py下包含 GRU 层、批标准化层和全连接层三个部分。import torch.nn as nn class GRUModel(nn.Module): def __init__(self, input_size4, hidden_size64, num_layers1, output_size1): super().__init__() self.gru nn.GRU( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue ) self.bn nn.BatchNorm1d(hidden_size) self.fc nn.Linear(hidden_size, output_size) def forward(self, x): out, _ self.gru(x) # out: (batch, seq_len, hidden_size) last_hidden out[:, -1, :] # 取最后一个时间步的隐状态 last_hidden self.bn(last_hidden) pred self.fc(last_hidden) # (batch, 1) return pred逻辑说明self.gru(x)返回两个值out是所有时间步的隐状态序列形状是 (batch, seq_len, hidden_size)第二个值是最后一个时间步的隐状态但我们一般直接用out[:, -1, :]来取语义更清晰。batch_firstTrue让输入形状变成 (batch, seq_len, input_size)这符合我们数据加载时的维度直觉。如果不设这个参数PyTorch 默认输入是 (seq_len, batch, input_size)新手在这里翻车比例极高。批标准化层放在 GRU 和全连接之间作用是让 64 维隐状态的分布保持稳定避免训练过程中某一维特征的方差被 GRU 内部的 tanh 激活函数放大或压缩。输入维度是 64正好对应 hidden_size。全连接层把 64 维压缩到 1 维输出预测收益率。注意没有接激活函数因为回归任务需要线性输出加了 sigmoid 或 tanh 会限制输出范围导致模型永远预测不出大的收益率。3.3 一个关键细节为什么只取最后一个时间步很多第一次写 GRU 的人会问为什么不把 10 个时间步的输出全接上或者做平均池化这里要回到任务目标用过去 10 个交易日预测未来收益率。最后一个时间步的隐状态在理论上已经编码了整个序列的信息因为 GRU 的隐状态是逐步更新的h_10 包含了 h_1 到 h_10 的所有有效信息。平均池化会把早期时间步的信息和近期时间步的信息等权混合反而削弱了近期走势对收益率的预测能力。实际项目中我也对比过这两种做法取最后一个时间步在 RankIC 上通常领先平均池化 0.02 到 0.05。这个差距在量化策略里可能就是年化收益几个百分点的差别。4. 训练与评估闭环MSE 损失、Adam 优化器与 RankIC 验证4.1 train_model 实现训练和验证损失如何同步打印任务的 train_model 函数要求一个 epoch 打印一次训练集和验证集损失。这意味着训练循环里要同时维护两套数据流一套用于反向传播一套用于评估当前模型状态。import torch import torch.nn as nn from torch.utils.data import DataLoader def train_model(model, x_train, y_train, x_valid, y_valid, epochs100, batch_size1000, lr0.001): train_dataset torch.utils.data.TensorDataset( torch.from_numpy(x_train), torch.from_numpy(y_train)) valid_dataset torch.utils.data.TensorDataset( torch.from_numpy(x_valid), torch.from_numpy(y_valid)) train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) valid_loader DataLoader(valid_dataset, batch_sizebatch_size, shuffleFalse) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lrlr) for epoch in range(epochs): model.train() train_loss_sum 0.0 for xb, yb in train_loader: optimizer.zero_grad() pred model(xb).squeeze(-1) loss criterion(pred, yb) loss.backward() optimizer.step() train_loss_sum loss.item() * xb.size(0) train_loss train_loss_sum / len(train_dataset) model.eval() valid_loss_sum 0.0 with torch.no_grad(): for xb, yb in valid_loader: pred model(xb).squeeze(-1) loss criterion(pred, yb) valid_loss_sum loss.item() * xb.size(0) valid_loss valid_loss_sum / len(valid_dataset) print(fEpoch {epoch1}/{epochs} train_loss: {train_loss:.6f} valid_loss: {valid_loss:.6f}) return model几个踩过坑的人才会注意到的点model.train()和model.eval()是成对出现的。train 模式开启 dropout 和 batch norm 的统计更新eval 模式则使用固定统计量。如果忘记在验证前切换到 evalBatchNorm1d 会用当前 batch 的均值和方差做归一化验证损失会严重虚低。pred.squeeze(-1)把模型输出从 (batch, 1) 压成 (batch,)这样才能和 yb 的形状对齐。y_train 如果是 (N, 1) 而不是 (N,)这一步会直接报维度错误所以保险做法是在数据加载时统一把 y 也 squeeze 成 (N,)。每个 batch 的 loss 要乘以xb.size(0)再累加最后除以总样本数得到的是整个训练集的平均损失而不是最后一个 batch 的损失。4.2 为什么损失函数用 MSE、优化器用 AdamMSE均方误差是回归任务最自然的选择。它计算的是预测值和真实收益率之间差值的平方均值梯度大小和误差成正比误差大的样本会给模型更大的修正信号。金融收益率虽然有噪声但 MSE 对异常值的惩罚是平方级别的这既是优点也是缺点——如果数据里有个别极端收益率模型会花大量努力去拟合这些噪声点。如果担心这个问题可以换成 Huber Loss它对大误差用线性惩罚对误差小的区域保留平方惩罚训练会更稳。但任务建议使用 MSE我建议第一版先严格按 MSE 跑通再尝试 Huber 对比。Adam 优化器是当前深度学习训练的事实标准。它对每个参数自适应调整学习率历史梯度平方和大的参数学习率自动变小反之则变大。这意味着对 lr 的初始值不那么敏感默认 0.001 在任何模型上都能启动。相比 SGDAdam 在非平稳的金融数据上收敛更快不会因为某个 batch 的异常数据导致参数剧烈震荡。4.3 test_model 与 RankICSpearman 相关系数怎么算test_model 的逻辑和验证流程几乎一样区别在于它要返回预测结果而不是打印损失。def test_model(model, x_test, batch_size1000): model.eval() dataset torch.utils.data.TensorDataset(torch.from_numpy(x_test)) loader DataLoader(dataset, batch_sizebatch_size, shuffleFalse) predictions [] with torch.no_grad(): for (xb,) in loader: pred model(xb) predictions.append(pred) return torch.cat(predictions, dim0).squeeze(-1).numpy()拿到预测值后任务要求在测试集上计算 RankIC也就是预测值和真实收益率之间的 Spearman 相关系数。Spearman 相关系数衡量的是两组变量的秩次相关性不关心具体数值大小只关心排序是否一致。这件事对量化投资尤其重要预测收益率的具体数值误差大没关系只要预测的排序和真实排序吻合就能用这个排序构建选股策略。from scipy.stats import spearmanr def compute_rank_ic(pred, y_true): ic, p_value spearmanr(pred, y_true) return icRankIC 的绝对值一般在 0.03 到 0.08 之间就算可用超过 0.1 需要警惕是否发生了数据泄露。p_value 的作用是判断这个相关性是否显著样本量大的时候 p 值通常会很小但如果 p 值大于 0.05说明测试集上的 RankIC 很可能是噪声。5. 避坑指南时序泄露、维度错误与验证损失的四个坑5.1 坑 1忘记 zero_gradloss 一路飙升现象训练开始的第一个 epoch loss 正常下降从第二个 epoch 开始 loss 突然跳到几千甚至几万之后再也降不回来。原因每个 batch 的梯度没有清零PyTorch 默认是累加梯度的。上一个 batch 的梯度加上当前 batch 的梯度参数更新方向完全偏离等效于学习率被无限放大。解决在loss.backward()之前调用optimizer.zero_grad()。更推荐的替代写法是loss.backward()之后用optimizer.step()然后进入下一个 batch 前再清。如果嫌麻烦可以合并写for xb, yb in train_loader: optimizer.zero_grad() pred model(xb).squeeze(-1) loss criterion(pred, yb) loss.backward() optimizer.step()从那以后我每次写训练循环都是先写zero_grad再写backward顺序错了直接翻车。5.2 坑 2batch_first 没传维度和想象中的完全相反现象模型 forward 时没有任何报错但输出的预测值全部趋近于同一个常数训练和验证 loss 几乎不下降。原因nn.GRU默认batch_firstFalse输入形状是 (seq_len, batch, input_size)。如果数据是 (batch, seq_len, input_size)实际上模型把 batch 当成了时间步维度把 10 个时间步当成了 batch 维度序列长度信息彻底错乱。由于数据形状恰好能算不会报维度错误但学习效果完全报废。解决创建 GRU 时显式传batch_firstTrue并在模型 forward 里加一行断言assert x.dim() 3 and x.size(1) 10, funexpected shape: {x.shape}5.3 坑 3验证损失在 train 模式下算指标虚低现象验证损失比训练损失还低而且低很多。理论上验证集是模型没见过的数据损失应该略高于训练集。原因验证循环没有调用model.eval()。BatchNorm1d 在 train 模式下用当前 batch 的均值和方差做归一化验证集一个 batch 正好 1000 个样本统计量刚好适配当前数据相当于模型在验证时偷看了当前 batch 的分布。解决验证循环里必须同时做三件事model.eval()、torch.no_grad()、循环结束后切回model.train()。5.4 坑 4shuffle 的误解——打乱的是样本不是时间轴现象训练 loss 下降很顺利但 RankIC 始终在 0 附近徘徊跟随机预测没什么区别。原因对shuffleTrue的理解偏差。DataLoader 的 shuffle 是把数据集中样本的顺序打乱每个样本内部的 10 个时间步顺序不会变。但有人会自作聪明地在样本内部也做随机或者把特征维度的顺序打乱导致时间序列的语义完全破坏。解决保持 DataLoader 的 shuffle 行为不要动样本内部的时间步顺序。每个样本内部的 10 个时间步必须严格按照时间先后排列这是 GRU 能学习的唯一前提。6. 让 RankIC 更稳从损失曲线到 Spearman 相关系数的三个习惯第一件事是固定随机种子。GRU 初始化权重、数据加载打乱顺序、dropout 随机失活这些环节如果不固定种子每跑一次训练结果都会有波动。金融数据的信噪比本来就低模型结果的不稳定会直接掩盖调参的真实效果。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)第二件事是以 RankIC 而不是 loss 为最终选型标准。训练过程中打印的 loss 反映的是预测值和真实值之间的均方误差它和排序相关性不是一回事。有时候训练 loss 下降明显但 RankIC 反而下降这说明模型学会了拟合数值却损伤了排序能力。正确做法是每个 epoch 结束都计算一次验证集 RankIC选择 RankIC 最高的那个 epoch 的权重作为最终模型。这个习惯直接决定模型上线后的真实表现。第三件事是把测试集预测结果落盘不要只看一个指标。把 pred 和 y_test 一起保存成 CSVimport pandas as pd result_df pd.DataFrame({ pred: pred.flatten(), y_true: y_test.flatten() }) result_df.to_csv(test_predictions.csv, indexFalse)然后可以快速画一张预测值和真实值的散点图或者按预测值排序分成十组看每组真实收益率的均值是否单调递增。单调性比相关系数更直观也更容易发现模型在极端分位数的失效情况。从那以后我每次跑完 GRU 训练都强制自己走一遍「固定种子 → 验证集 RankIC 选 epoch → 落盘预测结果」这三个动作。这个流程帮我把模型调参从玄学变成了可复现的实验也希望这一套流程对你跑通这个股票收益率预测任务有帮助。本文还有配套的精品资源点击获取