CH 10 / SOURCE EXAMPLE
cl4srec.py
ch10 召回模型/code/ch05_sequence_retrieval/cl4srec.py
这个示例讲什么
│ ├── cl4srec.py CL4SRec:crop/mask/reorder 三增强 + 对比学习
CL4SRec 对比学习序列召回(第10章 §10.4.5「对比学习序列召回 - CL4SRec召回」) 论文:Xie et al., "Contrastive Learning for Sequential Recommendation", ICDE 2022 核心思想: - 以 SASRec(因果 Self-Attention)作为序列编码器; - 对同一条用户行为序列做两次随机数据增强,得到两个视图,互为正样本对; 同一个 Mini-Batch 内其它用户的增强序列作为负样本; - 三种序列数据增强算子: * Item Crop :随机截取一段连续子序列(学习局部兴趣,适配不同序列长度) * Item Mask :随机把部分物品替换为 [MASK] 标记(抗行为缺失 / 噪声) * Item Reorder:随机打乱一段连续子序列的顺序(降低对严
上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
SeqAugment· 第 33 行CL4SRec 的三种数据增强算子:Crop / Mask / Reorder。CL4SRec· 第 79 行CL4SRec = SASRec 编码器 + 自监督对比学习辅助任务。
1"""2CL4SRec 对比学习序列召回(第10章 §10.4.5「对比学习序列召回 - CL4SRec召回」)34论文:Xie et al., "Contrastive Learning for Sequential Recommendation", ICDE 20225核心思想:6- 以 SASRec(因果 Self-Attention)作为序列编码器;7- 对同一条用户行为序列做两次随机数据增强,得到两个视图,互为正样本对;8 同一个 Mini-Batch 内其它用户的增强序列作为负样本;9- 三种序列数据增强算子:10 * Item Crop :随机截取一段连续子序列(学习局部兴趣,适配不同序列长度)11 * Item Mask :随机把部分物品替换为 [MASK] 标记(抗行为缺失 / 噪声)12 * Item Reorder:随机打乱一段连续子序列的顺序(降低对严格顺序的依赖)13- 对比损失采用 InfoNCE:14 L_cl = -log( exp(sim(h_i, h_j)/τ) / Σ_k exp(sim(h_i, h_k)/τ) )15 实现为 F.cross_entropy(sim_matrix / tau, torch.arange(B))16- 多任务联合训练:L_total = L_rec + λ·L_cl (L_rec 为下一物品预测损失)1718本实现:PyTorch autograd,增强操作用 Python 列表操作,编码器用19nn.TransformerEncoderLayer + causal mask,完整反向传播由 autograd 自动完成。20"""2122import math23import random2425import torch26import torch.nn as nn27import torch.nn.functional as F28import torch.optim as optim293031# ── 三种序列数据增强算子 ───────────────────────────────────────────────────────3233class SeqAugment:34 """CL4SRec 的三种数据增强算子:Crop / Mask / Reorder。"""3536 def __init__(self, mask_token: int, eta: float = 0.6, gamma: float = 0.3, beta: float = 0.5):37 self.mask_token = mask_token # [MASK] 占位 id(= n_items)38 self.eta = eta # crop 保留比例39 self.gamma = gamma # mask 比例40 self.beta = beta # reorder 子序列比例4142 def item_crop(self, seq: list[int]) -> list[int]:43 """随机截取长度为 ⌊η·T⌋ 的连续子序列。"""44 T = len(seq)45 sub_len = max(1, int(self.eta * T))46 start = random.randint(0, T - sub_len)47 return seq[start:start + sub_len]4849 def item_mask(self, seq: list[int]) -> list[int]:50 """随机将 ⌊γ·T⌋ 个位置替换为 [MASK]。"""51 out = list(seq)52 n_mask = max(1, int(self.gamma * len(seq)))53 for idx in random.sample(range(len(seq)), n_mask):54 out[idx] = self.mask_token55 return out5657 def item_reorder(self, seq: list[int]) -> list[int]:58 """随机选取长度为 ⌊β·T⌋ 的连续片段并打乱其内部顺序。"""59 out = list(seq)60 T = len(seq)61 sub_len = max(2, int(self.beta * T)) if T >= 2 else T62 sub_len = min(sub_len, T)63 start = random.randint(0, T - sub_len)64 chunk = out[start:start + sub_len]65 random.shuffle(chunk)66 out[start:start + sub_len] = chunk67 return out6869 def random_augment(self, seq: list[int]) -> tuple[str, list[int]]:70 """等概率随机挑选一种增强算子。"""71 name = random.choice(["crop", "mask", "reorder"])72 return name, {"crop": self.item_crop,73 "mask": self.item_mask,74 "reorder": self.item_reorder}[name](seq)757677# ── CL4SRec 模型 ──────────────────────────────────────────────────────────────7879class CL4SRec(nn.Module):80 """81 CL4SRec = SASRec 编码器 + 自监督对比学习辅助任务。8283 组件:84 item_emb : nn.Embedding [(n_items + 1) × dim],最后一行为 [MASK] 的 Embedding85 pos_emb : nn.Embedding [max_len × dim]86 transformer_layer: nn.TransformerEncoderLayer(因果 mask,单头)87 """8889 def __init__(self, n_items: int, dim: int = 16, max_len: int = 10,90 lr: float = 0.05, reg: float = 1e-4,91 tau: float = 0.5, lam: float = 0.3, n_heads: int = 1,92 dropout: float = 0.1):93 super().__init__()94 self.n_items = n_items95 self.dim = dim96 self.max_len = max_len97 self.lr = lr98 self.reg = reg99 self.tau = tau # 温度系数 τ100 self.lam = lam # 对比损失权重 λ101 self.mask_token = n_items102103 scale = math.sqrt(1.0 / dim)104 self.item_emb = nn.Embedding(n_items + 1, dim) # +1 for [MASK]105 self.pos_emb = nn.Embedding(max_len, dim)106 nn.init.uniform_(self.item_emb.weight, -scale, scale)107 nn.init.uniform_(self.pos_emb.weight, -scale, scale)108109 self.transformer_layer = nn.TransformerEncoderLayer(110 d_model=dim,111 nhead=n_heads,112 dim_feedforward=dim * 4,113 dropout=dropout,114 activation="relu",115 batch_first=True,116 norm_first=False,117 )118 self.aug = SeqAugment(self.mask_token)119120 def _encode_batch(self, seqs: list[list[int]], pool: str = "last") -> torch.Tensor:121 """122 编码一批序列,自动左填充到同长度。123 pool="last":取最后位置(下一物品预测任务);124 pool="mean":均值池化(对比学习视图,对增强后长度不一的序列更稳健)。125 返回 [B, dim]。126 """127 T = max(len(s) for s in seqs)128 T = min(T, self.max_len)129 B = len(seqs)130131 padded = torch.zeros(B, T, dtype=torch.long)132 for i, seq in enumerate(seqs):133 s = seq[-T:]134 padded[i, T - len(s):] = torch.tensor(s, dtype=torch.long)135136 positions = torch.arange(T).unsqueeze(0) # [1, T]137 x = self.item_emb(padded) + self.pos_emb(positions) # [B, T, dim]138139 causal_mask = nn.Transformer.generate_square_subsequent_mask(T, device=x.device)140 out = self.transformer_layer(x, src_mask=causal_mask, is_causal=True) # [B, T, dim]141142 if pool == "mean":143 # Mean over non-padding positions only144 lengths = torch.tensor([min(len(s), T) for s in seqs], dtype=torch.float)145 mask = torch.zeros(B, T)146 for i, seq in enumerate(seqs):147 l = min(len(seq), T)148 mask[i, T - l:] = 1.0149 return (out * mask.unsqueeze(-1)).sum(1) / lengths.unsqueeze(-1)150 else:151 return out[:, -1, :]152153 # ── 主任务:下一物品预测 ─────────────────────────────────────────────154 def _rec_loss(self, contexts: list[list[int]], pos_ids: list[int],155 neg_ids: list[int]) -> torch.Tensor:156 """BCE 形式的下一物品预测损失。"""157 h = self._encode_batch(contexts, pool="last") # [B, dim]158 v_pos = self.item_emb(torch.tensor(pos_ids, dtype=torch.long))159 v_neg = self.item_emb(torch.tensor(neg_ids, dtype=torch.long))160 s_pos = (h * v_pos).sum(-1)161 s_neg = (h * v_neg).sum(-1)162 return (F.binary_cross_entropy_with_logits(s_pos, torch.ones_like(s_pos))163 + F.binary_cross_entropy_with_logits(s_neg, torch.zeros_like(s_neg)))164165 # ── 辅助任务:InfoNCE 对比学习 ──────────────────────────────────────166 def _cl_loss(self, view_seqs_a: list[list[int]],167 view_seqs_b: list[list[int]]) -> torch.Tensor:168 """169 Batch 内 InfoNCE:view_seqs_a[i] 与 view_seqs_b[i] 来自同一序列,互为正样本。170 L_cl = F.cross_entropy(sim_matrix / τ, torch.arange(B))171 """172 ha = F.normalize(self._encode_batch(view_seqs_a, pool="mean"), dim=-1) # [B, dim]173 hb = F.normalize(self._encode_batch(view_seqs_b, pool="mean"), dim=-1) # [B, dim]174175 # sim_matrix[i, j] = cosine(ha[i], hb[j]) / τ176 sim = (ha @ hb.T) / self.tau # [B, B]177 labels = torch.arange(sim.size(0), dtype=torch.long)178 # Both directions: ha predicts hb and hb predicts ha179 return (F.cross_entropy(sim, labels) + F.cross_entropy(sim.T, labels)) / 2.0180181 def train_model(self, user_seqs: dict[int, list[int]], epochs: int = 6,182 batch_size: int = 4) -> None:183 """多任务联合训练:L_total = L_rec + λ·L_cl。"""184 uids = list(user_seqs.keys())185 optimizer = optim.Adam(self.parameters(), lr=self.lr, weight_decay=self.reg)186187 for epoch in range(epochs):188 random.shuffle(uids)189 rec_sum, cl_sum, n_batch, n_rec = 0.0, 0.0, 0, 0190191 for b in range(0, len(uids), batch_size):192 batch_uids = uids[b:b + batch_size]193 contexts_rec, pos_ids, neg_ids = [], [], []194 view_seqs_a, view_seqs_b = [], []195196 for uid in batch_uids:197 seq = user_seqs[uid]198 # 主任务:序列前缀预测下一物品(每个位置都作为监督信号)199 for t in range(1, len(seq)):200 contexts_rec.append(seq[:t])201 pos_ids.append(seq[t])202 neg = random.randrange(self.n_items)203 while neg == seq[t]:204 neg = random.randrange(self.n_items)205 neg_ids.append(neg)206 n_rec += 1207 # 辅助任务:同一序列两次独立增强 → 正样本对208 _, va = self.aug.random_augment(seq[:-1])209 _, vb = self.aug.random_augment(seq[:-1])210 view_seqs_a.append(va)211 view_seqs_b.append(vb)212213 optimizer.zero_grad()214 rec_loss = self._rec_loss(contexts_rec, pos_ids, neg_ids)215 cl_loss = self._cl_loss(view_seqs_a, view_seqs_b)216 loss = rec_loss + self.lam * cl_loss217 loss.backward()218 optimizer.step()219220 rec_sum += rec_loss.item()221 cl_sum += cl_loss.item()222 n_batch += 1223224 print(f" epoch {epoch:2d} L_rec={rec_sum / max(n_batch, 1):.4f} "225 f"L_cl={cl_sum / max(n_batch, 1):.4f} "226 f"L_total={rec_sum / max(n_batch, 1) + self.lam * cl_sum / max(n_batch, 1):.4f}")227228 def recommend(self, seq: list[int], top_k: int = 5,229 exclude: set[int] | None = None) -> list[tuple[int, float]]:230 """用序列表示 h 对全量物品打分(线上等价于 ANN 检索)。"""231 exclude = exclude if exclude is not None else set(seq)232 with torch.no_grad():233 h = self._encode_batch([seq], pool="last")[0] # [dim]234 scores = self.item_emb.weight[:self.n_items] @ h # [n_items]235 for iid in exclude:236 scores[iid] = -1e9237 top_vals, top_idx = scores.topk(top_k)238 return [(int(i), float(s)) for i, s in zip(top_idx.tolist(), top_vals.tolist())]239240241# ── 快速验证 ──────────────────────────────────────────────────────────────────242if __name__ == "__main__":243 random.seed(42)244 torch.manual_seed(42)245 n_items, n_users = 20, 8246 user_seqs = {u: random.sample(range(n_items), k=8) for u in range(n_users)}247248 demo_seq = user_seqs[0]249 print("三种数据增强算子作用在同一条序列上的示例:")250 print(f" 原始序列 : {demo_seq}")251 aug = SeqAugment(mask_token=n_items)252 print(f" Item Crop : {aug.item_crop(demo_seq)}")253 print(f" Item Mask : {aug.item_mask(demo_seq)} ({n_items} 表示 [MASK])")254 print(f" Item Reorder : {aug.item_reorder(demo_seq)}")255256 model = CL4SRec(n_items, dim=16, max_len=10, lr=0.05, tau=0.5, lam=0.3)257 print(f"\nCL4SRec 训练({n_users} 用户,λ={model.lam},τ={model.tau}):")258 model.train_model(user_seqs, epochs=6, batch_size=4)259260 recs = model.recommend(demo_seq, top_k=5)261 print(f"\n用户 0 序列 {demo_seq} 的 Top-5 召回:")262 for iid, sc in recs:263 print(f" item {iid:2d} score={sc:.4f}")264265 # 对比学习效果观察:同一序列两视图相似度 vs 不同用户序列相似度266 pos_sims, neg_sims = [], []267 with torch.no_grad():268 reps = {u: F.normalize(model._encode_batch([s], pool="mean")[0], dim=-1)269 for u, s in user_seqs.items()}270 for u, s in user_seqs.items():271 _, va = aug.random_augment(s)272 _, vb = aug.random_augment(s)273 ha = F.normalize(model._encode_batch([va], pool="mean")[0], dim=-1)274 hb = F.normalize(model._encode_batch([vb], pool="mean")[0], dim=-1)275 pos_sims.append((ha @ hb).item())276 for v in user_seqs:277 if v != u:278 neg_sims.append((ha @ reps[v]).item())279280 print(f"\n同序列两视图平均余弦相似度={sum(pos_sims) / len(pos_sims):.4f} "281 f"不同用户序列平均余弦相似度={sum(neg_sims) / len(neg_sims):.4f}")如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。