畳み込みニューラルネットワークの基礎と主要アーキテクチャ

はじめに

従来の画像処理では、画像データを平坦化して入力としていました。しかし、この手法では隣接するピクセル間の空間的相関関係を無視してしまうという本質的な問題がありました。

本章で解説する畳み込みニューラルネットワーク(Convolutional Neural Network、CNN)は、画像データの処理に特化した強力なニューラルネットワークアーキテクチャです。現在、计算机ビジョンの分野では、CNNベースのモデルが主流となっており、画像認識、オブジェクト検出、セマンティックセグメンテーションなど、ほぼすべてのコンペティションや商業アプリケーションがこの手法を基盤としています。

1. 画像の畳み込み演算

1.1 空間的不変性

画像から特定のオブジェクトを検出する状況を考えてみましょう。検出対象が画像のどの位置にあっても、それを検知できる方法が望ましいはずです。CNNは、この空間的不変性の概念を体系化したアーキテクチャです。

コンピュータビジョン向けのニューラルネットワーク設計において、以下の2つの原則が重要です:

  • 平行移動不変性:検出対象が画像のどの位置にあっても、ネットワークの初期層は同一の画像領域に対して 유사な反応を示す必要があります
  • 局所性:ネットワークの初期層は入力画像の局所領域のみを考慮し、遠く離れた領域間の関係を過度に意識する必要はありません

1.2 相互相関演算

畳み込み層における演算は、実際には相互相関演算です。以下に、2次元相互相関演算の実装を示します:

import torch
from torch import nn

def compute_correlation(input_data, kernel):
    """2次元相互相関演算を計算"""
    kh, kw = kernel.shape
    output_height = input_data.shape[0] - kh + 1
    output_width = input_data.shape[1] - kw + 1
    result = torch.zeros((output_height, output_width))
    
    for i in range(output_height):
        for j in range(output_width):
            result[i, j] = (input_data[i:i + kh, j:j + kw] * kernel).sum()
    return result

# 入力とカーネルの定義
input_tensor = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
kernel_tensor = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
compute_correlation(input_tensor, kernel_tensor)

1.3 畳み込み層の実装

以下に、カスタムの2次元畳み込み層を実装します:

class Convolution2D(nn.Module):
    def __init__(self, kernel_size):
        super().__init__()
        self.weight = nn.Parameter(torch.rand(kernel_size))
        self.bias = nn.Parameter(torch.zeros(1))

    def forward(self, x):
        return compute_correlation(x, self.weight) + self.bias

1.4 相互相関と数学的畳み込みの違い

相互相関演算と数学的畳み込みは非常によく似ていますが、重要な違いがあります。数学的畳み込みでは、カーネルを反転させてから演算を行います。一方、機械学習で一般的に「畳み込み」と呼ばれているものは、実際には反転を行わない相互相関演算です。

多くの深層学習フレームワークでは、計算の簡便さから相互相関演算が採用されており、「畳み込み」という用語は両方を包括的に指すようになりました。

1.5 特徴マップと受容野

特徴マップは、CNNの各畳み込み層で生成される出力です。フィルター(カーネル)を入力画像に適用することで、各特徴マップは特定の特徴(エッジ、テクスチャ、颜色など)への応答を表現します。网络の浅い層は低レベルの特徴を捉え、深い層はより抽象的な高レベル意味情報を表現します。

受容野は、特定の特徴マップ上のニューロンが対応する入力画像の領域を指します。受容野のサイズは网络的構造と層数に依存し、浅い層では局所的な情報のみを捉え、深い層ではより広範なコンテキスト情報を把握できます。

2. パディングとストライド

2.1 パディング

複数の畳み込み層を連続して適用すると、出力サイズが減少し、入力画像の境界情報が失われる問題が生じます。たとえば、240×240ピクセルの画像に5×5の畳み込みを10回適用すると、200×200ピクセルまで縮小します。

パディングは、入力画像の周囲にゼロ埋めを行うことで、この問題に対処します。パディング適用後の出力サイズは次の式で計算されます:

出力サイズ = (nh - kh + ph + 1) × (nw - kw + pw + 1)

通常、カーネルサイズを奇数(3×3、5×5など)に設定し、上下左右に同数のパディングを適用することで、入力と出力の空間サイズを一致させます。

import torch
from torch import nn

def calculate_output_shape(conv_layer, input_tensor):
    """畳み込み層の出力形状を計算"""
    input_reshaped = input_tensor.reshape((1, 1) + input_tensor.shape)
    output = conv_layer(input_reshaped)
    return output.shape[2:]

# 3×3カーネル、パディング1の畳み込み層
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1)
input_data = torch.rand((8, 8))
print(calculate_output_shape(conv2d, input_data))  # torch.Size([8, 8])

異なるカーネルサイズの場合は、個別の次元に対して異なるパディング値を指定できます:

conv2d = nn.Conv2d(1, 1, kernel_size=(5, 3), padding=(2, 1))
print(calculate_output_shape(conv2d, input_data))

2.2 ストライド

デフォルトでは、畳み込みウィンドウは1ピクセルずつ滑动しますが、ストライドを変更することで一度に複数のピクセルをスキップできます。これにより、出力サイズをより小さくしながら、計算量を削減できます。

ストライド適用後の出力サイズは次の式で計算されます:

出力サイズ = ⌊(nh - kh + ph + sh) / sh⌋ × ⌊(nw - kw + pw + sw) / sw

3. 複数入力・複数出力チャネル

3.1 複数入力チャネル

RGB画像など、複数のチャネルを持つ入力の場合、各チャネルに対して個別のカーネルを適用し、その結果を合算します。

import torch
from d2l import torch as d2l

def multi_channel_correlation(inputs, kernels):
    """複数入力チャネル対応の相互相関演算"""
    return sum(d2l.corr2d(inp, k) for inp, k in zip(inputs, kernels))

# 2チャネル、3×3の入力
input_tensor = torch.tensor([[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
                             [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]])

# 2チャネル、2×2のカーネル
kernel_tensor = torch.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]])
multi_channel_correlation(input_tensor, kernel_tensor)

3.2 複数出力チャネル

複数チャネルを出力するには、各出力チャネルに対応するカーネルを作成し、それらをスタックします:

def multi_channel_output(inputs, kernels):
    """複数出力チャネルを生成"""
    stacked_results = [multi_channel_output(inputs, k) for k in kernels]
    return torch.stack(stacked_results, dim=0)

# 3つの異なるカーネルを作成
kernels = torch.stack((kernel_tensor, kernel_tensor + 1, kernel_tensor + 2), dim=0)
print(kernels.shape)  # torch.Size([3, 2, 2, 2])
print(multi_channel_output(input_tensor, kernels))

3.3 1×1畳み込み

1×1畳み込みは、空間的な特徴抽出は行わず、チャネル方向での特徴変換に特化しています。主な用途として、計算コストの削減、的非線形性の追加、チャンネル間の特徴相互作用の実現があります。

4. プーリング層

プーリング層には2つの主要な役割があります:位置に対する感度を低下させることと、空間的なダウンサンプリングに対する感度を低下させることです。

4.1 最大プーリングと平均プーリング

プーリング層は、固定サイズのウィンドウを入力全体に滑动させ、各ウィンドウ内の最大値(最大プーリング)または平均值(平均プーリング)を出力します。畳み込み層と異なり、プーリング層は学習可能なパラメータを持ちません。

input_tensor = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4))
print(input_tensor)

4.2 パディングとストライド

デフォルトでは、プーリング層のストライドはウィンドウサイズと同じです:

pooling_layer = nn.MaxPool2d(3)
print(pooling_layer(input_tensor))  # 10を出力

# カスタム設定
pooling_layer = nn.MaxPool2d((2, 3), padding=1, stride=2)
print(pooling_layer(input_tensor))

4.3 複数チャネルの処理

プーリング層は、畳み込み層とは異なり、チャネル方向に統合せず、各入力チャネル独立して演算を行います:

channel_inputs = torch.cat((input_tensor, input_tensor + 1), dim=1)
pooling_layer = nn.MaxPool2d(3, padding=1, stride=2)
print(pooling_layer(channel_inputs))

5. LeNetアーキテクチャ

LeNetは、CNNの草分け的なアーキテクチャで、手書き数字認識において優れた性能を発揮しました。

5.1 ネットワーク構成

LeNetは2つの主要部分で構成されます:

  • 畳み込みエンコーダ:2つの畳み込み層
  • 全結合層ブロック:3つの全結合層
import torch
from torch import nn
from d2l import torch as d2l

model = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Flatten(),
    nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
    nn.Linear(120, 84), nn.Sigmoid(),
    nn.Linear(84, 10))

6. AlexNetアーキテクチャ

