Ricerca nel sito web

Normalizzazione batch nelle reti neurali convoluzionali


La normalizzazione batch è un termine comunemente menzionato nel contesto delle reti neurali convoluzionali. In questo articolo esploreremo cosa comporta effettivamente e i suoi eventuali effetti sulle prestazioni o sul comportamento generale delle reti neurali convoluzionali.

Prerequisiti

  • Python: per eseguire il codice qui all'interno, la tua macchina avrà bisogno di Python installato. I lettori dovrebbero avere un'esperienza di base nella codifica Python prima di continuare
  • Nozioni di base sul deep learning: questo articolo tratta i concetti essenziali per applicare la teoria del deep learning e ci si aspetta che i lettori abbiano una certa esperienza con i termini pertinenti e la teoria di base.

Il termine normalizzazione

    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')

La normalizzazione in statistica si riferisce al processo di vincolo dei dati o di un insieme di valori compresi nell'intervallo compreso tra 0 e 1. Piuttosto inopportunamente, in alcuni ambienti la normalizzazione si riferisce anche al processo di impostazione della media di una distribuzione di dati su zero e della sua deviazione standard a 1.

In realtà, questo processo di impostare la media di una distribuzione su 0 e la sua deviazione standard su 1 è chiamato standardizzazione. A causa di alcune libertà, tuttavia, viene anche chiamata normalizzazione o normalizzazione del punteggio z. È importante imparare questa distinzione e tenerla presente.

Preelaborazione dei dati

La preelaborazione dei dati si riferisce ai passaggi eseguiti nella preparazione dei dati prima di essere forniti a un algoritmo di machine learning o deep learning. I due processi (normalizzazione e standardizzazione) menzionati nella sezione precedente sono fasi di preelaborazione dei dati.

Normalizzazione min-max

La normalizzazione min-max è uno dei metodi più comuni per normalizzare i dati. Tipico del suo nome, vincola i punti dati all'interno dell'intervallo compreso tra 0 e 1 impostando il valore minimo nel set di dati su 0, il massimo su 1 e tutto il resto scalato di conseguenza. L'equazione seguente fornisce una descrizione matematica del processo di normalizzazione min-max. Essenzialmente si tratta di sottrarre il valore minimo nel set di dati da ciascun punto dati, quindi dividerlo per l'intervallo (massimo - minimo).

Utilizzando la funzione seguente possiamo replicare il processo di normalizzazione min-max. Utilizzando questa funzione possiamo sviluppare un'intuizione su ciò che realmente accade dietro le quinte.

    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)

Creiamo una serie di valori casuali utilizzando NumPy, quindi proviamo a normalizzarli utilizzando la funzione di normalizzazione min-max definita sopra.

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

    #  normalizing data points
    normalized = min_max_normalize(data)

Dai grafici sottostanti, si può vedere che prima della normalizzazione, i valori variavano da o a 20 con una grande maggioranza di punti dati con valori compresi tra 5 e 10. Dopo la normalizzazione, tuttavia, si può vedere che i valori ora variano tra 0 e 1 con la stragrande maggioranza dei punti dati ha valori compresi tra 0,25 e 0,5. Nota: se/quando esegui questo codice, la distribuzione dei dati sarà diversa da quella utilizzata in questo articolo poiché viene generata in modo casuale.

    #  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')

Normalizzazione del punteggio Z

La normalizzazione del punteggio Z, chiamata anche standardizzazione, è il processo che imposta la media e la deviazione standard di una distribuzione di dati rispettivamente su 0 e 1. L'equazione seguente è l'equazione matematica che governa la normalizzazione del punteggio z, comporta la sottrazione della media della distribuzione dal valore da normalizzare prima di dividere per la deviazione standard della distribuzione.

La funzione definita di seguito replica il processo di normalizzazione dello z-score, con questa funzione possiamo vedere più da vicino cosa comporta effettivamente.

    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)

Utilizzando la distribuzione dei dati generata nella sezione precedente, proviamo a normalizzare i punti dati utilizzando la funzione 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

