最初に
前回は、データの準備から学習・評価まで、ニューラルネットワークを作る手順を見ていきました
学習用のデータで重みを更新し、検証用のデータで途中経過を確認する
そして、最後にテスト用のデータで性能を確かめる、という流れでした
今回は、その中で少し触れた 過学習 について見ていきます
学習を続けると、学習用のデータに対する損失は小さくなっていきます
それなら、できるだけ長く学習させたほうがよさそうに思えます
「学習を続ければ、初めて見るデータでも予測がよくなるのか?」
実は、そうとは限りません
今回は小さな実験を動かし、学習曲線と予測の形から、その理由を確認します
過学習とは
過学習とは、学習用のデータに合わせすぎて、学習に使っていないデータではうまく予測できなくなることです
例えば、練習問題の答えだけを丸暗記した状態を想像してください
練習した問題がそのまま出れば答えられます
しかし、数字や聞き方が少し変わると、解けなくなるかもしれません
ニューラルネットワークでも、学習用のデータに含まれる共通の規則だけでなく、そのデータ特有のばらつきまで合わせてしまうことがあります
一方、学習に使っていないデータにも対応できることを 汎化 と呼びます
目指したいのは、新しいデータでも役に立つ予測ができる状態です
過学習と汎化の基本的な関係は、Googleの機械学習入門でも解説されています
学習用のデータだけでは判断できない
ここで、前回のデータの分け方を振り返ります
| データ | 役割 |
|---|---|
| 学習用データ | 勾配を計算し、重みやバイアスを更新する |
| 検証用データ | 学習途中の性能を比較し、設定やモデルを選ぶ |
| テスト用データ | モデルを選び終えてから、最終的な性能を測る |
学習用データに対する損失だけを見ていると、そのデータへの当てはまりしか分かりません
そこで、学習用と検証用の損失を並べて確認します
学習中に検証用の正解も参照しますが、それは性能を測るためです
検証用データの損失で重みを更新するわけではありません
学習曲線の見方
今回の学習曲線は、横軸をエポック数、縦軸を損失にしたグラフです
エポックは、学習用データ全体を1周する単位でした
グラフには、次の2本の線を描きます
- 学習用データに対する損失
- 検証用データに対する損失
今回はどちらも、前回と同じ平均二乗誤差の MSE を使います
同じデータに対して比べるなら、小さいほど予測が正解に近い値です
大まかな読み方は、次のようになります
| 曲線の変化 | 読み取れること |
|---|---|
| 学習用も検証用も下がる | 新しいデータに対する予測も改善している |
| 学習用は下がるが、検証用は悪化する傾向が続く | 過学習の兆候がある |
| 学習用も検証用も十分に下がらない | 学習不足や、モデル・設定・データの問題が考えられる |
2本の線に差があるだけで、過学習と決めつける必要はありません
データの難しさや件数が違えば、損失にも差が出ます
学習を続けたときに、それぞれの損失がどう変化しているかを見ていきましょう
過学習が起こる様子を見てみる
前回と同じく、次の関係を使ってデータを作ります
正解 = 入力 × 2 + 1 + ランダムなばらつき
今回は、過学習を観察しやすくするため、少ない学習データに対して、前回より大きなモデルを用意します
| 項目 | 前回 | 今回 |
|---|---|---|
| 学習用データ | 180件 | 20件 |
| 検証用データ | 60件 | 200件 |
| テスト用データ | 60件 | 200件 |
| 隠れ層 | 1層、ニューロン8個 | 2層、それぞれニューロン64個 |
| ばらつきの標準偏差 | 0.1 | 0.3 |
| 学習回数 | 200エポック | 3,000エポック |
これは、過学習を観察するための設定です
実際の開発で、学習用データをこの割合にすることを勧めるものではありません
ばらつきまで当てようとするとどうなるか
今回加えるばらつきは、入力と独立に作るランダムな値です
そのため、入力だけから、新しいデータに加わるばらつきを正確に当てることはできません
例えば、同じくらいの入力でも、たまたま正解の値が上にずれたり、下にずれたりします
表現力のあるモデルは、この細かなずれにも合わせられます
学習用データの損失は小さくできますが、その合わせ方が別のデータでも役に立つとは限りません
実際に動かしてみる
実行環境
次の環境で、GPU実行とCPU実行を確認しています
| 項目 | 確認した環境 |
|---|---|
| Python | 3.10.6 |
| PyTorch | 2.10.0+cu130 |
| Matplotlib | 3.10.9 |
| GPU | NVIDIA GeForce RTX 3090 |
前回のPyTorch環境を使います
今回はグラフを描くため、Matplotlibも使用します
インストールしていない場合は、次のコマンドで追加してください
python -m pip install matplotlib==3.10.9
コードはCUDAを利用できる場合にGPUを使い、利用できない場合はCPUを使います
外部データのダウンロードは必要ありません
今回のコードで変えるところ
モデルは、次の構成にします
入力1個
↓
隠れ層64個のニューロン + ReLU
↓
隠れ層64個のニューロン + ReLU
↓
出力1個
今回は20件すべてをまとめて使うので、1エポックにつき重みを1回更新します
前回のように、1エポックの中でミニバッチを何回も取り出す形にはしていません
更新方法には Adam を使います
SGDと同じく勾配を使う方法ですが、過去の勾配の情報も利用して更新量を調整します
ここでは更新方法の比較は行わず、同じ設定で学習を続けたときの変化を観察します
コード全体
以下を neural_network_overfitting.py として保存してください
"""学習曲線と予測の形から過学習を観察する入門例"""
from copy import deepcopy
import csv
from pathlib import Path
import platform
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import torch
from torch import nn
def evaluate(model, x, y):
model.eval()
with torch.no_grad():
return (model(x) - y).square().mean().item()
def main():
torch.manual_seed(0)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Python: {platform.python_version()}")
print(f"PyTorch: {torch.__version__}")
print(f"Matplotlib: {matplotlib.__version__}")
print(f"使用デバイス: {device}")
if device.type == "cuda":
print(f"GPU: {torch.cuda.get_device_name(device)}")
# 入力と正解を作り、学習20件・検証200件・テスト200件に分ける
generator = torch.Generator().manual_seed(0)
x = torch.rand(420, 1, generator=generator)
y = 2 * x + 1 + 0.3 * torch.randn(420, 1, generator=generator)
x, y = x.to(device), y.to(device)
x_train, y_train = x[:20], y[:20]
x_val, y_val = x[20:220], y[20:220]
x_test, y_test = x[220:], y[220:]
# 少数の学習データに対して、表現力のあるモデルを用意する
model = nn.Sequential(
nn.Linear(1, 64), nn.ReLU(),
nn.Linear(64, 64), nn.ReLU(),
nn.Linear(64, 1),
).to(device)
loss_fn = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
epochs = 3000
history = []
best_val = float("inf")
best_state = None
best_epoch = 0
for epoch in range(1, epochs + 1):
# 今回は20件すべてを使い、1エポックにつき1回更新する
model.train()
optimizer.zero_grad()
loss = loss_fn(model(x_train), y_train)
loss.backward()
optimizer.step()
# 同じ更新後のモデルで学習用と検証用のMSEを測る
train_mse = evaluate(model, x_train, y_train)
val_mse = evaluate(model, x_val, y_val)
history.append((epoch, train_mse, val_mse))
if val_mse < best_val:
best_val = val_mse
best_state = deepcopy(model.state_dict())
best_epoch = epoch
if epoch in (1, 100, 500, 1000, 2000, 3000):
print(f"Epoch {epoch:4d}: 学習MSE={train_mse:.6f}, 検証MSE={val_mse:.6f}")
# 最終モデルの予測を控えてから、検証で選んだモデルに戻す
grid = torch.linspace(0, 1, 400).reshape(-1, 1).to(device)
model.eval()
with torch.no_grad():
final_prediction = model(grid).cpu().flatten().numpy()
model.load_state_dict(best_state)
best_train = evaluate(model, x_train, y_train)
best_validation = evaluate(model, x_val, y_val)
test_mse = evaluate(model, x_test, y_test)
with torch.no_grad():
best_prediction = model(grid).cpu().flatten().numpy()
print(f"\n検証で選んだエポック: {best_epoch}")
print(f"採用モデルの学習MSE: {best_train:.6f}")
print(f"採用モデルの検証MSE: {best_validation:.6f}")
print(f"採用モデルのテストMSE: {test_mse:.6f}")
# スクリプトと同じ場所に、実測した曲線と予測の図を保存する
output_dir = Path(__file__).resolve().parent / "overfitting_assets"
output_dir.mkdir(exist_ok=True)
with (output_dir / "learning_history.csv").open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["epoch", "train_mse", "validation_mse"])
writer.writerows(history)
plt.rcParams.update({"font.size": 12, "axes.spines.top": False, "axes.spines.right": False})
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5), layout="constrained")
for ax, limit, title in zip(axes, [3000, 300], ["Full training", "First 300 epochs"]):
ax.plot([h[0] for h in history], [h[1] for h in history], color="#00877b", label="Training MSE")
ax.plot([h[0] for h in history], [h[2] for h in history], color="#d46a24", label="Validation MSE")
ax.axvline(best_epoch, color="#4169b5", linestyle="--", label=f"Selected epoch: {best_epoch}")
ax.set(xlim=(1, limit), yscale="log", xlabel="Epoch", ylabel="MSE (log scale)", title=title)
ax.grid(alpha=0.18)
ax.legend(fontsize=9)
fig.savefig(output_dir / "learning_curve.png", dpi=180)
plt.close(fig)
fig, ax = plt.subplots(figsize=(9, 5), layout="constrained")
grid_values = grid.cpu().flatten().numpy()
ax.scatter(x_train.cpu().numpy(), y_train.cpu().numpy(), color="#4b5563", s=35, label="Training observations", zorder=4)
ax.plot(grid_values, 2 * grid_values + 1, color="#707070", linestyle="--", label="Underlying relation: 2x + 1")
ax.plot(grid_values, best_prediction, color="#4169b5", linewidth=2, label=f"Selected model (epoch {best_epoch})")
ax.plot(grid_values, final_prediction, color="#d46a24", linewidth=2, label="Final model (epoch 3000)")
ax.set(xlabel="Input x", ylabel="Predicted / observed y", title="What changed as training continued?")
ax.grid(alpha=0.18)
ax.legend(fontsize=10)
fig.savefig(output_dir / "prediction_comparison.png", dpi=180)
plt.close(fig)
print("保存先: overfitting_assets")
if __name__ == "__main__":
main()
evaluate()では、重みを更新せずにMSEを計算しています
学習用と検証用を、どちらも そのエポックの更新が終わった時点のモデル で評価している点がポイントです
また、検証MSEが最小になった時点の状態を deepcopy(model.state_dict()) で控えています
これは、後の学習でその状態まで書き換わらないようにするためです
この扱いは PyTorchの保存・読み込みの解説でも紹介されています
実行方法
保存したフォルダで、次のコマンドを実行します
python neural_network_overfitting.py
スクリプトと同じ場所に overfitting_assets フォルダができ、次のファイルが保存されます
-
learning_curve.png:学習用と検証用の損失の変化 -
prediction_comparison.png:途中のモデルと最終モデルの予測の違い -
learning_history.csv:エポックごとのMSEの記録
グラフはウィンドウで表示せず、画像として保存します
実行結果
以下は、GPUで実行した結果です
Python: 3.10.6
PyTorch: 2.10.0+cu130
Matplotlib: 3.10.9
使用デバイス: cuda
GPU: NVIDIA GeForce RTX 3090
Epoch 1: 学習MSE=3.030457, 検証MSE=3.493625
Epoch 100: 学習MSE=0.047615, 検証MSE=0.106029
Epoch 500: 学習MSE=0.046191, 検証MSE=0.107164
Epoch 1000: 学習MSE=0.038639, 検証MSE=0.108640
Epoch 2000: 学習MSE=0.010517, 検証MSE=0.125836
Epoch 3000: 学習MSE=0.009335, 検証MSE=0.124815
検証で選んだエポック: 48
採用モデルの学習MSE: 0.052015
採用モデルの検証MSE: 0.102445
採用モデルのテストMSE: 0.115580
保存先: overfitting_assets
乱数のシードを固定していますが、CPUとGPU、ライブラリのバージョンなどによって細かな数値は変わる場合があります
同じ数値になることよりも、学習用と検証用の損失の変化に注目してください
グラフから過学習を読み取る
学習用の損失は下がり続けている
次の図は、実行中に記録したMSEをそのまま描いたものです
左が3,000エポック全体、右が最初の300エポックを拡大したグラフです
- 緑の線:学習用データのMSE
- オレンジの線:検証用データのMSE
- 青の破線:検証MSEが最小になったエポック
縦軸は、小さな値の変化も見えるように対数目盛りにしています
10⁰ は1、10⁻¹ は0.1、10⁻² は0.01です
学習が進むにつれて、緑の線は全体として下がっています
一方、オレンジの線は最初に下がったあと、ほぼ横ばいになり、後半では少し悪化しています
数値を比べると、違いが分かりやすくなります
| モデル | 学習MSE | 検証MSE |
|---|---|---|
| 検証で選んだ48エポック目 | 0.052015 | 0.102445 |
| 最後の3,000エポック目 | 0.009335 | 0.124815 |
3,000エポック目のほうが、学習用データではよく当たっています
しかし、検証用データでは48エポック目より誤差が大きくなっています
学習用データへの当てはまりがよくなっても、別のデータでの予測がよくなったとは限りません
今回の結果では、この食い違いが過学習の兆候として現れています
なお、途中で損失が一時的に跳ね上がることもあります
1回の上下だけを見て判断せず、複数のエポックにわたる傾向を確認します
予測の形も変わっている
続いて、入力を0から1まで少しずつ変え、それぞれのモデルが何を予測するかを描きます
| 図の要素 | 意味 |
|---|---|
| 灰色の点 | 学習用20件の入力と正解 |
| 灰色の破線 | データを作ったときの、ばらつきの中心となる関係 2x + 1
|
| 青い線 | 検証で選んだ48エポック目のモデルの予測 |
| オレンジの線 | 3,000エポック目のモデルの予測 |
青い線は、おおむね直線に近い形になっています
オレンジの線は、学習用の点に近づくように細かく折れ曲がっています
学習用の点を見れば、オレンジの線のほうがよく合っているように見えます
しかし、新しいデータのばらつきは、学習用の点と同じ位置に現れるとは限りません
今回は、細かなずれに合わせて予測の形が変わる一方で、検証MSEが悪化しています
この2つを合わせて見ると、過学習のイメージをつかみやすくなります
ただし、線が曲がっているだけで過学習というわけではありません
本来の関係が曲線なら、曲線で予測することが必要です
見た目だけでなく、学習に使っていないデータでの結果を確認することが大切です
灰色の破線は、この人工データを説明するために描いています
モデルの予測や、採用するエポックの選択には使っていません
どの時点のモデルを使えばよいか
今回のコードでは、検証MSEが最小だった48エポック目のモデルを選びました
その後、初めてテスト用200件で評価しています
テストMSEは 0.115580 でした
ここでの順番は、次のとおりです
学習用データで重みを更新する
↓
検証用データでモデルを選ぶ
↓
選び終えたモデルをテスト用データで評価する
「48エポックが最適」と、どんな問題にも使えるわけではありません
今回のデータとモデル、設定で、検証MSEが最小になったのが48エポック目だった、という結果です
また、テストMSEを見て何度も学習回数や設定を選び直すと、テストデータまで選択に使っていることになります
調整には検証用データを使い、テスト用データは最後の確認に残しましょう
学習を途中で止める Early Stopping
ここまでの実験では、後半の変化を観察するため、3,000エポックすべてを実行しました
実際には、改善が見られなくなった段階で学習を止める方法もあります
これを Early Stopping(早期終了) と呼びます
例えば、次のようなルールです
検証MSEが、それまでの最小値を更新した
→ 重みを控える
→ 改善していない回数を0に戻す
検証MSEが最小値を更新しなかった
→ 改善していない回数を1増やす
100エポック連続で改善しなかった
→ 学習を止める
→ 控えておいた重みを読み戻す
この「改善を待つ長さ」は、一般に patience と呼ばれます
1回悪化しただけで止めず、一定期間の様子を見るための設定です
考え方は、EarlyStoppingの公式ドキュメントでも確認できます
今回の記録に、このルールを当てはめると、48エポック目のあと100回改善がないため、148エポック目で止めることになります
使う重みは、停止した148エポック目ではなく、控えておいた48エポック目のものです
「学習を止めた時点」と「採用するモデルの時点」は、同じとは限りません
これは今回の記録に対する読み方であり、掲載コード自体には早期終了を入れていません
また、待つ回数を小さくしすぎると、その後に起こる改善を待たずに止める場合があります
過学習への対策
学習回数を見直す以外にも、対策はいくつかあります
| 対策 | 考え方 |
|---|---|
| 学習データを増やす | 実際に使う場面に近い、多様な例を集める |
| モデルを小さくする | 層やニューロンを減らし、細かなばらつきへ合わせる自由度を抑える |
| Early Stoppingを使う | 検証結果の改善が止まった段階で学習を終える |
| 正則化を使う | モデルが複雑になりすぎないよう、学習に制約を加える |
例えば L2正則化は、重みが大きくなることにペナルティを加える方法です
Dropoutは、学習時に一部の出力をランダムに0にする方法です
ただし、対策を強くすればするほどよいわけではありません
モデルを小さくしすぎるなどして必要な関係まで学べなくなると、学習不足(アンダーフィッティング) につながることもあります
学習用と検証用の両方を見ながら、変更が役に立っているかを確認します
過学習と決めつける前に確認すること
検証用の成績が悪いとき、原因が必ず過学習とは限りません
例えば、学習用は明るい場所で撮影した画像ばかりで、検証用は暗い場所の画像ばかりなら、データの条件自体が違います
入力と正解の組み合わせがずれていたり、学習用と検証用で異なる前処理をしていたりすることもあります
まずは、次の点を確認します
- 入力と正解が正しく対応しているか
- 学習用と検証用に、実際に扱うデータの特徴が含まれているか
- 学習用と検証用で、同じ基準の前処理を適用しているか
- 評価するときに
model.eval()へ切り替えているか
特にDropoutなどを使うモデルは、学習時と評価時で動作が変わります
今回のモデルはLinearとReLUだけですが、今後の実装でも評価モードへの切り替えを忘れないようにしましょう
最後に
今回は、過学習について、学習曲線と予測の形から確認しました
学習を続けることで、学習用データの損失は小さくなりました
しかし、検証用データでの予測は、途中のモデルのほうがよい結果になりました
大切なのは、学習用データにどこまで合わせられたかだけでなく、新しいデータでも役に立つかを確かめることです
次回は、今回触れた「正則化」について、重みへの制約やDropoutがどのように働くのかを、もう少し詳しく見ていく予定です
またゆっくり記事を書きます
本記事では、サンプルコードの作成に生成AIを使用しています
株式会社ONE WEDGE
https://onewedge.co.jp/
_______
株式会社ONE WEDGEは、Webシステム開発・SES・AI/DX支援を行うIT企業です。
生成AIを活用した業務効率化や次世代システム開発にも注力しており、企業の課題解決だけでなく、エンジニア一人ひとりの成長にも本気で向き合っています。
また、技術は「一人で学ぶもの」ではなく、「仲間と成長するもの」だと考え、社内外でのコミュニティづくりにも力を入れています。
_______

