ギークなエンジニアを目指す男

機械学習系の知識を蓄えようとするブログ

ゼロから作るDeepLearning 5章を学ぶ 〜誤差逆伝播法〜

本日から5章に入りました。
余談ですが、現在仕事の関係で、電車で1時間ほどかかる場所へよく出張に行っています。
普段の通勤は電車に乗る時間が15分ほどなため、ゆっくり読書などはできないのですが(もちろん、時間だけが原因でなく、混雑しているのも原因の一つです)、1時間も電車に揺られていると、読書も捗ります。

さて、今日から誤差逆伝播法です。

順伝播と逆伝播

逆伝播に関しては、こちらの記事がとても勉強になりました。

誤差逆伝播法のノート - Qiita

読んで字のごとく、順方向とは逆に向かっての伝播を考えるアルゴリズムが逆伝播です。

連鎖律

逆方向の伝播では「局所的な微分」を順方向とは逆方向に伝達します。
この「局所的な微分」を伝達する原理を連鎖律と呼びます。

連鎖律とは、合成関数の微分についての性質であり、下記のように定義されます。

ある関数が合成関数で表される場合、その合成関数の微分は、合成関数を構成するそれぞれの関数の微分の積によって表すことができる

リンゴの買い物による逆伝播

この書籍では、リンゴの買い物を例に逆伝播の解説を行なっています。

Q. 太郎くんはスーパーで1個100円のリンゴを2個買いました。消費税が10%適用された場合、支払金額はいくらか

上記に問いに関する答えは、おそらく暗算で解けてしまうと思います。

では、リンゴの値段が値上がりした場合、最終的な支払金額にどのように影響するか知りたいとします。
これは「リンゴの値段に関する支払金額の微分」で求めることができます。(リンゴの値段をx、支払金額をLとしたとき、Lをxで偏微分する)
この微分の値は、リンゴの値段が少しだけ値上がりした場合に、支払金額がどの程度増加するか、ということを表しています。

下記図形で、赤い矢印が逆伝播のイメージです。
この場合、リンゴが1円値上がりしたら、最終的な支払金額は2.2円増える、ということを表しています。
(個数、消費税に関しても同様に偏微分して求めることが可能です)

f:id:taxa_program:20180606214834p:plain

加算ノードの逆伝播

入力信号をそのまま次のノードに出力するだけの、とてもシンプルなもの。

# 加算レイヤ
class AddLayer:
    # コンストラクタ
    def __init__(self):
        pass # 何も行わないの意

    # 順伝播
    def forward(self, x, y):
        # 単純な足し算
        out = x + y
        return out

    # 逆伝播
    def backward(self, dout):
        dx = dout * 1
        dy = dout * 1
        return dx, dy

乗算ノードの逆伝播

上流の値に、順伝播の際の入力信号を"ひっくり返した値"を乗算して下流へ流します。
したがって、乗算の逆伝播では、順伝播のときの入力信号の値が必要になる。

# 乗算レイヤ
class MulLayer:
    # コンストラクタ
    def __init__(self):
        self.x = None
        self.y = None

    # 順伝播
    def forward(self, x, y):
        self.x = x
        self.y = y
        # 順伝播は単純に乗算するだけ
        out = x * y

    # 逆伝播
    def backward(self, dout):
        # xとyをひっくり返す
        dx = dout * self.y
        dy = dout * self.x

        return dx, dy

活性化関数レイヤの実装

下記活性化関数のレイヤも実装しました。
ソースコードは例のごとく、最後に掲載しようと思います。

  • ReLUレイヤ
  • Sigmoidレイヤ
  • Affineレイヤ
  • Softmax-with-Lossレイヤ(ソフトマックス + 交差エントロピー)

そもそも誤差逆伝播法はどこで使用するのか

今はニューラルネットワークの勉強をしていました。(←これ重要なので忘れちゃダメです)
前の記事にも掲載しましたが、ニューラルネットワークの全体像として、

  • STEP1(ミニバッチ)
    訓練データの中からランダムに一部のデータを選び出す。

  • STEP2(勾配の算出)
    各重みパラメータに関する損失関数の勾配を求める。

  • STEP3(パラメータの更新)
    重みパラメータを勾配方向に微小量だけ更新する。

  • STEP4(繰り返す)
    STEP1 ~ STEP3を任意の回数繰り返す。

でした。
誤差逆伝播法が登場するのは、STEP2の勾配の算出部分です。
前の記事では、この勾配を求めるために数値微分を利用していましたが、数値微分は簡単に実装できる反面、計算処理に時間がかかりました。
誤差逆伝播法を使用すれば、高速に効率良く勾配を求めることができます。

誤差逆伝播法に対応したニューラルネットワークの構築

2層のニューラルネットワークを構築してみます。

# coding: utf-8
# ---------------------------------------
# 誤差逆伝播法によるニューラルネットワークの構築
# ---------------------------------------

import os, sys
sys.path.append(os.pardir)
import numpy as np
from common.layers import *
from common.gradient import numerical_gradient
from collections import OrderedDict

class TwoLayerNet:

    # コンストラクタ
    # 引数は(入力層のニューロン数, 隠れ層のニューロン数, 出力層のニューロン数)
    def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01):
        # 重みの初期化
        self.params = {} # 辞書型で初期化
        self.params['W1'] = weight_init_std * np.random.randn(input_size, hidden_size) # ガウス分布で 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)

        # レイヤの生成
        # 順番付きディクショナリで各レイヤを保持する
        self.layers = OrderedDict()
        self.layers['Affine1'] = Affine(self.params['W1'], self.params['b1']) # 順伝播で行う行列の積(アフィン変換)
        self.layers['Relu1'] = Relu()
        self.layers['Affine2'] = Affine(self.params['W2'], self.params['b2']) # 順伝播で行う行列の積(アフィン変換)
        # ニューラルネットワークの最後のレイヤは活性化関数にソフトマックス関数、損失関数に交差エントロピーを使用する
        self.lastLayer = SoftmaxWithLoss()

    # 予測
    # xは画像データ
    def predict(self, x):
        for layer in self.layers.values():
            x = layer.forward(x)

        return x

    # 損失関数の値を求める
    # x:入力データ, t:教師データ
    def loss(self, x, t):
        # 予測値yハット
        y = self.predict(x)
        # 最後のレイヤをfoward処理し、最終出力をリターン
        return self.lastLayer.forward(y, t)

    # 認識精度を求める
    def accuracy(self, x, t):
        # 予測値yハット
        y = self.predict(x)

        # 配列の中で最大の要素のインデックスを取得
        y = np.argmax(y, axis=1)
        # t = np.argmax(t, axis=1)
        if t.ndim != 1 : t = np.argmax(t, axis=1)

        # 認識精度の計算
        # 予測値 = 正解値の合計 / 全データ数
        accuracy = np.sum(y == t) / float(x.shape[0])
        return accuracy

    # 勾配を求める
    # x:入力データ, t:教師データ
    def numerical_gradient(self, x, t):
        loss_W = lambda W: self.loss(x, t)

        # 勾配を保持
        grads = {}
        grads['W1'] = numerical_gradient(loss_W, self.params['W1']) # 1層目の重みの勾配
        grads['b1'] = numerical_gradient(loss_W, self.params['b1']) # 1層目のバイアスの勾配
        grads['W2'] = numerical_gradient(loss_W, self.params['W2']) # 2層目の重みの勾配
        grads['b2'] = numerical_gradient(loss_W, self.params['b2']) # 2層目のバイアスの勾配

        return grads

    # 勾配を求める(高速版)
    def gradient(self, x, t):
        # foward処理(順伝播)
        self.loss(x, t)

        # backward処理(逆伝播)
        dout = 1
        dout = self.lastLayer.backward(dout)
        # レイヤを逆順に呼び出す
        layers = list(self.layers.values())
        layers.reverse()
        for layer in layers:
            dout = layer.backward(dout)

        # 勾配の保持
        grads = {}
        grads['W1'], grads['b1'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
        grads['W2'], grads['b2'] = self.layers['Affine2'].dW, self.layers['Affine2'].db

        return grads

上記のようにニューラルネットワークの構成要素をレイヤとして実装することで、簡単に構築することができました。
レイヤとしてモジュール化することで、5層、10層、20層のニューラルネットワークを作りたいときは、単に必要なレイヤを追加するだけで作ることができます。
あとは、レイヤの中で実装している順伝播と逆伝播によって、認識処理や学習に必要な勾配を正しく求めることができます。

さて、勾配を求める方法としては、

  • 数値微分をする方法
  • 解析的に数式をといて求める方法(誤差逆伝播法)

の2つがありました。誤差逆伝播法は、大量のパラメータが存在していても、効率的に計算することができました。
では数値微分は何の役にも立たないのか・・・・と言われると、そんなことはありません。
この数値微分、誤差逆伝播法の実装の正しさを確認するために一役買ってくれます。

誤差逆伝播法の勾配確認

数値微分の利点は、実装が容易であるということです。そのため、数値微分の実装はミスが起きにくく、一方で誤差逆伝播法の実装は複雑なため、ミスが起きやすいです。
そこで、数値微分の結果と誤差逆伝播法の結果を比較して、誤差逆伝播法の実装の正しさを確認することができます。
これを勾配確認と呼びます。

実際に実装してみます。

# coding: uft-8
# ---------------------------------------
# 数値微分を使って、誤差逆伝播法の勾配確認を行う
# ---------------------------------------

import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist
from mine_two_layer_net import TwoLayerNet

# mnistデータの読み込み
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True)

