Pular para conteúdo

Redes Neurais

Uma rede neural é construída a partir de uma peça que você já conhece bem. A regressão logística da aula de regressão logística calcula uma soma ponderada dos atributos, soma um viés e espreme o resultado com uma sigmoide: isso é um neurônio artificial. Uma rede neural liga muitos deles em camadas (as saídas de uma camada viram os atributos da seguinte) e treina todos os pesos juntos por gradiente descendente.

Esta aula constrói essa ideia em passos pequenos, cada um medido: o neurônio isolado e o perceptron de Rosenblatt (com um simulador), o problema do XOR que nenhum neurônio sozinho resolve, uma rede de duas camadas que o resolve com pesos escolhidos à mão, por que a função de ativação precisa ser não linear, a passagem direta como produtos de matrizes, a retropropagação (backpropagation) feita número a número numa rede 2-2-1 e, por fim, o MLPClassifier do scikit-learn na prática, incluindo uma comparação honesta com o gradient boosting da aula anterior em dados tabulares. A aula termina com um simulador passo a passo da retropropagação. A história completa (redes convolucionais, transformers, PyTorch) pertence ao curso de Deep Learning; aqui o objetivo é entender o que uma rede calcula e como ela aprende. Todo número citado vem dos blocos de código ou dos scripts das figuras que ficam ao lado desta página.

A aula segue um único pipeline em cinco etapas, desenhado abaixo: 1. um neurônio, a regressão logística lida como neurônio e o perceptron; 2. camadas ocultas, o XOR e por que a ativação precisa ser não linear; 3. passagem direta, o que uma rede calcula, como produtos de matrizes; 4. retropropagação, como cada peso aprende com o erro; 5. na prática, o treino com o scikit-learn e quando uma rede vale a pena. Cada etapa leva à sua seção; nos slides, uma faixa embaixo mostra em que etapa você está.

  1. Um neurônioa regressão logística como neurônio; o perceptron
  2. Camadas ocultasXOR, uma camada oculta, ativações não lineares
  3. Passagem diretaum produto de matrizes por camada; contar parâmetros
  4. Retropropagaçãoregra da cadeia, seis passos, uma perda não convexa
  5. Na práticaescala, largura, weight decay; dados tabulares

Slides

Clique nos slides e use as setas ← → para navegar; F abre em tela cheia e Esc mostra todos os slides. Abrir os slides numa aba nova

Da regressão logística a um neurônio

Etapa 1. Um neurônio artificial recebe entradas \(x_1, \dots, x_d\), calcula uma pré-ativação \(z\) (uma soma ponderada mais um viés) e a passa por uma função de ativação \(\varphi\):

\[ z = w_1 x_1 + \dots + w_d x_d + b = w^\top x + b, \qquad a = \varphi(z). \]

Com \(\varphi = \sigma\), a sigmoide \(\sigma(z) = 1/(1 + e^{-z})\), isso é exatamente a regressão logística: \(a\) é a probabilidade prevista da classe positiva. Um exemplo numérico com \(w = (2, -1)\), \(b = 0{,}5\) e a entrada \(x = (1, 2)\): \(z = 2 \cdot 1 - 1 \cdot 2 + 0{,}5 = 0{,}5\) e \(a = \sigma(0{,}5) = 0{,}622\).

Tudo nesta aula muda uma de duas coisas: a ativação \(\varphi\) (um degrau, uma sigmoide, uma ReLU) ou a ligação (um neurônio ou camadas deles alimentando umas às outras). A primeira escolha histórica foi um degrau.

O perceptron

A regra de atualização

O perceptron de Frank Rosenblatt (1958) é um neurônio cuja ativação é um degrau: ele responde \(+1\) se \(z > 0\) e \(-1\) caso contrário, com rótulos \(y \in \{-1, +1\}\) como na aula de SVM. Ele foi construído como máquina, o Mark I Perceptron, com fotocélulas como entradas e potenciômetros movidos a motor como pesos. A regra de aprendizado não precisa de cálculo. Visite os pontos de treino um de cada vez; se o ponto \(i\) está do lado errado da fronteira ou exatamente sobre ela (\(y_i z_i \le 0\)), mova os pesos na direção dele:

\[ w \leftarrow w + \eta \, y_i \, x_i, \qquad b \leftarrow b + \eta \, y_i ; \]

se ele está do lado certo, não mude nada. Por que isso ajuda: depois da atualização, o escore do mesmo ponto passa a ser

\[ y_i \big( (w + \eta y_i x_i)^\top x_i + b + \eta y_i \big) = y_i z_i + \eta \big( \lVert x_i \rVert^2 + 1 \big), \]

ou seja, o escore com sinal do ponto mal classificado cresce \(\eta(\lVert x_i \rVert^2 + 1) > 0\): a fronteira gira e se desloca no sentido de classificá-lo corretamente. Uma atualização pode não bastar e ainda estragar outro ponto; por isso o procedimento percorre os dados em épocas (passadas completas) até que uma época não tenha nenhum erro.

Três atualizações à mão

Seis pontos (três de cada classe) e o início em \(w = (0, 0)\), \(b = 0\), \(\eta = 1\):

ponto \(x_1\) \(x_2\) \(y\)
A 4 2 +1
B 0 2 −1
C 1 3 −1
D 2 4 −1
E 3 0 +1
F 3 1 +1

À mão, época 1.

  • A: \(z = 0\), então \(y z = 0 \le 0\), um erro (todo ponto está "sobre a reta" quando \(w = 0\)). Atualização: \(w = (0, 0) + (+1)(4, 2) = (4, 2)\), \(b = 0 + 1 = 1\).
  • B: \(z = 4 \cdot 0 + 2 \cdot 2 + 1 = 5\), mas \(y = -1\): \(y z = -5\), um erro. Atualização: \(w = (4, 2) - (0, 2) = (4, 0)\), \(b = 1 - 1 = 0\).
  • C: \(z = 4 \cdot 1 + 0 \cdot 3 + 0 = 4\), \(y = -1\): um erro. Atualização: \(w = (4, 0) - (1, 3) = (3, -3)\), \(b = -1\).
  • D, E, F: \(z = 3 \cdot 2 - 3 \cdot 4 - 1 = -7\), \(z = 9 - 0 - 1 = 8\), \(z = 9 - 3 - 1 = 5\), todos do lado certo. Nada muda.

A época 2 visita os seis pontos sem nenhum erro, então o perceptron para com a fronteira \(3x_1 - 3x_2 - 1 = 0\), a reta \(x_2 = x_1 - \tfrac{1}{3}\). O código segue os mesmos passos:

import numpy as np
import pandas as pd

X = np.array([[4, 2], [0, 2], [1, 3], [2, 4], [3, 0], [3, 1]])
y = np.array([+1, -1, -1, -1, +1, +1])


def perceptron(X, y, eta=1.0, epochs=20, trace=False):
    """Regra de Rosenblatt: visita os pontos em ordem e só atualiza nos erros."""
    w, b = np.zeros(X.shape[1]), 0.0
    history = []                                   # (erros, w, b) ao fim de cada época
    for epoch in range(1, epochs + 1):
        mistakes = 0
        for i in range(len(X)):
            z = w @ X[i] + b
            mistake = y[i] * z <= 0                # lado errado ou sobre a reta
            if mistake:
                w, b = w + eta * y[i] * X[i], b + eta * y[i]
                mistakes += 1
            if trace and epoch == 1:
                print(f"x = ({X[i][0]:g}, {X[i][1]:g})  y = {y[i]:+d}  z = {z:+g}  y·z = {y[i] * z + 0.0:+g}  "   # + 0.0: imprime -0 como 0
                      + (f"mistake -> w = ({w[0]:g}, {w[1]:g}), b = {b:g}" if mistake else "correct"))
        history.append((mistakes, w.copy(), b))
        if mistakes == 0:
            break
    return w, b, history


w, b, history = perceptron(X, y, trace=True)
print("mistakes per epoch:", [m for m, _, _ in history])
print(f"final w = ({w[0]:g}, {w[1]:g}), b = {b:g}")
x = (4, 2)  y = +1  z = +0  y·z = +0  mistake -> w = (4, 2), b = 1
x = (0, 2)  y = -1  z = +5  y·z = -5  mistake -> w = (4, 0), b = 0
x = (1, 3)  y = -1  z = +4  y·z = -4  mistake -> w = (3, -3), b = -1
x = (2, 4)  y = -1  z = -7  y·z = +7  correct
x = (3, 0)  y = +1  z = +8  y·z = +8  correct
x = (3, 1)  y = +1  z = +5  y·z = +5  correct
mistakes per epoch: [3, 0]
final w = (3, -3), b = -1

