Deep Neural Network com Pytorch

Alysson Guimarães
Data Hackers
Published in
37 min readFeb 20, 2024

--

Generated with AI ∙ February 14, 2024 at 7:15 PM

PyTorch é uma biblioteca de código aberto escrita em Python e C++ para computação numérica acelerada por GPU, principalmente usada para aplicações de deep learning. Ele fornece as principais funcionalidades necessárias para o desenvolvimento de redes neurais, como suporte multidimensional tensor (matrizes multi-dimensional) e operadores diferenciais automáticos. Esse framework possue diversas vantagens, a citar algumas:

  • Facilidade no prototipagem e debugging devido à sua interface dinâmica;
  • Suporte nativo para computação paralela através da utilização de GPUs;
  • Integração com outras ferramentas populares do ecossistema do Python,como NumPy e SciPy;
  • Além disso tem comunidade ativa de desenvolvedores contribuindo com novas features e soluções para problemas específicos.

O PyTorch foi criado pelo Facebook AI Research Lab (FAIR), mas hoje em dia tem um grande número de colaboradores, incluindo empresas líderes na indústria como NVIDIA, Google, Microsoft e IBM.

Os modelos de deep learning criados usando o PyTorch podem ser aplicados em uma variedade de tarefas e domínios em diversas indústrias, como:

Visão Computacional:

  • Classificação de imagens: Identificação de objetos em fotografias, como animais, veículos, pessoas e outros elementos presentes no conjunto de dados ImageNet.
  • Detecção de Objetos: Localização exata de objetos em imagens, geralmente expressa em caixas delimitadoras. Modelos notáveis incluem YOLO (You Only Look Once) e SSD (Single Shot Multibox Detector).
  • Segmentação de Imagens: Divisão de imagens em regiões correspondentes a classes distintas, como pixel por pixel classificação.
  • Geração de Imagens: Produção de imagens artísticas, hiperrealistas ou fantásticas geradas por meio de redes neuronais condicionadas por entrada textual ou aleatória.

Processamento Natural de Linguagem (NLP)

  • Tradução Automática: Conversão de texto escrito em uma língua para outra língua usando modelos como Transformer e Seq2Seq.
  • Resposta de Perguntas (QA): Previsão de respostas a perguntas feitas em diversos idiomas.
  • Text Summarization: Gerar sumários curtos e concisos de documentos longos preservando informações importantes.
  • Chatbots: Construção de assistentes virtuais capazes de compreender e responder consultas de usuários de formas naturais e contextualmente relevantes.

Áudio e Processamento de Sinal

  • Reconhecimento de Falas: Interpretação da fala humana convertendo ondas sonoras em transcrições textuais, usado em sistemas de dictâfonos digitais, smartphones e aparelhos auditivos.
  • Separação de Fontes Sonoras (Audio Source Separation): Capacidade de separar sons misturados, isolando fontes musicais individuais ou falas de conversas complexas.
  • Música Generativa: Criação de músicas originais através de composição e arranjos gerados por máquinas, inspirados em estilos clássicos ou contemporâneos.

Jogos e Robótica

  • Reinforcement Learning: Treino de agentes inteligentes que tomam decisões autônomas enfrentando desafios em jogos como Go, poker e StarCraft II.
  • Animação e Robótica: Controle de personagens virtuais, manipulação de robôs industriais e assistência a deficientes visuais em ambientes urbanos movimentados.

Este artigo está dividido em dois grandes blocos, e neles, abordaremos os seguintes tópicos:

Fundamentos de Machine Learning com PyTorch

— Tensores
— Derivada no PyTorch
— Gradiente Descendente Estocástico (SGD)
— Gradiente Descendente com Momentum
— Mini-Batch Gradient Descent
— Normalização em Lote
— Divisão em Treinamento, Validação e Teste

Redes Neurais:

— Redes Neurais Rasas
— Redes Neurais Profundas
— nn.ModuleList()
— Funções de Ativação
— Backpropagation
— O problema do “vanishing gradient”
— Dropout
— Pesos e Inicialização da Rede Neural

Fundamentos de Machine Learning com PyTorch

Tensores

Tensores são as estruturas fundamentais que representam dados multi-dimensionais. Eles são semelhantes aos arrays ou matrizes. Eles são os building blocks e a base para construção de e manipulação de modelos de machine learning e deep learning.

Os tensores são semelhantes aos arrays do NumPy e, muitas vezes, podem ser convertidos de um para o outro. Isso facilita a integração com bibliotecas científicas em Python. Além disso o PyTorch permite a computação em GPUs para acelerar operações, e eles podem ser movidos para uma GPU para aproveitar o poder de processamento paralelo. Ele fornece uma ampla variedade de operações matemáticas que podem ser aplicadas a tensores, isso inclui operações aritméticas, funções trigonométricas, álgebra linear, entre outras.

Uma característica fundamental do Pytorch é o sistema de autograd, que automaticamente calcula gradientes para tensores. Isso é crucial para a otimização de modelos de aprendizado de máquina.

Podemos criar tensores de várias maneiras, seja inicializando-os com valores específicos, gerando números aleatórios, ou a partir de dados existentes. Eles são úteis para representar dados ao longo de uma única dimensão, como séries temporais, uma linha de pixels de uma imagem ou um conjunto de valores.

import torch

# Criando um tensor 1D
tensor_1d = torch.tensor([1, 2, 3, 4, 5])

# Acessando elementos do tensor
print(tensor_1d[0]) # Saída: 1
print(tensor_1d[2]) # Saída: 3

# Operações matemáticas em tensores 1D
tensor_resultado = tensor_1d * 2
print(tensor_resultado)

# Saída: tensor([2, 4, 6, 8, 10])

Neste exemplo, tensor_1d é um tensor 1D contendo os valores de 1 a 5. Podemos acessar elementos individualmente e realizar operações matemáticas diretamente nos tensores. Os tensores 1D são frequentemente usados em problemas onde os dados estão organizados de forma linear, e são a base para a construção de estruturas de dados mais complexas, como matrizes bidimensionais (tensores 2D) e tensores de ordens superiores.

Tensores bidimensionais em PyTorch são estruturas de dados que representam matrizes, ou seja, arranjos retangulares de elementos organizados em duas dimensões. Eles são comumente utilizados para representar dados tabulares, imagens, ou qualquer outra informação que possa ser organizada em linhas e colunas.

import torch

# Criando um tensor bidimensional (matriz)
tensor_2d = torch.tensor([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

# Acessando elementos do tensor bidimensional
print(tensor_2d[0, 1]) # Saída: 2 (linha 0, coluna 1)

# Operações matemáticas em tensores bidimensionais
tensor_resultado = tensor_2d * 2
print(tensor_resultado)
# Saída:
# tensor([[ 2, 4, 6],
# [ 8, 10, 12],
# [14, 16, 18]])

Neste exemplo, tensor_2d é uma matriz 3x3 com elementos de 1 a 9. Eles são geralmente usados em tarefas de processamento de imagens, onde cada elemento da matriz pode representar um pixel. Eles também são essenciais para a construção e treinamento de modelos de aprendizado de máquina, especialmente redes neurais profundas, onde as entradas muitas vezes são representadas por tensores bidimensionais.

## Esses são apenas alguns exemplos.
# Criando dois tensores bidimensionais
import torch
tensor_a = torch.tensor([[1, 2, 3], [4, 5, 6]])
tensor_b = torch.tensor([[7, 8, 9], [10, 11, 12]])

# Soma de tensores
soma = tensor_a + tensor_b
print("Soma:")
print(soma)

# Subtração de tensores
subtracao = tensor_a - tensor_b
print("\nSubtração:")
print(subtracao)

# Multiplicação de tensores (element-wise)
multiplicacao_elementwise = tensor_a * tensor_b
print("\nMultiplicação (element-wise):")
print(multiplicacao_elementwise)

# Multiplicação de tensores (produto matricial)
produto_matricial = torch.matmul(tensor_a, tensor_b.T) # Transpondo tensor_b para alinhar dimensões*
print("\nProduto Matricial:")
print(produto_matricial)

# Operações de redução (somando elementos ao longo das colunas)
soma_colunas = torch.sum(tensor_a, dim=0)
print("\nSoma das Colunas:")
print(soma_colunas)

# Operações de broadcast (somando uma constante a cada elemento)
tensor_soma_constante = tensor_a + 10
print("\nSoma com Constante:")
print(tensor_soma_constante)

# Saída
# Soma:
# tensor([[ 8, 10, 12],
# [14, 16, 18]])
#
# Subtração:
# tensor([[-6, -6, -6],
# [-6, -6, -6]])
#
# Multiplicação (element-wise):
# tensor([[ 7, 16, 27],
# [40, 55, 72]])
#
# Produto Matricial:
# tensor([[ 50, 68],
# [122, 167]])
#
# Soma das Colunas:
# tensor([5, 7, 9])
#
# Soma com Constante:
# tensor([[11, 12, 13],
# [14, 15, 16]])

Os tensores tridimensionais (tensores 3D) são utilizados para representar dados que possuem uma organização tridimensional, como volumes de imagens ou séries temporais multivariadas.

# Criando um tensor 3D (volume)
tensor_3d = torch.tensor([[[1, 2, 3],[4, 5, 6]],
[[7, 8, 9], [10, 11, 12]],
[[13, 14, 15], [16, 17, 18]]])

# Acessando elementos do tensor 3D
print("Acessando um elemento:")
print(tensor_3d[1, 0, 2]) # Saída: 9 (depth 1, linha 0, coluna 2)

# Operações matemáticas em tensores 3D
tensor_resultado = tensor_3d * 2
print("\nMultiplicação por 2:")
print(tensor_resultado)

# Operações de redução (somando elementos ao longo das dimensões)
soma_dim1 = torch.sum(tensor_3d, dim=1)
print("\nSoma ao Longo da Dimensão 1:")
print(soma_dim1)

# Operações de broadcast (somando uma constante a cada elemento)
tensor_soma_constante = tensor_3d + 10
print("\nSoma com Constante:")
print(tensor_soma_constante)

# Saída
# Acessando um elemento:
# tensor(9)
#
#Multiplicação por 2:
# tensor([[[ 2, 4, 6],
# [ 8, 10, 12]],
#
# [[14, 16, 18],
# [20, 22, 24]],
#
# [[26, 28, 30],
# [32, 34, 36]]])
#
# Soma ao Longo da Dimensão 1:
# tensor([[ 5, 7, 9],
# [17, 19, 21],
# [29, 31, 33]])
#
# Soma com Constante:
# tensor([[[11, 12, 13],
# [14, 15, 16]],
#
# [[17, 18, 19],
# [20, 21, 22]],
#
# [[23, 24, 25],
# [26, 27, 28]]])

Neste exemplo, tensor_3d representa um volume 3D com dimensões 3x2x3. Podemos acessar os elementos individualmente usando índices em cada dimensão, e as operações matemáticas podem ser feitas de forma parecida aos feitos com tensores 2D. Eles são frequentemente encontrados em problemas de visão computacional para representar volumes de dados tridimensionais, como pilhas de imagens ou volumes médicos.

Derivada no Pytorch

A derivada e a derivada parcial são conceitos fundamentais em cálculo diferencial, e desempenham um papel crucial na compreensão do comportamento de funções em uma ou mais variáveis.

A derivada de uma função em relação a uma variável representa a taxa de variação instantânea da função em relação a essa variável. Em outras palavras, indica como a função está mudando em um ponto específico do domínio. A notação matemática padrão para a derivada de uma função f(x) em relação a x é f′(x) ou (∂f/∂x). Geometricamente, a derivada no ponto x é equivalente à inclinação da tangente à curva da função nesse ponto. Para entender a intuição, imagine que você está dirigindo em um carro e olhando para o velocímetro. A velocidade do carro está mudando constantemente conforme você dirige. A derivada de sua posição em relação ao tempo seria como a leitura instantânea do velocímetro, indicando a velocidade exata em que você está se movendo naquele momento. A derivada de uma função em um ponto nos diz como a função está mudando nesse ponto. Se a função representa a posição de um objeto ao longo do tempo, a derivada seria a velocidade instantânea.

Já a derivada parcial de uma função de várias variáveis em relação a uma variável específica mede a taxa de variação da função em relação a essa variável, mantendo todas as outras variáveis constantes. A notação para a derivada parcial de uma função f(x,y,…) em relação a uma variável x é ∂f/∂x. Aqui, ∂ é usado para indicar uma derivada parcial. Analogamente, a derivada parcial em relação a y seria ∂f/∂y, e assim por diante. Novamente, vamos tentar entender a intuição: Imagine que você está em um terreno acidentado e a temperatura varia em diferentes partes desse terreno. A derivada parcial em relação à temperatura em um determinado ponto seria como medir o quanto a temperatura muda nesse ponto específico, mantendo todas as outras variáveis (como a posição) constantes. Em termos mais gerais, a derivada parcial de uma função de várias variáveis em relação a uma delas nos dá a taxa de variação da função em relação a uma variável específica, mantendo as outras constantes. É como analisar como uma feature específica influencia o comportamento da função, enquanto outras feature permanecem inalteradas.

Ambos os conceitos, derivada e derivada parcial, ajudam a entender como as coisas mudam em relação a uma variável ou a uma mudança em uma única dimensão, seja em trajetórias de carros ou em paisagens de temperatura. Essas ideias são fundamentais para modelagem matemática e são amplamente aplicadas em diversas áreas, desde física até aprendizado de máquina.

A derivada é uma generalização da ideia de taxa de variação para funções de uma única variável, enquanto a derivada parcial lida com funções de várias variáveis, permitindo-nos entender como a função muda ao longo de cada dimensão independente.

A fórmula para a derivada parcial em relação a uma variável específica é semelhante à fórmula da derivada para funções de uma variável, considerando todas as outras variáveis como constantes. Essas ferramentas são fundamentais para a análise e otimização de funções em problemas matemáticos e científicos.

A diferenciação automática é uma feature fundamental em PyTorch que permite calcular automaticamente gradientes em relação às variáveis de interesse. Isso é especialmente útil em aprendizado de máquina, onde otimizar modelos envolve ajustar parâmetros para minimizar ou maximizar uma função de perda. O mecanismo de diferenciação automática em PyTorch é implementado pelo módulo autograd.

import torch

# Variáveis que requerem gradiente
x = torch.tensor([2.0], requires_grad=True)
y = torch.tensor([3.0], requires_grad=True)

# Operações envolvendo as variáveis
z = x**2 + y**3

# Calcular gradientes
z.backward()

# Exibir gradientes
print("Gradiente em relação a x:", x.grad)
print("Gradiente em relação a y:", y.grad)

# Saída
# Gradiente em relação a x: tensor([4.])
# Gradiente em relação a y: tensor([27.])

Neste exemplo, x e y são tensores que requerem gradiente (requires_grad=True). A expressão z = x**2 + y**3 é calculada, e em seguida, chamamos z.backward() para calcular automaticamente os gradientes de z em relação a x e y. Os gradientes são então acessados através dos atributos .grad das variáveis.

Este é um exemplo muito simples, mas em modelos de aprendizado de máquina, esses gradientes são essenciais para a retropropagação (backpropagation) durante o treinamento.

import torch
import torch.optim as optim

# Dados de entrada e saída desejada
x_data = torch.tensor([1.0, 2.0, 3.0, 4.0], requires_grad=False)
y_data = torch.tensor([2.0, 4.0, 6.0, 8.0], requires_grad=False)

# Inicializando parâmetros
w = torch.tensor([1.0], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)

# Definindo a função linear
def modelo_linear(x):
return w * x + b

# Função de perda (erro quadrático médio)
def loss_function(y_pred, y_actual):
return torch.mean((y_pred - y_actual)**2)

# Configurando o otimizador
optimizer = optim.SGD([w, b], lr=0.01)

# Treinamento do modelo
for epoch in range(100):
# Forward pass
y_pred = modelo_linear(x_data)

# Cálculo da perda
loss = loss_function(y_pred, y_data)

# Retropropagação e otimização
optimizer.zero_grad() # Zera os gradientes acumulados
loss.backward() # Calcula gradientes
optimizer.step() # Atualiza parâmetros

# Exibindo parâmetros finais
print("Parâmetros finais: w =", w.item(), ", b =", b.item())
# Parâmetros finais: w = 1.9231737852096558 , b = 0.22587870061397552

Gradiente Descendente Estocástico

O Gradiente Descendente Estocástico (SGD, do inglês Stochastic Gradient Descent) é uma variação do algoritmo de otimização do Gradiente Descendente, frequentemente usado no treinamento de modelos de aprendizado de máquina e redes neurais. A principal diferença entre o Gradiente Descendente Estocástico e o Gradiente Descendente clássico está na forma como as atualizações dos parâmetros do modelo são realizadas.

No Gradiente Descendente clássico, a atualização dos parâmetros é realizada utilizando o gradiente médio dos parâmetros sobre o conjunto de dados de treinamento completo. A função de custo é calculada considerando todos os exemplos de treinamento.

Já no Gradiente Descendente Estocástico (SGD), a atualização dos parâmetros é realizada para cada exemplo de treinamento (ou em pequenos lotes (mini lotes)). Ou seja, em vez de calcular o gradiente médio, o gradiente é calculado apenas para um exemplo aleatório a cada iteração. Isso acaba introduzindo estocasticidade (aleatoriedade) no processo de atualização dos parâmetros. Isso trás vantagens como

  • Eficiência Computacional: Utilizar apenas um exemplo ou mini lote por vez é computacionalmente eficiente, especialmente em conjuntos de dados grandes.
  • É Mais Rápido para Convergência: A aleatoriedade introduzida pode ajudar a escapar de mínimos locais e acelerar a convergência, especialmente em funções de custo não convexas.

Mas também tras desvantanges como:

  • Estocasticidade: Devido à estocasticidade, o SGD pode levar a uma convergência mais ruidosa. A função de custo pode variar mais ao longo do tempo.
  • Taxa de Aprendizado Ajustável: A escolha apropriada da taxa de aprendizado é crucial. Pode ser necessário ajustar dinamicamente a taxa de aprendizado durante o treinamento.
import torch
import torch.optim as optim

# Definindo um modelo e uma função de custo
model = torch.nn.Linear(2, 1)
criterion = torch.nn.MSELoss()

# Definindo um otimizador SGD
optimizer = optim.SGD(model.parameters(), lr=0.01)

# Exemplo de treinamento com SGD
for epoch in range(num_epochs):
for inputs, labels in data_loader: # Iterando sobre mini lotes
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()

Neste exemplo, optim.SGD é usado como otimizador para aplicar o Gradiente Descendente Estocástico durante o treinamento. O loop aninhado sobre mini lotes ilustra o conceito de atualização de parâmetros para cada exemplo ou mini lote.

Gradient Descent com Momentum

O Gradiente Descendente com Momentum é uma variação do algoritmo básico de Gradiente Descendente, projetada para acelerar a convergência e lidar melhor com regiões de superfície de perda complicadas. Este método incorpora um conceito chamado “momentum”, inspirado pela física.

A ideia do momentum é simular o comportamento de uma bola rolando por uma superfície de perda. Quando a bola começa a rolar em uma direção, ela acumula momentum e ganha velocidade. Isso ajuda a superar pequenos obstáculos e acelerar em direção ao mínimo global.

A atualização dos pesos usando o Gradiente Descendente com Momentum é dada pela seguinte fórmula:

onde:

  • θ_t são os parâmetros (pesos) no passo de iteração t,
  • ∇J(θ_t) é o gradiente da função de perda J em relação aos parâmetros θ_t1,
  • α é a taxa de aprendizado ou learning rate,
  • β é o coeficiente de momentum, geralmente entre 0 e 1,
  • v_t é o vetor de momentum no passo t.

A introdução do termo β⋅v_t na atualização dos pesos permite que o momentum acumule ao longo das iterações. Isso é especialmente útil quando o gradiente varia de direção, permitindo que o modelo ganhe impulso em direção ao mínimo global.

A principal vantagem do Gradiente Descendente com Momentum é sua capacidade de acelerar o treinamento, especialmente em áreas onde o gradiente oscila ou possui muitos mínimos locais. Essa técnica pode ajudar a superar regiões planas na função de perda e escapar de mínimos locais.

Em PyTorch, você pode implementar o Gradiente Descendente com Momentum usando o otimizador torch.optim.SGD com o argumento momentum.

import torch
import torch.optim as optim

# Definindo parâmetros
learning_rate = 0.01
momentum = 0.9

# Criando um otimizador com momentum
optimizer = optim.SGD(model.parameters(), lr=learning_rate, momentum=momentum)

# No loop de treinamento, você usaria optimizer.step() para atualizar os pesos
# e optimizer.zero_grad() para zerar os gradientes antes de uma nova backward pass.

A adição de momentum pode ser benéfica em muitos casos, mas é importante ajustar a taxa de aprendizado e o coeficiente de momentum para obter o melhor desempenho em um problema específico.

O valor típico de 0.9 para o coeficiente de momentum é uma escolha empírica comum que muitas vezes funciona bem em prática. A escolha do valor específico para o coeficiente de momentum pode depender do problema, da arquitetura da rede e da natureza do conjunto de dados. Vários valores, como 0.9, 0.99, ou até mesmo outros, podem ser experimentados para encontrar o melhor desempenho em um determinado contexto.

O momentum é um termo de regularização que ajuda a suavizar as atualizações dos pesos ao longo do tempo. Um valor de 0.9 significa que a atualização dos pesos é uma média ponderada entre a direção atual do gradiente e a direção do termo de momentum anterior. Isso dá ao modelo uma “inércia” para continuar na mesma direção em que estava indo nas iterações anteriores.

Como regra geral, valores mais altos de momentum (por exemplo, 0.9) podem ajudar a acelerar o treinamento, especialmente em problemas com muitos mínimos locais ou planaltos na função de perda. No entanto, um valor muito alto pode levar a oscilações excessivas e até mesmo prejudicar o desempenho. Em alguns casos, valores menores de momentum podem ser preferíveis.

A escolha do valor ideal muitas vezes envolve experimentação e ajuste fino. Recomenda-se começar com valores padrão (como 0.9) e ajustar conforme necessário com base no desempenho observado durante o treinamento. Realizar uma busca de hiperparâmetros ou utilizar métodos automatizados de ajuste de hiperparâmetros também pode ajudar a encontrar os melhores valores em uma variedade de contextos.

Mini-Batch Gradient Descent

O Mini-Batch Gradient Descent é uma variação do Gradiente Descendente em que, em vez de calcular o gradiente e fazer uma atualização de parâmetros usando o conjunto de dados completo (Batch Gradient Descent) ou apenas um exemplo por vez (Stochastic Gradient Descent), trabalhamos com mini lotes de dados. Cada atualização de parâmetro é realizada com base em um pequeno subconjunto (mini lote) dos dados de treinamento.

Usar o Gradiente Descendente Estocástico (SGD) com o DataLoader em PyTorch é bastante simples. O DataLoader lida com o embaralhamento dos dados e a divisão em mini lotes, enquanto o otimizador SGD é responsável pelas atualizações dos parâmetros do modelo.

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

# Criando dados fictícios
x_train = torch.randn((1000, 3, 64, 64)) # Exemplo de imagens (canais x altura x largura)
y_train = torch.randint(0, 2, (1000,)) # Exemplo de rótulos binários

# Criando um conjunto de dados PyTorch
dataset = TensorDataset(x_train, y_train)

# Criando um DataLoader
batch_size = 32
shuffle = True
num_workers = 4 # Número de processos de leitura em paralelo
data_loader = DataLoader(dataset, batch_size=batch_size, shuffle=shuffle, num_workers=num_workers)

# Criando um modelo de exemplo
class SimpleModel(nn.Module):
def __init__(self):
super(SimpleModel, self).__init__()
self.fc = nn.Linear(3 * 64 * 64, 1)

def forward(self, x):
x = x.view(x.size(0), -1) # Achatando as imagens para um vetor
return self.fc(x)

# Criando uma instância do modelo
modelo = SimpleModel()

# Definindo a função de perda e o otimizador SGD
criterio = nn.BCEWithLogitsLoss() # Para problemas de classificação binária
otimizador = optim.SGD(modelo.parameters(), lr=0.01)

# Treinando o modelo usando SGD e DataLoader
num_epochs = 10

for epoch in range(num_epochs):
for inputs, labels in data_loader:
# Forward pass
outputs = modelo(inputs)
loss = criterio(outputs.squeeze(), labels.float())

# Backward pass e otimização
otimizador.zero_grad()
loss.backward()
otimizador.step()

print(f'Época [{epoch + 1}/{num_epochs}], Perda: {loss.item():.4f}')

# Saída
# Época [1/10], Perda: 0.9239
# Época [2/10], Perda: 0.1510
# Época [3/10], Perda: 0.1002
# Época [4/10], Perda: 0.0714
# Época [5/10], Perda: 0.0521
# Época [6/10], Perda: 0.0513
# Época [7/10], Perda: 0.0373
# Época [8/10], Perda: 0.0273
# Época [9/10], Perda: 0.0294
# Época [10/10], Perda: 0.0289

Neste exemplo:

  • Criamos um DataLoader para o conjunto de dados usando a classe DataLoader.
  • Definimos um modelo de exemplo (SimpleModel) para fins explicativos, didáticos.
  • Utilizamos a função de perda BCEWithLogitsLoss, que é apropriada para problemas de classificação binária.
  • Utilizamos o otimizador SGD para ajustar os parâmetros do modelo.
  • Iteramos sobre os mini lotes gerados pelo DataLoader durante o treinamento, realizando o treinamento por várias épocas.

Este é um exemplo básico para demonstrar como usar o DataLoader em conjunto com o SGD para treinar um modelo. No dia-a-dia, teríamos que adaptar o modelo, a função de perda e outros hiperparâmetros para atender às necessidades específicas do seu problema.

Normalização em Lote

A Batch Normalization (normalização em lote) é uma técnica de regularização e normalização usada em redes neurais para melhorar a estabilidade e acelerar o treinamento. A ideia principal por trás da Batch Normalization é normalizar as ativações em cada camada, aplicando uma normalização em lote durante o treinamento.

A normalização em lote é aplicada a um mini lote de exemplos durante o treinamento e consiste em duas etapas principais:

  1. Normalização: Normaliza as ativações de cada neurônio para ter média zero e desvio padrão um dentro do mini lote.
  2. Escalonamento e Deslocamento (Scaling and Shifting): Introduz dois parâmetros treináveis (gama e beta) para reescalar e deslocar as ativações normalizadas. Isso permite que a rede aprenda a melhor representação dos dados.

A normalização em lote é aplicada antes da função de ativação em cada camada da rede. Isso ajuda a manter as ativações dentro de uma faixa mais estável, o que pode facilitar o treinamento. Além disso, a normalização em lote tem benefícios de regularização, agindo como um mecanismo de suavização do treinamento, reduzindo a dependência em relação à inicialização específica dos pesos e mitigando problemas como o desaparecimento ou explosão de gradientes.

A fórmula para normalização em lote em uma camada é dada por:

onde:

  • x são as ativações da camada,
  • μ é a média das ativações no mini lote,
  • σ é o desvio padrão das ativações no mini lote,
  • ϒ e β são parâmetros treináveis,
  • ε é uma constante pequena para evitar a divisão por zero.

Em PyTorch, você pode usar a camada torch.nn.BatchNorm1d (ou torch.nn.BatchNorm2d para convoluções) para adicionar Batch Normalization a uma camada da rede.

import torch
import torch.nn as nn

class NeuralNetworkWithBatchNorm(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(NeuralNetworkWithBatchNorm, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.bn1 = nn.BatchNorm1d(hidden_size) # Adicionando Batch Normalization
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_size, output_size)

def forward(self, x):
x = self.fc1(x)
x = self.bn1(x) # Aplicando Batch Normalization antes da ativação
x = self.relu(x)
x = self.fc2(x)
return x

Adicionar Batch Normalization à sua rede pode ajudar na estabilidade do treinamento, permitindo taxas de aprendizado mais altas e reduzindo a sensibilidade à inicialização dos pesos. Essa técnica é especialmente útil em redes mais profundas.

Divisão em Treinamento, Validação e Teste

No pipeline de ciencia de dados e comum dividir-mos os dados em um conjunto de treino, validação e teste.

Abaixo, implementamos um modelo de regressão linear, mas focando na explicação da divisão de treinamento, validação e teste e como utilizar cada um.

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt

# Criando dados fictícios
torch.manual_seed(42)
X = torch.rand(1000, 1) # Feature unidimensional
y = 2 * X + 1 + 0.1 * torch.rand(1000, 1) # Regressão linear com ruído

# Dividindo os dados em treino, validação e teste
X_train, X_temp, y_train, y_temp = train_test_split(X, y, test_size=0.3, random_state=42)
X_val, X_test, y_val, y_test = train_test_split(X_temp, y_temp, test_size=0.5, random_state=42)

# Convertendo para tensores
X_train, y_train = torch.tensor(X_train, dtype=torch.float32), torch.tensor(y_train, dtype=torch.float32)
X_val, y_val = torch.tensor(X_val, dtype=torch.float32), torch.tensor(y_val, dtype=torch.float32)
X_test, y_test = torch.tensor(X_test, dtype=torch.float32), torch.tensor(y_test, dtype=torch.float32)

# Criando conjuntos de dados PyTorch
train_dataset = TensorDataset(X_train, y_train)
val_dataset = TensorDataset(X_val, y_val)
test_dataset = TensorDataset(X_test, y_test)

# Criando DataLoader para treino, validação e teste
batch_size = 32
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=batch_size)
test_loader = DataLoader(test_dataset, batch_size=batch_size)

Neste primeira etapa, transformamos os dados em tensores utilizando o torch.tensor e, em seguida utilizamos o TensorDataset, para representar os valores como lista de tensores que entrarão como inputs para o modelo. Em seguida, utilizamos o DataLoader para selecionar amostras de cada conjunto de dados, utilizando o batch_size = 32, ou seja, é o número de amostras de dados que serão usadas em cada iteração durante o treinamento do modelo.

# Definindo um modelo de regressão linear simples
class LinearRegressionModel(nn.Module):
def __init__(self):
super(LinearRegressionModel, self).__init__()
self.linear = nn.Linear(1, 1)

def forward(self, x):
return self.linear(x)

# Criando uma instância do modelo
model = LinearRegressionModel()

# Definindo a função de perda e o otimizador
criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.01)

# Treinando o modelo
num_epochs = 500
train_losses, val_losses = [], []

for epoch in range(num_epochs):
model.train() # Modo de treino
for inputs, labels in train_loader:
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()

# Calculando a perda no conjunto de treino
model.eval() # Modo de avaliação
with torch.no_grad():
total_val_loss = 0
for inputs_val, labels_val in val_loader:
outputs_val = model(inputs_val)
val_loss = criterion(outputs_val, labels_val)
total_val_loss += val_loss.item()

average_val_loss = total_val_loss / len(val_loader)
val_losses.append(average_val_loss)

print(f'Época [{epoch + 1}/{num_epochs}], Perda de Treino: {loss.item():.4f}, Perda de Validação: {average_val_loss:.4f}')

# Testando o modelo
model.eval() # Modo de avaliação
with torch.no_grad():
total_test_loss = 0
for inputs_test, labels_test in test_loader:
outputs_test = model(inputs_test)
test_loss = criterion(outputs_test, labels_test)
total_test_loss += test_loss.item()

average_test_loss = total_test_loss / len(test_loader)
print(f'Perda no Conjunto de Teste: {average_test_loss:.4f}')

# Visualizando os resultados
plt.scatter(X_test, y_test, label='Dados Reais')
plt.plot(X_test, model(X_test).detach().numpy(), color='red', label='Regressão Linear Ajustada')
plt.xlabel('X')
plt.ylabel('y')
plt.legend()
plt.show()

Aqui, definimos o modelo e o instanciamos. Depois definimos a função de perda (criterion) e o otimizador. O modelo é treinado por um número fixo de épocas (500 neste caso). Para cada época, os dados de treinamento são passados pelo modelo, a perda é calculada usando a função de perda definida e os parâmetros do modelo são atualizados pelo otimizador.

Durante o treinamento, a perda no conjunto de validação é calculada para monitorar o desempenho do modelo em dados não vistos.

Após o treinamento, o modelo é testado no conjunto de teste para avaliar seu desempenho em dados completamente novos. A perda no conjunto de teste é calculada usando a função de perda.

Ao final, os resultados são visualizados plotando os dados reais do conjunto de teste e a linha da regressão linear ajustada pelo modelo. Isso ajuda a visualizar como o modelo se ajusta aos dados de teste.

Redes Neurais

Um neurônio em deep learning é uma unidade básica de processamento que recebe entradas, realiza uma série de operações matemáticas nelas (como multiplicação por pesos e soma ponderada), aplica uma função de ativação e produz uma saída.

Redes neurais são formadas por interconectar esses neurônios em camadas. Cada camada recebe entradas das camadas anteriores, processa essas entradas e passa as saídas para as camadas seguintes. Isso forma uma rede de processamento de informações, capaz de aprender padrões e realizar tarefas complexas.

Redes Neurais Rasas

Uma Shallow Neural Network (Rede Neural Rasa) refere-se a uma arquitetura de rede neural com apenas uma única camada oculta entre a camada de entrada e a camada de saída. Em outras palavras, uma rede neural rasa possui uma estrutura simples, com apenas uma camada intermediária contendo um número limitado de neurônios. A arquitetura básica de uma Shallow Neural Network é composta por três camadas principais:

  • Camada de Entrada (Input Layer): Recebe os dados de entrada e transfere esses dados para a camada oculta.
  • Camada Oculta (Hidden Layer): Realiza transformações nos dados recebidos da camada de entrada. Esta camada contém neurônios que aplicam transformações lineares seguidas de funções de ativação não lineares. A presença desta camada permite que a rede aprenda representações mais complexas dos dados.
  • Camada de Saída (Output Layer): Produz a saída final da rede após processar os dados pela camada oculta. Dependendo da tarefa, a camada de saída pode ter um único neurônio (para problemas de regressão) ou vários neurônios (para problemas de classificação).

Embora uma Shallow Neural Network seja limitada em termos de capacidade de aprendizado em comparação com redes mais profundas, ela ainda pode ser eficaz para tarefas relativamente simples. Essa arquitetura é frequentemente utilizada em problemas onde os padrões nos dados podem ser aprendidos com uma quantidade menor de parâmetros.

  • Menos Suscetíveis a Overfitting: Redes rasas geralmente têm menos parâmetros do que redes profundas, o que pode torná-las menos propensas a overfitting (ajuste excessivo) em conjuntos de dados menores.
  • Treinamento Mais Rápido: Como há menos camadas e parâmetros para treinar, o treinamento de Shallow Neural Networks pode ser mais rápido em comparação com redes mais profundas.
  • Limitações na Representação de Recursos Complexos: Redes rasas podem ter dificuldades em aprender representações hierárquicas profundas de características complexas presentes nos dados, o que pode limitar seu desempenho em tarefas mais desafiadoras.
  • Aplicações: Shallow Neural Networks podem ser adequadas para problemas mais simples, como classificação binária, regressão linear, ou problemas onde os dados têm padrões que podem ser facilmente aprendidos com uma arquitetura mais simples.

À medida que a complexidade das tarefas aumenta, é comum recorrer a arquiteturas de redes mais profundas para extrair e aprender representações mais complexas e abstratas dos dados.
Redes Neurais Profundas

# Definindo a Shallow Neural Network
class ShallowNeuralNetwork(nn.Module):
def __init__(self, input_size):
super(ShallowNeuralNetwork, self).__init__()
self.linear1 = nn.Linear(input_size, 10) # 10 neuronios na camada oculta
self.relu = nn.ReLU() # funcao de ativacao da camada oculta
self.linear2 = nn.Linear(10, 1) # 1 neuronio na camada de saida
self.sigmoid = nn.Sigmoid() # funcao de ativacao da camada de saida

def forward(self, x):
x = self.relu(self.linear1(x))
x = self.sigmoid(self.linear2(x))
return x

Exemplo completo

import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# Gerando dados fictícios
X, y = make_classification(n_samples=1000, n_features=5, n_classes=2, n_clusters_per_class=1, random_state=42)

# Dividindo os dados em conjuntos de treino e teste
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Padronizando os dados (importante para redes neurais)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

# Convertendo os dados para tensores do PyTorch
X_train_tensor = torch.tensor(X_train, dtype=torch.float32)
y_train_tensor = torch.tensor(y_train, dtype=torch.float32).view(-1, 1) # reshape para uma coluna
X_test_tensor = torch.tensor(X_test, dtype=torch.float32)
y_test_tensor = torch.tensor(y_test, dtype=torch.float32).view(-1, 1) # reshape para uma coluna

# Definindo a Shallow Neural Network
class ShallowNeuralNetwork(nn.Module):
def __init__(self, input_size):
super(ShallowNeuralNetwork, self).__init__()
self.linear1 = nn.Linear(input_size, 10)
self.relu = nn.ReLU()
self.linear2 = nn.Linear(10, 1)
self.sigmoid = nn.Sigmoid()

def forward(self, x):
x = self.relu(self.linear1(x))
x = self.sigmoid(self.linear2(x))
return x

# Instanciando o modelo
input_size = X_train.shape[1]
model = ShallowNeuralNetwork(input_size)

# Definindo a função de perda (Binary Cross Entropy Loss) e o otimizador
criterion = nn.BCELoss()
optimizer = optim.SGD(model.parameters(), lr=0.01)

# Treinando o modelo
num_epochs = 500

for epoch in range(num_epochs):
# Forward pass
outputs = model(X_train_tensor)

# Calculando a perda
loss = criterion(outputs, y_train_tensor)

# Backward pass e otimização
optimizer.zero_grad()
loss.backward()
optimizer.step()

# Imprimindo a perda a cada 50 épocas
if (epoch + 1) % 50 == 0:
print(f'Época [{epoch + 1}/{num_epochs}], Perda: {loss.item():.4f}')

# Avaliando o modelo no conjunto de teste
model.eval() # Modo de avaliação
with torch.no_grad():
predictions = model(X_test_tensor)
binary_predictions = (predictions > 0.5).float()

# Calculando a acurácia do modelo no conjunto de teste
accuracy = (binary_predictions == y_test_tensor).float().mean()
print(f'Acurácia do modelo no conjunto de teste: {accuracy.item():.4f}')

# Saída
#Época [50/500], Perda: 0.6457
#Época [100/500], Perda: 0.6074
#Época [150/500], Perda: 0.5694
#Época [200/500], Perda: 0.5321
#Época [250/500], Perda: 0.4960
#Época [300/500], Perda: 0.4619
#Época [350/500], Perda: 0.4305
#Época [400/500], Perda: 0.4024
#Época [450/500], Perda: 0.3773
#Época [500/500], Perda: 0.3552
#Acurácia do modelo no conjunto de teste: 0.9150

Neste exemplo, criamos uma Shallow Neural Network com uma camada oculta contendo 10 neurônios. A função de ativação ReLU é usada na camada oculta, e a função de ativação Sigmoid é usada na camada de saída para lidar com a tarefa de classificação binária. O modelo é treinado usando o otimizador SGD (Stochastic Gradient Descent) e a função de perda Binary Cross Entropy Loss. O desempenho do modelo é avaliado no conjunto de teste.

Redes Neurais Profundas

Uma Deep Neural Network (DNN) ou Rede Neural Profunda , é simplesmente uma arquitetura de rede neural artificial que possui múltiplas camadas entre a entrada e a saída. Essa abordagem permite que a rede aprenda representações mais complexas e hierárquicas dos dados, tornando-a capaz de realizar tarefas mais sofisticadas e de lidar com características abstratas.

As DNNs são caracterizadas por sua profundidade, que é determinada pelo número de camadas que compõem a arquitetura. Cada camada da rede contém um conjunto de neurônios (também chamados de unidades ou nodos) que realizam operações de transformação nos dados de entrada. As camadas podem ser classificadas em três tipos principais:

  1. Camada de Entrada (Input Layer): Recebe os dados de entrada e transmite-os para a primeira camada oculta.
  2. Camadas Ocultas (Hidden Layers): São as camadas intermédias entre a camada de entrada e a camada de saída. Cada neurônio em uma camada oculta realiza transformações nos dados recebidos das camadas anteriores. A existência de múltiplas camadas ocultas permite que a rede aprenda representações cada vez mais complexas.
  3. Camada de Saída (Output Layer): Produz a saída final da rede após processar os dados através das camadas ocultas. A arquitetura da camada de saída depende da tarefa que a rede está destinada a realizar (por exemplo, uma única unidade para regressão ou várias unidades para classificação multiclasse).

O treinamento de DNNs geralmente envolve o uso do algoritmo de backpropagation, que consiste na propagação do gradiente durante a fase de treinamento para ajustar os pesos da rede de acordo com um critério de desempenho (como a minimização da função de custo). A capacidade de treinar redes profundas foi impulsionada por avanços em algoritmos de otimização, funções de ativação eficazes e inicializações adequadas de pesos.

A popularização das DNNs contribuiu significativamente para avanços em campos como visão computacional, processamento de linguagem natural, reconhecimento de voz e muito mais. Exemplos notáveis de arquiteturas de DNNs incluem Redes Neurais Convolucionais (CNNs) para visão computacional e Redes Neurais Recorrentes (RNNs) para processamento de sequências temporais.

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt

# Transformações para normalizar as imagens do MNIST
transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,))])

# Carregando os conjuntos de treino e teste do MNIST
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)

# Criando DataLoader para os conjuntos de treino e teste
batch_size = 64
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

# Visualizando uma imagem do MNIST
sample_image, sample_label = next(iter(train_loader))
plt.imshow(sample_image[0][0], cmap='gray')
plt.title(f'Label: {sample_label[0]}')
plt.show()

# Definindo a arquitetura da DNN com mais camadas ocultas
class DeepNeuralNetwork(nn.Module):
def __init__(self, input_size, hidden_size1, hidden_size2, output_size):
super(DeepNeuralNetwork, self).__init__()
# Camadas ocultas hidden_size1 e hidden_size2
self.fc1 = nn.Linear(input_size, hidden_size1)
self.relu1 = nn.ReLU()
self.fc2 = nn.Linear(hidden_size1, hidden_size2)
self.relu2 = nn.ReLU()
# Camada de saída output_size
self.fc3 = nn.Linear(hidden_size2, output_size)
self.softmax = nn.Softmax(dim=1)

def forward(self, x):
# Camadas ocultas
x = self.relu1(self.fc1(x))
x = self.relu2(self.fc2(x))
# Camada de saída
x = self.softmax(self.fc3(x))
return x

# Instanciando o modelo com mais camadas ocultas
input_size = 28 * 28 # Tamanho da imagem MNIST
hidden_size1 = 128 # Número de neurônios na primeira camada oculta
hidden_size2 = 64 # Número de neurônios na segunda camada oculta
output_size = 10 # Número de classes no MNIST (dígitos de 0 a 9)

