畳み込みニューラル ネットワークにおけるグローバル プーリング
導入
プーリング操作は、しばらくの間、畳み込みニューラル ネットワークの主流となってきました。最大プーリングや平均プーリングなどのプロセスが中心的な役割を果たすことがよくありますが、あまり知られていないそれらのいとこであるグローバル最大プーリングとグローバル平均プーリングも同様に重要になっています。この記事では、2 つの一般的なプーリング技術のグローバルなバリアントが何を意味するのか、またそれらがどのように比較されるのかを探っていきます。
# 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')
前提条件
- CNN の基本的な理解: 畳み込み層、プーリング層、全結合層などの層を含む、CNN のアーキテクチャに関する知識。
- プーリングの概念: CNN で空間次元を削減するために使用される一般的なプーリング手法 (最大プーリング、平均プーリングなど) に関する知識。
- 線形代数とテンソル演算: グローバル プーリングには多次元テンソルをより低い次元に削減することが含まれるため、行列演算とテンソル操作について理解しています。
- 活性化関数: 活性化関数 (ReLU、シグモイドなど) が CNN 層によって抽出された特徴にどのような影響を与えるかについての基本的な知識。
- フレームワークの熟練度: TensorFlow や PyTorch などの深層学習フレームワーク、特にカスタム プーリング レイヤーの実装に関する経験。
古典的な畳み込みニューラル ネットワーク
コンピューター ビジョンの初心者の多くは、入力画像から特徴を学習/抽出しながら入力画像の空間構造を保持するため、画像データの理想的なニューラル ネットワークとして畳み込みニューラル ネットワークをよく紹介されます。そうすることで、隣接するピクセル間の関係と画像内のオブジェクトの位置を学習できるため、非常に強力なニューラル ネットワークになります。
多層パーセプトロンは画像分類コンテキストでも機能しますが、そのパフォーマンスは convnet の対応物と比較して大幅に低下します。これは単に、画像を平坦化/ベクトル化することで画像の空間構造を即座に破壊し、それによって隣接する画像間の関係のほとんどを削除するためです。ピクセル。
特徴抽出と分類器のコンボ
多くの古典的な畳み込みニューラル ネットワークは、実際には convnet と MLP を組み合わせたものです。たとえば、LeNet と AlexNet のアーキテクチャを見ると、それらのアーキテクチャが、最後に線形層が接続された 2 つの畳み込み層にすぎないことがはっきりとわかります。
この構成は非常に理にかなっていて、畳み込み層が 2 つの空間次元でデータ内の特徴を抽出するという最も得意なことを実行できるようになります。その後、抽出された特徴は線形レイヤーに渡されるため、特徴ベクトルとターゲットの間の関係を見つけるという得意なことも実行できます。
設計上の欠陥
この設計の問題は、線形層がデータに過剰適合する傾向が非常に高いことです。この問題を軽減するためにドロップアウト正則化が導入されましたが、それでも問題は残りました。さらに、空間構造を破壊しないことに誇りを持っているニューラル ネットワークの場合、古典的な convnet は、ネットワークのより深く、程度は低いとはいえ、とにかくそれを実行しました。
古典的な問題に対する最新の解決策
convnet におけるこの過剰適合の問題を防ぐために、ドロップアウト正則化を試みた後の論理的な次のステップは、線形層をすべてまとめて完全に削除することでした。線形層が除外される場合、特徴マップをダウンサンプリングし、問題のクラスの数と同じサイズのベクトル表現を生成するまったく新しい方法が模索されます。まさにここでグローバル プーリングが登場します。
4 クラス分類タスクを考えてみましょう。1 x 1 畳み込み層は、特徴マップの数が 4 になるまでダウンサンプリングするのに役立ちます。グローバル プーリングは、損失関数で使用できる 4 要素の長さのベクトル表現を作成するのに役立ちます。勾配を計算しています。
グローバル平均プーリング
上記と同じ分類タスクで、畳み込み層が適切な深さにあると感じるが、サイズ (3, 3)
の特徴マップが 8 つあるシナリオを想像してください。 8 つの特徴マップを 4 にダウンサンプリングするために 1 x 1 畳み込み層を利用できます。実際に必要なのは次のベクトルであるとき、サイズ (3, 3)
の行列が 4 つあります。 4つの要素。
これらの特徴マップから 4 要素ベクトルを導出する 1 つの方法は、各特徴マップ内のすべてのピクセルの平均を計算し、それを単一の要素として返すことです。これは本質的に、世界的な平均プーリングに伴うものです。
グローバル最大プーリング
4 つの行列から 4 要素ベクトルを生成する上記のシナリオと同様に、この場合、各特徴マップ内のすべてのピクセルの平均値を取得する代わりに、最大値を取得し、それを個別の要素として返します。対象となるベクトル表現。
グローバル プーリング方法のベンチマーク
ここでのベンチマークの目的は、分類ベクトル表現の生成に使用されたときのパフォーマンスに基づいて、両方のグローバル プーリング手法を比較することです。ベンチマークに使用されるデータセットは、一般的なファッション アイテムの 28 ピクセル x 28 ピクセルの画像を含む FashionMNIST データセットです。
# 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())
ラベル
説明
Tシャツ
1
ズボン
2
引っ張る
3
ドレス
4
コート
5
サンダル
6
シャツ
7
スニーカー
8
バッグ
9
アンクルブーツ
グローバル平均プーリングを使用した Convnet
以下に定義されている convnet は、正則化なしで 10 要素のベクトル表現を生成する際に、線形層の代わりに 1 x 1 畳み込み層をグローバル平均プーリングと連携して利用します。 PyTorch でのグローバル平均プーリングの実装に関して行う必要があるのは、通常の平均プーリング クラスを利用することだけですが、個々の特徴マップのサイズと同じサイズのカーネル/フィルターを使用することだけです。たとえば、レイヤー 6 から出力される機能マップのサイズは (3, 3)
であるため、グローバル平均プーリングを実行するには、サイズ 3 のカーネルが使用されます。 注: 各特徴マップの平均値を単純に取得しても、同じ結果が得られます。
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)
Global Max プーリングを使用した Convnet
一方、以下の ConvNet_2 は、線形層を、グローバル最大プーリングと連携して動作する 1 x 1 畳み込み層に置き換えて、正則化なしで 10 要素ベクトルを生成します。グローバル平均プーリングと同様に、PyTorch でグローバル最大プーリングを実装するには、その時点での機能マップのサイズと等しいカーネル サイズを持つ通常の最大プーリング クラスを使用する必要があります。 注: 各特徴マップの最大ピクセル値を単純に導出すると、同じ結果が得られます。
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)
畳み込みニューラル ネットワーク クラス
以下に定義されたクラスには、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 (グローバル平均プーリング)
ConvNet_1 は、分類ベクトルの生成にグローバル平均プーリングを使用します。対象のパラメータを設定して 60 エポックのトレーニングを行うと、以下で分析されるようなメトリクス ログが生成されます。
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)
取得されたログから、モデルのトレーニングの過程でトレーニングと検証の両方の精度が向上しました。検証精度は約 66% から始まり、28 エポックまでに 80% 弱の値まで着実に増加します。その後、31 番目のエポックまでに 85% 未満の値への急激な増加が観察され、最終的に 60 番目のエポックまでに約 87% に達します。
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 (グローバル最大プーリング)
ConvNet_2 は、10 要素の分類ベクトルを生成する際に、グローバル平均プーリングではなくグローバル最大プーリングを利用します。すべてのパラメーターを同じに保ち、60 エポックの間トレーニングすると、以下のメトリック ログが得られます。
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)
全体的に、トレーニングと検証の両方の精度が 60 エポックにわたって向上しました。検証精度は 70% 弱で始まり、変動しながら、60 エポックまでに 85% 弱の値まで着実に増加します。
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)
性能の比較
両方のグローバル プーリング手法のパフォーマンスを比較すると、少なくとも使用することを選択したデータセット (FashionMNIST) では、グローバル平均プーリングのパフォーマンスが優れていることが容易に推測できます。グローバル平均プーリングは、各特徴マップに存在する他のピクセルに関係なく単独で単一の値を生成するグローバル最大プーリングとは対照的に、各特徴マップ内のすべてのピクセルの一般的な性質を表す単一の値を生成するため、これは非常に論理的であるように見えます。機能マップ。ただし、より決定的な判断を下すには、複数のデータセットにわたってベンチマークを実行する必要があります。
内部でのグローバル プーリング
グローバル プーリングが実際に機能する理由を直感的に理解するには、畳み込みニューラル ネットワークの中間層の出力を視覚化できる関数を作成する必要があります。多くの場合、ニューラル ネットワークはブラック ボックス モデルであると考えられていますが、内部で何が起こっているのかを理解するために、少なくともブラック ボックスをこじ開けようとする特定の方法があります。以下の関数はまさにそれを行います。
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')
関数を使用するにはパラメータを正しく理解する必要があります。このモデルは、この記事で行ったのと同じ方法でインスタンス化された畳み込みニューラル ネットワークを指します。他のタイプはこの関数では機能しません。この場合のデータセットは任意のデータセットですが、検証セットであることが望ましいです。 Image_idx は、提供されたデータセットの最初のバッチ内の画像のインデックスです。関数はバッチを 250 枚の画像として定義するため、image_idx の範囲は 0 ~ 249 になります。一方、Layer_idx は正確には畳み込み層を参照するのではなく、層を参照します。以下に示すように、PyTorch シーケンシャル クラスによって定義されています。
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)
)
)
グローバル平均プーリングが機能する理由
グローバル平均プーリングが機能する理由を理解するには、グローバル平均プーリングが実行される直前に出力層の出力を視覚化する必要があります。これはレイヤー 15 に対応するため、レイヤー 15 までのレイヤーを取得/インデックスする必要があります。これは、layer_idx= を意味します。 16. model_1 (ConvNet_1) を使用すると、以下の結果が得られます。
visualize_layer(model=model_1, dataset=validation_set, image_idx=2, layer_idx=16)
output
>>>> actual class: 1
>>>> predicted class: 1
グローバル平均プーリングの直前のイメージ 3 (インデックス 2) の出力を視覚化すると、上で見たように、モデルがそのクラスをクラス 1 (ズボン) として正しく予測していることがわかります。ビジュアライゼーションを見ると、他の特徴マップと比較して、インデックス 1 の特徴マップが平均して最も明るいピクセルを持っていることがわかります。言い換えれば、convnet は、グローバル平均プーリングの直前に対象の特徴マップ内のより多くのピクセルを「オン」にすることによって画像を分類することを学習しました。その後、グローバル平均プーリングが実行されると、最高値の要素がインデックス 1 に配置されるため、それが正しいクラスとして選択されます。
グローバル平均プーリング出力。
Global Max Pooling が機能する理由
すべてのパラメーターを同じに保ちますが、このインスタンスでは model_2 (ConvNet_2) を使用すると、以下の結果が得られます。繰り返しますが、convnet はこのイメージをクラス 1 に属するものとして正しく分類します。生成された視覚化を見ると、インデックス 1 の特徴マップに最も明るいピクセルが含まれていることがわかります。
この場合、convnet は、グローバル最大プーリングの直前に、対象の特徴マップ内で最も明るいピクセルを「オン」にすることによって画像を分類することを学習しました。
visualize_layer(model=model_2, dataset=validation_set, image_idx=2, layer_idx=16)
output
>>>> actual class: 1
>>>> predicted class: 1
グローバル最大プーリング出力。
最後の挨拶
この記事では、グローバル平均プーリングと最大プーリングにどのような意味があるのかを検討しました。これらがなぜ使用されるようになったのか、またそれらがどのように相互に比較されるのかについて話し合いました。また、コンブネットの生検を実行し、中間層を視覚化することで、それらが機能する理由についての直観も開発しました。