Diferenciação automática (Autodiff)

O que é “autodiff” (e o que não é)

Diferenciação automática (autodiff) é uma técnica para calcular derivadas exatas de funções implementadas como programas. Ela fica entre:

  • Diferenciação simbólica (symbolic differentiation) (manipular expressões algébricas como um CAS): pode “explodir” em tamanho de expressão e tem dificuldade com o fluxo de controle do programa.
  • Diferenciação numérica (numerical differentiation) (diferenças finitas (finite differences)): é fácil, mas imprecisa (problemas de cancelamento/tamanho do passo) e cara em altas dimensões.
  • Diferenciação automática: aplica a regra da cadeia (chain rule) de forma sistemática às operações elementares do programa, produzindo derivadas precisas até a precisão de máquina (até o limite do arredondamento de ponto flutuante).

Em aprendizado profundo (deep learning), a diferenciação automática é o motor por trás do treinamento com Descida do Gradiente (Gradient Descent) e Otimizadores (Optimizers) modernos. Quando as pessoas dizem “calcular gradientes”, quase sempre querem dizer diferenciação automática em modo reverso (reverse-mode autodiff), que é intimamente relacionada à Retropropagação (Backpropagation).

A ideia central: programas como grafos de computação

Qualquer computação diferenciável pode ser representada como um grafo acíclico direcionado (directed acyclic graph, DAG) de operações primitivas (primitive operations) (adição, multiplicação, multiplicação de matrizes (matmul), sin, exp etc.). Cada nó é um valor intermediário.

Exemplo:

[ y = (a \cdot b + \sin(a))^2 ]

Uma possível decomposição:

  • (u = a \cdot b)
  • (v = \sin(a))
  • (w = u + v)
  • (y = w^2)

Esse “grafo” é o que a diferenciação automática diferencia. O ponto-chave é: você não diferencia a expressão inteira de uma vez; você diferencia cada primitiva e compõe com a regra da cadeia.

Na prática, frameworks constroem e percorrem esses grafos nos bastidores.

Dois modos de diferenciação automática: modo direto vs modo reverso

A diferenciação automática trata fundamentalmente de computar Jacobianos (Jacobians) de forma eficiente.

Seja (f: \mathbb{R}^n \to \mathbb{R}^m). O Jacobiano (J \in \mathbb{R}^{m \times n}) é grande demais para ser formado explicitamente em redes neurais. Em vez disso, a diferenciação automática computa produtos com o Jacobiano:

  • Modo direto (forward-mode) computa produtos Jacobiano-vetor (Jacobian-vector products, JVPs): (J \cdot v)
  • Modo reverso (reverse-mode) computa produtos vetor-Jacobiano (vector-Jacobian products, VJPs): (v^\top \cdot J)

Intuição do modo direto (JVP)

O modo direto propaga informação de derivada junto com os valores, das entradas para as saídas.

Se você escolher uma direção (v \in \mathbb{R}^n), o modo direto informa como a saída muda nessa direção. O custo escala aproximadamente com o número de entradas (bom quando (n) é pequeno).

Intuição do modo reverso (VJP)

O modo reverso propaga “sensibilidades” para trás, das saídas para as entradas.

Se você se importa com gradientes de uma perda escalar (scalar loss) (L) (logo (m = 1)), o modo reverso computa eficientemente:

[ \nabla_x L \in \mathbb{R}^n ]

com custo aproximadamente proporcional a uma passagem direta (forward pass) mais uma passagem reversa (backward pass), em grande parte independente de (n). É por isso que o modo reverso é o padrão em aprendizado profundo, onde tipicamente temos:

  • milhões/bilhões de parâmetros (enorme (n))
  • uma perda escalar (pequeno (m), muitas vezes (m=1))

A retropropagação é essencialmente a diferenciação automática em modo reverso aplicada a grafos de redes neurais; veja Retropropagação para a visão clássica por redes em camadas.

Diferenciação automática em modo reverso: o modelo mental que realmente funciona

A diferenciação automática em modo reverso pode parecer abstrata até você adotar um modelo mental central:

Todo valor intermediário na passagem direta “contribui” para a perda final. O modo reverso calcula quanto a perda mudaria se você perturbasse levemente cada intermediário.

Frameworks chamam essas sensibilidades de adjuntos (adjoints), cotangentes (cotangents), ou simplesmente gradientes. Para um intermediário (z), o modo reverso acompanha:

[ \bar{z} \equiv \frac{\partial L}{\partial z} ]

