はじめに
ふと思い立って勉強を始めた「ゼロから作るDeep LearningーーPythonで学ぶディープラーニングの理論と実装」の4章で私がつまずいたことのメモです。
実行環境はmacOS Mojave + Anaconda 2019.10、Pythonのバージョンは3.7.4です。詳細はこのメモの1章をご参照ください。
(このメモの他の章へ:1章 / 2章 / 3章 / 4章 / 5章 / 6章 / 7章 / 8章 / まとめ)
この記事は個人で作成したものであり、内容や意見は所属企業・部門見解を代表するものではありません。
4章 ニューラルネットワークの学習
この章はニューラルネットワークの学習についての説明です。
4.1 データから学習する
通常は人が規則性を導き出してアルゴリズムを考え、それをプログラムに書いてコンピューターに実行させます。このアルゴリズムを考える作業自体もコンピューターにやらせてしまおうというのが、機械学習やニューラルネットワーク、ディープラーニングです。
この本では、処理したいデータに対して、事前に人が考えた特徴量の抽出(ベクトル化など)が必要なものを「機械学習」、さらにその「機械学習」に特徴量の抽出まで任せて生データをそのまま渡せるようにしたものを「ニューラルネットワーク(ディープラーニング)」と定義しています。この定義はやや乱暴な感じもしますが、言葉の使い分けにはあまり興味がないので気にせず先に進みます。
訓練データ、テストデータ、過学習などについて解説されていますが、特につまずく部分はありませんでした。
4.2 損失関数
損失関数として良く使われる2乗和誤差と交差エントロピー誤差の解説、そして訓練データの一部を使って学習するミニバッチ学習の解説です。ここも特につまずく部分はありませんでした。訓練データを全数使っても良さそうですが時間がかかって非効率ということですね。いわゆる標本調査みたいなことなのかと思います。
また、損失関数の代わりに認識精度を使うことができない理由として、認識精度は結果の微小な変化で反応せず不連続に変化するためうまく学習できないと解説されています。最初はピンと来ないかも知れませんが、次の微分の説明が終わると腹落ちするかと思います。
4.3 数値微分
微分の解説です。実装時の丸め誤差の説明は実用的でありがたいです。「微分」とか「偏微分」とか言葉を聞くと難しそうに思えてしまいますが、ちょっと値を変化させたら結果はどう変わるか?というだけなので、特に高校数学のおさらいとかはしなくても前に進めます。
ちなみに微分で出てくる $ \partial $ という記号はWikipedia曰くデルとかディーとかパーシャル・ディーとかラウンド・ディーとか読むそうです。
それにしても、Pythonは引数に関数が簡単に渡せていいですね。私のプログラマー現役時代はC/C++メインだったのですが、関数ポインタの表記がホント分かりにくくて嫌いでした
4.4 勾配
すべての変数の偏微分をベクトルにしたものが勾配です。これ自体は難しくはありません。
NumPy配列で小数を出力する時に値を丸めて表示してくれるのは見やすくていいですね。
>>> import numpy as np
>>> a = np.array([1.00000000123, 2.99999999987])
>>> a
array([1., 3.])
でも勝手に丸められると困ることもあるし、どんな仕様なんだろうと調べて見たら、表示方法を設定する機能がありました。numpy.set_printoptions
で、小数の表示方法や、要素数が多い場合の省略方法などを変更できます。例えばprecision
で小数点以下の桁数を大きく指定すると、きちんと丸められずに表示されます。
>>> np.set_printoptions(precision=12)
>>> a
array([1.00000000123, 2.99999999987])
これは便利!
4.4.1 勾配法
文中で「勾配降下法(gradient descent method)」という言葉が出てきますが、これは、以前勉強した時の教材では「最急降下法」と訳されていたものでした。
あと、学習率を示す $ \eta $という記号が出てきますが、これはギリシャ文字でイータと読みます(以前勉強した時は読み方を覚えていたのですが、すっかり忘れていてググりました)。
4.4.2 ニューラルネットワークに対する勾配
numerical_gradient(f, x)
を使って勾配を求めるのですが、このf
に渡す関数が
def f(W):
return net.loss(x, t)
となっていて、あれ?この関数は引数W
を使っていない?と少し混乱しましたが、「4.4 勾配」のところで実装したnumerical_gradient(f, x)
の関数の形をそのまま使おうとしているためで、引数W
はダミーとのこと。確かにsimpleNet
クラスは自身で重みW
を保持しているので、損失関数simpleNet.loss
に重みW
を渡す必要はありません。ダミーがあると分かりにくいので、私は引数なしで実装してみることにしました。
あとここで、numerical_gradient
を多次元配列でも大丈夫な形に修正しておく必要があります。
4.5 学習アルゴリズムの実装
ここからは、これまでに学んだ内容を使って、実際に確率的勾配降下法(SGD)を実装します。
まず、必要な関数を寄せ集めたfunctions.py
です。
# coding: utf-8
import numpy as np
def sigmoid(x):
"""シグモイド関数
本の実装ではオーバーフローしてしまうため、以下のサイトを参考に修正。
http://www.kamishima.net/mlmpyja/lr/sigmoid.html
Args:
x (numpy.ndarray): 入力
Returns:
numpy.ndarray: 出力
"""
# xをオーバーフローしない範囲に補正
sigmoid_range = 34.538776394910684
x = np.clip(x, -sigmoid_range, sigmoid_range)
# シグモイド関数
return 1 / (1 + np.exp(-x))
def softmax(x):
"""ソフトマックス関数
Args:
x (numpy.ndarray): 入力
Returns:
numpy.ndarray: 出力
"""
# バッチ処理の場合xは(バッチの数, 10)の2次元配列になる。
# この場合、ブロードキャストを使ってうまく画像ごとに計算する必要がある。
# ここでは1次元でも2次元でも共通化できるようnp.max()やnp.sum()はaxis=-1で算出し、
# そのままブロードキャストできるようkeepdims=Trueで次元を維持する。
c = np.max(x, axis=-1, keepdims=True)
exp_a = np.exp(x - c) # オーバーフロー対策
sum_exp_a = np.sum(exp_a, axis=-1, keepdims=True)
y = exp_a / sum_exp_a
return y
def numerical_gradient(f, x):
"""勾配の算出
Args:
f (function): 損失関数
x (numpy.ndarray): 勾配を調べたい重みパラメーターの配列
Returns:
numpy.ndarray: 勾配
"""
h = 1e-4
grad = np.zeros_like(x)
# np.nditerで多次元配列の要素を列挙
it = np.nditer(x, flags=['multi_index'])
while not it.finished:
idx = it.multi_index # it.multi_indexは列挙中の要素番号
tmp_val = x[idx] # 元の値を保存
# f(x + h)の算出
x[idx] = tmp_val + h
fxh1 = f()
# f(x - h)の算出
x[idx] = tmp_val - h
fxh2 = f()
# 勾配を算出
grad[idx] = (fxh1 - fxh2) / (2 * h)
x[idx] = tmp_val # 値を戻す
it.iternext()
return grad
def cross_entropy_error(y, t):
"""交差エントロピー誤差の算出
Args:
y (numpy.ndarray): ニューラルネットワークの出力
t (numpy.ndarray): 正解のラベル
Returns:
float: 交差エントロピー誤差
"""
# データ1つ場合は形状を整形
if y.ndim == 1:
t = t.reshape(1, t.size)
y = y.reshape(1, y.size)
# 誤差を算出してバッチ数で正規化
batch_size = y.shape[0]
return -np.sum(t * np.log(y + 1e-7)) / batch_size
def sigmoid_grad(x):
"""5章で学ぶ関数。誤差逆伝播法を使う際に必要。
"""
return (1.0 - sigmoid(x)) * sigmoid(x)
softmax
は、ゼロから作るDeep Learningで素人がつまずいたことメモ:3章で直したものをさらにスッキリさせてみました。この本のGitHubリポジトリのissueにあったsoftmax関数のコード改善案 #45を参考にしています。
numerical_gradient
は前述のように、引数f
で渡す関数の引数をなくしました。また、多次元配列に対応させるため、numpy.nditer
でループしています。なお、本のコードではnumpy.nditer
を使う際にop_flags=['readwrite']
を指定していますが、x
にアクセスするためのインデックスをmulti_index
で取り出しているだけで、イテレータにより列挙させたオブジェクトを更新している訳ではないのでop_flags
は省略(op_flags=['readonly']
になる)しました。詳細は英語ですがIterating Over Arrays#Modifying Array Valuesを参照してください。
最後の関数sigmoid_grad
は5章で学ぶものなのですが、処理時間短縮のために必要なので(後述)、本の通り実装しています。
続いて2層ニューラルネットワークを実装したtwo_layer_net.py
です。
# coding: utf-8
from functions import sigmoid, softmax, numerical_gradient, \
cross_entropy_error, sigmoid_grad
import numpy as np
class TwoLayerNet:
def __init__(self, input_size, hidden_size, output_size,
weight_init_std=0.01):
"""2層のニューラルネットワーク
Args:
input_size (int): 入力層のニューロンの数
hidden_size (int): 隠れ層のニューロンの数
output_size (int): 出力層のニューロンの数
weight_init_std (float, optional): 重みの初期値の調整パラメーター。デフォルトは0.01。
"""
# 重みの初期化
self.params = {}
self.params['W1'] = weight_init_std * \
np.random.randn(input_size, hidden_size)
self.params['b1'] = np.zeros(hidden_size)
self.params['W2'] = weight_init_std * \
np.random.randn(hidden_size, output_size)
self.params['b2'] = np.zeros(output_size)
def predict(self, x):
"""ニューラルネットワークによる推論
Args:
x (numpy.ndarray): ニューラルネットワークへの入力
Returns:
numpy.ndarray: ニューラルネットワークの出力
"""
# パラメーター取り出し
W1, W2 = self.params['W1'], self.params['W2']
b1, b2 = self.params['b1'], self.params['b2']
# ニューラルネットワークの計算(forward)
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
y = softmax(a2)
return y
def loss(self, x, t):
"""損失関数の値算出
Args:
x (numpy.ndarray): ニューラルネットワークへの入力
t (numpy.ndarray): 正解のラベル
Returns:
float: 損失関数の値
"""
# 推論
y = self.predict(x)
# 交差エントロピー誤差の算出
loss = cross_entropy_error(y, t)
return loss
def accuracy(self, x, t):
"""認識精度算出
Args:
x (numpy.ndarray): ニューラルネットワークへの入力
t (numpy.ndarray): 正解のラベル
Returns:
float: 認識精度
"""
y = self.predict(x)
y = np.argmax(y, axis=1)
t = np.argmax(t, axis=1)
accuracy = np.sum(y == t) / x.shape[0]
return accuracy
def numerical_gradient(self, x, t):
"""重みパラメーターに対する勾配の算出
Args:
x (numpy.ndarray): ニューラルネットワークへの入力
t (numpy.ndarray): 正解のラベル
Returns:
dictionary: 勾配を格納した辞書
"""
grads = {}
grads['W1'] = \
numerical_gradient(lambda: self.loss(x, t), self.params['W1'])
grads['b1'] = \
numerical_gradient(lambda: self.loss(x, t), self.params['b1'])
grads['W2'] = \
numerical_gradient(lambda: self.loss(x, t), self.params['W2'])
grads['b2'] = \
numerical_gradient(lambda: self.loss(x, t), self.params['b2'])
return grads
def gradient(self, x, t):
"""5章で学ぶ関数。誤差逆伝播法の実装
"""
W1, W2 = self.params['W1'], self.params['W2']
b1, b2 = self.params['b1'], self.params['b2']
grads = {}
batch_num = x.shape[0]
# forward
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
y = softmax(a2)
# backward
dy = (y - t) / batch_num
grads['W2'] = np.dot(z1.T, dy)
grads['b2'] = np.sum(dy, axis=0)
dz1 = np.dot(dy, W2.T)
da1 = sigmoid_grad(a1) * dz1
grads['W1'] = np.dot(x.T, da1)
grads['b1'] = np.sum(da1, axis=0)
return grads
ほとんど本のコードと同じです。最後のgradient
は5章で学ぶものなのですが、処理時間短縮のために必要なので(後述)、本の通り実装しています。
最後にミニバッチ学習の実装です。
# coding: utf-8
import numpy as np
import matplotlib.pylab as plt
import os
import sys
from two_layer_net import TwoLayerNet
sys.path.append(os.pardir) # パスに親ディレクトリ追加
from dataset.mnist import load_mnist
# MNISTの訓練データとテストデータ読み込み
(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, one_hot_label=True)
# ハイパーパラメーター設定
iters_num = 10000 # 更新回数
batch_size = 100 # バッチサイズ
learning_rate = 0.1 # 学習率
# 結果の記録リスト
train_loss_list = [] # 損失関数の値の推移
train_acc_list = [] # 訓練データに対する認識精度
test_acc_list = [] # テストデータに対する認識精度
train_size = x_train.shape[0] # 訓練データのサイズ
iter_per_epoch = max(train_size / batch_size, 1) # 1エポック当たりの繰り返し数
# 2層のニューラルワーク生成
network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)
# 学習開始
for i in range(iters_num):
# ミニバッチ生成
batch_mask = np.random.choice(train_size, batch_size, replace=False)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
# 勾配の計算
# grad = network.numerical_gradient(x_batch, t_batch) 遅いので誤差逆伝搬法で……
grad = network.gradient(x_batch, t_batch)
# 重みパラメーター更新
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]
# 損失関数の値算出
loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)
# 1エポックごとに認識精度算出
if i % iter_per_epoch == 0:
train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list.append(train_acc)
test_acc_list.append(test_acc)
# 経過表示
print(f"[更新数]{i: >4} [損失関数の値]{loss:.4f} "
f"[訓練データの認識精度]{train_acc:.4f} [テストデータの認識精度]{test_acc:.4f}")
# 損失関数の値の推移を描画
x = np.arange(len(train_loss_list))
plt.plot(x, train_loss_list, label='loss')
plt.xlabel("iteration")
plt.ylabel("loss")
plt.xlim(left=0)
plt.ylim(bottom=0)
plt.show()
# 訓練データとテストデータの認識精度の推移を描画
x2 = np.arange(len(train_acc_list))
plt.plot(x2, train_acc_list, label='train acc')
plt.plot(x2, test_acc_list, label='test acc', linestyle='--')
plt.xlabel("epochs")
plt.ylabel("accuracy")
plt.xlim(left=0)
plt.ylim(0, 1.0)
plt.legend(loc='lower right')
plt.show()
本のコードでは、ミニバッチ生成の際に使っているnumpy.random.choice
の引数にreplace=False
の指定がありませんが、これだと同じ要素を重複して取り出してしまうことがありそうなので指定してみました。
勾配の算出は、本来はTwoLayerNet.numerical_gradient
を使って数値微分でやるのですが、処理速度が遅くて手元の環境では 1日かかっても10,000回の更新が終わらなそうです 半日で約600回しか更新できず、10,000回の更新には8日間くらいかかりそうです。そのため本のアドバイスに従い、5章で学ぶ誤差伝搬法を実装したTwoLayerNet.gradient
を使いました。
最後に損失関数の値の推移と、訓練データ・テストデータの認識精度の推移をグラフで表示しています。
以下、実行結果です。
[更新数] 0 [損失関数の値]2.2882 [訓練データの認識精度]0.1044 [テストデータの認識精度]0.1028
[更新数] 600 [損失関数の値]0.8353 [訓練データの認識精度]0.7753 [テストデータの認識精度]0.7818
[更新数]1200 [損失関数の値]0.4573 [訓練データの認識精度]0.8744 [テストデータの認識精度]0.8778
[更新数]1800 [損失関数の値]0.4273 [訓練データの認識精度]0.8972 [テストデータの認識精度]0.9010
[更新数]2400 [損失関数の値]0.3654 [訓練データの認識精度]0.9076 [テストデータの認識精度]0.9098
[更新数]3000 [損失関数の値]0.2816 [訓練データの認識精度]0.9142 [テストデータの認識精度]0.9146
[更新数]3600 [損失関数の値]0.3238 [訓練データの認識精度]0.9195 [テストデータの認識精度]0.9218
[更新数]4200 [損失関数の値]0.2017 [訓練データの認識精度]0.9231 [テストデータの認識精度]0.9253
[更新数]4800 [損失関数の値]0.1910 [訓練データの認識精度]0.9266 [テストデータの認識精度]0.9289
[更新数]5400 [損失関数の値]0.1528 [訓練データの認識精度]0.9306 [テストデータの認識精度]0.9320
[更新数]6000 [損失関数の値]0.1827 [訓練データの認識精度]0.9338 [テストデータの認識精度]0.9347
[更新数]6600 [損失関数の値]0.1208 [訓練データの認識精度]0.9362 [テストデータの認識精度]0.9375
[更新数]7200 [損失関数の値]0.1665 [訓練データの認識精度]0.9391 [テストデータの認識精度]0.9377
[更新数]7800 [損失関数の値]0.1787 [訓練データの認識精度]0.9409 [テストデータの認識精度]0.9413
[更新数]8400 [損失関数の値]0.1564 [訓練データの認識精度]0.9431 [テストデータの認識精度]0.9429
[更新数]9000 [損失関数の値]0.2361 [訓練データの認識精度]0.9449 [テストデータの認識精度]0.9437
[更新数]9600 [損失関数の値]0.2183 [訓練データの認識精度]0.9456 [テストデータの認識精度]0.9448
結果を見ると、すでに認識精度が94.5%くらいになっていて、3章で用意されていた学習済みパラメーターの認識精度を超えていました。
4.6 まとめ
4章は本として読むだけなら良いのかも知れませんが、実装しながら進めると結構大変でした。
(ソフトマックス関数と数値微分の関数を多次元配列に対応させる部分の解説は欲しかったなぁ……)
この章は以上です。誤りなどありましたら、ご指摘いただけますとうれしいです。
(このメモの他の章へ:1章 / 2章 / 3章 / 4章 / 5章 / 6章 / 7章 / 8章 / まとめ)