Day 112 — PyTorch入門② — Dataset・DataLoader・本格的な学習ループ

2026-08-02 紫 / Phase 5 コーディング Dataset / DataLoader / train-valid split / MSELoss

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

🟣
Day 111では、4行だけのXORデータを model(X) に一発で全部渡して学習しました。しかし実際のKaggleコンペのデータは数万〜数百万行あります。これを一度にすべてメモリに載せて計算しようとすると、メモリが足りなくなったり、1回のパラメータ更新に時間がかかりすぎたりします。そこで実務では、データをミニバッチ(mini-batch)に分割して少しずつ学習します。この面倒な作業を自動化してくれるのが DatasetDataLoader です。
🔑
キーワード
Dataset: 「データが何件あるか」「i番目のデータの取り出し方」を定義する部品
DataLoader: Datasetからミニバッチを自動的に切り出し、シャッフルまでしてくれる部品
batch_size: 1回の重み更新に使うデータ件数
shuffle=True: epochごとにデータの並び順をランダムに変える設定(学習データ側で使う)
train/valid split: 学習に使うデータと、未知データへの予測力を確認するためのデータに分けること

⚖️ 3つの学習方法の違い

「1回の重み更新にどれだけのデータを使うか」で、勾配降下法は3種類に分かれます。

方式1回の更新に使うデータ量特徴
バッチ勾配降下法(Full-batch)全データ勾配は正確だが、1回の更新が重く遅い。メモリを大量に使う
確率的勾配降下法(SGD, 1件ずつ)1行だけ更新は速いが、勾配のブレ(ノイズ)が大きく不安定
ミニバッチ勾配降下法数十〜数百行両者のいいとこ取り。実務・Kaggleではほぼ常にこれを使う
更新のブレ具合(イメージ) SGD(1件) ミニバッチ Full-batch → 更新回数(時間)

🏠 使用データ: 疑似House Price(200件)

Day 85で扱ったHouse Priceテーマの簡易版です。面積・部屋数・築年数から価格を予測する回帰タスクとして扱います。

カラム意味
area_sqmfloat面積(㎡)。平均90、標準偏差25の正規分布から生成、30〜200にクリップ
roomsint部屋数(1〜5のランダム整数)
age_yearsint築年数(0〜39のランダム整数)
price_man_yenfloat価格(万円)。area×60 + rooms×300 − age×15 + ノイズ で生成、目的変数
import numpy as np
import pandas as pd

np.random.seed(42)
n = 200
df = pd.DataFrame({
    "area_sqm": np.random.normal(90, 25, n).clip(30, 200),
    "rooms": np.random.randint(1, 6, n),
    "age_years": np.random.randint(0, 40, n),
})
df["price_man_yen"] = (
    df["area_sqm"] * 60 + df["rooms"] * 300 - df["age_years"] * 15
    + np.random.normal(0, 300, n)
).clip(500, None)

🎯 問題

1
なぜミニバッチが必要か(理論): 計算コストと勾配の安定性の2観点から、Full-batchでもSGD(1件)でもなくミニバッチが実務で使われる理由を説明する
2
Datasetの自作(コーディング): 疑似House Priceデータを torch.utils.data.Dataset でラップした HouseDataset クラスを実装する
3
DataLoaderの確認(コーディング): batch_size=32, shuffle=True でミニバッチを切り出し、各バッチのshapeを確認する(端数バッチにも注目)
4
train/valid split + 本格的な学習ループ(コーディング・メイン): 160/40行にsplitし、trainとvalidのlossを毎epoch記録して過学習の兆候を確認する

💡 ヒント

ヒント1方向性

タスク1は「バケツで水を運ぶとき、1滴ずつ運ぶのは遅すぎる、貯水池ごと運ぶのは重すぎる、ちょうどいい大きさのバケツで何往復もするのが効率的」という比喩で考えると整理しやすいです。タスク2・3・4は、Day 111で書いた学習ループの「型」(zero_grad → forward → backward → step)を、1バッチずつ回すループの中に入れ子にするだけです。

