Mímisbrunnr知恵の泉

← マーケティングサイエンス 一覧

🎓 レベル:標準 | 重要度:A(必須)

📎 関連:第8章 レコメンデーション 目次 | 前提:クラスタリングによるセグメンテーション(k-means)

要点(BLUF)

1. 協調フィルタリングとは:行動の履歴だけで薦める

レコメンドの出発点は、巨大な評価行列です。行がユーザー、列がアイテム、セルが「ユーザー uu がアイテム ii に付けた評価(あるいは買った/見たという事実)」。現実にはこの表の99% 以上が空白——誰もが全アイテムのごく一部しか触れていないからです。推薦とは、この空白のセルを予測し、各ユーザーについて「まだ出会っていないが高く評価しそうな」アイテムを上位提示することに他なりません。

協調フィルタリングの発想は、アイテムの中身をまったく見ないことにあります。映画のジャンルも、商品のスペックも使わない。使うのは評価行列の数字だけです。それでも推薦できるのは、「人々の好みには共通のパターンがある」から——「この映画を高く評価した人は、あの映画も高く評価しがち」「あなたと評価が似ているあの人が絶賛したものは、あなたも気に入る公算が大きい」。みんなの行動を協調的に寄せ集めて穴を埋める。これが名前の由来です。

実装の二系統を押さえます。近傍法(memory-based)は、評価行列からアイテム間(またはユーザー間)の類似度を測り、似たものの評価を加重平均して欠測を予測します。類似度には クラスタリングによるセグメンテーション(k-means) で使ったコサイン類似度をそのまま使います。もう一方の行列分解(model-based)は、評価行列 RR低次元の潜在因子の積 UVUV^\top で近似します。各ユーザーを KK 次元のベクトル(好みの傾向)、各アイテムを KK 次元のベクトル(特徴の傾向)で表し、その内積で評価を再構成する——「ジャンル」を人が与えるのではなく、データから潜在的な軸を発見させるのです。

flowchart LR
  R["評価行列 R(大半が欠測)"] --> N["近傍法:アイテム/ユーザー間のコサイン類似度"]
  R --> M["行列分解:R ≒ U Vᵀ(潜在因子K次元)"]
  N --> P1["欠測セルを類似度の加重平均で予測"]
  M --> P2["欠測セルを内積 pᵤ・qᵢ で予測"]
  P1 --> REC["予測上位を推薦(Top-N)"]
  P2 --> REC

2. コサイン類似度・近傍予測・行列分解(数式)

まず評価行列 RRm×nR\in\mathbb{R}^{m\times n}mm ユーザー、nn アイテム)を考えます。観測されたセルの集合を Ω\Omega とし、(u,i)Ω(u,i)\in\Omega なら ruir_{ui} が分かっています。

近傍法では、まずアイテムどうしのコサイン類似度を測ります。アイテム ii の評価ベクトル rir_i(全ユーザーにわたる列)と jjrjr_j について

sim(i,j)=rirjrirj\text{sim}(i,j)=\frac{r_i\cdot r_j}{\lVert r_i\rVert\,\lVert r_j\rVert}

です(実装では平均中心化してから測ります=後述)。アイテムベースの予測は、ユーザー uu が実際に評価した他アイテム jj の評価を、アイテム ii との類似度で加重平均して作ります。アイテム平均 rˉi\bar r_i を基準に取り、

r^ui=rˉi+jΩusim(i,j)(rujrˉj)jΩusim(i,j)\hat r_{ui}=\bar r_i+\frac{\sum_{j\in\Omega_u}\text{sim}(i,j)\,(r_{uj}-\bar r_j)}{\sum_{j\in\Omega_u}\lvert\text{sim}(i,j)\rvert}

ここで Ωu\Omega_u はユーザー uu が評価済みのアイテム集合。「ii に似たアイテムを uu が高く評価していれば、uuii も高く評価するだろう」を式にしたものです。

行列分解は発想が違います。評価行列を、ユーザー因子 PRm×KP\in\mathbb{R}^{m\times K} とアイテム因子 QRn×KQ\in\mathbb{R}^{n\times K} の積で近似します(U,VU,V と同じ役割を学習対象として P,QP,Q と書きます)。予測は内積 r^ui=puqi\hat r_{ui}=p_u^\top q_i。これを観測セルでの二乗誤差+L2 正則化を最小化して当てます。

minP,Q (u,i)Ω(ruipuqi)2+λ(pu2+qi2)\min_{P,Q}\ \sum_{(u,i)\in\Omega}\bigl(r_{ui}-p_u^\top q_i\bigr)^2+\lambda\bigl(\lVert p_u\rVert^2+\lVert q_i\rVert^2\bigr)

潜在次元 KK が「いくつの軸で好みを説明するか」、λ\lambda が過学習を抑える正則化の強さです。実務では全体平均 μ\mu(さらにユーザ/アイテムのバイアス)を引いた残差を分解し、予測を r^ui=μ+puqi\hat r_{ui}=\mu+p_u^\top q_i とするのが標準で、本ノートのコードも μ\mu を引いてから分解します(KK 個の潜在軸を「平均からのズレ」に専念させるため)。最適化は**確率的勾配降下法(SGD)**で、観測セルを1つずつ見て、誤差 eui=ruiμpuqie_{ui}=r_{ui}-\mu-p_u^\top q_i を使い

pupu+η(euiqiλpu),qiqi+η(euipuλqi)p_u\leftarrow p_u+\eta\,(e_{ui}\,q_i-\lambda\,p_u),\qquad q_i\leftarrow q_i+\eta\,(e_{ui}\,p_u-\lambda\,q_i)

と更新します(η\eta は学習率)。「予測が低すぎたら、uuii の因子を同じ向きに少し伸ばす」——内積を上げる方向への素直な調整です。

3. 評価行列の穴を埋める(コード)

真の潜在因子 K=2K=2 から評価行列を合成します。ユーザー50・アイテム20、UN(0,1)U\sim N(0,1)(50,2)(50,2)VN(0,1)V\sim N(0,1)(20,2)(20,2)、真の評価 R=clip(3+UV,1,5)R^*=\mathrm{clip}(3+UV^\top,\,1,\,5)。各セルを確率 0.40.4 で観測(学習)、残りはテストとして隠します。(a) アイテムベースCF(観測のみで平均中心化 → アイテム間コサイン → テストセルを類似度加重平均で予測)、(b) 行列分解(潜在 K=2K=2rng.normal(0,0.1) 初期化・SGD・学習率 0.010.01・正則化 0.050.05)を実装し、いずれも全体平均で埋めるベースラインとテストRMSEで比べます。sklearn は使わず、類似度は自前の cosine、行列分解は numpy の SGD です。

import numpy as np
import pandas as pd

# ユーザー50 × アイテム20 の評価行列を、真の潜在因子 K=2 から生成する。
# R* = 3 + U V^T を 1..5 にclip。各セルを確率0.4で観測(残りはテスト)。
rng = np.random.default_rng(1)
n_users, n_items, K_true = 50, 20, 2
U_true = rng.normal(0, 1, (n_users, K_true))
V_true = rng.normal(0, 1, (n_items, K_true))
R_star = np.clip(3.0 + U_true @ V_true.T, 1.0, 5.0)

observed = rng.random((n_users, n_items)) < 0.4     # True=観測(学習), False=テスト
n_cell = n_users * n_items
n_obs = int(observed.sum())
print(f"評価行列: {n_users}ユーザー × {n_items}アイテム = {n_cell}セル")
print(f"観測(学習)セル: {n_obs}{n_obs/n_cell:.1%}), テストセル: {n_cell - n_obs}")

R_obs = np.where(observed, R_star, np.nan)          # 未観測は NaN
mu_global = float(np.nanmean(R_obs))
test = ~observed
print(f"観測の全体平均 mu = {mu_global:.3f}")

# テストRMSE を測る関数(テストセルのみで真の R* と比較)
def rmse(pred):
    return float(np.sqrt(np.mean((pred[test] - R_star[test]) ** 2)))

# === ベースライン:テストセルを全体平均で予測 ===
rmse_base = rmse(np.full_like(R_star, mu_global))
print(f"\n[ベースライン]   全体平均で予測         : テストRMSE = {rmse_base:.3f}")

# === (a) アイテムベースCF ===
# アイテム平均(観測のみ)で各列を中心化、未観測は0で埋める
item_mean = np.nanmean(R_obs, axis=0)
C = np.where(observed, R_obs - item_mean, 0.0)

# アイテム間コサイン類似度(自前。列ベクトルを正規化して内積)
def cosine_cols(M):
    norm = np.sqrt((M ** 2).sum(axis=0))
    norm[norm == 0] = 1e-9
    Mn = M / norm
    return Mn.T @ Mn

S = cosine_cols(C)
np.fill_diagonal(S, 0.0)                 # 自分自身は使わない

# テストセル(u,i)を、ユーザーuが観測した他アイテムjの中心化評価の類似度加重平均で予測
pred_cf = np.full_like(R_star, np.nan)
for u in range(n_users):
    obs_j = np.where(observed[u])[0]
    for i in np.where(test[u])[0]:
        w = S[i, obs_j]
        denom = np.abs(w).sum()
        if denom < 1e-9:
            pred_cf[u, i] = item_mean[i]
        else:
            pred_cf[u, i] = item_mean[i] + (w * C[u, obs_j]).sum() / denom
pred_cf = np.clip(pred_cf, 1.0, 5.0)
rmse_cf = rmse(pred_cf)
print(f"[近傍法]         アイテムベースCF        : テストRMSE = {rmse_cf:.3f}")

# === (b) 行列分解(SGD)===
# 残差 (r - mu) を K=2 で分解。予測 = mu + p_u . q_i
K, lr, reg, n_epoch = 2, 0.01, 0.05, 200
P = rng.normal(0, 0.1, (n_users, K))
Q = rng.normal(0, 0.1, (n_items, K))
obs_idx = np.argwhere(observed)
for epoch in range(n_epoch):
    rng.shuffle(obs_idx)
    for u, i in obs_idx:
        err = (R_star[u, i] - mu_global) - P[u] @ Q[i]
        pu = P[u].copy()
        P[u] += lr * (err * Q[i] - reg * pu)
        Q[i] += lr * (err * pu - reg * Q[i])
pred_mf = np.clip(mu_global + P @ Q.T, 1.0, 5.0)
rmse_mf = rmse(pred_mf)
print(f"[行列分解]       潜在K=2・SGD            : テストRMSE = {rmse_mf:.3f}")

print(f"\nベースライン比の改善: CF {(1-rmse_cf/rmse_base):.1%} 改善 / 行列分解 {(1-rmse_mf/rmse_base):.1%} 改善")

# === (c) あるユーザーへの Top-3 推薦(行列分解の予測で未観測アイテム上位)===
target = 0
unseen = np.where(test[target])[0]
order = np.argsort(-pred_mf[target, unseen])[:3]
top3 = unseen[order]
rec = pd.DataFrame({
    "アイテムID": top3,
    "予測評価(MF)": pred_mf[target, top3],
    "真の評価R*": R_star[target, top3],
}, index=[f"第{r+1}位" for r in range(3)])
print(f"\n=== ユーザー{target} への Top-3 推薦(未観測アイテムの予測上位)===")
print(rec.to_string(formatters={"予測評価(MF)": "{:.2f}".format, "真の評価R*": "{:.2f}".format}))

出力:

評価行列: 50ユーザー × 20アイテム = 1000セル
観測(学習)セル: 410(41.0%), テストセル: 590
観測の全体平均 mu = 3.004

[ベースライン]   全体平均で予測         : テストRMSE = 0.905
[近傍法]         アイテムベースCF        : テストRMSE = 0.582
[行列分解]       潜在K=2・SGD            : テストRMSE = 0.465

ベースライン比の改善: CF 35.6% 改善 / 行列分解 48.5% 改善

=== ユーザー0 への Top-3 推薦(未観測アイテムの予測上位)===
     アイテムID 予測評価(MF) 真の評価R*
第1位       4     3.53   3.51
第2位       0     3.44   3.48
第3位      12     3.26   3.06

出力の意味:評価行列は 50×20=100050\times20=1000 セルで、そのうち 410410 セル(41%41\%)だけを学習に使い、残り 590590 セルはテストとして隠しました。ベースラインは「テストセルを全部、観測の全体平均 3.0043.004 で予測」する素朴な戦略で、テストRMSEは 0.9050.905。これを基準に、二つの協調フィルタリングがどれだけ改善するかを見ます。

アイテムベースCF0.5820.582——ベースラインから 35.6%35.6\% の改善です。「ユーザー uu が評価済みのアイテムのうち、いま予測したい ii似たものの評価を重く見る」だけで、全体平均より大きく当たるようになりました。類似度は自前のコサイン(平均中心化した列ベクトルの内積)で、アイテムの中身は一切使っていません。

行列分解はさらに良く 0.4650.465——48.5%48.5\% の改善です。これが最良なのは、データがまさに K=2K=2 の潜在因子から生成されたから。SGD で各ユーザー・各アイテムの2次元因子を学習し、μ+puqi\mu+p_u^\top q_i で穴を埋めると、隠れていた2軸構造を取り戻して予測が締まります。近傍法が「似たものの平均」という局所的な使い方なのに対し、行列分解は行列全体を貫く低次元構造をモデル化するぶん、強い。

最後の Top-3 推薦が推薦の実物です。ユーザー0がまだ評価していないアイテムについて行列分解の予測値を出し、上位3つを並べました。予測評価 3.53/3.44/3.263.53/3.44/3.26 に対し、真の評価(本来は見えない正解)は 3.51/3.48/3.063.51/3.48/3.06——予測の高い順に、本当に好むアイテムが並んでいます。「この人にはアイテム4・0・12を薦めよ」という出力が、履歴の数字だけから得られました。

3手法の誤差と、行列分解の予測の素性を1枚にまとめます(このブロックはデータ生成から各手法までを内部で再実行し、上と同じ値を描きます)。

import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib

# データ生成 → ベースライン/CF/行列分解 をこのブロック内で再実行し、
# (左) 3手法のテストRMSE比較、(右) 行列分解の予測 vs 実測(テストセル)を描く。
rng = np.random.default_rng(1)
n_users, n_items = 50, 20
U_true = rng.normal(0, 1, (n_users, 2))
V_true = rng.normal(0, 1, (n_items, 2))
R_star = np.clip(3.0 + U_true @ V_true.T, 1.0, 5.0)
observed = rng.random((n_users, n_items)) < 0.4
test = ~observed
R_obs = np.where(observed, R_star, np.nan)
mu_global = float(np.nanmean(R_obs))
rmse = lambda pred: float(np.sqrt(np.mean((pred[test] - R_star[test]) ** 2)))

rmse_base = rmse(np.full_like(R_star, mu_global))

item_mean = np.nanmean(R_obs, axis=0)
C = np.where(observed, R_obs - item_mean, 0.0)
norm = np.sqrt((C ** 2).sum(axis=0)); norm[norm == 0] = 1e-9
S = (C / norm).T @ (C / norm); np.fill_diagonal(S, 0.0)
pred_cf = np.full_like(R_star, np.nan)
for u in range(n_users):
    obs_j = np.where(observed[u])[0]
    for i in np.where(test[u])[0]:
        w = S[i, obs_j]; denom = np.abs(w).sum()
        pred_cf[u, i] = item_mean[i] + ((w * C[u, obs_j]).sum() / denom if denom > 1e-9 else 0.0)
pred_cf = np.clip(pred_cf, 1.0, 5.0)
rmse_cf = rmse(pred_cf)

P = rng.normal(0, 0.1, (n_users, 2)); Q = rng.normal(0, 0.1, (n_items, 2))
obs_idx = np.argwhere(observed)
for _ in range(200):
    rng.shuffle(obs_idx)
    for u, i in obs_idx:
        err = (R_star[u, i] - mu_global) - P[u] @ Q[i]
        pu = P[u].copy()
        P[u] += 0.01 * (err * Q[i] - 0.05 * pu)
        Q[i] += 0.01 * (err * pu - 0.05 * Q[i])
pred_mf = np.clip(mu_global + P @ Q.T, 1.0, 5.0)
rmse_mf = rmse(pred_mf)

fig, ax = plt.subplots(1, 2, figsize=(11.5, 4.4))
names = ["ベースライン\n(全体平均)", "アイテムベース\nCF(近傍法)", "行列分解\n(K=2)"]
vals = [rmse_base, rmse_cf, rmse_mf]
bars = ax[0].bar(names, vals, color=["C7", "C0", "C3"])
for b, v in zip(bars, vals):
    ax[0].text(b.get_x() + b.get_width() / 2, v + 0.01, f"{v:.3f}", ha="center", fontsize=10)
ax[0].set_ylabel("テストRMSE(小さいほど良い)")
ax[0].set_title("3手法のテスト誤差:協調フィルタはベースラインを上回る")
ax[0].set_ylim(0, 1.0)

ax[1].scatter(R_star[test], pred_mf[test], s=14, alpha=0.4, color="C3", edgecolor="none")
ax[1].plot([1, 5], [1, 5], "k--", lw=1, label="完全一致の線")
ax[1].set_xlabel("真の評価 R*(テストセル)")
ax[1].set_ylabel("行列分解の予測評価")
ax[1].set_title(f"行列分解の予測 vs 実測(テストRMSE={rmse_mf:.3f})")
ax[1].legend(loc="upper left", fontsize=9)
ax[1].grid(alpha=0.3)
fig.tight_layout()
plt.show()

左の棒グラフは3手法のテストRMSE(0.9050.5820.4650.905\to0.582\to0.465)で、協調フィルタリングがベースラインを明確に下回る(=当たる)こと、行列分解が最良であることが一目で読めます。右は行列分解の予測 vs 実測の散布図で、点群が点線(完全一致の線)の周りに帯状に集まっています。低評価も高評価もそれなりに追えており、121\sim2 付近で予測がやや上振れ・454\sim5 付近でやや下振れする(中央に引っ張られる)正則化の効果も見て取れます。これが「欠測の穴を、履歴の数字だけで埋める」ということの中身です。

⚠️ よくある誤解

関連ノート