Scalaで集合知プログラミング その5


Scala de 集合知プログラミングの第2章の続きをやっていきますよ(`・ω・´)

前回はピアソン相関係数なんかを使って嗜好が似ているユーザランキングなんかを作ってみましたが、今回はコレをアイテムの推薦に応用していきたいと思います。

アイテムを推薦する

前回はとあるユーザと嗜好の似ているユーザをランキングで表現したのですが、本来の目的としてはどのユーザが自分で似ているか?ということよりもどのアイテムを自分が手に入れるべきか?が大事になりますデス。

そんなわけでユーザの嗜好類似度をアイテムの推薦度に変換剃る方法を考えてみることにシマス(´・ω・`)

重みつきスコアの計算

アイテムの推薦度を求めるために今回は類似度による重みつきスコアを計算してやります。

今回利用する重みつきスコアは次のような式になりますね(´・ω・`)

ユーザAに対してのユーザAが未評価のアイテムaの推薦度

((ユーザAとBの類似度 × ユーザBのアイテムaへの評点) + (ユーザAとCの類似度 × ユーザCのアイテムaへの評点) + ... ) ÷ (ユーザAとBの類似度 + ユーザAとCの類似度 ...)

日本語でダラダラ書いてみると、各評点に類似度をかけた値の合計を類似度の合計でわった値、みたいな表現になりますかね(´・ω・`)

ちなみに最終的に重みつきスコアを類似度の合計で除算しているのは、評価者多ければ多いほど推薦度が大きくなることを防ぐためのならし処理デスね

なお、推薦の対象となるアイテムは未評価のものだけとしておりますデス(´・ω・`)

Pythonで推薦度を計算

とりあえずサンプルにあるPythonによる推薦度計算コードを写経してみますよ

# rcommendation.pyに追記します

# person以外の全ユーザの評点について重みつき平均で推薦度を算出します
#   パラメータは次の通りです
#   prefs: 嗜好データ
#   person: 推薦対象ユーザ名
#   similarity: 類似度計算関数(省略時はピアソン相関係数)
def getRecommendations(prefs, person, similarity=sim_pearson):
  # 演算結果格納用の辞書を用意
  totals = {}
  simSums = {}
  
  # 全評価者について比較開始
  for other in prefs:
    # 自分自身との比較はしない
    if other == person:
      continue
    # 評価者同士の類似度を計算
    sim = similarity(pref, person, other)
    
    # 類似度が0以下の場合は推薦度計算の対象から外す
    if sim <= 0:
      continue
    
    # 各アイテムの重みつきスコアを計算
    for item in prefs[other]:
      # 推薦の対象者がまだ見ていない映画のみを得点の算出に利用
      if item not in prefs[person] or prefs[person][item] == 0:
          ## 類似度 * スコアを計算
          # totalsにアイテムの得点がない場合は0を設定
          totals.setdefault(item, 0)
          # totalsにアイテムの重みつきスコアを加算
          totals[item] += prefs[other][item] * sim
          ## 類似度を合計
          # simSumsが定義されていない場合は0を設定
          simSums.setdefault(item, 0)
          # simSumsにユーザ間の類似度を加算
          simSums[item] += sim
  
  # 類似度合計で除算して正規化したリストを作成
  rankings = [(total/simSums[item], item) for item, total in totals.items()]
  
  # ランキングをソートして値を返す
  rankings.sort()
  rankings.reverse()
  return rankings

それでは実行してみますかね(`・ω・´)

# モジュールをインポートします
>>> import recommendation

# Tobyに対する推薦アイテムを計算してやります
>>> recommendation.getRecommendations(recommendation.critics, 'Toby')[(3.3477895267131013, 'The Night Listener'), (2.8325499182641614, 'Lady in the Water'), (2.5309807037655645, 'Just My Luck')]

# ユークリッド距離によるユーザ類似度で推薦度を再計算してやります(´・ω・`)
# 若干推薦度の値が変わってきますデス
>>> recommendation.getRecommendations(recommendation.critics, 'Toby', similarity=recommendation.sim_distance)
[(3.5002478401415877, 'The Night Listener'), (2.7561242939959363, 'Lady in the Water'), (2.4619884860743739, 'Just My Luck')]

うん、とりあえず動いたので今度は本番としてScalaでコード書いてみますよ(`・ω・´)

Scalaで推薦度を計算

先ほどのPythonコードをScalaに翻訳してみますデス

// Recommendation.scalaのRecommendationオブジェクトに追記

// 推薦度の計算式を定義します                                                        
def getRecommendations(prefs:Map[String, Map[String, Double]],
  person:String, similarity:(Map[String, Map[String, Double]], String, String) =>
  Double = Recommendation.sim_pearson):List[(Double,String)] = {
  
  // 推薦対象者と他の評者の類似度を計算して格納します
  val other_sims= prefs.filter(other => other._1 != person).
    map(other => (other, similarity(prefs, person, other._1)))
  
  // 各合計値をミュータブルマップを使って計算します
  import scala.collection.mutable
  // 計算結果格納用Mapを初期化します
  val totals = mutable.Map.empty[String, Double]
  val simSums = mutable.Map.empty[String, Double]
  
  // 各評価者ごとにアイテムに対する推薦度を計算します
  for((other, sim) <- other_sims;if(0 < sim)){
    // 推薦対象者が未評価のアイテムについて推薦度を計算します
    for(item <- other._2; if(!prefs(person).contains(item._1)  ||
        prefs(person)(item._1) == 0)) yield {
      
      //// 重みつきスコアを計算します 
      // 未定義のMapのキーがあった場合は初期化デス
      if(!totals.isDefinedAt(item._1)) totals(item._1) = 0
      // 評価値 * 類似度を加算します
      totals(item._1) += item._2 * sim
    
      //// 類似度合計を計算します 
      // 未定義のMapのキーがあった場合は初期化デス
      if(!simSums.isDefinedAt(item._1)) simSums(item._1) = 0
      // アイテムごとに類似度の値を加算します
      simSums(item._1) += sim
    }
  }
  
  // 重みつきスコアを類似度合計で正規化した値をソートして返します
  totals.map(total => (total._2 / simSums(total._1), total._1)).
    toList.sort(_._1 > _._1)
}

無事翻訳できたので実際に動かしてみますよ(`・ω・´)

// インポートします
scala> import org.plasticscafe.collective.recommend.Recommendation                     
import org.plasticscafe.collective.recommend.Recommendation

// 推薦度を計算してやりますよ
scala> Recommendation.getRecommendations(Recommendation.critics, "Toby")               
res1: List[(Double, String)] = List((3.3477895267131017,The Night Listener), (2.832549918264162,Lady in the Water), (2.5309807037655645,Just My Luck))

// ユークリッド距離類似度でも計算です
scala> Recommendation.getRecommendations(Recommendation.critics, "Toby", similarity=Recommendation.sim_distance)
res2: List[(Double, String)] = List((3.5002478401415877,The Night Listener), (2.7561242939959363,Lady in the Water), (2.461988486074374,Just My Luck))

おお!出来ました(`・ω・´)

なんだかScalaっぽくないなぁ

ミュータブルマップを使ってしまったせいか、(個人的に憧れる)Scalaっぽさがなくなってしまったような気がするので、ちょっくら書きなおしてみます(´・ω・`)

とりあえずvarを使わないことを目標にしてみますよ

// 推薦度の計算式を定義します                                                        
def getRecommendations(prefs:Map[String, Map[String, Double]],
    person:String, similarity:(Map[String, Map[String, Double]], String, String) =>
    Double = Recommendation.sim_pearson) = {

    // 推薦対象者と他の評者の類似度を計算して格納します
    // 類似評価がマイナスのものは省きます
    val other_sims= prefs.filter(other => other._1 != person).
      map(other => (other, similarity(prefs, person, other._1))).
      filter(other_sim => 0 < other_sim._2)

    // 重みつきスコアを各アイテムごとに計算してやります
    // 生成するデータは後々に集計する類似度データも合わせた
    // タプルの形式にしてやりますよ
    val item_scores =  other_sims map {
      case(other, sim) => other._2.filter(item => !prefs(person).contains(item._1)  ||
        prefs(person)(item._1) == 0).map(item => (item._1, item._2 * sim, sim))
    }

    // 各評価者ごとに同一アイテムの重みつきスコアが複数存在するので
    // パターンマッチ再帰処理を使って集計してやります
    // なお、あらかじめキー(タプルの第一要素:アイテム名)でソートされているのが前提です
    def summary(in:List[(String, Double, Double)]):List[(String, Double, Double)] = in match {                                                                                
      // 空リストはスルーします
      case Nil => Nil
      // 同じキーを持つ要素が連続できたら集計して処理を継続します
      case a :: b :: other if a._1 == b._1 => summary((a._1, a._2 + b._2, a._3 + b._3) :: other)
      // 同じキーが連続しない場合はリストの第2要素以下で処理を継続します
      case a :: other => a :: summary(other)
    }
    
    // 重複ありの重みつきスコアをソートして集計します
    // 集計した結果を更にソートして戻りとしてリターンしますデス(`・ω・´)
    summary(item_scores.flatMap(x => x).toList.sort(_._1 > _._1)).
      map(item => (item._2 / item._3, item._1)).sort(_._1 > _._1)
  }

うん、何とかできたけどパフォーマンス的にはアレな気がする(´・ω・`)

もっといい方法がありそうな気がするので教えて偉い人!と叫んでおきますデス。

まとめ

今回は重みつきスコアを使ってユーザ類似度から各アイテムの推薦度の計算をやってみました。

次回はこれまでの計算方法を応用して、似ているアイテムの探索方法についてやっていきたいと思います(`・ω・´)