CNNの基礎 — 畳み込み・プーリング・受容野の数理

人間が画像を認識するとき、まず「エッジ」や「色の変化」といった局所的な特徴を捉え、それを組み合わせて「目」「鼻」「顔」といった高次の概念を認識します。画像全体を一度に処理するのではなく、局所的なパターンを階層的に統合していくのです。

畳み込みニューラルネットワーク(Convolutional Neural Network, CNN)は、まさにこの仕組みをモデル化したアーキテクチャです。1989年にルカン(LeCun)らが手書き数字認識のために提案したLeNet以来、CNNは画像認識の標準的なアプローチとなり、2012年のAlexNetによるImageNetコンペティションでの圧勝を契機に深層学習ブームを牽引しました。

全結合層(MLP)で画像を処理する場合、$224 \times 224 \times 3$ のカラー画像では入力が150,528次元になり、隠れ層のユニット数が1,000でも重みパラメータは約1.5億個に達します。これではメモリも計算も非現実的ですし、画像の空間的な構造を全く活用できません。

CNNを理解すると、以下のような応用が開けます。

  • 画像分類: 医療画像診断、製品の外観検査、衛星画像の解析
  • 物体検出: 自動運転における歩行者・車両認識、セキュリティカメラ
  • セマンティックセグメンテーション: 画像の各ピクセルにカテゴリを割り当てる
  • 画像生成: GAN、拡散モデルの基盤アーキテクチャ

本記事の内容

  • 畳み込み演算の数学的定義
  • パディング・ストライド・出力サイズの計算
  • プーリング層の役割と種類
  • 受容野(receptive field)の数理
  • 特徴マップの可視化とPythonでの実装

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

全結合層の限界と畳み込みのアイデア

なぜ全結合層では不十分なのか

全結合層(Fully Connected Layer, FC)で画像を処理する場合、3つの本質的な問題があります。

問題1: パラメータ数の爆発。$H \times W \times C$ の画像を $n$ ユニットの隠れ層に接続すると、重みは $HWC \times n$ 個になります。パラメータが多すぎると過学習しやすくなり、計算コストも膨大です。

問題2: 空間的な構造の無視。全結合層は入力を1次元ベクトルとして扱うため、隣接するピクセル同士の関係(空間的な局所性)が失われます。画像の左上にある「猫の目」も右下にある「猫の目」も、全く異なる入力として扱われます。

問題3: 位置不変性の欠如。猫が画像の中央にいても端にいても「猫」と認識したいのですが、全結合層では異なる位置に異なる重みが割り当てられるため、位置が変わると認識できなくなります。

CNNはこれらの問題を、局所結合(local connectivity)と重み共有(weight sharing)という2つのアイデアで解決します。

畳み込みの3つの原則

局所結合(Local Connectivity): 各ユニットは入力の全体ではなく、局所的な領域(受容野)のみに接続します。画像では、近くのピクセル同士が強く関連しているため、局所的なパターンを捉えるだけで十分です。

重み共有(Weight Sharing): 同じフィルタ(カーネル)を画像全体にスライドさせて適用します。これにより、パラメータ数が大幅に削減され、同時に位置不変性が実現されます。「エッジ検出」フィルタは画像のどの位置でもエッジを検出します。

階層的な特徴抽出: 浅い層でエッジやテクスチャなどの低次特徴を、深い層でオブジェクトの部分や全体構造などの高次特徴を学習します。

これらの原則を数学的に定式化したものが、次に述べる畳み込み演算です。

畳み込み演算の数学的定義

1次元の畳み込み

まず、信号処理での1次元畳み込みから始めましょう。入力信号 $f$ とカーネル $g$ の畳み込みは

$$ (f * g)(t) = \sum_{\tau=-\infty}^{\infty} f(\tau) g(t – \tau) $$

直感的には、カーネル $g$ を反転させて $f$ 上をスライドさせ、各位置での積の和を計算する操作です。

2次元の畳み込み(画像への適用)

2次元画像 $\bm{I} \in \mathbb{R}^{H \times W}$ に対するカーネル $\bm{K} \in \mathbb{R}^{k \times k}$ の畳み込みは

$$ \begin{equation} (\bm{I} * \bm{K})(i, j) = \sum_{m=0}^{k-1}\sum_{n=0}^{k-1} \bm{I}(i+m, j+n) \cdot \bm{K}(m, n) \end{equation} $$

厳密には、信号処理の畳み込みではカーネルを反転させますが、ニューラルネットワークでの「畳み込み」は実際には相互相関(cross-correlation)です。カーネルは学習によって決まるため、反転の有無は問題になりません。

多チャネルの畳み込み

カラー画像は3チャネル(RGB)を持つため、入力は $\bm{I} \in \mathbb{R}^{C_\text{in} \times H \times W}$、カーネルは $\bm{K} \in \mathbb{R}^{C_\text{in} \times k \times k}$ になります。

$$ \text{output}(i, j) = \sum_{c=1}^{C_\text{in}}\sum_{m=0}^{k-1}\sum_{n=0}^{k-1} \bm{I}(c, i+m, j+n) \cdot \bm{K}(c, m, n) + b $$

1つのカーネルが1つの特徴マップ(feature map)を生成します。$C_\text{out}$ 個のカーネルを使えば、$C_\text{out}$ 個の特徴マップが得られます。

つまり、畳み込み層のパラメータは $\bm{K} \in \mathbb{R}^{C_\text{out} \times C_\text{in} \times k \times k}$ とバイアス $\bm{b} \in \mathbb{R}^{C_\text{out}}$ で、パラメータ数は $C_\text{out} \times C_\text{in} \times k^2 + C_\text{out}$ です。

全結合層と比較すると、$3 \times 3$ カーネルで 64チャネル入力 → 128チャネル出力の場合、畳み込み層のパラメータ数は $128 \times 64 \times 9 + 128 = 73{,}856$。同等の全結合層($64 \times 224 \times 224 \to 128 \times 224 \times 224$)では約51億パラメータが必要で、桁違いの効率化が実現されています。

畳み込み演算の定義ができたところで、次に出力サイズを制御する2つの重要なパラメータ — パディングとストライド — について見ていきましょう。

パディングとストライド

パディング(Padding)

畳み込みをそのまま適用すると、出力サイズが入力より小さくなります。$H \times W$ の入力に $k \times k$ のカーネルを適用すると、出力は $(H – k + 1) \times (W – k + 1)$ です。

パディングは入力の周囲にゼロ(または他の値)を追加して、出力サイズを調整する手法です。

Valid パディング($p = 0$): パディングなし。出力は入力より小さい。

Same パディング($p = \lfloor k/2 \rfloor$): 出力サイズが入力と同じになるようにパディング。$3 \times 3$ カーネルなら $p = 1$。

ストライド(Stride)

ストライド $s$ は、カーネルを何ピクセルずつずらして適用するかを指定します。$s = 1$ は1ピクセルずつ(標準)、$s = 2$ は2ピクセルずつ(ダウンサンプリング)です。

出力サイズの計算式

入力サイズ $H$、カーネルサイズ $k$、パディング $p$、ストライド $s$ のとき、出力サイズは

$$ \begin{equation} H_\text{out} = \left\lfloor\frac{H + 2p – k}{s}\right\rfloor + 1 \end{equation} $$

この公式はCNNの設計で頻繁に使います。

: $H = 32$, $k = 3$, $p = 1$, $s = 1$ の場合、$H_\text{out} = \lfloor(32 + 2 – 3)/1\rfloor + 1 = 32$(Sameパディング)

: $H = 32$, $k = 3$, $p = 1$, $s = 2$ の場合、$H_\text{out} = \lfloor(32 + 2 – 3)/2\rfloor + 1 = 16$(半分にダウンサンプリング)

import numpy as np
import matplotlib.pyplot as plt

# --- 畳み込み演算の可視化 ---

def conv2d(image, kernel, stride=1, padding=0):
    """2D畳み込み(スクラッチ実装)"""
    if padding > 0:
        image = np.pad(image, padding, mode='constant')

    H, W = image.shape
    kH, kW = kernel.shape
    out_H = (H - kH) // stride + 1
    out_W = (W - kW) // stride + 1

    output = np.zeros((out_H, out_W))
    for i in range(out_H):
        for j in range(out_W):
            region = image[i*stride:i*stride+kH, j*stride:j*stride+kW]
            output[i, j] = np.sum(region * kernel)

    return output

# テスト画像の生成(市松模様 + 対角線)
np.random.seed(42)
img_size = 16
image = np.zeros((img_size, img_size))
# 市松模様
for i in range(img_size):
    for j in range(img_size):
        if (i // 4 + j // 4) % 2 == 0:
            image[i, j] = 1.0
# 対角線
for i in range(img_size):
    if i < img_size:
        image[i, i] = 0.5

# 各種カーネル
kernels = {
    "Horizontal Edge": np.array([[-1, -1, -1],
                                  [ 0,  0,  0],
                                  [ 1,  1,  1]], dtype=float),
    "Vertical Edge":   np.array([[-1, 0, 1],
                                  [-1, 0, 1],
                                  [-1, 0, 1]], dtype=float),
    "Sharpen":         np.array([[ 0, -1,  0],
                                  [-1,  5, -1],
                                  [ 0, -1,  0]], dtype=float),
}

fig, axes = plt.subplots(2, 4, figsize=(18, 8))

# 元画像
ax = axes[0, 0]
ax.imshow(image, cmap="gray", vmin=-1, vmax=1)
ax.set_title("Input Image", fontsize=12)
ax.axis("off")
axes[1, 0].axis("off")

# 各カーネルと結果
for idx, (name, kernel) in enumerate(kernels.items()):
    # カーネルの表示
    ax = axes[0, idx + 1]
    ax.imshow(kernel, cmap="RdBu_r", vmin=-5, vmax=5)
    for i in range(3):
        for j in range(3):
            ax.text(j, i, f"{kernel[i,j]:.0f}", ha="center",
                    va="center", fontsize=10, fontweight="bold")
    ax.set_title(f"Kernel: {name}", fontsize=11)
    ax.axis("off")

    # 畳み込み結果
    ax = axes[1, idx + 1]
    output = conv2d(image, kernel, stride=1, padding=1)
    ax.imshow(output, cmap="gray")
    ax.set_title(f"After Convolution", fontsize=11)
    ax.axis("off")

plt.tight_layout()
plt.savefig("conv_kernels.png", dpi=150, bbox_inches="tight")
plt.show()

この可視化から、異なるカーネルが画像から異なる特徴を抽出することがわかります。

  1. 水平エッジカーネル: 上の行が-1、下の行が1であり、水平方向のエッジ(明暗の変化)を検出しています。市松模様の水平境界が強く反応しています

  2. 垂直エッジカーネル: 左列が-1、右列が1であり、垂直方向のエッジを検出しています。市松模様の垂直境界が強く反応しています

  3. シャープンカーネル: 中央が5、周囲が-1であり、画像のエッジを強調しています。元画像のコントラストが高くなっていることが確認できます

CNNでは、これらのカーネルの値を手動で設計するのではなく、学習によって最適な値を自動的に見つけます。

畳み込みで特徴マップを生成した後、次に重要な処理がプーリングです。

プーリング層

プーリングの目的

プーリング(Pooling)は、特徴マップの空間サイズを縮小する操作です。その目的は3つあります。

計算量の削減: 特徴マップを縮小することで、後続の層の計算量とメモリ使用量を減らします。

位置不変性の獲得: 特徴の正確な位置ではなく、おおよその位置を捉えることで、入力のわずかな位置ずれに対してロバストになります。

受容野の拡大: プーリングで空間サイズを縮小すると、次の畳み込み層の各ユニットがカバーする入力画像上の領域(受容野)が拡大します。

Max Pooling

最も一般的なプーリングはMax Poolingです。$k \times k$ の局所領域から最大値を取り出します。

$$ \text{MaxPool}(\bm{X})(i, j) = \max_{0 \leq m < k, \, 0 \leq n < k} \bm{X}(i \cdot s + m, j \cdot s + n) $$

典型的には $k = 2, s = 2$ で使われ、特徴マップのサイズが半分になります。

Max Poolingの逆伝播では、最大値の位置にだけ勾配が流れ、他の位置の勾配は0になります。

Average Pooling

Average Poolingは、局所領域の平均値を計算します。

$$ \text{AvgPool}(\bm{X})(i, j) = \frac{1}{k^2}\sum_{m=0}^{k-1}\sum_{n=0}^{k-1} \bm{X}(i \cdot s + m, j \cdot s + n) $$

Global Average Pooling (GAP)

Global Average Poolingは、各チャネルの特徴マップ全体の平均を計算して1つの値にします。

$$ \text{GAP}(c) = \frac{1}{H \times W}\sum_{i=1}^{H}\sum_{j=1}^{W} \bm{X}(c, i, j) $$

GAPはResNetやMobileNetなど現代のCNNで、最後の全結合層の前に配置されます。空間方向の情報を完全に集約し、チャネル数だけの特徴ベクトルを出力します。これにより入力画像のサイズに依存しない設計が可能になり、かつパラメータ数も大幅に削減されます。

プーリング vs ストライド畳み込み

最近のアーキテクチャでは、プーリング層の代わりにストライド2の畳み込みを使ってダウンサンプリングする設計も増えています。ストライド畳み込みは学習可能なパラメータを持つため、より柔軟なダウンサンプリングが可能です。

プーリングの役割を理解したところで、次にCNNの重要な概念である受容野について掘り下げましょう。

受容野(Receptive Field)

受容野とは

受容野(receptive field)とは、ネットワークのある層のあるユニットが「見ている」入力画像上の領域のことです。これは生物の視覚系からの用語で、視覚野の各ニューロンが反応する網膜上の領域を指します。

浅い層のユニットは小さな受容野を持ち、エッジやテクスチャなどの局所的な特徴を捉えます。深い層のユニットは大きな受容野を持ち、物体の部分や全体構造などの大域的な特徴を捉えます。

受容野の計算

$l$ 層目の受容野サイズ $r_l$ は、以下の再帰式で計算できます。

$$ \begin{equation} r_l = r_{l-1} + (k_l – 1) \times \prod_{i=1}^{l-1} s_i \end{equation} $$

ここで $k_l$ は $l$ 層目のカーネルサイズ、$s_i$ は $i$ 層目のストライドです。$r_0 = 1$(入力ピクセル1つ)から始めます。

: $3 \times 3$ カーネル(ストライド1)を3層重ねた場合

  • $r_1 = 1 + (3-1) \times 1 = 3$
  • $r_2 = 3 + (3-1) \times 1 = 5$
  • $r_3 = 5 + (3-1) \times 1 = 7$

つまり、$3 \times 3$ カーネルを3層重ねると $7 \times 7$ の受容野を持ちます。$7 \times 7$ のカーネルを1層使うのと同じ受容野ですが、パラメータ数は $3 \times 3^2 = 27$ 対 $7^2 = 49$ で大幅に少なく、間に非線形活性化関数も挟めます。これがVGGNet(2014)で $3 \times 3$ カーネルのみを使う設計が提案された理由です。

プーリングの受容野への影響

ストライド2のプーリング層は受容野を急速に拡大させます。

$$ r_l = r_{l-1} + (k_l – 1) \times \prod_{i=1}^{l-1} s_i $$

プーリング後の畳み込みでは $\prod s_i$ の項にプーリングのストライドが含まれるため、受容野が倍のペースで拡大します。

受容野を理解することは、アーキテクチャ設計の指針になります。たとえば、物体検出では検出したい物体のサイズに受容野が対応している必要があります。

受容野の理論を押さえたところで、実際にCNNの全体的なアーキテクチャを構成してPythonで実装してみましょう。

CNNの全体アーキテクチャ

典型的な構成

典型的なCNNは、以下のブロックを繰り返し積み重ねた構成を取ります。

$$ \text{入力} \to [\text{Conv} \to \text{BN} \to \text{ReLU} \to \text{Pool}] \times N \to \text{GAP} \to \text{FC} \to \text{出力} $$

各ブロックの役割は以下の通りです。

ブロック 役割
Conv(畳み込み) 局所的な特徴を抽出
BN(BatchNorm) 学習の安定化と加速
ReLU 非線形性の導入
Pool(プーリング) 空間サイズの縮小と位置不変性
GAP 空間情報の集約
FC(全結合) 分類

深い層ほどチャネル数を増やし(空間サイズが小さくなる分を補う)、より抽象的な特徴を表現します。

歴史的なアーキテクチャの進化

モデル 特徴
1998 LeNet-5 最初の実用的CNN。手書き数字認識
2012 AlexNet GPUでの学習。ReLU、Dropout導入
2014 VGGNet $3\times3$ カーネルのみ。深さの重要性を実証
2014 GoogLeNet Inceptionモジュール。マルチスケール処理
2015 ResNet 残差接続で100層超の学習を実現
2017 MobileNet 深さ方向分離可能畳み込み。軽量化
2019 EfficientNet NASによる最適なスケーリング

Pythonでの実装

NumPyでCNNを実装する

シンプルなCNNをNumPyで実装し、2Dデータの分類問題に適用します。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

class Conv2D:
    """2次元畳み込み層"""

    def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0):
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.kernel_size = kernel_size
        self.stride = stride
        self.padding = padding

        # He初期化
        fan_in = in_channels * kernel_size * kernel_size
        self.W = np.random.randn(out_channels, in_channels,
                                  kernel_size, kernel_size) * np.sqrt(2.0 / fan_in)
        self.b = np.zeros(out_channels)

    def forward(self, X):
        """X: (batch, in_channels, H, W)"""
        self.X = X
        B, C, H, W = X.shape
        k = self.kernel_size
        s = self.stride
        p = self.padding

        if p > 0:
            X = np.pad(X, ((0,0), (0,0), (p,p), (p,p)), mode='constant')

        H_out = (H + 2*p - k) // s + 1
        W_out = (W + 2*p - k) // s + 1
        out = np.zeros((B, self.out_channels, H_out, W_out))

        for i in range(H_out):
            for j in range(W_out):
                region = X[:, :, i*s:i*s+k, j*s:j*s+k]
                # (B, C_in, k, k) と (C_out, C_in, k, k) の畳み込み
                for f in range(self.out_channels):
                    out[:, f, i, j] = np.sum(
                        region * self.W[f], axis=(1, 2, 3)) + self.b[f]

        return out


class MaxPool2D:
    """Max Pooling層"""

    def __init__(self, kernel_size=2, stride=2):
        self.kernel_size = kernel_size
        self.stride = stride

    def forward(self, X):
        B, C, H, W = X.shape
        k = self.kernel_size
        s = self.stride
        H_out = (H - k) // s + 1
        W_out = (W - k) // s + 1
        out = np.zeros((B, C, H_out, W_out))

        for i in range(H_out):
            for j in range(W_out):
                region = X[:, :, i*s:i*s+k, j*s:j*s+k]
                out[:, :, i, j] = np.max(region, axis=(2, 3))

        return out

# --- 畳み込みの動作確認 ---
# 入力画像(1枚, 1チャネル, 8x8)
image = np.random.randn(1, 1, 8, 8)

conv = Conv2D(in_channels=1, out_channels=4, kernel_size=3, padding=1)
pool = MaxPool2D(kernel_size=2, stride=2)

# 順伝播
conv_out = conv.forward(image)
relu_out = np.maximum(0, conv_out)
pool_out = pool.forward(relu_out)

print(f"入力:    {image.shape}")
print(f"Conv後:  {conv_out.shape}")
print(f"ReLU後:  {relu_out.shape}")
print(f"Pool後:  {pool_out.shape}")

# 特徴マップの可視化
fig, axes = plt.subplots(2, 5, figsize=(16, 6))

# 入力
axes[0, 0].imshow(image[0, 0], cmap="gray")
axes[0, 0].set_title("Input (8x8)", fontsize=11)
axes[0, 0].axis("off")
axes[1, 0].axis("off")

# Conv + ReLU の特徴マップ
for f in range(4):
    axes[0, f+1].imshow(relu_out[0, f], cmap="viridis")
    axes[0, f+1].set_title(f"Conv+ReLU ch{f} (8x8)", fontsize=10)
    axes[0, f+1].axis("off")

    axes[1, f+1].imshow(pool_out[0, f], cmap="viridis")
    axes[1, f+1].set_title(f"After Pool ch{f} (4x4)", fontsize=10)
    axes[1, f+1].axis("off")

plt.suptitle("Feature Maps through CNN Layers", fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig("cnn_feature_maps.png", dpi=150, bbox_inches="tight")
plt.show()

この可視化から、CNNの各層がどのように特徴を処理するかが確認できます。

  1. 入力画像(8×8): ランダムなパターンの画像です

  2. Conv + ReLU 後(8×8): 4つのフィルタがそれぞれ異なるパターンを抽出しています。ReLUにより負の値が0になっているため、活性化された(反応した)領域のみが強調されています。Sameパディング(p=1)を使用しているので、空間サイズは入力と同じ8×8を保っています

  3. Max Pooling 後(4×4): 2×2のMax Poolingにより空間サイズが半分になっています。各2×2領域から最大値が選択されるため、最も強い特徴の位置情報は保持しつつ、空間解像度が下がっています

受容野の計算と可視化

import numpy as np
import matplotlib.pyplot as plt

# --- 受容野の計算 ---

def compute_receptive_field(layers):
    """各層の受容野サイズを計算"""
    r = 1  # 入力の受容野は1
    j = 1  # 累積ストライド
    receptive_fields = [r]
    jumps = [j]

    for kernel_size, stride in layers:
        r = r + (kernel_size - 1) * j
        j = j * stride
        receptive_fields.append(r)
        jumps.append(j)

    return receptive_fields, jumps

# 異なるアーキテクチャの比較
architectures = {
    "3x3 Conv x5\n(stride=1)": [(3, 1)] * 5,
    "3x3 Conv x3\n+ MaxPool x2": [(3, 1), (2, 2), (3, 1), (2, 2), (3, 1)],
    "5x5 Conv x3\n(stride=1)": [(5, 1)] * 3,
    "VGG-style\n(3x3 + Pool)": [(3,1),(3,1),(2,2),(3,1),(3,1),(2,2)],
}

fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

# (a) 受容野サイズの推移
ax = axes[0]
colors = ["tab:blue", "tab:orange", "tab:green", "tab:red"]

for (name, layers), color in zip(architectures.items(), colors):
    rfs, _ = compute_receptive_field(layers)
    ax.plot(range(len(rfs)), rfs, "o-", linewidth=2, markersize=6,
            color=color, label=name)

ax.set_xlabel("Layer index", fontsize=12)
ax.set_ylabel("Receptive field size", fontsize=12)
ax.set_title("Receptive Field Growth by Architecture", fontsize=13)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)

