Lesson 8 / 12

CNNの学習・評価・正則化

このレッスンで学ぶこと

  • 評価時の作法(eval()no_grad)と正解率を計算する評価ループを書ける
  • BatchNorm を含む畳み込みブロックの形を追え、train/eval で統計の使い方が変わることを説明できる
  • 転移学習(凍結・付け替え・fine-tuning)のコードを読み書きできる
  • ResNet の skip connection(残差・downsample)が何をしているか説明できる
  • これらを統合したCNN学習パイプライン全体を読める

1. 学習と評価の作法

CNN の学習ループ自体は L06 と同じ(zero_grad → 順伝播 → 損失 → backward → step)。 違いは評価フェーズです。評価では model.eval() に切り替え、with torch.no_grad(): で囲むのが定石。 dropout や BatchNorm の挙動を評価モードにし、勾配計算を止めて速く・正確に測ります。

2. 正解率(accuracy)の計算

分類の評価でよく出るのが正解率。モデルの出力(ロジット)から argmax で予測クラスを取り、正解と比べて一致率を出します。

accuracy.py(PyTorch・読むだけ)
import torch

# 3サンプル・3クラスのロジット(モデル出力の例)
logits = torch.tensor([[2., 1., 0.],
                       [0., 3., 1.],
                       [1., 0., 2.]])
y = torch.tensor([0, 1, 1])      # 正解クラス

pred = logits.argmax(dim=1)    # 各行で最大のクラス → [0, 1, 2]
acc = (pred == y).float().mean()  # 一致率

print("pred:", pred.tolist())
print("acc :", round(acc.item(), 4))  # 3つ中2つ正解 → 0.6667
▼ 出力
pred: [0, 1, 2]
acc : 0.6667

実際の評価では、これをバッチごとに繰り返して全体で集計します。 model.eval()torch.no_grad() を付けた評価関数にまとめておくと、学習ループから何度でも呼べて便利です。

evaluate.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
torch.manual_seed(0)

# 評価用データ(120枚・2クラス)と未学習モデル
X = torch.randn(120, 1, 8, 8)
y = (X.mean(dim=(1, 2, 3)) > 0).long()
loader = DataLoader(TensorDataset(X, y), batch_size=32)
model = nn.Sequential(nn.Flatten(), nn.Linear(64, 2))

def evaluate(model, loader):
    model.eval()                       # ① 評価モード(dropout/BNを評価用に)
    correct, total = 0, 0
    with torch.no_grad():              # ② 勾配を計算しない(速い・省メモリ)
        for xb, yb in loader:
            pred = model(xb).argmax(dim=1)        # ③ バッチごとの予測クラス
            correct += (pred == yb).sum().item()  # ④ 正解数を加算
            total += yb.size(0)
    return correct / total             # ⑤ 最後に全体で割る

print("acc:", round(evaluate(model, loader), 4))
▼ 出力
acc: 0.4333

💡 評価ループの定石(上の evaluate() がこれ): model.eval() → ② with torch.no_grad(): の中で、③ argmax で予測 → ④ 正解数を加算 → ⑤ 最後に全体で割るargmax(dim=1)dim=1 は「クラス方向で最大」の意味です(softmax をかけても順位は変わらないので、ロジットのまま argmax してOK)。 上の出力 0.4333未学習モデルなので偶然レベル。学習が進むと、この同じ関数で測る精度が上がっていきます(§6で確認)。

3. 正則化を組み込んだ畳み込みブロック

過学習を抑える手段は L06 で見た通り(dropout・BatchNorm・weight decay・early stopping)。 CNN では BatchNorm2d を畳み込みの後に挟むのが定番です。典型的なブロックは Conv2d → BatchNorm2d → ReLU → MaxPool2d の並びです。

conv_block.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
torch.manual_seed(0)

block = nn.Sequential(
    nn.Conv2d(1, 16, 3, padding=1),  # 28 -> 28, 16ch
    nn.BatchNorm2d(16),            # チャネルごとに正規化(学習を安定化)
    nn.ReLU(),
    nn.MaxPool2d(2),               # 28 -> 14
)
x = torch.randn(8, 1, 28, 28)
print("block出力:", block(x).shape)   # (8, 16, 14, 14)
▼ 出力
block出力: torch.Size([8, 16, 14, 14])

BatchNorm が train()eval() で挙動を変える正体は、内部に持つ running統計(runningの平均 running_mean・分散 running_var)です。処理手順はこう分かれます。

下のコードで、train() のまま入力を1回通すと、running統計が 0 から動くことを確認します。

bn_modes.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
torch.manual_seed(0)

bn = nn.BatchNorm2d(3)                        # 3チャネル分のrunning統計を持つ
print("初期 running_mean:", bn.running_mean.tolist())   # 最初は全部0

bn.train()                                  # ① 学習モードにする
x = torch.randn(16, 3, 8, 8) * 2 + 5         # 平均5・ばらつき2あたりの入力
_ = bn(x)                                 # ② xを通す。出力は使わないので _ に捨てる
                                       #   ねらいは「running統計を更新させる」副作用のほう
print("更新後 running_mean:", [round(v, 3) for v in bn.running_mean.tolist()])
print("更新後 running_var :", [round(v, 3) for v in bn.running_var.tolist()])

bn.eval()                                  # ③ 評価モード。これ以降は上のrunning統計で正規化(バッチ統計は使わない)
▼ 出力
初期 running_mean: [0.0, 0.0, 0.0]
更新後 running_mean: [0.504, 0.493, 0.501]
更新後 running_var : [1.324, 1.293, 1.289]

💡 なぜ「平均5の入力」なのに running_mean は約0.5? running統計は一気に置き換えず、移動平均で目標に少しずつ近づけます。更新式はこうです:

新しい running_mean = (1 − momentum) × 旧 + momentum × バッチ平均

既定の momentum=0.1 なので、1バッチ通しただけだと 0.9 × 0 + 0.1 × 5 = 0.5。 つまり「目標(このバッチの平均=約5)に10%だけ近づいた」状態で 0 → 約0.5 です。これを何バッチも繰り返すうちに、だんだん5へ近づいていきます(running_var も同様)。

💡 だから評価の前に eval() を呼ぶ: eval() を忘れると、BatchNorm はその評価バッチの統計で正規化してしまい、同じ画像でも一緒に入れた他の画像しだいで結果が変わりますeval() にすれば学習で貯めた running統計を使うので、いつ・どんなバッチで評価しても安定します。

理論の確認: BatchNorm2d(C) の引数はチャネル数。各チャネルについて、ミニバッチ内で平均0・分散1になるよう正規化します。 学習を安定させ、より大きな学習率を使えるようにする効果があります。評価時は eval() で学習中に蓄えた統計を使う点に注意(L06)。
📘 理論の復習: 正規化(Batch/Layer Normalization 等)の理論は E資格対策ページ 「汎化性能向上のためのテクニック ― 正規化」、 dropout・L1/L2・早期終了などの正則化全体は 「深層モデルのための正則化」 で復習できます。

4. 転移学習(凍結と付け替え)

ゼロから学習する代わりに、大規模データ(ImageNet等)で学習済みのモデルを再利用するのが転移学習です。 学習済みの畳み込み部分は「エッジ・形・模様」といった汎用的な特徴をすでに獲得しているので、手元のデータが少なくても高精度を狙えます。

💡 2つの戦略:

  • ① 特徴抽出(feature extraction):畳み込み部分を凍結し、最後の分類層だけ付け替えて学習。データが少ないとき向き。
  • ② ファインチューニング(fine-tuning):分類層を付け替えたうえで、一部〜全体も小さい学習率で再学習。データがそこそこあるとき向き。

凍結は requires_grad = False、付け替えはその層を新しい層で上書きするだけです。

実務では torchvision の学習済みモデルを使うのが定番です。型はこうです(重みのダウンロードが必要なので、ここでは構造だけ確認)。

