Até aqui, os melhores modelos para dados tabulares foram os ensembles de árvores. Mas quando os dados são imagens, texto, áudio ou sequências, a engenharia manual de features atinge um teto — e é aí que o deep learning domina. Uma rede neural profunda aprende as próprias representações a partir dos dados brutos, dispensando boa parte da feature engineering manual. Esta aula constrói a base: o que é um neurônio, como redes aprendem por backpropagation, e como treiná-las de forma estável sem cair nas muitas armadilhas do caminho.

Entender a arquitetura de um MLP (perceptron multicamadas), o mecanismo de backpropagation e descida de gradiente, as funções de ativação e perda, e — sobretudo — as técnicas práticas que fazem o treino convergir: normalização, inicialização, otimizadores, regularização e early stopping.

Do neurônio à rede

Um neurônio artificial calcula uma combinação linear das entradas mais um viés, e aplica uma ativação não-linear:

saída = ativação(w·x + b)

Empilhando neurônios em camadas e camadas em profundidade, obtemos um MLP. A não-linearidade é essencial: sem ela, empilhar camadas lineares resultaria em... outra função linear. É a ativação que dá à rede o poder de aproximar funções complexas (teorema da aproximação universal).

import torch
import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, n_in, n_hidden, n_out):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_in, n_hidden),
            nn.ReLU(),
            nn.BatchNorm1d(n_hidden),
            nn.Dropout(0.3),
            nn.Linear(n_hidden, n_hidden),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(n_hidden, n_out),   # logits (sem softmax aqui)
        )
    def forward(self, x):
        return self.net(x)

modelo = MLP(20, 64, 2)
print(modelo)
print("Parâmetros:", sum(p.numel() for p in modelo.parameters()))

Funções de ativação

  • ReLU (max(0, x)): padrão atual. Simples, rápida, evita o desaparecimento de gradiente das saturações. Problema: "dying ReLU" (neurônios que morrem em zero).
  • Leaky ReLU / GELU / SiLU: variantes que resolvem o dying ReLU; GELU é padrão em Transformers.
  • Sigmoid / Tanh: saturam nas pontas (gradiente ~0), causando vanishing gradient em redes profundas. Use sigmoid apenas na saída de classificação binária.
  • Softmax: na saída de classificação multiclasse, converte logits em probabilidades.

Função de perda e o que a rede minimiza

  • Classificação: cross-entropy (entropia cruzada). No PyTorch, nn.CrossEntropyLoss recebe logits e aplica softmax internamente — não aplique softmax antes.
  • Regressão: MSE (erro quadrático) ou MAE/Huber (robusta a outliers).
criterio = nn.CrossEntropyLoss()   # espera logits + rótulos inteiros
# criterio = nn.MSELoss()          # para regressão

Backpropagation e descida de gradiente

O treino é um loop: a rede faz uma predição (forward), calcula a perda, propaga o gradiente da perda de volta pelos pesos (backward, via regra da cadeia) e ajusta cada peso na direção que reduz a perda. O PyTorch calcula os gradientes automaticamente (autograd).

from torch.utils.data import TensorDataset, DataLoader
import torch

# Dados sintéticos
X = torch.randn(3000, 20)
y = (X[:, :5].sum(dim=1) > 0).long()
loader = DataLoader(TensorDataset(X, y), batch_size=64, shuffle=True)

modelo = MLP(20, 64, 2)
otimizador = torch.optim.Adam(modelo.parameters(), lr=1e-3, weight_decay=1e-4)
criterio = nn.CrossEntropyLoss()

for epoca in range(10):
    modelo.train()
    perda_total = 0.0
    for xb, yb in loader:
        otimizador.zero_grad()        # zera gradientes acumulados
        logits = modelo(xb)           # forward
        perda = criterio(logits, yb)  # perda
        perda.backward()              # backward (calcula gradientes)
        otimizador.step()             # atualiza pesos
        perda_total += perda.item()
    print(f"Época {epoca+1}: perda={perda_total/len(loader):.4f}")

O zero_grad() é obrigatório — o PyTorch acumula gradientes por padrão; esquecê-lo é um bug clássico.

Otimizadores

  • SGD: o clássico; com momentum acelera e escapa de mínimos rasos.
  • Adam: taxa de aprendizado adaptativa por parâmetro; o padrão de fato, converge rápido e é robusto.
  • AdamW: Adam com weight decay corretamente desacoplado; preferível quando se usa regularização L2.

A taxa de aprendizado é o hiperparâmetro mais importante. Alta demais diverge; baixa demais fica presa. Use schedulers (cosine annealing, redução no platô) e, idealmente, um learning rate finder.

scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(otimizador, patience=3)
# ...após cada época: scheduler.step(perda_validacao)

Normalização e inicialização — por que o treino "não aprende"

Dois motivos frequentes de um treino que não converge:

  • Dados não normalizados: redes treinam mal com features em escalas muito diferentes. Sempre padronize as entradas (z-score).
  • Inicialização ruim: pesos mal inicializados causam gradientes que explodem ou desaparecem. Use inicialização de He (para ReLU) ou Xavier/Glorot (para tanh) — o PyTorch já faz razoavelmente por padrão.

Batch Normalization normaliza as ativações internas por mini-batch, estabilizando e acelerando o treino. Layer Normalization é a variante usada em Transformers. Elas reduzem a sensibilidade à inicialização e permitem taxas de aprendizado maiores.

Regularização — o combate ao overfitting

Redes têm milhões de parâmetros e overfittam com facilidade. Arsenal:

  • Dropout: desliga aleatoriamente uma fração dos neurônios no treino, forçando redundância. Desativa-se automaticamente na avaliação (modelo.eval()).
  • Weight decay (L2): penaliza pesos grandes (parâmetro weight_decay do otimizador).
  • Early stopping: para quando a perda de validação para de melhorar — talvez o regularizador mais eficaz.
  • Data augmentation: aumenta artificialmente os dados, o que é decisivo em visão computacional.
modelo.eval()   # desativa dropout e usa estatísticas fixas do BatchNorm
with torch.no_grad():
    logits = modelo(X_val)
    pred = logits.argmax(dim=1)

Esquecer modelo.eval() na avaliação (deixando dropout/BN em modo treino) é outro bug clássico que corrompe as métricas.

O loop de treino completo com validação e early stopping

import copy

def treinar(modelo, tr_loader, val_loader, epocas=100, paciencia=10):
    otim = torch.optim.AdamW(modelo.parameters(), lr=1e-3, weight_decay=1e-4)
    criterio = nn.CrossEntropyLoss()
    melhor_perda, melhor_estado, espera = float("inf"), None, 0

    for ep in range(epocas):
        # --- treino ---
        modelo.train()
        for xb, yb in tr_loader:
            otim.zero_grad()
            criterio(modelo(xb), yb).backward()
            otim.step()

        # --- validação ---
        modelo.eval()
        perda_val = 0.0
        with torch.no_grad():
            for xb, yb in val_loader:
                perda_val += criterio(modelo(xb), yb).item()
        perda_val /= len(val_loader)

        # --- early stopping ---
        if perda_val < melhor_perda:
            melhor_perda, melhor_estado, espera = perda_val, copy.deepcopy(modelo.state_dict()), 0
        else:
            espera += 1
            if espera >= paciencia:
                print(f"Early stopping na época {ep+1}")
                break

    modelo.load_state_dict(melhor_estado)   # restaura o melhor modelo
    return modelo

Este esqueleto — treino/validação por época, monitoramento da perda de validação, restauração do melhor estado — é o padrão que você reaproveitará em qualquer projeto de deep learning.

Deep learning vale a pena para dados tabulares?

Nem sempre. Para tabelas de tamanho pequeno/médio, gradient boosting (XGBoost/LightGBM) frequentemente supera redes, treina mais rápido e exige menos ajuste. Redes brilham quando: os dados são de alta dimensão e estruturados (imagens, texto, áudio, sequências); há muitos dados; ou você precisa de aprendizado de representação e transfer learning. Escolha a ferramenta pelo problema, não pela moda.

Um MLP empilha neurônios, cada um uma combinação linear seguida de ativação não linear, e é a não linearidade que dá o poder de aproximação — sem ela, o empilhamento colapsa numa única transformação linear. A ReLU, e variantes como a GELU, é a ativação padrão; sigmoid e tanh saturam e provocam vanishing gradient em profundidade. As perdas usuais são cross-entropy na classificação, recebendo logits, e MSE ou Huber na regressão. O treino é backpropagation com descida de gradiente, e dois detalhes cobram caro quando esquecidos: o zero_grad() antes do backward e o modelo.eval() na avaliação. Adam e AdamW são os otimizadores padrão, com a taxa de aprendizado no papel de hiperparâmetro central e os schedulers ajudando a conduzi-la. Para o treino ficar estável, normalize as entradas, use inicialização adequada — He ou Xavier — e BatchNorm ou LayerNorm. Contra o overfitting, dropout, weight decay, early stopping e aumentação de dados. E uma régua honesta sobre quando usar: em tabelas pequenas e médias o boosting costuma vencer; as redes brilham em dados de alta dimensão e quando há transfer learning disponível.

Exercícios

  1. Por que uma rede composta apenas por camadas lineares (sem funções de ativação não-lineares) não é mais poderosa que uma única camada linear?

    ✓ Resposta:

    A composição de transformações lineares é, ela mesma, uma transformação linear. Se cada camada calcula Wx + b sem não-linearidade, empilhar duas camadas dá W₂(W₁x + b₁) + b₂ = (W₂W₁)x + (W₂b₁ + b₂), que é equivalente a uma única camada linear com pesos W₂W₁ e viés combinado. Ou seja, profundidade não adiciona capacidade expressiva sem não-linearidade — a rede só consegue representar funções lineares, independentemente de quantas camadas tenha. As funções de ativação não-lineares (ReLU, etc.) quebram essa colapsabilidade e permitem que a rede aproxime funções arbitrariamente complexas (teorema da aproximação universal).

  2. O que faz optimizer.zero_grad() e por que esquecê-lo causa um bug?

    ✓ Resposta:

    O PyTorch acumula (soma) os gradientes no atributo .grad de cada parâmetro a cada chamada de backward(), em vez de sobrescrevê-los. zero_grad() zera esses gradientes acumulados antes de calcular os do batch atual. Se você esquecê-lo, os gradientes de mini-batches sucessivos se somam indevidamente, de modo que cada atualização usa uma mistura dos gradientes de vários batches. Isso corrompe a descida de gradiente, geralmente fazendo o treino divergir ou se comportar de forma errática. (O acúmulo é intencional e útil em casos específicos, como simular batches grandes, mas no loop padrão deve-se zerar a cada passo.)

  3. Por que é importante chamar modelo.eval() antes de avaliar, e o que muda no comportamento da rede?

    ✓ Resposta:

    modelo.eval() coloca a rede em modo de avaliação, o que altera camadas cujo comportamento difere entre treino e inferência — principalmente Dropout e BatchNorm. Em modo treino, o Dropout desliga neurônios aleatoriamente e o BatchNorm usa as estatísticas (média/variância) do mini-batch atual. Em modo avaliação, o Dropout é desativado (usa todos os neurônios) e o BatchNorm usa as estatísticas acumuladas durante o treino, não as do batch. Se você avaliar sem chamar eval(), as métricas ficam ruidosas e enviesadas (dropout aleatório nas predições, BN dependente do batch de teste), corrompendo a avaliação. Lembre também de voltar a modelo.train() antes de continuar treinando.

  4. Explique o problema do vanishing gradient e como ReLU, normalização e inicialização adequada ajudam a mitigá-lo.

    ✓ Resposta:

    No vanishing gradient, os gradientes propagados de volta pelas camadas encolhem exponencialmente, de modo que as camadas iniciais recebem gradientes minúsculos e quase não aprendem. Ocorre principalmente com ativações saturantes (sigmoid, tanh), cujas derivadas são próximas de zero nas extremidades, multiplicadas ao longo de muitas camadas. ReLU ajuda porque sua derivada é 1 na região positiva (não satura para valores grandes), preservando a magnitude do gradiente. Inicialização adequada (He para ReLU, Xavier para tanh) mantém a variância dos sinais e gradientes estável entre camadas, evitando que encolham ou explodam. Normalização (BatchNorm/LayerNorm) mantém as ativações em uma faixa bem-comportada a cada camada, estabilizando a escala dos gradientes e permitindo treinar redes profundas com taxas de aprendizado maiores.

  5. Você tem um dataset tabular com 5.000 linhas e 30 features. Vale a pena usar uma rede neural profunda? Justifique.

    ✓ Resposta:

    Provavelmente não como primeira escolha. Para dados tabulares de tamanho pequeno/médio (milhares de linhas), modelos de gradient boosting (XGBoost, LightGBM, CatBoost) tipicamente superam redes neurais, treinam muito mais rápido, exigem menos ajuste de hiperparâmetros e lidam naturalmente com features heterogêneas e faltantes. Redes profundas costumam precisar de muitos dados para brilhar e de cuidadosa normalização/regularização, oferecendo pouca vantagem em tabelas desse porte. A recomendação é começar com boosting como baseline forte; considerar redes apenas se houver estrutura especial (muitas categóricas de altíssima cardinalidade favorecendo embeddings, necessidade de transfer learning, ou integração com dados não estruturados) ou se o volume de dados crescer bastante.

Fontes e leituras recomendadas