Duas Retas Escondidas Resolvem o XOR (e Nem Precisam Ser Treinadas)
Fecho essa primeira leva grande da playlist exatamente onde ela tinha ficado presa: o post do XOR terminou com um neurônio sozinho batendo a cabeça na parede, e um gancho pra quando o professor publicasse a aula de MLP. Não existe aula 04b no repositório (ele pulou de 04a direto pra 04c), mas o resto chegou, e paga a promessa inteira: primeiro um MLP com pesos escondidos escolhidos à mão resolve o XOR, depois a mesma arquitetura troca a ativação escondida, e por fim um terceiro jeito de montar os pesos escondidos aparece, sem treinar nada.
A arquitetura: duas perguntas escondidas em vez de uma
class XORClassifer(BaseEstimator, ClassifierMixin):
def __init__(self):
self.ihw = np.array([[0.5, -1, 1], [0.5, 1, -1]]).T
self.how = np.array([0.5, -1, -1])
def fit(self, X, y):
pass
def predict(self, X):
self.Xb = include_bias(X)
self.pH = self.Xb @ self.ihw
self.H = np.sign(self.pH)
self.Hb = include_bias(self.H)
logits = self.Hb @ self.how
y_pred = np.sign(logits)
return y_pred
Repara no fit: só tem um pass lá dentro. Esse modelo não treina, os pesos já vêm prontos no __init__. ihw (input-to-hidden weights) é uma matriz (3,2): cada coluna é um neurônio da camada escondida, com seu próprio bias e seus próprios dois pesos, a mesma pré-ativação de sempre, só que agora tem dois neurônios calculando isso em paralelo, não um só. how (hidden-to-output weights) é o terceiro neurônio, que pega a saída dos dois primeiros como se fossem duas novas variáveis de entrada.
Essa é a primeira vez, nessa playlist inteira, que um output vira input de outra coisa. É isso que separa um MLP (multilayer perceptron, rede de múltiplas camadas) de tudo que eu vi até aqui: uma camada escondida (hidden layer) entre a entrada e a saída, cada neurônio dela enxergando o dado bruto, mas ninguém de fora enxerga o que ela calcula diretamente, só a próxima camada.
O que cada neurônio escondido está perguntando
Os dois neurônios escondidos, escritos como fronteira: 0.5 - x0 + x1 = 0 (x1 = x0 - 0.5) e 0.5 + x0 - x1 = 0 (x1 = x0 + 0.5). Duas retas paralelas (mesma inclinação, 1), uma deslocada meia unidade acima da outra. Calculei a pré-ativação (pH) dos dois neurônios escondidos exatamente nos quatro cantos que o XOR usa:
| Canto | XOR (rótulo) | pH neurônio 1 | pH neurônio 2 | H = sign(pH) |
|---|---|---|---|---|
(0.1, 0.1) | falso (-1) | 0.5 | 0.5 | [1, 1] |
(0.9, 0.1) | verdadeiro (+1) | -0.3 | 1.3 | [-1, 1] |
(0.1, 0.9) | verdadeiro (+1) | 1.3 | -0.3 | [1, -1] |
(0.9, 0.9) | falso (-1) | 0.5 | 0.5 | [1, 1] |
Repara em algo notável: os dois cantos falsos caem exatamente no mesmo ponto escondido, [1, 1]. Cada neurônio escondido é, na prática, uma pergunta de sim/não ("estou acima dessa reta?"), e o XOR vira verdadeiro exatamente quando as duas respostas discordam (uma sim, uma não), e falso quando elas concordam (as duas sim, nesse caso, já que nenhum canto cai abaixo das duas retas). Isso não é coincidência de dataset, é a definição do XOR reaparecendo um nível acima: a camada de saída só precisa separar "as duas respostas concordam" de "as duas discordam", e isso, olha só, é de novo XOR, só que agora entre duas respostas binárias em vez de duas variáveis contínuas. A diferença é que somar duas respostas que discordam sempre dá 0, e somar duas que concordam sempre dá 2 ou -2: isso é linearmente separável (é só separar por soma), o que a variável contínua original não permitia.
Com how = [0.5, -1, -1], a saída é 0.5 - H₀ - H₁, ou seja, 0.5 menos a soma das duas respostas escondidas. Cantos falsos ([1,1], soma 2): 0.5 - 2 = -1.5, negativo. Cantos verdadeiros (soma 0): 0.5 - 0 = 0.5, positivo. Bate exatamente com o rótulo, sem precisar de nenhum treino, porque os pesos já foram escolhidos sabendo disso.
Saída: acurácia 1.0.
A fronteira final: duas retas viram uma faixa
A região azul não é mais um semiplano cortado por uma reta, é uma faixa diagonal entre as duas retas paralelas dos neurônios escondidos. É exatamente essa forma que um único neurônio nunca conseguiu desenhar: uma faixa tem duas fronteiras, uma reta só tem uma. Duas fronteiras lineares, combinadas, produzem uma forma que nenhuma delas sozinha conseguia.
Aula 4d: trocando sign por tanh
class XORClassifer(BaseEstimator, ClassifierMixin):
def __init__(self):
self.ihw = np.array([[0.5, -1, 1], [0.5, 1, -1]]).T
self.how = np.array([0.75, -0.9, -0.9])
def predict(self, X):
self.Xb = include_bias(X)
self.pH = self.Xb @ self.ihw
self.H = np.tanh(self.pH)
self.Hb = include_bias(self.H)
logits = self.Hb @ self.how
y_pred = np.sign(logits)
return y_pred
Mesmo ihw, mesmas duas retas escondidas. A única mudança é H = np.tanh(self.pH) no lugar de H = np.sign(self.pH) (e how reajustado, de [0.5,-1,-1] pra [0.75,-0.9,-0.9], pra compensar a escala diferente). Como já vi no Adaline, a diferença entre pré-ativação e pós-ativação não é só teórica: aqui ela decide se a fronteira final tem quina ou é suave.
Saída: acurácia 1.0.
Compara com o gráfico de cima: mesma faixa diagonal no geral, mas agora com as bordas arredondadas em vez de retas cortadas. tanh satura suavemente perto de ±1 em vez de saltar direto pra lá, então a transição entre "dentro da faixa" e "fora da faixa" também fica suave.
O que a camada escondida realmente faz: transforma o espaço
O notebook plota a mesma nuvem de pontos depois de passar pelos dois neurônios escondidos (H, não mais x0/x1), e é aí que a mágica fica óbvia:
Nesse espaço novo, os dois clusters "falso" (que eram cantos opostos em x0/x1) colapsam praticamente no mesmo lugar, e os dois clusters "verdadeiro" se separam em dois grupos bem distintos, um de cada lado. E olha a fronteira: é uma reta, exatamente a mesma fronteira linear de sempre, só que desenhada em cima de H, não de x. Essa é a ideia central por trás de toda rede com camada escondida: ela não "aprende a curvar uma reta", ela transforma o espaço até o problema virar, de novo, linearmente separável, e aí resolve com a ferramenta mais simples que existe.
Aula 4e: e se os pesos escondidos fossem só sorteados?
class ELM(BaseEstimator, ClassifierMixin):
def __init__(self, n_hidden=2):
self.ihw = np.random.randn(3,n_hidden)
def fit(self, X, y):
self.Xb = include_bias(X)
self.pH = self.Xb @ self.ihw
self.H = np.tanh(self.pH)
self.Hb = include_bias(self.H)
self.how = np.linalg.pinv(self.Hb) @ y
return self
Isso é um ELM (Extreme Learning Machine). A diferença pro que eu vi até aqui: ihw (pesos da entrada pra camada escondida) é puramente aleatório, np.random.randn, e nunca muda, fit não ajusta ele em nenhum momento. Só how (camada de saída) é de fato calculado, e olha como: np.linalg.pinv(self.Hb) @ y, a mesmíssima pseudo-inversa da equação normal, lá do post 3. O ELM não treina a rede inteira, ele sorteia uma transformação de espaço aleatória e resolve só a última camada, de uma vez, sem gradiente nenhum.
Saída do notebook (
n_hidden=4): acurácia 1.0, pesos de saída[-216.55, -14.43, -13.51, -6.63, 232.97].
Reproduzi isso eu mesmo, com uma semente que também bate 1.0, pra poder plotar a fronteira final (repara nos pesos de saída, também bem grandes, 265.96, -158.78, 3.17, 61.15, -167.19, o mesmo comportamento da pseudo-inversa que já vi antes: ela sempre acha uma resposta exata, mesmo que isso signifique pesos enormes):
Nada parecido com a faixa organizada de cima, é uma forma irregular, quatro neurônios escondidos apontando pra direções aleatórias, cada um cortando o plano de um jeito diferente. Mas separa os quatro cantos do XOR do mesmo jeito, sem eu (nem o professor) ter escolhido uma única direção à mão.
Isso levanta a pergunta óbvia: quantos neurônios escondidos aleatórios são necessários pra ter uma chance boa de resolver o XOR? Rodei 15 sementes diferentes pra cada tamanho de camada escondida:
| Neurônios escondidos | Acurácia média | Rodadas com acerto perfeito |
|---|---|---|
| 1 | 0.522 | 0 de 15 |
| 2 | 0.548 | 0 de 15 |
| 3 | 0.853 | 3 de 15 |
| 4 | 0.977 | 10 de 15 |
| 8 | 1.000 | 15 de 15 |
Com 1 ou 2 neurônios, o sorteio quase nunca acerta duas direções que, combinadas, separam o XOR, a acurácia média fica perto de chute (0.5). A partir de 3 ou 4, a chance de pelo menos duas direções aleatórias caírem "bem posicionadas" cresce rápido, e com 8 elas nunca falharam, nas 15 sementes que testei. Quantidade de direções aleatórias compensa a falta de escolha inteligente, ideia central por trás de toda a família de métodos de projeção aleatória.
Fechando
| O que eu já sabia | O que essa aula assentou |
|---|---|
| Um neurônio sozinho não resolve XOR | Duas retas escondidas combinadas resolvem, transformando o espaço até o problema virar linearmente separável de novo |
| Pré-ativação vs. pós-ativação já apareceu no Adaline | A escolha da ativação escondida (sign vs. tanh) muda a forma da fronteira final, de faixa com quina pra faixa suave |
| Equação normal resolve regressão de uma vez | O ELM aplica a mesma conta numa camada escondida aleatória: só a última camada precisa de solução fechada, o resto pode ser sorteado |
Aplicação Prática
Testei o ELM no dataset de duas luas (make_moons, o mesmo do post do XOR, noise=0.25, o mesmo já usado no post passado), variando o número de neurônios escondidos, 10 sementes por tamanho, sempre com divisão treino/teste 70/30:
| Neurônios escondidos | Acurácia teste (média de 10 sementes) |
|---|---|
| Perceptron (sem camada escondida, post do XOR) | 0.867 |
| ELM, 1 neurônio | 0.714 |
| ELM, 5 neurônios | 0.881 |
| ELM, 20 neurônios | 0.927 |
| ELM, 50 neurônios | 0.930 |
| ELM, 100 neurônios | 0.843 |
Com só 1 neurônio aleatório, o ELM fica pior que o perceptron treinado de verdade: uma única direção sorteada, sem ajuste nenhum, raramente é tão boa quanto uma direção otimizada pelo próprio algoritmo. A partir de 5 neurônios o ELM já alcança o perceptron, e com 20 a 50 ultrapassa bastante, várias direções aleatórias combinadas conseguem curvar a fronteira em volta da forma de lua, coisa que nenhuma reta única consegue. Mas com 100 neurônios a acurácia cai: hidden layer grande demais pra só 210 pontos de treino vira espaço fácil demais de encaixar exatamente, a pseudo-inversa acerta o treino "perfeitamente" à custa de generalizar pior, o mesmo tipo de estrada de mão dupla entre complexidade e generalização que já apareceu na outra playlist.
O código que gerou essa tabela é o mesmo ELM de lá de cima, só que agora dentro de um laço variando n_hidden e a semente:
elm = ELM(n_hidden=50)
elm.fit(X_train, y_train)
Pegando a melhor das 10 sementes pra 50 neurônios (acurácia de teste 0.956), plotando a fronteira sobre os 90 pontos de teste, nunca vistos no treino:
50 direções aleatórias combinadas conseguem mesmo curvar em volta das duas luas, nada parecido com a reta única do perceptron nem com a faixa organizada de duas retas do XOR: é uma fronteira irregular, cheia de dobras, mas que segue a forma real dos dados de um jeito que nenhuma reta sozinha alcançaria. Comparando com o mlp-decision-region-chart das duas retas escondidas lá em cima: linha por linha, é o mesmo componente, a mesma matemática (tanh na pré-ativação escondida, sign no logit final), só que com 50 colunas de peso escondido em vez de 2, e essas 50 colunas vieram de np.random.randn, não de contas escolhidas à mão.