A biblioteca implementa a mesma regra. O Perceptron do scikit-learn, com passo constante eta0=1 e os pontos visitados na ordem original (shuffle=False), chega aos mesmos pesos; e, como o início é \(w = 0\), o tamanho do passo só muda a escala deles:

from sklearn.linear_model import Perceptron

skl = Perceptron(eta0=1.0, shuffle=False, tol=None, max_iter=20).fit(X, y)
print("scikit-learn:", skl.coef_[0], skl.intercept_[0])
w_small, b_small, _ = perceptron(X, y, eta=0.1)
print("eta = 0.1:   ", w_small.round(2), round(b_small, 2), "-> the same line, scaled by 0.1")
scikit-learn: [ 3. -3.] -1.0
eta = 0.1:    [ 0.3 -0.3] -0.1 -> the same line, scaled by 0.1

Os quatro estados da fronteira: nenhuma reta no início e uma reta depois de cada atualização (script da figura):

Perceptron em seis pontos: depois da atualização em A a reta é 4x1 + 2x2 + 1 = 0, depois de B é x1 = 0, depois de C é x2 = x1 − 1/3, que separa as duas classes; três atualizações ao todo

Repare no que a atualização faz geometricamente. O vetor \(w\) é perpendicular à fronteira e aponta para o lado \(+1\). Um erro num ponto \(-1\) (B, C) subtrai esse ponto de \(w\), girando o lado \(+1\) para longe dele; um erro num ponto \(+1\) o soma.

Convergência. Se uma reta separa as duas classes com alguma margem \(\gamma > 0\) e todo ponto fica a uma distância de no máximo \(R\) da origem (contando o viés como uma entrada extra fixa em 1), o perceptron comete no máximo \((R / \gamma)^2\) erros, qualquer que seja a ordem dos pontos (Novikoff, 1962). Ele para em alguma reta separadora, não na melhor; escolher a rua mais larga é o que a SVM acrescenta. E, se nenhuma reta separa as classes, o teorema não promete nada, como mostra a próxima seção.

Experimente: o simulador do perceptron

O simulador roda a mesma regra. Passo visita um ponto e mostra a conta: o escore \(z\), o teste \(y z \le 0\) e, num erro, a atualização com os números. Época inteira roda até o fim da passada atual e Rodar anima. O primeiro exemplo é o de seis pontos acima; o segundo sorteia um conjunto aleatório separável a cada clique; o terceiro é o XOR, o assunto da próxima seção: veja os erros por época nunca chegarem a zero.

XOR: por que uma reta não basta

Etapa 2. O ou-exclusivo tem quatro pontos: \((0, 1)\) e \((1, 0)\) são \(+1\); \((0, 0)\) e \((1, 1)\) são \(-1\). Nenhuma reta põe as duas diagonais em lados diferentes. A álgebra é curta: uma reta separadora precisaria de

  • \((0,0)\) negativo: \(b < 0\);
  • \((0,1)\) e \((1,0)\) positivos: \(w_2 + b > 0\) e \(w_1 + b > 0\);
  • \((1,1)\) negativo: \(w_1 + w_2 + b < 0\).

Somando as duas desigualdades do meio, \(w_1 + w_2 + 2b > 0\), logo \(w_1 + w_2 + b > -b > 0\), o que contradiz a última. O perceptron tenta mesmo assim:

X_xor = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y_xor = np.array([-1, +1, +1, -1])

w, b, history = perceptron(X_xor, y_xor, epochs=8, trace=True)
print("mistakes per epoch:", [m for m, _, _ in history])
print("weights at the end of every epoch:", {f"w = ({w_e[0]:g}, {w_e[1]:g}), b = {b_e:g}" for _, w_e, b_e in history})
x = (0, 0)  y = -1  z = +0  y·z = +0  mistake -> w = (0, 0), b = -1
x = (0, 1)  y = +1  z = -1  y·z = -1  mistake -> w = (0, 1), b = 0
x = (1, 0)  y = +1  z = +0  y·z = +0  mistake -> w = (1, 1), b = 1
x = (1, 1)  y = -1  z = +3  y·z = -3  mistake -> w = (0, 0), b = 0
mistakes per epoch: [4, 4, 4, 4, 4, 4, 4, 4]
weights at the end of every epoch: {'w = (0, 0), b = 0'}

Toda visita é um erro e as quatro atualizações de uma época somam exatamente zero: \(\sum_i y_i x_i = -(0,0) + (0,1) + (1,0) - (1,1) = (0,0)\) e \(\sum_i y_i = 0\). Assim, depois de cada época os pesos voltam a \(w = (0, 0)\), \(b = 0\); as mesmas quatro atualizações se repetem para sempre. Com outro início ou outra ordem o caminho é diferente, mas nunca termina, porque nenhuma época pode ficar sem erros.

Foi essa a observação que Minsky e Papert tornaram famosa no livro Perceptrons (1969): um perceptron de uma camada não consegue calcular o XOR nem, de modo mais geral, qualquer função que dependa de uma combinação de atributos que nenhuma reta expressa. O livro foi lido como um veredito sobre as redes neurais como um todo. O financiamento migrou para outras áreas por mais de uma década, parte do que a introdução chama de primeiro inverno da IA.

A mesma parede barra a regressão logística, que também é um único neurônio. Medido num XOR ruidoso, 400 pontos espalhados em volta dos quatro cantos, com validação cruzada de 5 folds:

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score

rng = np.random.default_rng(0)
corners = rng.integers(0, 2, size=(400, 2))              # cada ponto começa num canto do quadrado
X_nx = corners + rng.normal(0, 0.2, size=(400, 2))       # e é borrado por ruído
y_nx = corners[:, 0] ^ corners[:, 1]                     # rótulo: XOR das coordenadas do canto
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)

print("class balance:", np.bincount(y_nx))
print(f"logistic regression, CV accuracy: {cross_val_score(LogisticRegression(), X_nx, y_nx, cv=cv).mean():.3f}")
p = LogisticRegression().fit(X_nx, y_nx).predict_proba(X_nx)[:, 1]
print(f"fitted on all 400 points, P(class 1) goes only from {p.min():.2f} to {p.max():.2f}")
class balance: [205 195]
logistic regression, CV accuracy: 0.485
fitted on all 400 points, P(class 1) goes only from 0.37 to 0.61

As classes estão equilibradas (205 e 195 pontos), então chutar acerta cerca de metade das vezes; a regressão logística não faz melhor (0,485). Uma reta poderia superar o acaso (cortar um canto acerta três dos quatro cantos), mas a log loss prefere ficar em cima do muro: ajustada nos 400 pontos, a probabilidade prevista da classe 1 fica entre 0,37 e 0,61 em todo lugar. Um neurônio não tem nada melhor a oferecer.

Uma camada oculta resolve o XOR

O caminho é parar de pedir a um neurônio que faça tudo. O XOR é "\(x_1\) OU \(x_2\), mas NÃO os dois" e cada uma dessas peças é linearmente separável. Então use dois neurônios para calcular as peças e um terceiro para combiná-las:

  • o neurônio oculto \(h_1\) dispara quando \(x_1 + x_2 - 0{,}5 > 0\): é o OU;
  • o neurônio oculto \(h_2\) dispara quando \(x_1 + x_2 - 1{,}5 > 0\): é o E;
  • a saída dispara quando \(h_1 - h_2 - 0{,}5 > 0\): "OU e não E", que é o XOR.

Todos os pesos aqui foram escolhidos à mão. A tabela, para as quatro entradas:

step = lambda z: (z > 0).astype(int)

X4 = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
W1 = np.array([[1, 1],        # h1: x1 + x2 - 0.5 > 0  (OU)
               [1, 1]])       # h2: x1 + x2 - 1.5 > 0  (E)
b1 = np.array([-0.5, -1.5])
w2, b2 = np.array([1, -1]), -0.5                    # saída: h1 - h2 - 0.5 > 0

Z1 = X4 @ W1.T + b1                                 # pré-ativações ocultas, uma linha por entrada
H = step(Z1)                                        # ativações ocultas
z_out = H @ w2 + b2
print(pd.DataFrame({'x1': X4[:, 0], 'x2': X4[:, 1], 'z1': Z1[:, 0], 'h1 (OR)': H[:, 0],
                    'z2': Z1[:, 1], 'h2 (AND)': H[:, 1], 'z_out': z_out,
                    'output': step(z_out), 'XOR': X4[:, 0] ^ X4[:, 1]}).to_string(index=False))
 x1  x2   z1  h1 (OR)   z2  h2 (AND)  z_out  output  XOR
  0   0 -0.5        0 -1.5         0   -0.5       0    0
  0   1  0.5        1 -0.5         0    0.5       1    1
  1   0  0.5        1 -0.5         0    0.5       1    1
  1   1  1.5        1  0.5         1   -0.5       0    0

