Pooling globale nelle reti neurali convoluzionali
Introduzione
Le operazioni di pooling sono da tempo un pilastro delle reti neurali convoluzionali. Mentre processi come il max pooling e il pooling medio hanno spesso occupato un posto più centrale, i loro cugini meno conosciuti, il max pooling globale e il pooling medio globale, sono diventati altrettanto importanti. In questo articolo esploreremo cosa comportano le varianti globali delle due tecniche di pooling comuni e come si confrontano tra loro.
# article dependencies
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')
Prerequisiti
- Comprensione di base delle CNN: familiarità con l'architettura delle CNN, inclusi livelli come quelli convoluzionali, di pooling e completamente connessi.
- Concetti di pooling: conoscenza delle tecniche di pooling comuni (ad esempio, pooling massimo, pooling medio) utilizzate per ridurre le dimensioni spaziali nelle CNN.
- Algebra lineare e operazioni sui tensori: comprensione delle operazioni sulle matrici e delle manipolazioni dei tensori, poiché il pooling globale implica la riduzione di un tensore multidimensionale a una dimensione inferiore.
- Funzioni di attivazione: conoscenza di base di come le funzioni di attivazione (ad esempio ReLU, sigmoide) influiscono sulle caratteristiche estratte dai livelli CNN.
- Competenza nel framework: esperienza con framework di deep learning come TensorFlow o PyTorch, in particolare nell'implementazione di livelli di pooling personalizzati.
La rete neurale convoluzionale classica
Molti principianti nella visione artificiale vengono spesso introdotti alle reti neurali convoluzionali come rete neurale ideale per i dati di immagine poiché mantiene la struttura spaziale dell'immagine di input mentre apprende/estrae caratteristiche da esse. In questo modo è in grado di apprendere le relazioni tra i pixel vicini e la posizione degli oggetti nell'immagine, rendendolo così una rete neurale molto potente.
Un percettrone multistrato funzionerebbe anche in un contesto di classificazione delle immagini, ma le sue prestazioni sarebbero gravemente degradate rispetto alla sua controparte convnet semplicemente perché distrugge immediatamente la struttura spaziale dell'immagine appiattindola/vettorizzandola, rimuovendo così la maggior parte della relazione tra elementi vicini. pixel.
Combinazione di estrattore e classificatore di funzionalità
Molte reti neurali convoluzionali classiche sono in realtà una combinazione di convnet e MLP. Osservando ad esempio le architetture di LeNet e AlexNet, si può vedere chiaramente che le loro architetture sono solo un paio di strati di convoluzione con strati lineari attaccati all'estremità.
Questa configurazione ha molto senso, poiché ha consentito ai livelli di convoluzione di fare ciò che sanno fare meglio, ovvero estrarre caratteristiche nei dati con due dimensioni spaziali. Successivamente le caratteristiche estratte vengono passate su livelli lineari in modo che anche loro possano fare ciò in cui sono bravi, trovando relazioni tra vettori di caratteristiche e obiettivi.
Un difetto nel design
Il problema con questo progetto è che i livelli lineari hanno una propensione molto elevata ad adattarsi eccessivamente ai dati. La regolarizzazione dell'abbandono è stata introdotta per contribuire a mitigare questo problema, ma il problema è rimasto comunque. Inoltre, per una rete neurale che si vanta di non distruggere le strutture spaziali, la classica convnet lo faceva comunque, anche se più in profondità nella rete e in misura minore.
Soluzioni moderne a un problema classico
Per evitare questo problema di overfitting in convnets, il passo logico successivo dopo aver provato la regolarizzazione dell'abbandono è stato quello di eliminare completamente tutti gli strati lineari insieme. Se si vogliono escludere gli strati lineari, si deve cercare un modo completamente nuovo di sottocampionare le mappe delle caratteristiche e produrre una rappresentazione vettoriale di dimensioni uguali al numero di classi in questione. È proprio qui che entra in gioco il pooling globale.
Considera un'attività di classificazione di 4 classi, mentre i livelli di convoluzione 1 x 1 aiuteranno a sottocampionare le mappe delle caratteristiche fino a renderle in numero di 4, il pooling globale aiuterà a creare una rappresentazione vettoriale lunga 4 elementi che può quindi essere utilizzata dalla funzione di perdita in calcolo dei gradienti.
Raggruppamento medio globale
Sempre nello stesso compito di classificazione descritto sopra, immagina uno scenario in cui riteniamo che i nostri livelli di convoluzione siano a una profondità adeguata ma abbiamo 8 mappe di caratteristiche di dimensione (3, 3)
. Possiamo utilizzare un livello di convoluzione 1 x 1 per ridurre il campionamento delle 8 mappe di caratteristiche a 4. Ora abbiamo 4 matrici di dimensione (3, 3)
quando ciò di cui abbiamo effettivamente bisogno è un vettore di 4 elementi.
Un modo per derivare un vettore a 4 elementi da queste mappe di caratteristiche è calcolare la media di tutti i pixel in ciascuna mappa di caratteristiche e restituirla come un singolo elemento. Questo è essenzialmente ciò che comporta il pooling medio globale.
Raggruppamento massimo globale
Proprio come nello scenario precedente in cui vorremmo produrre un vettore di 4 elementi da 4 matrici, in questo caso invece di prendere il valore medio di tutti i pixel in ciascuna mappa di caratteristiche, prendiamo il valore massimo e lo restituiamo come singolo elemento nella rappresentazione vettoriale di interesse.
Benchmarking dei metodi di pooling globale
L'obiettivo del benchmarking qui è confrontare entrambe le tecniche di pooling globale in base alle loro prestazioni quando vengono utilizzate per generare rappresentazioni di vettori di classificazione. Il set di dati da utilizzare per il benchmarking è il set di dati FashionMNIST che contiene immagini di 28 x 28 pixel di articoli di moda comuni.
# loading training data
training_set = Datasets.FashionMNIST(root='./', download=True,
transform=transforms.ToTensor())
loading validation data
validation_set = Datasets.FashionMNIST(root='./', download=True, train=False,
transform=transforms.ToTensor())
Etichetta
Descrizione
Maglietta
1
Pantaloni
2
Pullover
3
Vestito
4
Cappotto
5
Sandalo
6
Camicia
7
Scarpa da ginnastica
8
Borsa
9
Stivaletto
Convnet con pooling medio globale
Il convnet definito di seguito utilizza uno strato di convoluzione 1 x 1 in tandem con il pooling medio globale invece di strati lineari per produrre una rappresentazione vettoriale di 10 elementi senza regolarizzazione. Per quanto riguarda l'implementazione del pooling medio globale in PyTorch, tutto ciò che deve essere fatto è utilizzare la normale classe di pooling medio ma utilizzare un kernel/filtro di dimensioni uguali alla dimensione di ogni singola mappa di funzionalità. Per illustrare, le mappe delle caratteristiche che escono dal livello 6 sono di dimensione (3, 3)
quindi per eseguire il pooling medio globale, viene utilizzato un kernel di dimensione 3. Nota: prendere semplicemente il valore medio di ciascuna mappa delle caratteristiche produrrà lo stesso risultato.
class ConvNet_1(nn.Module):
def __init__(self):
super().__init__()
self.network = nn.Sequential(
# layer 1
nn.Conv2d(1, 8, 3, padding=1),
nn.ReLU(), # feature map size = (28, 28)
# layer 2
nn.Conv2d(8, 8, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2), # feature map size = (14, 14)
# layer 3
nn.Conv2d(8, 16, 3, padding=1),
nn.ReLU(), # feature map size = (14, 14)
# layer 4
nn.Conv2d(16, 16, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2), # feature map size = (7, 7)
# layer 5
nn.Conv2d(16, 32, 3, padding=1),
nn.ReLU(), # feature map size = (7, 7)
# layer 6
nn.Conv2d(32, 32, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2), # feature map size = (3, 3)
# output layer
nn.Conv2d(32, 10, 1),
nn.AvgPool2d(3)
)
def forward(self, x):
x = x.view(-1, 1, 28, 28)
output = self.network(x)
output = output.view(-1, 10)
return torch.sigmoid(output)
Convnet con Global Max Pooling
ConvNet_2 di seguito invece sostituisce gli strati lineari con uno strato di convoluzione 1 x 1 che lavora in tandem con il pooling massimo globale per produrre un vettore di 10 elementi senza regolarizzazione. Similmente al pooling medio globale, per implementare il pooling massimo globale in PyTorch, è necessario utilizzare la normale classe di pooling massimo con una dimensione del kernel uguale alla dimensione della mappa delle funzionalità in quel punto. Nota: ricavare semplicemente il valore massimo dei pixel in ciascuna mappa delle caratteristiche produrrebbe gli stessi risultati.
class ConvNet_2(nn.Module):
def __init__(self):
super().__init__()
self.network = nn.Sequential(
# layer 1
nn.Conv2d(1, 8, 3, padding=1),
nn.ReLU(), # feature map size = (28, 28)
# layer 2
nn.Conv2d(8, 8, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2), # feature map size = (14, 14)
# layer 3
nn.Conv2d(8, 16, 3, padding=1),
nn.ReLU(), # feature map size = (14, 14)
# layer 4
nn.Conv2d(16, 16, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2), # feature map size = (7, 7)
# layer 5
nn.Conv2d(16, 32, 3, padding=1),
nn.ReLU(), # feature map size = (7, 7)
# layer 6
nn.Conv2d(32, 32, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2), # feature map size = (3, 3)
# output layer
nn.Conv2d(32, 10, 1),
nn.MaxPool2d(3)
)
def forward(self, x):
x = x.view(-1, 1, 28, 28)
output = self.network(x)
output = output.view(-1, 10)
return torch.sigmoid(output)
Classe di rete neurale convoluzionale
La classe definita di seguito contiene le funzioni di addestramento e classificazione da utilizzare per l'addestramento e l'utilizzo di convnet.
class ConvolutionalNeuralNet():
def __init__(self, network):
self.network = network.to(device)
self.optimizer = torch.optim.Adam(self.network.parameters(), lr=3e-4)
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)
# defining accuracy function
def accuracy(network, dataloader):
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)
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 = []
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)
ConvNet_1 (pooling medio globale)
ConvNet_1 utilizza il pooling medio globale per produrre un vettore di classificazione. L'impostazione dei parametri di interesse e di allenamento per 60 epoche produce un registro metrico come analizzato di seguito.
model_1 = ConvolutionalNeuralNet(ConvNet_1())
log_dict_1 = model_1.train(nn.CrossEntropyLoss(), epochs=60, batch_size=64,
training_set=training_set, validation_set=validation_set)
Dal registro ottenuto, sia l'accuratezza dell'addestramento che quella della convalida sono aumentate nel corso dell'addestramento del modello. L'accuratezza della convalida inizia a circa il 66% prima di aumentare costantemente fino a un valore poco inferiore all'80% entro la 28a epoca. Un forte aumento fino a un valore inferiore all'85% viene quindi osservato entro la 31a epoca prima di culminare infine a circa l'87% entro la 60a epoca.
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')
ConvNet_2 (pooling massimo globale)
ConvNet_2 utilizza il pooling massimo globale invece del pooling medio globale per produrre un vettore di classificazione di 10 elementi. Mantenendo tutti i parametri uguali e allenandosi per 60 epoche si ottiene il registro metrico riportato di seguito.
model_2 = ConvolutionalNeuralNet(ConvNet_2())
log_dict_2 = model_2.train(nn.CrossEntropyLoss(), epochs=60, batch_size=64,
training_set=training_set, validation_set=validation_set)
Nel complesso, sia la precisione dell'addestramento che quella della convalida sono aumentate nel corso di 60 epoche. L'accuratezza della convalida inizia a poco meno del 70% prima di fluttuare per aumentare costantemente fino a un valore poco inferiore all'85% entro la 60a epoca.
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')
plt.savefig('maxpool_benchmark.png', dpi=1000)
Confronto delle prestazioni
Confrontando le prestazioni di entrambe le tecniche di pooling globale, si può facilmente dedurre che il pooling medio globale funziona meglio, almeno sul set di dati che abbiamo scelto di utilizzare (FashionMNIST). Sembra essere abbastanza logico in realtà poiché il pooling medio globale produce un singolo valore che è rappresentativo della natura generale di tutti i pixel in ciascuna mappa delle caratteristiche in contrapposizione al pooling massimo globale che produce un singolo valore in isolamento senza riguardo agli altri pixel presenti nella mappa delle caratteristiche. Tuttavia, per raggiungere un verdetto più conclusivo, il benchmarking dovrebbe essere effettuato su diversi set di dati.
Pooling globale sotto il cofano
Per sviluppare un'intuizione sul perché il pooling globale funzioni effettivamente, dobbiamo scrivere una funzione che ci consentirà di visualizzare l'output di uno strato intermedio in una rete neurale convoluzionale. Molte volte si pensa che le reti neurali siano modelli di scatola nera, ma ci sono alcuni modi per provare almeno ad aprire la scatola nera nel tentativo di capire cosa succede al suo interno. La funzione seguente fa proprio questo.
def visualize_layer(model, dataset, image_idx: int, layer_idx: int):
"""
This function visulizes intermediate layers in a convolutional neural
network defined using the PyTorch sequential class
"""
# creating a dataloader
dataloader = DataLoader(dataset, 250)
# deriving a single batch from dataloader
for images, labels in dataloader:
images, labels = images.to(device), labels.to(device)
break
# deriving output from layer of interest
output = model.network.network[:layer_idx].forward(images[image_idx])
# deriving output shape
out_shape = output.shape
# classifying image
predicted_class = model.predict(images[image_idx])
print(f'actual class: {labels[image_idx]}\npredicted class: {torch.argmax(predicted_class)}')
# visualising layer
plt.figure(dpi=150)
plt.title(f'visualising output')
plt.imshow(np.transpose(make_grid(output.cpu().view(out_shape[0], 1,
out_shape[1],
out_shape[2]),
padding=2, normalize=True), (1,2,0)))
plt.axis('off')
Per poter utilizzare la funzione, i parametri devono essere compresi correttamente. Il modello si riferisce ad una rete neurale a convoluzione istanziata nello stesso modo in cui abbiamo fatto in questo articolo, altri tipi non funzioneranno con questa funzione. Il set di dati in questo caso potrebbe essere qualsiasi set di dati, ma preferibilmente il set di convalida. Image_idx è l'indice di un'immagine nel primo batch del set di dati fornito, la funzione definisce un batch come 250 immagini, quindi image_idx può variare da 0 a 249. Layer_idx d'altra parte non si riferisce esattamente ai livelli di convoluzione, si riferisce ai livelli come definito dalla classe sequenziale PyTorch come mostrato di seguito.
model_1.network
output
>>>> ConvNet_1(
(network): Sequential(
(0): Conv2d(1, 8, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(1): ReLU()
(2): Conv2d(8, 8, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(3): ReLU()
(4): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(5): Conv2d(8, 16, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(6): ReLU()
(7): Conv2d(16, 16, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(8): ReLU()
(9): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(10): Conv2d(16, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(11): ReLU()
(12): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(13): ReLU()
(14): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(15): Conv2d(32, 10, kernel_size=(1, 1), stride=(1, 1))
(16): AvgPool2d(kernel_size=3, stride=3, padding=0)
)
)
Perché il pooling medio globale funziona
Per capire perché il pooling della media globale funziona, dobbiamo visualizzare l'output del livello di output subito prima che venga eseguito il pooling della media globale, questo corrisponde al livello 15, quindi dobbiamo acquisire/indicizzare i livelli fino al livello 15, il che implica che layer_idx= 16. Utilizzando model_1 (ConvNet_1), produciamo i risultati seguenti.
visualize_layer(model=model_1, dataset=validation_set, image_idx=2, layer_idx=16)
output
>>>> actual class: 1
>>>> predicted class: 1
Quando visualizziamo l'output dell'immagine 3 (indice 2) appena prima del pooling medio globale, possiamo vedere che il modello ha previsto correttamente la sua classe come classe 1 (pantaloni) come visto sopra. Osservando la visualizzazione, possiamo vedere che la mappa delle caratteristiche all'indice 1 ha in media i pixel più luminosi rispetto alle altre mappe delle caratteristiche. In altre parole, convnet ha imparato a classificare le immagini “accendendo” più pixel nella mappa delle caratteristiche di interesse appena prima del raggruppamento medio globale. Quando viene quindi eseguito il pooling medio globale, l'elemento con il valore più alto sarà posizionato nell'indice 1, motivo per cui viene scelto come classe corretta.
Produzione media globale del pooling.
Perché il Global Max Pooling funziona
Mantenendo tutti i parametri uguali ma utilizzando model_2 (ConvNet_2) in questo caso, otteniamo i risultati seguenti. Ancora una volta, convnet classifica correttamente questa immagine come appartenente alla classe 1. Osservando la visualizzazione prodotta, possiamo vedere che la mappa delle caratteristiche all'indice 1 contiene il pixel più luminoso.
Convnet in questo caso ha imparato a classificare le immagini “accendendo” i pixel più luminosi nella mappa delle caratteristiche di interesse appena prima del pooling massimo globale.
visualize_layer(model=model_2, dataset=validation_set, image_idx=2, layer_idx=16)
output
>>>> actual class: 1
>>>> predicted class: 1
Output massimo globale del pooling.
Osservazioni finali
In questo articolo, abbiamo esplorato cosa comportano il pooling medio e massimo globale. Abbiamo discusso del motivo per cui sono stati utilizzati e di come si confrontano tra loro. Abbiamo anche sviluppato un'intuizione sul perché funzionano eseguendo una biopsia dei nostri connettivi e visualizzando gli strati intermedi.