拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Pytorch训练中Loss曲线绘制全攻略:从记录到可视化与调参

Pytorch训练中Loss曲线绘制全攻略:从记录到可视化与调参

简介:一份面向PyTorch初学者的练习笔记,聚焦模型训练中Loss曲线的绘制方法。内容以线性回归为例,逐步演示numpy与matplotlib的导入、样本数据设置、前向传播与均方误差损失函数定义,并通过遍历权重w计算MSE、最终绘制Loss曲线。适合正在学习深度学习基础、希望理解损失函数与参数关系的读者,也可作为课程配套的简明实验参考。资源为1个PDF文件,体积约60KB,内容精炼,便于快速查阅。目前已有16273人学习下载。通过这份材料,读者可以掌握在不依赖TensorBoard等高级工具的情况下,用原生PyTorch思路手写可视化流程,并了解如何通过Loss曲线判断模型收敛状态,为后续训练更复杂网络打下基础。

1. 绘制Loss曲线之前,先想清楚曲线要告诉你什么

一个常见误区是等训练全部结束,才想起来画Loss曲线。训练跑了一夜,图一画出来,训练损失一路走低,验证损失却从第20个epoch开始反弹,整晚白跑。其实Loss曲线在Pytorch里不是什么复杂功能,它就是从训练循环里记录的一组标量,再用matplotlib画两条线,但恰恰是“记录”这一步,决定了曲线能不能反映真实情况。作为一线工程师,我见过太多人把重心放在画图样式上,结果数据记录得稀碎,最后曲线完全不能用于判断拟合状态。这篇文章把Pytorch练习里最常用的Loss曲线绘制过程拆开,先讲训练循环里怎么记录train/val损失和学习率,再给最小绘图脚本,按“平滑、对比、排错、沉淀”的顺序讲透,适合刚跑通分类或目标检测任务、想快速看懂训练发生了什么的新手,也适合需要反复对比不同超参数的熟练工程师。

2. 训练循环先记录Loss:决定曲线成败的不是绘图而是数据

2.1 为什么按batch记录loss会把曲线画成锯齿

很多人习惯在训练循环里打印每个step的loss,然后把所有数值收集起来画图。这样做的直接后果是曲线变成一根密密麻麻的锯齿,完全看不出趋势。原因在于相邻batch的数据分布不一样,尤其用了BatchNorm之后,每个step的loss波动会被放大;训练初期loss下降很快,后期变化很慢,原始点会把这两种节奏混在一起。更麻烦的是,如果你把每个step的loss都存进列表,几千个点里真正有信息量的没几个,画出来之后你自己都不知道该信哪里。

我一般会按epoch做平均,每个epoch只产生一个点。这样保存下来的曲线天然平滑,而且能和其他实验的曲线对比。你不需要额外引入TensorBoard这类重工具,一个列表就够了。关键操作是loss要调用.item()取出Python标量,而不是保留整个张量——如果直接把loss加进list,整个计算图都不会释放,内存越积越多,训练到一半就可能被OOM干掉。

Pytorch基础框架里,每个epoch的loss平均值差不多就够画趋势了。如果你在跑分割或检测任务,一个batch里样本尺度差异大,平均值的波动也会大,这时候更不应该保存单步数值。先按epoch把数据点压缩,后续才谈得上平滑和对比。

2.2 一个能同时记录train/val的最小训练循环

直接给一个能跑的最小代码块,这段代码会把训练集和验证集的loss都按epoch存下来,同时记录学习率。验证集必须使用torch.no_grad(),否则不仅浪费显存,还可能因为验证阶段仍然构建计算图,让后续的绘图脚本出现莫名其妙的显存溢出。

