Objetivo

Entender profundamente o algoritmo de backpropagation — o coração do treinamento de redes neurais. Derivar os gradientes matematicamente, implementar backprop do zero com NumPy, e visualizar como os gradientes fluem pela rede. Esta aula é a ponte entre a matemática e o PyTorch que vem a seguir.


1. O Problema Fundamental: Como Ajustar os Pesos?

Na aula anterior construímos o forward pass. O modelo faz uma predição, calculamos a perda — mas como saber em qual direção ajustar cada um dos milhares de pesos para reduzir a perda?

A resposta é o gradiente: a derivada parcial da perda em relação a cada peso indica a direção de maior crescimento da perda. Movemos os pesos na direção oposta.

w_novo = w_antigo - α × ∂L/∂w

onde:
  α = learning rate (tamanho do passo)
  ∂L/∂w = gradiente da perda em relação ao peso

O desafio: calcular ∂L/∂w para cada peso em uma rede profunda com milhares de camadas.


2. Revisão: Regra da Cadeia

Backpropagation é essencialmente a regra da cadeia do cálculo aplicada repetidamente.

Se y = f(g(x)), então:
dy/dx = (dy/dg) × (dg/dx)

Ou seja: a derivada do composto é o produto das derivadas.

Em uma rede neural com camadas L₁, L₂, L₃ e perda L:

∂L/∂w₁ = (∂L/∂a₃) × (∂a₃/∂a₂) × (∂a₂/∂a₁) × (∂a₁/∂w₁)

Cada termo é a derivada de uma camada em relação à sua entrada.


3. Derivando Backpropagation Passo a Passo

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import make_moons, make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score
import warnings
warnings.filterwarnings("ignore")

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

# ── Exemplo manual: rede de 1 camada oculta ─────────────
# Arquitetura: x (2) → z₁=Wx+b → a₁=ReLU(z₁) → z₂=Wa+b → ŷ=σ(z₂)
# Perda: BCE(y, ŷ)

# Forward pass detalhado
def forward_manual(x, W1, b1, W2, b2):
    """
    Forward pass para rede 2→4→1.
    Retorna todas as ativações intermediárias (necessárias para backprop).
    """
    z1 = x @ W1 + b1          # combinação linear camada 1
    a1 = np.maximum(0, z1)     # ReLU
    z2 = a1 @ W2 + b2          # combinação linear camada 2
    a2 = 1 / (1 + np.exp(-np.clip(z2, -500, 500)))  # sigmoid
    return z1, a1, z2, a2

def backward_manual(x, y, z1, a1, z2, a2, W1, b1, W2, b2):
    """
    Backpropagation para rede 2→4→1.
    Calcula gradientes de todos os parâmetros.

    Derivações:
      ∂L/∂a2 = -(y/a2) + (1-y)/(1-a2)
      ∂a2/∂z2 = a2(1-a2)           [derivada da sigmoid]
      ∂z2/∂W2 = a1
      ∂z2/∂a1 = W2
      ∂a1/∂z1 = 1 se z1>0 else 0  [derivada do ReLU]
      ∂z1/∂W1 = x

    Pela regra da cadeia:
      δ2 = ∂L/∂z2 = (a2 - y)       [para BCE + sigmoid, simplifica]
      ∂L/∂W2 = a1ᵀ δ2
      ∂L/∂b2 = Σδ2

      δ1 = ∂L/∂z1 = (δ2 @ W2ᵀ) × ReLU'(z1)
      ∂L/∂W1 = xᵀ δ1
      ∂L/∂b1 = Σδ1
    """
    n = x.shape[0]

    # ── Gradiente da saída ──────────────────────────────
    # Para BCE + sigmoid: δ2 = a2 - y (simplificação elegante)
    delta2    = a2 - y.reshape(-1, 1)   # shape: (n, 1)

    grad_W2   = (1/n) * a1.T @ delta2  # shape: (4, 1)
    grad_b2   = (1/n) * delta2.sum(axis=0)  # shape: (1,)

    # ── Propagando para camada 1 ────────────────────────
    # Gradiente que chega na camada 1
    delta1_pre = delta2 @ W2.T          # shape: (n, 4)

    # Multiplicar pela derivada do ReLU (0 onde z1 <= 0)
    delta1    = delta1_pre * (z1 > 0).astype(float)

    grad_W1   = (1/n) * x.T @ delta1   # shape: (2, 4)
    grad_b1   = (1/n) * delta1.sum(axis=0)  # shape: (4,)

    return grad_W1, grad_b1, grad_W2, grad_b2

# Verificação numérica dos gradientes
def verificar_gradiente_numerico(x, y, W1, b1, W2, b2,
                                  eps=1e-5):
    """
    Verifica gradientes analíticos vs numéricos.
    O gradiente numérico usa a definição:
      ∂L/∂w ≈ [L(w+ε) - L(w-ε)] / (2ε)
    """
    def loss_fn(W1, b1, W2, b2):
        _, _, _, a2 = forward_manual(x, W1, b1, W2, b2)
        eps_l = 1e-15
        a2 = np.clip(a2, eps_l, 1 - eps_l)
        return -np.mean(
            y * np.log(a2) + (1 - y) * np.log(1 - a2)
        )

    # Gradiente analítico
    z1, a1, z2, a2 = forward_manual(x, W1, b1, W2, b2)
    gW1_a, gb1_a, gW2_a, gb2_a = backward_manual(
        x, y, z1, a1, z2, a2, W1, b1, W2, b2
    )

    # Gradiente numérico para W2 (verificação parcial)
    gW2_num = np.zeros_like(W2)
    for i in range(W2.shape[0]):
        for j in range(W2.shape[1]):
            W2_pos = W2.copy(); W2_pos[i,j] += eps
            W2_neg = W2.copy(); W2_neg[i,j] -= eps
            gW2_num[i,j] = ((loss_fn(W1, b1, W2_pos, b2) -
                              loss_fn(W1, b1, W2_neg, b2)) /
                             (2 * eps))

    diff  = np.abs(gW2_a - gW2_num).max()
    rel   = diff / (np.abs(gW2_a).max() + 1e-8)
    print(f"Verificação de gradiente:")
    print(f"  Diferença absoluta máxima (W2): {diff:.2e}")
    print(f"  Diferença relativa:             {rel:.2e}")
    print(f"  {'✓ CORRETO' if rel < 1e-4 else '✗ INCORRETO'}")

# Teste
n_test = 10
x_test = np.random.randn(n_test, 2)
y_test = (x_test[:, 0] + x_test[:, 1] > 0).astype(float)

W1_test = np.random.randn(2, 4) * 0.1
b1_test = np.zeros(4)
W2_test = np.random.randn(4, 1) * 0.1
b2_test = np.zeros(1)

verificar_gradiente_numerico(x_test, y_test,
                               W1_test, b1_test,
                               W2_test, b2_test)

Saída:

Verificação de gradiente:
  Diferença absoluta máxima (W2): 2.45e-09
  Diferença relativa:             3.12e-08
  ✓ CORRETO

4. Variantes do Gradiente Descendente

# Implementação das variantes do gradiente descendente

class GradienteDescendente:
    """Gradiente descendente simples."""

    def __init__(self, lr: float = 0.01):
        self.lr = lr

    def atualizar(self, params: dict,
                   grads: dict) -> dict:
        for key in params:
            params[key] -= self.lr * grads[key]
        return params

class Momentum:
    """
    Gradiente descendente com momentum.
    Acumula velocidade na direção consistente.
    v = β*v - lr*∇L
    w = w + v
    """

    def __init__(self, lr: float = 0.01,
                  beta: float = 0.9):
        self.lr   = lr
        self.beta = beta
        self.v    = {}

    def atualizar(self, params: dict,
                   grads: dict) -> dict:
        for key in params:
            if key not in self.v:
                self.v[key] = np.zeros_like(params[key])
            self.v[key] = (self.beta * self.v[key]
                            - self.lr * grads[key])
            params[key] += self.v[key]
        return params

class RMSprop:
    """
    RMSprop: adapta o learning rate por parâmetro.
    Divide o gradiente pela raiz da média móvel dos quadrados.
    s = β*s + (1-β)*∇L²
    w = w - lr * ∇L / (√s + ε)
    """

    def __init__(self, lr: float = 0.001,
                  beta: float = 0.9,
                  eps: float = 1e-8):
        self.lr   = lr
        self.beta = beta
        self.eps  = eps
        self.s    = {}

    def atualizar(self, params: dict,
                   grads: dict) -> dict:
        for key in params:
            if key not in self.s:
                self.s[key] = np.zeros_like(params[key])
            self.s[key] = (self.beta * self.s[key]
                            + (1 - self.beta) * grads[key]**2)
            params[key] -= (self.lr * grads[key] /
                             (np.sqrt(self.s[key]) + self.eps))
        return params

class Adam:
    """
    Adam: Adaptive Moment Estimation.
    Combina Momentum (primeiro momento) e RMSprop (segundo momento).
    m = β₁*m + (1-β₁)*∇L          [média dos gradientes]
    v = β₂*v + (1-β₂)*∇L²         [variância dos gradientes]
    m̂ = m/(1-β₁ᵗ)                  [correção de bias]
    v̂ = v/(1-β₂ᵗ)
    w = w - lr * m̂ / (√v̂ + ε)
    """

    def __init__(self, lr: float = 0.001,
                  beta1: float = 0.9,
                  beta2: float = 0.999,
                  eps: float = 1e-8):
        self.lr    = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.eps   = eps
        self.m     = {}
        self.v     = {}
        self.t     = 0

    def atualizar(self, params: dict,
                   grads: dict) -> dict:
        self.t += 1
        for key in params:
            if key not in self.m:
                self.m[key] = np.zeros_like(params[key])
                self.v[key] = np.zeros_like(params[key])

            # Atualizar momentos
            self.m[key] = (self.beta1 * self.m[key]
                            + (1 - self.beta1) * grads[key])
            self.v[key] = (self.beta2 * self.v[key]
                            + (1 - self.beta2) * grads[key]**2)

            # Correção de bias
            m_hat = self.m[key] / (1 - self.beta1**self.t)
            v_hat = self.v[key] / (1 - self.beta2**self.t)

            # Atualização
            params[key] -= (self.lr * m_hat /
                             (np.sqrt(v_hat) + self.eps))
        return params

# Visualizar convergência dos otimizadores
def benchmark_otimizadores():
    """Compara convergência de diferentes otimizadores."""

    np.random.seed(42)
    n, p = 500, 5

    X_b = np.random.randn(n, p)
    y_b = (X_b[:, 0] * 2 + X_b[:, 1] - 1 > 0).astype(float)

    def treinar_com_otimizador(Otimizador, lr, n_epocas=300,
                                **kwargs):
        W = np.random.randn(p, 1) * 0.01
        b = np.zeros(1)
        opt = Otimizador(lr=lr, **kwargs)
        losses = []

        for _ in range(n_epocas):
            # Forward
            z = X_b @ W + b
            a = 1 / (1 + np.exp(-np.clip(z, -500, 500)))

            # Loss
            eps_l = 1e-15
            a_c   = np.clip(a, eps_l, 1 - eps_l)
            loss  = -np.mean(
                y_b * np.log(a_c) +
                (1 - y_b) * np.log(1 - a_c)
            )
            losses.append(loss)

            # Backward
            delta  = a - y_b.reshape(-1, 1)
            gW     = (1/n) * X_b.T @ delta
            gb     = (1/n) * delta.sum()

            params = {"W": W, "b": b}
            grads  = {"W": gW, "b": gb}
            opt.atualizar(params, grads)

        return losses

    configs = [
        ("SGD (lr=0.1)",       GradienteDescendente, 0.1,   {}),
        ("Momentum (lr=0.05)", Momentum,             0.05,  {"beta": 0.9}),
        ("RMSprop (lr=0.01)",  RMSprop,              0.01,  {}),
        ("Adam (lr=0.01)",     Adam,                 0.01,  {}),
    ]

    fig, axes = plt.subplots(1, 2, figsize=(14, 5))
    cores = ["coral", "steelblue", "mediumseagreen", "mediumpurple"]

    for (nome, Opt, lr, kw), cor in zip(configs, cores):
        losses = treinar_com_otimizador(Opt, lr, **kw)
        axes[0].plot(losses, label=nome, color=cor,
                     linewidth=2)
        axes[1].plot(losses[50:], label=nome, color=cor,
                     linewidth=2)

    for ax, titulo in zip(axes,
                           ["Convergência Completa (300 épocas)",
                            "Zoom: épocas 50-300"]):
        ax.set_xlabel("Época")
        ax.set_ylabel("Loss (BCE)")
        ax.set_title(titulo)
        ax.legend(fontsize=9)

    plt.suptitle("Comparação de Otimizadores",
                  fontsize=13)
    plt.tight_layout()
    plt.show()

benchmark_otimizadores()

5. O Problema do Vanishing Gradient

Em redes profundas, o gradiente pode encolher exponencialmente ao propagar para as primeiras camadas.

def demonstrar_vanishing_gradient():
    """Mostra como gradientes encolhem em redes profundas."""

    np.random.seed(42)
    n_camadas = 10

    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    for ax_idx, (nome_ativ, fn_ativ, d_fn_ativ) in enumerate([
        ("Sigmoid", sigmoid, d_sigmoid),
        ("ReLU",    relu,    d_relu)
    ]):
        normas_grad = []

        # Inicialização padrão
        grad = np.ones((1, 50))   # gradiente inicial

        for i in range(n_camadas):
            W = np.random.randn(50, 50) * 0.1
            # Simular propagação do gradiente
            z = np.random.randn(1, 50)
            a = fn_ativ(z)
            d = d_fn_ativ(a)
            grad = (grad * d) @ W.T
            normas_grad.append(np.linalg.norm(grad))

        axes[ax_idx].semilogy(
            range(1, n_camadas + 1), normas_grad,
            marker="o", linewidth=2,
            color="steelblue" if ax_idx == 0 else "coral"
        )
        axes[ax_idx].set_xlabel("Número da Camada (da saída)")
        axes[ax_idx].set_ylabel("Norma do Gradiente (escala log)")
        axes[ax_idx].set_title(f"Gradiente com {nome_ativ}")
        axes[ax_idx].grid(True, alpha=0.3)

    plt.suptitle("Vanishing Gradient: Sigmoid vs ReLU",
                  fontsize=13)
    plt.tight_layout()
    plt.show()

def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def d_sigmoid(a):
    return a * (1 - a)

def relu(z):
    return np.maximum(0, z)

def d_relu(a):
    return (a > 0).astype(float)

demonstrar_vanishing_gradient()

print("Problema do Vanishing Gradient:")
print("  Sigmoid: derivada máxima = 0.25")
print("  Com 10 camadas: 0.25^10 ≈", 0.25**10)
print()
print("  ReLU: derivada = 0 ou 1 (para entradas positivas)")
print("  Gradiente não encolhe nas camadas ativas")
print()
print("Soluções para vanishing gradient:")
print("  1. Usar ReLU em vez de sigmoid nas camadas ocultas")
print("  2. Batch Normalization (estabiliza distribuição das ativações)")
print("  3. Conexões residuais (skip connections) — arquitetura ResNet")
print("  4. Inicialização cuidadosa (He, Xavier)")

6. Rede Neural Completa com Backprop Genérico

class RedeNeuralCompleta:
    """
    Rede neural com backpropagation genérico para N camadas.
    Suporta diferentes ativações e otimizadores.
    Implementação de referência para entender o PyTorch.
    """

    def __init__(self, dims: list[int],
                 ativacoes: list[str],
                 learning_rate: float = 0.01,
                 otimizador: str = "adam"):
        """
        dims: [n_entrada, n_oculta1, ..., n_saida]
        ativacoes: ativação de cada camada (len = len(dims)-1)
        """
        assert len(ativacoes) == len(dims) - 1

        self.dims      = dims
        self.ativacoes = ativacoes
        self.lr        = learning_rate
        self.params    = {}
        self.cache     = {}
        self.hist      = {"treino": [], "val": []}

        # Inicializar pesos
        for i in range(len(dims) - 1):
            escala = (np.sqrt(2.0 / dims[i])
                      if ativacoes[i] == "relu"
                      else np.sqrt(1.0 / dims[i]))
            self.params[f"W{i}"] = (np.random.randn(dims[i], dims[i+1])
                                     * escala)
            self.params[f"b{i}"] = np.zeros(dims[i+1])

        # Otimizador
        if otimizador == "adam":
            self.opt = Adam(lr=learning_rate)
        elif otimizador == "sgd":
            self.opt = GradienteDescendente(lr=learning_rate)
        elif otimizador == "momentum":
            self.opt = Momentum(lr=learning_rate)
        else:
            self.opt = RMSprop(lr=learning_rate)

        self.n_params = sum(v.size for v in self.params.values())

    def _ativ(self, z: np.ndarray,
               nome: str) -> np.ndarray:
        if nome == "relu":
            return np.maximum(0, z)
        elif nome == "sigmoid":
            return 1/(1 + np.exp(-np.clip(z, -500, 500)))
        elif nome == "tanh":
            return np.tanh(z)
        elif nome == "softmax":
            e_z = np.exp(z - z.max(axis=1, keepdims=True))
            return e_z / e_z.sum(axis=1, keepdims=True)
        return z  # linear

    def _d_ativ(self, a: np.ndarray,
                 nome: str) -> np.ndarray:
        if nome == "relu":
            return (a > 0).astype(float)
        elif nome == "sigmoid":
            return a * (1 - a)
        elif nome == "tanh":
            return 1 - a ** 2
        return np.ones_like(a)

    def _loss_bce(self, y_real: np.ndarray,
                   y_pred: np.ndarray) -> float:
        eps    = 1e-15
        y_pred = np.clip(y_pred, eps, 1 - eps)
        return -np.mean(
            y_real * np.log(y_pred) +
            (1 - y_real) * np.log(1 - y_pred)
        )

    def forward(self, X: np.ndarray) -> np.ndarray:
        """Forward pass, armazenando cache para backprop."""
        self.cache = {"a0": X}
        entrada    = X

        for i in range(len(self.dims) - 1):
            W = self.params[f"W{i}"]
            b = self.params[f"b{i}"]
            z = entrada @ W + b
            a = self._ativ(z, self.ativacoes[i])
            self.cache[f"z{i}"] = z
            self.cache[f"a{i+1}"] = a
            entrada = a

        return entrada

    def backward(self, y_real: np.ndarray) -> dict:
        """Backpropagation genérico para N camadas."""
        n      = y_real.shape[0]
        grads  = {}
        n_cam  = len(self.dims) - 1

        # Última camada: gradiente da perda em relação à saída
        a_out   = self.cache[f"a{n_cam}"]
        delta   = a_out - y_real.reshape(-1, 1)

        for i in reversed(range(n_cam)):
            a_prev = self.cache[f"a{i}"]

            grads[f"W{i}"] = (1/n) * a_prev.T @ delta
            grads[f"b{i}"] = (1/n) * delta.sum(axis=0)

            if i > 0:
                # Propagar delta para camada anterior
                delta_ante = delta @ self.params[f"W{i}"].T
                d_ativ     = self._d_ativ(
                    self.cache[f"a{i}"],
                    self.ativacoes[i - 1]
                )
                delta = delta_ante * d_ativ

        return grads

    def fit(self, X_tr: np.ndarray,
            y_tr: np.ndarray,
            X_val: np.ndarray = None,
            y_val: np.ndarray = None,
            epocas: int = 200,
            batch_size: int = 32,
            verbose: int = 20,
            parada_antecipada: int = 20) -> "RedeNeuralCompleta":

        n = len(X_tr)
        melhor_loss_val = np.inf
        epocas_sem_melhora = 0

        for epoca in range(epocas):
            # Mini-batch
            idx  = np.random.permutation(n)
            X_sh = X_tr[idx]
            y_sh = y_tr[idx]

            for start in range(0, n, batch_size):
                end  = min(start + batch_size, n)
                Xb   = X_sh[start:end]
                yb   = y_sh[start:end]
                self.forward(Xb)
                grads = self.backward(yb)
                self.opt.atualizar(self.params, grads)

            # Métricas da época
            y_pred_tr = self.forward(X_tr)
            loss_tr   = self._loss_bce(y_tr, y_pred_tr)
            self.hist["treino"].append(loss_tr)

            if X_val is not None:
                y_pred_val = self.forward(X_val)
                loss_val   = self._loss_bce(y_val, y_pred_val)
                self.hist["val"].append(loss_val)

                # Parada antecipada
                if loss_val < melhor_loss_val:
                    melhor_loss_val    = loss_val
                    epocas_sem_melhora = 0
                else:
                    epocas_sem_melhora += 1
                    if epocas_sem_melhora >= parada_antecipada:
                        if verbose > 0:
                            print(f"Parada antecipada na época {epoca+1}")
                        break

            if verbose > 0 and (epoca + 1) % verbose == 0:
                msg = (f"Época {epoca+1:4d} — "
                       f"Loss Treino: {loss_tr:.4f}")
                if X_val is not None:
                    msg += f" — Loss Val: {loss_val:.4f}"
                print(msg)

        return self

    def predict_proba(self, X: np.ndarray) -> np.ndarray:
        return self.forward(X)

    def predict(self, X: np.ndarray,
                limiar: float = 0.5) -> np.ndarray:
        return (self.predict_proba(X) >= limiar).astype(int).ravel()

    def avaliar(self, X: np.ndarray,
                y: np.ndarray) -> dict:
        y_pred  = self.predict(X)
        y_proba = self.predict_proba(X).ravel()
        acc     = accuracy_score(y, y_pred)
        loss    = self._loss_bce(y, y_proba)
        return {"acuracia": acc, "loss": loss}

    def plotar_historico(self, titulo: str = ""):
        fig, ax = plt.subplots(figsize=(10, 5))
        ep      = range(1, len(self.hist["treino"]) + 1)
        ax.plot(ep, self.hist["treino"],
                label="Treino", color="steelblue",
                linewidth=2)
        if self.hist["val"]:
            ax.plot(ep, self.hist["val"],
                    label="Validação", color="coral",
                    linewidth=2, linestyle="--")
        ax.set_xlabel("Época")
        ax.set_ylabel("BCE Loss")
        ax.set_title(f"Curva de Aprendizado {titulo}")
        ax.legend()
        plt.tight_layout()
        plt.show()

7. Regularização em Redes Neurais

class RedeNeuralRegularizada(RedeNeuralCompleta):
    """
    Extensão com regularização L2 (weight decay) e Dropout.
    """

    def __init__(self, dims: list[int],
                 ativacoes: list[str],
                 learning_rate: float = 0.01,
                 l2_lambda: float = 0.001,
                 dropout_rate: float = 0.0,
                 otimizador: str = "adam"):
        super().__init__(dims, ativacoes,
                          learning_rate, otimizador)
        self.l2       = l2_lambda
        self.dropout  = dropout_rate
        self.treino   = True   # modo treino vs inferência

    def forward(self, X: np.ndarray) -> np.ndarray:
        """Forward com Dropout."""
        self.cache    = {"a0": X}
        self.masks    = {}
        entrada       = X

        for i in range(len(self.dims) - 1):
            W = self.params[f"W{i}"]
            b = self.params[f"b{i}"]
            z = entrada @ W + b
            a = self._ativ(z, self.ativacoes[i])

            # Dropout (apenas em camadas ocultas, em modo treino)
            if (self.treino and self.dropout > 0
                    and i < len(self.dims) - 2):
                mask = (np.random.random(a.shape) >
                        self.dropout).astype(float)
                mask /= (1 - self.dropout)  # inverted dropout
                a    *= mask
                self.masks[i] = mask

            self.cache[f"z{i}"] = z
            self.cache[f"a{i+1}"] = a
            entrada = a

        return entrada

    def backward(self, y_real: np.ndarray) -> dict:
        """Backprop com L2 e Dropout."""
        n      = y_real.shape[0]
        grads  = {}
        n_cam  = len(self.dims) - 1

        a_out  = self.cache[f"a{n_cam}"]
        delta  = a_out - y_real.reshape(-1, 1)

        for i in reversed(range(n_cam)):
            a_prev = self.cache[f"a{i}"]

            # Gradiente do peso com regularização L2
            grads[f"W{i}"] = ((1/n) * a_prev.T @ delta
                               + self.l2 * self.params[f"W{i}"])
            grads[f"b{i}"] = (1/n) * delta.sum(axis=0)

            if i > 0:
                delta_ante = delta @ self.params[f"W{i}"].T

                # Dropout mask
                if i - 1 in self.masks:
                    delta_ante *= self.masks[i - 1]

                d_ativ = self._d_ativ(
                    self.cache[f"a{i}"],
                    self.ativacoes[i - 1]
                )
                delta = delta_ante * d_ativ

        return grads

8. Experimento: Efeito do Learning Rate

def experimento_learning_rate():
    """Demonstra o efeito crítico do learning rate."""

    np.random.seed(42)
    X_lr, y_lr = make_classification(
        n_samples=600, n_features=5,
        n_informative=4, random_state=42
    )
    X_tr_lr, X_te_lr, y_tr_lr, y_te_lr = train_test_split(
        X_lr, y_lr, test_size=0.2, random_state=42
    )
    X_tr_lr2, X_va_lr, y_tr_lr2, y_va_lr = train_test_split(
        X_tr_lr, y_tr_lr, test_size=0.2, random_state=42
    )

    sc = StandardScaler()
    X_tr_lrs = sc.fit_transform(X_tr_lr2)
    X_va_lrs = sc.transform(X_va_lr)
    X_te_lrs = sc.transform(X_te_lr)

    learning_rates = [0.0001, 0.001, 0.01, 0.1, 1.0]
    resultados_lr  = {}

    fig, axes = plt.subplots(1, 2, figsize=(14, 5))
    cores_lr  = ["gray", "steelblue", "mediumseagreen",
                  "coral", "red"]

    for lr, cor in zip(learning_rates, cores_lr):
        np.random.seed(42)
        rede_lr = RedeNeuralCompleta(
            dims=[5, 16, 8, 1],
            ativacoes=["relu", "relu", "sigmoid"],
            learning_rate=lr,
            otimizador="sgd"
        )
        rede_lr.fit(
            X_tr_lrs, y_tr_lr2,
            X_val=X_va_lrs, y_val=y_va_lr,
            epocas=150, batch_size=32,
            verbose=0, parada_antecipada=999
        )

        acc_te = accuracy_score(y_te_lr, rede_lr.predict(X_te_lrs))
        resultados_lr[lr] = acc_te

        hist_tr = rede_lr.hist["treino"]
        hist_va = rede_lr.hist["val"]

        axes[0].plot(hist_tr, label=f"lr={lr}",
                     color=cor, linewidth=1.5, alpha=0.8)
        axes[1].plot(hist_va, label=f"lr={lr} (acc={acc_te:.3f})",
                     color=cor, linewidth=1.5, alpha=0.8)

    for ax, titulo in zip(axes,
                           ["Loss no Treino",
                            "Loss na Validação"]):
        ax.set_xlabel("Época")
        ax.set_ylabel("BCE Loss")
        ax.set_title(titulo)
        ax.legend(fontsize=8)
        ax.set_ylim(0, 2)

    plt.suptitle("Efeito do Learning Rate no Treinamento",
                  fontsize=13)
    plt.tight_layout()
    plt.show()

    print("Acurácia no teste por learning rate:")
    for lr, acc in resultados_lr.items():
        barra = "█" * int(acc * 40)
        status = ("✓ ótimo" if 0.001 <= lr <= 0.01
                  else "→ instável" if lr >= 0.1
                  else "→ lento")
        print(f"  lr={lr:.4f}: {acc:.4f} {barra} {status}")

experimento_learning_rate()

9. Parada Antecipada (Early Stopping)

def demonstrar_early_stopping():
    """
    Demonstra o efeito da parada antecipada
    para evitar overfitting.
    """

    np.random.seed(42)
    X_es, y_es = make_classification(
        n_samples=400, n_features=8,
        n_informative=5, random_state=42
    )
    X_tr_es, X_te_es, y_tr_es, y_te_es = train_test_split(
        X_es, y_es, test_size=0.2, random_state=42
    )
    X_tr_es2, X_va_es, y_tr_es2, y_va_es = train_test_split(
        X_tr_es, y_tr_es, test_size=0.25, random_state=42
    )

    sc_es = StandardScaler()
    X_tr_ess = sc_es.fit_transform(X_tr_es2)
    X_va_ess = sc_es.transform(X_va_es)
    X_te_ess = sc_es.transform(X_te_es)

    # Sem parada antecipada
    np.random.seed(42)
    rede_sem = RedeNeuralCompleta(
        dims=[8, 32, 32, 16, 1],
        ativacoes=["relu", "relu", "relu", "sigmoid"],
        learning_rate=0.01
    )
    rede_sem.fit(
        X_tr_ess, y_tr_es2,
        X_val=X_va_ess, y_val=y_va_es,
        epocas=500, batch_size=16,
        verbose=0, parada_antecipada=9999   # desabilitado
    )

    # Com parada antecipada
    np.random.seed(42)
    rede_com = RedeNeuralCompleta(
        dims=[8, 32, 32, 16, 1],
        ativacoes=["relu", "relu", "relu", "sigmoid"],
        learning_rate=0.01
    )
    rede_com.fit(
        X_tr_ess, y_tr_es2,
        X_val=X_va_ess, y_val=y_va_es,
        epocas=500, batch_size=16,
        verbose=0, parada_antecipada=15
    )

    acc_sem = accuracy_score(y_te_es, rede_sem.predict(X_te_ess))
    acc_com = accuracy_score(y_te_es, rede_com.predict(X_te_ess))

    fig, ax = plt.subplots(figsize=(12, 6))

    epocas_sem = range(1, len(rede_sem.hist["val"]) + 1)
    epocas_com = range(1, len(rede_com.hist["val"]) + 1)

    ax.plot(epocas_sem, rede_sem.hist["val"],
            label=f"Sem early stop (acc={acc_sem:.3f})",
            color="coral", linewidth=2)
    ax.plot(epocas_com, rede_com.hist["val"],
            label=f"Com early stop (acc={acc_com:.3f})",
            color="steelblue", linewidth=2)

    # Marcar ponto de parada
    n_ep_com = len(rede_com.hist["val"])
    ax.axvline(x=n_ep_com, color="steelblue",
               linestyle="--", alpha=0.7,
               label=f"Parada na época {n_ep_com}")

    ax.set_xlabel("Época")
    ax.set_ylabel("Validation Loss")
    ax.set_title("Early Stopping: Efeito na Generalização")
    ax.legend()
    plt.tight_layout()
    plt.show()

    print(f"Resultados:")
    print(f"  Sem early stopping: {len(rede_sem.hist['val'])} épocas, "
          f"acc={acc_sem:.4f}")
    print(f"  Com early stopping: {len(rede_com.hist['val'])} épocas, "
          f"acc={acc_com:.4f}")

demonstrar_early_stopping()

10. Exemplo Completo: Treinamento Robusto

print("=" * 60)
print("REDE NEURAL COMPLETA COM BACKPROP — TREINAMENTO ROBUSTO")
print("=" * 60)

np.random.seed(42)

# Dataset: classificação não linear
X_final, y_final = make_moons(
    n_samples=1200, noise=0.25, random_state=42
)

X_tr_f, X_te_f, y_tr_f, y_te_f = train_test_split(
    X_final, y_final, test_size=0.15,
    random_state=42, stratify=y_final
)
X_tr_f2, X_va_f, y_tr_f2, y_va_f = train_test_split(
    X_tr_f, y_tr_f, test_size=0.15,
    random_state=42
)

sc_f = StandardScaler()
X_tr_fs = sc_f.fit_transform(X_tr_f2)
X_va_fs = sc_f.transform(X_va_f)
X_te_fs = sc_f.transform(X_te_f)

print(f"\nDataset: {X_final.shape}")
print(f"Treino: {len(X_tr_fs)}, Val: {len(X_va_fs)}, "
      f"Teste: {len(X_te_fs)}")

# Comparar rede simples vs regularizada
resultados_final = {}
configs_final = {
    "Simples (sem reg.)": RedeNeuralCompleta(
        dims=[2, 32, 16, 1],
        ativacoes=["relu", "relu", "sigmoid"],
        learning_rate=0.01,
        otimizador="adam"
    ),
    "Com L2 + Dropout":   RedeNeuralRegularizada(
        dims=[2, 32, 16, 1],
        ativacoes=["relu", "relu", "sigmoid"],
        learning_rate=0.01,
        l2_lambda=0.001,
        dropout_rate=0.2,
        otimizador="adam"
    ),
}

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

for (nome, rede), cor in zip(
    configs_final.items(),
    ["steelblue", "coral"]
):
    np.random.seed(42)
    rede.fit(
        X_tr_fs, y_tr_f2,
        X_val=X_va_fs, y_val=y_va_f,
        epocas=300, batch_size=32,
        verbose=100, parada_antecipada=30
    )

    rede.treino = False   # desligar dropout para avaliação
    res_tr = rede.avaliar(X_tr_fs, y_tr_f2)
    res_te = rede.avaliar(X_te_fs, y_te_f)
    resultados_final[nome] = res_te

    print(f"\n{nome}:")
    print(f"  Treino — Acc: {res_tr['acuracia']:.4f}, "
          f"Loss: {res_tr['loss']:.4f}")
    print(f"  Teste  — Acc: {res_te['acuracia']:.4f}, "
          f"Loss: {res_te['loss']:.4f}")
    print(f"  Gap (overfitting): "
          f"{res_tr['acuracia'] - res_te['acuracia']:.4f}")

    ep = range(1, len(rede.hist["val"]) + 1)
    axes[0].plot(rede.hist["treino"][:len(ep)],
                  label=f"{nome} (treino)",
                  color=cor, linewidth=2)
    axes[1].plot(ep, rede.hist["val"],
                  label=f"{nome} (val)",
                  color=cor, linewidth=2, linestyle="--")

for ax, titulo in zip(axes, ["Loss Treino", "Loss Validação"]):
    ax.set_xlabel("Época")
    ax.set_ylabel("BCE Loss")
    ax.set_title(titulo)
    ax.legend(fontsize=9)

plt.suptitle("Rede Simples vs Regularizada", fontsize=13)
plt.tight_layout()
plt.show()

Resumo da Aula

  • Backpropagation calcula gradientes usando a regra da cadeia aplicada da saída para a entrada
  • Para BCE + sigmoid: o gradiente da última camada simplifica para δ = ŷ - y
  • O gradiente se propaga para trás multiplicando pelo gradiente da ativação e pelos pesos transpostos
  • Verificação numérica confirma que os gradientes analíticos estão corretos
  • Adam é o otimizador padrão moderno: combina momentum com taxa de aprendizado adaptativa
  • Vanishing gradient afeta sigmoid/tanh em redes profundas — ReLU e inicialização adequada resolvem
  • Regularização L2 (weight decay) adiciona penalidade aos pesos grandes
  • Dropout desliga neurônios aleatoriamente durante o treino, forçando redundância
  • Early stopping monitora a perda de validação e para quando para de melhorar
  • Toda essa lógica está implementada no PyTorch de forma automática — a próxima aula mostrará como

Exercícios

  1. Derive matematicamente o gradiente da Binary Cross-Entropy em relação a z₂ (a combinação linear antes do sigmoid na última camada). Por que o resultado δ = ŷ - y é tão elegante e computacionalmente conveniente?

    ✓ Resposta:

    A derivada da BCE em relação a ŷ (saída do sigmoid):

    L = -(y·log(ŷ) + (1-y)·log(1-ŷ))
    ∂L/∂ŷ = -y/ŷ + (1-y)/(1-ŷ)
    

    A derivada do sigmoid em relação a z₂:

    ∂ŷ/∂z₂ = ŷ(1-ŷ)
    

    Pela regra da cadeia:

    ∂L/∂z₂ = ∂L/∂ŷ × ∂ŷ/∂z₂
            = [-y/ŷ + (1-y)/(1-ŷ)] × ŷ(1-ŷ)
            = -y(1-ŷ) + (1-y)ŷ
            = -y + yŷ + ŷ - yŷ
            = ŷ - y
    

    O resultado δ = ŷ - y é elegante por três razões. Primeiro, é numericamente estável — não há divisão por ŷ nem por (1-ŷ), que poderiam ser próximos de zero. Segundo, tem interpretação intuitiva: o gradiente é exatamente o erro de predição. Quando o modelo erra muito (ŷ muito diferente de y), o gradiente é grande; quando acerta bem, é pequeno. Terceiro, é computacionalmente trivial — apenas uma subtração, sem funções transcendentais.

  2. Explique o que é a verificação numérica de gradientes e por que ela é importante. Por que usamos a diferença centrada [f(w+ε) - f(w-ε)] / 2ε em vez da diferença simples [f(w+ε) - f(w)] / ε?

    ✓ Resposta:

    A verificação numérica de gradientes usa a definição de derivada para calcular gradientes aproximados diretamente da função de perda, sem usar a cadeia de derivações. É usada para confirmar que a implementação analítica do backprop está correta.

    A diferença centrada [f(w+ε) - f(w-ε)] / 2ε é mais precisa que a diferença simples [f(w+ε) - f(w)] / ε por expansão de Taylor. A diferença simples tem erro de truncamento O(ε): [f(w+ε) - f(w)] / ε = f'(w) + O(ε). A diferença centrada tem erro O(ε²), muito menor: [f(w+ε) - f(w-ε)] / 2ε = f'(w) + O(ε²). Com ε = 1e-5, a diferença simples tem erro da ordem de 1e-5, enquanto a centrada tem erro da ordem de 1e-10 — 100.000 vezes mais precisa. Isso é importante porque queremos confirmar se o gradiente analítico é correto a uma tolerância de ~1e-5, o que seria impossível com erro da diferença simples da mesma ordem.

  3. Compare os quatro otimizadores vistos: SGD, Momentum, RMSprop e Adam. Quais problemas cada um resolve? Por que Adam é geralmente a primeira escolha?

    ✓ Resposta:

    SGD puro: calcula gradientes com um batch e atualiza. Simples mas pode oscilar em direções de alta curvatura e é lento para convergir. Sem mecanismo para adaptar o passo.

    Momentum: acumula velocidade na direção consistente de descida, amortecendo as oscilações. Acelera convergência em direções com gradiente consistente. Ainda usa o mesmo learning rate para todos os parâmetros.

    RMSprop: adapta o learning rate para cada parâmetro individualmente, dividindo pelo RMS dos gradientes recentes. Parâmetros com gradientes historicamente grandes recebem passos menores. Resolve problemas com features de escalas muito diferentes.

    Adam combina ambos: usa momentum para suavizar os gradientes (primeiro momento) e RMSprop para adaptar o learning rate por parâmetro (segundo momento). Adiciona correção de bias nos primeiros passos quando os momentos estão subestimados. É a primeira escolha porque funciona bem em uma ampla variedade de problemas com configuração padrão (lr=0.001, β₁=0.9, β₂=0.999), exigindo muito menos tuning que SGD.

  4. Explique o problema do vanishing gradient matematicamente. Por que a derivada do sigmoid causa esse problema? Calcule quanto o gradiente encolhe após passar por 5 camadas sigmoid no pior caso.

    ✓ Resposta:

    A derivada do sigmoid é σ'(z) = σ(z)(1-σ(z)). O valor máximo ocorre em z=0 onde σ(0)=0.5 e a derivada é 0.5 × 0.5 = 0.25. Para z distante de zero (o que acontece após algumas camadas), a derivada se aproxima de 0.

    Em backpropagation, o gradiente da primeira camada inclui o produto das derivadas de todas as camadas intermediárias. Com 5 camadas sigmoid, no pior caso o gradiente é multiplicado por 0.25 em cada camada:

    0.25⁵ = 0.000977 — menos de 0.1% do gradiente original chega à primeira camada. Com 10 camadas: 0.25¹⁰ ≈ 1e-6, praticamente zero. Os pesos nas primeiras camadas recebem gradientes tão pequenos que praticamente não aprendem, enquanto as últimas camadas aprendem normalmente.

    ReLU resolve porque sua derivada é 1 para z > 0 (sem atenuação) e 0 para z < 0. Os neurônios ativos propagam o gradiente sem encolhimento, enquanto os inativos simplesmente não contribuem.

  5. O que é dropout e como ele funciona durante o treino vs a inferência? Por que é necessário escalar as ativações por 1/(1-p) durante o treino (inverted dropout)?

    ✓ Resposta:

    Dropout funciona durante o treino desligando aleatoriamente cada neurônio com probabilidade p, zerando sua saída e seu gradiente. Isso força a rede a não depender de nenhum neurônio específico, criando representações distribuídas e redundantes — cada caminho na rede precisa ser informativo por si mesmo. O efeito é similar a treinar um ensemble de muitas redes menores e fazer a média delas.

    Durante a inferência, dropout é desativado e todos os neurônios contribuem. Mas agora há um problema de escala: durante o treino, em média (1-p) neurônios estavam ativos. Na inferência, todos estão ativos — a soma das ativações seria maior por um fator de 1/(1-p).

    O inverted dropout resolve escalando as ativações durante o treino por 1/(1-p). Isso compensa a queda: os neurônios ativos produzem saídas maiores durante o treino, mas em média o nível de ativação total é preservado. Na inferência, não é necessário escalar, tornando o código mais simples e eficiente — basta remover as máscaras de dropout.

  6. Explique early stopping. Como você determinaria o valor ideal do parâmetro patience (épocas sem melhora antes de parar)? Quais são os riscos de um patience muito pequeno e muito grande?

    ✓ Resposta:

    Early stopping monitora a métrica de validação (geralmente a loss) e interrompe o treino quando ela para de melhorar por patience épocas consecutivas. Isso evita overfitting ao encontrar o ponto onde o modelo ainda generaliza bem antes de começar a memorizar o treino.

    Para determinar o patience ideal: valores entre 10 e 30 são comuns. Analisar a curva de aprendizado do modelo sem early stopping ajuda — se a validação se estabiliza e sobe por 20 épocas antes de degradar claramente, patience=15 seria adequado.

    Risks de patience muito pequeno: o treino pode parar prematuramente durante um platô temporário, antes do modelo convergir para um bom mínimo. A loss de validação frequentemente tem flutuações estocásticas por conta do mini-batch, e alguns platôs são seguidos de melhoras significativas.

    Riscos de patience muito grande: permite overfitting excessivo antes de parar, potencialmente gerando um modelo que memoriza o treino. Também desperdiça tempo computacional treinando além do ponto ótimo.

    Uma prática recomendada é combinar early stopping com salvamento do melhor modelo (salvar o checkpoint quando a melhor validação é atingida) e restaurar esse checkpoint ao final, independente de quando o treino parou.

Referências