在这里插入图片描述

章节 8: 监控与调试模型

构建和训练模型是重要的进展,但通常,初次尝试并不能达到预期效果或运行不顺畅。模型可能收敛缓慢、生成无意义的输出,或遇到运行时错误。本章讨论监控训练过程和调试 PyTorch 应用程序的实际需求。

我们将介绍诊断和解决常见问题的系统方法,包含张量形状不匹配以及与 CPU/GPU 设备分配相关的错误。你将学习如何检查梯度,以发现训练稳定性问题,比如梯度消失或梯度爆炸。此外,本章介绍监控训练动态的方法,特别是使用 TensorBoard 可视化例如损失和准确率随时间的变化。我们还将讨论集成基本日志记录,以及使用 Python 调试器 (pdb) 进行逐步代码检查。到本章结束时,你将掌握一个工具集,能够有效地排查问题并观察你的 PyTorch 模型。

PyTorch开发中常见错误

随着您开始构建较复杂的PyTorch模型和训练循环,您将不可避免地遇到错误,或模型行为不符预期的情况。有些错误会给出清晰的消息,而另一些则可能更难察觉,导致性能不佳却无明显崩溃。识别常见模式是高效调试的第一步。以下是一些PyTorch开发中常出现的问题。

形状不匹配

PyTorch中最常见的运行时错误可能涉及张量形状不兼容。这通常发生在某层的输出形状与下一层的预期输入形状不匹配,或输入数据的形状与模型第一层不一致时。

考虑一个简单序列:一个卷积层后接一个全连接(线性)层。nn.Conv2d层需要形状为 (Batch Size, Input Channels, Height, Width) 的输入张量,通常简写为 (N,Cin,Hin,Win)(N,C**in,H**in,W**in)。它会生成形状为 (N,Cout,Hout,Wout)(N,Cou**t,Hou**t,Wou**t) 的输出。然而,nn.Linear层需要一个形状为 (Batch Size, Input Features) 的2D输入,或 (N,输入特征数)(N,输入特征数)。直接连接它们而不改变形状会导致错误。

import torch
import torch.nn as nn

// 示例层
val conv_layer = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, stride=1, padding=1)
val linear_layer = nn.Linear(in_features=???, out_features=10) // 问题:in_features 是什么?

// 模拟输入数据
val input_data = torch.randn(64, 3, 32, 32) // (批量大小, 输入通道数, 高, 宽)

// 卷积层前向传播
val conv_output = conv_layer(input_data)
println(s"Conv output shape: ${conv_output.shape}")
// 输出:卷积输出形状:torch.Size([64, 16, 32, 32])

// 尝试直接传递给线性层(会失败)
// output = linear_layer(conv_output) // 这会引发一个 RuntimeError

// 正确方法需要展平
val flattened_output = conv_output.view(conv_output.size(0), -1) // 展平除批量维度外的所有维度
println(s"Flattened output shape: ${flattened_output.shape}")
// 输出:展平后输出形状:torch.Size([64, 16384]) # 16 * 32 * 32 = 16384

// 现在我们知道了线性层所需的 in_features
val correct_linear_layer = nn.Linear(in_features=16384, out_features=10)
val output = correct_linear_layer(flattened_output)
println(s"Final output shape: ${output.shape}")
// 输出:最终输出形状:torch.Size([64, 10])

错误通常看起来像:RuntimeError: size mismatch, m1: [64 x 16384], m2: [? x 10]m1 通常指传递给层的输入张量,m2 指的是层的权重矩阵。错误消息表明PyTorch尝试进行乘法运算的形状。调试这些错误包括:

  1. 使用.shape打印前一层输出张量的形状。
  2. 计算有问题层预期的输入特征数。对于卷积后的nn.Linear,这通常涉及使用tensor.view(batch_size, -1)将 (N,C,H,W)(N,C,H,W) 输出展平为 (N,C∗H∗W)(N,CHW)。
  3. 确保nn.Linear层的in_features参数与展平后的尺寸匹配。

设备不匹配 (CPU/GPU)

PyTorch允许在不同设备上进行计算,主要是在CPU和NVIDIA GPU(使用CUDA)。当您尝试对位于不同设备上的张量进行操作时,会经常发生运行时错误。例如,如果您的模型被移至GPU (model.to('cuda')),但您的输入数据保留在CPU上,前向传播将失败。

// 假设 CUDA 可用
if torch.cuda.is_available() then
    val device = torch.device("cuda")
else
    val device = torch.device("cpu")

println(s"Using device: ${device}")

val model = nn.Linear(10, 5)
val input_cpu = torch.randn(1, 10) // 默认在CPU上的张量

// 将模型移至GPU(如果可用)
model.to(device)
println(s"Model device: ${next(model.parameters()).device}")

// 尝试用CPU张量和GPU模型进行前向传播(如果设备是cuda则会失败)
try:
    output = model(input_cpu)
catch RuntimeError(e) =>
    println(s"Error: ${e}")
    // 输出可能是:错误:要求所有张量在同一设备上,
    // 但找到了至少两个设备,cuda:0 和 cpu!

