📚 背景知識(読んでから問題へ)
nn.Module・Dataset/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 |
📐 出力サイズの計算式(今日いちばん使う道具)
paddingなし・切り捨て除算の場合。例: 28×28画像に3×3カーネル・stride1 → (28−3)//1+1 = 26
タスク2のスクラッチ実装で使う6×6画像に3×3フィルタを当てるイメージ(出力は4×4):
左半分が暗い(0)・右半分が明るい(5)の6×6画像。縦のエッジ検出フィルタを当てると、境界線付近だけ値が大きくなる(模範解答参照)。
🎯 問題
conv2d_scratch(image, kernel)を実装して4×4の特徴マップを求める💡 ヒント
タスク1は「全結合層で画像を見る」を「1枚の写真の全ピクセルを1つ残らず記憶しようとする」、「畳み込みで画像を見る」を「小さな虫眼鏡(フィルタ)を持って画像の隅から隅までなぞりながら、同じパターンを探す」という比喩で考えると整理しやすいです。虫眼鏡は1つ作れば画像のどこにでも使い回せる、というのが重み共有の直感です。タスク2〜4は「畳み込みは掛け算して足し算するだけ」「サイズの変化は(in - kernel) // stride + 1の式で追いかけられる」という2点さえ押さえれば、Day 110〜113のnn.Moduleのコードに層を追加するだけです。
- スクラッチ実装: 出力の各マスは「画像の対応する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.Conv2dのout_channelsは「何種類のフィルタを学習するか」=次の層への出力チャンネル数Flatten直前のサイズは「最後のConv/Poolの出力チャンネル数 × 高さ × 幅」。nn.Flatten()かx.view(x.size(0), -1)で1次元化する- 具体的な入力サイズが分からなければ、一度ダミー入力を
forwardに通してshapeをprintすればLinearに渡すべき次元数が確認できる(実務でもよく使うデバッグ手法)
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は画像に強いのか
タスク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の構造(データがどう形を変えていくか)
📊 パラメータ数のイメージ比較(概念図)
100×100×3チャンネルの画像を1000ニューロンの層につなぐ場合の概念的な比較です(実際の数値は構成により変わります)。
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)
4Flatten で4次元を2次元に戻し Linear に渡す
x = self.flatten(x) # (N, 16, 5, 5) → (N, 400) x = self.fc(x) # (N, 400) → (N, 10)
(C, H, W)を1本の長いベクトル(C*H*W,)に平坦化する必要がある。Linearの入力次元数は直前の特徴マップのC×H×Wに正確に一致させないとRuntimeErrorになる。🧮 数学・統計の補足(文系向け)
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テーマ予定)
Day 113で「1-5 DL基礎」が完了。Phase 5内では順序を入れ替え、NLPより先に画像認識(CV)のCNNから着手する。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: