Rubyで書くユークリッド距離とピアソン相関(NArray編)

これまでのコードは標準ライブラリだけで書いてきました.
が,もうちょっと速くできないものかと思い,調べてみたところNArrayというGemがあることがわかりました.
(Ruby数値計算ではおなじみらしいですね...)
今回はこのNArrayを使って,高速化してみようという話です.

NArrayとは

一言で言うと,行列演算をCで書かれた拡張ライブラリを使って一気に計算してくれるものです.
スピードもさることながら,行列演算をすっきり記述できるのも大きなメリット.
今回は基本的な使い方しかしていませんが,使いこなせばもっと色々できそうです.
詳しくは「Numerical Ruby NArray」や「Ruby Library Report 【第 5 回】 数値計算と可視化」を読んでみてください.


では,早速インストール.

% sudo gem install narray

NArray版ユークリッド距離とピアソン相関

母体となる部分は変えずに,NArrayを利用するところだけを書き換えるので,
これまでに作成したRecommenderクラスを継承したFastRecommenderクラスを作ります.
あとは,行列演算しているところを書き換えるだけ.
ちなみにNArray#sumはFloatで返してくれるので,型変換をしなくてもそのまま計算結果が使えます.

require 'rubygems'
require 'narray'
require 'recommender'

module My
  class FastRecommender < Recommender
    def get_euclidean_similarity(critics, person1, person2)
      movies = critics[person1].keys & critics[person2].keys
      return 0 if movies.empty?

      critics_person1 = NArray.to_na(movies.map { |movie| critics[person1][movie] })
      critics_person2 = NArray.to_na(movies.map { |movie| critics[person2][movie] })

      # 映画ごとの批評差を求める
      distances = (critics_person1 - critics_person2) ** 2
      # 批評差の総和を正規化して返す
      normalize(distances.sum)
    end

    private

    #
    # ピアソン相関定義
    #
    def get_pearson(array_x, array_y)
      x = NArray.to_na(array_x)
      y = NArray.to_na(array_y)
      
      n = x.size
      sum_x = x.sum
      sum_y = y.sum
      sum_x_square = (x ** 2).sum
      sum_y_square = (y ** 2).sum
      sum_xy = (x * y).sum

      numerator = sum_xy - (sum_x * sum_y.quo(n))
      denominator = sqrt(
        (sum_x_square - (sum_x ** 2).quo(n)) * (sum_y_square - (sum_y ** 2).quo(n)))
      
      denominator == 0 ? 0 : numerator.quo(denominator)
    end
  end
end

実行結果

まずは,値がこれまでと同じになるか確認してみましょう.

recommender = My::Recommender.new
fast_recommender = My::FastRecommender.new

puts recommender.get_euclidean_similarity(critics, 'Lisa Rose', 'Gene Seymour')
puts fast_recommender.get_euclidean_similarity(critics, 'Lisa Rose', 'Gene Seymour')
puts '-----'
puts recommender.get_pearson_correlation(critics, 'Lisa Rose', 'Gene Seymour')
puts fast_recommender.get_pearson_correlation(critics, 'Lisa Rose', 'Gene Seymour')

いざ,実行!

% ./fast_recommender.rb
0.148148148148148 # ノーマル版ユークリッド距離
0.148148148148148 # NArray版ユークリッド距離
-----
0.39605901719067 # ノーマル版ピアソン相関
0.39605901719067 # NArray版ピアソン相関

大丈夫ですね.


今度はどれくらい速くなったか計ってみます.

require 'benchmark'
Benchmark.bm(16) do |x|
  x.report('normal_euclidean') {
    10000.times { recommender.get_euclidean_similarity(critics, 'Lisa Rose', 'Gene Seymour') }
  }
  x.report('narray_euclidean') {
    10000.times { fast_recommender.get_euclidean_similarity(critics, 'Lisa Rose', 'Gene Seymour') }
  }
  x.report('normal_pearson') {
    10000.times { recommender.get_pearson_correlation(critics, 'Lisa Rose', 'Gene Seymour') }
  }
  x.report('narray_pearson') {
    10000.times { fast_recommender.get_pearson_correlation(critics, 'Lisa Rose', 'Gene Seymour') }
  }
end

いざ,実行!

% ./fast_recommender.rb
                      user     system      total        real
normal_euclidean  1.210000   0.000000   1.210000 (  1.211600) # ノーマル版ユークリッド距離
narray_euclidean  1.060000   0.010000   1.070000 (  1.071124) # NArray版ユークリッド距離
normal_pearson    2.090000   0.010000   2.100000 (  2.102502) # ノーマル版ピアソン相関
narray_pearson    1.280000   0.000000   1.280000 (  1.285789) # NArray版ピアソン相関

ユークリッド距離では13%,ピアソン相関では63%速くなりました.
演算量が増える処理ほど速くなっています.