【PyTorch】CNNの仕組みと実装をわかりやすく解説

CNN(Convolutional Neural Network, 畳み込みニューラルネットワーク)は、画像認識を中心に広く利用されている深層学習モデルです。畳み込み演算により、画像の局所的な特徴(エッジ、テクスチャなど)を効率的に抽出し、それらを階層的に組み合わせて高次の特徴を認識します。

本記事では、CNNの各構成要素の仕組みを数学的に解説し、PyTorchでMNIST手書き数字分類を実装します。

本記事の内容

  • CNNのアーキテクチャの全体像
  • 畳み込み層、プーリング層の数学的定義
  • パラメータ数の計算
  • PyTorchでのMNIST分類の実装

前提知識

この記事を読む前に、ニューラルネットワークの基礎(全結合層、活性化関数、誤差逆伝播法)を押さえておくと理解が深まります。

CNNのアーキテクチャ

CNNは主に以下の3種類の層で構成されます。

  1. 畳み込み層(Convolution Layer): カーネル(フィルタ)を入力にスライドさせて特徴マップを生成
  2. プーリング層(Pooling Layer): 特徴マップのサイズを縮小し、位置不変性を獲得
  3. 全結合層(Fully Connected Layer): 抽出した特徴を基に分類を行う

典型的な構成は、

$$ \text{入力} \to [\text{Conv} \to \text{ReLU} \to \text{Pool}] \times L \to \text{Flatten} \to \text{FC} \to \text{出力} $$

畳み込み層

2次元畳み込みの定義

入力画像 $\bm{X} \in \mathbb{R}^{H \times W}$ とカーネル $\bm{K} \in \mathbb{R}^{k_h \times k_w}$ の畳み込みは、

$$ (\bm{X} * \bm{K})_{ij} = \sum_{m=0}^{k_h-1}\sum_{n=0}^{k_w-1} X_{i+m, j+n} \cdot K_{m,n} $$

出力のサイズは、

$$ H_{\text{out}} = \frac{H – k_h + 2p}{s} + 1, \quad W_{\text{out}} = \frac{W – k_w + 2p}{s} + 1 $$

ここで $p$ はパディング、$s$ はストライドです。

多チャネルの畳み込み

入力が $C_{\text{in}}$ チャネルの場合、カーネルは $\bm{K} \in \mathbb{R}^{C_{\text{in}} \times k_h \times k_w}$ となり、

$$ (\bm{X} * \bm{K})_{ij} = \sum_{c=1}^{C_{\text{in}}}\sum_{m=0}^{k_h-1}\sum_{n=0}^{k_w-1} X_{c, i+m, j+n} \cdot K_{c, m, n} + b $$

$C_{\text{out}}$ 個のカーネルを用いると、$C_{\text{out}}$ チャネルの出力が得られます。

パラメータ数

畳み込み層のパラメータ数は、

$$ \text{パラメータ数} = C_{\text{out}} \times (C_{\text{in}} \times k_h \times k_w + 1) $$

最後の +1 はバイアス項です。全結合層と比較して大幅にパラメータ数が少なく、これがCNNの効率性の源泉です。

プーリング層

プーリング層は特徴マップのサイズを縮小します。最もよく使われるのはMaxプーリングです。

$$ \text{MaxPool}_{ij} = \max_{(m, n) \in R_{ij}} X_{m,n} $$

$R_{ij}$ はプーリングウィンドウの領域です。

プーリングにより、

  • 計算量の削減
  • 微小な位置変化に対する不変性
  • 過学習の抑制

が得られます。

PyTorchでの実装

import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader

# デバイス設定
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Device: {device}")

# --- データの準備 ---
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])

train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST('./data', train=False, download=True, transform=transform)

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)

# --- CNNモデルの定義 ---
class CNN(nn.Module):
    def __init__(self):
        super().__init__()
        # 畳み込み層
        self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)   # 28x28 -> 28x28
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)  # 14x14 -> 14x14
        self.pool = nn.MaxPool2d(2, 2)                             # サイズを半分に
        self.dropout1 = nn.Dropout2d(0.25)
        self.dropout2 = nn.Dropout(0.5)
        # 全結合層
        self.fc1 = nn.Linear(64 * 7 * 7, 128)
        self.fc2 = nn.Linear(128, 10)

    def forward(self, x):
        # Conv1 -> ReLU -> Pool: (1, 28, 28) -> (32, 14, 14)
        x = self.pool(torch.relu(self.conv1(x)))
        # Conv2 -> ReLU -> Pool: (32, 14, 14) -> (64, 7, 7)
        x = self.pool(torch.relu(self.conv2(x)))
        x = self.dropout1(x)
        # Flatten
        x = x.view(-1, 64 * 7 * 7)
        # FC1 -> ReLU -> Dropout
        x = torch.relu(self.fc1(x))
        x = self.dropout2(x)
        # FC2 (出力)
        x = self.fc2(x)
        return x

model = CNN().to(device)

# パラメータ数の確認
total_params = sum(p.numel() for p in model.parameters())
print(f"総パラメータ数: {total_params:,}")

# --- 学習 ---
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()

n_epochs = 10
train_losses = []
test_accuracies = []

for epoch in range(n_epochs):
    model.train()
    epoch_loss = 0
    for batch_x, batch_y in train_loader:
        batch_x, batch_y = batch_x.to(device), batch_y.to(device)
        optimizer.zero_grad()
        output = model(batch_x)
        loss = criterion(output, batch_y)
        loss.backward()
        optimizer.step()
        epoch_loss += loss.item()

    avg_loss = epoch_loss / len(train_loader)
    train_losses.append(avg_loss)

    # テスト精度
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for batch_x, batch_y in test_loader:
            batch_x, batch_y = batch_x.to(device), batch_y.to(device)
            output = model(batch_x)
            pred = output.argmax(dim=1)
            correct += (pred == batch_y).sum().item()
            total += batch_y.size(0)
    acc = correct / total
    test_accuracies.append(acc)
    print(f"Epoch [{epoch+1}/{n_epochs}] Loss: {avg_loss:.4f}, Test Acc: {acc:.4f}")

# --- 可視化 ---
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

ax1 = axes[0]
ax1.plot(train_losses, 'b-o', markersize=4)
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.set_title('Training Loss')
ax1.grid(True, alpha=0.3)

ax2 = axes[1]
ax2.plot(test_accuracies, 'r-o', markersize=4)
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Accuracy')
ax2.set_title('Test Accuracy')
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# --- 予測結果の表示 ---
model.eval()
test_x, test_y = next(iter(test_loader))
with torch.no_grad():
    output = model(test_x.to(device))
    preds = output.argmax(dim=1).cpu()

fig, axes = plt.subplots(2, 5, figsize=(12, 5))
for i in range(10):
    ax = axes[i // 5, i % 5]
    ax.imshow(test_x[i].squeeze(), cmap='gray')
    color = 'green' if preds[i] == test_y[i] else 'red'
    ax.set_title(f'Pred: {preds[i].item()} (True: {test_y[i].item()})', color=color)
    ax.axis('off')
plt.tight_layout()
plt.show()

まとめ

本記事では、CNNの仕組みとPyTorchでの実装について解説しました。

  • CNNは畳み込み層・プーリング層・全結合層で構成され、局所的な特徴を階層的に抽出する
  • 畳み込み層はカーネルをスライドさせて特徴マップを生成し、パラメータ共有により効率的
  • プーリング層は特徴マップを縮小し、位置不変性と過学習抑制の効果がある
  • MNISTの手書き数字分類では、シンプルなCNNでも99%近い精度が得られる

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