简介:一套基于LSTM的时序收益预测系统代码包,面向具备基础Python知识、希望入门RNN/深度学习的开发者,也适合金融量化爱好者参考。项目以大宗商品历史收益序列为对象,围绕数据清洗与归一化、LSTM模型构建、训练及预测评估展开,代码拆分为三个脚本:数据创建、模型训练、预测测试,并配有操作文档、实验截图和训练日志,方便对照每一步输出。压缩包共55个文件,主体包括Python脚本、模型权重文件(tf格式及checkpoint)、npy缓存数据、xlsx原始数据、csv结果与可视化截图等,整体大小约2.27MB,目录按data、models、logs分模块存放。目前已有2911人学习。通过该资源可掌握LSTM输入门、遗忘门、输出门的门控机制,理解Adam优化器与均方误差损失的作用,并学会用MAE、RMSE等指标衡量预测误差;还能看到真实金融时间序列的处理技巧,为后续构建更复杂的量化模型打下基础。
1. 基于LSTM的时序收益预测系统:先想清楚“收益”再写代码
做CTA策略和股票多因子的人,大概率都动过这个念头:把过去一段行情喂给神经网络,让模型自己学出收益规律。基于LSTM的时序收益预测系统,就是把这件事工程化的结果。常见做法是用过去40到60个交易日的价量特征训练一个LSTM分类器,预测未来3到5日的上涨概率,再把它映射成仓位。这套方案适合手里有干净行情数据、想从线性因子切到序列模型的团队,也适合做设备寿命预测、电量负荷预测的人参考——模型本身是同一个套路,差在特征和标签怎么定义。很多新手以为重点是调LSTM结构,实际落地时,收益标签怎么构造、滑窗怎么切、归一化在哪一段做,才是最常翻车的地方。
2. 数据工程先于模型:滑窗、特征与归一化的落地细节
时序预测的第一条铁律是:数据准备次序不对,模型结构再好都白搭。LSTM本身吃的是“一段连续历史”,所以要先决定这段历史怎么截取、怎么对齐、怎么防止未来信息泄漏。这一章不碰模型,先把数据管线讲透。
2.1 用滑窗切分时间序列:窗口长度设定与无未来泄漏的划分
LSTM的输入是三维张量,形状是(样本数, 时间步数, 特征数)。滑窗要做的就是把一维的行情表切成一批这样的样本。假设你的DataFrame按日期升序排列,每行代表一个交易日,典型的切法如下:
import numpy as np import pandas as pd WINDOW = 40 # 用过去40个交易日做预测 HORIZON = 5 # 预测未来5日收益方向 def build_window_samples(df, feature_cols, window=WINDOW, horizon=HORIZON): X, y, dates = [], [], [] # 确保按时间升序 df = df.sort_values("date").reset_index(drop=True) # 未来收益对齐到当前行 df["fwd_ret"] = df["close"].shift(-horizon) / df["close"] - 1.0 df["label"] = (df["fwd_ret"] > 0).astype(float) for i in range(len(df) - window - horizon): X.append(df[feature_cols].iloc[i : i + window].values) y.append(df["label"].iloc[i + window]) dates.append(df["date"].iloc[i + window]) return np.array(X), np.array(y), dates这段代码的核心逻辑是:样本i的特征窗口覆盖第i天到第i+window-1天,标签落在第i+window天。也就是说,模型在第i+window天收盘后,用最近40天的数据预测未来5天的收益方向。特征和标签在时间上没有重叠,不会出现“用明天的数据预测明天”的荒谬情形。
参数上需要留意的有两点。window太小,模型看不到中期趋势;太大,训练样本锐减,且早年数据对当前市场环境的参考价值下降。日频行情我一般用40到60,分钟线可以放到120以上。horizon的选择直接影响信噪比:5日收益比1日收益更接近正态,预测难度反而低一些,但换仓频率也会降下来,交易成本随之下调,这一点在评估阶段要联动看待。
训练、验证、测试集的划分和普通机器学习完全不同,不能随机打乱。常见做法是这样:
train_end = int(len(df) * 0.7) val_end = int(len(df) * 0.85) train_df = df.iloc[:train_end] val_df = df.iloc[train_end - WINDOW : val_end] test_df = df.iloc[val_end - WINDOW :]注意验证集和测试集都往回调了WINDOW行。这是因为切片后还要构造长度为window的样本,直接从train_end开始切,第一批样本会缺失前面40天历史。很多人第一次跑时序模型时在这里直接踩空:验证集样本数比预期少了window条,还以为是自己代码写错了——其实只是没做这个回退。训练集可以shuffle,验证集和测试集必须保持原始时间顺序,否则滚动预测结果完全失去可信度。
2.2 特征工程与滚动归一化:为什么不能直接用全局均值
收益预测里最常用的特征可以分三组:量价类(close、volume、high-low幅度)、衍生类(过去5日收益率、波动率、RSI、MACD)、以及外部协变量(如全市场指数收益率、行业涨跌幅)。量价类原始数值之间尺度差异极大,volume的均值和close的均值都能差出数量级,不归一化,LSTM的梯度更新会被量纲大的特征带着跑。
归一化的核心问题是:用什么统计量去缩放。错误做法是把全样本的均值和标准差算好,一口气把train、val、test全部标准化。这样测试集的分布信息已经混进缩放系数里,等于在评估时偷看了未来。正确做法是只用训练集拟合scaler,然后依次transform三份数据:
from sklearn.preprocessing import StandardScaler feature_cols = ["close", "volume", "ret_5", "volatility", "rsi"] scaler = StandardScaler() # 只对训练集拟合 scaler.fit(train_df[feature_cols]) # 应用到三份数据 train_features = scaler.transform(train_df[feature_cols]) val_features = scaler.transform(val_df[feature_cols]) test_features = scaler.transform(test_df[feature_cols])这里还有一个更细的坑:如果行情数据跨越较长时间,zc_train阶段算出的均值和标准差,到几年后的测试阶段可能已经不再适用。市场波动率会长期漂移,价格中枢也会变。我常用的做法是滚动标准化:用过去250个交易日的统计量缩放当天特征,类似技术指标里的rolling zscore。这个方案对在线推断更友好,因为线上每来一天新数据,你永远只有过去的信息。
rolling zscore的实现可以用pandas的rolling方法,滚动窗口取250,min_periods设120,防止早期样本不足产生空值。与全局scaler相比,滚动标准化会对特征序列做去均值,直接消除价格中枢持续抬升带来的伪趋势,让LSTM更容易学到振荡特征而不是单纯记住价格大小。
2.3 标签构造:收益窗口、去极值与前向对齐
标签构造看起来简单,做起来最容易出现隐蔽错误。第一个问题是前向收益的shift方向。上一节代码里用的是shift(-horizon),含义是“未来第horizon天的收盘价相对今天的涨跌”。很多初学者会写成shift(horizon),那就变成“看过去”,模型拿到的是已经发生的事,回测自然漂亮到不可思议。
第二个问题是NaN处理。shift(-horizon)会让最后horizon行变成NaN,因为“未来”不存在了。这五行不能参与训练,也不能简单用0填充。前面滑窗代码里循环上限减掉了window和horizon,就是为了把这部分样本整个丢弃。如果数据里有停牌日,除法的分母为0或出现空值,要先用ffill或直接剔除异常行,再构造标签。
第三个问题是二分类标签在收益分布上完全不均衡。股票日收益多数时候在0轴附近小幅波动,未来5日收益大于0的概率接近55%到60%,直接做分类,模型很快学会“永远看涨”,loss很低但毫无区分度。更实用的做法是把收益做分档,例如按过去一年收益率分布的分位数划成三档或五档,让模型预测的不再是“涨还是跌”,而是“处于历史分布的什么位置”。
去极值也一样重要。用回归目标直接预测收益时,少数极端行情(比如单日涨10%)的平方误差会主导loss,造成模型只为极端值服务。可以对标签做clip,把收益限制在-0.1到0.1之间,或者用秩变换把连续收益映射到[0,1]均匀区间。秩变换会损失幅度信息,但换来的是对极端值的鲁棒性——在收益预测这个信噪比极低的问题上,稳健比精确更重要。
3. 搭建LSTM模型:结构、参数与训练配置
数据形态就绪之后才轮到模型。LSTM不是魔法,它解决的问题是“记住序列里哪些信息该保留、哪些该遗忘”。在收益预测场景里,这意味着模型有机会从价格形态中提取出比手工因子更灵活的特征。但LSTM的自由度很高,结构稍有不慎就会掉进过拟合或梯度问题的坑里。
3.1 理解LSTM的输入输出:从(batch, seq_len, features)到隐状态
PyTorch里nn.LSTM的默认输入形状是(seq_len, batch, features),很多人第一次用会在这里绕晕。加上batch_first=True之后,输入变成(batch, seq_len, features),和数据集构造时的X形状完全一致,少一层转置麻烦。
LSTM输出有两个返回值:output和(h_n, c_n)。output是所有时间步的隐状态序列,形状是(batch, seq_len, hidden_size)。h_n是最后一层最后一步的隐状态,形状是(num_layers, batch, hidden_size)。做收益预测时,我们通常取h_n[-1],也就是最后一层的最终隐状态接全连接层。
import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, n_features, hidden_size=64, num_layers=2, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=n_features, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, ) self.fc = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, 1), ) def forward(self, x): # x: (batch, seq_len, features) _, (h_n, _) = self.lstm(x) # 取最后一层最后一个时间步的隐状态 last_hidden = h_n[-1] # (batch, hidden_size) return self.fc(last_hidden).squeeze(-1)这里的逻辑要理清:h_n[-1]取的是最上面一层的最后时刻隐状态,它已经聚合了整个窗口的信息。output里也可以取output[:, -1, :],两者在数值上几乎一致,只是h_n路径省掉了存储完整输出序列的内存,训练时更省显存。全连接层设计成64→32→1,中间夹ReLU和Dropout,作用是给隐状态做非线性压缩。不要一上来就直接从hidden_size映射到1,中间缺一层非线性会让模型学不到特征交叉。
3.2 模型代码与超参选型:hidden_size、num_layers、dropout怎么配
超参选型没有标准答案,但有经验区间。以日频数据、40到60的窗口为例,我常用的起点如下:
| 参数 | 建议范围 | 说明 |
|---|---|---|
| hidden_size | 32-128 | 太小学不到模式,太大必过拟合 |
| num_layers | 1-3 | 日频数据2层足够,超过3层收益极小 |
| dropout | 0.1-0.3 | 只有层数大于1时dropout才生效 |
| 学习率 | 1e-4到1e-3 | 建议1e-3起步,验证集不下降再降 |
| 梯度裁剪阈值 | 0.5-2.0 | 缓解RNN梯度爆炸 |
hidden_size决定隐状态的表达能力。日频收益序列信号弱,64维是性价比很高的起点。金融时序不是图像,不存在“越大越好”的说法——模型参数越多,对噪声的拟合能力越强,回测曲线越好看,样本外越惨。num_layers超过3层后,梯度在时间维度和层维度双重衰减,训练时间翻倍但收益预测的IC通常没有改善。dropout在num_layers大于1时才有实际作用,它插入在层与层之间,单层网络设dropout等于没设,这一点对刚看pytorch lstm源码的人特别容易误解。
窗口长度和hidden_size还有一个联动关系:窗口越长,模型需要记忆的信息跨度越大,hidden_size也应适当放大。40的窗口配64维,60的窗口配96维,这是一个肉眼可用的经验比例。
3.3 训练循环与学习率策略:梯度裁剪和warmup的必要性
训练LSTM和训练CNN有个显著差别:梯度在时间步之间反复相乘,容易出现指数爆炸。收益序列波动剧烈时,一两根极端K线就足以让loss变成NaN。梯度裁剪是所有RNN训练的标配,它不是加分项,是必选项。
import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset model = LSTMPredictor(n_features=X_train.shape[2]) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.BCEWithLogitsLoss() # 内部自带sigmoid,数值更稳定 scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) CLIP = 1.0 EPOCHS = 30 BATCH_SIZE = 256 train_dataset = TensorDataset(torch.tensor(X_train, dtype=torch.float32), torch.tensor(y_train, dtype=torch.float32)) train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True) for epoch in range(EPOCHS): model.train() total_loss = 0.0 for xb, yb in train_loader: optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() # 关键一步:裁剪梯度范数 torch.nn.utils.clip_grad_norm_(model.parameters(), CLIP) optimizer.step() total_loss += loss.item() scheduler.step() print(f"epoch {epoch+1:02d} loss {total_loss / len(train_loader):.4f}")BCEWithLogitsLoss把sigmoid和交叉熵合并计算,比“先sigmoid再BCELoss”的数值稳定性好很多,尤其是logits绝对值偏大时,后者会出现梯度消失。学习率调度器用CosineAnnealingLR时,T_max要等于EPOCHS,让学习率在一个完整周期内从初始值余弦衰减到接近0。
关于warmup,小数据集上可以不加,但在数据量很大或训练epoch数超过50时,我会在前5个epoch用线性warmup把学习率从0升到目标值。原因是LSTM的隐状态初始化是随机的,训练初期梯度方向不稳定,直接上大学习率容易让模型陷进一个坏的损失曲面。warmup的设计可以做成一个LambdaLR,也可以用现成的transformers里的get_linear_schedule_with_warmup,但那个依赖huggingface库,这里直接手写更清爽:
from torch.optim.lr_scheduler import LambdaLR def warmup_cosine(step, warmup_steps=5, total_steps=30): if step < warmup_steps: return step / warmup_steps return 0.5 * (1 + np.cos(np.pi * (step - warmup_steps) / (total_steps - warmup_steps))) scheduler = LambdaLR(optimizer, lr_lambda=warmup_cosine)这里返回的是学习率倍率,不是绝对值。warmup_steps设为5,意味着前5个epoch学习率从0线性涨到初始值,之后进入余弦衰减。配合梯度裁剪1.0,这套配置在多数日频收益预测任务上能稳定跑通。
4. 收益预测最容易踩的5个坑:现象、原因与排查
这一章是实践里最值得反复读的部分。收益预测的模型代码并不复杂,真正让团队浪费数周时间的,往往是数据链上那些“看着对,实际错”的细节。
4.1 标签漂移:shift和iloc错位让模型“偷看未来”
现象:回测方向准确率高达65%以上,模型看起来无所不能,拿到新数据立刻失效。
原因:标签和特征没有对齐。最常见的是直接把原始DataFrame里的前向收益列shift(-horizon)之后,没有在滑窗循环里同步调整索引边界。于是第i个样本的特征窗口包含到了第i+window行,而该行的前向收益可能正好落在了特征窗口内部——模型其实用未来数据做了预测。
解决:把标签和特征的生成放在同一个变换里,像2.1节代码那样,在循环中同时管理i和i+window,保证特征窗口的最后一行严格早于标签的观测起点。排查时把构造好的X和y打印出来,人工核对一行样本的日期和标签对应的日期。
4.2 归一化泄漏:全样本统计量带来的虚高结果
现象:测试集上预测表现很好,实盘交易却一路亏损。仔细对比发现,线上数据接入后,特征的均值标准差与训练时差异巨大。
原因:有人图省事,对全量数据先标准化再切分。测试集的均值、标准差混入了scaler的拟合结果,模型在测试时已经“见过”未来分布。这类泄漏最难察觉,因为训练、验证、测试三段的准确率都会被垫高,而且垫高幅度不一致。
解决:回到2.2节的做法,scaler只拟合训练集,验证和测试集只做transform。上线时保存这个scaler,后续每来一日新数据都用它做缩放,绝不能实时重新fit。排查手段也简单:看验证集和测试集的特征分布是否与训练集一致,一旦出现明显的整体偏移,先怀疑scaler泄漏。
4.3 收益分布尖峰厚尾:模型退化成只会预测“不变”
现象:训练过程中loss下降正常,但预测值几乎全部落在0.5附近,或者干脆全部输出0或1。持仓信号没有任何区分度。
原因:金融收益分布不是高斯分布,中间是尖峰,两端是厚尾。大多数时间收益接近0,模型发现预测“不变”时loss最小,于是收敛到恒值预测。这在回归任务里尤其严重,分类任务稍好,但也容易把多数类预测为多数。
解决:改用分档标签代替二分类标签。按过去一年收益的20%、40%、60%、80%分位数划分成5档,模型输出变成一个5分类问题。或者把连续收益除以滚动标准差做标准化,让目标变量变成相对波动率倍数,削弱恒值先验。还有一种选择是调整训练样本权重:收益绝对值大的样本加权,让模型更关注有信息量的时刻。
4.4 回测价差与实盘价差不一致:成本一扣就翻车
现象:回测年化收益15%,实盘跑两个月只剩3%。单笔交易的买卖价差在回测中被完全忽略。
原因:回测里用收盘价成交,但实盘只能按买一卖一或VWAP附近的价格成交。LSTM预测的是日频信号,换仓频率越高,价差成本占比越大。很多回测框架只扣手续费不扣滑点,在这类高频切换信号的策略里,滑点往往比手续费贵一个数量级。
解决:在回测中至少计入双边成本。股票按成交金额的0.1%到0.2%估算,期货按每跳价差估算。判断系统是否值得做时,必须以扣成本后的净收益为准。毛利很漂亮、净利变为负的策略,直接放弃,不要抱有侥幸。
4.5 重叠滑窗样本:验证集和测试集“近亲繁殖”
现象:验证集和测试集的方向准确率都很好看,但滚动重训后模型性能明显衰减。调参时感觉模型的稳定性很差,小改动就能让结果大幅波动。
原因:滑窗构造样本时,相邻样本之间共享了绝大部分历史数据。第i个样本和第i+1个样本只差一个交易日,它们在时间轴上高度重叠。如果验证集紧挨着训练集,两组样本会有大量重叠窗口,验证结果被严重高估。
解决:在划分样本时加gap。训练集和验证集之间空出至少一个horizon长度的交易日,确保验证集中的第一个样本不在训练集最后一批样本的未来窗口内。更严格的做法是把验证集独立出一段连续区间,比如最后6个月完全不参与训练。排查时统计训练集和验证集样本的时间范围,看是否有交叉重叠。
5. 评估与取舍:用IC、ICIR和成本后的净收益判断值不值得做
模型跑通只是起点。接下来的问题是:这套系统到底有没有用、值不值得给它分配资金。只看准确率不够,需要一套与预测任务匹配的评估指标。
5.1 三个核心指标:方向准确率、IC与ICIR
方向准确率最直观,但单独用容易被恒值预测欺骗。IC(信息系数)衡量预测值和实际收益之间的秩相关性,ICIR是IC的均值除以标准差,代表预测能力的稳定程度。
| 指标 | 计算口径 | 参考经验 | 注意点 |
|---|---|---|---|
| 方向准确率 | 预测正确的样本占比 | 日频55%以上有意义 | 类别不均衡时需看分档准确率 |
| IC | 预测值与实际收益的Spearman相关 | 绝对值大于0.03可用 | 必须用样本外计算 |
| ICIR | IC均值 / IC标准差 | 大于0.3较好 | 低于0.2则信号太不稳定 |
计算IC时要特别注意是“预测当期的收益”还是“未来收益”。如果用分类模型的概率,把预测概率和未来5日收益算Spearman相关;如果用回归模型,直接用预测值和实际值算。实现很简洁:
from scipy.stats import spearmanr def calc_ic(pred, actual): return spearmanr(pred, actual).correlation def calc_icir(pred_list, actual_list): ics = [calc_ic(p, a) for p, a in zip(pred_list, actual_list)] return np.mean(ics) / (np.std(ics) + 1e-8)这里把预测序列按时间分成多段,逐段算IC再合成ICIR,这样能观察预测能力随时间是否衰减。IC的符号也要关注:如果IC稳定为负且绝对值大,说明模型的方向判断稳定反向,有时反着用也有价值,但这种情况在真实数据里极其罕见,更多时候是数据泄漏导致的幻觉。
5.2 策略回测:完整交易成本口径下的净收益
预测概率映射到仓位后,做一次完整的策略回测。最简策略是:预测概率大于阈值时持有,小于阈值时空仓。收益计算要把每次仓位变化对应的成本扣除。
def backtest_with_cost(pred, actual, cost_rate=0.0015, threshold=0.5): position = (pred >= threshold).astype(float) # 仓位变化点 turnover = np.abs(np.diff(position, prepend=position[0])).sum() / len(position) gross_ret = np.mean(position * actual) net_ret = gross_ret - turnover * cost_rate return gross_ret, net_ret, turnover三个参数值得细说。cost_rate按双边成本估算,股票市场在0.1%到0.2%之间,期货市场还要看合约价值。threshold直接影响换手率和持仓时间:threshold高,信号少,但每笔交易质量相对更高;threshold低,交易频繁,成本侵蚀收益。实际使用中可以在验证集上扫threshold,按净收益选最优值,但要小心扫出来的阈值过拟合——用ICIR配合判断,不要只看单一最优参数。
回测还有个容易忽略的细节:actual必须是每个持仓日的真实收益,不是未来horizon累积收益。日频换仓时用当日收益;如果按5日信号调仓,那么信号维护期内的每日收益都要算进持仓收益,但仓位只在调仓日更新。代码里用position和逐日收益相乘,就是这个含义。
5.3 稳健性检验:滚动重训与样本外一致性
一次性切分train/test只能证明模型在那一段历史上有效,不能证明它在下个月还有用。滚动重训(walk-forward)是业界常见的验证方法:从起始日期开始,每N天重训一次模型,预测未来M天,然后把预测结果拼接成完整的样本外序列。
results = [] for start in range(train_start, total_len - train_window - test_horizon, step): train_slice = df.iloc[start : start + train_window] test_slice = df.iloc[start + train_window : start + train_window + test_horizon] # 重新fit scaler和model scaler = StandardScaler().fit(train_slice[feature_cols]) X_train, y_train = build_window_samples(train_slice, feature_cols) X_test, y_test = build_window_samples(test_slice, feature_cols) model = train_lstm(X_train, y_train) # 内部完成训练循环 pred = predict(model, X_test) results.append((test_slice["date"].values[-len(pred):], pred, y_test)) # 拼接所有样本外预测 all_pred = np.concatenate([r[1] for r in results]) all_actual = np.concatenate([r[2] for r in results])注意每个滚动窗口内都要重新练兵scaler和模型,不能用第一次训练时的参数推理后面所有窗口。这样做的好处是模拟真实上线状态:每次只用当时已有的数据做决策。如果模型在滚动重训中的ICIR明显低于一次性切分的结果,说明过拟合严重,这个方向不值得继续投入。
滚动重训也存在计算成本。LSTM训练需要几分钟到几十分钟,窗口多时整体耗时可观。实践中step取20到60个交易日,一年大约滚动6到12轮,训练量完全可接受。如果连这个验证量都无法承受,那这套系统在实盘高频更新时的运维成本也会超出预期——提前止损反而是赚。
6. 进阶技巧:时序注意力、概率校准与推断优化
模型在样本外能跑出稳定IC之后,再往这个方向做增量优化才合理。下面三个技巧按实施性价比排序,第一项最推荐。
6.1 时序注意力:把最后一刻的隐状态换成加权池化
LSTM最后时刻的隐状态未必包含整个窗口最重要的信息。早盘的异动可能到收盘时已经被遗忘,而它恰恰是当天最有效的特征。解决思路是用注意力给每个时间步的隐状态打分,再加权求和:
class AttentionLSTM(nn.Module): def __init__(self, n_features, hidden_size=64, num_layers=2, dropout=0.2): super().__init__() self.lstm = nn.LSTM(n_features, hidden_size, num_layers, batch_first=True, dropout=dropout) self.attn = nn.Linear(hidden_size, 1, bias=False) def forward(self, x): out, _ = self.lstm(x) # (B, T, H) weights = torch.softmax(self.attn(out).squeeze(-1), dim=1) # (B, T) context = torch.bmm(weights.unsqueeze(1), out).squeeze(1) # (B, H) return self.fc(context)注意力层的参数只有hidden_size个权重,几乎不增加训练负担,但能让模型关注窗口内真正的关键时段。我在实际使用中发现,加注意力的模型对窗口长度不敏感,窗口从40加到80,性能不会明显衰减——这是它区别于“最后一刻隐状态”的显著优势。
6.2 概率校准:用温度缩放估计预测的不确定性
收益预测的另一个常见问题是模型输出0.6的概率并不代表真的60%会上涨。二分类模型训练时天然偏向多数类,概率值存在系统偏差。方法是温度缩放:在验证集上学习一个温度参数T,把logits除以T再求sigmoid,让输出概率与真实频率对齐。
具体做法是固定模型参数,用验证集logits拟合一个标量T,让交叉熵最小。这个T通常大于1,显著拉平过度自信的概率。信号映射到仓位时,用校准后的概率做阈值过滤,信号质量会更高。
6.3 TorchScript导出与批处理:上线前的最后一步
训练验证完成后,模型要离开Python训练环境。常见做法是torch.jit.script导出模型,再拿C++或服务框架加载。导出前把模型切换到eval模式,并固定dropout状态:
model.eval() traced_model = torch.jit.trace(model, torch.randn(1, WINDOW, n_features)) traced_model.save("lstm_ret.pt")TorchScript导出后的模型不依赖Python环境,推理速度快,适合部署在行情服务端旁。日常预测时用batch推理一次处理多只标的,把每只标的最近WINDOW天特征堆成(batch, WINDOW, n_features),一次forward得到全部预测概率,避免循环单条预测带来的开销。
我做收益预测这些年,最大的教训是:模型永远只是信号源,真正的收益来自数据管线的严谨性和成本控制。每次上线新模型之前,我都会强制自己跑一遍滚动重训,把所有回测结果扣满成本再看一遍。这个流程帮团队挡掉了不止一次“回测很美、实盘就亏”的翻车。如果你打算在这个方向投入,希望这些踩过的坑能帮你省下几周时间,也让你对模型输出的边界更诚实——收益预测系统做的是概率判断,不是确定性机器。希望帮到你。
本文还有配套的精品资源,点击获取