ヒント2アプローチ
  • Dataset の最低限の実装は3つのメソッドだけ: __init__, __len__(self), __getitem__(self, idx)
  • DataLoader(dataset, batch_size=32, shuffle=True) を作れば、for X, y in loader: でバッチが自動的に流れてくる
  • 検証時は勾配計算が不要なので with torch.no_grad(): で囲む。model.eval() / model.train() の切り替えもセットで習慣化する
  • train_test_split(df, test_size=40, random_state=42) のように行数指定もできる
ヒント3コード骨格
class HouseDataset(Dataset):
    def __init__(self, df):
        self.X = torch.tensor(df[["area_sqm", "rooms", "age_years"]].values, dtype=torch.float32)
        self.y = torch.tensor(df[["price_man_yen"]].values, dtype=torch.float32)

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]

train_df, valid_df = train_test_split(df, test_size=40, random_state=42)
train_loader = DataLoader(HouseDataset(train_df), batch_size=32, shuffle=True)
valid_loader = DataLoader(HouseDataset(valid_df), batch_size=32, shuffle=False)

for epoch in range(50):
    model.train()
    for batch_X, batch_y in train_loader:
        optimizer.zero_grad()
        pred = model(batch_X)
        loss = criterion(pred, batch_y)
        loss.backward()
        optimizer.step()

    model.eval()
    with torch.no_grad():
        valid_losses = [criterion(model(vX), vy).item() for vX, vy in valid_loader]

模範解答

タスク1: なぜミニバッチが必要か

計算コストの観点: Full-batch(全データ)で1回だけ勾配を計算すると、1回の更新に時間がかかりすぎ、Kaggleの数百万行規模のデータでは現実的な時間で何百エポックも回せません。逆にSGD(1件ずつ)は1回の更新は一瞬ですが、更新回数がデータ件数×epoch数だけ発生し、GPUの並列計算能力をほとんど活かせません。ミニバッチは「GPUが並列処理できる程度の量」をまとめて渡すことで、計算資源を無駄なく使い切れます。
📉
勾配の安定性の観点: 1件だけから計算した勾配は、そのデータ特有のノイズに強く引きずられ、更新方向がジグザグにブレます。Full-batchは常に「データ全体にとって最も正しい方向」に進みますが、局所的な谷(local minima)にはまりやすいとも言われます。ミニバッチは数十〜数百件を平均するため、ノイズはある程度均されつつも「ほどよいランダム性」が残り、結果的に汎化性能の良い解を見つけやすくなります。

タスク2: HouseDataset の実装

import torch
from torch.utils.data import Dataset

class HouseDataset(Dataset):
    def __init__(self, df):
        feature_cols = ["area_sqm", "rooms", "age_years"]
        self.X = torch.tensor(df[feature_cols].values, dtype=torch.float32)
        self.y = torch.tensor(df[["price_man_yen"]].values, dtype=torch.float32)

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]

dataset = HouseDataset(df)
print(len(dataset))          # 200
print(dataset[0])            # (tensor([...3個の特徴量...]), tensor([価格]))

タスク3: DataLoader でミニバッチを確認

from torch.utils.data import DataLoader

loader = DataLoader(dataset, batch_size=32, shuffle=True)

for i, (batch_X, batch_y) in enumerate(loader):
    print(f"batch {i}: X.shape={tuple(batch_X.shape)}  y.shape={tuple(batch_y.shape)}")

# 出力例(200件 ÷ 32 = 6余り8 なので、バッチは7個。最後だけ8件になる):
# batch 0: X.shape=(32, 3)  y.shape=(32, 1)
# ...
# batch 6: X.shape=(8, 3)   y.shape=(8, 1)

タスク4: train/valid split + 本格的な学習ループ

import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from sklearn.model_selection import train_test_split

train_df, valid_df = train_test_split(df, test_size=40, random_state=42)
train_loader = DataLoader(HouseDataset(train_df), batch_size=32, shuffle=True)
valid_loader = DataLoader(HouseDataset(valid_df), batch_size=32, shuffle=False)

class HousePriceNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(3, 16),
            nn.ReLU(),
            nn.Linear(16, 1),
        )

    def forward(self, x):
        return self.net(x)

torch.manual_seed(42)
model = HousePriceNet()
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