Ancora una volta, dalle visualizzazioni, possiamo vedere che la distribuzione originale ha valori che vanno da 0 a 20 mentre i valori normalizzati del punteggio z sono ora centrati attorno a 0 (una media di zero) e un intervallo di circa -1,5 a 1,5 che è una gamma più gestibile.

    #  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')

Motivi della preelaborazione

Quando consideriamo i dati nell'apprendimento automatico, consideriamo i singoli punti dati come funzionalità. Tutte queste funzionalità in genere non sono sulla stessa scala. Ad esempio, considera una casa con 3 camere da letto e un soggiorno di 400 piedi quadrati. Queste due funzionalità sono su scale così distanti che se vengono inserite in un algoritmo di apprendimento automatico previsto per essere ottimizzato mediante la discesa del gradiente. L'ottimizzazione sarebbe piuttosto noiosa, poiché la funzionalità con la scala più grande avrà la precedenza su tutte le altre. Per facilitare il processo di ottimizzazione, è una buona idea avere tutti i punti dati all'interno della stessa scala.

Normalizzazione negli strati di convoluzione

I punti dati in un'immagine sono i suoi pixel. I valori dei pixel in genere vanno da 0 a 255; ecco perché, prima di inserire le immagini in una rete neurale convoluzionale, è una buona idea normalizzarle in qualche modo in modo da mettere tutti i pixel in un intervallo gestibile.

Anche quando ciò viene fatto, durante l'addestramento di un convnet, i pesi (elementi nei suoi filtri) potrebbero diventare troppo grandi e quindi produrre mappe di caratteristiche con pixel distribuiti su un'ampia gamma. Ciò essenzialmente rende alquanto inutile la normalizzazione effettuata durante la fase di preelaborazione. Inoltre, ciò potrebbe ostacolare il processo di ottimizzazione rendendolo lento o in casi estremi potrebbe portare a un problema chiamato gradienti instabili, che potrebbe sostanzialmente impedire a convnet di ottimizzare ulteriormente i suoi pesi.

Per prevenire questo problema, viene introdotta una normalizzazione in ogni strato del convento. Questa normalizzazione è denominata Normalizzazione batch.

Il processo di normalizzazione batch

La normalizzazione batch essenzialmente imposta i pixel in tutte le mappe delle caratteristiche in uno strato di convoluzione su una nuova media e una nuova deviazione standard. In genere, si inizia normalizzando il punteggio z di tutti i pixel, quindi si continua moltiplicando i valori normalizzati per un parametro arbitrario alfa (scala) prima di aggiungere un altro parametro arbitrario beta (offset).

Questi due parametri alfa e beta sono parametri apprendibili che convnet utilizzerà quindi per garantire che i valori dei pixel nelle mappe delle caratteristiche rientrino in un intervallo gestibile, migliorando così il problema dei gradienti instabili.

Normalizzazione batch in azione

Per valutare realmente gli effetti della normalizzazione batch negli strati di convoluzione, dobbiamo confrontare due convnet, uno senza normalizzazione batch e l'altro con normalizzazione batch. Per questo utilizzeremo l'architettura LeNet-5 e il set di dati MNIST.

Classe di set di dati e rete neurale convoluzionale

In questo articolo, il set di dati MNIST verrà utilizzato per scopi di benchmarking come menzionato in precedenza. Questo set di dati è costituito da immagini da 28 x 28 pixel di cifre scritte a mano che vanno dalle cifre da 0 a 9 etichettate di conseguenza.

Immagini campione dal set di dati MNIST.

Può essere caricato in PyTorch utilizzando il blocco di codice seguente. Il set di training è composto da 60.000 immagini mentre il set di validazione è composto da 10.000 immagini. Poiché utilizzeremo questo set di dati con LeNet-5, le immagini dovranno essere ridimensionate a 32 x 32 pixel come definito nel parametro trasforma.

    #  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))]))

