作者: Fabien Hertschuh, Abheesht Sharma
创建日期 2025/04/28
最后修改 2025/04/28
描述: 使用双塔模型检索电影。
推荐系统通常由两个阶段组成
在本教程中,我们将重点关注第一阶段:召回。如果您对排序阶段感兴趣,请查看我们的排序教程。
召回模型通常由两个子模型组成
在本教程中,我们将使用 Movielens 数据集构建和训练这样的双塔模型。
我们将要
Movielens 数据集是明尼苏达大学 GroupLens 研究小组提供的经典数据集。它包含一组用户对电影的评分,是推荐系统研究的标准。
数据可以通过两种方式处理
在本教程中,我们重点关注一个召回系统:一个从目录中预测用户可能观看的一组电影的模型。为此,模型将尝试预测用户会给目录中所有电影的评分。因此,我们将使用显式评分数据。
让我们首先选择 JAX 作为我们想要运行的后端,并导入所有必要的库。
!pip install -q keras-rs
import os
os.environ["KERAS_BACKEND"] = "jax" # `"tensorflow"`/`"torch"`
import keras
import tensorflow as tf # Needed for the dataset
import tensorflow_datasets as tfds
import keras_rs
我们首先看一下数据。
我们使用来自 Tensorflow Datasets 的 MovieLens 数据集。加载 movielens/100k_ratings
会得到一个 tf.data.Dataset
对象,其中包含评分以及用户和电影数据。加载 movielens/100k_movies
会得到一个 tf.data.Dataset
对象,其中仅包含电影数据。
请注意,由于 MovieLens 数据集没有预定义的分割,所有数据都位于 train
分割下。
# Ratings data with user and movie data.
ratings = tfds.load("movielens/100k-ratings", split="train")
# Features of all the available movies.
movies = tfds.load("movielens/100k-movies", split="train")
评分数据集返回一个字典,包含电影 ID、用户 ID、分配的评分、时间戳、电影信息和用户信息。
for data in ratings.take(1).as_numpy_iterator():
print(str(data).replace(", '", ",\n '"))
{'bucketized_user_age': np.float32(45.0),
'movie_genres': array([7]),
'movie_id': b'357',
'movie_title': b"One Flew Over the Cuckoo's Nest (1975)",
'raw_user_age': np.float32(46.0),
'timestamp': np.int64(879024327),
'user_gender': np.True_,
'user_id': b'138',
'user_occupation_label': np.int64(4),
'user_occupation_text': b'doctor',
'user_rating': np.float32(4.0),
'user_zip_code': b'53211'}
在 Movielens 数据集中,用户 ID 是从 1 开始且没有间隔的整数(表示为字符串)。通常,您需要创建一个查找表将用户 ID 映射到从 0 到 N-1 的整数。但作为简化,我们将直接在模型中使用用户 ID 作为索引,特别是用于从用户嵌入表中查找用户嵌入。因此我们需要知道用户数量。
users_count = (
ratings.map(lambda x: tf.strings.to_number(x["user_id"], out_type=tf.int32))
.reduce(tf.constant(0, tf.int32), tf.maximum)
.numpy()
)
电影数据集包含电影 ID、电影标题和它所属的类型。注意类型是使用整数标签编码的。
for data in movies.take(1).as_numpy_iterator():
print(str(data).replace(", '", ",\n '"))
{'movie_genres': array([4]),
'movie_id': b'1681',
'movie_title': b'You So Crazy (1994)'}
在 Movielens 数据集中,电影 ID 是从 1 开始且没有间隔的整数(表示为字符串)。通常,您需要创建一个查找表将电影 ID 映射到从 0 到 N-1 的整数。但作为简化,我们将直接在模型中使用电影 ID 作为索引,特别是用于从电影嵌入表中查找电影嵌入。因此我们需要知道电影数量。
movies_count = movies.cardinality().numpy()
在此示例中,我们将重点关注评分数据。其他教程将探索如何使用电影信息数据和用户信息来提高模型质量。
我们在数据集中只保留 user_id
、movie_id
和 rating
字段。我们的输入是 user_id
。标签是 movie_id
以及给定电影和用户的 rating
。
rating
是一个介于 1 和 5 之间的数字,我们将其调整为介于 0 和 1 之间。
def preprocess_rating(x):
return (
# Input is the user IDs
tf.strings.to_number(x["user_id"], out_type=tf.int32),
# Labels are movie IDs + ratings between 0 and 1.
{
"movie_id": tf.strings.to_number(x["movie_id"], out_type=tf.int32),
"rating": (x["user_rating"] - 1.0) / 4.0,
},
)
为了拟合和评估模型,我们需要将其分割成训练集和评估集。在真实的推荐系统中,这很可能按时间完成:使用时间 T 之前的数据来预测 T 之后的交互。
然而,在这个简单的示例中,让我们使用随机分割,将 80% 的评分放入训练集,20% 放入测试集。
shuffled_ratings = ratings.map(preprocess_rating).shuffle(
100_000, seed=42, reshuffle_each_iteration=False
)
train_ratings = shuffled_ratings.take(80_000).batch(1000).cache()
test_ratings = shuffled_ratings.skip(80_000).take(20_000).batch(1000).cache()
选择模型的架构是建模的关键部分。
我们正在构建一个双塔召回模型,因此我们需要结合用于用户的查询塔和用于电影的候选项塔。
第一步是确定查询和候选项表示的维度。这是模型构造函数中的 embedding_dimension
参数。我们将使用值 32
进行测试。值越高,模型可能越准确,但也会更慢拟合且更容易过拟合。
第二步是定义模型本身。在这个简单的例子中,查询塔和候选项塔只是嵌入,没有其他内容。我们将使用 Keras 的 Embedding
层。
我们可以使用标准的 Keras 组件轻松扩展塔,使其任意复杂,只要最终返回一个 embedding_dimension
宽的输出即可。
召回本身将由 Keras Recommenders 的 BruteForceRetrieval
层执行。此层计算给定用户和所有候选项电影的相似度得分,然后按顺序返回排名前 K 的电影。
注意,在训练期间,我们实际上不需要执行任何召回,因为我们只需要批处理中用户和电影的相似度得分。作为优化,我们在 call
方法中完全跳过召回。
下一个组件是用于训练模型的损失。在这种情况下,我们使用均方误差损失来衡量预测的电影评分与用户的实际评分之间的差异。
注意,我们重写了 keras.Model
类中的 compute_loss
方法。这允许我们计算查询-候选项相似度得分,该得分是通过将两个塔的输出相乘获得的。然后可以将该相似度得分传递给损失函数。
class RetrievalModel(keras.Model):
"""Create the retrieval model with the provided parameters.
Args:
num_users: Number of entries in the user embedding table.
num_candidates: Number of entries in the candidate embedding table.
embedding_dimension: Output dimension for user and movie embedding tables.
"""
def __init__(
self,
num_users,
num_candidates,
embedding_dimension=32,
**kwargs,
):
super().__init__(**kwargs)
# Our query tower, simply an embedding table.
self.user_embedding = keras.layers.Embedding(num_users, embedding_dimension)
# Our candidate tower, simply an embedding table.
self.candidate_embedding = keras.layers.Embedding(
num_candidates, embedding_dimension
)
# The layer that performs the retrieval.
self.retrieval = keras_rs.layers.BruteForceRetrieval(k=10, return_scores=False)
self.loss_fn = keras.losses.MeanSquaredError()
def build(self, input_shape):
self.user_embedding.build(input_shape)
self.candidate_embedding.build(input_shape)
# In this case, the candidates are directly the movie embeddings.
# We take a shortcut and directly reuse the variable.
self.retrieval.candidate_embeddings = self.candidate_embedding.embeddings
self.retrieval.build(input_shape)
super().build(input_shape)
def call(self, inputs, training=False):
user_embeddings = self.user_embedding(inputs)
result = {
"user_embeddings": user_embeddings,
}
if not training:
# Skip the retrieval of top movies during training as the
# predictions are not used.
result["predictions"] = self.retrieval(user_embeddings)
return result
def compute_loss(self, x, y, y_pred, sample_weight, training=True):
candidate_id, rating = y["movie_id"], y["rating"]
user_embeddings = y_pred["user_embeddings"]
candidate_embeddings = self.candidate_embedding(candidate_id)
labels = keras.ops.expand_dims(rating, -1)
# Compute the affinity score by multiplying the two embeddings.
scores = keras.ops.sum(
keras.ops.multiply(user_embeddings, candidate_embeddings),
axis=1,
keepdims=True,
)
return self.loss_fn(labels, scores, sample_weight)
定义模型后,我们可以使用标准的 Keras model.fit()
方法来训练和评估模型。
我们首先实例化模型。请注意,我们将用户和电影的数量加 1
,以考虑 ID 零未被使用(ID 从 1 开始),但仍在嵌入表中占据一行。
model = RetrievalModel(users_count + 1, movies_count + 1)
model.compile(optimizer=keras.optimizers.Adagrad(learning_rate=0.1))
然后训练模型。评估需要一些时间,所以我们每 5 个 epoch 只评估一次模型。
history = model.fit(
train_ratings, validation_data=test_ratings, validation_freq=5, epochs=50
)
Epoch 1/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 3s 7ms/step - loss: 0.4772
Epoch 2/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4772
Epoch 3/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4772
Epoch 4/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4771
Epoch 5/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 3s 37ms/step - loss: 0.4771 - val_loss: 0.4836
Epoch 6/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4771
Epoch 7/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4770
Epoch 8/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - loss: 0.4770
Epoch 9/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4770
Epoch 10/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4769 - val_loss: 0.4836
Epoch 11/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4769
Epoch 12/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - loss: 0.4768
Epoch 13/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4768
Epoch 14/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4768
Epoch 15/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4767 - val_loss: 0.4836
Epoch 16/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4767
Epoch 17/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4766
Epoch 18/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4766
Epoch 19/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4765
Epoch 20/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4765 - val_loss: 0.4835
Epoch 21/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4764
Epoch 22/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4763
Epoch 23/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4763
Epoch 24/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4762
Epoch 25/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4761 - val_loss: 0.4833
Epoch 26/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4761
Epoch 27/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4759
Epoch 28/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4758
Epoch 29/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4757
Epoch 30/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4756 - val_loss: 0.4829
Epoch 31/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4755
Epoch 32/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4753
Epoch 33/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4752
Epoch 34/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4750
Epoch 35/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4748 - val_loss: 0.4822
Epoch 36/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4745
Epoch 37/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4742
Epoch 38/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - loss: 0.4740
Epoch 39/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4737
Epoch 40/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4734 - val_loss: 0.4809
Epoch 41/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4730
Epoch 42/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4726
Epoch 43/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4721
Epoch 44/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4716
Epoch 45/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4710 - val_loss: 0.4786
Epoch 46/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4703
Epoch 47/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4696
Epoch 48/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4688
Epoch 49/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4679
Epoch 50/50
80/80 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.4669 - val_loss: 0.4743
现在我们有了模型,我们希望能进行预测。
到目前为止,我们只通过 ID 处理电影。现在是时候创建一个以电影 ID 为键的映射,以便能够显示标题了。
movie_id_to_movie_title = {
int(x["movie_id"]): x["movie_title"] for x in movies.as_numpy_iterator()
}
movie_id_to_movie_title[0] = "" # Because id 0 is not in the dataset.
然后我们简单地使用 Keras 的 model.predict()
方法。在底层,它调用 BruteForceRetrieval
层来执行实际的召回。
请注意,此模型可以检索用户已经看过的电影。如果需要,我们可以轻松添加逻辑来移除它们。
user_id = 42
predictions = model.predict(keras.ops.convert_to_tensor([user_id]))
predictions = keras.ops.convert_to_numpy(predictions["predictions"])
print(f"Recommended movies for user {user_id}:")
for movie_id in predictions[0]:
print(movie_id_to_movie_title[movie_id])
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 82ms/step
Recommended movies for user 42:
b'Star Wars (1977)'
b'Godfather, The (1972)'
b'Back to the Future (1985)'
b'Fargo (1996)'
b'Snow White and the Seven Dwarfs (1937)'
b'Twelve Monkeys (1995)'
b'Pulp Fiction (1994)'
b'Raiders of the Lost Ark (1981)'
b'Dances with Wolves (1990)'
b'Courage Under Fire (1996)'
在此模型中,我们创建了一个用户-电影模型。然而,对于某些应用(例如,产品详情页),通常会进行项目到项目(例如,电影到电影或产品到产品)的推荐。
训练这类模型的模式将与本教程中所示的模式相同,但使用不同的训练数据。在这里,我们有一个用户塔和一个电影塔,并使用(用户,电影)对来训练它们。在项目到项目模型中,我们将有两个项目塔(用于查询项目和候选项项目),并使用(查询项目,候选项项目)对来训练模型。这些可以从产品详情页的点击行为构建。