Objetivo

Dominar os tensores do PyTorch e o sistema de diferenciação automática (autograd). Entender como o PyTorch implementa automaticamente o backpropagation que construímos manualmente na aula anterior, e como usar GPU para acelerar o treinamento.


1. Por que PyTorch?

Nas aulas 18 e 19 implementamos redes neurais e backpropagation do zero com NumPy. Isso foi fundamental para entender o que acontece internamente. Na prática, porém, usar NumPy puro tem limitações sérias:

  • Backprop manual é propenso a erros e difícil de manter
  • Sem suporte nativo a GPU (que acelera treino em 10x a 1000x)
  • Sem diferenciação automática para arquiteturas complexas

O PyTorch resolve tudo isso. É o framework dominante em pesquisa de IA e amplamente usado em produção. Sua filosofia é "Pythonic": código imperativo, debugging com ferramentas padrão Python, grafos dinâmicos que facilitam experimentação.


2. Instalação e Configuração

# Verificar instalação
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

print(f"PyTorch versão: {torch.__version__}")
print(f"CUDA disponível: {torch.cuda.is_available()}")

# Dispositivo: GPU se disponível, senão CPU
device = torch.device("cuda" if torch.cuda.is_available()
                       else "cpu")
print(f"Dispositivo usado: {device}")

# Seed para reprodutibilidade
torch.manual_seed(42)
np.random.seed(42)
sns.set_theme(style="whitegrid")

3. Tensores — O Objeto Central do PyTorch

Tensores são arrays N-dimensionais, conceitualmente idênticos aos arrays NumPy, mas com duas capacidades adicionais: execução em GPU e suporte a diferenciação automática.

3.1 Criando Tensores

# ── A partir de listas Python ────────────────────────────
t1 = torch.tensor([1.0, 2.0, 3.0, 4.0])
print(f"t1: {t1}")
print(f"  dtype: {t1.dtype}")
print(f"  shape: {t1.shape}")
print(f"  device: {t1.device}")

# ── A partir de arrays NumPy ─────────────────────────────
arr = np.array([[1.0, 2.0], [3.0, 4.0]])
t2  = torch.from_numpy(arr)           # compartilha memória!
t3  = torch.tensor(arr)               # cópia independente
print(f"\nt2 (from_numpy): {t2}")
print(f"t3 (cópia):      {t3}")

# Modificar arr afeta t2 mas não t3
arr[0, 0] = 99.0
print(f"\nApós arr[0,0]=99:")
print(f"  t2: {t2[0]}")   # afetado
print(f"  t3: {t3[0]}")   # não afetado

# ── Funções de criação ───────────────────────────────────
print("\nFunções de criação:")
print(torch.zeros(3, 4))
print(torch.ones(2, 3))
print(torch.eye(3))
print(torch.arange(0, 10, 2))          # [0, 2, 4, 6, 8]
print(torch.linspace(0, 1, 5))         # [0, 0.25, 0.5, 0.75, 1]
print(torch.full((2, 3), fill_value=7))

# Aleatórios
print(torch.rand(3, 3))                # uniforme [0, 1)
print(torch.randn(3, 3))               # normal(0, 1)
print(torch.randint(0, 10, (3, 3)))    # inteiros [0, 10)

3.2 Tipos de Dados (dtype)

print("Tipos de dados mais usados em ML:")

tipos = {
    "float32 (padrão em DL)": torch.float32,
    "float64 (alta precisão)": torch.float64,
    "int64":                   torch.int64,
    "bool":                    torch.bool,
    "float16 (mixed precision)": torch.float16,
}

for nome, dtype in tipos.items():
    t = torch.ones(3, dtype=dtype)
    print(f"  {nome:<30}: {t.dtype}, exemplo: {t[0]}")

# Conversão de tipos
x = torch.tensor([1, 2, 3])           # int64 por padrão
x_float = x.float()                    # → float32
x_double = x.double()                  # → float64
x_bool   = x.bool()                    # → bool

print(f"\nConversão: {x.dtype} → {x_float.dtype} → {x_bool.dtype}")

3.3 Operações com Tensores

a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
b = torch.tensor([[5.0, 6.0], [7.0, 8.0]])

# Operações elemento a elemento
print("Soma elemento a elemento:")
print(a + b)
print(torch.add(a, b))       # equivalente

print("\nMultiplicação elemento a elemento:")
print(a * b)
print(torch.mul(a, b))

print("\nDivisão:")
print(a / b)

# Produto matricial
print("\nProduto matricial (matmul):")
print(a @ b)
print(torch.matmul(a, b))    # equivalente
print(a.mm(b))                # equivalente para 2D

# Operações in-place (modificam o tensor)
c = torch.ones(2, 2)
c.add_(5)     # sufixo _ = in-place
print(f"\nIn-place add: {c}")

# Operações de redução
x = torch.tensor([[1.0, 2.0, 3.0],
                   [4.0, 5.0, 6.0]])
print(f"\nSoma total:    {x.sum()}")
print(f"Soma por col:  {x.sum(dim=0)}")    # soma ao longo das linhas
print(f"Soma por linha:{x.sum(dim=1)}")    # soma ao longo das colunas
print(f"Média:         {x.mean():.4f}")
print(f"Máximo:        {x.max()}")
print(f"Índice máximo: {x.argmax()}")
print(f"Máx por col:   {x.max(dim=0).values}")

3.4 Reshape e Manipulação de Forma

x = torch.arange(24, dtype=torch.float32)
print(f"Original: {x.shape}")

# view: reshape (requer memória contígua)
m = x.view(4, 6)
print(f"view(4,6): {m.shape}")

# reshape: mais flexível que view
t = x.reshape(2, 3, 4)
print(f"reshape(2,3,4): {t.shape}")

# -1 infere a dimensão
auto = x.reshape(6, -1)   # (6, 4)
print(f"reshape(6,-1): {auto.shape}")

# squeeze e unsqueeze
v = torch.ones(5)           # shape (5,)
v_col = v.unsqueeze(1)       # shape (5, 1)
v_row = v.unsqueeze(0)       # shape (1, 5)
print(f"\nunsqueeze: {v.shape} → {v_col.shape} / {v_row.shape}")

batch = torch.ones(1, 5, 3)  # shape (1, 5, 3)
sem_batch = batch.squeeze(0)  # shape (5, 3)
print(f"squeeze: {batch.shape} → {sem_batch.shape}")

# transpose e permute
m = torch.randn(3, 4)
mt = m.T              # transposta (só para 2D)
print(f"\ntranspose: {m.shape} → {mt.shape}")

t3d = torch.randn(2, 3, 4)
tp  = t3d.permute(0, 2, 1)   # troca dimensões 1 e 2
print(f"permute: {t3d.shape} → {tp.shape}")

# concatenação
a = torch.ones(2, 3)
b = torch.ones(2, 3)
print(f"\ncat dim=0: {torch.cat([a, b], dim=0).shape}")  # (4, 3)
print(f"cat dim=1: {torch.cat([a, b], dim=1).shape}")  # (2, 6)
print(f"stack:     {torch.stack([a, b], dim=0).shape}") # (2, 2, 3)

4. Tensores e GPU

print("Movendo tensores entre CPU e GPU:")

# Criar tensor na CPU
x_cpu = torch.randn(1000, 1000)
print(f"CPU: {x_cpu.device}")

# Mover para GPU (se disponível)
if torch.cuda.is_available():
    x_gpu = x_cpu.to(device)
    print(f"GPU: {x_gpu.device}")

    # Benchmark
    import time

    # CPU
    t0 = time.time()
    for _ in range(100):
        _ = x_cpu @ x_cpu.T
    t_cpu = time.time() - t0

    # GPU
    torch.cuda.synchronize()
    t0 = time.time()
    for _ in range(100):
        _ = x_gpu @ x_gpu.T
    torch.cuda.synchronize()
    t_gpu = time.time() - t0

    print(f"\nMultiplicação 1000×1000 (100 vezes):")
    print(f"  CPU: {t_cpu:.3f}s")
    print(f"  GPU: {t_gpu:.3f}s")
    print(f"  Speedup: {t_cpu/t_gpu:.1f}x")
else:
    print("  GPU não disponível — usando CPU")
    print("  (No Google Colab você pode habilitar GPU gratuita)")

# Boas práticas com device
def criar_tensor_no_device(shape, device):
    """Sempre especifique o device ao criar tensores."""
    return torch.randn(*shape, device=device)

# Mover modelo para device
# model = MinhaRede()
# model = model.to(device)
# x = x.to(device)

5. Autograd — Diferenciação Automática

O sistema autograd é o que torna o PyTorch revolucionário: ele rastreia todas as operações sobre tensores com requires_grad=True e calcula automaticamente os gradientes via backpropagation.

5.1 Como o Autograd Funciona

# requires_grad=True: "rastreie este tensor para calcular gradientes"
x = torch.tensor([2.0], requires_grad=True)

# Construir um grafo computacional
y = x ** 2          # y = x²
z = 3 * y + 1       # z = 3x² + 1

print(f"x = {x.data[0]}")
print(f"y = x² = {y.data[0]}")
print(f"z = 3x²+1 = {z.data[0]}")

# Calcular gradiente: dz/dx = 6x
# Em x=2: dz/dx = 6×2 = 12
z.backward()

print(f"\ndz/dx em x=2: {x.grad.item()}")
print(f"Esperado (6×2): {6*2}")

5.2 Grafo Computacional

# O grafo computacional é construído dinamicamente
a = torch.tensor(3.0, requires_grad=True)
b = torch.tensor(4.0, requires_grad=True)

# Operações constroem o grafo
c = a * b              # c = ab
d = c + a              # d = ab + a = a(b+1)
e = d ** 2             # e = a²(b+1)²

print("Grafo computacional:")
print(f"  a = {a.item()}, b = {b.item()}")
print(f"  c = a×b = {c.item()}")
print(f"  d = c+a = {d.item()}")
print(f"  e = d² = {e.item()}")

# Calcular gradientes
e.backward()

# Verificar matematicamente:
# e = a²(b+1)²
# ∂e/∂a = 2a(b+1)² = 2×3×(4+1)² = 150
# ∂e/∂b = 2a²(b+1) = 2×9×5 = 90
print(f"\n∂e/∂a = {a.grad.item():.1f}  (esperado: {2*3*(4+1)**2})")
print(f"∂e/∂b = {b.grad.item():.1f}  (esperado: {2*9*(4+1)})")

5.3 Cuidados com o Autograd

# ── Gradientes se acumulam! ──────────────────────────────
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)

y = (x ** 2).sum()
y.backward()
print(f"Primeira backward — x.grad: {x.grad}")   # [2, 4, 6]

# ERRO: rodar backward de novo SEM zerar os gradientes
y = (x ** 2).sum()
y.backward()
print(f"Segunda backward (acumulou!): {x.grad}")  # [4, 8, 12]

# CORRETO: zerar antes de cada backward
x.grad.zero_()
y = (x ** 2).sum()
y.backward()
print(f"Após zero_grad: {x.grad}")               # [2, 4, 6]

# ── torch.no_grad() — desabilitar rastreamento ──────────
# Usado em inferência: sem necessidade de gradientes = mais rápido
with torch.no_grad():
    resultado = x ** 2    # não rastreado
    print(f"\nno_grad: requires_grad = {resultado.requires_grad}")

# ── .detach() — separar tensor do grafo ─────────────────
y = x * 2
y_detach = y.detach()
print(f"y.requires_grad: {y.requires_grad}")
print(f"y_detach.requires_grad: {y_detach.requires_grad}")

# ── Gradientes para múltiplas saídas ────────────────────
x = torch.randn(3, requires_grad=True)
y = torch.tensor([2.0, 3.0, 4.0])

# Para vetores, precisamos fornecer gradient externo
loss = (x * y).sum()    # escalar — backward() funciona diretamente
loss.backward()
print(f"\n∂loss/∂x = {x.grad}")   # deve ser [2, 3, 4]

5.4 Grafo Dinâmico vs Estático

# PyTorch usa grafo DINÂMICO (define-by-run)
# O grafo é criado a cada forward pass — permite condicionais e loops!

def forward_dinamico(x, usar_relu=True):
    """O grafo muda dependendo do argumento."""
    W = torch.randn(3, 3, requires_grad=True)

    if usar_relu:
        return F.relu(x @ W)     # um grafo
    else:
        return torch.tanh(x @ W)  # outro grafo

x = torch.randn(2, 3)
out1 = forward_dinamico(x, usar_relu=True)
out2 = forward_dinamico(x, usar_relu=False)
print(f"Com ReLU:  {out1.shape}")
print(f"Com Tanh:  {out2.shape}")
print("Grafo dinâmico: o PyTorch se adapta à estrutura do código!")

6. Regressão Linear com Autograd

Agora vamos implementar regressão linear usando apenas tensores e autograd — sem nn.Module, para entender o fluxo completo.

torch.manual_seed(42)
np.random.seed(42)

# Dataset sintético: y = 2x + 1 + ruído
n = 200
X_np = np.random.uniform(-3, 3, (n, 1)).astype(np.float32)
y_np = (2 * X_np + 1 + np.random.normal(0, 0.5, (n, 1))).astype(np.float32)

X_t = torch.from_numpy(X_np)
y_t = torch.from_numpy(y_np)

# Parâmetros aprendíveis
W = torch.randn(1, 1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)

print(f"Parâmetros iniciais: W={W.item():.4f}, b={b.item():.4f}")
print(f"Valores reais:       W=2.0000, b=1.0000\n")

lr     = 0.1
epocas = 200
losses = []

for epoca in range(epocas):
    # ── Forward pass ───────────────────────────────────
    y_pred = X_t @ W + b     # ŷ = Wx + b

    # ── Calcular perda ─────────────────────────────────
    loss = ((y_pred - y_t) ** 2).mean()   # MSE
    losses.append(loss.item())

    # ── Backward pass (autograd calcula tudo!) ─────────
    loss.backward()

    # ── Atualizar parâmetros (sem rastreamento) ────────
    with torch.no_grad():
        W -= lr * W.grad
        b -= lr * b.grad

    # ── ZERAR GRADIENTES ──────────────────────────────
    W.grad.zero_()
    b.grad.zero_()

print(f"Parâmetros aprendidos: W={W.item():.4f}, b={b.item():.4f}")
print(f"Valores reais:         W=2.0000, b=1.0000")
print(f"Loss final: {losses[-1]:.4f}")

# Visualizar convergência e ajuste
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

axes[0].plot(losses, color="steelblue", linewidth=2)
axes[0].set_title("Convergência da Loss (MSE)")
axes[0].set_xlabel("Época")
axes[0].set_ylabel("MSE")
axes[0].set_yscale("log")

axes[1].scatter(X_np, y_np, alpha=0.4,
                color="steelblue", s=20, label="Dados")
x_line = np.linspace(-3, 3, 100).reshape(-1, 1).astype(np.float32)
with torch.no_grad():
    y_line = torch.from_numpy(x_line) @ W + b
axes[1].plot(x_line, y_line.numpy(), color="coral",
             linewidth=2.5, label=f"y={W.item():.2f}x+{b.item():.2f}")
axes[1].set_title("Regressão Linear com Autograd")
axes[1].set_xlabel("x")
axes[1].set_ylabel("y")
axes[1].legend()

plt.tight_layout()
plt.show()

7. Interoperabilidade NumPy ↔ PyTorch

print("Conversão NumPy ↔ PyTorch:")

# NumPy → Tensor
arr_np = np.random.randn(3, 4).astype(np.float32)
t_from_np = torch.from_numpy(arr_np)    # compartilha memória
t_tensor  = torch.tensor(arr_np)         # cópia

print(f"  np.ndarray → tensor: {arr_np.shape} → {t_from_np.shape}")

# Tensor → NumPy
t = torch.randn(3, 4)
arr = t.numpy()               # só funciona em CPU e sem grad
print(f"  tensor → np.ndarray: {t.shape} → {arr.shape}")

# Se tensor tiver gradiente ou estiver em GPU:
t_grad = torch.randn(3, 4, requires_grad=True)
arr_safe = t_grad.detach().cpu().numpy()
print(f"  tensor com grad → numpy: {arr_safe.shape}")

# Dicas práticas
print("\nDicas de conversão:")
print("  .item()    → Python scalar (de tensor de 1 elemento)")
print("  .tolist()  → lista Python")
print("  .numpy()   → array NumPy (CPU, sem grad)")
print("  .detach().cpu().numpy() → sempre funciona")

x_scalar = torch.tensor(3.14)
print(f"\n  .item():  {x_scalar.item()}, type={type(x_scalar.item())}")
print(f"  .tolist():{torch.tensor([1,2,3]).tolist()}")

8. Operações Avançadas de Tensores

# ── Indexação avançada ───────────────────────────────────
t = torch.arange(12, dtype=torch.float32).reshape(3, 4)
print("Tensor original:")
print(t)

# Indexação booleana
mascara = t > 6
print(f"\nElementos > 6: {t[mascara]}")

# Fancy indexing
indices = torch.tensor([0, 2])
print(f"\nLinhas 0 e 2:\n{t[indices]}")

# Where — seleção condicional
resultado = torch.where(t > 6, t, torch.zeros_like(t))
print(f"\nWhere (manter se >6, senão 0):\n{resultado}")

# ── Broadcasting ─────────────────────────────────────────
a = torch.ones(3, 1)    # shape (3, 1)
b = torch.ones(1, 4)    # shape (1, 4)
c = a + b               # shape (3, 4) — broadcasting!
print(f"\nBroadcasting: {a.shape} + {b.shape} = {c.shape}")

# ── Normalização de dados com tensores ───────────────────
dados = torch.randn(100, 5)

# Z-score por feature (coluna)
media  = dados.mean(dim=0, keepdim=True)    # shape (1, 5)
desvio = dados.std(dim=0, keepdim=True)     # shape (1, 5)
norm   = (dados - media) / (desvio + 1e-8)

print(f"\nNormalização Z-score:")
print(f"  Antes — média: {dados.mean():.4f}, std: {dados.std():.4f}")
print(f"  Depois — média: {norm.mean():.4f}, std: {norm.std():.4f}")

# ── clamp — limitar valores ───────────────────────────────
x = torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0])
x_clamped = x.clamp(min=-1.0, max=1.0)
print(f"\nclamp(-1, 1): {x_clamped}")

9. Primeiro Modelo com nn.Module

from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score

# Dataset
np.random.seed(42)
X_sk, y_sk = make_classification(
    n_samples=800, n_features=10,
    n_informative=6, n_redundant=2,
    random_state=42
)

X_tr_sk, X_te_sk, y_tr_sk, y_te_sk = train_test_split(
    X_sk, y_sk, test_size=0.2, random_state=42
)

scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr_sk).astype(np.float32)
X_te_s = scaler.transform(X_te_sk).astype(np.float32)

# Converter para tensores
X_tr_t = torch.from_numpy(X_tr_s)
y_tr_t = torch.from_numpy(y_tr_sk.astype(np.float32))
X_te_t = torch.from_numpy(X_te_s)
y_te_t = torch.from_numpy(y_te_sk.astype(np.float32))

class RedeSimples(nn.Module):
    """
    Primeiro modelo com nn.Module.
    nn.Module é a classe base para todos os modelos PyTorch.
    """

    def __init__(self, n_entrada: int,
                  n_oculta: int,
                  n_saida: int):
        super().__init__()   # obrigatório!

        # Definir camadas
        self.fc1 = nn.Linear(n_entrada, n_oculta)
        self.fc2 = nn.Linear(n_oculta, n_oculta // 2)
        self.fc3 = nn.Linear(n_oculta // 2, n_saida)
        self.relu    = nn.ReLU()
        self.dropout = nn.Dropout(p=0.2)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """Define o forward pass."""
        x = self.relu(self.fc1(x))
        x = self.dropout(x)
        x = self.relu(self.fc2(x))
        x = self.dropout(x)
        x = self.fc3(x)     # sem ativação — BCEWithLogitsLoss aplica sigmoid
        return x

# Instanciar modelo
model = RedeSimples(
    n_entrada=10,
    n_oculta=64,
    n_saida=1
)
model = model.to(device)

print("Arquitetura do modelo:")
print(model)
print(f"\nTotal de parâmetros: "
      f"{sum(p.numel() for p in model.parameters()):,}")
print(f"Parâmetros treináveis: "
      f"{sum(p.numel() for p in model.parameters() if p.requires_grad):,}")

# Loop de treinamento completo
criterion  = nn.BCEWithLogitsLoss()   # BCE com sigmoid embutida
optimizer  = optim.Adam(model.parameters(), lr=0.001)

n_epocas   = 100
batch_size = 32
hist_loss  = {"treino": [], "val": []}

# Dividir em treino e validação
n_tr    = int(0.8 * len(X_tr_t))
X_tr2_t = X_tr_t[:n_tr].to(device)
y_tr2_t = y_tr_t[:n_tr].to(device)
X_va_t  = X_tr_t[n_tr:].to(device)
y_va_t  = y_tr_t[n_tr:].to(device)

print(f"\nTreinando por {n_epocas} épocas...")

for epoca in range(n_epocas):

    # ── Modo treino ───────────────────────────────────
    model.train()   # ativa dropout, batch norm, etc.

    # Mini-batches
    perm   = torch.randperm(len(X_tr2_t))
    losses_batch = []

    for i in range(0, len(X_tr2_t), batch_size):
        idx    = perm[i:i + batch_size]
        Xb     = X_tr2_t[idx]
        yb     = y_tr2_t[idx]

        # Zero gradientes (OBRIGATÓRIO antes de cada backward)
        optimizer.zero_grad()

        # Forward
        logits = model(Xb).squeeze()

        # Calcular perda
        loss   = criterion(logits, yb)

        # Backward (autograd calcula tudo!)
        loss.backward()

        # Atualizar pesos
        optimizer.step()

        losses_batch.append(loss.item())

    # ── Modo avaliação ────────────────────────────────
    model.eval()   # desativa dropout, batch norm, etc.
    with torch.no_grad():
        logits_tr = model(X_tr2_t).squeeze()
        logits_va = model(X_va_t).squeeze()
        loss_tr   = criterion(logits_tr, y_tr2_t).item()
        loss_va   = criterion(logits_va, y_va_t).item()

    hist_loss["treino"].append(loss_tr)
    hist_loss["val"].append(loss_va)

    if (epoca + 1) % 20 == 0:
        print(f"  Época {epoca+1:3d}/{n_epocas} — "
              f"Loss Treino: {loss_tr:.4f}, "
              f"Loss Val: {loss_va:.4f}")

# Avaliação no teste
model.eval()
with torch.no_grad():
    X_te_dev   = X_te_t.to(device)
    logits_te  = model(X_te_dev).squeeze()
    probs_te   = torch.sigmoid(logits_te).cpu().numpy()
    preds_te   = (probs_te >= 0.5).astype(int)

acc_te = accuracy_score(y_te_sk, preds_te)
print(f"\nAcurácia no teste: {acc_te:.4f}")

# Plotar curvas de aprendizado
fig, ax = plt.subplots(figsize=(10, 5))
epocas_plot = range(1, n_epocas + 1)
ax.plot(epocas_plot, hist_loss["treino"],
        label="Treino", color="steelblue", linewidth=2)
ax.plot(epocas_plot, hist_loss["val"],
        label="Validação", color="coral",
        linewidth=2, linestyle="--")
ax.set_xlabel("Época")
ax.set_ylabel("BCE Loss")
ax.set_title("Treinamento com PyTorch (nn.Module)")
ax.legend()
plt.tight_layout()
plt.show()

10. Exemplo Completo: Pipeline PyTorch com DataLoader

print("=" * 60)
print("PIPELINE COMPLETO COM PYTORCH E DATALOADER")
print("=" * 60)

torch.manual_seed(42)
np.random.seed(42)

# Dataset maior
from sklearn.datasets import make_classification
X_big, y_big = make_classification(
    n_samples=2000, n_features=15,
    n_informative=8, n_redundant=3,
    n_clusters_per_class=2,
    random_state=42
)

X_tr_b, X_te_b, y_tr_b, y_te_b = train_test_split(
    X_big, y_big, test_size=0.2,
    random_state=42, stratify=y_big
)
X_tr_b, X_va_b, y_tr_b, y_va_b = train_test_split(
    X_tr_b, y_tr_b, test_size=0.15,
    random_state=42, stratify=y_tr_b
)

sc_big = StandardScaler()
X_tr_b = sc_big.fit_transform(X_tr_b).astype(np.float32)
X_va_b = sc_big.transform(X_va_b).astype(np.float32)
X_te_b = sc_big.transform(X_te_b).astype(np.float32)

print(f"\nDataset: {X_big.shape}")
print(f"Treino: {len(X_tr_b)}, Val: {len(X_va_b)}, Teste: {len(X_te_b)}")

# Criar TensorDatasets e DataLoaders
def criar_dataloaders(X_tr, y_tr, X_va, y_va,
                       batch_size=64):
    """Cria DataLoaders para treino e validação."""
    ds_tr = TensorDataset(
        torch.from_numpy(X_tr),
        torch.from_numpy(y_tr.astype(np.float32))
    )
    ds_va = TensorDataset(
        torch.from_numpy(X_va),
        torch.from_numpy(y_va.astype(np.float32))
    )
    dl_tr = DataLoader(ds_tr, batch_size=batch_size,
                        shuffle=True,  drop_last=False)
    dl_va = DataLoader(ds_va, batch_size=batch_size,
                        shuffle=False, drop_last=False)
    return dl_tr, dl_va

dl_tr_b, dl_va_b = criar_dataloaders(
    X_tr_b, y_tr_b, X_va_b, y_va_b, batch_size=64
)

# Modelo mais sofisticado com Sequential
class RedeClassificadora(nn.Module):
    def __init__(self, n_entrada: int):
        super().__init__()
        self.rede = nn.Sequential(
            nn.Linear(n_entrada, 128),
            nn.BatchNorm1d(128),
            nn.ReLU(),
            nn.Dropout(0.3),

            nn.Linear(128, 64),
            nn.BatchNorm1d(64),
            nn.ReLU(),
            nn.Dropout(0.2),

            nn.Linear(64, 32),
            nn.ReLU(),

            nn.Linear(32, 1)
        )

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

model_big = RedeClassificadora(n_entrada=15).to(device)
n_params  = sum(p.numel() for p in model_big.parameters())
print(f"\nModelo com BatchNorm: {n_params:,} parâmetros")

# Treinamento com DataLoader
criterion_b = nn.BCEWithLogitsLoss()
optimizer_b = optim.Adam(model_big.parameters(),
                           lr=0.001, weight_decay=1e-4)
scheduler   = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer_b, patience=10, factor=0.5, verbose=False
)

n_epocas_b  = 100
hist_b      = {"treino": [], "val": []}
melhor_val  = float("inf")
patience    = 20
sem_melhora = 0

print(f"\nTreinando por até {n_epocas_b} épocas...")

for epoca in range(n_epocas_b):

    # ── Treino ────────────────────────────────────────
    model_big.train()
    losses_tr_ep = []

    for Xb, yb in dl_tr_b:
        Xb = Xb.to(device)
        yb = yb.to(device)

        optimizer_b.zero_grad()
        logits = model_big(Xb).squeeze()
        loss   = criterion_b(logits, yb)
        loss.backward()
        optimizer_b.step()
        losses_tr_ep.append(loss.item())

    # ── Validação ─────────────────────────────────────
    model_big.eval()
    losses_va_ep = []

    with torch.no_grad():
        for Xb, yb in dl_va_b:
            Xb     = Xb.to(device)
            yb     = yb.to(device)
            logits = model_big(Xb).squeeze()
            loss   = criterion_b(logits, yb)
            losses_va_ep.append(loss.item())

    loss_tr_ep = np.mean(losses_tr_ep)
    loss_va_ep = np.mean(losses_va_ep)

    hist_b["treino"].append(loss_tr_ep)
    hist_b["val"].append(loss_va_ep)

    # LR scheduler
    scheduler.step(loss_va_ep)

    # Early stopping
    if loss_va_ep < melhor_val:
        melhor_val  = loss_va_ep
        sem_melhora = 0
        # Salvar melhor modelo
        torch.save(model_big.state_dict(), "melhor_modelo.pt")
    else:
        sem_melhora += 1
        if sem_melhora >= patience:
            print(f"\n  Early stopping na época {epoca+1}")
            break

    if (epoca + 1) % 20 == 0:
        lr_atual = optimizer_b.param_groups[0]["lr"]
        print(f"  Época {epoca+1:3d} — "
              f"Loss Treino: {loss_tr_ep:.4f}, "
              f"Loss Val: {loss_va_ep:.4f}, "
              f"LR: {lr_atual:.6f}")

# Carregar melhor modelo
model_big.load_state_dict(torch.load("melhor_modelo.pt",
                                       weights_only=True))

# Avaliação final
model_big.eval()
with torch.no_grad():
    X_te_bt = torch.from_numpy(X_te_b).to(device)
    logits_te_b = model_big(X_te_bt).squeeze()
    probs_te_b  = torch.sigmoid(logits_te_b).cpu().numpy()
    preds_te_b  = (probs_te_b >= 0.5).astype(int)

acc_big = accuracy_score(y_te_b, preds_te_b)
print(f"\nAcurácia no teste (melhor modelo): {acc_big:.4f}")

# Visualizações
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

ep_plot = range(1, len(hist_b["treino"]) + 1)
axes[0].plot(ep_plot, hist_b["treino"],
              label="Treino", color="steelblue", linewidth=2)
axes[0].plot(ep_plot, hist_b["val"],
              label="Validação", color="coral",
              linewidth=2, linestyle="--")
axes[0].set_xlabel("Época")
axes[0].set_ylabel("BCE Loss")
axes[0].set_title("Curvas de Aprendizado (PyTorch)")
axes[0].legend()

# Distribuição das probabilidades
axes[1].hist(probs_te_b[y_te_b == 0], bins=25,
              alpha=0.6, color="steelblue",
              label="Classe 0", edgecolor="white")
axes[1].hist(probs_te_b[y_te_b == 1], bins=25,
              alpha=0.6, color="coral",
              label="Classe 1", edgecolor="white")
axes[1].axvline(x=0.5, color="black",
                 linestyle="--", linewidth=1.5)
axes[1].set_xlabel("Probabilidade Predita")
axes[1].set_ylabel("Frequência")
axes[1].set_title("Distribuição de Probabilidades no Teste")
axes[1].legend()

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

# Salvando e carregando
print("\nSalvando modelo completo...")
torch.save({
    "epoch":       n_epocas_b,
    "state_dict":  model_big.state_dict(),
    "optimizer":   optimizer_b.state_dict(),
    "val_loss":    melhor_val,
    "acuracia":    acc_big,
}, "modelo_completo.pt")

checkpoint = torch.load("modelo_completo.pt",
                          weights_only=False)
print(f"  Salvo! Melhor val_loss: {checkpoint['val_loss']:.4f}")
print(f"  Acurácia no teste: {checkpoint['acuracia']:.4f}")

Resumo da Aula

  • Tensores são o objeto central do PyTorch: arrays N-dimensionais com suporte a GPU e autograd
  • torch.from_numpy compartilha memória; torch.tensor cria cópia independente
  • .to(device) move tensores e modelos entre CPU e GPU
  • requires_grad=True ativa rastreamento para diferenciação automática
  • .backward() calcula todos os gradientes automaticamente via regra da cadeia
  • Gradientes se acumulam — sempre chame optimizer.zero_grad() antes de backward()
  • Modo treino vs avaliação: model.train() ativa dropout/batchnorm, model.eval() desativa
  • with torch.no_grad() desabilita autograd para inferência — mais rápido e usa menos memória
  • nn.Module é a classe base para modelos: define arquitetura em __init__ e forward em forward
  • DataLoader gerencia batches, shuffle e paralelismo de carregamento de dados
  • torch.save / torch.load salvam e carregam modelos e checkpoints
  • O loop de treinamento PyTorch tem 5 passos fixos: zero_grad → forward → loss → backward → step

Exercícios

  1. Explique a diferença entre torch.from_numpy e torch.tensor. Quando o uso incorreto de um pode causar bugs difíceis de encontrar? Dê um exemplo concreto.

    ✓ Resposta:

    torch.from_numpy(arr) cria um tensor que compartilha a mesma memória do array NumPy. Modificar o array NumPy modifica o tensor e vice-versa — zero cópia, mais eficiente.

    torch.tensor(arr) sempre cria uma cópia independente. Modificações no array original não afetam o tensor.

    Exemplo de bug difícil de encontrar: você usa from_numpy para criar tensores de treino, depois normaliza os dados in-place no array NumPy original (talvez calculando estatísticas para outro propósito), e os tensores de treino são silenciosamente modificados. O modelo começa a receber dados diferentes dos esperados sem nenhum erro explícito. O treinamento degrada sutilmente e você passa horas procurando o bug.

    A regra prática: use from_numpy quando performance é crítica e você tem certeza que o array não será modificado. Use torch.tensor no geral para segurança.

  2. Por que é obrigatório chamar optimizer.zero_grad() antes de cada backward()? O que aconteceria se você esquecer? Mostre com um exemplo numérico o que ocorre quando gradientes se acumulam.

    ✓ Resposta:

    PyTorch acumula gradientes por design — cada chamada a backward() adiciona ao gradiente existente em vez de substituir. Isso é útil em alguns cenários (como gradient accumulation para simular batches maiores), mas no uso padrão causa problemas sérios.

    Exemplo numérico: parâmetro W com gradiente real = 2.0.

    Época 1: backward() → W.grad = 2.0, step() atualiza W
    Época 2 (sem zero_grad): backward() → W.grad = 2.0 + 2.0 = 4.0
      → step() aplica 2× o gradiente correto
    Época 3 (sem zero_grad): backward() → W.grad = 4.0 + 2.0 = 6.0
      → step() aplica 3× o gradiente correto
    

    O learning rate efetivo cresce a cada época, causando instabilidade e divergência do treinamento. Os pesos oscilam cada vez mais violentamente e a loss explode.

  3. Explique a diferença entre model.train() e model.eval(). Quais camadas se comportam diferente nos dois modos? Por que isso importa para a avaliação correta do modelo?

    ✓ Resposta:

    model.train() coloca o modelo em modo de treinamento. model.eval() coloca em modo de avaliação. As camadas que se comportam diferente são:

    Dropout: em modo treino, desliga neurônios aleatoriamente com probabilidade p. Em modo avaliação, todos os neurônios são usados e as ativações são escaladas implicitamente pela probabilidade (1-p) para manter a magnitude esperada.

    BatchNormalization: em modo treino, normaliza usando a média e variância do batch atual. Em modo avaliação, usa a média e variância acumuladas (running statistics) calculadas durante o treino.

    Se você avaliar no modo treino, o Dropout desligará neurônios aleatoriamente mesmo durante a avaliação, tornando as predições não determinísticas. Cada chamada ao modelo com os mesmos dados retornará resultados ligeiramente diferentes. A performance reportada será inconsistente e geralmente pior que a performance real.

  4. O que é BCEWithLogitsLoss e por que é preferida sobre aplicar sigmoid manualmente e usar BCELoss? Qual é a vantagem numérica?

    ✓ Resposta:

    BCEWithLogitsLoss combina sigmoid e BCE em uma única operação: L = -[y·log(σ(x)) + (1-y)·log(1-σ(x))].

    A vantagem numérica está na implementação: usar sigmoid separadamente e depois BCE cria problemas de underflow/overflow. Para logits muito positivos, σ(x) ≈ 1.0 representado em float32, e log(1.0) = 0 com erro de arredondamento. Para logits muito negativos, σ(x) ≈ 0.0, e log(0.0) = -inf.

    BCEWithLogitsLoss usa o truque de log-sum-exp que é numericamente estável em toda a faixa de logits reais: max(x, 0) - x·y + log(1 + exp(-|x|)). Esta formulação nunca causa overflow ou underflow em float32 para valores razoáveis de logits, ao contrário da composição sigmoid + BCE.

  5. Explique o que é um DataLoader e quais problemas ele resolve. Quais são os parâmetros shuffle, batch_size e drop_last e quando você ajustaria cada um?

    ✓ Resposta:

    DataLoader é um iterador que resolve três problemas: gerencia a divisão dos dados em mini-batches automaticamente, opcionalmente embaralha os dados a cada época, e pode paralelizar o carregamento de dados do disco com múltiplos workers.

    shuffle=True: embaralha os índices do dataset a cada época. Essencial no treino para que o modelo não memorize a ordem dos batches. Deve ser False na validação e teste para resultados reproduzíveis.

    batch_size: quantos exemplos por batch. Batches maiores (128, 256) dão gradientes mais estáveis mas usam mais memória. Batches menores (16, 32) são mais ruidosos mas frequentemente generalizam melhor e treinam mais rápido por época. O valor 32 ou 64 é o ponto de partida padrão.

    drop_last=True: descarta o último batch se tiver menos exemplos que batch_size. Útil quando BatchNormalization é usada, pois batch de tamanho 1 causaria divisão por zero na normalização. No treino com BatchNorm, use drop_last=True. Na validação/teste, use False para avaliar todos os exemplos.

  6. Descreva o loop de treinamento padrão do PyTorch com seus 5 passos essenciais. Por que a ordem importa? O que aconteceria se você chamasse optimizer.step() antes de loss.backward()?

    ✓ Resposta:

    Os 5 passos do loop de treinamento PyTorch são:

    1. optimizer.zero_grad(): zera todos os gradientes acumulados. Deve ser o primeiro passo de cada iteração.

    2. logits = model(X_batch): forward pass — calcula predições e constrói o grafo computacional.

    3. loss = criterion(logits, y_batch): calcula a perda escalar que será minimizada.

    4. loss.backward(): backpropagation — percorre o grafo de trás para frente calculando ∂loss/∂w para cada parâmetro via regra da cadeia.

    5. optimizer.step(): usa os gradientes calculados para atualizar os pesos: w = w - lr × ∂loss/∂w.

    A ordem importa criticamente. Se você chamar optimizer.step() antes de loss.backward(), os gradientes ainda serão os da iteração anterior (ou zero na primeira iteração). Os pesos serão atualizados na direção errada ou com magnitude incorreta. O modelo não aprenderá ou aprenderá erraticamente. Em particular, a chamada backward() popula o atributo .grad de cada parâmetro — sem isso, optimizer.step() não tem informação para fazer uma atualização significativa.

Referências