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
-
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.
-
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.
-
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.
-
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.
-
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).
-
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
- CS231n Stanford — CNNs
- PyTorch — Conv2d
- PyTorch — torchvision.models (modelos pré-treinados)
- PyTorch — Transfer Learning Tutorial
- Original LeNet-5 paper (LeCun, 1998)
- VGGNet paper (Simonyan & Zisserman, 2014)
- ResNet paper (He et al., 2016)
- 3Blue1Brown — But what is a convolution?
- Fast.ai — Practical Deep Learning for Coders