2.3.1. 深化用户兴趣表示¶
传统的向量召回方法,如双塔模型,倾向于将用户所有的历史行为“压扁”成一个单一的静态向量。这种“平均化”的处理方式虽然高效,但在两个关键方面存在明显局限:首先,它无法表达用户兴趣的多样性,例如一个用户可能既是需要购买专业书籍的程序员,又是一位需要选购婴儿用品的新手父亲,单一向量很难同时兼顾这两种截然不同的需求;其次,它忽略了兴趣的时效性,无法区分用户长期稳定的爱好(如对摄影的持续关注)和临时的即时需求(如今天突然搜索“感冒药”),而后者往往更能预示下一次的交互行为。
为了构建一个更丰富、更立体的用户画像以实现更精准的召回,研究者们沿着“深化用户兴趣表示”的路径进行了持续探索。本章将介绍其中的两个代表性模型:MIND 和 SDM。我们将首先探讨如何使用多个向量来表示用户的多元兴趣,然后在此基础上,进一步融入时间维度,学习如何动态地捕捉用户兴趣的演化。
2.3.1.1. MIND:用多个向量捕捉用户的多元兴趣¶
想象一下,你在淘宝上的购物历史:今天买了一本编程书,昨天买了运动鞋,上周买了咖啡豆。如果推荐系统只用一个数字向量来描述你,就像是用一个标签来概括一个人的全部——显然是不够的。
MIND (Multi-Interest Network with Dynamic Routing) (Li et al., 2019) 模型提出了一个更符合直觉的想法:既然用户有多种兴趣,为什么不用多个向量来分别表示呢?就像给每个兴趣爱好都分配一个专门的“代言人”。
这个模型的巧妙之处在于借鉴了胶囊网络的动态路由思想。简单来说,它会自动把你的行为按照兴趣类型进行分组——编程相关的行为归为一类,运动相关的归为另一类,美食相关的又是一类。每一类都会生成一个专门的兴趣向量,这样推荐系统就能更精准地理解你在不同场景下的需求。
图2.3.1 MIND模型整体结构¶
从整体架构来看,除了常规的Embedding层,MIND模型还包含了两个重要的组件:多兴趣提取层和Label-Aware注意力层。
多兴趣提取
MIND模型的多兴趣提取技术源于对胶囊网络动态路由机制的创新性改进。胶囊网络 (Sabour et al., 2017) 最初在计算机视觉领域被提出,其核心思想是用向量而非标量来表示特征,向量的方向编码属性信息,长度表示存在概率。动态路由则是确定不同层级胶囊之间连接强度的算法,它通过迭代优化的方式实现输入特征的软聚类。这种软聚类机制的优势在于,它不需要预先定义聚类数量或类别边界,而是让数据自然地分组,这正好契合了用户兴趣发现的需求。MIND模型引入了这一思想并提出了行为到兴趣(Behavior to Interest,B2I)动态路由:将用户的历史行为视为行为胶囊,将用户的多重兴趣视为兴趣胶囊,通过动态路由算法将相关的行为聚合到对应的兴趣维度中。MIND模型针对推荐系统的特点对原始动态路由算法进行了三个关键改进:
共享变换矩阵。与原始胶囊网络为每对胶囊使用独立变换矩阵不同,MIND采用共享的双线性映射矩阵 \(S \in \mathbb{R}^{d \times d}\)。这种设计有两个重要考虑:首先,用户行为序列长度变化很大,从几十到几百不等,共享矩阵确保了算法的通用性 ;其次,共享变换保证所有兴趣向量位于同一表示空间,便于后续的相似度计算和检索操作。路由连接强度的计算公式为:
(2.3.1)¶\[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引入了动态兴趣数量机制:
(2.3.2)¶\[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操作得到。
(2.3.3)¶\[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}\) 的转换。
(2.3.4)¶\[\boldsymbol{z}_j = \sum_{i\in \mathcal{I}_u} w_{ij} \boldsymbol{S} \boldsymbol{e}_i\]这一步是聚类的核心:根据刚刚算出的权重,将相关的用户行为聚合起来,形成代表特定兴趣的向量。
非线性压缩 : 为了将向量的模长(长度)约束在 [0, 1) 区间内,同时不改变其方向,模型使用了一个非线性的“squash”函数,从而得到本轮迭代的最终兴趣胶囊向量 \(\boldsymbol{u}_j\)。向量的长度可以被解释为该兴趣存在的概率,而其方向则编码了兴趣的具体属性。
(2.3.5)¶\[\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}\)。
(2.3.6)¶\[b_{ij} \leftarrow b_{ij} + \boldsymbol{u}_j^T \boldsymbol{S} \boldsymbol{e}_i\]
以上四个步骤会重复进行固定的次数(通常为3次),最终输出收敛后的兴趣胶囊向量集合 \(\{\boldsymbol{u}_j, j=1,...,K_{u}^\prime\}\) 作为该用户的多兴趣表示。
核心代码
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
标签感知的注意力机制
多兴趣提取层生成了用户的多个兴趣向量,但在训练阶段,我们需要确定哪个兴趣向量与当前的目标商品最相关。因为在训练时,我们拥有‘正确答案’(即用户实际点击的下一个商品),所以可以利用这个‘标签’信息,来反向监督模型,让模型学会在多个兴趣向量中,挑出与正确答案最相关的那一个。这相当于在训练阶段给模型一个明确的指引。MIND模型设计了标签感知注意力层来解决这个问题。
该注意力机制以目标商品向量作为查询,以用户的多个兴趣向量作为键和值。具体计算过程如下:
其中\(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\)趋于无穷时,机制退化为硬注意力,只选择相似度最高的兴趣向量。实验表明,使用较大的\(p\)值能够加快模型收敛速度。
通过标签感知得到用户向量\(v_u\)后,MIND模型的训练目标就是让用户向量与其真实交互的商品尽可能“匹配”。具体来说,模型会最大化用户与正样本商品的相似度,同时最小化与负样本的相似度。由于商品库通常非常庞大,直接计算所有商品的概率分布在计算上不现实,因此MIND采用了和YouTubeDNN相同的策略——使用Sampled Softmax损失函数,通过随机采样一小部分负样本来近似全局的归一化计算。
标签感知注意力的实现比较直观,核心是使用目标商品向量作为查询,计算与各个兴趣向量的相似度:
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
训练和评估
from funrec import run_experiment
run_experiment('mind')
+---------------+--------------+-----------+----------+----------------+---------------+
| 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 |
+---------------+--------------+-----------+----------+----------------+---------------+
2.3.1.2. SDM:融合长短期兴趣,捕捉动态变化¶
MIND解决了兴趣“广度”的问题,但新的问题随之而来:时间。 用户兴趣不仅是多元的,还是动态演化的。一个用户在一次购物会话(Session)中的行为,往往比他一个月前的行为更能预示他下一刻的需求。MIND虽然能捕捉多个兴趣,但并未在结构上显式地区分它们的时效性。序列深度匹配模型(SDM) (Lv et al., 2019) 正是为了解决这一问题而提出的。SDM模型 的核心思想是分别建模用户的短期即时兴趣和长期稳定偏好,然后智能地融合它们。
图2.3.2 SDM模型结构¶
捕捉短期兴趣
为了精准捕捉短期兴趣,SDM设计了一个三层结构来处理用户的当前会话序列(图2.3.2 左下角) 。
首先,将短期会话中的商品序列输入LSTM网络,学习序列间的时序依赖关系。LSTM的标准计算过程为:
这里\(\boldsymbol{e}_{i_{t}^{u}}\)表示第\(t\)个时间步的商品Embedding,\(\sigma\)表示sigmoid激活函数,\(\boldsymbol{W}\)表示权重矩阵,\(b\)表示偏置向量。LSTM采用多输入多输出模式,每个时间步都输出隐藏状态\(\boldsymbol{h}_{t}^{u} \in \mathbb{R}^{d \times 1}\),最终得到序列表示\(\boldsymbol{X}^{u} = [\boldsymbol{h}_{1}^{u}, \ldots, \boldsymbol{h}_{t}^{u}]\)。
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} \in \mathbb{R}^{d \times 1}\)。
核心代码
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}^{u}_{k}\),然后使用用户画像\(\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{e}_{u}\)、短期兴趣\(\boldsymbol{s}_{t}^{u}\)和长期兴趣\(\boldsymbol{p}^{u}\),输出的门控向量每个元素值介于0到1之间,决定了对应维度上长短期兴趣的贡献比例。这让模型能够在不同兴趣维度上分别选择保留长期或短期信息,避免简单平均可能带来的信息损失,使模型能够精确捕捉长期行为中与当前兴趣最相关的部分。
核心代码
门控融合机制通过学习三个权重矩阵来决定如何融合长短期兴趣:
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]
训练和评估
run_experiment('sdm')
+---------------+--------------+-----------+----------+----------------+---------------+
| 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 |
+---------------+--------------+-----------+----------+----------------+---------------+