Objetivo

Entender como redes convolucionais funcionam internamente, implementar operações de convolução do zero, construir arquiteturas CNN com PyTorch e aplicar transfer learning com modelos pré-treinados. CNNs são a base de toda visão computacional moderna.


1. Por que CNNs para Imagens?

Uma imagem de 224×224 pixels com 3 canais (RGB) tem 224×224×3 = 150.528 valores. Uma rede densa (MLP) com uma camada oculta de 512 neurônios precisaria de 150.528 × 512 ≈ 77 milhões de parâmetros só na primeira camada. Isso é computacionalmente proibitivo e ignora completamente a estrutura espacial da imagem.

CNNs resolvem isso com três ideias fundamentais:

  • Conectividade local: cada neurônio olha apenas para uma região pequena da imagem (campo receptivo)
  • Compartilhamento de pesos: o mesmo filtro é aplicado em todas as posições da imagem
  • Hierarquia de features: camadas iniciais detectam bordas, camadas intermediárias detectam formas, camadas finais detectam objetos

2. A Operação de Convolução

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from sklearn.metrics import accuracy_score, classification_report
import warnings
warnings.filterwarnings("ignore")

torch.manual_seed(42)
np.random.seed(42)
sns.set_theme(style="whitegrid")

device = torch.device("cuda" if torch.cuda.is_available()
                       else "cpu")
print(f"Dispositivo: {device}")

def convolucao_manual(imagem: np.ndarray,
                       filtro: np.ndarray,
                       stride: int = 1,
                       padding: int = 0) -> np.ndarray:
    """
    Implementação manual da operação de convolução 2D.
    Demonstrativa — na prática use PyTorch.

    Parâmetros:
        imagem:  array (H, W)
        filtro:  array (kH, kW)
        stride:  passo da janela deslizante
        padding: zeros ao redor da imagem
    """
    H, W   = imagem.shape
    kH, kW = filtro.shape

    # Aplicar padding
    if padding > 0:
        imagem = np.pad(imagem, padding, mode="constant")
        H, W   = imagem.shape

    # Dimensões da saída
    H_out = (H - kH) // stride + 1
    W_out = (W - kW) // stride + 1
    saida = np.zeros((H_out, W_out))

    for i in range(H_out):
        for j in range(W_out):
            region = imagem[i*stride:i*stride+kH,
                             j*stride:j*stride+kW]
            saida[i, j] = np.sum(region * filtro)

    return saida

# Criar imagem sintética
imagem_teste = np.zeros((8, 8))
imagem_teste[2:6, 2:6] = 1.0   # quadrado no centro

# Filtros clássicos de visão computacional
filtros = {
    "Identidade":  np.array([[0, 0, 0],
                               [0, 1, 0],
                               [0, 0, 0]]),
    "Blur (media)": np.ones((3, 3)) / 9,
    "Sobel X (borda vertical)": np.array([[-1, 0, 1],
                                            [-2, 0, 2],
                                            [-1, 0, 1]]),
    "Sobel Y (borda horiz.)":   np.array([[-1, -2, -1],
                                            [ 0,  0,  0],
                                            [ 1,  2,  1]]),
    "Sharpen":     np.array([[ 0, -1,  0],
                               [-1,  5, -1],
                               [ 0, -1,  0]]),
    "Laplaciano":  np.array([[ 0,  1,  0],
                               [ 1, -4,  1],
                               [ 0,  1,  0]]),
}

fig, axes = plt.subplots(2, len(filtros) + 1,
                          figsize=(20, 6))

# Imagem original
axes[0, 0].imshow(imagem_teste, cmap="gray")
axes[0, 0].set_title("Imagem original")
axes[0, 0].axis("off")
axes[1, 0].imshow(np.zeros((3, 3)), cmap="gray")
axes[1, 0].set_title("(vazio)")
axes[1, 0].axis("off")

for col, (nome, filtro) in enumerate(filtros.items(), 1):
    resultado = convolucao_manual(imagem_teste, filtro,
                                   padding=1)
    axes[0, col].imshow(resultado, cmap="gray")
    axes[0, col].set_title(nome, fontsize=8)
    axes[0, col].axis("off")

    axes[1, col].imshow(filtro, cmap="RdBu",
                          vmin=-filtro.max(),
                          vmax=filtro.max())
    axes[1, col].set_title("Filtro", fontsize=8)
    for i in range(3):
        for j in range(3):
            axes[1, col].text(j, i, f"{filtro[i,j]:.1f}",
                               ha="center", va="center",
                               fontsize=7)
    axes[1, col].axis("off")

plt.suptitle("Operação de Convolução: Diferentes Filtros",
             fontsize=13)
plt.tight_layout()
plt.show()

# Fórmula da dimensão de saída
print("Fórmula da dimensão de saída:")
print("  H_out = (H_in - kH + 2×padding) / stride + 1")
print("  W_out = (W_in - kW + 2×padding) / stride + 1")
print()
for H_in, kH, pad, st in [(28, 5, 0, 1),
                             (28, 3, 1, 1),
                             (224, 3, 1, 2)]:
    H_out = (H_in - kH + 2*pad) // st + 1
    print(f"  H={H_in}, k={kH}, p={pad}, s={st} → {H_out}")

3. Componentes de uma CNN

# ── Conv2d ───────────────────────────────────────────────
conv = nn.Conv2d(
    in_channels=1,     # canais de entrada (1=grayscale, 3=RGB)
    out_channels=32,   # número de filtros (feature maps)
    kernel_size=3,     # tamanho do filtro (3×3)
    stride=1,          # passo
    padding=1          # "same" padding mantém dimensões
)

x_demo = torch.randn(4, 1, 28, 28)   # batch de 4 imagens 28×28 grayscale
y_demo = conv(x_demo)
print(f"Conv2d demo:")
print(f"  Entrada: {x_demo.shape}")
print(f"  Saída:   {y_demo.shape}")
print(f"  Parâmetros: {sum(p.numel() for p in conv.parameters()):,}")
print(f"  (32 filtros × (1×3×3 pesos + 1 bias) = {32*(9+1)})")

