「うわっ…私のリコメンド、ビール多すぎ…？」doc2vecの拡張とリコメンドシステムへの応用

この記事は[Retty Inc. Advent Calendar 2017](https://qiita.com/advent-calendar/2017/retty) 1日目です。

一発目は緊張しますね。なので今年は去年の「[character-level CNNでクリスマスを生き抜く](https://qiita.com/bokeneko/items/c0f0ce60a998304400c8)」よりも若干真面目に書きます:)
doc2vecを拡張してリコメンドを学習させたお話です。

# リコメンデーション再考

以前「[口コミのdoc2vecを用いたリコメンデーションシステムの構築](https://qiita.com/bokeneko/items/36a549b1eb3f22a58c3f)」という記事でdoc2vecを用いて口コミの分散表現を獲得してそこからユーザー・店舗のベクトルを計算してリコメンドを行いました。

この時のやり方で出てきたおすすめ店舗は、Rettyクラフトビール担当の私にはありがたい内容だったのであれはあれで良しと思っています。(実はこの時の成果がちょろっとRettyのアプリに取り込まれました😊)

しかし一方でちょっと気になったのは、

**「うわっ…私のリコメンド、ビール多すぎ…？」**

ということです。
ビールの店以外にもExcellent(Rettyでの最高評価)をつけてる店はありますし、ビールの店でもExcellentをつけてる店はそれほど多いわけではありません。評価はともかく、投稿を多くしている種類の店がよく行くタイプの店としておすすめされるているわけです。

Rettyの世界観が「おすすめを投稿する」ということなので、投稿が多いほどおすすめしてると言えるようには思いますが、やっぱりビール以外にも自分に合う店は見つけていきたいので、評価を考慮したリコメンドというのも欲しいなと思います。

というわけで、今回は評価を考慮したリコメンドを実験していきます。

# 愚直に

まあ、Deep Learning使って愚直にやるなら、ユーザーIDと店舗IDからembedding_lookupでユーザーベクトルと店舗ベクトルを取ってきて、それを全結合層何層かに流して評価(Normal, Good, Excellent)にカテゴライズする、というモデルを学習してやればいいかなと思いました。

## 実装

ネットワークは以下のようなものにしました。

1. ユーザーIDと店舗IDを使ってembedding_lookup用のmatrixからユーザーベクトル、店舗ベクトルを取ってくる
2. ユーザーベクトルと店舗ベクトルをconcatする。
3. 全結合層 -> 全結合層 -> dropout -> 全結合層で３つのカテゴリー(Normal, Good, Excellent)に分類する。 

```py
with tf.variable_scope('user_restaurant_score'):
    user_matrix = tf.get_variable("user_matrix",
                                shape=[<全ユーザーの数>, <ベクトルの次元>],
                                initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                                dtype=tf.float32)
    restaurant_matrix = tf.get_variable("restaurant_matrix",
                                shape=[<全店舗の数>, <ベクトルの次元>],
                                initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                                dtype=tf.float32)
    fc1_weights = tf.get_variable("fc1_weight",
                                shape=[<ベクトルの次元>*2, <ベクトルの次元>],
                                initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                                dtype=tf.float32)
    fc1_biases = tf.get_variable("fc1_bias",
                                shape=[<ベクトルの次元>],
                                initializer=tf.zeros_initializer(),
                                dtype=tf.float32)
    fc2_weights = tf.get_variable("fc2_weight",
                                shape=[<ベクトルの次元>, 64],
                                initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                                dtype=tf.float32)
    fc2_biases = tf.get_variable("fc2_bias",
                                shape=[64],
                                initializer=tf.zeros_initializer(),
                                dtype=tf.float32)
    fc3_weights = tf.get_variable("fc3_weight",
                                shape=[64, 3], 
                                initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                                dtype=tf.float32)
    fc3_biases = tf.get_variable("fc3_bias",
                                shape=[3],
                                initializer=tf.zeros_initializer(),
                                dtype=tf.float32)
# categorize
user_vec = tf.nn.embedding_lookup(user_matrix, user_inputs)
restaurant_vec = tf.nn.embedding_lookup(restaurant_matrix, restaurant_inputs)
ur_vec = tf.concat([user_vec, restaurant_vec], 1)
fc1 = tf.nn.relu(tf.matmul(ur_vec, fc1_weights) + fc1_biases)
fc2 = tf.nn.relu(tf.matmul(fc1, fc2_weights) + fc2_biases)
do1 = tf.nn.dropout(fc2,  keep_prob)
predicted = tf.matmul(do1, fc3_weights) + fc3_biases
# loss
loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(labels=<教師データ>, logits=predicted))
```

## 結果

40万件ほどの評価を教師として50epochほど学習させてお店への評価を推定させたところ、accuracyは教師データで70%程、テストデータでは57.7%でした。ちょっと低いです。
単純すぎるといえば単純すぎるモデルですので、然もありなんというところでしょうか。
目隠しでランダムに評価を推定するよりはマシとはいえ、この程度の精度では実用には使えません。

# doc2vecを拡張して取り込む

上では単に評価だけを使ってユーザーベクトルと店舗ベクトルを学習したわけですが、あまり良い結果は出ませんでした。以前doc2vecを使ったリコメンドでは評価を利用してなかったとはいえなかなか良い結果が出たわけですので、どうにか前回の成果を取り入れたいところです。

そこでdoc2vecを拡張してユーザーベクトルと店舗ベクトルを学習する方法を考えてみました。

## doc2vecの拡張

word2vecではある単語の周りにどのような単語が現れるかで、その単語がどんな単語であるのかを推定していました。

doc2vecでは文脈がある単語の周りに現れる単語の分布にどう影響を与えるかで、文脈がどのようなものであるのかを推定していました。

では文脈がどのように作られたのか。どのユーザーがどの店舗に行ったのかということが文脈に影響を与えているはずだと考えました。つまり以下のような感じです。

- doc2vecの場合

<img width="482" alt="スクリーンショット 2017-11-15 12.18.00.png" src="https://qiita-image-store.s3.amazonaws.com/0/88387/1e794bce-f6d4-6b1d-6520-777572424ee8.png">

- doc vectorの前に別の要素を仮定

<img width="707" alt="スクリーンショット 2017-11-15 12.18.08.png" src="https://qiita-image-store.s3.amazonaws.com/0/88387/9b008fb8-a193-8593-2700-7e9965056eb3.png">

ユーザーベクトルと店舗ベクトルから文脈ベクトルが生成され、それが単語の分布に影響を与えているというモデルを書けば、doc2vecのようなモデルでユーザーベクトルと店舗ベクトルをend-to-endで学習できるはずだと考えました。これを先のカテゴライズと合わせて学習させることで評価の推定の精度を上げることができるんではないかと考えました。これは仮説なのでうまくいくかはやってみないとわかりません。というわけで実験してみました。

## 実装

ネットワークは以下のようなものにしました。

- 分類用ネットワーク

1. ユーザーと店舗のembedding_lookup用のmatrixからユーザーベクトル、店舗ベクトルを取ってくる
2. ユーザーベクトルと店舗ベクトルをconcatする。
3. 全結合層 -> dropout -> 全結合層で３つのカテゴリー(Normal, Good, Excellent)に分類するしてsoftmax_cross_entropyでlossを計算する。

- doc2vec用ネットワーク

1. ユーザーと店舗のembedding_lookup用のmatrixからユーザーベクトル、店舗ベクトルを取ってくる
2. ユーザーベクトルと店舗ベクトルをconcatする。
3. 全結合層を一度通した結果を口コミベクトルとする
4. 単語のembedding_lookup用のmatrixから単語ベクトルを取ってくる
5. 単語ベクトルと口コミベクトルの平均でnce_lossでlossを計算する。

二つのネットワークのlossを足したものを全体のlossとして学習する。

```py
with tf.variable_scope('user_restaurant'):
    user_matrix = tf.get_variable("user_matrix",
                        shape=[<全ユーザーの数>, <ベクトルの次元>],
                        initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                        dtype=tf.float32)
    restaurant_matrix = tf.get_variable("restaurant_matrix",
                        shape=[<全店舗の数>, <ベクトルの次元>],
                        initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                        dtype=tf.float32)
    word_matrix = tf.get_variable("word_matrix",
                        shape=[<全単語の数>, <ベクトルの次元>],
                        initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                        dtype=tf.float32)
    ur_weights = tf.get_variable("ur_weight",
                        shape=[<ベクトルの次元>*2, <ベクトルの次元>],
                        initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                        dtype=tf.float32)
    ur_biases = tf.get_variable("ur_bias",
                        shape=[<ベクトルの次元>],
                        initializer=tf.zeros_initializer(),
                        dtype=tf.float32)
    fc1_weights = tf.get_variable("fc1_weight",
                        shape=[<ベクトルの次元>*2, 64],
                        initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                        dtype=tf.float32)
    fc1_biases = tf.get_variable("fc1_bias",
                        shape=[64],
                        initializer=tf.zeros_initializer(),
                        dtype=tf.float32)
    fc2_weights = tf.get_variable("fc2_weight",
                        shape=[64, 3],
                        initializer=tf.random_uniform_initializer(minval=-1.0, maxval=1.0),
                        dtype=tf.float32)
    fc2_biases = tf.get_variable("fc2_bias",
                        shape=[3],
                        initializer=tf.zeros_initializer(),
                        dtype=tf.float32)
    nce_weights = tf.get_variable("nce_weight",
                        shape=[<全単語の数>, <ベクトルの次元>],
                        initializer=tf.truncated_normal_initializer(1.0 / math.sqrt(vec_size)),
                        dtype=tf.float32)
    nce_biases = tf.get_variable("nce_bias",
                        shape=[<全単語の数>],
                        initializer=tf.zeros_initializer(),
                        dtype=tf.float32)
# categorize
cat_user_vec = tf.nn.embedding_lookup(user_matrix, <ユーザー（カテゴライズ用）>)
cat_restaurant_vec = tf.nn.embedding_lookup(restaurant_matrix, <店舗（カテゴライズ用）>)
cat_ur_vec = tf.concat([cat_user_vec, cat_restaurant_vec], 1)
fc1 = tf.nn.relu(tf.matmul(cat_ur_vec, fc1_weights) + fc1_biases)
do = tf.nn.dropout(fc1, dropout_kept_param)
predicted_category = tf.matmul(do, fc2_weights) + fc2_biases
# doc2vec
doc2vec_word_vec = tf.nn.embedding_lookup(word_matrix, <単語入力データ>)
doc2vec_user_vec = tf.nn.embedding_lookup(user_matrix,  <ユーザー（doc2vec用）>)
doc2vec_restaurant_vec = tf.nn.embedding_lookup(restaurant_matrix, <店舗（doc2vec用）>)
doc2vec_ur_vec = tf.concat([ue, re], 1)
doc2vec_doc_vec = tf.matmul(doc2vec_ur_vec, ur_weights) + ur_biases
content_vector = tf.divide(tf.add_n([doc2vec_doc_vec, doc2vec_word_vec]), 2)
# loss
loss = tf.reduce_mean(tf.nn.nce_loss(nce_weights, nce_biases, <doc2vecの教師データ>, content_vector, 64, num_word))
loss += tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(labels=<カテゴライズの教師データ>, logits=predicted_category))
```

## 結果

40万件ほどの評価に追加で200万件の口コミを教師として50epoch(評価ベースで)ほど学習させてお店への評価を推定させたところ、accuracyは教師データで97%以上、テストデータで71.2%に向上しました。
実用に使うにはもうちょっと精度を上げたいところですが、前のモデルに比べるとそこそこ精度が上がりました。
doc2vecを拡張してユーザーベクトル・店舗ベクトルを直接end-to-endで学習させ、それを利用してカテゴライズの精度を上げることは出来たようです。

# 最後に

実際のところ、文脈に影響を与えるのはユーザーと店舗だけではなくて、天気や、いつ・誰と・どういう目的で訪れたのかなどのシチュエーションも影響を与えるだろうなーとは思うのですが、今回はそういった要素を無視して単純化して実験しました。
シチュエーションもうまく取り込めればさらに精度上げれるかなー？でも教師データどうやって作ろうか。