Deep Learning
Entenda o que realmente acontece dentro de uma rede.
Deep Learning e redes neurais
A expressão “rede neural” tem tudo para intimidar, e o vocabulário biológico não ajuda: ele sugere uma maquinaria misteriosa, vagamente cerebral, fora do alcance de quem não tem doutorado em matemática. A realidade é mais modesta e muito mais interessante. Uma rede neural é um empilhamento de multiplicações, somas e uma função simples aplicada ao resultado. Nada mais.
O que vale a pena entender, portanto, não é a fórmula de um neurônio — ela cabe em uma linha — mas duas coisas menos óbvias. Por que é preciso empilhar várias camadas, se uma só bastaria caso o mundo fosse simples? E como uma máquina consegue encontrar sozinha milhões de parâmetros, sem que ninguém lhe indique a direção?
A resposta à segunda pergunta se chama retropropagação, e ela costuma ser apresentada ou como algo óbvio, ou como um muro de equações. Vamos seguir um terceiro caminho: escrevê-la inteira em umas trinta linhas de NumPy, em um problema que podemos verificar a olho nu, e observar o erro diminuir passo a passo.
Ao final desta lição, você terá construído uma rede que aprende, sem biblioteca de aprendizado profundo, e saberá exatamente o que cada linha faz.
Um neurônio: uma soma ponderada e uma decisão
Um neurônio artificial recebe vários números de entrada, multiplica cada um por um peso, soma tudo, adiciona um viés (bias) e passa o resultado por uma função. É só isso.
import numpy as np
def neurone(entrees, poids, biais):
somme = float(np.dot(entrees, poids) + biais)
return 1 if somme > 0 else 0 # ativação: simples limiar
# Um neurônio que calcula o E lógico.
poids = np.array([1.0, 1.0])
biais = -1.5
for x in ([0, 0], [0, 1], [1, 0], [1, 1]):
print(x, neurone(np.array(x, dtype=float), poids, biais))
# [0, 0] 0
# [0, 1] 0
# [1, 0] 0
# [1, 1] 1
A aritmética se confere de cabeça: com as duas entradas em 1, a soma vale 1 + 1 - 1,5 = 0,5, positiva, então a saída é 1. Com apenas uma entrada em 1, a soma vale 1 - 1,5 = -0,5, negativa, então 0. O viés desempenha exatamente o papel de um limiar de disparo: é preciso ultrapassar 1,5 para ativar o neurônio.
Esse dispositivo é o perceptron, proposto por Frank Rosenblatt em 1957. Aqui os pesos foram definidos à mão; o aprendizado consistirá em fazer a máquina encontrá-los.
Por que empilhar camadas
Um único neurônio traça uma fronteira reta no espaço das entradas. Enquanto as classes forem separáveis por uma reta, isso basta. O problema é que um caso tão elementar quanto o OU exclusivo não é.
x2
│
1 │ ●(0,1) ○(1,1) ● = classe 1
│ ○ = classe 0
│
0 │ ○(0,0) ●(1,0)
└──────────────────────────── x1
0 1
Nenhuma reta consegue colocar os dois ● de um lado
e os dois ○ do outro.
Isso não é uma intuição, dá para medir:
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([0, 1, 1, 0]) # OU exclusivo
lineaire = LogisticRegression().fit(X, y)
print(lineaire.score(X, y), lineaire.predict(X)) # 0.5 [0 0 0 0]
reseau = MLPClassifier(hidden_layer_sizes=(4,), activation="tanh",
max_iter=5000, random_state=1).fit(X, y)
print(reseau.score(X, y), reseau.predict(X)) # 1.0 [0 1 1 0]
O modelo linear obtém 0,5 — a pontuação do acaso em um problema equilibrado — e prevê a mesma classe para os quatro pontos: não conseguiu fazer nada. A rede com uma camada oculta de quatro neurônios obtém 1,0.
O mecanismo é o seguinte: a camada oculta transforma o espaço de entrada. No novo espaço que ela produz, os quatro pontos tornam-se separáveis por uma reta, e a camada de saída só precisa traçar essa reta. Empilhar camadas é encadear mudanças de representação até que o problema fique simples.
Essa rede tem apenas 17 parâmetros: 2 × 4 pesos de entrada, 4 vieses ocultos, 4 × 1 pesos de saída, 1 viés de saída. Uma rede moderna aplica o mesmo princípio com várias ordens de grandeza a mais.
As funções de ativação
O limiar do perceptron tem um defeito fatal para o aprendizado: sua inclinação é nula em todo lugar, exceto em um ponto onde é infinita. É impossível extrair dele uma direção de correção. Por isso o substituímos por funções suaves.
import numpy as np
z = np.array([-2.0, -0.5, 0.0, 0.5, 2.0])
print(np.maximum(0, z)) # [0. 0. 0. 0.5 2. ] ReLU
print((1 / (1 + np.exp(-z))).round(3)) # [0.119 0.378 0.5 0.622 0.881] sigmóide
print(np.tanh(z).round(3)) # [-0.964 -0.462 0. 0.462 0.964] tanh
| Função | Saída | Uso comum | Ponto de atenção |
|---|---|---|---|
ReLU max(0, z) | 0 ou z | camadas ocultas, padrão | um neurônio travado em 0 não se corrige mais |
| Sigmóide | entre 0 e 1 | saída de uma classificação binária | inclinação quase nula nos extremos |
| tanh | entre −1 e 1 | camadas ocultas, redes pequenas | mesma saturação da sigmóide |
O papel dessas funções costuma ser mal compreendido. Elas não estão ali para “normalizar”: estão ali para introduzir não linearidade. Sem elas, empilhar dez camadas de multiplicações e somas produziria… uma multiplicação e uma soma. A composição de funções lineares é linear, então uma rede sem ativação, qualquer que seja sua profundidade, continuaria tão impotente diante do OU exclusivo quanto a regressão logística acima.
O gradiente descendente: corrigir na direção certa
Resta a questão central: como encontrar os pesos? A ideia é medir o erro, calcular em que sentido cada parâmetro deve se mover para reduzi-lo e dar um pequeno passo nesse sentido. Depois, recomeçar.
Vamos retomar os aluguéis da lição anterior, mas sem LinearRegression: desta vez, nós mesmos escrevemos o aprendizado.
import numpy as np
x = np.array([30.0, 45.0, 60.0, 75.0, 90.0])
y = np.array([620.0, 780.0, 1010.0, 1150.0, 1400.0])
# Padronização das entradas: indispensável, veremos o porquê logo adiante.
xn = (x - x.mean()) / x.std()
a, b = 0.0, 0.0
pas = 0.1
for etape in range(1, 201):
prediction = a * xn + b
erreur = prediction - y
perte = (erreur ** 2).mean() # erro quadrático médio
# Derivadas da perda em relação a a e b.
a -= pas * 2 * (erreur * xn).mean()
b -= pas * 2 * erreur.mean()
if etape in (1, 10, 50, 200):
print(etape, round(perte, 1), round(a, 2), round(b, 2))
# 1 1059080.0 54.59 198.4
# 10 19587.4 243.64 885.48
# 50 518.0 272.94 991.99
# 200 518.0 272.94 992.0
A perda cai de mais de um milhão para 518 e se estabiliza. Duas verificações mostram que o resultado não é aproximado:
- O viés converge para 992,0, que é exatamente a média dos cinco aluguéis:
(620 + 780 + 1010 + 1150 + 1400) / 5 = 992. - A inclinação converge para 272,94 no espaço padronizado. Ao convertê-la para metros quadrados, dividimos pelo desvio padrão das áreas, que é 21,213:
272,94 / 21,213 ≈ 12,86€ por m². É o coeficiente queLinearRegressionencontrou na lição anterior, obtido aqui por tentativa guiada.
A padronização não é cosmética. Com as áreas brutas e o mesmo passo de 0,1, a descida diverge: a perda vai de 1,06 × 10⁶ a 6,9 × 10¹¹ já na segunda iteração e depois explode. O gradiente é proporcional à amplitude das entradas; entradas em torno de 60 produzem passos gigantescos que ultrapassam o mínimo em vez de se aproximar dele. É a principal causa de falha em um treinamento que “não converge”.
perda
│╲
│ ╲ passo pequeno demais: correto, mas lento
│ ╲___
│ ╲___
│ ╲________
└────────────────────► iterações
perda
│ ╱╲ ╱╲
│ ╱ ╲ ╱ ╲ passo grande demais: oscila ou explode
│ ╱ ╲╱ ╲
└────────────────────► iterações
A retropropagação, sem formalismo
Em um modelo com dois parâmetros, escrevemos as derivadas à mão. Em uma rede, um peso da primeira camada influencia a saída através de todas as camadas seguintes. A retropropagação é a forma de organizar esse cálculo: propagamos o erro da saída para a entrada, camada por camada, reaproveitando a cada etapa o que já foi calculado.
Aqui está uma rede completa — duas entradas, quatro neurônios ocultos, uma saída — escrita sem nenhuma biblioteca de aprendizado profundo.
import numpy as np
rng = np.random.default_rng(1)
X = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
y = np.array([[0.0], [1.0], [1.0], [0.0]]) # OU exclusivo
# Inicialização aleatória: dois pesos idênticos receberiam sempre
# a mesma correção e permaneceriam idênticos para sempre.
W1 = rng.normal(0, 1, (2, 4))
b1 = np.zeros((1, 4))
W2 = rng.normal(0, 1, (4, 1))
b2 = np.zeros((1, 1))
def sigmoide(z):
return 1 / (1 + np.exp(-z))
for etape in range(1, 5001):
# --- Passagem direta: da entrada para a previsão ---
z1 = X @ W1 + b1
a1 = np.tanh(z1)
z2 = a1 @ W2 + b2
a2 = sigmoide(z2)
perte = float(np.mean((a2 - y) ** 2))
# --- Passagem reversa: do erro para os pesos ---
# d2: sensibilidade da perda a z2. O fator a2*(1-a2) é a
# derivada da sigmóide; ele traduz "quanto a saída se move
# se z2 se mover um pouco".
d2 = 2 * (a2 - y) / len(X) * a2 * (1 - a2)
dW2 = a1.T @ d2
db2 = d2.sum(axis=0, keepdims=True)
# Subimos uma camada: o erro é redistribuído aos neurônios
# ocultos proporcionalmente ao seu peso W2, e depois multiplicado
# pela derivada de tanh, que vale 1 - tanh².
d1 = (d2 @ W2.T) * (1 - a1 ** 2)
dW1 = X.T @ d1
db1 = d1.sum(axis=0, keepdims=True)
# --- Atualização ---
for param, gradient in ((W1, dW1), (b1, db1), (W2, dW2), (b2, db2)):
param -= 0.5 * gradient
if etape in (1, 100, 1000, 5000):
print(etape, round(perte, 4))
# 1 0.24
# 100 0.1182
# 1000 0.0018
# 5000 0.0003
print(a2.round(3).ravel()) # [0.007 0.983 0.983 0.021]
A rede parte de um erro de 0,24 e desce até 0,0003. As quatro saídas finais — 0,007 / 0,983 / 0,983 / 0,021 — devem ser comparadas com os alvos 0 / 1 / 1 / 0. O OU exclusivo está resolvido, por um programa cuja lógica de aprendizado inteira cabe em oito linhas.
Três observações merecem ser guardadas:
- A passagem reversa é o espelho da passagem direta.
X @ W1na ida se tornaX.T @ d1na volta. Não é coincidência: é a regra da cadeia aplicada a um produto matricial. - Nenhuma camada sabe o que está detectando. A camada oculta construiu uma representação útil dos quatro pontos, mas ninguém lhe disse qual. É o que chamamos de aprendizado de representações, e é a principal contribuição do aprendizado profundo.
- Nada aqui depende do número de camadas. Com dez camadas, repetimos o mesmo bloco dez vezes. Foi essa uniformidade que tornou possíveis as redes profundas, e bibliotecas como PyTorch ou TensorFlow não fazem nada além de derivar automaticamente essas passagens reversas.
O que tornou o aprendizado profundo possível
A retropropagação foi popularizada em 1986 por Rumelhart, Hinton e Williams. A ascensão do aprendizado profundo data dos anos 2010. Essa defasagem de vinte e cinco anos não se deve a uma descoberta teórica que faltava, mas a três condições materiais que ainda não estavam reunidas.
| Condição | Situação nos anos 1990 | Situação nos anos 2010 |
|---|---|---|
| Dados anotados | alguns milhares de exemplos | ImageNet: cerca de 1,2 milhão de imagens de treinamento, 1000 categorias |
| Computação | processadores de uso geral | placas gráficas, massivamente paralelas |
| Receitas de treinamento | saturação das sigmóides, inicializações arriscadas | ReLU, inicializações adequadas, normalizações, regularização |
O multiplicador é brutal. Cada iteração do nosso laço processa 4 exemplos e atualiza 17 parâmetros. O treinamento de uma rede de visão comum processa milhões de imagens e atualiza dezenas de milhões de parâmetros, dezenas de vezes. A teoria é a mesma; é a escala que muda de natureza, e com ela o custo, cujas consequências mediremos na última lição.
Pontos-chave
- Um neurônio é uma soma ponderada seguida de uma função. O viés faz as vezes de limiar, e a aritmética se confere à mão no E lógico.
- Uma única camada só traça uma fronteira reta: ela falha no OU exclusivo, com pontuação de 0,5. Empilhar camadas serve para transformar o espaço até que o problema se torne linearmente separável.
- As funções de ativação existem para introduzir não linearidade. Sem elas, uma rede de dez camadas equivale a uma única camada, qualquer que seja seu tamanho.
- O gradiente descendente mede o erro, calcula a direção de correção e dá um pequeno passo. O passo e a escala das entradas são críticos: os mesmos dados sem padronização fazem o mesmo laço divergir.
- A retropropagação nada mais é que a regra da cadeia organizada da saída para a entrada. Ela cabe em poucas linhas, é idêntica qualquer que seja o número de camadas, e é isso que a torna industrializável.
Comentários
Os comentários são alimentados pelo GitHub Discussions
Conecte-se com o GitHub para participar da discussão