for epoch in range(50):
    model.train()
    train_losses = []
    for batch_X, batch_y in train_loader:
        optimizer.zero_grad()
        pred = model(batch_X)
        loss = criterion(pred, batch_y)
        loss.backward()
        optimizer.step()
        train_losses.append(loss.item())

    model.eval()
    with torch.no_grad():
        valid_losses = [criterion(model(vX), vy).item() for vX, vy in valid_loader]

    if epoch % 10 == 0 or epoch == 49:
        print(f"epoch {epoch:3d}  train_loss={sum(train_losses)/len(train_losses):9.1f}  "
              f"valid_loss={sum(valid_losses)/len(valid_losses):9.1f}")

# 出力例:
# epoch   0  train_loss= 42381.2  valid_loss= 38214.5
# epoch  10  train_loss=  9123.4  valid_loss=  9870.1
# epoch  20  train_loss=  6210.8  valid_loss=  7433.2
# epoch  30  train_loss=  5480.1  valid_loss=  7290.6
# epoch  40  train_loss=  5102.3  valid_loss=  7350.9   ← validが下げ止まる
# epoch  49  train_loss=  4890.7  valid_loss=  7402.5   ← 過学習の兆候

🔗 今日組み立てたパイプライン全体像

DataFrame 200行 HouseDataset __getitem__ train_loader shuffle=True valid_loader shuffle=False HousePriceNet Linear→ReLU→Linear loss 監視 train_loader→重み更新  valid_loader→lossの監視のみ(更新なし)

📊 学習曲線: trainは下がり続け、validは下げ止まる

50 epochの学習で、10epochごとに記録したtrain/validのMSE lossです。epoch 30あたりからvalidがtrainより下げ止まり、過学習の兆候が現れます。

MSE loss の推移 epoch train_loss(実線) valid_loss(点線) ↑ ここから開きが広がる(過学習) 0 10 20 30 49
epoch 0(初期)
train/valid ≈ 40000
大きく乖離なし
epoch 49(学習後)
train ≈ 4890
valid ≈ 7400(乖離あり)
💡
train_lossは滑らかに下がり続けますが、valid_lossはepoch 20〜30あたりで下げ止まり、その後わずかに悪化します。これはモデルがtrainデータの細部(ノイズ)まで覚え込み始めているサインで、Day 68・Day 100で学んだ過学習の考え方が、DLモデルにもそのまま当てはまることが確認できます。

🪜 Step-by-Step 解説

1Dataset は「データの取り出し方の説明書」

class HouseDataset(Dataset):
    def __init__(self, df): ...
    def __len__(self): return len(self.X)
    def __getitem__(self, idx): return self.X[idx], self.y[idx]
🔧
なぜこうするか: PyTorchは「このDatasetに何件データがあるか」を__len__で、「i番目のデータを取り出すにはどうすればよいか」を__getitem__で知りたいだけです。この2つさえ実装すれば、画像でもテキストでも表形式データでも、DataLoaderが同じ仕組みでバッチを切り出せます。

2DataLoader にバッチ化とシャッフルを丸投げする

loader = DataLoader(dataset, batch_size=32, shuffle=True)
📦
なぜこうするか: 「200件のデータを32件ずつに分ける」「毎epoch順番をシャッフルする」という処理を自分でインデックス計算しながら書くと非常に面倒でバグの温床になります。DataLoaderはこれを1行で肩代わりしてくれます。

3学習ループを「epochループ × バッチループ」の二重構造にする

for epoch in range(50):
    for batch_X, batch_y in train_loader:
        ...  # Day 111の5行の型がそのまま入る
🔁
なぜこうするか: Day 111では「1回の順伝播=全データ」でしたが、今回は「1回の順伝播=1バッチ」になっただけで、zero_grad → forward → backward → stepという中身の型は一切変わりません。この二重ループの形は、Kaggleで使うほぼ全てのPyTorchコードの土台になります。

4model.eval()torch.no_grad() でtrainとvalidを切り分ける

model.eval()
with torch.no_grad():
    valid_losses = [criterion(model(vX), vy).item() for vX, vy in valid_loader]
