CH 12 / SOURCE EXAMPLE
ple.py
ch12 精排模型/code/ch03_multi_task/ple.py
这个示例讲什么
│ └── ple.py PLE/CGC:任务专属专家 + 共享专家逐层分离
PLE 多任务精排模型(第12章 §12.4.2.2) Progressive Layered Extraction(腾讯 RecSys 2020): CGC(Customized Gate Control)模块: 在共享专家之外,每个任务有专属专家; 每个任务的门控混合「本任务专属专家 + 共享专家」: f^(k) = Σ g_i^(k) · e_i^(k 或 shared) 共享专家另有独立的共享门控,聚合出跨任务的共享表示, 作为下一层 CGC 共享专家的输入。 PLE:将多个 CGC 模块逐层堆叠,逐步分离不同任务的特征表示: 第 1 层 CGC → 第 2 层 CGC → … → 各任务塔网络 与 MMoE 的区别: - MMoE : 所有任务仅使用共享专家,通过独立门控选择组合; - CGC : 引入任
上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
_auc· 第 35 行_mlp· 第 44 行CGCLayer· 第 55 行一个 CGC 抽取层。PLE· 第 142 行PLE:逐层堆叠 CGC,再接各任务塔网络。make_data· 第 207 行两任务的输入特征互不重叠:
1"""2PLE 多任务精排模型(第12章 §12.4.2.2)34Progressive Layered Extraction(腾讯 RecSys 2020):56CGC(Customized Gate Control)模块:7 在共享专家之外,每个任务有专属专家;8 每个任务的门控混合「本任务专属专家 + 共享专家」:910 f^(k) = Σ g_i^(k) · e_i^(k 或 shared)1112 共享专家另有独立的共享门控,聚合出跨任务的共享表示,13 作为下一层 CGC 共享专家的输入。1415PLE:将多个 CGC 模块逐层堆叠,逐步分离不同任务的特征表示:1617 第 1 层 CGC → 第 2 层 CGC → … → 各任务塔网络1819与 MMoE 的区别:20 - MMoE : 所有任务仅使用共享专家,通过独立门控选择组合;21 - CGC : 引入任务专属专家,共享专家补充跨任务信号;22 - PLE : 逐层堆叠 CGC,使任务特异性随层深递增,减少负迁移。2324本书依据:《第十二章-精排模型.pdf》§12.4.2.2,PDF 第 35–40 页(多任务部分)。25依赖:PyTorch。运行:python3 ple.py26"""2728import random29import torch30import torch.nn as nn31import torch.nn.functional as F32import torch.optim as optim333435def _auc(scores, labels) -> float:36 pos = [s for s, y in zip(scores, labels) if y == 1]37 neg = [s for s, y in zip(scores, labels) if y == 0]38 if not pos or not neg:39 return 0.540 return sum(1.0 if p > n else 0.5 if p == n else 0.041 for p in pos for n in neg) / (len(pos) * len(neg))424344def _mlp(dims: list[int]) -> nn.Sequential:45 layers = []46 for i in range(len(dims) - 1):47 layers.append(nn.Linear(dims[i], dims[i + 1]))48 if i < len(dims) - 2:49 layers.append(nn.ReLU())50 return nn.Sequential(*layers)515253# ── CGC 抽取层 ────────────────────────────────────────────────────────────────5455class CGCLayer(nn.Module):56 """57 一个 CGC 抽取层。5859 正文说明(PDF §12.4.2.2):60 「CGC 模块在共享专家之外,为每个任务设置专属专家;61 每个任务的门控混合本任务专属专家和共享专家的输出。」6263 共享专家的输入是上一层聚合的共享表示(首层为原始输入),64 而非某个任务的输出——这是 PLE 与朴素 MMoE 堆叠的关键差异。6566 参数:67 in_dim : 输入维度(上一层输出或原始特征)68 hidden : 专家内部隐藏/输出维度69 n_task_experts: 每个任务的专属专家数70 n_shared : 共享专家数71 n_tasks : 任务数72 """7374 def __init__(self, in_dim: int, hidden: int,75 n_task_experts: int, n_shared: int, n_tasks: int):76 super().__init__()77 self.n_tasks = n_tasks78 self.n_task_exp = n_task_experts79 self.n_shared = n_shared8081 # 任务专属专家: n_tasks 组,每组 n_task_experts 个82 self.task_experts = nn.ModuleList([83 nn.ModuleList([84 nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU(),85 nn.Linear(hidden, hidden), nn.ReLU())86 for _ in range(n_task_experts)87 ])88 for _ in range(n_tasks)89 ])9091 # 共享专家(接收共享输入)92 self.shared_experts = nn.ModuleList([93 nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU(),94 nn.Linear(hidden, hidden), nn.ReLU())95 for _ in range(n_shared)96 ])9798 # 每个任务的门控:任务输入 → 任务专属(n_task_experts) + 共享(n_shared) → softmax99 gate_out = n_task_experts + n_shared100 self.task_gates = nn.ModuleList([101 nn.Linear(in_dim, gate_out) for _ in range(n_tasks)])102103 # 共享门控:共享输入 → n_shared → softmax104 self.shared_gate = nn.Linear(in_dim, n_shared)105106 def forward(self, task_inputs: list[torch.Tensor],107 shared_input: torch.Tensor108 ) -> tuple[list[torch.Tensor], torch.Tensor]:109 """110 task_inputs : list of T tensors, 每个 (B, in_dim)111 第 k 个元素是任务 k 的上一层输出(首层均为原始输入)112 shared_input : (B, in_dim) 上一层聚合的共享表示(首层为原始输入)113 返回:114 new_task_outputs : list of T tensors, 每个 (B, hidden)115 new_shared : (B, hidden)116 """117 # 共享专家:用共享输入计算一次,所有任务共享118 shared_outs = [e(shared_input) for e in self.shared_experts]119 shared_stack = torch.stack(shared_outs, dim=1) # (B, n_shared, H)120121 # 共享门控 → 聚合共享专家为下一层的共享输入122 sg = F.softmax(self.shared_gate(shared_input), dim=1) # (B, n_shared)123 new_shared = (sg.unsqueeze(-1) * shared_stack).sum(dim=1) # (B, H)124125 new_task_outputs = []126 for k in range(self.n_tasks):127 # 任务 k 的专属专家(用任务 k 自己的上一层输入)128 task_outs = [e(task_inputs[k]) for e in self.task_experts[k]]129130 # 任务专属专家 + 共享专家拼在一起供门控选择131 all_outs = torch.stack(task_outs + shared_outs, dim=1) # (B, E_k+S, H)132133 g = F.softmax(self.task_gates[k](task_inputs[k]), dim=1) # (B, E_k+S)134 f_k = (g.unsqueeze(-1) * all_outs).sum(dim=1) # (B, H)135 new_task_outputs.append(f_k)136137 return new_task_outputs, new_shared138139140# ── PLE 模型 ─────────────────────────────────────────────────────────────────141142class PLE(nn.Module):143 """144 PLE:逐层堆叠 CGC,再接各任务塔网络。145146 正文(PDF §12.4.2.2):147 「PLE 通过多层网络逐步完成信息提取与任务分离,148 随层深递增,减少任务间的负迁移(Negative Transfer)。」149 """150151 def __init__(self, in_dim: int, n_tasks: int,152 hidden: int = 16, n_layers: int = 2,153 n_task_experts: int = 2, n_shared: int = 2,154 tower_hidden: int = 16):155 super().__init__()156 self.n_tasks = n_tasks157 self.n_layers = n_layers158159 # 逐层 CGC(各层维度相同,方便堆叠)160 self.cgc_layers = nn.ModuleList()161 cur_dim = in_dim162 for _ in range(n_layers):163 self.cgc_layers.append(164 CGCLayer(cur_dim, hidden, n_task_experts, n_shared, n_tasks))165 cur_dim = hidden166167 # 各任务塔网络168 self.towers = nn.ModuleList([169 _mlp([cur_dim, tower_hidden, 1]) for _ in range(n_tasks)])170171 def forward(self, x: torch.Tensor) -> torch.Tensor:172 # 初始时各任务输入与共享输入均为原始特征173 task_inputs = [x] * self.n_tasks174 shared_input = x175176 # 逐层 CGC177 for layer in self.cgc_layers:178 task_inputs, shared_input = layer(task_inputs, shared_input)179180 # 各任务塔181 return torch.cat([self.towers[k](task_inputs[k])182 for k in range(self.n_tasks)], dim=1) # (B, T)183184 def fit(self, x, ys, epochs: int = 100, lr: float = 5e-3,185 task_weights=None):186 w = task_weights or [1.0] * self.n_tasks187 opt = optim.Adam(self.parameters(), lr=lr, weight_decay=1e-5)188 for ep in range(epochs):189 opt.zero_grad()190 logits = self.forward(x)191 loss = sum(192 w[k] * F.binary_cross_entropy_with_logits(193 logits[:, k], ys[:, k].float())194 for k in range(self.n_tasks))195 loss.backward()196 opt.step()197 if (ep + 1) % 20 == 0:198 print(f" epoch {ep+1:3d} total_loss={loss.item():.4f}")199200 def predict_proba(self, x) -> torch.Tensor:201 with torch.no_grad():202 return torch.sigmoid(self.forward(x))203204205# ── 合成数据 ──────────────────────────────────────────────────────────────────206207def make_data(n: int = 2400, in_dim: int = 16, seed: int = 42):208 """209 两任务的输入特征互不重叠:210 任务 0(点击)依赖 x0–x3 的线性与 x1*x2 交互;211 任务 1(完播)依赖 x6–x9 的线性与 x7*x8 交互。212 每个任务都有自己的专属信号,正是 CGC/PLE 的任务专属专家213 要捕捉的对象;共享专家则负责跨任务的共性表示。214 与 mmoe.py 共用同一份数据生成逻辑,方便公平对比。215 """216 torch.manual_seed(seed)217 random.seed(seed)218 x = torch.randn(n, in_dim)219220 l0 = 2.2 * x[:, 0] + 1.8 * (x[:, 1] * x[:, 2]) + 1.0 * x[:, 3] - 0.3221 l1 = 2.2 * x[:, 6] + 1.8 * (x[:, 7] * x[:, 8]) + 1.0 * x[:, 9] - 0.3222223 y0 = (torch.sigmoid(l0 + 0.35 * torch.randn(n)) > 0.5).long()224 y1 = (torch.sigmoid(l1 + 0.35 * torch.randn(n)) > 0.5).long()225226 return x, torch.stack([y0, y1], dim=1)227228229# ── 主演示 ────────────────────────────────────────────────────────────────────230231if __name__ == "__main__":232 random.seed(42)233 torch.manual_seed(42)234235 IN_DIM, N_TASKS = 16, 2236 x, y = make_data(2400, IN_DIM)237 split = 1920238 x_tr, y_tr = x[:split], y[:split]239 x_te, y_te = x[split:], y[split:]240241 # ── 对比基线 1:Shared-Bottom(与 mmoe.py 配置一致)───────────────────────242 from mmoe import MMoE, SharedBottom243 torch.manual_seed(42)244 sb = SharedBottom(IN_DIM, N_TASKS, bottom_hidden=8, tower_hidden=16)245 opt_sb = optim.Adam(sb.parameters(), lr=5e-3, weight_decay=1e-5)246 for _ in range(120):247 opt_sb.zero_grad()248 logits = sb(x_tr)249 loss = sum(F.binary_cross_entropy_with_logits(250 logits[:, k], y_tr[:, k].float()) for k in range(N_TASKS))251 loss.backward()252 opt_sb.step()253 with torch.no_grad():254 p_sb = torch.sigmoid(sb(x_te))255 auc_sb = [_auc(p_sb[:, k].tolist(), y_te[:, k].tolist())256 for k in range(N_TASKS)]257 print(f"\nShared-Bottom Task0 AUC={auc_sb[0]:.4f} Task1 AUC={auc_sb[1]:.4f}")258259 # ── 对比基线 2:MMoE(来自正文 §12.4.2.1 的对比) ──────────────────────────260 torch.manual_seed(42)261 mmoe = MMoE(IN_DIM, n_experts=4, n_tasks=N_TASKS,262 expert_hidden=8, tower_hidden=16)263 mmoe.fit(x_tr, y_tr, epochs=120, lr=5e-3)264 p_mmoe = mmoe.predict_proba(x_te)265 auc_mmoe = [_auc(p_mmoe[:, k].tolist(), y_te[:, k].tolist())266 for k in range(N_TASKS)]267 print(f"\nMMoE Task0 AUC={auc_mmoe[0]:.4f} Task1 AUC={auc_mmoe[1]:.4f}")268269 # ── PLE(2 层 CGC,每任务 2 专属专家 + 2 共享专家)─────────────────────────270 torch.manual_seed(42)271 ple = PLE(IN_DIM, N_TASKS, hidden=8, n_layers=2,272 n_task_experts=2, n_shared=2, tower_hidden=16)273 print(f"\nPLE 训练(2 层 CGC,各任务 2 专属 + 2 共享专家,专家宽度 8)")274 ple.fit(x_tr, y_tr, epochs=100, lr=5e-3)275276 p_ple = ple.predict_proba(x_te)277 auc_ple = [_auc(p_ple[:, k].tolist(), y_te[:, k].tolist())278 for k in range(N_TASKS)]279 print(f"\nPLE Task0 AUC={auc_ple[0]:.4f} Task1 AUC={auc_ple[1]:.4f}")280281 # ── 三模型对比 ────────────────────────────────────────────────────────────282 print(f"\n对比(正文 §12.4.2.2):")283 print(f" Shared-Bottom avg AUC={sum(auc_sb)/2:.4f}")284 print(f" MMoE avg AUC={sum(auc_mmoe)/2:.4f}")285 print(f" PLE avg AUC={sum(auc_ple)/2:.4f}")286 print(" PLE 通过任务专属专家 + 共享专家逐层分离,")287 print(" 在任务特征支持不重叠时进一步减少负迁移,超越 MMoE。")288289 # ── 门控分析(PLE 最后一层 CGC 的任务专属专家权重)──────────────────────────290 print(f"\n最后一层 CGC 门控权重分析(前 20 样本平均):")291 with torch.no_grad():292 # 取得倒数第二层(即最后 CGC 的输入)293 task_in = [x_te[:20]] * N_TASKS294 shared_in = x_te[:20]295 for layer in ple.cgc_layers[:-1]:296 task_in, shared_in = layer(task_in, shared_in)297 last_cgc = ple.cgc_layers[-1]298 n_te = last_cgc.n_task_exp299 n_sh = last_cgc.n_shared300 for k in range(N_TASKS):301 g = F.softmax(last_cgc.task_gates[k](task_in[k]), dim=1)302 g_mean = g.mean(dim=0)303 task_w = g_mean[:n_te].sum().item()304 shared_w = g_mean[n_te:].sum().item()305 print(f" Task{k}: 专属专家权重合计={task_w:.3f} "306 f"共享专家权重合计={shared_w:.3f}")307 print(" 正文(PDF §12.4.2.2):任务差异越大,专属专家权重占比应越高。")308309 # ── 精排推理演示(任务 0)─────────────────────────────────────────────────310 scores_t0 = p_ple[:20, 0].tolist()311 top5 = sorted(enumerate(scores_t0), key=lambda t: -t[1])[:5]312 print("\n按任务0(点击)精排 Top-5(候选集 20 条):")313 for rank_i, (idx, s) in enumerate(top5, 1):314 print(f" #{rank_i} item={idx:2d} p_click={s:.4f} "315 f"label_click={y_te[idx, 0].item()}")如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。