Confira a linha \((1, 1)\) com uma calculadora: \(z_1 = 1 + 1 - 0{,}5 = 1{,}5\), então \(h_1 = 1\); \(z_2 = 1 + 1 - 1{,}5 = 0{,}5\), então \(h_2 = 1\); \(z_{\text{out}} = 1 - 1 - 0{,}5 = -0{,}5\), então a saída é 0. A rede calcula o XOR exatamente.

O que aconteceu é mais fácil de ver do que de dizer (script da figura):

Esquerda: o XOR ruidoso no espaço de entrada, com a reta do OU e a reta do E dos dois neurônios ocultos; a classe 1 fica na faixa entre elas. Direita: os mesmos 400 pontos no espaço oculto (h1, h2) caem em três cantos e basta a reta h1 − h2 = 0,5 para separar as classes

À esquerda, cada neurônio oculto traça uma reta no espaço de entrada e a classe 1 é a faixa entre as duas retas, algo que nenhuma reta sozinha descreve. À direita estão os mesmos dados vistos através da camada oculta, \((h_1, h_2)\) no lugar de \((x_1, x_2)\) (com a versão sigmoide da rede, logo abaixo, para que os pontos ruidosos não desabem todos em exatamente três cantos). Nesse novo espaço, \((0,0)\) fica em \((0,0)\), \((1,1)\) vai para \((1,1)\) e os dois cantos da classe 1 caem em \((1, 0)\). Agora uma reta separa as classes e o neurônio de saída só precisa traçá-la.

Essa é a ideia central da aula: a camada oculta calcula atributos novos, nos quais o problema fica linearmente separável; a camada de saída é um classificador linear sobre esses atributos. Você já fez isso à mão, acrescentando colunas \(x^2\) como atributos polinomiais ou deixando o kernel da SVM fazê-lo implicitamente. Uma rede aprende esses atributos a partir dos dados, por gradiente descendente.

Há um obstáculo. O gradiente descendente precisa de derivadas, mas a função degrau tem derivada 0 em todo lugar (e nenhuma em 0): uma mudança pequena num peso nunca muda a saída, então o gradiente não diz nada. A solução é trocar o degrau por uma função suave com a mesma forma. Com a sigmoide e todos os pesos multiplicados por 10, a mesma rede devolve probabilidades:

sigmoid = lambda z: 1 / (1 + np.exp(-z))

H_soft = sigmoid(10 * (X4 @ W1.T + b1))
p = sigmoid(10 * (H_soft @ w2 + b2))
print(pd.DataFrame({'x1': X4[:, 0], 'x2': X4[:, 1], 'h1': H_soft[:, 0].round(3),
                    'h2': H_soft[:, 1].round(3), 'P(XOR = 1)': p.round(3)}).to_string(index=False))
 x1  x2    h1    h2  P(XOR = 1)
  0   0 0.007 0.000       0.007
  0   1 0.993 0.007       0.992
  1   0 0.993 0.007       0.992
  1   1 1.000 0.993       0.007

As duas entradas da classe 0 recebem probabilidade 0,007 e as duas da classe 1 recebem 0,992: a mesma rede, agora diferenciável. Multiplicar os pesos por 10 deixa cada sigmoide íngreme, perto de um degrau; pesos menores dariam probabilidades mais suaves. É esse tipo de rede que um treino baseado em gradiente consegue encontrar sozinho a partir de pesos aleatórios; é o que o MLPClassifier faz nas próximas seções.

Funções de ativação

Sigmoide, tanh e ReLU

Três ativações cobrem quase toda a prática:

ativação \(\varphi(z)\) imagem derivada \(\varphi'(z)\) maior derivada
sigmoide \(1 / (1 + e^{-z})\) \((0, 1)\) \(\varphi(z)\,(1 - \varphi(z))\) 0,25, em \(z = 0\)
tanh \((e^{z} - e^{-z}) / (e^{z} + e^{-z})\) \((-1, 1)\) \(1 - \varphi(z)^2\) 1, em \(z = 0\)
ReLU \(\max(0, z)\) \([0, \infty)\) 0 se \(z < 0\), 1 se \(z > 0\) 1, para todo \(z > 0\)

Sigmoide, tanh e ReLU com as derivadas: a inclinação da sigmoide nunca passa de 0,25 e some para |z| grande; a tanh chega a 1; a ReLU tem inclinação exatamente 1 para toda entrada positiva e 0 para as negativas

(script da figura)

A coluna da derivada é a que importa para o treino, porque a retropropagação (abaixo) multiplica uma derivada de ativação por camada. Com sigmoides, cada camada multiplica o gradiente por no máximo 0,25; ao longo de 10 camadas, isso dá no máximo \(0{,}25^{10} \approx 10^{-6}\) e as primeiras camadas quase não aprendem. Esse gradiente que desaparece (vanishing gradient) é o motivo de a ReLU (Nair & Hinton, 2010; Glorot, Bordes & Bengio, 2011) ter virado o padrão nas camadas ocultas: sua inclinação é exatamente 1 onde o neurônio está ativo. É também o padrão do scikit-learn (activation='relu'). A sigmoide continua sendo a escolha natural para a saída de um classificador binário, porque produz uma probabilidade, exatamente como na regressão logística; para \(k\) classes a saída é uma softmax.

Sem não linearidade, as camadas colapsam

Por que não dispensar a ativação e empilhar camadas lineares puras? Porque uma função linear de uma função linear é linear. Duas camadas \(W_2 (W_1 x + b_1) + b_2\) equivalem a uma camada com pesos \(W_2 W_1\) e viés \(W_2 b_1 + b_2\). Numericamente, com pesos aleatórios (3 unidades ocultas):

rng = np.random.default_rng(0)
W1r, b1r = rng.normal(size=(3, 2)), rng.normal(size=3)       # camada 1: 2 entradas -> 3 unidades
W2r, b2r = rng.normal(size=(1, 3)), rng.normal(size=1)       # camada 2: 3 unidades -> 1 saída
x_new = np.array([1.0, -2.0])

two_linear_layers = W2r @ (W1r @ x_new + b1r) + b2r
W_merged, b_merged = W2r @ W1r, W2r @ b1r + b2r
print("two linear layers:", two_linear_layers.round(4))
print("one merged layer: ", (W_merged @ x_new + b_merged).round(4), "with W =", W_merged.round(3))
print("with a ReLU between them:", (W2r @ np.maximum(0, W1r @ x_new + b1r) + b2r).round(4))
two linear layers: [-5.4084]
one merged layer:  [-5.4084] with W = [[-0.58   0.117]]
with a ReLU between them: [-5.3273]

As duas camadas lineares e a camada única fundida imprimem o mesmo número (−5,4084) e imprimiriam o mesmo para qualquer entrada: as três unidades ocultas não acrescentaram nada; a rede 2 → 3 → 1 inteira é o modelo linear \(-0{,}58\,x_1 + 0{,}117\,x_2 + b\). Ponha uma ReLU entre as camadas e a saída muda (−5,3273 aqui), porque a rede não pode mais ser reescrita como uma única matriz.

O mesmo, medido no XOR ruidoso: um MLPClassifier com 8 unidades ocultas e activation='identity' (sem não linearidade) contra a mesma rede com ReLU, nos mesmos folds da regressão logística:

from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

for act in ['identity', 'relu']:
    net = make_pipeline(StandardScaler(),
                        MLPClassifier(hidden_layer_sizes=(8,), activation=act, max_iter=2000, random_state=0))
    print(f"8 hidden units, activation={act:8s}: CV accuracy {cross_val_score(net, X_nx, y_nx, cv=cv).mean():.3f}")
8 hidden units, activation=identity: CV accuracy 0.510
8 hidden units, activation=relu    : CV accuracy 0.983

Sem ativação, a rede de 8 unidades é um modelo linear disfarçado e marca 0,510, o mesmo nível de acaso da regressão logística (0,485). Com ReLU chega a 0,983. As unidades, o solver e os dados são idênticos; só a não linearidade mudou.

A passagem direta como produtos de matrizes

Uma camada é um produto de matrizes

Etapa 3. Escreva os pesos de uma camada como uma matriz, uma linha por neurônio. Para a rede 2-2-1 abaixo:

\[ z = W x + b^{h}, \quad h = \sigma(z), \quad u = v^\top h + b^{y}, \quad \hat{y} = \sigma(u). \]

Para um lote inteiro de \(n\) linhas de uma vez, empilhe as entradas como linhas de \(X\) e a mesma conta vira \(H = \sigma(X W^\top + b^h)\): um produto de matrizes e uma função elemento a elemento por camada. Essa é a passagem direta (forward pass). É por isso que as redes neurais rodam tão bem em GPUs, que são máquinas de multiplicar matrizes.

Uma rede 2-2-1, à mão

A rede abaixo é a usada no curso de Deep Learning e no simulador do fim desta página: duas entradas, duas unidades ocultas sigmoides, uma saída sigmoide, um exemplo de treino \(x = (0{,}5;\ 0{,}8)\) com alvo \(y = 0\) e estes pesos iniciais:

de → para peso de → para peso
\(x_1 \to h_1\) \(w_{11} = 0{,}2\) \(h_1 \to \hat y\) \(v_{11} = 0{,}3\)
\(x_2 \to h_1\) \(w_{12} = 0{,}4\) \(h_2 \to \hat y\) \(v_{12} = 0{,}5\)
\(x_1 \to h_2\) \(w_{21} = 0{,}6\) viés \(\to \hat y\) \(b^{y}_1 = 0{,}4\)
\(x_2 \to h_2\) \(w_{22} = 0{,}8\)
viés \(\to h_1\) \(b^{h}_1 = 0{,}1\)
viés \(\to h_2\) \(b^{h}_2 = 0{,}2\)

À mão: \(z_1 = 0{,}2 \cdot 0{,}5 + 0{,}4 \cdot 0{,}8 + 0{,}1 = 0{,}52\) e \(z_2 = 0{,}6 \cdot 0{,}5 + 0{,}8 \cdot 0{,}8 + 0{,}2 = 1{,}14\); \(h_1 = \sigma(0{,}52)\) e \(h_2 = \sigma(1{,}14)\); depois \(u = 0{,}3 h_1 + 0{,}5 h_2 + 0{,}4\) e \(\hat y = \sigma(u)\). Para manter os números idênticos aos do curso de Deep Learning, este exemplo mede o erro com a perda quadrática \(L = (\hat y - y)^2\):

x = np.array([0.5, 0.8])
y_true = 0.0
W = np.array([[0.2, 0.4],       # linha i = os pesos que entram na unidade oculta h_i
              [0.6, 0.8]])
b_h = np.array([0.1, 0.2])
v = np.array([0.3, 0.5])        # pesos que entram na saída
b_y = 0.4

z = W @ x + b_h                 # pré-ativações ocultas
h = sigmoid(z)                  # ativações ocultas
u = v @ h + b_y                 # pré-ativação da saída
y_hat = sigmoid(u)              # saída
L = (y_hat - y_true) ** 2       # perda quadrática
print("z =", z.round(3), " h =", h.round(3))
print(f"u = {u:.3f}  y_hat = {y_hat:.3f}  L = {L:.4f}")
z = [0.52 1.14]  h = [0.627 0.758]
u = 0.967  y_hat = 0.725  L = 0.5249

As pré-ativações ocultas são \(z = (0{,}52;\ 1{,}14)\), as ativações ocultas \(h = (0{,}627;\ 0{,}758)\), a pré-ativação da saída \(u = 0{,}3 \cdot 0{,}627 + 0{,}5 \cdot 0{,}758 + 0{,}4 = 0{,}967\) e a previsão \(\hat y = \sigma(0{,}967) = 0{,}725\). O alvo é 0, então a rede erra bastante: \(L = 0{,}725^2 = 0{,}5249\).

O scikit-learn calcula a mesma coisa. Carregue esses pesos num MLPClassifier com unidades ocultas sigmoides (activation='logistic'); o atributo coefs_ guarda uma matriz por camada, no formato (entradas × unidades), a transposta do nosso \(W\):

import warnings
from sklearn.exceptions import ConvergenceWarning

warnings.filterwarnings('ignore', category=ConvergenceWarning)
net = MLPClassifier(hidden_layer_sizes=(2,), activation='logistic', max_iter=1).fit([[0, 0], [1, 1]], [0, 1])
net.coefs_ = [W.T, v.reshape(2, 1)]              # nossos pesos substituem os iniciais
net.intercepts_ = [b_h, np.array([b_y])]
print("scikit-learn P(y = 1 | x) =", net.predict_proba([x])[0, 1].round(4))
scikit-learn P(y = 1 | x) = 0.7245

Contando parâmetros

Uma camada com \(n_\text{ent}\) entradas e \(n_\text{sai}\) unidades tem \(n_\text{ent} \cdot n_\text{sai}\) pesos e \(n_\text{sai}\) vieses, \((n_\text{ent} + 1) \cdot n_\text{sai}\) parâmetros ao todo. Para imagens de dígitos de 8 × 8 (64 pixels, 10 classes):

def n_params(sizes):
    return sum((n_in + 1) * n_out for n_in, n_out in zip(sizes[:-1], sizes[1:]))


for sizes in [(2, 2, 1), (64, 10), (64, 32, 10), (64, 256, 10), (64, 64, 64, 10)]:
    print(f"{' -> '.join(map(str, sizes)):22s} {n_params(sizes):6,d} parameters")
2 -> 2 -> 1                 9 parameters
64 -> 10                  650 parameters
64 -> 32 -> 10          2,410 parameters
64 -> 256 -> 10        19,210 parameters
64 -> 64 -> 64 -> 10    8,970 parameters

A rede 2-2-1 tem 9 parâmetros (6 na primeira camada, 3 na segunda): os da tabela acima. A regressão softmax sobre 64 pixels tem \(65 \cdot 10 = 650\). Uma camada oculta de 32 unidades leva a conta a \(65 \cdot 32 + 33 \cdot 10 = 2.410\); com 256 unidades, são 19.210, mais parâmetros do que imagens em todo o conjunto de dígitos (1.797). Duas camadas ocultas de 64 unidades têm menos parâmetros (8.970) do que uma de 256: a conta cresce com o produto das larguras vizinhas. Com tantos parâmetros por exemplo, a regularização (abaixo) não é opcional.

Treino: retropropagação

A perda e a regra da cadeia

Etapa 4. Treinar é escolher todos os pesos para minimizar uma perda no conjunto de treino. O MLPClassifier do scikit-learn minimiza a mesma entropia cruzada da regressão logística, mais uma penalidade L2 nos pesos, \(\tfrac{\alpha}{2} \lVert W \rVert^2\), a mesma penalidade do Ridge; na literatura de redes neurais ela se chama weight decay (decaimento de pesos) e, no scikit-learn, corresponde ao parâmetro alpha. O gradiente descendente precisa de \(\partial L / \partial w\) para todo peso \(w\). Os pesos da camada de saída são fáceis, mas um peso da primeira camada só influencia a perda através de tudo o que vem depois dele:

\[ w_{11} \;\to\; z_1 \;\to\; h_1 \;\to\; u \;\to\; \hat y \;\to\; L . \]

A regra da cadeia multiplica as derivadas locais ao longo desse caminho. A retropropagação (Rumelhart, Hinton & Williams, 1986) é a regra da cadeia organizada para que nada seja calculado duas vezes: comece pela perda, calcule a derivada em relação à pré-ativação de cada camada (o delta dela, \(\delta\)) e passe-a para trás, uma camada de cada vez. O gradiente de cada peso é então "o delta da unidade que ele alimenta × o valor que entrou por ele".

Para a rede 2-2-1, com a perda quadrática, os seis passos são:

  1. gradiente da perda: \(\partial L / \partial \hat y = 2(\hat y - y)\);
  2. delta da saída: \(\delta_y = \partial L / \partial u = 2(\hat y - y) \cdot \hat y (1 - \hat y)\), porque \(\sigma'(u) = \hat y(1 - \hat y)\);
  3. pesos da saída: \(\partial L / \partial v_{1j} = \delta_y \, h_j\) e \(\partial L / \partial b^y_1 = \delta_y\);
  4. deltas ocultos: \(\delta_{h_j} = \delta_y \, v_{1j} \cdot h_j (1 - h_j)\), o delta da saída mandado de volta pelo peso \(v_{1j}\), vezes a inclinação local da sigmoide;
  5. pesos ocultos: \(\partial L / \partial w_{jk} = \delta_{h_j} \, x_k\) e \(\partial L / \partial b^h_j = \delta_{h_j}\);
  6. atualizar todos os parâmetros: \(p \leftarrow p - \eta \, \partial L / \partial p\).

Os seis passos, com números

dL_dyhat = 2 * (y_hat - y_true)                  # 1. gradiente da perda
delta_y = dL_dyhat * y_hat * (1 - y_hat)         # 2. delta da saída, dL/du
grad_v = delta_y * h                             # 3. pesos da saída
grad_b_y = delta_y
delta_h = delta_y * v * h * (1 - h)              # 4. deltas ocultos, dL/dz
grad_W = np.outer(delta_h, x)                    # 5. pesos ocultos: delta da unidade × sua entrada
grad_b_h = delta_h

print(f"1. dL/dy_hat = 2({y_hat:.3f} - {y_true:.0f}) = {dL_dyhat:.3f}")
print(f"2. delta_y   = {dL_dyhat:.3f} · {y_hat:.3f}(1 - {y_hat:.3f}) = {delta_y:.4f}")
print(f"3. dL/dv     = {grad_v.round(4)}   dL/db_y = {grad_b_y:.4f}")
print(f"4. delta_h   = {delta_h.round(4)}")
print(f"5. dL/dW     = {grad_W.round(4).tolist()}   dL/db_h = {grad_b_h.round(4)}")
1. dL/dy_hat = 2(0.725 - 0) = 1.449
2. delta_y   = 1.449 · 0.725(1 - 0.725) = 0.2892
3. dL/dv     = [0.1814 0.2191]   dL/db_y = 0.2892
4. delta_h   = [0.0203 0.0266]
5. dL/dW     = [[0.0101, 0.0162], [0.0133, 0.0212]]   dL/db_h = [0.0203 0.0266]

Leia os números de cima para baixo:

  • o gradiente da perda, 1,449: se \(\hat y\) sobe um pouco, a perda sobe cerca de 1,449 vezes isso, então o treino precisa empurrar \(\hat y\) para baixo, em direção ao alvo 0;
  • o delta da saída, 0,289: a inclinação da sigmoide na saída é \(0{,}725 \times 0{,}275 = 0{,}199\) e \(1{,}449 \times 0{,}199 = 0{,}289\);
  • os pesos da saída: \(0{,}289 \times h\) dá 0,181 para \(v_{11}\) e 0,219 para \(v_{12}\), maior porque \(h_2 = 0{,}758\) contribuiu mais para a saída;
  • os deltas ocultos, 0,0203 e 0,0266: cerca de dez vezes menores que o delta da saída, porque no caminho de volta cada um foi multiplicado por um peso menor que 1 (0,3 e 0,5) e por uma inclinação de sigmoide menor que 0,25 (\(0{,}627 \times 0{,}373 = 0{,}234\) para \(h_1\));
  • os gradientes da primeira camada, os deltas ocultos vezes as entradas 0,5 e 0,8, vão de 0,0101 a 0,0212: os menores de todos. É o gradiente que desaparece da seção de ativações, em miniatura.

Isso é mesmo a derivada? Confira pelo caminho lento: empurre cada um dos 9 parâmetros em \(\pm 10^{-6}\), recalcule a perda e tome o quociente de diferenças (uma checagem por diferenças finitas):

def loss(params):
    W_, b_h_, v_, b_y_ = params[:4].reshape(2, 2), params[4:6], params[6:8], params[8]
    return (sigmoid(v_ @ sigmoid(W_ @ x + b_h_) + b_y_) - y_true) ** 2


params = np.r_[W.ravel(), b_h, v, b_y]           # os 9 parâmetros num só vetor
backprop = np.r_[grad_W.ravel(), grad_b_h, grad_v, grad_b_y]
eps = 1e-6
numeric = np.array([(loss(params + eps * e) - loss(params - eps * e)) / (2 * eps) for e in np.eye(9)])
print("largest difference, backprop vs finite differences:", f"{np.abs(numeric - backprop).max():.1e}")

eta = 0.5                                        # 6. um passo de gradiente nos 9 parâmetros
new_params = params - eta * backprop
print(f"loss before: {loss(params):.4f} · after one step with eta = {eta}: {loss(new_params):.4f}")
largest difference, backprop vs finite differences: 1.3e-10
loss before: 0.5249 · after one step with eta = 0.5: 0.4405

A retropropagação e as diferenças finitas concordam até \(10^{-10}\), o erro de arredondamento dos quocientes: a retropropagação não é uma aproximação, é a derivada exata, calculada de forma eficiente. Um passo de gradiente com \(\eta = 0{,}5\) nos 9 parâmetros baixa a perda deste exemplo de 0,5249 para 0,4405.

A checagem por diferenças finitas precisou de 18 passagens diretas extras para 9 parâmetros; a retropropagação obteve os 9 gradientes com uma única passagem de volta, que custa mais ou menos o mesmo que a passagem direta. Para os 2.410 parâmetros de uma rede de dígitos 64-32-10, é uma passagem de volta em vez de 4.820 passagens diretas; para as redes modernas, com bilhões de parâmetros, essa razão é o que torna o treino possível.

Uma observação que liga de volta à regressão logística: com a perda de entropia cruzada no lugar da quadrática, os passos 1 e 2 se fundem num só, \(\delta_y = \hat y - y\) (aqui \(0{,}725\)), o mesmo "previsão menos alvo" do gradiente da regressão logística. Do passo 3 em diante nada muda.

Não convexo: o ponto de partida importa

Na regressão logística a perda é convexa: um só mínimo, alcançado de qualquer ponto de partida. Compor camadas quebra isso. A mesma rede de 2 unidades ocultas que resolve o XOR à mão, treinada a partir de 10 inicializações aleatórias (o random_state), no XOR ruidoso:

for units in [2, 8]:
    accs = [make_pipeline(StandardScaler(), MLPClassifier(hidden_layer_sizes=(units,), max_iter=5000,
                                                          random_state=seed)).fit(X_nx, y_nx).score(X_nx, y_nx)
            for seed in range(10)]
    print(f"{units} hidden units, training accuracy for seeds 0-9:", np.round(accs, 2))
2 hidden units, training accuracy for seeds 0-9: [0.76 0.66 0.74 0.94 0.94 0.94 0.94 0.94 0.74 0.66]
8 hidden units, training accuracy for seeds 0-9: [0.98 0.98 0.97 0.98 0.98 0.96 0.99 0.99 0.97 0.95]

Com 2 unidades ocultas, 5 dos 10 inícios chegam a 0,94 de acurácia de treino, quase tão bom quanto a rede feita à mão (0,930 nesses mesmos pontos, calculado no script da figura acima). Os outros 5 param entre 0,66 e 0,76, presos num mínimo local pior. Com 8 unidades, todo início termina entre 0,95 e 0,99: unidades extras dão ao gradiente descendente mais caminhos até uma boa solução, um dos motivos de as redes usadas na prática serem mais largas do que o mínimo que funcionaria. No scikit-learn o início é definido pelo random_state: fixe-o para ter reprodutibilidade e, quando uma conclusão importar, confira que ela vale para algumas sementes.

Otimizadores e regularização

O MLPClassifier oferece três solvers. 'adam' (o padrão; Kingma & Ba, 2015) é o gradiente descendente em mini-batch (lotes de 200 linhas) com momento e um tamanho de passo por parâmetro; é a escolha habitual para conjuntos grandes. 'sgd' é o gradiente descendente em mini-batch puro, com momento opcional. 'lbfgs' usa o conjunto de treino inteiro a cada passo e uma aproximação da curvatura; em conjuntos pequenos (alguns milhares de linhas) costuma convergir mais rápido e melhor.

Regularização, no vocabulário que você já tem:

  • alpha, o weight decay: a penalidade L2 do Ridge, em todos os pesos de todas as camadas;
  • early_stopping=True: separa 10% das linhas de treino, acompanha a acurácia nelas depois de cada época e para quando ela não melhora por 10 épocas (n_iter_no_change), a mesma ideia da parada antecipada no gradient boosting;
  • nas bibliotecas de deep learning existe também o dropout (Srivastava et al., 2014), que desliga um subconjunto aleatório de unidades a cada passo, para que nenhuma unidade dependa de outra específica; o scikit-learn não o implementa.

Treino na prática com o scikit-learn

Escalone as entradas

Etapa 5. O gradiente descendente numa rede sofre com atributos em escalas diferentes exatamente como sofria nos modelos lineares: a superfície de perda vira um vale longo e estreito, onde uma única taxa de aprendizado não serve para todas as direções. No conjunto spambase (4.601 e-mails, 57 atributos numéricos: frequências de palavras e de caracteres, além de comprimentos de sequências de letras maiúsculas que chegam aos milhares):

from sklearn.datasets import fetch_openml

spam = fetch_openml(data_id=44, as_frame=True)                 # spambase (UCI)
X_sp, y_sp = spam.data, (spam.target == '1').astype(int)       # 1 = spam
print(X_sp.shape, f"spam share {y_sp.mean():.3f}",
      f"· feature std from {X_sp.std().min():.3f} to {X_sp.std().max():.0f}")

for name, model in [('raw inputs', MLPClassifier(max_iter=1000, random_state=0)),
                    ('StandardScaler first', make_pipeline(StandardScaler(),
                                                           MLPClassifier(max_iter=1000, random_state=0)))]:
    print(f"MLP, {name:20s}: CV accuracy {cross_val_score(model, X_sp, y_sp, cv=cv).mean():.3f}")
(4601, 57) spam share 0.394 · feature std from 0.076 to 606
MLP, raw inputs          : CV accuracy 0.927
MLP, StandardScaler first: CV accuracy 0.948

Os desvios padrão dos atributos vão de 0,076 a 606. Com as entradas cruas, o MLP chega a 0,927 de acurácia na validação cruzada; com um StandardScaler na frente, 0,948: dois pontos a mais com uma linha de pré-processamento. O escalonador vai dentro do pipeline, para que cada fold o ajuste nas próprias linhas de treino, como na aula de pipelines. (O Spambase tem 394 e-mails duplicados exatos, mantidos aqui; a aula de árvores de decisão explica por que descartá-los dá escores um pouco menores e mais honestos.)

Largura: quantas unidades ocultas?

hidden_layer_sizes=(h,) define uma camada oculta de \(h\) unidades; (h1, h2) define duas camadas. Mais unidades significam mais peças para a rede combinar, portanto uma fronteira mais flexível. Em 300 pontos de duas meias-luas ruidosas, com o solver 'lbfgs' e quase nenhum weight decay (alpha=1e-4):

from sklearn.datasets import make_moons
from sklearn.model_selection import cross_validate

X_m, y_m = make_moons(n_samples=300, noise=0.3, random_state=1)


def train_and_cv(units, alpha):
    net = make_pipeline(StandardScaler(), MLPClassifier(hidden_layer_sizes=(units,), solver='lbfgs',
                                                        alpha=alpha, max_iter=5000, random_state=0))
    r = cross_validate(net, X_m, y_m, cv=cv, return_train_score=True)
    return f"train {r['train_score'].mean():.3f} · CV {r['test_score'].mean():.3f}"


print(f"logistic regression:  CV {cross_val_score(LogisticRegression(), X_m, y_m, cv=cv).mean():.3f}")
for units in [1, 3, 10, 100]:
    print(f"{units:3d} hidden units: ", train_and_cv(units, alpha=1e-4))
logistic regression:  CV 0.830
  1 hidden units:  train 0.842 · CV 0.843
  3 hidden units:  train 0.897 · CV 0.873
 10 hidden units:  train 0.938 · CV 0.893
100 hidden units:  train 0.997 · CV 0.857

Com 1 unidade oculta, a rede não é melhor que a regressão logística (0,843 contra 0,830 na validação cruzada): uma unidade traça uma reta. Com 3 e 10 unidades a fronteira se curva e a acurácia sobe para 0,873 e 0,893. Com 100 unidades, a rede ajusta os folds de treino quase perfeitamente (0,997) enquanto a acurácia de validação cai de volta para 0,857: agora ela tem unidades suficientes para contornar pontos ruidosos individuais, o mesmo sobreajuste que você viu com árvores de decisão profundas e graus polinomiais altos.

Weight decay: alpha

Mantenha as 100 unidades e aumente alpha:

for alpha in [1e-4, 0.01, 0.1, 1, 10]:
    print(f"100 units, alpha = {alpha:<6g}:", train_and_cv(100, alpha))
100 units, alpha = 0.0001: train 0.997 · CV 0.857
100 units, alpha = 0.01  : train 0.993 · CV 0.880
100 units, alpha = 0.1   : train 0.939 · CV 0.903
100 units, alpha = 1     : train 0.914 · CV 0.917
100 units, alpha = 10    : train 0.837 · CV 0.830

Fronteiras de decisão nas luas ruidosas: 1 unidade oculta traça uma reta (0,843 na validação cruzada), 3 e 10 unidades a curvam (0,873 e 0,893), 100 unidades contornam pontos isolados (treino 0,997, validação 0,857) e 100 unidades com alpha = 1 dão uma curva suave (0,917)

(script da figura)

A tabela e a figura contam de novo a história do Ridge. Quase sem penalidade, as 100 unidades sobreajustam (treino 0,997, validação 0,857). Aumentar alpha encolhe os pesos, a fronteira se suaviza e a distância entre treino e validação se fecha: com alpha = 1, o treino fica em 0,914 e a validação em 0,917, a melhor configuração da página e melhor que 10 unidades sem penalidade (0,893). Penalidade demais achata a rede de volta numa reta: alpha = 10 dá 0,830, a marca da regressão logística. Repare também na forma das fronteiras: com unidades ReLU elas são feitas de segmentos retos, com uma dobra onde uma unidade liga ou desliga, o que se vê bem nos painéis de 3 e 10 unidades.

A receita prática, então, é a de todo modelo deste curso: escolha uma arquitetura generosa e ajuste alpha (e a largura) com validação cruzada. O lab faz exatamente isso.

Por que profundidade e quando usar uma rede neural

Profundidade

Uma camada oculta já é um aproximador universal: com unidades suficientes, ela aproxima qualquer função contínua numa região limitada tão bem quanto se queira (Cybenko, 1989; Hornik, 1991). O teorema garante existência, não que o treino encontre os pesos nem que o número de unidades seja razoável. Na prática, redes profundas (muitas camadas) chegam à mesma acurácia com muito menos unidades, porque cada camada constrói sobre os atributos da anterior: em imagens, as primeiras camadas de uma rede convolucional respondem a bordas, as seguintes a texturas e partes, as últimas a objetos inteiros. Essa hierarquia é o motivo de o deep learning ter tomado os problemas de percepção (imagens, áudio, texto) a partir de 2012, quando dados e GPUs ficaram abundantes (AlexNet).

Dados tabulares: uma comparação honesta

Numa tabela de colunas heterogêneas, a história é outra. Mesmos folds, mesmos dados do spambase, quatro modelos:

from sklearn.ensemble import HistGradientBoostingClassifier

candidates = {
    'logistic': make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000)),
    'MLP': make_pipeline(StandardScaler(), MLPClassifier(max_iter=1000, random_state=0)),
    'MLP early stop': make_pipeline(StandardScaler(),
                                    MLPClassifier(max_iter=1000, early_stopping=True, random_state=0)),
    'boosting': HistGradientBoostingClassifier(random_state=0),
}
folds = pd.DataFrame({name: cross_val_score(m, X_sp, y_sp, cv=cv) for name, m in candidates.items()})
print(pd.concat([folds, folds.agg(['mean', 'std'])]).round(3).to_string())
print("boosting - MLP, per fold:", (folds['boosting'] - folds['MLP']).round(3).tolist())
      logistic    MLP  MLP early stop  boosting
0        0.921  0.951           0.946     0.954
1        0.928  0.953           0.950     0.955
2        0.926  0.946           0.937     0.966
3        0.934  0.949           0.942     0.965
4        0.913  0.941           0.935     0.939
mean     0.924  0.948           0.942     0.956
std      0.008  0.005           0.006     0.011
boosting - MLP, per fold: [0.003, 0.002, 0.021, 0.016, -0.002]

Nesta tabela a ordem é: gradient boosting 0,956 ± 0,011, MLP 0,948 ± 0,005, MLP com parada antecipada 0,942 ± 0,006, regressão logística 0,924 ± 0,008. O boosting vence o MLP em 4 dos 5 folds, mas por pouco (de −0,002 a 0,021): uma vantagem, não um nocaute. E ele chegou lá com os padrões e sem pré-processamento, enquanto a rede precisou de um escalonador, de uma escolha de arquitetura, de alpha e de solver. Além disso, a parada antecipada não trouxe ganho nenhum aqui.

Isso bate com benchmarks grandes: em conjuntos tabulares típicos, os ensembles de árvores continuam tão bons quanto as redes neurais ou melhores, com menos ajuste e menos computação (Shwartz-Ziv & Armon, 2022; Grinsztajn, Oyallon & Varoquaux, 2022). Árvores dividem por um atributo de cada vez, então não ligam para escala, ignoram colunas inúteis com facilidade e lidam com relações irregulares, em degraus, entre um atributo e o alvo; uma rede precisa aprender tudo isso a partir de funções suaves. Em imagens a balança se inverte, como o lab mostra com pixels.