model = DeepNeuralNetwork(input_size, hidden_size1, hidden_size2, output_size)

# Definindo a função de perda e o otimizador
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

# Treinando o modelo
num_epochs = 10

for epoch in range(num_epochs):
for batch_images, batch_labels in train_loader:
# Achatando as imagens para um vetor
batch_images = batch_images.view(-1, 28 * 28)

# Forward pass
outputs = model(batch_images)

# Calculando a perda
loss = criterion(outputs, batch_labels)

# Backward pass e otimização
optimizer.zero_grad()
loss.backward()
optimizer.step()

print(f'Época [{epoch + 1}/{num_epochs}], Perda: {loss.item():.4f}')

# Avaliando o modelo no conjunto de teste
model.eval() # Modo de avaliação
correct = 0
total = 0

with torch.no_grad():
for batch_images, batch_labels in test_loader:
batch_images = batch_images.view(-1, 28 * 28)
outputs = model(batch_images)
_, predicted = torch.max(outputs.data, 1)
total += batch_labels.size(0)
correct += (predicted == batch_labels).sum().item()

accuracy = correct / total
print(f'Acurácia do modelo no conjunto de teste: {accuracy:.4f}')

# Saída
#Época [1/10], Perda: 1.5257
#Época [2/10], Perda: 1.4962
#Época [3/10], Perda: 1.5844
#Época [4/10], Perda: 1.5775
#Época [5/10], Perda: 1.5120
#Época [6/10], Perda: 1.5854
#Época [7/10], Perda: 1.4931
#Época [8/10], Perda: 1.5444
#Época [9/10], Perda: 1.4860
#Época [10/10], Perda: 1.4744
#Acurácia do modelo no conjunto de teste: 0.9569

