0%
Deep Learning e redes neurais

Deep Learning

Entenda o que realmente acontece dentro de uma rede.

10-15 min

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çãoSaídaUso comumPonto de atenção
ReLU max(0, z)0 ou zcamadas ocultas, padrãoum neurônio travado em 0 não se corrige mais
Sigmóideentre 0 e 1saída de uma classificação bináriainclinação quase nula nos extremos
tanhentre −1 e 1camadas ocultas, redes pequenasmesma 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 que LinearRegression encontrou 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 @ W1 na ida se torna X.T @ d1 na 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çãoSituação nos anos 1990Situação nos anos 2010
Dados anotadosalguns milhares de exemplosImageNet: cerca de 1,2 milhão de imagens de treinamento, 1000 categorias
Computaçãoprocessadores de uso geralplacas gráficas, massivamente paralelas
Receitas de treinamentosaturação das sigmóides, inicializações arriscadasReLU, 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

Link copiado!