DEV Community

Cover image for XOR — Backpropagation na prática em rust
Alexandre
Alexandre

Posted on

XOR — Backpropagation na prática em rust

No post anterior construímos uma rede MLP completa com forward pass e backpropagation. A rede funcionava. Neste post vamos atacar algo que um único neurônio nunca consegue resolver, o XOR, e entender por que isso exige uma rede com camada oculta.

Conteúdo


1. Prólogo

O problema do canhão que escolhemos nos posts anteriores não era o mais adequado para representar o potencial da nossa implementação. Como ele deixava muitos "vazios" entre os exemplos do dataset, dificilmente conseguiríamos chegar ao resultado exato sem adicionar mais complexidade ao modelo.

Por isso, para colocar nossa implementação de backpropagation em prática e observar um resultado mais concreto e satisfatório, vamos implementar uma solução para o XOR.

O XOR é um problema clássico usado para validar implementações de backpropagation. Ele é simples o suficiente para entendermos cada etapa do treinamento manualmente, mas complexo o bastante para exigir uma camada oculta. Historicamente, esse problema ficou famoso por mostrar as limitações dos perceptrons de camada única, contribuindo para o declínio das pesquisas na área após a publicação do livro de Minsky e Papert, em 1969.


2. O problema XOR

XOR (exclusive OR) retorna 1 quando as entradas são diferentes e 0 quando são iguais:

A | B | A XOR B
0 | 0 |    0
0 | 1 |    1
1 | 0 |    1
1 | 1 |    0
Enter fullscreen mode Exit fullscreen mode

Plotando os 4 pontos num plano 2D, onde a cor indica a classe, o problema fica visível:

4 pontos do XOR no plano 2D mostrando que nenhuma reta separa as classes

Os zeros ficam nas diagonais opostas aos uns. Não existe nenhuma linha reta que separe as duas classes.

Para contrastar, o AND é linearmente separável onde uma única reta isola o único ponto verdadeiro (1,1):

4 pontos do AND no plano 2D com reta separando o ponto (1,1) dos demais

Linearmente separável significa que existe uma reta que divide perfeitamente as classes. XOR não é. AND é.

Matematicamente: não existe nenhum conjunto de pesos e bias capaz de representar o XOR usando apenas uma transformação linear.


3. Por que um neurônio não resolve

Um único neurônio calcula y = sigmoid(Wx + b), é uma transformação linear seguida de uma função de ativação sempre criando uma reta.

Com um único neurônio só conseguimos desenhar retas, e retas não resolvem XOR. Com múltiplos neurônios em camadas, cada um aprende uma transformação diferente do espaço, e essa transformações cria as curvas necessárias para separar o que uma reta nunca conseguiria.

A camada oculta não tenta separar diretamente, ela aprende uma nova forma de representar os pontos, transformando o espaço de entrada em algo onde a separação se torna possível.

É como dobrar um papel antes de cortar, com papel aberto, o corte não resolve. Dobrado, um único corte separa tudo. A camada oculta faz essa dobra e a camada de saída faz o corte.


4. A estrutura da rede

Para resolver XOR precisamos de uma rede com duas camadas:

entrada (2)  →  camada oculta (4)  →  saída (1)
Enter fullscreen mode Exit fullscreen mode
  • 2 entradas — os bits A e B
  • 4 neurônios ocultos — cada um aprende uma "dobra" diferente do espaço. XOR precisa de no mínimo 2, usamos 4 para dar mais folga ao gradiente encontrar uma solução
  • 1 saída — um único valor entre 0 e 1. Para um par como [0, 1], após passar pelas transformações da camada oculta, a rede produz a probabilidade de a saída ser 1. Valores próximos de 0 significam "provavelmente 0", próximos de 1 significam "provavelmente 1"

Em Rust, representamos isso como uma struct com dois conjuntos de pesos e bias, um por camada:

pub struct Network {
    pub w1: Matrix,   // shape: 2×4 — pesos da camada oculta
    pub b1: Vec<f64>, // shape: 4   — bias da camada oculta
    pub w2: Matrix,   // shape: 4×1 — pesos da camada de saída
    pub b2: f64,      //             — bias da camada de saída
}
Enter fullscreen mode Exit fullscreen mode

w1 tem shape 2×4 porque conecta 2 entradas a 4 neurônios. w2 tem shape 4×1 porque combina os 4 neurônios ocultos em 1 saída.

Os pesos são inicializados aleatoriamente e o treino vai ajustar. Os bias começam em zero:

impl Network {
    pub fn new() -> Self {
        let mut rng = rand::thread_rng();
        Network {
            w1: Matrix::new(2, 4, (0..8).map(|_| rng.gen_range(-1.0..1.0)).collect()),
            b1: vec![0.0; 4],
            w2: Matrix::new(4, 1, (0..4).map(|_| rng.gen_range(-1.0..1.0)).collect()),
            b2: 0.0,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Forward pass — avançando pela rede

Com a estrutura definida, podemos alimentar a rede com o dataset. Ao entrar com um par [A, B], ele percorre a rede camada por camada até virar uma previsão.

O mesmo y = Wx + b do primeiro neurônio, agora aplicado duas vezes em sequência com x sendo um vetor e W uma matriz:

y = sigmoid(sigmoid([A, B] @ W1 + b1) @ W2 + b2)
Enter fullscreen mode Exit fullscreen mode

O fluxo completo:

[A, B]  →  X @ W1 + b1  →  sigmoid  →  X @ W2 + b2  →  sigmoid  →  saída
Enter fullscreen mode Exit fullscreen mode

Função de ativação — sigmoid

Sem uma função de ativação entre as camadas, empilhar y = Wx + b duas vezes ainda resulta numa reta. A ativação é o que quebra essa linearidade.

Usamos o sigmoid:

σ(x) = 1 / (1 + e^(-x))
Enter fullscreen mode Exit fullscreen mode

Ela espreme qualquer valor real para o intervalo (0, 1), o que faz sentido para o nosso problema onde a saída final é uma probabilidade.

fn sigmoid(x: f64) -> f64 {
    1.0 / (1.0 + (-x).exp())
}
Enter fullscreen mode Exit fullscreen mode

Outra propriedade que vai ser útil no backprop: a derivada do sigmoid tem forma simples, σ(x) * (1 - σ(x)), facilitando os gradientes.

Implementando o forward

O forward é passar o input pelas duas camadas em sequência. Usamos z para a parte linear (antes do sigmoid) e a para o resultado após a ativação, essa convenção vai aparecer de novo no backprop:

pub fn forward(&self, x: &Matrix) -> Vec<f64> {
    // Fórmula completa: y = sigmoid(sigmoid(x @ W1 + b1) @ W2 + b2)

    // ── Camada oculta ─────────────────────────────────────────────
    let z1 = x.matmul(&self.w1); // z1 = x @ W1  (linear, sem ativação ainda)

    let mut a1_data = vec![0.0; x.rows * 4];
    for i in 0..x.rows {
        for j in 0..4 {
            a1_data[i * 4 + j] = sigmoid(z1.get(i, j) + self.b1[j]); // a1 = sigmoid(z1 + b1)
        }
    }
    let a1 = Matrix::new(x.rows, 4, a1_data); // a1 é o espaço transformado

    // ── Camada de saída ───────────────────────────────────────────
    let z2 = a1.matmul(&self.w2); // z2 = a1 @ W2  (linear)

    (0..x.rows).map(|i| sigmoid(z2.get(i, 0) + self.b2)).collect() // a2 = sigmoid(z2 + b2)
}
Enter fullscreen mode Exit fullscreen mode

a1 é a saída da camada oculta, os 4 neurônios já transformados pelo sigmoid. É ela que entra na camada de saída, não o input original. O diagrama abaixo mostra o forward do par [A=0, B=1] com os valores reais em cada nó:

diagrama da rede com os valores reais em cada nó para o par A=0 B=1 antes do treino

A rede ainda não sabe nada, os pesos são aleatórios e a previsão é ruído. O que vem a seguir é o que transforma esse ruído em conhecimento.


6. Loss — medindo o erro com BCE

O forward produz uma previsão ŷ. Para saber o quanto a rede errou, precisamos de uma função que compare ŷ com o valor esperado y e retorne um número, quanto maior, pior a previsão. Esse número é a loss.

Usamos Binary Cross-Entropy (BCE), que é a loss correta para classificação binária, quando a saída é uma probabilidade entre 0 e 1:

L = -1/n * Σ [ y * log(ŷ) + (1-y) * log(1-ŷ) ]
Enter fullscreen mode Exit fullscreen mode

A intuição: quando y=1 e ŷ é próximo de 0, log(ŷ) vira um número muito negativo e a loss explode. Quando ŷ é próximo de 1, log(ŷ) se aproxima de 0 e a loss some. A função pune previsões confiantes e erradas com muito mais força do que o MSE faria.

pub fn bce(predictions: &[f64], targets: &[f64]) -> f64 {
    let n = predictions.len() as f64;
    predictions.iter().zip(targets.iter()).map(|(&yhat, &y)| {
        let yhat = yhat.clamp(1e-7, 1.0 - 1e-7); // evita log(0)
        -(y * yhat.ln() + (1.0 - y) * (1.0 - yhat).ln())
    }).sum::<f64>() / n
}
Enter fullscreen mode Exit fullscreen mode

O clamp evita log(0) que seria -∞. Na prática a rede nunca prevê exatamente 0 ou 1, mas é uma proteção necessária.

Para o backprop precisamos também da derivada da loss em relação a ŷ:

dL/dŷ = -y/ŷ + (1-y)/(1-ŷ)
Enter fullscreen mode Exit fullscreen mode
pub fn bce_grad(predictions: &[f64], targets: &[f64]) -> Vec<f64> {
    let n = predictions.len() as f64;
    predictions.iter().zip(targets.iter()).map(|(&yhat, &y)| {
        let yhat = yhat.clamp(1e-7, 1.0 - 1e-7);
        (-y / yhat + (1.0 - y) / (1.0 - yhat)) / n
    }).collect()
}
Enter fullscreen mode Exit fullscreen mode

Esse gradiente é o ponto de partida do backprop, o erro medido na saída que vai voltar camada por camada até ajustar cada peso da rede.


7. Backpropagation — o gradiente voltando

Com a loss calculada, precisamos saber quanto cada peso contribuiu para o erro e em qual direção ajustár. Isso é o backprop: aplicar a chain rule de trás pra frente, camada por camada.

O fluxo inverso ao forward:

dL/dŷ  →  dL/dz2  →  dL/dW2, dL/db2
                  →  dL/da1  →  dL/dz1  →  dL/dW1, dL/db1
Enter fullscreen mode Exit fullscreen mode

1. Gradiente da loss → dL/dŷ

Vem direto do bce_grad, um valor por exemplo do dataset.

2. Desfazendo o sigmoid da saída → dL/dz2

O sigmoid foi a última operação antes de ŷ. Para voltar por ele usamos sua derivada, σ'(x) = σ(x) * (1 - σ(x)):

dz2[i] = dl_dyhat[i] * yhat[i] * (1.0 - yhat[i]);
Enter fullscreen mode Exit fullscreen mode

3. Gradientes de W2 e b2

z2 = a1 @ W2 + b2, então:

// dW2 = a1ᵀ @ dz2
for k in 0..4 {
    for i in 0..batch {
        dw2[k] += a1.get(i, k) * dz2[i];
    }
}
let db2: f64 = dz2.iter().sum();
Enter fullscreen mode Exit fullscreen mode

4. Propagando para a camada oculta → dL/dz1

O gradiente chega em a1 via W2, depois passa pelo sigmoid da camada oculta:

for i in 0..batch {
    for k in 0..4 {
        let dl_da1 = dz2[i] * self.w2.get(k, 0); // ← volta por W2
        let a = a1.get(i, k);
        dz1[i][k] = dl_da1 * a * (1.0 - a);      // ← desfaz sigmoid
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Gradientes de W1 e b1

Mesmo padrão da camada anterior:

let dw1 = x.transpose().matmul(&dz1); // dW1 = Xᵀ @ dz1
// db1[k] = Σ dz1 por coluna
Enter fullscreen mode Exit fullscreen mode

6. Update — gradient descent

Com os gradientes calculados, ajustamos cada peso na direção que reduz a loss:

// w = w - lr * dw
for i in 0..self.w1.data.len() { self.w1.data[i] -= lr * dw1.data[i]; }
for k in 0..4 { self.b1[k] -= lr * db1[k]; }
for k in 0..4 { self.w2.data[k] -= lr * dw2[k]; }
self.b2 -= lr * db2;
Enter fullscreen mode Exit fullscreen mode

Repetir isso para cada epoch, forward → loss → backward → update, é o treino completo.

O diagrama abaixo mostra os gradientes voltando pela rede. A espessura de cada aresta representa a magnitude do gradiente naquele peso:

diagrama de backpropagation com arestas de espessura proporcional à magnitude do gradiente


8. Resultados

Rodamos o treino por 20.000 epochs com lr = 0.5. Antes do treino os pesos são aleatórios e as 4 previsões ficam em torno de 0.5. Depois, cada previsão converge exatamente para o valor correto:

comparação lado a lado das previsões antes e depois do treino para os 4 casos do XOR

Dá para ver a rede aprendendo epoch por epoch. No epoch 0 tudo é ruído, no epoch 500 já começa a separar os zeros, no epoch 2000 quase lá, a partir do epoch 5000 os pontos grudam nos targets. A cor indo de roxo para verde segue o progresso:

grade com 6 painéis mostrando a evolução das previsões nos epochs 0, 500, 2000, 5000, 10000 e 19999

A curva de loss começa em ~0.69 (máximo da BCE com pesos aleatórios) e cai até ~0.0007:

curva de loss BCE ao longo de 20000 epochs caindo de 0.69 para 0.0007

Resultado final:

[0 XOR 0] → 0.0008  classe: 0  esperado: 0  ✓
[0 XOR 1] → 0.9993  classe: 1  esperado: 1  ✓
[1 XOR 0] → 0.9993  classe: 1  esperado: 1  ✓
[1 XOR 1] → 0.0007  classe: 0  esperado: 0  ✓
Enter fullscreen mode Exit fullscreen mode

4/4. O backprop está funcionando.


9. Conclusão

Neste post implementamos uma rede neural que resolve o XOR do zero, com forward pass, BCE e backpropagation completo em Rust.

O que foi aprendido:

  • Separabilidade linear — um único neurônio só desenha retas. XOR precisa de curvas, o que exige uma camada oculta
  • Sigmoid como ativação — transforma qualquer valor real em probabilidade (0,1) e tem derivada simples que facilita o backprop
  • BCE — a loss correta para classificação binária, pune previsões confiantes e erradas com muito mais força do que o MSE
  • Backpropagation — a chain rule aplicada de trás pra frente, desfazendo cada operação do forward para calcular o gradiente de cada peso
  • Gradient descentw = w - lr * dw, repetido epoch por epoch até a loss convergir

O que ainda não resolvemos: o dataset XOR tem só 4 pontos e a rede decorou a solução. Para problemas reais, com dados mais distribuidos e distribuições desconhecidas, precisamos de generalização, regularização e validação. Isso fica para frente.


Referências


Se este post fizer sentido pra você, o próximo passo natural é representar conceitos como vetores em vez de números, e ver como a semântica emerge da geometria desse espaço.

Top comments (0)