Phase 03 - Lesson 03

Backpropagation do zero

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Backpropagation é o algoritmo que torna o aprendizado possível. Sem ele, redes neurais são apenas geradores de números aleatórios caros.

Tipo: Build Linguagens: Python Pré-requisitos: Lição 03.02 (Redes Multicamadas) Tempo: ~120 minutos

Objetivos de aprendizagem

  • Implementar um motor de autograd baseado em Value que constrói um grafo computacional e calcula gradientes via ordenação topológica
  • Derivar o passo backward para adição, multiplicação e sigmoide usando a regra da cadeia
  • Treinar uma rede multicamadas em XOR e classificação de círculo usando apenas o seu motor de backpropagation feito do zero
  • Identificar o problema do gradiente que desaparece em redes sigmoides profundas e explicar por que os gradientes encolhem exponencialmente

O Problema

Sua rede tem uma única camada oculta com 768 entradas e 3072 saídas. Isso são 2.359.296 pesos. Ela fez uma previsão errada. Quais pesos causaram o erro? Testar cada peso individualmente significa 2,3 milhões de passos forward. Backpropagation calcula todos os 2,3 milhões de gradientes em um único passo backward. Isso não é uma otimização. É a diferença entre treinável e impossível.

A abordagem ingênua: pegue um peso, ajuste-o por uma quantidade minúscula, rode o passo forward novamente, meça se a perda subiu ou desceu. Isso te dá o gradiente para aquele peso. Agora faça isso para cada peso na rede. Multiplique por milhares de passos de treinamento e milhões de pontos de dados. Você precisaria de tempo geológico para treinar qualquer coisa útil.

Backpropagation resolve isso. Um passo forward, um passo backward, todos os gradientes calculados. O truque é a regra da cadeia do cálculo, aplicada sistematicamente a um grafo computacional. Este é o algoritmo que tornou o deep learning prático. Sem ele, ainda estaríamos presos em problemas de brinquedo.

O Conceito

A Regra da Cadeia, Aplicada a Redes

Você viu a regra da cadeia na Fase 01, Lição 05. Recapitulação rápida: se y = f(g(x)), então dy/dx = f'(g(x)) * g'(x). Você multiplica derivadas ao longo da cadeia.

Em uma rede neural, a "cadeia" é a sequência de operações da entrada até a perda. Cada camada aplica pesos, soma vieses, passa por uma ativação. A função de perda compara a saída final com o alvo. Backpropagation traça essa cadeia de trás para frente, calculando como cada operação contribuiu para o erro.

Grafos Computacionais

Todo passo forward constrói um grafo. Cada nó é uma operação (multiplicar, somar, sigmoide). Cada aresta carrega um valor para frente e um gradiente para trás.

graph LR
    x["x"] --> mul["*"]
    w["w"] --> mul
    mul -- "z1 = w*x" --> add["+"]
    b["b"] --> add
    add -- "z2 = z1 + b" --> sig["sigmoid"]
    sig -- "a = sigmoid(z2)" --> loss["Perda"]
    y["alvo"] --> loss

Passo forward: valores fluem da esquerda para a direita. x e w produzem z1 = w*x. Some b para obter z2. Sigmoide dá a ativação a. Compare a com o alvo y usando a função de perda.

Passo backward: gradientes fluem da direita para a esquerda. Comece com dL/da (como a perda muda com a ativação). Multiplique por da/dz2 (derivada da sigmoide). Isso dá dL/dz2. Divida em dL/db (que é igual a dL/dz2, já que z2 = z1 + b) e dL/dz1. Então dL/dw = dL/dz1 * x e dL/dx = dL/dz1 * w.

Todo nó no grafo tem um trabalho durante o passo backward: pegar o gradiente que vem de cima, multiplicar pela sua derivada local e passá-lo para baixo.

Forward vs Backward

