CH 12 / SOURCE EXAMPLE

target_attention.py

ch12 精排模型/code/ch04_attention/target_attention.py

这个示例讲什么

│ ├── target_attention.py Target Attention(代码 12.3)+ 缩放点积对照

Target Attention(目标注意力)精排示例(第12章 §12.4.3.2) 本书依据:《第十二章-精排模型.pdf》§12.4.3.2,代码 12.3。 推荐精排中的 Target Attention 用「当前候选物品」对用户行为序列做加权汇聚, 从而提取与候选相关的兴趣表示。它与缩放点积 Cross-Attention 的主要区别在于: 打分函数是一个可学习的非线性 MLP,而非简单的向量内积。 缩放点积 Cross-Attention:α_i = softmax( (e_a · e_i) / √d ) Target Attention(DIN 风格):α_i = MLP([e_a; e_i; e_a−e_i; e_a⊙e_i]) 后者能将候选、历史行为及两者之间的差异与交互作为打分的输入, 因此当「差分」或「元素乘积」携带有效判别信息时,MLP 打分

上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。

源码结构与阅读入口

  1. _auc · 第 35 行
  2. TargetAttention · 第 46 行DIN 风格目标注意力(正文代码 12.3)。
  3. TargetAttentionUnnormalized · 第 92 行DIN 原文的「局部激活单元」:直接用原始相关性打分做加权求和,不做 softmax 归一化。
  4. ScaledDotCrossAttention · 第 116 行缩放点积 Cross-Attention:α_i = softmax((W_q e_a)·(W_k e_i) / √d)。
  5. CTRPredictor · 第 140 行嵌入 → 行为序列汇聚 → MLP → CTR。
  6. make_data · 第 208 行用户偏好与物品类目绑定:候选物品与偏好同类目时点击率高;

完整源码 · 332 行

1"""2Target Attention(目标注意力)精排示例(第12章 §12.4.3.2)34本书依据:《第十二章-精排模型.pdf》§12.4.3.2,代码 12.3。56推荐精排中的 Target Attention 用「当前候选物品」对用户行为序列做加权汇聚,7从而提取与候选相关的兴趣表示。它与缩放点积 Cross-Attention 的主要区别在于:8打分函数是一个可学习的非线性 MLP,而非简单的向量内积。910    缩放点积 Cross-Attention:α_i = softmax( (e_a · e_i) / √d )11    Target Attention(DIN 风格):α_i = MLP([e_a; e_i; e_a−e_i; e_a⊙e_i])1213后者能将候选、历史行为及两者之间的差异与交互作为打分的输入,14因此当「差分」或「元素乘积」携带有效判别信息时,MLP 打分函数的表达能力更强。1516正文指出:完整的 DIN 模型和 BST 模型将在第20章《用户兴趣与序列建模》详细介绍;17本节只展示 Target Attention 模块在精排特征交互框架中的作用。1819本文件的对照实验:20  - 基线:平均池化(忽略候选物品,静态汇聚序列兴趣)21  - 缩放点积 Cross-Attention(内积打分,不使用差分/交互项)22  - Target Attention(MLP 打分,使用 [e_a; e_i; e_a−e_i; e_a⊙e_i])2324依赖:PyTorch。运行:python3 target_attention.py25"""2627import random28import torch29import torch.nn as nn30import torch.nn.functional as F31import torch.optim as optim32import math333435def _auc(scores, labels) -> float:36    pos = [s for s, y in zip(scores, labels) if y == 1]37    neg = [s for s, y in zip(scores, labels) if y == 0]38    if not pos or not neg:39        return 0.540    return sum(1.0 if p > n else 0.5 if p == n else 0.041               for p in pos for n in neg) / (len(pos) * len(neg))424344# ── Target Attention(代码 12.3,正文 §12.4.3.2)────────────────────────────4546class TargetAttention(nn.Module):47    """48    DIN 风格目标注意力(正文代码 12.3)。4950    打分网络输入 [target; history; target−history; target⊙history],51    包含四类关系:原始向量、差分(方向)、逐元素乘积(交互)。52    与仅使用内积相比,能捕获非线性的相关性模式。5354    与缩放点积 Cross-Attention 的关键区别:55      - 缩放内积:α_i = softmax(e_a · e_i / √d)  — 线性相似度56      - 本模块:  α_i = MLP([e_a; e_i; e_a−e_i; e_a⊙e_i]) — 可学习非线性打分57    正文强调,推荐系统不必局限于向量内积,可以使用更丰富的匹配特征。58    """5960    def __init__(self, d_model: int, hidden_dim: int = 16):61        super().__init__()62        self.score_net = nn.Sequential(63            nn.Linear(4 * d_model, hidden_dim),64            nn.ReLU(),65            nn.Linear(hidden_dim, 1),66        )6768    def forward(self, target: torch.Tensor, history: torch.Tensor,69                valid_mask: torch.Tensor | None = None):70        """71        target     : (B, D)   当前候选物品嵌入72        history    : (B, L, D) 用户历史行为嵌入序列73        valid_mask : (B, L) bool,True 表示有效位置(可选)74        返回: pooled (B, D),weights (B, L)75        """76        t = target.unsqueeze(1).expand_as(history)             # (B, L, D)77        feat = torch.cat([t, history, t - history, t * history], dim=-1)78        scores = self.score_net(feat).squeeze(-1)               # (B, L)7980        if valid_mask is not None:81            scores = scores.masked_fill(~valid_mask,82                                        torch.finfo(scores.dtype).min)83        weights = torch.softmax(scores, dim=-1)                 # (B, L)84        if valid_mask is not None:85            weights = weights * valid_mask.float()86            weights = weights / weights.sum(dim=-1, keepdim=True).clamp_min(1e-8)8788        pooled = (weights.unsqueeze(-1) * history).sum(dim=1)  # (B, D)89        return pooled, weights909192class TargetAttentionUnnormalized(TargetAttention):93    """94    DIN 原文的「局部激活单元」:直接用原始相关性打分做加权求和,不做 softmax 归一化。9596    正文代码 12.3 为演示简洁采用了 softmax;而 DIN 论文强调不做归一化,97    以便在候选与某条历史行为高度相关时,让该行为的表示被**放大**,98    而不是仅仅在若干行为之间重新分配 1 的总权重。99    当序列中只有少数行为与候选真正相关时,归一化会把注意力「摊薄」,100    未归一化则保留了兴趣强度信息。101    """102103    def forward(self, target: torch.Tensor, history: torch.Tensor,104                valid_mask: torch.Tensor | None = None):105        t = target.unsqueeze(1).expand_as(history)106        feat = torch.cat([t, history, t - history, t * history], dim=-1)107        weights = self.score_net(feat).squeeze(-1)             # (B, L) 原始打分108        if valid_mask is not None:109            weights = weights.masked_fill(~valid_mask, 0.0)110        pooled = (weights.unsqueeze(-1) * history).sum(dim=1)111        return pooled, weights112113114# ── 缩放点积 Cross-Attention(对照组)────────────────────────────────────────115116class ScaledDotCrossAttention(nn.Module):117    """缩放点积 Cross-Attention:α_i = softmax((W_q e_a)·(W_k e_i) / √d)。"""118119    def __init__(self, d_model: int):120        super().__init__()121        self.q = nn.Linear(d_model, d_model, bias=False)122        self.k = nn.Linear(d_model, d_model, bias=False)123        self.scale = math.sqrt(d_model)124125    def forward(self, target: torch.Tensor, history: torch.Tensor,126                valid_mask: torch.Tensor | None = None):127        q = self.q(target).unsqueeze(1)                        # (B,1,D)128        k = self.k(history)                                    # (B,L,D)129        scores = (q * k).sum(-1) / self.scale                  # (B,L)130        if valid_mask is not None:131            scores = scores.masked_fill(~valid_mask,132                                        torch.finfo(scores.dtype).min)133        weights = torch.softmax(scores, dim=-1)134        pooled = (weights.unsqueeze(-1) * history).sum(dim=1)135        return pooled, weights136137138# ── CTR 预测器(三种汇聚方式共用相同 MLP 头)────────────────────────────────139140class CTRPredictor(nn.Module):141    """142    嵌入 → 行为序列汇聚 → MLP → CTR。143144    pool_mode 取值:145      "avg"         平均池化(基线)146      "dot"         缩放点积 Cross-Attention147      "target_attn" Target Attention(MLP 打分 + softmax,正文代码 12.3)148      "target_unnorm" Target Attention(MLP 打分 + 不归一化,DIN 原文做法)149    """150151    def __init__(self, vocab: int, n_fields: int, emb_dim: int = 16,152                 hidden: int = 32, pool_mode: str = "target_attn"):153        super().__init__()154        self.emb = nn.Embedding(vocab, emb_dim)155        self.pool_mode = pool_mode156        self.n_fields = n_fields157158        if pool_mode == "dot":159            self.attn = ScaledDotCrossAttention(emb_dim)160        elif pool_mode == "target_attn":161            self.attn = TargetAttention(emb_dim, hidden_dim=16)162        elif pool_mode == "target_unnorm":163            self.attn = TargetAttentionUnnormalized(emb_dim, hidden_dim=16)164        # avg 不需要额外模块165166        # 输入:汇聚兴趣 + 候选物品 + 场景字段167        mlp_in = emb_dim * (n_fields + 2)168        self.mlp = nn.Sequential(169            nn.Linear(mlp_in, hidden), nn.ReLU(),170            nn.Linear(hidden, hidden // 2), nn.ReLU(),171            nn.Linear(hidden // 2, 1),172        )173174    def forward(self, seq_ids: torch.Tensor, target_id: torch.Tensor,175                feat_ids: torch.Tensor) -> torch.Tensor:176        seq_emb = self.emb(seq_ids)                            # (B, T, D)177        target_emb = self.emb(target_id)                       # (B, D)178179        if self.pool_mode == "avg":180            v = seq_emb.mean(dim=1)181        else:182            v, _ = self.attn(target_emb, seq_emb)183184        fields = [self.emb(feat_ids[:, i]) for i in range(self.n_fields)]185        z = torch.cat([v, target_emb] + fields, dim=1)186        return self.mlp(z).squeeze(-1)187188    def fit(self, seq_ids, target_id, feat_ids, y,189            epochs: int = 40, lr: float = 5e-3):190        opt = optim.Adam(self.parameters(), lr=lr, weight_decay=1e-4)191        for ep in range(epochs):192            opt.zero_grad()193            loss = F.binary_cross_entropy_with_logits(194                self.forward(seq_ids, target_id, feat_ids), y.float())195            loss.backward()196            opt.step()197            if (ep + 1) % 20 == 0:198                print(f"  epoch {ep+1:3d}  loss={loss.item():.4f}")199        return loss.item()200201    def predict_proba(self, seq_ids, target_id, feat_ids) -> torch.Tensor:202        with torch.no_grad():203            return torch.sigmoid(self.forward(seq_ids, target_id, feat_ids))204205206# ── 合成数据:差分信息比内积更能刻画「相关」────────────────────────────────────207208def make_data(n: int = 3000, vocab: int = 16, n_cat: int = 4,209              seq_len: int = 6, n_fields: int = 3, seed: int = 42):210    """211    用户偏好与物品类目绑定:候选物品与偏好同类目时点击率高;212    行为序列中约 60% 是偏好类目物品,其余为其他类目的随机浏览。213214    这种构造要求模型从序列里挑出「与候选同类目」的那部分行为。215      - 平均池化与候选无关,只能给出静态摘要,无法区分候选;216      - 内积打分只能利用线性相似度;217      - MLP 打分函数可以利用 [e_a; e_i; e_a−e_i; e_a⊙e_i] 中的差分与交互项,218        表达更复杂的相关性判断。219    """220    torch.manual_seed(seed)221    random.seed(seed)222223    item_cat = torch.randint(0, n_cat, (vocab,))224    user_pref = torch.randint(0, n_cat, (n,))225    target = torch.randint(0, vocab, (n,))226    feat = torch.randint(0, vocab, (n, n_fields))227228    match = (item_cat[target] == user_pref).float()229    logit = 2.4 * match - 1.0230    y = (torch.sigmoid(logit + 0.5 * torch.randn(n)) > 0.5).long()231232    seqs = []233    for p in user_pref.tolist():234        s = []235        for _ in range(seq_len):236            if random.random() < 0.6:237                pool = (item_cat == p).nonzero(as_tuple=True)[0].tolist()238            else:239                pool = (item_cat != p).nonzero(as_tuple=True)[0].tolist()240            s.append(random.choice(pool) if pool else random.randrange(vocab))241        seqs.append(s)242    seq = torch.tensor(seqs)243244    return dict(seq=seq, target=target, feat=feat, y=y)245246247# ── 主演示 ────────────────────────────────────────────────────────────────────248249if __name__ == "__main__":250    random.seed(42)251    torch.manual_seed(42)252253    VOCAB, N_FIELDS, SEQ_LEN = 16, 3, 6254    data = make_data(3000, vocab=VOCAB, seq_len=SEQ_LEN, n_fields=N_FIELDS)255    split = 2400256    tr = {k: v[:split] for k, v in data.items()}257    te = {k: v[split:] for k, v in data.items()}258259    def build(mode):260        torch.manual_seed(42)261        return CTRPredictor(VOCAB, N_FIELDS, emb_dim=8, hidden=32,262                            pool_mode=mode)263264    def evaluate(model):265        p = model.predict_proba(te["seq"], te["target"], te["feat"])266        return _auc(p.tolist(), te["y"].tolist())267268    results, models = {}, {}269    for mode, label in [("avg", "平均池化(基线)"),270                        ("dot", "缩放点积 Cross-Attention"),271                        ("target_attn", "Target Attention(MLP + softmax,代码 12.3)"),272                        ("target_unnorm", "Target Attention(MLP,不归一化,DIN 原文)")]:273        print(f"\n=== {label} ===")274        m = build(mode)275        m.fit(tr["seq"], tr["target"], tr["feat"], tr["y"], epochs=40)276        models[mode] = m277        results[mode] = evaluate(m)278        print(f"  AUC = {results[mode]:.4f}")279280    print(f"\n对比(正文 §12.4.3.2):")281    print(f"  平均池化                    AUC={results['avg']:.4f}")282    print(f"  缩放点积 CrossAttn          AUC={results['dot']:.4f}"283          f"  (Δ={results['dot'] - results['avg']:+.4f})")284    print(f"  Target Attn + softmax       AUC={results['target_attn']:.4f}"285          f"  (Δ={results['target_attn'] - results['avg']:+.4f})")286    print(f"  Target Attn(不归一化)     AUC={results['target_unnorm']:.4f}"287          f"  (Δ={results['target_unnorm'] - results['avg']:+.4f})")288    print("  正文要点:Target Attention 的打分函数可以是可学习的非线性网络,")289    print("  将目标、历史行为及二者之间的差分与逐元素乘积作为输入,")290    print("  从而表达比向量内积更丰富的关系;它按候选动态汇聚兴趣,")291    print("  而平均池化只能给出与候选无关的静态兴趣摘要。")292    print("  另外,归一化与不归一化的差别很关键:softmax 把总权重固定为 1,")293    print("  序列中只有少数行为真正相关时会摊薄注意力;DIN 原文不做归一化,")294    print("  让高度相关的行为被放大,保留兴趣强度。")295296    # ── 关键性质:同一序列、不同候选,汇聚表示应变化 ────────────────────────297    print("\n同一序列在不同候选下的汇聚表示变化(L2 距离,16 个候选的平均):")298    with torch.no_grad():299        for mode in ("avg", "target_attn"):300            m = models[mode]301            se = m.emb(te["seq"][:1])302            if mode == "avg":303                delta = 0.0304            else:305                base = se.mean(dim=1)306                vs = torch.cat([m.attn(m.emb(torch.tensor([c])), se)[0]307                                for c in range(VOCAB)])308                delta = (vs - base).norm(dim=1).mean().item()309            print(f"  {mode:<12} 汇聚表示随候选的变化 = {delta:.4f}")310311    # ── 注意力权重分析:同一序列、不同候选,权重应不同 ──────────────────────312    print("\nTarget Attention 权重(前 3 个测试样本,对比同一序列对不同候选的关注):")313    m_ta = models["target_attn"]314    with torch.no_grad():315        for i in range(3):316            seq_i = te["seq"][i:i + 1]317            seq_emb = m_ta.emb(seq_i)318            cand_ids = [te["target"][i].item(), (te["target"][i].item() + 5) % VOCAB]319            for c in cand_ids:320                c_emb = m_ta.emb(torch.tensor([c]))321                _, w = m_ta.attn(c_emb, seq_emb)322                print(f"  样本{i} 候选={c:2d} 权重="323                      f"{[f'{v:.2f}' for v in w[0].tolist()]}")324325    # ── 精排推理演示 ─────────────────────────────────────────────────────────326    p = m_ta.predict_proba(te["seq"][:20], te["target"][:20],327                           te["feat"][:20]).tolist()328    top5 = sorted(enumerate(p), key=lambda t: -t[1])[:5]329    print("\n精排 Top-5(候选集 20 条):")330    for rank_i, (idx, s) in enumerate(top5, 1):331        print(f"  #{rank_i}  item={idx:2d}  p_click={s:.4f}  "332              f"label={te['y'][idx].item()}")

如何用于学习或编写新代码

  1. 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
  2. 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
  3. 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
  4. 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。