CH 12 / SOURCE EXAMPLE
mmoe.py
ch12 精排模型/code/ch03_multi_task/mmoe.py
这个示例讲什么
│ ├── mmoe.py MMoE:共享专家 + 任务独立门控
MMoE 多任务精排模型(第12章 §12.4.2.1) Multi-gate Mixture-of-Experts(Google KDD 2018): e_i = f_i(x), i=1…n # n 个共享专家(MLP) g^(k)(x) = softmax(W_g^(k) x), # 第 k 个任务的门控向量 ∈ ℝ^n f^(k)(x) = Σ_i g_i^(k)(x) e_i # 任务 k 的加权专家输出 ŷ_k = h_k(f^(k)(x)) # 任务 k 的独立塔网络 与 Shared-Bottom / OMoE 对比: - Shared-Bottom : f^(k)(x) = f(x) 所有任务固定共享同一底层 - OMoE :
上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
_auc· 第 27 行SharedBottom· 第 38 行所有任务固定共享同一底层 MLP,再各自过塔网络。MMoE· 第 61 行MMoE:n 个共享专家 + T 个任务独立门控 + T 个塔网络。make_data· 第 144 行两任务的输入特征互不重叠:
1"""2MMoE 多任务精排模型(第12章 §12.4.2.1)34Multi-gate Mixture-of-Experts(Google KDD 2018):56 e_i = f_i(x), i=1…n # n 个共享专家(MLP)7 g^(k)(x) = softmax(W_g^(k) x), # 第 k 个任务的门控向量 ∈ ℝ^n8 f^(k)(x) = Σ_i g_i^(k)(x) e_i # 任务 k 的加权专家输出9 ŷ_k = h_k(f^(k)(x)) # 任务 k 的独立塔网络1011与 Shared-Bottom / OMoE 对比:12 - Shared-Bottom : f^(k)(x) = f(x) 所有任务固定共享同一底层13 - OMoE : f^(k)(x) = Σ g_i(x) e_i 共享门控,各任务使用相同权重14 - MMoE : f^(k)(x) = Σ g_i^(k)(x) e_i 每任务独立门控,自适应专家选择1516本书依据:《第十二章-精排模型.pdf》§12.4.2.1,PDF 第 30–35 页(多任务部分)。17依赖:PyTorch。运行:python3 mmoe.py18"""1920import random21import torch22import torch.nn as nn23import torch.nn.functional as F24import torch.optim as optim252627def _auc(scores, labels) -> float:28 pos = [s for s, y in zip(scores, labels) if y == 1]29 neg = [s for s, y in zip(scores, labels) if y == 0]30 if not pos or not neg:31 return 0.532 return sum(1.0 if p > n else 0.5 if p == n else 0.033 for p in pos for n in neg) / (len(pos) * len(neg))343536# ── Shared-Bottom(对比基线)────────────────────────────────────────────────3738class SharedBottom(nn.Module):39 """所有任务固定共享同一底层 MLP,再各自过塔网络。"""4041 def __init__(self, in_dim: int, n_tasks: int,42 bottom_hidden: int = 32, tower_hidden: int = 16):43 super().__init__()44 self.bottom = nn.Sequential(45 nn.Linear(in_dim, bottom_hidden), nn.ReLU(),46 nn.Linear(bottom_hidden, bottom_hidden), nn.ReLU(),47 )48 self.towers = nn.ModuleList([49 nn.Sequential(nn.Linear(bottom_hidden, tower_hidden), nn.ReLU(),50 nn.Linear(tower_hidden, 1))51 for _ in range(n_tasks)52 ])5354 def forward(self, x):55 h = self.bottom(x)56 return torch.cat([t(h) for t in self.towers], dim=1) # (B, T)575859# ── MMoE 模型 ─────────────────────────────────────────────────────────────────6061class MMoE(nn.Module):62 """63 MMoE:n 个共享专家 + T 个任务独立门控 + T 个塔网络。6465 正文(PDF §12.4.2.1)核心:66 「不同任务可以根据输入特征,自适应地组合不同专家的输出……67 即使输入特征相同,各任务可以生成不同的专家权重,68 从而形成不同的特征组合。」69 """7071 def __init__(self, in_dim: int, n_experts: int, n_tasks: int,72 expert_hidden: int = 32, tower_hidden: int = 16):73 super().__init__()74 self.n_experts = n_experts75 self.n_tasks = n_tasks7677 # 共享专家(结构相同,参数独立)78 self.experts = nn.ModuleList([79 nn.Sequential(80 nn.Linear(in_dim, expert_hidden), nn.ReLU(),81 nn.Linear(expert_hidden, expert_hidden), nn.ReLU(),82 )83 for _ in range(n_experts)84 ])8586 # 每个任务独立的门控线性层:x → ℝ^n_experts → softmax87 self.gates = nn.ModuleList([88 nn.Linear(in_dim, n_experts) for _ in range(n_tasks)])8990 # 每个任务独立的塔网络91 self.towers = nn.ModuleList([92 nn.Sequential(93 nn.Linear(expert_hidden, tower_hidden), nn.ReLU(),94 nn.Linear(tower_hidden, 1),95 )96 for _ in range(n_tasks)97 ])9899 def forward(self, x: torch.Tensor) -> torch.Tensor:100 """101 x : (B, in_dim)102 返回 logits : (B, T)103 """104 # 所有专家并行计算105 expert_outs = torch.stack(106 [e(x) for e in self.experts], dim=1) # (B, n, expert_hidden)107108 logits = []109 for k in range(self.n_tasks):110 g = F.softmax(self.gates[k](x), dim=1) # (B, n)111 # 加权融合专家输出112 f_k = (g.unsqueeze(-1) * expert_outs).sum(dim=1) # (B, expert_hidden)113 logits.append(self.towers[k](f_k)) # (B, 1)114115 return torch.cat(logits, dim=1) # (B, T)116117 def fit(self, x, ys, epochs: int = 40, lr: float = 1e-2,118 task_weights=None):119 """120 ys : (B, T) 多任务标签121 task_weights : 各任务损失权重(默认均等)122 """123 w = task_weights or [1.0] * self.n_tasks124 opt = optim.Adam(self.parameters(), lr=lr, weight_decay=1e-5)125 for ep in range(epochs):126 opt.zero_grad()127 logits = self.forward(x) # (B, T)128 loss = sum(129 w[k] * F.binary_cross_entropy_with_logits(130 logits[:, k], ys[:, k].float())131 for k in range(self.n_tasks))132 loss.backward()133 opt.step()134 if (ep + 1) % 10 == 0:135 print(f" epoch {ep+1:3d} total_loss={loss.item():.4f}")136137 def predict_proba(self, x) -> torch.Tensor:138 with torch.no_grad():139 return torch.sigmoid(self.forward(x)) # (B, T)140141142# ── 合成数据:两任务存在显式冲突 ─────────────────────────────────────────────143144def make_data(n: int = 2400, in_dim: int = 16, seed: int = 42):145 """146 两任务的输入特征互不重叠:147 任务 0(点击)依赖 x0–x3 的线性与 x1*x2 交互;148 任务 1(完播)依赖 x6–x9 的线性与 x7*x8 交互。149 模拟正文所述「不同任务关注重点存在明显差异」的场景——150 每个任务都有自己的专属信号,Shared-Bottom 被迫用同一底层表示151 同时拟合两组不相关的特征,容易出现负迁移。152 """153 torch.manual_seed(seed)154 random.seed(seed)155 x = torch.randn(n, in_dim)156157 l0 = 2.2 * x[:, 0] + 1.8 * (x[:, 1] * x[:, 2]) + 1.0 * x[:, 3] - 0.3158 l1 = 2.2 * x[:, 6] + 1.8 * (x[:, 7] * x[:, 8]) + 1.0 * x[:, 9] - 0.3159160 y0 = (torch.sigmoid(l0 + 0.35 * torch.randn(n)) > 0.5).long()161 y1 = (torch.sigmoid(l1 + 0.35 * torch.randn(n)) > 0.5).long()162163 return x, torch.stack([y0, y1], dim=1)164165166# ── 主演示 ────────────────────────────────────────────────────────────────────167168if __name__ == "__main__":169 random.seed(42)170 torch.manual_seed(42)171172 IN_DIM, N_TASKS = 16, 2173 x, y = make_data(2400, IN_DIM)174 split = 1920175 x_tr, y_tr = x[:split], y[:split]176 x_te, y_te = x[split:], y[split:]177178 # ── Shared-Bottom 基线(容量受限:bottom_hidden=8)───────────────────────179 # 正文(PDF §12.4.2.1)指出 Shared-Bottom 的局限:180 # 「所有任务必须使用相同的底层表示。当不同任务的关注重点存在明显差异时,181 # 共享表示可能难以同时满足所有任务的需求。」182 # 用较小的底层容量来凸显这一局限。183 torch.manual_seed(42)184 sb = SharedBottom(IN_DIM, N_TASKS, bottom_hidden=8, tower_hidden=16)185 opt_sb = optim.Adam(sb.parameters(), lr=5e-3, weight_decay=1e-5)186 for ep in range(120):187 opt_sb.zero_grad()188 logits = sb(x_tr)189 loss = sum(F.binary_cross_entropy_with_logits(190 logits[:, k], y_tr[:, k].float()) for k in range(N_TASKS))191 loss.backward()192 opt_sb.step()193 with torch.no_grad():194 p_sb = torch.sigmoid(sb(x_te))195 auc_sb = [_auc(p_sb[:, k].tolist(), y_te[:, k].tolist()) for k in range(N_TASKS)]196 print(f"Shared-Bottom(h=8) Task0 AUC={auc_sb[0]:.4f} Task1 AUC={auc_sb[1]:.4f}")197198 # ── MMoE ─────────────────────────────────────────────────────────────────199 # 4 个共享专家,每个专家容量与 Shared-Bottom 底层一致(h=8),200 # 但通过独立门控让两个任务选择不同的专家组合。201 torch.manual_seed(42)202 mmoe = MMoE(IN_DIM, n_experts=4, n_tasks=N_TASKS,203 expert_hidden=8, tower_hidden=16)204 print(f"\nMMoE 训练(4 个共享专家 h=8,2 个独立门控)")205 mmoe.fit(x_tr, y_tr, epochs=120, lr=5e-3)206207 p_mmoe = mmoe.predict_proba(x_te)208 auc_mmoe = [_auc(p_mmoe[:, k].tolist(), y_te[:, k].tolist())209 for k in range(N_TASKS)]210 print(f"\nMMoE(4 专家 h=8) Task0 AUC={auc_mmoe[0]:.4f} Task1 AUC={auc_mmoe[1]:.4f}")211212 # ── 门控权重分析 ──────────────────────────────────────────────────────────213 print(f"\n门控权重(前 20 样本平均,正文:不同任务应学到不同专家偏好):")214 with torch.no_grad():215 for k in range(N_TASKS):216 g = F.softmax(mmoe.gates[k](x_te[:20]), dim=1)217 g_mean = g.mean(dim=0)218 print(f" Task{k} 专家权重: " +219 " ".join(f"E{i}={g_mean[i].item():.3f}"220 for i in range(4)))221222 # ── 与 Shared-Bottom 对比 ────────────────────────────────────────────────223 print(f"\n对比(正文 §12.4.2.1):")224 print(f" Shared-Bottom avg AUC={sum(auc_sb)/2:.4f} "225 f"MMoE avg AUC={sum(auc_mmoe)/2:.4f}")226 print(" MMoE 通过任务独立门控,让冲突任务选择不同专家组合,")227 print(" 缓解了 Shared-Bottom 中「所有任务被迫使用相同底层表示」的局限。")228229 # ── 精排推理演示(任务 0:点击)────────────────────────────────────────────230 scores_t0 = p_mmoe[:20, 0].tolist()231 top5 = sorted(enumerate(scores_t0), key=lambda t: -t[1])[:5]232 print("\n按任务0(点击)精排 Top-5(候选集 20 条):")233 for rank_i, (idx, s) in enumerate(top5, 1):234 print(f" #{rank_i} item={idx:2d} p_click={s:.4f} "235 f"label_click={y_te[idx, 0].item()}")如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。