ウェブサイト検索

畳み込みニューラルネットワークにおけるグローバルプーリング


紹介

プーリング操作は、畳み込みニューラルネットワークにおいて長い間主軸となってきました。最大プーリングや平均プーリングなどのプロセスがしばしば中心的な役割を果たしていますが、あまり知られていない従兄弟であるグローバル最大プーリングとグローバル平均プーリングも同様に重要になっています。この記事では、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で空間次元を減少させるために使用される一般的なプーリング技術(例:マックスプーリング、平均プーリング)に関する知識。
  • 線形代数とテンソル操作: 行列操作とテンソル操作の理解。グローバルプーリングは、多次元テンソルを低次元に減らすことを含むためです。
  • 活性化関数: CNN層によって抽出される特徴にどのように活性化関数(例:ReLU、シグモイド)が影響するかの基本知識。
  • フレームワークの熟練度: 特にカスタムプーリングレイヤーを実装する際のTensorFlowやPyTorchのような深層学習フレームワークの経験。

古典的畳み込みニューラルネットワーク

コンピュータビジョンの初心者は、画像データに最適なニューラルネットワークとして畳み込みニューラルネットワークにしばしば紹介されます。これは、入力画像の空間構造を保持しながら特徴を学習/抽出するためです。これにより、隣接するピクセル間の関係や画像内の物体の位置を学習することができるため、非常に強力なニューラルネットワークとなっています。

多層パーセプトロンは画像分類の文脈でも機能しますが、画像を平坦化/ベクトル化することによって即座に空間構造を破壊し、隣接するピクセル間の関係の大部分を排除するため、CNNと比較してパフォーマンスは著しく低下します。

特徴抽出器と分類器のコンボ

多くの古典的な畳み込みニューラルネットワークは、実際には畳み込みネットワークと多層パーセプトロン(MLP)の組み合わせです。例えば、LeNetやAlexNetのアーキテクチャを見てみると、それらのアーキテクチャは、最後に線形層が接続されたいくつかの畳み込み層から成り立っていることが明確にわかります。

この構成は非常に理にかなっており、畳み込み層が二次元のデータから特徴を抽出するという得意分野を発揮できるようにしています。その後、抽出された特徴は線形層に渡され、特徴ベクトルとターゲットとの関係を見つけるという得意分野を発揮することができます。

デザインの欠陥

このデザインの問題は、線形層がデータに過剰適合する傾向が非常に高いことです。ドロップアウト正規化がこの問題を軽減するために導入されましたが、問題は依然として残りました。さらに、空間構造を壊さないことを誇りに思うニューラルネットワークに対して、古典的な畳み込みネットワークはそれでもなお空間構造を壊してしまいましたが、ネットワークの奥深くかつより少ない程度でそうなりました。

古典的な問題への現代的解決策

畳み込みネットワークでのこの過剰適合の問題を防ぐためには、ドロップアウト正則化を試した後の論理的な次のステップは、線形層を完全に取り除くことでした。線形層を除外する場合、対象となるクラスの数と同じサイズのベクトル表現を生成するために、特徴マップのダウンサンプリングを行う全く新しい方法を探す必要があります。これこそがグローバルプーリングが登場するところです。

4クラスの分類タスクを考慮すると、1x1の畳み込み層はフィーチャーマップをダウンサンプルして4つの数になるまで助け、グローバルプーリングは要素数4のベクトル表現を作成するのに役立ち、その後、勾配を計算するための損失関数によって使用されることができます。

グローバル平均プーリング

上記の分類タスクについて、私たちの畳み込み層が適切な深さにあると感じるシナリオを想像してください。しかし、サイズが(3, 3)の8つの特徴マップがあります。1 x 1の畳み込み層を利用することで、8つの特徴マップを4つにダウンサンプルできます。これで、実際に必要な4つの要素のベクトルではなく、サイズが(3, 3)の4つの行列を持つことになります。

これらの特徴マップから4要素ベクトルを導出する方法の1つは、各特徴マップ内のすべてのピクセルの平均を計算し、それを単一の要素として返すことです。これは、グローバル平均プーリングが本質的に意味することです。

グローバルマックスプーリング

上記のシナリオのように、4つの行列から4要素のベクトルを生成したい場合、各特徴マップのすべてのピクセルの平均値を取る代わりに、最大値を取り、それを対象のベクトル表現の個々の要素として返します。

グローバルプーリング手法のベンチマーキング

ここでのベンチマーク目的は、分類ベクタ表現を生成する際のパフォーマンスに基づいて、2つのグローバルプーリング技術を比較することです。ベンチマークに使用されるデータセットは、一般的なファッションアイテムの28ピクセル×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は、正則化なしで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)

グローバルマックスプーリングを用いた畳み込みネットワーク

一方、下の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)

畳み込みニューラルネットワーククラス

以下に定義されたクラスは、ConvNetsのトレーニングと利用に使用されるトレーニングおよび分類関数を含んでいます。

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になります。モデル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の特徴マップは他の特徴マップと比較して平均的に最も明るいピクセルを持っています。言い換えれば、畳み込みネットワークは、グローバル平均プーリングの直前で興味のある特徴マップのピクセルを「オン」にすることによって画像を分類することを学習しています。そして、グローバル平均プーリングが行われると、最も高い値を持つ要素がインデックス1に位置するため、これが正しいクラスとして選ばれる理由です。

グローバル平均プーリング出力。

なぜグローバルマックスプーリングが機能するのか

すべてのパラメータを同じに保ちながら、この場合、model_2(ConvNet_2)を使用すると、以下の結果が得られます。再度、ConvNetはこの画像をクラス1に属すると正しく分類します。生成された視覚化を見てみると、インデックス1のフィーチャーマップに最も明るいピクセルが含まれていることがわかります。

この場合、畳み込みニューラルネットワークは、グローバルマックスプーリングの直前に興味のある特徴マップで最も明るいピクセルを「オン」にすることによって画像を分類することを学びました。

visualize_layer(model=model_2, dataset=validation_set, image_idx=2, layer_idx=16)

 output
>>>> actual class: 1
>>>> predicted class: 1

グローバル最大プーリング出力。

最後の remarks

この記事では、グローバル平均プーリングと最大プーリングが何を意味するかを探りました。それらがどのように使用されるようになったか、そして互いにどのように比較されるかについて議論しました。また、私たちの畳み込みネットワークの生検を行い、中間層を可視化することで、なぜそれらが機能するのかという直感を深めました。

関連記事: