畳み込みとCNN

CNN を理解するときに大事なのは、いきなり大規模画像分類を覚えることではなく、「画像のどこに何があるかを保ちながら特徴を取り出す」とはどういう計算かを追うことです。

このノートでは、1 チャネル画像への 2 次元演算から始めて、padding、stride、pooling、パラメータ効率、小さな分類器、そして FCN とのつながりまでを順に見ます。

画像を平坦化しないまま扱うと、何が嬉しいのか

CNN の強みは、画像の縦横の並びを壊さずに局所構造を見ることです。エッジや模様のような小さな手掛かりを拾い、その反応を次の層へ渡していく設計が、全結合層とは違う学習の仕方を作ります。

もし画像を最初から 1 本の長いベクトルに潰してしまうと、近くにあった画素同士の関係はかなり失われます。そこで局所窓を滑らせる計算へ切り替えると、どこに模様があるかを保ちながら特徴を拾えるようになります。以後のセルは、その設計が shape と parameter 数にどう効くかを確かめる流れです。

ここで追いたいのは「反応する場所」と「形の変化」です

CNN を読むときに最初に迷いやすいのは、重みの意味よりテンソルの形です。各層で高さ・幅・チャネルがどう変わるかを追えるようになると、畳み込みと pooling の役割が急に読みやすくなります。

畳み込みは、画像のどこでその模様が出たかを残せる

全結合層は入力位置の構造を壊してしまいますが、畳み込みは局所窓を滑らせるので、特徴の位置関係をある程度保てます。まずはこの直感を、手計算に近い実装で確かめます。

pooling や 1x1 conv も、単なるおまけではない

pooling は細かな位置情報を捨てて要約し、1x1 conv は空間よりチャネル方向の混合を担います。後半では、その両方が CNN 全体の設計で何をしているかを見ます。

まずは 1 チャネル画像にカーネルをかける

最初に、最小の 2 次元演算を自分で実装して、カーネルが画像のどこで強く反応するかを見ます。ここが CNN の最も小さな部品です。

import numpy as np
import matplotlib.pyplot as plt

try:
    import torch
    import torch.nn as nn
    import torch.optim as optim
    TORCH_AVAILABLE = True
except ModuleNotFoundError:
    torch = None
    nn = None
    optim = None
    TORCH_AVAILABLE = False

np.random.seed(42)

まず、1チャネル画像に対する2次元演算を自分で実装します。
ここでは深層学習ライブラリ(Conv2d)と同じ慣習に合わせ、カーネル反転なしの相互相関(cross-correlation)を標準にします。

厳密な数学的畳み込みを見たいときは、カーネルを上下左右反転してから積和します。

def conv2d_single(image, kernel, stride=1, padding=0, flip_kernel=False):
    h, w = image.shape
    kh, kw = kernel.shape
    k = np.flip(kernel, axis=(0, 1)) if flip_kernel else kernel

    if padding > 0:
        padded = np.pad(image, ((padding, padding), (padding, padding)), mode='constant')
    else:
        padded = image

    ph, pw = padded.shape
    out_h = (ph - kh) // stride + 1
    out_w = (pw - kw) // stride + 1
    out = np.zeros((out_h, out_w), dtype=np.float64)

    for i in range(out_h):
        for j in range(out_w):
            patch = padded[i * stride:i * stride + kh, j * stride:j * stride + kw]
            out[i, j] = np.sum(patch * k)

    return out


def maxpool2d_single(feature_map, pool=2, stride=2):
    h, w = feature_map.shape
    out_h = (h - pool) // stride + 1
    out_w = (w - pool) // stride + 1
    out = np.zeros((out_h, out_w), dtype=np.float64)

    for i in range(out_h):
        for j in range(out_w):
            patch = feature_map[i * stride:i * stride + pool, j * stride:j * stride + pool]
            out[i, j] = np.max(patch)

    return out

padding と stride で出力サイズはどう変わるか

次は周辺を残すか、どれだけ粗く見るかを変えます。padding と stride は、見える範囲と解像度の調整器だと考えると理解しやすくなります。

toy = np.zeros((10, 10), dtype=np.float64)
toy[2:8, 4:6] = 1.0
toy[6:8, 1:9] = 1.0

kernel_vertical = np.array([
    [-1, 0, 1],
    [-2, 0, 2],
    [-1, 0, 1],
], dtype=np.float64)

kernel_horizontal = np.array([
    [-1, -2, -1],
    [ 0,  0,  0],
    [ 1,  2,  1],
], dtype=np.float64)

feat_v = conv2d_single(toy, kernel_vertical, stride=1, padding=1)
feat_h = conv2d_single(toy, kernel_horizontal, stride=1, padding=1)

fig, axes = plt.subplots(1, 3, figsize=(10.5, 3.2))
axes[0].imshow(toy, cmap='gray', vmin=0, vmax=1)
axes[0].set_title('Input')
axes[1].imshow(feat_v, cmap='coolwarm')
axes[1].set_title('Vertical-edge response')
axes[2].imshow(feat_h, cmap='coolwarm')
axes[2].set_title('Horizontal-edge response')
for ax in axes:
    ax.axis('off')
plt.tight_layout()
plt.show()

padding は周辺情報を残したまま畳み込みするために使います。
stride はカーネルの移動幅で、値を大きくすると出力解像度が下がります。

out_no_pad = conv2d_single(toy, kernel_vertical, stride=1, padding=0)
out_pad1 = conv2d_single(toy, kernel_vertical, stride=1, padding=1)
out_stride2 = conv2d_single(toy, kernel_vertical, stride=2, padding=1)

print('input shape      :', toy.shape)
print('no padding shape :', out_no_pad.shape)
print('padding=1 shape  :', out_pad1.shape)
print('stride=2 shape   :', out_stride2.shape)

1x1 畳み込みとパラメータ効率を見る

1x1 conv は空間方向を混ぜるのではなく、チャネル方向の混ぜ替えに使います。また、全結合に比べて畳み込みの方がパラメータを共有できることも数で確認します。

pooled = maxpool2d_single(np.maximum(feat_v, 0.0), pool=2, stride=2)

fig, axes = plt.subplots(1, 2, figsize=(7.4, 3.0))
axes[0].imshow(np.maximum(feat_v, 0.0), cmap='magma')
axes[0].set_title('ReLU(feature)')
axes[1].imshow(pooled, cmap='magma')
axes[1].set_title('MaxPool 2x2')
for ax in axes:
    ax.axis('off')
plt.tight_layout()
plt.show()

次に、CNNが全結合層よりパラメータ効率が良い理由を数で確認します。

# 32x32x3 画像を 64 ユニットへ直接全結合する場合
fc_params = 32 * 32 * 3 * 64 + 64

# 3x3 Conv (in=3, out=64) の場合
conv_params = 3 * 3 * 3 * 64 + 64

print('Fully connected params:', fc_params)
print('Conv 3x3 params      :', conv_params)
print('FC / Conv ratio      :', round(fc_params / conv_params, 1))

小さな画像分類へつなぐ

ここからは、横帯と縦帯を区別する toy データで CNN 的な特徴抽出を使ってみます。実画像分類の縮小版として読むと流れがつかみやすくなります。

def make_stripe_image(kind, size=16, noise_std=0.12, rng=None):
    if rng is None:
        rng = np.random.default_rng()
    img = np.zeros((size, size), dtype=np.float64)

    if kind == 0:  # horizontal stripe
        y0 = int(rng.integers(3, size - 3))
        img[max(0, y0 - 1):min(size, y0 + 1), :] = 1.0
    else:  # vertical stripe
        x0 = int(rng.integers(3, size - 3))
        img[:, max(0, x0 - 1):min(size, x0 + 1)] = 1.0

    img += rng.normal(0.0, noise_std, size=(size, size))
    return np.clip(img, 0.0, 1.0)


