使用侧边特征:特征预处理

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

使用深度学习框架构建推荐模型的一大优势是能够构建丰富、灵活的特征表示。

第一步是准备特征,因为原始特征通常不能直接在模型中使用。

例如

  • 用户和项目 ID 可能是字符串(标题、用户名)或大型非连续整数(数据库 ID)。
  • 项目描述可能是原始文本。
  • 交互时间戳可能是原始 Unix 时间戳。

这些需要进行适当的转换才能在构建模型时发挥作用

  • 用户和项目 ID 必须转换为嵌入向量:高维数值表示,在训练期间进行调整以帮助模型更好地预测其目标。
  • 原始文本需要进行标记化(拆分为更小的部分,例如单个单词)并转换为嵌入。
  • 数值特征需要进行归一化,使其值位于以 0 为中心的较小范围内。

幸运的是,通过使用 TensorFlow,我们可以将这种预处理作为模型的一部分,而不是单独的预处理步骤。这不仅方便,而且可以确保我们的预处理在训练和服务期间完全相同。这使得部署包含非常复杂的预处理的模型变得安全且容易。

在本教程中,我们将重点介绍推荐器以及我们需要对 MovieLens 数据集 进行的预处理。如果您有兴趣了解没有推荐系统重点的更大型教程,请查看完整的 Keras 预处理指南

MovieLens 数据集

让我们首先看看我们可以从 MovieLens 数据集中使用哪些特征

pip install -q --upgrade tensorflow-datasets
import pprint

import tensorflow_datasets as tfds

ratings = tfds.load("movielens/100k-ratings", split="train")

for x in ratings.take(1).as_numpy_iterator():
  pprint.pprint(x)
