はじめに
- 前回の記事では、暗黙的評価に対する推薦アルゴリズムとして、ALSとBPRのアルゴリズムについて勉強した
- 今回はそれに加え、精度を改善するための2つの取り組みを実施する
- 1.ユーザー属性を考慮した予測をおこなう
- 2.Reciprocal Rank Fusion(RRF)によるアンサンブルをおこなう
手法
ユーザー属性の考慮
- 前回は、ユーザー間型協調フィルタリングの考え方に基づいた手法で、H&Mのデータセットに対してALSとBPRを実装
- 行列分解的な手法にユーザーの属性を足すものとしてスタンダードなのは、Factorization Machines (FM)である
- Factorization Machinesを本当は試したかったが、行列がメモリに乗らなくなってしまったので、今回は、ユーザーの類似度にユーザー属性も用いる形式にして、ALSやBPRの枠組みの範囲で実装することにした
- 用いるユーザー属性(選定はあまり丁寧には実施していない)
- ユーザーの年齢を10歳刻みにしてone-hotにしたもの
- Activeかどうかのone-hot
- club_member_statusがactiveかどうかのone-hot
- fashion_news_frequencyがnoneかどうかのone-hot
- 比較する手法
- 単純なALS
- ユーザー属性だけ考慮したALS
アンサンブル
- Reciprocal Rank Fusion(RRF)を用いる
- 回帰問題のアンサンブルは、単純平均を用いることが一般的である
- 推薦システムでは、順位を予測することを踏まえると、順位の絶対値の平均をとって小さいものから選ぶアプローチだと、一度でもハズレ値的に低い順位があると、高い順位の予測が選ばれづらくなる
- 例えば2つのモデルをアンサンブルする際、アイテムAの順位が1, 100、アイテムBの順位が50, 51だったときに、アンサンブル平均はともに50.5となる。しかし、そもそも当てるのが難しい推薦の問題においては、100位の予測があったとしても1位の予測があった方を優先すべきと考える
- この問題を解決するアンサンブル方法がReciprocal Rank Fusion(RRF)である [1]
- RRFは、各モデルにおける順位の逆数の和によってアンサンブルする
$$
RRF(i) = \sum_{m \in M} \frac{1}{k + r_m(i)}
$$
- $M$: モデルの集合、$r_m(i)$: モデル $m$ におけるアイテム $i$ の順位、$k$: ハイパーパラメータ(デフォルト値 $k=60$ が文献でも標準的に用いられる)
- 実装でも $k=60$ を使用した
[1] Cormack, G. V., Clarke, C. L. A., & Buettcher, S. (2009). Reciprocal rank fusion outperforms condorcet and individual rank learning methods. SIGIR 2009.
結果
- 上記2つの手法を適用した結果の精度は下記である
| 手法 | MAP@12 |
|---|---|
| ALS | 0.0042 |
| ALS(ユーザー属性つき) | 0.0065 |
| ALS(ユーザー属性つき、モデルパラメータをかえた4モデルのRRFアンサンブル) | 0.0065 |
| ALSとBPRのRRFアンサンブル | 0.0045 |
- ユーザー属性を考慮することによって、ALSの精度は大幅に改善した
- ALSモデルパラメータを変更したモデル4つのRRFアンサンブルをおこなったが、アンサンブルしない場合と比べて精度はほとんど変わらなかった
- よりロジックが異なるモデルを混ぜる方が、アンサンブルの効果が出やすいのかもしれない
- ALSとBPRのアンサンブルもおこなったが、これは精度の改善にはつながらなかった。そもそものBPRの精度が低かったことが原因と考えられる
学び
- ユーザー同士の類似度として、アイテム評価(今回であれば購買履歴)に加えて、ユーザーの属性を追加で加味することにより、似たユーザー属性に基づいた推薦を実施することができる
- 推薦システムにおけるアンサンブルでは、より上位の順位を重視するために、順位の逆数の和でアンサンブルするReciprocal Rank Fusion(RRF)が有効である
- 今回は上手く精度を得ることができなかったが
ソースコード
ユーザー属性ベクトルの構築 (user_features.py)
ユーザー属性を11次元のスパースベクトルとして構築する。年齢は10歳刻みで8ビン(列0〜7)にone-hot化し、残り3次元はActive・club_member_status・fashion_news_frequencyのフラグである。
import numpy as np
import pandas as pd
from scipy.sparse import csr_matrix
# 列インデックスの定義
AGE_COL = 0 # age_bin_{0..7}: 列0〜7の8次元
ACTIVE_COL = 8
CLUB_COL = 9
NEWS_COL = 10
N_FEATURE_COLS = 11
def build_user_features(df_customers: pd.DataFrame, user_idx: dict) -> csr_matrix:
"""ユーザー属性を (n_users, 11) のスパース行列で返す。"""
df = df_customers.set_index("customer_id")
known_ids = [cid for cid in user_idx if cid in df.index]
df_known = df.loc[known_ids]
uids = np.array([user_idx[cid] for cid in known_ids])
rows, cols, vals = [], [], []
# 年齢: 10歳刻みで列0〜7にone-hot(欠損は無視)
ages = pd.to_numeric(df_known["age"], errors="coerce")
valid = ages.notna()
age_uids = uids[valid.values]
bin_idx = np.clip((ages[valid].values.astype(int) // 10), 0, 7)
rows.extend(age_uids)
cols.extend(bin_idx)
vals.extend([1.0] * len(age_uids))
# Active フラグ
active_mask = (df_known["Active"] == 1.0).values
rows.extend(uids[active_mask])
cols.extend([ACTIVE_COL] * active_mask.sum())
vals.extend([1.0] * active_mask.sum())
# club_member_status が ACTIVE かどうか
club_mask = (df_known["club_member_status"] == "ACTIVE").values
rows.extend(uids[club_mask])
cols.extend([CLUB_COL] * club_mask.sum())
vals.extend([1.0] * club_mask.sum())
# fashion_news_frequency が NONE 以外かどうか
news = df_known["fashion_news_frequency"].fillna("NONE").str.upper()
news_mask = (news != "NONE").values
rows.extend(uids[news_mask])
cols.extend([NEWS_COL] * news_mask.sum())
vals.extend([1.0] * news_mask.sum())
return csr_matrix((vals, (rows, cols)), shape=(len(user_idx), N_FEATURE_COLS))
ALS with User Attributes (als_with_features.py)
ユーザー属性行列をinteraction matrixの右に横連結してALSに渡す。連結後の行列サイズは (n_users, n_items + 11)。推薦時は後ろの属性列インデックスが混入しないよう < n_items でフィルタリングする。
import numpy as np
import pandas as pd
from scipy.sparse import csr_matrix, hstack
import implicit
df_trans = pd.read_parquet("data/processed/transactions_5m.parquet")
df_customers = pd.read_csv("data/raw/customers.csv")
class ALSWithFeaturesRecommender:
def __init__(self, factors: int = 50, iterations: int = 20, random_state: int = 42):
self.factors = factors
self.iterations = iterations
self.random_state = random_state
def fit(self, df_trans, df_customers=None):
# ユーザー・アイテムのインデックスマッピング
self.user_ids = df_trans["customer_id"].unique()
self.item_ids = df_trans["article_id"].unique()
self.user_idx = {u: i for i, u in enumerate(self.user_ids)}
item_idx = {a: i for i, a in enumerate(self.item_ids)}
self._idx_to_item = np.array(self.item_ids)
# interaction matrix: (n_users, n_items)
rows = df_trans["customer_id"].map(self.user_idx)
cols = df_trans["article_id"].map(item_idx)
interaction_matrix = csr_matrix(
(np.ones(len(df_trans)), (rows, cols)),
shape=(len(self.user_ids), len(self.item_ids)),
)
# ユーザー属性を横に連結: (n_users, n_items + 11)
user_features = build_user_features(df_customers, self.user_idx)
self.user_item_matrix = hstack([interaction_matrix, user_features]).tocsr()
model = implicit.als.AlternatingLeastSquares(
factors=self.factors,
iterations=self.iterations,
random_state=self.random_state,
)
model.fit(self.user_item_matrix)
self.model = model
def recommend_all(self, customer_ids, top_n=12, fallback=None):
known = [c for c in customer_ids if c in self.user_idx]
unknown = [c for c in customer_ids if c not in self.user_idx]
n_items = len(self.item_ids)
results = {}
if known:
uids = np.array([self.user_idx[c] for c in known])
# 属性列インデックスが混入しないよう N_FEATURE_COLS 分余裕を持って取得
ids_batch, _ = self.model.recommend(
uids, self.user_item_matrix[uids],
N=top_n + N_FEATURE_COLS,
filter_already_liked_items=False,
)
for customer_id, item_indices in zip(known, ids_batch):
in_range = item_indices[item_indices < n_items] # 属性列を除外
results[customer_id] = self._idx_to_item[in_range[:top_n]].tolist()
for customer_id in unknown:
results[customer_id] = fallback.recommend(customer_id, top_n) if fallback else []
return results
RRFアンサンブル (ensemble.py / train_submit.py)
ハイパーパラメータを変えた4つのALS+属性モデルをRRFでアンサンブルする。各モデルから上位120件を取得し、ユーザーごとにRRFスコアを集計してTop-12を選ぶ。
import numpy as np
from recommenders.als_with_features import ALSWithFeaturesRecommender
from recommenders.ensemble import RRFEnsembleRecommender
df_trans = pd.read_parquet("data/processed/transactions_5m.parquet")
df_customers = pd.read_csv("data/raw/customers.csv")
# 4モデルの設定
_ALS_CONFIGS = [
{"factors": 50, "iterations": 30, "random_state": 0},
{"factors": 50, "iterations": 30, "random_state": 1},
{"factors": 32, "iterations": 40, "random_state": 0},
{"factors": 50, "iterations": 30, "random_state": 2},
]
models = []
for cfg in _ALS_CONFIGS:
m = ALSWithFeaturesRecommender(**cfg)
m.fit(df_trans, df_customers)
models.append(m)
rec = RRFEnsembleRecommender(models, k=60)
# ensemble.py: RRFスコアの集計
class RRFEnsembleRecommender:
def __init__(self, recommenders, k=60):
self.recommenders = recommenders
self.k = k
def recommend_all(self, customer_ids, top_n=12, fallback=None):
fetch_n = top_n * 10 # 各モデルから120件取得
rrf_weights = 1.0 / (self.k + np.arange(1, fetch_n + 1)) # rank=1,2,...,fetch_n
# 全モデルの推薦結果を事前取得
per_model = [
rec.recommend_all(customer_ids, top_n=fetch_n, fallback=fallback)
for rec in self.recommenders
]
results = {}
for customer_id in customer_ids:
scores = {}
for model_results in per_model:
items = model_results.get(customer_id, [])
for item, w in zip(items, rrf_weights[:len(items)]):
scores[item] = scores.get(item, 0.0) + w
# スコア上位 top_n を抽出
items_arr = np.array(list(scores.keys()))
score_arr = np.array(list(scores.values()), dtype=np.float32)
actual_top = min(top_n, len(items_arr))
top_idx = np.argpartition(-score_arr, actual_top)[:actual_top]
top_idx = top_idx[np.argsort(-score_arr[top_idx])]
results[customer_id] = items_arr[top_idx].tolist()
return results