nn.ModuleList()

O nn.ModuleList é uma classe no PyTorch que permite criar uma lista de módulos (camadas) em um modelo. Isso é útil quando você deseja criar uma arquitetura de rede com várias camadas, e o número de camadas pode variar dinamicamente. O nn.ModuleList facilita a gestão de uma sequência de camadas em seu modelo.

import torch
import torch.nn as nn

class DynamicNet(nn.Module):
def __init__(self, input_size, hidden_sizes, output_size):
super(DynamicNet, self).__init__()

# Lista de camadas ocultas
self.hidden_layers = nn.ModuleList([
nn.Linear(input_size, hidden_sizes[0]),
nn.ReLU()])

# Loop para adicionar as demais camadas internas
for i in range(1, len(hidden_sizes)):
self.hidden_layers.extend([
nn.Linear(hidden_sizes[i - 1], hidden_sizes[i]),
nn.ReLU()])

# Camada de saída
self.output_layer = nn.Linear(hidden_sizes[-1], output_size)

def forward(self, x):
for layer in self.hidden_layers:
x = layer(x)
output = self.output_layer(x)
return output

# Exemplo de uso
input_size = 10
hidden_sizes = [32, 64, 128]
output_size = 1

model = DynamicNet(input_size, hidden_sizes, output_size)
print(model)

