『集合知プログラミング』を Ruby で

さっそく時間を作って読んでいるわけですが,コードが Python なんですよね.僕は Ruby の人なので Ruby でやりたいなと思っていまして,読みながらその場で同時通訳しています.その過程で Ruby のコードができるわけですが,せっかくなのでココで紹介しますね.現状,2.5 の最後 (p.20) まで読みましたので,それを載っけましょう.ファイル名は recommendations.rb です.

まずは p.8 のデータセットです.recommendations.rb は load で読み込むことを前提にしています.Ruby はファイルスコープを持っていて,ローカル変数はファイルの外からは見えません*1.そのため本文中の変数 critics は,Ruby では手っ取り早くグローバル変数にしました.

# 映画の評者といくつかの映画に対する彼らの評点のディクショナリ
$critics = {
  'Lisa Rose' => {
    'Lady in the Water' => 2.5,
    'Snakes on a Plane' => 3.5,
    'Just My Luck' => 3.0,
    'Superman Returns' => 3.5,
    'You, Me and Dupree' => 2.5,
    'The Night Listener' => 3.0,
  },
  'Gene Seymour' => {
    'Lady in the Water' => 3.0,
    'Snakes on a Plane' => 3.5,
    'Just My Luck' => 1.5,
    'Superman Returns' => 5.0,
    'The Night Listener' => 3.0,
    'You, Me and Dupree' => 3.5,
  },
  'Michael Phillips' => {
    'Lady in the Water' => 2.5,
    'Snakes on a Plane' => 3.0,
    'Superman Returns' => 3.5,
    'The Night Listener' => 4.0,
  },
  'Claudia Puig' => {
    'Snakes on a Plane' => 3.5,
    'Just My Luck' => 3.0,
    'The Night Listener' => 4.5,
    'Superman Returns' => 4.0,
    'You, Me and Dupree' => 2.5,
  },
  'Mick LaSalle' => {
    'Lady in the Water' => 3.0,
    'Snakes on a Plane' => 4.0,
    'Just My Luck' => 2.0,
    'Superman Returns' => 3.0,
    'The Night Listener' => 3.0,
    'You, Me and Dupree' => 2.0,
  },
  'Jack Matthews' => {
    'Lady in the Water' => 3.0,
    'Snakes on a Plane' => 4.0,
    'The Night Listener' => 3.0,
    'Superman Returns' => 5.0,
    'You, Me and Dupree' => 3.5,
  },
  'Toby' => {
    'Snakes on a Plane' => 4.5,
    'You, Me and Dupree' => 1.0,
    'Superman Returns' => 4.0,
  },
}

このコードに対する p. 9 の実行例は次のようになります.irb を使います.

muraken#mrkn-macbook:~/study/books/collective_intelligence_ja/ruby$ irb
>> load 'recommendations.rb'
=> true
>> $critics['Lisa Rose']['Lady in the Water']
=> 2.5
>> $critics['Toby']['Snakes on a Plane'] = 4.5
=> 4.5
>> $critics['Toby']
=> {"Superman Returns"=>4.0, "Snakes on a Plane"=>4.5, "You, Me and Dupree"=>1.0}

ちなみに,本文の実行例の最後の行は間違っていて,上記ように Superman Returns も出てくるのが正しいです.

次に p.11,ユークリッド距離に基づく類似性スコアを求める関数です.Ruby では以下のように Recommendations モジュールの中で,モジュール関数として定義しました.

# person1 と person2 の距離を基にした類似性スコアを返す
module Recommendations
  def self.sim_distance(prefs, person1, person2)
    # 二人とも評価しているアイテムのリストを得る
    si = prefs[person1].keys & prefs[person2].keys

    # 両者共に評価しているものが一つもなければ0を返す
    return 0.0 if si.empty?

    # すべての差の平方を足し合わせる
    sum_of_squares = si.inject(0.0) {|acc, item|
      acc += (prefs[person1][item] - prefs[person2][item])**2 }

    return 1.0 / (1.0 + sum_of_squares)
  end
end

Array の集合演算を使うと小気味良いコードになりますね.差の平方の総和ではもちろん inject を使います JK.実行例は以下のとおりです.

>> load 'recommendations.rb'
=> true
>> Recommendations.sim_distance($critics, 'Lisa Rose', 'Gene Seymour')
=> 0.148148148148148

今度は Pearson の積率相関係数に基づく類似性スコアを算出する関数です.ユークリッド距離との違いは,データの分布関数の位置 (平均値の値) が影響するか否かです.ユークリッド距離は位置が影響します.Pearson の積率相関係数は位置は影響せず,分布関数の形だけが重要になります.

# p1 と p2 のピアソン相関係数を返す
module Recommendations
  def self.sim_pearson(prefs, p1, p2)
    # 両者が互いに評価しているアイテムのリストを得る
    si = prefs[p1].keys & prefs[p2].keys

    # 要素の数を調べる
    n = si.length

    # 共に評価しているアイテムがなければ0を返す
    return 0.0 if si.empty?

    # すべての嗜好を合計する
    sum1 = si.inject(0.0) {|acc, item| acc += prefs[p1][item] }
    sum2 = si.inject(0.0) {|acc, item| acc += prefs[p2][item] }

    # 平方を合計する
    sum1sq = si.inject(0.0) {|acc, item| acc += prefs[p1][item]**2 }
    sum2sq = si.inject(0.0) {|acc, item| acc += prefs[p2][item]**2 }

    # 積を合計する
    psum = si.inject(0.0) {|acc, item|
      acc += prefs[p1][item]*prefs[p2][item] }

    # ピアソンによるスコアを計算する
    num = psum - (sum1*sum2/n)
    den = Math.sqrt((sum1sq - sum1**2/n)*(sum2sq - sum2**2/n))
    return 0 if den == 0.0

    r = num / den

    return r
  end
end

これらのコードは,本文のコードのコメントをそのまま残す形で翻訳しています.上のコードの sum1,sum2,sum1sq,sum2sq を求める4つの inject は次のようにまとめることができて,こっちのほうがループが1/4回になるので,データのサンプル数が多いときに有利です.

    # すべての嗜好,平方をそれぞれ合計する
    sum1, sum2, sum1sq, sum2sq = si.inject([0.0] * 4) {|accs, item|
      accs[0] += prefs[p1][item]      # sum1
      accs[1] += prefs[p2][item]      # sum2
      accs[3] += prefs[p1][item]**2   # sum3
      accs[4] += prefs[p2][item]**2 } # sum4

実行例は以下のようになります.

>> load 'recommendations.rb'
=> true
>> Recommendations.sim_pearson($critics, 'Lisa Rose', 'Gene Seymour')
=> 0.39605901719067

さて,どんどん行きましょう.次は p.15 の topMatches 関数です.Ruby へ翻訳するときは,関数名と変数名を snake_case に変更しています.

# ディクショナリ prefs から person にもっともマッチするものたちを返す
# 結果の数と類似性関数はオプションのパラメータ
module Recommendations
  def self.top_matches(prefs, person, n=5, &similarity)
    scores = (prefs.keys - [person]).collect {|other|
      [similarity[prefs, person, other], other] }
    # 高スコアがリストの最初に来るように並び替える
    scores.sort!
    scores.reverse!
    return scores[0, n]
  end
end

このコードでも Array の集合演算がとてもキレイにはまってる気がします.実行例は以下のとおりです.

>> load 'recommendations.rb'
=> true
>> Recommendations.top_matches($critics, 'Toby', n=3) {|prefs, p1, p2| Recommendations.sim_pearson(prefs, p1, p2) }
=> [[0.99124070716193, "Lisa Rose"], [0.924473451641905, "Mick LaSalle"], [0.893405147441565, "Claudia Puig"]]

今回はあと2つです.推薦を算出する関数は次のようになりました.

# person 以外の全ユーザの評点の重み付き平均を使い,person への推薦を算出する
module Recommendations
  def self.get_recommendations(prefs, person, &similarlity)
    totals = {}
    sim_sums = {}
    for other, items in prefs
      # 自分自身とは比較しない
      next if other == person
      sim = similarlity[prefs, person, other]

      # 0以下のスコアは無視する
      next if sim <= 0.0

      for item, value in items
        # まだ見ていない映画の得点のみを算出
        if !prefs[person].has_key?(item) || prefs[person][item] == 0.0
          # 類似度 * スコア
          totals[item] ||= 0.0
          totals[item] += value * sim
          # 類似度を合計
          sim_sums[item] ||= 0.0
          sim_sums[item] += sim
        end
      end
    end

    # 正規化したリストを作る
    rankings = totals.keys.collect {|item|
      [totals[item] / sim_sums[item], item] }

    # ソート済みのリストを返す
    rankings.sort!
    rankings.reverse!
    return rankings
  end
end

totals と sim_sums を初期化するところで付値している {} を Hash.new(0.0) へ書き換えて

    totals = Hash.new(0.0)
    sim_sums = Hash.new(0.0)

このようにすると,

          totals[item] ||= 0.0

          sim_sums[item] ||= 0.0

