Day 114 — CNN入門① — 畳み込み・プーリングの直感、画像データの扱い方

2026-08-04 紫 / Phase 5 理論→コーディング Conv2d / MaxPool2d / feature map / (N,C,H,W)

📚 背景知識(読んでから問題へ)

🟣
Day 110〜113で、PyTorchの基本装備(Tensor・自動微分・nn.ModuleDataset/DataLoader・GPU学習・モデル保存)が一通り揃いました。今日からは、その装備を使って画像認識(CV)の世界に入ります。核となる2つの操作、畳み込み(Convolution)プーリング(Pooling)を学びます。
🖼️
なぜ全結合層(nn.Linear)だけでは画像に向かないのか: 100×100ピクセル・3チャンネルの画像は入力次元数が30,000。これを1000ニューロンに全結合するだけで約3,000万パラメータが必要になり、画像が少し大きくなるだけでパラメータ数が爆発する。また「同じ模様が画像のどこにあっても同じパターンとして認識する」性質も活かせない。
🔑
キーワード
畳み込み(Convolution): 小さなフィルタを画像上でスライドさせ、フィルタとピクセル値の積和を取る操作。フィルタは画像全体で使い回される(重み共有)
プーリング(Pooling): 小領域ごとに代表値(最大値など)だけを残して縮小する操作。学習パラメータは持たない
特徴マップ(feature map): 畳み込み層の出力。フィルタが画像のどこに強く反応したかを表す
(N, C, H, W): PyTorchでの画像Tensorの標準的な形。バッチ数・チャンネル数・高さ・幅

🧩 画像データのTensorスキーマ: (N, C, H, W)

表形式データの(N, 特徴量数)という2次元Tensorとは違い、画像は縦横の空間的な位置関係を持つため4次元になります。

記号意味
Nバッチサイズ(同時に処理する画像の枚数)32
Cチャンネル数(グレースケール=1, RGBカラー=3)1 or 3
H画像の高さ(ピクセル数)28
W画像の幅(ピクセル数)28

📐 出力サイズの計算式(今日いちばん使う道具)

out = (in − kernel_size) ÷ stride + 1

paddingなし・切り捨て除算の場合。例: 28×28画像に3×3カーネル・stride1 → (28−3)//1+1 = 26

タスク2のスクラッチ実装で使う6×6画像に3×3フィルタを当てるイメージ(出力は4×4):

0
0
0
5
5
5
0
0
0
5
5
5
0
0
0
5
5
5
0
0
0
5
5
5
0
0
0
5
5
5
0
0
0
5
5
5

左半分が暗い(0)・右半分が明るい(5)の6×6画像。縦のエッジ検出フィルタを当てると、境界線付近だけ値が大きくなる(模範解答参照)。

🎯 問題

1
なぜCNNは画像に強いのか(理論): 全結合層だけで画像を扱う問題点と、畳み込み層(パラメータ数・重み共有・平行移動不変性)がそれをどう解決するかを説明する
2
numpyで畳み込みをスクラッチ実装(コーディング): 6×6画像と3×3の縦エッジ検出フィルタを用意し、conv2d_scratch(image, kernel)を実装して4×4の特徴マップを求める
3
nn.Conv2d / nn.MaxPool2d の出力サイズ確認(コーディング): (4,1,28,28)のダミー画像を通し、出力サイズの計算式と実際のshapeが一致することを確認する
4
SimpleCNNクラスの実装(コーディング・メイン): Conv→ReLU→Pool を2段重ねてFlatten→Linearで10クラス分類まで通し、出力shapeが(4,10)になることを確認する

💡 ヒント

ヒント1方向性

タスク1は「全結合層で画像を見る」を「1枚の写真の全ピクセルを1つ残らず記憶しようとする」、「畳み込みで画像を見る」を「小さな虫眼鏡(フィルタ)を持って画像の隅から隅までなぞりながら、同じパターンを探す」という比喩で考えると整理しやすいです。虫眼鏡は1つ作れば画像のどこにでも使い回せる、というのが重み共有の直感です。タスク2〜4は「畳み込みは掛け算して足し算するだけ」「サイズの変化は(in - kernel) // stride + 1の式で追いかけられる」という2点さえ押さえれば、Day 110〜113のnn.Moduleのコードに層を追加するだけです。

ヒント2アプローチ
  • スクラッチ実装: 出力の各マスは「画像の対応する3×3の範囲」と「フィルタ」の要素ごとの掛け算の総和(np.sum(image[i:i+3, j:j+3] * kernel))で求まる。2重forループで出力マスの位置を動かす
  • 出力サイズの式: out = (in - kernel_size) // stride + 1。28×28に3×3カーネル・stride1で26、さらに2×2のMaxPoolで13
  • nn.Conv2dout_channelsは「何種類のフィルタを学習するか」=次の層への出力チャンネル数
  • Flatten直前のサイズは「最後のConv/Poolの出力チャンネル数 × 高さ × 幅」。nn.Flatten()x.view(x.size(0), -1)で1次元化する
  • 具体的な入力サイズが分からなければ、一度ダミー入力をforwardに通してshapeをprintすればLinearに渡すべき次元数が確認できる(実務でもよく使うデバッグ手法)
ヒント3コード骨格
def conv2d_scratch(image, kernel):
    kh, kw = kernel.shape
    ih, iw = image.shape
    oh, ow = ih - kh + 1, iw - kw + 1
    output = np.zeros((oh, ow))
    for i in range(oh):
        for j in range(ow):
            region = image[i:i+kh, j:j+kw]
            output[i, j] = np.sum(region * kernel)
    return output

# nn.Conv2d / nn.MaxPool2d
x = torch.randn(4, 1, 28, 28)
conv = nn.Conv2d(in_channels=1, out_channels=8, kernel_size=3)
pool = nn.MaxPool2d(kernel_size=2)
x1 = conv(x)
print(x1.shape)   # (4, 8, 26, 26)
x2 = pool(x1)
print(x2.shape)   # (4, 8, 13, 13)

# SimpleCNN
class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 8, kernel_size=3)
        self.conv2 = nn.Conv2d(8, 16, kernel_size=3)
        self.pool = nn.MaxPool2d(2)
        self.relu = nn.ReLU()
        self.flatten = nn.Flatten()
        self.fc = nn.Linear(16 * 5 * 5, 10)  # ← forwardで確認して埋める

    def forward(self, x):
        x = self.pool(self.relu(self.conv1(x)))
        x = self.pool(self.relu(self.conv2(x)))
        x = self.flatten(x)
        x = self.fc(x)
        return x

模範解答

タスク1: なぜCNNは画像に強いのか

全結合層だけで画像を扱う問題点: 全結合層は入力の全ピクセルと次の層の全ニューロンを1対1でつなぐため、画像サイズが大きくなるとパラメータ数が爆発的に増える(例: 100×100×3チャンネルを1000ニューロンに全結合するだけで約3,000万パラメータ)。また全結合層は「ピクセルの位置」ごとに別々の重みを持つため、同じ模様が別の位置にあると一から学習し直す必要がある。
🧷
畳み込み層による解決: 小さなフィルタを画像全体でスライドさせながら同じ重みを使い回す(重み共有)。(1)パラメータ数はフィルタサイズだけで決まり画像サイズに依存しないため大幅に削減される、(2)1つのフィルタを学習すれば画像のどこでそのパターンを検出しても同じ重みが使われる(平行移動不変性に近い性質)、という2つの利点が生まれる。

タスク2: numpyで畳み込みをスクラッチ実装

import numpy as np

def conv2d_scratch(image, kernel):
    kh, kw = kernel.shape
    ih, iw = image.shape
    oh, ow = ih - kh + 1, iw - kw + 1
    output = np.zeros((oh, ow))
    for i in range(oh):
        for j in range(ow):
            region = image[i:i+kh, j:j+kw]
            output[i, j] = np.sum(region * kernel)
    return output

# 左半分が暗い(0)・右半分が明るい(5) = 縦のエッジがある6x6画像
image = np.array([
    [0, 0, 0, 5, 5, 5],
    [0, 0, 0, 5, 5, 5],
    [0, 0, 0, 5, 5, 5],
    [0, 0, 0, 5, 5, 5],
    [0, 0, 0, 5, 5, 5],
    [0, 0, 0, 5, 5, 5],
], dtype=float)

# 縦方向のエッジを検出するフィルタ
kernel = np.array([
    [1, 0, -1],
    [1, 0, -1],
    [1, 0, -1],
], dtype=float)

feature_map = conv2d_scratch(image, kernel)
print(feature_map.shape)  # (4, 4)
print(feature_map)
# [[ 0. 15. 15.  0.]
#  [ 0. 15. 15.  0.]
#  [ 0. 15. 15.  0.]
#  [ 0. 15. 15.  0.]]
# 画像の左右の境界(縦のエッジ)付近で値が大きくなっている = フィルタがエッジに反応した

タスク3: nn.Conv2d / nn.MaxPool2d の出力サイズ確認

import torch
import torch.nn as nn

x = torch.randn(4, 1, 28, 28)
print(x.shape)  # torch.Size([4, 1, 28, 28])

