最初に
前回は、勾配降下法について解説しました
損失関数で予測と正解のズレを測り、誤差逆伝播法で勾配を計算し、重みやバイアスを更新する
最後には、小さなニューラルネットワークも動かしました
今回は、その続きを見ていきます
前回は、学習に使っていない入力を2件だけ渡して、予測を確認しました
ただ、実際にモデルを作るときには、もう少し丁寧に確認したいところです
「学習に使ったデータ以外でも、うまく予測できているのか?」
この点を確かめるために、データの準備から学習・評価までを順番に進めます
今回は、Pythonの基本的な変数・繰り返し・関数を知っている方を想定しています
今回作るもの
前回と同じく、1つの数値から別の数値を予測する、回帰のニューラルネットワークを作ります
題材は「入力を2倍して1を足す」という関係です
今回は、正解の値に少しだけランダムなばらつきを加えます
正解の値 = 入力 × 2 + 1 + 小さなばらつき
同じような入力でも、観測される値が少しずれる状況を、人工的なデータで表現します
| 項目 | 前回 | 今回 |
|---|---|---|
| データ | 手で用意した5件 | プログラムで作る300件 |
| 正解の値 | すべて式どおり | ランダムなばらつきがある |
| 学習 | 5件すべてを使って毎回更新 | 最大32件ずつ取り出して更新 |
| 評価 | 新しい入力を2件確認 | 検証用・テスト用のデータを分ける |
この単純な関係には、線形回帰などのモデルも使えます
今回は、前回のコードを土台に、ニューラルネットワークを作る一連の手順を理解することが目的です
作成の全体像
まず、今回の流れを確認します
モデルを作る作業には、学習処理だけでなく、データの分け方や、学習後の確かめ方も含まれます
1 入力と正解を用意する
教師あり学習では、「入力」と、それに対応する「正解」を1組にして用意します
例えば、住宅の条件から家賃を予測するなら、部屋の広さなどが入力、実際の家賃が正解になります
今回は仕組みを追いやすいように、入力も正解も1個の数値にします
x = torch.rand(300, 1, generator=data_generator, dtype=torch.float32)
noise = 0.1 * torch.randn(
300, 1, generator=data_generator, dtype=torch.float32
)
y = 2 * x + 1 + noise
※ 途中に出てくるコードは説明用の抜粋です
実行するときは、後半のコード全体を使用してください
xは0以上1未満の入力を300件、noiseは平均0・標準偏差0.1の正規分布から作るばらつきです
0.1はばらつきの上限ではありません
入力と正解は、どちらも次の形になります
(300, 1)
│ └─ 1件につき数値が1個
└──── データが300件
xの1行目にはyの1行目が対応する、という関係が大切です
正解を作るための式はデータの準備に使います
その後、モデルは入力と正解の組を使って学習し、予測時には入力だけを受け取ります
数値の型と形をそろえる
今回の入力と正解は、torch.float32という小数を扱える型にそろえます
また、モデルの出力と正解の形を、どちらも「件数 × 1」にします
例えば32件を処理するときは、次の対応になります
入力の形 : (32, 1)
予測の形 : (32, 1)
正解の形 : (32, 1)
正解だけを(32,)にすると、自動的な形の拡張によって、意図した1対1の比較にならない場合があります
MSEを計算する前に、予測と正解の形を確認しましょう
2 データを学習用・検証用・テスト用に分ける
ここが、前回から大きく変わるところです
練習した問題で高得点を取れても、初めて見る問題で同じように解けるとは限りません
モデルについても、重みの更新に使うデータと、性能を確かめるデータを分けて考えます
今回は、300件を次のように分けます
| データ | 件数 | 役割 |
|---|---|---|
| 学習用データ | 180件 | 勾配を計算し、重みやバイアスを更新する |
| 検証用データ | 60件 | 学習途中の状態を比較し、採用するモデルを選ぶ |
| テスト用データ | 60件 | モデルを選び終えてから、最終的な性能を測る |
6:2:2は今回の例であり、どの問題でもこの比率が正解というわけではありません
indices = torch.randperm(300, generator=data_generator)
train_idx = indices[:180]
val_idx = indices[180:240]
test_idx = indices[240:]
torch.randpermで、0〜299の番号をランダムな順番に並べます
その番号を3つに分け、入力と正解に同じ番号を使って取り出します
x_train, y_train = x[train_idx], y[train_idx]
入力と正解を別々にシャッフルすると、組み合わせが壊れるため注意しましょう
今回のように独立に生成したデータではランダムに分割できます
ただし、実データでは同じ人の記録や連続した時系列など、データ同士の関係に応じて分け方も考える必要があります
検証用とテスト用を分ける理由
検証用データでは、例えば「50エポック目と100エポック目では、どちらのモデルがよいか」を比較します
一方、テスト結果を見て何度もモデルを調整すると、テストデータの特徴に合わせて選ぶことになります
そのため、試行錯誤には検証用データを使い、テスト用データは最後の確認に残します
検証用の正解も評価に使いますが、その損失でbackward()やoptimizer.step()は実行しません
検証結果はモデル選択に使われる、という区別です
3 ミニバッチでデータを取り出す
前回は、5件すべてをまとめて使い、1回ずつ重みを更新しました
今回は学習用の180件を、最大32件ずつ取り出します
この小さなまとまりをミニバッチと呼びます
train_loader = DataLoader(
TensorDataset(x_train, y_train),
batch_size=32,
shuffle=True,
generator=torch.Generator().manual_seed(1),
num_workers=0,
)
TensorDatasetは入力と正解を組にして扱うためのものです
DataLoaderは、そこから指定した件数ずつ取り出す役割を持ちます
shuffle=Trueにすると、学習用データを取り出す順番を毎周入れ替えます
学習用・検証用・テスト用の所属を入れ替える設定ではありません
num_workers=0では、データの読み込みに別のプロセスを起動しません
バッチサイズとエポックの違い
| 用語 | 意味 | 今回の設定 |
|---|---|---|
| バッチサイズ | 1回の更新に使うデータの件数 | 最大32件 |
| エポック | 学習用データ全体を1周する単位 | 200周する |
今回の設定では、1エポックが次のようになります
32件 → 更新
32件 → 更新
32件 → 更新
32件 → 更新
32件 → 更新
20件 → 更新
─────────────
合計180件で1エポック
最後の20件も使うので、1エポックで6回更新します
200エポックなら合計1,200回です
「200エポック」と「重みを200回更新すること」は、このコードでは異なります
4 モデルと学習の設定を決める
モデルの構成は、前回と同じです
入力1個 → 隠れ層1層(ニューロン8個・ReLU) → 出力1個
model = nn.Sequential(
nn.Linear(1, 8),
nn.ReLU(),
nn.Linear(8, 1),
).to(device)
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
nn.Linear(1, 8)の「1」は1件あたりの入力数、「8」は隠れ層のニューロン数です
データの件数やバッチサイズを表しているわけではありません
数値の予測なので、損失関数には前回と同じ平均二乗誤差(MSE)を使います
更新方法はSGD、学習率は0.05とします
今回はDataLoaderでミニバッチを取り出すので、ミニバッチごとに勾配を計算して更新する学習になります
5 学習と検証を繰り返す
学習中の基本的な処理は、前回と共通です
model.train()
for x_batch, y_batch in train_loader:
x_batch = x_batch.to(device)
y_batch = y_batch.to(device)
optimizer.zero_grad()
prediction = model(x_batch)
loss = loss_fn(prediction, y_batch)
loss.backward()
optimizer.step()
違いは、学習用データ全体の代わりに、取り出したミニバッチを渡すことです
ミニバッチを取り出す
↓
予測 → 損失計算 → 勾配計算 → 重みの更新
↓
次のミニバッチへ
学習用データを1周したら、その時点のモデルで学習用・検証用それぞれのMSEを計算します
表示する学習MSEは、エポック終了時のモデルで学習用180件を評価し直した値です
評価中は重みを更新しない
評価処理では、次の形を使います
model.eval()
with torch.no_grad():
prediction = model(x.to(device))
model.eval()は評価モードへの切り替え、torch.no_grad()は勾配計算用の履歴を記録しない設定です
2つは別の役割を持ちます
今回のモデルはLinearとReLUだけなのでモードによる層の動作の違いはありませんが、学習時と評価時を切り替える形にします
評価では予測と正解を比較するだけです
重みの更新は行いません
検証結果がよかったモデルを控えておく
今回は、検証MSEがそれまでの最小値を更新したときに、その時点の重みをコピーします
if val_mse < best_val_mse:
best_val_mse = val_mse
best_state = deepcopy(model.state_dict())
best_epoch = epoch
state_dict()からモデルの重みなどの状態を取り出し、deepcopyで独立したコピーを作ります
単にmodel.state_dict()を変数へ代入するだけでは、その後の更新も反映されてしまいます
学習後は、控えておいた状態を読み戻してからテストします
model.load_state_dict(best_state)
ここでは200エポックすべてを実行してからモデルを選びます
途中で学習を打ち切る処理ではありません
6 テスト用データで評価する
最終評価では、MSEに加えて 平均絶対誤差(MAE) も確認します
| 指標 | 計算方法 | 読み取り方 |
|---|---|---|
| MSE | 予測と正解の差を二乗して平均する | 大きなずれが強く反映される |
| MAE | 予測と正解の差の絶対値を平均する | 元の数値の単位で、平均的なずれを読める |
どちらも、この例では小さいほど予測が正解に近い指標です
例えばMAEが0.07なら、テストデータでは予測と正解の差の絶対値が平均約0.07だった、という意味です
正解率が93%という意味ではありません
単純な予測方法とも比べる
MSEだけを見ても、その値が十分小さいのかは判断しにくいことがあります
そこで今回は、「どんな入力にも、学習用の正解の平均値を返す」という単純な予測と比べます
このような比較の基準をベースラインと呼びます
baseline = y_train.mean()
baseline_mse = (baseline - y_test).square().mean().item()
平均値を計算するのは、学習用の正解だけです
ニューラルネットワークとベースラインを、同じテスト用データ・同じMSEで評価します
実際に動かしてみる
実行環境
次の環境で、掲載コードのGPU実行とCPU実行を確認しています
| 項目 | 確認した環境 |
|---|---|
| Python | 3.10.6 |
| PyTorch | 2.10.0+cu130 |
| GPU | NVIDIA GeForce RTX 3090 |
前回のPyTorch環境をそのまま使えます
追加のライブラリや、外部データのダウンロードは必要ありません
PyTorchをこれから導入する場合は、公式のインストール案内でOSやGPUに合う方法を選んでください
前回と同じ2.10.0の導入方法は、過去バージョンの案内で確認できます
コードはCUDAを利用できる場合にGPUを使い、利用できない場合はCPUを使います
コード全体
以下をneural_network_workflow.pyとして保存してください
"""データの準備から学習・評価までを確認するPyTorchの入門例。"""
from copy import deepcopy
import platform
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
def evaluate(model, x, y, device):
"""重みを更新せず、指定したデータ全体のMSEとMAEを計算する。"""
model.eval()
with torch.no_grad():
prediction = model(x.to(device))
error = prediction - y.to(device)
mse = error.square().mean().item()
mae = error.abs().mean().item()
return mse, mae
def main():
# 1. 実行環境と初期値を設定する
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"使用デバイス: {device}")
if device.type == "cuda":
print(f"GPU: {torch.cuda.get_device_name(device)}")
# 2. 入力と正解を300件作る。データ作成用の乱数は独立させる
data_generator = torch.Generator().manual_seed(0)
x = torch.rand(300, 1, generator=data_generator, dtype=torch.float32)
noise = 0.1 * torch.randn(
300, 1, generator=data_generator, dtype=torch.float32
)
y = 2 * x + 1 + noise
# 3. 同じインデックスで、入力と正解を一緒に分割する
indices = torch.randperm(300, generator=data_generator)
train_idx = indices[:180]
val_idx = indices[180:240]
test_idx = indices[240:]
x_train, y_train = x[train_idx], y[train_idx]
x_val, y_val = x[val_idx], y[val_idx]
x_test, y_test = x[test_idx], y[test_idx]
print(f"件数: 学習={len(x_train)}, 検証={len(x_val)}, テスト={len(x_test)}")
print(f"学習用の形状: 入力={tuple(x_train.shape)}, 正解={tuple(y_train.shape)}")
# 4. CPU上のデータを、最大32件ずつ取り出せるようにする
train_loader = DataLoader(
TensorDataset(x_train, y_train),
batch_size=32,
shuffle=True,
generator=torch.Generator().manual_seed(1),
num_workers=0,
)
# 5. 前回と同じ、小さなニューラルネットワークを作る
model = nn.Sequential(
nn.Linear(1, 8),
nn.ReLU(),
nn.Linear(8, 1),
).to(device)
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
initial_mse, _ = evaluate(model, x_train, y_train, device)
print(f"\n学習前の学習データMSE: {initial_mse:.6f}")
# 6. 学習し、検証用データで最も良かった重みを控える
best_val_mse = float("inf")
best_state = None
best_epoch = 0
epochs = 200
for epoch in range(1, epochs + 1):
model.train()
for x_batch, y_batch in train_loader:
x_batch = x_batch.to(device)
y_batch = y_batch.to(device)
optimizer.zero_grad()
prediction = model(x_batch)
loss = loss_fn(prediction, y_batch)
loss.backward()
optimizer.step()
# 両方とも、そのエポック終了時点のモデルで測る
train_mse, _ = evaluate(model, x_train, y_train, device)
val_mse, _ = evaluate(model, x_val, y_val, device)
if val_mse < best_val_mse:
best_val_mse = val_mse
best_state = deepcopy(model.state_dict())
best_epoch = epoch
if epoch == 1 or epoch % 50 == 0:
print(
f"Epoch {epoch:3d}: "
f"学習MSE={train_mse:.6f}, 検証MSE={val_mse:.6f}"
)
# 7. 検証で選んだモデルに戻し、最後にテスト用データで評価する
model.load_state_dict(best_state)
test_mse, test_mae = evaluate(model, x_test, y_test, device)
print(f"\n採用したエポック: {best_epoch}")
print(f"採用時の検証MSE: {best_val_mse:.6f}")
print(f"テストMSE: {test_mse:.6f}")
print(f"テストMAE: {test_mae:.6f}")
# 学習用の正解の平均だけを返す、単純な予測とも比較する
baseline = y_train.mean()
baseline_mse = (baseline - y_test).square().mean().item()
print(f"平均値だけで予測した場合のテストMSE: {baseline_mse:.6f}")
# 8. 入力だけを渡し、学習済みモデルで予測する
x_new = torch.tensor([[0.2], [0.8]], dtype=torch.float32, device=device)
model.eval()
with torch.no_grad():
predictions = model(x_new).cpu().flatten().tolist()
for value, predicted in zip([0.2, 0.8], predictions):
print(f"新しい入力: {value:.1f}, 予測: {predicted:.3f}")
if __name__ == "__main__":
main()
torch.manual_seedやtorch.Generator().manual_seed(...)は、乱数の初期値を指定する処理です
今回はモデルの初期化、データの作成・分割、学習時の並び順を比較しやすくしています
ただし、実行環境やPyTorchのバージョンが違う場合に、結果の完全な一致を保証するものではありません
また、この小さな例では評価用のデータをまとめてデバイスに移しています
大量のデータを扱う場合には、評価もミニバッチに分けて計算します
実行方法
ファイルを保存したフォルダで、次のコマンドを実行します
python neural_network_workflow.py
実行結果
以下は、上記環境のGPUで取得した実行結果です
Python: 3.10.6
PyTorch: 2.10.0+cu130
使用デバイス: cuda
GPU: NVIDIA GeForce RTX 3090
件数: 学習=180, 検証=60, テスト=60
学習用の形状: 入力=(180, 1), 正解=(180, 1)
学習前の学習データMSE: 3.421679
Epoch 1: 学習MSE=0.175833, 検証MSE=0.210497
Epoch 50: 学習MSE=0.012736, 検証MSE=0.012516
Epoch 100: 学習MSE=0.012916, 検証MSE=0.012815
Epoch 150: 学習MSE=0.012752, 検証MSE=0.012504
Epoch 200: 学習MSE=0.012830, 検証MSE=0.012513
採用したエポック: 87
採用時の検証MSE: 0.012477
テストMSE: 0.008248
テストMAE: 0.070116
平均値だけで予測した場合のテストMSE: 0.325076
新しい入力: 0.2, 予測: 1.395
新しい入力: 0.8, 予測: 2.613
最初に、使用デバイスとデータ件数を確認します
cpuと表示された場合は、CPUで処理しています
学習前の学習データMSEは約3.42でしたが、学習後は約0.013まで小さくなっています
検証MSEも小さくなり、この実行では87エポック目の状態が選ばれました
200エポック目ではなく、検証用データで最もよかった87エポック目を使って、テストしています
テストMSEは約0.0082で、平均値だけを返すベースラインの約0.3251より小さくなりました
このデータでは、入力に応じて予測を変えるモデルが、一定値を返す方法よりよい結果になっています
テストMAEは約0.0701なので、テスト用60件に対する平均的なずれの大きさは約0.07と読めます
最後の2件は、新しい入力だけを渡した予測例です
ばらつきの中心となる関係では、0.2に対して1.4、0.8に対して2.6になります
予測も、その近くの値になっています
ただし、実際の正解データにはランダムなばらつきが含まれます
学習結果を見るときのポイント
損失が0にならなくても失敗とは限らない
今回の正解には、入力だけからは分からないランダムなばらつきを加えています
新しいデータのばらつきまで、毎回ぴったり当てることはできません
そのため、学習用データの損失を0に近づけることだけを目標にせず、検証用データでもよい予測ができているかを見ます
学習用データの細かな特徴に合わせすぎ、別のデータで予測が悪くなることを過学習と呼びます
学習と検証の両方を見る
学習の進み方は、おおむね次のように読み取れます
| 観察した変化 | 考えられる状態 |
|---|---|
| 学習MSEも検証MSEも下がる | 学習に使わないデータでも予測が改善している |
| 学習MSEは下がるが、検証MSEは悪化し続ける | 過学習の可能性がある |
| どちらも高いまま | 学習の設定やデータ、モデルを確認する必要がある |
ミニバッチ学習では数値が上下することもあるので、1回の変化だけで判断せず、複数エポックにわたる傾向を見ます
今回の実行結果は、検証MSEがおおむね横ばいになった例です
過学習がはっきり進行する様子を示したものではありません
テスト結果から言える範囲を考える
今回確かめたのは、同じ作り方・同じ入力範囲のデータに対する性能です
テストMSEが検証MSEより小さいのも、それぞれ異なる60件を使っているため起こり得ます
これだけで、0〜1の範囲を大きく外れた入力や、別の規則で作られたデータでもうまく予測できると判断することはできません
使う場面を考えながら、評価するデータを用意することが大切です
実際のデータを使うときの前処理
今回は、欠けた値がなく、入力も0〜1の範囲にあるデータを作りました
実際のデータでは、空欄の扱いや、数値の大きさをそろえる前処理なども必要になります
例えば標準化では、平均と標準偏差を使って数値の尺度をそろえます
この平均や標準偏差は学習用データだけで求め、検証用・テスト用にも同じ値を使います
評価用データの情報まで使って前処理を決めると、評価が実力以上によく見える原因になります
こうした情報の混入を、データリークと呼びます
まずデータを分け、学習用データから前処理の基準を決める
この順番も、モデル作成の大事な部分です
最後に
今回は、前回の小さなニューラルネットワークを使いながら、データの準備から学習・評価まで進めました
入力と正解をそろえ、データを用途ごとに分け、ミニバッチで学習する
そして、検証結果からモデルを選び、最後にテスト用データで確かめる
ニューラルネットワークを作るときは、学習を動かすことと同じくらい、結果をどう確かめるかが大切です
これで、これまで登場したデータ、ニューラルネットワーク、活性化関数、損失関数、勾配降下法が、実際の作業としてつながりました
次回は、学習用データではうまくいくのに、別のデータでは予測が悪くなる「過学習」を、学習曲線を見ながらもう少し詳しく確認していく予定です
またゆっくり記事を書きます
本記事では、構成の整理・サンプルコードの作成に生成AIを使用しています
株式会社ONE WEDGE
https://onewedge.co.jp/
_______
株式会社ONE WEDGEは、Webシステム開発・SES・AI/DX支援を行うIT企業です。
生成AIを活用した業務効率化や次世代システム開発にも注力しており、企業の課題解決だけでなく、エンジニア一人ひとりの成長にも本気で向き合っています。
また、技術は「一人で学ぶもの」ではなく、「仲間と成長するもの」だと考え、社内外でのコミュニティづくりにも力を入れています。
_______