跳到主要内容
SUN / 笔记
← 全部文章

PyTorch:张量与自动微分

1. 张量是训练系统中的共同数据结构

torch.Tensor 同时承载数值、形状、数据类型、设备和梯度信息。排查训练错误时,不要只看数值,至少同时确认:

python
print(x.shape, x.dtype, x.device, x.requires_grad)

常见创建方式:

python
import torch

a = torch.tensor([1, 2, 3], dtype=torch.float32)
b = torch.zeros((2, 3))
c = torch.ones_like(b)
d = torch.rand((2, 3))       # [0, 1) 均匀分布
e = torch.randn((2, 3))      # 标准正态分布
f = torch.arange(0, 10, 2)
g = torch.linspace(0, 1, steps=5)

数据类型要与任务匹配

  • 模型输入通常是浮点张量,如 float32。
  • 多分类标签通常是 long,与 CrossEntropyLoss 匹配。
  • 掩码通常是 bool。
  • 不要依赖隐式类型转换;出现损失函数或索引错误时先检查 dtype。

2. 形状操作

python
x = torch.randn(3, 4, 5)

x.shape               # torch.Size([3, 4, 5])
x.reshape(12, 5)      # 允许在必要时复制
x.view(12, 5)         # 要求底层存储布局兼容
x.unsqueeze(0)        # (1, 3, 4, 5)
x.squeeze(0)          # 只删除指定的大小为 1 的维度
x.flatten(start_dim=1)
x.permute(0, 2, 1)

实践中优先显式指定 squeeze(dim),避免把 batch 维一并删掉。view() 报连续性错误时,可改用 reshape(),或者先调用 contiguous(),但要理解这可能产生复制。

广播

从尾部维度向前比较,两个维度相等,或其中一个为 1,才能广播:

python
a = torch.tensor([1, 2, 3])          # (3,)
b = torch.tensor([[4], [5], [6]])    # (3, 1)
c = a + b                            # (3, 3)

广播不会真的把输入复制成完整矩阵,但可能让错误形状“看起来能运行”。损失异常时,要确认广播是否符合语义。

3. 设备迁移

python
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
x = x.to(device)
y = y.to(device)

模型参数和参与同一次运算的张量必须位于同一设备。启用 DataLoader(pin_memory=True) 后,可配合:

python
x = x.to(device, non_blocking=True)

non_blocking=True 是否真正异步,取决于源内存、设备和后续同步点,不能仅凭参数名认定已经提速。

4. 自动微分的核心链条

flowchart LR
    X[叶子张量<br/>requires_grad=True] --> O[前向运算]
    O --> L[标量 loss]
    L --> B[loss.backward]
    B --> G[梯度累积到 parameter.grad]
    G --> S[optimizer.step]

一个最小例子:

python
x = torch.tensor([2.0, 3.0], requires_grad=True)
y = x**2 + 3 * x
loss = y.sum()
loss.backward()

print(x.grad)  # 2*x + 3 -> tensor([7., 9.])

backward() 为什么常从标量开始

标量损失对参数求导会自然得到一组梯度。非标量张量也能调用 backward(),但需要传入同形状的上游梯度,本质是计算向量-雅可比积:

python
y.backward(torch.ones_like(y))

梯度默认累积

每次反向传播都会把新梯度加到 .grad:

python
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()

set_to_none=True 通常更省内存,但要注意:没有收到梯度的参数保持 grad is None,它与全零梯度的优化器行为并不完全相同。

5. detach()、no_grad()、inference_mode() 与 eval()

工具 影响范围 典型用途
tensor.detach() 返回与原张量共享存储、但脱离当前计算图的张量 截断某一条梯度路径
torch.no_grad() 上下文内不记录反向图 验证、推理或局部无梯度计算
torch.inference_mode() 比 no_grad() 更严格,还关闭部分视图追踪和版本计数 纯评估和部署推理
model.eval() 切换 Dropout、BatchNorm 等模块的运行行为 验证与推理前切换模型模式

它们不是替代关系。评估通常同时需要:

python
model.eval()
with torch.inference_mode():
    logits = model(x)

detach() 的正确认识

python
x = torch.tensor(2.0, requires_grad=True)
y = x**2
z = y.detach()

print(z.requires_grad)  # False

此时直接 z.backward() 会失败,因为 z 已经没有原计算图的 grad_fn。如果希望“从这里开始建立一张新图”,可以显式使用:

python
z = y.detach().clone().requires_grad_(True)

但新图对 z 求出的梯度不会回传给原来的 x。

冻结参数不等于分离某个输出

迁移学习中的冻结应直接设置参数:

python
for parameter in backbone.parameters():
    parameter.requires_grad_(False)

冻结后再创建优化器更清晰:

python
optimizer = torch.optim.AdamW(
    (p for p in model.parameters() if p.requires_grad),
    lr=1e-3,
)

6. item() 的边界

loss.item() 把单元素张量转成 Python 数字,适合日志记录:

python
running_loss += loss.item() * batch_size

它会脱离计算图,并且 GPU 张量取值通常需要主机与设备同步。因此不要在高频内部循环中无目的地反复调用,也不要把它描述成一种通用的“释放显存”手段。

7. 梯度裁剪

RNN 等模型出现梯度爆炸时,可以在 backward() 之后、step() 之前裁剪:

python
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()

梯度裁剪是稳定训练的保护措施,不会替代合适的初始化、学习率和模型结构。

8. 快速排错清单

  • loss 是否为有限值:torch.isfinite(loss)。
  • 输入、标签、模型是否在同一设备。
  • 标签类型和范围是否匹配损失函数。
  • 输出是否是 logits;使用 CrossEntropyLoss 时不要先手动 softmax()。
  • model.train() / model.eval() 是否在正确阶段调用。
  • 是否忘记清梯度,或错误地在清梯度前读取梯度。
  • 是否误用 detach() 截断了本应保留的梯度路径。
  • 广播是否掩盖了错误形状。

来源映射

本笔记主要归纳自:

  • 第二周.md
  • 标准训练过程.md
  • d2l_Study.ipynb
  • LeNet_MNIST.ipynb

API 行为参考:

相关文章

CNN:LeNet 与 ResNet →PyTorch 训练流程与验证 →