• 474
    いいね
  • 6
    コメント
この記事は最終更新日から1年以上が経過しています。

背景

現在、TensorFlow、Chainer他多数のDeepLearning用ライブラリが公開されています。
本格的なアプリケーションで使うには実行スピード、クオリティ、拡張性、ドキュメント、コミュニティの充実等多くの面で、それらの中から選択して使用するのが鉄板な状況です。もちろん、私もメインではそれらを使わせてもらっています。これらのライブラリ、例えばtensorFlowではcomputatoin graphを構築、operationを追加してそれを実行というイメージで(行列、数式で取り扱うイメージ)、根底にある古典的なニューロンの結合という考え方が隠されている気がします。むしろ、そのことは忘れて突き進んでしまっても良い気はしますが、自分の理解を深める意味でもニューロン指向でスクラッチからニューラルネットワークを書いてみました。
使用言語は機械学習分野ではPythonに残念ながら遅れをとっているRubyを選択。
DeepLearningに興味があるけどガッツリではなく少しさわってみたい方、公開ライブラリがpython用のためなんとなく手を出していなかった方などの入り口になればうれしいです。

RubyBrain

今回使用するライブラリの名前はRubyBrainです。
背景でも書いたとおり現在主流のライブラリは使用者側からはニューロンを繋ぎあわせて人間の脳を模倣したネットワークを作って何かを学習させるという古典的なイメージは隠され、数式をつなぎ合わせてそのgraphを情報が流れて状態更新を行うイメージです。その点では、tensorFlow, Chainer共にネーミングセンスがとてもよいと感心します(特にChainer)。tensorFlowは次元が流れるイメージ、chainerは数式をつなぎ合わせていくイメージで、ライブラリの基本概念をうまく表現しています。
で、RubyBrainですが、名前から少しは想像できるかもしれませんが、古典的なニューロンのつなぎ合わせをイメージして実装してあります。ニューロンを表すclass Neuronを用意して、ネットワーク内の各ニューロンはNeuron classのインスタンスで表現します。また、Rubyの組み込み&標準ライブラリのみ使用して実装しており、入力、出力に使用するデータ構造もRuby標準のArrayです。ニューロン指向での実装の良い点は、各ニューロンを個別に操作して実験したい場合などそれほど難しくない(はず)。悪い点は、とにかく遅い。

使用環境、ライブラリ

今回作ったgemは、rubygems.orgにruby_brainとしてリリースしています。また、ソースコードはgithubに公開しています。
rubygems.org/gems/ruby_brain
github.com/elgoog/ruby_brain

ruby_brain本体はRubyの組込&標準ライブラリのみを使用して実装していますので環境準備に関しては大きな問題はないかと思います。以下の記事の内容はRuby 2.3.1で試していますが、他のバージョンでも問題なく動くはずです。
irb/pry等で順次入力して試してみるとよいかも。

インストール

gem install ruby_brain

iRuby notebook

この記事では3つのexampleを取り扱いますが、後半2つのexampleについては、iRuby notebookでも用意しました。
一部、本記事と違う部分もありますが、こちらのnotebookもご参照ください。
examples/wave_form - iRuby
examples/mnist - iRuby
本記事内の図はiRuby notebook上でnyaplotを使って描いたものを貼り付けています。

Example 1 - ANDネットワーク

まず、簡単な例として2入力、1出力のANDの動作を行うANDネットワークを構築してみます。
ANDの真理値表は以下のとおりです。
入力が両方共1の時に出力が1、それ以外の時は出力が0となることを期待しています。

in 1 in 2 out
0 0 0
0 1 0
1 0 0
1 1 1

データセット

ネットワークの学習に使うデータセットを準備します。
入力データ、出力データともに2次元Arrayになります。各次元はデータのサンプル、データのfeature(上記表の各カラム)に使用されます。

training_input_set = [
  [0, 0],
  [0, 1],
  [1, 0],
  [1, 1],
]

training_supervisor_set = [
  [0],
  [0],
  [0],
  [1],
]

training_input_setがネットワークへの入力データセット、それを入力した際に期待する出力データセットがtraining_supervisor_setです。

ネットワークの構築

