Executando verificação de segurança...
1

Destrinchando Redes Neurais do zero (parte 2)

Clique aqui para ver a parte 1 desse post.

A rede neural que fiz treinava usando uma tupla do tipo (f64, f64, f64) que representava x1, x2 e target. E ela fazia sua previsão usando valores do tipo f64. O objetivo agora é generalizar para que eu possa inicializar ela um arranjo qualquer em suas camadas.

O primeiro passo para isso é passar como argumento no construtor da rede:

struct NeuralNet {
    layers: Vec<(Vec<Vec<f64>>, Vec<f64>)> 
}

impl NeuralNet {
    fn new(sizes: &[usize]) -> Self {
        // sizes: [n_inputs, n_hidden, n_output]
        ...
        NeuralNet { layers }
    }
}

layers é um vetor que representa uma camada da rede. Cada camada (Vec<Vec<f64>>, Vec<f64>) é uma tupla com uma matriz e um vetor. A matriz são os pesos daquela camada, é um vetor de neurônios onde cada neurônio tem um vetor de pesos vindo de outro neurônio da camada anterior. O outro vetor da tupla são os vieses (ou biases), um pra cada neurônio.

Em seguida preciso inicializar os pesos e vieses da rede neural. Para saber dinamicamente quantos pesos devo dar para cada neurônio (neurônios da mesma camada tem o mesmo número de pesos) eu preciso saber quantos inputs ele recebe, ou seja, quantos neurônios a camada anterior tem.

for i in 0..sizes.len() - 1 {
    let n_in = sizes[i];
    let n_out = sizes[i + 1];
    ...
}

Esse for itera sobre duas camadas adjacentes, onde n é o número de neurônios daquela camada.
Agora tenho que criar n_in pesos para os n_out neurônios da camada seguinte, portanto uma matriz.

// Forma expandida
let mut weights: Vec<Vec<f64>> = Vec::new();

for _ in 0..n_out {
    let mut neuron_weights: Vec<f64> = Vec::new();

    for _ in 0..n_in {
        let w = rng.random_range(-1.0..1.0);
        neuron_weights.push(w);
    }

    weights.push(neuron_weights);
}

// Forma resumida
let weights: Vec<Vec<f64>> = (0..n_out)
    .map( |_| (0..n_in).map( |_| rng.random_range(-1.0..1.0)).collect())
    .collect();

Os vieses são a mesma coisa, mas uma pra cada neurônio, então é um vetor.

// Forma expandida
let mut biases: Vec<f64> = Vec::new();

for _ in 0..n_out {
    let b = rng.random_range(-1.0..1.0);
    biases.push(b);
}

// Forma resumida
let biases: Vec<f64> = (0..n_out).map(|_| rng.random_range(-1.0..1.0)).collect();

Agora temos uma rede neural com qualquer arranjo definido na sua inicialização.
forward() agora tem que receber um input de tamanho variado, agora que a camada de input pode ter qualquer número de neurônios.

fn forward(&self, mut input: Vec<f64>) -> Vec<f64> {
    for (weights, biases) in &self.layers {
        let mut next = Vec::new();
        for (w_row, b) in weights.iter().zip(biases) {
            ...
        }
    }
}

Aqui estou iterando sobre cada camada que tem vetores de pesos e vieses, então sobre cada neurônio com seus pesos e viés.

fn forward(&self, mut input: Vec<f64>) -> Vec<f64> {
    for (weights, biases) in &self.layers {
        let mut next = Vec::new();
        for (w_row, b) in weights.iter().zip(biases) {
            let mut sum: f64 = *b;
            for (w, x) in w_row.iter().zip(&input) {
                sum += w * x;
            }
            next.push(sigmoid(sum));
        }
        input = next; // output vira input da próxima camada
    }
    input
}

Aqui estou usando a mesma estrutura para somar o viés do neurônio com o produto do peso com a entrada, aplicando sigmoid e dizendo que esse resultado vai ser mandado para a próxima camada.

Passando para o treino. Mudei o tipo de data nos parâmetros. Data é um vetor que representa várias entradas com resposta esperada. Assim é possível calcular o erro.

fn train_random(&mut self, data: &[(Vec<f64>, Vec<f64>)], steps: usize, step_size: f64){
    ...
    // faz mutação aleatória nos pesos
    for (weights, biases) in self.layers.iter_mut() {
        for neuron in weights.iter_mut() {
            for weight in neuron.iter_mut() {
                *weight += rng.random_range(-step_size..step_size);
            }
        }
        for bias in biases.iter_mut() {
            *bias += rng.random_range(-step_size..step_size);
        }
    }
    ...
}

A função que calcula o loss também teve que mudar:

fn total_loss(&self, data: &[(Vec<f64>, Vec<f64>)]) -> f64 {
    let mut total: f64 = 0.0;
    for (input, target) in data.iter().cloned() {
        let mut sum = 0.0;
        let output = self.forward(input);
        for (y, t) in output.iter().zip(target) {
            sum += (y - t).powi(2);
        }
        sum /= output.len() as f64;
        total += sum;
    }
    total / data.len() as f64 // média de todas as amostras
}

A alteração mais importante é que agora ela calcula a média do erro para cada input em data. E output agora pode ter mais de um neurônio.

Leia também a parte 3

Carregando publicação patrocinada...