This commit is contained in:
2024-08-10 19:46:55 +08:00
commit 2233526534
798 changed files with 35282 additions and 0 deletions

View File

@@ -0,0 +1,332 @@
# 前言
这是 Airbnb 于2018年发表的一篇论文主要介绍了 Airbnb 在 Embedding 技术上的应用,并获得了 KDD 2018 的 Best Paper。Airbnb 是全球最大的短租平台,包含了数百万种不同的房源。这篇论文介绍了 Airbnb 如何使用 Embedding 来实现相似房源推荐以及实时个性化搜索。在本文中Airbnb 在用户和房源的 Embedding 上的生成都是基于谷歌的 Word2Vec 模型,<u>故阅读本文要求大家了解 Word2Vec 模型,特别是 Skip-Gram 模型**(重点***</u>。
本文将从以下几个方面来介绍该论文:
- 了解 Airbnb 是如何利用 Word2Vec 技术生成房源和用户的Embedding并做出了哪些改进。
- 了解 Airbnb 是如何利用 Embedding 解决房源冷启动问题。
- 了解 Airbnb 是如何衡量生成的 Embedding 的有效性。
- 了解 Airbnb 是如何利用用户和房源 Embedding 做召回和搜索排序。
考虑到本文的目的是为了让大家快速了解 Airbnb 在 Embedding 技术上的应用故不会完全翻译原论文。如需进一步了解建议阅读原论文或文末的参考链接。原论文链接https://dl.acm.org/doi/pdf/10.1145/3219819.3219885
# Airbnb 的业务背景
在介绍 Airbnb 在 Embedding 技术上的方法前,先了解 Airbnb 的业务背景。
- Airbnb 平台包含数百万种不同的房源,用户可以通过**浏览搜索结果页面**来寻找想要的房源。Airbnb 技术团队通过复杂的机器学习模型,并使用上百种信号对搜索结果中的房源进行排序。
- 当用户在查看某一个房源时,接下来的有两种方式继续搜索:
- 返回搜索结果页,继续查看其他搜索结果。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653049527431-0b09af70-bda0-4a30-8082-6aa69548213a.png" alt="img" style="zoom:50%;" />
- 在当前房源的详情页下,「相似房源」板块(你可能还喜欢)所推荐的房源。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653049385995-7a775df1-a36f-4795-9e79-8e577bcf2097.png" alt="img" style="zoom:50%;" />
- Airbnb 平台 99% 的房源预订来自于搜索排序和相似房源推荐。
# Embedding 方法
Airbnb 描述了两种 Embedding 的构建方法,分别为:
- 用于描述短期实时性的个性化特征 Embedding**listing Embeddings**
- **listing 表示房源的意思,<u>它将贯穿全文,请务必了解</u>。**
- 用于描述长期的个性化特征 Embedding**user-type & listing type Embeddings**
## Listing Embeddings
Listing Embeddings 是基于用户的点击 session 学习得到的,用于表示房源的短期实时性特征。给定数据集 $ \mathcal{S} $ ,其中包含了 $ N $ 个用户的 $ S $ 个点击 session序列
- 每个 session $ s=\left(l_{1}, \ldots, l_{M}\right) \in \mathcal{S} $ ,包含了 $ M $ 个被用户点击过的 listing ids 。
- 对于用户连续两次点击若时间间隔超过了30分钟则启动新的 session。
在拿到多个用户点击的 session 后,可以基于 Word2Vec 的 Skip-Gram 模型来学习不同 listing 的 Embedding 表示。最大化目标函数 $ \mathcal{L} $
$$
\mathcal{L}=\sum_{s \in \mathcal{S}} \sum_{l_{i} \in s}\left(\sum_{-m \geq j \leq m, i \neq 0} \log \mathbb{P}\left(l_{i+j} \mid l_{i}\right)\right)
$$
概率 $ \mathbb{P}\left(l_{i+j} \mid l_{i}\right) $ 是基于 soft-max 函数的表达式。表示在一个 session 中,已知中心 listing $ l_i $ 来预测上下文 listing $ l_{i+j} $ 的概率:
$$
\mathbb{P}\left(l_{i+j} \mid l_{i}\right)=\frac{\exp \left(\mathbf{v}_{l_{i}}^{\top} \mathbf{v}_{l_{i+j}}^{\prime}\right)}{\sum_{l=1}^{|\mathcal{V}|} \exp \left(\mathbf{v}_{l_{i}}^{\top} \mathbf{v}_{l}^{\prime}\right)}
$$
- 其中, $ \mathbf{v}_{l_{i}} $ 表示 listing $ l_i $ 的 Embedding 向量, $ |\mathcal{V}| $ 表示全部的物料库的数量。
考虑到物料库 $ \mathcal{V} $ 过大,模型中参数更新的时间成本和 $ |\mathcal{V}| $ 成正比。为了降低计算复杂度,要进行负采样。负采样后,优化的目标函数如下:
$$
\underset{\theta}{\operatorname{argmax}} \sum_{(l, c) \in \mathcal{D}_{p}} \log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime^{\prime}} \mathbf{v}_{l}}}+\sum_{(l, c) \in \mathcal{D}_{n}} \log \frac{1}{1+e^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{l}}}
$$
至此,对 Skip-Gram 模型和 NEG 了解的同学肯定很熟悉,上述方法和 Word2Vec 思想基本一致。
下面,将进一步介绍 Airbnb 是如何改进 Listing Embedding 的学习以及其他方面的应用。
**1正负样本集构建的改进**
- 使用 booked listing 作为全局上下文
- booked listing 表示用户在 session 中最终预定的房源,一般只会出现在结束的 session 中。
- Airbnb 将最终预定的房源,始终作为滑窗的上下文,即全局上下文。如下图:
- 如图,对于当前滑动窗口的 central listing实线箭头表示context listings虚线指向booked listing表示 global context listing。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653053823336-0564b2da-c993-46aa-9b22-f5cbb784dae2.png" alt="img" style="zoom:50%;" />
- booked listing 作为全局正样本,故优化的目标函数更新为:
$$
\underset{\theta}{\operatorname{argmax}} \sum_{(l, c) \in \mathcal{D}_{p}} \log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime^{\prime}} \mathbf{v}_{l}}}+\sum_{(l, c) \in \mathcal{D}_{n}} \log \frac{1}{1+e^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{l}}} +
\log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime} \mathbf{v}_{l_b}}}
$$
- 优化负样本的选择
- 用户通过在线网站预定房间时,通常只会在同一个 market (将要停留区域)内进行搜索。
- 对于用户点击过的样本集 $ \mathcal{D}_{p} $ (正样本集)而言,它们大概率位于同一片区域。考虑到负样本集 $ \mathcal{D}_{n} $ 是随机抽取的,大概率来源不同的区域。
- Airbnb 发现这种样本的不平衡,在学习同一片区域房源的 Embedding 时会得到次优解。
- 解决办法也很简单,对于每个滑窗中的中心 lisitng其负样本的选择新增了与其位于同一个 market 的 listing。至此优化函数更新如下
$$
\underset{\theta}{\operatorname{argmax}} \sum_{(l, c) \in \mathcal{D}_{p}} \log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime^{\prime}} \mathbf{v}_{l}}}+\sum_{(l, c) \in \mathcal{D}_{n}} \log \frac{1}{1+e^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{l}}} +\log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime} \mathbf{v}_{l_b}}} +
\sum_{(l, m_n ) \in \mathcal{D}_{m_n}} \log \frac{1}{1+e^{\mathbf{v}_{m_n}^{\prime} \mathbf{v}_{l}}}
$$
+ $ \mathcal{D}_{m_n} $ 表示与滑窗中的中心 listing 位于同一区域的负样本集。
**2Listing Embedding 的冷启动**
- Airbnb 每天都有新的 listings 产生,而这些 listings 却没有 Embedding 向量表征。
- Airbnb 建议利用其他 listing 的现有的 Embedding 来为新的 listing 创建 Embedding。
- 在新的 listing 被创建后,房主需要提供如位置、价格、类型等在内的信息。
- 然后利用房主提供的房源信息为其查找3个相似的 listing并将它们 Embedding 的均值作为新 listing 的 Embedding表示。
- 这里的相似包含了位置最近10英里半径内房源类型相似价格区间相近。
- 通过该手段Airbnb 可以解决 98% 以上的新 listing 的 Embedding 冷启动问题。
**3Listing Embedding 的评估**
经过上述的两点对 Embedding 的改进后,为了评估改进后 listing Embedding 的效果。
- Airbnb 使用了800万的点击 session并将 Embedding 的维度设为32。
评估方法包括:
- 评估 Embedding 是否包含 listing 的地理位置相似性。
- 理论上,同一区域的房源相似性应该更高,不同区域房源相似性更低。
- Airbnb 利用 k-means 聚类将加利福尼亚州的房源聚成100个集群来验证类似位置的房源是否聚集在一起。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653056809526-15401069-6fff-40d8-ac5e-35871d3f254a.png" alt="img" style="zoom:50%;" />
- 评估不同类型、价格区间的房源之间的相似性。
- 简而言之,我们希望类型相同、价格区间一致的房源它们之间的相似度更高。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653056981037-18edee91-493a-4d5b-b066-57f0b200032d.png" alt="img" style="zoom:50%;" />
- 评估房源的隐式特征
- Airbnb 在训练房源listing的 Embedding时并没有用到房源的图像信息。
- 对于一些隐式信息,例如架构、风格、观感等是无法直接学习。
- 为了验证基于 Word2Vec 学习到的 Embedding是否隐含了它们在外观等隐式信息上的相似性Airbnb 内部开发了一款内部相似性探索工具。
- 大致原理就是,利用训练好的 Embedding 进行 K 近邻相似度检索。
- 如下,与查询房源在 Embedding 相似性高的其他房源,它们之间的外观风格也很相似。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653057336798-fd8451cb-84b6-40fb-8733-1e3d08a39793.png" alt="img" />
## User-type & Listing-type Embedding
前面提到的 Listing Embedding它是基于用户的点击 sessions 学习得到的。
- 同一个 session 内的点击时间间隔低于30分钟所以**它们更适合短期session 内的个性化需求**。
- 在用户搜索 session 期间,该方法有利于向用户展示与点击过的 listing 更相似的其他 listings 。
Airbnb 除了挖掘 Listing 的短期兴趣特征表示外,还对 User 和 Listing 的长期兴趣特征表示进行了探索。长期兴趣的探索是有利于 Airbnb 的业务发展。例如,用户当前在洛杉矶进行搜索,并且过去在纽约和伦敦预定过其他房源。那么,向用户推荐与之前预定过的 listing 相似的 listings 是更合适的。
- 长期兴趣的探索是基于 booking session用户的历史预定序列
- 与前面 Listing Embedding 的学习类似Airbnb 希望借助了 Skip-Gram 模型学习不同房源的 Embedding 表示。
但是,面临着如下的挑战:
- booking sessions $ \mathcal{S}_{b} $ 数据量的大小远远小于 click sessions $ \mathcal{S} $ ,因为预定本身就是一件低频率事件。
- 许多用户过去只预定了单个数量的房源无法从长度为1的 session 中学习 Embedding
- 对于任何实体,要基于 context 学习到有意义的 Embedding该实体至少在数据中出现5-10次。
- 但平台上大多数 listing_ids 被预定的次数低于5-10次。
- 用户连续两次预定的时间间隔可能较长,在此期间用户的行为(如价格敏感点)偏好可能会发生改变(由于职业的变化)。
为了解决该问题Airbnb 提出了基于 booking session 来学习用户和房源的 Type Embedding。给定一个 booking sessions 集合 $ \mathcal{S}_{b} $ ,其中包含了 $ M $ 个用户的 booking session
- 每个 booking session 表示为: $ s_{b}=\left(l_{b 1}, \ldots, l_{b M}\right) $
- 这里 $ l_{b1} $ 表示 listing_id学习到 Embedding 记作 $ \mathbf{v}_{l_{i d}} $
**1什么是Type Embedding **
在介绍 Type Embedding 之前,回顾一下 Listing Embedding
- 在 Listing Embedding 的学习中,只学习房源的 Embedding 表示,未学习用户的 Embedding。
- 对于 Listing Embedding与相应的 Lisitng ID 是一一对应的, 每个 Listing 它们的 Embedding 表示是唯一的。
对于 Type Embedding ,有如下的区别:
- 对于不同的 Listing它们的 Type Embedding **可能是相同的**User 同样如此)。
- Type Embedding 包含了 User-type Embedding 和 Listing-type Embedding。
为了更直接快速地了解什么是 Listing-type 和 User-type举个简单的例子
- 小王是一名西藏人性别男今年21岁就读于中国山东的蓝翔技校的挖掘机专业。
- 通常,对于不同的用户(如小王),给定一个 ID 编码,然后学习相应的 User Embedding。
- 但前面说了,用户数据过于稀疏,学习到的 User Embedding 特征表达能力不好。
- 另一种方式:利用小王身上的用户标签,先组合出他的 User-type然后学习 Embedding 表示。
- 小王的 User-type西藏人_男_学生_21岁_位置中国山东_南翔技校_挖掘机专业。
- 组合得到的 User-type 本质上可视为一个 Category 特征,然后学习其对应的 Embedding 表示。
下表给出了原文中Listing-type 和 User-type 包含的属性及属性的值:
- 所有的属性都基于一定的规则进行了分桶buckets。例如21岁被分桶到 20-30 岁的区间。
- 对于首次预定的用户,他的属性为 buckets 的前5行因为预定之前没有历史预定相关的信息。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653125260611-7d33731b-9167-4fcc-b83b-0a2407ea89ca.png" alt="img" style="zoom: 67%;" />
看到过前面那个简单的例子后,现在可以看一个原文的 Listing-type 的例子:
- 一个来自 US 的 Entire Home listinglt1它是一个二人间c21 床b1一个卧室bd21 个浴室bt2每晚平均价格为 60.8 美元pn3每晚每个客人的平均价格为 29.3 美元pg35 个评价r3所有均 5 星好评5s4100% 的新客接受率nu3
- 因此该 listing 根据上表规则可以映射为Listing-type = US_lt1_pn3_pg3_r3_5s4_c2_b1_bd2_bt2_nu3。
**2Type Embedding 的好处**
前面在介绍 Type Embedding 和 Listing Embedding 的区别时,提到过不同 User 或 Listing 他们的 Type 可能相同。
- 故 User-type 和 Listing-type 在一定程度上可以缓解数据稀疏性的问题。
- 对于 user 和 listing 而言,他们的属性可能会随着时间的推移而变化。
- 故它们的 Embedding 在时间上也具备了动态变化属性。
**3Type Embedding 的训练过程**
Type Embedding 的学习同样是基于 Skip-Gram 模型,但是有两点需要注意:
- 联合训练 User-type Embedding 和 Listing-type Embedding
- 如下图a在 booking session 中,每个元素代表的是 User-type, Listing-type组合。
- 为了学习在相同向量空间中的 User-type 和 Listing-type 的 EmbeddingsAirbnb 的做法是将 User-type 插入到 booking sessions 中。
- 形成一个User-type, Listing-type组成的元组序列这样就可以让 User-type 和 Listing-type 的在 session 中的相对位置保持一致了。
- User-type 的目标函数:
$$
\underset{\theta}{\operatorname{argmax}} \sum_{\left(u_{t}, c\right) \in \mathcal{D}_{b o o k}} \log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime} \mathbf{v}_{u_{t}}}}+\sum_{\left(u_{t}, c\right) \in \mathcal{D}_{n e g}} \log \frac{1}{1+e^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{u_{t}}}}
$$
+ $ \mathcal{D}_{\text {book }} $ 中的 $ u_t $ (中心词)表示 User-type $ c $ (上下文)表示用户最近的预定过的 Listing-type。 $ \mathcal{D}_{\text {neg}} $ 中的 $ c $ 表示 negative Listing-type。
+ $ u_t $ 表示 User-type 的 Embedding $ \mathbf{v}_{c}^{\prime} $ 表示 Listing-type 的Embedding。
- Listing-type 的目标函数:
$$
\begin{aligned}
\underset{\theta}{\operatorname{argmax}} & \sum_{\left(l_{t}, c\right) \in \mathcal{D}_{b o o k}} \log \frac{1}{1+\exp ^{-\mathrm{v}_{c}^{\prime} \mathbf{v}_{l_{t}}}}+\sum_{\left(l_{t}, c\right) \in \mathcal{D}_{n e g}} \log \frac{1}{1+\exp ^{\mathrm{v}_{c}^{\prime} \mathbf{v}_{l_{t}}}} \\
\end{aligned}
$$
+ 同理,不过窗口中的中心词为 Listing-type 上下文为 User-type。
- Explicit Negatives for Rejections
- 用户预定房源以后,还要等待房源主人的确认,主人可能接受或者拒绝客人的预定。
- 拒接的原因可能包括,客人星级评定不佳,资料不完整等。
- 前面学习到的 User-type Embedding 包含了客人的兴趣偏好Listing-type Embedding 包含了房源的属性特征。
- 但是,用户的 Embedding 未包含更容易被哪类房源主人拒绝的潜语义信息。
- 房源的 Embedding 未包含主人对哪类客人的拒绝偏好。
- 为了提高用户预定房源以后被主人接受的概率。同时降低房源主人拒绝客人的概率。Airbnb 在训练 User-type 和 Listing-type 的 Embedding时将用户预定后却被拒绝的样本加入负样本集中如下图b
- 更新后Listing-type 的目标函数:
$$
\begin{aligned}
\underset{\theta}{\operatorname{argmax}} & \sum_{\left(u_{t}, c\right) \in \mathcal{D}_{b o o k}} \log \frac{1}{1+\exp ^{-\mathbf{v}_{c}^{\prime} \mathbf{v}_{u_{t}}}}+\sum_{\left(u_{t}, c\right) \in \mathcal{D}_{n e g}} \log \frac{1}{1+\exp ^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{u_{t}}}} \\
&+\sum_{\left(u_{t}, l_{t}\right) \in \mathcal{D}_{\text {reject }}} \log \frac{1}{1+\exp ^{\mathrm{v}_{{l_{t}}}^{\prime} \mathrm{v}_{u_{t}}}}
\end{aligned}
$$
- 更新后User-type 的目标函数:
$$
\begin{aligned}
\underset{\theta}{\operatorname{argmax}} & \sum_{\left(l_{t}, c\right) \in \mathcal{D}_{b o o k}} \log \frac{1}{1+\exp ^{-\mathrm{v}_{c}^{\prime} \mathbf{v}_{l_{t}}}}+\sum_{\left(l_{t}, c\right) \in \mathcal{D}_{n e g}} \log \frac{1}{1+\exp ^{\mathrm{v}_{c}^{\prime} \mathbf{v}_{l_{t}}}} \\
&+\sum_{\left(l_{t}, u_{t}\right) \in \mathcal{D}_{\text {reject }}} \log \frac{1}{1+\exp ^{\mathrm{v}^{\prime}_{u_{t}} \mathrm{v}_{l_{t}}}}
\end{aligned}
$$
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653131985447-e033cb39-235b-4f46-9634-3b7faec284be.png" alt="img" style="zoom:50%;" />
# 实验部分
前面介绍了两种 Embedding 的生成方法,分别为 Listing Embedding 和 User-type & Listing-type Embedding。本节的实验部分将会介绍它们是如何被使用的。回顾 Airbnb 的业务背景,当用户查看一个房源时,他们有两种方式继续搜索:返回搜索结果页,或者查看房源详情页的「相似房源」。
## 相似房源检索
在给定学习到的 Listing Embedding通过计算其向量 $ v_l $ 和来自同一区域的所有 Listing 的向量 $ v_j $ 之间的余弦相似度,可以找到给定房源 $ l $ 的相似房源。
- 这些相似房源可在同一日期被预定(如果入住-离开时间已确定)。
- 相似度最高的 $ K $ 个房源被检索为相似房源。
- 计算是在线执行的,并使用我们的分片架构并行进行,其中部分 Embedding 存储在每个搜索机器上。
A/B 测试显示,基于 Embedding 的解决方案使「相似房源」点击率增加了21最终通过「相似房源」产生的预订增加了 4.9%。
## 实时个性化搜索排名
Airbnb 的搜索排名的大致流程为:
- 给定查询 $ q $ ,返回 $ K $ 条搜索结果。
- 基于排序模型 GBDT对预测结果进行排序。
- 将排序后的结果展示给用户。
**1Query Embedding**
原文中似乎并没有详细介绍 Airbnb 的搜索技术,在参考的博客中对他们的 Query Embedding 技术进行了描述。如下:
> Airbnb 对搜索的 Query 也进行了 Embedding和普通搜索引擎的 Embedding 不太相同的是,这里的 Embedding 不是用自然语言中的语料库去训练的,而是用 Search Session 作为关系训练数据,训练方式更类似于 Item2VecAirbnb 中 Queue Embedding 的一个很重要的作用是捕获用户模糊查询与相关目的地的关联,这样做的好处是可以使搜索结果不再仅仅是简单地进行关键字匹配,而是通过更深层次的语义和关系来找到关联信息。比如下图所示的使用 Query Embedding 之前和之后的两个示例Airbnb 非常人性化地在搜索栏的添加了自动补全,通过算法去 “猜想” 用户的真实目的,大大提高了用户的检索体验)
**2特征构建**
对于各查询,给定的训练数据形式为: $ D_s = \left(\mathbf{x}_{i}, y_{i}\right), i=1 \ldots K $ ,其中 $ K $ 表示查询返回的房源数量。
- $ \mathbf{x}_{i} $ 表示第 $ i $ 个房源结果的 vector containing features
- 由 listing featuresuser featuresquery features 以及 cross-features 组成。
- $ y_{i} \in\{0,0.01,0.25,1,-0.4\} $ 表示第 $ i $ 个结果的标签。
- $ y_i=1 $ 表示用户预定了房源,... $ y_i=-0.4 $ 表示房主拒绝了用户。
下面,介绍 Airbnb 是如何利用前面的两种种 Embedding 进行特征构建的。
- 如果用一句话来概括,这些基于 Embedding 的构建特征均为余弦相似度。
- 新构建的特征均为样本 $ \mathbf{x}_{i} $ 特征的一部分。
构建的特征如下表所示:
- 表中的 Embedding Features 包含了8种类型前6种类型的特征计算方式相同。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653139981920-a100085b-007b-4a9c-9edf-74297e9115ae.png" alt="img" style="zoom:50%;" />
**① 基于 Listing Embedding Features 的特征构建**
- Airbnb 保留了用户过去两周6种不同类型的历史行为如下图
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653140202230-1f49e1dd-5c8c-4445-bd0b-9a17788a7b3f.png" alt="img" style="zoom:50%;" />
- 对于每个行为,还要将其按照 market (地域)进行划分。以 $ H_c $ 为例:
- 假如 $ H_c $ 包含了 New YorK 和 Los Angeles 两个 market 的点击记录,则划分为 $ H_c(NY) $ 和 $ H_c(LA) $ 。
- 计算候选房源和不同行为之间的相似度。
- 上述6种行为对应的相似度特征计算方式是相同的以 $ H_c $ 为例:
$$
\operatorname{EmbClickSim}\left(l_{i}, H_{c}\right)=\max _{m \in M} \cos \left(\mathbf{v}_{l_{i}}, \sum_{l_{h} \in m, l_{h} \in H_{c}} \mathbf{v}_{l_{h}}\right)
$$
- 其中, $ M $ 表示 market 的集合。第二项实际上为 Centroid EmbeddingEmbedding 的均值)。
- 除此之外Airbnb 还计算了候选房源的 Embedding 与 latest long click 的 Embedding 之间的余弦相似度。
$$
\operatorname{EmbLastLongClickSim }\left(l_{i}, H_{l c}\right)=\cos \left(\mathbf{v}_{l_{i}}, \mathbf{v}_{l_{\text {last }}}\right)
$$
**② 基于 User-type & Listing-type Embedding Features 的特征构建**
- 对于候选房源 $ l_i $ ,先查到其对应的 Listing-type $ l_t $ ,再找到用户的 User-type $ u_t $ 。
- 最后,计算 $ u_t $ 与 $ l_t $ 对应的 Embedding 之间的余弦相似度:
$$
\text { UserTypeListingTypeSim }\left(u_{t}, l_{t}\right)=\cos \left(\mathbf{v}_{u_{t}}, \mathbf{v}_{l_{t}}\right)
$$
为了验证上述特征的构建是否有效Airbnb 还做了特征重要性排序,如下表:
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653142188111-1975bcc4-22a2-45cf-bff0-2783ecb00a0c.png" alt="img" style="zoom:50%;" />
**3)模型**
特征构建完成后,开始对模型进行训练。
- Airbnb 在搜索排名中使用的是 GBDT 模型,该模型是一个回归模型。
- 模型的训练数据包括数据集 $ \mathcal{D} $ 和 search labels 。
最后,利用 GBDT 模型来预测线上各搜索房源的在线分数。得到预测分数后,将按照降序的方式展现给用户。
# 参考链接
+ [Embedding 在大厂推荐场景中的工程化实践 - 卢明冬的博客 (lumingdong.cn)](https://lumingdong.cn/engineering-practice-of-embedding-in-recommendation-scenario.html#Airbnb)
+ [KDD'2018 Best Paper-Embedding技术在Airbnb实时搜索排序中的应用 (qq.com)](https://mp.weixin.qq.com/s/f9IshxX29sWg9NhSa7CaNg)
+ [再评Airbnb的经典Embedding论文 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/162163054)
+ [Airbnb爱彼迎房源排序中的嵌入(Embedding)技术 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/43295545)

View File

@@ -0,0 +1,629 @@
# 双塔召回模型
---
双塔模型在推荐领域中是一个十分经典的模型无论是在召回还是粗排阶段都会是首选。这主要是得益于双塔模型结构使得能够在线预估时满足低延时的要求。但也是因为其模型结构的问题使得无法考虑到user和item特之间的特征交叉使得影响模型最终效果因此很多工作尝试调整经典双塔模型结构在保持在线预估低延时的同时保证双塔两侧之间有效的信息交叉。下面针对于经典双塔模型以及一些改进版本进行介绍。
## 经典双塔模型
DSSM(Deep Structured Semantic Model)是由微软研究院于CIKM在2013年提出的一篇工作该模型主要用来解决NLP领域语义相似度任务 利用深度神经网络将文本表示为低维度的向量用来提升搜索场景下文档和query匹配的问题。DSSM 模型的原理主要是通过用户搜索行为中query 和 doc 的日志数据通过深度学习网络将query和doc映射到到共同维度的语义空间中通过最大化query和doc语义向量之 间的余弦相似度,从而训练得到隐含语义模型,即 query 侧特征的 embedding 和 doc 侧特征的 embedding进而可以获取语句的低维 语义向量表达 sentence embedding可以预测两句话的语义相似度。模型结构如下所示
<div align=center>
<img src="https://pic4.zhimg.com/v2-7f75cc71f5e959d6efa95289d2f5ac13_r.jpg" style="zoom:45%;"/>
</div>
从上图可以看出该网络结构比较简单是一个由几层DNN组成网络我们将要搜索文本(Query)和要匹配的文本(Document)的 embedding 输入到网络,网络输出为 128 维的向量,然后通过向量之间计算余弦相似度来计算向量之间距离,可以看作每一个 query 和 document 之间相似分数,然后在做 softmax。
而在推荐系统中最为关键的问题是如何做好用户与item的匹配问题因此对于推荐系统中DSSM模型的则是为 user 和 item 分别构建独立的子网络塔式结构利用user和item的曝光或点击日期进行训练最终得到user侧的embedding和item侧的embedding。因此在推荐系统中常见的模型结构如下所示
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220522103456450.png" style="zoom:60%;"/>
</div>
从模型结构上来看主要包括两个部分user侧塔和item侧塔对于每个塔分别是一个DNN结构。通过两侧的特征输入通过DNN模块到user和item的embedding然后计算两者之间的相似度(常用內积或者余弦值,下面会说这两种方式的联系和区别)因此对于user和item两侧最终得到的embedding维度需要保持一致即最后一层全连接层隐藏单元个数相同。
在召回模型中将这种检索行为视为多类分类问题类似于YouTubeDNN模型。将物料库中所有的item视为一个类别因此损失函数需要计算每个类的概率值
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220522110742879.png" style="zoom:60%;"/>
</div>
其中$s(x,y)$表示两个向量的相似度,$P(y|x;\theta)$表示预测类别的概率,$M$表示物料库所有的item。但是在实际场景中由于物料库中的item数量巨大在计算上式时会十分的耗时因此会采样一定的数量的负样本来近似计算后面针对负样本的采样做一些简单介绍。
以上就是推荐系统中经典的双塔模型,之所以在实际应用中非常常见,是因为**在海量的候选数据进行召回的场景下,速度很快,效果说不上极端好,但一般而言效果也够用了**。之所以双塔模型在服务时速度很快,是因为模型结构简单(两侧没有特征交叉),但这也带来了问题,双塔的结构无法考虑两侧特征之间的交互信息,**在一定程度上牺牲掉模型的部分精准性**。例如在精排模型中来自user侧和item侧的特征会在第一层NLP层就可以做细粒度的特征交互而对于双塔模型user侧和item侧的特征只会在最后的內积计算时发生这就导致很多有用的信息在经过DNN结构时就已经被其他特征所模糊了因此双塔结构由于其结构问题先天就会存在这样的问题。下面针对这个问题来看看一下现有模型的解决思路。
## SENet双塔模型
SENet由Momenta在2017年提出当时是一种应用于图像处理的新型网络结构。后来张俊林大佬将SENet引入了精排模型[FiBiNET](https://arxiv.org/abs/1905.09433)中其作用是为了将大量长尾的低频特征抛弃弱化不靠谱低频特征embedding的负面影响强化高频特征的重要作用。那SENet结构到底是怎么样的呢为什么可以起到特征筛选的作用
<div align=center>
<img src="https://camo.githubusercontent.com/ccf54fc4fcac46667d451f22368e31cf86855bc8bfbff40b7675d524bc899ecf/68747470733a2f2f696d672d626c6f672e6373646e696d672e636e2f32303231303730333136313830373133392e706e673f782d6f73732d70726f636573733d696d6167652f77617465726d61726b2c747970655f5a6d46755a33706f5a57356e6147567064476b2c736861646f775f31302c746578745f6148523063484d364c7939696247396e4c6d4e7a5a473475626d56304c336431656d6876626d6478615746755a773d3d2c73697a655f312c636f6c6f725f4646464646462c745f3730237069635f63656e746572" style="zoom:80%;"/>
</div>
从上图可以看出SENET主要分为三个步骤Squeeze, Excitation, Re-weight
- Squeeze阶段我们对每个特征的Embedding向量进行数据压缩与信息汇总即在Embedding维度计算均值
$$z_i = F_{sq}(e_i) = \frac{1}{k} \sum_{t=1}^k e_i^{(t)}$$
其中k表示Embedding的维度Squeeze阶段是将每个特征的Squeeze转换成单一的数值。
- Excitation阶段这阶段是根据上一阶段得到的向量进行缩放即将上阶段的得到的 $1 \times f$ 的向量$Z$先压缩成 $1 \times \frac{f}{r}$ 长度,然后在放回到 $1 \times f$ 的维度,其中$r$表示压缩的程度。这个过程的具体操作就是经过两层DNN。
$$A = F_{ex}(Z) = \sigma_2(W_2\sigma_1(W_1Z)) $$
该过程可以理解为对于当前所有输入的特征通过相互发生关联来动态地判断哪些特征重要哪些特征不重要而这体现在Excitation阶段的输出结果 $A$,其反应每个特征对应的重要性权重。
- Re-weight阶段是将Excitation阶段得到的每个特征对应的权重 $A$ 再乘回到特征对应的Embedding里就完成了对特征重要性的加权操作。
$$V=F_{ReWeight }(A,E)=[a_1 \cdot e_1,⋯,a_f \cdot e_f]=[v_1,⋯,v_f]$$
以上简单的介绍了一下SENet结构可以发现这种结构可以通过对特征embedding先压缩再交互再选择进而实现特征选择的效果。
此外张俊林大佬还将SENet应用于双塔模型中[(SENet双塔模型在推荐领域召回粗排的应用及其它)](https://zhuanlan.zhihu.com/p/358779957),模型结构如下所示:
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220522152508824.png" style="zoom:70%;"/>
</div>
从上图可以发现具体地是将双塔中的user塔和Item侧塔的特征输入部分加上一个SENet模块通过SENet网络动态地学习这些特征的重要性通过小权重抑制噪音或者无效低频特征通过大权重放大重要特征影响的目的。
之所以SENet双塔模型是有效的呢张俊林老师的解释是双塔模型的问题在于User侧特征和Item侧特征交互太晚在高层交互会造成细节信息也就是具体特征信息的损失影响两侧特征交叉的效果。而SENet模块在最底层就进行了特征的过滤使得很多无效低频特征即使被过滤掉这样更多有用的信息被保留到了双塔的最高层使得两侧的交叉效果很好同时由于SENet模块选择出更加重要的信息使得User侧和Item侧特征之间的交互表达方面增强了DNN双塔的能力。
因此SENet双塔模型主要是从特征选择的角度提高了两侧特征交叉的有效性减少了噪音对有效信息的干扰进而提高了双塔模型的效果。此外除了这样的方式还可以通过增加通道的方式来增强两侧的信息交互。即对于user和item两侧不仅仅使用一个DNN结构而是可以通过不同结构(如FMDCN等)来建模user和item的自身特征交叉例如下图所示
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/v2-9c2f7a30c6cadc47be23d6797f095b61_b.jpg" style="zoom:80%;"/>
</div>
这样对于user和item侧会得到多个embedding类似于多兴趣的概念。通过得到的多个user和item的embedding然后分别计算余弦值再相加(两侧的Embedding维度需要对齐),进而增加了双塔两侧的信息交互。而这种方法在腾讯进行过尝试,他们提出的“并联”双塔就是按照这样的思路,感兴趣的可以了解一下。
## 多目标的双塔模型
现如今多任务学习在实际的应用场景也十分的常见主要是因为实际场景中业务复杂往往有很多的衡量指标例如点击评论收藏关注转发等。在多任务学习中往往会针对不同的任务使用一个独有的tower然后优化不同任务损失。那么针对双塔模型应该如何构建多任务学习框架呢
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220523113206177.png" style="zoom:60%;"/>
</div>
如上图所示在user侧和item侧分别通过多个通道(DNN结构)为每个任务得到一个user embedding和item embedding然后针对不同的目标分别计算user 和 item 的相似度,并计算各个目标的损失,最后的优化目标可以是多个任务损失之和,或者使用多任务学习中的动态损失权重。
这种模型结构可以针对多目标进行联合建模通过多任务学习的结构一方面可以利用不同任务之间的信息共享为一些稀疏特征提供其他任务中的迁移信息另一方面可以在召回时直接使用一个模型得到多个目标预测解决了多个模型维护困难的问题。也就是说在线上通过这一个模型就可以同时得到多个指标例如视频场景一个模型就可以直接得到点赞品论转发等目标的预测值进而通过这些值计算分数获得最终的Top-K召回结果。
## 双塔模型的细节
关于双塔模型,其模型结构相比排序模型来说很简单,没有过于复杂的结构。但除了结构,有一些细节部分容易被忽视,而这些细节部分往往比模型结构更加重要,因此下面主要介绍一下双塔模型中需要主要的一些细节问题。
### 归一化与温度系数
在[Google的双塔召回模型](https://dl.acm.org/doi/pdf/10.1145/3298689.3346996)中重点介绍了两个trick将user和item侧输出的embedding进行归一化以及对于內积值除以温度系数实验证明这两种方式可以取得十分好的效果。那为什么这两种方法会使得模型的效果更好呢
- 归一化对user侧和item侧的输入embedding进行L2归一化
$$u(x,\theta) \leftarrow = \frac{u(x,\theta)}{||u(x,\theta)||_2}$$
$$v(x,\theta) \leftarrow = \frac{v(x,\theta)}{||v(x,\theta)||_2}$$
- 温度系数:在归一化之后的向量计算內积之后,除以一个固定的超参 $r$ ,论文中命名为温度系数。
$$s(u,v) = \frac{<u(x,\theta), v(x,\theta)>}{r}$$
那为什么需要进行上述的两个操作呢?
- 归一化的操作主要原因是因为向量点积距离是非度量空间,不满足三角不等式,而归一化的操作使得点击行为转化成了欧式距离。
首先向量点积是向量对应位相乘并求和,即向量內积。而向量內积**不保序**,例如空间上三个点(A=(10,0),B=(0,10),C=(11,0)),利用向量点积计算的距离 dis(A,B) < dis(A,C),但是在欧式距离下这是错误的。而归一化的操作则会让向量点积转化为欧式距离,例如 $user_{emb}$ 表示归一化user的embedding $item_{emb}$ 表示归一化 item 的embedding那么两者之间的欧式距离 $||user_{emb} - item_{emb}||$ 如下, 可以看出归一化的向量点积已转化成了欧式距离。
$$||user_{emb} - item_{emb}||=\sqrt{||user_{emb}||^2+||item_{emb}||^2-2<user_{emb},item_{emb}>} = \sqrt{2-2<user_{emb},item_{emb}>}$$
那没啥非要转为欧式距离呢这是因为ANN一般是通过计算欧式距离进行检索这样转化成欧式空间保证训练和检索一致。
### 模型的应用
在实际的工业应用场景中,分为离线训练和在线服务两个环节。
- 在离线训练阶段同过训练数据训练好模型参数。然后将候选库中所有的item集合离线计算得到对应的embedding并存储进ANN检索系统比如faiss。为什么将离线计算item集合主要是因为item的会相对稳定不会频繁的变动而对于用户而言如果将用户行为作为user侧的输入那么user的embedding会随着用户行为的发生而不断变化因此对于user侧的embedding需要实时的计算。
- 在线服务阶段正是因为用户的行为变化需要被即使的反应在用户的embedding中以更快的反应用户当前的兴趣即可以实时地体现用户即时兴趣的变化。因此在线服务阶段需要实时的通过拼接用户特征输入到user侧的DNN当中进而得到user embedding在通过user embedding去 faiss中进行ANN检索召回最相似的K个item embedding。
可以看到双塔模型结构十分的适合实际的应用场景,在快速服务的同时,还可以更快的反应用户即时兴趣的变化。
### 负样本采样
相比于排序模型而言召回阶段的模型除了在结构上的不同在样本选择方面也存在着很大的差异可以说样本的选择很大程度上会影响召回模型的效果。对于召回模型而言其负样本并不能和排序模型一样只使用展现未点击样本因为召回模型在线上面临的数据分布是全部的item而不仅仅是展现未点击样本。因此在离线训练时需要让其保证和线上分布尽可能一致所以在负样本的选择样要尽可能的增加很多未被曝光的item。下面简单的介绍一些常见的采样方法
#### 全局随机采样
全局随机采样指从全局候选item里面随机抽取一定数量item做为召回模型的负样本。这样的方式实现简单也可以让模型尽可能的和线上保持一致的分布尽可能的多的让模型对于全局item有区分的能力。例如YoutubeDNN算法。
但这样的方式也会存在一定的问题由于候选的item属于长尾数据即“八二定律”也就是说少数热门物料占据了绝大多数的曝光与点击。因此存随机的方式只能让模型在学到粗粒度上差异对一些尾部item并不友好。
#### 全局随机采样 + 热门打压
针对于全局随机采样的不足一个直观的方法是针对于item的热度item进行打压即对于热门的item很多用户可能会点击需要进行一定程度的欠采样使得模型更加关注一些非热门的item。 此外在进行负样本采样时应该对一些热门item进行适当的过采样这可以尽可能的让模型对于负样本有更加细粒度的区分。例如在word2vec中负采样方法是根据word的频率对 negative words进行随机抽样降 低 negative words 量级。
之所以热门item做负样本时要适当过采样增加负样本难度。因为对于全量的item模型可以轻易的区分一些和用户兴趣差异性很大的item难点在于很难区分一些和用户兴趣相似的item。因此在训练模型时需要适当的增加一些难以区分的负样本来提升模型面对相似item的分区能力。
#### Hard Negative增强样本
Hard Negative指的是选取一部分匹配度适中的item能够增加模型在训练时的难度提升模型能学习到item之间细粒度上的差异。至于 如何选取在工业界也有很多的解决方案。
例如Airbnb根据业务逻辑来采样一些hard negative (增加与正样本同城的房间作为负样本,增强了正负样本在地域上的相似性;增加与正样本同城的房间作为负样本,增强了正负样本在地域上的相似性,),详细内容可以查看[原文](https://www.kdd.org/kdd2018/accepted-papers/view/real-time-personalization-using-embeddings-for-search-ranking-at-airbnb)
例如百度和facebook依靠模型自己来挖掘Hard Negative都是用上一版本的召回模型筛选出"没那么相似"的<user,item>对,作为额外负样本,用于训练下一版本召回模型。 详细可以查看[Mobius](http://research.baidu.com/Public/uploads/5d12eca098d40.pdf) 和 [EBR](https://arxiv.org/pdf/2006.11632.pdf)
#### Batch内随机选择负采样
基于batch的负采样方法是将batch内选择除了正样本之外的其它Item做为负样本其本质就是利用其他样本的正样本随机采样作为自己的负样本。这样的方法可以作为负样本的选择方式特别是在如今分布式训练以及增量训练的场景中是一个非常值得一试的方法。但这种方法也存在他的问题基于batch的负采样方法受batch的影响很大当batch的分布与整体的分布差异很大时就会出现问题同时batch内负采样也会受到热门item的影响需要考虑打压热门item的问题。至于解决的办法Google的双塔召回模型中给出了答案想了解的同学可以去学习一下。
总的来说负样本的采样方法,不光是双塔模型应该重视的工作,而是所有召回模型都应该仔细考虑的方法。
## 代码实现
下面使用一点资讯提供的数据实践一下DSSM召回模型。该模型的实现主要参考DeepCtr和DeepMatch模块。
### 模型训练数据
1、数据预处理
用户侧主要包含一些用户画像属性(用户性别,年龄,所在省市,使用设备及系统);新闻侧主要包括新闻的创建时间,题目,所属 一级、二级类别,题片个数以及关键词。下面主要是对着两部分数据的简单处理:
```python
def proccess(file):
if file=="user_info_data_5w.csv":
data = pd.read_csv(file_path + file, sep="\t",index_col=0)
data["age"] = data["age"].map(lambda x: get_pro_age(x))
data["gender"] = data["gender"].map(lambda x: get_pro_age(x))
data["province"]=data["province"].fillna(method='ffill')
data["city"]=data["city"].fillna(method='ffill')
data["device"] = data["device"].fillna(method='ffill')
data["os"] = data["os"].fillna(method='ffill')
return data
elif file=="doc_info.txt":
data = pd.read_csv(file_path + file, sep="\t")
data.columns = ["article_id", "title", "ctime", "img_num","cate","sub_cate", "key_words"]
select_column = ["article_id", "title_len", "ctime", "img_num","cate","sub_cate", "key_words"]
# 去除时间为nan的新闻以及除脏数据
data= data[(data["ctime"].notna()) & (data["ctime"] != 'Android')]
data['ctime'] = data['ctime'].astype('str')
data['ctime'] = data['ctime'].apply(lambda x: int(x[:10]))
data['ctime'] = pd.to_datetime(data['ctime'], unit='s', errors='coerce')
# 这里存在nan字符串和异常数据
data["sub_cate"] = data["sub_cate"].astype(str)
data["sub_cate"] = data["sub_cate"].apply(lambda x: pro_sub_cate(x))
data["img_num"] = data["img_num"].astype(str)
data["img_num"] = data["img_num"].apply(photoNums)
data["title_len"] = data["title"].apply(lambda x: len(x) if isinstance(x, str) else 0)
data["cate"] = data["cate"].fillna('其他')
return data[select_column]
```
2、构造训练样本
该部分主要是根据用户的交互日志中前6天的数据作为训练集第7天的数据作为测试集来构造模型的训练测试样本。
```python
def dealsample(file, doc_data, user_data, s_data_str = "2021-06-24 00:00:00", e_data_str="2021-06-30 23:59:59", neg_num=5):
# 先处理时间问题
data = pd.read_csv(file_path + file, sep="\t",index_col=0)
data['expo_time'] = data['expo_time'].astype('str')
data['expo_time'] = data['expo_time'].apply(lambda x: int(x[:10]))
data['expo_time'] = pd.to_datetime(data['expo_time'], unit='s', errors='coerce')
s_date = datetime.datetime.strptime(s_data_str,"%Y-%m-%d %H:%M:%S")
e_date = datetime.datetime.strptime(e_data_str,"%Y-%m-%d %H:%M:%S") + datetime.timedelta(days=-1)
t_date = datetime.datetime.strptime(e_data_str,"%Y-%m-%d %H:%M:%S")
# 选取训练和测试所需的数据
all_data_tmp = data[(data["expo_time"]>=s_date) & (data["expo_time"]<=t_date)]
# 处理训练数据集 防止穿越样本
# 1. merge 新闻信息,得到曝光时间和新闻创建时间; inner join 去除doc_data之外的新闻
all_data_tmp = all_data_tmp.join(doc_data.set_index("article_id"),on="article_id",how='inner')
# 发现还存在 ctime大于expo_time的交互存在 去除这部分错误数据
all_data_tmp = all_data_tmp[(all_data_tmp["ctime"]<=all_data_tmp["expo_time"])]
# 2. 去除与新闻的创建时间在测试数据时间内的交互 ()
train_data = all_data_tmp[(all_data_tmp["expo_time"]>=s_date) & (all_data_tmp["expo_time"]<=e_date)]
train_data = train_data[(train_data["ctime"]<=e_date)]
print("有效的样本数:",train_data["expo_time"].count())
# 负采样
if os.path.exists(file_path + "neg_sample.pkl") and os.path.getsize(file_path + "neg_sample.pkl"):
neg_samples = pd.read_pickle(file_path + "neg_sample.pkl")
# train_neg_samples.insert(loc=2, column="click", value=[0] * train_neg_samples["user_id"].count())
else:
# 进行负采样的时候对于样本进行限制,只对一定时间范围之内的样本进行负采样
doc_data_tmp = doc_data[(doc_data["ctime"]>=datetime.datetime.strptime("2021-06-01 00:00:00","%Y-%m-%d %H:%M:%S"))]
neg_samples = negSample_like_word2vec(train_data, doc_data_tmp[["article_id"]].values, user_data[["user_id"]].values, neg_num=neg_num)
neg_samples = pd.DataFrame(neg_samples, columns= ["user_id","article_id","click"])
neg_samples.to_pickle(file_path + "neg_sample.pkl")
train_pos_samples = train_data[train_data["click"] == 1][["user_id","article_id", "expo_time", "click"]] # 取正样本
neg_samples_df = train_data[train_data["click"] == 0][["user_id","article_id", "click"]]
train_neg_samples = pd.concat([neg_samples_df.sample(n=train_pos_samples["click"].count()) ,neg_samples],axis=0) # 取负样本
print("训练集正样本数:",train_pos_samples["click"].count())
print("训练集负样本数:",train_neg_samples["click"].count())
train_data_df = pd.concat([train_neg_samples,train_pos_samples],axis=0)
train_data_df = train_data_df.sample(frac=1) # shuffle
print("训练集总样本数:",train_data_df["click"].count())
test_data_df = all_data_tmp[(all_data_tmp["expo_time"]>e_date) & (all_data_tmp["expo_time"]<=t_date)][["user_id","article_id", "expo_time", "click"]]
print("测试集总样本数:",test_data_df["click"].count())
print("测试集总样本数:",test_data_df["click"].count())
all_data_df = pd.concat([train_data_df, test_data_df],axis=0)
print("总样本数:",all_data_df["click"].count())
return all_data_df
```
3、负样本采样
该部分主要采用基于item的展现次数对全局item进行负采样。
```python
def negSample_like_word2vec(train_data, all_items, all_users, neg_num=10):
"""
为所有item计算一个采样概率根据概率为每个用户采样neg_num个负样本返回所有负样本对
1. 统计所有item在交互中的出现频次
2. 根据频次进行排序并计算item采样概率频次出现越多采样概率越低打压热门item
3. 根据采样概率,利用多线程为每个用户采样 neg_num 个负样本
"""
pos_samples = train_data[train_data["click"] == 1][["user_id","article_id"]]
pos_samples_dic = {}
for idx,u in enumerate(pos_samples["user_id"].unique().tolist()):
pos_list = list(pos_samples[pos_samples["user_id"] == u]["article_id"].unique().tolist())
if len(pos_list) >= 30: # 30是拍的 需要数据统计的支持确定
pos_samples_dic[u] = pos_list[30:]
else:
pos_samples_dic[u] = pos_list
# 统计出现频次
article_counts = train_data["article_id"].value_counts()
df_article_counts = pd.DataFrame(article_counts)
dic_article_counts = dict(zip(df_article_counts.index.values.tolist(),df_article_counts.article_id.tolist()))
for item in all_items:
if item[0] not in dic_article_counts.keys():
dic_article_counts[item[0]] = 0
# 根据频次排序, 并计算每个item的采样概率
tmp = sorted(list(dic_article_counts.items()), key=lambda x:x[1], reverse=True) # 降序
n_articles = len(tmp)
article_prob = {}
for idx, item in enumerate(tmp):
article_prob[item[0]] = cal_pos(idx, n_articles)
# 为每个用户进行负采样
article_id_list = [a[0] for a in article_prob.items()]
article_pro_list = [a[1] for a in article_prob.items()]
pos_sample_users = list(pos_samples_dic.keys())
all_users_list = [u[0] for u in all_users]
print("start negative sampling !!!!!!")
pool = multiprocessing.Pool(core_size)
res = pool.map(SampleOneProb((pos_sample_users,article_id_list,article_pro_list,pos_samples_dic,neg_num)), tqdm(all_users_list))
pool.close()
pool.join()
neg_sample_dic = {}
for idx, u in tqdm(enumerate(all_users_list)):
neg_sample_dic[u] = res[idx]
return [[k,i,0] for k,v in neg_sample_dic.items() for i in v]
```
### DSSM 模型
1、模型构建
模型构建部分主要是将输入的user 特征以及 item 特征处理完之后分别送入两侧的DNN结构。
```python
def DSSM(user_feature_columns, item_feature_columns, dnn_units=[64, 32],
temp=10, task='binary'):
# 构建所有特征的Input层和Embedding层
feature_encode = FeatureEncoder(user_feature_columns + item_feature_columns)
feature_input_layers_list = list(feature_encode.feature_input_layer_dict.values())
# 特征处理
user_dnn_input, item_dnn_input = process_feature(user_feature_columns,\
item_feature_columns, feature_encode)
# 构建模型的核心层
if len(user_dnn_input) >= 2:
user_dnn_input = Concatenate(axis=1)(user_dnn_input)
else:
user_dnn_input = user_dnn_input[0]
if len(item_dnn_input) >= 2:
item_dnn_input = Concatenate(axis=1)(item_dnn_input)
else:
item_dnn_input = item_dnn_input[0]
user_dnn_input = Flatten()(user_dnn_input)
item_dnn_input = Flatten()(item_dnn_input)
user_dnn_out = DNN(dnn_units)(user_dnn_input)
item_dnn_out = DNN(dnn_units)(item_dnn_input)
# 计算相似度
scores = CosinSimilarity(temp)([user_dnn_out, item_dnn_out]) # (B,1)
# 确定拟合目标
output = PredictLayer()(scores)
# 根据输入输出构建模型
model = Model(feature_input_layers_list, output)
return model
```
2、CosinSimilarity相似度计算
在余弦相似度计算,主要是注意使用归一化以及温度系数的技巧。
```python
def call(self, inputs, **kwargs):
"""inputs 是一个列表"""
query, candidate = inputs
# 计算两个向量的二范数
query_norm = tf.norm(query, axis=self.axis) # (B, 1)
candidate_norm = tf.norm(candidate, axis=self.axis)
# 计算向量点击,即內积操作
scores = tf.reduce_sum(tf.multiply(query, candidate), axis=-1)#(B,1)
# 相似度除以二范数, 防止除零
scores = tf.divide(scores, query_norm * candidate_norm + 1e-8)
# 对score的范围限制到(-1, 1)之间
scores = tf.clip_by_value(scores, -1, 1)
# 乘以温度系数
score = scores * self.temperature
return score
```
### 模型训练
1、稀疏特征编码
该部分主要是针对于用户侧和新闻侧的稀疏特征进行编码并将训练样本join上两侧的特征。
```python
# 数据和测试数据
data, user_data, doc_data = get_all_data()
# 1.Label Encoding for sparse features,and process sequence features with `gen_date_set` and `gen_model_input`
feature_max_idx = {}
feature_encoder = {}
user_sparse_features = ["user_id", "device", "os", "province", "city", "age", "gender"]
for feature in user_sparse_features:
lbe = LabelEncoder()
user_data[feature] = lbe.fit_transform(user_data[feature]) + 1
feature_max_idx[feature] = user_data[feature].max() + 1
feature_encoder[feature] = lbe
doc_sparse_features = ["article_id", "cate", "sub_cate"]
doc_dense_features = ["title_len", "img_num"]
for feature in doc_sparse_features:
lbe = LabelEncoder()
if feature in ["cate","sub_cate"]:
# 这里面会出现一些float的数据导致无法编码
doc_data[feature] = lbe.fit_transform(doc_data[feature].astype(str)) + 1
else:
doc_data[feature] = lbe.fit_transform(doc_data[feature]) + 1
feature_max_idx[feature] = doc_data[feature].max() + 1
feature_encoder[feature] = lbe
data["article_id"] = feature_encoder["article_id"].transform(data["article_id"].tolist())
data["user_id"] = feature_encoder["user_id"].transform(data["user_id"].tolist())
# join 用户侧和新闻侧的特征
data = data.join(user_data.set_index("user_id"), on="user_id", how="inner")
data = data.join(doc_data.set_index("article_id"), on="article_id", how="inner")
sparse_features = user_sparse_features + doc_sparse_features
dense_features = doc_dense_features
features = sparse_features + dense_features
mms = MinMaxScaler(feature_range=(0, 1))
data[dense_features] = mms.fit_transform(data[dense_features])
```
2、配置特征以及模型训练
构建模型所需的输入特征同时构建DSSM模型及训练。
```python
embedding_dim = 8
user_feature_columns = [SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim),
SparseFeat("gender", feature_max_idx['gender'], embedding_dim),
SparseFeat("age", feature_max_idx['age'], embedding_dim),
SparseFeat("device", feature_max_idx['device'], embedding_dim),
SparseFeat("os", feature_max_idx['os'], embedding_dim),
SparseFeat("province", feature_max_idx['province'], embedding_dim),
SparseFeat("city", feature_max_idx['city'], embedding_dim), ]
item_feature_columns = [SparseFeat('article_id', feature_max_idx['article_id'], embedding_dim),
DenseFeat('img_num', 1),
DenseFeat('title_len', 1),
SparseFeat('cate', feature_max_idx['cate'], embedding_dim),
SparseFeat('sub_cate', feature_max_idx['sub_cate'], embedding_dim)]
model = DSSM(user_feature_columns, item_feature_columns,
user_dnn_hidden_units=(32, 16, embedding_dim), item_dnn_hidden_units=(32, 16, embedding_dim)) # FM(user_feature_columns,item_feature_columns)
model.compile(optimizer="adagrad", loss = "binary_crossentropy", metrics=[tf.keras.metrics.Recall(), tf.keras.metrics.Precision()] ) #
history = model.fit(train_model_input, train_label, batch_size=256, epochs=4, verbose=1, validation_split=0.2, )
```
3、生成embedding用于召回
利用训练过的模型获取所有item的embeddings同时获取所有测试集的user embedding保存之后用于之后的召回工作。
```python
all_item_model_input = {"article_id": item_profile['article_id'].values,
"img_num": item_profile['img_num'].values,
"title_len": item_profile['title_len'].values,
"cate": item_profile['cate'].values,
"sub_cate": item_profile['sub_cate'].values,}
user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
item_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
user_embs = user_embedding_model.predict(test_user_model_input, batch_size=2 ** 12)
item_embs = item_embedding_model.predict(all_item_model_input, batch_size=2 ** 12)
user_idx_2_rawid, doc_idx_2_rawid = {}, {}
for i in range(len(user_embs)):
user_idx_2_rawid[i] = test_user_model_input["user_id"][i]
for i in range(len(item_embs)):
doc_idx_2_rawid[i] = all_item_model_input["article_id"][i]
# 保存一份
pickle.dump((user_embs, user_idx_2_rawid, feature_encoder["user_id"]), open(file_path + 'user_embs.pkl', 'wb'))
pickle.dump((item_embs, doc_idx_2_rawid, feature_encoder["article_id"]), open(file_path + 'item_embs.pkl', 'wb'))
```
### ANN召回
1、为测试集用户召回
通过annoy tree为所有的item构建索引并通过测试集中所有的user embedding为每个用户召回一定数量的item。
```python
def get_DSSM_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk):
"""近邻检索这里用annoy tree"""
# 把doc_embs构建成索引树
f = user_embs.shape[1]
t = AnnoyIndex(f, 'angular')
for i, v in enumerate(doc_embs):
t.add_item(i, v)
t.build(10)
# 每个用户向量, 返回最近的TopK个item
user_recall_items_dict = collections.defaultdict(dict)
for i, u in enumerate(user_embs):
recall_doc_scores = t.get_nns_by_vector(u, topk, include_distances=True)
# recall_doc_scores是(([doc_idx], [scores])) 这里需要转成原始doc的id
raw_doc_scores = list(recall_doc_scores)
raw_doc_scores[0] = [doc_idx_2_rawid[i] for i in raw_doc_scores[0]]
# 转换成实际用户id
user_recall_items_dict[user_idx_2_rawid[i]] = dict(zip(*raw_doc_scores))
# 默认是分数从小到大排的序, 这里要从大到小
user_recall_items_dict = {k: sorted(v.items(), key=lambda x: x[1], reverse=True) for k, v in user_recall_items_dict.items()}
pickle.dump(user_recall_items_dict, open(file_path + 'DSSM_u2i_dict.pkl', 'wb'))
return user_recall_items_dict
```
2、测试召回结果
为测试集用户的召回结果进行测试。
```python
user_recall_items_dict = get_DSSM_recall_res(user_embs, item_embs, user_idx_2_rawid, doc_idx_2_rawid, topk=TOP_NUM)
test_true_items = {line[0]:line[1] for line in test_set}
s = []
precision = []
for i, uid in tqdm(enumerate(list(user_recall_items_dict.keys()))):
# try:
pred = [x for x, _ in user_recall_items_dict[uid]]
filter_item = None
recall_score = recall_N(test_true_items[uid], pred, N=TOP_NUM)
s.append(recall_score)
precision_score = precision_N(test_true_items[uid], pred, N=TOP_NUM)
precision.append(precision_score)
print("recall", np.mean(s))
print("precision", np.mean(precision))
```
## 参考
- [负样本为王评Facebook的向量化召回算法](https://zhuanlan.zhihu.com/p/165064102)
- [多目标DSSM召回实战](https://mp.weixin.qq.com/s/aorZ43WozKrD2AudR6AnOg)
- [召回模型中的负样本构造](https://zhuanlan.zhihu.com/p/358450850)
- [Youtube双塔模型](https://dl.acm.org/doi/10.1145/3298689.3346996)
- [张俊林SENet双塔模型在推荐领域召回粗排的应用及其它](https://zhuanlan.zhihu.com/p/358779957)
- [双塔召回模型的前世今生(上篇)](https://zhuanlan.zhihu.com/p/430503952)
- [双塔召回模型的前世今生(下篇)](https://zhuanlan.zhihu.com/p/441597009)
- [Learning Deep Structured Semantic Models for Web Search using Clickthrough Data](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/cikm2013_DSSM_fullversion.pdf)

View File

@@ -0,0 +1,124 @@
# FM 模型结构
FM 模型用于排序时,模型的公式定义如下:
$$
\hat{y}(\mathbf{x}):=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+\sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j}
$$
+ 其中,$i$ 表示特征的序号,$n$ 表示特征的数量;$x_i \in \mathbb{R}$ 表示第 $i$ 个特征的值。
+ $v_i,v_j \in \mathbb{R}^{k} $ 分别表示特征 $x_i,x_j$ 对应的隐语义向量Embedding向量 $\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle:=\sum_{f=1}^{k} v_{i, f} \cdot v_{j, f}$ 。
+ $w_0,w_i\in \mathbb{R}$ 均表示需要学习的参数。
**FM 的一阶特征交互**
在 FM 的表达式中,前两项为特征的一阶交互项。将其拆分为用户特征和物品特征的一阶特征交互项,如下:
$$
\begin{aligned}
& w_{0}+\sum_{i=1}^{n} w_{i} x_{i} \\
&= w_{0} + \sum_{t \in I}w_{t} x_{t} + \sum_{u\in U}w_{u} x_{u} \\
\end{aligned}
$$
+ 其中,$U$ 表示用户相关特征集合,$I$ 表示物品相关特征集合。
**FM 的二阶特征交互**
观察 FM 的二阶特征交互项,可知其计算复杂度为 $O\left(k n^{2}\right)$ 。为了降低计算复杂度,按照如下公式进行变换。
$$
\begin{aligned}
& \sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j} \\
=& \frac{1}{2} \sum_{i=1}^{n} \sum_{j=1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j}-\frac{1}{2} \sum_{i=1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{i}\right\rangle x_{i} x_{i} \\
=& \frac{1}{2}\left(\sum_{i=1}^{n} \sum_{j=1}^{n} \sum_{f=1}^{k} v_{i, f} v_{j, f} x_{i} x_{j}-\sum_{i=1}^{n} \sum_{f=1}^{k} v_{i, f} v_{i, f} x_{i} x_{i}\right) \\
=& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f} x_{i}\right)^{}\left(\sum_{j=1}^{n} v_{j, f} x_{j}\right)-\sum_{i=1}^{n} v_{i, f}^{2} x_{i}^{2}\right) \\
=& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f} x_{i}\right)^{2}-\sum_{i=1}^{n} v_{i, f}^{2} x_{i}^{2}\right)
\end{aligned}
$$
+ 公式变换后,计算复杂度由 $O\left(k n^{2}\right)$ 降到 $O\left(k n\right)$。
由于本文章需要将 FM 模型用在召回,故将二阶特征交互项拆分为用户和物品项。有:
$$
\begin{aligned}
& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f} x_{i}\right)^{2}-\sum_{i=1}^{n} v_{i, f}^{2} x_{i}^{2}\right) \\
=& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{u \in U} v_{u, f} x_{u} + \sum_{t \in I} v_{t, f} x_{t}\right)^{2}-\sum_{u \in U} v_{u, f}^{2} x_{u}^{2} - \sum_{t\in I} v_{t, f}^{2} x_{t}^{2}\right) \\
=& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{u \in U} v_{u, f} x_{u}\right)^{2} + \left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} + 2{\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} - \sum_{u \in U} v_{u, f}^{2} x_{u}^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right)
\end{aligned}
$$
+ 其中,$U$ 表示用户相关特征集合,$I$ 表示物品相关特征集合。
# FM 用于召回
基于 FM 召回,我们可以将 $\hat{y}(\mathbf{x}):=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+\sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j}$ 作为用户和物品之间的匹配分。
+ 在上一小节中,对于 FM 的一阶、二阶特征交互项,已将其拆分为用户项和物品项。
+ 对于同一用户,即便其与不同物品进行交互,但用户特征内部之间的一阶、二阶交互项得分都是相同的。
+ 这就意味着在比较用户与不同物品之间的匹配分时只需要比较1物品内部之间的特征交互得分2用户和物品之间的特征交互得分。
**FM 的一阶特征交互**
+ 将全局偏置和用户一阶特征交互项进行丢弃,有:
$$
FM_{一阶} = \sum_{t \in I} w_{t} x_{t}
$$
**FM 的二阶特征交互**
+ 将用户特征内部的特征交互项进行丢弃,有:
$$
\begin{aligned}
& FM_{二阶} = \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} + 2{\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right) \\
&= \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right) + \sum_{f=1}^{k}\left( {\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} \right)
\end{aligned}
$$
合并 FM 的一阶、二阶特征交互项,得到基于 FM 召回的匹配分计算公式:
$$
\text{MatchScore}_{FM} = \sum_{t \in I} w_{t} x_{t} + \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right) + \sum_{f=1}^{k}\left( {\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} \right)
$$
在基于向量的召回模型中,为了 ANN近似最近邻算法 或 Faiss 加速查找与用户兴趣度匹配的物品。基于向量的召回模型,一般最后都会得到用户和物品的特征向量表示,然后通过向量之间的内积或者余弦相似度表示用户对物品的兴趣程度。
基于 FM 模型的召回算法,也是向量召回算法的一种。所以下面,将 $\text{MatchScore}_{FM}$ 化简为用户向量和物品向量的内积形式,如下:
$$
\text{MatchScore}_{FM} = V_{item} V_{user}^T
$$
+ 用户向量:
$$
V_{user} = [1; \quad {\sum_{u \in U} v_{u} x_{u}}]
$$
+ 用户向量由两项表达式拼接得到。
+ 第一项为常数 $1$,第二项是将用户相关的特征向量进行 sum pooling 。
+ 物品向量:
$$
V_{item} = [\sum_{t \in I} w_{t} x_{t} + \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right); \quad
{\sum_{t \in I} v_{t} x_{t}} ]
$$
+ 第一项表示物品相关特征向量的一阶、二阶特征交互。
+ 第二项是将物品相关的特征向量进行 sum pooling 。
# 思考题
1. 为什么不直接将 FM 中学习到的 User Embedding ${\sum_{u \in U} v_{u} x_{u}}$ 和 Item Embedding $\sum_{t \in I} v_{t} x_{t}$ 的内积做召回呢?
答:这样做,也不是不行,但是效果不是特别好。**因为用户喜欢的未必一定是与自身最匹配的也包括一些自身性质极佳的iteme.g.,热门item**,所以,**非常有必要将"所有Item特征一阶权重之和"和“所有Item特征隐向量两两点积之和”考虑进去**,但是也还必须写成点积的形式。
# 代码实战
正在完善...
# 参考链接
+ [paper.dvi (ntu.edu.tw)](https://www.csie.ntu.edu.tw/~b97053/paper/Rendle2010FM.pdf)
+ [FM推荐算法中的瑞士军刀 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/343174108)

View File

@@ -0,0 +1,589 @@
## 写在前面
YouTubeDNN模型是2016年的一篇文章虽然离着现在有些久远 但这篇文章无疑是工业界论文的典范, 完全是从工业界的角度去思考如何去做好一个推荐系统并且处处是YouTube工程师留给我们的宝贵经验 由于这两天用到了这个模型,今天也正好重温了下这篇文章,所以借着这个机会也整理出来吧, 王喆老师都称这篇文章是"神文" 可见其不一般处。
今天读完之后, 给我的最大感觉,首先是从工程的角度去剖析了整个推荐系统,讲到了推荐系统中最重要的两大模块: 召回和排序, 这篇论文对初学者非常友好,之前的论文模型是看不到这么全面的系统的,总有一种管中规豹的感觉,看不到全局,容易着相。 其次就是这篇文章给出了很多优化推荐系统中的工程性经验, 不管是召回还是排序上都有很多的套路或者trick比如召回方面的"example age", "负采样""非对称消费,防止泄露",排序方面的特征工程,加权逻辑回归等, 这些东西至今也都非常的实用,所以这也是这篇文章厉害的地方。
本篇文章依然是以paper为主线 先剖析paper里面的每个细节当然我这里也参考了其他大佬写的文章王喆老师的几篇文章写的都很好链接我也放在了下面建议也看看。然后就是如何用YouTubeDNN模型代码复现部分由于时间比较短自己先不复现了调deepmatch的包跑起来然后在新闻推荐数据集上进行了一些实验 尝试了论文里面讲述的一些方法这里主要是把deepmatch的YouTubeDNN模型怎么使用以及我整个实验过程的所思所想给整理下 因为这个模型结构本质上并不是很复杂(三四层的全连接网络),就不自己在实现一遍啦, 一些工程经验或者思想,我觉得才是这篇文章的精华部分。
## 引言与推荐系统的漏斗范式
### 引言部分
本篇论文是工程性论文(之前的DIN也是偏工程实践的论文) 行文风格上以实际应用为主, 我们知道YouTube是全球性的视频网站 所以这篇文章主要讲述了YouTube视频推荐系统的基本架构以及细节以及各种处理tricks。
在Introduction部分 作者首先说了在工业上的YouTube视频推荐系统主要面临的三大挑战:
1. Scale(规模): 视频数量非常庞大大规模数据下需要分布式学习算法以及高效的线上服务系统文中体现这一点的是召回模型线下训练的时候采用了负采样的思路线上服务的时候采用了hash映射然后近邻检索的方式来满足实时性的需求 这个之前我整理过faiss包和annoy包的使用 感兴趣的可以看看。 其实,再拔高一层,我们推荐系统的整体架构呈漏斗范式,也是为了保证能从大规模情景下实时推荐。
2. Freshness(新鲜度): YouTube上的视频是一个动态的 用户实时上传,且实时访问, 那么这时候, 最新的视频往往就容易博得用户的眼球, 用户一般都比较喜欢看比较新的视频, 而不管是不是真和用户相关(这个感觉和新闻比较类似呀) 这时候,就需要模型有建模新上传内容以及用户最新发生的行为能力。 为了让模型学习到用户对新视频有偏好, 后面策略里面加了一个"example age"作为体现。我们说的"探索与利用"中的探索,其实也是对新鲜度的把握。
3. Noise(噪声): 由于数据的稀疏和不可见的其他原因, 数据里面的噪声非常之多,这时候,就需要让这个推荐系统变得鲁棒,怎么鲁棒呢? 这个涉及到召回和排序两块,召回上需要考虑更多实际因素,比如非对称消费特性,高活用户因素,时间因素,序列因素等,并采取了相应的措施, 而排序上做更加细致的特征工程, 尽量的刻画出用户兴趣以及视频的特征 优化训练目标,使用加权的逻辑回归等。而召回和排序模型上,都采用了深度神经网络,通过特征的相互交叉,有了更强大的建模能力, 相比于之前用的MF(矩阵分解) 建模能力上有了很大的提升, 这些都有助于帮助减少噪声, 使得推荐结果更加准确。
所以从文章整体逻辑上看, 后面的各个细节,其实都是围绕着挑战展开的,找到当前推荐面临的问题,就得想办法解决问题,所以这篇文章的行文逻辑也是非常清晰的。
知道了挑战, 那么下面就看看YouTubeDNN的整体推荐系统架构。
### YouTubeDNN推荐系统架构
整个推荐架构图如下, 这个算是比较原始的漏斗结构了:
<div align=center>
<img src="https://img-blog.csdnimg.cn/1c5dbd6d6c1646d09998b18d45f869e5.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
这篇文章之所以写的好, 是给了我们一个看推荐系统的宏观视角, 这个系统主要是两大部分组成: 召回和排序。召回的目的是根据用户部分特征,从海量物品库,快速找到小部分用户潜在感兴趣的物品交给精排,重点强调快,精排主要是融入更多特征,使用复杂模型,来做个性化推荐,强调准。
而对于这两块的具体描述, 论文里面也给出了解释, 我这里简单基于我目前的理解扩展下主流方法:
1. 召回侧
<div align=center>
<img src="https://img-blog.csdnimg.cn/5ebcd6f882934b7e9e2ffb9de2aee29d.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
召回侧模型的输入一般是用户的点击历史, 因为我们认为这些历史能更好的代表用户的兴趣, 另外还有一些人口统计学特征,比如性别,年龄,地域等, 都可以作为召回侧模型的输入。 而最终模型的输出,就是与该用户相关的一个候选视频集合, 量级的话一般是几百。
<br>召回侧, 目前根据我的理解,大致上有两大类召回方式,一类是策略规则,一类是监督模型+embedding其中策略规则往往和真实场景有关比如热度历史重定向等等不同的场景会有不同的召回方式这种属于"特异性"知识。
<br>后面的模型+embedding思路是一种"普适"方法我上面图里面梳理出了目前给用户和物品打embedding的主流方法 这些方法大致成几个系列比如FM系列(FM,FFM等) 用户行为序列基于图和知识图谱系列经典双塔系列等这些方法看似很多很复杂其实本质上还是给用户或者是物品打embedding而已只不过考虑的角度方式不同。 这里的YouTubeDNN召回模型也是这里的一种方式而已。
2. 精排侧
<div align=center>
<img src="https://img-blog.csdnimg.cn/08953c0e8a00476f90bd9e206d4a02c6.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
召回那边对于每个用户, 给出了几百个比较相关的候选视频, 把几百万的规模降到了几百, 当然,召回那边利用的特征信息有限,并不能很好的刻画用户和视频特点,所以, 在精排侧,主要是想利用更多的用户,视频特征,刻画特点更加准确些,从这几百个里面选出几个或者十几个推荐给用户。 而涉及到准, 主要的发力点一般有三个:特征工程, 模型设计以及训练方法。 这三个发力点文章几乎都有所涉及, 除了模式设计有点审时度势之外,特征工程以及训练方法的处理上非常漂亮,具体的后面再整理。<br>
精排侧这一块的大致发展趋势从ctr预估到多目标 而模型演化上,从人工特征工程到特征工程自动化。主要是三大块, CTR预估主要分为了传统的LRFM大家族以及后面自动特征交叉的DNN家族而多目标优化目前是很多大公司的研究现状更是未来的一大发展趋势如何能让模型在各个目标上面的学习都能"游刃有余"是一件非常具有挑战的事情毕竟不同的目标可能会互相冲突互相影响所以这里的研究热点又可以拆分成网络结构演化以及loss设计优化等 而网络结构演化中,又可以再一次细分。 当然这每个模型或者技术几乎都有对应paper我们依然可以通过读paper的方式把这些关键技术学习到。
这两阶段的方法, 就能保证我们从大规模视频库中实时推荐, 又能保证个性化,吸引用户。 当然,随着时间的发展, 可能数据量非常非常大了, 此时召回结果规模精排依然无法处理,所以现在一般还会在召回和精排之间,加一个粗排进一步筛选作为过渡, 而随着场景越来越复杂, 精排产生的结果也不是直接给到用户而是会再后面加一个重排后处理下这篇paper里面其实也简单的提了下这种思想在排序那块会整理到。 所以如今的漏斗, 也变得长了些。
<div align=center>
<img src="https://img-blog.csdnimg.cn/aeae52971a1345a98b310890ea81be53.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
论文里面还提到了对模型的评估方面, 线下评估的时候,主要是采用一些常用的评估指标(精确率,召回率, 排序损失或者auc这种) 但是最终看算法和模型的有效性, 是通过A/B实验 在A/B实验中会观察用户真实行为比如点击率 观看时长, 留存率这种, 这些才是我们终极目标, 而有时候, A/B实验的结果和线下我们用的这些指标并不总是相关 这也是推荐系统这个场景的复杂性。 我们往往也会用一些策略,比如修改模型的优化目标,损失函数这种, 让线下的这个目标尽量的和A/B衡量的这种指标相关性大一些。 当然,这块又是属于业务场景问题了,不在整理范畴之中。 但2016年竟然就提出了这种方式 所以我觉得,作为小白的我们, 想了解工业上的推荐系统, 这篇paper是不二之选。
OK 从宏观的大视角看完了漏斗型的推荐架构我们就详细看看YouTube视频推荐架构里面召回和排序模块的模型到底长啥样子 为啥要设计成这个样子? 为了应对实际中出现的挑战,又有哪些策略?
## YouTubeDNN的召回模型细节剖析
上面说过, 召回模型的目的是在大量YouTube视频中检索出数百个和用户相关的视频来。
这个问题,我们可以看成一个多分类的问题,即用户在某一个时刻点击了某个视频, 可以建模成输入一个用户向量, 从海量视频中预测出被点击的那个视频的概率。
换成比较准确的数学语言描述, 在时刻$t$下, 用户$U$在背景$C$下对每个视频$i$的观看行为建模成下面的公式:
$$
P\left(w_{t}=i \mid U, C\right)=\frac{e^{v_{i} u}}{\sum_{j \in V} e^{v_{j} u}}
$$
这里的$u$表示用户向量, 这里的$v$表示视频向量, 两者的维度都是$N$ 召回模型的任务,就是通过用户的历史点击和山下文特征, 去学习最终的用户表示向量$u$以及视频$i$的表示向量$v_i$ 不过这俩还有个区别是$v_i$本身就是模型参数, 而$u$是神经网络的输出(函数输出),是输入与模型参数的计算结果。
>解释下这个公式, 为啥要写成这个样子其实是word2vec那边借鉴过来的$e^{ (v_{i} u)}$表示的是当前用户向量$u$与当前视频$v_i$的相似程度,$e$只是放大这个相似程度而已, 不用管。 为啥这个就能表示相似程度呢? 因为两个向量的点积运算的含义就是可以衡量两个向量的相似程度, 两个向量越相似, 点积就会越大。 所以这个应该解释明白了。 再看分母$\sum_{j \in V} e^{v_{j} u}$, 这个显然是用户向量$u$与所有视频$v$的一个相似程度求和。 那么两者一除, 依然是代表了用户$u$与输出的视频$v_i$的相似程度只不过归一化到了0-1之间 毕竟我们知道概率是0-1之间的 这就是为啥这个概率是右边形式的原因。 因为右边公式表示了用户$u$与输出的视频$v_i$的相似程度, 并且这个相似程度已经归一化到了0-1之间 我们给定$u$希望输出$v_i$的概率越大,因为这样,当前的视频$v_i$和当前用户$u$更加相关,正好对应着点击行为不是吗?
那么,这个召回模型到底长啥样子呢?
### 召回模型结构
召回模型的结构如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/724ff38c1d6448399edb658b1b27e18e.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这个模型结构呢,相比之前的模型, 比较简单就是一个DNN。
它的输入主要是用户侧的特征包括用户观看的历史video序列 用户搜索的历史tokens 然后就是用户的人文特征,比如地理位置, 性别,年龄这些。 这些特征处理上,和之前那些模型的也比较类似,
* 用户历史序列历史搜索tokens这种序列性的特征: 一般长这样`[item_id5, item_id2, item_id3, ...]` 这种id特征是高维稀疏首先会通过一个embedding层转成低维稠密的embedding特征即历史序列里面的每个id都会对应一个embedding向量 这样历史序列就变成了多个embedding向量的形式 这些向量一般会进行融合常见的是average pooling即每一维求平均得到一个最终向量来表示用户的历史兴趣或搜索兴趣。
>这里值的一提的是这里的embedding向量得到的方式 论文中作者这里说是通过word2vec方法计算的 关于word2vec这里就不过多解释也就是每个item事先通过w2v方式算好了的embedding直接作为了输入然后进行pooling融合。<br><br>除了这种算好embedding方式之外还可以过embedding层跟上面的DNN一起训练这些都是常规操作之前整理的精排模型里面大都是用这种方式。
论文里面使用了用户最近的50次观看历史用户最近50次搜索历史token embedding维度是256维 采用的average pooling。 当然这里还可以把item的类别信息也隐射到embedding 与前面的concat起来。
* 用户人文特征, 这种特征处理方式就是离散型的依然是labelEncoder然后embedding转成低维稠密 而连续型特征,一般是先归一化操作,然后直接输入,当然有的也通过分桶,转成离散特征,这里不过多整理,特征工程做的事情了。 当然,这里还有一波操作值得注意,就是连续型特征除了用了$x$本身,还用了$x^2$$logx$这种, 可以加入更多非线性,增加模型表达能力。<br>
这些特征对新用户的推荐会比较有帮助,常见的用户的地理位置, 设备, 性别,年龄等。
* 这里一个比较特色的特征是example age这个特征后面需要单独整理。
这些特征处理好了之后拼接起来就成了一个非常长的向量然后就是过DNN这里用了一个三层的DNN 得到了输出, 这个输出也是向量。
Ok到这里平淡无奇 前向传播也大致上快说完了, 还差最后一步。 最后这一步就是做多分类问题然后求损失这就是training那边做的事情。 但是在详细说这个之前, 我想先简单回忆下word2vec里面的skip-gram Model 这个模型,如果回忆起来,这里理解起来就非常的简单了。
这里只需要看一张图即可, 这个来自cs231N公开课PPT 我之前整理w2v的时候用到的这里的思想其实也是从w2v那边过来的。
<div align=center>
<img src="https://img-blog.csdnimg.cn/20200624193409649.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
skip-gram的原理咱这里就不整理了 这里就只看这张图这其实就是w2v训练的一种方式当然是最原始的。 word2vec的核心思想呢 就是共现频率高的词相关性越大所以skip-gram采用中心词预测上下文词的方式去训练词向量模型的输入是中心词做样本采用滑动窗口的形式和这里序列其实差不多窗口滑动一次就能得到一个序列[word1, word2, ...wordn] 而这个序列里面呢? 就会有中心词(比如中间那个) 两边向量的是上下文词。 如果我们输入中心词之后,模型能预测上下文词的概率大,那说明这个模型就能解决词相关性问题了。
>一开始, 我们的中心单词$w_t$就是one-hot的表示形式也就是在词典中的位置这里的形状是$V \times1$ $V$表示词库里面有$V$个单词, 这里的$W$长上面那样, 是一个$d\times V$的矩阵, $d$表示的是词嵌入的维度, 那么用$W*w_t$(矩阵乘法)就会得到中心词的词向量表示$v_c$ 大小是$d\times1$。这个就是中心词的embedding向量。 其实就是中心词过了一个embedding层得到了它的embedding向量。
><br>然后就是$v_c$和上下文矩阵$W'$相乘, 这里的$W'$是$V\times d$的一个矩阵, 每一行代表每个单词作为上下文的时候的词向量表示, 也就是$u_w$ 每一列是词嵌入的维度。 这样通过$W'*v_c$就会得到一个$V\times 1$的向量,这个表示的就是中心单词$w_t$与每个单词的相似程度。
><br>最后我们通过softmax操作把这个相似程度转成概率 选择概率最大的index输出。
这就是这个模型的前向传播过程。
有了这个过程, 再理解YouTubeDNN顶部就非常容易了 我单独截出来:
<div align=center>
<img src="https://img-blog.csdnimg.cn/98811e09226f42a2be981b0aa3449ab3.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
只看这里的这个过程, 其实就是上面skip-gram过程 不一样的是右边这个中心词向量$v_c$是直接过了一个embedding层得到的而左边这个用户向量$u$是用户的各种特征先拼接成一个大的向量然后过了一个DNN降维。 训练方式上,这两个也是一模一样的,无非就是左边的召回模型,多了几层全连接而已。
> 这样也就很容易的理解模型训练好了之后用户向量和item向量到底在哪里取了吧。
> * 用户向量其实就是全连接的DNN网络的输出向量其实即使没有全连接原始的用户各个特征拼接起来的那个长向量也能用不过维度可能太大了所以DNN在这里的作用一个是特征交叉另一个还有降维的功效。
> * item向量: 这个其实和skip-gram那个一样每个item其实是用两个embedding向量的比如skip-gram那里就有一个作为中心词时候的embedding矩阵$W$和作为上下文词时候的embedding矩阵$W'$ 一般取的时候会取前面那个$W$作为每个词的词向量。 这里其实一个道理只不过这里最前面那个item向量矩阵是通过了w2v的方式训练好了直接作为的输入如果不事先计算好对应的是embedding层得到的那个矩阵。 后面的item向量矩阵就是这里得到用户向量之后后面进行softmax之前的这个矩阵 **YouTubeDNN最终是从这个矩阵里面拿item向量**。
这就是知识串联的魅力其实熟悉了word2vec 这个召回模型理解非常简单。
这其实就是这个模型训练阶段最原始的剖析,实际训练的时候,依然是采用了优化方法, 这个和word2vec也是一样采用了负采样的方式(当然实现细节上有区别),因为视频的数量太大,每次做多分类,最终那个概率分母上的加和就非常可怕了,所以就把多分类问题转成了多个二分类的问题。 也就是不用全部的视频,而是随机选择出了一些没点的视频, 标记为0 点了的视频标记为1 这样就成了二分类的问题。 关于负样本采样原理, 我之前也整理了[一篇博客](https://blog.csdn.net/wuzhongqiang/article/details/106979179?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522164310239216780274177509%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=164310239216780274177509&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-106979179.nonecase&utm_term=word2vec&spm=1018.2226.3001.4450)
>负类基于样本分布抽取而来。负采样是针对类别数很多情况下的常用方法。当然,负样本的选择也是有讲究的,详细的看[这篇文章](https://www.zhihu.com/question/334844408/answer/2299283878), 我后面实验主要用了下面两种
>* 展示数据随机选择负例
>* 随机负例与热门打压
<div align=center>
<img src="https://img-blog.csdnimg.cn/6fe56d71de8a4d769a583f27a3ce9f40.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这样整个召回模型训练部分的"基本操作"就基本整理完了。关于细节部分,后面代码里面会描述下, 但是在训练召回模型过程中,还有一些经验性的知识也非常重要。 下面重点整理一下。
### 训练数据的选取和生成
模型训练的时候, 为了计算更加高效,采用了负采样的方法, 但正负样本的选取,以及训练样本的来源, 还有一些注意事项。
首先训练样本来源于全部的YouTube观看记录而不仅仅是被推荐的观看记录
<div align=center>
<img src="https://img-blog.csdnimg.cn/faf8a8abf7b54b779287acadc015b6a0.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
否则对于新视频会难以被曝光,会使最终推荐结果有偏;同时系统也会采集用户从其他渠道观看的视频,从而可以快速应用到协同过滤中;
其次, 是训练数据来源于用户的隐式数据, 且**用户看完了的视频作为正样本** 注意这里是看完了, 有一定的时长限制, 而不是仅仅曝光点击,有可能有误点的。 而负样本,是从视频库里面随机选取,或者在曝光过的里面随机选取用户没看过的作为负样本。
==这里的一个经验==是**训练数据中对于每个用户选取相同的样本数, 保证用户在损失函数等权重** 因为这样可以减少高度活跃用户对于loss的影响。可以改进线上A/B测试的效果。
<div align=center>
<img src="https://img-blog.csdnimg.cn/35386af8fd064de3a87cb418b008e444.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这里的==另一个经验==是**避免让模型知道不该知道的信息**
<div align=center>
<img src="https://img-blog.csdnimg.cn/0765134e1ca445c693058aaaaf20ae74.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这里作者举了一个例子是如果模型知道用户最后的行为是搜索了"Taylor Swift" 那么模型可能会倾向于推荐搜索页面搜"Taylor Swift"时搜索的视频, 这个不是推荐模型期望的行为。 解法方法是**扔掉时序信息** 历史搜索tokens随机打乱 使用无序的搜索tokens来表示搜索queryies(average pooling)。
>基于这个例子就把时序信息扔掉理由挺勉强的解决这种特殊场景的信息泄露会有更针对性的方法比如把搜索query与搜索结果行为绑定让它们不可分。 感觉时序信息还是挺重要的, 有专门针对时序信息建模的研究。
在生成样本的时候, 如果我们的用户比较少,行为比较少, 是不足以训练一个较好的召回模型,此时一个用户的历史观看序列,可以采用滑动窗口的形式生成多个训练样本, 比如一个用户的历史观看记录是"abcdef" 那么采用滑动窗口, 可以是abc预测d, bcd预测e, cde预测f这样一个用户就能生成3条训练样本。 后面实验里面也是这么做的。 但这时候一定要注意一点,就是**信息泄露**这个也是和word2vec的cbow不一样的地方。
论文中上面这种滑动制作样本的方式依据是用户的"asymmetric co-watch probabilities(非对称观看概率)",即一般情况下,用户开始浏览范围较广, 之后浏览范围逐渐变窄。
下图中的$w_{tN}$表示当前样本, 原来的做法是它前后的用户行为都可以用来产生特征行为输入(word2vec的CBOW做样本的方法)。 而作者担心这一点会导致信息泄露, 模型**不该知道的信息是未来的用户行为** 所以作者的做法是只使用更早时间的用户行为来产生特征, 这个也是目前通用的做法。 两种方法的对比如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/049cbeb814f843fd97638ef02d6c5703.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_2,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
(a)是许多协同过滤会采取的方法利用全局的观看信息作为输入包括时间节点N前N后的观看这种方法忽略了观看序列的不对称性而本文中采取(b)所示的方法,只把历史信息当作输入,用历史来预测未来
<div align=center>
<img src="https://img-blog.csdnimg.cn/4ac0c81e5f4f4276a4ed0e4c6329f458.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
模型的测试集, 往往也是用户最近一次观看行为, 后面的实验中,把用户最后一次点击放到了测试集里面去。这样可以防止信息穿越。
数据集的细节和tricks基本上说完 更细的东西,就得通过代码去解释了。 接下来, 再聊聊作者加入的非常有意思的一个特征叫做example age。
### "Example Age"特征
这个特征我想单独拿出来说,是因为这个是和场景比较相关的特征,也是作者的经验传授。 我们知道,视频有明显的生命周期,例如刚上传的视频比之后更受欢迎,也就是用户往往喜欢看最新的东西,而不管它是不是和用户相关,所以视频的流行度随着时间的分布是高度非稳态变化的(下面图中的绿色曲线)
<div align=center>
<img src="https://img-blog.csdnimg.cn/15dfce743bd2490a8adb21fd3b2b294e.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
但是我们模型训练的时候,是基于历史数据训练的(历史观看记录的平均),所以模型对播放某个视频预测值的期望会倾向于其在训练数据时间内的平均播放概率(平均热度) 上图中蓝色线。但如上面绿色线,实际上该视频在训练数据时间窗口内热度很可能不均匀, 用户本身就喜欢新上传的内容。 所以为了让模型学习到用户这种对新颖内容的bias 作者引入了"example age"这个特征来捕捉视频的生命周期。
"example age"定义为$t_{max}-t$ 其中$t_{max}$是训练数据中所有样本的时间最大值(有的文章说是当前时间,但我总觉得还是选取的训练数据所在时间段的右端点时间比较合适,就比如我用的数据集, 最晚时间是2021年7月的总不能用现在的时间吧) 而$t$为当前样本的时间。**线上预测时, 直接把example age全部设为0或一个小的负值这样就不依赖于各个视频的上传时间了**。
>其实这个操作, 现在常用的是位置上的除偏, 比如商品推荐的时候, 用户往往喜欢点击最上面位置的商品或广告, 但这个bias模型依然是不知道 为了让模型学习到这个东西, 也可以把商品或者广告的位置信息做成一个feature 训练的时候告诉模型。 而线上推理的那些商品, 这个feature也都用一样的。 异曲同工的意思有没有。<br><br>那么这样的操作为啥会work呢 example age这个我理解是有了这个特征 就可以把某视频的热度分布信息传递给模型了, 比如某个example age时间段该视频播放较多 而另外的时间段播放较少, 这样模型就能发现用户的这种新颖偏好, 消除热度偏见。<br><br>这个地方看了一些文章写说, 这样做有利于让模型推新热内容, 总感觉不是很通。 我这里理解是类似让模型消除位置偏见那样, 这里消除一种热度偏见。 <br><br>我理解是这样假设没有这样一个example age特征表示视频新颖信息或者一个位置特征表示商品的位置信息那模型训练的样本可能是用户点击了这个item就是正样本 但此时有可能是用户真的喜欢这个item 也有可能是因为一些bias 比如用户本身喜欢新颖, 用户本身喜欢点击上面位置的item等 但模型推理的时候都会误认为是用户真的喜欢这个item。 所以为了让模型了解到可能是存在后面这种bias 我们就把item的新颖信息 item的位置信息等做成特征 在模型训练的时候就告诉模型,用户点了这个东西可能是它比较新或者位置比较靠上面等,这样模型在训练的时候, 就了解到了这些bias等到模型在线推理的时候呢 我们把这些bias特征都弄成一样的这样每个样品在模型看来就没有了新颖信息和位置信息bias(一视同仁了),只能靠着相关性去推理, 这样才能推到用户真正感兴趣的东西吧。<br><br>而有些文章记录的, 能够推荐更热门的视频啥的, 我很大一个疑问就是推理的时候不是把example age用0表示吗 模型应该不知道这些视频哪个新不新吧。 当然,这是我自己的看法,感兴趣的可以帮我解答下呀。
`example age`这个特征到这里还没完, 原来加入这种时间bias的传统方法是使用`video age` 即一个video上传到样本生成的这段时间跨度 这么说可能有些懵, 看个图吧, 原来这是两个东西:
<div align=center>
<img src="https://img-blog.csdnimg.cn/10475c194c0044a3a93b01a3193e294f.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
王喆老师那篇文章里面也谈到了这两种理解, 对于某个视频的不同样本,其实这两种定义是等价的,因为他们的和是一个常数。
$$
t_{\text {video age }}+t_{\text {example age }}=\text { Const }
$$
详细证明可以看参考的第三篇文章。但`example age`的定义有下面两点好处:
1. 线上预测时`example age`是常数值, 所有item可以设置成统一的 但如果是`video age`的话,这个根每个视频的上传时间有关, 那这样在计算用户向量的时候就依赖每个候选item了。 而统一的这个好处就是用户向量只需要计算一次。
2. 对不同的视频,对应的`example age`所在范围一致, 只依赖训练数据选取的时间跨度,便于归一化操作。
### 实验结果
这里就简单过下就好, 作者这里主要验证了下DNN的结构对推荐效果的影响对于DNN的层级作者尝试了0~4层 实验结果是**层数越多越好, 但4层之后提升很有限 层数越多训练越困难**
<div align=center>
<img src="https://img-blog.csdnimg.cn/fd1849a8881444fbb12490bad7598125.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
作者这里还启发了一个事情, 从"双塔"的角度再看YouTubeDNN召回模型 这里的DNN个结构其实就是一个用户塔 输入用户的特征最终通过DNN编码出了用户的embedding向量。
而得到用户embedding向量到后面做softmax那块不是说了会经过一个item embedding矩阵吗 其实这个矩阵也可以用一个item塔来实现 和用户embedding计算的方式类似 首先各个item通过一个物品塔(输入是item 特征, 输出是item embedding)这样其实也能得到每个item的embedding然后做多分类或者是二分类等。 所以**YouTubeDNN召回模型本质上还是双塔结构** 只不过上面图里面值体现了用户塔。 我看deepmatch包里面实现的时候 用户特征和item特征分开输入的 感觉应该就是实现了个双塔。源码倒是没看, 等看了之后再确认。
### 线上服务
线上服务的时候, YouTube采用了一种最近邻搜索的方法去完成topK推荐这其实是工程与学术trade-off的结果 model serving过程中对几百万个候选集一一跑模型显然不现实 所以通过召回模型得到用户和video的embedding之后 用最近邻搜索的效率会快很多。
我们甚至不用把任何model inference的过程搬上服务器只需要把user embedding和video embedding存到redis或者内存中就好了。like this:
<div align=center>
<img src="https://img-blog.csdnimg.cn/86751a834d224ad69220b5040e0e03c9.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
在线上可以根据用户兴趣Embedding采用类似Faiss等高效Embedding检索工具快速找出和用户兴趣匹配的物品 高效embedding检索工具 我目前接触到了两个一个是Faiss 一个是annoy 关于这两个工具的使用, 我也整理了两篇文章:
* [annoy(快速近邻向量搜索包)学习小记](https://blog.csdn.net/wuzhongqiang/article/details/122516942?spm=1001.2014.3001.5501)
* [Faiss(Facebook开源的高效相似搜索库)学习小记](https://blog.csdn.net/wuzhongqiang/article/details/122516942?spm=1001.2014.3001.5501)
之前写新闻推荐比赛的时候用过Faiss 这次实验中使用的是annoy工具包。
另外多整理一点:
>我们做线上召回的时候, 其实可以有两种:
>1. item_2_item: 因为我们有了所有item的embedding了 那么就可以进行物品与物品之间相似度计算每个物品得到近似的K个 这时候,就和协同过滤原理一样, 之间通过用户观看过的历史item就能进行相似召回了 工程实现上一般会每个item建立一个相似度倒排表
>2. user_2_item: 将item用faiss或者annoy组织成index然后用user embedding去查相近item
## 基于Deepmatch包YouTubeDNN的使用方法
由于时间原因, 我这里并没有自己写代码复现YouTubeDNN模型这个结构也比较简单 几层的DNN自己再写一遍剖析架构也没有啥意思 所以就采用浅梦大佬写的deepmatch包 直接用到了自己的数据集上做了实验。 关于Deepmatch源码 还是看[deepmatch项目](https://github.com/shenweichen/DeepMatch) 这里主要是整理下YouTubeDNN如何用。
项目里面其实给出了如何使用YouTubeDNN采用的是movielens数据集 见[这里](https://github.com/shenweichen/DeepMatch/blob/master/examples/run_youtubednn.py)
我这里就基于我做实验用的新闻推荐数据集, 把代码的主要逻辑过一遍。
### 数据集
实验用的数据集是新闻推荐的一个数据集是做func-rec项目时候一个伙伴分享的来自于某个推荐比赛因为这个数据集是来自工业上的真实数据所以使用起来比之前用的movielens数据集可尝试的东西多一些并且原数据有8个多G总共3个文件: 用户画像,文章画像, 点击日志用户数量100多万6000多万次点击 文章规模是几百,数据量也比较丰富,所以后面就打算采用这个统一的数据集, 重新做实验对比目前GitHub上的各个模型。关于数据集每个文件详细描述后面会更新到GitHub项目。
这里只整理我目前的使用过程, 由于有8个多G的数据我这边没法直接跑所以对数据进行了采样 采样方法写成了一个jupyter文件。 主要包括:
1. 分块读取数据, 无法一下子读入内存
2. 对于每块数据基于一些筛选规则进行记录的删除比如只用了后7天的数据 删除了一些文章不在物料池的数据, 删除不合法的点击记录(曝光时间大于文章上传时间) 删除没有历史点击的用户删除观看时间低于3s的视频 删除历史点击序列太短和太长的用户记录
3. 删除完之后重新保存一份新数据集大约3个G然后再从这里面随机采样了20000用户进行了后面实验
通过上面的一波操作, 我的小本子就能跑起来了当然可能数据比较少最终训练的YouTubeDNN效果并不是很好。详细看后面GitHub的: `点击日志数据集初步处理与采样.ipynb`
### 简单数据预处理
这个也是写成了一个笔记本, 主要是看了下采样后的数据,序列长度分布等,由于上面做了一些规整化,这里有毛病的数据不是太多,并没有太多处理, 但是用户数据里面的年龄,性别源数据是给出了多种可能, 每个可能有概率值,我这里选出了概率最大的那个,然后简单填充了缺失。
最后把能用到的用户画像和文章画像统一拼接到了点击日志数据,又保存了一份。 作为YouTubeDNN模型的使用数据 其他模型我也打算使用这份数据了。
详见`EDA与数据预处理.ipynb`
### YouTubeDNN召回
这里就需要解释下一些代码了, 首先拿到采样的数据集,我们先划分下训练集和测试集:
* 测试集: 每个用户的最后一次点击记录
* 训练集: 每个用户除最后一次点击的所有点击记录
这个具体代码就不在这里写了。
```python
user_click_hist_df, user_click_last_df = get_hist_and_last_click(click_df)
```
这么划分的依据,就是保证不能发生数据穿越,拿最后的测试,不能让模型看到。
接下来就是YouTubeDNN模型的召回从构造数据集 -> 训练模型 -> 产生召回结果,我写到了一个函数里面去。
```cpp
def youtubednn_recall(data, topk=200, embedding_dim=8, his_seq_maxlen=50, negsample=0,
batch_size=64, epochs=1, verbose=1, validation_split=0.0):
"""通过YouTubeDNN模型计算用户向量和文章向量
param: data:
topk:
"""
user_id_raw = data[['user_id']].drop_duplicates('user_id')
doc_id_raw = data[['article_id']].drop_duplicates('article_id')
# 类别数据编码
base_features = ['user_id', 'article_id', 'city', 'age', 'gender']
feature_max_idx = {}
for f in base_features:
lbe = LabelEncoder()
data[f] = lbe.fit_transform(data[f])
feature_max_idx[f] = data[f].max() + 1
# 构建用户id词典和doc的id词典方便从用户idx找到原始的id
user_id_enc = data[['user_id']].drop_duplicates('user_id')
doc_id_enc = data[['article_id']].drop_duplicates('article_id')
user_idx_2_rawid = dict(zip(user_id_enc['user_id'], user_id_raw['user_id']))
doc_idx_2_rawid = dict(zip(doc_id_enc['article_id'], doc_id_raw['article_id']))
# 保存下每篇文章的被点击数量, 方便后面高热文章的打压
doc_clicked_count_df = data.groupby('article_id')['click'].apply(lambda x: x.count()).reset_index()
doc_clicked_count_dict = dict(zip(doc_clicked_count_df['article_id'], doc_clicked_count_df['click']))
train_set, test_set = gen_data_set(data, doc_clicked_count_dict, negsample, control_users=True)
# 构造youtubeDNN模型的输入
train_model_input, train_label = gen_model_input(train_set, his_seq_maxlen)
test_model_input, test_label = gen_model_input(test_set, his_seq_maxlen)
# 构建模型并完成训练
model = train_youtube_model(train_model_input, train_label, embedding_dim, feature_max_idx, his_seq_maxlen, batch_size, epochs, verbose, validation_split)
# 获得用户embedding和doc的embedding 并进行保存
user_embs, doc_embs = get_embeddings(model, test_model_input, user_idx_2_rawid, doc_idx_2_rawid)
# 对每个用户,拿到召回结果并返回回来
user_recall_doc_dict = get_youtube_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk)
return user_recall_doc_dict
```
这里面说一下主要逻辑,主要是下面几步:
1. 用户id和文章id我们要先建立索引-原始id的字典因为我们模型里面是要把id转成embedding模型的表示形式会是{索引: embedding}的形式, 如果我们想得到原始id必须先建立起映射来
2. 把类别特征进行label Encoder 模型输入需要, embedding层需要这是构建词典常规操作 这里要记录下每个特征特征值的个数,建词典索引的时候用到,得知道词典大小
3. 保存了下每篇文章被点击数量, 方便后面对高热文章实施打压
4. 构建数据集
```python
rain_set, test_set = gen_data_set(data, doc_clicked_count_dict, negsample, control_users=True)
```
这个需要解释下, 虽然我们上面有了一个训练集,但是这个东西是不能直接作为模型输入的, 第一个原因是正样本太少样本数量不足我们得需要滑动窗口每个用户再滑动构造一些第二个是不满足deepmatch实现的模型输入格式所以gen_data_set这个函数是用deepmatch YouTubeDNN的第一个范式基本上得按照这个来只不过我加了一些策略上的尝试:
```python
def gen_data_set(click_data, doc_clicked_count_dict, negsample, control_users=False):
"""构造youtubeDNN的数据集"""
# 按照曝光时间排序
click_data.sort_values("expo_time", inplace=True)
item_ids = click_data['article_id'].unique()
train_set, test_set = [], []
for user_id, hist_click in tqdm(click_data.groupby('user_id')):
# 这里按照expo_date分开每一天用滑动窗口滑可能相关性更高些,另外这样序列不会太长因为eda发现有点击1111个的
#for expo_date, hist_click in hist_date_click.groupby('expo_date'):
# 用户当天的点击历史id
pos_list = hist_click['article_id'].tolist()
user_control_flag = True
if control_users:
user_samples_cou = 0
# 过长的序列截断
if len(pos_list) > 50:
pos_list = pos_list[-50:]
if negsample > 0:
neg_list = gen_neg_sample_candiate(pos_list, item_ids, doc_clicked_count_dict, negsample, methods='multinomial')
# 只有1个的也截断 去掉,当然我之前做了处理,这里没有这种情况了
if len(pos_list) < 2:
continue
else:
# 序列至少是2
for i in range(1, len(pos_list)):
hist = pos_list[:i]
# 这里采用打压热门item策略降低高展item成为正样本的概率
freq_i = doc_clicked_count_dict[pos_list[i]] / (np.sum(list(doc_clicked_count_dict.values())))
p_posi = (np.sqrt(freq_i/0.001)+1)*(0.001/freq_i)
# p_posi=0.3 表示该item_i成为正样本的概率是0.3
if user_control_flag and i != len(pos_list) - 1:
if random.random() > (1-p_posi):
row = [user_id, hist[::-1], pos_list[i], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], hist_click.iloc[i]['example_age'], 1, len(hist[::-1])]
train_set.append(row)
for negi in range(negsample):
row = [user_id, hist[::-1], neg_list[i*negsample+negi], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], hist_click.iloc[i]['example_age'], 0, len(hist[::-1])]
train_set.append(row)
if control_users:
user_samples_cou += 1
# 每个用户序列最长是50 即每个用户正样本个数最多是50个, 如果每个用户训练样本数量到了30个训练集不能加这个用户了
if user_samples_cou > 30:
user_samples_cou = False
# 整个序列加入到test_set 注意,这里一定每个用户只有一个最长序列,相当于测试集数目等于用户个数
elif i == len(pos_list) - 1:
row = [user_id, hist[::-1], pos_list[i], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], 0, 0, len(hist[::-1])]
test_set.append(row)
random.shuffle(train_set)
random.shuffle(test_set)
return train_set, test_set
```
关键代码逻辑是首先点击数据按照时间戳排序,然后按照用户分组,对于每个用户的历史点击, 采用滑动窗口的形式,边滑动边构造样本, 第一个注意的地方,是每滑动一次生成一条正样本的时候, 要加入一定比例的负样本进去, 第二个注意最后一整条序列要放到test_set里面。<br><br>我这里面加入的一些策略负样本候选集生成我单独写成一个函数因为尝试了随机采样和打压热门item采样两种方式 可以通过methods参数选择。 另外一个就是正样本里面也按照热门实现了打压, 减少高热item成为正样本概率增加高热item成为负样本概率。 还加了一个控制用户样本数量的参数,去保证每个用户生成一样多的样本数量,打压下高活用户。
5. 构造模型输入
这个也是调包的定式操作,必须按照这个写法来:
```python
def gen_model_input(train_set, his_seq_max_len):
"""构造模型的输入"""
# row: [user_id, hist_list, cur_doc_id, city, age, gender, label, hist_len]
train_uid = np.array([row[0] for row in train_set])
train_hist_seq = [row[1] for row in train_set]
train_iid = np.array([row[2] for row in train_set])
train_u_city = np.array([row[3] for row in train_set])
train_u_age = np.array([row[4] for row in train_set])
train_u_gender = np.array([row[5] for row in train_set])
train_u_example_age = np.array([row[6] for row in train_set])
train_label = np.array([row[7] for row in train_set])
train_hist_len = np.array([row[8] for row in train_set])
train_seq_pad = pad_sequences(train_hist_seq, maxlen=his_seq_max_len, padding='post', truncating='post', value=0)
train_model_input = {
"user_id": train_uid,
"click_doc_id": train_iid,
"hist_doc_ids": train_seq_pad,
"hist_len": train_hist_len,
"u_city": train_u_city,
"u_age": train_u_age,
"u_gender": train_u_gender,
"u_example_age":train_u_example_age
}
return train_model_input, train_label
```
上面构造数据集的时候,是把每个特征加入到了二维数组里面去, 这里得告诉模型,每一个维度是啥特征数据。如果相加特征,首先构造数据集的时候,得把数据加入到数组中, 然后在这个函数里面再指定新加入的特征是啥。 下面的那个词典, 是为了把数据输入和模型的Input层给对应起来通过字典键进行标识。
6. 训练YouTubeDNN
这一块也是定式, 在建模型事情,要把特征封装起来,告诉模型哪些是离散特征,哪些是连续特征, 模型要为这些特征建立不同的Input层处理方式是不一样的
```python
def train_youtube_model(train_model_input, train_label, embedding_dim, feature_max_idx, his_seq_maxlen, batch_size, epochs, verbose, validation_split):
"""构建youtubednn并完成训练"""
# 特征封装
user_feature_columns = [
SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim),
VarLenSparseFeat(SparseFeat('hist_doc_ids', feature_max_idx['article_id'], embedding_dim,
embedding_name="click_doc_id"), his_seq_maxlen, 'mean', 'hist_len'),
SparseFeat('u_city', feature_max_idx['city'], embedding_dim),
SparseFeat('u_age', feature_max_idx['age'], embedding_dim),
SparseFeat('u_gender', feature_max_idx['gender'], embedding_dim),
DenseFeat('u_example_age', 1,)
]
doc_feature_columns = [
SparseFeat('click_doc_id', feature_max_idx['article_id'], embedding_dim)
# 这里后面也可以把文章的类别画像特征加入
]
# 定义模型
model = YoutubeDNN(user_feature_columns, doc_feature_columns, num_sampled=5, user_dnn_hidden_units=(64, embedding_dim))
# 模型编译
model.compile(optimizer="adam", loss=sampledsoftmaxloss)
# 模型训练这里可以定义验证集的比例如果设置为0的话就是全量数据直接进行训练
history = model.fit(train_model_input, train_label, batch_size=batch_size, epochs=epochs, verbose=verbose, validation_split=validation_split)
return model
```
然后就是建模型编译训练即可。这块就非常简单了当然模型方面有些参数可以了解下另外一个注意点就是这里用户特征和item特征进行了分开 这其实和双塔模式很像, 用户特征最后编码成用户向量, item特征最后编码成item向量。
7. 获得用户向量和item向量
模型训练完之后就能从模型里面拿用户向量和item向量 我这里单独写了一个函数:
```python
获取用户embedding和文章embedding
def get_embeddings(model, test_model_input, user_idx_2_rawid, doc_idx_2_rawid, save_path='embedding/'):
doc_model_input = {'click_doc_id':np.array(list(doc_idx_2_rawid.keys()))}
user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
doc_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
# 保存当前的item_embedding 和 user_embedding 排序的时候可能能够用到但是需要注意保存的时候需要和原始的id对应
user_embs = user_embedding_model.predict(test_model_input, batch_size=2 ** 12)
doc_embs = doc_embedding_model.predict(doc_model_input, batch_size=2 ** 12)
# embedding保存之前归一化一下
user_embs = user_embs / np.linalg.norm(user_embs, axis=1, keepdims=True)
doc_embs = doc_embs / np.linalg.norm(doc_embs, axis=1, keepdims=True)
# 将Embedding转换成字典的形式方便查询
raw_user_id_emb_dict = {user_idx_2_rawid[k]: \
v for k, v in zip(user_idx_2_rawid.keys(), user_embs)}
raw_doc_id_emb_dict = {doc_idx_2_rawid[k]: \
v for k, v in zip(doc_idx_2_rawid.keys(), doc_embs)}
# 将Embedding保存到本地
pickle.dump(raw_user_id_emb_dict, open(save_path + 'user_youtube_emb.pkl', 'wb'))
pickle.dump(raw_doc_id_emb_dict, open(save_path + 'doc_youtube_emb.pkl', 'wb'))
# 读取
#user_embs_dict = pickle.load(open('embedding/user_youtube_emb.pkl', 'rb'))
#doc_embs_dict = pickle.load(open('embedding/doc_youtube_emb.pkl', 'rb'))
return user_embs, doc_embs
```
获取embedding的这两行代码是固定操作 下面做了一些归一化操作以及把索引转成了原始id的形式。
8. 向量最近邻检索为每个用户召回相似item
```python
def get_youtube_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk):
"""近邻检索这里用annoy tree"""
# 把doc_embs构建成索引树
f = user_embs.shape[1]
t = AnnoyIndex(f, 'angular')
for i, v in enumerate(doc_embs):
t.add_item(i, v)
t.build(10)
# 可以保存该索引树 t.save('annoy.ann')
# 每个用户向量, 返回最近的TopK个item
user_recall_items_dict = collections.defaultdict(dict)
for i, u in enumerate(user_embs):
recall_doc_scores = t.get_nns_by_vector(u, topk, include_distances=True)
# recall_doc_scores是(([doc_idx], [scores])) 这里需要转成原始doc的id
raw_doc_scores = list(recall_doc_scores)
raw_doc_scores[0] = [doc_idx_2_rawid[i] for i in raw_doc_scores[0]]
# 转换成实际用户id
try:
user_recall_items_dict[user_idx_2_rawid[i]] = dict(zip(*raw_doc_scores))
except:
continue
# 默认是分数从小到大排的序, 这里要从大到小
user_recall_items_dict = {k: sorted(v.items(), key=lambda x: x[1], reverse=True) for k, v in user_recall_items_dict.items()}
# 保存一份
pickle.dump(user_recall_items_dict, open('youtube_u2i_dict.pkl', 'wb'))
return user_recall_items_dict
```
用了用户embedding和item向量就可以通过这个函数进行检索 这块主要是annoy包做近邻检索的固定格式 检索完毕为用户生成最相似的200个候选item。
以上就是使用YouTubeDNN做召回的整个流程。 效果如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/e904362d28fd4bdbacb5715ff2abaac2.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这个字典长这样:
<div align=center>
<img src="https://img-blog.csdnimg.cn/840e3abaf30845499f0926c61ba88635.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
接下来就是评估模型的效果这里我采用了简单的HR@N计算的 具体代码看GitHub吧 结果如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/eb6ccadaa98e46bd87e594ee11e957a7.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
结果不怎么样啊,唉, 难道是数据量太少了? 总归是跑起来且能用了。
详细代码见尾部GitHub链接吧 硬件设施到位的可以尝试多用一些数据试试看哈哈。
## YouTubeDNN新闻推荐数据集的实验记录
这块就比较简单了,简单的整理下我用上面代码做个的实验,尝试了论文里面的几个点,记录下:
1. 负采样方式上尝试了随机负采样和打压高热item两种方式 从我的实验结果上来看, 带打压的效果略好一点点
<div align=center>
<img src="https://img-blog.csdnimg.cn/7cf27f1b849049f0b4bd98d0ebb7925f.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
2. 特征上, 尝试原论文给出的example age的方式做一个样本的年龄特征出来
这个年龄样本我是用的训练集的最大时间减去曝光的时间然后转成小时间隔算的而测试集里面的统一用0表示 但效果好差。 看好多文章说这个时间单位是个坑,不知道是小时,分钟,另外这个特征我只做了简单归一化,感觉应该需要做归一化
<div align=center>
<img src="https://img-blog.csdnimg.cn/1ea482f538c94b8bb07a69023b14ca9b.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
3. 尝试了控制用户数量,即每个用户的样本数量保持一样,效果比上面略差
<div align=center>
<img src="https://img-blog.csdnimg.cn/8653b76d0b434d1088da196ce94bb954.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
4. 开始模型评估,我尝试用最后一天的,而不是最后一次点击的, 感觉效果不如最后一次点击作为测试集效果好
当然,上面实验并没有太大说服力,第一个是我采样的数据量太少,模型本身训练的不怎么样,第二个这些策略相差的并不是很大, 可能有偶然性。
并且我这边做一次实验,要花费好长时间,探索就先到这里吧, example age那个确实是个迷 其他的感觉起来, 打压高活效果要比不打压要好。
另外要记录下学习小tricks:
> 跑一次这样的实验,我这边一般会花费两个小时左右的时间, 而这个时间在做实验之前,一定要做规划才能好好的利用起来, 比如,我计划明天上午要开始尝试各种策略做实验, 今天晚上的todo里面就要记录好 我会尝试哪些策略,记录一个表, 调整策略,跑模型的时候,我这段空档要干什么事情, todo里面都要记录好比如我这段空档就是解读这篇paper写完这篇博客基本上是所有实验做完我这篇博客也差不多写完正好哈哈<br><br>这个空档利用一定要提前在todo里面写好而不是跑模型的时候再想这个时候往往啥也干不下去并且还会时不时的看模型跑或者盯着进度条发呆那这段时间就有些浪费了呀即使这段时间不学习看个久违的电视剧 久违的书或者keep下不香吗哈哈 但得提前规划。<br><br>可能每个人习惯不一样,对于我,是这样哈,所以记录下 ;)
## 总结
由于这篇文章里面的工程经验太多啦,我前面介绍的时候,可能涉及到知识的一些扩展补充,把经验整理的比较凌乱,这里再统一整理下, 这些也都是工业界常用的一些经验了:
召回部分:
1. 训练数据的样本来源应该是全部物料, 而不仅仅是被推荐的物料,否则对于新物料难以曝光
2. 训练数据中对于每个用户选取相同的样本数, 保证用户在损失函数等权重, 这个虽然不一定非得这么做但考虑打压高活用户或者是高活item的影响还是必须的
3. 序列无序化: 用户的最近一次搜索与搜索之后的播放行为有很强关联,为了避免信息泄露,将搜索行为顺序打乱。
4. 训练数据构造: 预测接下来播放而不是用传统cbow中的两侧预测中间的考虑是可以防止信息泄露并且可以学习到用户的非对称视频消费模式
5. 召回模型中类似word2vecvideo 有input embedding和output embedding两组embedding并不是共享的 input embedding论文里面是用w2v事先训练好的 其实也可以用embedding层联合训练
6. 召回模型的用户embedding来自网络输出 而video的embedding往往用后面output处的
7. 使用 `example age` 特征处理 time bias这样线上检索时可以预先计算好用户向量
**参考资料**
* [重读Youtube深度学习推荐系统论文](https://zhuanlan.zhihu.com/p/52169807)
* [YouTube深度学习推荐系统的十大工程问题](https://zhuanlan.zhihu.com/p/52169807)
* [你真的读懂了Youtube DNN推荐论文吗](https://zhuanlan.zhihu.com/p/372238343)
* [推荐系统经典论文(二)】YouTube DNN](https://zhuanlan.zhihu.com/p/128597084)
* [张俊林-推荐技术发展趋势与召回模型](https://www.icode9.com/content-4-764359.html)
* [揭开YouTube深度推荐系统模型Serving之谜](https://zhuanlan.zhihu.com/p/61827629)
* [Deep Neural Networks for YouTube Recommendations YouTubeDNN推荐召回与排序](https://www.pianshen.com/article/82351182400/)

View File

@@ -0,0 +1,308 @@
# 背景介绍
**文章核心思想**
+ 在大规模的推荐系统中利用双塔模型对user-item对的交互关系进行建模学习 $\{usercontext\}$ 向量与 $\{item\}$ 向量.
+ 针对大规模流数据提出in-batch softmax损失函数与流数据频率估计方法(Streaming Frequency Estimation)可以更好的适应item的多种数据分布。
**文章主要贡献**
+ 提出了改进的流数据频率估计方法针对流数据来估计item出现的频率利用实验分析估计结果的偏差与方差模拟实验证明该方法在数据动态变化时的功效
+ 提出了双塔模型架构:提供了一个针对大规模的检索推荐系统,包括了 in-batch softmax 损失函数与流数据频率估计方法减少了负采样在每个batch中可能会出现的采样偏差问题。
# 算法原理
给定一个查询集 $Query: \left\{x_{i}\right\}_{i=1}^{N}$ 和一个物品集$Item:\left\{y_{j}\right\}_{j=1}^{M}$。
+ $x_{i} \in X,\quad y_{j} \in \mathcal{Y}$ 是由多种特征例如稀疏ID和 Dense 特征)组成的高维混合体。
+ 推荐的目标是对于给定一个 $query$,检索到一系列 $item$ 子集用于后续排序推荐任务。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506202824884.png" alt="image-20220506202824884" style="zoom:50%;" />
## 模型目标
模型结构如上图所示,论文旨在对用户和物品建立两个不同的模型,将它们投影到相同维度的空间:
$$
u: X \times \mathbb{R}^{d} \rightarrow \mathbb{R}^{k}, v: y \times \mathbb{R}^{d} \rightarrow \mathbb{R}^{k}
$$
模型的输出为用户与物品向量的内积:
$$
s(x, y)=\langle u(x, \theta), v(y, \theta)\rangle
$$
模型的目标是为了学习参数 $\theta$ 样本集被表示为如下格式 $\{query, item, reward \}$
$$
\mathcal{T}:=\left\{\left(x_{i}, y_{i}, r_{i}\right)\right\}_{i=1}^{T}
$$
* 在推荐系统中,$r_i$ 可以扩展来捕获用户对不同候选物品的参与度。
* 例如,在新闻推荐中 $r_i$ 可以是用户在某篇文章上花费的时间。
## 模型流程
1. 给定用户 $x$,基于 softmax 函数从物料库 $M$ 中选中候选物品 $y$ 的概率为:
$$
\mathcal{P}(y \mid x ; \theta)=\frac{e^{s(x, y)}}{\sum_{j \in[M]} e^{s\left(x, y_{j}\right)}}
$$
* 考虑到相关奖励 $r_i$ ,加权对数似然函数的定义如下:
$$
L_{T}(\theta):=-\frac{1}{T} \sum_{i \in[T]} r_{i} \cdot \log \left(\mathcal{P}\left(y_{i} \mid x_{i} ; \theta\right)\right)
$$
2. 原表达式 $\mathcal{P}(y \mid x ; \theta)$ 中的分母需要遍历物料库中所有的物品,计算成本太高,故对分母中的物品要进行负采样。为了提高负采样的速度,一般是直接从训练样本所在 Batch 中进行负样本选择。于是有:
$$
\mathcal{P}_{B}\left(y_{i} \mid x_{i} ; \theta\right)=\frac{e^{s\left(x_{i}, y_{i}\right)}}{\sum_{j \in[B]} e^{s\left(x_{i}, y_{j}\right)}}
$$
* 其中,$B$ 表示与样本 $\{x_i,y_j\}$ 同在一个 Batch 的物品集合。
* 举例来说对于用户1Batch 内其他用户的正样本是用户1的负样本。
3. 一般而言,负采样分为 Easy Negative Sample 和 Hard Negative Sample。
+ 这里的 Easy Negative Sample 一般是直接从全局物料库中随机选取的负样本,由于每个用户感兴趣的物品有限,而物料库又往往很大,故即便从物料库中随机选取负样本,也大概率是用户不感兴趣的。
+ 在真实场景中,热门物品占据了绝大多数的购买点击。而这些热门物品往往只占据物料库物品的少部分,绝大部分物品是冷门物品。
+ 在物料库中随机选择负样本,往往被选中的是冷门物品。这就会造成马太效应,热门物品更热,冷门物品更冷。
+ 一种解决方式时,在对训练样本进行负采样时,提高热门物品被选为负样本的概率,工业界的经验做法是物品被选为负样本的概率正比于物品点击次数的 0.75 次幂。
+ 前面提到 Batch 内进行负采样,热门物品出现在一个 Batch 的概率正比于它的点击次数。问题是,热门物品被选为负样本的概率过高了(一般正比于点击次数的 0.75 次幂),导致热门物品被过度打压。
+ 在本文中,为了避免对热门物品进行过度惩罚,进行了纠偏。公式如下:
$$
s^{c}\left(x_{i}, y_{j}\right)=s\left(x_{i}, y_{j}\right)-\log \left(p_{j}\right)
$$
+ 在内积 $s(x_i,y_j)$ 的基础上,减去了物品 $j$ 的采样概率的对数。
4. 纠偏后,物品 $y$ 被选中的概率为:
$$
\mathcal{P}_{B}^{c}\left(y_{i} \mid x_{i} ; \theta\right)=\frac{e^{s^{c}\left(x_{i}, y_{i}\right)}}{e^{s^{c}\left(x_{i}, y_{i}\right)}+\sum_{j \in[B], j \neq i} e^{s^{c}\left(x_{i}, y_{j}\right)}}
$$
+ 此时batch loss function 的表示式如下:
$$
L_{B}(\theta):=-\frac{1}{B} \sum_{i \in[B]} r_{i} \cdot \log \left(\mathcal{P}_{B}^{c}\left(y_{i} \mid x_{i} ; \theta\right)\right)
$$
+ 通过 SGD 和学习率,来优化模型参数 $\theta$
$$
\theta \leftarrow \theta-\gamma \cdot \nabla L_{B}(\theta)
$$
5. Normalization and Temperature
* 最后一层,得到用户和物品的特征 Embedding 表示后,再进行进行 $l2$ 归一化:
$$
\begin{aligned}
u(x, \theta) \leftarrow u(x, \theta) /\|u(x, \theta)\|_{2}
\\
v(y, \theta) \leftarrow v(y, \theta) /\|v(y, \theta)\|_{2}
\end{aligned}
$$
+ 本质上,其实就是将用户和物品的向量内积转换为了余弦相似度。
* 对于内积的结果,再除以温度参数 $\tau$
$$
s(x, y)=\langle u(x, \theta), v(y, \theta)\rangle / \tau
$$
+ 论文提到,这样有利于提高预测准确度。
+ 从实验结果来看,温度参数 $\tau$ 一般小于 $1$,所以感觉就是放大了内积结果。
**上述模型训练过程可以归纳为:**
1从实时数据流中采样得到一个 batch 的训练样本。
2基于流频估计法估算物品 $y_i$ 的采样概率 $p_i$ 。
3计算损失函数 $L_B$ ,再利用 SGD 方法更新参数。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506211935092.png" alt="image-20220506211935092" style="zoom: 50%;" />
## 流频估计算法
考虑一个随机的数据 batch ,每个 batch 中包含一组物品。现在的问题是如何估计一个 batch 中物品 $y$ 的命中概率。具体方法如下:
+ 利用全局步长,将对物品采样频率 $p$ 转换为 对 $\delta$ 的估计,其中 $\delta$ 表示连续两次采样物品之间的平均步数。
+ 例如,某物品平均 50 个步后会被采样到,那么采样频率 $p=1/\delta=0.02$ 。
**具体的实现方法为:**
1. 建立两个大小为 $H$ 的数组 $A,B$ 。
2. 通过哈希函数 $h(\cdot)$ 可以把每个物品映射为 $[H]$ 范围内的整数。
+ 映射的内容可以是 ID 或者其他的简单特征值。
+ 对于给定的物品 $y$,哈希后的整数记为 $h(y)$,本质上它表示物品 $y$ 在数组中的序号。
3. 数组 $A$ 中存放的 $A[h(y)]$ 表示物品 $y$ 上次被采样的时间, 数组 $B$ 中存放的 $B[h(y)]$ 表示物品 $y$ 的全局步长。
+ 假设在第 $t$ 步时采样到物品 $y$,则 $A[h(y)]$ 和 $B[h(y)]$ 的更新公式为:
$$
B[h(y)] \leftarrow(1-\alpha) \cdot B[h(y)]+\alpha \cdot(t-A[h(y)])
$$
+ 在$B$ 被更新后,将 $t$ 赋值给 $A[h(y)]$ 。
4. 对整个batch数据采样后取数组 $B$ 中 $B[h(y)]$ 的倒数,作为物品 $y$ 的采样频率,即:
$$
\hat{p}=1 / B[h(y)]
$$
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506220529932.png" alt="image-20220506220529932" style="zoom:50%;" />
**从数学理论上证明这种迭代更新的有效性:**
假设物品 $y$ 被采样到的时间间隔序列为 $\Delta=\left\{\Delta_{1}, \ldots, \Delta_{t}\right\}$ 满足独立同分布,这个随机变量的均值为$\delta=E[\Delta]$。对于每一次采样迭代:$\delta_{i}=(1-\alpha) \delta_{i-1}+\alpha \Delta_{i}$,可以证明时间间隔序列的均值和方差满足:
$$
\begin{aligned}
& E\left(\delta_{t}\right)-\delta=(1-\alpha)^{t} \delta_{0}-(1-\alpha)^{t-1} \delta
\\ \\
& E\left[\left(\delta_{t}-E\left[\delta_{t}\right]\right)^{2}\right] \leq(1-\alpha)^{2 t}\left(\delta_{0}-\delta\right)^{2}+\alpha E\left[\left(\Delta_{1}-\alpha\right)^{2}\right]
\end{aligned}
$$
1. **对于均值的证明:**
$$
\begin{aligned}
E\left[\delta_{t}\right] &=(1-\alpha) E\left[\delta_{t-1}\right]+\alpha \delta \\
&=(1-\alpha)\left[(1-\alpha) E\left[\delta_{t-2}\right]+\alpha \delta\right]+\alpha \delta \\
&=(1-\alpha)^{2} E\left[\delta_{t-2}\right]+\left[(1-\alpha)^{1}+(1-\alpha)^{0}\right] \alpha \delta \\
&=(1-\alpha)^{3} E\left[\delta_{t-3}\right]+\left[(1-\alpha)^{2}+(1-\alpha)^{1}+(1-\alpha)^{0}\right] \alpha \delta \\
&=\ldots \ldots \\
&=(1-\alpha)^{t} \delta_{0}+\left[(1-\alpha)^{t-1}+\ldots+(1-\alpha)^{1}+(1-\alpha)^{0}\right] \alpha \delta \\
&=(1-\alpha)^{t} \delta_{0}+\left[1-(1-\alpha)^{t-1}\right] \delta
\end{aligned}
$$
+ 根据均值公式可以看出:$t \rightarrow \infty \text { 时, }\left|E\left[\delta_{t}\right]-\delta\right| \rightarrow 0 $ 。
+ 即当采样数据足够多的时候,数组 $B$ (每多少步采样一次)趋于真实采样频率。
+ 因此递推式合理,且当初始值 $\delta_{0}=\delta /(1-\alpha)$,递推式为无偏估计。
2. **对于方差的证明:**
$$
\begin{aligned}
E\left[\left(\delta_{t}-E\left[\delta_{t}\right]\right)^{2}\right] &=E\left[\left(\delta_{t}-\delta+\delta-E\left[\delta_{t}\right]\right)^{2}\right] \\
&=E\left[\left(\delta_{t}-\delta\right)^{2}\right]+2 E\left[\left(\delta_{t}-\delta\right)\left(\delta-E\left[\delta_{t}\right]\right)\right]+\left(\delta-E\left[\delta_{t}\right]\right)^{2} \\
&=E\left[\left(\delta_{t}-\delta\right)^{2}\right]-\left(E\left[\delta_{t}\right]-\delta\right)^{2} \\
& \leq E\left[\left(\delta_{t}-\delta\right)^{2}\right]
\end{aligned}
$$
+ 对于 $E\left[\left(\delta_{i}-\delta\right)^{2}\right]$
$$
\begin{aligned}
E\left[\left(\delta_{i}-\delta\right)^{2}\right] &=E\left[\left((1-\alpha) \delta_{i-1}+\alpha \Delta_{i}-\delta\right)^{2}\right] \\
&=E\left[\left((1-\alpha) \delta_{i-1}+\alpha \Delta_{i}-(1-\alpha+\alpha) \delta\right)^{2}\right] \\
&=E\left[\left((1-\alpha)\left(\delta_{i-1}-\delta\right)+\alpha\left(\Delta_{i}-\delta\right)\right)^{2}\right] \\
&=(1-\alpha)^{2} E\left[\left(\delta_{i-1}-\delta\right)^{2}\right]+\alpha^{2} E\left[\Delta_{i}-\delta\right]^{2}+2 \alpha(1-\alpha) E\left[\left(\delta_{i-1}-\delta\right)\left(\Delta_{i}-\delta\right)\right]
\end{aligned}
$$
+ 由于 $\delta_{i-1}$ 和 $\Delta_{i}$ 独立,所以上式最后一项为 0因此
$$
E\left[\left(\delta_{i}-\delta\right)^{2}\right]=(1-\alpha)^{2} E\left[\left(\delta_{i-1}-\delta\right)^{2}\right]+\alpha^{2} E\left[\Delta_{i}-\delta\right]^{2}
$$
+ 与均值的推导类似,可得:
$$
\begin{aligned}
E\left[\left(\delta_{t}-\delta\right)^{2}\right] &=(1-\alpha)^{2 t}\left(\delta_{0}-\delta\right)^{2}+\alpha^{2} \frac{1-(1-\alpha)^{2 t-2}}{1-(1-\alpha)^{2}} E\left[\left(\Delta_{1}-\delta\right)^{2}\right] \\
& \leq(1-\alpha)^{2 t}\left(\delta_{0}-\delta\right)^{2}+\alpha E\left[\left(\Delta_{1}-\delta\right)^{2}\right]
\end{aligned}
$$
+ 由此可证明:
$$
E\left[\left(\delta_{t}-E\left[\delta_{t}\right]\right)^{2}\right] \leq(1-\alpha)^{2 t}\left(\delta_{0}-\delta\right)^{2}+\alpha E\left[\left(\Delta_{1}-\alpha\right)^{2}\right]
$$
+ 对于方差,上式给了一个估计方差的上界。
## 多重哈希
上述流动采样频率估计算法存在的问题:
+ 对于不同的物品,经过哈希函数映射的整数可能相同,这就会导致哈希碰撞的问题。
+ 由于哈希碰撞,对导致对物品采样频率过高的估计。
**解决方法:**
* 使用 $m$ 个哈希函数,取 $m$ 个估计值中的最大值来表示物品连续两次被采样到之间的步长。
**具体的算法流程:**
1. 分别建立 $m$ 个大小为 $H$ 的数组 $\{A\}_{i=1}^{m}$$\{B\}_{i=1}^{m}$,一组对应的独立哈希函数集合 $\{h\}_{i=1}^{m}$ 。
2. 通过哈希函数 $h(\cdot)$ 可以把每个物品映射为 $[H]$ 范围内的整数。对于给定的物品 $y$,哈希后的整数记为$h(y)$
3. 数组 $A_i$ 中存放的 $A_i[h(y)]$ 表示在第 $i$ 个哈希函数中物品 $y$ 上次被采样的时间。数组 $B_i$ 中存放的 $B_i[h(y)]$ 表示在第 $i$ 个哈希函数中物品 $y$ 的全局步长。
4. 假设在第 $t$ 步采样到物品 $y$,分别对 $m$ 个哈希函数对应的 $A[h(y)]$ 和 $B[h(y)]$ 进行更新:
$$
\begin{aligned}
& B_i[h(y)] \leftarrow(1-\alpha) \cdot B_i[h(y)]+\alpha \cdot(t-A_i[h(y)])\\ \\
& A_i[h(y)]\leftarrow t
\end{aligned}
$$
5. 对整个 batch 数据采样后,取 $\{B\}_{i=1}^{m}$ 中最大的 $B[h(y)]$ 的倒数,作为物品 $y$ 的采样频率,即:
$$
\hat{p}=1 / \max _{i}\left\{B_{i}[h(y)]\right\}
$$
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506223731749.png" alt="image-20220506223731749" style="zoom:50%;" />
# YouTube 神经召回模型
本文构建的 YouTube 神经检索模型由查询和候选网络组成。下图展示了整体的模型架构。
![image-20220506224501697](https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506224501697.png)
在任何时间点,用户正在观看的视频,即种子视频,都会提供有关用户当前兴趣的强烈信号。因此,本文利用了大量种子视频特征以及用户的观看历史记录。候选塔是为了从候选视频特征中学习而构建的。
* Training Label
* 视频点击被用作正面标签。对于每次点击,我们都会构建一个 rewards 来反映用户对视频的不同程度的参与。
* $r_i$ = 0观看时间短的点击视频$r_i$ = 1表示观看了整个视频。
* Video Features
* YouTube 使用的视频特征包括 categorical 特征和 dense 特征。
* 例如 categorical 特征有 video id 和 channel id 。
* 对于 categorical 特征,都会创建一个嵌入层以将每个分类特征映射到一个 Embedding 向量。
* 通常 YouTube 要处理两种类别特征。从原文的意思来看,这两类应该是 one-hot 型和 multi-hot 型。
* User Features
* 使用**用户观看历史记录**来捕捉 seed video 之外的兴趣。将用户最近观看的 k个视频视为一个词袋BOW),然后将它们的 Embedding 平均。
* 在查询塔中,最后将用户和历史 seed video 的特征进行融合,并送入输入前馈神经网络。
* 类别特征的 Embedding 共享
* 原文For the same type of IDs, embeddings are shared among the related features. For example, the same set of video id embeddings is used for seed, candidate and users past watches. We did experiment with non-shared embeddings, but did not observe significant model quality improvement.
* 大致意思就是,对于相同 ID 的类别,他们之间的 Embedding 是共享的。例如对于 seed video出现的地方包括用户历史观看以及作为候选物品故只要视频的 ID 相同Embedding也是相同的。如果不共享也没啥提升。
# 参考链接
+ [Sampling-bias-corrected neural modeling for large corpus item recommendations | Proceedings of the 13th ACM Conference on Recommender Systems](https://dl.acm.org/doi/abs/10.1145/3298689.3346996)
+ [【推荐系统经典论文(九)】谷歌双塔模型 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/137538147)
+ [借Youtube论文谈谈双塔模型的八大精髓问题 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/369152684)

View File

@@ -0,0 +1,114 @@
# 前言
在自然语言处理NLP领域谷歌提出的 Word2Vec 模型是学习词向量表示的重要方法。其中带有负采样SGNSSkip-gram with negative sampling的 Skip-Gram 神经词向量模型在当时被证明是最先进的方法之一。各位读者需要自行了解 Word2Vec 中的 Skip-Gram 模型,本文只会做简单介绍。
在论文 Item2VecNeural Item Embedding for Collaborative Filtering 中,作者受到 SGNS 的启发,提出了名为 Item2Vec 的方法来生成物品的向量表示,然后将其用于基于物品的协同过滤。
# 基于负采样的 Skip-Gram 模型
Skip-Gram 模型的思想很简单:给定一个句子 $(w_i)^K_{i=1}$,然后基于中心词来预测它的上下文。目标函数如下:
$$
\frac{1}{K} \sum_{i=1}^{K} \sum_{-c \leq j \leq c, j \neq 0} \log p\left(w_{i+j} \mid w_{i}\right)
$$
+ 其中,$c$ 表示上下文的窗口大小;$w_i$ 表示中心词;$w_{i+j}$ 表示上下文。
+ 表达式中的概率 $p\left(w_{j} \mid w_{i}\right)$ 的公式为:
$$
p\left(w_{j} \mid w_{i}\right)=\frac{\exp \left(u_{i}^{T} v_{j}\right)}{\sum_{k \in I_{W}} \exp \left(u_{i}^{T} v_{k}^{T}\right)}
$$
+ $u_{i} \in U\left(\subset \mathbb{R}^{m}\right),v_{i} \in V\left(\subset \mathbb{R}^{m}\right)$,分别对应中心和上下文词的 Embedding 特征表示。
+ 这里的意思是每个单词有2个特征表示作为中心词 $u_i$ 和上下文 $v_i$ 时的特征表示不一样。
+ $I_{W} \triangleq\{1, \ldots,|W|\}$ $|W|$ 表示语料库中词的数量。
简单来理解一下 Skip-Gram 模型的表达式:
+ 对于句子中的某个词 $w_i$,当其作为中心词时,希望尽可能准确预测它的上下文。
+ 我们可以将其转换为多分类问题:
+ 对于中心词 $w_i$ 预测的上下文 $w_j$,其 $label=1$ ;那么,模型对上下文的概率预测 $p\left(w_{j} \mid w_{i}\right)$ 越接近1越好。
+ 若要 $p\left(w_{j} \mid w_{i}\right)$ 接近1对于分母项中的 $k\ne j$,其 $\exp \left(u_{i}^{T} v_{k}^{T}\right)$ 越小越好(等价于将其视为了负样本)。
注意到分母项由于需要遍历语料库中所有的单词从而导致计算成本过高。一种解决办法是基于负采样NEG的方式来降低计算复杂度
$$
p\left(w_{j} \mid w_{i}\right)=\sigma\left(u_{i}^{T} v_{j}\right) \prod_{k=1}^{N} \sigma\left(-u_{i}^{T} v_{k}\right)
$$
+ 其中,$\sigma(x)=1/1+exp(-x)$$N$ 表示负样本的数量。
其它细节:
+ 单词 $w$ 作为负样本时,被采样到的概率:
$$
\frac{[\operatorname{counter}(w)]^{0.75}}{\sum_{u \in \mathcal{W}}[\operatorname{counter}(u)]^{0.75}}
$$
+ 单词 $w$ 作为中心词时,被丢弃的概率:
$$
\operatorname{prob}(w)=1-\sqrt{\frac{t}{f(w)}}
$$
# Item2Vec模型
Item2Vec 的原理十分十分简单,它是基于 Skip-Gram 模型的物品向量训练方法。但又存在一些区别,如下:
+ 词向量的训练是基于句子序列sequence但是物品向量的训练是基于物品集合set
+ 因此,物品向量的训练丢弃了空间、时间信息。
Item2Vec 论文假设对于一个集合的物品,它们之间是相似的,与用户购买它们的顺序、时间无关。当然,该假设在其他场景下不一定使用,但是原论文只讨论了该场景下它们实验的有效性。由于忽略了空间信息,原文将共享同一集合的每对物品视为正样本。目标函数如下:
$$
\frac{1}{K} \sum_{i=1}^{K} \sum_{j \neq i}^{K} \log p\left(w_{j} \mid w_{i}\right)
$$
+ 对于窗口大小 $K$,由设置的决定。
在 Skip-Gram 模型中,提到过每个单词 $w_i$ 有2个特征表示。在 Item2Vec 中同样如此,论文中是将物品的中心词向量 $u_i$ 作为物品的特征向量。作者还提到了其他两种方式来表示物品向量:
+ **add**$u_i + v_i$
+ **concat**$\left[u_{i}^{T} v_{i}^{T}\right]^{T}$
原文还补充到,这两种方式有时候会有很好的表现。
# 总结
+ Item2Vec 的原理很简单,就是基于 Word2Vec 的 Skip-Gram 模型,并且还丢弃了时间、空间信息。
+ 基于 Item2Vec 得到物品的向量表示后,物品之间的相似度可由二者之间的余弦相似度计算得到。
+ 可以看出Item2Vec 在计算物品之间相似度时,仍然依赖于不同物品之间的共现。因为,其无法解决物品的冷启动问题。
+ 一种解决方法:取出与冷启物品类别相同的非冷启物品,将它们向量的均值作为冷启动物品的向量表示。
原论文链接:[[1603.04259\] Item2Vec: Neural Item Embedding for Collaborative Filtering (arxiv.org)](https://arxiv.org/abs/1603.04259)

View File

@@ -0,0 +1,341 @@
# 背景和引入
在所有的NLP任务中首先面临的第一个问题是我们该如何表示单词。这种表示将作为inputs输入到特定任务的模型中如机器翻译文本分类等典型NLP任务。
## 同义词表达单词
一个很容易想到的解决方案是使用同义词来表示一个单词的意义。
比如***WordNet***,一个包含同义词和有“is a”关系的词的词库。
**导包**
```python
!pip install --user -U nltk
```
```python
!python -m nltk.downloader popular
```
**如获取"good"的同义词**
```python
from nltk.corpus import wordnet as wn
poses = { 'n':'noun', 'v':'verb', 's':'adj (s)', 'a':'adj', 'r':'adv'}
for synset in wn.synsets("good"):
print("{}: {}".format(poses[synset.pos()],", ".join([l.name() for l in synset.lemmas()])))
```
**如获取与“pandas”有"is a"关系的词**
```python
panda = wn.synset("panda.n.01")
hyper = lambda s: s.hypernyms()
list(panda.closure(hyper))
```
***WordNet的问题***
1. 单词与单词之间缺少些微差异的描述。比如“高效”只在某些语境下是"好"的同义词
2. 丢失一些词的新含义。比如“芜湖”,“蚌埠”等词的新含义
3. 相对主观
4. 需要人手动创建和调整
5. 无法准确计算单词的相似性
## one-hot编码
在传统NLP中人们使用one-hot向量一个向量只有一个值为1其余的值为0来表示单词
motel = [0 0 0 0 0 0 0 0 0 0 1 0 0 0 0]
hotel = [0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
one-hot向量的维度是词汇表的大小500000
注:上面示例词向量的维度为方便展示所以比较小
**one-hot向量表示单词的问题**
1. 这些词向量是***正交向量***,无法通过数学计算(如点积)计算相似性
2. 依赖WordNet等同义词库建立相似性效果也不好
## dense word vectors表达单词
如果我们可以使用某种方法为每个单词构建一个合适的dense vector如下图那么通过点积等数学计算就可以获得单词之间的某种联系
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1.png" />
# Word2vec
## 语言学基础
首先,我们引入一个上世纪五十年代,一个语言学家的研究成果:**“一个单词的意义由它周围的单词决定”**
“You shall know a word by the company it keeps” (J. R. Firth 1957: 11)
这是现代NLP中一个最为成功的理念。
我们先引入上下文context的概念当单词 w 出现在文本中时,其**上下文context**是出现在w附近的一组单词在固定大小的窗口内如下图
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片2.png" />
这些上下文单词context words决定了banking的意义
## Word2vec概述
Word2vec(Mikolov et al. 2013)是一个用来学习dense word vector的算法
1. 我们使用**大量的文本语料库**
2. 词汇表中的每个单词都由一个**词向量dense word vector**表示
3. 遍历文本中的每个位置 t都有一个**中心词 ccenter 和上下文词 o“outside”**如图1中的banking
4. 在整个语料库上使用数学方法**最大化单词o在单词c周围出现了这一事实**从而得到单词表中每一个单词的dense vector
5. 不断调整词向量dense word vector以达到最好的效果
## Skip-gram(SG)
Word2vec包含两个模型**Skip-gram与CBOW**。下面,我们先讲**Skip-gram**模型,用此模型详细讲解概述中所提到的内容。
概述中我们提到,我们希望**最大化单词o在单词c周围出现了这一事实**而我们需要用数学语言表示“单词o在单词c周围出现了”这一事件如此才能进行词向量的不断调整。
很自然地,我们需要**使用概率工具描述事件的发生**,我们想到用条件概率$P(o|c)$表示“给定中心词c,它的上下文词o在它周围出现了”
下图展示了以“into”为中心词窗口大小为2的情况下它的上下文词。以及相对应的$P(o|c)$
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片3.png" />
我们滑动窗口再以banking为中心词
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片4.png" />
那么,如果我们在整个语料库上不断地滑动窗口,我们可以得到所有位置的$P(o|c)$,我们希望在所有位置上**最大化单词o在单词c周围出现了这一事实**,由极大似然法,可得:
$$
max\prod_{c} \prod_{o}P(o|c)
$$
此式还可以依图3写为
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片5.png" />
加log,加负号,缩放大小可得:
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片7.png" />
上式即为**skip-gram的损失函数**,最小化损失函数,就可以得到合适的词向量
得到式1后产生了两个问题
1. P(o|c)怎么表示?
2. 为何最小化损失函数能够得到良好表示的词向量dense word vector
回答1我们使用**中心词c和上下文词o的相似性**来计算$P(o|c)$,更具体地,相似性由**词向量的点积**表示:$u_o \cdot v_c$。
使用词向量的点积表示P(o|c)的原因1.计算简单 2.出现在一起的词向量意义相关,则希望它们相似
又P(o|c)是一个概率,所以我们在整个语料库上使用**softmax**将点积的值映射到概率如图6
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片6.png" />
注:注意到上图,中心词词向量为$v_{c}$,而上下文词词向量为$u_{o}$。也就是说每个词会对应两个词向量,**在词w做中心词时使用$v_{w}$作为词向量,而在它做上下文词时,使用$u_{w}$作为词向量**。这样做的原因是为了求导等操作时计算上的简便。当整个模型训练完成后,我们既可以使用$v_{w}$作为词w的词向量也可以使用$u_{w}$作为词w的词向量亦或是将二者平均。在下一部分的模型结构中我们将更清楚地看到两个词向量究竟在模型的哪个位置。
回答2由上文所述$P(o|c)=softmax(u_{o^T} \cdot v_c)$。所以损失函数是关于$u_{o}$和$v_c$的函数,我们通过梯度下降法调整$u_{o}$和$v_c$的值,最小化损失函数,即得到了良好表示的词向量。
## Word2vec模型结构
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片8.png" />
如图八所示这是一个输入为1 X V维的one-hot向量V为整个词汇表的长度这个向量只有一个1值其余为0值表示一个词单隐藏层**隐藏层的维度为N这里是一个超参数这个参数由我们定义也就是词向量的维度**输出为1 X V维的softmax层的模型。
$W^{I}$为V X N的参数矩阵$W^{O}$为N X V的参数矩阵。
模型的输入为1 X V形状的one-hot向量V为整个词汇表的长度这个向量只有一个1值其余为0值表示一个词。隐藏层的维度为N这里是一个超参数这个参数由我们定义也就是词向量的维度。$W^{I}$为V X N的参数矩阵。
我们这里考虑Skip-gram算法输入为中心词c的one-hot表示
由输入层到隐藏层,根据矩阵乘法规则,可知,**$W^{I}$的每一行即为词汇表中的每一个单词的词向量v**,1 X V 的 inputs 乘上 V X N 的$W^{I}$隐藏层即为1 X N维的$v_{c}$。
而$W^{O}$中的每一列即为词汇表中的每一个单词的词向量u。根据乘法规则1 X N 的隐藏层乘上N X V的$W^{O}$参数矩阵得到的1 X V 的输出层的每一个值即为$u_{w^T} \cdot v_c$,加上softmax变化即为$P(w|c)$。
有V个w,其中的P(o|c)即实际样本中的上下文词的概率,为我们最为关注的值。
## CBOW
如上文所述Skip-gram为给定中心词预测周围的词即求P(o|c),如下图所示:
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220424105817437.png" />
而CBOW为给定周围的词预测中心词即求P(c|o),如下图所示:
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片10.png" />
注意在使用CBOW时上文所给出的模型结构并没有变在这里我们输入多个上下文词o在隐藏层**将这多个上下文词经过第一个参数矩阵的计算得到的词向量相加作为隐藏单元的值**。其余均不变,$W^{O}$中的每一列依然为为词汇表中的每一个单词的词向量u。
# 负采样 Negative Sampling
## softmax函数带来的问题
我们再看一眼通过softmax得到的$P(o|c)$,如图:
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220424105958191.png" />
可以看到,$P(o|c)$的分母需要在整个单词表上做乘积和exp运算这无疑是非常消耗计算资源的Word2vec的作者针对这个问题做出了改进。
他提出了两种改进的方法Hierarchical Softmax和Negative Sampling因为Negative Sampling更加常见所以我们下面只介绍Negative Sampling感兴趣的朋友可以在文章下面的参考资料中学习Hierarchical Softmax。
## 负采样Negative Sampling
我们依然以Skip-gram为例CBOW与之差别不大感兴趣的朋友们依然可以参阅参考资料
我们首先给出负采样的损失函数:
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片12.png" />
其中$\sigma$为sigmoid函数$1/(1+e^{-x})$, $u_{o}$为实际样本中的上下文词的词向量,而$u_{k}$为我们在单词表中随机选出按一定的规则随机选出具体可参阅参考资料的K个单词。
由函数单调性易知,**$u_{o^T} \cdot v_c$越大,损失函数越小,而$u_{k^T} \cdot v_c$越小**损失函数越大。这与原始的softmax损失函数优化目标一致即$maxP(o|c)$,而且避免了在整个词汇表上的计算。
# 核心代码与核心推导
## Naive softmax 损失函数
损失函数关于$v_c$的导数:
$$
\frac{\partial{J_{naive-softmax}(\boldsymbol v_c,o,\boldsymbol U)}}{\partial \boldsymbol v_c} \\=
-\frac{\partial{log(P(O=o|C=c))}}{\partial \boldsymbol v_c} \\ =
-\frac{\partial{log(exp( \boldsymbol u_o^T\boldsymbol v_c))}}{\partial \boldsymbol v_c} + \frac{\partial{log(\sum_{w=1}^{V}exp(\boldsymbol u_w^T\boldsymbol v_c))}}{\partial \boldsymbol v_c} \\=
-\boldsymbol u_o + \sum_{w=1}^{V} \frac{exp(\boldsymbol u_w^T\boldsymbol v_c)}{\sum_{w=1}^{V}exp(\boldsymbol u_w^T\boldsymbol v_c)}\boldsymbol u_w \\=
-\boldsymbol u_o+ \sum_{w=1}^{V}P(O=w|C=c)\boldsymbol u_w \\=
\boldsymbol U^T(\hat{\boldsymbol y} - \boldsymbol y)
$$
可以看到涉及整个U矩阵的计算计算量很大关于$u_w$的导数读者可自行推导
损失函数及其梯度的求解
来自https://github.com/lrs1353281004/CS224n_winter2019_notes_and_assignments
```python
def naiveSoftmaxLossAndGradient(
centerWordVec,
outsideWordIdx,
outsideVectors,
dataset
):
""" Naive Softmax loss & gradient function for word2vec models
Arguments:
centerWordVec -- numpy ndarray, center word's embedding
in shape (word vector length, )
(v_c in the pdf handout)
outsideWordIdx -- integer, the index of the outside word
(o of u_o in the pdf handout)
outsideVectors -- outside vectors is
in shape (num words in vocab, word vector length)
for all words in vocab (tranpose of U in the pdf handout)
dataset -- needed for negative sampling, unused here.
Return:
loss -- naive softmax loss
gradCenterVec -- the gradient with respect to the center word vector
in shape (word vector length, )
(dJ / dv_c in the pdf handout)
gradOutsideVecs -- the gradient with respect to all the outside word vectors
in shape (num words in vocab, word vector length)
(dJ / dU)
"""
# centerWordVec: (embedding_dim,1)
# outsideVectors: (vocab_size,embedding_dim)
scores = np.matmul(outsideVectors, centerWordVec) # size=(vocab_size, 1)
probs = softmax(scores) # size=(vocab, 1)
loss = -np.log(probs[outsideWordIdx]) # scalar
dscores = probs.copy() # size=(vocab, 1)
dscores[outsideWordIdx] = dscores[outsideWordIdx] - 1 # dscores=y_hat - y
gradCenterVec = np.matmul(outsideVectors, dscores) # J关于vc的偏导数公式 size=(vocab_size, 1)
gradOutsideVecs = np.outer(dscores, centerWordVec) # J关于u的偏导数公式 size=(vocab_size, embedding_dim)
return loss, gradCenterVec, gradOutsideVecs
```
## 负采样损失函数
负采样损失函数关于$v_c$的导数:
$$
\frac{\partial{J_{neg-sample}(\boldsymbol v_c,o,\boldsymbol U)}}{\partial\boldsymbol v_c} \\=
\frac{\partial (-log(\sigma (\boldsymbol u_o^T\boldsymbol v_c))-\sum_{k=1}^{K} log(\sigma (-\boldsymbol u_k^T\boldsymbol v_c)))}{\partial \boldsymbol v_c} \\=
-\frac{\sigma(\boldsymbol u_o^T\boldsymbol v_c)(1-\sigma(\boldsymbol u_o^T\boldsymbol v_c))}{\sigma(\boldsymbol u_o^T\boldsymbol v_c)}\frac{\partial \boldsymbol u_o^T\boldsymbol v_c}{\partial \boldsymbol v_c} -
\sum_{k=1}^{K}\frac{\partial log(\sigma(-\boldsymbol u_k^T\boldsymbol v_c))}{\partial \boldsymbol v_c} \\=
-(1-\sigma(\boldsymbol u_o^T\boldsymbol v_c))\boldsymbol u_o+\sum_{k=1}^{K}(1-\sigma(-\boldsymbol u_k^T\boldsymbol v_c))\boldsymbol u_k
$$
可以看到其只与$u_k$和$u_o$有关,避免了在整个单词表上的计算
负采样方法的损失函数及其导数的求解
```python
def negSamplingLossAndGradient(
centerWordVec,
outsideWordIdx,
outsideVectors,
dataset,
K=10
):
negSampleWordIndices = getNegativeSamples(outsideWordIdx, dataset, K)
indices = [outsideWordIdx] + negSampleWordIndices
gradCenterVec =np.zeros(centerWordVec.shape) # (embedding_size,1)
gradOutsideVecs = np.zeros(outsideVectors.shape) # (vocab_size, embedding_size)
loss = 0.0
u_o = outsideVectors[outsideWordIdx] # size=(embedding_size,1)
z = sigmoid(np.dot(u_o, centerWordVec)) # size=(1, )
loss -= np.log(z) # 损失函数的第一部分
gradCenterVec += u_o * (z - 1) # J关于vc的偏导数的第一部分
gradOutsideVecs[outsideWordIdx] = centerWordVec * (z - 1) # J关于u_o的偏导数计算
for i in range(K):
neg_id = indices[1 + i]
u_k = outsideVectors[neg_id]
z = sigmoid(-np.dot(u_k, centerWordVec))
loss -= np.log(z)
gradCenterVec += u_k * (1-z)
gradOutsideVecs[neg_id] += centerWordVec * (1 - z)
return loss, gradCenterVec, gradOutsideVecs
```
**参考资料**
- Mikolov T, Sutskever I, Chen K, et al. Distributed representations of words and phrases and their compositionality[J]. Advances in neural information processing systems, 2013, 26.
- https://www.cnblogs.com/peghoty/p/3857839.html
- http://web.stanford.edu/class/cs224n/