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
- 2 O problema XOR
- 3 Por que um neurônio não resolve
- 4 A estrutura da rede
- 5 Forward pass — avançando pela rede
- 6 Loss — medindo o erro com BCE
- 7 Backpropagation — o gradiente voltando
- 8 Resultados
- 9 Conclusão
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
Plotando os 4 pontos num plano 2D, onde a cor indica a classe, o problema fica visível:
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):
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)
- 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
}
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,
}
}
}
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)
O fluxo completo:
[A, B] → X @ W1 + b1 → sigmoid → X @ W2 + b2 → sigmoid → saída
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))
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())
}
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)
}
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ó:
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-ŷ) ]
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
}
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-ŷ)
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()
}
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
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]);
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();
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
}
}
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
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;
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:
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:
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:
A curva de loss começa em ~0.69 (máximo da BCE com pesos aleatórios) e cai até ~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 ✓
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 descent —
w = 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
- Código-fonte do projeto
- Neural Network from Scratch — vídeo que inspirou essa série
- Minsky & Papert, Perceptrons (1969)
- Binary Cross-Entropy — Wikipedia
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)