用动态嵌入训练推荐模型

680 阅读12分钟

现代推荐器大量利用嵌入来创建每个用户和候选项目的向量表示。然后,这些嵌入可以用来计算用户和项目之间的相似性,以便向用户推荐更有趣和更相关的候选项目。但是,当大规模处理数据时,P...

现代推荐器大量利用嵌入来创建每个用户和候选项目的向量表示。然后,这些嵌入可以用来计算用户和项目之间的相似性,以便向用户推荐更有趣和更相关的候选项目。但是,当大规模处理数据时,特别是在在线机器学习环境中,嵌入表的大小会急剧增长,积累了数百万(有时是数十亿)的项目。在这种规模下,在内存中存储这些嵌入表变得不可能。此外,很大一部分项目可能是很少见的,所以为这些很少出现的项目保留专门的嵌入是没有意义的。一个更好的解决方案是用一个共同的嵌入来表示这些项目。这可以极大地减少嵌入表的大小,而性能成本却非常小。这就是动态嵌入表背后的主要动机。

TensorFlow内置的tf.keras.layer.Embedding层在创建时有一个固定的大小,所以我们需要另一种方法。幸运的是,有一个TensorFlow SIG项目正是为了这个目的:TensorFlow Recommenders Addons(TFRA)。你可以从它的资源库中了解更多,但在高层次上,TFRA利用动态嵌入技术来动态改变嵌入的大小,实现比静态嵌入更好的推荐结果。TFRA与TF2.0完全兼容,并能与熟悉的Keras API接口顺利工作,因此它可以很容易地与其他TensorFlow产品集成,如TensorFlow Recommenders(TFRS)。

在本教程中,我们将通过利用TFRS和TFRA来建立一个电影推荐模型。我们将使用MovieLens数据集,其中包含显示用户对电影的评分的匿名数据。我们的主要重点是展示TensorFlow Recommenders Addons库中提供的动态嵌入如何被用来在推荐设置中动态地增加和缩小嵌入表的大小。你可以在这里找到完整的实现,在这里找到演练。

导入库

我们将首先导入所需的库。

注意我们是如何在导入TensorFlow之后导入TFRA库的。建议遵循这个顺序,因为TFRA库将在TensorFlow上应用一些补丁。

处理数据

让我们首先用TensorFlow推荐器建立一个基线模型。我们将遵循这个TFRS检索教程的模式,建立一个双塔检索模型。用户塔将把用户ID作为输入,但项目塔将使用标记化的电影标题作为输入。

为了处理电影标题,我们定义了一个辅助函数,将电影标题转换为小写字母,删除给定电影标题中的任何标点符号,并使用空格进行分割,生成一个标记列表。最后,我们只从电影标题中抽取最多的max_token_length tokens(从头开始)。如果一个电影名称有更少的标记,所有的标记都会被提取。这个数字是根据一些分析选择的,代表了数据集中标题长度的第90个百分点。

我们还将标记化的电影标题填充到一个固定的长度,并使用相同的随机种子分割数据集,这样我们就能在训练历时中得到一致的验证结果。你可以在笔记本的 "处理数据集 "部分找到详细代码。

**

建立双塔模型

**

我们的用户塔与TFRS检索教程中的基本相同(除了它更深),但对于电影塔来说,在嵌入查找之后有一个GlobalAveragePooling1D ,它将电影标题标记的嵌入平均为一个嵌入。

def get_movie_title_lookup_layer(dataset: tf.data.Dataset) -> tf.keras.layers.Layer: movie_title_lookup_layer = tf.keras.layers.StringLookup(mask_token=pad_token) movie_title_lookup_layer.adapt(dataset.map(lambda x: x["movie_title"])) return movie_title_lookup_layer def build_item_model(movie_title_lookup_layer: tf.keras.layers.StringLookup): vocab_size = movie_title_lookup_layer.vocabulary_size() return tf.keras.models.Sequential([ tf.keras.layers.InputLayer(input_shape=(max_token_length), dtype=tf.string), movie_title_lookup_layer, tf.keras.layers.Embedding(vocab_size, 64), tf.keras.layers.GlobalAveragePooling1D(), tf.keras.layers.Dense(64, activation="gelu"), tf.keras.layers.Dense(32), tf.keras.layers.Lambda(lambda x: tf.math.l2_normalize(x, axis=1)) ])