graph TB
    subgraph Forward["Passo Forward"]
        direction LR
        f1["Entrada x"] --> f2["z = Wx + b"]
        f2 --> f3["a = sigmoid(z)"]
        f3 --> f4["Perda = (a - y)^2"]
    end
    subgraph Backward["Passo Backward"]
        direction RL
        b4["dL/dL = 1"] --> b3["dL/da = 2(a-y)"]
        b3 --> b2["dL/dz = dL/da * a(1-a)"]
        b2 --> b1["dL/dW = dL/dz * x\ndL/db = dL/dz"]
    end
    Forward --> Backward

O passo forward armazena todo valor intermediário: z, a, as entradas de cada camada. O passo backward precisa desses valores armazenados para calcular gradientes. Este é o tradeoff memória-computação no coração do backprop. Você troca memória (armazenando ativações) por velocidade (um passo em vez de milhões).

Fluxo de Gradiente Através de uma Rede

Para uma rede de 3 camadas, gradientes encadeiam através de cada camada:

graph RL
    L["Perda"] -- "dL/da3" --> L3["Camada 3\na3 = sigmoid(z3)"]
    L3 -- "dL/dz3 = dL/da3 * sigmoid'(z3)" --> L2["Camada 2\na2 = sigmoid(z2)"]
    L2 -- "dL/dz2 = dL/da2 * sigmoid'(z2)" --> L1["Camada 1\na1 = sigmoid(z1)"]
    L1 -- "dL/dz1 = dL/da1 * sigmoid'(z1)" --> I["Entrada"]

Em cada camada, o gradiente é multiplicado pela derivada da sigmoide. A derivada da sigmoide é a * (1 - a), que atinge no máximo 0,25 (quando a = 0,5). Três camadas de profundidade, o gradiente foi multiplicado por no máximo 0,25^3 = 0,0156. Dez camadas de profundidade: 0,25^10 = 0,000001.

Gradientes que Desaparecem

Este é o problema do gradiente que desaparece. A sigmoide comprime sua saída entre 0 e 1. Sua derivada é sempre menor que 0,25. Empilhe sigmoides suficientes e os gradientes encolhem até nada. As camadas iniciais quase não aprendem porque recebem gradientes próximos de zero.

sigmoid(z):     Faixa de saída [0, 1]
sigmoid'(z):    Valor máximo 0.25 (em z = 0)

Após 5 camadas:   gradiente * 0.25^5 = 0.001x do original
Após 10 camadas:  gradiente * 0.25^10 = 0.000001x do original

É por isso que redes sigmoides profundas são quase impossíveis de treinar. A solução -- ReLU e suas variantes -- é o tema da Lição 04. Por enquanto, entenda que o backprop funciona perfeitamente. O problema é aquilo através do qual ele está trabalhando.

Derivando Gradientes para uma Rede de 2 Camadas

Matemática concreta para uma rede com entrada x, camada oculta com sigmoide, camada de saída com sigmoide e perda MSE.

Passo forward:

z1 = W1 * x + b1
a1 = sigmoid(z1)
z2 = W2 * a1 + b2
a2 = sigmoid(z2)
L = (a2 - y)^2

Passo backward (aplicando a regra da cadeia passo a passo):

dL/da2 = 2(a2 - y)
da2/dz2 = a2 * (1 - a2)
dL/dz2 = dL/da2 * da2/dz2 = 2(a2 - y) * a2 * (1 - a2)

dL/dW2 = dL/dz2 * a1
dL/db2 = dL/dz2

dL/da1 = dL/dz2 * W2
da1/dz1 = a1 * (1 - a1)
dL/dz1 = dL/da1 * da1/dz1

dL/dW1 = dL/dz1 * x
dL/db1 = dL/dz1

Todo gradiente é um produto de derivadas locais traçadas de volta a partir da perda. Isso é tudo o que backpropagation é.

Construa

Passo 1: O Nó Value

Todo número em nossa computação se torna um Value. Ele armazena seus dados, seu gradiente e como foi criado (para que saiba como calcular gradientes de trás para frente).

class Value:
    def __init__(self, data, children=(), op=''):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._children = set(children)
        self._op = op

    def __repr__(self):
        return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

