Perceptron: o Primeiro Neurônio que Aprende Sozinho
Primeira aula da playlist viva, e ela começa exatamente onde qualquer curso de redes neurais deveria começar: no neurônio mais simples que existe.
Antes do código: dois papers e 15 anos de distância
Rede neural não nasceu como código, nasceu como pergunta de biofísica. Em 1943, Warren McCulloch e Walter Pitts publicaram "A Logical Calculus of the Ideas Immanent in Nervous Activity", propondo um modelo matemático bem enxuto pra um neurônio biológico: soma os sinais de entrada, e dispara um sinal binário (tudo ou nada) se essa soma passar de um limiar. Sem aprendizado nenhum ainda, é só um circuito lógico fixo, cada "neurônio" resolve uma função lógica que alguém já decidiu de antemão.
O salto que faltava veio 15 anos depois, com Frank Rosenblatt em 1958: e se, em vez de alguém escolher os pesos à mão, o próprio neurônio aprendesse os pesos certos olhando pra exemplos? Esse é o Perceptron, e é exatamente o que a aula 1a do professor implementa: o Perceptron Learning Algorithm (PLA), do jeito mais cru possível, sem bias ainda (isso fica pra próxima aula).
O dataset: dois grupos separáveis por uma reta
def createDataset(n=20):
X = np.random.rand(n,2)
coefs = np.array([1, -1])
labels = X @ coefs
y = np.array(labels>0, dtype=int)*2-1
return X, y
O professor gera pontos aleatórios em 2D e rotula cada um pelo sinal de X @ coefs, ou seja, o produto escalar entre o ponto e o vetor [1, -1]. Geometricamente, X @ coefs é positivo de um lado da reta que passa pela origem e é perpendicular a [1, -1], e negativo do outro lado, então o rótulo y (-1 ou +1) já nasce linearmente separável por construção: existe uma reta que separa as duas classes perfeitamente, porque foi exatamente essa reta que gerou os rótulos.
def plotHyperplan(vector):
xs = np.array([0,1])
ys = -(vector[0]*xs)/vector[1]
plt.plot(xs, ys)
Essa função desenha a reta onde vector[0]*x + vector[1]*y = 0, a fronteira de decisão de um vetor de pesos w qualquer. É a mesma equação de sempre, w · x = 0 define um hiperplano, só que aqui sem bias, então o hiperplano é obrigado a passar pela origem (0,0). Guarda esse detalhe, ele vai importar daqui a pouco.
O professor até testa um DummyClassifier com pesos fixos [1, -1], os mesmos que geraram o dataset, e claro que acerta 100%: é o gabarito jogado direto na resposta. Dá pra ver isso de verdade, plotando a fronteira que esse vetor [1, -1] desenha em cima dos mesmos 20 pontos que voltam a aparecer no resto da aula:
Repara que a reta passa exatamente pela origem (0,0), como a conta do plotHyperplan garante, e separa as duas cores sem errar um único ponto. Não tem mágica nenhuma: o vetor [1, -1] é a régua que decidiu cada rótulo lá dentro do createDataset. A pergunta de verdade é outra: dá pra um algoritmo aprender esse mesmo vetor sozinho, só olhando pros pontos coloridos, sem eu entregar [1, -1] de bandeja?
O algoritmo: ajustar o peso na direção do erro
class PLA(BaseEstimator, ClassifierMixin):
def __init__(self, max_iter=10):
self.max_iter = max_iter
def fit(self, X, y):
self.w_ = np.random.rand(X.shape[1])
for _ in range(self.max_iter):
cost = 0
idx = np.arange(X.shape[0])
np.random.shuffle(idx)
for i in idx:
logits = X[i] @ self.w_
y_pred = np.sign(logits)
error = y[i] - y_pred
if error != 0:
cost += error**2
self.w_ += error*X[i]
if cost == 0:
break
return self
Antes de entrar linha por linha, a ideia inteira cabe numa frase: mostra um ponto pro modelo, se ele errou, empurra o peso um pouquinho na direção que teria acertado aquele ponto, repete até não errar mais ninguém. Só isso. Não tem gradiente calculado, não tem taxa de aprendizado pra ajustar, é a receita mais direta que dá pra imaginar.
Agora desmontando o fit de cima linha por linha:
self.w_ = np.random.rand(X.shape[1]): o vetor de pesos começa totalmente aleatório, um número entre 0 e 1 pra cada variável. O modelo abre o jogo sem noção nenhuma de onde fica a fronteira certa.for _ in range(self.max_iter):: dá atémax_iterchances pro algoritmo se corrigir. Cada uma dessas chances é uma época: uma passada completa pelos 20 pontos.cost = 0: um contador que zera no começo de cada época. Serve só pra responder uma pergunta lá no fim: "rolou algum erro dessa vez?"idx = np.arange(X.shape[0]); np.random.shuffle(idx): embaralha a ordem dos 20 pontos antes de visitá-los. Sem isso, o modelo veria sempre os mesmos pontos primeiro em toda época, o que pode enviesar o treino.for i in idx:: agora sim, visita cada ponto, um de cada vez, nessa ordem embaralhada.logits = X[i] @ self.w_: o produto escalar entre o ponto e o peso atual, um número só, positivo ou negativo.y_pred = np.sign(logits): vira decisão. Positivo diz "essa é da classe +1", negativo diz "essa é da classe -1".error = y[i] - y_pred: compara com o rótulo verdadeiro. Como os dois vivem em{-1, +1}, só existem três valores possíveis praerror:0(acertou),+2(era +1, o modelo chutou -1) ou-2(era -1, o modelo chutou +1).if error != 0: cost += error**2; self.w_ += error*X[i]: só mexe emw_quando errou.error**2é sempre4quando há erro (o valor exato não importa, só o fato de não ser zero), eself.w_ += error*X[i]é a atualização de verdade.
Pra ver essa atualização com números reais em vez de abstrato: imagina que, numa dessas passadas, w_ esteja em [0.5, -0.2], e o ponto da vez seja x = (0.6854, 0.9534), que no dataset de verdade é da classe -1. logits = 0.6854×0.5 + 0.9534×(-0.2) ≈ 0.152, positivo, então y_pred = +1. Só que o rótulo verdadeiro é -1, então error = -1 - 1 = -2. A atualização vira w_ += -2×(0.6854, 0.9534) ≈ (-0.871, -2.107), um empurrão forte na direção oposta a x.
Faz sentido pensar nisso geometricamente: w é o vetor normal ao hiperplano de decisão, ele aponta pro lado que o modelo chama de "classe +1". O modelo achou (errado) que esse ponto era +1, então empurrar w pra longe dele deixa w · x mais negativo da próxima vez, corrigindo na direção certa. O oposto acontece quando o erro é pro outro lado (error = +2): aí w é empurrado na direção do ponto, pra deixar w · x mais positivo. É um ajuste local e barato, cada erro move a fronteira de decisão um pouquinho na direção que teria acertado aquele ponto específico (isso é justamente o que o Aggarwal chama de "critério do perceptron", capítulo 1: uma regra de atualização heurística que se parece muito com gradiente descendente, mas que foi desenhada direto em cima do erro de classificação, antes de alguém formalizar qual função de custo suave ela estaria otimizando por baixo dos panos).
E o Rosenblatt provou, lá em 1958, algo forte: se os dados são mesmo linearmente separáveis (como são aqui, por construção), o PLA sempre converge pra uma solução com erro zero, em um número finito de passos. Não é "geralmente funciona", é uma garantia matemática.
Um bug real, escondido na condição de parada
Só que reparar de perto no if cost == 0: break revela um problema. Essa checagem está dentro do laço que percorre os pontos, não depois dele. Isso significa: assim que cost (que só cresce quando há erro) estiver em zero, o laço para imediatamente, mesmo que ainda faltem pontos pra checar naquela época.
O problema é que, logo no início de cada época, cost já começa em zero. Então se o primeiro ponto sorteado naquela época por acaso já está classificado certo, cost continua zero, e o break dispara ali mesmo, sem checar os outros 19 pontos. A época termina achando que "está tudo certo", quando na verdade só um ponto foi conferido.
Rodei esse código exato, byte a byte, com uma semente fixa (np.random.seed(10)), instrumentando só uma coisa: um contador que registra quantos dos 20 pontos cada época realmente processou antes do break disparar (ou de o laço acabar sozinho).
np.random.seed(10)
X, y = createDataset(20)
w_ = np.random.rand(X.shape[1])
counts = []
for _ in range(10):
cost = 0
idx = np.arange(X.shape[0])
np.random.shuffle(idx)
n_visto = 0
for i in idx:
n_visto += 1
logits = X[i] @ w_
y_pred = np.sign(logits)
error = y[i] - y_pred
if error != 0:
cost += error**2
w_ += error*X[i]
if cost == 0:
break
counts.append(n_visto)
Saída:
[20, 1, 1, 20, 1, 1, 1, 1, 1, 1]. Só as épocas 1 e 4 conferiram o dataset inteiro, as outras 8 pararam depois de olhar um único ponto. Acurácia final: 0.95, ainda longe da separação perfeita que o Rosenblatt garante, com pesosw = [2.228, -1.847].
Repara o ponto laranja isolado no meio da região azul, perto de (0.63, 0.75): é o único que sobrou mal classificado. Ele nunca teve chance de corrigir o modelo, porque em todas as épocas em que apareceu no meio da fila, o algoritmo já tinha desistido cedo demais antes de chegar nele.
Movendo só o if cost == 0: break pra fora do laço interno (checar zero erros depois de passar por todos os 20 pontos, não a cada um), a mesma semente, os mesmos dados, o mesmo w inicial:
Saída:
[20, 20, 20, 20], quatro épocas completas, e pronto: convergiu com acurácia 1.0, exatamente o que o teorema promete pra dado linearmente separável, com pesosw = [2.106, -2.082].
Mesmo ponto laranja de (0.63, 0.75) agora cai do lado certo: com a chance de ver o dataset inteiro em toda época, o algoritmo teve tempo de corrigir a fronteira até encaixar ele também.
Achado real, não hipotético: a intenção óbvia do código é "para quando não tiver mais erro nesta época", mas o jeito como o break foi posicionado faz ele parar assim que um único ponto favorável aparece, mesmo cercado de erro em algum lugar mais à frente na fila. Com sorte (como em boa parte das sementes que testei), isso não muda o resultado final porque outras épocas compensam. Mas com essa semente específica, o algoritmo declara sucesso prematuramente oito vezes seguidas, e fecha o treino com 1 ponto ainda errado, quando deveria ter chegado na separação perfeita.
Interativo: treinando o perceptron ponto por ponto
Reconstruí o mesmo dataset (a semente 10 de cima, os mesmos 20 pontos) num componente que roda a versão correta do algoritmo, um ponto por vez. Clica em "Processar próximo ponto" e repara: cada vez que um ponto colorido cai do lado errado da região de fundo, isso é um erro, e o próximo clique empurra a fronteira em direção a ele.
Repara que, sem bias, a fronteira é sempre uma reta passando pela origem (0,0), ela só pode girar, nunca deslizar. Nesse dataset funciona porque os dados foram desenhados em volta da origem de propósito. Mas e se a nuvem de pontos estivesse toda deslocada, longe da origem? Chego nisso na Aplicação Prática.
Fechando
| O que eu já sabia | O que essa aula assentou |
|---|---|
| Rede neural é sobre "aprender pesos" | O McCulloch-Pitts vem antes disso: primeiro alguém teve que propor que um neurônio pudesse ser modelado matematicamente, aprendizado veio 15 anos depois com Rosenblatt |
| Atualizar peso "na direção do erro" é intuitivo | Isso tem nome (critério do perceptron) e uma garantia matemática de convergência pra dado linearmente separável |
| Código do professor é a referência de verdade | Mesmo código de referência pode ter um bug sutil escondido numa condição de parada, e vale a pena testar em vez de confiar de olhos fechados |
Aplicação Prática
Testei o mesmo PLA sem bias (código corrigido, sem o bug do break) num dataset real: Iris, as duas classes mais fáceis de separar (setosa vs. versicolor), usando comprimento e largura da pétala como as duas variáveis.
from sklearn.datasets import load_iris
iris = load_iris()
mask = iris.target < 2
X = iris.data[mask][:, [2, 3]] # comprimento e largura da pétala
y = np.where(iris.target[mask] == 0, -1, 1)
Comparando com o createDataset de lá em cima, linha por linha: iris.data[mask][:, [2, 3]] faz o papel de np.random.rand(n,2), só que em vez de sortear pontos, pega duas colunas de um dataset real (comprimento e largura de pétala). E np.where(iris.target[mask] == 0, -1, 1) faz o papel de np.array(labels>0, dtype=int)*2-1: mesmo destino (rótulos em {-1, +1}), caminho diferente pra chegar lá. Lá em cima eu inventei os rótulos aplicando uma régua matemática (X @ coefs) em cima dos pontos; aqui os rótulos já existem (é a espécie real de cada flor), só precisa converter do formato 0/1 do sklearn pro formato -1/+1 que o PLA espera. A classe PLA em si, fit e predict, não muda uma linha sequer, prova que o algoritmo nem sabe (nem precisa saber) se o dado que ele está vendo é sintético ou real.
Essas duas classes são genuinamente linearmente separáveis (é um dos exemplos mais citados de "separável de verdade" em todo material introdutório de ML). Só que comprimento e largura de pétala nunca são negativos, então a nuvem de pontos inteira vive longe da origem, bem diferente do dataset sintético de cima.
| Versão | Convergiu em (máx. 50 épocas) | Acurácia final |
|---|---|---|
| PLA sem bias | nunca convergiu | 0.84 a 0.93 (variando a semente) |
| PLA com bias | 2 épocas | 1.0 |
Sem bias, o PLA nunca declara convergência dentro do limite de 50 épocas, porque a reta que separaria de verdade as duas classes não passa pela origem, e sem bias essa reta está simplesmente fora do alcance do modelo. Rodei 5 sementes diferentes e nenhuma passou de 93% de acurácia. Adicionando um único parâmetro (o bias, que desloca o hiperplano em vez de só girá-lo em torno da origem), o mesmo algoritmo converge em só 2 épocas com acurácia perfeita, pesos w = [3.549, 5.515], bias -9.397:
A folga entre as duas nuvens é gigante comparado ao dataset sintético lá em cima, por isso qualquer reta razoável nesse corredor vazio já separa as duas espécies. É o exato limite que a próxima aula ataca de frente: dado que não vive perto da origem precisa de bias pra ter alguma chance.