// 正确方法:将输入张量移至与模型相同的设备
val input_gpu = input_cpu.to(device)
println(s"Input tensor device: ${input_gpu.device}")

output = model(input_gpu) // 这可以正常运行
println(s"Output tensor device: ${output.device}")
println("Forward pass successful!")

GPU 内存输入张量(CPU)模型(GPU)错误!设备不匹配输入张量(GPU).to(device)输出张量(GPU)前向传播

导致设备不匹配错误的常见情况。在前向传播之前,输入张量必须移动到与模型相同的设备上(例如GPU)。

错误消息 RuntimeError: Expected all tensors to be on the same device... 相当明确。调试方法包括:

  1. 在脚本早期确定目标device(检查torch.cuda.is_available())。
  2. 使用model.to(device)将模型移动到目标设备。
  3. 在训练或评估循环中,使用data = data.to(device)targets = targets.to(device)将输入数据张量明确地移动到同一目标设备。
  4. 如果不确定,检查张量和模型参数的.device属性(next(model.parameters()).device)。

损失函数或目标形状/类型不正确

选择正确的损失函数很重要,但您还需要确保您的模型输出和目标标签具有该损失函数预期的形状和数据类型。使用错误的组合可能不会总是导致崩溃,但会导致“静默”失败,即损失减小但模型未能正确学习实际任务。

  • nn.CrossEntropyLoss:常用于多类别分类。
    • 需要模型输出的原始、未归一化分数(logits),通常形状为 (N,C)(N,C),N 表示批量大小,C 表示类别数量。
    • 需要目标标签为类别索引(长整型),通常形状为 (N)(N)。
    • 通常不应在此损失函数 之前 应用 softmax 函数,因为CrossEntropyLoss结合了LogSoftmaxNLLLoss
  • nn.MSELoss (均方误差):常用于回归任务。
    • 需要模型输出和目标张量具有相同的形状(例如,(N,输出特征数)(N,输出特征数))。
    • 两个张量通常都应是浮点类型。
  • nn.BCEWithLogitsLoss:用于二元分类或多标签分类。
    • 需要模型输出的原始logits,形状为 (N,∗)(N,∗),这里的 ∗∗ 是一个或多个维度。
    • 需要目标标签为相同形状 (N,∗)(N,∗) 的概率(浮点数),通常包含0和1。
    • 在此损失函数 之前 使用 sigmoid 是不正确的,因为它已包含了 sigmoid 计算。

此处的不匹配可能导致:

  • 如果形状基本不兼容,则引发 RuntimeError
  • 如果数据类型错误(例如,向CrossEntropyLoss提供浮点型目标),则梯度计算不正确。
  • 模型进行训练但学习效果不佳,因为损失函数衡量的是错误的东西(例如,将MSELoss用于分类索引)。

调试需要仔细阅读您选择的损失函数在PyTorch文档中的说明,并验证:

  1. 模型输出张量的形状。
  2. 目标张量的形状和数据类型(.dtype)。
  3. 损失函数 之前 是否应应用任何激活函数(如softmaxsigmoid)。

梯度流问题

有时,梯度未能如预期地反向传播通过网络,导致参数没有得到更新。如果不监控,这可能会静默发生。

  • 忘记 requires_grad=True: 尽管nn.Module参数会自动带有requires_grad=True,但如果您创建的中间张量应是计算图的一部分,请确保它们正确设置了此标志。通常,如果它们是已需要梯度的张量运算的结果,这会自动处理。
  • 原地操作: 某些原地操作(如tensor.add_())可能会干扰较旧PyTorch版本或复杂的计算图中的梯度跟踪。虽然PyTorch已改进其处理方式,但在需要梯度的网络计算中,通常更安全地使用非原地操作版本(y = x + 1而不是x += 1)。
  • 使用NumPy: 将张量转换为NumPy(.numpy())会将其从计算图中分离。使用该NumPy数组进行的任何后续操作,即使转换回张量,也不会有梯度流回图的原始部分。
  • .detach(): 对张量调用.detach()会明确地将其从计算图中移除。这有时是必要的(例如,在评估期间),但如果在训练期间意外使用它将停止梯度。

这种问题的症状是发现某些参数的.grad属性在loss.backward()之后保持None,或者尽管训练循环正在运行,模型性能却没有改善。本章后续您将学习如何更规范地检查梯度。

数据加载与预处理错误

错误也可能源于您的Dataset实现或数据转换。

  • __getitem__不正确: 返回错误类型的数据(例如,如果模型期望张量,却返回NumPy数组而非张量图像)或不正确的形状。
  • 大小不一致: 如果__getitem__返回大小不一的张量(例如,变长序列或不同维度的图像),并且DataLoadercollate_fn未能正确处理这种填充或堆叠,这可能在批量创建期间导致错误。
  • 转换错误: 不正确地应用转换(例如,错误的归一化常数,在transforms.Compose流程中过早或过晚地转换为张量)可以静默地破坏输入到模型的数据。

