0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

特徴量追加とアンサンブルによる商品推薦モデルの改善

0
Posted at

はじめに

  • 前回の記事では、暗黙的評価に対する推薦アルゴリズムとして、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
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?