け日記

SIerから転職したWebアプリエンジニアが最近のITのキャッチアップに四苦八苦するブログ

「Context-Aware Recommender Systems」まとめ

今回はRecommender Systems Handbook(第1版)の第7章「Context-Aware Recommender Systems」について読んでまとめました。 第1版第7章の内容は以下で公開されています。

NSF CAREER Award Page

現在は第2版がリリースされています。

Recommender Systems Handbook

Recommender Systems Handbook

なぜこれを読んだのか

これまでユーザベースとアイテムベースの協調フィルタリング(9/229/29の投稿)、コンテンツベースフィルタリング(10/13の投稿)について学んできました。
それらはユーザやアイテムの類似や過去の評価に基いて推薦しています。

しかし、いずれも"今のユーザの状況(コンテキスト)"を反映する枠組みが無く、実際の利用にあたってはギャップを感じます。
例えば、このユーザはいつもならAの居酒屋を好むが、「"今回は昼食時に探している"ので、Bの蕎麦屋を推薦する」「"今回は家族との外食先"を探しているので、Bのファミリーレストランを推薦する」といった考慮ができるべきかと思います。

そのため、推薦システムにコンテキストの概念を説明しているこの本を読み、ギャップを埋めることにしました。
サーベイ論文っぽい内容のため、細かいところまで立ち入らず、「最良かどうかはわからないけども、こうすればこんな状況を考慮した推薦が実装できそうだ」とイメージできることを重視しながら読みました。

1 Introduction and Motivation

これまでの推薦システムの多くは、ユーザとアイテムに関する情報のみがインプットとなっていました。

予測精度を上げるために、ユーザとアイテムの情報に加えて、コンテキスト(時間、場所、一緒にいる人など)の情報を導入したContext-aware recommender systems(以降、CARS)について説明します。

2 Context in Recommender Systems

いろいろな分野の"コンテキスト"

  • データマイニングの分野では、顧客のライフステージ(結婚・離婚、出産、退職など)が重要なコンテキストで、ライフステージが変われば嗜好、ステータス、価値観が変わる
  • Eコマースでは、ユーザやアイテム以外に時間や同行者、天気などの情報をコンテキストとして使う研究もある
    • 特にモバイル環境では、各ユーザの現在位置やその周辺の地理情報、気温などをコンテキストとして扱うことができる
  • 情報検索では、検索ワードと潜在的に関連するトピックのこと
    • 例えば"scikit-learn"や"ロジスティック回帰"、"L2正則化"などで検索されていれば、潜在的なトピックは"機械学習"と予想することができます
    • 検索によって解決したい問題(時間軸が短い)と興味・嗜好(時間軸が長い)をどうやって検索結果に反映させるかが焦点

CARSのモデル

評価値を予測するモデル(ここではRとしています)を定義していきます。

協調フィルタリングなどの従来の推薦アルゴリズムは、ユーザとアイテムによって評価値が定まる、いわば2次元のモデルでした。

  • User: ユーザの属性で、ユーザID、名前、年齢、性別、職業など
  • Item: アイテムの属性で、映画を例にすると、映画ID、タイトル、リリース年月日、監督、ジャンルなど


R:User \times Item → Rating

CARSではそれにコンテキストが加わった3次元のモデルになります。


R:User \times Item \times Context → Rating

映画を例にあげると、コンテキストは以下のような情報です。

  • 映画館: 映画館ID、住所、収容人数など
  • 時間: 日付、曜日、時刻など
  • 同行者: 一人、友達と、恋人と、家族となど

User、Item、Contextはそれぞれ異なる次元数で属性値を持ちます。
下図(原文Fig.2の抜粋)の通り、コンテキスト(ここでは奥行方向のTime)が3次元(Weekday, Weekend, Holiday)になっており、時間帯によって評価値が異なるモデルが表現されています。

f:id:ohke:20171014120811p:plain

コンテキスト情報を取得する

こうしたコンテキストはどうやって得られるものでしょうか。 アプローチは3つあります。

  • アンケートなどでユーザから明示的に取得する
  • 暗黙的に取得する
    • 商品の購入履歴から購入時間を得る、デバイスGPSから位置情報を得る、など
  • 推論から得る
    • おむつと幼児用ミルクの購入履歴から子供がいると推定する、など
    • 推論にはナイーブベイズ分類やベイジアンネットワークが使われます

3 Paradigms for Incorporating Context in Recommender Systems

従来のユーザとアイテムのみの2次元の推薦システムによる推薦は、大きく3つのプロセスで行われます。

  1. これまでの購買履歴などの元データから、評価データ(U×I×R)を取得する
  2. 評価データから、関数(U×I→R)を構築する
  3. 特定のユーザの情報(u)を受け取り、関数を使って推薦するアイテムのリスト(i_1, i_2, i_3, ...)を出力する

CARSでは、コンテキスト情報を追加した評価データ(U×I×C×R)を入力として受け取り、出力は同じく推薦するアイテムのリストになります。
コンテキスト情報を適用するタイミングによって、システムの構成は下図(原文Fig.4抜粋)の通りプリフィルタリング(下図a)、ポストフィルタリング(下図b)、コンテキストモデリング(下図c)の3パターンに分類します。

f:id:ohke:20171014124914p:plain

プリフィルタリング

コンテキスト情報を評価データの選択に使い、関数の構築や推薦(上記プロセスの2.以降)はこれまで通り行う方法です。
つまり、土曜日に見る映画を検索しているなら、土曜日の評価のみを入力データとして使うというイメージです。

コンテキストとして時間(Time)を使うと、評価値の計算はUser、Item、Timeの3値で決まります。 Dは(user, item, time, rating)からなるレコードです。


R^{D}_{User \times Item \times Time}:U \times I \times T → Rating

例えば土曜日だけの場合は以下のように、土曜日だけのデータを入力にして評価値を計算します。


R^{D(User, Item, Time=Saturday, Rating)}_{User \times Item}(u, i)

入力データを前段で絞るだけのため、従来の協調フィルタリングなどと容易に組み合わせられます。

しかし、コンテキストの情報が増えすぎる(細かすぎる)と2つの問題を抱えることになります。

  • あまり意味がないかもしれない(映画を見るのに土曜日と日曜日では大差ないかもしれない)
  • 疎になって十分なデータ数が得られないかもしれない

ポストフィルタリング

評価データの取得と関数の構築(上記プロセスの2.以前)はこれまで通り行い、得られた推薦アイテムリストに対して、コンテキスト情報を使って調整(一部を除外したり、順番を入れ替えたり)する方法です。
例えば、土曜日に見る映画を探していて、その人が土曜日にコメディ映画のみを見ることがわかっているなら、推薦リストからコメディ映画以外を除外する、といったイメージです。

一般化すると、このアプローチではコンテキスト情報(映画を見るのは土曜日)から好まれるアイテムのパターン(土曜日はコメディ映画のみを見る)を見つけ、そのパターンに従って調整する(コメディ映画以外を除外する)アプローチで、大きく2つの方法に分類できます。

  • ユーザとコンテキストの情報とアイテムの属性を利用するヒューリスティックな方法
    • 好みの出演俳優が0人の映画は除外する、好みの出演俳優の数が多い映画を上位に移す、など
  • 特定のコンテキストにおける特定のアイテムをユーザが好む確率を計算するモデルベースの方法

プリフィルタリングと同様に、従来の協調フィルタリングなどと容易に組み合わせることができます。

コンテキストモデリング

コンテキスト情報を含めた関数(U×I×C→R)を構築し、ユーザの情報とコンテキスト情報(c)から、推薦するアイテムリストを出力する方法です。

ヒューリスティックなアプローチ

ユーザやアイテムの情報にコンテキスト情報を加えたn次元のベクトルを作り、(類似度の計算に代わって)ベクトル同士の距離から予測する方式です。

User×Item×Timeの推薦システムを例にとると、予測値r_{u,i,t}は下式で計算します。
Wは重みで、(u,i,t)と(u',i',t')の距離の逆数のため、2つの距離が近いほど重みが大きくなります。 つまり、距離が近い評価値ほど、予測値に大きな影響を与えます。 距離の計算には、ごく一般的なマンハッタン距離やユークリッド距離が使われます。

モデルベースのアプローチ

従来の2次元の推薦システムで使ってきたモデルを多次元に拡張し、マルコフ連鎖モンテカルロ法などの確率的モデルや、決定木やSVMなどで予測するアプローチです。

4 Combining Multiple Approaches

複数のフィルタを使って1つの推薦リストを得る方法について考えていきます。

プリフィルタリングであれば、下図(原文Fig.6抜粋)のようになります。
例えば、(Girlfriend, Theater, Saturday)というコンテキストであれば、c{1}=(Friend, AnyPlace, Saturday)やc{2}=(NotAlone, Theater, AnyTime)などの複数のコンテキストと組み合わせて結果を出すこともできます。
Recommender Aggregatorで組み合わせる方法は2つ考えられます。

  • 特定のコンテキストにおいて、最も適したプリフィルタ1つを選択する
  • 複数のプリフィルタの結果を多数決などで統合する

f:id:ohke:20171015111118p:plain

ただし、コンテキストによって適したフィルタが異なる可能性はあります。
時間であればプリフィルタが適しています(お昼ごはんを食べるお店を探しているのに、17時から営業開始するお店が推薦リストに入ってきたらNGですよね)。 一方、天気はポストフィルタの方が望ましいでしょう(近くのレジャー施設を探しているとすると、雨が降っていたとしても、推薦リストの下位に屋外球場が入っていても良いかと思います)。

プリフィルタを組み合わせる

大きく2ステップのアルゴリズムになります。 なお、組み合わせて使う協調フィルタリングなどのアルゴリズムをAとしています。

  1. トレーニングデータ(ユーザの既知の評価値)を使い、アルゴリズムA単体よりも優れたプリフィルタを選ぶ
  2. 特定のコンテキストにおいて最も性能の高いプリフィルタを選択し、そのプリフィルタを使って学習されたアルゴリズムAを適用して予測する
    • 入力するコンテキストに該当するプリフィルタが無い場合は、全てのデータでトレーニングされたアルゴリズムAを使う(コンテキストを全く考慮しない従来方法と同じ)

ステップ1はさらに3ステップからなります。

  1. 考えうる全てのプリフィルタから、閾値N以上の十分なデータ数を持つコンテキストのセグメントを選別する
    • コンテキストが細かすぎてデータ数が不足するという問題への対策
  2. 選別された各プリフィルタに対してアルゴリズムAを使って予測精度を計測し、プリフィルタを使っていない場合の精度よりも、精度が低いプリフィルタは除外します
    • 予測精度には、F値(適合率と再現率の調和平均)などが使われます
  3. 残ったプリフィルタから、より一般的で、かつ、精度の高いプリフィルタのみをさらに選択する
    • 「土曜日」のコンテキストに基づくプリフィルタと、「週末」のコンテキストに基づいたプリフィルタが存在した場合、
    • 「週末」のプリフィルタの方が「土曜日」のプリフィルタよりも精度が高ければ、
    • 「土曜日」は「週末」に包含されるため「土曜日」のプリフィルタは冗長とみなして除外する

論文では、62人の学生を対象に、時間(weekday, weekend, don't remember)・場所(in a movie theater, at home, don't remember)・同行者(alone, with friends, with boyfriend/girlfriend, with family, others)のコンテキストを含む1457の評価値(映画は202種類)を採取し、通常の協調フィルタリングとプリフィルタを使った協調フィルタリングで、予測値の比較を行っていました。
F値で結果を比較すると、トータルで0.063、プリフィルタを適用できたケースのみで0.095の改善が見られました。

5 Additional Issues in Context-Aware Recommender Systems

最後にCARSの課題を挙げます。

  • 3つのモデルのどれを選択すべきか
  • CARSのコンテキストの情報が加わるため単純な協調フィルタリングに比べると複雑になりがち
  • CARSはコンテキスト情報をユーザから取得する必要があるため、高いインタラクティブ性(要するにグッドなUI)を求められる
  • アーキテクチャまでスコープに入れたハイパフォーマンスなCARSの構築

まとめ

自身の所感についてまとめておきます。

プリフィルタリングを重点的に説明されていますが、協調フィルタリングなどと簡単に組み合わせられ、現実的に実装もできそうです。
一方でモデルベースのCARSについては、自身の理解が追いつかず、ちょっとイメージできませんでした。 具体的な実装を行っている文献を読む必要がありそうです。

また、コンテキストにはどういった情報を使うのが有効的なのかを探すのは大変そうです。
闇雲に細かい分類はいくらでもできてしまいますし、次元数を増やせば学習にかかるコストが簡単に高くなってしまいます。 scikit-learnのGridSearchのように、トライアンドエラーが簡単にできる仕組みなどが必要かもしれません。

Pythonでレコメンドシステムを作る(コンテンツベースフィルタリング)

今回はコンテンツベースフィルタリングで、ジョークをお薦めするシステムを作ります。

コンテンツベースフィルタリングとは

コンテンツベースフィルタリングは、アイテムそのものの特徴を利用して、推薦したいユーザがこれまで高評価したアイテムと類似するアイテムを推薦する方法です。
例えば、過去に「ハリー・ポッターと賢者の石」と「指輪物語 1」を高評価しているユーザは、おそらく「小説」「ファンタジー」を嗜好しているため、「ナルニア国物語 1」などを薦める、といった感じです。

ユーザの評価値のみを使って推薦する協調フィルタリングと比較すると、以下の違いがあります。

  • アイテムのコンテンツを入力する処理が必要
  • アイテムのコンテンツから特徴ベクトルを抽出する処理が必要
  • 多様性・新規性に欠ける(過去に評価したアイテムと類似するため)
  • システム全体のユーザ数・評価数が少ない状況でも、予測精度は下がらない
  • まだどのユーザからも評価されていないアイテムの評価値も予測できる

コンテンツベースフィルタリングを実装する

今回はJester Datasetを使ってユーザにジョークをお薦めするシステムを実装してみます。

コンテンツベースフィルタリングでは、大きく3ステップで推薦します。

  1. アイテムの特徴ベクトルを抽出する
  2. アイテム間の特徴ベクトルの類似度を算出する
  3. 過去に高く評価したアイテムと類似度が高いアイテムが上位になるようにランキング化する

Jester Dataset

今回は、英語のジョーク集の評価を集めたJester Collaborative Filtering Datasetを使います。

これは150種類のジョークに対して50692人が-10〜+10で評価しています。

Jester Datasets

本家サイトのは評価値がxlsファイルだったり、ジョーク本文をhtmlからパースする必要があったりするため、非常に扱いにくいものになっています。
そこで、csv/tsvファイルに加工されてkaggleで提供されているデータセットがありますので、そこから jesterfinal151cols.csv (評価値) と jester_items.tsv (ジョーク本文)をダウンロードして、これから作るPythonファイルと同じディレクトリに配置してください。

www.kaggle.com

ただし、そのkaggleのjester_items.tsvは150番目のジョークが抜けているので、本家から150番目のジョークをダウンロードして以下の1行を追加します。

150: In an interview with David Letterman, Carter passed along an anecdote of a translation problem in Japan. Carter was speaking at a business lunch in Tokyo, where he decided to open his speech with a brief joke. He told the joke, then waited for the translator to announce the Japanese version. Even though the story was quite short, Carter was surprised by how quickly the interpreter was able to re-tell it. Even more impressive was the reaction from the crowd. Carter thought the story was cute, but not outright hilarious, yet the crowd broke right up. Carter was very flattered. After the speech, Carter wanted to meet the translator to ask him how he told the joke. Perhaps there is better way to tell the joke? When Carter asked how the joke had been told in Japanese, the translator responded, "I told them, 'President Carter has told a very funny joke. Please laugh now.'"

特徴ベクトルの抽出

まずはjester_items.tsvからジョーク本文をDataFrameに読み込みます。

import pandas as pd
pd.set_option("display.max_colwidth", 1000)


jokes = pd.read_csv('jester_items.tsv', sep='\t', names=['id', 'joke'])
jokes.drop('id', axis=1, inplace=True)
print('Joke {0}: {1}'.format(0, jokes.ix[0, 'joke']))
# Joke 0: A man visits the doctor. The doctor says, "I have bad news for you. You have cancer and Alzheimer's disease". The man replies, "Well, thank God I don't have cancer!"

f:id:ohke:20170927092221p:plain

次にTF-IDFで特徴ベクトルを抽出します。
以前の投稿も参考にしてください。

  • Porterでステミングすることで、時制や単複の違いを吸収しています
  • 語の出現頻度による足切りは行いません(min_df=1)

scikit-learnでスパムメッセージを分類する(TfidfVectorizer + PorterStemmer + BernoulliNB) - け日記

from nltk import word_tokenize
from nltk.stem.porter import PorterStemmer
from sklearn.feature_extraction.text import TfidfVectorizer


class PorterTokenizer(object):
    def __init__(self):
        self.porter = PorterStemmer()
        
    def __call__(self, doc):
        return [self.porter.stem(w) for w in word_tokenize(doc)]
    

# Porterでステミングして、TF-IDFで特徴ベクトルを構築する
vectorizer = TfidfVectorizer(norm='l2', min_df=1, tokenizer=PorterTokenizer())
vectorizer.fit(jokes['joke'])

print(vectorizer.transform(jokes.ix[0]))
#   (0, 1877)  0.136130990259
#   (0, 1809)  0.143046021434
#   (0, 1779)  0.21261619802
#   ...
print(vectorizer.vocabulary_)
# {'a': 98, 'man': 1049, 'visit': 1779, 'the': 1672, 'doctor': 555, '.': 26, ...

類似度の算出

得られた特徴ベクトル同士のコサイン類似度を計算します。 こちらも以前の投稿で同様の計算をしていますね。

scikit-learnでスパムメッセージを分類する(TfidfVectorizer + PorterStemmer + BernoulliNB) - け日記

実装上の留意点が2点あります。

  • 類似度行列は対称行列になります
    • similarities[0][2]とsimilarities[2][0]はいずれ0番目と2番目のジョークの類似度を表しており、同じ値です
  • 類似度行列の対角成分はnp.nanと置いています
  • vectorizer.transformで得られる特徴ベクトルはscipy.sparse.csr.csr_matrix(疎行列用のデータ型)となっており扱いにくいので、toarrayでndarrayに変換しています

得られた類似度行列の最大値は0.978で、112番目と132番目のジョークでした。 2つを比較してみると、", walking by,"が入っているかどうかの違いだけで、同じ内容となっていました。

import numpy as np


def get_similarities(words_vector):
    """
    BoWベクトル同士のコサイン類似度行列を計算する
    """
    similarities = np.zeros((words_vector.shape[0], words_vector.shape[0]))
    
    for i1, v1 in enumerate(words_vector):
        for i2, v2 in enumerate(words_vector):
            if i1 == i2:
                similarities[i1][i2] = np.nan
            else:
                similarities[i1][i2] = np.dot(v1, v2.T) / (np.linalg.norm(v1) * np.linalg.norm(v2))

    return similarities


similarities = get_similarities(vectorizer.transform(jokes['joke']).toarray())

print('Max similarity: {0:.3f}'.format(np.nanmax(similarities), np.nanargmax(similarities)))
# Max similarity: 0.978
print('Joke {0}:\n{1}'.format(np.nanargmax(similarities) // similarities.shape[0], 
                              jokes.ix[np.nanargmax(similarities) // similarities.shape[0], 'joke']))
print('Joke {0}:\n{1}'.format(np.nanargmax(similarities) % similarities.shape[0], 
                              jokes.ix[np.nanargmax(similarities) % similarities.shape[0], 'joke']))
# Max similarity: 0.978
# Joke 112:
# The new employee stood before the paper shredder looking confused. "Need some help?" a secretary asked. "Yes," he replied. "How does this thing work?" "Simple," she said, taking the fat report from his hand and feeding it into the shredder. "Thanks, but where do the copies come out?"
# Joke 132:
# The new employee stood before the paper shredder looking confused. "Need some help?" a secretary, walking by, asked. "Yes," he replied, "how does this thing work?" "Simple," she said, taking the fat report from his hand and feeding it into the shredder. "Thanks, but where do the copies come out?"

評価値の予測

評価値をロードして、任意のユーザとアイテムの評価値を予測します。

まずjesterfinal151cols.csvファイルをndarrayにロードします。

  • 評価値は-10.0〜+10.0の連続値となっており、未評価は99です
  • csvファイルに値が入っていない列があるため、filling_valuesで未評価(99)で補完しています
    • numpy.loadtxtではこうした補完ができずエラーになってしまうので、genfromtxtを使っています

次に、評価値を予測する関数predict_ratingを実装します。
ユーザuのアイテムaに対する評価値を、以下の式で予測しています。 sim(a,b)はアイテムaとbの類似度、Iは評価済みアイテム集合、r_bはアイテムbの評価値です。
他のユーザの評価値を使わずに、対象ユーザの評価値とアイテムの特徴ベクトルの類似度から計算していることに着目してください(協調フィルタリングと異なる点ですね)。

0番目のユーザの0番目のアイテムに対する評価値を予測すると、3.391となりました。

# 評価値のロード
ratings = np.genfromtxt('jesterfinal151cols.csv', delimiter=',', filling_values=(99))
ratings = np.delete(ratings, 0, axis=1)

print('Rating 0:\n{}'.format(ratings[0]))
# Rating 0:
# [  9.90000000e+01   9.90000000e+01   9.90000000e+01   9.90000000e+01
#   2.18750000e-01   9.90000000e+01  -9.28125000e+00  -9.28125000e+00
# ...


def predict_rating(rating, item_index, similarities):
    """
    評価値を予測する
    """
    numerator = 0.0
    enumerator = 0.0
    
    for i, r in enumerate(rating):
        if r != 99:
            numerator += similarities[item_index][i] * r
            enumerator += similarities[item_index][i]
            
    return numerator / enumerator if enumerator != 0 else np.nan


# 評価値の予測
print('Predicted rating: {:.3f}'.format(predict_rating(ratings[0], 0, similarities)))
# Predicted rating: 3.391

ランキング

最後におすすめすべきアイテムをランキングにします。

関数rank_itemsは、未評価のアイテム全てについて評価値を予測して降順で返します。
0番目のユーザの場合、139番目、42番目、76番目のジョークの評価値が高いと予測されており(それぞれ4.89、4.03、3.99)、この順番で薦めるべきことがわかります。

def rank_items(ratings, user_index, similarities):
    """
    (アイテムのインデックス, 予測した評価値)のタプルリストをランキング(評価値の降順ソート)で返す
    """
    ranking = [ ]
    rating = ratings[user_index]
    
    for i, r in enumerate(rating):
        if r != 99:
            continue
            
        ranking.append((i, predict_rating(rating, i, similarities)))
        
    return sorted(ranking, key=lambda r: r[1], reverse=True)


print('Ranking(User 0):\n{}'.format(rank_items(ratings, 0, similarities)))
# Ranking(User 0):
# [(139, 4.8879743288985411), (42, 4.0260000988334159), (76, 3.9892937363894996), ...

情報推薦システム入門 -理論と実践-

情報推薦システム入門 -理論と実践-

Python scikit-surpriseを使ってレコメンドする

scikit-surpriseというライブラリを使って、お寿司データセットのレコメンドを実装します。

scikit-surpriseとは

scikit-surpriseは、レコメンドシステムで必要となる類似度評価や予測アルゴリズムなどを提供するライブラリです。

類似のライブラリとしてcrabrecsysなどもありますが、scikit-surpriseは最近もアップデートされ続けられており、伸びしろのあるライブラリかと思います。

surprise.readthedocs.io

pypi.python.org

github.com

利用する前にインストールしておきます。

$ pip install -U scikit-surprise

scikit-surpriseで寿司ネタをレコメンドする

scikit-surpriseで寿司ネタをレコメンドするモデルを構築してみます。

基本的な手順は3段階です。 1. データセットをDatasetクラスに読み込む 2. 評価値を予測するアルゴリズムを学習させてモデルを得る 3. モデルを評価する

事前準備

今回もSUSHI Preference Data Setsを使いますので、All Data Setからsushi3-2016.zipをダウンロード・展開して、sushi3b.5000.10.scoreファイルを.pyファイルと同じディレクトリにコピーしておきます。

www.kamishima.net

sushi3b.5000.10.scoreファイルは、以下のような5000行100列となっており、行がユーザ、列がアイテム(寿司ネタ)に対応して評価値(-1は未評価を表す)を持っています。

-1 0 -1 4 2 -1 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 4 -1 2 -1 -1 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 2 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 0 -1 1 -1 -1 -1 0 -1 -1 -1 -1 0 -1 -1 -1 1 2 -1 0 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 3 4 -1 -1 -1 3 -1 -1 -1 -1 -1 -1 4 -1 4 -1 -1 -1 -1 -1 -1 -1 -1 -1 3 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 2 -1 -1 -1 -1 -1 -1 -1 -1 1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 2 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
...

しかし、このフォーマットではDatasetへロードできません。 1行が1評価値となるように変換する必要があります。
そのため、ユーザID アイテムID 評価値 に変換したファイル(sushi3b.5000.10.score_converted)を予め作成しておきます。 ユーザIDは0000〜4999、アイテムIDは00〜99とします。

def convert(input_file_name):
    """
    'ユーザID アイテムID 評価値'のフォーマットへ変換してファイルに出力する
    """
    output_file_name = input_file_name + '_converted' # 変換後のファイル名
    output = ''
    
    with open(input_file_name, mode='r') as f:
        lines = f.readlines()
        
        user_id = 0
        for line in lines:
            words = line.strip().split(' ')
            for item_id, word in enumerate(words):
                score = int(word)
                if score != -1:
                    output += '{0:04d} {1:02d} {2:01d}\n'.format(user_id, item_id, score)
                    
            user_id += 1
    
    with open(output_file_name, mode='w') as f:
        f.write(output)
    
    return output_file_name


file_name = convert('./sushi3b.5000.10.score')

with open(output_file_name, mode='r') as f:
    print(f.read())
# 0000 01 0
# 0000 03 4
# 0000 04 2
# 0000 12 1
# 0000 44 1
# ...

Datasetへの読み込み

scikit-surpriseでは、最初に評価値の情報をDatasetクラスへロードする必要があります。

まずはReaderを作成します。
引数line_formatで各行がユーザID、アイテムID、評価値の順番で列を持つこと、sepで各列が' '(半角スペース)で区切られることを明示的に指定しています。

Dataset.load_from_fileメソッドで、先程変換したファイルとReaderインスタンスを渡すことで、Datasetが生成されます。
現時点の最新バージョン(1.0.3)では、ファイル(または同梱されているデータセット)からしかロードできません。 (numpyのndarrayやPandasのDataFrameなど、メモリ上で直接ロードできるようになると嬉しいのですが、もう一つ不便なところですね。)
10/9追記 1.0.4からpandas.DataFrameからロードできるload_from_dfも用意されました。

from surprise import Reader, Dataset

reader = Reader(line_format='user item rating', sep=' ')
dataset = Dataset.load_from_file(file_name, reader=reader)

SVDによる学習と予測

得られたDatasetを使ってモデルを学習させます。

予測アルゴリズムにはSVDクラスを使います。
scikit-surpriseの予測アルゴリズムは全てAlgoBaseを親クラスとしており、子クラスはtrain、test、predict、compute_similaritiesなどのメソッドを実装しています。 そのため、どの予測アルゴリズムでも、共通したインタフェースで扱えるようになっています。

Dataset.build_full_trainsetメソッドで全てのデータを学習データセットとしたTrainsetインスタンスを取得して、trainメソッドで学習します。

0番目のユーザの0番目のアイテム(エビ)の評価値をpredictメソッドで予測してみたところ、2.72の評価値が得られました。

from surprise import SVD

model = SVD()

# 全てのデータを使って学習
trainset = dataset.build_full_trainset()
model.train(trainset)

# ユーザ間の類似度を計算
similarities = model.compute_similarities()

print('similarities.shape: {}'.format(similarities.shape))
# similarities.shape: (5000, 5000)
print('Similarity(User 0000 and 0001: {:.3f})'.format(similarities[0,1]))
# Similarity(User 0000 and 0001: 0.500)

# 0番目のユーザの0番目のアイテム(エビ)の評価値を予測する
user_id = '{:04d}'.format(0)
item_id = '{:02d}'.format(0)

prediction = model.predict(uid=user_id, iid=item_id)

print('Predicted rating(User: {0}, Item: {1}): {2:.2f}'
        .format(prediction.uid, prediction.iid, prediction.est))
# Predicted rating(User: 0000, Item: 00): 2.72

評価

最後にモデルを評価します。

Datasetのsplitメソッドでデータセットをk分割して交差検証することができます。
scikit-learnのtrain_test_splitなどと異なり、あくまでDatset内での分割となります。

evaluateにモデル、データセット、そして指標のリスト(1.0.3時点では'RMSE'、'MAE'、'FCP'の3種類)を渡すことで、交差検証を行います。
結果は指標毎のディクショナリとして返ってきます。

from surprise import evaluate

# 学習データとテストデータを4分割
dataset.split(n_folds=4)

# 平方平均二乗誤差と平均絶対誤差の算出
result = evaluate(model, dataset, measures=['RMSE', 'MAE'])
# Evaluating RMSE, MAE of algorithm SVD.
#
# ------------
# Fold 1
# RMSE: 1.1653
# MAE:  0.9437
# ------------
# Fold 2
# RMSE: 1.1534
# MAE:  0.9362
# ------------
# Fold 3
# RMSE: 1.1474
# MAE:  0.9277
# ------------
# Fold 4
# RMSE: 1.1560
# MAE:  0.9349
# ------------
# ------------
# Mean RMSE: 1.1555
# Mean MAE : 0.9356
# ------------
# ------------

print('Mean RMSE: {:.3f}'.format(sum(result['rmse'])/len(result['rmse'])))
print('Mean MAE: {:.3f}'.format(sum(result['mae'])/len(result['mae'])))
# Mean RMSE: 1.156
# Mean MAE: 0.936

おわりに

この他にも類似度の計算やグリッドサーチによるパラメータの最適化などの豊富な機能が提供されています。
機会があればまた紹介してきます。