conv = nn.Conv2d(in_channels=1, out_channels=8, kernel_size=3)
x1 = conv(x)
print(x1.shape)  # torch.Size([4, 8, 26, 26])
# 手計算: (28 - 3) // 1 + 1 = 26 → 一致

pool = nn.MaxPool2d(kernel_size=2)
x2 = pool(x1)
print(x2.shape)  # torch.Size([4, 8, 13, 13])
# 手計算: (26 - 2) // 2 + 1 = 13 → 一致

タスク4: SimpleCNNクラスの実装

import torch
import torch.nn as nn

class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 8, kernel_size=3)   # (N,1,28,28) → (N,8,26,26)
        self.conv2 = nn.Conv2d(8, 16, kernel_size=3)  # (N,8,13,13) → (N,16,11,11)
        self.pool = nn.MaxPool2d(2)
        self.relu = nn.ReLU()
        self.flatten = nn.Flatten()
        # conv2+pool後: (N,16,11,11) → pool → (N,16,5,5) → flatten → (N, 16*5*5=400)
        self.fc = nn.Linear(16 * 5 * 5, 10)

    def forward(self, x):
        x = self.pool(self.relu(self.conv1(x)))  # (N,8,26,26) → (N,8,13,13)
        x = self.pool(self.relu(self.conv2(x)))  # (N,16,11,11) → (N,16,5,5)
        x = self.flatten(x)                       # (N, 400)
        x = self.fc(x)                             # (N, 10)
        return x

model = SimpleCNN()
dummy_input = torch.randn(4, 1, 28, 28)
output = model(dummy_input)
print(output.shape)  # torch.Size([4, 10])

🏗️ SimpleCNNの構造(データがどう形を変えていくか)

入力画像 (N,1,28,28) Conv1+ReLU →(N,8,26,26) Pool→(N,8,13,13) Conv2+ReLU →(N,16,11,11) Pool→(N,16,5,5) Flatten (N, 400) Linear 400→10 出力 (N, 10) 10クラススコア 特徴抽出(Conv+Pool)は空間情報を保ったまま処理 → Flattenで1本のベクトルに → 分類はLinearに任せる

📊 パラメータ数のイメージ比較(概念図)

100×100×3チャンネルの画像を1000ニューロンの層につなぐ場合の概念的な比較です(実際の数値は構成により変わります)。

全結合層(Linear)1層のみ
約3,000万パラメータ
画像サイズに比例して爆発
畳み込み層(3×3, 8フィルタ)
約224パラメータ
画像サイズに依存しない
💡
畳み込み層のパラメータ数は「カーネルサイズ × 入力チャンネル数 × 出力チャンネル数 + バイアス」で決まり、画像がどれだけ大きくなっても変わりません(今日のタスク3のような3×3・1→8チャンネルなら 3×3×1×8+8=80)。これが「全結合層だけでは画像に向かない」の具体的な数値イメージです。

🪜 Step-by-Step 解説

1画像を4次元Tensor (N, C, H, W) として扱う

x = torch.randn(4, 1, 28, 28)
🔧
なぜこうするか: 表形式データが(サンプル数, 特徴量数)の2次元だったのに対し、画像は縦・横の空間的な位置関係を持つ情報なので、それを潰さずに扱うためにH(高さ)・W(幅)という2つの軸を残したまま4次元Tensorにする。C(チャンネル)は「同じ位置に複数の値が重なっている」層で、グレースケールなら1、RGBカラーなら3になる。

2nn.Conv2d で特徴マップを作る

conv = nn.Conv2d(in_channels=1, out_channels=8, kernel_size=3)
x1 = conv(x)  # (4, 1, 28, 28) → (4, 8, 26, 26)
🧷
なぜこうするか: out_channels=8は「8種類の異なるフィルタを学習する」ことを意味し、出力は8枚の特徴マップになる。kernel_size=3・paddingなしのため、フィルタが画像の端まで到達できる範囲が2ピクセル分減り、出力サイズは28→26に縮む。

3nn.MaxPool2d で情報を圧縮する

pool = nn.MaxPool2d(kernel_size=2)
x2 = pool(x1)  # (4, 8, 26, 26) → (4, 8, 13, 13)
📦
なぜこうするか: 2×2の各領域から最大値だけを残すことで、縦横それぞれのサイズが半分になる。データ量が1/4に減り計算コストが下がるだけでなく、「多少位置がズレても最大値は変わらない」という頑健性も得られる。プーリングには学習パラメータが無いため、モデルの重みは増えない。

4Flatten で4次元を2次元に戻し Linear に渡す

