推薦系統系列X:從零開始搭建基於向量的推薦策略

背景

向量召回/語義向量召回是現在流行的一種推薦策略,簡單來說就是訓練一個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個項目作爲用戶推薦內容。

總結

以上是企業環境構建向量的推薦策略的一個過程,限於篇幅,很多細節沒有展開。學術界和工業界的一個很大差別是工業界需要很多工程的工作,包括開發各種服務,各種數據流程。這篇文章旨在給還未在工業環境動手實踐過深度推薦算法的同學一些參考,希望能幫到大家。

待續……

參考

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