跳转至

多兴趣召回与因果推荐

⚠️ 时效性说明:本章涉及前沿模型/价格/榜单等信息,可能随版本快速变化;请以论文原文、官方发布页和 API 文档为准。

多兴趣召回

前沿专题:多兴趣召回解决用户兴趣多样性表征问题,因果推荐解决系统偏差与反事实推理问题。两者均为工业界高频面试主题。

📋 学习目标

完成本章学习后,你应该能够:

  • 理解单兴趣向量的局限性,掌握多兴趣召回的动机
  • 掌握 MIND、ComiRec、SINE 等多兴趣召回模型的原理与实现
  • 了解多兴趣召回在工业系统中的工程实践要点
  • 理解推荐系统中的各类偏差问题及其因果分析
  • 掌握 IPS、Doubly Robust、因果去混淆等核心方法
  • 能够用 PyTorch 实现多兴趣召回与因果纠偏的核心模块

第一部分:多兴趣召回

1. 单兴趣向量的局限性

1.1 传统召回的单 Embedding 表征

在传统的双塔(Two-Tower)召回模型中,用户侧通常被编码为一个固定维度的向量:

\[\mathbf{v}_u = f_\theta(\text{用户特征}, \text{行为序列})\]

然后通过 ANN(近似最近邻)检索与该向量最相似的 item embedding。

1.2 问题所在

Text Only
用户小明的兴趣:
├── 科技数码(经常浏览手机评测)
├── 篮球体育(关注 NBA 赛事)
├── 日式料理(周末搜索餐厅)
└── 编程学习(偶尔看教程)

单向量表征 → 被平均化 → 一个"什么都像又什么都不像"的向量

核心矛盾: - 用户兴趣天然是多模态、多样化的 - 单个向量无法充分表征兴趣的多样性 - 检索时只能找到与"平均兴趣"最近的候选,利基兴趣被淹没

1.3 多兴趣召回的思路

将用户表征从 1 个向量 扩展为 K 个兴趣向量

\[\{\mathbf{v}_u^1, \mathbf{v}_u^2, \ldots, \mathbf{v}_u^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 架构概览

Text Only
用户行为序列: [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\) 轮):

  1. 初始化路由 logit \(b_{ij} = 0\),其中 \(i\) 表示行为 item,\(j\) 表示兴趣胶囊
  2. 对每轮迭代 \(t = 1, \ldots, T\)
  3. 计算路由权重:\(c_{ij} = \text{softmax}_j(b_{ij})\)
  4. 加权聚合:\(\mathbf{s}_j = \sum_i c_{ij} \cdot \mathbf{S} \cdot \mathbf{e}_i\)\(\mathbf{S}\) 为共享变换矩阵)
  5. 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\|}\)
  6. 更新路由 logit:\(b_{ij} \leftarrow b_{ij} + \mathbf{v}_j \cdot (\mathbf{S} \cdot \mathbf{e}_i)\)

2.4 Label-Aware Attention

训练时,需要根据 target item 选择最相关的兴趣向量计算损失:

\[\text{Attention}(\mathbf{v}_j, \mathbf{e}_t) = \frac{\exp(\mathbf{v}_j^T \mathbf{e}_t)}{\sum_{k=1}^K \exp(\mathbf{v}_k^T \mathbf{e}_t)}\]
\[\mathbf{v}_u = \sum_{j=1}^K \text{Attention}(\mathbf{v}_j, \mathbf{e}_t) \cdot \mathbf{v}_j\]

推理时,不需要 Label-Aware Attention,直接使用 K 个兴趣向量分别检索。

2.5 PyTorch 实现

Python
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{A} = \text{softmax}\left(\mathbf{W}_2 \cdot \tanh(\mathbf{W}_1 \cdot \mathbf{H}^T)\right)\]

其中 \(\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 候选后,使用带多样性控制的贪心选择:

\[\text{score}(i) = (1 - \lambda) \cdot \max_j \text{sim}(\mathbf{v}_j, \mathbf{e}_i) - \lambda \cdot \max_{i' \in S} \text{sim}(\mathbf{e}_i, \mathbf{e}_{i'})\]
  • \(\lambda = 0\):纯相关性,无多样性约束
  • \(\lambda = 1\):最大多样性
  • 工业中通常 \(\lambda \in [0.2, 0.5]\)

3.4 PyTorch 实现

Python
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 核心机制

Text Only
全局兴趣概念池(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 动态稀疏选择
概念共享 全局概念池
可解释性 较弱 概念-兴趣映射可解释
计算效率 较好 稀疏选择更高效
Python
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 的选择

Text Only
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 检索时多兴趣向量的处理

Python
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 实验要点

Text Only
多兴趣召回 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 偏差的恶性循环

Text Only
推荐策略偏向热门 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))\):干预概率(切断混淆路径)
Text Only
推荐系统的因果图示例:

    用户偏好 Z(混淆变量)
      ↙        ↘
推荐决策 T → 用户反馈 Y

\(Z\) 同时影响推荐策略(推荐什么)和用户反馈(是否点击),导致 \(T\)\(Y\) 的关联中混入了非因果部分。


8. IPS(逆倾向得分加权)

8.1 原理

倾向得分(Propensity Score)\(p(u, i)\):item \(i\) 被推荐给用户 \(u\) 的概率。

传统损失函数只在观察到的数据上计算,存在选择偏差。IPS 通过加权来纠正:

\[\hat{R}_{\text{IPS}}(\theta) = \frac{1}{|\mathcal{O}|} \sum_{(u,i) \in \mathcal{O}} \frac{\delta_{u,i}}{p(u,i)} \cdot \ell(f_\theta(u,i), y_{u,i})\]

其中: - \(\mathcal{O}\) 为观察到的交互集合 - \(\delta_{u,i}\) 为观察指示器 - \(p(u,i)\) 为倾向得分(越小说明越不容易被观察到,权重越大) - \(\ell\) 为损失函数

直觉:对那些虽然不太容易被曝光但被观察到的数据加大权重,纠正曝光偏差。

8.2 倾向得分估计

Python
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 加权训练

Python
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{R}_{\text{DR}} = \frac{1}{N} \sum_{(u,i)} \left[ \hat{y}_{u,i} + \frac{\delta_{u,i}}{p(u,i)} (y_{u,i} - \hat{y}_{u,i}) \right]\]

其中 \(\hat{y}_{u,i}\)填补模型(Imputation Model)对缺失数据的预测。

"双重稳健":只要倾向得分模型或填补模型中有一个是正确的,DR 估计就是无偏的。

9.2 直觉理解

Text Only
DR = 填补预测 + IPS 修正残差

当填补模型准确时 → 残差 ≈ 0 → DR ≈ 直接法(稳定)
当倾向得分准确时 → IPS 修正有效 → DR ≈ IPS(无偏)
两个都准确     → 最优
两个都不准确   → 仍然比单用任一个好

9.3 PyTorch 实现

Python
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):用户因为从众/流行度/展示位置而点击

Text Only
用户点击 = 兴趣驱动 + 遵从性驱动

因果图:
  Interest ──→ Click ←── Conformity
     ↑                       ↑
  用户偏好               流行度/位置

DICE 方法: 1. 为每个用户/item 学习两组 embedding:兴趣 embedding 和遵从性 embedding 2. 训练时使用因果架构显式分离两种影响 3. 推理时仅使用兴趣 embedding 进行推荐

Python
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 的差异来估计因果效应

Python
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 使用随机数据的因果推理方法
  • 能在面试中清晰回答因果推荐的核心问题

📚 参考资料

  1. Li et al. "Multi-Interest Network with Dynamic Routing for Recommendation at Tmall" (CIKM 2019)
  2. Cen et al. "Controllable Multi-Interest Framework for Recommendation" (KDD 2020)
  3. Tan et al. "Sparse-Interest Network for Sequential Recommendation" (WSDM 2021)
  4. Schnabel et al. "Recommendations as Treatments: Debiasing Learning and Evaluation" (ICML 2016)
  5. Wang et al. "Doubly Robust Joint Learning for Recommendation on Data Missing Not at Random" (ICML 2019)
  6. Zheng et al. "Disentangling User Interest and Conformity for Recommendation with Causal Embedding" (WWW 2021)
  7. Bonner & Vasile "Causal Embeddings for Recommendation" (RecSys 2018)

💡 下一步学习:结合 09-召回算法 理解传统召回方法,对比多兴趣召回的改进;结合 13-推荐系统评估 学习如何无偏地评估推荐系统。