2.2.2. U2I召回¶
在完成了I2I召回的探讨后,我们转向另一条同样重要的技术路径:U2I(用户到物品)召回。如果说I2I召回解决的是“买了这个商品的人还会买什么”的问题,那么U2I召回直面的则是推荐系统的核心命题——“这个用户会喜欢什么商品”。
U2I召回的核心挑战在于如何在庞大的物品库中,快速找到与用户兴趣高度匹配的候选集。传统的协同过滤方法虽然有效,但在面对数亿用户和数千万商品时,计算复杂度成为不可逾越的障碍。U2I召回的演进历程,本质上是一个将复杂的“匹配”问题逐步简化为高效“搜索”问题的过程。
这一转变的关键突破来自于一个统一的架构思想:双塔模型(Two-Tower Model)。无论是经典的因子分解机FM、深度结构化语义模型DSSM,还是YouTube的深度神经网络YouTubeDNN,它们在表面上看起来差异巨大,但在本质上都遵循着同一个设计哲学——将用户和物品分别编码为向量,然后通过向量间的相似度计算来衡量匹配程度。
双塔模型的核心思想是将推荐问题分解为两个相对独立的子问题。用户塔(User Tower)专注于理解用户——处理用户的历史行为、人口统计学特征、上下文信息等,最终输出一个代表用户兴趣的向量\(u\)。物品塔(Item Tower)则专精于刻画物品——整合物品的ID、类别、属性、内容特征等,输出一个表征物品特性的向量\(v\)。
这种“分而治之”的设计带来了巨大的工程优势。在训练完成后,所有物品的向量都可以离线预计算并存储在高效的向量检索系统中(如Faiss、Annoy等)。当用户发起推荐请求时,系统只需实时计算用户向量,然后通过近似最近邻(ANN)搜索快速找到最相似的物品向量。这种架构的优雅之处在于,它将原本需要\(O(U \times I)\)的用户-物品匹配复杂度,降低到了\(O(U + I)\)的向量计算复杂度。
用户与物品的匹配度通过两个向量的点积或余弦相似度来衡量:
其中\(d\)是向量维度, \(u_i\)和\(v_i\)是向量\(u\)和\(v\)的第\(i\)个分量。这个简单的数学操作背后,蕴含着“语义相似性”的深刻含义——向量空间中的距离反映了用户兴趣与物品特性的匹配程度。
接下来,我们将沿着双塔模型的演进轨迹,从经典的数学基础到现代的深度学习实现,逐一探讨这些里程碑式的工作。
2.2.2.1. FM(因子分解机):双塔模型的雏形¶
虽然因子分解机(Factorization Machine, FM) (Rendle, 2010) 诞生于深度学习兴起之前,但它在思想上可以说是双塔模型的雏形。FM的核心贡献在于,它首次将用户-物品的复杂交互,优雅地分解为两个低维向量的内积操作。
2.2.2.1.1. 从交互矩阵到向量内积¶
FM模型的完整数学表达式为:
这个公式看起来复杂,但其核心思想简单而深刻:每个特征\(i\)都对应一个\(k\)维的隐向量\(\mathbf{v}_i\),特征间的交互通过这些隐向量的内积\(\langle\mathbf{v}_{i}, \mathbf{v}_{j}\rangle = \sum_{f=1}^{k} v_{i,f} \cdot v_{j,f}\)来建模。
FM的真正巧妙之处在于一个数学变换技巧。原本\(O(n^2)\)复杂度的二阶交互项,可以通过代数运算重写为:
这一变换将计算复杂度从\(O(kn^2)\)降低到\(O(kn)\),使得FM能够处理大规模稀疏数据。
2.2.2.1.2. 分解为双塔结构¶
虽然FM通过数学变换解决了计算复杂度问题,但在召回任务中,我们还面临另一个挑战:如何高效地为用户从海量候选物品中筛选出最相关的推荐结果?这时就需要考虑将FM分解为双塔结构。
在召回场景下,我们可以将所有特征自然地分为两类:用户侧特征集\(U\)(如用户年龄、性别、历史偏好等)和物品侧特征集\(I\)(如物品类别、价格、品牌等)。
这里有一个关键的发现:当我们为同一个用户推荐不同物品时,用户特征是固定不变的。因此,用户特征内部的交互得分(无论是一阶还是二阶)对所有候选物品都是相同的。既然这部分得分相同,在排序时就可以忽略,我们只需要关注: 1. 物品特征内部的交互得分 2. 用户特征与物品特征之间的交互得分
基于这个思路,我们可以将FM的二阶交互项重新组织:
基于前面的分析,我们发现用户特征内部的交互项对所有候选物品都相同,因此可以在召回阶段忽略。这样就可以将FM重新组织,只保留对排序有影响的部分:
观察上面的公式,我们发现一个重要的数学结构:最后一项\(\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}\)的内积。这启发我们将整个匹配分数重新组织为双塔结构:
通过这种重新组织,我们得到了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召回的双塔实现关键在于如何将数学推导转化为实际的向量表示。用户塔构建了包含常数项和特征聚合的向量:
# 用户塔: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])。这种设计使得物品向量可以离线预计算,用户向量实时计算,从而支持高效的召回检索。
训练和评估
from funrec import run_experiment
run_experiment('fm_recall')
+---------------+--------------+-----------+----------+----------------+---------------+
| 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 |
+---------------+--------------+-----------+----------+----------------+---------------+
2.2.2.2. DSSM:深度结构化语义模型¶
虽然FM在数学上优雅地实现了向量分解,但它本质上仍是线性模型,对于复杂的非线性用户-物品关系表达能力有限。深度结构化语义模型(Deep Structured Semantic Model, DSSM) (Huang et al., 2013) 的出现,将双塔模型的表达能力推向了新的高度——通过深度神经网络替代线性变换,实现了更强的特征学习和表示能力。其核心思想是通过深度神经网络将用户和物品映射到共同的语义空间中,通过向量间的相似度计算来衡量匹配程度。
图2.2.5 DSSM双塔架构¶
2.2.2.2.1. 推荐中的双塔架构¶
在推荐系统中,DSSM的架构包括两个核心部分:用户塔和物品塔,每个塔都是独立的DNN结构。用户特征(如历史行为、人口统计学信息等)经过用户塔处理后输出用户Embedding,物品特征(如ID、类别、属性等)经过物品塔处理后输出物品Embedding。两个Embedding的维度必须保持一致,以便进行后续的相似度计算。
相比FM的线性组合,DSSM的深度结构能够使用户侧和物品侧的特征各自在塔内进行复杂的非线性变换,但两塔之间的交互仅在最终的内积计算时发生。这种设计带来了显著的工程优势——物品向量可以离线预计算,用户向量可以实时计算,然后通过高效的ANN检索完成召回。
2.2.2.2.2. 多分类训练范式¶
DSSM将召回任务视为一个极端多分类问题,将物料库中的所有物品看作不同的类别。模型的目标是最大化用户对正样本物品的预测概率:
这里\(s(x,y)\)表示用户\(x\)和物品\(y\)的相似度分数,\(P(y|x,\theta)\)表示匹配概率,\(M\)表示整个物料库。由于物料库规模庞大,直接计算这个Softmax在计算上不可行,因此实际训练时采用负采样技术,为每个正样本采样一定数量的负样本来近似计算。
2.2.2.2.3. 双塔模型的细节¶
除了相对简单的模型结构外,双塔模型在实际应用中的一些关键细节同样值得深入探讨。这些细节往往决定了模型的最终效果,(Yi et al., 2019) 等研究对此进行了分析。
向量归一化:对用户塔和物品塔输出的Embedding进行L2归一化:
归一化的核心作用是解决向量点积的非度量性问题。原始的向量点积不满足三角不等式,可能导致“距离”计算的不一致性。例如,对于三个点\(A=(10,0)\)、\(B=(0,10)\)、\(C=(11,0)\),使用点积计算会得到\(\text{dist}(A,B) < \text{dist}(A,C)\),但这与直观的几何距离不符。
通过归一化,向量点积被转化为欧式距离的度量形式。对于归一化向量\(u\)和\(v\),它们的欧式距离为:
这种转换的关键意义在于训练与检索的一致性:模型训练时使用的相似度计算(归一化后的点积)与线上ANN检索系统使用的距离度量(欧式距离)本质上是等价的。这确保了离线训练学到的向量关系能够在线上检索中得到正确体现,避免了训练-服务不一致的问题。
温度系数调节:在归一化后的向量计算内积后,除以温度系数\(\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索引中,实现毫秒级的召回响应。
训练和评估
run_experiment('dssm')
+---------------+--------------+-----------+----------+----------------+---------------+
| 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 |
+---------------+--------------+-----------+----------+----------------+---------------+
2.2.2.3. YouTubeDNN:从匹配到预测用户下一行为¶
YouTube深度神经网络推荐系统 (Covington et al., 2016) 代表了双塔模型演进的一个重要里程碑。YouTubeDNN在架构上延续了双塔设计,但引入了一个关键的思想转变:将召回任务重新定义为“预测用户下一个会观看的视频”。
图2.2.6 YouTubeDNN候选生成模型架构¶
YouTubeDNN采用了“非对称”的双塔架构:用户塔集成了观看历史、搜索历史、人口统计学特征等多模态信息,用户观看的视频ID通过嵌入层映射后进行平均池化聚合,模型还引入了“Example Age”特征来建模内容新鲜度的影响;物品塔则相对简化,本质上是一个巨大的嵌入矩阵,每个视频对应一个可学习的向量,避免了复杂的物品特征工程。
这种“预测下一个观看视频”的任务设定,本质上类似于NLP中的next token预测,可以自然地建模为一个极端多分类问题:
这里\(w_t\)表示用户在时间\(t\)观看的视频,\(U\)是用户特征,\(C\)是上下文信息,\(V\)是整个视频库。由于视频库规模庞大,直接计算全量Softmax不可行,因此采用Sampled Softmax进行高效训练。
2.2.2.3.1. 关键的工程技巧¶
YouTubeDNN的成功不仅来自于模型设计,更来自于一系列精心设计的工程技巧:
非对称的时序分割:传统协同过滤通常随机保留验证项目,但这种做法存在未来信息泄露问题。视频消费具有明显的不对称模式——剧集通常按顺序观看,用户往往从热门内容开始逐步深入小众领域。因此,YouTubeDNN采用时序分割策略:对于作为预测目标的用户观看记录,只使用该目标之前的历史行为作为输入特征。这种“回滚”机制更符合真实的推荐场景。
图2.2.7 非对称共同观看模式¶
负采样策略:为了高效处理数百万类别的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]
])
这种设计的核心优势在于:用户塔可以根据业务需求灵活扩展特征和模型复杂度,而物品塔保持简洁高效,易于离线预计算和实时检索。
训练和评估
run_experiment('youtubednn')
+---------------+--------------+-----------+----------+----------------+---------------+
| 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 |
+---------------+--------------+-----------+----------+----------------+---------------+