ロジスティック回帰のROC曲線のコード

 今回は下記書籍のロジスティック回帰のROC曲線のコードを Ruby で実装してみたいと思います。

ITエンジニアのための機械学習理論入門

www.amazon.co.jp

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

github.com

データセットの更新

 Python版では計算結果の確率をデータセットに反映する際に、下記のように pandas.DataFrame.ix メソッドを使って対象を特定し、値を代入しています。

p = 1.0/(1.0+np.exp(-a))
training_set.ix[index, 'probability'] = p

pandas.DataFrame.ix — pandas 0.19.2 documentation

 Ruby版ではデータの取り出しと代入を一度では行えなさそうだったので、 Daru::DataFrame#row で Vector オブジェクトを取得し、値を更新した上で Daru::DataFrame#set_row_at で更新するやり方にしてみました。

p = 1.0 / (1.0 + Math.exp(-a))
v = train_set.row[index]
v[:probability] = p
train_set.set_row_at([index], v)

Method: Daru::DataFrame#row — Documentation for daru (0.1.4.1)

Method: Daru::DataFrame#set_row_at — Documentation for daru (0.1.4.1)

データセットのソート

 Python版では pandas.DataFrame.sort_values メソッドを使ってデータセットを確率の高い順にソートしています。

result = training_set.sort_values(by=['probability'], ascending=[False])

pandas.DataFrame.sort_values — pandas 0.19.2 documentation

 Ruby版では Daru::DataFrame#sort メソッドを使ってソートしました。

sorted_train_set = train_set.sort([:probability], ascending: false)

Method: Daru::DataFrame#sort — Documentation for daru (0.1.4.1)

Class: Daru::DataFrame — Documentation for daru (0.1.4.1)

Indexの振り直し

 Python版ではソート後のデータセットのインデックスを pandas.DataFrame.reset_index メソッドで振り直しています。

return result.reset_index()

pandas.DataFrame.reset_index — pandas 0.19.2 documentation

 Ruby版では単純にインデックスを振り直すやり方がみつからなかったので、Indexに入れる値を別の列のデータとして追加した後、 Daru::DataFrame#reindex でその列をインデックスとして使うように変更しました。

Method: Daru::DataFrame#set_index — Documentation for daru (0.1.4.1)

sorted_train_set[:index] = sorted_train_set.size.times.to_a
sorted_train_set.set_index(:index)

スクリプト全体

 上記以外のデータセットの用意やロジスティック回帰の実施部分などは前回とほぼ同じです。ここまでの内容を踏まえて、コード全体としては下記のように実装しました。

# ロジスティック回帰のROC曲線

require 'daru'
require 'nyaplot'
require 'numo/narray'

Variances = [50, 150] # 両クラス共通の分散(2種類の分散で計算を実施)

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)
  n1 = 80
  n2 = 200
  mu1 = [9, 9]
  mu2 = [-3, -3]

  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(0))

  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