# Saída
#DynamicNet(
# (hidden_layers): ModuleList(
# (0): Linear(in_features=10, out_features=32, bias=True)
# (1): ReLU()
# (2): Linear(in_features=32, out_features=64, bias=True)
# (3): ReLU()
# (4): Linear(in_features=64, out_features=128, bias=True)
# (5): ReLU()
# )
# (output_layer): Linear(in_features=128, out_features=1, bias=True)
#)

Ao usar nn.ModuleList, estamos garantindo que ambas as camadas (a camada linear e a função de ativação ReLU) sejam tratadas como parte do modelo. Isso é fundamental para que o PyTorch rastreie e otimize os parâmetros durante o treinamento, além de facilitar a manipulação de várias camadas em uma rede neural dinâmica. Essa abordagem é especialmente útil quando se cria redes com arquiteturas variáveis.

for i in range(1, len(hidden_sizes)):
self.hidden_layers.extend([
nn.Linear(hidden_sizes[i - 1], hidden_sizes[i]),
nn.ReLU()
])

Este loop é responsável por criar uma sequência de camadas ocultas na rede neural.

  1. for i in range(1, len(hidden_sizes)):: O loop percorre os elementos da lista hidden_sizes a partir do segundo elemento (índice 1). O motivo de começar do segundo elemento é porque estamos criando conexões entre camadas adjacentes.
  2. nn.Linear(hidden_sizes[i - 1], hidden_sizes[i]): Aqui, estamos criando uma camada linear (totalmente conectada) que conecta a camada oculta anterior (com hidden_sizes[i - 1] neurônios) à próxima camada oculta (com hidden_sizes[i] neurônios). Isso permite que a rede aprenda representações mais complexas à medida que avança nas camadas ocultas.
  3. nn.ReLU(): Em seguida, adicionamos uma camada de ativação ReLU. A função de ativação ReLU (Rectified Linear Unit) introduz não-linearidades na rede, permitindo que ela aprenda padrões mais complexos e não lineares nos dados.
  4. self.hidden_layers.extend([...]): Aqui, usamos o método extend de nn.ModuleList para adicionar as camadas criadas ao final da lista de camadas ocultas. O método extend é usado porque queremos adicionar múltiplos módulos de uma vez.

Ao usar nn.ModuleList, o PyTorch rastreia automaticamente os parâmetros dessas camadas, tornando-os parte do modelo. Isso é crucial para garantir que a retropropagação e a atualização dos pesos ocorram corretamente durante o treinamento.

Ao criar seu modelo, você pode ajustar o tamanho da lista hidden_sizes para adicionar ou remover camadas ocultas conforme necessário.

Funções de Ativação

As funções de ativação são elementos-chave em redes neurais, adicionando não-linearidades às saídas dos neurônios. Elas são responsáveis por introduzir complexidade e capacidade de aprendizado não linear aos modelos, permitindo que as redes neurais aproximem funções mais complexas. Algumas das funções de ativação mais comuns:

Sigmoide:

  • Fórmula: σ(x)= 1 / 1+e ^-x
  • Intervalo de Saída: (0, 1)
  • Uso: Originalmente usada em problemas de classificação binária, mas tem caído em desuso em camadas internas de redes profundas devido ao problema do “vanishing gradient”.

