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