からっぽのしょこ

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

4.4.2:ニューラルネットワークに対する勾配【ゼロつく1のノート(実装)】

はじめに

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

 関数やクラスとして実装される処理の塊を細かく分解して、1つずつ実行結果を見ながら処理の意図を確認していきます。

 この記事は、4.4.2項「ニューラルネットワークに対する勾配」の内容です。練習として1層のニューラルネットワークのクラスを実装します。また、その勾配を求めます。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

4.4.2 ニューラルネットワークに対する勾配

 この項では、1層の簡単なニューラルネットワークをクラスとして実装します。実装するクラスは、「重みの初期化」「ニューラルネットワークの計算(推論処理)」「損失の計算」の3つの機能(メソッド)を持ちます。また、ニューラルネットワークのクラスを使って、重みの勾配も求めます。勾配降下法によるパラメータの学習は行いません。クラスについては【Pythonノート】の1章を参照してください。
 次節で、これまでの総まとめのような内容を行います。この項は、その前の確認・準備運動のような内容です。

 利用するライブラリを読み込みます。

# 4.4.2項で利用するライブラリ
import numpy as np


 この節では、「3.5:ソフトマックス関数の実装【ゼロつく1のノート(実装)】 - からっぽのしょこ」の(バッチ対応版)ソフトマックス関数softmax()、「4.2.2-4:交差エントロピー誤差の実装【ゼロつく1のノート(実装)】 - からっぽのしょこ」の(バッチ対応版)交差エントロピー誤差cross_entropy_error()、「4.4.0:勾配【ゼロつく1のノート(数学)】 - からっぽのしょこ」の(バッチ対応版)勾配を計算する関数numerical_gradient()を利用します。そのため、関数定義を再度実行しておく必要があります。
 または次の方法で、「deep-learning-from-scratch-master」フォルダ内の「common」フォルダの「functions.py」ファイルから実装済みの関数を読み込むこともできます。ファイルの読み込みについては「3.6.1:MNISTデータセットの読み込み【ゼロつく1のノート(Python)】 - からっぽのしょこ」を参照してください。

# 読み込み用の設定
import sys
sys.path.append('../deep-learning-from-scratch-master') # ファイルパスを指定

# 実装済みの関数を読み込み
from common.functions import softmax # ソフトマックス関数:3.5節
from common.functions import cross_entropy_error # 交差エントロピー誤差:4.2.4項
from common.gradient import numerical_gradient # 勾配の計算:4.4節

 ただし、どの関数も本での実装とは多少異なります。バッチデータ(多次元配列)を処理できるようになっています。また、本では紹介していない機能を使って洗練された実装になっています。

・数式の確認

 まずは、1層のニューラルネットワークで行う計算と数式上の表記を確認します。

 ここでは簡単な例として、ニューラルネットワークの入力$\mathbf{x}$、重み$\mathbf{W}$、バイアス$\mathbf{b}$を次の形状とします。Pythonのインデックスに合わせて添字を0から割り当てています。

$$ \mathbf{x} = \begin{pmatrix} x_0 & x_1 \end{pmatrix} ,\ \mathbf{W} = \begin{pmatrix} w_{0,0} & w_{0,1} & w_{0,2} \\ w_{1,0} & w_{1,1} & w_{1,2} \end{pmatrix} ,\ \mathbf{b} = \begin{pmatrix} b_0 & b_1 & b_2 \end{pmatrix} $$

 1つの入力データ($N = 1$)を3クラス($K = 3$)に分類するイメージです。具体的な例に言い換えると、0から2の数字を1つ入力して認識するイメージです。

 入力$\mathbf{x}$と重み$\mathbf{W}$の行列の積にバイアス$\mathbf{b}$を加えて、重み付き和$\mathbf{a}$を計算します(3.3節)。

$$ \mathbf{a} = \mathbf{x} \mathbf{W} + \mathbf{b} $$

 各要素に注目すると、それぞれ次の計算をしています。

$$ \begin{aligned} a_k &= \sum_{h=0}^1 x_h w_{h,k} + b_k \\ &= x_0 w_{0,k} + x_1 w_{1,k} + b_k \end{aligned} $$

 この例だと、計算結果は次の形状になります。

$$ \mathbf{a} = \begin{pmatrix} a_0 & a_1 & a_2 \end{pmatrix} $$

 分類問題(手書き数字認識)では、ソフトマックス関数により重み付き和$\mathbf{a}$を活性化して(3.5節)、ニューラルネットワークの出力$\mathbf{y}$とします。

$$ \mathbf{y} = \mathrm{softmax}(\mathbf{a}) $$

 出力$\mathbf{y}$は重み付き和$\mathbf{a}$と同じ形状です。活性化関数は形状に影響しません。

$$ \mathbf{y} = \begin{pmatrix} y_0 & y_1 & y_2 \end{pmatrix} ,\ \mathbf{t} = \begin{pmatrix} t_0 & t_1 & t_2 \end{pmatrix} $$

 $\mathbf{y}$と同じ形状で正解ラベルが1・それ以外が0の教師データ$\mathbf{t}$を用いて、交差エントロピー誤差を計算して(4.2節)、損失$L$とします。

$$ \begin{aligned} L &= - \sum_{k=0}^2 t_k \log y_k \\ &= - t_0 \log y_0 - t_1 \log y_1 - t_2 \log y_2 \end{aligned} $$

 損失$L$は、スカラで0以上の値になります。値が大きいほど誤差が大きい(データへの当てはまりが悪い)ことを表します。

 続いて、損失を求めるまでの計算を1つの関数とみなして、重みの勾配$\frac{\partial L}{\partial \mathbf{W}}$とバイアスの勾配$\frac{\partial L}{\partial \mathbf{b}}$を求めます。

$$ \frac{\partial L}{\partial \mathbf{W}} = \begin{pmatrix} \frac{\partial L}{\partial w_{0,0}} & \frac{\partial L}{\partial w_{0,1}} & \frac{\partial L}{\partial w_{0,2}} \\ \frac{\partial L}{\partial w_{1,0}} & \frac{\partial L}{\partial w_{1,1}} & \frac{\partial L}{\partial w_{0,2}} \end{pmatrix} ,\ \frac{\partial L}{\partial \mathbf{b}} = \begin{pmatrix} \frac{\partial L}{\partial b_0} & \frac{\partial L}{\partial b_1} & \frac{\partial L}{\partial b_2} \end{pmatrix} \tag{4.8} $$

 各パラメータ(重みとバイアス)の勾配$\frac{\partial L}{\partial \mathbf{W}},\ \frac{\partial L}{\partial \mathbf{b}}$は、元のパラメータ$\mathbf{W},\ \mathbf{b}$と同じ形状になります。各要素は、それぞれパラメータの対応する要素の偏微分です(4.3.3項)。

 この項では行いませんが、各パラメータの勾配$\frac{\partial L}{\partial \mathbf{W}},\ \frac{\partial L}{\partial \mathbf{b}}$を用いて、勾配降下法によりパラメータ$\mathbf{W},\ \mathbf{b}$を更新します(4.4.1項)。更新後のパラメータを$\mathbf{W}^{(\mathrm{new})},\ \mathbf{b}^{(\mathrm{new})}$とすると、更新式は次の式で表せます。

$$ \begin{aligned} \mathbf{W}^{(\mathrm{new})} &= \mathbf{W} - \eta \frac{\partial L}{\partial \mathbf{W}} \\ \mathbf{b}^{(\mathrm{new})} &= \mathbf{b} - \eta \frac{\partial L}{\partial \mathbf{b}} \end{aligned} $$

 各要素に注目すると、それぞれ次の式で計算しています。

$$ \begin{aligned} w_{h,k}^{(\mathrm{new})} &= w_{h,k} - \eta \frac{\partial L}{\partial w_{h,k}} \\ b_k^{(\mathrm{new})} &= b_k - \eta \frac{\partial L}{\partial b_k} \end{aligned} $$

 学習率$\eta$が適切に設定されていれば、パラメータ$\mathbf{W}^{(\mathrm{new})},\ \mathbf{b}^{(\mathrm{new})}$を使って推論を行うことで、更新前のパラメータ$\mathbf{W},\ \mathbf{b}$の時よりも損失$L$が下がります。

 以上がニューラルネットワークの学習における計算の流れです。