x = self.flatten(x)  # (N, 16, 5, 5) → (N, 400)
x = self.fc(x)        # (N, 400) → (N, 10)
🚧
なぜこうするか: Conv/Pool層の役割は「画像から役立つ特徴を抽出すること」で、最終的な分類判断は全結合層に任せる。そのために空間的な形を持つ(C, H, W)を1本の長いベクトル(C*H*W,)に平坦化する必要がある。Linearの入力次元数は直前の特徴マップのC×H×Wに正確に一致させないとRuntimeErrorになる。

🧮 数学・統計の補足(文系向け)

🧵
畳み込みは「型紙を当てて模様を探す」作業: 布地(画像)の上に決まった形の型紙(フィルタ)を少しずつ動かしながら当てていき、「型紙とぴったり合う部分ほど強く反応する」イメージです。タスク2のエッジ検出フィルタは「左側が明るく右側が暗い(またはその逆の)境界線」に強く反応するように数値が設計されています。実際の学習ではこの型紙の数値自体も自動的に最適化され、人間が事前に設計しなくてもデータから有効なパターンを見つけ出します。
🔢
出力サイズの式の直感: 6マスの列に3マスのフィルタを1マスずつずらしながら何回置けるかを数える計算です。最初の位置から最後に置ける位置までは6-3=3マス分動けるので3回、それに最初の1回を足して3+1=4通り置けます。これが(6-3)//1+1=4の意味で、タスク2の6×6画像に3×3フィルタを適用した結果が4×4になるのと一致します。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 画像認識(CV) / ☐ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 時系列(Time Series)

標準構成

🧱 画像分類コンペの基本形

CNNで特徴抽出 → 全結合層でクラス分類

猫犬分類・病変検出・衛星画像分類など、Kaggle画像コンペの大半はこの構造がベース。実務ではResNetやEfficientNetなど事前学習済みCNNを流用することが多いが、内部構造の理解は今日のようなシンプルなCNNから。

実務スキル

🐞 出力サイズのデバッグ

ダミー入力をforwardに通してshape確認

Conv2d/MaxPool2dを何層も重ねるとLinearに渡す入力次元数の計算ミスが頻発する。ダミー入力でshapeを確認するデバッグ習慣は実務でも非常によく使われる。

設計の定石

📈 チャンネル数を深いほど増やす

8 → 16 → 32 ... と段階的に

浅い層では「エッジ・色」のような単純なパターンを少数のフィルタで検出し、深い層ほど複雑なパターンをより多くのフィルタで検出する、というのがCNN設計の直感。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Linearの入力次元数を勘で決めてエラーになるConv/Poolを重ねた後のサイズ計算を暗算で済ませようとする(in-kernel)//stride+1を層ごとに順番に適用するか、ダミー入力を通してshapeを確認してからLinearを定義する
チャンネル数(C)と画像サイズ(H,W)を混同する4次元Tensorに慣れていない(N,C,H,W)の並びは固定。Cは「フィルタの種類数」、H・Wは「1枚の特徴マップの縦横サイズ」で全く別の軸
プーリングにも学習パラメータがあると思い込む「層」と名がつくものは全部学習すると誤解するnn.MaxPool2dは固定ルールで動き、学習される重みは持たない。model.parameters()にも現れない
フィルタの中身を人間が設計しないといけないと思うタスク2でエッジ検出フィルタを手で用意したためタスク2は理解のための手作業。実際の学習ではnn.Conv2dの重みはランダム初期化され誤差逆伝播で自動的に最適化される
画像サイズが変わるとモデルを作り直す必要があると思うLinearの入力次元数が画像サイズに依存するためnn.AdaptiveAvgPool2dなどを使えばFlatten直前の出力サイズを画像サイズによらず固定できる(発展内容)

🚀 次のステップ

  • 発展: タスク2のフィルタを[[1,1,1],[0,0,0],[-1,-1,-1]](横エッジ検出用)に変えて、縦エッジ用フィルタとの出力の違いを比較してみましょう
  • 次回予告: Day 115 — CNN入門②(実際の画像データセットでの学習・DataLoaderへの画像前処理・簡易的な学習ループ)。今日組み立てたSimpleCNNの構造を使い、実際に手を動かして画像分類の学習ループを回します

Phase 5 の学習マップ(全20テーマ予定)

1-5 DL基礎(PyTorch) 完了
6-10 NLP
11-15 CV ← 本日
16-18 時系列
19-20 マルチモーダル

Day 113で「1-5 DL基礎」が完了。Phase 5内では順序を入れ替え、NLPより先に画像認識(CV)のCNNから着手する。

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: