からっぽのしょこ

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

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

はじめに

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

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

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

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

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

 この項では、重みパラメータの初期値の設定から損失関数による誤差の計算までの一連の処理をクラスとして実装します。その後勾配を求めますが、勾配降下法によるパラメータの学習までは行いません。クラスについては【1章:Python入門【ゼロつく1のノート(Python)】 - からっぽのしょこ】を確認してください。(この項は、次節でこれまでの総まとめをする前の確認・準備運動のような内容です。)

 (ニューラルネットワークとは関係のないある)変数$x_0,\ x_1$とその勾配は次のように書けます。

$$ \mathbf{x} = \begin{pmatrix} x_0 & x_1 \end{pmatrix} ,\ \frac{d f}{d \mathbf{x}} = \begin{pmatrix} \frac{d f}{d x_0} & \frac{d f}{d x_1} \end{pmatrix} $$

 この表現を使って式(4.7)を次のようにまとめられるのでした。

$$ \mathbf{x}^{(\mathrm{new})} = \mathbf{x} - \eta \frac{d f}{d \mathbf{x}} \tag{4.7'} $$


 これを更に$2 \times 3$の行列である重み$\mathbf{W}$と損失関数$L$の勾配に拡張すると次のようになります。

$$ \mathbf{W} = \begin{pmatrix} w_{11} & w_{12} & w_{13} \\ w_{21} & w_{22} & w_{23} \end{pmatrix} ,\ \frac{\partial L}{\partial \mathbf{W}} = \begin{pmatrix} \frac{\partial L}{\partial w_{11}} & \frac{\partial L}{\partial w_{12}} & \frac{\partial L}{\partial w_{13}} \\ \frac{\partial L}{\partial w_{21}} & \frac{\partial L}{\partial w_{22}} & \frac{\partial L}{\partial w_{23}} \end{pmatrix} \tag{4.8} $$

 ニューラルネットワークの重みに対する勾配降下法は次の式になります。

$$ \mathbf{W}^{(\mathrm{new})} = \mathbf{W} - \eta \frac{\partial L}{\partial \mathbf{W}} $$

 更新した重みを$\mathbf{W}^{(\mathrm{new})}$としました。

 この式について1行1列目の要素に注目すると次の式になります。

$$ w_{11}^{(\mathrm{new})} = w_{11} - \eta \frac{\partial L}{\partial x_{11}} $$

 要素が(縦や横に)増えてもやっていることは式(4.7)と同じです。

 この項では次の一連の処理をクラスとして実装します。まず重み$\mathbf{W}$の初期値をランダムに設定します。その重みを使った1層の(シンプルな)ニューラルネットワークで、入力データを処理します。最後に、ニューラルネットワークの出力に対する交差エントロピー誤差を計算します。

 この節以前の内容も含むことから、これまでに実装した関数を用います。そのため、4.2.4項の交差エントロピー誤差cross_entropy_error()の関数定義を再度実行する必要があります。あるいは次の方法で、マスターデータの「common」フォルダの「functions.py」ファイルから定義されている関数を読み込むこともできます。

# 関数の読み込みに利用するライブラリを読み込む
import sys
import os

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

# ファイルから関数を読み込む
from common.functions import softmax # ソフトマックス関数:3.5.1項
from common.functions import cross_entropy_error # 交差エントロピー誤差:4.2.3項
from common.gradient import numerical_gradient # 勾配の計算:4.4節

 うまくいかないようであれば、どれも数行で定義されている関数なので直接ファイルを開いてコピペしてもいいです。(ただしどの関数も本での実装方法とは多少異なります。本では紹介していない機能を使って洗練された実装になっています。基本的に使用するにあたっての違いはありません。)

 ただし、3.5.1項で実装したソフトマックス関数softmax()4.4節で実装した勾配の計算numerical_gradient()のままでは、2次元配列のデータを処理できません(上の方法で読み込んだ場合は処理できます)。そこで、2次元配列を処理できるように変更します。

# NumPyを読み込む
import numpy as np

# (2次元配列対応版)ソフトマックス関数を実装
def softmax(a):
    
    # 式(3.10)の計算
    exp_a = np.exp(a - np.max(a, axis=-1, keepdims=True)) # オーバーフロー対策:式(3.11)
    y = exp_a / np.sum(exp_a, axis=-1, keepdims=True)
    
    return y

 axis引数は行や列のどの方向に対して行うのかを指定します。-1を指定すると、.shapeの返り値の最後の軸に対してのみ処理します。つまり1次元配列ならそもそも1方向にしか要素が並んでいないのでそのまま処理します。2次元配列であれば((行, 列)の順なので)、行ごとに各列の要素に対して処理します。

 np.max()np.sum()も複数の要素に対して処理(最大値を抽出する・和をとる)ことで1つの値にします。つまり入力よりも出力の次元が減ります。keepdims=Trueとすると、元も次元を保ったまま出力します。

 では1次元配列と2次元配列に対して試してみましょう。

# 1次元配列の場合
y1 = softmax(np.array([1.0, 2.0]))
print(y1)

# 2次元配列の場合
y2 = softmax(np.array([[1.0, 2.0]]))
print(y2)
[0.26894142 0.73105858]
[[0.26894142 0.73105858]]

 入力と出力の形状が同じになりました。

 続いて勾配を改良します。前項では、1次元配列の各要素に対してforで繰り返し処理を行っていました。これをforを2重(入れ子)にすることで、各行と各列に対して順番に処理していきます。それに伴い添字に指定する値が2つになります。

# (2次元配列対応版)勾配を定義
def numerical_gradient(f, x):
    
    # 1次元配列の場合は2次元配列に変換
    if x.ndim == 1:
        x = x.reshape(1, x.size)
        dim_num = 1 # 元の次元数を記録
    else:
        dim_num = x.ndim # 元の次元数を記録
    
    # 微小な値
    h = 1e-4 # 0.0001
    
    # 勾配を初期化(受け皿を作成)
    grad = np.zeros_like(x)
    
    # 勾配を計算
    for i in range(x.shape[0]): # 1行ずつ処理
        
        for j in range(x.shape[1]): # 1列ずつ処理
            
            # 微分する変数の値を取り出す
            tmp_val = x[i, j]
            
            # f(x+h)を計算
            x[i, j] = tmp_val + h
            fxh1 = f(x[i, :])
            
            # f(x-h)を計算
            x[i, j] = tmp_val - h
            fxh2 = f(x[i, :])
            
            # 数値微分(中心差分)
            grad[i, j] = (fxh1 - fxh2) / (2 * h)
            
            # 値を元に戻す
            x[i, j] = tmp_val
    
    # 入力が1次元配列の場合は元の形状に戻す
    if dim_num == 1:
        return grad.reshape(grad.size)
    else:
        return grad

 (これまでに使った機能で実装するとこんな感じでしょうか(「gradient.py」の実装とは全然違います)。)

 使うのはこの項の最後の方なので、ここで試しておきましょうか。

# 2乗和関数:式(4.7)
def tmp_fn(x):
    return np.sum(x ** 2)

# 1次元配列の場合
y1 = numerical_gradient(tmp_fn, np.array([1.0, 2.0, 3.0]))
print(y1)

# 2次元配列の場合
y2 = numerical_gradient(tmp_fn, np.array([[1.0, 2.0, 3.0]]))
print(y2)
[2. 4. 6.]
[[2. 4. 6.]]

 こちらも入力時の形状が保たれて出力されていますね。

 では実装に移ります。

 まずは処理の確認を行うにあたって利用する仮のデータを作成しておきます。この項はあくまで練習なので、ここでは式(4.8)の重み$\mathbf{W}$の形状の場合に限定して実装します。入力データは(重みの行数と同じ)要素数2の1次元配列、教師データは(列数と同じ)要素数3の1次元配列になります。

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

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

 (ちなみに次でnp.rondom.randn(x.size, t.size)とすれば重みの形状の方をデータに合わせられるので、xtの要素数をいくつにしても処理できます。ではxを2次元配列(複数の(画像)データ)にする場合はどのように指定すればよいでしょうか?(余談でした)。)

 重みの初期値をランダムに設定します(初期化します)。np.rondom.randn(行数, 列数)で、ガウス分布(正規分布)に従う乱数を生成します。

# 重みの初期値を設定
W = np.random.randn(2, 3)
print(W)
[[ 0.53209333  0.60639097  0.14636213]
 [-0.47814282 -0.29687516  0.86977918]]

 行数は入力データの要素数、列数は教師データの要素数に対応しています。

 この処理を重みの初期化関数として定義します。

# 重みの初期化関数を定義
def init_W():
    # ガウス分布に従う乱数により初期化
    W = np.random.randn(2, 3)
    return W

 ランダムに値が生成されるので、処理の度に結果が異なります。

 次に、ニューラルネットワークの処理(入力と重みの計算(行列の積))を行います。

# 重みを初期化
W = init_W()
print(W)

# 重み付き和を計算
a = np.dot(x, W)
print(a)
[[ 0.26314004  0.47701428 -1.35169189]
 [-0.29089188  1.86708888 -1.10596201]]
[-0.10391867  1.96658856 -1.80638095]

 分類問題(手書き文字認識)では、最終層のソフトマックス関数による活性化は省略できるのでした(3.5.3項)。なのでこの計算結果が、推論結果(出力)となります。

 これも推論(1層のニューラルネットワーク)関数として定義します。

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


 次は交差エントロピー誤差を計算します。ただし交差エントロピー誤差の計算には、出力を確率として扱えるようにソフトマックス関数による活性化が必要でした。そこで重み付き和asoftmax()で変換してから、cross_entropy_error()で処理します。

# 重み付き和を計算
a = predict(x, W)
print(a)

# ソフトマックス関数による活性化
y = softmax(a)
print(y)

# 交差エントロピー誤差を計算
loss = cross_entropy_error(y, t)
print(loss)
[-0.10391867  1.96658856 -1.80638095]
[0.1097565  0.87024211 0.02000139]
3.911948323599456

 交差エントロピー誤差を計算できました。

 これも損失関数として定義します。

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


 作成した関数でも交差エントロピー誤差を計算してみます。

# 損失関数による計算
loss_score = loss(x, W, t)
print(loss_score)
3.911948323599456

 途中で重みの初期化を行わなければ、結果は変わりません。

 1層のニューラルネットワークでは、「パラメータの初期化」、「推論(ニューラルネットワーク)」、「誤差の計算」の3つのパートが必要なのが分かりました。この3つの機能を持つクラスとしてまとめて実装します。

 重みはどの処理にも必要な要素であるため、インスタンス変数(クラス内変数)として実装します。インスタンス変数の定義(作成)には__init__()を使います。
 重みをインスタンス変数とすることで、インスタンス(クラスが反映されたオブジェクト)は常に重みを持っている状態になります。よってメソッド(クラス内関数)の使用時に、重みを引数に指定する必要がなくなります。

 またメソッドの定義時は、第1引数をselfとすることに注意してください。

# 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)
        
        # ソフトマックス関数による活性化
        y = softmax(a) # 3.5.1項
        
        # 交差エントロピー誤差を計算
        loss = cross_entropy_error(y, t) # 4.2.3項
        
        return loss

 インスタンス変数として実装された重みはself.Wとして扱うことになります。

 1つずつ動作確認していきましょう。ただし処理に乱数を含むため同じ結果にはなりません。

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

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

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

# 重み(インスタンス変数)を確認
print(net.W)
[[ 0.72906121 -1.05252577  0.15418118]
 [ 0.01740023 -1.29394101  1.07785566]]

 インスタンスが作成された時点で、重みが$2 \times 3$の行列のインスタンス変数として作成されています。

 ニューラルネットワークの処理(分類)を行ってみましょう。メソッドの使用は、インスタンス名.メソッド名()で行えます。

 最初に設定した入力xを引数に指定します。重みはインスタンス化の時点で既に作成され、インスタンス内で既に利用可能な状態にあります。またメソッド内に組み込まれているので、引数として渡す必要はありません。

# 推論(正規化なし)
p = net.predict(x)
print(p)

# 推論結果を確認
print(np.argmax(p)) # 推論結果を抽出
print(np.argmax(p) == np.argmax(t)) #推論結果と正解ラベルを比較
[ 0.45309694 -1.79606237  1.0625788 ]
2
True

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

 同様に、教師データtも使って交差エントロピー誤差を計算します。これも重みについてはメソッド内で定義されているので、引数に指定するのは入力データxと教師データtのみです。

# 交差エントロピー誤差を計算
net.loss(x, t)
0.47061522121097543

 損失関数メソッド.loss()により、交差エントロピー誤差の計算を行えました。

 これで1層のニューラルネットワークを実装できました!

 続いて、勾配を求めていきます。

 numerical_gradient()には、対象となる関数を引数として指定する必要があります。そこで、.loss()メソッドの出力をそのまま返す関数を作成しておきます。

# `numerical_gradient()`用に関数を定義
def f(W):
    return net.loss(x, t)


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

# 勾配を計算
dW = numerical_gradient(f, net.W)
print(dW)
[[ 0.20373747  0.02149183 -0.2252293 ]
 [ 0.3056062   0.03223775 -0.33784395]]

 これで損失関数の勾配を得られました。

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

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

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

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

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

 関数の戻り値(returnの値)をラムダ式とすることで、関数を作成する関数を定義することもできます(次でそうします!)。

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

# 勾配を計算
dW = numerical_gradient(f, net.W)
print(dW)
[[ 0.20373747  0.02149183 -0.2252293 ]
 [ 0.3056062   0.03223775 -0.33784395]]

 これで損失関数の勾配を得られました。

 勾配を求められたので、勾配降下法によって重みを更新できる訳です。つまり、ようやく学習を行えるところまで来ました!

 この節ではパラメータの学習に必要な数学的ツールを確認し、最後にシンプルなニューラルネットワークを実装することで、それぞれの役割を確認できました。また、クラスを使って実装する準備運動的な意味もありました。次節では、これまでの内容を総動員してより実践的なニューラルネットワークを実装します。勿論クラスとして!

参考文献

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

おわりに

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

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

【次節の内容】

www.anarchive-beta.com