2022-12-14 12:28:42.209347: 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:28:42.209434: 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:28:42.209443: 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.
{'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:28:48.039834: 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 可用作用户标识符。
  • 时间戳将使我们能够模拟时间的影响。

前两个是分类特征;时间戳是连续特征。

将分类特征转换为嵌入

一个 分类特征 是一个不表达连续数量的特征,而是从一组固定值中取值。

大多数深度学习模型通过将这些特征转换为高维向量来表达它们。在模型训练期间,该向量的值会进行调整以帮助模型更好地预测其目标。

例如,假设我们的目标是预测哪个用户将观看哪部电影。为此,我们用嵌入向量表示每个用户和每部电影。最初,这些嵌入将取随机值 - 但在训练期间,我们将调整它们,以便用户的嵌入和他们观看的电影的嵌入最终更靠近。

将原始分类特征转换为嵌入通常是一个两步过程

  1. 首先,我们需要将原始值转换为一系列连续整数,通常是通过构建一个映射(称为“词汇表”)来将原始值(“星球大战”)映射到整数(例如,15)。
  2. 其次,我们需要将这些整数转换为嵌入。

定义词汇表

第一步是定义一个词汇表。我们可以使用 Keras 预处理层轻松地做到这一点。

import numpy as np
import tensorflow as tf

movie_title_lookup = tf.keras.layers.StringLookup()

该层本身还没有词汇表,但我们可以使用我们的数据来构建它。

movie_title_lookup.adapt(ratings.map(lambda x: x["movie_title"]))

print(f"Vocabulary: {movie_title_lookup.get_vocabulary()[:3]}")
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
Vocabulary: ['[UNK]', 'Star Wars (1977)', 'Contact (1997)']

一旦我们有了它,我们就可以使用该层将原始标记转换为嵌入 ID

movie_title_lookup(["Star Wars (1977)", "One Flew Over the Cuckoo's Nest (1975)"])
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([ 1, 58])>

请注意,该层的词汇表包含一个(或多个!)未知(或“超出词汇表”,OOV)标记。这非常方便:这意味着该层可以处理词汇表中不存在的分类值。在实际应用中,这意味着即使使用在词汇表构建期间未见过的特征,模型也可以继续学习并进行推荐。

使用特征哈希

事实上,StringLookup 层允许我们配置多个 OOV 索引。如果我们这样做,任何不在词汇表中的原始值都将被确定性地哈希到一个 OOV 索引。我们拥有的此类索引越多,两个不同的原始特征值哈希到同一个 OOV 索引的可能性就越小。因此,如果我们有足够的此类索引,模型应该能够像具有显式词汇表的模型一样训练,而不会有必须维护标记列表的缺点。

我们可以将其推向逻辑极限,完全依赖特征哈希,而根本不需要词汇表。这在 tf.keras.layers.Hashing 层中实现。

# We set up a large number of bins to reduce the chance of hash collisions.
num_hashing_bins = 200_000

movie_title_hashing = tf.keras.layers.Hashing(
    num_bins=num_hashing_bins
)

我们可以像以前一样进行查找,而无需构建词汇表

movie_title_hashing(["Star Wars (1977)", "One Flew Over the Cuckoo's Nest (1975)"])
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([101016,  96565])>

定义嵌入

现在我们有了整数 ID,我们可以使用 Embedding 层将它们转换为嵌入。

嵌入层有两个维度:第一个维度告诉我们我们可以嵌入多少个不同的类别;第二个维度告诉我们表示每个类别的向量可以有多大。

在为电影标题创建嵌入层时,我们将第一个值设置为标题词汇表的大小(或哈希 bin 的数量)。第二个取决于我们:它越大,模型的容量就越高,但拟合和服务的效率就越低。

movie_title_embedding = tf.keras.layers.Embedding(
    # Let's use the explicit vocabulary lookup.
    input_dim=movie_title_lookup.vocab_size(),
    output_dim=32
)
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.

我们可以将两者组合成一个单层,该层接收原始文本并生成嵌入。

movie_title_model = tf.keras.Sequential([movie_title_lookup, movie_title_embedding])

就这样,我们可以直接获取电影标题的嵌入。

movie_title_model(["Star Wars (1977)"])
WARNING:tensorflow:Layers in a Sequential model should only have a single input tensor. Received: inputs=['Star Wars (1977)']. Consider rewriting this model with the Functional API.
WARNING:tensorflow:Layers in a Sequential model should only have a single input tensor. Received: inputs=['Star Wars (1977)']. Consider rewriting this model with the Functional API.
<tf.Tensor: shape=(1, 32), dtype=float32, numpy=
array([[ 0.03062222,  0.04599922,  0.02472514, -0.00171293, -0.03286115,
         0.01045175, -0.0012536 , -0.00229961,  0.00553482, -0.03525121,
         0.01329443,  0.01554203,  0.02376631, -0.01887287,  0.00513816,
        -0.04662405, -0.04039361,  0.03888489, -0.02348953,  0.03543044,
         0.04810036, -0.00186436, -0.01540039,  0.00501189,  0.04872096,
         0.02183789, -0.03257982, -0.04470251,  0.02888315, -0.04022648,
         0.0046916 , -0.04307072]], dtype=float32)>

我们可以对用户嵌入做同样的事情。

user_id_lookup = tf.keras.layers.StringLookup()
user_id_lookup.adapt(ratings.map(lambda x: x["user_id"]))

user_id_embedding = tf.keras.layers.Embedding(user_id_lookup.vocab_size(), 32)

user_id_model = tf.keras.Sequential([user_id_lookup, user_id_embedding])
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.

规范化连续特征

连续特征也需要规范化。例如,timestamp 特征太大,无法直接在深度模型中使用。

for x in ratings.take(3).as_numpy_iterator():
  print(f"Timestamp: {x['timestamp']}.")
Timestamp: 879024327.
Timestamp: 875654590.
Timestamp: 882075110.

我们需要在使用它之前对其进行处理。虽然我们可以用很多方法来做到这一点,但离散化和标准化是两种常见的方法。

标准化

标准化 通过减去特征的平均值并除以其标准差来重新缩放特征以规范化其范围。它是一种常见的预处理转换。

这可以使用 tf.keras.layers.Normalization 层轻松完成。

timestamp_normalization = tf.keras.layers.Normalization(
    axis=None
)
timestamp_normalization.adapt(ratings.map(lambda x: x["timestamp"]).batch(1024))

for x in ratings.take(3).as_numpy_iterator():
  print(f"Normalized timestamp: {timestamp_normalization(x['timestamp'])}.")
Normalized timestamp: [-0.8429372].
Normalized timestamp: [-1.4735202].
Normalized timestamp: [-0.27203265].

离散化

另一种常见的转换是将连续特征转换为多个分类特征。如果我们有理由怀疑特征的影响是非连续的,那么这样做很有意义。

为此,我们首先需要确定用于离散化的桶的边界。最简单的方法是确定特征的最小值和最大值,并将得到的间隔平均划分。

max_timestamp = ratings.map(lambda x: x["timestamp"]).reduce(
    tf.cast(0, tf.int64), tf.maximum).numpy().max()
min_timestamp = ratings.map(lambda x: x["timestamp"]).reduce(
    np.int64(1e9), tf.minimum).numpy().min()

timestamp_buckets = np.linspace(
    min_timestamp, max_timestamp, num=1000)

print(f"Buckets: {timestamp_buckets[:3]}")
Buckets: [8.74724710e+08 8.74743291e+08 8.74761871e+08]

有了桶边界,我们就可以将时间戳转换为嵌入。

timestamp_embedding_model = tf.keras.Sequential([
  tf.keras.layers.Discretization(timestamp_buckets.tolist()),
  tf.keras.layers.Embedding(len(timestamp_buckets) + 1, 32)
])

for timestamp in ratings.take(1).map(lambda x: x["timestamp"]).batch(1).as_numpy_iterator():
  print(f"Timestamp embedding: {timestamp_embedding_model(timestamp)}.")
Timestamp embedding: [[-0.00684877 -0.00895538  0.00957515 -0.01513529  0.0387269   0.00683295
   0.02544342 -0.02451316 -0.04256866  0.01276667 -0.04428785  0.02050248
  -0.01246307  0.0345451   0.02073885 -0.01192726 -0.01197611  0.0368802
   0.01981271  0.04876235 -0.00646602 -0.01923322 -0.00054507  0.03711143
   0.04613707  0.0188375   0.04404927 -0.00687717  0.01918397  0.03958556
  -0.01230479 -0.02550827]].

处理文本特征

我们可能还想将文本特征添加到我们的模型中。通常,产品描述等内容是自由格式的文本,我们可以希望我们的模型能够学习使用其中包含的信息来做出更好的推荐,尤其是在冷启动或长尾场景中。

虽然 MovieLens 数据集没有提供丰富的文本特征,但我们仍然可以使用电影标题。这可能有助于我们捕捉到具有非常相似标题的电影可能属于同一系列的事实。

我们需要对文本应用的第一个转换是标记化(拆分为组成词或词片),然后是词汇学习,然后是嵌入。

Keras tf.keras.layers.TextVectorization 层可以为我们完成前两个步骤。

title_text = tf.keras.layers.TextVectorization()
title_text.adapt(ratings.map(lambda x: x["movie_title"]))

让我们试试看。

for row in ratings.batch(1).map(lambda x: x["movie_title"]).take(1):
  print(title_text(row))
tf.Tensor([[ 32 266 162   2 267 265  53]], shape=(1, 7), dtype=int64)

每个标题都被转换为一个标记序列,每个标记对应于我们标记化的一个片段。

我们可以检查学习到的词汇表以验证该层是否使用正确的标记化。

title_text.get_vocabulary()[40:45]
['first', '1998', '1977', '1971', 'monty']

这看起来是正确的:该层将标题标记化为单个单词。

为了完成处理,我们现在需要嵌入文本。因为每个标题包含多个单词,所以我们将为每个标题获得多个嵌入。为了在后续模型中使用,这些嵌入通常被压缩成一个单一的嵌入。RNN 或 Transformer 等模型在这里很有用,但将所有单词的嵌入平均在一起是一个很好的起点。

将所有内容整合在一起

有了这些组件,我们就可以构建一个模型,将所有预处理步骤整合在一起。

用户模型

完整的用户模型可能如下所示。

class UserModel(tf.keras.Model):

  def __init__(self):
    super().__init__()

    self.user_embedding = tf.keras.Sequential([
        user_id_lookup,
        tf.keras.layers.Embedding(user_id_lookup.vocab_size(), 32),
    ])
    self.timestamp_embedding = tf.keras.Sequential([
      tf.keras.layers.Discretization(timestamp_buckets.tolist()),
      tf.keras.layers.Embedding(len(timestamp_buckets) + 2, 32)
    ])
    self.normalized_timestamp = tf.keras.layers.Normalization(
        axis=None
    )

  def call(self, inputs):

    # Take the input dictionary, pass it through each input layer,
    # and concatenate the result.
    return tf.concat([
        self.user_embedding(inputs["user_id"]),
        self.timestamp_embedding(inputs["timestamp"]),
        tf.reshape(self.normalized_timestamp(inputs["timestamp"]), (-1, 1))
    ], axis=1)

让我们试试看。

user_model = UserModel()

user_model.normalized_timestamp.adapt(
    ratings.map(lambda x: x["timestamp"]).batch(128))

for row in ratings.batch(1).take(1):
  print(f"Computed representations: {user_model(row)[0, :3]}")
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
Computed representations: [-0.02608593  0.0144566   0.01150807]

电影模型

我们可以对电影模型做同样的事情。

class MovieModel(tf.keras.Model):

  def __init__(self):
    super().__init__()

    max_tokens = 10_000

    self.title_embedding = tf.keras.Sequential([
      movie_title_lookup,
      tf.keras.layers.Embedding(movie_title_lookup.vocab_size(), 32)
    ])
    self.title_text_embedding = tf.keras.Sequential([
      tf.keras.layers.TextVectorization(max_tokens=max_tokens),
      tf.keras.layers.Embedding(max_tokens, 32, mask_zero=True),
      # We average the embedding of individual words to get one embedding vector
      # per title.
      tf.keras.layers.GlobalAveragePooling1D(),
    ])

  def call(self, inputs):
    return tf.concat([
        self.title_embedding(inputs["movie_title"]),
        self.title_text_embedding(inputs["movie_title"]),
    ], axis=1)

让我们试试看。

movie_model = MovieModel()

movie_model.title_text_embedding.layers[0].adapt(
    ratings.map(lambda x: x["movie_title"]))

for row in ratings.batch(1).take(1):
  print(f"Computed representations: {movie_model(row)[0, :3]}")
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
Computed representations: [ 0.00771372 -0.03204167 -0.02703585]

下一步

通过以上两个模型,我们已经迈出了第一步,在推荐模型中表示丰富的特征:要进一步了解如何使用这些特征来构建有效的深度推荐模型,请查看我们的深度推荐教程。