多兴趣召回与因果推荐¶
⚠️ 时效性说明:本章涉及前沿模型/价格/榜单等信息,可能随版本快速变化;请以论文原文、官方发布页和 API 文档为准。
前沿专题:多兴趣召回解决用户兴趣多样性表征问题,因果推荐解决系统偏差与反事实推理问题。两者均为工业界高频面试主题。
📋 学习目标¶
完成本章学习后,你应该能够:
- 理解单兴趣向量的局限性,掌握多兴趣召回的动机
- 掌握 MIND、ComiRec、SINE 等多兴趣召回模型的原理与实现
- 了解多兴趣召回在工业系统中的工程实践要点
- 理解推荐系统中的各类偏差问题及其因果分析
- 掌握 IPS、Doubly Robust、因果去混淆等核心方法
- 能够用 PyTorch 实现多兴趣召回与因果纠偏的核心模块
第一部分:多兴趣召回¶
1. 单兴趣向量的局限性¶
1.1 传统召回的单 Embedding 表征¶
在传统的双塔(Two-Tower)召回模型中,用户侧通常被编码为一个固定维度的向量:
然后通过 ANN(近似最近邻)检索与该向量最相似的 item embedding。
1.2 问题所在¶
用户小明的兴趣:
├── 科技数码(经常浏览手机评测)
├── 篮球体育(关注 NBA 赛事)
├── 日式料理(周末搜索餐厅)
└── 编程学习(偶尔看教程)
单向量表征 → 被平均化 → 一个"什么都像又什么都不像"的向量
核心矛盾: - 用户兴趣天然是多模态、多样化的 - 单个向量无法充分表征兴趣的多样性 - 检索时只能找到与"平均兴趣"最近的候选,利基兴趣被淹没
1.3 多兴趣召回的思路¶
将用户表征从 1 个向量 扩展为 K 个兴趣向量:
每个向量捕获用户的一个兴趣簇,然后分别进行 ANN 检索,最终合并去重。
2. MIND 模型(Multi-Interest Network with Dynamic Routing)¶
📖 论文:Multi-Interest Network with Dynamic Routing for Recommendation at Tmall(CIKM 2019,阿里)
2.1 核心思想¶
MIND 借鉴了 胶囊网络(Capsule Network) 的动态路由机制,将用户行为序列中的 item embedding 聚类为 K 个兴趣胶囊(Interest Capsule),每个胶囊代表一个兴趣方向。
2.2 架构概览¶
用户行为序列: [item_1, item_2, ..., item_n]
│
▼
Embedding Layer
│
▼
┌─────────────────────────┐
│ Behavior-to-Interest │ ← 动态路由(Dynamic Routing)
│ (Capsule Layer) │
└─────────────────────────┘
│
▼
K 个兴趣向量 [v_1, v_2, ..., v_K]
│
▼
Label-Aware Attention(训练时)
│
▼
与 target item 计算相似度 → 损失函数
2.3 动态路由过程¶
输入:行为序列 item embedding \(\{\mathbf{e}_1, \mathbf{e}_2, \ldots, \mathbf{e}_n\}\)
路由迭代(共 \(T\) 轮):
- 初始化路由 logit \(b_{ij} = 0\),其中 \(i\) 表示行为 item,\(j\) 表示兴趣胶囊
- 对每轮迭代 \(t = 1, \ldots, T\):
- 计算路由权重:\(c_{ij} = \text{softmax}_j(b_{ij})\)
- 加权聚合:\(\mathbf{s}_j = \sum_i c_{ij} \cdot \mathbf{S} \cdot \mathbf{e}_i\)(\(\mathbf{S}\) 为共享变换矩阵)
- Squash 激活:\(\mathbf{v}_j = \text{squash}(\mathbf{s}_j) = \frac{\|\mathbf{s}_j\|^2}{1 + \|\mathbf{s}_j\|^2} \cdot \frac{\mathbf{s}_j}{\|\mathbf{s}_j\|}\)
- 更新路由 logit:\(b_{ij} \leftarrow b_{ij} + \mathbf{v}_j \cdot (\mathbf{S} \cdot \mathbf{e}_i)\)
2.4 Label-Aware Attention¶
训练时,需要根据 target item 选择最相关的兴趣向量计算损失:
推理时,不需要 Label-Aware Attention,直接使用 K 个兴趣向量分别检索。
2.5 PyTorch 实现¶
import torch
import torch.nn as nn
import torch.nn.functional as F
class DynamicRouting(nn.Module): # 继承nn.Module定义网络层
"""胶囊网络动态路由机制,用于提取多兴趣表征"""
def __init__(self, input_dim, num_caps, iter_rounds=3):
"""
Args:
input_dim: 输入 embedding 维度
num_caps: 兴趣胶囊数量 K
iter_rounds: 动态路由迭代轮数
"""
super().__init__() # super()调用父类方法
self.input_dim = input_dim
self.num_caps = num_caps
self.iter_rounds = iter_rounds
# 共享双线性变换矩阵
self.S = nn.Parameter(torch.randn(input_dim, input_dim) * 0.01)
@staticmethod # @staticmethod不需要实例即可调用
def squash(s):
"""Squash 激活函数:将向量长度压缩到 (0, 1)"""
s_norm = torch.norm(s, dim=-1, keepdim=True)
return (s_norm ** 2 / (1 + s_norm ** 2)) * (s / (s_norm + 1e-8))
def forward(self, item_embs, mask=None):
"""
Args:
item_embs: (batch, seq_len, dim) 行为序列 embedding
mask: (batch, seq_len) 有效 item 的 mask
Returns:
caps: (batch, num_caps, dim) K 个兴趣胶囊
"""
batch, seq_len, dim = item_embs.shape
# 变换: (batch, seq_len, dim)
u_hat = torch.matmul(item_embs, self.S) # (B, N, D)
# 初始化路由 logits: (batch, seq_len, num_caps)
b = torch.zeros(batch, seq_len, self.num_caps, device=item_embs.device)
for t in range(self.iter_rounds):
if mask is not None:
# 被 mask 的位置路由权重设为极小值
b = b.masked_fill(~mask.unsqueeze(-1), float('-inf')) # unsqueeze增加一个维度
# 数值稳定性:可添加 logits = logits.clamp(-10, 10)
b = b.clamp(-10, 10)
c = F.softmax(b, dim=-1) # (B, N, K) # F.xxx PyTorch函数式API
if mask is not None:
c = c.masked_fill(~mask.unsqueeze(-1), 0.0)
# 加权聚合: (B, K, D)
s = torch.einsum('bnk,bnd->bkd', c, u_hat)
caps = self.squash(s) # (B, K, D)
if t < self.iter_rounds - 1:
# 更新路由 logits
delta = torch.einsum('bkd,bnd->bnk', caps, u_hat)
b = b + delta
return caps
class MIND(nn.Module):
"""MIND: Multi-Interest Network with Dynamic Routing"""
def __init__(self, num_items, embed_dim, num_interests=4, routing_iters=3, padding_idx=0):
super().__init__()
self.num_interests = num_interests
self.item_embedding = nn.Embedding(num_items, embed_dim, padding_idx=padding_idx)
self.routing = DynamicRouting(embed_dim, num_interests, routing_iters)
self.output_mlp = nn.Sequential(
nn.Linear(embed_dim, embed_dim),
nn.ReLU(),
nn.Linear(embed_dim, embed_dim)
)
def forward(self, behavior_seq, target_item=None):
"""
Args:
behavior_seq: (batch, seq_len) 行为序列 item id
target_item: (batch,) 目标 item id(训练时使用)
Returns:
训练: 用户向量 (batch, dim)
推理: K 个兴趣向量 (batch, K, dim)
"""
mask = (behavior_seq != 0) # padding mask
item_embs = self.item_embedding(behavior_seq) # (B, N, D)
interest_caps = self.routing(item_embs, mask) # (B, K, D)
if target_item is not None and self.training:
# Label-Aware Attention
target_emb = self.item_embedding(target_item) # (B, D)
attn_scores = torch.einsum('bkd,bd->bk', interest_caps, target_emb)
attn_weights = F.softmax(attn_scores, dim=-1) # (B, K)
user_vec = torch.einsum('bk,bkd->bd', attn_weights, interest_caps)
return self.output_mlp(user_vec)
else:
# 推理:返回 K 个兴趣向量
return interest_caps
# ========== 使用示例 ==========
def mind_example():
model = MIND(num_items=10000, embed_dim=64, num_interests=4)
# 模拟一个 batch
behavior = torch.randint(1, 10000, (32, 50)) # 50 个历史行为
target = torch.randint(1, 10000, (32,))
# 训练模式
model.train() # train()训练模式
user_vec = model(behavior, target) # (32, 64)
print(f"训练 - 用户向量: {user_vec.shape}")
# 推理模式
model.eval()
with torch.no_grad(): # 禁用梯度计算,节省内存
interests = model(behavior) # (32, 4, 64)
print(f"推理 - 多兴趣向量: {interests.shape}")
if __name__ == "__main__":
mind_example()
3. ComiRec 模型(Controllable Multi-Interest Recommendation)¶
📖 论文:Controllable Multi-Interest Framework for Recommendation(KDD 2020,阿里)
3.1 两种变体¶
ComiRec 提出了两种多兴趣提取机制:
| 变体 | 机制 | 特点 |
|---|---|---|
| ComiRec-DR | Dynamic Routing(类似 MIND) | 更好捕捉聚类结构 |
| ComiRec-SA | Multi-Head Self-Attention | 计算更高效,效果相当 |
3.2 ComiRec-SA:基于多头自注意力¶
核心思想:用 多头自注意力(Multi-Head Attention)替代胶囊路由,每个 head 对应一个兴趣:
其中 \(\mathbf{H} \in \mathbb{R}^{n \times d}\) 是行为序列 embedding 矩阵,\(\mathbf{W}_1 \in \mathbb{R}^{d_a \times d}\),\(\mathbf{W}_2 \in \mathbb{R}^{K \times d_a}\)。
兴趣矩阵为:\(\mathbf{V}_u = \mathbf{A} \cdot \mathbf{H} \in \mathbb{R}^{K \times d}\)
3.3 可控多样性(Aggregation Module)¶
ComiRec 的亮点在于推理阶段的可控多样性。每个兴趣向量分别检索 top-N 候选后,使用带多样性控制的贪心选择:
- \(\lambda = 0\):纯相关性,无多样性约束
- \(\lambda = 1\):最大多样性
- 工业中通常 \(\lambda \in [0.2, 0.5]\)
3.4 PyTorch 实现¶
import torch
import torch.nn as nn
import torch.nn.functional as F
class ComiRecSA(nn.Module):
"""ComiRec-SA: 基于多头自注意力的多兴趣提取"""
def __init__(self, num_items, embed_dim, num_interests=4,
hidden_dim=64, padding_idx=0):
super().__init__()
self.num_interests = num_interests
self.item_embedding = nn.Embedding(num_items, embed_dim, padding_idx=padding_idx)
# 自注意力参数
self.W1 = nn.Linear(embed_dim, hidden_dim, bias=False)
self.W2 = nn.Linear(hidden_dim, num_interests, bias=False)
def forward(self, behavior_seq, target_item=None):
"""
Args:
behavior_seq: (batch, seq_len) 行为序列
target_item: (batch,) 目标 item(训练时)
"""
mask = (behavior_seq != 0)
H = self.item_embedding(behavior_seq) # (B, N, D)
# 多头注意力提取兴趣
attn = self.W2(torch.tanh(self.W1(H))) # (B, N, K)
# mask padding
attn = attn.masked_fill(~mask.unsqueeze(-1), float('-inf'))
A = F.softmax(attn, dim=1) # (B, N, K)
A = A.transpose(1, 2) # (B, K, N)
interest_vecs = torch.bmm(A, H) # (B, K, D)
if target_item is not None and self.training:
target_emb = self.item_embedding(target_item) # (B, D)
scores = torch.einsum('bkd,bd->bk', interest_vecs, target_emb)
weights = F.softmax(scores, dim=-1)
user_vec = torch.einsum('bk,bkd->bd', weights, interest_vecs)
return user_vec
return interest_vecs
@staticmethod
def controllable_select(interest_vecs, item_embs, top_n=50, lam=0.3):
"""
可控多样性聚合
Args:
interest_vecs: (K, D) 单个用户的 K 个兴趣向量
item_embs: (M, D) 候选 item embedding(多兴趣合并后)
top_n: 最终选择数量
lam: 多样性控制参数 [0, 1]
Returns:
selected: 选中的 item 索引列表
"""
K, D = interest_vecs.shape
M = item_embs.shape[0]
# 每个 item 与最相关兴趣的相似度
relevance = torch.mm(item_embs, interest_vecs.T).max(dim=1)[0] # (M,)
selected = []
selected_embs = []
for _ in range(min(top_n, M)):
if len(selected_embs) == 0:
diversity = torch.zeros(M, device=item_embs.device)
else:
stacked = torch.stack(selected_embs) # (|S|, D) # torch.stack沿新维度拼接张量
diversity = torch.mm(item_embs, stacked.T).max(dim=1)[0]
score = (1 - lam) * relevance - lam * diversity
# 已选中的设为 -inf
for idx in selected:
score[idx] = float('-inf')
best = score.argmax().item() # 将单元素张量转为Python数值
selected.append(best)
selected_embs.append(item_embs[best])
return selected
4. SINE 模型(Sparse-Interest Network)¶
📖 论文:Sparse-Interest Network for Sequential Recommendation(WSDM 2021,阿里)
4.1 动机¶
MIND 和 ComiRec 的兴趣向量数 K 是固定的,但实际用户的活跃兴趣数随时间动态变化。SINE 提出稀疏兴趣激活机制。
4.2 核心机制¶
全局兴趣概念池(Concept Pool)
C = {c_1, c_2, ..., c_P} (P 个共享概念原型)
│
▼
行为序列 → 概念激活(概率稀疏选择 top-K 概念)
│
▼
激活的稀疏兴趣子集 → 兴趣表征
关键设计: 1. 全局概念池:维护 \(P\) 个可学习的概念原型向量,所有用户共享 2. 稀疏激活:对每个用户/序列,仅激活 top-\(K\)(\(K \ll P\))个最相关概念 3. 注意力聚合:在激活的概念引导下,对行为序列做注意力聚合得到兴趣表征
4.3 优势¶
| 特性 | MIND/ComiRec | SINE |
|---|---|---|
| 兴趣数 | 固定 K | 动态稀疏选择 |
| 概念共享 | 无 | 全局概念池 |
| 可解释性 | 较弱 | 概念-兴趣映射可解释 |
| 计算效率 | 较好 | 稀疏选择更高效 |
class ConceptPool(nn.Module):
"""SINE 全局兴趣概念池"""
def __init__(self, num_concepts, embed_dim, top_k=4):
super().__init__()
self.num_concepts = num_concepts
self.top_k = top_k
# 可学习的概念原型
self.concepts = nn.Parameter(torch.randn(num_concepts, embed_dim) * 0.02)
def forward(self, user_repr):
"""
Args:
user_repr: (batch, dim) 用户粗粒度表征
Returns:
activated: (batch, top_k, dim) 激活的概念向量
indices: (batch, top_k) 激活的概念索引
"""
# 计算与所有概念的相似度
logits = torch.mm(user_repr, self.concepts.T) # (B, P)
# 稀疏选择 top-K
topk_vals, topk_ids = torch.topk(logits, self.top_k, dim=-1)
# Gumbel-Softmax 或直接 softmax
weights = F.softmax(topk_vals, dim=-1) # (B, K)
# 取出激活的概念
activated = self.concepts[topk_ids] # (B, K, D)
activated = activated * weights.unsqueeze(-1)
return activated, topk_ids
5. 多兴趣召回的工程实践¶
5.1 兴趣向量数 K 的选择¶
K 值选择经验:
├── K = 1: 退化为单兴趣,不推荐
├── K = 2~4: 适用于行为较短(<20)的用户
├── K = 4~8: 适用于行为中等(20~100)的场景,工业主流
├── K = 8~16: 适用于长行为序列(100+),但需注意计算开销
└── 自适应 K: 根据用户行为丰富度动态决定(进阶方案)
实践建议: - 线上通常 \(K=4\) 或 \(K=8\) 即可覆盖大部分用户 - K 过大会导致兴趣向量之间过于相似(坍缩问题) - 可通过监控兴趣向量间余弦相似度来检测坍缩
5.2 ANN 检索时多兴趣向量的处理¶
def multi_interest_retrieval(user_interests, ann_index, top_n_per_interest=100,
final_top_n=300):
"""
多兴趣向量 ANN 召回流程
Args:
user_interests: list of np.array, K 个兴趣向量
ann_index: Faiss / Milvus ANN 索引
top_n_per_interest: 每个兴趣检索数量
final_top_n: 最终返回数量
"""
all_candidates = {}
# Step 1: 每个兴趣向量独立检索
for i, vec in enumerate(user_interests): # enumerate同时获取索引和元素
distances, ids = ann_index.search(
vec.reshape(1, -1), top_n_per_interest # 重塑张量形状
)
for dist, item_id in zip(distances[0], ids[0]): # zip按位置配对
if item_id not in all_candidates or dist < all_candidates[item_id]['dist']:
all_candidates[item_id] = {
'dist': dist,
'interest_id': i,
'score': float(-dist) # 距离越小越好
}
# Step 2: 去重 + 按分数排序
sorted_items = sorted(all_candidates.items(), key=lambda x: x[1]['score'], reverse=True) # lambda匿名函数
# Step 3: 打散(确保多兴趣都有曝光)
result = interest_aware_shuffle(sorted_items, num_interests=len(user_interests))
return result[:final_top_n]
def interest_aware_shuffle(sorted_items, num_interests, window_size=10):
"""
兴趣打散策略:在每个窗口内确保不同兴趣来源的候选都有出现
"""
result = []
remaining = list(sorted_items)
while remaining and len(result) < len(sorted_items):
window = remaining[:window_size * num_interests]
# 按兴趣来源分组
by_interest = {}
for item_id, info in window:
iid = info['interest_id']
if iid not in by_interest:
by_interest[iid] = []
by_interest[iid].append((item_id, info))
# 轮流从每个兴趣取一个
added = set()
for _ in range(window_size):
for iid in range(num_interests):
if iid in by_interest and by_interest[iid]:
item = by_interest[iid].pop(0)
if item[0] not in added:
result.append(item)
added.add(item[0])
# 移除已添加的
remaining = [(iid, info) for iid, info in remaining if iid not in added]
return result
5.3 线上 AB 实验要点¶
多兴趣召回 AB 实验 Checklist:
┌─────────────────────────────────────────────────────────┐
│ 1. 分层实验:召回层单独分层,避免与排序实验交叉污染 │
│ 2. 核心指标: │
│ - 召回率@N / 命中率改善 │
│ - 候选多样性(类目覆盖率、信息熵) │
│ - 最终推荐列表 CTR / CVR / GMV │
│ 3. 分人群观察: │
│ - 高活跃 vs 低活跃用户的增益差异 │
│ - 新用户 vs 老用户 │
│ 4. 兴趣向量质量监控: │
│ - K 个向量间的平均余弦相似度(检测坍缩) │
│ - 各兴趣向量的召回贡献比例 │
│ 5. 系统性能: │
│ - ANN 检索 QPS(K 次检索的延迟开销) │
│ - 向量存储空间(用户数 × K × 维度) │
└─────────────────────────────────────────────────────────┘
第二部分:因果推荐¶
6. 推荐系统中的偏差问题¶
6.1 四大核心偏差¶
| 偏差类型 | 定义 | 示例 |
|---|---|---|
| 选择偏差 (Selection Bias) | 用户只对曝光的 item 有反馈 | 用户只能评价看过的电影 |
| 位置偏差 (Position Bias) | 展示位置影响点击概率 | 排第 1 的比排第 10 的点击率高数倍 |
| 流行度偏差 (Popularity Bias) | 热门 item 获得更多曝光和反馈 | 马太效应:热门越来越热 |
| 曝光偏差 (Exposure Bias) | 推荐策略决定了用户能看到什么 | 协同过滤倾向推荐相似 item |
6.2 偏差的恶性循环¶
推荐策略偏向热门 item
↓
热门 item 获得更多曝光
↓
更多用户点击热门 item
↓
训练数据中热门 item 正样本更多
↓
模型学到"热门 = 好" 的虚假关联
↓
推荐策略更偏向热门 item ← 恶性循环!
6.3 因果视角的重新审视¶
关联 ≠ 因果:用户点击了推荐的 item,不代表用户真正喜欢它。
反事实问题(Counterfactual):如果用户没有看到这个推荐,TA 还会主动寻找并消费这个 item 吗?
7. 因果推断基础在推荐中的应用¶
7.1 Rubin 因果模型(Potential Outcomes Framework)¶
对于用户-item 对 \((u, i)\): - \(Y_i(1)\):item 被推荐时的潜在结果(如点击概率) - \(Y_i(0)\):item 未被推荐时的潜在结果 - 因果效应 = \(Y_i(1) - Y_i(0)\)(个体处理效应 ITE)
核心困难:反事实不可观测——我们只能观察到用户在推荐/未推荐其中一种情况下的行为。
7.2 do-calculus 与因果图¶
用 do-算子区分"观察到"与"干预":
- \(P(Y|X)\):观察条件概率(包含混淆因素)
- \(P(Y|do(X))\):干预概率(切断混淆路径)
\(Z\) 同时影响推荐策略(推荐什么)和用户反馈(是否点击),导致 \(T\) 和 \(Y\) 的关联中混入了非因果部分。
8. IPS(逆倾向得分加权)¶
8.1 原理¶
倾向得分(Propensity Score)\(p(u, i)\):item \(i\) 被推荐给用户 \(u\) 的概率。
传统损失函数只在观察到的数据上计算,存在选择偏差。IPS 通过加权来纠正:
其中: - \(\mathcal{O}\) 为观察到的交互集合 - \(\delta_{u,i}\) 为观察指示器 - \(p(u,i)\) 为倾向得分(越小说明越不容易被观察到,权重越大) - \(\ell\) 为损失函数
直觉:对那些虽然不太容易被曝光但被观察到的数据加大权重,纠正曝光偏差。
8.2 倾向得分估计¶
import numpy as np
def estimate_propensity_naive(interaction_matrix):
"""
基于流行度的简单倾向得分估计
Args:
interaction_matrix: (num_users, num_items) 交互矩阵,1表示有交互
Returns:
propensity: (num_items,) 每个 item 的倾向得分
"""
item_pop = interaction_matrix.sum(axis=0) # 每个 item 的交互次数
# 归一化到 (0, 1]
propensity = item_pop / item_pop.max()
# 裁剪避免极端值
propensity = np.clip(propensity, 0.01, 1.0)
return propensity
8.3 IPS 加权训练¶
import torch
import torch.nn as nn
class IPSWeightedLoss(nn.Module):
"""IPS 加权损失函数"""
def __init__(self, clip_min=0.01, clip_max=1.0):
super().__init__()
self.clip_min = clip_min
self.clip_max = clip_max
def forward(self, predictions, labels, propensity_scores):
"""
Args:
predictions: (batch,) 模型预测值
labels: (batch,) 真实标签
propensity_scores: (batch,) 倾向得分
"""
# 裁剪倾向得分,防止方差爆炸
clipped_ps = torch.clamp(propensity_scores, self.clip_min, self.clip_max)
# IPS 权重
weights = 1.0 / clipped_ps
# 归一化权重(Self-Normalized IPS,降低方差)
weights = weights / weights.sum() * len(weights)
# 加权 BCE 损失
bce = nn.functional.binary_cross_entropy_with_logits(
predictions, labels, reduction='none'
)
return (weights * bce).mean()
# 使用示例
def train_with_ips(model, optimizer, dataloader, propensity_fn):
"""IPS 加权训练循环"""
ips_loss_fn = IPSWeightedLoss(clip_min=0.05)
model.train()
for batch in dataloader:
user_ids, item_ids, labels = batch
predictions = model(user_ids, item_ids).squeeze() # squeeze压缩维度
# 获取倾向得分
ps = propensity_fn(item_ids)
loss = ips_loss_fn(predictions, labels.float(), ps)
optimizer.zero_grad() # 清零梯度
loss.backward() # 反向传播计算梯度
optimizer.step() # 更新参数
8.4 IPS 的问题¶
- 高方差:当倾向得分很小时,权重 \(1/p\) 会非常大,导致估计不稳定
- 常用缓解手段:裁剪(Clipping)、自归一化(SNIPS)
9. Doubly Robust 估计¶
9.1 原理¶
Doubly Robust(DR)结合了直接法(Direct Method)和 IPS 的优势:
其中 \(\hat{y}_{u,i}\) 是填补模型(Imputation Model)对缺失数据的预测。
"双重稳健":只要倾向得分模型或填补模型中有一个是正确的,DR 估计就是无偏的。
9.2 直觉理解¶
DR = 填补预测 + IPS 修正残差
当填补模型准确时 → 残差 ≈ 0 → DR ≈ 直接法(稳定)
当倾向得分准确时 → IPS 修正有效 → DR ≈ IPS(无偏)
两个都准确 → 最优
两个都不准确 → 仍然比单用任一个好
9.3 PyTorch 实现¶
import torch
import torch.nn as nn
class DoublyRobustLoss(nn.Module):
"""Doubly Robust 损失函数"""
def __init__(self, clip_min=0.05, clip_max=1.0):
super().__init__()
self.clip_min = clip_min
self.clip_max = clip_max
def forward(self, predictions, labels, propensity_scores,
imputed_labels, observed_mask):
"""
Args:
predictions: (batch,) 模型预测
labels: (batch,) 真实标签(仅 observed 部分有效)
propensity_scores: (batch,) 倾向得分
imputed_labels: (batch,) 填补模型的预测
observed_mask: (batch,) 是否被观测到 (0/1)
"""
clipped_ps = torch.clamp(propensity_scores, self.clip_min, self.clip_max)
# Direct Method 部分:对所有样本
loss_dm = nn.functional.binary_cross_entropy_with_logits(
predictions, imputed_labels, reduction='none'
)
# IPS 修正部分:仅对观测样本
loss_observed = nn.functional.binary_cross_entropy_with_logits(
predictions, labels, reduction='none'
)
loss_imputed = nn.functional.binary_cross_entropy_with_logits(
predictions, imputed_labels, reduction='none'
)
residual = (loss_observed - loss_imputed) * observed_mask / clipped_ps
# DR = DM + IPS correction
dr_loss = loss_dm + residual
return dr_loss.mean()
class DoublyRobustTrainer:
"""DR 训练框架"""
def __init__(self, main_model, imputation_model, propensity_model):
self.main_model = main_model
self.imputation_model = imputation_model
self.propensity_model = propensity_model
self.dr_loss = DoublyRobustLoss()
def train_step(self, batch, optimizer):
user_ids, item_ids, labels, observed_mask = batch
# Step 1: 获取填补预测(detach,不参与梯度)
with torch.no_grad():
imputed = torch.sigmoid(self.imputation_model(user_ids, item_ids).squeeze())
propensity = self.propensity_model(item_ids)
# Step 2: 主模型前向
predictions = self.main_model(user_ids, item_ids).squeeze()
# Step 3: DR 损失
loss = self.dr_loss(predictions, labels.float(), propensity,
imputed, observed_mask.float())
optimizer.zero_grad()
loss.backward()
optimizer.step()
return loss.item()
10. 因果去混淆¶
10.1 DICE:解耦兴趣和遵从性¶
📖 论文:Disentangling User Interest and Conformity for Recommendation with Causal Embedding(WWW 2021)
核心观点:用户的点击行为可以分解为两部分: - 兴趣(Interest):用户真正喜欢这个 item - 遵从性(Conformity):用户因为从众/流行度/展示位置而点击
DICE 方法: 1. 为每个用户/item 学习两组 embedding:兴趣 embedding 和遵从性 embedding 2. 训练时使用因果架构显式分离两种影响 3. 推理时仅使用兴趣 embedding 进行推荐
class DICE(nn.Module):
"""DICE: 解耦兴趣和遵从性"""
def __init__(self, num_users, num_items, embed_dim):
super().__init__()
# 兴趣 embedding
self.user_interest = nn.Embedding(num_users, embed_dim)
self.item_interest = nn.Embedding(num_items, embed_dim)
# 遵从性 embedding
self.user_conformity = nn.Embedding(num_users, embed_dim)
self.item_popularity = nn.Embedding(num_items, embed_dim)
def forward(self, user_ids, item_ids, mode='train'):
# 兴趣得分
u_int = self.user_interest(user_ids)
i_int = self.item_interest(item_ids)
interest_score = (u_int * i_int).sum(dim=-1)
if mode == 'inference':
# 推理时仅使用兴趣得分
return interest_score
# 遵从性得分
u_conf = self.user_conformity(user_ids)
i_pop = self.item_popularity(item_ids)
conformity_score = (u_conf * i_pop).sum(dim=-1)
return interest_score, conformity_score
def compute_loss(self, user_ids, pos_items, neg_items):
"""
DICE 训练:正负样本需要区分 click 来源
"""
# 正样本
int_pos, conf_pos = self.forward(user_ids, pos_items, mode='train')
# 负样本
int_neg, conf_neg = self.forward(user_ids, neg_items, mode='train')
# 兴趣损失:正 > 负
interest_loss = -torch.log(
torch.sigmoid(int_pos - int_neg) + 1e-8
).mean()
# 遵从性损失:正样本中的从众部分不应过大
# 使用因果正则化
conformity_loss = -torch.log(
torch.sigmoid(conf_pos) + 1e-8
).mean()
# 解耦正则:兴趣和遵从 embedding 应尽量正交
discrepancy = (
(self.user_interest.weight * self.user_conformity.weight).sum(dim=-1).pow(2).mean()
+ (self.item_interest.weight * self.item_popularity.weight).sum(dim=-1).pow(2).mean()
)
return interest_loss + 0.1 * conformity_loss + 0.01 * discrepancy
10.2 CausE:反事实数据增强¶
📖 论文:Causal Embeddings for Recommendation(RecSys 2018)
核心思路: 1. 收集一小部分随机曝光的数据(不受推荐策略偏差影响) 2. 用随机数据作为"治疗组",常规数据作为"对照组" 3. 学习两组 embedding 的差异来估计因果效应
class CausE(nn.Module):
"""CausE: 因果 Embedding"""
def __init__(self, num_users, num_items, embed_dim):
super().__init__()
self.user_emb = nn.Embedding(num_users, embed_dim)
# 两套 item embedding
self.item_emb_control = nn.Embedding(num_items, embed_dim) # 常规数据
self.item_emb_treatment = nn.Embedding(num_items, embed_dim) # 随机数据
def forward(self, user_ids, item_ids, is_random=False):
u = self.user_emb(user_ids)
if is_random:
i = self.item_emb_treatment(item_ids)
else:
i = self.item_emb_control(item_ids)
return (u * i).sum(dim=-1)
def compute_loss(self, batch_control, batch_treatment, reg_weight=0.1):
"""
Args:
batch_control: (user, item, label) 常规推荐数据
batch_treatment: (user, item, label) 随机曝光数据
"""
u_c, i_c, y_c = batch_control
u_t, i_t, y_t = batch_treatment
# 常规数据损失
pred_c = self.forward(u_c, i_c, is_random=False)
loss_c = nn.functional.binary_cross_entropy_with_logits(pred_c, y_c.float())
# 随机数据损失
pred_t = self.forward(u_t, i_t, is_random=True)
loss_t = nn.functional.binary_cross_entropy_with_logits(pred_t, y_t.float())
# 因果正则:两套 item embedding 不应差异过大
shared_items = torch.unique(torch.cat([i_c, i_t])) # torch.cat沿已有维度拼接张量
emb_diff = (
self.item_emb_control(shared_items) - self.item_emb_treatment(shared_items)
).pow(2).mean()
return loss_c + loss_t + reg_weight * emb_diff
11. 面试常考题¶
多兴趣召回(4 题)¶
题目 1:为什么单向量不够,多兴趣召回的核心动机是什么?¶
💡 参考答案
**核心矛盾**:用户兴趣天然是多样化的,但单个 embedding 向量只能表征一个"平均兴趣"。 具体问题: 1. **信息瓶颈**:将丰富的行为序列压缩到一个向量,损失大量信息 2. **利基兴趣湮没**:如果用户 80% 的行为是科技类,20% 是美食类,单向量会偏向科技,美食类召回困难 3. **ANN 检索局限**:单向量只能找到一个"最近邻邻域",无法覆盖多个兴趣空间 多兴趣召回通过 K 个向量分别表征不同兴趣簇,每个向量独立 ANN 检索,显著提升召回的覆盖率和多样性。题目 2:MIND 中动态路由的作用是什么?为什么不直接用 K-Means 聚类?¶
💡 参考答案
**动态路由的作用**:将行为序列中的 item embedding 自适应地聚合为 K 个兴趣胶囊,类似于"软聚类"。 **与 K-Means 的区别**: 1. **可微分**:动态路由是端到端可训练的,梯度可以回传到 embedding 层;K-Means 不可微 2. **上下文感知**:路由权重依赖于全局聚合结果(迭代更新),K-Means 只看局部距离 3. **Squash 激活**:输出向量长度表征"置信度",短向量 = 低置信兴趣 4. **训练效率**:路由集成在网络前向传播中,无需额外聚类步骤 K-Means 的问题:不可导、簇中心更新需要全局数据、无法与下游任务端到端优化。题目 3:多兴趣召回线上部署时,K 个向量如何检索?有什么工程挑战?¶
💡 参考答案
**检索流程**: 1. 模型推理生成 K 个兴趣向量 2. 每个向量独立查询 ANN 索引,各取 top-N 3. 合并 K×N 候选 → 去重 → 打散 → 截断 **工程挑战**: - **延迟**:K 次 ANN 查询,延迟约为单次的 K 倍。解决:批量查询、向量拼接后一次查询 - **存储**:用户向量存储从 1 份变 K 份。解决:定期更新 + 增量更新 - **打散策略**:避免某个强兴趣垄断结果,需要设计兴趣公平的打散规则 - **K 值选择**:K 太小表征不够,K 太大向量坍缩且检索开销大 - **实时性**:用户兴趣变化快,需要近实时更新兴趣向量题目 4:ComiRec 的可控多样性机制是如何工作的?¶
💡 参考答案
ComiRec 在推荐阶段引入了**多样性控制参数 λ**: $$\text{score}(i) = (1-\lambda) \cdot \text{relevance}(i) - \lambda \cdot \text{redundancy}(i)$$ - relevance:item 与最相关兴趣向量的相似度 - redundancy:item 与已选集合中最相似 item 的相似度 贪心选择过程类似 **MMR(Maximal Marginal Relevance)**: 1. λ=0 → 纯相关性排序 2. λ=1 → 最大多样性 3. 工业中 λ∈[0.2, 0.5] 实际应用中,λ 可以作为 AB 实验参数调优,或根据用户类型个性化设置(新用户适合较大 λ 增加探索)。因果推荐(4 题)¶
题目 5:推荐系统中的位置偏差是什么?如何消除?¶
💡 参考答案
**位置偏差**:用户倾向于点击列表靠前位置的 item,即使后面的 item 更相关。 **量化**:$P(\text{click}|u,i,\text{pos}) = P(\text{examine}|\text{pos}) \cdot P(\text{click}|u,i,\text{examined})$ **消除方法**: 1. **IPS 方法**:估计位置的曝光概率作为倾向得分,加权训练 2. **PAL(Position-Aware Learning)**:训练时引入位置特征,推理时去除位置影响 3. **Unbiased Learning to Rank**:基于 examination hypothesis,显式建模"是否被查看" 4. **随机实验**:偶尔随机打散排序,收集无偏数据 **工业实践**:许多公司在训练时加入位置特征,推理时将位置设为固定值(如中位位置)来消除偏差。题目 6:IPS 的核心思想是什么?有什么局限?如何改进?¶
💡 参考答案
**核心思想**:通过对每个样本赋予 $1/p(u,i)$ 的权重来纠正选择偏差,使得在偏差数据上的加权期望等于无偏期望。 **局限**: 1. **高方差**:当 $p(u,i)$ 很小时,$1/p$ 极大,导致估计不稳定 2. **倾向得分估计困难**:真实倾向得分难以准确估计,错误的倾向得分会引入新的偏差 3. **仅适用于 MNAR(Missing Not At Random)**:假设缺失机制已知 **改进方案**: - **Clipping**:裁剪极端权重,$\min(1/p, M)$ - **SNIPS(Self-Normalized)**:$\sum w_i \ell_i / \sum w_i$,权重归一化 - **Doubly Robust**:结合 IPS 和直接法,双重稳健 - **CVIB**:控制变量方法降低方差题目 7:Doubly Robust 为什么叫"双重稳健"?什么情况下会失效?¶
💡 参考答案
**双重稳健**:DR 估计只要 **倾向得分模型** 或 **填补模型** 中有一个是正确的,最终估计就是无偏的。 $$\hat{R}_{DR} = \hat{y}_{u,i} + \frac{\delta}{p}(y - \hat{y}_{u,i})$$ - 若填补模型准确:$\hat{y} ≈ y$ → 修正项 ≈ 0 → 即使 $p$ 不准也无所谓 - 若倾向得分准确:IPS 修正有效 → 即使 $\hat{y}$ 不准也能纠正 - 两个都不准确时:DR 仍然比单独使用任一个更稳健(但不保证无偏) **失效情况**: 1. 两个模型都严重偏差 2. 倾向得分接近 0 且填补模型不准 → 方差仍然很大 3. 数据量极小,无法可靠估计任何一个模型题目 8:DICE 如何解耦兴趣和遵从性?在实际中有什么价值?¶
💡 参考答案
**DICE 的解耦方法**: 1. 为用户/item 分别学习两组 embedding:兴趣 embedding 和遵从性 embedding 2. 预测得分 = 兴趣得分 + 遵从性得分 3. 添加正交正则化,促使两组 embedding 捕获不同信息 4. 推理时仅使用兴趣 embedding,去除遵从性影响 **实际价值**: - **消除流行度偏差**:推荐基于真实兴趣而非从众行为 - **提升长尾 item 曝光**:不再被热门 item 的马太效应主导 - **改善用户体验**:减少"信息茧房"效应 - **赋能精准营销**:区分"真正喜欢"和"随大流"的用户 **工业意义**:尤其适用于内容推荐(如短视频),帮助识别"被动消费"(刷到就看)和"主动需求"(真正感兴趣)。12. 学习检查清单¶
多兴趣召回¶
- 能解释单兴趣向量为什么不够
- 能描述 MIND 模型的架构和动态路由过程
- 能手写 CapsuleLayer 的核心代码
- 了解 ComiRec-SA 和 ComiRec-DR 的区别
- 理解可控多样性参数 λ 的作用
- 了解 SINE 稀疏兴趣网络的动机和设计
- 知道多兴趣召回线上部署的关键工程问题
- 能回答"K 怎么选"和"多向量如何检索"
因果推荐¶
- 能列举推荐系统中 4 种主要偏差
- 理解反事实推理的基本思想
- 能写出 IPS 的公式并解释直觉
- 知道 IPS 的局限和 SNIPS / Clipping 改进
- 理解 Doubly Robust 为什么"双重稳健"
- 能描述 DICE 的解耦思路
- 了解 CausE 使用随机数据的因果推理方法
- 能在面试中清晰回答因果推荐的核心问题
📚 参考资料¶
- Li et al. "Multi-Interest Network with Dynamic Routing for Recommendation at Tmall" (CIKM 2019)
- Cen et al. "Controllable Multi-Interest Framework for Recommendation" (KDD 2020)
- Tan et al. "Sparse-Interest Network for Sequential Recommendation" (WSDM 2021)
- Schnabel et al. "Recommendations as Treatments: Debiasing Learning and Evaluation" (ICML 2016)
- Wang et al. "Doubly Robust Joint Learning for Recommendation on Data Missing Not at Random" (ICML 2019)
- Zheng et al. "Disentangling User Interest and Conformity for Recommendation with Causal Embedding" (WWW 2021)
- Bonner & Vasile "Causal Embeddings for Recommendation" (RecSys 2018)
💡 下一步学习:结合 09-召回算法 理解传统召回方法,对比多兴趣召回的改进;结合 13-推荐系统评估 学习如何无偏地评估推荐系统。
