Destrinchando Redes Neurais do zero (parte 3)
Comecei a fazer essa rede neural sem saber como orquestrar todo o processo de criar pesos, fazer o feed forward que significa alimentar a rede com inputs e fazer uma inferência.
Agora tenho uma estrutura que além disso ainda faz uma espécie de treino primitivo. A ideia é estressar essa rede neural com novos problemas e encontrar os limites, assim ficará claro o que devo aprender em seguida. Essa etapa também ajuda a entender o porquê de cada técnica e cada componente.
No post passado generalizei a criação e todos os métodos da struct NeuralNet para poder usar qualquer número de neurônios de entrada, ocultos e de saída. Então posso criar uma rede como esta:
let mut nn = NeuralNet::new(&[1, 5, 1]);
O primeiro problema que usei era um problema discreto(0 ou 1), agora vou passar uma função contínua, ou seja que recebe e retorna valores reais, não só inteiros.
Aqui esbarramos no primeiro problema: A função de ativação.
Sigmoid
Não expliquei com esse nível de detalhe, mas a função sigmoid "achata" o resultado obtido pelo neurônio ao fazer a multiplicação pelos pesos e somar o bias em um valor entre 0 e 1.
Por isso é impossivel para o modelo atual devolver valores negativos ou maiores que 1.
Eu teria que trocar a função de ativação para usar o modelo para realizar previsões com funções normais, mas antes disso quero testar com a função seno.
Função Seno
Essa função recebe um valor entre 0 e 2π e devolve um valor entre -1 e 1. Ainda está fora do que o modelo suporta. Porém é possível achatar essa saída para valores entre 0 e 1, basta somar 1 e dividir por 2. Dessa forma só seria necessário fazer o oposto (multiplicar por 2 e subtrair 1) com a resposta da rede.
Aqui estão os dados de treino. São os ângulos notáveis, mais complicados do que nunca haha.
let data: Vec<(Vec<f64>, Vec<f64>)> = vec![
(vec![0.0], vec![0.0]),
(vec![PI / 6.0], vec![((PI / 6.0).sin() + 1.0) / 2.0]),
(vec![PI / 4.0], vec![((PI / 4.0).sin() + 1.0) / 2.0]),
(vec![PI / 3.0], vec![((PI / 3.0).sin() + 1.0) / 2.0]),
(vec![PI / 2.0], vec![((PI / 2.0).sin() + 1.0) / 2.0]),
(vec![2.0 * PI / 3.0], vec![((2.0 * PI / 3.0).sin() + 1.0) / 2.0]),
(vec![3.0 * PI / 4.0], vec![((3.0 * PI / 4.0).sin() + 1.0) / 2.0]),
(vec![5.0 * PI / 6.0], vec![((5.0 * PI / 6.0).sin() + 1.0) / 2.0]),
(vec![PI], vec![(PI.sin() + 1.0) / 2.0]),
(vec![3.0 * PI / 2.0], vec![((3.0 * PI / 2.0).sin() + 1.0) / 2.0]),
(vec![2.0 * PI], vec![((2.0 * PI).sin() + 1.0) / 2.0]),
];
Também seria possível preencher dinamicamente da seguinte forma:
let mut data: Vec<(Vec<f64>, Vec<f64>)> = vec![];
let mut input: f64 = 0.0;
while input < (2.0 * PI) {
let mut expected_output = input.sin();
expected_output += 1.0;
expected_output /= 2.0;
let vet1: Vec<f64> = vec![input];
let vet2: Vec<f64> = vec![expected_output];
data.push((vet1, vet2));
input += PI / 6.0;
}
nn.train_random(&data, 1_000_000, 0.1);
//Step 990000 | Loss = 0.003124258929832566
Mesmo um treino de 1 milhão de passos não zerou o erro. Aumentar ou diminuir o tamanho do passo não adiantou de nada.
Agora veja o que acontece quando aumento o número de neurônios na camada oculta de 5 para 10. Isso dobra o número de conexões e o número de multiplicações a serem feitas e o erro fica dez vezes menor.
let mut nn = NeuralNet::new(&[1, 10, 1]);
//Step 990000 | Loss = 0.00030728859660097567
Após isso tentei várias outras configurações, como duas camadas ocultas [1, 5, 5, 1], três camadas [1, 10, 20, 10, 1], mas nenhum sinal de melhora.
Tentei popular o vetor com mais dados para treinar usando a segunda forma que mostrei.
input += PI / 12.0;
//Step 990000 | Loss = 0.00046474690715605295
Qual o gargalo?
Aqui existem dois gargalos que impedem a melhoria do modelo (não importa o quanto eu aumente o treino a ordem de grandeza do erro não diminui). São eles: sigmoid e tipo de treino.
A sigmoid tende a 1 quando a entrada tende ao infito, ou seja, a diferença de sigmoid(5) pra sigmoid(6) é muito maior que a diferença de sigmoid(10) para sigmoid(100).
Então para valores distantes de 0 meio que perde precisão.
Já a estratégia de treino, não pesquisei muito sobre, mas meu amigo gepeto chamou de "hill climbing aleatório". Essa estratégia já foi bem discutida no canal Universo Programado, lá ele fala bastante sobre ótimos globais e ótimos locais. A analogia aqui é que o modelo dá passos aleatórios, dando meia volta quando percebe que desceu a montanha (ficou pior, erro aumentou).
Em posts futuros vou ter que encarar o temido gradiente descendente de quem tanto fugi, mas por enquanto acho que dá pra melhorar esse erro mudando a função de ativação.