DEV Community

Cover image for Reinventando a Roda: Criando uma Rede Neural com csharp
Angelo Belchior
Angelo Belchior

Posted on • Edited on

Reinventando a Roda: Criando uma Rede Neural com csharp

Por que causa, motivo, razão ou circunstância um ser humano em sã consciência escreveria uma Rede Neural do Zero, sem o uso de bibliotecas de terceiros e usando csharp?

Eu gostaria de dar a você meu caro leitor, minha cara leitora, uma resposta mais embasada ou até mesmo poética. Mas o fato é que eu queria apenas me desafiar a construir algo que não faz parte do meu dia a dia e que me fizesse pensar, estudar. Redes Neurais, IA, Machine Learning, Deep Learning, etc... todas essas palavrinhas sempre me soaram como Mágica e hoje eu descobri que tudo isso não passa de Regex e um monte de ifs aninhados...

Sendo assim, me propus o desafio de estudar o tema e tentar implementar algo, mesmo que básico, para compreender verdadeiramente o processo de ponta a ponta.
A grande questão é que esse tema é absurdamente complexo, denso e demanda muito, mas muito estudo. Muito mesmo!

O que eu apresentarei aqui é algo tratado como muito básico. É o "Hello World" dos cientistas de dados. É apenas uma fração desse universo gigantesco chamado Inteligência Artificial. Sinceramente, é o que eu consegui entender e implementar. Apenas isso.
E, olha... suei sangue para entender alguns conceitos... Talvez você que é mais inteligente ache que eu estou fazendo drama, mas não... foram dias e dias de estudo, mas no fim foi um baita aprendizado. Na verdade, ainda está sendo...

Começando pelo começo!

Quando eu conheci o canal Universo Programado eu pirei. Simplesmente pirei. Que conteúdo espetacular, senhoras e senhores. Os vídeos beiram o estado da arte. Aliás, alguns são, de fato, o estado da arte.
Eu achei incrível o fato do Victor Dias escrever os algoritmos na mão e pensei: Se ele consegue, eu também consigo!

Fui tentar e quebrei a cara. O buraco era muito mais embaixo... Precisava estudar muitas coisas antes de escrever a primeira linha de código.

Foi aí que eu decidi me desafiar. Apanhei mais que atacante de time de várzea, mas valeu a pena.

Eu já tinha uma certa noção de como as coisas aparentemente funcionavam pelo fato de ter estudado o ML.Net, porém, por ser uma biblioteca, ela abstrai praticamente tudo. A gente precisa apenas saber como configurar e qual algoritmo utilizar. O resto ela faz sozinha.
Eu queria evitar ao máximo isso. Queria escrever linha a linha e entender passo-a-passo o que estava acontecendo.

Apelei ao youtube e logo de cara descobri um curso SENSACIONAL chamado Rede neural do zero em Python.
Além desse curso também tem esse Rede neural do zero em Python: Modelo básico que é muito bom e que me abriu muito a mente sobre o que é e como funciona uma Rede Neural.

Esses dois cursos são do canal Machine Learning para Humanos. Se inscreva! Vale muito a pena!!

Dentre meus estudos eu precisei focar em algumas frentes. Não dava para estudar todas as arquiteturas de Redes Neurais e, sendo assim, decidi escolher a Feed-Forward. O motivo é que, naquele momento, me parecia ser a mais simples de ser implementada. Não sei se foi a melhor escolha. Mas acho que deu certo... acho...

Sendo assim, vamos avançar. Quero fazer uma breve introdução sobre o que é uma Rede Neural, e explicar um pouco sobre o Feed-Forward.

O que é uma Rede Neural Artificial

Uma Rede Neural Artificial é um sistema de computação inspirado no cérebro humano. Ela é composta por várias camadas de "neurônios artificiais" interconectados que processam informações e aprendem com os dados.

Essa é a definição que qualquer ChatGPT da vida vai te dar.

Porém, dois pontos na afirmação acima me intrigam:

  • Como uma Rede Neural aprende?
  • E o que é essa aprendizagem? (Como eu consigo salvar esse resultado para que depois seja possível utilizá-lo para fazer a predição de um determinado valor?)

Quando a gente começa a ler sobre o tema, se depara com argumentos do tipo:

"Uma Rede Neural aprimora seu aprendizado ajustando os pesos das conexões entre os neurônios para minimizar o erro entre a saída prevista e a saída esperada."...

E ainda...

"O resultado da aprendizagem são os padrões e as relações identificadas entre os dados de entrada e saída."...

É nesse momento que a gente precisa ter calma e se aprofundar um pouco mais.

Como de costume pegue aquele café quentinho. Aliás, recomendo deixar a térmica do seu lado porque a jornada do post vai ser, digamos.... um pouco longa.

Mas não se preocupe meu amigo com os horrores que eu lhe digo, quando a gente se deparar com o código, vamos começar a ter aquela sensação de:

"Nossa... então são essas três linhas simples de código que ajustam o peso das conexões?".

"Caramba, então essa matriz de pesos contém todo aprendizado?"

As coisas tendem a ficar mais claras e simples no decorrer do post...

Certo, mas como, de fato, uma Rede Neural funciona?
Eu vou tentar ser o mais didático possível...

Nós já sabemos que uma Rede Neural é um processo inspirado no Cérebro Humano. Ela é composta por Neurônios Artificiais, que recebem informações, processam e passam adiante o resultado.

A forma como os Neurônios Artificiais estão dispostos em relação uns aos outros, a quantidade de camadas e os cálculos executados determinam a Arquitetura da Rede Neural.
Logo, temos um conjunto amplo de arquiteturas, dentre elas Perceptron de Camada Única (Single-Layer Perceptron), Redes Neurais Multicamadas (MLP - Multi-Layer Perceptron), Redes Convolucionais (CNN - Convolutional Neural Networks), Redes Recorrentes (RNN - Recurrent Neural Networks) e etc.
Cada uma delas tem um propósito, como por exemplo, Reconhecimento de Imagens (Visão Computacional), Interpretação de Textos (Processamento de Linguagem Natural) etc., e como disse acima, eu precisei focar em algumas para poder avançar nos estudos. Mas esse universo é realmente grande.

Uma das arquiteturas mais simples, é a Feed-Forward. (O que é tratado como simples em Inteligência Artificial é simples apenas no contexto de Inteligência Artificial) Nela, as camadas estão dispostas sequencialmente e a informação flui apenas para frente, sem loops.

Não ficou claro? Explico!

Imagine um fluxo composto por vários passos. No primeiro eu recebo alguns dados, nos seguintes eu efetuo vários cálculos e no passo final é onde eu recebo o resultado desse processamento. Materializando a ideia, imagine que eu passo um conjunto de valores que descrevem um tipo de flor no primeiro passo do processo na expectativa de que no último passo eu receba uma informação que defina (ou tente definir) qual é o tipo da flor.

No caso do Feed-Forward, esse processo envolve basicamente 5 passos: Entrada de Dados, Cálculo dos Pesos, Soma Ponderada, Função de Ativação e Saída.

Entrada de Dados

Tudo começa com uma entrada de dados, que pode ser imagem, texto ou números.
Porém, no final, tudo vai se transformar numa grande matriz de double que vai representar essas informações.

Esses dados de entrada vão servir para treinarmos a nossa Rede! É com as características deles que a Rede Neural vai aprender! Isso é o máximo!

