Pesquisa de site

Normalização em lote em redes neurais convolucionais


Normalização em lote é um termo comumente mencionado no contexto de redes neurais convolucionais. Neste artigo, exploraremos o que isso realmente acarreta e seus efeitos, se houver, no desempenho ou comportamento geral das redes neurais convolucionais.

Pré-requisitos

  • Python: para executar o código aqui dentro, sua máquina precisará do Python instalado. Os leitores devem ter experiência básica em codificação Python antes de continuar
  • Noções básicas de Deep Learning: Este artigo cobre conceitos essenciais para a aplicação da teoria de Deep Learning, e espera-se que os leitores tenham alguma experiência com termos relevantes e teoria básica.

O termo normalização

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    import torchvision
    import torchvision.transforms as transforms
    import torchvision.datasets as Datasets
    from torch.utils.data import Dataset, DataLoader
    import numpy as np
    import matplotlib.pyplot as plt
    import cv2
    from tqdm.notebook import tqdm
    import seaborn as sns
    from torchvision.utils import make_grid

    if torch.cuda.is_available():
      device = torch.device('cuda:0')
      print('Running on the GPU')
    else:
      device = torch.device('cpu')
      print('Running on the CPU')

A normalização nas estatísticas refere-se ao processo de restringir dados ou um conjunto de valores entre o intervalo de 0 e 1. De forma bastante inconveniente, em alguns trimestres a normalização também se refere ao processo de definir a média de uma distribuição de dados para zero e seu desvio padrão para 1.

Na verdade, esse processo de definir a média de uma distribuição como 0 e seu desvio padrão como 1 é chamado de padronização. No entanto, devido a certas liberdades, também é chamada de normalização ou normalização de pontuação z. É importante aprender essa distinção e tê-la em mente.

Pré-processamento de dados

O pré-processamento de dados refere-se às etapas executadas na preparação dos dados antes de serem alimentados em um algoritmo de aprendizado de máquina ou aprendizado profundo. Os dois processos (normalização e padronização) mencionados na seção anterior são etapas de pré-processamento de dados.

Normalização mínimo-máximo

A normalização min-max é um dos métodos mais comuns de normalização de dados. Típico de seu nome, ele restringe os pontos de dados dentro do intervalo de 0 e 1, definindo o valor mínimo no conjunto de dados como 0, o máximo como 1 e tudo o que estiver entre eles dimensionado de acordo. A equação abaixo fornece uma descrição matemática do processo de normalização min-max. Essencialmente, envolve subtrair o valor mínimo no conjunto de dados de cada ponto de dados e depois dividir pelo intervalo (máximo - mínimo).

Usando a função abaixo podemos replicar o processo de normalização min-max. Utilizando esta função podemos desenvolver uma intuição sobre o que realmente acontece nos bastidores.

    def min_max_normalize(data_points: np.array):
      """
      This function normalizes data by constraining
      data points between the range of 0 & 1  
      """
      #  convert list to numpy array
      if type(data_points) == list:
        data_points = np.array(data_points)
      else:
        pass

      #  create a list to hold normalized data  
      normalized = []

      #  derive minimum and maximum values
      minimum = data_points.min()
      maximum = data_points.max()

      #  convert to list for iteration
      data_points = list(data_points)
      #  normalizing data
      for value in data_points:
        normalize = (value-minimum)/(maximum-minimum)
        normalized.append(round(normalize, 2))

      return np.array(normalized)

Vamos criar uma matriz de valores aleatórios usando NumPy e tentar normalizá-los usando a função de normalização min-max definida acima.

    #  creating a random set of data points
    data = np.random.rand(50)*20

    #  normalizing data points
    normalized = min_max_normalize(data)

A partir dos gráficos abaixo, pode-se ver que antes da normalização, os valores variavam de o a 20, com a grande maioria dos pontos de dados tendo valores entre 5 e 10. Após a normalização, entretanto, pode-se ver que os valores agora variam entre 0 e 1 com uma grande maioria de pontos de dados com valores entre 0,25 e 0,5. Observação: se/quando você executar este código, a distribuição dos dados será diferente da usada neste artigo, pois é gerada aleatoriamente.

    #  visualising distribution
    figure, axes = plt.subplots(1, 2, sharey=True, dpi=100)
    sns.histplot(data, ax=axes[0])
    axes[0].set_title('unnormalized')
    sns.histplot(normalized, ax=axes[1])
    axes[1].set_title('min-max normalized')

Normalização de pontuação Z

A normalização do escore Z, também chamada de padronização, é o processo de definir a média e o desvio padrão de uma distribuição de dados como 0 e 1, respectivamente. A equação abaixo é a equação matemática que rege a normalização do escore z, envolve subtrair a média da distribuição do valor a ser normalizado antes de dividir pelo desvio padrão da distribuição.

A função definida abaixo replica o processo de normalização do escore z. Com esta função podemos dar uma olhada mais de perto no que isso realmente implica.

    def z_score_normalize(data_points: np.array):
      """
      This function normalizes data by computing
      their z-scores  
      """
      #  convert list to numpy array
      if type(data_points) == list:
        data_points = np.array(data_points)
      else:
        pass

      #  create a list to hold normalized data
      normalized = []

      #  derive mean and and standard deviation
      mean = data_points.mean()
      std = data_points.std()

      #  convert to list for iteration
      data_points = list(data_points)
      #  normalizing data
      for value in data_points:
        normalize = (value-mean)/std
        normalized.append(round(normalize, 2))

      return np.array(normalized)

Usando a distribuição de dados gerada na seção anterior, vamos tentar normalizar os pontos de dados usando a função z-score.

    #  normalizing data points
    z_normalized = z_score_normalize(data)

    #  check the mean value
    z_normalized.mean()
    >>>> -0.0006

    #  check the standard deviation
    z_normalized.std()
    >>>> 1.0000

Novamente, a partir das visualizações, podemos ver que a distribuição original tem valores variando de 0 a 20, enquanto os valores normalizados do escore z estão agora centrados em torno de 0 (uma média de zero) e um intervalo de aproximadamente -1,5 a 1,5, que é uma faixa mais gerenciável.

    #  visualizing distributions
    figure, axes = plt.subplots(1, 2, sharey=True, dpi=100)
    sns.histplot(data, ax=axes[0])
    axes[0].set_title('unnormalized')
    sns.histplot(z_normalized, ax=axes[1])
    axes[1].set_title('z-score normalized')

Razões para pré-processamento

Ao considerar dados em aprendizado de máquina, consideramos os pontos de dados individuais como recursos. Todos esses recursos normalmente não estão na mesma escala. Por exemplo, considere uma casa com 3 quartos e uma sala de estar de 400 pés quadrados. Esses dois recursos estão em escalas tão distantes que são alimentados em um algoritmo de aprendizado de máquina programado para ser otimizado por gradiente descendente. A otimização seria bastante tediosa, pois o recurso com maior escala terá precedência sobre todos os outros. Para facilitar o processo de otimização, é uma boa ideia ter todos os pontos de dados na mesma escala.

Normalização em camadas de convolução

Os pontos de dados em uma imagem são seus pixels. Os valores de pixel normalmente variam de 0 a 255; é por isso que, antes de alimentar imagens em uma rede neural convolucional, é uma boa ideia normalizá-las de alguma forma para colocar todos os pixels em um intervalo gerenciável.

Mesmo quando isso é feito, ao treinar uma rede convencional, os pesos (elementos em seus filtros) podem se tornar muito grandes e, assim, produzir mapas de recursos com pixels espalhados por uma ampla faixa. Isso essencialmente torna a normalização feita durante a etapa de pré-processamento um tanto fútil. Além disso, isso poderia dificultar o processo de otimização, tornando-o lento ou, em casos extremos, poderia levar a um problema chamado gradientes instáveis, que poderia essencialmente impedir que a rede otimizasse ainda mais seus pesos.

Para evitar este problema, é introduzida uma normalização em cada camada do convento. Essa normalização é chamada de Normalização em lote.

O Processo de Normalização em Lote

A normalização em lote essencialmente define os pixels em todos os mapas de recursos em uma camada de convolução para uma nova média e um novo desvio padrão. Normalmente, ele começa com a pontuação z normalizando todos os pixels e depois multiplica os valores normalizados por um parâmetro arbitrário alfa (escala) antes de adicionar outro parâmetro arbitrário beta (deslocamento).