调试这些错误通常包括:

  1. 单独实例化您的Dataset
  2. 使用dataset[i]手动获取少量项目。
  3. 在样本进入DataLoader 之前,检查其形状、数据类型和值范围。

了解这些常见问题有助于您预见潜在问题,并在问题出现时提供诊断的起点。后续章节将提供更结构化的方法,用于调试和监控模型。

调试张量形状不匹配

在PyTorch中开发神经网络时,张量形状不匹配可能是最常遇到的运行时错误。当传递给层或操作的张量维度与该层或操作的预期不一致时,就会出现这些错误。诊断这些问题是调试过程的主要部分。弄清楚如何追踪和修正形状不兼容问题,对于构建能正常运行的模型很重要。

形状错误通常是因为不同层对其输入张量的维度和大小有特定要求而出现的。例如,线性层需要(batch_size, in_features)的二维输入,而二维卷积层需要(batch_size, in_channels, height, width)这样的四维输入。执行矩阵乘法或逐元素相加等操作时,也会对操作张量的形状有严格要求。

形状不匹配的常见原因

让我们看看一些形状错误出现的典型情形:

  1. 线性层 (nn.Linear): 定义为nn.Linear(in_features, out_features)的线性层,要求其输入张量x的最后一个维度与in_features匹配。一个常见错误是在展平卷积层输出后,给它提供了特征数量不正确的张量。
    • 错误示例: RuntimeError: mat1 and mat2 shapes cannot be multiplied (64x1024 and 512x10) - 这里,输入批次有1024个特征,但线性层在定义时预期的是512个。
  2. 卷积层 (nn.Conv2d): 这些层要求输入张量形状为 (N, C_in, H, W),其中 N 是批次大小,C_in 是输入通道数,HW 是空间高度和宽度。如果通道维度不正确或输入张量不是四维的,就可能出现错误。
    • 错误示例: RuntimeError: Given groups=1, weight of size [32, 3, 3, 3], expected input[64, 1, 28, 28] to have 3 channels, but got 1 channels instead. - 该层预期有3个输入通道(如RGB),但收到的输入只有1个通道(如灰度图)。
  3. 展平操作: 从卷积/池化层过渡到线性层时,张量需要展平。展平后维度大小的计算错误是后续线性层经常出错的原因。nn.Flatten()层可以帮助自动化此过程,但您仍然需要确保第一个线性层的in_features与展平后的总元素数量匹配。
  4. 批次维度问题: PyTorch层通常要求输入数据包含一个批次维度,即使批次大小为1。在将数据传递给模型之前,忘记添加此维度(例如,对于单个样本使用tensor.unsqueeze(0))可能导致形状错误。
  5. 矩阵乘法 (torch.matmul, @): 标准矩阵乘法规则适用。对于 A @ BA 的列数必须等于 B 的行数。如果这些维度不匹配,就会出现错误。
  6. 逐元素操作: 张量之间的加法 (+)、减法 (-) 或乘法 (*) 等操作通常要求张量具有完全相同的形状,或者根据广播规则兼容。如果形状不兼容且无法广播,则会发生RuntimeError

调试形状错误的方法

系统地查找形状不匹配的来源,需要追踪张量维度在您的模型或操作中的变化。以下是有效的方法:

  1. 打印张量形状: 这是最直接的方式。在模型的forward方法或训练循环中的不同位置插入打印语句,以观察张量形状如何变化。使用f-string可以使代码更整洁:

    import torch
    import torch.nn as nn
    
    // 在模型的forward方法或训练代码中:
    // ... 前面的层 ...
    var x = some_layer(x)
    println(s"Shape after some_layer: ${x.shape}")
    x = next_layer(x)
    println(s"Shape after next_layer: ${x.shape}")
    // ... 后续的层 ...
    

    通过比较打印出的形状与下一层的预期输入形状,您可以定位到不匹配发生的位置。

  2. 理解错误信息: PyTorch运行时错误通常提供详细信息,包括失败的操作和涉及的张量形状。仔细阅读这些信息。它们通常看起来像这样: RuntimeError: size mismatch, m1: [A x B], m2: [C x D] ... 这会直接告知您在特定操作(通常是矩阵乘法)期间导致不兼容的形状([A x B][C x D])。

  3. 查阅层文档: 如果您不确定某个PyTorch层(例如nn.Conv2dnn.LSTMnn.BatchNorm1d)的预期输入或输出形状,请查阅PyTorch官方文档。它清楚地说明了所需的维度,以及如何根据核大小、步长、填充等参数计算输出形状。

  4. 手动计算形状(尤其是对于CNN): 对于卷积层和池化层,了解如何计算输出空间维度很重要。公式涉及输入大小、核大小、填充和步长。手动计算几层后的预期输出形状有助于检查您的网络架构是否与您的设想一致。例如,Conv2d层的输出高度 HoutHou**t 通常按如下方式计算:

    Hout=⌊Hin+2×padding−dilation×(kernel_size−1)−1stride+1⌋Hou**t=⌊strideH**in+2×padding−dilation×(kernel_size−1)−1+1⌋

    类似的公式也适用于宽度 WoutWou**t。了解这些有助于预测后续层所需的输入大小,特别是展平后的全连接层。

  5. 明智地使用 nn.Flatten: 从卷积层到线性层转换时,使用nn.Flatten(start_dim=1)通常比手动使用view进行重塑更安全。它会将从start_dim开始的所有维度(通常为1,以保持批次维度独立)展平为一个维度。但是,您仍然需要确保后续nn.Linear层的in_features与此展平后的大小匹配。

    // 示例:CNN输出 -> 展平 -> 线性
    // 假设 conv_output 的形状为 [batch_size, channels, height, width]
    val flatten = nn.Flatten() // 默认从维度1开始展平
    val flat_output = flatten(conv_output)
    // flat_output 形状:[batch_size, channels * height * width]
    
    // 计算线性层预期的特征数
    val num_features = flat_output.shape(1) 
    val linear_layer = nn.Linear(num_features, num_classes) 
    val output = linear_layer(flat_output) 
    
  6. 使用调试器单步执行: 对于复杂的模型或难以发现的错误,使用Python调试器(如本章后面讨论的pdb)可以逐行执行代码,并在模型的forward传递或训练循环中的每一步检查张量形状(x.shape)。

