今回は下記書籍のパーセプトロンによる二項分類のコードを Ruby で実装してみたいと思います。
ITエンジニアのための機械学習理論入門
https://www.amazon.co.jp/IT-ebook/dp/B016Q22IX2/www.amazon.co.jp
サンプルコードはこちらで公開されています。
データセットの用意
サンプルコードでは下記のようにデータセットを用意するためのコードが実装されています。
N1 = 20 # クラス t=+1 のデータ数 Mu1 = [15,10] # クラス t=+1 の中心座標 N2 = 30 # クラス t=-1 のデータ数 Mu2 = [0,0] # クラス t=-1 の中心座標 Variances = [15,30] # 両クラス共通の分散(2種類の分散で計算を実施) # データセット {x_n,y_n,type_n} を用意 def prepare_dataset(variance): cov1 = np.array([[variance,0],[0,variance]]) cov2 = np.array([[variance,0],[0,variance]]) df1 = DataFrame(multivariate_normal(Mu1,cov1,N1),columns=['x','y']) df1['type'] = 1 df2 = DataFrame(multivariate_normal(Mu2,cov2,N2),columns=['x','y']) df2['type'] = -1 df = pd.concat([df1,df2],ignore_index=True) df = df.reindex(np.random.permutation(df.index)).reset_index(drop=True) return df
このコードの目的としては、クラス t = +1 のデータと t = -1 のデータが混在したデータセットを返すことで、 クラス t = +1 は {x, y} = {15, 10} を中心とし、クラス t = -1 は {x, y} = {0, 0} を中心とした、 分散15もしくは30の場合の二次元正規分布乱数のセットです。
処理の内容としては、 multivariate_normal メソッドで二次元正規分布乱数の配列を生成して それぞれのクラス用のDataFrameを用意し、結合してランダムにシャッフルしてインデックスを振り直しています。
multivariate_normal は平均の配列と共分散行列を引数に取り、多次元正規分布乱数を生成してくれます。
numpy.random.multivariate_normal — NumPy v1.11 Manual
こちらも参考にしました。
3次元正規分布を Axes3D で描画-python | コード7区
今回のケースでは平均の配列としてそれぞれのクラスの x, y の中心座標値を渡しており、 共分散行列としてはどちらも共通で [[variance,0],[0,variance]] という 2 x 2 行列を渡しています。 この場合対角成分であるvarianceはそれぞれxの分散、yの分散ということになり、 また、xyの共分散は0ということになりますのでxとyの間に相関はないということを表しているという理解です。
最初は multivariate_normal に該当する処理をrubyで探したものの、ライブラリ等は見つからず、 自前実装にも multivariate_normal の内容の理解が足りなかったのですが、今回のケースであれば、 xyに相関はなく、xとyで独立して正規分布乱数を生成すれば問題ないのではないかと思ったので、 下記のようにデータセット生成処理を実装しました。
N1 = 20 Mu1 = [15, 10] N2 = 30 Mu2 = [0, 0] def normal_rand(mu = 0, sigma = 1.0) random = Random.new (Math.sqrt(-2 * Math.log(random.rand)) * Math.sin(2 * Math::PI * random.rand) * sigma) + mu end # データセット {x_n,y_n,type_n} を用意 def prepare_dataset(variance) sigma = Math.sqrt(variance) df1 = N1.times.map do [normal_rand(Mu1[0], sigma), normal_rand(Mu1[1], sigma)] end df1 = df1.transpose df1 = Daru::DataFrame.new(x: df1[0], y: df1[1], type: Array.new(N1).fill(1)) df2 = N2.times.map do [normal_rand(Mu2[0], sigma), normal_rand(Mu2[1], sigma)] end df2 = df2.transpose df2 = Daru::DataFrame.new(x: df2[0], y: df2[1], type: Array.new(N2).fill(-1)) df = df1.concat(df2) df = df.reindex(Daru::Index.new(df.index.to_a.shuffle)) df[:index] = (N1 + N2).times.to_a df.set_index(:index) end
normal_rand メソッドは前回まででも実装した内容と同じで、正規分布の乱数を生成します。引数としては平均と標準偏差を取ります。 prepare_database メソッドに渡される variance は分散なので、平方根を求めて偏差 sigma として normal_rand に渡しています。
Perceptronのアルゴリズム(確率的勾配降下法)を実行
該当メソッドのコードとしては下記のように実装しました。
まずはデータセットのプロット部分。
# Perceptronのアルゴリズム(確率的勾配降下法)を実行 def run_simulation(variance) data_graph = Nyaplot::Plot.new param_graph = Nyaplot::Plot.new train_set = prepare_dataset(variance) train_set1 = train_set.filter_rows {|row| row[:type] == 1 } train_set2 = train_set.filter_rows {|row| row[:type] == -1 } ymin = train_set.y.min - 5 xmin = train_set.x.min - 5 ymax = train_set.y.max + 10 xmax = train_set.x.max + 10 data_graph.configure do x_label('') y_label('') xrange([xmin, xmax]) yrange([ymin, ymax]) legend(true) height(300) width(490) end scatter_true = data_graph.add_with_df(train_set1.to_nyaplotdf, :scatter, :x, :y) scatter_true.color('green') scatter_true.title('1') scatter_false = data_graph.add_with_df(train_set2.to_nyaplotdf, :scatter, :x, :y) scatter_false.color('orange') scatter_false.title('-1')
データセットから filter_rows メソッドでクラス t = 1 のセットとクラス t = -1 のセットに分け、 それぞれのセットのデータを一つの図にプロットしています。
次に確率的勾配降下法による処理で30回のIterationを回して各パラメータを決定している部分です。
# パラメータの初期値とbias項の設定 w0 = 0.0 w1 = 0.0 w2 = 0.0 bias = 0.5 * (train_set.x.mean + train_set.y.mean) # Iterationを30回実施 paramhist = Daru::DataFrame.new(w0: [w0], w1: [w1], w2: [w2]) 10.times do train_set.each_row do |point| x = point.x y = point.y type = point[:type] if type * (w0*bias + w1*x + w2*y) <= 0 w0 += type * bias w1 += type * x w2 += type * y end end paramhist.add_row(Daru::Vector.new([w0, w1, w2], index: [:w0, :w1, :w2])) end
各パラメータの初期値を入れたDataFrameを用意しておき、Iterationごとにadd_rowで各パラメータ値をVectorとして追加しています。
そして次にその結果について判定誤差の割合を計算します。
# 判定誤差の計算 err = 0 train_set.each_row do |point| x = point.x y = point.y type = point[:type] if type * (w0*bias + w1*x + w2*y) <= 0 err += 1 end end err_rate = err * 100 / train_set.size
決定されたパラメータでデータセットの各値に対して判定を行い、エラーになった数の割合を計算しています。
そして最後にここまでの結果をグラフに表示します。
# 結果の表示 linex = Numo::NArray[*(xmin.to_i-5..xmax.to_i+4).to_a] liney = -linex * w1 / w2 - bias * w0 / w2 label = "ERR %.2f%%" % err_rate line_err = data_graph.add(:line, linex.cast_to(Numo::Int64).to_a, liney.cast_to(Numo::Int64).to_a) line_err.title(label) line_err.color('red') line_w0 = param_graph.add(:line, paramhist.index.to_a, paramhist.w0) line_w0.title('w0') line_w0.color('blue') line_w1 = param_graph.add(:line, paramhist.index.to_a, paramhist.w1) line_w1.title('w1') line_w1.color('green') line_w2 = param_graph.add(:line, paramhist.index.to_a, paramhist.w2) line_w2.title('w2') line_w2.color('red') param_graph.configure do x_label('') y_label('') legend(true) height(300) width(490) end [data_graph, param_graph] end
元の python のコードでは、 linex を numpy.arange メソッドを使って numpy.ndarray クラスのオブジェクトとして取得しています。 numpy.ndarray は 多次元配列を扱うためのクラスです。
numpy.ndarray — NumPy v1.12 Manual
ruby では同様のことを行うために、下記サイトを参考にして NArray を使用しました。
linex を NArray のオブジェクトとして生成し、各パラメータ値による計算を行い、liney を求めています。
NArrayではオブジェクトが生成されるときに各値のデータ型は自動的に判定されます。今回のケースでは Numo::Int32 として判定されたのですが、そのオブジェクトを to_a で通常の配列に変換すると正しく変換されなかったため、一旦 Numo::Int64 に変換した上で配列に変換しました。
irb(main):024:0* Numo::NArray[-3, -2, -1, 0, 1, 2, 3] => Numo::Int32#shape=[7] [-3, -2, -1, 0, 1, 2, 3] irb(main):025:0> Numo::NArray[-3, -2, -1, 0, 1, 2, 3].to_a => [4294967293, 4294967294, 4294967295, 0, 1, 2, 3] irb(main):026:0> Numo::NArray[-3, -2, -1, 0, 1, 2, 3].cast_to(Numo::Int64).to_a => [-3, -2, -1, 0, 1, 2, 3]
スクリプト全体
スクリプト全体としては下記のようになります。
require 'daru' require 'nyaplot' require 'numo/narray' N1 = 20 Mu1 = [15, 10] N2 = 30 Mu2 = [0, 0] Variances = [15, 30] class Array def mean self.inject(:+) / self.size.to_f end end def normal_rand(mu = 0, sigma = 1.0) random = Random.new (Math.sqrt(-2 * Math.log(random.rand)) * Math.sin(2 * Math::PI * random.rand) * sigma) + mu end # データセット {x_n,y_n,type_n} を用意 def prepare_dataset(variance) sigma = Math.sqrt(variance) df1 = N1.times.map do [normal_rand(Mu1[0], sigma), normal_rand(Mu1[1], sigma)] end df1 = df1.transpose df1 = Daru::DataFrame.new(x: df1[0], y: df1[1], type: Array.new(N1).fill(1)) df2 = N2.times.map do [normal_rand(Mu2[0], sigma), normal_rand(Mu2[1], sigma)] end df2 = df2.transpose df2 = Daru::DataFrame.new(x: df2[0], y: df2[1], type: Array.new(N2).fill(-1)) df = df1.concat(df2) df = df.reindex(Daru::Index.new(df.index.to_a.shuffle)) df[:index] = (N1 + N2).times.to_a df.set_index(:index) end # Perceptronのアルゴリズム(確率的勾配降下法)を実行 def run_simulation(variance) data_graph = Nyaplot::Plot.new param_graph = Nyaplot::Plot.new train_set = prepare_dataset(variance) train_set1 = train_set.filter_rows {|row| row[:type] == 1 } train_set2 = train_set.filter_rows {|row| row[:type] == -1 } ymin = train_set.y.min - 5 xmin = train_set.x.min - 5 ymax = train_set.y.max + 10 xmax = train_set.x.max + 10 data_graph.configure do x_label('') y_label('') xrange([xmin, xmax]) yrange([ymin, ymax]) legend(true) height(300) width(490) end scatter_true = data_graph.add_with_df(train_set1.to_nyaplotdf, :scatter, :x, :y) scatter_true.color('green') scatter_true.title('1') scatter_false = data_graph.add_with_df(train_set2.to_nyaplotdf, :scatter, :x, :y) scatter_false.color('orange') scatter_false.title('-1') # パラメータの初期値とbias項の設定 w0 = 0.0 w1 = 0.0 w2 = 0.0 bias = 0.5 * (train_set.x.mean + train_set.y.mean) # Iterationを30回実施 paramhist = Daru::DataFrame.new(w0: [w0], w1: [w1], w2: [w2]) 30.times do train_set.each_row do |point| x = point.x y = point.y type = point[:type] if type * (w0*bias + w1*x + w2*y) <= 0 w0 += type * bias w1 += type * x w2 += type * y end end paramhist.add_row(Daru::Vector.new([w0, w1, w2], index: [:w0, :w1, :w2])) end # 判定誤差の計算 err = 0 train_set.each_row do |point| x = point.x y = point.y type = point[:type] if type * (w0*bias + w1*x + w2*y) <= 0 err += 1 end end err_rate = err * 100 / train_set.size # 結果の表示 linex = Numo::NArray[*(xmin.to_i-5..xmax.to_i+4).to_a] liney = -linex * w1 / w2 - bias * w0 / w2 label = "ERR %.2f%%" % err_rate line_err = data_graph.add(:line, linex.cast_to(Numo::Int64).to_a, liney.cast_to(Numo::Int64).to_a) line_err.title(label) line_err.color('red') line_w0 = param_graph.add(:line, paramhist.index.to_a, paramhist.w0) line_w0.title('w0') line_w0.color('blue') line_w1 = param_graph.add(:line, paramhist.index.to_a, paramhist.w1) line_w1.title('w1') line_w1.color('green') line_w2 = param_graph.add(:line, paramhist.index.to_a, paramhist.w2) line_w2.title('w2') line_w2.color('red') param_graph.configure do x_label('') y_label('') legend(true) height(300) width(490) end [data_graph, param_graph] end fig = Nyaplot::Frame.new Variances.each do |variance| data_graph, param_graph = run_simulation(variance) fig.add(data_graph) fig.add(param_graph) end fig.show
これを Jupyter Notebook 上で実行すると下記のようなグラフが表示されます。
書籍のサンプルと近い形になっているのであっていそうな気はしていますが、今回は特に理解が怪しいので、間違いなどありましたらご指摘いただければと思います。
コードは下記にも公開しました。