简介:量化投资中基于GRU的股票收益率预测模型任务指南,面向具备机器学习尤其是循环神经网络基础、关注量化金融建模的研究生与科研工作者,以Python和PyTorch为工具,解决利用10×4时序数据预测股票未来收益率的建模问题。任务描述详细,数据已预处理完毕并分为训练、验证与测试集,可直接按步骤完成数据读取与模型构建。资源包仅含1个PDF文件,大小约160KB,内容涵盖从数据读取、模型构建到训练评估的完整说明,并附有清晰的任务步骤与评分标准。该PDF具体拆解了x_train、y_train等数据集加载方式,GRU层、批标准化层和全连接层设计,以及训练函数中的MSE损失、Adam优化器与batch_size=1000的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_code="600519.SH", start_date="20180101", end_date="20241231" ) df = df.sort_values("trade_date").reset_index(drop=True) 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[f"ma{w}"] = df["close"].rolling(w).mean() df[f"std{w}"] = df["close"].rolling(w).std() def calc_rsi(close, n=14): diff = close.diff() up = diff.clip(lower=0).rolling(n).mean() down = (-diff.clip(upper=0)).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(drop=True)ret_1是当日对数收益率,ma5/10/20是不同周期的均线,std是滚动标准差,rsi14是相对强弱指标。这里的关键是label:close.shift(-5)取的是未来第 5 天的收盘价,除以今天收盘价再取对数,就是持有 5 天的对数收益。训练时这个标签没问题,但回测时有个大坑——后文避坑章节会专门讲。
replace([np.inf, -np.inf], np.nan).dropna()把除零产生的无穷值统一清掉。务必保留这行,尤其是计算 RSI 时,连续同向行情里 down 可能为 0,不处理会污染整个特征矩阵。
2.3 按时间切分训练/验证/测试集:为什么不能随机 shuffle
切分是量化建模里最容易翻车的一步。很多人在 sklearn 里习惯了train_test_split(random_state=42),直接把时间序列随机打散——这是对金融数据的误用。收益率样本之间存在先后依赖,随机切分会让模型“偷看”未来信息。正确做法是严格按时间顺序切。
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,但日线级别的收益率预测,序列长度通常只有 20~60 个交易日,LSTM 的记忆优势根本发挥不出来。GRU 把 LSTM 的输入门、遗忘门、输出门合并成两个门(重置门和更新门),参数量更少,过拟合风险更低,训练速度更快。在几百个样本的训练集上,这个差异非常明显:LSTM 可能跑 30 轮才收敛,GRU 十几轮就稳定了。
GRU 也不是没有短板。它对超参数更敏感,尤其是隐层维度hidden_size和层数num_layers。我一般用hidden_size=32、num_layers=2起步,别一上来就 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_len=20): self.X = torch.tensor(X, dtype=torch.float32) self.y = torch.tensor(y, dtype=torch.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_len=20) val_ds = StockDataset(X_val, y_val, seq_len=20) train_loader = DataLoader(train_ds, batch_size=128, shuffle=False) val_loader = DataLoader(val_ds, batch_size=128, shuffle=False)__len__返回len(X) - seq_len,是因为最后seq_len行无法形成完整样本。__getitem__里x取idx到idx+seq_len的区间,y则取窗口后一天的值,也就是预测“第 21 天开始的未来 5 日收益”。注意这里shuffle=False,训练时也保持时间顺序,防止相邻样本被打乱后模型学到“未来模式”。
如果你的机器支持 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_size=32, num_layers=2, dropout=0.2): super().__init__() self.gru = nn.GRU( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout, ) 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_first=True让输入形状是(batch, seq_len, input_size),这比默认的(seq_len, batch, input_size)直观。out是 GRU 每个时间步的输出,形状为(batch, seq_len, hidden_size),取[:, -1, :]就是最后一个时间步的隐状态。_是最后一个时间步的隐状态向量,这里没用它,因为我们取的是out的最后一行,两者等价。
dropout=0.2只在num_layers > 1时生效,它加在多层 GRU 的层间,不是加在输出层后面。如果只有一层 GRU,这个参数会被忽略,别以为它自动生效了。
3.4 损失函数与优化器:MSE、AdamW 与学习率调度
收益率预测是回归任务,损失函数选 MSE 或 MAE 都行。MSE 对异常值敏感,更容易让模型关注极端行情;MAE 更稳健,但梯度恒定,训练后期收敛慢。我习惯先用 MSE,如果发现验证集被个别暴涨暴跌样本支配,再切 MAE。
import torch.optim as optim model = GRUModel(input_size=len(feature_cols)) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) criterion = nn.MSELoss()AdamW 比 Adam 多了权重衰减解耦,配合weight_decay=1e-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()patience=15表示验证 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, label="train") plt.plot(val_losses, label="val") 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都保持shuffle=False。做一个简单测试:把标签整体向后平移 3 天再训练,如果模型 Rank IC 没有明显下降,说明模型学的是滞后信息,大概率有数据穿越。
5.2 标签与预测错位:IC 高得离谱,先检查索引
现象:验证集 Rank IC 高达 0.4,但预测值和真实值画出来明显错位了一天,方向对但数值延迟。
原因:Dataset.__getitem__里 x 和 y 的索引不对齐。比如我早期写代码时,x 取idx: idx+seq_len,y 却取y[idx],相当于用“窗口内最后一天”去预测“窗口内第一天”,等于把答案提前暴露给了模型。
解决:打印第一个样本验证对齐关系。x[-1]应该对应y[idx+seq_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 收益率预测从“实验玩具”推到“可信基线”的位置。希望帮到你。
本文还有配套的精品资源,点击获取