示例:修正展平不匹配

考虑一个简单的CNN,后接一个线性层:

import torch
import torch.nn as nn

// 示例输入(1张图片批次,1通道,28x28)
val dummy_input = torch.randn(1, 1, 28, 28) 
println(s"Input shape: ${dummy_input.shape}")

class SimpleNet extends nn.Module:
    def __init__(self):
        super().__init__()
        val conv1 = nn.Conv2d(1, 10, kernel_size=5) // 输出: (1, 10, 24, 24)
        val relu = nn.ReLU()
        val pool = nn.MaxPool2d(2) // 输出: (1, 10, 12, 12)
        // 错误:in_features 计算不正确或硬编码不当
        val fc1 = nn.Linear(10 * 12 * 12, 50) // 预期1440个特征

    def forward(x: torch.Tensor):
        println(s"Input shape: ${x.shape}")
        x = pool(relu(conv1(x)))
        println(s"Shape after conv/pool: ${x.shape}")
        // 不正确的展平尝试
        // x = x.view(-1, 10 * 10 * 10) // 如果运行,这将导致运行时错误
        // 正确的展平
        x = x.view(x.size(0), -1) // 展平除批次之外的所有维度
        println(s"Shape after flattening: ${x.shape}") 
        // 现在 x 的形状是 [1, 1440],因为 10 * 12 * 12 = 1440
        // 下面的 fc1 层预期有1440个特征,与展平后的匹配
        try:
            x = fc1(x)
        catch RuntimeError as e:
            println(s"\nError occurred: ${e}")
            println(s"Input shape to fc1: ${x.shape}")
            println(s"fc1 expects input features: ${fc1.in_features}")

# 实例化并运行
val model = SimpleNet()
model(dummy_input)

运行这段代码(包含try-except块)会打印出:

Input shape: torch.Size([1, 1, 28, 28])
Shape after conv/pool: torch.Size([1, 10, 12, 12])
Shape after flattening: torch.Size([1, 1440])

Error occurred: mat1 and mat2 shapes cannot be multiplied (1x1440 and 1000x50)
Input shape to fc1: torch.Size([1, 1440])
fc1 expects input features: 1000

打印出的语句和错误信息清楚地显示了不匹配:展平后的张量有1440个特征,但fc1在定义时in_features=1000

修正方法: 使用从池化层输出计算出的正确输入特征数量(10 * 12 * 12 = 1440)重新定义fc1

// __init__中的正确定义
val fc1 = nn.Linear(10 * 12 * 12, 50) 

另外,使用nn.LazyLinear会将in_features的初始化推迟到第一次前向传递时,自动正确设置它,尽管显式定义有助于理解。

输入(N, 1, 28, 28)nn.Conv2d(1, 10, ks=5)(N, 10, 24, 24)nn.MaxPool2d(2)(N, 10, 12, 12)ReLU展平(N, 101212 = 1440)nn.Linear(1440, 50)(N, 50)输出(N, 50)

张量形状通过一个简单CNN的流程,显示了线性层之前的展平步骤。N 代表批次大小。

调试形状不匹配通常感觉像侦探工作。通过系统地检查每一步的张量维度,弄清层要求,并仔细阅读错误信息,您可以有效处理这些常见问题,并确保您的模型架构实现正确。

检查设备放置 (CPU/GPU)

在 PyTorch 中使用 GPU 时,最常见的运行时错误之一源于尝试在位于不同设备(CPU 与 GPU)上的张量或模块之间执行操作。PyTorch 要求操作中涉及的张量以及执行操作的模型位于同一设备上。未能确保这种一致性会导致明确的错误,并停止执行。识别和纠正这些设备放置问题是主要关注点。

