← Voltar pra playlist

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:

CantoXOR (rótulo)pH neurônio 1pH neurônio 2H = sign(pH)
(0.1, 0.1)falso (-1)0.50.5[1, 1]
(0.9, 0.1)verdadeiro (+1)-0.31.3[-1, 1]
(0.1, 0.9)verdadeiro (+1)1.3-0.3[1, -1]
(0.9, 0.9)falso (-1)0.50.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 escondidosAcurácia médiaRodadas com acerto perfeito
10.5220 de 15
20.5480 de 15
30.8533 de 15
40.97710 de 15
81.00015 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á sabiaO que essa aula assentou
Um neurônio sozinho não resolve XORDuas 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 AdalineA 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 vezO 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 escondidosAcurácia teste (média de 10 sementes)
Perceptron (sem camada escondida, post do XOR)0.867
ELM, 1 neurônio0.714
ELM, 5 neurônios0.881
ELM, 20 neurônios0.927
ELM, 50 neurônios0.930
ELM, 100 neurônios0.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.