1. 为什么训练过程需要一双“眼睛”
搞深度学习的人都有一个共同的痛点:模型跑起来了,loss 在跳,准确率在爬,但你完全不知道它到底在干什么。你盯着终端里一行行滚动的日志,心里想的只有一件事——它到底是在收敛,还是已经跑偏了?
我刚开始做图像分类项目那会儿,训练一个卷积网络,日志里 loss 从 2.3 慢慢降到 0.4,看起来一切正常。结果训了八个小时拿出来一测,验证集准确率还不如随机猜。回头翻日志才发现,验证 loss 从第三个 epoch 就开始往上走了,只是当时没人提醒我,我自己也没注意到。如果当时有可视化工具把训练曲线画出来,这个问题五分钟就能发现。
这就是TensorBoard存在的意义。它本质上是一个训练过程的可视化工具,把原本藏在日志里的标量、图像、计算图、参数分布这些东西,用图形界面的方式呈现出来。你能实时看到 loss 曲线是不是健康、权重分布有没有异常、梯度是不是消失了、模型结构长什么样。对于深度学习从业者来说,它不是锦上添花的东西,而是排查问题、调参、写论文、做汇报的刚需工具。
这篇文章适合三类人看:第一类是刚入门深度学习、还在用 print 大法调模型的新手;第二类是已经会跑训练但没系统用过可视化、想把这套东西补起来的中级开发者;第三类是需要做实验记录和结果展示、想把训练过程管理得更规范的研究人员。我会从整体设计思路讲到具体实操,把踩过的坑和总结出来的经验都摊开说,争取让你看完就能直接在自己的项目里用起来。
2. 可视化方案的整体设计与选型思路
2.1 为什么是 TensorBoard 而不是别的
可视化工具这个领域其实选择不少,有 TensorBoard、有 WandB、有 MLflow、有 Neptune,还有一些轻量级的自建方案。我选 TensorBoard 作为主线来讲,原因很实际。
第一,它是免费且本地化的。你不需要注册账号,不需要联网上传数据,所有东西都跑在本地,数据存在自己的磁盘上。对于公司内部项目或者数据敏感的场景,这一点几乎是决定性的。
第二,它和主流框架的集成度最高。PyTorch 从 1.2 版本开始就内置了torch.utils.tensorboard,TensorFlow 更是原生自带。你不需要额外装一堆 SDK,不需要改训练代码的架构,加几行调用就能用。
第三,它的功能覆盖面足够全。标量曲线、图像、直方图、计算图、嵌入向量投影、超参数对比,这些常用的可视化需求它都能满足。虽然单项功能可能不如某些专用工具精致,但胜在全面且稳定。
WandB 确实界面更漂亮、协作功能更强,但它的免费版有数据量限制,而且需要联网。MLflow 更偏向实验管理和模型部署,可视化只是它的一部分。所以如果你的需求是“把训练过程看清楚”,TensorBoard 是性价比最高的选择。
2.2 整体架构:数据是怎么从训练代码流到浏览器的
很多人用 TensorBoard 就是照着教程加几行add_scalar,但从来没想过这些数据到底是怎么流转的。理解这个链路,对你排查“为什么我的图不显示”这类问题非常关键。
整个流程分四步:
训练代码写入事件文件。你在代码里调用
SummaryWriter的各种 add 方法,它会把数据序列化成 protobuf 格式,追加写入到一个叫events.out.tfevents.xxx的文件里。这个文件在你指定的 log 目录下。事件文件落盘。每次调用 add 方法,数据不是立刻写磁盘的,而是先缓存在内存里,默认每 120 秒或者缓存达到一定大小才 flush 一次。这就是为什么你有时候加了日志但 TensorBoard 里半天不显示。
TensorBoard 服务读取事件文件。你在命令行启动
tensorboard --logdir=xxx,它会启动一个本地 web 服务,扫描指定目录下的所有事件文件,解析出数据。浏览器渲染。你打开浏览器访问本地端口,前端把解析出来的数据画成图表。
理解了这个链路,你就知道常见的坑在哪了:log 目录写错、事件文件没 flush、启动了多个 writer 写到同一个目录导致数据混乱、端口被占用等等。后面讲排查技巧的时候我会具体展开。
2.3 日志目录的组织策略:别把所有东西堆在一起
这是我最想强调的一点,也是很多教程不讲但实际项目中极其重要的东西。
如果你所有实验的日志都写到一个目录里,TensorBoard 会把它们全部叠加显示在同一张图上,几十条曲线糊在一起,根本没法看。正确的做法是按实验维度组织目录结构。
我常用的结构是这样的:
runs/ exp_20240115_lr0.001_batch32/ train/ val/ exp_20240116_lr0.0001_batch64/ train/ val/每个实验一个独立目录,目录名里带上关键超参数和日期。这样在 TensorBoard 里,它会自动按子目录分组,你可以选择只看某一个实验,也可以勾选多个实验做对比。对比不同学习率、不同 batch size 的效果时,这个结构能省你大量时间。
提示:目录名不要用中文和空格,虽然大多数情况能识别,但偶尔会出现路径解析问题,用下划线和英文最稳妥。
3. 核心功能模块与实操要点拆解
3.1 标量可视化:loss 和 accuracy 曲线怎么看
标量(Scalar)是使用频率最高的功能,没有之一。训练 loss、验证 loss、学习率、准确率、F1 分数,这些都是标量。
PyTorch 里的基本写法:
from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter('runs/exp_001') for epoch in range(num_epochs): train_loss = train_one_epoch() val_loss, val_acc = validate() writer.add_scalar('Loss/train', train_loss, epoch) writer.add_scalar('Loss/val', val_loss, epoch) writer.add_scalar('Accuracy/val', val_acc, epoch) writer.close()这里有几个细节值得说。
标签命名用斜杠分层。Loss/train和Loss/val这种写法,TensorBoard 会自动把它们归到Loss这个分组下,界面上可以折叠展开,比平铺的train_loss、val_loss清晰得多。当你的指标有十几个的时候,这个习惯能救命。
global_step 的选择。第三个参数是步数,用 epoch 还是用 iteration 取决于你的需求。如果每个 epoch 的 iteration 数不一样(比如变长序列),用 epoch 更合适;如果想看更细粒度的变化,用全局 iteration 数。我一般两个都记,用不同的 tag 区分。
曲线怎么判断健康与否。train loss 和 val loss 同步下降,说明模型在正常学习。train loss 降但 val loss 平或者升,说明过拟合了,该加正则化或者早停。两条曲线都剧烈震荡,说明学习率太大。val loss 一开始就比 train loss 低很多,别高兴太早,大概率是验证集太小或者分布不一致。
3.2 图像可视化:把中间结果“看”出来
做计算机视觉的人,光看 loss 曲线是不够的,你得看模型到底学到了什么。图像可视化能帮你做几件事:检查数据增强是否合理、观察特征图、对比生成模型的输出。
记录输入图像和增强后的图像:
from torchvision.utils import make_grid # 假设 images 是一个 batch 的 tensor,shape 为 [B, C, H, W] grid = make_grid(images, nrow=8, normalize=True) writer.add_image('Input/batch', grid, epoch)make_grid把一批图像拼成一张大图,nrow=8表示每行放 8 张。normalize=True会把像素值归一化到 [0,1],否则可能显示成全黑或全白。
记录特征图的时候要注意,中间层的特征图通道数可能很多,直接全画出来会糊成一片。我的做法是只取前 16 或 32 个通道,用make_grid拼起来看。另外特征图的数值范围可能很大,需要手动做归一化,不然可视化出来对比度很差。
注意:
add_image默认期望的输入格式是[C, H, W],如果你传的是[B, C, H, W],要么用make_grid拼好,要么指定dataformats='NCHW'。这个格式问题坑过我不止一次,图像显示不出来先检查这个。
3.3 直方图与分布:监控权重和梯度的健康度
权重和梯度的分布变化,是判断训练是否健康的重要信号。梯度消失、梯度爆炸、权重退化这些问题,从 loss 曲线上不一定看得出来,但直方图上一目了然。
for name, param in model.named_parameters(): writer.add_histogram(f'Weights/{name}', param, epoch) if param.grad is not None: writer.add_histogram(f'Gradients/{name}', param.grad, epoch)健康的权重分布应该是一个相对集中的钟形曲线,随着训练缓慢移动。如果某个层的权重分布突然变得极窄(所有值挤在一个点上),说明这一层基本没在学。如果梯度直方图大部分值都接近零,那就是梯度消失;如果出现很多极端大值,那就是梯度爆炸。
我遇到过一次典型的梯度消失:一个深层网络,前几层的梯度直方图几乎全在零附近,后面几层正常。原因是激活函数选错了,用了 Sigmoid 导致深层梯度传不回去。换成 ReLU 之后,梯度分布立刻正常了。这种问题光看 loss 是发现不了的,因为 loss 还在缓慢下降,只是效果很差。
3.4 计算图可视化:模型结构一目了然
计算图(Graph)能把你模型的网络结构画出来,包括每一层的连接关系、输入输出维度。写论文的时候放一张计算图,比文字描述直观一百倍。
PyTorch 里记录计算图:
dummy_input = torch.randn(1, 3, 224, 224).to(device) writer.add_graph(model, dummy_input)这里必须传一个真实的输入张量,TensorBoard 会用它做一次前向传播来追踪计算图。输入的形状要和模型实际接收的一致,否则会报错。
计算图可视化有个小坑:如果你的模型有复杂的控制流(比如 if-else 分支、循环),追踪出来的图可能不完整或者很乱。这种情况下建议把模型拆成几个子模块分别记录,或者用torch.jit.trace先转成静态图再记录。
3.5 超参数对比:一次训练多组配置
TensorBoard 的 HParams 功能可以让你在一次运行中对比多组超参数的效果。这个功能在调参阶段特别有用。
from torch.utils.tensorboard import SummaryWriter for lr in [0.001, 0.0001, 0.00001]: for batch_size in [16, 32, 64]: writer = SummaryWriter(f'runs/lr{lr}_bs{batch_size}') writer.add_hparams( {'lr': lr, 'batch_size': batch_size}, {'hparam/accuracy': final_acc, 'hparam/loss': final_loss} ) writer.close()跑完之后在 TensorBoard 的 HPARAMS 标签页里,你能看到不同超参数组合对应的最终指标,还能用平行坐标图分析哪个参数影响最大。这个功能比手动整理表格高效得多。
4. 完整实操流程:从零搭一套可视化训练
4.1 环境准备与依赖安装
先把环境弄干净。TensorBoard 的安装很简单:
pip install tensorboard如果你用的是 PyTorch,torch.utils.tensorboard已经随 PyTorch 一起装了,不需要额外装tensorboardX。这两个东西功能重叠,同时装有时候会冲突,建议只保留一个。
验证安装:
tensorboard --version能输出版本号就说明装好了。如果提示命令找不到,检查一下 pip 安装的路径是不是在系统 PATH 里,或者用python -m tensorboard.main --version试试。
4.2 训练脚本改造:最小侵入式接入
我的原则是可视化代码不要污染训练逻辑。具体做法是把 writer 的创建和关闭放在训练循环外面,add 调用集中在一个专门的日志函数里。
import os import time import torch from torch.utils.tensorboard import SummaryWriter class TrainLogger: def __init__(self, log_dir): self.writer = SummaryWriter(log_dir) self.start_time = time.time() def log_scalars(self, metrics, step): for name, value in metrics.items(): self.writer.add_scalar(name, value, step) def log_images(self, tag, images, step): from torchvision.utils import make_grid grid = make_grid(images, nrow=8, normalize=True) self.writer.add_image(tag, grid, step) def log_histograms(self, model, step): for name, param in model.named_parameters(): self.writer.add_histogram(f'Weights/{name}', param, step) if param.grad is not None: self.writer.add_histogram(f'Gradients/{name}', param.grad, step) def log_lr(self, optimizer, step): for i, param_group in enumerate(optimizer.param_groups): self.writer.add_scalar(f'LR/group_{i}', param_group['lr'], step) def close(self): self.writer.close() print(f'Training time: {time.time() - self.start_time:.2f}s')这样封装之后,训练循环里只需要调用logger.log_scalars(...)就行,代码干净很多。而且以后想换可视化工具,只需要改这个类,不用动训练逻辑。
4.3 启动 TensorBoard 与远程访问
本地训练的话,直接在命令行启动:
tensorboard --logdir=runs --port=6006然后浏览器打开http://localhost:6006就能看到界面。
如果你在服务器上训练,本地想看结果,有两种常见做法。一种是把日志目录同步到本地再启动 TensorBoard;另一种是在服务器上启动 TensorBoard 并指定--host 0.0.0.0,然后通过端口转发的方式在本地浏览器访问。具体用哪种取决于你的网络环境,核心思路就是让浏览器能访问到 TensorBoard 服务。
提示:
--logdir指向的应该是包含多个实验子目录的父目录,而不是单个实验目录。这样 TensorBoard 才能正确分组和对比。如果你指向单个实验目录,切换实验就得重启服务。
4.4 一个完整的训练示例
把上面的东西串起来,一个完整的训练脚本大概长这样:
import torch import torch.nn as nn from torch.utils.data import DataLoader from logger import TrainLogger def train(): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = MyModel().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=32) logger = TrainLogger('runs/exp_lr1e-3_bs32') for epoch in range(50): model.train() total_loss = 0 for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() total_loss += loss.item() global_step = epoch * len(train_loader) + batch_idx if batch_idx % 50 == 0: logger.log_scalars({'Loss/train_step': loss.item()}, global_step) avg_train_loss = total_loss / len(train_loader) model.eval() val_loss, correct, total = 0, 0, 0 with torch.no_grad(): for data, target in val_loader: data, target = data.to(device), target.to(device) output = model(data) val_loss += criterion(output, target).item() pred = output.argmax(dim=1) correct += (pred == target).sum().item() total += target.size(0) avg_val_loss = val_loss / len(val_loader) val_acc = correct / total logger.log_scalars({ 'Loss/train_epoch': avg_train_loss, 'Loss/val': avg_val_loss, 'Accuracy/val': val_acc }, epoch) logger.log_lr(optimizer, epoch) if epoch % 5 == 0: logger.log_histograms(model, epoch) logger.log_images('Input/sample', data[:32], epoch) print(f'Epoch {epoch}: train_loss={avg_train_loss:.4f}, val_loss={avg_val_loss:.4f}, val_acc={val_acc:.4f}') logger.close() if __name__ == '__main__': train()这个脚本里,标量每个 iteration 记一次(但每 50 步才写一次,避免日志文件过大),直方图和图像每 5 个 epoch 记一次(这些操作比较耗时,不需要太频繁)。学习率每个 epoch 记一次,方便观察调度器的效果。
5. 常见问题与排查技巧实录
5.1 图表不显示或显示不全
这是最高频的问题,我按排查顺序列一下。
第一步,确认事件文件生成了没有。去 log 目录下看有没有events.out.tfevents.xxx文件。如果没有,说明 writer 根本没写进去,检查SummaryWriter的路径参数对不对,以及writer.close()有没有被调用。
第二步,确认 TensorBoard 指向的目录对不对。--logdir指向的目录必须包含事件文件,或者包含事件文件的子目录。如果指向了一个空目录,界面就是一片空白。
第三步,确认数据有没有 flush。前面说过,writer 默认有缓冲。如果你训练刚启动就去看,可能数据还没落盘。可以在关键节点手动调用writer.flush(),或者等一两分钟再看。
第四步,检查浏览器缓存。有时候数据更新了但浏览器显示的还是旧的,强制刷新一下(Ctrl+Shift+R)。
5.2 曲线震荡严重看不清
如果 loss 曲线震荡得像心电图,有几个处理办法。
一是平滑处理。TensorBoard 界面左侧有个 Smoothing 滑块,默认 0.6,调大一点曲线会平滑很多。但要注意,平滑只是视觉效果,原始数据没变,不要被过度平滑的曲线骗了。
二是降低记录频率。每个 iteration 都记 loss,震荡是必然的。改成每 N 个 iteration 记一次平均值,曲线会干净很多。
三是检查学习率。如果平滑之后还是剧烈震荡,大概率是学习率太大,试试降一个数量级。
5.3 日志文件过大导致磁盘爆满
直方图和图像记录太频繁的话,事件文件会迅速膨胀。我见过一个项目,训练三天日志文件涨到了 80 多个 G。
控制方法:直方图不要每个 epoch 都记,隔几个 epoch 记一次;图像只记少量样本,不要整个 batch 都记;训练结束后及时清理不需要的日志。另外可以在SummaryWriter里设置max_queue和flush_secs参数来控制写入行为。
5.4 多个实验曲线混在一起
这个问题前面提过,根源是日志目录结构没组织好。如果所有实验都写到同一个目录,TensorBoard 会把它们当成同一个实验的不同 run,全部叠加显示。
解决办法是每个实验一个独立子目录。如果已经混了,可以在 TensorBoard 界面左侧的 Runs 列表里取消勾选不需要的 run,只保留想看的。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 界面空白无数据 | logdir 路径错误 | 检查路径是否包含事件文件 |
| 数据延迟显示 | 缓冲区未 flush | 手动 flush 或等待 |
| 图像显示全黑 | 未归一化或格式错误 | 加 normalize=True,检查 dataformats |
| 计算图报错 | 输入形状不匹配 | 用与模型输入一致的 dummy input |
| 曲线震荡剧烈 | 学习率过大或记录太频繁 | 降学习率,降低记录频率 |
| 磁盘占用过大 | 直方图/图像记录过频 | 降低记录频率,定期清理 |
| 多实验混淆 | 日志目录未分离 | 每个实验独立子目录 |
5.6 几个我踩过的坑
坑一:在 DataParallel 或 DistributedDataParallel 下重复记录。多卡训练时,如果不做判断,每张卡都会写一遍日志,导致数据重复。解决办法是只在 rank 0 的进程里创建 writer 和记录。
坑二:add_scalar的 step 不单调递增。TensorBoard 对 step 的顺序有要求,如果 step 忽大忽小,曲线会画得很乱。确保你的 global_step 是严格递增的。
坑三:在 Jupyter Notebook 里启动 TensorBoard。Notebook 里直接跑tensorboard命令会阻塞,需要用%load_ext tensorboard和%tensorboard --logdir runs的魔法命令,或者用后台进程启动。
坑四:事件文件被多个 writer 同时写入。如果你在同一个目录下创建了多个 SummaryWriter 实例,它们会各自写各自的事件文件,TensorBoard 读取时可能出现数据错乱。一个目录只用一个 writer。
6. 进阶技巧与效率提升
6.1 用嵌入向量投影做特征分析
TensorBoard 的 Projector 功能可以把高维特征降维到三维空间可视化,做聚类分析或者检查特征质量时很有用。
writer.add_embedding( features, metadata=labels, label_img=images, global_step=epoch, tag='feature_embedding' )features是模型输出的特征向量,metadata是对应的标签,label_img是原始图像。在 TensorBoard 的 PROJECTOR 标签页里,你能看到特征在空间中的分布,同类样本是否聚在一起一目了然。这个功能在分析模型为什么分错类的时候特别有用。
6.2 用 PR Curves 分析分类阈值
做二分类或者多分类的时候,PR 曲线能帮你选合适的阈值。
from torch.utils.tensorboard import SummaryWriter writer.add_pr_curve('pr_curve', labels, predictions, global_step=epoch)labels是真实标签,predictions是模型输出的概率值。TensorBoard 会自动画出不同阈值下的 precision 和 recall 曲线,帮你找到最佳平衡点。
6.3 日志的自动化管理
实验多了之后,手动管理日志目录很麻烦。我一般会写一个简单的脚本,自动按日期和超参数生成目录名,训练结束后把关键指标汇总到一个 CSV 里。这样既保留了完整的 TensorBoard 日志,又有一个便于检索的汇总表。
另外,训练结束后可以把不再需要的图像和直方图日志删掉,只保留标量日志,能省不少磁盘空间。标量日志通常很小,几十个实验加起来也就几百兆。
6.4 把 TensorBoard 集成到实验流程里
我的习惯是每个实验开始前先确定好要记录哪些指标,在配置文件里列出来,训练脚本根据配置自动记录。这样不同实验之间的记录标准是一致的,对比起来才有意义。
另外,重要的实验我会在训练结束后把 TensorBoard 的截图保存下来,附在实验报告里。因为 TensorBoard 的日志文件不方便直接分享,截图是最直接的记录方式。
7. 一些个人体会
TensorBoard 这个东西,刚接触的时候觉得就是个画图的,用久了才发现它是训练过程中最重要的反馈渠道。没有它的时候,调模型基本靠猜,改一个参数跑一天,看结果好不好全凭运气。有了它之后,你能看到模型在每一步的状态,知道问题出在哪,调整起来有方向。
我现在的习惯是,任何超过半小时的训练,必须接上 TensorBoard。哪怕只是跑个 baseline,也要把 loss 曲线和准确率记下来。因为你永远不知道什么时候需要回头对比,而日志没了就真的没了。
最后分享一个小技巧:如果你觉得 TensorBoard 默认的界面信息密度不够,可以在启动时加--samples_per_plugin参数,控制每个图表加载的数据点数。默认是 1000 个点,调大一点能看到更细的曲线变化,但加载会慢一些。我一般设成 5000,平衡了细节和性能。