6.1 概要

AlexNetは2012年に発表され、深層学習の現代的トレンドを確立しました。従来の機械学習では画像特徴は手動で設計されていましたが、AlexNetは特徴も学習可能であることを実証しました。

AlexNetの成功には2つの要因があります:大規模なデータセット(ImageNet)とGPUハードウェアによる計算の高速化です。

6.2 LeNetとの違い

AlexNetはLeNetの基本的な設計思想を継承していますが、以下の点で異なります:

  • より深い構造:8層(5つの畳み込み層、3つの全結合層)
  • 活性化関数:sigmoidではなくReLUを採用
  • より大きな畳み込みウィンドウ(11×11から開始)
  • Dropoutによる正則化
  • データ拡張による汎化性能の向上
model = nn.Sequential(
    nn.Conv2d(1, 96, kernel_size=11, stride=4, padding=1), nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2),
    nn.Conv2d(96, 256, kernel_size=5, padding=2), nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2),
    nn.Conv2d(256, 384, kernel_size=3, padding=1), nn.ReLU(),
    nn.Conv2d(384, 384, kernel_size=3, padding=1), nn.ReLU(),
    nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2),
    nn.Flatten(),
    nn.Linear(6400, 4096), nn.ReLU(),
    nn.Dropout(p=0.5),
    nn.Linear(4096, 4096), nn.ReLU(),
    nn.Dropout(p=0.5),
    nn.Linear(4096, 10))

AlexNetの入力画像サイズは224×224であるため、Fashion-MNISTデータセット使用时にはリサイズが必要です。

7. VGGアーキテクチャ

7.1 VGGブロック

VGGネットワークは、統一されたサイズの畳み込み層、ReLU活性化関数、および最大プーリング層を積み重ねた構成です。以下にVGGブロックを定義します:

import torch
from torch import nn

def vgg_block(num_convs, in_channels, out_channels):
    layers = []
    for _ in range(num_convs):
        layers.append(nn.Conv2d(in_channels, out_channels,
                               kernel_size=3, padding=1))
        layers.append(nn.ReLU())
        in_channels = out_channels
    layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
    return nn.Sequential(*layers)

7.2 VGGネットワーク

VGGネットワークは、5つのVGGブロックと3つの全結合層で構成されます。入力チャネル数から始まり、各ブロックでチャネル数が増加します:

def build_vgg(architecture):
    conv_blocks = []
    current_channels = 1
    
    for num_convs, out_channels in architecture:
        conv_blocks.append(vgg_block(num_convs, current_channels, out_channels))
        current_channels = out_channels

    return nn.Sequential(
        *conv_blocks,
        nn.Flatten(),
        nn.Linear(current_channels * 7 * 7, 4096), nn.ReLU(), nn.Dropout(0.5),
        nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(0.5),
        nn.Linear(4096, 10))

architecture = ((1, 64), (1, 128), (2, 256), (2, 512), (2, 512))
vgg_model = build_vgg(architecture)

計算コストを考慮する場合、チャネル数を削減したモデルを使用できます。

8. Network in Network(NiN)

8.1 NiNブロック

NiNは、各ピクセル位置で独立に全結合層を適用するという革新的なアイデアに基づいています。これは1×1畳み込みとして実装できます。従来のアーキテクチャでは、全結合層によって空間構造が失われていましたが、NiNはこの問題を解決します。

def nin_block(in_channels, out_channels, kernel_size, strides, padding):
    return nn.Sequential(
        nn.Conv2d(in_channels, out_channels, kernel_size, strides, padding),
        nn.ReLU(),
        nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU(),
        nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU())

8.2 NiNモデル

NiNの最大の特徴は、全結合層を完全に排除したことです。代わりに、チャンネル数と等しい出力チャネルを持つNiNブロックを使用し、最終的にグローバル平均プーリングを適用します:

model = nn.Sequential(
    nin_block(1, 96, kernel_size=11, strides=4, padding=0),
    nn.MaxPool2d(3, stride=2),
    nin_block(96, 256, kernel_size=5, strides=1, padding=2),
    nn.MaxPool2d(3, stride=2),
    nin_block(256, 384, kernel_size=3, strides=1, padding=1),
    nn.MaxPool2d(3, stride=2),
    nn.Dropout(0.5),
    nin_block(384, 10, kernel_size=3, strides=1, padding=1),
    nn.AdaptiveAvgPool2d((1, 1)),
    nn.Flatten())

9. GoogLeNet(Inception)

9.1 Inceptionブロック

GoogLeNetの核心は、Inceptionブロックです。このブロックは、複数の異なるサイズの畳み込みを並列に適用し、その出力を連結します。これにより、異なるスケールでの特徴を同時に抽出できます。

import torch
from torch import nn
from torch.nn import functional as F

class InceptionBlock(nn.Module):
    def __init__(self, in_channels, c1, c2, c3, c4):
        super().__init__()
        # 1×1畳み込みパス
        self.path1 = nn.Conv2d(in_channels, c1, kernel_size=1)
        # 1×1 → 3×3畳み込みパス
        self.path2 = nn.Sequential(
            nn.Conv2d(in_channels, c2[0], kernel_size=1),
            nn.Conv2d(c2[0], c2[1], kernel_size=3, padding=1))
        # 1×1 → 5×5畳み込みパス
        self.path3 = nn.Sequential(
            nn.Conv2d(in_channels, c3[0], kernel_size=1),
            nn.Conv2d(c3[0], c3[1], kernel_size=5, padding=2))
        # MaxPool → 1×1畳み込みパス
        self.path4 = nn.Sequential(
            nn.MaxPool2d(kernel_size=3, stride=1, padding=1),
            nn.Conv2d(in_channels, c4, kernel_size=1))

    def forward(self, x):
        p1 = F.relu(self.path1(x))
        p2 = F.relu(self.path2(F.relu(self.path2[0](x))))
        p3 = F.relu(self.path3(F.relu(self.path3[0](x))))
        p4 = F.relu(self.path4(self.path4[0](x)))
        return torch.cat((p1, p2, p3, p4), dim=1)

9.2 GoogLeNetモデル

GoogLeNetは9つのInceptionブロックで構成され、段階的にチャネル数を増加させます:

stage1 = nn.Sequential(
    nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

stage2 = nn.Sequential(
    nn.Conv2d(64, 64, kernel_size=1),
    nn.ReLU(),
    nn.Conv2d(64, 192, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

stage3 = nn.Sequential(
    InceptionBlock(192, 64, (96, 128), (16, 32), 32),
    InceptionBlock(256, 128, (128, 192), (32, 96), 64),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

stage4 = nn.Sequential(
    InceptionBlock(480, 192, (96, 208), (16, 48), 64),
    InceptionBlock(512, 160, (112, 224), (24, 64), 64),
    InceptionBlock(512, 128, (128, 256), (24, 64), 64),
    InceptionBlock(512, 112, (144, 288), (32, 64), 64),
    InceptionBlock(528, 256, (160, 320), (32, 128), 128),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

stage5 = nn.Sequential(
    InceptionBlock(832, 256, (160, 320), (32, 128), 128),
    InceptionBlock(832, 384, (192, 384), (48, 128), 128),
    nn.AdaptiveAvgPool2d((1, 1)),
    nn.Flatten())

model = nn.Sequential(stage1, stage2, stage3, stage4, stage5, nn.Linear(1024, 10))

10. バッチ正規化

10.1 問題提起

層が深くなるにつれて、以下の問題が生じます:

  • 損失がネットワークの出力層付近に集中するため、深い層が早く学習される
  • 入力に近い層(浅い層)の学習が遅くなる
  • 浅い層の変化が上位のすべての層に影響を与える
  • 収束速度が低下する

バッチ正規化は、各層の入力をバッチ単位で正規化することで、これらの問題を解決します。

10.2 正規化式

バッチ正規化の数式は以下の通りです:

BN(x) = γ ⊙ (x - μB) / σB + β

ここで、μBはバッチ内のサンプル平均、σBは標準偏差、γはスケーリングパラメータ、βはシフトパラメータです。

10.3 実装

以下に、バッチ正規化層の実装を示します:

import torch
from torch import nn

def batch_normalization(x, gamma, beta, moving_mean, moving_var, eps=1e-5, momentum=0.9):
    if not torch.is_grad_enabled():
        normalized = (x - moving_mean) / torch.sqrt(moving_var + eps)
    else:
        if len(x.shape) == 2:
            mean = x.mean(dim=0)
            variance = ((x - mean) ** 2).mean(dim=0)
        else:
            mean = x.mean(dim=(0, 2, 3), keepdim=True)
            variance = ((x - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
        normalized = (x - mean) / torch.sqrt(variance + eps)
        moving_mean = momentum * moving_mean + (1 - momentum) * mean
        moving_var = momentum * moving_var + (1 - momentum) * variance
    return gamma * normalized + beta, moving_mean, moving_var

class BatchNorm2D(nn.Module):
    def __init__(self, num_features):
        super().__init()
        self.gamma = nn.Parameter(torch.ones(1, num_features, 1, 1))
        self.beta = nn.Parameter(torch.zeros(1, num_features, 1, 1))
        self.moving_mean = torch.zeros(1, num_features, 1, 1)
        self.moving_var = torch.ones(1, num_features, 1, 1)

    def forward(self, x):
        if self.moving_mean.device != x.device:
            self.moving_mean = self.moving_mean.to(x.device)
            self.moving_var = self.moving_var.to(x.device)
        return batch_normalization(
            x, self.gamma, self.beta, self.moving_mean, self.moving_var)[0]

10.4 バッチ正規化を含むLeNet

model = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5), BatchNorm2D(6), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5), BatchNorm2D(16), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Flatten(),
    nn.Linear(16 * 4 * 4, 120), BatchNorm1D(120), nn.Sigmoid(),
    nn.Linear(120, 84), BatchNorm1D(84), nn.Sigmoid(),
    nn.Linear(84, 10))

フレームワーク組み込みのBatchNormを使用する場合は、より高速です。

11. ResNet(残差ネットワーク)

11.1 残差ブロック

ResNetの核心はスキップ接続です。入力が層の出力を直接加算することで、勾配の消失問題を軽減します。

import torch
from torch import nn
from torch.nn import functional as F

class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, use_conv=False, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, stride=stride)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
        self.conv3 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride) if use_conv else None
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.bn2 = nn.BatchNorm2d(out_channels)

    def forward(self, x):
        y = F.relu(self.bn1(self.conv1(x)))
        y = self.bn2(self.conv2(y))
        if self.conv3:
            x = self.conv3(x)
        return F.relu(y + x)

11.2 ResNetモデル

def residual_block_group(in_channels, out_channels, num_blocks, first_block=False):
    blocks = []
    for i in range(num_blocks):
        use_conv = (i == 0 and not first_block)
        stride = 2 if (i == 0 and not first_block) else 1
        blocks.append(ResidualBlock(in_channels, out_channels, use_conv, stride))
        in_channels = out_channels
    return nn.Sequential(*blocks)

network = nn.Sequential(
    nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
    nn.BatchNorm2d(64), nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
    residual_block_group(64, 64, 2, first_block=True),
    residual_block_group(64, 128, 2),
    residual_block_group(128, 256, 2),
    residual_block_group(256, 512, 2),
    nn.AdaptiveAvgPool2d((1, 1)),
    nn.Flatten(),
    nn.Linear(512, 10))

12. DenseNet(稠密結合ネットワーク)

12.1 概要

DenseNetは、ResNetの加算操作の代わりに、チャンネル方向で特徴を連結します。これにより、特徴の再利用が促進され、パラメータ効率が向上します。

ResNet:y = x + f(x)

DenseNet:y = [x, f1(x), f2([x, f1(x)]), ...]

12.2 稠密ブロック

def convolution_block(in_channels, out_channels):
    return nn.Sequential(
        nn.BatchNorm2d(in_channels), nn.ReLU(),
        nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))

class DenseBlock(nn.Module):
    def __init__(self, num_convs, in_channels, out_channels):
        super().__init__()
        layers = []
        for i in range(num_convs):
            layers.append(convolution_block(out_channels * i + in_channels, out_channels))
        self.net = nn.Sequential(*layers)

    def forward(self, x):
        for layer in self.net:
            y = layer(x)
            x = torch.cat((x, y), dim=1)
        return x

12.3 过渡層

DenseBlockはチャンネル数を急速に増加させるため、過度に复杂になる前にチャンネル数を削減する过渡層が必要です:

def transition_layer(in_channels, out_channels):
    return nn.Sequential(
        nn.BatchNorm2d(in_channels), nn.ReLU(),
        nn.Conv2d(in_channels, out_channels, kernel_size=1),
        nn.AvgPool2d(kernel_size=2, stride=2))

DenseNetは、稠密ブロックと过渡層を交互に配置することで、効率的な特徴抽出とパラメータ圧縮を実現します。

タグ: CNN ConvolutionalNeuralNetwork LeNet AlexNet VGG

6月30日 19:50 投稿