Isto é o “gradiente da perda em relação ao valor daquele nó”.

Um pequeno exemplo resolvido

Usando a decomposição acima:

  • (u = a b)
  • (v = \sin(a))
  • (w = u + v)
  • (y = w^2)
  • Seja (L = y) (escalar)

A passagem direta computa (u, v, w, y).

A passagem reversa começa na saída:

  1. (\bar{y} = \frac{\partial L}{\partial y} = 1)
  2. (y = w^2 \Rightarrow \bar{w} = \bar{y} \cdot \frac{\partial y}{\partial w} = 1 \cdot 2w)
  3. (w = u + v \Rightarrow \bar{u} += \bar{w} \cdot 1,\ \bar{v} += \bar{w} \cdot 1)
  4. (u = ab \Rightarrow \bar{a} += \bar{u} \cdot b,\ \bar{b} += \bar{u} \cdot a)
  5. (v = \sin(a) \Rightarrow \bar{a} += \bar{v} \cdot \cos(a))

Observe duas mecânicas-chave:

  • Derivadas locais: cada primitiva contribui com uma regra simples de derivada.
  • Acumulação de gradiente: (a) recebe contribuições de múltiplos caminhos (via (u) e via (v)), então nós as somamos.

Esse comportamento de “soma sobre caminhos” é por isso que frameworks implementam acumulação em .grad e por que, em muitos sistemas, você precisa zerar gradientes entre passos.

Como frameworks calculam gradientes: a “fita” e a passagem reversa

A maioria dos frameworks de aprendizado profundo implementa diferenciação automática em modo reverso construindo uma fita (tape) (também chamada de lista de Wengert (Wengert list)): um registro das operações executadas durante a passagem direta.

Cada operação registrada armazena informação suficiente para calcular gradientes depois:

  • referências às suas entradas (pais)
  • sua saída
  • uma função de retropropagação (backward function) que mapeia adjunto(s) da saída → adjunto(s) das entradas
  • opcionalmente alguns tensores salvos (saved tensors) necessários para a regra de retropropagação (por exemplo, probabilidades de softmax, ativações)

Então, durante .backward() (ou equivalente), o framework:

  1. inicializa o adjunto da saída (tipicamente 1 para uma perda escalar)
  2. percorre a fita em ordem topológica inversa (reverse topological order)
  3. chama a regra de retropropagação de cada nó
  4. acumula gradientes nos nós pais (e, por fim, nos parâmetros)

Pseudocódigo para modo reverso

Conceitualmente:

# Forward
tape = []
for op in program:
    out = op.forward(inputs)
    tape.append((op, inputs, out, saved_for_backward))

# Backward (reverse)
adjoint[out_final] = 1
for (op, inputs, out, saved) in reversed(tape):
    grad_out = adjoint[out]
    grad_inputs = op.backward(grad_out, saved)
    for x, gx in zip(inputs, grad_inputs):
        adjoint[x] += gx

Sistemas reais lidam com tensores, difusão (broadcasting), múltiplas saídas, tipo de dado (dtype)/dispositivo (device) e gerenciamento de memória — mas o núcleo é o mesmo.

Exemplo prático: PyTorch autograd em ação

O PyTorch usa um grafo de computação dinâmico (dynamic): ele registra operações conforme elas executam, o que combina naturalmente com o fluxo de controle do Python.

import torch

# Parameters
w = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(-1.0, requires_grad=True)

# Data
x = torch.tensor([1.0, 2.0, 3.0])
y_true = torch.tensor([2.0, 4.0, 6.0])

# Forward: prediction and MSE loss
y_pred = w * x + b
loss = ((y_pred - y_true) ** 2).mean()

# Backward: compute d(loss)/d(w) and d(loss)/d(b)
loss.backward()

print("loss:", loss.item())
print("grad w:", w.grad.item())
print("grad b:", b.grad.item())

O que está acontecendo nos bastidores:

  • Cada operação com tensor (multiplicação, adição, subtração, quadrado, média) cria um nó interno do autograd.
  • O PyTorch salva o que precisa para cada regra de retropropagação.
  • .backward() executa a passagem reversa e preenche .grad nos tensores folha (leaf tensors) (w, b).

Esta é a história de “o framework calcula gradientes” na sua forma completa mais simples.

Acumulação de gradiente e zeragem

A maioria dos frameworks acumula gradientes em buffers de parâmetros:

w.grad = None
b.grad = None
loss.backward()

Ou, ao usar um otimizador:

opt.zero_grad()
loss.backward()
opt.step()

Esse comportamento de acumulação é essencial para recursos como acumulação de gradientes ao longo de microbatches, mas também explica bugs comuns de iniciantes (esquecer de zerar os gradientes).

Modelo de custo do modo reverso: por que é rápido — e por que usa memória

O modo reverso é eficiente para perdas escalares porque calcula o gradiente completo (\nabla_\theta L) com um custo próximo ao de um pequeno múltiplo constante de avaliar (L).

Mas há uma troca:

  • Para calcular regras de retropropagação, você frequentemente precisa de valores intermediários da passagem direta (ativações).
  • Então os frameworks ou armazenam ativações ou as recomputam depois.

Isso se conecta diretamente a questões práticas do aprendizado profundo:

  • Pressão de memória com batches grandes / sequências longas
  • Checkpointing de ativações (activation checkpointing) (salvar menos tensores, recomputar alguns segmentos da passagem direta)
  • Escalonamento de gradiente e tópicos de estabilidade como Problemas de Gradiente (Gradient Issues)

Exemplo: por que valores intermediários importam

Para (y = \sigma(x)) (sigmoide), a derivada usa (\sigma(x)):

[ \frac{dy}{dx} = y(1-y) ]

Um framework pode salvar y durante a passagem direta para evitar recomputar a sigmoide na passagem reversa. Economias similares aparecem para softmax, camadas de normalização, atenção etc.

Grafos dinâmicos vs estáticos (e por que você deveria se importar)

Ecossistemas diferentes enfatizam estilos diferentes de construção de grafos:

  • Grafo dinâmico (execução imediata (eager)): estilo PyTorch. O grafo é construído conforme o Python executa.

    • Prós: depuração fácil, fluxo de controle natural.
    • Contras: mais difícil otimizar globalmente a menos que você rastreie/compile.
  • Grafo estático / em estágios (static / staged graph): o grafo é construído primeiro e depois executado (ou compilado).

    • Abordagens modernas (por exemplo, jit do JAX, torch.compile do PyTorch, tf.function do TensorFlow) colocam computações em estágios para otimização.

Em todos os casos, a ideia subjacente da diferenciação automática permanece o modo reverso sobre um grafo de computação; o que muda é quando e como o grafo é construído e otimizado.

JVP, VJP e como isso aparece em ferramentas modernas de ML

Mesmo que você use principalmente .backward(), frameworks modernos expõem as primitivas subjacentes de álgebra linear:

  • VJP (produto vetor-Jacobiano): núcleo do modo reverso. Dado um gradiente upstream (v), calcule (v^\top J).
  • JVP (produto Jacobiano-vetor): núcleo do modo direto. Dada uma direção (v), calcule (J v).

Isso importa para:

  • Otimização de ordem superior (higher-order optimization) (Hessianas (Hessians), diferenciação implícita (implicit differentiation))
  • Meta-aprendizado (meta-learning)
  • Análise de sensibilidade (sensitivity analysis)
  • Gradientes por exemplo / funções de influência (influence functions) (frequentemente via combinações engenhosas de JVP/VJP)

No JAX, isso é explícito:

import jax
import jax.numpy as jnp

def f(x):
    return jnp.sum(jnp.sin(x) * x)

x = jnp.array([1.0, 2.0, 3.0])

grad_f = jax.grad(f)          # reverse-mode gradient (scalar output)
print(grad_f(x))

jvp_val = jax.jvp(f, (x,), (jnp.ones_like(x),))  # (f(x), JVP)
print(jvp_val)

Isso deixa claro que “gradiente em modo reverso” é um caso especial de VJP com uma saída escalar.

Aplicações práticas em aprendizado profundo

Treinamento de redes neurais

Dada uma rede com parâmetros (\theta) e perda (L(\theta)), o treinamento repetidamente precisa de:

  1. passagem direta para computar (L)
  2. diferenciação automática em modo reverso para computar (\nabla_\theta L)
  3. uma regra de atualização (SGD/Adam/etc.): veja Otimizadores

Este é o caso de uso canônico: o modo reverso computa gradientes de milhões de parâmetros com eficiência porque a perda é escalar.

Perdas personalizadas e prototipagem de pesquisa

A diferenciação automática brilha quando você inventa novos objetivos (perdas contrastivas, regularizadores, tarefas auxiliares). Desde que sua computação seja composta por primitivas diferenciáveis com regras de gradiente conhecidas, o framework “simplesmente funciona”.

Isso se relaciona fortemente com tópicos como Ativações e Perdas (Activations & Losses): escolher formulações diferenciáveis e numericamente estáveis é fundamental (por exemplo, prefira log_softmax + NLL em vez de softmax ingênua e depois log).

Ajuste fino e aprendizado por transferência

Quando você congela ou descongela partes de um modelo, a diferenciação automática respeita isso ao controlar quais tensores requerem gradientes (ou quais parâmetros são registrados em um otimizador). Isso é mais uma camada de API, mas se apoia no mesmo mecanismo: apenas nós conectados a folhas com “requires_grad” contribuem com gradientes.

Armadilhas comuns e como frameworks lidam com elas

Pontos não diferenciáveis e subgradientes

Algumas funções não são diferenciáveis em todos os pontos (por exemplo, ReLU em 0, valor absoluto em 0). Frameworks tipicamente definem um subgradiente (subgradient) ou convenção razoável:

  • ReLU’: 0 exatamente em 0 (escolha comum)
  • abs: subgradiente em [-1, 1] em 0, frequentemente escolhido como 0

Isso geralmente é adequado para treinamento no estilo SGD, mas pode importar em casos limite.

Operações in-place e integridade do grafo

No modo reverso, a passagem reversa pode precisar de valores da passagem direta. Se você os sobrescreve in-place, pode quebrar o cálculo de gradientes. Frameworks ou proíbem certas edições in-place, ou rastreiam contadores de versão e geram erro quando a segurança é violada.

Desacoplar / interromper gradientes

Às vezes você quer impedir que gradientes fluam por parte da computação (por exemplo, redes-alvo, truques straight-through, registro (logging)). Frameworks fornecem operadores explícitos:

  • PyTorch: tensor.detach(), with torch.no_grad():
  • TensorFlow: tf.stop_gradient
  • JAX: jax.lax.stop_gradient

Use-os deliberadamente; desacoplamentos acidentais são um bug comum em modelos complexos.

Estabilidade numérica não é “consertada” pela diferenciação automática

A diferenciação automática dá derivadas corretas da computação que você escreveu — mas se a computação for numericamente instável (estouro/subfluxo), os gradientes podem virar inf/nan ou desaparecer. Isso se conecta a Problemas de Gradiente e a formulações estáveis em Ativações e Perdas.

Técnicas de economia de memória e escalonamento construídas sobre a diferenciação automática

À medida que modelos escalam, a passagem reversa se torna uma grande consumidora de memória por causa das ativações armazenadas.

Técnicas comuns:

  • Checkpointing de ativações: troca computação por memória ao recomputar partes da passagem direta durante a passagem reversa.
  • Treinamento em precisão mista (mixed precision training): armazenar alguns tensores em menor precisão; usar escalonamento da perda (loss scaling) para estabilizar gradientes.
  • Acumulação de gradientes: dividir batches grandes em etapas enquanto acumula .grad.

Esses não são novos algoritmos de diferenciação; são estratégias práticas em torno do comportamento de memória do modo reverso.

Como a diferenciação automática se relaciona com a retropropagação (e por que ambos os termos existem)

  • Retropropagação historicamente se refere a calcular gradientes em redes neurais em camadas, frequentemente apresentada como sinais de erro fluindo para trás através das camadas.
  • Diferenciação automática em modo reverso é a visão mais geral de ciência da computação: funciona para qualquer programa diferenciável, incluindo fluxo de controle complexo, subgrafos compartilhados e operações personalizadas.

Em frameworks modernos de ML, “backprop” é implementada por diferenciação automática em modo reverso sobre um grafo de computação. Se você entende a diferenciação automática em modo reverso, a retropropagação vira um caso especial; veja Retropropagação para a derivação e intuição centradas em redes.

Resumo

  • A diferenciação automática computa derivadas exatas (até ponto flutuante), aplicando a regra da cadeia ao longo de um grafo de computação.
  • A diferenciação automática em modo reverso é o carro-chefe do aprendizado profundo porque computa eficientemente gradientes de muitos parâmetros para uma perda escalar.
  • Frameworks implementam o modo reverso com uma “fita” da passagem direta mais uma travessia reversa que aplica regras locais de derivada e acumula gradientes.
  • O treinamento prático depende desses gradientes alimentando Otimizadores e é influenciado por tópicos de estabilidade como Problemas de Gradiente.
  • Entender os conceitos de VJP/JVP ajuda quando você vai além do treinamento básico para métodos de ordem superior, meta-aprendizado e trabalho de eficiência em grande escala.