O que realmente acontece em uma Rede Neural Artificial
- Vinicius Goia
- 22 de nov. de 2024
- 10 min de leitura
Atualizado: 23 de nov. de 2024

Imagem de rawpixel no Freepik
Esta postagem surgiu de uma necessidade. Apesar de estarmos sempre em contato com novas ferramentas e bibliotecas, o uso, a manipulação, tem sido cada vez mais superficial. Entendam que, quando digo "superficial", refiro-me ao total entendimento do que realmente está acontecendo. Obviamente necessitamos de todos os conceitos e estruturação para criar um pipeline. Precisamos saber como o método que estamos instanciando age nos dados, o que ele nos retorna. Contudo, temos a total noção de todos os processos que estão ocorrendo ali?
O meu objetivo aqui é documentar o aprofundamento nos estudos de redes neurais artificiais através do livro "Deep Learning for Vision Systems", de Mohamed Elgendy, e mostrar a todos que não tem como fugir da matemática (é claro, se este caminho for de seu interesse).
Redes Neurais Artificiais
Não irei detalhar aqui como as redes neurais artificiais surgiram ou como se assemelham aos nossos neurônios. Focarei diretamente em sua estrutura e funcionamento.

Estrutura de uma Rede Neural Artificial
Como demonstrado acima, temos os inputs, que são os atributos utilizados para alimentar qualquer algoritmo de machine learning. Para uma tarefa de classificação, por exemplo, onde queremos prever se uma pessoa é passível de inadimplência, podemos utilizar características como salário, profissão, endereço, se possui residência, se possui carro, etc, como entradas em nossa rede neural. As layers (também chamadas de hidden layers) são camadas (linhas) compostas por neurônios. Esses neurônios recebem as informações, processam-nas e as enviam para outras camadas de neurônios. A última camada de neurônios é a nossa saída (output), onde podemos encontrar as previsões.
Vamos nomear as nossas entradas como Xi (i é referente a quantidade de entradas. Se temos duas entradas, então teremos as variáveis X1 e X2). Cada entrada é multiplicada por um peso Wi, gerado aleatoriamente, e somado a um viés B, também gerado aleatoriamente. Portanto, temos a seguinte equação:
z = Xi · Wi + B
A multiplicação dos pesos se faz importante para podermos distinguir quais atributos interferem mais em nossos resultados finais e a adição de um viés nos garante mais aleatoriedade no sistema. Se repararmos, essa equação é basicamente uma função que descreve uma reta. Caso não tivéssemos o viés, a reta sempre se originaria nos pontos (0,0), o que dificultaria uma boa predição.
Em cada neurônio, essas funções, atreladas a cada entrada e seus respectivos pesos, são somadas e multiplicadas por uma função de ativação, obtendo o seguinte formato:
a11 = σ(Σ Xi · Wi + B)
OBS: a11 refere-se ao neurônio 1 da camada 1.
Funções de Ativação
Nesse contexto, a função de ativação é importante para transformar a combinação linear gerada em um modelo não linear, restringindo os valores em um range. Se não utilizássemos as funções de ativação, a rede neural se comportaria apenas como uma função linear, não se adaptando à dados mais complexos. Existem diversos tipos de funções de ativação, mas em nossos estudos iremos utilizar apenas duas: a função Sigmoid e a função Softmax.
A função Sigmoid, também conhecida como função Logística, é recomendada para tarefas de classificação binária, restringindo os valores entre 0 e 1. Isso significa que, para uma tarefa de classificação onde a saída é 0 e 1, esta função é utilizada na camada final. Se a tarefa a ser trabalhada exigisse um valor numérico, como por exemplo, o preço de uma casa, não utilizaríamos funções de ativação na última camada.


Função Sigmoid
Caso nossa tarefa exigisse uma predição maior que duas classes, como será o caso a ser estudado, utilizaríamos a função Softmax, que restringe os valores entre 0 e 1, onde a somatória de todos os valores é 1. Ou seja, para uma tarefa onde teríamos que identificar em uma imagem a presença de um cachorro, um pato ou uma zebra, a saída seria semelhante a (0.2, 0.7, 0.1), classificando-a como pato.


Função Softmax
Desenvolvendo uma Rede Neural
Uma rede neural artificial pode conter diversas entradas, como milhares de neurônios, dependendo do objetivo do projeto. Para nosso estudo e até para se tornar possível uma abordagem mais manual, vamos considerar uma rede neural com quatro entradas, duas camadas de neurônios com quatro elementos cada e três neurônios de saídas, como representado na imagem abaixo:

Estrutura de Rede Neural Artificial proposta
Consideremos:
As nossas entradas como X1, X2, X3 e X4;
Os neurônios da primeira camada como a11, a12, a13 e a14;
Os neurônios da segunda camada como a21, a22, a23 e a24;
Os neurônios da última camada como a31, a32 e a33.
Entre as entradas e a primeira camada de neurônios temos os pesos W1;
Entre a primeira e a segunda camada de neurônios temos os pesos W2;
Entre a segunda e a terceira camada de neurônios temos os pesos W3;
A notação W1(21) indica que o peso localizado entre as entradas e a primeira camada de neurônio está conectado entre o segundo neurônio e a primeira entrada X1;
Na maioria das notações, o elemento viés B não é representado, sendo considerado parte do W.
Tendo isso em mente, vamos construir as equações de feedforward, que nada mais é do que as sequências de multiplicações da entrada até a saída de nossa rede:
a11 = σ · (W1(11) · X1 + W1(12) · X2 + W1(13) · X3 + W1(14) · X4)
a12 = σ · (W1(21) · X1 + W1(22) · X2 + W1(23) · X3 + W1(24) · X4)
a13 = σ · (W1(31) · X1 + W1(32) · X2 + W1(33) · X3 + W1(34) · X4)
a14 = σ · (W1(41) · X1 + W1(42) · X2 + W1(43) · X3 + W1(44) · X4)
a21 = σ · (W2(11) · a11 + W2(12) · a12 + W2(13) · a13 + W2(14) · a14)
a22 = σ · (W2(21) · a11 + W2(22) · a12 + W2(23) · a13 + W2(24) · a14)
a23 = σ · (W2(31) · a11 + W2(32) · a12 + W2(33) · a13 + W2(34) · a14)
a24 = σ · (W2(41) · a11 + W2(42) · a12 + W2(43) · a13 + W2(44) · a14)
a31 = σ · (W3(11) · a21 + W3(12) · a22 + W3(13) · a23 + W3(14) · a24)
a32 = σ · (W3(21) · a21 + W3(22) · a22 + W3(23) · a23 + W3(24) · a24)
a33 = σ · (W3(31) · a21 + W3(32) · a22 + W3(33) · a23 + W3(34) · a24)
Agora, vamos facilitar a representação dessas equações através de notações de matrizes.

Implementação em Python
Vamos analisar como seria implementar em Python esses passos. Utilizaremos o dataset Iris para esse estudo. Não vou detalhar aqui as etapas necessárias para a preparação dos dados, mas o código estará comentado:
# Importação das bibliotecas necessárias
import numpy as np
from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# Carregar o dataset Iris
iris = datasets.load_iris()
X = iris.data # Características (4 características)
y = iris.target # Labels (0, 1 ou 2, para as 3 classes)
# One-hot encoding das labels
y_one_hot = np.zeros((y.size, 3))
y_one_hot[np.arange(y.size), y] = 1
# Normalização dos dados (média 0 e desvio padrão 1)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Divisão em treino e teste (80% treino, 20% teste)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y_one_hot, test_size=0.2, random_state=42)
A partir deste ponto, temos o dataset dividido e pronto para ser alimentado em uma rede neural. Vamos definir a quantidade de neurônios em cada camada:
# Número de neurônios
n_input = X_train.shape[1] # 4 características de entrada
n_neurons_1 = 4 # Camada oculta 1
n_neurons_2 = 4 # Camada oculta 2
n_neurons_3 = y_train.shape[1] # 3 classes na camada de saída
Lembrando que o dataset Iris possui 4 colunas de atributos e 3 possíveis classificações.
Vamos também gerar aleatoriamente as matrizes dos pesos e vieses.
# Inicialização dos pesos e vieses
w1 = np.random.randn(n_neurons_1, n_input) # Pesos camada 1
w2 = np.random.randn(n_neurons_2, n_neurons_1) # Pesos camada 2
w3 = np.random.randn(n_neurons_3, n_neurons_2) # Pesos camada 3 (saída)
b1 = np.random.randn(n_neurons_1, 1) # Vieses camada 1
b2 = np.random.randn(n_neurons_2, 1) # Vieses camada 2
b3 = np.random.randn(n_neurons_3, 1) # Vieses camada 3
Agora precisamos iterar em cada elemento do dataset X_train para obter as entradas e iniciar as multiplicações:
# Funções auxiliares
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def softmax(x):
return np.exp(x) / np.sum(np.exp(x), axis=0)
for n, i in enumerate(X_train): # X_train são os dados de entrada
input = i.reshape(4, 1)
# Feedforward
layer1 = sigmoid(np.dot(w1, input) + b1) # Camada 1
layer2 = sigmoid(np.dot(w2, layer1) + b2) # Camada 2
layer3 = softmax(np.dot(w3, layer2) + b3) # Camada de saída (softmax)
E pronto. Através do cálculo do layer3 conseguimos nossa previsão. Porém, como analisaremos se essa previsão realmente foi correta? Neste ponto, temos que utilizar uma função de custo/erro e como nosso problema é referente a classificação entre 3 alvos, utilizaremos a função Cross-Entropy:

