Mímisbrunnr知恵の泉

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

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

📎 関連:第8章 レコメンデーション 目次 | 前提:協調フィルタリング(近傍法・行列分解)

要点(BLUF)

1. コンテンツベースとは:属性で「似たもの」を薦める

協調フィルタリング(近傍法・行列分解) は強力でしたが、コールドスタートという致命的な弱点がありました——履歴のない新規ユーザー/新規アイテムは、評価行列に痕跡が無いので推薦に乗らない。新作映画は誰の「おすすめ」にも現れず、入会したての客には何も出せない。ところが実務では、毎日のように新商品が追加され、新規客が訪れます。ここで発想を変えます。アイテムの中身(属性)を見れば、評価が一つも無くても「似たもの」は分かる——これがコンテンツベース推薦です。

仕組みは素直です。各アイテムを特徴ベクトルで表します——映画なら「アクション/ロマンス/コメディ/SF/ドキュメンタリー」のジャンル成分、商品なら価格帯・カテゴリ・ブランドなど。これは製品を属性の束として測る コンジョイント分析(部分効用・WTP・市場シェア) の特徴量設計と同じ土俵です。次に、ユーザーが過去に好んだアイテムの特徴を平均して、その人の好みを一本のベクトル=ユーザープロファイルにまとめます。「この人はアクションとSFが好き」がベクトルとして表れる。あとは、プロファイルに似た属性のアイテムクラスタリングによるセグメンテーション(k-means) と同じコサイン類似度で探し、上位を薦めるだけ。

協調フィルタリングとの対比が肝心です。協調は他人から学ぶ(あなたが知らないジャンルでも、似た人が好めば薦められる)。コンテンツはアイテムの中身から薦める(他人がゼロでも、属性が合えば薦められる)。だから、履歴ゼロの新規アイテムはコンテンツの独壇場です。逆に、コンテンツは「あなたの過去の好みに似たもの」しか出せず、視野が広がりにくい。両者は弱点が裏返し——なので実務では重み α\alpha で混ぜるハイブリッドが定番になります。

flowchart LR
  L["好んだアイテム集合 L(の特徴ベクトル)"] --> U["ユーザープロファイル u = 特徴の平均"]
  X["全アイテムの特徴ベクトル xᵢ"] --> SC["content = cos(u, xᵢ)"]
  U --> SC
  CF["協調フィルタリングのスコア(08-01)"] --> H["ハイブリッド score = α・CF + (1-α)・content"]
  SC --> H
  H --> REC["推薦(新規アイテムも拾える)"]

2. ユーザープロファイル・コサインスコア・ハイブリッド(数式)

各アイテム ii特徴ベクトル xiRdx_i\in\mathbb{R}^d で表します(dd は属性の数、本ノートでは5ジャンル)。ユーザーが過去に好んだアイテムの集合を LL とすると、ユーザープロファイルはその特徴の平均ベクトルです。

u=1LiLxiu=\frac{1}{|L|}\sum_{i\in L}x_i

「好んだものの平均的な属性」が、その人の好みの中心になります。アイテム ii へのコンテンツスコアは、プロファイルと特徴ベクトルのコサイン類似度で測ります。

content(u,i)=cos(u,xi)=uxiuxi\text{content}(u,i)=\cos(u,x_i)=\frac{u\cdot x_i}{\lVert u\rVert\,\lVert x_i\rVert}

コサインは向き(属性の構成比)だけを見て大きさを無視するので、「アクション寄りかSF寄りか」というジャンルの方向が一致するほど高くなります。ここで決定的なのは、この計算に評価行列が一切要らないこと。xix_i さえあれば、評価ゼロの新規アイテムでもスコアが付きます——コールドスタートに強い理由です。

最後にハイブリッド。協調フィルタリングのスコア CF(u,i)\text{CF}(u,i)協調フィルタリング(近傍法・行列分解) の予測)とコンテンツスコアを、重み α[0,1]\alpha\in[0,1] で線形結合します。

score(u,i)=αCF(u,i)+(1α)content(u,i)\text{score}(u,i)=\alpha\cdot\text{CF}(u,i)+(1-\alpha)\cdot\text{content}(u,i)

α=1\alpha=1 なら純粋な協調、α=0\alpha=0 なら純粋なコンテンツ、中間で両取り。CF とコンテンツはスケールが違う(CF は評価スケール、コンテンツは cos\cos[1,1][-1,1])ので、合成前に両者を [0,1][0,1] に正規化してから混ぜるのが実務の作法です。α\alpha は「既存の行動データをどれだけ信じ、属性での外挿をどれだけ効かせるか」を決めるツマミで、コールドスタートの多寡などに応じて調整します。

3. コールドスタートをコンテンツで救う(コード)

アイテム30個、ジャンル特徴5次元(各アイテムは主ジャンル1つが強い)を合成し、アクション・SF 好きのユーザーを置きます。好んだアイテムからプロファイルを作り、コサインで全アイテムをスコアリング。そこへ評価ゼロだが特徴のある新規アイテム(アクション+SFの新作)を1つ加え、協調フィルタリングは評価ゼロで推薦できないのに対し、コンテンツベースは特徴から正しくスコアできることを示します。最後にハイブリッド score=αCF+(1α)content\text{score}=\alpha\cdot\text{CF}+(1-\alpha)\cdot\text{content}α\alpha を振り、新規アイテムの順位がどう動くかを見ます。sklearn は使わず、コサインは自前です。

import numpy as np
import pandas as pd

rng = np.random.default_rng(2)
genres = ["アクション", "ロマンス", "コメディ", "SF", "ドキュメンタリー"]
n_exist, n_feat = 30, 5

# 既存アイテムの特徴ベクトル(5ジャンル)。各アイテムは主ジャンル1つが強い(弱い副成分+ノイズ)
prim = rng.integers(0, n_feat, n_exist)
X_exist = rng.uniform(0.0, 0.25, (n_exist, n_feat))
X_exist[np.arange(n_exist), prim] += rng.uniform(0.7, 1.0, n_exist)

# ユーザーの嗜好:アクション・SF を好む。高評価アイテム=嗜好との整合が高い上位5件
taste = np.array([0.9, 0.0, 0.0, 0.9, 0.0])
liked = np.sort(np.argsort(-(X_exist @ taste))[:5])
print("ユーザーが高評価したアイテム liked =", liked.tolist())

# ユーザープロファイル u = 好んだアイテムの特徴ベクトルの平均
profile = X_exist[liked].mean(axis=0)
print("ユーザープロファイル u =", np.round(profile, 3).tolist(), "(順に", "/".join(genres), ")")

# 新規アイテム:特徴はあるが評価ゼロ(コールドスタート)。アクション+SF を両取りした新作
x_new = np.array([0.90, 0.10, 0.10, 0.90, 0.10])
X = np.vstack([X_exist, x_new])        # 31アイテム、index 30 = 新規
n_items, new_id = n_exist + 1, n_exist

# コサイン類似度(自前)でコンテンツスコア score(u,i)=cos(u, x_i)
def cosine(u, M):
    un = u / (np.linalg.norm(u) + 1e-12)
    Mn = M / (np.linalg.norm(M, axis=1, keepdims=True) + 1e-12)
    return Mn @ un
content = cosine(profile, X)            # 全31アイテムのコンテンツスコア

# CF(協調フィルタリング)の予測を模擬:既存は評価履歴から予測値、新規は0(履歴なし)
cf = np.concatenate([np.clip(rng.normal(3.3, 0.5, n_exist), 1, 5), [0.0]])

# 0..1 に正規化して合成可能に(CFは評価スケール、contentはcosで尺度が違うため)
norm01 = lambda v: (v - v.min()) / (v.max() - v.min() + 1e-12)
content_n, cf_n = norm01(content), norm01(cf)

# === コールドスタートの実演 ===
print(f"\n=== コールドスタート:新規アイテム(ID={new_id}) ===")
print(f"  CF予測    : {cf[new_id]:.2f}  → 評価履歴ゼロ。協調フィルタは推薦できない")
print(f"  コンテンツ : cos={content[new_id]:.3f} → 特徴ベクトルがあるので推薦できる")

# === コンテンツベース Top-5 推薦(既に好んだ liked は除外)===
cand = np.array([i for i in range(n_items) if i not in liked])
top = cand[np.argsort(-content[cand])[:5]]
df = pd.DataFrame({
    "アイテムID": top,
    "コンテンツスコア": content[top],
    "新規?": ["★新規" if i == new_id else "" for i in top],
}, index=[f"第{r+1}位" for r in range(5)])
print("\n=== コンテンツベース Top-5 推薦(嗜好プロファイルに近い順)===")
print(df.to_string(formatters={"コンテンツスコア": "{:.3f}".format}))

