Destrinchando Redes Neurais do zero
A essa altura ninguém aguenta mais ouvir falar de IA. Pelo menos é como me sinto, mas pensando mais sobre isso percebi que eu nunca pus a mão em nenhum código nem implementei qualquer lógica relacionada a isso. consumir API não conta
A ideia é a seguinte: Eu já ouvi milhões de vezes como uma IA funciona, pesos, vieses, camadas ocultas, etc... Mas será que na prática a teoria é realmente a mesma?
Pra começar eu não tinha nem ideia de como organizar isso, então só fiz de forma linear.
fn main() {
let x1: f64 = 0.0;
let x2: f64 = 0.0;
let target: f64 = 1.0;
let w1: f64 = 1.0;
let w2: f64 = 1.0;
let w3: f64 = 1.0;
let w4: f64 = 1.0;
let b1: f64 = 0.0;
let b2: f64 = 0.0;
let h1: f64 = sigmoid(x1 * w1 + x2 * w2 + b1);
let h2: f64 = sigmoid(x1 * w3 + x2 * w4 + b2);
let w5: f64 = 1.0;
let w6: f64 = 1.0;
let b3: f64 = 0.0;
let y: f64 = sigmoid(h1 * w5 + h2 * w6 + b3);
let loss: f64 = 0.5 * (y - target).powi(2);
println!("x1 = {} \nx2 = {} \ny = {} \nloss = {}", x2, x2, y, loss);
}
fn sigmoid(x: f64) -> f64 {
1.0 / (1.0 + (-x).exp())
}
Do jeito que está agora não passa de uma fórmula linear um pouco mais complicada.
Ah, esqueci de dizer, como queria uma rede neural simples pra começar, estou tentando simular um operador chamado XOR que verifica se as entradas são diferentes
| x1 | x2 | y(esperado) |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
Pra podermos ajustar os pesos precisamos calcular o loss/erro. Como não sei nada sobre isso escolhi o mais simples. Veja outros tipos de loss functions aqui.
A função sigmoid serve para remover a linearidade, mas o que isso quer dizer? Sem funções de ativação como sigmoid, ReLU ou qualquer outra, o que acontece é uma sucessão de multiplicações e adições.
Digamos que eu tenho um x. Eu aplico uma função y = a*x + b. y aqui representa o que sai do neurônio na camada oculta para a próxima.
O próximo neurônio recebe y e propaga para frente um z, que é igual a c * y + d.
Ou seja, z = c * (a * x + b) + d.
Ou seja, a saída z é linearmente proporcional a entrada.
Embora isso possa ser útil para outras coisas, não o é em redes neurais.
Aplicar uma função de ativação vai me permitir, nesse caso específico, aumentar o input de (0, 1) para (1, 1) e fazer o output diminuir de 1 para 0 (não linear, não proporcional).
Chegando aqui eu já tinha uma noção melhor de como organizar isso tudo. Usando um vetor para os dados de entrada, um objeto para guardar os pesos e implementar as funções, que por enquanto será apenas forward que roda a rede neural.
struct NeuralNet {
// input -> hidden
w1: f64, w2: f64,
w3: f64, w4: f64,
b1: f64, b2: f64,
// hidden -> output
w5: f64, w6: f64,
b3: f64,
}
impl NeuralNet {
fn new() -> Self {
NeuralNet {
w1: 1.0, w2: 1.0,
w3: 1.0, w4: 1.0,
b1: 0.0, b2: 0.0,
w5: 1.0, w6: 1.0,
b3: 0.0,
}
}
fn forward(&self, x1: f64, x2: f64) -> f64 {
let h1 = sigmoid(x1 * self.w1 + x2 * self.w2 + self.b1);
let h2 = sigmoid(x1 * self.w3 + x2 * self.w4 + self.b2);
let y = sigmoid(h1 * self.w5 + h2 * self.w6 + self.b3);
y
}
}
fn sigmoid(x: f64) -> f64 {
1.0 / (1.0 + (-x).exp())
}
fn main() {
let nn = NeuralNet::new();
let data = vec![
(0.0, 0.0, 0.0),
(0.0, 1.0, 1.0),
(1.0, 0.0, 1.0),
(1.0, 1.0, 0.0),
];
for (x1, x2, target) in data {
let y = nn.forward(x1, x2);
let loss = 0.5 * (y - target).powi(2);
println!("x1={:.1}, x2={:.1}, target={}, y={:.4}, loss={:.4}",
x1, x2, target, y, loss);
}
}
Isso não apresenta nenhum conceito novo, mas deixa bem mais fácil seguir em frente.
Aqui em empaquei em como eu devia atualizar os pesos. Eu queria dar pequenos passos aleatórios, verificar a variação do erro e aplicar ou não os novos pesos a rede que estou treinando.
Pedi ajuda a uma rede neural bem conhecida rsrs. Depois de ser chamado de burro por não querer usar gradiente, consegui o seguinte código:
fn train_random(&mut self, data: &[(f64, f64, f64)], steps: usize, step_size: f64) {
let mut rng = rand::thread_rng();
for step in 0..steps {
// loss atual
let old_loss = self.total_loss(data);
// salva pesos antigos
let old = self.clone();
// faz mutação aleatória nos pesos
self.w1 += rng.gen_range(-step_size..step_size);
self.w2 += rng.gen_range(-step_size..step_size);
self.w3 += rng.gen_range(-step_size..step_size);
self.w4 += rng.gen_range(-step_size..step_size);
self.w5 += rng.gen_range(-step_size..step_size);
self.w6 += rng.gen_range(-step_size..step_size);
self.b1 += rng.gen_range(-step_size..step_size);
self.b2 += rng.gen_range(-step_size..step_size);
self.b3 += rng.gen_range(-step_size..step_size);
// calcula loss novo
let new_loss = self.total_loss(data);
// aceita ou reverte
if new_loss > old_loss {
*self = old; // piorou, volta pros pesos antigos
}
if step % 1000 == 0 {
println!("Step {step} | Loss = {old_loss}");
}
}
}
fn main() {
let mut nn = NeuralNet::new();
let data = vec![
(0.0, 0.0, 0.0),
(0.0, 1.0, 1.0),
(1.0, 0.0, 1.0),
(1.0, 1.0, 0.0)
];
nn.train_random(&data, 100_000, 0.1);
for (x1, x2, target) in &data {
let y = nn.forward(*x1, *x2);
println!("x1={x1}, x2={x2} => y={:.3}, target={}", y, target);
}
}
Ele dá um passo em uma direção aleatória do tamanho que eu defino e pronto. Depois de 74 mil passos o loss zerou. Foi legal ver ele ficar cada vez menor até o limite das casas decimais do f64.
Step 74000 | Loss = 0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044
Step 75000 | Loss = 0
Step 76000 | Loss = 0
Step 77000 | Loss = 0
Step 78000 | Loss = 0
Step 79000 | Loss = 0
Step 80000 | Loss = 0
Step 81000 | Loss = 0
Step 82000 | Loss = 0
Step 83000 | Loss = 0
Step 84000 | Loss = 0
Step 85000 | Loss = 0
Step 86000 | Loss = 0
Step 87000 | Loss = 0
Step 88000 | Loss = 0
Step 89000 | Loss = 0
Step 90000 | Loss = 0
Step 91000 | Loss = 0
Step 92000 | Loss = 0
Step 93000 | Loss = 0
Step 94000 | Loss = 0
Step 95000 | Loss = 0
Step 96000 | Loss = 0
Step 97000 | Loss = 0
Step 98000 | Loss = 0
Step 99000 | Loss = 0
x1=0, x2=0 => y=0.000, target=0
x1=0, x2=1 => y=1.000, target=1
x1=1, x2=0 => y=1.000, target=1
x1=1, x2=1 => y=0.000, target=0
Pensando rapidamente acho que os próximos passos são:
- Generalizar uma estrutura que aceite N camadas
- Tentar problemas mais complexos
- Explorar diferentes loss functions
- Explorar outras funções de ativação
- O famoso backpropagation
Observação: Pode me chamar de modinha ou o que for, mas redes neurais são genuinamente um dos assuntos mais interessantes nessa área, independente do hype ou do mau uso.
Leia também a parte 2.