理解设备专属性

PyTorch 张量和模型参数有特定的关联设备:可以是 CPU 或特定的 GPU。默认情况下,张量是在 CPU 上创建的。为了使用 GPU 提供的加速,您必须将模型和数据都明确地移动到 GPU 上。

操作通常要求所有参与的张量都在同一设备上。例如,您不能直接将位于 CPU 上的张量与位于 GPU 上的张量相加。同样,位于 GPU 上的模型层(其中包含参数,参数本身也是张量)不能直接处理仍位于 CPU 上的输入张量。

识别设备不匹配

此问题最常见的表现是 RuntimeError,通常带有类似以下的消息:

RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cpu and cuda:0! (when checking argument for argument mat1 in method wrapper_addmm)

此错误消息很有说明性。它告诉您:

  1. 存在设备不匹配。
  2. 它识别出涉及的设备(例如 cpucuda:0,它表示第一个 GPU)。
  3. 它通常指向发生不匹配的具体操作(例如 addmm,它用于线性层)。

这通常发生在模型的正向传播或计算损失时,因为在这些地方模型参数直接与输入数据或标签进行交互。

检查张量和模型的设备

为了调试这些错误,您首先需要确定您的张量和模型参数位于何处。

检查张量的设备: 每个张量都有一个 .device 属性,它告诉您其当前位置。

import torch

// 在 CPU 上创建的张量(默认)
val cpu_tensor = torch.randn(2, 2)
println(s"cpu_tensor 位于: ${cpu_tensor.device}")

// 检查 GPU 是否可用并移动张量
if torch.cuda.is_available() then
    val gpu_tensor = cpu_tensor.to("cuda")
    println(s"gpu_tensor 位于: ${gpu_tensor.device}")
else:
    println("GPU 不可用,无法创建 gpu_tensor。")

// 输出(如果 GPU 可用):
// cpu_tensor 位于: cpu
// gpu_tensor 位于: cuda:0

检查模型的设备: 使用 torch.nn.Module 定义的模型也需要位于正确的设备上。由于模型由包含参数(参数本身是张量)的层组成,您可以检查任何参数的设备,以推断模型的实际设备。一个常见方法是检查第一个参数的设备:

import torch
import torch.nn as nn

// 定义一个简单模型
class SimpleNet extends nn.Module:
    def __init__(self):
        super().__init__()
        val linear = nn.Linear(10, 5)

    def forward(x: torch.Tensor):
        return linear(x)

//实例化模型(最初在 CPU 上)
val model = SimpleNet()
// 参数最初在 CPU 上
println(s"模型最初位于: ${next(model.parameters()).device}")

// 如果 GPU 可用,将模型移动到 GPU
if torch.cuda.is_available() then
    val device = torch.device("cuda")
    model.to(device)
    println(s"模型已移至: ${next(model.parameters()).device}")
else
    val device = torch.device("cpu")
    println("GPU 不可用,模型仍留在 CPU 上。")

// 输出(如果 GPU 可用):
// 模型最初位于: cpu
// 模型已移至: cuda:0

请注意,model.to(device) 会就地修改模型的参数和缓冲区,如果模型已在目标设备上,否则会返回一个已移动到设备上的模型对象。通常的做法是重新赋值结果,例如 model = model.to(device),尽管不重新赋值地调用 model.to(device) 通常也有效,因为它会修改内部状态。但是,明确的重新赋值更安全、更清晰。

纠正设备放置

一旦您识别出不匹配,解决方案是使用 .to(device) 方法将相关的对象(张量或模型)移动到所需的公共设备上。

建立设备环境: 一种标准做法是在脚本开始时定义一个 device 对象。此对象保存目标设备(如果 GPU 可用则为 GPU,否则为 CPU),并且可以在您的代码中重复使用。

import torch.*

// 在开始时定义设备
val device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
println(s"使用设备: $device")

// ... 定义您的模型、损失函数、优化器 ...

// 确保模型在正确的设备上
val model = SimpleNet().to(device)

// 在您的训练循环中:
// 确保输入数据和标签已移至设备
for inputs, labels <- data_loader:
    inputs = inputs.to(device)
    labels = labels.to(device)

    // 现在,模型和数据都在同一设备上
    optimizer.zero_grad()
    val outputs = model(inputs)
    val loss = criterion(outputs, labels)
    loss.backward()
    optimizer.step()

通过在训练开始持续应用 .to(device) 到您的模型,以及在训练循环(对于每个批次)应用于输入数据,您可以确保所有计算都在目标设备上进行,从而防止设备不匹配错误。

设备错误的调试流程