dados primeira escolha
tabelas de colunas heterogêneas gradient boosting ou uma random forest
imagens, áudio, vídeo redes convolucionais, vision transformers
texto transformers ou embeddings + um modelo linear
poucas linhas modelos lineares, k-NN, SVM

Para onde ir: o curso de Deep Learning

Esta aula para no perceptron multicamadas. O curso complementar Artificial Neural Networks and Deep Learning continua exatamente daqui: a aula de MLP deduz a retropropagação na forma matricial; depois, o curso segue para o treino de redes profundas (inicialização, normalização, dropout), redes convolucionais e recorrentes, transformers e modelos generativos, em PyTorch no lugar do scikit-learn.

Retropropagação, passo a passo: o simulador

O simulador refaz a conta da seção de retropropagação na mesma rede 2-2-1. O passo 1 é a passagem direta, com cada valor guardado no seu nó; os passos 2 a 6 mandam o gradiente para trás, da perda até a primeira camada, um elo da regra da cadeia de cada vez; o passo 7 aplica a atualização com \(\eta = 0{,}5\). Os números são os que o código daquela seção imprime. Use Avançar e Voltar, os pontos ou as setas do teclado.

Lab: uma rede neural em dígitos manuscritos

O problema. O conjunto digits, que vem com o scikit-learn, tem 1.797 dígitos manuscritos, digitalizados e reduzidos a imagens de 8 × 8 com 17 níveis de cinza (0 a 16), da coleção "Optical Recognition of Handwritten Digits" da UCI. Cada imagem vira uma linha de 64 valores de pixel; a tarefa é dizer qual é o dígito. Pixels não são uma tabela de colunas heterogêneas: toda coluna é o mesmo tipo de medida e o que importa é como pixels vizinhos se combinam, o tipo de estrutura que uma camada oculta consegue aprender.