Esses dois parâmetros alfa e beta são parâmetros que podem ser aprendidos que o convnet usará para garantir que os valores de pixel nos mapas de recursos estejam dentro de uma faixa gerenciável - melhorando assim o problema de gradientes instáveis.

Normalização em lote em ação

Para realmente avaliar os efeitos da normalização de lote nas camadas de convolução, precisamos avaliar duas convnets, uma sem normalização de lote e outra com normalização de lote. Para isso utilizaremos a arquitetura LeNet-5 e o conjunto de dados MNIST.

Classe de conjunto de dados e rede neural convolucional

Neste artigo, o conjunto de dados MNIST será usado para fins de benchmarking, conforme mencionado anteriormente. Este conjunto de dados consiste em imagens de 28 x 28 pixels de dígitos manuscritos variando de 0 a 9 rotulados de acordo.

Exemplos de imagens do conjunto de dados MNIST.

Ele pode ser carregado no PyTorch usando o bloco de código abaixo. O conjunto de treinamento é composto por 60.000 imagens, enquanto o conjunto de validação é composto por 10.000 imagens. Como usaremos este conjunto de dados com LeNet-5, as imagens precisam ser redimensionadas para 32 x 32 pixels conforme definido no parâmetro transforms.

    #  loading training data
    training_set = Datasets.MNIST(root='./', download=True,
                                  transform=transforms.Compose([transforms.ToTensor(),
                                                                transforms.Resize((32, 32))]))

    #  loading validation data
    validation_set = Datasets.MNIST(root='./', download=True, train=False,
                                    transform=transforms.Compose([transforms.ToTensor(),
                                                                  transforms.Resize((32, 32))]))

Para treinamento e utilização de nossas convnets, usaremos a classe abaixo apropriadamente chamada de ‘ConvolutionalNeuralNet()’. Esta classe contém métodos que ajudarão a treinar e classificar instâncias usando a convnet treinada. O método train() também contém funções auxiliares internas, como init_weights() e precisão.

    class ConvolutionalNeuralNet():
      def __init__(self, network):
        self.network = network.to(device)
        self.optimizer = torch.optim.Adam(self.network.parameters(), lr=1e-3)

      def train(self, loss_function, epochs, batch_size, 
                training_set, validation_set):

        #  creating log
        log_dict = {
            'training_loss_per_batch': [],
            'validation_loss_per_batch': [],
            'training_accuracy_per_epoch': [],
            'validation_accuracy_per_epoch': []
        } 

        #  defining weight initialization function
        def init_weights(module):
          if isinstance(module, nn.Conv2d):
            torch.nn.init.xavier_uniform_(module.weight)
            module.bias.data.fill_(0.01)
          elif isinstance(module, nn.Linear):
            torch.nn.init.xavier_uniform_(module.weight)
            module.bias.data.fill_(0.01)

        #  defining accuracy function
        def accuracy(network, dataloader):
          network.eval()
          total_correct = 0
          total_instances = 0
          for images, labels in tqdm(dataloader):
            images, labels = images.to(device), labels.to(device)
            predictions = torch.argmax(network(images), dim=1)
            correct_predictions = sum(predictions==labels).item()
            total_correct+=correct_predictions
            total_instances+=len(images)
          return round(total_correct/total_instances, 3)

        #  initializing network weights
        self.network.apply(init_weights)

        #  creating dataloaders
        train_loader = DataLoader(training_set, batch_size)
        val_loader = DataLoader(validation_set, batch_size)

        #  setting convnet to training mode
        self.network.train()

        for epoch in range(epochs):
          print(f'Epoch {epoch+1}/{epochs}')
          train_losses = []

          #  training
          print('training...')
          for images, labels in tqdm(train_loader):
            #  sending data to device
            images, labels = images.to(device), labels.to(device)
            #  resetting gradients
            self.optimizer.zero_grad()
            #  making predictions
            predictions = self.network(images)
            #  computing loss
            loss = loss_function(predictions, labels)
            log_dict['training_loss_per_batch'].append(loss.item())
            train_losses.append(loss.item())
            #  computing gradients
            loss.backward()
            #  updating weights
            self.optimizer.step()
          with torch.no_grad():
            print('deriving training accuracy...')
            #  computing training accuracy
            train_accuracy = accuracy(self.network, train_loader)
            log_dict['training_accuracy_per_epoch'].append(train_accuracy)

          #  validation
          print('validating...')
          val_losses = []

          #  setting convnet to evaluation mode
          self.network.eval()

          with torch.no_grad():
            for images, labels in tqdm(val_loader):
              #  sending data to device
              images, labels = images.to(device), labels.to(device)
              #  making predictions
              predictions = self.network(images)
              #  computing loss
              val_loss = loss_function(predictions, labels)
              log_dict['validation_loss_per_batch'].append(val_loss.item())
              val_losses.append(val_loss.item())
            #  computing accuracy
            print('deriving validation accuracy...')
            val_accuracy = accuracy(self.network, val_loader)
            log_dict['validation_accuracy_per_epoch'].append(val_accuracy)

          train_losses = np.array(train_losses).mean()
          val_losses = np.array(val_losses).mean()

          print(f'training_loss: {round(train_losses, 4)}  training_accuracy: '+
          f'{train_accuracy}  validation_loss: {round(val_losses, 4)} '+  
          f'validation_accuracy: {val_accuracy}\n')

        return log_dict

      def predict(self, x):
        return self.network(x)

