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:

  1. Empacotamento de valor. Envolva cada número em um objeto que armazena seu valor e gradiente.
  2. Registro do grafo. Cada operação registra suas entradas e a função de gradiente local.
  3. 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:

  1. Cria um no Tensor para x com requires_grad=True
  2. Cada operação (**, *, +) cria um novo nó e registra a função para trás
  3. y.backward() dispara o autodiff em modo reverso pelo grafo registrado
  4. O grad_fn de cada nó calcula gradientes locais e os passa para os nós pais
  5. Os gradientes se acumulam nós atributos .grad via 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 autograd
  • code/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

  1. Adicione __pow__ à classe Value para que você possa calcular x ** n. Verifique que d/dx(x^3) em x=2 e igual a 12.0.

  2. Adicione tanh como função de ativação. Verifique que tanh'(0) = 1 e tanh'(2) = 0.0707 (aprox).

  3. 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.

  4. Implemente o autodiff em modo direto usando números duais. Crie uma classe Dual e 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

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).