# ロジスティック回帰
def run_logistic(train_set, plot)
  w = Numo::NArray[[0], [0.1], [0.1]]
  phi = train_set[:x, :y]
  phi[:bias] = Array.new(phi.size).fill(1)
  t = train_set[:type]
  t = t.to_matrix

  # 最大100回のIterationを実施
  100.times do
    # IRLS法によるパラメータの修正
    y = []
    phi.each_row do |line|
      a = Vector[*line.to_a].dot(w)
      y << (1.0 / (1.0 + Math.exp(-a)))
    end
    r = Matrix.diagonal(*(Numo::NArray[*y] * (1 - Numo::NArray[*y]).to_a))
    y = Numo::NArray[y].transpose
    tmp1 = Matrix[*Numo::NArray[*phi.to_matrix.transpose.to_a].dot(Numo::NArray[*r.to_a]).dot(Numo::NArray[*phi.to_matrix.to_a]).to_a].inverse
    tmp2 = Numo::NArray[*phi.to_matrix.transpose.to_a].dot(y - Numo::NArray[*t.transpose.to_a])
    w_new = w - Numo::NArray[*tmp1.to_a].dot(tmp2)

    # パラメータの変化が 0.1% 未満になったら終了
    if (w_new - w).transpose.dot(w_new - w).flatten[0] < (0.001 * (w.transpose.dot(w))).flatten[0]
      w = w_new
      break
    end
  end

  # 分類誤差の計算と確率付きデータの用意
  d0 = w[0]
  dx = w[1]
  dy = w[2]
  err = 0
  train_set[:probability] = Array.new(train_set.size).fill(0.0)

  train_set.each_row_with_index do |line, index|
    a = Vector[1, line.x, line.y].dot(w)
    p = 1.0 / (1.0 + Math.exp(-a))
    v = train_set.row[index]
    v[:probability] = p
    train_set.set_row_at([index], v)
    if (p - 0.5) * (line[:type] * 2 - 1) < 0
      err += 1
    end
  end

  err_rate = err * 100 / train_set.size

  # 境界線(P=0.5)を表示
  xmin = train_set.x.min - 5
  xmax = train_set.x.max + 5
  linex = Numo::NArray[*(xmin.to_i-5..xmax.to_i+4).to_a]
  liney = -linex * dx / dy - d0 / dy
  label = "ERR %.2f%%" % err_rate

  line_err = plot.add(:line, linex.cast_to(Numo::Int64).to_a, liney.cast_to(Numo::Int64).to_a)
  line_err.title(label)
  line_err.color('blue')

  # 確率付きデータを返却
  sorted_train_set = train_set.sort([:probability], ascending: false)
  sorted_train_set[:index] = sorted_train_set.size.times.to_a
  sorted_train_set.set_index(:index)
end

def run_simulation(variance, plot)
  train_set = prepare_dataset(variance)
  train_set1 = train_set.filter_rows {|row| row[:type] == 1 }
  train_set2 = train_set.filter_rows {|row| row[:type] == 0 }
  ymin = train_set.y.min - 5
  xmin = train_set.x.min - 5
  ymax = train_set.y.max + 10
  xmax = train_set.x.max + 10
  plot.configure do
    x_label("Variance: #{variance}")
    y_label('')
    xrange([xmin, xmax])
    yrange([ymin, ymax])
    legend(true)
    height(300)
    width(490)
  end

  scatter_true = plot.add_with_df(train_set1.to_nyaplotdf, :scatter, :x, :y)
  scatter_true.color('green')
  scatter_true.title('1')
  scatter_false = plot.add_with_df(train_set2.to_nyaplotdf, :scatter, :x, :y)
  scatter_false.color('orange')
  scatter_false.title('0')

  run_logistic(train_set, plot)
end

def draw_roc(result, plot)
  positives = result.filter_rows {|row| row[:type] == 1 }.size
  negatives = result.filter_rows {|row| row[:type] == 0 }.size
  tp = result.size.times.map { 0.0 }
  fp = result.size.times.map { 0.0 }
  result.each_row_with_index do |line, index|
    result.size.times do |c|
      if index < c
        if line[:type] == 1
          tp[c] += 1
        else
          fp[c] += 1
        end
      end
    end
  end
  
  tp_rate = Numo::NArray[*tp] / positives
  fp_rate = Numo::NArray[*fp] / negatives

  plot.configure do
    x_label('False positive rate')
    y_label('True positive rate')
    xrange([0, 1])
    yrange([0, 1])
    height(300)
    width(400)
  end

  plot.add(:line, fp_rate, tp_rate)
end

fig = Nyaplot::Frame.new

Variances.each do |variance|
  plot = Nyaplot::Plot.new
  result = run_simulation(variance, plot)
  fig.add(plot)

  plot = Nyaplot::Plot.new
  draw_roc(result, plot)
  fig.add(plot)
end

fig.show

 これを Jupyter Notebook 上で実行すると下記のようなグラフが表示されます。

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

 コードは下記にも公開してあります。

github.com