Ainda sem gradiente (0.0). Ainda sem função backward (no-op). Os _children rastreiam quais Values produziram este, para que possamos ordenar topologicamente o grafo depois.

Passo 2: Operações com Funções Backward

Cada operação cria um novo Value e define como os gradientes fluem para trás através dela.

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

Para adição: d(a+b)/da = 1, d(a+b)/db = 1. Então ambas as entradas recebem o gradiente da saída diretamente.

Para multiplicação: d(ab)/da = b, d(ab)/db = a. Cada entrada recebe o valor da outra vezes o gradiente da saída.

O += é crítico. Um Value pode ser usado em múltiplas operações. Seu gradiente é a soma dos gradientes de todos os caminhos.

Passo 3: Sigmoide e Perda

import math

def sigmoid(self):
    x = self.data
    x = max(-500, min(500, x))
    s = 1.0 / (1.0 + math.exp(-x))
    out = Value(s, (self,), 'sigmoid')

    def _backward():
        self.grad += (s * (1 - s)) * out.grad

    out._backward = _backward
    return out

Derivada da sigmoide: sigmoid(x) * (1 - sigmoid(x)). Nós calculamos sigmoid(x) = s durante o passo forward. Reutilize. Sem trabalho extra.

def mse_loss(predicted, target):
    diff = predicted + Value(-target)
    return diff * diff

MSE para uma única saída: (predicted - target)^2. Expressamos a subtração como adição com um Value negado.

Passo 4: Passo Backward

A ordenação topológica garante que processamos os nós na ordem certa -- o gradiente de um nó é totalmente acumulado antes de propagarmos através dele.

def backward(self):
    topo = []
    visited = set()

    def build_topo(v):
        if v not in visited:
            visited.add(v)
            for child in v._children:
                build_topo(child)
            topo.append(v)

    build_topo(self)
    self.grad = 1.0
    for v in reversed(topo):
        v._backward()

Comece na perda (gradiente = 1.0, já que dL/dL = 1). Caminhe para trás através do grafo ordenado. O _backward de cada nó empurra gradientes para seus filhos.

Passo 5: Camada e Rede

import random

class Neuron:
    def __init__(self, n_inputs):
        scale = (2.0 / n_inputs) ** 0.5
        self.weights = [Value(random.uniform(-scale, scale)) for _ in range(n_inputs)]
        self.bias = Value(0.0)

    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.weights, x)), self.bias)
        return act.sigmoid()

    def parameters(self):
        return self.weights + [self.bias]


class Layer:
    def __init__(self, n_inputs, n_outputs):
        self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]

    def __call__(self, x):
        out = [n(x) for n in self.neurons]
        return out[0] if len(out) == 1 else out

    def parameters(self):
        params = []
        for n in self.neurons:
            params.extend(n.parameters())
        return params


class Network:
    def __init__(self, sizes):
        self.layers = []
        for i in range(len(sizes) - 1):
            self.layers.append(Layer(sizes[i], sizes[i + 1]))

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
            if not isinstance(x, list):
                x = [x]
        return x[0] if len(x) == 1 else x

    def parameters(self):
        params = []
        for layer in self.layers:
            params.extend(layer.parameters())
        return params

    def zero_grad(self):
        for p in self.parameters():
            p.grad = 0.0

Um Neuron recebe entradas, calcula a soma ponderada + viés e aplica a sigmoide. A inicialização dos pesos escala por sqrt(2/n_inputs) para evitar a saturação da sigmoide em redes mais profundas. Uma Layer é uma lista de Neurons. Uma Network é uma lista de Layers. O método parameters() coleta todos os Values aprendíveis para que possamos atualizá-los.

Passo 6: Treinar em XOR

random.seed(42)
net = Network([2, 4, 1])

xor_data = [
    ([0.0, 0.0], 0.0),
    ([0.0, 1.0], 1.0),
    ([1.0, 0.0], 1.0),
    ([1.0, 1.0], 0.0),
]

learning_rate = 1.0

