機械学習によるレコメンドエンジンで自分に小説をおすすめした

はじめに

この一年ほど、小説をよく読んでいます。それまでは一年に一冊も小説を読まなかったのですが、一度読んでみるとだんだん自分の好みが分かってきて楽しくなりました。

すると、自分の好みに合う小説や、以前読んで気に入った小説に似た小説が読みたくなります。Amazonの「この商品をチェックした人はこんな商品もチェックしています」のレコメンドを参考に新しい本を知ることが多いです。

しかし、Amazonが作ったブラックボックスのアルゴリズムに自分の興味を操作されるのはなんか嫌ですね。そこで機械学習によるレコメンドエンジンを作って自分に本をおすすめすることにしました。

スクレイピング

レコメンドエンジンを作るためのレビューデータとして、読書メーターをスクレイピングしました。

読書メーターではユーザが読んだ本を登録することができます。この、それぞれのユーザがどの本を何回「読んだ本」リストに登録したかというデータを用います。「AさんはX, Y, Zの3冊を読んだ」というようなデータです。本の作家の名前やあらすじなど、それ以外の情報は用いていません。

ユーザIDがuser_idのユーザの読んだ本は、https://bookmeter.com/users/{user_id}/books/readで見ることができます。user_idは1から始まる自然数であり、約150万まで存在しました。この中から20%分のIDをサンプリングして、十分に間隔を空けながらforループで上のページをスクレイピングしました。

その結果、IDが存在しないユーザ(おそらく退会したユーザ)や、1冊も読んでいないユーザを除外して、168,262人のユーザによる1,679,143冊の本に対する22,188,378件のレビューを得ることができました。

さて、今回取得した168,262人のユーザに最も読まれた本は何でしょうか?

titleauthorcountuser_count
“阪急電車 (幻冬舎文庫)”“有川 浩”1236811712
“夜は短し歩けよ乙女 (角川文庫 も 19-2)”“森見 登美彦”1086210318
“西の魔女が死んだ (新潮文庫)”“梨木 香歩”1058710090

阪急電車」でした。11,712人のユーザに合計12,368回読まれた本でした。ユーザ数より読まれた回数が多いのは、読書メーターでは同一の本を複数回読んだと登録できるためです。

Implicit Matrix Factorizationのアルゴリズム

今回のレコメンドエンジンで使うImplicit Matrix Factorizationについて説明します。この分野は全くの素人なので誤りがあるかもしれません。

この節の説明は推薦システム実践入門を参考にしました。体系的にレコメンドエンジンを学ぶことができ、理論と実装の説明のバランスが取れているかなりの良書でした。

明示的評価値と暗黙的評価値

いま、ユーザ$u (1, \dots, n)$のアイテム$i (1, \dots, m)$に対する評価値を$r_{u,i}$とします。$r_{u,i}$を行方向にユーザ、列方向にアイテムを取って並べた$n \times m$の行列を評価値行列と呼びます。以下、$R$と表記します。

この評価値には、明示的評価値と暗黙的評価値の二種類があります。

明示的評価値とは、ユーザが直接アイテムに得点を付けたような評価値を指します。Amazonのレビューの点数のようなものですね。一方、暗黙的評価値とは、ユーザがアイテムに対して起こした行動に関するデータです。例えばECサイトでのアイテムの閲覧回数です。今回用いる読書メーターの評価値は、ユーザがそれぞれの本を読んだと登録した回数であり、暗黙的評価値にあたります。

明示的評価値はユーザの好みを正確に示したデータですが、暗黙的評価値はユーザの好みをそのまま反映しているとは限りません。そのため、各種のレコメンド手法は、明示的評価値に適用できるものと暗黙的評価値に適用できるものに分かれます。

行列分解によるレコメンドエンジン

評価値行列$R$を、ユーザの特徴を表すユーザ行列とアイテムの特徴を表すアイテム行列という二つの行列に分解することを考えます。具体的には、$R$を$R = PQ^{T}$で表される行列$P (n \times k)$, $Q (m \times k)$に分解します。

$P$をユーザ行列、$Q$をアイテム行列と呼びます。$k$は潜在因子数というハイパーパラメータです。この操作は、ユーザ$u$とアイテム$i$をそれぞれ$k$次元のベクトルで表現するということです。$k$は大きい値にするほど表現力が高くなりますが、過学習しやすくなります。

以上より、ユーザ$u$のアイテム$i$に対する評価値$r_{u,i}$の予測値はベクトルの内積$P_{u} Q_{i}^{T}$で求められます。評価値の予測値が高いものをユーザにレコメンドします。

Implicit Matrix Factorization

Implicit Matrix Factorizationは、暗黙的評価値に対して適用できる、行列分解による協調フィルタリングベースのレコメンド手法の一つです。

概要を簡単に説明します。以下、$r_{u,i}$を暗黙的評価値とします。

$\bar{r}_{u,i}$を$\bar{r}_{u,i} = 1 (r_{u,i} > 0), 0 (r_{u,i} = 0)$で定義します。$r_{u,i}$が0より大きい正の値であれば、ユーザ$u$はアイテム$i$に対して好意を持っていることを示します。$\bar{r}_{u,i}$は、好意を持っているかどうかを示す0/1の変数です。

$c_{u,i} = 1 + \alpha r_{u,i}$で定義される$c_{u,i}$を信頼度と呼びます。

$r_{u,i} = 0$の場合、ユーザ$u$はアイテム$i$に対して好意を持っていないとは限りません。そのアイテムを知らなかっただけの可能性もあるからです。そのため、$r_{u,i} = 0$の場合でも$c_{u,i} = 1$を割り当てます。

$r_{u,i}$が大きければ大きいほどユーザ$u$はアイテム$i$に対して大きな好意を持っていると考えられますが、暗黙的評価値である$r_{u,i}$をそのまま好意の度合いとして使用することは適切ではありません。$r_{u,i}$がどの程度好意を表すかというパラメータ$\alpha$を導入して、好意の信頼度$c_{u,i}$を定義します。

Implicit Matrix Factorizationで求められるユーザ行列$P$とアイテム行列$Q$は、以下を満たす行列です。

$$ min_{p,q} \sum_{u} \sum_{i} c_{u,i} (\bar{r}_{u,i} - p_{u}^{T} q_{i})^{2} + \lambda (\sum_{u} ||p_{u}||^{2} + \sum_{i} ||q_{i}||^{2}) $$

右辺第2項は過学習防止のためのL2正則化であり、$||p_{u}||^{2} = p_{u,1}^{2} + p_{u,2}^{2} + \dots$(L2ノルム)です。

この関数を普通に最小化しようとするとユーザ数 x アイテム数を計算することになり計算コストが大きいですが、implicit alternating least squares(iALS)というアルゴリズムを用いるとこの目的関数を効率的に最小化することができます。2008年の論文のモデルですが、Revisiting the Performance of iALS on Item Recommendation Benchmarksによると、ハイパーパラメータを調整することで2022年時点では深層学習系のモデルと引けを取らない精度が出るそうです。

アルゴリズムについては参考文献に載せたとおり素晴らしく分かりやすく解説されたサイトがありますので、詳細はそちらをご覧ください。

実装

評価値行列の作成

環境はPython 3.12.0, polars 1.31.0, implicit 0.7.2です。

import implicit
import numpy as np
import polars as pl
from scipy import sparse
from threadpoolctl import threadpool_limits
from tqdm import tqdm

次のデータを持っています。ユーザuser_idが本book_idcount回読んだというデータであり、user_idbook_idの組み合わせ数だけレコードがあります。ただし表示しているuser_idは匿名化したものであり、読書メーターの実際のuser_idからは変更しています。なお、読まれた人数が少ない本や、読んだ本の数が少ないユーザによるレビューを除外しています。

shape: (14_841_848, 3)
┌─────────┬──────────┬───────┐
│ user_id ┆ book_id  ┆ count │
╞═════════╪══════════╪═══════╡
│ 1       ┆ 2845     ┆ 1     │
│ 1       ┆ 104674   ┆ 1     │
│ 1       ┆ 105027   ┆ 1     │
│ 1       ┆ 105086   ┆ 1     │
│ 1       ┆ 105096   ┆ 1     │
│ …       ┆ …        ┆ …     │
│ 134877  ┆ 20586079 ┆ 1     │
│ 134877  ┆ 20716260 ┆ 1     │
│ 134877  ┆ 21248535 ┆ 1     │
│ 134877  ┆ 21658085 ┆ 1     │
│ 134877  ┆ 21700595 ┆ 1     │
└─────────┴──────────┴───────┘

このDataFrameから評価値行列を作成します。

user_ids = np.array(sorted(set(df.get_column("user_id"))))
book_ids = np.array(sorted(set(df.get_column("book_id"))))

user_id2index = dict(zip(user_ids, range(len(user_ids))))
book_id2index = dict(zip(book_ids, range(len(book_ids))))
index2user_id = dict(zip(range(len(user_ids)), user_ids))
index2book_id = dict(zip(range(len(book_ids)), book_ids))

# implicit.als.AlternatingLeastSquares.fitに通せるのはcsr_matrixだが、
# 行方向の代入はlil_matrixのほうが早いので、lil_matrixで代入してからcsr_matrixに変換する
feature_matrix = sparse.lil_matrix(
    np.zeros((len(user_ids), len(book_ids)), dtype=np.int8)
)
# Implicit Matrix Factorizationのalpha
alpha = 1.0
for u, b, c in tqdm(zip(
    df.get_column("user_id").to_numpy(),
    df.get_column("book_id").to_numpy(),
    df.get_column("count").to_numpy()
)):
    feature_matrix[
        user_id2index[u], book_id2index[b]
    ] = 1.0 * alpha
feature_matrix = feature_matrix.tocsr()

同一のユーザが同一の本を2回以上読んでいることがありますが、評価値行列では1回しか読んでいないという扱いにしました。$r_{u,i} \geq 2$の場合は$r_{u,i} = 1$として扱ったということです。読書メーターの「読んだ本」の登録は何回でもできるのですが、たいていのユーザは1回だけ登録している一方、一部のユーザは何回も登録しているようなことがあるため、2回以上読んだ場合でも1回とみなす方が適当に思われたためです。

134,877人のユーザ x 119,737冊の本の合計14,841,848件のレビューを評価値行列とします。

feature_matrix
<Compressed Sparse Row sparse matrix of dtype 'int8'
    with 14841848 stored elements and shape (134877, 119737)>

モデルの学習

Pythonではimplicit.als.AlternatingLeastSquaresで実装されているのでこれを使います。

なお、潜在因子数$k$は$k = 512$としました。いろいろ試してみて、なんとなく妥当な結果だと思えたのが512だからです。評価指標を用いてちゃんと決めたほうがいいです。

また、その他のハイパーパラメータはデフォルト値のままですが、先の論文によるとハイパーパラメータチューニングによって大きく性能が変わるようなのでこれもちゃんとチューニングしたほうがいいです。論文によれば、iALSはまずは$k$をできるだけ大きく取り、次に正則化パラメータ$\lambda$を調整することでよい精度が出るそうです。(ハイパーパラメータのチューニングは別の記事にするかもしれません)。

i9-9900K(16スレッド)でスレッド並列で実行すると2分くらいで計算が終わります。データセットの規模の割に高速ですね。

# これを実行するとマルチスレッド環境でimplicit.als.AlternatingLeastSquaresの計算速度が落ちない
threadpool_limits(1, "blas")
model = implicit.als.AlternatingLeastSquares(
    factors=512,
    regularization=0.01, # デフォルト値
    iterations=10,
    calculate_training_loss=True,
    random_state=1,
)
# 元のfeature_matrixでモデルを再学習(新しいユーザ推薦用)
model.fit(feature_matrix)

レコメンド

実際にレコメンドを出してみます。

インプットにしたい本を評価値行列にして、partial_fit_usersというメソッドで学習済みモデルから埋め込みベクトルを作り、recommendでレコメンドを出せます。このときすべてのデータセットで再学習する必要はなく、1秒もしないうちに結果が出るので一度学習済みモデルを作ってしまえばとても使い勝手が良いです。

わたしは綿矢りささんが好きです。女性の割り切れない感情を繊細で美しい文章で表現するところが好きです。というわけでまずは綿矢さんの「かわいそうだね?」に対するレコメンドを出してみます。

# 新しいユーザの評価値行列を付ける(今レコメンドを出したいユーザ)
new_book_ids = [4255880]
new_feature_matrix = sparse.lil_matrix(
    np.zeros((1, len(book_ids)), dtype=np.int8)
)
for nbi in new_book_ids:
    new_feature_matrix[0, book_id2index[nbi]] = 1.0 * alpha
new_feature_matrix = new_feature_matrix.tocsr()

# 新しいユーザのインデックス
new_user_index = feature_matrix.shape[0]
# 新しいユーザをモデルに追加学習させる
model.partial_fit_users([new_user_index], new_feature_matrix)

# 新しいユーザに対するレコメンド
ids, scores = model.recommend(
    userid=new_user_index,
    user_items=new_feature_matrix,
    N=100,
    filter_already_liked_items=True
)

res = (
    pl.DataFrame({"book_id": [index2book_id[i] for i in ids], "score": scores})
    .with_columns(book_id=pl.col("book_id").cast(pl.Int32))
)

# 別に持っていたbook_idと著者名のマスタテーブルから著者名を付ける
res = (
    res
    .join(books, on="book_id", how="left")
    .with_columns(score=pl.col("score").round(4))
    .select("title", "author", "score")
)
res.head(10)
titleauthorscore
“何者”“朝井 リョウ”0.0207
“勝手にふるえてろ”“綿矢 りさ”0.0167
“蹴りたい背中 (河出文庫 わ 1-2)”“綿矢 りさ”0.0161
“推し、燃ゆ”“宇佐見りん”0.0139
“ひらいて”“綿矢 りさ”0.0137
“勝手にふるえてろ (文春文庫 わ 17-1)”“綿矢 りさ”0.0132
“蹴りたい背中”“綿矢 りさ”0.0125
“ふがいない僕は空を見た”“窪 美澄”0.0118
“しょうがの味は熱い”“綿矢 りさ”0.0109
“すべて真夜中の恋人たち”“川上 未映子”0.0109

似ている度合いのスコアが高い順に並べました。特徴量に作家名は使っていないにもかかわらず、インプットと同じ綿矢りさ作品が多く並んでいるのが驚きです。本を読む人は、読んだ本の別の作家の本を読んだり作家を追っていたりすることが多いので、協調フィルタリングがこのような結果を返すのは納得です。

なお、同一タイトルの本が複数回登場していますが、単行本と文庫本の違いです。読書メーターでは単行本と文庫本はそれぞれ別の本として存在するためです。レコメンド上はどちらか一つに揃えてもよいのですが、元のデータが分かれているので仕方ないものとしてそのままにしています。

一方で、別の本として扱うことにメリットもあります。ふつう、最初に単行本で発売され、ある程度売れると文庫本が出ます。そのため、単行本で読んだ人はその作者を追っている熱心なファンである可能性が高く、単行本で読んだか文庫本で読んだかは異なる情報を持っています。特徴量設計の難しいポイントですね。

複数冊をインプットにしてレコメンドすることもできます。大人のやさしい恋愛小説やささやかな日常をテーマにした短編が好きなので、以下の2冊でレコメンドしてみます。

titleauthorscore
“マカン・マラン - 二十三時の夜食カフェ”“古内 一絵”0.0053
“わたしたちは銀のフォークと薬を手にして”“島本 理生”0.0051
“女王さまの夜食カフェ - マカン・マラン ふたたび”“古内 一絵”0.0049
“神さまを待っている”“畑野 智美”0.0048
“大人は泣かないと思っていた”“寺地 はるな”0.0046
“あなたの愛人の名前は”“島本 理生”0.0045
“婚活中毒”“秋吉 理香子”0.0044
“きまぐれな夜食カフェ - マカン・マラン みたび (単行本)”“古内 一絵”0.0043
“BUTTER”“柚木 麻子”0.0043
“デートクレンジング”“柚木 麻子”0.0043

この辺りの本をよく読む方なら納得ではないでしょうか。

わたしたちは銀のフォークと薬を手にして」と「大人は泣かないと思っていた」はわたしのAmazonのほしいものリストに入っていました。自分で作ったレコメンドエンジンに好みを当てられていますね。

デートクレンジング」が気になったので実際に買って読んでみました。文庫本では「踊る彼女のシルエット」に改題されています。女性はある程度の年齢になると結婚や出産の有無で規定されがちという息苦しさをテーマにした小説です。ストーリーの展開は好みが分かれそうですが、柚木さんらしい視線のするどさもあってお気に入りの一冊になりました。これは「大人になったら、」と近いテーマでして、レコメンドエンジンの真骨頂を感じました。

レコメンドエンジンのすごさを感じたのはこちらの二冊です。

後述のとおり課題点なのですが、実行するとこの二人の本ばかり上位に出てしまうので、結果のうち二人の本以外のものに絞って載せます。

titleauthorscore
“三千円の使いかた (中公文庫 は 74-1)”“原田 ひ香”0.0355
“傲慢と善良 (朝日文庫)”“辻村 深月”0.0344
“満月珈琲店の星詠み (文春文庫 も 29-21)”“望月 麻衣”0.0338
“和菓子のアン (光文社文庫 さ 24-3)”“坂木 司”0.0296
“マカン・マラン - 二十三時の夜食カフェ”“古内 一絵”0.0295
“52ヘルツのクジラたち (単行本)”“町田 そのこ”0.0274
“タルト・タタンの夢 (創元推理文庫)”“近藤 史恵”0.0268
“コンビニ人間 (文春文庫 む 16-1)”“村田 沙耶香”0.0255
“夜空に泳ぐチョコレートグラミー (新潮文庫)”“町田 そのこ”0.0253
“そして、バトンは渡された (文春文庫 せ 8-3)”“瀬尾 まいこ”0.0246

「木曜日にはココアを」はカフェ、「キッチン常夜灯」はビストロを舞台に、そこに集う人たちの悩みを癒やしていく物語です。

満月珈琲店の星詠み」、「和菓子のアン」、「マカン・マラン - 二十三時の夜食カフェ」、「タルト・タタンの夢」と、飲食店が舞台で似たテーマの小説が出てくるのは素晴らしいですね。特徴量は各ユーザがそれぞれの本を読んだか読んでいないかというデータであり、あらすじの情報は用いていないにもかかわらず、好みに合いそうな小説を上手に選ぶことができています。

おわりに

想像していたよりもいい感じにレコメンドできたので、StreamlitでWebアプリにして早速使いつつ(このアプリは個人利用目的であり非公開です)、レコメンドされた作品をいくつか買って読んでいます。

今のモデルでは、インプットに入れた本の作家の別の本ばかりがレコメンドされたりする課題があり、改善したいところです。同じ作家の本を読む人が多いから協調フィルタリングがそのような結果を返すのは当然なのですが、「推薦システム実践入門」にも書いてあるように、レコメンド結果の面白さは「意外性」が大切です。

iALSは学習が高速ながら精度が出る優れたモデルなので、先に挙げた論文を読んで勉強してハイパーパラメータチューニングにも取り組みたいです。

参考文献