Lenet-5

LeNet-5 (Y. Lecun et al) é uma das primeiras redes neurais convolucionais projetadas especificamente para reconhecer/classificar imagens de dígitos escritos à mão. Sua arquitetura está representada na imagem acima e sua implementação em PyTorch é fornecida no bloco de código a seguir.

    class LeNet5(nn.Module):
      def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 6, 5)
        self.pool1 = nn.AvgPool2d(2)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.pool2 = nn.AvgPool2d(2)
        self.linear1 = nn.Linear(5*5*16, 120)
        self.linear2 = nn.Linear(120, 84)
        self.linear3 = nn. Linear(84, 10)

      def forward(self, x):
        x = x.view(-1, 1, 32, 32)

        #----------
        # LAYER 1
        #----------
        output_1 = self.conv1(x)
        output_1 = torch.tanh(output_1)
        output_1 = self.pool1(output_1)

        #----------
        # LAYER 2
        #----------
        output_2 = self.conv2(output_1)
        output_2 = torch.tanh(output_2)
        output_2 = self.pool2(output_2)

        #----------
        # FLATTEN
        #----------
        output_2 = output_2.view(-1, 5*5*16)

        #----------
        # LAYER 3
        #----------
        output_3 = self.linear1(output_2)
        output_3 = torch.tanh(output_3)

        #----------
        # LAYER 4
        #----------
        output_4 = self.linear2(output_3)
        output_4 = torch.tanh(output_4)

        #-------------
        # OUTPUT LAYER
        #-------------
        output_5 = self.linear3(output_4)
        return(F.softmax(output_5, dim=1))

Usando a arquitetura LeNet-5 definida acima, instanciaremos model_1, um membro da classe ConvolutionalNeuralNet, com parâmetros conforme vistos no bloco de código. Este modelo servirá como base para fins de benchmarking.

    #  training model 1
    model_1 = ConvolutionalNeuralNet(LeNet5())

    log_dict_1 = model_1.train(nn.CrossEntropyLoss(), epochs=10, batch_size=64, 
                           training_set=training_set, validation_set=validation_set)

Depois de treinar por 10 épocas e visualizar as precisões do registro métrico que recebemos em troca, podemos ver que a precisão do treinamento e da validação aumentou ao longo do treinamento. Em nosso experimento, a precisão da validação começou em aproximadamente 93% após a primeira época, antes de continuar a aumentar de forma constante ao longo das próximas 9 iterações, terminando em pouco mais de 98% na época 10.

    sns.lineplot(y=log_dict_1['training_accuracy_per_epoch'], x=range(len(log_dict_1['training_accuracy_per_epoch'])), label='training')

    sns.lineplot(y=log_dict_1['validation_accuracy_per_epoch'], x=range(len(log_dict_1['validation_accuracy_per_epoch'])), label='validation')

    plt.xlabel('epoch')
    plt.ylabel('accuracy')

LeNet-5 normalizado em lote