# train_losses 和 val_losses 会在整个训练过程中不断被追加 train_losses, val_losses, lr_records = [], [], [] for epoch in range(epochs): model.train() running_loss = 0.0 for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() optimizer.step() # 用 .item() 拿标量,不保留计算图,避免内存累积 running_loss += loss.item() epoch_train_loss = running_loss / len(train_loader) train_losses.append(epoch_train_loss) model.eval() val_running = 0.0 with torch.no_grad(): for x, y in val_loader: x, y = x.to(device), y.to(device) out = model(x) val_running += criterion(out, y).item() epoch_val_loss = val_running / len(val_loader) val_losses.append(epoch_val_loss) # 记录当前学习率,后面很多对比实验要靠它归因 lr_records.append(optimizer.param_groups[0]["lr"]) print(f"epoch {epoch+1:03d} | train {epoch_train_loss:.4f} | val {epoch_val_loss:.4f}")

这段代码的逻辑不复杂,但有三个点必须说清楚。第一,len(train_loader)是batch的个数,不是样本数,所以算出来的是一个batch的平均loss,这个值和你loss函数里设置的reduction保持一致就行。第二,如果换用BCE Loss、Focal Loss这类不同损失函数,曲线的绝对数值区间会不同,但记录方式完全一样,不需要改结构。第三,验证loss的数值通常和训练loss有差距,这是正常的,关键是看两条线之间的距离怎么变化,而不是追求它们完全相等。

2.3 学习率也要留下来,不然对比实验没法归因

只记loss不记学习率,是很多人后期做对比实验时最后悔的一件事。Pytorch里常见的学习率调度器,比如StepLR、CosineAnnealingLR,每个epoch的学习率都在变。如果某一次实验loss掉得特别快,你可能说不清是模型结构变好了,还是恰好撞上了学习率的高位区间。

所以我建议在训练循环里顺手把optimizer.param_groups[0]["lr"]存下来。这样画Loss曲线的时候,可以把学习率曲线放在同一张图的次坐标轴上,一眼就能看出loss的拐点是不是由学习率变化引起的。这种做法在调warmup和CosineAnnealing时尤其好用,因为曲线形状经常和学习率曲线强相关。

另外一个容易被忽略的点是:列表只存在内存里,脚本一退出就没了。我建议每个epoch结束后,把本轮的数字追加到CSV里,具体做法在后面的避坑章节会给出。先把损失记录这一环做扎实,绘图的输入才不会出问题。

3. 用matplotlib把Loss曲线画出来:最小脚本与三个必调参数

3.1 训练线和验证线为什么要分开画

很多人第一次画Loss曲线时只画训练loss,画完看不出问题。训练loss低只能说明模型在训练集上拟合得好,无法判断泛化情况。真正有价值的信息来自训练线和验证线之间的距离和走向。如果训练loss持续下降,验证loss在第20个epoch之后开始反弹,说明模型开始过拟合;如果两条线都高居不下,说明欠拟合或学习率设置不对;如果两条线同步下降且间距稳定,说明训练状态比较健康。

所以从记录数据那一刻起,就要把train loss和val loss分开存。画图时也分开用不同线型和颜色画。有人会问验证集每轮只有一批数据,计算出来会不会波动大?确实会,尤其是验证集样本少的时候。这时候可以在验证集上多做几次dropout采样,或者直接对验证loss用指数移动平均处理,这一点我会在下一章展开。

3.2 最小可用的绘图脚本

下面是一段可以直接复制的matplotlib绘图代码。它没有任何花哨功能,就是画出两条曲线、加上图例和网格、保存成PNG。这样做的好处是无论你在本机还是服务器上跑,输出都是同一张图,不需要额外配置图形界面。

import matplotlib.pyplot as plt # 如果标题里要显示中文,需要指定中文字体;不想折腾就直接用英文标题 plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False epochs = range(1, len(train_losses) + 1) fig, ax = plt.subplots(figsize=(10, 6), dpi=120) ax.plot(epochs, train_losses, "o-", label="train loss", linewidth=1.8, markersize=4) ax.plot(epochs, val_losses, "s--", label="val loss", linewidth=1.8, markersize=4) ax.set_xlabel("epoch") ax.set_ylabel("loss") ax.set_title("Pytorch 训练 Loss 曲线") ax.legend() ax.grid(True, linestyle="--", alpha=0.4) fig.tight_layout() fig.savefig("loss_curve.png")