如果您遇到指示设备不匹配的 RuntimeError

  1. 识别出错的行: 查看堆栈跟踪以精确找出导致错误的具体操作。
  2. 检查操作数: 就在出错行之前,插入打印语句或使用调试器检查该操作中涉及的所有张量和模型参数的 .device 属性。例如,如果错误发生在 outputs = model(inputs) 期间,检查 inputs.devicenext(model.parameters()).device。如果错误发生在 loss = criterion(outputs, labels) 期间,检查 outputs.devicelabels.device
  3. 应用 .to(device) 确保任何被识别为位于错误设备上的张量或模型都使用 .to(device) 明确移动到出错操作发生之前。请记住在数据加载循环内移动输入和标签。

检查和管理设备放置是编写 PyTorch 代码的基本方面,特别是在使用 GPU 进行加速时。采纳尽早定义 device 对象并相应地持续移动模型和数据的做法将帮助您避免许多常见的运行时错误。

检查梯度问题(消失/爆炸)

有效的训练依赖于反向传播期间计算的梯度。这些梯度引导优化器更新模型参数以最小化损失函数。然而,这些梯度的大小有时会成为问题,导致训练不稳定或停滞。两个常见问题是梯度消失和梯度爆炸。了解如何检查梯度是诊断训练中出现的问题的一项重要技能。

理解梯度问题

在反向传播过程中,梯度使用链式法则逐层计算。在深度网络中,这涉及将许多小数字(导数)相乘。

  • 梯度消失: 当梯度从输出层向初始层反向传播时变得极其小,就会发生这种情况。结果是,初始层的权重和偏置更新得非常缓慢,甚至完全不更新。网络基本上停止从早期层的数据中学习有意义的特征。这在深度网络中尤其常见,当使用 sigmoid 或 tanh 等激活函数时,这些函数在大多数区域的导数都小于 1。
  • 梯度爆炸: 这是相反的问题,即在反向传播过程中梯度变得过大。大梯度会导致模型权重发生显著更新。这可能导致优化过程变得不稳定,损失剧烈波动甚至变成 NaN(非数字),从而有效地停止训练。梯度爆炸可能由于不佳的权重初始化、过高的学习率或某些网络结构引起,尤其是在循环神经网络中。

在 PyTorch 中检测梯度问题

PyTorch 的 Autograd 系统计算梯度,并将其存储在 requires_grad=True 的张量的 .grad 属性中。这些梯度在 loss.backward() 调用后即可访问,并在 optimizer.step() 更新模型参数或 optimizer.zero_grad() 清除梯度之前保持可用。

监控整体梯度范数

一个常用做法是监控模型中所有可训练参数的梯度整体大小(范数)。L2 范数(欧几里得范数)是常用的一种。非常小的范数表明梯度消失,而非常大或 NaN 的范数则表明梯度爆炸。

以下是在训练循环中计算并记录总梯度范数的方法:

// 在训练循环中,在 loss.backward() 之后

var total_norm = 0.0
for p <- model.parameters():
    if p.grad is not None then
        val param_norm = p.grad.detach().data.norm(2) // 计算此参数梯度的 L2 范数
        total_norm += param_norm.item() ** 2      // 平方和
total_norm = total_norm ** 0.5                   // 平方和的平方根

println(s"总梯度范数: $total_norm")
// 通常,你会使用 TensorBoard 或其他日志框架来记录此值

随时间监控此值可以提供信息:

模型梯度总 L2 范数随训练步数变化的趋势,以对数尺度显示。稳定的训练显示相对一致的范数,梯度爆炸显示快速增加(常导致 NaN),梯度消失则显示趋近于零的下降。

逐层检查梯度

有时,梯度问题可能局限于特定层。你可以直接检查单个参数的梯度。

// 在训练循环中,在 loss.backward() 之后

// 示例:检查第一个卷积层的权重梯度
if hasattr(model, 'conv1') and model.conv1.weight.grad is not None:
    val conv1_grad_mean = model.conv1.weight.grad.abs().mean().item()
    val conv1_grad_max = model.conv1.weight.grad.abs().max().item()
    println(s"层 conv1 - 平均绝对梯度: $conv1_grad_mean, 最大绝对梯度: $conv1_grad_max")

// 示例:检查特定线性层的偏置梯度
if hasattr(model, 'fc2') and model.fc2.bias.grad is not None:
    val fc2_bias_grad_norm = model.fc2.bias.grad.norm(2).item()
    println(s"层 fc2 (偏置) - L2 范数: $fc2_bias_grad_norm")

查看平均或最大绝对梯度值,或特定层的范数,可以帮助确定梯度是在减小还是在不受控制地增长。使用直方图(例如,使用 Matplotlib 或通过 TensorBoard 记录)来可视化某一层的梯度值分布也很有用。

使用钩子进行更细致的检查

为了进行更详细的调试,PyTorch 提供了钩子。可以在任何 nn.Module 上注册一个反向钩子(register_full_backward_hook)。当为该模块计算了梯度时,此钩子会执行,允许你检查甚至修改通过它的梯度(grad_inputgrad_output)。尽管功能强大,但钩子会增加复杂性,通常在简单检查方法不足时使用。

观察损失行为