ネットワークの構造はArrayで表します。
入力2、5つのニューロンをもつ隠れ層1、出力1のネットワーク構造は下記の通り表現できます。

# 2 inputs
# 5 units in a hidden layer
# 1 output
[2, 5, 1]

隠れ層が2層以上の場合も下記のように簡単に記述できます。

# 2 inputs
# 4 units in 1st hidden layer
# 3 units in 2nd hidden layer
# 1 output
[2, 4, 2, 1]

次に、実際にネットワークを作ってみます。
ここでは、ANDの入力が2つ、出力が1つなので、[2, 5, 1]の構成にします。
隠れ層は1つでニューロン5つを持つことにしました。

require 'ruby_brain'

# Netwworkクラスのコンストラクタにネットワーク構造Arrayを渡すことによりネットワークを作る
a_network = RubyBrain::Network.new([2, 5, 1])

# learning_rateをセットします。(私の設計ミスでこんなところで学習率を設定することになってます。。時間取れたら修正するかもしれません。)
a_network.learning_rate = 0.5

# ネットワークを使用する前に初期化する必要があります。ここで実際に内部で重み用Arrayを確保し、初期値を設定します
a_network.init_network

トレーニング

ネットワークのトレーニングはlearnメソッドに、入力データ、期待する出力データ(教師データ)を与えることにより、行います。

# max_training_cout : 最大で何回まで学習を行うかを設定
# tolerance : RMSエラーの許容値を設定。エラーがこの値より小さくなると学習会数がmax_training_countに達していなくても学習を終了する
# monitoring_channels : 学習中に何をログとして出力するかを設定。今のところ下記の設定をしておいてください。
a_network.learn(training_input_set, training_supervisor_set, max_training_count=3000, tolerance=0.0004, monitoring_channels=[:best_params_training])

メソッドの実行が終了したら、a_networkがAND動作を行うように最適化されているはずです。

ネットワークの動作確認

get_forward_outputsメソッドを使用することにより、ネットワークに入力を与えた場合の出力を得ることができます。
ANDのin1, in2に各入力を与えて確かめてみます。

a_network.get_forward_outputs([0, 0]) # => [0.00023268152328014436]
a_network.get_forward_outputs([0, 1]) # => [0.01829368167074594]
a_network.get_forward_outputs([1, 0]) # => [0.01900452216228691]
a_network.get_forward_outputs([1, 1]) # => [0.9727050287128143]

ネットワークが正しくANDの動作を行っていることが確認できます。
ここでの出力値は、上記とピッタリ同じになることはありません。ネットワーク内の重みの初期値がランダムに設定されているため、学習が終わった時点でネットワーク内の重みが私の環境と同じになる可能性は非常に低いためです。
しかし、[1, 1]を入力した場合に1に近い値、それ以外の入力の場合0に近い値になっているはずです。
以上でANDネットワークの構築完了です。

ネットワークの重みの保存&復元

上記の例のネットワークは非常に単純なものなので、トレーニングにも時間がかかりませんでしたが、
もっと大きな構造&大きなデータでトレーニングしてネットワークを作った場合、出来上がったネットワークの重みを再利用したい場合があるかと思います。
以下のメソッドで、保存&復元が可能です。フォーマットはYAMLで保存されます。

# 保存
a_network.dump_weights_to_yaml('/path/to/saved/weights/file.yml')

# 復元
a_network = RubyBrain::Network.new([2, 5, 1])
a_network.init_network
a_network.load_weights_from_yaml_file('/path/to/saved/weights/file.yml')

Example 2 - 波形の近似

次に少しデータ量を増やした例を見るため、適当な波形を作ってトレーニングデータとして使用してみます。
iRuby notebook版はこちら=> examples/wave_form - iRuby

ここで、しれっと書きますが、、
現在activation関数としてsigmoid関数を持つニューロンしか用意してません。。(完全な手抜きです。。)
なので最終層もsigmoidの出力0~1しか出力できないため、学習データセットも0~1の間で表現するように注意します。(入力側は0~1の範囲外でも大丈夫です。)

Xの値を0~1の範囲、0.01ステップで用意します。

X = (0..1).step(0.01).to_a

$(0.75 * sin(x*2*\pi) + 0.15 * cos(5*\pi – 0.023) + 1) / 2$をY_IDEALとして、
それにランダムでノイズ(-0.05..0.005)をのせたものをYとします。

Y_IDEAL = X.map {|x| (0.75 * Math.sin(x*2*Math::PI) - 0.2 * Math.cos(5*x*2*Math::PI - 0.023) + 1) / 2} 
Y = [Y_IDEAL, Array.new(X.size) {rand(-0.05..0.05)}].transpose.map {|e| e.inject(:+)}

私の環境で生成したX, Y, Y_IDEALをプロットしてみると以下のようになりました。
example2_1.png

このXをinput、Yをoutputとしてネットワークに学習させます。
ネットワークは構成は[1, 13, 6, 1]にしてみました。

a_network = RubyBrain::Network.new([1, 13, 6, 1])
a_network.init_network
a_network.learning_rate = 0.5
a_network.learn(X.map{|e| [e]}, Y.map{|e| [e]}, max_tra2ining_count=40000, tolerance=0.0004, monitoring_channels=[:best_params_training])

トレーニングしたa_networは下記のコードで確認できます。Y_PREDICATEDがa_networkにXを入れた時のoutputになります。

Y_PREDICATED = X.map{|e| [e]}.map {|a| a_network.get_forward_outputs(a).first}

私の環境での結果をプロットすると下図のようになりました。
良い感じで模倣しています。
fig(1).png

Example 3 - MNIST

Exampleの最後としてMNISTを試してみます。28*28ピクセルの手書き数字画像を0~9にクラス分けする機械学習のHelloWorld的なチュートリアルです。
iRuby notebook版はこちら => examples/mnist - iRuby

MNISTデータの取得

付属の便利メソッドを使用するとMNISTのデータを簡単にRubyのArrayに入れることができます。
データは THE MNIST DATABASE of handwritten digits より、取得しています。
また、データの読み込みにはmrknさんのgem mnistを使用させていただきました。

require 'ruby_brain'

# MNISTのデータセットを取り扱う便利メソッドをロード
require 'ruby_brain/dataset/mnist/data'

下記のように、DataSet::Mnist::dataメソッドを実行するとMNISTのデータをArrayとして取得できます。
datasetはArrayで、training用データセットとtest用データセットを持っています。

dataset = RubyBrain::DataSet::Mnist::data
training_dataset = dataset.first
test_dataset = dataset.last

各データセットは、以下のようになっています

# :input, :outputをkeyとするHashになっています。
training_dataset.keys # => [:input, :output]
test_dataset.keys     # => [:input, :output]

# トレーニングデータセットの :input は 60000(samples) x 784(28 * 28 input pixcels)
training_dataset[:input].size       # => 60000
training_dataset[:input].first.size # => 784

# トレーニングデータセットの :output は 60000(samples) x 10(0~9の10クラス)
training_dataset[:output].size       # => 60000
training_dataset[:output].first.size # => 10

# テストデータセットの:input は 10000(samples) x 784(28 * 28 input pixcels)
test_dataset[:input].size       # => 10000
test_dataset[:input].first.size # => 784

# テストデータセットの :output は 10000(samples) x 10(0~9の10クラス)
test_dataset[:output].size       # => 10000
test_dataset[:output].first.size # => 10

この例ではtraining_datasetの最初の5000サンプルのみ実際のトレーニングに使用します。RubyBrainが遅く、全70000枚の画像を使用すると学習に時間がかかりすぎるためです。

# 最初の5000枚のみトレーニングに使用
NUM_TRAIN_DATA = 5000
training_input = training_dataset[:input][0..(NUM_TRAIN_DATA-1)]
training_supervisor = training_dataset[:output][0..(NUM_TRAIN_DATA-1)]
# test_datasetはすべて使用
test_input = test_dataset[:input]
test_supervisor = test_dataset[:output]

ネットワークの構築&トレーニング実行

ここでは画像が784(28x28)ピクセルで10クラス(0..9)分類です。
隠れ層は1層で50個のニューロンを持つとして、[784, 50, 10]として構成しました。

# ネットワーク構成 [784, 50, 10]
network = RubyBrain::Network.new([training_input.first.size, 50, training_supervisor.first.size])
# learning rate is 0.7
network.learning_rate = 0.7
# initialize network
network.init_network

トレーニングの実行

network.learn(training_input, training_supervisor, max_training_count=100, tolerance=0.0004, monitoring_channels=[:best_params_training])

結果の確認

下記のコードで、トレーニングしたnetworkをtest_datasetを使って評価できます。
画像1枚分のデータ(input)をnetworkに入力した際の出力がpredicated_outputです。
このpredicated_outputはサイズ10のArrayで、そのindex 0~9がラベル0~9に対応します。
そして、最も大きい要素を持つindexがnetworkの予想したラベル(数字)となります。
下記のコードでは、手書き画像を簡易的にasciiで表示しています。
フルで走らすと10000枚分がstdoutに出力され、非常に時間がかかるので気をつけてください。
最後のaccuracyが認識率です。

test_input.each_with_index do |input, i|
  input.each_with_index do |e, j|
    print(e > 0.3 ? 'x' : ' ')
    puts if (j % 28) == 0
  end
  puts
  supervisor_label = test_supervisor[i].index(test_supervisor[i].max)
  predicated_output = network.get_forward_outputs(input)
  predicated_label = predicated_output.index(predicated_output.max)
  puts "test_supervisor: #{supervisor_label}"
  puts "predicate: #{predicated_label}"
  results << (supervisor_label == predicated_label)
  puts "------------------------------------------------------------"
end

puts "accuracy: #{results.count(true).to_f/results.size}"

実際にどんな画像で誤判定をしたかみてみると、なぜこれを間違うのかってのもいくつか見られましたが、多くが確かに間違える可能性ありそうな画像でした。以下、判定を間違ったケースの一部です。

mnist_1_5_2.png
test_supervisor[8] : 5
predicated_class : 2
 -------------------------------------------------------------------------------------

mnist_2_4_6.png
test_supervisor[33] : 4
predicated_class : 6
 -------------------------------------------------------------------------------------

mnist_3_6_2.png
test_supervisor[66] : 6
predicated_class : 2
 -------------------------------------------------------------------------------------

mnist_4_9_7.png
test_supervisor[73] : 9
predicated_class : 7
 -------------------------------------------------------------------------------------

mnist_5_9_4.png
test_supervisor[92] : 9
predicated_class : 4
 -------------------------------------------------------------------------------------

mnist_6_9_5.png
test_supervisor[104] : 9
predicated_class : 5
 -------------------------------------------------------------------------------------

mnist_7_2_9.png
test_supervisor[149] : 2
predicated_class : 9
 -------------------------------------------------------------------------------------

mnist_8_9_5.png
test_supervisor[151] : 9
predicated_class : 5
 -------------------------------------------------------------------------------------

私の環境では上記の条件で、何回かためしてみて0.9312(93.12%)の認識率になりました。
出来上がった重みは こちら

最後に

本記事のタイトルにディープラーニングとついていますが、ReLUすら実装していないのでかなりつりっぽいタイトルになっていてすみませんm(_ _)m
MNISTの認識率が予想外に簡単に90%を超えることができました。やはりここから更に上げていくのが難しいんでしょうね。

やりたかったけどやらなかったこと

  • ランダムノイズをのせるときに、SciRuby/distributionを使って、ガウス分布でのせる => 今回、図の生成に初めてnyaplotを使ってみたところ非常に使いやすかったので、他のSciRubyプロジェクトにも興味がでてきました。見てみたところ面白いプロジェクトが結構あったので今後機会があれば使ってみたい。

疑問

この記事を書いてて、いくつか不明な点があったのでご存知の方がいましたら教えていただけると助かります。
この記事の趣旨とは関係ない内容ですが。。

  • iruby notebookの各cellのoutputを表示させないようにする方法 => バックエンドがipythonの場合、%%captureを使えるらしいですが、irubyの場合どうするのかわからなかったので、とりあえずnilを最後に付加して対処。
  • nyaplotのheatmapのgridが長方形になってしまうのを正方形にする方法