DSSM---YoutobeDNN
DSSM--双塔模型
DSSM 是由微软研究院于 CIKM 在 2013 年提出的一篇工作,该模型主要用来解决 NLP 领域语义相似度任务,利用深度神经网络将文本表示为低维度的向量,用来提升搜索场景下文档和 query 匹配的问题。
因为模型分为 Query 和 Documents 两部分,在推荐场景中也可对应 user 和 item 部分,被人形象地成为 “双塔”。DSSM 是经典的双塔模型。
其中,原始输入 x 是初始文本,使用词袋模型或 TF-IDF 模型来表示。毫无疑问,x 的维度会很大,因为要囊括所有可能的词汇。
为了降维以降低模型复杂度和计算量,论文提出了 Word hashing 方法,也就是图中的第一层。该方法实际上是将基于 Word 的表征转换为基于 letter n-grams 的表征,比如将单词 "good" 转换为 letter trigrams: #go, goo, ood, od#。对英文来说,word 几乎是无限的,可能不断增长,但是 letter n-grams 是有限的,能够很好地降维、并且对那些 unseen words 或者拼写错误的单词有很好的应对能力。存在的缺点是,有可能两个不同的单词会有相同的 letter n-grams 分布。但是实验证明,这种缺点带来的负面影响可以忽略不计,如下表所示。
这种方法也可以用在中文场景,将基于词汇(word)的文本模型转换为基于字(char)的文本模型。
另外值得强调的是,DSSM 模型优化过程中的损失函数设置和负样本生成。
模型的训练数据来自 clickthrough 日志,它由查询列表及其用户点击过的文档组成。我们假设一个查询与随后用户点击过的文档相关,至少部分相关。模型使用有监督的方法来训练,最大化给定查询下点击文档的条件可能性。
给定查询下,候选文档被点击的概率如下所示:
其中,γ 是 softmax 函数中的平滑因子,该因子是在我们的实验中根据经验在 held-out 数据集上设置的。
我们希望在训练模型后,真实被点击的文档得到的分数高,未被点击的文档得到的分数低。因此,我们需要正负样本来帮助训练。理想情况下,未被点击的文档都应该算作负样本,但这在计算上是不现实的。实际的做法是随机选择指定数量的未被点击文档作为负样本(论文中设定为4)。模型的训练目标(最小化)如下所示:
class DSSM(torch.nn.Module): """Deep Structured Semantic Model Args: user_features (list[Feature Class]): training by the user tower module. item_features (list[Feature Class]): training by the item tower module. temperature (float): temperature factor for similarity score, default to 1.0. user_params (dict): the params of the User Tower module, keys include:`{"dims":list, "activation":str, "dropout":float, "output_layer":bool`}. item_params (dict): the params of the Item Tower module, keys include:`{"dims":list, "activation":str, "dropout":float, "output_layer":bool`}. """ def __init__(self, user_features, item_features, user_params, item_params, temperature=1.0): super().__init__() self.user_features = user_features self.item_features = item_features self.temperature = temperature self.user_dims = sum([fea.embed_dim for fea in user_features]) self.item_dims = sum([fea.embed_dim for fea in item_features]) self.embedding = EmbeddingLayer(user_features + item_features) self.user_mlp = MLP(self.user_dims, output_layer=False, **user_params) self.item_mlp = MLP(self.item_dims, output_layer=False, **item_params) self.mode = None def forward(self, x): user_embedding = self.user_tower(x) item_embedding = self.item_tower(x) if self.mode == "user": return user_embedding if self.mode == "item": return item_embedding # calculate cosine score y = torch.mul(user_embedding, item_embedding).sum(dim=1) # y = y / self.temperature return torch.sigmoid(y) def user_tower(self, x): if self.mode == "item": return None input_user = self.embedding(x, self.user_features, squeeze_dim=True) #[batch_size, num_features*deep_dims] user_embedding = self.user_mlp(input_user) #[batch_size, user_params["dims"][-1]] user_embedding = F.normalize(user_embedding, p=2, dim=1) # L2 normalize return user_embedding def item_tower(self, x): if self.mode == "user": return None input_item = self.embedding(x, self.item_features, squeeze_dim=True) #[batch_size, num_features*embed_dim] item_embedding = self.item_mlp(input_item) #[batch_size, item_params["dims"][-1]] item_embedding = F.normalize(item_embedding, p=2, dim=1) return item_embedding
YoutobeDNN--双塔模型
YouTube 是全球最大的视频分享和观看平台,服务超过十亿用户,也存在着很严重的信息过载问题。因此,YouTube 的推荐系统是非常重要的。目前,YouTube 视频推荐系统主要面临以下三个挑战:
-
Scale(规模): 视频数量非常庞大,大规模数据下需要分布式学习算法以及高效的线上服务系统(此时,很多在小规模数据中工作良好的推荐算法不再适用)。
-
Freshness(新鲜度): YouTube上的视频一直在动态变化,每秒钟都有很多用户去上传新视频。用户一般都比较喜欢看比较新的视频,而不管是不是真和用户相关(这个感觉和新闻比较类似)。
-
Noise(噪声): 由于数据的稀疏和不可见的其他原因,数据里面的噪声非常之多,很难获取用户对观看过的视频的满意度或隐式反馈。
召回(Candidate generation)
论文把推荐问题转换为一个多分类问题:根据用户 U 和上下文 C,预测在时刻 t 观看视频 i 的概率。
式子中,u 指的是用户 U 和上下文 C 的 Embedding,v 指的是候选视频项目的 Embedding。深度学习的任务是学习一个函数(参数估计),怎么根据用户的历史记录和上下文生成当前的 Embedding。
另外,YouTube 不使用点赞等用户显式反馈作为用户满意度的信号,而是使用用户是否观看完视频的隐式反馈作为用户满意度信号。因为后者有更多的可用数据(很多视频用户感兴趣,看完了,但因为一些原因不愿点赞),前者的数据稀疏性较为严重。
召回阶段的模型架构如下所示(可以理解为一个 DNN):
上图中,搜索历史(search history)指用户在搜索中输入的 Query。Query 将被分词为 unigrames 和 bigrams,并且每个词汇都会转变为 Embedding 进行处理(和 watch history 类似)。用户的基本信息,比如年龄、地区、性别等,可使用binary或者嵌入的方式表征,得到的表征与其他的拼接在一起。
“example age” 是和场景比较相关的特征,也是作者的经验传授。 我们知道,视频有明显的生命周期,例如刚上传的视频比之后更受欢迎,也就是用户往往喜欢看最新的东西,而不管它是不是和用户相关,所以视频的流行度随着时间的分布是高度非稳态变化的(下面图中的绿色曲线)。
"example age" 定义为 tmax−t 其中 tmax 是训练数据中所有样本的时间最大值,而 t 为当前样本的时间。线上预测时, 直接把example age全部设为0或一个小的负值,这样就不依赖于各个视频的上传时间了。
精排(Ranking)
class YoutubeDNN(torch.nn.Module): """The match model mentioned in `Deep Neural Networks for YouTube Recommendations` paper. It's a DSSM match model trained by global softmax loss on list-wise samples. Note in origin paper, it's without item dnn tower and train item embedding directly. Args: user_features (list[Feature Class]): training by the user tower module. item_features (list[Feature Class]): training by the embedding table, it's the item id feature. neg_item_feature (list[Feature Class]): training by the embedding table, it's the negative items id feature. user_params (dict): the params of the User Tower module, keys include:`{"dims":list, "activation":str, "dropout":float, "output_layer":bool`}. temperature (float): temperature factor for similarity score, default to 1.0. """ def __init__(self, user_features, item_features, neg_item_feature, user_params, temperature=1.0): super().__init__() self.user_features = user_features self.item_features = item_features self.neg_item_feature = neg_item_feature self.temperature = temperature self.user_dims = sum([fea.embed_dim for fea in user_features]) self.embedding = EmbeddingLayer(user_features + item_features) self.user_mlp = MLP(self.user_dims, output_layer=False, **user_params) self.mode = None def forward(self, x): user_embedding = self.user_tower(x) item_embedding = self.item_tower(x) if self.mode == "user": return user_embedding if self.mode == "item": return item_embedding # calculate cosine score y = torch.mul(user_embedding, item_embedding).sum(dim=2) y = y / self.temperature return y def user_tower(self, x): if self.mode == "item": return None input_user = self.embedding(x, self.user_features, squeeze_dim=True) #[batch_size, num_features*deep_dims] user_embedding = self.user_mlp(input_user).unsqueeze(1) #[batch_size, 1, embed_dim] user_embedding = F.normalize(user_embedding, p=2, dim=2) if self.mode == "user": return user_embedding.squeeze(1) #inference embedding mode -> [batch_size, embed_dim] return user_embedding def item_tower(self, x): if self.mode == "user": return None pos_embedding = self.embedding(x, self.item_features, squeeze_dim=False) #[batch_size, 1, embed_dim] pos_embedding = F.normalize(pos_embedding, p=2, dim=2) if self.mode == "item": #inference embedding mode return pos_embedding.squeeze(1) #[batch_size, embed_dim] neg_embeddings = self.embedding(x, self.neg_item_feature, squeeze_dim=False).squeeze(1) #[batch_size, n_neg_items, embed_dim] neg_embeddings = F.normalize(neg_embeddings, p=2, dim=2) return torch.cat((pos_embedding, neg_embeddings), dim=1) #[batch_size, 1+n_neg_items, embed_dim]