LESSON

Matrix Factorization

「協調フィルタリングはスパースなデータに弱い。NetShop社の50万商品のうち、一人のユーザーが買うのはせいぜい数十品だ。」

田中VPoEがスカスカのユーザー×アイテム行列を示す。

「Matrix Factorizationはこのスパース問題を解決する。Netflix Prizeで優勝チームが使った手法だ。」

Matrix Factorizationの基本原理

Matrix Factorization(MF)は、ユーザー×アイテムの評価行列を低ランク行列の積に分解する手法である。

直感的な理解

元の行列 R (m×n) ≈ P (m×k) × Q^T (k×n)

R: ユーザー×アイテム行列(スパース)
P: ユーザー潜在因子行列(各ユーザーをk次元ベクトルで表現)
Q: アイテム潜在因子行列(各アイテムをk次元ベクトルで表現)
k: 潜在因子の数(ハイパーパラメータ、通常10〜200)

直感:
  潜在因子 = 「アクション好き度」「ロマンス好き度」「コメディ好き度」...
  ユーザーベクトル = [0.8, 0.2, 0.5, ...]  ← アクション好き
  アイテムベクトル = [0.9, 0.1, 0.3, ...]  ← アクション映画
  予測スコア = 内積 = 0.8*0.9 + 0.2*0.1 + 0.5*0.3 = 0.89

なぜMFが有効か

協調フィルタリングの問題点:
  - スパース性: 99%以上が未評価 → 類似度計算が不安定
  - スケーラビリティ: 全ペアの類似度計算はO(n^2)

MFの利点:
  - 低次元ベクトルに圧縮 → スパース性を解消
  - 潜在因子で類似性を暗黙的に捉える
  - 予測はベクトルの内積 → 高速
  - 未評価アイテムの予測が自然に可能

SVD(Singular Value Decomposition)

基本SVD

import numpy as np
from scipy.sparse.linalg import svds

# ユーザー×アイテム行列(0=未評価)
R = np.array([
    [5, 3, 0, 1, 0],
    [4, 0, 0, 1, 0],
    [1, 1, 0, 5, 4],
    [0, 4, 5, 0, 3],
    [2, 0, 4, 4, 5],
], dtype=float)

# 欠損値を平均で埋める(基本的なアプローチ)
user_means = np.true_divide(R.sum(1), (R != 0).sum(1))
R_filled = R.copy()
for i in range(R.shape[0]):
    R_filled[i, R[i] == 0] = user_means[i]

# SVD分解(k=2次元)
U, sigma, Vt = svds(R_filled, k=2)
sigma_diag = np.diag(sigma)

# 予測行列の復元
R_pred = np.dot(np.dot(U, sigma_diag), Vt)

print("元の行列:")
print(R)
print("\n予測行列:")
print(np.round(R_pred, 1))

Funk SVD(SGDベース)

Netflix Prizeで有名になった手法。欠損値を無視して観測データのみで学習する。

class FunkSVD:
    """SGDベースのMatrix Factorization"""

    def __init__(self, n_factors=10, lr=0.005, reg=0.02, n_epochs=20):
        self.n_factors = n_factors
        self.lr = lr
        self.reg = reg
        self.n_epochs = n_epochs

    def fit(self, ratings):
        """
        ratings: [(user_id, item_id, rating), ...]
        """
        users = set(r[0] for r in ratings)
        items = set(r[1] for r in ratings)
        self.user_map = {u: i for i, u in enumerate(users)}
        self.item_map = {it: i for i, it in enumerate(items)}

        n_users = len(users)
        n_items = len(items)

        # ランダム初期化
        self.P = np.random.normal(0, 0.1, (n_users, self.n_factors))
        self.Q = np.random.normal(0, 0.1, (n_items, self.n_factors))
        self.bu = np.zeros(n_users)  # ユーザーバイアス
        self.bi = np.zeros(n_items)  # アイテムバイアス
        self.mu = np.mean([r[2] for r in ratings])  # 全体平均

        # SGDで学習
        for epoch in range(self.n_epochs):
            np.random.shuffle(ratings)
            total_loss = 0

            for user, item, rating in ratings:
                u = self.user_map[user]
                i = self.item_map[item]

                # 予測
                pred = self.mu + self.bu[u] + self.bi[i] + np.dot(self.P[u], self.Q[i])

                # 誤差
                error = rating - pred
                total_loss += error ** 2

                # パラメータ更新
                self.bu[u] += self.lr * (error - self.reg * self.bu[u])
                self.bi[i] += self.lr * (error - self.reg * self.bi[i])

                P_u_old = self.P[u].copy()
                self.P[u] += self.lr * (error * self.Q[i] - self.reg * self.P[u])
                self.Q[i] += self.lr * (error * P_u_old - self.reg * self.Q[i])

            rmse = np.sqrt(total_loss / len(ratings))
            if (epoch + 1) % 5 == 0:
                print(f"Epoch {epoch+1}: RMSE = {rmse:.4f}")

    def predict(self, user, item):
        u = self.user_map.get(user)
        i = self.item_map.get(item)
        if u is None or i is None:
            return self.mu
        return self.mu + self.bu[u] + self.bi[i] + np.dot(self.P[u], self.Q[i])

ALS(Alternating Least Squares)

ALSは暗黙的フィードバックデータに特に適したMF手法である。

暗黙的フィードバックの扱い

明示的フィードバック(星評価):
  - 値そのものが嗜好の強さを表す
  - 欠損値 = 未評価(嗜好不明)

暗黙的フィードバック(購買/閲覧):
  - 行動あり = 嗜好あり(ただし確信度は様々)
  - 行動なし ≠ 嗜好なし(知らなかっただけかも)
  - 値の大きさ = 確信度(購入5回 > 購入1回)
import implicit
from scipy.sparse import csr_matrix

# 暗黙的フィードバックデータ(購入回数)
# user_id, item_id, purchase_count
data = np.array([
    [0, 0, 5], [0, 1, 3], [0, 3, 1],
    [1, 0, 4], [1, 3, 1],
    [2, 0, 1], [2, 1, 1], [2, 3, 5], [2, 4, 4],
    [3, 1, 4], [3, 2, 5], [3, 4, 3],
    [4, 0, 2], [4, 2, 4], [4, 3, 4], [4, 4, 5],
])

# スパース行列に変換(item×user形式がimplicitの規約)
n_users, n_items = 5, 5
user_item = csr_matrix(
    (data[:, 2], (data[:, 0], data[:, 1])),
    shape=(n_users, n_items)
)

# ALSモデル
model = implicit.als.AlternatingLeastSquares(
    factors=10,
    regularization=0.1,
    iterations=20,
)

# 学習(item×user形式で渡す)
model.fit(user_item.T)

# User 0への推薦
user_id = 0
item_ids, scores = model.recommend(
    user_id,
    user_item[user_id],
    N=3,
    filter_already_liked_items=True
)
print(f"User {user_id}への推薦:")
for item_id, score in zip(item_ids, scores):
    print(f"  Item {item_id}: score = {score:.4f}")

ALSの利点

特徴SGDALS
並列化困難容易(P固定でQ更新、Q固定でP更新)
暗黙的データ追加の工夫が必要自然に扱える
収束速度遅い速い(大規模データ向き)
実装シンプルやや複雑
代表的ライブラリSurpriseimplicit

MFの拡張

バイアス項の導入

予測 = μ + bu + bi + P_u・Q_i

μ:  全体平均(例: 3.5)
bu: ユーザーバイアス(甘い評価者は+、辛い評価者は-)
bi: アイテムバイアス(人気作品は+、不人気作品は-)
P_u・Q_i: 潜在因子の内積(パーソナライズ部分)

時間的ダイナミクス

ユーザーの嗜好は時間とともに変化する:
  - 最近の行動に高い重みを付与
  - 時間減衰関数: weight = exp(-λ * (t_now - t_action))
  - 季節性の考慮(冬にコートが売れる)

まとめ

項目ポイント
MFの原理ユーザー×アイテム行列を低ランク分解
SVD明示的評価向け、SGDで学習
ALS暗黙的フィードバック向け、並列化容易
バイアス全体平均+ユーザー/アイテムバイアスで精度向上
適用場面スパースなデータ、大規模データセット

チェックリスト

  • Matrix Factorizationの直感的な意味を説明できる
  • SVDとALSの違いと使い分けを理解した
  • 明示的/暗黙的フィードバックの扱いの違いを理解した
  • Funk SVDのSGD学習の仕組みを説明できる
  • implicitライブラリでALSモデルを実装できる

次のステップへ

Matrix Factorizationを理解したところで、次はアイテムの属性情報を活用するコンテンツベース推薦を学ぼう。

推定読了時間: 30分