这里有个小习惯:训练线用实线加圆点"o-",验证线用虚线加方块"s--"。这样即使打印成黑白图,或者读者有色盲问题,依然能区分两条线。linewidth=1.8保证线条清晰,markersize=4让每个epoch点不会太大、也不会小到看不清。fig.tight_layout()这行不能省,它会把坐标轴标签和图例重新排版,避免保存出来的图片四周留白不均匀或者标签被裁剪。

3.3 三个必调参数:figsize、dpi、平滑窗口

每次画Loss曲线都反复调样式是浪费时间,先把下面这几个参数定好,后面基本不用动。

参数典型值作用
figsize(10, 6)画布尺寸,太小看不清拐点,太大存图浪费空间
dpi120像素密度,120用于屏幕看图足够,论文插图用到200以上
平滑窗口见第四章对验证线做平滑,避免单点噪声干扰判断

figsize和dpi其实是一对配合的参数,同样一张图,figsize=(10,6), dpi=120和figsize=(6,4), dpi=300的最终像素数量差不多,但文字和线条的相对大小完全不同。我一般习惯屏幕预览用figsize=(10,6), dpi=120,要放进报告里再单独用大尺寸重新渲染一次。

平滑窗口不应该是每次画图时顺手改的数字,而应该是一个固定的函数参数。你只需要记住:训练loss的epoch平均已经比较光滑了,平滑主要用在验证loss或那些带噪声的原始记录上。具体算法我推荐指数移动平均,下一章详细说。

4. 让曲线更有信息量:平滑、对比与视觉秩序

4.1 指数移动平均:比滑动窗口更好用的平滑方式

很多教程会用np.convolve做滑动窗口平均,但我更推荐指数移动平均(EMA)。滑动窗口平均有一个问题:窗口大小需要自己试,太小压不住噪声,太大拐点会被抹平。用EMA的时候,你只需要控制一个alpha参数,它对近期的数据赋予更高的权重,实际效果更直观。

下面这个函数可以直接贴进你的绘图脚本里:

def ema_smooth(values, alpha=0.95, warmup=5): smoothed = [] last = values[0] for i, v in enumerate(values): if i < warmup: # warmup阶段保留原始点,避免曲线头部变形 smoothed.append(v) last = v else: last = alpha * last + (1 - alpha) * v smoothed.append(last) return smoothed

这个函数有两次调整空间。alpha越大,曲线越平滑,但对真实拐点反应越迟钝;alpha越小,曲线越贴近原始数据,噪声也会保留更多。我常用的区间在0.9到0.99之间。warmup的作用是防止前面的点被平滑得过早,因为训练前几个epoch的loss往往变化剧烈,如果一上来就用EMA,开头一段会明显失真。经验做法是warmup设为5左右,前5个epoch保留原始值。

EMA还有一个隐藏好处:你可以把平滑后的线和原始线叠在一张图上,原始线用很浅的颜色画,平滑线用深色画。这样既能展示真实波动范围,又不丢失趋势。这个技巧在验证集数据量少、曲线噪声大的时候特别管用。

4.2 两代模型的对比图:横向比较才更能说明问题

如果你只画一次训练的Loss曲线,其实看不出太多东西。真正有价值的是把修改前后的两条曲线放在同一张图里对比,比如换了一个数据增强策略、调整了损失函数、或者改了网络结构。

画对比图有个细节:两次训练的epoch总数可能不一样,直接画在一起会导致两条曲线的x轴长度不一致,视觉上容易误判。我的做法是在代码里先做一次对齐,比如都以较短实验的epoch数为准去做截断,或者用numpy.interp插值到统一长度。截断更简单,也不会引入虚拟数据点。

