推荐系统系列(二):从零开始搭建基于向量的推荐策略

背景

向量召回/语义向量召回是现在流行的一种推荐策略,简单来说就是训练一个DNN双塔模型,模型分为用户侧和项目侧,可以分别得到用户的embedding向量和项目的embedding向量,然后以用户向量和项目向量之间的距离作为作为推荐的衡量标准,通过最近邻查询,找到用户最近的项目作为推荐候选项目。

这篇文章会记录我从零开始搭建一个向量化推荐策略的过程,包括遇到的问题,和方案设计和技术选型的思考。

架构

搭建向量化推荐策略,需要分离线和在线两个流程。
离线Pipeline大致包括这几个步骤:

  1. 用户行为日志上报
  2. 日志存储到数据仓库
  3. 数据预处理
  4. 模型训练
  5. 用户模型发布&项目向量索引构建。

模型训练我选择现在工业界最流行的TensorFlow框架,在线模型预测服务相应的选择TensorFlow配套的TF Serving框架。另外,在数据预处理时将样本保存成TFRecord格式。TensorFlow提供一些高级的API,可以方便地处理TFRecord格式的数据集,减少一部分数据加载处理方面的工作。

在线流程包括:

  1. 用户向量计算
  2. 最近邻查找

整体架构如下图:
在这里插入图片描述

离线Pipeline搭建

用户行为日志上报

用户行为日志上报除了上报用户的行为,比如信息流场景下的点击、评论、点赞,电商场景下的加购物车、收藏、购买,还要上报训练模型所需的特征。分用户侧和项目侧,主要的特征包括(这里只列了几个典型的特征):
用户侧:

特征名 注释
user_id 用户的唯一ID
gender 用户性别
age 用户年龄
city 用户所在城市
action_history 用户交互历史,不定长的序列,以item_id_1#item_id_2#item_id_3格式存储(仅作为例子)
…… ……

项目侧:

特征名 注释
item_id 项目id
category 项目类别
tags 项目标签,不定长
…… ……

日志存储到数据仓库

将原始的日志导入到数据仓库可以方便地进行一些ETL操作和统计分析。当然,现在还有一些通过实时流构建样本进行在线实时训练的做法。这里我选择先从简单的离线方式开始。

数据预处理

这个环节简单概括来讲就是:首先通过Spark SQL读取数据仓库的用户行为数据,然后进行清洗,字段转换的操作,最后存储成模型训练直接可用的数据集。
spark-tensorflow-connector是TensorFlow生态提供的一个工具,可以实现用Spark加载和存储TFRecords格式的文件。因为模型训练并不在Spark集群上进行,这里主要是使用这个库写数据的API。
spark-tensorflow-connector库提供了Scala和Python两种语言的接口。我选择使用Scala语言的API,使用的方式比较简单,通过增加Maven依赖即可。

<dependency>
  <groupId>org.tensorflow</groupId>
  <artifactId>spark-tensorflow-connector_2.11</artifactId>
  <version>1.10.0</version>
</dependency>

spark-tensorflow-connector的写方法提供了几个选项,如下图:
spark-tensorflow-connector使用说明
这里codec选择org.apache.hadoop.io.compress.GzipCodec,即在存储时对数据进行压缩,压缩的好处是可以减少文件网络传输的时间和磁盘的占用。Spark会将文件存储在HDFS上,而模型训练在TensorFlow容器上,需要网络传输。另外,样本数据可能很大,即使TFRecord是二进制文件,比纯文本文件小,但是因为产品用户基数大,最后的样本数据可能还是不小。
通过Spark SQL得到的是DataFrame类型的对象,但是由于DataFrame的API不是很灵活,我采用了Spark底层的RDD API对数据进行一些准换。比如用户行为历史以item_id_1#item_id_2#item_id_3格式的字符串存储,我需要将它转成Array
spark-tensorflow-connnector只能将DataFrame存储成TFRecord格式数据,因此我需要RDD对象再转成DataFrame对象。在转DataFrame的过程因为图方便,我使用了Spark隐式的自动类型推理的功能,因为特征有比较多,手动编写DataFrame的Scheme比较麻烦。结果遇到了一个java.lang.ClassNotFoundException: scala.Any的异常。部分异常日志如下:

20/04/20 12:14:08 ERROR ApplicationMaster: User class threw exception: java.lang.ClassNotFoundException: scala.Any
java.lang.ClassNotFoundException: scala.Any
	at java.net.URLClassLoader.findClass(URLClassLoader.java:382)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
	at java.lang.Class.forName0(Native Method)
	at java.lang.Class.forName(Class.java:348)
	at scala.reflect.runtime.JavaMirrors$JavaMirror.javaClass(JavaMirrors.scala:555)
	at scala.reflect.runtime.JavaMirrors$JavaMirror$$anonfun$classToJava$1.apply(JavaMirrors.scala:1211)
	at scala.reflect.runtime.JavaMirrors$JavaMirror$$anonfun$classToJava$1.apply(JavaMirrors.scala:1203)
	at scala.reflect.runtime.TwoWayCaches$TwoWayCache$$anonfun$toJava$1.apply(TwoWayCaches.scala:49)
	at scala.reflect.runtime.Gil$class.gilSynchronized(Gil.scala:19)
	at scala.reflect.runtime.JavaUniverse.gilSynchronized(JavaUniverse.scala:16)
	at scala.reflect.runtime.TwoWayCaches$TwoWayCache.toJava(TwoWayCaches.scala:44)
	at scala.reflect.runtime.JavaMirrors$JavaMirror.classToJava(JavaMirrors.scala:1203)

异常原因:
检查字段转换的代码,发现在对数据补齐的是否默认值写成了long型,而原数组存的String,默认值改成空字符串后不再抛出异常。

val clickDocIds = clickSeqSplits.map(_._1).padTo(clickSeqLimit, OL).take(clickSeqLimit) // _._1是字符串

这里还有一个截断补齐的逻辑。因为用户的行为历史序列是不定长的,为了减少模型特征处理的工作,这里设置了一个最大长度,用户行为历史序列长度超过最大长度的进行截断,少于最大长度的用默认值补齐,将这个特征变成了定长的。这个不是最佳的变长序列特征处理方式,为了简单,先这样处理。

模型训练

模型训练是比较复杂的一个流程,包括以下几个环节:

  1. 从HDFS上拉取最新的训练数据到本地
    使用Hadoop客户端把存储在HDFS上的样本数据下载到本地机器。

  2. 数据加载
    TensorFlow封装了TFRecordDataset的API,提供了数据shuffle、缓存等接口,省去了写DataLoader的一部门工作。但是TFRecord的数据还不能直接塞给模型,还需要写一个parser,将数据转换成模型可以接收的格式。

def parser(record):
    keys_to_features = {
        "label": tf.io.FixedLenFeature((1), tf.int64, default_value=0),
        "age": tf.io.FixedLenFeature((1), tf.int64, default_value=0),
        "gender": tf.io.FixedLenFeature((1), tf.int64, default_value=0),
        "city": tf.io.FixedLenFeature((1), tf.int64, default_value=0),
        "item_hist": tf.io.FixedLenSequenceFeature((), tf.string, allow_missing=True, default_value=""),
        "item_id": tf.io.FixedLenFeature((1), tf.string, default_value=""),
        "tag_ids": tf.io.FixedLenSequenceFeature((), tf.string, allow_missing=True, default_value=""),
        "category": tf.io.FixedLenFeature((1), tf.string, default_value=""),
    }
    parsed = tf.io.parse_single_example(record, keys_to_features)
    return parsed

FixedLenFeature用来处理固定长度的数据特征。FixedLenSequenceFeature用来处理变长的模型特征。

  1. 图建构
    TensorFlow以Graph来表示模型。TensorFlow通过几年的演进,从底层的接口开始不断封装了一些高级易用的接口。出于习惯,我使用了比较早的API的接口来构建模型图。后面有时间再把代码封装更易用的方法。
    第一步:定义占位符(placeholder)变量。这些变量跟上面的parser返回的特征对应。
