Phase 04 - Lesson 04

Classificação de Imagens

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Um classificador é uma função que vai de pixels a uma distribuição de probabilidade sobre classes. Todo o resto é encanamento.

Tipo: Build Linguagens: Python Pré-requisitos: Fase 2 Lição 09 (Avaliação de Modelos), Fase 3 Lição 10 (Mini Framework), Fase 4 Lição 03 (CNNs) Tempo: ~75 minutos

Objetivos de Aprendizagem

  • Construir um pipeline ponta a ponta de classificação de imagens no CIFAR-10: dataset, augmentation, modelo, loop de treino, avaliação
  • Explicar o papel de cada componente (dataloader, loss, optimizer, scheduler, augmentation) e prever como quebrar qualquer um deles se manifesta na curva de loss
  • Implementar mixup, cutout e label smoothing do zero e justificar quando vale a pena adicionar cada um
  • Ler uma matriz de confusão e uma tabela de precisão/recall por classe para diagnosticar falhas de dataset e de modelo para além da acurácia agregada

O Problema

Toda tarefa de visão que vai para produção se reduz, em algum nível, a classificação de imagens. A detecção classifica regiões. A segmentação classifica pixels. A recuperação ordena por similaridade aos centroides de classe. Acertar a classificação — o loop do dataset, a política de augmentation, a loss, a avaliação — é a habilidade que se transfere para todas as outras tarefas da fase.

A maioria dos bugs de classificação não está no modelo. Eles moram no pipeline: uma normalização quebrada, um conjunto de treino não embaralhado, augmentation que distorce os rótulos, um split de validação contaminado por dados de treino, uma taxa de aprendizado que diverge silenciosamente depois da época 30. Uma CNN que chegaria a 93% no CIFAR-10 com uma configuração correta normalmente marca 70-75% com uma quebrada, e a curva de loss parece plausível o tempo todo.

Esta lição liga todo o pipeline à mão para que cada parte seja inspecionável. Você não vai usar nada de torchvision.datasets que pudesse esconder um bug.

O Conceito

O pipeline de classificação

flowchart LR
    A["Dataset<br/>(imagens + rótulos)"] --> B["Augment<br/>(transformações aleatórias)"]
    B --> C["Normalizar<br/>(média/desvio)"]
    C --> D["DataLoader<br/>(lote + shuffle)"]
    D --> E["Modelo<br/>(CNN)"]
    E --> F["Logits<br/>(N, C)"]
    F --> G["Loss de cross-entropy"]
    F --> H["Argmax<br/>na avaliação"]
    G --> I["Backward"]
    I --> J["Passo do optimizer"]
    J --> K["Passo do scheduler"]
    K --> E

    style A fill:#dbeafe,stroke:#2563eb
    style E fill:#fef3c7,stroke:#d97706
    style G fill:#fecaca,stroke:#dc2626
    style H fill:#dcfce7,stroke:#16a34a

Cada linha desse loop é onde um bug pode morar. A cross-entropy recebe logits crus, não saídas de softmax, então qualquer model(x).softmax() antes da loss calcula silenciosamente o gradiente errado. As augmentations se aplicam apenas às entradas, não aos rótulos — exceto o mixup, que mistura os dois. optimizer.zero_grad() precisa acontecer uma vez por passo; pular isso acumula gradientes e parece uma taxa de aprendizado absurdamente instável. Cada um desses bugs achata a curva de aprendizado sem lançar erro.

Cross-entropy, logits e softmax

Um classificador produz C números por imagem chamados logits. Aplicar softmax os converte em uma distribuição de probabilidade:

softmax(z)_i = exp(z_i) / sum_j exp(z_j)

A cross-entropy mede a log-probabilidade negativa da classe correta:

CE(z, y) = -log( softmax(z)_y )
        = -z_y + log( sum_j exp(z_j) )

A forma do lado direito é a numericamente estável (log-sum-exp). O nn.CrossEntropyLoss do PyTorch funde softmax + NLL em uma só operação e recebe logits crus diretamente. Aplicar softmax você mesmo antes é quase sempre um bug — você calcula log(softmax(softmax(z))), uma quantidade sem sentido.

Por que augmentation funciona

Uma CNN tem viés indutivo para translação (do compartilhamento de pesos), mas nenhuma invariância embutida a crops, flips, color jitter ou oclusão. A única forma de ensinar essas invariâncias é mostrar a ela pixels que as exercitem. Toda transformação aleatória durante o treino é uma forma de dizer: "estas duas imagens têm o mesmo rótulo; aprenda as features que ignoram a diferença."