# ── MaxPool2d ────────────────────────────────────────────
pool = nn.MaxPool2d(kernel_size=2, stride=2)
y_pool = pool(y_demo)
print(f"\nMaxPool2d demo:")
print(f"  Entrada: {y_demo.shape}")
print(f"  Saída:   {y_pool.shape}")
print(f"  Redução: 28×28 → 14×14 (factor 2)")

# ── Visualizar MaxPool vs AvgPool ────────────────────────
x_pool_demo = torch.tensor([[
    [[1., 3., 2., 4.],
     [5., 6., 1., 2.],
     [3., 2., 4., 1.],
     [1., 4., 2., 3.]]
]])

max_p = nn.MaxPool2d(2, 2)(x_pool_demo)
avg_p = nn.AvgPool2d(2, 2)(x_pool_demo)
print(f"\nComparação Max vs Avg Pooling (2×2 blocks):")
print(f"  Original:\n{x_pool_demo[0,0].numpy()}")
print(f"  MaxPool:\n{max_p[0,0].numpy()}")
print(f"  AvgPool:\n{avg_p[0,0].numpy()}")

# ── Batch Normalization 2D ───────────────────────────────
bn = nn.BatchNorm2d(32)   # normaliza por canal
y_bn = bn(y_demo)
print(f"\nBatchNorm2d:")
print(f"  Antes — média: {y_demo.mean():.4f}, "
      f"std: {y_demo.std():.4f}")
print(f"  Depois — média: {y_bn.mean():.4f}, "
      f"std: {y_bn.std():.4f}")

4. CNN do Zero para MNIST

# ── Dataset MNIST sintético ──────────────────────────────
# (substituindo torchvision para não precisar de download)
class MNISTSintetico(Dataset):
    """
    Dataset sintético que simula o MNIST.
    Cada imagem é um padrão geométrico simples com label.
    """

    def __init__(self, n: int = 5000,
                  n_classes: int = 5,
                  tamanho: int = 28):
        self.n = n
        self.tamanho = tamanho
        np.random.seed(42)

        self.imagens = []
        self.labels  = []

        for _ in range(n):
            label = np.random.randint(0, n_classes)
            img   = self._gerar_imagem(label, tamanho)
            self.imagens.append(img)
            self.labels.append(label)

        self.imagens = np.array(self.imagens,
                                  dtype=np.float32)
        self.labels  = np.array(self.labels, dtype=np.int64)

    def _gerar_imagem(self, label: int,
                       tamanho: int) -> np.ndarray:
        """Gera padrão sintético por classe."""
        img   = np.zeros((tamanho, tamanho))
        ruido = np.random.normal(0, 0.05,
                                   (tamanho, tamanho))

        if label == 0:   # Círculo
            cy, cx = tamanho//2, tamanho//2
            r      = tamanho // 4
            for y in range(tamanho):
                for x in range(tamanho):
                    if (r-2)**2 <= (y-cy)**2+(x-cx)**2 <= r**2:
                        img[y, x] = 1.0

        elif label == 1:   # Cruz
            m = tamanho // 2
            img[m-1:m+2, 4:-4] = 1.0
            img[4:-4, m-1:m+2] = 1.0

        elif label == 2:   # Quadrado
            m = tamanho // 4
            img[m:3*m, m:3*m] = 0.0
            img[m, m:3*m] = img[3*m, m:3*m] = 1.0
            img[m:3*m, m] = img[m:3*m, 3*m] = 1.0

        elif label == 3:   # Diagonal
            for i in range(tamanho):
                j = i
                if j < tamanho:
                    img[i, max(0,j-1):j+2] = 1.0

        elif label == 4:   # T
            m = tamanho // 2
            img[4:m, m-1:m+2] = 1.0
            img[4:7, 4:-4]    = 1.0

        return np.clip(img + ruido * 0.3, 0, 1)

    def __len__(self) -> int:
        return self.n

    def __getitem__(self, idx: int):
        img   = torch.from_numpy(
            self.imagens[idx]
        ).unsqueeze(0)   # (1, 28, 28)
        label = torch.tensor(self.labels[idx])
        return img, label

# Criar dataset e dataloaders
ds_total = MNISTSintetico(n=6000, n_classes=5, tamanho=28)

# Dividir
n_tr = int(0.7 * len(ds_total))
n_va = int(0.15 * len(ds_total))
n_te = len(ds_total) - n_tr - n_va

from torch.utils.data import random_split
ds_tr_cnn, ds_va_cnn, ds_te_cnn = random_split(
    ds_total, [n_tr, n_va, n_te],
    generator=torch.Generator().manual_seed(42)
)

dl_tr_cnn = DataLoader(ds_tr_cnn, batch_size=64,
                         shuffle=True,  drop_last=True)
dl_va_cnn = DataLoader(ds_va_cnn, batch_size=64,
                         shuffle=False)
dl_te_cnn = DataLoader(ds_te_cnn, batch_size=64,
                         shuffle=False)

print(f"Dataset sintético de 5 classes:")
print(f"  Treino: {len(ds_tr_cnn)}, "
      f"Val: {len(ds_va_cnn)}, "
      f"Teste: {len(ds_te_cnn)}")

# Visualizar amostras
fig, axes = plt.subplots(1, 10, figsize=(15, 2))
nomes_classes = ["Círculo", "Cruz", "Quadrado",
                  "Diagonal", "T"]
for i, ax in enumerate(axes):
    img, label = ds_total[i * 60]
    ax.imshow(img.squeeze().numpy(), cmap="gray")
    ax.set_title(nomes_classes[label.item()], fontsize=8)
    ax.axis("off")
plt.suptitle("Amostras do Dataset", fontsize=11)
plt.tight_layout()
plt.show()

5. Arquitetura CNN Completa

