CH 11 / SOURCE EXAMPLE

teacher_student_rocket.py

ch11 粗排模型/code/ch03_distillation/teacher_student_rocket.py

这个示例讲什么

│ ├── teacher_student_rocket.py Teacher-Student(KL 蒸馏)+ Rocket Launching

知识蒸馏粗排(第11章 §11.3) 本文件实现两种粗排蒸馏范式: 1. Teacher-Student:精排模型(教师)→ 粗排双塔(学生),KL 散度对齐 logit 2. Rocket Launching(火箭发射):共享 Booster 层,轻量 Light Net 作推理路径 PyTorch autograd 实现,无手写梯度。

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

源码结构与阅读入口

  1. SimpleMLP · 第 24 行
  2. TeacherModel · 第 44 行大模型(精排教师):深层 MLP + 交叉特征。
  3. StudentDSSM · 第 70 行轻量双塔学生模型:蒸馏教师的 soft label。
  4. RocketLaunching · 第 177 行Rocket Launching 蒸馏范式:

完整源码 · 318 行

1"""2知识蒸馏粗排(第11章 §11.3)34本文件实现两种粗排蒸馏范式:51. Teacher-Student:精排模型(教师)→ 粗排双塔(学生),KL 散度对齐 logit62. Rocket Launching(火箭发射):共享 Booster 层,轻量 Light Net 作推理路径78PyTorch autograd 实现,无手写梯度。9"""1011import math12import random13import collections14from typing import List, Tuple, Dict1516import torch17import torch.nn as nn18import torch.nn.functional as F19import torch.optim as optim202122# ─── 通用 MLP ────────────────────────────────────────────────────────────────2324class SimpleMLP(nn.Module):25    def __init__(self, dims: List[int]):26        super().__init__()27        layers = []28        for in_d, out_d in zip(dims[:-1], dims[1:]):29            layers.append(nn.Linear(in_d, out_d))30        self.layers = nn.ModuleList(layers)31        self.output_dim = dims[-1]3233    def forward(self, x: torch.Tensor) -> torch.Tensor:34        h = x35        for i, layer in enumerate(self.layers):36            h = layer(h)37            if i < len(self.layers) - 1:38                h = F.relu(h)39        return h404142# ─── 1. Teacher-Student 蒸馏 ─────────────────────────────────────────────────4344class TeacherModel(nn.Module):45    """大模型(精排教师):深层 MLP + 交叉特征。"""4647    def __init__(self, n_users: int, n_items: int, emb_dim: int = 32, output_dim: int = 32):48        super().__init__()49        scale = math.sqrt(1.0 / emb_dim)50        self.user_emb = nn.Embedding(n_users, emb_dim)51        self.item_emb = nn.Embedding(n_items, emb_dim)52        self.net = SimpleMLP([emb_dim * 2, 64, 32, output_dim])53        nn.init.uniform_(self.user_emb.weight, -scale, scale)54        nn.init.uniform_(self.item_emb.weight, -scale, scale)5556    def forward(self, uid: torch.Tensor, iid: torch.Tensor) -> torch.Tensor:57        x = torch.cat([self.user_emb(uid), self.item_emb(iid)], dim=-1)58        out = self.net(x)59        return out.norm(dim=-1)  # 模拟打分6061    def soft_labels(self, uid: int, candidates: List[int], temperature: float = 1.0) -> torch.Tensor:62        """对候选集输出 soft label(用于学生蒸馏)。"""63        with torch.no_grad():64            u = torch.tensor([uid]).expand(len(candidates))65            c = torch.tensor(candidates)66            scores = self(u, c) / temperature67            return F.softmax(scores, dim=0)686970class StudentDSSM(nn.Module):71    """轻量双塔学生模型:蒸馏教师的 soft label。"""7273    def __init__(74        self,75        n_users: int,76        n_items: int,77        emb_dim: int = 32,78        output_dim: int = 16,79        temperature: float = 1.0,80    ):81        super().__init__()82        self.n_items = n_items83        self.temperature = temperature84        scale = math.sqrt(1.0 / emb_dim)85        self.user_emb = nn.Embedding(n_users, emb_dim)86        self.item_emb = nn.Embedding(n_items, emb_dim)87        self.user_mlp = SimpleMLP([emb_dim, 32, output_dim])88        self.item_mlp = SimpleMLP([emb_dim, 32, output_dim])89        nn.init.uniform_(self.user_emb.weight, -scale, scale)90        nn.init.uniform_(self.item_emb.weight, -scale, scale)9192    def encode_user(self, uid: torch.Tensor) -> torch.Tensor:93        return self.user_mlp(self.user_emb(uid))9495    def encode_item(self, iid: torch.Tensor) -> torch.Tensor:96        return self.item_mlp(self.item_emb(iid))9798    def score(self, uid: int, iid: int) -> float:99        with torch.no_grad():100            ue = self.encode_user(torch.tensor([uid]))101            ie = self.encode_item(torch.tensor([iid]))102            return F.cosine_similarity(ue, ie).item()103104    def distill_step(105        self,106        uid: int,107        candidates: List[int],108        teacher_probs: torch.Tensor,109        alpha: float = 0.5,110    ) -> torch.Tensor:111        """112        KL 蒸馏 + BPR 硬标签联合训练。113        loss = (1-α)*bpr_loss + α*F.kl_div(log_p_S, p_T, reduction='batchmean')114        """115        u = torch.tensor([uid]).expand(len(candidates))116        c = torch.tensor(candidates)117        ue = self.encode_user(u)                          # [N, D]118        ie = self.encode_item(c)                          # [N, D]119        sims = F.cosine_similarity(ue, ie, dim=1)         # [N]120121        # KL 散度蒸馏122        log_p_S = F.log_softmax(sims / self.temperature, dim=0).unsqueeze(0)123        p_T = teacher_probs.unsqueeze(0)124        kl_loss = F.kl_div(log_p_S, p_T, reduction='batchmean')125126        # BPR 硬标签:取教师打分最高的为正样本127        pos_idx = teacher_probs.argmax().item()128        neg_idx = random.choice([j for j in range(len(candidates)) if j != pos_idx])129        bpr_loss = -F.logsigmoid(sims[pos_idx] - sims[neg_idx])130131        return (1 - alpha) * bpr_loss + alpha * kl_loss132133    def train_distill(134        self,135        user_seqs: Dict[int, List[int]],136        teacher: TeacherModel,137        epochs: int = 6,138        lr: float = 0.01,139    ) -> None:140        optimizer = optim.Adam(self.parameters(), lr=lr)141        for epoch in range(epochs):142            total_loss = 0.0143            for uid, seq in user_seqs.items():144                if not seq:145                    continue146                seen = set(seq)147                pool = [i for i in range(self.n_items) if i not in seen]148                if len(pool) < 2:149                    continue150                candidates = random.sample(pool, k=min(8, len(pool)))151                teacher_probs = teacher.soft_labels(uid, candidates)152153                optimizer.zero_grad()154                loss = self.distill_step(uid, candidates, teacher_probs)155                loss.backward()156                optimizer.step()157                total_loss += loss.item()158            if epoch % 2 == 0:159                print(f"  [Student] epoch {epoch:3d}  avg_loss={total_loss / max(len(user_seqs), 1):.4f}")160161    def pre_rank(162        self, uid: int, candidates: List[int], top_k: int = 10163    ) -> List[Tuple[int, float]]:164        with torch.no_grad():165            u = torch.tensor([uid]).expand(len(candidates))166            c = torch.tensor(candidates)167            ue = self.encode_user(u)168            ie = self.encode_item(c)169            sims = F.cosine_similarity(ue, ie, dim=1)170            scores = [(candidates[j], sims[j].item()) for j in range(len(candidates))]171        scores.sort(key=lambda x: x[1], reverse=True)172        return scores[:top_k]173174175# ─── 2. Rocket Launching(共享 Booster 层)────────────────────────────────────176177class RocketLaunching(nn.Module):178    """179    Rocket Launching 蒸馏范式:180    - Heavy Net(精排路径)= Booster 层 + Heavy Head181    - Light Net(粗排路径)= Booster 层(共享) + Light Head(轻量头)182    训练时让 Light Head 做 hint loss 对齐 Heavy Head 中间层(.detach() 实现 stop-gradient)。183    推理只走 Light Net(Booster + Light Head)。184    """185186    def __init__(187        self,188        n_users: int,189        n_items: int,190        emb_dim: int = 32,191        booster_dim: int = 32,192        output_dim: int = 16,193    ):194        super().__init__()195        self.n_items = n_items196        scale = math.sqrt(1.0 / emb_dim)197        self.user_emb = nn.Embedding(n_users, emb_dim)198        self.item_emb = nn.Embedding(n_items, emb_dim)199        # 共享 Booster200        self.booster = SimpleMLP([emb_dim, booster_dim])201        # Heavy Net Head202        self.heavy_head = SimpleMLP([booster_dim, 64, output_dim])203        # Light Net Head(轻量:单隐层,仍远小于 Heavy Head)204        self.light_head = SimpleMLP([booster_dim, booster_dim, output_dim])205        nn.init.uniform_(self.user_emb.weight, -scale, scale)206        nn.init.uniform_(self.item_emb.weight, -scale, scale)207208    def _booster_out(self, x: torch.Tensor) -> torch.Tensor:209        return self.booster(x)210211    def heavy_encode(self, uid: torch.Tensor, iid: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:212        ub = self._booster_out(self.user_emb(uid))213        ib = self._booster_out(self.item_emb(iid))214        return self.heavy_head(ub), self.heavy_head(ib)215216    def light_encode(self, uid: torch.Tensor, iid: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:217        ub = self._booster_out(self.user_emb(uid))218        ib = self._booster_out(self.item_emb(iid))219        return self.light_head(ub), self.light_head(ib)220221    def forward(222        self,223        uid: torch.Tensor,224        pos_iid: torch.Tensor,225        neg_iid: torch.Tensor,226    ) -> torch.Tensor:227        # Heavy Net BPR loss228        ue_h, pe_h = self.heavy_encode(uid, pos_iid)229        ue_h_neg, ne_h = self.heavy_encode(uid, neg_iid)230        heavy_loss = -F.logsigmoid(231            F.cosine_similarity(ue_h, pe_h, dim=1) - F.cosine_similarity(ue_h_neg, ne_h, dim=1)232        ).mean()233234        # Light Net hint loss:Light head 对齐 Heavy head 输出(L2 归一化后做 MSE)235        # 归一化把向量投到单位球,MSE ∈ [0, 4],与 BPR loss 量级匹配236        # .detach() 实现 stop-gradient(hint target 不回传梯度给 Heavy head)237        ub = self._booster_out(self.user_emb(uid))238        ue_l = F.normalize(self.light_head(ub), dim=1)239        hint_loss = F.mse_loss(ue_l, F.normalize(ue_h.detach(), dim=1))240241        return heavy_loss + 0.5 * hint_loss242243    def fit(244        self,245        user_seqs: Dict[int, List[int]],246        epochs: int = 6,247        lr: float = 0.01,248    ) -> None:249        """训练入口(命名为 fit 避免遮蔽 nn.Module.train())。"""250        optimizer = optim.Adam(self.parameters(), lr=lr)251        user_pos: Dict[int, set] = collections.defaultdict(set)252        for uid, seq in user_seqs.items():253            for i in seq:254                user_pos[uid].add(i)255        samples = [256            (uid, seq[t - 1], seq[t])257            for uid, seq in user_seqs.items()258            if len(seq) >= 2259            for t in range(1, len(seq))260        ]261        for epoch in range(epochs):262            random.shuffle(samples)263            total_loss = 0.0264            for uid_s, pos_id, _ in samples:265                neg_id = random.randint(0, self.n_items - 1)266                while neg_id in user_pos[uid_s]:267                    neg_id = random.randint(0, self.n_items - 1)268                uid_t = torch.tensor([uid_s])269                pos_t = torch.tensor([pos_id])270                neg_t = torch.tensor([neg_id])271                optimizer.zero_grad()272                loss = self(uid_t, pos_t, neg_t)273                loss.backward()274                nn.utils.clip_grad_norm_(self.parameters(), max_norm=1.0)275                optimizer.step()276                total_loss += loss.item()277            if epoch % 2 == 0:278                print(f"  [Rocket] epoch {epoch:3d}  avg_loss={total_loss / max(len(samples), 1):.4f}")279280    def light_pre_rank(281        self, uid: int, candidates: List[int], top_k: int = 10282    ) -> List[Tuple[int, float]]:283        """推理只走 Light Net(Booster + Light Head)。"""284        with torch.no_grad():285            ub = self._booster_out(self.user_emb(torch.tensor([uid])))286            ue = self.light_head(ub)  # [1, D]287            cand_t = torch.tensor(candidates)288            ib = self._booster_out(self.item_emb(cand_t))289            ie = self.light_head(ib)  # [N, D]290            sims = F.cosine_similarity(ue, ie, dim=1)291            scores = [(candidates[j], sims[j].item()) for j in range(len(candidates))]292        scores.sort(key=lambda x: x[1], reverse=True)293        return scores[:top_k]294295296# ─── 快速验证 ────────────────────────────────────────────────────────────────297298if __name__ == "__main__":299    random.seed(42)300    torch.manual_seed(42)301302    n_users, n_items = 8, 20303    user_seqs = {u: random.sample(range(n_items), k=random.randint(5, 10)) for u in range(n_users)}304305    print("=== Teacher-Student 蒸馏 ===")306    teacher = TeacherModel(n_users, n_items, emb_dim=16, output_dim=16)307    student = StudentDSSM(n_users, n_items, emb_dim=16, output_dim=16, temperature=1.0)308    student.train_distill(user_seqs, teacher, epochs=6, lr=0.05)309    uid, seq = 0, user_seqs[0]310    cands = [i for i in range(n_items) if i not in set(seq)]311    for iid, s in student.pre_rank(uid, cands, top_k=5):312        print(f"  item {iid}: {s:.4f}")313314    print("\n=== Rocket Launching ===")315    rocket = RocketLaunching(n_users, n_items, emb_dim=16, booster_dim=16, output_dim=16)316    rocket.fit(user_seqs, epochs=6, lr=0.05)317    for iid, s in rocket.light_pre_rank(uid, cands, top_k=5):318        print(f"  item {iid}: {s:.4f}")

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

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