图像分类


文档摘要

图像分类 本节摘要:分类器就是一个「从像素到类别概率分布」的函数,其余都是管道工程。本节在 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)。

学习目标

阅读完本节,你应当能够:

  1. 在 CIFAR-10 上搭起端到端的图像分类流水线:数据集、增强、模型、训练循环、评估
  2. 解释每个组件(dataloader、损失、优化器、调度器、增强)的作用,并预测弄坏其中任何一个会如何反映在损失曲线上。
  3. 从零实现 mixup、cutout、标签平滑,并说出每种何时值得加。
  4. 读懂混淆矩阵和每类精确率/召回率表,诊断出聚合准确率之外的数据集与模型失败

一、问题与直觉

每一个交付的视觉任务,在某个层面都归约为图像分类:检测是对区域分类,分割是对像素分类,检索是按与类别中心的相似度排序。把分类做对——数据集循环、增强策略、损失、评估——是会迁移到本章所有其他任务的技能。

多数分类 bug 不在模型里,而在流水线里:归一化写错、训练集没 shuffle、增强扭曲了标签、验证集被训练数据污染、学习率在第 30 个 epoch 悄悄发散。一个本可在正确配置下打到 CIFAR-10 93% 的 CNN,在坏配置下常常只有 70-75%,而损失曲线全程看起来都很合理。

本节把整条流水线手工接起来,让每个零件都可检查。你不会用任何能藏 bug 的 torchvision.datasets 工具。

分类流水线

这条回路里每一行都可能藏 bug。交叉熵要的是原始 logits,不是 softmax 输出,所以在损失前任何 model(x).softmax() 都会悄悄算出错误的梯度。增强只作用于输入,不作用于标签——除了 mixup,它两者都混。optimizer.zero_grad() 必须每步一次;漏掉它会累加梯度,看起来就像一个剧烈不稳定的学习率。这些 bug 每一个都把学习曲线压扁,且不报错。

交叉熵、logits 与 softmax

分类器每张图像产出 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」;对这种数据集,你要用更小的旋转范围,挑选尊重数字特有不变性的增强。

Mixup 与 cutmix

普通增强改像素但保留 one-hot 标签。Mixupcutmix 打破这点,把输入和标签都插值。

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%。真正能告诉你发生了什么的工具:

  • 每类准确率 —— 每类一个数,立刻暴露表现差的类别。
  • 混淆矩阵 —— C×C 网格,行 i 列 j = 真实类 i 被预测成 j 的计数;对角线是对的,非对角线是模型出错的地方。
  • Top-1 / Top-5 —— 正确类别是否在最高 1 或最高 5 个预测里;Top-5 对 ImageNet 重要,因为像「诺维奇梗」对「诺福克梗」这种类别确实模棱两可。
  • 校准(ECE) —— 0.8 置信度的预测,真有 80% 的把握对吗?现代网络系统性地过度自信;用温度缩放或标签平滑修。

二、从零实现

步骤 1:一个确定性合成数据集

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])

每个类有自己的色彩调色板和频率模式,再加高斯噪声逼模型学信号而非死记像素。十个类,每类一千张,打乱顺序。

步骤 2:标准化与增强

每个视觉流水线都有的两种变换。

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 填充,因为黑边是模型会以无益方式学着忽略的信号。

步骤 3:Mixup

在训练步里混合两张图和两个标签。实现成批变换,让它紧贴前向,而不是埋在数据集里。

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 情形。

步骤 4:训练循环

完整配方:过一遍数据,每批一次梯度,每个 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

每次写训练循环都要检查的五条不变量:

  1. 训练前 model.train(),评估前 model.eval()——切换 dropout 和 batchnorm 行为。
  2. .backward().zero_grad()
  3. 累积指标时用 .item(),别让计算图一直留着。
  4. 评估期间用 @torch.no_grad()——省内存和时间,防微妙事故。
  5. argmax 作用在原始 logits,不是 softmax——结果一样,少一次运算。

步骤 5:组装起来

用上一节的 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%。

步骤 6:读混淆矩阵

光看准确率永远不知道模型在哪失败,混淆矩阵能。

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:一个技能——给定混淆矩阵和类名列表,总结每类失败,并给出最有冲击力的单一修复建议。

五、练习

  1. (简单) 在合成数据集上,分别用和不用 mixup 训五 epoch。画出两者的训练/验证损失。解释为什么开 mixup 的训练损失更高,验证准确率却相近甚至更好。
  2. (中等) 实现 Cutout——每张训练图随机擦零一个 8×8 方块——并做消融:无增强、hflip+crop、hflip+crop+cutout、hflip+crop+mixup。报告每种组合的验证准确率。
  3. (困难) 搭一个 CIFAR-100 流水线(100 类,同样输入尺寸),复现一次 ResNet-34 训练,准确率与公开结果差 1% 以内。加分项:扫三个学习率和两个权重衰减,日志写到本地 CSV,输出「混淆矩阵-最常混淆」表。

本节要点回顾

  1. 分类是所有视觉任务的归约——检测分类区域,分割分类像素,检索按相似度排序;把分类做对,技能迁移到所有任务。
  2. 多数 bug 在流水线不在模型——归一化、shuffle、标签污染、学习率发散,都能让损失曲线看着合理却把 93% 打成 70%。
  3. 交叉熵吃原始 logits——它内部已融合 log-softmax+NLL;损失前自己 softmax 几乎一定是 bug。
  4. 增强教不变性——CNN 对裁剪/翻转/颜色/遮挡没有天生不变性;每次随机变换都在说「标签相同,忽略差异」。
  5. mixup/cutmix 混输入也混标签——模型学会类别间插值,训练损失升、测试准确率也升;最便宜的鲁棒性升级。
  6. 标签平滑改善校准——[eps/C, ..., 1-eps, ..., eps/C],阻止任意尖锐 logits,几乎零成本。
  7. 五条训练循环不变量——train/eval 切换、zero_grad 在 backward 前、.item() 累积、评估 @torch.no_grad()、argmax 作用于原始 logits。
  8. 聚合准确率掩盖不平衡——靠混淆矩阵、每类 prec/rec、Top-k、ECE 才知道模型在哪失败。
  9. mean/std 是数据集特有——CIFAR-10 不能套 ImageNet 统计量,否则悄悄漏 1%。
  10. reflect 填充优于 zero 填充——黑边是模型会无益地学着忽略的信号。

下一节,我们把「加载预训练 + 冻结骨干 + 换头」这条迁移学习配方讲透,并讨论何时该全量微调、何时该只训头、何时该冻结中间层。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U