class CNNSimples(nn.Module):
    """
    CNN para classificação de imagens 28×28 grayscale.
    Arquitetura: Conv → Pool → Conv → Pool → FC → saída
    """

    def __init__(self, n_classes: int = 5):
        super().__init__()

        # Bloco 1: extração de features de baixo nível
        self.bloco1 = nn.Sequential(
            nn.Conv2d(1, 32, kernel_size=3,
                      padding=1),        # 28×28×1 → 28×28×32
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.MaxPool2d(2, 2),          # 28×28 → 14×14
        )

        # Bloco 2: features de médio nível
        self.bloco2 = nn.Sequential(
            nn.Conv2d(32, 64, kernel_size=3,
                      padding=1),        # 14×14×32 → 14×14×64
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.MaxPool2d(2, 2),          # 14×14 → 7×7
        )

        # Bloco 3: features de alto nível
        self.bloco3 = nn.Sequential(
            nn.Conv2d(64, 128, kernel_size=3,
                      padding=1),        # 7×7×64 → 7×7×128
            nn.BatchNorm2d(128),
            nn.ReLU(),
            # GlobalAvgPool: 7×7 → 1×1
            nn.AdaptiveAvgPool2d(1),
        )

        # Classificador
        self.classificador = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(64, n_classes)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.bloco1(x)
        x = self.bloco2(x)
        x = self.bloco3(x)
        return self.classificador(x)

    def visualizar_feature_maps(self,
                                  x: torch.Tensor) -> None:
        """Visualiza os feature maps de cada bloco."""
        self.eval()
        with torch.no_grad():
            f1 = self.bloco1(x)
            f2 = self.bloco2(f1)
            f3 = self.bloco3(f2)

        fig, axes = plt.subplots(3, 8, figsize=(16, 6))

        for i, (nome, fmaps) in enumerate([
            ("Bloco 1 (32 filtros)",  f1),
            ("Bloco 2 (64 filtros)",  f2),
            ("Bloco 3 (128 filtros)", f3)
        ]):
            for j in range(8):
                if j < fmaps.shape[1]:
                    fm = fmaps[0, j].cpu().numpy()
                    axes[i, j].imshow(fm, cmap="viridis")
                axes[i, j].axis("off")
            axes[i, 0].set_ylabel(nome, fontsize=8,
                                    rotation=90)

        plt.suptitle("Feature Maps por Camada",
                     fontsize=12)
        plt.tight_layout()
        plt.show()

# Instanciar e inspecionar
cnn = CNNSimples(n_classes=5).to(device)

print("Arquitetura CNN:")
print(cnn)
n_params = sum(p.numel() for p in cnn.parameters())
print(f"\nTotal de parâmetros: {n_params:,}")

# Verificar shapes intermediários
x_dummy = torch.randn(1, 1, 28, 28).to(device)
print("\nShapes intermediários:")
with torch.no_grad():
    b1 = cnn.bloco1(x_dummy)
    b2 = cnn.bloco2(b1)
    b3 = cnn.bloco3(b2)
    out = cnn(x_dummy)
print(f"  Entrada:  {x_dummy.shape}")
print(f"  Bloco 1:  {b1.shape}")
print(f"  Bloco 2:  {b2.shape}")
print(f"  Bloco 3:  {b3.shape}")
print(f"  Saída:    {out.shape}")

6. Treinando a CNN

# Treinamento completo
criterion_cnn = nn.CrossEntropyLoss()
optimizer_cnn = optim.AdamW(cnn.parameters(),
                               lr=1e-3, weight_decay=1e-4)
scheduler_cnn = optim.lr_scheduler.OneCycleLR(
    optimizer_cnn,
    max_lr=1e-2,
    steps_per_epoch=len(dl_tr_cnn),
    epochs=30,
    pct_start=0.3
)

n_epocas_cnn = 30
hist_cnn     = {"treino_loss": [], "treino_acc": [],
                 "val_loss":    [], "val_acc": []}
melhor_val   = float("inf")

print(f"Treinando CNN por {n_epocas_cnn} épocas...")

for epoca in range(n_epocas_cnn):

    # ── Treino ────────────────────────────────────────
    cnn.train()
    losses_tr, n_corr_tr, n_tot_tr = [], 0, 0

    for X_b, y_b in dl_tr_cnn:
        X_b = X_b.to(device)
        y_b = y_b.to(device)

        optimizer_cnn.zero_grad()
        logits = cnn(X_b)
        loss   = criterion_cnn(logits, y_b)
        loss.backward()
        nn.utils.clip_grad_norm_(cnn.parameters(), 1.0)
        optimizer_cnn.step()
        scheduler_cnn.step()

        losses_tr.append(loss.item())
        preds    = logits.argmax(dim=1)
        n_corr_tr += (preds == y_b).sum().item()
        n_tot_tr  += len(y_b)

    # ── Validação ─────────────────────────────────────
    cnn.eval()
    losses_va, n_corr_va, n_tot_va = [], 0, 0

    with torch.no_grad():
        for X_b, y_b in dl_va_cnn:
            X_b    = X_b.to(device)
            y_b    = y_b.to(device)
            logits = cnn(X_b)
            loss   = criterion_cnn(logits, y_b)
            losses_va.append(loss.item())
            preds    = logits.argmax(dim=1)
            n_corr_va += (preds == y_b).sum().item()
            n_tot_va  += len(y_b)

    loss_tr = np.mean(losses_tr)
    acc_tr  = n_corr_tr / n_tot_tr
    loss_va = np.mean(losses_va)
    acc_va  = n_corr_va / n_tot_va

    hist_cnn["treino_loss"].append(loss_tr)
    hist_cnn["treino_acc"].append(acc_tr)
    hist_cnn["val_loss"].append(loss_va)
    hist_cnn["val_acc"].append(acc_va)

    if loss_va < melhor_val:
        melhor_val = loss_va
        torch.save(cnn.state_dict(), "cnn_melhor.pt")

    if (epoca + 1) % 10 == 0:
        print(f"  Época {epoca+1:2d}/{n_epocas_cnn} | "
              f"Loss: {loss_tr:.4f}/{loss_va:.4f} | "
              f"Acc: {acc_tr:.4f}/{acc_va:.4f}")

# Carregar melhor e avaliar
cnn.load_state_dict(torch.load("cnn_melhor.pt",
                                  weights_only=True))
cnn.eval()

todos_preds  = []
todos_labels = []
with torch.no_grad():
    for X_b, y_b in dl_te_cnn:
        X_b    = X_b.to(device)
        logits = cnn(X_b)
        preds  = logits.argmax(dim=1).cpu().numpy()
        todos_preds.extend(preds)
        todos_labels.extend(y_b.numpy())

acc_te = accuracy_score(todos_labels, todos_preds)
print(f"\nAcurácia no teste: {acc_te:.4f}")
print(f"\n{classification_report(todos_labels, todos_preds, target_names=nomes_classes)}")

# Visualizar curvas e feature maps
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
ep = range(1, n_epocas_cnn + 1)
axes[0].plot(ep, hist_cnn["treino_loss"],
              label="Treino", color="steelblue",
              linewidth=2)
axes[0].plot(ep, hist_cnn["val_loss"],
              label="Validação", color="coral",
              linewidth=2, linestyle="--")
axes[0].set_title("Loss")
axes[0].set_xlabel("Época")
axes[0].legend()

axes[1].plot(ep, hist_cnn["treino_acc"],
              label="Treino", color="steelblue",
              linewidth=2)
axes[1].plot(ep, hist_cnn["val_acc"],
              label="Validação", color="coral",
              linewidth=2, linestyle="--")
axes[1].set_title("Acurácia")
axes[1].set_xlabel("Época")
axes[1].legend()

plt.suptitle("Treinamento da CNN", fontsize=13)
plt.tight_layout()
plt.show()

# Feature maps de um exemplo
img_exemplo, label_exemplo = ds_total[0]
cnn.visualizar_feature_maps(
    img_exemplo.unsqueeze(0).to(device)
)

7. Data Augmentation

# Data augmentation para imagens
# Aumenta a diversidade do dataset sem coletar mais dados

from torchvision import transforms

print("Data Augmentation para imagens:")
print("=" * 50)

# Pipeline de augmentation para treino
transform_treino = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomRotation(degrees=10),
    transforms.RandomAffine(
        degrees=0,
        translate=(0.1, 0.1),   # translação de até 10%
        scale=(0.9, 1.1)
    ),
    transforms.ColorJitter(
        brightness=0.2,
        contrast=0.2
    ),
    transforms.Normalize(mean=[0.5], std=[0.5])
])

# Pipeline para validação/teste (sem augmentation)
transform_val = transforms.Compose([
    transforms.Normalize(mean=[0.5], std=[0.5])
])

# Demonstrar as transformações
img_aug_demo = torch.from_numpy(ds_total[0][0].numpy())

fig, axes = plt.subplots(2, 6, figsize=(18, 6))

axes[0, 0].imshow(img_aug_demo.squeeze().numpy(),
                   cmap="gray")
axes[0, 0].set_title("Original")
axes[0, 0].axis("off")

transforms_demo = [
    ("H-Flip",   transforms.RandomHorizontalFlip(p=1.0)),
    ("Rotação",  transforms.RandomRotation(degrees=20)),
    ("Affine",   transforms.RandomAffine(degrees=10,
                                           translate=(0.2, 0.2))),
    ("Brilho",   transforms.ColorJitter(brightness=0.5)),
    ("Contraste", transforms.ColorJitter(contrast=0.5)),
]

for col, (nome, t) in enumerate(transforms_demo, 1):
    img_t = t(img_aug_demo)
    axes[0, col].imshow(img_t.squeeze().numpy(), cmap="gray")
    axes[0, col].set_title(nome, fontsize=9)
    axes[0, col].axis("off")

# Segunda linha: múltiplas amostras augmentadas
t_random = transforms.Compose([
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(15),
    transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),
])

for col in range(6):
    img_t = t_random(img_aug_demo)
    axes[1, col].imshow(img_t.squeeze().numpy(), cmap="gray")
    axes[1, col].set_title(f"Aug {col+1}", fontsize=9)
    axes[1, col].axis("off")

plt.suptitle("Exemplos de Data Augmentation", fontsize=12)
plt.tight_layout()
plt.show()

print("Técnicas comuns de augmentation:")
print("  RandomHorizontalFlip: espelhamento horizontal")
print("  RandomRotation:       rotação aleatória")
print("  RandomAffine:         translação e escala")
print("  ColorJitter:          brilho, contraste, saturação")
print("  RandomCrop:           corte aleatório")
print("  RandomErasing:        apaga região aleatória (Cutout)")
print("  MixUp:                mistura duas imagens")
print("  CutMix:               combina regiões de duas imagens")

8. Transfer Learning

print("\n" + "=" * 60)
print("TRANSFER LEARNING")
print("=" * 60)

print("""
Transfer Learning usa modelos pré-treinados em grandes datasets
(como ImageNet com 1M+ imagens) como ponto de partida.

Estratégias:
1. Feature Extraction: congela todas as camadas, treina só a cabeça
2. Fine-tuning parcial: descongela as últimas camadas
3. Fine-tuning completo: treina toda a rede com LR muito baixo
""")

# Simulando transfer learning com nossa CNN
# (na prática usaria torchvision.models.resnet18, etc.)