O fluxo é o da aula de validação: trancar um conjunto de teste, comparar modelos nos mesmos folds de validação cruzada, ajustar com GridSearchCV e tocar o teste uma única vez. Toda saída abaixo é o que o código imprime. Para rodar você mesmo, baixe o notebook do lab e abra no Jupyter ou no Colab (Arquivo → Fazer upload de notebook); ele roda em cerca de um minuto.

Passo 1 — carregar e olhar

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits

X, y = load_digits(return_X_y=True)          # 1.797 imagens de 8 × 8 pixels, uma linha de 64 valores cada
print(X.shape, "· pixel values from", X.min(), "to", X.max())
print("images per digit:", np.bincount(y))

fig, axes = plt.subplots(1, 10, figsize=(10, 1.4))
for digit, ax in enumerate(axes):
    ax.imshow(X[y == digit][0].reshape(8, 8), cmap='gray_r')
    ax.set_title(digit)
    ax.axis('off')
plt.show()
(1797, 64) · pixel values from 0.0 to 16.0
images per digit: [178 182 177 183 181 182 181 179 174 180]

São 1.797 imagens, 64 colunas de pixels com valores de 0 a 16 e entre 174 e 183 imagens por dígito: as classes estão equilibradas, então a acurácia é um bom resumo. A fileira de miniaturas mostra como 8 × 8 pixels são grosseiros. Todas as colunas estão na mesma unidade, mas a rede é treinada por gradiente descendente, então ganha um StandardScaler no pipeline mesmo assim.

Passo 2 — trancar o conjunto de teste

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42)
print(X_train.shape, X_test.shape)
(1347, 64) (450, 64)

São 1.347 imagens para desenvolvimento e 450 trancadas até o passo 8, as duas partes com a mesma proporção de cada dígito graças ao stratify=y. No teste, cada imagem vale 0,22 ponto de acurácia (1/450).

Passo 3 — uma baseline e os folds que todos compartilham

from sklearn.dummy import DummyClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)


def cv_summary(model):
    scores = cross_val_score(model, X_train, y_train, cv=cv)
    return f"{scores.mean():.3f} ± {scores.std():.3f}"


print("always the most frequent digit:", cv_summary(DummyClassifier(strategy='most_frequent')))
always the most frequent digit: 0.100 ± 0.000

Responder sempre o dígito mais frequente acerta 10,0% das vezes, sem variação entre folds porque a estratificação dá a todo fold as mesmas proporções de classe. Toda acurácia daqui em diante deve ser lida contra 0,100. O objeto cv é criado uma vez e reusado em todos os passos, então todos os modelos são avaliados nos mesmos cinco folds. A função cv_summary informa a média ± o desvio padrão entre eles.

Passo 4 — três modelos nos mesmos folds

from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

models = {
    'logistic regression': make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
    'random forest, 300 trees': RandomForestClassifier(n_estimators=300, random_state=42, n_jobs=-1),
    'MLP, defaults (100 units)': make_pipeline(StandardScaler(), MLPClassifier(max_iter=1000, random_state=42)),
}
for name, model in models.items():
    print(f"{name:26s} {cv_summary(model)}")
logistic regression        0.966 ± 0.010
random forest, 300 trees   0.976 ± 0.009
MLP, defaults (100 units)  0.974 ± 0.010

Os três ficam muito acima da baseline de 0,100. A regressão logística, um modelo linear sobre os pixels crus, já chega a 0,966; a random forest, a 0,976; o MLP com os padrões do scikit-learn (uma camada oculta de 100 unidades ReLU, alpha = 0.0001, o solver Adam), a 0,974. Esse MLP tem \((64 + 1) \cdot 100 + (100 + 1) \cdot 10 = 7.510\) parâmetros. As diferenças entre os três (0,002 a 0,010) são do tamanho de um desvio padrão entre folds (0,009 a 0,010), então nada está decidido ainda.

Passo 5 — ajustar a largura e o alpha

from sklearn.model_selection import GridSearchCV

mlp = make_pipeline(StandardScaler(), MLPClassifier(max_iter=1000, random_state=42))
param_grid = {'mlpclassifier__hidden_layer_sizes': [(16,), (64,), (256,), (64, 64)],
              'mlpclassifier__alpha': [0.0001, 1, 10]}
grid = GridSearchCV(mlp, param_grid, cv=cv, n_jobs=-1).fit(X_train, y_train)

res = pd.DataFrame(grid.cv_results_)
res['layers'] = res['param_mlpclassifier__hidden_layer_sizes'].astype(str)
table = res.pivot(index='layers', columns='param_mlpclassifier__alpha', values='mean_test_score')
print(table.reindex(['(16,)', '(64,)', '(256,)', '(64, 64)']).round(3))
best_std = res.loc[grid.best_index_, 'std_test_score']
print(f"best: {grid.best_params_} · CV accuracy {grid.best_score_:.3f} ± {best_std:.3f}")
param_mlpclassifier__alpha  0.0001   1.0000   10.0000
layers
(16,)                         0.959    0.969    0.951
(64,)                         0.977    0.977    0.951
(256,)                        0.982    0.975    0.950
(64, 64)                      0.973    0.973    0.941
best: {'mlpclassifier__alpha': 0.0001, 'mlpclassifier__hidden_layer_sizes': (256,)} · CV accuracy 0.982 ± 0.003

Leia a tabela por linhas e por colunas:

  • a largura ajuda, até certo ponto: 16 unidades são poucas (0,959 sem penalidade), 64 e 256 chegam a 0,977 e 0,982;
  • alpha = 10 prejudica todas as arquiteturas (0,941 a 0,951): a penalidade espreme os pesos até a rede subajustar. alpha = 1 ajuda a rede menor (0,969 contra 0,959) e prejudica um pouco a maior (0,975 contra 0,982);
  • uma segunda camada oculta não ajuda aqui: (64, 64) marca 0,973, abaixo de uma camada única de 64 (0,977). A profundidade compensa em dados de percepção numa escala muito maior que 1.347 imagens de 8 × 8 pixels.

A vencedora, 256 unidades com alpha = 0.0001, marca 0,982 ± 0,003; as segundas colocadas, com 0,977, ficam a menos de dois desvios padrão entre folds. Como na aula de validação, é uma boa escolha, não um ótimo provado.

Passo 6 — a curva de perda e a parada antecipada

best = grid.best_estimator_                        # a vencedora, reajustada em todo o X_train
net = best[-1]
print(f"no early stopping: {net.n_iter_} epochs · final training loss {net.loss_curve_[-1]:.4f}")

settings = {k.split('__')[1]: v for k, v in grid.best_params_.items()}
early = make_pipeline(StandardScaler(), MLPClassifier(**settings, early_stopping=True, max_iter=1000,
                                                      random_state=42)).fit(X_train, y_train)
net_es = early[-1]
print(f"early stopping:    {net_es.n_iter_} epochs · best accuracy on its 10% validation rows "
      f"{net_es.best_validation_score_:.3f}")
print("CV accuracy with early stopping:", cv_summary(early))