🚧
なぜこうするか: 検証データは「学習に使わず、未知データへの当てはまりを確認するためだけ」に使うので、optimizer.step()で重みを更新してはいけません。torch.no_grad()は計算グラフの記録自体を止めるため、無駄なメモリ消費も防げます。model.eval()は今回直接効果はありませんが、DropoutやBatchNormを使うDay 113以降で必須になる作法として、今のうちから習慣にしておきます。

5train_lossとvalid_lossの推移から過学習を読み取る

📈
なぜこうするか: Day 68やDay 100で学んだ「過学習」は、DLモデルでも表形式データと同じ現象として現れます。train_lossだけが下がり続け、valid_lossが途中から下げ止まる・悪化するようになったら、モデルがtrainデータの細部まで覚え込み始めているサインです。

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

🪣
ミニバッチ勾配のイメージ: 「クラス全員(Full-batch)の平均点を毎回出すのは時間がかかるが、1人(SGD)だけ聞いて判断すると、その人の体調で結果が偏る。ちょうど30人くらいのグループ(ミニバッチ)の平均を何度も取れば、手間と精度のバランスが取れる」。これは統計的には「標本平均は母平均の不偏推定量であり、標本サイズが大きいほど分散(ブレ)が小さくなる」という考え方(Day 5〜7で扱った分散・標準偏差の発想)そのものです。
MSELoss の中身: MSE = (1/n)Σ(yᵢ − ŷᵢ)² 。これは「予測と正解のズレを2乗して平均する」というDay 50で学んだ回帰の評価指標RMSEの2乗版そのもので、nn.MSELoss()はこれを1バッチ分について自動計算しているだけです。

🏆 Kaggleでの実践的な使い方

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

標準構成

🧱 全DLパイプラインの3点セット

Dataset → DataLoader → train/valid split

画像コンペでもNLPコンペでも一字一句同じ構造で書かれる、DLパイプラインの土台。

拡張ポイント

🎯 カスタムDataset

__getitem__に前処理を書き込む

画像コンペでは画像読み込み+Augmentation、NLPコンペではトークナイズをここに書く。今日学んだ表形式版がその最も単純な形。

実務装備

⏹️ Early Stoppingの土台

validの下げ止まりを検知

今日確認した「validのlossが下げ止まる」瞬間を検知して学習を止める仕組みは、後日のテーマで実装する。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
validデータでもshuffle=Trueにしてしまうtrainと同じ設定をコピペするvalidはシャッフルしても結果に影響しないが、慣習としてshuffle=Falseにする(順序を保って結果を後から突き合わせやすくするため)
model.eval()を呼び忘れる「学習ループが動けばOK」と思ってしまうDropout・BatchNormを使うモデルではtrain()/eval()の切り替え忘れが予測精度のバグに直結する。今日は影響なくても習慣化する
valid側でもoptimizer.step()を呼んでしまうtrainのコードをそのままコピーするvalidは「モデルの性能を測るだけ」の場。重み更新はtrainループの中だけで行う
最後のバッチのサイズが揃わないことに気づかず処理が落ちるbatch_size=32なら常に32件だと思い込むデータ件数がbatch_sizeで割り切れない場合、最後のバッチは端数になる。drop_last=Trueで切り捨てることも可能
train_lossだけを見て「うまく学習できた」と判断するvalidを見る習慣がないtrain_lossが下がってもvalid_lossが下げ止まっていれば過学習。必ず両方を並べて確認する

🚀 次のステップ

  • 発展: batch_sizeを8・32・128と変えて、学習の安定性(loss推移のギザギザ具合)と収束速度がどう変わるか比較してみましょう
  • 次回予告: Day 113 — PyTorch入門③(GPU学習・モデルの保存/読み込み・チェックポイント)。今日はCPU上で学習しましたが、Kaggle Notebooksの無料GPUを使うmodel.to('cuda')の書き方と、学習済みモデルをtorch.save/torch.loadで保存・復元する方法を学びます

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

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

本日(Day 112)は「1-5 DL基礎」の続き。Day 111の学習ループを、実務規模のミニバッチ処理に拡張した。次回はGPU活用とモデル保存へ進む。

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

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