Eu vou utilizar como exemplo um conjunto de dados de flores muito famoso, o Iris Dataset. Esses dados são altura e largura em centímetros das pétalas e das sépalas (eu também não sabia que flor tinha esse negócio aí...). E a partir deles conseguimos saber se determinada flor é da espécie Setosa, Versicolor ou Virginica. (Além de Rede Neural você aprende biologia. É muita informação aleatória para um post só...)

Nesse dataset (que nada mais é do que uma tabela ou matriz, como preferir) temos basicamente as seguintes informações:

Alt.Sep | Larg.Sep | Alt.Pet | Larg.Pet | Esp
-------------------------------------------------------
5.1     | 3.5      | 1.4     | 0.2      | Setosa
4.9     | 3.0      | 1.4     | 0.2      | Setosa
7.0     | 3.2      | 4.7     | 1.4      | Versicolor
6.4     | 3.2      | 4.5     | 1.5      | Versicolor
6.3     | 3.3      | 6.0     | 2.5      | Virginica
5.8     | 2.7      | 5.1     | 1.9      | Virginica
Enter fullscreen mode Exit fullscreen mode

O melhor lugar do mundo para se obter datasets é o https://www.kaggle.com. Lá você pode encontrar o Iris Dataset completo.

Só que temos um problema. Nem todas as informações são do tipo numérico double. A espécie é um texto!!!
E é aqui que entra uma sacada genial! Nós podemos transformar esse texto, num conjunto numérico.

Nós temos três espécies diferentes: Setosa, Versicolor e Virginica.

Logo, podemos afirmar categoricamente que, quando a espécie é Setosa ela não pode ser nem Versicolor e nem Virginica.
Em uma forma mais técnica, podemos afirmar que:

Se "Setosa" = 1, então "Versicolor" = 0 e "Virginica" = 0 => [1, 0, 0]

Se "Setosa" = 0, "Versicolor" = 1 e "Virginica" = 0 => [0, 1, 0]

Se "Setosa" = 0, "Versicolor" = 0 então "Virginica" = 1 => [0, 0, 1]

Sacaram a ideia?

Eu tenho um array com três posições que me define quando uma espécie é Setosa, Versicolor ou Virginica.
Isso é genial. Eu pagaria um Dog de Osasco com uma Tubaína para quem teve essa ideia.

Sendo assim, nossa matriz fica:

Alt.Sep | Larg.Sep | Alt.Pet | Larg.Pet | Setosa | Versicolor | Virginica
-------------------------------------------------------------------------
5.1     | 3.5      | 1.4     | 0.2      | 1.0    | 0.0        | 0.0
4.9     | 3.0      | 1.4     | 0.2      | 1.0    | 0.0        | 0.0
7.0     | 3.2      | 4.7     | 1.4      | 0.0    | 1.0        | 0.0
6.4     | 3.2      | 4.5     | 1.5      | 0.0    | 1.0        | 0.0
6.3     | 3.3      | 6.0     | 2.5      | 0.0    | 0.0        | 1.0
5.8     | 2.7      | 5.1     | 1.9      | 0.0    | 0.0        | 1.0
Enter fullscreen mode Exit fullscreen mode

Porém, é importante destacar que, nossos dados de entrada são apenas as colunas Alt.Sep, Larg.Sep, Alt.Pet e Larg.Pet.
As colunas Setosa, Versicolor e Virginica formam o que a gente chama de saída esperada.

Basicamente, entra porco, sai linguíça! Se a entrada for [5.1, 3.5, 1.4, 0.2], a saída esperada deve ser obrigatoriamente [1.0, 0.0, 0.0].

Pronto, temos nossos dados de entrada!

Pesos e Soma Ponderada

Os pesos são valores que indicam o nível de importância de cada entrada para o neurônio. Eles dizem se um valor deve ser levado mais ou menos em conta no cálculo (veremos mais sobre o cálculo a seguir). No início, esses pesos são definidos aleatoriamente e vão sendo ajustados durante o treinamento.

Cada entrada da Rede passa por um neurônio e é multiplicada pelo seu peso correspondente. Depois, todos esses valores são somados, formando a soma ponderada.

Parece complexo, mas não é muito!

Vamos pegar como exemplo a primeira linha da nossa matriz de entrada (Sem os dados da saída esperada).

  • Altura da Sépala (x₁ = 5.1)
  • Largura da Sépala (x₂ = 3.5)
  • Altura da Pétala (x₃ = 1.4)
  • Largura da Pétala (x₄ = 0.2)

Os pesos (w) são números ajustáveis durante o treinamento da Rede. Vamos assumir valores iniciais aleatórios:

  • w1​=0.3
  • w2=0.2
  • w3=0.8
  • w4=0.5
  • Viés b=−0.1 (valor fixo aleatório que ajuda na ativação)

Antes de seguir precisamos adicionar o conceito de Viés!

O Viés (ou Bias) é um parâmetro adicional que ajusta a saída de um neurônio, independentemente dos valores de entrada. Ele é um valor extra adicionado à soma ponderada para garantir que, mesmo que os valores de entrada sejam zero, a saída do neurônio não seja nula, permitindo que o cálculo continue corretamente.

Dito isto, vamos seguir com os cálculos...

Eu acredito que soma ponderada seja algo que você já tenha feito alguma vez no ensino médio, mas não custa nada relembrar a fórmula.

S=(x1​×w1​)+(x2​×w2​)+(x3​×w3​)+(x4​×w4​)+b

Substituindo os valores...

S = (5.1 * 0.3) + (3.5 * 0.2) + (1.4 * 0.8) + (0.2 * 0.5) + (-0.1)
S = 3.35
Enter fullscreen mode Exit fullscreen mode

Por fim, a soma ponderada do neurônio criado a partir do primeiro registro da nossa entrada de dados é de 3.35!
Com isso podemos aplicar uma função de ativação. O resultado do cálculo da ativação pode ser repassado para o próximo neurônio numa outra camada, ou pode ser usado como resposta final (saída esperada) .

Função de Ativação

A função de ativação nada mais é do que um cálculo que pode definir se o próximo neurônio da próxima camada vai ser ou não ativado ou se o seu valor é a saída final calculada.

E o que significa ser ativado ou não? Significa que se um neurônio tem saída próxima de 0 ou exatamente 0, ele não influencia a rede significativamente. Logo o valor propagado não vai ter nenhum impacto no processamento. É como se aquele processo "morresse" no ponto onde a ativação foi próxima de 0 ou exatamente 0.

A função de ativação é literalmente um método que recebe um double e retorna um double.
Esse valor pode ser um valor de saída definitivo, binário (0 ou 1) ou contínuo (entre -1 e 1), dependendo do tipo de função utilizada.

Existem vários cálculos para a ativação, como Sigmóide, Tangente Hiperbólica, ReLU, Softmax e etc. Eu optei pela função Sigmóide pela característica do nosso problema (classificar um conjunto de dados) já que ela é útil quando a saída precisa ser entre 0 e 1, como em problemas de classificação binária (lembra do Se "Setosa" = 1, então "Versicolor" = 0 e "Virginica" = 0? É isso!).

Vai ficar mais simples de se entender quando começarmos a explicar o código csharp.

Uma coisa interessante é que, dependendo da arquitetura da Rede Neural escolhida, a função de ativação pode ser aplicada em todas as camadas ou exclusivamente na camada de saída. No nosso exemplo, essa função será aplicada apenas na camada de saída.

Saídas