network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)

x_batch = x_train[:3]
t_batch = t_train[:3]

print(x_batch)
# >>>
# [[0. 0. 0. ... 0. 0. 0.]
# [0. 0. 0. ... 0. 0. 0.]
# [0. 0. 0. ... 0. 0. 0.]]
print(t_batch)
# >>>
# [[0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
# [1. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 1. 0. 0. 0. 0. 0.]]

# 数値微分の勾配
grad_numerical = network.numerical_gradient(x_batch, t_batch)

# 誤差逆伝播法の勾配
grad_backprop = network.gradient(x_batch, t_batch)

# 各重みの絶対誤差の平均を求める(勾配確認)
for key in grad_numerical.keys():
    diff = np.average( np.abs(grad_backprop[key] - grad_numerical[key]) )
    print(key + ":" + str(diff))

最終的な出力は下記のようになります。

W1:1.6298545995118232e-10
b1:8.764278717102538e-10
W2:6.735583098657627e-08
b2:1.3535359504601718e-07

見てわかるように、それぞれで求めた勾配の差はかなり小さいことがわかると思います。
よって、誤差逆伝播法の実装に誤りがないことがわかります。
もし、誤差逆伝播法の実装が誤っていれば、ここの誤差の数値が大きくなるはずです。

誤差逆伝播法を使用した学習

最後に学習を行なってみます。

# condig: utf-8
# ------------------------------
# 誤差逆伝播法を使用した学習
# ------------------------------
import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist
from mine_two_layer_net import TwoLayerNet
import matplotlib.pyplot as plt

# データの読み込み
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True)

# ネットワークの構築
network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)

iters_num = 10000 # 繰り返し回数
train_size = x_train.shape[0] # 60000
batch_size = 100 # バッチサイズ
learning_rate = 0.1 # 学習率

# 正答率の表示頻度
iter_per_epoch = max(train_size / batch_size, 1) # 600

train_loss_list = []
train_acc_list = []
test_acc_list = []

for i in range(iters_num):
    # 0~(train_size-1)の中から重複ありランダムで100個を抽出
    batch_mask = np.random.choice(train_size, batch_size)
    x_batch = x_train[batch_mask]
    t_batch = t_train[batch_mask]

    # 誤差逆伝播法によって勾配を求める
    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)

    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(train_acc, test_acc)

# グラフの描画
markers = {'train': 'o', 'test': 's'}
x = np.arange(len(train_acc_list))
plt.plot(x, train_acc_list, label='train acc')
plt.plot(x, test_acc_list, label='test acc', linestyle='--')
plt.xlabel("epochs")
plt.ylabel("accuracy")
plt.ylim(0, 1.0)
plt.legend(loc='lower right')
plt.show()

正答率はこのようになります。
学習を繰り返す度に、正答率が向上していることが分かります。

0.11025 0.1093
0.7984166666666667 0.8032
0.8760833333333333 0.878
0.8978333333333334 0.8986
0.9070666666666667 0.9087
0.9143166666666667 0.9175
0.9185666666666666 0.9194
0.9233333333333333 0.925
0.92795 0.9292
0.9313333333333333 0.9326
0.9333 0.9348
0.9364166666666667 0.9348
0.93855 0.9375
0.9404333333333333 0.9396
0.9427333333333333 0.9407
0.9449166666666666 0.942
0.9460833333333334 0.9438

グラフはこのようになります。

f:id:taxa_program:20180607223545p:plain

トレーニングデータ、テストデータで正答率が同じくらいになっていることが分かります。
よって、過学習も起きていないため、学習が成功したと言えるでしょう。

5章を学んでみて

誤差逆伝播法は、初めは「むずかしい・・・」というのが率直な感想でした。
(数式いっぱいでてくるし、新しい単語もたくさん出てくるし・・・)

しかし、良い時代になったもので、WEBで検索すれば有識者の方がとても分かりやすく解説してくれています。この時代に生まれてよかった。笑

しかし、誤差逆伝播法は再度勉強したいと思うほど、内容が濃く、ニューラルネットワークには重要なアルゴリズムということは分かりました。

引き続き頑張ります。