简介:一份面向金融时序建模与深度学习初学者的长短期记忆网络外汇预测项目包,系统展示从历史汇率数据到预测结果的全流程。项目围绕外汇预测任务,详细实践了缺失值与异常值清洗、数据标准化、训练集验证集测试集划分等预处理步骤,并完整呈现了长短期记忆网络的多层结构搭建、门控机制原理、均方误差损失函数、Adam优化器选取以及批次大小和迭代次数等参数调优过程。压缩包共包含二十七个文件,主要类型包括Jupyter Notebook交互式代码、Python脚本、CSV历史行情数据、训练好的模型权重文件和Markdown说明文档,整体大小约13.41MB。目前已有一百一十九人学习浏览。借助该资源,读者能够基于美元兑日元、英镑兑美元等货币对数据直接复现实验,加载预训练权重观察预测效果,也可尝试调整网络层数、隐藏单元数量或引入经济指标等外部特征来进一步提升精度,适合作为课程设计、量化交易入门及个人研究的起点。
1. 基于LSTM网络的外汇预测模型包:先想清楚它能不能用,再谈怎么跑
你拿到一个「基于LSTM网络的外汇预测模型.zip」,解压、装依赖、跑训练脚本,三分钟之后看到的往往是两种结果:loss掉得漂亮,一测未来数据就翻车;或者loss压根不降,模型吃掉了每一轮训练却没有学会任何东西。外汇行情的低信噪比和随机游走特性,让LSTM的序列记忆优势很难发挥出来。这篇文章想把解压后一定会遇到的数据处理、网络结构、训练参数和验证方法,按我实际跑过的顺序讲清楚,并给出一份能直接改着用的PyTorch代码骨架。适合想复现模型、做毕设或量化入门的人;不适合期待“装上就盈利”的人,先把预期降到一个真正诚实的回测结果上。
2. 把行情数据变成LSTM能吃的样本:时间帧、滑窗与标签设计
2.1 原始数据长什么样:外汇的OHLCV与时间对齐
外汇数据和股票数据有个明显区别:它是24小时连续交易的分散市场,没有统一的成交量和交易所时间戳。你从MT4导出的CSV通常是“Date, Time, Open, High, Low, Close, Volume”这样的结构,其中的Volume字段大多是tick成交量,只能当参考。常见做法是只用OHLC,Close作为主特征,其余列去重后参与计算衍生指标。如果你拿到的zip里只有几百根D1数据,那就要先接受一个现实:数据量不够,LSTM再强也白搭。
这里第一个新手坑是时间对齐。MT4默认导出的是服务器时间,通常是GMT+2或GMT+3,而且会随夏令时切换。如果你把两个不同来源的数据拼在一起,不统一时区,LSTM看到的K线顺序就会错位。我一般会把时间戳全部转成UTC,再按时间升序排列,遇到周末停盘产生的缺失K线不填充,直接保留时间序列的原生空隙。不要把缺失的K线用前向填充补出来,那等于造出一段模型永远见不到的假价格平台,预测时会引入系统性偏差。
时间帧选择上,M1数据噪音太大,D1样本量太小,H1和H4是多数外汇预测场景的折中。做LSTM时间序列预测python项目时,H1大概一天24根K线,两年数据约有12000根,足够支撑一个seq_len在200以上的滑窗模型。选好时间帧后,下一步就是把连续K线切成监督学习的样本。
2.2 用PyTorch Dataset把K线切成滑窗样本
把K线序列变成样本的核心手段是滑窗:用过去N根K线作为输入,用未来M根K线的某个量作为标签。这里我一般用一个PyTorch Dataset来封装,原因是可以和DataLoader无缝配合,也方便在训练循环里统一处理。下面这段代码是常见做法,几乎可以直接抄进你的项目里:
import numpy as np import torch from torch.utils.data import Dataset class FXWindowDataset(Dataset): def __init__(self, data, feature_idx, target_idx=0, seq_len=240, horizon=1): # data: 按时间升序排列的二维numpy数组 # feature_idx: 模型输入用到的特征列索引列表 # target_idx: 预测目标的列索引,默认第0列是收益率 self.data = torch.from_numpy(data.astype(np.float32)) self.feature_idx = feature_idx self.target_idx = target_idx self.seq_len = seq_len self.horizon = horizon def __len__(self): return len(self.data) - self.seq_len - self.horizon + 1 def __getitem__(self, idx): x = self.data[idx: idx + self.seq_len, self.feature_idx] y = self.data[idx + self.seq_len: idx + self.seq_len + self.horizon, self.target_idx] return x, y代码逻辑说明:data的第一维是时间,第二维是特征列。__len__返回的是能切出的窗口数,减掉seq_len和horizon是为了保证最后一个窗口也有完整的预测目标。__getitem__返回两个张量:x是过去seq_len根K线在feature_idx列上的取值,形状是(seq_len, len(feature_idx));y是未来horizon根K线在target_idx列上的取值,形状是(horizon,)。每个样本的输入和标签在时间上严格分开,不存在未来信息提前进入输入的问题。
参数说明:seq_len=240在H1时间帧下约等于10个交易日,能覆盖短中期走势;horizon=1表示只预测下一根K线,想预测未来5根就改成5。target_idx指收益率列,默认第0列放收益率。feature_idx可以包含0,也就是把历史收益率也作为特征输入,这没问题;问题在于不要把未归一化的Close价格同时塞进多个派生特征里,那会造成信息冗余。DataLoader构造时,验证集和测试集千万不要shuffle=True,保持时间顺序的连续性。
2.3 为什么预测收益率而不是预测价格
如果你直接把Close价格塞给LSTM做回归,训练loss会很快降下去,但预测曲线和真实价格几乎错开一根K线,呈现明显的“滞后跟随”。原因是价格序列近似随机游走,模型学会的最简单策略就是“预测值约等于上一个时刻的价格”,这样MSE已经很小,但方向预测毫无意义。解决这个问题的常用办法是把原始价格转换成对数收益率或百分比收益率,让序列变得平稳。
数据构造时,把Close列做一阶差分并转成收益率,放在data的第0列,target_idx就设为0。收益率标签不需要再做标准化,它是平稳序列,标准化反而让预测结果失去可解释性。特征列里可以放技术指标,比如RSI、ATR、布林带位置,也可以放时间周期性特征,例如“小时”和“星期”的sin/cos编码。所有特征必须在同一个数组里,按相同的时间对齐,顺序不能乱。
最后说切分原则。时间序列切分永远按顺序:前70%训练,中间15%验证,最后15%测试。
注意:不要把切出来的滑窗样本整体随机shuffle后再划分训练和验证,这等于把未来段行情混进了训练窗口,测试结果虚高,属于最典型的数据泄漏。
如果你担心相邻窗口重叠度太高,模型反复看到同一段数据,可以把滑窗步长从1改成5,或者在训练集里使用dropout来缓解,这都比破坏时间顺序更安全。
3. 搭建LSTM神经网络预测模型:从输入输出到训练循环
3.1 nn.LSTM的输入输出别搞混:PyTorch源码级的理解
LSTM神经网络的核心是nn.LSTM模块,PyTorch里的用法很固定,这里把输入输出关系先说透,能省掉后面一大半调参时间。初始化时常用的参数是input_size、hidden_size、num_layers、batch_first和dropout。batch_first=True的情况下,输入张量形状是(batch, seq_len, input_size),绝大多数人习惯这个维度顺序,建议保持默认。
前向传播返回两个东西:output和(h_n, c_n)。output形状是(batch, seq_len, hidden_size * num_directions),保存了每个时间步最后一层LSTM的输出;h_n是最后一层最后一个时间步的隐状态,形状是(num_layers * num_directions, batch, hidden_size)。单层单向LSTM里,output[:, -1, :]和h_n[-1]取出来的值是一样的;多层LSTM要取h_n[-1]才是最后一层。还有一个常见误用是把h_n[0]当作输出,那是第一层的隐状态,不是最终预测应该用的。
普通外汇预测我不建议开bidirectional=True,因为双向LSTM在最后一个时间步会看到序列右侧的信息,这在时间序列预测里等价于“偷看未来”,训练和测试时模型拿到的信息完全不对等。如果要做“序列标注”类任务另说,但做预测,老老实实单向就够了。模型定义我一般这样写,也算一份能直接跑通的LSTM模型代码:
import torch.nn as nn class FXPricePredictor(nn.Module): def __init__(self, input_size, hidden_size=64, num_layers=2, dropout=0.2, horizon=1): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, ) self.regressor = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, horizon), ) def forward(self, x): out, _ = self.lstm(x) # out: (batch, seq_len, hidden_size) last = out[:, -1, :] # 取最后一个时间步的隐状态 return self.regressor(last) # (batch, horizon)逻辑说明:LSTM处理完整个滑窗后,取最后一个时间步的输出喂给一个两层全连接网络做回归。input_size就是2.2节里feature_idx的长度;hidden_size=64对于外汇这种低信噪比数据足够,再大容易过拟合。num_layers设2,注意dropout在num_layers=1时会被PyTorch忽略,这是文档里写了但很多人不看的地方。最后的Linear直接输出horizon个预测值,没有额外激活函数,因为回归任务不需要限制输出范围。
3.2 训练循环:从零写一个不花哨的LSTM训练器
有了模型,接下来是训练循环。我不建议新手直接上PyTorch Lightning这类框架,先把原生训练循环搞明白,后面换任何框架都顺手。代码里最关键的三件事是:梯度裁剪、Huber损失、保存最佳权重。
import torch import torch.optim as optim from torch.utils.data import DataLoader device = "cuda" if torch.cuda.is_available() else "cpu" model = FXPricePredictor(input_size=len(feature_idx)).to(device) optimizer = optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.HuberLoss(delta=1.0) train_loader = DataLoader(train_ds, batch_size=128, shuffle=False) for epoch in range(50): model.train() epoch_loss = 0.0 for x, y in train_loader: x = x.to(device) y = y.to(device) pred = model(x).squeeze(-1) loss = loss_fn(pred, y) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() epoch_loss += loss.item() * x.size(0) print(f"epoch {epoch+1}, train loss {epoch_loss / len(train_loader.dataset):.6f}")逻辑说明:pred形状是(batch, horizon),squeeze(-1)在horizon=1时会变成(batch,),和y形状对齐。梯度裁剪固定在max_norm=1.0,外汇数据里经常出现极端点,一轮剧烈行情就能让LSTM梯度爆炸,这个参数能兜底。HuberLoss在delta=1.0以内是MSE,超过delta退化成MAE,极端行情不会像MSE那样单点拉扯整个模型,这是外汇这种尾部噪声大的序列里很实用的选择。
训练时用shuffle=False的原因前面说过:相邻滑窗高度重叠,打乱顺序并不会让样本真正独立,反而让模型在验证时失去时间语境的连续性。如果你想提一点收敛速度,可以在训练集里开shuffle=True,但验证集必须保持False,并且要清楚这会引入少量重叠偏差。每个epoch打印的应该是“平均到每个样本”的损失,而不是累加值,否则epoch变长时loss数字会跟着虚高,不好判断收敛。
3.3 训练和评估用不同指标:方向准确率才是外汇关心的
训练阶段用回归损失,但评估阶段我强烈建议把方向准确率单独列出来,因为你的目标不是让预测曲线贴着真实曲线走,而是让预测收益率的正负符合真实收益率。以下是常用指标表:
| 阶段 | 指标 | 用途与说明 |
|---|---|---|
| 训练 | MSE / HuberLoss | 让模型在数值上逼近收益率 |
| 早停 | 验证集HuberLoss | 衡量泛化,防止过拟合 |
| 评估 | MAE / RMSE | 分析误差分布,看是否被极端行情带偏 |
| 交易 | 方向准确率、盈亏比 | 决定这套预测能否覆盖交易成本 |
方向准确率的计算很简单:预测收益率大于0记做多,真实收益率大于0也记做多,一致就是对的。这个指标在训练时每几个epoch顺手统计一次,如果训练了3个epoch还在50%上下打转,说明模型学到的基本是噪声。外汇单边趋势少、震荡多,方向准确率能做到52%以上并配合盈亏比才算能用,低于这个数值就要回头查数据或者改标签。
4. 训练参数怎么设:学习率、seq_len与早停的实战经验
4.1 一套不算玄学的默认参数
调参这件事在LSTM时间序列预测领域很容易变成玄学,但我还是愿意给一套默认值,让新手不用从零开始猜。下面这组参数是我在H1时间帧上常用的起点,配合后面的早停机制,大部分项目第一天都能跑起来:
| 参数 | 默认值 | 调整依据 |
|---|---|---|
| seq_len | 240 | H1约10个交易日,足够覆盖短中期波动 |
| horizon | 1 | 先从单步预测起步,多步预测后面再试 |
| hidden_size | 64 | 约为特征数的4倍,过深易过拟合 |
| num_layers | 2 | 1层太浅,3层以上训练慢且更容易过拟合 |
| batch_size | 128 | 太小导致梯度震荡,太大吃内存且收敛慢 |
| lr | 1e-3 | Adam默认推荐值,跑5个epoch后观察loss |
| dropout | 0.2 | H1数据上过拟合不严重时取0.1~0.3 |
| epochs | 50 | 配合早停,实际训练会在20~30个epoch结束 |
这里的核心调整顺序是:先固定seq_len和hidden_size,把lr调出合理区间,再回头动seq_len和dropout。不要一开始就同时改四个参数,LSTM模型对参数组合的敏感度很高,同时调整出了问题很难定位。我见过有人把hidden_size从32调到512,loss确实降了,但验证集在10个epoch后开始暴走,这就是典型的容量过大加数据不足。这套参数默认只适合H1和H4,如果换到M1,seq_len=240只代表4小时,模型根本看不到足够的走势形态;换到D1,240根K线又接近一年的时间跨度,数据量要求完全不同。
4.2 学习率调度与早停:给训练过程留一颗后悔药
训练到第10个epoch后,loss下降速度会肉眼可见地变慢。不要盲目拉高epochs,正确做法是给学习率一个自适应的下降机制,并用早停给模型喊停。下面这段是常见的训练与调参骨架:
from torch.optim.lr_scheduler import ReduceLROnPlateau import copy best_val = float("inf") best_state = copy.deepcopy(model.state_dict()) no_improve = 0 patience = 10 scheduler = ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=5, min_lr=1e-5) for epoch in range(50): train_one_epoch(model, train_loader, optimizer, loss_fn, device) val_loss = evaluate(model, val_loader, loss_fn, device) scheduler.step(val_loss) if val_loss < best_val: best_val = val_loss best_state = copy.deepcopy(model.state_dict()) no_improve = 0 else: no_improve += 1 if no_improve >= patience: print(f"early stop at epoch {epoch+1}") break model.load_state_dict(best_state) torch.save(best_state, "best_lstm_fx.pt")代码说明:train_one_epoch和evaluate就是上一节循环的封装,这里不重复展开。ReduceLROnPlateau监控验证集loss,连续5个epoch不下降就把学习率减半;学习率降到1e-5后不再递减,防止在最后阶段扰动已经收敛的权重。早停的patience我一般取10~15,比scheduler的patience大,给学习率下降后的模型一点缓冲时间,不会因为一次波动被误杀。
保存的best_state是验证集loss最小的那组权重,不是最后一个epoch的权重,这点很重要。很多人训练结束直接用最后一个epoch的参数,过拟合已经发生,回测自然难看。这里还有个容易被忽略的细节:loss下降不代表模型在变好,训练前期验证集loss下降,方向准确率可能反而在下降。早停只认验证集loss,但打印信息里要把方向准确率一起带出来,方便人工判断要不要提前断开。
4.3 归一化的正确姿势:只允许训练集提供统计量
外汇特征列的数值范围差异很大,Close价格和ATR差好几个数量级,不归一化的话LSTM很难收敛。归一化用什么方法?常见做法是StandardScaler,每个特征减去训练集均值、除以训练集标准差。但这有个必须服从的纪律:只能对训练集调用fit_transform,验证集和测试集只能调用transform,不能fit。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() train_features = scaler.fit_transform(train_df[feature_cols]) val_features = scaler.transform(val_df[feature_cols]) test_features = scaler.transform(test_df[feature_cols])逻辑说明:fit_transform在训练集上算出了均值和方差,之后验证集和测试集都用同一套参数变换。如果你不区分,直接在全部数据上fit,就等于让模型在训练时偷看了测试数据的统计信息,测试结果会偏乐观。这在做LSTM时间序列预测python项目时是最常被忽略的数据泄漏来源之一。
注意两个例外:收益率标签不要做标准化,它已经是平稳序列,标准化反而让预测结果失去可解释性;sin/cos编码的周期性特征也不建议标准化,它们的值域本来就在-1到1之间。特征列的标准化要在滑窗切分之前完成,然后才构造2.2节的Dataset,顺序反了会出现同一个窗口里混入不同scale的特征,模型学起来非常痛苦。
5. 常见问题与避坑:外汇LSTM模型跑不通的五个翻车点
5.1 现象:训练loss降得很漂亮,一测新数据就崩
原因几乎都是数据泄漏,最常见三种:一是归一化时fit了全量数据;二是验证集和训练集没有严格按时间切分,用了随机分割;三是在特征里混入了未来信息,比如用t时刻的真实数据计算出t+k时刻的指标,再预测t+k的标签。泄漏会让训练和验证指标全线虚高,回测曲线漂亮得就像艺术品,但实盘就是另一回事。
解决办法是给数据管道加一道检查:把测试集留到模型完全训练好之后再碰。我习惯把切分、标准化、滑窗封装成一个固定的数据类,训练任何模型都走同一个入口,最大程度避免人为把未来数据带进训练集。如果你发现自己“不小心”让某些逻辑同时用到了训练和测试数据,不要犹豫,立刻停下重写那段管道。
5.2 现象:loss完全不降或者剧烈震荡
先排查网络输入shape。PyTorch报错常见的是(batch, seq_len, features)和(batch, features)的维度错配,检查Dataset返回的x是不是(seq_len, len(feature_idx))。其次看学习率,1e-3太大会让loss在前几个iteration剧烈震荡,降到3e-4再看;如果降到1e-5才稳住,说明特征scale有问题,回过去查归一化。
第三个原因是梯度爆炸。H1数据偶尔会有一根巨大波动K线,单一样本的loss能冲乱整个模型权重,此时loss会突然跳到上一个数量级。解决就是3.2节里的clip_grad_norm_,我把它当成LSTM外汇训练的必要项,不是可选项。遇到loss不降时,先打印几行x和y的统计值,确认特征里没有NaN或inf,再谈调参。
5.3 现象:预测曲线滞后真实曲线一期
这是最典型、也最好认的翻车现场。当预测值和真实值画在同一张图上,预测曲线就像真实曲线向右平移了一根K线,意味着模型实际上在重复上一根K线的值,这就是用价格做标签的必然结果。价格随机游走时,最好的“预测”就是维持原价不变,MSE已经最优,方向准确率约等于掷硬币。
解决办法就是2.3节说的改用收益率标签。如果已经用了收益率还是滞后,检查是不是在恢复价格时出了问题,或者损失函数被某个极端样本主导,换HuberLoss再试。还有一个容易忽视的点:如果你在滑窗里用了归一化后的价格做特征,同时用价格做标签,即使标签是差分后的收益率,特征里的价格漂移也可能让模型走上“记忆昨天价格”的捷径,所以特征里尽量少放原始价格。
5.4 现象:回归loss在降,方向准确率一直卡在50%
这说明模型在数值上逼近均值,但没有捕捉到可交易的方向信号。外汇市场大部分时间在震荡,价格走势接近随机游走,50%的方向准确率其实是个常态基准,不要因此怀疑代码有bug。你要做的不是加大模型,而是先确认数据里到底有没有可预测性。
常见做法是做一个朴素基准对比:直接把上一个时刻的收益率当成预测值,算出它的MAE和方向准确率,作为底线。如果LSTM连这个底线都打不过,问题在数据或特征,不在网络。还可以把回归任务改成分类任务,用CrossEntropyLoss预测“涨/跌/平”三分类,很多情况下分类比回归更容易让模型抓住方向,因为分类对数值精度不敏感,更贴近交易决策本身。
5.5 现象:每次运行结果差很多,复现不了
外汇数据量小,模型初始化、数据Loader的顺序、甚至CPU和GPU上的浮点误差都会导致训练结果不同。解决方法是固定随机种子,包括python random、numpy和torch各自的seed,并在PyTorch里关掉cudnn的自动benchmark。更稳妥的做法是保存每次训练的完整配置,包括特征列表、seq_len、学习率、归一化参数,这样即使复现失败也能定位是哪一步变了。
严格固定seed并不能保证100%一致,GPU本身的非确定性计算是客观存在的。所以我更推荐在项目里记录“指标范围”而不是“单次指标”,比如同一个配置跑三次,取方向准确率的均值作为判断依据,比纠缠单次复现要实际得多。数据增强在这个场景里几乎没有意义,别为了凑复现去微调随机种子,那等于自欺欺人。
6. walk-forward验证与交易成本模拟:模型能不能真用
6.1 walk-forward滚动验证的参考实现
一次性把测试集跑完,得到的指标往往偏乐观,因为模型在训练中见过的市场环境可能和测试期高度相似。更接近实盘的做法是walk-forward:用一段历史训练,预测下一小段,然后把这段真实数据并入训练集,继续向前滚动。这样每次预测使用的都是过去的信息,且市场环境不断更新。下面是一个参考实现:
def walk_forward(data, train_size, step, build_model, predict): total = len(data) results = [] start = train_size while start + step + 1 <= total: train_data = data.iloc[:start] test_data = data.iloc[start: start + step] model = build_model(train_data) pred = predict(model, test_data) results.append(evaluate_direction(pred, test_data)) start += step return results参数说明:train_size是第一次训练用到的样本数,step是每次滚动验证的K线根数,常见做法是step取seq_len的1~2倍,比如seq_len=240时,step取500根H1。build_model接收训练数据并返回训练好的模型,predict返回预测序列,evaluate_direction直接比较预测收益率和真实收益率的符号,返回方向准确率。walk-forward的缺点是训练次数多、耗时大,好处是结果接近实盘,不容易自欺欺人。
做完预测验证,还要过交易成本这一关。外汇常见的点差成本是1~2个pip,如果模型预测的平均收益连成本都覆盖不了,再高的方向准确率也白搭。每笔扣掉点差成本后,再看累计收益、最大回撤和盈亏比三个值。方向准确率52%、盈亏比1.2、回撤控制在20%以内,我才会觉得这个模型有继续迭代的价值;如果只有方向准确率好看而扣成本后是负的,那就该回去调整特征或者换预测周期了。
把上面几步连起来,你会得到一套相对诚实的工作流:先定数据和标签,再定模型和参数,最后用walk-forward和成本核算验证。这个流程没有银弹,LSTM做外汇预测本来就是从高噪声里找微弱信号,能稳定跑通并知道自己每一步在做什么,已经比大多数只会对着训练曲线嗨的人走得更远。我自己的习惯是保留一个baseline模型目录,每次实验先跑朴素基准,再跑LSTM,只要LSTM打不过baseline就当这次实验失败,这个习惯帮我避开了不少自我感动。希望帮到你。
本文还有配套的精品资源,点击获取