の2行が不要になります.最初から初期値が 0.0 だと分かってる場合は,このようにすると良いでしょう.ところで,私はあまり for を使わない人なんです.しかし,今回翻訳するにあたって Python 版が for を使っていたので試しに for で書いてみたのですが,以外とすっきりしていてビックリです.ただし,for はスコープを汚すので,それが嫌な場合はイテレータを使うことになります.

      for item, value in items

この部分をそのまま

      items.each do |item, value|

と書き換えれば良いと思います.実行例はこうなります.

>> load 'recommendations.rb'
=> true
>> Recommendations.get_recommendations($critics, 'Toby') {|prefs, p1, p2| Recommendations.sim_pearson(prefs, p1, p2) }
=> [[3.3477895267131, "The Night Listener"], [2.83254991826416, "Lady in the Water"], [2.53098070376556, "Just My Luck"]]
>> Recommendations.get_recommendations($critics, 'Toby') {|prefs, p1, p2| Recommendations.sim_distance(prefs, p1, p2) }
=> [[3.50024784014159, "The Night Listener"], [2.75612429399594, "Lady in the Water"], [2.46198848607437, "Just My Luck"]]

さて,今回の最後の関数です.critics を転置するための関数ですね.次のようになりました.

module Recommendations
  def self.transform_prefs(prefs)
    result = {}
    for person, items in prefs
      for item, value in items
        result[item] ||= {}
        # item と person を入れ替える
        result[item][person] = value
      end
    end
    return result
  end
end

result へ最初に付値するハッシュを {} ではなく

    result = Hash.new {|h, k| h[k] = {} }

このように書き換えると,次の1行が不要になります.

        result[item] ||= {}

また,このコードの for もイテレータに置き換えられます.

    for person, items in prefs
      for item, value in items

これが

    prefs.each do |person, items|
      items.each do |item, value|

こうなります.お好きな方を使えば良いと思います*2.私は後者が好みです.最後の実行例は次のとおりです.

>> load 'recommendations.rb'
=> true
>> movies = Recommendations.transform_prefs($critics)
=> {"The Night Listener"=>{"Jack Matthews"=>3.0, "Gene Seymour"=>3.0, "Mick LaSalle"=>3.0, "Lisa Rose"=>3.0, "Claudia Puig"=>4.5, "Michael Phillips"=>4.0}, "Superman Returns"=>{"Jack Matthews"=>5.0, "Gene Seymour"=>5.0, "Mick LaSalle"=>3.0, "Toby"=>4.0, "Lisa Rose"=>3.5, "Claudia Puig"=>4.0, "Michael Phillips"=>3.5}, "Lady in the Water"=>{"Jack Matthews"=>3.0, "Gene Seymour"=>3.0, "Mick LaSalle"=>3.0, "Lisa Rose"=>2.5, "Michael Phillips"=>2.5}, "Snakes on a Plane"=>{"Jack Matthews"=>4.0, "Gene Seymour"=>3.5, "Mick LaSalle"=>4.0, "Toby"=>4.5, "Lisa Rose"=>3.5, "Claudia Puig"=>3.5, "Michael Phillips"=>3.0}, "Just My Luck"=>{"Gene Seymour"=>1.5, "Mick LaSalle"=>2.0, "Lisa Rose"=>3.0, "Claudia Puig"=>3.0}, "You, Me and Dupree"=>{"Jack Matthews"=>3.5, "Gene Seymour"=>3.5, "Mick LaSalle"=>2.0, "Toby"=>1.0, "Lisa Rose"=>2.5, "Claudia Puig"=>2.5}}
>> Recommendations.top_matches(movies, 'Superman Returns') {|prefs, p1, p2| Recommendations.sim_pearson(prefs, p1, p2) }
=> [[0.657951694959769, "You, Me and Dupree"], [0.487950036474269, "Lady in the Water"], [0.111803398874989, "Snakes on a Plane"], [-0.179847194799054, "The Night Listener"], [-0.422890031611031, "Just My Luck"]]
>> Recommendations.get_recommendations(movies, 'Just My Luck') {|prefs, p1, p2| Recommendations.sim_pearson(prefs, p1, p2) }
=> [[4.0, "Michael Phillips"], [3.0, "Jack Matthews"]]

来週くらいまでに2章を終えて3章へ突入したいなと思っています.

(追記 2008-08-22 12:42)
既に Ruby への翻訳をやっている方がいらっしゃいましたので,リンク貼っておきます:

翻訳の仕方がちょっとずつ違っていて面白いですね.

*1:[ruby-list:34274]

*2:for もイテレータも内部実装では同じ扱いです