ニューラルネットワークの実装(基礎)
PyTorch での順伝播に関する一連の流れが理解できたところで、本章ではデータセットが与えられた場合のモデルを学習させるまでの実践的な一連の流れを紹介します。次章で紹介する PyTorch Lightning を用いるとこれから紹介する流れをより簡単に記述することができますが、内部の流れをまずは理解しておかないと PyTorch Lightning での記述も理解することができず、遠回りが一番の近道ですので、基礎をしっかり押さえていきましょう。
- Step 1 : データセットを準備
- Step 2 : モデルを定義
- Step 3 : 目的関数を選択
- Step 4 : 最適化手法を選択
- Step 5 : モデルを学習
本章の構成
- データセットの準備
- モデルの定義
- モデルを学習
データセットを準備
この例では、scikit-learn に用意されている Iris という「アヤメの品種」を分類する問題に取り組みます。分類する品種は virginica (0)
、versicolor (1)
、setosa (2)
の 3 種類で、入力変数は以下の 4 つです。
ID | 変数名 | 説明 | データ型 |
---|---|---|---|
0 | sepal_length | がく片の長さ | float |
1 | sepal_width | がく片の幅 | float |
2 | petal_length | 花びらの長さ | float |
3 | petal_width | 花びらの幅 | float |
scikit-learn には sklearn.datasets
モジュールにサンプル用のデータセットが豊富に用意されています。今回は、そのひとつである load_iris()
を用います。
from sklearn.datasets import load_iris
# Iris データセットの読み込み
x, t = load_iris(return_X_y=True)
# 形の確認
x.shape, t.shape
# 型の確認
type(x), type(t)
# データ型の確認
x.dtype, t.dtype
サンプル数が 150 であり、入力変数の数が紹介した通りの 4 つです。PyTorch では torch.Tensor
が標準であり、分類の問題ということも考慮してデータ型も合わせて変換します。
import torch
import torch.nn as nn
import torch.nn.functional as F
# データ型の変換
x = torch.tensor(x, dtype=torch.float32)
t = torch.tensor(t, dtype=torch.int64)
# 型の確認
type(x), type(t)
# データ型の確認
x.dtype, t.dtype
PyTorch では、学習時に使用するデータ x
と t
をひとつのオブジェクト dataset
にまとめます。torch.utils.data.TensorDataset
を使用して dataset
に格納しましょう。
# 入力変数と目的変数をまとめて、ひとつのオブジェクト dataset に変換
dataset = torch.utils.data.TensorDataset(x, t)
dataset
# 型の確認
type(dataset)
# (入力値, 目標値) のようにタプルで格納されている
dataset[0]
# 型の確認
type(dataset[0])
# 1 サンプル目の入力値
dataset[0][0]
# 1 サンプル目の目標値
dataset[0][1]
# サンプル数は len で取得可能
len(dataset)
次に、学習用とテスト用にデータセットを分割します。
それでは、torch.utils.data.random_split
を使用してオリジナルのデータセットを学習用データセット、検証用データセット、テスト用データセットに分割しましょう。最初にそれぞれのサンプル数を比率から決定していきます。
# 各データセットのサンプル数を決定
# train : val: test = 60% : 20% : 20%
n_train = int(len(dataset) * 0.6)
n_val = int(len(dataset) * 0.2)
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])
# サンプル数の確認
len(train), len(val), len(test)
ミニバッチ学習
通常ニューラルモデルを勾配降下法で最適化する場合は、データを 1 つ 1 つ用いてパラメータを更新するのではなく、いくつかのデータをまとめて入力し、それぞれの勾配を計算したあと、その勾配の平均値を用いてパラメータの更新を行う方法である、ミニバッチ学習が使われるのでした。
PyTorch では DataLoader
が用意されており、ミニバッチ学習のためのバッチサイズ単位の分割や、学習時のシャッフルなどを担当します。
# バッチサイズ
batch_size = 10
# shuffle はデフォルトで False のため、学習データのみ True に指定
train_loader = torch.utils.data.DataLoader(train, batch_size, shuffle=True)
val_loader = torch.utils.data.DataLoader(val, batch_size)
test_loader = torch.utils.data.DataLoader(test, batch_size)
今回のデータ読み込みでは train_loader
において shuffle=True
と記述しました。この設定によりのエポックが回るごとに各ミニバッチを構成するデータがランダムに入れ替わることになります。
なぜサンプルをシャッフルする必要があるのでしょうか。狙いは目的関数を変化させ、過学習を防ぐところにあります。勾配降下法では目的関数を下っていきますが、固定されたサンプルで局所解に囚われてしまうとそれ以上計算を進めることができません。これはサンプルの固定により目的関数もまた固定されてしまうためです。この問題は学習に使用するサンプルをその都度変化させることによって回避できる考えられます。目的関数の形が代わり、いずれかの方向に勾配降下できる可能性が生まれます。
モデルの定義
ここでは学習に使用するモデルを定義します。
本章以降では、モデルを構成する「層 (layer) 」という言葉を、これまでの説明で用いてきたノードの集まりに対してではなく、学習可能なパラメータを持つ関数に対して用います。よって下の図のモデルは、入力層と全結合層 1 つからなる 2 層のモデルということになります。
今回は、入力変数が 4 であり、分類したいクラスの数が 3 であるため、全結合層 fc1
と fc2
のノードの数を以下のように決めます。
- fc1: input: 4 => output: 4
- fc2: input: 4 => output: 3
順伝播の計算の流れは、「線形変換 (fc1
) => 非線形変換 (ReLU) => 線形変換 (fc2
) => 非線形変換 (Softmax)」とします。分類の場合に必要な Softmax 関数は記述する必要がなく、理由は目的関数の部分で説明します。演習問題とほとんど同じように記述しますが、PyTorch では x
が入力されて、出力にも x
を上書きするように記述することが多く、今回もその記述を採用しています。
class Net(nn.Module):
# 使用するオブジェクトを定義
def __init__(self):
super(Net, self).__init__()
self.fc1 = nn.Linear(4, 4)
self.fc2 = nn.Linear(4, 3)
# 順伝播
def forward(self, x):
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
return x
クラス内に定義したモデル構造を持つインスタンス net
を作成しています。__init__
メソッドの内部にパラメータを持つモデルを、forward
メソッドの内部に順伝播の計算を記述します。
また、forward
は自前で定義したメソッドですが、これから使用するモジュール内で forward
という名前が指定されているため、fwd
など別の名前を付けないように気をつけましょう。
# 乱数のシードを固定して再現性を確保
torch.manual_seed(0)
# インスタンス化
net = Net()
# モデルの確認
net
目的関数を選択
今回は 3 クラスの分類であるため、目的関数としてクロスエントロピーを採用します。ここで、サンプル数を 、 クラス分類だとするとクロスエントロピーは、
の式で表されます。また、この予測値 の計算の前に活性化関数として Softmax 関数による処理があります。つまり、Softmax => Log
の順番で計算を行います。
PyTorch では計算を高速化させるために、Softmax 関数と対数変換を同時に行う関数である F.log_softmax
が用意されています。Softmax => Log
の計算がそれぞれ行うと速度として遅いこと、そして計算後の値が安定しないといった理由で、それぞれを別々に計算するのではなく一度にまとめて計算する方が優れているようです。そして、PyTorch で用意されている F.cross_entropy
では内部の計算に F.log_softmax
が使用されています。したがって、事前に Softmax 関数の計算を行う必要がありません。そのため、モデルの定義の部分では Softmax 関数を設けていませんでした。
PyTorch では目的関数を criterion
という名前で定義することが一般的です。
# 目的関数の設定
criterion = F.cross_entropy
criterion
最適化手法の選択
モデルの学習にあたり使用する最適化手法を選択します。optimizer
という変数名で定義することが一般的です。PyTorch では、torch.optim
内に様々な最適化手法が用意されており、今回は確率的勾配降下法 (SGD) を選択します。optimizer
を定義する際に、引数としてモデルのパラメータを渡す必要があり、パラメータの取得には net.parameters()
を用います。もう1つの引数として学習係数 (lr: learning rate) も選択します。
# net.parameters() を展開
for parameter in iter(net.parameters()):
print(parameter)
# 最適化手法の選択
optimizer = torch.optim.SGD(net.parameters(), lr=0.1)
optimizer
モデルの学習手順
学習を実行する前に、モデルを学習する際の手順を把握しておきましょう。学習では、以下の 5 つの手順を繰り返します。
- ミニバッチ単位でサンプル , を抽出
- 現在のパラメータ , を利用して、順伝播で予測値 を算出
- 目標値 と予測値 から目的関数 を算出
- 誤差逆伝播法に基づいて各パラメータの勾配 を算出
- 勾配の値に基づいて選択した最適化手法によりパラメータ , を更新
ディープラーニングのフレームワークによって多少の設定の違いはありますが、基本的には上記の流れを押さえておきましょう。それでは、上記の流れを具体的に実装していきます。最初に学習用のデータセット train_loader
からバッチサイズ分のサンプルを抽出します。train_loader
は DataLoader
のオブジェクトであり、下記のように for
文を使うことで、各イテレーションにおけるミニバッチを抽出できます。
for batch in train_loader:
batch を利用した処理
今回は挙動を確認するために、for
文において 1 イテレーション分取り出すことができるイテレータ iter
を用います。このイテレータから順番に値を取り出すときには next
を使用します。
# バッチサイズ分のサンプルの抽出
batch = next(iter(train_loader))
batch
# 入力値と目標値に分割
x, t = batch
# 入力値の確認
x
# 目標値の確認
t
このようにバッチサイズ分の入力変数と目的変数のサンプルを抽出できました。次に、パラメータの値を用いて予測値を算出します。現状のパラメータの値を確認しましょう。
# 全結合層 fc1 の重み
net.fc1.weight
# 全結合層 fc1 のバイアス
net.fc1.bias
# 全結合層 fc2 の重み
net.fc2.weight
# 全結合層 fc2 のバイアス
net.fc2.bias
これらのパラメータの値とバッチサイズ分抽出したサンプルを用いて予測値を算出します。順伝播の計算は Net
クラスの forward
メソッドに記述してあります。
# 予測値の算出
y = net.forward(x)
y
Net
クラスは nn.Module
を継承しており、nn.Module
では forward
メソッドを call
メソッドで呼び出すことができるため、以下の記述でも上記と同じ処理となります。
# call メソッドを用いた forward の計算(推奨)
y = net(x)
y
この予測値を用いて、目的関数を算出します。
# 目的関数の計算 criterion の call メソッドを利用
loss = criterion(y, t)
loss
この loss
に基づいて勾配の算出を行います。まず勾配を求める前に、勾配の情報が格納されている場所を確認します。勾配の情報は各パラメータに格納されています。初期状態で何も値が格納されていません。
# 全結合層 fc1 の重みに関する勾配
net.fc1.weight.grad
# 全結合層 fc1 のバイアスに関する勾配
net.fc1.bias.grad
# 全結合層 fc2 の重みに関する勾配
net.fc2.weight.grad
# 全結合層 fc2 のバイアスに関する勾配
net.fc2.bias.grad
目的関数の勾配は、backward()
メソッドを使用し、自動微分が PyTorch 側で実行されます。
# 勾配の算出
loss.backward()
勾配の算出後に、勾配の情報がどのように変化しているのか確認しましょう。
# 全結合層 fc1 の重みに関する勾配
net.fc1.weight.grad
# 全結合層 fc1 のバイアスに関する勾配
net.fc1.bias.grad
# 全結合層 fc2 の重みに関する勾配
net.fc2.weight.grad
# 全結合層 fc2 のバイアスに関する勾配
net.fc2.bias.grad
上記のように、各パラメータに関する勾配が求まっています。loss
に記述されているメソッドを実行し、net
の中のパラメータに変化があったことに戸惑うかも知れませんが、net => y => loss
のように計算を行うとその関係性も保存されており、勾配の算出の際には逆向きに net <= y <= loss
のように計算結果が格納されているため、net
内の属性に変化がありました。
これらの値を用いて、パラメータの更新を行います。パラメータの更新には optimizer
を用います。
# 勾配の情報を用いたパラメータの更新
optimizer.step()
更新後のパラメータを更新前のパラメータと比較すると、値が変化していることがわかります。
# 全結合層 fc1 の重み
net.fc1.weight
# 全結合層 fc1 のバイアス
net.fc1.bias
# 全結合層 fc2 の重み
net.fc2.weight
# 全結合層 fc2 のバイアス
net.fc2.bias
これで一連の流れを確認することができました。数学の流れを把握しておければ、あとは PyTorch 側で用意されているメソッドを用いるだけで大枠を実装することができることがわかります。たとえば、誤差逆伝播法を一から実装することは大変ですが、PyTorch では loss.backward()
のたった 1 行で実装することができます。
また、一連の流れでは紹介していませんでしたが、以下の 2 つも一連の処理の中で行います。
- データを使用するデバイスに転送
- パラメータの勾配を初期化
まず、データのデバイスのへの転送ですが、ディープラーニングでは Graphics Processing Unit (GPU) を用いた演算の高速化を行うことが一般的であり、その場合は学習時にデータとモデルの両方を GPU のメモリ上に転送する必要があります。学習に用いる計算機に GPU が搭載されている必要がありますが、Colab では、無料で手軽に GPU を用いた演算を試すことができます。現環境で、GPU が使用できる状況にあるかは、以下の関数で確認することができます。
# 演算に使用できる GPU の有無を確認
torch.cuda.is_available()
今回は True と表示されているため、GPU でも CPU でも演算可能です。この結果に基づいて、自動的にデバイスの選択ができるようには、以下のように記述します。
# GPU の設定状況に基づいたデバイスの選択
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
device
GPU が使用できる状況だと今回のように 0 番目の GPU が cuda:0
のように指定されることになります。CUDA とは NVIDIA 社が開発、提供している GPU 向けの統合開発環境です。GPU 関連で必要なパッケージといった程度の認識で問題ありません。モデルとデータを以下のようにデバイスへ転送しましょう。
# 指定したデバイスへのモデルの転送
net.to(device)
# 指定したデバイスへの入力変数の転送
x = x.to(device)
x
# 指定したデバイスへの目的変数の転送
t = t.to(device)
t
次に、勾配の初期化に関して説明します。勾配の情報は loss.backward()
で算出することができますが、loss.backward()
では求めた勾配情報が各パラメータの grad
に代入されるのではなく、現状の勾配情報に加算されます。これは累積勾配 (accumulated gradient) を用いた方が良いアルゴリズムのための仕様でありますが、この累積勾配を用いた実装が必要な状況はもう少し先の応用にあります。そのため、今回は PyTorch の仕様であるといった程度の認識で問題なく、パラメータの勾配を求める前には勾配情報の初期化が必要であると認識しておきましょう。
# 勾配情報の初期化
optimizer.zero_grad()
初期化後にパラメータの勾配を確認すると、すべての値が 0 で初期化されています。
# 初期化後の勾配情報
net.fc1.weight.grad
net.fc1.bias.grad
モデルを学習
それでは、これまで学んだ知識をまとめて、本格的な学習ループを記述します。とりあえず、エポック数は 1 として期待通りに動くのか試してみましょう。
# エポックの数
max_epoch = 1
# モデルの初期化
torch.manual_seed(0)
# モデルのインスタンス化とデバイスへの転送
net = Net().to(device)
# 最適化手法の選択
optimizer = torch.optim.SGD(net.parameters(), lr=0.1)
# 学習ループ
for epoch in range(max_epoch):
for batch in train_loader:
# バッチサイズ分のサンプルを抽出
x, t = batch
# 学習時に使用するデバイスへデータの転送
x = x.to(device)
t = t.to(device)
# パラメータの勾配を初期化
optimizer.zero_grad()
# 予測値の算出
y = net(x)
# 目標値と予測値から目的関数の値を算出
loss = criterion(y, t)
# 目的関数の値を表示して確認
# item(): tensot.Tensor => float
print('loss: ', loss.item())
# 各パラメータの勾配を算出
loss.backward()
# 勾配の情報を用いたパラメータの更新
optimizer.step()
上記の用に 1 エポック(9 イテレーション)分の学習を行い、目的関数の値が順調に小さくなっていることがわかります。
ここで、分類の問題設定であるため、結果をひと目でわかりやすいように正解率 (accuracy) を追加してみましょう。100 サンプル中 97 サンプルが正しいクラスに分類できていれば、正解率は 97% といった直感的にわかりやすい指標です。
正解率の算出に当たり、まずは予測値において最も値が大きなクラスの番号を取得します。最大値を求めるときは torch.max
関数が用意されており、最大値に対する要素番号を求めるときには torch.argmax
を用います。今回は属するクラスの番号だけで良いため torch.argmax
となります。
# dim=1 で行ごとの最大値に対する要素番号を取得(dim=0 は列ごと)
y_label = torch.argmax(y, dim=1)
# 予測値から最大となるクラスの番号を取り出した結果
y_label
# 目的変数
t
この結果から、比較演算子を使って、正解率を求めます。
# 値が一致しているか確認
y_label == t
# 値が True となる個数の総和
torch.sum(y_label == t)
この値を要素数で割ると正解率を求めることができますが、現状が torch.Tensor
の int
型となっているため、割り算をして 1 を下回る場合には小数点以下が切り捨てられて 0 となってしまいます。この簡単な解決策として、1.0
を乗じて float
型に変換しておきましょう。
# int => float
torch.sum(y_label == t) * 1.0
そして、要素数で割ることで正解率が求まります。
# 正解率
acc = torch.sum(y_label == t) * 1.0 / len(t)
acc
上記のように、正解率の計算を記述できました。こちらを学習ループに追加して確認しましょう。
# モデルの初期化
torch.manual_seed(0)
# モデルのインスタンス化とデバイスへの転送
net = Net().to(device)
# 最適化手法の選択
optimizer = torch.optim.SGD(net.parameters(), lr=0.1)
for epoch in range(max_epoch):
for batch in train_loader:
x, t = batch
x = x.to(device)
t = t.to(device)
optimizer.zero_grad()
y = net(x)
loss = criterion(y, t)
# New:正解率の算出
y_label = torch.argmax(y, dim=1)
acc = torch.sum(y_label == t) * 1.0 / len(t)
print('accuracy:', acc)
loss.backward()
optimizer.step()
学習後の正解率を検証
これまでは学習データに対する処理を書いていきましたが、検証データやテストデータに対する結果も見ていく必要があります。学習データの場合とほとんど同じですが、一点だけ異なる点として、検証データやテストデータに対しては学習を行わないため、勾配の情報が必要ないという点です。そのため with torch.no_grad
の中に検証用のコードを記述し、勾配に関する無駄な計算や計算機リソースの専有を避けましょう。
検証データでもテストデータでも対応できるように、data_loader
を引数としてとり、使用する際には val_loader
もしくは test_loader
を指定します。
# 正解率の計算
def calc_acc(data_loader):
with torch.no_grad():
accs = [] # 各バッチごとの結果格納用
for batch in data_loader:
x, t = batch
x = x.to(device)
t = t.to(device)
y = net(x)
y_label = torch.argmax(y, dim=1)
acc = torch.sum(y_label == t) * 1.0 / len(t)
accs.append(acc)
# 全体の平均を算出
avg_acc = torch.tensor(accs).mean()
print('Accuracy: {:.1f}%'.format(avg_acc * 100))
return avg_acc
# 検証データで確認
calc_acc(val_loader)
# テストデータで確認
calc_acc(test_loader)
この関数を学習ループに追加することで学習中においても検証データに対する状況を確認することができます。
本章では、PyTorch の仕様の確認から、学習ループの記述まで幅広く紹介しました。次章では、今回の学習の流れをより簡単に記述できる PyTorch Lightning という PyTorch ラッパーを紹介します。この PyTorch Lightning を理解するためには、今回の学習ループを把握しておく必要があったため、今回の経験が次章からに活きてきます。