推薦システムで避けては通れないコールドスタート問題
推薦システムを考える上で、避けて通れないのが「コールドスタート問題」です。これは、明示的/暗黙的に関わりなく、多くのアルゴリズムが
- 行動履歴が少ない(あるいはほとんどない)ユーザーには推薦精度が悪くなる(あるいは全くできなくなる)
- 1回もユーザーからの評価(接触)がないアイテムは、どう推薦に組み込めばいいか分からない
という性質を持つことを指します。特に現場で問題になる事象としては、
- 新規登録されたユーザーに対して何を推薦すればいいか分からない
- 新規登録されたアイテムが推薦されないので、それらのアイテムに対するレーティングや閲覧のデータが集まらない。
という点が挙げられます。
対策としては「新規登録されたユーザーに対しては人気順推薦を行う」ことや、「一定数の新規登録アイテムをランダムに推薦に織り交ぜる」などのややアドホックな方法もありますが、もし可能ならば
- ユーザー/アイテムの補助属性情報を使ってそれらしいアイテムを推薦する
という方法が系統的な方法として挙げられます。
例えばMovieLensの例では、「新規登録ユーザーであるこの若い男子大学生は、『風と共に去りぬ』のような古い映画よりも、『スターウォーズ』のほうを平均的に好むだろう」といった類推や、「新規アイテムであるこの映画はジャンル=SFなので、SFを好んで見ているユーザーに当てよう」といった方法が思い浮かぶでしょう。
このような方法を、機械学習を使って自動的に発見するのはコールドスタート問題に対する有力な解決法で、いくつものアルゴリズムが存在します。本連載でも第2回において、簡便かつ実装が容易でありながら比較的優れた精度を示す、CB2CFというアイデアについて紹介します。
システム実装に必要なリソース
データログの収集
暗黙的フィードバックをもとに推薦システムを作るには、前述の通りユーザーとアイテムの接触ログが存在すれば十分です。ただし、次のように接触が発生した時刻も記録することをおすすめします。
UserID | ItemID | TimeStamp |
---|---|---|
1 | 1 | 2020-10-01T01:23:45 |
1 | 2 | 2020-10-01T12:34:56 |
2 | 1 | 2020-10-11T13:58:31 |
2 | 5 | 2020-10-11T17:50:19 |
これは、予測モデルの検証において、交差検証(学習データと検証データの役割を互い違いに変えながらデータを分割する検証手法)を実施する際に、時間を考慮したデータの分割を行う必要が出てくる場合があることや、環境が時間に応じて変わり、現在のユーザーに適用するための推薦システムの構築に利用できるデータが直近のデータに限られる場合があるためです。
このような形式のログデータが会社のデータベースに残っている場合は、そのデータを使って推薦システムを構築するチャンスだと言えます。
前処理 & 頻度によるカットオフ
接触ログが蓄積できたら、
- \(C_u\)回以上接触ログに登場するユーザーのみを残す
- \(C_i\)回以上接触ログに登場するアイテムのみを残す
という手続きでデータをフィルターするのが一般的です。これは、ごく少数のユーザーによってのみしか評価されないアイテムは、他アイテムとの関係も分かりづらい上に、そもそも不人気である可能性も考えられるので、推薦するのに不適でであることや、計算量を抑えて現実的な時間内に計算を終了させる必要があるという理由があります。
疎行列によるデータ表現
すでに述べたように、暗黙的フィードバックとはユーザーIDとアイテムIDの組み合わせに相当します。
ここでは視点を変えて、データを行列として表すことにします。すなわち、既知のユーザーの数を\(u\)、アイテムの数を\(I\)として、レーティング行列\(R\)を
- もし\(u\)と\(i\)に接触があれば、\(R_{ui} = 1\)
- \(u\)と\(i\)に接触がなければ、\(R_{ui} = 0\)
で定義します。レーティング行列\(R\)の各行はユーザーの履歴データ、各列はアイテムの履歴データとみることができます。
ここで、\(R_{ui}\)の\(0\)でない要素は全て\(1\)としましたが、レーティングの値が存在するのであれば、\(1\)とする代わりにレーティングの値に置き換えたり、重複した接触が許される場合は、接触の回数に置き換えたりすることも可能です。ただし、筆者はこのように\(1\)以外も許すことによって推薦精度が改善するデータセットの例を知りません。
試しに、MovieLens 100Kという小さいデータセットでレーティング行列を作ってみましょう。下記コードの実行のためには、次の準備が必要です。
-
MovieLens 100K のデータ(ml-100k.zip)を公式サイトよりダウンロードし、zipファイルを展開しておくこと。ここでは展開結果が
/home/hoge/ml-100k/
に保存されていると仮定します。 -
numpy
、scipy
、pandas
をインストールしておくこと。
import os import numpy as np import pandas as pd import scipy.sparse as sps df = pd.read_csv( # ...(1) '/home/hoge/ml-100k/u.data', sep='\t', header=None, names=['user_id', 'movie_id', 'rating', 'time'] ) # データ読み込み user_ids, user_index = np.unique(df.user_id, return_inverse=True) # ...(2) movie_ids, movie_index = np.unique(df.movie_id, return_inverse=True) # ...(3) R = sps.csr_matrix( (np.ones(len(df)), (user_index, movie_index),), shape=(len(user_ids), len(movie_ids)) ) # レーティング行列 ...(4)
(1)の部分はユーザー/アイテム(ここでは映画)の接触データを読み込んでいます。
また(2)では、第1戻り値(user_ids
)はdf.user_id
から重複が除かれ、かつソートされたものを、第2戻り値(user_index
)は、元の配列(df.user_id
)と同じ長さの配列で、df.user_id
の第i
番目の要素は第1戻り値のどれに相当するかを、それぞれ表します。例えば、仮に df.user_id
が[0, 1, 2, 0, 2]
だった場合、np.unique(df.user_id, return_inverse=True)
の戻り値は [0, 1, 2]
と [0, 1, 2, 0, 2]
になります。user_ids[user_index]
は元の配列と同じものになることに注意しましょう。
(3)は(2)と同様の処理をdf.movie_id
に対して実施しています。
(4)はレーティング行列R
を疎行列として表します。
R = sps.csr_matrix( (np.ones(len(df)), (user_index, movie_index),), shape=(len(user_ids), len(movie_ids)) ) # レーティング行列
これらにおける各引数は
-
np.ones(len(df))
がレーティングの値として何が入るかを表す。ここでは、全ての非ゼロ要素が1であるとするので、np.ones
を使う。 -
(user_index, movie_index)
は、非ゼロ要素がどの行・列に存在するものかを指定する -
shape=(len(user_ids),len(movie_ids))
はR
が何行何列なのかを指定する
という意味を持ちます。
MovieLens 100Kは小さいデータですので、\(R\)を疎行列だと思わなくてもメモリに乗ってしまいます。
しかし、巨大なデータセットでは、\(U\times I \)行列である\(R\)の要求するメモリは\(O(UI)\)となり、このような密行列をメモリに載せるのは困難です。疎行列で\(R\)を表せば、\(R\)の記憶に必要なデータは\(O(\mathrm{NNZ})\)のメモリで表現できます。
ただし\(\mathrm{NNZ}\)は\(R\)の\(0\)でない要素の数、言い換えれば、接触ログのレコード数です。次回以降は、このようにデータが疎行列で表現されているという前提のもと、アルゴリズムの紹介を行います。