Como o tema deste artigo está centrado na normalização de lote em camadas de convolução, a norma de lote só é aplicada nas duas camadas de convolução presentes nesta arquitetura conforme ilustrado na imagem acima.

    class LeNet5_BatchNorm(nn.Module):
      def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 6, 5)
        self.batchnorm1 = nn.BatchNorm2d(6)
        self.pool1 = nn.AvgPool2d(2)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.batchnorm2 = nn.BatchNorm2d(16)
        self.pool2 = nn.AvgPool2d(2)
        self.linear1 = nn.Linear(5*5*16, 120)
        self.linear2 = nn.Linear(120, 84)
        self.linear3 = nn. Linear(84, 10)

      def forward(self, x):
        x = x.view(-1, 1, 32, 32)

        #----------
        # LAYER 1
        #----------
        output_1 = self.conv1(x)
        output_1 = torch.tanh(output_1)
        output_1 = self.batchnorm1(output_1)
        output_1 = self.pool1(output_1)

        #----------
        # LAYER 2
        #----------
        output_2 = self.conv2(output_1)
        output_2 = torch.tanh(output_2)
        output_2 = self.batchnorm2(output_2)
        output_2 = self.pool2(output_2)

        #----------
        # FLATTEN
        #----------
        output_2 = output_2.view(-1, 5*5*16)

        #----------
        # LAYER 3
        #----------
        output_3 = self.linear1(output_2)
        output_3 = torch.tanh(output_3)

        #----------
        # LAYER 4
        #----------
        output_4 = self.linear2(output_3)
        output_4 = torch.tanh(output_4)

        #-------------
        # OUTPUT LAYER
        #-------------
        output_5 = self.linear3(output_4)
        return(F.softmax(output_5, dim=1))

Usando o segmento de código abaixo, podemos nstanciar o model_2 com a normalização em lote incluída e começar o treinamento com os mesmos parâmetros do model_1. Então, produzimos pontuações de precisão…

    #  training model 2
    model_2 = ConvolutionalNeuralNet(LeNet5_BatchNorm())

    log_dict_2 = model_2.train(nn.CrossEntropyLoss(), epochs=10, batch_size=64, 
                           training_set=training_set, validation_set=validation_set)

Olhando para o gráfico, fica claro que as precisões de treinamento e validação aumentaram ao longo do treinamento, semelhante ao modelo sem normalização de lote. A precisão da validação após a primeira época ficou um pouco acima de 95%, 3 pontos percentuais acima do model_1 no mesmo ponto, antes de aumentar gradualmente e culminar em aproximadamente 98,5%, 0,5% acima do model_1.

    sns.lineplot(y=log_dict_2['training_accuracy_per_epoch'], x=range(len(log_dict_2['training_accuracy_per_epoch'])), label='training')

    sns.lineplot(y=log_dict_2['validation_accuracy_per_epoch'], x=range(len(log_dict_2['validation_accuracy_per_epoch'])), label='validation')

    plt.xlabel('epoch')
    plt.ylabel('accuracy')

Comparando Modelos

Comparando os dois modelos, fica claro que o modelo LeNet-5 com camadas de convolução normalizadas em lote superou o modelo regular sem camadas de convolução normalizadas em lote. Portanto, é seguro dizer que a normalização em lote ajudou a aumentar o desempenho neste caso.

A comparação das perdas de treinamento e validação entre os modelos LeNet-5 regular e normalizado em lote também mostra que o modelo normalizado em lote atinge valores de perda mais baixos mais rapidamente do que o modelo regular. Este é um indicador para a normalização em lote, aumentando a taxa na qual o modelo otimiza seus pesos na direção correta ou, em outras palavras, a normalização em lote aumenta a taxa na qual a rede convencional aprende.

Perdas de treinamento e validação.

Considerações Finais

Neste artigo, exploramos o que a normalização implica em um contexto de aprendizado de máquina/aprendizado profundo. Também exploramos os processos de normalização como etapas de pré-processamento de dados e como a normalização pode ser levada além do pré-processamento e para camadas de convolução por meio do processo de normalização em lote.

Posteriormente, examinamos o próprio processo de normalização de lote antes de avaliar seus efeitos comparando duas variações de convnets LeNet-5 (uma sem norma de lote e outra com norma de lote) no conjunto de dados MNIST. A partir dos resultados, inferimos que a normalização do lote contribuiu para um aumento no desempenho e na velocidade de otimização do peso. Houve também algumas sugestões de que isso impede a mudança interna de covariáveis, no entanto, um consenso poderia muito bem não ter sido alcançado sobre isso.

Artigos relacionados: