No post anterior, vimos como o MLP organiza neurônios em camadas e como os dados fluem pelo feedforward. Ao final, a rede gerava previsões com pesos fixos e medíamos o erro — mas a rede não fazia nada com ele.
Agora vamos resolver isso: como a rede usa o erro para aprender?
Otimização
O treinamento de uma rede neural é, basicamente, um problema de otimização: o objetivo é encontrar o conjunto de pesos que resulte no menor erro possível entre a resposta da rede e o resultado esperado. Sem um algoritmo de otimização, os pesos permaneceriam estáticos e a rede seria incapaz de aprender.
Existem diversas estratégias para realizar essa busca pelos pesos ideais:
- Força Bruta: consiste em testar todas as combinações de pesos possíveis. É computacionalmente inviável, já que mesmo uma rede pequena teria trilhões de combinações.
- Recozimento Simulado (Simulated Annealing: método probabilístico que busca o mínimo global aceitando soluções piores temporariamente para evitar ficar preso em mínimos locais.
- Algoritmos Genéticos: utilizam conceitos de evolução biológica, como mutação e cruzamento. Diferentes populações de pesos são testadas, e os conjuntos que apresentam menor erro são selecionados para gerar a próxima geração.
- Enxame de Partículas (PSO): otimização baseada no comportamento social de grupos de animais. Várias “partículas” (conjuntos de pesos) exploram o espaço de erro, compartilhando entre si as melhores posições encontradas para convergir ao objetivo.
Neste post, optamos pela Descida do Gradiente. Essa escolha se deve à sua direcionabilidade e escalabilidade. Usando cálculo diferencial para estimar a direção de redução do erro, em vez de depender apenas de variações aleatórias, o processo torna-se muito mais rápido e viável para redes grandes.
Descida do Gradiente (Gradient Descent)
Após a medição do erro, utilizamos o algoritmo de Descida do Gradiente para realizar os ajustes nos pesos. O objetivo é encontrar a direção na qual devemos alterar cada peso para que o erro global da rede seja minimizado.
A Derivada da Função Sigmoide
Para ajustar os pesos, precisamos saber como a saída de um neurônio responde a pequenas variações em sua entrada. Como utilizamos a função Sigmoide, sua derivada pode ser calculada de forma eficiente utilizando o próprio resultado da ativação $y$: $$ d = y \cdot(1-y) $$ Esta fórmula nos indica a sensibilidade do neurônio:
- Se $y$ está próximo de 0,5, a derivada atinge seu valor máximo ($0,25$). O neurônio está em uma zona de alta sensibilidade, onde pequenas mudanças nos pesos têm grande impacto na saída.
- Se $y$ está próximo de 0 ou 1, a derivada aproxima-se de zero. O neurônio está em uma zona de saturação, onde ajustes nos pesos terão pouco efeito na saída.
Delta
O Delta representa o gradiente local de um neurônio. Ele combina o erro atribuído ao neurônio com a sua sensibilidade (derivada). No entanto, o cálculo do Delta varia dependendo da posição do neurônio na rede:
- Camada de Saída: como temos o valor esperado (target), o cálculo é direto: $\delta_{output} = (target-prediction) \cdot \text{derivative}$
- Camada Oculta: como não temos um valor esperado para neurônios intermediários, o erro é calculado com base no impacto que o neurônio oculto teve na camada seguinte. O erro é “transportado” de volta através dos pesos: $\delta_{hidden} = \text{derivative} \cdot(\delta_{output}\cdot weight)$
Essa diferenciação é a base do algoritmo de backpropagation, permitindo que o erro da saída seja distribuído proporcionalmente entre todos os neurônios da rede.
Atualização dos Pesos
É neste momento que a rede “age” sobre o erro calculado, modificando sua estrutura interna para melhorar o desempenho da próxima iteração. Para controlar esse processo, utilizamos um parâmetro essencial: o learning rate.
A fórmula de atualização aplicada nesse projeto é: $$ weight_{n+1} = weight_{n} + (input \cdot \delta \cdot \eta) $$ Onde:
- $\eta$ (Learning Rate): A Taxa de Aprendizagem controla a magnitude do ajuste. Valores muito altos podem causar instabilidade, enquanto valores muito baixos tornam a convergência lenta.
- $input$: sinal vindo da camada anterior.
- $\delta$: gradiente local calculado para o neurônio de destino.
Existe também uma técnica chamada momentum, mas ela não será usada nos códigos abaixo. No momentum clássico, guardamos uma “velocidade” para cada peso, baseada nos ajustes anteriores. Isso é diferente de simplesmente multiplicar o peso atual por uma constante.
Batch vs. Stochastic
Uma decisão importante no treinamento de redes neurais é definir a frequência com que os pesos serão atualizados em relação ao volume de dados. Existem três abordagens principais:
- Batch Gradient Descent: o algoritmo calcula o erro para todo o conjunto de dados antes de realizar uma única atualização nos pesos. É o método mais estável, mas pode ser lento e exige muita memória em datasets grandes. É a técnica utilizada nesse post.
- Stochastic Gradient Descent (SGD): os pesos são atualizados após processar cada amostra individualmente. É muito rápido e as atualizações frequentes podem ajudar a rede a escapar dos mínimos locais, mas a convergência é mais instável.
- Mini-batch Gradient Descent: os dados são divididos em pequenos grupos, e os pesos são atualizados após cada grupo. É o equilíbrio ideal entre estabilidade e velocidade, sendo o padrão utilizado na maioria das redes neurais modernas.
Backpropagation
O backpropagation (retropropagação) é o processo que coloca a Descida do Gradiente em prática: ele é responsável por propagar o erro da saída de volta até as camadas iniciais, calculando os gradientes necessários para que cada peso seja ajustado. Enquanto o feedforward é a etapa de execução, o backpropagation é a etapa de correção.
A cada iteração (época), a rede realiza esse ciclo:
- Avança (feedforward) para gerar uma previsão.
- Calcula o erro comparando com o alvo.
- Retropropaga (backpropagation) para encontrar os gradientes.
- Ajusta os pesos para tentar diminuir o erro na próxima vez.
Épocas
Nos próximos exemplos práticos, você notará o uso de uma variável chamada epochs. No contexto de machine learning, uma época representa uma passagem completa de todo o conjunto de dados pela rede neural (feedforward e backpropagation).
Como vimos na Descida do Gradiente, os ajustes nos pesos são feitos em pequenos passos, controlados pelo learning rate. Um único ciclo não é suficiente para que a rede “enxergue” o padrão completo, por isso precisamos de milhares ou até mesmo milhões de épocas.
É através da repetição que os pesos convergem lentamente para os valores ideais, minimizando o erro até que a rede seja capaz de resolver o problema proposto.
Transposição de Matrizes
Ao analisar o código abaixo, podemos notar o uso frequente da operação de transposição (.T ou np.transpose). Na álgebra linear, para realizar o produto escalar entre duas matrizes, o número de colunas da primeira matriz deve ser exatamente igual ao número de linhas da segunda.
Para isso, a transposição é utilizada para inverter linhas e colunas, alinhando suas dimensões e permitindo que as operações de retropropagação e cálculo de gradientes sejam possíveis. Sem isso, a rede seria incapaz de mapear o erro da saída de volta para as conexões corretas nas camadas anteriores.
Prática: Treinando o XOR
O script abaixo realiza o treinamento completo para resolver o problema do XOR, executando muitas épocas até que a rede minimize o erro e aprenda a lógica correta.
import numpy as np
np.random.seed(42)
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def sigmoid_derivative(x):
# Derivada da sigmoide expressa em termos do seu resultado
return x * (1 - x)
# 1. Configuração do Dataset (XOR)
inputs = np.array([
[0, 0],
[0, 1],
[1, 0],
[1, 1]
])
expected_outputs = np.array([[0], [1], [1], [0]])
# 2. Inicialização dos Pesos
# Entrada (2) -> Camada Oculta (3)
weights_input_hidden = 2 * np.random.random((2,3)) - 1
# Camada Oculta (3) -> Saída (1)
weights_hidden_output = 2 * np.random.random((3, 1)) - 1
# 3. Parâmetros de Treinamento
epochs = 1_000_000
learning_rate = 0.6
if __name__ == '__main__':
for epoch in range(epochs):
# --- FORWARD PROPAGATION ---
hidden_layer_input = np.dot(inputs, weights_input_hidden)
hidden_layer_output = sigmoid(hidden_layer_input)
output_layer_input = np.dot(hidden_layer_output, weights_hidden_output)
predictions = sigmoid(output_layer_input)
# --- CÁLCULO DO ERRO ---
error = expected_outputs - predictions
mean_absolute_error = np.mean(np.abs(error))
# Monitoramento do progresso a cada 10.000 épocas
if epoch % 10000 == 0:
print(f"Época {epoch} | MAE: {mean_absolute_error:.6f}")
# --- BACKPROPAGATION ---
# 1. Calculando o Delta da Saída
output_derivative = sigmoid_derivative(predictions)
delta_output = error * output_derivative
# 2. Propagando o erro para a Camada Oculta
# Transpomos os pesos para alinhar as dimensões e transportar o delta de volta
weights_hidden_output_transposed = weights_hidden_output.T
delta_hidden_layer = np.dot(delta_output, weights_hidden_output_transposed) * sigmoid_derivative(hidden_layer_output)
# --- ATUALIZAÇÃO DOS PESOS ---
# Ajuste: Camada Oculta -> Saída
# Transpomos a ativação para alinhar com o delta no produto escalar
output_gradient = np.dot(hidden_layer_output.T, delta_output)
weights_hidden_output += output_gradient * learning_rate
# Ajuste: Camada de Entrada -> Camada Oculta
# Transpomos a entrada para alinhar com o delta da camada oculta
hidden_gradient = np.dot(inputs.T, delta_hidden_layer)
weights_input_hidden += hidden_gradient * learning_rate
print("\n--- Treinamento Concluído ---")
print(f"Previsões finais:\n{predictions}")
print(f"Erro Final (MAE): {mean_absolute_error:.6f}")
Neste exemplo, a rede realiza um milhão de iterações. A cada ciclo, ela sente o erro através do backpropagation e utiliza a Descida do Gradiente para mover os pesos em direção à solução.
Ao final, vemos que as previsões para as quatro combinações do XOR estarão extremamente próximas dos valores reais (0, 1, 1, 0), provando que a adição da camada oculta e do aprendizado iterativo superou a limitação do Perceptron original.
Bias
Até aqui, vimos que o neurônio multiplica suas entradas por pesos e soma os resultados. Porém, se todas as entradas forem zero, a soma seria obrigatoriamente zero, e o neurônio ficaria sem flexibilidade para aprender padrões que não comecem exatamente da origem.
Para resolver esse problema, usamos o bias (viés), que é um valor extra somado ao cálculo que define o ponto de partida de cada neurônio.
Para entender melhor, imagine que o neurônio está decidindo se deve comprar o Mass Effect 4 quando lançar. As entradas são o preço e a nota do review, e os pesos são a importância que você dá a cada um. Mas e o seu gosto pessoal? É aqui que entra o bias:
- Bias Negativo (cético): você é um determinado amigo meu. Mesmo que o jogo seja barato e tenha boas notas, ainda precisa de um empurrão extra das entradas para se convencer a comprar.
- Bias Positivo (fã): você é igual a mim, que já ama a franquia. Mesmo que o preço seja alto ou a nota não seja tão boa, você já começa com a vontade de comprar lá em cima.
Na rede neural, o bias dá essa liberdade, permitindo que o neurônio decida disparar mesmo quando as entradas são baixas, ou permaneça em silêncio mesmo quando são altas. Durante o treinamento, a rede aprende o valor ideal de bias, ajustando o seu “nível de exigência” para cada neurônio, até encontrar a melhor configuração para resolver o problema.
Prática: XOR com Bias
No script abaixo, adicionamos um vetor de bias para a camada oculta e outro para a camada de saída. Note que eles também são inicializados e atualizados a cada época, permitindo que a rede encontre o “ponto de equilíbrio” ideal para cada neurônio.
import numpy as np
np.random.seed(42)
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def sigmoid_derivative(x):
return x * (1 - x)
# Dataset XOR
inputs = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
expected_outputs = np.array([[0], [1], [1], [0]])
# Inicialização de Pesos e Bias
# Usamos valores aleatórios para demonstrar a convergência
weights_input_hidden = np.random.uniform(size=(2, 3))
weights_hidden_output = np.random.uniform(size=(3, 1))
# O Bias é inicializado como um vetor para cada camada
bias_hidden = np.random.uniform(size=(1, 3))
bias_output = np.random.uniform(size=(1, 1))
epochs = 500_000
learning_rate = 0.3
if __name__ == '__main__':
for epoch in range(epochs):
# --- FORWARD PROPAGATION ---
# Somamos o bias ao produto escalar
hidden_layer_input = np.dot(inputs, weights_input_hidden) + bias_hidden
hidden_layer_output = sigmoid(hidden_layer_input)
output_layer_input = np.dot(hidden_layer_output, weights_hidden_output) + bias_output
predictions = sigmoid(output_layer_input)
# --- CÁLCULO DO ERRO ---
error = expected_outputs - predictions
if epoch % 50000 == 0:
print(f"Época {epoch} | MAE: {np.mean(np.abs(error)):.6f}")
# --- BACKPROPAGATION ---
delta_output = error * sigmoid_derivative(predictions)
delta_hidden_layer = np.dot(delta_output, weights_hidden_output.T) * sigmoid_derivative(hidden_layer_output)
# --- ATUALIZAÇÃO DOS PESOS E BIAS ---
# O ajuste do Bias segue a mesma lógica do peso, mas sua "entrada" é sempre 1
weights_hidden_output += np.dot(hidden_layer_output.T, delta_output) * learning_rate
bias_output += np.sum(delta_output, axis=0) * learning_rate
weights_input_hidden += np.dot(inputs.T, delta_hidden_layer) * learning_rate
bias_hidden += np.sum(delta_hidden_layer, axis=0) * learning_rate
print("\n--- Treinamento com Bias Concluído ---")
print(f"Previsões finais:\n{predictions}")
No próximo post, vamos levar esse conhecimento para problemas do mundo real e explorar como medir melhor o erro, lidar com múltiplas saídas e entender as limitações e extensões da arquitetura MLP.