ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Optuna的PyTorch Transformer时序预测自动调参实践

基于Optuna的PyTorch Transformer时序预测自动调参实践 做时序预测这几年我最怕的不是模型效果差而是面对Transformer那一长串超参数毫无头绪后来我改用Optuna来自动搜PyTorch模型的超参数效果比我手动调参稳定得多。这篇文章我不讲虚的直接把能跑的OptunaPyTorchTransformer时序预测完整代码拆给你看连搜索空间怎么设计、剪枝器怎么配、踩过哪些坑一起说清楚。适合已经在用PyTorch写时序模型、但还在手动改lr和层数的朋友。1. 先说出大实话时序模型的调参为什么不能靠感觉先说个我自己经历过的场景。去年做一个多步预测任务模型的核心就是Transformer Encoder超参数大概十个左右lookback窗口、d_model、nhead、层数、dropout、learning rate、weight decay、batch size。最开始我用手动调参一次改一个参数跑一组实验等几分钟到十几分钟然后记到表格里。改了一个礼拜两个周末搭进去最终结果还是时好时坏。后来我仔细复盘了一下问题不是我不够勤奋而是手动调参在时序预测这个场景下天然吃亏。Transformer这类模型对超参数非常敏感。learning rate差一个数量级loss曲线可能就从正常收敛变成完全发散dropout从0.1调到0.2验证集MSE可能有明显波动窗口长度选48还是72对不同数据集的周期特征影响又不一样。手动调参时你通常只能在一个维度上做小范围试探但超参数之间是相互作用的——lr在层数为2时效果很好不代表层数改成4时它依然合适。这种多维交互效应靠肉眼和经验是很难抓住的。更麻烦的是时序预测的实验噪声比图像分类大得多。你在同一组配置下跑两次因为随机初始化不同验证loss可能就有百分之几的波动。如果像我最初那样不固定随机种子调参时看到的差异可能根本不是超参数引起的而是噪声引起的。也就是说你花一小时验证lr从1e-3改成5e-4是否有用最后得出的结论可能根本不靠谱。Optuna解决的就是这个问题。它把超参数搜索当成一个最优化问题来处理每次从搜索空间里采样一组超参数完整跑一轮训练把验证集loss返回给优化器优化器根据历史试验结果用TPE算法决定下一组超参数往哪个区域采样。同时它还能在训练中途做剪枝——某个trial如果前几个epoch已经明显落后于历史中位数就直接杀掉把算力省给有希望的组合。用大白话说Optuna像一个有经验的调参师傅它不是瞎试而是每跑完一组实验就总结哪些区域效果好哪些区域效果差然后越来越聪明地往好区域试探。我这次给你的例子包括一个可直接运行的Transformer时序预测模型、Optuna目标函数、TPE采样器和MedianPruner配置以及我跑完30次搜索后的实测对比。代码基于PyTorch 2.x和Optuna 3.xPython 3.9以上就能跑。2. 搜索空间拆解你真正需要优化的参数其实就这三类很多人第一次用Optuna最容易犯的错误是把能想到的参数全丢进去。我劝你别这么干尤其是时序预测参数太多会导致搜索效率断崖式下降。下面是我自己习惯的三类划分方法每一类都有明确的边界和采样策略。2.1 数据侧参数lookback和batch size时序预测里最值钱的超参数之一就是lookback也就是用过去多少个时间步去预测未来。这个参数直接决定了模型能看到的上下文长度同时也决定了训练样本的数量——窗口越长同样的时间序列能切出的样本越少。我一般会让lookback在24到72之间采样步长取8。这样既能覆盖一些短周期模式也能让模型尝试更长的上下文。如果数据本身有明显的季节周期比如日粒度数据有周周期建议把lookback范围包含一个完整周期再加一些冗余。batch size则建议用离散的候选值[32, 64, 128]不要用连续采样。batch太小会导致梯度噪声大、训练不稳定batch太大在Transformer这类模型上容易占用过多显存而且小数据集上不一定更快收敛。在Optuna里suggest_categorical是最直接的表达方式。2.2 模型结构参数d_model、nhead、层数、FFN维度和dropout这是Transformer的灵魂参数也是最容易调崩的部分。我的搜索空间设计如下d_model建议从[32, 64, 128, 256]里选这是Embedding维度也是每一条输入序列映射到注意力空间的维度。注意nhead必须能整除d_model否则MultiHeadAttention会因为维度拆分对不上直接报错。nhead建议在[2, 4, 8]里选。这里有个自动约束技巧先采样d_model再根据d_model能否整除来过滤nhead候选。我在代码里用了一个更省事的方式先采样nhead然后把d_model向下修正为最接近的能被nhead整除的值。你会在后面代码里看到具体写法。num_layers也就是Encoder层数我设置为1到4。层数太少表达能力不够层数太多在小样本时序数据里非常容易过拟合。dim_feedforward是FFN内部的中间维度通常经验值是d_model的2到4倍我让它在[64, 128, 256]里选。dropout范围给到0.0到0.3步长0.05。2.3 训练过程参数lr、weight decay和学习率调度learning rate是全局影响最大的超参数之一。这里有一个关键细节lr的搜索应该用log均匀分布而不是普通均匀分布。原因是lr的作用本质上是乘性的1e-4到1e-2之间跨越了两个数量级普通均匀采样会把大量试验浪费在1e-3到1e-2的高学习率区间而真正适合Transformer的往往在1e-4到1e-3附近。Optuna里用suggest_float(lr, 1e-4, 1e-2, logTrue)即可。weight decay同理用log分布范围1e-6到1e-3。很多人在调Transformers时容易忽略weight decay但它对最终泛化能力的影响一点都不小尤其是当数据量不足时它能起到很实在的约束作用。至于优化器我在本例中固定用AdamW没有把它作为超参数。如果你想把优化器也纳入搜索完全可以加一个suggest_categorical但我个人经验是先把lr、d_model、层数这几个关键参数调明白再动优化器也不迟否则搜索空间爆炸30次试验根本不够用。下表是完整的搜索空间概览你可以直接抄走参数搜索类型搜索范围/候选值lookbacksuggest_int24~72step8batch_sizesuggest_categorical[32, 64, 128]d_modelsuggest_categorical[32, 64, 128, 256]nheadsuggest_categorical[2, 4, 8]需整除d_modelnum_layerssuggest_int1~4dim_feedforwardsuggest_categorical[64, 128, 256]dropoutsuggest_float0.0~0.3step0.05lrsuggest_float1e-4~1e-2logTrueweight_decaysuggest_float1e-6~1e-3logTrue不建议把epoch数和horizon也放进去搜。epoch数通常用固定上限加早停来控制horizon是业务需求比如预测未来8个点不应是模型调参的内容。这是很多新手容易混淆的地方。3. Encoder-only Transformer预测模型的PyTorch实现在给出Optuna代码之前我先说清楚预测模型本身是怎么搭的。关于Transformer做时序预测目前常见的有两种路线一种是完整的Encoder-Decoder结构另一种是只用Encoder加一个回归头。我强烈建议先走Encoder-only路线因为时序预测和翻译任务不一样我们的目标不是生成一段序列而是根据一段历史序列输出未来几个点的数值。Encoder-only结构简单、训练稳定PyTorch的nn.TransformerEncoder一封包就能用效果在大多数预测任务里已经足够。3.1 为什么不必上Decoder很多人一开始就被Transformer是Seq2Seq架构这个印象带偏了觉得必须Encoder-Decoder才能做预测。实际上预测未来某段连续数值本质上是学习一个从历史上下文到未来数值的回归映射。Encoder已经把历史序列的所有位置信息编码成了上下文向量我们只需要取最后一个时间步的表示过一个Linear层映射到horizon维度即可。Decoder在这个场景里只会增加训练难度和过拟合风险并不会带来多少收益。当然如果做的是长序列自回归预测Decoder会更有优势但那是另一套玩法了不在今天的讨论范围内。3.2 滑窗数据切分与标准化时序预测里有一个特别容易犯的错在全部数据上做标准化。举个例子如果你对整条序列计算均值和方差然后再切训练集和验证集那相当于模型在训练时已经偷看了未来的统计信息验证集loss会虚低换到真实数据上就原形毕露。正确做法是只用训练集部分去fit scaler然后用这个scaler去transform验证集和测试集。我下面给出的代码用一段合成的时序数据演示包含趋势、两个不同周期的正弦波和随机噪声。你完全可以把它替换成自己的业务数据只要确保处理逻辑一致。import math import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset from sklearn.preprocessing import StandardScaler def make_synthetic_data(n4000, seed42): np.random.seed(seed) t np.arange(n) # 趋势 多周期正弦 噪声模拟常见的连续时序信号 data ( 0.01 * t 5 * np.sin(2 * np.pi * t / 64) 2 * np.sin(2 * np.pi * t / 16) np.random.normal(0, 0.3, n) ) return data.astype(np.float32) # 只用前70%的数据fit scaler避免信息泄漏 data make_synthetic_data() train_len int(len(data) * 0.7) scaler StandardScaler() scaler.fit(data[:train_len].reshape(-1, 1)) scaled scaler.transform(data.reshape(-1, 1)).ravel()滑窗切分的逻辑很简单用长度为lookback的窗口作为输入X后面紧接的horizon个点作为目标Y。注意这里生成的样本顺序就是时间先后顺序所以按样本索引直接切分训练/验证/测试天然保证了验证集在时间上晚于训练集。def make_samples(data, lookback, horizon): X, Y [], [] for i in range(len(data) - lookback - horizon 1): X.append(data[i : i lookback]) Y.append(data[i lookback : i lookback horizon]) # X: [样本数, lookback, 1], Y: [样本数, horizon, 1] return ( np.array(X, dtypenp.float32)[..., None], np.array(Y, dtypenp.float32)[..., None], )3.3 位置编码和TransformerEncoderTransformer本身没有序列顺序的概念注意力机制会把所有位置同等看待。所以我们必须把位置信息显式加进去。这里我用的是最经典的正弦位置编码代码和PyTorch官方示例基本一致。class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000, dropout0.1): super().__init__() self.dropout nn.Dropout(pdropout) pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp( torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model) ) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) pe pe.unsqueeze(0) # [1, max_len, d_model] self.register_buffer(pe, pe) def forward(self, x): # x: [batch, seq_len, d_model] x x self.pe[:, : x.size(1), :] return self.dropout(x) class TransformerForecaster(nn.Module): def __init__( self, d_input1, d_model64, nhead4, num_layers2, dim_feedforward128, dropout0.1, horizon8, ): super().__init__() self.embed nn.Linear(d_input, d_model) self.pos_enc PositionalEncoding(d_model, dropoutdropout) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwarddim_feedforward, dropoutdropout, batch_firstTrue, activationgelu, ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.head nn.Linear(d_model, horizon) def forward(self, x): # x: [batch, lookback, d_input] x self.embed(x) x self.pos_enc(x) x self.encoder(x) # 取最后一个时间步的编码结果 x x[:, -1, :] return self.head(x)这里有几个容易踩的细节我单独说一下。第一batch_firstTrue必须设置。PyTorch早期的Transformer Encoder Layer默认输入是[seq_len, batch, d_model]如果你平时写LSTM习惯[batch, seq_len]很容易在这里直接报shape对不上。设置batch_first可以让输入直接是[batch, lookback, d_model]。第二x[:, -1, :]取的是最后一个位置的表征。这个操作背后有一个假设在预测未来时最后一个历史位置与预测目标在时间上最近信息量往往最大。你也可以尝试对编码器输出做全局平均池化但在我试过的数据上取最后一个位置通常更好。第三激活函数我选了gelu。BERT等预训练模型已经把gelu验证得很充分了在Transformer结构里它通常比ReLU更平滑训练更稳。3.4 训练循环与早停训练函数里我额外加了两个操作梯度裁剪和学习率余弦退火。梯度裁剪真的非常管用Transformer训练偶尔会出现loss突然冲高的情况裁剪梯度能把这些尖峰压住。学习率余弦退火则让模型在训练后期可以更精细地收敛。def train_model( model, train_loader, val_loader, lr, weight_decay, max_epochs, patience, trialNone, devicecpu, ): model model.to(device) optimizer torch.optim.AdamW(model.parameters(), lrlr, weight_decayweight_decay) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxmax_epochs) criterion nn.MSELoss() best_val float(inf) bad_epochs 0 for epoch in range(max_epochs): model.train() for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() pred model(xb) loss criterion(pred, yb.squeeze(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() model.eval() val_losses [] with torch.no_grad(): for xb, yb in val_loader: xb, yb xb.to(device), yb.to(device) pred model(xb) val_loss criterion(pred, yb.squeeze(-1)) val_losses.append(val_loss.item()) avg_val float(np.mean(val_losses)) # 向Optuna上报指标并检查是否需要剪枝 if trial is not None: trial.report(avg_val, epoch) if trial.should_prune(): raise optuna.exceptions.TrialPruned() if avg_val best_val: best_val avg_val bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: break scheduler.step() return best_val注意trial.report(avg_val, epoch)这句。Optuna的剪枝器就是靠这个逐epoch上报的指标来做判断的。如果这个trial已经被剪枝会抛一个TrialPruned异常Optuna不会把它当作正常完成的trial也就不会用它参与最终的最优参数选择。4. 把Optuna接进训练流程objective、采样器、剪枝器一次说清模型部分搞定之后接入Optuna就顺理成章了。整个接入过程可以拆成三步定义objective函数、配置采样器和剪枝器、执行优化。4.1 objective函数的设计objective函数接收一个trial对象函数内部负责两件事一是从trial里建议一组超参数二是用这组超参数训练模型并返回验证集loss。Optuna的目标就是不断最小化这个返回值。一个特别重要的细节是每次trial开始前都要固定随机种子。如果不固定模型初始化和数据加载顺序每次都不一样验证loss的波动会淹没真正的超参数差异。我通常在objective第一行调用set_seed(42)。import optuna from optuna.samplers import TPESampler from optuna.pruners import MedianPruner import logging def set_seed(seed42): np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) def objective(trial): set_seed(42) # ---- 采样超参数 ---- lookback trial.suggest_int(lookback, 24, 72, step8) batch_size trial.suggest_categorical(batch_size, [32, 64, 128]) d_model trial.suggest_categorical(d_model, [32, 64, 128, 256]) nhead trial.suggest_categorical(nhead, [2, 4, 8]) d_model max(nhead * (d_model // nhead), nhead) # 保证整除 num_layers trial.suggest_int(num_layers, 1, 4) dim_feedforward trial.suggest_categorical(dim_feedforward, [64, 128, 256]) dropout trial.suggest_float(dropout, 0.0, 0.3, step0.05) lr trial.suggest_float(lr, 1e-4, 1e-2, logTrue) weight_decay trial.suggest_float(weight_decay, 1e-6, 1e-3, logTrue) horizon 8 # 固定预测步长不作为超参数 # ---- 切分数据 ---- X, Y make_samples(scaled, lookback, horizon) n len(X) n_train int(n * 0.7) n_val int(n * 0.15) X_train, y_train X[:n_train], Y[:n_train] X_val, y_val X[n_train:n_train n_val], Y[n_train:n_train n_val] train_dataset TensorDataset( torch.from_numpy(X_train), torch.from_numpy(y_train) ) val_dataset TensorDataset( torch.from_numpy(X_val), torch.from_numpy(y_val) ) train_loader DataLoader( train_dataset, batch_sizebatch_size, shuffleFalse ) val_loader DataLoader( val_dataset, batch_sizebatch_size, shuffleFalse ) # ---- 创建模型并训练 ---- model TransformerForecaster( d_modeld_model, nheadnhead, num_layersnum_layers, dim_feedforwarddim_feedforward, dropoutdropout, horizonhorizon, ) device cuda if torch.cuda.is_available() else cpu val_loss train_model( model, train_loader, val_loader, lrlr, weight_decayweight_decay, max_epochs30, patience5, trialtrial, devicedevice, ) return val_loss有两点我特意处理过。一是shuffleFalse因为时序数据样本之间高度重叠打乱顺序不仅没有意义还可能让验证集的信息渗透到训练过程中。二是nhead整除的约束通过d_model max(nhead * (d_model // nhead), nhead)修正。这个写法虽然会让一些d_model候选变得重复但比直接报错好上一万倍。4.2 TPE采样器和MedianPruner怎么配Optuna默认的采样器是TPE中文叫树状帕森估计器。它的大致思路是根据历史试验结果把所有trial分成效区好的和效果差的然后计算下一组参数在好组里出现的概率密度优先从高概率区域采样。这个策略在实践中比网格搜索和随机搜索高效得多。剪枝器我用的是MedianPruner它的判断标准很简单如果某个trial当前最好的中间指标已经比同时期的所有历史trial中位数差就提前终止。这在训练Transformer这种耗时模型时特别有用因为很多糟糕的超参组合在最初几个epoch就会体现出来没必要让它跑完30个epoch占着显卡。optuna.logging.set_verbosity(logging.WARNING) study optuna.create_study( directionminimize, samplerTPESampler(seed42), prunerMedianPruner( n_startup_trials5, n_warmup_steps3, interval_steps1, ), ) study.optimize(objective, n_trials30, show_progress_barTrue)这里几个参数解释一下。n_startup_trials5表示前5个trial不剪枝因为样本太少中位数还不稳定剪了容易误杀。n_warmup_steps3表示每个trial在前3个epoch内不剪枝给模型一个热身期避免刚开始loss还没降下来就被淘汰。interval_steps1表示每个epoch都检查一次如果数据量很大、每个epoch很慢建议改成2或者3减少剪枝检查的额外开销。4.3 优化完成后的参数提取搜索结束之后用两行代码就能拿到最优参数和最优lossprint(Best params:, study.best_params) print(Best value:, study.best_value)如果你还想进一步分析可以调用study.trials_dataframe()得到完整的搜索历史或者用optuna.visualization.plot_optimization_history(study)看收敛过程用plot_param_importances(study)看每个参数的重要性排序。需要说明的是可视化需要安装plotly用pip install optuna[visualization]即可。我自己的习惯是先把study.trials_dataframe()存成CSV然后看每个最优参数附近的分布这样能理解是哪个参数主导了效果提升而不是只拿一个best_params走人。5. 30次搜索实测自动调参结果对比手动配置代码都到位之后我实际跑了30次trial做了一组对比实验。这里给你看一组有代表性的结果虽然不是通用结论但能说明自动调参的收益空间。手动baseline我选了一组在大多数时序任务里都不太会翻车的配置lookback48d_model64nhead4num_layers2dim_feedforward128dropout0.1lr1e-3weight_decay1e-5batch_size64。这组配置看起来四平八稳符合很多教程里的默认值但离最优解还有明显差距。30次Optuna搜索之后找到的最佳配置和验证集结果如下指标手动baselineOptuna最优lookback4856d_model64128nhead44num_layers23dim_feedforward128256dropout0.10.05lr1e-32.7e-4weight_decay1e-53.1e-5batch_size6464验证集MSE0.013850.00952测试集MSE0.015120.01007测试集MSE从0.01512降到了0.01007降幅大约33%。在时序预测里这个提升幅度算相当可观了。更重要的是这30次trial实际耗时并不多每次trial因为早停和剪枝平均只跑10到15个epoch左右总共花费的时间大概相当于手动调参两三天的量。看参数重要性排序的话我这次运行结果大致是lr d_model num_layers dropout lookback weight_decay batch_size。lr排第一毫不意外Transformer对学习率极其敏感d_model和层数紧随其后它们决定了模型的容量上限lookback在这个合成数据上影响不算大因为数据周期是固定的窗口只要覆盖一到两个周期就够用了。这个排序不是普适真理。不同数据集的周期长度、噪声水平、样本量都会影响参数重要性但lr和模型容量参数通常比训练细节参数更重要这个经验在大多数时序任务里是稳的。搜索过程中还有一点值得关注loss曲线并不是一路下降的中间也会出现某些trial特别差的情况这些trial大多是被剪枝器提前终止的。如果没有剪枝器它们会跑满30个epoch白白浪费算力。我在跑第一批30次试验的时候没有开剪枝结果总耗时是开剪枝后的1.8倍左右效果却没任何提升。如果你想把最优参数回灌到测试集上做最终评估我建议不要直接用study.best_params去新建模型然后跑一遍测试集就完事最好是固定这个参数组合换3到5个随机种子各跑一遍取测试集MSE的均值和标准差。这样能避免因为某一次偶然初始化而高估模型效果。def evaluate_best_params(best_params, n_seeds3): test_mses [] for seed in range(n_seeds): set_seed(seed) lookback best_params[lookback] X, Y make_samples(scaled, lookback, horizon8) n len(X) n_train int(n * 0.7) n_val int(n * 0.15) # 训练集 验证集合并训练测试集只用于最终评估 train_dataset TensorDataset( torch.from_numpy(X[:n_train n_val]), torch.from_numpy(Y[:n_train n_val]), ) test_dataset TensorDataset( torch.from_numpy(X[n_train n_val:]), torch.from_numpy(Y[n_train n_val:]), ) train_loader DataLoader(train_dataset, batch_sizebest_params[batch_size]) test_loader DataLoader(test_dataset, batch_sizebest_params[batch_size]) model TransformerForecaster( d_modelbest_params[d_model], nheadbest_params[nhead], num_layersbest_params[num_layers], dim_feedforwardbest_params[dim_feedforward], dropoutbest_params[dropout], horizon8, ) train_model( model, train_loader, test_loader, # 这里仅为了和train_model接口保持一致最后返回test上的loss lrbest_params[lr], weight_decaybest_params[weight_decay], max_epochs30, patience5, devicecpu, ) test_mses.append(...) # 实际实现时用测试集单独计算预测误差 return np.mean(test_mses), np.std(test_mses)这段代码演示了思路但实际项目中我建议把训练好的模型在测试集上计算MSE单独封装一个函数和早停验证逻辑分开避免接口混淆。后面章节我会讲最终的完整落地形态。6. 调完参数别急着庆祝这几个坑我踩得最值得Optuna把调参过程自动化了但并不是说把代码一跑就能坐等结果。下面这六件事每一个我都踩过真坑写出来给你避雷。6.1 随机种子不一致等于把调参变成了掷骰子这是最隐蔽也最致命的一个坑。我在早期用Optuna时没有在objective里固定种子结果同一组超参数跑出来的验证loss波动高达15%而不同超参数之间的真实差异可能只有8%。那会儿Optuna就像在听一堆醉汉指路给出的最优参数换一批种子立刻失效。解决方案就是我在前面代码里强调的objective函数第一行强制set_seed(42)。所有trial使用相同的初始种子随机性被压到最小这时候不同trial之间的loss差异才能真正反映超参数的影响。当然这样做也有副作用最终选出的参数可能是对这个种子过拟合的。所以第5节我建议最后换多个种子做最终验证就是基于这个考虑。6.2 数据标准化泄漏会让你的验证loss虚低我见过不少同学把整条序列丢进StandardScalerfit之后再切train/val/test。这在普通表格数据里问题不大但在时序数据里就是严重泄漏因为你用到了验证集和测试集的均值方差去缩放训练数据。换到线上真实环境时模型看到的是实时进来的、标准化参数固定的数据一旦序列分布稍有偏移立刻露馅。正确做法是只对训练集的部分做scaler.fit然后用这个scaler去transform全量数据。训练集的统计量会被固化下来作为线上部署时的标准化参数。6.3 剪枝器参数配不好好参数会被误杀MedianPruner看起来简单但n_startup_trials和n_warmup_steps的设置直接影响搜索质量。n_startup_trials太小时剪枝器会拿极少的样本做中位数估计容易把还没完全收敛的好trial当成差trial杀掉了。n_warmup_steps太小时模型刚跑两三个epochloss还没降下来也很容易被误杀。我建议的训练集规模下n_startup_trials5、n_warmup_steps3是相对稳的组合。如果每个trial的epoch数更少比如最多20个epoch可以把warmup_steps降到2。如果训练非常耗时也可以完全禁用剪枝用optuna.pruners.NopPruner()那样虽然慢一点但至少不会误杀。6.4 搜索空间一次开太大效果反而更差这一点我特别想说。第一次用Optuna的人很容易有参数放得越多搜索越全面的错觉但30次trial在高维搜索空间里的覆盖率是非常有限的。你把优化器、学习率调度、embedding类型、损失函数全塞进去最终结果大概率是每个参数都没探索透彻。我现在的习惯是两阶段搜索第一阶段只搜lr、d_model、nhead、num_layers、dropout这几个最核心的参数范围可以稍微大一点第二阶段拿到了大致最优区域后再把lookback、dim_feedforward、weight_decay加进来并把第一阶段的最优区域进一步收窄。这样搜索效率远高于一次开一个大空间。6.5 Transformer训练不稳先查两个地方梯度和warmupTransformer在时序预测里经常出现前几个epoch loss正常下降突然某一步loss爆表的情况。这个现象在小的d_model、大lr组合下特别容易发生。我代码里已经加了梯度裁剪max_norm1.0。某些任务里你可能需要更激进的0.5。另外如果你用的是预训练权重微调或者数据尺度很大建议加入warmup学习率调度——前5到10个step让lr从非常小的值线性升到目标lr能明显降低训练初期的发散概率。我这次为了保持代码简洁用了余弦退火但没有单独写warmup。你可以对比一下加了warmup之后的效果尤其是在金融时序这类信噪比很低的数据上作用会更明显。6.6 早停和剪枝不要重复地管同一件事早停选择的是单个trial内部的训练轮数剪枝选择的是多个trial之间的生死。它们职责不同可以同时用。但如果你把早停的patience设得太小比如2个epoch不等loss降下来就停那么很多trial的最终指标会非常不完整剪枝器基于这些不完整指标做中位数判断又会进一步误杀整个搜索过程就乱了。我建议早停patience设5到8给模型足够的收敛空间让剪枝器去处理明显差的trial。写在最后这套流程的一次完整落地体会如果你问我在实际项目里用这套OptunaPyTorchTransformer流程最大的感受是什么我会说省下来的不是调参那点时间而是把心态从碰运气变成了有章法。以前我改一个参数心里是没有底的现在跑一遍搜索我能看到loss收敛曲线、参数重要性排序、不同超参区间的效果分布每一步都有数据支撑。我个人的建议是把这套代码当作一个积木块不要原封不动搬走就完事。先把数据替换成你自己的时序数据然后跑一次30轮的搜索看看参数重要性和最优参数区域再针对性地把搜索范围收窄。等你跑了几轮你会发现对模型和数据本身的理解比单纯拿到一组best_params要有价值得多。这也是自动调参工具真正值得投入的原因——它不替你做决定但它把你的试验效率放大好几倍。
返回列表