Adaline: Treinar na Reta, Classificar no Sinal
Aula 2e e 2f, e o nome do modelo muda pra Adaline (ADAptive LInear NEuron), de Bernard Widrow e Ted Hoff, 1960, só dois anos depois do Rosenblatt. A ideia central deles é sutil, mas separa duas coisas que até aqui eu tratava como uma só: o que o modelo otimiza durante o treino e o que ele calcula na hora de prever.
Pré-ativação e pós-ativação: a distinção que o Adaline introduz
class AdalinePseudoInverse(BaseEstimator, ClassifierMixin):
def fit(self, X, y):
X = include_bias(X)
self.w_ = np.linalg.pinv(X) @ y
return self
def predict(self, X):
X = include_bias(X)
return X @ self.w_
Repara: isso é exatamente a equação normal do post passado, sem mudar uma linha, só que agora y é -1 ou +1 em vez de contínuo. O professor está tratando classificação como se fosse regressão: ajustando a reta pra chegar o mais perto possível de -1 nos pontos de uma classe e +1 na outra, sem nunca aplicar sign().
Saída: RMSE
0.4148, pesos[-1.76, 1.01, 2.74].
RMSE faz sentido aqui porque predict devolve um número contínuo, não uma classe. Só que pra classificar de verdade, falta o último passo:
class AdalinePseudoInverse(BaseEstimator, ClassifierMixin):
def fit(self, X, y):
X = include_bias(X)
self.w_ = np.linalg.pinv(X) @ y
return self
def pre_activation(self, X):
X = include_bias(X)
return X @ self.w_
def predict(self, X):
return np.sign(self.pre_activation(X))
Agora tem dois métodos: pre_activation (o valor contínuo, antes de qualquer limiar) e predict (aplica sign() em cima). O Aggarwal chama exatamente essas duas coisas de valor de pré-ativação e valor de pós-ativação (capítulo 1): tudo que um neurônio calcula acontece em duas etapas, primeiro a soma ponderada, depois a função de ativação em cima dela. O Adaline treina em cima da pré-ativação (ela é contínua, então dá pra medir "quão longe" cada previsão ficou do alvo) e só aplica a ativação (sign) na hora de decidir a classe final.
Saída: acurácia
1.0, os mesmos pesos[-1.76, 1.01, 2.74]de antes (é a mesma conta, só que agora avaliada por acurácia em vez de RMSE).
Isso é a regra delta, e eu já vi ela antes, com esse nome exato: "atualizar o peso proporcionalmente ao erro vezes a entrada" é a assinatura do Widrow-Hoff. A diferença pro perceptron do Rosenblatt (que eu vi duas aulas atrás) é justamente essa: o perceptron mede o erro depois de aplicar sign() (erro em {-2,0,+2}), o Adaline mede o erro antes, na pré-ativação contínua. Isso parece um detalhe pequeno, mas muda tudo: erro contínuo dá um gradiente de verdade, suave, que aponta pra melhor direção mesmo quando a previsão já está do lado certo mas ainda meio "insegura". Já o erro binário do perceptron só liga quando classifica errado, sem noção de "quão errado".
Interativo: mexendo na pré-ativação e vendo o RMSE (e a acurácia) mudarem
Em vez de treinar automaticamente, arrasta os sliders de w0, w1 e bias na mão e observa duas leituras ao mesmo tempo: o RMSE (contínuo, muda suavemente a cada arrastão) e a acurácia (discreta, só pula quando um ponto atravessa a fronteira de decisão).
RMSE (pré-ativação contínua vs. rótulo ±1): 1.000 · acurácia (pós-ativação, sign): 35%
Repara que o RMSE quase sempre continua mudando um pouquinho mesmo depois que a acurácia já bateu 100%: dá pra empurrar a fronteira mais fundo pro meio do vazio entre as duas classes (RMSE cai mais) sem ganhar nem perder nenhum ponto (acurácia parada). É exatamente essa diferença que separa "achar uma reta que separa" (o que o perceptron faz) de "achar a reta que separa com folga" (o que dá pra melhorar otimizando RMSE em vez de só contar erro).
Aula 2f: a mesma conta, só que iterando (e o notebook chama isso de "SGD")
class Adaline(BaseEstimator, ClassifierMixin):
def fit(self, X, y):
X = include_bias(X)
self.w_ = np.zeros(X.shape[1])
for _ in range(self.max_iter):
y_pred = X @ self.w_
error = y - y_pred
self.w_ += self.learning_rate * error @ X
return self
Um detalhe honesto sobre o nome do notebook (aula02f adaline with SGD): o código mostrado aqui é gradiente em lote (o mesmo X.T @ error de sempre), calculando o erro em cima do dataset inteiro a cada iteração, não SGD de verdade (que atualizaria um exemplo por vez, em ordem embaralhada). É um jeito comum de falar informalmente ("é tipo gradiente descendente, então chamo de SGD"), mas vale registrar a diferença técnica, já que os nomes têm significado preciso.
Saída: acurácia
1.0no treino, pesos[-2.92, 3.32, 2.63]. Testando em 1000 pontos novos: acurácia 0.953.
O dataset "ruim": o que ele realmente prova
X_bad = np.concatenate((X,np.ones_like(X)))
y_bad = np.concatenate((y,np.ones_like(y)))
X_bad = np.concatenate((X_bad,np.ones_like(X)))
y_bad = np.concatenate((y_bad,np.ones_like(y)))
clf_bad = Adaline()
clf_bad.fit(X_bad, y_bad)
O professor concatena o dataset original com dois blocos extras de pontos artificiais: todo mundo em (1,1), todo mundo rotulado +1. Isso não é ruído, é um enviesamento deliberado, empurrando o treino a "acreditar" que a região perto de (1,1) é ainda mais fortemente classe +1 do que ela realmente é.
Saída: acurácia
0.967no treino (sobre o dataset enviesado), mas só 0.811 nos mesmos 1000 pontos de teste limpos de antes.
Caiu de 0.953 pra 0.811. Antes de escrever esse post, eu tinha a hipótese de que essa célula ia mostrar a equação normal (pseudo-inversa) quebrando nesse dataset problemático, e o SGD aguentando melhor. Reproduzi o experimento eu mesmo, comparando pseudo-inversa contra o gradiente em lote no mesmo dataset enviesado, e a hipótese não se sustentou:
| Método | Acurácia treino (enviesado) | Acurácia teste (limpo) |
|---|---|---|
| Pseudo-inversa | 0.967 | 0.783 |
| Gradiente em lote | 0.967 | 0.783 |
Os dois caem exatamente igual, com pesos praticamente idênticos entre si. A lição de verdade dessa célula não é sobre qual algoritmo de treino é mais robusto, é sobre qualidade do dado de treino: enviesar a distribuição do treino (mesmo sem adicionar "ruído" no sentido de erro aleatório) desloca a fronteira aprendida pra um lugar que não representa mais a distribuição real, e isso prejudica os dois métodos igualmente, porque os dois estão resolvendo o mesmíssimo problema de otimização por baixo dos panos. Isso ecoa uma lição que já vi na outra playlist: treino com distribuição diferente do mundo real é um problema de dado, não de algoritmo.
Fechando
| O que eu já sabia | O que essa aula assentou |
|---|---|
| Perceptron classifica e atualiza pelo erro binário | Adaline separa pré-ativação (contínua, usada no treino) de pós-ativação (sign, usada só na hora de decidir a classe) |
| Regra delta já apareceu na outra playlist | Adaline é a regra delta aplicada à classificação, treinando como se fosse regressão nos rótulos ±1 |
| Achar uma reta que separa parece suficiente | Otimizar RMSE (não só contar erro) continua achando fronteira melhor mesmo depois da acurácia bater 100% |
Aplicação Prática
Reproduzi pseudo-inversa vs. gradiente em lote no Iris (setosa vs. versicolor), a essa altura já um velho conhecido dessa playlist, mas dessa vez treinando como Adaline, no alvo contínuo ±1, não como perceptron.
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
w_pinv = np.linalg.pinv(include_bias(X_train)) @ y_train
Na primeira tentativa, usei o mesmo learning_rate=0.01 do notebook original pro gradiente em lote, e ele divergiu (peso virou NaN) direto no dado cru do Iris. Nada de surpresa nessa altura: é a mesma lição de escala do post da equação normal, só reencontrada de novo, dessa vez precisando de uma taxa bem menor (0.001) pra não explodir.
| Método | RMSE (treino) | Acurácia (teste) |
|---|---|---|
| Pseudo-inversa (Adaline) | 0.2414 | 1.0 |
Gradiente em lote, learning_rate=0.001 (Adaline) | 0.2414 | 1.0 |
Com a taxa ajustada, mesmo resultado dos dois métodos de novo, RMSE idêntico até a quarta casa decimal, e as duas acurácias batendo 100%, a mesma margem generosa do Iris que já favoreceu o perceptron com bias. A diferença real entre os métodos, nesse dataset fácil, continua sendo só velocidade de convergência (e sensibilidade à escolha de learning_rate), não qualidade da solução final.