はじめに
『ゼロから作るDeep Learning 2――自然言語処理編』の初学者向け【実装】攻略ノートです。『ゼロつく2』学習の補助となるように適宜解説を加えています。本と一緒に読んでください。
本の内容を1つずつ確認しながらゆっくりと組んでいきます。
この記事は、1.3節「ニューラルネットワークの学習」の内容です。全結合のニューラルネットワークを構成する基本的なレイヤの逆伝播を説明して、各レイヤをPythonで実装します。
【前節の内容】
【他の節の内容】
【この節の内容】
1.3.1 損失関数
・Softmax関数
Softmax関数とは、最終層のAffineレイヤの出力(スコア)$\mathbf{s} = (s_1, s_2, \cdots, s_H)$に対して、次の式の計算により活性化を行う関数です。ここで$H$は最終層のニューロン数です。
Softmax関数の出力$\mathbf{y} = (y_1, y_2, \cdots, y_H)$は
となることから、スコア$\mathbf{s}$を確率値に変換したと解釈できます。この変換(活性化)を正規化と呼びます。詳しくは「3.5:出力層の設計」を参照ください。
ではSoftmax関数を関数として実装します。
# Softmax関数の実装 def softmax(x): # データの形状により条件分岐 if x.ndim == 2: # 2次元配列のとき x = x - x.max(axis=1, keepdims=True) # オーバーフロー対策 x = np.exp(x) # 式(1.6)の分子 x /= x.sum(axis=1, keepdims=True) # 式(1.6) if x.ndim == 1: # 1次元配列のとき x = x - np.max(x) # オーバーフロー対策 x = np.exp(x) / np.sum(np.exp(x)) # 式(1.6) return x
試してみましょう。
# 仮のスコアを生成 s = np.arange(-5, 5) print(s) # Softmax関数による正規化 y = softmax(s) print(np.round(y, 3)) print(np.sum(y))
[-5 -4 -3 -2 -1 0 1 2 3 4]
[0. 0. 0.001 0.002 0.004 0.012 0.031 0.086 0.233 0.632]
1.0
大小関係を保ったまま0以上1以下の値に変換され、またその和が1になっています。
次は正規化された出力$\mathbf{y}$を用いて、推論の損失を求めます。
・交差エントロピー誤差
交差エントロピー誤差は、Softmax関数の出力$\mathbf{y}$とone-hotベクトル(one-hot表現)の教師ラベル$\mathbf{t} = (t_1, t_2, \cdots, t_H)$を用いて、次の式で求めます。
バッチデータを扱う場合は
となります。各データに対して式(1.7)の計算を行い、それをミニバッチ全体で和をとり、バッチサイズで割ります。つまりデータごとの損失の平均と言えます。詳しくは「4.2:損失関数」を参照ください。
では交差エントロピー誤差を関数として実装します。
# 交差エントロピー誤差の実装 def cross_entropy_error(y, t): # 2次元配列に変形 if y.ndim == 1: # 1次元配列のとき t = t.reshape(1, t.size) y = y.reshape(1, y.size) # 正解ラベルのインデックスに変換 if t.size == y.size: # one-hotベクトルのとき t = t.argmax(axis=1) # バッチサイズを取得 batch_size = y.shape[0] # 交差エントロピー誤差を計算 return - np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size # 式(1.8)
1e-7
は$\frac{1}{10^7}$のことで、$\log 0$とならないようにするための微小な値です。
試してみましょう。教師ラベルもNumPy配列である必要があります。
# 正解のインデックスを指定 k = 1 # 仮の教師ラベルを生成 t = np.zeros_like(y) t[k] = 1 print(t) # 交差エントロピー誤差を計算 L = cross_entropy_error(y, t) print(L)
[0. 1. 0. 0. 0. 0. 0. 0. 0. 0.]
8.45815829637557
全てのデータで和をとるので、損失はスカラになります。
Softmaxレイヤと損失関数レイヤを合わせて、Softmax with Lossレイヤとして扱います。このレイヤの実装は、1.3.5.3項で逆伝播と共に行います。
1.3.2-4
微分の基礎については「4.3:数値微分」、勾配については「4.4:勾配」、解析的な微分とチェインルール(連鎖率)については「5.2:連鎖率」を参照ください。
また加算ノードと乗算ノードについては「5.4:単純なレイヤの実装」を参照ください。
1.3.4.5 MatMulノード
・順伝播の確認
MatMulレイヤの順伝播は、行列の積のことです。1.1.4項と同じ内容なので、簡単に確認します。
MatMulノードの順伝播の入力$\mathbf{x}$、重み$\mathbf{W}$を
とすると、順伝播の出力$\mathbf{y}$は
となります。
入力の$i$番目の要素$x_i$は、出力$\mathbf{y}$の全ての要素に影響していることが分かります。つまり$x_i$は、次のレイヤの$H$個のニューロン全てに伝播しています。これはRepeatノードの順伝播と言えます。またこのことは図1-7からも分かります。
・逆伝播の確認
順伝播の入力$\mathbf{x}$に関する勾配$\frac{\partial L}{\partial \mathbf{x}}$は、$\mathbf{x}$と同じ形状なので
です。これがMatMulレイヤの逆伝播の出力になります。
この勾配の$i$番目の要素(微分)$\frac{\partial L}{\partial x_i}$を考えます。$x_i$が$y_1$から$y_H$のニューロンに(順)伝播していることから、$x_i$のニューロンには$\frac{\partial L}{\partial y_1}$から$\frac{\partial L}{\partial y_H}$が(逆)伝播してきます。よってRepeatノードの逆伝播より、全ての和
となります。
また
なので、順伝播の入力に関する出力の微分は
となります。
従って、MatMulレイヤの$i$番目のニューロンの逆伝播の出力は
になります。
$i = 1, 2, \cdots, D$についても同様に解けるので
となります。
またこの式は、次のように行列の積に分解できます。
ここで$\mathbf{W}^{\mathrm{T}}$は、$\mathbf{W}$の転置行列です。
さてこのことを利用してえいやっとすると、MatMulレイヤの逆伝播(各変数の勾配)は
で計算できます。これはバッチデータであっても成り立ちます。詳しくは「Affineレイヤの逆伝播」を参照ください。
ではMatMulレイヤをクラスとして実装します。
# MatMulレイヤの実装 class MatMul: # 初期化メソッドの定義 def __init__(self, W): self.params = [W] # パラメータ self.grads = [np.zeros_like(W)] # 勾配 self.x = None # 入力 # 順伝播メソッドの定義 def forward(self, x): # 重みを取得 W, = self.params # 行列の積を計算 out = np.dot(x, W) # 入力を保存 self.x = x return out # 逆伝播メソッドの定義 def backward(self, dout): # 重みを取得 W, = self.params # 勾配を計算 dx = np.dot(dout, W.T) dW = np.dot(self.x.T, dout) # 勾配を格納 self.grads[0][...] = dW return dx
[...]
を使ったコピーについては、本を読んでください。
W, = self.params
の処理について確認します。インスタンス変数params
には、NumPy配列(重み)を1つの要素として格納しています。,
を付けて受け取ることで、このリストの要素(配列)を取り出せます。
# NumPy配列を格納したリストを作成 lt = [np.array([0, 1, 2])] print(lt.__class__) print(lt) # NumPy配列として要素を抽出 arr, = lt print(arr.__class__) print(arr)
<class 'list'>
[array([0, 1, 2])]
<class 'numpy.ndarray'>
[0 1 2]
実装したクラスを試してみましょう。入力と重みを生成して、インスタンスを作成します。
# バッチサイズを指定 N = 10 # このレイヤのニューロン数を指定 D = 15 # 次のレイヤのニューロン数を指定 H = 5 # 入力を生成 x = np.random.randn(N, D) print(x.shape) # 重みを生成 W = np.random.randn(D, H) print(W.shape) # インスタンスを作成 matmal_layer = MatMul(W)
(10, 15)
(15, 5)
順伝播の入力x
の形状を$N \times D$、重みW
の形状を$D \times H$とします。
順伝播と逆伝播のメソッドを実行します。
# 順伝播の計算 y = matmal_layer.forward(x) print(y.shape) # 逆伝播の入力を生成 dout = np.random.randn(*y.shape) print(dout.shape) # 逆伝播の計算 dx = matmal_layer.backward(dout) print(dx.shape) # 重みに関する勾配を取得 dW, = matmal_layer.grads print(dW.shape)
(10, 5)
(10, 5)
(10, 15)
(15, 5)
順伝播の出力y
と逆伝播の入力dout
は$N \times H$に、逆伝播の出力dx
はx
、重みに関する勾配dW
はW
と同じ形状になることを確認できました。
ここまでは、基本的な計算に関するノードの順伝播と逆伝播の処理を確認しました。次項からは、レイヤ(関数)レベルでの順伝播と逆伝播の確認と実装を行っていきます。
1.3.5 勾配の導出と逆伝播の実装
1.3.5.1 Sigmoidレイヤ
シグモイド関数の順伝播は、式(1.5)でした。逆伝播は、次の式になります。
順伝播の出力$y$のみを用いて計算できます。
この式の導出については「シグモイド関数の逆伝播」を、実装については「5.5:活性化レイヤの実装」参照ください。
ではSigmoidレイヤをクラスとして実装します。逆伝播の計算に順伝播の出力out
を用いるため、インスタンス変数として保存しておきます。
# Sigmoidレイヤの実装 class Sigmoid: # 初期化メソッドの定義 def __init__(self): self.params = [] # パラメータ self.grads = [] # 勾配 self.out = None # 順伝播の出力 # 順伝播メソッドの定義 def forward(self, x): out = 1 / (1 + np.exp(-x)) # 式(1.5) # 出力を保存 self.out = out return out # 逆伝播メソッドの定義 def backward(self, dout): dx = dout * (1.0 - self.out) * self.out # 式(1.15) return dx
実装したクラスを試してみましょう。インスタンスを作成して、前項の出力y
はバイアスを省略したAffineレイヤの出力と言えるので、y
を順伝播メソッドに渡します。
# インスタンスを作成 sigmoid_layer = Sigmoid() # 順伝播の計算 a = sigmoid_layer.forward(y) print(np.round(a, 3)) print(a.shape) # 逆伝播の入力を生成 dout = np.random.randn(*a.shape) # 逆伝播の計算 da = sigmoid_layer.backward(dout) print(da.shape)
[[0.051 0.89 0.632 0.988 0.219]
[0.139 0.216 0.978 0.569 1. ]
[0.987 0.994 0.926 0.871 0.004]
[0.714 0.85 0.147 0.001 0.991]
[0.745 0.998 0.965 0.014 0.984]
[0.754 0.947 0.928 0.558 0.808]
[0.032 0.779 0.07 0.858 0.731]
[0.135 0.897 0.984 0.204 0.988]
[1. 0.282 0.085 0.286 0.999]
[0.924 0. 0.893 0.991 0.627]]
(10, 5)
(10, 5)
0から1の値に変換されています。また順伝播でも逆伝播でも形状は変わりません。
Sigmoidレイヤを実装できました。次項では、Sigmoidレイヤとセットで用いるAffineレイヤを実装します。
1.3.5.2 Affineレイヤ
Affineレイヤの順伝播は、1.2.1項で確認しました。また入力$\mathbf{x}$と重み$\mathbf{W}$に関する勾配は、(加算ノードは逆伝播に影響しないので)MatMulノードの逆伝播と同じ式になります。
バイアス$\mathbf{b} = (b_1, b_2, \cdots, b_H)$に関する勾配$\frac{\partial L}{\partial \mathbf{b}} = (\frac{\partial L}{\partial b_1}, \frac{\partial L}{\partial b_2}, \cdots, \frac{\partial L}{\partial b_H})$は、Repeatノードの逆伝播より
となります。
逆伝播の導出については「バッチ版Affineレイヤの逆伝播」を、実装については「5.6.2:バッチ版Affineレイヤ」を参照ください。
ではAffineレイヤをクラスとして実装します。
重みとバイアスに関する勾配は、それぞれ同じ形状の全ての要素が0の変数を作成して、リストに格納し、インスタンス変数として保持します。順伝播時に引数に指定する入力x
は、逆伝播の計算にも用いるため、これもインスタンス変数として保存します。
# Affineレイヤの実装 class Affine: # 初期化メソッドの定義 def __init__(self, W, b): self.params = [W, b] # パラメータ self.grads = [np.zeros_like(W), np.zeros_like(b)] # 勾配 self.x = None # 入力 # 順伝播メソッドの定義 def forward(self, x): # パラメータを取得 W, b = self.params # 重み付き和を計算 out = np.dot(x, W) + b # 式(1.4) # 入力を保存 self.x = x return out # 逆伝播メソッドの定義 def backward(self, dout): # パラメータを取得 W, b = self.params # 勾配を計算:(1.3.4.5項) dx = np.dot(dout, W.T) dW = np.dot(self.x.T, dout) db = np.sum(dout, axis=0) # 勾配を保存 self.grads[0][...] = dW self.grads[1][...] = db return dx
1.3.4.5項と同様に、実装したクラスを試してみましょう。まずは順伝播に必要な変数を生成して、各レイヤのインスタンスを作成します。
# バッチサイズを指定 N = 10 # このレイヤのニューロン数を指定 D = 15 # 次のレイヤのニューロン数を指定 H = 5 # 入力を生成 x = np.random.randn(N, D) print(x.shape) # 重みを生成 W = np.random.randn(D, H) print(W.shape) # バイアスを生成 b = np.random.randn(H) print(b.shape) # Affineレイヤのインスタンスを作成 affine_layer = Affine(W, b) # Sigmoidレイヤのインスタンスを作成 sigmoid_layer = Sigmoid()
(10, 15)
(15, 5)
(5,)
順伝播メソッドを実行します。
# Affineレイヤの順伝播の計算 h = affine_layer.forward(x) print(h.shape) # Sigmoidレイヤの順伝播の計算 a = sigmoid_layer.forward(h) print(a.shape)
(10, 5)
(10, 5)
逆伝播の入力を生成して、逆伝播メソッドを実行します。
# 逆伝播の入力を生成 dout = np.random.randn(*y.shape) print(dout.shape) # Sigmoidレイヤの逆伝播の計算 dout = sigmoid_layer.backward(dout) print(dout.shape) # Affineレイヤの逆伝播の計算 dx = affine_layer.backward(dout) print(dx.shape) # 重みとバイアスに関する勾配を取得 dW, db = affine_layer.grads print(dW.shape) print(db.shape)
(10, 5)
(10, 5)
(10, 15)
(15, 5)
(5,)
それぞれ対応する変数が同じ形状になっていることを確認できました。
Affineレイヤを実装できました。次項では、最終層の活性化レイヤと損失関数レイヤを合わせて実装します。
1.3.5.3 Softmax with Lossレイヤ
Softmax関数と損失関数(交差エントロピー誤差)の順伝播は、1.3.1項で確認しました。またこの二つのレイヤを合わせてSoftmax with Lossレイヤとして扱います。
これは2つ合わせたレイヤの逆伝播が
とシンプルな式になるためです。ここで$N$はバッチサイズ、$y_{ik}$は(最終層の)Affineレイヤの出力、$t_{ik}$は教師ラベルです。$t_{ik}$は、正解ラベルであれば1、それ以外であれば0をとります。
つまり各要素$y_{ik}$について、正解の要素であれば1を引いてから、正解でなければそのままバッチサイズで割った値を次のレイヤに伝播します。
この式の導出は「5.6.3:ソフトマックス関数と交差エントロピー誤差の逆伝播」を、実装については「5.6.3:Softmax-with-Lossレイヤ」を参照ください。
ではSoftmax with Lossレイヤをクラスとして実装します。
このレイヤの逆伝播の入力dout
は、$\frac{\partial L}{\partial L} = 1$のことです。他のレイヤの連鎖率においてもこの項は含まれていますが、値が1のため省略されています。
# Softmax with Lossレイヤの実装 class SoftmaxWithLoss: # 初期化メソッドの定義 def __init__(self): self.params = [] # パラメータ self.grads = [] # 勾配 self.y = None # Softmaxレイヤの出力 self.t = None # 教師ラベル # 順伝播メソッドの定義 def forward(self, x, t): self.t = t # 教師ラベル self.y = softmax(x) # 式(1.6) # 正解インデックスに変換 if self.t.size == self.y.size: # one-hotベクトルのとき self.t = self.t.argmax(axis=1) # 交差エントロピー誤差を計算 loss = cross_entropy_error(self.y, self.t) # 式(1.8) return loss # 逆伝播メソッドの定義 def backward(self, dout=1): # バッチサイズを取得 batch_size = self.t.shape[0] # 順伝播の入力をコピー dx = self.y.copy() # 逆伝播の計算:(1.3.5.3項) dx[np.arange(batch_size), self.t] -= 1 # 正解ラベルのみ1を引く dx *= dout dx = dx / batch_size return dx
逆伝播の処理では、次のような操作によって正解ラベルの要素のみ計算しています。
# バッチサイズを指定 N = 5 # クラス(最終レイヤのニューロン)数を指定 H = 5 # 正解ラベルのインデックスを指定 t = np.array([0, 1, 2, 3, 4]) # 0からバッチサイズ未満の整数列を生成 row_idx = np.arange(N) # 全ての要素が0の配列を作成 dx = np.zeros((N, H)) # 該当する要素から1を引く dx[row_idx, t] -= 1 print(dx)
[[-1. 0. 0. 0. 0.]
[ 0. -1. 0. 0. 0.]
[ 0. 0. -1. 0. 0.]
[ 0. 0. 0. -1. 0.]
[ 0. 0. 0. 0. -1.]]
実装してクラスを試してみましょう。
各データのサイズを指定してAffineレイヤの変数を生成して、また各レイヤのインスタンスを作成します。
# バッチサイズ,1つ前のニューロン数,クラス数を指定 N, D, H = 10, 15, 5 # Affineレイヤの入力を生成 x = np.random.randn(N, D) print(x.shape) # 重みを生成 W = np.random.randn(D, H) print(W.shape) # バイアスを生成 b = np.random.randn(H) print(b.shape) # Affineレイヤのインスタンスを作成 affine_layer = Affine(W, b) # Softmax with Lossレイヤのインスタンスを作成 loss_layer = SoftmaxWithLoss()
(10, 15)
(15, 5)
(5,)
Affineレイヤの順伝播メソッドを実行し、対応する教師ラベルを生成します。最後にSoftmax with Lossレイヤの順伝播メソッドを実行します。
# Affineレイヤの順伝播の計算 y = affine_layer.forward(x) print(y.shape) # 教師ラベルを生成 t = np.zeros_like(y) k = np.random.randint(0, H, N) # 正解ラベル t[np.arange(N), k] = 1 print(t) print(y.shape) # Softmax with Lossレイヤの順伝播の計算 L = loss_layer.forward(y, t) print(L)
(10, 5)
[[0. 0. 1. 0. 0.]
[0. 0. 0. 1. 0.]
[0. 1. 0. 0. 0.]
[0. 1. 0. 0. 0.]
[0. 0. 0. 0. 1.]
[0. 0. 1. 0. 0.]
[0. 1. 0. 0. 0.]
[0. 0. 0. 0. 1.]
[0. 1. 0. 0. 0.]
[1. 0. 0. 0. 0.]]
(10, 5)
3.322577465420487
これで損失(交差エントロピー誤差)が求まりました。
続いて逆伝播メソッドを実行します。
# 逆伝播の入力 dout = 1 # Softmax with Lossレイヤの逆伝播の計算 dout = loss_layer.backward(dout) print(dout.shape) # Affineレイヤの逆伝播の計算 dout = affine_layer.backward(dout) print(dout.shape)
(10, 5)
(10, 15)
以上でニューラルネットワークを構成する基本パーツを実装できました。これで誤差逆伝播法による勾配を求められます。次は勾配を用いてパラメータを更新する処理を実装します。
1.3.6 重みの更新
勾配$\frac{\partial L}{\partial \mathbf{W}}$を用いて、次の式によりパラメータを更新します。
ここで$\eta$は学習係数(学習率)で、更新の幅を調整する項です。元のパラメータ$\mathbf{W}$から$\eta$で割り引いた勾配を引いた値を新たなパラメータとします。$\leftarrow$は、右辺の計算によって左辺の$\mathbf{W}$を更新することを表現しています。
この手法を勾配降下法(SGD)と言います。詳しい内容やSGDを発展させた手法については「6.1節の記事」を参照ください。
ではSGDをクラスとして実装します。
# SGDの実装 class SGD: # インスタンスメソッドの定義 def __init__(self, lr=0.01): self.lr = lr # 学習率 # 更新メソッド def update(self, params, grads): # パラメータごとに更新 for i in range(len(params)): params[i] -= self.lr * grads[i] # 式(1.16)
実装したクラスを試してみましょう。前項のAffineレイヤのインスタンスに保存されているパラメータparams
と勾配grads
をそのまま用います。
# 最適化手法のインスタンスを作成 optimizer = SGD(lr=0.01) # パラメータを更新 optimizer.update(affine_layer.params, affine_layer.grads) # パラメータを取得 W, b = affine_layer.params print(np.round(W, 2)) print(W.shape) print(np.round(b, 2)) print(b.shape)
[[ 0.71 2.15 -0.75 -0.58 -0.43]
[-0.23 -1.74 1.47 -0.51 -0.53]
[ 1.67 -0.74 -1.8 -0.26 2.36]
[-0.63 -0.19 -1.49 0.88 -2.23]
[-0.96 0.19 1.88 -0.32 0.05]
[-0.64 0.68 0.54 0.2 1.02]
[-1.86 -1.04 1.32 1.98 -0.69]
[-1.56 0.49 1.77 0.4 1.05]
[ 1.31 1.03 0.81 -0.77 1.56]
[-0.47 1.9 -1.03 -0.94 -1.36]
[ 0.53 0.15 -0.14 -0.64 2.31]
[-1.6 0.4 0.57 0.58 0.39]
[ 0.23 -1.97 0.94 -0.81 -0.61]
[-0.68 0.29 1.83 0.11 1.42]
[ 0.25 0.17 0.72 0.71 0.16]]
(15, 5)
[-1.08 -0.41 -0.6 1.67 1.6 ]
(5,)
乱数によって生成したデータを使っているので、この例の値を見ること自体に意味はありませんが、このような処理によってパラメータを更新します。
次節では、ここまでに実装したレイヤを用いて2層のニューラルネットワークを実装し、また実際にデータを使って学習を行います。
参考文献
おわりに
1章は1巻の復習なので、サクサク進められてます。実装方法が1巻と少し変わっていますが、その恩恵はまだ感じられていません。いつ頃利いてくるのかな。
1巻については、かなり細かく噛み砕いた解説記事にしました。2巻の記事のレベルではまだ理解しきれないという方は、是非リンクしている記事も読んでみてください!
【次節の内容】