パーセプトロンによる二項分類のコード

 今回は下記書籍のパーセプトロンによる二項分類のコードを Ruby で実装してみたいと思います。

ITエンジニアのための機械学習理論入門
https://www.amazon.co.jp/IT-ebook/dp/B016Q22IX2/www.amazon.co.jp

 サンプルコードはこちらで公開されています。

github.com

データセットの用意

 サンプルコードでは下記のようにデータセットを用意するためのコードが実装されています。

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 を使用しました。

Numerical Ruby NArray

github.com

 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 上で実行すると下記のようなグラフが表示されます。

f:id:akanuma-hiroaki:20170118083001p:plain

 書籍のサンプルと近い形になっているのであっていそうな気はしていますが、今回は特に理解が怪しいので、間違いなどありましたらご指摘いただければと思います。

 コードは下記にも公開しました。

github.com