畳み込みニューラルネットワークの精度向上
本章では、モデルの精度向上に役立つチューニングのテクニックをいくつか紹介していきます。前章までは基礎編として使い方を重視して説明してきました。しかし本当に重要なのは、実装した後にどのように本番環境でも使用できるレベルの精度にするかです。
よく使われるテクニックをいくつか実際に実装しながら、適用前と適用後の精度を比較します。
本章の構成
- ベースモデルの作成
- 最適化アルゴリズム
- 過学習対策
- 活性化関数
ベースモデルの作成
はじめに、ベースモデルを作成しましょう。今後色々なテクニックを適用する際に、適用前と適用後の差分を正確に測るためです。それぞれのテクニックを適用する場合には、追加部分以外はベースモデルと同じモデル構造にします。
!pip install pytorch_lightning
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision
import torchvision.transforms as transforms
import pytorch_lightning as pl
from pytorch_lightning import Trainer
pl.__version__
データセットの準備
前章で扱った手書き文字である MNIST の分類は簡単なモデルの定義でもある程度の正解率が得られますが、もう少し難しい問題設定で試してみましょう。CIFAR10 と呼ばれる以下のような 10 クラスの分類を行います。CIFAR10 は MNIST のグレースケール画像とは異なり、フルカラー画像です。CIFAR10 も MNIST と同様に、torchvision
にデータセットが用意されています。
# 前処理
transform = transforms.Compose([
transforms.ToTensor(),
])
# データの取得と分割
train_val = torchvision.datasets.CIFAR10(root='data', train=True, download=True, transform=transform)
test = torchvision.datasets.CIFAR10(root='data', train=False, download=True, transform=transform)
# train : val = 0.8 : 0.2
n_train = int(len(train_val) * 0.8)
n_val = len(train_val) - n_train
# ランダムに分割を行うため、シードを固定して再現性を確保
torch.manual_seed(0)
# train と val に分割
train, val = torch.utils.data.random_split(train_val, [n_train, n_val])
それでは、今回扱うデータを 25 枚ランダムに抜粋して表示します。
正解ラベル | 種別 |
---|---|
0 | airplane |
1 | automobile |
2 | bird |
3 | cat |
4 | deer |
5 | dog |
6 | frog |
7 | horse |
8 | ship |
9 | truck |
10 クラス分類となっており、上記の表の種別を分類することが目標です。 と低解像度で計算リソースがあまり必要ない点も CIFAR10 がよく画像の練習問題として扱われる理由のひとつです。
#画像の表示
plt.figure(figsize=(12,12))
for i in range(25):
# 画像を (height, width, channel) の順に変換
img = np.transpose(train[i][0].numpy(), (1, 2, 0))
plt.subplot(5, 5, i+1)
plt.imshow(img)
モデルの定義と学習
今回は過学習対策の効果検証をするため、学習用データと検証用データの正解率を比較します。そのため、TrainNet
クラスに正解率を算出するスクリプトを追記しています。
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)
# 以下追加
y_label = torch.argmax(y, dim=1)
acc = torch.sum(t == y_label) * 1.0 / len(t)
results = {'loss': loss, 'acc': acc}
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, batch_size=128):
super(Net, self).__init__()
self.batch_size = batch_size
# 畳み込み層
self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
# 全結合層
self.fc1 = nn.Linear(128*4*4, 128)
self.fc2 = nn.Linear(128, 10)
def lossfun(self, y, t):
return F.cross_entropy(y, t)
def configure_optimizers(self):
return torch.optim.Adam(self.parameters(), lr=1e-3)
def forward(self, x):
# ch: 3 -> 32, size: 32 * 32 -> 16 * 16
x = self.conv1(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
# ch: 32 -> 64, size: 16 * 16 -> 8 * 8
x = self.conv2(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
# ch: 64 -> 128, size: 8 * 8 -> 4 * 4
x = self.conv3(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x
# 乱数のシードを固定
torch.manual_seed(0)
# モデルの準備
net = Net(batch_size=1024)
trainer = Trainer(gpus=1, max_epochs=50, early_stop_callback=False)
trainer.fit(net)
# 結果の確認
trainer.test()
trainer.callback_metrics
Train | Val | Test | |
---|---|---|---|
Base Accuracy | 0.797 | 0.705 | 0.702 |
Base Loss | 0.589 | 0.924 | 0.919 |
上記のスコアをベースラインとして、様々なテクニックを適用すると、どのように変化するか確認していきましょう。
最適化アルゴリズム
ディープラーニングの基礎の章で、勾配降下法を紹介しました。勾配降下法をベースとして SGD などのさまざまな最適化アルゴリズムが生まれています。どのアルゴリズムが一番良いのかについてはその時のデータやモデルによってさまざまであり、色々と試していく必要があるのが現状で、これを使えば間違いないというアルゴリズムは存在しません。今でも活発に世界中のトップリサーチャーによって研究されています。
その中でも現在、代表的な最適化アルゴリズムとしては下記が挙げられます。
- SGD
- Momentum SGD
- RMSprop
- Adam
他にも無数に存在しますが、まずはこちらを把握しておけば問題ありません。最適化アルゴリズムを使った精度向上のポイントとしては、大きく 2 点あります。
- 最適化アルゴリズムの選択
- ハイパーパラメータのチューニング
まずはどの最適化アルゴリズムを選択するかですが、こちらは決まりはありません。Momentum SGD と Adam が初手でよく使われています。
また、各アルゴリズムのハイパーパラメータチューニングも重要なポイントです。一概にどの値にすれば良いのかというのは難しいですが、学習率 (Learning Rate) を 1e-1
〜 1e-5
辺りの値にすることが経験上多いです。手動でチューニングすることも多いですが、Optuna などの探索ツールを使用して、効率的に探していくこともありますのでどちらも頭の中の候補にいれておきましょう。
それでは、上記で紹介した 4 つの最適化アルゴリズムについて、数式を交えて簡単にご紹介します。数式は覚える必要ありませんので、気になる方だけご覧ください。ここでは勾配を (ナブラ)という記号で表現します。
SGD (Stochastic Gradient Descent: 確率的勾配降下法)
SGD (Stochastic Gradient Descent: 確率的勾配降下法) は、勾配降下法をミニバッチ学習(オンライン学習)で行ったものです。勾配降下法では、局所最適解への収束が起こりやすいという問題点をランダムにサンプルを選び出すことで解消しました。勾配を求め、重み を勾配と逆方向に更新します。更新する際の更新幅の調整のために、学習係数 を設けています。
PyTorch では、torch.optim.SGD(lr=1e-2)
として設定できます。lr
が学習係数です。
Momentum SGD
Momentum SGD は SGD に慣性項()を付け足したものです。上の式を下の式に代入すると となり、前半部分は SGD と同様であることがわかります。
また、ハイパーパラメータとして が追加されており、実装では momentum
引数として指定します。PyTorchでは、torch.optim.SGD(lr=1e-2, momentum=0.9)
のように設定できます。
このアルゴリズムはハイパーパラメータが 2 つに増えており、SGD よりも最適化が難しいという問題があります。しかし、初手で使うことが多い最適化アルゴリズムなので、皆さんも迷われたらこちらを使ってみてはいかがでしょうか。
RMSprop
RMSprop は、SGD における学習係数の箇所を学習の収束に合わせて(勾配の大きさに合わせて)変化するように組まれたアルゴリズムです。勾配の二乗の指数移動平均を取るように設計されています。
2 つ目の式を 3 つ目の式に代入すると、
となり、SGDでの学習係数 の箇所が に変わっていることがわかります。
PyTorch では torch.optim.RMSprop(lr=0.01, alpha=0.99, eps=1e-8)
として設定でき、それぞれの引数は
- :
lr
- :
alpha
- :
eps
に対応しています。こちらもハイパーパラメータの数が増え、最適化が困難なアルゴリズムの一つです。
Adam (Adaptive moment estimation)
Adam は 前述の Momentum の慣性的な動きと、RMSprop の適応的に学習係数を調整する考えを組み合わせたアルゴリズムです。現在、最も評価されているアルゴリズムのひとつですが Adam はハイパーパラメータの数が非常に多いので、それぞれのハイパーパラメータを適切にチューニングすることがポイントになります。
PyTorch では、 torch.optim.Adam(lr=1e-3, betas=(0.9, 0.999), eps=1e-8)
として設定でき、それぞれの引数は
- :
lr
- , :
betas
- :
eps
に対応しています。デフォルトでも良い結果が出やすいアルゴリズムですが、Optuna 等を使用してチューニングすることもおすすめします。
過学習対策
機械学習をおこなう上で、よく遭遇する現象として過学習があります。学習データに対して過度にフィッティングしてしまい本来の目的である未知のデータに対する誤差(汎化誤差)が大きくなってしまうことを指します。そのような過学習に対する対策として、いくつかの方法をご紹介していきます。
過学習を防止するための最良の解決策は、より多くの学習用データを使うことです。多くのデータで学習を行えば行うほど、モデルは自然により汎化していく様になります。これが不可能な場合、次善の策は本節で紹介するようなテクニックを使うことです。
過学習への対策として、本章では 4 つのテクニックをご紹介します。
- Dropout
- 正則化(Regularization)
- Early Stopping
- Batch Normalization
Dropout
出典:http://jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf
ドロップアウトは、ニューラルネットワークを学習する際に、ある更新で層の中のノードのうちのいくつかを無効にして学習を行い、次の更新では別のノードを無効にして学習をおこなうことを繰り返していきます。これにより学習時にネットワークの自由度を強制的に小さくして汎化性能をあげ、過学習を避けることができます。
ドロップアウトが高性能である理由は、アンサンブル学習を近似しているからと言われています。アンサンブル学習とは、複数のモデルに学習させて、予測結果を統合することで汎化性能を高める手法です。
nn.Dropout2d
で用意されており、引数には入力ユニットをドロップする割合 (p
) を指定します。もし、nn.Dropout2d(p=0.5)
とするならば半分のノードを無効化して学習するということを意味しています。
class Net(TrainNet, ValidationNet, TestNet):
def __init__(self, batch_size=128):
super(Net, self).__init__()
self.batch_size = batch_size
self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
self.fc1 = nn.Linear(128*4*4, 128)
self.fc2 = nn.Linear(128, 10)
# 追加
self.dropout = nn.Dropout2d(p=0.5)
def lossfun(self, y, t):
return F.cross_entropy(y, t)
def configure_optimizers(self):
return torch.optim.Adam(self.parameters(), lr=1e-3)
def forward(self, x):
# ch: 3 -> 32, size: 32 * 32 -> 16 * 16
x = self.conv1(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
# ch: 32 -> 64, size: 16 * 16 -> 8 * 8
x = self.conv2(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
# ch: 64 -> 128, size: 8 * 8 -> 4 * 4
x = self.conv3(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
x = x.view(x.size(0), -1)
# 追加
x = self.dropout(x)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x
# 乱数のシードを固定
torch.manual_seed(0)
# モデル学習の準備
net = Net(batch_size=1024)
trainer = Trainer(gpus=1, max_epochs=50, early_stop_callback=False)
trainer.fit(net)
trainer.test()
trainer.callback_metrics
Train | Val | Test | |
---|---|---|---|
Base Accuracy | 0.797 | 0.705 | 0.702 |
Base Loss | 0.589 | 0.924 | 0.919 |
Dropout Accuracy | 0.75 | 0.738 | 0.736 |
Dropout Loss | 0.603 | 0.770 | 0.769 |
検証データの正解率が向上し、学習データの正解率との乖離が小さくなりました。過学習抑制に効果があることが確認できます。
正則化 (Regularization)
パラメータの値の大きさに対してモデルの複雑さが増すことに対するペナルティを設け、過学習を抑える方法があります。これを正則化と呼びます。Basic of Machine Learning 回帰の章で詳しく説明しているので、そちらも合わせてご覧ください。
正則化には大きく 2 種類あり、
- L1 正則化 (Lasso):パラメータの絶対値の総和を用い、極端なデータの重みを 0 にする
- L2 正則化 (Ridge):パラメータの二乗の総和を用い、極端なデータの重みを 0 に近づける
ただし注意点として、過学習をしているからといってむやみに正則化をしすぎると逆に学習不足(アンダーフィッティング)となって精度が落ちることもあるので注意が必要です。学習不足の原因は様々であり、
- モデルが十分複雑でない
- 正則化が強すぎる
- 単に学習時間が短すぎる
といった理由が挙げられます。学習不足は、学習用データの中の関連したパターンを学習しきっていないということを意味します。
PyTorch では、各最適化アルゴリズムの weight_decay
引数で、 正則化の強さを指定できます。
class Net(TrainNet, ValidationNet, TestNet):
def __init__(self, batch_size=128):
super(Net, self).__init__()
self.batch_size = batch_size
self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
self.fc1 = nn.Linear(128*4*4, 128)
self.fc2 = nn.Linear(128, 10)
def lossfun(self, y, t):
return F.cross_entropy(y, t)
def configure_optimizers(self):
# weight_decay 引数を追加
return torch.optim.Adam(self.parameters(), lr=1e-3, weight_decay=1e-3)
def forward(self, x):
# ch: 3 -> 32, size: 32 * 32 -> 16 * 16
x = self.conv1(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
# ch: 32 -> 64, size: 16 * 16 -> 8 * 8
x = self.conv2(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
# ch: 64 -> 128, size: 8 * 8 -> 4 * 4
x = self.conv3(x)
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x
# 乱数のシードを固定
torch.manual_seed(0)
# モデル学習の準備
net = Net(batch_size=1024)
trainer = Trainer(gpus=1, max_epochs=50, early_stop_callback=False)
trainer.fit(net)
trainer.test()
trainer.callback_metrics
Train | Val | Test | |
---|---|---|---|
Base Accuracy | 0.797 | 0.705 | 0.702 |
Base Loss | 0.589 | 0.924 | 0.919 |
Dropout Accuracy | 0.75 | 0.738 | 0.736 |
Dropout Loss | 0.603 | 0.770 | 0.769 |
Regularization Accuracy | 0.75 | 0.699 | 0.696 |
Regularization Loss | 0.807 | 0.902 | 0.696 |
今回は効果が薄いようでしたが、一つの選択肢にしておきましょう。
Early Stopping
Early Stopping は、学習が進まなくなった場合に途中であっても学習を打ち切ることができる機能です。デフォルトでは val_loss
をモニタリングしており、val_loss
が一定期間下がらなくなると学習を打ち切ります。
デフォルトで True
となっている機能でしたが、今まで False
としていました。明示的に Trainer
のインスタンス化の際に early_stop_callback=True
とすることで指定できます。
# 乱数のシードを固定
torch.manual_seed(0)
# モデル学習の準備
net = Net(batch_size=1024)
trainer = Trainer(gpus=1, max_epochs=50, early_stop_callback=True)
trainer.fit(net)
trainer.test()
trainer.callback_metrics
Train | Val | Test | |
---|---|---|---|
Base Accuracy | 0.797 | 0.705 | 0.702 |
Base Loss | 0.589 | 0.924 | 0.919 |
Dropout Accuracy | 0.75 | 0.738 | 0.736 |
Dropout Loss | 0.603 | 0.770 | 0.769 |
Regularization Accuracy | 0.75 | 0.699 | 0.696 |
Regularization Loss | 0.807 | 0.902 | 0.696 |
Early Stopping Accuracy | 0.766 | 0.659 | 0.658 |
Early Stopping Loss | 0.729 | 1.00 | 0.996 |
エポック数は最大 50 回までと設定していましたが、val_loss
が下がらなくなるタイミング(36 エポック目)で学習が打ち切りされました。
注意点として、ある一定期間に値の向上が見られない場合でも、しばらくするとまた向上することがあるということです。効率的に学習するために、大きめのエポック数を準備して学習を実行することもありますが、ケースバイケースという点は抑えておきましょう。
Batch Normalization
バッチノーマリゼーションは、ミニバッチごとに平均 と 標準偏差 を求め、
のように へと各変数ごとに変換を行います。ここで、 と はハイパーパラメータであり、単純な正規化のように平均 0、標準偏差 1 とするのではなく、平均 、標準偏差 となるように変換を行います。必ずしも平均 0、標準偏差 1 が良いとは限らないためです。
実装としては、各バッチ毎に平均と標準偏差を定めて標準化を行うといった非常に簡単な手法なのですが、こちらを層に加えることで各変数感のスケールによる差を吸収できます。
それでは、バッチノーマリゼーションがある場合で試してみましょう。
class Net(TrainNet, ValidationNet, TestNet):
def __init__(self, batch_size=128):
super(Net, self).__init__()
self.batch_size = batch_size
self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
self.fc1 = nn.Linear(128*4*4, 128)
self.fc2 = nn.Linear(128, 10)
# 追加
self.bn1 = nn.BatchNorm2d(32)
self.bn2 = nn.BatchNorm2d(64)
self.bn3 = nn.BatchNorm2d(128)
def lossfun(self, y, t):
return F.cross_entropy(y, t)
def configure_optimizers(self):
return torch.optim.Adam(self.parameters(), lr=1e-3)
def forward(self, x):
# ch: 3 -> 32, size: 32 * 32 -> 16 * 16
x = self.conv1(x)
x = self.bn1(x) # 追加
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
# ch: 32 -> 64, size: 16 * 16 -> 8 * 8
x = self.conv2(x)
x = self.bn2(x) # 追加
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
# ch: 64 -> 128, size: 8 * 8 -> 4 * 4
x = self.conv3(x)
x = self.bn3(x) # 追加
x = F.relu(x)
x = F.max_pool2d(x, 2, 2)
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x
# 乱数のシードを固定
torch.manual_seed(0)
# モデル学習の準備
net = Net(batch_size=1024)
trainer = Trainer(gpus=1, max_epochs=50, early_stop_callback=False)
trainer.fit(net)
trainer.test()
trainer.callback_metrics
Train | Val | Test | |
---|---|---|---|
Base Accuracy | 0.797 | 0.705 | 0.702 |
Base Loss | 0.589 | 0.924 | 0.919 |
Dropout Accuracy | 0.75 | 0.738 | 0.736 |
Dropout Loss | 0.603 | 0.770 | 0.769 |
Regularization Accuracy | 0.75 | 0.699 | 0.696 |
Regularization Loss | 0.807 | 0.902 | 0.696 |
Early Stopping Accuracy | 0.766 | 0.659 | 0.658 |
Early Stopping Loss | 0.729 | 1.00 | 0.996 |
BatchNormalization Accuracy | 0.813 | 0.772 | 0.777 |
BatchNormalization Loss | 0.367 | 0.712 | 0.703 |
学習データ、検証データ共に正解率の向上が確認できました。ベースモデルほどの乖離もなく、やはりバッチノーマリゼーションは効果的な手法であることが実験からわかりました。
活性化関数
これまで活性化関数として、ReLU 関数を多く使ってきましたが、ニューラルネットワークに活性化関数(非線型変換)を用いる理由は何でしょうか。それは、モデルの表現力を増すためです。
では、なぜ活性化関数を用いるとニューラルネットワークの表現力が増すのでしょうか。線形変換をいくら繋げても、それらは一つの線形変換で表現し直すことができることになり、層を深いディープラーニングの恩恵を受けることができなくなってしまうからです。非線形な関数を表現するには、非線形な関数を活性化関数に入れようというのは直感的に理解できそうです。
また、ReLU 関数の他でよく使われる活性化関数としては、
- sigmoid 関数
- tanh 関数
が代表されるので、まずはこちらを押さえておきましょう。
他にも ReLU 関数がよく使われているのは以下のような理由があります。
- は単純ゆえに計算コストが低い
- の部分では微分値が常に 1 であるため勾配消失の心配が少なくなる
より高度な活性化関数
上記で説明した単純な関数よりも高度な活性化関数を用いたい場合は、torch.nn.functional
モジュールにありますので、こちらの公式ドキュメントをご覧ください。
https://pytorch.org/docs/stable/nn.functional.html#non-linear-activation-functions
有名な活性化関数を確認しましょう。
def sigmoid(x):
return 1 / (1+np.exp(-x))
def relu(x):
return np.maximum(0, x)
def tanh(x):
return np.tanh(x)
def leaky_relu(x):
return np.maximum(x, 0.01*x)
def prelu(x, a):
return np.maximum(x, a*x)
fig = plt.figure(figsize=(10, 6))
x = np.linspace(-10, 10, 1000)
ax = fig.add_subplot(111)
ax.plot(x, sigmoid(x), label='sigmoid')
ax.plot(x, relu(x), label='ReLU')
ax.plot(x, tanh(x), label='tanh')
ax.plot(x, leaky_relu(x), label='leaky_relu')
ax.plot(x, prelu(x, 0.08), label='prelu')
plt.legend()
plt.xlim(-5, 5)
plt.ylim(-1.1, 2)
plt.grid(color='white', linestyle='-')
plt.show();