Escrevendo LeNet5 derazo em PyTorch

Introdução

Neste artigo, vamos construir uma das primeiras redes neurales convolucionais já apresentadas, a LeNet5. Estamos a construir esta CNN de raiz em PyTorch e também veremos como ela se comporta the base de dados do mundo real.

Começaremos explorando a arquitetura de LeNet5. Em seguida, carregaremos e analisaremos nossa base de dados, o MNIST, usando a classe fornecida de torchvision. Usando PyTorch, construiremos nossa LeNet5 de raiz e a treinaremos em nossos dados. Finalmente, veremos o desempenho do modelo em dados de teste não vistos.

Pré-requisitos

O conhecimento de redes neurais será útil para entender este artigo. Isso significa estar familiarizado com as diferentes camadas de redes neurais (camada de entrada, camadas ocultas, camada de saída), funções de ativação, algoritmos de otimização (variações do descida de gradiente), funções de perda, etc. Adicionalmente, a familiaridade com a sintaxe de Python e com a biblioteca PyTorch é essencial para entender os trechos de código apresentados neste artigo.

Um entendimento das CNNs é também recomendado. Isso inclui o conhecimento das camadas convolucionais, das camadas de pooling, e seu papel na extração de recursos de dados de entrada. O entendimento de conceitos como stride, padding, e o impacto do tamanho do kernel/filtro é benefício.

LeNet5

LeNet5 foi usado para a reconhecimento de caracteres escritos à mão e foi proposto por Yann LeCun e outros em 1998 com o artigo,Gradient-Based Learning Applied to Document Recognition.

Vamos entender a arquitetura de LeNet5 como mostrado na figura abaixo:

Como o nome indica, LeNet5 tem 5 camadas com duas camadas convolucionais e três camadas totalmente conectadas. Vamos começar com a entrada. LeNet5 aceita como entrada uma imagem em escala de cinza de 32×32, indicando que a arquitetura não é adequada para imagens RGB (múltiplos canais). Portanto, a imagem de entrada deve conter apenas um canal. Depois disto, começamos com nossas camadas convolucionais

A primeira camada convolucional tem um tamanho de filtro de 5×5 com 6 filtros desse tamanho. Isto reduzirá a largura e a altura da imagem enquanto aumenta a profundidade (número de canais). A saída seria 28x28x6. Depois disso, a pooling é aplicada para reduzir o mapa de recursos em metade, isto é, 14x14x6. O mesmo tamanho de filtro (5×5) com 16 filtros é agora aplicado ao resultado seguido de uma camada de pooling. Isto reduz o mapa de recursos de saída para 5x5x16.

Após isso, uma camada convolucional de tamanho 5×5 com 120 filtros é aplicada para expandir o mapa de recursos para 120 valores. Então vem a primeira camada totalmente conectada, com 84 neurônios. Finalmente, temos a camada de saída, que tem 10 neurônios de saída, pois os dados MNIST têm 10 classes para cada um dos 10 dígitos numéricos representados.


Carregamento de Dados

Vamos começar carregando e analisando os dados. Vamos usar o conjunto de dados MNIST. O conjunto de dados MNIST contém imagens de dígitos numéricos escritos à mão. As imagens são em preto e branco, todas com um tamanho de 28×28, e é composto por 60.000 imagens de treinamento e 10.000 imagens de teste.

Você pode ver alguns exemplos das imagens abaixo:

Importando as Bibliotecas

Vamos começar importando as bibliotecas necessárias e definindo algumas variáveis (hipérparametros e device também são detalhados para ajudar o pacote a decidir se treinar no GPU ou CPU):

# Carregue as bibliotecas relevantes e aliasie onde apropriado
import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms

# Defina variáveis relevantes para a tarefa de ML
batch_size = 64
num_classes = 10
learning_rate = 0.001
num_epochs = 10

# O dispositivo determinará se a execução de treinamento deve ser feita no GPU ou CPU.
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

Carregando e Transformando os Dados

Usando o torchvision, carregaremos o conjunto de dados, pois isso nos permitirá realizar qualquer etapa de pré-processamento com facilidade.

#Carregando o conjunto de dados e pré-processamento
train_dataset = torchvision.datasets.MNIST(root = './data',
                                           train = True,
                                           transform = transforms.Compose([
                                                  transforms.Resize((32,32)),
                                                  transforms.ToTensor(),
                                                  transforms.Normalize(mean = (0.1307,), std = (0.3081,))]),
                                           download = True)