for epoch in range(1000):
    total_loss = Value(0.0)
    for inputs, target in xor_data:
        x = [Value(i) for i in inputs]
        pred = net(x)
        loss = mse_loss(pred, target)
        total_loss = total_loss + loss

    net.zero_grad()
    total_loss.backward()

    for p in net.parameters():
        p.data -= learning_rate * p.grad

    if epoch % 100 == 0:
        print(f"Epoch {epoch:4d} | Loss: {total_loss.data:.6f}")

print("\nXOR Results:")
for inputs, target in xor_data:
    x = [Value(i) for i in inputs]
    pred = net(x)
    print(f"  {inputs} -> {pred.data:.4f} (expected {target})")

Observe a perda diminuir. De previsões aleatórias a saídas XOR corretas, conduzido inteiramente por backpropagation calculando gradientes e ajustando pesos na direção certa.

Passo 7: Classificação de Círculo

Na Lição 02, você ajustou pesos manualmente para classificação de círculo. Agora deixe a rede aprendê-los.

random.seed(7)

def generate_circle_data(n=100):
    data = []
    for _ in range(n):
        x1 = random.uniform(-1.5, 1.5)
        x2 = random.uniform(-1.5, 1.5)
        label = 1.0 if x1 * x1 + x2 * x2 < 1.0 else 0.0
        data.append(([x1, x2], label))
    return data

circle_data = generate_circle_data(80)

circle_net = Network([2, 8, 1])
learning_rate = 0.5

for epoch in range(2000):
    random.shuffle(circle_data)
    total_loss_val = 0.0
    for inputs, target in circle_data:
        x = [Value(i) for i in inputs]
        pred = circle_net(x)
        loss = mse_loss(pred, target)
        circle_net.zero_grad()
        loss.backward()
        for p in circle_net.parameters():
            p.data -= learning_rate * p.grad
        total_loss_val += loss.data

    if epoch % 200 == 0:
        correct = 0
        for inputs, target in circle_data:
            x = [Value(i) for i in inputs]
            pred = circle_net(x)
            predicted_class = 1.0 if pred.data > 0.5 else 0.0
            if predicted_class == target:
                correct += 1
        accuracy = correct / len(circle_data) * 100
        print(f"Epoch {epoch:4d} | Loss: {total_loss_val:.4f} | Accuracy: {accuracy:.1f}%")

Usamos SGD online aqui -- atualizamos os pesos após cada amostra em vez de acumular o batch completo. Isso quebra a simetria mais rápido e evita a saturação da sigmoide no panorama de perda completo. Embaralhar os dados a cada época impede que a rede memorize a ordem.

Sem ajuste manual. A rede descobre a fronteira de decisão circular por conta própria. Esse é o poder do backpropagation: você define a arquitetura, a função de perda e os dados. O algoritmo descobre os pesos.

Use

PyTorch faz tudo acima em poucas linhas. A ideia central é idêntica -- autograd constrói um grafo computacional durante o passo forward e o traça de trás para frente para calcular gradientes.

import torch
import torch.nn as nn

model = nn.Sequential(
    nn.Linear(2, 4),
    nn.Sigmoid(),
    nn.Linear(4, 1),
    nn.Sigmoid(),
)
optimizer = torch.optim.SGD(model.parameters(), lr=1.0)
criterion = nn.MSELoss()

X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)

for epoch in range(1000):
    pred = model(X)
    loss = criterion(pred, y)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

print("PyTorch XOR Results:")
with torch.no_grad():
    for i in range(4):
        pred = model(X[i])
        print(f"  {X[i].tolist()} -> {pred.item():.4f} (expected {y[i].item()})")

loss.backward() é o seu total_loss.backward(). optimizer.step() é o seu p.data -= lr * p.grad manual. optimizer.zero_grad() é o seu net.zero_grad(). Mesmo algoritmo, implementação de força industrial. PyTorch lida com aceleração por GPU, precisão mista, gradient checkpointing e centenas de tipos de camadas. Mas o passo backward é a mesma regra da cadeia aplicada ao mesmo grafo computacional.

