Day 116 — CNN入門③ — Data Augmentation・学習曲線の可視化・過学習の兆候の見つけ方

2026-08-06 紫 / Phase 5 理論→コーディング RandomHorizontalFlip / RandomRotation / 学習曲線 / 過学習検知

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

🟣
Data Augmentation(データ拡張)は「同じ問題集を、少しだけ形を変えて何度も解かせる」勉強法です。同じ画像を毎回全く同じ見た目のまま学習させると、モデルは「猫の耳の形」ではなく「その1枚のたまたまの明るさやノイズ」まで覚えてしまいます。学習中に画像を毎回少しだけ反転・回転させることで、本質的なパターンだけを覚えさせ、見た目の違いに振り回されないようにします。
📈
学習曲線(Learning Curve)は「テストの点数の伸びをグラフにしたもの」。エポック(学習を何周したか)を横軸に、訓練データと検証データそれぞれの loss・accuracy を縦軸にプロットします。2本の線を重ねて見ることで、モデルが順調に賢くなっているのか、それとも「問題集の丸暗記」に陥り始めているのかが一目で分かります。
🔑
キーワード
transforms.RandomHorizontalFlip(): 確率0.5で左右反転するAugmentation
transforms.RandomRotation(degrees): ±degrees度の範囲でランダムに回転
過学習(Overfitting): 訓練データへの適合度は上がり続けるのに、検証データへの適合度が頭打ち・悪化する現象
Early Stopping: 検証lossが改善しなくなったら学習を自動的に止めるテクニック(今日の判定ロジックがその原型)

🔍 過学習の兆候は「開いていく2本の線」で見つける

Loss Epoch train_loss val_loss val_lossが最小 (ベストエポック) ギャップ拡大 = 過学習の兆候

訓練lossは順調に下がり続けているのに、検証lossはある地点(黄色い縦線)を境に反転して上昇し始めています。この「ベストエポック以降にギャップが開いていく」形が、今日のタスク3で機械的に判定する対象です。

🎯 問題

Day 115で組み立てたFashionMNISTの学習ループをベースに、以下3つを実装してください。

1
Data Augmentationを追加したtransformを定義する: 学習用のtransformRandomHorizontalFlip()RandomRotation(10)を追加し、検証用はAugmentationなしにする
2
訓練・検証それぞれのloss/accuracyを記録しながら10エポック学習する: train_lossestrain_accsval_lossesval_accsの4リストに毎エポックの値を追加する
3
学習曲線を可視化し、過学習の兆候を機械的に判定する(メイン): loss/accuracyの推移をグラフ化し、val_losses[-1]min(val_losses)を比較して過学習の有無をprintする

💡 ヒント

ヒント1方向性

なぜ検証用データにはAugmentationをかけないのか考えてみましょう。Augmentationは「訓練を難しくして頑健性を高める」ための工夫であり、検証・テストの役割は「本番でどれくらいの実力が出るかを正確に測ること」です。測定条件そのものを毎回ランダムに変えてしまうと、モデルの実力を公平に評価できなくなります。

ヒント2アプローチ
  • transforms.Compose([...])は上から順に適用されるリスト。訓練用と検証用で「中身が違う2つのCompose」を用意し、Datasetにそれぞれ別のtransformを渡す
  • 1エポックの平均lossは「loss.item() * images.size(0)を積算し、最後にtotal(サンプル総数)で割る」のが正確な出し方(バッチサイズが端数でも歪まない)
  • 学習曲線のギャップは、val_lossesの中で「谷(最小値)を打った後、また上がっているか」をmin()とインデックスで確認すれば判定できる
  • val_losses[-1] > min(val_losses)が真なら「最終エポックの検証lossがベスト時点より悪化している」=過学習の兆候あり
ヒント3コード骨格
train_transform = transforms.Compose([
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(10),
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),
])
val_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),
])

train_losses, train_accs, val_losses, val_accs = [], [], [], []

for epoch in range(10):
    # --- 学習ループ(Day115と同様)---
    # --- 評価ループ(Day115と同様)---
    train_losses.append(...)
    val_losses.append(...)

# 過学習判定: min(val_losses) の位置 vs 最後の要素
if val_losses[-1] > min(val_losses):
    print("過学習の兆候あり")

模範解答

タスク1: Augmentation付きtransformの定義

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

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

train_transform = transforms.Compose([
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(10),
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),
])
val_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),
])

train_dataset = datasets.FashionMNIST(root="./data", train=True, download=True, transform=train_transform)
val_dataset = datasets.FashionMNIST(root="./data", train=False, download=True, transform=val_transform)

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)

タスク2: モデル定義+記録付き学習ループ

