推薦システムのしくみ:協調フィルタリング・行列分解から two-tower モデルまで
通販サイトの「この商品を買った人はこんな商品も買っています」、動画サイトのおすすめ、音楽アプリの自動のプレイリストなど、私たちは毎日のように 推薦システムの結果を目にしています。何万・何億もある商品や動画の中から、その人が気に入りそうなものを選んで見せる技術です。
推薦の難しさは、手がかりがとても少ないことです。1 人の利用者が評価したり見たりするのは、全体のほんの一部です。この記事では、その少ない記録から、どうやって好みを推し量るのかを、小さな計算例で確かめます。
結論を先に書くと、推薦システムは次の考え方でできています。
- 好みの似た人が気に入ったもの、よく一緒に選ばれるものを薦める(協調フィルタリング)。作品の中身を知らなくても推薦できる
- 利用者と作品を、同じ空間のベクトルで表す(行列分解)。ベクトルの内積が、その人がその作品を気に入る度合いになる
- 実際に集まるのは「評価の点数」より「見た・買った」の記録。この場合は、点数の誤差ではなく、薦めた上位のものに実際に見たものが含まれるかで測る
- まだ誰も見ていない新しい作品は、協調フィルタリングでは薦められない(コールドスタート)。作品の内容の情報を組み合わせて補う
説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。
環境
計算例を動かした環境です。
| 項目 | バージョン |
|---|---|
| Python | 3.13 |
| NumPy | 2.5 |
推薦の問題を表で考える
推薦の問題は、利用者 × 作品の大きな表で考えると分かりやすくなります。行が利用者、列が作品で、ます目には評価の点数や「見たかどうか」が入ります。ほとんどのます目は空いていて、その空いたます目のうち、どこに高い値が入りそうかを当てるのが推薦です。
手がかりの種類は、大きく 2 つあります。
| 種類 | 例 | 特徴 |
|---|---|---|
| 明示的なフィードバック | 星 1〜5 の評価、「いいね」 | 好き嫌いがはっきり分かるが、集まる量が少ない |
| 暗黙的なフィードバック | 見た・買った・クリックした・最後まで聞いた | 大量に集まるが、「見なかった」が「嫌い」なのか「知らなかった」なのかは分からない |
評価の点数を予測する
平均とかたより
まず、星 1〜5 の評価を予測する問題を考えます。2006〜2009 年に Netflix が、自社の推薦の精度を 10% 上回れば 100 万ドルという賞金をかけたコンテスト(Netflix Prize)を開き、この問題の研究が大きく進みました。
1,000 人 × 500 本の映画で、見えない 5 つの「ジャンル」への好みと、作品のジャンルの重みから評価が決まるデータを作ります。利用者は人気のある作品ほど評価しやすく、1 人 20 本、表全体の 4% のます目だけが埋まっています。そのうち 80% で学習し、残り 20% の評価を予測して、誤差の大きさ(RMSE、二乗誤差の平均の平方根)を測ります。
いちばん簡単な予測は、全体の平均です。次に、甘く付ける人・辛く付ける人、誰もが高く評価する作品・低く評価する作品という「かたより」を加えます。かたよりは、評価の数が少ない人や作品で極端な値にならないように、数に 5 を足して割っています。
import numpy as np
# 1,000 人 × 500 本の映画。好みと作品の特徴は、見えない 5 つの「ジャンル」の重みで決まる
rng = np.random.default_rng(0)
n_users, n_items, k_true = 1000, 500, 5
U_true = rng.standard_normal((n_users, k_true))
V_true = rng.standard_normal((n_items, k_true))
popularity = rng.zipf(1.5, n_items).clip(max=200).astype(float) # 一部の作品だけがよく見られる
score = U_true @ V_true.T / np.sqrt(k_true)
item_bias = 0.5 * rng.standard_normal(n_items)
user_bias = 0.3 * rng.standard_normal(n_users)
rating = np.clip(np.round(3 + user_bias[:, None] + item_bias + score + 0.5 * rng.standard_normal((n_users, n_items))), 1, 5)
# 各ユーザーが評価した作品(人気のある作品ほど評価されやすい)。全体の約 4%
p_seen = popularity / popularity.sum()
seen = np.zeros((n_users, n_items), bool)
for u in range(n_users):
seen[u, rng.choice(n_items, 20, replace=False, p=p_seen)] = True
users, items = np.nonzero(seen)
r = rating[users, items]
perm = rng.permutation(len(r))
tr, te = perm[: int(0.8 * len(r))], perm[int(0.8 * len(r)):]
print(len(r), round(seen.mean(), 3), len(tr), len(te))
rmse = lambda pred: round(float(np.sqrt(np.mean((pred - r[te]) ** 2))), 3)
# 1. 全体の平均
mu = r[tr].mean()
print("平均", rmse(np.full(len(te), mu)))
# 2. ユーザーと作品のかたより(甘い人・辛い人、良い作品・悪い作品)
bu, bi = np.zeros(n_users), np.zeros(n_items)
for _ in range(20):
bi = np.bincount(items[tr], r[tr] - mu - bu[users[tr]], n_items) / (np.bincount(items[tr], minlength=n_items) + 5)
bu = np.bincount(users[tr], r[tr] - mu - bi[items[tr]], n_users) / (np.bincount(users[tr], minlength=n_users) + 5)
print("かたより", rmse(mu + bu[users[te]] + bi[items[te]]))
実行結果:
20000 0.04 16000 4000
平均 1.154
かたより 1.063
評価は全部で 20,000 件で、16,000 件で学習し、4,000 件で測っています。全体の平均で予測すると誤差は 1.154 で、かたよりを加えると 1.063 に下がりました。ただし、かたよりだけでは「誰にとっても良い作品」を薦めるだけで、その人の好みは反映されません。
行列分解:利用者と作品をベクトルで表す
Netflix Prize で大きな成果を上げたのが 行列分解です。利用者 u をベクトル p_u、作品 i をベクトル q_i で表し、評価を次のように予測します。
予測 = 平均 + 利用者のかたより + 作品のかたより + p_u · q_i
ベクトルの各成分は、「アクションが好きか」「この作品はアクションか」のような、はっきりした名前のない隠れた特徴(潜在因子)です。誰も教えていないのに、評価の記録から自動で見つかります。利用者のベクトルと作品のベクトルの向きがそろっているほど、内積が大きくなり、評価が高いと予測されます。表全体で見ると、「利用者 × 作品」の大きな表を、「利用者 × k」と「k × 作品」の 2 つの細長い表の積で近似していることになります。
埋まっているます目の誤差の 2 乗に、ベクトルの大きさの罰則(過学習と正則化のしくみ で説明した L2 正則化)を加えたものを、確率的勾配降下法で小さくします。上のコードの続きです(この環境では 10 秒ほどかかりました)。
# 3. 行列分解:評価 ≒ 平均 + かたより + ユーザーのベクトル・作品のベクトル
def mf(k, lam, epochs=30, lr=0.01):
P = 0.1 * rng.standard_normal((n_users, k)); Q = 0.1 * rng.standard_normal((n_items, k))
b_u, b_i = bu.copy(), bi.copy()
for _ in range(epochs):
for j in rng.permutation(tr):
u, i = users[j], items[j]
e = r[j] - (mu + b_u[u] + b_i[i] + P[u] @ Q[i])
b_u[u] += lr * (e - lam * b_u[u]); b_i[i] += lr * (e - lam * b_i[i])
P[u], Q[i] = P[u] + lr * (e * Q[i] - lam * P[u]), Q[i] + lr * (e * P[u] - lam * Q[i])
return P, Q, b_u, b_i
for k, lam in [(5, 0.0), (20, 0.0), (20, 0.1)]:
P, Q, b_u, b_i = mf(k, lam)
print("行列分解", k, lam, rmse(mu + b_u[users[te]] + b_i[items[te]] + np.sum(P[users[te]] * Q[items[te]], axis=1)))
# 答えを知っている場合(見えない好みと作品の特徴から、雑音なしで計算)
print("本当の値", rmse(np.clip(3 + user_bias[users[te]] + item_bias[items[te]] + score[users[te], items[te]], 1, 5)))
実行結果:
行列分解 5 0.0 0.943
行列分解 20 0.0 0.941
行列分解 20 0.1 0.919
本当の値 0.54
左から、ベクトルの次元 k、正則化の強さ、誤差です。行列分解で、誤差はかたよりだけの 1.063 から 0.94 前後まで下がりました。20 次元にして正則化を加えると 0.919 になり、いちばん良い結果です。1 人あたり 16 件ほどの評価しかないので、正則化でベクトルが極端な値にならないように抑えることが効いています。
最後の行は、データを作ったときの本当の好みと作品の特徴を知っていて、雑音だけが分からない場合の誤差です。行列分解の 0.919 とはまだ差があります。1 人 16 件ほどの評価から、5 つの好みを正確に推し量るのは難しいということです。
視聴の記録だけから薦める
点数の誤差ではなく、上位に当たりがあるかで測る
実際のサービスで大量に集まるのは、評価の点数より「見た・買った」の記録です。この場合、予測の誤差ではなく、薦めた上位 10 本の中に、その人が実際に見た作品がどれだけ含まれるかで推薦を評価します。隠しておいた視聴のうち、上位 10 本に入った割合を Recall@10(再現率)と呼びます(機械学習の評価指標のしくみ で説明した再現率と同じ考え方です)。
2,000 人が 500 本の中から 1 人 30 本ずつ、好みに合う作品・人気のある作品ほど見やすいように見た記録を作ります。各利用者の視聴のうち 5 本を隠し、残りの記録から薦めた上位 10 本に、隠した 5 本がどれだけ入るかを、いくつかの方法で比べます。
人気順・協調フィルタリング・行列分解を比べる
- 人気順:よく見られている作品から順に薦める。誰にでも同じものを薦める
- アイテムベースの協調フィルタリング:「一緒に見られることが多い作品」を薦める。作品どうしの似ている度合いを、見た利用者の重なり(コサイン類似度)で測り、その人が見た作品に似た作品の点数を足し合わせる。2003 年に Amazon が紹介した「この商品を買った人は」の方法です
- 行列分解:視聴の表(見たら 1、見ていなければ 0)を、特異値分解で k 次元のベクトルの積に近似し、近似した表の値が大きい作品を薦める
import numpy as np
# 視聴の記録だけがある場合(評価の点数はない)。好みに合い、人気のある作品ほど見られやすい
rng = np.random.default_rng(0)
n_users, n_items, k_true = 2000, 500, 5
U_true = rng.standard_normal((n_users, k_true))
V_true = rng.standard_normal((n_items, k_true))
popularity = np.log(rng.zipf(1.5, n_items).clip(max=200)) + 1
logit = 1.5 * U_true @ V_true.T / np.sqrt(k_true) + np.log(popularity)
watched = np.zeros((n_users, n_items), bool)
for u in range(n_users):
p = np.exp(logit[u]); p /= p.sum()
watched[u, rng.choice(n_items, 30, replace=False, p=p)] = True
# 各ユーザーの視聴のうち 5 本を隠し、残りから当てる
train = watched.copy()
hidden = np.zeros_like(watched)
for u in range(n_users):
h = rng.choice(np.flatnonzero(watched[u]), 5, replace=False)
train[u, h] = False; hidden[u, h] = True
X = train.astype(float)
def recall_at_10(scores):
scores = np.where(train, -np.inf, scores) # もう見た作品は薦めない
top = np.argsort(-scores, axis=1)[:, :10]
return round(float(np.take_along_axis(hidden, top, axis=1).sum() / hidden.sum()), 3)
print("ランダム", recall_at_10(rng.random((n_users, n_items))))
print("人気順", recall_at_10(np.tile(X.sum(axis=0), (n_users, 1))))
# アイテムベースの協調フィルタリング:一緒に見られることが多い作品を薦める
norm = np.linalg.norm(X, axis=0) + 1e-9
sim = (X.T @ X) / norm[:, None] / norm[None] # 作品どうしのコサイン類似度
np.fill_diagonal(sim, 0)
print("協調フィルタリング", recall_at_10(X @ sim))
# 行列分解:視聴の表を、ユーザーのベクトルと作品のベクトルの積で近似する(特異値分解)
for k in [5, 20, 100]:
Uk, S, Vt = np.linalg.svd(X, full_matrices=False)
print("行列分解", k, recall_at_10((Uk[:, :k] * S[:k]) @ Vt[:k]))
print("本当の好み", recall_at_10(logit))
実行結果:
ランダム 0.021
人気順 0.076
協調フィルタリング 0.227
行列分解 5 0.196
行列分解 20 0.215
行列分解 100 0.109
本当の好み 0.288
ランダムに薦めると、隠した視聴のうち上位 10 本に入るのは 2.1% だけです。人気順にすると 7.6% と 3 倍以上になりますが、誰にでも同じものを薦めるので、限界があります。
アイテムベースの協調フィルタリングは 22.7% と、人気順の 3 倍になりました。作品の中身(ジャンル)を一切使っていないのに、「一緒に見られる」という記録だけで、好みに合う作品を見つけられています。行列分解も、20 次元で 21.5% と近い結果です。一方、100 次元にすると 10.9% に下がりました。ベクトルの次元が大きすぎると、学習に使った表を細かく再現しすぎてしまい、隠した視聴を当てる力が下がるのです(過学習)。
最後の行は、データを作ったときの本当の好みと人気で並べた場合で、どんなに良い方法でもこの 28.8% 前後が上限です。人は好みに合う作品の中からも、ある程度はでたらめに選んで見ているからです。
「見ていない」は「嫌い」ではない
暗黙的なフィードバックでは、見ていない作品をそのまま「0 点」として扱うと、「知らなかっただけ」の作品まで嫌いとみなしてしまいます。そこで、見ていない作品の重みを小さくして学習する方法(2008 年の 重み付きの ALS)や、「見た作品は、見ていない作品より上に来るはず」という 順位の比較で学習する方法(2009 年の BPR)が使われます。BPR の考え方は、LLM の学習のしくみ で説明した報酬モデルの、2 つを比べて学ぶ方法とよく似ています。
まだ誰も見ていない作品を薦める
コールドスタート問題
協調フィルタリングと行列分解には、大きな弱点があります。まだ誰も見ていない新しい作品は、表の列がすべて空いているので、似た作品もベクトルも求められず、薦めることができません。登録したばかりの利用者も同じです。これを コールドスタート問題と呼びます。
作品の内容から薦める
そこで、作品の 内容の情報(ジャンルのタグ、説明文、出演者、画像など)を使います。上のコードの続きで、まだ誰も見ていない新作 100 本を用意します。新作の視聴の記録はありませんが、ジャンルのタグ(作品の本当の特徴に雑音が乗ったもの)は分かるとします。利用者が過去に見た作品のタグを平均して「その人の好み」とし、新作のタグとの内積で薦めます(内容ベースのフィルタリング)。
# 新しい作品 100 本:まだ誰も見ていないが、ジャンルのタグ(作品の特徴に雑音が乗ったもの)は分かる
V_new = rng.standard_normal((100, k_true))
tags_old = V_true + 0.5 * rng.standard_normal(V_true.shape)
tags_new = V_new + 0.5 * rng.standard_normal(V_new.shape)
logit_new = 1.5 * U_true @ V_new.T / np.sqrt(k_true)
will_watch = np.zeros((n_users, 100), bool) # 公開後に見ることになる 3 本
for u in range(n_users):
p = np.exp(logit_new[u]); p /= p.sum()
will_watch[u, rng.choice(100, 3, replace=False, p=p)] = True
def recall_new(scores):
top = np.argsort(-scores, axis=1)[:, :10]
return round(float(np.take_along_axis(will_watch, top, axis=1).sum() / will_watch.sum()), 3)
print("新作・ランダム", recall_new(rng.random((n_users, 100))))
# 内容ベース:見た作品のタグの平均を、そのユーザーの好みとみなす
profile = X @ tags_old / X.sum(axis=1, keepdims=True)
print("新作・内容ベース", recall_new(profile @ tags_new.T))
実行結果:
新作・ランダム 0.094
新作・内容ベース 0.426
各利用者が公開後に見ることになる 3 本が、薦めた上位 10 本に入った割合です。新作は 100 本しかないので、ランダムでも 9.4% は当たりますが、内容ベースでは 42.6% と 4.5 倍になりました。一度も見られていない作品でも、利用者の好みと作品の内容を同じ空間で表せれば、薦めることができます。
内容ベースの方法は、内容の情報が好みをよく表している場合には強力ですが、「似たジャンルばかり薦める」ことになりやすく、タグに表れない好み(演出の雰囲気など)は捉えられません。実際のサービスでは、協調フィルタリングと内容ベースを組み合わせて使います。
two-tower モデルと 2 段階の推薦
利用者と作品を、別々のニューラルネットワークでベクトルにする
行列分解と内容ベースの考え方をまとめたのが、深層学習の two-tower モデルです。利用者の情報(視聴の記録・年齢・地域・検索した言葉など)を 1 つのニューラルネットワーク(塔)で、作品の情報(ID・タグ・説明文・画像など)をもう 1 つの塔で、それぞれ同じ次元のベクトルにし、内積で好みの度合いを表します。作品の内容も入力に使うので、新しい作品のベクトルも作れます。
学習では、実際に見た「利用者と作品」の組の内積を大きく、同じまとまり(バッチ)の中のほかの利用者が見た作品との内積を小さくします。単語の埋め込みのしくみ で説明した、word2vec の負例サンプリングと同じ考え方です。
候補の絞り込みと並べ替え
作品が何億もあると、すべての作品について細かく計算することはできません。そこで、大規模な推薦システムは、2016 年に YouTube が紹介したような 2 段階で動きます。
- 候補の絞り込み:two-tower モデルの利用者のベクトルに近い作品のベクトルを、ベクトル検索 の近似最近傍探索で数百本選ぶ。作品のベクトルは前もって計算しておける
- 並べ替え:数百本の候補それぞれについて、利用者と作品の情報を組み合わせた、より大きなモデルで「クリックする確率」「最後まで見る確率」などを予測し、並べ替える
オフラインの評価と A/B テスト
この記事の計算例のように、過去の記録の一部を隠して当てる評価を オフライン評価と呼びます。ただし、過去の記録は「その時の推薦システムが薦めたもの」に引きずられているので、オフラインで良い方法が、実際に利用者の満足を上げるとは限りません。実際のサービスでは、利用者を 2 つのグループに分けて、新旧の推薦システムを比べる A/B テストで最終的に判断します。
また、好みに合うものばかりを薦めると、利用者が見るものの幅が狭まり(フィルターバブル)、新しい好みを見つける機会が減ります。強化学習のしくみ で説明した「探索と活用」のように、ときどき意外なものを薦めて反応を見ることも大切です。
推薦システムの手法の流れ
この記事で紹介した手法を年代順に並べます。
| 年 | 手法・出来事 | ポイント |
|---|---|---|
| 1992 | Tapestry | 人の評価を手がかりにメールを選ぶ「協調フィルタリング」という言葉が生まれる |
| 1994 | GroupLens | 好みの似た利用者の評価から、ニュース記事を薦める |
| 2003 | Amazon のアイテムベース協調フィルタリング | 作品どうしの似ている度合いを前もって計算し、大規模に推薦する |
| 2006〜2009 | Netflix Prize | 評価の予測のコンテスト。行列分解とモデルの組み合わせで精度を 10% 上げる |
| 2008 | 暗黙的なフィードバックの行列分解(重み付き ALS) | 「見ていない」を弱い 0 として扱う |
| 2009 | BPR | 見た作品が見ていない作品より上に来るように、順位の比較で学習する |
| 2016 | YouTube の深層学習による推薦・Wide & Deep | 候補の絞り込みと並べ替えの 2 段階。深層学習で利用者と作品を表す |
| 2019 | two-tower モデルによる候補の絞り込み | 利用者と作品を別々の塔でベクトルにし、バッチ内の負例で学習する |
まとめ
- 推薦は、ほとんどが空いた「利用者 × 作品」の表の、空いたます目の値を当てる問題と考えられる
- 評価の予測では、平均と利用者・作品のかたよりを引いたうえで、行列分解で利用者と作品をベクトルにし、内積で好みを表す。記録が少ないので正則化が効く
- 視聴の記録だけの場合は、上位に当たりが入る割合(Recall@10 など)で評価する。アイテムベースの協調フィルタリングと行列分解は、人気順を大きく上回る
- 行列分解の次元を大きくしすぎると、学習の記録を再現しすぎて当たらなくなる
- まだ誰も見ていない作品は協調フィルタリングでは薦められない。作品の内容の情報を使えば、新しい作品も薦められる
- 大規模な推薦は、two-tower モデルとベクトル検索で候補を絞り、大きなモデルで並べ替える 2 段階で動き、最終的には A/B テストで評価する
参考
- D. Goldberg et al., "Using collaborative filtering to weave an information tapestry", Communications of the ACM 1992
- P. Resnick et al., "GroupLens: An Open Architecture for Collaborative Filtering of Netnews", CSCW 1994
- G. Linden, B. Smith and J. York, "Amazon.com Recommendations: Item-to-Item Collaborative Filtering", IEEE Internet Computing 2003
- Y. Hu, Y. Koren and C. Volinsky, "Collaborative Filtering for Implicit Feedback Datasets", ICDM 2008
- Y. Koren, R. Bell and C. Volinsky, "Matrix Factorization Techniques for Recommender Systems", IEEE Computer 2009
- S. Rendle et al., "BPR: Bayesian Personalized Ranking from Implicit Feedback", UAI 2009(arXiv:1205.2618)
- P. Cremonesi, Y. Koren and R. Turrin, "Performance of Recommender Algorithms on Top-N Recommendation Tasks", RecSys 2010
- P. Covington, J. Adams and E. Sargin, "Deep Neural Networks for YouTube Recommendations", RecSys 2016
- H.-T. Cheng et al., "Wide & Deep Learning for Recommender Systems", DLRS 2016(arXiv:1606.07792)
- X. Yi et al., "Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations", RecSys 2019