test_dataset = torchvision.datasets.MNIST(root = './data',
                                          train = False,
                                          transform = transforms.Compose([
                                                  transforms.Resize((32,32)),
                                                  transforms.ToTensor(),
                                                  transforms.Normalize(mean = (0.1325,), std = (0.3105,))]),
                                          download=True)


train_loader = torch.utils.data.DataLoader(dataset = train_dataset,
                                           batch_size = batch_size,
                                           shuffle = True)


test_loader = torch.utils.data.DataLoader(dataset = test_dataset,
                                           batch_size = batch_size,
                                           shuffle = True)

Vamos entender o código:

  • Primeiro, os dados MNIST não podem ser usados como são para a arquitetura LeNet5. A arquitetura LeNet5 aceita a entrada como 32×32 e as imagens MNIST são 28×28. Podemos corrigir isso redimensionando as imagens, normalizando-as usando a média e a variância pré-calculadas (disponíveis online) e, finalmente, armazenando-as como tensores.
  • Nós definimos download=True caso os dados ainda não estejam baixados.
  • A seguir, nós usamos os carregadores de dados. Isto pode não afetar o desempenho no caso de um conjunto de dados pequeno como o MNIST, mas pode realmente impedir o desempenho no caso de grandes conjuntos de dados e é normalmente considerada uma boa prática. Os carregadores de dados nos permitem iterar pelos dados em lotes, e o dado é carregado enquanto iteramos e não todos de uma vez no início.
  • Nós especificamos o tamanho do lote e embaralhamos o conjunto de dados ao carregar para que cada lote tenha alguma variação nos tipos de rótulos que tem. Isto aumentará a eficácia de nossa modelo final.

LeNet5 do zero

Vamos primeiro olhar para o código:

# Definindo a rede neural convolucional
class LeNet5(nn.Module):
    def __init__(self, num_classes):
        super(ConvNeuralNet, self).__init__()
        self.layer1 = nn.Sequential(
            nn.Conv2d(1, 6, kernel_size=5, stride=1, padding=0),
            nn.BatchNorm2d(6),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer2 = nn.Sequential(
            nn.Conv2d(6, 16, kernel_size=5, stride=1, padding=0),
            nn.BatchNorm2d(16),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.fc = nn.Linear(400, 120)
        self.relu = nn.ReLU()
        self.fc1 = nn.Linear(120, 84)
        self.relu1 = nn.ReLU()
        self.fc2 = nn.Linear(84, num_classes)
        
    def forward(self, x):
        out = self.layer1(x)
        out = self.layer2(out)
        out = out.reshape(out.size(0), -1)
        out = self.fc(out)
        out = self.relu(out)
        out = self.fc1(out)
        out = self.relu1(out)
        out = self.fc2(out)
        return out

Definindo o Modelo LeNet5

Eu explicarei o código linearmente:

  • Em PyTorch, nós definimos uma rede neural criando uma classe que herda de nn.Module pois ela contém muitos dos métodos que precisaremos de utilizar.
  • Existem duas etapas principais depois disso. A primeira é inicializar as camadas que vamos usar em nossa CNN dentro de __init__, e a outra é definir a sequência em que essas camadas processarão a imagem. Isto é definido dentro da função forward.
  • Para a arquitetura mesma, nós primeiro definimos as camadas convolucionais usando a função nn.Conv2D com o tamanho de kernel apropriado e os canais de entrada/saída. Nós também aplicamos o max pooling usando a função nn.MaxPool2D. O que é legal em PyTorch é que nós podemos combinar a camada convolucional, função de ativação e max pooling em uma única camada (elas serão aplicadas separadamente, mas ajuda com a organização) usando a função nn.Sequential.
  • Então, definimos as camadas totalmente conectadas. Observe que nós podemos usar nn.Sequential aqui também e combinar as funções de ativação e as camadas lineares, mas eu quis mostrar que qualquer uma é possível.
  • Finalmente, nossa última camada sai com 10 neurônios, que são nossas predições finais para os dígitos.

Configurando Hiperparâmetros

Antes de treinar, precisamos configurar alguns hiperparâmetros, como a função de perda e o otimizador a ser usado.

model = LeNet5(num_classes).to(device)

#Configurando a função de perda
cost = nn.CrossEntropyLoss()

#Configurando o otimizador com os parâmetros do modelo e a taxa de aprendizagem
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

#isto está definido para imprimir quantos passos restam durante o treinamento
total_step = len(train_loader)

Começamos inicializando nosso modelo usando o número de classes como argumento, o que neste caso é 10. Então, definimos nossa função de custo como perda de cross-entropy e o otimizador como Adam. Existem muitas opções para estes, mas essas tendem a dar bons resultados com o modelo e os dados dados. Finalmente, definimos total_step para manter melhor controle dos passos durante o treinamento.


Treino do Modelo

Agora, podemos treinar nosso modelo:

total_step = len(train_loader)
for epoch in range(num_epochs):
    for i, (images, labels) in enumerate(train_loader):  
        images = images.to(device)
        labels = labels.to(device)
        
        #Passo de frente
        outputs = model(images)
        loss = cost(outputs, labels)
        #Retrocesso e otimização
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        if (i+1) % 400 == 0:
            print ('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}' 

.format(epoch+1, num_epochs, i+1, total_step, loss.item()))

Vamos ver o que o código faz:

  • Começamos iterando pelo número de épocas, e depois pelas batches em nosso conjunto de dados de treinamento.
  • Nós convertemos as imagens e as etiquetas de acordo com o dispositivo que estamos usando, isto é, GPU ou CPU.
  • Na passagem para frente, fazemos previsões usando nosso modelo e calculamos a perda com base nestas previsões e nas nossas etiquetas reais.
  • A seguir, fazemos a passagem para trás onde atualmente as nossas pesos para melhorar o nosso modelo
  • Então, definimos as gradientes para zero antes de cada atualização usando a função optimizer.zero_grad().
  • Em seguida, calculamos os novos gradientes usando a função loss.backward().
  • E finalmente, atualizamos os pesos com a função optimizer.step().

Podemos ver a saída da seguinte maneira:

Como podemos ver, a perda está decrescendo com cada época, o que mostra que o nosso modelo está realmente aprendendo. Note que esta perda é no conjunto de treinamento e, se a perda for muito pequena (como é no nosso caso), isso pode indicar sobreajuste. Existem muitas maneiras de resolver esse problema, como regularização, data augmentation e assim por diante, mas não entraremos nisto no artigo. Vamos agora testar o nosso modelo para ver como ele performa.


Teste do Modelo

Vamos agora testar o nosso modelo:

# Testar o modelo
# Na fase de teste, não precisamos calcular gradientes (para eficiência de memória)
  
with torch.no_grad():
correct = 0
total = 0
for images, labels in test_loader:
        images = images.to(device)
        labels = labels.to(device)
        outputs = model(images)
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()
    print('Accuracy of the network on the 10000 test images: {} %'.format(100 * correct / total))

Como podem ver, o código não é muito diferente do utilizado para treinamento. A única diferença é que neste caso, não estamos calculando gradientes (usando with torch.no_grad()), e também não estamos calculando a perda, já que aqui não precisamos de backpropagation. Para calcular a acurácia resultante do modelo, podemos simplesmente contar o número total de predições corretas dividido pelo número total de imagens.

Usando este modelo, obtemos cerca de 98,8% de acurácia, o que é muito bom:

Acurácia de Teste

Observe que o conjunto de dados MNIST é bastante básico e pequeno, comparado aos padrões de hoje, e é difícil de obter resultados semelhantes para outros conjuntos de dados. No entanto, é um bom ponto de partida quando se está aprendendo a trabalhar com deep learning e CNNs.


Conclusão

Vamos agora concluir o que fizemos neste artigo:

  • Começamos aprendendo a arquitetura de LeNet5 e os diferentes tipos de camadas que ela contém.
  • Em seguida, exploramos o conjunto de dados MNIST e carregamos os dados usando torchvision.
  • Depois, construímos a LeNet5 de raiz, definindo parâmetros de hiper-aprendizagem para o modelo.
  • Finalmente, treinamos e testamos o nosso modelo no conjunto de dados MNIST, e o modelo pareceu se sair bem no conjunto de testes.

Trabalho Futuro

Embora isso seja uma ótima introdução ao aprendizado profundo em PyTorch, você pode estender este trabalho para aprender mais também:

  • Você pode tentar usar diferentes conjuntos de dados, mas para este modelo você precisará de conjuntos de dados em escala de cinza. Um desses conjuntos de dados é o FashionMNIST.
  • Você pode experimentar com diferentes parâmetros e ver a melhor combinação deles para o modelo.
  • Finalmente, você pode tentar adicionar ou remover camadas do conjunto de dados para ver o impacto delas na capacidade do modelo.

Source:
https://www.digitalocean.com/community/tutorials/writing-lenet5-from-scratch-in-python