间接来看,训练损失本身就是一个强有力的指示器。

  • 损失变为 NaN 几乎总是梯度爆炸或数学上无效操作(如 log(0))的迹象。
  • 损失下降极其缓慢或过早停滞: 可能是梯度消失的症状,特别是如果涉及初始层。
  • 损失剧烈波动: 可能表示梯度爆炸或学习率过高。

缓解的初步措施

检测梯度问题是第一步。解决这些问题通常涉及其他地方更详细介绍的技术,但常见策略包括:

  • 梯度裁剪: 对于梯度爆炸,在优化器步骤之前限制梯度的最大范数或值。torch.nn.utils.clip_grad_norm_torch.nn.utils.clip_grad_value_ 是标准实用工具。
  • 激活函数: 在深度网络中用 ReLU 或其变体(Leaky ReLU, PReLU, ELU)替换 sigmoid/tanh,这些函数通常具有问题较少的导数特性。
  • 权重初始化: 使用旨在维持各层方差的初始化方案,例如 Xavier/Glorot 或 He 初始化。
  • 批量归一化: 通过归一化层输入,有助于稳定学习并可以缓解梯度消失/爆炸问题。
  • 网络架构: 使用跳跃连接或残差连接(如 ResNets 中)为梯度流动提供替代路径,从而在非常深的神经网络中对抗梯度消失。
  • 学习率调整: 降低学习率有时可以帮助解决梯度爆炸问题,尽管它可能无法解决根本原因。

模型工作后,你不一定需要在每次训练运行时都检查梯度,但当训练不稳定或无效时,它是一个必不可少的诊断工具。通过监控梯度范数和检查单个层的梯度,你可以获得关于训练动态的有价值信息,并发现潜在的梯度消失或梯度爆炸问题。

使用 TensorBoard 可视化训练进度

虽然打印语句和手动记录可以提供损失或准确率等指标的快照,但它们往往无法清晰地展示整个训练过程中的趋势和变化。损失是在持续下降,还是在剧烈波动?验证准确率是否停滞不前?使用可视化工具回答这些问题会容易得多。TensorBoard 是一个功能强大的可视化工具包,最初为 TensorFlow 开发,它通过 torch.utils.tensorboard 模块与 PyTorch 顺畅结合。它能让您在基于网络的仪表板中跟踪和可视化模型训练的各个方面。

设置 SummaryWriter

PyTorch 中用于将数据记录到 TensorBoard 的主要接口是 SummaryWriter 类。您通常会在训练脚本的开头实例化它。

import torch.utils.tensorboard.SummaryWriter
import torch.* // 假设 torch 已被导入

// 创建一个 SummaryWriter 实例
// 这将创建一个类似 'runs/experiment_name' 的目录
// 如果未提供参数,则默认为 'runs/CURRENT_DATETIME_HOSTNAME'
val log_dir = "runs/my_first_experiment"
val writer = new SummaryWriter(log_dir)

println(s"TensorBoard 日志目录: $log_dir")
// 之后可以通过以下命令查看: tensorboard --logdir runs

SummaryWriter 会将事件文件写入指定的 log_dir。TensorBoard 读取这些文件来生成可视化图表。最佳做法是为不同实验(例如,改变超参数)使用不同的目录,以便您可以轻松比较各个运行。

记录标量值

TensorBoard 最常用的场景是记录随时间变化的标量值,例如损失和准确率。这通过 add_scalar 方法完成。

writer.add_scalar(tag, scalar_value, global_step=None)
  • tag (字符串): 标量的名称,例如 ‘Training Loss’ 或 ‘Validation Accuracy’。在标签中使用斜杠(例如,‘Loss/train’, ‘Loss/validation’)有助于在 TensorBoard 用户界面中组织图表。
  • scalar_value (浮点数或整数): 您想记录的值。请注意,这应该是一个 CPU 标量值。如果您的损失在 GPU 上,您需要使用 .item() 将其移至 CPU。
  • global_step (整数): 与此数据点关联的步数,通常表示 epoch 数或批次迭代计数。这决定了图中 x 轴的值。

让我们看看如何将其整合到一个典型的训练和验证循环结构中:

// --- 假设这些已定义: ---
// model: 您的 torch.nn.Module
// train_loader, valid_loader: 您的 DataLoaders
// criterion: 您的损失函数 (例如, nn.CrossEntropyLoss())
// optimizer: 您的优化器 (例如, optim.Adam(model.parameters()))
// num_epochs: 训练的 epoch 数量
// device: torch.device('cuda' if torch.cuda.is_available() else 'cpu')
// ---------------------------------

model.to(device)

