CH 10 / SOURCE EXAMPLE
bert4rec.py
ch10 召回模型/code/ch05_sequence_retrieval/bert4rec.py
这个示例讲什么
│ ├── bert4rec.py BERT4Rec:双向注意力 + Cloze 完形填空
BERT4Rec 双向 Transformer 序列召回(第10章 §10.4.3「Transformer序列召回 - BERT4Rec模型」) 论文:Sun et al., "BERT4Rec: Sequential Recommendation with Bidirectional Encoder Representations from Transformer", CIKM 2019 核心思想: - 用双向 Transformer Encoder 编码用户行为序列,训练目标为 Cloze(完形填空 / MLM): 随机把序列中的若干物品替换成 [MASK],再用「前后双向上下文」还原被遮蔽的物品。 [i1,i2,i3,i4,i5] => [i1,[MASK],i3,[MASK],i5] L = - Σ_{t in M} log P(i_t
上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
BERT4Rec· 第 36 行精简版 BERT4Rec:双向多头 Transformer Encoder + Cloze 训练目标。
1r"""2BERT4Rec 双向 Transformer 序列召回(第10章 §10.4.3「Transformer序列召回 - BERT4Rec模型」)34论文:Sun et al., "BERT4Rec: Sequential Recommendation with Bidirectional Encoder5 Representations from Transformer", CIKM 201967核心思想:8- 用双向 Transformer Encoder 编码用户行为序列,训练目标为 Cloze(完形填空 / MLM):9 随机把序列中的若干物品替换成 [MASK],再用「前后双向上下文」还原被遮蔽的物品。10 [i1,i2,i3,i4,i5] => [i1,[MASK],i3,[MASK],i5]11 L = - Σ_{t in M} log P(i_t | S_\t)12- 推理时在序列末尾追加一个 [MASK] token,用该位置的隐状态预测下一个物品,13 再用它作为 User Embedding 去 ANN 索引中检索 Top-K。1415与 SASRec 的关键区别(见同目录 sasrec.py):16- 注意力掩码:SASRec 用因果 Mask(位置 t 只能看 ≤t);BERT4Rec 不加因果 Mask,双向看全序列。17- 训练目标:SASRec 自回归预测下一个物品;BERT4Rec 随机 Mask 若干位置后用双向上下文还原。18- 监督信号:SASRec 每个位置 1 个;BERT4Rec 一条序列可采样多组 Mask,数据利用率更高。19- 推理方式:SASRec 直接取最后位置隐状态;BERT4Rec 需在末尾追加 [MASK] 再取该位置隐状态。2021本实现:PyTorch autograd,使用 nn.TransformerEncoder(无因果 Mask)+ Tied Embedding 输出层。22完整反向传播由 autograd 自动完成。23"""2425import math26import random2728import torch29import torch.nn as nn30import torch.nn.functional as F31import torch.optim as optim323334# ── BERT4Rec 模型 ─────────────────────────────────────────────────────────────3536class BERT4Rec(nn.Module):37 """38 精简版 BERT4Rec:双向多头 Transformer Encoder + Cloze 训练目标。3940 参数:41 item_emb: nn.Embedding [(n_items+1) × dim] 物品 Embedding,下标 n_items 为 [MASK] token42 pos_emb: nn.Embedding [max_len × dim] 位置 Embedding43 transformer: nn.TransformerEncoder(无因果 Mask,双向注意力)44 out_bias: [n_items] 输出层 bias(输出矩阵与 item_emb 共享,Tied Embedding)45 """4647 def __init__(self, n_items: int, dim: int = 16, max_len: int = 10, n_layers: int = 2,48 n_heads: int = 2, d_ff: int = 32, lr: float = 0.05, mask_prob: float = 0.3,49 dropout: float = 0.1):50 super().__init__()51 assert dim % n_heads == 0, "dim 必须能被 n_heads 整除"52 self.n_items = n_items53 self.mask_id = n_items # 下标 n_items 即 [MASK] token54 self.dim = dim55 self.max_len = max_len56 self.lr = lr57 self.mask_prob = mask_prob5859 scale = math.sqrt(1.0 / dim)60 self.item_emb = nn.Embedding(n_items + 1, dim) # +1 for [MASK]61 self.pos_emb = nn.Embedding(max_len, dim)62 nn.init.uniform_(self.item_emb.weight, -scale, scale)63 nn.init.uniform_(self.pos_emb.weight, -scale, scale)6465 self.out_bias = nn.Parameter(torch.zeros(n_items))6667 # 双向 Transformer Encoder(关键点 1:无因果 Mask,batch_first=True)68 encoder_layer = nn.TransformerEncoderLayer(69 d_model=dim,70 nhead=n_heads,71 dim_feedforward=d_ff,72 dropout=dropout,73 activation="relu",74 batch_first=True,75 norm_first=False,76 )77 self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)7879 def _forward(self, tokens: torch.Tensor) -> torch.Tensor:80 """81 完整前向:Embedding → 双向 Transformer Encoder(无因果 Mask)82 tokens: [B, T] 包含 [MASK] id 的 token 序列83 返回: [B, T, dim] 每个位置的隐状态84 """85 B, T = tokens.shape86 positions = torch.arange(T, device=tokens.device).unsqueeze(0) # [1, T]87 x = self.item_emb(tokens) + self.pos_emb(positions) # [B, T, dim]88 # 注意:这里不传 src_mask,双向看全序列(与 SASRec 的因果 mask 的关键区别)89 return self.transformer(x) # [B, T, dim]9091 # ── Cloze 训练(关键点 2:随机 Mask + 双向上下文还原) ───────────────────92 def _make_cloze_sample(self, seq: list[int]) -> tuple[list[int], list[tuple[int, int]]]:93 """以 mask_prob 随机遮蔽序列中的物品;至少遮蔽一个位置。"""94 tokens, targets = list(seq), []95 for t, iid in enumerate(seq):96 if random.random() < self.mask_prob:97 tokens[t] = self.mask_id98 targets.append((t, iid))99 if not targets: # 保证至少有一个监督信号100 t = random.randrange(len(seq))101 tokens[t] = self.mask_id102 targets.append((t, seq[t]))103 return tokens, targets104105 def _cloze_loss(self, tokens: torch.Tensor,106 mask_positions: list[list[int]],107 mask_labels: list[list[int]]) -> torch.Tensor:108 """109 批量 Cloze 交叉熵损失。110 tokens: [B, T]111 mask_positions: list of per-sample masked position indices112 mask_labels: list of per-sample true item ids at masked positions113 Tied Embedding: logits = h @ item_emb.T + out_bias114 """115 hs = self._forward(tokens) # [B, T, dim]116 total_loss = torch.zeros((), device=tokens.device)117 n = 0118 for b, (positions, labels) in enumerate(zip(mask_positions, mask_labels)):119 if not positions:120 continue121 pos_t = torch.tensor(positions, dtype=torch.long, device=tokens.device)122 lbl_t = torch.tensor(labels, dtype=torch.long, device=tokens.device)123 h = hs[b, pos_t, :] # [M, dim]124 # Tied Embedding 输出层(共享 item_emb,不含 [MASK] 行)125 logits = h @ self.item_emb.weight[:self.n_items].T + self.out_bias # [M, n_items]126 total_loss = total_loss + F.cross_entropy(logits, lbl_t)127 n += 1128 return total_loss / max(n, 1)129130 def train_model(self, user_seqs: dict[int, list[int]], epochs: int = 6,131 batch_size: int = 32) -> None:132 """133 Cloze 训练主循环。每条序列产出两类样本:134 (1) 随机 Mask 样本:学习双向上下文还原能力;135 (2) 仅 Mask 末尾位置的样本:论文中用于缓解「训练目标与线上 next-item 预测」136 之间的不一致(与推理时末尾追加 [MASK] 的做法对齐)。137 """138 seqs = [s[-self.max_len:] for s in user_seqs.values() if len(s) >= 2]139 optimizer = optim.Adam(self.parameters(), lr=self.lr)140141 for epoch in range(epochs):142 random.shuffle(seqs)143 total, n_batches = 0.0, 0144145 # Build all samples: (tokens, positions, labels) per sequence × 2 variants146 all_samples: list[tuple[list[int], list[int], list[int]]] = []147 for seq in seqs:148 toks, tgts = self._make_cloze_sample(seq)149 all_samples.append((toks, [t for t, _ in tgts], [v for _, v in tgts]))150 # Last-position variant151 last_toks = list(seq[:-1]) + [self.mask_id]152 all_samples.append((last_toks, [len(seq) - 1], [seq[-1]]))153154 for start in range(0, len(all_samples), batch_size):155 chunk = all_samples[start:start + batch_size]156 T = max(len(s[0]) for s in chunk)157 # Pad tokens to uniform length (left-pad with 0)158 padded = torch.zeros(len(chunk), T, dtype=torch.long)159 for i, (toks, _, _) in enumerate(chunk):160 offset = T - len(toks)161 padded[i, offset:] = torch.tensor(toks, dtype=torch.long)162 # Adjust positions to account for left-padding163 mask_positions = []164 mask_labels = []165 for i, (toks, positions, labels) in enumerate(chunk):166 offset = T - len(toks)167 mask_positions.append([p + offset for p in positions])168 mask_labels.append(labels)169170 optimizer.zero_grad()171 loss = self._cloze_loss(padded, mask_positions, mask_labels)172 loss.backward()173 optimizer.step()174175 total += loss.item()176 n_batches += 1177178 print(f" epoch {epoch:2d} cloze_loss={total / max(n_batches, 1):.4f}")179180 # ── 推理(关键点 3:末尾追加 [MASK]) ───────────────────────────────────181 def user_embedding(self, seq: list[int]) -> torch.Tensor:182 """在序列末尾追加 [MASK],取该位置隐状态作为 User Embedding,shape [dim]。"""183 with torch.no_grad():184 tokens = list(seq[-(self.max_len - 1):]) + [self.mask_id]185 tok_t = torch.tensor(tokens, dtype=torch.long).unsqueeze(0) # [1, T]186 hs = self._forward(tok_t) # [1, T, dim]187 return hs[0, -1, :] # [dim]188189 def recommend(self, seq: list[int], top_k: int = 5,190 exclude_seen: bool = True) -> list[tuple[int, float]]:191 """给定历史序列,预测下一个物品 Top-K。"""192 with torch.no_grad():193 u = self.user_embedding(seq)194 # Tied Embedding 打分195 logits = self.item_emb.weight[:self.n_items] @ u + self.out_bias # [n_items]196 if exclude_seen:197 for iid in seq:198 logits[iid] = -1e9199 top_vals, top_idx = logits.topk(top_k)200 return [(int(i), float(s)) for i, s in zip(top_idx.tolist(), top_vals.tolist())]201202 def fill_mask(self, tokens: list[int], top_k: int = 3) -> dict[int, list[tuple[int, float]]]:203 """完形填空:对序列中所有 [MASK] 位置,用双向上下文给出 Top-K 候选。"""204 with torch.no_grad():205 tok_t = torch.tensor(tokens, dtype=torch.long).unsqueeze(0) # [1, T]206 hs = self._forward(tok_t) # [1, T, dim]207 result: dict[int, list[tuple[int, float]]] = {}208 for t, tok in enumerate(tokens):209 if tok != self.mask_id:210 continue211 logits = hs[0, t, :] @ self.item_emb.weight[:self.n_items].T + self.out_bias212 probs = logits.softmax(dim=-1)213 top_vals, top_idx = probs.topk(top_k)214 result[t] = list(zip(top_idx.tolist(), top_vals.tolist()))215 return result216217218# ── 快速验证 ──────────────────────────────────────────────────────────────────219if __name__ == "__main__":220 random.seed(42)221 torch.manual_seed(42)222 n_users, n_items, seq_len = 8, 20, 8223 # 合成序列:兴趣簇内按 +1 递进点击,前后文都能提供预测线索(适合双向建模)224 user_seqs: dict[int, list[int]] = {}225 for u in range(n_users):226 start = (u % 4) * 5 + random.randint(0, 2)227 user_seqs[u] = [(start + k) % n_items for k in range(seq_len)]228229 print("合成用户行为序列:")230 for u in range(n_users):231 print(f" user {u}: {user_seqs[u]}")232233 model = BERT4Rec(n_items, dim=16, max_len=10, n_layers=2, n_heads=2, d_ff=32, lr=0.05)234 print("\nBERT4Rec Cloze 训练(双向注意力,无因果 Mask):")235 model.train_model(user_seqs, epochs=6)236237 seq, probe, truth = user_seqs[0], user_seqs[0][:-1], user_seqs[0][-1]238 print(f"\n历史序列 {probe}(真实下一个物品 = {truth})\n末尾追加 [MASK] 后的 Top-5 召回:")239 for iid, s in model.recommend(probe, top_k=5, exclude_seen=False):240 flag = " <-- 命中" if iid == truth else ""241 print(f" item {iid}: score={s:.4f}{flag}")242243 # 演示双向能力:遮蔽序列中间位置,用前后文同时推断244 mid, tokens = len(seq) // 2, list(seq)245 tokens[mid] = model.mask_id246 print(f"\n完形填空演示:把位置 {mid}(真实物品 {seq[mid]})替换为 [MASK]")247 print(f" 输入 tokens({model.mask_id} 表示 [MASK]):{tokens}")248 for pos, cands in model.fill_mask(tokens, top_k=3).items():249 pretty = ", ".join(f"item {i}(p={p:.3f})" for i, p in cands)250 print(f" 位置 {pos} 的双向预测:{pretty}")如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。