class CNNComTransfer(nn.Module):
    """
    Simula transfer learning:
    - Backbone 'pré-treinado' (congelado)
    - Nova cabeça de classificação (treinável)
    """

    def __init__(self,
                  n_classes_novo: int,
                  n_classes_original: int = 5,
                  strategy: str = "feature_extraction"):
        super().__init__()

        # Carregar backbone 'pré-treinado'
        backbone_pretrained = CNNSimples(
            n_classes=n_classes_original
        )
        backbone_pretrained.load_state_dict(
            torch.load("cnn_melhor.pt", weights_only=True)
        )

        # Extrair apenas as camadas convolucionais
        self.backbone = nn.Sequential(
            backbone_pretrained.bloco1,
            backbone_pretrained.bloco2,
            backbone_pretrained.bloco3,
        )

        # Estratégia de congelamento
        if strategy == "feature_extraction":
            # Congelar TUDO do backbone
            for param in self.backbone.parameters():
                param.requires_grad = False
            print("Estratégia: Feature Extraction")
            print("  Backbone congelado — treina apenas a cabeça")

        elif strategy == "fine_tuning_parcial":
            # Congelar apenas os dois primeiros blocos
            for param in self.backbone[0].parameters():
                param.requires_grad = False
            for param in self.backbone[1].parameters():
                param.requires_grad = False
            print("Estratégia: Fine-tuning Parcial")
            print("  Blocos 1 e 2 congelados, bloco 3 treinável")

        else:  # fine_tuning_completo
            print("Estratégia: Fine-tuning Completo")
            print("  Toda a rede treinável (LR baixo no backbone)")

        # Nova cabeça de classificação
        self.nova_cabeca = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(64, n_classes_novo)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        features = self.backbone(x)
        return self.nova_cabeca(features)

    def info_parametros(self):
        total       = sum(p.numel() for p in self.parameters())
        treinavel   = sum(p.numel() for p in self.parameters()
                          if p.requires_grad)
        congelado   = total - treinavel
        print(f"\nParâmetros:")
        print(f"  Total:    {total:,}")
        print(f"  Treinável:{treinavel:,} ({treinavel/total:.1%})")
        print(f"  Congelado:{congelado:,} ({congelado/total:.1%})")

# Criar dataset de 'novo domínio' (mesmo padrão mas classes diferentes)
ds_novo = MNISTSintetico(n=1000, n_classes=3, tamanho=28)
n_tr_n  = int(0.7 * len(ds_novo))
n_va_n  = int(0.15 * len(ds_novo))
n_te_n  = len(ds_novo) - n_tr_n - n_va_n

ds_tr_n, ds_va_n, ds_te_n = random_split(
    ds_novo, [n_tr_n, n_va_n, n_te_n],
    generator=torch.Generator().manual_seed(42)
)
dl_tr_n = DataLoader(ds_tr_n, batch_size=32, shuffle=True)
dl_va_n = DataLoader(ds_va_n, batch_size=32)
dl_te_n = DataLoader(ds_te_n, batch_size=32)

print(f"\nNovo dataset: {len(ds_novo)} exemplos, 3 classes")

# Comparar estratégias
estrategias = ["feature_extraction",
                "fine_tuning_parcial",
                "fine_tuning_completo"]
resultados_tl = {}

for estrategia in estrategias:
    print(f"\n{'='*50}")
    modelo_tl = CNNComTransfer(
        n_classes_novo=3,
        strategy=estrategia
    ).to(device)
    modelo_tl.info_parametros()

    opt_tl = optim.AdamW(
        filter(lambda p: p.requires_grad,
               modelo_tl.parameters()),
        lr=1e-3
    )

    melhor_acc = 0
    for epoca in range(20):
        modelo_tl.train()
        for X_b, y_b in dl_tr_n:
            X_b = X_b.to(device)
            y_b = y_b.to(device)
            opt_tl.zero_grad()
            loss = criterion_cnn(modelo_tl(X_b), y_b)
            loss.backward()
            opt_tl.step()

        modelo_tl.eval()
        n_corr, n_tot = 0, 0
        with torch.no_grad():
            for X_b, y_b in dl_va_n:
                X_b = X_b.to(device)
                preds = modelo_tl(X_b).argmax(1).cpu()
                n_corr += (preds == y_b).sum().item()
                n_tot  += len(y_b)
        acc = n_corr / n_tot
        if acc > melhor_acc:
            melhor_acc = acc

    # Teste final
    modelo_tl.eval()
    n_corr_te, n_tot_te = 0, 0
    with torch.no_grad():
        for X_b, y_b in dl_te_n:
            X_b = X_b.to(device)
            preds = modelo_tl(X_b).argmax(1).cpu()
            n_corr_te += (preds == y_b).sum().item()
            n_tot_te  += len(y_b)
    acc_te_tl = n_corr_te / n_tot_te

    resultados_tl[estrategia] = acc_te_tl
    print(f"\n  Acurácia no teste: {acc_te_tl:.4f}")

print(f"\n{'='*50}")
print("Comparação de estratégias de Transfer Learning:")
for est, acc in resultados_tl.items():
    barra = "█" * int(acc * 30)
    print(f"  {est:<25}: {acc:.4f} {barra}")

9. CNN vs MLP — Comparação Direta

print("\n" + "=" * 60)
print("CNN vs MLP: COMPARAÇÃO DIRETA")
print("=" * 60)

# MLP para comparação
class MLPImagens(nn.Module):
    """MLP que planifica a imagem — ignora estrutura espacial."""

    def __init__(self, n_entrada: int = 784,
                  n_classes: int = 5):
        super().__init__()
        self.rede = nn.Sequential(
            nn.Flatten(),
            nn.Linear(n_entrada, 256),
            nn.BatchNorm1d(256),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(128, n_classes)
        )

    def forward(self, x):
        return self.rede(x)

mlp_imagens = MLPImagens(
    n_entrada=28*28, n_classes=5
).to(device)

opt_mlp  = optim.AdamW(mlp_imagens.parameters(), lr=1e-3)
hist_mlp = {"treino_acc": [], "val_acc": []}

for epoca in range(n_epocas_cnn):
    mlp_imagens.train()
    n_corr, n_tot = 0, 0
    for X_b, y_b in dl_tr_cnn:
        X_b = X_b.to(device)
        y_b = y_b.to(device)
        opt_mlp.zero_grad()
        loss = criterion_cnn(mlp_imagens(X_b), y_b)
        loss.backward()
        opt_mlp.step()
        preds = mlp_imagens(X_b).argmax(1)
        n_corr += (preds == y_b).sum().item()
        n_tot  += len(y_b)
    hist_mlp["treino_acc"].append(n_corr / n_tot)

    mlp_imagens.eval()
    n_corr, n_tot = 0, 0
    with torch.no_grad():
        for X_b, y_b in dl_va_cnn:
            X_b   = X_b.to(device)
            preds = mlp_imagens(X_b).argmax(1).cpu()
            n_corr += (preds == y_b).sum().item()
            n_tot  += len(y_b)
    hist_mlp["val_acc"].append(n_corr / n_tot)

