🏠 总目录📚 本教程 15 · PyTorch 实战手册 ← →
📑 本页目录(点开跳转)

15 / PyTorch 实战 · 边做边查

先跑通一次训练,
再按需要查写法。

这是一份实战手册,不是需要顺序背诵的 API 列表。先确认输入、输出和你要检查的结果。

训练循环只记三件事

预测与算损失,
反传与更新,独立验证。

每一步都有可观察的结果;不靠“没有报错”判断训练成功。

  1. 1看输入与输出

    维度、类型、设备一致;损失是有限数。

  2. 2检查是否在学习

    先用极小训练集检查,再正式训练。

  3. 3用验证集做选择

    保留最佳权重,最后才报告测试结果。

第一次接触

我想先看一个能跑的小例子

从张量形状与一次自动求导开始。只需要 PyTorch,不需要 GPU。

去张量与求导示例 →

已经会基本写法

我需要完整训练模板

乳腺癌二分类示例:数据划分、标准化、模型、训练和最佳权重恢复。依赖 NumPy、scikit-learn 和 PyTorch。

去模板与运行说明 →

正在排查问题

我的训练结果不对

先看损失配对与六类常见问题。训练模式、梯度记录和梯度清零是不同的事。

去问题对照表 →

可以停在这里

能解释一次训练迭代每步在做什么,就有了继续用模板的基础。提速技巧等跑通后再看。

打开正文,从当前需要的部分读起 →
继续探索

原有正文、图解和例子都在下方。按需跳转,不必一次读完。

15 · PyTorch 实战手册

⏱ 34 分钟 | ⭐ 可直接抄的模板 | 🔨 有完整代码


🎯 一句话

PyTorch 只需要理解四个东西:张量、模块、数据加载器、训练循环。 这一章给你小例子、配对表和一份带数据与模型准备的二分类训练模板。先跑适用的例子,再按自己的任务修改,不必背完所有写法。


🧱 一、张量:会自动求导的 numpy

import torch

x = torch.tensor([[1., 2.], [3., 4.]])
print(x.shape, x.dtype, x.device)          # torch.Size([2,2]) torch.float32 cpu

# 和 numpy 几乎一样
x @ x.T          # 矩阵乘
x.sum(dim=0)     # 沿维度求和
x.view(-1)       # view 要求尺寸/步幅兼容;reshape 必要时会复制

# 三个高频操作
x.unsqueeze(1)   # 加一个维度 (2,2) → (2,1,2)
x.squeeze()      # 去掉所有大小为1的维度
torch.cat([x, x], dim=0)   # 拼接

自动求导(第 8 章的框架版):

import torch
w = torch.tensor([2.0], requires_grad=True)
loss = (w * 3 - 6) ** 2
loss.backward()          # 自动算梯度
print(w.grad)            # tensor([0.]) —— w=2 时正好是最优

⚠️ 三个坑: - .item() 返回不可求导的 Python 标量,但不会销毁原张量的计算图 - .detach() 显式切断梯度(想阻断反传时用) - 梯度默认累加 → 独立更新前清零;有意做梯度累积时则按累积周期清零


🏗️ 二、定义模型:两种写法

import torch.nn as nn

# 写法1:Sequential —— 简单直筒结构
model = nn.Sequential(
    nn.Linear(30, 64), nn.ReLU(), nn.Dropout(0.2),
    nn.Linear(64, 32), nn.ReLU(),
    nn.Linear(32, 1),               # ⭐ 二分类不加 Sigmoid
)

# 写法2:继承 Module —— 需要控制前向逻辑时
class Net(nn.Module):
    def __init__(self, in_dim, n_class):
        super().__init__()
        self.backbone = nn.Sequential(nn.Linear(in_dim,64), nn.ReLU())
        self.head = nn.Linear(64, n_class)
    def forward(self, x):
        h = self.backbone(x)
        return self.head(h)          # 可以在这加残差、多分支等

损失函数配对表(⭐ 最高频的错误来源):

接口依据:CrossEntropyLoss 官方说明;求导与模式区别见 Autograd 官方说明。

任务 损失函数 输出层 注意
二分类 BCEWithLogitsLoss 1 个数,不加 Sigmoid 内部含 sigmoid,数值更稳
多分类 CrossEntropyLoss C 个数,不加 Softmax 内部含 log_softmax;常规标签用整数索引,也支持与输出同形的浮点概率分布
回归 MSELoss / L1Loss 1 个数,不加激活 有离群值用 L1 或 SmoothL1

📦 三、数据加载

先用小张量演示内存数据。自定义 Dataset 的例子采用 NumPy 文件,每个文件存一条样本;使用时传入自己的文件路径。

from torch.utils.data import TensorDataset, DataLoader, Dataset
import torch
import numpy as np

# 简单情况:数据已在内存
X_tensor = torch.tensor([[1., 2.], [3., 4.], [5., 6.], [7., 8.]])
y_tensor = torch.tensor([[0.], [0.], [1.], [1.]])
ds = TensorDataset(X_tensor, y_tensor)
loader = DataLoader(ds, batch_size=32, shuffle=True,
                    num_workers=0)   # 先跑通,再按瓶颈调整
print(next(iter(loader))[0].shape)    # torch.Size([4, 2])

# 自定义 Dataset:数据要现读现处理时
class MyDataset(Dataset):
    def __init__(self, paths, labels, transform=None):
        self.paths, self.labels, self.transform = paths, labels, transform
    def __len__(self):
        return len(self.paths)
    def __getitem__(self, i):
        x = torch.as_tensor(np.load(self.paths[i]), dtype=torch.float32)
        if self.transform: x = self.transform(x)
        return x, self.labels[i]

💡 GPU 利用率忽高忽低,可能来自加载瓶颈,也可能来自同步或小模型。先定位,再比较 num_workers=0/2/4。 CPU 到 CUDA 传输可测试 pin_memory=True。Windows 多进程加载要正确使用主入口保护;小数据增加进程反而可能更慢。


🔁 四、完整训练模板(包含数据与模型准备)

运行条件:已安装 NumPy、scikit-learn 与 PyTorch;使用库自带数据,无需下载数据、无需 GPU。先划分训练/验证/测试,再只用训练集拟合标准化器。

本次校验范围:已做语法检查;当前预览环境没有 PyTorch 和 scikit-learn,未重跑此训练。下方历史日志不作为补全后模板逐项一致的承诺。

import numpy as np, torch, torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(
    X_train, y_train, test_size=0.25, stratify=y_train, random_state=42)
scaler = StandardScaler().fit(X_train)
Xtr, Xva, Xte = [torch.tensor(scaler.transform(a), dtype=torch.float32)
                 for a in (X_train, X_val, X_test)]
ytr, yva, yte = [torch.tensor(a, dtype=torch.float32).reshape(-1, 1)
                 for a in (y_train, y_val, y_test)]
loader = DataLoader(TensorDataset(Xtr, ytr), batch_size=32,
                    shuffle=True, num_workers=0)

def make_model():
    return nn.Sequential(nn.Linear(30, 64), nn.ReLU(),
                         nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1))

def set_seed(s=42):
    import random
    random.seed(s); np.random.seed(s); torch.manual_seed(s)
    torch.cuda.manual_seed_all(s)
set_seed()

device = "cuda" if torch.cuda.is_available() else "cpu"
model = make_model().to(device)
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60)

# ========== 第0步:先证明能过拟合 10 个样本(第11章)⭐ ==========
tx, ty = Xtr[:10].to(device), ytr[:10].to(device)
model.train()
for step in range(301):
    optimizer.zero_grad()
    loss = criterion(model(tx), ty)
    loss.backward(); optimizer.step()
print(f"10样本过拟合检查: loss={loss.item():.5f} "
      f"{'小样本拟合通过' if loss.item()<0.01 else '待排查:容量、学习率、标签与训练步数'}")

# ========== 正式训练 + 早停 ==========
set_seed(); model = make_model().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60)

best, wait, patience, best_state = float("inf"), 0, 10, None
for ep in range(60):
    # --- 训练 ---
    model.train()                     # ⭐ 开启 Dropout/BN 训练模式
    total = 0.0
    for xb, yb in loader:
        xb, yb = xb.to(device), yb.to(device)
        optimizer.zero_grad()                                   # ⭐ 必须
        loss = criterion(model(xb), yb)
        loss.backward()
        gn = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0).item()
        optimizer.step()
        total += loss.item() * len(xb)
    scheduler.step()

    # --- 验证 ---
    model.eval()                      # ⭐ 关闭 Dropout,BN 用running统计
    with torch.no_grad():             # ⭐ 不建计算图,省显存加速
        val_loss = criterion(model(Xva.to(device)), yva.to(device)).item()

    print(f"ep {ep:<3} train {total/len(Xtr):.4f} | val {val_loss:.4f} "
          f"| lr {optimizer.param_groups[0]['lr']:.2e} | grad {gn:.2f}")   # 第11章六个数字

    # --- 早停 ---
    if val_loss < best:
        best, wait = val_loss, 0
        best_state = {k: v.clone() for k, v in model.state_dict().items()}
    else:
        wait += 1
        if wait >= patience:
            # 跳出循环后统一恢复最佳权重
            print(f"早停于 ep {ep}"); break

# 早停或跑满轮数,都恢复验证损失最小的一份权重
model.load_state_dict(best_state)
model.eval()
with torch.no_grad():
    test_logits = model(Xte.to(device))
    test_acc = ((test_logits > 0) == yte.to(device)).float().mean().item()
print(f"恢复最佳权重后的测试准确率:{test_acc:.1%}")

原文保留的历史日志(乳腺癌;原记录未完整保留模型与划分配置,不要求上面的模板输出相同数字):

历史小样本检查:10 个样本,loss=0.00000,记录为通过。这里只保留原文观察值。

历史训练日志:比较训练和验证趋势
轮数训练损失验证损失验证准确率学习率末批梯度范数
00.66160.597084.8%9.99e-040.47
100.06770.081496.5%9.19e-040.38
300.03400.068496.5%4.74e-041.11
500.03240.067496.5%5.45e-050.03

原记录早停于第 51 轮、恢复最佳权重;最终测试准确率 96.5%。这不是补全后模板的新实测结果。

💡 注意 train 一直在降而 val 在 ep 40 后基本不动 —— 早停正是在这时候起作用。


🚨 五、六个高频 Bug

Bug 症状 修
无意中漏掉 optimizer.zero_grad() 梯度跨步累加,更新偏离预期 独立更新前清零;有意累积则按周期清零
忘了 model.eval() Dropout 仍随机丢弃,BN 可能使用/更新批统计 验证前切换 eval;它不自动关闭梯度记录
验证时仍记录梯度 可能增加内存占用和开销 按需使用 no_grad 或 inference mode
输出层多加了 Sigmoid/Softmax loss 下降异常缓慢 用 WithLogits 版损失,删掉激活
忘了 .to(device) 报错 "expected all tensors on same device" 模型和数据都要 to
CrossEntropyLoss 标签类型/形状不匹配 可能报错或算错任务 常规用整数索引;概率标签须为同形浮点分布,各类和为 1

⚡ 六、提速三件套

下面是接入已有训练循环的写法片段,不是独立训练脚本。先定位瓶颈,再比较速度、显存与稳定性。梯度累积还需处理最后不足一个周期的批次,不能丢掉它们的梯度。

# 1. CUDA 混合精度:收益取决于硬件和算子,不保证显存减半
import torch
scaler = torch.amp.GradScaler()
with torch.amp.autocast("cuda"):
    loss = criterion(model(xb), yb)
scaler.scale(loss).backward()
scaler.step(optimizer); scaler.update()

# 2. 梯度累积(小显存模拟大 batch)
loss = criterion(model(xb), yb) / accum
loss.backward()
if (i+1) % accum == 0:
    optimizer.step(); optimizer.zero_grad()

# 3. 编译加速(PyTorch 2.0+)
model = torch.compile(model)

🔗 这一章连到哪里

去哪 为什么
AI基础设施 09 ⭐ 撞上 OOM 之后的下一步:梯度检查点、offload、累加,各省多少各花多少
AI基础设施 07 ⚠️ torch.compile 为什么快,以及随手加的 .item() / print 会怎么把图切断
Kaggle 02 同一份模板在竞赛里的加料版:混合精度、EMA、梯度累加

✅ 检查点

  1. .item() 和 .detach() 的区别?为什么每步要 zero_grad()?
  2. 二分类的输出层该不该加 Sigmoid?为什么?
  3. model.train() 和 model.eval() 影响哪些层?
  4. torch.no_grad() 的作用?
  5. GPU 利用率忽高忽低怎么办?
👀 答案
  1. .item() 返回不可求导的 Python 标量,原张量的图仍在;.detach() 返回脱离求导历史的张量。独立更新前清零,有意累积时按周期清零。
  2. 不加。用 BCEWithLogitsLoss,它内部合并了 sigmoid + 交叉熵,数值更稳定(避免 log(0))。
  3. Dropout(train 时随机丢,eval 时全保留)和 BatchNorm(train 用当前 batch 统计,eval 用累积的 running 统计)。
  4. no_grad 关闭区域内的反向梯度记录,常用于验证推理以降低开销;不等于 eval,需要输入梯度的任务不能盲目关闭求导。
  5. 先判断是否是数据加载瓶颈,再比较进程数、预处理与传输设置;利用率低并不总靠增加 workers 解决。

🛑 可以停在这里

⚡ 走神救援

张量承载数据,模块定义运算,DataLoader 提供批次,训练循环负责更新。

  • 二分类用 BCEWithLogitsLoss 时,输出层不要另加 Sigmoid;多分类 CrossEntropyLoss 直接接原始分数。
  • 先用少量样本检查能否拟合;拟合不佳是排查线索,不是单凭 loss 阈值证明有 bug。
  • 独立更新前清梯度,训练/验证切换模式,普通验证不记录梯度。
  • 用验证集选最佳权重,再报告测试成绩;六类问题与三种提速写法都在上方,按需回查。

下一节 👉 16-特征工程基础.md

打卡记录保存在你的浏览器里,首页能看到总进度