対話型推薦システムの実装

# はじめに

推薦システムは、ユーザの興味を惹きそうなアイテムを推測し、提案するアルゴリズムです。YouTubeやAmazonをはじめ、様々なwebサービスで活用されており、Qiitaでもトップページを開くと、おすすめの記事が表示されます。

多くの場合、ユーザの属性情報や行動履歴のデータを収集し、これを元に推薦を行います。近年では、これらの情報に加えて、システムがユーザの対話と通じ、ユーザの好みに関する情報を収集して推薦を行う対話型推薦システム（Conversational Recommender Systems）に関する研究も増えています。十分な履歴データがない新規ユーザに対する推薦、いわゆるコールドスタート問題に対する手法としても注目されています。

対話型推薦システムにおいては、ユーザの好みをより早く、正しく把握するために、ユーザに投げかける質問内容を最適化する必要があります。

本記事では、動的な質問生成に関する研究である[[1]](#参考文献)で提案されている手法について、その一部を実装し、映画のレビューのデータセットであるMovieLens[[2]](#参考文献)を用いて、その挙動について確認してみました。

# 今回取り扱うシステムの概要

対話型推薦システムと一言で言っても、その実装方法や内部のロジックには様々なパターンが考えられます。そのため、今回取り扱う対話型推薦システムの挙動について、その概要を整理します。

## 全体の流れ

大まかな全体の流れとしては、以下に示す形になります。

1. システムがユーザに対して質問を投げかける。
2. ユーザが質問に回答する。
3. 既定の条件が満たされるまで1.〜2.を繰り返し、システムは最終的な推薦リストをユーザに提示する。

1〜2番の質問の生成、回答については、自然言語処理的な要素を含むものもありますが、ここでは選択式の質問を想定します。したがって、システム側はYes/Noで答えられる質問か、複数の候補から選択させるような質問を生成します。ユーザの回答は、質問内容に応じた0/1のフラグをつけるようなものになります。

3番の推薦リストの生成については、質問によって得られた情報をユーザの属性情報や、擬似的な履歴情報として扱い、一般的な推薦モデルの枠組みで推薦するアプローチを取ります。

また、質問の終了条件についてですが、モデルが推薦内容に対して、高い確信度を持てた場合や、一定の回数をこなした場合などが考えられます。今回は一番簡単な一定の回数を繰り返す実装を行います。

したがって、今回の実装の肝となるのは、どのような質問を投げかけるか、を決める部分になります。

## 質問の内容

今回は選択式の質問を取り扱うことにしましたが、この中でも質問のパターンにはいくつか考えることができます。大きな区分としては、属性情報に関する質問を投げるか、アイテムに関する質問を投げるか、の二つです。

属性情報に関する質問の例としては、「アクション映画は好きですか？」といったものが考えられます。アイテムに対して、何らかの属性情報が付与されているような、内容ベースの推薦システムと相性がよいです。

アイテムに関する質問の例としては、映画を1本ピックアップし、「この映画に興味がありますか？」といった質問をします。これに対し、Yesと答えたら、これを高く評価したような扱いをし、Noと答えたら低く評価したよう扱いをした擬似的なレコードデータを作成します。このような質問は協調フィルタリングに基づく推薦システムと相性がよいです。

一般に、映画のレビューは、映画を見終わった上でレビューをするので、タイトルやサムネイルだけ見て面白そう、と思ったものの実際に見てみたらあまり面白くなかった、ということも当然起こり得ます。そのため、この擬似的なレコードの扱いの正確性については、怪しい部分もありますが、少なくとも何の情報も持たない推薦よりは良くなるであろう、という期待があるものと私は認識しています。また、後にも触れますが、推薦モデルはある程度人気度の高いアイテムを高い順位に置くことが多いため、あまり期待外れな展開にはならない、という期待もあると推察します。

当然ながら、何を推薦するシステムかによって、相性の良い質問のやり方は変わりますので、実際に利用する場合には、適切な手法をよく検討する必要があります。今回は対話型推薦システムの挙動を確認することを目的としているので、この点については深く考えないことにします。

また、ここでは属性、アイテムに関しても一つピックアップし、好みかどうかを問うような絶対評価の質問を説明しましたが、複数個を選び、一番興味を惹くものを選択させる、といった相対評価の質問も考えられます。

今回取り上げる文献[[1]](#参考文献)では、アイテムに関する質問をターゲットとしていますが、その中で、一つのアイテムを取り出して評価させる方法と、二つのアイテムを取り出し、どちらがより好みかを選ばせる方法の2パターンで実験を行っています。

本記事における実装では、二つのアイテムから一つを選ばせる相対評価の方式を試してみます。さらに、この方式の場合、履歴データの作り方として、文献[[1]](#参考文献)では以下の二つのパターンが提案されています。それぞれ良し悪しはあると思いますが、今回は後者の手法を採用します。
- 高く評価したものを、高評価のレコードデータとして追加する。
- 高く評価したものを、高評価のレコードデータとして、もう一方を低評価のレコードデータとして追加する。


## アイテムの選び方

最後に決めるべきは、この質問の中で、どのアイテムを選択するかです。このような対話型の試みでは、いわゆる「探索と活用」のバランスが重要となります。

今回の記事では取り上げませんが、強化学習を用いた対話型推薦の試みも存在します。

今回取り上げた論文[[1]](#参考文献)の大きなテーマの一つは、この「探索と活用」の部分であり、様々なパターンで実験をしているのですが、この記事においては、シンプルな貪欲方策を採用して、大まかな挙動の様子を把握することをゴールとします。

提示する二つのアイテムを選ぶ方法ですが、論文[[1]](#参考文献)にしたがって、以下のように定めます。

- 現在のユーザの履歴を元にした推薦リストの最上位に来るアイテム
- 上記のアイテムにネガティブな評価をしたと仮定した場合に最上位に来るアイテム

履歴が存在しない新規ユーザに対しては、一番最初は、全ユーザの埋め込みベクトルの平均をユーザのベクトルとして考え、推薦リストを生成します。

# 実装

システムの概要を定めたので、実際にこれを実装していきます。今回は推薦モデル部分についてはSurprise[[3]](#参考文献)というライブラリに実装されているSVDを利用します。

今回用いるライブラリのバージョンは以下になります。

```
Python 3.9.19
---
Numpy 1.23.5
Pandas 1.5.3
Surprise 1.1.3
```

最初に、今回使うライブラリをインポートします。
```Python
import surprise
import numpy as np
import pandas as pd
import copy
```

## データの読み込み

冒頭に述べた通り、今回はMovieLens[[2]](#参考文献)のデータセットを利用します。ここでは、一番小さな100kを利用します。

```Python
data_dir = "ファイルの場所を指定"
data = pd.read_csv(data_dir+"u.data", sep="\t", header=None, names=["userID", "itemID", "rating", "timestamp"])
data = data[["userID", "itemID", "rating"]] # timestampは今回利用しません。
```

## 最初の質問の生成

まずは、このデータを用いてモデルを学習させます。

```Python
train_set = surprise.Dataset.load_from_df(data, reader= surprise.Reader(rating_scale=(1, 5))).build_full_trainset()

# model train
svd = surprise.SVD(random_state=0, n_factors=200, n_epochs=30, verbose=False)
svd.fit(train_set)
```

新規ユーザはレコードデータを持っていないため、全ユーザの平均ベクトルを取り出し、これを新規ユーザのベクトルとみなし、各アイテムとのスコアを計算します。そして、1番スコアの高いアイテムを取り出します。

```Python
# make user vec
average_user_vec = np.mean(svd.pu, axis=0)
average_user_bias = np.mean(svd.bu)

# make prediction
mu = train_set.global_mean
score_dict = dict()

for iid in train_set.all_items():
    item_vec = svd.qi[iid]
    item_bias = svd.bi[iid]

    score = np.dot(average_user_vec, item_vec) + average_user_bias + item_bias + mu

    score_dict[iid] = score

sorted_score = sorted(score_dict.items(), key=lambda x:x[1], reverse=True)

inner_id, score = sorted_score[0]
first_choice_raw_id = train_set.to_raw_iid(inner_id)
```

次に、このアイテムにネガティブな評価をした場合の擬似レコードデータを作成し、2番目の選択肢のアイテムを同様に選びます。

```Python
# make virtual observation
virtual_data = data.copy()
virtual_data.loc[len(virtual_data)] = [1000, first_choice_raw_id, 1] # 擬似的にユーザIDとして、1000番を振る（被らないように注意）

# model train (2nd)
train_set = surprise.Dataset.load_from_df(virtual_data, reader= surprise.Reader(rating_scale=(1, 5))).build_full_trainset()

svd = surprise.SVD(random_state=0, n_factors=200, n_epochs=30, verbose=False)
svd.fit(train_set)
```

今回は、新規ユーザは学習データ中に擬似的なレコードデータを持っているので、このユーザのベクトルを取り出します。

```Python
# make new user vec
new_user_inner_id = train_set.to_inner_uid(1000)

new_user_vec = svd.pu[new_user_inner_id]
new_user_bias = svd.bu[new_user_inner_id]

# predict
mu = train_set.global_mean
score_dict = dict()

for iid in train_set.all_items():
    item_vec = svd.qi[iid]
    item_bias = svd.bi[iid]

    score = np.dot(new_user_vec, item_vec) + new_user_bias + item_bias + mu

    score_dict[iid] = score
    
sorted_score = sorted(score_dict.items(), key=lambda x:x[1], reverse=True)

inner_id, score = sorted_score[0]
second_choice_raw_id = train_set.to_raw_iid(inner_id)
```

得られた二つのアイテムについて、確認すると、以下の二つとなりました。

| Movie ID | Movie Name | Movie Genre |
| --- | ---- | ---- |
|318| Schindler's List (1993) | 'Drama', 'War' |
|50| Star Wars (1977) | 'Action', 'Adventure', 'Romance', 'Sci-Fi', 'War' |

## 次の質問の生成

以後は、この二つのアイテムのどちらを選んだかによって、擬似レコードを追加しながら質問していきます。

これを関数の形で整備しておきます。

```Python
def predict_with_observation(data, obseravation_data):
    # 観測済みデータの追加
    virtual_data = data.copy()
    observed_ids = []
    for d in obseravation_data:
        virtual_data.loc[len(virtual_data)] = d
        observed_ids.append(d[1])

    train_set = surprise.Dataset.load_from_df(virtual_data, reader= surprise.Reader(rating_scale=(1, 5))).build_full_trainset()

    svd = surprise.SVD(random_state=0, n_factors=200, n_epochs=30, verbose=False)
    svd.fit(train_set)
    
    # make new user vec
    new_user_inner_id = train_set.to_inner_uid(1000)
    
    new_user_vec = svd.pu[new_user_inner_id]
    new_user_bias = svd.bu[new_user_inner_id]

    # predict
    mu = train_set.global_mean
    score_dict = dict()
    
    for iid in train_set.all_items():
        # すでに観測したデータは推薦リストに含めない
        if train_set.to_raw_iid(iid) not in observed_ids:
            item_vec = svd.qi[iid]
            item_bias = svd.bi[iid]
            score = np.dot(new_user_vec, item_vec) + new_user_bias + item_bias + mu
            score_dict[iid] = score
        
    sorted_score = sorted(score_dict.items(), key=lambda x:x[1], reverse=True)

    raw_id_list = [0] * len(sorted_score)
    score_list = [0] * len(sorted_score)

    for i, (id, score) in enumerate(sorted_score):
        raw_id_list[i] = train_set.to_raw_iid(id)
        score_list[i] = score
    
    return raw_id_list, score_list


def make_new_question(data, observation_data):
    # first choice
    first_choice_raw_id = predict_with_observation(data, observation_data)[0][0]

    virtual_observation_data = copy.deepcopy(observation_data)
    virtual_observation_data.append([1000, first_choice_raw_id, 1])

    # second choice
    second_choice_raw_id = predict_with_observation(data, virtual_observation_data)[0][0]

    return first_choice_raw_id, second_choice_raw_id
```

先ほどの二つの質問で、「Star Wars (1977) 」を選択した場合、以下のようにすることで、次の質問が得られます。

```Python
make_new_question(data, [[1000, 318, 1], [1000, 50, 5]])
# (172, 641)
```
得られたアイテムについて確認すると以下のようになります。

| Movie ID | Movie Name | Movie Genre |
| --- | ---- | ---- |
|172| Empire Strikes Back, The (1980)| 'Action', 'Adventure', 'Drama', 'Romance', 'Sci-Fi', 'War'|
|641| Paths of Glory (1957) | 'Drama', 'War' |

「Star Wars (1977) 」を選択した場合、そのシリーズ作品である「Empire Strikes Back, The (1980)」（邦題：スターウォーズ エピソード5/帝国の逆襲）が推薦リストの最上位に来ているというのは、推薦システムらしい挙動といえます。対話型推薦システムとして、正しく動いているのではないでしょうか。

ちなみに、ここで「Empire Strikes Back, The (1980)」を選ぶと、次は「Return of the Jedi (1983)」（邦題：スターウォーズ エピソード6/ジェダイの帰還）が最上位に来ます。

今回の実装では、質問の選択肢に入ったものは推薦リストから外すようにしています。理由としては、質問生成のロジックの関係で、選択したアイテムは以後最上位に来ることが多く、同じアイテムが質問に何度も出現することになるので、より多様な情報を集めるために外すことにしました。

# 結果の確認と考察

以上の手順を繰り返し、3問ほど質問を生成させると、今回のシステムでは、新規ユーザに対しては以下のような質問の分岐をしていくことがわかりました。

![分岐図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2670216/3fbbeac8-930a-3d3f-58d1-f3744203ff7b.png)


見ていただくとわかるのは、様々なルートで「Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1963)」という映画が出現していることです。

次に、最終的に出力された映画の推薦リストについて、8個全部を見ると大変なので、1番上のパターンと、1番下のパターンについて、上位10件を見てみることにします。

| # | 1番上のパターン | 1番下のパターン |
|---|---|---|
| 1 | Amadeus (1984) | Sting, The (1973) |
| 2 | __Boot, Das (1981)__ | Third Man, The (1949) |
| 3 | __Rear Window (1954)__ | __Rear Window (1954)__ |
| 4 | To Kill a Mockingbird (1962) | Once Were Warriors (1994) |
| 5 | The African Queen (1951) | Local Hero (1983) |
| 6 | __It's a Wonderful Life (1946)__ | __It's a Wonderful Life (1946)__ |
| 7 | Chinatown (1974) | __Affair to Remember, An (1957)__ |
| 8 | __Affair to Remember, An (1957)__ | Full Monty, The (1997) |
| 9 | Room with a View, A (1986) | Good Will Hunting (1997) |
| 10 | Close Shave, A (1995) | __Boot, Das (1981)__ |

見ていただけるとわかる通り、上位10件中、4件が被っています。ここでは、二つのリストしか見ていませんが、残りのリストと見比べてみても、それなりに被りがあります。

これは推薦モデル一般に言える傾向なのですが、全体で見た際に、人気が高いアイテムはあらゆるユーザの推薦リストの上位に出てくる可能性が高いです。そして、精度ベースで評価した場合、このような方針は多くの場合高いスコアに結びつきます。実際、推薦モデルなどを使わずとも、単純に人気度が高い順で推薦するだけでも、それなりに高い精度が出ることは多いです。

そのため、推薦モデルの挙動としては自然ではあるのですが、対話型という観点から見た際に、対話を行うことで、積極的にパーソナライズをしようと試みているのに、思ったほどパーソナライズできていない、と捉えることもできそうです。一方で、3問という限られた質問数であること、あまり的外れな推薦をしてもしょうがないことを考えると、10個中4個しか被っていないならば、十分パーソナライズできているという評価もありえると思います。

結果と評価については、扱うデータセット、利用するモデル、推薦システムに求められる役割によって変化する部分も大きいと思いますので、あくまで今回の簡単な検証の範囲での結果として参考にしていただければ幸いです。


# 参考文献

1. [Konstantina Christakopoulou, Filip Radlinski, and Katja Hofmann. 2016. Towards Conversational Recommender Systems. In Proceedings of the 22nd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining (KDD '16). Association for Computing Machinery, New York, NY, USA, 815–824.](https://dl.acm.org/doi/abs/10.1145/2939672.2939746)

2. [MovieLens](https://grouplens.org/datasets/movielens/) (アクセス日：2024/7/1)

3. [Surprise: A Python library for recommender systems](https://surpriselib.com/) (アクセス日：2024/7/1)