# Teste final MLP
mlp_imagens.eval()
n_corr_mlp, n_tot_mlp = 0, 0
with torch.no_grad():
    for X_b, y_b in dl_te_cnn:
        X_b   = X_b.to(device)
        preds = mlp_imagens(X_b).argmax(1).cpu()
        n_corr_mlp += (preds == y_b).sum().item()
        n_tot_mlp  += len(y_b)
acc_mlp_te = n_corr_mlp / n_tot_mlp

# Comparação
print(f"\nResultados no Teste:")
print(f"  CNN:  {acc_te:.4f}  | "
      f"Params: "
      f"{sum(p.numel() for p in cnn.parameters()):,}")
print(f"  MLP:  {acc_mlp_te:.4f}  | "
      f"Params: "
      f"{sum(p.numel() for p in mlp_imagens.parameters()):,}")

fig, ax = plt.subplots(figsize=(10, 5))
ep = range(1, n_epocas_cnn + 1)
ax.plot(ep, hist_cnn["val_acc"],
         label=f"CNN (teste={acc_te:.3f})",
         color="steelblue", linewidth=2)
ax.plot(ep, hist_mlp["val_acc"],
         label=f"MLP (teste={acc_mlp_te:.3f})",
         color="coral", linewidth=2, linestyle="--")
ax.set_xlabel("Época")
ax.set_ylabel("Acurácia de Validação")
ax.set_title("CNN vs MLP em Classificação de Imagens")
ax.legend()
plt.tight_layout()
plt.show()

10. Exemplo Completo: Pipeline CNN de Produção

print("\n" + "=" * 60)
print("PIPELINE CNN COMPLETO DE PRODUÇÃO")
print("=" * 60)

# CNN mais profunda para o dataset de 5 classes
class CNNProfunda(nn.Module):
    """CNN com arquitetura VGG-like para 5 classes."""

    def __init__(self, n_classes: int = 5):
        super().__init__()

        self.features = nn.Sequential(
            # Bloco 1: 28×28 → 14×14
            nn.Conv2d(1, 32, 3, padding=1),
            nn.BatchNorm2d(32), nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1),
            nn.BatchNorm2d(32), nn.ReLU(),
            nn.MaxPool2d(2, 2),
            nn.Dropout2d(0.1),

            # Bloco 2: 14×14 → 7×7
            nn.Conv2d(32, 64, 3, padding=1),
            nn.BatchNorm2d(64), nn.ReLU(),
            nn.Conv2d(64, 64, 3, padding=1),
            nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(2, 2),
            nn.Dropout2d(0.1),

            # Bloco 3: 7×7 → 1×1
            nn.Conv2d(64, 128, 3, padding=1),
            nn.BatchNorm2d(128), nn.ReLU(),
            nn.AdaptiveAvgPool2d(1),
        )

        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.4),
            nn.Linear(64, n_classes)
        )

        self._init_weights()

    def _init_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(
                    m.weight, mode="fan_out",
                    nonlinearity="relu"
                )
                if m.bias is not None:
                    nn.init.zeros_(m.bias)
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.ones_(m.weight)
                nn.init.zeros_(m.bias)
            elif isinstance(m, nn.Linear):
                nn.init.kaiming_normal_(m.weight)
                nn.init.zeros_(m.bias)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.classifier(self.features(x))

# Pipeline completo
cnn_prod = CNNProfunda(n_classes=5).to(device)
n_p_prod = sum(p.numel() for p in cnn_prod.parameters())
print(f"\nCNN Profunda: {n_p_prod:,} parâmetros")

opt_prod  = optim.AdamW(cnn_prod.parameters(),
                           lr=1e-3, weight_decay=1e-4)
total_steps = len(dl_tr_cnn) * 40
sch_prod  = optim.lr_scheduler.OneCycleLR(
    opt_prod, max_lr=5e-3,
    total_steps=total_steps, pct_start=0.2
)

melhor_acc_prod = 0
hist_prod = {"treino_acc": [], "val_acc": []}

print(f"Treinando CNN Profunda por 40 épocas...")

for epoca in range(40):
    cnn_prod.train()
    n_corr_tr, n_tot_tr = 0, 0
    for X_b, y_b in dl_tr_cnn:
        X_b = X_b.to(device)
        y_b = y_b.to(device)
        opt_prod.zero_grad()
        logits = cnn_prod(X_b)
        loss   = criterion_cnn(logits, y_b)
        loss.backward()
        nn.utils.clip_grad_norm_(cnn_prod.parameters(), 1.0)
        opt_prod.step()
        sch_prod.step()
        preds = logits.argmax(1)
        n_corr_tr += (preds == y_b).sum().item()
        n_tot_tr  += len(y_b)

    cnn_prod.eval()
    n_corr_va, n_tot_va = 0, 0
    with torch.no_grad():
        for X_b, y_b in dl_va_cnn:
            X_b = X_b.to(device)
            y_b = y_b.to(device)
            preds = cnn_prod(X_b).argmax(1)
            n_corr_va += (preds == y_b).sum().item()
            n_tot_va  += len(y_b)

    acc_tr = n_corr_tr / n_tot_tr
    acc_va = n_corr_va / n_tot_va
    hist_prod["treino_acc"].append(acc_tr)
    hist_prod["val_acc"].append(acc_va)

    if acc_va > melhor_acc_prod:
        melhor_acc_prod = acc_va
        torch.save(cnn_prod.state_dict(), "cnn_prod_melhor.pt")

    if (epoca + 1) % 10 == 0:
        print(f"  Época {epoca+1:2d}/40 | "
              f"Acc Treino: {acc_tr:.4f} | "
              f"Acc Val: {acc_va:.4f}")

