からっぽのしょこ

読んだら書く!書いたら読む!読書読読書読書♪同じ事は二度調べ(たく)ない

5.7.2-3:誤差逆伝播法に対応したニューラルネットワークの実装【ゼロつく1のノート(実装)】

はじめに

 「プログラミング」学習初手『ゼロから作るDeep Learning』民のための実装攻略ノートです。『ゼロつく1』学習の補助となるように適宜解説を加えています。本と一緒に読んでください。

 関数やクラスとして実装される処理の塊を細かく分解して、1つずつ処理を確認しながらゆっくりと組んでいきます。

 この記事は、5.7.2項「誤差逆伝播法に対応したニューラルネットワークの実装」と5.7.3項「誤差逆伝播法の勾配確認」の内容になります。誤差逆伝播法を用いた2層のニューラルネットワークのをPythonで実装します。また数値微分により求めた勾配と誤差逆伝播法により求めた勾配を比較します。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

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

 5章で実装してきた各レイヤを組み合わせて、2層のニューラルネットワークを実装します。

 基本的な実装方法は4.5.1項と同じです。「4.5.1:2層ニューラルネットワークのクラス【ゼロつく1のノート(実装)】 - からっぽのしょこ」こちらも参考にしてください。主な違いの1つが、OrdereDictライブラリの順番付き辞書を使うところです。まずはこの機能と使い方を確認しましょう。

 ライブラリを読み込み、OrderedDict()で順番付きディクショナリ変数を作成します。

# 利用するライブラリを読み込む
import numpy as np
from collections import OrderedDict


# 順番付きディクショナリ変数を作成
dic_123 = OrderedDict()

# キーと変数を指定
dic_123['One'] = "構って欲しくて"
dic_123['Two'] = "触って欲しくて"
dic_123['Three'] = "笑って欲しくて"

# 中身を確認
print(dic_123.keys())
print(dic_123.values())
odict_keys(['One', 'Two', 'Three'])
odict_values(['構って欲しくて', '触って欲しくて', '笑って欲しくて'])

 通常のディクショナリ変数と同様に、キーと値をセットで指定します。指定した順番にデータが並びます。

 4.5節ではキーを直接手で打つことで順番にアクセスしていましたが、辞書の作成時に指定した並びでデータを取り出して、処理することができます。

# 順番にデータを取り出す
for key in dic_123.keys():
    print(key)
    print(dic_123[key])
One
構って欲しくて
Two
触って欲しくて
Three
笑って欲しくて

 これは順伝播の処理で行う操作になります。

 逆伝播では逆順にアクセスする必要があります。

 データを取り出してリスト型のオブジェクトとして保存します。それを.reverse()で逆順に並べ替えます。

# キーを取り出してリスト型に変換
key_list = list(dic_123.keys())

# 要素を逆順に変換
key_list.reverse()
print(key_list)
['Three', 'Two', 'One']


 先ほどと同様に、格納しているデータにアクセスします。

# 逆順でデータを取り出す
for key in key_list:
    print(key)
    print(dic_123[key])
Three
笑って欲しくて
Two
触って欲しくて
One
構って欲しくて

 (比較するために通常のディクショナリ変数でも試してみましたが、同じことをできるっぽい(?)なんでも最近通常版でも順番を覚えるようになったんだとか(?))

 実装では、順番付きディクショナリ変数の値として、各レイヤのインスタンスを格納します。またそのディクショナリの変数名をlayersとします。

 そして順伝播と逆伝播の処理では、キーkeyではなく、値value(格納しているインスタンス)を順番に取り出して、そのインスタンスの順伝播メソッド.foward()・逆伝播メソッド.backward()を使用して計算します。

 ただし最終レイヤの活性化レイヤと損失関数レイヤは、推論(画像認識認識)や精度測定では(値を正規化しなくていいので)必要ありません。そこでSoftmaxWithLoss()のインスタンスは、lastLayerとして分けておきます。