label = tf.placeholder(tf.float32, shape=(None, None), name='label')
# user feature
age = tf.placeholder(tf.int32, shape=(None, None), name='age')
gender = tf.placeholder(tf.int32, shape=(None, None), name='gender')
item_hist = tf.placeholder(tf.string, shape=(None, None), name='item_hist')
# item feature
item_id = tf.placeholder(tf.string, shape=(None, None), name='item_id')
category = tf.placeholder(tf.string, shape=(None, None), name='category')
tag_ids = tf.placeholder(tf.string, shape=(None, None), name='doc_tag_ids')

第二步:特征转换。以项目类别为例,我们希望将其embedding化。

 # category_dict是一个list,保存全部类别ID
 category_dict = len(category_dict)
 category_keys = tf.constant(np.array(category_dict), dtype=tf.string)
 category_values = tf.constant(
     np.array([i for i in range(subject_dict_len)]), dtype=tf.int32)
 category_default = tf.constant(subject_dict_len, dtype=tf.int32)
 category_table = tf.contrib.lookup.HashTable(tf.contrib.lookup.KeyValueTensorInitializer(
     category_keys, category_values), category_default)
 category_embedding = tf.get_variable('category_embedding', [
                                     category_dict_len+1, embedding_dim], initializer=initializer)

第三步:定义参数变量,连接网络

# doc
doc_w1 = tf.get_variable(
    "doc_w1", shape=[doc_input_len, 512], dtype=tf.float32)
doc_b1 = tf.get_variable("doc_b1", shape=[512], dtype=tf.float32)
doc_w2 = tf.get_variable("doc_w2", shape=[512, 256], dtype=tf.float32)
doc_b2 = tf.get_variable("doc_b2", shape=[256], dtype=tf.float32)
doc_w3 = tf.get_variable("doc_w3", shape=[256, 128], dtype=tf.float32)
doc_b3 = tf.get_variable("doc_b3", shape=[128], dtype=tf.float32)
doc_w4 = tf.get_variable("doc_w4", shape=[128, 32], dtype=tf.float32)
doc_b4 = tf.get_variable("doc_b4", shape=[32], dtype=tf.float32)
doc_layer_1 = tf.nn.selu(tf.matmul(doc_input, doc_w1) + doc_b1)
doc_layer_1 = tf.layers.batch_normalization(
    doc_layer_1, training=is_training)
doc_layer_2 = tf.nn.selu(
    tf.matmul(doc_layer_1, doc_w2) + doc_b2, name="doc_layer2")
doc_layer_2 = tf.layers.batch_normalization(
    doc_layer_2, training=is_training)
doc_layer_3 = tf.nn.selu(tf.matmul(doc_layer_2, doc_w3) + doc_b3)
doc_layer_3 = tf.layers.batch_normalization(
    doc_layer_3, training=is_training)
doc_layer_4 = tf.matmul(doc_layer_3, doc_w4) + doc_b4

第四步:定义损失函数

loss = tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(
        logits=inner_product_sum, labels=label))

第五步:定义评价指标。一般常用auc来作为评价指标。

auc_op = tf.metrics.auc(label, y_pred)

第六步:选择优化器

train_op = train = tf.train.AdamOptimizer(learning_rate=args.learning_rate).minimize(loss)
  1. 加载旧模型
    我们的训练流程在线上周期运行。每次启动训练的时候,先加载之前训练好的模型,可以使模型更快的收敛。
    每次训练完成后,保存一下模型。
saver = tf.train.Saver(max_to_keep=5)
saver.save(sess, model_save_path)

启动训练前,加载旧模型来初始化参数。

ckpt = tf.train.get_checkpoint_state(args.model_dir)
saver.restore(sess, ckpt.all_model_checkpoint_paths[-1])
  1. 更新模型
    将数据集按batch喂给网络进行更新。
  2. 模型评估
    使用TensorBoard来监控模型的训练指标,包括AUC和Loss。
tf.summary.scalar('loss', loss)
tf.summary.scalar('auc', auc_op[1])
summary_writer = tf.summary.FileWriter(args.log_dir, sess.graph)
  1. 导出模型
    模型训练完成后,我需要将模型保存下来,用于在线的预测服务。TensorFlow提供了SaveModel的接口。
builder = tf.saved_model.builder.SavedModelBuilder(export_path)
signature = tf.saved_model.signature_def_utils.build_signature_def(
    inputs={
        "age": tf.saved_model.utils.build_tensor_info(age),
        "gender": tf.saved_model.utils.build_tensor_info(gender),
        "item_hist": tf.saved_model.utils.build_tensor_info(item_hist),
    },
    outputs={
        'user_vec': tf.saved_model.utils.build_tensor_info(user_vec),
    },
    method_name=tf.saved_model.signature_constants.PREDICT_METHOD_NAME)
builder.add_meta_graph_and_variables(sess,
                                     [tf.saved_model.tag_constants.SERVING],
                                     signature_def_map={'predict': signature})
builder.save()
  1. 模型发布
    导出模型后,我们需要新模型替代线上的旧模型。可以讲模型存到一个中间存储介质,比如S3,通知线上预测服务去拉取最新的模型。

构建项目索引

生产环境,项目的数量有可能很大,一个用户向量跟所有项目向量计算相似度的计算量很大,暴力计算耗时会很长。而我们线上的服务耗时往往要求是毫秒级的。
faiss是Facebook开源的一个稠密向量相似查找的库,可以使用它来提高查找的效率。类似的项目还有nmslib。每次模型更新完成后,我们需要重新计算出所有项目向量,然后重新构建索引。

Faiss is a library for efficient similarity search and clustering of dense vectors. It contains algorithms that search in sets of vectors of any size, up to ones that possibly do not fit in RAM. It also contains supporting code for evaluation and parameter tuning. Faiss is written in C++ with complete wrappers for Python/numpy. Some of the most useful algorithms are implemented on the GPU. It is developed by Facebook AI Research.

在线服务

用户向量预测

TensorFlow Serving是TensorFlow提供的用户进行模型推理(inference)的模块。它提供了gRPC 和 HTTP 两种协议的接口。它可以同时加载多个模型或一个模型的多个版本来提供预测服务。
我采用C++语言作为服务开发语言,因此我需要将TF Serving作为一个库引进来。这一步是一项挺复杂的工作,我需要控制模型的上线、下线、加载、以及编写模型推理接口。因为生产环境的数据会变化,所以我们需要定时的去更新模型。
每个用户请求到达后,我们需要准备好用户侧的特征,传给模型预测服务,然后拿到用户的向量表达。

最近项目查找

获取用户向量后,我们用这个向量去索引查找最近的项目,截取最近的k个项目作为用户推荐内容。

总结

以上是企业环境构建向量的推荐策略的一个过程,限于篇幅(jing li you xian),很多细节没有展开。学术界和工业界的一个很大差别是工业界需要很多工程的工作,包括开发各种服务,各种数据流程。这篇文章旨在给还未在工业环境动手实践过深度推荐算法的同学一些参考,希望能帮到大家。

待续……

参考

  1. TFRecord: https://www.tensorflow.org/tutorials/load_data/tfrecord
  2. spark-tensorflow-connector: https://github.com/tensorflow/ecosystem/tree/master/spark/spark-tensorflow-connector
  3. spark-tensorflow-connector用例:https://github.com/tensorflow/ecosystem/tree/master/spark/spark-tensorflow-connector#usage-examples
  4. SaveModel: https://github.com/tensorflow/tensorflow/blob/master/tensorflow/python/saved_model/README.md
  5. TensorFlow DType: https://www.tensorflow.org/api_docs/python/tf/dtypes/DType
  6. TensorFlow Serving:https://github.com/tensorflow/serving
  7. TensorFlow C++ API:https://www.tensorflow.org/api_docs/cc/class/tensorflow/tensor
  8. FixedLenFeature: https://www.tensorflow.org/api_docs/python/tf/io/FixedLenFeature
  9. FixedLenSequenceFeature: https://www.tensorflow.org/api_docs/python/tf/io/FixedLenSequenceFeature
  10. feature_column: https://www.tensorflow.org/tutorials/structured_data/feature_columns?hl=zh-cn
  11. faiss: https://github.com/facebookresearch/faiss

在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章