Tangente Hiperbólica (tanh):

  • Fórmula: tanh⁡(x)= e^x-e^-z / e^x + e^-z
  • Intervalo de Saída: (-1, 1)
  • Uso: Similar à sigmoide, mas com saída variando de -1 a 1. Pode ser usada em camadas internas.

ReLU (Rectified Linear Unit):

  • Fórmula: ReLU(x)=max(0,x)
  • Intervalo de Saída: [0, +∞)
  • Uso: Muito popular devido à sua simplicidade e eficácia. Ajuda a mitigar o problema do “vanishing gradient”, mas pode levar a problemas de “dying ReLU” quando os neurônios ficam inativos.

Leaky ReLU:

  • Fórmula: Leaky ReLU(x)=max(αx,x) onde α é uma constante pequena, geralmente próxima de 0.
  • Intervalo de Saída: (-∞, +∞)
  • Uso: Introduz um leve componente linear para evitar o problema de “dying ReLU”.

Função de Unidade Linear Exponencial (ELU):

  • Fórmula:
onde α é uma constante positiva
  • Intervalo de Saída: (-∞, +∞)
  • Uso: Similar ao Leaky ReLU, com uma suavização nas regiões negativas.

Softmax:

  • Fórmula: Softmax(x)i= e^zi/∑_j e^zj para cada i na saída.
  • Intervalo de Saída: [0, 1] e a soma de todas as saídas é 1.
  • Uso: Principalmente usado na camada de saída para problemas de classificação multiclasse, onde as saídas representam probabilidades.

Essas são apenas algumas das muitas funções de ativação disponíveis. A escolha da função de ativação depende do problema específico, da arquitetura da rede e da natureza dos dados. Experimentar diferentes funções de ativação pode ser crucial para alcançar o melhor desempenho em uma tarefa específica. Abaixo sintetizamos algumas das funções de ativação existentes.

Tabela de Funções de Ativação
Tabela Comparativa
  • Não Saturante: Indica se a função de ativação é não saturante, ou seja, se ela não satura para valores extremos. “Saturar” refere-se ao comportamento da função quando a entrada ultrapassa um determinado limite, resultando em uma saída constante. Isso significa que, à medida que a entrada aumenta, a saída da função de ativação atinge um valor máximo ou mínimo e permanece constante para entradas subsequentes
  • Rápido: Refere-se à rapidez computacional da função de ativação, indicando se é computacionalmente eficiente.
  • Binária: Relaciona-se com o uso da função de ativação em camadas de saída binárias, como em problemas de classificação binária.
  • Varia entre -1 e 1: Indica se a função de ativação produz saídas que variam entre -1 e 1, sendo útil para dados centrados.
  • Probabilidades: Mostra se a função de ativação é comumente usada para converter saídas em probabilidades, frequentemente usado em problemas de classificação.
  • Evita Morte Neuronal: Indica se a função de ativação possui características que ajudam a evitar a “morte” de neurônios durante o treinamento.
  • Paramétrico: Refere-se a funções de ativação que têm parâmetros que são aprendidos durante o treinamento, como o coeficiente em ReLU paramétrico (PReLU).
  • Suavização Negativa: Indica se a função de ativação tem suavização para valores negativos, como no caso da Exponential Linear Unit (ELU).
  • Auto-Normalizante: Refere-se a funções de ativação que têm a capacidade de auto-normalização, como a Scaled Exponential Linear Unit (SELU).
  • Suave e Diferenciável: Indica se a função de ativação é suave e diferenciável, o que é desejável para facilitar o treinamento com otimizadores que requerem derivadas.

Essas características são importantes ao escolher uma função de ativação, pois diferentes cenários e arquiteturas de rede podem se beneficiar de propriedades específicas de cada função.

Backpropagation

Backpropagation, que significa propagação para trás, é um algoritmo fundamental no treinamento de redes neurais. Ele é utilizado para ajustar os pesos da rede de forma a minimizar a função de custo durante o processo de treinamento supervisionado. A ideia principal do backpropagation é calcular gradientes em relação aos pesos da rede, permitindo a atualização dos pesos de forma a reduzir o erro da rede. Principais passos do algoritmo de backpropagation:

  1. Feedforward (Propagação Direta): Durante a fase de feedforward, a entrada é passada pela rede, camada por camada, até a camada de saída. Cada neurônio realiza uma transformação linear seguida de uma função de ativação.
  2. Cálculo da Função de Custo: A saída da rede é comparada com as saídas reais (rótulos) para calcular a função de custo, que representa o quão longe as previsões da rede estão dos rótulos desejados.
  3. Backpropagation (Propagação para Trás): O objetivo do backpropagation é calcular os gradientes da função de custo em relação aos pesos da rede. Isso é feito iterativamente, começando pela última camada e indo em direção à primeira. Os gradientes são calculados usando a regra da cadeia.
  4. Atualização dos Pesos: Com os gradientes calculados, os pesos da rede são ajustados para minimizar a função de custo. Isso é geralmente feito usando um algoritmo de otimização, como o Gradiente Descendente ou suas variantes.
  5. Iteração: Os passos 1 a 4 são repetidos por várias épocas (ou iterações) até que a rede neural atinja um desempenho aceitável no conjunto de treino.

Os passos acima são os que são codficados no loop de treinamento. É importante observar que o sucesso do backpropagation depende da diferenciabilidade das funções de ativação utilizadas na rede, pois a regra da cadeia e o cálculo dos gradientes dependem dessa propriedade. A diferenciabilidade é uma propriedade fundamental de funções matemáticas que expressa a capacidade de calcular derivadas em cada ponto do seu domínio. Uma função é considerada diferenciável em um determinado ponto se a sua derivada existe nesse ponto.

A regra da cadeia é fundamental para o backpropagation. Se C é a função de custo, a é a saída da rede, e w é um dos pesos da rede, então o gradiente dC/dw pode ser calculado como:

onde z é a entrada ponderada para um neurônio (z=∑^i_=1(wi⋅xi)+b), a é a saída após a aplicação da função de ativação, e C é a função de custo.

O backpropagation é uma técnica poderosa que tornou possível treinar redes neurais profundas. Atualmente, frameworks de aprendizado profundo, como PyTorch e TensorFlow, implementam o backpropagation automaticamente, facilitando sua aplicação em uma variedade de problemas.

O problema do “vanishing gradient”

O problema do “vanishing gradient” (gradiente desaparecendo) é uma questão que pode ocorrer durante o treinamento de redes neurais, especialmente em arquiteturas profundas. Esse problema está relacionado à propagação do gradiente durante a fase de backpropagation, e ocorre quando os gradientes das camadas mais profundas se tornam muito pequenos, aproximando-se de zero. Como resultado, os pesos dessas camadas são ajustados muito lentamente ou quase não são ajustados durante o treinamento. Esse problema pode acontecer nos seguintes casos:

  1. Funções de Ativação Sigmóide e Tangente Hiperbólica: Funções de ativação como a sigmóide e a tangente hiperbólica têm uma feature em que suas derivadas se aproximam de zero para valores extremos. Quando a propagação do gradiente ocorre através de várias camadas, essas derivadas multiplicadas entre si podem resultar em gradientes muito pequenos nas camadas mais profundas.
  2. Inicialização inadequada de pesos: A inicialização inadequada dos pesos da rede também pode contribuir para o problema do vanishing gradient. Se os pesos iniciais forem muito pequenos, os gradientes nas camadas anteriores também serão pequenos, o que pode ser amplificado ao longo das camadas.
  3. Arquitetura da Rede: Em arquiteturas muito profundas, os gradientes podem diminuir exponencialmente à medida que se propagam para trás, tornando-se insignificantes nas camadas mais profundas.
  4. Problema de Longo Prazo (Long-Term Dependency): O vanishing gradient é particularmente preocupante em modelos que precisam capturar dependências a longo prazo, como em redes recorrentes (RNNs). A propagação do gradiente ao longo de muitos passos temporais pode resultar em gradientes que desaparecem ou explodem.