Basicamente são os valores calculados. No nosso exemplo, essa saída é um conjunto de valores binários. Lembram novamente do Se "Setosa" = 1, então "Versicolor" = 0 e "Virginica" = 0? Eu sempre pergunto isso pois eu tenho memória de lesma... O que teremos na posição 0, 1 e 2 do array de saída um valor que vai ser ou muito próximo de 0 ou muito próximo de 1.
Exemplo:

Entrada: [5.1, 3.5, 1.4, 0.2 ] 
Saída: [ 0.9985675612054569, 0.002134671378971386, 6.939394848026144E-10 ] 
Enter fullscreen mode Exit fullscreen mode

"Visualizando" a nossa Rede Neural

Após apresentar os 5 passos do processo, acredito que a melhor forma de entendermos como as coisas se conectam é fazer um desenho representando tudo aquilo que dissemos acima.

Desenho desajeitado da nossa Rede Neural

No desenho temos a representação dos 5 passos dispostos em colunas verticais.
A primeira coluna representa os dados de entrada e nela temos as 4 informações sobre a flor.
Em seguida temos uma coluna que representa basicamente os passos dos cálculos dos Pesos e a Soma Ponderada.
Na próxima coluna temos a função de ativação que recebe o resultado dos cálculos da coluna anterior, e que vai decidir se o neurônio da próxima coluna (camada de saída) vai ser ou não ativado.
Por fim temos a última camada e nela temos 3 neurônios com os valores calculados pelo processo de ativação. Esses 3 valores são os que definem que, dada a entrada [5.1, 3.5, 1.4, 0.2], temos a saída - arredondada - [1.0, 0.0, 0.0], que indica que a flor é da espécie Setosa.

E se você está curioso ou curiosa para saber que raios é essa flor Setosa, toma aqui uma foto da danada...

