1. 为什么一张Loss曲线值得单独拿出来讲
训练一个神经网络,最怕的不是报错,而是"看起来在跑,其实早就废了"。终端里一行行刷过去的loss: 0.6931数字,单看某一个值毫无意义,只有把它们连成一条线,你才能判断模型到底是在学、在瞎猜、还是在过拟合。pytorch负责算梯度、更新参数,但它本身不负责画图;matplotlib就是那个把训练日志变成"心电图"的工具。这个组合几乎是每个做深度学习的人绕不开的基本功。
这篇内容解决的就是一个非常具体的问题:在 PyTorch 训练循环里,把每个 epoch(或每个 step)的 loss 收集起来,用 matplotlib 画成一条清晰、可读、能直接放进报告或论文的曲线图。看起来简单,但真正动手时,坑一点都不少——loss 列表长度对不上、图画出来是空白、中文标签变成方块、多条曲线挤在一起分不清谁是谁、验证集曲线和训练集曲线画在同一张图上却忘了加图例。这些问题我在带新人和自己复现项目时见过太多次。
适合谁看:刚跑通第一个nn.Module、想把训练过程"可视化"的新手;已经会画图但总在细节上翻车的同学;以及需要把 loss 曲线整理成规范图表放进项目文档的从业者。下面我会从整体设计思路讲到逐行可抄的代码,再到我实际踩过的坑和排查方法,尽量让你看完就能直接用到自己的训练脚本里。
2. 整体设计思路:先想清楚"画什么",再动手"怎么画"
2.1 数据从哪来:训练循环里的"记账"环节
画图的前提是有数据。PyTorch 的训练循环本质上是一个不断重复的过程:前向传播算输出、算 loss、反向传播算梯度、优化器更新参数。loss 这个标量在每一步都会产生,但它默认只存在于当前这一步的变量里,下一步就被覆盖了。所以第一件事是在循环外准备容器,在循环内把 loss 追加进去。
这里有个容易被忽略的细节:你到底要记录每个 batch 的 loss还是每个 epoch 的平均 loss?两者画出来的曲线形态完全不同。batch 级 loss 抖动大、点密集,能看出训练早期的剧烈震荡;epoch 级 loss 平滑,适合看整体趋势。我的习惯是两者都留:batch 级用于观察稳定性,epoch 级用于对外汇报。容器用最朴素的 Pythonlist就够了,不需要 numpy 数组——因为训练过程中长度是动态增长的,list 的append是 O(1),而反复拼接 numpy 数组反而低效。
2.2 为什么选 matplotlib 而不是别的
有人会问,TensorBoard 不是更专业吗?确实,TensorBoard 能实时刷新、能对比多次实验,但它的门槛在于要起一个本地服务、要在浏览器里看,而且导出的图样式不够自由。matplotlib 的优势是完全离线、完全可控、导出即用:一条曲线、一个坐标轴、几个标签,几行代码就能生成 PNG 或 PDF,直接贴进文档。对于"我只要一张能说明训练收敛情况的图"这个需求,matplotlib 是性价比最高的选择。
选型上还有个小决策:用plt.plot的面向过程写法,还是用fig, ax = plt.subplots()的面向对象写法?新手教程多用前者,但只要你需要画多子图、或者要把图嵌进更大的画布,面向对象写法更清晰。我下面统一用subplots风格,因为它扩展性更好,后面加验证集曲线、加平滑处理都不用重构。
2.3 一张合格的 loss 曲线应该包含什么
在动手写代码前,先明确"合格"的标准,否则画出来自己都不知道缺什么。我的检查清单是这样的:
- 横轴:epoch 或 step 编号,必须有轴标签,单位明确。
- 纵轴:loss 值,同样要有标签。如果训练集和验证集 loss 量纲差异大,考虑是否要分开或做归一化。
- 曲线:至少一条训练 loss,理想情况下加一条验证 loss 做对比。
- 图例:多条曲线时必须有,且位置不能遮挡数据。
- 标题:说明这是哪个模型、哪次实验的曲线。
- 网格:轻量网格线能帮助读数,但别太密。
这六点看着琐碎,但缺任何一项,图的可读性都会打折。尤其是图例和轴标签,很多人图省事不写,结果过两周自己回看都分不清哪条是训练哪条是验证。
3. 核心细节解析:从 loss 收集到曲线成型的每一步
3.1 训练循环中的 loss 收集写法
先看最核心的收集逻辑。下面这段是一个最小可用的训练循环骨架,重点看train_losses和val_losses两个列表怎么用:
import torch import torch.nn as nn from torch.utils.data import DataLoader train_losses = [] val_losses = [] for epoch in range(num_epochs): model.train() running_loss = 0.0 for batch_idx, (inputs, targets) in enumerate(train_loader): optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) epoch_train_loss = running_loss / len(train_loader.dataset) train_losses.append(epoch_train_loss) model.eval() running_val_loss = 0.0 with torch.no_grad(): for inputs, targets in val_loader: outputs = model(inputs) loss = criterion(outputs, targets) running_val_loss += loss.item() * inputs.size(0) epoch_val_loss = running_val_loss / len(val_loader.dataset) val_losses.append(epoch_val_loss) print(f"Epoch {epoch+1}: train_loss={epoch_train_loss:.4f}, val_loss={epoch_val_loss:.4f}")这里有几个关键点值得展开。第一,loss.item()一定要调用,它把只含一个元素的张量转成 Python 浮点数,避免把计算图一起存进列表导致显存泄漏——这是新手最常犯的隐性错误,训练久了显存莫名其妙爆掉,往往就是忘了.item()。第二,running_loss += loss.item() * inputs.size(0)这种按样本数加权的写法,比直接累加 batch loss 再除以 batch 数更准确,因为最后一个 batch 的样本数可能不满。第三,验证阶段务必model.eval()加torch.no_grad(),前者影响 BatchNorm 和 Dropout 的行为,后者省显存,两者缺一不可。
3.2 单条曲线的绘制与样式控制
数据齐了,画图本身其实很短。下面是最小绘图代码:
import matplotlib.pyplot as plt plt.figure(figsize=(8, 5)) plt.plot(range(1, len(train_losses) + 1), train_losses, label="Train Loss", color="#1f77b4", linewidth=1.8) plt.xlabel("Epoch") plt.ylabel("Loss") plt.title("Training Loss Curve") plt.legend() plt.grid(True, linestyle="--", alpha=0.5) plt.tight_layout() plt.savefig("loss_curve.png", dpi=150) plt.show()figsize控制画布比例,8:5 是比较通用的宽屏比例,适合放进文档。linewidth我一般设 1.5 到 2 之间,太细看不清,太粗显得笨重。dpi=150是导出清晰度的关键,默认 100 在屏幕上够用,但打印或放大就会糊,150 到 200 是比较稳妥的区间。tight_layout()用来防止轴标签被裁掉,这个函数在保存前调用能省掉很多"图边缘文字被切"的麻烦。
3.3 训练集与验证集双曲线的对比画法
单条曲线只能看"降没降",双曲线才能看"过没过拟合"。把两条画在同一张图上,核心是颜色区分和图例:
plt.figure(figsize=(8, 5)) epochs = range(1, len(train_losses) + 1) plt.plot(epochs, train_losses, label="Train Loss", color="#1f77b4", linewidth=1.8) plt.plot(epochs, val_losses, label="Val Loss", color="#d62728", linewidth=1.8) plt.xlabel("Epoch") plt.ylabel("Loss") plt.title("Train vs Validation Loss") plt.legend(loc="best") plt.grid(True, linestyle="--", alpha=0.5) plt.tight_layout() plt.savefig("train_val_loss.png", dpi=150) plt.show()判断过拟合的经典信号是:训练 loss 持续下降,验证 loss 先降后升,两条曲线在某个 epoch 后开始"分叉"。这时候图例的位置就很重要,loc="best"让 matplotlib 自动找空白区域放图例,避免压住曲线。如果两条曲线量纲差太多(比如训练 loss 是 0.01 级别,验证 loss 是 1.0 级别),可以考虑用双 y 轴,但我不太推荐——双轴容易误导读者对"差距"的感知,更稳妥的做法是分开画两个子图。
3.4 中文标签与字体问题的处理
如果你的标题或轴标签要写中文,直接写会得到一堆方块。原因是 matplotlib 默认字体不含中文字形。解决办法是显式指定一个系统里存在的中文字体:
import matplotlib matplotlib.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "DejaVu Sans"] matplotlib.rcParams["axes.unicode_minus"] = False第一行按顺序尝试字体,找不到就往后fallback;第二行解决负号显示成方块的问题,因为很多中文字体里没有正确的负号字形。这里要注意,SimHei在部分系统上不一定装了,所以给一个 fallback 列表比写死一个字体更稳。如果实在搞不定字体,最省事的办法是标签全用英文,反而不会有兼容问题。
4. 实操过程:从零跑通一张可发布的 loss 曲线
4.1 环境准备与依赖确认
动手前先确认环境。需要的东西不多:torch、matplotlib,如果要处理数据再加numpy。版本上没有特别苛刻的要求,matplotlib3.x 以上都支持本文所有写法。检查方式很简单:
python -c "import torch, matplotlib; print(torch.__version__, matplotlib.__version__)"能打印出版本号就说明装好了。如果是在无图形界面的服务器上跑,plt.show()会报错或卡住,这时候要么改用matplotlib.use("Agg")后端只保存不显示,要么干脆去掉show()只保留savefig()。这个细节在本地开发时不会暴露,一上服务器就翻车,提前知道能省不少时间。
4.2 完整可复现脚本
把前面的片段拼成一个能直接跑的完整脚本。为了不依赖真实数据集,这里用一个极简的线性回归任务模拟训练过程,loss 收集和绘图逻辑与真实项目完全一致:
import torch import torch.nn as nn import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt torch.manual_seed(42) X = torch.randn(500, 10) true_w = torch.randn(10, 1) y = X @ true_w + 0.1 * torch.randn(500, 1) model = nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 1)) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.01) train_losses, val_losses = [], [] num_epochs = 50 for epoch in range(num_epochs): model.train() optimizer.zero_grad() pred = model(X) loss = criterion(pred, y) loss.backward() optimizer.step() train_losses.append(loss.item()) model.eval() with torch.no_grad(): val_pred = model(X) val_loss = criterion(val_pred, y) val_losses.append(val_loss.item()) plt.figure(figsize=(8, 5)) epochs = range(1, num_epochs + 1) plt.plot(epochs, train_losses, label="Train Loss", color="#1f77b4", linewidth=1.8) plt.plot(epochs, val_losses, label="Val Loss", color="#d62728", linewidth=1.8, linestyle="--") plt.xlabel("Epoch") plt.ylabel("Loss") plt.title("Loss Curve Demo") plt.legend(loc="best") plt.grid(True, linestyle="--", alpha=0.5) plt.tight_layout() plt.savefig("loss_curve_demo.png", dpi=150) print("saved:", train_losses[-1], val_losses[-1])跑完会在当前目录生成loss_curve_demo.png。这里验证集和训练集用的是同一批数据,只是为了演示双曲线画法,真实项目里当然要分开。注意验证曲线我用了linestyle="--"虚线,这是个小技巧:颜色加线型双重区分,即使打印成黑白也能分清两条线。
4.3 曲线平滑:让趋势更易读
当 batch 级 loss 抖动很大时,原始曲线像一团毛线,趋势反而看不清。这时候可以做滑动平均平滑。核心思路是用一个窗口在序列上滑动取均值:
def smooth(values, weight=0.9): smoothed = [] last = values[0] for v in values: last = last * weight + (1 - weight) * v smoothed.append(last) return smoothed这是指数移动平均(EMA)的写法,weight越接近 1 越平滑,但滞后也越明显。我一般取 0.9 左右,既能压住抖动又不至于把真实拐点抹平。画图时把原始曲线用浅色细线、平滑曲线用深色粗线叠在一起,读者既能看到原始波动又能抓住趋势,这个组合在论文里很常见。
4.4 多组实验对比的画法
调参时经常要对比不同学习率或不同结构的 loss 曲线。最直接的做法是把多组结果画在同一张图上,用不同颜色区分:
configs = { "lr=0.001": losses_lr1, "lr=0.01": losses_lr2, "lr=0.1": losses_lr3, } plt.figure(figsize=(9, 5)) for name, curve in configs.items(): plt.plot(range(1, len(curve) + 1), curve, label=name, linewidth=1.6) plt.xlabel("Epoch") plt.ylabel("Loss") plt.title("Loss under Different Learning Rates") plt.legend() plt.grid(True, linestyle="--", alpha=0.5) plt.tight_layout() plt.savefig("lr_compare.png", dpi=150)这里有个经验:曲线超过四条就别挤一张图了,颜色会开始难分辨,改用子图(plt.subplots(2, 2))更清晰。另外不同实验的 epoch 数可能不同,range要按各自长度生成,别硬套同一个范围,否则会报长度不匹配。
5. 常见问题与排查技巧实录
5.1 图画出来是空白或只有一条直线
最常见的原因是数据没进列表。检查两点:append是不是写在了循环外面(那样只会存最后一个值),或者loss.item()是不是漏了导致存进去的是张量。还有一种情况是plt.plot的 x 和 y 长度不一致,matplotlib 会静默画错或报错。排查方法很简单,画图前先print(len(train_losses)),确认长度等于 epoch 数。
5.2 中文显示成方块
前面 3.4 节已经给了方案,这里补充一个排查顺序:先确认系统里到底有没有那个字体(matplotlib.font_manager.findfont("SimHei")能查),再确认rcParams设置是不是在import pyplot之后被覆盖了。有时候是代码里别处又改回了默认字体,导致设置失效。
5.3 保存的图边缘文字被裁
savefig默认按画布大小裁切,轴标签或标题超出边界就被切掉。解决办法是在savefig里加bbox_inches="tight",它会自动扩展边界包住所有元素。这个参数比tight_layout()更彻底,两者可以一起用。
5.4 显存越训越大
如果 loss 列表里存的是张量而不是浮点数,每个张量都带着计算图,几十个 epoch 下来显存就被吃光了。确认每个append进去的都是loss.item()的结果。这个坑非常隐蔽,因为训练能跑、图也能画,只是显存悄悄涨。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 图为空白 | 列表为空或长度不匹配 | 打印列表长度核对 |
| 只有一条直线 | 只存了最后一个值 | 检查 append 位置 |
| 中文变方块 | 字体未指定 | 设置 sans-serif 字体列表 |
| 边缘文字被切 | 边界未扩展 | 加 bbox_inches="tight" |
| 显存持续增长 | 存了张量未转浮点 | 统一用 loss.item() |
| 曲线抖动看不清 | batch 级噪声大 | 加 EMA 平滑或改画 epoch 均值 |
| 多曲线颜色难分 | 曲线过多 | 改子图或加线型区分 |
5.6 几个我踩过的实操心得
第一个心得:先存数据再画图,别边训边画。有人喜欢在每个 epoch 结束时调用一次plt.plot刷新,这在交互环境里看着爽,但会拖慢训练,而且容易因为图形对象没清理导致内存泄漏。正确做法是训练全程只收集,训练结束后一次性画。
第二个心得:给图起个能自解释的文件名。loss_curve.png这种名字过一周就分不清是哪次实验了,带上模型名、学习率、日期,比如resnet_lr0.01_20240601.png,回头找图能省大量时间。
第三个心得:验证 loss 的横轴要和训练 loss 对齐。如果训练按 step 记录、验证按 epoch 记录,直接画在一张图上横轴含义就乱了。要么都按 epoch,要么把 step 级数据按 epoch 聚合后再画。
第四个心得:导出格式按用途选。放网页或文档用 PNG,放论文或需要无损缩放用 PDF 或 SVG,savefig会根据扩展名自动选格式,不用额外配置。
6. 让曲线真正服务于训练决策
画图不是终点,看懂图才是。一条健康的训练 loss 曲线,早期应该快速下降,中期趋于平缓,末期在一个小范围内波动。如果它长时间不降,先别急着改模型,检查学习率是不是太大导致震荡、或者太小导致几乎不动。如果训练 loss 降但验证 loss 升,那是过拟合的典型信号,考虑加正则、加数据或早停。如果两条曲线都剧烈抖动,多半是 batch size 太小或学习率偏高。
我个人的习惯是每次实验都固定导出两张图:一张原始 batch 级曲线看稳定性,一张 epoch 级平滑曲线看趋势,文件名带上关键超参。时间久了,这些图本身就是一份实验记录,比翻日志高效得多。这套流程不复杂,但坚持下来,你对模型行为的判断会越来越准,调参也不再是盲目试错。