# Avaliar no teste
cnn_prod.load_state_dict(
    torch.load("cnn_prod_melhor.pt", weights_only=True)
)
cnn_prod.eval()
preds_prod, labels_prod = [], []
with torch.no_grad():
    for X_b, y_b in dl_te_cnn:
        X_b = X_b.to(device)
        p   = cnn_prod(X_b).argmax(1).cpu().numpy()
        preds_prod.extend(p)
        labels_prod.extend(y_b.numpy())

acc_prod_final = accuracy_score(labels_prod, preds_prod)
print(f"\nAcurácia final CNN Profunda: {acc_prod_final:.4f}")
print(f"\nRelatório completo:")
print(classification_report(
    labels_prod, preds_prod,
    target_names=nomes_classes
))

# Visualização final
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

ep = range(1, 41)
axes[0].plot(ep, hist_prod["treino_acc"],
              color="steelblue", linewidth=2, label="Treino")
axes[0].plot(ep, hist_prod["val_acc"],
              color="coral", linewidth=2,
              linestyle="--", label="Validação")
axes[0].set_title("CNN Profunda — Acurácia")
axes[0].set_xlabel("Época")
axes[0].legend()

# Comparação final de modelos
modelos_comp = {
    "MLP":           acc_mlp_te,
    "CNN Simples":   acc_te,
    "CNN Profunda":  acc_prod_final,
}
cores_comp = ["coral", "steelblue", "mediumseagreen"]
barras_c = axes[1].bar(
    modelos_comp.keys(),
    modelos_comp.values(),
    color=cores_comp, alpha=0.85
)
axes[1].set_ylim(0.5, 1.05)
axes[1].set_title("Comparação: MLP vs CNN")
axes[1].set_ylabel("Acurácia no Teste")
for b, v in zip(barras_c, modelos_comp.values()):
    axes[1].text(b.get_x() + b.get_width()/2,
                  v + 0.01, f"{v:.4f}",
                  ha="center", fontsize=10)

# Exemplos corretos vs errados
cnn_prod.eval()
corretos_imgs, errados_imgs = [], []
with torch.no_grad():
    for X_b, y_b in dl_te_cnn:
        X_b   = X_b.to(device)
        preds = cnn_prod(X_b).argmax(1).cpu()
        for i in range(len(y_b)):
            if preds[i] == y_b[i] and len(corretos_imgs) < 3:
                corretos_imgs.append((X_b[i].cpu(), y_b[i],
                                       preds[i]))
            elif preds[i] != y_b[i] and len(errados_imgs) < 3:
                errados_imgs.append((X_b[i].cpu(), y_b[i],
                                      preds[i]))
        if len(corretos_imgs) >= 3 and len(errados_imgs) >= 3:
            break

for col, (img, real, pred) in enumerate(corretos_imgs[:3]):
    ax = axes[2]
    break

axes[2].axis("off")
axes[2].text(0.5, 0.5,
              "Exemplos de predições\n"
              "ver console para detalhes",
              ha="center", va="center",
              transform=axes[2].transAxes,
              fontsize=11)

plt.suptitle("Pipeline CNN Completo", fontsize=13)
plt.tight_layout()
plt.show()

Resumo da Aula

  • Convolução aplica filtros deslizantes que detectam padrões locais: bordas, texturas, formas
  • A dimensão de saída segue: (H - k + 2p)/s + 1
  • MaxPool reduz dimensões espaciais preservando features mais proeminentes
  • BatchNorm2d normaliza por canal, estabilizando o treinamento
  • CNNs têm muito menos parâmetros que MLPs para imagens graças ao compartilhamento de pesos
  • AdaptiveAvgPool2d cria uma camada que reduz para tamanho fixo independente do input
  • Data augmentation expande o dataset virtualmente aplicando transformações aleatórias
  • Transfer learning reutiliza representações aprendidas em datasets grandes para novos problemas
  • Feature extraction congela o backbone, fine-tuning treina total ou parcialmente
  • CNNs superam MLPs em imagens por explorar a estrutura espacial (localidade e tradução invariância)
  • Em produção, salvar o modelo completo com parâmetros de pré-processamento é essencial

