ニューラルネットワークの数値微分による学習アルゴリズムの実装

 今回も引き続き「ゼロから作るDeepLearning」をベースに、数値微分による学習アルゴリズムをRubyで実装してみました。

www.oreilly.co.jp

 最初に書いておくと、今回の数値微分での実装は、実装はシンプルなもののその分処理に時間がかかり、手元の環境では繰り返し処理を終わらせることができませんでした。次回以降で書籍の次の章で解説されている、誤差逆伝播法での実装に変更してみたいと思います。

2層ニューラルネットワークのクラス

 書籍のPython実装に基づき、2層のニューラルネットワークをTwoLayerNetという一つのクラスとして実装します。まずはコード全体です。

require 'numo/narray'
require './softmax.rb'
require './sigmoid.rb'
require './cross_entropy_error.rb'
require './numerical_gradient.rb'

class TwoLayerNet
  def initialize(input_size, hidden_size, output_size, weight_init_std = 0.01)
    @params = {}
    @params[:w1] = weight_init_std * Numo::DFloat.new(input_size, hidden_size).rand_norm
    @params[:b1] = Numo::DFloat.zeros(hidden_size)
    @params[:w2] = weight_init_std * Numo::DFloat.new(hidden_size, output_size).rand_norm
    @params[:b2] = Numo::DFloat.zeros(output_size)
  end

  def params
    @params
  end

  def predict(x)
    w1 = @params[:w1]
    w2 = @params[:w2]
    b1 = @params[:b1]
    b2 = @params[:b2]

    a1 = x.dot(w1) + b1
    z1 = sigmoid(a1)
    a2 = z1.dot(w2) + b2
    softmax(a2)
  end

  def loss(x, t)
    y = predict(x)
    cross_entropy_error(y, t)
  end

  def accuracy(x, t)
    y = predict(x)
    y = y.max_index(1) % 10
    t = t.max_index(1) % 10

    y.eq(t).cast_to(Numo::UInt32).sum / x.shape[0].to_f
  end

  def numerical_gradients(x, t)
    loss_w = lambda {|w| loss(x, t) }

    grads = {}
    grads[:w1] = numerical_gradient(loss_w, @params[:w1])
    grads[:b1] = numerical_gradient(loss_w, @params[:b1])
    grads[:w2] = numerical_gradient(loss_w, @params[:w2])
    grads[:b2] = numerical_gradient(loss_w, @params[:b2])

    grads
  end
end

初期化

 まずはinitializeメソッドで重みとバイアスのパラメータを初期化し、インスタンス変数にHashとして保持します。合わせて外部からパラメータを参照するためのメソッドも用意しておきます。

def initialize(input_size, hidden_size, output_size, weight_init_std = 0.01)
  @params = {}
  @params[:w1] = weight_init_std * Numo::DFloat.new(input_size, hidden_size).rand_norm
  @params[:b1] = Numo::DFloat.zeros(hidden_size)
  @params[:w2] = weight_init_std * Numo::DFloat.new(hidden_size, output_size).rand_norm
  @params[:b2] = Numo::DFloat.zeros(output_size)
end

def params
  @params
end

 initializeメソッドの引数には、入力層のニューロン数、隠れ層のニューロン数、出力層のニューロン数を渡します。

 重みの初期値の生成は、まず Numo::DFloat.new で “入力層ニューロン数 x 隠れ層ニューロン数” もしくは “隠れ層ニューロン数 x 出力層ニューロン数” の行列を用意し、rand_normメソッドで標準正規分布に従う乱数を設定します。

Class: Numo::DFloat — Documentation by YARD 0.9.8

 バイアスの初期値は Numo::DFloat.zeros メソッドで全てゼロの配列を用意します。

Class: Numo::NArray — Documentation by YARD 0.9.8

推論処理

 画像データを引数にとり、推論処理を行います。

def predict(x)
  w1 = @params[:w1]
  w2 = @params[:w2]
  b1 = @params[:b1]
  b2 = @params[:b2]

  a1 = x.dot(w1) + b1
  z1 = sigmoid(a1)
  a2 = z1.dot(w2) + b2
  softmax(a2)
end

 処理内容としては前回の記事で実装したものと同じで、隠れ層の活性化関数にシグモイド関数、出力層の活性化関数にソフトマックス関数を使用しています。

def sigmoid(x)
  1 / (1 + Numo::DFloat::Math.exp(-x))
end
def softmax(a)
  if a.ndim == 2
    a = a.transpose
    a = a - a.max(0)
    y = Numo::DFloat::Math.exp(a) / Numo::DFloat::Math.exp(a).sum(0)
    return y.transpose
  end

  c = a.max
  exp_a = Numo::DFloat::Math.exp(a - c)
  sum_exp_a = exp_a.sum
  exp_a / sum_exp_a
end

損失関数

 入力データ(画像データ)と教師データ(正解ラベル)を引数にとり、損失関数の値を求めます。

def loss(x, t)
  y = predict(x)
  cross_entropy_error(y, t)
end

 画像データからpredictメソッドで推論を行なった結果と正解ラベルから交差エントロピー誤差を求めています。

def cross_entropy_error(y, t)
  if y.ndim == 1
    t = t.reshape(1, t.size)
    y = y.reshape(1, y.size)
  end

  batch_size = y.shape[0]
  -(t * (Numo::DFloat::Math.log(y))).sum / batch_size # one-hot表現用
end

認識精度の計算

 入力データ(画像データ)と教師データ(正解ラベル)を引数にとり、認識精度を計算します。

def accuracy(x, t)
  y = predict(x)
  y = y.max_index(1) % 10
  t = t.max_index(1) % 10

  y.eq(t).cast_to(Numo::UInt32).sum / x.shape[0].to_f
end

 画像データからpredictメソッドで推論を行なった結果は0〜9の数字である確度(確率)の配列として返されるため、どの数字である確率が最も高いかを max_index メソッドを用いて取得します。推論はバッチ処理で行うため、複数の画像データに対する結果として二次元配列として返ってくるので、二次元目を基準に最大値を求めるため、max_indexの引数には1を渡しています(0次元目、1次元目と考えるため)。ただし、max_index では多次元配列の場合、全ての要素数に対してのインデックス値(10 x 10の配列だったら0〜99の値)として戻ってくるため、10で割ったあまりを求めることで、0〜9のラベルを表すようにしています。

y = y.max_index(1) % 10

 これは正解ラベルについても同様で、 one-hot表現で渡されている複数データについての正解ラベルは二次元配列なので、10で割ることで0〜9のラベルを表すようにしています。

t = t.max_index(1) % 10

 そして最後に推論結果のラベル配列と正解ラベル配列をeqメソッドで比較し、正解数(配列の値が1になっている数)を入力データの数で割ることで、正解率を求めています。この辺りのeqメソッドの使い方等は前回記事と同様です。

重みパラメータに対する勾配の計算

 入力データ(画像データ)と教師データ(正解ラベル)を引数にとり、各パラメータに対する勾配を計算します。

def numerical_gradients(x, t)
  loss_w = lambda {|w| loss(x, t) }

  grads = {}
  grads[:w1] = numerical_gradient(loss_w, @params[:w1])
  grads[:b1] = numerical_gradient(loss_w, @params[:b1])
  grads[:w2] = numerical_gradient(loss_w, @params[:w2])
  grads[:b2] = numerical_gradient(loss_w, @params[:b2])

  grads
end

 損失関数を計算結果を取得するlambdaを用意し、勾配計算用のnumerical_gradientメソッドに各パラメータ共に渡し、結果をHashに格納して返します。

def numerical_gradient(f, x)
  h = 1e-4
  grad = Numo::DFloat.zeros(x.shape)

  x.size.times do |i|
    tmp_val = x[i]

    x[i] = tmp_val + h
    fxh1 = f.call(x)

    x[i] = tmp_val - h
    fxh2 = f.call(x)

    grad[i] = (fxh1 - fxh2) / (2 * h)
    x[i] = tmp_val
  end

  grad
end

 numerical_gradientメソッドでは中心差分での数値微分によって各パラメータの勾配を計算します。まず結果の格納用に入力パラメータと同じ形のゼロ配列を用意します。そして入力パラメータの各要素について (f(x + h) - f(x - h)) / 2h を計算して結果格納用の配列に格納し、最後にその配列を返しています。Numo::NArrayの多次元配列では単一のインデックスで配列の内容を参照する場合、全要素をフラットに並べた時のインデックス値を意味するので、x.size で全要素数を取得して、timesでその要素数分ループを回して、順次配列の内容を参照するようにしています。

ミニバッチ学習と評価の実装

 TwoLayerNetクラスを使ってMNISTデータセットを用いた学習と評価を行います。学習の実装は、訓練データから無作為に一部のデータを取り出して入力データとする、ミニバッチ学習で行います。まずはコード全体です。

require 'numo/narray'
require 'numo/gnuplot'
require './mnist.rb'
require './two_layer_net.rb'

# データの読み込み
x_train, t_train, x_test, t_test = load_mnist(true, true, true)

network = TwoLayerNet.new(784, 50, 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|
  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.numerical_gradients(x_batch, t_batch)

  # パラメータの更新
  %i(w1 b1 w2 b2).each do |key|
    network.params[key] -= learning_rate * grad[key]
  end

  loss = network.loss(x_batch, t_batch)
  train_loss_list << loss

  next if i % iter_per_epoch != 0

  train_acc = network.accuracy(x_train, t_train)
  test_acc = network.accuracy(x_test, 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

MNISTデータの読み込みとパラメータ初期化

 まずは以前実装したMNISTデータのロード処理を使ってMNISTデータを読み込んだ後、TwoLayerNetの初期化と、繰り返し数等の設定を行います。

x_train, t_train, x_test, t_test = load_mnist(true, true, true)

network = TwoLayerNet.new(784, 50, 10)

iters_num = 10_000 # 繰り返し回数
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1

 ニューロン数の構成は、入力層は28 x 28の画像データなので 784、出力層は0-9の数字を表すので 10、隠れ層は50としています。また、確率勾配降下法によるパラメータ更新の繰り返し数は10,000回で、ミニバッチのサイズは100としています。

ミニバッチデータの取得

 繰り返し処理の中では、まず60,000件の画像データの中からミニバッチデータを取得しています。

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]

 Numo::Int32.new でバッチサイズ分の要素数の配列を用意し、randメソッドで0以上60,000未満のランダム値を生成しています。これによって、60,000件の画像データのどのインデックス値のデータを取得するかを決めています。そしてそのインデックス値に該当する画像データと正解ラベルを取得していますが、それぞれ 60,000 x 784、60,000 x 10 の二次元配列になっているため、ランダムに生成したインデックス値の配列が一次元目を表すことを意味するように、二次元目はtrueを指定し、該当する二次元目のデータは全て取得するようにしています。

勾配の計算とパラメータの更新

 ミニバッチデータをTwoLayerNetの勾配計算メソッドに渡して勾配データを取得し、学習率をかけたものを各パラメータから引くことで、各パラメータを更新します。

# 勾配の計算
grad = network.numerical_gradients(x_batch, t_batch)

# パラメータの更新
%i(w1 b1 w2 b2).each do |key|
  network.params[key] -= learning_rate * grad[key]
end

損失関数の計算と記録

 ミニバッチデータから損失関数を計算し、繰り返しごとの結果を格納し、経過を記録します。

loss = network.loss(x_batch, t_batch)
train_loss_list << loss

認識精度の計算

 トレーニングデータとテストデータに対する認識精度を計算します。

next if i % iter_per_epoch != 0

train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list << train_acc
test_acc_list << test_acc
puts "train acc, test acc | #{train_acc}, #{test_acc}"

 繰り返しごとに毎回認識精度を計算すると時間がかかってしまうため、1エポック(学習において全ての訓練データを使い切った時の回数。今回は60,000件の訓練データに対してミニバッチサイズが100なので、 60,000 / 100 = 600回)ごとに認識精度を計算してその値を結果配列に格納し、コンソールにも表示します。

認識精度推移のグラフ描画

 最後に認識精度の推移をグラフに描画します。

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

 グラフの描画にはgnuplotを使い、Rubyでgnuplotを扱うためのgemとして、Numo::Gnuplot を使わせていただきました。

github.com

 こちらも参考にさせていただきました。

MF / 【Ruby】numo-gnuplotで遊ぶ

 以前に Jupyter Notebook 上で Nyaplot を使わせてもらっていたことはあるのですが、ターミナル上で動かして手軽にグラフを表示したいというときは Numo::Gnuplot が手軽に使えて良さそうです。

次は誤差逆伝播法

 冒頭でも書きましたが、今回実装はしたものの、繰り返しの一回の処理でもかなり時間がかかり、10,000回の繰り返しは現実的な時間では終わりそうもありませんでした。書籍でも言われていますが、数値微分による実装は実装はシンプルなものの処理にはかなり時間がかかるので、次は誤差逆伝播法での実装で試してみたいと思います。

 今回のコードは下記リポジトリで公開してあります。

github.com