この記事は (1人で)基礎から学ぶ推薦システム Advent Calendar 2022の13日目の記事です。
今回は、EASEというアルゴリズムを試してみようと思います。
※見様見真似で書いてみたものの、スコアが低すぎてなんかおかしいので多分後で実装し直します。
Embarrassingly Shallow Autoencoders for Sparse Data (EASE)
もともとの論文はこちらです。
実装がこちら。
超ざっくり説明
ざっくりもでるの説明としては
- S: score
- X: 評価値行列
- B: アイテム-アイテム weight matrix
としたとき、
このようなモデルを考えたとき、Bの行列を学習することで推定を可能にします。
このとき、下記のような式について最小化するように学習していきます。
※Bの対角成分はすべて0となっています。
その他、詳しくは論文読んでもらったり、こちらのブログ記事がわかりやすいので、そちらも合わせて参照いただければと思います。
理屈は置いておいて、特徴的なのは実装が非常にシンプル点です。 もともとの実装(movielensが想定されてる)ですら、モデルの定義がこれくらいなので、非常にシンプルなことがわかります。
class EASE: def __init__(self): self.user_enc = LabelEncoder() self.item_enc = LabelEncoder() def _get_users_and_items(self, df): users = self.user_enc.fit_transform(df.loc[:, 'user_id']) items = self.item_enc.fit_transform(df.loc[:, 'item_id']) return users, items def fit(self, df, lambda_: float = 0.5, implicit=True): """ df: pandas.DataFrame with columns user_id, item_id and (rating) lambda_: l2-regularization term implicit: if True, ratings are ignored and taken as 1, else normalized ratings are used """ users, items = self._get_users_and_items(df) values = ( np.ones(df.shape[0]) if implicit else df['rating'].to_numpy() / df['rating'].max() ) X = csr_matrix((values, (users, items))) self.X = X G = X.T.dot(X).toarray() diagIndices = np.diag_indices(G.shape[0]) G[diagIndices] += lambda_ P = np.linalg.inv(G) B = P / (-np.diag(P)) B[diagIndices] = 0 self.B = B self.pred = X.dot(B) def predict(self, train, users, items, k): items = self.item_enc.transform(items) dd = train.loc[train.user_id.isin(users)] dd['ci'] = self.item_enc.transform(dd.item_id) dd['cu'] = self.user_enc.transform(dd.user_id) g = dd.groupby('cu') with Pool(cpu_count()) as p: user_preds = p.starmap( self.predict_for_user, [(user, group, self.pred[user, :], items, k) for user, group in g], ) df = pd.concat(user_preds) df['item_id'] = self.item_enc.inverse_transform(df['item_id']) df['user_id'] = self.user_enc.inverse_transform(df['user_id']) return df @staticmethod def predict_for_user(user, group, pred, items, k): watched = set(group['ci']) candidates = [item for item in items if item not in watched] pred = np.take(pred, candidates) res = np.argpartition(pred, -k)[-k:] r = pd.DataFrame( { "user_id": [user] * len(res), "item_id": np.take(candidates, res), "score": np.take(pred, res), } ).sort_values('score', ascending=False) return r
実装
念の為自分でも実装して実行してみます。
class EASE: def __init__(self, user_df, item_df): self.user_enc = LabelEncoder() self.item_enc = LabelEncoder() self.users = self.user_enc.fit_transform(user_df.loc[:, "user_id"]) self.items = self.item_enc.fit_transform(item_df.loc[:, "item_id"]) def _get_users_and_items(self, df): users = self.user_enc.transform(df.loc[:, "user_id"]) items = self.item_enc.transform(df.loc[:, "item_id"]) return users, items def train(self, df, lambda_: float = 0.5, implicit=True): """ df: pandas.DataFrame with columns user_id, item_id and (rating) lambda_: l2-regularization term implicit: if True, ratings are ignored and taken as 1, else normalized ratings are used """ self.train_df = df users, items = self._get_users_and_items(df) values = ( np.ones(df.shape[0]) if implicit else df["rating"].to_numpy() / df["rating"].max() ) X = csr_matrix((values, (users, items))) self.X = X G = X.T.dot(X).toarray() diagIndices = np.diag_indices(G.shape[0]) G[diagIndices] += lambda_ P = np.linalg.inv(G) B = P / (-np.diag(P)) B[diagIndices] = 0 self.B = B self.pred = X.dot(B) def predict(self, test_df:pd.DataFrame): train = self.train_df users = test_df["user_id"] items = test_df["item_id"] k = np.unique(self.items).size users = self.user_enc.transform(users) items = self.item_enc.transform(items) dd = train.loc[train.user_id.isin(users)] dd['ci'] = self.item_enc.transform(dd.item_id) dd['cu'] = self.user_enc.transform(dd.user_id) g = dd.groupby('cu') with Pool(cpu_count()) as p: user_preds = p.starmap( self.predict_for_user, [(user, group, self.pred[user, :], items, k) for user, group in g], ) df = pd.concat(user_preds) df['item_id'] = self.item_enc.inverse_transform(df['item_id']) df['user_id'] = self.user_enc.inverse_transform(df['user_id']) result = test_df.merge(df, on=["user_id", "item_id"], how="left") return result["score"] @staticmethod def predict_for_user(user, group, pred, items, k): watched = set(group['ci']) candidates = [item for item in items if item not in watched] pred = np.take(pred, candidates) res = np.argpartition(pred, -k)[-k:] r = pd.DataFrame( { "user_id": [user] * len(res), "item_id": np.take(candidates, res), "score": np.take(pred, res), } ).sort_values('score', ascending=False) return r
結果の比較
アルゴリズム | RMSE | Recall@10 | nDCG@10 |
---|---|---|---|
EASE | 3.418 | 0.179 | 0.778 |
...???
RMSEが結構ずれているのは一旦無視(線形変換でどうにかならないこともあるので)するとして、他の値がランダムのときとあんま変わってない?ので、多分なんかおかしいです。
ちょっと今回は自分で実装してしまって失敗しているので、なんかの機会に下のサンプルコード見て実装し直したいと思います。
サンプルコード
これに使用した実装のコードはこちらにあります。
参考文献
この記事を書くにあたって、下記の文献を参考にさせていただきました。
- [1905.03375] Embarrassingly Shallow Autoencoders for Sparse Data
- シンプルなのに高性能!推薦モデル Easer の紹介 | かよしいブログ
- GitHub - Darel13712/ease_rec: embarrassingly shallow autoencoder
自分が論文読んだときのメモはこちら。
感想
見様見真似で実装してみたんですが、なんかおかしくなってしまったのでちょっとあれですが、pwcの結果を見る限りはかなり高い精度が出るモデルのようなので時間があるときに別のコードを参考に試してみたいと思います。