资讯动态

PyTorch训练过程可视化:用Matplotlib搭建Loss曲线监控与模型调优脚手架

发布时间:2026/10/19 3:30:48 来源:尧图企业网站定制
1. 为什么每个 PyTorch 项目都需要训练过程可视化刚开始接触 PyTorch 的时候我有一段时间是“盲训”的写好 DataLoader、搭好模型、跑起训练循环然后就盯着终端里不断刷新的 loss 数字发呆。模型到底学得怎么样过拟合是从哪个 epoch 开始的学习率是不是该调整了完全靠猜。直到第一次把 loss 曲线画出来才意识到训练过程可视化这件事远比想象中重要甚至可以说它是模型调优的第一步。训练过程可视化本质上是把训练循环中产生的数值记录下来再用图像的方式呈现。Matplotlib 就是其中最直接的方案。相比 TensorBoard、wandb 这类工具Matplotlib 的好处非常明显零额外依赖、完全离线、绘图逻辑完全由自己控制还能无缝嵌入到 Jupyter Notebook 和本地 Python 脚本里。这篇文章我想围绕“PyTorch 训练过程可视化 Matplotlib”这套组合把从数据记录到最终出图的完整链路讲明白。适合看的读者有两类一是刚开始用 PyTorch 训练模型、想知道自己模型到底在干什么的新手二是已经在用 TensorBoard 但觉得定制能力不够、想在本地快速看曲线的朋友。看完这篇文章你能搭出一套属于自己的训练可视化脚手架而且这套东西放到任何框架里都能复用。2. 先拆解需求要记录什么、为什么这么设计2.1 训练可视化要解决的核心问题训练过程可视化可视化的是“训练中的状态”。最常见的状态指标包括训练集 loss验证集 loss很多情况下它比训练 loss 更能反映泛化能力训练集/验证集准确率或者其他业务指标比如 F1、IoU、AUC学习率的变化梯度范数判断是否梯度爆炸或消失单 batch 的训练耗时这些指标反映了训练的不同侧面。loss 曲线告诉你模型是否在收敛训练验证 loss 的差值告诉你是否过拟合学习率曲线告诉你当前的优化策略是否合理梯度范数则能提前预警数值稳定性问题。如果一句话总结可视化的设计原则那就是训练循环里能算出来的确定性数值都应该被记录并画出来。很多东西在训练时一闪而过不记录下来后面想复盘就再也找不回来了。2.2 为什么选择 Matplotlib 而不是 TensorBoard普通训练可视化我会优先推荐 TensorBoard但为什么这篇文章专门讲 Matplotlib我的个人判断是这样的TensorBoard 的优势是交互式、可远程、支持高维数据比如 embedding、image但代价是引入额外依赖、需要启动服务、和某些自定义场景配合比较累。而 Matplotlib 属于“任何 Python 环境里几乎必然存在”的库画出来的图是静态的 PNG 或 GIF直接放进论文、周报、群里分享都很方便。同样一张 loss 曲线图TensorBoard 需要你先安装 tensorboard、然后训练时写 SummaryWriter、再启动浏览器查看。Matplotlib 只需要在训练循环里收集一个 list训练结束后画图三行代码搞定。对于中小规模实验、论文级配图、快速 debug 来说Matplotlib 的性价比非常高。我自己的做法是“两条腿走路”日常盯训练用 TensorBoard出正式图表、做实验对比时用 Matplotlib 把关键曲线导出成高清图。这篇文章围绕后者展开但你完全可以把它作为 TensorBoard 之外的一个补充工具链二者并不冲突。2.3 记录数据时最容易犯的错误只在终端打印很多人会在训练循环里写一行print(fepoch {epoch}, loss {loss.item():.4f})然后觉得“我已经在监控了”。但实际上这样做有三个问题终端日志是线性刷动的曲线形态要靠缩略信息自己在脑内重建训练一旦崩了或者中断历史输出难以快速回溯想对比两次实验时必须翻终端粘贴复制非常痛苦。所以我的建议是从项目最开始就引入一个最小的记录器。记录器不需要很复杂一个可以追加数值的 dict 就够了比如class MetricRecorder: def __init__(self): self.history {} def update(self, tag, value): if tag not in self.history: self.history[tag] [] self.history[tag].append(value) def get(self, tag): return self.history.get(tag, [])这段代码虽然简单但它解决的是一个核心问题训练数据与绘图逻辑解耦。训练循环只需要负责update画图的时候只需要get。后面不管你是想画单条曲线、多条曲线还是做实时刷新都变得非常自然。3. 从零搭建训练可视化脚手架3.1 记录器设计进阶按 epoch 和按 step 分开记录实际训练中我们常常需要两种粒度的数据按 step 记录每个 batch 更新一次 loss曲线会非常密集适合观察早期收敛速度按 epoch 记录每个 epoch 结束后记录平均 loss、准确率曲线平滑适合观察整体趋势我个人推荐把这两种粒度混用。比如 loss 按 step 记录用于实时刷新验证集的 loss 和准确率按 epoch 记录用于最终成图。因为它们产生的数据量差异很大处理逻辑也不一样最好在记录器里区分class MetricRecorder: def __init__(self, nametrain): self.name name self.history {} def record_step(self, tag, value, stepNone): key (tag, step) if key not in self.history: self.history[key] {x: [], y: []} self.history[key][x].append(step if step is not None else len(self.history[key][x])) self.history[key][y].append(value) def record_epoch(self, tag, value, epoch): key (tag, epoch) if key not in self.history: self.history[key] {x: [], y: []} self.history[key][x].append(epoch) self.history[key][y].append(value)为什么我把 step 也放进去了因为后面画实时的x轴坐标一定要用到它。如果不记录 step画出来的曲线永远只有数据点没有时间轴非常不利于定位是“第多少个 batch 出现了 loss 波动”。3.2 训练循环中的埋点套路有了记录器训练循环的埋点就变成机械操作recorder MetricRecorder() for epoch in range(num_epochs): model.train() epoch_loss 0.0 for batch_idx, (x, y) in enumerate(train_loader): x, y x.to(device), y.to(device) optimizer.zero_grad() out model(x) loss criterion(out, y) loss.backward() optimizer.step() loss_val loss.item() epoch_loss loss_val recorder.record_step(train_loss, loss_val, stepepoch * len(train_loader) batch_idx) avg_epoch_loss epoch_loss / len(train_loader) recorder.record_epoch(train_loss_epoch, avg_epoch_loss, epoch) # 验证 model.eval() val_loss 0.0 correct 0 total 0 with torch.no_grad(): for x, y in val_loader: x, y x.to(device), y.to(device) out model(x) val_loss criterion(out, y).item() pred out.argmax(dim1) correct (pred y).sum().item() total y.size(0) recorder.record_epoch(val_loss, val_loss / len(val_loader), epoch) recorder.record_epoch(val_acc, correct / total, epoch)这个套路里有一个特别容易被忽视的点验证时必须用torch.no_grad()包裹否则验证阶段也会构建计算图显存直接吃不消。而我见过的不少新手代码训练和验证的写法几乎一样结果 loss 曲线越画越怪本质上就是因为验证阶段也混入了反向传播的数值记录。3.3 日志文件与绘图解耦训练完再画 vs 边训边画很多初学者会急着在训练循环里调用 Matplotlib 的plt.show()然后发现程序卡住或者窗口疯狂弹出来。正确做法是让日志记录与绘图解耦具体分两种情况快速 debug可以在每个 epoch 结束后用plt.clf()清空当前图形重新画一条简易 loss 曲线并暂停零点几秒产生“实时刷新”的效果。正式训练建议把 recorder 的数据在训练结束后统一落盘为.json或者.log文件然后由单独的画图脚本读取并成图。这样做的理由是正式训练中我们会跑很长的 epoch每次都画图会有大量重复渲染浪费时间和 CPU。把画图独立出来还可以让你在训练结束后对同一份数据反复修改样式不会因为重跑训练才能更新图表。4. 核心图表实操loss 曲线、多子图、平滑处理4.1 最简单的 loss 曲线也要注意这三个细节先写一个最基本的代码import matplotlib.pyplot as plt def plot_loss(history, save_pathloss.png): epochs history[x] train_loss history[y] fig, ax plt.subplots(figsize(8, 5)) ax.plot(epochs, train_loss, labeltrain_loss, linewidth2) ax.set_xlabel(Epoch) ax.set_ylabel(Loss) ax.set_title(Training Loss Curve) ax.legend() ax.grid(True, linestyle--, alpha0.6) fig.savefig(save_path, dpi200, bbox_inchestight) plt.close(fig)有三个细节是我画了无数次曲线之后才总结出来的fig使用后必须plt.close(fig)。否则每次画图都会产生新的 figure 对象长时间运行会内存暴涨。dpi200是论文和笔记的黄金参数。dpi100用作分享够用但偏模糊dpi300文件偏大且不一定有收益。坐标轴上的网格线默认是不开的但训练曲线的参考价值很大一部分来自网格线否则你很难估算某一个 loss 数值是在 1.2 还是 1.5。4.2 训练/验证对比曲线一眼看出过拟合真正实用的 loss 曲线通常同时包含训练集和验证集。判断是否过拟合最直观的方式就是看两条曲线之间的差距def plot_train_val(train_hist, val_hist, save_pathtrain_val_loss.png): fig, ax plt.subplots(figsize(9, 5)) ax.plot(train_hist[x], train_hist[y], labeltrain_loss, color#1f77b4) ax.plot(val_hist[x], val_hist[y], labelval_loss, color#d62728, linestyle--) ax.fill_between( val_hist[x], val_hist[y], train_hist[y], colorgray, alpha0.15, labelgap, ) ax.set_xlabel(Epoch) ax.set_ylabel(Loss) ax.set_title(Train vs Val Loss) ax.legend() ax.grid(True, linestyle--, alpha0.6) fig.savefig(save_path, dpi200, bbox_inchestight) plt.close(fig)这里的fill_between是一个特别加分的技巧它涂出来的灰色区域就是训练和验证 loss 的“差距面积”面积越大过拟合风险越高。我第一次看到这种图是在一次模型答辩的 PPT 里一眼就抓住了重点后来我自己做实验对比时也总会加上这个阴影区。不过要注意fill_between默认填充的是两条曲线围合的区域如果你的两条曲线因为坐标轴设置不同而出现交叉涂出来的区域会非常乱。所以绘制之前最好检查一下两条曲线的 x 轴是否完全一致如果不一致需要用np.interp或者直接把训练集 loss 降采样到和验证集相同长度。4.3 多子图看板一个图看清所有训练状态多子图布局是训练可视化的进阶形态。我常用的布局是2x2分别放左上train/val loss右上train/val accuracy左下学习率变化右下梯度范数def plot_dashboard(recorder, save_pathdashboard.png): fig, axes plt.subplots(2, 2, figsize(14, 10)) # Loss axes[0, 0].plot(recorder.history[(train_loss_epoch, epoch)][x], recorder.history[(train_loss_epoch, epoch)][y], labeltrain) axes[0, 0].plot(recorder.history[(val_loss, epoch)][x], recorder.history[(val_loss, epoch)][y], labelval) axes[0, 0].set_title(Loss) axes[0, 0].legend() axes[0, 0].grid(True, linestyle--, alpha0.6) # Accuracy axes[0, 1].plot(recorder.history[(val_acc, epoch)][x], recorder.history[(val_acc, epoch)][y], labelval_acc, color#2ca02c) axes[0, 1].set_title(Accuracy) axes[0, 1].legend() axes[0, 1].grid(True, linestyle--, alpha0.6) # LR axes[1, 0].plot(lr_history[x], lr_history[y], labellr, color#9467bd) axes[1, 0].set_title(Learning Rate) axes[1, 0].legend() axes[1, 0].grid(True, linestyle--, alpha0.6) # Gradient norm axes[1, 1].plot(grad_history[x], grad_history[y], labelgrad_norm, color#ff7f0e) axes[1, 1].set_title(Gradient Norm) axes[1, 1].legend() axes[1, 1].grid(True, linestyle--, alpha0.6) fig.tight_layout() fig.savefig(save_path, dpi180, bbox_inchestight) plt.close(fig)多子图最大的价值是“全局联动”。比如你看到验证 loss 在第 50 个 epoch 突然抬升同时梯度范数曲线在那个位置剧烈抖动那大概率是优化器的学习率在那个阶段出了问题。单看一张图往往只能发现问题多图联动才能定位问题。4.4 用滑动平均让曲线更清晰原生 loss 曲线通常非常陡峭尤其 step 粒度数据噪声很大。直接画上去结果就是一条毛茸茸的刺猬曲线根本看不出趋势。我的解决方案是滑动平均EMA 也可以本质类似。def smooth_curve(values, factor0.95): smoothed [] last values[0] for v in values: last last * factor (1 - factor) * v smoothed.append(last) return smoothedfactor0.95是我实测比较顺手的一个值取 0.9 保留更多细节取 0.99 曲线过于平缓。使用滑动平均后曲线会保留整体趋势去掉大部分噪声。但要注意平滑后的曲线会有“滞后”尤其是在 loss 突然下降时所以正式论文配图时建议把原始曲线用浅色半透明画出来把平滑曲线用实线叠上去两个都展示既诚实又好看。5. 边训练边动态可视化5.1 开启交互模式实时更新曲线如果不想训练结束后才看到结果希望每个 epoch 结束都能看到最新曲线那就要使用 Matplotlib 的交互模式。核心是plt.ion()开启交互模式后plt.pause()会强制刷新图形窗口而不会阻塞循环。plt.ion() fig, ax plt.subplots(figsize(8, 5)) line, ax.plot([], [], lw2) ax.set_xlim(0, num_epochs) ax.set_ylim(0, 1.5) ax.grid(True) def update(epoch, loss): line.set_xdata(epochs [epoch]) line.set_ydata(losses [loss]) ax.relim() ax.autoscale_view() plt.pause(0.01)其中line.set_xdata/set_ydata是更新已有线对象而不是重新创建一个新的线对象这样效率更高。plt.pause(0.01)的数值不要太大否则训练会被拖慢也不要太小否则可能出现窗口显示刷新不及时。5.2 动态可视化最常见的坑窗口假死与 CPU 过热我在实际使用交互模式时遇到过两个典型问题第一个是窗口假死。原因是plt.show()和plt.ion()配合不当在非主进程里调用了绘图操作或者训练循环里没有回到事件循环。解决办法是把plt.pause()保留在循环中并在循环结束后关闭脚本进程另外如果是在 Jupyter Notebook 中使用%matplotlib inline会让这种动态更新失效需要改用%matplotlib notebook。第二个是 CPU 过热。实时刷新如果每个 step 都做一次一秒钟可能刷新几十次画布重绘开销巨大。解决办法是降低刷新频率比如每 N 个 step 才更新一次。我通常设置一个简单的计数器每 100 个 step 或每个 epoch 结束时才调用一次刷新逻辑实测性能下降几乎可以忽略。5.3 用 FuncAnimation 实现更流畅的动画如果追求更顺滑的实时动画效果可以使用matplotlib.animation.FuncAnimation。它的原理是通过定时器反复调用一个更新函数把每一帧数据渲染到画布上。这个方案尤其适合想在训练结束后生成 GIF 的情况from matplotlib.animation import FuncAnimation fig, ax plt.subplots(figsize(8, 5)) line, ax.plot([], [], lw2) ax.grid(True) def init(): ax.set_xlim(0, max_steps) ax.set_ylim(0, max_loss) return line, def animate(step): x steps[:step 1] y losses[:step 1] line.set_data(x, y) ax.relim() ax.autoscale_view() return line, anim FuncAnimation(fig, animate, frameslen(steps), init_funcinit, interval50, blitTrue) anim.save(training_process.gif, writerpillow, dpi120)这段代码生成的 GIF 非常适合放进 README 或者演示文档里。interval控制每帧之间间隔单位是毫秒50 毫秒是较为流畅的间隔writerpillow可以避免系统未安装 ffmpeg 时报错。不过FuncAnimation在训练过程中并不是必要的。因为训练循环本身是阻塞式的动画必须在“每步生成”的模式下工作如果你想在训练过程中同时看动画还是建议回到 5.1 节的交互模式。FuncAnimation 更适合“训练完毕之后回放”的场景。6. 进阶技巧让图表达到“论文级”观感6.1 颜色、字体和线条宽度的一致性原则训练曲线不是图越花哨越好。我经常看到有人把所有颜色都用一遍结果图特别“串味”。在做实验对比时建议固定一套颜色模板保证风格统一。我个人的习惯是训练曲线用暖色系例如橙红验证曲线用冷色系例如蓝色不同实验之间用同色系不同亮度区分网格线统一为灰色虚线透明度 0.5 左右线条宽度统一为 2标记尺寸统一为 4Matplotlib 默认的颜色轮太花哨我一般直接在代码开头定义颜色常量后续所有图都引用这些常量既方便又整齐。6.2 双坐标轴loss 和 lr 同框有一个非常实用的技巧就是用副轴把 loss 和学习率画在同一张图上。因为 loss 和学习率的量纲差异巨大直接画在两个子图里很难观察它们之间的关系。副轴方案可以让你看到“学习率变化时loss 如何响应”fig, ax1 plt.subplots(figsize(9, 5)) ax1.plot(epochs, train_loss, labeltrain_loss, color#d62728) ax1.set_xlabel(Epoch) ax1.set_ylabel(Loss, color#d62728) ax1.tick_params(axisy, labelcolor#d62728) ax2 ax1.twinx() ax2.plot(epochs, lr_history, labellr, color#1f77b4, linestyle--) ax2.set_ylabel(Learning Rate, color#1f77b4) ax2.tick_params(axisy, labelcolor#1f77b4) fig.legend() fig.tight_layout()这个图用下来你会发现某类学习率策略非常容易诊断问题比如学习率下降时loss 也同步下降说明当前模型还没有收敛学习率下降时loss 反而上涨说明模型可能已经过拟合或者步长过小进入了震荡区。这种联动判断是单看 loss 曲线做不到的。6.3 导出高清图与 GIF 的最终参数建议每一次训练结束后除了在终端里打印总结我都会固定生成一套标准的可视化报告文件。统一的目录结构大概是runs/ exp_001/ logs/ metrics.json figures/ loss_curve.png train_val_loss.png dashboard.png lr_curve.png training_process.gif这里有两个细节值得留意一是metrics.json最好在每次 epoch 结束时就同步写盘而不是最后统一写防止训练中断导致数据丢失二是所有图形文件名最好带metric信息比如train_val_loss_epoch50.png这样后续整理结果时光看文件名就能知道实验进行到了哪个阶段。关于导出参数我的最终建议是展示用图dpi150文件大小控制在 1MB 以内论文或报告配图dpi300同时使用bbox_inchestight裁剪空白区域GIF帧数控制在 60~100 帧以内帧间隔 80~100ms否则 GIF 文件会非常大7. 常见问题与排查技巧实录7.1 问题一训练结束画图时曲线消失现象训练过程中数据明明都记录到了 list 或 recorder 里画图时却只有坐标轴没有曲线。排查方向检查plot的参数是否传对。最常见的是把train_hist[x]和train_hist[y]顺序写反或者传入的 y 是 nested list检查训练数据是否为 NaN 或者 Inf。如果 loss 中出现 NaNMatplotlib 会直接跳过这些点看起来像曲线断掉检查坐标轴范围。如果记录器的数值范围非常大而手写set_ylim的下限和上限不合理曲线可能被“挡”在视野之外我的处理习惯是在画图前先print几个数据点确认 x、y 的长度和数值范围都正常然后再考虑 Matplotlib 本身的问题。7.2 问题二内存暴涨 / 反复弹窗现象训练结束后内存一直不下降或者绘图窗口数量不断增多。这是我早期踩过最大的坑。原因几乎都是没有plt.close(fig)。在循环里每创建一个 Figure 就是一次内存分配不关闭就全部累积在内存里。解决方法是绘图函数里最后必须plt.close(fig)如果使用subplots明确拿到fig和ax不要再额外调用plt.figure()创建新的画布在训练循环中绘制实时图时只更新line对象不要重复创建新的 Figure7.3 问题三服务器上跑训练没有显示器怎么办这是碰到的频率最高的问题。很多训练任务是在远程服务器或容器里跑的没有 GUI 环境。直接调用plt.show()会直接报错或卡住因为根本没有显示设备。解决方案是全部改成“无头模式”export MPLBACKENDAgg或者在代码里强制设置import matplotlib matplotlib.use(Agg)Agg后端不支持交互式显示但可以正常保存图片到文件。这个设置一定要放在import matplotlib.pyplot之前否则不生效。在服务器上我通常只保存 PNG 和 GIF然后下载到本地查看如果需要远程实时监控我会把曲线写入日志配合tailf命令盯终端指标或者定期把生成的 PNG 上传到可视化目录。7.4 问题四多个实验曲线对比时图例错乱现象把两次实验的 loss 曲线放在同一张图里时图例显示错乱或者颜色对不上。排查方向label参数是否在每次plot时都传入如果没有Matplotlib 会用自动生成的_line0这样的标签是否提前调用过plt.legend()如果提前保留了一个旧的 legend后续又添加新曲线图例会混在一起。推荐的做法是全部plot完成后再统一调用一次legend()如果要画很多次实验建议把颜色映射到实验名而不是让 Matplotlib 自动分配7.5 问题五曲线很抖看不到趋势训练 loss 曲线特别抖常见于 batch 比较小或者学习率比较大。除了平滑处理还有一个更规范的做法是在记录时同时记录滑动平均后的值。这样哪怕最后忘了做平滑原始记录里也已经包含了平滑版本。当然我更推荐保留原始数据平滑只发生在绘图阶段因为平滑参数改变时不需要重新训练。8. 我个人在实操中的体会这套可视化脚手架我从最开始在单机小模型上跑通到后来搬到多卡训练和远程服务器环境踩过了不少坑整体思路已经比较稳定。核心就是前面反复强调的三件事记录与绘图解耦、用统一 recorder 收拢数据、坚持每次训练后出全套图表。我个人特别建议每跑一次正式实验都把dashboard.png和metrics.json归档到同一个实验目录。这样过了几周再翻出来看着曲线就能回忆起当时做了什么决策、踩了什么问题。这种复盘能力在模型迭代频繁的实战环境里非常值钱。另外一个小技巧想分享给所有还在“盲训”的朋友第一次跑可视化不用追求复杂。先把 train loss 画出来再把 val loss 加进去最后再补学习率和梯度范数。这张从简单到完整的曲线图就是你理解模型训练规律的最好工具。很多时候很多模型问题不需要调参只需要多看几眼曲线就能发现答案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