CH 10 / SOURCE EXAMPLE

item2vec.py

ch10 召回模型/code/ch05_sequence_retrieval/item2vec.py

这个示例讲什么

│ ├── item2vec.py Item2Vec:session 内两两共现(无滑动窗口)

Item2Vec 物品 Embedding 学习(第10章 §10.4.1「List2Vec召回 - Item2Vec模型」) 论文:Barkan & Koenigstein, "Item2Vec: Neural Item Embedding for Collaborative Filtering", MLSP 2016 核心思想: - 把一个用户的一次行为列表当作一个「句子」,把物品当作「词」, 直接用 Word2Vec 的 Skip-Gram with Negative Sampling(SGNS)学习物品向量。 - 共同出现在同一用户行为集合中的物品,向量距离更近(协同过滤信号)。 与 word2vec 的关键区别(本实现的重点): - word2vec:中心词只与固定大小滑动窗口 [t-c, t+c] 内的词构成正样本, 因此「空间/时间距离」会影响共现关

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

源码结构与阅读入口

  1. Item2Vec · 第 40 行Item2Vec(Skip-Gram + 负采样)。

完整源码 · 235 行

1"""2Item2Vec 物品 Embedding 学习(第10章 §10.4.1「List2Vec召回 - Item2Vec模型」)34论文:Barkan & Koenigstein, "Item2Vec: Neural Item Embedding for Collaborative5      Filtering", MLSP 201667核心思想:8- 把一个用户的一次行为列表当作一个「句子」,把物品当作「词」,9  直接用 Word2Vec 的 Skip-Gram with Negative Sampling(SGNS)学习物品向量。10- 共同出现在同一用户行为集合中的物品,向量距离更近(协同过滤信号)。1112与 word2vec 的关键区别(本实现的重点):13- word2vec:中心词只与固定大小滑动窗口 [t-c, t+c] 内的词构成正样本,14  因此「空间/时间距离」会影响共现关系。15- Item2Vec:丢弃了窗口概念,把一次行为集合 S={i_1,...,i_K} 当作「动态窗口」,16  集合内任意两个物品(i_a != i_b)都构成正样本,与先后顺序无关。17  因此 A->B->C 与 C->B->A 产生完全相同的训练样本,Item2Vec 学到的是18  共现关系(Co-occurrence)而非时序依赖。1920优化目标(对应书中公式):21    L = -(1/K) * sum_{i=1..K} sum_{j != i} log p(i_j | i_i)22其中 p(i_j|i_i) 用 SGNS 近似:23    log p(j|i) ≈ log σ(u_i · v_j) + sum_{n in Neg} log σ(-u_i · v_n)2425本实现:PyTorch autograd,SGNS 损失用 F.binary_cross_entropy_with_logits 计算。26"""2728import math29import random30from collections import defaultdict3132import torch33import torch.nn as nn34import torch.nn.functional as F35import torch.optim as optim363738# ── Item2Vec 模型 ─────────────────────────────────────────────────────────────3940class Item2Vec(nn.Module):41    """42    Item2Vec(Skip-Gram + 负采样)。4344    参数矩阵:45      item_emb: [n_items × dim]  中心物品向量 u_i(最终用于召回的 Item Embedding)46      ctx_emb:  [n_items × dim]  上下文物品向量 v_j(训练辅助参数)47    """4849    def __init__(50        self,51        n_items: int,52        dim: int = 16,53        lr: float = 0.05,54        n_neg: int = 5,55        reg: float = 1e-5,56    ):57        super().__init__()58        self.n_items = n_items59        self.dim = dim60        self.lr = lr61        self.n_neg = n_neg62        self.reg = reg6364        self.item_emb = nn.Embedding(n_items, dim)65        self.ctx_emb = nn.Embedding(n_items, dim)6667        nn.init.uniform_(self.item_emb.weight, -math.sqrt(1.0 / dim), math.sqrt(1.0 / dim))68        nn.init.zeros_(self.ctx_emb.weight)6970        self._neg_table: list[int] = []7172    # ── 样本构造:整个 session 两两共现(Item2Vec 的核心差异点) ──────────────73    def _build_pairs(self, user_seqs: dict[int, list[int]]) -> list[tuple[int, int]]:74        """75        把每个 session 看作无序集合,集合内任意有序对 (i_a, i_b), a != b 都是正样本。76        注意:这里没有任何窗口大小参数,这正是 Item2Vec 与 word2vec 的区别。77        """78        pairs: list[tuple[int, int]] = []79        for seq in user_seqs.values():80            items = list(dict.fromkeys(seq))  # 集合语义:同一 session 内去重81            for a in range(len(items)):82                for b in range(len(items)):83                    if a != b:84                        pairs.append((items[a], items[b]))85        return pairs8687    def _build_neg_table(self, user_seqs: dict[int, list[int]], table_size: int = 2000) -> None:88        """按 freq^0.75 构造负采样表(与 word2vec 的 unigram 分布一致)。"""89        freq: dict[int, int] = defaultdict(int)90        for seq in user_seqs.values():91            for iid in seq:92                freq[iid] += 193        weights = {iid: (c ** 0.75) for iid, c in freq.items()}94        total = sum(weights.values()) or 1.095        table: list[int] = []96        for iid, w in weights.items():97            cnt = max(1, int(round(table_size * w / total)))98            table.extend([iid] * cnt)99        self._neg_table = table or list(range(self.n_items))100101    def _sample_negs(self, exclude: int, k: int) -> list[int]:102        negs = []103        for _ in range(k * 3):104            cand = random.choice(self._neg_table)105            if cand != exclude:106                negs.append(cand)107            if len(negs) == k:108                break109        while len(negs) < k:110            negs.append((exclude + 1) % self.n_items)111        return negs112113    def forward(self, center_ids: torch.Tensor, pos_ids: torch.Tensor,114                neg_ids: torch.Tensor) -> torch.Tensor:115        """116        center_ids: [B]117        pos_ids:    [B]118        neg_ids:    [B, n_neg]119120        loss = BCE(u·v_pos, 1) + sum_neg BCE(u·v_neg, 0)121             = F.binary_cross_entropy_with_logits on stacked logits122        """123        u = self.item_emb(center_ids)           # [B, dim]124        v_pos = self.ctx_emb(pos_ids)           # [B, dim]125        v_neg = self.ctx_emb(neg_ids)           # [B, n_neg, dim]126127        pos_logits = (u * v_pos).sum(-1)        # [B]128        neg_logits = torch.bmm(v_neg, u.unsqueeze(-1)).squeeze(-1)  # [B, n_neg]129130        pos_loss = F.binary_cross_entropy_with_logits(131            pos_logits, torch.ones_like(pos_logits))132        neg_loss = F.binary_cross_entropy_with_logits(133            neg_logits, torch.zeros_like(neg_logits))134        return pos_loss + neg_loss135136    def train_model(self, user_seqs: dict[int, list[int]], epochs: int = 5,137                    batch_size: int = 256) -> None:138        """用所有用户行为集合训练 Item Embedding。"""139        self._build_neg_table(user_seqs)140        pairs = self._build_pairs(user_seqs)141        print(f"  共现物品对数量:{len(pairs)}(整 session 两两组合,无滑动窗口)")142143        optimizer = optim.Adam(self.parameters(), lr=self.lr, weight_decay=self.reg)144        for epoch in range(epochs):145            random.shuffle(pairs)146            total_loss = 0.0147            n_batches = 0148            for start in range(0, len(pairs), batch_size):149                batch = pairs[start:start + batch_size]150                center_ids = torch.tensor([p[0] for p in batch], dtype=torch.long)151                pos_ids = torch.tensor([p[1] for p in batch], dtype=torch.long)152                neg_ids = torch.tensor(153                    [self._sample_negs(p[1], self.n_neg) for p in batch],154                    dtype=torch.long)155156                optimizer.zero_grad()157                loss = self(center_ids, pos_ids, neg_ids)158                loss.backward()159                optimizer.step()160161                total_loss += loss.item()162                n_batches += 1163            print(f"  epoch {epoch:2d}  avg_loss={total_loss / max(n_batches, 1):.4f}")164165    # ── 召回接口 ──────────────────────────────────────────────────────────────166    def item_vector(self, iid: int) -> torch.Tensor:167        """返回物品的最终 Embedding(线上写入 ANN 索引的向量)。"""168        with torch.no_grad():169            return self.item_emb.weight[iid]170171    def most_similar(self, iid: int, top_k: int = 5) -> list[tuple[int, float]]:172        """I2I 检索:给定 Trigger 物品,返回余弦最相似的 Top-K 物品。"""173        with torch.no_grad():174            W = F.normalize(self.item_emb.weight, dim=-1)  # [n_items, dim]175            base = W[iid]176            sims = (W @ base)  # [n_items]177            sims[iid] = -1e9178            top_vals, top_idx = sims.topk(top_k)179        return [(int(i), float(s)) for i, s in zip(top_idx.tolist(), top_vals.tolist())]180181    def recommend(self, seq: list[int], top_k: int = 5) -> list[tuple[int, float]]:182        """183        多 Trigger 融合召回:用行为列表中的每个物品做 I2I 检索,分数取最大值融合。184        """185        exclude = set(seq)186        fused: dict[int, float] = {}187        for trigger in seq:188            for cand, s in self.most_similar(trigger, top_k=self.n_items):189                if cand in exclude:190                    continue191                fused[cand] = max(fused.get(cand, -2.0), s)192        ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)193        return ranked[:top_k]194195196# ── 快速验证 ──────────────────────────────────────────────────────────────────197if __name__ == "__main__":198    random.seed(42)199    torch.manual_seed(42)200201    n_users, n_items = 8, 20202    # 构造合成行为序列:把物品分成 4 个「兴趣簇」,用户主要在某个簇内行为203    clusters = [list(range(0, 5)), list(range(5, 10)), list(range(10, 15)), list(range(15, 20))]204    user_seqs: dict[int, list[int]] = {}205    for u in range(n_users):206        main = clusters[u % len(clusters)]207        seq = random.sample(main, k=4)                       # 簇内 4 个物品208        seq += random.sample(range(n_items), k=4)            # 混入 4 个随机噪声物品209        random.shuffle(seq)210        user_seqs[u] = seq211212    print("合成用户行为序列(session 视为无序集合):")213    for u in range(n_users):214        print(f"  user {u}: {user_seqs[u]}")215216    model = Item2Vec(n_items, dim=16, lr=0.05, n_neg=5)217    print("\nItem2Vec 训练:")218    model.train_model(user_seqs, epochs=6)219220    print("\nI2I 相似物品(Top-5,簇内物品应彼此靠近):")221    for trigger in (0, 6, 12):222        sims = model.most_similar(trigger, top_k=5)223        pretty = ", ".join(f"{i}({s:.3f})" for i, s in sims)224        print(f"  trigger item {trigger} -> {pretty}")225226    seq = user_seqs[0]227    print(f"\n用户 0 的行为列表 {seq}")228    print("多 Trigger 融合召回 Top-5:")229    for iid, s in model.recommend(seq, top_k=5):230        print(f"  item {iid}: sim={s:.4f}")231232    # 验证「无序性」:顺序颠倒后训练样本完全相同233    fwd = sorted(model._build_pairs({0: [1, 2, 3]}))234    bwd = sorted(model._build_pairs({0: [3, 2, 1]}))235    print(f"\n顺序颠倒后共现样本是否完全一致:{fwd == bwd}(Item2Vec 不建模时序)")

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

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