Em Python, temos:
def cross_entropy(y,layer):
return -np.sum(y * np.log(layer))
# Função de custo: entropia cruzada
e = cross_entropy(y_train[n],layer3)
E a mágica acontece...
A partir deste momento é que a mágica das redes neurais acontece. Não é apenas calcular as previsões e a função de custo em movimento feedforward. Temos que minimizar o erro das previsões através do gradiente descendente em movimento de backpropagation para atualização dos valores dos pesos. Parece complicado e realmente é. Mas vamos tentar entender o que realmente acontece.
Para cada entrada do nosso dataset é calculada uma previsão de valor e essa previsão é comparada com o valor real através da função de custo. Para minimizar essa perda, calculamos a derivada (gradiente) dessa função. A objetivo da derivada é direcionar os cálculos para o valor mínimo, já que ela indica a inclinação (slope) da reta tangente naquele ponto:

Gráfico da função de perda
Essa derivada é propagada de modo reverso em toda a rede, atualizando os pesos através de uma taxa de aprendizado (learning rate), realizando o treinamento do modelo. Se a taxa de aprendizado for alta, o treinamento será mais rápido, porém pode não conseguir atingir a mínima global dos erros. Com uma taxa de aprendizado menor, o treinamento será mais lento, mas há mais garantia de atingir a mínimo global. Portanto, temos a seguinte equação:

Onde:

Sendo:




Sendo assim, temos todas as informações necessárias para implementar nossa rede neural "manualmente". Lembrando que todo esse processo será realizado em cada interação em nosso dataset de treinamento. Ao final de toda iteração, contamos uma época (epoch) e realizamos todos os cálculos novamente. O processo será finalizado quando todas as épocas foram calculadas, tendo todos os pesos ajustados para o menor erro. A partir desses novos pesos, é possível testar o algoritmo no dataset de teste e verificar a acurácia. A implementação completa pode ser observada abaixo:
# Importação das bibliotecas necessárias
import numpy as np
from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# Funções auxiliares
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def sigmoid_derivative(x):
return x * (1 - x)
def softmax(x):
return np.exp(x) / np.sum(np.exp(x), axis=0)
# Derivada da Softmax em relação ao logit (z)
def softmax_derivative(y_hat):
n = len(y_hat)
# Criando uma matriz de derivadas da softmax
d_softmax = np.zeros((n, n))
for i in range(n):
for j in range(n):
if i == j:
d_softmax[i][j] = y_hat[i] * (1 - y_hat[i])
else:
d_softmax[i][j] = -y_hat[i] * y_hat[j]
return d_softmax
def cross_entropy(y,layer):
return -np.sum(y * np.log(layer))
def cross_entropy_derivative(y, y_hat):
# A derivada da entropia cruzada é -y / y_hat
return -y / y_hat
# Carregar o dataset Iris
iris = datasets.load_iris()
X = iris.data # Características (4 características)
y = iris.target # Labels (0, 1 ou 2, para as 3 classes)
# One-hot encoding das labels
y_one_hot = np.zeros((y.size, 3))
y_one_hot[np.arange(y.size), y] = 1
# Normalização dos dados (média 0 e desvio padrão 1)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Divisão em treino e teste (80% treino, 20% teste)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y_one_hot, test_size=0.2, random_state=42)
# Número de neurônios
n_input = X_train.shape[1] # 4 características de entrada
n_neurons_1 = 4 # Camada oculta 1
n_neurons_2 = 4 # Camada oculta 2
n_neurons_3 = y_train.shape[1] # 3 classes na camada de saída
# Inicialização dos pesos e vieses
w1 = np.random.randn(n_neurons_1, n_input) # Pesos camada 1
w2 = np.random.randn(n_neurons_2, n_neurons_1) # Pesos camada 2
w3 = np.random.randn(n_neurons_3, n_neurons_2) # Pesos camada 3 (saída)
b1 = np.random.randn(n_neurons_1, 1) # Vieses camada 1
b2 = np.random.randn(n_neurons_2, 1) # Vieses camada 2
b3 = np.random.randn(n_neurons_3, 1) # Vieses camada 3
# Parâmetros de treinamento
epochs = 1000 # Número de épocas
learning_rate = 0.1 # Taxa de aprendizado
# Treinamento da rede
for epoch in range(epochs):
erro = []
for n, i in enumerate(X_train): # X_train são os dados de entrada
input = i.reshape(4, 1)
# Feedforward
layer1 = sigmoid(np.dot(w1, input) + b1) # Camada 1
layer2 = sigmoid(np.dot(w2, layer1) + b2) # Camada 2
layer3 = softmax(np.dot(w3, layer2) + b3) # Camada de saída (softmax)
# Função de custo: entropia cruzada
e = cross_entropy(y_train[n],layer3)
erro.append(e)
# Backpropagation
# Camada de saída (softmax)
s3 = softmax_derivative(layer3) @ cross_entropy_derivative(y_train[n].reshape(3,1),layer3)
bp3 = s3 @ layer2.T
d_b3 = s3
# Camada 2 (sigmoide)
s2 = np.dot(w3.T, s3) * sigmoid_derivative(layer2) # Derivada da sigmoide
bp2 = np.dot(s2, layer1.T)
d_b2 = s2
# Camada 1 (sigmoide)
s1 = np.dot(w2.T, s2) * sigmoid_derivative(layer1) # Derivada da sigmoide
bp1 = np.dot(s1, input.T)
d_b1 = s1
# Atualização dos pesos e vieses
w1 -= learning_rate * bp1
b1 -= learning_rate * d_b1
w2 -= learning_rate * bp2
b2 -= learning_rate * d_b2
w3 -= learning_rate * bp3
b3 -= learning_rate * d_b3
# Cálculo da média do erro para essa época
avg_error = np.mean(erro)
print(f'Época {epoch + 1}, Erro médio: {avg_error}')
# Avaliação no conjunto de teste
correct = 0
for i in range(X_test.shape[0]):
input = X_test[i].reshape(4, 1)
layer1 = sigmoid(np.dot(w1, input) + b1)
layer2 = sigmoid(np.dot(w2, layer1) + b2)
layer3 = softmax(np.dot(w3, layer2) + b3)
# Verifica qual classe foi escolhida (maior probabilidade)
predicted_class = np.argmax(layer3)
true_class = np.argmax(y_test[i])
if predicted_class == true_class:
correct += 1
accuracy = correct / X_test.shape[0]
print(f'Acurácia no conjunto de teste: {accuracy * 100:.2f}%')
Saída do Google Colab
.
.
.
Época 998, Erro médio: 25.41598324542721
Época 999, Erro médio: 25.419856280379644
Época 1000, Erro médio: 25.423723574136353
Acurácia no conjunto de teste: 96.67%
Conclusão
Espero que, depois dessa pequena jornada, a mente dos leitores se torne um pouco mais propensa a tentar entender todo o processo por detrás de qualquer ferramenta que hoje seja taxada como Inteligência Artificial. Afinal, o termo se tornou tão superficial e o uso tão facilitado que as vezes esquecemos dos milhares de cálculos que são realizados. O que foi demonstrado aqui não chega nem perto da complexidade dos problemas a serem resolvidos no mundo real, mas agora podemos ter uma ideia do funcionamento, e quem sabe, sugerir implementações em situações específicas.
Comments