Para mitigar o problema do vanishing gradient, várias técnicas podem ser utilizadas, como:

  • Escolha de Funções de Ativação: O uso de funções de ativação que não sofrem tanto com o problema do vanishing gradient, como a ReLU (Rectified Linear Unit), pode ajudar a mitigar esse problema.
  • Inicialização Adequada de Pesos: Estratégias de inicialização de pesos, como a inicialização de He ou Xavier, podem ajudar a evitar que os pesos comecem muito pequenos.
  • Normalização de Lote (Batch Normalization): A normalização de lote pode ajudar a manter ativações e gradientes em uma escala razoável durante o treinamento, o que pode ajudar a prevenir o vanishing gradient.
  • Uso de Arquiteturas Específicas: Arquiteturas projetadas para mitigar o problema do vanishing gradient, como as conexões residuais em ResNets, podem ser utilizadas.

O vanishing gradient é um desafio significativo em treinamento de redes neurais profundas, e abordá-lo adequadamente é crucial para garantir que a rede possa aprender efetivamente representações complexas dos dados.

Dropout

Dropout é uma técnica de regularização usada em redes neurais para reduzir o overfitting durante o treinamento. O overfitting ocorre quando um modelo se ajusta demais aos dados de treinamento e tem dificuldade em generalizar para novos dados. O dropout é projetado para evitar que unidades específicas em uma rede neural se tornem excessivamente especializadas, melhorando assim a generalização do modelo.

A ideia principal do dropout é “eliminar” aleatoriamente unidades (neurônios) durante o treinamento. Durante cada iteração de treinamento, um conjunto aleatório de unidades é “desligado” (ou “dropado”), o que significa que suas contribuições para a propagação para frente e para trás são temporariamente removidas. Isso simula a ideia de treinar diferentes sub-redes em cada iteração, e o modelo precisa aprender a ser robusto mesmo quando partes aleatórias da rede são desativadas.

A implementação do dropout é bastante simples. Na prática, você adiciona uma camada de dropout após as camadas de ativação em sua rede neural. O dropout é geralmente aplicado apenas durante o treinamento e não durante a inferência ou avaliação.

No PyTorch, você pode usar a camada nn.Dropout para implementar o dropout em sua rede. Aqui está um exemplo de como adicionar dropout a uma rede em PyTorch:

import torch
import torch.nn as nn

class NeuralNetworkWithDropout(nn.Module):
def __init__(self, input_size, hidden_size, output_size, dropout_rate=0.5):
super(NeuralNetworkWithDropout, self).__init__()

self.fc1 = nn.Linear(input_size, hidden_size)
self.relu1 = nn.ReLU()
self.dropout = nn.Dropout(p=dropout_rate) # Adicionando a camada de dropout
self.fc2 = nn.Linear(hidden_size, output_size)

def forward(self, x):
x = self.fc1(x)
x = self.relu1(x)
x = self.dropout(x) # Aplicando o dropout após a ativação
x = self.fc2(x)
return x

Neste exemplo, a camada nn.Dropout é adicionada após a primeira camada de ativação (nn.ReLU). O parâmetro p em nn.Dropout representa a probabilidade de desativar uma unidade durante o treinamento. Um valor típico para dropout_rate é 0.5, o que significa que, em média, metade das unidades será desativada durante cada iteração de treinamento.

Dropout é uma ferramenta eficaz para melhorar a generalização de modelos de redes neurais e é comumente usado em diversas arquiteturas de redes neurais.

Pesos e Inicialização da Rede Neural

A inicialização adequada dos pesos em uma rede neural é crucial para o sucesso do treinamento do modelo. Peso inicializado de maneira inadequada pode levar a problemas como convergência lenta, convergência para mínimos locais ou até mesmo a falta de convergência. Algumas técnicas comuns de inicialização de pesos usadas em redes neurais são:

  • Inicialização Aleatória: Inicializar os pesos com valores aleatórios é uma abordagem comum, onde os pesos são inicializados com números retirados de uma distribuição uniforme ou normal. No entanto, essa abordagem pode levar a problemas, especialmente em redes profundas, onde o gradiente pode diminuir ou explodir durante a retropropagação.
  • Inicialização de Xavier/Glorot: Proposta por Xavier Glorot, esta abordagem ajusta a escala dos pesos com base no número de unidades nas camadas de entrada e saída. Os pesos são inicializados a partir de uma distribuição normal com média zero e variância 2 / número de entradas + número de saídas ou uma distribuição uniforme entre −a e a, onde a é a raíz de 6 / número de entradas + numero de saidas.
  • Inicialização de He: Semelhante à inicialização de Xavier, mas ajusta a escala de acordo com apenas o número de unidades na camada de entrada. Os pesos são inicializados a partir de uma distribuição normal com média zero e variância 2/número de entradas ou uma distribuição uniforme entre −a e a, onde a é 6/número de entradas.
  • Inicialização de LeCun: Similar à inicialização de Xavier, mas utiliza uma constante diferente para ajustar a escala dos pesos. Os pesos são inicializados a partir de uma distribuição normal com média zero e variância 1/número de entradas ou uma distribuição uniforme entre −a e a, onde a é 3/número de entradas.
  • Inicialização de Orthogonal: Inicializa os pesos de tal forma que a matriz de pesos é ortogonal. Essa abordagem é útil para arquiteturas que envolvem recorrência.

Em PyTorch, muitas dessas inicializações podem ser facilmente implementadas usando as funções de inicialização fornecidas no módulo torch.nn.init. Por exemplo, você pode usar torch.nn.init.xavier_uniform_ ou torch.nn.init.kaiming_uniform_ para inicializações de Xavier ou He, respectivamente.

A escolha da inicialização de pesos depende da arquitetura da rede, do tipo de função de ativação utilizada e do contexto específico do problema. Experimentar diferentes técnicas de inicialização pode ser benéfico para encontrar a que melhor se adapta ao seu modelo.

import torch
import torch.nn as nn
import torch.nn.init as init

# Definindo uma rede neural simples com inicialização de Xavier
class SimpleNet(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(SimpleNet, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_size, output_size)

# Inicialização de Xavier para as camadas lineares
init.xavier_uniform_(self.fc1.weight)
init.xavier_uniform_(self.fc2.weight)

def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x

# Parâmetros da rede
input_size = 10
hidden_size = 20
output_size = 5

# Instanciando a rede neural
model = SimpleNet(input_size, hidden_size, output_size)

# Exibindo a arquitetura da rede e os pesos
print(model)
print("\nPesos após a inicialização de Xavier:")
for name, param in model.named_parameters():
if 'weight' in name:
print(name, param.data)

Além disso podemos inicializar todos os pesos de uma rede sem passar camada por camada utilizando um loop sobre todos os parâmetros da rede.

import torch
import torch.nn as nn
import torch.nn.init as init
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

# Função para inicializar todos os pesos da rede usando Xavier
def initialize_weights(model):
for param in model.parameters():
if len(param.shape) > 1: # Inicializa apenas os pesos (não bias) das camadas lineares
init.xavier_uniform_(param)

# Definindo uma rede neural simples para regressão
class RegressionNet(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(RegressionNet, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_size, output_size)

def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x


# Instanciando a rede neural
model = RegressionNet(input_size, hidden_size, output_size)

# Inicializando todos os pesos da rede usando a função definida
initialize_weights(model)

Neste exemplo, a função initialize_weights percorre todos os parâmetros da rede e inicializa apenas os pesos (não bias) das camadas lineares usando a inicialização de Xavier. A função é chamada antes do treinamento para garantir que todos os pesos estejam inicializados corretamente.

Conclusão

O Pytorch é uma ferramenta essencial para criação de redes neurais e que possui diversas vantagens, como facilidade de prototipagem, suporte nativo a GPUs, sua integração com outras bibliotecas como numpy e scipy e também sua comunidade ativa. Com ele, podemos resolver problemas de diversas áreas e vários tipos de tarefas, como de visão computacional, NLP, Áudio e Processamento de sinais, Jogos e Robótica. Neste artigo, abordamos desde os fundamentos de machine learning com Pytorch, passando em diversos tópicos desde tensores ao treinamento, e validação de modelos e também em redes neurais rasas e profundas, assim como vários conceitos importantes como Funções de ativação, backpropagation, vanishing gradient e como incializar os pesos da rede.

Me siga clicando aqui para acompanhar mais blog posts sobre ciência de dados, machine learning, NLP e demais temas.

--

--

Alysson Guimarães
Data Hackers

Data Scientist. MBA Competitive Intelligence, Bachelor of Business Administration.