Escrevendo VGG de raiz em PyTorch

Continuando minha série sobre a construção de redes neurais convolucionais clássicas que revolucionaram o campo da visão computacional nos últimos 1-2 décadas, vamos construir agora a rede VGG, uma rede convolucional muito profunda, a partir do zero usando PyTorch. Você pode ver os artigos anteriores da série no meu perfil, principalmente LeNet5 e AlexNet.

Como antes, vamos olhar para a arquitetura e intuição por trás da VGG e como os resultados eram na época. Em seguida, exploraremos o nosso conjunto de dados, CIFAR100, e carregaremos ele no nosso programa usando código eficiente em memória. Em seguida, implementaremos a VGG16 (o número refere-se ao número de camadas, existem duas versões básicas, VGG16 e VGG19) a partir do zero usando PyTorch e então treinaremos-la em nosso conjunto de dados, além de avaliá-la no nosso conjunto de teste para ver como ela se comporta em dados não vistos.


VGG

Com base no trabalho de AlexNet, o VGG se concentra em outro aspecto crucial das Redes Convolucionais Neurais (CNNs), a profundidade. Foi desenvolvido por Simonyan e Zisserman. Normalmente consiste em 16 camadas convolucionais, mas pode ser estendido a 19 camadas (portanto, as duas versões, VGG-16 e VGG-19). Todas as camadas convolucionais consistem em filtros de 3×3. Você pode ler mais sobre a rede no artigo oficial aqui

arquitetura VGG16. Fonte


Carregamento de Dados

Conjunto de Dados

Antes de construir o modelo, uma das coisas mais importantes em qualquer projeto de Aprendizado de Máquina é carregar, analisar e pré-processar o conjunto de dados. Neste artigo, nós vamos usar o conjunto de dados CIFAR-100. Este conjunto de dados é semelhante ao CIFAR-10, exceto que ele tem 100 classes com 600 imagens cada. Há 500 imagens de treinamento e 100 imagens de teste por classe. As 100 classes no CIFAR-100 são agrupadas em 20 superclasses. Cada imagem vem com um “fine” label (a classe para qual ela pertence) e um “coarse” label (a superclasse para qual ela pertence). Nós vamos usar o “fine” label aqui. Aqui está a lista de classes no CIFAR-100:


Lista de Classes para o conjunto de dados CIFAR-100

Importando as bibliotecas

Nós vamos trabalhar principalmente com torch (usado para construir o modelo e treiná-lo), torchvision (para carregar/processar dados, contém conjuntos de dados e métodos para processar esses conjuntos de dados em visão computacional) e numpy (para manipulação matemática). Nós também vamos definir uma variável device para que o programa possa usar o GPU, se disponível.

import numpy as np
import torch
import torch.nn as nn
from torchvision import datasets
from torchvision import transforms
from torch.utils.data.sampler import SubsetRandomSampler


# Configuração do Dispositivo
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

Carregando os Dados

torchvision é uma biblioteca que fornece acesso fácil a montes de conjuntos de dados de visão computacional e métodos para pré-processar esses conjuntos de dados de uma maneira fácil e intuitiva

  • Nós definimos uma função data_loader que retorna seja dados de treinamento/validação ou dados de teste dependendo dos argumentos
  • Começamos definindo a variável normalize com as médias e desvios padrões de cada canal (vermelho, verde e azul) no conjunto de dados. Esses valores podem ser calculados manualmente, mas estão também disponíveis online. Isso é usado na variável transform onde redimensionamos os dados, convertemos-os em tensores e depois normalizamos-os
  • Se o argumento test for verdadeiro, simplesmente carregamos a divisão de teste do conjunto de dados e o retornamos usando carregadores de dados (explicado abaixo)
  • Caso o test seja falso (comportamento padrão também), carregamos a divisão de treinamento do conjunto de dados e dividimos aleatoriamente em treinamento e validação (0.9:0.1)
  • Finalmente, usamos carregadores de dados. Isto pode não afetar o desempenho no caso de um pequeno conjunto de dados como o CIFAR100, mas pode realmente impedir o desempenho no caso de grandes conjuntos de dados e é considerado常规mente uma boa prática. Carregadores de dados permitem que iteramos pelos dados em lotes, e o dado é carregado enquanto iteramos e não é carregado todo de uma só vez no início do seu RAM.
def data_loader(data_dir,
                batch_size,
                random_seed=42,
                valid_size=0.1,
                shuffle=True,
                test=False):
  
    normalize = transforms.Normalize(
        mean=[0.4914, 0.4822, 0.4465],
        std=[0.2023, 0.1994, 0.2010],
    )

    # define transformações
    transform = transforms.Compose([
            transforms.Resize((227,227)),
            transforms.ToTensor(),
            normalize,
    ])

    if test:
        dataset = datasets.CIFAR100(
          root=data_dir, train=False,
          download=True, transform=transform,
        )

        data_loader = torch.utils.data.DataLoader(
            dataset, batch_size=batch_size, shuffle=shuffle
        )

        return data_loader

    # carregar o conjunto de dados
    train_dataset = datasets.CIFAR100(
        root=data_dir, train=True,
        download=True, transform=transform,
    )

    valid_dataset = datasets.CIFAR10(
        root=data_dir, train=True,
        download=True, transform=transform,
    )

    num_train = len(train_dataset)
    indices = list(range(num_train))
    split = int(np.floor(valid_size * num_train))

    if shuffle:
        np.random.seed(random_seed)
        np.random.shuffle(indices)

    train_idx, valid_idx = indices[split:], indices[:split]
    train_sampler = SubsetRandomSampler(train_idx)
    valid_sampler = SubsetRandomSampler(valid_idx)

    train_loader = torch.utils.data.DataLoader(
        train_dataset, batch_size=batch_size, sampler=train_sampler)
 
    valid_loader = torch.utils.data.DataLoader(
        valid_dataset, batch_size=batch_size, sampler=valid_sampler)

    return (train_loader, valid_loader)


# Conjunto de dados CIFAR100
train_loader, valid_loader = data_loader(data_dir='./data',
                                         batch_size=64)

test_loader = data_loader(data_dir='./data',
                              batch_size=64,
                              test=True)

VGG16 a partir do zero

Para construir o modelo a partir do zero, precisamos primeiro entender como funcionam as definições de modelos em torch e os diferentes tipos de camadas que vamos usar aqui:

  • Todos os modelos personalizados precisam herdar da classe nn.Module porque ela fornece algumas funcionalidades básicas que ajudam o modelo a treinar.
  • Em segundo lugar, existem duas coisas principais que precisamos fazer. Primeiro, definir as diferentes camadas do nosso modelo dentro da função __init__ e a sequência em que essas camadas serão executadas no input na função forward

Vamos agora definir os diferentes tipos de camadas que estamos usando aqui:

  • nn.Conv2d: Estas são as camadas de convolução que aceitam o número de canais de entrada e saída como argumentos, juntamente com o tamanho do kernel para o filtro. Também aceita quaisquer strides ou padding se você quiser aplicar esses
  • nn.BatchNorm2d: Isso aplica normalização em lote à saída da camada de convolução
  • nn.ReLU: Esta é a ativação aplicada a várias saídas na rede
  • nn.MaxPool2d : Isto aplica o pooling máximo na saída com o tamanho do kernel dado
  • nn.Dropout: Isso é usado para aplicar dropout na saída com uma probabilidade dada
  • nn.Linear: Essencialmente, isto é uma camada totalmente conectada
  • nn.Sequential: Isto não é tecnicamente um tipo de camada, mas ajuda a combinar diferentes operações que fazem parte do mesmo passo

Usando este conhecimento, agora podemos construir o nosso modelo VGG16 usando a arquitetura descrita no artigo:

class VGG16(nn.Module):
    def __init__(self, num_classes=10):
        super(VGG16, self).__init__()
        self.layer1 = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU())
        self.layer2 = nn.Sequential(
            nn.Conv2d(64, 64, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(), 
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer3 = nn.Sequential(
            nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU())
        self.layer4 = nn.Sequential(
            nn.Conv2d(128, 128, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer5 = nn.Sequential(
            nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU())
        self.layer6 = nn.Sequential(
            nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU())
        self.layer7 = nn.Sequential(
            nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer8 = nn.Sequential(
            nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU())
        self.layer9 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU())
        self.layer10 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer11 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU())
        self.layer12 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU())
        self.layer13 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.fc = nn.Sequential(
            nn.Dropout(0.5),
            nn.Linear(7*7*512, 4096),
            nn.ReLU())
        self.fc1 = nn.Sequential(
            nn.Dropout(0.5),
            nn.Linear(4096, 4096),
            nn.ReLU())
        self.fc2= nn.Sequential(
            nn.Linear(4096, num_classes))
        
    def forward(self, x):
        out = self.layer1(x)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)
        out = self.layer5(out)
        out = self.layer6(out)
        out = self.layer7(out)
        out = self.layer8(out)
        out = self.layer9(out)
        out = self.layer10(out)
        out = self.layer11(out)
        out = self.layer12(out)
        out = self.layer13(out)
        out = out.reshape(out.size(0), -1)
        out = self.fc(out)
        out = self.fc1(out)
        out = self.fc2(out)
        return out

VGG16 de raiz


Hiperparâmetros

Um dos componentes importantes de qualquer projeto de machine learning ou aprendizado profundo é otimizar os hiperparâmetros. Aqui, nós não vamos experimentar com valores diferentes para eles, mas vamos ter que defini-los antes mesmo. Eles incluem definir o número de épocas, tamanho do lote, taxa de aprendizado, função de perda juntamente com o otimizador

num_classes = 100
num_epochs = 20
batch_size = 16
learning_rate = 0.005

model = VGG16(num_classes).to(device)


# Função de perda e otimizador
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate, weight_decay = 0.005, momentum = 0.9)  


# Treinar o modelo
total_step = len(train_loader)

Configurando os hiperparâmetros


Treinamento

Agora estamos prontos para treinar o nosso modelo. Primeiro, vamos olhar para como treinamos o nosso modelo em torch e depois olharmos para o código:

  • Para cada época, passamos pelas imagens e rótulos dentro de nosso train_loader e movemos essas imagens e rótulos para a GPU se disponível. Isso acontece automaticamente
  • Usamos o nosso modelo para prever sobre os rótulos (model(images)) e depois calculamos a perda entre as previsões e os rótulos reais usando nossa função de perda (criterion(outputs, labels))
  • Em seguida, usamos essa perda para propagar de volta (loss.backward) e atualizar as pesos (optimizer.step()). Mas lembre-se de definir os gradientes para zero antes de cada atualização. Isso é feito usando optimizer.zero_grad()
  • Também, no final de cada época, usamos o nosso conjunto de validação para calcular a precisão do modelo. Neste caso, não precisamos de gradientes, então usamos with torch.no_grad() para avaliação mais rápida

Agora, combinamos tudo isso no seguinte código:

total_step = len(train_loader)

for epoch in range(num_epochs):
    for i, (images, labels) in enumerate(train_loader):  
        # Move tensors to the configured device
        images = images.to(device)
        labels = labels.to(device)
        
        # Forward pass
        outputs = model(images)
        loss = criterion(outputs, labels)
        
        # Backward and optimize
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    print ('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}' 
                   .format(epoch+1, num_epochs, i+1, total_step, loss.item()))
            
    # Validation
    with torch.no_grad():
        correct = 0
        total = 0
        for images, labels in valid_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()
            del images, labels, outputs
    
        print('Accuracy of the network on the {} validation images: {} %'.format(5000, 100 * correct / total)) 

Treino

Podemos ver a saída do código acima como seguinte, que mostra que o modelo está realmente aprendendo, pois a perda está decrescendo com cada época:


Perdas de treinamento


Testando

Para testar, usamos exatamente o mesmo código que para validação, mas com o test_loader:

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()
        del images, labels, outputs

    print('Accuracy of the network on the {} test images: {} %'.format(10000, 100 * correct / total))   

Testando

Usando o código acima e treinando o modelo por 20 épocas, conseguimos alcançar uma precisão de 75% no conjunto de teste.


Conclusão

Vamos concluir o que fizemos neste artigo:

  • Começamos entendendo a arquitetura e diferentes tipos de camadas na modelo VGG-16
  • A seguir, carregamos e pré-processamos o conjunto de dados CIFAR100 usando torchvision
  • Em seguida, usamos PyTorch para construir o nosso modelo VGG-16 de raiz, juntamente com o entendimento de diferentes tipos de camadas disponíveis em torch
  • Finalmente, treinamos e testamos o nosso modelo no conjunto de dados CIFAR100, e o modelo pareceu-se bem no conjunto de teste com 75% de precisão

Trabalho Futuro

Usando este artigo, você obtém uma boa introdução e aprendizado prático, mas você aprenderá muito mais se esticar isto e ver o que você pode fazer de mais:

  • Pode tentar usar diferentes conjuntos de dados. Um desses conjuntos de dados é o CIFAR10 ou um subconjunto do conjunto de dados ImageNet.
  • Pode experimentar com diferentes hiperparâmetros e ver a melhor combinação deles para o modelo
  • Finalmente, pode tentar adicionar ou remover camadas do conjunto de dados para ver o impacto delas na capacidade do modelo. Ainda melhor, tentar construir a versão VGG-19 deste modelo.

Source:
https://www.digitalocean.com/community/tutorials/vgg-from-scratch-pytorch