# 2層のニューラルネットワークを定義
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)
        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()
        
    # 推論メソッドを定義
    def predict(self, x):
        # 各レイヤを順番に処理
        for layer in self.layers.values():
            # 順伝播メソッドによる計算
            x = layer.forward(x)
        
        return x
    
    # 損失関数メソッドを定義
    def loss(self, x, t):
        # ニューラルネットワークの処理
        y = self.predict(x)
        return self.lastLayer.forward(y, t)
    
    # 認識精度メソッドを定義
    def accuracy(self, x, t):
        # 推論処理(ソフトマックス関数による正規化なし)
        y = self.predict(x)
        
        # 推論結果を抽出(最大値のインデックスを取得)
        if y.ndim == 2: # 2次元配列のとき(バッチデータのとき)
            y = np.argmax(y, axis=1)
            t = np.argmax(t, axis=1)
        elif y.ndim == 1: # 1次元配列のとき(データが1つのとき)
            y = np.argmax(y)
            t = np.argmax(t)
        
        # 正解率を計算
        accuracy = np.sum(y == t) / float(x.shape[0])
        return accuracy
    
    # 勾配(数値微分)メソッドを定義
    def numerical_gradient(self, x, t):
        # 偏微分用に関数を定義
        loss_W = lambda W: self.loss(x, t)
        
        # パラメータごとに勾配を計算
        grads = {} # 初期化
        grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
        grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
        grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
        grads['b2'] = numerical_gradient(loss_W, self.params['b2'])
        
        return grads
    
    # 勾配メソッドを定義
    def gradient(self, x, t):
        # 損失関数を計算(結果はインスタンス変数に保存される)
        self.loss(x, t)
        
        # 誤差逆伝播法による微分を計算
        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'] = self.layers['Affine1'].dW
        grads['b1'] = self.layers['Affine1'].db
        grads['W2'] = self.layers['Affine2'].dW
        grads['b2'] = self.layers['Affine2'].db
        
        return grads

 4.5.1項での実装と(概ね)同じものについては簡単な説明に留めます。詳しくは4.5.1項の解説を参考にしてください。

 各パラメータと各パラメータごとの勾配は、ディクショナリ変数として値を格納し、それぞれインスタンス変数paramsgradsとして保持します。各パラメータの形状は、インスタンス作成時に引数に指定します。また重みの初期値は標準正規分布に従いランダムに生成し、バイアスの初期値は0とします。

 交差エントロピー誤差の計算メソッド.loss()は、ニューラルネットワークの処理を行い、最終層でソフトマックス関数による活性化(正規化)を行い、交差エントロピー誤差の計算を行うという処理の内容は同じです。ただしこの項の実装方法では、推論(ニューラルネットワーク)メソッド.predict()においてソフトマックス関数の処理は行わず、ソフトマックス関数と損失関数の処理をまとめたクラスSoftmaxWithLossのインスタンスlastLayerの順伝播メソッド.predict()によって処理する点が異なります。

 認識精度の計算メソッド.accuracy()では、データ数が1の場合にも対応できるように(本の内容とも)一部変更しています。変数の次元数(.ndimの値)によって最大値を検索する軸(引数axis)の指定が異なるので、if文で条件分岐します。

 勾配の計算には、数値微分を用いる方法.namerical_gradient()と誤差逆伝播法を用いる方法.gradient()を実装します。数値微分による勾配の計算メソッドでは、4.4.2項「4.4.2:ニューラルネットワークに対する勾配【ゼロつく1のノート(実装)】 - からっぽのしょこ」で実装したnumerical_gradient()を使います。あるいは本のようにマスターデータの「common」フォルダの「gradient.py」ファイルからインポートしてください。

 また5章で実装した各レイヤの定義を再実行しないのであれば、同じく「common」フォルダの「layers.py」ファイルから読み込む必要があります。この方法も4.5.1項の記事で解説しています。が、おそらくすぐ次の処理と組み合わせれば行えます。

 これでニューラルネットワークの実装が完了です。では学習!の前に、誤差逆伝播法による勾配の計算が正しく組めているのかを確認します。

5.7.3 誤差逆伝播法の勾配確認

 数値微分で求めた勾配(の値)と誤差逆伝播法により解析的に求めた勾配を比較して、正しく実装できているのかを確認します。これを勾配確認と言います。

 まずはそのための画像データを読み込みます。このデータセットは次の学習にも利用します。MNISTデータセットの読み込みについては「MNISTデータセットの読み込み【ゼロつく1のノート(Python)】 - からっぽのしょこ」で詳しく解説しています。

# データ読み込み用ライブラリを読み込む
import sys, os

# ファイルパスを指定
sys.path.append('C:\\Users\\「ユーザー名」\\Documents\\・・・\\deep-learning-from-scratch-master')

# MNISTデータセット読み込み関数を読み込む
from dataset.mnist import load_mnist