for epoch <- 0 until num_epochs:
    model.train() // 设置模型为训练模式
    var running_loss = 0.0
    var total_train_samples = 0
    
    for i <- 0 until train_loader.size:
        val data = train_loader(i)
        val inputs = data(0).to(device)
        val labels = data(1).to(device)

        optimizer.zero_grad()

        val outputs = model(inputs)
        val loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        running_loss += loss.item() // inputs.size(0) // 累加按批次大小加权的损失
        total_train_samples += inputs.size(0)

        // 每 N 次迭代(例如 100 次)记录批次损失
        val log_interval = 100
        if i % log_interval == log_interval - 1:
            val current_step = epoch * train_loader.size + i
            val avg_batch_loss = running_loss / (log_interval * train_loader.batch_size) // 近似区间平均值
            writer.add_scalar('Loss/train_batch', avg_batch_loss, current_step)
            // 注意: 这只是一个示例记录方案

    val epoch_loss = running_loss / total_train_samples // 该 epoch 的平均损失
    writer.add_scalar('Loss/train_epoch', epoch_loss, epoch)
    println(f'Epoch {epoch+1}/{num_epochs}, 训练损失: {epoch_loss:.4f}')

    // --- 验证阶段 ---
    model.eval() // 设置模型为评估模式
    var validation_loss = 0.0
    var correct = 0
    var total_val_samples = 0
    with torch.no_grad(): // 禁用梯度计算
        for i <- 0 until valid_loader.size:
            val data = valid_loader(i)
            val inputs = data(0).to(device)
            val labels = data(1).to(device)
            val outputs = model(inputs)
            val loss = criterion(outputs, labels)
            validation_loss += loss.item() // inputs.size(0) // 累加按批次大小加权的损失
            
            val _, predicted = torch.max(outputs.data, 1)
            total_val_samples += labels.size(0)
            correct += (predicted == labels).sum().item()

    val avg_val_loss = validation_loss / total_val_samples // 该 epoch 的平均损失
    val accuracy = 100.0 * correct / total_val_samples // 该 epoch 的准确率
    println(f'Epoch {epoch+1}/{num_epochs}, 验证损失: {avg_val_loss:.4f}, 准确率: {accuracy:.2f}%')

    writer.add_scalar('Loss/validation', avg_val_loss, epoch)
    writer.add_scalar('Accuracy/validation', accuracy, epoch)
    println(f'Epoch {epoch+1}/{num_epochs}, 验证损失: {avg_val_loss:.4f}, 准确率: {accuracy:.2f}%')

// 训练完成后关闭写入器
writer.close()
println("训练完成。TensorBoard 日志已保存。")

在这个例子中:

  1. 我们在训练循环之前实例化了 SummaryWriter
  2. 在训练循环中,我们定期记录每个批次的平均训练损失以及每个 epoch 的平均损失。
  3. 在验证循环中,我们计算并记录每个 epoch 的平均验证损失和准确率。
  4. 我们将 epoch 数字用作 epoch 级别指标的 global_step。对于批次级别指标,我们根据 epoch 和批次索引计算组合步数。
  5. 重要的是,我们在训练结束后调用 writer.close() 以确保所有缓冲数据都写入磁盘。

启动和使用 TensorBoard

您的脚本运行并生成日志文件后,您可以从终端启动 TensorBoard 界面。导航到包含 runs(或自定义日志)目录的父目录并运行:

tensorboard --logdir runs

如果您使用了特定的目录,例如 logs/my_experiment_1,您将使用:

tensorboard --logdir logs/my_experiment_1

TensorBoard 通常会启动一个网络服务器,通常在 http://localhost:6006。在您的网络浏览器中打开此地址。您应该会看到一个仪表板,您可以在其中查看记录的标量、比较不同运行(如果您的 logdir 中有多个子目录),并观察 epoch 或步数的变化趋势。

一个类似于 TensorBoard 可能显示的图表示例,展示了训练损失、验证损失和验证准确率在不同 epoch 上的变化。

关于标量

记录标量是基础操作,但 SummaryWriter 也提供了可视化其他类型数据的方法,这对于更具体的调试情况很有用:

  • add_histogram(tag, values, global_step): 跟踪张量值随时间的变化分布。这对于监控不同层中权重或梯度的分布非常有用,可以帮助诊断梯度消失或梯度爆炸等问题。
  • add_graph(model, input_to_model): 可视化模型的架构。您传入 nn.Module 和一个示例输入张量。TensorBoard 会显示操作图,这有助于验证连接和形状。请注意,动态控制流(例如依赖于张量值的 if 语句)可能无法完全显示。
  • add_image(tag, img_tensor, global_step): 记录图像。在计算机视觉任务中很有用,可以在训练期间查看示例输入、输出或生成的图像。img_tensor 的格式需要仔细处理(例如 CHWNCHW)。
  • add_embedding(mat, metadata, label_img, global_step, tag): 使用 PCA 或 t-SNE 等技术将高维嵌入(如词嵌入或图像特征)可视化到较低维空间。

对于中级课程,掌握 add_scalar 是最重要的一步。随着遇到更复杂的调试难题,试用 add_histogram 用于权重/梯度以及 add_graph 用于模型结构是很好的后续步骤。

使用 TensorBoard 将调试从解读一串数字转变为分析视觉趋势。它能帮助了解收敛速度、潜在的过拟合(比较训练损失与验证损失),以及学习过程的稳定性,使其成为实际深度学习开发中必不可少的工具。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