word2vecでitem2vecを実装して映画を推薦する

自然言語処理 Advent Calendar 2017 - Qiita 8日目の投稿となります。

qiita.com

前回の投稿ではword2vecを推薦に応用したitem2vecを紹介しました。
今回は、gensimのword2vecを使ってitem2vecの実装を行い、映画を推薦するシステムを作ります。

ohke.hateblo.jp

MovieLens

学習に用いるデータセットとして、かの有名なMovieLensを採用します。

MovieLensは、ミネソタ大学のGroupLensプロジェクトの一環で収集されているデータセットです。
映画のユーザ評価値、各映画のカテゴリやタグなどのメタ情報など、質・量ともに充実しており、推薦システムの評価によく用いられています。

https://movielens.org/

今回は扱いやすさを優先して、MovieLens 100K Datasetを使います(今なお収集・更新され続けているデータセットですので、最新版では2,000万を超える評価値が収録されています)。

  • ユーザ数: 943
    • 20以上の評価をしているユーザが対象
  • タイトル数: 1,682
  • 評価数: 100,000(1〜5の5段階評価)
    • 1997/9/19〜1998/4/22の7ヶ月間の収集データ

以下よりダウンロードして、今回作成するPythonファイルと同じディレクトリに解凍します。

https://grouplens.org/datasets/movielens/

次に評価値をDataFrameにロードしておきます。

import numpy as np
import pandas as pd


# 評価値を持つファイルはu.data
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)

ratings.head()

ユーザID、アイテムID、評価値のシンプルなテーブルとなります。

f:id:ohke:20171203153153p:plain

各アイテムの映画タイトルもDataFrame(items)にロードしておきます(推薦結果の確認のために使います)。

import codecs


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

こちらもアイテムID(インデックス)と映画タイトルからなる、シンプルなテーブルとなります。

f:id:ohke:20171203161217p:plain

分布を概観しておきます。

評価しているアイテム(左の図)、評価しているユーザ(真ん中の図)にそれぞれ偏りがあり、一部のアイテム・ユーザの評価が極端に多くなっています。
また評価値は4が一番多く、1や2などの低い評価は少ないことがわかります。

f:id:ohke:20171203161304p:plain

item2vecの実装

本題です。

前回紹介したitem2vecの論文では、アプリの購入明細やユーザごとの再生ログをそれぞれ文章としてとらえて、ベクトル化していました。

MovieLensを使った今回の取り組みでは「word2vecで言うところの"単語"を"アイテムID"、"文章(単語列)"を"ユーザごとに高評価したアイテムIDリストの文字列"に置き換え(ここではコンテキストと呼ぶことにします)、skip-gram negative sampling(SGNS)でベクトル化して、類似度の高いアイテムを推薦する」方針で実装していきます。

  • 各コンテキストは"61 33 160 20 202 ..."のような文字列になります
  • 平均値を上回る評価をしたアイテムに限定して、下回るアイテムについてはコンテキストから除外します
    • 評価が甘いユーザ、辛いユーザが混在しているため、平均値はユーザごとに計算します

文字列に変換するので、既存のword2vecの実装をそのまま利用できます。
今回はgensimを使います(gensimのword2vecを使った例は、Word2Vecで京都観光に関するブログ記事の単語をベクトル化する - け日記も参考にしてみてください)。

具体的な実装に入ります。

gensimのword2vecは、決まったフォーマット(1行1文章で、各文章内の単語はスペースで区切られている)のファイルを入力として受け取る必要があります。
そのため、まずはratingsからコンテキストを作り、このフォーマットでファイル(ml100k.txt)に出力します。

# ユーザIDをキーが、コンテキストが値
item_buckets = {}

for user_id in ratings['user_id'].unique():
    # ユーザごとに、評価が平均値以上のアイテムIDを抽出して' 'で連結する
    average = np.average(ratings[ratings['user_id'] == user_id]['rating'])
    user_ratings = ratings[(ratings['user_id'] == user_id) & (ratings['rating'] >= average)]
    item_buckets[user_id] = ' '.join(user_ratings['item_id'].astype('str'))

print('item_buckets[1]: {}'.format(item_buckets[1]))
# item_buckets[1]: 61 33 160 20 202 171 265 47 222 253 113 227 90 64 228 121 114 132 134 98 186 221 84 60 177 174 82 56 80 229 235 6 206 76 72 185 96 258 81 212 151 51 175 107 209 108 12 14 44 163 210 184 157 150 183 248 208 128 242 193 236 250 91 129 241 267 86 196 39 230 23 224 65 190 100 154 214 161 170 9 246 22 187 135 68 146 176 166 89 249 269 32 270 133 239 194 256 93 234 1 197 173 75 268 144 119 181 257 109 182 223 46 169 162 66 77 199 57 50 192 178 87 238 156 106 115 137 127 16 79 45 48 25 251 195 168 123 191 203 55 42 7 43 165 198 124 95 58 216 204 3 207 19 18 59 15 111 52 88 13 28 172 152

# ml100k.txtファイルに書き出す
with open('ml100k.txt', 'w') as f:
    f.write('\n'.join(item_buckets.values()))

次に、word2vecのモデルを構築しますが、いくつか考慮すべきことがあります。

  • skip-gramか、cbowか
    • item2vecの論文に則って、skip-gramを使います
  • 単語ベクトルのサイズ
    • gensimのデフォルトは100次元ですが、データサイズも大きくなく、まずは今回の取り組みの勝ち目を検証したいので、大きめの300次元に設定します
  • ウィンドウサイズ
    • コンテキスト内の全てのアイテムを周辺単語に含めて計算したいので、巨大な値を設定しておきます
  • ネガティブサンプリングか、階層的ソフトマックスか
    • こちらもitem2vecの論文に則って、ネガティブサンプリングを使います
    • ネガティブサンプリングする単語数は、学習結果にどう影響をあたえるのかが予測できなかったので、ここではデフォルトの5にしました

以上を引数として渡して、モデルを構築します。

from gensim.models import word2vec


sentences = word2vec.LineSentence('ml100k.txt')

# word2vecのモデルを構築
#     sg: 1ならskip-gram(cbowではなく)
#     size: 単語ベクトルの次元数
#     window: ウィンドウサイズ(今回は1行に含まれる全ての単語を対象とするため大きな値を設定)
#     hs: 0ならネガティブサンプリング(1なら階層的ソフトマックス)
#     negative: ネガティブサンプリングする単語数(結果への影響が予測できなかったのでデフォルトの5に設定)
#     seed: 乱数シード(結果を安定させたいので固定値を設定)
model = word2vec.Word2Vec(sentences, sg=1, size=300, window=100000, hs=0, negative=5, seed=0)

評価

最後に学習結果を評価していきます。

まずはあまりメジャーどころではない映画3本で試してみます。

# 「アイテムID : 映画タイトル : 類似度」を順番に表示する
def print_similar_titles(tuples):
    for t in tuples:
        print('{} : {} : {}'.format(t[0], items.ix[int(t[0])]['item_title'], t[1]))


# Boys on the Side (1995)
print_similar_titles(model.wv.most_similar(positive='723', topn=5))
# 155 : Dirty Dancing (1987) : 0.9308373332023621
# 1086 : It's My Party (1995) : 0.9249939918518066
# 1160 : Love! Valour! Compassion! (1997) : 0.9087437391281128
# 731 : Corrina, Corrina (1994) : 0.9083319902420044
# 958 : To Live (Huozhe) (1994) : 0.9018335938453674

# Akira (1988)
print_similar_titles(model.wv.most_similar(positive='206', topn=10)) 
# 1240 : Ghost in the Shell (Kokaku kidotai) (1995) : 0.8688986301422119
# 55 : Professional, The (1994) : 0.8473154306411743
# 184 : Army of Darkness (1993) : 0.8467724323272705
# 154 : Monty Python's Life of Brian (1979) : 0.8439988493919373
# 91 : Nightmare Before Christmas, The (1993) : 0.8403595089912415

# Anne Frank Remembered (1995)
print_similar_titles(model.wv.most_similar(positive='1084', topn=5))
# 1120 : I'm Not Rappaport (1996) : 0.8835359215736389
# 1117 : Surviving Picasso (1996) : 0.8799570798873901
# 740 : Jane Eyre (1996) : 0.8373265862464905
# 295 : Breakdown (1997) : 0.8226629495620728
# 676 : Crucible, The (1996) : 0.8197730183601379

一方で、メジャーで癖のない映画になると、映画のストーリーやジャンルと関係なく、同じくメジャーどころの映画が並びます。 「話題の映画を見たい」というミーハーの欲求にこたえたラインナップですが、内容の嗜好といったものが反映されていません。

# Toy Story (1995)
print_similar_titles(model.wv.most_similar(positive='1', topn=5))
# 222 : Star Trek: First Contact (1996) : 0.8651309609413147
# 50 : Star Wars (1977) : 0.8567565679550171
# 151 : Willy Wonka and the Chocolate Factory (1971) : 0.8447127342224121
# 405 : Mission: Impossible (1996) : 0.8326137065887451
# 257 : Men in Black (1997) : 0.8289622068405151

# Jurassic Park (1993)
print_similar_titles(model.wv.most_similar(positive=['82'], topn=5))
# 161 : Top Gun (1986) : 0.9202654361724854
# 380 : Star Trek: Generations (1994) : 0.8908969163894653
# 210 : Indiana Jones and the Last Crusade (1989) : 0.8803819417953491
# 403 : Batman (1989) : 0.880357027053833
# 79 : Fugitive, The (1993) : 0.8706289529800415

word2vecでベクトル化されていますので、足し算引き算も可能です。 The RockBad Boysを足すと、やはりアクション映画が多くなる傾向にあります。

# 117: Rock, The (1996), 27: Bad Boys (1995)
print_similar_titles(model.wv.most_similar(positive=['117', '27'], topn=5))
# 1016 : Con Air (1997) : 0.8946918249130249
# 597 : Eraser (1996) : 0.8450523614883423
# 147 : Long Kiss Goodnight, The (1996) : 0.833519458770752
# 273 : Heat (1995) : 0.8113495707511902
# 685 : Executive Decision (1996) : 0.8015687465667725

おわりに

word2vecでitem2vecを実装し、MovieLensのデータで推薦システムを作り、大雑把にいくつか映画を推薦させて傾向を見てみました。