如果你跑Transformer模型训练时还只会盯着终端里滚动的loss发呆,那这篇内容能帮你省掉好几个熬夜的晚上。前阵子我做一个小型序列生成模型的训练任务,结构就是常见的Transformer decoder,可训练到第三天,val loss突然从稳定下降变成周期性反弹。不是NaN也不是发散,而是那种"涨一点、跌一点、反反复复"的节奏。我对着终端看了半小时,完全无从下手。后来把所有监测手段补上,才定位到根因——验证集的batch没有打乱,同一批超长文本被反复评估,文本长、loss高,于是曲线出现了"周期峰"。这个经历让我对训练追踪与监测有了全新的认识:它不是训练跑完后翻出来的记录,而是让模型内部状态在训练过程中"开口说话"。
这篇Transformer实战(37)就专门讲模型训练追踪与监测。不管你是用现成框架调包炼丹,还是像我一样手写训练循环,下面这些关于指标口径、日志基建、可视化面板和梯度诊断的经验都能直接用。我会把每次训练里实际遇到过的坑也一并写出来,尽量做到可复现、可落地。
1. 盯住哪些指标才叫"训练有数":先把监测目标清单拉出来
训练追踪这件事,最忌讳的是"想到什么记什么"。今天看loss,明天看显存,后天忘了自己昨天看过什么——这不是监测,是刷屏幕。我建议在训练任何Transformer模型之前,先把监测目标清单写成一张表,贴在自己能看见的地方,然后老老实实按表执行。
1.1 训练指标的全景清单:loss之外我们常常漏掉什么
我习惯把Transformer训练中需要盯的指标分成四类:标量类、分布类、采样类、快照类。别小看这个分类,它决定了你的日志系统里到底要留哪些字段。
| 类别 | 常见指标 | 监测目的 |
|---|---|---|
| 标量类 | train loss、val loss、perplexity、learning rate、吞吐量 tokens/s、显存占用 | 判断整体收敛水平和资源利用情况 |
| 分布类 | 权重直方图、梯度直方图、激活值分布、logits 尺度 | 提前暴露数值异常和层内退化 |
| 采样类 | 验证集生成的文本、翻译case、mask比例 | 定性确认模型输出是否符合预期 |
| 快照类 | checkpoint 保存时间、best checkpoint 指标、数据加载耗时 | 保证训练可回放、可恢复、可归因 |
很多人在loss之外从不看其他指标,这是个危险习惯。Transformer的loss曲线往往比CV模型平滑得多,收敛好坏藏在细微走势里。比如梯度分布,它常常在loss还在下降时就开始劣化,等loss反映出来,问题已经滚雪球了。还有吞吐量,如果某天发现tokens/s突然掉了一半,多半是数据加载环节出了幺蛾子,而不是模型问题。这类基础指标虽然不直接参与梯度计算,但训练崩了、卡了,往往先从它们身上暴露。
另一个容易被漏掉的是数据侧的指标,尤其是padding占比和序列长度分布。Transformer对变长序列做batch时要用padding对齐,如果padding比例过高,模型大部分计算都在填充位上做无用功,loss还会被人为稀释。把mask比例、每batch平均有效token数记下来,很多"loss怎么一直不降"的怪问题都能从这里找到答案。
1.2 指标口径的隐性差异:同一份loss为什么不能直接对比
指标清单列出来了,真正的坑是"算法口径"。我吃过一次亏:拿着两份实验结果对比,一份loss降到1.3,另一份只有0.9,以为是后者模型更好,最后才发现两份代码处理padding的方式不一样。
Transformer训练里,loss计算最常见的口径问题是reduction方式。同样的logits和labels,用"所有token的平均"还是"有效token的平均"来算,结果能差出好几个量级。如果batch里padding token特别多,把padding也平均进去,loss会被人为压低,而且这个压低幅度随序列长度波动。你看到"loss下降了",其实只是这批数据变短了。推荐的做法是写一个显式的masked loss函数,把padding剔除后再reduce:
import torch.nn.functional as F def masked_cross_entropy(logits, labels, mask): # logits: [batch, seq_len, vocab] # labels: [batch, seq_len] loss = F.cross_entropy( logits.reshape(-1, logits.size(-1)), labels.reshape(-1), reduction="none", ) loss = loss.view(labels.size()) loss = loss * mask.float() return loss.sum() / mask.sum().clamp(min=1)另一个口径问题是"按token平均"还是"按序列平均"。做机器翻译时大家更关注sequence-level质量,但训练loss通常按token-level计算更平滑。调参时拿token-level loss的绝对值去对比别人论文里的序列级loss,没有任何意义。我之前训序列生成模型时,内部日志和外部数字对不上,就是差在这个口径上。所以定监测方案的第一步,是把你要用什么口径的指标固定下来,写进文档或者注释里,免得三天后自己都忘了。
2. 日志与检查点基建:让训练过程从不可视变成可回放
指标清单定了,接下来要搭"让指标流动起来"的基础设施。没有好的日志和检查点机制,前面列的再完整也是空谈。
2.1 结构化日志:不要把print当日志用
新手最常见的做法是训练循环里print几个变量:print(f"step {i}: loss {loss.item()}"),然后就没有然后了。print的问题在于它只存在于终端滚动区,等你想回头看的时候早就被刷没了。
我现在统一用Python的logging模块,配成结构化JSON输出。核心就一个函数:
import json import logging logger = logging.getLogger("train") def log_metrics(step, epoch, metrics): record = {"step": step, "epoch": epoch, **metrics} logger.info(json.dumps(record))这样设计有三个好处。第一,trainer逻辑和日志逻辑分离,后续想改输出格式不用动训练代码;第二,JSON格式可以被任意解析工具读取,resume训练时还能直接load历史曲线;第三,每条日志只占一行,不互相污染,终端看着清爽,检索起来也方便。在训练循环里,我一般每50步通过logger.info写一条记录,每个epoch结束再写一次特别标记的summary记录。
2.2 分布式训练下的日志收敛与检查点轮换
如果用的是DDP多卡训练,日志混淆是个容易踩的坑。每个进程都print,输出会交错在一起,根本没法看。我的方案是只让rank0进程输出详细日志,其他进程只保留ERROR级别输出。如果确实想看每个rank的信息,在日志里加rank字段,但默认别开。
检查点保存也是追踪体系的一环。你得能从检查点里恢复训练、复现最优结果,否则整个训练等于白跑。我的固定套路是:
- 每个epoch保存一个完整checkpoint,包含模型参数、optimizer状态、scheduler状态、EMA状态(如果有)、当前step和epoch计数;
- 单独维护一个best checkpoint,按验证集目标指标决定是否覆盖;
- 磁盘紧张时,只保留最近3个epoch的checkpoint,但best永远单独保留。
state = { "step": step, "epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "best_metric": best_metric, } torch.save(state, f"checkpoints/epoch_{epoch}.pt")保存checkpoint这件事本身也要纳入监测:每次写盘记录耗时,如果发现保存时间越来越长,说明磁盘在老化或者IO路径有问题,这时候别硬扛,先排查存储。
3. 可视化面板之外,训练曲线到底在说什么
日志是给机器看的,人还得靠眼睛。可视化这块我用过不少方案,这里只讲两条我认为最有效的主线:本地面板和实验追踪平台。
3.1 本地可视化面板:scalar、histogram与graph怎么组合着看
我用得最多的本地工具还是TensorBoard,配合PyTorch的SummaryWriter,几分钟就能跑起来:
from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter(log_dir="runs/transformer_t1") writer.add_scalar("loss/train", train_loss, step) writer.add_scalar("loss/val", val_loss, step) writer.add_scalar("lr", current_lr, step) writer.add_histogram("weight/embeddings", emb.weight.data, step) writer.add_text("sample", generated_text, step)启动方式也简单,命令行一条:
tensorboard --logdir runs --port 6006路径设计上建议按runs/实验名组织,多个实验放同一个父目录,面板里可以直接并排对比。观察曲线时我有几个固定习惯:train_loss和val_loss必须同时看,两者之间的gap是过拟合的风向标;学习率曲线要叠在同屏,如果你用了warmup+decay,看到loss某个点波动先别慌,看看是不是正好在调低学习率;加强面板里还有一个容易忽略的view——按相对时间对齐曲线,这样不同速度的实验也能对比走势。
histogram怎么看呢?重点看分布形态的演化,而不是单看某一时刻。Transformer权重直方图正常情况下是缓慢漂移,分布形状相对稳定。如果某层权重突然全部挤到一个极窄的区间,或者出现多个离散峰,说明这一层已经失去表达能力了,这时候loss曲线可能还好好的。
3.2 实验追踪平台:多组实验横评与超参数留档
本地TensorBoard的短板在于模型一多、超参一多,横向比较就很不方便。所以我后续习惯在训练一开始就把超参和指标同步记录到在线的实验追踪平台。这类平台的基本思路是:一次训练叫一个run,run里存config和metrics,网页上可以把多个run拉进同一张图里对比。
实操上,我在训练脚本最前面放一个配置字典,把learning rate、batch size、hidden size、层数、dropout、数据规模这些一次性传给追踪服务;训练过程中每步或每几步同步丢一条metric。跑完一组实验后,我不用翻目录看日志,直接筛选所有lr=3e-4或hidden=512为主的run,看平均表现和最优表现。这个习惯帮我省下了大量"咦,上次那个实验参数是什么来着"的时间。
一个经验是:config和metric的命名要预先约定好。比如学习率统一叫lr而不是learning_rate,batch size统一叫batch_size而不是bs。不然你跑了二十个run之后想筛选,光字段名不统一就能让人崩溃。这些小规矩写在团队的训练规范里,一个人跑单的时候也照样受益。
4. 梯度范数与参数分布:训练异常的早期预警
Transformer训练失败,最常见的两个病根是梯度爆炸和梯度消失。这两个问题在loss上的表现,往往滞后于梯度本身的异常。所以我把梯度监控放在很高优先级。
4.1 梯度范数:一个能同时报警"爆炸"和"消失"的哨兵
梯度爆炸的经典表现是loss突然变成NaN或急剧升高,但更微妙的情况是:grad norm某一步突然冲到几千,然后被grad clip按下去,你又没记录这个过程,只看到loss小幅波动。这种感觉最难受,因为你不知道问题是偶尔一次还是反复发生。
我的做法是每个step计算一次全局梯度范数并记入日志。计算方法很直接:
def compute_grad_norm(model): total_norm = 0.0 for p in model.parameters(): if p.grad is not None: param_norm = p.grad.data.norm(2) total_norm += param_norm.item() ** 2 return total_norm ** 0.5有了这个值,训练追踪与监测才算"闭环":loss下降、grad norm稳定在一个合理区间,模型才真的在健康学习。如果grad norm持续低于某个极小值(比如1e-4),大概率是初始化不好、残差连接被破坏或者层归一化位置不对,跟loss停滞对照着看就能定位。
我还会把每一层的梯度分布hook出来。这里给一个可用的最小实现:
def add_grad_hooks(model): def make_hook(name): def hook_fn(module, grad_input, grad_output): norms = [g.norm(2).item() for g in grad_output if g is not None] if norms: logger.info("layer=%s grad_out_norm=%.6f", name, max(norms)) return hook_fn for name, module in model.named_modules(): module.register_full_backward_hook(make_hook(name))全局范数看整体,分层范数找病灶,两个搭配起来,梯度问题基本能在第一时间暴露。
4.2 权重分布与激活值诊断:区分"模型坏了"还是"数据坏了"
只看梯度还不够,权重分布能进一步告诉我们病根。Transformer里 Embedding 层和最后一层往往权重范数变化最快,中间层相对平缓。如果中间层出现剧烈分布漂移,就要警惕有层在退化。
我有一次遇到loss阶梯式下降后再也不动,打开权重直方图一看,某一层权重全挤在0附近,另一层却出现明显离群峰——这就是典型的层表达退化。配合梯度范数定位后,确认是初始化尺度不合适。
更重要的是,权重监控要和输入数据关联起来。如果怀疑数据问题,我会把输入mask比例、序列长度分布、padding占比也加进面板,和权重直方图放在一起看。这样判断"模型坏了"还是"数据坏了"就用不着猜了——数据侧指标异常就是数据问题,数据侧正常但权重退化就是模型问题。这个区分能力在长时间训练里非常宝贵。
5. 一次loss曲线异常的完整排查链路与告警阈值配置
理论说了这么多,我最后用一个真实排查过程收尾,顺便给出一套告警阈值配置的经验值。
5.1 复现一次排查:从现象、假设到根因
现象非常典型:训练到第三天,val loss从持续下降变成"下降—反弹—下降—再反弹"的周期振动,train loss倒是还在下降。我当时的第一反应是scheduler出了问题,但看了日志发现反弹周期是每3个epoch一次,而scheduler是每step更新的,时间对不上。
第二步,我盯上了评估数据。翻数据加载代码,发现验证集构造batch时没有打乱,不同epoch的验证batch排序完全一样。再追下去就清楚了:每3个epoch会循环到同一批超长文本,文本越长loss越高,于是曲线出现周期性反弹。根因确认后我做了两件事:修正数据加载顺序,把"验证集每batch的平均序列长度"也纳入监测指标。修复后val loss曲线恢复正常波动。
这个案例有很强的通用性:如果你发现loss周期波动,优先检查两个东西——学习率调度的周期性和数据加载顺序。这两个环节隐藏在训练循环之外,最容易成为"看不见的黑手"。而监测系统真正的作用,就是把这些藏起来的东西,变成一条一眼就能看到的曲线。
5.2 告警阈值如何配置:既要灵敏又别天天响
追踪和监测做过头也会出事——告警疲劳。阈值太紧,每个小时都在响,你很快会无视它;阈值太松,告警失去意义。我的做法是分级告警:
| 告警级别 | 触发条件 | 处理方式 |
|---|---|---|
| 严重 | 梯度全局范数 > 10 或 loss 出现 NaN/inf | 立即停住,保存现场 |
| 严重 | 连续3个step的loss是NaN/inf | 立即停住,定位数据源 |
| 提醒 | val loss超过历史最优的20%以上且持续2个epoch | 训练结束后再排查 |
| 提醒 | 吞吐量低于正常值一半 | 检查数据加载和IO |
这些数值是基于我的训练任务调出来的,你换数据换模型要重新校准。比如梯度范数上限,得配合你grad clip的设置;如果clip value设的是1.0,那上限可以再放宽一点。关键是分级的思路:能救命的问题走严重通道,影响效率的问题走提醒通道,不要把所有异常都混在一起。
最后再分享一个小习惯:每次训练新模型,我都先跑一个很小的step数,比如200步,把loss、grad norm、学习率、吞吐量这些面板全部打开扫一遍,确认loss确实会降、grad norm确实在合理范围、学习率曲线确实在走。这一步虽然只需要十分钟,但能避免你在错误配置上白跑十几个小时。我吃过这个亏,希望你不用再吃。如果这几百步的数值看起来健康,再放心去跑长训练——所谓训练追踪与监测,说到底不是为了看一堆曲线,而是为了在模型彻底坏掉之前,给你一个停下来思考的机会。