Crop original:  "cachorro virado para a esquerda"
Flip:           "cachorro virado para a direita"   <- mesmo rótulo, pixels diferentes
Rotate(+15):    "cachorro, leve inclinação"
Color jitter:   "cachorro sob luz mais quente"
RandomErasing:  "cachorro com um pedaço faltando"

A regra: a augmentation deve preservar o rótulo. Cutout e rotação em um dígito podem transformar "6" em "9"; para esse dataset você usa faixas menores de rotação e escolhe augmentations que respeitem as invariâncias específicas de dígitos.

Mixup e cutmix

A augmentation comum transforma pixels mas mantém os rótulos one-hot. Mixup e cutmix quebram isso ao interpolar ambos.

Mixup:
  lambda ~ Beta(a, a)
  x = lambda * x_i + (1 - lambda) * x_j
  y = lambda * y_i + (1 - lambda) * y_j

Cutmix:
  cola um retângulo aleatório de x_j em x_i
  y = mistura de y_i e y_j ponderada por área

Por que ajuda: o modelo para de memorizar alvos one-hot pontiagudos e aprende a interpolar entre classes. A loss de treino sobe, a acurácia de teste sobe. É o upgrade de robustez mais barato para qualquer classificador.

Label smoothing

Um primo do mixup. Em vez de treinar contra [0, 0, 1, 0, 0], treine contra [eps/C, eps/C, 1-eps, eps/C, eps/C] para um eps pequeno como 0.1. Impede o modelo de produzir logits arbitrariamente afiados e melhora a calibração a custo quase nulo. Embutido no nn.CrossEntropyLoss(label_smoothing=0.1) desde o PyTorch 1.10.

Avaliação para além da acurácia

A acurácia agregada esconde desbalanceamento. Um classificador binário 90-10 que sempre prevê a classe majoritária marca 90%. As ferramentas que de fato dizem o que está acontecendo:

  • Acurácia por classe — um número por classe; aflora imediatamente categorias com baixo desempenho.
  • Matriz de confusão — grade C x C com a linha i coluna j = contagem da classe verdadeira i prevista como classe j; a diagonal está correta, as fora da diagonal são onde seu modelo vive.
  • Top-1 / Top-5 — se a classe correta está entre as 1 ou 5 melhores predições; o Top-5 importa para o ImageNet porque classes como "Norwich terrier" vs "Norfolk terrier" são genuinamente ambíguas.
  • Calibração (ECE) — uma predição com confiança 0.8 acerta 80% das vezes? Redes modernas são sistematicamente superconfiantes; corrija com temperature scaling ou label smoothing.

Construa

Passo 1: Um dataset sintético determinístico

O CIFAR-10 mora em disco. Para tornar esta lição reproduzível e rápida, construímos um dataset sintético que se parece com o CIFAR — imagens RGB 32x32 com estrutura específica de classe que o modelo precisa aprender. O mesmíssimo pipeline funciona sem alteração no CIFAR-10 real.

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

Cada classe ganha sua própria paleta de cores e padrão de frequência, mais ruído gaussiano para forçar o modelo a aprender o sinal em vez de memorizar pixels. Dez classes, mil imagens cada, permutadas.

Passo 2: Normalização e augmentation

As duas transformações que todo pipeline de visão tem.

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

Use reflect-pad antes do crop, não zero-pad, porque bordas pretas são um sinal que o modelo aprenderia a ignorar de uma forma não-útil.

Passo 3: Mixup

Mistura duas imagens e dois rótulos dentro do passo de treino. Implementado como uma transformação de lote para que viva ao lado do forward pass em vez de dentro do dataset.

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 é a cross-entropy contra uma distribuição de rótulos suaves. Ela se reduz ao caso one-hot usual quando o alvo é exatamente one-hot.

Passo 4: O loop de treino

A receita completa: uma passada sobre os dados, gradientes uma vez por lote, scheduler avançado uma vez por época.

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)
        # A acuracia de treino contra os rotulos `y` nao misturados e apenas uma
        # aproximacao quando o mixup esta ligado (o modelo viu alvos suaves, nao y).
        # Trate-a como um sinal de progresso aproximado; confie na acuracia de val
        # para o desempenho real.
        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