fig, ax = plt.subplots(figsize=(7, 3.2))
ax.plot(net.loss_curve_, label='training loss, no early stopping')
ax.plot(net_es.loss_curve_, label='training loss, early stopping')
ax.set_xlabel('epoch')
ax.set_ylabel('training loss')
ax.set_yscale('log')
ax2 = ax.twinx()
ax2.plot(net_es.validation_scores_, color='green', label='validation accuracy (early stopping)')
ax2.set_ylabel('validation accuracy')
fig.legend(loc='center right')
plt.show()
no early stopping: 105 epochs · final training loss 0.0039
early stopping:    31 epochs · best accuracy on its 10% validation rows 0.978
CV accuracy with early stopping: 0.961 ± 0.008

Sem parada antecipada, o Adam rodou 105 épocas (passadas pelas 1.347 imagens de treino) até a perda parar de melhorar, terminando com perda de treino 0,0039: a rede ajusta as imagens de treino quase perfeitamente e com confiança. Com early_stopping=True, o scikit-learn separou 10% das linhas de treino, parou depois de 31 épocas porque a acurácia nelas não melhorava desde a época 20 e chegou a 0,978 nessas linhas (script da figura):

Esquerda: a perda de treino em escala log cai de 2,17 na época 1 para 0,0039 na época 105 sem parada antecipada; a rodada com parada antecipada termina na época 31. Direita: a acurácia de validação da rodada com parada antecipada sobe de 0,50 para 0,978 na época 20 e depois fica estável até a rodada parar, na época 31

A acurácia na validação cruzada, porém, é 0,961 ± 0,008, abaixo dos 0,982 da vencedora. Num conjunto tão pequeno, abrir mão de 10% das linhas de treino e parar cedo custa mais do que o sobreajuste que isso evita; a penalidade alpha, escolhida por validação cruzada, é o melhor regularizador aqui. A parada antecipada se paga em conjuntos grandes, onde cada época é cara e sobram linhas.

Passo 7 — o MLP é mesmo melhor? Mesmos folds, fold a fold

def fold_scores(g):                                # as 5 notas por fold da vencedora de uma grade
    return np.array([g.cv_results_[f'split{k}_test_score'][g.best_index_] for k in range(5)])


lr_grid = GridSearchCV(models['logistic regression'],
                       {'logisticregression__C': [0.01, 0.1, 1, 10, 100]}, cv=cv).fit(X_train, y_train)
print("logistic regression, best C:", lr_grid.best_params_['logisticregression__C'])
folds = pd.DataFrame({'MLP': fold_scores(grid),
                      'logistic': fold_scores(lr_grid),
                      'forest': cross_val_score(models['random forest, 300 trees'], X_train, y_train, cv=cv)})
folds['MLP - logistic'] = folds['MLP'] - folds['logistic']
folds['MLP - forest'] = folds['MLP'] - folds['forest']
print(pd.concat([folds, folds.agg(['mean', 'std'])]).round(3).to_string())
logistic regression, best C: 10
        MLP  logistic  forest  MLP - logistic  MLP - forest
0     0.978     0.956   0.974           0.022         0.004
1     0.985     0.981   0.974           0.004         0.011
2     0.981     0.963   0.989           0.019        -0.007
3     0.981     0.959   0.963           0.022         0.019
4     0.985     0.978   0.981           0.007         0.004
mean  0.982     0.967   0.976           0.015         0.006
std   0.003     0.012   0.010           0.009         0.010

Ajustada em C, a regressão logística fica com C = 10 e 0,967. Depois, fold a fold:

  • o MLP vence a regressão logística ajustada em todos os cinco folds, por 0,004 a 0,022, média 0,015 ± 0,009: os atributos aprendidos pela camada oculta valem cerca de 1,5 ponto de acurácia em pixels;
  • contra a random forest ele vence 4 dos 5 folds e perde um (−0,007), média 0,006 ± 0,010: uma diferença menor que o próprio desvio padrão, portanto um empate.

Valem as ressalvas da aula de validação: a coluna do MLP é a melhor de 12 configurações, então é um pouco otimista, enquanto a floresta rodou com os padrões. Em pixels a rede é pelo menos tão boa quanto um ensemble de árvores forte; na tabela do spambase da aula, o boosting ficou à frente.

Passo 8 — o único teste

from sklearn.metrics import accuracy_score, confusion_matrix

pred = best.predict(X_test)
print(f"test accuracy {accuracy_score(y_test, pred):.3f} · {(pred != y_test).sum()} errors in {len(y_test)} images")
cm = confusion_matrix(y_test, pred)
errors = cm - np.diag(np.diag(cm))                 # só os erros
true_d, pred_d = np.unravel_index(errors.argmax(), errors.shape)
print(f"most frequent mistake: true {true_d}, predicted {pred_d} ({errors[true_d, pred_d]} times)")
print("errors per true digit:", errors.sum(axis=1))
test accuracy 0.982 · 8 errors in 450 images
most frequent mistake: true 8, predicted 1 (3 times)
errors per true digit: [1 1 0 0 0 1 1 0 4 0]

Uma única vez, no fim, o MLP ajustado é avaliado nas 450 imagens de teste: acurácia 0,982, 8 erros, exatamente a estimativa da validação cruzada (0,982 ± 0,003). Metade dos erros (4 de 8) são 8 verdadeiros, três deles lidos como 1, enquanto os dígitos 2, 3, 4, 7 e 9 não têm nenhum erro no teste. Com 8 × 8 pixels há pouco espaço para distinguir um 8 estreito de um 1.

Sua vez

  1. Profundidade. Acrescente (128, 64) e (256, 128) à grade do passo 5. Uma segunda camada oculta supera a melhor camada única por mais de um desvio padrão entre folds? Conte os parâmetros de cada vencedora com a n_params da aula.
  2. Gradient boosting em pixels. Avalie HistGradientBoostingClassifier(random_state=42) com cv_summary (demora mais que os outros). No spambase ele venceu o MLP; o que acontece com pixels? Ponha as notas por fold dele ao lado das do MLP, como no passo 7.
  3. Solver e ativação. Reajuste a vencedora com solver='lbfgs' e depois com activation='tanh', nos mesmos folds. Qual configuração muda a acurácia de validação cruzada mais do que o desvio padrão dela?
  4. Olhe os erros. Plote as imagens de teste mal classificadas (X_test[pred != y_test], reorganizadas em 8 × 8) com os rótulos verdadeiro e previsto. Você as teria lido corretamente?

Material de aula

Vídeos

Leitura complementar


Referências

  • Rosenblatt, F. "The Perceptron: A Probabilistic Model for Information Storage and Organization in the Brain." Psychological Review 65, n. 6 (1958): 386–408. DOI
  • Novikoff, A. B. J. "On Convergence Proofs on Perceptrons." Proceedings of the Symposium on the Mathematical Theory of Automata 12 (1962): 615–622 — o limite de erros \((R/\gamma)^2\).
  • Minsky, M.; Papert, S. Perceptrons: An Introduction to Computational Geometry. MIT Press (1969) — a limitação dos perceptrons de uma camada no XOR.
  • Rumelhart, D. E.; Hinton, G. E.; Williams, R. J. "Learning Representations by Back-propagating Errors." Nature 323 (1986): 533–536. DOI
  • Cybenko, G. "Approximation by Superpositions of a Sigmoidal Function." Mathematics of Control, Signals and Systems 2 (1989): 303–314. DOI
  • Hornik, K. "Approximation Capabilities of Multilayer Feedforward Networks." Neural Networks 4 (1991): 251–257. DOI
  • Nair, V.; Hinton, G. E. "Rectified Linear Units Improve Restricted Boltzmann Machines." ICML (2010).
  • Glorot, X.; Bordes, A.; Bengio, Y. "Deep Sparse Rectifier Neural Networks." AISTATS (2011).
  • Srivastava, N.; Hinton, G.; Krizhevsky, A.; Sutskever, I.; Salakhutdinov, R. "Dropout: A Simple Way to Prevent Neural Networks from Overfitting." Journal of Machine Learning Research 15 (2014): 1929–1958.
  • Kingma, D. P.; Ba, J. "Adam: A Method for Stochastic Optimization." ICLR (2015). arXiv
  • Shwartz-Ziv, R.; Armon, A. "Tabular Data: Deep Learning Is Not All You Need." Information Fusion 81 (2022): 84–90. DOI
  • Grinsztajn, L.; Oyallon, E.; Varoquaux, G. "Why Do Tree-Based Models Still Outperform Deep Learning on Typical Tabular Data?" NeurIPS Datasets and Benchmarks (2022). arXiv

Quiz