YouTube Net 是推荐系统步入深度DNN时代的开山之作,文中所提到的推荐系统框架非常经典,基本上奠定了后来推荐系统的主要步骤:召回和排序。至今在工业界有着广泛的应用。
1. 系统概况
YouTube Net 推荐系统主要包含两部分内容:召回和排序。
(1)召回的主要工作是从全体视频库中筛选出用户感兴趣的视频,此过程要求检索速度快,并且所检索出的视频与用户的历史行为和偏好相关。所以召回模型和特征都较为简单。
(2)排序的主要工作是对召回的视频进行精粒度的打分排序,模型和特征较召回环节更加复杂化。
目前 YouTube Net 在召回环节仍然有着广泛的应用,因此本文只介绍在召回环节的理论和实践。
2. YouTube Net 召回模型
(1)构建模型
在召回阶段,YouTube Net 简单粗暴的将推荐问题转化为了一个 Softmax 多分类问题。即定义一个后验概率,基于用户 和其上下文 ,在 时刻将视频库 中指定的视频 分为第 类的概率。公式如下:
其中: 表示用户和上下文的Embedding, 表示每个视频的Embedding。
(2)训练模型技巧:负采样技术
与 Word2vec 算法一样,为了高效的训练模型,YouTube Net 算法也通过负采样的方式在全体候选集分布中抽取负类,然后通过重要性加权对抽样进行校正。对于每个(正)样本,对真实标签和采样得到的负类,最小化其交叉熵损失函数。
(3)召回模型网络结构
召回模型的结构如图1所示。
图1 YouTube推荐系统召回模型结构图
输入层:特征全部都是用户相关特征,分别为:
(i)用户历史观看的视频ID Embedding;
(ii)用户搜索视频ID Embedding;
(iii)用户的地理属性及设备信息的Embedding;
(iv)用户画像特征(年龄、性别等);
MLP层:
(i)使用三层ReLU全连接层;
输出Softmax层:
(i)输入:经过三层ReLU全连接层生成的用户Embedding;
(ii)输出:用户观看每一个视频的概率分布;
该模型主要是应用有监督的学习方式去学习用户历史和上下文信息的Embedding,然后应用Softmax分类器区分视频,从而获得视频Embedding。其中:Softmax前一层的输出作为User的Embedding,而Softmax层中的权重矩阵的每一行向量作为视频的Embedding。
图2 YouTube召回模型Softmax层输出权重Embedding图示
图2中权重矩阵 即为视频Embedding矩阵, 代表视频的格式, 代表视频Embedding的维度,即每一行代表一个视频的Embedding;右侧的 即为用户的Embedding。两个Embedding矩阵做内积,然后经过Softmax函数,再进行归一化得到每个视频对应的概率值,从一定程度上反映了预测概率的大小。
线上通过 检索的方式(faiss或annoy),对于每个用户向量,对视频库中的所有视频向量做最近邻算法,得到 的视频作为召回结果。
3. 实现 YouTube Net 召回模型
(1)数据情况
下面使用一个简单的数据集,实践一下YouTube Net召回模型。数据集是《movielens-1M数据》,数据下载地址:
http://files.grouplens.org/datasets/movielens/ml-1m.zip
将数据集加工成如下格式:
5030 2 2 2 2247 3412,2899,2776,2203,2309,2385,743,2958,2512,2485,2404,2675,2568,2555,217,2491,2566,2481,2503,2786,1051,2502,803,3030,1789,2,424,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 27 2558 1112,1284,1174,3100,1049,2137,2273,2651,340,2163
279 2 3 15 2831 2210,1456,453,1293,3210,2235,2284,1095,1487,3511,738,886,1926,3501,1023,150,1198,3413,156,909,1019,2848,260,2737,1096,2684,1887,107,1143,347,1107,1111,1151,1133,3113,3592,1119,3287,1203,1181,1121,852,1915,1247,3038,240,0,0,0,0 46 2212 820,1009,2076,529,3032,2503,2742,2345,965,366
1300 2 1 11 3282 692,3041,1234,519,1554,1258,3452,1509,1170,1252,2804,754,2866,1987,2416,596,1250,1824,1225,2323,2542,2647,2355,2267,1248,2543,1818,2512,1815,1167,1289,1241,1803,2974,3252,3127,3320,3061,3278,3075,3249,3322,2945,3179,65,1109,3091,1245,2311,3357 165 1880 1545,332,2754,2254,267,1532,1062,1450,1440,2467
323 2 5 13 1799 580,864,1060,2098,2824,1203,1213,1088,1185,2,1925,309,2427,1994,1176,1486,853,1161,29,254,1259,528,1179,1107,1567,4,427,3567,3130,1174,2129,575,347,1415,2786,2204,2487,21,1223,3032,2652,67,2198,1737,45,51,218,2400,1225,467 117 1295 1114,2758,435,318,2251,2111,3650,2510,3705,1111
695 1 2 2 233 2161,2235,700,2962,444,2489,2375,1849,3662,3582,3650,3225,3128,3060,3127,3581,3252,3510,3556,3076,3281,3302,3050,3384,3702,2969,3303,3551,3543,3178,3249,3670,3342,3652,3665,3378,3322,3073,3376,3075,3584,3179,3504,3511,3278,1289,2,467,107,994 190 2945 2456,2716,2635,990,3657,3403,2210,1602,3251,143
说明:
第 1 列 | user_id | 用户id |
第 2 列 | gender | 用户性别 |
第 3 列 | age | 用户年龄 |
第 4 列 | occupation | 用户工作 |
第 5 列 | zip | 用户邮编 |
第 6 列 | hist_movie_id | 用户历史观看电影序列 |
第 7 列 | hist_len | 用户历史观看电影长度 |
第 8 列 | pos_movie_id | 用户下一步观看的电影(正样本) |
第 9 列 | neg_movie_id | 用户下一步未观看的电影(抽样作为负样本) |
数据加工逻辑见:https://github.com/wziji/deep_ctr/blob/master/youtubeNet/data.py
(2)行为序列数据处理
由于本示例中有历史的行为序列数据,需要对历史数据进行pooling处理,方式一般为:mean, max, sum等方式。
另外由于用户历史的行为数据的个数不一致,需要预先对数据进行对齐操作,本文的 hist_movie_id字段 保留50条历史数据,不足50条的以0填充。所以在进行pooling操作的时候,需要先进行mask操作,操作步骤见:tf.sequence_mask后做max操作或avg操作。
pooling功能见:
https://github.com/wziji/deep_ctr/blob/master/youtubeNet/SequencePoolingLayer.py
(3)构建 YouTube Net 模型
#-*- coding:utf-8 -*-
import tensorflow as tf
from tensorflow.keras.layers import Input, Embedding, concatenate, Dense, Dropout
from tensorflow.keras.models import Model
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.callbacks import EarlyStopping
from SequencePoolingLayer import SequencePoolingLayer
def YouTubeNet(
sparse_input_length=1,
dense_input_length=1,
sparse_seq_input_length=50,
embedding_dim = 64,
neg_sample_num = 10,
user_hidden_unit_list = [128, 64]
):
# 1. Input layer
user_id_input_layer = Input(shape=(sparse_input_length, ), name="user_id_input_layer")
gender_input_layer = Input(shape=(sparse_input_length, ), name="gender_input_layer")
age_input_layer = Input(shape=(sparse_input_length, ), name="age_input_layer")
occupation_input_layer = Input(shape=(sparse_input_length, ), name="occupation_input_layer")
zip_input_layer = Input(shape=(sparse_input_length, ), name="zip_input_layer")
user_click_item_seq_input_layer = Input(shape=(sparse_seq_input_length, ), name="user_click_item_seq_input_layer")
user_click_item_seq_length_input_layer = Input(shape=(sparse_input_length, ), name="user_click_item_seq_length_input_layer")
pos_item_sample_input_layer = Input(shape=(sparse_input_length, ), name="pos_item_sample_input_layer")
neg_item_sample_input_layer = Input(shape=(neg_sample_num, ), name="neg_item_sample_input_layer")
# 2. Embedding layer
user_id_embedding_layer = Embedding(6040+1, embedding_dim, mask_zero=True, name='user_id_embedding_layer')(user_id_input_layer)
gender_embedding_layer = Embedding(2+1, embedding_dim, mask_zero=True, name='gender_embedding_layer')(gender_input_layer)
age_embedding_layer = Embedding(7+1, embedding_dim, mask_zero=True, name='age_embedding_layer')(age_input_layer)
occupation_embedding_layer = Embedding(21+1, embedding_dim, mask_zero=True, name='occupation_embedding_layer')(occupation_input_layer)
zip_embedding_layer = Embedding(3439+1, embedding_dim, mask_zero=True, name='zip_embedding_layer')(zip_input_layer)
item_id_embedding_layer = Embedding(3706+1, embedding_dim, mask_zero=True, name='item_id_embedding_layer')
pos_item_sample_embedding_layer = item_id_embedding_layer(pos_item_sample_input_layer)
neg_item_sample_embedding_layer = item_id_embedding_layer(neg_item_sample_input_layer)
user_click_item_seq_embedding_layer = item_id_embedding_layer(user_click_item_seq_input_layer)
user_click_item_seq_embedding_layer = SequencePoolingLayer(sequence_mask_length=sparse_seq_input_length)\
([user_click_item_seq_embedding_layer, user_click_item_seq_length_input_layer])
### ********** ###
# user part
### ********** ###
# 3. Concat "sparse" embedding & "sparse_seq" embedding
user_embedding_layer = concatenate([user_id_embedding_layer, gender_embedding_layer, age_embedding_layer,
occupation_embedding_layer, zip_embedding_layer, user_click_item_seq_embedding_layer],
axis=-1)
for i, u in enumerate(user_hidden_unit_list):
user_embedding_layer = Dense(u, activation="relu", name="FC_{0}".format(i+1))(user_embedding_layer)
### ********** ###
# item part
### ********** ###
item_embedding_layer = concatenate([pos_item_sample_embedding_layer, neg_item_sample_embedding_layer], \
axis=1)
item_embedding_layer = tf.transpose(item_embedding_layer, [0,2,1])
# Output
dot_output = tf.matmul(user_embedding_layer, item_embedding_layer)
dot_output = tf.nn.softmax(dot_output) # 输出11个值,index为0的值是正样本,负样本的索引位置为[1-10]
user_inputs_list = [user_id_input_layer, gender_input_layer, age_input_layer, \
occupation_input_layer, zip_input_layer, \
user_click_item_seq_input_layer, user_click_item_seq_length_input_layer]
item_inputs_list = [pos_item_sample_input_layer, neg_item_sample_input_layer]
model = Model(inputs = user_inputs_list + item_inputs_list,
outputs = dot_output)
model.__setattr__("user_input", user_inputs_list)
model.__setattr__("user_embedding", user_embedding_layer)
model.__setattr__("item_input", pos_item_sample_input_layer)
model.__setattr__("item_embedding", pos_item_sample_embedding_layer)
return model
模型结构图见:
输入:7个特征数据,和2组label数据(包含1个正样本数据,抽样的10个负样本数据);
输出:11个样本的 Softmax 概率分布;
(4)训练 YouTube Net 模型
# Train model
early_stopping_cb = tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True)
callbacks = [early_stopping_cb]
model = YouTubeNet()
model.compile(loss='sparse_categorical_crossentropy', \
optimizer=Adam(lr=1e-3), \
metrics=['sparse_categorical_accuracy'])
history = model.fit(train_generator, \
epochs=2, \
steps_per_epoch = steps_per_epoch, \
callbacks = callbacks,
validation_data = val_generator, \
validation_steps = validation_steps, \
shuffle=True
)
model.save_weights('YouTubeNet_model.h5')
使用的 loss 函数为:sparse_categorical_crossentropy,请参考tf.nn.sparse_softmax_cross_entropy_with_logits 函数简介 温习一下。
(5)加载 YouTube Net 模型,得到最终的用户和电影Embedding
# Generate user features for testing and full item features for retrieval
test_user_model_input = [user_id, gender, age, occupation, zip, hist_movie_id, hist_len]
all_item_model_input = list(range(0, 3706+1))
user_embedding_model = Model(inputs=re_model.user_input, outputs=re_model.user_embedding)
item_embedding_model = Model(inputs=re_model.item_input, outputs=re_model.item_embedding)
user_embs = user_embedding_model.predict(test_user_model_input)
item_embs = item_embedding_model.predict(all_item_model_input, batch_size=2 ** 12)
user_embs = np.reshape(user_embs, (-1, 64))
item_embs = np.reshape(item_embs, (-1, 64))
print(user_embs.shape)
print(item_embs.shape)
# (6040, 64)
# (3707, 64)
(6)基于最终的用户和电影Embedding,使用faiss或annoy检索用户感兴趣的候选池
(暂时未实现)
本文的代码请见,欢迎star:https://github.com/wziji/deep_ctr/tree/master/youtubeNet
欢迎关注“python科技园”及添加小编进群交流。