内容ベースフィルタリング#

概要#

内容ベースフィルタリング(Content-Based Filtering) は、アイテムの属性(特徴量)とユーザーの過去の嗜好情報を用いて推薦を行う手法。

協調フィルタリングが「他のユーザーの行動」を利用するのに対し、内容ベースフィルタリングは アイテムそのものの特徴 に着目する。

  • 例:「ユーザーAがアクション映画を好んで観ている → 他のアクション映画を推薦する」

  • アイテムの属性(ジャンル、テキスト、カテゴリなど)からアイテムのプロファイルを構築し、ユーザーの嗜好プロファイルとの類似度でランキングする

基本的な枠組み#

1. アイテムプロファイルの構築#

各アイテム \(j\) を特徴ベクトル \(\boldsymbol{x}_j \in \mathbb{R}^d\) で表現する。特徴量の例:

ドメイン

特徴量の例

映画

ジャンル、監督、出演俳優、あらすじのテキスト

ニュース記事

カテゴリ、キーワード、TF-IDF

商品

カテゴリ、価格帯、ブランド、商品説明文

音楽

ジャンル、テンポ、アーティスト、音響特徴量

2. ユーザープロファイルの構築#

ユーザー \(u\) が過去に高評価したアイテム集合 \(I_u^+\) から、ユーザープロファイル \(\boldsymbol{p}_u\) を構築する。

最もシンプルな方法は、高評価アイテムの特徴ベクトルの平均:

\[ \boldsymbol{p}_u = \frac{1}{|I_u^+|} \sum_{j \in I_u^+} \boldsymbol{x}_j \]

3. 推薦スコアの計算#

ユーザープロファイル \(\boldsymbol{p}_u\) と各アイテムの特徴ベクトル \(\boldsymbol{x}_j\) のコサイン類似度を計算し、スコアが高い順に推薦する:

\[ \text{score}(u, j) = \cos(\boldsymbol{p}_u, \boldsymbol{x}_j) = \frac{\boldsymbol{p}_u \cdot \boldsymbol{x}_j}{\|\boldsymbol{p}_u\| \|\boldsymbol{x}_j\|} \]

テキスト特徴量の抽出#

アイテムの説明文やタイトルなどのテキスト情報から特徴ベクトルを構築する代表的な手法。

TF-IDF#

TF-IDF(Term Frequency - Inverse Document Frequency) は古典的だが内容ベースフィルタリングで広く使われている手法。

\[ \text{TF-IDF}(t, d) = \text{TF}(t, d) \times \text{IDF}(t) \]
  • \(\text{TF}(t, d)\): 文書 \(d\) における単語 \(t\) の出現頻度

  • \(\text{IDF}(t) = \log \frac{N}{|\{d : t \in d\}|}\): 単語 \(t\) のレア度(\(N\) は全文書数)

Embedding#

近年は事前学習済みの言語モデルによる埋め込みベクトルが主流になりつつある。

  • Word2Vec / fastText → 単語レベルの埋め込みを平均

  • Sentence-BERT → 文レベルの埋め込み

  • OpenAI Embeddings / その他のLLM埋め込み

TF-IDFと比較して、意味的な類似性 を捉えられるのが利点。

実装例:MovieLens + TF-IDF#

import pandas as pd
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

# MovieLens 100K のデータを取得
ratings = pd.read_csv(
    "http://files.grouplens.org/datasets/movielens/ml-100k/u.data",
    names=["user_id", "item_id", "rating", "timestamp"],
    sep="\t",
)

# 映画情報(ジャンルを特徴量として使う)
genre_cols = [
    "unknown", "Action", "Adventure", "Animation", "Children's", "Comedy",
    "Crime", "Documentary", "Drama", "Fantasy", "Film-Noir", "Horror",
    "Musical", "Mystery", "Romance", "Sci-Fi", "Thriller", "War", "Western",
]
movies = pd.read_csv(
    "http://files.grouplens.org/datasets/movielens/ml-100k/u.item",
    names=["item_id", "title", "release_date", "video_release_date", "imdb_url"] + genre_cols,
    sep="|",
    encoding="latin-1",
)
movies.head(3)
item_id title release_date video_release_date imdb_url unknown Action Adventure Animation Children's ... Fantasy Film-Noir Horror Musical Mystery Romance Sci-Fi Thriller War Western
0 1 Toy Story (1995) 01-Jan-1995 NaN http://us.imdb.com/M/title-exact?Toy%20Story%2... 0 0 0 1 1 ... 0 0 0 0 0 0 0 0 0 0
1 2 GoldenEye (1995) 01-Jan-1995 NaN http://us.imdb.com/M/title-exact?GoldenEye%20(... 0 1 1 0 0 ... 0 0 0 0 0 0 0 1 0 0
2 3 Four Rooms (1995) 01-Jan-1995 NaN http://us.imdb.com/M/title-exact?Four%20Rooms%... 0 0 0 0 0 ... 0 0 0 0 0 0 0 1 0 0

3 rows × 24 columns

# ジャンル情報を文字列に変換してTF-IDFの入力にする
# 例: [0, 1, 0, 0, 0, 1, ...] → "Action Comedy"
movies["genre_text"] = movies[genre_cols].apply(
    lambda row: " ".join([g for g, v in zip(genre_cols, row) if v == 1]),
    axis=1,
)
movies[["title", "genre_text"]].head()
title genre_text
0 Toy Story (1995) Animation Children's Comedy
1 GoldenEye (1995) Action Adventure Thriller
2 Four Rooms (1995) Thriller
3 Get Shorty (1995) Action Comedy Drama
4 Copycat (1995) Crime Drama Thriller
# TF-IDFでアイテムプロファイルを構築
tfidf = TfidfVectorizer()
item_profiles = tfidf.fit_transform(movies["genre_text"])

print(f"アイテムプロファイル行列: {item_profiles.shape}")
print(f"特徴量: {tfidf.get_feature_names_out()}")
アイテムプロファイル行列: (1682, 21)
特徴量: ['action' 'adventure' 'animation' 'children' 'comedy' 'crime'
 'documentary' 'drama' 'fantasy' 'fi' 'film' 'horror' 'musical' 'mystery'
 'noir' 'romance' 'sci' 'thriller' 'unknown' 'war' 'western']
def build_user_profile(user_id: int, ratings_df: pd.DataFrame, item_profiles, threshold: float = 4.0):
    """ユーザーが高評価(threshold以上)したアイテムの特徴ベクトルを平均してユーザープロファイルを構築"""
    user_ratings = ratings_df[ratings_df["user_id"] == user_id]
    liked_items = user_ratings[user_ratings["rating"] >= threshold]["item_id"].values

    # item_id は1始まりなのでインデックス調整
    liked_indices = [i - 1 for i in liked_items if i - 1 < item_profiles.shape[0]]

    if len(liked_indices) == 0:
        return None

    user_profile = item_profiles[liked_indices].mean(axis=0)
    return np.asarray(user_profile)


def recommend(user_id: int, ratings_df: pd.DataFrame, item_profiles, movies_df: pd.DataFrame, top_n: int = 10):
    """ユーザープロファイルとアイテムプロファイルのコサイン類似度で推薦"""
    user_profile = build_user_profile(user_id, ratings_df, item_profiles)
    if user_profile is None:
        return pd.DataFrame()

    scores = cosine_similarity(user_profile, item_profiles).flatten()

    # 既に評価済みのアイテムは除外
    rated_items = set(ratings_df[ratings_df["user_id"] == user_id]["item_id"].values)
    candidate_indices = [i for i in range(len(scores)) if (i + 1) not in rated_items]

    top_indices = sorted(candidate_indices, key=lambda i: scores[i], reverse=True)[:top_n]

    results = movies_df.iloc[top_indices][["item_id", "title", "genre_text"]].copy()
    results["score"] = [scores[i] for i in top_indices]
    return results.reset_index(drop=True)
# ユーザー1に対する推薦
user_id = 1

# ユーザーが高評価した映画を確認
user_liked = ratings[(ratings["user_id"] == user_id) & (ratings["rating"] >= 4)].merge(
    movies[["item_id", "title", "genre_text"]], on="item_id"
)
print(f"ユーザー{user_id}が高評価した映画({len(user_liked)}件、上位5件):")
print(user_liked[["title", "genre_text", "rating"]].head().to_string(index=False))
print()

# 推薦結果
recs = recommend(user_id, ratings, item_profiles, movies, top_n=10)
print(f"ユーザー{user_id}への推薦:")
recs
ユーザー1が高評価した映画(163件、上位5件):
                     title              genre_text  rating
Three Colors: White (1994)                   Drama       4
          Desperado (1995) Action Romance Thriller       4
Glengarry Glen Ross (1992)                   Drama       4
 Angels and Insects (1995)           Drama Romance       4
      Groundhog Day (1993)          Comedy Romance       5

ユーザー1への推薦:
item_id title genre_text score
0 316 As Good As It Gets (1997) Comedy Drama 0.745285
1 345 Deconstructing Harry (1997) Comedy Drama 0.745285
2 347 Wag the Dog (1997) Comedy Drama 0.745285
3 382 Adventures of Priscilla, Queen of the Desert, ... Comedy Drama 0.745285
4 409 Jack (1996) Comedy Drama 0.745285
5 481 Apartment, The (1960) Comedy Drama 0.745285
6 522 Down by Law (1986) Comedy Drama 0.745285
7 523 Cool Hand Luke (1967) Comedy Drama 0.745285
8 598 Big Squeeze, The (1996) Comedy Drama 0.745285
9 652 Rosencrantz and Guildenstern Are Dead (1990) Comedy Drama 0.745285

協調フィルタリングとの比較#

観点

内容ベースフィルタリング

協調フィルタリング

必要なデータ

アイテムの属性情報

ユーザーの行動履歴(評価、クリックなど)

コールドスタート(アイテム)

属性さえあれば新規アイテムも推薦可能

他のユーザーの評価がないと推薦できない

コールドスタート(ユーザー)

ある程度の行動履歴が必要

同様に行動履歴が必要

セレンディピティ

低い(既知の嗜好に近いものばかり推薦される)

高い(意外な発見がありうる)

スケーラビリティ

ユーザー数に依存しない

ユーザー数×アイテム数に依存

ドメイン知識

必要(特徴量設計)

不要

フィルターバブル

陥りやすい

比較的陥りにくい

課題と発展#

フィルターバブル問題#

内容ベースフィルタリングは、ユーザーが過去に好んだアイテムと類似したものばかりを推薦するため、推薦の多様性が低下 しやすい。対策として:

  • 推薦結果に多様性制約を加える(MMR: Maximal Marginal Relevanceなど)

  • 協調フィルタリングとのハイブリッド化

ハイブリッド手法#

実用的なシステムでは、内容ベースと協調フィルタリングを組み合わせた ハイブリッド手法 が一般的。

  • 重み付きハイブリッド: 両手法のスコアを線形結合する

  • スイッチング: 状況に応じて手法を切り替える(例:コールドスタート時は内容ベース、データが蓄積したら協調フィルタリング)

  • 特徴量結合: 内容ベースの特徴量を協調フィルタリングモデルの入力に加える

参考#