推荐电影:检索

在 TensorFlow.org 上查看 在 Google Colab 中运行 在 GitHub 上查看源代码 下载笔记本

现实世界中的推荐系统通常由两个阶段组成

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

在本教程中,我们将重点介绍第一个阶段,即检索。如果您对排名阶段感兴趣,请查看我们的 排名 教程。

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

  1. 查询模型使用查询特征计算查询表示(通常是固定维度的嵌入向量)。
  2. 候选模型使用候选特征计算候选表示(大小相同的向量)

然后将两个模型的输出相乘,得到查询-候选亲和度得分,得分越高表示候选与查询的匹配度越好。

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

我们将

  1. 获取我们的数据并将其拆分为训练集和测试集。
  2. 实现检索模型。
  3. 拟合和评估它。
  4. 通过构建近似最近邻 (ANN) 索引将其导出以进行高效服务。

数据集

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

数据可以以两种方式处理

  1. 它可以被解释为表达用户观看(并评分)了哪些电影,以及哪些电影没有观看。这是一种隐式反馈形式,用户观看告诉我们他们更喜欢看哪些内容,以及哪些内容他们不想看。
  2. 它也可以被视为表达用户对他们观看的电影的喜爱程度。这是一种显式反馈形式:假设用户观看了一部电影,我们可以通过查看他们给出的评分来大致了解他们对这部电影的喜爱程度。

在本教程中,我们将重点介绍检索系统:一个预测用户可能观看的目录中的一组电影的模型。通常,隐式数据在这里更有用,因此我们将 Movielens 视为一个隐式系统。这意味着用户观看的每部电影都是一个正例,而他们没有观看的每部电影都是一个隐式负例。

导入

首先,让我们导入必要的库。

pip install -q tensorflow-recommenders
pip install -q --upgrade tensorflow-datasets
pip install -q scann
import os
import pprint
import tempfile

from typing import Dict, Text

import numpy as np
import tensorflow as tf
import tensorflow_datasets as tfds
2022-12-14 12:14:44.722984: W tensorflow/compiler/xla/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libnvinfer.so.7'; dlerror: libnvinfer.so.7: cannot open shared object file: No such file or directory
2022-12-14 12:14:44.723084: W tensorflow/compiler/xla/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libnvinfer_plugin.so.7'; dlerror: libnvinfer_plugin.so.7: cannot open shared object file: No such file or directory
2022-12-14 12:14:44.723093: W tensorflow/compiler/tf2tensorrt/utils/py_utils.cc:38] TF-TRT Warning: Cannot dlopen some TensorRT libraries. If you would like to use Nvidia GPU with TensorRT, please make sure the missing libraries mentioned above are installed properly.
import tensorflow_recommenders as tfrs

准备数据集

首先,让我们看一下数据。

我们使用来自 Tensorflow Datasets 的 MovieLens 数据集。加载 movielens/100k_ratings 将生成一个包含评分数据的 tf.data.Dataset 对象,加载 movielens/100k_movies 将生成一个仅包含电影数据的 tf.data.Dataset 对象。

请注意,由于 MovieLens 数据集没有预定义的拆分,因此所有数据都在 train 拆分下。

# Ratings 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 x in ratings.take(1).as_numpy_iterator():
  pprint.pprint(x)
{'bucketized_user_age': 45.0,
 'movie_genres': array([7]),
 'movie_id': b'357',
 'movie_title': b"One Flew Over the Cuckoo's Nest (1975)",
 'raw_user_age': 46.0,
 'timestamp': 879024327,
 'user_gender': True,
 'user_id': b'138',
 'user_occupation_label': 4,
 'user_occupation_text': b'doctor',
 'user_rating': 4.0,
 'user_zip_code': b'53211'}
2022-12-14 12:14:51.221818: W tensorflow/core/kernels/data/cache_dataset_ops.cc:856] The calling iterator did not fully read the dataset being cached. In order to avoid unexpected truncation of the dataset, the partially cached contents of the dataset  will be discarded. This can happen if you have an input pipeline similar to `dataset.cache().take(k).repeat()`. You should use `dataset.take(k).cache().repeat()` instead.

