Bias e Vetorização: Destravando o Perceptron
Aula 2a e 2b, e as duas resolvem um problema cada: a primeira destrava onde a fronteira de decisão pode ficar, a segunda destrava como o peso é atualizado.
O mesmo limite da aula passada, confirmado de novo
def createDataset(n=20):
X = np.random.rand(n,2)
coefs = np.array([1, 1])
intercept = 1
labels = X @ coefs - intercept
y = np.array(labels>0, dtype=int)*2-1
return X, y
Repara na diferença sutil pro dataset da aula passada: agora tem um intercept = 1 subtraído antes de aplicar o sinal. Isso desloca a reta separadora verdadeira pra x + y = 1, que não passa pela origem. Isso é exatamente o cenário que eu simulei artificialmente lá no post anterior usando o Iris: uma fronteira que não passa por (0,0).
O professor roda o mesmo PLA sem bias da aula passada, sem mudar uma linha, direto nesse dataset novo:
Saída: acurácia 0.7, pesos
[2.71, 0.20].
Confirma, com o código de verdade dele (não só com a minha simulação no Iris), o mesmo limite: sem bias, a fronteira só pode girar em torno da origem, e não existe jeito de girar uma reta pela origem até ela coincidir com x + y = 1.
Adicionando bias: a fronteira ganha liberdade de deslizar
class PLA(BaseEstimator, ClassifierMixin):
def __init__(self, max_iter=1000):
self.max_iter = max_iter
def fit(self, X, y):
self.w_ = np.random.rand(X.shape[1])
self.b_ = np.random.rand()
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_ + self.b_
y_pred = np.sign(logits)
error = y[i] - y_pred
if error != 0:
cost += error**2
self.w_ += error*X[i]
self.b_ += error
if cost == 0:
break
return self
A mudança é pequena no código mas grande no que ela destrava: agora logits = X[i] @ w_ + b_, e o b_ é atualizado junto, self.b_ += error. O jeito mais direto de entender por que essa é a atualização certa: pensa no bias como o peso de uma variável de entrada extra que vale sempre 1. Se w_ já é atualizado por error * x[i] pra cada variável real, o "peso" dessa variável fantasma de valor 1 seria atualizado por error * 1, ou seja, error sozinho. É o mesmo truque de sempre (adicionar uma coluna de 1s), só que aqui ele aparece explícito como uma variável separada em vez de escondido dentro de X.
Geometricamente, o bias desloca o hiperplano sem girar ele: agora w · x + b = 0 pode estar em qualquer lugar do plano, não só cruzando a origem.
Saída: acurácia 1.0, pesos
[5.10, 3.28], bias-3.98.
Converge. Repara que o professor também aumentou max_iter de 10 pra 1000 nessa versão, o mesmo bug de parada da aula passada ainda mora dentro desse if cost == 0: break, só que com 1000 épocas de chance em vez de 10, a probabilidade de nunca conseguir uma passada inteira limpa fica bem menor. Não é um conserto do bug, é só dar espaço suficiente pra sorte compensar.
Esses pesos generalizam, ou só decoraram os 20 pontos?
O professor não para no treino. Ainda dentro da aula 2a, ele pega esse mesmo w_/b_ (treinado só nos 20 pontos de cima) e desenha a fronteira dele em cima de 200 pontos novos, gerados pela mesma regra mas nunca vistos durante o treino, só pra olhar visualmente se a fronteira aprendida bate com a fronteira de verdade (x + y = 1) longe dos 20 pontos originais.
O notebook não imprime a acurácia dessa checada, só o gráfico. Pra eu poder plotar isso aqui de um jeito que dá pra examinar de perto (em vez de uma imagem estática do matplotlib), sorteei minha própria leva de 70 pontos novos, na mesma regra (x + y = 1, uniforme em [0,1]²), e usei exatamente o w/b que o professor treinou. Não é o sorteio exato dele (o notebook não salva os números, só a imagem), mas é a mesma pergunta: será que a fronteira aprendida em cima de só 20 pontos continua valendo pra pontos que ela nunca viu?
A região colorida de fundo é a fronteira aprendida, w · x + b = 0. Qualquer ponto cuja cor bate com a região de baixo dele acertou; qualquer ponto "fora de lugar" (cor clara sobre região escura da outra classe) é um erro de generalização. Na minha leva, deu 93% de acerto, alguns pontos bem perto da linha x + y = 1 de verdade caem do lado errado, porque a fronteira aprendida com só 20 exemplos não é idêntica à fronteira que gerou os dados, só uma aproximação boa o suficiente pra acertar aqueles 20. Guarda essa ideia, ela volta com números de verdade lá embaixo, na versão vetorizada.
Interativo: perceptron com bias, ponto por ponto
Mesmo dataset de verdade do notebook (os 20 pontos exatos que o professor rodou), agora com bias ligado. Clica em "Processar próximo ponto" e repara como a fronteira, dessa vez, consegue deslizar pra longe da origem até encaixar em x + y = 1.
Vetorizando: de laço ponto a ponto pra uma conta só
class Perceptron(BaseEstimator, ClassifierMixin):
def __init__(self, max_iter=1000):
self.max_iter = max_iter
def fit(self, X, y):
self.w_ = np.random.rand(X.shape[1])
self.b_ = np.random.rand()
for _ in range(self.max_iter):
cost = 0
y_pred = self.predict(X)
error = y - y_pred
self.w_ += np.dot(X.T, error)
self.b_ += np.sum(error)
cost = np.sum(error**2)
if cost == 0:
break
return self
Essa versão joga fora o laço for i in idx inteiro. Em vez de olhar um ponto por vez, ela: prevê todo mundo de uma vez (y_pred = self.predict(X)), calcula o erro de todo mundo de uma vez (error = y - y_pred), e faz uma única atualização somando a contribuição de cada ponto errado (X.T @ error, o mesmo produto matriz-vetor que já apareceu na equação normal e na especialização do Andrew Ng). Isso também mata o bug da parada prematura de vez: como cost só é calculado depois de já ter processado o dataset inteiro naquela iteração, não tem como o break disparar cedo demais.
Essa é a mesma distinção entre gradiente descendente em lote (usa o dataset inteiro por atualização) e gradiente descendente estocástico (atualiza a cada exemplo) que eu já vi na outra playlist, só que aplicada aqui na regra de aprendizado do perceptron em vez de numa regressão.
Saída (mesmo dataset de 20 pontos): acurácia 1.0, pesos
[24.75, 16.55], bias-19.04.
O detalhe que passa batido: 100% no treino não é 100% garantido em dado novo
O professor testa esse mesmo modelo vetorizado num conjunto de teste bem maior, 1000 pontos novos gerados pela mesma regra:
Saída: acurácia 0.897 em 1000 pontos de teste.
Caiu de 1.0 pra 0.897. Isso não é sinal de erro no código, é uma propriedade do próprio perceptron que o Aggarwal comenta no capítulo 1: a regra de atualização do perceptron só garante achar alguma reta que separa os pontos de treino, não necessariamente a melhor reta (a de maior margem até os pontos de cada classe). Com só 20 pontos de treino, várias retas diferentes conseguem separar todos eles perfeitamente, mas cada uma dessas retas erra uma fatia diferente de pontos novos que caem perto da fronteira real. O Aggarwal chama isso de "critério do perceptron", em contraste com a SVM (Support Vector Machine), que resolve exatamente esse problema maximizando a margem de propósito.
Rodei essa mesma comparação (treino pequeno, teste de 1000 pontos) 5 vezes, tanto na versão ponto a ponto quanto na vetorizada, pra ver se isso é specific de uma versão ou das duas:
| Rodada | Ponto a ponto (treino / teste) | Vetorizada (treino / teste) |
|---|---|---|
| 1 | 1.0 / 0.894 | 1.0 / 0.937 |
| 2 | 1.0 / 0.963 | 1.0 / 0.881 |
| 3 | 1.0 / 0.967 | 1.0 / 0.960 |
| 4 | 1.0 / 0.986 | 1.0 / 0.978 |
| 5 | 1.0 / 0.948 | 1.0 / 0.980 |
As duas sempre acertam 100% no treino, e as duas variam bastante no teste (de 0.88 a 0.99), sem um padrão claro de qual jeito de atualizar é mais confiável. Isso confirma que a instabilidade não vem de "atualizar ponto a ponto" versus "atualizar tudo de uma vez", vem do próprio critério do perceptron: qualquer separador serve, não necessariamente o mais seguro.
Mesmo exercício de olhar de perto que fiz lá em cima pro w/b sem vetorização, agora pros pesos [24.75, 16.55] e bias -19.04 que a versão vetorizada aprendeu (mesma sacada: sorteei minha própria leva de pontos novos com a mesma regra, já que o notebook só guarda a imagem, não os números):
Reparei que os erros se concentram exatamente numa faixa fina em volta da linha x + y = 1, nunca longe dela. Faz todo sentido: pontos bem no meio de cada classe têm folga de sobra, qualquer fronteira minimamente próxima da certa já acerta. Quem sofre é o ponto que caiu por pouco de um lado, aí a diferença entre a fronteira aprendida e a fronteira real decide tudo. É exatamente o tipo de ponto que uma fronteira de margem máxima (o que a SVM faz de propósito) trataria melhor.
Interativo: a versão vetorizada, um passo = uma época inteira
Mesmo dataset, mesmo componente, só trocando o modo: agora cada clique processa o dataset inteiro de uma vez, em vez de ponto por ponto.
Fechando
| O que eu já sabia | O que essa aula assentou |
|---|---|
| Fronteira sem bias sempre passa pela origem | Adicionar bias é o mesmo truque de sempre (variável fantasma de valor 1), e destrava a fronteira pra deslizar pra qualquer lugar |
| Gradiente em lote vs. estocástico é distinção de regressão | A mesma distinção existe pro perceptron: laço ponto a ponto é a versão "estocástica", X.T @ error de uma vez é a versão "em lote" |
| Acurácia 1.0 no treino soa como "terminei" | Não garante generalização: o perceptron só acha alguma reta separadora, não a mais segura, e isso é limite do critério, não bug de implementação |
Aplicação Prática
Repeti a comparação ponto a ponto vs. vetorizada no Iris (setosa vs. versicolor, comprimento e largura de pétala), agora com bias e com uma divisão treino/teste de verdade (70/30).
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
| Versão | Épocas até convergir | Acurácia treino | Acurácia teste |
|---|---|---|---|
| Ponto a ponto | 2 a 3 | 1.0 | 1.0 |
| Vetorizada | 7 | 1.0 | 1.0 |
Dessa vez as duas bateram 100% no teste também, nas 3 sementes que testei. Faz sentido: diferente do dataset sintético de cima (só 20 pontos espalhados meio soltos), o Iris tem uma margem bem generosa entre as duas classes, então qualquer reta separadora razoável já acerta os pontos novos. A diferença que sobrou foi a velocidade: a versão ponto a ponto convergiu em 2 a 3 épocas, a vetorizada precisou de 7. Faz sentido com o que expliquei mais acima: numa única época, a versão ponto a ponto pode fazer até 20 atualizações de peso (uma por ponto errado encontrado), enquanto a vetorizada faz só uma atualização por época, então ela naturalmente precisa de mais passadas pelo dataset pra acumular a mesma quantidade de ajuste.
O código que gerou essa tabela é literalmente as mesmas duas classes de lá de cima, PLA e Perceptron, sem mudar uma linha, só trocando o dado de entrada:
clf = Perceptron(max_iter=1000)
clf.fit(X_train, y_train)
Comparando com o dataset sintético do começo do post: lá, X/y vinham de createDataset, um gerador que eu controlo, então dava pra testar a fronteira aprendida contra pontos novos só sorteando mais pontos pela mesma regra. Aqui não tem regra nenhuma pra sortear de novo, o train_test_split faz esse papel: separa 30% dos 100 pontos reais do Iris antes do treino, pra garantir que a checagem de generalização (o mesmo espírito da checagem com 200 pontos novos, lá em cima) usa dado que o modelo nunca viu, e não flor nenhuma inventada.
Pesos aprendidos pela versão vetorizada (semente 0): w = [40.349, 56.515], bias = -121.397. Plotando só os 30 pontos de teste, nunca vistos no treino, coloridos pela fronteira que os outros 70 ensinaram:
Nenhum ponto de teste caiu do lado errado. É a mesma margem generosa da tabela lá em cima, só que dessa vez dá pra ver com os próprios olhos, não só confiar no número.