# (b) 受容野の可視化(3x3 Conv x3)
ax = axes[1]
img_size = 15
image = np.ones((img_size, img_size)) * 0.9

# 3層目のユニットの受容野(中央のユニット)
# 3x3 Conv x3: RF = 7
rf_size = 7
center = img_size // 2
start = center - rf_size // 2
end = start + rf_size

# 受容野の階層を色分け
# Layer 1: 3x3 (赤)
layer1_start = center - 1
layer1_end = center + 2
# Layer 2: 5x5 (オレンジ)
layer2_start = center - 2
layer2_end = center + 3
# Layer 3: 7x7 (黄)
layer3_start = center - 3
layer3_end = center + 4

rf_map = np.ones((img_size, img_size, 3)) * 0.9

# Layer 3 RF
rf_map[layer3_start:layer3_end, layer3_start:layer3_end] = [1.0, 1.0, 0.7]
# Layer 2 RF
rf_map[layer2_start:layer2_end, layer2_start:layer2_end] = [1.0, 0.8, 0.5]
# Layer 1 RF
rf_map[layer1_start:layer1_end, layer1_start:layer1_end] = [1.0, 0.5, 0.3]
# Center pixel
rf_map[center, center] = [1.0, 0.0, 0.0]

ax.imshow(rf_map, extent=[0, img_size, img_size, 0])
ax.set_title("Receptive Field Visualization\n(3x3 Conv x3, RF=7x7)",
             fontsize=12)
ax.set_xlabel("Width", fontsize=11)
ax.set_ylabel("Height", fontsize=11)

# 注釈
ax.annotate("Center unit", xy=(center+0.5, center+0.5),
            xytext=(center+3, center-3),
            fontsize=10, ha="center",
            arrowprops=dict(arrowstyle="->", color="black"))

ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("receptive_field.png", dpi=150, bbox_inches="tight")
plt.show()

この可視化から、受容野の概念と成長パターンが理解できます。

  1. 左図(受容野の成長): 異なるアーキテクチャで受容野サイズがどのように成長するかを比較しています。3×3 Conv x5(青)は層ごとに2ずつ線形に増加し、5層で11×11の受容野を持ちます。プーリングを含むアーキテクチャ(オレンジ、赤)はプーリング後に受容野が急速に拡大します。VGGスタイル(赤)は3×3 Convとプーリングの組み合わせで効率的に受容野を拡大しています

  2. 右図(受容野の可視化): 3×3 Conv を3層重ねた場合の受容野を色で示しています。中央のユニット(赤点)の直接的な入力は3×3(濃いオレンジ)、2層目では5×5(薄いオレンジ)、3層目では7×7(黄色)の領域が受容野になります。深い層のユニットほど広い範囲の入力情報を統合していることが視覚的にわかります

まとめ

本記事では、畳み込みニューラルネットワーク(CNN)の基礎を数理的に解説しました。

  • 畳み込み演算は局所結合と重み共有により、全結合層に比べてパラメータ数を大幅に削減しつつ、画像の空間的構造を活用する
  • 出力サイズはパディング $p$、ストライド $s$、カーネルサイズ $k$ から $\lfloor(H + 2p – k)/s\rfloor + 1$ で計算される
  • プーリング層は空間サイズを縮小し、位置不変性と受容野の拡大に寄与する。Max PoolingとGlobal Average Poolingが代表的
  • 受容野はあるユニットが「見ている」入力領域であり、$3 \times 3$ カーネルを $n$ 層重ねると受容野は $2n+1$ になる。プーリングは受容野の成長を加速する
  • 典型的なCNNは Conv → BN → ReLU → Pool のブロックを繰り返し、GAP → FC で分類を行う

次のステップとして、以下の記事も参考にしてください。