def make_dataset(n_samples=400, size=16, seed=0):
    rng = np.random.default_rng(seed)
    X = np.zeros((n_samples, size, size), dtype=np.float64)
    y = np.zeros((n_samples,), dtype=np.int64)

    for i in range(n_samples):
        label = int(rng.integers(0, 2))
        X[i] = make_stripe_image(label, size=size, rng=rng)
        y[i] = label

    return X, y

X_all, y_all = make_dataset(n_samples=500, size=16, seed=7)

fig, axes = plt.subplots(2, 5, figsize=(9.2, 3.8))
for i, ax in enumerate(axes.ravel()):
    ax.imshow(X_all[i], cmap='gray', vmin=0, vmax=1)
    ax.set_title(f'label={y_all[i]}', fontsize=9)
    ax.axis('off')
plt.tight_layout()
plt.show()

CNN全体を最初から実装する代わりに、

  1. 畳み込み + ReLU + Pooling で特徴抽出(ここは固定フィルタで学習しない)
  2. その特徴に線形分類器(Softmax)を学習(ここで学習するのは W, b

という2段構成で、CNNの役割分担を見える化します。まずは「何が固定で、何を学習しているか」に注目してください。

filters = [
    np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float64),
    np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float64),
    np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float64),
]


def extract_features(images, kernels):
    feats = []
    for img in images:
        f_img = []
        for k in kernels:
            conv = conv2d_single(img, k, stride=1, padding=1)
            act = np.maximum(conv, 0.0)
            pool = maxpool2d_single(act, pool=2, stride=2)
            f_img.append(np.mean(pool))
            f_img.append(np.max(pool))
        feats.append(f_img)
    return np.array(feats, dtype=np.float64)

features_all = extract_features(X_all, filters)
print('feature shape:', features_all.shape)

固定特徴と学習部分を分けて考える

ここでは全てを同時に学習するのではなく、畳み込み的特徴抽出と線形分類器を分けます。CNN 全体を部品へ分解して理解するためです。

idx = np.random.permutation(len(X_all))
train_size = int(0.8 * len(X_all))
train_idx = idx[:train_size]
test_idx = idx[train_size:]

X_train = features_all[train_idx]
y_train = y_all[train_idx]
X_test = features_all[test_idx]
y_test = y_all[test_idx]


def softmax(logits):
    z = logits - np.max(logits, axis=1, keepdims=True)
    exp_z = np.exp(z)
    return exp_z / np.sum(exp_z, axis=1, keepdims=True)


def one_hot(y, n_classes):
    out = np.zeros((len(y), n_classes), dtype=np.float64)
    out[np.arange(len(y)), y] = 1.0
    return out


def train_softmax(X, y, lr=0.1, epochs=500):
    n, d = X.shape
    c = int(np.max(y)) + 1
    W = np.zeros((d, c), dtype=np.float64)
    b = np.zeros((1, c), dtype=np.float64)
    Y = one_hot(y, c)

    hist = []
    for _ in range(epochs):
        logits = X @ W + b
        prob = softmax(logits)

        loss = -np.mean(np.sum(Y * np.log(prob + 1e-12), axis=1))
        hist.append(loss)

        dlogits = (prob - Y) / n
        dW = X.T @ dlogits
        db = np.sum(dlogits, axis=0, keepdims=True)

        W -= lr * dW
        b -= lr * db

    return W, b, hist

W_cls, b_cls, loss_cls = train_softmax(X_train, y_train, lr=0.2, epochs=600)

train_pred = np.argmax(X_train @ W_cls + b_cls, axis=1)
test_pred = np.argmax(X_test @ W_cls + b_cls, axis=1)

train_acc = np.mean(train_pred == y_train)
test_acc = np.mean(test_pred == y_test)

