图像分类 本节摘要:分类器就是一个「从像素到类别概率分布」的函数,其余都是管道工程。本节在 CIFAR-10 上手工搭起端到端的分类流水线——数据集、增强、模型、训练循环、评估——让每个环节都可检查。我们会拆解交叉熵、logits、softmax 的关系(为什么在损失前自己 softmax 几乎一定是 bug),讲清增强为何有效、mixup/cutmix/标签平滑各自在什么时候值得加,以及为什么「90% 准确率」这种聚合指标会掩盖数据不平衡,真正能告诉你模型在哪儿失败的是混淆矩阵和每类精确率/召回率。读完本节,你能在不靠 帮忙(那会藏 bug)的前提下,把一条会跑到 90% 的训练循环写出来。 对应原课程:Phase 4 · Lesson 04 · (原英文 )。
本节摘要:分类器就是一个「从像素到类别概率分布」的函数,其余都是管道工程。本节在 CIFAR-10 上手工搭起端到端的分类流水线——数据集、增强、模型、训练循环、评估——让每个环节都可检查。我们会拆解交叉熵、logits、softmax 的关系(为什么在损失前自己 softmax 几乎一定是 bug),讲清增强为何有效、mixup/cutmix/标签平滑各自在什么时候值得加,以及为什么「90% 准确率」这种聚合指标会掩盖数据不平衡,真正能告诉你模型在哪儿失败的是混淆矩阵和每类精确率/召回率。读完本节,你能在不靠
torchvision.datasets帮忙(那会藏 bug)的前提下,把一条会跑到 90% 的训练循环写出来。
对应原课程:Phase 4 · Lesson 04 ·
image-classification(原英文phases/04-computer-vision/04-image-classification/docs/en.md)。
阅读完本节,你应当能够:
每一个交付的视觉任务,在某个层面都归约为图像分类:检测是对区域分类,分割是对像素分类,检索是按与类别中心的相似度排序。把分类做对——数据集循环、增强策略、损失、评估——是会迁移到本章所有其他任务的技能。
多数分类 bug 不在模型里,而在流水线里:归一化写错、训练集没 shuffle、增强扭曲了标签、验证集被训练数据污染、学习率在第 30 个 epoch 悄悄发散。一个本可在正确配置下打到 CIFAR-10 93% 的 CNN,在坏配置下常常只有 70-75%,而损失曲线全程看起来都很合理。
本节把整条流水线手工接起来,让每个零件都可检查。你不会用任何能藏 bug 的 torchvision.datasets 工具。
这条回路里每一行都可能藏 bug。交叉熵要的是原始 logits,不是 softmax 输出,所以在损失前任何 model(x).softmax() 都会悄悄算出错误的梯度。增强只作用于输入,不作用于标签——除了 mixup,它两者都混。optimizer.zero_grad() 必须每步一次;漏掉它会累加梯度,看起来就像一个剧烈不稳定的学习率。这些 bug 每一个都把学习曲线压扁,且不报错。
分类器每张图像产出 C 个数,叫 logits。套上 softmax 就把它们变成概率分布:
softmax(z)_i = exp(z_i) / sum_j exp(z_j)
交叉熵衡量的是「正确类别的负对数概率」:
CE(z, y) = -log( softmax(z)_y ) = -z_y + log( sum_j exp(z_j) )
右边那个形式是数值稳定的(log-sum-exp)。PyTorch 的 nn.CrossEntropyLoss 把 softmax + NLL 融合在一次运算里,直接吃原始 logits。自己先 softmax 几乎总是 bug——你算的是 log(softmax(softmax(z))),一个无意义的量。
CNN 对平移有归纳偏置(来自权重共享),但对裁剪、翻转、颜色抖动、遮挡没有天生不变性。教它这些不变性的唯一办法,是给它看用上这些变换的像素。训练时的每一次随机变换,都是在说:「这两张图标签相同;学会忽略差异的那些特征。」
原始裁剪: "朝左的狗" 翻转: "朝右的狗" <- 同标签,不同像素 旋转(+15): "略微歪头的狗" 颜色抖动: "暖光下的狗" 随机擦除: "缺了一块的狗"
规则:增强必须保留标签。在数字上做 cutout 和旋转,可能把「6」翻成「9」;对这种数据集,你要用更小的旋转范围,挑选尊重数字特有不变性的增强。
普通增强改像素但保留 one-hot 标签。Mixup 和 cutmix 打破这点,把输入和标签都插值。
Mixup: lambda ~ Beta(a, a) x = lambda * x_i + (1 - lambda) * x_j y = lambda * y_i + (1 - lambda) * y_j Cutmix: 把 x_j 的一个随机矩形贴到 x_i 上 y = y_i 和 y_j 的按面积加权混合
为什么有用:模型不再死记尖锐的 one-hot 目标,而学会在类别之间插值。训练损失上升,测试准确率也上升。这是任何分类器最便宜的鲁棒性升级。
mixup 的表亲。不再用 [0, 0, 1, 0, 0] 训练,而是用 [eps/C, eps/C, 1-eps, eps/C, eps/C],eps 取 0.1 之类的小值。它阻止模型产出任意尖锐的 logits,几乎零成本地改善校准。从 PyTorch 1.10 起,内建在 nn.CrossEntropyLoss(label_smoothing=0.1) 里。
聚合准确率掩盖不平衡。一个 90-10 的二分类器,只要永远预测多数类就有 90%。真正能告诉你发生了什么的工具:
CIFAR-10 在磁盘上。为让本节可复现且快,我们造一个像 CIFAR 的合成数据集——32×32 RGB 图,带类别特有的结构让模型去学。同一条流水线用在真实 CIFAR-10 上无需改动。
import numpy as np import torch from torch.utils.data import Dataset def synthetic_cifar(num_per_class=1000, num_classes=10, seed=0): rng = np.random.default_rng(seed) X = [] Y = [] for c in range(num_classes): centre = rng.uniform(0, 1, (3,)) freq = 2 + c for _ in range(num_per_class): yy, xx = np.meshgrid(np.linspace(0, 1, 32), np.linspace(0, 1, 32), indexing="ij") r = np.sin(xx * freq) * 0.5 + centre[0] g = np.cos(yy * freq) * 0.5 + centre[1] b = (xx + yy) * 0.5 * centre[2] img = np.stack([r, g, b], axis=-1) img += rng.normal(0, 0.08, img.shape) img = np.clip(img, 0, 1) X.append(img.astype(np.float32)) Y.append(c) X = np.stack(X) Y = np.array(Y) idx = rng.permutation(len(X)) return X[idx], Y[idx] class ArrayDataset(Dataset): def __init__(self, X, Y, transform=None): self.X = X self.Y = Y self.transform = transform def __len__(self): return len(self.X) def __getitem__(self, i): img = self.X[i] if self.transform is not None: img = self.transform(img) img = torch.from_numpy(img).permute(2, 0, 1) return img, int(self.Y[i])
每个类有自己的色彩调色板和频率模式,再加高斯噪声逼模型学信号而非死记像素。十个类,每类一千张,打乱顺序。
每个视觉流水线都有的两种变换。
def standardize(mean, std): mean = np.array(mean, dtype=np.float32) std = np.array(std, dtype=np.float32) def _fn(img): return (img - mean) / std return _fn def random_hflip(p=0.5): def _fn(img): if np.random.random() < p: return img[:, ::-1, :].copy() return img return _fn def random_crop(pad=4): def _fn(img): h, w = img.shape[:2] padded = np.pad(img, ((pad, pad), (pad, pad), (0, 0)), mode="reflect") y = np.random.randint(0, 2 * pad) x = np.random.randint(0, 2 * pad) return padded[y:y + h, x:x + w, :] return _fn def compose(*fns): def _fn(img): for fn in fns: img = fn(img) return img return _fn
裁剪前用 reflect 填充,不用 zero 填充,因为黑边是模型会以无益方式学着忽略的信号。
在训练步里混合两张图和两个标签。实现成批变换,让它紧贴前向,而不是埋在数据集里。
def mixup_batch(x, y, num_classes, alpha=0.2): if alpha <= 0: return x, torch.nn.functional.one_hot(y, num_classes).float() lam = float(np.random.beta(alpha, alpha)) idx = torch.randperm(x.size(0), device=x.device) x_mixed = lam * x + (1 - lam) * x[idx] y_onehot = torch.nn.functional.one_hot(y, num_classes).float() y_mixed = lam * y_onehot + (1 - lam) * y_onehot[idx] return x_mixed, y_mixed def soft_cross_entropy(logits, soft_targets): log_probs = torch.log_softmax(logits, dim=-1) return -(soft_targets * log_probs).sum(dim=-1).mean()
soft_cross_entropy 是针对软标签分布的交叉熵。当目标恰好 one-hot 时,它退化成通常的 one-hot 情形。
完整配方:过一遍数据,每批一次梯度,每个 epoch 调度器 step 一次。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torch.optim import SGD from torch.optim.lr_scheduler import CosineAnnealingLR def train_one_epoch(model, loader, optimizer, device, num_classes, use_mixup=True): model.train() total, correct, loss_sum = 0, 0, 0.0 for x, y in loader: x, y = x.to(device), y.to(device) if use_mixup: x_m, y_soft = mixup_batch(x, y, num_classes) logits = model(x_m) loss = soft_cross_entropy(logits, y_soft) else: logits = model(x) loss = nn.functional.cross_entropy(logits, y, label_smoothing=0.1) optimizer.zero_grad() loss.backward() optimizer.step() loss_sum += loss.item() * x.size(0) total += x.size(0) # 开启 mixup 时,针对未混合标签 y 的训练准确率只是近似 # (模型看到的是软标签而非 y)。把它当粗略进度信号; # 真实性能以验证准确率为准。 with torch.no_grad(): pred = logits.argmax(dim=-1) correct += (pred == y).sum().item() return loss_sum / total, correct / total @torch.no_grad() def evaluate(model, loader, device, num_classes): model.eval() total, correct = 0, 0 loss_sum = 0.0 cm = torch.zeros(num_classes, num_classes, dtype=torch.long) for x, y in loader: x, y = x.to(device), y.to(device) logits = model(x) loss = nn.functional.cross_entropy(logits, y) pred = logits.argmax(dim=-1) for t, p in zip(y.cpu(), pred.cpu()): cm[t, p] += 1 loss_sum += loss.item() * x.size(0) total += x.size(0) correct += (pred == y).sum().item() return loss_sum / total, correct / total, cm
每次写训练循环都要检查的五条不变量:
model.train(),评估前 model.eval()——切换 dropout 和 batchnorm 行为。.backward() 前 .zero_grad()。.item(),别让计算图一直留着。@torch.no_grad()——省内存和时间,防微妙事故。用上一节的 TinyResNet,训几个 epoch,评估。
from main import synthetic_cifar, ArrayDataset from main import standardize, random_hflip, random_crop, compose from main import mixup_batch, soft_cross_entropy from main import train_one_epoch, evaluate # TinyResNet 来自上一节 (03-cnns-lenet-to-resnet)。 # 按你存放上一节代码的实际位置调整导入路径。 from cnns_lenet_to_resnet import TinyResNet # 示例占位 X, Y = synthetic_cifar(num_per_class=500) split = int(0.9 * len(X)) X_train, Y_train = X[:split], Y[:split] X_val, Y_val = X[split:], Y[split:] mean = [0.5, 0.5, 0.5] std = [0.25, 0.25, 0.25] train_tf = compose(random_hflip(), random_crop(pad=4), standardize(mean, std)) eval_tf = standardize(mean, std) train_ds = ArrayDataset(X_train, Y_train, transform=train_tf) val_ds = ArrayDataset(X_val, Y_val, transform=eval_tf) train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=0) val_loader = DataLoader(val_ds, batch_size=256, shuffle=False, num_workers=0) device = "cuda" if torch.cuda.is_available() else "cpu" model = TinyResNet(num_classes=10).to(device) optimizer = SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4, nesterov=True) scheduler = CosineAnnealingLR(optimizer, T_max=10) for epoch in range(10): tr_loss, tr_acc = train_one_epoch(model, train_loader, optimizer, device, 10, use_mixup=True) va_loss, va_acc, _ = evaluate(model, val_loader, device, 10) scheduler.step() print(f"epoch {epoch:2d} lr {scheduler.get_last_lr()[0]:.4f} " f"train {tr_loss:.3f}/{tr_acc:.3f} val {va_loss:.3f}/{va_acc:.3f}")
在合成数据集上,五个 epoch 内就能逼近完美的验证准确率——这正是重点:流水线对了,模型能学会能学的东西。把数据集换成真实 CIFAR-10,同一循环不变地训到约 90%。
光看准确率永远不知道模型在哪失败,混淆矩阵能。
def print_confusion(cm, labels=None): c = cm.shape[0] labels = labels or [str(i) for i in range(c)] print(f"{'':>6}" + "".join(f"{l:>5}" for l in labels)) for i in range(c): row = cm[i].tolist() print(f"{labels[i]:>6}" + "".join(f"{v:>5}" for v in row)) print() tp = cm.diag().float() fp = cm.sum(dim=0).float() - tp fn = cm.sum(dim=1).float() - tp prec = tp / (tp + fp).clamp_min(1) rec = tp / (tp + fn).clamp_min(1) f1 = 2 * prec * rec / (prec + rec).clamp_min(1e-9) for i in range(c): print(f"{labels[i]:>6} prec {prec[i]:.3f} rec {rec[i]:.3f} f1 {f1[i]:.3f}") _, _, cm = evaluate(model, val_loader, device, 10) print_confusion(cm)
行是真实类,列是预测。类 3 和 5 之间非对角线聚集,意味着模型把这两类搞混——这给了你一个起点,去做定向数据收集或类别特有增强。
torchvision 把上面这一切包成地道组件。对真实 CIFAR-10,整条流水线就是四行加一个训练循环。
from torchvision.datasets import CIFAR10 from torchvision.transforms import Compose, RandomCrop, RandomHorizontalFlip, ToTensor, Normalize mean = (0.4914, 0.4822, 0.4465) std = (0.2470, 0.2435, 0.2616) train_tf = Compose([ RandomCrop(32, padding=4, padding_mode="reflect"), RandomHorizontalFlip(), ToTensor(), Normalize(mean, std), ]) eval_tf = Compose([ToTensor(), Normalize(mean, std)]) train_ds = CIFAR10(root="./data", train=True, download=True, transform=train_tf) val_ds = CIFAR10(root="./data", train=False, download=True, transform=eval_tf)
两件事要注意:这里的 mean/std 是数据集特有的——在 CIFAR-10 训练集上算的,不是 ImageNet 的——而 reflect 填充是社区默认的裁剪策略。在这里复制粘贴 ImageNet 统计量,会悄悄漏掉约 1% 准确率,直到有人给模型做 profiling 才会发现。
本节产出两个可复用文件(位于原课程 outputs/):
prompt-classifier-pipeline-auditor.md:一个提示词——审查一个训练脚本是否满足上面五条不变量,并报告第一处违反。skill-classification-diagnostics.md:一个技能——给定混淆矩阵和类名列表,总结每类失败,并给出最有冲击力的单一修复建议。[eps/C, ..., 1-eps, ..., eps/C],阻止任意尖锐 logits,几乎零成本。下一节,我们把「加载预训练 + 冻结骨干 + 换头」这条迁移学习配方讲透,并讨论何时该全量微调、何时该只训头、何时该冻结中间层。