Pular para o conteúdo
5/30Capítulo 5 de 30

Backpropagation do zero: primeiro o motor, depois a rede

Escreva um motor autodiff em Python puro, compare com PyTorch até 16 casas decimais e entenda zero_grad ao removê-lo.

Nesta página

Quatro capítulos depois, há um buraco no meio do curso.

O Capítulo 3 nos deu gradient descent: para melhorar um parâmetro, encontre a inclinação da perda em relação a ele e dê um passo ladeira abaixo. O Capítulo 4 nos deu uma perda que vale a pena descer. Mas, em ambos, a derivada foi calculada à mão — um modelo, um parâmetro, uma linha de cálculo, e tudo cabia em uma página.

Agora empilhe duas camadas. A saída da primeira alimenta a segunda, então cada peso na primeira afeta a perda por meio de cada neurônio na segunda. Uma rede com duas camadas ocultas de cem unidades cada tem cerca de vinte mil parâmetros, e cada um precisa de sua própria derivada parcial da mesma perda. Fazer isso à mão não é tedioso; é impossível, e continua impossível para toda arquitetura no restante deste curso.

A saída não é uma notação melhor. É perceber que a derivada de uma composição pode ser calculada mecanicamente, por um programa, a partir da estrutura da própria computação — e que, se você fizer isso na direção certa, obtém todas as vinte mil derivadas por mais ou menos o custo de calcular a perda uma vez.

Esse mecanismo é diferenciação automática em modo reverso. Aplicado a uma rede neural, ele se chama backpropagation, e até o fim deste capítulo você terá escrito um em cerca de 120 linhas de Python, sem bibliotecas, comparado com o PyTorch, e usado para resolver o problema XOR que matou o perceptron no Capítulo 1.

Primeiro: por que precisa haver uma não linearidade

Link para a seção: Primeiro: por que precisa haver uma não linearidade

Antes de construir a máquina, uma pergunta precisa ser resolvida, porque, se a resposta fosse a outra, não haveria nada a construir.

O perceptron falhou no XOR porque uma linha não consegue separar os quatro pontos. A correção óbvia é empilhar: passar a entrada por uma camada linear, depois por outra. Isso ajuda?

Não, e a prova tem duas linhas. Uma camada linear é h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1. Alimente outra com ela, y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2, e substitua:

y=W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)\mathbf{y} = W_2(W_1\mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 = (W_2W_1)\mathbf{x} + (W_2\mathbf{b}_1 + \mathbf{b}_2)

A composição é Wx+bW\mathbf{x} + \mathbf{b} com W=W2W1W = W_2W_1 e b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. Uma pilha de camadas lineares é uma única camada linear. Dez delas, mil delas: ainda uma linha, ainda incapaz de resolver XOR.

Vale a pena ver isso acontecer em vez de apenas acreditar:

linear_is_linear.pyPYTHON
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)

two_layers = W2 @ (W1 @ x + b1) + b2
one_layer  = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))
TEXT
-4.612963371048  -4.612963371048  0.00e+00

Não aproximadamente iguais. Idênticas bit a bit, porque é a mesma aritmética rearranjada.

Então profundidade, por si só, não compra nada. O que compra algo é colocar uma função não linear entre as camadas — e essa é toda a razão pela qual funções de ativação existem. Elas não são um floreio biológico nem um truque de normalização. Sem uma delas, a segunda camada é decoração.

A regra da cadeia, no papel, com um nó compartilhado

Link para a seção: A regra da cadeia, no papel, com um nó compartilhado

Agora a matemática, e é uma regra que você já conhece aplicada em um lugar um pouco menos familiar.

A regra da cadeia de uma variável diz que, se LL depende de cc e cc depende de xx, então dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. Derivadas se multiplicam ao longo de uma cadeia.

A parte que importa aqui é o que acontece quando uma variável alimenta mais de um caminho downstream. Se xx influencia LL por meio de aa e também por meio de bb, as contribuições se somam:

dLdx=Laax+Lbbx\frac{dL}{dx} = \frac{\partial L}{\partial a}\frac{\partial a}{\partial x} + \frac{\partial L}{\partial b}\frac{\partial b}{\partial x}

Multiplique ao longo de um caminho, some entre caminhos. Isso é a totalidade da backpropagation, e todo detalhe de implementação no restante deste capítulo — incluindo o += no código e a chamada zero_grad() que derruba todo mundo que escreve seu primeiro loop de treino — é uma consequência direta dessa segunda palavra.

Pegue um circuito concreto de cinco operações, com x=0.5x = 0.5 e y=1.4y = 1.4:

a=xy,b=x+y,c=ab,d=tanh(c),L=d+xa = xy, \quad b = x + y, \quad c = ab, \quad d = \tanh(c), \quad L = d + x

Observe que xx aparece três vezes: em aa, em bb e diretamente em LL. Faça a passagem backward no papel, da direita para a esquerda, começando por dLdL=1\frac{dL}{dL} = 1:

L=d+xL = d + x, então Ld=1\frac{\partial L}{\partial d} = 1 e o caminho direto contribui Lx=1\frac{\partial L}{\partial x} = 1. A adição distribui o gradiente de entrada inalterado para as duas entradas.

d=tanh(c)d = \tanh(c) com c=ab=0.7×1.9=1.33c = ab = 0.7 \times 1.9 = 1.33, então dLdc=1tanh2(1.33)=0.2444\frac{dL}{dc} = 1 - \tanh^2(1.33) = 0.2444.

c=abc = ab, então dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 e dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711. A multiplicação troca: o gradiente de cada entrada é escalado pela outra entrada.

Por aa: dLday=0.4644×1.4=0.6501\frac{dL}{da} \cdot y = 0.4644 \times 1.4 = 0.6501. Por bb: dLdb1=0.1711\frac{dL}{db} \cdot 1 = 0.1711. Diretamente: 11.

dLdx=0.6501+0.1711+1.0000=1.8212\frac{dL}{dx} = 0.6501 + 0.1711 + 1.0000 = 1.8212

Guarde esse número. Em algumas páginas, um programa vai produzi-lo sem que ninguém tenha contado nada disso a ele.

O insight que torna isso programável: cada uma daquelas etapas era local. Para empurrar um gradiente pelo nó de multiplicação, você precisava do gradiente de entrada e dos dois valores de entrada armazenados — nada sobre o restante do circuito. Cada operação sabe como diferenciar a si mesma.

Então crie um número que se lembra do que o produziu.

value.pyPYTHON
class Value:
    """A number that remembers where it came from."""

    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(_children)      
        self._op = _op

Quatro campos. data é o valor. grad acumula Lself\frac{\partial L}{\partial \text{self}}. _prev é o conjunto de Values a partir dos quais este foi calculado — as arestas do grafo. E _backward é uma closure que cada operação instala: ela sabe como empurrar o gradiente deste nó um passo para trás, até suas entradas.

Todo operador segue o mesmo formato: calcula a saída, registra os pais, instala a regra local.

value.py (continued)PYTHON
    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 tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")

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

        out._backward = _backward
        return out

    def relu(self):
        out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")

        def _backward():
            self.grad += (1.0 if out.data > 0 else 0.0) * out.grad

        out._backward = _backward
        return out

Leia os quatro corpos de _backward como uma tabela e os padrões de fluxo da derivação no papel aparecem logo ali:

operaçãoo que ela faz com o gradiente
+distribui — o mesmo gradiente para todas as entradas
*troca — cada entrada escalada pelo valor da outra
reluroteia — passa adiante ou bloqueia por completo
tanhatenua — escala por 1t21 - t^2, que é no máximo 1 e normalmente menor

Todas usam += e nunca =. Essa é a regra de “somar entre caminhos”, codificada. Um nó que alimenta dois consumidores é chamado duas vezes, e as duas contribuições se somam por conta própria.

Depois vem o driver, que é a única parte com qualquer conhecimento global:

value.py (continued)PYTHON
    def backward(self):
        order, seen = [], set()

        def build(v):
            if v in seen:
                return
            seen.add(v)
            for child in v._prev:
                build(child)
            order.append(v)

        build(self)
        self.grad = 1.0
        for v in reversed(order):       
            v._backward()               

