LightFMで前処理・学習から予測・評価・潜在表現の取得までやってみる

f:id:nnkkmto:20201221195753p:plain
ロゴがかっこいい、、、

概要

こちらのLightFMを実際にMovieLensのデータを使って一通り動かしてみます。 github.com

元になっている論文はこちらです。 arxiv.org

細かい論文の内容に関しては以下の記事でまとめています。 nnkkmto.hatenablog.com

また、動かすことが目的であるため精度に関してはこの記事では考えません。

論文概要

user, itemの潜在表現をuser, itemそれぞれのメタデータの潜在表現のsumとして表現することで、

  • user, itemのメタデータに関してもMatrix Factorizationのように類似度を元にした潜在表現の学習を行う
  • Matrix Factorizationにおけるcold-start問題に対処する

というものがLightFMで、user×itemのinteractionのみを考慮するという点においては、全特徴量間のinteractionを考慮するFactorization Machinesの特殊系というかLightになったバージョンと言えます。

流れ

以下のような流れで使い方をみていきます。

  1. lightfm.data.Datasetを用いた前処理
  2. 学習
  3. 潜在表現の取得
  4. 既存ユーザーに対する予測
  5. 新規ユーザーに対するfeatureを用いた予測
  6. lightfm.evaluationを用いた評価(recall@4)

使用するデータセット

MovieLens 100K Dataset | GroupLens からratingが4以上のinteractionのみを抜き出し、整形した以下のようなテーブルを使用します。

f:id:nnkkmto:20201221145322p:plain

使用したスクリプト

今回使ったスクリプトは以下レポジトリに格納しています。 github.com

lightfm.data.Datasetを用いた前処理

LightFMの学習時の入力値としては、

  • interaction matrix: shape=(n_users, n_items)
  • user feature matrix: shape=(n_users, n_user_features)
  • item feature matrix: shape=(n_items, n_item_features)

があります。interaction matrixが教師ラベルとなるuser×itemのinteractionを含む行列です。

user, item feature matrixは各user, itemに結びつくfeatureをあらわす行列で、user, item embeddingはこれらfeatureのembeddingのsumで表現されます。

ここで、user, item feature matrixは任意で、両方とも入力しない場合はMatrix Factorizationになります。

これらLightFMへの入力の構築をencoding含めてやってくれるのがlightfm.data.Dataset になります。

概要

また、lightfm.data.Datasetにおける手順としては、

  1. user_id, item_idを含む各種特徴量における値のユニーク値を元にencoder生成(fitメソッド)
  2. 各種データセットのビルド(build_*メソッド)

という流れになります。

まず、存在する特徴量をuser_id, item_id含めてencoderに登録し、build_*メソッドにより入力をone-hot encodingすることによりmatrixにするという形になっています。

また、lightfm.data.Datasetへの入力形式に関して、

こちらのissueにある通り、build_*メソッドにおいては特徴量の重みを指定しない場合は以下のような入力形式になります。

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", 5),
]
users_features = (
["user_A", ["user_feat1", "user_feat2"]],
["user_B", ["user_feat3", "user_feat4", "user_feat2"]],
["user_C", ["user_feat1", "user_feat4"]],
)
items_features = (
["item_X", ["item_feat1"]],
["item_Y", ["item_feat2", "item_feat3", "item_feat4"]],
["item_Z", ["item_feat1", "item_feat3", "item_feat4"]]
)

特徴量の重みを指定する場合は、こちらのissueにある通り、

users_features = (
["user_A", {"user_feat1": 1, "user_feat2": 3}],
["user_B", {"user_feat3": , "user_feat4": 1, "user_feat2": 2}],
["user_C", {"user_feat1": 0.5, "user_feat4": 1}],
)

のように、feature名のlistになっているところをfeature名: weight のdictとして渡すことで指定可能です。

ここで、interactionsは0 or 1つまりimplicit feedbackの問題設定である場合はratingが省略可能です。

また、特徴量間で同じ値を使用している際は、prefixとして特徴量名をfeatureの値に付与しておくなどして値の重複を防いだ方が良いように思います。

dataframeからの変換

f:id:nnkkmto:20201221145322p:plain

こちらのデータセットから、以下のようにDatasetの入力形式へ変換します。

# prefixの付与
df["age"] = "age-" + df["age"].astype(str)
df["gender"] = "gender-" + df["gender"].astype(str)
df["occupation"] = "occupation-" + df["occupation"].astype(str)
df["release_date"] = "release_date-" + df["release_date"].astype(str)
df["genre"] = [["genre-" + value for value in i] for i in df["genre"].values.tolist()]


# user, item 特徴量の結合
df["user_features"] = df[["age", "gender", "occupation"]].values.tolist()
df["item_features"] = df[["release_date", "genre"]].values.tolist()

# list of listになっているレコードをflatten
def flatten_sequences(sequences):
    sequences = [i if type(i) == list else [i] for i in sequences]
    flattened = list(itertools.chain.from_iterable(sequences))
    return flattened

df["item_features"] = df["item_features"].apply(flatten_sequences)

df = df[["user_id", "movie_title", "user_features", "item_features", "timestamp"]]

df

f:id:nnkkmto:20201221182200p:plain

また、評価時に使いたいので、全体のデータセットから各ユーザーに対してtimestampが最大、つまり最新のinteractionを抽出したものをtest setとして用意します。

df_test_indices = df.groupby("user_id")
df_test = df.loc[df_test_indices['timestamp'].idxmax(),:]
df_test

f:id:nnkkmto:20201221151822p:plain

train setとしてはそれ以外のinteractionを使用します。

df_test["test_flag"] = 1
df_train = pd.merge(df, df_test[["test_flag"]], how="left", left_index=True, right_index=True)
df_train = df_train[df_train["test_flag"].isnull()].drop(columns=["test_flag"])
df_train

f:id:nnkkmto:20201221152055p:plain

encoderの生成

上記datasetからencoderを作成します。

user_id, item_id, user_feature, item_featureそれぞれの値をユニークで取得し、それを入力としてencoderを生成します。

# user, item featureは全て既知として、既存のレコード全てからマスタを作成
uq_users = np.unique(df.user_id.values)
uq_items = np.unique(df.movie_title.values)
uq_user_features = np.unique(np.array(flatten_sequences(list(df.user_features.values))))
uq_item_features = np.unique(np.array(flatten_sequences(list(df.item_features.values))))

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

datasetのビルド

以下のように、datasetへの入力形式に変換します。

# train interaction matrix
df_train_interactions = df_train[["user_id", "movie_title"]].drop_duplicates()
train_interactions = list(df_train_interactions.itertuples(index=False, name=None))  # [(user_id, item_id), ...]

# test interaction matrix
df_test_interactions = df_test[["user_id", "movie_title"]].drop_duplicates()
test_interactions = list(df_test_interactions.itertuples(index=False, name=None))  # [(user_id, item_id), ...]

# user feature matrix
df_user_features = df[["user_id","user_features"]].drop_duplicates(subset='user_id').set_index('user_id')
user_features = list(df_user_features.itertuples(index=True, name=None))  # (user_id, [feature1, feature2, ...])

# item feature matrix
df_item_features = df[["movie_title","item_features"]].drop_duplicates(subset='movie_title').set_index('movie_title')
item_features = list(df_item_features.itertuples(index=True, name=None))  # (item_id, [feature1, feature2, ...])

そして、それを各build_*メソッドへ渡すことで、LightFMへの入力を得ることができます

# implicit feedbackのため、weightつきのinteractionは使用しない
train_interactions, _ = dataset.build_interactions(train_interactions)
test_interactions, _ = dataset.build_interactions(test_interactions)

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

ここで、build_interactionsからは0 or 1つまりimplicitなinteractionと重み付きのinteractionの二つが返ってきますが、今回はimplicit feedbackを扱うため後者は使用しません。

また、LightFMにおいてはuser, itemのidもfeatureの一つとして扱うため、build_user_features build_item_features の返してくるmatrixの列にはidも含まれます。

そのため、例えばuser_featuresのshapeは正確には(n_users, n_users+n_user_features)となります。

mappingの取得

以下のように、featureとidとmatrixにおけるindexのmappingは以下のようにして取得可能です。

これを使用して、予測結果のmatrixを簡単にid名やfeature名で確認できたりするようになっています。

user_id_map, user_feature_map, item_id_map, item_feature_map = dataset.mapping()

print(f"index of user_feature age-48: {user_feature_map['age-48']}")
print(f"index of item_feature Full Monty, The (1997): {item_feature_map['Full Monty, The (1997)']}")
index of user_feature age-48: 979
index of item_feature Full Monty, The (1997): 498

また、前述のuser, item_featuresと同じく、user, item_feature_mapにはuser, item_idのmappingも含まれます。

そのため、例えばlen(user_id_map) == n_user len(user_feature_map) == n_users+n_user_features となります。

連続値を入力とする場合

ここでは扱いませんが、連続値を入力する場合は前掲のissueにあるように、特徴量の重みを指定することで変換してくれます。 ここで、連続値に対してはカテゴリー値とは違い、値ではなく特徴量名をfitに渡すことになります。

例えば、ageを連続値として渡す場合は以下のようになります。

dataset = Dataset()

dataset.fit([1, 2], uq_items, user_features=["age"])
# 見やすいようにnormalizeしないようにする
user_features = dataset.build_user_features([[1, {"age": 20}], [2, {"age": 10}]], normalize=False)  # [[user_id, {feature1: weight, ...}, ...], ...])

print(user_features.toarray())
# 1, 2列目がuser_id、3列目が連続値として渡したage
array([[ 1.,  0., 20.],
       [ 0.,  1., 10.]], dtype=float32)

学習

学習時は、生成済みの入力を渡して完了です。 no_componentsがembeddingの次元です。

model = LightFM(no_components=10, loss='warp')
model.fit(train_interactions, item_features=item_features, user_features=user_features, epochs=100)

また、epochsは100で設定していますが、デフォルトだと

  • epochs: 1
  • user_alpha , item_alpha(user, item embeddingに対するL2正則化): 0.0

という設定になっていて、データセットによるとは思いますがデフォルトの設定そのままだとうまく学習できないような気がします。

潜在表現の取得

学習済みのモデルから

  • 各種featureの潜在表現
  • user, itemの潜在表現

を取得することができます。

各種featureの潜在表現

学習済みのモデルには user_embeddings item_embeddings として、各種featureのembeddingが格納されており、(n_users, 指定したembeddingの次元), (n_items, 指定したembeddingの次元) の行列が取得できます。

そのため、datasetで作成されたmappingを使用すれば、以下のように簡単に各値のembeddingを取得することができます。

user_id_map, user_feature_map, item_id_map, item_feature_map = dataset.mapping()

# user embeddingの取得
user_embeddings = model.user_embeddings
mapped_user_embeddings = {name: user_embeddings[index] for name, index in user_feature_map.items()}
print("Embedding: gender-F")
print(mapped_user_embeddings["gender-F"])

# item embeddingの取得
item_embeddings = model.item_embeddings
mapped_item_embeddings = {name: item_embeddings[index] for name, index in item_feature_map.items()}
print("Embedding: MatchMaker, The (1997)")
print(mapped_item_embeddings["MatchMaker, The (1997)"])
Embedding: gender-F
[-2.3874125   0.9395195  -2.2523065  -6.961481   -0.7835085   4.270282
 -0.47744742  0.6703578   6.288997    5.106352  ]

Embedding: MatchMaker, The (1997)
[ 0.48336646  0.1213533   0.6352143   0.08677411  0.5721241  -0.5955509
  0.6072041  -0.3372576  -0.7195242  -0.93152696]

user, itemの潜在表現

前述の通りLightFMでは、user, itemのembeddingはuser feature, item featureのembeddingを足し合わせたものとして表現されます。

そのため、featureを元にそのuserやitemのembeddingを取得するメソッドもget_user_representations get_item_representations として用意されています。

以下のように、各featureの値を学習に使用したdatasetのmappingを用いて、LightFMの入力であるuser_featuresとしてcsr_matrixに変換します。

それを渡すことで、get_user_representations ならそのfeatureを持つuserのembeddingとそのbiasを返してくれます。get_item_representations に関しても同様の手順となります。

user_id_map, user_feature_map, item_id_map, item_feature_map = dataset.mapping()

def create_matrix_with_user_features(user_features):
    # csr_matrixに変換
    col = np.array([user_feature_map[key] for key in user_features])
    row = np.repeat(0, len(user_features))
    data = np.repeat(1, len(user_features))
    user_matrix = csr_matrix((data, (row, col)), shape=(1, len(user_feature_map)))
    return user_matrix

user_features = ["age-33", "gender-F", "occupation-administrator"]
user_matrix = create_matrix_with_user_features(user_features)

bias, embeddings = model.get_user_representations(features=user_matrix)

print(f"bias: {bias}")
print(f"embedding: {embeddings[0]}")
bias: [-195.15112]
embedding: [-4.9082627 -1.2549659 -4.376274  -8.901307  -2.6249416  4.056341
 -1.5262341  2.209255   7.078497   6.1518583]

予測

既存user, itemに対する予測

学習時に存在していたユーザーに対しては、user_ids, item_idsに学習で渡したinteractionにおけるindexを渡せば対象のユーザーの対象のアイテムに対する予測を返してくれます。

# 学習時のinteraction matrixにおける、0行目のuserの[0,1,2]列目のitemに対するscore
predictions = model.predict(user_ids=0, item_ids=[0,1,2])
print(predictions)
array([-15.714636 , -11.901891 , -13.5374365], dtype=float32)

また、datasetのmappingを使用することで、indexではなく本来のuser_idに対して本来のitem_idへの予測結果の確認を行うことが簡単にできます

ここでは、以下のuser_id=873のユーザーに対する全アイテムの予測結果をdataframeに格納して表示してみます。

df_train[df_train["user_id"] == 873]

f:id:nnkkmto:20201221171246p:plain

user_id_map, user_feature_map, item_id_map, item_feature_map = dataset.mapping()

# user_idに対して、全アイテムへの予測値を格納したdfを返す
def predict_all_items_by_user_id(user_id):
    user_index = user_id_map[user_id]
    predicted = model.predict(user_ids=user_index, item_ids=np.array(range(0,len(item_id_map))))
    return make_predict_df(predicted)    
    
def make_predict_df(predicted):
    df_predicted = pd.DataFrame.from_dict(item_id_map, orient='index').rename(columns={0: "item_index"}).sort_values(by="item_index")
    df_predicted["score"] = predicted
    df_predicted = df_predicted.sort_values(by="score", ascending=False)
    return df_predicted[["score"]]

predict_all_items_by_user_id(873)

f:id:nnkkmto:20201221171504p:plain

新規user, itemに対する予測

こちらのissueにあるように、predict において、上記のようにuser_ids item_ids のみを渡した場合は、それらは学習時のinteractionにおけるindexを意味するんですが、user_features item_features を一緒に渡した場合はそれらfeaturesのmatrixにおけるindexを意味するようになります。

そのため、新規user, itemに対してはuser_id, item_idが全て0であるuser_features, item_featuresを渡し、そのfeaturesにおけるindexをidsとして指定することで予測結果を得ることができます。

以下では user_features = ["age-48", "gender-F", "occupation-administrator"] である新規userに対する全アイテムの予測結果を表示しています。

def predict_all_items_by_user_features(user_features):
    # user_featuresをmatrixに変換
    col = np.array([user_feature_map[key] for key in unknown_user_features])
    row = np.repeat(0, len(unknown_user_features))
    data = np.repeat(1, len(unknown_user_features))
    user_features = csr_matrix((data, (row, col)), shape=(1, len(user_feature_map)))
    
    # predict 
    predicted = model.predict(user_ids=0, item_ids=np.array(range(0,len(item_id_map))), user_features=user_features)
    return make_predict_df(predicted)

unknown_user_features = ["age-48", "gender-F", "occupation-administrator"]
predict_all_items_by_user_features(unknown_user_features)

f:id:nnkkmto:20201221172435p:plain

predict_rank

その他の予測用のメソッドとしてpredict_rankが用意されています。

こちらのissueにあるように、test_interactionsとして渡した行列においてnon-zeroになっているuser×itemが、そのuserの全itemに対するスコアの中で上位何位になっているかを含む行列を返してくれるメソッドになっています。 そのため、test_interactionsとしてはランクを知りたいuser×itemのindexがnon-zeroになっている(n_users, n_items)の行列を用意します。

ここでは、上記で用意したtest setをinteractionに変換したものを渡します。 また、item_featuresとuser_featuresは

model.predict_rank(
    test_interactions=test_interactions, user_features=user_features, 
    item_features=item_features)

predict_rankにおける注意点

Performs best when only a handful of interactions need to be evaluated per user. If you need to compute predictions for many items for every user, use the predict method instead.

predict_rankのドキュメントに上記の記載があるように、predict_rankはuserに対するitemのinteractionが多くなるとパフォーマンスが落ちるようです。

そのため、こちらのissueにある通り、あるuserに対して全itemのランクが知りたいという場合においてはpredictメソッドを使用した方が良いです。

評価

ここでは、train setに存在する全userに対して4件のitemを予測した際に、その中にuserが次にratingをつけるitemが含まれるかを問題設定とします。*1

方法としては、userの最新のinteractionを抽出したものをtest set、それ以外をtrain setとしてrecall@4での評価を行います。

また、全てのuser, itemが学習時に既知であると仮定します。

前述の前処理で用意したdatasetを元に同じようにLightFMへの入力形式に変換します。

df_test_interactions = df_test[["user_id", "movie_title"]].drop_duplicates()

test_interactions = list(df_test_interactions.itertuples(index=False, name=None))  # [(user_id, item_id), ...]
test_interactions, _ = dataset.build_interactions(test_interactions)  # shape=(n_users, n_items)のcsr_matrix

そして以下で各userに対するrecall@4が返ってきます

# 全てのuser, itemが既知であるとするため、学習時に生成したuser_featuresとitem_featuresを使用する
recalls = evaluation.recall_at_k(model, k=4, test_interactions=test_interactions, user_features=user_features, item_features=item_features)

print(recalls.mean())

結果は0.0031847133757961785でした。今回の問題設定だと942人のuserのうち3人にのみ正解したという結果になっています。

また、その他の評価指標に関しても同じインターフェースで使用できます。

# recall_at_kと同じくuserごとの結果が格納された配列が返ってくる
precisions = evaluation.precision_at_k(model, k=4, test_interactions=test_interactions, user_features=user_features, item_features=item_features)
aucs = evaluation.auc_score(model, test_interactions=test_interactions, user_features=user_features, item_features=item_features)

評価時の注意点

上記のどのメソッドもpredict_rankを内部で使用しているため、同じくuserに対するitemのinteractionが多くなるような問題設定で評価を行う場合は、predictメソッドを使用し他のパッケージもしくは自作の評価関数で評価する形が良いように思われます。*2

最後に

書いたようにいくつか注意点やわかりづらい仕様があり困りましたが、手軽にFactorization Machines系モデルを使えるという点ではかなりLightFMいい感じです。

*1:レコメンドにおけるitem表示において、その表示順がuserの行動に影響しない場合において有効な評価だと考えています。

*2:実際に予測モデルとして運用していく際に、特定のuserの特定のitemのランクのみを知りたいというケースがあまりないように思うので、そもそもpredict_rankは評価時に使う用のメソッドという立ち位置なのかもしれません