Iris Setosa. Para mais informações, [clique aqui (https://www.picturethisai.com/pt/wiki/Iris_setosa.html).

Biólogos, não me critiquem, eu apenas dei uma googlada e achei essa planta... quer dizer... flor... ahhh sei lá... pra mim é tudo igual...

Um ponto interessante é que, no nosso cenário, não necessitamos de uma ou mais camadas ocultas. Para esse tipo de classificação, as camadas de entrada e de saída bastam.
Problemas mais complexos vão demandar um estudo maior para definir a quantidade de camadas e a quantidade de neurônios. É praticamente outro universo.

Como treinar sua Rede Neural?

O que eu apresentei acima, é uma descrição simples e didática (creio eu) de uma Rede Neural. Inclusive, o desenho serviu mais para dar uma clareada nas ideias, já que para mim, ao estudar o tema, tudo era muito abstrato (e muitas coisas ainda são).
O que me ajudou a fixar alguns conhecimentos foi esse trabalho estupendo do professor Fernando Lucambio Perez do Departamento de Estatística da Universidade Federal do Paraná: http://leg.ufpr.br/~lucambio/Nonparam/NparamV.html.
Eu não vou entrar em detalhes porque o trabalho é razoavelmente longo, e tem muitas informações. Mas a partir da sessão V.2 Redes neurais temos ótimas explicações sobre Redes Neurais e função de ativação Sigmóide com representações visuais e explanação de cálculos.

Esse, para mim, foi o post definitivo. Foi a partir dele que eu comecei a escrever os códigos em csharp.
Que trabalho senhoras e senhores. Que trabalho...

Pois bem! Tendo o desenho acima em mente, quando falamos em treinar a nossa Rede Neural, basicamente estamos falando em pegar os dados de entrada, efetuar os cálculos, passar para a camada de saída e por fim, compará-los com o resultado esperado para calcularmos a taxa de erro.

Lembrando que os cálculos nada mais são do que a soma ponderada e a função de ativação.

E é nesse momento que vamos precisar daqueles conjuntos com três valores, colunas Setosa, Versicolor e Virginica, justamente para validar o quão assertivo está sendo esse processo.

O cálculo de erro é baseado na diferença entre a saída prevista (calculada) e a saída esperada. Nós vamos olhar com calma os detalhes desse cálculo em csharp. Não é complicado, já adianto.
Em seguida temos a multiplicação do erro pela derivada da função de ativação.

Meus amigos e minhas amigas... foi nesse momento que eu simplesmente quis largar tudo e criar um CRUD em Brainfuck com MS Access para relaxar...

Não que calcular uma derivada seja difícil... talvez tenha sido a única coisa que eu aprendi nas aulas de Cálculo I com o professor Cesareo de La Rosa Siqueira.
É que eu jurava que nunca na minha vida eu ia precisar disso. Quebrei a cara!

Certo, mas por qual motivo precisamos usar a derivada da função de ativação (no nosso caso a Sigmóide)?
A derivada da função de ativação é usada para determinar como a saída de um neurônio varia em resposta a mudanças em suas entradas. A partir do momento que estamos efetuando o treinamento, os valores vão variando e sendo repassados de camada para camada (Entrada para Saída). E nesse repasse essa função calcula o "impacto" na atualização dos pesos durante o processo.

Em outras palavras, quando o resultado desse cálculo é grande, pequenas alterações nos pesos resultam em mudanças significativas na saída, acelerando a correção do erro. Quando contrário, indica menor sensibilidade, exigindo ajustes mais sutis. Isso permite que a Rede Neural aprenda de forma mais eficiente. (Deus abençoe Edge Copilot... eu não estava conseguindo expressar em palavras o entendimento que estava na minha mente...).

Nós podemos (e devemos) repetir todo esse processo quantas vezes quisermos! E essa repetição a gente chama carinhosamente de épocas.
A cada época, todo esse processo maroto é executado e os dados são atualizados e repassados de uma camada para outra... e assim vai até terminar...

Eu imagino que essa explicação não tenha ficado tão clara, mas quando a gente analisar o código vamos ter a noção real do que foi explicado acima. Não é um bicho tão feio quanto parece...

Fim da teoria... quero ver código

Essa foi uma breve introdução sobre Redes Neurais. Tentei ser o mais didático possível, mas sinceramente acho que você que está lendo vai conseguir entender melhor quando ver código.
E é agora que a brincadeira começa a ficar séria.

Pega mais um café... eu espero... Se quiser vai no banheiro tirar aquela água do joelho (de onde vem essa expressão??? Se você souber, deixe ai nos comentários) ... pois veremos códigos... \o/
(Eu estava ansioso por esse momento... heheheh)

Porém, preciso fazer alguns disclaimers aqui:

  • O código a seguir não deve servir de base para nada. Ele foi feito com um único intuito: ser didático!
  • Eu foquei em resolver o problema e não em criar uma biblioteca ou que as classes tivessem o melhor design do mundo!
  • Não me importei com performance. Simples assim. Fiz a coisa funcionar, na maioria das vezes debugando e refazendo cálculos manualmente. Isso serviu para que eu entendesse de fato o que estava acontecendo.
  • Palmeiras não tem mundial!

Posto isso, vamos começar!!!

Criação da Rede Neural

O código gira em torno de 4 classes: RedeNeural, Camada, Neuronio e uma de apoio chamada Funcoes onde eu abstraio o uso das funções de Ativação. Além disso temos um record chamado Dataset que serve apenas para transportar dados de entrada e dados de saída.

A classe RedeNeural contém duas camadas, a Camada de Entrada e a Camada de Saída. É essa classe que orquestra todo o fluxo. Eu vou apresentá-la por partes, mas no código fonte você vai conseguir ver o código por completo:

internal class RedeNeural
{
    private readonly Camada _camadaDeEntrada;
    private readonly Camada _camadaSaida;
    private readonly IFuncoes _funcoes;

    public RedeNeural(
        IFuncoes funcoes,
        int tamanhoDosDadosDeEntrada,
        int tamanhoDosDadosDeSaida,
        int quantidadeDeNeuronios = 3)
    {
        _funcoes = funcoes;

        _camadaDeEntrada = new Camada(quantidadeDeNeuronios, tamanhoDosDadosDeEntrada);
        _camadaSaida = new Camada(quantidadeDeNeuronios, tamanhoDosDadosDeSaida);
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

Eu inicializo a classe RedeNeural com três informações:

  • IFuncoes: É uma interface com os métodos Ativar e Derivada. Mais à frente vamos ver com calma eles dois!
  • tamanhoDosDadosDeEntrada: Basicamente é a quantidade de colunas que temos na nossa matriz de dados de entrada. No exemplo apresentado temos 4: Alt.Sep, Larg.Sep, Alt.Pet e Larg.Pet.
  • tamanhoDosDadosDeSaida: Aqui nós informamos o tamanho dos dados de saída. No caso o valor é 3, já que esperamos aque array informando se é Setosa, Versicolor ou Virginica.

Esses valores são necessários para a criação das camadas.
Note que temos uma variável chamada quantidadeDeNeuronios onde eu pego a média entre o tamanhoDosDadosDeEntrada e o tamanhoDosDadosDeSaida.

Aqui temos algo bem curioso! Li em alguns lugares que essa seria uma boa prática para se definir a quantidade de neurônios da camada, porém conversando com o grande mestre Diego Nogare ele me explicou que essa definição é um dos grandes desafios existentes dentro da área da Inteligência Artificial, e que inclusive "existem estudos científicos propondo métodos para isso a partir de segmentações dos dados o que ainda é bem difícil de ajustar"!

O fato que para mim isso se tornou uma incógnita, porém, esse cálculo gera uma quantidade de neurônios suficientes para podermos obter o nosso resultado. Fiquei com essa lição de casa: Estudar mais afundo esse assunto.

Vamos seguir para a classe Camada:

internal class Camada
{
    public Neuronio[] Neuronios { get; }

    public Camada(int quantidadeDeNeuronios, int quantidadeDeEntradas)
    {
        Neuronios = new Neuronio[quantidadeDeNeuronios];
        for (var i = 0; i < quantidadeDeNeuronios; i++)
            Neuronios[i] = new Neuronio(quantidadeDeEntradas);
    }

    public double[][] ObterPesos()
    {
        var pesos = new double[Neuronios.Length][];
        for (var i = 0; i < Neuronios.Length; i++) pesos[i] = Neuronios[i].Pesos;
        return pesos;
    }
}
Enter fullscreen mode Exit fullscreen mode

Essa classe é extremamente simples. Nela temos apenas um array de neurônios cujo tamanho é definido pela variável quantidadeDeNeuronios.

O método ObterPesos vai nos auxiliar mais adiantes no processamento dos dados, porém ele serve apenas para retornar uma matriz com os pesos de cada neurônio. É apenas um método facilitador.

Já na classe Neuronio temos:

internal class Neuronio
{
    public double[] Pesos { get; set; }
    public double Vies { get; set; }

    public Neuronio(int quantidadeDeEntradas)
    {
        Pesos = new double[quantidadeDeEntradas];
        for (var i = 0; i < quantidadeDeEntradas; i++)
            Pesos[i] = Funcoes.ObterValorAleatorio();

        Vies = Funcoes.ObterValorAleatorio();
    }
}
Enter fullscreen mode Exit fullscreen mode

Acima temos um for maroto onde são criados os Pesos e o Viés a partir de um valor aleatório.
Os pesos são armazenados num array de double onde cada posição desse array corresponde a uma posição da entrada dos dados.

Basicamente nós teremos algo como:

[5.1   , 3.5   , 1.4   , 0.2   ] -> Dados de entrada
  |       |       |       |     
  v       v       v       v 
[-0.327, -0.124, -0.097, -0.345] -> Pesos
Enter fullscreen mode Exit fullscreen mode

É importante que você consiga mentalizar essa estrutura, pois ela é predominante!

O método ObterValorAleatorio da classe estática Funcoes é algo simples:

public static double ObterValorAleatorio()  
  => (Random.Shared.NextDouble() - 0.5) * 0.1;
Enter fullscreen mode Exit fullscreen mode

Não vou entrar aqui na discussão de que um computador não consegue gerar de números aleatórios e bla, bla, bla. Mas se quiser se aprofundar recomendo começar com o vídeo do Ciência Todo Dia: # Por Que Computadores NÃO PODEM Gerar Números Aleatórios?.

Nesse ponto, temos a nossa estrutura inicializada. Porém, nada acontece. Precisamos começar a treinar nossa Rede Neural.

A partir de agora, todo código está na classe RedeNeural.

Treinando nossa Rede Neural

Para efetuar o treinamento invocamos o método Treinar e passamos o Dataset com dados de entrada e dados de saída.
Essa classe é um record simples:

public record Dataset(double[][] Entrada, double[][] SaidaEsperada);
Enter fullscreen mode Exit fullscreen mode

Além do dataset, informamos a quantidade de épocas e uma taxa de aprendizagem. Eu não citei essa tal taxa de aprendizagem na explanação acima, mas não se preocupe, quando chegar o código de atualização dos Pesos e do Viéseu explico.
Esse método público não tem nada de especial:

public void Treinar(Dataset dataset, int quantidadeDeEpocas, double taxaDeAprendizagem)
{
    for (var epoca = 0; epoca < quantidadeDeEpocas; epoca++)
    {
        for (var i = 0; i < dataset.Entrada.Length; i++)
            Treinar(dataset.Entrada[i], dataset.SaidaEsperada[i], taxaDeAprendizagem);
    }
}
Enter fullscreen mode Exit fullscreen mode

É agora que a coisa começa a esquentar!!!

O método privado Treinar segue um fluxo bem simples, invocando alguns métodos bem importantes:
Ele começa processando a camada oculta, pega o resultado do processamento e repassa para a camada de saída processar.
Em seguida, calcula a taxa de erro de cada camada e atualiza o Peso e Viés com esses valores além da taxa de aprendizagem.
O método em si é muito simples:

private void Treinar(double[] entrada, double[] saidaEsperada, double taxaDeAprendizagem)  
{  
  var resultadoCamadaDeEntrada = Processar(_camadaDeEntrada, entrada);  
  var resultadoCamadaDeSaida = Processar(_camadaSaida, resultadoCamadaDeEntrada);  

  var deltasCamadaDeSaida = CalcularDeltaErroCamadaSaida(resultadoCamadaDeSaida, saidaEsperada);  
  var deltasCamadaDeEntrada = CalcularDeltaErroCamadaDeEntrada(resultadoCamadaDeEntrada, deltasCamadaDeSaida, _camadaSaida.ObterPesos());  

  AtualizarPesosEVies(_camadaSaida, resultadoCamadaDeEntrada, deltasCamadaDeSaida, taxaDeAprendizagem);  
  AtualizarPesosEVies(_camadaDeEntrada, entrada, deltasCamadaDeEntrada, taxaDeAprendizagem);  
}
Enter fullscreen mode Exit fullscreen mode

Se você ainda tiver aquele desenho na cabeça, basicamente o que esse método faz é conectar as camadas, calcular os Pesos e efetuar as ativações dos neurônios para gerar a saída.
Viu, o bixo não era tão feio quanto parecia...

Esse processo é repetido várias vezes, baseado na quantidade de épocas informada.

Ok, agora precisamos se aprofundar mais nos métodos Processar e AtualizarPesosEVies, além dos métodos para cálculo de erro CalcularDeltaErroCamadaSaida e CalcularDeltaErroCamadaDeEntrada. Vamos seguir a ordem apresentada no código!

O processamento da camada nada mais é do que o cálculo da soma ponderada juntamente com a ativação de cada neurônio. Olha que coisa linda:

private double[] Processar(Camada camada, double[] entrada)  
{  
    var saida = new double[camada.Neuronios.Length];  
    for (var i = 0; i < camada.Neuronios.Length; i++)  
    {  
        var somaPonderada = CalcularSomaPonderada(camada.Neuronios[i], entrada);  
        saida[i] = _funcoes.Ativar(somaPonderada + camada.Neuronios[i].Vies);  
    }  
    return saida;  
}
Enter fullscreen mode Exit fullscreen mode

E o cálculo da Soma Ponderada nada mais é do que...

public double CalcularSomaPonderada(double[] entrada)
{
    var soma = 0.0;
    for (var i = 0; i < entrada.Length; i++)
        soma += entrada[i] * Pesos[i];

    return soma;
}
Enter fullscreen mode Exit fullscreen mode

Aquele cálculo S=(x1​×w1​)+(x2​×w2​)+(x3​×w3​)+(x4​×w4​)+b se resume a um for somando o valor das entradas multiplicados pelo seu peso correspondente e no final, aplicamos a função de ativação que recebe a soma ponderada mais o valor do Viés.

E agora é hora de ver como é a função de ativação Sigmóide em csharp! Tire as crianças da sala!

Na classe Funcoes:

public class Sigmoid : IFuncoes  
{  
  public double Ativar(double valor) 
      => 1.0 / (1.0 + Math.Exp(-valor));  
  ...
}
Enter fullscreen mode Exit fullscreen mode

Em detalhes, temos:

  • Math.Exp(-valor): Calcula o exponencial de -valor.
  • 1.0 + Math.Exp(-valor): Adiciona 1 ao resultado do exponencial.
  • 1.0 / (1.0 + Math.Exp(-valor)): Calcula o inverso do valor obtido, resultando em um valor entre 0 e 1.

Obrigado Github Copilot \o/

A função de ativação Sigmóide é extremamente simples, como podemos notar!

Nesse ponto ja temos todos os neurônios de cada camada processados. Agora vamos calcular a taxa de erros.

Na classe RedeNeural temos:

private double[] CalcularDeltaErroCamadaSaida(double[] saida, double[] saidaEsperada)  
{  
    var deltas = new double[saida.Length];  
    for (var o = 0; o < saida.Length; o++)  
    {  
        var erro = saida[o] - saidaEsperada[o];  
        deltas[o] = erro * _funcoes.Derivada(saida[o]);  
    }  
    return deltas;  
}
Enter fullscreen mode Exit fullscreen mode

É um processo bem simples também:

  • O erro é a diferença entre a saída real (saida[o]) e a saída esperada (saidaEsperada[o]).
  • O delta é o produto do erro e da derivada da função de ativação aplicada à saída real. Isso é feito usando _funcoes.Derivada(saida[o]).

Lembram daquela explicação meio nebulosa sobre o uso da derivada? Então, se resume a isso :)

Vamos olhar o código da função derivada?

Na classe Funcoes:

public double Derivada(double valorDaSigmoide)   
    => valorDaSigmoide * (1 - valorDaSigmoide);
Enter fullscreen mode Exit fullscreen mode

Se você estudou derivada vai notar que é simples, simples, simples. E se você nunca estudou, também vai notar o mesmo...

Eu fiquei horas para achar um erro tosco, no qual eu recalculava o valor da Sigmóide dentro do método, sem perceber que ele já vinha calculado. Faz parte do jogo. Nem tudo é derrota... tem coisas que são apenas humilhações mesmo...

Avançando...

private double[] CalcularDeltaErroCamadaDeEntrada(double[] saida, double[] deltas,  
  double[][] pesos)  
{  
    var delta = new double[saida.Length];  
    for (var i = 0; i < saida.Length; i++)  
    {  
        var erro = 0.0;  
        for (var j = 0; j < deltas.Length; j++)  
            erro += deltas[j] * pesos[i][j]; 
        delta[i] = erro * _funcoes.Derivada(saida[i]);  
    }  
    return delta;  
}
Enter fullscreen mode Exit fullscreen mode
  • O erro é a soma dos produtos dos deltas da próxima camada e os pesos correspondentes da próxima camada.
  • O delta é o produto do erro e da derivada da função de ativação aplicada à saída do neurônio.

Os deltas são então retornados e usados para ajustar os Pesos e Viés durante o processo de treinamento.

Seguindo em frente temos o método AtualizarPesosEVies. Esse apenas percorre todos os Pesos do neurônio ajustando seu valor e em seguida faz o mesmo com o Viés.

public void AtualizarPesosEVies(double[] entrada, double delta, double taxaDeAprendizagem)  
{  
    for (var i = 0; i < Pesos.Length; i++)  
      Pesos[i] -= taxaDeAprendizagem * delta * entrada[i];  

    _vies -= taxaDeAprendizagem * delta;  
}
Enter fullscreen mode Exit fullscreen mode

O método acima implementa a atualização dos pesos e do Viés de um neurônio usando o conceito gradiente descendente.

O gradiente descendente é um algoritmo de otimização usado para ajustar os pesos de uma rede neural. Ele minimiza a função de erro calculando o gradiente (a derivada) da função de perda em relação aos pesos e atualizando-os na direção oposta ao gradiente. Isso reduz gradualmente o erro da rede durante o treinamento, permitindo que ela aprenda padrões nos dados.

Nesse cálculo simples, nós podemos notar que a variável taxaDeAprendizagem tem como objetivo controlar o impacto das atualizações dos pesos e do Viés, influenciando a velocidade com que a rede neural aprende. Esse valor pode ser ajustado a fim de se melhorar o aprendizado da Rede Neural.

Basicamente passamos por todos os cálculos. Viu só? Não teve nada de "anormal", ou algum algoritmo "extraordinário".
Quando você debuga esses valores e entende o impacto deles no aprendizado, tudo fica muito mais fácil.

Testando e obtendo Métricas da qualidade da nossa Rede Neural

Após treinarmos nossa Rede, precisamos entender o quão bem ela aprendeu.
Nós repassamos os dados de entrada e os valores esperados de saída. Com isso foi possível treinar.
Agora nós precisamos repassar outro conjunto de dados de entrada e outro conjunto de valores esperados de saída.

Isso é uma informação importante. Nós precisamos separar nossa massa de dados em duas partes: Dados para treinamentos e dados para Teste. Jamais devemos testar nossa Rede Neural com os mesmos dados utilizados para treinamento.

O motivo?

Suponhamos que você esteja ensinando uma pessoa a resolver um tipo específico de problema. Você a treina com vários exemplos e, depois, decide avaliá-la com os mesmos problemas que ela já viu. Logo, obviamente, a pessoa terá um desempenho praticamente perfeito, pois já conhece as respostas! E com a nossa Rede Neural, acontece o mesmo

Se ela aprendeu de verdade, vai conseguir resolver praticamente qualquer problema (dentro do seu propósito, claro)! E é isso que queremos!!!

Vamos ver como funciona o método de Teste.

Na classe RedeNeural temos:

public MatrizDeConfusao Testar(Dataset dataset)  
{  
    var matrizDeConfusao = new MatrizDeConfusao(_tamanhoDosDadosDeSaida);  
    for (var i = 0; i < dataset.Entrada.Length; i++)  
    {  
        var resultadoCamadaDeEntrada = Processar(_camadaDeEntrada, dataset.Entrada[i]);  
        var resultadoCamadaDeSaida = Processar(_camadaSaida, resultadoCamadaDeEntrada);  

        var indiceEsperado = Array.IndexOf(dataset.SaidaEsperada[i], dataset.SaidaEsperada[i].Max());  
        var indicePredito = Array.IndexOf(resultadoCamadaDeSaida, resultadoCamadaDeSaida.Max());  

        matrizDeConfusao.AdicionarResultado(indiceEsperado, indicePredito);  
    }  
    return matrizDeConfusao;  
}
Enter fullscreen mode Exit fullscreen mode

O que fazemos nesse método é obter o processamento da Camada de Entrada e o Processamento da Camada de Saída e utilizamos uma Matriz de Confusão para avaliar o desempenho. Como resultado dessa avaliação teremos uma tabela que compara as previsões da nossa Rede Neural com os valores reais (esperados), facilitando a identificação de acertos e erros para cada classe (Setosa, Versicolor e Virginica).

Em seguida temos indiceEsperado e indicePredito que se referem à identificação da classe correta e da classe que a rede neural previu, respectivamente. O índice esperado é o índice do valor máximo no vetor de saída esperada, enquanto o índice predito é o índice do valor máximo no vetor de saída gerado pela rede neural (Lembrando que os valores podem ser muito próximos de 0, ou muito próximos de 1) . Esses índices são usados para atualizar a matriz de confusão.

Basicamente podemos representar o valor dessa matriz dessa maneira:

Real / Previsto Previsto: Setosa Previsto: Versicolor Previsto: Virginica
Real: Setosa 6 0 0
Real: Versicolor 0 5 0
Real: Virginica 0 0 5

Nesse caso, os valores da diagonal principal indica previsões corretas (6, 5 e 5) e os valores fora da diagonal mostram erros.
Eu tirei esses dados da execução dos testes da nossa Rede Neural.

Em outras palavras temos:

  • Quantas vezes a nossa Rede Neural previu que era Setosa e foi Setosa? 6!
  • Quantas vezes a nossa Rede Neural previu que era Versicolor e foi Versicolor? 5!
  • Quantas vezes a nossa Rede Neural previu que era Virginica e foi Virginica? 5!

Esses são os valores da diagonal principal.
Já nos outros valores temos:

  • Quantas vezes a nossa Rede Neural previu que era Setosa e foi Versicolor? 0!

  • Quantas vezes a nossa Rede Neural previu que era Setosa e foi Virginica? 0!

  • Quantas vezes a nossa Rede Neural previu que era Versicolor e foi Setosa? 0!

  • Quantas vezes a nossa Rede Neural previu que era Versicolor e foi Virginica? 0!

  • Quantas vezes a nossa Rede Neural previu que era Virginica e foi Versicolor? 0!

  • Quantas vezes a nossa Rede Neural previu que era Virginica e foi Setosa? 0!

Essa é a leitura da nossa matriz de confusão!

Esmiuçando o código da nossa classe MatrizDeConfusao temos:

public class MatrizDeConfusao(int numClasses, params string[] nomesDasClasses)  
{  
    private readonly int[,] _matriz = new int[numClasses, numClasses];

    public void AdicionarResultado(int esperado, int predito)  
      => _matriz[esperado, predito]++;

    private double CalcularAcuracia()
    {
        int corretos = 0, total = 0;
        for (var i = 0; i < _numClasses; i++)
        {
            for (var j = 0; j < _numClasses; j++)
            {
                total += _matriz[i, j];
                if (i == j) corretos += _matriz[i, j];
            }
        }

        return total == 0 ? 0.0 : (double)corretos / total;
    }

    private Dictionary<string, double> CalcularPrecisaoPorClasse()
    {
        var precisao = new Dictionary<string, double>(_numClasses);
        for (var i = 0; i < _numClasses; i++)
        {
            var tp = _matriz[i, i];
            var fp = 0;
            for (var j = 0; j < _numClasses; j++)
            {
                if (i != j) fp += _matriz[j, i];
            }

            var valor = (tp + fp) == 0 ? 0.0 : (double)tp / (tp + fp);
            precisao[_nomesDasClasses[i]] = valor;
        }

        return precisao;
    }

    private Dictionary<string, double> CalcularSensibilidadePorClasse()
    {
        var sensibilidade = new Dictionary<string, double>(_numClasses);
        for (var i = 0; i < _numClasses; i++)
        {
            var tp = _matriz[i, i];
            var fn = 0;
            for (var j = 0; j < _numClasses; j++)
            {
                if (i != j) fn += _matriz[i, j];
            }

            var valor = (tp + fn) == 0 ? 0.0 : (double)tp / (tp + fn);
            sensibilidade[_nomesDasClasses[i]] = valor;
        }

        return sensibilidade;
    }

    private Dictionary<string, double> CalcularF1ScorePorClasse()
    {
        var precisao = CalcularPrecisaoPorClasse();
        var sensibilidade = CalcularSensibilidadePorClasse();
        var f1 = new Dictionary<string, double>(_numClasses);

        for (var i = 0; i < _numClasses; i++)
        {
            var soma = precisao[_nomesDasClasses[i]] + sensibilidade[_nomesDasClasses[i]];
            var valor = soma == 0
                ? 0.0
                : 2 * (precisao[_nomesDasClasses[i]] * sensibilidade[_nomesDasClasses[i]]) / soma;
            f1[_nomesDasClasses[i]] = valor;
        }

        return f1;
    }

    public void ExibirMatriz()
    {
        Console.WriteLine("Matriz de Confusão:");
        for (var i = 0; i < _numClasses; i++)
        {
            for (var j = 0; j < _numClasses; j++)
            {
                Console.Write($"{_matriz[i, j],5}");
            }

            Console.WriteLine();
        }

        Console.WriteLine($"\nAcurácia: {CalcularAcuracia():F2}");

        var precisao = CalcularPrecisaoPorClasse();
        Console.WriteLine("Precisão por classe:");
        foreach (var (classe, valor) in precisao)
            Console.WriteLine($"    {classe}: {valor:F2}");


        var sensibilidade = CalcularSensibilidadePorClasse();
        Console.WriteLine("Sensibilidade por classe:");
        foreach (var (classe, valor) in sensibilidade)
            Console.WriteLine($"    {classe}: {valor:F2}");

        var f1 = CalcularF1ScorePorClasse();
        Console.WriteLine("F1-Score por classe:");
        foreach (var (classe, valor) in f1)
            Console.WriteLine($"    {classe}: {valor:F2}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Com a Matriz de Confusão carregada com os dados da predição e dados reais, podemos obter 4 métricas:

  • Acurácia: Indica a proporção de previsões corretas sobre o total de casos (total de entradas)
  • Precisão: Mostra a proporção de verdadeiros positivos entre todas as previsões positivas feitas pelaa nossa Rede Neural (quantas vezes eu disse que era Versicolor e, de fato, era Versicolor).
  • Sensibilidade (Recall): Mede a capacidade da nossa Rede Neural em identificar corretamente os casos positivos. Quando o recall está baixo, isso quer dizer que a nossa Rede Neural está classificando entradas de testes positivas como negativas (Eu passo informações de uma Versicolor, mas os cálculos sugerem que aqueles dados não representam a classe correta).
  • F1-Score: O F1-Score é a média harmônica entre Precisão e a Sensibilidade, variando de 0 a 1, onde 1 indica o melhor desempenho. Ele é útil quando se busca um equilíbrio entre identificar todos os exemplos positivos e evitar falsos positivos.

Todas essas métricas são fundamentais para que possamos ter uma noção exata de quão assertiva está nossa Rede Neural e, acima de tudo, entender quais pontos estão em desequilíbrio.

Imagine que nossa massa de dados para treinamento tenha muito mais amostras da classe Setosa do que das outras. Com isso, o resultado final pode não ser tão preciso para identificar Versicolor e Virginica, por exemplo. Por isso, é essencial avaliar as métricas de cada classe.


É importante destacar que o método Testar só pode ser invocado após o treinamento, com os dados em memória.
Inclusive, uma coisa que eu poderia ter feito, mas não fiz, é salvar o resultado do treinamento em um arquivo, e carregá-lo no momento de testes ou de predição.

E respondendo aquela pergunta lá do começo do post: - E o que é o resultado dessa aprendizagem? (Como eu consigo salvar esse resultado para que depois seja possível utilizá-lo para fazer a predição de um determinado valor?)

Simples: É toda a estrutura de valores que calculamos, Pesos e Viés de cada Neurônio de cada Camada e o nome da função de ativação, além dos parâmetros tamanhoDosDadosDeEntrada e tamanhoDosDadosDeSaida. Basicamente, tudo que pudesse inicializar a classe RedeNeural sem precisar efetuar todo os cálculos.

Será que funciona?

Vamos colocar para rodar tudo?

Na classe Program temos:

using RedeNeural.FeedForward;
using RedeNeural.FeedForward.Calculos;

double[][] dadosDeTreino =
[
    [5.1, 3.5, 1.4, 0.2],
    [4.9, 3.0, 1.4, 0.2],
    [4.7, 3.2, 1.3, 0.2],
    [4.6, 3.1, 1.5, 0.2],
    [5.0, 3.6, 1.4, 0.2],

    ...
];

double[][] dadosDeSaidasEsperadas =
[
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],

    ...
];

double[][] testData =
[
    [5.1, 3.8, 1.9, 0.4], // Iris-setosa       [1.0, 0.0, 0.0]
    [4.8, 3.0, 1.4, 0.3], // Iris-setosa       [1.0, 0.0, 0.0]
    [5.1, 3.8, 1.6, 0.2], // Iris-setosa       [1.0, 0.0, 0.0]
    [4.6, 3.2, 1.4, 0.2], // Iris-setosa       [1.0, 0.0, 0.0]
    [5.3, 3.7, 1.5, 0.2], // Iris-setosa       [1.0, 0.0, 0.0]
    [5.0, 3.3, 1.4, 0.2], // Iris-setosa       [1.0, 0.0, 0.0]
    [5.7, 3.0, 4.2, 1.2], // Iris-versicolor   [0.0, 1.0, 0.0]
    [5.7, 2.9, 4.2, 1.3], // Iris-versicolor   [0.0, 1.0, 0.0]
    [6.2, 2.9, 4.3, 1.3], // Iris-versicolor   [0.0, 1.0, 0.0]
    [5.1, 2.5, 3.0, 1.1], // Iris-versicolor   [0.0, 1.0, 0.0]
    [5.7, 2.8, 4.1, 1.3], // Iris-versicolor   [0.0, 1.0, 0.0]
    [6.7, 3.0, 5.2, 2.3], // Iris-virginica    [0.0, 0.0, 1.0]
    [6.3, 2.5, 5.0, 1.9], // Iris-virginica    [0.0, 0.0, 1.0]
    [6.5, 3.0, 5.2, 2.0], // Iris-virginica    [0.0, 0.0, 1.0]
    [6.2, 3.4, 5.4, 2.3], // Iris-virginica    [0.0, 0.0, 1.0]
    [5.9, 3.0, 5.1, 1.8], // Iris-virginica    [0.0, 0.0, 1.0]
];

double[][] dadosDeSaidasEsperadasDeTestes =
[
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0],
    [0.0, 1.0, 0.0],
    [0.0, 1.0, 0.0],
    [0.0, 1.0, 0.0],
    [0.0, 1.0, 0.0],
    [0.0, 1.0, 0.0],
    [0.0, 0.0, 1.0],
    [0.0, 0.0, 1.0],
    [0.0, 0.0, 1.0],
    [0.0, 0.0, 1.0],
    [0.0, 0.0, 1.0]
];

string[] classes = ["Setosa", "Versicolor", "Virginica"];  

var tamanhoDosDadosDeEntrada = dadosDeTreino[0].Length;  
var tamanhoDosDadosDeSaida = classes.Length;  
var nn = new RedeNeural.FeedForward.RedeNeural(  
    funcoes: Funcoes.Sigmoid,  
    tamanhoDosDadosDeEntrada: tamanhoDosDadosDeEntrada,  
     tamanhoDosDadosDeSaida: tamanhoDosDadosDeSaida); 

var datasetDeTreino = new Dataset(dadosDeTreino, dadosDeSaidasEsperadas);  
nn.Treinar(datasetDeTreino, 1000, 0.1);  

var datasetDeTeste = new Dataset(testData, dadosDeSaidasEsperadasDeTestes);  
var matrizDeConfusao = nn.Testar(datasetDeTeste);  
Console.WriteLine("\nMatriz de Confusão:");  
for (var i = 0; i < tamanhoDosDadosDeSaida; i++)  
{  
    for (var j = 0; j < tamanhoDosDadosDeSaida; j++)  
        Console.Write(matrizDeConfusao[i, j] + " ");  

    Console.WriteLine();  
}

var resultado = nn.Predizer([5.1, 3.5, 1.4, 0.2]);  
Console.WriteLine($"\nPredição: {string.Join(',', resultado)}");
Enter fullscreen mode Exit fullscreen mode

Os dados de treino e teste utilizados pelas variáveis podem ser obtidos em https://www.kaggle.com/datasets/uciml/iris. No código fonte disponibilizado logo abaixo, temos os dados completos.

Acredito que o código seja bem descritivo, mas basicamente carregamos um datasetde treino e configuramos 1.000 épocas. Esse processo leva poucos segundos, dependendo do seu computador.

E seguida, efetuamos testes para validar o quanto a Rede Neural aprender.

E por fim, efetuamos a predição....

Ué, não vimos esse método...

Na classe RedeNeural temos:

public double[] Predizer(double[] entradas)  
{  
    var saidasOcultas = _camadaOculta.Processar(entradas);  
    var saida = _camadaSaida.Processar(saidasOcultas);  
    return saida;  
}
Enter fullscreen mode Exit fullscreen mode

👀

Se você entendeu o que o processamento das camadas, acredito que não preciso explicar o que é feito acima :)

Vamos ver o resultado dessa execução?

Treinando a rede neural...
...

Época: 1000 | Entrada[111] = 7.7,2.8,6.7,  2 | Saida[111] =   0,  0,  1 
Época: 1000 | Entrada[112] = 6.3,2.7,4.9,1.8 | Saida[112] =   0,  0,  1 
Época: 1000 | Entrada[113] = 6.7,3.3,5.7,2.1 | Saida[113] =   0,  0,  1 
Época: 1000 | Entrada[114] = 7.2,3.2,  6,1.8 | Saida[114] =   0,  0,  1 
Época: 1000 | Entrada[115] = 6.2,2.8,4.8,1.8 | Saida[115] =   0,  0,  1 
Época: 1000 | Entrada[116] = 6.1,  3,4.9,1.8 | Saida[116] =   0,  0,  1 
Época: 1000 | Entrada[117] = 6.4,2.8,5.6,2.1 | Saida[117] =   0,  0,  1 
Época: 1000 | Entrada[118] = 7.2,  3,5.8,1.6 | Saida[118] =   0,  0,  1 
Época: 1000 | Entrada[119] = 7.4,2.8,6.1,1.9 | Saida[119] =   0,  0,  1 

...

Testando a rede neural:

...

Entrada: [4.6,3.2,1.4,0.2] | Esperado: [  1,  0,  0] | Saída: [    0.9838999793395734,  0.016504203805423636, 0.0007969325625330965] 
Entrada: [5.3,3.7,1.5,0.2] | Esperado: [  1,  0,  0] | Saída: [    0.9849103762956705,  0.015523103824749402, 0.0007938171062215532] 
Entrada: [  5,3.3,1.4,0.2] | Esperado: [  1,  0,  0] | Saída: [    0.9844081188881989,   0.01599526311436517, 0.0007933646307086881] 
Entrada: [5.7,  3,4.2,1.2] | Esperado: [  0,  1,  0] | Saída: [  0.020482280154361238,    0.9858580861795864,  0.009577687957636695] 
Entrada: [5.7,2.9,4.2,1.3] | Esperado: [  0,  1,  0] | Saída: [   0.01987709852619787,    0.9855836017043265,  0.010048505871754942] 
Entrada: [6.2,2.9,4.3,1.3] | Esperado: [  0,  1,  0] | Saída: [  0.020092637518271544,    0.9861930905059628,  0.009627392164046341] 
Entrada: [5.1,2.5,  3,1.1] | Esperado: [  0,  1,  0] | Saída: [   0.03859736493992313,    0.9726434568195353,  0.007619628729616546] 
Entrada: [5.7,2.8,4.1,1.3] | Esperado: [  0,  1,  0] | Saída: [  0.020043842942311294,    0.9856808557186106,   0.00988123233268674] 

...

Matriz de Confusão:
    6    0    0
    0    5    0
    0    0    5

Acurácia: 1,00
Precisão por classe:
    Setosa: 1,00
    Versicolor: 1,00
    Virginica: 1,00
Sensibilidade por classe:
    Setosa: 1,00
    Versicolor: 1,00
    Virginica: 1,00
F1-Score por classe:
    Setosa: 1,00
    Versicolor: 1,00
    Virginica: 1,00

Predizer:
Entrada: [5.1,3.5,1.4,0.2] | Saída: [    0.9847084207102101,   0.01572765238679501, 0.0008048791443435225 ]  
Enter fullscreen mode Exit fullscreen mode

É isso!

Chegamos ao fim da nossa jornada épica de implementar uma Rede Neural do Zero em Csharp.
Você vai poder acessar o código fonte da aplicação e ver o toda a implementação. Um ponto importante é que eu coloquei no post apenas trechos de código. Você vai encontrar mais códigos no projeto csharp, porém nada que mereça explicações, apenas classes de apoio e métodos para formatação e apresentação de dados.
Recomento que você efetue testes com outras funções de ativação como ReLU e Tangente Hiperbólica e outros datasets como o Red Wine Quality onde é possível avaliar a qualidade de um vinho baseado em alguns parâmetros. A diversão aqui é boa!

Foram aproximadamente 5 semanas de estudos, quase que diários, para chegar nesse resultado.
O caminho foi longo, mas a satisfação é gigantesca.

E eu não posso deixar de agradecer o grande mestre Diego Nogare por todas as explicações.
Se não fosse ele, muitos conceitos não teriam sido esclarecidos!

Aliás, existe uma mina de ouro no seu github https://github.com/diegonogare/machinelearning: Aprendizado de Máquina através de exemplos práticos! Mais do que recomendado!!!

Além de todas as explicações ainda fui brindado com ótimas indicações de livros sobre o assunto:

Eu comprei o Redes Neurais: Princípios e Prática usado no Mercado Livre por um precinho bom. Fica a dica!

E caso queira baixar o código fonte, acesse https://github.com/angelobelchior/RedeNeural

Muito obrigado pela paciência, e caso tenha alguma dúvida, deixe ai nos comentários!

Fontes de Estudo

https://pt.wikipedia.org/wiki/Rede_neural_artificial
https://sites.icmc.usp.br/andre/research/neural/
https://aws.amazon.com/pt/what-is/neural-network/
https://embarcados.com.br/redes-neurais-artificiais/
https://elisaterumi.substack.com/p/redes-neurais-a-ia-inspirada-no-cerebro
http://leg.ufpr.br/~lucambio/Nonparam/NparamV.html
https://github.com/diegonogare/machinelearning
https://diegonogare.net/
https://en.wikipedia.org/wiki/Feedforward_neural_network

https://www.deeplearningbook.com.br/a-arquitetura-das-redes-neurais/

https://en.wikipedia.org/wiki/Activation_function
https://pt.wikipedia.org/wiki/Fun%C3%A7%C3%A3o_sigmoide
https://pt.wikipedia.org/wiki/Tangente_hiperb%C3%B3lica
https://en.wikipedia.org/wiki/Rectifier_(neural_networks)

https://machinelearningmastery.com/implement-backpropagation-algorithm-scratch-python/
https://www.ibm.com/br-pt/think/topics/gradient-descent
https://diegonogare.net/2020/04/performance-de-machine-learning-matriz-de-confusao/

https://dotnet.microsoft.com/en-us/learn/ml-dotnet

https://www.kaggle.com/datasets/uciml/iris
https://www.kaggle.com/datasets/uciml/red-wine-quality-cortez-et-al-2009

https://www.youtube.com/@UniversoProgramado
https://www.youtube.com/@MLparahumanos
https://www.youtube.com/@escoladeinteligenciaartifi6597

Black Sabbath - Live… Gathered In Their Masses FULL CONCERT HD

Rede neural do zero em Python #01

Rede neural do zero em Python: Modelo básico

O que é Rede Neural Artificial e como funciona | Pesquisador de IA explica | IA Descomplicada

Como uma Rede Neural Aprende? Tutorial para Leigos

Top comments (6)

Collapse
 
mariomeyrelles profile image
Mario Meyrelles

Sensacional cara, uma belíssima explicação para mundanos como nós!

Collapse
 
angelobelchior profile image
Angelo Belchior

Muito obrigado mano :)

Collapse
 
guiireal profile image
Guilherme França

Essa foi a melhor explicação didática sobre como uma rede neural simples funciona. Incrível.

Collapse
 
angelobelchior profile image
Angelo Belchior

Muito obrigado!

Collapse
 
tonhaosemacento profile image
TonhaoSemAcento

Muito bom!

Collapse
 
angelobelchior profile image
Angelo Belchior

Valew mano :)