build produz uma ordenação topológica do grafo: todo nó aparece depois de todas as suas entradas. Percorrer essa lista ao contrário garante que, quando você chama o _backward de um nó, seu próprio gradiente já está completo — todo consumidor downstream dele já contribuiu. Erre a ordem e você empurra um gradiente meio pronto para trás, o que produz uma resposta errada sem mensagem de erro.

check.pyPYTHON
x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)
TEXT
forward:  a=0.7000  b=1.9000  c=1.3300  d=0.8692  L=1.3692
backward: dL/dd=1.0000  dL/dc=0.2444  dL/da=0.4644  dL/db=0.1711
          dL/dx=1.8212   dL/dy=0.4033

1.8212. O mesmo número, vindo de um programa que recebeu a regra para +, a regra para *, a regra para tanh, e nada sobre este circuito.

Duas verificações independentes, porque “bate com o que eu derivei” é um teste fraco quando a mesma pessoa fez as duas coisas.

Diferenciação numérica. Dê um pequeno empurrão na entrada e meça. A diferença centrada L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} estima a derivada sem cálculo nenhum:

TEXT
dL/dx:  analytic=1.821202805  numeric=1.821202805  |diff|=1.80e-10
dL/dy:  analytic=0.403269235  numeric=0.403269235  |diff|=7.64e-12

Contra o PyTorch, que tem um motor autodiff industrial escrito por pessoas que fazem isso profissionalmente:

TEXT
torch dL/dx=1.821202805316   ours=1.821202805316   |diff|=2.22e-16
torch dL/dy=0.403269234753   ours=0.403269234753   |diff|=1.11e-16

Concordância em 2×10162 \times 10^{-16}, que é o épsilon de máquina para um float de 64 bits: os dois motores estão realizando aritmética idêntica. Guarde a verificação numérica no bolso — ela é a ferramenta para depurar a passagem backward de uma nova camada, e é a razão pela qual um gradiente errado pode ser encontrado.

O mesmo circuito, entradas diferentes. Defina x=2x = 2 e y=3y = -3, o que torna c=6c = 6:

TEXT
x=0.5, y=1.4:  dL/dc = 0.244400     three paths into x:  0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025     three paths into x:  0.0001 + -0.0001 + 1.0000 = 0.9999

O gradiente atravessando o nó tanh\tanh caiu por um fator de 9.945. Tudo upstream dele — em uma rede real, toda camada antes dele — recebe praticamente nada. Os dois caminhos pelo circuito ficaram em silêncio; só a conexão direta que pula tanh\tanh ainda carrega sinal.

Esse é o problema do gradiente desvanecente, em um nó. Empilhe quarenta camadas de tanh\tanh e multiplique quarenta fatores desse tipo, e as camadas iniciais param de aprender por completo. Também é, incidentalmente, um argumento a favor de conexões de salto que você consegue ver aqui em miniatura: o caminho que contornou a não linearidade é o único que sobreviveu.

O que zero_grad realmente faz, e por que o bug se esconde

Link para a seção: O que zero_grad realmente faz, e por que o bug se esconde

Todo _backward usa +=. Isso está correto — é assim que os caminhos se somam. Mas tem uma consequência que pega todo mundo: gradientes acumulam entre chamadas a backward() também. O motor não faz ideia de que sua segunda chamada é uma nova etapa de treino, e não outro caminho no mesmo grafo.

Então um loop de treino precisa limpá-los:

train.pyPYTHON
for step in range(steps):
    ys = [model(x) for x, _ in DATA]
    loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))

    for p in model.parameters():   
        p.grad = 0.0

    loss.backward()
    for p in model.parameters():
        p.data -= lr * p.grad

Isso é optimizer.zero_grad() no PyTorch, e o conselho usual é que esquecê-lo quebra o treino. Então vamos apagar essas duas linhas e ver o quanto fica quebrado. Mesmas seeds, mesmo tudo, 200 etapas de XOR:

learning rateseedcom resetsem reset
0.051337perda 3.255088, 3/4perda 0.000000, 4/4
0.057perda 2.144820, 2/4perda 0.000000, 4/4
0.0542perda 2.126074, 2/4perda 0.000000, 4/4
0.11337perda 0.038597, 4/4perda 0.000000, 4/4
0.17perda 2.055048, 2/4perda 0.000000, 4/4
0.142perda 2.049876, 2/4perda 0.000073, 4/4
0.31337perda 4.512310, 2/4perda 8.000000, 2/4
0.37perda 0.015247, 4/4perda 4.000000, 3/4
0.342perda 0.005478, 4/4perda 4.000000, 3/4

Nas learning rates pequenas, a versão com bug vence em todas as linhas. Ela converge quando a versão correta empaca.

Isso não é acaso e vale a pena entender, porque explica por que esse bug é tão difícil de pegar. Se você nunca limpa o gradiente, então na etapa kk o parâmetro é atualizado pela soma de todos os gradientes calculados até agora. Em uma perda que continua apontando mais ou menos para a mesma direção, essa soma cresce de forma constante, e o efeito é uma learning rate que aumenta sozinha. Em η=0.05\eta = 0.05, onde o algoritmo correto está se arrastando, o tamanho de passo descontrolado parece exatamente uma correção.

Depois olhe para as três linhas de baixo. Em η=0.3\eta = 0.3, o mesmo mecanismo explode o modelo — perda 8.0 é o que um modelo colapsado para uma constante ±1\pm 1 marca — metade dos 16 que quatro respostas maximamente erradas custariam — enquanto a versão correta agora converge limpamente.

Então a afirmação honesta não é “sempre chame zero_grad ou seu modelo não vai treinar”. É: sem isso, você não está mais executando gradient descent. Você está executando algo cujo tamanho de passo deriva para cima a uma taxa que ninguém escolheu, e vai parecer funcionar, às vezes melhor do que a coisa real, até deixar de funcionar — momento em que você vai culpar a learning rate, a inicialização ou os dados. Esse é o formato dos piores bugs em machine learning: eles não travam, eles transformam o algoritmo em outro algoritmo que ocasionalmente pontua melhor.

Com o motor pronto, uma rede neural quase não exige código. Um neurônio é um produto escalar, um bias e uma ativação; uma camada é uma lista de neurônios; uma rede é uma lista de camadas.

nn.pyPYTHON
class Neuron:
    def __init__(self, nin):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        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, nin, nout):
        self.neurons = [Neuron(nin) for _ in range(nout)]

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

    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]


class MLP:
    def __init__(self, nin, nouts):
        sizes = [nin] + nouts
        self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

Não há passagem backward em nada disso. Nem uma linha. A classe Value já sabe diferenciar qualquer coisa que essas classes venham a construir, que é o objetivo de tê-la escrito primeiro: um motor autodiff não sabe que está sendo usado para uma rede neural.

Agora o problema do Capítulo 1. Duas entradas, duas unidades ocultas, uma saída, nove parâmetros:

TEXT
step   1: loss 4.156690
step  10: loss 4.005572
step  50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597

[0, 0] -> -0.9081  (target -1)  ok
[0, 1] -> +0.8934  (target +1)  ok
[1, 0] -> +0.8906  (target +1)  ok
[1, 1] -> -0.9207  (target -1)  ok

Quatro de quatro. A função que nenhum perceptron consegue calcular — provada no Capítulo 1 por quatro desigualdades que exigiam que bb fosse positivo e negativo ao mesmo tempo — é calculada por nove números encontrados automaticamente.

A parte satisfatória não é funcionar. É conseguir ver como, porque, com duas unidades ocultas, a representação intermediária é um ponto em um plano e você pode simplesmente imprimi-la.

Treinada até uma perda de 0.001241, aqui está onde cada entrada cai depois da camada oculta, e o que o neurônio de saída faz com ela:

entradasaída da camada ocultascore de saídarótulo
(0,0)(0, 0)(+0.8206, 0.8474)(+0.8206,\ -0.8474)2.4045-2.40451-1
(0,1)(0, 1)(+0.9985, +0.8564)(+0.9985,\ +0.8564)+2.3049+2.3049+1+1
(1,0)(1, 0)(0.8401, 0.9990)(-0.8401,\ -0.9990)+2.3006+2.3006+1+1
(1,1)(1, 1)(+0.8368, 0.8550)(+0.8368,\ -0.8550)2.4786-2.47861-1

Olhe para a primeira e a quarta linhas. As entradas (0,0)(0,0) e (1,1)(1,1) são cantos diagonalmente opostos do quadrado — tão distantes quanto dois pontos podem estar neste problema — e a camada oculta as mapeia para (0.82,0.85)(0.82, -0.85) e (0.84,0.86)(0.84, -0.86). Quase o mesmo ponto. A camada dobrou o plano para que os dois cantos rejeitados caiam um sobre o outro, e, quando eles estão no mesmo lugar, uma linha os separa dos outros dois.

E o neurônio de saída é exatamente essa linha. Seus parâmetros aprendidos são w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697, então sua fronteira de decisão é

3.1153h1+3.0893h2+2.7697=0-3.1153\,h_1 + 3.0893\,h_2 + 2.7697 = 0

que é uma linha reta — um perceptron, o mesmo objeto do Capítulo 1, inalterado. Ele não conseguia resolver XOR antes e não consegue agora. O que mudou é que ele não está mais olhando para a entrada; está olhando para um espaço que a primeira camada construiu para ele, no qual o problema é linearmente separável.

Isso é o que uma representação aprendida é, e vale a pena ser preciso porque a expressão é usada de forma solta pelo restante deste curso e pelo restante da área. Não é uma compressão, um resumo ou um embedding em qualquer sentido místico. É uma mudança de coordenadas, aprendida em vez de projetada, cujo único trabalho é facilitar o trabalho da próxima camada.

O teorema da aproximação universal, e o que ele não diz

Link para a seção: O teorema da aproximação universal, e o que ele não diz

Há um teorema aqui, e ele costuma ser mal citado.

Cybenko, em 1989, e Hornik, em 1991, provaram que uma rede feedforward com uma única camada oculta e uma função de ativação adequada pode aproximar qualquer função contínua em um conjunto compacto, com qualquer precisão que você quiser, desde que tenha unidades ocultas suficientes.34 É um resultado real e importante: ele diz que a arquitetura não é a limitação.

Agora leia o que ele omite. Ele não diz quantas unidades — o limite pode ser astronomicamente grande. Ele não diz que os pesos podem ser encontrados; ele afirma existência, e gradient descent a partir de um início aleatório não é um oráculo. E não diz nada sobre comportamento em dados que você não viu, que é a segunda metade do Capítulo 6.

A distância entre “existe” e “é encontrável” não é acadêmica. Aqui está o mesmo problema XOR, 50 inicializações aleatórias cada, 1000 etapas, mudando apenas o tamanho da camada oculta:

unidades ocultasinicializações que chegaram a 4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

Com a arquitetura mínima viável, uma em cada quatro execuções nunca chega lá — ela se acomoda em uma configuração da qual não consegue descer, exatamente o mínimo local que o Capítulo 3 mostrou em uma superfície unidimensional. Adicione uma unidade e as falhas quase desaparecem, não porque a rede tenha ficado mais expressiva (duas unidades já bastam — 38 execuções provam isso), mas porque dimensões extras dão à descida mais direções pelas quais escapar.

E então oito unidades vai ligeiramente pior do que quatro. Com uma learning rate e um orçamento de etapas fixos, mais capacidade não é monotonicamente melhor. Quem disser que a correção para uma rede empacada é sempre uma rede maior está extrapolando a partir do meio dessa tabela.

Esta é a mesma lição do teorema de convergência no Capítulo 1, e será a mesma lição no Capítulo 10 sobre leis de escala, na forma que esse capítulo dá a ela: uma previsão da perda não é uma previsão da capacidade pela qual você está pagando, e a distância entre as duas é onde vive a engenharia.

Mostrar detalhes

Opcional: a forma matricial, e por que o código acima não a usa.

Tudo aqui foi escrito um escalar por vez, que é a forma mais clara de ver o mecanismo e a forma mais lenta de executá-lo. Na prática, uma camada é uma multiplicação de matrizes, e a passagem backward de y=Wx\mathbf{y} = W\mathbf{x} é

LW=Lyx,Lx=WLy\frac{\partial L}{\partial W} = \frac{\partial L}{\partial \mathbf{y}}\mathbf{x}^\top, \qquad \frac{\partial L}{\partial \mathbf{x}} = W^\top\frac{\partial L}{\partial \mathbf{y}}

As transpostas não são um truque para memorizar; elas são a aparência da regra de somar entre caminhos quando os caminhos são indexados por entradas de matriz. O objeto geral é o Jacobiano, a matriz de todas as derivadas parciais de todas as saídas em relação a todas as entradas, e o modo reverso é precisamente o cálculo de um produto vetor-Jacobiano sem jamais formar o Jacobiano — o que importa, porque, para uma camada com 4096 entradas e 4096 saídas, essa matriz tem dezesseis milhões de entradas e nunca vale a pena construí-la.

Você não precisa de nada disso para acompanhar os próximos capítulos; a versão escalar faz tudo o que a versão matricial faz, mais devagar. Isso se torna necessário no Capítulo 9, onde os formatos deixam de ser óbvios.

Agora você tem uma rede que treina. Isso é uma conquista menor do que parece, porque a rede que você tem treina em quatro exemplos e é medida nos mesmos quatro.

Rode o mesmo código em um dataset real e surge um novo conjunto de problemas, nenhum deles sobre gradientes. A perda cai por um tempo e depois para. Ou cai nos dados de treino e sobe em todo o resto. Ou não se mexe desde a primeira etapa, e a causa acaba sendo a faixa dos pesos aleatórios iniciais. Ou a entrada de uma unidade ficou negativa em todos os exemplos na terceira época e ela está morta desde então, em silêncio, levando consigo um pedaço da capacidade do modelo.

Essas não são falhas exóticas; são a condição normal de uma rede que acabou de ser escrita, e nenhuma delas se anuncia. O gradiente está correto — você o comparou com o PyTorch até a décima sexta casa decimal — e o modelo ainda assim não aprende.

O Capítulo 6 trata disso: inicialização, normalização, overfitting e regularização, e o hábito diagnóstico de perguntar qual dessas coisas está acontecendo antes de mudar qualquer coisa. É a diferença entre uma rede que roda e uma rede que funciona.


A classe Value deste capítulo descende diretamente do micrograd de Andrej Karpathy, e o vídeo dele The spelled-out intro to neural networks and backpropagation: building micrograd é as melhores três horas que você pode gastar neste material se quiser vê-lo explicado de uma segunda forma por outra pessoa. O post dele de 2016, Yes you should understand backprop, defende o argumento de escrever um por conta própria e é leitura obrigatória no CS224n de Stanford. As notas do CS231n sobre backpropagation (cs231n.github.io/optimization-2) são o tratamento canônico dos padrões de fluxo tabulados acima. Para a matemática como cálculo em um grafo, e não como folclore de redes neurais, o capítulo 5.6 de Mathematics for Machine Learning, de Deisenroth, Faisal e Ong, é incomumente claro; e a revisão de Baydin, Pearlmutter, Radul e Siskind, Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767), é a referência da área como um todo, incluindo a troca entre forward e reverso discutida acima.

  1. Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Dissertação de mestrado, Universidade de Helsinki (1970). Acumulação em modo reverso, dezesseis anos antes de chegar a este campo e sob uma motivação completamente diferente.

  2. Rumelhart, D. E., Hinton, G. E. e Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). O artigo que tornou o método conhecido, e a fonte da leitura das unidades ocultas como representações aprendidas em que a seção O que a camada oculta fez deste capítulo concentra suas medições.

  3. Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989).

  4. Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). Generaliza Cybenko: o resultado depende de a ativação ser não polinomial, não de ser sigmoidal.

Pronto para deixar a LIA escolher por você?

Crie com todos os modelos de IA em um só lugar — comece grátis hoje.