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.
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.
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.