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


Scala de 集合知プログラミングを進めていきますよ(`・ω・´)

今回は第2章の推薦からアイテムベースのフィルタリングについてやっていきますよ

アイテムベースのフィルタリング

前回までに行ってきたアイテムの推薦では、全ユーザについての各アイテムに対する評価を記録したデータセットを利用する、ユーザベースの協調フィルタリングというものでした。

ただし、ユーザベースの協調フィルタリングではデータ数が多くなるに従って複数のユーザが共通して利用しているアイテムが少なくなりすぎるため、ユーザ間の類似度を正確に出すことが難しくなってしまうみたいです。

なので、巨大なデータセットが対象にあるような場合は事前にアイテム間の類似を計算しておいて、そのアイテム間類似度を利用してユーザへの推薦を行うアイテムベースの協調フィルタリングが向いているとのこと(´・ω・`)

アイテムベースの協調フィルタリングは次のようなイメージで行われますね

  • 全アイテムについて、アイテム間の類似度を事前に計算
  • 推薦対象のユーザが高く評価しているアイテムを取得
  • 推薦対象ユーザが高評価をつけているアイテムに類似するアイテムを推薦

ちなみにアイテム間の類似度を事前に計算しておくことで、実際にリアルタイムで行われる推薦処理について負荷を下げることも出来ますデス。

まあ、具体的な作法については実際にコードを書きながら見ていきましょうかね(´・ω・`)

アイテム間の類似度のデータセットを作る

とりあえずアイテム間の類似度を計算したデータセットを作るところから始めて見ますデス

元々用意していたユーザベースのデータセットからアイテム間の類似度を計算してやりますYO!

まずは本書のサンプルを写経してPython的類似度計算をしてやりますよ。実際にはこれまで作成してきた各メソッドを上手く組み合わせていく感じですね(´・ω・`)

# recomendation.pyに追記します

# ユーザベースのデータセットから各アイテム間の類似度を計算して
# {Item:[(類似度, Item), (類似度, Item), ... ], ... }の形式に再編成します
def calculateSimilarItems(prefs, n=10):
  # 類似度格納用の変数を初期化します
  result = {}
  # ユーザ→アイテムの形式のデータを
  # アイテム→ユーザの形式に反転します
  itemPrefs = transformPrefs(prefs)
  
  # 処理の進捗状況表示用の変数を定義シマス
  c = 0
  # 各アイテムごとに類似度計算処理を行います
  for item in itemPrefs:
    # 処理状況を表示します(100アイテムごと)
    c += 1
    if c % 100 == 0:
      print "%d / %d" % (c, len(itemPrefs))
    # 各アイテムごとに類似度ランキングを計算します
    scores = topMatches(itemPrefs, item, n = n, similarity = sim_distance)
    result[item] = scores
  # 計算終了で値を返します
  return result

うん、こんな感じでできましたな(`・ω・´)とりあえず動かしてみますよ

# モジュールを読み込みます
>>> import recommendation
# アイテム間の類似度を計算シマスです
>>> itemsim = recommendation.calculateSimilarItems(recommendation.critics)
>>> itemsim
{'Lady in the Water': [(0.40000000000000002, 'You Me and Dupree'), (0.2857142857142857, 'The Night Listener'), (0.22222222222222221, 'Snakes on a Plane'), (0.22222222222222221
<省略>

無事計算できてますね(`・ω・´)

それでは本題のScalaコードへの翻訳を行ってみますよ

// Recommendation.scala内のRecommendationオブジェクトに追記します

// アイテム間類似度を計算してやります
def calculateSimilarItems(prefs:Map[String, Map[String, Double]],
  n:Int=10):Map[String, List[(Double, String)]] = {
  
  // ユーザ→アイテム形式を反転シマス
  val itemPrefs = transformPrefs(prefs)
  
  // 前アイテムについて類似度ランキングを計算してまとめます
  itemPrefs.map(item => item ._1 ->
   topMatches(itemPrefs, item._1, n=n, similarity=sim_distance))
}

うん、イミュータブルにするのが若干面倒なので進捗表示を省いてしまいましたが…こんな感じでいかがでしょう(´・ω・`)進捗表示版は文末におまけで付けておきますです

それでは動かしてみますよ

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

// 類似度を計算しますよ
scala> val itemsim = calculateSimilarItems(critics)
itemsim: Map[String,List[(Double, String)]] = Map((Superman Returns,List((0.16666666666666666,Snakes on a Plane), (0.10256410256410256,The Night Listener), (0.0

おお、できました(`・ω・´)実際にこのような前処理を行う場合は定期的にバッチで回す感じですネ

推薦を行う

それでは作成したアイテム間類似度のデータエットを利用して推薦を行ってみますよ(`・ω・´)

推薦の詳細な手順は次のようになりますね

  • 特定のユーザが評価した全てのアイテムを取得
  • 取得アイテムに似ている他のアイテムを探索して類似度で重み付け
  • ユーザ評価と、類似度の重み付けから各アイテムの推薦度を計算

推薦度の計算は次のような感じですかね(´・ω・`)実際には以前計算したユーザベースの推薦度と殆ど変わらないです

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

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

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

とりあえずコードを書いてみて理解していきましょー

# recommendation.pyに追記

# アイテムベースで推薦度を計算
def getRecommendItems(prefs, itemMatch, user):
  # ユーザが評価したアイテムを取得
  userRatings = prefs[user]
  # 計算用の変数を初期化
  scores = {}
  totalSim = {}
  
  # ユーザが評価したアイテムをループ
  for (item, rating) in userRatings.items():
    # ユーザ評価アイテムの類似アイテムごとに処理
    for (similarity, item2) in itemMatch[item]:
      # このアイテムに対してユーザが既に評価していれば無視
      if item2 in userRatings:
        continue
      
      # 評点 * 類似度の合計を計算
      scores.setdefault(item2, 0)
      scores[item2] += similarity * rating
      
      # 全ての類似度を合計
      totalSim.setdefault(item2, 0)
      totalSim[item2] += similarity
  
  # ソレゾレ重み付けして合計したスコアを、類似度の合計で正規化    
  rankings = [(score/totalSim[item], item) for item, score in scores.items()]
      
  # 降順にソートして結果を返す
  rankings.sort()
  rankings.reverse()
  return rankings  

うん、できましたな(`・ω・´)それでは実行してみますよ

# モジュールをリロード
>>> reload(recommendation)
<module 'recommendation' from 'recommendation.pyc'>

# 先ほど計算したアイテム間類似度を利用して計算します
>>> recommendation.getRecommendItems(recommendation.critics, itemsim, 'Toby')
[(3.182634730538922, 'The Night Listener'), (2.5983318700614575, 'Just My Luck'), (2.4730878186968837, 'Lady in the Water')]

とりあえずPython側はOKのようです(´・ω・`)

  • Scalaで推薦度を計算

それでは本題のScalaへの翻訳を行っていきますよ(`・ω・´)

一度getRecommendで似た様なコードを書いているので、今回は最初からimmutableバーションにしてみます

// Recommendation.scala内のRecommendationオブジェクトに追記します

// 推薦度の計算式を定義します                                                        
def getRecommendationItems(prefs:Map[String, Map[String, Double]],
    itemMatch:Map[String, List[(Double, String)]],
    user:String):List[(Double, String)] = {
    
    // 推薦対象ユーザの評価状況を取得します
    val userRatings = prefs(user)

    // 推薦の対象となるアイテムを抽出して
    // 各アイテムごとの重みつきスコアを計算してやりますデス
    //計算結果は集計しやすいように (アイテム名、類似度 * 評価値、類似度)の形式にします
    val candidates = itemMatch.filter(item => !userRatings.contains(item._1)).
      flatMap(item =>
        item._2.filter(x => userRatings.contains(x._2)).
        map(x => (item._1, x._1 * userRatings(x._2), x._1)))

    // 各評価者ごとに同一アイテムの重みつきスコアが複数存在するので
    // パターンマッチ再帰処理を使って集計してやります
    // なお、あらかじめタプルの第一要素(アイテム名)でソートされているのが前提です
    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(candidates.toList.sort(_._1 > _._1)).
      map(item => (item._2 / item._3, item._1)).sort(_._1 > _._1)
  }

うん、できたようなので実行してみますよ(`・ω・´)

scala> getRecommendationItems(critics, itemsim, "Toby")
res0: List[(Double, String)] = List((3.182634730538922,The Night Listener), (2.5983318700614575,Just My Luck), (2.4730878186968837,Lady in the Water))

うんきちんと計算できていますね(`・ω・´)

まとめ

今回はユーザの嗜好データからアイテム間類似度を計算して新しいデータセットを作ることで、アイテムベースの協調フィルタリングを試してみました。

次回はGroupLensプロジェクトのMovieLensという実際の映画評価のデータセットを使って推薦処理を書いてみたいと思います(`・ω・´)

おまけ

アイテム類似度のデータセットを作るScala版処理に、無理やり進捗表示を付けてみました。

もっといい方法がありそうなので、教えてください詳しい人(´・ω・`)

def calculateSimilarItems(prefs:Map[String, Map[String, Double]],
  n:Int=10):Map[String, List[(Double, String)]] = {
  
  val itemPrefs = transformPrefs(prefs)
  
  // 再帰処理で変換を行います
  // 再帰で処理を行うようにデータセットMapをリストに変換します
  // ... ちょっと強引すぎるので他の方法があったら教えてください(´・ω・`)
  def create_itemsim(items:List[(String, Map[String, Double])],
    n:Int=0):Map[String, List[(Double, String)]] = items match {  
    // 値がなくなったら空のMapを返します
    case Nil => Map.empty[String, List[(Double, String)]]
    // 再帰で行う実際の処理です
    case a :: other => {
      // 100件ごとにメッセージを出力します
      if(n % 100 == 0) println(n + "/" + items.size)
      // 計算したアイテム類似度ランキングを計算済みのMapに結合していきます
      //    itemPrefを直接使えばtoMapしなくてもいいよな(´・ω・`)
      //    スコープ的に気持ち悪いけども
      create_itemsim(other, n +1) +
      (a._1 -> topMatches(items.toMap, a._1, n=n, similarity=sim_distance))
    }
  }
  // 再帰処理のためにデータセットMapをリストに変換します
  create_itemsim(itemPrefs.toList)
}

計算効率的にダメな気がする...