Cinco invariantes que você verifica toda vez que escreve um loop de treino:

  1. model.train() antes do treino, model.eval() antes da avaliação — alterna o comportamento de dropout e batchnorm.
  2. .zero_grad() antes de .backward().
  3. .item() ao acumular métricas para que nada mantenha o grafo de computação vivo.
  4. @torch.no_grad() durante a avaliação — economiza memória e tempo, evita acidentes sutis.
  5. Argmax contra logits crus, não softmax — mesmo resultado, uma operação a menos.

Passo 5: Juntando tudo

Use o TinyResNet da lição anterior, treine por algumas épocas, avalie.

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 vem da licao anterior (03-cnns-lenet-to-resnet).
# Ajuste o caminho de import para onde voce guardou o codigo da licao anterior.
from cnns_lenet_to_resnet import TinyResNet  # example placeholder

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}")

No dataset sintético, isso chega a uma acurácia de validação quase perfeita em cinco épocas, que é o ponto: o pipeline está correto, o modelo consegue aprender o que é aprendível. Troque o dataset pelo CIFAR-10 real e o mesmo loop treina até ~90% sem alterações.

Passo 6: Leia a matriz de confusão

A acurácia sozinha nunca diz onde o modelo está falhando. A matriz de confusão diz.

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)

As linhas são as classes verdadeiras, as colunas são as predições. Um aglomerado de contagens fora da diagonal entre as classes 3 e 5 significa que o modelo confunde as duas e te dá um ponto de partida para coleta de dados direcionada ou uma augmentation específica de classe.

Use

O torchvision empacota tudo acima em componentes idiomáticos. Para o CIFAR-10 real, o pipeline completo são quatro linhas mais um loop de treino.

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)

Duas coisas para notar: a média/desvio são específicas do dataset — calculadas no conjunto de treino do CIFAR-10, não no ImageNet — e o reflect pad é a política de crop padrão da comunidade. Copiar e colar as estatísticas do ImageNet aqui é um vazamento de ~1% de acurácia que ninguém pega até alguém perfilar o modelo.

Entregue

Esta lição produz:

  • outputs/prompt-classifier-pipeline-auditor.md — um prompt que audita um script de treino para os cinco invariantes acima e aflora a primeira violação.
  • outputs/skill-classification-diagnostics.md — uma skill que, dada uma matriz de confusão e uma lista de nomes de classes, resume as falhas por classe e propõe a única correção de maior impacto.

Exercícios

  1. (Fácil) Treine o mesmo modelo com e sem mixup por cinco épocas no dataset sintético. Plote a loss de treino e de validação para ambos. Explique por que a loss de treino com mixup é maior, ainda que a acurácia de validação seja similar ou melhor.
  2. (Médio) Implemente o Cutout — zere um quadrado aleatório de 8x8 em cada imagem de treino — e rode uma ablação contra nenhuma augmentation, hflip+crop, hflip+crop+cutout, hflip+crop+mixup. Reporte a acurácia de validação de cada um.
  3. (Difícil) Construa um pipeline para o CIFAR-100 (100 classes, mesmo tamanho de entrada) e reproduza um treino de ResNet-34 dentro de 1% da acurácia publicada. Extras: varra três taxas de aprendizado e dois weight decays, registre em um CSV local, produza a tabela final de matriz-de-confusão-top-confusões.

Termos-Chave

Termo O que as pessoas dizem O que realmente significa
Logits "Saídas cruas" O vetor pré-softmax de C números por imagem; a cross-entropy espera estes, não valores com softmax aplicado
Cross-entropy "A loss" Log-probabilidade negativa da classe correta; combina log-softmax e NLL em uma operação estável
DataLoader "O lotador" Envolve um dataset com shuffling, batching e (opcional) carregamento multi-worker; leva a culpa por metade dos bugs de treino
Augmentation "Transformações aleatórias" Qualquer transformação a nível de pixel no momento do treino que preserva o rótulo; ensina invariâncias que a CNN não tem nativamente
Mixup / Cutmix "Misturar duas imagens" Mescla tanto as entradas quanto os rótulos para que o classificador aprenda interpolações suaves em vez de fronteiras duras
Label smoothing "Alvos mais suaves" Substitui one-hot por (1-eps, eps/(C-1), ...); melhora a calibração e aumenta levemente a acurácia
Acurácia Top-k "Top-5" A classe correta está entre as k predições de maior probabilidade; usada em datasets com classes genuinamente ambíguas
Matriz de confusão "Onde os erros vivem" Tabela C x C onde a entrada (i, j) conta imagens da classe verdadeira i previstas como j; a diagonal está certa, a fora da diagonal te diz o que corrigir

Leitura Adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).