transfer_real.py(PyTorch・実務の型)
import torch
import torch.nn as nn
from torchvision import models

net = models.resnet18(weights="IMAGENET1K_V1")   # 学習済みResNet18をロード

# ① backbone を凍結
for p in net.parameters():
    p.requires_grad = False

# ② 最終の全結合(fc)を自分のクラス数(=3)に付け替え(新しい層はrequires_grad=True)
net.fc = nn.Linear(net.fc.in_features, 3)

# ③ optimizer には「学習する層」だけ渡す
opt = torch.optim.Adam(net.fc.parameters(), lr=1e-3)

仕組みを小さなモデルで実際に確認します(凍結 → 付け替え → 学習対象のパラメータ数を数える)。

transfer.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
torch.manual_seed(0)

# 学習済みモデルに見立てた「特徴抽出器+分類層」
model = nn.Sequential(
    nn.Conv2d(3, 8, 3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(1),
    nn.Flatten(),
    nn.Linear(8, 1000),    # 元の分類層(1000クラス)
)

# ① すべて凍結
for p in model.parameters():
    p.requires_grad = False

# ② 最後の層を3クラス用に付け替え(新しい層は学習対象)
model[-1] = nn.Linear(8, 3)

total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("総パラメータ    :", total)        # 251
print("学習するパラメータ:", trainable)  # 27(付け替えた層だけ)
▼ 出力
総パラメータ    : 251
学習するパラメータ: 27

💡 効く理由と実装のコツ:

  • 少データ・短時間で高精度:汎用特徴は再利用し、最後の層だけ学習すればよい。新しく作った層は requires_grad=True が既定なので自動で学習対象。
  • optimizer には学習する層だけ渡すのが安全(凍結分は勾配が無いので渡しても更新されませんが、明示すると意図が明確)。
  • 凍結した BatchNorm は eval():backbone を凍結するなら統計も更新しないよう評価モードにするのが定石。
  • fine-tuning するなら学習率を小さめに(学習済みの重みを壊さないため)。まず分類層だけ学習 → その後 backbone も小さい lr で、という二段構えも一般的。

5. ResNet と skip connection(残差)

層を深くするほど表現力は上がりますが、勾配が消えて(小さくなって)学習しにくくなる問題が起きます。 ResNet はこれを skip connection(残差接続)で解決しました。 ブロックの出力に入力をそのまま足すout + x)ことで、勾配が「近道」を通って流れやすくなります。

residual.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
torch.manual_seed(0)

class Residual(nn.Module):
    def __init__(self, ch):
        super().__init__()
        self.conv1 = nn.Conv2d(ch, ch, 3, padding=1)
        self.bn1   = nn.BatchNorm2d(ch)
        self.conv2 = nn.Conv2d(ch, ch, 3, padding=1)
        self.bn2   = nn.BatchNorm2d(ch)

    def forward(self, x):
        out = torch.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        return torch.relu(out + x)   # ← 入力xを足す(skip connection)

x = torch.randn(4, 16, 8, 8)
print("残差ブロック出力:", Residual(16)(x).shape)  # 形は変わらない
▼ 出力
残差ブロック出力: torch.Size([4, 16, 8, 8])

⚠ skip connection の条件: out + x ができるのは out と x の形が同じのときだけ。だから残差ブロックは 入力と同じチャネル数・同じ H×W を保つように作ります(padding=1, K=3 でサイズ維持)。 形が変わる場合は x 側にも 1×1 畳み込みを入れて形を合わせます。

実際の ResNet には、途中でチャネルを増やし・サイズを半分にするブロックがあります。そのときは x 側に 1×1 畳み込み(downsample)を通して形をそろえてから足します。

residual_down.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
torch.manual_seed(0)

class ResidualDown(nn.Module):
    def __init__(self, in_ch, out_ch, stride=2):
        super().__init__()
        self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride=stride, padding=1)
        self.bn1   = nn.BatchNorm2d(out_ch)
        self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1)
        self.bn2   = nn.BatchNorm2d(out_ch)
        # x 側を out と同じ形にそろえる 1×1 畳み込み
        self.down  = nn.Conv2d(in_ch, out_ch, 1, stride=stride)

    def forward(self, x):
        out = torch.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        return torch.relu(out + self.down(x))   # 形をそろえてから足す

x = torch.randn(4, 16, 8, 8)
print("出力:", ResidualDown(16, 32, stride=2)(x).shape)  # ch16→32, 8→4
▼ 出力
出力: torch.Size([4, 32, 4, 4])
📘 理論の復習: skip connection が緩和する「勾配消失」の理論(活性化関数と勾配の関係)は、E資格対策ページ 「順伝播型ニューラルネットワーク ― 活性化関数(勾配消失)」 で復習できます。

6. 総合演習:CNN学習パイプライン全体

このレッスンの仕上げです。これまでの部品——Conv–BatchNorm–ReLU・残差ブロック・Global Average Pooling・学習ループ・評価関数(eval/no_grad)——を1つにまとめた、 最小だが本格的な学習パイプラインを読み解きます。各部分がどの回の内容かを意識しながら追ってみましょう。

cnn_pipeline.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
torch.manual_seed(0)

# --- データ:合成画像(1ch・16×16)を2クラスに ---
X = torch.randn(256, 1, 16, 16)
y = (X.mean(dim=(1, 2, 3)) > 0).long()
train_loader = DataLoader(TensorDataset(X[:200], y[:200]), batch_size=32, shuffle=True)
test_loader  = DataLoader(TensorDataset(X[200:], y[200:]), batch_size=32)

# --- 残差ブロック(このレッスンの5.)---
class Residual(nn.Module):
    def __init__(self, ch):
        super().__init__()
        self.conv1 = nn.Conv2d(ch, ch, 3, padding=1); self.bn1 = nn.BatchNorm2d(ch)
        self.conv2 = nn.Conv2d(ch, ch, 3, padding=1); self.bn2 = nn.BatchNorm2d(ch)
    def forward(self, x):
        out = torch.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        return torch.relu(out + x)            # skip connection

# --- モデル:Conv-BN-ReLU → 残差 → GAP → 全結合 ---
model = nn.Sequential(
    nn.Conv2d(1, 8, 3, padding=1), nn.BatchNorm2d(8), nn.ReLU(),
    Residual(8),
    nn.AdaptiveAvgPool2d(1), nn.Flatten(),   # GAP → (N, 8)
    nn.Linear(8, 2),
)
loss_fn = nn.CrossEntropyLoss()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)

# --- 評価関数(このレッスンの2.)---
def evaluate(model, loader):
    model.eval()
    correct, total = 0, 0
    with torch.no_grad():
        for xb, yb in loader:
            correct += (model(xb).argmax(1) == yb).sum().item()
            total += yb.size(0)
    return correct / total

# --- 学習ループ(L6):train()で学習 → evaluate()で測る ---
for epoch in range(3):
    model.train()
    for xb, yb in train_loader:
        opt.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        opt.step()
    print(f"epoch {epoch}  test_acc {evaluate(model, test_loader):.3f}")
▼ 出力
epoch 0  test_acc 0.554
epoch 1  test_acc 0.554
epoch 2  test_acc 0.661

💡 ここを変えて試してみよう(まず出力を予想 → 手元で確かめる):

  • エポック数を 3 → 10 に増やすと test_acc はどう動く?
  • Residual(8) を外すと(残差なし)学習の進みはどう変わる?
  • nn.BatchNorm2d(8) を抜くと収束は速い?遅い?
  • 評価で model.eval() を呼ばないと、結果はどうブレる?

※ PyTorchはこのページでは動かせないので、まず頭の中で予想してから、手元の環境で実際に動かして確かめるのがおすすめです(合成データなので精度は参考値)。

7. 練習問題

問題 1

正解率はいくつ?

予測 pred = [1, 0, 1, 1]、正解 y = [1, 1, 1, 0] のとき、正解率はいくつですか。

  • A. 0.25
  • B. 0.50
  • C. 0.75