・処理の確認

 次に、1層のニューラルネットワークで行う処理を確認します。ただしこの項はあくまで練習なので、式(4.8)の重み$\mathbf{W}$の形状($2 \times 3$の行列)の場合に限定します。また、バイアス$\mathbf{b}$を省略します。

・入力データの用意

 入力データ$\mathbf{x}$と教師データ$\mathbf{t}$を作成します。

# (仮の)入力データを作成
x = np.array([0.6, 0.9])
print(x)

# (仮の)教師データを作成
t = np.array([0, 0, 1])
print(t)
[0.6 0.9]
[0 0 1]

 xは手書き数字の画像データ、tは画像に書かれている数字を示すラベルデータの代わりです。

・パラメータの初期化

 2行3列の重み$\mathbf{W}$の初期値をランダムに設定します。この資料では、「初期値を設定する」ことや「空のオブジェクトを作成する」ことを「初期化する」とも表現します。

# 標準正規分布に従う乱数により重み初期化
W = np.random.randn(2, 3)
print(W)
print(W.shape)
[[-0.80360559  1.11407118 -0.64148153]
 [ 1.05127229 -1.58416335 -0.02139028]]
(2, 3)

 np.rondom.randn(行数, 列数)を使って、平均0・標準偏差1のガウス分布(標準正規分布)に従う乱数を生成します。重みWの行数は入力データの要素数、列数は教師データの要素数(クラス数)に対応しています。重みの初期値については6.2節を参照してください。

 重みの初期化を行う関数として、この処理を定義します。ここで作成する関数は、クラスの実装時のメソッドに対応しています。

# 重みを初期化する関数を定義
def init_W():
    # 重み初期化
    W = np.random.randn(2, 3)
    return W

 この例は練習なので、重みの形状は$2 \times 3$の2次元配列に限定します。

 作成した関数を使って、重みを初期化します。

# 重みを初期化
W = init_W()
print(W)
print(W.shape)
[[-0.08030741  0.37134897 -0.8606653 ]
 [ 0.48908233  1.56618134  0.34363295]]
(2, 3)

 ランダムな処理を行うので、実行する度に値が変わります。

・ニューラルネットワークの計算

 入力$\mathbf{x}$と重み$\mathbf{W}$を用いて、重み付き和$\mathbf{a}$を計算します。ただしこの例では、バイアス$\mathbf{b}$を省略します。

# 重み付き和を計算
a = np.dot(x, W)
print(a)
[ 0.39198965  1.63237259 -0.20712952]

 np.dot()を使って、行列の積を計算します。

 推論時には、最終層のソフトマックス関数による活性化を省略できるのでした(3.5.3項)。よって、この計算結果(重み付き和)が推論結果となります。必要に応じてソフトマックス関数の処理を行います。

 推論処理(1層のニューラルネットワークの計算)を行う関数として、この処理を定義します。

# 推論を行う関数を定義
def predict(x, W):
    # 重み付き和を計算
    return np.dot(x, W)


 作成した関数を使って、重み付き和を計算します。

# 重み付き和を計算
a = predict(x, W)
print(a)
[ 0.39198965  1.63237259 -0.20712952]

 xWの値が同じであれば、計算結果は変わりません。

・損失の計算

 学習時(損失の計算時)には、3.5.2項で実装したsoftmax()を使って、ソフトマックス関数による活性化(正規化)を行う必要があります。

# ソフトマックス関数による正規化
y = softmax(a)
print(y)
[0.19975102 0.69052669 0.10972229]

 各要素は、0から1の値で総和が1となる値(確率として扱える値)に変換されます。これが、ニューラルネットワークの出力$\mathbf{y}$です。

 4.2.4項で実装したcross_entropy_error()を使って、ニューラルネットワークの出力$\mathbf{y}$と教師データ$\mathbf{t}$を用いて損失(交差エントロピー誤差)$L$を計算します。

# 交差エントロピー誤差を計算
L = cross_entropy_error(y, t)
print(L)
2.209801855236205


 損失を計算する関数として、ここまでの処理を定義します。

# 損失を計算する関数を定義
def loss(x, W, t):
    # 重み付き和を計算
    a = predict(x, W)
    
    # ソフトマックス関数による正規化
    y = softmax(a)
    
    # 交差エントロピー誤差を計算
    loss = cross_entropy_error(y, t)
    return loss

 この関数の中で推論処理も行うことで、1つの関数で損失を計算できます。

 作成した関数でも損失を計算してみます。

# 損失を計算
L = loss(x, W, t)
print(L)
2.209801855236205

 損失$L$が求まりました。

 以上が1層のニューラルネットワークで行う処理です。

・実装

 処理の確認ができたので、1層のニューラルネットワークをクラスとして実装します。

 このクラスは、初期化メソッドによって「パラメータの初期化」を行います。また、「推論処理(ニューラルネットワークの計算)」と「損失の計算」の2つのメソッドを持ちます。

# 1層のニューラルネットワークの実装
class simpleNet:
    # 初期化メソッド
    def __init__(self):
        # 重みを初期化
        self.W = np.random.randn(2, 3)
    
    # 推論メソッド
    def predict(self, x):
        # 重み付き和を計算
        return np.dot(x, self.W)
    
    # 損失メソッド
    def loss(self, x, t):
        # 重み付き和を計算
        a = self.predict(x)
        
        # ソフトマックス関数による正規化:3.5.1項
        y = softmax(a)
        
        # 交差エントロピー誤差を計算:4.2.3項
        loss = cross_entropy_error(y, t)
        return loss

 重みはどの計算にも利用するので、インスタンス変数(クラス内変数)Wとして値を保存します。
 重みをインスタンス変数とすることで、インスタンス(クラスが反映されたオブジェクト)は常に重みを持っている状態になります。よって、メソッド(クラス内関数)の使用時に、重みを引数に指定する必要がなくなります。

 メソッドの定義において、第1引数をselfにする必要があります。

 実装したクラスを試してみましょう。ただし、処理に乱数を含むため実行する度に結果が変わります。

 まずは、作成したクラスをオブジェクト(変数)に反映させます。これをインスタンス化と呼び、反映されたオブジェクトをインスタンスと呼びます。インスタンスの作成は、クラス名()で行えます。

# インスタンスを作成
net = simpleNet()

 この例では、インスタンス名(変数名)をnet(ネットワークのこと)とします。

 インスタンス変数は、インスタンス名.インスタンス変数名で取り出せます。

# 重み(インスタンス変数)を確認
print(net.W)
print(net.W.shape)
[[-0.19182917  0.52706189  0.47955191]
 [-1.74211709 -0.51386459  0.50421016]]
(2, 3)

 simpleNetクラスのインスタンスnetが作成された時点で、インスタンス変数net.Wとして$2 \times 3$の2次元配列の重みが作成されています。

 推論メソッドpredict()を使って、ニューラルネットワークの推論(分類)を行います。メソッドの使用は、インスタンス名.メソッド名()で行えます。

# ニューラルネットワークの計算
a = net.predict(x)
print(a)
[-1.68300288 -0.146241    0.74152029]

 この項の最初に作成した入力xを引数に指定します。重みは、インスタンス変数としてメソッド内に組み込まれているので、引数に指定しません。

 推論結果を確認しましょう。

# 推論結果を確認
print(np.argmax(a)) # 推論結果
print(np.argmax(t)) # 正解ラベル
print(np.argmax(a) == np.argmax(t)) # 比較
2
2
True

 np.argmax()を使って、(正規化を行っていない)ニューラルネットワークの出力aと教師データtの最大値のインデックスを抽出します。インデックスがクラス番号(書かれている数字)に対応しています。
 抽出した値(ラベル)に対して、比較演算子==を使って同じ値か調べます。値が同じであればTrueとなり、正しく認識できたことを表します。値が違えばFalseとなり、認識できなかったこと表します。

 ここでは学習を行っていないので、当たるも当たらないもただの運です。

 損失メソッドloss()を使って、損失(交差エントロピー誤差)を計算します。

