📚

LightFMから始める推薦システム入門

2023/12/09に公開

アドベントカレンダー

株式会社GENDAでデータサイエンティストをしているtoma2です。
この記事は、GENDAアドベントカレンダー2023の9日目の記事になります。
https://qiita.com/advent-calendar/2023/genda
GENDAアドベントカレンダーでは、プロダクト開発や組織開発に関わるメンバーを中心に多様なテーマの記事を投稿しています。ぜひ、購読登録をしていただき12月25日までお楽しみください。

はじめに

最近、推薦モデルを調べる中でLightFMについて勉強したので、その内容をまとめとデータセットMovieLensでの実行例を示します。また、私が推薦モデルから推薦システムを作ろうとした際に躓いた、新規データへの対応やモデル更新といった実用的な内容も記載しています。

参考文献

https://arxiv.org/abs/1507.08439
https://making.lyst.com/lightfm/docs/index.html
こうもとさんのブログ「宇宙 日本 世田谷 機械学習」は、lightFMの理論から実用上の細かい点まで詳しく記載されており、大変参考にさせていただきました。
https://nnkkmto.hatenablog.com/entry/2020/12/20/000000
https://nnkkmto.hatenablog.com/entry/2020/12/21/193616

LightFMから始めるのがおすすめな理由

実業務での運用実績

LightFMは、Lystから発表された論文で提案された手法です。LystはイギリスのECファッションサイトで日本で言うZOZOTOWNのようなサイトです。この手法は、サイトの課題であった

  • サイト内に800万以上のファッションアイテムがある上、データが非常にスパースである
  • ファッションアイテムは新しい商品同士で関連性があるため、短期間でデータを収集しレコメンドする必要がある
  • 多くのユーザーが初めてのサイト利用者のため、過去の購買データがない(コールドスタート問題)

を解決するために開発されたもので、実際にLystでも運用されていたようです。

CPUで動く動作の軽さ

名前の通り動作が軽くCPUで動きます。GPU環境を必要としないので、環境構築が比較的容易で入門に最適です。「サービスに推薦システムを導入したい」と考えている実務者の方は、LightFMからの導入してみるのも良いのではないでしょうか。

ライブラリと公式ドキュメントの完成度の高さ

LightFMのPythonライブラリであるlightfmの完成度が非常に高いです。データ形式の変換関数やloss functionなどが充実しており実用的です。また、公式のドキュメントや実行例が豊富なので、スムーズにライブラリの活用ができます。

LightFM

モデルの説明

Uをユーザー集合、Iをアイテム集合としたとき、ユーザー特徴量をF^U、アイテム特徴量をF^Iとします。このとき、F^Uは「ユーザー集合(ユーザーID)数+ユーザーの特徴(性別や年代)数」となっていることに注意しましょう(F^Iも同様)。以降、断りがなく「特徴量」と記載しているものは、Fのことを指しています。

特徴量を使ってユーザーとアイテムを表現してみます。あるユーザーuf_u \subset F^U、アイテムはif_i \subset F^Iと表現できます。

LightFMは、embeddings(埋め込み)をパラメータとして持っています。embeddingsはd次元ベクトルで、ユーザーembeddings \bm{e}^U_fとアイテムembeddings \bm{e}^I_fがあります。また、各特徴量のスカラーバイアスをユーザー特徴量はb^U_f、アイテム特徴量ではb^I_fとします。

ユーザーuの潜在表現\bm{q}_uは、その特徴量の潜在ベクトル(embeddings)の合計で与えられますので

\bm{q}_u = \sum_{j \in f_u}{\bm{e}^U_j}

と表せ、アイテムiの潜在表現\bm{p}_i

\bm{p}_i = \sum_{j \in f_i}{\bm{e}^I_j}

となります。

バイアス項についても同様で、ユーザーuのバイアス項b_uは特徴量のバイアスの合計であるので

b_u = \sum_{j \in f_u}{b^U_j}

となり、アイテムiのバイアス項b_i

b_i = \sum_{j \in f_i}{b^I_j}

で表せます。

最後にユーザーuとアイテムiに対するモデルの予測は、ユーザーとアイテムの潜在表現の内積を2つのバイアス項で調整して与えられます。

\hat{r}_{ui} = \bm{f} \left( \bm{q}_u \cdot \bm{p}_i + b_u + b_i \right)

モデルの特徴

LightFMでは、協調フィルタリングモデルと同様に、ユーザとアイテムは潜在表現により表現されています。潜在表現は式からも分かる通り、各ユーザやアイテムの潜在ベクトルの線形結合によって定義されます。

例えば、映画「オズの魔法使い」が「ミュージカル・ファンタジー」、「ジュディ・ガーランド」、「オズの魔法使い」の3つのアイテム特徴量で記述される場合、その潜在表現はこれらの潜在ベクトルの和となります。

線形結合によって潜在表現を表現しているため、新しい映画の潜在表現は、過去の映画の潜在ベクトルの和で表現できます。このような特徴から、コールドスタート(ユーザーがどのアイテムに興味があるかのデータがない場合)でも機能するモデルとなっています。

他のモデルとの比較

LightFMはユーザー特徴量とアイテム特徴量がない場合(F^U=U, F^I=I)、Matrix Factorization (MF) と同等になります。よって、MFの拡張モデルと言えます。

また、Factorization Machines (FM) がすべての特徴量とのinteraction(交互作用)を考慮するのに対して、LightFMではアイテムとユーザー特徴量間のinteractionしか考慮しないため、FMの特殊ケースとして解釈できます。

他には、LSI (Latent Semantic Indexing) に帰着する場合があります。ユーザー特徴量とアイテム特徴量がなく(F^U=U, F^I=I)、ユーザーが1つのアイテムとのみinteractionを取る際は、LSIと等価になります。

明示的フィードバックと暗黙的フィードバック

レコメンドデータには、明示的フィードバック(explicit feedback)と暗黙的フィードバック(implicit feedback)の2種類があります。明示的フィードバックは、5段階評価でアイテムを評価してもらったデータといったユーザーの嗜好データです。一方、暗黙的フィードバックはクリック数や閲覧数などのユーザーの関心の強さのデータとなります。したがって、よくある勘違い(私もしていた)として、「暗黙的フィードバックはあるなしの2値、明示的フィードバックはそれ以外」といった理解は正しくありません。

LightFMは暗黙的フィードバックのデータを想定したモデルとなっています。明示的フィードバックのデータには対応していないので、利用の際は注意が必要です。

MovieLensでの実行例

MovieLensのデータ利用して、LightFMを実行してみます。
MovieLensは、ユーザーの映画評価のデータで、以下の3つのデータから構成されています。カラム名はデータには記載されていないので、私が適当に付けたものになります。

movies.dat:映画タイトルとジャンル

index movie_id title genres
0 1 Toy Story (1995) Animation|Children's|Comedy
1 2 Jumanji (1995) Adventure|Children's|Fantasy
2 3 Grumpier Old Men (1995) Comedy|Romance

ratings.dat:ユーザーの映画評価

index user_id movie_id rating date
0 1 1193 5 978300760
1 1 661 3 978302109
2 1 914 3 978301968

users.dat:ユーザー情報

index user_id gender age occupation zip
0 1 F 1 10 48067
1 2 M 56 16 70072
2 3 M 25 15 55117

このデータは、明示的フィードバックのデータとなっています。しかしながら、暗黙的フィードバックの良いデータ例がありませんでしたので、今回は「ユーザーの映画評価」を「映画評価ページ閲覧数」のような暗黙的フィードバックだとして、LightFMに適用することにしました。そのため、計算結果自体は算出されますが、レコメンドスコアや潜在表現に数値以上の意味がないことをご容赦ください。

環境

Google Colaboratoryを使って実行していきます。

lightfmのインストール

LightFMのライブラリlightfmをインストールします。
記事作成時点では、最新バージョンは1.17でしたので、こちらを利用していきます。

!pip install lightfm

データのダウンロード

実は、lightfmライブラリ自体にMovieLensのデータがすぐに利用できる形式で入っています。大変ありがたいのですが、実際に利用する際はデータ形式の変換に手こずったりすることが多いので、今回はデータフレームからlightfmで使えるデータ形式への変換を示します。

# ref : https://qiita.com/nujust/items/9cb4564e712720549bc1
import io
import urllib.request
import zipfile

# MovieLens 1M movie ratings. Stable benchmark dataset.
# 1 million ratings from 6000 users on 4000 movies. Released 2/2003.
url = "https://files.grouplens.org/datasets/movielens/ml-1m.zip"
extract_dir = "."

with (
    urllib.request.urlopen(url) as res,
    io.BytesIO(res.read()) as bytes_io,
    zipfile.ZipFile(bytes_io) as zip_obj,
):
    zip_obj.extractall(extract_dir)

データの読み込み

import pandas as pd

movies = pd.read_csv(
    "ml-1m/movies.dat",
    sep="::",
    encoding="latin1",
    engine="python",
    header=None,
    names=["movie_id", "title", "genres"],
)
ratings = pd.read_csv(
    "ml-1m/ratings.dat",
    sep="::",
    encoding="latin1",
    engine="python",
    header=None,
    names=["user_id", "movie_id", "rating", "date"],
)
users = pd.read_csv(
    "ml-1m/users.dat",
    sep="::",
    encoding="latin1",
    engine="python",
    header=None,
    names=["user_id", "gender", "age", "occupation", "zip"],
)

データの整形

このままでは少しデータが使いにくいので、データを整形します。

# ref : https://recruit.gmo.jp/engineer/jisedai/blog/python_movie_recommendation/
# ref : https://recruit.gmo.jp/engineer/jisedai/blog/movielens_fmm/

# 公開年の取り出し
movies["release"] = (
    movies["title"].str.findall(r"\((\d{4})\)$").apply(lambda x: x[0]).astype(int)
)
# ジャンルのリスト化
movies["genres"] = movies["genres"].str.split("|")

# UNIX時間→日付へ
ratings["date"] = pd.to_datetime(ratings["date"], unit="s")

# ageカテゴリとoccupation(職業)カテゴリの変換
age_map = {
    1: " -18",
    18: "18-24",
    25: "25-34",
    35: "35-44",
    45: "45-49",
    50: "50-55",
    56: "56+",
}
occupation_map = {
    0: "other",
    1: "academic/educator",
    2: "artist",
    3: "clerical/admin",
    4: "college/grad student",
    5: "customer service",
    6: "doctor/health care",
    7: "executive/managerial",
    8: "farmer",
    9: "homemaker",
    10: "K-12 student",
    11: "lawyer",
    12: "programmer",
    13: "retired",
    14: "sales/marketing",
    15: "scientist",
    16: "self-employed",
    17: "technician/engineer",
    18: "tradesman/craftsman",
    19: "unemployed",
    20: "writer",
}
users["age"] = users["age"].map(age_map)
users["occupation"] = users["occupation"].map(occupation_map)

データの分割

それぞれのデータを2つに分割します。usersmoviesratingsのデータを利用し、_newが付いているデータは後ほど利用します。

from sklearn.model_selection import train_test_split


def split_dataframe(df, bool_seires):
    return (
        df[bool_seires].reset_index(drop=True),
        df[~bool_seires].reset_index(drop=True),
    )


# users     : ランダムに選んだ9割のユーザー
# users_new : ランダムに選んだ残り1割のユーザー
users, users_new = train_test_split(users, train_size=0.1, random_state=0)

# movies     : 1998年までの映画データ
# movies_new : 1999年以降の映画データ
movies, movies_new = split_dataframe(movies, movies["release"] < 1999)

# ratings     : ランダムに選んだ9割のユーザー かつ  1998年までの映画の評価データ
# ratings_new : ランダムに選んだ残り1割のユーザーの評価データ、1999年以降の映画評価データ
ratings, ratings_new = split_dataframe(
    ratings,
    (~ratings["user_id"].isin(users_new["user_id"]))
    & (~ratings["movie_id"].isin(movies_new["movie_id"])),
)

データの変換

いよいよ、lightfm用にデータを変換していきます。

最初にlightfmのDatasetクラスのインスタンスを生成します。インスタンス生成時は

  • users:ユニークなユーザーIDの一覧
  • items:ユニークなアイテムIDの一覧
  • user_features:ユニークなユーザー特徴量(ユーザーIDを除く)
  • item_features:ユニークなアイテム特徴量(アイテムIDを除く)
    の4つのデータを指定します。ただし、user_featuresitem_featuresがない場合は、省略可能です。

次に生成したインスタンスから、lightfm用にデータをビルドします。最終的な学習には、interactionssample_weightuser_featuresitem_featuresの4つのデータが必要です。なお、sample_weightについては、interactionsと同時にビルドされます。

それぞれのデータのビルドの際の入力形式は、以下のようになっています。interactionsは重みが等しい場合は、重みの要素の省略が可能です。user_featuresitems_featuresは、2値のみの場合はリスト型でよいですが、連続値を含む場合はdict型で記載します[1]

# interactionsの重みが違う場合
interactions = [
    ("user_A", "item_X", 0),
    ("user_A", "item_Y", 5),
    ("user_A", "item_Z", 1),
    ("user_B", "item_X", 1),
    ("user_C", "item_Y", -2),
]
# interactionsの重みが全て同じ場合
interactions = [
    ("user_A", "item_X"),
    ("user_A", "item_Y"),
    ("user_A", "item_Z"),
    ("user_B", "item_X"),
    ("user_C", "item_Y"),
]
# user_featuresが2値のみの場合
user_features = (
    ["user_A", ["user_feat1", "user_feat2"]],
    ["user_B", ["user_feat3", "user_feat4", "user_feat2"]],
    ["user_C", ["user_feat1", "user_feat4"]],
)
# user_featuresに連続値を含む場合
user_features = (
    ["user_A", {"user_feat1":1, "user_feat2":2}],
    ["user_B", {"user_feat3":1, "user_feat4":0.5, "user_feat2":1}],
    ["user_C", {"user_feat1":1, "user_feat4":10}],
)
# items_featuresが2値のみの場合
items_features = (
    ["item_X", ["item_feat1"]],
    ["item_Y", ["item_feat2", "item_feat3", "item_feat4"]],
    ["item_Z", ["item_feat1", "item_feat3", "item_feat4"]]
)

# items_featuresが連続値を含む場合
items_features = (
    ["item_X", {"item_feat1": 1}],
    ["item_Y", {"item_feat2":1, "item_feat3":2, "item_feat4":3}],
    ["item_Z", {"item_feat1":1, "item_feat3":6, "item_feat4":0.1}],
)

データフレームから変換はこのように書けます。

import itertools

import numpy as np
from lightfm.data import Dataset


