LightFMから始める推薦システム入門
アドベントカレンダー
株式会社GENDAでデータサイエンティストをしているtoma2です。
この記事は、GENDAアドベントカレンダー2023の9日目の記事になります。
GENDAアドベントカレンダーでは、プロダクト開発や組織開発に関わるメンバーを中心に多様なテーマの記事を投稿しています。ぜひ、購読登録をしていただき12月25日までお楽しみください。
はじめに
最近、推薦モデルを調べる中でLightFMについて勉強したので、その内容をまとめとデータセットMovieLensでの実行例を示します。また、私が推薦モデルから推薦システムを作ろうとした際に躓いた、新規データへの対応やモデル更新といった実用的な内容も記載しています。
参考文献
こうもとさんのブログ「宇宙 日本 世田谷 機械学習」は、lightFMの理論から実用上の細かい点まで詳しく記載されており、大変参考にさせていただきました。
LightFMから始めるのがおすすめな理由
実業務での運用実績
LightFMは、Lystから発表された論文で提案された手法です。LystはイギリスのECファッションサイトで日本で言うZOZOTOWNのようなサイトです。この手法は、サイトの課題であった
- サイト内に800万以上のファッションアイテムがある上、データが非常にスパースである
- ファッションアイテムは新しい商品同士で関連性があるため、短期間でデータを収集しレコメンドする必要がある
- 多くのユーザーが初めてのサイト利用者のため、過去の購買データがない(コールドスタート問題)
を解決するために開発されたもので、実際にLystでも運用されていたようです。
CPUで動く動作の軽さ
名前の通り動作が軽くCPUで動きます。GPU環境を必要としないので、環境構築が比較的容易で入門に最適です。「サービスに推薦システムを導入したい」と考えている実務者の方は、LightFMからの導入してみるのも良いのではないでしょうか。
ライブラリと公式ドキュメントの完成度の高さ
LightFMのPythonライブラリであるlightfm
の完成度が非常に高いです。データ形式の変換関数やloss functionなどが充実しており実用的です。また、公式のドキュメントや実行例が豊富なので、スムーズにライブラリの活用ができます。
LightFM
モデルの説明
特徴量を使ってユーザーとアイテムを表現してみます。あるユーザー
LightFMは、embeddings(埋め込み)をパラメータとして持っています。embeddingsは
ユーザー
と表せ、アイテム
となります。
バイアス項についても同様で、ユーザー
となり、アイテム
で表せます。
最後にユーザー
モデルの特徴
LightFMでは、協調フィルタリングモデルと同様に、ユーザとアイテムは潜在表現により表現されています。潜在表現は式からも分かる通り、各ユーザやアイテムの潜在ベクトルの線形結合によって定義されます。
例えば、映画「オズの魔法使い」が「ミュージカル・ファンタジー」、「ジュディ・ガーランド」、「オズの魔法使い」の3つのアイテム特徴量で記述される場合、その潜在表現はこれらの潜在ベクトルの和となります。
線形結合によって潜在表現を表現しているため、新しい映画の潜在表現は、過去の映画の潜在ベクトルの和で表現できます。このような特徴から、コールドスタート(ユーザーがどのアイテムに興味があるかのデータがない場合)でも機能するモデルとなっています。
他のモデルとの比較
LightFMはユーザー特徴量とアイテム特徴量がない場合(
また、Factorization Machines (FM) がすべての特徴量とのinteraction(交互作用)を考慮するのに対して、LightFMではアイテムとユーザー特徴量間のinteractionしか考慮しないため、FMの特殊ケースとして解釈できます。
他には、LSI (Latent Semantic Indexing) に帰着する場合があります。ユーザー特徴量とアイテム特徴量がなく(
明示的フィードバックと暗黙的フィードバック
レコメンドデータには、明示的フィードバック(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つに分割します。users
、movies
、ratings
のデータを利用し、_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_features
とitem_features
がない場合は、省略可能です。
次に生成したインスタンスから、lightfm
用にデータをビルドします。最終的な学習には、interactions
、sample_weight
、user_features
、item_features
の4つのデータが必要です。なお、sample_weight
については、interactions
と同時にビルドされます。
それぞれのデータのビルドの際の入力形式は、以下のようになっています。interactions
は重みが等しい場合は、重みの要素の省略が可能です。user_features
とitems_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_components
とloss
についてのみ紹介します。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_ids
とitem_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の取得
潜在ベクトルの取得
ユーザーの潜在ベクトル
user_embeddings_e = model.user_embeddings
item_embeddings_e = model.item_embeddings
print(len(user_feature_map))
# 634
print(user_embeddings.shape)
# (634, 100)
潜在表現の取得
ユーザーの潜在表現 model.get_user_representations
、アイテムの潜在表現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を利用して新規データのみ学習できれば、時間の短縮が期待できます。lightfm
はfit_partial
を利用して、モデル更新を行えます。しかし、fit_partial
は過去に登場していない新規ユーザーや新規アイテムが追加された際は、モデルの更新が行えません。この問題点を解決するgithubに投稿されていた[4]LightFMResizable
クラスを紹介します。このクラスを利用することで、新規データへ対応してモデル更新が行えるようなります。
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データチームでは、プロダクトのデータ分析や機械学習プロジェクトを推進できるデータサイエンティストを現在募集しています。カジュアル面談も可能ですので、お気軽にご連絡ください。
Discussion