class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 8, kernel_size=3, padding=1)
        self.pool = nn.MaxPool2d(2)
        self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)
        self.flatten = nn.Flatten()
        self.fc = nn.Linear(16 * 7 * 7, 10)

    def forward(self, x):
        x = self.pool(torch.relu(self.conv1(x)))   # (N,1,28,28) -> (N,8,14,14)
        x = self.pool(torch.relu(self.conv2(x)))   # (N,8,14,14) -> (N,16,7,7)
        x = self.flatten(x)
        return self.fc(x)

model = SimpleCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

train_losses, train_accs, val_losses, val_accs = [], [], [], []
EPOCHS = 10

for epoch in range(EPOCHS):
    # 学習フェーズ
    model.train()
    running_loss, correct, total = 0.0, 0, 0
    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        running_loss += loss.item() * images.size(0)
        preds = outputs.argmax(dim=1)
        correct += (preds == labels).sum().item()
        total += labels.size(0)

    train_losses.append(running_loss / total)
    train_accs.append(correct / total)

    # 評価フェーズ
    model.eval()
    running_loss, correct, total = 0.0, 0, 0
    with torch.no_grad():
        for images, labels in val_loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)

            running_loss += loss.item() * images.size(0)
            preds = outputs.argmax(dim=1)
            correct += (preds == labels).sum().item()
            total += labels.size(0)

    val_losses.append(running_loss / total)
    val_accs.append(correct / total)

    print(f"Epoch {epoch+1}/{EPOCHS}: train_loss={train_losses[-1]:.4f}, val_loss={val_losses[-1]:.4f}")

タスク3: 学習曲線の可視化と過学習判定

epochs_range = range(1, EPOCHS + 1)
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

axes[0].plot(epochs_range, train_losses, label="train_loss")
axes[0].plot(epochs_range, val_losses, label="val_loss")
axes[0].set_title("Loss Curve")
axes[0].legend()

axes[1].plot(epochs_range, train_accs, label="train_acc")
axes[1].plot(epochs_range, val_accs, label="val_acc")
axes[1].set_title("Accuracy Curve")
axes[1].legend()

plt.tight_layout()
plt.show()

# 過学習判定
best_epoch = val_losses.index(min(val_losses)) + 1
print(f"検証lossが最小だったエポック: {best_epoch} (val_loss={min(val_losses):.4f})")
print(f"最終エポック: {EPOCHS} (val_loss={val_losses[-1]:.4f})")

if val_losses[-1] > min(val_losses):
    print("過学習の兆候あり")
else:
    print("過学習の兆候なし")
# 検証lossが最小だったエポック: 6 (val_loss=0.2981)
# 最終エポック: 10 (val_loss=0.3204)
# 過学習の兆候あり(実際の値は初期化・環境により変動する)

📊 学習曲線の実行結果イメージ(loss推移)

模範解答の実行例(実際の数値は初期化・Augmentationの乱数により変動します)。

Epoch 1 val_loss
0.5203
学習開始直後
Epoch 6 val_loss(ベスト)
0.2981
検証loss最小
Epoch 10 val_loss(最終)
0.3204
ベストより悪化 ⇒ 過学習の兆候
Epoch 10 train_loss
0.1602
下がり続けている
⚠️
train_lossは10エポック目でも下がり続けているのに、val_lossはEpoch 6を境に反転して上昇しています。これが今日のタスク3の判定ロジックがprintする「過学習の兆候あり」に対応する典型パターンです。

🪜 Step-by-Step 解説

1訓練用と検証用でtransformを分ける

train_transform = transforms.Compose([
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(10),
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),
])
val_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),
])
🔧
なぜこうするか: RandomHorizontalFlipRandomRotationは呼び出すたびに結果が変わるランダムな変換。訓練データにだけ適用することで毎エポック少しずつ違う見た目の画像を学習させ、「たまたまの見た目」への過剰な適応を防げる。検証の目的は「今のモデルの実力を毎回同じ条件で測る」ことなので、ランダム性を持ち込むとエポック間の公平な比較ができなくなる。

2学習ループの中で4つの指標を記録する

train_losses.append(running_loss / total)
train_accs.append(correct / total)
📝
なぜこうするか: 学習曲線を描くには各エポックの値をリストに貯めていく必要がある。running_lossloss.item() * images.size(0)(バッチ内合計loss)を積み上げたものなので、最後にtotal(サンプル総数)で割ることで1サンプルあたりの平均lossに正規化している。バッチ数ではなくサンプル数で割ることで、最後のバッチが端数でも正しい平均になる。

3model.eval()torch.no_grad()を検証フェーズで使う

model.eval()
with torch.no_grad():
    for images, labels in val_loader:
        ...
🚧
なぜこうするか: 検証はパラメータを更新しない「実力測定」。Augmentationを混ぜないのと同じ理由で、model.eval()no_grad()によって学習中の特殊な動作・パラメータ更新を完全に排除し、モデルの素の実力だけを測る。

4学習曲線を2つ並べてプロットする

axes[0].plot(epochs_range, train_losses, label="train_loss")
axes[0].plot(epochs_range, val_losses, label="val_loss")
📐
なぜこうするか: lossとaccuracyは単位もスケールも違うため、1つのグラフに無理に重ねると読み取りにくい。横に2枚並べ、それぞれに訓練用・検証用の2本の線を重ねることで、2本の線がどこで開き始めたかを視覚的に一目で比較できる。

5「検証lossの最小値」と「最終エポックの検証loss」を比較する

if val_losses[-1] > min(val_losses):
    print("過学習の兆候あり")
🎯
なぜこうするか: 過学習の最もシンプルな数値的サインは「検証lossが一度下がりきった後、再び上昇し始めること」。min(val_losses)で一番調子が良かった時点のlossを取得し、それをval_losses[-1](学習を止めた時点のloss)と比較することで、学習を続けたことでむしろ悪化していないかを機械的に判定できる。

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

📝
「訓練lossと検証lossのギャップ」は模試の「自己採点」と「本番の点数」のギャップと同じ: 過去問(訓練データ)を何度も解くと答えを覚えてしまい自己採点の点数は上がり続けます。しかし本番の初見問題(検証データ)では本当の実力しか出ません。「過去問の自己採点は上がり続けているのに、模試の本番点数は頭打ちになっている」状態こそが過学習で、今日のグラフのtrain_accval_accの開きがまさにこれに対応します。
🔄
Data Augmentationの「回転10度」「左右反転」の意味: RandomRotation(10)は画像を-10度〜+10度の範囲でランダムに回転させるという意味。角度は「見た目をどれだけ変えるか」の調整ノブに近く、回転させすぎると(例えば90度)服の形が不自然になり学習を妨げてしまうため、「元の意味を壊さない範囲で少しだけ形を変える」塩梅が重要になる。この「やりすぎない」調整感覚は、Day 004で扱った正則化の強さの調整とも通じる考え方。

🏆 Kaggleでの実践的な使い方

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

選択の基準

🧵 Augmentationはデータの性質次第

意味を壊す変換は逆効果

服の画像では左右反転は自然だが、文字認識(6と9)では左右反転や180度回転でラベルの意味が変わってしまう。ドメイン知識に基づいた取捨選択がスコアに直結する。

上級者の習慣

🗺️ 学習曲線はチューニングの地図

グラフを見てから次の一手を決める

「まだ伸びしろがあるから学習を続ける」「ギャップが開き始めたのでAugmentationを強める・正則化を足す」といった判断を学習曲線を見ながら行う。

提出戦略

💾 ベストモデルを保存して提出する

最終エポック≠最良モデル

過学習が進んだ最終エポックのモデルをそのまま提出するとLBスコアが落ちるリスクがある。val_lossが最小だったエポックの重みを別途保存し提出用に使うのが基本戦術。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
検証データにもAugmentationをかけてしまう訓練用のコードをそのままコピーする検証の目的は毎回同じ条件で実力を測ること。Augmentationはランダム性を持ち込むため検証には不適切
Augmentationをかけるほど精度が上がると思い込む「データが増える=良いこと」という単純な連想回転・反転を強くしすぎると画像の意味が変わったりノイズだらけになったりして逆に精度が下がることがある。強さは実験しながら調整する
訓練accuracyが高い=良いモデルだと判断する「正解率」という言葉のわかりやすさに引っ張られる訓練accuracyは問題集を覚えた度合いにすぎない。本当の実力は必ず検証accuracyで判断する
過学習の兆候が出たら即座に学習を止めるべきだと思うグラフが開いた瞬間=失敗という短絡的な理解一時的にギャップが開いても再び縮まることがある。複数エポックの傾向を見て、必要ならpatienceを設けたEarly Stoppingで対応する
running_lossをバッチ数で割ってしまう「エポックのloss=バッチのloss平均」という単純化最後のバッチが端数だとバッチ数で割ると歪む。loss.item() * images.size(0)を積み上げてサンプル総数で割るのが正確

🚀 次のステップ

  • 発展: transforms.ColorJitter(brightness=0.2, contrast=0.2)train_transformに追加し、明るさ・コントラストの変化がAugmentationなし・回転反転のみの場合と比べて学習曲線にどう影響するかを比較してみましょう
  • 次回予告: Day 117 — 転移学習入門(事前学習済みモデル・ResNetの活用・Fine-tuningの基本)。今日までゼロから組んできた自作CNNを卒業し、大規模データで学習済みのモデルを流用する転移学習に進みます

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

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

Day 114でCNNの構造を組み、Day 115で学習ループを回し、Day 116でAugmentationと過学習検知まで到達。次回からは自作CNNを卒業し、ResNetなど事前学習済みモデルの転移学習へ進む。

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

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