# === ハイブリッド score = α*CF + (1-α)*content:α を振って新規アイテムの順位を見る ===
print("\n=== ハイブリッド score = α*CF + (1-α)*content ===")
print(f"   (候補{len(cand)}件中での新規アイテムID={new_id}の順位。α大=CF重視)")
for a in [0.0, 0.25, 0.5, 0.75, 1.0]:
    h = a * cf_n + (1 - a) * content_n
    rank = int((h[cand] > h[new_id]).sum() + 1)
    note = "← 履歴ゼロの新規が埋もれる" if a == 1.0 else ("← コンテンツが新規を拾う" if a == 0.0 else "")
    print(f"  α={a:.2f}: 新規スコア={h[new_id]:.3f}, {len(cand)}件中 {rank:2d}{note}")

出力:

ユーザーが高評価したアイテム liked = [2, 15, 23, 24, 27]
ユーザープロファイル u = [0.775, 0.124, 0.141, 0.422, 0.118] (順に アクション/ロマンス/コメディ/SF/ドキュメンタリー )

=== コールドスタート:新規アイテム(ID=30) ===
  CF予測    : 0.00  → 評価履歴ゼロ。協調フィルタは推薦できない
  コンテンツ : cos=0.955 → 特徴ベクトルがあるので推薦できる

=== コンテンツベース Top-5 推薦(嗜好プロファイルに近い順)===
     アイテムID コンテンツスコア  新規?
第1位       7    0.959     
第2位      30    0.955  ★新規
第3位      13    0.951     
第4位      11    0.614     
第5位      26    0.579     

=== ハイブリッド score = α*CF + (1-α)*content ===
   (候補26件中での新規アイテムID=30の順位。α大=CF重視)
  α=0.00: 新規スコア=0.994, 26件中  2位 ← コンテンツが新規を拾う
  α=0.25: 新規スコア=0.745, 26件中  3位 
  α=0.50: 新規スコア=0.497, 26件中 13位 
  α=0.75: 新規スコア=0.248, 26件中 26位 
  α=1.00: 新規スコア=0.000, 26件中 26位 ← 履歴ゼロの新規が埋もれる

出力の意味:まずユーザーのプロファイルが立ちます。アクション・SF 好きという嗜好から、好んだアイテム(ID [2, 15, 23, 24, 27])の特徴を平均すると、プロファイル u=[0.775,0.124,0.141,0.422,0.118]u=[0.775,0.124,0.141,0.422,0.118]——アクション 0.7750.775・SF 0.4220.422 が突出し、好みがベクトルとして表れています。

次が本題のコールドスタート。新規アイテム(ID30、アクション+SFの新作)について、協調フィルタリングの予測は 0.000.00——評価履歴がゼロなので、内積も加重平均も計算できず、推薦に乗せられません。ところがコンテンツベースは cos=0.955\cos=0.955。属性ベクトルがプロファイルとほぼ同じ向きを向いているので、一つも評価が無くても高くスコアできるのです。これがコンテンツベースの存在意義です。

コンテンツベース Top-5 を見ると、新規アイテム(ID30、0.9550.955)が堂々の2位で推薦リストに入っています(1位は既存のアクション強アイテム7、僅差)。33 位までが 0.950.95 超で、44 位以降は 0.60.6 台へ急落——嗜好に合う「アクション+SF」の塊と、そうでないアイテムがくっきり分かれています。履歴ゼロの新作が、初日からおすすめ上位に並ぶ——協調フィルタリング単独では絶対にできなかったことです。

最後のハイブリッドが両系統の橋渡しです。α\alpha(CF を信じる重み)を 010\to1 に振ると、新規アイテムの順位は 2位 → 3位 → 13位 → 26位 → 26位 と沈みます。α=0\alpha=0(純コンテンツ)では新規が2位に来るのに、α=1\alpha=1(純CF)では履歴ゼロゆえ最下位。逆に言えば、α\alpha を上げるほど「みんなの行動で裏打ちされた既存アイテム」が優先される。α\alpha は「新規をどれだけ攻めて拾うか/既存の実績をどれだけ重んじるか」のツマミだと分かります。コールドスタートが課題なら α\alpha を下げ、既存資産を活かしたいなら上げる——この一本のパラメータで両系統を連続的に混ぜられるのがハイブリッドの妙です。

特徴空間と α\alpha の効き方を1枚にまとめます(このブロックはデータ生成からスコア計算までを内部で再実行し、上と同じ値を描きます)。

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

# データ生成 → プロファイル → コンテンツ/CF/ハイブリッド をこのブロック内で再実行し、
# (左) 特徴空間(SVDで2D)のアイテム配置とユーザープロファイル、
# (右) α に対する「新規アイテム」と「高CFの既存アイテム」の推薦順位の入れ替わりを描く。
rng = np.random.default_rng(2)
n_exist, n_feat = 30, 5
prim = rng.integers(0, n_feat, n_exist)
X_exist = rng.uniform(0.0, 0.25, (n_exist, n_feat))
X_exist[np.arange(n_exist), prim] += rng.uniform(0.7, 1.0, n_exist)
taste = np.array([0.9, 0.0, 0.0, 0.9, 0.0])
liked = np.sort(np.argsort(-(X_exist @ taste))[:5])
profile = X_exist[liked].mean(axis=0)
x_new = np.array([0.90, 0.10, 0.10, 0.90, 0.10])
X = np.vstack([X_exist, x_new])
n_items, new_id = n_exist + 1, n_exist

def cosine(u, M):
    un = u / (np.linalg.norm(u) + 1e-12)
    Mn = M / (np.linalg.norm(M, axis=1, keepdims=True) + 1e-12)
    return Mn @ un
content = cosine(profile, X)
cf = np.concatenate([np.clip(rng.normal(3.3, 0.5, n_exist), 1, 5), [0.0]])
norm01 = lambda v: (v - v.min()) / (v.max() - v.min() + 1e-12)
content_n, cf_n = norm01(content), norm01(cf)
cand = np.array([i for i in range(n_items) if i not in liked])
best_cf = cand[np.argmax(cf[cand])]     # 候補内で最もCFの高い既存アイテム

fig, ax = plt.subplots(1, 2, figsize=(11.8, 4.6))

# 左:特徴空間(SVDで2次元へ)。プロファイル近傍が推薦される様子
Xc = X - X.mean(axis=0)
_, _, Vt = np.linalg.svd(Xc, full_matrices=False)
W = Vt[:2].T
co = Xc @ W
pc = (profile - X.mean(axis=0)) @ W
others = [i for i in range(n_items) if i not in liked and i != new_id]
ax[0].scatter(co[others, 0], co[others, 1], s=40, color="C7", alpha=0.5, label="既存アイテム")
ax[0].scatter(co[liked, 0], co[liked, 1], s=70, color="C0", label="好んだアイテム(liked)")
ax[0].scatter(*pc, s=320, marker="*", color="C2", zorder=6, label="ユーザープロファイル u")
ax[0].scatter(co[new_id, 0], co[new_id, 1], s=240, marker="X", color="C3", zorder=6,
              label="新規アイテム(評価ゼロ)")
ax[0].set_xlabel("特徴 第1主成分")
ax[0].set_ylabel("特徴 第2主成分")
ax[0].set_title("特徴空間:プロファイルの近くを推薦する")
ax[0].legend(loc="best", fontsize=8.5)
ax[0].grid(alpha=0.3)

# 右:α に対する順位(小さいほど上位)。新規は沈み、高CF既存は浮く=ハイブリッドの調整
alphas = np.linspace(0, 1, 51)
rank_new, rank_cf = [], []
for a in alphas:
    h = a * cf_n + (1 - a) * content_n
    rank_new.append(int((h[cand] > h[new_id]).sum() + 1))
    rank_cf.append(int((h[cand] > h[best_cf]).sum() + 1))
ax[1].plot(alphas, rank_new, "-", color="C3", lw=2.2, label=f"新規アイテム(ID={new_id}, 履歴ゼロ)")
ax[1].plot(alphas, rank_cf, "-", color="C0", lw=2.2, label=f"高CFの既存アイテム(ID={best_cf})")
ax[1].invert_yaxis()
ax[1].set_xlabel("ハイブリッド重み α(0=コンテンツのみ, 1=CFのみ)")
ax[1].set_ylabel("推薦順位(上が上位)")
ax[1].set_title("α で新規と既存の優先が入れ替わる")
ax[1].legend(loc="center right", fontsize=9)
ax[1].grid(alpha=0.3)

fig.tight_layout()
plt.show()

左パネルは、5次元の特徴を SVD で2次元に落とした特徴空間です。緑の星がユーザープロファイル、青が好んだアイテム、赤い × が新規アイテム。新規アイテムがプロファイルと好んだアイテムの塊のすぐ近くに位置し、灰色の他アイテム群から離れているのが見て取れます——「プロファイルの近くを薦める」というコンテンツベースの幾何が、そのまま絵になっています。右パネルは α\alpha に対する推薦順位で、上ほど上位。α\alpha を上げる(CF重視にする)と、新規アイテム(赤)は履歴ゼロゆえ沈み、入れ替わりに高CFの既存アイテム(青)が浮上します。二本の線が中ほどで交差する——これが「ハイブリッドの α\alpha で、新規と既存の優先順位を連続的に調整している」ことの可視化です。

⚠️ よくある誤解

関連ノート