# 画像データを読み込む
(x_train, t_train), (x_test, t_test) = load_mnist(normalize = True, one_hot_label=True)
print(x_train.shape)
print(t_train.shape)
(60000, 784)
(60000, 10)


 読み込んだデータセットの一部を取り出します。

# データの一部を抽出
x_batch = x_train[:3]
t_batch = t_train[:3]
print(x_batch)
print(x_batch.shape)
print(t_batch)
print(t_batch.shape)
[[0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]]
(3, 784)
[[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.]]
(3, 10)

 この例では3枚分のデータを使うことにします。

 インスタンスを作成して、数値微分による勾配計算メソッドnumerical_gradient()と誤差逆伝播法による勾配計算メソッドgradient()で勾配を求めます。

# インスタンスを作成
network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)

# 勾配を計算
grad_numerical = network.numerical_gradient(x_batch, t_batch) # 数値微分
grad_backprop = network.gradient(x_batch, t_batch) # 誤差逆伝播法
print(grad_numerical.keys())
print(grad_backprop.keys())
dict_keys(['W1', 'b1', 'W2', 'b2'])
dict_keys(['W1', 'b1', 'W2', 'b2'])

 パラメータごとの勾配がディクショナリ変数として格納されています。

 試しに第1層のバイアスに関する勾配を比較してみましょう。

print(np.round(grad_numerical['b1'] - grad_backprop['b1'], 2))
print(np.round(grad_numerical['b1'] - grad_backprop['b1'], 10))
[-0.  0. -0. -0. -0.  0. -0.  0.  0.  0. -0. -0.  0.  0.  0.  0.  0.  0.
  0.  0.  0. -0. -0. -0. -0. -0.  0. -0. -0.  0. -0.  0.  0. -0. -0. -0.
  0.  0.  0.  0.  0.  0. -0.  0. -0. -0. -0.  0. -0.  0.]
[-7.0e-10  5.0e-10 -4.0e-09 -9.0e-10 -3.4e-09  0.0e+00 -2.8e-09  6.1e-09
  0.0e+00  0.0e+00 -1.2e-09 -3.0e-09  0.0e+00  9.0e-10  0.0e+00  5.9e-09
  0.0e+00  9.4e-09  1.0e-09  2.7e-09  0.0e+00 -7.0e-10 -4.0e-10 -5.0e-10
 -2.9e-09 -2.0e-09  1.4e-09 -6.8e-09 -5.7e-09  1.5e-09 -1.6e-09  0.0e+00
  2.3e-09 -1.2e-09 -3.3e-09 -2.5e-09  0.0e+00  3.5e-09  9.0e-10  0.0e+00
  8.6e-09  0.0e+00 -4.1e-09  5.9e-09 -7.0e-10 -1.6e-09 -3.3e-09  2.9e-09
 -2.3e-09  5.1e-09]

 2つの方法による勾配の差がほぼ0です(プログラム上の誤差もありますが、数値微分はそもそも誤差を必ず含む求め方なので、0にはなりません)。丸め込みの桁数を変更すると、0ではないことが確認できます。

 全てのパラメータで誤差を確認しましょう。各行の誤差はプラスにもマイナスにもなるため、このまま和をとると相殺されてしまします。なので、np.abs()で絶対値をとってからnp.average()で平均を求めます。

# 全てのパラメータで誤差を計算
for key in grad_numerical.keys():
    diff = np.average(np.abs(grad_numerical[key] - grad_backprop[key]))
    print(key + ":" + str(np.round(diff, 2)))
W1:0.0
b1:0.0
W2:0.0
b2:0.0

 ディクショナリ変数として各パラメータの値を格納しているので、対応するキーを指定することで、必要なパラメータにアクセスできるのでした。

 誤差逆伝播法による勾配計算に問題がないことを確認できたので、次項では勾配降下法による(4章の計算時間の問題を回避した)学習を行います。

参考文献

  • 斎藤康毅『ゼロから作るDeep Learning』オライリー・ジャパン,2016年.

おわりに

 パーツを作って、組み立てたのなら、次は実践ですね!ここで止めるとクラスを再実行したりと面倒なので、このままラストまでやっちゃいましょう。

【次節の内容】

https://www.anarchive-beta.com/entry/2020/08/08/180000www.anarchive-beta.com


 今日の挿入歌♪♪

 8年前の曲ですが、現メンバーも4人います。当時は全然知りませんでした。

 そ・れ・と、2020年8月7日はハロプログループ「BEYOOOOONDS」メジャーデビュー1周年!おめでとおおおおおございます!!!!!!!!!!!