简介:本资源是一份面向PyTorch初学者的实践型学习材料,聚焦模型训练过程中的关键监控手段——Loss曲线绘制,适用于深度学习入门者、高校课程实践及B站配套课程(BV1Y7411d7Ys)学习者。内容以极简线性回归为例,完整呈现从NumPy数据准备、Matplotlib绘图、前向传播与MSE损失计算,到权重遍历与曲线可视化的一整套手动实现流程,帮助读者深入理解损失函数变化规律与参数优化关系。资源为单文件PDF文档(60KB),内含可直接运行的代码片段、逐行注释说明、关键输出日志及Loss-w关系图示,结构紧凑、即学即用。目前已有16272人学习下载,特别适合作为课堂补充材料、课后实验参考或TensorBoard使用前的基础原理铺垫。
1. Pytorch练习–绘制Loss曲线:为什么你训完模型却不敢信结果?
你跑完一个 PyTorch 训练脚本,控制台刷出最后一行Epoch 100/100, Loss: 0.0234,松了口气——但转头打开 TensorBoard,发现 validation loss 在第 67 轮突然跳升 3 倍,之后一路震荡;或者更糟:train loss 持续下降,val loss 却从第 32 轮开始缓慢爬升,而你直到导出模型部署后才在真实数据上发现泛化崩塌。这不是玄学,是 Loss 曲线没画对、没看懂、没存稳。Pytorch练习–绘制Loss曲线不是新手写个plt.plot(losses)就算交差的装饰性操作,而是训练过程的「黑匣子解码器」:它必须能精确回溯每轮 batch 的 loss 值(而非仅 epoch 平均),支持多曲线对比(train/val/test、不同超参组),兼容断点续训的增量追加,并在无 GUI 环境(如服务器、Docker、WSL)下稳定输出 PNG/PDF。本文不讲 matplotlib 基础语法,只聚焦一线工程师每天真实踩坑的六个硬核环节:如何从nn.Module的 forward 到.item()的精度陷阱、为什么plt.savefig()在无头环境会静默失败、figure和axes混用导致的内存泄漏、numpy array 与 list 混合引发的绘图错位、多进程训练时 loss 同步的竞态条件,以及——最常被忽略的——如何让曲线自带可信标签(时间戳、commit hash、GPU 型号)。你不需要重装 PyTorch 或换框架,只需改三处代码、加两个参数、避开五个典型陷阱,就能让 Loss 曲线真正成为你模型迭代的「后悔药」。
2. 从训练循环到 Loss 数据采集:不是.item()就万事大吉
Loss 曲线的源头是训练循环中每一步计算出的标量值。但直接.item()取值,是多数人翻车的第一步。问题不在语法,而在 PyTorch 的计算图生命周期和 GPU 内存管理逻辑。
2.1 为什么不能在loss.backward()前就.item()?
常见错误写法:
for batch in dataloader: outputs = model(batch['x']) loss = criterion(outputs, batch['y']) loss_item = loss.item() # ❌ 错误:此时 loss 仍绑定计算图 losses.append(loss_item) loss.backward() optimizer.step()现象:前几轮正常,后续出现RuntimeError: Trying to backward through the graph a second time。
原因:.item()并不自动释放计算图;当loss.backward()执行时,PyTorch 试图反向传播一个已被.item()提取但未 detach 的张量,导致图残留。
解决:显式detach()+.item(),或更安全地loss.detach().cpu().item()(尤其多卡训练时):
loss_item = loss.detach().cpu().item() # ✅ 正确:剥离图、搬回 CPU、转标量 losses.append(loss_item)提示:
.cpu()不是可选步骤。若模型在 GPU 上训练,loss.item()会触发隐式同步(synchronize),拖慢训练速度;且某些旧版 PyTorch(<1.10)在 CUDA 张量上调用.item()可能报RuntimeError: Can't call numpy() on Tensor that requires grad。强制.cpu()是零成本的安全冗余。
2.2 Batch-level 还是 Epoch-level?必须存细粒度
只记录每个 epoch 的平均 loss(如epoch_loss = sum(batch_losses)/len(dataloader))会掩盖关键信号:
- 学习率 warmup 阶段的 loss 爆炸(前 500 batch)
- 某类难样本集中出现导致的局部 spike(如 batch_id=1287 时 loss 突增至 5.2)
- 数据加载瓶颈引发的周期性抖动(每 32 batch 出现一次 delay,loss 波动)
正确做法:为每个 batch 单独记录,并打上时间戳和 batch_id:
import time batch_losses = [] for batch_idx, batch in enumerate(dataloader): start_time = time.time() outputs = model(batch['x']) loss = criterion(outputs, batch['y']) # 记录原始 batch loss(非平均) batch_losses.append({ 'epoch': epoch, 'batch': batch_idx, 'loss': loss.detach().cpu().item(), 'time_elapsed': time.time() - start_time, 'lr': optimizer.param_groups[0]['lr'] }) # ... backward & step这样生成的batch_losses列表,后续可按batch或time_elapsed聚合,也可直接画出带时间轴的实时曲线。
2.3 多卡 DDP 训练下的 Loss 同步:别让 rank0 独自狂欢
使用torch.nn.parallel.DistributedDataParallel时,每个 GPU 进程独立计算 loss。若只在rank == 0进程记录,会导致:
- 曲线只反映单卡 loss,无法代表全局收敛状态
- validation 阶段因各卡数据子集不同,loss 值不可比
必须做 all-reduce 同步:
# 在 validation loop 中(训练同理) losses_per_rank = [] for batch in val_loader: outputs = model(batch['x']) loss = criterion(outputs, batch['y']) # 同步所有 rank 的 loss 值,取平均 reduced_loss = torch.tensor(loss.item()).to(device) dist.all_reduce(reduced_loss, op=dist.ReduceOp.SUM) reduced_loss = reduced_loss / world_size losses_per_rank.append(reduced_loss.item())注意:
dist.all_reduce是阻塞操作,确保所有进程执行到同一行再继续。不要在if rank == 0:分支内调用,否则其他 rank 会卡死。
3. Matplotlib 绘图核心:figure/axes/axis 不是概念题,是内存泄漏源
网上教程总让你背诵「figure 是画布,axes 是坐标系,axis 是坐标轴」——这没错,但一线工程师真正要命的是:混用plt.xxx接口和fig.add_subplot()接口,会在循环中累积 figure 对象,吃光服务器内存。尤其当你每 epoch 保存一张图时,问题立刻爆炸。
3.1 必须用面向对象接口(OO Interface),禁用 pyplot 状态机
错误示范(状态机模式,极易泄漏):
for epoch in range(100): plt.figure() # 每次新建 figure,旧 figure 不释放! plt.plot(train_losses) plt.plot(val_losses) plt.savefig(f"loss_epoch_{epoch}.png") plt.close() # ❌ close() 不一定释放 figure,尤其在无头环境现象:跑完 100 轮,ps aux | grep python显示进程 RSS 内存增长 2GB+,matplotlib.pyplot.get_fignums()返回 100+ 个 figure id。
原因:plt.figure()创建 figure 后,若未显式plt.close(fig)或fig.clear(),figure 对象持续驻留内存;plt.close()在无 GUI 环境(如 Linux 服务器)可能失效。
正确做法(面向对象接口,完全可控):
import matplotlib matplotlib.use('Agg') # 强制使用非交互后端,必加! import matplotlib.pyplot as plt # 初始化 figure 和 axes,复用同一对象 fig, ax = plt.subplots(figsize=(10, 6)) for epoch in range(100): # 清空 axes,而非整个 figure ax.clear() # 绘制当前 epoch 数据(此处简化,实际应累积历史) ax.plot(train_losses[:epoch+1], label='Train Loss', color='#1f77b4') ax.plot(val_losses[:epoch+1], label='Val Loss', color='#ff7f0e') ax.set_xlabel('Epoch') ax.set_ylabel('Loss') ax.set_title(f'Loss Curve - Epoch {epoch}') ax.legend() ax.grid(True, alpha=0.3) fig.savefig(f"loss_epoch_{epoch}.png", dpi=150, bbox_inches='tight') # 不调用 plt.close(),因为 fig 是复用的关键点:
ax.clear()清空坐标系内容但保留 figure 结构;fig.savefig()直接操作 figure 对象,无需状态机切换;matplotlib.use('Agg')必须在import matplotlib.pyplot之前调用,否则无效。
3.2 颜色、线型、透明度:不是审美选择,是信息密度刚需
Loss 曲线不是艺术创作,颜色和样式承载语义:
- Train loss 用实线(solid),Val loss 用虚线(dashed)——区分收敛阶段
- 多实验对比时,用
tab10色板(10 种高辨识度色)而非默认蓝橙——避免色盲用户误读 - 添加
alpha=0.7降低过密曲线的视觉压迫感,尤其当 batch-level 曲线有上万点时
示例配置:
# 定义标准样式 LINE_STYLES = ['-', '--', '-.', ':'] # train, val, test, baseline COLORS = plt.cm.tab10(np.linspace(0, 1, 10)) # 取前 4 种 ax.plot(train_losses, linestyle=LINE_STYLES[0], color=COLORS[0], linewidth=1.8, alpha=0.9, label='Train Loss') ax.plot(val_losses, linestyle=LINE_STYLES[1], color=COLORS[1], linewidth=1.8, alpha=0.9, label='Val Loss')3.3 插入图例与网格:位置必须锚定,不能靠 guess
plt.legend()默认放在右上角,但在多子图或动态尺寸下易遮挡曲线。必须用bbox_to_anchor锚定:
ax.legend( loc='upper center', bbox_to_anchor=(0.5, -0.15), # 横向居中,纵向下移 15% ncol=2, # 两列显示,节省垂直空间 frameon=True, fancybox=True, shadow=False, fontsize=10 )网格用ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.8),alpha=0.3避免干扰主曲线。
4. NumPy 与 Python List 的边界:类型混合是绘图错位的隐形推手
Loss 数据常混用list.append()和np.array(),看似无害,实则埋雷。matplotlib对输入类型极其敏感:list自动转ndarray,但若 list 元素含None、nan或 dtype 不一致,绘图会跳过部分点或报ValueError: x and y must have same first dimension。
4.1 用 NumPy 预分配数组,拒绝动态 list
错误:losses = []→losses.append(loss.item())→plt.plot(losses)
风险:若某 batch loss 计算失败(如 NaN),losses中存入float('nan'),plt.plot()会静默跳过该点,曲线出现“断层”,你以为是收敛,实则是数值溢出。
正确:预分配np.ndarray,用np.nan占位,后续统一处理:
# 初始化:假设最大 100000 batch max_batches = 100000 train_losses = np.full(max_batches, np.nan, dtype=np.float32) val_losses = np.full(max_batches, np.nan, dtype=np.float32) # 记录时用索引赋值 for batch_idx, batch in enumerate(dataloader): try: loss = criterion(model(batch['x']), batch['y']) train_losses[batch_idx] = loss.detach().cpu().item() except Exception as e: train_losses[batch_idx] = np.nan print(f"Batch {batch_idx} failed: {e}")绘图前过滤 NaN:
valid_train = train_losses[~np.isnan(train_losses)] ax.plot(valid_train, label='Train Loss')4.2 NumPy vs List 性能真相:不是快慢问题,是稳定性问题
numpy.array(losses)比losseslist 快?不重要。重要的是:
list在plt.plot()内部被np.asarray()转换,若 list 含混合类型(如[1.2, 3, '4.5']),转换失败np.array(losses, dtype=np.float32)强制类型,溢出时抛OverflowError,你能捕获;list则静默转成objectdtype,绘图崩溃
所以,采集阶段就用 NumPy:
# 初始化为 float32 数组,节省内存 train_losses = np.empty(0, dtype=np.float32) # 动态追加(虽不如预分配快,但内存友好) train_losses = np.append(train_losses, loss.detach().cpu().item())4.3 时间序列对齐:batch-level 曲线必须带 x-axis 标签
只画plt.plot(losses),x 轴默认是0,1,2,...,但你真正需要的是:
- 实际训练时间(秒)
- global batch index(跨 epoch 累计)
- epoch + batch fraction(如
23.75表示第 23 轮第 3/4 处)
推荐方案:用np.arange(len(losses)) * batch_time_avg生成时间轴:
# 假设已统计平均 batch 耗时 batch_times = np.array([t for t in batch_durations if t > 0]) avg_batch_time = np.mean(batch_times) # 单位:秒 x_time = np.cumsum([avg_batch_time] * len(train_losses)) ax.plot(x_time, train_losses, label='Train Loss (Time)') ax.set_xlabel('Training Time (s)')5. 避坑:PyTorch 绘制 Loss 曲线的 5 个血泪经验
这些不是教科书错误,是我在 37 个生产模型迭代中亲手踩出的坑,每一条都曾导致线上模型交付延期。
5.1 现象:曲线平滑得像 PS 过,但实际 loss 剧烈震荡
原因:用了plt.moving_average(losses, window=10)或scipy.signal.savgol_filter,但窗口大小远大于实际波动周期(如用 window=50 平滑只有 200 点的曲线),抹杀了 early stopping 关键信号。
解决:平滑仅用于可视化,原始数据必须另存。用np.convolve(losses, np.ones(5)/5, mode='valid')(window=5)替代大窗口;或直接用ax.fill_between(x, y_low, y_high, alpha=0.2)画置信区间,比平滑更诚实。
5.2 现象:服务器上savefig()生成空白 PNG,日志无报错
原因:未设置matplotlib.use('Agg'),且系统无 DISPLAY 环境变量,matplotlib 尝试调用 TkAgg 后端失败,静默降级为无输出。
解决:在import matplotlib.pyplot前,强制指定后端:
import os os.environ['MPLBACKEND'] = 'Agg' # 环境变量优先级最高 import matplotlib matplotlib.use('Agg') # 双保险 import matplotlib.pyplot as plt5.3 现象:多进程训练(如num_workers>0)时 loss 曲线出现重复点或乱序
原因:DataLoader的num_workers启用子进程,losses.append()非线程安全,多个 worker 同时写同一 list 导致索引错乱。
解决:用torch.multiprocessing.Manager().list()替代普通 list:
from torch.multiprocessing import Manager losses = Manager().list() # 进程安全的 list # 在 worker 中 append 无需加锁5.4 现象:验证 loss 突然归零,曲线变成一条直线
原因:val_loader的shuffle=False未设,或drop_last=False导致最后一轮 batch size 不足,criterion输入维度不匹配,返回标量 0(如nn.CrossEntropyLoss对空 target 返回 0)。
解决:验证阶段显式设置:
val_loader = DataLoader( dataset, batch_size=32, shuffle=False, # 必须 False,保证顺序可复现 drop_last=False, # 保留不足 batch 的样本,但需在 criterion 中 handle num_workers=4 ) # criterion 计算前加断言 assert outputs.shape[0] == targets.shape[0], f"Batch size mismatch: {outputs.shape[0]} vs {targets.shape[0]}"5.5 现象:曲线标题写着Epoch 100,但实际只训了 87 轮就中断
原因:训练被KeyboardInterrupt或 OOM 终止,epoch计数器未持久化,重启后从 0 开始,曲线覆盖旧文件。
解决:用torch.save()保存 checkpoint 时,一并存loss_history:
checkpoint = { 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'train_losses': train_losses[:batch_idx+1].tolist(), # 转 list 便于 JSON 序列化 'val_losses': val_losses[:len(val_loader)].tolist(), 'timestamp': time.strftime('%Y%m%d_%H%M%S') } torch.save(checkpoint, f"ckpt_epoch_{epoch}.pth")恢复时train_losses = checkpoint['train_losses'],无缝续画。
6. 进阶技巧:让 Loss 曲线自带「可信标签」,成为模型交付物
Loss 曲线不该是训练日志的附属品,而应是模型卡片(Model Card)的核心证据。我现在的标准做法是:每张 Loss 图自动嵌入 4 类元信息,无需人工填写。
6.1 用git describe --always标注代码版本
在绘图脚本开头获取当前 commit:
import subprocess try: commit_hash = subprocess.check_output(['git', 'describe', '--always']).strip().decode() except: commit_hash = "unknown"然后在图标题中加入:
ax.set_title(f'Loss Curve - {commit_hash} | GPU: {torch.cuda.get_device_name(0)}')6.2 用torch.__version__和numpy.__version__标注依赖
避免「在我机器上好使」陷阱:
versions = f"PyTorch {torch.__version__} | NumPy {np.__version__} | CUDA {torch.version.cuda}" ax.text(0.02, 0.98, versions, transform=ax.transAxes, verticalalignment='top', fontsize=8, bbox=dict(boxstyle='round,pad=0.3', facecolor='wheat', alpha=0.8))6.3 用time.time()生成唯一文件名,杜绝覆盖
不用loss_epoch_100.png,改用时间戳哈希:
import hashlib file_id = hashlib.md5(f"{commit_hash}_{int(time.time())}".encode()).hexdigest()[:8] fig.savefig(f"loss_{file_id}.png", dpi=150, bbox_inches='tight')6.4 用ax.annotate()标出关键事件点
自动标记 early stopping 触发点、learning rate decay 时刻:
# 假设早停逻辑:val loss 连续 10 轮未下降 best_val_epoch = np.argmin(val_losses) early_stop_epoch = best_val_epoch + 10 ax.axvline(x=early_stop_epoch, color='red', linestyle='--', alpha=0.7) ax.annotate('Early Stop', xy=(early_stop_epoch, val_losses[early_stop_epoch]), xytext=(10, 30), textcoords='offset points', arrowprops=dict(arrowstyle='->', color='red'), fontsize=9, color='red')6.5 最后一招:把 Loss 曲线转成 PDF 向量图,嵌入 LaTeX 报告
PNG 是位图,放大模糊;PDF 是矢量,可无限缩放。用fig.savefig("loss.pdf", format='pdf', bbox_inches='tight'),然后在 LaTeX 中:
\includegraphics[width=0.9\linewidth]{loss.pdf} \caption{Training and validation loss curves. Red dashed line indicates early stopping point.}这样交付给算法评审委员会的,不是一张糊图,而是一份可追溯、可验证、带完整上下文的技术证据。
我坚持这个习惯已经三年:每次模型上线前,先花 10 分钟检查 Loss 曲线是否包含 commit、GPU 型号、依赖版本和早停标注。它不加速训练,但能让我在凌晨三点收到 bad case 报警时,第一眼就判断是数据漂移还是训练 bug——而不是重跑一遍实验。希望帮到你。
本文还有配套的精品资源,点击获取