接下来我们要对模型进行训练。

训练模型

训练模型就是简单地在模型上调用fit() ,并加上所需的参数。我们将使用我们的验证数据集validation_ds 来衡量我们模型的性能。

history = model.fit(datasets.training_datasets.train_ds, epochs=3, validation_data=datasets.training_datasets.validation_ds)

最后,输出看起来如下:

Epoch 3/3 220/220 [==============================] - 146s 633ms/step ...... val_factorized_top_k/top_10_categorical_accuracy: 0.0179 - val_factorized_top_k/top_50_categorical_accuracy: 0.0766 - val_factorized_top_k/top_100_categorical_accuracy: 0.1338 - val_loss: 12359.0557 - val_regularization_loss: 0.0000e+00 - val_total_loss: 12359.0557

我们在验证数据集上取得了13.38%的前100名分类准确性。

**

用动态嵌入建立模型

**

概述

我们现在将学习如何使用TensorFlow Recommenders Addons(TFRA)库中的动态嵌入,而不是静态嵌入表。顾名思义,相对于为词汇表中的所有项目创建嵌入,动态嵌入只在需要时增加嵌入表的大小。这种行为在处理数以百万计和数十亿计的项目和用户时真正大放异彩,正如一些公司所做的那样。对于这些公司来说,发现静态嵌入表在内存中放不下也就不奇怪了。静态嵌入表可以增长到数百吉字节甚至兆字节,甚至使云环境中的最高内存实例都无法使用。

当你有一个具有大cardinality的嵌入表时,访问权重将是相当稀疏的。因此,一个基于哈希表的数据结构被用来保存权重,每次迭代所需的权重都从底层表结构中检索出来。在这里,为了专注于库的核心功能,我们将专注于一个非分布式的设置。在这种情况下,TFRA将默认选择cuckoo hashtable。但也有其他的解决方案,如Redis、nvhash等可用。

A chart showing the various embedding solutions across distruted and non-distributed settings in the TFRA library

当使用动态嵌入时,我们用一些初始容量来初始化表,当在模型训练期间看到更多的ID时,表的大小将按需增长。关于动机和内部机制的更多信息,请参考RFC

嵌入的类型

目前在TFRAdynamic_embedding 模块中,有三种类型的嵌入可用:

  • 嵌入 - 嵌入的最基本形式。它期望一个一维([batch_size])或二维([batch_size, time_steps])的ID张量,并分别输出一个[batch_size, embedding_dim]或[batch_size, time_steps, embedding_dim] 大小的张量。
  • SquashedEmbedding - 该层基于某种缩减操作(例如平均/总和)来压扁时间步长,将[batch_size, time_steps]大小的ID张量转化为[batch_size, embedding_dim]张量。
  • FieldwiseEmbedding - 这种类型可以一次处理多个特征(即字段)。该层把n_slots 作为一个参数,ID被映射到该层中的一个槽。该层将返回一个大小为[batch_size, n_slots, embedding_dim]的张量。

定义嵌入层

我们将使用Embedding 来表示用户ID,用SquashedEmbedding来表示token ID。请记住,每个电影标题都有多个标记,因此,我们需要一种方法,将产生的标记嵌入减少到一个单一的代表嵌入。

**注意:**Embedding的行为从0.5版到0.6版已经改变。请确保在本教程中使用0.6版本。

有了这个,我们可以像在标准模型中那样定义两个塔。然而,这一次我们将使用动态嵌入层而不是静态嵌入层。

def build_de_user_model(user_id_lookup_layer: tf.keras.layers.StringLookup) -> tf.keras.layers.Layer: vocab_size = user_id_lookup_layer.vocabulary_size() return tf.keras.Sequential([ tf.keras.layers.InputLayer(input_shape=(), dtype=tf.string), user_id_lookup_layer, de.keras.layers.Embedding( embedding_size=64, initializer=tf.random_uniform_initializer(), init_capacity=int(vocab_size*0.8), restrict_policy=de.FrequencyRestrictPolicy, name="UserDynamicEmbeddingLayer" ), tf.keras.layers.Dense(64, activation="gelu"), tf.keras.layers.Dense(32), tf.keras.layers.Lambda(lambda x: tf.math.l2_normalize(x, axis=1)) ], name='user_model') def build_de_item_model(movie_title_lookup_layer: tf.keras.layers.StringLookup) -> tf.keras.layers.Layer: vocab_size = movie_title_lookup_layer.vocabulary_size() return tf.keras.models.Sequential([ tf.keras.layers.InputLayer(input_shape=(max_token_length), dtype=tf.string), movie_title_lookup_layer, de.keras.layers.SquashedEmbedding( embedding_size=64, initializer=tf.random_uniform_initializer(), init_capacity=int(vocab_size*0.8), restrict_policy=de.FrequencyRestrictPolicy, combiner="mean", name="ItemDynamicEmbeddingLayer" ), tf.keras.layers.Dense(64, activation="gelu"), tf.keras.layers.Dense(32), tf.keras.layers.Lambda(lambda x: tf.math.l2_normalize(x, axis=1)) ])

随着用户塔和电影塔模型的定义,我们可以像往常一样定义检索模型。

创建和编译最终模型

作为建立模型的最后一步,我们将创建模型并编译它。

def create_de_two_tower_model(dataset: tf.data.Dataset, candidate_dataset: tf.data.Dataset) -> tf.keras.Model: user_id_lookup_layer = get_user_id_lookup_layer(dataset) movie_title_lookup_layer = get_movie_title_lookup_layer(dataset) user_model = build_de_user_model(user_id_lookup_layer) item_model = build_de_item_model(movie_title_lookup_layer) task = tfrs.tasks.Retrieval( metrics=tfrs.metrics.FactorizedTopK( candidate_dataset.map(item_model) ), ) model = DynamicEmbeddingTwoTowerModel(user_model, item_model, task) optimizer = de.DynamicEmbeddingOptimizer(tf.keras.optimizers.Adam()) model.compile(optimizer=optimizer) return model datasets = create_datasets() de_model = create_de_two_tower_model(datasets.training_datasets.train_ds, datasets.candidate_dataset)

注意在标准TensorFlow优化器周围使用DynamicEmbeddingOptimizer 包装器。必须将标准优化器包裹在DynamicEmbeddingOpitmizer ,因为它将提供训练存储在hashtable中的权重所需的专门功能。我们现在可以训练我们的模型了。

**

训练模型

**

训练模型是非常直接的,但会涉及到一些额外的努力,因为我们想记录一些额外的信息。我们将通过一个tf.keras.callbacks.Callback 对象来执行日志记录。我们将其命名为DynamicEmbeddingCallback

epochs = 3 history_de = {} history_de_size = {} de_callback = DynamicEmbeddingCallback(de_model, steps_per_logging=20) for epoch in range(epochs): datasets = create_datasets() train_steps = len(datasets.training_datasets.train_ds) hist = de_model.fit( datasets.training_datasets.train_ds, epochs=1, validation_data=datasets.training_datasets.validation_ds, callbacks=[de_callback] ) for k,v in de_model.dynamic_embedding_history.items(): if k=="step": v = [vv+(epoch*train_steps) for vv in v] history_de_size.setdefault(k, []).extend(v) for k,v in hist.history.items(): history_de.setdefault(k, []).extend(v)

我们已经从fit() 函数中取出了穿越历时的循环。然后在每个历时中我们重新创建数据集,因为这将提供一个不同的训练数据集的洗牌。我们将在循环内对模型进行单次历时训练。最后,我们在history_de_size (这是由我们的自定义回调提供的)中积累记录的嵌入大小,并在history_de 中积累性能指标。

回调的实现方式如下。

该回调做了两件事:

  • steps_per_logging 迭代一次,记录嵌入层的大小
  • 如果restrict=True ,将嵌入表的大小减少到总词汇量的80%(这在默认情况下被设置为False )。

让我们来理解减少大小的含义以及为什么它很重要。

减少嵌入表的大小

我们还没有讨论的一个重要话题是如何减少嵌入表的大小,如果它增长超过一些预定义的阈值。这是一个强大的功能,因为它允许我们定义一个阈值,嵌入表不应该超过这个阈值。这将使我们能够处理大的词汇表,同时将内存需求保持在我们可能有的内存限制之下。我们通过在嵌入层的底层变量上调用restrict() 来实现这一目标,如DynamicEmbeddingCallbackrestrict() 需要两个参数:num_reserved (减少后的大小)和trigger (应该触发减少的大小)。管理如何进行缩减的策略是通过层结构中的restrict_policy 参数定义的。你可以看到,我们使用的是FrequencyRestrictPolicy 。这意味着最不频繁的项目将被从嵌入表中删除。回调使用户能够通过设置steps_per_restrictrestrict 参数来设置削减的频率,在DynamicEmbeddingCallback

当你有流数据时,减少嵌入表的大小更有意义。想想一个在线学习的环境,你每天(甚至每小时)对一些传入的数据进行模型训练。你可以认为外部for循环(即epochs)代表天。每天你都会收到一个数据集(例如包含前一天的用户互动),你从前一个检查点开始训练模型。在这种情况下,你可以使用DynamicEmbeddingCallback ,如果嵌入表的增长超过触发器参数中定义的大小,就可以触发一个限制。

分析性能

这里我们分析了三种变体的性能。

  • 标准检索模型(使用静态嵌入表)。
  • 使用动态嵌入但不执行限制的检索模型
  • 使用动态嵌入并执行限制的检索模型

image.png

你可以看到,使用动态嵌入的模型(绿色实线)与基线(红色实线)具有可比的验证性能。你也可以看到训练准确率的类似趋势。在实践中,动态嵌入通常可以被看作是在大规模的在线学习设置中提高准确性。

最后,我们可以看到restrict ,这对验证准确性有一定的不利影响,这是可以理解的。因为我们使用的是一个相对较小的数据集,项目数量不多,减少可能是摆脱了那些最好保留在表中的嵌入。例如,你可以在restrict 函数中增加num_reserved 参数(例如设置为int(self.model.lookup_vocab_sizes[k]*0.95) ),这将产生向没有restrict 的性能改进的性能。

接下来我们看一下dynamic 嵌入表随着时间的推移到底是怎样的。

image.png 我们可以看到,当不使用限制时,嵌入表会增长到词汇的全部大小(虚线)并保持在那里。然而,当restrict (虚线)时,当它遇到新的ID时,其大小会下降并再次增长。

同样重要的是要注意,构建一个适当的验证并不是一个微不足道的任务。有一些考虑因素,如样本外验证、时间外验证、分层等,需要仔细考虑。然而,在这项工作中,我们没有关注这些因素,而是通过从现有的数据集中随机抽样来创建一个验证集。

结论

在处理包含数百万或数十亿实体的大型项目集时,使用动态嵌入表是进行表征学习的一种强大方式。在本教程中,我们学习了如何使用TensorFlow Recommender Addons库中提供的dynamic_embedding 模块来实现这一目标。我们首先探索了数据,并通过提取我们将用于模型训练和评估的特征来构建tf.data.Dataset 对象。接下来我们定义了一个使用静态嵌入表的模型,作为评估基线。然后我们创建了一个使用动态嵌入的模型,并在数据上对其进行了训练。我们看到,使用动态嵌入,嵌入表只在需要时才会增长,并且仍然取得与基线相当的性能。我们还讨论了如何使用restrict 功能来缩小嵌入表,如果它的增长超过了预先定义的阈值。

我们希望这个教程能给你一个关于TFRA和动态嵌入的良好概念介绍,并帮助你思考如何利用它来增强你自己的推荐器。如果你想进行更深入的讨论,请访问TFRA资源库。