DSSM又称双塔召回, 起源自微软的搜索算法文章,现在是推广搜算法最通用的向量召回算法之一。
作为深度学习算法之一, 它将将用户侧和物料侧特征组成的稀疏向量通过层层深度网络压缩到低维向量,并通过余弦相似度来计算两个语义向量的距离,最终训练出代表用户和物料在高维空间语义相似度模型。
上线预测时利用训练完的用户向量ANN检索物料向量返回TopK近似物料。
使用DSSM深度召回模型基于用户物料特征分别进行深度交叉后返回物料, 并且因为是基于特征推理得到的向量,所以一般能覆盖度100%用户。
双塔召回虽然作为U2I向量召回早起典范, 年代比较久远,但是因为其模型简单通用,上线预测快速,所以有很多优化点,因此下面我们从样本,loss,网络结构等阐述它的诸多变种。
石塔西大佬在著名的负样本为王:评Facebook的向量化召回算法 写到排序是特征的艺术而,召回是样本的艺术。
在漏斗形的推荐算法中, 排序大多数情况无疑正样本用真实曝光且互动(或时长点击), 负样本是真实曝光且无互动。 但是对于召回,如果负样本直接用真实曝光且无互动会造成下面两个问题。
DSSM 原文中的采样方法使用(Q,D+) 来表示一个(Query,Doc)对,其中 D+ 表示这个Doc是被点击过的。 使用D+和四个随机选取没有被点击过的Doc来近似全部文档集合D ,其中{D−(j) ;j=1,…,4}表示负样本
但是业界这个负样本,正样本到底怎么得出来的呢。 业界主要有两种做法, 一种是batch内负采样,一个batch全是用户-物料对正样本, 负样本由用户和batch内其他样本的物料构建。这种方式的好处是实现比较简单,而且效果也比较容易出, 之后说的第二种全局负采样很容易构建成easy negative sample模型难以学到有益信息。而batch内负采样将其他正样本物料作为自己负样本物料可视为hard negative sample。具体实现方式可以看这篇文章batch内负采样
第二种方法是全局负采样,负样本从全局物料库随机采样。因为线上召回时,候选库里大多数的物料是与用户无关的,随机抽样能够很好地模拟这一分布 。但是简单的随机采样并不能带来什么效果,只有增加 hard negative样本才能提高召回效果,接下去介绍业界几家公司是如何做困难样本负采样的。
这篇文章是facebook发表在KDD2020上的一篇关于社交网络中的搜索中的embedding 检索问题, 包括了特征、样本、模型、全链路等各种细节知识。
文中实验论证了使用曝光且无点击的负样本比随机抽样效果差很多,并提出了负采样的几种方式:
这篇文章是Airbnb关于搜索的session数据来生成房源和用户embedding并应用的文章,embedding学习过程将搜索session中数据看作类似序列信息,并通过类似word2vec的方式学习出每个房源id的embedding值。
文中提出构建负样本的多种方式:
除了上述论文,还有不少优秀的文章阐述了召回负样本生成的工作
链接1: 推荐系统之—正负样本构造trick_Ricky-程序员宅基地_推荐系统负采样
链接2: 召回和粗排负样本构造问题
链接3: 召回模型中的负样本构造
链接4: 推荐系统正负样本的划分和采样,如何做更合理?
链接5: 推荐系统论文阅读(三十三)-百度:谈谈召回任务中负样本的选取优化
链接6: 召回负样本选取梳理
链接7: 双塔召回模型的前世今生(上篇)
链接8: 负样本为王:评Facebook的向量化召回算法
链接9: 借Youtube论文,谈谈双塔模型的八大精髓问题
我司在场景中实测batch内负采样较方法全局采样个性化效果提升明显。 因为后者很容易构建成easy negative sample模型难以学到有益信息。而前者将其他正样本物料作为自己负样本物料可视为hard negative sample。
热度打压同样是在处理召回正负样本的时候常规操作,在大多数推荐系统里少数热门物料占据了绝大多数的曝光与点击。 这样一来,正样本被少数热门物料所绑架,导致所有人的召回结果都集中于少数热门物料,完全失去了个性化。因此:
之前提过的样本链接中也有对于热门打压相关的描述:
链接1: 负样本为王:评Facebook的向量化召回算法
链接2: 推荐系统论文阅读(三十三)-百度:谈谈召回任务中负样本的选取优化 。此文中对样本根据一定曝光频率来采集,本身也是热度打压的一种。
在我司场景中,实测不对正样本热度打压时, 召回返回基本时全是全站Topk高热物料,缺乏个性化。加入打压后,个性化明显。
首先, 有一篇我觉得非常好的文章 【辩难】DSSM 损失函数是 Pointwise Loss 吗?很清晰的阐述了DSSM loss的本质, 属于pairwise+ loss的一种。相比纯粹的pairwise loss, 它计算单个 loss 时会用到多个负样本。
在我们的开发历程中, 因为排序的惯性思维并且追求快速上线,一开始使用pointwise loss的形式, 仅从单条用户-物料样本的分类角度来计算,没有考虑同一用户对不同物料的相对顺序。 比如我使用了评估binary cross entropy , 它评估了单个样本真实标签的预测准确度,总误差既包括正样本带来的误差,也包括负样本带来的误差。但是结果是效果很差, 召回基本没有个性化。
看了上面那篇文章后, 使用DSSM原生的pairwise+ loss, 使用batch内负采样,给每一个正样本用户-正样本物料配上4个负样本物料。 然后Softmax计算正样本概率
随后只基于正样本用极大似然估计得到 DSSM 的损失函数为:
代码实验如这个链接 batch内负采样。 最终实验结果是远远优于binary cross entropy loss的
相关代码实现如下
user_emb,item_emb,neg_sampling = inputs
user_emb = tf.nn.l2_normalize(user_emb,-1)
item_emb = tf.nn.l2_normalize(item_emb,-1)
x = tf.keras.layers.dot([user_emb,item_emb],axes=-1)
NEG = 4
batchSize = 1024
item_y = item_emb
item_y_temp = item_emb
user_y = user_emb
#temperature = 0.1
for i in range(NEG):
rand = int((random.random() + i) * batchSize / NEG)
item_y = tf.concat([item_y,tf.slice(item_y_temp, [rand, 0], [batchSize - rand, -1]),tf.slice(item_y_temp, [0, 0], [rand, -1])], 0)
prod_raw = tf.reduce_sum(tf.multiply(tf.tile(user_y, [NEG + 1, 1]), item_y), 1, True)
prod = tf.transpose(tf.reshape(tf.transpose(prod_raw), [NEG + 1, batchSize]))
# prod = tf.transpose(tf.reshape(tf.transpose(prod_raw), [NEG + 1, batchSize])) / temperature
# 转化为softmax概率矩阵。
prob = tf.nn.softmax(prod)
# 只取第一列,即正样本列概率。
hit_prob = tf.slice(prob, [0, 0], [-1, 1])
x = hit_prob
return x
model.compile(optimizer=opt.optimizer, loss="binary_crossentropy")
除了DSSM原生loss不少博客下面推荐 Infocse loss和Triplet loss这种纯pairwise loss,因为对于召回来说这种loss很适合尽可能拉近用户和正样本物料在高维空间的距离而拉远用户与负样本物料的距离, 而且他们关注困难负样本和困难负样本配合起来用会更好。
InfoNCE Loss
我个人理解下来就是DSSM原生loss加上一个温度系数, 会给和正样本相似度更高的负样本比较高的惩罚,我们 实验了多种温度系数没有明显的效果.
Triplet loss
Triplet loss可分为三类, easy triplets, hard triplets和semi-hard triplets
具体可以看链接中的原理知识
具体实验中, 这个loss中有margin需要设置,负样本越偏向困难负样本越需要设置大一点。具体实验中尝试了triplet loss, 指标提升了一些但是不是很明显,可能其中参数margin值需要调参。
闲鱼搜索召回升级:向量召回&个性化召回 提出了triplet loss和online hard negative mining匹配用,即在mini-batch内,每个正样本对之间互为负样本,选择其中最难的样本作为负样本(相似度打分最高的样本对)。
相关代码实现:
user_emb,item_emb,neg_sampling = inputs
user_emb = tf.nn.l2_normalize(user_emb,-1)
item_emb = tf.nn.l2_normalize(item_emb,-1)
NEG = 4
batchSize = 1024
item_y = item_emb
item_y_temp = item_emb
user_y = user_emb
# shape为[batchSize, emb_size]
for i in range(NEG):
rand = int((random.random() + i) * batchSize / NEG)
item_tmp = tf.concat([tf.slice(item_y_temp, [rand, 0], [batchSize - rand, -1]),tf.slice(item_y_temp, [0, 0], [rand, -1])], 0)
item_y = tf.concat([item_y,item_tmp], 1)
x = tf.concat([user_y, item_y],1)
return x
model.compile(optimizer=opt.optimizer, loss=triplet_loss)
def triplet_loss(y_true, y_pred):
emb_size = 32
alpha = 1.0
anchor, positive, negative1, negative2, negative3, negative4 = y_pred[:,:emb_size], y_pred[:,emb_size:2*emb_size], y_pred[:,2*emb_size:3*emb_size], y_pred[:,3*emb_size:4*emb_size], y_pred[:,4*emb_size:5*emb_size], y_pred[:,5*emb_size:]
positive_dist = tf.reduce_mean(tf.square(anchor - positive), axis=1)
negative_dist1 = tf.reduce_mean(tf.square(anchor - negative1), axis=1)
negative_dist2 = tf.reduce_mean(tf.square(anchor - negative2), axis=1)
negative_dist3 = tf.reduce_mean(tf.square(anchor - negative3), axis=1)
negative_dist4 = tf.reduce_mean(tf.square(anchor - negative4), axis=1)
negative_dist = tf.concat([negative_dist1, negative_dist2, negative_dist3, negative_dist4], 0)
positive_dist_final = tf.tile(positive_dist, [4, ])
return tf.maximum(positive_dist_final - negative_dist + alpha, 0.)
据说BPR loss效果比triplet loss好,而且它没有需要调参的地方。没有ma rain,鼓励用户和正样本物料相对和负样本物料距离拉的越远越好。可以之后尝试一下。
在模型化召回在陌陌社交推荐的应用和探索
中提到Weighted-Hinge-Loss,更适合ANN检索,可以一试。
其他相关链接:
链接1: 排序主要的三种损失函数(pointwise、pairwise、listwise)
链接2: 【推荐】pairwise、pointwise 、 listwise算法是什么?怎么理解?主要区别是什么?
链接3: 负样本为王:评Facebook的向量化召回算法
DSSM 是召回的经典模型虽然比较简单古老但正因为简单所以上线方便快捷,因此有很多改进空间。
其中第一个是张俊林老师提出的将SENet运用在 双塔模型, 具体可以参考文章SENet双塔模型:在推荐领域召回粗排的应用及其它
众所周知,DSSM双塔的优势就是用户侧和物料侧特征解藕,两个塔分别输出一个向量, 然后线上用Faiss这种工具做实时ANN检索, 对召回面向海量物料需要预测的情况下能够有毫秒级别反馈。
但是这种优点也是缺点, 用户特征和物料特征分别经过层层DNN压缩成低维向量,最后才在Cos层交叉, 相比排序模型或者FM召回 用户侧特征和物料侧特征在浅层网络就进行交叉相比显得为时已晚而且丢失了很多信息再交叉。
而SENet换了种思维解决这一问题, 对User侧和Item侧的特征,动态学习特征权重,强化那些重要特征,弱化甚至清除掉不重要的特征。 这样就算只在最后一层交叉, 也尽可能让重要信息进行交叉。
SENet原文里面,对每个特征embedding压缩是使用CNN的二维卷积核进行Max操作, 而张俊林老师的知乎里面是使用平均迟化。 而我这里是使用一个MLP网络把原来32维的向量映射8再到1,作为乘到原始向量上的权重值。
代码实现如下:
# SE-BLOCK
user_profile_emb_reshape = tf.reshape(user_profile_emb,(-1, user_feature_len, embedding_size), name='user_profile_emb_reshape') # (?,1152)->(?,36, 32)
item_profile_emb_reshape = tf.reshape(item_profile_emb,(-1, item_feature_len, embedding_size), name='item_profile_emb_reshape') # (?,2880)->(?,90, 32)
user_profile_emb_excitation = Dense(embedding_size//4, activation='sigmoid', use_bias=False, name='user_profile_emb_excitation_0')(user_profile_emb_reshape) # (?,36, 32) ->(?,36, 8)
item_profile_emb_excitation = Dense(embedding_size//4, activation='sigmoid', use_bias=False, name='item_profile_emb_excitation_0')(item_profile_emb_reshape) # (?,90, 32) ->(?,36, 8)
user_profile_emb_excitation = Dense(1, activation='sigmoid', use_bias=False, name='user_profile_emb_excitation_1')(user_profile_emb_excitation) # (?,36, 8) ->(?,36, 1)
item_profile_emb_excitation = Dense(1, activation='sigmoid', use_bias=False, name='item_profile_emb_excitation_1')(item_profile_emb_excitation) # (?,90, 8) ->(?,36, 1)
user_profile_emb_senet = multiply([user_profile_emb_reshape,user_profile_emb_excitation], name='user_profile_emb_senet') # (?,36, 32) ->(?,36, 32)
item_profile_emb_senet = multiply([item_profile_emb_reshape,item_profile_emb_excitation], name='item_profile_emb_senet') # (?,90, 32) ->(?,90, 32)
user_profile_emb_senet_final = tf.reshape(user_profile_emb_senet, (-1, user_feature_len*embedding_size), name='user_profile_emb_senet_final') # (?,36, 32) ->(?,1152)
item_profile_emb_senet_final = tf.reshape(item_profile_emb_senet, (-1, item_feature_len*embedding_size), name='item_profile_emb_senet_final') #(?,90, 32) ->(?,2880)
曾经做排序的时候做过一段时间多任务学习MMOE,PLE等, 多任务学习主要解决的任务第一是满足业务上的同时多个指标, 第二就是解决某个任务数据稀疏的问题,用一个辅助任务去帮助主要任务去训练(比如点击正样本比较多帮助互动正样本少的任务训练)
召回也有多任务学习, 在这边还没有具体尝试,先放上链接和图片。
QQ浏览器:小说召回中的DSSM模型优化实践 https://www.sohu.com/a/447529493_187948
现实业务中有一些重要的特征,比如用户活跃度,物料出现在列表里的位置,可以像阿里position bias的想法一样, 把这些强特征单独经过一个网络(通过一个LR或一层fully connection)和和主DNN最后的输出再拼接起来。 在排序中是将这两部分的logits 加和在一起,召回双塔模型中我认为可以组成两部分向量后concentrate在一起。
另一种更高级的做法,就是石塔西前辈提到的动态权重 动态权重:推荐算法的新范式
。 将强特征输出后的向量reshape成一个三层网络Dynamic Weighted Network(DWN)后拼接在双塔DNN上,这种方式我觉得对于DSSM更合适,值得一试。
相关链接:
链接1: 动态权重:推荐算法的新范式
链接2: 初来乍到:帮助新用户冷启的算法技巧
链接3: 推荐系统中的debias算法
对比学习是这几年比较火热的点, 也可以运用在双塔上。 但是这其中的难点在于辅助任务构建data augumentation上。具体可以看石塔西和张俊林两位前辈的文章。目前还没来得及在业务上尝试。
链接1: 对比学习视角:重新审视推荐系统的召回粗排模型
链接2: 少数派报告:谈推荐场景下的对比学习
其他优化方式还有很多就不一一展开了,其中特征常规操作可以加上用户序列类特征,博主/up主id特征, 用户id,物料id。
另外就是改变label是影响线上效果最直接的方式,比如你的目标是提升DAU,UV,可以尝试互动, 点击,时长的各种组合。而且, 对label设定进行reweight其实也是改变训练样本分布的一种, 这里有很多文章可以参考。这里给出蘑菇街的一篇文章蘑菇街首页推荐多目标优化之reweight实践:一把双刃剑?
评估的方式最好的肯定是ab线上评估,不过受制于流量有限或者其他因素,线上评估是比较慢的。
排序的离线评估无疑首选是AUC,但是对于召回来说不一定, 实际上好的召回可能auc低一些,但是会召回出更符合真实分布的内容,实际工作中auc当作参考就好。
最适合的还是模拟召回这一过程, 拿Faiss召回后的TopK结果与用户实际点击做交集并计算precision/recall, 我在实际运用中计算得到召回率和命中率,其中召回率是有多少用户点击物料实际被TopK召回了, 比如用户日志里点击了5条物料,有3条出现在Faiss召回TopK中那么召回率便为0.6。 另外, 假如5条全中那么命中率就为1。 最后求所有用户平均值。
当然对于召回来说还有用肉眼去观察效果。
链接1: 召回模型中的负样本构造
链接2: 「召回层」如何评估召回效果?
1, 推荐粗排(召回)工程实践之双塔DNN模型
https://mp.weixin.qq.com/s/w-J_hz1Qf3Y-Kc8ywx9kUg
2, 干货篇 | 58 同城:向量化召回上的深度学习实践
https://www.6aiq.com/article/1618011600160
负采样筛选同城的帖子为负样本, 同样可以筛选同兴趣标签的为负样本。
用户点击标签的标签embedding lookup table和 标签embedding lookup table可以share,但应该实验可能不在一个空间
3, 小米收音机如何提高内容分发效率?DSSM召回模型做到了!
https://mp.weixin.qq.com/s/bDIrbA7GmLxgPFYzA1uijg
4, 【推荐系统】DSSM双塔召回
https://blog.csdn.net/weixin_31866177/article/details/115941316
序列特征attention
5, 借Youtube论文,谈谈双塔模型的八大精髓问题
https://zhuanlan.zhihu.com/p/369152684
6, 双塔召回模型的前世今生(上篇)
https://zhuanlan.zhihu.com/p/430503952
7,双塔召回模型的前世今生(下篇)
https://zhuanlan.zhihu.com/p/441597009
8,模型化召回在陌陌社交推荐的应用和探索
https://mp.weixin.qq.com/s/tZOcfz9u3c_Z8SU1EhTDUg
weighted-hinge-loss
9, QQ音乐推荐召回算法的探索与实践
https://mp.weixin.qq.com/s/I2ZOsh63-ApXDgK5hbQ1yQ
10, 闲鱼搜索召回升级:向量召回&个性化召回
https://mp.weixin.qq.com/s/bXGWnXiIOYYFaXbghZNKUA