ニューラルネットワークの実装(分類)
本章では、前章での内容を踏まえて、PyTorch Lightning による学習ループの簡略化からはじめ、精度向上のテクニックを扱っていきます。
本章の構成
- PyTorch Lightning による学習ループの簡略化
- データセットの準備
- PyTorch Lightning によるモデルと学習手順の定義
- 予測精度を向上させるテクニック
- 学習済みモデルの保存から推論
PyTorch Lightning による学習ループの簡略化
PyTorch の標準的な学習ループの記述は for 文が二重で登場したりと、初学者にとって易しい記述とは言えません。また、上級者にとってもコードが複雑であるということはエラーのデバッグ処理が面倒になったりと悪影響があります。これを楽にするために、skorch のようなラッパーがありますが、簡単なモデル構成や設定でないと逆に設定が面倒になるといったデメリットがあります。PyTorch は研究開発に積極的に使われている背景からして、skorch で完璧に対応できる簡単なケースでは満足行かないことが多々あると想定されます。
PyTorch ラッパーを使用した学習ループの簡略化
ある程度の学習ループの記述が必要になりつつも、複雑な設定を行うことができる PyTorch ラッパーを用いることをおすすめします。PyTorch のラッパーとしては、以下の 3 つが有名です。
- Ignite
- PyTorch Lightning
- Catalyst
もともとは PyTorch チームが Ignite を開発しており、筆者も Ignite を使用して開発を行っていましたが、最近 2019 年の後半から PyTorch Lightning の人気が高まっており、Ignite よりもシンプルに記述ができつつも、分散処理など本格的な開発にも耐えられる仕様で作られているといった大きなメリットがあります。そこで、本資料ではPyTorch Lightning がこれからの PyTorch ラッパーの標準になると予想し、PyTorch Lightning を採用します。世界最大級の機械学習プラットフォームの Kaggle に挑戦する人たちの間では Catalyst も使われはじめているようで、こちらも今後の成長に注目です。これらのラッパーは現在も開発が進んでおり、バージョンが変わるごとに書き方が変わることは日常茶飯事です。コードを動かすためにはバージョンに合わせておくことを常に意識しましょう。
Colab には pytorch_lightning
がインストールされていないので、インストールから始める必要があります。
# ライブラリのインストール
!pip install pytorch_lightning
データセットの準備
今回はこちらのデータを使用して分類の実装を練習してみましょう。どのような形式でデータを準備しておくと良いのかといった参考になるため、格納されている生のデータも確認しておきます。
import numpy as np
import pandas as pd
from google.colab import files
uploaded = files.upload()
# データの読み込み(df: data frame)
df = pd.read_csv('wine_class.csv')
# データの表示(先頭の5件)
df.head()
Class | Alcohol | Ash | Alcalinity of ash | Magnesium | Total phenols | Flavanoids | Nonflavanoid phenols | Color intensity | Hue | Proline | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 14.23 | 2.43 | 15.6 | 127 | 2.80 | 3.06 | 0.28 | 5.64 | 1.04 | 1065 |
1 | 1 | 13.20 | 2.14 | 11.2 | 100 | 2.65 | 2.76 | 0.26 | 4.38 | 1.05 | 1050 |
2 | 1 | 13.16 | 2.67 | 18.6 | 101 | 2.80 | 3.24 | 0.30 | 5.68 | 1.03 | 1185 |
3 | 1 | 14.37 | 2.50 | 16.8 | 113 | 3.85 | 3.49 | 0.24 | 7.80 | 0.86 | 1480 |
4 | 1 | 13.24 | 2.87 | 21.0 | 118 | 2.80 | 2.69 | 0.39 | 4.32 | 1.04 | 735 |
入力変数と目的変数に切り分け
もうすっかり慣れた操作ですが、データセットを入力変数 と目的変数 に切り分けていきます。今回の問題設定では、ワインの種類を分類するワインソムリエモデルを構築することが目標です。Class
列にワインのラベルが入っていますので何クラスか確認しておきましょう。
np.unique(df['Class'], return_counts=True)
3 クラスの分類です。それでは、Class
を目的変数 、それ以外を入力変数 として分割しましょう。
x = df.drop('Class', axis=1)
t = df['Class']
正しく切り分けられているかデータの中身を表示して確認しておきます。
# 表示して確認
x.head(3)
Alcohol | Ash | Alcalinity of ash | Magnesium | Total phenols | Flavanoids | Nonflavanoid phenols | Color intensity | Hue | Proline | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 14.23 | 2.43 | 15.6 | 127 | 2.80 | 3.06 | 0.28 | 5.64 | 1.04 | 1065 |
1 | 13.20 | 2.14 | 11.2 | 100 | 2.65 | 2.76 | 0.26 | 4.38 | 1.05 | 1050 |
2 | 13.16 | 2.67 | 18.6 | 101 | 2.80 | 3.24 | 0.30 | 5.68 | 1.03 | 1185 |
# サイズの確認
x.shape, t.shape
.shape
を使うことで、サンプル数(今回は 178 )と入力変数の数(今回は 10 )を確認することができます。
type(x), type(t)
tensor に変換
PyTorch では torch.Tensor
と呼ばれる形式がスタンダードであるため、こちらに変換します。ただ、pandas.core.frame.DataFrame
、 pandas.core.series.Series
型から Tensor
型への直接変換ができないため、一度 NumPy の形式に変換します。NumPy の形式に変換するには、.values
を使用します。こちらは頻出するので覚えておきましょう。
import torch
# Tensor 形式へ変換
x = torch.tensor(x.values, dtype=torch.float32)
t = torch.tensor(t.values, dtype=torch.int64)
また、分類の場合にはラベルが 0 から始まらなければならないというルールがあるため、1, 2, 3
→ 0, 1, 2
と変換しましょう。torch.Tensor
では全体で引き算がサポートしてくれています。
t
# ラベルを 0 から始める
t = t - 1
t
dataset にまとめる
PyTorch では x
と t
をひとつにまとめることが一般的です。TensorDataset
を使いましょう。
import torch.utils.data
# 入力変数と目的変数をまとめて、ひとつのオブジェクト dataset に変換
dataset = torch.utils.data.TensorDataset(x, t)
dataset
# (入力変数, 目的変数) のようにタプルで格納されている
dataset[0]
学習用データ、検証用データ、テスト用データに分割
前章では設定した DataLoader
の設定は PyTorch Lightning 側で用意されているため、必要ありません。こちらでは、学習用、検証用、テスト用のデータセットの分割だけ行います。
# 各データセットのサンプル数を決定
# train : val : test = 60% : 20% : 20%
n_train = int(len(dataset) * 0.6)
n_val = int((len(dataset) - n_train) * 0.5)
n_test = len(dataset) - n_train - n_val
# サンプル数の確認
n_train, n_val, n_test
# ランダムに分割を行うため、シードを固定して再現性を確保
torch.manual_seed(0)
# データセットの分割
train, val, test = torch.utils.data.random_split(dataset, [n_train, n_val, n_test])
PyTorch Lightning によるモデルと学習手順の定義
PyTorch Lightning では、モデルを定義するクラスの中に、学習の手順も記述します。標準的な PyTorch の書き方を学んだ後であれば、学習ループの一部を抽出しているだけとわかるため、つまづきは少ないはずです。モデルを定義する際のクラスは nn.Module
を継承していましたが、この点を PyTorch Lightning の LightningModule
を継承します。まずは検証データとテストデータを抜いた学習データのみに対する最小限のクラスを設計していきます。
import pytorch_lightning as pl
# バージョンの確認
pl.__version__
import torch.nn as nn
import torch.nn.functional as F
class Net(pl.LightningModule):
# New: バッチサイズ等を引数に指定
def __init__(self, input_size=10, hidden_size=5, output_size=3, batch_size=10):
super(Net, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, output_size)
self.batch_size = batch_size
# 変更なし
def forward(self, x):
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
return x
# New: 目的関数の設定
def lossfun(self, y, t):
return F.cross_entropy(y, t)
# New: optimizer の設定
def configure_optimizers(self):
return torch.optim.SGD(self.parameters(), lr=0.1)
# New: train 用の DataLoader の設定
@pl.data_loader
def train_dataloader(self):
return torch.utils.data.DataLoader(train, self.batch_size, shuffle=True)
# New: 学習データに対する処理
def training_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
results = {'loss': loss}
return results
新しく lossfun
、configure_optimizers
、train_dataloader
、train_step
というメソッドを追加しています。
メソッドこそ新しく追加しましたが、これまで登場してきていたものばかりであり、lossfun
は目的関数、configure_optimizers
では最適化手法の設定、train_dataloader
では DataLoader の設定 train_step
では学習ループのミニバッチ抽出後の処理です。
最も基本的な PyTorch Lightning での記述は、これで設定完了であり、ここから簡単にモデルの学習を行うことができます。なお、前章までは、入力値や目的変数を学習に使用するデバイスに渡す処理を明示していましたが、この処理は PyTorch Lightning 側で行われるため、気にする必要がなくなっています。
モデルの学習
モデルの学習には PyTorch Lightning で用意されている Trainer
を用います。まずは、Trainer
のデフォルト設定で学習を行います。
from pytorch_lightning import Trainer
# 乱数のシード固定
torch.manual_seed(0)
# インスタンス化
net = Net()
# 学習用に用いるクラスの Trainer をインスタンス化
trainer = Trainer()
# Trainer によるモデルの学習
trainer.fit(net)
これで学習が完了しました。前章までは for 文の中に複雑に処理を書いていたことを思うと、遥かにすっきりとした印象です。また、複雑なモデルの構成も __init__
と forward
内に書くことが実現でき、複雑な学習のステップがあったとしても training_step
内に書くことが実現できます。このように、PyTorch Lightning は手軽さと柔軟さの両方を併せ持ったラッパーであり、研究開発にとって有力な選択肢になるはずです。
ここで、Trainer
の引数の中でも代表的なものを紹介しておきます。
引数名 | デフォルトの値 | 説明 |
---|---|---|
show_progress_bar | True | 学習時の進捗を標準出力 |
max_epochs | 1000 | 学習時の最大エポック数 |
min_epochs | 1 | 学習時の最小エポック数 |
train_percent_check | 1.0 | 学習データに対する確認の比率 (%) |
val_percent_check | 1.0 | 検証データに対する確認の比率 (%) |
test_percent_check | 1.0 | テストデータに対する確認の比率 (%) |
early_stop_callback | False | 早期終了の使用の有無 |
gpus | None | 使用するGPUの数 |
distributed_backend | None | 分散学習の方法 |
ここで、最初でも抑えておくべき点として、デフォルトでは early_stop_callback=False
となっているため、早期終了 (early stopping) が適用されていません。早期終了とはある計測する指標に対して学習によって変化がなくなった場合に終了するといった方法です。基本的には、検証データに対する目的関数の値もしくは正解率などが指標として採用されます。そのため、エポックの数は max_nb_epochs
で最大の数を指定する程度で、あとは早期終了を有効にし、不要な学習は打ち切ることにすることも多いです。
また、nb_
がよく登場しますが、英語の the number of … を指しており、…の数という意味です。今回だとエポック数の最大値という意味になります。エポック数も一種のハイパーパラメータであり、適切に決めることが状況によるため、計算時間的に可能な範囲の max_nb_epochs
を指定しておく方法が現実的な最良の解といえるのではないでしょうか。
検証データの追加
検証データに対する結果の計算などは任意で用意されており、validation_step
と validation_end
に記述しましょう。validation_step
は検証データに対する各イテレーションごとの結果で、validation_end
はエポック毎にその結果を集計します。検証データやテストデータに対する計算の場合には、torch.no_grad()
を用いて勾配情報を持たないようにしていましたが、そういった処理も PyTorch Lightning 側で設定されています。
class Net(pl.LightningModule):
def __init__(self, input_size=10, hidden_size=5, output_size=3, batch_size=10):
super(Net, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, output_size)
self.batch_size = batch_size
def forward(self, x):
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
return x
def lossfun(self, y, t):
return F.cross_entropy(y, t)
def configure_optimizers(self):
return torch.optim.SGD(self.parameters(), lr=0.1)
@pl.data_loader
def train_dataloader(self):
return torch.utils.data.DataLoader(train, self.batch_size, shuffle=True)
def training_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
results = {'loss': loss}
return results
# New: 検証用データセットの設定
@pl.data_loader
def val_dataloader(self):
return torch.utils.data.DataLoader(val, self.batch_size)
# New: 検証データに対するイテレーションごとの処理
def validation_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
# 正解率の算出
y_label = torch.argmax(y, dim=1)
acc = torch.sum(t == y_label) * 1.0 / len(t)
results = {'val_loss': loss, 'val_acc': acc}
return results
# New: 検証データに対するエポックごとの処理
def validation_end(self, outputs):
# 各イテレーションごとに得られた値をまとめて、平均値の取得
avg_loss = torch.stack([x['val_loss'] for x in outputs]).mean()
avg_acc =torch.stack([x['val_acc'] for x in outputs]).mean()
results = {'val_loss': avg_loss, 'val_acc': avg_acc}
return results
それで、設計した内容に基づいて検証データでの結果の確認も含めた学習を行っていきます。
torch.manual_seed(0)
net = Net()
trainer = Trainer()
# モデルの学習
trainer.fit(net)
テストデータを追加
検証データと同様に、テストデータに対する結果が得られるようにクラスにメソッドを追加します。
class Net(pl.LightningModule):
def __init__(self, input_size=10, hidden_size=5, output_size=3, batch_size=10):
super(Net, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, output_size)
self.batch_size = batch_size
def forward(self, x):
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
return x
def lossfun(self, y, t):
return F.cross_entropy(y, t)
def configure_optimizers(self):
return torch.optim.SGD(self.parameters(), lr=0.1)
@pl.data_loader
def train_dataloader(self):
return torch.utils.data.DataLoader(train, self.batch_size, shuffle=True)
def training_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
results = {'loss': loss}
return results
@pl.data_loader
def val_dataloader(self):
return torch.utils.data.DataLoader(val, self.batch_size)
def validation_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
y_label = torch.argmax(y, dim=1)
acc = torch.sum(t == y_label) * 1.0 / len(t)
results = {'val_loss': loss, 'val_acc': acc}
return results
def validation_end(self, outputs):
avg_loss = torch.stack([x['val_loss'] for x in outputs]).mean()
avg_acc =torch.stack([x['val_acc'] for x in outputs]).mean()
results = {'val_loss': avg_loss, 'val_acc': avg_acc}
return results
# New: テストデータセットの設定
@pl.data_loader
def test_dataloader(self):
return torch.utils.data.DataLoader(test, self.batch_size)
# New: テストデータに対するイテレーションごとの処理
def test_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
y_label = torch.argmax(y, dim=1)
acc = torch.sum(t == y_label) * 1.0 / len(t)
results = {'test_loss': loss, 'test_acc': acc}
return results
# New: テストデータに対するエポックごとの処理
def test_end(self, outputs):
avg_loss = torch.stack([x['test_loss'] for x in outputs]).mean()
avg_acc = torch.stack([x['test_acc'] for x in outputs]).mean()
results = {'test_loss': avg_loss, 'test_acc': avg_acc}
return results
# 学習に関する一連の流れを実行
torch.manual_seed(0)
net = Net()
trainer = Trainer()
trainer.fit(net)
最終的な結果は trainer
のcallback_metrics
に格納されます。テストデータに対する結果を検証する場合は trainer.test()
メソッドが用意されており、実行すると検証用データセットに対する結果とテスト用データセットに対する結果の両方が確認できます。
テストデータに対する結果を検証する場合は test
メソッドが用意されており、検証用データセットに対する結果とテスト用データセットに対する結果が格納されています。
# テストデータに対する処理の実行(test_step と test_end)
trainer.test()
# テストデータに対する結果の確認
trainer.callback_metrics
可読性と汎用性を向上
PyTorch Lightning では学習時の記述を簡略化できますが、検証データやテストデータ分まで追加すると、クラスが長くなりがちです。難しいモデルを記述するまでは、forward
の内容ぐらいしか大きく変えないことが多く、それに対して毎回長めのクラスを記述することは避けることが望ましいです。そこで、学習データ、検証データ、テストデータのそれぞれに対する処理を TrainNet
、ValidationNet
、TestNet
のクラスにそれぞれ記述し、それらを継承した Net
に変化のある部分を記述していきます。
# 学習データに対する処理
class TrainNet(pl.LightningModule):
@pl.data_loader
def train_dataloader(self):
return torch.utils.data.DataLoader(train, self.batch_size, shuffle=True)
def training_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
results = {'loss': loss}
return results
# 検証データに対する処理
class ValidationNet(pl.LightningModule):
@pl.data_loader
def val_dataloader(self):
return torch.utils.data.DataLoader(val, self.batch_size)
def validation_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
y_label = torch.argmax(y, dim=1)
acc = torch.sum(t == y_label) * 1.0 / len(t)
results = {'val_loss': loss, 'val_acc': acc}
return results
def validation_end(self, outputs):
avg_loss = torch.stack([x['val_loss'] for x in outputs]).mean()
avg_acc = torch.stack([x['val_acc'] for x in outputs]).mean()
results = {'val_loss': avg_loss, 'val_acc': avg_acc}
return results
# テストデータに対する処理
class TestNet(pl.LightningModule):
@pl.data_loader
def test_dataloader(self):
return torch.utils.data.DataLoader(test, self.batch_size)
def test_step(self, batch, batch_nb):
x, t = batch
y = self.forward(x)
loss = self.lossfun(y, t)
y_label = torch.argmax(y, dim=1)
acc = torch.sum(t == y_label) * 1.0 / len(t)
results = {'test_loss': loss, 'test_acc': acc}
return results
def test_end(self, outputs):
avg_loss = torch.stack([x['test_loss'] for x in outputs]).mean()
avg_acc = torch.stack([x['test_acc'] for x in outputs]).mean()
results = {'test_loss': avg_loss, 'test_acc': avg_acc}
return results
# 学習データ、検証データ、テストデータへの処理を継承したクラス
class Net(TrainNet, ValidationNet, TestNet):
def __init__(self, input_size=10, hidden_size=5, output_size=3, batch_size=10):
super(Net, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, output_size)
self.batch_size = batch_size
def forward(self, x):
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
return x
def lossfun(self, y, t):
return F.cross_entropy(y, t)
def configure_optimizers(self):
return torch.optim.SGD(self.parameters(), lr=0.1)
このように記述を行うと、Net
でモデルの構造を記述するだけで良く、可読性が高まります。また、各種のデータに対する処理は毎回同じものを使うことができ、汎用性も高まります。学習がこれまでと同様に行えることを確認しておきましょう。
# 学習に関する一連の流れを実行
torch.manual_seed(0)
net = Net()
trainer = Trainer()
trainer.fit(net)
予測精度向上させるテクニック
PyTorch Lightning を使った一通りの流れを説明しましたが、まだ精度がよくありません。ここからが試行錯誤になります。まず精度を向上させる方法の代表的なテクニックとして、バッチノーマリゼーション (Batch Normalization) が挙げられます。
バッチノーマリゼーションでは、ミニバッチごとに平均 と 標準偏差 を求め、
のように へと各変数ごとに変換を行います。ここで、 と は学習可能なパラメータであり、単純な正規化のように平均 0、標準偏差 1 とするのではなく、平均 、標準偏差 となるように変換を行います。必ずしも平均 0、標準偏差 1 が良いとは限らないため、標準化した後に少し値を変換させることで完全に分布を制限してしまうことを避けています。
実装としては、各バッチ毎に平均 と標準偏差 を定めて標準化を行うといった非常に簡単な手法なのですが、こちらを層に加えることで各変数感のスケールによる差を吸収できます。
それでは、バッチノーマリゼーションがある場合で試してみましょう。
# 学習データ、検証データ、テストデータへの処理を継承したクラス
class Net(TrainNet, ValidationNet, TestNet):
def __init__(self, input_size=10, hidden_size=5, output_size=3, batch_size=10):
super(Net, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, output_size)
self.batch_size = batch_size
# 追加
self.bn = nn.BatchNorm1d(input_size)
def forward(self, x):
# 追加
x = self.bn(x)
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
return x
def lossfun(self, y, t):
return F.cross_entropy(y, t)
def configure_optimizers(self):
return torch.optim.SGD(self.parameters(), lr=0.1)
# 学習に関する一連の流れを実行
torch.manual_seed(0)
net = Net()
trainer = Trainer()
trainer.fit(net)
# テストデータに対する処理の実行(test_step と test_end)
trainer.test()
# 結果の確認
trainer.callback_metrics
飛躍的に正解率が向上しました。正解率の値 (acc) が良くなっていれば成功です。このようにディープラーニングでは、BatchNormalization を含めた細かなポイントがあるため、今後も様々なテクニックを紹介していきます。PyTorch では、その多くの機能がすでに実装されているため、上記のコードのように少し付け加えるだけでその効果を検証できるため、非常に便利です。
学習済みモデルの保存から推論
学習が終わると学習済みモデルが得られます。PyTorch で準備されている torch.save
の関数を使用すれば保存できます。保存の際には、モデルの名前と学習済みモデルを指定しましょう。
# モデルの重み
net.state_dict()
# 学習済みモデルを保存
torch.save(net.state_dict(), 'wine.pt')
こちらで wine.pt
というファイルが新しく作成できていれば学習済みモデルの保存が完了です。
学習済みモデルを使用した推論
学習済みモデルは単にファイルをロードするだけでなく、モデルの構造を明示しておき、そのモデルに対して、パラメータの値を当てはめながらロードしていくことになります。
成功すれば <All keys matched successfully>
とでますがパラメータ数などが合わなければ、失敗してしまいます。
# インスタンス化
net = Net()
net.load_state_dict(torch.load('wine.pt'))
予測値の計算
本来であれば学習を終えたモデルは新しく取得したデータに対して推論しますが、今回は新しいデータが無いので学習で使用したデータセットのいちばん最初のサンプルに対する予測値を計算してみましょう。
# 予測値の計算
y = net(x)[0]
y
# 予測ラベル
torch.argmax(y)
予測が正解しているかどうかを目的変数と比較して確認してみましょう。
# 目的変数
t[0]
このように学習済みモデルを使用した推論を実行できました。続いては、回帰に取り組んでいきます。