# 損失を計算
L = net.loss(x, t)
print(L)
0.405529248302061

 こちらも引数に指定するのは入力データxと教師データtのみです。

 ここまでが1層のニューラルネットワークで行う処理です。続いて、4.4.0項で実装したnumerical_gradient()を使って、勾配を求めていきます。

・勾配の計算

 numerical_gradient()の引数には、対象となる関数と勾配を求める変数を指定します。そこで、loss()メソッドの出力をそのまま返す関数を作成しておきます。

# 損失メソッドを実行する関数を作成
def f(W):
    # 損失メソッドを実行
    return net.loss(x, t)

 引数に指定した変数は直接利用しません。これについては後述します。

 作成した関数を使って、損失を計算します。重み(インスタンス変数)を引数に指定しておきますが、この段階では計算結果には影響しません。

# 損失を計算
L = f(net.W)
print(L)
0.405529248302061

 当然、先ほど同じ結果になります。

 作成した関数fと重み(インスタンス変数)net.Wnumerical_gradient()の引数に指定して、勾配を計算します。

# 重みの勾配を計算
dW = numerical_gradient(f, net.W)
print(dW)
[[ 0.03540585  0.16461984 -0.20002569]
 [ 0.05310877  0.24692976 -0.30003853]]

 これで重みの勾配$\frac{\partial L}{\partial \mathbf{W}}$を得られました。

 このような単純な関数の場合には、ラムダ式という記法を使って関数を定義することもできます。lambdaに続けて引数を指定して、:を挟んで実行する処理を書きます。

関数名 = lambda 引数: 実行する処理

 変数に代入するように関数を作成することができます。

# ラムダ式による関数の定義
f = lambda w: net.loss(x, t)

 先ほどの定義と全く同じ関数を1行で作成することができました。この記法は次節で利用します。

 この関数を使って勾配を求めても、当然同じ結果になります。

# 損失を計算
L = f(net.W)
print(L)

# 重みの勾配を計算
dW = numerical_gradient(f, net.W)
print(dW)
0.405529248302061
[[ 0.03540585  0.16461984 -0.20002569]
 [ 0.05310877  0.24692976 -0.30003853]]


 最後に、numerical_gradient()の内部の内部の処理を確認しましょう。

 重みの値を確認します。

# 値を確認
print(net.W)
[[-0.19182917  0.52706189  0.47955191]
 [-1.74211709 -0.51386459  0.50421016]]


 通常の変数のように、インスタンス変数のインデックスを指定して値を代入できます。

# 0行0列目の値を変更
net.W[0, 0] = 1.0
print(net.W)
[[ 1.          0.52706189  0.47955191]
 [-1.74211709 -0.51386459  0.50421016]]

 指定した要素の値を変更できました。

 他の要素も同様に書き換えられます。

# 1行2列目の値を変更
net.W[1, 2] = 2.0
print(net.W)
[[ 1.          0.52706189  0.47955191]
 [-1.74211709 -0.51386459  2.        ]]

 simpleNetクラスのインスタンスnetが持つ重み(インスタンス変数)の値が書き替えられました。

 値を変更した重みを使って、損失を計算します。

# 損失を計算
L = net.loss(x, t)
print(L)
0.1434029239098499

 重みの値が変わったことで推論結果(重み付き和)が変わり、損失も変わりました。

 numerical_gradient()では、この機能を使ってインスタンス変数の各要素に微小な値を加えて(引いて)から損失メソッドを実行することで、数値微分を計算しています。詳しくは4.4.0項を参照してください。

 この項では、簡単なニューラルネットワークをクラスとして実装しました。また、ニューラルネットワークのパラメータ(重み)の勾配を求めました。パラメータの勾配が得られたということは、パラメータの学習を行えるようになったということです。次節では、これまでの内容を総動員して、実践的なニューラルネットワークを実装し、学習を行います。

参考文献

github.com

おわりに

 次節は4章の最終節です。これまでの内容の総まとめになります!

 勾配の実装で添字の指定がちょっとミスってる気がするので、確認して後で修正します。これはまだ確認できていませんが、ソフトマックス関数に問題があったのに気付いたので修正しました。

  • 2021.08.27:加筆修正しました。

 どこをミスっていたのか確認していませんが、もうミスはないと思います。

【次節の内容】

www.anarchive-beta.com