け日記

最近はPythonでいろいろやってます

SVDでMovieLensのレコメンドを実装する

前回の投稿( Pythonで特異値分解(SVD)を理解する - け日記 )で特異値分解(SVD)についてPythonで(車輪の再発明的に)実装してみました。
今回はSVDを使って、映画のレコメンドシステムを作ります。データセットはMovieLens 100Kを用います。

データセット

今回データセットに用いるMovieLens 100K Datasetを準備します。

以下からダウンロードして、今回作成するPythonファイルと同じディレクトリに解凍します。以前の投稿( word2vecでitem2vecを実装して映画を推薦する - け日記 )も参考にしてみてください。

grouplens.org

評価値をnumpy行列にして持ちます。欠測値は0で補完しています

あわせてアイテムIDとタイトルの一覧もDataFrameにロードしておきます。

import numpy as np
import pandas as pd
import codecs


# 評価値を持つファイルはu.data。楽に行列化するために、まずはDataFrameでロードする
ratings = pd.read_csv('ml-100k/u.data', delimiter='\t', header=None).iloc[:, :3]
ratings.rename(columns={0: 'user_id', 1: 'item_id', 2: 'rating'}, inplace=True)
print('ratings.shape: {}'.format(ratings.shape)) # ratings.shape: (100000, 3)

# item_idを行、user_idを列とする1682*943の行列(欠測値は0埋め)
rating_matrix = ratings.pivot(index='item_id', columns='user_id', values='rating').fillna(0).as_matrix()
print('rating_matrix.shape: {}'.format(rating_matrix.shape)) # rating_matrix.shape: (1682, 943)

# pd.read_csvで読み込もうとすると文字コードエラーになるため、
# codecs.openでエラーを無視してファイルを読み込んだ後にDataFrameにする
with codecs.open('ml-100k/u.item', 'r', 'utf-8', errors='ignore') as f:
    item_df = pd.read_table(f, delimiter='|', header=None).ix[:, 0:1]
    item_df.rename(columns={0: 'item_id', 1: 'item_title'}, inplace=True)
    item_df.set_index('item_id', inplace=True)

items = pd.Series(item_df['item_title'])
print('items.shape: {}'.format(items.shape)) # items.shape: (1682, 1)

SVDで行列分解

まずは行列分解するロジックを実装します。

numpy.linarg.svdで分解して、指定のk次元まで近似した行列を返します。
k=2(全ての特異値を使う)では同じ行列、k=1では元の行列と少し値が異なる行列がそれぞれ得られていることがわかります。

今回は評価値行列(rating_matrix)をk=30で次元圧縮します。この値は決め打ちです。

def reduct_matrix(matrix, k):
    # SVDで分解
    # full_metrices=Falseとすると、uがM×M(正方行列)ではなく、M×Nとなる
    u, s, v = np.linalg.svd(matrix, full_matrices=False)
    
    # k次元まで圧縮
    s_k = np.array(s)
    s_k[k:] = 0
    sigma_k = np.diag(s_k)
    
    return np.dot(u, np.dot(sigma_k, v))


reduct_matrix(np.matrix([[1, 2],[3, 4],[5, 6]]), k=2)
# matrix([[ 1.,  2.],
#         [ 3.,  4.],
#         [ 5.,  6.]])

reduct_matrix(np.matrix([[1, 2],[3, 4],[5, 6]]), k=1)
# matrix([[ 1.357,  1.718],
#         [ 3.097,  3.923],
#         [ 4.838,  6.128]])

# k=30で次元圧縮
reducted_matrix = reduct_matrix(rating_matrix, k=30)

評価ランキングの予測

対象ユーザが未評価のアイテムに対して、評価値を予測してランキングを作ります。

SVDでは負の値をとることもあります。また、今回の単純なモデルでは、アイテムやユーザごとの評価値の平均を考慮しておらず、評価していないアイテムについては0で補完しています。
そのため、絶対値の予測(例えば「ユーザAの映画Xの評価は3.7」という予測)の精度は期待できず、ランキングにしています。

def predict_ranking(user_index, reducted_matrix, original_matrix, n):
    # 対象ユーザのSVD評価値
    reducted_vector = reducted_matrix[:, user_index]
    
    # 評価済みのアイテムの値は0にする
    filter_vector = original_matrix[:, user_index] == 0
    predicted_vector = reducted_vector * filter_vector

    # 上位n個のアイテムのインデックスを返す
    return [(i, predicted_vector[i]) for i in np.argsort(predicted_vector)[::-1][:n]]


def print_ranking(user_ids, items):
    for user_id in user_ids:
        predicted_ranking = predict_ranking(user_id - 1, reducted_matrix, rating_matrix, 10)
        print('User: {}:'.format(user_id))
        for item_id, rating in predicted_ranking:
            # アイテムID, 映画タイトル, 予測した評価値を表示
            print('{}: {} [{}]'.format(item_id, items[item_id + 1], rating))

ここまで実装して、具体的に2人のユーザの推薦ランキング(TOP10)を作ります。
この2人(ユーザIDで267と868)は、"Akira(1988)"と"Ghost in the Shell(Kokaku kidotai)(1995)"をいずれも5で評価してます(つまり、SF+アニメーション好きです)。
結果は以下の通りです。

  • 267の人は、1位が"Terminator 2: Judgment Day (1991)"でSFだったり、6位が"Toy Story (1995)"でアニメだったりです
    • 全体的にはサスペンス物が多い印象です
  • 868の人は、1位の"Brazil (1985)"や9位の"Apollo 13 (1995)"などのSFが含まれます
    • 全体的には、ドラマのような、カルトのような、、、という感じです
User: 267
95: Terminator 2: Judgment Day (1991) [4.682164278048727]
133: Citizen Kane (1941) [3.3170890216340365]
10: Seven (Se7en) (1995) [3.2720405176668845]
78: Fugitive, The (1993) [3.1124326890407046]
3: Get Shorty (1995) [2.96920110165377]
172: Princess Bride, The (1987) [2.754536418480666]
0: Toy Story (1995) [2.6663561243251217]
231: Young Guns (1988) [2.6232331423202835]
635: Escape from New York (1981) [2.618586732939522]
153: Monty Python's Life of Brian (1979) [2.4655795942476715]

User: 868
174: Brazil (1985) [3.9280576476220705]
195: Dead Poets Society (1989) [2.7738253899665537]
430: Highlander (1986) [2.764929365245002]
356: One Flew Over the Cuckoo's Nest (1975) [2.573415252362662]
143: Die Hard (1988) [2.4954484877473875]
41: Clerks (1994) [2.29187936816679]
181: GoodFellas (1990) [2.132322532767669]
287: Scream (1996) [2.089763076093557]
27: Apollo 13 (1995) [2.0590921066379777]
82: Much Ado About Nothing (1993) [1.920902451839764]

まとめ

SVDを使ってMovieLens 100Kのデータでレコメンドを行いました。

素のSVDを推薦システムへ適用するといくつか問題があります。

  • 負の値を含む
    • MovieLensでも1〜5で評価されるため、予測値がマイナスとなるのは適合していない
  • 欠測値も含めて計算される
    • 今回は0で補完しているため、「値0で評価している」という誤った状態で計算されている
  • 各ユーザのバイアス(甘め辛め)が考慮されていない

1番目の問題を解決するためには、Matrix Factorizationという別の行列分解手法が必要となります。
また、2番目と3番目の問題には、モデルを拡張して対応する必要があります。以下の記事などが参考になりそうです。

ameblo.jp

参考文献

岩波データサイエンス Vol.5

岩波データサイエンス Vol.5