← Voltar pra playlist

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 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. A pergunta de verdade é: dá pra aprender esses pesos só olhando pros exemplos, sem eu entregar a resposta pronta?

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

O w_ começa aleatório. Depois, ponto por ponto, em ordem embaralhada: calcula a previsão (sign(w · x)), compara com o rótulo verdadeiro, e se errou, atualiza w_ += error * x. Essa é a regra de aprendizado inteira, e vale entender por que ela funciona, não só decorar a fórmula.

Pensa geometricamente: w é o vetor normal ao hiperplano de decisão, ele aponta pro lado que o modelo considera "classe +1". Se o modelo errou um ponto que era de verdade classe +1 mas foi classificado como -1, isso significa que w está apontando "longe demais" desse ponto. Somar error * x a w (aqui error = +2, já que y_pred e y estão em {-1,+1}) empurra o vetor de pesos na direção desse ponto, deixando w · x um pouco mais positivo da próxima vez. O oposto acontece quando o erro é pro outro lado. É 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, sem precisar calcular gradiente nenhum de verdade (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)), pra ver o estrago na prática:

Saída: contando quantos pontos (de 20) cada época realmente processou antes do break: [1, 1, 1, 1, 1, 1, 1, 1, 20, 20]. Só as duas últimas épocas conferiram o dataset inteiro. Acurácia final: 0.6, bem longe da separação perfeita que o Rosenblatt garante.

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], duas épocas completas, e pronto: convergiu com acurácia 1.0, exatamente o que o teorema promete pra dado linearmente separável.

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 nunca chega na solução perfeita que deveria.

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á sabiaO 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" é intuitivoIsso 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 verdadeMesmo 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)

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ãoConvergiu em (máx. 50 épocas)Acurácia final
PLA sem biasnunca convergiu0.84 a 0.93 (variando a semente)
PLA com bias2 épocas1.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. É o exato limite que a próxima aula ataca de frente.