print('train acc:', round(float(train_acc), 4))
print('test  acc:', round(float(test_acc), 4))

1x1 畳み込みの出力を、目で見て確かめる

数式だけだと 1x1 conv は地味に見えますが、チャネルごとの混合が実際に画像としてどう現れるかを見ると役割がつかみやすくなります。ここでは出力マップを並べて、空間方向は保ったまま表現だけが変わっていることを確認します。

fig, ax = plt.subplots(figsize=(6.0, 3.5))
ax.plot(loss_cls, color='#2b6cb0')
ax.set_title('Classifier Loss on Conv Features')
ax.set_xlabel('epoch')
ax.set_ylabel('cross-entropy')
plt.tight_layout()
plt.show()

cm = np.zeros((2, 2), dtype=int)
for yt, yp in zip(y_test, test_pred):
    cm[yt, yp] += 1
print('confusion matrix (rows=true, cols=pred)')
print(cm)

PyTorch の小さな CNN と対応づける

最後に PyTorch 版でも同じような分類を行います。手で追った部品が、ライブラリの Conv2d, ReLU, Pool, Linear にどう対応するかを確認してください。

if TORCH_AVAILABLE:
    torch.manual_seed(42)

    X_train_img = torch.tensor(X_all[train_idx][:, None, :, :], dtype=torch.float32)
    y_train_t = torch.tensor(y_all[train_idx], dtype=torch.long)
    X_test_img = torch.tensor(X_all[test_idx][:, None, :, :], dtype=torch.float32)
    y_test_t = torch.tensor(y_all[test_idx], dtype=torch.long)

    model = nn.Sequential(
        nn.Conv2d(1, 8, kernel_size=3, padding=1),
        nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Conv2d(8, 16, kernel_size=3, padding=1),
        nn.ReLU(),
        nn.AdaptiveAvgPool2d((1, 1)),
        nn.Flatten(),
        nn.Linear(16, 2),
    )

    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=0.01)

    loss_torch = []
    for _ in range(60):
        logits = model(X_train_img)
        loss = criterion(logits, y_train_t)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        loss_torch.append(float(loss.detach()))

    with torch.no_grad():
        train_pred_t = torch.argmax(model(X_train_img), dim=1)
        test_pred_t = torch.argmax(model(X_test_img), dim=1)
        train_acc_t = (train_pred_t == y_train_t).float().mean().item()
        test_acc_t = (test_pred_t == y_test_t).float().mean().item()

    print('torch train acc:', round(train_acc_t, 4))
    print('torch test  acc:', round(test_acc_t, 4))

    fig, ax = plt.subplots(figsize=(6.0, 3.5))
    ax.plot(loss_torch, color='#6b46c1')
    ax.set_title('PyTorch CNN Training Loss')
    ax.set_xlabel('epoch')
    ax.set_ylabel('cross-entropy')
    plt.tight_layout()
    plt.show()
else:
    print('PyTorchが未導入のため、この節はスキップしました。')

分類 CNN から FCN へつながる

最後に 1x1 畳み込みを見ると、分類 CNN が「各位置のクラススコアを出す」形へ自然に拡張できることが分かります。これが FCN とセグメンテーションへの入口です。

# 形だけを確認する簡易デモ
feat_map = np.random.randn(1, 8, 6, 6)  # (batch, channel, height, width)
conv1x1_w = np.random.randn(3, 8)        # 3クラス用 1x1 conv 重み

# 1x1 conv: 各位置で channel 方向の内積
logits_map = np.einsum('oc,bchw->bohw', conv1x1_w, feat_map)
upsampled = np.repeat(np.repeat(logits_map, 2, axis=2), 2, axis=3)

print('feature map shape :', feat_map.shape)
print('logits map shape  :', logits_map.shape)
print('upsampled shape   :', upsampled.shape)

このノートで押さえるべき点は次の通りです。

次は損失関数・最適化・正則化の観点から、CNN学習をより安定化する方法を扱います。