Exercícios

  1. Calcule a dimensão de saída para: entrada 224×224, kernel 3×3, padding=1, stride=1. Depois com stride=2. Por que stride=2 é uma alternativa ao MaxPool?

    ✓ Resposta:

    Com stride=1: (224 - 3 + 2×1)/1 + 1 = 224. A imagem mantém o tamanho (same convolution).

    Com stride=2: (224 - 3 + 2×1)/2 + 1 = 112. A dimensão é reduzida à metade.

    Stride=2 é uma alternativa ao MaxPool porque ambos reduzem a resolução espacial por um fator de 2. A diferença: MaxPool é uma operação não paramétrica que apenas seleciona o máximo de cada janela, enquanto stride=2 é uma convolução aprendível que pode selecionar quais informações preservar. Arquiteturas modernas como ResNet usam stride=2 em vez de MaxPool na maioria dos blocos porque é diferenciável e permite que a rede aprenda a melhor forma de fazer downsampling para sua tarefa específica.

  2. Explique o conceito de compartilhamento de pesos em CNNs. Por que isso é fundamental para processar imagens? Compare o número de parâmetros de uma camada Conv2d(1, 32, 3) aplicada a uma imagem 28×28 com uma camada Linear equivalente.

    ✓ Resposta:

    Compartilhamento de pesos significa que o mesmo filtro (kernel) é aplicado em todas as posições espaciais da imagem. Um filtro de bordas que funciona no canto superior esquerdo da imagem é exatamente o mesmo filtro aplicado no centro e no canto inferior direito.

    Isso faz sentido para imagens porque features úteis (bordas, texturas) aparecem em qualquer posição. Uma borda horizontal no topo tem a mesma importância que uma borda horizontal no meio.

    Comparação de parâmetros: - Conv2d(1, 32, 3): 32 filtros × (1×3×3 pesos + 1 bias) = 320 parâmetros, independente do tamanho da imagem - Linear equivalente (28×28→28×28×32): 784 × 25.088 = 19.669.032 parâmetros

    A diferença é de 60.000× em favor da CNN. E além disso, a CNN aprende detecção de bordas que generaliza para qualquer posição, enquanto o MLP aprenderia "há uma borda nessa coordenada específica" — sem generalização espacial.

  3. O que é Global Average Pooling (GAP) e por que ele é preferido a Flatten + Linear em arquiteturas modernas? Quais vantagens ele oferece em termos de parâmetros e generalização?

    ✓ Resposta:

    Global Average Pooling calcula a média de cada feature map inteiro, produzindo um único valor por canal. Para uma saída de (batch, 128, 7, 7), GAP produz (batch, 128) — independente do tamanho do feature map.

    Vantagens sobre Flatten + Linear:

    Parâmetros: GAP não tem parâmetros aprendíveis. Uma alternativa Flatten(128×7×7=6272) → Linear(6272, 256) teria 6272×256 = 1.6M parâmetros adicionais.

    Generalização: GAP força cada feature map a representar conceitualmente uma classe inteira, funcionando como um regularizador. O modelo não pode usar posições específicas no feature map para tomar decisões.

    Flexibilidade: como GAP funciona com qualquer tamanho de feature map, o modelo pode processar imagens de tamanhos diferentes sem modificação. Um classificador Flatten + Linear exige tamanho fixo de entrada.

  4. Explique as três estratégias de transfer learning: feature extraction, fine-tuning parcial e fine-tuning completo. Em qual situação você escolheria cada uma? O que considera ao definir o learning rate para fine-tuning?

    ✓ Resposta:

    Feature extraction: congela todos os pesos do backbone pré-treinado e treina apenas uma nova cabeça de classificação. Use quando: o dataset novo é pequeno (< 1000 exemplos por classe), o domínio é similar ao pré-treinamento, e você quer treino rápido sem risco de overfitting no backbone.

    Fine-tuning parcial: congela as primeiras camadas (que aprendem features genéricas como bordas) e treina as últimas camadas convolucional junto com a nova cabeça. Use quando: o dataset é médio (alguns milhares de exemplos), o domínio tem alguma diferença do pré-treinamento (ex: imagens médicas vs ImageNet).

    Fine-tuning completo: treina toda a rede. Use quando: o dataset é grande o suficiente, o domínio é muito diferente do pré-treinamento, ou você precisa de performance máxima. Use learning rate muito baixo (10x a 100x menor que o normal) para não destruir as representações aprendidas.

    Para o learning rate no fine-tuning, a prática comum é usar "discriminative learning rates": LR menor nas primeiras camadas (ex: 1e-5) e gradualmente maior nas últimas (ex: 1e-3), porque as primeiras camadas têm representações mais genéricas e valiosas que não devem mudar muito.

  5. Por que data augmentation melhora a generalização? Quais transformações fazem sentido para imagens de documentos (fotos de formulários) vs imagens médicas (raios-X) vs imagens de satélite? Dê exemplos concretos de augmentations inadequadas para cada domínio.

    ✓ Resposta:

    Data augmentation funciona porque forçamos o modelo a aprender invariâncias. Ao treiná-lo com a mesma imagem rotacionada, espelhada ou com brilho alterado, ensinamos que essas variações não mudam a classe. O modelo aprende representações mais robustas e generaliza melhor para variações naturais nos dados reais.

    Para imagens de documentos (formulários): augmentations úteis incluem pequenas rotações (documentos podem estar ligeiramente inclinados), mudanças de brilho/contraste (diferentes condições de scan), e distorções leves de perspectiva. Augmentations inadequadas: espelhamento horizontal (inverte texto tornando-o ilegível), rotações grandes (documento de cabeça para baixo não tem o mesmo significado).

    Para imagens médicas (raios-X): augmentations úteis incluem pequenas rotações (posicionamento do paciente varia), zoom suave, e ajustes de contraste (diferentes configurações do equipamento). Augmentations inadequadas: espelhamento horizontal em alguns casos (situs inversus é uma condição rara, não a norma), mudanças de cor extremas (tonalidade tem significado diagnóstico), e deformações elásticas excessivas (podem criar aparência de patologias inexistentes).

    Para imagens de satélite: augmentations úteis incluem rotações de 90° (não há "cima" absoluto em imagens de cima), espelhamento (simetria), e variações de brilho (diferentes horários e condições de nuvem). Augmentations inadequadas: distorções de perspectiva fortes (deformam a escala geográfica), mudanças de cor saturadas (índices espectrais como NDVI têm significado físico preciso).

  6. Explique a hierarquia de features em CNNs. O que as camadas iniciais, intermediárias e finais tendem a detectar? Como o conceito de campo receptivo (receptive field) cresce ao longo das camadas?

    ✓ Resposta:

    CNNs aprendem uma hierarquia de representações visuais:

    Camadas iniciais (1-2): detectam features de baixo nível independentes da tarefa — bordas em diferentes orientações (horizontal, vertical, diagonal), gradientes de cor, e texturas simples como pontos e listras. Essas features são praticamente universais e são por isso que as primeiras camadas são frequentemente congeladas no transfer learning.

    Camadas intermediárias (3-5): combinam as features básicas para detectar padrões mais complexos — cantos, círculos, texturas compostas, partes de objetos como olhos ou rodas. Essas representações começam a ser específicas para o domínio.

    Camadas finais (6+): detectam features de alto nível e objetos completos — rostos, carros, gatos. São altamente específicas para o dataset de treinamento.

    O campo receptivo (receptive field) é a região da imagem original que influencia um neurônio específico. Após uma camada com kernel 3×3, cada neurônio "vê" uma região 3×3. Após duas camadas 3×3, vê 5×5. Após três, vê 7×7. Com MaxPool ou stride=2 no meio, o crescimento é mais rápido. Nas últimas camadas de uma rede profunda, o campo receptivo pode cobrir toda a imagem — o neurônio integra informação de toda a cena para tomar sua decisão.

Referências