02 Retrieval
召回系统是推荐漏斗的第一个环节,需要在有限延迟内从海量候选中筛选出千级规模的初选集合,为后续排序提供高质量输入
- 有限时间、初步筛选
本章围绕三条主线展开:
- 协同过滤通过物品或用户的相似性
- 建模刻画关系(ItemCF 与 Swing 改进物品相似度;UserCF 与矩阵分解开启向量化表示)
- 向量召回将用户与物品映射到统一嵌入空间,支持 I2I 与 U2I 的高效向量检索
- 序列召回关注用户行为的时序性,通过兴趣建模与序列预测提升相关性
01 Collaborative Filtering¶
协同过滤(Collaborative Filtering) 是推荐系统中最基础的技术之一。它的核心思想基于一个朴素的假设:相似的用户会喜欢相似的物品
- 具体而言,如果两个用户在历史上对很多物品有相似的偏好,那么其中一个用户喜欢的新物品,另一个用户也很可能会喜欢;反之,如果两个物品被很多相同的用户喜欢,那么喜欢其中一个物品的用户也很可能喜欢另一个物品
这个想法最早在 1992 年的 Tapestry 系统 ( Goldberget al., 1992) 中被提出,后来在 Amazon、Netflix 等公司得到了广泛应用
协同过滤的工作过程可以简单描述为:首先收集用户的历史行为数据(如评分、点击、购买记录
根据计算相似度的对象不同,协同过滤可以分为两种基本类型:
基于物品的协同过滤(Item-based CF)( 2.1.1 节 ):找到与用户历史喜欢物品相似的其他物品进行推荐。我们从 ItemCF 开始介绍,主要基于以下考虑:首先,ItemCF 在工业界应用更广泛,Amazon 早在 2003 年就将其成功应用于电商推荐;其次,物品数量通常少于用户数量且属性更稳定,相似度矩阵维护成本更低。Swing 算法作为 ItemCF 的改进方法,通过分析用户 - 物品二部图结构来提升相似度计算的鲁棒性,我们将其作为 ItemCF 的自然延伸一并介绍
基于用户的协同过滤(User-based CF)( 2.1.2 节 ):找到与目标用户兴趣相似的其他用户,然后推荐这些相似用户喜欢的物品。UserCF 作为协同过滤的开山之作,奠定了通过用户相似性进行推荐的基础思路。相比 ItemCF,UserCF 更擅长发现用户的潜在兴趣和提供新颖性推荐,因为相似用户可能会喜欢目标用户从未接触过的物品类别。然而,UserCF 在实际应用中面临更大的挑战:用户兴趣变化快、用户数量庞大导致计算复杂度高、用户行为稀疏性使得相似度计算不稳定。这些问题使得 UserCF 在大规模工业系统中的应用相对有限,但其“通过群体智慧进行推荐”的核心思想为后续的深度学习方法提供了重要启发
矩阵分解(Matrix Factorization)( 2.1.3 节 ):不同于传统的 U2U2I 或 I2I 召回路径,矩阵分解直接建模用户 - 物品交互矩阵,通过学习用户和物品的低维向量表示来预测偏好,实现了 U2I 的直接召回。这标志着协同过滤从基于邻域的统计方法向基于模型的学习方法转变,其向量化表示的思想为后续的向量召回技术奠定了基础。我们将矩阵分解作为协同过滤技术演进的重要里程碑单独介绍
接下来我们将详细介绍这些协同过滤方法的原理和实现,从基于邻域的统计方法到基于模型的学习方法,逐步展现协同过滤技术的演进历程
1.1 ItemCF¶
当你在购物网站上买了一件 T 恤后 , 系统往往会推荐夹克等其他服装。这背后的逻辑很直观 : 既然你喜欢这件 T 恤 , 那么与它相似的其他服装可能也符合你的品味。这就是基于物品的协同过滤 (ItemCF)的核心思想
ItemCF 的思路建立在一个简单的假设上 : 用户的兴趣具有一定的连贯性 , 喜欢某个物品的用户往往也会对相似的物品感兴趣。它关注的是 " 和你喜欢的东西相似的其他东西 "
从下图可以看到 , 当我们要给左方用户推荐物品时 , ItemCF 会分析 T 恤和夹克之间的相似性。由于右方三个用户都同时喜欢这两种服装 , 系统判断它们具有较高的相似度 , 从而将夹克推荐给购买过 T 恤的用户
ItemCF 核心算法 ¶
ItemCF 的实现流程主要包含以下两个步骤 :
第一步 : 物品相似度计算
要实现 ItemCF, 首先需要量化物品之间的相似程度。在大多数实际应用场景中 , 我们通常只有用户是否对物品有过交互行为的数据 ( 如点击、购买、收藏等 )
- 物品本身的信息?
理论上 , 我们可以将每个物品表示为一个用户向量 , 然后计算向量间的相似度。但当商品数量巨大时 , 计算所有物品对之间的相似度会变成一个巨大的计算负担
- 相当于使用 " 用户对物品的交互行为 " 的数据来建模物品的表示
实际上 , 很多物品对之间没有共同的用户交互 , 它们的相似度必然为 0, 计算它们就是浪费时间。因此我们从用户出发找物品组合 , 采用更高效的实现方式 :
- 构建用户 - 物品倒排表: 为每个用户维护一个交互过的物品列表
- 计算物品共现矩阵: 创建一个矩阵 \(C[i][j]\) 来记录物品 \(i\) 和 \(j\) 的共同用户数量。遍历所有用户的物品列表 , 将列表中的物品两两组合 , 对应的 \(C[i][j]\) 加 1
- 计算最终相似度: 使用余弦相似度公式计算物品相似度 :
这里 \(|N(i)|\) 表示与物品 \(i\) 有交互的用户总数 , \(C[i][j]\) 是两个物品的共现次数。这个公式很直观 : 分子是两个物品的共同用户数 , 分母是它们各自用户数的几何平均值
算法复杂度分析
这种基于用户交互的实现方式相比暴力计算有显著的效率提升 :
- 暴力方法: 计算所有物品对的相似度 , 复杂度为 \(O(|I|^2 \cdot |U|)\)
- 优化方法: 只计算有共同用户的物品对 , 复杂度为 \(O(\sum_{u} |N(u)|^2)\)
- 为什么这里是平方级别的? 我们所拥有的数据格式为:\(I(U) = \{i_{1},i_{2},...\}\) 将每个 U 所拥有的物品两两配对 (意味着这两个物品同时拥有一个共同用户 U),配对复杂度为 \(|N(u)|^{2}\) 对所有用户 U 求和,就能得到上述复杂度
其中 \(|N(u)|\) 是用户 \(u\) 交互过的物品数量。由于 \(\sum_{u} |N(u)| = R\) ( 总交互数 ), 且用户平均交互物品数 \(\bar{m} = R/|U|\), 所以优化方法的复杂度可近似为 \(O(R \cdot \bar{m})\)
- 用户每一次与物品的交互,都会导致计算共同用户的物品对增加 \(\overline{m}\) 次
在实际的稀疏数据场景中 , 由于 \(\bar{m} \ll |I|\), 这种方法的效率远高于暴力计算。更重要的是 , 它只计算真正有意义的物品对 ( 即有共同用户的物品对 ), 避免了大量无效计算
第二步 : 候选物品推荐
有了物品相似度矩阵 , 我们就能预测用户对未接触物品的喜好程度了。在实际应用中 , ItemCF 的推荐流程通常采用以下策略来提高效率 :
首先 , 系统会选取用户最近交互的物品作为兴趣种子 ( 通常是几百个 ), 然后为每个种子物品找到最相似的若干个候选物品 ( 如 Top-10), 这样可以快速扩展出候选物品集
其中 \(N(u)\) 是用户 \(u\) 交互过的物品集合 , \(w_{ij}\) 是物品 \(i\) 和 \(j\) 的相似度 , \(r_{uj}\) 表示用户对物品 \(j\) 的兴趣程度 ( 在隐式反馈场景下可设为 1)
- 隐式反馈:用户对物品的交互行为 ( 点击、浏览 ) 是无法进行量化的 相对的,有显式的交互行为 (评分、点赞数.....)
最终 , 系统对所有候选物品按兴趣分数排序 , 选择 Top-N 物品作为 ItemCF 通道的推荐结果。这种 " 种子扩展 " 的方式既保证了推荐质量 , 又大大提高了计算效率
扩展 : 处理评分数据的相似度计算
前面介绍的余弦相似度适用于隐式反馈场景 ( 如点击、浏览 ), 但在某些应用中我们还有显式评分数据 ( 如 5 星评分、点赞数等 )。对于这类数据 , 可以使用皮尔逊相关系数
皮尔逊相关系数
当有评分数据时 , 皮尔逊相关系数能更好地捕获物品间的相似性模式 :
其中 \(U_{ij}\) 表示同时评价过物品 \(i\) 和 \(j\) 的用户集合 , \(r_{ui}\) 是用户 \(u\) 对物品 \(i\) 的评分 , \(\bar{r}_i\) 是物品 \(i\) 的平均评分。
皮尔逊相关系数通过中心化处理 , 能够 : - 消除不同物品评分分布的差异 (有些物品普遍评分高, 有些偏低) - 关注用户评分的相对趋势而非绝对数值 - 更好地识别"用户对两个物品评分模式一致"的相似性
基于皮尔逊相似度 , 可以预测用户对未接触物品的评分 :
这个公式以物品 \(j\) 的平均评分为基准 , 根据用户对相似物品的评分偏好进行调整。
虽然皮尔逊相关系数在理论上更适合评分数据 , 但在现代大规模推荐系统中 , 由于计算复杂度和数据稀疏性问题 , 多数系统仍倾向于使用余弦相似度并基于隐式反馈数据进行优化
应用实践
数据集,下表是用户评分数据 :
| - | 用户 1 | 用户 2 | 用户 3 | 用户 4 | 用户 5 |
|---|---|---|---|---|---|
| 物品 1 | 5 | 3 | 4 | 3 | 1 |
| 物品 2 | 3 | 1 | 3 | 3 | 5 |
| 物品 3 | 4 | 2 | 4 | 1 | 5 |
| 物品 4 | 4 | 3 | 3 | 5 | 2 |
| 物品 5 | ? | 3 | 5 | 4 | 1 |
手动分析:计算物品之间的相似度 , 以物品 5 和物品 1 之间的皮尔逊相关系数为例。\(\hat{r}_{item5}=3.25,\ \hat{r}_{item1}=2.75\), 向量减去均值 \(\text{item5}:(-0.25, 1.75, 0.75, -2.25) \quad \text{item1}: (0.25, 1.25, 0.25, -1.75)\)
根据皮尔逊相关系数 , 可以找到与物品 5 最相似的两个物品是物品 1 和物品 4。基于相似物品 , 根据上面的计算公式 , 可以计算出用户 1 对物品 5 的最终得分是 :
代码实践 ¶
- 数据准备
import numpy as np
item_data = {
'item1': {'user1': 5, 'user2': 3, 'user3': 4, 'user4': 3, 'user5': 1},
'item2': {'user1': 3, 'user2': 1, 'user3': 3, 'user4': 3, 'user5': 5},
'item3': {'user1': 4, 'user2': 2, 'user3': 4, 'user4': 1, 'user5': 5},
'item4': {'user1': 4, 'user2': 3, 'user3': 3, 'user4': 5, 'user5': 2},
'item5': {'user2': 3, 'user3': 5, 'user4': 4, 'user5': 1},
}
- 计算物品间的相似度矩阵
import pandas as pd
similarity_matrix = pd.DataFrame(
np.identity(len(item_data)),
index=item_data.keys(),
columns=item_data.keys(),
)
# 遍历每条物品-用户评分数据
<div markdown="1" style="margin-top: -30px; font-size: 0.75em; opacity: 0.7;">
:material-circle-edit-outline: 约 21733 个字 :fontawesome-solid-code: 520 行代码 :material-image-multiple-outline: 15 张图片 :material-clock-time-two-outline: 预计阅读时间 79 分钟
</div>
for i1, users1 in item_data.items():
for i2, users2 in item_data.items():
if i1 == i2:
continue
vec1, vec2 = [], []
for user, rating1 in users1.items():
rating2 = users2.get(user, -1)
if rating2 == -1:
continue
vec1.append(rating1)
vec2.append(rating2)
similarity_matrix[i1][i2] = np.corrcoef(vec1, vec2)[0][1]
print(similarity_matrix)
- 评分预测
从 user1 交互过的物品中 , 找到与 item5 最相似的 2 个物品 :
target_user = 'user1'
target_item = 'item5'
num = 2
sim_items = []
sim_items_list = similarity_matrix[target_item].sort_values(ascending=False).index.tolist()
for item in sim_items_list:
# 如果target_user对物品item评分过
if target_user in item_data[item]:
sim_items.append(item)
if len(sim_items) == num:
break
print(f'与物品{target_item}最相似的{num}个物品为:{sim_items}')
预测用户 1 对物品 5 的评分 :
target_user_mean_rating = np.mean(list(item_data[target_item].values()))
weighted_scores = 0.
corr_values_sum = 0.
for item in sim_items:
corr_value = similarity_matrix[target_item][item]
user_mean_rating = np.mean(list(item_data[item].values()))
weighted_scores += corr_value * (item_data[item][target_user] - user_mean_rating)
corr_values_sum += corr_value
target_item_pred = target_user_mean_rating + weighted_scores / corr_values_sum
print(f'用户{target_user}对物品{target_item}的预测评分为:{target_item_pred}')
- 训练和评估模型
输出结果 :
+---------------+--------------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | precision@10 | precision@5 |
+===============+==============+================+===============+
| 0.6594 | 0.5459 | 0.1444 | 0.1826 |
+---------------+--------------+----------------+---------------+
从 ItemCF 到 Swing: 面向工业场景的优化 ¶
ItemCF 虽然简单有效 , 但在工业应用中暴露出明显问题 : 热门物品因共现次数多而占据主导地位 , 随机点击等噪音行为影响相似度的准确性。如何在保持计算效率的同时提升推荐精度 ?
Swing 算法给出了一个优雅的答案——通过分析用户 - 物品交互的图结构特征来过滤噪音 , 更准确地捕捉物品间的真实关联
Swing 算法原理 ¶
当你在电商平台购买了一件 T 恤后,系统会向你推荐什么?如果是其他款式的 T 恤,这属于替代性推荐;如果是配套的裤子或配饰,这就是互补性推荐。传统 ItemCF 在处理这类细粒度的商品关系时往往力不从心——它将所有用户 - 物品交互一视同仁,无论是用户深思熟虑的购买,还是偶然的误点击、好奇心驱动的浏览,都被赋予相同的权重。这种做法不仅难以区分真正的兴趣关联和随机噪声,更无法有效捕捉物品间的替代性与互补性关系
Swing Score 提供了一个新的解题思路。它不再简单地统计共同购买次数,而是深入分析用户 - 物品二部图中的内部子结构,通过捕捉那些在用户行为中反复出现、具备较强“鲁棒性”的共同购买关系来度量物品相似性。其核心洞察是:如果多个用户在其他共同购买行为较少的情况下,同时购买了同一对物品,那么这对物品之间的关联性就更可信
基于这一思想 , Swing 算法能够构建更准确的产品索引 , 并在此基础上衍生出 Surprise 算法来专门处理互补商品推荐的挑战
Swing 算法的实现可以分为两个主要步骤 :
- 先构建用户 - 物品二部图
- 再通过分析图中用户对的共同交互模式 ( 即 swing 结构 ) 来计算物品相似度
第一步 : 用户 - 物品二部图构建
我们首先将用户与物品的交互数据转化为一个二部图。在这个图中 , 一边是所有用户节点 , 另一边是所有物品节点 ,。如果用户对某个物品发生了点击、购买等行为,就在对应的用户节点与物品节点之间添加一条边。这个二部图为后续的相似度计算提供了结构化的数据基础
第二步 : 物品相似度计算
构建好二部图后 , 我们来计算任意一对物品 \(i\) 与 \(j\) 的相似度。设 \(U_i\) 和 \(U_j\) 分别表示与物品 \(i\) 和 \(j\) 有过交互的用户集合 , \(I_u\) 表示用户 \(u\) 交互过的所有物品集合
具体计算过程如下 :
- 首先找到同时与物品 \(i\) 和 \(j\) 相连的用户集合 \(U_i \cap U_j\)
- 然后对集合中的每一对用户 \((u, v)\), 统计他们的其他共同购买行为
- 如果用户 \(u\) 与 \(v\) 的其他交互行为重合较少 ( 即 \(|I_u \cap I_v|\) 较小 ), 则认为他们在共同选择物品 \(i\) 和 \(j\) 上的行为更具特异性 , 应该为这对物品贡献更高的相似度得分
Swing score 的基础计算公式为 :
其中 , \(\alpha\) 是平滑系数 , 用来防止分母过小导致数值不稳定
让我们通过一个具体例子来理解这个公式。如下图所示 , 红色边连接的子图表示一个 swing 结构。用户 A 和 B 有四个 swing 结构 , 分别是 \([A, h, B]\)、\([A, t, B]\)、\([A, r, B]\)、\([A, p, B]\)
假设 \(\alpha = 1\), 那么 :
- 用户对 \([A, B]\) 的贡献分数为 : \(\frac{1}{1 + 4} = \frac{1}{5}\)
- 用户对 \([B, C]\) 的贡献分数为 : \(\frac{1}{1 + 2} = \frac{1}{3}\)
- 用户对 \([A, C]\) 的贡献分数为 : \(\frac{1}{1 + 2} = \frac{1}{3}\)
因此 , 物品 h 和 p 之间的 swing score 为 : \(s (h, p) = \frac{1}{5} + \frac{1}{3} + \frac{1}{3} = \frac{13}{15}\), 而物品 h 和 t ( 或 r) 之间的 swing score 为 : \(s (h, t) = s (h, r) = \frac{1}{5}\)
用户权重调整:为了降低活跃用户对计算结果的过度影响 , 我们引入用户权重 \(w_u = \frac{1}{\sqrt{|I_u|}}\)。最终的物品相似度计算公式为 :
这个公式能够精准刻画物品之间的关联强度 , 为推荐系统提供更可靠的相似度度量
Surprise 算法 : 互补商品推荐 ¶
虽然 Swing Score 已经能够有效捕捉物品间的关联关系,但在处理互补商品推荐时仍面临挑战。相比替代关系,互补关系具有明确的时序性和方向性——用户通常先购买主商品,再购买配套商品(如先买手机再买手机壳
Surprise 算法的设计基于一个重要观察:真正的互补关系主要发生在不同商品类别之间,且购买时间间隔越短,互补性越强。同时,为了解决用户共购数据稀疏的问题,该算法通过商品聚类将稀疏的商品级别共现信号聚合到聚类级别,从而获得更可靠的互补关系证据
Surprise 算法从类别、商品和聚类三个层面来衡量互补相关性:
类别层面
在这一层面 , 我们利用商品与类别之间的映射关系构建 user-category 矩阵。基于这个矩阵 , 可以计算类别 \(i\) 与类别 \(j\) 之间的相关性 :
其中 , \(N (c_{i, j})\) 表示先购买类别 \(i\) 后又购买类别 \(j\) 这一事件发生的次数 , \(N (c_j)\) 表示购买类别 \(j\) 的次数。考虑到不同类别的数量差异 , 我们采用最大相对落点作为划分阈值来筛选相关类别
例如 , 图 (a) 中 T 恤类选取了前八个相关类别 , 而图 (b) 中手机类选择了前三个相关类别
商品层面
针对类别相关的商品对 , Surprise 算法重点考虑两个因素 : 购买顺序的重要性 ( 如先买手机后买充电宝比反之更合理 ) 以及时间间隔的影响 ( 间隔越短 , 互补性越强 )。
商品层面的互补相关性计算公式为 :
其中 , \(t_{ui}\) 和 \(t_{uj}\) 分别表示用户 \(u\) 购买物品 \(i\) 和 \(j\) 的时间 , 且只有当 \(j\) 属于 \(i\) 的相关类别且购买时间晚于 \(i\) 时才计入计算
聚类层面
考虑到实际业务中商品数量可能达到数十亿的规模 , 传统聚类算法难以胜任。Surprise 算法采用标签传播算法在大规模 Item-Item 图上进行聚类 , 这里的邻居关系通过 Swing 算法计算 , 边权重即为 Swing 分数
这种方法不仅高效 ( 通常 15 分钟内可对数十亿商品完成聚类 ), 还能显著缓解数据稀疏问题。设 \(L (i)\) 表示商品 \(i\) 的聚类标签 , 则聚类层面的相似度可以表示为 :
线性组合
最终 , Surprise 算法采用线性组合的方式融合不同层面的信息 :
其中 , \(\omega\) 是权重超参数 , \(s_{2}(i, j)\) 代表聚类层面的互补相关性
通过这种多层次的综合分析 , Surprise 算法成功拓展了 Swing Score 的应用范围 , 为互补商品推荐提供了一套系统而高效的解决方案。该方法不仅利用了类别信息和标签传播技术有效解决了数据稀疏问题 , 还能够在保持高效的同时提升推荐的准确性和多样性
代码实践 ¶
首先 , 我们导入 funrec 库中的核心组件 :
Swing 算法的核心在于 Swing Score 的计算 , 它通过分析用户 - 物品二部图中的 swing 结构来度量物品相似度 :
def calculate_swing_similarity(item_users, user_items, user_weights, item_i, item_j, alpha=1.0):
"""
计算两个物品之间的Swing Score相似度
参考funrec.models.swing.Swing._calculate_swing_similarity_optimized()的核心逻辑
"""
# 找到同时与物品i和j交互的用户(共同用户)
common_users = item_users[item_i].intersection(item_users[item_j])
if len(common_users) < 2:
return 0.0 # 至少需要2个共同用户才能计算Swing score
swing_score = 0.0
common_users_list = list(common_users)
# 计算所有共同用户对的贡献
for u in common_users_list:
for v in common_users_list:
if u == v:
continue
# 找到用户u和v的共同交互物品
common_items_uv = user_items[u].intersection(user_items[v])
# 使用预计算的用户权重
user_weight_u = user_weights[u] # 1.0 / sqrt(|I_u|)
user_weight_v = user_weights[v] # 1.0 / sqrt(|I_v|)
# Swing Score核心公式
contribution = (user_weight_u * user_weight_v) / (alpha + len(common_items_uv))
swing_score += contribution
return swing_score
构建、训练和评估 Swing 算法 :
输出结果 :
+---------------+--------------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | precision@10 | precision@5 |
+===============+==============+================+===============+
| 0.6194 | 0.5042 | 0.1282 | 0.1629 |
+---------------+--------------+----------------+---------------+
1.2 UserCF¶
在网上购物时,我们经常会参考其他买家的选择和评价。如果你发现某个用户和你买了同样的 T 恤和裤子 , 而且都给了好评 , 那么当你看到这个用户还买了一双鞋子时 , 你可能也会考虑购买同款鞋子。这就是基于用户的协同过滤 (User-based Collaborative Filtering, UserCF) 的核心思想
UserCF 是早期提出的协同过滤方法之一 , 它的工作原理很直观:先找到与目标用户购买偏好相似的 " 邻居用户 ", 然后基于这些邻居对商品的选择来预测目标用户的兴趣。如图所示 , 如果用户 A 和用户 B 都购买了衣服和帽子 , 那么当用户 B 还购买了裤子和鞋子时 , 我们就可以推测用户 A 可能也会对这些商品感兴趣
UserCF 和前面介绍的 ItemCF 构成了协同过滤的两大基本方法,它们都属于基于邻域(Neighborhood-based)的协同过滤,核心思想是通过计算相似度来寻找邻居。不同之处在于视角的转换:ItemCF 从物品出发,关注“与用户喜欢的物品相似的还有什么”;UserCF 从用户出发,关注“与目标用户相似的人还喜欢什么”。虽然 ItemCF 在工业应用中更常见,但 UserCF 作为协同过滤的开山之作,其思想对后续方法有着深远影响,特别是其隐含的“用户可以用向量表示”的理念,为矩阵分解等方法奠定了基础。
UserCF 核心算法 ¶
UserCF 的实现过程可以分解为两个核心步骤:首先计算用户之间的相似度 , 找出与目标用户偏好最接近的邻居用户;然后基于这些邻居用户的历史行为来生成推荐列表。
第一步:用户相似度计算
要实现 UserCF, 首先需要回答一个关键问题:如何判断两个用户是否相似? 这就需要用到相似度计算。我们来看几种常用的方法
假设用户 \(u\) 和用户 \(v\) 分别对应物品集合 \(N (u)\) 和 \(N (v)\)(即他们各自有过行为的物品)
杰卡德相似系数:如果你的系统只记录用户是否对物品有过行为(比如是否点击、购买), 而没有具体的评分 , 那么杰卡德系数是个好选择:
这个公式可以直观理解为:分子是两人共同喜欢的物品数量 , 分母是两人喜欢的物品总数(去重后
余弦相似度:将每个用户看成一个向量 , 计算向量间的夹角:
余弦相似度考虑了用户活跃度的差异
皮尔逊相关系数:当你有具体的评分数据时(比如 5 星评分), 皮尔逊系数能更好地捕捉用户的偏好模式:
这里 \(r_{ui}\) 表示用户 \(u\) 对物品 \(i\) 的评分 , \(\bar{r}_u\) 是用户 \(u\) 的平均评分 , \(I\) 是两个用户都评价过的物品集合。皮尔逊系数的优势在于它能消除用户评分习惯的影响——有些人习惯打高分 , 有些人比较严格 , 通过减去均值可以让比较更公平
计算完相似度后 , 我们通常选择相似度最高的 K 个用户作为 " 邻居 ", 这个 K 值需要仔细调整——太小可能信息不足 , 太大可能引入噪音
第二步:候选物品推荐
有了相似用户 , 下一步就是利用他们的偏好来预测目标用户对未交互过的物品的兴趣。
简单加权平均:最直接的方法是用相似度作为权重 , 对邻居的评分进行加权平均:
这里 \(\hat{r}_{u, p}\) 是预测的用户 \(u\) 对物品 \(p\) 的评分 , \(S_u\) 是邻居用户集合 , \(w_{uv}\) 是相似度权重 , \(r_{v, p}\) 是邻居用户 \(v\) 对物品 \(p\) 的实际评分。
考虑评分偏置的版本:为了进一步消除个人评分习惯的影响 , 我们可以加入偏置修正:
这里 \(\bar{r}_u\) 和 \(\bar{r}_v\) 分别是用户 \(u\) 和 \(v\) 的平均评分。这个公式的思路是:先看邻居们对这个物品的评分相比他们平均水平高还是低 , 然后把这个 " 偏离度 " 加权平均后 , 加到目标用户的平均评分上
优化策略:让算法跑得更快
UserCF 看起来很简单 , 但有个大问题:当用户数量很大时 , 计算所有用户对之间的相似度会非常耗时 , 时间复杂度达到 \(O (|U|^2)\)
但仔细观察就会发现 , 很多用户对之间根本没有共同行为的物品 , 相似度必然为 0, 计算它们就是浪费时间。我们可以利用这个特点来优化算法
基于物品倒排表的优化:
- 构建倒排表:为每个物品维护一个用户列表 , 记录哪些用户对这个物品有过行为。这样就可以通过物品快速找到相关用户
- 稀疏矩阵计算:创建一个矩阵 \(C[u][v]\) 来记录用户 \(u\) 和 \(v\) 的共同物品数量。遍历每个物品的用户列表 , 将列表中的用户两两配对 , 对应的 \(C[u][v]\) 加 1
- 计算最终相似度:矩阵 \(C\) 给出了余弦相似度公式的分子 , 再除以分母 \(\sqrt{|N (u)||N (v)|}\) 就得到了用户相似度
- 线上召回:有了用户相似度矩阵 , UserCF 的推荐流程通常采用以下策略来提高效率:
首先 , 系统为目标用户找到最相似的 K 个用户作为 " 相似用户集合 "(通常 K=50-200
其中 \(S_u\) 是与用户 \(u\) 最相似的 K 个用户集合 , \(N (i)\) 是对物品 \(i\) 有过行为的用户集合 , \(w_{uv}\) 是用户相似度 , \(r_{vi}\) 是用户 \(v\) 对物品 \(i\) 的行为权重(如评分、点击次数等)
最终 , 系统对所有候选物品按兴趣分数排序 , 选择 Top-N 物品作为 UserCF 通道的推荐结果。这种 " 相似用户扩展 " 的方式既保证了推荐的个性化质量 , 又避免了对所有物品进行打分的巨大计算开销
这个优化的效果如何呢?新算法的时间复杂度约为 \(O (R \cdot \bar{n})\), 其中 \(R\) 是总的用户 - 物品交互记录数 , \(\bar{n}\) 是每个物品的平均交互用户数。在实际推荐系统中 , 用户 - 物品交互矩阵通常非常稀疏 , 这使得优化后的算法效率大大提升
应用实践 ¶
- 数据集
表格里是 5 个用户对 5 个物品的评分数据 , 可以理解为用户对物品的偏好程度
表:用户评分数据
| 物品 1 | 物品 2 | 物品 3 | 物品 4 | 物品 5 | |
|---|---|---|---|---|---|
| 用户 1 | 5 | 3 | 4 | 4 | ? |
| 用户 2 | 3 | 1 | 2 | 3 | 3 |
| 用户 3 | 4 | 3 | 4 | 3 | 5 |
| 用户 4 | 3 | 3 | 1 | 5 | 4 |
| 用户 5 | 1 | 5 | 5 | 2 | 1 |
- 手动分析
基于皮尔逊相关系数 , 计算用户 1 与其他用户(以用户 2 为例)的相似度
- 计算用户 1 与用户 2 的皮尔逊相关系数 : \(\bar{r}_{user1}=4,\ \bar{r}_{user2}=2.25\)。向量减去均值 : \(\text{user1}: (1,-1, 0,0) \quad \text{user2}: (0.75,-1.25,-0.25,0.75)\)
- 计算这俩新向量的余弦相似度 , 得到皮尔逊相似度 \(0.852\)
根据相似用户 , 计算用户 1 对物品 5 的最终得分。用户 2 对物品 5 的评分是 3, 用户 3 对物品 5 的打分是 5, 那么根据上面的计算公式 , 可以计算出用户 1 对物品 5 的最终得分是 :
代码实践¶
- 数据准备
import numpy as np
user_data = {
'user1': {'item1': 5, 'item2': 3, 'item3': 4, 'item4': 4},
'user2': {'item1': 3, 'item2': 1, 'item3': 2, 'item4': 3, 'item5': 3},
'user3': {'item1': 4, 'item2': 3, 'item3': 4, 'item4': 3, 'item5': 5},
'user4': {'item1': 3, 'item2': 3, 'item3': 1, 'item4': 5, 'item5': 4},
'user5': {'item1': 1, 'item2': 5, 'item3': 5, 'item4': 2, 'item5': 1},
}
这里使用字典来建立用户 - 物品的交互表 :
- 字典 users 的键表示不同用户的名字 , 值为一个评分字典 , 评分字典的键值对表示某物品被当前用户的评分
- 由于现实场景中 , 用户对物品的评分比较稀疏。如果直接使用矩阵进行存储 , 会存在大量空缺值 , 故此处使用了字典
- 基于皮尔逊相关系数 , 计算用户相似性矩阵
# 初始化相似性矩阵
import pandas as pd
similarity_matrix = pd.DataFrame(
np.identity(len(user_data)),
index=user_data.keys(),
columns=user_data.keys(),
)
# 遍历每条用户-物品评分数据
for u1, items1 in user_data.items():
for u2, items2 in user_data.items():
if u1 == u2:
continue
vec1, vec2 = [], []
for item, rating1 in items1.items():
rating2 = items2.get(item, -1)
if rating2 == -1:
continue
vec1.append(rating1)
vec2.append(rating2)
# 计算不同用户之间的皮尔逊相关系数
similarity_matrix[u1][u2] = np.corrcoef(vec1, vec2)[0][1]
print(similarity_matrix)
- 计算用户 1 最相似的 n 个用户
target_user = 'user1'
num = 2
# 由于最相似的用户为自己,去除本身
sim_users = similarity_matrix[target_user].sort_values(ascending=False)[1:num+1].index.tolist()
print(f'与用户{target_user}最相似的{num}个用户为:{sim_users}')
- 预测用户 1 对物品 5 的评分
weighted_scores = 0.
corr_values_sum = 0.
target_item = 'item5'
# 基于皮尔逊相关系数预测用户评分
for user in sim_users:
corr_value = similarity_matrix[target_user][user]
user_mean_rating = np.mean(list(user_data[user].values()))
weighted_scores += corr_value * (user_data[user][target_item] - user_mean_rating)
corr_values_sum += corr_value
target_user_mean_rating = np.mean(list(user_data[target_user].values()))
target_item_pred = target_user_mean_rating + weighted_scores / corr_values_sum
print(f'用户{target_user}对物品{target_item}的预测评分为:{target_item_pred:.4f}')
- 训练和评估模型
+---------------+--------------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | precision@10 | precision@5 |
+===============+==============+================+===============+
| 0.6912 | 0.5927 | 0.1643 | 0.2063 |
+---------------+--------------+----------------+---------------+
1.3 MF¶
UserCF 和 ItemCF 虽然思路直观、易于理解,但它们都面临一个根本性的挑战:数据稀疏性。在真实的推荐场景中,用户 - 物品交互矩阵往往是极度稀疏的——大部分用户只与极少数物品发生过交互。这导致两个问题:一是很难找到足够的共同评分来计算可靠的相似度;二是即使找到了相似用户或物品,他们的交互覆盖面也可能很有限。
更深层的问题在于,邻域方法将相似度计算和推荐预测分离开来,先显式地计算相似度,再基于相似度进行推荐。这种两阶段的方法虽然直观,但缺乏对用户 - 物品交互数据的全局优化。能否换一种思路——不再显式计算相似度,而是通过学习用户和物品的隐向量表示,让向量空间中的距离自然地反映相似性?矩阵分解正是这一思想的体现,它标志着协同过滤从统计方法向机器学习方法的转变
隐向量时代的开端 ¶
在推荐系统中 , 我们经常观察到这样的规律 : 喜欢《理智与情感》的用户往往也会给《公主日记》好评 , 而钟爱《致命武器》的观众通常也喜欢《独立日
回顾前面介绍的 UserCF 和 ItemCF 这些基于邻域的协同过滤方法 , 它们的思路很直观 : 通过寻找相似的用户或物品来进行推荐 , 就像图 2.1.6 展示的那样
图 2.1.6 基于用户行为统计的方法。张三喜欢左边的三部电影。为了对他进行预测 , 系统会找到也喜欢这些电影的相似用户 , 然后确定他们还喜欢哪些其他电影
但这种方法有个致命弱点 : 当评分数据非常稀疏时 , 很难找到足够的相似用户或物品。想想看 , 在一个有百万用户和十万电影的系统中 , 大部分用户可能只看过几十部电影 , 想要找到看过相同电影的相似用户变得非常困难
这时候 , 矩阵分解登场了。它不再直接寻找相似性 , 而是换了个思路 : 假设用户的偏好和电影的特征都可以用几个关键因子来描述。比如 , 我们可以用 " 动作 vs 浪漫 "、" 严肃 vs 娱乐 " 这样的隐含维度来刻画电影 , 同时也用这些维度来描述用户的偏好。这样一来 , 即使两个用户没有看过完全相同的电影 , 只要他们在这些隐含维度上表现相似 , 我们就能推断他们可能喜欢类似的内容
矩阵分解的核心想法建立在两个关键假设上 :
- 第一个是低秩假设 : 虽然评分矩阵看起来很复杂 , 但实际上可能只受少数几个隐含因素影响 , 比如 " 面向男性 vs 面向女性 "、" 严肃 vs 逃避现实 " 等。这些因素的数量远小于用户和物品的数量。
- 第二个是隐向量假设 : 每个用户和每部电影都能用一个包含这些隐含因子的向量来表示 , 用户向量反映了其对各种因子的偏好程度 , 而电影向量则描述了它在这些因子上的特征强度。
这种做法的好处是显而易见的 : 即使两个用户没有看过相同的电影 , 只要他们在隐含因子上表现相似 , 我们就能为他们推荐相似的内容。这大大提高了模型处理稀疏数据的能力
图 2.1.7 隐语义模型意图 , 该方法通过两个维度来刻画用户和电影 : 一个是面向男性与面向女性 , 另一个是严肃与逃避现实
接下来我们看看如何把这个想法变成具体的算法。我们将介绍两种矩阵分解模型:简单直接的基础模型(FunkSVD)和考虑评分偏差的改进模型(BiasSVD)
FunkSVD: 基础模型 ¶
FunkSVD 由 Simon Funk 在 2006 年提出 , 是矩阵分解家族中最容易理解的一个。它的想法非常直接 : 把复杂的用户 - 物品评分矩阵分解成两个简单的矩阵——用户特征矩阵和物品特征矩阵 , 然后通过这两个矩阵的乘积来预测未知的评分
假设我们有 \(m\) 个用户和 \(n\) 个物品 , 想要用 \(K\) 个隐含因子来描述它们。那么用户 \(u\) 可以用一个 \(K\) 维向量 \(p_u\) 来表示 , 物品 \(i\) 也用一个 \(K\) 维向量 \(q_i\) 来表示。用户 \(u\) 对物品 \(i\) 的预测评分就是这两个向量的内积 :
这里 \(p_{u, k}\) 表示用户 \(u\) 在第 \(k\) 个隐含因子上的偏好程度 , \(q_{i, k}\) 表示物品 \(i\) 在第 \(k\) 个隐含因子上的特征强度
现在问题变成了 : 如何找到这些隐含因子 ? 我们采用一个很自然的思路——让预测评分尽可能接近真实评分。具体来说 , 我们要最小化所有已知评分的预测误差平方和 :
这里 \(\mathcal{K}\) 表示所有已知评分的用户 - 物品对 , \(r_{ui}\) 是用户 \(u\) 对物品 \(i\) 的真实评分
要解决这个优化问题 , 我们使用梯度下降法。对于每个观测到的评分 , 我们先计算预测误差 \(e_{ui} = r_{ui} - p_u^T q_i\), 然后沿着误差减小的方向更新参数 :
其中 \(\eta\) 是学习率 , 控制每次更新的步长。这个更新规则的直觉很简单 : 如果预测评分偏低了 (\(e_{ui} > 0\)), 我们就增大相关的参数值 ; 如果预测评分偏高了 (\(e_{ui} < 0\)), 就减小参数值。通过不断迭代 , 模型会逐渐学会准确预测用户评分
不过在实际应用中 , 我们通常还会加入 L2 正则化来防止过拟合 :
这样可以避免模型过度拟合训练数据 , 提高在新数据上的表现
核心代码
FunkSVD 的核心在于学习用户和物品的隐向量表示。在实现中 , 我们使用 Embedding 层来表示这些隐向量 , 并通过内积计算预测评分 :
# 用户的隐向量表示
user_factors = Embedding(
user_vocab_size,
embedding_dim,
embeddings_initializer="normal",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="user_factors",
)(user_id_input)
user_factors = Flatten()(user_factors)
# 物品的隐向量表示
item_factors = Embedding(
item_vocab_size,
embedding_dim,
embeddings_initializer="normal",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="item_factors",
)(item_id_input)
item_factors = Flatten()(item_factors)
# 预测:计算用户向量和物品向量的内积
prediction = Dot(axes=1)([user_factors, item_factors])
这里的 embedding_dim 对应公式中的隐含因子数量 \(K\)。user_factors 对应 \(p_u\), item_factors对应 \(q_i\), 而 Dot 操作计算它们的内积得到预测评分 \(\hat{r}_{ui}\)。
训练和评估
FunkSVD 在 MovieLens 数据集上的应用
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0002 | 0 | 0.0001 | 0 | 0 | 0 |
+---------------+--------------+-----------+----------+----------------+---------------+
BiasSVD: 改进模型 ¶
基础模型虽然简洁 , 但在实际使用中我们发现了一个问题 : 不同用户的评分习惯差异很大。有些用户天生就是 " 好人 ", 很少给低分 ; 有些用户则比较严格 , 平均评分偏低。同样 , 有些电影本身就很受欢迎 , 大家都给高分 ; 有些电影则口碑一般。这些系统性的偏差与具体的用户 - 物品交互模式无关 , 更多是反映了个体的评分习惯和物品的整体质量。
这些系统性的偏差如果不处理 , 会影响推荐的准确性。BiasSVD 正是为了解决这个问题而提出的。它在基础模型的基础上引入了偏置项 , 让预测公式变成 :
这里新增了三个项 : \(\mu\) 是所有评分的全局平均值 , 反映了整个系统的评分水平 ; \(b_u\) 是用户 \(u\) 的个人偏置 , 反映了该用户相对于平均水平的评分倾向 ; \(b_i\) 是物品 \(i\) 的偏置 , 反映了该物品相对于平均水平受欢迎的程度。最后的 \(p_u^T q_i\) 才是真正体现用户和物品之间个性化交互的部分。
相应地 , 优化目标也要调整 :
在参数更新时 , 除了用户和物品的隐向量 , 我们还需要更新偏置项 :
这种改进看似简单 , 但效果显著。通过分离出系统性偏差 , 模型能够更准确地捕捉用户和物品之间的真实交互模式 , 从而提供更精准的推荐。
核心代码
BiasSVD 在 FunkSVD 的基础上增加了偏置项。在实现中 , 我们为用户和物品分别添加偏置 Embedding, 并在预测时将所有项相加 :
# 用户的隐向量表示 + 用户偏置
user_factors = Embedding(
user_vocab_size, embedding_dim,
embeddings_initializer="normal",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="user_factors",
)(user_id_input)
user_factors = Flatten()(user_factors)
user_bias = Embedding(
user_vocab_size, 1,
embeddings_initializer="zeros",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="user_bias",
)(user_id_input)
user_bias = Flatten()(user_bias)
# 物品的隐向量表示 + 物品偏置
item_factors = Embedding(
item_vocab_size, embedding_dim,
embeddings_initializer="normal",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="item_factors",
)(item_id_input)
item_factors = Flatten()(item_factors)
item_bias = Embedding(
item_vocab_size, 1,
embeddings_initializer="zeros",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="item_bias",
)(item_id_input)
item_bias = Flatten()(item_bias)
# 计算各个部分
interaction = Dot(axes=1)([user_factors, item_factors]) # 交互项
ones_input = tf.keras.layers.Lambda(lambda x: tf.ones_like(x))(interaction)
global_bias = Dense(1, use_bias=True, kernel_initializer="zeros",
name="global_bias")(ones_input) # 全局偏置
# BiasSVD 预测:global_bias + user_bias + item_bias + interaction
prediction = Add()([global_bias, user_bias, item_bias, interaction])
与 FunkSVD 相比 , BiasSVD 的关键区别在于 :
- 用户和物品都增加了独立的偏置 Embedding (
user_bias和item_bias) - 添加了全局偏置项 (
global_bias), 对应公式中的 \(\mu\) - 最终预测使用
Add层将四个部分相加 : \(\mu + b_u + b_i + p_u^T q_i\)
偏置项的初始化使用"zeros" , 因为它们代表的是相对于平均水平的偏移。这种设计使得模型能够自动学习不同用户的打分习惯和不同物品的受欢迎程度。
训练和评估
BiasSVD 在 MovieLens 数据集上的应用
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0081 | 0.0065 | 0.0037 | 0.0032 | 0.0008 | 0.0013 |
+---------------+--------------+-----------+----------+----------------+---------------+
Summary¶
ItemCF 的实践价值:我们深入分析了 ItemCF 在工业界广泛应用的原因。除了计算效率和稳定性优势外,ItemCF 还具有很好的可解释性——用户能够理解“因为你喜欢 A,所以推荐 B”的逻辑。Swing 算法的引入进一步提升了 ItemCF 的效果,通过二部图分析有效降低了热门物品对相似度计算的干扰,这种基于图结构的优化思路在后续的图神经网络方法中得到了进一步发展
UserCF 的理论意义:虽然 UserCF 在大规模系统中应用受限,但其蕴含的“集体智慧”理念具有深远影响。UserCF 能够发现用户的跨领域兴趣,这种探索性推荐的思想后来在深度学习模型中通过注意力机制和序列建模得到了更好的实现。UserCF 面临的挑战也推动了推荐系统向更精细化的用户建模方向发展
矩阵分解的范式转变:矩阵分解不仅解决了传统协同过滤的稀疏性问题,更重要的是引入了向量化表示的概念。这种将用户和物品映射到连续向量空间的思想,为整个推荐系统领域开辟了新的技术路径。从 FunkSVD 到 BiasSVD 的演进,展现了如何通过模型改进来处理实际数据中的偏置问题
协同过滤,特别是矩阵分解,在推荐系统发展史上具有承上启下的重要地位。它承接了早期基于规则和统计的方法,启发了后续基于深度学习的现代推荐技术。无论是双塔模型中的用户物品向量化,还是序列推荐中的 embedding 技术,都可以追溯到矩阵分解的核心思想。这种技术传承体现了推荐系统领域知识积累和创新发展的连续性
02 Embedding¶
在前一章中,我们学习了协同过滤技术,特别是矩阵分解。该方法的核心思想是通过学习用户和物品的隐向量来预测偏好,这本质上是将离散的 ID 映射为连续的向量。这种向量化表示是本章将要讨论的向量召回技术的基础
然而,矩阵分解仍然面临一些局限:它是一个相对简单的线性模型,通过用户向量和物品向量的内积来预测评分,表达能力有限;它主要依赖用户 - 物品交互矩阵,难以融入更多的特征信息(如用户画像、物品属性、上下文信息等
为了突破这些局限,向量召回技术应运而生。它继承并发展了矩阵分解的向量化思想,但将其推向了更深、更广的维度。其核心洞察是:既然向量表示如此强大,为什么不用更复杂的模型(如深度神经网络)来学习向量?为什么不融入更多的特征信息?为什么不借鉴其他领域(如自然语言处理)的成功经验?
这种思想的灵感尤其来自 NLP 领域的嵌入(Embedding)技术。Word2Vec 通过分析大量文本中词语的共现关系,能够为每个词学习一个稠密的向量表示,使得语义相近的词在向量空间中距离更近。这种嵌入技术的核心价值在于,它能够将离散的符号映射到连续的向量空间中,让“距离”具有了语义意义。向量召回正是将这一思想与矩阵分解的理念相结合,将用户和物品都映射到同一个向量空间中,让“距离”代表“相似度”
在向量空间中,推荐问题得到了根本性的简化。原本需要遍历巨大交互矩阵的召回过程,转变为在高维向量空间中根据一个“查询”向量快速搜索出距离最近的 K 个物品向量。这种转变不仅大幅提升了计算效率,还通过向量的表示能力捕捉到了更深层次的语义相似性
向量召回技术主要沿着两条路径发展。I2I(Item-to-Item)召回(2.2.1 节)专注于计算物品与物品之间的相似性。U2I(User-to-Item)召回(2.2.2 节)则直接匹配用户与物品
接下来,我们将深入探讨这两大技术路径的演进历程,看看工业界是如何在实践中不断完善和创新这些核心思想的
2.1 I2I Retrieval¶
在推荐系统中,I2I(Item-to-Item)召回是一个核心任务:给定一个物品,如何快速找出与之相似的其他物品?这个看似简单的问题,实际上蕴含着深刻的洞察——“相似性”并非仅仅由物品的内在属性决定,而是与用户的行为所共同定义的。如果两个商品经常被同一批用户购买,两部电影被同一群观众喜欢,那么它们之间就可能存在某种关联
这种思想的灵感来源于自然语言处理领域的一个重要发现。在语言学中,有一个著名的分布假说 ( Firth, 1957 )
接下来,我们将看到所有 I2I 召回方法的本质都是在回答同一个问题:如何更好地定义和利用“序列”来学习物品之间的相似性。从最直接的用户行为序列,到融合属性信息的增强序列,再到面向业务目标的会话序列,每一种方法都是对“序列”概念的不同诠释和深化
Word2Vec: 序列建模的理论基础 ¶
Word2Vec (Mikolov et al., 2013) 的成功建立在一个简单而深刻的假设之上 : 在相似语境中出现的词语往往具有相似的含义。通过分析海量文本中词语的共现模式 , 我们可以为每个词学习到一个稠密的向量表示 , 使得语义相似的词在向量空间中彼此接近。
Word2Vec 主要包含两种模型架构 :Skip-Gram和CBOW(Continuous Bag of Words)。Skip-Gram 模型通过给定的中心词来预测其周围的上下文词 , 而 CBOW 模型则相反 , 通过上下文词来预测中心词。在推荐系统中 , Skip-Gram 模型由于其更好的表示能力而被更广泛地采用
Skip-Gram 模型详解 ¶

在 Skip-Gram 模型中 , 给定文本序列中位置 \(t\) 的中心词 \(w_t\), 模型的目标是最大化其预测周围上下文词的概率
中心词 \(w_t\) 预测上下文词 \(w_{t+j}\) 的条件概率定义为 :
其中 \(v_{w_i}\) 表示词 \(w_i\) 的向量表示 , \(|V|\) 是词汇表大小
负采样优化 ¶
直接计算上述 softmax 的分母需要遍历整个词汇表 , 在实际应用中计算代价过高。为了解决这个问题 , Word2Vec 采用了负采样 (Negative Sampling) 技术。这种方法将原本的多分类问题转化为多个二分类问题 :
其中 \(\sigma (x) = \frac{1}{1 + e^{-x}}\) 是 sigmoid 函数 , \(k\) 是负样本数量 , \(P_n (w)\) 是负样本的采样分布。负采样的直观解释是:对于真实的词对,我们希望增加它们的相似度;对于随机采样的负样本词对,我们希望降低它们的相似度
这种优化策略不仅大幅提升了训练效率 , 还为后续推荐系统中的模型训练提供了重要的技术范式。当我们将这一思想迁移到推荐领域时 ," 词语 " 变成了 " 物品 "," 句子 " 变成了 " 用户行为序列 "
todo 负采样优化的具体方式再看一眼
Item2Vec: 最直接的迁移 ¶
Word2Vec 在自然语言处理领域的成功 , 自然引发了一个问题 : 能否将这种基于序列的学习方法直接应用到推荐系统中 ? Item2Vec 给出了肯定的答案
从词语到物品的映射 ¶
Item2Vec (Barkan & Koenigstein, 2016) 的核心洞察在于发现了用户行为数据与文本数据的结构相似性。在文本中 , 一个句子由多个词语组成 , 词语之间的共现关系反映了语义相似性。类似地 , 在推荐系统中 , 用户的交互历史本质上也是一个序列 : 用户依次点击、购买或评价的物品形成了一条行为链。同一个用户交互过的物品往往具有某种相关性——它们可能属于同一品类 , 满足相似的需求 , 或者风格接近
这种映射关系可以表示为 :
- 词语 → 物品
- 句子 → 用户交互序列
- 词语共现 → 物品共同被用户交互
模型实现 ¶
Item2Vec 直接采用 Word2Vec 的 Skip-Gram 架构 , 但在序列构建上有所简化。给定数据集 \(\mathcal{S} = \{s_1, s_2, \ldots, s_n\}\), 其中每个 \(s_i = (l_1, l_2, \ldots, l_{|s_i|})\) 是一个物品序列 ( 即包含用户 \(i\) 所交互过的物品 )
Item2Vec 将每个用户的交互历史视为一个集合而非序列,忽略了交互的时间顺序
优化目标函数与 Word2Vec 保持一致 :
其中 \(l_i\) 表示物品 , \(m\) 是上下文窗口大小 , \(P (l_{i+j} | l_i)\) 表示给定中心物品 \(l_i\) 预测上下文物品 \(l_{i+j}\) 的概率。
核心代码
Item2Vec 的实现可以直接调用 gensim 库 (Řehůřek & Sojka, 2010) 的 Word2Vec 模型。核心在于将用户交互序列作为训练语料 :
def fit(self, train_hist_movie_id_list):
# train_hist_movie_id_list: 用户交互序列列表
# 每个元素是一个用户的物品ID序列
self.model = Word2Vec(
train_hist_movie_id_list,
vector_size=self.model_config["embedding_dim"],
window=self.model_config["window_size"],
min_count=self.model_config["min_count"],
workers=self.model_config["workers"],
)
这里的 train_hist_movie_id_list 就是前面提到的数据集 \(\mathcal{S}\), 每个元素是一个用户交互过的物品 ID 序列。
训练和评估
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0066 | 0.0033 | 0.0025 | 0.0019 | 0.0007 | 0.0007 |
+---------------+--------------+-----------+----------+----------------+---------------+
EGES: 用属性信息增强序列 ¶
Item2Vec 虽然验证了序列建模在推荐系统中的可行性 , 但其简单的设计也带来了明显的局限性。首先 , 将用户交互历史简单视为无序集合 , 忽略了时间顺序和用户意图的变化。其次 , 对于交互次数很少的长尾物品或新上架的商品, 由于缺乏足够的共现数据 , 难以学到高质量的向量表示 , 这在实际的电商推荐系统中是一个严重的问题
EGES (Enhanced Graph Embedding with Side Information,2018) 正是为了解决这些核心挑战而提出的。该方法通过两个关键创新来改进传统的序列建模 :
- 一是基于会话构建更精细的商品关系图来更好地反映用户行为的时序性和因果关系
- 二是引入商品的辅助信息 ( 如类别、品牌、价格等 ) 来增强向量表示 , 从而有效缓解数据稀疏性问题 👉 冷启动
构建商品关系图 ¶
EGES 的第一个创新是将物品序列的概念从简单的用户交互扩展为更精细的会话级序列。考虑到用户行为的复杂性和计算效率 , 研究者设置了一小时作为会话的时间窗口 : 如果用户连续两次行为的时间间隔超过一小时 , 就将它们划分到不同的会话中。这种基于会话的切分能够更准确地捕捉用户在特定时间段内的真实意图和兴趣变化
具体构建过程如图所示:当两个商品在同一会话(时间窗口内)的用户行为序列中连续出现时,在它们之间建立一条有向边,边的权重等于这种商品转移模式在所有用户行为历史中出现的频率。相比于传统方法将整个用户历史视为一个序列,这种基于会话的图构建方法能够更准确地捕捉用户在特定时间段内的连续兴趣转移模式
- ※ 会话窗口的概念
在构建好的商品图上 , EGES 采用带权随机游走策略生成训练序列。从一个节点出发 , 转移概率由边权重决定 :
$\(P(v_j|v_i) = \begin{cases} \frac{M_{ij}}{\sum_{j=1}^{|N_+(v_i)|}M_{ij}} & \text{if } v_j \in N_+(v_i) \\ 0 & \text{if } e_{ij} \notin E \end{cases} \tag{2.2.4}\)$ 其中 \(M_{ij}\) 表示节点 \(v_i\) 到节点 \(v_j\) 的边权重, \(N_+(v_i)\) 表示节点 \(v_i\) 的出邻居集合 - 通过这种随机游走过程,可以生成大量的商品序列用于后续的 Embedding 学习
融合辅助信息解决稀疏性问题 ¶
基于上述商品图和随机游走策略 , 我们可以采用类似 Word2Vec 的方法学习商品的向量表示。然而 , 这种纯粹基于行为序列的方法面临一个关键挑战 : 对于交互次数很少的长尾商品或新上架的商品 , 由于缺乏足够的共现数据 , 难以学习到高质量的表示。
为了解决这种稀疏性问题 , EGES 方法的第二个创新是引入商品的辅助信息 ( 如类别、品牌、价格区间等 ) 来增强商品的向量表示
GES 的核心思想是将商品本身的 Embedding 与其各种属性的 Embedding 进行平均聚合 : $\(H_v=\frac{1}{n+1} \sum_{s=0}^n{W_v^s} \tag{2.2.5}\)$
其中 \(W_v^s\) 表示商品 \(v\) 的第 \(s\) 种属性的 Embedding 向量 (\(s=0\) 表示商品 ID 本身 , \(s=1,2,\ldots, n\) 表示 \(n\) 种辅助信息 )。
EGES 的核心创新在于认识到不同类型的辅助信息应该有不同的重要性。对于手机 , 品牌可能比价格更重要 ; 对于日用品 , 价格可能比品牌更重要。更进一步 , 即使是同一类型的信息 , 对不同商品的重要性也不相同
对于具有 \(n\) 种辅助信息的商品 \(v\), EGES 为其维护 \(n+1\) 个向量 ( 一个商品 ID 向量和 \(n\) 个属性向量 ), 并通过可学习的注意力权重来自适应地聚合这些向量 :
其中 \(a_v^j\) 是可学习的权重参数。这种设计的精妙之处在于 , 不同类型的辅助信息对不同商品的重要性是不同的 , 而这些权重会在训练过程中自动学习
核心代码
EGES 的核心在于商品特定注意力层 (ItemSpecificAttentionLayer), 它为每个商品学习一组特征权重 :
def call(self, inputs, item_indices):
"""
参数:
inputs: 特征嵌入 [batch_size, n+1, emb_dim]
item_indices: 商品索引 [batch_size]
"""
# 获取每个商品对应的权重参数 a_v^j
batch_attention_weights = tf.gather(self.attention_weights, item_indices)
# 计算 e^(a_v^j)
exp_attention = tf.exp(batch_attention_weights)
# 归一化权重: e^(a_v^j) / sum(e^(a_v^j))
attention_sum = tf.reduce_sum(exp_attention, axis=1, keepdims=True)
normalized_attention = exp_attention / attention_sum
# 应用权重到特征嵌入
normalized_attention = tf.expand_dims(normalized_attention, -1)
weighted_embedding = inputs * normalized_attention # [batch_size, n+1, emb_dim]
# 求和得到最终的商品表示 H_v
output = tf.reduce_sum(weighted_embedding, axis=1)
return output, normalized_attention
这里的 attention_weights 是一个形状为 \(|V| \times (n+1)\) 的参数矩阵 , 其中 \(|V|\) 是商品数量 , \(n+1\) 表示特征数量 ( 商品 ID + \(n\) 种辅助信息 )。对于每个商品 , 模型会学习到一组特定的权重 , 自动发现哪些特征对该商品更重要。这种设计使得模型能够为不同商品赋予不同的特征重要性。
冷启动商品的处理: 对于新上架且没有任何用户交互历史的商品 , EGES 提供了有效的冷启动解决方案。由于这类商品缺乏行为数据 , 无法在商品图中通过随机游走生成训练样本 , 也就无法学习商品 ID 对应的 Embedding 向量。
在这种情况下 , 系统采用简单而有���的 mean pooling 策略 : 直接对该商品的所有辅助信息向量 ( 类别、品牌、价格区间等 ) 进行平均聚合来构建商品表示。虽然这种方法无法体现不同属性的差异化重要性 , 但它依然能够利用商品的固有属性为其生成合理的初始表示。
训练优化 ¶
EGES 采用与 Word2Vec 类似的负采样策略 , 但损失函数经过了优化 : $\(L(v,u,y) = -[y\log(\sigma(H_v^TZ_u)) + (1-y)\log(\sigma(-H_v^TZ_u))] \tag{2.2.7}\)$
其中 \(y\) 是标签 (1 表示正样本 , 0 表示负样本 ), \(H_v\) 是商品 \(v\) 的向量表示 , \(Z_u\) 是上下文商品 \(u\) 的向量表示
通过这种方式 , 即使是刚上架、没有任何用户交互的新商品 , 也能通过其属性信息获得有意义的向量表示 , 从而被纳入推荐候选集
EGES 在淘宝的实际部署效果显著 : 在包含十亿级训练样本的大规模数据集上 , 相比传统方法在推荐准确率上有了显著的提升 , 同时有效解决了新商品冷启动问题
训练和评估
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0136 | 0.0061 | 0.0064 | 0.0038 | 0.0014 | 0.0012 |
+---------------+--------------+-----------+----------+----------------+---------------+
Airbnb: 将业务目标融入序列 ¶
Airbnb 作为全球最大的短租平台 , 面临着与传统电商不同的挑战。房源不是标准化商品 , 用户的预订行为远比点击浏览稀疏 , 而且地理位置成为了一个至关重要的因素。这些特殊性促使 Airbnb 对序列建模方法进行了深度定制
面向业务的序列构建 ¶
Airbnb 重新定义了 " 序列 " 的概念 (Grbovic & Cheng, 2018), 使其更贴近实际业务场景 :
会话切分机制: 系统不再简单地将用户交互过的所有房源串联 , 而是基于用户的点击会话 (Click Sessions) 构建序列。当用户连续点击间隔超过 30 分钟时 , 系统会自动开始一个新的会话。这种时间窗口的设计能够更准确地捕捉用户在特定搜索场景下的真实意图和偏好演变。
行为权重差异化: Airbnb 引入了重要的业务洞察——用户行为的信号强度存在显著差异。最终的预订行为相比于简单的点击浏览 , 包含了更强的相关性信号。一个用户浏览了 20 个房源但最终预订了其中一个 , 这个预订房源与其他被浏览房源之间的关系 , 应该被赋予更高的学习权重
全局上下文机制 ¶
为了强化模型对最终转化行为的学习,Airbnb 设计了全局上下文机制。在传统的 Skip-Gram 模型中,只有在滑动窗口内的物品才被视为上下文,但这种局部窗口无法充分利用最终预订这一强烈的正向信号。因此,Airbnb 让用户最终预订的房源(booked listing)与序列中的每一个浏览房源都形成正样本对进行训练,无论它们在序列中的距离有多远 - C-DLM 的训练也用了类似的技巧,让 final result 与中间的结果计算损失
针对有预订行为的会话 (booked sessions), Airbnb 修改了优化目标函数,增加了全局上下文项: $\(\mathrm{argmax}\sum_\theta\log\frac1{1+e^{-v_c^Tv_l}}+\sum_{(l,c)\in\mathcal{D}_n}\log\frac1{1+e^{v_c^Tv_l}}+\log\frac1{1+e^{-v_{l_b}^Tv_l}}\)$
在这个公式中,前两项是标准的 Skip-Gram 目标函数:第一项最大化正样本对 \((l, c)\) 的相似度,其中 \(l\) 是目标房源,\(c\) 是滑动窗口内的上下文房源;第二项最小化负样本对的相似度。关键的创新在于第三项 \(\log\frac1{1+e^{-v_h^Tv_l}}\), 这里 \(l_b\) 表示用户在该会话中最终预订的房源
通过这种全局上下文机制,预订房源为序列中的每个房源都提供了额外的学习信号,使得模型能够更有效地捕捉 " 什么样的房源组合最终会导致预订”这一关键转化模式
市场感知的负采样 ¶
Airbnb 的另一个创新是改进了负采样策略。传统方法从整个物品库中随机选择负样本,但 Airbnb 观察到用户通常只会在同一个市场(城市或地区)内进行预订。如果负样本来自不同的地理位置,模型就容易学到地理位置这种“简单特征”,而忽略了房源本身的特点。
因此,Airbnb 增加了“同市场负采样”策略,一部分负样本从与正样本相同的地理市场中选择:
其中 \(l_m^-\) 表示来自相同市场的负样本。这迫使模型学习同一地区内房源的细微差别 , 提升了推荐的精细度
2.2 U2I Retrieval¶
在完成了 I2I 召回的探讨后 , 我们转向另一条同样重要的技术路径 : U2I ( 用户到物品 ) 召回。如果说 I2I 召回解决的是 " 买了这个商品的人还会买什么 " 的问题 , 那么 U2I 召回直面的则是推荐系统的核心命题——" 这个用户会喜欢什么商品 "
U2I 召回的核心挑战在于如何在庞大的物品库中 , 快速找到与用户兴趣高度匹配的候选集。传统的协同过滤方法虽然有效 , 但在面对数亿用户和数千万物品的大规模场景时 , 计算复杂度和实时性成为难以逾越的瓶颈
这一转变的关键突破来自于一个统一的架构思想 :双塔模型 (Two-Tower Model)。无论是经典的因子分解机 FM、深度结构化语义模型 DSSM, 还是 YouTube 的深度神经网络 YouTubeDNN, 它们在表面上看起来差异巨大 , 但在本质上都遵循同一设计哲学
双塔模型的核心思想是将推荐问题分解为两个相对独立的子问题 - 用户塔 (User Tower) 专注于理解用户——处理用户的历史行为、人口统计学特征、上下文信息等 , 最终输出一个代表用户兴趣的向量 \(u\) - 物品塔 (Item Tower) 则专精于刻画物品——整合物品的 ID、类别、属性、内容特征等 , 输出一个表征物品特性的向量 \(v\)
这种 " 分而治之 " 的设计带来了巨大的工程优势。在训练完成后 , 所有物品的向量都可以离线预计算并存储在高效的向量检索系统中 ( 如 Faiss、Milvus 等 )。线上服务时 , 只需实时计算用户向量 , 然后通过近似最近邻 (ANN) 搜索 , 在毫秒级别内从数千万物品中返回 Top-K 候选
用户与物品的匹配度通过两个向量的点积或余弦相似度来衡量 : $\(score(u, v) = u \cdot v = \sum_{i=1}^{d} u_i v_i\)$
其中 \(d\) 是向量维度 , \(u_i\) 和 \(v_i\) 是向量 \(u\) 和 \(v\) 的第 \(i\) 个分量。这个简单的数学操作背后 , 蕴含着 " 语义相似性 " 的深刻含义——向量空间中的距离反映了用户兴趣与物品特性的契合程度
接下来 , 我们将沿着双塔模型的演进轨迹 , 从经典的数学基础到现代的深度学习实现 , 逐一探讨这些里程碑式的工作
FM ( 因子分解机 ): 双塔模型的雏形 ¶
虽然因子分解机 (Factorization Machine, FM) 诞生于深度学习兴起之前 , 但它在思想上可以说是双塔模型的雏形。FM 的核心贡献在于 , 它首次将用户 - 物品的复杂交互 , 优雅地分解为两个低维向量的内积形式 , 为后续双塔架构的发展奠定了数学基础
从交互矩阵到向量内积 ¶
FM 模型的完整数学表达式为 : $\(\hat{y}(\mathbf{x}):=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+\sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j}\)$ 这个公式看起来复杂, 但其核心思想简单而深刻: 每个特征 \(i\) 都对应一个 \(k\) 维的隐向量 \(\mathbf{v}_i\), 特征间的交互通过这些隐向量的内积来建模
FM 的真正巧妙之处在于一个数学变换技巧。原本 \(O (n^2)\) 复杂度的二阶交互项 , 可以通过代数运算重写为 : $\(\sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j} = \frac{1}{2} \sum_{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)\)$
这一变换将计算复杂度从 \(O (kn^2)\) 降低到 \(O (kn)\), 使得 FM 能够处理大规模稀疏数据
分解为双塔结构 ¶
虽然 FM 通过数学变换解决了计算复杂度问题 , 但在召回任务中 , 我们还面临另一个挑战 : 如何高效地为用户从海量候选物品中筛选出最相关的推荐结果
在召回场景下 , 我们可以将所有特征自然地分为两类 : 用户侧特征集 \(U\) ( 如用户年龄、性别、历史偏好等 ) 和物品侧特征集 \(I\) ( 如物品类别、价格、品牌等 )
这里有一个关键的发现 : 当我们为同一个用户推荐不同物品时 , 用户特征是固定不变的。因此 , 用户特征内部的交互得分 ( 无论是一阶还是二阶 ) 对所有候选物品都是相同的常数。在 Retrieval 阶段 , 我们只需要关注两类真正影响排序的项 : 1. 物品特征内部的交互得分 2. 用户特征与物品特征之间的交互得分
基于这个思路 , 我们可以将 FM 的二阶交互项重新组织 : $\(\begin{aligned} & \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f} x_{i}\right)^{2}-\sum_{i=1}^{n} v_{i, f}^{2} x_{i}^{2}\right) \\ =& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{u \in U} v_{u, f} x_{u} + \sum_{t \in I} v_{t, f} x_{t}\right)^{2}-\sum_{u \in U} v_{u, f}^{2} x_{u}^{2} - \sum_{t\in I} v_{t, f}^{2} x_{t}^{2}\right) \\ =& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{u \in U} v_{u, f} x_{u}\right)^{2} + \left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} + 2{\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}}-\sum_{u \in U} v_{u, f}^{2} x_{u}^{2} - \sum_{t\in I} v_{t, f}^{2} x_{t}^{2}\right) \end{aligned}\)$
基于前面的分析 , 我们发现用户特征内部的交互项对所有候选物品都相同 , 因此可以在召回阶段忽略。这样就可以将 FM 重新组织 , 只保留对排序有影响的项 : $\(\text{score}_{FM} = \sum_{t \in I} w_{t} x_{t} + \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right) + \sum_{f=1}^{k}\left( {\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} \right)\)$
观察上面的公式 , 我们发现一个重要的数学结构 : 最后一项 \(\sum_{f=1}^{k}\left ( {\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} \right)\) 实际上是两个向量 \(\sum_{u \in U} v_{u} x_{u}\) 和 \(\sum_{t \in I} v_{t} x_{t}\) 的内积 ! $\(\text{score}_{FM} = V_{item} \cdot V_{user}^T\)$
通过这种重新组织 , 我们得到了 FM 的双塔表示 :
- 用户向量 : \(V_{user} = [1; \sum_{u \in U} v_{u} x_{u}]\)
- 物品向量 : \(V_{item} = [\sum_{t \in I} w_{t} x_{t} + \frac{1}{2} \sum_{f=1}^{k}((\sum_{t \in I} v_{t, f} x_{t})^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}); \sum_{t \in I} v_{t} x_{t}]\)
这里的设计很巧妙 : 用户向量包含一个常数项 1 和用户特征的聚合表示 , 物品向量则包含物品的内部交互信息和物品特征的聚合表示。两个向量通过内积就能得到 FM 的完整预测分数
这样的分解揭示了一个重要原理 : 即使是复杂的特征交互模式 , 也可以通过合适的向量表示和简单的内积运算来实现
核心代码
FM 召回的双塔实现关键在于如何将数学推导转化为实际的向量表示。用户塔构建了包含常数项和特征聚合的向量 :
# 用户塔:V_user = [1; ∑(v_u * x_u)]
user_concat = Concatenate(axis=1)(user_embeddings) # [batch_size, num_user_features, embedding_dim]
user_embedding_sum = SumPooling()(user_concat) # [batch_size, embedding_dim]
# 构建用户向量:[1; ∑(v_u * x_u)]
ones_vector = OnesLayer()(user_embedding_sum) # [batch_size, 1]
user_vector = Concatenate(axis=1)([ones_vector, user_embedding_sum])
物品塔则更为复杂 , 需要计算一阶线性项和 FM 交互项 :
# 物品塔:V_item = [∑w_t*x_t + FM_interaction; ∑(v_t * x_t)]
item_concat = Concatenate(axis=1)(item_embeddings) # [batch_size, num_item_features, embedding_dim]
item_embedding_sum = SumPooling()(item_concat) # [batch_size, embedding_dim]
# 计算一阶线性项:∑(w_t * x_t)
item_linear_weights = Dense(1, use_bias=False)(item_embedding_sum)
# 计算FM二阶交互项:0.5 * ((∑v_t*x_t)² - ∑(v_t²*x_t²))
sum_squared = SquareLayer()(item_embedding_sum)
item_squared = SquareLayer()(item_concat)
squared_sum = SumPooling()(item_squared)
fm_interaction_vector = Subtract()([sum_squared, squared_sum])
fm_interaction_scalar = SumScalarLayer()(ScaleLayer(0.5)(fm_interaction_vector))
# 组合为物品向量
first_term = Add()([item_linear_weights, fm_interaction_scalar])
item_vector = Concatenate(axis=1)([first_term, item_embedding_sum])
最终通过内积计算匹配分数 : fm_score = Dot (axes=1)([item_vector, user_vector])。这种设计使得物品向量可以离线预计算 , 用户向量实时计算 , 从而支持高效的大规模召回。
训练和评估
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0123 | 0.0073 | 0.0071 | 0.0055 | 0.0012 | 0.0015 |
+---------------+--------------+-----------+----------+----------------+---------------+
DSSM: 深度结构化语义模型 ¶
虽然 FM 在数学上优雅地实现了向量分解,但它本质上仍是线性模型,对于复杂的非线性用户 - 物品关系表达能力有限。深度结构化语义模型(Deep Structured Semantic Model, DSSM) (Huang et al., 2013) 的出现,将双塔模型的表达能力推向了新的高度——通过深度神经网络替代线性变换,实现了更强的特征学习和表示能力。其核心思想是通过深度神经网络将用户和物品映射到共同的语义空间中,通过向量间的相似度计算来衡量匹配程度
推荐中的双塔架构 ¶
在推荐系统中,DSSM 的架构包括两个核心部分:用户塔和物品塔,每个塔都是独立的 DNN 结构。用户特征(如历史行为、人口统计学信息等)经过用户塔处理后输出用户 Embedding,物品特征(如 ID、类别、属性等)经过物品塔处理后输出物品 Embedding。两个 Embedding 的维度必须保持一致,以便进行后续的相似度计算
相比 FM 的线性组合,DSSM 的深度结构能够使用户侧和物品侧的特征各自在塔内进行复杂的非线性变换,但两塔之间的交互仅在最终的内积计算时发生。这种设计带来了显著的工程优势——物品向量可以离线预计算,用户向量可以实时计算,然后通过高效的 ANN 检索完成召回
多分类训练范式 ¶
DSSM 将召回任务视为一个极端多分类问题 , 将物料库中的所有物品看作不同的类别。模型的目标是最大化用户对正样本物品的预测概率 : $\(P(y|x,\theta) = \frac{e^{s(x,y)}}{\sum_{j\in M}e^{s(x,y_j)}}\)$
这里 \(s (x, y)\) 表示用户 \(x\) 和物品 \(y\) 的相似度分数 , \(P (y|x,\theta)\) 表示匹配概率 , \(M\) 表示整个物料库。由于物料库规模巨大 ( 通常数百万到数千万 ), 直接计算这个 Softmax 在计算上不可行,因此实际训练时采用负采样技术,为每个正样本采样一定数量的负样本来近似计算
双塔模型的细节 ¶
除了相对简单的模型结构外 , 双塔模型在实际应用中的一些关键细节同样值得深入探讨。这些细节往往决定了模型的最终效果
向量归一化: 对用户塔和物品塔输出的 Embedding 进行 L2 归一化 :
归一化的核心作用是解决向量点积的非度量性问题。原始的向量点积不满足三角不等式 , 可能导致 " 距离 " 计算的不一致性。例如 , 对于三个点 \(A\)、\(B\)、\(C\), 可能出现 \(A\) 与 \(B\) 相似、\(B\) 与 \(C\) 相似 , 但 \(A\) 与 \(C\) 不相似的情况 , 这违背了度量空间的基本性质
通过归一化 , 向量点积被转化为欧式距离的度量形式。对于归一化向量 \(u\) 和 \(v\), 它们的欧式距离为 : $\(||u - v|| = \sqrt{2-2\langle u,v \rangle}\)$
这种转换的关键意义在于训练与检索的一致性:模型训练时使用的相似度计算(归一化后的点积)与线上 ANN 检索系统使用的距离度量(欧式距离)本质上是等价的。这确保了离线训练学到的向量关系能够在线上检索中得到正确体现,避免了训练 - 服务不一致的问题
温度系数调节: 在归一化后的向量计算内积后 , 除以温度系数 \(\tau\): $\(s(u,v) = \frac{\langle u,v \rangle}{\tau}\)$
这里的温度系数 \(\tau\) 看起来是个简单的除法操作,但实际上它对模型的训练效果有着深远的影响。从数学角度来看,温度系数本质上是在缩放 logits,进而改变 Softmax 函数的输出分布形状。当我们设置 \(\tau < 1\) 时,相似度的差异会被放大,这意味着模型会对高分样本给出更高的概率,预测变得更加“确定”;相反,当 \(\tau > 1\) 时,分布会变得更加平滑,模型的预测也更加保守
核心代码
DSSM 的实现核心在于构建独立的用户塔和物品塔 , 每个塔都是一个深度神经网络 :
# 拼接用户侧和物品侧特征
user_feature = concat_group_embedding(
group_embedding_feature_dict, "user", axis=1, flatten=True
) # B x (N*D)
item_feature = concat_group_embedding(
group_embedding_feature_dict, "item", axis=1, flatten=True
) # B x (N*D)
# 构建用户塔和物品塔(深度神经网络)
user_tower = DNNs(
units=dnn_units, activation="tanh", dropout_rate=dropout_rate, use_bn=True
)(user_feature)
item_tower = DNNs(
units=dnn_units, activation="tanh", dropout_rate=dropout_rate, use_bn=True
)(item_feature)
关键的向量归一化和相似度计算 :
# L2归一化:确保训练与检索的一致性
user_embedding = tf.keras.layers.Lambda(lambda x: tf.nn.l2_normalize(x, axis=1))(
user_tower
)
item_embedding = tf.keras.layers.Lambda(lambda x: tf.nn.l2_normalize(x, axis=1))(
item_tower
)
# 计算余弦相似度(归一化向量的点积)
cosine_similarity = tf.keras.layers.Dot(axes=1)([user_embedding, item_embedding])
这种设计使得用户和物品的表示完全独立 , 支持离线预计算物品向量并存储在 ANN 索引中 , 实现毫秒级的召回响应。
训练和评估
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0161 | 0.0131 | 0.0083 | 0.0074 | 0.0016 | 0.0026 |
+---------------+--------------+-----------+----------+----------------+---------------+
YouTubeDNN: 从匹配到预测用户下一行为 ¶
YouTube 深度神经网络推荐系统代表了双塔模型演进的一个重要里程碑。YouTubeDNN 在架构上延续了双塔设计 , 但引入了一个关键的思想转变 : 将召回任务重新定义为 " 预测用户下一个会观看的视频 ", 这种转变使得模型能够更好地捕捉用户的序列行为模式和动态兴趣演化
YouTubeDNN 采用了 " 非对称 " 的双塔架构 : 用户塔集成了观看历史、搜索历史、人口统计学特征等多模态信息 , 用户观看的视频 ID 通过嵌入层映射后进行平均池化 , 形成用户兴趣的紧凑表示。此外 , 模型还引入了 "Example Age" 特征来建模内容新鲜度的影响 ; 物品塔则相对简化 , 本质上是一个巨大的嵌入矩阵 , 每个视频对应一个可学习的向量 , 避免了复杂的物品特征工程
这种 " 预测下一个观看视频 " 的任务设定 , 本质上类似于 NLP 中的 next token 预测 , 可以自然地建模为一个极端多分类问题 : $\(P(w_t=i|U,C) = \frac{e^{v_i \cdot u}}{\sum_{j \in V} e^{v_j \cdot u}}\)$
这里 \(w_t\) 表示用户在时间 \(t\) 观看的视频 , \(U\) 是用户特征 , \(C\) 是上下文信息 , \(V\) 是整个视频库。由于视频库规模巨大 ( 数百万级别 ), 模型采用 Sampled Softmax 进行高效训练
关键的工程技巧 ¶
YouTubeDNN 的成功不仅来自于模型设计 , 更来自于一系列精心设计的工程技巧 :
非对称的时序分割:传统协同过滤通常随机保留验证项目,但这种做法存在未来信息泄露问题。视频消费具有明显的不对称模式——剧集通常按顺序观看,用户往往从热门内容开始逐步深入小众领域。因此,YouTubeDNN 采用时序分割策略:对于作为预测目标的用户观看记录,只使用该目标之前的历史行为作为输入特征。这种“回滚”机制更符合真实的推荐场景
负采样策略: 为了高效处理数百万类别的 Softmax, 模型采用重要性采样技术 , 每次只对数千个负样本进行计算 , 将训练速度提升了 100 多倍
用户样本均衡: 为每个用户生成固定数量的训练样本 , 避免高活跃用户主导模型学习。这个看似简单的技巧 , 对提升长尾用户的推荐效果至关重要
YouTubeDNN 的成功在于建立了一套可扩展、可工程化的推荐系统范式——训练时使用复杂的多分类目标和丰富的用户特征 , 服务时通过预计算物品向量和 ANN 检索实现毫秒级响应 , 在准确性和效率之间取得了优雅的平衡
核心代码
YouTubeDNN 的用户塔设计体现了 " 非对称 " 的思想 , 它整合了多种用户特征和历史行为序列 :
# 整合用户特征和历史行为序列
user_feature_embedding = concat_group_embedding(
group_embedding_feature_dict, "user_dnn"
) # B x (D * N)
if "raw_hist_seq" in group_embedding_feature_dict:
hist_seq_embedding = concat_group_embedding(
group_embedding_feature_dict, "raw_hist_seq"
) # B x D
user_dnn_inputs = tf.concat(
[user_feature_embedding, hist_seq_embedding], axis=1
) # B x (D * N + D)
else:
user_dnn_inputs = user_feature_embedding
# 构建用户塔:输出归一化的用户向量
user_dnn_output = DNNs(
units=dnn_units + [emb_dim], activation="relu", use_bn=False
)(user_dnn_inputs)
user_dnn_output = L2NormalizeLayer(axis=-1)(user_dnn_output)
物品塔则采用简化设计 , 直接使用物品 Embedding 表 :
# 物品Embedding表(从特征列配置中获取)
item_embedding_table = embedding_table_dict[label_name]
# 为评估构建物品模型
output_item_embedding = SqueezeLayer(axis=1)(
item_embedding_table(input_layer_dict[label_name])
)
output_item_embedding = L2NormalizeLayer(axis=-1)(output_item_embedding)
训练时采用 Sampled Softmax 优化 , 将百万级的多分类问题转化为高效的采样学习 :
# 构建采样softmax层
sampled_softmax_layer = SampledSoftmaxLayer(item_vocab_size, neg_sample, emb_dim)
output = sampled_softmax_layer([
item_embedding_table.embeddings,
user_dnn_output,
input_layer_dict[label_name]
])
这种设计的核心优势在于 : 用户��可以根据业务需求灵活扩展特征和模型复杂度 , 而物品塔保持简洁高效 , 易于离线预计算和实时检索。
训练和评估
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0303 | 0.0219 | 0.0169 | 0.0142 | 0.003 | 0.0044 |
+---------------+--------------+-----------+----------+----------------+---------------+
Summary¶
向量召回技术的核心贡献在于将推荐系统从“匹配”转向“搜索”,通过向量化表示实现了从 \(O (U×I)\) 到 \(O (U+I)\) 的复杂度优化。这一转变不仅解决了大规模推荐系统的计算瓶颈,更重要的是为推荐算法提供了统一的数学框架——将用户和物品都映射到同一个向量空间中,让“距离”代表“相似度”
I2I 召回的演进脉络:从 NLP 领域的 Word2Vec 出发,I2I 召回经历了三个关键发展阶段。Item2Vec 实现了从“词语 - 句子”到“物品 - 用户序列”的直接迁移,验证了序列建模在推荐系统中的可行性;EGES 通过构建商品关系图和融合辅助信息,解决了稀疏性和冷启动问题,展现了图结构和属性信息的价值;Airbnb 则将业务目标深度融入序列构建,通过会话切分、全局上下文和市场感知负采样,实现了从相似性到转化率的跨越。这一演进过程本质上是对“序列”概念的不断深化——从简单的共现关系到复杂的业务逻辑
U2I 召回的技术路径:U2I 召回以双塔模型为核心架构,体现了“分而治之”的设计哲学。FM 通过数学变换首次实现了用户 - 物品交互的向量分解,奠定了双塔模型的理论基础;DSSM 用深度神经网络替代线性变换,将双塔模型的表达能力推向新高度,同时建立了多分类训练范式和工程化部署模式;YouTubeDNN 则通过“预测下一个观看视频”的任务重定义,引入了时序分割、负采样优化等关键工程技巧,展现了双塔模型在超大规模场景下的实用价值
核心技术洞察:向量召回的成功建立在几个关键洞察之上。首先是向量空间的语义性——通过合适的训练目标,向量间的距离能够反映真实的用户偏好和物品相似性;其次是架构的可分解性——双塔设计实现了训练复杂度和服务效率的平衡,物品向量可离线预计算,用户向量可实时生成;最后是序列的信息密度——用户行为序列蕴含着丰富的偏好信号,通过不同的序列构建和建模方式可以挖掘出不同层次的用户意图
技术边界与挑战:向量召回在解决效率问题的同时,也带来了固有的局限性。双塔之间缺乏深度交互可能损失复杂的用户 - 物品关系,向量表示的有限维度难以完全捕捉丰富的语义信息,这些挑战推动了后续序列建模技术的发展。向量召回的使命是在庞大的物品库中高效筛选出与用户兴趣匹配的候选集,为后续的精排模型提供高质量输入,它更像是推荐系统的“粗筛”阶段,追求的是召回率而非精确率
03 Sequence¶
在前面的章节中,我们学习了协同过滤和向量召回的方法。这些方法通常将用户的历史行为汇总成一个静态的表示(比如一个向量
比如,一个用户先浏览了跑鞋,然后看了运动服,接着又看了健身器材,这个顺序告诉我们这个用户可能对健身运动感兴趣。如果我们只是简单地把这些行为加起来或者平均,就丢失了这种时间顺序的信息
序列召回就是要利用用户行为的时间顺序信息来进行推荐。它的基本想法是:用户的当前兴趣不仅取决于他过去喜欢什么,还取决于他最近在做什么,以及这些行为的顺序
相比传统的静态表示方法,序列召回能够对用户的兴趣理解的更加的准确及全面。它可以理解行为间的因果关系,如用户买手机后通常会买手机壳;能够捕捉兴趣的演化过程,如从数码产品转向户外运动;还能更好地处理多元化兴趣,在不同时段识别用户对电影、运动等领域的偏好变化
在序列召回的研究中,学者们探索了多种不同的建模思路。本章将重点介绍其中两类具有代表性的方法:
多兴趣用户表示 传统方法将用户压缩为单一向量,难以充分表达用户兴趣的多样性和时序性。多兴趣表示方法尝试通过多个向量或动态结构来更好地刻画用户的复杂兴趣模式。我们将介绍 MIND 模型如何使用多个向量分别捕捉用户的不同兴趣维度,以及 SDM 模型如何将用户兴趣分解为长期偏好和短期行为两个层次
生成式序列预测 另一类方法将推荐问题重新定义为序列生成任务,借鉴自然语言处理领域的成功经验。我们将探讨 SASRec 如何将 Transformer 架构引入推荐系统,通过自注意力机制建模序列依赖关系,以及后续的 HSTU 和 TIGER 模型在特征融合和物品表示方面的改进
通过这些具体案例,我们将深入理解不同建模策略如何挖掘时序信息的价值
3.1 深化用户兴趣表示 ¶
传统的向量召回方法,如双塔模型 , 倾向于将用户所有的历史行为 " 压扁 " 成一个单一的静态向量。这种 " 平均化 " 的处理方式虽然高效,但在两个方面存在明显不足:首先,它无法体现用户兴趣的多样性——一个人可能同时喜欢科技产品和运动装备,但单一向量很难同时表达这两种截然不同的偏好;其次,它忽视了用户兴趣的动态演化——你昨天浏览的商品往往比一个月前的行为更能反映当前的购物意图
为了构建一个更丰富、更立体的用户画像以实现更精准的召回,研究者们沿着 " 深化用户兴趣表示 " 的路径进行了持续探索。本章将介绍其中的两个代表性模型:MIND 和 SDM 我们将首先探讨如何使用多个向量来表示用户的多元兴趣,然后在此基础上,进一步融入时间维度,学习如何动态地捕捉用户兴趣的演化
MIND:用多个向量捕捉用户的多元兴趣 ¶
想象一下,你在淘宝上的购物历史:今天买了一本编程书,昨天买了运动鞋,上周买了咖啡豆。如果推荐系统只用一个数字向量来描述你,就像是用一个标签来概括一个人的全部——显然是不够的
MIND (Multi-Interest Network with Dynamic Routing) (Li et al., 2019) 模型提出了一个更符合直觉的想法:既然用户有多种兴趣,为什么不用多个向量来分别表示呢?就像给每个兴趣爱好都分配一个专门的“代言人”
这个模型的巧妙之处在于借鉴了胶囊网络的动态路由思想。简单来说,它会自动把你的行为按照兴趣类型进行分组——编程相关的行为归为一类,运动相关的归为另一类,美食相关的又是一类。每一类都会生成一个专门的兴趣向量,这样推荐系统就能更精准地理解你在不同场景下的需求
从整体架构来看,除了常规的 Embedding 层,MIND 模型还包含了两个重要的组件:多兴趣提取层和 Label-Aware 注意力层
多兴趣提取
MIND 模型的多兴趣提取技术源于对胶囊网络动态路由机制的创新性改进。胶囊网络 ( Sabouret al., 2017) 最初在计算机视觉领域被提出,其核心思想是用向量而非标量来表示特征,向量的方向编码属性信息,长度表示存在概率。动态路由则是确定不同层级胶囊之间连接强度的算法,它通过迭代优化的方式实现输入特征的软聚类。这种软聚类机制的优势在于,它不需要预先定义聚类数量或类别边界,而是让数据自然地分组,这正好契合了用户兴趣发现的需求。MIND 模型引入了这一思想并提出了行为到兴趣(Behavior to Interest,B2I)动态路由:将用户的历史行为视为行为胶囊,将用户的多重兴趣视为兴趣胶囊,通过动态路由算法将相关的行为聚合到对应的兴趣维度中。MIND 模型针对推荐系统的特点对原始动态路由算法进行了三个关键改进: 1. 共享变换矩阵。与原始胶囊网络为每对胶囊使用独立变换矩阵不同,MIND 采用共享的双线性映射矩阵 \(S \in \mathbb{R}^{d \times d}\)。这种设计有两个重要考虑:首先,用户行为序列长度变化很大,从几十到几百不等,共享矩阵确保了算法的通用性 ;其次,共享变换保证所有兴趣向量位于同一表示空间,便于后续的相似度计算和检索操作。路由连接强度的计算公式为:
$\(b_{ij} = \boldsymbol{u}_j^T \boldsymbol{\textrm{S}} \boldsymbol{e}_i\)$
其中 \(\boldsymbol{e}_i\) 表示用户历史行为 \(i\) 的物品向量,\(\boldsymbol{u}_j\) 表示第 \(j\) 个兴趣胶囊的向量,\(b_{ij}\) 衡量行为 \(i\) 与兴趣 \(j\) 的关联程度
- 随机初始化策略。为避免所有兴趣胶囊收敛到相同状态,算法采用高斯分布随机初始化路由系数 \(b_{ij}\)。这一策略类似于 K-Means 聚类中的随机中心初始化,确保不同兴趣胶囊能够捕捉用户兴趣的不同方面
- 自适应兴趣数量。考虑到不同用户的兴趣复杂度差异很大,MIND 引入了动态兴趣数量机制: $\(K_u' = \max (1, \min (K, \log_2 (|\mathcal{I}_u|)))\)$
其中 \(|\mathcal{I}_u|\) 表示用户 \(u\) 的历史行为数量,\(K\) 是预设的最大兴趣数。这种设计为行为较少的用户节省计算资源,同时为活跃用户提供更丰富的兴趣表示。
改进后的动态路由过程通过迭代方式进行更新。\(b_{ij}\) 在第一轮迭代时,初始化为 0,在每轮迭代中更新路由系数和兴趣胶囊向量,直到收敛。 公式 (2.3.1)描述了路由系数 \(b_{ij}\) 的更新,但关键的兴趣胶囊向量 \(\boldsymbol{u}_j\) 是通过以下步骤计算的,这本质上是一个软聚类算法:
- 计算路由权重 : 对于每一个历史行为(低层胶囊 \(i\)),其分配到各个兴趣(高层胶囊 \(j\))的权重 \(w_{ij}\) 通过对路由系数 \(b_{ij}\) 进行 Softmax 操作得到。
$\(w_{ij} = \frac{\exp{b_{ij}}}{\sum_{k=1}^{K_u'} \exp{b_{ik}}}\)$
这里的 \(w_{ij}\) 可以理解为行为 \(i\) 属于兴趣 \(j\) 的"软分配"概率。
- 聚合行为以形成兴趣向量 : 每一个兴趣胶囊的初步向量 \(\boldsymbol{z}_j\) 是通过对所有行为向量 \(\boldsymbol{e}_i\) 进行加权求和得到的。每个行为向量在求和前会先经过共享变换矩阵 \(\boldsymbol{S}\) 的转换。
$\(\boldsymbol{z}_j = \sum_{i\in \mathcal{I}_u} w_{ij} \boldsymbol{S} \boldsymbol{e}_i\)$
这一步是聚类的核心:根据刚刚算出的权重,将相关的用户行为聚合起来,形成代表特定兴趣的向量。
- 非线性压缩 : 为了将向量的模长(长度)约束在 [0, 1) 区间内,同时不改变其方向,模型使用了一个非线性的"squash"函数,从而得到本轮迭代的最终兴趣胶囊向量 \(\boldsymbol{u}_j\)。向量的长度可以被解释为该兴趣存在的概率,而其方向则编码了兴趣的具体属性。
$\(\boldsymbol{u}_j = \text{squash}(\boldsymbol{z}_j) = \frac{\left\lVert \boldsymbol{z}_j \right\rVert ^ 2}{1 + \left\lVert \boldsymbol{z}_j \right\rVert ^ 2} \frac{\boldsymbol{z}_j}{\left\lVert \boldsymbol{z}_j \right\rVert}\)$
- 更新路由系数 (Updating Routing Logits): 最后,根据新生成的兴趣胶囊 \(\boldsymbol{u}_j\) 和行为向量 \(\boldsymbol{e}_i\) 之间的一致性(通过点积衡量),来更新下一轮迭代的路由系数 \(b_{ij}\)。
$\(b_{ij} \leftarrow b_{ij} + \boldsymbol{u}_j^T \boldsymbol{S} \boldsymbol{e}_i\)$
以上四个步骤会重复进行固定的次数(通常为 3 次
核心代码
MIND 的核心在于胶囊网络的动态路由实现。在每次迭代中,模型首先通过 softmax 计算路由权重,然后通过双线性变换聚合行为向量,最后使用 squash 函数进行非线性压缩:
# 动态路由的核心循环
for i in range(self.iteration_times): # 通常迭代3次
# 1. 计算路由权重 w_ij
routing_logits_with_padding = tf.where(mask, mask_routing_logits, pad)
weight = tf.nn.softmax(routing_logits_with_padding) # [B, k_max, max_len]
# 2. 通过共享的双线性映射矩阵 S 变换行为嵌入
behavior_embdding_mapping = tf.tensordot(
behavior_embddings, self.bilinear_mapping_matrix, axes=1
) # [B, max_len, out_units]
# 3. 加权聚合形成兴趣胶囊
Z = tf.matmul(weight, behavior_embdding_mapping) # [B, k_max, out_units]
interest_capsules = squash(Z) # 非线性压缩到 [0, 1)
# 4. 更新路由系数:基于兴趣胶囊与行为的一致性
delta_routing_logits = tf.reduce_sum(
tf.matmul(interest_capsules, tf.transpose(behavior_embdding_mapping, perm=[0, 2, 1])),
axis=0, keepdims=True
)
self.routing_logits.assign_add(delta_routing_logits)
这里的 squash 函数实现了向量长度的非线性压缩,确保输出向量的模长在 \([0, 1)\) 区间内:
def squash(inputs):
"""非线性压缩函数,将向量长度映射到 [0, 1) 区间"""
vec_squared_norm = tf.reduce_sum(tf.square(inputs), axis=-1, keepdims=True)
scalar_factor = vec_squared_norm / (1 + vec_squared_norm) / tf.sqrt(vec_squared_norm + 1e-9)
return scalar_factor * inputs
标签感知的注意力机制
多兴趣提取层生成了用户的多个兴趣向量,但在训练阶段,我们需要确定哪个兴趣向量与当前的目标商品最相关。因为在训练时,我们拥有 ' 正确答案 '(用户实际交互的商品
该注意力机制以目标商品向量作为查询,以用户的多个兴趣向量作为键和值。具体计算过程如下:
其中 \(V_u = (v_u^1, \ldots, v_u^K)\) 表示用户的兴趣胶囊矩阵,通过将兴趣胶囊向量 \(\boldsymbol{u}\) 与用户画像 Embedding 进行拼接,再经过多层 ReLU 网络得到 (见图 2.3.1)。\(e_i\) 是目标商品 \(i\) 的 Embedding 向量,\(p\) 是控制注意力集中度的超参数。
参数 \(p\) 控制注意力分布:当 \(p\) 接近 0 时,所有兴趣向量获得均等关注;随着 \(p\) 增大,注意力逐渐集中于与目标商品最相似的兴趣;当 \(p\) 趋于无穷大时,模型退化为硬选择,只选择相似度最高的那个兴趣向量。
通过标签感知得到用户向量 \(v_u\) 后,MIND 模型的训练目标就是让用户向量与其真实交互的商品尽可能 " 匹配 "。具体来说,模型会最大化用户向量与正样本商品的内积,同时最小化与负样本商品的内积。在实践中,由于商品数量巨大(通常百万级别
标签感知注意力的实现比较直观,核心是使用目标商品向量作为查询,计算与各个兴趣向量的相似度:
def call(self, inputs, training=None, **kwargs):
keys = inputs[0] # 多个兴趣胶囊向量 [batch_size, k_max, dim]
query = inputs[1] # 目标商品向量 [batch_size, dim]
# 计算每个兴趣向量与目标商品的相似度
weight = tf.reduce_sum(keys * query, axis=-1, keepdims=True) # [batch_size, k_max, 1]
# 通过幂次运算控制注意力集中度
weight = tf.pow(weight, self.pow_p)
# 如果 pow_p 很大(>= 100),直接选择最相似的兴趣
if self.pow_p >= 100:
idx = tf.argmax(weight, axis=1, output_type=tf.int32)
output = tf.gather_nd(keys, idx)
else:
# 否则使用 softmax 进行加权聚合
weight = tf.nn.softmax(weight, axis=1)
output = tf.reduce_sum(keys * weight, axis=1) # [batch_size, dim]
return output
训练和评估
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0036 | 0.0008 | 0.0012 | 0.0003 | 0.0004 | 0.0002 |
+---------------+--------------+-----------+----------+----------------+---------------+
SDM:融合长短期兴趣,捕捉动态变化 ¶
MIND 解决了兴趣 " 广度 " 的问题,但新的问题随之而来:时间。 用户兴趣不仅是多元的,还是动态演化的。一个用户在一次购物会话(Session)中的行为,往往比他一个月前的行为更能预示他下一刻的需求。MIND 虽然能识别多个兴趣,但对所有历史行为一视同仁,没有区分远近亲疏。SDM 模型 正是为了解决这一问题而提出的。SDM 模型 的核心思想是分别建模用户的短期即时兴趣和长期稳定偏好,然后智能地融合它们
捕捉短期兴趣
为了精准捕捉短期兴趣,SDM 设计了一个三层结构来处理用户的当前会话序列 ( 图 2.3.2 左下角) 。
首先,将短期会话中的商品序列输入 LSTM 网络,学习序列间的时序依赖关系。LSTM 的标准计算过程为:
这里 \(\boldsymbol{e}_{i_{t}^{u}}\) 表示第 \(t\) 个时间步的商品 Embedding,\(\sigma\) 表示 sigmoid 激活函数,\(\boldsymbol{W}\) 表示权重矩阵,\(b\) 表示偏置项。
LSTM 的引入主要是为了处理在线购物中的一个常见问题:用户往往会产生一些随机点击,这些不相关的行为会干扰序列表示。通过 LSTM 的门控机制,模型能够选择性地记忆重要信息、遗忘噪声,从而更准确地捕捉用户的真实意图。
然后,SDM 采用多头自注意力机制来捕捉用户兴趣的多样性。多头自注意力机制。
具体计算过程为:
其中 \(Q_{i}^{u}\)、\(K_{i}^{u}\)、\(V_{i}^{u}\) 分别表示第 \(i\) 个头的查询、键、值矩阵,\(\boldsymbol{W}_{i}^{Q}\)、\(\boldsymbol{W}_{i}^{K}\)、\(\boldsymbol{W}_{i}^{V}\) 是对应的权重矩阵。
多头注意力的最终输出为:
其中 \(h\) 是头的数量,\(W^{O}\) 是输出权重矩阵。每个头可以专注于不同的兴趣维度,通过多头机制实现对用户多重兴趣的并行建模。
最后,SDM 加入个性化注意力层,使用用户画像向量 \(\boldsymbol{e}_u\) 作为查询,对多头注意力输出进行加权:
这里 \(\hat{\boldsymbol{h}}_{k}^{u}\) 是多头注意力输出 \(\hat{X}^{u}\) 中第 \(k\) 个位置的隐藏状态,\(\alpha_{k}\) 是对应的注意力权重。最终得到的短期兴趣向量 \(\boldsymbol{s}_{t}^{u}\) 融合了用户个性化信息,能够更准确地反映用户在当前时刻的即时兴趣。
核心代码
SDM 的短期兴趣建模采用了三层架构,逐步从原始序列中提取用户的即时兴趣:
# 1. 序列信息学习层:使用LSTM处理序列依赖
lstm_layer = tf.keras.layers.LSTM(
emb_dim,
return_sequences=True, # 返回所有时间步的输出
recurrent_initializer='glorot_uniform'
)
sequence_output = lstm_layer(short_history_item_emb) # [batch_size, seq_len, dim]
# 2. 多兴趣提取层:多头自注意力捕捉序列内的复杂关系
norm_sequence_output = tf.keras.layers.LayerNormalization()(sequence_output)
sequence_output = tf.keras.layers.MultiHeadAttention(
num_heads=num_heads,
key_dim=emb_dim // num_heads,
dropout=0.1
)(norm_sequence_output, sequence_output) # [batch_size, seq_len, dim]
short_term_output = tf.keras.layers.LayerNormalization()(sequence_output)
# 3. 用户个性化注意力层:使用用户画像作为查询向量
user_attention = UserAttention(name='user_attention_short')
short_term_interest = user_attention(
user_embedding, # [batch_size, 1, dim] 用户画像作为查询
short_term_output # [batch_size, seq_len, dim] 序列作为键和值
) # [batch_size, 1, dim]
个性化注意力层的实现通过用户画像与序列特征的点积来计算注意力权重:
class UserAttention(tf.keras.layers.Layer):
"""用户注意力层,使用用户基础表示作为查询向量"""
def call(self, query_vector, key_vectors):
# 计算注意力分数:query · key^T
attention_scores = tf.matmul(
query_vector, # [batch_size, 1, dim]
tf.transpose(key_vectors, [0, 2, 1]) # [batch_size, dim, seq_len]
) # [batch_size, 1, seq_len]
attention_scores = tf.squeeze(attention_scores, axis=1)
attention_weights = tf.nn.softmax(attention_scores, axis=-1)
# 加权求和得到上下文向量
context_vector = tf.matmul(
tf.expand_dims(attention_weights, axis=1),
key_vectors
) # [batch_size, 1, dim]
return context_vector
捕捉长期兴趣
长期行为包含丰富的用户偏好信息,但与短期行为的建模方式不同。SDM 从特征维度对长期行为进行聚合,将历史行为按不同特征分成多个子集 (图 2.3.2 左上角):
具体包括:交互过的商品 ID 集合 \(\mathcal{L}^{u}_{id}\),叶子类别集合 \(\mathcal{L}^{u}_{leaf}\),一级类别集合 \(\mathcal{L}^{u}_{cate}\),访问过的商店集合 \(\mathcal{L}^{u}_{shop}\),交互过的品牌集合 \(\mathcal{L}^{u}_{brand}\)。这种特征维度的分离使模型能够从不同角度理解用户的长期偏好模式。
对每个特征子集,模型使用注意力机制计算用户在该维度上的偏好。将特征实体 \(f^{u}_{k} \in \mathcal{L}^{u}_{f}\) 通过嵌入矩阵转换为向量 \(\boldsymbol{g}_{k}^{u}\),然后以用户画像 \(\boldsymbol{e}_{u}\) 为查询计算注意力权重:
其中 \(\left|\mathcal{L}_{f}^{u}\right|\) 表示特征子集的大小。
最终将各特征维度的表示拼接,通过全连接网络得到长期兴趣表示:
其中 \(\boldsymbol{W}^{p}\) 是权重矩阵,\(\boldsymbol{b}\) 是偏置向量。
核心代码
长期兴趣的建模采用特征维度聚合的方式,对每个特征维度分别应用注意力机制:
# 从不同特征维度对长期行为进行聚合
long_history_features = group_embedding_feature_dict['raw_hist_seq_long']
long_term_interests = []
for name, long_history_feature in long_history_features.items():
# 为每个特征维度生成 mask
long_history_mask = tf.keras.layers.Lambda(
lambda x: tf.expand_dims(
tf.cast(tf.not_equal(x, 0), dtype=tf.float32), axis=-1
)
)(input_layer_dict[name]) # [batch_size, max_len_long, 1]
# 应用 mask 到特征嵌入
long_history_item_emb = tf.keras.layers.Lambda(lambda x: x[0] * x[1])(
[long_history_feature, long_history_mask]
) # [batch_size, max_len_long, dim]
# 对每个特征维度应用用户注意力
user_attention = UserAttention(name=f'user_attention_long_{name}')
long_term_interests.append(
user_attention(user_embedding, long_history_item_emb)
) # [batch_size, 1, dim]
# 拼接所有特征维度的表示
long_term_interests_concat = tf.keras.layers.Concatenate(axis=-1)(
long_term_interests
) # [batch_size, 1, dim * len(long_history_features)]
# 通过全连接层融合
long_term_interest = tf.keras.layers.Dense(emb_dim, activation='tanh')(
long_term_interests_concat
) # [batch_size, 1, dim]
长短期兴趣融合
有了长短期兴趣表示后,关键问题是如何有效融合这两部分信息。用户的长期行为虽然丰富,但通常只有一小部分与当前决策相关。简单的拼接或加权平均会引入大量噪声。
SDM 设计了门控融合机制,类似 LSTM 中的门控思想 (图 2.3.2 中间部分):
这里 \(\boldsymbol{G}_{t}^{u} \in \mathbb{R}^{d \times 1}\) 是门控向量,\(\odot\) 表示逐元素乘法,\(\boldsymbol{W}^{1}\)、\(\boldsymbol{W}^{2}\)、\(\boldsymbol{W}^{3}\) 是可学习的权重矩阵,\(\boldsymbol{b}\) 是偏置项。
门控网络接收三个输入:用户画像 \(\boldsymbol{e}_{u}\)、短期兴趣 \(\boldsymbol{s}_{t}^{u}\) 和长期兴趣 \(\boldsymbol{p}^{u}\),输出的门控向量 \(\boldsymbol{G}_{t}^{u}\) 决定了在最终的用户表示 \(\boldsymbol{o}_{t}^{u}\) 中,短期兴趣和长期兴趣各占多大比重。这种自适应融合机制使模型能够根据不同用户、不同场景灵活调整长短期信息的权重。
核心代码
门控融合机制通过学习三个权重矩阵来决定如何融合长短期兴趣:
class GatedFusion(tf.keras.layers.Layer):
"""门控融合层,用于融合长期和短期兴趣"""
def build(self, input_shape):
dim = input_shape[0][-1]
# 为用户画像、短期兴趣、长期兴趣分别学习权重矩阵
self.W1 = self.add_weight(
shape=(dim, dim), initializer='glorot_uniform', trainable=True, name='W1'
)
self.W2 = self.add_weight(
shape=(dim, dim), initializer='glorot_uniform', trainable=True, name='W2'
)
self.W3 = self.add_weight(
shape=(dim, dim), initializer='glorot_uniform', trainable=True, name='W3'
)
self.b = self.add_weight(
shape=(dim,), initializer='zeros', trainable=True, name='bias'
)
super(GatedFusion, self).build(input_shape)
def call(self, inputs):
user_embedding, short_term, long_term = inputs
# 计算门控向量:G = sigmoid(W1·e_u + W2·s_t + W3·p_u + b)
gate = tf.sigmoid(
tf.matmul(user_embedding, self.W1) +
tf.matmul(short_term, self.W2) +
tf.matmul(long_term, self.W3) +
self.b
) # [batch_size, 1, dim]
# 门控融合:o_t = (1 - G) ⊙ p_u + G ⊙ s_t
output = (1 - gate) * long_term + gate * short_term
return output
整个 SDM 模型的最终实现将三个模块串联起来:
# 短期兴趣建模
short_term_interest = build_short_term_interest(
short_history_item_emb, user_embedding
) # [batch_size, 1, dim]
# 长期兴趣建模
long_term_interest = build_long_term_interest(
long_history_features, user_embedding
) # [batch_size, 1, dim]
# 门控融合
gated_fusion = GatedFusion(name='gated_fusion')
final_interest = gated_fusion(
[user_embedding, short_term_interest, long_term_interest]
) # [batch_size, 1, dim]
训练和评估
+---------------+--------------+-----------+----------+----------------+---------------+
| hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===============+==============+===========+==========+================+===============+
| 0.0058 | 0.0051 | 0.0046 | 0.0044 | 0.0006 | 0.001 |
+---------------+--------------+-----------+----------+----------------+---------------+