O treinamento roda o passo forward, depois o passo backward, depois atualiza os pesos. A inferência roda apenas o passo forward. Sem gradientes, sem atualizações. Essa distinção importa porque a inferência é o que acontece em produção. Quando você chama uma API como Claude ou GPT, você está rodando inferência -- seu prompt flui para frente através da rede, e tokens saem do outro lado. Nenhum peso muda. Entender backprop importa porque ele moldou cada peso naquela rede.

Entregue

Esta lição produz:

  • outputs/prompt-gradient-debugger.md -- um prompt reutilizável para diagnosticar problemas de gradiente (que desaparece, que explode, NaN) em qualquer rede neural

Exercícios

  1. Adicione um método __sub__ à classe Value (a - b = a + (-1 * b)). Depois implemente um método __neg__. Verifique que os gradientes estão corretos comparando com o cálculo manual para uma expressão simples como (a - b)^2.

  2. Adicione um método relu ao Value (saída max(0, x), derivada é 1 se x > 0, senão 0). Substitua a sigmoide por relu nas camadas ocultas e treine em XOR novamente. Compare a velocidade de convergência. Você deve ver treinamento mais rápido -- isso prenuncia a Lição 04.

  3. Implemente um método __pow__ no Value para potências inteiras. Use-o para substituir mse_loss por uma expressão (predicted - target) ** 2 apropriada. Verifique que os gradientes correspondem à implementação original.

  4. Adicione clipping de gradiente ao loop de treinamento: após chamar backward(), faça clipping de todos os gradientes para [-1, 1]. Treine uma rede mais profunda (4+ camadas com sigmoide) e compare as curvas de perda com e sem clipping. Esta é sua primeira defesa contra gradientes que explodem.

  5. Construa uma visualização: após treinar em XOR, imprima o gradiente de cada parâmetro na rede. Identifique qual camada tem os menores gradientes. Isso demonstra o problema do gradiente que desaparece sobre o qual você leu na seção do Conceito.

Termos-chave

Termo O que as pessoas dizem O que realmente significa
Backpropagation "A rede aprende" Um algoritmo que calcula dL/dw para cada peso aplicando a regra da cadeia de trás para frente através do grafo computacional
Grafo computacional "A estrutura da rede" Um grafo acíclico direcionado onde os nós são operações e as arestas carregam valores (forward) e gradientes (backward)
Regra da cadeia "Multiplique as derivadas" Se y = f(g(x)), então dy/dx = f'(g(x)) * g'(x) -- a fundação matemática do backpropagation
Gradiente "A direção de subida mais íngreme" A derivada parcial da perda em relação a um parâmetro -- diz como mudar esse parâmetro para reduzir a perda
Gradiente que desaparece "Redes profundas não aprendem" Gradientes encolhem exponencialmente conforme se propagam através de camadas com ativações saturantes como a sigmoide
Passo forward "Rodar a rede" Calcular a saída a partir das entradas aplicando sequencialmente as operações de cada camada e armazenando valores intermediários
Passo backward "Calcular gradientes" Percorrer o grafo computacional em reverso, acumulando gradientes em cada nó usando a regra da cadeia
Taxa de aprendizagem "Quão rápido aprende" Um escalar que controla o tamanho do passo ao atualizar pesos: w_new = w_old - lr * gradient
Ordenação topológica "A ordem certa" Uma ordenação de nós do grafo onde cada nó aparece após todos os nós dos quais depende -- garante que os gradientes sejam totalmente acumulados antes da propagação
Autograd "Diferenciação automática" Um sistema que constrói grafos computacionais durante a computação forward e calcula gradientes automaticamente -- o que o motor do PyTorch faz

Leitura Adicional

  • Rumelhart, Hinton & Williams, "Learning representations by back-propagating errors" (1986) -- o artigo que tornou o backpropagation mainstream e desbloqueou o treinamento de redes multicamadas
  • 3Blue1Brown, série "Neural Networks" (https://www.youtube.com/playlist?list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi) -- a melhor explicação visual de backpropagation e fluxo de gradiente através de redes
0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).