答えと解説を見る

正解:B. 0.50

一致しているのは1番目(1=1)と3番目(1=1)の2つ。4つ中2つで 2/4 = 0.5

問題 2

層を凍結するには?

転移学習で、ある層を「学習しない(凍結)」にするにはどうしますか。

  • A. その層のパラメータの requires_gradFalse にする
  • B. その層を eval() にする
  • C. その層を削除する
答えと解説を見る

正解:A

p.requires_grad = False にすると勾配が計算されず、step() で更新されません=凍結。 eval() は dropout/BatchNorm のモード切り替えで、凍結とは別物です。

問題 3

skip connection の役割は?

ResNet の skip connection(out + x)の主な目的はどれですか。

  • A. 画像のサイズを小さくする
  • B. クラス数を増やす
  • C. 深いネットワークでも勾配が流れやすくし、学習を可能にする
答えと解説を見る

正解:C

入力を足すことで勾配が「近道」を通れるようになり、勾配消失を緩和。これにより非常に深いネットワークでも学習できます。

問題 4

BatchNorm2d の引数は?

nn.BatchNorm2d(?) に渡すのは何の数ですか。

  • A. バッチサイズ
  • B. チャネル数
  • C. 画像の高さ
答えと解説を見る

正解:B. チャネル数

BatchNorm2d(C) はチャネルごとに正規化するので、引数はチャネル数。直前の畳み込みの out_ch に合わせます。

問題 5

畳み込みブロックの出力 shape は?

次のブロックに (4, 3, 32, 32) を通すと、出力 shape はどれですか。

quiz_block.py
block = nn.Sequential(
    nn.Conv2d(3, 32, 3, padding=1),
    nn.BatchNorm2d(32),
    nn.ReLU(),
    nn.MaxPool2d(2),
)
out = block(torch.randn(4, 3, 32, 32))
  • A. (4, 32, 16, 16)
  • B. (4, 32, 32, 32)
  • C. (4, 3, 16, 16)
答えと解説を見る

正解:A. (4, 32, 16, 16)

チャネルは 3→32Conv2d(pad=1, K=3)32×32 を保ち、BatchNorm2dReLU は形を変えず、MaxPool2d(2)32→16 にします。

問題 6

学習するパラメータ数は?

次の転移学習コードのあと、trainable(学習対象のパラメータ数)はいくつですか。

quiz_transfer.py
model = nn.Sequential(
    nn.Linear(10, 20), nn.ReLU(), nn.Linear(20, 5)
)
for p in model.parameters():
    p.requires_grad = False
model[2] = nn.Linear(20, 4)   # 付け替え(新しい層は学習対象)

trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
  • A. 304
  • B. 220
  • C. 84
答えと解説を見る

正解:C. 84

凍結後に付け替えた Linear(20, 4) だけが学習対象。20×4 + 4 = 84。 元の層は requires_grad=False のままなので数えません(総数は 304)。

8. まとめ

このレッスンのポイント

  • 評価は model.eval()with torch.no_grad():評価ループ。バッチごとに argmax → 正解数を加算 → 全体で割る
  • BatchNorm2d(C) の引数はチャネル数train()はバッチ統計+running更新/eval()はrunning統計を使う(だから評価前に eval()
  • CNN の正則化は BatchNorm・dropout・weight decay・early stopping
  • 転移学習=「特徴抽出部を凍結requires_grad=False)+最後の層を付け替え」。少データは特徴抽出、データがあれば fine-tuning(小さいlr)
  • ResNet の skip connection(out + xは勾配消失を緩和。形が変わる所は 1×1 畳み込み(downsample)x をそろえる
  • これらを統合した学習パイプライン=データ → モデル(Conv-BN-残差-GAP) → train()で学習 → eval()で評価

次は、画像以外の系列データ(RNN/LSTM)と、いま主流の Transformer・Self-Attention のコードを読み解きます。

完了するとコース一覧に進捗が記録されます