Phase 01 - Lesson 05
Regra da Cadeia e Diferenciação Automática
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
A regra da cadeia é o motor por trás de toda rede neural que aprende.
Tipo: Build Linguagem: Python Pré-requisitos: Fase 1, Lição 04 (Derivadas e Gradientes) Tempo: ~90 minutos
Objetivos de Aprendizagem
- Construir um motor de autograd mínimo (classe Value) que registra operações e calcula gradientes via autodiff em modo reverso
- Implementar passagens para frente e para trás por um grafo de computação usando ordenação topológica
- Construir e treinar um perceptron multicamadas no XOR usando apenas o motor de autograd feito do zero
- Verificar a corretude do autodiff usando checagem de gradiente contra diferenças finitas numéricas
O Problema
Você sabe calcular derivadas de funções simples. Mas uma rede neural não é uma função simples. Ela é centenas de funções compostas: multiplicação de matrizes, soma do viés, aplicação da ativação, nova multiplicação de matrizes, softmax, perda de entropia cruzada. A saída é uma função de uma função de uma função.
Para treinar a rede, você precisa do gradiente da perda em relação a cada peso individual. Fazer isso à mão é impossível para milhões de parâmetros. Fazer numericamente (diferenças finitas) é lento demais.
A regra da cadeia te dá a matemática. A diferenciação automática te dá o algoritmo. Juntas, elas permitem calcular gradientes exatos através de composições arbitrárias de funções em tempo proporcional a uma única passagem para frente.
É assim que PyTorch, TensorFlow e JAX funcionam. Você vai construir uma versão em miniatura do zero.
O Conceito
A Regra da Cadeia
Se y = f(g(x)), a derivada de y em relação a x e:
dy/dx = dy/dg * dg/dx = f'(g(x)) * g'(x)
Multiplique as derivadas ao longo da cadeia. Cada elo contribui com sua derivada local.
Exemplo: y = sin(x^2)
g(x) = x^2 g'(x) = 2x
f(g) = sin(g) f'(g) = cos(g)
dy/dx = cos(x^2) * 2x
Para composições mais profundas, a cadeia se estende:
y = f(g(h(x)))
dy/dx = f'(g(h(x))) * g'(h(x)) * h'(x)
Cada camada em uma rede neural é um elo nessa cadeia.
Grafos de Computação
Um grafo de computação torna a regra da cadeia visual. Cada operação vira um nó. Os dados fluem para frente pelo grafo. Os gradientes fluem para trás.
Passagem para frente (calcula valores):
graph TD
x1["x1 = 2"] --> mul["* (multiply)"]
x2["x2 = 3"] --> mul
mul -->|"a = 6"| add["+ (add)"]
b["b = 1"] --> add
add -->|"c = 7"| relu["relu"]
relu -->|"y = 7"| y["output y"]
Passagem para trás (calcula gradientes):
graph TD
dy["dy/dy = 1"] -->|"relu'(c)=1 since c>0"| dc["dy/dc = 1"]
dc -->|"dc/da = 1"| da["dy/da = 1"]
dc -->|"dc/db = 1"| db["dy/db = 1"]
da -->|"da/dx1 = x2 = 3"| dx1["dy/dx1 = 3"]
da -->|"da/dx2 = x1 = 2"| dx2["dy/dx2 = 2"]
A passagem para trás aplica a regra da cadeia em cada nó, propagando gradientes da saída para as entradas.
Modo Direto vs Modo Reverso
Existem duas maneiras de aplicar a regra da cadeia através de um grafo.
Modo direto começa nas entradas e empurra as derivadas para frente. Calcula dx/dx = 1 e propaga por cada operação. Bom quando você tem poucas entradas e muitas saídas.
Modo direto: semeia dx/dx = 1, propaga para frente
x = 2 (dx/dx = 1)
a = x^2 (da/dx = 2x = 4)
y = sin(a) (dy/dx = cos(a) * da/dx = cos(4) * 4 = -2.615)
Modo reverso começa na saída e puxa os gradientes para trás. Calcula dy/dy = 1 e propaga por cada operação em ordem reversa. Bom quando você tem muitas entradas e poucas saídas.
Modo reverso: semeia dy/dy = 1, propaga para tras
y = sin(a) (dy/dy = 1)
a = x^2 (dy/da = cos(a) = cos(4) = -0.654)
x = 2 (dy/dx = dy/da * da/dx = -0.654 * 4 = -2.615)
Redes neurais têm milhões de entradas (pesos) e uma saída (perda). O modo reverso calcula todos os gradientes em uma única passagem para trás. É por isso que a retropropagação usa o modo reverso.
| Modo | Semente | Direção | Melhor quando |
|---|---|---|---|
| Direto | dx_i/dx_i = 1 |
Da entrada para a saída | Poucas entradas, muitas saídas |
| Reverso | dy/dy = 1 |
Da saída para a entrada | Muitas entradas, poucas saídas (redes neurais) |
Números Duais para o Modo Direto
O modo direto pode ser implementado de forma elegante com números duais. Um número dual tem a forma a + b*epsilon onde epsilon^2 = 0.
Numero dual: (valor, derivada)
(2, 1) significa: valor e 2, derivada em relacao a x e 1
Regras aritmeticas:
(a, a') + (b, b') = (a+b, a'+b')
(a, a') * (b, b') = (a*b, a'*b + a*b')
sin(a, a') = (sin(a), cos(a)*a')
Semeie a variável de entrada com derivada 1. A derivada se propaga automaticamente por cada operação.
Construindo um Motor de Autograd
Um motor de autograd precisa de três coisas:
- Empacotamento de valor. Envolva cada número em um objeto que armazena seu valor e gradiente.
- Registro do grafo. Cada operação registra suas entradas e a função de gradiente local.
- Passagem para trás. Faça a ordenação topológica do grafo, depois percorra-o em ordem reversa, aplicando a regra da cadeia em cada nó.
É exatamente isso que o autograd do PyTorch faz. A classe torch.Tensor envolve valores, registra operações quando requires_grad=True e calcula gradientes quando você chama .backward().
Como o Autograd do PyTorch Funciona Por Baixo dos Panos
Quando você escreve código PyTorch:
x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 + 3 * x + 1
y.backward()
print(x.grad) # 7.0 = 2*x + 3 = 2*2 + 3
O PyTorch internamente:
- Cria um no
Tensorparaxcomrequires_grad=True - Cada operação (
**,*,+) cria um novo nó e registra a função para trás y.backward()dispara o autodiff em modo reverso pelo grafo registrado- O
grad_fnde cada nó calcula gradientes locais e os passa para os nós pais - Os gradientes se acumulam nós atributos
.gradvia adição (não substituição)
O grafo é dinâmico (definido pela execução). Um novo grafo é construído a cada passagem para frente. É por isso que o PyTorch suporta fluxo de controle (if/else, laços) dentro dos modelos.
Construa
Passo 1: A classe Value
class Value:
def __init__(self, data, children=(), op=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(children)
self._op = op
def __repr__(self):
return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"
Cada Value armazena seu dado numérico, seu gradiente (inicialmente zero), uma função para trás e ponteiros para os nós filhos que o produziram.
Passo 2: Operações aritméticas com rastreamento de gradiente
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(max(0, self.data), (self,), 'relu')
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return out
Cada operação cria uma closure que sabe como calcular gradientes locais e multiplicar pelo gradiente que vem de cima (out.grad). O += cuida do caso em que um valor é usado em múltiplas operações.
Passo 3: A passagem para trás
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
A ordenação topológica garante que o gradiente de cada nó esteja totalmente calculado antes de propagar para seus filhos. O gradiente semente é 1.0 (dy/dy = 1).
Passo 4: Mais operações para um motor completo
A classe Value básica lida com adição, multiplicação e relu. Um motor de autograd real precisa de mais. Aqui estão as operações que você precisa para construir redes neurais:
def __neg__(self):
return self * -1
def __sub__(self, other):
return self + (-other)
def __radd__(self, other):
return self + other
def __rmul__(self, other):
return self * other
def __rsub__(self, other):
return other + (-self)
def __pow__(self, n):
out = Value(self.data ** n, (self,), f'**{n}')
def _backward():
self.grad += n * (self.data ** (n - 1)) * out.grad
out._backward = _backward
return out
def __truediv__(self, other):
return self * (other ** -1) if isinstance(other, Value) else self * (Value(other) ** -1)
def exp(self):
import math
e = math.exp(self.data)
out = Value(e, (self,), 'exp')
def _backward():
self.grad += e * out.grad
out._backward = _backward
return out
def log(self):
import math
out = Value(math.log(self.data), (self,), 'log')
def _backward():
self.grad += (1.0 / self.data) * out.grad
out._backward = _backward
return out
def tanh(self):
import math
t = math.tanh(self.data)
out = Value(t, (self,), 'tanh')
def _backward():
self.grad += (1 - t ** 2) * out.grad
out._backward = _backward
return out
Por que cada operação importa:
| Operação | Regra para trás | Usada em |
|---|---|---|
__sub__ |
Reutiliza add + neg | Cálculo da perda (pred - target) |
__pow__ |
n * x^(n-1) | Ativacoes polinomiais, MSE (erro^2) |
__truediv__ |
Reutiliza mul + pow(-1) | Normalização, escala da taxa de aprendizado |
exp |
exp(x) * de cima | Softmax, log-verossimilhança |
log |
(1/x) * de cima | Perda de entropia cruzada, log-probabilidades |
tanh |
(1 - tanh^2) * de cima | Função de ativação clássica |
A parte engenhosa: __sub__ e __truediv__ são definidas em termos de operações existentes. Elas obtêm gradientes corretos de graça porque a regra da cadeia se compõe através das operações subjacentes de add/mul/pow.
Passo 5: Mini MLP do zero
Com uma classe Value completa, você pode construir uma rede neural. Sem PyTorch. Sem NumPy. Apenas Values e a regra da cadeia.
import random
class Neuron:
def __init__(self, n_inputs):
self.w = [Value(random.uniform(-1, 1)) for _ in range(n_inputs)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, n_inputs, n_outputs):
self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]
def __call__(self, x):
return [n(x) for n in self.neurons]
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, sizes):
self.layers = [Layer(sizes[i], sizes[i+1]) for i in range(len(sizes)-1)]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x[0] if len(x) == 1 else x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]
Um Neuron calcula tanh(w1*x1 + w2*x2 + ... + b). Uma Layer é uma lista de neurônios. Um MLP empilha camadas. Cada peso é um Value, então chamar loss.backward() propaga gradientes para cada parâmetro.
Treinamento no XOR:
random.seed(42)
model = MLP([2, 4, 1]) # 2 inputs, 4 hidden neurons, 1 output
xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
ys = [-1, 1, 1, -1] # XOR pattern (using -1/1 for tanh)
for step in range(100):
preds = [model(x) for x in xs]
loss = sum((p - y) ** 2 for p, y in zip(preds, ys))
for p in model.parameters():
p.grad = 0.0
loss.backward()
lr = 0.05
for p in model.parameters():
p.data -= lr * p.grad
if step % 20 == 0:
print(f"step {step:3d} loss = {loss.data:.4f}")
print("\nPredictions after training:")
for x, y in zip(xs, ys):
print(f" input={x} target={y:2d} pred={model(x).data:6.3f}")
Isto é o micrograd. Um laco completo de treinamento de rede neural em Python puro com diferenciação automática. Todo framework comercial de deep learning faz a mesma coisa em escala massiva.
Passo 6: Checagem de gradiente
Como você sabe que seu autodiff está correto? Compare-o com derivadas numéricas. Isto é a checagem de gradiente.
def gradient_check(build_expr, x_val, h=1e-7):
x = Value(x_val)
y = build_expr(x)
y.backward()
autodiff_grad = x.grad
y_plus = build_expr(Value(x_val + h)).data
y_minus = build_expr(Value(x_val - h)).data
numerical_grad = (y_plus - y_minus) / (2 * h)
diff = abs(autodiff_grad - numerical_grad)
return autodiff_grad, numerical_grad, diff
Teste-o em uma expressao complexa:
def expr(x):
return (x ** 3 + x * 2 + 1).tanh()
ad, num, diff = gradient_check(expr, 0.5)
print(f"Autodiff: {ad:.8f}")
print(f"Numerical: {num:.8f}")
print(f"Difference: {diff:.2e}")
# Difference should be < 1e-5
A checagem de gradiente é essencial ao implementar novas operações. Se sua passagem para trás tiver um bug, a checagem numérica o detecta. Toda implementacao seria de deep learning roda checagens de gradiente durante o desenvolvimento.
Quando usar a checagem de gradiente:
| Situação | Fazer checagem de gradiente? |
|---|---|
| Adicionar uma nova operação ao seu autograd | Sim, sempre |
| Depurar um laco de treinamento que não converge | Sim, cheque os gradientes primeiro |
| Treinamento em produção | Não, lento demais (2x passagens para frente por parâmetro) |
| Testes unitários para código de autograd | Sim, automatize |
Passo 7: Verificar contra o cálculo manual
x1 = Value(2.0)
x2 = Value(3.0)
a = x1 * x2 # a = 6.0
b = a + Value(1.0) # b = 7.0
y = b.relu() # y = 7.0
y.backward()
print(f"y = {y.data}") # 7.0
print(f"dy/dx1 = {x1.grad}") # 3.0 (= x2)
print(f"dy/dx2 = {x2.grad}") # 2.0 (= x1)
Checagem manual: y = relu(x1*x2 + 1). Como x1*x2 + 1 = 7 > 0, relu é a identidade.
dy/dx1 = x2 = 3. dy/dx2 = x1 = 2. O motor confere.
Use
Verificar contra o PyTorch
import torch
x1 = torch.tensor(2.0, requires_grad=True)
x2 = torch.tensor(3.0, requires_grad=True)
a = x1 * x2
b = a + 1.0
y = torch.relu(b)
y.backward()
print(f"PyTorch dy/dx1 = {x1.grad.item()}") # 3.0
print(f"PyTorch dy/dx2 = {x2.grad.item()}") # 2.0
Mesmos gradientes. Seu motor calcula o mesmo resultado que o PyTorch porque a matemática é a mesma: autodiff em modo reverso via regra da cadeia.
Uma expressao mais complexa
a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
f = (a * b + c).relu() # relu(2*(-3) + 10) = relu(4) = 4
f.backward()
print(f"df/da = {a.grad}") # -3.0 (= b)
print(f"df/db = {b.grad}") # 2.0 (= a)
print(f"df/dc = {c.grad}") # 1.0
Entregue
Esta lição produz:
outputs/skill-autodiff.md-- uma skill para construir e depurar sistemas de autogradcode/autodiff.py-- um motor de autograd mínimo que você pode estender
A classe Value construída aqui é a base para o laco de treinamento de rede neural na Fase 3.
Exercícios
Adicione
__pow__à classe Value para que você possa calcularx ** n. Verifique qued/dx(x^3)emx=2e igual a12.0.Adicione
tanhcomo função de ativação. Verifique quetanh'(0) = 1etanh'(2) = 0.0707(aprox).Construa um grafo de computação para um único neurônio:
y = relu(w1*x1 + w2*x2 + b). Calcule os cinco gradientes e verifique contra o PyTorch.Implemente o autodiff em modo direto usando números duais. Crie uma classe
Duale verifique que ela fornece as mesmas derivadas que seu motor em modo reverso.
Termos-Chave
| Termo | O que as pessoas dizem | O que realmente significa |
|---|---|---|
| Regra da cadeia | "Multiplique as derivadas" | A derivada de funções compostas é igual ao produto da derivada local de cada função, avaliada no ponto correto |
| Grafo de computação | "O diagrama da rede" | Um grafo acíclico dirigido onde os nós são operações e as arestas carregam valores (para frente) ou gradientes (para trás) |
| Modo direto | "Empurra derivadas para frente" | Autodiff que propaga derivadas das entradas para as saídas. Uma passagem por variável de entrada. |
| Modo reverso | "Retropropagação" | Autodiff que propaga gradientes das saídas para as entradas. Uma passagem por variável de saída. |
| Autograd | "Gradientes automáticos" | Um sistema que registra operações sobre valores, constrói um grafo e calcula gradientes exatos via regra da cadeia |
| Números duais | "Valor mais derivada" | Números da forma a + b*epsilon (epsilon^2 = 0) que carregam informação de derivada pela aritmética |
| Ordenação topológica | "Ordem de dependência" | Ordenar os nós do grafo de modo que cada nó venha depois de todas as suas dependências. Necessária para a propagação correta de gradientes. |
| Acumulação de gradiente | "Some, não substitua" | Quando um valor alimenta múltiplas operações, seu gradiente é a soma de todas as contribuições de gradiente recebidas |
| Grafo dinâmico | "Definido pela execução" | Um grafo de computação reconstruído a cada passagem para frente, permitindo fluxo de controle Python dentro dos modelos (estilo PyTorch) |
| Checagem de gradiente | "Verificação numérica" | Comparar gradientes do autodiff com gradientes numéricos por diferenças finitas para verificar a corretude. Essencial para depuração. |
| MLP | "Perceptron multicamadas" | Uma rede neural com uma ou mais camadas ocultas de neurônios. Cada neurônio calcula uma soma ponderada mais viés, depois aplica uma função de ativação. |
| Neurônio | "Soma ponderada + ativação" | A unidade básica: saída = ativação(w1x1 + w2x2 + ... + b). Os pesos e o viés são parâmetros aprendíveis. |
Leitura Adicional
- 3Blue1Brown: Backpropagation calculus -- explicação visual da regra da cadeia em redes neurais
- PyTorch Autograd mechanics -- como o sistema real funciona
- Baydin et al., Automatic Differentiation in Machine Learning: a Survey -- referência abrangente