import numpy as np min_epochs = min(len(losses_a), len(losses_b)) epochs_aligned = range(1, min_epochs + 1) fig, ax = plt.subplots(figsize=(10, 6), dpi=120) ax.plot(epochs_aligned, losses_a[:min_epochs], "o-", label="baseline") ax.plot(epochs_aligned, losses_b[:min_epochs], "s-", label="modified") ax.set_xlabel("epoch") ax.set_ylabel("loss") ax.legend() ax.grid(alpha=0.4) fig.savefig("compare.png")

对比时不要只看训练loss。如果baseline的验证loss比改进方案的更低,说明这次修改大概率是负优化;如果训练loss差不多,但验证loss明显更低,说明泛化能力确实提升了。一次完整的对比实验应该同时包含train和val四条线,但为了视觉清晰,我建议分两张图画,一张训练loss对比,一张验证loss对比,共享相同的y轴范围会更直观。

4.3 颜色、线型、图例的视觉约定

多条线放在同一张图的时候,最容易翻车的不是代码,而是视觉秩序。如果每次画图都是随机颜色,看三分钟眼睛就花了。我给自己定的约定是:同一个模型的训练线和验证线用同一色系的颜色,训练用实线,验证用虚线;不同模型之间用不同颜色区分。这样横向对比时,先看颜色定位模型,再看线型区分train/val,效率会高很多。

图例的位置也有讲究。默认的ax.legend()一般放在右上角,但如果你的验证loss曲线正好在右上角区域,图例就会挡住关键拐点。我一般用ax.legend(loc="upper right")或bbox_to_anchor=(1.02, 1)手动调节。如果你画的是多标签任务的多个loss,建议用subplots,每个子图放一条曲线,而不是把所有线都叠在一起。多条线叠一张图的做法我真的踩过坑,最后除了自己谁也看不懂。

网格线alpha=0.4是一个比较舒服的值,太深会抢走数据线条的注意力,太浅在投影仪上看不清。这些参数不需要记,记住“每条曲线都要能被单独辨认”这个原则就够了。

5. 绘制Loss曲线常见的6个翻车点与排查思路

5.1 锯齿严重,趋势完全看不清

现象:画出来的曲线像心电图,每一个点都剧烈跳动,看不出loss是升还是降。原因:你保存的是每个step的原始loss值,而不是每个epoch的平均值。相邻batch的差异在视觉上被放大了。解决:改成按epoch平均后再画,这是最省事也最有效的办法。如果平均之后还是抖得厉害,再对验证loss用上一章的EMA平滑处理。

5.2 val loss比train loss高一个量级

现象:验证loss大概是训练loss的5到10倍,曲线整体被托上去,两条线几乎分不开。原因:最常见的是训练时模型处于train模式,有dropout和BatchNorm,而验证阶段忘了调用model.eval(),导致验证时网络仍然在做不稳定的采样;另一种可能是训练和验证用了不同的loss计算方式,比如训练用reduction="mean",验证时误用了reduction="sum"。解决:先把验证阶段加model.eval(),再用同一个criterion分别计算两个集合的loss。如果确认无误后差距还是很大,说明模型泛化确实有问题,这时候曲线变差反而是真相。

5.3 训练loss在降,但精度或mAP不涨

现象:loss曲线所有都在往下走,看起来一切正常,但accuracy指标原地不动。原因:损失函数和评估指标并不总是一致的。比如用一个边界框回归loss,它的下降不代表IoU在上升;又比如类别极度不均衡时,模型学会把所有样本预测为多数类,loss可能仍然在下降,但目标类的精度是0。解决:不要把Loss曲线当唯一依据,应该把accuracy、precision或mAP曲线和Loss曲线上下拼在同一张图里,共享epoch轴,一起观察。画两个独立的子图即可,代码不复杂,但能让你立刻发现“loss在骗人”的情况。

5.4 训练中断,所有记录在内存里全丢

现象:训练到第80个epoch时服务器OOM或者代码报错退出,你发现train_losses这个列表只存在于刚才的进程里,什么都没留下。原因:你只在内存里保存了记录,没有落盘。解决:每个epoch结束后把当前数字追加到CSV文件。只需要几行代码:

import csv with open("train_log.csv", "a", newline="") as f: writer = csv.writer(f) writer.writerow([epoch + 1, epoch_train_loss, epoch_val_loss, optimizer.param_groups[0]["lr"]])

这样就算训练中断,重新写一个脚本读CSV也能把已经完成的曲线画出来,不用重新训练。这个习惯我一直保留着,它相当于给训练过程吃了颗后悔药。

5.5 loss突然变成NaN,曲线断档

现象:训练到某个epoch之后loss变成nan,曲线出现断崖或者消失,后面再也画不出来。原因:学习率设置过高导致梯度爆炸,或者数据里出现inf/nan值,或者在混合精度训练下loss缩放异常。解决:先把学习率降一个量级试试,这是成本最低的排查方式。然后在训练循环里加一个检查,如果loss不是有限数值,跳过当前step的更新:

if not torch.isfinite(loss): print(f"epoch {epoch+1} step {step}: loss is nan, skip update") optimizer.zero_grad() continue

注意要放在loss.backward()之前判断,因为loss已经算出来了,直接检查它本身。不会损失太多训练进度,但能防止整个训练进程因为一个坏样本挂掉。

6. 把画图嵌进训练流程:用CSV兜底,让曲线随时可复现

6.1 用CSV兜底:训练中断也能把曲线重新画出来

现在建议你把训练和绘图彻底解耦。训练时只负责往CSV里追加数据,绘图时单独写一个脚本从CSV读数据。这样做有两个好处:训练过程中崩溃不会丢数据;同一份CSV可以随时重新绘制,方便不同阶段调整画图参数后反复出图。

一个简单的离线绘图脚本可以是这样的:

import csv import matplotlib.pyplot as plt epochs, train_losses, val_losses = [], [], [] with open("train_log.csv", "r") as f: reader = csv.reader(f) header = next(reader) # 跳过表头 for row in reader: epochs.append(int(row[0])) train_losses.append(float(row[1])) val_losses.append(float(row[2])) fig, ax = plt.subplots(figsize=(10, 6), dpi=120) ax.plot(epochs, train_losses, "o-", label="train loss", linewidth=1.8) ax.plot(epochs, val_losses, "s--", label="val loss", linewidth=1.8) ax.set_xlabel("epoch") ax.set_ylabel("loss") ax.legend() ax.grid(alpha=0.4) fig.savefig("loss_curve_from_csv.png")

从CSV重新画出来的曲线,和你训练结束时画的那张完全一致,这也方便你在不同机器之间迁移实验记录。把上一次实验的CSV和训练脚本一起归档,下次想要复现的时候,根本不用重新训练。

6.2 曲线画完之后,怎样验证这次训练是否正常

很多人画完曲线看一眼就关了,缺少一次正式的“验收”。我自己的习惯是固定检查四件事:训练loss最终值是否落在合理范围;验证loss和训练loss之间的距离是否稳定;两条线有没有在最后阶段出现明显分叉;学习率曲线是否和loss曲线匹配。这四条看完,再决定是继续训练、提前停止还是调整超参。

如果你训练的是分类任务,还可以额外注意一点:初始loss是否符合预期。比如用CrossEntropyLoss做10分类,随机初始化的模型loss应该在log(10)≈2.3附近;如果差很多,大概率是权重初始化或数据处理出了问题。用BCE Loss做多标签分类时,初始值会根据正负样本比例变化,不能直接套经验值。这些数值层面的细节,比曲线形状更能说明问题。

说句实在话,我现在开新项目的第一件事不是搭模型,而是先把CSV记录和画图脚本写好。训练再久也不慌,因为随时可以重新画图、随时可以对比实验、随时能回答“上一次模型到底在哪一步开始过拟合”这个最容易被忽略的问题。希望这些做法能帮你也少走一次弯路,画出的每条Loss曲线都能真正指导你调参。

本文还有配套的精品资源,点击获取

返回列表