Per la formazione e l'utilizzo delle nostre reti di connessione, utilizzeremo la classe seguente denominata "ConvolutionalNeuralNet()". Questa classe contiene metodi che aiuteranno ad addestrare e classificare le istanze utilizzando il convnet addestrato. Il metodo train() contiene anche funzioni di supporto interne come init_weights() e accuratezza.

    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) è una delle prime reti neurali convoluzionali progettate specificamente per riconoscere/classificare immagini di cifre scritte a mano. La sua architettura è illustrata nell'immagine sopra e la sua implementazione in PyTorch è fornita nel seguente blocco di codice.

    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))

Utilizzando l'architettura LeNet-5 sopra definita, creeremo un'istanza di model_1, un membro della classe ConvolutionalNeuralNet, con i parametri visualizzati nel blocco di codice. Questo modello servirà come base di riferimento per scopi di 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)

Dopo l'addestramento per 10 epoche e la visualizzazione della precisione dal registro metrico che riceviamo in cambio, possiamo vedere che sia la precisione dell'addestramento che quella della convalida sono aumentate nel corso dell'addestramento. Nel nostro esperimento, l'accuratezza della validazione è iniziata a circa il 93% dopo la prima epoca prima di procedere ad aumentare costantemente nelle successive 9 iterazioni, per terminare infine a poco più del 98% entro l'epoca 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 normalizzato in batch

Poiché il tema di questo articolo è incentrato sulla normalizzazione batch negli strati di convoluzione, la norma batch viene applicata solo sui due strati di convoluzione presenti in questa architettura, come illustrato nell'immagine sopra.

    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))

Utilizzando il segmento di codice riportato di seguito, possiamo creare un'istanza di model_2 con la normalizzazione batch inclusa e iniziare l'addestramento con gli stessi parametri di model_1. Quindi, forniamo punteggi di precisione...

    #  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)

Osservando il grafico, è chiaro che sia la precisione dell'addestramento che quella della validazione sono aumentate nel corso dell'addestramento in modo simile al modello senza normalizzazione batch. L'accuratezza della validazione dopo la prima epoca era appena superiore al 95%, 3 punti percentuali in più rispetto a model_1 nello stesso punto, prima di aumentare gradualmente e culminare a circa il 98,5%, 0,5% in più rispetto a model_1< /codice>.

    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')

Confronto di modelli

Confrontando entrambi i modelli, è chiaro che il modello LeNet-5 con strati di convoluzione normalizzati in batch ha sovraperformato il modello normale senza strati di convoluzione normalizzati in batch. Si può quindi affermare con certezza che la normalizzazione batch ha contribuito ad aumentare le prestazioni in questo caso.

Confrontando le perdite di addestramento e validazione tra i modelli LeNet-5 regolari e normalizzati per batch si vede anche che il modello normalizzato per batch raggiunge valori di perdita inferiori più velocemente rispetto al modello normale. Questo è un puntatore alla normalizzazione batch che aumenta la velocità con cui il modello ottimizza i suoi pesi nella direzione corretta o in altre parole, la normalizzazione batch aumenta la velocità con cui il convnet apprende.

Perdite di formazione e convalida.

Osservazioni finali

In questo articolo, abbiamo esplorato cosa comporta la normalizzazione in un contesto di machine learning/deep learning. Abbiamo anche esplorato i processi di normalizzazione come fasi di preelaborazione dei dati e come la normalizzazione possa essere portata oltre la preelaborazione e negli strati di convoluzione tramite il processo di normalizzazione batch.

Successivamente, abbiamo esaminato il processo di normalizzazione batch stesso prima di valutarne gli effetti confrontando due varianti di convnet LeNet-5 (una senza norma batch e l'altra con norma batch) sul set di dati MNIST. Dai risultati, abbiamo dedotto che la normalizzazione dei lotti ha contribuito ad un aumento delle prestazioni e alla velocità di ottimizzazione del peso. Ci sono stati anche alcuni suggerimenti che impediscono lo spostamento della covariata interna, ma su questo potrebbe anche non essere stato raggiunto un consenso.

Articoli correlati: