KerasRS / 示例 / 推荐电影:检索

推荐电影:检索

作者: Fabien Hertschuh, Abheesht Sharma
创建日期 2025/04/28
最后修改日期 2025/04/28
描述: 使用双塔模型检索电影。

在 Colab 中查看 GitHub 源代码


简介

推荐系统通常由两个阶段组成。

  1. 检索阶段负责从所有可能的候选者中选择一个初步的数百个候选者。此模型的主要目标是高效地剔除用户不感兴趣的所有候选者。由于检索模型可能需要处理数百万个候选者,因此它必须在计算上高效。
  2. 排序阶段接收检索模型的输出,并对其进行微调,以选择最佳的一小部分推荐。它的任务是将用户可能感兴趣的物品集缩小到可能候选者的短名单。

在本教程中,我们将重点关注第一阶段——检索。如果您对排序阶段感兴趣,请参阅我们的排序教程。

检索模型通常由两个子模型组成。

  1. 一个查询塔,使用查询特征计算查询表示(通常是固定维度的嵌入向量)。
  2. 一个候选塔,使用候选者特征计算候选者表示(一个等大小的向量)。然后将两个模型的输出相乘,得到查询-候选亲和度分数,分数越高表示候选者与查询的匹配程度越好。

在本教程中,我们将使用 Movielens 数据集构建和训练这样一个双塔模型。

我们将:

  1. 获取数据并将其分割为训练集和测试集。
  2. 实现一个检索模型。
  3. 拟合和评估它。
  4. 测试使用模型运行预测。

数据集

Movielens 数据集是来自明尼苏达大学 GroupLens 研究小组的经典数据集。它包含用户对电影的评分集,是推荐系统研究的标准。

数据可以从两个方面处理:

  1. 可以将其解释为表达用户观看了(并评价了)哪些电影,哪些没有。这是一种隐式反馈形式,其中用户的观看行为告诉我们他们更喜欢看哪些内容,更不愿意看哪些内容。
  2. 也可以将其解释为表达用户对他们观看过的电影的喜爱程度。这是一种显式反馈形式:给定用户观看了一部电影,我们可以通过查看他们给出的评分来了解他们有多喜欢它。

在本教程中,我们专注于检索系统:一个预测用户可能观看的目录中电影集合的模型。为此,模型将尝试预测用户将给目录中所有电影的评分。因此,我们将使用显式评分数据。

让我们首先选择 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_idmovie_idrating 字段。我们的输入是 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 层。

只要最后返回一个 embedding_dimension 宽度的输出,我们就可以轻松地使用标准的 Keras 组件来扩展塔,使其变得任意复杂。

检索

检索本身将由 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)'

物品到物品推荐

在此模型中,我们创建了一个用户-电影模型。但是,对于某些应用程序(例如,产品详情页面),进行物品到物品(例如,电影到电影或产品到产品)推荐是很常见的。

训练此类模型将遵循本教程所示的相同模式,但使用不同的训练数据。在这里,我们有一个用户塔和一个电影塔,并使用 (用户, 电影) 对来训练它们。在物品到物品模型中,我们将有两个物品塔(用于查询物品和候选物品),并使用 (查询物品, 候选物品) 对来训练模型。这些可以从产品详情页面的点击中构建。