Day 115 — CNN入門② — 実データセットでの学習・DataLoader前処理・学習ループ

2026-08-05 紫 / Phase 5 コーディング FashionMNIST / transforms / DataLoader / CrossEntropyLoss

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

🟣
Day 114でSimpleCNNクラスの骨組みを組み立て、ダミーTensorでshapeを確認しました。今日はDay 112で学んだDataset/DataLoaderと学習ループの知識を画像データに応用し、FashionMNIST(Tシャツ・スニーカーなど10種類のファッションアイテム、28×28グレースケール画像)SimpleCNNを実際に学習させます。
🖼️
torchvision.datasets: MNIST・FashionMNIST・CIFAR10などの定番画像データセットを、自分でDatasetを自作せずにdatasets.FashionMNIST(root=..., train=True, download=True)の1行で取得できる。今日のFashionMNISTは(1,28,28)・10クラスで、Day 114のSimpleCNNの想定とそのまま一致する。
🔑
キーワード
transforms.ToTensor(): PIL Image(0~255)をfloat32 Tensor(0.0~1.0)に変換
transforms.Normalize(mean, std): (x-mean)/stdで値を0付近に揃える正規化
nn.CrossEntropyLoss(): 多クラス分類の損失関数。ラベルはクラス番号のまま渡してよい(One-Hot不要)
model.eval() / torch.no_grad(): 評価モードへの切り替え、勾配計算の停止

🧵 前処理パイプライン: 生画像 → 学習可能なTensor

PIL Image 画素値 0〜255 (H, W) ToTensor() 0.0〜1.0にスケーリング (C, H, W) Normalize(0.5, 0.5) (x-0.5)/0.5 概ね -1.0〜1.0 DataLoader batch_size=64 (64,1,28,28) CNN Datasetのtransform引数がToTensor→Normalizeを自動適用し、DataLoaderがバッチにまとめてモデルに渡す

🎯 問題

1
FashionMNISTをロードして前処理する(コーディング): transforms.Composeで前処理パイプラインを作り、datasets.FashionMNISTで学習用・テスト用データセットを読み込む
2
DataLoaderを作る(コーディング): batch_size=64で学習用(shuffle=True)・テスト用(shuffle=False)のローダーを作り、1バッチのshapeを確認する
3
学習ループを実装する(コーディング): Day 114のSimpleCNNを使い、CrossEntropyLossAdamで3エポック学習し、各エポックの平均損失をprintする
4
評価ループでテスト精度を計算する(コーディング・メイン): model.eval()+torch.no_grad()でテストデータの正解率を計算する

💡 ヒント

ヒント1方向性

タスク1〜2はDay 112で表形式データに対して行った「Datasetを作ってからDataLoaderに包む」流れと全く同じで、対象が画像に変わっただけです。torchvisionDataset部分を用意してくれるので、自分で書くのはtransformの定義だけです。タスク3〜4は「学習ループ=パラメータを動かすフェーズ」「評価ループ=パラメータを動かさず答え合わせだけするフェーズ」という役割の違いを意識すると、torch.no_grad()をどこに付けるべきかが自然に分かります。

ヒント2アプローチ
  • download=Trueは初回実行時のみデータをダウンロードする。既にダウンロード済みなら何もしない
  • バッチのimages.shape(batch_size, C, H, W)labels.shape(batch_size,)SimpleCNNforwardにそのまま渡せる
  • 1エポックの平均損失は「total_loss += loss.item()を毎バッチ積算し、最後にtotal_loss / len(train_loader)で割る」のが定番パターン
  • 正解数のカウントは(preds == labels).sum().item()のように、比較結果のブールTensorをsum()すればよい
  • nn.CrossEntropyLoss()はラベルを「クラス番号(0〜9の整数)」のまま受け取る。One-Hotエンコーディングは不要
ヒント3コード骨格
# タスク1: データセットの読み込み
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,))
])
train_dataset = datasets.FashionMNIST(root="./data", train=True, download=True, transform=transform)
test_dataset = datasets.FashionMNIST(root="./data", train=False, download=True, transform=transform)

# タスク2: DataLoader
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)

# タスク3: 学習ループ
model = SimpleCNN()  # Day 114で定義したクラス
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

for epoch in range(3):
    total_loss = 0.0
    for images, labels in train_loader:
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Epoch {epoch+1}: avg_loss={total_loss/len(train_loader):.4f}")

# タスク4: 評価ループ
model.eval()
correct = 0
total = 0
with torch.no_grad():
    for images, labels in test_loader:
        outputs = model(images)
        preds = torch.argmax(outputs, dim=1)
        correct += (preds == labels).sum().item()
        total += labels.size(0)
print(f"accuracy = {correct/total:.4f}")

模範解答

タスク1: FashionMNISTのロードと前処理

import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

transform = transforms.Compose([
    transforms.ToTensor(),                    # PIL Image(0~255) → Tensor(0.0~1.0)
    transforms.Normalize((0.5,), (0.5,))      # (x-0.5)/0.5 → 概ね -1.0〜1.0
])

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

print(len(train_dataset))  # 60000
image0, label0 = train_dataset[0]
print(image0.shape, label0)  # torch.Size([1, 28, 28]) 9

タスク2: DataLoaderの作成

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)

images, labels = next(iter(train_loader))
print(images.shape)  # torch.Size([64, 1, 28, 28])
print(labels.shape)  # torch.Size([64])

タスク3: 学習ループの実装

# SimpleCNNはDay 114で定義したクラスをそのまま使う
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)

    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

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

for epoch in range(3):
    total_loss = 0.0
    for images, labels in train_loader:
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    avg_loss = total_loss / len(train_loader)
    print(f"Epoch {epoch+1}: avg_loss={avg_loss:.4f}")

# Epoch 1: avg_loss=0.5721
# Epoch 2: avg_loss=0.3842
# Epoch 3: avg_loss=0.3391
# (実際の値は初期化・環境により変動する)

タスク4: 評価ループとテスト精度の計算

model.eval()
correct = 0
total = 0
with torch.no_grad():
    for images, labels in test_loader:
        outputs = model(images)
        preds = torch.argmax(outputs, dim=1)
        correct += (preds == labels).sum().item()
        total += labels.size(0)

accuracy = correct / total
print(f"accuracy = {accuracy:.4f}")
# accuracy = 0.8734(3エポックのみ・実際の値は変動する)

🔁 学習ループの5ステップ(Day 112の復習+画像版)

① zero_grad() 前回の勾配をリセット ② forward outputs=model(images) ③ loss計算 CrossEntropyLoss ④ backward() 誤差逆伝播で勾配計算 ⑤ step() 重み更新 train_loaderの次のバッチへ(1エポック内で全バッチ分繰り返す) 3エポック分この輪を回し、各エポックの終わりに平均lossをprintする

📊 学習結果のイメージ(loss推移・最終精度)

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

Epoch 1 平均loss
0.5721
学習開始直後
Epoch 2 平均loss
0.3842
徐々に低下
Epoch 3 平均loss
0.3391
3エポックで一旦終了
テスト精度(accuracy)
0.8734
10クラス分類・ランダム10%基準
💡
10クラス分類でランダムに答えた場合の正解率はおよそ10%。3エポックだけのシンプルなCNNでも87%前後まで到達するのが一般的な目安で、「畳み込み層が有効な特徴を学習できている」ことの分かりやすい確認になります。

🪜 Step-by-Step 解説

1transforms.Composeで前処理パイプラインを組む

transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,))
])
🔧
なぜこうするか: Composeは複数の変換を順番に適用するパイプラインとしてまとめる。NormalizeはTensor(float)を前提にした演算なので、必ずToTensor()の後に置く必要がある。

2datasets.FashionMNISTtransformを渡す

train_dataset = datasets.FashionMNIST(root="./data", train=True, download=True, transform=transform)
🧷
なぜこうするか: Dataset__getitem__が呼ばれた瞬間に画像を読み込んで返す設計になっており、transformを渡しておくとその読み込みのタイミングで自動的に前処理まで適用される。Day 112で自作したDataset__getitem__torchvisionが肩代わりしているイメージ。

3DataLoaderでミニバッチに分割する

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
📦
なぜこうするか: 60,000枚全部を一度にメモリに載せるのは非効率。DataLoaderは64枚ずつランダムに取り出しバッチTensorにまとめる。shuffle=Trueは毎エポックで並び順を変え、データの順番自体への過学習を防ぐ。テスト時はshuffle=Falseでよい。

4学習ループの5ステップを画像データに適用する

optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
🔁
なぜこうするか: Day 112で表形式データに書いたものと一字一句同じ構造。model(images)forward(Conv→ReLU→Pool→Flatten→Linear)が実行され(64,10)のスコアが返る。criterionがスコアとラベルを比較し損失にまとめ、backward()で全パラメータの勾配が計算され、step()でパラメータが更新される。

5model.eval() + torch.no_grad()で評価する

model.eval()
with torch.no_grad():
    ...
🚧
なぜこうするか: 評価は「今のモデルの実力を測るだけ」でパラメータを更新してはいけない。torch.no_grad()は勾配計算を止めメモリ・計算量を節約する。model.eval()は今日のモデルには直接効果はないが、Dropoutやバッチ正規化を含むモデルでは動作が変わるため評価時に呼ぶ習慣を身につけておく。

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

📏
正規化 (x-mean)/std は「テストの点数を偏差値に変換する」のと同じ計算: Day 029の偏差値変換は「平均点を50、ばらつきを10に揃えることで異なるテスト同士を公平に比較できるようにする」計算でした。画像の正規化(x-0.5)/0.5も発想は同じで、「ピクセル値の中心を0付近に、ばらつきを一定の範囲に揃えることでニューラルネットが学習しやすい数値スケールに整える」操作です。
🎯
CrossEntropyLossは「正解クラスにどれだけ自信を持てなかったか」を数値化したもの: モデルの出力(64,10)は「64枚それぞれについて10クラスのどれである可能性が高いかのスコア」。CrossEntropyLossは内部でSoftmax関数によりこれを「合計が1になる確率」に変換し、正解クラスの確率が高いほど損失を小さく、低いほど損失を大きくする。「答えを外すほど強くペナルティを受ける」仕組みです。

🏆 Kaggleでの実践的な使い方

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

共通テンプレート

🧱 4点セットの型

Dataset+transform → DataLoader → 学習ループ → 評価ループ

今日書いた構造はKaggleの画像コンペのほぼ全てで使われる定番テンプレート。データセットが独自の医療画像・衛星画像に変わっても骨組み自体はほとんど変わらない。

実務の定石

📐 正規化の統計量

実データの平均・標準偏差を使う

今日は簡易的にmean=0.5・std=0.5を使ったが、実務・Kaggleでは学習データ全体の実際の統計量を計算するか、ImageNet事前学習モデルを使う場合はImageNetの統計量を使うのが定石。

運用の勘所

⏱️ エポック数とEarly Stopping

検証lossが下がらなくなったら止める

今日は3エポックのみで簡易的に学習させたが、実コンペでは学習曲線を見ながら早期終了する。考え方はGBDTの早期終了(Day 091)と同じ。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
NormalizeToTensorより先に書いてしまう変換の順序を意識せずに並べるCompose内の変換は上から順に適用される。Normalizeは必ずToTensor()の後に置く
評価ループでもzero_grad()backward()を書いてしまう学習ループのコードをそのままコピーする評価はパラメータを更新しない。torch.no_grad()の中ではbackward()は呼べない
CrossEntropyLossにOne-Hotラベルを渡してしまう分類=One-Hotという固定観念nn.CrossEntropyLoss()はクラス番号(整数)をそのまま受け取る設計。One-Hot化すると逆にエラーになる
total_lossloss(Tensorのまま)を足し続ける.item()の意味を意識せずコピーするTensorのままだと計算グラフの情報を保持しメモリを圧迫する。.item()でfloatに変換してから足し合わせる
model.eval()を呼んだら学習を再開できないと思う「eval」という名前から終わりを連想する評価モードに切り替えるだけで、再度model.train()を呼べば学習モードに戻る

🚀 次のステップ

  • 発展: nn.CrossEntropyLoss()が内部でSoftmaxを計算していることを確認するために、torch.softmax(outputs, dim=1)を自分で挟んで確率に変換してからargmaxを取っても同じ予測クラスになることを確かめてみましょう
  • 次回予告: Day 116 — CNN入門③(Data Augmentation・学習曲線の可視化・過学習の兆候の見つけ方)。今日3エポックで止めた学習をもっと長く回すとどうなるか、Augmentationで精度がどう変わるかを実験します

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

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

Day 114でCNNの構造を組み、Day 115で実際にFashionMNISTを学習させるところまで到達。Phase 5内ではNLPより先に画像認識(CV)から着手している。

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

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