电影数据集包含电影 ID、电影标题以及它所属的类型的相关数据。请注意,类型使用整数标签进行编码。

for x in movies.take(1).as_numpy_iterator():
  pprint.pprint(x)
{'movie_genres': array([4]),
 'movie_id': b'1681',
 'movie_title': b'You So Crazy (1994)'}
2022-12-14 12:14:51.385630: W tensorflow/core/kernels/data/cache_dataset_ops.cc:856] The calling iterator did not fully read the dataset being cached. In order to avoid unexpected truncation of the dataset, the partially cached contents of the dataset  will be discarded. This can happen if you have an input pipeline similar to `dataset.cache().take(k).repeat()`. You should use `dataset.take(k).cache().repeat()` instead.

在本例中,我们将重点关注评分数据。其他教程将探讨如何使用电影信息数据来提高模型质量。

我们只保留数据集中的 user_idmovie_title 字段。

ratings = ratings.map(lambda x: {
    "movie_title": x["movie_title"],
    "user_id": x["user_id"],
})
movies = movies.map(lambda x: x["movie_title"])
WARNING:tensorflow:From /tmpfs/src/tf_docs_env/lib/python3.9/site-packages/tensorflow/python/autograph/pyct/static_analysis/liveness.py:83: Analyzer.lamba_check (from tensorflow.python.autograph.pyct.static_analysis.liveness) is deprecated and will be removed after 2023-09-23.
Instructions for updating:
Lambda fuctions will be no more assumed to be used in the statement where they are used, or at least in the same block. https://github.com/tensorflow/tensorflow/issues/56089
WARNING:tensorflow:From /tmpfs/src/tf_docs_env/lib/python3.9/site-packages/tensorflow/python/autograph/pyct/static_analysis/liveness.py:83: Analyzer.lamba_check (from tensorflow.python.autograph.pyct.static_analysis.liveness) is deprecated and will be removed after 2023-09-23.
Instructions for updating:
Lambda fuctions will be no more assumed to be used in the statement where they are used, or at least in the same block. https://github.com/tensorflow/tensorflow/issues/56089

为了拟合和评估模型,我们需要将其拆分为训练集和评估集。在工业推荐系统中,这很可能通过时间来完成:截至时间 \(T\) 的数据将用于预测 \(T\) 之后发生的交互。

但是,在这个简单的示例中,让我们使用随机拆分,将 80% 的评分放入训练集中,将 20% 的评分放入测试集中。

tf.random.set_seed(42)
shuffled = ratings.shuffle(100_000, seed=42, reshuffle_each_iteration=False)

train = shuffled.take(80_000)
test = shuffled.skip(80_000).take(20_000)

让我们找出数据中存在的唯一用户 ID 和电影标题。

这很重要,因为我们需要能够将分类特征的原始值映射到模型中的嵌入向量。为此,我们需要一个词汇表,将原始特征值映射到连续范围内的整数:这使我们能够在嵌入表中查找相应的嵌入。

movie_titles = movies.batch(1_000)
user_ids = ratings.batch(1_000_000).map(lambda x: x["user_id"])

unique_movie_titles = np.unique(np.concatenate(list(movie_titles)))
unique_user_ids = np.unique(np.concatenate(list(user_ids)))

unique_movie_titles[:10]
array([b"'Til There Was You (1997)", b'1-900 (1994)',
       b'101 Dalmatians (1996)', b'12 Angry Men (1957)', b'187 (1997)',
       b'2 Days in the Valley (1996)',
       b'20,000 Leagues Under the Sea (1954)',
       b'2001: A Space Odyssey (1968)',
       b'3 Ninjas: High Noon At Mega Mountain (1998)',
       b'39 Steps, The (1935)'], dtype=object)

实现模型

选择模型的架构是建模的关键部分。

因为我们正在构建一个双塔检索模型,所以我们可以分别构建每个塔,然后在最终模型中将它们组合起来。

查询塔

让我们从查询塔开始。

第一步是确定查询和候选表示的维度。

embedding_dimension = 32

较高的值将对应于可能更准确的模型,但拟合速度也会更慢,更容易过拟合。

第二步是定义模型本身。在这里,我们将使用 Keras 预处理层首先将用户 ID 转换为整数,然后通过 Embedding 层将这些整数转换为用户嵌入。请注意,我们使用之前计算的唯一用户 ID 列表作为词汇表。

user_model = tf.keras.Sequential([
  tf.keras.layers.StringLookup(
      vocabulary=unique_user_ids, mask_token=None),
  # We add an additional embedding to account for unknown tokens.
  tf.keras.layers.Embedding(len(unique_user_ids) + 1, embedding_dimension)
])

像这样的简单模型与经典的 矩阵分解 方法完全一致。虽然为这个简单模型定义 tf.keras.Model 的子类可能有点过分,但我们可以使用标准 Keras 组件轻松地将其扩展到任意复杂的模型,只要我们在最后返回一个 embedding_dimension 宽度输出。

候选塔

我们可以对候选塔做同样的事情。

movie_model = tf.keras.Sequential([
  tf.keras.layers.StringLookup(
      vocabulary=unique_movie_titles, mask_token=None),
  tf.keras.layers.Embedding(len(unique_movie_titles) + 1, embedding_dimension)
])

指标

在我们的训练数据中,我们有正(用户,电影)对。为了确定模型的优劣,我们需要将模型为这对计算的亲和力得分与所有其他可能候选者的得分进行比较:如果正对的得分高于所有其他候选者的得分,则模型的准确性很高。

为此,我们可以使用 tfrs.metrics.FactorizedTopK 指标。该指标有一个必需的参数:用于评估的候选数据集,这些候选数据集用作隐式负样本。

在我们的例子中,那就是 movies 数据集,通过我们的电影模型转换为嵌入。

metrics = tfrs.metrics.FactorizedTopK(
  candidates=movies.batch(128).map(movie_model)
)

损失

下一个组件是用于训练模型的损失。TFRS 有几个损失层和任务来简化这一过程。

在本例中,我们将使用 Retrieval 任务对象:一个方便的包装器,将损失函数和指标计算捆绑在一起。

task = tfrs.tasks.Retrieval(
  metrics=metrics
)

任务本身是一个 Keras 层,它以查询和候选嵌入作为参数,并返回计算的损失:我们将使用它来实现模型的训练循环。

完整模型

现在我们可以将所有内容组合到一个模型中。TFRS 公开了一个基本模型类 (tfrs.models.Model),它简化了模型的构建:我们只需要在 __init__ 方法中设置组件,并实现 compute_loss 方法,传入原始特征并返回损失值。

然后,基本模型将负责创建适当的训练循环来拟合我们的模型。

class MovielensModel(tfrs.Model):

  def __init__(self, user_model, movie_model):
    super().__init__()
    self.movie_model: tf.keras.Model = movie_model
    self.user_model: tf.keras.Model = user_model
    self.task: tf.keras.layers.Layer = task

  def compute_loss(self, features: Dict[Text, tf.Tensor], training=False) -> tf.Tensor:
    # We pick out the user features and pass them into the user model.
    user_embeddings = self.user_model(features["user_id"])
    # And pick out the movie features and pass them into the movie model,
    # getting embeddings back.
    positive_movie_embeddings = self.movie_model(features["movie_title"])

    # The task computes the loss and the metrics.
    return self.task(user_embeddings, positive_movie_embeddings)

tfrs.Model 基本类只是一个简单的便利类:它允许我们使用相同的方法计算训练和测试损失。

在幕后,它仍然是一个普通的 Keras 模型。您可以通过继承 tf.keras.Model 并覆盖 train_steptest_step 函数来实现相同的功能(有关详细信息,请参阅 指南)。

class NoBaseClassMovielensModel(tf.keras.Model):

  def __init__(self, user_model, movie_model):
    super().__init__()
    self.movie_model: tf.keras.Model = movie_model
    self.user_model: tf.keras.Model = user_model
    self.task: tf.keras.layers.Layer = task

  def train_step(self, features: Dict[Text, tf.Tensor]) -> tf.Tensor:

    # Set up a gradient tape to record gradients.
    with tf.GradientTape() as tape:

      # Loss computation.
      user_embeddings = self.user_model(features["user_id"])
      positive_movie_embeddings = self.movie_model(features["movie_title"])
      loss = self.task(user_embeddings, positive_movie_embeddings)

      # Handle regularization losses as well.
      regularization_loss = sum(self.losses)

      total_loss = loss + regularization_loss

    gradients = tape.gradient(total_loss, self.trainable_variables)
    self.optimizer.apply_gradients(zip(gradients, self.trainable_variables))

    metrics = {metric.name: metric.result() for metric in self.metrics}
    metrics["loss"] = loss
    metrics["regularization_loss"] = regularization_loss
    metrics["total_loss"] = total_loss

    return metrics

  def test_step(self, features: Dict[Text, tf.Tensor]) -> tf.Tensor:

    # Loss computation.
    user_embeddings = self.user_model(features["user_id"])
    positive_movie_embeddings = self.movie_model(features["movie_title"])
    loss = self.task(user_embeddings, positive_movie_embeddings)

    # Handle regularization losses as well.
    regularization_loss = sum(self.losses)

    total_loss = loss + regularization_loss

    metrics = {metric.name: metric.result() for metric in self.metrics}
    metrics["loss"] = loss
    metrics["regularization_loss"] = regularization_loss
    metrics["total_loss"] = total_loss

    return metrics

但是,在本教程中,我们将坚持使用 tfrs.Model 基本类,以将我们的重点放在建模上,并抽象出一些样板代码。

拟合和评估

定义模型后,我们可以使用标准 Keras 拟合和评估例程来拟合和评估模型。

让我们首先实例化模型。

model = MovielensModel(user_model, movie_model)
model.compile(optimizer=tf.keras.optimizers.Adagrad(learning_rate=0.1))

然后对训练和评估数据进行混洗、批处理和缓存。

cached_train = train.shuffle(100_000).batch(8192).cache()
cached_test = test.batch(4096).cache()

然后训练模型。

model.fit(cached_train, epochs=3)
Epoch 1/3
10/10 [==============================] - 6s 309ms/step - factorized_top_k/top_1_categorical_accuracy: 7.2500e-04 - factorized_top_k/top_5_categorical_accuracy: 0.0063 - factorized_top_k/top_10_categorical_accuracy: 0.0140 - factorized_top_k/top_50_categorical_accuracy: 0.0753 - factorized_top_k/top_100_categorical_accuracy: 0.1471 - loss: 69820.5881 - regularization_loss: 0.0000e+00 - total_loss: 69820.5881
Epoch 2/3
10/10 [==============================] - 3s 302ms/step - factorized_top_k/top_1_categorical_accuracy: 0.0011 - factorized_top_k/top_5_categorical_accuracy: 0.0119 - factorized_top_k/top_10_categorical_accuracy: 0.0260 - factorized_top_k/top_50_categorical_accuracy: 0.1403 - factorized_top_k/top_100_categorical_accuracy: 0.2616 - loss: 67457.6612 - regularization_loss: 0.0000e+00 - total_loss: 67457.6612
Epoch 3/3
10/10 [==============================] - 3s 301ms/step - factorized_top_k/top_1_categorical_accuracy: 0.0014 - factorized_top_k/top_5_categorical_accuracy: 0.0189 - factorized_top_k/top_10_categorical_accuracy: 0.0400 - factorized_top_k/top_50_categorical_accuracy: 0.1782 - factorized_top_k/top_100_categorical_accuracy: 0.3056 - loss: 66284.5682 - regularization_loss: 0.0000e+00 - total_loss: 66284.5682
<keras.callbacks.History at 0x7f6039c48160>

如果您想使用 TensorBoard 监控训练过程,可以将 TensorBoard 回调添加到 fit() 函数中,然后使用 %tensorboard --logdir logs/fit 启动 TensorBoard。有关更多详细信息,请参阅 TensorBoard 文档

随着模型的训练,损失正在下降,并且一组 top-k 检索指标正在更新。这些指标告诉我们,真阳性是否位于从整个候选集中检索到的 top-k 项目中。例如,top-5 类别准确率指标为 0.2 将告诉我们,平均而言,真阳性有 20% 的时间位于 top 5 检索到的项目中。

请注意,在本例中,我们在训练和评估期间都评估了指标。由于这在候选集很大的情况下可能非常慢,因此明智的做法是在训练中关闭指标计算,只在评估中运行它。

最后,我们可以使用测试集评估我们的模型。

model.evaluate(cached_test, return_dict=True)
5/5 [==============================] - 3s 191ms/step - factorized_top_k/top_1_categorical_accuracy: 0.0010 - factorized_top_k/top_5_categorical_accuracy: 0.0087 - factorized_top_k/top_10_categorical_accuracy: 0.0212 - factorized_top_k/top_50_categorical_accuracy: 0.1218 - factorized_top_k/top_100_categorical_accuracy: 0.2334 - loss: 31086.5010 - regularization_loss: 0.0000e+00 - total_loss: 31086.5010
{'factorized_top_k/top_1_categorical_accuracy': 0.0010000000474974513,
 'factorized_top_k/top_5_categorical_accuracy': 0.008700000122189522,
 'factorized_top_k/top_10_categorical_accuracy': 0.021150000393390656,
 'factorized_top_k/top_50_categorical_accuracy': 0.121799997985363,
 'factorized_top_k/top_100_categorical_accuracy': 0.23340000212192535,
 'loss': 28256.8984375,
 'regularization_loss': 0,
 'total_loss': 28256.8984375}

测试集的性能远不如训练集的性能。这是由于两个因素造成的。

  1. 我们的模型很可能在它已经看到的数据上表现更好,仅仅因为它可以记住它。当模型具有许多参数时,这种过拟合现象尤其强烈。可以通过模型正则化和使用有助于模型更好地泛化到未见数据的用户和电影特征来缓解它。
  2. 该模型正在重新推荐一些用户已经观看过的电影。这些已知的正观看可能会将测试电影挤出 top K 推荐之外。

可以通过从测试推荐中排除以前观看过的电影来解决第二个现象。这种方法在推荐系统文献中比较常见,但我们不在这些教程中使用它。如果不再推荐过去的观看很重要,我们应该期望适当指定的模型能够从过去的用户信息和上下文信息中自动学习这种行为。此外,多次推荐同一项通常是合适的(例如,常青电视剧或定期购买的商品)。

进行预测

现在我们有了模型,我们希望能够进行预测。我们可以使用 tfrs.layers.factorized_top_k.BruteForce 层来做到这一点。

# Create a model that takes in raw query features, and
index = tfrs.layers.factorized_top_k.BruteForce(model.user_model)
# recommends movies out of the entire movies dataset.
index.index_from_dataset(
  tf.data.Dataset.zip((movies.batch(100), movies.batch(100).map(model.movie_model)))
)

# Get recommendations.
_, titles = index(tf.constant(["42"]))
print(f"Recommendations for user 42: {titles[0, :3]}")
Recommendations for user 42: [b'Christmas Carol, A (1938)' b'Rudy (1993)'
 b'Bridges of Madison County, The (1995)']

当然, BruteForce 层对于服务具有许多可能候选者的模型来说太慢了。以下部分将展示如何通过使用近似检索索引来加快速度。

模型服务

模型训练完成后,我们需要一种部署它的方法。

在双塔检索模型中,服务有两个组件。

  • 一个服务查询模型,接收查询的特征并将它们转换为查询嵌入,以及
  • 一个服务候选模型。这通常采用近似最近邻 (ANN) 索引的形式,该索引允许快速近似查找响应查询模型生成的查询的候选者。

在 TFRS 中,这两个组件都可以打包到一个可导出模型中,从而为我们提供一个模型,该模型接收原始用户 ID 并返回该用户的 top 电影标题。这是通过将模型导出到 SavedModel 格式来完成的,这使得可以使用 TensorFlow Serving 进行服务。

要部署这样的模型,我们只需导出上面创建的 BruteForce 层。

# Export the query model.
with tempfile.TemporaryDirectory() as tmp:
  path = os.path.join(tmp, "model")

  # Save the index.
  tf.saved_model.save(index, path)

  # Load it back; can also be done in TensorFlow Serving.
  loaded = tf.saved_model.load(path)

  # Pass a user id in, get top predicted movie titles back.
  scores, titles = loaded(["42"])

  print(f"Recommendations: {titles[0][:3]}")
WARNING:absl:Found untraced functions such as query_with_exclusions while saving (showing 1 of 1). These functions will not be directly callable after loading.
INFO:tensorflow:Assets written to: /tmpfs/tmp/tmptfkkd57q/model/assets
INFO:tensorflow:Assets written to: /tmpfs/tmp/tmptfkkd57q/model/assets
Recommendations: [b'Christmas Carol, A (1938)' b'Rudy (1993)'
 b'Bridges of Madison County, The (1995)']

我们还可以导出近似检索索引来加快预测速度。这将使我们能够有效地从数千万个候选集中提供推荐。

为此,我们可以使用 scann 包。这是 TFRS 的可选依赖项,我们通过在本教程开头调用 !pip install -q scann 来单独安装它。

安装完成后,我们可以使用 TFRS ScaNN 层。

scann_index = tfrs.layers.factorized_top_k.ScaNN(model.user_model)
scann_index.index_from_dataset(
  tf.data.Dataset.zip((movies.batch(100), movies.batch(100).map(model.movie_model)))
)
<tensorflow_recommenders.layers.factorized_top_k.ScaNN at 0x7f5fa01ff130>

该层将执行 *近似* 查找:这使得检索的准确性略有下降,但在大型候选集上速度快几个数量级。

# Get recommendations.
_, titles = scann_index(tf.constant(["42"]))
print(f"Recommendations for user 42: {titles[0, :3]}")
Recommendations for user 42: [b'Little Big League (1994)' b'Miracle on 34th Street (1994)'
 b'Cinderella (1950)']

导出它以供服务与导出 BruteForce 层一样容易。

# Export the query model.
with tempfile.TemporaryDirectory() as tmp:
  path = os.path.join(tmp, "model")

  # Save the index.
  tf.saved_model.save(
      scann_index,
      path,
      options=tf.saved_model.SaveOptions(namespace_whitelist=["Scann"])
  )

  # Load it back; can also be done in TensorFlow Serving.
  loaded = tf.saved_model.load(path)

  # Pass a user id in, get top predicted movie titles back.
  scores, titles = loaded(["42"])

  print(f"Recommendations: {titles[0][:3]}")
WARNING:absl:Found untraced functions such as query_with_exclusions while saving (showing 1 of 1). These functions will not be directly callable after loading.
INFO:tensorflow:Assets written to: /tmpfs/tmp/tmpxpt22mi0/model/assets
INFO:tensorflow:Assets written to: /tmpfs/tmp/tmpxpt22mi0/model/assets
Recommendations: [b'Little Big League (1994)' b'Miracle on 34th Street (1994)'
 b'Cinderella (1950)']

要详细了解如何使用和调整快速近似检索模型,请查看我们的 高效服务 教程。

项到项推荐

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

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

后续步骤

这结束了检索教程。

要扩展此处介绍的内容,请查看

  1. 学习多任务模型:共同优化评分和点击。
  2. 使用电影元数据:构建更复杂的电影模型来缓解冷启动。