def lightfm_data(rating, items, users, dataset=Dataset()):
    def get_item_features(item: pd.DataFrame):
        item_features = []
        for genres, release in zip(item["genres"], item["release"]):
            features = {value: 1 for value in genres}
            features.update({"release": release})
            item_features += [features]

        uq_item_features = np.unique(list(itertools.chain.from_iterable(item_features)))
        item_features = list(zip(item["movie_id"], item_features))
        uq_items = np.unique(item["movie_id"])

        return item_features, uq_items, uq_item_features

    def get_user_features(users: pd.DataFrame):
        user_features = [
            [row["user_id"], [row["gender"], row["age"], row["occupation"]]]
            for _, row in users.iterrows()
        ]

        uq_user_features = np.unique(users[["gender", "age", "occupation"]].unstack())
        uq_users = np.unique(users["user_id"])

        return user_features, uq_users, uq_user_features

    user_features, uq_users, uq_user_features = get_user_features(users)
    item_features, uq_items, uq_item_features = get_item_features(items)

    dataset.fit_partial(
        uq_users,
        uq_items,
        item_features=uq_item_features,
        user_features=uq_user_features,
    )

    interactions, weights = dataset.build_interactions(
        list(zip(rating["user_id"], rating["movie_id"], rating["rating"]))
    )

    user_features = dataset.build_user_features(user_features)
    item_features = dataset.build_item_features(item_features)

    return interactions, weights, user_features, item_features, dataset


interactions, weights, user_features, item_features, dataset = lightfm_data(
    ratings, movies, users
)
user_id_map, user_feature_map, item_id_map, item_feature_map = dataset.mapping()

item_featuresにおいて、releaseとして映画公開日を連続値として指定していますが、実際のレコメンドの際に有効でなさそうです。しかし、連続値を取り扱う練習として入れました。

dataset.fit()ではなく、dataset.fit_partial()とすることで、この関数が新規データにも対応できるようにしています。

dataset.mapping()を実行すると、各データのマッピング(lightfm内でのインデックスと入力したIDの対応表)が取得できます。

学習

モデルインスタンス生成時に指定するパラメータの詳細や調整方法は別の機会で紹介することとして、今回はno_componentslossについてのみ紹介します。no_components はembeddingsの次元数dの値です。lossはloss functionとなっており、

  • logistic : ロジスティック損失
  • bpr : BPR (Bayesian Personalised Ranking)
  • warp: WARP (Weighted Approximate-Rank Pairwise) 2 loss
  • warp-kos : k-th order statistic loss 3

の4種類が選べます。

warpについて

正の相互作用のみが存在し、推薦リストのトップ(precision@k)を最適化したい場合に有用(和訳)

と公式ページに記載してあるので、 今回はwarpを選択することにします。 なお、warp-kosはsample_weightを指定する場合には利用できないので注意が必要です。

from lightfm import LightFM

model = LightFM(no_components=100, loss="bpr", random_state=123)
# 学習時間は約2分
model.fit(
    interactions=interactions,
    sample_weight=weights,
    item_features=item_features,
    user_features=user_features,
    epochs=100,
    num_threads=2,
    verbose=True,
)

epochsは100と設定しましたが、実際の運用では適切に設定する必要があります。ハイパーパラメータの設定とloss functionについても別の機会にまとめたいと考えています。

予測(レコメンド)

既存ユーザーの既存アイテムに対する予測

ユーザー1名の場合であれば、user_idsを数値で指定し、item_idsはリストで指定すればよいです。ただし、どちらもlightfm内のインデックスで指定する必要があります。インデックスは、dataset.mapping()で生成したマップから取得できます。

predictions = model.predict(user_ids=0, item_ids=[0, 1, 2])
print(predictions)
# [101.37569   48.311745  21.090853 -35.42724 ]

複数ユーザーの予測をしたい場合には、指定方法に注意が必要です。2次元配列を渡したくなりますが、2次元配列の形式には対応していません。

# この指定はエラーが出る
model.predict(user_ids=[1, 2], item_ids=[[0, 1], [3, 4, 5]])

複数ユーザーの予測の場合は、user_idsitem_idsを1次元配列で渡さなくてはなりません。つまり、2つのidが一対一で紐づく必要があります。

import itertools

# 予測したいユーザーインデックス:[アイテムインデックス]
users_items = {1: [0, 1], 2: [3, 4, 5]}

input_users = list(users_items.keys())
input_item_n = [len(item) for item in users_items.values()]

input_user_ids = np.repeat(input_users, input_item_n)
input_item_ids = np.array(
    list(itertools.chain.from_iterable(list(users_items.values())))
)

print(input_user_ids)
# [1 1 2 2 2]
print(input_item_ids)
# [0 1 3 4 5]

予測結果は、1次元リストで出力されます。

predictions = model.predict(user_ids = input_user_ids, item_ids=input_item_ids)
print(predictions)
# [338.07364  158.04309  -26.784586  10.424532  24.438726]

少々扱いづらいので、以下のような処理で2次元にすると扱いやすくなります。

predictions = np.split(predictions, list(itertools.accumulate(input_item_n))[:-1])
print(predictions)
# [array([338.07364, 158.04309], dtype=float32), 
# array([-26.784586,  10.424532,  24.438726], dtype=float32)]

ユーザー特徴量の既存アイテムに対する予測

interactionsがない、つまりユーザー特徴量のデータだけがあるコールドスタートのような場合は、このような予測がしたいことがあります。ユーザー特徴量もしくはアイテム特徴量を指定する場合、その特徴量はcsr_matrix形式で渡す必要があります[2]。そして、ユーザー特徴量を渡す場合はuser_ids、アイテム特徴量を渡す場合はitem_idsで、渡すcsr_matrix形式の特徴量のインデックスを指定する必要があり注意が必要です。少しややこしいですが、以下のコードを見ていただくと理解していただけるのではないでしょうか。

# ref : https://nnkkmto.hatenablog.com/entry/2020/12/21/193616
from scipy.sparse import csr_matrix


def convert_csr_features(features, features_map):
    row = np.repeat(range(len(features)), [len(value) for value in features])
    # 連続値
    if type(features[0]) == dict:
        col = []
        data = []
        for dict_value in features:
            col += [features_map[key] for key in dict_value.keys()]
            data += dict_value.values()
    # 2値
    else:
        col = np.array(
            [features_map[key] for key in itertools.chain.from_iterable(features)]
        )
        data = np.repeat(1, len(col))

    csr_features = csr_matrix(
        (data, (row, col)), shape=(len(features), len(features_map))
    )
    return csr_features


# 予測したいユーザー特徴量
pridict_user_features = [["25-34", "M", "programmer"], ["18-24", "F"], ["scientist"]]
# 予測したいアイテムインデックス
pridict_item_list = [[0, 1, 2], [4, 5], [7]]

input_item_n = [len(item) for item in pridict_item_list]
# csr_matrixに変換する
new_user_features_csr = convert_csr_features(pridict_user_features, user_feature_map)

input_user_ids = np.repeat(range(new_user_features_csr.shape[0]), input_item_n)
input_item_ids = np.array(list(itertools.chain.from_iterable(list(pridict_item_list))))

print(input_user_ids)
# [0 0 0 1 1 2]
print(input_item_ids)
# [0 1 2 4 5 7]
new_user_features_csr
# <3x634 sparse matrix of type '<class 'numpy.int64'>'
# 	with 6 stored elements in Compressed Sparse Row format>
predictions = model.predict(
    user_ids=input_user_ids,
    item_ids=input_item_ids,
    user_features=new_user_features_csr,
)
print(predictions)
# [22397.225    11281.854     5412.0024    3601.626     4415.794     -97.393456]

試しにユーザーインデックスの3を指定してみると以下のエラーが出ます。既存userの既存itemに対する予測のときとは、参照しているものが違うとわかります。

model.predict(
    user_ids=[0, 0, 0, 1, 1, 3],
    item_ids=input_item_ids,
    user_features=new_user_features_csr,
)
# Exception: Number of user feature rows does not equal the number of users

既存ユーザーのアイテム特徴量に対する予測

ユーザー特徴量の既存アイテムに対する予測と同様です。

# 予測したいユーザーインデックス
pridict_item_list = [1, 10, 10, 100]
# 予測するアイテム特徴量
pridict_item_features = [
    {"Animation": 1, "Comedy": 1, "Action": 1},
    {"Horror": 1},
    {"Action": 1},
    {"release": 1995},
]

# csr_matrixに変換する
new_item_features_csr = convert_csr_features(pridict_item_features, item_feature_map)

input_item_n = [len(item) for item in pridict_item_features]

input_user_ids = pridict_item_list
input_item_ids = list(input_item_ids)

predictions = model.predict(
    user_ids=input_user_ids,
    item_ids=input_item_ids,
    item_features=new_item_features_csr,
)
predictions
# array([  681.6143 ,   121.10423,   452.80695, 10841.821  ], dtype=float32)

ユーザー特徴量のアイテム特徴量に対する予測も同じ要領で予測できます。

embeddingsの取得

潜在ベクトルの取得

ユーザーの潜在ベクトル \bm{e}^Uとアイテムの潜在ベクトル \bm{e}^Iは、学習済みのモデルに格納されています。

user_embeddings_e = model.user_embeddings
item_embeddings_e = model.item_embeddings

print(len(user_feature_map))
# 634
print(user_embeddings.shape)
# (634, 100)

潜在表現の取得

ユーザーの潜在表現 \bm{q}model.get_user_representations、アイテムの潜在表現\bm{p}model.get_item_representationsで計算できます。よくあるアイテム間のコサイン類似度を求める際は、潜在ベクトルではなくこちらを利用しましょう。モデルの説明で示した通り、潜在表現を線形結合によって表現しているためです[3]

user_bias, user_embeddings_q = model.get_user_representations(user_features)
item_bias, item_embeddings_p = model.get_item_representations(item_features)

print(user_embeddings_q.shape)
print(user_bias.shape)

評価

モデル評価用の関数で評価ができます。本来はinteractionsを学習用とテストデータで分けて、モデルの評価を行うのですが、今回は学習用のデータでテストを行います。

from lightfm import evaluation

recalls = evaluation.recall_at_k(
    model,
    k=10,
    test_interactions=interactions,
    user_features=user_features,
    item_features=item_features,
)
precisions = evaluation.precision_at_k(
    model,
    k=10,
    test_interactions=interactions,
    user_features=user_features,
    item_features=item_features,
)
auc_scores = evaluation.auc_score(
    model,
    test_interactions=interactions,
    user_features=user_features,
    item_features=item_features,
)
reciprocal_rank = evaluation.reciprocal_rank(
    model,
    test_interactions=interactions,
    user_features=user_features,
    item_features=item_features,
)

print(recalls.mean())
# 0.05386387440379941
print(precisions.mean())
# 0.48360923
print(auc_scores.mean())
# 0.8725047
print(reciprocal_rank.mean())
# 0.6471216

新規データへの対応とモデル更新

新規データへの対応とモデル更新について考えます。モデル作成後に新規データが追加された場合、過去のデータと合わせて再度1から学習する方法がありますが、学習の際に初期値ではなく現在のembeddingsやbiasを利用して新規データのみ学習できれば、時間の短縮が期待できます。lightfmfit_partialを利用して、モデル更新を行えます。しかし、fit_partialは過去に登場していない新規ユーザーや新規アイテムが追加された際は、モデルの更新が行えません。この問題点を解決するgithubに投稿されていた[4]LightFMResizableクラスを紹介します。このクラスを利用することで、新規データへ対応してモデル更新が行えるようなります。

LightFMResizable.py
from lightfm import LightFM
from sklearn.base import clone

class LightFMResizable(LightFM):
    """
    https://github.com/lyst/lightfm/issues/347#issuecomment-707829342
    A LightFM that resizes the model to accomodate new users,
    items, and features
    """

    def fit_partial(
        self,
        interactions,
        user_features=None,
        item_features=None,
        sample_weight=None,
        epochs=1,
        num_threads=1,
        verbose=False,
    ):
        try:
            self._check_initialized()
            self._resize(interactions, user_features, item_features)
        except ValueError:
            # This is the first call so just fit without resizing
            pass

        super().fit_partial(
            interactions,
            user_features,
            item_features,
            sample_weight,
            epochs,
            num_threads,
            verbose,
        )

        return self

    def _resize(self, interactions, user_features=None, item_features=None):
        """Resizes the model to accommodate new users/items/features"""

        no_components = self.no_components
        no_user_features, no_item_features = interactions.shape  # default

        if hasattr(user_features, "shape"):
            no_user_features = user_features.shape[-1]
        if hasattr(item_features, "shape"):
            no_item_features = item_features.shape[-1]

        if (
            no_user_features == self.user_embeddings.shape[0]
            and no_item_features == self.item_embeddings.shape[0]
        ):
            return self

        new_model = clone(self)
        new_model._initialize(no_components, no_item_features, no_user_features)

        # update all attributes from self._check_initialized
        for attr in (
            "item_embeddings",
            "item_embedding_gradients",
            "item_embedding_momentum",
            "item_biases",
            "item_bias_gradients",
            "item_bias_momentum",
            "user_embeddings",
            "user_embedding_gradients",
            "user_embedding_momentum",
            "user_biases",
            "user_bias_gradients",
            "user_bias_momentum",
        ):
            # extend attribute matrices with new rows/cols from
            # freshly initialized model with right shape
            old_array = getattr(self, attr)
            old_slice = [slice(None, i) for i in old_array.shape]
            new_array = getattr(new_model, attr)
            new_array[tuple(old_slice)] = old_array
            setattr(self, attr, new_array)

        return self

LightFMResizableクラスからmodelを生成し、同様にデータを適用します。

model = LightFMResizable(no_components=100, loss="warp", random_state=123)
model.fit(
    interactions=interactions,
    sample_weight=weights,
    item_features=item_features,
    user_features=user_features,
    epochs=100,
    num_threads=2,
    verbose=True,
)

新規データをlightfm用のデータに変換したのち、model.fit_partialを実行することでモデルの更新が行えました。

(
    interactions_new,
    weights_new,
    user_features_new,
    item_features_new,
    dataset_new,
) = lightfm_data(ratings_new, movies_new, users_new, dataset)
user_id_map, user_feature_map, item_id_map, item_feature_map = dataset_new.mapping()

model.fit_partial(
    interactions=interactions,
    sample_weight=weights,
    item_features=item_features,
    user_features=user_features,
    epochs=50,
    num_threads=2,
    verbose=True,
)

おわりに

私自身が推薦モデルについては入門書で勉強したが、どう推薦モデルを推薦システムとして運用すればよいか迷っていたので、このような記事を作成しました。簡単にまとめるつもりが、それなりに長い記事になってしまいました。lightFMがシンプルでありながら、拡張性が高く実用的なモデルであることが伝わっていれば嬉しいです。また、記事を作成する中で、ハイパーパラメータの設定方法やloss functionについての理解が浅いことに気づけましたので、別の機会にまとめる予定です。最後に、この記事で同じような境遇の方に少しでも有益な情報を提供できていれば幸いです。

宣伝

GENDAデータチームでは、プロダクトのデータ分析や機械学習プロジェクトを推進できるデータサイエンティストを現在募集しています。カジュアル面談も可能ですので、お気軽にご連絡ください。
https://hrmos.co/pages/genda/jobs/1700006587045969926

脚注
  1. https://github.com/lyst/lightfm/issues/494#issuecomment-544011763 ↩︎

  2. https://github.com/lyst/lightfm/issues/210 ↩︎

  3. 回帰分析の回帰係数の解釈でも同じ話があります。 ↩︎

  4. https://github.com/lyst/lightfm/issues/347#issuecomment-707829342 ↩︎

GENDA

Discussion