今回も引き続き「ゼロから作るDeepLearning」をベースに、前回数値微分で実装した学習アルゴリズムの誤差逆伝播法版をRubyで実装してみました。計算の内容等は書籍を参照いただくとして、Rubyで実装した際のポイントを説明していきたいと思います。
www.oreilly.co.jp
活性化関数レイヤ実装
今回はニューラルネットワークの各レイヤをそれぞれ一つのクラスとして実装しています。活性化関数レイヤはReLUレイヤとSigmoidレイヤです。
ReLUレイヤ
ReLUレイヤは下記のように実装しました。
class Relu
def initialize
@mask = nil
end
def forward(x:)
@mask = (x <= 0)
out = x.copy
out[@mask] = 0
out
end
def backward(dout:)
dout[@mask] = 0
dout
end
end
順伝播時のforwardメソッドの引数としてはNArray配列を期待しています。NArray配列 x に対して (x <= 0) をすると0以下の要素が1、それ以外が0のBit配列を返しますので、それをマスクに使い、入力に対して0以下の要素を0に置き換えた結果を返しています。また、マスクは逆伝播時にも使うのでインスタンス変数に保持します。
逆伝播時のbackwardメソッドでは順伝播時に保持したマスクを元に、上流からの入力に対してマスクの要素が1になっている要素に0を設定しています。
Sigmoidレイヤ
Sigmoidレイヤの実装は下記のように行いました。
require './sigmoid.rb'
class Sigmoid
def initialize
@out = nil
end
def forward(x:)
@out = sigmoid(x)
@out
end
def backword(dout:)
dout * (1.0 - @out) * @out
end
end
順伝播時は以前実装したsigmoidメソッドを呼んでいるだけで、その結果をインスタンス変数に保持しておきます。
逆伝播時は順伝播時の出力を元に計算を行なった結果を返します。
AffineレイヤとSoftmaxレイヤの実装
ニューラルネットワークの順伝播において重みの計算とバイアスの加算を行なっていた層をAffineレイヤとして実装し、出力層ではソフトマックス関数を用いて出力を正規化するSoftmaxレイヤを実装し、それぞれの順伝播、逆伝播の処理を実装します。
Affineレイヤ
Affineレイヤの実装は下記の通りです。
class Affine
def initialize(w:, b:)
@w = w
@b = b
@x = nil
@original_x_shape = nil
@dw = nil
@db = nil
end
def dw
@dw
end
def db
@db
end
def forward(x:)
@original_x_shape = x.shape
x = x.reshape(x.shape[0], nil)
@x = x
@x.dot(@w.first) + @b.first
end
def backward(dout:)
dx = dout.dot(@w.first.transpose)
@dw = @x.transpose.dot(dout)
@db = dout.sum(0)
dx.reshape(*@original_x_shape)
end
end
initializeメソッドでは重み、バイアスパラメータをインスタンス変数に格納し、それ以外にも処理に必要になるインスタンス変数を定義しています。
順伝播時は入力の行列の形と入力行列を保持し、入力行列と重みパラメータの内積にバイアスを加算した結果を返します。
逆伝播時はまず内積の逆伝播の計算として重みパラメータと順伝播時の入力値の転置行列を使った計算を行なっています。NArray行列の転置行列はtransposeメソッドで取得できます。
Numo::NArray#transpose
http://ruby-numo.github.io/narray/narray/Numo/NArray.html#transpose-instance_method
バイアスの逆伝播の計算では行列の0番目の軸(データ単位)に対しての合計を求めるため、sumメソッドのパラメータに0を指定しています。
Numo::UInt32#sum
http://ruby-numo.github.io/narray/narray/Numo/Int32.html#sum-instance_method
最後に入力値の逆伝播の計算結果を入力値と同じ行列の形にreshapeして返しています。入力値の形状は変数に配列データとして格納していますが、reshapeメソッドのパラメータは配列ではないので、* で配列を展開して渡しています。
Softmaxレイヤ
Softmaxレイヤは損失関数である交差エントロピー誤差の計算も含めて、SoftmaxWithLossクラスとして下記のように実装しました。
require './softmax.rb'
require './cross_entropy_error.rb'
class SoftmaxWithLoss
def initialize
@loss = nil
@y = nil
@t = nil
end
def forward(x:, t:)
@t = t
@y = softmax(x)
@loss = cross_entropy_error(@y, @t)
@loss
end
def backward(dout: 1)
batch_size = @t.shape[0]
if @t.size == @y.size
return (@y - @t) / batch_size
end
dx = @y.copy
(0..(batch_size - 1)).to_a.zip(@t).each do |index_array|
dx[*index_array] -= 1
end
dx / batch_size
end
end
順伝播時は以前実装したsoftmaxメソッドとcross_entropy_errorメソッドを使用しています。入力値をsoftmaxメソッドで正規化し、その結果と教師データをcross_entropy_errorメソッドに渡して交差エントロピー誤差を計算しています。
逆伝播時はデータ一個あたりの誤差を伝播するために誤差をデータ数で割っています。
行列の要素の参照方法については、Pythonのndarrayの場合は配列を二つ渡すと、それぞれを行・列のインデックスとして要素を参照してくれますが、NArray行列で同じような指定をすると、一つ目の配列で指定した全ての行で、二つ目の配列に指定した全ての要素が取得されてしまいます。
>>> array
array([[ 1, 2, 3, 4],
[ 5, 6, 7, 8],
[ 9, 10, 11, 12]])
>>> array[[0, 2], [1, 3]]
array([ 2, 12])
irb(main):021:0> array
=> Numo::UInt32
[[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11, 12]]
irb(main):022:0> array[[0, 2], [1, 3]]
=> Numo::UInt32(view)
[[2, 4],
[10, 12]]
そこでzipメソッドを使ってそれぞれの配列から一つずつデータを取得し、直接一つの要素を参照するようにしました。
irb(main):024:0* [0, 2].zip([1, 3]).each do |array_index|
irb(main):025:1* puts array[*array_index]
irb(main):026:1> end
2
12
=> [[0, 1], [2, 3]]
誤差逆伝播法でのニューラルネットワーク実装
上記で実装したレイヤを組み合わせて、誤差逆伝播法でのニューラルネットワークを実装します。下記のように一つのクラスとして実装します。
require 'numo/narray'
require './numerical_gradient.rb'
require './layers.rb'
class TwoLayerNet
def initialize(input_size:, hidden_size:, output_size:, weight_init_std: 0.01)
Numo::NArray.srand
@params = {
w1: [weight_init_std * Numo::DFloat.new(input_size, hidden_size).rand_norm],
b1: [Numo::DFloat.zeros(hidden_size)],
w2: [weight_init_std * Numo::DFloat.new(hidden_size, output_size).rand_norm],
b2: [Numo::DFloat.zeros(output_size)]
}
@layers = {
affine1: Affine.new(w: @params[:w1], b: @params[:b1]),
relu1: Relu.new,
affine2: Affine.new(w: @params[:w2], b: @params[:b2])
}
@last_layer = SoftmaxWithLoss.new
end
def params
@params
end
def predict(x:)
@layers.values.inject(x) do |x, layer|
x = layer.forward(x: x)
end
end
def loss(x:, t:)
y = predict(x: x)
@last_layer.forward(x: y, t: t)
end
def accuracy(x:, t:)
y = predict(x: x)
y = y.max_index(1) % 10
if t.ndim != 1
t = t.max_index(1) % 10
end
y.eq(t).cast_to(Numo::UInt16).sum / x.shape[0].to_f
end
def numerical_gradients(x:, t:)
loss_w = lambda { loss(x: x, t: t) }
{
w1: numerical_gradient(loss_w, @params[:w1].first),
b1: numerical_gradient(loss_w, @params[:b1].first),
w2: numerical_gradient(loss_w, @params[:w2].first),
b2: numerical_gradient(loss_w, @params[:b2].first)
}
end
def gradient(x:, t:)
loss(x: x, t: t)
dout = 1
dout = @last_layer.backward(dout: dout)
layers = @layers.values.reverse
layers.inject(dout) do |dout, layer|
dout = layer.backward(dout: dout)
end
{
w1: @layers[:affine1].dw,
b1: @layers[:affine1].db,
w2: @layers[:affine2].dw,
b2: @layers[:affine2].db
}
end
end
initializeでは重みとバイアスパラメータの初期化と、各レイヤのインスタンスの生成を行なっています。重みとバイアスのパラメータを配列として保持しているのは、学習結果をTwoLayerNetクラス内のパラメータに反映したものを各レイヤで参照できるようにするためです。Affineクラスのインスタンス生成時にパラメータを w: @params[:w1]
という形で渡していますが、Rubyでは参照の値渡しになるため、TwoLayerNet側で @params[:w1] に計算結果を代入しても、 Affineインスタンス側では初期化時に渡されたパラメータを参照し続けます。そこで参照先を配列にして、計算結果はその配列の中身を更新する形にしています。書籍のPythonコードでは参照渡しになるため、配列として保持しなくてもTwoLayerNet側での変更がAffineインスタンス側で参照されています。
それと、パラメータをランダムに生成する前に、Numo::NArray.srandメソッドでseedが変わるようにしています。これをしないとスクリプト実行時に毎回同じ内容のパラメータが作成されてしまいます。
また、ニューラルネットワークでは各レイヤが実行される順番が重要なので、Pythonの場合は通常のDictionaryではなくOrderedDictを使っていますが、RubyのHashでは順番が保持されるため、通常のHashをそのまま使っています。
勾配の計算処理のgradientメソッドでは、まず順伝播の処理を行うためにlossメソッドを実行します。lossメソッドではpredictメソッドを呼び出し、injectメソッドで各レイヤのforward処理を実行し、順伝播の処理を行なっています。そして最後に last_layer変数に保持しているSoftmaxWithLossインスタンスの順伝播処理で交差エントロピー誤差を計算しています。
続いて逆伝播処理ではまずSoftmaxWithLossインスタンスの逆伝播処理を行なったあと、各レイヤを逆順に逆伝播処理を行ない、計算結果を返しています。
精度確認用のaccuracyメソッドでは、まず各レイヤの順伝播処理を行い、その結果一番確度の高い要素のインデックスと教師データを付き合わせて正解率を計算しています。max_indexメソッドでは各データの最大値を判定したいので、引数で1軸目を指定しています(0だと全ての要素の中からの最大値を判定する)。結果は行列全ての要素の中でのインデックス値を返すので、10で割ることで各データのインデックス値に変換しています。
eqメソッドでは一致する要素は1、一致しない要素は0のNumo::Bit配列を返します。正解数としてBitが1の要素の合計を計算するため、Numo::Bit配列をcast_toメソッドでInt配列に変換しています。この時、各ビット値は1か0なので、Int8でも正しく変換できますが、合計を計算するときにInt8の範囲を超えると正しく合計値が取得できません。今回の入力データの件数は60,000件あるので、Int8だと範囲を超えてしまうため、UInt16に変換した上で合計値を取得しています。
誤差逆伝播法の勾配確認
誤差逆伝播法で求めた勾配が正しいかどうかを確認するため、数値微分で求めた勾配と比較して確認します。
require './mnist.rb'
require './two_layer_net.rb'
x_train, t_train, x_test, t_test = load_mnist(normalize: true, one_hot_label: true)
network = TwoLayerNet.new(input_size: 784, hidden_size: 50, output_size: 10)
x_batch = x_train[0..2, true]
t_batch = t_train[0..2, true]
grad_numerical = network.numerical_gradients(x: x_batch, t: t_batch)
grad_backprop = network.gradient(x: x_batch, t: t_batch)
grad_numerical.keys.each do |key|
diff = (grad_backprop[key] - grad_numerical[key]).abs.mean
puts "#{key}: #{diff}"
end
MNISTデータの先頭3件を使い、数値微分での計算と誤差逆伝播法での計算を一度行い、それぞれの結果の差を計算しています。実行結果は下記のようになり、ほぼ差がないことが確認できます。
[vagrant@localhost vagrant]$ ruby gradient_check.rb
w1: 2.616843054226442e-13
b1: 8.347379796928845e-13
w2: 1.0232621862468414e-12
b2: 1.2012612987666316e-10
誤差逆伝播法を使った学習
前回の記事で実装したミニバッチ学習と評価を、誤差逆伝播法を使うように変更します。前回との違いはnumerical_gradientsメソッドではなくgradientメソッドを使うようにした点です。
require 'numo/narray'
require 'numo/gnuplot'
require './mnist.rb'
require './two_layer_net.rb'
x_train, t_train, x_test, t_test = load_mnist(normalize: true, one_hot_label: true)
network = TwoLayerNet.new(input_size: 784, hidden_size: 50, output_size: 10)
iters_num = 10_000
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1
train_loss_list = []
train_acc_list = []
test_acc_list = []
iter_per_epoch = [train_size / batch_size, 1].max
iters_num.times do |i|
Numo::NArray.srand
batch_mask = Numo::Int32.new(batch_size).rand(0, train_size)
x_batch = x_train[batch_mask, true]
t_batch = t_train[batch_mask, true]
grad = network.gradient(x: x_batch, t: t_batch)
%i(w1 b1 w2 b2).each do |key|
network.params[key][0] -= learning_rate * grad[key]
end
loss = network.loss(x: x_batch, t: t_batch)
train_loss_list << loss
next if i % iter_per_epoch != 0
train_acc = network.accuracy(x: x_train, t: t_train)
test_acc = network.accuracy(x: x_test, t: t_test)
train_acc_list << train_acc
test_acc_list << test_acc
puts "train acc, test acc | #{train_acc}, #{test_acc}"
end
x = (0..(train_acc_list.size - 1)).to_a
Numo.gnuplot do
plot x, train_acc_list, { w: :lines, t: 'train acc', lc_rgb: 'blue' },
x, test_acc_list, { w: :lines, t: 'test acc', lc_rgb: 'green' }
set xlabel: 'epochs'
set ylabel: 'accuracy'
set yrange: 0..1
end
実行結果は下記のようになります。シェルからスクリプトファイルを実行してもグラフの描画が行われなかったので、irbからロードすることで実行し、グラフが表示されるようにしています。
irb(main):001:0> load './train_neuralnet.rb'
train acc, test acc | 0.12578333333333333, 0.1225
train acc, test acc | 0.9026833333333333, 0.9071
train acc, test acc | 0.92225, 0.923
train acc, test acc | 0.9348833333333333, 0.9331
train acc, test acc | 0.9436, 0.9403
train acc, test acc | 0.9513666666666667, 0.9503
train acc, test acc | 0.9568833333333333, 0.9565
train acc, test acc | 0.9614666666666667, 0.9594
train acc, test acc | 0.96415, 0.9611
train acc, test acc | 0.9659, 0.9616
train acc, test acc | 0.9677833333333333, 0.9622
train acc, test acc | 0.97175, 0.9655
train acc, test acc | 0.97275, 0.9666
train acc, test acc | 0.9745666666666667, 0.968
train acc, test acc | 0.9764166666666667, 0.9682
train acc, test acc | 0.9780666666666666, 0.9696
train acc, test acc | 0.9788166666666667, 0.9704
=> true
実行速度
手元のVagrant環境で実行した結果、Ruby版とPython版のそれぞれの実行速度は下記のようになりました。
Ruby: 4分41秒
Python: 39秒
Python版の方がかなり早いです。今回はPython版をベースにRuby版を実装したので、パフォーマンスチューニングが可能かやってみたいところです。
コードは下記リポジトリでも公開しています。
github.com