diff --git a/.vitepress/config.js b/.vitepress/config.js index 140610e..7572a1b 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -394,7 +394,7 @@ export default defineConfig({ { text: '4.6.6.2.3序列化推荐', link: '/4.人工智能/4.6.6.2.3序列化推荐' }, ] }, - { text: '4.6.6.3知识图谱', link: '/4.人工智能/4.6.6.3知识图谱' } + { text: '4.6.6.3知识图谱', link: '/4.人工智能/4.6.6.3知识图谱' }, ] }, { @@ -441,6 +441,114 @@ export default defineConfig({ { text: '4.10科研论文写作', link: '/4.人工智能/4.10科研论文写作' }, { text: '4.11从 AI 到 智能系统 —— 从 LLMs 到 Agents', link: '/4.人工智能/4.11从 AI 到 智能系统 —— 从 LLMs 到 Agents' }, { text: '4.12本章节内容的局限性', link: '/4.人工智能/4.12本章节内容的局限性' }, + { + text: 'FunRec', + collapsed: true, + items: [ + { text: 'FunRec概述', link: '/4.人工智能/FunRec概述' }, + { + text: '推荐系统概述', + collapsed: true, + items: [ + { text: '推荐系统的意义', link: '/4.人工智能/ch01/ch1.1.md' }, + { text: '推荐系统架构', link: '/4.人工智能/ch01/ch1.2.md' }, + { text: '推荐系统技术栈', link: '/4.人工智能/ch01/ch1.3.md' }, + ] + }, + { + text: '推荐系统算法基础', + collapsed: true, + items: [ + { + text: '经典召回模型', + collapsed: true, + items: [ + { + text: '基于协同过滤的召回', collapsed: true, items: [ + { text: 'UserCF', link: '/4.人工智能/ch02/ch2.1/ch2.1.1/usercf.md' }, + { text: 'ItemCF', link: '/4.人工智能/ch02/ch2.1/ch2.1.1/itemcf.md' }, + { text: 'Swing', link: '/4.人工智能/ch02/ch2.1/ch2.1.1/Swing.md' }, + { text: '矩阵分解', link: '/4.人工智能/ch02/ch2.1/ch2.1.1/mf.md' }, + ] + }, + { text: 'FM召回', link: '/4.人工智能/ch02/ch2.1/ch2.1.2/FM.md' }, + { + text: 'item2vec召回系列', collapsed: true, items: [ + { text: 'word2vec原理', link: '/4.人工智能/ch02/ch2.1/ch2.1.2/word2vec.md' }, + { text: 'item2vec召回', link: '/4.人工智能/ch02/ch2.1/ch2.1.2/item2vec.md' }, + { text: 'Airbnb召回', link: '/4.人工智能/ch02/ch2.1/ch2.1.2/Airbnb.md' }, + ] + }, + { text: 'YoutubeDNN召回', link: '/4.人工智能/ch02/ch2.1/ch2.1.2/YoutubeDNN.md' }, + { + text: '双塔召回', collapsed: true, items: [ + { text: '经典双塔', link: '/4.人工智能/ch02/ch2.1/ch2.1.2/DSSM.md' }, + { text: 'Youtube双塔', link: '/4.人工智能/ch02/ch2.1/ch2.1.2/YoutubeTwoTower.md' }, + ] + }, + { + text: '图召回', collapsed: true, items: [ + { text: 'EGES', link: '/4.人工智能/ch02/ch2.1/ch2.1.3/EGES.md' }, + { text: 'PinSAGE', link: '/4.人工智能/ch02/ch2.1/ch2.1.3/PinSage.md' }, + ] + }, + { + text: '序列召回', collapsed: true, items: [ + { text: 'MIND', link: '/4.人工智能/ch02/ch2.1/ch2.1.4/MIND.md' }, + { text: 'SDM', link: '/4.人工智能/ch02/ch2.1/ch2.1.4/SDM.md' }, + ] + }, + { + text: '树模型召回', collapsed: true, items: [ + { text: 'TDM', link: '/4.人工智能/ch02/ch2.1/ch2.1.5/TDM.md' }, + ] + } + ] + }, + { + text: '经典排序模型', + collapsed: true, + items: [ + { text: 'GBDT+LR', link: '/4.人工智能/ch02/ch2.2/ch2.2.1.md' }, + { + text: '特征交叉', collapsed: true, items: [ + { text: 'FM', link: '/4.人工智能/ch02/ch2.2/ch2.2.2/FM.md' }, + { text: 'PNN', link: '/4.人工智能/ch02/ch2.2/ch2.2.2/PNN.md' }, + { text: 'DCN', link: '/4.人工智能/ch02/ch2.2/ch2.2.2/DCN.md' }, + { text: 'AutoInt', link: '/4.人工智能/ch02/ch2.2/ch2.2.2/AutoInt.md' }, + { text: 'FiBiNET', link: '/4.人工智能/ch02/ch2.2/ch2.2.2/FiBiNet.md' }, + ] + }, + { + text: 'WideNDeep系列', collapsed: true, items: [ + { text: 'Wide&Deep', link: '/4.人工智能/ch02/ch2.2/ch2.2.3/WideNDeep.md' }, + { text: 'NFM', link: '/4.人工智能/ch02/ch2.2/ch2.2.3/NFM.md' }, + { text: 'AFM', link: '/4.人工智能/ch02/ch2.2/ch2.2.3/AFM.md' }, + { text: 'DeepFM', link: '/4.人工智能/ch02/ch2.2/ch2.2.3/DeepFM.md' }, + { text: 'xDeepFM', link: '/4.人工智能/ch02/ch2.2/ch2.2.3/xDeepFM.md' }, + ] + }, + { + text: '序列模型', collapsed: true, items: [ + { text: 'DIN', link: '/4.人工智能/ch02/ch2.2/ch2.2.4/DIN.md' }, + { text: 'DIEN', link: '/4.人工智能/ch02/ch2.2/ch2.2.4/DIEN.md' }, + { text: 'DSIN', link: '/4.人工智能/ch02/ch2.2/ch2.2.4/DSIN.md' }, + ] + }, + { + text: '多任务学习', collapsed: true, items: [ + { text: '多任务学习概述', link: '/4.人工智能/ch02/ch2.2/ch2.2.5/2.2.5.0.md' }, + { text: 'ESMM', link: '/4.人工智能/ch02/ch2.2/ch2.2.5/ESMM.md' }, + { text: 'MMOE', link: '/4.人工智能/ch02/ch2.2/ch2.2.5/MMOE.md' }, + { text: 'PLE', link: '/4.人工智能/ch02/ch2.2/ch2.2.5/PLE.md' }, + ] + } + ] + } + ] + } + ] + } ] }, { diff --git a/4.人工智能/FunRec概述.md b/4.人工智能/FunRec概述.md new file mode 100644 index 0000000..52819b2 --- /dev/null +++ b/4.人工智能/FunRec概述.md @@ -0,0 +1,16 @@ +# FunRec概述 + +本教程主要是针对具有机器学习基础并想找推荐算法岗位的同学。教程内容由推荐系统概述、推荐算法基础、推荐系统实战和推荐系统面经四个部分组成。本教程对于入门推荐算法的同学来说,可以从推荐算法的基础到实战再到面试,形成一个闭环。每个部分的详细内容如下: + +- **推荐系统概述。** 这部分内容会从推荐系统的意义及应用,到架构及相关的技术栈做一个概述性的总结,目的是为了让初学者更加了解推荐系统。 +- **推荐系统算法基础。** 这部分会介绍推荐系统中对于算法工程师来说基础并且重要的相关算法,如经典的召回、排序算法。随着项目的迭代,后续还会不断的总结其他的关键算法和技术,如重排、冷启动等。 +- **推荐系统实战。** 这部分内容包含推荐系统竞赛实战和新闻推荐系统的实践。其中推荐系统竞赛实战是结合阿里天池上的新闻推荐入门赛做的相关内容。新闻推荐系统实践是实现一个具有前后端交互及整个推荐链路的项目,该项目是一个新闻推荐系统的demo没有实际的商业化价值。 +- **推荐系统算法面经。** 这里会将推荐算法工程师面试过程中常考的一些基础知识、热门技术等面经进行整理,方便同学在有了一定推荐算法基础之后去面试,因为对于初学者来说只有在公司实习学到的东西才是最有价值的。 + +**特别说明**:项目内容是由一群热爱分享的同学一起花时间整理而成,**大家的水平都非常有限,内容难免存在一些错误和问题,如果学习者发现问题,也欢迎及时反馈,避免让后学者踩坑!** 如果对该项目有改进或者优化的建议,还希望通过下面的二维码找到项目负责人或者在交流社区中提出,我们会参考大家的意见进一步对该项目进行修改和调整!如果想对该项目做一些贡献,也可以通过上述同样的方法找到我们! + +为了方便学习和交流,**我们建立了FunRec学习社区(微信群+知识星球)**,微信群方便大家平时日常交流和讨论,知识星球方便沉淀内容。由于我们的内容面向的人群主要是学生,所以**知识星球永久免费**,感兴趣的可以加入星球讨论(加入星球的同学先看置定的必读帖)!**FunRec学习社区内部会不定期分享(FunRec社区中爱分享的同学)技术总结、个人管理等内容,[跟技术相关的分享内容都放在了B站](https://space.bilibili.com/431850986/channel/collectiondetail?sid=339597)上面**。由于微信群的二维码只有7天内有效,所以直接加下面这个微信,备注:**Fun-Rec**,会被拉到Fun-Rec交流群,如果觉得微信群比较吵建议直接加知识星球!。 + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## 音乐评分实例
+
+假设每个用户都有自己的听歌偏好, 比如用户 A 喜欢带有**小清新的**, **吉他伴奏的**, **王菲**的歌曲,如果一首歌正好**是王菲唱的, 并且是吉他伴奏的小清新**, 那么就可以将这首歌推荐给这个用户。 也就是说是**小清新, 吉他伴奏, 王菲**这些元素连接起了用户和歌曲。
+
+当然每个用户对不同的元素偏好不同, 每首歌包含的元素也不一样, 所以我们就希望找到下面的两个矩阵:
+
+1. 潜在因子—— 用户矩阵Q
+ 这个矩阵表示不同用户对于不同元素的偏好程度, 1代表很喜欢, 0代表不喜欢, 比如下面这样:
+
+
+
+
+
+
+
+
+
+
+## 计算过程
+
+以下图为例,给用户推荐物品的过程可以形象化为一个猜测用户对物品进行打分的任务,表格里面是5个用户对于5件物品的一个打分情况,就可以理解为用户对物品的喜欢程度。
+
+
+
+UserCF算法的两个步骤:
+
++ 首先,根据前面的这些打分情况(或者说已有的用户向量)计算一下 Alice 和用户1, 2, 3, 4的相似程度, 找出与 Alice 最相似的 n 个用户。
+
++ 根据这 n 个用户对物品 5 的评分情况和与 Alice 的相似程度会猜测出 Alice 对物品5的评分。如果评分比较高的话, 就把物品5推荐给用户 Alice, 否则不推荐。
+
+**具体过程:**
+
+1. 计算用户之间的相似度
+
+ + 根据 1.2 节的几种方法, 我们可以计算出各用户之间的相似程度。对于用户 Alice,选取出与其最相近的 $N$ 个用户。
+
+2. 计算用户对新物品的评分预测
+
+ + 常用的方式之一:利用目标用户与相似用户之间的相似度以及相似用户对物品的评分,来预测目标用户对候选物品的评分估计:
+ $$
+ R_{\mathrm{u}, \mathrm{p}}=\frac{\sum_{\mathrm{s} \in S}\left(w_{\mathrm{u}, \mathrm{s}} \cdot R_{\mathrm{s}, \mathrm{p}}\right)}{\sum_{\mathrm{s} \in S} w_{\mathrm{u}, \mathrm{s}}}
+ $$
+
+ + 其中,权重 $w_{u,s}$ 是用户 $u$ 和用户 $s$ 的相似度, $R_{s,p}$ 是用户 $s$ 对物品 $p$ 的评分。
+
+ + 另一种方式:考虑到用户评分的偏置,即有的用户喜欢打高分, 有的用户喜欢打低分的情况。公式如下:
+ $$
+ R_{\mathrm{u}, \mathrm{p}}=\bar{R}_{u} + \frac{\sum_{\mathrm{s} \in S}\left(w_{\mathrm{u}, \mathrm{s}} \cdot \left(R_{s, p}-\bar{R}_{s}\right)\right)}{\sum_{\mathrm{s} \in S} w_{\mathrm{u}, \mathrm{s}}}
+ $$
+
+ + 其中,$\bar{R}_{s}$ 表示用户 $s$ 对物品的历史平均评分。
+
+3. 对用户进行物品推荐
+
+ + 在获得用户 $u$ 对不同物品的评价预测后, 最终的推荐列表根据预测评分进行排序得到。
+
+**手动计算:**
+
+根据上面的问题, 下面手动计算 Alice 对物品 5 的得分:
+
+
+1. 计算 Alice 与其他用户的相似度(基于皮尔逊相关系数)
+
+ + 手动计算 Alice 与用户 1 之间的相似度:
+
+ >用户向量 $\text {Alice}:(5,3,4,4) , \text{user1}:(3,1,2,3) , \text {user2}:( 4,3,4,3) , \text {user3}:(3,3,1,5) , \text {user4}:(1,5,5,2) $
+ >
+ >+ 计算Alice与user1的余弦相似性:
+ >$$
+ >\operatorname{sim}(\text { Alice, user1 })=\cos (\text { Alice, user } 1)=\frac{15+3+8+12}{\operatorname{sqrt}(25+9+16+16) * \operatorname{sqrt}(9+1+4+9)}=0.975
+ >$$
+ >
+ >+ 计算Alice与user1皮尔逊相关系数:
+ > + $Alice\_ave =4 \quad user1\_ave =2.25 $
+ > + 向量减去均值: $\text {Alice}:(1,-1, 0,0) \quad \text { user1 }:(0.75,-1.25,-0.25,0.75)$
+ >
+ >+ 计算这俩新向量的余弦相似度和上面计算过程一致, 结果是 0.852 。
+ >
+
+ + 基于 sklearn 计算所有用户之间的皮尔逊相关系数。可以看出,与 Alice 相似度最高的用户为用户1和用户2。
+
+
+
+ - 在当前房源的详情页下,「相似房源」板块(你可能还喜欢)所推荐的房源。
+
+
+
+- Airbnb 平台 99% 的房源预订来自于搜索排序和相似房源推荐。
+# Embedding 方法
+Airbnb 描述了两种 Embedding 的构建方法,分别为:
+
+- 用于描述短期实时性的个性化特征 Embedding:**listing Embeddings**
+ - **listing 表示房源的意思,它将贯穿全文,请务必了解。**
+- 用于描述长期的个性化特征 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。
+
+
+
+ - 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 位于同一区域的负样本集。
+
+**(2)Listing Embedding 的冷启动**
+
+- Airbnb 每天都有新的 listings 产生,而这些 listings 却没有 Embedding 向量表征。
+- Airbnb 建议利用其他 listing 的现有的 Embedding 来为新的 listing 创建 Embedding。
+ - 在新的 listing 被创建后,房主需要提供如位置、价格、类型等在内的信息。
+ - 然后利用房主提供的房源信息,为其查找3个相似的 listing,并将它们 Embedding 的均值作为新 listing 的 Embedding表示。
+ - 这里的相似,包含了位置最近(10英里半径内),房源类型相似,价格区间相近。
+- 通过该手段,Airbnb 可以解决 98% 以上的新 listing 的 Embedding 冷启动问题。
+
+**(3)Listing Embedding 的评估**
+经过上述的两点对 Embedding 的改进后,为了评估改进后 listing Embedding 的效果。
+
+- Airbnb 使用了800万的点击 session,并将 Embedding 的维度设为32。
+
+评估方法包括:
+
+- 评估 Embedding 是否包含 listing 的地理位置相似性。
+ - 理论上,同一区域的房源相似性应该更高,不同区域房源相似性更低。
+ - Airbnb 利用 k-means 聚类,将加利福尼亚州的房源聚成100个集群,来验证类似位置的房源是否聚集在一起。
+
+
+
+- 评估不同类型、价格区间的房源之间的相似性。
+ - 简而言之,我们希望类型相同、价格区间一致的房源它们之间的相似度更高。
+
+
+
+- 评估房源的隐式特征
+ - Airbnb 在训练房源(listing)的 Embedding时,并没有用到房源的图像信息。
+ - 对于一些隐式信息,例如架构、风格、观感等是无法直接学习。
+ - 为了验证基于 Word2Vec 学习到的 Embedding是否隐含了它们在外观等隐式信息上的相似性,Airbnb 内部开发了一款内部相似性探索工具。
+ - 大致原理就是,利用训练好的 Embedding 进行 K 近邻相似度检索。
+ - 如下,与查询房源在 Embedding 相似性高的其他房源,它们之间的外观风格也很相似。
+
+
+
+## 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行,因为预定之前没有历史预定相关的信息。
+
+
+
+看到过前面那个简单的例子后,现在可以看一个原文的 Listing-type 的例子:
+
+- 一个来自 US 的 Entire Home listing(lt1),它是一个二人间(c2),1 床(b1),一个卧室(bd2),1 个浴室(bt2),每晚平均价格为 60.8 美元(pn3),每晚每个客人的平均价格为 29.3 美元(pg3),5 个评价(r3),所有均 5 星好评(5s4),100% 的新客接受率(nu3)。
+- 因此该 listing 根据上表规则可以映射为:Listing-type = US_lt1_pn3_pg3_r3_5s4_c2_b1_bd2_bt2_nu3。
+
+**(2)Type Embedding 的好处**
+前面在介绍 Type Embedding 和 Listing Embedding 的区别时,提到过不同 User 或 Listing 他们的 Type 可能相同。
+
+- 故 User-type 和 Listing-type 在一定程度上可以缓解数据稀疏性的问题。
+- 对于 user 和 listing 而言,他们的属性可能会随着时间的推移而变化。
+ - 故它们的 Embedding 在时间上也具备了动态变化属性。
+
+**(3)Type Embedding 的训练过程**
+Type Embedding 的学习同样是基于 Skip-Gram 模型,但是有两点需要注意:
+
+- 联合训练 User-type Embedding 和 Listing-type Embedding
+ - 如下图(a),在 booking session 中,每个元素代表的是 (User-type, Listing-type)组合。
+ - 为了学习在相同向量空间中的 User-type 和 Listing-type 的 Embeddings,Airbnb 的做法是将 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}
+ $$
+
+
+
+# 实验部分
+
+前面介绍了两种 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,对预测结果进行排序。
+- 将排序后的结果展示给用户。
+
+**(1)Query Embedding**
+原文中似乎并没有详细介绍 Airbnb 的搜索技术,在参考的博客中对他们的 Query Embedding 技术进行了描述。如下:
+
+> Airbnb 对搜索的 Query 也进行了 Embedding,和普通搜索引擎的 Embedding 不太相同的是,这里的 Embedding 不是用自然语言中的语料库去训练的,而是用 Search Session 作为关系训练数据,训练方式更类似于 Item2Vec,Airbnb 中 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 features,user features,query 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种类型的特征计算方式相同。
+
+
+
+**① 基于 Listing Embedding Features 的特征构建**
+
+- Airbnb 保留了用户过去两周6种不同类型的历史行为,如下图:
+
+
+
+- 对于每个行为,还要将其按照 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 Embedding(Embedding 的均值)。
+
+- 除此之外,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 还做了特征重要性排序,如下表:
+
+
+
+**(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)
diff --git a/4.人工智能/ch02/ch2.1/ch2.1.2/DSSM.md b/4.人工智能/ch02/ch2.1/ch2.1.2/DSSM.md
new file mode 100644
index 0000000..8c94c46
--- /dev/null
+++ b/4.人工智能/ch02/ch2.1/ch2.1.2/DSSM.md
@@ -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,可以预测两句话的语义相似度。模型结构如下所示:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## 模型目标
+
+模型结构如上图所示,论文旨在对用户和物品建立两个不同的模型,将它们投影到相同维度的空间:
+$$
+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 的物品集合。
+ * 举例来说,对于用户1,Batch 内其他用户的正样本是用户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 方法更新参数。
+
+
+
+## 流频估计算法
+
+考虑一个随机的数据 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)]
+ $$
+
+
+
+
+**从数学理论上证明这种迭代更新的有效性:**
+
+假设物品 $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\}
+$$
+
+
+
+
+
+# YouTube 神经召回模型
+
+本文构建的 YouTube 神经检索模型由查询和候选网络组成。下图展示了整体的模型架构。
+
+
+
+在任何时间点,用户正在观看的视频,即种子视频,都会提供有关用户当前兴趣的强烈信号。因此,本文利用了大量种子视频特征以及用户的观看历史记录。候选塔是为了从候选视频特征中学习而构建的。
+
+* 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)
diff --git a/4.人工智能/ch02/ch2.1/ch2.1.2/item2vec.md b/4.人工智能/ch02/ch2.1/ch2.1.2/item2vec.md
new file mode 100644
index 0000000..472b8e6
--- /dev/null
+++ b/4.人工智能/ch02/ch2.1/ch2.1.2/item2vec.md
@@ -0,0 +1,114 @@
+# 前言
+
+在自然语言处理(NLP)领域,谷歌提出的 Word2Vec 模型是学习词向量表示的重要方法。其中,带有负采样(SGNS,Skip-gram with negative sampling)的 Skip-Gram 神经词向量模型在当时被证明是最先进的方法之一。各位读者需要自行了解 Word2Vec 中的 Skip-Gram 模型,本文只会做简单介绍。
+
+在论文 Item2Vec:Neural 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)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/4.人工智能/ch02/ch2.1/ch2.1.2/word2vec.md b/4.人工智能/ch02/ch2.1/ch2.1.2/word2vec.md
new file mode 100644
index 0000000..2ed19ec
--- /dev/null
+++ b/4.人工智能/ch02/ch2.1/ch2.1.2/word2vec.md
@@ -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向量的维度是词汇表的大小(如:500,000)
+注:上面示例词向量的维度为方便展示所以比较小
+
+
+**one-hot向量表示单词的问题:**
+1. 这些词向量是***正交向量***,无法通过数学计算(如点积)计算相似性
+2. 依赖WordNet等同义词库建立相似性效果也不好
+
+
+## dense word vectors表达单词
+如果我们可以使用某种方法为每个单词构建一个合适的dense vector,如下图,那么通过点积等数学计算就可以获得单词之间的某种联系
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+正样本对。对于负样本部分,相对来说更为重要,因此内容相对比较多,将在下面的负样本生成部分详细介绍。 + +这里还有一个比较重要的细节需要注意,由于模型是用于 item to item的召回,因此优化目标是与正样本之间的表示尽可能的相近,与负样本之间的表示尽可能的远。而图卷积操作会使得具有邻接关系的节点表示具有同质性,因此结合这两点,就需要在构建图结构的时,要将**训练样本之间可能存在的边在二部图上删除**,避免因为边的存在使得因卷积操作而导致的信息泄露。 + +### 工程技巧 + +由于PinSAGE是一篇工业界的论文,其中会涉及与实际工程相关的内容,这里在了解完算法思想之后,再从实际落地的角度看看PinSAGE给我们介绍的工程技巧。 + +**负样本的生成** + +召回模型最主要的任务是从候选集合中选出用户可能感兴趣的item,直观的理解就是让模型将用户喜欢的和不喜欢的进行区分。然而由于候选集合的庞大数量,许多item之间十分相似,导致模型划分出来用户喜欢的item中会存在一些难以区分的item(即与用户非常喜欢item比较相似的那一部分)。因此对于召回模型不仅能区分用户喜欢和不喜欢的 item,同时还能区分与用户喜欢的 item 十分相似的那一部分item。那么如果做到呢?这主要是交给 easy negative examples 和 hard negative examples 两种负样本给模型学习。 + +- easy 负样本:这里对于mini-batch内的所有pair(训练样本对)会共享500负样本,这500个样本从batch之外的所有节点中随机采样得到。这么做可以减少在每个mini-batch中因计算所有节点的embedding所需的时间,文中指出这和为每个item采样一定数量负样本无差异。 +- hard 负样本:这里使用hard 负样本的原因是根据实际场景的问题出发,模型需要从20亿的物品item集合中识别出最相似的1000个,即模型需要从2百万 item 中识别出最相似的那一个 item。也就是说模型的区分能力不够细致,为了解决这个问题,加入了一些hard样本。对于hard 负样本,应该是与 q 相似 以及和 i 不相似的物品,具体地的生成方式是将图上的节点计算相对节点 q 的个性化PageRank分值,根据分值的排序随机从2000~5000的位置选取节点作为负样本。 + +负样本的构建是召回模型的中关键的内容,在各家公司的工作都予以体现,具体的大家可以参考 Facebook 发表的[《Embedding-based Retrieval in Facebook Search》]([https://arxiv.org/pdf/2006.11632v1.pdf](https://links.jianshu.com/go?to=https%3A%2F%2Farxiv.org%2Fpdf%2F2006.11632v1.pdf)) + +**渐进式训练(Curriculum training)** + +由于hard 负样本的加入,模型的训练时间加长(由于与q过于相似,导致loss比较小,导致梯度更新的幅度比较小,训练起来比较慢),那么渐进式训练就是为了来解决这个问题。 + +如何渐进式:先在第一轮训练使用easy 负样本,帮助模型先快速收敛(先让模型有个最基本的分辨能力)到一定范围,然后在逐步分加入hard负样本(方式是在第n轮训练时给每个物品的负样本集合增加n-1个 hard 负样本),以调整模型细粒度的区分能力(让模型能够区分相似的item)。 + +**节点特征(side information)** + +这里与EGES的不同,这里的边信息不是端到端训练得到,而是通过事前的预处理得到的。对于每个节点(即 pin),都会有一个图片和一点文本信息。因此对于每个节点使用图片的向量、文字的向量以及节点的度拼接得到。这里其实也解释了为什么在图卷积操作时,会先进行一个非线性转化,其实就是将不同空间的特征进行转化(融合)。 + +**构建 mini-batch** + +不同于常规的构建方式,PinSAGE中构建mini-batch的方式是基于生产者消费者模式。什么意思的,就是将CPU和GPU分开工作,让CPU负责取特征,重建索引,邻接列表,负采样等工作,让GPU进行矩阵运算,即CPU负责生产每个batch所需的所有数据,GPU则根据CPU生产的数据进行消费(运算)。这样由于考虑GPU的利用率,无法将所有特征矩阵放在GPU,只能存在CPU中,然而每次查找会导致非常耗时,通过上面的方式使得图卷积操作过程中就没有GPU与CPU的通信需求。 + +**多GPU训练超大batch** + +前向传播过程中,各个GPU等分minibatch,共享一套模型参数;反向传播时,将每个GPU中的参数梯度都聚合到一起,同步执行SGD。为了保证因海量数据而使用的超大batchsize的情况下模型快速收敛以及泛化精度,采用warmup过程,即在第一个epoch中将学习率线性提升到最高,后面的epoch中再逐步指数下降。 + +**使用MapReduce高效推断** + +在模型训练结束之后,需要为所有节点计算一个embedding,如果按照训练过程中的前向传播过程来生成,会存在大量重复的计算。因为当计算一个节点的embedding的时候,其部分邻居节点已经计算过了,同时如果该节点作为其他节点邻居时,也会被再次计算。针对这个问题,本文采用MapReduce的方法进行推断。该过程主要分为两步,具体如下图所示: + +++ +1. 将item的embedding进行聚合,即利用item的图片、文字和度等信息的表示进行join(拼接),在通过一层dense后得到item的低维向量。 +2. 然后根据item来匹配其一阶邻居(join),然后根据item进行pooling(其实就是GroupBy pooling),得到一次图卷积操作。通过堆叠多次直接得到全量的embedding。 + +其实这块主要就是通过MapReduce的大数据处理能力,直接对全量节点进行一次运算得到其embedding,避免了分batch所导致的重复计算。 + +## 代码解析 + +了解完基本的原理之后,最关键的还是得解析源码,以证实上面讲的细节的准确性。下面基于DGL中实现的代码,看看模型中的一些细节。 + +### 数据处理 + +在弄清楚模型之前,最重要的就是知道送入模型的数据到底是什么养的,以及PinSAGE相对于GraphSAGE最大的区别就在于如何采样邻居,如何构建负样本等。 + +首先需要明确的是,无论是**邻居采样**还是**样本的构造**都发生在图结构上,因此最主要的是需要先构建一个user和item组成的二部图。 + +```python +# ratings是所有的用户交互 +# 过滤掉为出现在交互中的用户和项目 +distinct_users_in_ratings = ratings['user_id'].unique() +distinct_movies_in_ratings = ratings['movie_id'].unique() +users = users[users['user_id'].isin(distinct_users_in_ratings)] +movies = movies[movies['movie_id'].isin(distinct_movies_in_ratings)] + +# 将电影特征分组 genres (a vector), year (a category), title (a string) +genre_columns = movies.columns.drop(['movie_id', 'title', 'year']) +movies[genre_columns] = movies[genre_columns].fillna(False).astype('bool') +movies_categorical = movies.drop('title', axis=1) + +## 构建图 +graph_builder = PandasGraphBuilder() +graph_builder.add_entities(users, 'user_id', 'user') # 添加user类型节点 +graph_builder.add_entities(movies_categorical, 'movie_id', 'movie') # 添加movie类型节点 + +# 构建用户-电影的无向图 +graph_builder.add_binary_relations(ratings, 'user_id', 'movie_id', 'watched') +graph_builder.add_binary_relations(ratings, 'movie_id', 'user_id', 'watched-by') + +g = graph_builder.build() +``` + +在构建完原图之后,需要将交互数据(ratings)分成训练集和测试集,然后根据测试集从原图中抽取出与训练集中相关节点的子图。 + +```python +# train_test_split_by_time 根据时间划分训练集和测试集 +# 将用户的倒数第二次交互作为验证,最后一次交互用作测试 +# train_indices 为用于训练的用户与电影的交互 +train_indices, val_indices, test_indices = train_test_split_by_time(ratings, 'timestamp', 'user_id') + +# 只使用训练交互来构建图形,测试集相关的节点不应该出现在训练过程中。 +# 从原图中提取与训练集相关节点的子图 +train_g = build_train_graph(g, train_indices, 'user', 'movie', 'watched', 'watched-by') +``` + + + +### 正负样本采样 + +在得到训练图结构之后,为了进行PinSAGE提出的item2item召回任务,需要构建相应的训练样本。对于训练样本主要是构建正样本对和负样本对,前面我们已经提到了正样本对是基于 item to user to item的随即游走得到的;对于负样本DGL的实现主要是随机采样,即只有easy sample,未实现hard sample。具体地,DGL中主要是通过sampler_module.ItemToItemBatchSampler方法进行采样,主要代码如下: + +```python +class ItemToItemBatchSampler(IterableDataset): + def __init__(self, g, user_type, item_type, batch_size): + self.g = g + self.user_type = user_type + self.item_type = item_type + self.user_to_item_etype = list(g.metagraph()[user_type][item_type])[0] + self.item_to_user_etype = list(g.metagraph()[item_type][user_type])[0] + self.batch_size = batch_size + + def __iter__(self): + while True: + # 随机采样batch_size个节点作为head 即论文中的q + heads = torch.randint(0, self.g.number_of_nodes(self.item_type), (self.batch_size,)) + + # 本次元路径表示从item游走到user,再从user游走到item,总共二跳,取出二跳节点(电影节点)作为tails(即正样本) + # 得到与heads被同一个用户消费过的其他item,做正样本 + # 这么做可能存在问题, + # 1. 这种游走肯定会使正样本集中于少数热门item; + # 2. 如果item只被一个用户消费过,二跳游走岂不是又回到起始item,这种case还是要处理的 + tails = dgl.sampling.random_walk( + self.g, + heads, + metapath=[self.item_to_user_etype, self.user_to_item_etype])[0][:, 2] + + # 随机采样做负样本, 没有hard negative + # 这么做会存在被同一个用户交互过的movie也会作为负样本 + neg_tails = torch.randint(0, self.g.number_of_nodes(self.item_type), (self.batch_size,)) + + mask = (tails != -1) + yield heads[mask], tails[mask], neg_tails[mask] +``` + +上面的样本采样过程只是一个简单的示例,如果面对实际问题,需要自己来重新完成这部分的内容。 + +### 邻居节点采样 + +再得到训练样本之后,接下来主要是在训练图上,为heads节点采用其邻居节点。在DGL中主要是通过sampler_module.NeighborSampler来实现,具体地,通过**sample_blocks**方法回溯生成各层卷积需要的block,即所有的邻居集合。其中需要注意的几个地方,基于随机游走的重要邻居采样,DGL已经实现,具体参考**[dgl.sampling.PinSAGESampler](https://link.zhihu.com/?target=https%3A//docs.dgl.ai/generated/dgl.sampling.PinSAGESampler.html%3Fhighlight%3Dpinsagesampler)**,其次避免信息泄漏,代码中,先将head → tails,head → neg_tails从frontier中先删除,再生成block。 + +```python +class NeighborSampler(object): # 图卷积的邻居采样 + def __init__(self, g, user_type, item_type, random_walk_length, random_walk_restart_prob, + num_random_walks, num_neighbors, num_layers): + self.g = g + self.user_type = user_type + self.item_type = item_type + self.user_to_item_etype = list(g.metagraph()[user_type][item_type])[0] + self.item_to_user_etype = list(g.metagraph()[item_type][user_type])[0] + + # 每层都有一个采样器,根据随机游走来决定某节点邻居的重要性(主要的实现已封装在PinSAGESampler中) + # 可以认为经过多次游走,落脚于某邻居节点的次数越多,则这个邻居越重要,就更应该优先作为邻居 + self.samplers = [ + dgl.sampling.PinSAGESampler(g, item_type, user_type, random_walk_length, + random_walk_restart_prob, num_random_walks, num_neighbors) + for _ in range(num_layers)] + + def sample_blocks(self, seeds, heads=None, tails=None, neg_tails=None): + """根据随机游走得到的重要性权重,进行邻居采样""" + blocks = [] + for sampler in self.samplers: + frontier = sampler(seeds) # 通过随机游走进行重要性采样,生成中间状态 + if heads is not None: + # 如果是在训练,需要将heads->tails 和 head->neg_tails这些待预测的边都去掉 + eids = frontier.edge_ids(torch.cat([heads, heads]), torch.cat([tails, neg_tails]), return_uv=True) + + if len(eids) > 0: + old_frontier = frontier + frontier = dgl.remove_edges(old_frontier, eids) + + # 只保留seeds这些节点,将frontier压缩成block + # 并设置block的input/output nodes + block = compact_and_copy(frontier, seeds) + + # 本层的输入节点就是下一层的seeds + seeds = block.srcdata[dgl.NID] + blocks.insert(0, block) + return blocks +``` + +其次**sample_from_item_pairs**方法是通过上面得到的heads, tails, neg_tails分别构建基于正样本对以及基于负样本对的item-item图。由heads→tails生成的pos_graph,用于计算pairwise loss中的pos_score,由heads→neg_tails生成的neg_graph,用于计算pairwise loss中的neg_score。 + +```python +class NeighborSampler(object): # 图卷积的邻居采样 + def __init__(self, g, user_type, item_type, random_walk_length, ....): + pass + + def sample_blocks(self, seeds, heads=None, tails=None, neg_tails=None): + pass + + def sample_from_item_pairs(self, heads, tails, neg_tails): + # 由heads->tails构建positive graph, num_nodes设置成原图中所有item节点 + pos_graph = dgl.graph( + (heads, tails), + num_nodes=self.g.number_of_nodes(self.item_type)) + + # 由heads->neg_tails构建negative graph,num_nodes设置成原图中所有item节点 + neg_graph = dgl.graph( + (heads, neg_tails), + num_nodes=self.g.number_of_nodes(self.item_type)) + + # 去除heads, tails, neg_tails以外的节点,将大图压缩成小图,避免与本轮训练不相关节点的结构也传入模型,提升计算效率 + pos_graph, neg_graph = dgl.compact_graphs([pos_graph, neg_graph]) + + # 压缩后的图上的节点是原图中的编号 + # 注意这时pos_graph与neg_graph不是分开编号的两个图,它们来自于同一幅由heads, tails, neg_tails组成的大图 + # pos_graph和neg_graph中的节点相同,都是heads+tails+neg_tails,即这里的seeds,pos_graph和neg_graph只是边不同而已 + seeds = pos_graph.ndata[dgl.NID] # 字典 不同类型节点为一个tensor,为每个节点的id值 + + blocks = self.sample_blocks(seeds, heads, tails, neg_tails) + return pos_graph, neg_graph, blocks +``` + + + +### PinSAGE + +在得到所有所需的数据之后,看看模型结构。其中主要分为三个部分:**节点特征映射**,**多层卷积模块 **和 **给边打分**。 + +```python +class PinSAGEModel(nn.Module): + def __init__(self, full_graph, ntype, textsets, hidden_dims, n_layers): + super().__init__() + # 负责将节点上的各种特征都映射成向量,并聚合在一起,形成这个节点的原始特征向量 + self.proj = layers.LinearProjector(full_graph, ntype, textsets, hidden_dims) + # 负责多层图卷积,得到各节点最终的embedding + self.sage = layers.SAGENet(hidden_dims, n_layers) + # 负责根据首尾两端的节点的embedding,计算边上的得分 + self.scorer = layers.ItemToItemScorer(full_graph, ntype) + + def forward(self, pos_graph, neg_graph, blocks): + """ pos_graph, neg_graph, blocks 的最后一层都对应batch中 heads+tails+neg_tails 这些节点 + """ + # 得到batch中heads+tails+neg_tails这些节点的最终embedding + h_item = self.get_repr(blocks) + # 得到heads->tails这些边上的得分 + pos_score = self.scorer(pos_graph, h_item) + # 得到heads->neg_tails这些边上的得分 + neg_score = self.scorer(neg_graph, h_item) + # pos_graph与neg_graph边数相等,因此neg_score与pos_score相减 + # 返回margin hinge loss,这里的margin是1 + return (neg_score - pos_score + 1).clamp(min=0) + + def get_repr(self, blocks): + """ + 通过self.sage,经过多层卷积,得到输出节点上的卷积结果,再加上这些输出节点上原始特征的映射结果 + 得到输出节点上最终的向量表示 + """ + h_item = self.proj(blocks[0].srcdata) # 将输入节点上的原始特征映射成hidden_dims长的向量 + h_item_dst = self.proj(blocks[-1].dstdata) # 将输出节点上的原始特征映射成hidden_dims长的向量 + return h_item_dst + self.sage(blocks, h_item) +``` + +**节点特征映射:**由于节点使用到了多种类型(int,float array,text)的原始特征,这里使用了一个DNN层来融合成固定的长度。 + +```python +class LinearProjector(nn.Module): + def __init__(self, full_graph, ntype, textset, hidden_dims): + super().__init__() + self.ntype = ntype + # 初始化参数,这里为全图中所有节点特征初始化 + # 如果特征类型是float,就定义一个nn.Linear线性变化为指定维度 + # 如果特征类型是int,就定义Embedding矩阵,将id型特征转化为向量 + self.inputs = _init_input_modules(full_graph, ntype, textset, hidden_dims) + + def forward(self, ndata): + projections = [] + for feature, data in ndata.items(): + # NID是计算子图中节点、边在原图中的编号,没必要用做特征 + if feature == dgl.NID: + continue + module = self.inputs[feature] # 根据特征名取出相应的特征转化器 + # 对文本属性进行处理 + if isinstance(module, (BagOfWords, BagOfWordsPretrained)): + length = ndata[feature + '__len'] + result = module(data, length) + else: + result = module(data) # look_up + projections.append(result) + + # 将每个特征都映射后的hidden_dims长的向量,element-wise相加 + return torch.stack(projections, 1).sum(1) # [nodes, hidden_dims] +``` + +**多层卷积模块:**根据采样得到的节点blocks,然后通过进行逐层卷积,得到各节点最终的embedding。 + +```python +class SAGENet(nn.Module): + def __init__(self, hidden_dims, n_layers): + """g : 二部图""" + super().__init__() + self.convs = nn.ModuleList() + for _ in range(n_layers): + self.convs.append(WeightedSAGEConv(hidden_dims, hidden_dims, hidden_dims)) + + def forward(self, blocks, h): + # 这里根据邻居节点进逐层聚合 + for layer, block in zip(self.convs, blocks): + h_dst = h[:block.number_of_nodes('DST/' + block.ntypes[0])] #前一次卷积的结果 + h = layer(block, (h, h_dst), block.edata['weights']) + return h +``` + +其中WeightedSAGEConv为根据邻居权重的聚合函数。 + +```python +class WeightedSAGEConv(nn.Module): + def __init__(self, input_dims, hidden_dims, output_dims, act=F.relu): + super().__init__() + self.act = act + self.Q = nn.Linear(input_dims, hidden_dims) + self.W = nn.Linear(input_dims + hidden_dims, output_dims) + self.reset_parameters() + self.dropout = nn.Dropout(0.5) + + def reset_parameters(self): + gain = nn.init.calculate_gain('relu') + nn.init.xavier_uniform_(self.Q.weight, gain=gain) + nn.init.xavier_uniform_(self.W.weight, gain=gain) + nn.init.constant_(self.Q.bias, 0) + nn.init.constant_(self.W.bias, 0) + + def forward(self, g, h, weights): + """ + g : 基于batch的子图 + h : 节点特征 + weights : 边的权重 + """ + h_src, h_dst = h # 邻居节点特征,自身节点特征 + with g.local_scope(): + # 将src节点上的原始特征映射成hidden_dims长,存储于'n'字段 + g.srcdata['n'] = self.act(self.Q(self.dropout(h_src))) + g.edata['w'] = weights.float() + + # src节点上的特征'n'乘以边上的权重,构成消息'm' + # dst节点将所有接收到的消息'm',相加起来,存入dst节点的'n'字段 + g.update_all(fn.u_mul_e('n', 'w', 'm'), fn.sum('m', 'n')) + + # 将边上的权重w拷贝成消息'm' + # dst节点将所有接收到的消息'm',相加起来,存入dst节点的'ws'字段 + g.update_all(fn.copy_e('w', 'm'), fn.sum('m', 'ws')) + + # 邻居节点的embedding的加权和 + n = g.dstdata['n'] + ws = g.dstdata['ws'].unsqueeze(1).clamp(min=1) # 边上权重之和 + + # 先将邻居节点的embedding,做加权平均 + # 再拼接上一轮卷积后,dst节点自身的embedding + # 再经过线性变化与非线性激活,得到这一轮卷积后各dst节点的embedding + z = self.act(self.W(self.dropout(torch.cat([n / ws, h_dst], 1)))) + + # 本轮卷积后,各dst节点的embedding除以模长,进行归一化 + z_norm = z.norm(2, 1, keepdim=True) + z_norm = torch.where(z_norm == 0, torch.tensor(1.).to(z_norm), z_norm) + z = z / z_norm + return z +``` + +**给边打分:** 经过SAGENet得到了batch内所有节点的embedding,这时需要根据学习到的embedding为pos_graph和neg_graph中的每个边打分,即计算正样本对和负样本的內积。具体逻辑是根据两端节点embedding的点积,然后加上两端节点的bias。 + +```python +class ItemToItemScorer(nn.Module): + def __init__(self, full_graph, ntype): + super().__init__() + n_nodes = full_graph.number_of_nodes(ntype) + self.bias = nn.Parameter(torch.zeros(n_nodes, 1)) + + def _add_bias(self, edges): + bias_src = self.bias[edges.src[dgl.NID]] + bias_dst = self.bias[edges.dst[dgl.NID]] + # 边上两顶点的embedding的点积,再加上两端节点的bias + return {'s': edges.data['s'] + bias_src + bias_dst} + + def forward(self, item_item_graph, h): + """ + item_item_graph : 每个边 为 pair 对 + h : 每个节点隐层状态 + """ + with item_item_graph.local_scope(): + item_item_graph.ndata['h'] = h + # 边两端节点的embedding做点积,保存到s + item_item_graph.apply_edges(fn.u_dot_v('h', 'h', 's')) + # 为每个边加上偏置,即加上两个顶点的偏置 + item_item_graph.apply_edges(self._add_bias) + # 算出来的得分为 pair 的预测得分 + pair_score = item_item_graph.edata['s'] + return pair_score +``` + +### 训练过程 + +介绍完“数据处理”和“PinSAGE模块”之后,接下来就是通过训练过程将上述两部分串起来,详细的见代码: + +```python +def train(dataset, args): + #从dataset中加载数据和原图 + g = dataset['train-graph'] + ... + + device = torch.device(args.device) + # 为节点随机初始化一个id,用于做embedding + g.nodes[user_ntype].data['id'] = torch.arange(g.number_of_nodes(user_ntype)) + g.nodes[item_ntype].data['id'] = torch.arange(g.number_of_nodes(item_ntype)) + + + # 负责采样出batch_size大小的节点列表: heads, tails, neg_tails + batch_sampler = sampler_module.ItemToItemBatchSampler( + g, user_ntype, item_ntype, args.batch_size) + + # 由一个batch中的heads,tails,neg_tails构建训练这个batch所需要的 + # pos_graph,neg_graph 和 blocks + neighbor_sampler = sampler_module.NeighborSampler( + g, user_ntype, item_ntype, args.random_walk_length, + args.random_walk_restart_prob, args.num_random_walks, args.num_neighbors, + args.num_layers) + + # 每次next()返回: pos_graph,neg_graph和blocks,做训练之用 + collator = sampler_module.PinSAGECollator(neighbor_sampler, g, item_ntype, textset) + dataloader = DataLoader( + batch_sampler, + collate_fn=collator.collate_train, + num_workers=args.num_workers) + + # 每次next()返回blocks,做训练中测试之用 + dataloader_test = DataLoader( + torch.arange(g.number_of_nodes(item_ntype)), + batch_size=args.batch_size, + collate_fn=collator.collate_test, + num_workers=args.num_workers) + dataloader_it = iter(dataloader) + + # 准备模型 + model = PinSAGEModel(g, item_ntype, textset, args.hidden_dims, args.num_layers).to(device) + opt = torch.optim.Adam(model.parameters(), lr=args.lr) + + # 训练过程 + for epoch_id in range(args.num_epochs): + model.train() + for batch_id in tqdm.trange(args.batches_per_epoch): + pos_graph, neg_graph, blocks = next(dataloader_it) + for i in range(len(blocks)): + blocks[i] = blocks[i].to(device) + pos_graph = pos_graph.to(device) + neg_graph = neg_graph.to(device) + + loss = model(pos_graph, neg_graph, blocks).mean() + opt.zero_grad() + loss.backward() + opt.step() +``` + +至此,DGL PinSAGE example的主要实现代码已经全部介绍完了,感兴趣的可以去官网对照源代码自行学习。 + +## 参考 + +[Graph Convolutional Neural Networks for Web-Scale Recommender Systems](https://arxiv.org/abs/1806.01973) + +[PinSAGE 召回模型及源码分析(1): PinSAGE 简介](https://zhuanlan.zhihu.com/p/275942839) + +[全面理解PinSage](https://zhuanlan.zhihu.com/p/133739758) + +[[论文笔记]PinSAGE——Graph Convolutional Neural Networks for Web-Scale Recommender Systems](https://zhuanlan.zhihu.com/p/461720302) + diff --git a/4.人工智能/ch02/ch2.1/ch2.1.4/MIND.md b/4.人工智能/ch02/ch2.1/ch2.1.4/MIND.md new file mode 100644 index 0000000..b672177 --- /dev/null +++ b/4.人工智能/ch02/ch2.1/ch2.1.4/MIND.md @@ -0,0 +1,415 @@ +## 写在前面 +MIND模型(Multi-Interest Network with Dynamic Routing), 是阿里团队2019年在CIKM上发的一篇paper,该模型依然是用在召回阶段的一个模型,解决的痛点是之前在召回阶段的模型,比如双塔,YouTubeDNN召回模型等,在模拟用户兴趣的时候,总是基于用户的历史点击,最后通过pooling的方式得到一个兴趣向量,用该向量来表示用户的兴趣,但是该篇论文的作者认为,**用一个向量来表示用户的广泛兴趣未免有点太过于单一**,这是作者基于天猫的实际场景出发的发现,每个用户每天与数百种产品互动, 而互动的产品往往来自于很多个类别,这就说明用户的兴趣极其广泛,**用一个向量是无法表示这样广泛的兴趣的**,于是乎,就自然而然的引出一个问题,**有没有可能用多个向量来表示用户的多种兴趣呢?** + +这篇paper的核心是胶囊网络,**该网络采用了动态路由算法能非常自然的将历史商品聚成多个集合,每个集合的历史行为进一步推断对应特定兴趣的用户表示向量。这样,对于一个特定的用户,MND输出了多个表示向量,它们代表了用户的不同兴趣。当用户再有新的交互时,通过胶囊网络,还能实时的改变用户的兴趣表示向量,做到在召回阶段的实时个性化**。那么,胶囊网络究竟是怎么做到的呢? 胶囊网络又是什么原理呢? + +**主要内容**: +* 背景与动机 +* 胶囊网络与动态路由机制 +* MIND模型的网络结构与细节剖析 +* MIND模型之简易代码复现 +* 总结 + +## 背景与动机 +本章是基于天猫APP的背景来探索十亿级别的用户个性化推荐。天猫的推荐的流程主要分为召回阶段和排序阶段。召回阶段负责检索数千个与用户兴趣相关的候选物品,之后,排序阶段预测用户与这些候选物品交互的精确概率。这篇文章做的是召回阶段的工作,来对满足用户兴趣的物品的有效检索。 + +作者这次的出发点是基于场景出发,在天猫的推荐场景中,作者发现**用户的兴趣存在多样性**。平均上,10亿用户访问天猫,每个用户每天与数百种产品互动。交互后的物品往往属于不同的类别,说明用户兴趣的多样性。 一张图片会更加简洁直观: + + +因此如果能在**召回阶段建立用户多兴趣模型来模拟用户的这种广泛兴趣**,那么作者认为是非常有必要的,因为召回阶段的任务就是根据用户兴趣检索候选商品嘛。 + +那么,如何能基于用户的历史交互来学习用户的兴趣表示呢? 以往的解决方案如下: +* 协同过滤的召回方法(itemcf和usercf)是通过历史交互过的物品或隐藏因子直接表示用户兴趣, 但会遇到**稀疏或计算问题** +* 基于深度学习的方法用低维的embedding向量表示用户,比如YoutubeDNN召回模型,双塔模型等,都是把用户的基本信息,或者用户交互过的历史商品信息等,过一个全连接层,最后编码成一个向量,用这个向量来表示用户兴趣,但作者认为,**这是多兴趣表示的瓶颈**,因为需要压缩所有与用户多兴趣相关的信息到一个表示向量,所有用户多兴趣的信息进行了混合,导致这种多兴趣并无法体现,所以往往召回回来的商品并不是很准确,除非向量维度很大,但是大维度又会带来高计算。 +* DIN模型在Embedding的基础上加入了Attention机制,来选择的捕捉用户兴趣的多样性,但采用Attention机制,**对于每个目标物品,都需要重新计算用户表示**,这在召回阶段是行不通的(海量),所以DIN一般是用于排序。 + +所以,作者想在召回阶段去建模用户的多兴趣,但以往的方法都不好使,为了解决这个问题,就提出了动态路由的多兴趣网络MIND。为了推断出用户的多兴趣表示,提出了一个多兴趣提取层,该层使用动态路由机制自动的能将用户的历史行为聚类,然后每个类簇中产生一个表示向量,这个向量能代表用户某种特定的兴趣,而多个类簇的多个向量合起来,就能表示用户广泛的兴趣了。 + +这就是MIND的提出动机以及初步思路了,这里面的核心是Multi-interest extractor layer, 而这里面重点是动态路由与胶囊网络,所以接下来先补充这方面的相关知识。 + +## 胶囊网络与动态路由机制 +### 胶囊网络初识 +Hinton大佬在2011年的时候,就首次提出了"胶囊"的概念, "胶囊"可以看成是一组聚合起来输出整个向量的小神经元组合,这个向量的每个维度(每个小神经元),代表着某个实体的某个特征。 + +胶囊网络其实可以和神经网络对比着看可能更好理解,我们知道神经网络的每一层的神经元输出的是单个的标量值,接收的输入,也是多个标量值,所以这是一种value to value的形式,而胶囊网络每一层的胶囊输出的是一个向量值,接收的输入也是多个向量,所以它是vector to vector形式的。来个图对比下就清楚了: + + +左边的图是普通神经元的计算示意,而右边是一个胶囊内部的计算示意图。 神经元这里不过多解释,这里主要是剖析右边的这个胶囊计算原理。从上图可以看出, 输入是两个向量$v_1,v_2$,首先经过了一个线性映射,得到了两个新向量$u_1,u_2$,然后呢,经过了一个向量的加权汇总,这里的$c_1$,$c_2$可以先理解成权重,具体计算后面会解释。 得到汇总后的向量$s$,接下来进行了Squash操作,整体的计算公式如下: +$$ +\begin{aligned} +&u^{1}=W^{1} v^{1} \quad u^{2}=W^{2} v^{2} \\ +&s=c_{1} u^{1}+c_{2} u^{2} \\ +&v=\operatorname{Squash}(s) =\frac{\|s\|^{2}}{1+\|s\|^{2}} \frac{s}{\|s\|} +\end{aligned} +$$ +这里的Squash操作可以简单看下,主要包括两部分,右边的那部分其实就是向量归一化操作,把norm弄成1,而左边那部分算是一个非线性操作,如果$s$的norm很大,那么这个整体就接近1, 而如果这个norm很小,那么整体就会接近0, 和sigmoid很像有没有? + +这样就完成了一个胶囊的计算,但有两点需要注意: +1. 这里的$W^i$参数是可学习的,和神经网络一样, 通过BP算法更新 +2. 这里的$c_i$参数不是BP算法学习出来的,而是采用动态路由机制现场算出来的,这个非常类似于pooling层,我们知道pooling层的参数也不是学习的,而是根据前面的输入现场取最大或者平均计算得到的。 + +所以这里的问题,就是怎么通过动态路由机制得到$c_i$,下面是动态路由机制的过程。 + +### 动态路由机制原理 +我们先来一个胶囊结构: + + +这个$c_i$是通过动态路由机制计算得到,那么动态路由机制究竟是啥子意思? 其实就是通过迭代的方式去计算,没有啥神秘的,迭代计算的流程如下图: +  +首先我们先初始化$b_i$,与每一个输入胶囊$u_i$进行对应,这哥们有个名字叫做"routing logit", 表示的是输出的这个胶囊与输入胶囊的相关性,和注意力机制里面的score值非常像。由于一开始不知道这个哪个胶囊与输出的胶囊有关系,所以默认相关性分数都一样,然后进入迭代。 + +在每一次迭代中,首先把分数转成权重,然后加权求和得到$s$,这个很类似于注意力机制的步骤,得到$s$之后,通过归一化操作,得到$a$,接下来要通过$a$和输入胶囊的相关性以及上一轮的$b_i$来更新$b_i$。最后那个公式有必要说一下在干嘛: +>如果当前的$a$与某一个输入胶囊$u_i$非常相关,即内积结果很大的话,那么相应的下一轮的该输入胶囊对应的$b_i$就会变大, 那么, 在计算下一轮的$a$的时候,与上一轮$a$相关的$u_i$就会占主导,相当于下一轮的$a$与上一轮中和他相关的那些$u_i$之间的路径权重会大一些,这样从空间点的角度观察,就相当于$a$点朝与它相关的那些$u$点更近了一点。 + +通过若干次迭代之后,得到最后的输出胶囊向量$a$会慢慢的走到与它更相关的那些$u$附近,而远离那些与它不相干的$u$。所以上面的这个迭代过程有点像**排除异常输入胶囊的感觉**。 + + + +而从另一个角度来考虑,这个过程其实像是聚类的过程,因为胶囊的输出向量$v$经过若干次迭代之后,会最终停留到与其非常相关的那些输入胶囊里面,而这些输入胶囊,其实就可以看成是某个类别了,因为既然都共同的和输出胶囊$v$比较相关,那么彼此之间的相关性也比较大,于是乎,经过这样一个动态路由机制之后,就不自觉的,把输入胶囊实现了聚类。把和与其他输入胶囊不同的那些胶囊给排除了出去。 + +所以,这个动态路由机制的计算设计的还是比较巧妙的, 下面是上述过程的展开计算过程, 这个和RNN的计算有点类似: + +这样就完成了一个胶囊内部的计算过程了。 + +Ok, 有了上面的这些铺垫,再来看MIND就会比较简单了。下面正式对MIND模型的网络架构剖析。 + +## MIND模型的网络结构与细节剖析 +### 网络整体结构 +MIND网络的架构如下: + +初步先分析这个网络结构的运作: 首先接收的输入有三类特征,用户base属性,历史行为属性以及商品的属性,用户的历史行为序列属性过了一个多兴趣提取层得到了多个兴趣胶囊,接下来和用户base属性拼接过DNN,得到了交互之后的用户兴趣。然后在训练阶段,用户兴趣和当前商品向量过一个label-aware attention,然后求softmax损失。 在服务阶段,得到用户的向量之后,就可以直接进行近邻检索,找候选商品了。 这就是宏观过程,但是,多兴趣提取层以及这个label-aware attention是在做什么事情呢? 如果单独看这个图,感觉得到多个兴趣胶囊之后,直接把这些兴趣胶囊以及用户的base属性拼接过全连接,那最终不就成了一个用户向量,此时label-aware attention的意义不就没了? 所以这个图初步感觉画的有问题,和论文里面描述的不符。所以下面先以论文为主,正式开始描述具体细节。 + +### 任务目标 +召回任务的目标是对于每一个用户$u \in \mathcal{U}$从十亿规模的物品池$\mathcal{I}$检索出包含与用户兴趣相关的上千个物品集。 +#### 模型的输入 +对于模型,每个样本的输入可以表示为一个三元组:$\left(\mathcal{I}_{u}, \mathcal{P}_{u}, \mathcal{F}_{i}\right)$,其中$\mathcal{I}_{u}$代表与用户$u$交互过的物品集,即用户的历史行为;$\mathcal{P}_{u}$表示用户的属性,例如性别、年龄等;$\mathcal{F}_{i}$定义为目标物品$i$的一些特征,例如物品id和种类id等。 +#### 任务描述 +MIND的核心任务是学习一个从原生特征映射到**用户表示**的函数,用户表示定义为: +$$ +\mathrm{V}_{u}=f_{u s e r}\left(\mathcal{I}_{u}, \mathcal{P}_{u}\right) +$$ +其中,$\mathbf{V}_{u}=\left(\overrightarrow{\boldsymbol{v}}_{u}^{1}, \ldots, \overrightarrow{\boldsymbol{v}}_{u}^{K}\right) \in \mathbb{R}^{d \times k}$是用户$u$的表示向量,$d$是embedding的维度,$K$表示向量的个数,即兴趣的数量。如果$K=1$,那么MIND模型就退化成YouTubeDNN的向量表示方式了。 + +目标物品$i$的embedding函数为: +$$ +\overrightarrow{\mathbf{e}}_{i}=f_{\text {item }}\left(\mathcal{F}_{i}\right) +$$ +其中,$\overrightarrow{\mathbf{e}}_{i} \in \mathbb{R}^{d \times 1}, \quad f_{i t e m}(\cdot)$表示一个embedding&pooling层。 +#### 最终结果 +根据评分函数检索(根据**目标物品与用户表示向量的内积的最大值作为相似度依据**,DIN的Attention部分也是以这种方式来衡量两者的相似度),得到top N个候选项: + +$$ +f_{\text {score }}\left(\mathbf{V}_{u}, \overrightarrow{\mathbf{e}}_{i}\right)=\max _{1 \leq k \leq K} \overrightarrow{\mathbf{e}}_{i}^{\mathrm{T}} \overrightarrow{\mathbf{V}}_{u}^{\mathrm{k}} +$$ + +### Embedding & Pooling层 +Embedding层的输入由三部分组成,用户属性$\mathcal{P}_{u}$、用户行为$\mathcal{I}_{u}$和目标物品标签$\mathcal{F}_{i}$。每一部分都由多个id特征组成,则是一个高维的稀疏数据,因此需要Embedding技术将其映射为低维密集向量。具体来说, + +* 对于$\mathcal{P}_{u}$的id特征(年龄、性别等)是将其Embedding的向量进行Concat,组成用户属性Embedding$\overrightarrow{\mathbf{p}}_{u}$; +* 目标物品$\mathcal{F}_{i}$通常包含其他分类特征id(品牌id、店铺id等) ,这些特征有利于物品的冷启动问题,需要将所有的分类特征的Embedding向量进行平均池化,得到一个目标物品向量$\overrightarrow{\mathbf{e}}_{i}$; +* 对于用户行为$\mathcal{I}_{u}$,由物品的Embedding向量组成用户行为Embedding列表$E_{u}=\overrightarrow{\mathbf{e}}_{j}, j \in \mathcal{I}_{u}$, 当然这里不仅只有物品embedding哈,也可能有类别,品牌等其他的embedding信息。 + +### Multi-Interest Extractor Layer(核心) +作者认为,单一的向量不足以表达用户的多兴趣。所以作者采用**多个表示向量**来分别表示用户不同的兴趣。通过这个方式,在召回阶段,用户的多兴趣可以分别考虑,对于兴趣的每一个方面,能够更精确的进行物品检索。 + +为了学习多兴趣表示,作者利用胶囊网络表示学习的动态路由将用户的历史行为分组到多个簇中。来自一个簇的物品应该密切相关,并共同代表用户兴趣的一个特定方面。 + +由于多兴趣提取器层的设计灵感来自于胶囊网络表示学习的动态路由,所以这里作者回顾了动态路由机制。当然,如果之前对胶囊网络或动态路由不了解,这里读起来就会有点艰难,但由于我上面进行了铺垫,这里就直接拿过原文并解释即可。 +#### 动态路由 +动态路由是胶囊网络中的迭代学习算法,用于学习低水平胶囊和高水平胶囊之间的路由对数(logit)$b_{ij}$,来得到高水平胶囊的表示。 + +我们假设胶囊网络有两层,即低水平胶囊$\vec{c}_{i}^{l} \in \mathbb{R}^{N_{l} \times 1}, i \in\{1, \ldots, m\}$和高水平胶囊$\vec{c}_{j}^{h} \in \mathbb{R}^{N_{h} \times 1}, j \in\{1, \ldots, n\}$,其中$m,n$表示胶囊的个数, $N_l,N_h$表示胶囊的维度。 路由对数$b_{ij}$计算公式如下: +$$ +b_{i j}=\left(\vec{c}_{j}^{h}\right)^{T} \mathrm{~S}_{i j} \vec{c}_{i}^{l} +$$ +其中$\mathbf{S}_{i j} \in \mathbb{R}^{N_{h} \times N_{l}}$表示待学习的双线性映射矩阵【在胶囊网络的原文中称为转换矩阵】 + +通过计算路由对数,将高阶胶囊$j$的候选向量计算为所有低阶胶囊的加权和: +$$ +\vec{z}_{j}^{h}=\sum_{i=1}^{m} w_{i j} S_{i j} \vec{c}_{i}^{l} +$$ +其中$w_{ij}$定义为连接低阶胶囊$i$和高阶胶囊$j$的权重【称为耦合系数】,而且其通过对路由对数执行softmax来计算: +$$ +w_{i j}=\frac{\exp b_{i j}}{\sum_{k=1}^{m} \exp b_{i k}} +$$ +最后,应用一个非线性的“压缩”函数来获得一个高阶胶囊的向量【胶囊网络向量的模表示由胶囊所代表的实体存在的概率】 +$$ +\vec{c}_{j}^{h}=\operatorname{squash}\left(\vec{z}_{j}^{h}\right)=\frac{\left\|\vec{z}_{j}^{h}\right\|^{2}}{1+\left\|\vec{z}_{j}^{h}\right\|^{2}} \frac{\vec{z}_{j}^{h}}{\left\|\vec{z}_{j}^{h}\right\|} +$$ +路由过程重复进行3次达到收敛。当路由结束,高阶胶囊值$\vec{c}_{j}^{h}$固定,作为下一层的输入。 + +Ok,下面我们开始解释,其实上面说的这些就是胶囊网络的计算过程,只不过和之前所用的符号不一样了。这里拿个图: + +首先,论文里面也是个两层的胶囊网络,低水平层->高水平层。 低水平层有$m$个胶囊,每个胶囊向量维度是$N_l$,用$\vec{c}_{i}^l$表示的,高水平层有$n$个胶囊,每个胶囊$N_h$维,用$\vec{c}_{j}^h$表示。 + +单独拿出每个$\vec{c}_{j}^h$,其计算过程如上图所示。首先,先随机初始化路由对数$b_{ij}=0$,然后开始迭代,对于每次迭代: +$$ +w_{i j}=\frac{\exp b_{i j}}{\sum_{k=1}^{m} \exp b_{i k}} \\ +\vec{z}_{j}^{h}=\sum_{i=1}^{m} w_{i j} S_{i j} \vec{c}_{i}^{l} \\ \vec{c}_{j}^{h}=\operatorname{squash}\left(\vec{z}_{j}^{h}\right)=\frac{\left\|\vec{z}_{j}^{h}\right\|^{2}}{1+\left\|\vec{z}_{j}^{h}\right\|^{2}} \frac{\vec{z}_{j}^{h}}{\left\|\vec{z}_{j}^{h}\right\|} \\ b_{i j}=\left(\vec{c}_{j}^{h}\right)^{T} \mathrm{~S}_{i j} \vec{c}_{i}^{l} +$$ +只不过这里的符合和上图中的不太一样,这里的$w_{ij}$对应的是每个输入胶囊的权重$c_{ij}$, 这里的$\vec{c}_{j}^h$对应上图中的$a$, 这里的$\vec{z}_{j}^h$对应的是输入胶囊的加权组合。这里的$\vec{c}_{i}^l$对应上图中的$v_i$,这里的$S_{ij}$对应的是上图的权重$W_{ij}$,只不过这个可以换成矩阵运算。 和上图中不同的是路由对数$b_{ij}$更新那里,没有了上一层的路由对数值,但感觉这样会有问题。 + +所以,这样解释完之后就会发现,其实上面的一顿操作就是说的传统的动态路由机制。 + +#### B2I动态路由 +作者设计的多兴趣提取层就是就是受到了上述胶囊网络的启发。 + +如果把用户的行为序列看成是行为胶囊, 把用户的多兴趣看成兴趣胶囊,那么多兴趣提取层就是利用动态路由机制学习行为胶囊`->`兴趣胶囊的映射关系。但是原始路由算法无法直接应用于处理用户行为数据。因此,提出了**行为(Behavior)到兴趣(Interest)(B2I)动态路由**来自适应地将用户的行为聚合到兴趣表示向量中,它与原始路由算法有三个不同之处: + +1. **共享双向映射矩阵**。在初始动态路由中,使用固定的或者说共享的双线性映射矩阵$S$而不是单独的双线性映射矩阵, 在原始的动态路由中,对于每个输出胶囊$\vec{c}_{j}^h$,都会有对应的$S_{ij}$,而这里是每个输出胶囊,都共用一个$S$矩阵。 原因有两个: + 1. 一方面,用户行为是可变长度的,从几十个到几百个不等,因此使用共享的双线性映射矩阵是有利于泛化。 + 2. 另一方面,希望兴趣胶囊在同一个向量空间中,但不同的双线性映射矩阵将兴趣胶囊映射到不同的向量空间中。因为映射矩阵的作用就是对用户的行为胶囊进行线性映射嘛, 由于用户的行为序列都是商品,所以希望经过映射之后,到统一的商品向量空间中去。路由对数计算如下: +$$ +b_{i j}=\overrightarrow{\boldsymbol{u}}_{j}^{T} \mathrm{S\overrightarrow{e}}_{i}, \quad i \in \mathcal{I}_{u}, j \in\{1, \ldots, K\} +$$ + 其中,$\overrightarrow{\boldsymbol{e}}_{i} \in \mathbb{R}^{d}$是历史物品$i$的embedding,$\vec{u}_{j} \in \mathbb{R}^{d}$表示兴趣胶囊$j$的向量。$S \in \mathbb{R}^{d \times d}$是每一对行为胶囊(低价)到兴趣胶囊(高阶)之间 的共享映射矩阵。 + + +2. **随机初始化路由对数**。由于利用共享双向映射矩阵$S$,如果再初始化路由对数为0将导致相同的初始的兴趣胶囊。随后的迭代将陷入到一个不同兴趣胶囊在所有的时间保持相同的情景。因为每个输出胶囊的运算都一样了嘛(除非迭代的次数不同,但这样也会导致兴趣胶囊都很类似),为了减轻这种现象,作者通过高斯分布进行随机采样来初始化路由对数$b_{ij}$,让初始兴趣胶囊与其他每一个不同,其实就是希望在计算每个输出胶囊的时候,通过随机化的方式,希望这几个聚类中心离得远一点,这样才能表示出广泛的用户兴趣(我们已经了解这个机制就仿佛是聚类,而计算过程就是寻找聚类中心)。 +3. **动态的兴趣数量**,兴趣数量就是聚类中心的个数,由于不同用户的历史行为序列不同,那么相应的,其兴趣胶囊有可能也不一样多,所以这里使用了一种启发式方式自适应调整聚类中心的数量,即$K$值。 +$$ +K_{u}^{\prime}=\max \left(1, \min \left(K, \log _{2}\left(\left|\mathcal{I}_{u}\right|\right)\right)\right) +$$ +这种调整兴趣胶囊数量的策略可以为兴趣较小的用户节省一些资源,包括计算和内存资源。这个公式不用多解释,与行为序列长度成正比。 + +最终的B2I动态路由算法如下: + +应该很好理解了吧。 + +### Label-aware Attention Layer + 通过多兴趣提取器层,从用户的行为embedding中生成多个兴趣胶囊。不同的兴趣胶囊代表用户兴趣的不同方面,相应的兴趣胶囊用于评估用户对特定类别的偏好。所以,在训练的期间,最后需要设置一个Label-aware的注意力层,对于当前的商品,根据相关性选择最相关的兴趣胶囊。这里其实就是一个普通的注意力机制,和DIN里面的那个注意力层基本上是一模一样,计算公式如下: +$$ +\begin{aligned} +\overrightarrow{\boldsymbol{v}}_{u} &=\operatorname{Attention}\left(\overrightarrow{\boldsymbol{e}}_{i}, \mathrm{~V}_{u}, \mathrm{~V}_{u}\right) \\ +&=\mathrm{V}_{u} \operatorname{softmax}\left(\operatorname{pow}\left(\mathrm{V}_{u}^{\mathrm{T}} \overrightarrow{\boldsymbol{e}}_{i}, p\right)\right) +\end{aligned} +$$ +首先这里的$\overrightarrow{\boldsymbol{e}}_{i}$表示当前的商品向量,$V_u$表示用户的多兴趣向量组合,里面有$K$个向量,表示用户的$K$的兴趣。用户的各个兴趣向量与目标商品做内积,然后softmax转成权重,然后反乘到多个兴趣向量进行加权求和。 但是这里需要注意的一个小点,就是这里做内积求完相似性之后,先做了一个指数操作,**这个操作其实能放大或缩小相似程度**,至于放大或者缩小的程度,由$p$控制。 比如某个兴趣向量与当前商品非常相似,那么再进行指数操作之后,如果$p$也很大,那么显然这个兴趣向量就占了主导作用。$p$是一个可调节的参数来调整注意力分布。当$p$接近0,每一个兴趣胶囊都得到相同的关注。当$p$大于1时,随着$p$的增加,具有较大值的点积将获得越来越多的权重。考虑极限情况,当$p$趋近于无穷大时,注意机制就变成了一种硬注意,选关注最大的值而忽略其他值。在实验中,发现使用硬注意导致更快的收敛。 +>理解:$p$小意味着所有的相似程度都缩小了, 使得之间的差距会变小,所以相当于每个胶囊都会受到关注,而越大的话,使得各个相似性差距拉大,相似程度越大的会更大,就类似于贫富差距, 最终使得只关注于比较大的胶囊。 + +### 训练与服务 +得到用户向量$\overrightarrow{\boldsymbol{v}}_{u}$和标签物品embedding$\vec{e}_{i}$后,计算用户$u$与标签物品$i$交互的概率: +$$ +\operatorname{Pr}(i \mid u)=\operatorname{Pr}\left(\vec{e}_{i} \mid \vec{v}_{u}\right)=\frac{\exp \left(\vec{v}_{u}^{\mathrm{T} \rightarrow}\right)}{\sum_{j \in I} \exp \left(\vec{v}_{u}^{\mathrm{T}} \vec{e}_{j}\right)} +$$ +目标函数是: +$$ +L=\sum_{(u, i) \in \mathcal{D}} \log \operatorname{Pr}(i \mid u) +$$ +其中$\mathcal{D}$是训练数据包含用户物品交互的集合。因为物品的数量可伸缩到数十亿,所以不能直接算。因此。使用采样的softmax技术,并且选择Adam优化来训练MIND。 + +训练结束后,抛开label-aware注意力层,MIND网络得到一个用户表示映射函数$f_{user}$。在服务期间,用户的历史序列与自身属性喂入到$f_{user}$,每个用户得到多兴趣向量。然后这个表示向量通过一个近似邻近方法来检索top N物品。 + +这就是整个MIND模型的细节了。 + +## MIND模型之简易代码复现 +下面参考Deepctr,用简易的代码实现下MIND,并在新闻推荐的数据集上进行召回任务。 + +### 整个代码架构 + +整个MIND模型算是参考deepmatch修改的一个简易版本: + +```python +def MIND(user_feature_columns, item_feature_columns, num_sampled=5, k_max=2, p=1.0, dynamic_k=False, user_dnn_hidden_units=(64, 32), + dnn_activation='relu', dnn_use_bn=False, l2_reg_dnn=0, l2_reg_embedding=1e-6, dnn_dropout=0, output_activation='linear', seed=1024): + """ + :param k_max: 用户兴趣胶囊的最大个数 + """ + # 目前这里只支持item_feature_columns为1的情况,即只能转入item_id + if len(item_feature_columns) > 1: + raise ValueError("Now MIND only support 1 item feature like item_id") + + # 获取item相关的配置参数 + item_feature_column = item_feature_columns[0] + item_feature_name = item_feature_column.name + item_vocabulary_size = item_feature_column.vocabulary_size + item_embedding_dim = item_feature_column.embedding_dim + + behavior_feature_list = [item_feature_name] + + # 为用户特征创建Input层 + user_input_layer_dict = build_input_layers(user_feature_columns) + item_input_layer_dict = build_input_layers(item_feature_columns) + # 将Input层转化成列表的形式作为model的输入 + user_input_layers = list(user_input_layer_dict.values()) + item_input_layers = list(item_input_layer_dict.values()) + + # 筛选出特征中的sparse特征和dense特征,方便单独处理 + sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), user_feature_columns)) if user_feature_columns else [] + dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), user_feature_columns)) if user_feature_columns else [] + varlen_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), user_feature_columns)) if user_feature_columns else [] + + # 由于这个变长序列里面只有历史点击文章,没有类别啥的,所以这里直接可以用varlen_feature_columns + # deepctr这里单独把点击文章这个放到了history_feature_columns + seq_max_len = varlen_feature_columns[0].maxlen + + # 构建embedding字典 + embedding_layer_dict = build_embedding_layers(user_feature_columns+item_feature_columns) + + # 获取当前的行为特征(doc)的embedding,这里面可能又多个类别特征,所以需要pooling下 + query_embed_list = embedding_lookup(behavior_feature_list, item_input_layer_dict, embedding_layer_dict) # 长度为1 + # 获取行为序列(doc_id序列, hist_doc_id) 对应的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起 + keys_embed_list = embedding_lookup([varlen_feature_columns[0].name], user_input_layer_dict, embedding_layer_dict) # 长度为1 + + # 用户离散特征的输入层与embedding层拼接 + dnn_input_emb_list = embedding_lookup([col.name for col in sparse_feature_columns], user_input_layer_dict, embedding_layer_dict) + + # 获取dense + dnn_dense_input = [] + for fc in dense_feature_columns: + if fc.name != 'hist_len': # 连续特征不要这个 + dnn_dense_input.append(user_input_layer_dict[fc.name]) + + # 把keys_emb_list和query_emb_listpooling操作, 这是因为可能每个商品不仅有id,还可能用类别,品牌等多个embedding向量,这种需要pooling成一个 + history_emb = PoolingLayer()(NoMask()(keys_embed_list)) # (None, 50, 8) + target_emb = PoolingLayer()(NoMask()(query_embed_list)) # (None, 1, 8) + + hist_len = user_input_layer_dict['hist_len'] + # 胶囊网络 + # (None, 2, 8) 得到了两个兴趣胶囊 + high_capsule = CapsuleLayer(input_units=item_embedding_dim, out_units=item_embedding_dim, + max_len=seq_max_len, k_max=k_max)((history_emb, hist_len)) + + + # 把用户的其他特征拼接到胶囊网络上来 + if len(dnn_input_emb_list) > 0 or len(dnn_dense_input) > 0: + user_other_feature = combined_dnn_input(dnn_input_emb_list, dnn_dense_input) + # (None, 2, 32) 这里会发现其他的用户特征是每个胶囊复制了一份,然后拼接起来 + other_feature_tile = tf.keras.layers.Lambda(tile_user_otherfeat, arguments={'k_max': k_max})(user_other_feature) + user_deep_input = Concatenate()([NoMask()(other_feature_tile), high_capsule]) # (None, 2, 40) + else: + user_deep_input = high_capsule + + # 接下来过一个DNN层,获取最终的用户表示向量 如果是三维输入, 那么最后一个维度与w相乘,所以这里如果不自己写,可以用Dense层的列表也可以 + user_embeddings = DNN(user_dnn_hidden_units, dnn_activation, l2_reg_dnn, + dnn_dropout, dnn_use_bn, output_activation=output_activation, seed=seed, + name="user_embedding")(user_deep_input) # (None, 2, 8) + + # 接下来,过Label-aware layer + if dynamic_k: + user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb, hist_len)) + else: + user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb)) + + # 接下来 + item_embedding_matrix = embedding_layer_dict[item_feature_name] # 获取doc_id的embedding层 + item_index = EmbeddingIndex(list(range(item_vocabulary_size)))(item_input_layer_dict[item_feature_name]) # 所有doc_id的索引 + item_embedding_weight = NoMask()(item_embedding_matrix(item_index)) # 拿到所有item的embedding + pooling_item_embedding_weight = PoolingLayer()([item_embedding_weight]) # 这里依然是当可能不止item_id,或许还有brand_id, cat_id等,需要池化 + + # 这里传入的是整个doc_id的embedding, user_embedding, 以及用户点击的doc_id,然后去进行负采样计算损失操作 + output = SampledSoftmaxLayer(num_sampled)([pooling_item_embedding_weight, user_embedding_final, item_input_layer_dict[item_feature_name]]) + + model = Model(inputs=user_input_layers+item_input_layers, outputs=output) + + # 下面是等模型训练完了之后,获取用户和item的embedding + model.__setattr__("user_input", user_input_layers) + model.__setattr__("user_embedding", user_embeddings) + model.__setattr__("item_input", item_input_layers) + model.__setattr__("item_embedding", get_item_embedding(pooling_item_embedding_weight, item_input_layer_dict[item_feature_name])) + + return model +``` +简单说下流程, 函数式API搭建模型的方式,首先我们需要传入封装好的用户特征描述以及item特征描述,比如: + +```python +# 建立模型 +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'), + DenseFeat('hist_len', 1), + 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), + ] +doc_feature_columns = [ + SparseFeat('click_doc_id', feature_max_idx['article_id'], embedding_dim) + # 这里后面也可以把文章的类别画像特征加入 +] +``` +首先, 函数会对传入的这种特征建立模型的Input层,主要是`build_input_layers`函数。建立完了之后,获取到Input层列表,这个是为了最终定义模型用的,keras要求定义模型的时候是列表的形式。 + +接下来是选出sparse特征和Dense特征来,这个也是常规操作了,因为不同的特征后面处理方式不一样,对于sparse特征,后面要接embedding层,Dense特征的话,直接可以拼接起来。这就是筛选特征的3行代码。 + +接下来,是为所有的离散特征建立embedding层,通过函数`build_embedding_layers`。建立完了之后,把item相关的embedding层与对应的Input层接起来,作为query_embed_list, 而用户历史行为序列的embedding层与Input层接起来作为keys_embed_list,这两个有单独的用户。而Input层与embedding层拼接是通过`embedding_lookup`函数完成的。 这样完成了之后,就能通过Input层-embedding层拿到item的系列embedding,以及历史序列里面item系列embedding,之所以这里是系列embedding,是有可能不止item_id这一个特征,还可能有品牌id, 类别id等好几个,所以接下来把系列embedding通过pooling操作,得到最终表示item的向量。 就是这两行代码: + +```python +# 把keys_emb_list和query_emb_listpooling操作, 这是因为可能每个商品不仅有id,还可能用类别,品牌等多个embedding向量,这种需要pooling成一个 +history_emb = PoolingLayer()(NoMask()(keys_embed_list)) # (None, 50, 8) +target_emb = PoolingLayer()(NoMask()(query_embed_list)) # (None, 1, 8) +``` +而像其他的输入类别特征, 依然是Input层与embedding层拼起来,留着后面用,这个存到了dnn_input_emb_list中。 而dense特征, 不需要embedding层,直接通过Input层获取到,然后存到列表里面,留着后面用。 + +上面得到的history_emb,就是用户的历史行为序列,这个东西接下来要过兴趣提取层,去学习用户的多兴趣,当然这里还需要传入行为序列的真实长度。因为每个用户行为序列不一样长,通过mask让其等长了,但是真实在胶囊网络计算的时候,这些填充的序列是要被mask掉的。所以必须要知道真实长度。 + +```python +# 胶囊网络 +# (None, 2, 8) 得到了两个兴趣胶囊 +high_capsule = CapsuleLayer(input_units=item_embedding_dim, out_units=item_embedding_dim,max_len=seq_max_len, k_max=k_max)((history_emb, hist_len)) +``` +通过这步操作,就得到了两个兴趣胶囊。 至于具体细节,下一节看。 然后把用户的其他特征拼接上来,这里有必要看下代码究竟是怎么拼接的: + +```python +# 把用户的其他特征拼接到胶囊网络上来 +if len(dnn_input_emb_list) > 0 or len(dnn_dense_input) > 0: + user_other_feature = combined_dnn_input(dnn_input_emb_list, dnn_dense_input) + # (None, 2, 32) 这里会发现其他的用户特征是每个胶囊复制了一份,然后拼接起来 + other_feature_tile = tf.keras.layers.Lambda(tile_user_otherfeat, arguments={'k_max': k_max})(user_other_feature) + user_deep_input = Concatenate()([NoMask()(other_feature_tile), high_capsule]) # (None, 2, 40) +else: + user_deep_input = high_capsule +``` +这里会发现,使用了一个Lambda层,这个东西的作用呢,其实是将用户的其他特征在胶囊个数的维度上复制了一份,再拼接,这就相当于在每个胶囊的后面都拼接上了用户的基础特征。这样得到的维度就成了(None, 2, 40),2是胶囊个数, 40是兴趣胶囊的维度+其他基础特征维度总和。这样拼完了之后,接下来过全连接层 + +```python +# 接下来过一个DNN层,获取最终的用户表示向量 如果是三维输入, 那么最后一个维度与w相乘,所以这里如果不自己写,可以用Dense层的列表也可以 +user_embeddings = DNN(user_dnn_hidden_units, dnn_activation, l2_reg_dnn, + dnn_dropout, dnn_use_bn, output_activation=output_activation, seed=seed, + name="user_embedding")(user_deep_input) # (None, 2, 8) +``` +最终得到的是(None, 2, 8)的向量,这样就解决了之前的那个疑问, 最终得到的兴趣向量个数并不是1个,而是多个兴趣向量了,因为上面用户特征拼接,是每个胶囊后面都拼接一份同样的特征。另外,就是原来DNN这里的输入还可以是3维的,这样进行运算的话,是最后一个维度与W进行运算,相当于只在第3个维度上进行了降维操作后者非线性操作,这样得到的兴趣个数是不变的。 + +这样,有了两个兴趣的输出之后,接下来,就是过LabelAwareAttention层了,对这两个兴趣向量与当前item的相关性加注意力权重,最后变成1个用户的最终向量。 + +```python +user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb)) +``` +这样,就得到了用户的最终表示向量,当然这个操作仅是训练的时候,服务的时候是拿的上面DNN的输出,即多个兴趣,这里注意一下。 + +拿到了最终的用户向量,如何计算损失呢? 这里用了负采样层进行操作。关于这个层具体的原理,后面我们可能会出一篇文章总结。 + +接下来有几行代码也需要注意: + +```python +# 下面是等模型训练完了之后,获取用户和item的embedding +model.__setattr__("user_input", user_input_layers) +model.__setattr__("user_embedding", user_embeddings) +model.__setattr__("item_input", item_input_layers) +model.__setattr__("item_embedding", get_item_embedding(pooling_item_embedding_weight, item_input_layer_dict[item_feature_name])) +``` +这几行代码是为了模型训练完,我们给定输入之后,拿embedding用的,设置好了之后,通过: + +```python +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) +# user_embs = user_embs[:, i, :] # i in [0,k_max) if MIND +item_embs = item_embedding_model.predict(all_item_model_input, batch_size=2 ** 12) +``` +这样就能拿到用户和item的embedding, 接下来近邻检索完成召回过程。 注意,MIND的话,这里是拿到的多个兴趣向量的。 + +## 总结 +今天这篇文章整理的MIND,这是一个多兴趣的召回模型,核心是兴趣提取层,该层通过动态路由机制能够自动的对用户的历史行为序列进行聚类,得到多个兴趣向量,这样能在召回阶段捕获到用户的广泛兴趣,从而召回更好的候选商品。 + + +**参考**: +* Multi-Interest Network with Dynamic Routing for Recommendation at Tmall +* [ AI上推荐 之 MIND(动态路由与胶囊网络的奇光异彩)](https://blog.csdn.net/wuzhongqiang/article/details/123696462?spm=1001.2014.3001.5501) +* [Dynamic Routing Between Capsule ](https://arxiv.org/pdf/1710.09829.pdf) +* [CIKM2019|MIND---召回阶段的多兴趣模型](https://zhuanlan.zhihu.com/p/262638999) +* [B站胶囊网络课程](https://www.bilibili.com/video/BV1eW411Q7CE?p=2) +* [胶囊网络识别交通标志](https://blog.csdn.net/shebao3333/article/details/79008688) + + diff --git a/4.人工智能/ch02/ch2.1/ch2.1.4/SDM.md b/4.人工智能/ch02/ch2.1/ch2.1.4/SDM.md new file mode 100644 index 0000000..4fa3b62 --- /dev/null +++ b/4.人工智能/ch02/ch2.1/ch2.1.4/SDM.md @@ -0,0 +1,404 @@ +## 写在前面 +SDM模型(Sequential Deep Matching Model),是阿里团队在2019年CIKM上的一篇paper。和MIND模型一样,是一种序列召回模型,研究的依然是如何通过用户的历史行为序列去学习到用户的丰富兴趣。 对于MIND,我们已经知道是基于胶囊网络的动态路由机制,设计了一个动态兴趣提取层,把用户的行为序列通过路由机制聚类,然后映射成了多个兴趣胶囊,以此来获取到用户的广泛兴趣。而SDM模型,是先把用户的历史序列根据交互的时间分成了短期和长期两类,然后从**短期会话**和**长期行为**中分别采取**相应的措施(短期的RNN+多头注意力, 长期的Att Net)** 去学习到用户的短期兴趣和长期行为偏好,并**巧妙的设计了一个门控网络==有选择==的将长短期兴趣进行融合**,以此得到用户的最终兴趣向量。 这篇paper中的一些亮点,比如长期偏好的行为表示,多头注意力机制学习多兴趣,长短期兴趣的融合机制等,又给了一些看待问题的新角度,同时,给出了我们一种利用历史行为序列去捕捉用户动态偏好的新思路。 + +这篇paper依然是从引言开始, 介绍SDM模型提出的动机以及目前方法存在的不足(why), 接下来就是SDM的网络模型架构(what), 这里面的关键是如何从短期会话和长期行为两个方面学习到用户的短期长期偏好(how),最后,依然是简易代码实现。 + +大纲如下: +* 背景与动机 +* SDM的网络结构与细节 +* SDM模型代码复现 + +## 背景与动机 + 这里要介绍该模型提出的动机,即why要有这样的一个模型? + +一个好的推荐系统应该是能精确的捕捉用户兴趣偏好以及能对他们当前需求进行快速响应的,往往工业上的推荐系统,为了能快速响应, 一般会把整个推荐流程分成召回和排序两个阶段,先通过召回,从海量商品中得到一个小的候选集,然后再给到排序模型做精确的筛选操作。 这也是目前推荐系统的一个范式了。在这个过程中,召回模块所检索到的候选对象的质量在整个系统中起着至关重要的作用。 + +淘宝目前的召回模型是一些基于协同过滤的模型, 这些模型是通过用户与商品的历史交互建模,从而得到用户的物品的表示向量,但这个过程是**静态的**,而用户的行为或者兴趣是时刻变化的, 对于协同过滤的模型来说,并不能很好的捕捉到用户整个行为序列的动态变化。 + +那我们知道了学习用户历史行为序列很重要, 那么假设序列很长呢?这时候直接用模型学习长序列之间的演进可能不是很好,因为很长的序列里面可能用户的兴趣发生过很大的转变,很多商品压根就没有啥关系,这样硬学,反而会导致越学越乱,就别提这个演进了。所以这里是以会话为单位,对长序列进行切分。作者这里的依据就是用户在同一个Session下,其需求往往是很明确的, 这时候,交互的商品也往往都非常类似。 但是Session与Session之间,可能需求改变,那么商品类型可能骤变。 所以以Session为单位来学习商品之间的序列信息,感觉要比整个长序列学习来的靠谱。 + +作者首先是先把长序列分成了多个会话, 然后**把最近的一次会话,和之前的会话分别视为了用户短期行为和长期行为分别进行了建模,并采用不同的措施学习用户的短期兴趣和长期兴趣,然后通过一个门控机制融合得到用户最终的表示向量**。这就是SDM在做的事情, + + +长短期行为序列联合建模,其实是在给我们提供一种新的学习用户兴趣的新思路, 那么究竟是怎么做的呢?以及为啥这么做呢? +* 对于短期用户行为, 首先作者使用了LSTM来学习序列关系, 而接下来是用一个Multi-head attention机制,学习用户的多兴趣。 + + 先分析分析作者为啥用多头注意力机制,作者这里依然是基于实际的场景出发,作者发现,**用户的兴趣点在一个会话里面其实也是多重的**。这个可能之前的很多模型也是没考虑到的,但在商品购买的场景中,这确实也是个事实, 顾客在买一个商品的时候,往往会进行多方比较, 考虑品牌,颜色,商店等各种因素。作者认为用普通的注意力机制是无法反映广泛的兴趣了,所以用多头注意力网络。 + + 多头注意力机制从某个角度去看,也有类似聚类的功效,首先它接收了用户的行为序列,然后从多个角度学习到每个商品与其他商品的相关性,然后根据与其他商品的相关性加权融合,这样,相似的item向量大概率就融合到了一块组成一个向量,所谓用户的多兴趣,可能是因为这些行为商品之间,可以从多个空间或者角度去get彼此之间的相关性,这里面有着用户多兴趣的表达信息。 + +* 用户的长期行为也会影响当前的决策,作者在这里举了一个NBA粉丝的例子,说如果一个是某个NBA球星的粉丝,那么他可能在之前会买很多有关这个球星的商品,如果现在这个时刻想买鞋的时候,大概率会考虑和球星相关的。所以作者说**长期偏好和短期行为都非常关键**。但是长期偏好或者行为往往是复杂广泛的,就像刚才这个例子里面,可能长期行为里面,买的与这个球星相关商品只占一小部分,而就只有这一小部分对当前决策有用。 + 这个也是之前的模型利用长期偏好方面存在的问题,那么如何选择出长期偏好里面对于当前决策有用的那部分呢? 作者这里设计了一个门控的方式融合短期和长期,这个想法还是很巧妙的,后面介绍这个东西的时候说下我的想法。 + +所以下面总结动机以及本篇论文的亮点: +* 动机: 召回模型需要捕获用户的动态兴趣变化,这个过程中利用好用户的长期行为和短期偏好非常关键,而以往的模型有下面几点不足: + * 协同过滤模型: 基于用户的交互进行静态建模,无法感知用户的兴趣变化过程,易召回同质性的商品 + * 早期的一些序列推荐模型: 要么是对整个长序列直接建模,但这样太暴力,没法很好的学习商品之间的序列信息,有些是把长序列分成会话,但忽视了一个会话中用户的多重兴趣 + * 有些方法在考虑用户的长期行为方面,只是简单的拼接或者加权求和,而实际上用户长期行为中只有很少一小部分对当前的预测有用,这样暴力融合反而会适得其反,起不到效果。另外还有一些多任务或者对抗方法, 在工业场景中不适用等。 + * 这些我只是通过我的理解简单总结,详细内容看原论文相关工作部分。 +* 亮点: + * SDM模型, 考虑了用户的短期行为和长期兴趣,以会话的形式进行分割,并对这两方面分别建模 + * 短期会话由于对当前决策影响比较大,那么我们就学习的全面一点, 首先RNN学习序列关系,其次通过多头注意力机制捕捉多兴趣,然后通过一个Attention Net加权得到短期兴趣表示 + * 长期会话通过Attention Net融合,然后过DNN,得到用户的长期表示 + * 我们设计了一个门控机制,类似于LSTM的那种门控,能巧妙的融合这两种兴趣,得到用户最终的表示向量 + +这就是动机与背景总结啦。 那么接下来,SDM究竟是如何学习短期和长期表示,又是如何融合的? 为什么要这么玩? + +## SDM的网络结构与细节剖析 +### 问题定义 +这里本来直接看模型结构,但感觉还是先过一下问题定义吧,毕竟这次涉及到了会话,还有几个小规则。 + +$\mathcal{U}$表示用户集合,$\mathcal{I}$表示item集合,模型考虑在时间$t$,是否用户$u$会对$i$产生交互。 对于$u$, 我们能够得到它的历史行为序列,那么先说一下如何进行会话的划分, 这里有三个规则: +1. 相同会话ID的商品(后台能获取)算是一个会话 +2. 相邻的商品,时间间隔小于10分钟(业务自己调整)算一个会话 +3. 同一个会话中的商品不能超过50个,多出来的放入下一个会话 + +这样划分开会话之后, 对于用户$u$的短期行为定义是离目前最近的这次会话, 用$\mathcal{S}^{u}=\left[i_{1}^{u}, \ldots, i_{t}^{u}, \ldots, i_{m}^{u}\right]$表示,$m$是序列长度。 而长期的用户行为是过去一周内的会话,但不包括短期的这次会话, 这个用$\mathcal{L}^{u}$表示。网络推荐架构如下: + +这个感觉并不用过多解释。看过召回的应该都能懂, 接收了用户的短期行为和长期行为,然后分别通过两个盲盒得到表示向量,再通过门控融合就得到了最终的用户表示。 + +下面要开那三个盲盒操作,即短期行为学习,长期行为学习以及门控融合机制。但在这之前,得先说一个东西,就是输入层这里, 要带物品的side infomation,比如物品的item ID, 物品的品牌ID,商铺ID, 类别ID等等, 那你说,为啥要单独说呢? 之前的模型不也有, 但是这里在利用方式上有些不一样需要注意。 + +### Input Embedding with side Information +在淘宝的推荐场景中,作者发现, 顾客与物品产生交互行为的时候,不仅考虑特定的商品本身,还考虑产品, 商铺,价格等,这个显然。所以,这里对于一个商品来说,不仅要用到Item ID,还用了更多的side info信息,包括`leat category, fist level category, brand,shop`。 + +所以,假设用户的短期行为是$\mathcal{S}^{u}=\left[i_{1}^{u}, \ldots, i_{t}^{u}, \ldots, i_{m}^{u}\right]$, 这里面的每个商品$i_t^u$其实有5个属性表示了,每个属性本质是ID,但转成embedding之后,就得到了5个embedding, 所以这里就涉及到了融合问题。 这里用$\boldsymbol{e}_{{i}^u_t} \in \mathbb{R}^{d \times 1}$来表示每个$i_t^u$,但这里不是embedding的pooling操作,而是Concat +$$ +\boldsymbol{e}_{i_{t}^{u}}=\operatorname{concat}\left(\left\{\boldsymbol{e}_{i}^{f} \mid f \in \mathcal{F}\right\}\right) +$$ +其中,$\boldsymbol{e}_{i}^{f}=\boldsymbol{W}^{f} \boldsymbol{x}_{i}^{f} \in \mathbb{R}^{d_{f} \times 1}$, 这个公式看着负责,其实就是每个side info的id过embedding layer得到各自的embedding。这里embedding的维度是$d_f$, 等拼接起来之后,就是$d$维了。这个点要注意。 + +另外就是用户的base表示向量了,这个很简单, 就是用户的基础画像,得到embedding,直接也是Concat,这个常规操作不解释: +$$ +\boldsymbol{e}_{u}=\operatorname{concat}\left(\left\{\boldsymbol{e}_{u}^{p} \mid p \in \mathcal{P}\right\}\right) +$$ +$e_u^p$是特征$p$的embedding。 + +Ok,输入这里说完了之后,就直接开盲盒, 不按照论文里面的顺序来了。想看更多细节的就去看原论文吧,感觉那里面说的有些啰嗦。不如直接上图解释来的明显: + + +这里正好三个框把盒子框住了,下面剖析出每个来就行啦。 +### 短期用户行为建模 +这里短期用户行为是下面的那个框, 接收的输入,首先是用户最近的那次会话,里面各个商品加入了side info信息之后,有了最终的embedding表示$\left[\boldsymbol{e}_{i_{1}^{u}}, \ldots, \boldsymbol{e}_{i_{t}^{u}}\right]$。 + +这个东西,首先要过LSTM,学习序列信息,这个感觉不用多说,直接上公式: +$$ +\begin{aligned} +\boldsymbol{i} \boldsymbol{n}_{t}^{u} &=\sigma\left(\boldsymbol{W}_{i n}^{1} \boldsymbol{e}_{i_{t}^{u}}+\boldsymbol{W}_{i n}^{2} \boldsymbol{h}_{t-1}^{u}+b_{i n}\right) \\ +f_{t}^{u} &=\sigma\left(\boldsymbol{W}_{f}^{1} \boldsymbol{e}_{i_{t}^{u}}+\boldsymbol{W}_{f}^{2} \boldsymbol{h}_{t-1}^{u}+b_{f}\right) \\ +\boldsymbol{o}_{t}^{u} &=\sigma\left(\boldsymbol{W}_{o}^{1} \boldsymbol{e}_{i}^{u}+\boldsymbol{W}_{o}^{2} \boldsymbol{h}_{t-1}^{u}+b_{o}\right) \\ +\boldsymbol{c}_{t}^{u} &=\boldsymbol{f}_{t} \boldsymbol{c}_{t-1}^{u}+\boldsymbol{i} \boldsymbol{n}_{t}^{u} \tanh \left(\boldsymbol{W}_{c}^{1} \boldsymbol{e}_{i_{t}^{u}}+\boldsymbol{W}_{c}^{2} \boldsymbol{h}_{t-1}^{u}+b_{c}\right) \\ +\boldsymbol{h}_{t}^{u} &=\boldsymbol{o}_{t}^{u} \tanh \left(\boldsymbol{c}_{t}^{u}\right) +\end{aligned} +$$ +这里采用的是多输入多输出, 即每个时间步都会有一个隐藏状态$h_t^u$输出出来,那么经过LSTM之后,原始的序列就有了序列相关信息,得到了$\left[\boldsymbol{h}_{1}^{u}, \ldots, \boldsymbol{h}_{t}^{u}\right]$, 把这个记为$\boldsymbol{X}^{u}$。这里的$\boldsymbol{h}_{t}^{u} \in \mathbb{R}^{d \times 1}$表示时间$t$的序列偏好表示。 + +接下来, 这个东西要过Multi-head self-attention层,这个东西的原理我这里就不多讲了,这个东西可以学习到$h_i^u$系列之间的相关性,这个操作从某种角度看,也很像聚类, 因为我们这里是先用多头矩阵把$h_i^u$系列映射到多个空间,然后从各个空间中互求相关性 +$$ +\text { head }{ }_{i}^{u}=\operatorname{Attention}\left(\boldsymbol{W}_{i}^{Q} \boldsymbol{X}^{u}, \boldsymbol{W}_{i}^{K} \boldsymbol{X}^{u}, \boldsymbol{W}_{i}^{V} \boldsymbol{X}^{u}\right) +$$ +得到权重后,对原始的向量加权融合。 让$Q_{i}^{u}=W_{i}^{Q} X^{u}$, $K_{i}^{u}=W_{i}^{K} \boldsymbol{X}^{u}$,$V_{i}^{u}=W_{i}^{V} X^{u}$, 背后计算是: +$$ +\begin{aligned} +&f\left(Q_{i}^{u}, K_{i}^{u}\right)=Q_{i}^{u T} K_{i}^{u} \\ +&A_{i}^{u}=\operatorname{softmax}\left(f\left(Q_{i}^{u}, K_{i}^{u}\right)\right) +\end{aligned} \\ \operatorname{head}_{i}^{u}=V_{i}^{u} A_{i}^{u T} +$$ + +这里如果有多头注意力基础的话非常好理解啊,不多解释,可以看我[这篇文章](https://blog.csdn.net/wuzhongqiang/article/details/104414239?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522164872966516781683952272%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=164872966516781683952272&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-104414239.nonecase&utm_term=Attention+is+all&spm=1018.2226.3001.4450)补一下。 + +这是一个头的计算, 接下来每个头都这么算,假设有$h$个头,这里会通过上面的映射矩阵$W$系列,先把原始的$h_i^u$向量映射到$d_{k}=\frac{1}{h} d$维度,然后计算$head_i^u$也是$d_k$维,这样$h$个head进行拼接,正好是$d$维, 接下来过一个全连接或者线性映射得到MultiHead的输出。 +$$ +\hat{X}^{u}=\text { MultiHead }\left(X^{u}\right)=W^{O} \text { concat }\left(\text { head }_{1}^{u}, \ldots, \text { head }_{h}^{u}\right) +$$ + +这样就相当于更相似的$h_i^u$融合到了一块,而这个更相似又是从多个角度得到的,于是乎, 作者认为,这样就能学习到用户的多兴趣。 + +得到这个东西之后,接下来再过一个User Attention, 因为作者发现,对于相似历史行为的不同用户,其兴趣偏好也不太一样。 +所以加入这个用户Attention层,想挖掘更细粒度的用户个性化信息。 当然,这个就是普通的embedding层了, 用户的base向量$e_u$作为query,与$\hat{X}^{u}$的每个向量做Attention,然后加权求和得最终向量: +$$ +\begin{aligned} +\alpha_{k} &=\frac{\exp \left(\hat{\boldsymbol{h}}_{k}^{u T} \boldsymbol{e}_{u}\right)}{\sum_{k=1}^{t} \exp \left(\hat{\boldsymbol{h}}_{k}^{u T} \boldsymbol{e}_{u}\right)} \\ +\boldsymbol{s}_{t}^{u} &=\sum_{k=1}^{t} \alpha_{k} \hat{\boldsymbol{h}}_{k}^{u} +\end{aligned} +$$ +其中$s_{t}^{u} \in \mathbb{R}^{d \times 1}$,这样短期行为兴趣就修成了正果。 + +### 用户长期行为建模 +从长期的视角来看,用户在不同的维度上可能积累了广泛的兴趣,用户可能经常访问一组类似的商店,并反复购买属于同一类别的商品。 所以长期行为$\mathcal{L}^{u}$来自于不同的特征尺度。 +$$ +\mathcal{L}^{u}=\left\{\mathcal{L}_{f}^{u} \mid f \in \mathcal{F}\right\} +$$ +这里面包含了各种side特征。这里就和短期行为那里不太一样了,长期行为这里,是从特征的维度进行聚合,也就是把用户的历史长序列分成了多个特征,比如用户历史点击过的商品,历史逛过的店铺,历史看过的商品的类别,品牌等,分成了多个特征子集,然后这每个特征子集里面有对应的id,比如商品有商品id, 店铺有店铺id等,对于每个子集,过user Attention layer,和用户的base向量求Attention, 相当于看看用户喜欢逛啥样的商店, 喜欢啥样的品牌,啥样的商品类别等等,得到每个子集最终的表示向量。每个子集的计算过程如下: +$$ +\begin{aligned} +\alpha_{k} &=\frac{\exp \left(\boldsymbol{g}_{k}^{u T} \boldsymbol{e}_{u}\right)}{\sum_{k=1}^{\left|\mathcal{L}_{f}^{u}\right|} \exp \left(\boldsymbol{g}_{k}^{u T} \boldsymbol{e}_{u}\right)} \\ +z_{f}^{u} &=\sum_{k=1}^{\left|\mathcal{L}_{f}^{u}\right|} \alpha_{k} \boldsymbol{g}_{k}^{u} +\end{aligned} +$$ +每个子集都会得到一个加权的向量,把这个东西拼起来,然后过DNN。 +$$ +\begin{aligned} +&z^{u}=\operatorname{concat}\left(\left\{z_{f}^{u} \mid f \in \mathcal{F}\right\}\right) \\ +&\boldsymbol{p}^{u}=\tanh \left(\boldsymbol{W}^{p} z^{u}+b\right) +\end{aligned} +$$ +这里的$\boldsymbol{p}^{u} \in \mathbb{R}^{d \times 1}$, 这样就得到了用户的长期兴趣表示。 +### 短长期兴趣融合 +长短期兴趣融合这里,作者发现之前模型往往喜欢直接拼接起来,或者加和,注意力加权等,但作者认为这样不能很好的将两类兴趣融合起来,因为长期序列里面,其实只有很少的一部分行为和当前有关。那么这样的话,直接无脑融合是有问题的。所以这里作者用了一种较为巧妙的方式,即门控机制: +$$ +G_{t}^{u}=\operatorname{sigmoid}\left(\boldsymbol{W}^{1} \boldsymbol{e}_{u}+\boldsymbol{W}^{2} s_{t}^{u}+\boldsymbol{W}^{3} \boldsymbol{p}^{u}+b\right) \\ +o_{t}^{u}=\left(1-G_{t}^{u}\right) \odot p^{u}+G_{t}^{u} \odot s_{t}^{u} +$$ +这个和LSTM的这种门控机制很像,首先门控接收的输入有用户画像$e_u$,用户短期兴趣$s_t^u$, 用户长期兴趣$p^u$,经过sigmoid函数得到了$G_{t}^{u} \in \mathbb{R}^{d \times 1}$,用来决定在$t$时刻短期和长期兴趣的贡献程度。然后根据这个贡献程度对短期和长期偏好加权进行融合。 + +为啥这东西就有用了呢? 实验中证明了这个东西有用,但这里给出我的理解哈,我们知道最终得到的短期或者长期兴趣都是$d$维的向量, 每一个维度可能代表着不同的兴趣偏好,比如第一维度代表品牌,第二个维度代表类别,第三个维度代表价格,第四个维度代表商店等等,当然假设哈,真实的向量不可解释。 + +那么如果我们是直接相加或者是加权相加,其实都意味着长短期兴趣这每个维度都有很高的保留, 但其实上,万一长期兴趣和短期兴趣维度冲突了呢? 比如短期兴趣里面可能用户喜欢这个品牌,长期用户里面用户喜欢那个品牌,那么听谁的? 你可能说短期兴趣这个占更大权重呗,那么普通加权可是所有向量都加的相同的权重,品牌这个维度听短期兴趣的,其他维度比如价格,商店也都听短期兴趣的?本身存在不合理性。那么反而直接相加或者加权效果会不好。 + +而门控机制的巧妙就在于,我会给每个维度都学习到一个权重,而这个权重非0即1(近似哈), 那么接下来融合的时候,我通过这个门控机制,取长期和短期兴趣向量每个维度上的其中一个。比如在品牌方面听谁的,类别方面听谁的,价格方面听谁的,只会听短期和长期兴趣的其中一个的。这样就不会有冲突发生,而至于具体听谁的,交给网络自己学习。这样就使得用户长期兴趣和短期兴趣融合的时候,每个维度上的信息保留变得**有选择**。使得兴趣的融合方式更加的灵活。 + + ==这其实又给我们提供了一种两个向量融合的一种新思路,并不一定非得加权或者拼接或者相加了,还可以通过门控机制让网络自己学== + + +## SDM模型的简易复现 +下面参考DeepMatch,用简易的代码实现下SDM,并在新闻推荐的数据集上进行召回任务。 + +首先,下面分析SDM的整体架构,从代码层面看运行流程, 然后就这里面几个关键的细节进行说明。 + +### 模型的输入 +对于SDM模型,由于它是将用户的行为序列分成了会话的形式,所以在构造SDM模型输入方面和前面的MIND以及YouTubeDNN有很大的不同了,所以这里需要先重点强调下输入。 + +在为SDM产生数据集的时候, 需要传入短期会话的长度以及长期会话的长度, 这样, 对于一个行为序列,构造数据集的时候要按照两个长度分成短期行为和长期行为两种,并且每一种都需要指明真实的序列长度。另外,由于这里用到了文章的side info信息,所以我这里在之前列的基础上加入了文章的两个类别特征分别是cat_1和cat_2,作为文章的side info。 这个产生数据集的代码如下: + +```python +"""构造sdm数据集""" +def get_data_set(click_data, seq_short_len=5, seq_prefer_len=50): + """ + :param: seq_short_len: 短期会话的长度 + :param: seq_prefer_len: 会话的最长长度 + """ + click_data.sort_values("expo_time", inplace=True) + + train_set, test_set = [], [] + for user_id, hist_click in tqdm(click_data.groupby('user_id')): + pos_list = hist_click['article_id'].tolist() + cat1_list = hist_click['cat_1'].tolist() + cat2_list = hist_click['cat_2'].tolist() + + # 滑动窗口切分数据 + for i in range(1, len(pos_list)): + hist = pos_list[:i] + cat1_hist = cat1_list[:i] + cat2_hist = cat2_list[:i] + # 序列长度只够短期的 + if i <= seq_short_len and i != len(pos_list) - 1: + train_set.append(( + # 用户id, 用户短期历史行为序列, 用户长期历史行为序列, 当前行为文章, label, + user_id, hist[::-1], [0]*seq_prefer_len, pos_list[i], 1, + # 用户短期历史序列长度, 用户长期历史序列长度, + len(hist[::-1]), 0, + # 用户短期历史序列对应类别1, 用户长期历史行为序列对应类别1 + cat1_hist[::-1], [0]*seq_prefer_len, + # 历史短期历史序列对应类别2, 用户长期历史行为序列对应类别2 + cat2_hist[::-1], [0]*seq_prefer_len + )) + # 序列长度够长期的 + elif i != len(pos_list) - 1: + train_set.append(( + # 用户id, 用户短期历史行为序列,用户长期历史行为序列, 当前行为文章, label + user_id, hist[::-1][:seq_short_len], hist[::-1][seq_short_len:], pos_list[i], 1, + # 用户短期行为序列长度,用户长期行为序列长度, + seq_short_len, len(hist[::-1])-seq_short_len, + # 用户短期历史行为序列对应类别1, 用户长期历史行为序列对应类别1 + cat1_hist[::-1][:seq_short_len], cat1_hist[::-1][seq_short_len:], + # 用户短期历史行为序列对应类别2, 用户长期历史行为序列对应类别2 + cat2_hist[::-1][:seq_short_len], cat2_hist[::-1][seq_short_len:] + )) + # 测试集保留最长的那一条 + elif i <= seq_short_len and i == len(pos_list) - 1: + test_set.append(( + user_id, hist[::-1], [0]*seq_prefer_len, pos_list[i], 1, + len(hist[::-1]), 0, + cat1_hist[::-1], [0]*seq_perfer_len, + cat2_hist[::-1], [0]*seq_prefer_len + )) + else: + test_set.append(( + user_id, hist[::-1][:seq_short_len], hist[::-1][seq_short_len:], pos_list[i], 1, + seq_short_len, len(hist[::-1])-seq_short_len, + cat1_hist[::-1][:seq_short_len], cat1_hist[::-1][seq_short_len:], + cat2_list[::-1][:seq_short_len], cat2_hist[::-1][seq_short_len:] + )) + + random.shuffle(train_set) + random.shuffle(test_set) + + return train_set, test_set +``` +思路和之前的是一样的,无非就是根据会话的长短,把之前的一个长行为序列划分成了短期和长期两个,然后加入两个新的side info特征。 + +### 模型的代码架构 +整个SDM模型算是参考deepmatch修改的一个简易版本: + +```python +def SDM(user_feature_columns, item_feature_columns, history_feature_list, num_sampled=5, units=32, rnn_layers=2, + dropout_rate=0.2, rnn_num_res=1, num_head=4, l2_reg_embedding=1e-6, dnn_activation='tanh', seed=1024): + """ + :param rnn_num_res: rnn的残差层个数 + :param history_feature_list: short和long sequence field + """ + # item_feature目前只支持doc_id, 再加别的就不行了,其实这里可以改造下 + if (len(item_feature_columns)) > 1: + raise ValueError("SDM only support 1 item feature like doc_id") + + # 获取item_feature的一些属性 + item_feature_column = item_feature_columns[0] + item_feature_name = item_feature_column.name + item_vocabulary_size = item_feature_column.vocabulary_size + + # 为用户特征创建Input层 + user_input_layer_dict = build_input_layers(user_feature_columns) + item_input_layer_dict = build_input_layers(item_feature_columns) + + # 将Input层转化成列表的形式作为model的输入 + user_input_layers = list(user_input_layer_dict.values()) + item_input_layers = list(item_input_layer_dict.values()) + + # 筛选出特征中的sparse特征和dense特征,方便单独处理 + sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), user_feature_columns)) if user_feature_columns else [] + dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), user_feature_columns)) if user_feature_columns else [] + if len(dense_feature_columns) != 0: + raise ValueError("SDM dont support dense feature") # 目前不支持Dense feature + varlen_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), user_feature_columns)) if user_feature_columns else [] + + # 构建embedding字典 + embedding_layer_dict = build_embedding_layers(user_feature_columns+item_feature_columns) + + # 拿到短期会话和长期会话列 之前的命名规则在这里起作用 + sparse_varlen_feature_columns = [] + prefer_history_columns = [] + short_history_columns = [] + + prefer_fc_names = list(map(lambda x: "prefer_" + x, history_feature_list)) + short_fc_names = list(map(lambda x: "short_" + x, history_feature_list)) + + for fc in varlen_feature_columns: + if fc.name in prefer_fc_names: + prefer_history_columns.append(fc) + elif fc.name in short_fc_names: + short_history_columns.append(fc) + else: + sparse_varlen_feature_columns.append(fc) + + # 获取用户的长期行为序列列表 L^u + # [+
, , ] + prefer_emb_list = embedding_lookup(prefer_fc_names, user_input_layer_dict, embedding_layer_dict) + # 获取用户的短期序列列表 S^u + # [ , , ] + short_emb_list = embedding_lookup(short_fc_names, user_input_layer_dict, embedding_layer_dict) + + # 用户离散特征的输入层与embedding层拼接 e^u + user_emb_list = embedding_lookup([col.name for col in sparse_feature_columns], user_input_layer_dict, embedding_layer_dict) + user_emb = concat_func(user_emb_list) + user_emb_output = Dense(units, activation=dnn_activation, name='user_emb_output')(user_emb) # (None, 1, 32) + + # 长期序列行为编码 + # 过AttentionSequencePoolingLayer --> Concat --> DNN + prefer_sess_length = user_input_layer_dict['prefer_sess_length'] + prefer_att_outputs = [] + # 遍历长期行为序列 + for i, prefer_emb in enumerate(prefer_emb_list): + prefer_attention_output = AttentionSequencePoolingLayer(dropout_rate=0)([user_emb_output, prefer_emb, prefer_sess_length]) + prefer_att_outputs.append(prefer_attention_output) + prefer_att_concat = concat_func(prefer_att_outputs) # (None, 1, 64) <== Concat(item_embedding,cat1_embedding,cat2_embedding) + prefer_output = Dense(units, activation=dnn_activation, name='prefer_output')(prefer_att_concat) + # print(prefer_output.shape) # (None, 1, 32) + + # 短期行为序列编码 + short_sess_length = user_input_layer_dict['short_sess_length'] + short_emb_concat = concat_func(short_emb_list) # (None, 5, 64) 这里注意下, 对于短期序列,描述item的side info信息进行了拼接 + short_emb_input = Dense(units, activation=dnn_activation, name='short_emb_input')(short_emb_concat) # (None, 5, 32) + # 过rnn 这里的return_sequence=True, 每个时间步都需要输出h + short_rnn_output = DynamicMultiRNN(num_units=units, return_sequence=True, num_layers=rnn_layers, + num_residual_layers=rnn_num_res, # 这里竟然能用到残差 + dropout_rate=dropout_rate)([short_emb_input, short_sess_length]) + # print(short_rnn_output) # (None, 5, 32) + # 过MultiHeadAttention # (None, 5, 32) + short_att_output = MultiHeadAttention(num_units=units, head_num=num_head, dropout_rate=dropout_rate)([short_rnn_output, short_sess_length]) # (None, 5, 64) + # user_attention # (None, 1, 32) + short_output = UserAttention(num_units=units, activation=dnn_activation, use_res=True, dropout_rate=dropout_rate)([user_emb_output, short_att_output, short_sess_length]) + + # 门控融合 + gated_input = concat_func([prefer_output, short_output, user_emb_output]) + gate = Dense(units, activation='sigmoid')(gated_input) # (None, 1, 32) + + # temp = tf.multiply(gate, short_output) + tf.multiply(1-gate, prefer_output) 感觉这俩一样? + gated_output = Lambda(lambda x: tf.multiply(x[0], x[1]) + tf.multiply(1-x[0], x[2]))([gate, short_output, prefer_output]) # [None, 1,32] + gated_output_reshape = Lambda(lambda x: tf.squeeze(x, 1))(gated_output) # (None, 32) 这个维度必须要和docembedding层的维度一样,否则后面没法sortmax_loss + + # 接下来 + item_embedding_matrix = embedding_layer_dict[item_feature_name] # 获取doc_id的embedding层 + item_index = EmbeddingIndex(list(range(item_vocabulary_size)))(item_input_layer_dict[item_feature_name]) # 所有doc_id的索引 + item_embedding_weight = NoMask()(item_embedding_matrix(item_index)) # 拿到所有item的embedding + pooling_item_embedding_weight = PoolingLayer()([item_embedding_weight]) # 这里依然是当可能不止item_id,或许还有brand_id, cat_id等,需要池化 + + # 这里传入的是整个doc_id的embedding, user_embedding, 以及用户点击的doc_id,然后去进行负采样计算损失操作 + output = SampledSoftmaxLayer(num_sampled)([pooling_item_embedding_weight, gated_output_reshape, item_input_layer_dict[item_feature_name]]) + + model = Model(inputs=user_input_layers+item_input_layers, outputs=output) + + # 下面是等模型训练完了之后,获取用户和item的embedding + model.__setattr__("user_input", user_input_layers) + model.__setattr__("user_embedding", gated_output_reshape) # 用户embedding是取得门控融合的用户向量 + model.__setattr__("item_input", item_input_layers) + # item_embedding取得pooling_item_embedding_weight, 这个会发现是负采样操作训练的那个embedding矩阵 + model.__setattr__("item_embedding", get_item_embedding(pooling_item_embedding_weight, item_input_layer_dict[item_feature_name])) + return model +``` +函数式API搭建模型的方式,首先我们需要传入封装好的用户特征描述以及item特征描述,比如: + +```python +# 建立模型 +user_feature_columns = [ + SparseFeat('user_id', feature_max_idx['user_id'], 16), + SparseFeat('gender', feature_max_idx['gender'], 16), + SparseFeat('age', feature_max_idx['age'], 16), + SparseFeat('city', feature_max_idx['city'], 16), + + VarLenSparseFeat(SparseFeat('short_doc_id', feature_max_idx['article_id'], embedding_dim, embedding_name="doc_id"), SEQ_LEN_short, 'mean', 'short_sess_length'), + VarLenSparseFeat(SparseFeat('prefer_doc_id', feature_max_idx['article_id'], embedding_dim, embedding_name='doc_id'), SEQ_LEN_prefer, 'mean', 'prefer_sess_length'), + VarLenSparseFeat(SparseFeat('short_cat1', feature_max_idx['cat_1'], embedding_dim, embedding_name='cat_1'), SEQ_LEN_short, 'mean', 'short_sess_length'), + VarLenSparseFeat(SparseFeat('prefer_cat1', feature_max_idx['cat_1'], embedding_dim, embedding_name='cat_1'), SEQ_LEN_prefer, 'mean', 'prefer_sess_length'), + VarLenSparseFeat(SparseFeat('short_cat2', feature_max_idx['cat_2'], embedding_dim, embedding_name='cat_2'), SEQ_LEN_short, 'mean', 'short_sess_length'), + VarLenSparseFeat(SparseFeat('prefer_cat2', feature_max_idx['cat_2'], embedding_dim, embedding_name='cat_2'), SEQ_LEN_prefer, 'mean', 'prefer_sess_length'), + ] + +item_feature_columns = [SparseFeat('doc_id', feature_max_idx['article_id'], embedding_dim)] +``` +这里需要注意的一个点是短期和长期序列的名字,必须严格的`short_, prefer_`进行标识,因为在模型搭建的时候就是靠着这个去找到短期和长期序列特征的。 + +逻辑其实也比较清晰,首先是建立Input层,然后是embedding层, 接下来,根据命名选择出用户的base特征列, 短期行为序列和长期行为序列。长期序列的话是过`AttentionPoolingLayer`层进行编码,这里本质上注意力然后融合,但这里注意的一个点就是for循环,也就是长期序列行为里面的特征列,比如商品,cat_1, cat_2是for循环的形式求融合向量,再拼接起来过DNN,和论文图保持一致。 + +短期序列编码部分,是`item_embedding,cat_1embedding, cat_2embedding`拼接起来,过`DynamicMultiRNN`层学习序列信息, 过`MultiHeadAttention`学习多兴趣,最后过`UserAttentionLayer`进行向量融合。 接下来,长期兴趣向量和短期兴趣向量以及用户base向量,过门控融合机制,得到最终的`user_embedding`。 + +而后面的那块是为了模型训练完之后,拿用户embedding和item embedding用的, 这个在MIND那篇文章里作了解释。 + +## 总结 +今天整理的是SDM,这也是一个标准的序列推荐召回模型,主要还是研究用户的序列,不过这篇paper里面一个有意思的点就是把用户的行为训练以会话的形式进行切分,然后再根据时间,分成了短期会话和长期会话,然后分别采用不同的策略去学习用户的短期兴趣和长期兴趣。 +* 对于短期会话,可能和当前预测相关性较大,所以首先用RNN来学习序列信息,然后采用多头注意力机制得到用户的多兴趣, 隐隐约约感觉多头注意力机制还真有种能聚类的功效,接下来就是和用户的base向量进行注意力融合得到短期兴趣 +* 长期会话序列中,每个side info信息进行分开,然后分别进行注意力编码融合得到 + +为了使得长期会话中对当前预测有用的部分得以体现,在融合短期兴趣和长期兴趣的时候,采用了门控的方式,而不是普通的拼接或者加和等操作,使得兴趣保留信息变得**有选择**。 + +这其实就是这篇paper的故事了,借鉴的地方首先是多头注意力机制也能学习到用户的多兴趣, 这样对于多兴趣,就有了胶囊网络与多头注意力机制两种思路。 而对于两个向量融合,这里又给我们提供了一种门控融合机制。 + + +**参考**: + +* SDM原论文 +* [AI上推荐 之 SDM模型(建模用户长短期兴趣的Match模型)](https://blog.csdn.net/wuzhongqiang/article/details/123856954?spm=1001.2014.3001.5501) +* [一文读懂Attention机制](https://zhuanlan.zhihu.com/p/129316415) +* [【推荐系统经典论文(十)】阿里SDM模型](https://zhuanlan.zhihu.com/p/137775247?from_voters_page=true) +* [SDM-深度序列召回模型](https://zhuanlan.zhihu.com/p/395673080) +* [推荐广告中的序列建模](https://blog.csdn.net/qq_41010971/article/details/123762312?spm=1001.2014.3001.5501) diff --git a/4.人工智能/ch02/ch2.1/ch2.1.5/TDM.md b/4.人工智能/ch02/ch2.1/ch2.1.5/TDM.md new file mode 100644 index 0000000..209928f --- /dev/null +++ b/4.人工智能/ch02/ch2.1/ch2.1.5/TDM.md @@ -0,0 +1,65 @@ +# 背景和目的 + +召回早前经历的第一代协同过滤技术,让模型可以在数量级巨大的item集中找到用户潜在想要看到的商品。这种方式有很明显的缺点,一个是对于用户而言,只能通过他历史行为去构建候选集,并且会基于算力的局限做截断。所以推荐结果的多样性和新颖性比较局限,导致推荐的有可能都是用户看过的或者买过的商品。之后在Facebook开源了FASSI库之后,基于内积模型的向量检索方案得到了广泛应用,也就是第二代召回技术。这种技术通过将用户和物品用向量表示,然后用内积的大小度量兴趣,借助向量索引实现大规模的全量检索。这里虽然改善了第一代的无法全局检索的缺点,然而这种模式下存在索引构建和模型优化目标不一致的问题,索引优化是基于向量的近似误差,而召回问题的目标是最大化topK召回率。且这类方法也不方便在用户和物品之间做特征组合。 + +所以阿里开发了一种可以承载各种深度模型来检索用户潜在兴趣的推荐算法解决方案。这个TDM模型是基于树结构,利用树结构对全量商品进行检索,将复杂度由O(N)下降到O(logN)。 + +# 模型结构 + +**树结构** + + ++ +如上图,树中的每一个叶子节点对应一个商品item,非叶子结点表示的是item的集合**(这里的树不限于二叉树)**。这种层次化结构体现了粒度从粗到细的item架构。 + +**整体结构** + ++
++ +# 算法详解 + +1. 基于树的高效检索 + + 算法通常采用beam-search的方法,根据用户对每层节点挑选出topK,将挑选出来的这几个topK节点的子节点作为下一层的候选集,最终会落到叶子节点上。 + 这么做的理论依据是当前层的最有优topK节点的父亲必然属于上次的父辈节点的最优topK: + $$ + p^{(j)}(n|u) = {{max \atop{n_{c}\in{\{n's children nodes in level j+1\}}}}p^{(j+1)}(n_{c}|u) \over {\alpha^{j}}} + $$ + 其中$p^{(j)}(n|u)$表示用户u对j层节点n感兴趣的概率,$\alpha^{j}$表示归一化因子。 + +2. 对兴趣进行建模 + ++
++ + 如上图,用户对叶子层item6感兴趣,可以认为它的兴趣是1,同层别的候选节点的兴趣为0,顺着着绿色线路上去的节点都标记为1,路线上的同层别的候选节点都标记为0。这样的操作就可以根据1和0构建用于每一层的正负样本。 + + 样本构建完成后,可以在模型结构左侧采用任意的深度学习模型来承担用户兴趣判别器的角色,输入就是当前层构造的正负样本,输出则是(用户,节点)对的兴趣度,这个将被用作检索过程中选取topK的评判指标。**在整体结构图中,我们可以看到节点特征方面,使用的是node embedding**,说明在进入模型前已经向量化了。 + +3. 训练过程 + ++
++ + 整体联合训练的方式如下: + + 1. 构造随机二叉树 + 2. 基于树模型生成样本 + 3. 训练DNN模型直到收敛 + 4. 基于DNN模型得到样本的Embedding,重新构造聚类二叉树 + 5. 循环上述2~4过程 + + 具体的,在初始化树结构的时候,首先借助商品的类别信息进行排序,将相同类别的商品放到一起,然后递归的将同类别中的商品等量的分到两个子类中,直到集合中只包含一项,利用这种自顶向下的方式来初始化一棵树。基于该树采样生成深度模型训练所需的样本,然后进一步训练模型,训练结束之后可以得到每个树节点对应的Embedding向量,利用节点的Embedding向量,采用K-Means聚类方法来重新构建一颗树,最后基于这颗新生成的树,重新训练深层网络。 + +**参考资料** + +- [阿里妈妈深度树检索技术(TDM) 及应用框架的探索实践](https://mp.weixin.qq.com/s/sw16_sUsyYuzpqqy39RsdQ) +- [阿里TDM:Tree-based Deep Model](https://zhuanlan.zhihu.com/p/78941783) +- [阿里妈妈TDM模型详解](https://zhuanlan.zhihu.com/p/93201318) +- [Paddle TDM 模型实现](https://github.com/PaddlePaddle/PaddleRec/blob/master/models/treebased/README.md) diff --git a/4.人工智能/ch02/ch2.2/ch2.2.1.md b/4.人工智能/ch02/ch2.2/ch2.2.1.md new file mode 100644 index 0000000..e43ab26 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.1.md @@ -0,0 +1,352 @@ +### GBDT+LR简介 + +前面介绍的协同过滤和矩阵分解存在的劣势就是仅利用了用户与物品相互行为信息进行推荐, 忽视了用户自身特征, 物品自身特征以及上下文信息等,导致生成的结果往往会比较片面。 而这次介绍的这个模型是2014年由Facebook提出的GBDT+LR模型, 该模型利用GBDT自动进行特征筛选和组合, 进而生成新的离散特征向量, 再把该特征向量当做LR模型的输入, 来产生最后的预测结果, 该模型能够综合利用用户、物品和上下文等多种不同的特征, 生成较为全面的推荐结果, 在CTR点击率预估场景下使用较为广泛。 + +下面首先会介绍逻辑回归和GBDT模型各自的原理及优缺点, 然后介绍GBDT+LR模型的工作原理和细节。 + +### 逻辑回归模型 + +逻辑回归模型非常重要, 在推荐领域里面, 相比于传统的协同过滤, 逻辑回归模型能够综合利用用户、物品、上下文等多种不同的特征生成较为“全面”的推荐结果, 关于逻辑回归的更多细节, 可以参考下面给出的链接,这里只介绍比较重要的一些细节和在推荐中的应用。 + +逻辑回归是在线性回归的基础上加了一个 Sigmoid 函数(非线形)映射,使得逻辑回归成为了一个优秀的分类算法, 学习逻辑回归模型, 首先应该记住一句话:**逻辑回归假设数据服从伯努利分布,通过极大化似然函数的方法,运用梯度下降来求解参数,来达到将数据二分类的目的。** + +相比于协同过滤和矩阵分解利用用户的物品“相似度”进行推荐, 逻辑回归模型将问题看成了一个分类问题, 通过预测正样本的概率对物品进行排序。这里的正样本可以是用户“点击”了某个商品或者“观看”了某个视频, 均是推荐系统希望用户产生的“正反馈”行为, 因此**逻辑回归模型将推荐问题转化成了一个点击率预估问题**。而点击率预测就是一个典型的二分类, 正好适合逻辑回归进行处理, 那么逻辑回归是如何做推荐的呢? 过程如下: +1. 将用户年龄、性别、物品属性、物品描述、当前时间、当前地点等特征转成数值型向量 +2. 确定逻辑回归的优化目标,比如把点击率预测转换成二分类问题, 这样就可以得到分类问题常用的损失作为目标, 训练模型 +3. 在预测的时候, 将特征向量输入模型产生预测, 得到用户“点击”物品的概率 +4. 利用点击概率对候选物品排序, 得到推荐列表 + +推断过程可以用下图来表示: + ++
++ +这里的关键就是每个特征的权重参数$w$, 我们一般是使用梯度下降的方式, 首先会先随机初始化参数$w$, 然后将特征向量(也就是我们上面数值化出来的特征)输入到模型, 就会通过计算得到模型的预测概率, 然后通过对目标函数求导得到每个$w$的梯度, 然后进行更新$w$ + +这里的目标函数长下面这样: + +$$ +J(w)=-\frac{1}{m}\left(\sum_{i=1}^{m}\left(y^{i} \log f_{w}\left(x^{i}\right)+\left(1-y^{i}\right) \log \left(1-f_{w}\left(x^{i}\right)\right)\right)\right. +$$ +求导之后的方式长这样: +$$ +w_{j} \leftarrow w_{j}-\gamma \frac{1}{m} \sum_{i=1}^{m}\left(f_{w}\left(x^{i}\right)-y^{i}\right) x_{j}^{i} +$$ +这样通过若干次迭代, 就可以得到最终的$w$了, 关于这些公式的推导,可以参考下面给出的文章链接, 下面我们分析一下逻辑回归模型的优缺点。 + +**优点:** +1. LR模型形式简单,可解释性好,从特征的权重可以看到不同的特征对最后结果的影响。 +2. 训练时便于并行化,在预测时只需要对特征进行线性加权,所以**性能比较好**,往往适合处理**海量id类特征**,用id类特征有一个很重要的好处,就是**防止信息损失**(相对于范化的 CTR 特征),对于头部资源会有更细致的描述 +3. 资源占用小,尤其是内存。在实际的工程应用中只需要存储权重比较大的特征及特征对应的权重。 +4. 方便输出结果调整。逻辑回归可以很方便的得到最后的分类结果,因为输出的是每个样本的概率分数,我们可以很容易的对这些概率分数进行cutoff,也就是划分阈值(大于某个阈值的是一类,小于某个阈值的是一类) + +**当然, 逻辑回归模型也有一定的局限性** +1. 表达能力不强, 无法进行特征交叉, 特征筛选等一系列“高级“操作(这些工作都得人工来干, 这样就需要一定的经验, 否则会走一些弯路), 因此可能造成信息的损失 +2. 准确率并不是很高。因为这毕竟是一个线性模型加了个sigmoid, 形式非常的简单(非常类似线性模型),很难去拟合数据的真实分布 +3. 处理非线性数据较麻烦。逻辑回归在不引入其他方法的情况下,只能处理线性可分的数据, 如果想处理非线性, 首先对连续特征的处理需要先进行**离散化**(离散化的目的是为了引入非线性),如上文所说,人工分桶的方式会引入多种问题。 +4. LR 需要进行**人工特征组合**,这就需要开发者有非常丰富的领域经验,才能不走弯路。这样的模型迁移起来比较困难,换一个领域又需要重新进行大量的特征工程。 + +所以如何**自动发现有效的特征、特征组合,弥补人工经验不足,缩短LR特征实验周期**,是亟需解决的问题, 而GBDT模型, 正好可以**自动发现特征并进行有效组合** + +### GBDT模型 + +GBDT全称梯度提升决策树,在传统机器学习算法里面是对真实分布拟合的最好的几种算法之一,在前几年深度学习还没有大行其道之前,gbdt在各种竞赛是大放异彩。原因大概有几个,一是效果确实挺不错。二是即可以用于分类也可以用于回归。三是可以筛选特征, 所以这个模型依然是一个非常重要的模型。 + +GBDT是通过采用加法模型(即基函数的线性组合),以及不断减小训练过程产生的误差来达到将数据分类或者回归的算法, 其训练过程如下: + ++
++gbdt通过多轮迭代, 每轮迭代会产生一个弱分类器, 每个分类器在上一轮分类器的残差基础上进行训练。 gbdt对弱分类器的要求一般是足够简单, 并且低方差高偏差。 因为训练的过程是通过降低偏差来不断提高最终分类器的精度。 由于上述高偏差和简单的要求,每个分类回归树的深度不会很深。最终的总分类器是将每轮训练得到的弱分类器加权求和得到的(也就是加法模型)。 + +关于GBDT的详细细节,依然是可以参考下面给出的链接。这里想分析一下GBDT如何来进行二分类的,因为我们要明确一点就是**gbdt 每轮的训练是在上一轮的训练的残差基础之上进行训练的**, 而这里的残差指的就是当前模型的负梯度值, 这个就要求每轮迭代的时候,弱分类器的输出的结果相减是有意义的, 而**gbdt 无论用于分类还是回归一直都是使用的CART 回归树**, 那么既然是回归树, 是如何进行二分类问题的呢? + +GBDT 来解决二分类问题和解决回归问题的本质是一样的,都是通过不断构建决策树的方式,使预测结果一步步的接近目标值, 但是二分类问题和回归问题的损失函数是不同的, 关于GBDT在回归问题上的树的生成过程, 损失函数和迭代原理可以参考给出的链接, 回归问题中一般使用的是平方损失, 而二分类问题中, GBDT和逻辑回归一样, 使用的下面这个: + +$$ +L=\arg \min \left[\sum_{i}^{n}-\left(y_{i} \log \left(p_{i}\right)+\left(1-y_{i}\right) \log \left(1-p_{i}\right)\right)\right] +$$ +其中, $y_i$是第$i$个样本的观测值, 取值要么是0要么是1, 而$p_i$是第$i$个样本的预测值, 取值是0-1之间的概率,由于我们知道GBDT拟合的残差是当前模型的负梯度, 那么我们就需要求出这个模型的导数, 即$\frac{dL}{dp_i}$, 对于某个特定的样本, 求导的话就可以只考虑它本身, 去掉加和号, 那么就变成了$\frac{dl}{dp_i}$, 其中$l$如下: +$$ +\begin{aligned} +l &=-y_{i} \log \left(p_{i}\right)-\left(1-y_{i}\right) \log \left(1-p_{i}\right) \\ +&=-y_{i} \log \left(p_{i}\right)-\log \left(1-p_{i}\right)+y_{i} \log \left(1-p_{i}\right) \\ +&=-y_{i}\left(\log \left(\frac{p_{i}}{1-p_{i}}\right)\right)-\log \left(1-p_{i}\right) +\end{aligned} +$$ +如果对逻辑回归非常熟悉的话, $\left(\log \left(\frac{p_{i}}{1-p_{i}}\right)\right)$一定不会陌生吧, 这就是对几率比取了个对数, 并且在逻辑回归里面这个式子会等于$\theta X$, 所以才推出了$p_i=\frac{1}{1+e^-{\theta X}}$的那个形式。 这里令$\eta_i=\frac{p_i}{1-p_i}$, 即$p_i=\frac{\eta_i}{1+\eta_i}$, 则上面这个式子变成了: + +$$ +\begin{aligned} +l &=-y_{i} \log \left(\eta_{i}\right)-\log \left(1-\frac{e^{\log \left(\eta_{i}\right)}}{1+e^{\log \left(\eta_{i}\right)}}\right) \\ +&=-y_{i} \log \left(\eta_{i}\right)-\log \left(\frac{1}{1+e^{\log \left(\eta_{i}\right)}}\right) \\ +&=-y_{i} \log \left(\eta_{i}\right)+\log \left(1+e^{\log \left(\eta_{i}\right)}\right) +\end{aligned} +$$ + 这时候,我们对$log(\eta_i)$求导, 得 +$$ +\frac{d l}{d \log (\eta_i)}=-y_{i}+\frac{e^{\log \left(\eta_{i}\right)}}{1+e^{\log \left(\eta_{i}\right)}}=-y_i+p_i +$$ +这样, 我们就得到了某个训练样本在当前模型的梯度值了, 那么残差就是$y_i-p_i$。GBDT二分类的这个思想,其实和逻辑回归的思想一样,**逻辑回归是用一个线性模型去拟合$P(y=1|x)$这个事件的对数几率$log\frac{p}{1-p}=\theta^Tx$**, GBDT二分类也是如此, 用一系列的梯度提升树去拟合这个对数几率, 其分类模型可以表达为: +$$ +P(Y=1 \mid x)=\frac{1}{1+e^{-F_{M}(x)}} +$$ + +下面我们具体来看GBDT的生成过程, 构建分类GBDT的步骤有两个: +1. 初始化GBDT + 和回归问题一样, 分类 GBDT 的初始状态也只有一个叶子节点,该节点为所有样本的初始预测值,如下: +$$ + F_{0}(x)=\arg \min _{\gamma} \sum_{i=1}^{n} L(y, \gamma) +$$ + 上式里面, $F$代表GBDT模型, $F_0$是模型的初识状态, 该式子的意思是找到一个$\gamma$,使所有样本的 Loss 最小,在这里及下文中,$\gamma$都表示节点的输出,即叶子节点, 且它是一个 $log(\eta_i)$ 形式的值(回归值),在初始状态,$\gamma =F_0$。 + + 下面看例子(该例子来自下面的第二个链接), 假设我们有下面3条样本: + ++
++ +我们希望构建 GBDT 分类树,它能通过「喜欢爆米花」、「年龄」和「颜色偏好」这 3 个特征来预测某一个样本是否喜欢看电影。我们把数据代入上面的公式中求Loss: +$$ +\operatorname{Loss}=L(1, \gamma)+L(1, \gamma)+L(0, \gamma) +$$ +为了令其最小, 我们求导, 且让导数为0, 则: +$$ +\operatorname{Loss}=p-1 + p-1+p=0 +$$ +于是, 就得到了初始值$p=\frac{2}{3}=0.67, \gamma=log(\frac{p}{1-p})=0.69$, 模型的初识状态$F_0(x)=0.69$ + +2. 循环生成决策树 + 这里回忆一下回归树的生成步骤, 其实有4小步, 第一就是计算负梯度值得到残差, 第二步是用回归树拟合残差, 第三步是计算叶子节点的输出值, 第四步是更新模型。 下面我们一一来看: + + 1. 计算负梯度得到残差 + $$ + r_{i m}=-\left[\frac{\partial L\left(y_{i}, F\left(x_{i}\right)\right)}{\partial F\left(x_{i}\right)}\right]_{F(x)=F_{m-1}(x)} + $$ + 此处使用$m-1$棵树的模型, 计算每个样本的残差$r_{im}$, 就是上面的$y_i-pi$, 于是例子中, 每个样本的残差: + ++
++ + 2. 使用回归树来拟合$r_{im}$, 这里的$i$表示样本哈,回归树的建立过程可以参考下面的链接文章,简单的说就是遍历每个特征, 每个特征下遍历每个取值, 计算分裂后两组数据的平方损失, 找到最小的那个划分节点。 假如我们产生的第2棵决策树如下: + ++
++ + 3. 对于每个叶子节点$j$, 计算最佳残差拟合值 + $$ + \gamma_{j m}=\arg \min _{\gamma} \sum_{x \in R_{i j}} L\left(y_{i}, F_{m-1}\left(x_{i}\right)+\gamma\right) + $$ + 意思是, 在刚构建的树$m$中, 找到每个节点$j$的输出$\gamma_{jm}$, 能使得该节点的loss最小。 那么我们看一下这个$\gamma$的求解方式, 这里非常的巧妙。 首先, 我们把损失函数写出来, 对于左边的第一个样本, 有 + $$ + L\left(y_{1}, F_{m-1}\left(x_{1}\right)+\gamma\right)=-y_{1}\left(F_{m-1}\left(x_{1}\right)+\gamma\right)+\log \left(1+e^{F_{m-1}\left(x_{1}\right)+\gamma}\right) + $$ + 这个式子就是上面推导的$l$, 因为我们要用回归树做分类, 所以这里把分类的预测概率转换成了对数几率回归的形式, 即$log(\eta_i)$, 这个就是模型的回归输出值。而如果求这个损失的最小值, 我们要求导, 解出令损失最小的$\gamma$。 但是上面这个式子求导会很麻烦, 所以这里介绍了一个技巧就是**使用二阶泰勒公式来近似表示该式, 再求导**, 还记得伟大的泰勒吗? + $$ + f(x+\Delta x) \approx f(x)+\Delta x f^{\prime}(x)+\frac{1}{2} \Delta x^{2} f^{\prime \prime}(x)+O(\Delta x) + $$ + 这里就相当于把$L(y_1, F_{m-1}(x_1))$当做常量$f(x)$, $\gamma$作为变量$\Delta x$, 将$f(x)$二阶展开: + $$ + L\left(y_{1}, F_{m-1}\left(x_{1}\right)+\gamma\right) \approx L\left(y_{1}, F_{m-1}\left(x_{1}\right)\right)+L^{\prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right) \gamma+\frac{1}{2} L^{\prime \prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right) \gamma^{2} + $$ + 这时候再求导就简单了 + $$ + \frac{d L}{d \gamma}=L^{\prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right)+L^{\prime \prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right) \gamma + $$ + Loss最小的时候, 上面的式子等于0, 就可以得到$\gamma$: + $$ + \gamma_{11}=\frac{-L^{\prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right)}{L^{\prime \prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right)} + $$ + **因为分子就是残差(上述已经求到了), 分母可以通过对残差求导,得到原损失函数的二阶导:** + $$ + \begin{aligned} + L^{\prime \prime}\left(y_{1}, F(x)\right) &=\frac{d L^{\prime}}{d \log (\eta_1)} \\ + &=\frac{d}{d \log (\eta_1)}\left[-y_{i}+\frac{e^{\log (\eta_1)}}{1+e^{\log (\eta_1)}}\right] \\ + &=\frac{d}{d \log (\eta_1)}\left[e^{\log (\eta_1)}\left(1+e^{\log (\eta_1)}\right)^{-1}\right] \\ + &=e^{\log (\eta_1)}\left(1+e^{\log (\eta_1)}\right)^{-1}-e^{2 \log (\eta_1)}\left(1+e^{\log (\eta_1)}\right)^{-2} \\ + &=\frac{e^{\log (\eta_1)}}{\left(1+e^{\log (\eta_1)}\right)^{2}} \\ + &=\frac{\eta_1}{(1+\eta_1)}\frac{1}{(1+\eta_1)} \\ + &=p_1(1-p_1) + \end{aligned} + $$ + 这时候, 就可以算出该节点的输出: + $$ + \gamma_{11}=\frac{r_{11}}{p_{10}\left(1-p_{10}\right)}=\frac{0.33}{0.67 \times 0.33}=1.49 + $$ + 这里的下面$\gamma_{jm}$表示第$m$棵树的第$j$个叶子节点。 接下来是右边节点的输出, 包含样本2和样本3, 同样使用二阶泰勒公式展开: + $$ + \begin{array}{l} + L\left(y_{2}, F_{m-1}\left(x_{2}\right)+\gamma\right)+L\left(y_{3}, F_{m-1}\left(x_{3}\right)+\gamma\right) \\ + \approx L\left(y_{2}, F_{m-1}\left(x_{2}\right)\right)+L^{\prime}\left(y_{2}, F_{m-1}\left(x_{2}\right)\right) \gamma+\frac{1}{2} L^{\prime \prime}\left(y_{2}, F_{m-1}\left(x_{2}\right)\right) \gamma^{2} \\ + +L\left(y_{3}, F_{m-1}\left(x_{3}\right)\right)+L^{\prime}\left(y_{3}, F_{m-1}\left(x_{3}\right)\right) \gamma+\frac{1}{2} L^{\prime \prime}\left(y_{3}, F_{m-1}\left(x_{3}\right)\right) \gamma^{2} + \end{array} + $$ + 求导, 令其结果为0,就会得到, 第1棵树的第2个叶子节点的输出: + $$ + \begin{aligned} + \gamma_{21} &=\frac{-L^{\prime}\left(y_{2}, F_{m-1}\left(x_{2}\right)\right)-L^{\prime}\left(y_{3}, F_{m-1}\left(x_{3}\right)\right)}{L^{\prime \prime}\left(y_{2}, F_{m-1}\left(x_{2}\right)\right)+L^{\prime \prime}\left(y_{3}, F_{m-1}\left(x_{3}\right)\right)} \\ + &=\frac{r_{21}+r_{31}}{p_{20}\left(1-p_{20}\right)+p_{30}\left(1-p_{30}\right)} \\ + &=\frac{0.33-0.67}{0.67 \times 0.33+0.67 \times 0.33} \\ + &=-0.77 + \end{aligned} + $$ + 可以看出, 对于任意叶子节点, 我们可以直接计算其输出值: + $$ + \gamma_{j m}=\frac{\sum_{i=1}^{R_{i j}} r_{i m}}{\sum_{i=1}^{R_{i j}} p_{i, m-1}\left(1-p_{i, m-1}\right)} + $$ + + 4. 更新模型$F_m(x)$ + $$ + F_{m}(x)=F_{m-1}(x)+\nu \sum_{j=1}^{J_{m}} \gamma_{m} + $$ + + 这样, 通过多次循环迭代, 就可以得到一个比较强的学习器$F_m(x)$ + ++
+ +**下面分析一下GBDT的优缺点:** + +我们可以把树的生成过程理解成**自动进行多维度的特征组合**的过程,从根结点到叶子节点上的整个路径(多个特征值判断),才能最终决定一棵树的预测值, 另外,对于**连续型特征**的处理,GBDT 可以拆分出一个临界阈值,比如大于 0.027 走左子树,小于等于 0.027(或者 default 值)走右子树,这样很好的规避了人工离散化的问题。这样就非常轻松的解决了逻辑回归那里**自动发现特征并进行有效组合**的问题, 这也是GBDT的优势所在。 + +但是GBDT也会有一些局限性, 对于**海量的 id 类特征**,GBDT 由于树的深度和棵树限制(防止过拟合),不能有效的存储;另外海量特征在也会存在性能瓶颈,当 GBDT 的 one hot 特征大于 10 万维时,就必须做分布式的训练才能保证不爆内存。所以 GBDT 通常配合少量的反馈 CTR 特征来表达,这样虽然具有一定的范化能力,但是同时会有**信息损失**,对于头部资源不能有效的表达。 + +所以, 我们发现其实**GBDT和LR的优缺点可以进行互补**。 + +### GBDT+LR模型 +2014年, Facebook提出了一种利用GBDT自动进行特征筛选和组合, 进而生成新的离散特征向量, 再把该特征向量当做LR模型的输入, 来产生最后的预测结果, 这就是著名的GBDT+LR模型了。GBDT+LR 使用最广泛的场景是CTR点击率预估,即预测当给用户推送的广告会不会被用户点击。 + +有了上面的铺垫, 这个模型解释起来就比较容易了, 模型的总体结构长下面这样: + +++**训练时**,GBDT 建树的过程相当于自动进行的特征组合和离散化,然后从根结点到叶子节点的这条路径就可以看成是不同特征进行的特征组合,用叶子节点可以唯一的表示这条路径,并作为一个离散特征传入 LR 进行**二次训练**。 + +比如上图中, 有两棵树,x为一条输入样本,遍历两棵树后,x样本分别落到两颗树的叶子节点上,每个叶子节点对应LR一维特征,那么通过遍历树,就得到了该样本对应的所有LR特征。构造的新特征向量是取值0/1的。 比如左树有三个叶子节点,右树有两个叶子节点,最终的特征即为五维的向量。对于输入x,假设他落在左树第二个节点,编码[0,1,0],落在右树第二个节点则编码[0,1],所以整体的编码为[0,1,0,0,1],这类编码作为特征,输入到线性分类模型(LR or FM)中进行分类。 + +**预测时**,会先走 GBDT 的每棵树,得到某个叶子节点对应的一个离散特征(即一组特征组合),然后把该特征以 one-hot 形式传入 LR 进行线性加权预测。 + +这个方案应该比较简单了, 下面有几个关键的点我们需要了解: +1. **通过GBDT进行特征组合之后得到的离散向量是和训练数据的原特征一块作为逻辑回归的输入, 而不仅仅全是这种离散特征** +2. 建树的时候用ensemble建树的原因就是一棵树的表达能力很弱,不足以表达多个有区分性的特征组合,多棵树的表达能力更强一些。GBDT每棵树都在学习前面棵树尚存的不足,迭代多少次就会生成多少棵树。 +3. RF也是多棵树,但从效果上有实践证明不如GBDT。且GBDT前面的树,特征分裂主要体现对多数样本有区分度的特征;后面的树,主要体现的是经过前N颗树,残差仍然较大的少数样本。优先选用在整体上有区分度的特征,再选用针对少数样本有区分度的特征,思路更加合理,这应该也是用GBDT的原因。 +4. 在CRT预估中, GBDT一般会建立两类树(非ID特征建一类, ID类特征建一类), AD,ID类特征在CTR预估中是非常重要的特征,直接将AD,ID作为feature进行建树不可行,故考虑为每个AD,ID建GBDT树。 + 1. 非ID类树:不以细粒度的ID建树,此类树作为base,即便曝光少的广告、广告主,仍可以通过此类树得到有区分性的特征、特征组合 + 2. ID类树:以细粒度 的ID建一类树,用于发现曝光充分的ID对应有区分性的特征、特征组合 + +### 编程实践 + +下面我们通过kaggle上的一个ctr预测的比赛来看一下GBDT+LR模型部分的编程实践, [数据来源](https://github.com/zhongqiangwu960812/AI-RecommenderSystem/tree/master/GBDT%2BLR/data) + +我们回顾一下上面的模型架构, 首先是要训练GBDT模型, GBDT的实现一般可以使用xgboost, 或者lightgbm。训练完了GBDT模型之后, 我们需要预测出每个样本落在了哪棵树上的哪个节点上, 然后通过one-hot就会得到一些新的离散特征, 这和原来的特征进行合并组成新的数据集, 然后作为逻辑回归的输入,最后通过逻辑回归模型得到结果。 + +根据上面的步骤, 我们看看代码如何实现: + +假设我们已经有了处理好的数据x_train, y_train。 + +1. **训练GBDT模型** + + GBDT模型的搭建我们可以通过XGBOOST, lightgbm等进行构建。比如: + + ```python + gbm = lgb.LGBMRegressor(objective='binary', + subsample= 0.8, + min_child_weight= 0.5, + colsample_bytree= 0.7, + num_leaves=100, + max_depth = 12, + learning_rate=0.05, + n_estimators=10, + ) + + gbm.fit(x_train, y_train, + eval_set = [(x_train, y_train), (x_val, y_val)], + eval_names = ['train', 'val'], + eval_metric = 'binary_logloss', + # early_stopping_rounds = 100, + ) + ``` + +2. **特征转换并构建新的数据集** + + 通过上面我们建立好了一个gbdt模型, 我们接下来要用它来预测出样本会落在每棵树的哪个叶子节点上, 为后面的离散特征构建做准备, 由于不是用gbdt预测结果而是预测训练数据在每棵树上的具体位置, 就需要用到下面的语句: + + ```python + model = gbm.booster_ # 获取到建立的树 + + # 每个样本落在每个树的位置 , 下面两个是矩阵 (样本个数, 树的棵树) , 每一个数字代表某个样本落在了某个数的哪个叶子节点 + gbdt_feats_train = model.predict(train, pred_leaf = True) + gbdt_feats_test = model.predict(test, pred_leaf = True) + + # 把上面的矩阵转成新的样本-特征的形式, 与原有的数据集合并 + gbdt_feats_name = ['gbdt_leaf_' + str(i) for i in range(gbdt_feats_train.shape[1])] + df_train_gbdt_feats = pd.DataFrame(gbdt_feats_train, columns = gbdt_feats_name) + df_test_gbdt_feats = pd.DataFrame(gbdt_feats_test, columns = gbdt_feats_name) + + # 构造新数据集 + train = pd.concat([train, df_train_gbdt_feats], axis = 1) + test = pd.concat([test, df_test_gbdt_feats], axis = 1) + train_len = train.shape[0] + data = pd.concat([train, test]) + ``` + +3. **离散特征的独热编码,并划分数据集** + + ```python + # 新数据的新特征进行读入编码 + for col in gbdt_feats_name: + onehot_feats = pd.get_dummies(data[col], prefix = col) + data.drop([col], axis = 1, inplace = True) + data = pd.concat([data, onehot_feats], axis = 1) + + # 划分数据集 + train = data[: train_len] + test = data[train_len:] + + x_train, x_val, y_train, y_val = train_test_split(train, target, test_size = 0.3, random_state = 2018) + ``` + +4. **训练逻辑回归模型作最后的预测** + + ```python + # 训练逻辑回归模型 + lr = LogisticRegression() + lr.fit(x_train, y_train) + tr_logloss = log_loss(y_train, lr.predict_proba(x_train)[:, 1]) + print('tr-logloss: ', tr_logloss) + val_logloss = log_loss(y_val, lr.predict_proba(x_val)[:, 1]) + print('val-logloss: ', val_logloss) + + # 预测 + y_pred = lr.predict_proba(test)[:, 1] + ``` + +上面我们就完成了GBDT+LR模型的基本训练步骤, 具体详细的代码可以参考链接。 + +### 思考 +1. **为什么使用集成的决策树? 为什么使用GBDT构建决策树而不是随机森林?** +2. **面对高维稀疏类特征的时候(比如ID类特征), 逻辑回归一般要比GBDT这种非线性模型好, 为什么?** + + +**参考资料** + +* 王喆 - 《深度学习推荐系统》 +* [决策树之 GBDT 算法 - 分类部分](https://www.jianshu.com/p/f5e5db6b29f2) +* [深入理解GBDT二分类算法](https://zhuanlan.zhihu.com/p/89549390?utm_source=zhihu) +* [逻辑回归、优化算法和正则化的幕后细节补充](https://blog.csdn.net/wuzhongqiang/article/details/108456051) +* [梯度提升树GBDT的理论学习与细节补充](https://blog.csdn.net/wuzhongqiang/article/details/108471107) +* [推荐系统遇上深度学习(十)--GBDT+LR融合方案实战](https://zhuanlan.zhihu.com/p/37522339) +* [CTR预估中GBDT与LR融合方案](https://blog.csdn.net/lilyth_lilyth/article/details/48032119) +* [GBDT+LR算法解析及Python实现](https://www.cnblogs.com/wkang/p/9657032.html) +* [常见计算广告点击率预估算法总结](https://zhuanlan.zhihu.com/p/29053940) +* [GBDT--分类篇](https://blog.csdn.net/On_theway10/article/details/83576715?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-5.channel_param&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-5.channel_param) + +**论文** + +* [http://quinonero.net/Publications/predicting-clicks-facebook.pdf 原论文](http://quinonero.net/Publications/predicting-clicks-facebook.pdf) +* [Predicting Clicks: Estimating the Click-Through Rate for New Ads](https://www.microsoft.com/en-us/research/publication/predicting-clicks-estimating-the-click-through-rate-for-new-ads/)\ +* [Greedy Fun tion Approximation : A Gradient Boosting](https://www.semanticscholar.org/paper/Greedy-Fun-tion-Approximation-%3A-A-Gradient-Boosting-Friedman/0d97ee4888506beb30a3f3b6552d88a9b0ca11f0?p2df) + diff --git a/4.人工智能/ch02/ch2.2/ch2.2.2/AutoInt.md b/4.人工智能/ch02/ch2.2/ch2.2.2/AutoInt.md new file mode 100644 index 0000000..d2882a1 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.2/AutoInt.md @@ -0,0 +1,277 @@ +## 写在前面 +AutoInt(Automatic Feature Interaction),这是2019年发表在CIKM上的文章,这里面提出的模型,重点也是在特征交互上,而所用到的结构,就是大名鼎鼎的transformer结构了,也就是通过多头的自注意力机制来显示的构造高阶特征,有效的提升了模型的效果。所以这个模型的提出动机比较简单,和xdeepFM这种其实是一样的,就是针对目前很多浅层模型无法学习高阶的交互, 而DNN模型能学习高阶交互,但确是隐性学习,缺乏可解释性,并不知道好不好使。而transformer的话,我们知道, 有着天然的全局意识,在NLP里面的话,各个词通过多头的自注意力机制,就能够使得各个词从不同的子空间中学习到与其它各个词的相关性,汇聚其它各个词的信息。 而放到推荐系统领域,同样也是这个道理,无非是把词换成了这里的离散特征而已, 而如果通过多个这样的交叉块堆积,就能学习到任意高阶的交互啦。这其实就是本篇文章的思想核心。 + +## AutoInt模型的理论及论文细节 +### 动机和原理 +这篇文章的前言部分依然是说目前模型的不足,以引出模型的动机所在, 简单的来讲,就是两句话: +1. 浅层的模型会受到交叉阶数的限制,没法完成高阶交叉 +2. 深层模型的DNN在学习高阶隐性交叉的效果并不是很好, 且不具有可解释性 + +于是乎: ++
++ +那么是如何做到的呢? 引入了transformer, 做成了一个特征交互层, 原理如下: ++
++ +### AutoInt模型的前向过程梳理 +下面看下AutoInt模型的结构了,并不是很复杂 ++
++ +#### Input Layer +输入层这里, 用到的特征主要是离散型特征和连续性特征, 这里不管是哪一类特征,都会过embedding层转成低维稠密的向量,是的, **连续性特征,这里并没有经过分桶离散化,而是直接走embedding**。这个是怎么做到的呢?就是就是类似于预训练时候的思路,先通过item_id把连续型特征与类别特征关联起来,最简单的,就是把item_id拿过来,过完embedding层取出对应的embedding之后,再乘上连续值即可, 所以这个连续值事先一定要是归一化的。 当然,这个玩法,我也是第一次见。 学习到了, 所以模型整体的输入如下: + +$$ +\mathbf{x}=\left[\mathbf{x}_{1} ; \mathbf{x}_{2} ; \ldots ; \mathbf{x}_{\mathbf{M}}\right] +$$ +这里的$M$表示特征的个数, $X_1, X_2$这是离散型特征, one-hot的形式, 而$X_M$在这里是连续性特征。过embedding层的细节应该是我上面说的那样。 +#### Embedding Layer +embedding层的作用是把高维稀疏的特征转成低维稠密, 离散型的特征一般是取出对应的embedding向量即可, 具体计算是这样: +$$ +\mathbf{e}_{\mathbf{i}}=\mathbf{V}_{\mathbf{i}} \mathbf{x}_{\mathbf{i}} +$$ +对于第$i$个离散特征,直接第$i$个嵌入矩阵$V_i$乘one-hot向量就取出了对应位置的embedding。 当然,如果输入的时候不是个one-hot, 而是个multi-hot的形式,那么对应的embedding输出是各个embedding求平均得到的。 + +$$ +\mathbf{e}_{\mathbf{i}}=\frac{1}{q} \mathbf{V}_{\mathbf{i}} \mathbf{x}_{\mathbf{i}} +$$ +比如, 推荐里面用户的历史行为item。过去点击了多个item,最终的输出就是这多个item的embedding求平均。 +而对于连续特征, 我上面说的那样, 也是过一个embedding矩阵取相应的embedding, 不过,最后要乘一个连续值 +$$ +\mathbf{e}_{\mathbf{m}}=\mathbf{v}_{\mathbf{m}} x_{m} +$$ +这样,不管是连续特征,离散特征还是变长的离散特征,经过embedding之后,都能得到等长的embedding向量。 我们把这个向量拼接到一块,就得到了交互层的输入。 + ++
++ +#### Interacting Layer +这个是本篇论文的核心了,其实这里说的就是transformer块的前向传播过程,所以这里我就直接用比较白话的语言简述过程了,不按照论文中的顺序展开了。 + +通过embedding层, 我们会得到M个向量$e_1, ...e_M$,假设向量的维度是$d$维, 那么这个就是一个$d\times M$的矩阵, 我们定一个符号$X$。 接下来我们基于这个矩阵$X$,做三次变换,也就是分别乘以三个矩阵$W_k^{(h)}, W_q^{(h)},W_v^{(h)}$, 这三个矩阵的维度是$d'\times d$的话, 那么我们就会得到三个结果: +$$Q^{(h)}=W_q^{(h)}\times X \\ K^{(h)} = W_k^{(h)} \times X \\ V^{(h)} = W_v^{(h)} \times X$$ +这三个矩阵都是$d'\times M$的。这其实就完成了一个Head的操作。所谓的自注意力, 就是$X$通过三次变换得到的结果之间,通过交互得到相关性,并通过相关性进行加权汇总,全是$X$自发的。 那么是怎么做到的呢?首先, 先进行这样的操作: +$$Score(Q^h,K^h)=Q^h \times {K^h}^T$$ +这个结果得到的是一个$d'\times d'$的矩阵, 那么这个操作到底是做了一个什么事情呢? + ++
++ +假设这里的$c_1..c_6$是我们的6个特征, 而每一行代表每个特征的embedding向量,这样两个矩阵相乘,相当于得到了当前特征与其它特征两两之间的內积值, 而內积可以表示两个向量之间的相似程度。所以得到的结果每一行,就代表当前这个特征与其它特征的相似性程度。 + +接下来,我们对$Score(Q^h,K^h)$, 在最后一个维度上进行softmax,就根据相似性得到了权重信息,这其实就是把相似性分数归一化到了0-1之间 + +$$Attention(Q^h,K^h)=Softmax(Score(Q^h,K^h))$$ +接下来, 我们再进行这样的一步操作 +$$E^{(h)}=Attention(Q^h,K^h) \times V$$ +这样就得到了$d'\times M$的矩阵$E$, 这步操作,其实就是一个加权汇总的过程, 对于每个特征, 先求与其它特征的相似度,然后得到一个权重,再回乘到各自的特征向量再求和。 只不过这里的特征是经过了一次线性变化的过程,降维到了$d'$。 + +上面是我从矩阵的角度又过了一遍, 这个是直接针对所有的特征向量一部到位。 论文里面的从单个特征的角度去描述的,只说了一个矩阵向量过多头注意力的操作。 +$$ +\begin{array}{c} +\alpha_{\mathbf{m}, \mathbf{k}}^{(\mathbf{h})}=\frac{\exp \left(\psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{\mathbf{k}}\right)\right)}{\sum_{l=1}^{M} \exp \left(\psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{1}\right)\right)} \\ +\psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{\mathbf{k}}\right)=\left\langle\mathbf{W}_{\text {Query }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{m}}, \mathbf{W}_{\text {Key }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{k}}\right\rangle +\end{array} \\ +\widetilde{\mathbf{e}}_{\mathrm{m}}^{(\mathbf{h})}=\sum_{k=1}^{M} \alpha_{\mathbf{m}, \mathbf{k}}^{(\mathbf{h})}\left(\mathbf{W}_{\text {Value }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{k}}\right) +$$ + +这里会更好懂一些, 就是相当于上面矩阵的每一行操作拆开了, 首先,整个拼接起来的embedding矩阵还是过三个参数矩阵得到$Q,K,V$, 然后是每一行单独操作的方式,对于某个特征向量$e_k$,与其它的特征两两內积得到权重,然后在softmax,回乘到对应向量,然后进行求和就得到了融合其它特征信息的新向量。 具体过程如图: + ++
++ +上面的过程是用了一个头,理解的话就类似于从一个角度去看特征之间的相关关系,用论文里面的话讲,这是从一个子空间去看, 如果是想从多个角度看,这里可以用多个头,即换不同的矩阵$W_q,W_k,W_v$得到不同的$Q,K,V$然后得到不同的$e_m$, 每个$e_m$是$d'\times 1$的。 + +然后,多个头的结果concat起来 +$$ +\widetilde{\mathbf{e}}_{\mathrm{m}}=\widetilde{\mathbf{e}}_{\mathrm{m}}^{(1)} \oplus \widetilde{\mathbf{e}}_{\mathrm{m}}^{(2)} \oplus \cdots \oplus \widetilde{\mathbf{e}}_{\mathbf{m}}^{(\mathbf{H})} +$$ +这是一个$d'\times H$的向量, 假设有$H$个头。 + +接下来, 过一个残差网络层,这是为了保留原始的特征信息 +$$ +\mathbf{e}_{\mathbf{m}}^{\mathrm{Res}}=\operatorname{ReL} U\left(\widetilde{\mathbf{e}}_{\mathbf{m}}+\mathbf{W}_{\text {Res }} \mathbf{e}_{\mathbf{m}}\right) +$$ +这里的$e_m$是$d\times 1$的向量, $W_{Res}$是$d'H\times d$的矩阵, 最后得到的$e_m^{Res}$是$d'H\times 1$的向量, 这是其中的一个特征,如果是$M$个特征堆叠的话,最终就是$d'HM\times 1$的矩阵, 这个就是Interacting Layer的结果输出。 +#### Output Layer +输出层就非常简单了,加一层全连接映射出输出值即可: +$$ +\hat{y}=\sigma\left(\mathbf{w}^{\mathrm{T}}\left(\mathbf{e}_{1}^{\mathbf{R e s}} \oplus \mathbf{e}_{2}^{\mathbf{R e s}} \oplus \cdots \oplus \mathbf{e}_{\mathbf{M}}^{\text {Res }}\right)+b\right) +$$ +这里的$W$是$d'HM\times 1$的, 这样最终得到的是一个概率值了, 接下来交叉熵损失更新模型参数即可。 + +AutoInt的前向传播过程梳理完毕。 + +### AutoInt的分析 +这里论文里面分析了为啥AutoInt能建模任意的高阶交互以及时间复杂度和空间复杂度的分析。我们一一来看。 + +关于建模任意的高阶交互, 我们这里拿一个transformer块看下, 对于一个transformer块, 我们发现特征之间完成了一个2阶的交互过程,得到的输出里面我们还保留着1阶的原始特征。 + +那么再经过一个transformer块呢? 这里面就会有2阶和1阶的交互了, 也就是会得到3阶的交互信息。而此时的输出,会保留着第一个transformer的输出信息特征。再过一个transformer块的话,就会用4阶的信息交互信息, 其实就相当于, 第$n$个transformer里面会建模出$n+1$阶交互来, 这个与CrossNet其实有异曲同工之妙的,无法是中间交互时的方式不一样。 前者是bit-wise级别的交互,而后者是vector-wise的交互。 + +所以, AutoInt是可以建模任意高阶特征的交互的,并且这种交互还是显性。 + +关于时间复杂度和空间复杂度,空间复杂度是$O(Ldd'H)$级别的, 这个也很好理解,看参数量即可, 3个W矩阵, H个head,再假设L个transformer块的话,参数量就达到这了。 时间复杂度的话是$O(MHd'(M+d))$的,论文说如果d和d'很小的话,其实这个模型不算复杂。 +### 3.4 更多细节 +这里整理下实验部分的细节,主要是对于一些超参的实验设置,在实验里面,作者首先指出了logloss下降多少算是有效呢? +>It is noticeable that a slightly higher AUC or lower Logloss at 0.001-level is regarded significant for CTR prediction task, which has also been pointed out in existing works + +这个和在fibinet中auc说的意思差不多。 + +在这一块,作者还写到了几个观点: +1. NFM use the deep neural network as a core component to learning high-order feature interactions, they do not guarantee improvement over FM and AFM. +2. AFM准确的说是二阶显性交互基础上加了交互重要性选择的操作, 这里应该是没有在上面加全连接 +3. xdeepFM这种CIN网络,在实际场景中非常难部署,不实用 +4. AutoInt的交互层2-3层差不多, embedding维度16-24 +5. 在AutoInt上面加2-3层的全连接会有点提升,但是提升效果并不是很大 + +所以感觉AutoInt这篇paper更大的价值,在于给了我们一种特征高阶显性交叉与特征选择性的思路,就是transformer在这里起的功效。所以后面用的时候, 更多的应该考虑如何用这种思路或者这个交互模块,而不是直接搬模型。 + +## AutoInt模型的简单复现及结构解释 +经过上面的分析, AutoInt模型的核心其实还是Transformer,所以代码部分呢? 主要还是Transformer的实现过程, 这个之前在整理DSIN的时候也整理过,由于Transformer特别重要,所以这里再重新复习一遍, 依然是基于Deepctr,写成一个简版的形式。 + +```python +def AutoInt(linear_feature_columns, dnn_feature_columns, att_layer_num=3, att_embedding_size=8, att_head_num=2, att_res=True): + """ + :param att_layer_num: transformer块的数量,一个transformer块里面是自注意力计算 + 残差计算 + :param att_embedding_size: 文章里面的d', 自注意力时候的att的维度 + :param att_head_num: 头的数量或者自注意力子空间的数量 + :param att_res: 是否使用残差网络 + """ + # 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型 + dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns+dnn_feature_columns) + + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入预Input层对应,是通过模型输入时候的字典数据的key与对应name的Input层 + input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values()) + + # 线性部分的计算逻辑 -- linear + linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns) + + # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型 + # 线性层和dnn层统一的embedding层 + embedding_layer_dict = build_embedding_layers(linear_feature_columns+dnn_feature_columns, sparse_input_dict, is_linear=False) + + # 构造self-att的输入 + att_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False) + att_input = Concatenate(axis=1)(att_sparse_kd_embed) # (None, field_num, embed_num) + + # 下面的循环,就是transformer的前向传播,多个transformer块的计算逻辑 + for _ in range(att_layer_num): + att_input = InteractingLayer(att_embedding_size, att_head_num, att_res)(att_input) + att_output = Flatten()(att_input) + att_logits = Dense(1)(att_output) + + # DNN侧的计算逻辑 -- Deep + # 将dnn_feature_columns里面的连续特征筛选出来,并把相应的Input层拼接到一块 + dnn_dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns)) if dnn_feature_columns else [] + dnn_dense_feature_columns = [fc.name for fc in dnn_dense_feature_columns] + dnn_concat_dense_inputs = Concatenate(axis=1)([dense_input_dict[col] for col in dnn_dense_feature_columns]) + + # 将dnn_feature_columns里面的离散特征筛选出来,相应的embedding层拼接到一块 + dnn_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=True) + dnn_concat_sparse_kd_embed = Concatenate(axis=1)(dnn_sparse_kd_embed) + + # DNN层的输入和输出 + dnn_input = Concatenate(axis=1)([dnn_concat_dense_inputs, dnn_concat_sparse_kd_embed, att_output]) + dnn_out = get_dnn_output(dnn_input) + dnn_logits = Dense(1)(dnn_out) + + # 三边的结果stack + stack_output = Add()([linear_logits, dnn_logits]) + + # 输出层 + output_layer = Dense(1, activation='sigmoid')(stack_output) + + model = Model(input_layers, output_layer) + + return model +``` +这里由于大部分都是之前见过的模块,唯一改变的地方,就是加了一个`InteractingLayer`, 这个是一个transformer块,在这里面实现特征交互。而这个的结果输出,最终和DNN的输出结合到一起了。 而这个层,主要就是一个transformer块的前向传播过程。这应该算是最简单的一个版本了: + +```python +class InteractingLayer(Layer): + """A layer user in AutoInt that model the correction between different feature fields by multi-head self-att mechanism + input: 3维张量, (none, field_num, embedding_size) + output: 3维张量, (none, field_num, att_embedding_size * head_num) + """ + def __init__(self, att_embedding_size=8, head_num=2, use_res=True, seed=2021): + super(InteractingLayer, self).__init__() + self.att_embedding_size = att_embedding_size + self.head_num = head_num + self.use_res = use_res + self.seed = seed + + + def build(self, input_shape): + embedding_size = int(input_shape[-1]) + + # 定义三个矩阵Wq, Wk, Wv + self.W_query = self.add_weight(name="query", shape=[embedding_size, self.att_embedding_size * self.head_num], + dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed)) + self.W_key = self.add_weight(name="key", shape=[embedding_size, self.att_embedding_size * self.head_num], + dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+1)) + self.W_value = self.add_weight(name="value", shape=[embedding_size, self.att_embedding_size * self.head_num], + dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+2)) + + if self.use_res: + self.W_res = self.add_weight(name="res", shape=[embedding_size, self.att_embedding_size * self.head_num], + dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+3)) + + super(InteractingLayer, self).build(input_shape) + + def call(self, inputs): + # inputs (none, field_nums, embed_num) + + querys = tf.tensordot(inputs, self.W_query, axes=(-1, 0)) # (None, field_nums, att_emb_size*head_num) + keys = tf.tensordot(inputs, self.W_key, axes=(-1, 0)) + values = tf.tensordot(inputs, self.W_value, axes=(-1, 0)) + + # 多头注意力计算 按照头分开 (head_num, None, field_nums, att_embed_size) + querys = tf.stack(tf.split(querys, self.head_num, axis=2)) + keys = tf.stack(tf.split(keys, self.head_num, axis=2)) + values = tf.stack(tf.split(values, self.head_num, axis=2)) + + # Q * K, key的后两维转置,然后再矩阵乘法 + inner_product = tf.matmul(querys, keys, transpose_b=True) # (head_num, None, field_nums, field_nums) + normal_att_scores = tf.nn.softmax(inner_product, axis=-1) + + result = tf.matmul(normal_att_scores, values) # (head_num, None, field_nums, att_embed_size) + result = tf.concat(tf.split(result, self.head_num, ), axis=-1) # (1, None, field_nums, att_emb_size*head_num) + result = tf.squeeze(result, axis=0) # (None, field_num, att_emb_size*head_num) + + if self.use_res: + result += tf.tensordot(inputs, self.W_res, axes=(-1, 0)) + + result = tf.nn.relu(result) + + return result +``` +这就是一个Transformer块做的事情,这里只说两个小细节: +* 第一个是参数初始化那个地方, 后面的seed一定要指明出参数来,我第一次写的时候, 没有用seed=,结果导致训练有问题。 +* 第二个就是这里自注意力机制计算的时候,这里的多头计算处理方式, **把多个头分开,采用堆叠的方式进行计算(堆叠到第一个维度上去了)**。只有这样才能使得每个头与每个头之间的自注意力运算是独立不影响的。如果不这么做的话,最后得到的结果会含有当前单词在这个头和另一个单词在另一个头上的关联,这是不合理的。 + +OK, 这就是AutoInt比较核心的部分了,当然,上面自注意部分的输出结果与DNN或者Wide部分结合也不一定非得这么一种形式,也可以灵活多变,具体得结合着场景来。详细代码依然是看后面的GitHub啦。 + +## 总结 +这篇文章整理了AutoInt模型,这个模型的重点是引入了transformer来实现特征之间的高阶显性交互, 而transformer的魅力就是多头的注意力机制,相当于在多个子空间中, 根据不同的相关性策略去让特征交互然后融合,在这个交互过程中,特征之间计算相关性得到权重,并加权汇总,使得最终每个特征上都有了其它特征的信息,且其它特征的信息重要性还有了权重标识。 这个过程的自注意力计算以及汇总是一个自动的过程,这是很powerful的。 + +所以这篇文章的重要意义是又给我们传授了一个特征交互时候的新思路,就是transformer的多头注意力机制。 + +在整理transformer交互层的时候, 这里忽然想起了和一个同学的讨论, 顺便记在这里吧,就是: +> 自注意力里面的Q,K能用一个吗? 也就是类似于只用Q, 算注意力的时候,直接$QQ^T$, 得到的矩阵维度和原来的是一样的,并且在参数量上,由于去掉了$w_k$矩阵, 也会有所减少。 + +关于这个问题, 我目前没有尝试用同一个的效果,但总感觉是违背了当时设计自注意力的初衷,最直接的一个结论,就是这里如果直接$QQ^T$,那么得到的注意力矩阵是一个对称的矩阵, 这在汇总信息的时候可能会出现问题。 因为这基于了一个假设就是A特征对于B特征的重要性,和B特征对于A的重要性是一致的, 这个显然是不太符合常规的。 比如"学历"这个特征和"职业"这个特征, 对于计算机行业,高中生和研究生或许都可以做, 但是对于金融类的行业, 对学历就有着很高的要求。 这就说明对于职业这个特征, 学历特征对其影响很大。 而如果是看学历的话,研究生学历或许可以入计算机,也可以入金融, 可能职业特征对学历的影响就不是那么明显。 也就是学历对于职业的重要性可能会比职业对于学历的重要性要大。 所以我感觉直接用同一个矩阵,在表达能力上会受到限制。当然,是自己的看法哈, 这个问题也欢迎一块讨论呀! + + +**参考资料**: +* [AutoInt: Automatic Feature Interaction Learning via Self-Attentive Neural Networks](https://link.zhihu.com/?target=https%3A//arxiv.org/abs/1810.11921) +* [AutoInt:基于Multi-Head Self-Attention构造高阶特征](https://zhuanlan.zhihu.com/p/60185134) diff --git a/4.人工智能/ch02/ch2.2/ch2.2.2/DCN.md b/4.人工智能/ch02/ch2.2/ch2.2.2/DCN.md new file mode 100644 index 0000000..41c2e97 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.2/DCN.md @@ -0,0 +1,155 @@ +# DCN +## 动机 +Wide&Deep模型的提出不仅综合了“记忆能力”和“泛化能力”, 而且开启了不同网络结构融合的新思路。 所以后面就有各式各样的模型改进Wide部分或者Deep部分, 而Deep&Cross模型(DCN)就是其中比较典型的一个,这是2017年斯坦福大学和谷歌的研究人员在ADKDD会议上提出的, 该模型针对W&D的wide部分进行了改进, 因为Wide部分有一个不足就是需要人工进行特征的组合筛选, 过程繁琐且需要经验, 而2阶的FM模型在线性的时间复杂度中自动进行特征交互,但是这些特征交互的表现能力并不够,并且随着阶数的上升,模型复杂度会大幅度提高。于是乎,作者用一个Cross Network替换掉了Wide部分,来自动进行特征之间的交叉,并且网络的时间和空间复杂度都是线性的。 通过与Deep部分相结合,构成了深度交叉网络(Deep & Cross Network),简称DCN。 + +## 模型结构及原理 + +这个模型的结构是这个样子的: ++
++ +这个模型的结构也是比较简洁的, 从下到上依次为:Embedding和Stacking层, Cross网络层与Deep网络层并列, 以及最后的输出层。下面也是一一为大家剖析。 + +### Embedding和Stacking 层 + +Embedding层我们已经非常的熟悉了吧, 这里的作用依然是把稀疏离散的类别型特征变成低维密集型。 +$$ +\mathbf{x}_{\text {embed, } i}=W_{\text {embed, } i} \mathbf{x}_{i} +$$ +其中对于某一类稀疏分类特征(如id),$X_{embed, i}$是第个$i$分类值(id序号)的embedding向量。$W_{embed,i}$是embedding矩阵, $n_e\times n_v$维度, $n_e$是embedding维度, $n_v$是该类特征的唯一取值个数。$x_i$属于该特征的二元稀疏向量(one-hot)编码的。 【实质上就是在训练得到的Embedding参数矩阵中找到属于当前样本对应的Embedding向量】。其实绝大多数基于深度学习的推荐模型都需要Embedding操作,参数学习是通过神经网络进行训练。 + +最后,该层需要将所有的密集型特征与通过embedding转换后的特征进行联合(Stacking): +$$ +\mathbf{x}_{0}=\left[\mathbf{x}_{\text {embed, } 1}^{T}, \ldots, \mathbf{x}_{\text {embed, }, k}^{T}, \mathbf{x}_{\text {dense }}^{T}\right] +$$ +一共$k$个类别特征, dense是数值型特征, 两者在特征维度拼在一块。 上面的这两个操作如果是看了前面的模型的话,应该非常容易理解了。 + +### Cross Network + +这个就是本模型最大的亮点了【Cross网络】, 这个思路感觉非常Nice。设计该网络的目的是增加特征之间的交互力度。交叉网络由多个交叉层组成, 假设第$l$层的输出向量$x_l$, 那么对于第$l+1$层的输出向量$x_{l+1}$表示为: + +$$ +\mathbf{x}_{l+1}=\mathbf{x}_{0} \mathbf{x}_{l}^{T} \mathbf{w}_{l}+\mathbf{b}_{l}+\mathbf{x}_{l}=f\left(\mathbf{x}_{l}, \mathbf{w}_{l}, \mathbf{b}_{l}\right)+\mathbf{x}_{l} +$$ +可以看到, 交叉层的二阶部分非常类似PNN提到的外积操作, 在此基础上增加了外积操作的权重向量$w_l$, 以及原输入向量$x_l$和偏置向量$b_l$。 交叉层的可视化如下: + ++
+ +可以看到, 每一层增加了一个$n$维的权重向量$w_l$(n表示输入向量维度), 并且在每一层均保留了输入向量, 因此输入和输出之间的变化不会特别明显。关于这一层, 原论文里面有个具体的证明推导Cross Network为啥有效, 不过比较复杂,这里我拿一个式子简单的解释下上面这个公式的伟大之处: + +> **我们根据上面这个公式, 尝试的写前面几层看看:** +> +> $l=0:\mathbf{x}_{1} =\mathbf{x}_{0} \mathbf{x}_{0}^{T} \mathbf{w}_{0}+ \mathbf{b}_{0}+\mathbf{x}_{0}$ +> +> $l=1:\mathbf{x}_{2} =\mathbf{x}_{0} \mathbf{x}_{1}^{T} \mathbf{w}_{1}+ \mathbf{b}_{1}+\mathbf{x}_{1}=\mathbf{x}_{0} [\mathbf{x}_{0} \mathbf{x}_{0}^{T} \mathbf{w}_{0}+ \mathbf{b}_{0}+\mathbf{x}_{0}]^{T}\mathbf{w}_{1}+\mathbf{b}_{1}+\mathbf{x}_{1}$ +> +> $l=2:\mathbf{x}_{3} =\mathbf{x}_{0} \mathbf{x}_{2}^{T} \mathbf{w}_{2}+ \mathbf{b}_{2}+\mathbf{x}_{2}=\mathbf{x}_{0} [\mathbf{x}_{0} [\mathbf{x}_{0} \mathbf{x}_{0}^{T} \mathbf{w}_{0}+ \mathbf{b}_{0}+\mathbf{x}_{0}]^{T}\mathbf{w}_{1}+\mathbf{b}_{1}+\mathbf{x}_{1}]^{T}\mathbf{w}_{2}+\mathbf{b}_{2}+\mathbf{x}_{2}$ + +我们暂且写到第3层的计算, 我们会发现什么结论呢? 给大家总结一下: + +1. $\mathrm{x}_1$中包含了所有的$\mathrm{x}_0$的1,2阶特征的交互, $\mathrm{x}_2$包含了所有的$\mathrm{x}_1, \mathrm{x}_0$的1、2、3阶特征的交互,$\mathrm{x}_3$中包含了所有的$\mathrm{x}_2$, $\mathrm{x}_1$与$\mathrm{x}_0$的交互,$\mathrm{x}_0$的1、2、3、4阶特征交互。 因此, 交叉网络层的叉乘阶数是有限的。 **第$l$层特征对应的最高的叉乘阶数$l+1$** + +2. Cross网络的参数是共享的, 每一层的这个权重特征之间共享, 这个可以使得模型泛化到看不见的特征交互作用, 并且对噪声更具有鲁棒性。 例如两个稀疏的特征$x_i,x_j$, 它们在数据中几乎不发生交互, 那么学习$x_i,x_j$的权重对于预测没有任何的意义。 + +3. 计算交叉网络的参数数量。 假设交叉层的数量是$L_c$, 特征$x$的维度是$n$, 那么总共的参数是: + + $$ + n\times L_c \times 2 + $$ + 这个就是每一层会有$w$和$b$。且$w$维度和$x$的维度是一致的。 + +4. 交叉网络的时间和空间复杂度是线性的。这是因为, 每一层都只有$w$和$b$, 没有激活函数的存在,相对于深度学习网络, 交叉网络的复杂性可以忽略不计。 + +5. Cross网络是FM的泛化形式, 在FM模型中, 特征$x_i$的权重$v_i$, 那么交叉项$x_i,x_j$的权重为$+
$。在DCN中, $x_i$的权重为${W_K^{(i)}}_{k=1}^l$, 交叉项$x_i,x_j$的权重是参数${W_K^{(i)}}_{k=1}^l$和${W_K^{(j)}}_{k=1}^l$的乘积,这个看上面那个例子展开感受下。因此两个模型都各自学习了独立于其他特征的一些参数,并且交叉项的权重是相应参数的某种组合。FM只局限于2阶的特征交叉(一般),而DCN可以构建更高阶的特征交互, 阶数由网络深度决定,并且交叉网络的参数只依据输入的维度线性增长。 + +6. 还有一点我们也要了解,对于每一层的计算中, 都会跟着$\mathrm{x}_0$, 这个是咱们的原始输入, 之所以会乘以一个这个,是为了保证后面不管怎么交叉,都不能偏离我们的原始输入太远,别最后交叉交叉都跑偏了。 + +7. $\mathbf{x}_{l+1}=f\left(\mathbf{x}_{l}, \mathbf{w}_{l}, \mathbf{b}_{l}\right)+\mathbf{x}_{l}$, 这个东西其实有点跳远连接的意思,也就是和ResNet也有点相似,无形之中还能有效的缓解梯度消失现象。 + +好了, 关于本模型的交叉网络的细节就介绍到这里了。这应该也是本模型的精华之处了,后面就简单了。 + +### Deep Network + +这个就和上面的D&W的全连接层原理一样。这里不再过多的赘述。 +$$ +\mathbf{h}_{l+1}=f\left(W_{l} \mathbf{h}_{l}+\mathbf{b}_{l}\right) +$$ +具体的可以参考W&D模型。 + +### 组合输出层 + +这个层负责将两个网络的输出进行拼接, 并且通过简单的Logistics回归完成最后的预测: +$$ +p=\sigma\left(\left[\mathbf{x}_{L_{1}}^{T}, \mathbf{h}_{L_{2}}^{T}\right] \mathbf{w}_{\text {logits }}\right) +$$ +其中$\mathbf{x}_{L_{1}}^{T}$和$\mathbf{h}_{L_{2}}^{T}$分别表示交叉网络和深度网络的输出。 +最后二分类的损失函数依然是交叉熵损失: +$$ +\text { loss }=-\frac{1}{N} \sum_{i=1}^{N} y_{i} \log \left(p_{i}\right)+\left(1-y_{i}\right) \log \left(1-p_{i}\right)+\lambda \sum_{l}\left\|\mathbf{w}_{i}\right\|^{2} +$$ + +Cross&Deep模型的原理就是这些了,其核心部分就是Cross Network, 这个可以进行特征的自动交叉, 避免了更多基于业务理解的人工特征组合。 该模型相比于W&D,Cross部分表达能力更强, 使得模型具备了更强的非线性学习能力。 + +## 代码实现 + +下面我们看下DCN的代码复现,这里主要是给大家说一下这个模型的设计逻辑,参考了deepctr的函数API的编程风格, 具体的代码以及示例大家可以去参考后面的GitHub,里面已经给出了详细的注释, 这里主要分析模型的逻辑这块。关于函数API的编程式风格,我们还给出了一份文档, 大家可以先看这个,再看后面的代码部分,会更加舒服些。 + +从上面的结构图我们也可以看出, DCN的模型搭建,其实主要分为几大模块, 首先就是建立输入层,用到的函数式`build_input_layers`,有了输入层之后, 我们接下来是embedding层的搭建,用到的函数是`build_embedding_layers`, 这个层的作用是接收离散特征,变成低维稠密。 接下来就是把连续特征和embedding之后的离散特征进行拼接,分别进入wide端和deep端。 wide端就是交叉网络,而deep端是DNN网络, 这里分别是`CrossNet()`和`get_dnn_output()`, 接下来就是把这两块的输出拼接得到最后的输出了。所以整体代码如下: + +```python +def DCN(linear_feature_columns, dnn_feature_columns): + # 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型 + dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns) + + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层 + input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values()) + + # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型 + embedding_layer_dict = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False) + + concat_dense_inputs = Concatenate(axis=1)(list(dense_input_dict.values())) + + # 将特征中的sparse特征筛选出来 + sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns)) if linear_feature_columns else [] + + sparse_kd_embed = concat_embedding_list(sparse_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=True) + + concat_sparse_kd_embed = Concatenate(axis=1)(sparse_kd_embed) + + dnn_input = Concatenate(axis=1)([concat_dense_inputs, concat_sparse_kd_embed]) + + dnn_output = get_dnn_output(dnn_input) + + cross_output = CrossNet()(dnn_input) + + # stack layer + stack_output = Concatenate(axis=1)([dnn_output, cross_output]) + + # 这里的激活函数使用sigmoid + output_layer = Dense(1, activation='sigmoid')(stack_output) + + model = Model(input_layers, output_layer) + return model +``` + +这个模型的实现过程和DeepFM比较类似,这里不画草图了,如果想看的可以去参考DeepFM草图及代码之间的对应关系。 + +下面是一个通过keras画的模型结构图,为了更好的显示,类别特征都只是选择了一小部分,画图的代码也在github中。 + + + +## 思考 + +1. 请计算Cross Network的复杂度,需要的变量请自己定义。 +2. 在实现矩阵计算$x_0*x_l^Tw$的过程中,有人说要先算前两个,有人说要先算后两个,请问那种方式更好?为什么? + +**参考资料** +* 《深度学习推荐系统》 --- 王喆 +* [Deep&Cross模型原论文](https://arxiv.org/abs/1708.05123) +* [AI上推荐 之 Wide&Deep与Deep&Cross模型(记忆与泛化并存的华丽转身)]() +* [Wide&Deep模型的进阶---Cross&Deep模型](https://mp.weixin.qq.com/s/DkoaMaXhlgQv1NhZHF-7og) + diff --git a/4.人工智能/ch02/ch2.2/ch2.2.2/FM.md b/4.人工智能/ch02/ch2.2/ch2.2.2/FM.md new file mode 100644 index 0000000..44a713f --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.2/FM.md @@ -0,0 +1,147 @@ +### FM模型的引入 + +#### 逻辑回归模型及其缺点 + +FM模型其实是一种思路,具体的应用稍少。一般来说做推荐CTR预估时最简单的思路就是将特征做线性组合(逻辑回归LR),传入sigmoid中得到一个概率值,本质上这就是一个线性模型,因为sigmoid是单调增函数不会改变里面的线性模型的CTR预测顺序,因此逻辑回归模型效果会比较差。也就是LR的缺点有: + +* 是一个线性模型 +* 每个特征对最终输出结果独立,需要手动特征交叉($x_i*x_j$),比较麻烦 + ++
+ +#### 二阶交叉项的考虑及改进 + +由于LR模型的上述缺陷(主要是手动做特征交叉比较麻烦),干脆就考虑所有的二阶交叉项,也就是将目标函数由原来的 + +$$ +y = w_0+\sum_{i=1}^nw_ix_i +$$ +变为 + +$$ +y = w_0+\sum_{i=1}^nw_ix_i+\sum_{i=1}^{n-1}\sum_{i+1}^nw_{ij}x_ix_j +$$ +但这个式子有一个问题,**只有当$x_i$与$x_j$均不为0时这个二阶交叉项才会生效**,后面这个特征交叉项本质是和多项式核SVM等价的,为了解决这个问题,我们的FM登场了! + +FM模型使用了如下的优化函数: + +$$ +y = w_0+\sum_{i=1}^nw_ix_i+\sum_{i=1}^{n}\sum_{i+1}^n\lt v_i,v_j\gt x_ix_j +$$ +事实上做的唯一改动就是把$w_{ij}$替换成了$\lt v_i,v_j\gt$,大家应该就看出来了,这实际上就有深度学习的意味在里面了,实质上就是给每个$x_i$计算一个embedding,然后将两个向量之间的embedding做内积得到之前所谓的$w_{ij}$好处就是这个模型泛化能力强 ,即使两个特征之前从未在训练集中**同时**出现,我们也不至于像之前一样训练不出$w_{ij}$,事实上只需要$x_i$和其他的$x_k$同时出现过就可以计算出$x_i$的embedding! + +
+ +### FM公式的理解 + +从公式来看,模型前半部分就是普通的LR线性组合,后半部分的交叉项:特征组合。首先,单从模型表达能力上来看,FM是要强于LR的,至少它不会比LR弱,当交叉项参数$w_{ij}$全为0的时候,整个模型就退化为普通的LR模型。对于有$n$个特征的模型,特征组合的参数数量共有$1+2+3+\cdots + n-1=\frac{n(n-1)}{2}$个,并且任意两个参数之间是独立的。所以说特征数量比较多的时候,特征组合之后,维度自然而然就高了。 + +> 定理:任意一个实对称矩阵(正定矩阵)$W$都存在一个矩阵$V$,使得 $W=V.V^{T}$成立。 + +类似地,所有二次项参数$\omega_{ij}$可以组成一个对称阵$W$(为了方便说明FM的由来,对角元素可以设置为正实数),那么这个矩阵就可以分解为$W=V^TV$,$V$ 的第$j$列($v_{j}$)便是第$j$维特征($x_{j}$)的隐向量。 + +$$ +\hat{y}(X) = \omega_{0}+\sum_{i=1}^{n}{\omega_{i}x_{i}}+\sum_{i=1}^{n-1}{\sum_{j=i+1}^{n} \color{red}{x_{i}x_{j}}} +$$ + +需要估计的参数有$\omega_{0}∈ R$,$\omega_{i}∈ R$,$V∈ R$,$< \cdot, \cdot>$是长度为$k$的两个向量的点乘,公式如下: + +$$ + = \sum_{f=1}^{k}{v_{i,f}\cdot v_{j,f}} +$$ + +上面的公式中: + +- $\omega_{0}$为全局偏置; +- $\omega_{i}$是模型第$i$个变量的权重; +- $\omega_{ij} = < v_{i}, v_{j}>$特征$i$和$j$的交叉权重; +- $v_{i} $是第$i$维特征的隐向量; +- $<\cdot, \cdot>$代表向量点积; +- $k(k< x_ix_j}} +&= \frac{1}{2}\sum_{i=1}^{n}{\sum_{j=1}^{n}{ x_ix_j}} - \frac{1}{2} {\sum_{i=1}^{n}{ x_ix_i}} \\ +&= \frac{1}{2} \left( \sum_{i=1}^{n}{\sum_{j=1}^{n}{\sum_{f=1}^{k}{v_{i,f}v_{j,f}x_ix_j}}} - \sum_{i=1}^{n}{\sum_{f=1}^{k}{v_{i,f}v_{i,f}x_ix_i}} \right) \\ +&= \frac{1}{2}\sum_{f=1}^{k}{\left[ \left( \sum_{i=1}^{n}{v_{i,f}x_i} \right) \cdot \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} +$$ +**解释**: + +- $v_{i,f}$ 是一个具体的值; +- 第1个等号:对称矩阵 $W$ 对角线上半部分; +- 第2个等号:把向量内积 $v_{i}$,$v_{j}$ 展开成累加和的形式; +- 第3个等号:提出公共部分; +- 第4个等号: $i$ 和 $j$ 相当于是一样的,表示成平方过程。 + +
+ +### FM优缺点 + +**优点** +1. 通过向量内积作为交叉特征的权重,可以在数据非常稀疏的情况下还能有效的训练处交叉特征的权重(因为不需要两个特征同时不为零) +2. 可以通过公式上的优化,得到O(nk)的计算复杂度,k一般比较小,所以基本上和n是正相关的,计算效率非常高 +3. 尽管推荐场景下的总体特征空间非常大,但是FM的训练和预测只需要处理样本中的非零特征,这也提升了模型训练和线上预测的速度 +4. 由于模型的计算效率高,并且在稀疏场景下可以自动挖掘长尾低频物料。所以在召回、粗排和精排三个阶段都可以使用。应用在不同阶段时,样本构造、拟合目标及线上服务都有所不同(注意FM用于召回时对于user和item相似度的优化) +5. 其他优点及工程经验参考石塔西的文章 + +**缺点** +1. 只能显示的做特征的二阶交叉,对于更高阶的交叉无能为力。对于此类问题,后续就提出了各类显示、隐式交叉的模型,来充分挖掘特征之间的关系 + +### 代码实现 + +```python +class FM(Layer): + """显示特征交叉,直接按照优化后的公式实现即可 + 注意: + 1. 传入进来的参数看起来是一个Embedding权重,没有像公式中出现的特征,那是因 + 为,输入的id特征本质上都是onehot编码,取出对应的embedding就等价于特征乘以 + 权重。所以后续的操作直接就是对特征进行操作 + 2. 在实现过程中,对于公式中的平方的和与和的平方两部分,需要留意是在哪个维度 + 上计算,这样就可以轻松实现FM特征交叉模块 + """ + def __init__(self, **kwargs): + super(FM, self).__init__(**kwargs) + + def build(self, input_shape): + if not isinstance(input_shape, list) or len(input_shape) < 2: + raise ValueError('`FM` layer should be called \ + on a list of at least 2 inputs') + super(FM, self).build(input_shape) # Be sure to call this somewhere! + + def call(self, inputs, **kwargs): + """ + inputs: 是一个列表,列表中每个元素的维度为:(None, 1, emb_dim), 列表长度 + 为field_num + """ + concated_embeds_value = Concatenate(axis=1)(inputs) #(None,field_num,emb_dim) + # 根据最终优化的公式计算即可,需要注意的是计算过程中是沿着哪个维度计算的,将代码和公式结合起来看会更清晰 + square_of_sum = tf.square(tf.reduce_sum( + concated_embeds_value, axis=1, keepdims=True)) # (None, 1, emb_dim) + sum_of_square = tf.reduce_sum( + concated_embeds_value * concated_embeds_value, + axis=1, keepdims=True) # (None, 1, emb_dim) + cross_term = square_of_sum - sum_of_square + cross_term = 0.5 * tf.reduce_sum(cross_term, axis=2, keepdims=False)#(None,1) + return cross_term + + def compute_output_shape(self, input_shape): + return (None, 1) + + def get_config(self): + return super().get_config() +``` + + +**参考资料** +* [FM:推荐算法中的瑞士军刀](https://zhuanlan.zhihu.com/p/343174108) +* [FM算法解析](https://zhuanlan.zhihu.com/p/37963267) +* [FM论文原文]([https://www.csie.ntu.edu.tw/~b97053/paper/Rendle2010FM.pdf](https://www.csie.ntu.edu.tw/~b97053/paper/Rendle2010FM.pdf)) +* [AI上推荐 之 FM和FFM](https://blog.csdn.net/wuzhongqiang/article/details/108719417) \ No newline at end of file diff --git a/4.人工智能/ch02/ch2.2/ch2.2.2/FiBiNet.md b/4.人工智能/ch02/ch2.2/ch2.2.2/FiBiNet.md new file mode 100644 index 0000000..f4552be --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.2/FiBiNet.md @@ -0,0 +1,451 @@ +## 写在前面 +FiBiNET(Feature Importance and Bilinear feature Interaction)是2019年发表在RecSys的一个模型,来自新浪微博张俊林老师的团队。这个模型如果从模型演化的角度来看, 主要是在特征重要性以及特征之间交互上做出了探索。所以,如果想掌握FiBiNet的话,需要掌握两大核心模块: +* 模型的特征重要性选择 --- SENET网络 +* 特征之间的交互 --- 双线性交叉层(组合了内积和哈达玛积) + + +## FiBiNet? 我们先需要先了解这些 + +FiBiNet的提出动机是因为在特征交互这一方面, 目前的ctr模型要么是简单的两两embedding内积(这里针对离散特征), 比如FM,FFM。 或者是两两embedding进行哈达玛积(NFM这种), 作者认为这两种交互方式还是过于简单, 另外像NFM这种,FM这种,也忽视了特征之间的重要性程度。 + +对于特征重要性,作者在论文中举得例子非常形象 +>the feature occupation is more important than the feature hobby when we predict a person’s income + +所以要想让模型学习到更多的信息, 从作者的角度来看,首先是离散特征之间的交互必不可少,且需要更细粒度。第二个就是需要考虑不同特征对于预测目标的重要性程度,给不同的特征根据重要性程度进行加权。 写到这里, 如果看过之前的文章的话,这个是不是和某些模型有些像呀, 没错,AFM其实考虑了这一点, 不过那里是用了一个Attention网络对特征进行的加权, 这里采用了另一种思路而已,即SENET, 所以这里我们如果是考虑特征重要性程度的话, 就有了两种思路: +* Attention +* SENET + +而考虑特征交互的话, 思路应该会更多: +* PNN里面的内积和外积 +* NFM里面的哈达玛积 +* 这里的双线性函数交互(内积和哈达玛积的组合) + +所以,读论文, 这些思路感觉要比模型本身重要,而读论文还有一个有意思的事情,那就是我们既能了解思路,也能想一下,为啥这些方法会有效果呢? 我们自己能不能提出新的方法来呢? 如果读一篇paper,再顺便把后面的这些问题想通了, 那么这篇paper对于我们来说就发挥效用了, 后面就可以用拉马努金式方法训练自己的思维。 + +在前面的准备工作中,作者依然是带着我们梳理了整个推荐模型的演化过程, 我们也简单梳理下,就当回忆: +* FNN: 下面是经过FM预训练的embedding层, 也就是先把FM训练好,得到各个特征的embedding,用这个embedding初始化FNN下面的embedding层, 上面是DNN。 这个模型用的不是很多,缺点是只能搞隐性高阶交互,并且下面的embedding和高层的DNN配合不是很好。 +* WDL: 这是一个经典的W&D架构, w逻辑回归维持记忆, DNN保持高阶特征交互。问题是W端依然需要手动特征工程,也就是低阶交互需要手动来搞,需要一定的经验。一般工业上也不用了。 +* DeepFM:对WDL的逻辑回归进行升级, 把逻辑回归换成FM, 这样能保证低阶特征的自动交互, 兼顾记忆和泛化性能,低阶和高阶交互。 目前这个模型在工业上非常常用,效果往往还不错,SOTA模型。 +* DCN: 认为DeepFM的W端的FM的交互还不是很彻底,只能到二阶交互。所以就提出了一种交叉性网络,可以在W端完成高阶交互。 +* xDeepFM: DCN的再次升级,认为DCN的wide端交叉网络这种element-wise的交互方式不行,且不是显性的高阶交互,所以提出了一个专门用户高阶显性交互的CIN网络, vector-wise层次上的特征交互。 +* NFM: 下层是FM, 中间一个交叉池化层进行两两交互,然后上面接DNN, 工业上用的不多。 +* AFM: 从NFM的基础上,考虑了交互完毕之后的特征重要性程度, 从NFM的基础上加了一个Attention网络,所以如果用的话,也应该用AFM。 + +综上, 这几个网络里面最常用的还是属DeepFM了, 当然对于交互来讲,在我的任务上试过AFM和xDeepFM, 结果是AFM和DeepFM差不多持平, 而xDeepFM要比这俩好一些,但并不多,而考虑完了复杂性, 还是DeepFM或者AFM。 + +对于上面模型的问题,作者说了两点,第一个是大部分模型没有考虑特征重要性,也就是交互完事之后,没考虑对于预测目标来讲谁更重要,一视同仁。 第二个是目前的两两特征交互,大部分依然是内积或者哈达玛积, 作者认为还不是细粒度(fine-grained way)交互。 + +那么,作者是怎么针对这两个问题进行改进的呢? 为什么这么改进呢? + +## FiBiNet模型的理论以及论文细节 +这里我们直接分析模型架构即可, 因为这个模型不是很复杂,也非常好理解前向传播的过程: + +++ +从模型架构上来看,如果把我框出来的两部分去掉, 这个基本上就退化成了最简单的推荐深度模型DeepCrossing,甚至还比不上那个(那个还用了残差网络)。不过,加上了两个框,效果可就不一样了。所以下面重点是剖析下这两个框的结构,其他的简单一过即可。 +>梳理细节之前, 先说下前向传播的过程。+
+>首先,我们输入的特征有离散和连续,对于连续的特征,输入完了之后,先不用管,等待后面拼起来进DNN即可,这里也没有刻意处理连续特征。 +>
对于离散特征,过embedding转成低维稠密,一般模型的话,这样完了之后,就去考虑embedding之间交互了。 而这个模型不是, 在得到离散特征的embedding之后,分成了两路 +>* 一路保持原样, 继续往后做两两之间embedding交互,不过这里的交互方式,不是简单的内积或者哈达玛积,而是采用了非线性函数,这个后面会提到。 +>* 另一路,过一个SENET Layer, 过完了之后得到的输出是和原来embedding有着相同维度的,这个SENET的理解方式和Attention网络差不多,也是根据embedding的重要性不同出来个权重乘到了上面。 这样得到了SENET-like Embedding,就是加权之后的embedding。 这时候再往上两两双线性交互。 +> +>两路embedding都两两交互完事, Flatten展平,和连续特征拼在一块过DNN输出。 + + +### Embedding Layer +这个不多讲, 整理这个是为了后面统一符号。 + +假设我们有$f$个离散特征,经过embedding层之后,会得到$E=\left[e_{1}, e_{2}, \cdots, e_{i}, \cdots, e_{f}\right]$, 其中$e_{i} \in R^{k}$,表示第$i$个离散特征对应的embedding向量,$k$维。 + +### SENET Layer +这是第一个重点,首先这个网络接收的输入是上面的$E=\left[e_{1}, e_{2}, \cdots, e_{i}, \cdots, e_{f}\right]$, 网络的输出也是个同样大小的张量`(None, f, k)`矩阵。 结构如下: + +++ +SENet由自动驾驶公司Momenta在2017年提出,在当时,是一种应用于图像处理的新型网络结构。它基于CNN结构,**通过对特征通道间的相关性进行建模,对重要特征进行强化来提升模型准确率,本质上就是针对CNN中间层卷积核特征的Attention操作**。ENet仍然是效果最好的图像处理网络结构之一。 +>SENet能否用到推荐系统?--- 张俊林老师的知乎(链接在文末)+
+>推荐领域里面的特征有个特点,就是海量稀疏,意思是大量长尾特征是低频的,而这些低频特征,去学一个靠谱的Embedding是基本没希望的,但是你又不能把低频的特征全抛掉,因为有一些又是有效的。既然这样,**如果我们把SENet用在特征Embedding上,类似于做了个对特征的Attention,弱化那些不靠谱低频特征Embedding的负面影响,强化靠谱低频特征以及重要中高频特征的作用,从道理上是讲得通的** + +所以拿来用了再说, 把SENet放在Embedding层之上,通过SENet网络,动态地学习这些特征的重要性。**对于每个特征学会一个特征权重,然后再把学习到的权重乘到对应特征的Embedding里,这样就可以动态学习特征权重,通过小权重抑制噪音或者无效低频特征,通过大权重放大重要特征影响的目的**。在推荐系统里面, 结构长这个样子: + +++ +下面看下这个网络里面的具体计算过程, SENET主要分为三个步骤Squeeze, Excitation, Re-weight。 + +* **在Squeeze阶段**,我们对每个特征的Embedding向量进行数据压缩与信息汇总,如下: + + $$ + z_{i}=F_{s q}\left(e_{i}\right)=\frac{1}{k} \sum_{t=1}^{k} e_{i}^{(t)} + $$ + + 假设某个特征$v_i$是$k$维大小的$Embedding$,那么我们对$Embedding$里包含的$k$维数字求均值,得到能够代表这个特征汇总信息的数值 $z_i$,也就是说,把第$i$个特征的$Embedding$里的信息压缩到一个数值。原始版本的SENet,在这一步是对CNN的二维卷积核进行$Max$操作的,这里等于对某个特征Embedding元素求均值。我们试过,在推荐领域均值效果比$Max$效果好,这也很好理解,因为**图像领域对卷积核元素求$Max$,等于找到最强的那个特征,而推荐领域的特征$Embedding$,每一位的数字都是有意义的,所以求均值能更好地保留和融合信息**。通过Squeeze阶段,对于每个特征$v_i$ ,都压缩成了单个数值$z_i$,假设特征Embedding层有$f$个特征,就形成Squeeze向量$Z$,向量大小$f$。 + +* **Excitation阶段**,这个阶段引入了中间层比较窄的两层MLP网络,作用在Squeeze阶段的输出向量$Z$上,如下: + + $$ + A=F_{e x}(Z)=\sigma_{2}\left(W_{2} \sigma_{1}\left(W_{1} Z\right)\right) + $$ + + $\sigma$非线性激活函数,一般$relu$。本质上,这是在做特征的交叉,也就是说,每个特征以一个$Bit$来表征,通过MLP来进行交互,通过交互,得出这么个结果:对于当前所有输入的特征,通过相互发生关联,来动态地判断哪些特征重要,哪些特征不重要。 + + 其中,第一个MLP的作用是做特征交叉,第二个MLP的作用是为了保持输出的大小维度。因为假设Embedding层有$f$个特征,那么我们需要保证输出$f$个权重值,而第二个MLP就是起到将大小映射到$f$个数值大小的作用。+
这样,经过两层MLP映射,就会产生$f$个权重数值,第$i$个数值对应第$i$个特征Embedding的权重$a_i$ 。
这个东西有没有感觉和自动编码器很像,虽然不是一样的作用, 但网络结构是一样的。这就是知识串联的功效哈哈。 + +++ + 瞬间是不是就把SENet这里的网络结构记住了哈哈。下面再分析下维度, SENet的输入是$E$,这个是`(None, f, k)`的维度, 通过Squeeze阶段,得到了`(None, f)`的矩阵,这个也就相当于Layer L1的输入(当然这里没有下面的偏置哈),接下来过MLP1, 这里的$W_{1} \in R^{f \times \frac{f}{r}}, W_{2} \in R^{\frac{f}{r} \times f}$, 这里的$r$叫做reduction + ratio, $\frac{f}{r}$这个就是中间层神经元的个数, $r$表示了压缩的程度。 + +* Re-Weight + 我们把Excitation阶段得到的每个特征对应的权重$a_i$,再乘回到特征对应的Embedding里,就完成了对特征重要性的加权操作。 + $$V=F_{\text {ReWeight }}(A, E)=\left[a_{1} \cdot e_{1}, \cdots, a_{f} \cdot e_{f}\right]=\left[v_{1}, \cdots, v_{f}\right]$$ + $a_{i} \in R, e_{i} \in R^{k}$, and $v_{i} \in R^{k}$。$a_i$数值大,说明SENet判断这个特征在当前输入组合里比较重要, $a_i$数值小,说明SENet判断这个特征在当前输入组合里没啥用。如果非线性函数用Relu,会发现大量特征的权重会被Relu搞成0,也就是说,其实很多特征是没啥用的。 + + +这样,就可以将SENet引入推荐系统,用来对特征重要性进行动态判断。注意,**所谓动态,指的是比如对于某个特征,在某个输入组合里可能是没用的,但是换一个输入组合,很可能是重要特征。它重要不重要,不是静态的,而是要根据当前输入,动态变化的**。 + +这里正确的理解,算是一种特征重要性选择的思路, SENET和AFM的Attention网络是起着同样功效的一个网络。只不过那个是在特征交互之后进行特征交互重要性的选择,而这里是从embedding这里先压缩,再交互,再选择,去掉不太重要的特征。 **考虑特征重要性上的两种考虑思路,难以说孰好孰坏,具体看应用场景**。 不过如果分析下这个东西为啥会有效果, 就像张俊林老师提到的那样, 在Excitation阶段, 各个特征过了一个MLP进行了特征组合, 这样就真有可能过滤掉对于当前的交互不太重要的特征。 至于是不是, 那神经网络这东西就玄学了,让网络自己去学吧。 + +### Bilinear-Interaction Layer +特征重要性选择完事, 接下来就是研究特征交互, 这里作者直接就列出了目前的两种常用交互以及双线性交互: + ++
++ +这个图其实非常了然了。以往模型用的交互, 内积的方式(FM,FFM)这种或者哈达玛积的方式(NFM,AFM)这种。 + ++
++ +所谓的双线性,其实就是组合了内积和哈达玛积的操作,看上面的右图。就是在$v_i$和$v_j$之间先加一个$W$矩阵, 这个$W$矩阵的维度是$(f,f)$, $v_i, v_j$是$(1,f)$的向量。 先让$v_i$与$W$内积,得到$(1,f)$的向量,这时候先仔细体会下这个**新向量的每个元素,相当于是原来向量$v_i$在每个维度上的线性组合了**。这时候再与$v_j$进行哈达玛积得到结果。 + +这里我不由自主的考虑了下双线性的功效,也就是为啥作者会说双线性是细粒度,下面是我自己的看法哈。 +* 如果我们单独先看内积操作,特征交互如果是两个向量直接内积,这时候, 结果大的,说明两个向量相似或者特征相似, 但向量内积,其实是相当于向量的各个维度先对应位置元素相乘再相加求和。 这个过程中认为的是向量的各个维度信息的重要性是一致的。类似于$v_1+v_2+..v_k$, 但真的一致吗? --- **内积操作没有考虑向量各个维度的重要性** +* 如果我们单独看哈达玛积操作, 特征交互如果是两个向量哈达玛积,这时候,是各个维度对应位置元素相乘得到一个向量, 而这个向量往往后面会进行线性或者非线性交叉的操作, 最后可能也会得到具体某个数值,但是这里经过了线性或者非线性交叉操作之后, 有没有感觉把向量各个维度信息的重要性考虑了进来? 就类似于$w_1v_{i1j1}+w_2k_{v2j2},...w_kv_{vkjk}$。 如果模型认为重要性相同,那么哈达玛积还有希望退化成内积,所以哈达玛积感觉考虑的比内积就多了一些。 --- **哈达玛积操作自身也没有考虑各个维度重要性,但通过后面的线性或者非线性操作,有一定的维度重要性在里面** +* 再看看这个双线性, 是先内积再哈达玛积。这个内积操作不是直接$v_i$和$v_j$内积,而是中间引入了个$W$矩阵,参数可学习。 那么$v_i$和$W$做内积之后,虽然得到了同样大小的向量,但是这个向量是$v_i$各个维度元素的线性组合,相当于$v_i$变成了$[w_{11}v_{i1}+...w_{1k}v_{ik}, w_{21}v_{i1}+..w_{2k}v_{ik}, ...., w_{k1}v_{i1}+...w_{kk}v_{ik}]$, 这时候再与$v_j$哈达玛积的功效,就变成了$[(w_{11}v_{i1}+...w_{1k}v_{ik})v_{j1}, (w_{21}v_{i1}+..w_{2k}v_{ik})v_{j2}, ...., (w_{k1}v_{i1}+...w_{kk}v_{ik})v_{j_k}]$, 这时候,就可以看到,如果这里的$W$是个对角矩阵,那么这里就退化成了哈达玛积。 所以双线性感觉考虑的又比哈达玛积多了一些。如果后面再走一个非线性操作的话,就会发现这里同时考虑了两个交互向量各个维度上的重要性。---**双线性操作同时可以考虑交互的向量各自的各个维度上的重要性信息, 这应该是作者所说的细粒度,各个维度上的重要性** + +**当然思路是思路,双线性并不一定见得一定比哈达玛积有效, SENET也不一定就会比原始embedding要好,一定要辩证看问题** + +这里还有个厉害的地方在于这里的W有三种选择方式,也就是三种类型的双线性交互方式。 +1. Field-All Type +$$ +p_{i j}=v_{i} \cdot W \odot v_{j} +$$ +也就是所有的特征embedding共用一个$W$矩阵,这也是Field-All的名字来源。$W \in R^{k \times k}, \text { and } v_{i}, v_{j} \in R^{k}$。这种方式最简单 +2. Field-Each Type +$$ +p_{i j}=v_{i} \cdot W_{i} \odot v_{j} +$$ +每个特征embedding共用一个$W$矩阵, 那么如果有$f$个特征的话,这里的$W_i$需要$f$个。所以这里的参数个数$f-1\times k\times k$, 这里的$f-1$是因为两两组合之后,比如`[0,1,2]`, 两两组合`[0,1], [0,2], [1,2]`。 这里用到的域是0和1。 +3. Field-Interaction Type +$$ +p_{i j}=v_{i} \cdot W_{i j} \odot v_{j} +$$ +每组特征交互的时候,用一个$W$矩阵, 那么这里如果有$f$个特征的话,需要$W_{ij}$是$\frac{f(f-1)}{2}$个。参数个数$\frac{f(f-1)}{2}\times k\times k$个。 + +不知道看到这里,这种操作有没有种似曾相识的感觉, 有没有想起FM和FFM, 反正我是不自觉的想起了哈哈,不知道为啥。总感觉FM的风格和上面的Field-All很像, 而FFM和下面的Field-Interaction很像。 + +我们的原始embedding和SKNET-like embedding都需要过这个层,那么得到的就是一个双线性两两组合的矩阵, 维度是$(\frac{f(f-1)}{2}, k)$的矩阵。 + ++
++ +### Combination Layer +这个层的作用就是把目前得到的特征拼起来 +$$ +c=F_{\text {concat }}(p, q)=\left[p_{1}, \cdots, p_{n}, q_{1}, \cdots, q_{n}\right]=\left[c_{1}, \cdots, c_{2 n}\right] +$$ +这里他直拼了上面得到的两个离散特征通过各种交互之后的形式,如果是还有连续特征的话,也可以在这里拼起来,然后过DNN,不过这里其实还省略了一步操作就是Flatten,先展平再拼接。 + +### DNN和输出层 +这里就不多说了, DNN的话普通的全连接网络, 再捕捉一波高阶的隐性交互。 +$$ +a^{(l)}=\sigma\left(W^{(l)} a^{(l-1)}+b^{(l)}\right) +$$ +而输出层 +$$ +\hat{y}=\sigma\left(w_{0}+\sum_{i=0}^{m} w_{i} x_{i}+y_{d}\right) +$$ +分类问题损失函数: +$$ +\operatorname{loss}=-\frac{1}{N} \sum_{i=1}^{N}\left(y_{i} \log \left(\hat{y}_{i}\right)+\left(1-y_{i}\right) * \log \left(1-\hat{y}_{i}\right)\right) +$$ +这里就不解释了。 + +### 其他重要细节 +实验部分,这里作者也是做了大量的实验来证明提出的模型比其他模型要好,这个就不说了。 + ++
++ +竟然比xDeepFM都要好。 + +在模型评估指标上,用了AUC和Logloss,这个也是常用的指标,Logloss就是交叉熵损失, 反映了样本的平均偏差,经常作为模型的损失函数来做优化,可是,当训练数据正负样本不平衡时,比如我们经常会遇到正样本很少,负样本很多的情况,此时LogLoss会倾向于偏向负样本一方。 而AUC评估不会受很大影响,具体和AUC的计算原理有关。这个在这里就不解释了。 + +其次了解到的一个事情: + ++
++ +接下来,得整理下双线性与哈达玛积的组合类型,因为我们这个地方其实有两路embedding的, 一路是原始embedding, 一路是SKNet侧的embedding。而面临的组合方式,有双线性和哈达玛积两种。那么怎么组合会比较好呢? 作者做了实验。结论是,作者建议: +>深度学习模型中,原始那边依然哈达玛,SE那边双线性, 可能更有效, 不过后面的代码实现里面,都用了双线性。 + + +而具体,在双线性里面,那种W的原则有效呢? 这个视具体的数据集而定。 + +超参数选择,主要是embedding维度以及DNN层数, embedding维度这个10-50, 不同的数据集可能表现不一样, 但尽量不要超过50了。否则在DNN之前的特征维度会很大。 + +DNN层数,作者这里建议3层,而每一层神经单元个数,也是没有定数了。 + +这里竟然没有说$r$的确定范围。 Deepctr里面默认是3。 + +对于实际应用的一些经验: +1. SE-FM 在实验数据效果略高于 FFM,优于FM,对于模型处于低阶的团队,升级FM、SE-FM成本比较低 + +2. deepSE-FM 效果优于DCN、XDeepFM 这类模型,相当于**XDeepFM这种难上线的模型**来说,很值得尝试,不过大概率怀疑是**增加特征交叉的效果,特征改进比模型改进work起来更稳** +3. 实验中增加embeding 长度费力不讨好,效果增加不明显,如果只是增加长度不改变玩法边际效应递减,**不增加长度增加emmbedding 交叉方式类似模型的ensemble更容易有效果** + + +## FiBiNet模型的代码复现及重要结构解释 +这里的话,参考deepctr修改的简化版本。 +### 全貌 + +对于输入,就不详细的说了,在xDeepFM那里已经解释了, 首先网络的整体全貌: + +```python +def fibinet(linear_feature_columns, dnn_feature_columns, bilinear_type='interaction', reduction_ratio=3, hidden_units=[128, 128]): + """ + :param linear_feature_columns, dnn_feature_columns: 封装好的wide端和deep端的特征 + :param bilinear_type: 双线性交互类型, 有'all', 'each', 'interaction'三种 + :param reduction_ratio: senet里面reduction ratio + :param hidden_units: DNN隐藏单元个数 + """ + + # 构建输出层, 即所有特征对应的Input()层, 用字典的形式返回,方便后续构建模型 + dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns) + + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入预Input层对应,是通过模型输入时候的字典数据的key与对应name的Input层 + input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values()) + + # 线性部分的计算逻辑 -- linear + linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns) + + # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型 + # 线性层和dnn层统一的embedding层 + embedding_layer_dict = build_embedding_layers(linear_feature_columns+dnn_feature_columns, sparse_input_dict, is_linear=False) + + # DNN侧的计算逻辑 -- Deep + # 将dnn_feature_columns里面的连续特征筛选出来,并把相应的Input层拼接到一块 + dnn_dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns)) if dnn_feature_columns else [] + dnn_dense_feature_columns = [fc.name for fc in dnn_dense_feature_columns] + dnn_concat_dense_inputs = Concatenate(axis=1)([dense_input_dict[col] for col in dnn_dense_feature_columns]) + + # 将dnn_feature_columns里面的离散特征筛选出来,相应的embedding层拼接到一块,然后过SENet_layer + dnn_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False) + sparse_embedding_list = Concatenate(axis=1)(dnn_sparse_kd_embed) + + # SENet layer + senet_embedding_list = SENETLayer(reduction_ratio)(sparse_embedding_list) + + # 双线性交互层 + senet_bilinear_out = BilinearInteraction(bilinear_type=bilinear_type)(senet_embedding_list) + raw_bilinear_out = BilinearInteraction(bilinear_type=bilinear_type)(sparse_embedding_list) + + bilinear_out = Flatten()(Concatenate(axis=1)([senet_bilinear_out, raw_bilinear_out])) + + # DNN层的输入和输出 + dnn_input = Concatenate(axis=1)([bilinear_out, dnn_concat_dense_inputs]) + dnn_out = get_dnn_output(dnn_input, hidden_units=hidden_units) + dnn_logits = Dense(1)(dnn_out) + + # 最后的输出 + final_logits = Add()([linear_logits, dnn_logits]) + + # 输出层 + output_layer = Dense(1, activation='sigmoid')(final_logits) + + model = Model(input_layers, output_layer) + + return model +``` +这里依然是是采用了线性层计算与DNN相结合的方式。 前向传播这里也不详细描述了。这里面重点是SENETLayer和BilinearInteraction层,其他的和之前网络模块基本上一样。 + +### SENETLayer +这里的输入是`[None, field_num embed_dim]`的维度,也就是离散特征的embedding, 拿到这个输入之后,三个步骤,得到的是一个`[None, feild_num, embed_dim]`的同样维度的矩阵,只不过这里是SKNET-like embedding了。 + +```python +class SENETLayer(Layer): + def __init__(self, reduction_ratio, seed=2021): + super(SENETLayer, self).__init__() + self.reduction_ratio = reduction_ratio + self.seed = seed + + def build(self, input_shape): + # input_shape [None, field_nums, embedding_dim] + self.field_size = input_shape[1] + self.embedding_size = input_shape[-1] + + # 中间层的神经单元个数 f/r + reduction_size = max(1, self.field_size // self.reduction_ratio) + + # FC layer1和layer2的参数 + self.W_1 = self.add_weight(shape=( + self.field_size, reduction_size), initializer=glorot_normal(seed=self.seed), name="W_1") + self.W_2 = self.add_weight(shape=( + reduction_size, self.field_size), initializer=glorot_normal(seed=self.seed), name="W_2") + + self.tensordot = tf.keras.layers.Lambda( + lambda x: tf.tensordot(x[0], x[1], axes=(-1, 0))) + + # Be sure to call this somewhere! + super(SENETLayer, self).build(input_shape) + + def call(self, inputs): + # inputs [None, field_num, embed_dim] + + # Squeeze -> [None, field_num] + Z = tf.reduce_mean(inputs, axis=-1) + + # Excitation + A_1 = tf.nn.relu(self.tensordot([Z, self.W_1])) # [None, reduction_size] + A_2 = tf.nn.relu(self.tensordot([A_1, self.W_2])) # [None, field_num] + + # Re-Weight + V = tf.multiply(inputs, tf.expand_dims(A_2, axis=2)) # [None, field_num, embedding_dim] + + return V +``` +三个步骤还是比较好理解的, 这里这种自定义层权重的方式需要学习下。 + +### 4.3 BilinearInteraction Layer +这里接收的输入同样是`[None, field_num embed_dim]`的维度离散特征的embedding。 输出是来两两交互完毕的矩阵$[None, \frac{f(f-1)}{2}, embed\_dim]$ + +这里的双线性交互有三种形式,具体实现的话可以参考下面的代码,我加了注释, 后面两种用到了组合的方式, 感觉人家这种实现方式还是非常巧妙的。 +```python +class BilinearInteraction(Layer): + """BilinearInteraction Layer used in FiBiNET. + Input shape + - 3D tensor with shape: ``(batch_size,field_size,embedding_size)``. + Output shape + - 3D tensor with shape: ``(batch_size,filed_size*(filed_size-1)/2,embedding_size)``. + """ + def __init__(self, bilinear_type="interaction", seed=2021, **kwargs): + super(BilinearInteraction, self).__init__(**kwargs) + self.bilinear_type = bilinear_type + self.seed = seed + + def build(self, input_shape): + # input_shape: [None, field_num, embed_num] + self.field_size = input_shape[1] + self.embedding_size = input_shape[-1] + + if self.bilinear_type == "all": # 所有embedding矩阵共用一个矩阵W + self.W = self.add_weight(shape=(self.embedding_size, self.embedding_size), initializer=glorot_normal( + seed=self.seed), name="bilinear_weight") + elif self.bilinear_type == "each": # 每个field共用一个矩阵W + self.W_list = [self.add_weight(shape=(self.embedding_size, self.embedding_size), initializer=glorot_normal( + seed=self.seed), name="bilinear_weight" + str(i)) for i in range(self.field_size-1)] + elif self.bilinear_type == "interaction": # 每个交互用一个矩阵W + self.W_list = [self.add_weight(shape=(self.embedding_size, self.embedding_size), initializer=glorot_normal( + seed=self.seed), name="bilinear_weight" + str(i) + '_' + str(j)) for i, j in + itertools.combinations(range(self.field_size), 2)] + else: + raise NotImplementedError + + super(BilinearInteraction, self).build(input_shape) # Be sure to call this somewhere! + + def call(self, inputs): + # inputs: [None, field_nums, embed_dims] + # 这里把inputs从field_nums处split, 划分成field_nums个embed_dims长向量的列表 + inputs = tf.split(inputs, self.field_size, axis=1) # [(None, embed_dims), (None, embed_dims), ..] + n = len(inputs) # field_nums个 + + if self.bilinear_type == "all": + # inputs[i] (none, embed_dims) self.W (embed_dims, embed_dims) -> (None, embed_dims) + vidots = [tf.tensordot(inputs[i], self.W, axes=(-1, 0)) for i in range(n)] # 点积 + p = [tf.multiply(vidots[i], inputs[j]) for i, j in itertools.combinations(range(n), 2)] # 哈达玛积 + elif self.bilinear_type == "each": + vidots = [tf.tensordot(inputs[i], self.W_list[i], axes=(-1, 0)) for i in range(n - 1)] + # 假设3个域, 则两两组合[(0,1), (0,2), (1,2)] 这里的vidots是第一个维度, inputs是第二个维度 哈达玛积运算 + p = [tf.multiply(vidots[i], inputs[j]) for i, j in itertools.combinations(range(n), 2)] + elif self.bilinear_type == "interaction": + # combinations(inputs, 2) 这个得到的是两两向量交互的结果列表 + # 比如 combinations([[1,2], [3,4], [5,6]], 2) + # 得到 [([1, 2], [3, 4]), ([1, 2], [5, 6]), ([3, 4], [5, 6])] (v[0], v[1]) 先v[0]与W点积,然后再和v[1]哈达玛积 + p = [tf.multiply(tf.tensordot(v[0], w, axes=(-1, 0)), v[1]) + for v, w in zip(itertools.combinations(inputs, 2), self.W_list)] + else: + raise NotImplementedError + + output = Concatenate(axis=1)(p) + return output +``` +这里第一个是需要学习组合交互的具体实现方式, 人家的代码方式非常巧妙,第二个会是理解下。 + +关于FiBiNet网络的代码细节就到这里了,具体代码放到了我的GitHub链接上了。 + +## 总结 +这篇文章主要是整理了一个新模型, 这个模型是在特征重要性选择以及特征交互上做出了新的探索,给了我们两个新思路。 这里面还有两个重要的地方,感觉是作者对于SENET在推荐系统上的使用思考,也就是为啥能把这个东西迁过来,以及为啥双线性会更加细粒度,这种双线性函数的优势在哪儿?我们通常所说的知其然,意思是针对特征交互, 针对特征选择,我又有了两种考虑思路双线性和SENet, 而知其所以然,应该考虑为啥双线性或者SENET会有效呢? 当然在文章中给出了自己的看法,当然这个可能不对哈,是自己对于问题的一种思考, 欢迎伙伴们一块讨论。 + +我现在读论文,一般读完了之后,会刻意逼着自己想这么几个问题: +>本篇论文核心是讲了个啥东西? 是为啥会提出这么个东西? 为啥这个新东西会有效? 与这个新东西类似的东西还有啥? 在工业上通常会怎么用? + +一般经过这样的灵魂5问就能把整篇论文拎起来了,而读完了这篇文章,你能根据这5问给出相应的答案吗? 欢迎在下方留言呀。 + +还有一种读论文的厉害姿势,和张俊林老师学的,就是拉马努金式思维,就是读论文之前,看完题目之后, 不要看正文,先猜测作者在尝试解决什么样的问题,比如 + ++
++ +看到特征重要性和双线性特征交互, 就大体上能猜测到这篇推荐论文讲的应该是和特征选择和特征交互相关的知识。 那么如果是我解决这两方面的话应该怎么解决呢? +* 特征选择 --- 联想到Attention +* 特征交互 --- 联想到哈达玛积或者内积 + +这时候, 就可以读论文了,读完之后, 对比下人家提出的想法和自己的想法的区别,考虑下为啥会有这样的区别? 然后再就是上面的灵魂5问, 通过这样的方式读论文, 能够理解的更加深刻,就不会再有读完很多论文,依然很虚的感觉,啥也没记住了。 如果再能花点时间总结输出下, 和之前的论文做一个对比串联,再花点时间看看代码,复现下,用到自己的任务上。 那么这样, 就算是真正把握住模型背后的思想了,而不是仅仅会个模型而已, 并且这种读论文方式,只要习惯了之后, 读论文会很快,因为我隐约发现,万变不离其宗, 论文里面抛去实验部分,抛去前言部分, 剩下的精华其实没有几页的。当然整理会花费时间, 但也有相应的价值在里面。 我以后整理,也是以经典思路模型为主, 对于一般的,我会放到论文总结的专栏里面,一下子两三篇的那种整理,只整理大体思路就即可啦。 + +下面只整理来自工业大佬的使用经验和反思, 具体参考下面的第二篇参考: +* 适用的数据集 +虽然模型是针对点击率预测的场景提出的,但可以尝试的数据场景也不少,比较适合包含大量categorical feature且这些feature cardinality本身很高,或者因为encode method导致的某些feature维度很高且稀疏的情况。推荐系统的场景因为大量的user/item属性都是符合这些要求的,所以效果格外好,但我们也可以举一反三把它推广到其他相似场景。另外,文字描述类的特征(比如人工标注的主观评价,名字,地址信息……)可以用tokenizer处理成int sequence/matrix作为embedding feature喂进模型,丰富的interaction方法可以很好的学习到这些样本中这些特征的相似之处并挖掘出一些潜在的关系。 + +* 回归和分类问题都可以做,无非改一下DNN最后一层的activation函数和objective,没有太大的差别。 +* 如果dense feature比较多而且是分布存在很多异常值的numeric feature,尽量就不要用FiBiNET了,相比大部分NN没有优势不说,SENET那里的一个最大池化极其容易把特征权重带偏,如果一定要上,可能需要修改一下池化的方法。 + +* DeepCTR的实现还把指定的linear feature作为类似于WDL中的wide部分直接输入到DNN的最后一层,以及DNN部分也吸收了一部分指定的dnn feature中的dense feature直接作为输入。毫无疑问,DeepCTR作者在尽可能的保留更多的特征作为输入防止信息的丢失。 + + +* 使用Field-Each方式能够达到最好的预测准确率,而且相比默认的Field-Interaction,参数也减少了不少,训练效率更高。当然,三种方式在准确率方面差异不是非常巨大。 +* reduce ratio设置到8效果最好,这方面我的经验和不少人达成了共识,SENET用于其他学习任务也可以得到相似的结论。 -- 这个试了下,确实有效果 +* 使用dropout方法扔掉hidden layer里的部分unit效果会更好,系数大约在0.3时最好,原文用的是0.5,请根据具体使用的网络结构和数据集特点自己调整。-- 这个有效果 + +* 在双线性部分引入Layer Norm效果可能会更好些 + +* 尝试在DNN部分使用残差防止DNN效果过差 +* 直接取出Bilinear的输出结果然后上XGBoost,也就是说不用它来训练而是作为一种特征embedding操作去使用, 这个方法可能发生leak +* 在WDL上的调优经验: 适当调整DNN hideen layer之间的unit数量的减小比例,防止梯度爆炸/消失。 + +后记: +>fibinet在我自己的任务上也试了下,确实会效果, 采用默认参数的话, 能和xdeepfm跑到同样的水平,而如果再稍微调调参, 就比xdeepfm要好些了。 + +**参考**: +* [论文原文](https://arxiv.org/pdf/1905.09433.pdf) +* [FiBiNET: paper reading + 实践调优经验](https://zhuanlan.zhihu.com/p/79659557) +* [FiBiNET:结合特征重要性和双线性特征交互进行CTR预估](https://zhuanlan.zhihu.com/p/72931811) +* [FiBiNET(新浪)](https://zhuanlan.zhihu.com/p/92130353) +* [FiBiNet 网络介绍与源码浅析](https://zhuanlan.zhihu.com/p/343572144) +* [SENET双塔模型及应用](https://mp.weixin.qq.com/s/Y3A8chyJ6ssh4WLJ8HNQqw) + + diff --git a/4.人工智能/ch02/ch2.2/ch2.2.2/PNN.md b/4.人工智能/ch02/ch2.2/ch2.2.2/PNN.md new file mode 100644 index 0000000..31feda2 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.2/PNN.md @@ -0,0 +1,244 @@ +# PNN +## 动机 +在特征交叉的相关模型中FM, FFM都证明了特征交叉的重要性,FNN将神经网络的高阶隐式交叉加到了FM的二阶特征交叉上,一定程度上说明了DNN做特征交叉的有效性。但是对于DNN这种“add”操作的特征交叉并不能充分挖掘类别特征的交叉效果。PNN虽然也用了DNN来对特征进行交叉组合,但是并不是直接将低阶特征放入DNN中,而是设计了Product层先对低阶特征进行充分的交叉组合之后再送入到DNN中去。 + +PNN模型其实是对IPNN和OPNN的总称,两者分别对应的是不同的Product实现方法,前者采用的是inner product,后者采用的是outer product。在PNN的算法方面,比较重要的部分就是Product Layer的简化实现方法,需要在数学和代码上都能够比较深入的理解。 + +## 模型的结构及原理 + +> 在学习PNN模型之前,应当对于DNN结构具有一定的了解,同时已经学习过了前面的章节。 + +PNN模型的整体架构如下图所示: + ++
+ +一共分为五层,其中除了Product Layer别的layer都是比较常规的处理方法,均可以从前面的章节进一步了解。模型中最重要的部分就是通过Product层对embedding特征进行交叉组合,也就是上图中红框所显示的部分。 + +Product层主要有线性部分和非线性部分组成,分别用$l_z$和$l_p$来表示, + +
+ +1. 线性模块,一阶特征(未经过显示特征交叉处理),对应论文中的$l_z=(l_z^1,l_z^2, ..., l_z^{D_1})$ +2. 非线性模块,高阶特征(经过显示特征交叉处理),对应论文中的$l_p=(l_p^1,l_p^2, ..., l_p^{D_1})$ + +**线性部分** + +先来解释一下$l_z$是如何计算得到的,在介绍计算$l_z$之前先介绍一下矩阵内积计算, 如下公式所示,用一句话来描述就是两个矩阵对应元素相称,然后将相乘之后的所有元素相加 +$$ +A \odot{B} = \sum_{i,j}A_{i,j}B_{i,j} +$$ +$l_z^n$的计算就是矩阵内积,而$l_z$是有$D_1$个$l_z^n$组成,所以需要$D1$个矩阵求得,但是在代码实现的时候不一定是定义$D_1$个矩阵,可以将这些矩阵Flatten,具体的细节可以参考给出的代码。 +$$ +l_z=(l_z^1,l_z^2, ..., l_z^{D_1})\\ +l_z^n = W_z^n \odot{z} \\ +z = (z_1, z_2, ..., z_N) +$$ +总之这一波操作就是将所有的embedding向量中的所有元素都乘以一个矩阵的对应元素,最后相加即可,这一部分比较简单(N表示的是特征的数量,M表示的是所有特征转化为embedding之后维度,也就是N*emb_dim) +$$ +l_z^n = W_z^n \odot{z} = \sum_{i=1}^N \sum_{j=1}^M (W_z^n)_{i,j}z_{i,j} +$$ + +### Product Layer +**非线性部分** + +上面介绍了线性部分$l_p$的计算,非线性部分的计算相比线性部分要复杂很多,先从整体上看$l_p$的计算 +$$ +l_p=(l_p^1,l_p^2, ..., l_p^{D_1}) \\ +l_p^n = W_p^n \odot{p} \\ +p = \{p_{i,j}\}, i=1,2,...,N,j=1,2,...,N +$$ +从上述公式中可以发现,$l_p^n$和$l_z^n$类似需要$D_1$个$W_p^n$矩阵计算内积得到,重点就是如何求这个$p$,这里作者提出了两种方式,一种是使用内积计算,另一种是使用外积计算。 + +#### IPNN + +使用内积实现特征交叉就和FM是类似的(两两向量计算内积),下面将向量内积操作表示如下表达式 +$$ +g(f_i,f_j) =+
+$$ +将内积的表达式带入$l_p^n$的计算表达式中有: +$$ +\begin{aligned} + +l_p^n &= W_p^n \odot{p} \\ + &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\ + &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j} + +\end{aligned} +$$ +上面就提到了这里使用的内积是计算两两特征之间的内积,然而向量a和向量b的内积与向量b和向量a的内积是相同的,其实是没必要计算的,看一下下面FM的计算公式: +$$ +\hat{y}(X) = \omega_{0}+\sum_{i=1}^{n}{\omega_{i}x_{i}}+\sum_{i=1}^{n}{\sum_{j=i+1}^{n} x_{i}x_{j}} +$$ +也就是说计算的内积矩阵$p$是对称的,那么与其对应元素做矩阵内积的矩阵$W_p^n$也是对称的,对于可学习的权重来说如果是对称的是不是可以只使用其中的一半就行了呢,所以基于这个思考,对Inner Product的权重定义及内积计算进行优化,首先将权重矩阵分解$W_p^n=\theta^n \theta^{nT}$,此时$\theta^n \in R^N$(参数从原来的$N^2$变成了$N$),将分解后的$W_p^n$带入$l_p^n$的计算公式有: +$$ +\begin{aligned} + +l_p^n &= W_p^n \odot{p} \\ + &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\ + &= \sum_{i=1}^N \sum_{j=1}^N \theta^n \theta^n \\ + &= \sum_{i=1}^N \sum_{j=1}^N <\theta^n f_i, \theta^n f_j> \\ + &= <\sum_{i=1}^N \theta^n f_i, \sum_{j=1}^N \theta^n f_j> \\ + &= ||\sum_{i=1}^N \theta^n f_i||^2 +\end{aligned} +$$ +所以优化后的$l_p$的计算公式为: +$$ +l_p = (||\sum_{i=1}^N \theta^1 f_i||^2, ||\sum_{i=1}^N \theta^2 f_i||^2, ..., ||\sum_{i=1}^N \theta^{D_1} f_i||^2) +$$ +这里为了好理解不做过多的解释,其实这里对于矩阵分解省略了一些细节,感兴趣的可以去看原文,最后模型实现的时候就是基于上面的这个公式计算的(给出的代码也是基于优化之后的实现)。 + +#### OPNN + +使用外积实现相比于使用内积实现,唯一的区别就是使用向量的外积来计算矩阵$p$,首先定义向量的外积计算 +$$ +g(i,j) = f_i f_j^T +$$ +从外积公式可以发现两个向量的外积得到的是一个矩阵,与上面介绍的内积计算不太相同,内积得到的是一个数值。内积实现的Product层是将计算得到的内积矩阵,乘以一个与其大小一样的权重矩阵,然后求和,按照这个思路的话,通过外积得到的$p$计算$W_p^n \odot{p}$相当于之前的内积值乘以权重矩阵对应位置的值求和就变成了,外积矩阵乘以权重矩阵中对应位置的子矩阵然后将整个相乘得到的大矩阵对应元素相加,用公式表示如下: +$$ +\begin{aligned} +l_p^n &= W_p^n \odot{p} \\ + &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\ + &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j} f_i f_j^T +\end{aligned} +$$ +需要注意的是此时的$(W_p^n)_{i,j}$表示的是一个矩阵,而不是一个值,此时计算$l_p$的复杂度是$O(D_1*N^2*M^2)$, 其中$N^2$表示的是特征的组合数量,$M^2$表示的是计算外积的复杂度。这样的复杂度肯定是无法接受的,所以为了优化复杂度,PNN的作者重新定义了$p$的计算方式: +$$ +p=\sum_{i=1}^{N} \sum_{j=1}^{N} f_{i} f_{j}^{T}=f_{\Sigma}\left(f_{\Sigma}\right)^{T} \\ +f_{\Sigma}=\sum_{i=1}^{N} f_{i} +$$ +需要注意,这里新定义的外积计算与传统的外积计算时不等价的,这里是为了优化计算效率重新定义的计算方式,从公式中可以看出,相当于先将原来的embedding向量在特征维度上先求和,变成一个向量之后再计算外积。加入原embedding向量表示为$E \in R^{N\times M}$,其中$N$表示特征的数量,M表示的是所有特征的总维度,即$N*emb\_dim$, 在特征维度上进行求和就是将$E \in R^{N\times M}$矩阵压缩成了$E \in R^M$, 然后两个$M$维的向量计算外积得到最终所有特征的外积交叉结果$p\in R^{M\times M}$,最终的$l_p^n$可以表示为: +$$ +l_p^n = W_p^n \odot{p} = \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\ +$$ +最终的计算方式和$l_z$的计算方式看起来差不多,但是需要注意外积优化后的$W_p^n$的维度是$R^{M \times M}$的,$M$表示的是特征矩阵的维度,即$N*emb\_dim$。 + +> 虽然叠加概念的引入可以降低计算开销,但是中间的精度损失也是很大的,性能与精度之间的tradeoff + +## 代码实现 + +代码实现的整体逻辑比较简单,就是对类别特征进行embedding编码,然后通过embedding特征计算$l_z,l_p$, 接着将$l_z, l_p$的输出concat到一起输入到DNN中得到最终的预测结果 + +```python +def PNN(dnn_feature_columns, inner=True, outer=True): + # 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型 + _, sparse_input_dict = build_input_layers(dnn_feature_columns) + + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层 + input_layers = list(sparse_input_dict.values()) + + # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型 + embedding_layer_dict = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False) + + sparse_embed_list = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False) + + dnn_inputs = ProductLayer(units=32, use_inner=True, use_outer=True)(sparse_embed_list) + + # 输入到dnn中,需要提前定义需要几个残差块 + output_layer = get_dnn_logits(dnn_inputs) + + model = Model(input_layers, output_layer) + return model +``` + +PNN的难点就是Product层的实现,下面是Product 层实现的代码,代码中是使用优化之后$l_p$的计算方式编写的, 代码中有详细的注释,但是要完全理解代码还需要去理解上述说过的优化思路。 + +```python +class ProductLayer(Layer): + def __init__(self, units, use_inner=True, use_outer=False): + super(ProductLayer, self).__init__() + self.use_inner = use_inner + self.use_outer = use_outer + self.units = units # 指的是原文中D1的大小 + + def build(self, input_shape): + # 需要注意input_shape也是一个列表,并且里面的每一个元素都是TensorShape类型, + # 需要将其转换成list然后才能参与数值计算,不然类型容易错 + # input_shape[0] : feat_nums x embed_dims + self.feat_nums = len(input_shape) + self.embed_dims = input_shape[0].as_list()[-1] + flatten_dims = self.feat_nums * self.embed_dims + + # Linear signals weight, 这部分是用于产生Z的权重,因为这里需要计算的是两个元素对应元素乘积然后再相加 + # 等价于先把矩阵拉成一维,然后相乘再相加 + self.linear_w = self.add_weight(name='linear_w', shape=(flatten_dims, self.units), initializer='glorot_normal') + + # inner product weight + if self.use_inner: + # 优化之后的内积权重是未优化时的一个分解矩阵,未优化时的矩阵大小为:D x N x N + # 优化后的内积权重大小为:D x N + self.inner_w = self.add_weight(name='inner_w', shape=(self.units, self.feat_nums), initializer='glorot_normal') + + if self.use_outer: + # 优化之后的外积权重大小为:D x embed_dim x embed_dim, 因为计算外积的时候在特征维度通过求和的方式进行了压缩 + self.outer_w = self.add_weight(name='outer_w', shape=(self.units, self.embed_dims, self.embed_dims), initializer='glorot_normal') + + + def call(self, inputs): + # inputs是一个列表 + # 先将所有的embedding拼接起来计算线性信号部分的输出 + concat_embed = Concatenate(axis=1)(inputs) # B x feat_nums x embed_dims + # 将两个矩阵都拉成二维的,然后通过矩阵相乘得到最终的结果 + concat_embed_ = tf.reshape(concat_embed, shape=[-1, self.feat_nums * self.embed_dims]) + lz = tf.matmul(concat_embed_, self.linear_w) # B x units + + # inner + lp_list = [] + if self.use_inner: + for i in range(self.units): + # 相当于给每一个特征向量都乘以一个权重 + # self.inner_w[i] : (embed_dims, ) 添加一个维度变成 (embed_dims, 1) + # concat_embed: B x feat_nums x embed_dims; delta = B x feat_nums x embed_dims + delta = tf.multiply(concat_embed, tf.expand_dims(self.inner_w[i], axis=1)) + # 在特征之间的维度上求和 + delta = tf.reduce_sum(delta, axis=1) # B x embed_dims + # 最终在特征embedding维度上求二范数得到p + lp_list.append(tf.reduce_sum(tf.square(delta), axis=1, keepdims=True)) # B x 1 + + # outer + if self.use_outer: + # 外积的优化是将embedding矩阵,在特征间的维度上通过求和进行压缩 + feat_sum = tf.reduce_sum(concat_embed, axis=1) # B x embed_dims + + # 为了方便计算外积,将维度进行扩展 + f1 = tf.expand_dims(feat_sum, axis=2) # B x embed_dims x 1 + f2 = tf.expand_dims(feat_sum, axis=1) # B x 1 x embed_dims + + # 求外积, a * a^T + product = tf.matmul(f1, f2) # B x embed_dims x embed_dims + + # 将product与外积权重矩阵对应元素相乘再相加 + for i in range(self.units): + lpi = tf.multiply(product, self.outer_w[i]) # B x embed_dims x embed_dims + # 将后面两个维度进行求和,需要注意的是,每使用一次reduce_sum就会减少一个维度 + lpi = tf.reduce_sum(lpi, axis=[1, 2]) # B + # 添加一个维度便于特征拼接 + lpi = tf.expand_dims(lpi, axis=1) # B x 1 + lp_list.append(lpi) + + # 将所有交叉特征拼接到一起 + lp = Concatenate(axis=1)(lp_list) + + # 将lz和lp拼接到一起 + product_out = Concatenate(axis=1)([lz, lp]) + + return product_out +``` + +因为这个模型的整体实现框架比较简单,就不画实现的草图了,直接看模型搭建的函数即可,对于PNN重点需要理解Product的两种类型及不同的优化方式。 + +下面是一个通过keras画的模型结构图,为了更好的显示,类别特征都只是选择了一小部分,画图的代码也在github中。 + + + +## 思考题 + +1. 降低复杂度的具体策略与具体的product函数选择有关,IPNN其实通过矩阵分解,“跳过”了显式的product层,而OPNN则是直接在product层入手进行优化。看原文去理解优化的动机及细节。 + + +**参考文献** +- [PNN原文论文](https://arxiv.org/pdf/1611.00144.pdf) +- [推荐系统系列(四):PNN理论与实践](https://zhuanlan.zhihu.com/p/89850560) +- [deepctr](https://github.com/shenweichen/DeepCTR) \ No newline at end of file diff --git a/4.人工智能/ch02/ch2.2/ch2.2.3/AFM.md b/4.人工智能/ch02/ch2.2/ch2.2.3/AFM.md new file mode 100644 index 0000000..8216485 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.3/AFM.md @@ -0,0 +1,127 @@ +# AFM +## AFM提出的动机 + +AFM的全称是Attentional Factorization Machines, 从模型的名称上来看是在FM的基础上加上了注意力机制,FM是通过特征隐向量的内积来对交叉特征进行建模,从公式中可以看出所有的交叉特征都具有相同的权重也就是1,没有考虑到不同的交叉特征的重要性程度: +$$ +y_{fm} = w_0+\sum_{i=1}^nw_ix_i+\sum_{i=1}^{n}\sum_{i+1}^n\lt v_i,v_j\gt x_ix_j +$$ +如何让不同的交叉特征具有不同的重要性就是AFM核心的贡献,在谈论AFM交叉特征注意力之前,对于FM交叉特征部分的改进还有FFM,其是考虑到了对于不同的其他特征,某个指定特征的隐向量应该是不同的(相比于FM对于所有的特征只有一个隐向量,FFM对于一个特征有多个不同的隐向量)。 + +## AFM模型原理 ++
++上图表示的就是AFM交叉特征部分的模型结构(非交叉部分与FM是一样的,图中并没有给出)。AFM最核心的两个点分别是Pair-wise Interaction Layer和Attention-based Pooling。前者将输入的非零特征的隐向量两两计算element-wise product(哈达玛积,两个向量对应元素相乘,得到的还是一个向量),假如输入的特征中的非零向量的数量为m,那么经过Pair-wise Interaction Layer之后输出的就是$\frac{m(m-1)}{2}$个向量,再将前面得到的交叉特征向量组输入到Attention-based Pooling,该pooling层会先计算出每个特征组合的自适应权重(通过Attention Net进行计算),通过加权求和的方式将向量组压缩成一个向量,由于最终需要输出的是一个数值,所以还需要将前一步得到的向量通过另外一个向量将其映射成一个值,得到最终的基于注意力加权的二阶交叉特征的输出。(对于这部分如果不是很清楚,可以先看下面对两个核心层的介绍) + +### Pair-wise Interaction Layer + +FM二阶交叉项:所有非零特征对应的隐向量两两点积再求和,输出的是一个数值 +$$ +\sum_{i=1}^{n}\sum_{i+1}^n\lt v_i,v_j\gt x_ix_j +$$ +AFM二阶交叉项(无attention):所有非零特征对应的隐向量两两对应元素乘积,然后再向量求和,输出的还是一个向量。 +$$ +\sum_{i=1}^{n}\sum_{i+1}^n (v_i \odot v_j) x_ix_j +$$ +上述写法是为了更好的与FM进行对比,下面将公式变形方便与原论文中保持一致。首先是特征的隐向量。从上图中可以看出,作者对数值特征也对应了一个隐向量,不同的数值乘以对应的隐向量就可以得到不同的隐向量,相对于onehot编码的特征乘以1还是其本身(并没有什么变化),其实就是为了将公式进行统一。虽然论文中给出了对数值特征定义隐向量,但是在作者的代码中并没有发现有对数值特征进行embedding的过程([原论文代码链接](https://github.com/hexiangnan/attentional_factorization_machine/blob/master/code/AFM.py))具体原因不详。 + +按照论文的意思,特征的embedding可以表示为:$\varepsilon = {v_ix_i}$,经过Pair-wise Interaction Layer输出可得: +$$ +f_{PI}(\varepsilon)=\{(v_i \odot v_j) x_ix_j\}_{i,j \in R_x} +$$ +$R_x$表示的是有效特征集合。此时的$f_{PI}(\varepsilon)$表示的是一个向量集合,所以需要先将这些向量集合聚合成一个向量,然后在转换成一个数值: +$$ +\hat{y} = p^T \sum_{(i,j)\in R_x}(v_i \odot v_j) x_ix_j + b +$$ +上式中的求和部分就是将向量集合聚合成一个维度与隐向量维度相同的向量,通过向量$p$再将其转换成一个数值,b表示的是偏置。 + +从开始介绍Pair-wise Interaction Layer到现在解决的一个问题是,如何将使用哈达玛积得到的交叉特征转换成一个最终输出需要的数值,到目前为止交叉特征之间的注意力权重还没有出现。在没有详细介绍注意力之前先感性的认识一下如果现在已经有了每个交叉特征的注意力权重,那么交叉特征的输出可以表示为: +$$ +\hat{y} = p^T \sum_{(i,j)\in R_x}\alpha_{ij}(v_i \odot v_j) x_ix_j + b +$$ +就是在交叉特征得到的新向量前面乘以一个注意力权重$\alpha_{ij}$, 那么这个注意力权重如何计算得到呢? + +### Attention-based Pooling + +对于神经网络注意力相关的基础知识大家可以去看一下邱锡鹏老师的《神经网络与深度学习》第8章注意力机制与外部记忆。这里简单的叙述一下使用MLP实现注意力机制的计算。假设现在有n个交叉特征(假如维度是k),将nxk的数据输入到一个kx1的全连接网络中,输出的张量维度为nx1,使用softmax函数将nx1的向量的每个维度进行归一化,得到一个新的nx1的向量,这个向量所有维度加起来的和为1,每个维度上的值就可以表示原nxk数据每一行(即1xk的数据)的权重。用公式表示为: +$$ +\alpha_{ij}' = h^T ReLU(W(v_i \odot v_j)x_ix_j + b) +$$ +使用softmax归一化可得: +$$ +\alpha_{ij} = \frac{exp(\alpha_{ij}')}{\sum_{(i,j)\in R_x}exp(\alpha_{ij}')} +$$ +这样就得到了AFM二阶交叉部分的注意力权重,如果将AFM的一阶项写在一起,AFM模型用公式表示为: +$$ +\hat{y}_{afm}(x) = w_0+\sum_{i=1}^nw_ix_i+p^T \sum_{(i,j)\in R_x}\alpha_{ij}(v_i \odot v_j) x_ix_j + b +$$ +### AFM模型训练 + +AFM从最终的模型公式可以看出与FM的模型公式是非常相似的,所以也可以和FM一样应用于不同的任务,例如分类、回归及排序(不同的任务的损失函数是不一样的),AFM也有对防止过拟合进行处理: + +1. 在Pair-wise Interaction Layer层的输出结果上使用dropout防止过拟合,因为并不是所有的特征组合对预测结果都有用,所以随机的去除一些交叉特征,让剩下的特征去自适应的学习可以更好的防止过拟合。 +2. 对Attention-based Pooling层中的权重矩阵$W$使用L2正则,作者没有在这一层使用dropout的原因是发现同时在特征交叉层和注意力层加dropout会使得模型训练不稳定,并且性能还会下降。 + +加上正则参数之后的回归任务的损失函数表示为: +$$ +L = \sum_{x\in T} (\hat{y}_{afm}(x) - y(x))^2 + \lambda ||W||^2 +$$ +## AFM代码实现 + +1. linear part: 这部分是有关于线性计算,也就是FM的前半部分$w1x1+w2x2...wnxn+b$的计算。对于这一块的计算,我们用了一个get_linear_logits函数实现,后面再说,总之通过这个函数,我们就可以实现上面这个公式的计算过程,得到linear的输出 +2. dnn part: 这部分是后面交叉特征的那部分计算,这一部分需要使用注意力机制来将所有类别特征的embedding计算注意力权重,然后通过加权求和的方式将所有交叉之后的特征池化成一个向量,最终通过一个映射矩阵$p$将向量转化成一个logits值 +3. 最终将linear部分与dnn部分相加之后,通过sigmoid激活得到最终的输出 + +```python +def AFM(linear_feature_columns, dnn_feature_columns): + # 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型 + dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns) + + # 将linear部分的特征中sparse特征筛选出来,后面用来做1维的embedding + linear_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns)) + + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层 + input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values()) + + # linear_logits由两部分组成,分别是dense特征的logits和sparse特征的logits + linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns) + + # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型 + # embedding层用户构建FM交叉部分和DNN的输入部分 + embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False) + + # 将输入到dnn中的sparse特征筛选出来 + att_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns)) + + att_logits = get_attention_logits(sparse_input_dict, att_sparse_feature_columns, embedding_layers) # B x (n(n-1)/2) + + # 将linear,dnn的logits相加作为最终的logits + output_logits = Add()([linear_logits, att_logits]) + + # 这里的激活函数使用sigmoid + output_layers = Activation("sigmoid")(output_logits) + + model = Model(input_layers, output_layers) + return model +``` + +关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。 + ++
++ +下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。 + ++
++ +## 思考 +1. AFM与NFM优缺点对比。 + + +**参考资料** +[原论文](https://www.ijcai.org/Proceedings/2017/0435.pdf) +[deepctr](https://github.com/shenweichen/DeepCTR) \ No newline at end of file diff --git a/4.人工智能/ch02/ch2.2/ch2.2.3/DeepFM.md b/4.人工智能/ch02/ch2.2/ch2.2.3/DeepFM.md new file mode 100644 index 0000000..93d532f --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.3/DeepFM.md @@ -0,0 +1,156 @@ +# DeepFM +## 动机 +对于CTR问题,被证明的最有效的提升任务表现的策略是特征组合(Feature Interaction), 在CTR问题的探究历史上来看就是如何更好地学习特征组合,进而更加精确地描述数据的特点。可以说这是基础推荐模型到深度学习推荐模型遵循的一个主要的思想。而组合特征大牛们研究过组合二阶特征,三阶甚至更高阶,但是面临一个问题就是随着阶数的提升,复杂度就成几何倍的升高。这样即使模型的表现更好了,但是推荐系统在实时性的要求也不能满足了。所以很多模型的出现都是为了解决另外一个更加深入的问题:如何更高效的学习特征组合? + +为了解决上述问题,出现了FM和FFM来优化LR的特征组合较差这一个问题。并且在这个时候科学家们已经发现了DNN在特征组合方面的优势,所以又出现了FNN和PNN等使用深度网络的模型。但是DNN也存在局限性。 + +- **DNN局限** +当我们使用DNN网络解决推荐问题的时候存在网络参数过于庞大的问题,这是因为在进行特征处理的时候我们需要使用one-hot编码来处理离散特征,这会导致输入的维度猛增。这里借用AI大会的一张图片: ++
++ +这样庞大的参数量也是不实际的。为了解决DNN参数量过大的局限性,可以采用非常经典的Field思想,将OneHot特征转换为Dense Vector ++
++ +此时通过增加全连接层就可以实现高阶的特征组合,如下图所示: ++
++但是仍然缺少低阶的特征组合,于是增加FM来表示低阶的特征组合。 + +- **FNN和PNN** +结合FM和DNN其实有两种方式,可以并行结合也可以串行结合。这两种方式各有几种代表模型。在DeepFM之前有FNN,虽然在影响力上可能并不如DeepFM,但是了解FNN的思想对我们理解DeepFM的特点和优点是很有帮助的。 + ++
++ +FNN是使用预训练好的FM模块,得到隐向量,然后把隐向量作为DNN的输入,但是经过实验进一步发现,在Embedding layer和hidden layer1之间增加一个product层(如上图所示)可以提高模型的表现,所以提出了PNN,使用product layer替换FM预训练层。 + +- **Wide&Deep** +FNN和PNN模型仍然有一个比较明显的尚未解决的缺点:对于低阶组合特征学习到的比较少,这一点主要是由于FM和DNN的串行方式导致的,也就是虽然FM学到了低阶特征组合,但是DNN的全连接结构导致低阶特征并不能在DNN的输出端较好的表现。看来我们已经找到问题了,将串行方式改进为并行方式能比较好的解决这个问题。于是Google提出了Wide&Deep模型(将前几章),但是如果深入探究Wide&Deep的构成方式,虽然将整个模型的结构调整为了并行结构,在实际的使用中Wide Module中的部分需要较为精巧的特征工程,换句话说人工处理对于模型的效果具有比较大的影响(这一点可以在Wide&Deep模型部分得到验证)。 ++
++如上图所示,该模型仍然存在问题:**在output Units阶段直接将低阶和高阶特征进行组合,很容易让模型最终偏向学习到低阶或者高阶的特征,而不能做到很好的结合。** + +综上所示,DeepFM模型横空出世。 + +## 模型的结构与原理 ++
++ +前面的Field和Embedding处理是和前面的方法是相同的,如上图中的绿色部分;DeepFM将Wide部分替换为了FM layer如上图中的蓝色部分 + +这幅图其实有很多的点需要注意,很多人都一眼略过了,这里我个人认为在DeepFM模型中有三点需要注意: + + - **Deep模型部分** + - **FM模型部分** + - **Sparse Feature中黄色和灰色节点代表什么意思** + +### FM +详细内容参考FM模型部分的内容,下图是FM的一个结构图,从图中大致可以看出FM Layer是由一阶特征和二阶特征Concatenate到一起在经过一个Sigmoid得到logits(结合FM的公式一起看),所以在实现的时候需要单独考虑linear部分和FM交叉特征部分。 +$$ +\hat{y}_{FM}(x) = w_0+\sum_{i=1}^N w_ix_i + \sum_{i=1}^N \sum_{j=i+1}^N v_i^T v_j x_ix_j +$$ ++
++### Deep +Deep架构图 ++
++Deep Module是为了学习高阶的特征组合,在上图中使用用全连接的方式将Dense Embedding输入到Hidden Layer,这里面Dense Embeddings就是为了解决DNN中的参数爆炸问题,这也是推荐模型中常用的处理方法。 + +Embedding层的输出是将所有id类特征对应的embedding向量concat到到一起输入到DNN中。其中$v_i$表示第i个field的embedding,m是field的数量。 +$$ +z_1=[v_1, v_2, ..., v_m] +$$ +上一层的输出作为下一层的输入,我们得到: +$$ +z_L=\sigma(W_{L-1} z_{L-1}+b_{L-1}) +$$ +其中$\sigma$表示激活函数,$z, W, b $分别表示该层的输入、权重和偏置。 + +最后进入DNN部分输出使用sigmod激活函数进行激活: +$$ +y_{DNN}=\sigma(W^{L}a^L+b^L) +$$ + + +## 代码实现 +DeepFM在模型的结构图中显示,模型大致由两部分组成,一部分是FM,还有一部分就是DNN, 而FM又由一阶特征部分与二阶特征交叉部分组成,所以可以将整个模型拆成三部分,分别是一阶特征处理linear部分,二阶特征交叉FM以及DNN的高阶特征交叉。在下面的代码中也能够清晰的看到这个结构。此外每一部分可能由是由不同的特征组成,所以在构建模型的时候需要分别对这三部分输入的特征进行选择。 + +- linear_logits: 这部分是有关于线性计算,也就是FM的前半部分$w1x1+w2x2...wnxn+b$的计算。对于这一块的计算,我们用了一个get_linear_logits函数实现,后面再说,总之通过这个函数,我们就可以实现上面这个公式的计算过程,得到linear的输出, 这部分特征由数值特征和类别特征的onehot编码组成的一维向量组成,实际应用中根据自己的业务放置不同的一阶特征(这里的dense特征并不是必须的,有可能会将数值特征进行分桶,然后在当做类别特征来处理) +- fm_logits: 这一块主要是针对离散的特征,首先过embedding,然后使用FM特征交叉的方式,两两特征进行交叉,得到新的特征向量,最后计算交叉特征的logits +- dnn_logits: 这一块主要是针对离散的特征,首先过embedding,然后将得到的embedding拼接成一个向量(具体的可以看代码,也可以看一下下面的模型结构图),通过dnn学习类别特征之间的隐式特征交叉并输出logits值 + +```python +def DeepFM(linear_feature_columns, dnn_feature_columns): + # 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型 + dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns) + + # 将linear部分的特征中sparse特征筛选出来,后面用来做1维的embedding + linear_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns)) + + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层 + input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values()) + + # linear_logits由两部分组成,分别是dense特征的logits和sparse特征的logits + linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns) + + # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型 + # embedding层用户构建FM交叉部分和DNN的输入部分 + embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False) + + # 将输入到dnn中的所有sparse特征筛选出来 + dnn_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns)) + + fm_logits = get_fm_logits(sparse_input_dict, dnn_sparse_feature_columns, embedding_layers) # 只考虑二阶项 + + # 将所有的Embedding都拼起来,一起输入到dnn中 + dnn_logits = get_dnn_logits(sparse_input_dict, dnn_sparse_feature_columns, embedding_layers) + + # 将linear,FM,dnn的logits相加作为最终的logits + output_logits = Add()([linear_logits, fm_logits, dnn_logits]) + + # 这里的激活函数使用sigmoid + output_layers = Activation("sigmoid")(output_logits) + + model = Model(input_layers, output_layers) + return model +``` + +关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。 + ++
++ +下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。 + ++
++ +## 思考 +1. 如果对于FM采用随机梯度下降SGD训练模型参数,请写出模型各个参数的梯度和FM参数训练的复杂度 +2. 对于下图所示,根据你的理解Sparse Feature中的不同颜色节点分别表示什么意思 + ++
++ + +**参考资料** +- [论文原文](https://arxiv.org/pdf/1703.04247.pdf) +- [deepctr](https://github.com/shenweichen/DeepCTR) +- [FM](https://github.com/datawhalechina/team-learning-rs/blob/master/RecommendationSystemFundamentals/04%20FM.md) +- [推荐系统遇上深度学习(三)--DeepFM模型理论和实践](https://www.jianshu.com/p/6f1c2643d31b) +- [FM算法公式推导](https://blog.csdn.net/qq_32486393/article/details/103498519) \ No newline at end of file diff --git a/4.人工智能/ch02/ch2.2/ch2.2.3/NFM.md b/4.人工智能/ch02/ch2.2/ch2.2.3/NFM.md new file mode 100644 index 0000000..0ccd2ca --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.3/NFM.md @@ -0,0 +1,146 @@ +# NFM +## 动机 + +NFM(Neural Factorization Machines)是2017年由新加坡国立大学的何向南教授等人在SIGIR会议上提出的一个模型,传统的FM模型仅局限于线性表达和二阶交互, 无法胜任生活中各种具有复杂结构和规律性的真实数据, 针对FM的这点不足, 作者提出了一种将FM融合进DNN的策略,通过引进了一个特征交叉池化层的结构,使得FM与DNN进行了完美衔接,这样就组合了FM的建模低阶特征交互能力和DNN学习高阶特征交互和非线性的能力,形成了深度学习时代的神经FM模型(NFM)。 + +那么NFM具体是怎么做的呢? 首先看一下NFM的公式: +$$ +\hat{y}_{N F M}(\mathbf{x})=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+f(\mathbf{x}) +$$ +我们对比FM, 就会发现变化的是第三项,前两项还是原来的, 因为我们说FM的一个问题,就是只能到二阶交叉, 且是线性模型, 这是他本身的一个局限性, 而如果想突破这个局限性, 就需要从他的公式本身下点功夫, 于是乎,作者在这里改进的思路就是**用一个表达能力更强的函数来替代原FM中二阶隐向量内积的部分**。 + ++
++而这个表达能力更强的函数呢, 我们很容易就可以想到神经网络来充当,因为神经网络理论上可以拟合任何复杂能力的函数, 所以作者真的就把这个$f(x)$换成了一个神经网络,当然不是一个简单的DNN, 而是依然底层考虑了交叉,然后高层使用的DNN网络, 这个也就是我们最终的NFM网络了: ++
++这个结构,如果前面看过了PNN的伙伴会发现,这个结构和PNN非常像,只不过那里是一个product_layer, 而这里换成了Bi-Interaction Pooling了, 这个也是NFM的核心结构了。这里注意, 这个结构中,忽略了一阶部分,只可视化出来了$f(x)$, 我们还是下面从底层一点点的对这个网络进行剖析。 + +## 模型结构与原理 +### Input 和Embedding层 +输入层的特征, 文章指定了稀疏离散特征居多, 这种特征我们也知道一般是先one-hot, 然后会通过embedding,处理成稠密低维的。 所以这两层还是和之前一样,假设$\mathbf{v}_{\mathbf{i}} \in \mathbb{R}^{k}$为第$i$个特征的embedding向量, 那么$\mathcal{V}_{x}=\left\{x_{1} \mathbf{v}_{1}, \ldots, x_{n} \mathbf{v}_{n}\right\}$表示的下一层的输入特征。这里带上了$x_i$是因为很多$x_i$转成了One-hot之后,出现很多为0的, 这里的$\{x_iv_i\}$是$x_i$不等于0的那些特征向量。 + +### Bi-Interaction Pooling layer +在Embedding层和神经网络之间加入了特征交叉池化层是本网络的核心创新了,正是因为这个结构,实现了FM与DNN的无缝连接, 组成了一个大的网络,且能够正常的反向传播。假设$\mathcal{V}_{x}$是所有特征embedding的集合, 那么在特征交叉池化层的操作: +$$ +f_{B I}\left(\mathcal{V}_{x}\right)=\sum_{i=1}^{n} \sum_{j=i+1}^{n} x_{i} \mathbf{v}_{i} \odot x_{j} \mathbf{v}_{j} +$$ + +$\odot$表示两个向量的元素积操作,即两个向量对应维度相乘得到的元素积向量(可不是点乘呀),其中第$k$维的操作: +$$ +\left(v_{i} \odot v_{j}\right)_{k}=\boldsymbol{v}_{i k} \boldsymbol{v}_{j k} +$$ + +这便定义了在embedding空间特征的二阶交互,这个不仔细看会和感觉FM的最后一项很像,但是不一样,一定要注意这个地方不是两个隐向量的内积,而是元素积,也就是这一个交叉完了之后k个维度不求和,最后会得到一个$k$维向量,而FM那里内积的话最后得到一个数, 在进行两两Embedding元素积之后,对交叉特征向量取和, 得到该层的输出向量, 很显然, 输出是一个$k$维的向量。 + +注意, 之前的FM到这里其实就完事了, 上面就是输出了,而这里很大的一点改进就是加入特征池化层之后, 把二阶交互的信息合并, 且上面接了一个DNN网络, 这样就能够增强FM的表达能力了, 因为FM只能到二阶, 而这里的DNN可以进行多阶且非线性,只要FM把二阶的学习好了, DNN这块学习来会更加容易, 作者在论文中也说明了这一点,且通过后面的实验证实了这个观点。 + +如果不加DNN, NFM就退化成了FM,所以改进的关键就在于加了一个这样的层,组合了一下二阶交叉的信息,然后又给了DNN进行高阶交叉的学习,成了一种“加强版”的FM。 + +Bi-Interaction层不需要额外的模型学习参数,更重要的是它在一个线性的时间内完成计算,和FM一致的,即时间复杂度为$O\left(k N_{x}\right)$,$N_x$为embedding向量的数量。参考FM,可以将上式转化为: +$$ +f_{B I}\left(\mathcal{V}_{x}\right)=\frac{1}{2}\left[\left(\sum_{i=1}^{n} x_{i} \mathbf{v}_{i}\right)^{2}-\sum_{i=1}^{n}\left(x_{i} \mathbf{v}_{i}\right)^{2}\right] +$$ +后面代码复现NFM就是用的这个公式直接计算,比较简便且清晰。 + +### 隐藏层 +这一层就是全连接的神经网络, DNN在进行特征的高层非线性交互上有着天然的学习优势,公式如下: +$$ +\begin{aligned} +\mathbf{z}_{1}=&\sigma_{1}\left(\mathbf{W}_{1} f_{B I} +\left(\mathcal{V}_{x}\right)+\mathbf{b}_{1}\right) \\ +\mathbf{z}_{2}=& \sigma_{2}\left(\mathbf{W}_{2} \mathbf{z}_{1}+\mathbf{b}_{2}\right) \\ +\ldots \ldots \\ +\mathbf{z}_{L}=& \sigma_{L}\left(\mathbf{W}_{L} \mathbf{z}_{L-1}+\mathbf{b}_{L}\right) +\end{aligned} +$$ +这里的$\sigma_i$是第$i$层的激活函数,可不要理解成sigmoid激活函数。 + +### 预测层 +这个就是最后一层的结果直接过一个隐藏层,但注意由于这里是回归问题,没有加sigmoid激活: +$$ +f(\mathbf{x})=\mathbf{h}^{T} \mathbf{z}_{L} +$$ + +所以, NFM模型的前向传播过程总结如下: +$$ +\begin{aligned} +\hat{y}_{N F M}(\mathbf{x}) &=w_{0}+\sum_{i=1}^{n} w_{i} x_{i} \\ +&+\mathbf{h}^{T} \sigma_{L}\left(\mathbf{W}_{L}\left(\ldots \sigma_{1}\left(\mathbf{W}_{1} f_{B I}\left(\mathcal{V}_{x}\right)+\mathbf{b}_{1}\right) \ldots\right)+\mathbf{b}_{L}\right) +\end{aligned} +$$ +这就是NFM模型的全貌, NFM相比较于其他模型的核心创新点是特征交叉池化层,基于它,实现了FM和DNN的无缝连接,使得DNN可以在底层就学习到包含更多信息的组合特征,这时候,就会减少DNN的很多负担,只需要很少的隐藏层就可以学习到高阶特征信息。NFM相比之前的DNN, 模型结构更浅,更简单,但是性能更好,训练和调参更容易。集合FM二阶交叉线性和DNN高阶交叉非线性的优势,非常适合处理稀疏数据的场景任务。在对NFM的真实训练过程中,也会用到像Dropout和BatchNormalization这样的技术来缓解过拟合和在过大的改变数据分布。 + +下面通过代码看下NFM的具体实现过程, 学习一些细节。 + +## 代码实现 +下面我们看下NFM的代码复现,这里主要是给大家说一下这个模型的设计逻辑,参考了deepctr的函数API的编程风格, 具体的代码以及示例大家可以去参考后面的GitHub,里面已经给出了详细的注释, 这里主要分析模型的逻辑这块。关于函数API的编程式风格,我们还给出了一份文档, 大家可以先看这个,再看后面的代码部分,会更加舒服些。下面开始: + +这里主要说一下NFM模型的总体运行逻辑, 这样可以让大家从宏观的层面去把握模型的设计过程, 该模型所使用的数据集是criteo数据集,具体介绍参考后面的GitHub。 数据集的特征会分为dense特征(连续)和sparse特征(离散), 所以模型的输入层接收这两种输入。但是我们这里把输入分成了linear input和dnn input两种情况,而每种情况都有可能包含上面这两种输入。因为我们后面的模型逻辑会分这两部分走,这里有个细节要注意,就是光看上面那个NFM模型的话,是没有看到它线性特征处理的那部分的,也就是FM的前半部分公式那里图里面是没有的。但是这里我们要加上。 +$$ +\hat{y}_{N F M}(\mathbf{x})=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+f(\mathbf{x}) +$$ +所以模型的逻辑我们分成了两大部分,这里我分别给大家解释下每一块做了什么事情: + +1. linear part: 这部分是有关于线性计算,也就是FM的前半部分$w1x1+w2x2...wnxn+b$的计算。对于这一块的计算,我们用了一个get_linear_logits函数实现,后面再说,总之通过这个函数,我们就可以实现上面这个公式的计算过程,得到linear的输出 +2. dnn part: 这部分是后面交叉特征的那部分计算,FM的最后那部分公式f(x)。 这一块主要是针对离散的特征,首先过embedding, 然后过特征交叉池化层,这个计算我们用了get_bi_interaction_pooling_output函数实现, 得到输出之后又过了DNN网络,最后得到dnn的输出 + +模型的最后输出结果,就是把这两个部分的输出结果加和(当然也可以加权),再过一个sigmoid得到。所以NFM的模型定义就出来了: + +```python +def NFM(linear_feature_columns, dnn_feature_columns): + """ + 搭建NFM模型,上面已经把所有组块都写好了,这里拼起来就好 + :param linear_feature_columns: A list. 里面的每个元素是namedtuple(元组的一种扩展类型,同时支持序号和属性名访问组件)类型,表示的是linear数据的特征封装版 + :param dnn_feature_columns: A list. 里面的每个元素是namedtuple(元组的一种扩展类型,同时支持序号和属性名访问组件)类型,表示的是DNN数据的特征封装版 + """ + # 构建输入层,即所有特征对应的Input()层, 这里使用字典的形式返回, 方便后续构建模型 + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层 + dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns+dnn_feature_columns) + input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values()) + + # 线性部分的计算 w1x1 + w2x2 + ..wnxn + b部分,dense特征和sparse两部分的计算结果组成,具体看上面细节 + linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns) + + # DNN部分的计算 + # 首先,在这里构建DNN部分的embedding层,之所以写在这里,是为了灵活的迁移到其他网络上,这里用字典的形式返回 + # embedding层用于构建FM交叉部分以及DNN的输入部分 + embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False) + + # 过特征交叉池化层 + pooling_output = get_bi_interaction_pooling_output(sparse_input_dict, dnn_feature_columns, embedding_layers) + + # 加个BatchNormalization + pooling_output = BatchNormalization()(pooling_output) + + # dnn部分的计算 + dnn_logits = get_dnn_logits(pooling_output) + + # 线性部分和dnn部分的结果相加,最后再过个sigmoid + output_logits = Add()([linear_logits, dnn_logits]) + output_layers = Activation("sigmoid")(output_logits) + + model = Model(inputs=input_layers, outputs=output_layers) + + return model +``` + +有了上面的解释,这个模型的宏观层面相信就很容易理解了。关于这每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播。(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。 ++
++下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。 ++
++ +## 思考题 +1. NFM中的特征交叉与FM中的特征交叉有何异同,分别从原理和代码实现上进行对比分析 + +**参考资料** +- [论文原文](https://arxiv.org/pdf/1708.05027.pdf) +- [deepctr](https://github.com/shenweichen/DeepCTR) +- [AI上推荐 之 FNN、DeepFM与NFM(FM在深度学习中的身影重现)](https://blog.csdn.net/wuzhongqiang/article/details/109532267?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522161442951716780255224635%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=161442951716780255224635&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_v1~rank_blog_v1-1-109532267.pc_v1_rank_blog_v1&utm_term=NFM) diff --git a/4.人工智能/ch02/ch2.2/ch2.2.3/WideNDeep.md b/4.人工智能/ch02/ch2.2/ch2.2.3/WideNDeep.md new file mode 100644 index 0000000..a42f087 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.3/WideNDeep.md @@ -0,0 +1,117 @@ +# Wide & Deep +## 动机 + +在CTR预估任务中利用手工构造的交叉组合特征来使线性模型具有“记忆性”,使模型记住共现频率较高的特征组合,往往也能达到一个不错的baseline,且可解释性强。但这种方式有着较为明显的缺点: + +1. 特征工程需要耗费太多精力。 +2. 模型是强行记住这些组合特征的,对于未曾出现过的特征组合,权重系数为0,无法进行泛化。 + +为了加强模型的泛化能力,研究者引入了DNN结构,将高维稀疏特征编码为低维稠密的Embedding vector,这种基于Embedding的方式能够有效提高模型的泛化能力。但是,基于Embedding的方式可能因为数据长尾分布,导致长尾的一些特征值无法被充分学习,其对应的Embedding vector是不准确的,这便会造成模型泛化过度。 + +Wide&Deep模型就是围绕记忆性和泛化性进行讨论的,模型能够从历史数据中学习到高频共现的特征组合的能力,称为是模型的Memorization。能够利用特征之间的传递性去探索历史数据中从未出现过的特征组合,称为是模型的Generalization。Wide&Deep兼顾Memorization与Generalization并在Google Play store的场景中成功落地。 + +## 模型结构及原理 ++
++ +其实wide&deep模型本身的结构是非常简单的,对于有点机器学习基础和深度学习基础的人来说都非常的容易看懂,但是如何根据自己的场景去选择那些特征放在Wide部分,哪些特征放在Deep部分就需要理解这篇论文提出者当时对于设计该模型不同结构时的意图了,所以这也是用好这个模型的一个前提。 + +**如何理解Wide部分有利于增强模型的“记忆能力”,Deep部分有利于增强模型的“泛化能力”?** + +- wide部分是一个广义的线性模型,输入的特征主要有两部分组成,一部分是原始的部分特征,另一部分是原始特征的交叉特征(cross-product transformation),对于交互特征可以定义为: + $$ + \phi_{k}(x)=\prod_{i=1}^d x_i^{c_{ki}}, c_{ki}\in \{0,1\} + $$ + $c_{ki}$是一个布尔变量,当第i个特征属于第k个特征组合时,$c_{ki}$的值为1,否则为0,$x_i$是第i个特征的值,大体意思就是两个特征都同时为1这个新的特征才能为1,否则就是0,说白了就是一个特征组合。用原论文的例子举例: + + > AND(user_installed_app=QQ, impression_app=WeChat),当特征user_installed_app=QQ,和特征impression_app=WeChat取值都为1的时候,组合特征AND(user_installed_app=QQ, impression_app=WeChat)的取值才为1,否则为0。 + + 对于wide部分训练时候使用的优化器是带$L_1$正则的FTRL算法(Follow-the-regularized-leader),而L1 FTLR是非常注重模型稀疏性质的,也就是说W&D模型采用L1 FTRL是想让Wide部分变得更加的稀疏,即Wide部分的大部分参数都为0,这就大大压缩了模型权重及特征向量的维度。**Wide部分模型训练完之后留下来的特征都是非常重要的,那么模型的“记忆能力”就可以理解为发现"直接的",“暴力的”,“显然的”关联规则的能力。**例如Google W&D期望wide部分发现这样的规则:**用户安装了应用A,此时曝光应用B,用户安装应用B的概率大。** + +- Deep部分是一个DNN模型,输入的特征主要分为两大类,一类是数值特征(可直接输入DNN),一类是类别特征(需要经过Embedding之后才能输入到DNN中),Deep部分的数学形式如下: + $$ + a^{(l+1)} = f(W^{l}a^{(l)} + b^{l}) + $$ + **我们知道DNN模型随着层数的增加,中间的特征就越抽象,也就提高了模型的泛化能力。**对于Deep部分的DNN模型作者使用了深度学习常用的优化器AdaGrad,这也是为了使得模型可以得到更精确的解。 + +**Wide部分与Deep部分的结合** + +W&D模型是将两部分输出的结果结合起来联合训练,将deep和wide部分的输出重新使用一个逻辑回归模型做最终的预测,输出概率值。联合训练的数学形式如下:需要注意的是,因为Wide侧的数据是高维稀疏的,所以作者使用了FTRL算法优化,而Deep侧使用的是 Adagrad。 +$$ +P(Y=1|x)=\delta(w_{wide}^T[x,\phi(x)] + w_{deep}^T a^{(lf)} + b) +$$ + +## 代码实现 + +Wide侧记住的是历史数据中那些**常见、高频**的模式,是推荐系统中的“**红海**”。实际上,Wide侧没有发现新的模式,只是学习到这些模式之间的权重,做一些模式的筛选。正因为Wide侧不能发现新模式,因此我们需要**根据人工经验、业务背景,将我们认为有价值的、显而易见的特征及特征组合,喂入Wide侧** + +Deep侧就是DNN,通过embedding的方式将categorical/id特征映射成稠密向量,让DNN学习到这些特征之间的**深层交叉**,以增强扩展能力。 + +模型的实现与模型结构类似由deep和wide两部分组成,这两部分结构所需要的特征在上面已经说过了,针对当前数据集实现,我们在wide部分加入了所有可能的一阶特征,包括数值特征和类别特征的onehot都加进去了,其实也可以加入一些与wide&deep原论文中类似交叉特征。只要能够发现高频、常见模式的特征都可以放在wide侧,对于Deep部分,在本数据中放入了数值特征和类别特征的embedding特征,实际应用也需要根据需求进行选择。 + +```python +# Wide&Deep 模型的wide部分及Deep部分的特征选择,应该根据实际的业务场景去确定哪些特征应该放在Wide部分,哪些特征应该放在Deep部分 +def WideNDeep(linear_feature_columns, dnn_feature_columns): + # 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型 + dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns) + + # 将linear部分的特征中sparse特征筛选出来,后面用来做1维的embedding + linear_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns)) + + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层 + input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values()) + + # Wide&Deep模型论文中Wide部分使用的特征比较简单,并且得到的特征非常的稀疏,所以使用了FTRL优化Wide部分(这里没有实现FTRL) + # 但是是根据他们业务进行选择的,我们这里将所有可能用到的特征都输入到Wide部分,具体的细节可以根据需求进行修改 + linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns) + + # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型 + embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False) + + dnn_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns)) + + # 在Wide&Deep模型中,deep部分的输入是将dense特征和embedding特征拼在一起输入到dnn中 + dnn_logits = get_dnn_logits(dense_input_dict, sparse_input_dict, dnn_sparse_feature_columns, embedding_layers) + + # 将linear,dnn的logits相加作为最终的logits + output_logits = Add()([linear_logits, dnn_logits]) + + # 这里的激活函数使用sigmoid + output_layer = Activation("sigmoid")(output_logits) + + model = Model(input_layers, output_layer) + return model +``` + +关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播。(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。 + ++
++ +下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。 + ++
++ +## 思考 +1. 在你的应用场景中,哪些特征适合放在Wide侧,哪些特征适合放在Deep侧,为什么呢? +2. 为什么Wide部分要用L1 FTRL训练? +3. 为什么Deep部分不特别考虑稀疏性的问题? + +思考题可以参考[见微知著,你真的搞懂Google的Wide&Deep模型了吗?](https://zhuanlan.zhihu.com/p/142958834) + + +**参考资料** +- [论文原文](https://arxiv.org/pdf/1606.07792.pdf) +- [deepctr](https://github.com/shenweichen/DeepCTR) +- [看Google如何实现Wide & Deep模型(1)](https://zhuanlan.zhihu.com/p/47293765) +- [推荐系统系列(六):Wide&Deep理论与实践](https://zhuanlan.zhihu.com/p/92279796?utm_source=wechat_session&utm_medium=social&utm_oi=753565305866829824&utm_campaign=shareopn) +- [见微知著,你真的搞懂Google的Wide&Deep模型了吗?](https://zhuanlan.zhihu.com/p/142958834) +- [用NumPy手工打造 Wide & Deep](https://zhuanlan.zhihu.com/p/53110408) +- [tensorflow官网的WideDeepModel](https://www.tensorflow.org/api_docs/python/tf/keras/experimental/WideDeepModel) +- [详解 Wide & Deep 结构背后的动机](https://zhuanlan.zhihu.com/p/53361519) + diff --git a/4.人工智能/ch02/ch2.2/ch2.2.3/xDeepFM.md b/4.人工智能/ch02/ch2.2/ch2.2.3/xDeepFM.md new file mode 100644 index 0000000..c856d2c --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.3/xDeepFM.md @@ -0,0 +1,565 @@ +## 写在前面 +xDeepFM(eXtreme DeepFM),这是2018年中科大联合微软在KDD上提出的一个模型,在DeepFM的前面加了一个eXtreme,看这个名字,貌似是DeepFM的加强版,但当我仔细的读完原文之后才发现,如果论血缘关系,这个模型应该离着DCN更近一些,这个模型的改进出发点依然是如何更好的学习特征之间的高阶交互作用,从而挖掘更多的交互信息。而基于这样的动机,作者提出了又一个更powerful的网络来完成特征间的高阶显性交互(DCN的话是一个交叉网络), 这个网络叫做CIN(Compressed Interaction Network),这个网络也是xDeepFM的亮点或者核心创新点了(牛x的地方), 有了这个网络才使得这里的"Deep"变得名副其实。而xDeepFM的模型架构依然是w&D结构,更好的理解方式就是用这个CIN网络代替了DCN里面的Cross Network, 这样使得该网络同时能够显性和隐性的学习特征的高阶交互(显性由CIN完成,隐性由DNN完成)。 那么为啥需要同时学习特征的显性和隐性高阶交互呢? 为啥会用CIN代替Cross Network呢? CIN到底有什么更加强大之处呢? xDeepFM与之前的DeepFM以及FM的关系是怎样的呢? 这些问题都会在后面一一揭晓。 + +这篇文章的逻辑和前面一样,首先依然是介绍xDeepFM的理论部分和论文里面的细节,我觉得这篇文章的创新思路还是非常厉害的,也就是CIN的结构,在里面是会看到RNN和CNN的身影的,又会看到Cross Network的身影。所以这个结构我这次也是花了一些时间去理解,花了一些时间找解读文章看, 但讲真,解读文章真没有论文里面讲的清晰,所以我这次整理也是完全基于原论文加上我自己的理解进行解读。 当然,我水平有限,难免有理解不到位的地方,如果发现有错,也麻烦各位大佬帮我指出来呀。这样优秀的一个模型,不管是工业上还是面试里面,也是非常喜欢用或者考的内容,所以后面依然参考deepctr的代码,进行简化版的复现,重点看看CIN结构的实现过程。最后就是简单介绍和小总。 + +这篇文章依然比较长,首先是CIN结构本身可能比较难理解,前面需要一定的铺垫任务,比如一些概念(显隐性交叉,bit-wise和vector-wise等), 一些基础模型(FM,FNN,PNN,DNN等),DCN的Cross Network,有了这些铺垫后再理解CIN以及操作会简单些,而CIN本身运算也可能比较复杂,再加上里面时间复杂度和空间复杂度那块的分析,还有后面实验的各个小细节以最后论文还帮助我们串联了各种模型,我想在这篇文章中都整理一下。 再加上模型的复现内容,所以篇幅上还是会很长,各取所需吧还是哈哈。当然这篇文章的重点还是在CIN,这个也是面试里面非常喜欢问的点。 + +## xDeepFM? 我们需要先了解这些 +### 简介与进化动机 +再具体介绍xDeepFM之前,想先整理点铺垫的知识,也是以前的一些内容,是基于原论文的Introduction部分摘抄了一些,算是对前面内容的一些回顾吧,因为这段时间一直忙着找实习,也已经好久没有写这个系列的相关文章了。所以多少还是有点风格和知识上的遗忘哈哈。 + +首先是在推荐系统里面, 一般原始的特征很难让模型学习到隐藏在数据背后的规律,因为推荐系统中的原始特征往往非常稀疏,且维度非常高。所以如果想得到一个好的推荐系统,我们必须尽可能的制作更多的特征出来,而特征组合往往是比较好的方式,毕竟特征一般都不是独立存在的,那么特征究竟怎么组合呢? 这是一个比较值得研究的难题,并且好多学者在这上面也下足了工夫。 如果你说,特征组合是啥来? 不太清楚了呀,那么文章中这个例子正好能解决你的疑问 + ++
++ +起初的时候,是人工特征组合,这个往往在作比赛的时候会遇到,就是特征工程里面自己进行某些特征的交叉与组合来生成新的特征。 这样的方式会有几个问题,作者在论文里面总结了: +1. 一般需要一些经验和时间才会得到比较好的特征组合,也就是找这样的组合对于人来说有着非常高的要求,无脑组合不可取 --- 需要一定的经验,耗费大量的时间 +2. 由于推荐系统中数据的维度规模太大了,如果人工进行组合,根本就不太可能实现 --- 特征过多,无法全面顾及特征的组合 +3. 手工制作的特征也没有一定的泛化能力,而恰巧推荐系统中的数据往往又非常稀疏 --- 手工组合无泛化能力 + +所以,让**模型自动的进行特征交叉组合**探索成了推荐模型里面的比较重要的一个任务,还记得吗? 这个也是模型进化的方向之一,之所以从前面进行引出,是因为本质上这篇的主角xDeepFM也是从这个方向上进行的探索, 那么既然又探索,那也说明了前面模型在这方面还有一定的问题,那么我们就来再综合理一理。 + +1. FM模型: 这个模型能够自动学习特征之间的两两交叉,并且比较厉害的地方就是用特征的隐向量内积去表示两两交叉后特征的重要程度,这使得模型在学习交互信息的同时,也让模型有了一定的泛化能力。 But,这个模型也有缺点,首先一般是只能应付特征的两两交叉,再高阶一点的交叉虽然行,但计算复杂,并且作者在论文中提到了高阶交叉的FM模型是不管有用还是无用的交叉都建模,这往往会带来一定的噪声。 +2. DNN模型: 这个非常熟悉了,深度学习到来之后,推荐模型的演化都朝着DNN的时代去了,原因之一就是因为DNN的多层神经网络可以比较出色的完成特征之间的高阶交互,只需要增加网络的层数,就可以轻松的学习交互,这是DNN的优势所在。 比较有代表的模型PNN,DeepCrossing模型等。 But, DNN并不是非常可靠,有下面几个问题。 + 1. 首先,DNN的这种特征交互是隐性的,后面会具体说显隐性交互区别,但直观上理解,这种隐性交互我们是无法看到到底特征之间是怎么交互的,具体交互到了几阶,这些都是带有一定的不可解释性。 + 2. 其次,DNN是bit-wise层级的交叉,关于bit-wise,后面会说,这种方式论文里面说一个embedding向量里面的各个元素也会相互影响, 这样我觉得在这里带来的一个问题就是可能会发生过拟合。 因为我们知道embedding向量的表示方法就是想从不同的角度去看待某个商品(比如颜色,价格,质地等),当然embedding各个维度是无可解释性的,但我们还是希望这各个维度更加独立一点好,也就是相关性不那么大为妙,这样的话往往能更加表示出各个商品的区别来。 但如果这各个维度上的元素也互相影响了(神经网络会把这个也学习进去), 那过拟合的风险会变大。当然,上面这个是我自己的感觉, 原作者只是给了这样一段:+
+ +++ + 也就是**DNN是否能够真正的有效学习特征之间的高阶交互是个谜!** + 3. DNN还存在的问题就是学习高阶交互或许是可能,但没法再兼顾低阶交互,也就是记忆能力,所以后面w&D架构在成为了主流架构。 +3. Wide&Deep, DeepFM模型: 这两个模型是既有DNN的深度,也有FM或者传统模型的广度,兼顾记忆能力和泛化能力,也是后面的主流模型。但依然有不足之处,wide&Deep的话不用多讲,首先逻辑回归(宽度部分)仍然需要人工特征交叉,而深度部分的高阶交叉又是一个谜。 DeepFM的话是FM和DNN的组合,用FM替代了逻辑回归,这样至少是模型的自动交叉特征,结合了FM的二阶以及DNN的高阶交叉能力。 但如果DNN这块高度交叉是个谜的话,就有点玄乎了。 +4. DCN网络: 这个模型也是w&D架构,不过宽度那部分使用了一个Cross Network,这个网络的奇妙之处,就是扩展了FM这种只能显性交叉的二阶的模型, 通过交叉网络能真正的显性的进行特征间的高阶交叉。 具体结构后面还会在复习,毕竟这次的xdeepFM主要是在Cross Network的基础上进行的再升级,这也是为啥说论血缘关系,xdeepFM离DCN更近一些的原因。那么Cross network有啥问题呢? 这个想放在后面的2.4去说了,这样能更好的引出本篇主角来。 + +通过上面的一个梳理,首先是回忆起了前面这几个模型的发展脉络, 其次差不多也能明白xDeepFM到底再干个什么事情了,或者要解决啥问题了,xDeepFM其实简单的说,依然是研究如何自动的进行特征之间的交叉组合,以让模型学习的更好。 从上面这段梳理中,我们至少要得到3个重要信息: + +1. 推荐模型如何有效的学习特征的交叉组合信息是非常重要的, 而原始的人工特征交叉组合不可取,如何让模型自动的学习交叉组合信息就变得非常关键 +2. 有的模型(FM)可以显性的交叉特征, 但往往没法高阶,只能到二阶 +3. 有的模型(DNN)可以进行高阶的特征交互,但往往是以一种无法解释的方式(隐性高阶交互),并且是bit-wise的形式,能不能真正的学习到高阶交互其实是个谜。 +4. 有的模型(DCN)探索了显性的高阶交叉特征,但仍然存在一些问题。 + +所以xDeepFM的改进动机来了: 更有效的高阶显性交叉特征(CIN),更高的泛化能力(vector-wise), 显性和隐性高阶特征的组合(CIN+DNN), 这就是xDeepFM了, 而这里面的关键,就是CIN网络了。在这之前,还是先把准备工作做足。 + +### Embedding Layer +这个是为了回顾一下,简单一说,我们拿到的数据往往会有连续型数据和离散型或者叫类别型数据之分。 如果是连续型数据,那个不用多说,一般会归一化或者标准化处理,当然还可能进行一定的非线性化操作,就算处理完了。 而类别型数据,一般需要先LabelEncoder,转成类别型编码,然后再one-hot转成0或者1的编码格式。 比如论文里面的这个例子: + ++
++ +这样的数据往往是高维稀疏的,不利于模型的学习,所以往往在这样数据之后,加一个embedding层,把数据转成低维稠密的向量表示。 关于embedding的原理这里不说了,但这里要注意一个细节,就是如果某个特征每个样本只有一种取值,也就是one-hot里面只有一个地方是1,比如前面3个field。这时候,可以直接拿1所在位置的embedding当做此时类别特征的embedding向量。 但是如果某个特征域每个样本好多种取值,比如interests这个,有好几个1的这种,那么就拿到1所在位置的embedding向量之后**求和**来代表该类别特征的embedding。这样,经过embedding层之后,我们得到的数据成下面这样了: +$$ +\mathbf{e}=\left[\mathbf{e}_{1}, \mathbf{e}_{2}, \ldots, \mathbf{e}_{m}\right] +$$ +这个应该比较好理解,$e_i$表示的一个向量,一般是$D$维的(隐向量的维度), 那么假设$m$表示特征域的个数,那么此时的$\mathbf{e}$是$m\times D$的矩阵。 + ++
++ +### bit-wise VS vector-wise +这是特征交互的两种方式,需要了解下,因为论文里面的CIN结构是在vector-wise上完成特征交互的, 这里拿从网上找到的一个例子来解释概念。 + +假设隐向量的维度是3维, 如果两个特征对应的向量分别是$(a_1, b_1, c_1)$和$(a_2,b_2, c_2)$ + +1. bit-wise = element-wise +在进行交互时,交互的形式类似于$f(w_1a_1a_2, w_2b_1b_2,w_3c_1c_2)$,此时我们认为特征交互发生在元素级别上,bit-wise的交互是以bit为最小单元的,也就是向量的每一位上交互,且学习一个$w_i$ +2. vector-wise +如果特征交互形式类似于$f(w(a_1a_2,b_1b_2,c_1c_2))$, 我们认为特征交互发生在向量级别上,vector-wise交互是以整个向量为最小单元的,向量层级的交互,为交互完的向量学习一个统一的$w$ + +这个一直没弄明白后者为什么会比前者好,我也在讨论群里问过这个问题,下面是得到的一个伙伴的想法: + ++
++ +对于这个问题有想法的伙伴,也欢迎在下面评论,我自己的想法是bit-wise看上面的定义,仿佛是在元素的级别交叉,然后学习权重, 而vector-wise是在向量的级别交叉,然后学习统一权重,bit-wise具体到了元素级别上,虽然可能学习的更加细致,但这样应该会增加过拟合的风险,失去一定的泛化能力,再联想作者在论文里面解释的bit-wise: + ++
++ +更觉得这个想法会有一定的合理性,就想我在DNN那里解释这个一样,把细节学的太细,就看不到整体了,佛曰:着相了哈哈。如果再联想下FM的设计初衷,FM是一个vector-wise的模型,它进行了显性的二阶特征交叉,却是embedding级别的交互,这样的好处是有一定的泛化能力到看不见的特征交互。 emmm, 我在后面整理Cross Network问题的时候,突然悟了一下, bit-wise最大的问题其实在于**违背了特征交互的初衷**, 我们本意上其实是让模型学习特征之间的交互,放到embedding的角度,也理应是embedding与embedding的相关作用交互, 但bit-wise已经没有了embedding的概念,以bit为最细粒度进行学习, 这里面既有不同embedding的bit交互,也有同一embedding的bit交互,已经**意识不到Field vector的概念**。 具体可以看Cross Network那里的解释,分析了Cross Network之后,可能会更好理解些。 + +这个问题最好是先这样理解或者自己思考下,因为xDeepFM的一个挺大的亮点就是保留了FM的这种vector-wise的特征交互模式,也是作者一直强调的,vector-wise应该是要比bit-wise要好的,否则作者就不会强调Cross Network的弊端之一就是bit-wise,而改进的方法就是设计了CIN,用的是vector-wise。 + +### 高阶隐性特征交互(DNN) VS 高阶显性特征交互(Cross Network) +#### DNN的隐性高阶交互 +DNN非常擅长学习特征之间的高阶交互信息,但是隐性的,这个比较好理解了,前面也提到过: +$$ +\begin{array}{c} +\mathbf{x}^{1}=\sigma\left(\mathbf{W}^{(1)} \mathbf{e}+\mathbf{b}^{1}\right) \\ +\mathbf{x}^{k}=\sigma\left(\mathbf{W}^{(k)} \mathbf{x}^{(k-1)}+\mathbf{b}^{k}\right) +\end{array} +$$ +但是问题的话,前面也剖析过了, 简单总结: +1. DNN 是一种隐性的方式学习特征交互, 但这种交互是不可解释性的, 没法看出究竟是学习了几阶的特征交互 +2. DNN是在bit-wise层级上学习的特征交互, 这个不同于传统的FM的vector-wise +3. DNN是否能有效的学习高阶特征交互是个迷,其实不知道学习了多少重要的高阶交互,哪些高阶交互会有作用,高阶到了几阶等, 如果用的话,只能靠玄学二字来解释 + +#### Cross Network的显性高阶交互 +谈到显性高阶交互,这里就必须先分析一下我们大名鼎鼎的DCN网络的Cross Network了, 关于这个模型,我在[AI上推荐 之 Wide&Deep与Deep&Cross模型](https://blog.csdn.net/wuzhongqiang/article/details/109254498)文章中进行了一些剖析,这里再复习的话我又参考了一个大佬的文章,因为再把我之前的拿过来感觉没有啥意思,重新再阅读别人的文章很可能会再get新的点,于是乎还真的学习到了新东西,具体链接放到了下面。 这里我们重温下Cross Network,看看到底啥子叫显性高阶交互。再根据论文看看这样子的交互有啥问题。 + ++
++ +这里的输入$x_0$需要提醒下,首先对于离散的特征,需要进行embedding, 对于multi-hot的离散变量, 需要embedding之后再做一个简单的average pooling, 而dense特征,归一化, **然后和embedding的特征拼接到一块作为Cross层和Deep层的输入,也就是Dense特征会在这里进行拼接**。 下面回顾Cross Layer。 + +Cross的目的是一一种显性、可控且高效的方式,**自动**构造**有限高阶**交叉特征。 具体的公式如下: +$$ +\boldsymbol{x}_{l+1}=\boldsymbol{x}_{0} \boldsymbol{x}_{l}^{T} \boldsymbol{w}_{l}+\boldsymbol{b}_{l}+\boldsymbol{x}_{l}=f\left(\boldsymbol{x}_{l}, \boldsymbol{w}_{l}, \boldsymbol{b}_{l}\right)+\boldsymbol{x}_{l} +$$ +其中$\boldsymbol{x}_{l+1}, \boldsymbol{x}_{l}, \boldsymbol{x}_{0} \in \mathbb{R}^{d}$。有图有真相: + ++
++ +Cross Layer的巧妙之处全部体现在上面的公式,下面放张图是为了更好的理解,这里我们回顾一些细节。 +1. 每层的神经元个数相同,都等于输入$\boldsymbol{x}_0$的维度$d$, 即每层的输入和输出维度是相等的(这个之前没有整理,没注意到) +2. 残差网络的结构启发,每层的函数$\boldsymbol{f}$拟合的是$\boldsymbol{x}_{l+1}-\boldsymbol{x}_l$的残差,残差网络有很多优点,其中一个是处理梯度消失的问题,可以使得网络更“深” + +那么显性交叉到底体会到哪里呢? 还是拿我之前举的那个例子:假设$\boldsymbol{x}_{0}=\left[\begin{array}{l}x_{0,1} \\ x_{0,2}\end{array}\right]$, 为了讨论各层,先令$\boldsymbol{b}_i=0$, 则 + +$$ +\boldsymbol{x}_{1}=\boldsymbol{x}_{0} \boldsymbol{x}_{0}^{T} \boldsymbol{w}_{0}+\boldsymbol{x}_{0}=\left[\begin{array}{l} +x_{0,1} \\ +x_{0,2} +\end{array}\right]\left[x_{0,1}, x_{0,2}\right]\left[\begin{array}{c} +w_{0,1} \\ +w_{0,2} +\end{array}\right]+\left[\begin{array}{l} +x_{0,1} \\ +x_{0,2} +\end{array}\right]=\left[\begin{array}{l} +w_{0,1} x_{0,1}^{2}+w_{0,2} x_{0,1} x_{0,2}+x_{0,1} \\ +w_{0,1} x_{0,2} x_{0,1}+w_{0,2} x_{0,2}^{2}+x_{0,2} +\end{array}\right] \\ +\begin{aligned} +\boldsymbol{x}_{2}=& \boldsymbol{x}_{0} \boldsymbol{x}_{1}^{T} \boldsymbol{w}_{1}+\boldsymbol{x}_{1} \\ +=&\left[\begin{array}{l} +w_{1,1} x_{0,1} x_{1,1}+w_{1,2} x_{0,1} x_{1,2}+x_{1,1} \\ +\left.w_{1,1} x_{0,2} x_{1,1}+w_{1,2} x_{0,2} x_{1,2}+x_{1,2}\right] +\end{array}\right. \\ +&=\left[\begin{array}{l} +\left.w_{0,1} w_{1,1} x_{0,1}^{3}+\left(w_{0,2} w_{1,1}+w_{0,1} w_{1,2}\right) x_{0,1}^{2} x_{0,2}+w_{0,2} w_{1,2} x_{0,1} x_{0,2}^{2}+\left(w_{0,1}+w_{1,1}\right) x_{0,1}^{2}+\left(w_{0,2}+w_{1,2}\right) x_{0,1} x_{0,2}+x_{0,1}\right] \\ +\ldots \ldots \ldots . +\end{array}\right. +\end{aligned} +$$ +最后得到$y_{\text {cross }}=\boldsymbol{x}_{2}^{T} * \boldsymbol{w}_{\text {cross }} \in \mathbb{R}$参与到最后的loss计算。 可以看到$\boldsymbol{x}_1$包含了原始特征$x_{0,1},x_{0,2}$从一阶导二阶所有可能叉乘组合, 而$\boldsymbol{x}_2$包含了从一阶导三阶素有可能的叉乘组合, 而**显性特征组合的意思,就是最终的结果可以经过一系列转换,得到类似$W_{i,j}x_ix_j$的形式**, 上面这个可以说是非常明显了吧。 +1. **有限高阶**: 叉乘**阶数由网络深度决定**, 深度$L_c$对应最高$L_c+1$阶的叉乘 +2. **自动叉乘**:Cross输出包含了原始从一阶(本身)到$L_c+1$阶的**所有叉乘组合**, 而模型参数量仅仅随着输入维度**线性增长**:$2\times d\times L_c$ +3. **参数共享**: 不同叉乘项对应的权重不同,但并非每个叉乘组合对应独立的权重,通过参数共享,Cross有效**降低了参数数量**。 并且,使得模型有更强的**泛化性**和**鲁棒性**。例如,如果独立训练权重,当训练集中$x_{i} \neq 0 \wedge x_{j} \neq 0$这个叉乘特征没有出现,对应权重肯定是0,而参数共享不会,类似的,数据集中的一些噪声可以由大部分样本来纠正权重参数的学习 + +这里有一点很值得留意,前面介绍过,文中将dense特征和embedding特征拼接后作为Cross层和Deep层的共同输入。这对于Deep层是合理的,但我们知道人工交叉特征基本是对原始sparse特征进行叉乘,那为何不直接用原始sparse特征作为Cross的输入呢?联系这里介绍的Cross设计,每层layer的节点数都与Cross的输入维度一致的,**直接使用大规模高维的sparse特征作为输入,会导致极大地增加Cross的参数量**。当然,可以畅想一下,其实直接拿原始sparse特征喂给Cross层,才是论文真正宣称的“省去人工叉乘”的更完美实现,但是现实条件不太允许。所以将高维sparse特征转化为低维的embedding,再喂给Cross,实则是一种**trade-off**的可行选择。 + +看下DNN与Cross Network的参数量对比:+
初始输入$x_0$维度是$d$, Deep和Cross层数分别为$L_{cross}$和$L_{deep}$, 为便于分析,设Deep每层神经元个数为$m$则两部分参数量: +$$ +\text { Cross: } d * L_{\text {cross }} * 2 \quad V S \quad \text { Deep: }(d * m+m)+\left(m^{2}+m\right) *\left(L_{\text {deep }}-1\right) +$$ +可以看到Cross的参数量随$d$增大仅呈“线性增长”!相比于Deep部分,对整体模型的复杂度影响不大,这得益于Cross的特殊网络设计,对于模型在业界落地并实际上线来说,这是一个相当诱人的特点。Deep那部分参数计算其实是第一层单算$m(d+1)$, 接下来的$L-1$层,每层都是$m$, 再加上$b$个个数,所以$m(m+1)$。 + +好了, Cross的好处啥的都分析完了, 下面得分析点不好的地方了,否则就没法引出这次的主角了。作者直接说: + +++ +每一层学习到的是$\boldsymbol{x}_0$的标量倍,这是啥意思。 这里有一个理论: + ++
++ +这里作者用数学归纳法进行了证明。 + +当$k=1$的时候 +$$ +\begin{aligned} +\mathbf{x}_{1} &=\mathbf{x}_{0}\left(\mathbf{x}_{0}^{T} \mathbf{w}_{1}\right)+\mathbf{x}_{0} \\ +&=\mathbf{x}_{0}\left(\mathbf{x}_{0}^{T} \mathbf{w}_{1}+1\right) \\ +&=\alpha^{1} \mathbf{x}_{0} +\end{aligned} +$$ +这里的$\alpha^{1}=\mathbf{x}_{0}^{T} \mathbf{w}_{1}+1$是$x_0$的一个线性回归, $x_1$是$x_0$的标量倍成立。 假设当$k=i$的时候也成立,那么$k=i+1$的时候: +$$ +\begin{aligned} +\mathbf{x}_{i+1} &=\mathbf{x}_{0} \mathbf{x}_{i}^{T} \mathbf{w}_{i+1}+\mathbf{x}_{i} \\ +&=\mathbf{x}_{0}\left(\left(\alpha^{i} \mathbf{x}_{0}\right)^{T} \mathbf{w}_{i+1}\right)+\alpha^{i} \mathbf{x}_{0} \\ +&=\alpha^{i+1} \mathbf{x}_{0} +\end{aligned} +$$ +其中$\alpha^{i+1}=\alpha^{i}\left(\mathbf{x}_{0}^{T} \mathbf{w}_{i+1}+1\right)$, 即$x_{i+1}$依然是$x_0$的标量倍。 + +所以作者认为Cross Network有两个缺点: +1. 由于每个隐藏层是$x_0$的标量倍,所以CrossNet的输出受到了特定形式的限制 +2. CrossNet的特征交互是bit-wise的方式(这个经过上面举例子应该是显然了),这种方式embedding向量的各个元素也会互相影响,这样在泛化能力上可能受到限制,并且也**意识不到Field Vector的概念**, **这其实违背了我们特征之间相互交叉的初衷**。因为我们想让模型学习的是特征与特征之间的交互或者是相关性,从embedding的角度,,那么自然的特征与特征之间的交互信息应该是embedding与embedding的交互信息。 但是**bit-wise的交互上,已经意识不到embedding的概念了**。由于最细粒度是bit(embedding的具体元素),所以这样的交互既包括了不同embedding不同元素之间的交互,也包括了同一embedding不同元素的交互。本质上其实发生了改变。 **这也是作者为啥强调CIN网络是vector-wise的原因**。而FM,恰好是以向量为最细粒度学习相关性。 + +好了, 如果真正理解了Cross Network,以及上面存在的两个问题,理解xDeepFM的动机就不难了,**xDeepFM的动机,正是将FM的vector-wise的思想引入到了Cross部分**。 + +下面主角登场了: ++
++ +## xDeepFM模型的理论以及论文细节 +了解了xDeepFM的动机,再强调下xDeepFM的核心,就是提出的一个新的Cross Network(CIN),这个是基于DCN的Cross Network,但是有上面那几点好处。下面的逻辑打算是这样,首先先整体看下xDeepFM的模型架构,由于我们已经知道了这里其实就是用一个CIN网络代替了DCN的Cross Network,那么这里面除了这个网络,其他的我们都熟悉。 然后我们再重点看看CIN网络到底在干个什么样的事情,然后再看看CIN与FM等有啥关系,最后分析下这个新网络的时间复杂度等问题。 +### xDeepFM的架构剖析 +首先,我们先看下xDeepFM的架构 + ++
++ +这个网络结构名副其实,依然是采用了W&D架构,DNN负责Deep端,学习特征之间的隐性高阶交互, 而CIN网络负责wide端,学习特征之间的显性高阶交互,这样显隐性高阶交互就在这个模型里面体现的淋漓尽致了。不过这里的线性层单拿出来了。 + ++
++ +最终的计算公式如下: +$$ +\hat{y}=\sigma\left(\mathbf{w}_{\text {linear }}^{T} \mathbf{a}+\mathbf{w}_{d n n}^{T} \mathbf{x}_{d n n}^{k}+\mathbf{w}_{\text {cin }}^{T} \mathbf{p}^{+}+b\right) +$$ +这里的$\mathbf{a}$表示原始的特征,$\mathbf{a}_{dnn}^k$表示的是DNN的输出, $\mathbf{p}^+$表示的是CIN的输出。最终的损失依然是交叉熵损失,这里也是做一个点击率预测的问题: +$$ +\mathcal{L}=-\frac{1}{N} \sum_{i=1}^{N} y_{i} \log \hat{y}_{i}+\left(1-y_{i}\right) \log \left(1-\hat{y}_{i}\right) +$$ +最终的目标函数加了正则化: +$$ +\mathcal{J}=\mathcal{L}+\lambda_{*}\|\Theta\| +$$ + +### CIN网络的细节(重头戏) +这里尝试剖析下本篇论文的主角CIN网络,全称Compressed Interaction Network。这个东西说白了其实也是一个网络,并不是什么高大上的东西,和Cross Network一样,也是一层一层,每一层都是基于一个固定的公式进行的计算,那个公式长这样: +$$ +\mathbf{X}_{h, *}^{k}=\sum_{i=1}^{H_{k-1}} \sum_{j=1}^{m} \mathbf{W}_{i j}^{k, h}\left(\mathbf{X}_{i, *}^{k-1} \circ \mathbf{X}_{j, *}^{0}\right) +$$ + 这个公式第一眼看过来,肯定更是懵逼,这是写的个啥玩意?如果我再把CIN的三个核心图放上来: + ++
++ + 上面其实就是CIN网络的精髓了,也是它具体的运算过程,只不过直接上图的话,会有些抽象,难以理解,也不符合我整理论文的习惯。下面,我们就一一进行剖析, 先从上面这个公式开始。但在这之前,需要先约定一些符号。要不然不知道代表啥意思。 +1. $\mathbf{X}^{0} \in \mathbb{R}^{m \times D}$: 这个就是我们的输入,也就是embedding层的输出,可以理解为各个embedding的堆叠而成的矩阵,假设有$m$个特征,embedding的维度是$D$维,那么这样就得到了这样的矩阵, $m$行$D$列。$\mathbf{X}_{i, *}^{0}=\mathbf{e}_{i}$, 这个表示的是第$i$个特征的embedding向量$e_i$。所以上标在这里表示的是网络的层数,输入可以看做第0层,而下标表示的第几行的embedding向量,这个清楚了。 +2. $\mathbf{X}^{k} \in \mathbb{R}^{H_{k} \times D}$: 这个表示的是CIN网络第$k$层的输出,和上面这个一样,也是一个矩阵,每一行是一个embedding向量,每一列代表一个embedding维度。这里的$H_k$表示的是第$k$层特征的数量,也可以理解为神经元个数。那么显然,这个$\mathbf{X}^{k}$就是$H_k$个$D$为向量堆叠而成的矩阵,维度也显然了。$\mathbf{X}_{h, *}^{k}$代表的就是第$k$层第$h$个特征向量了。 + +所以上面的那个公式: +$$ +\mathbf{X}_{h, *}^{k}=\sum_{i=1}^{H_{k-1}} \sum_{j=1}^{m} \mathbf{W}_{i j}^{k, h}\left(\mathbf{X}_{i, *}^{k-1} \circ \mathbf{X}_{j, *}^{0}\right) +$$ +其实就是计算第$k$层第$h$个特征向量, 这里的$1 \leq h \leq H_{k}, \mathbf{W}^{k, h} \in \mathbb{R}^{H_{k-1} \times m}$是第$h$个特征向量的参数矩阵。 $\circ$表示的哈达玛积,也就是向量之间对应维度元素相乘(不相加了)。$\left\langle a_{1}, a_{2}, a_{3}\right\rangle \circ\left\langle b_{1}, b_{2}, b_{3}\right\rangle=\left\langle a_{1} b_{1}, a_{2} b_{2}, a_{3} b_{3}\right\rangle$。通过这个公式也能看到$\mathbf{X}^k$是通过$\mathbf{X}^{k-1}$和$\mathbf{X}^0$计算得来的,也就是说特征的显性交互阶数会虽然网络层数的加深而增加。 + +那么这个公式到底表示的啥意思呢? 是具体怎么计算的呢?我们往前计算一层就知道了,这里令$k=1$,也就是尝试计算第一层里面的第$h$个向量, 那么上面公式就变成了: + +$$ +\mathbf{X}_{h, *}^{1}=\sum_{i=1}^{H_{0}} \sum_{j=1}^{m} \mathbf{W}_{i j}^{1, h}\left(\mathbf{X}_{i, *}^{0} \circ \mathbf{X}_{j, *}^{0}\right) +$$ +这里的$\mathbf{W}^{1, h} \in \mathbb{R}^{H_{0} \times m}$。这个能看懂吗? 首先这个$\mathbf{W}$矩阵是$H_0$行$m$列, 而前面那两个累加正好也是$H_0$行$m$列的参数。$m$代表的是输入特征的个数, $H_0$代表的是第0层($k-1$层)的神经元的个数, 这个也是$m$。这个应该好理解,输入层就是第0层。所以这其实就是一个$m\times m$的矩阵。那么后面这个运算到底是怎么算的呢? 首先对于第$i$个特征向量, 要依次和其他的$m$个特征向量做哈达玛积操作,当然也乘以对应位置的权重,求和。对于每个$i$特征向量,都重复这样的操作,最终求和得到一个$D$维的向量,这个就是$\mathbf{X}_{h, *}^{1}$。好吧,这么说。我觉得应该也没有啥感觉,画一下就了然了,现在可以先不用管论文里面是怎么说的,先跟着这个思路走,只要理解了这个公式是怎么计算的,论文里面的那三个图就会非常清晰了。灵魂画手: + ++
++ +这就是上面那个公式的具体过程了,图实在是太难看了, 但应该能说明这个详细的过程了。这样只要给定一个$\mathbf{W}^{1,h}$之后,就能算出一个相应的$\mathbf{X}^1_{h,*}$来,这样第一层的$H_1$个神经元按照这样的步骤就能够都计算出来了。 后面的计算过程其实是同理,无非就是输入是前一层的输出以及$\mathbf{X}_0$罢了,而这时候,第一个矩阵特征数就不一定是$m$了,而是一个$H_{k-1}$行$D$列的矩阵了。这里的$\mathbf{W}^{k,h}$就是上面写的$H_{k-1}$行$m$列了。 + +这个过程明白了之后,再看论文后面的内容就相对容易了,首先 + ++
++ +CIN里面能看到RNN的身影,也就是当前层的隐藏单元的计算要依赖于上一层以及当前的输入层,只不过这里的当前输入每个时间步都是$\mathbf{X}_0$。 同时这里也能看到,CIN的计算是vector-wise级别的,也就是向量之间的哈达玛积的操作,并没有涉及到具体向量里面的位交叉。 + +下面我们再从CNN的角度去看这个计算过程。其实还是和上面一样的计算过程,只不过是换了个角度看而已,所以上面那个只要能理解,下面CNN也容易理解了。首先,这里引入了一个tensor张量$\mathbf{Z}^{k+1}$表示的是$\mathbf{X}^k$和$\mathbf{X}^0$的外积,那么这个东西是啥呢? 上面加权求和前的那个矩阵,是一个三维的张量。 + ++
++ +这个可以看成是一个三维的图片,$H_{k-1}$高,$m$宽,$D$个通道。而$\mathbf{W}^{k,h}$的大小是$H_{k-1}\times m$的, 这个就相当于一个过滤器,用这个过滤器对输入的图片如果**逐通道进行卷积**,就会最终得到一个$D$维的向量,而这个其实就是$\mathbf{X}^{k}_{h,*}$,也就是一张特征图(每个通道过滤器是共享的)。 第$k$层其实有$H_k$个这样的过滤器,所以最后得到的是一个$H_k\times D$的矩阵。这样,在第$k$个隐藏层,就把了$H_{k-1}\times m\times D$的三维张量通过逐通道卷积的方式,压缩成了一个$H_k\times D$的矩阵($H_k$张特征图), 这就是第$k$层的输出$\mathbf{X}^k$。 而这也就是“compressed"的由来。这时候再看这两个图就非常舒服了: + ++
++ +通过这样的一个CIN网络,就很容易的实现了特征的显性高阶交互,并且是vector-wise级别的,那么最终的输出层是啥呢? 通过上面的分析,首先我们了解了对于第$k$层输出的某个特征向量,其实是综合了输入里面各个embedding向量显性高阶交互的信息(第$k$层其实学习的输入embedding$k+1$阶交互信息),这个看第一层那个输出就能看出来。第$k$层的每个特征向量其实都能学习到这样的信息,那么如果把这些向量在从$D$维度上进行加和,也就是$\mathbf{X}^k$,这是个$H_k\times D$的,我们沿着D这个维度加和,又会得到一个$H_k$的向量,公式如下: +$$ +p_{i}^{k}=\sum_{j=1}^{D} \mathbf{X}_{i, j}^{k} +$$ + 每一层,都会得到一个这样的向量,那么把所有的向量拼接到一块,其实就是CIN网络的输出了。之所以,这里要把中间结果都与输出层相连,就是因为CIN与Cross不同的一点是,在第$k$层,CIN只包含$k+1$阶的组合特征,而Cross是能包含从1阶-$k+1$阶的组合特征的,所以为了让模型学习到从1阶到所有阶的组合特征,CIN这里需要把中间层的结果与输出层建立连接。 + +这也就是第三个图表示的含义: + ++
++ +这样, 就得到了最终CIN的输出$\mathbf{p}^+$了: + +$$ +\mathbf{p}^{+}=\left[\mathbf{p}^{1}, \mathbf{p}^{2}, \ldots, \mathbf{p}^{T}\right] \in \mathbb{R} \sum_{i=1}^{T} H_{i} +$$ +后面那个维度的意思,就是说每一层是的向量维度是$H_i$维, 最后是所有时间步的维度之和。 CIN网络的计算过程的细节就是这些了。 + +### CIN网络的其他角度分析 +#### 设计意图分析 +CIN和DCN层的设计动机是相似的,Cross层的input也是前一层与输入层,这么做的原因就是可以实现: **有限高阶交互,自动叉乘和参数共享**。 + +但是CIN与Cross的有些地方是不一样的: +1. Cross是bit-wise级别的, 而CIN是vector-wise级别的 +2. 在第$l$层,Cross包含从1阶-$l+1$阶的所有组合特征, 而CIN只包含$l+1$阶的组合特征。 相应的,Cross在输出层输出全部结果, 而CIN在每层都输出中间结果。 而之所以会造成这两者的不同, 就是因为Cross层计算公式中除了与CIN一样包含"上一层与输入层的×乘"外,会再额外加了个"+输入层"。**这是两种涵盖所有阶特征交互的不同策略,CIN和Cross也可以使用对方的策略**。 + +#### 时间和空间复杂度分析 +1. 空间复杂度 +假设CIN和DNN每层神经元个数是$H$,网络深度为$T$。 那么CIN的参数空间复杂度$O(mTH^2)$。 这个我们先捋捋是怎么算的哈, 首先对于CIN,第$k$层的每个神经元都会对应着一个$H_{k-1}\times m$的参数矩阵$\mathbf{W}^{k,h}$, 那么第$k$层$H$个神经元的话,那就是$H \times H_{k-1} \times m$个参数,这里假设的是每层都有$H$个神经元,那么就是$O(H^2\times m)$,这是一层。 而网络深度一共$T$层的话,那就是$H \times H_{k-1} \times m\times T$的规模。 但别忘了,输出层还有参数, 由于输出层的参数会和输出向量的维度相对应,而输出向量的维度又和每一层神经单元个数相对应, 所以CIN的网络参数一共是$\sum_{k=1}^{T} H_{k} \times\left(1+H_{k-1} \times m\right)$, 而换成大O表示的话,其实就是上面那个了。当然,CIN还可以对$\mathbf{W}$进行$L$阶矩阵分解,使得空间复杂度再降低。+
再看DNN,第一层是$m\times D\times H_1$, 中间层$H_k\times H_{k-1}$,T-1层,这是一个$O(mDH+TH^2)$的空间复杂度,并且参数量会随着$D$的增加而增加。
所以空间上来说,CIN会有优势。 +2. 时间复杂度 +对于CIN, 我们计算某一层的某个特征向量的时候,需要前面的$H_{k-1}$个向量与输入的$m$个向量两两哈达玛积的操作,这个过程花费的时间$O(Hm)$, 而哈达玛积完事之后,有需要拿个过滤器在D维度上逐通道卷积,这时候得到了$\mathbf{Z}^{k+1}$,花费时间$O(HmD)$。 这只是某个特征向量, $k$层一共$H$个向量, 那么花费时间$O(H^2mD)$, 而一共$T$层,所以最终时间为$O(mH^2TD)$
对于普通的DNN,花费时间$O(mHD+H^2T)$
**所以时间复杂度会是CIN的一大痛点**。 + +#### 多项式逼近 +这地方没怎么看懂,大体写写吧, 通过对问题进行简化,即假设CIN中不同层的feature map的数量全部一致,均为fields的数量$m$,并且用`[m]`表示小于等于m的正整数。CIN中的第一层的第$h$个feature map表示为$x_h^1 \in \mathbb{R}^D$,即 +$$ +\boldsymbol{x}_{\boldsymbol{h}}^{1}=\sum_{i \in[m], j \in[m]} \boldsymbol{W}_{i, j}^{1, h}\left(x_{i}^{0} \circ x_{j}^{0}\right) +$$ +因此, 在第一层中通过$O(m^2)$个参数来建模成对的特征交互关系,相似的,第二层的第$h$个特征图表示为: +$$ +\begin{array}{c} +\boldsymbol{x}_{h}^{2}=\sum_{i \in[m], j \in[m]} \boldsymbol{W}_{i, j}^{2, h}\left(x_{i}^{1} \circ x_{j}^{0}\right) \\ +=\sum_{i \in[m], j \in[m]] \in[m], k \in[m]} \boldsymbol{W}_{i, j}^{2, h} \boldsymbol{W}_{l, k}^{1, h}\left(x_{j}^{0} \circ x_{k}^{0} \circ x_{l}^{0}\right) +\end{array} +$$ +由于第二个$\mathbf{W}$矩阵在前面一层计算好了,所以第二层的feature map也是只用了$O(m^2)$个参数就建模出了3阶特征交互关系。 + +我们知道一个经典的$k$阶多项式一般是需要$O(m^k)$个参数的,而我们展示了CIN在一系列feature map中只需要$O(k m^2)$个参数就可以近似此类多项式。而且paper使用了归纳假设的方法证明了一下,也就是后面那两个公式。具体的没咋看懂证明,不整理了。但得知道两个结论: +1. 对于CIN来讲, 第$k$层只包含$k+1$阶特征间的显性特征交互 +2. CIN的一系列特征图只需要$O(km^2)$个参数就可以近似此类多项式 + +#### xDeepFM与其他模型的关系 +1. 对于xDeepFM,将CIN模块的层数设置为1,feature map数量也为1时,其实就是DeepFM的结构,因此DeepFM是xDeepFM的特殊形式,而xDeepFM是DeepFM的一般形式; +2. 在1中的基础上,当我们再将xDeepFM中的DNN去除,并对feature map使用一个常数1形式的 `sum filter`,那么xDeepFM就退化成了FM形式了。 + +一般这种模型的改进,是基于之前模型进行的,也就是简化之后,会得到原来的模型,这样最差的结果,模型效果还是原来的,而不应该会比原来模型的表现差,这样的改进才更有说服力。 + +所以,既然提到了FM,再考虑下面两个问题理解下CIN设计的合理性。 +1. 每层通过sum pooling对vector的元素加和输出,这么做的意义或者合理性? 这个就是为了退化成FM做准备,如果CIN只有1层, 只有$m$个vector,即 $H_1=m$ ,且加和的权重矩阵恒等于1,即$W^1=1$ ,那么sum pooling的输出结果,就是一系列的两两向量内积之和,即标准的FM(不考虑一阶与偏置)。 +2. 除了第一层,中间层的基于Vector高阶组合有什么物理意义? 回顾FM,虽然是二阶的,但可以扩展到多阶,例如考虑三阶FM,是对三个嵌入向量做哈达玛积乘再对得到的vector做sum, CIN基于vector-wise的高阶组合再sum pooling与之类似,这也是模型名字"eXtreme Deep Factorization Machine(xDeepFM)"的由来。 +### 论文的其他重要细节 +#### 实验部分 +这一块就是后面实验了,这里作者依然是抛出了三个问题,并通过实验进行了解答。 +1. CIN如何学习高阶特征交互 + 通过提出的交叉网络,这里单独证明了这个结构要比CrossNet,DNN模块和FM模块要好 +2. 推荐系统中,是否需要显性和隐性的高阶特征交互都存在? + +++ +3. 超参对于xDeepFM的影响 + 1. 网络的深度: 不用太深, CIN网络层数大于3,就不太好了,容易过拟合 + 2. 每一层神经网络的单元数: 100是比较合适的一个数值 + 3. 激活函数: CIN这里不用加任何的非线性激活函数,用恒等函数$f(x)=x$效果最好 + + +这里用了三个数据集 +1. 公开数据集 Criteo 与 微软数据集 BingNews +2. DianPing 从大众点评网整理的相关数据,收集6个月的user check-in 餐厅poi的记录,从check-in餐厅周围3km内,按照poi受欢迎度抽取餐厅poi作为负例。根据user属性、poi属性,以及user之前3家check-in的poi,预测用户check-in一家给定poi的概率。 + +评估指标用了两个AUC和Logloss, 这两个是从不同的角度去评估模型。 +1. AUC: AUC度量一个正的实例比一个随机选择的负的实例排名更高的概率。它只考虑预测实例的顺序,对类的不平衡问题不敏感. +2. LogLoss(交叉熵损失): 真实分数与预测分数的距离 + +作者说: ++
++ +#### 相关工作部分 +这里作者又梳理了之前的模型,这里就再梳理一遍了 + +1. 经典推荐系统 + 1. 非因子分解模型: 主要介绍了两类,一类是常见的线性模型,例如LR with FTRL,这一块很多工作是在交互特征的特征工程方面;另一类是提升决策树模型的研究(GBDT+LR) + 2. 因子分解模型: MF模型, FM模型,以及在FM模型基础上的贝叶斯模型 + +2. 深度学习模型 + 1. 学习高阶交互特征: 论文中提到的DeepCross, FNN,PNN,DCN, NFM, W&D, DeepFM, + 2. 学习精心的表征学习:这块常见的深度学习模型不是focus在学习高阶特征交互关系。比如NCF,ACF,DIN等。 + + 推荐系统数据特点: 稀疏,类别连续特征混合,高维。 + +关于未来两个方向: +1. CIN的sum pooling这里, 后面可以考虑DIN的那种思路,根据当前候选商品与embedding的关联进行注意力权重的添加 +2. CIN的时间复杂度还是比较高的,后面在GPU集群上使用分布式的方式来训练模型。 + + +## xDeepFM模型的代码复现及重要结构解释 +### xDeepFM的整体代码逻辑 +下面看下xDeepFM模型的具体实现, 这样可以从更细节的角度去了解这个模型, 这里我依然是参考的deepctr的代码风格,这种函数式模型编程更清晰一些,当然由于时间原因,我这里目前只完成了一个tf2版本的(pytorch版本的后面有时间会补上)。 这里先看下xDeepFM的全貌: + +```python +def xDeepFM(linear_feature_columns, dnn_feature_columns, cin_size=[128, 128]): + # 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型 + dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns+dnn_feature_columns) + + # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式 + # 注意:这里实际的输入预Input层对应,是通过模型输入时候的字典数据的key与对应name的Input层 + input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values()) + + # 线性部分的计算逻辑 -- linear + linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns) + + # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型 + # 线性层和dnn层统一的embedding层 + embedding_layer_dict = build_embedding_layers(linear_feature_columns+dnn_feature_columns, sparse_input_dict, is_linear=False) + + # DNN侧的计算逻辑 -- Deep + # 将dnn_feature_columns里面的连续特征筛选出来,并把相应的Input层拼接到一块 + dnn_dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns)) if dnn_feature_columns else [] + dnn_dense_feature_columns = [fc.name for fc in dnn_dense_feature_columns] + dnn_concat_dense_inputs = Concatenate(axis=1)([dense_input_dict[col] for col in dnn_dense_feature_columns]) + + # 将dnn_feature_columns里面的离散特征筛选出来,相应的embedding层拼接到一块 + dnn_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=True) + dnn_concat_sparse_kd_embed = Concatenate(axis=1)(dnn_sparse_kd_embed) + + # DNN层的输入和输出 + dnn_input = Concatenate(axis=1)([dnn_concat_dense_inputs, dnn_concat_sparse_kd_embed]) + dnn_out = get_dnn_output(dnn_input) + dnn_logits = Dense(1)(dnn_out) + + # CIN侧的计算逻辑, 这里使用的DNN feature里面的sparse部分,这里不要flatten + exFM_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False) + exFM_input = Concatenate(axis=1)(exFM_sparse_kd_embed) + exFM_out = CIN(cin_size=cin_size)(exFM_input) + exFM_logits = Dense(1)(exFM_out) + + # 三边的结果stack + stack_output = Add()([linear_logits, dnn_logits, exFM_logits]) + + # 输出层 + output_layer = Dense(1, activation='sigmoid')(stack_output) + + model = Model(input_layers, output_layer) + + return model +``` +这种风格最好的一点,就是很容易从宏观上把握模型的整体逻辑。 首先,接收的输入是linear_feature_columns和dnn_feature_columns, 这两个是深度和宽度两侧的特征,具体选取要结合着场景来。 接下来,就是为这些特征建立相应的Input层,这里要分成连续特征和离散的特征,因为后面的处理方式不同, 连续特征的话可以直接拼了, 而离散特征的话,需要过一个embedding层转成低维稠密,这就是第一行代码干的事情了。 + +接下来, 计算线性部分,从上面xDeepFM的结构里面可以看出, 是分三路走的,线性,CIN和DNN路, 所以`get_linear_logits`就是线性这部分的计算结果,完成的是$w_1x_1+w_2x_2..w_kx_k+b$, 这里面依然是连续和离散的不太一样,对于连续特征,直接过一个全连接就实现了这个操作,而离散特征,这里依然过一个embedding,不过这个维度是1,目的是转成了一个连续数值(这个相当于离散特征对应的w值),这样后面进行总的加和操作即可。 + +接下来是另外两路,DNN这路也比较简单, dnn_feature_columns里面的离散特征过embedding,和连续特征拼接起来,然后过DNN即可。 CIN这路使用的是dnn_feature_columns里面的离散embedding特征,进行显性高阶交叉,这里的输入是`[None, field_num, embedding_dim]`的维度。这个也好理解,每个特征embedding之后,拼起来即可,注意`flatten=False`了。 这个输入,过CIN网络得到输出。 + +这样三路输出都得到,然后进行了一个加和,再连接一个Dense映射到最终输出。这就是整体的逻辑了,关于每个部分的具体细节,可以看代码。 下面主要是看看CIN这个网络是怎么实现的,因为其他的在之前的模型里面也基本是类似的操作,比如前面DIEN,DSIN版本,并且我后面项目里面补充了DCN的deepctr风格版,这个和那个超级像,唯一不同的就是把CrossNet换成了CIN,所以这个如果感觉看不大懂,可以先看那个网络代码。下面说CIN。 +### CIN网络的代码实现细节 +再具体代码实现, 我们先简单捋一下CIN网络的实现过程,这里的输入是`[None, field_num embed_dim]`的维度,在CIN里面,我们知道接下来的话,就是每一层会有$H_k$个神经元, 而每个神经元的计算要根据上面的那个计算公式,也就是$X_0$要和前面一层的输出两两embedding,加权求和再求和的方式。 而从CNN的角度来看,这个过程可以是这样,对于每一层的计算,先$X_0$和$X_k$进行外积运算(相当于两两embedding),然后采用$H_k$个过滤器对前面的结果逐通道卷积就能得到每一层的$X_k$了。 最后的输出是每一层的$X_k$拼接起来,然后在embedding维度上的求和。 所以依据这个思路,就能得到下面的实现代码: + +```python +class CIN(Layer): + def __init__(self, cin_size, l2_reg=1e-4): + """ + :param: cin_size: A list. [H_1, H_2, ....H_T], a list of number of layers + """ + super(CIN, self).__init__() + self.cin_size = cin_size + self.l2_reg = l2_reg + + def build(self, input_shape): + # input_shape [None, field_nums, embedding_dim] + self.field_nums = input_shape[1] + + # CIN 的每一层大小,这里加入第0层,也就是输入层H_0 + self.field_nums = [self.field_nums] + self.cin_size + + # 过滤器 + self.cin_W = { + 'CIN_W_' + str(i): self.add_weight( + name='CIN_W_' + str(i), + shape = (1, self.field_nums[0] * self.field_nums[i], self.field_nums[i+1]), # 这个大小要理解 + initializer='random_uniform', + regularizer=l2(self.l2_reg), + trainable=True + ) + for i in range(len(self.field_nums)-1) + } + + super(CIN, self).build(input_shape) + + def call(self, inputs): + # inputs [None, field_num, embed_dim] + embed_dim = inputs.shape[-1] + hidden_layers_results = [inputs] + + # 从embedding的维度把张量一个个的切开,这个为了后面逐通道进行卷积,算起来好算 + # 这个结果是个list, list长度是embed_dim, 每个元素维度是[None, field_nums[0], 1] field_nums[0]即输入的特征个数 + # 即把输入的[None, field_num, embed_dim],切成了embed_dim个[None, field_nums[0], 1]的张量 + split_X_0 = tf.split(hidden_layers_results[0], embed_dim, 2) + + for idx, size in enumerate(self.cin_size): + # 这个操作和上面是同理的,也是为了逐通道卷积的时候更加方便,分割的是当一层的输入Xk-1 + split_X_K = tf.split(hidden_layers_results[-1], embed_dim, 2) # embed_dim个[None, field_nums[i], 1] feild_nums[i] 当前隐藏层单元数量 + + # 外积的运算 + out_product_res_m = tf.matmul(split_X_0, split_X_K, transpose_b=True) # [embed_dim, None, field_nums[0], field_nums[i]] + out_product_res_o = tf.reshape(out_product_res_m, shape=[embed_dim, -1, self.field_nums[0]*self.field_nums[idx]]) # 后两维合并起来 + out_product_res = tf.transpose(out_product_res_o, perm=[1, 0, 2]) # [None, dim, field_nums[0]*field_nums[i]] + + # 卷积运算 + # 这个理解的时候每个样本相当于1张通道为1的照片 dim为宽度, field_nums[0]*field_nums[i]为长度 + # 这时候的卷积核大小是field_nums[0]*field_nums[i]的, 这样一个卷积核的卷积操作相当于在dim上进行滑动,每一次滑动会得到一个数 + # 这样一个卷积核之后,会得到dim个数,即得到了[None, dim, 1]的张量, 这个即当前层某个神经元的输出 + # 当前层一共有field_nums[i+1]个神经元, 也就是field_nums[i+1]个卷积核,最终的这个输出维度[None, dim, field_nums[i+1]] + cur_layer_out = tf.nn.conv1d(input=out_product_res, filters=self.cin_W['CIN_W_'+str(idx)], stride=1, padding='VALID') + + cur_layer_out = tf.transpose(cur_layer_out, perm=[0, 2, 1]) # [None, field_num[i+1], dim] + + hidden_layers_results.append(cur_layer_out) + + # 最后CIN的结果,要取每个中间层的输出,这里不要第0层的了 + final_result = hidden_layers_results[1:] # 这个的维度T个[None, field_num[i], dim] T 是CIN的网络层数 + + # 接下来在第一维度上拼起来 + result = tf.concat(final_result, axis=1) # [None, H1+H2+...HT, dim] + # 接下来, dim维度上加和,并把第三个维度1干掉 + result = tf.reduce_sum(result, axis=-1, keepdims=False) # [None, H1+H2+..HT] + + return result +``` +这里主要是解释四点: +1. 每一层的W的维度,是一个`[1, self.field_nums[0]*self.field_nums[i], self.field_nums[i+1]`的,首先,得明白这个`self.field_nums`存储的是每一层的神经单元个数,这里包括了输入层,也就是第0层。那么每一层的每个神经元计算都会有一个$W^{k,h}$, 这个的大小是$[H_{k-1},m]$维的,而第$K$层一共$H_k$个神经元,所以总的维度就是$[H_{k-1},m,H_k]$, 这和上面这个是一个意思,只不过前面扩展了维度1而已。 +2. 具体实现的时候,这里为了更方便计算,采用了切片的思路,也就是从embedding的维度把张量切开,这样外积的计算就会变得更加的简单。 +3. 具体卷积运算的时候,这里采用的是Conv1d,1维卷积对应的是一张张高度为1的图片(理解的时候可这么理解),输入维度是`[None, in_width, in_channels]`的形式,而对应这里的数据是`[None, dim, field_nums[0]*field_nums[i]]`, 而这里的过滤器大小是`[1, field_nums[0]*field_nums[i], field_nums[i+1]`, 这样进行卷积的话,最后一个维度是卷积核的数量。是沿着dim这个维度卷积,得到的是`[None, dim, field_nums[i+1]]`的张量,这个就是第$i+1$层的输出了。和我画的 + ++
++ +这个不同的是它把前面这个矩形Flatten了,得到了一个$[D,H_{k-1}\times m]$的二维矩阵,然后用$[1,H_{k-1}\times m]$的卷积核沿着D这个维度进行Conv1D, 这样就直接得到了一个D维向量, 而$H_k$个卷积核,就得到了$H_k\times D$的矩阵了。 +4. 每一层的输出$X_k$先加入到列表里面,然后在$H_i$的维度上拼接,再从$D$这个维度上求和,这样就得到了CIN的最终输出。 + +关于CIN的代码细节解释到这里啦,剩下的可以看后面链接里面的代码了。 + +## 总结 +这篇文章主要是介绍了又一个新的模型xDeepFM, 这个模型的改进焦点依然是特征之间的交互信息,xDeepFM的核心就是提出了一个新的CIN结构(这个是重点,面试的时候也喜欢问),将基于Field的vecotr-wise思想引入到了Cross Network中,并保留了Cross高阶交互,自动叉乘,参数共享等优势,模型结构上保留了DeepFM的广深结构。主要有三大优势: +1. CIN可以学习高效的学习有界的高阶特征; +2. xDeepFM模型可以同时显示和隐式的学习高阶交互特征; +3. 以vector-wise方式而不是bit-wise方式学习特征交互关系。 + +如果说DeepFM只是“Deep & FM”,那么xDeepFm就真正做到了”Deep” Factorization Machine。当然,xDeepFM的时间复杂度比较高,会是工业落地的主要瓶颈,后面需要进行一些优化操作。 + +这篇论文整体上还是非常清晰的,实验做的也非常丰富,语言描述上也非常地道,建议读读原文呀。 + +**参考**: +* [xDeepFM原论文-建议读一下,这个真的超级不错](https://arxiv.org/abs/1803.05170) +* [xDeepFM:名副其实的 ”Deep” Factorization Machine](https://zhuanlan.zhihu.com/p/57162373) +* [深度CTR之xDeepFM:融合了显式和隐式特征交互关系的深度模型推荐系统](https://blog.csdn.net/oppo62258801/article/details/104236828) +* [揭秘 Deep & Cross : 如何自动构造高阶交叉特征](https://zhuanlan.zhihu.com/p/55234968) +* [一文读懂xDeepFM](https://zhuanlan.zhihu.com/p/110076629) +* [推荐系统 - xDeepFM架构详解](https://blog.csdn.net/maqunfi/article/details/99664119) + diff --git a/4.人工智能/ch02/ch2.2/ch2.2.4/DIEN.md b/4.人工智能/ch02/ch2.2/ch2.2.4/DIEN.md new file mode 100644 index 0000000..37a21e7 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.4/DIEN.md @@ -0,0 +1,177 @@ +# DIEN +## DIEN提出的动机 +在推荐场景,用户无需输入搜索关键词来表达意图,这种情况下捕捉用户兴趣并考虑兴趣的动态变化将是提升模型效果的关键。以Wide&Deep为代表的深度模型更多的是考虑不同field特征之间的相互作用,未关注用户兴趣。 + +DIN模型考虑了用户兴趣,并且强调用户兴趣是多样的,该模型使用注意力机制来捕捉和**target item**的相关的兴趣,这样以来用户的兴趣就会随着目标商品自适应的改变。但是大多该类模型包括DIN在内,直接将用户的行为当做用户的兴趣(因为DIN模型只是在行为序列上做了简单的特征处理),但是用户潜在兴趣一般很难直接通过用户的行为直接表示,大多模型都没有挖掘用户行为背后真实的兴趣,捕捉用户兴趣的动态变化对用户兴趣的表示非常重要。DIEN相比于之前的模型,即对用户的兴趣进行建模,又对建模出来的用户兴趣继续建模得到用户的兴趣变化过程。 + +## DIEN模型原理 ++
++ +模型的输入可以分成两大部分,一部分是用户的行为序列(这部分会通过兴趣提取层及兴趣演化层转换成与用户当前兴趣相关的embedding),另一部分就是除了用户行为以外的其他所有特征,如Target id, Coontext Feature, UserProfile Feature,这些特征都转化成embedding的类型然后concat在一起(形成一个大的embedding)作为非行为相关的特征(这里可能也会存在一些非id类特征,应该可以直接进行concat)。最后DNN输入的部分由行为序列embedding和非行为特征embedding(多个特征concat到一起之后形成的一个大的向量)组成,将两者concat之后输入到DNN中。 + +所以DIEN模型的重点就是如何将用户的行为序列转换成与用户兴趣相关的向量,在DIN中是直接通过与target item计算序列中每个元素的注意力分数,然后加权求和得到最终的兴趣表示向量。在DIEN中使用了两层结构来建模用户兴趣相关的向量。 + +### Interest Exterator Layer + +兴趣抽取层的输入原本是一个id序列(按照点击时间的先后顺序形成的一个序列),通过Embedding层将其转化成一个embedding序列。然后使用GRU模块对兴趣进行抽取,GRU的输入是embedding层之后得到的embedding序列。对于GRU模块不是很了解的可以看一下[动手学深度学习中GRU相关的内容](https://zh.d2l.ai/chapter_recurrent-neural-networks/gru.html) + +作者并没有直接完全使用原始的GRU来提取用户的兴趣,而是引入了一个辅助函数来指导用户兴趣的提取。作者认为如果直接使用GRU提取用户的兴趣,只能得到用户行为之间的依赖关系,不能有效的表示用户的兴趣。因为是用户的兴趣导致了用户的点击,用户的最后一次点击与用户点击之前的兴趣相关性就很强,但是直接使用行为序列训练GRU的话,只有用户最后一次点击的物品(也就是label,在这里可以认为是Target Ad), 那么最多就是能够捕捉到用户最后一次点击时的兴趣,而最后一次的兴趣又和前面点击过的物品在兴趣上是相关的,而前面点击的物品中并没有target item进行监督。**所以作者提出的辅助损失就是为了让行为序列中的每一个时刻都有一个target item进行监督训练,也就是使用下一个行为来监督兴趣状态的学习** + +**辅助损失** +首先需要明确的就是辅助损失是计算哪两个量的损失。计算的是用户每个时刻的兴趣表示(GRU每个时刻输出的隐藏状态形成的序列)与用户当前时刻实际点击的物品表示(输入的embedding序列)之间的损失,相当于是行为序列中的第t+1个物品与用户第t时刻的兴趣表示之间的损失**(为什么这里用户第t时刻的兴趣与第t+1时刻的真实点击做损失呢?我的理解是,只有知道了用户第t+1真实点击的商品,才能更好的确定用户第t时刻的兴趣)。** + ++
++ +当然,如果只计算用户点击物品与其点击前一次的兴趣之间的损失,只能认为是正样本之间的损失,那么用户第t时刻的兴趣其实还有很多其他的未点击的商品,这些未点击的商品就是负样本,负样本一般通过从用户点击序列中采样得到,这样一来辅助损失中就包含了用户某个时刻下的兴趣及与该时刻兴趣相关的正负物品。所以最终的损失函数表示如下。 + ++
++其中$h_t^i$表示的是用户$i$第$t$时刻的隐藏状态,可以表示用户第$t$时刻的兴趣向量,$e_b^i,\hat{e_b^i}$分别表示的是正负样本,$e_b^i[t+1]$表示的是用户$i$第$t+1$时刻点击的物品向量。 + +辅助损失会加到最终的目标损失(ctr损失)中一起进行优化,并且通过$\alpha$参数来平衡点击率和兴趣的关系 +$$ +L = L_{target} + \alpha L_{aux} +$$ + +**引入辅助函数的函数有:** + +- 辅助loss可以帮助GRU的隐状态更好地表示用户兴趣。 + +- RNN在长序列建模场景下梯度传播可能并不能很好的影响到序列开始部分,如果在序列的每个部分都引入一个辅助的监督信号,则可一定程度降低优化难度。 + +- 辅助loss可以给embedding层的学习带来更多语义信息,学习到item对应的更好的embedding。 + +### Interest Evolving Layer +将用户的行为序列通过GRU+辅助损失建模之后,对用户行为序列中的兴趣进行了提取并表达成了向量的形式(GRU每个时刻输出的隐藏状态)。而用户的兴趣会因为外部环境或内部认知随着时间变化,特点如下: + +- **兴趣是多样化的,可能发生漂移**。兴趣漂移对行为的影响是用户可能在一段时间内对各种书籍感兴趣,而在另一段时间却需要衣服 + +- 虽然兴趣可能会相互影响,但是**每一种兴趣都有自己的发展过程**,例如书和衣服的发展过程几乎是独立的。**而我们只关注与target item相关的演进过程。** + +由于用户的兴趣是多样的,但是用户的每一种兴趣都有自己的发展过程,即使兴趣发生漂移我们可以只考虑用户与target item(广告或者商品)相关的兴趣演化过程,这样就不用考虑用户多样化的兴趣的问题了,而如何只获取与target item相关的信息,作者使用了与DIN模型中提取与target item相同的方法,来计算用户历史兴趣与target item之间的相似度,即这里也使用了DIN中介绍的局部激活单元(就是下图中的Attention模块)。 + ++
++ +当得到了用户历史兴趣序列及兴趣序列与target item之间的相关性(注意力分数)之后,就需要再次对注意力序列进行建模得到用户注意力的演化过程,进一步表示用户最终的兴趣向量。此时的序列数据等同于有了一个序列及序列中每个向量的注意力权重,下面就是考虑如何使用这个注意力权重来一起优化序列建模的结果了。作者提出了三种注意力结合的GRU模型快: + +1. **AIGRU:** 将注意力分数直接与输入的序列进行相乘,也就是权重越大的向量对应的值也越大, 其中$i_t^{'}, h_t, a_t$分别表示用户$i$在兴趣演化过程使用的GRU的第t时刻的输入,$h_t$表示的是兴趣抽取层第t时刻的输出,$a_t$表示的是$h_t$的注意力分数,这种方式的弊端是即使是零输入也会改变GRU的隐藏状态,所以相对较少的兴趣值也会影响兴趣的学习进化(根据GRU门的更新公式就可以知道,下一个隐藏状态的计算会用到上一个隐藏状态的信息,所以即使当前输入为0,最终隐藏状态也不会直接等于0,所以即使兴趣较少,也会影响到最终兴趣的演化)。 + $$ + i_t^{'} = h_t * a_t + $$ + +2. **AGRU:** 将注意力分数直接作为GRU模块中,更新门的值,则重置门对应的值表示为$1-a_t$, 所以最终隐藏状态的更新公式表示为:其中$\hat{h_t^{'}}$表示的是候选隐藏状态。但是这种方式的弊端是弱化了兴趣之间的相关性,因为最终兴趣的更新前后是没关系的,只取决于输入的注意力分数 + $$ + h_t^{'} = (1-a_t)h_{t-1}^{'} + a_t * \tilde{h_t^{'}} + $$ + +3. **AUGRU:** 将注意力分数作为更新门的权重,这样既兼顾了注意力分数很低时的状态更新值,也利用了兴趣之间的相关性,最终的表达式如下: + $$ + \begin{align} + & \tilde{u_t^{'}} = a_t * u_t \\ + & h_t^{'} = (1-\tilde{u_t^{'}})h_{t-1}^{'} + \tilde{u_t^{'}} * \tilde{h_t^{'}} + \end{align} + $$ + +**建模兴趣演化过程的好处:** +- 追踪用户的interest可以使我们学习final interest的表达时包含更多的历史信息 +- 可以根据interest的变化趋势更好地进行CTR预测 + +## 代码实现 +下面我们看下DIN的代码复现,这里主要是给大家说一下这个模型的设计逻辑,参考了deepctr的函数API的编程风格, 具体的代码以及示例大家可以去参考后面的GitHub,里面已经给出了详细的注释, 这里主要分析模型的逻辑这块。关于函数API的编程式风格,我们还给出了一份文档, 大家可以先看这个,再看后面的代码部分,会更加舒服些。下面开始: + +这里主要和大家说一下DIN模型的总体运行逻辑,这样可以让大家从宏观的层面去把握模型的编写过程。该模型所使用的数据集是movielens数据集, 具体介绍可以参考后面的GitHub。 因为上面反复强调了DIN的应用场景,需要基于用户的历史行为数据, 所以在这个数据集中会有用户过去对电影评分的一系列行为。这在之前的数据集中往往是看不到的。 大家可以导入数据之后自行查看这种行为特征(hist_behavior)。另外还有一点需要说明的是这种历史行为是序列性质的特征, 并且**不同的用户这种历史行为特征长度会不一样**, 但是我们的神经网络是要求序列等长的,所以这种情况我们一般会按照最长的序列进行padding的操作(不够长的填0), 而到具体层上进行运算的时候,会用mask掩码的方式标记出这些填充的位置,好保证计算的准确性。 在我们给出的代码中,大家会在AttentionPoolingLayer层的前向传播中看到这种操作。下面开始说编写逻辑: + +首先, DIN模型的输入特征大致上分为了三类: Dense(连续型), Sparse(离散型), VarlenSparse(变长离散型),也就是指的上面的历史行为数据。而不同的类型特征也就决定了后面处理的方式会不同: + +* Dense型特征:由于是数值型了,这里为每个这样的特征建立Input层接收这种输入, 然后拼接起来先放着,等离散的那边处理好之后,和离散的拼接起来进DNN +* Sparse型特征,为离散型特征建立Input层接收输入,然后需要先通过embedding层转成低维稠密向量,然后拼接起来放着,等变长离散那边处理好之后, 一块拼起来进DNN, 但是这里面要注意有个特征的embedding向量还得拿出来用,就是候选商品的embedding向量,这个还得和后面的计算相关性,对历史行为序列加权。 +* VarlenSparse型特征:这个一般指的用户的历史行为特征,变长数据, 首先会进行padding操作成等长, 然后建立Input层接收输入,然后通过embedding层得到各自历史行为的embedding向量, 拿着这些向量与上面的候选商品embedding向量进入AttentionPoolingLayer去对这些历史行为特征加权合并,最后得到输出。 + +通过上面的三种处理, 就得到了处理好的连续特征,离散特征和变长离散特征, 接下来把这三种特征拼接,进DNN网络,得到最后的输出结果即可。所以有了这个解释, 就可以放DIN模型的代码全貌了,大家可以感受下我上面解释的: + +```python +def DIEN(feature_columns, behavior_feature_list, behavior_seq_feature_list, neg_seq_feature_list, use_neg_sample=False, alpha=1.0): + # 构建输入层 + input_layer_dict = build_input_layers(feature_columns) + + # 将Input层转化为列表的形式作为model的输入 + input_layers = list(input_layer_dict.values()) # 各个输入层 + user_behavior_length = input_layer_dict["hist_len"] + + # 筛选出特征中的sparse_fea, dense_fea, varlen_fea + sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns)) if feature_columns else [] + dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), feature_columns)) if feature_columns else [] + varlen_sparse_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), feature_columns)) if feature_columns else [] + + # 获取dense + dnn_dense_input = [] + for fc in dense_feature_columns: + dnn_dense_input.append(input_layer_dict[fc.name]) + + # 将所有的dense特征拼接 + dnn_dense_input = concat_input_list(dnn_dense_input) + + # 构建embedding字典 + embedding_layer_dict = build_embedding_layers(feature_columns, input_layer_dict) + + # 因为这里最终需要将embedding拼接后直接输入到全连接层(Dense)中, 所以需要Flatten + dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=True) + # 将所有sparse特征的embedding进行拼接 + dnn_sparse_input = concat_input_list(dnn_sparse_embed_input) + + # 获取当前的行为特征(movie)的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起 + query_embed_list = embedding_lookup(behavior_feature_list, input_layer_dict, embedding_layer_dict) + # 获取行为序列(movie_id序列, hist_movie_id) 对应的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起 + keys_embed_list = embedding_lookup(behavior_seq_feature_list, input_layer_dict, embedding_layer_dict) + # 把q,k的embedding拼在一块 + query_emb, keys_emb = concat_input_list(query_embed_list), concat_input_list(keys_embed_list) + + # 采样的负行为 + neg_uiseq_embed_list = embedding_lookup(neg_seq_feature_list, input_layer_dict, embedding_layer_dict) + neg_concat_behavior = concat_input_list(neg_uiseq_embed_list) + + # 兴趣进化层的计算过程 + dnn_seq_input, aux_loss = interest_evolution(keys_emb, query_emb, user_behavior_length, neg_concat_behavior, gru_type="AUGRU") + + # 后面的全连接层 + deep_input_embed = Concatenate()([dnn_dense_input, dnn_sparse_input, dnn_seq_input]) + + # 获取最终dnn的logits + dnn_logits = get_dnn_logits(deep_input_embed, activation='prelu') + model = Model(input_layers, dnn_logits) + + # 加兴趣提取层的损失 这个比例可调 + if use_neg_sample: + model.add_loss(alpha * aux_loss) + + # 所有变量需要初始化 + tf.compat.v1.keras.backend.get_session().run(tf.compat.v1.global_variables_initializer()) + return model +``` + +关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播。(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。 + +下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中(看不清的话可以自己用代码生成之后使用其他的软件打开看)。 + +> 下面这个图失效了 ++
++ +## 思考 +1. 对于知乎上大佬们对DIEN的探讨,你有什么看法呢?[也评Deep Interest Evolution Network](https://zhuanlan.zhihu.com/p/54838663) + + +**参考资料** +- [deepctr](https://github.com/shenweichen/DeepCTR) +- [原论文](https://arxiv.org/pdf/1809.03672.pdf) +- [论文阅读-阿里DIEN深度兴趣进化网络之总体解读](https://mp.weixin.qq.com/s/IlVZCVtDco3hWuvnsUmekg) +- [也评Deep Interest Evolution Network](https://zhuanlan.zhihu.com/p/54838663) + diff --git a/4.人工智能/ch02/ch2.2/ch2.2.4/DIN.md b/4.人工智能/ch02/ch2.2/ch2.2.4/DIN.md new file mode 100644 index 0000000..f99a9c5 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.4/DIN.md @@ -0,0 +1,177 @@ +# DIN +## 动机 +Deep Interest Network(DIIN)是2018年阿里巴巴提出来的模型, 该模型基于业务的观察,从实际应用的角度进行改进,相比于之前很多“学术风”的深度模型, 该模型更加具有业务气息。该模型的应用场景是阿里巴巴的电商广告推荐业务, 这样的场景下一般**会有大量的用户历史行为信息**, 这个其实是很关键的,因为DIN模型的创新点或者解决的问题就是使用了注意力机制来对用户的兴趣动态模拟, 而这个模拟过程存在的前提就是用户之前有大量的历史行为了,这样我们在预测某个商品广告用户是否点击的时候,就可以参考他之前购买过或者查看过的商品,这样就能猜测出用户的大致兴趣来,这样我们的推荐才能做的更加到位,所以这个模型的使用场景是**非常注重用户的历史行为特征(历史购买过的商品或者类别信息)**,也希望通过这一点,能够和前面的一些深度学习模型对比一下。 + +在个性化的电商广告推荐业务场景中,也正式由于用户留下了大量的历史交互行为,才更加看出了之前的深度学习模型(作者统称Embeding&MLP模型)的不足之处。如果学习了前面的各种深度学习模型,就会发现Embeding&MLP模型对于这种推荐任务一般有着差不多的固定处理套路,就是大量稀疏特征先经过embedding层, 转成低维稠密的,然后进行拼接,最后喂入到多层神经网络中去。 + +这些模型在这种个性化广告点击预测任务中存在的问题就是**无法表达用户广泛的兴趣**,因为这些模型在得到各个特征的embedding之后,就蛮力拼接了,然后就各种交叉等。这时候根本没有考虑之前用户历史行为商品具体是什么,究竟用户历史行为中的哪个会对当前的点击预测带来积极的作用。 而实际上,对于用户点不点击当前的商品广告,很大程度上是依赖于他的历史行为的,王喆老师举了个例子 + +>假设广告中的商品是键盘, 如果用户历史点击的商品中有化妆品, 包包,衣服, 洗面奶等商品, 那么大概率上该用户可能是对键盘不感兴趣的, 而如果用户历史行为中的商品有鼠标, 电脑,iPad,手机等, 那么大概率该用户对键盘是感兴趣的, 而如果用户历史商品中有鼠标, 化妆品, T-shirt和洗面奶, 鼠标这个商品embedding对预测“键盘”广告的点击率的重要程度应该大于后面的那三个。 + +这里也就是说如果是之前的那些深度学习模型,是没法很好的去表达出用户这广泛多样的兴趣的,如果想表达的准确些, 那么就得加大隐向量的维度,让每个特征的信息更加丰富, 那这样带来的问题就是计算量上去了,毕竟真实情景尤其是电商广告推荐的场景,特征维度的规模是非常大的。 并且根据上面的例子, 也**并不是用户所有的历史行为特征都会对某个商品广告点击预测起到作用**。所以对于当前某个商品广告的点击预测任务,没必要考虑之前所有的用户历史行为。 + +这样, DIN的动机就出来了,在业务的角度,我们应该自适应的去捕捉用户的兴趣变化,这样才能较为准确的实施广告推荐;而放到模型的角度, 我们应该**考虑到用户的历史行为商品与当前商品广告的一个关联性**,如果用户历史商品中很多与当前商品关联,那么说明该商品可能符合用户的品味,就把该广告推荐给他。而一谈到关联性的话, 我们就容易想到“注意力”的思想了, 所以为了更好的从用户的历史行为中学习到与当前商品广告的关联性,学习到用户的兴趣变化, 作者把注意力引入到了模型,设计了一个"local activation unit"结构,利用候选商品和历史问题商品之间的相关性计算出权重,这个就代表了对于当前商品广告的预测,用户历史行为的各个商品的重要程度大小, 而加入了注意力权重的深度学习网络,就是这次的主角DIN, 下面具体来看下该模型。 + +## DIN模型结构及原理 +在具体分析DIN模型之前, 我们还得先介绍两块小内容,一个是DIN模型的数据集和特征表示, 一个是上面提到的之前深度学习模型的基线模型, 有了这两个, 再看DIN模型,就感觉是水到渠成了。 + +### 特征表示 +工业上的CTR预测数据集一般都是`multi-group categorial form`的形式,就是类别型特征最为常见,这种数据集一般长这样: + ++
++ +这里的亮点就是框出来的那个特征,这个包含着丰富的用户兴趣信息。 + +对于特征编码,作者这里举了个例子:`[weekday=Friday, gender=Female, visited_cate_ids={Bag,Book}, ad_cate_id=Book]`, 这种情况我们知道一般是通过one-hot的形式对其编码, 转成系数的二值特征的形式。但是这里我们会发现一个`visted_cate_ids`, 也就是用户的历史商品列表, 对于某个用户来讲,这个值是个多值型的特征, 而且还要知道这个特征的长度不一样长,也就是用户购买的历史商品个数不一样多,这个显然。这个特征的话,我们一般是用到multi-hot编码,也就是可能不止1个1了,有哪个商品,对应位置就是1, 所以经过编码后的数据长下面这个样子: ++
++这个就是喂入模型的数据格式了,这里还要注意一点 就是上面的特征里面没有任何的交互组合,也就是没有做特征交叉。这个交互信息交给后面的神经网络去学习。 + +### 基线模型 + +这里的base 模型,就是上面提到过的Embedding&MLP的形式, 这个之所以要介绍,就是因为DIN网络的基准也是他,只不过在这个的基础上添加了一个新结构(注意力网络)来学习当前候选广告与用户历史行为特征的相关性,从而动态捕捉用户的兴趣。 + +基准模型的结构相对比较简单,我们前面也一直用这个基准, 分为三大模块:Embedding layer,Pooling & Concat layer和MLP, 结构如下: + ++
++ +前面的大部分深度模型结构也是遵循着这个范式套路, 简介一下各个模块。 + +1. **Embedding layer**:这个层的作用是把高维稀疏的输入转成低维稠密向量, 每个离散特征下面都会对应着一个embedding词典, 维度是$D\times K$, 这里的$D$表示的是隐向量的维度, 而$K$表示的是当前离散特征的唯一取值个数, 这里为了好理解,这里举个例子说明,就比如上面的weekday特征: + +> 假设某个用户的weekday特征就是周五,化成one-hot编码的时候,就是[0,0,0,0,1,0,0]表示,这里如果再假设隐向量维度是D, 那么这个特征对应的embedding词典是一个$D\times7$的一个矩阵(每一列代表一个embedding,7列正好7个embedding向量,对应周一到周日),那么该用户这个one-hot向量经过embedding层之后会得到一个$D\times1$的向量,也就是周五对应的那个embedding,怎么算的,其实就是$embedding矩阵* [0,0,0,0,1,0,0]^T$ 。其实也就是直接把embedding矩阵中one-hot向量为1的那个位置的embedding向量拿出来。 这样就得到了稀疏特征的稠密向量了。其他离散特征也是同理,只不过上面那个multi-hot编码的那个,会得到一个embedding向量的列表,因为他开始的那个multi-hot向量不止有一个是1,这样乘以embedding矩阵,就会得到一个列表了。通过这个层,上面的输入特征都可以拿到相应的稠密embedding向量了。 + +2. **pooling layer and Concat layer**: pooling层的作用是将用户的历史行为embedding这个最终变成一个定长的向量,因为每个用户历史购买的商品数是不一样的, 也就是每个用户multi-hot中1的个数不一致,这样经过embedding层,得到的用户历史行为embedding的个数不一样多,也就是上面的embedding列表$t_i$不一样长, 那么这样的话,每个用户的历史行为特征拼起来就不一样长了。 而后面如果加全连接网络的话,我们知道,他需要定长的特征输入。 所以往往用一个pooling layer先把用户历史行为embedding变成固定长度(统一长度),所以有了这个公式: +$$ + e_i=pooling(e_{i1}, e_{i2}, ...e_{ik}) +$$ +这里的$e_{ij}$是用户历史行为的那些embedding。$e_i$就变成了定长的向量, 这里的$i$表示第$i$个历史特征组(是历史行为,比如历史的商品id,历史的商品类别id等), 这里的$k$表示对应历史特种组里面用户购买过的商品数量,也就是历史embedding的数量,看上面图里面的user behaviors系列,就是那个过程了。 Concat layer层的作用就是拼接了,就是把这所有的特征embedding向量,如果再有连续特征的话也算上,从特征维度拼接整合,作为MLP的输入。 + +3. **MLP**:这个就是普通的全连接,用了学习特征之间的各种交互。 + +4. **Loss**: 由于这里是点击率预测任务, 二分类的问题,所以这里的损失函数用的负的log对数似然: +$$ +L=-\frac{1}{N} \sum_{(\boldsymbol{x}, y) \in \mathcal{S}}(y \log p(\boldsymbol{x})+(1-y) \log (1-p(\boldsymbol{x}))) +$$ + +这就是base 模型的全貌, 这里应该能看出这种模型的问题, 通过上面的图也能看出来, 用户的历史行为特征和当前的候选广告特征在全都拼起来给神经网络之前,是一点交互的过程都没有, 而拼起来之后给神经网络,虽然是有了交互了,但是原来的一些信息,比如,每个历史商品的信息会丢失了一部分,因为这个与当前候选广告商品交互的是池化后的历史特征embedding, 这个embedding是综合了所有的历史商品信息, 这个通过我们前面的分析,对于预测当前广告点击率,并不是所有历史商品都有用,综合所有的商品信息反而会增加一些噪声性的信息,可以联想上面举得那个键盘鼠标的例子,如果加上了各种洗面奶,衣服啥的反而会起到反作用。其次就是这样综合起来,已经没法再看出到底用户历史行为中的哪个商品与当前商品比较相关,也就是丢失了历史行为中各个商品对当前预测的重要性程度。最后一点就是如果所有用户浏览过的历史行为商品,最后都通过embedding和pooling转换成了固定长度的embedding,这样会限制模型学习用户的多样化兴趣。 + +那么改进这个问题的思路有哪些呢? 第一个就是加大embedding的维度,增加之前各个商品的表达能力,这样即使综合起来,embedding的表达能力也会加强, 能够蕴涵用户的兴趣信息,但是这个在大规模的真实推荐场景计算量超级大,不可取。 另外一个思路就是**在当前候选广告和用户的历史行为之间引入注意力的机制**,这样在预测当前广告是否点击的时候,让模型更关注于与当前广告相关的那些用户历史产品,也就是说**与当前商品更加相关的历史行为更能促进用户的点击行为**。 作者这里又举了之前的一个例子: +> 想象一下,当一个年轻母亲访问电子商务网站时,她发现展示的新手袋很可爱,就点击它。让我们来分析一下点击行为的驱动力。+
展示的广告通过软搜索这位年轻母亲的历史行为,发现她最近曾浏览过类似的商品,如大手提袋和皮包,从而击中了她的相关兴趣 + +第二个思路就是DIN的改进之处了。DIN通过给定一个候选广告,然后去注意与该广告相关的局部兴趣的表示来模拟此过程。 DIN不会通过使用同一向量来表达所有用户的不同兴趣,而是通过考虑历史行为的相关性来自适应地计算用户兴趣的表示向量(对于给的广告)。 该表示向量随不同广告而变化。下面看一下DIN模型。 + +### DIN模型架构 + +上面分析完了base模型的不足和改进思路之后,DIN模型的结构就呼之欲出了,首先,它依然是采用了基模型的结构,只不过是在这个的基础上加了一个注意力机制来学习用户兴趣与当前候选广告间的关联程度, 用论文里面的话是,引入了一个新的`local activation unit`, 这个东西用在了用户历史行为特征上面, **能够根据用户历史行为特征和当前广告的相关性给用户历史行为特征embedding进行加权**。我们先看一下它的结构,然后看一下这个加权公式。 + +++ +这里改进的地方已经框出来了,这里会发现相比于base model, 这里加了一个local activation unit, 这里面是一个前馈神经网络,输入是用户历史行为商品和当前的候选商品, 输出是它俩之间的相关性, 这个相关性相当于每个历史商品的权重,把这个权重与原来的历史行为embedding相乘求和就得到了用户的兴趣表示$\boldsymbol{v}_{U}(A)$, 这个东西的计算公式如下: +$$ +\boldsymbol{v}_{U}(A)=f\left(\boldsymbol{v}_{A}, \boldsymbol{e}_{1}, \boldsymbol{e}_{2}, \ldots, \boldsymbol{e}_{H}\right)=\sum_{j=1}^{H} a\left(\boldsymbol{e}_{j}, \boldsymbol{v}_{A}\right) \boldsymbol{e}_{j}=\sum_{j=1}^{H} \boldsymbol{w}_{j} \boldsymbol{e}_{j} +$$ +这里的$\{\boldsymbol{v}_{A}, \boldsymbol{e}_{1}, \boldsymbol{e}_{2}, \ldots, \boldsymbol{e}_{H}\}$是用户$U$的历史行为特征embedding, $v_{A}$表示的是候选广告$A$的embedding向量, $a(e_j, v_A)=w_j$表示的权重或者历史行为商品与当前广告$A$的相关性程度。$a(\cdot)$表示的上面那个前馈神经网络,也就是那个所谓的注意力机制, 当然,看图里的话,输入除了历史行为向量和候选广告向量外,还加了一个它俩的外积操作,作者说这里是有利于模型相关性建模的显性知识。 + +这里有一点需要特别注意,就是这里的权重加和不是1, 准确的说这里不是权重, 而是直接算的相关性的那种分数作为了权重,也就是平时的那种scores(softmax之前的那个值),这个是为了保留用户的兴趣强度。 + +## DIN实现 + +下面我们看下DIN的代码复现,这里主要是给大家说一下这个模型的设计逻辑,参考了deepctr的函数API的编程风格, 具体的代码以及示例大家可以去参考后面的GitHub,里面已经给出了详细的注释, 这里主要分析模型的逻辑这块。关于函数API的编程式风格,我们还给出了一份文档, 大家可以先看这个,再看后面的代码部分,会更加舒服些。下面开始: + +这里主要和大家说一下DIN模型的总体运行逻辑,这样可以让大家从宏观的层面去把握模型的编写过程。该模型所使用的数据集是movielens数据集, 具体介绍可以参考后面的GitHub。 因为上面反复强调了DIN的应用场景,需要基于用户的历史行为数据, 所以在这个数据集中会有用户过去对电影评分的一系列行为。这在之前的数据集中往往是看不到的。 大家可以导入数据之后自行查看这种行为特征(hist_behavior)。另外还有一点需要说明的是这种历史行为是序列性质的特征, 并且**不同的用户这种历史行为特征长度会不一样**, 但是我们的神经网络是要求序列等长的,所以这种情况我们一般会按照最长的序列进行padding的操作(不够长的填0), 而到具体层上进行运算的时候,会用mask掩码的方式标记出这些填充的位置,好保证计算的准确性。 在我们给出的代码中,大家会在AttentionPoolingLayer层的前向传播中看到这种操作。下面开始说编写逻辑: + +首先, DIN模型的输入特征大致上分为了三类: Dense(连续型), Sparse(离散型), VarlenSparse(变长离散型),也就是指的上面的历史行为数据。而不同的类型特征也就决定了后面处理的方式会不同: + +* Dense型特征:由于是数值型了,这里为每个这样的特征建立Input层接收这种输入, 然后拼接起来先放着,等离散的那边处理好之后,和离散的拼接起来进DNN +* Sparse型特征,为离散型特征建立Input层接收输入,然后需要先通过embedding层转成低维稠密向量,然后拼接起来放着,等变长离散那边处理好之后, 一块拼起来进DNN, 但是这里面要注意有个特征的embedding向量还得拿出来用,就是候选商品的embedding向量,这个还得和后面的计算相关性,对历史行为序列加权。 +* VarlenSparse型特征:这个一般指的用户的历史行为特征,变长数据, 首先会进行padding操作成等长, 然后建立Input层接收输入,然后通过embedding层得到各自历史行为的embedding向量, 拿着这些向量与上面的候选商品embedding向量进入AttentionPoolingLayer去对这些历史行为特征加权合并,最后得到输出。 + +通过上面的三种处理, 就得到了处理好的连续特征,离散特征和变长离散特征, 接下来把这三种特征拼接,进DNN网络,得到最后的输出结果即可。所以有了这个解释, 就可以放DIN模型的代码全貌了,大家可以感受下我上面解释的: + +```python +# DIN网络搭建 +def DIN(feature_columns, behavior_feature_list, behavior_seq_feature_list): + """ + 这里搭建DIN网络,有了上面的各个模块,这里直接拼起来 + :param feature_columns: A list. 里面的每个元素是namedtuple(元组的一种扩展类型,同时支持序号和属性名访问组件)类型,表示的是数据的特征封装版 + :param behavior_feature_list: A list. 用户的候选行为列表 + :param behavior_seq_feature_list: A list. 用户的历史行为列表 + """ + # 构建Input层并将Input层转成列表作为模型的输入 + input_layer_dict = build_input_layers(feature_columns) + input_layers = list(input_layer_dict.values()) + + # 筛选出特征中的sparse和Dense特征, 后面要单独处理 + sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns)) + dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), feature_columns)) + + # 获取Dense Input + dnn_dense_input = [] + for fc in dense_feature_columns: + dnn_dense_input.append(input_layer_dict[fc.name]) + + # 将所有的dense特征拼接 + dnn_dense_input = concat_input_list(dnn_dense_input) # (None, dense_fea_nums) + + # 构建embedding字典 + embedding_layer_dict = build_embedding_layers(feature_columns, input_layer_dict) + + # 离散的这些特特征embedding之后,然后拼接,然后直接作为全连接层Dense的输入,所以需要进行Flatten + dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=True) + + # 将所有的sparse特征embedding特征拼接 + dnn_sparse_input = concat_input_list(dnn_sparse_embed_input) # (None, sparse_fea_nums*embed_dim) + + # 获取当前行为特征的embedding, 这里有可能有多个行为产生了行为列表,所以需要列表将其放在一起 + query_embed_list = embedding_lookup(behavior_feature_list, input_layer_dict, embedding_layer_dict) + + # 获取历史行为的embedding, 这里有可能有多个行为产生了行为列表,所以需要列表将其放在一起 + keys_embed_list = embedding_lookup(behavior_seq_feature_list, input_layer_dict, embedding_layer_dict) + # 使用注意力机制将历史行为的序列池化,得到用户的兴趣 + dnn_seq_input_list = [] + for i in range(len(keys_embed_list)): + seq_embed = AttentionPoolingLayer()([query_embed_list[i], keys_embed_list[i]]) # (None, embed_dim) + dnn_seq_input_list.append(seq_embed) + + # 将多个行为序列的embedding进行拼接 + dnn_seq_input = concat_input_list(dnn_seq_input_list) # (None, hist_len*embed_dim) + + # 将dense特征,sparse特征, 即通过注意力机制加权的序列特征拼接起来 + dnn_input = Concatenate(axis=1)([dnn_dense_input, dnn_sparse_input, dnn_seq_input]) # (None, dense_fea_num+sparse_fea_nums*embed_dim+hist_len*embed_dim) + + # 获取最终的DNN的预测值 + dnn_logits = get_dnn_logits(dnn_input, activation='prelu') + + model = Model(inputs=input_layers, outputs=dnn_logits) + + return model +``` + +关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播。(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。 + ++
++ +下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。 + ++
++ +## 思考 +DIN模型在工业上的应用还是比较广泛的, 大家可以自由去通过查资料看一下具体实践当中这个模型是怎么用的? 有什么问题?比如行为序列的制作是否合理, 如果时间间隔比较长的话应不应该分一下段? 再比如注意力机制那里能不能改成别的计算注意力的方式会好点?(我们也知道注意力机制的方式可不仅DNN这一种), 再比如注意力权重那里该不该加softmax? 这些其实都是可以值的思考探索的一些问题,根据实际的业务场景,大家也可以总结一些更加有意思的工业上应用该模型的技巧和tricks,欢迎一块讨论和分享。 + +**参考资料** +* [DIN原论文](https://arxiv.org/pdf/1706.06978.pdf) +* [deepctr](https://github.com/shenweichen/DeepCTR) +* [AI上推荐 之 AFM与DIN模型(当推荐系统遇上了注意力机制)](https://blog.csdn.net/wuzhongqiang/article/details/109532346) +* 王喆 - 《深度学习推荐系统》 \ No newline at end of file diff --git a/4.人工智能/ch02/ch2.2/ch2.2.4/DSIN.md b/4.人工智能/ch02/ch2.2/ch2.2.4/DSIN.md new file mode 100644 index 0000000..3a8dd0d --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.4/DSIN.md @@ -0,0 +1,731 @@ +## 写在前面 +DSIN全称是Deep Session Interest Network(深度会话兴趣网络), 重点在这个Session上,这个是在DIEN的基础上又进行的一次演化,这个模型的改进出发点依然是如何通过用户的历史点击行为,从里面更好的提取用户的兴趣以及兴趣的演化过程,这个模型就是从user历史行为信息挖掘方向上进行演化的。而提出的动机呢? 就是作者发现用户的行为序列的组成单位,其实应该是会话(按照用户的点击时间划分开的一段行为),每个会话里面的点击行为呢? 会高度相似,而会话与会话之间的行为,就不是那么相似了,但是像DIN,DIEN这两个模型,DIN的话,是直接忽略了行为之间的序列关系,使得对用户的兴趣建模或者演化不是很充分,而DIEN的话改进了DIN的序列关系的忽略缺点,但是忽视了行为序列的本质组成结构。所以阿里提出的DSIN模型就是从行为序列的组成结构会话的角度去进行用户兴趣的提取和演化过程的学习,在这个过程中用到了一些新的结构,比如Transformer中的多头注意力,比如双向LSTM结构,再比如前面的局部Attention结构。 + + +## DSIN模型的理论以及论文细节 +### DSIN的简介与进化动机 +DSIN模型全称叫做Deep Session Interest Network, 这个是阿里在2019年继DIEN之后的一个新模型, 这个模型依然是研究如何更好的从用户的历史行为中捕捉到用户的动态兴趣演化规律。而这个模型的改进动机呢? 就是作者认为之前的序列模型,比如DIEN等,忽视了序列的本质结构其实是由会话组成的: + ++
++ +这是个啥意思呢? 其实举个例子就非常容易明白DIEN存在的问题了(DIN这里就不说了,这个存在的问题在DIEN那里说的挺详细了,这里看看DIEN有啥问题),上一篇文章中我们说DIEN为了能够更好的利用用户的历史行为信息,把序列模型引进了推荐系统,用来学习用户历史行为之间的关系, 用兴趣提取层来学习各个历史行为之间的关系,而为了更有针对性的模拟与目标广告相关的兴趣进化路径,又在兴趣提取层后面加了注意力机制和兴趣进化层网络。这样理论上就感觉挺完美的了啊。这里依然是把DIEN拿过来,也方便和后面的DSIN对比:+
+ +++ +但这个模型存在个啥问题呢? **就是只关注了如何去改进网络,而忽略了用户历史行为序列本身的特点**, 其实我们仔细想想的话,用户过去可能有很多历史点击行为,比如`[item3, item45, item69, item21, .....]`, 这个按照用户的点击时间排好序了,既然我们说用户的兴趣是非常广泛且多变的,那么这一大串序列的商品中,往往出现的一个规律就是**在比较短的时间间隔内的商品往往会很相似,时间间隔长了之后,商品之间就会出现很大的差别**,这个是很容易理解的,一个用户在半个小时之内的浏览点击的几个商品的相似度和一个用户上午点击和晚上点击的商品的相似度很可能是不一样的。这其实就是作者说的`homogeneous`和`heterogeneous`。而DIEN模型呢? 它并没有考虑这个问题,而是会直接把这一大串行为序列放入GRU让它自己去学(当然我们其实可以人工考虑这个问题,然后如果发现序列很长的话我们也可以分成多个样本哈,当然这里不考虑这个问题),如果一大串序列一块让GRU学习的话,往往用户的行为快速改变和突然终止的序列会有很多噪声点,不利于模型的学习。 + +所以,作者这里就是从序列本身的特点出发, 把一个用户的行为序列分成了多个会话,所谓会话,其实就是按照时间间隔把序列分段,每一段的商品列表就是一个会话,那这时候,会话里面每个商品之间的相似度就比较大了,而会话与会话之间商品相似度就可能比较小。作者这里给了个例子: + ++
++ +这是某个用户过去的历史点击行为,然后按照30分钟的时间间隔进行的分段,分成了3段。这里就一下子看出上面说的那些是啥意思了吧。就像这个女生,前30分钟在看裤子,再过30分钟又停留在了化妆品,又过30分钟,又看衣服。这种现象是非常普遍的啊,反映了一个用户通常在某个会话里面会有非常单一的兴趣但是当过一段时间之后,兴趣就会突然的改变。这个时候如果再统一的考虑所有行为就不合理了呀。**这其实也是DSIN改进的动机了**, DSIN,这次的关键就是在S上。 + +那它要怎么改呢? 如果是我们的话应该怎么改呢? 那一定会说,这个简单啊,不是说DIEN没考虑序列本身的特点吗? 既然我们发现了上面用户点击行为的这种会话规律,那么我们把序列进行分段,然后再用DIEN不就完事了? 哈哈, 那当然可以呀, 如果想用DIEN的话确实可以这么玩, 但那样就没有新模型了啊,那不还是DIEN? 这样的改进思路是没法发顶会的哟哈哈。 下面分析下人家是怎么改进的。 + +简单的说是用了四步,这个也是DSIN模型的整体逻辑: + +1. 首先, 分段这个是必须的了吧,也就是在用户行为序列输入到模型之前,要按照固定的时间间隔(比如30分钟)给他分开段,每一段里面的商品序列称为一个会话Session。 这个叫做**会话划分层** + +2. 然后呢,就是学习商品时间的依赖关系或者序列关系,由于上面把一个整的行为序列划分成了多段,那么在这里就是每一段的商品时间的序列关系要进行学习,当然我们说可以用GRU, 不过这里作者用了**多头的注意力机制**,这个东西是在**多个角度研究一个会话里面各个商品的关联关系**, 相比GRU来讲,没有啥梯度消失,并且可以并行计算,比GRU可强大多了。这个叫做**会话兴趣提取层** +3. 上面研究了会话内各个商品之间的关联关系,接下来就是研究会话与会话之间的关系了,虽然我们说各个会话之间的关联性貌似不太大,但是可别忘了会话可是能够表示一段时间内用户兴趣的, 所以研究会话与会话的关系其实就是在学习用户兴趣的演化规律,这里用了**双向的LSTM**,不仅看从现在到未来的兴趣演化,还能学习未来到现在的变化规律, 这个叫做**会话交互层**。 +4. 既然会话内各个商品之间的关系学到了,会话与会话之间的关系学到了,然后呢? 当然也是针对性的模拟与目标广告相关的兴趣进化路径了, 所以后面是**会话兴趣局部激活层**, 这个就是注意力机制, 每次关注与当前商品更相关的兴趣。 + +所以,我们细品一下,其实DSIN和思路和DIEN的思路是差不多的,无非就是用了一些新的结构,这样,我们就从宏观上感受了一波这个模型。接下来,研究架构细节了。 看看上面那几块到底是怎么玩的。 + +### DSIN的架构剖析 +这里在说DSIN之前,作者也是又复习了一下base model模型架构,这里我就不整理了,其实是和DIEN那里一模一样的,具体的可以参考我上一篇文章。直接看DSIN的架构: + ++
++ +这个模型第一印象又是挺吓人的。核心的就是上面剖析的那四块,这里也分别用不同颜色表示出来了。也及时右边的那几块,左边的那两块还是我们之前的套路,用户特征和商品特征的串联。这里主要研究右边那四块,作者在这里又强调了下DSIN的两个目的,而这两个目的就对应着本模型最核心的两个层(会话兴趣提取层和会话交互层): + ++
++ +#### Session Division Layer +这一层是将用户的行为序列进行切分,首先将用户的点击行为按照时间排序,判断两个行为之间的时间间隔,如果前后间隔大于30min,就进行切分(划一刀), 当然30min不是定死的,具体跟着自己的业务场景来。 + ++
++ +划分完了之后,我们就把一个行为序列$\mathbf{S}$转成了Sessions $\mathbf{Q}$,比如上面这个分成了4个会话,会分别用$\mathbf{Q_1}, \mathbf{Q_2}, \mathbf{Q_3}, \mathbf{Q_4}$表示。 第$k$个会话$\mathbf{Q_k}$中,又包含了$T$个行为,即 +$$ +\mathbf{Q}_{k}=\left[\mathbf{b}_{1} ; \ldots ; \mathbf{b}_{i} ; \ldots ; \mathbf{b}_{T}\right] \in \mathbb{R}^{T \times d_{\text {model }}} +$$ +$\mathbf{b}_{i}$表示的是第$k$个会话里面的第$i$个点击行为(具体的item),这个东西是一个$d_{model}$维的embedding向量。所以$\mathbf{Q}_{k}$是一个$T \times d_{\text {model }}$维的。 而整个大$\mathbf{Q}$, 就是一个$K\times T \times d_{\text {model }}$维的矩阵。 这里的$K$指的是session的个数。 这样,就把这个给捋明白了。但要注意,这个层是在embedding层之后呀,也就是各个商品转成了embedding向量之后,我们再进行切割。 + +#### Session Interest Extractor Layer +这个层是学习每个会话中各个行为之间的关系,之前也分析过,在同一个会话中的各个商品的相关性是非常大的。此外,作者这里还提到,用户的随意的那种点击行为会偏离用户当前会话兴趣的表达,所以**为了捕获同一会话中行为之间的内在关系,同时降低这些不相关行为的影响**,这里采用了multi-head self-attention。关于这个东西, 这里不会详细整理,可以参考我之前的文章。这里只给出两个最核心关键的图,有了这两个图,这里的知识就非常容易理解了哈哈。 + +第一个,就是Transformer的编码器的一小块: ++
++ +拿过来是为了更好的对比,看DSIN的结构的第二层,其实就是这个东西。 而这个东西的整体的计算过程,我在之前的文章中剖析好了: + ++
++ +有了上面这两张图,在解释这里就非常好说了。 + +这一块其实是分两步的,第一步叫做位置编码,而第二步就是self-attention计算关联。 同样,DSIN中也是这两步,只不过第一步里面的位置编码,作者在这里做了点改进,称为**Bias Encoding**。先看看这个是怎么做的。 +>这里先解释下为啥要进行位置编码或者Bias Encoding, 这是因为我们说self-attention机制是要去学习会话里面各个商品之间的关系的, 而商品我们知道是一个按照时间排好的小序列,由于后面的self-attention并没有循环神经网络的迭代运算,所以我们必须提供每个字的位置信息给后面的self-attention,这样后面self-attention的输出结果才能蕴含商品之间的顺序信息。 + +在Transformer中,对输入的序列会进行Positional Encoding。Positional Encoding对序列中每个物品,以及每个物品对应的Embedding的每个位置,进行了处理,如下: + ++
++ +上式中$pos$指的是某个会话里面item位于第几个位置位置, 取值范围是$[0, max\_len]$, $i$指的是词向量的某个维度, 取值范围是$[0, embed \_ dim]$, 上面有$sin$和$cos$一组公式, 也就是对应着$embed \_ dim$维度的一组奇数和偶数的序号的维度, 例如$0, 1$一组, $2, 3$一组, 分别用上面的$sin$和$cos$函数做处理, 从而产生不同的周期性变化, 而位置嵌入在$embed \_ dim$维度上随着维度序号增大, 周期变化会越来越慢, 而产生一种包含位置信息的纹理, 位置嵌入函数的周期从$2 \pi$到$10000 * 2 \pi$变化, 而每一个位置在$embed \_ dim$维度上都会得到不同周期的$sin$和$cos$函数的取值组合, 从而产生独一的纹理位置信息, 模型从而学到位置之间的依赖关系和自然语言的时序特性。这个在这里说可能有些迷糊,具体可以去另一篇文章看细节,**总结起来,就是通过这个公式,可以让每个item在每个embedding维度上都有独特的位置信息。但注意,位置编码的矩阵和输入的维度是一样的,这样两者加起来之后就相当于原来的序列加上了位置信息** 。 + +而这里,作者并不是用的这种方式,这是因为在这里还需要考虑各个会话之间的位置信息,毕竟这里是多个会话,并且各个会话之间也是有位置顺序的呀,所以还需要对每个会话添加一个Positional Encoding, 在DSIN中,这种对位置的处理,称为Bias Encoding。 + +于是乎作者在这里提出了个$\mathbf{B E} \in \mathbb{R}^{K \times T \times d_{\text {model }}}$,会发现这个东西的维度和会话分割层得到的$\mathbf{Q}$的维度也是一样的啊,其实这个东西就是这里使用的位置编码。那么这个东西咋计算呢? +$$ +\mathbf{B} \mathbf{E}_{(k, t, c)}=\mathbf{w}_{k}^{K}+\mathbf{w}_{t}^{T}+\mathbf{w}_{c}^{C} +$$ +$\mathbf{B} \mathbf{E}_{(k, t, c)}$表示的是第$k$个会话中,第$t$个物品在第$c$维度这个位置上的偏置项(是一个数), 其中$\mathbf{w}^{K} \in \mathbb{R}^{K}$表示的会话层次上的偏置项(位置信息)。如果有$n$个样本的话,这个应该是$[n, K, 1, 1]$的矩阵, 后面两个维度表示的$T$和$emb \_dim$。$\mathbf{w}^{T} \in \mathbb{R}^{T}$这个是在会话里面时间位置层次上的偏置项(位置信息),这个应该是$[n, 1, T, 1]$的矩阵。$\mathbf{w}^{C} \in \mathbb{R}^{d_{\text {model }}}$这个是embedding维度层次上的偏置(位置信息), 这个应该是$[n, 1, 1, d_{model}]$的矩阵。 而上面的$\mathbf{w}_{k}^{K},\mathbf{w}_{t}^{T},\mathbf{w}_{c}^{C}$都是表示某个维度上的具体的数字,所以$\mathbf{B} \mathbf{E}_{(k, t, c)}$也是一个数。 + +所以$\mathbf{B} \mathbf{E}$就是一个$[n,K, T, d_{model}]$的矩阵(这里其实是借助了广播机制的),蕴含了每个会话,每个物品,每个embedding位置的位置信息,所以经过Bias编码之后,得到的结果如下: +$$ +\mathbf{Q}=\mathbf{Q}+\mathbf{B} \mathbf{E} +$$ +这个$\mathbf{Q}$的维度$[n,K, T, d_{model}]$, 当然这里我们先不考虑样本个数,所以是$[K, T, d_{model}]$。相比上面的transformer,这里会多出一个会话的维度来。 + +接下来,就是每个会话的序列都通过Transformer进行处理: + ++
++ +一定要注意,这里说的是每个会话,这里我特意把下面的$Q_1$框出来了,就是每个$Q_i$都会走这个自注意力机制,因为我们算的是某个会话当中各个物品之间的关系。这里的计算和Transformer的block的计算是一模一样的了, 我这里就拿一个会话来解释。 + +首先$Q_1$这是一个$T\times embed \_dim$的一个矩阵,这个就和上面transformer的那个是一模一样的了,细节的计算过程其实是一样的。 + ++
++ +这里在拿过更细的个图来解释,首先这个$Q_1$会过一个多头的注意力机制,这个东西干啥用呢? 原理这里不说,我们只要知道,这里的头其实是从某个角度去看各个物品之间的关系,而多头的意思就是从不同的角度去计算各个物品之间的关系, 比如各个物品在价格上啊,重量上啊,颜色上啊,时尚程度上啊等等这些不同方面的关系。然后就是看这个运算图,我们会发现self-attention的输出维度和输入维度也是一样的,但经过这个多头注意力的东西之后,**就能够得到当前的商品与其他商品在多个角度上的相关性**。怎么得到呢? +>拿一个head来举例子:+
+>我们看看这个$QK^T$在表示啥意思: + +++ +>假设当前会话有6个物品,embedding的维度是3的话,那么会看到这里一成,得到的结果中的每一行其实表示的是当前商品与其他商品之间的一个相似性大小(embedding内积的形式算的相似)。而沿着最后一个维度softmax归一化之后,得到的是个权重值。这是不是又想起我们的注意力机制来的啊,这个就叫做注意力矩阵,我们看看乘以V会是个啥 + ++
++ +>这时候,我们从注意力矩阵取出一行(和为1),然后依次点乘V的列,因为矩阵V的每一行代表着每一个字向量的数学表达,这样操作,**得到的正是注意力权重进行数学表达的加权线性组合,从而使每个物品向量都含有当前序列的所有物品向量的信息**。而多头,不过是含有多个角度的信息罢了,这就是Self-attention的魔力了。 + + +好了, 下面再看论文里面的描述就非常舒服了,如果令$\mathbf{Q}_{k}=\left[\mathbf{Q}_{k 1} ; \ldots ; \mathbf{Q}_{k h} ; \ldots ; \mathbf{Q}_{k H}\right]$, 这里面的$\mathbf{Q}_{k h} \in \mathbb{R}^{T \times d_{h}}$代表的就是多头里面的某一个头了,由于这多个头合起来的维度$d_{model}$维度,那么一个头就是$d_{h}=\frac{1}{h} d_{\text {model }}$, 这里必须要保证能整除才行。这里用了$h$个头。某个头$h$的计算为: +$$ +\begin{aligned} +\text { head }_{h} &=\text { Attention }\left(\mathbf{Q}_{k h} \mathbf{W}^{Q}, \mathbf{Q}_{k h} \mathbf{W}^{K}, \mathbf{Q}_{k h} \mathbf{W}^{V}\right) \\ +&=\operatorname{softmax}\left(\frac{\mathbf{Q}_{k h} \mathbf{W}^{Q} \mathbf{W}^{K^{T}} \mathbf{Q}_{k h}^{T}}{\sqrt{d_{m o d e l}}}\right) \mathbf{Q}_{k h} \mathbf{W}^{V} +\end{aligned} +$$ +这里是某一个头的计算过程, 这里的$\mathbf{W}^{Q}, \mathbf{W}^{K}, \mathbf{W}^{Q}$是要学习的参数,由于是一个头,维度应该是$\frac{1}{h} d_{\text {model }}\times \frac{1}{h} d_{\text {model }}$, 这样的话softmax那块算出来的是$T \times T$的矩阵, 而后面是一个$T \times \frac{1}{h} d_{\text {model }}$的矩阵,这时候得到的$head_h$是一个$T \times \frac{1}{h} d_{\text {model }}$的矩阵。 而$h$个头的话,正好是$T \times d_{\text {model }}$的维度,也就是我们最后的输出了。即下面这个计算: +$$ +\mathbf{I}_{k}^{Q}=\operatorname{FFN}\left(\text { Concat }\left(\text { head }_{1}, \ldots, \text { head }_{H}\right) \mathbf{W}^{O}\right) +$$ +这个是self-attention 的输出再过一个全连接网络得到的。如果是用残差网络的话,最后的结果依然是个$T \times d_{\text {model }}$的,也就是$\mathbf{I}_{k}^{Q}$的维度。这时候我们在$T$的维度上进行一个avg pooling的操作,就能够把每个session兴趣转成一个$embedding$维的向量了,即 +$$ +\mathbf{I}_{k}=\operatorname{Avg}\left(\mathbf{I}_{k}^{Q}\right) +$$ +即这个$\mathbf{I}_{k}$是一个embedding维度的向量, 表示当前用户在第$k$会话的兴趣。这就是一个会话里面兴趣提取的全过程了,如果用我之前的神图总结的话就是: + ++
++ +不同点就是这里用了两个transformer块,开始用的是bias编码。 + +接下来就是不同的会话都走这样的一个Transformer网络,就会得到一个$K \times embed \_dim$的矩阵,代表的是某个用户在$K$个会话里面的兴趣信息, 这个就是会话兴趣提取层的结果了。 两个注意点: +1. 这$K$个会话是走同一个Transformer网络的,也就是在自注意力机制中不同的会话之间权重共享 +2. 最后得到的这个矩阵,$K$这个维度上是有时间先后关系的,这为后面用LSTM学习这各个会话之间的兴趣向量奠定了基础。 + +#### Session Interest Interacting Layer +感觉这篇文章最难的地方在上面这块,所以我用了些篇幅,而下面这些就好说了,因为和之前的东西对上了又。 首先这个会话兴趣交互层 + ++
++ +作者这里就是想通过一个双向的LSTM来学习下会话兴趣之间的关系, 从而增加用户兴趣的丰富度,或许还能学习到演化规律。 + ++
++ +双向的LSTM这个,这里就不介绍了,关于LSTM之前我也总结过了,无非双向的话,就是先从头到尾计算,在从尾到头回来。所以这里每个时刻隐藏状态的输出计算公式为: +$$ +\mathbf{H}_{t}=\overrightarrow{\mathbf{h}_{f t}} \oplus \overleftarrow{\mathbf{h}_{b t}} +$$ +这是一个$[1,\#hidden\_units]$的维度。相加的两项分别是前向传播和反向传播对应的t时刻的hidden state,这里得到的隐藏层状态$H_t$, 我们可以认为是混合了上下文信息的会话兴趣。 +#### Session Interest Activating Layer +用户的会话兴趣与目标物品越相近,那么应该赋予更大的权重,这里依然使用注意力机制来刻画这种相关性,根据结构图也能看出,这里是用了两波注意力计算: + ++
++ +由于这里的这种局部Attention机制,DIN和DIEN里都见识过了, 这里就不详细解释了, 简单看下公式就可以啦。 +1. 会话兴趣提取层 +$$ +\begin{aligned} +a_{k}^{I} &=\frac{\left.\exp \left(\mathbf{I}_{k} \mathbf{W}^{I} \mathbf{X}^{I}\right)\right)}{\sum_{k}^{K} \exp \left(\mathbf{I}_{k} \mathbf{W}^{I} \mathbf{X}^{I}\right)} \\ +\mathbf{U}^{I} &=\sum_{k}^{K} a_{k}^{I} \mathbf{I}_{k} +\end{aligned} +$$ +这里$X^I$是候选商品的embedding向量, 是$[embed \_dim,1]$的维度, $I_k$是$[1, embed \_dim]$的,而$W^I$是一个$[embed \_dim, embed \_dim]$, 所以这样能算出个分数,表示当前会话兴趣与候选商品之间的相似性程度。 而最终的$U^I$是各个会话兴趣向量的加权线性组合, 维度是$[1, embed \_dim]$。 +2. 会话兴趣交互层 +同样,混合了上下文信息的会话兴趣,也进行同样的处理: +$$ +\begin{aligned} +a_{k}^{H} &=\frac{\left.\exp \left(\mathbf{H}_{k} \mathbf{W}^{H} \mathbf{X}^{I}\right)\right)}{\sum_{k}^{K} \exp \left(\mathbf{H}_{k} \mathbf{W}^{H} \mathbf{X}^{I}\right)} \\ +\mathbf{U}^{H} &=\sum_{k}^{K} a_{k}^{H} \mathbf{H}_{k} +\end{aligned} +$$ +这里$X^I$是候选商品的embedding向量, 是$[embed \_dim,1]$的维度, $H_k$是$[1, \# hidden \_units]$的,而$W^I$是一个$[ \# hidden \_units, embed \_dim]$, 所以这样能算出个分数,当然实际实现,这里都是过神经网络的,表示混合了上下文信息的当前会话兴趣与候选商品之间的相似性程度。 而最终的$U^H$是各个混合了上下文信息的会话兴趣向量的加权线性组合, 维度是$[1, \# hidden \_units]$。 + + +#### Output Layer +这个就很简单了,上面的用户行为特征, 物品行为特征以及求出的会话兴趣特征进行拼接,然后过一个DNN网络,就可以得到输出了。 + ++
++ +损失这里依然用的交叉熵损失: +$$ +L=-\frac{1}{N} \sum_{(x, y) \in \mathbb{D}}(y \log p(x)+(1-y) \log (1-p(x))) +$$ +这里的$x$表示的是$\left[\mathbf{X}^{U}, \mathbf{X}^{I}, \mathbf{S}\right]$,分布表示用户特征,物品特征和会话兴趣特征。 + +到这里DSIN模型就解释完毕了。 + +### 论文的其他细节 +这里的其他细节,后面就是实验部分了,用的数据集是一个广告数据集一个推荐数据集, 对比了几个比较经典的模型Youtubetnet, W&D, DIN, DIEN, 用了RNN的DIN等。并做了一波消融实验,验证了偏置编码的有效性, 会话兴趣抽取层和会话交互兴趣抽取层的有效性。 最后可视化的self-attention和Action Unit的图比较有意思: + ++
++ +好了,下面就是DSIN的代码细节了。 +## DSIN的代码复现细节 +下面就是DSIN的代码部分,这里我依然是借鉴了Deepctr进行的简化版本的复现, 这次复现代码会非常多,因为想借着这个机会学习一波Transformer,具体的还是参考我后面的GitHub。 下面开始: + +### 数据处理 +首先, 这里使用的数据集还是movielens数据集,延续的DIEN那里的,没来得及尝试其他,这里说下数据处理部分和DIEN不一样的地方。最大的区别就是这里的用户历史行为上的处理, 之前的是一个历史行为序列列表,这里得需要把这个列表分解成几个会话的形式, 由于每个用户的会话还不一定一样长,所以这里还需要进行填充。具体的数据格式如下: + ++
++ +就是把之前的hist_id序列改成了5个session。其他的特征那里没有变化。 而特征封装那里,需要把这5个会话封装起来,同时还得记录**每个用户的有效会话个数以及每个会话里面商品的有效个数, 这个在后面计算里面是有用的,因为目前是padding成了一样长,后面要根据这个个数进行mask, 所以这里有两波mask要做** + +```python +feature_columns = [SparseFeat('user_id', max(samples_data["user_id"])+1, embedding_dim=8), + SparseFeat('gender', max(samples_data["gender"])+1, embedding_dim=8), + SparseFeat('age', max(samples_data["age"])+1, embedding_dim=8), + SparseFeat('movie_id', max(samples_data["movie_id"])+1, embedding_dim=8), + SparseFeat('movie_type_id', max(samples_data["movie_type_id"])+1, embedding_dim=8), + DenseFeat('hist_len', 1)] + +feature_columns += [VarLenSparseFeat('sess1', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length1'), + VarLenSparseFeat('sess2', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length2'), + VarLenSparseFeat('sess3', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length3'), + VarLenSparseFeat('sess4', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length4'), + VarLenSparseFeat('sess5', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length5'), + ] +feature_columns += ['sess_length'] +``` +封装代码变成了上面这个样子, 之所以放这里, 我是想说明一个问题,也是我这次才刚刚发觉的,就是这块封装特征的代码是用于建立模型用的, 也就是不用管有没有数据集,只要基于这个feature_columns就能把模型建立出来。 而这里面有几个重要的细节要梳理下: +1. 上面的那一块特征是常规的离散和连续特征,封装起来即可,这个会对应的建立Input层接收后面的数据输入 +2. 第二块的变长离散特征, 注意后面的`seq_length`,这个东西的作用是标记每个用户在每个会话里面有效商品的真实长度,所以这5个会话建Input层的时候,不仅给前面的sess建立Input,还会给length_name建立Input层来接收每个用户每个会话里面商品的真实长度信息, 这样在后面创建mask的时候才有效。也就是**没有具体数据之前网络就能创建mask信息才行**。这个我是遇到了坑的,之前又忽略了这个`seq_length`, 想着直接用上面的真实数据算出长度来给网络不就行? 其实不行,因为我们算出来的长度mask给网络的时候,那个样本数已经确定了,这时候会出bug的到后面。 因为真实训练的时候,batch_size是我们自己指定。并且这个思路的话是网络依赖于数据才能建立出来,显然是不合理的。所以一定要切记,**先用`seq_length`在这里占坑,作为一个Input层, 然后过embedding,后面基于传进的序列长度和填充的最大长度用 `tf.sequence_mask`就能建立了**。 +3. 最后的`sess_length`, 这个标记每个用户的有效会话个数,后面在会话兴趣与当前候选商品算注意力的时候,也得进行mask操作,所以这里和上面这个原理是一样的,**必须先用sess_length在这里占坑,创建一个Input层**。 +4. 对应关系, 既然我们这里封装的时候是这样封装的,这样就会根据上面的建立出不同的Input层,这时候我们具体用X训练的时候,**一定要注意数据对应,也就是特征必须够且能对应起来,这里是通过名字对应的**, 看下面的X: + ++
++ +真实数据要和Input层接收进行对应好。 + +好了,关于数据处理就说这几个细节,感觉mask的那个处理非常需要注意。具体的看代码就可以啦。下面重头戏,剖析模型。 + +### DSIN模型全貌 +有了Deepctr的这种代码风格,使得建立模型会从宏观上看起来非常清晰,简单说下逻辑, 先建立输入层,由于输入的特征有三大类(离散,连续和变长离散),所以分别建立Input层,然后离散特征还得建立embedding层。下面三大类特征就有了不同的走向: +1. 连续特征: 这类特征拼先拼接到一块,然后等待最后往DNN里面输入 +2. 普通离散特征: 这块从输入 -> embedding -> 拼接到一块,等待DNN里面输入 +3. 用户的会话特征: 这块从输入 -> embedding -> 会话兴趣分割层(`sess_interest_division`) -> 会话兴趣提取层(`sess_interest_extractor`) -> 会话兴趣交互层(`BiLSTM`) -> 会话兴趣激活层( `AttentionPoolingLayer`) -> 得到两个兴趣性特征 + +把上面的连续特征,离散特征和兴趣特征拼接起来,然后过DNN得到输出即可。就是这么个逻辑了,具体代码如下: + +```python +def DSIN(feature_columns, sess_feature_list, sess_max_count=5, bias_encoding=True, singlehead_emb_size=1, + att_head_nums=8, dnn_hidden_units=(200, 80)): + """ + 建立DSIN网络 + :param feature_columns: A list, 每个特征的封装 nametuple形式 + :param behavior_feature_list: A list, 行为特征名称 + :param sess_max_count: 会话的个数 + :param bias_encoding: 是否偏置编码 + :singlehead_emb_size: 每个头的注意力的维度,注意这个和头数的乘积必须等于输入的embedding的维度 + :att_head_nums: 头的个数 + :dnn_hidden_units: 这个是全连接网络的神经元个数 + """ + + # 检查下embedding设置的是否合法,因为这里有了多头注意力机制之后,我们要保证我们的embedding维度 = att_head_nums * att_embedding_size + hist_emb_size = sum( + map(lambda fc: fc.embedding_dim, filter(lambda fc: fc.name in sess_feature_list, [feature for feature in feature_columns if not isinstance(feature, str)])) + ) + if singlehead_emb_size * att_head_nums != hist_emb_size: + raise ValueError( + "hist_emb_size must equal to singlehead_emb_size * att_head_nums ,got %d != %d *%d" % ( + hist_emb_size, singlehead_emb_size, att_head_nums)) + + # 建立输入层 + input_layer_dict = build_input_layers(feature_columns) + # 将Input层转化为列表的形式作为model的输入 + input_layers = list(input_layer_dict.values()) # 各个输入层 + input_keys = list(input_layer_dict.keys()) # 各个列名 + user_sess_seq_len = [input_layer_dict['seq_length'+str(i+1)] for i in range(sess_max_count)] + user_sess_len = input_layer_dict['sess_length'] + + # 筛选出特征中的sparse_fra, dense_fea, varlen_fea + sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns)) if feature_columns else [] + dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), feature_columns)) if feature_columns else [] + varlen_sparse_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), feature_columns)) if feature_columns else [] + + # 获取dense + dnn_dense_input = [] + for fc in dense_feature_columns: + dnn_dense_input.append(input_layer_dict[fc.name]) + # 将所有的dense特征拼接 + dnn_dense_input = concat_input_list(dnn_dense_input) + + # 构建embedding词典 + embedding_layer_dict = build_embedding_layers(feature_columns, input_layer_dict) + + # 因为这里最终需要将embedding拼接后直接输入到全连接层(Dense)中, 所以需要Flatten + dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=True) + # 将所有sparse特征的embedding进行拼接 + dnn_sparse_input = concat_input_list(dnn_sparse_embed_input) + # dnn_dense_input和dnn_sparse_input这样就不用管了,等待后面的拼接就完事, 下面主要是会话行为兴趣的提取 + + + # 首先获取当前的行为特征(movie)的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起 + # 这个东西最后求局域Attention的时候使用,也就是选择与当前候选物品最相关的会话兴趣 + query_embed_list = embedding_lookup(sess_feature_list, input_layer_dict, embedding_layer_dict) + query_emb = concat_input_list(query_embed_list) + + # 下面就是开始会话行为的处理了,四个层来: 会话分割层,会话兴趣提取层,会话兴趣交互层和局部Attention层,下面一一来做 + + # 首先这里是找到会话行为中的特征列的输入层, 其实用input_layer_dict也行 + user_behavior_input_dict = {} + for idx in range(sess_max_count): + sess_input = OrderedDict() + for i, feat in enumerate(sess_feature_list): # 我这里只有一个movie_id + sess_input[feat] = input_layer_dict["sess" + str(idx+1)] + + user_behavior_input_dict['sess'+str(idx+1)] = sess_input # 这里其实是获取那五个会话的输入层 + + # 会话兴趣分割层: 拿到每个会话里面各个商品的embedding,并且进行偏置编码,得到transformer的输入 + transformer_input = sess_interest_division(embedding_layer_dict, user_behavior_input_dict, + sparse_feature_columns, sess_feature_list, + sess_max_count, bias_encoding=bias_encoding) + # 这个transformer_input是个列表,里面的每个元素代表一个会话,维度是(None, max_seq_len, embed_dim) + + # 会话兴趣提取层: 每个会话过transformer,从多个角度得到里面各个商品之间的相关性(交互) + self_attention = Transformer(singlehead_emb_size, att_head_nums, dropout_rate=0, use_layer_norm=True, + use_positional_encoding=(not bias_encoding), blinding=False) + sess_fea = sess_interest_extractor(transformer_input, sess_max_count, self_attention, user_sess_seq_len) + # 这里的输出sess_fea是个矩阵,维度(None, sess_max_cout, embed_dim), 这个东西后期要和当前的候选商品求Attention进行sess维度上的加权 + + # 会话兴趣交互层 上面的transformer结果,过双向的LSTM + lstm_output = BiLSTM(hist_emb_size, layers=2, res_layers=0, dropout_rate=0.2)(sess_fea) + # 这个lstm_output是个矩阵,维度是(None, sess_max_count, hidden_units_num) + + # 会话兴趣激活层 这里就是计算两波注意力 + interest_attention = AttentionPoolingLayer(user_sess_len)([query_emb, sess_fea]) + lstm_attention = AttentionPoolingLayer(user_sess_len)([query_emb, lstm_output]) + # 上面这两个的维度分别是(None, embed_size), (None, hidden_units_num) 这里embed_size=hidden_units_num + + # 下面就是把dnn_sense_input, dnn_sparse_input, interest_attention, lstm_attention拼接起来 + deep_input = Concatenate(axis=-1)([dnn_dense_input, dnn_sparse_input, interest_attention, lstm_attention]) + + # 全连接接网络, 获取最终的dnn_logits + dnn_logits = get_dnn_logits(deep_input, activation='prelu') + + model = Model(input_layers, dnn_logits) + + # 所有变量需要初始化 + tf.compat.v1.keras.backend.get_session().run(tf.compat.v1.global_variables_initializer()) + + return model +``` +下面开始解释每块的细节实现。 + +### 会话兴趣分割层(sess_interest_division) +这里面接收的输入是一个每个用户的会话列表, 比如上面那5个会话的时候,每个会话里面是有若干个商品的,当然还不仅仅是有商品id,还有可能有类别id这种。 而这个函数干的事情就是遍历这5个会话,然后对于每个会话,要根据商品id拿到每个会话的商品embedding(有类别id的话也会拿到类别id然后拼起来), 所以每个会话会得到一个`(None, seq_len, embed_dim)`的一个矩阵,而最后的输出就是5个会话的矩阵放到一个列表里返回来。也就是上面的`transformer_input`, 作为transformer的输入。 这里面的一个细节,就是偏置编码。 如果需要偏置编码的话,要在这里面进行。偏置编码的过程 + +```python +class BiasEncoding(Layer): + """位置编码""" + def __init__(self, sess_max_count, seed=1024): + super(BiasEncoding, self).__init__() + self.sess_max_count = sess_max_count + self.seed = seed + + def build(self, input_shape): + # 在该层创建一个可训练的权重 input_shape [None, sess_max_count, max_seq_len, embed_dim] + if self.sess_max_count == 1: + embed_size = input_shape[2] + seq_len_max = input_shape[1] + else: + embed_size = input_shape[0][2] + seq_len_max = input_shape[0][1] + # 声明那三个位置偏置编码矩阵 + self.sess_bias_embedding = self.add_weight('sess_bias_encoding', shape=(self.sess_max_count, 1, 1), + initializer=tf.keras.initializers.TruncatedNormal(mean=0.0, stddev=0.0001, seed=self.seed)) # 截断产生正太随机数 + self.seq_bias_embedding = self.add_weight('seq_bias_encoding', shape=(1, seq_len_max, 1), + initializer=tf.keras.initializers.TruncatedNormal(mean=0.0, stddev=0.0001, seed=self.seed)) + self.embed_bias_embedding = self.add_weight('embed_beas_encoding', shape=(1, 1, embed_size), + initializer=tf.keras.initializers.TruncatedNormal(mean=0.0, stddev=0.0001, seed=self.seed)) + super(BiasEncoding, self).build(input_shape) + + def call(self, inputs, mask=None): + """ + :param inputs: A list 长度是会话数量,每个元素表示一个会话矩阵,维度是[None, max_seq_len, embed_dim] + """ + bias_encoding_out = [] + for i in range(self.sess_max_count): + bias_encoding_out.append( + inputs[i] + self.embed_bias_embedding + self.seq_bias_embedding + self.sess_bias_embedding[i] # 这里会广播 + ) + return bias_encoding_out +``` +这里的核心就是build里面的那三个偏置矩阵,对应论文里面的$\mathbf{w}_{k}^{K},\mathbf{w}_{t}^{T},\mathbf{w}_{c}^{C}$, 这里之所以放到build里面建立,是为了让这些参数可学习, 而前向传播里面就是论文里面的公式加就完事,这里面会用到广播机制。 + +### 会话兴趣提取层(sess_interest_extractor) +这里面就是复现了大名鼎鼎的Transformer了, 这也是我第一次看transformer的代码,果真与之前的理论分析还是有很多不一样的点,下面得一一梳理一下,Transformer是非常重要的。 + +首先是位置编码, 代码如下: + +```python +def positional_encoding(inputs, pos_embedding_trainable=True,scale=True): + """ + inputs: (None, max_seq_len, embed_dim) + """ + _, T, num_units = inputs.get_shape().as_list() # [None, max_seq_len, embed_dim] + + position_ind = tf.expand_dims(tf.range(T), 0) # [1, max_seq_len] + + # First part of the PE function: sin and cos argument + position_enc = np.array([ + [pos / np.power(1000, 2. * i / num_units) for i in range(num_units)] for pos in range(T) + ]) + + # Second part, apply the cosine to even columns and sin to odds. # 这个操作秀 + position_enc[:, 0::2] = np.sin(position_enc[:, 0::2]) # dim 2i + position_enc[:, 1::2] = np.cos(position_enc[:, 1::2]) # dim 2i+1 + + # 转成张量 + if pos_embedding_trainable: + lookup_table = K.variable(position_enc, dtype=tf.float32) + + outputs = tf.nn.embedding_lookup(lookup_table, position_ind) + if scale: + outputs = outputs * num_units ** 0.5 + return outputs + inputs +``` +这一块的话没有啥好说的东西感觉,这个就是在按照论文里面的公式sin, cos变换, 这里面比较秀的操作感觉就是dim2i和dim 2i+1的赋值了。 + +接下来,LayerNormalization, 这个也是按照论文里面的公式实现的代码,求均值和方差的维度都是embedding: + +```python +class LayerNormalization(Layer): + def __init__(self, axis=-1, eps=1e-9, center=True, scale=True): + super(LayerNormalization, self).__init__() + self.axis = axis + self.eps = eps + self.center = center + self.scale = scale + def build(self, input_shape): + """ + input_shape: [None, max_seq_len, singlehead_emb_dim*head_num] + """ + self.gamma = self.add_weight(name='gamma', shape=input_shape[-1:], # [1, max_seq_len, singlehead_emb_dim*head_num] + initializer=tf.keras.initializers.Ones(), trainable=True) + self.beta = self.add_weight(name='beta', shape=input_shape[-1:], + initializer=tf.keras.initializers.Zeros(), trainable=True) # [1, max_seq_len, singlehead_emb_dim*head_num] + super(LayerNormalization, self).build(input_shape) + + def call(self, inputs): + """ + [None, max_seq_len, singlehead_emb_dim*head_num] + """ + mean = K.mean(inputs, axis=self.axis, keepdims=True) # embed_dim维度上求均值 + variance = K.mean(K.square(inputs-mean), axis=-1, keepdims=True) # embed_dim维度求方差 + std = K.sqrt(variance + self.eps) + outputs = (inputs - mean) / std + + if self.scale: + outputs *= self.gamma + if self.center: + outputs += self.beta + return outputs +``` +下面就是伟大的Transformer网络,下面我先把整体代码放上来,然后解释一些和我之前见到过的一样的地方,也是通过看具体代码学习到的点: + +```python +class Transformer(Layer): + """Transformer网络""" + def __init__(self, singlehead_emb_size=1, att_head_nums=8, dropout_rate=0.0, use_positional_encoding=False,use_res=True, + use_feed_forword=True, use_layer_norm=False, blinding=False, seed=1024): + super(Transformer, self).__init__() + self.singlehead_emb_size = singlehead_emb_size + self.att_head_nums = att_head_nums + self.num_units = self.singlehead_emb_size * self.att_head_nums + self.use_res = use_res + self.use_feed_forword = use_feed_forword + self.dropout_rate = dropout_rate + self.use_positional_encoding = use_positional_encoding + self.use_layer_norm = use_layer_norm + self.blinding = blinding # 如果为True的话表明进行attention的时候未来的units都被屏蔽, 解码器的时候用 + self.seed = seed + + # 这里需要为该层自定义可训练的参数矩阵 WQ, WK, WV + def build(self, input_shape): + # input_shape: [None, max_seq_len, embed_dim] + embedding_size= int(input_shape[0][-1]) + # 检查合法性 + if self.num_units != embedding_size: + raise ValueError( + "att_embedding_size * head_num must equal the last dimension size of inputs,got %d * %d != %d" % ( + self.singlehead_emb_size, att_head_nums, embedding_size)) + self.seq_len_max = int(input_shape[0][-2]) + # 定义三个矩阵 + self.W_Query = self.add_weight(name='query', shape=[embedding_size, self.singlehead_emb_size*self.att_head_nums], + dtype=tf.float32,initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed)) + self.W_Key = self.add_weight(name='key', shape=[embedding_size, self.singlehead_emb_size*self.att_head_nums], + dtype=tf.float32,initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+1)) + self.W_Value = self.add_weight(name='value', shape=[embedding_size, self.singlehead_emb_size*self.att_head_nums], + dtype=tf.float32,initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+2)) + # 用神经网络的话,加两层训练参数 + if self.use_feed_forword: + self.fw1 = self.add_weight('fw1', shape=[self.num_units, 4 * self.num_units], dtype=tf.float32, + initializer=tf.keras.initializers.glorot_uniform(seed=self.seed)) + self.fw2 = self.add_weight('fw2', shape=[4 * self.num_units, self.num_units], dtype=tf.float32, + initializer=tf.keras.initializers.glorot_uniform(seed=self.seed+1)) + self.dropout = tf.keras.layers.Dropout(self.dropout_rate) + self.ln = LayerNormalization() + super(Transformer, self).build(input_shape) + + def call(self, inputs, mask=None, training=None): + """ + :param inputs: [当前会话sessi, 当前会话sessi] 维度 (None, max_seq_len, embed_dim) + :param mask: 当前会话mask 这是个1维数组, 维度是(None, ), 表示每个样本在当前会话里面的行为序列长度 + """ + # q和k其实是一样的矩阵 + queries, keys = inputs + query_masks, key_masks = mask, mask + + # 这里需要对Q和K进行mask操作, + # key masking目的是让key值的unit为0的key对应的attention score极小,这样加权计算value时相当于对结果不产生影响 + # Query Masking 要屏蔽的是被0所填充的内容。 + query_masks = tf.sequence_mask(query_masks, self.seq_len_max, dtype=tf.float32) # (None, 1, seq_len_max) + key_masks = tf.sequence_mask(key_masks, self.seq_len_max, dtype=tf.float32) # (None, 1, seq_len_max), 注意key_masks开始是(None,1) + key_masks = key_masks[:, 0, :] # 所以上面会多出个1维度来, 这里去掉才行,(None, seq_len_max) + query_masks = query_masks[:, 0, :] # 这个同理 + + # 是否位置编码 + if self.use_positional_encoding: + queries = positional_encoding(queries) + keys = positional_encoding(queries) + + # tensordot 是矩阵乘,好处是当两个矩阵维度不同的时候,只要指定axes也可以乘 + # 这里表示的是queries的-1维度与W_Query的0维度相乘 + # (None, max_seq_len, embedding_size) * [embedding_size, singlehead_emb_size*head_num] + querys = tf.tensordot(queries, self.W_Query, axes=(-1, 0)) # [None, max_seq_len_q, singlehead_emb_size*head_num] + + keys = tf.tensordot(keys, self.W_Key, axes=(-1, 0)) # [None, max_seq_len_k, singlehead_emb_size*head_num] + values = tf.tensordot(keys, self.W_Value, axes=(-1, 0)) # [None, max_seq_len_k, singlehead_emb_size*head_num] + + # tf.split切分张量 这里从头那里切分成head_num个张量, 然后从0维拼接 + querys = tf.concat(tf.split(querys, self.att_head_nums, axis=2), axis=0) # [head_num*None, max_seq_len_q, singlehead_emb_size] + keys = tf.concat(tf.split(keys, self.att_head_nums, axis=2), axis=0) # [head_num*None, max_seq_len_k, singlehead_emb_size] + values = tf.concat(tf.split(values, self.att_head_nums, axis=2), axis=0) # [head_num*None, max_seq_len_k, singlehead_emb_size] + + # Q*K keys后两维转置然后再乘 [head_num*None, max_seq_len_q, max_seq_len_k] + outputs = tf.matmul(querys, keys, transpose_b=True) + outputs = outputs / (keys.get_shape().as_list()[-1] ** 0.5) + + # 从0维度上复制head_num次 + key_masks = tf.tile(key_masks, [self.att_head_nums, 1]) # [head_num*None, max_seq_len_k] + key_masks = tf.tile(tf.expand_dims(key_masks, 1), [1, tf.shape(queries)[1], 1]) # [head_num*None, max_seq_len_q,max_seq_len_k] + paddings = tf.ones_like(outputs) * (-2**32+1) + + outputs = tf.where(tf.equal(key_masks, 1), outputs, paddings) # 被填充的部分赋予极小的权重 + + # 标识是否屏蔽未来序列的信息(解码器self attention的时候不能看到自己之后的哪些信息) + # 这里通过下三角矩阵的方式进行,依此表示预测第一个词,第二个词,第三个词... + if self.blinding: + diag_vals = tf.ones_like(outputs[0, :, :]) # (T_q, T_k) + tril = tf.linalg.LinearOperatorLowerTriangular(diag_vals).to_dense() # (T_q, T_k) 这是个下三角矩阵 + masks = tf.tile(tf.expand_dims(tril, 0), [tf.shape(outputs)[0], 1, 1]) # (h*N, T_q, T_k) + + paddings = tf.ones_like(masks) * (-2 ** 32 + 1) + outputs = tf.where(tf.equal(masks, 0), paddings, outputs) # (h*N, T_q, T_k) + + outputs -= tf.reduce_max(outputs, axis=-1, keepdims=True) + outputs = tf.nn.softmax(outputs, axis=-1) # 最后一个维度求softmax,换成权重 + + query_masks = tf.tile(query_masks, [self.att_head_nums, 1]) # [head_num*None, max_seq_len_q] + query_masks = tf.tile(tf.expand_dims(query_masks, -1), [1, 1, tf.shape(keys)[1]]) # [head_num*None, max_seq_len_q, max_seq_len_k] + + outputs *= query_masks + + # 权重矩阵过下dropout [head_num*None, max_seq_len_q, max_seq_len_k] + outputs = self.dropout(outputs, training=training) + + # weighted sum [head_num*None, max_seq_len_q, max_seq_len_k] * # [head_num*None, max_seq_len_k, singlehead_emb_size] + result = tf.matmul(outputs, values) # [head_num*None, max_seq_len_q, singlehead_emb_size] + # 换回去了 + result = tf.concat(tf.split(result, self.att_head_nums, axis=0), axis=2) # [None, max_seq_len_q, head_num*singlehead_emb_size] + + if self.use_res: # 残差连接 + result += queries + if self.use_layer_norm: + result = self.ln(result) + if self.use_feed_forword: # [None, max_seq_len_q, head_num*singlehead_emb_size] 与 [num_units, self.num_units] + fw1 = tf.nn.relu(tf.tensordot(result, self.fw1, axes=[-1, 0])) # [None, max_seq_len_q, 4*num_units] + fw1 = self.dropout(fw1, training=training) + fw2 = tf.tensordot(fw1, self.fw2, axes=[-1, 0]) # [None, max_seq_len_q, num_units] 这个num_units其实就等于head_num*singlehead_emb_size + + if self.use_res: + result += fw2 + if self.use_layer_norm: + result = self.ln(result) + + return tf.reduce_mean(result, axis=1, keepdims=True) # [None, 1, head_num*singleh] +``` +这里面的整体逻辑, 首先在build里面会构建3个矩阵`WQ, WK, WV`,在这里定义依然是为了这些参数可训练, 而出乎我意料的是残差网络的参数w也是这里定义, 之前还以为这个是单独写出来,后面看了前向传播的逻辑时候明白了。 + +前向传播的逻辑和我之前画的图上差不多,不一样的细节是这里的具体实现上, 就是这里的**把多个头分开,采用堆叠的方式进行计算(堆叠到第一个维度上去了)**。这个是我之前忽略的一个问题, 只有这样才能使得每个头与每个头之间的自注意力运算是独立不影响的。如果不这么做的话,最后得到的结果会含有当前单词在这个头和另一个单词在另一个头上的关联,这是不合理的。**这是看了源码之后才发现的细节**。 + +另外就是mask操作这里,Q和K都需要进行mask操作,因为我们接受的输入序列是经过填充的,这里必须通过指明长度在具体计算的时候进行遮盖,否则softmax那里算的时候会有影响,因为e的0次方是1,所以这里需要找到序列里面填充的那些地方,给他一个超级大的负数,这样e的负无穷接近0,才能没有影响。但之前不知道这里的细节,这次看发现是Q和K都进行mask操作,且目的还不一样。 + +第三个细节,就是对未来序列的屏蔽,这个在这里是用不到的,这个是Transformer的解码器用的一个操作,就是在解码的时候,我们不能让当前的序列看到自己之后的信息。这里也需要进行mask遮盖住后面的。而具体实现,竟然使用了一个下三角矩阵, 这个东西的感觉是这样: + ++
++ +解码的时候,只能看到自己及以前的相关性,然后加权,这个学到了哈哈。 + +transformer这里接收的是会话兴趣分割层传下来的兴趣列表,返回的是个矩阵,维度是`(None, sess_nums, embed_dim)`, 因为这里每个会话都要过Transformer, 输入的维度是(None, seq_len, embed_dim), 而经过transformer之后,本来输出的维度也是这个,但是最后返回的时候在seq_len的维度上求了个平均。所以每个会话得到的输出是(None, 1, embed_dim), 相当于兴趣综合了下。而5个会话,就会得到5个这样的结果,然后再会话维度上拼接,就是上面的这个矩阵结果了,这个东西作为双向LSTM的输入。 + +### 会话兴趣交互层(BiLSTM) +这里主要是值得记录下多层双向LSTM的实现过程, 用下面的这种方式非常的灵活: + +```python +class BiLSTM(Layer): + def __init__(self, units, layers=2, res_layers=0, dropout_rate=0.2, merge_mode='ave'): + super(BiLSTM, self).__init__() + self.units = units + self.layers = layers + self.res_layers = res_layers + self.dropout_rate = dropout_rate + self.merge_mode = merge_mode + + # 这里要构建正向的LSTM和反向的LSTM, 因为我们是要两者的计算结果最后加和,所以这里需要分别计算 + def build(self, input_shape): + """ + input_shape: (None, sess_max_count, embed_dim) + """ + self.fw_lstm = [] + self.bw_lstm = [] + for _ in range(self.layers): + self.fw_lstm.append( + LSTM(self.units, dropout=self.dropout_rate, bias_initializer='ones', return_sequences=True, unroll=True) + ) + # go_backwards 如果为真,则反向处理输入序列并返回相反的序列 + # unroll 布尔(默认错误)。如果为真,则网络将展开,否则使用符号循环。展开可以提高RNN的速度,尽管它往往会占用更多的内存。展开只适用于较短的序列。 + self.bw_lstm.append( + LSTM(self.units, dropout=self.dropout_rate, bias_initializer='ones', return_sequences=True, go_backwards=True, unroll=True) + ) + super(BiLSTM, self).build(input_shape) + + def call(self, inputs): + + input_fw = inputs + input_bw = inputs + for i in range(self.layers): + output_fw = self.fw_lstm[i](input_fw) + output_bw = self.bw_lstm[i](input_bw) + output_bw = Lambda(lambda x: K.reverse(x, 1), mask=lambda inputs, mask:mask)(output_bw) + + if i >= self.layers - self.res_layers: + output_fw += input_fw + output_bw += input_bw + + input_fw = output_fw + input_bw = output_bw + + if self.merge_mode == "fw": + output = output_fw + elif self.merge_mode == "bw": + output = output_bw + elif self.merge_mode == 'concat': + output = K.concatenate([output_fw, output_bw]) + elif self.merge_mode == 'sum': + output = output_fw + output_bw + elif self.merge_mode == 'ave': + output = (output_fw + output_bw) / 2 + elif self.merge_mode == 'mul': + output = output_fw * output_bw + elif self.merge_mode is None: + output = [output_fw, output_bw] + + return output +``` +这里这个操作是比较骚的,以后建立双向LSTM就用这个模板了,具体也不用解释,并且这里之所以说灵活,是因为最后前向LSTM的结果和反向LSTM的结果都能单独的拿到,且可以任意的两者运算。 我记得Keras里面应该是也有直接的函数实现双向LSTM的,但依然感觉不如这种灵活。 这个层数自己定,单元自己定看,最后结果形式自己定,太帅了简直。 关于LSTM,可以看[官方文档](https://tensorflow.google.cn/versions/r2.0/api_docs/python/tf/keras/layers/LSTM) + +这个的输入是`(None, sess_nums, embed_dim)`, 输出是`(None, sess_nums, hidden_units_num)`。 + +### 会话兴趣局部激活 +这里就是局部Attention的操作了,这个在这里就不解释了,和之前的DIEN,DIN的操作就一样了, 代码也不放在这里了,剩下的代码都看后面的GitHub链接吧, 这里我只记录下我觉得后面做别的项目会有用的代码哈哈。 + +## 总结 +DSIN的核心创新点就是把用户的历史行为按照时间间隔进行切分,以会话为单位进行学习, 而学习的方式首先是会话之内的行为自学习,然后是会话之间的交互学习,最后是与当前候选商品相关的兴趣演进,总体上还是挺清晰的。 + +具体的实际使用场景依然是有丰富的用户历史行为序列才可以,而会话之间的划分间隔,也得依据具体业务场景。 具体的使用可以调deepctr的包。 + +**参考资料**: +* [DSIN原论文](https://arxiv.org/abs/1905.06482) +* [自然语言处理之Attention大详解(Attention is all you need)](https://blog.csdn.net/wuzhongqiang/article/details/104414239?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522161512240816780357259240%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=161512240816780357259240&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_v1~rank_blog_v1-1-104414239.pc_v1_rank_blog_v1&utm_term=Attention+is+all) +* [推荐系统遇上深度学习(四十五)-探秘阿里之深度会话兴趣网络](https://www.jianshu.com/p/82ccb10f9ede) +* [深度兴趣网络模型探索——DIN+DIEN+DSIN](https://blog.csdn.net/baymax_007/article/details/91130374) +* [Transformer解读](https://www.cnblogs.com/flightless/p/12005895.html) +* [Welcome to DeepCTR’s documentation!](https://deepctr-doc.readthedocs.io/en/latest/) diff --git a/4.人工智能/ch02/ch2.2/ch2.2.5/2.2.5.0.md b/4.人工智能/ch02/ch2.2/ch2.2.5/2.2.5.0.md new file mode 100644 index 0000000..c1e37e2 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.5/2.2.5.0.md @@ -0,0 +1,129 @@ +## 背景与动机 + +在推荐系统的精排模块,多任务学习的模型结构已成业界的主流,获得了广阔的应用。多任务学习(multi-task learning),本质上是希望使用一个模型完成多个任务的建模。在推荐系统中,多任务学习一般即指多目标学习(multi-label learning),不同目标输入相同的feature进行联合训练,是迁移学习的一种。他们之间的关系如图: + ++
++ +下面我们先讨论三个问题 + +**一、为什么要用多任务学习?** + +(1)很多业界推荐的业务,天然就是一个多目标的建模场景,需要多目标共同优化。以微信视频号推荐为例,打开一个视频,如图,首页上除了由于视频自动播放带来的“播放时长”、“完播率”(用户播放时长占视频长度的比例)目标之外,还有大量的互动标签,例如“点击好友头像”、“进入主页”、“关注”、“收藏”、“分享”、“点赞”、“评论”等。究竟哪一个标签最符合推荐系统的建模目标呢? + ++
++ + + +如果要用一个词来概括所有各式各样的推荐系统的终极目标,那就是“用户满意度”,但我们无法找到一个显示的指标量化用户满意度。业界一般使用“DAU”、“用户日均使用时长”、“留存率”来作为客观的间接的“用户满意度”(或者说算法工程师绩效)评价指标。而这些指标都是难以通过单一目标建模的,以使用时长为例,长视频播放长度天然大于短视频。所幸的是,虽然没有显式的用户满意度评价指标,但是现在的app都存在类似上述视频号推荐场景的丰富具体的隐式反馈。但这些独立的隐式反馈也存在一些挑战: + +- 目标偏差:点赞、分享表达的满意度可能比播放要高 +- 物品偏差:不同视频的播放时长体现的满意度不一样,有的视频可能哄骗用户看到尾部(类似新闻推荐中的标题党) +- 用户偏差:有的用户表达满意喜欢用点赞,有的用户可能喜欢用收藏 + +因此我们需要使用多任务学习模型针对多个目标进行预测,并在线上融合多目标的预测结果进行排序。多任务学习也不能直接表达用户满意度,但是可以最大限度利用能得到的用户反馈信息进行充分的表征学习,并且可建模业务之间的关系,从而高效协同学习具体任务。 + +(2)工程便利,不用针对不同的任务训练不同的模型。一般推荐系统中排序模块延时需求在40ms左右,如果分别对每个任务单独训练一个模型,难以满足需求。出于控制成本的目的,需要将部分模型进行合并。合并之后,能更高效的利用训练资源和进行模型的迭代升级。 + +**二、为什么多任务学习有效?** + +当把业务独立建模变成多任务联合建模之后,有可能带来四种结果: + ++
++ +多任务学习的优势在于通过部分参数共享,联合训练,能在保证“还不错”的前提下,实现多目标共同提升。原因有以下几种: + +- 任务互助:对于某个任务难学到的特征,可通过其他任务学习 +- 隐式数据增强:不同任务有不同的噪声,一起学习可抵消部分噪声 +- 学到通用表达,提高泛化能力:模型学到的是对所有任务都偏好的权重,有助于推广到未来的新任务 +- 正则化:对于一个任务而言,其他任务的学习对该任务有正则化效果 + +**三、多任务学习都在研究什么问题**? + +如上所述,多任务的核心优势在于通过不同任务的网络参数共享,实现1+1>2的提升,因此多任务学习的一大主流研究方向便是如何设计有效的网络结构。多个label的引入自然带来了多个loss,那么如何在联合训练中共同优化多个loss则是关键问题。 + +- 网络结构设计:主要研究哪些参数共享、在什么位置共享、如何共享。这一方向我们认为可以分为两大类,第一类是在设计网络结构时,考虑目标间的显式关系(例如淘宝中,点击之后才有购买行为发生),以阿里提出的ESMM为代表;另一类是目标间没有显示关系(例如短视频中的收藏与分享),在设计模型时不考虑label之间的量化关系,以谷歌提出的MMOE为代表。 +- 多loss的优化策略:主要解决loss数值有大有小、学习速度有快有慢、更新方向时而相反的问题。最经典的两个工作有UWL(Uncertainty Weight):通过自动学习任务的uncertainty,给uncertainty大的任务小权重,uncertainty小的任务大权重;GradNorm:结合任务梯度的二范数和loss下降梯度,引入带权重的损失函数Gradient Loss,并通过梯度下降更新该权重。 + +## loss加权融合 + +一种最简单的实现多任务学习的方式是对不同任务的loss进行加权。例如谷歌的Youtube DNN论文中提到的一种加权交叉熵: +$$ +\text { Weighted CE Loss }=-\sum_{i}\left[T_{i} y_{i} \log p_{i}+\left(1-y_{i}\right) \log \left(1-p_{i}\right)\right] +$$ +其中![[公式]](https://www.zhihu.com/equation?tex=T_i) 为观看时长。在原始训练数据中,正样本是视频展示后用户点击了该视频,负样本则是展示后未点击,这个一个标准的CTR预估问题。该loss通过改变训练样本的权重,让所有负样本的权重都为 1,而正样本的权重为点击后的视频观看时长 ![[公式]](https://www.zhihu.com/equation?tex=T_i) 。作者认为按点击率排序会倾向于把诱惑用户点击(用户未必真感兴趣)的视频排前面,而观看时长能更好地反映出用户对视频的兴趣,通过重新设计loss使得该模型在保证主目标点击的同时,将视频观看时长转化为样本的权重,达到优化平均观看时长的效果。 + +另一种更为简单粗暴的加权方式是人工手动调整权重,例如 0.3\*L(点击)+0.7*L\*(视频完播) + +这种loss加权的方式优点如下: + +- 模型简单,仅在训练时通过梯度乘以样本权重实现对其它目标的加权 +- 模型上线简单,和base完全相同,不需要额外开销 + +缺点: + +- 本质上并不是多目标建模,而是将不同的目标转化为同一个目标。样本的加权权重需要根据AB测试才能确定。 + +## Shared-Bottom + +最早的多任务学习模型是底层共享结构(Shared-Bottom),如图所示。 + +通过共享底层模块,学习任务间通用的特征表征,再往上针对每一个任务设置一个Tower网络,每个Tower网络的参数由自身对应的任务目标进行学习。Shared Bottom可以根据自身数据特点,使用MLP、DeepFM、DCN、DIN等,Tower网络一般使用简单的MLP。 + +代码如下,共享特征embedding,共享底层DNN网络,任务输出层独立,loss直接使用多个任务的loss值之和。 + +```python +def Shared_Bottom(dnn_feature_columns, num_tasks=None, task_types=None, task_names=None, + bottom_dnn_units=[128, 128], tower_dnn_units_lists=[[64,32], [64,32]], + l2_reg_embedding=0.00001, l2_reg_dnn=0, seed=1024,dnn_dropout=0, + dnn_activation='relu', dnn_use_bn=False): + + features = build_input_features(dnn_feature_columns) + inputs_list = list(features.values()) + + sparse_embedding_list, dense_value_list = input_from_feature_columns(features, dnn_feature_columns, l2_reg_embedding,seed) + #共享输入特征 + dnn_input = combined_dnn_input(sparse_embedding_list, dense_value_list) + #共享底层网络 + shared_bottom_output = DNN(bottom_dnn_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed)(dnn_input) + #任务输出层 + tasks_output = [] + for task_type, task_name, tower_dnn in zip(task_types, task_names, tower_dnn_units_lists): + tower_output = DNN(tower_dnn, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed, name='tower_'+task_name)(shared_bottom_output) + + logit = tf.keras.layers.Dense(1, use_bias=False, activation=None)(tower_output) + output = PredictionLayer(task_type, name=task_name)(logit) + tasks_output.append(output) + + model = tf.keras.models.Model(inputs=inputs_list, outputs=tasks_output) + return model +``` + +优点: + +- 浅层参数共享,互相补充学习,任务相关性越高,模型loss优化效果越明显,也可以加速训练。 + +缺点: + +- 任务不相关甚至优化目标相反时(例如新闻的点击与阅读时长),可能会带来负收益,多个任务性能一起下降。 + +一般把Shared-Bottom的结构称作“参数硬共享”,多任务学习网络结构设计的发展方向便是如何设计更灵活的共享机制,从而实现“参数软共享”。 + + + +参考资料: + +[https://developer.aliyun.com/article/793252](https://link.zhihu.com/?target=https%3A//developer.aliyun.com/article/793252) + +https://zhuanlan.zhihu.com/p/291406172 + +Gradnorm: Gradient normalization for adaptive loss balancing in deep multitask networks (ICML'2018) + +UWL: Multi-task learning using uncertainty to weigh losses for scene geometry and semantics (CVPR'2018) + +YoutubeDNN: Deep neural networks for youtube recommendations (RecSys'2016) \ No newline at end of file diff --git a/4.人工智能/ch02/ch2.2/ch2.2.5/ESMM.md b/4.人工智能/ch02/ch2.2/ch2.2.5/ESMM.md new file mode 100644 index 0000000..7aeef01 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.5/ESMM.md @@ -0,0 +1,162 @@ +# ESMM + +不同的目标由于业务逻辑,有显式的依赖关系,例如**曝光→点击→转化**。用户必然是在商品曝光界面中,先点击了商品,才有可能购买转化。阿里提出了ESMM(Entire Space Multi-Task Model)网络,显式建模具有依赖关系的任务联合训练。该模型虽然为多任务学习模型,但本质上是以CVR为主任务,引入CTR和CTCVR作为辅助任务,解决CVR预估的挑战。 + +## 背景与动机 + +传统的CVR预估问题存在着两个主要的问题:**样本选择偏差**和**稀疏数据**。下图的白色背景是曝光数据,灰色背景是点击行为数据,黑色背景是购买行为数据。传统CVR预估使用的训练样本仅为灰色和黑色的数据。 ++
++ +这会导致两个问题: +- 样本选择偏差(sample selection bias,SSB):如图所示,CVR模型的正负样本集合={点击后未转化的负样本+点击后转化的正样本},但是线上预测的时候是样本一旦曝光,就需要预测出CVR和CTR以排序,样本集合={曝光的样本}。构建的训练样本集相当于是从一个与真实分布不一致的分布中采样得到的,这一定程度上违背了机器学习中训练数据和测试数据独立同分布的假设。 +- 训练数据稀疏(data sparsity,DS):点击样本只占整个曝光样本的很小一部分,而转化样本又只占点击样本的很小一部分。如果只用点击后的数据训练CVR模型,可用的样本将极其稀疏。 + +## 解决方案 + +阿里妈妈团队提出ESMM,借鉴多任务学习的思路,引入两个辅助任务CTR、CTCVR(已点击然后转化),同时消除以上两个问题。 + +三个预测任务如下: + +- **pCTR**:p(click=1 | impression); +- **pCVR**: p(conversion=1 | click=1,impression); +- **pCTCVR**: p(conversion=1, click=1 | impression) = p(click=1 | impression) * p(conversion=1 | click=1, impression); + +> 注意:其中只有CTR和CVR的label都同时为1时,CTCVR的label才是正样本1。如果出现CTR=0,CVR=1的样本,则为不合法样本,需删除。 +> pCTCVR是指,当用户已经点击的前提下,用户会购买的概率;pCVR是指如果用户点击了,会购买的概率。 + +三个任务之间的关系为: + ++
++ +其中x表示曝光,y表示点击,z表示转化。针对这三个任务,设计了如图所示的模型结构: + ++
++ + +如图,主任务和辅助任务共享特征,不同任务输出层使用不同的网络,将cvr的预测值*ctr的预测值作为ctcvr任务的预测值,利用ctcvr和ctr的label构造损失函数: + ++
++ + + +该架构具有两大特点,分别给出上述两个问题的解决方案: + +- 帮助CVR模型在完整样本空间建模(即曝光空间X)。 + ++
++ + + + 从公式中可以看出,pCVR 可以由pCTR 和pCTCVR推导出。从原理上来说,相当于分别单独训练两个模型拟合出pCTR 和pCTCVR,再通过pCTCVR 除以pCTR 得到最终的拟合目标pCVR 。在训练过程中,模型只需要预测pCTCVR和pCTR,利用两种相加组成的联合loss更新参数。pCVR 只是一个中间变量。而pCTCVR和pCTR的数据是在完整样本空间中提取的,从而相当于pCVR也是在整个曝光样本空间中建模。 + +- 提供特征表达的迁移学习(embedding层共享)。CVR和CTR任务的两个子网络共享embedding层,网络的embedding层把大规模稀疏的输入数据映射到低维的表示向量,该层的参数占了整个网络参数的绝大部分,需要大量的训练样本才能充分学习得到。由于CTR任务的训练样本量要大大超过CVR任务的训练样本量,ESMM模型中特征表示共享的机制能够使得CVR子任务也能够从只有展现没有点击的样本中学习,从而能够极大地有利于缓解训练数据稀疏性问题。 + +模型训练完成后,可以同时预测cvr、ctr、ctcvr三个指标,线上根据实际需求进行融合或者只采用此模型得到的cvr预估值。 + +## 总结与拓展 + +可以思考以下几个问题 + +1. 能不能将乘法换成除法? + 即分别训练CTR和CTCVR模型,两者相除得到pCVR。论文提供了消融实验的结果,表中的DIVISION模型,比起BASE模型直接建模CTCVRR和CVR,有显著提高,但低于ESMM。原因是pCTR 通常很小,除以一个很小的浮点数容易引起数值不稳定问题。 + ++
++ +2. 网络结构优化,Tower模型更换?两个塔不一致? + 原论文中的子任务独立的Tower网络是纯MLP模型,事实上业界在使用过程中一般会采用更为先进的模型(例如DeepFM、DIN等),两个塔也完全可以根据自身特点设置不一样的模型。这也是ESMM框架的优势,子网络可以任意替换,非常容易与其他学习模型集成。 + +3. 比loss直接相加更好的方式? + 原论文是将两个loss直接相加,还可以引入动态加权的学习机制。 + +4. 更长的序列依赖建模? + 有些业务的依赖关系不止有曝光-点击-转化三层,后续的改进模型提出了更深层次的任务依赖关系建模。 + + 阿里的ESMM2: 在点击到购买之前,用户还有可能产生加入购物车(Cart)、加入心愿单(Wish)等行为。 + ++
++ + 相较于直接学习 click->buy (稀疏度约2.6%),可以通过Action路径将目标分解,以Cart为例:click->cart (稀疏 度为10%),cart->buy(稀疏度为12%),通过分解路径,建立多任务学习模型来分步求解CVR模型,缓解稀疏问题,该模型同样也引入了特征共享机制。 + + 美团的[AITM](https://zhuanlan.zhihu.com/p/508876139/[https://cloud.tencent.com/developer/article/1868117](https://cloud.tencent.com/developer/article/1868117)):信用卡业务中,用户转化通常是一个**曝光->点击->申请->核卡->激活**的过程,具有5层的链路。 + ++
++ + + + 美团提出了一种自适应信息迁移多任务(**Adaptive Information Transfer Multi-task,AITM**)框架,该框架通过自适应信息迁移(AIT)模块对用户多步转化之间的序列依赖进行建模。AIT模块可以自适应地学习在不同的转化阶段需要迁移什么和迁移多少信息。 + +总结: + +ESMM首创了利用用户行为序列数据在完整样本空间建模,并提出利用学习CTR和CTCVR的辅助任务,迂回学习CVR,避免了传统CVR模型经常遭遇的样本选择偏差和训练数据稀疏的问题,取得了显著的效果。 + +## 代码实践 + +与Shared-Bottom同样的共享底层机制,之后两个独立的Tower网络,分别输出CVR和CTR,计算loss时只利用CTR与CTCVR的loss。CVR Tower完成自身网络更新,CTR Tower同时完成自身网络和Embedding参数更新。在评估模型性能时,重点是评估主任务CVR的auc。 + +```python +def ESSM(dnn_feature_columns, task_type='binary', task_names=['ctr', 'ctcvr'], + tower_dnn_units_lists=[[128, 128],[128, 128]], l2_reg_embedding=0.00001, l2_reg_dnn=0, + seed=1024, dnn_dropout=0,dnn_activation='relu', dnn_use_bn=False): + + features = build_input_features(dnn_feature_columns) + inputs_list = list(features.values()) + + sparse_embedding_list, dense_value_list = input_from_feature_columns(features, dnn_feature_columns, l2_reg_embedding,seed) + + dnn_input = combined_dnn_input(sparse_embedding_list, dense_value_list) + + ctr_output = DNN(tower_dnn_units_lists[0], dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed)(dnn_input) + cvr_output = DNN(tower_dnn_units_lists[1], dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed)(dnn_input) + + ctr_logit = tf.keras.layers.Dense(1, use_bias=False, activation=None)(ctr_output) + cvr_logit = tf.keras.layers.Dense(1, use_bias=False, activation=None)(cvr_output) + + ctr_pred = PredictionLayer(task_type, name=task_names[0])(ctr_logit) + cvr_pred = PredictionLayer(task_type)(cvr_logit) + + ctcvr_pred = tf.keras.layers.Multiply(name=task_names[1])([ctr_pred, cvr_pred])#CTCVR = CTR * CVR + + model = tf.keras.models.Model(inputs=inputs_list, outputs=[ctr_pred, cvr_pred, ctcvr_pred]) + return model +``` + +测试数据集: + +adult:[https://archive.ics.uci.edu/ml/datasets/census+income](https://link.zhihu.com/?target=https%3A//archive.ics.uci.edu/ml/datasets/census%2Bincome) + +将里面两个特征转为label,完成两个任务的预测: + +- 任务1预测该用户收入是否大于50K, +- 任务2预测该用户的婚姻是否未婚。 + +以上两个任务均为二分类任务,使用交叉熵作为损失函数。在ESMM框架下,我们把任务1作为CTR任务,任务2作为CVR任务,两者label相乘得到CTCVR任务的标签。 + +除ESSM之外,之后的MMOE、PLE模型都使用本数据集做测试。 + +> 注意上述代码,并未实现论文模型图中提到的field element-wise +模块。该模块实现较为简单,即分别把用户、商品相关特征的embedding求和再拼接,然后输入Tower网络。我们使用数据不具有该属性,暂未区分。 + +参考资料: + +https://www.zhihu.com/question/475787809 + +https://zhuanlan.zhihu.com/p/37562283 + +美团:[https://cloud.tencent.com/developer/article/1868117](https://link.zhihu.com/?target=https%3A//cloud.tencent.com/developer/article/1868117) + +Entire Space Multi-Task Model: An Effective Approach for Estimating Post-Click Conversion Rate (SIGIR'2018) diff --git a/4.人工智能/ch02/ch2.2/ch2.2.5/MMOE.md b/4.人工智能/ch02/ch2.2/ch2.2.5/MMOE.md new file mode 100644 index 0000000..e135bd9 --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.5/MMOE.md @@ -0,0 +1,176 @@ +## 写在前面 + +MMOE是2018年谷歌提出的,全称是Multi-gate Mixture-of-Experts, 对于多个优化任务,引入了多个专家进行不同的决策和组合,最终完成多目标的预测。解决的是硬共享里面如果多个任务相似性不是很强,底层的embedding学习反而相互影响,最终都学不好的痛点。 + +本篇文章首先是先了解下Hard-parameter sharing以及存在的问题,然后引出MMOE,对理论部分进行整理,最后是参考deepctr简单复现。 + +## 背景与动机 + +推荐系统中,即使同一个场景,常常也不只有一个业务目标。 在Youtube的视频推荐中,推荐排序任务不仅需要考虑到用户点击率,完播率,也需要考虑到一些满意度指标,例如,对视频是否喜欢,用户观看后对视频的评分;在淘宝的信息流商品推荐中,需要考虑到点击率,也需要考虑转化率;而在一些内容场景中,需要考虑到点击和互动、关注、停留时长等指标。 + +模型中,如果采用一个网络同时完成多个任务,就可以把这样的网络模型称为多任务模型, 这种模型能在不同任务之间学习共性以及差异性,能够提高建模的质量以及效率。 常见的多任务模型的设计范式大致可以分为三大类: +* hard parameter sharing 方法: 这是非常经典的一种方式,底层是共享的隐藏层,学习各个任务的共同模式,上层用一些特定的全连接层学习特定任务模式。 ++
++ 这种方法目前用的也有,比如美团的猜你喜欢,知乎推荐的Ranking等, 这种方法最大的优势是Task越多, 单任务更加不可能过拟合,即可以减少任务之间过拟合的风险。 但是劣势也非常明显,就是底层强制的shared layers难以学习到适用于所有任务的有效表达。 **尤其是任务之间存在冲突的时候**。MMOE中给出了实验结论,当两个任务相关性没那么好(比如排序中的点击率与互动,点击与停留时长),此时这种结果会遭受训练困境,毕竟所有任务底层用的是同一组参数。 +* soft parameter sharing: 硬的不行,那就来软的,这个范式对应的结果从`MOE->MMOE->PLE`等。 即底层不是使用共享的一个shared bottom,而是有多个tower, 称为多个专家,然后往往再有一个gating networks在多任务学习时,给不同的tower分配不同的权重,那么这样对于不同的任务,可以允许使用底层不同的专家组合去进行预测,相较于上面所有任务共享底层,这个方式显得更加灵活 +* 任务序列依赖关系建模:这种适合于不同任务之间有一定的序列依赖关系。比如电商场景里面的ctr和cvr,其中cvr这个行为只有在点击之后才会发生。所以这种依赖关系如果能加以利用,可以解决任务预估中的样本选择偏差(SSB)和数据稀疏性(DS)问题 + * 样本选择偏差: 后一阶段的模型基于上一阶段采样后的样本子集训练,但最终在全样本空间进行推理,带来严重泛化性问题 + * 样本稀疏: 后一阶段的模型训练样本远小于前一阶段任务 + ++
ESSM是一种较为通用的任务序列依赖关系建模的方法,除此之外,阿里的DBMTL,ESSM2等工作都属于这一个范式。 这个范式可能后面会进行整理,本篇文章不过多赘述。 + +通过上面的描述,能大体上对多任务模型方面的几种常用建模范式有了解,然后也知道了hard parameter sharing存在的一些问题,即不能很好的权衡特定任务的目标与任务之间的冲突关系。而这也就是MMOE模型提出的一个动机所在了, 那么下面的关键就是MMOE模型是怎么建模任务之间的关系的,又是怎么能使得特定任务与任务关系保持平衡的? + +带着这两个问题,下面看下MMOE的细节。 + +## MMOE模型的理论及论文细节 +MMOE模型结构图如下。 + +++ +这其实是一个演进的过程,首先hard parameter sharing这个就不用过多描述了, 下面主要是看MOE模型以及MMOE模型。 + +### 混合专家模型 +我们知道共享的这种模型结构,会遭受任务之间冲突而导致可能无法很好的收敛,从而无法学习到任务之间的共同模式。这个结构也可以看成是多个任务共用了一个专家。 + +先抛开任务关系, 我们发现一个专家在多任务学习上的表达能力很有限,于是乎,尝试引入多个专家,这就慢慢的演化出了混合专家模型。 公式表达如下: +$$ +y=\sum_{i=1}^{n} g(x)_{i} f_{i}(x) +$$ +这里的$y$表示的是多个专家的汇总输出,接下来这个东西要过特定的任务塔去得到特定任务的输出。 这里还加了一个门控网络机制,就是一个注意力网络, 来学习各个专家的重要性权重$\sum_{i=1}^{n} g(x)_{i}=1$。$f_i(x)$就是每个专家的输出, 而$g(x)_i$就是每个专家对应的权重。 虽然感觉这个东西,无非就是在单个专家的基础上多引入了几个全连接网络,然后又给这几个全连接网络加权,但是在我看来,这里面至少蕴含了好几个厉害的思路: +1. 模型集成思想: 这个东西很像bagging的思路,即训练多个模型进行决策,这个决策的有效性显然要比单独一个模型来的靠谱一点,不管是从泛化能力,表达能力,学习能力上,应该都强于一个模型 +2. 注意力思想: 为了增加灵活性, 为不同的模型还学习了重要性权重,这可能考虑到了在学习任务的共性模式上, 不同的模型学习的模式不同,那么聚合的时候,显然不能按照相同的重要度聚合,所以为各个专家学习权重,默认了不同专家的决策地位不一样。这个思想目前不过也非常普遍了。 +3. multi-head机制: 从另一个角度看, 多个专家其实代表了多个不同head, 而不同的head代表了不同的非线性空间,之所以说表达能力增强了,是因为把输入特征映射到了不同的空间中去学习任务之间的共性模式。可以理解成从多个角度去捕捉任务之间的共性特征模式。 + +MOE使用了多个混合专家增加了各种表达能力,但是, 一个门控并不是很灵活,因为这所有的任务,最终只能选定一组专家组合,即这个专家组合是在多个任务上综合衡量的结果,并没有针对性了。 如果这些任务都比较相似,那就相当于用这一组专家组合确实可以应对这多个任务,学习到多个相似任务的共性。 但如果任务之间差的很大,这种单门控控制的方式就不行了,因为此时底层的多个专家学习到的特征模式相差可能会很大,毕竟任务不同,而单门控机制选择专家组合的时候,肯定是选择出那些有利于大多数任务的专家, 而对于某些特殊任务,可能学习的一塌糊涂。 + +所以,这种方式的缺口很明显,这样,也更能理解为啥提出多门控控制的专家混合模型了。 + +### MMOE结构 +Multi-gate Mixture-of-Experts(MMOE)的魅力就在于在OMOE的基础上,对于每个任务都会涉及一个门控网络,这样,对于每个特定的任务,都能有一组对应的专家组合去进行预测。更关键的时候,参数量还不会增加太多。公式如下: + +$$ +y_{k}=h^{k}\left(f^{k}(x)\right), +$$ +where $f^{k}(x)=\sum_{i=1}^{n} g^{k}(x)_{i} f_{i}(x)$. 这里的$k$表示任务的个数。 每个门控网络是一个注意力网络: +$$ +g^{k}(x)=\operatorname{softmax}\left(W_{g k} x\right) +$$ +$W_{g k} \in \mathbb{R}^{n \times d}$表示权重矩阵, $n$是专家的个数, $d$是特征的维度。 + +上面的公式这里不用过多解释。 + +这个改造看似很简单,只是在OMOE上额外多加了几个门控网络,但是却起到了杠杆般的效果,我这里分享下我的理解。 +* 首先,就刚才分析的OMOE的问题,在专家组合选取上单门控会产生限制,此时如果多个任务产生了冲突,这种结构就无法进行很好的权衡。 而MMOE就不一样了。MMOE是针对每个任务都单独有个门控选择专家组合,那么即使任务冲突了,也能根据不同的门控进行调整,选择出对当前任务有帮助的专家组合。所以,我觉得单门控做到了**针对所有任务在专家选择上的解耦**,而多门控做到了**针对各个任务在专家组合选择上的解耦**。 +* 多门控机制能够建模任务之间的关系了。如果各个任务都冲突, 那么此时有多门控的帮助, 此时让每个任务独享一个专家,如果任务之间能聚成几个相似的类,那么这几类之间应该对应的不同的专家组合,那么门控机制也可以选择出来。如果所有任务都相似,那这几个门控网络学习到的权重也会相似,所以这种机制把任务的无关,部分相关和全相关进行了一种统一。 +* 灵活的参数共享, 这个我们可以和hard模式或者是针对每个任务单独建模的模型对比,对于hard模式,所有任务共享底层参数,而每个任务单独建模,是所有任务单独有一套参数,算是共享和不共享的两个极端,对于都共享的极端,害怕任务冲突,而对于一点都不共享的极端,无法利用迁移学习的优势,模型之间没法互享信息,互为补充,容易遭受过拟合的困境,另外还会增加计算量和参数量。 而MMOE处于两者的中间,既兼顾了如果有相似任务,那就参数共享,模式共享,互为补充,如果没有相似任务,那就独立学习,互不影响。 又把这两种极端给进行了统一。 +* 训练时能快速收敛,这是因为相似的任务对于特定的专家组合训练都会产生贡献,这样进行一轮epoch,相当于单独任务训练时的多轮epoch。 + +OK, 到这里就把MMOE的故事整理完了,模型结构本身并不是很复杂,非常符合"大道至简"原理,简单且实用。 + + +那么, 为什么多任务学习为什么是有效的呢? 这里整理一个看到比较不错的答案: +>多任务学习有效的原因是引入了归纳偏置,两个效果: +> - 互相促进: 可以把多任务模型之间的关系看作是互相先验知识,也称为归纳迁移,有了对模型的先验假设,可以更好提升模型的效果。解决数据稀疏性其实本身也是迁移学习的一个特性,多任务学习中也同样会体现 +>- 泛化作用:不同模型学到的表征不同,可能A模型学到的是B模型所没有学好的,B模型也有其自身的特点,而这一点很可能A学不好,这样一来模型健壮性更强 + +## MMOE模型的简单复现之多任务预测 +### 模型概貌 +这里是MMOE模型的简单复现,参考的deepctr。 + +由于MMOE模型不是很复杂,所以这里就可以直接上代码,然后简单解释: + +```python +def MMOE(dnn_feature_columns, num_experts=3, expert_dnn_hidden_units=(256, 128), tower_dnn_hidden_units=(64,), + gate_dnn_hidden_units=(), l2_reg_embedding=0.00001, l2_reg_dnn=0, dnn_dropout=0, dnn_activation='relu', + dnn_use_bn=False, task_types=('binary', 'binary'), task_names=('ctr', 'ctcvr')): + + num_tasks = len(task_names) + + # 构建Input层并将Input层转成列表作为模型的输入 + input_layer_dict = build_input_layers(dnn_feature_columns) + input_layers = list(input_layer_dict.values()) + + # 筛选出特征中的sparse和Dense特征, 后面要单独处理 + sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns)) + dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns)) + + # 获取Dense Input + dnn_dense_input = [] + for fc in dense_feature_columns: + dnn_dense_input.append(input_layer_dict[fc.name]) + + # 构建embedding字典 + embedding_layer_dict = build_embedding_layers(dnn_feature_columns) + # 离散的这些特特征embedding之后,然后拼接,然后直接作为全连接层Dense的输入,所以需要进行Flatten + dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=False) + + # 把连续特征和离散特征合并起来 + dnn_input = combined_dnn_input(dnn_sparse_embed_input, dnn_dense_input) + + # 建立专家层 + expert_outputs = [] + for i in range(num_experts): + expert_network = DNN(expert_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=2022, name='expert_'+str(i))(dnn_input) + expert_outputs.append(expert_network) + + expert_concat = Lambda(lambda x: tf.stack(x, axis=1))(expert_outputs) + + # 建立多门控机制层 + mmoe_outputs = [] + for i in range(num_tasks): # num_tasks=num_gates + # 建立门控层 + gate_input = DNN(gate_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=2022, name='gate_'+task_names[i])(dnn_input) + gate_out = Dense(num_experts, use_bias=False, activation='softmax', name='gate_softmax_'+task_names[i])(gate_input) + gate_out = Lambda(lambda x: tf.expand_dims(x, axis=-1))(gate_out) + + # gate multiply the expert + gate_mul_expert = Lambda(lambda x: reduce_sum(x[0] * x[1], axis=1, keep_dims=False), name='gate_mul_expert_'+task_names[i])([expert_concat, gate_out]) + + mmoe_outputs.append(gate_mul_expert) + + # 每个任务独立的tower + task_outputs = [] + for task_type, task_name, mmoe_out in zip(task_types, task_names, mmoe_outputs): + # 建立tower + tower_output = DNN(tower_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=2022, name='tower_'+task_name)(mmoe_out) + logit = Dense(1, use_bias=False, activation=None)(tower_output) + output = PredictionLayer(task_type, name=task_name)(logit) + task_outputs.append(output) + + model = Model(inputs=input_layers, outputs=task_outputs) + return model +``` +这个其实比较简单, 首先是传入封装好的dnn_features_columns, 这个是 + +```python +dnn_features_columns = [SparseFeat(feat, feature_max_idx[feat], embedding_dim=4) for feat in sparse_features] \ + + [DenseFeat(feat, 1) for feat in dense_features] +``` +就是数据集先根据特征类别分成离散型特征和连续型特征,然后通过sparseFeat或者DenseFeat进行封装起来,组成的一个列表。 + +传入之后, 首先为这所有的特征列建立Input层,然后选择出离散特征和连续特征来,连续特征直接拼接即可, 而离散特征需要过embedding层得到连续型输入。把这个输入与连续特征拼接起来,就得到了送入专家的输入。 + +接下来,建立MMOE的多个专家, 这里的专家直接就是DNN,当然这个可以替换,比如MOSE里面就用了LSTM,这样的搭建模型方式非常灵活,替换起来非常简单。 把输入过多个专家得到的专家的输出,这里放到了列表里面。 + +接下来,建立多个门控网络,由于MMOE里面是每个任务会有一个单独的门控进行控制,所以这里的门控网络个数和任务数相同,门控网络也是DNN,接收输入,得到专家个输出作为每个专家的权重,把每个专家的输出加权组合得到门控网络最终的输出,放到列表中,这里的列表长度和task_num对应。 + +接下来, 为每个任务建立tower,学习特定的feature信息。同样也是DNN,接收的输入是上面列表的输出,每个任务的门控输出输入到各自的tower里面,得到最终的输出即可。 最终的输出也是个列表,对应的每个任务最终的网络输出值。 + +这就是整个MMOE网络的搭建逻辑。 + + +**参考资料**: +* [MMOE论文](https://dl.acm.org/doi/pdf/10.1145/3219819.3220007) +* [Recommending What Video to Watch Next: A Multitask +Ranking System](https://dl.acm.org/doi/pdf/10.1145/3298689.3346997) +* [Multitask Mixture of Sequential Experts for User Activity Streams](https://research.google/pubs/pub49274/) +* [推荐系统中的多目标学习](https://zhuanlan.zhihu.com/p/183760759) +* [推荐精排模型之多目标](https://zhuanlan.zhihu.com/p/221738556) +* [Youtube视频推荐中应用MMOE模型](http://t.zoukankan.com/Lee-yl-p-13274642.html) +* [多任务学习论文导读:Recommending What Video to Watch Next-A Multitask Ranking System](https://blog.csdn.net/fanzitao/article/details/104525843/) +* [多任务模型之MoSE](https://zhuanlan.zhihu.com/p/161628342) diff --git a/4.人工智能/ch02/ch2.2/ch2.2.5/PLE.md b/4.人工智能/ch02/ch2.2/ch2.2.5/PLE.md new file mode 100644 index 0000000..2c1050c --- /dev/null +++ b/4.人工智能/ch02/ch2.2/ch2.2.5/PLE.md @@ -0,0 +1,390 @@ +# PLE + +**PLE**(Progressive Layered Extraction)模型由腾讯PCG团队在2020年提出,主要为了解决跷跷板问题,该论文获得了RecSys'2020的最佳长论文(Best Lone Paper Award)。 + +## 背景与动机 + +文章首先提出多任务学习中不可避免的两个缺点: + +- 负迁移(Negative Transfer):针对相关性较差的任务,使用shared-bottom这种硬参数共享的机制会出现负迁移现象,不同任务之间存在冲突时,会导致模型无法有效进行参数的学习,不如对多个任务单独训练。 +- 跷跷板现象(Seesaw Phenomenon):针对相关性较为复杂的场景,通常不可避免出现跷跷板现象。多任务学习模式下,往往能够提升一部分任务的效果,但同时需要牺牲其他任务的效果。即使通过MMOE这种方式减轻负迁移现象,跷跷板问题仍然广泛存在。 + +在腾讯视频推荐场景下,有两个核心建模任务: + +- VCR(View Completion Ratio):播放完成率,播放时间占视频时长的比例,回归任务 +- VTR(View Through Rate) :有效播放率,播放时间是否超过某个阈值,分类任务 + +这两个任务之间的关系是复杂的,在应用以往的多任务模型中发现,要想提升VTR准确率,则VCR准确率会下降,反之亦然。 + +上一小节提到的MMOE网络存在如下几个缺点 + +- MMOE中所有的Expert是被所有任务所共享,这可能无法捕捉到任务之间更复杂的关系,从而给部分任务带来一定的噪声。 +- 在复杂任务机制下,MMOE不同专家在不同任务的权重学的差不多 +- 不同的Expert之间没有交互,联合优化的效果有所折扣 + +## 解决方案 + +为了解决跷跷板现象,以及优化MMOE模型,PLE在网络结构设计上提出两大改进: + +**一、CGC**(Customized Gate Control) 定制门控 + +PLE将共享的部分和每个任务特定的部分**显式的分开**,强化任务自身独立特性。把MMOE中提出的Expert分成两种,任务特定task-specific和任务共享task-shared。保证expert“各有所得”,更好的降低了弱相关性任务之间参数共享带来的问题。 + +网络结构如图所示,同样的特征输入分别送往三类不同的专家模型(任务A专家、任务B专家、任务共享专家),再通过门控机制加权聚合之后输入各自的Tower网络。门控网络,把原始数据和expert网络输出共同作为输入,通过单层全连接网络+softmax激活函数,得到分配给expert的加权权重,与attention机制类型。 + + + ++
++ +任务A有 ![[公式]](https://www.zhihu.com/equation?tex=m_A) 个expert,任务B有 ![[公式]](https://www.zhihu.com/equation?tex=m_B) 个expert,另外还有 ![[公式]](https://www.zhihu.com/equation?tex=m_S) 个任务A、B共享的Expert。这样对Expert做一个显式的分割,可以让task-specific expert只受自己任务梯度的影响,不会受到其他任务的干扰(每个任务保底有一个独立的网络模型),而只有task-shared expert才受多个任务的混合梯度影响。 + +MMOE则是将所有Expert一视同仁,都加权输入到每一个任务的Tower,其中任务之间的关系完全交由gate自身进行学习。虽然MMOE提出的门控机制理论上可以捕捉到任务之间的关系,比如任务A可能与任务B确实无关,则MMOE中gate可以学到,让个别专家对于任务A的权重趋近于0,近似得到PLE中提出的task-specific expert。如果说MMOE是希望让expert网络可以对不同的任务各有所得,则PLE是保证让expert网络各有所得。 + +二、**PLE** (progressive layered extraction) 分层萃取 + +PLE就是上述CGC网络的多层纵向叠加,以获得更加丰富的表征能力。在分层的机制下,Gate设计成两种类型,使得不同类型Expert信息融合交互。task-share gate融合所有Expert信息,task-specific gate只融合specific expert和share expert。模型结构如图: + ++
++ +将任务A、任务B和shared expert的输出输入到下一层,下一层的gate是以这三个上一层输出的结果作为门控的输入,而不是用原始input特征作为输入。这使得gate同时融合task-shares expert和task-specific expert的信息,论文实验中证明这种不同类型expert信息的交叉,可以带来更好的效果。 + +三、多任务loss联合优化 + +该论文专门讨论了loss设计的问题。在传统的多任务学习模型中,多任务的loss一般为 + ++
++ +其中K是指任务数, ![[公式]](https://www.zhihu.com/equation?tex=w_k) 是每个任务各自对应的权重。这种loss存在两个关键问题: + +- 不同任务之间的样本空间不一致:在视频推荐场景中,目标之间的依赖关系如图,曝光→播放→点击→(分享、评论),不同任务有不同的样本空间。 + ++
++ + + + PLE将训练样本空间作为全部任务样本空间的并集,在分别针对每个任务算loss时,只考虑该任务的样本的空 间,一般需对这种数据集会附带一个样本空间标签。loss公式如下: + ++
++ + + 其中, ![[公式]](https://www.zhihu.com/equation?tex=%5Cdelta_%7Bk%7D%5E%7Bi%7D+%5Cin%5C%7B0%2C1%5C%7D%2C+%5Cdelta_%7Bk%7D%5E%7Bi%7D+) 表示样本i是否处于任务k的样本空间。 + +- 不同任务各自独立的权重设定:PLE提出了一种加权的规则,它的思想是随着迭代次数的增加,任务的权重应当不断衰减。它为每个任务设定一个初始权重 ![[公式]](https://www.zhihu.com/equation?tex=w_%7Bk%2C0%7D) ,再按该公式进行更新: + ++
++ +## 实验 + +该论文的一大特点是提供了极其丰富的实验,首先是在自身大规模数据集上的离线实验。 + +第一组实验是两个关系复杂的任务VTR(回归)与VCR(分类),如表1,实验结果证明PLE可以实现多任务共赢,而其他的硬共享或者软共享机制,则会导致部分任务受损。 + ++
++ + + +第二组实验是两个关系简单清晰的任务,CTR与VCR,都是分类任务,且CTR→VCR存在任务依赖关系,如表2,这种多任务下,基本上所有参数共享的模型都能得到性能的提升,而PLE的提升效果最为明显。 + ++
++ + + +第三组实验则是线上的A/B Test,上面两组离线实验中,其实PLE相比于其他baseline模型,无论是回归任务的mse,还是分类任务的auc,提升都不是特别显著。在推荐场景中,评估模型性能的最佳利器还是线上的A/B Test。作者在pcg视频推荐的场景中,将部分用户随机划分到不同的实验组中,用PLE模型预估VTR和VCR,进行四周的实验。如表3所示,线上评估指标(总播放完成视频数量和总播放时间)均得到了较为显著的提升,而硬参数共享模型则带对两个指标都带来显著的下降。 + ++
++ +第四组实验中,作者引入了更多的任务,验证PLE分层结构的必要性。如表4,随着任务数量的增加,PLE对比CGC的优势更加显著。 + ++
++ +文中也设计实验,单独对MMOE和CGC的专家利用率进行对比分析,为了实现方便和公平,每个expert都是一个一层网络,每个expert module都只有一个expert,每一层只有3个expert。如图所示,柱子的高度和竖直短线分别表示expert权重的均值和方差。 + ++
++ + + +可以看到,无论是 MMoE 还是 ML-MMoE,不同任务在三个 Expert 上的权重都是接近的,但对于 CGC & PLE 来说,不同任务在共享 Expert 上的权重是有较大差异的。PLE针对不同的任务,能够有效利用共享 Expert 和独有 Expert 的信息,解释了为什么其能够达到比 MMoE 更好的训练结果。CGC理论上是MMOE的子集,该实验表明,现实中MMOE很难收敛成这个CGC的样子,所以PLE模型就显式的规定了CGC这样的结构。 + +## 总结与拓展 + +总结: + +CGC在结构上设计的分化,实现了专家功能的分化,而PLE则是通过分层叠加,使得不同专家的信息进行融合。整个结构的设计,是为了让多任务学习模型,不仅可以学习到各自任务独有的表征,还能学习不同任务共享的表征。 + +论文中也对大多数的MTL模型进行了抽象,总结如下图: + ++
++ + + +不同的MTL模型即不同的参数共享机制,CGC的结构最为灵活。 + +可以思考下以下几个问题: + +1. 多任务模型线上如何打分融合? + 在论文中,作者分享了腾讯视频的一种线上打分机制 + ++
++ + 每个目标的预估值有一个固定的权重,通过乘法进行融合,并在最后未来排除视频自身时长的影响,使用 $ f(videolen)$对视频时长进行了非线性变化。其实在业界的案例中,也基本是依赖乘法或者加法进行融合,爱奇艺曾经公开分享过他们使用过的打分方法: + ++
++ + + + 在业务目标较少时,通过加法方式融合新增目标可以短期内快速获得收益。但是随着目标增多,加法融合会 逐步弱化各字母表的重要性影响,而乘法融合则具有一定的模板独立性,乘法机制更加灵活,效益更好。融 合的权重超参一般在线上通过A/B test调试。 + +2. 专家的参数如何设置? + PLE模型存在的超参数较多,其中专家和门控网络都有两种类型。一般来说,task-specific expert每个任务1-2个,shared expert个数在任务个数的1倍以上。原论文中的gate网络即单层FC,可以适当增加,调试。 + +3. ESMM、MMOE、PLE模型如何选择? + + - 个人经验,无论任务之间是否有依赖关系,皆可以优先尝试CGC。而多层CGC(即PLE)未必比CGC效果好,且在相同参数规模小,CGC普遍好于MMOE。对于相关性特别差的多任务,CGC相对MMOE而言有多个专有expert兜底。 + + - 对于典型的label存在路径依赖的多任务,例如CTR与CVR,可尝试ESMM。 + + - 而在业界的实践案例中,更多的是两种范式的模型进行融合。例如美团在其搜索多业务排序场景上提出的模型: + ++
++ + + + 总框架是ESMM的架构,以建模下单(CVR)为主任务,CTR和CTCVR为辅助任务。在底层的模块中,则使用了CGC模块,提取多任务模式下的特征表达信息。 + +4. 不同Tower能否输入不同的特征?不同的expert使用不同的特征?不同的门控使用不同的特征? + MMOE、PLE原论文中介绍的模型均是使用同样的原始特征输入各个不同的expert,也输入给第一层的gate。最顶层的Tower网络中则均是由一个gate融合所有expert输出作为输入。在实践中,可以根据业务需求进行调整。 + + - 例如上图中美团提出的模型,在CTR的tower下,设置了五个子塔:闪购子网络、买菜子网络、外卖子网络、优选子网络和团好货子网络,并且对不同的子塔有额外输入不同的特征。 + 对于底层输入给expert的特征,美团提出通过增加一个自适应的特征选择门,使得选出的特征对不同的业务权重不同。例如“配送时间”这个特征对闪购业务比较重要,但对于团好货影响不是很大。模型结构如图: + ++
++ + + + 特征选择门与控制expert信息融合的gate类似,由一层FC和softmax组成,输出是特征维度的权重。对于每一个特征通过该门都得到一个权重向量,权重向量点乘原始特征的embedding作为expert的输入。 + +5. 多任务loss更高效的融合机制 + + 推荐首先尝试两种简单实用的方法,GrandNorm和UWL,具体实现细节查看下文所附的参考资料。 + + - UWL(Uncertainty Weight):通过自动学习任务的uncertainty,给uncertainty大的任务小权重,uncertainty小的任务大权重; + - GradNorm:结合任务梯度的二范数和loss下降梯度,引入带权重的损失函数Gradient Loss,并通过梯度下降更新该权重。 + +## 代码实践 + +主要是分两个层级,在PLE的层级下,由于PLE是分层,上一层是输出是下一层的输入,代码逻辑为: + +```python +# build Progressive Layered Extraction + ple_inputs = [dnn_input] * (num_tasks + 1) # [task1, task2, ... taskn, shared task] + ple_outputs = [] + for i in range(num_levels): + if i == num_levels - 1: # the last level + ple_outputs = cgc_net(inputs=ple_inputs, level_name='level_' + str(i) + '_', is_last=True) + else: + ple_outputs = cgc_net(inputs=ple_inputs, level_name='level_' + str(i) + '_', is_last=False) + ple_inputs = ple_outputs +``` + +其中cgc_net函数则对应论文中提出的CGC模块,我们把expert分成两类,task-specific和task-shared,为了方便索引,expert list中expert的排列顺序为[task1-expert1, task1-expert2,...task2-expert1, task2-expert2,...shared expert 1... ],则可以通过双重循环创建专家网络: + +```python +for i in range(num_tasks): #任务个数 + for j in range(specific_expert_num): #每个任务对应的task-specific专家个数 + pass +``` + +注意门控网络也分为两种类型,task-specific gate的输入是每个任务对应的expert的输出和共享expert的输出,我们同样把共享expert的输出放在最后,方便索引 + +```python +for i in range(num_tasks): + # concat task-specific expert and task-shared expert + cur_expert_num = specific_expert_num + shared_expert_num + # task_specific + task_shared + cur_experts = specific_expert_outputs[ + i * specific_expert_num:(i + 1) * specific_expert_num] + shared_expert_outputs +``` + +在最后一层中,由于CGC模块的输出需要分别输入给不同任务各自的Tower模块,所以不需要创建task-shared gate。完整代码如下 + +```python +def PLE(dnn_feature_columns, shared_expert_num=1, specific_expert_num=1, num_levels=2, + expert_dnn_hidden_units=(256,), tower_dnn_hidden_units=(64,), gate_dnn_hidden_units=(), + l2_reg_embedding=0.00001, + l2_reg_dnn=0, seed=1024, dnn_dropout=0, dnn_activation='relu', dnn_use_bn=False, + task_types=('binary', 'binary'), task_names=('ctr', 'ctcvr')): + """Instantiates the multi level of Customized Gate Control of Progressive Layered Extraction architecture. + :param dnn_feature_columns: An iterable containing all the features used by deep part of the model. + :param shared_expert_num: integer, number of task-shared experts. + :param specific_expert_num: integer, number of task-specific experts. + :param num_levels: integer, number of CGC levels. + :param expert_dnn_hidden_units: list,list of positive integer or empty list, the layer number and units in each layer of expert DNN. + :param tower_dnn_hidden_units: list,list of positive integer or empty list, the layer number and units in each layer of task-specific DNN. + :param gate_dnn_hidden_units: list,list of positive integer or empty list, the layer number and units in each layer of gate DNN. + :param l2_reg_embedding: float. L2 regularizer strength applied to embedding vector. + :param l2_reg_dnn: float. L2 regularizer strength applied to DNN. + :param seed: integer ,to use as random seed. + :param dnn_dropout: float in [0,1), the probability we will drop out a given DNN coordinate. + :param dnn_activation: Activation function to use in DNN. + :param dnn_use_bn: bool. Whether use BatchNormalization before activation or not in DNN. + :param task_types: list of str, indicating the loss of each tasks, ``"binary"`` for binary logloss, ``"regression"`` for regression loss. e.g. ['binary', 'regression'] + :param task_names: list of str, indicating the predict target of each tasks + :return: a Keras model instance. + """ + num_tasks = len(task_names) + if num_tasks <= 1: + raise ValueError("num_tasks must be greater than 1") + + if len(task_types) != num_tasks: + raise ValueError("num_tasks must be equal to the length of task_types") + + for task_type in task_types: + if task_type not in ['binary', 'regression']: + raise ValueError("task must be binary or regression, {} is illegal".format(task_type)) + + features = build_input_features(dnn_feature_columns) + + inputs_list = list(features.values()) + + sparse_embedding_list, dense_value_list = input_from_feature_columns(features, dnn_feature_columns, + l2_reg_embedding, seed) + dnn_input = combined_dnn_input(sparse_embedding_list, dense_value_list) + + # single Extraction Layer + def cgc_net(inputs, level_name, is_last=False): + # inputs: [task1, task2, ... taskn, shared task] + specific_expert_outputs = [] + # build task-specific expert layer + for i in range(num_tasks): + for j in range(specific_expert_num): + expert_network = DNN(expert_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, + seed=seed, + name=level_name + 'task_' + task_names[i] + '_expert_specific_' + str(j))( + inputs[i]) + specific_expert_outputs.append(expert_network) + + # build task-shared expert layer + shared_expert_outputs = [] + for k in range(shared_expert_num): + expert_network = DNN(expert_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, + seed=seed, + name=level_name + 'expert_shared_' + str(k))(inputs[-1]) + shared_expert_outputs.append(expert_network) + + # task_specific gate (count = num_tasks) + cgc_outs = [] + for i in range(num_tasks): + # concat task-specific expert and task-shared expert + cur_expert_num = specific_expert_num + shared_expert_num + # task_specific + task_shared + cur_experts = specific_expert_outputs[ + i * specific_expert_num:(i + 1) * specific_expert_num] + shared_expert_outputs + + expert_concat = tf.keras.layers.Lambda(lambda x: tf.stack(x, axis=1))(cur_experts) + + # build gate layers + gate_input = DNN(gate_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, + seed=seed, + name=level_name + 'gate_specific_' + task_names[i])( + inputs[i]) # gate[i] for task input[i] + gate_out = tf.keras.layers.Dense(cur_expert_num, use_bias=False, activation='softmax', + name=level_name + 'gate_softmax_specific_' + task_names[i])(gate_input) + gate_out = tf.keras.layers.Lambda(lambda x: tf.expand_dims(x, axis=-1))(gate_out) + + # gate multiply the expert + gate_mul_expert = tf.keras.layers.Lambda(lambda x: reduce_sum(x[0] * x[1], axis=1, keep_dims=False), + name=level_name + 'gate_mul_expert_specific_' + task_names[i])( + [expert_concat, gate_out]) + cgc_outs.append(gate_mul_expert) + + # task_shared gate, if the level not in last, add one shared gate + if not is_last: + cur_expert_num = num_tasks * specific_expert_num + shared_expert_num + cur_experts = specific_expert_outputs + shared_expert_outputs # all the expert include task-specific expert and task-shared expert + + expert_concat = tf.keras.layers.Lambda(lambda x: tf.stack(x, axis=1))(cur_experts) + + # build gate layers + gate_input = DNN(gate_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, + seed=seed, + name=level_name + 'gate_shared')(inputs[-1]) # gate for shared task input + + gate_out = tf.keras.layers.Dense(cur_expert_num, use_bias=False, activation='softmax', + name=level_name + 'gate_softmax_shared')(gate_input) + gate_out = tf.keras.layers.Lambda(lambda x: tf.expand_dims(x, axis=-1))(gate_out) + + # gate multiply the expert + gate_mul_expert = tf.keras.layers.Lambda(lambda x: reduce_sum(x[0] * x[1], axis=1, keep_dims=False), + name=level_name + 'gate_mul_expert_shared')( + [expert_concat, gate_out]) + + cgc_outs.append(gate_mul_expert) + return cgc_outs + + # build Progressive Layered Extraction + ple_inputs = [dnn_input] * (num_tasks + 1) # [task1, task2, ... taskn, shared task] + ple_outputs = [] + for i in range(num_levels): + if i == num_levels - 1: # the last level + ple_outputs = cgc_net(inputs=ple_inputs, level_name='level_' + str(i) + '_', is_last=True) + else: + ple_outputs = cgc_net(inputs=ple_inputs, level_name='level_' + str(i) + '_', is_last=False) + ple_inputs = ple_outputs + + task_outs = [] + for task_type, task_name, ple_out in zip(task_types, task_names, ple_outputs): + # build tower layer + tower_output = DNN(tower_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed, + name='tower_' + task_name)(ple_out) + logit = tf.keras.layers.Dense(1, use_bias=False, activation=None)(tower_output) + output = PredictionLayer(task_type, name=task_name)(logit) + task_outs.append(output) + + model = tf.keras.models.Model(inputs=inputs_list, outputs=task_outs) + return model +``` + +参考资料 + +Progressive Layered Extraction (PLE): A Novel Multi-Task Learning (MTL) Model for Personalized Recommendations (RecSys'2020) + +https://zhuanlan.zhihu.com/p/291406172 + +爱奇艺:[https://www.6aiq.com/article/1624916831286](https://link.zhihu.com/?target=https%3A//www.6aiq.com/article/1624916831286) + +美团:[https://mp.weixin.qq.com/s/WBwvfqOTDKCwGgoaGoSs6Q](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s/WBwvfqOTDKCwGgoaGoSs6Q) + +多任务loss优化:[https://blog.csdn.net/wuzhongqi](https://link.zhihu.com/?target=https%3A//blog.csdn.net/wuzhongqiang/article/details/124258128) ++