PEPNet多域推荐实战:PPNet与EPNet门控机制解析
1. 多域推荐的老问题以及 PEPNet 到底想解决什么做推荐系统的人大概都经历过这样一个阶段业务线越铺越多模型却越做越拧巴。短视频、直播、电商、本地生活每个场景都有自己的数据分布和用户行为模式可你又不可能给每条业务线单独维护一套完整的召回加排序模型。算力扛不住维护成本也扛不住。于是大家很自然地走向共享底层 多任务/多域头的架构。这个方向没错真正让人头疼的是共享这件事本身。共享底层的好处很直接——参数利用率高、冷启动场景有兜底、训练样本被充分利用。但共享带来的副作用同样明显不同域的样本会互相稀释一个域里高频的行为模式会主导底层参数的更新方向导致其他域的表现被拉平。你会看到一种很典型的线上现象大盘指标微涨但某个垂直场景的点击率反而掉了。排查下来问题就出在底层 embedding 和参数被主域带偏了。PEPNet 这个结构全称是 Parameter and Embedding Personalized Network参数与嵌入个性化网络就是冲着这个矛盾来的。它的核心主张非常朴素底层参数和 embedding 可以共享但共享的方式要看人下菜碟——每个用户、每个域在复用同一份底层能力时应该拿到一份属于自己的加权版本。它不是把参数做成完全独立的那又回到了各做各的它是让共享的参数在输出时被一个由个性化先验信息生成的门缩放一下从而在同一套底座上逼出接近个性化的效果。这套思路适合谁看如果你正在做多域或多场景的排序模型如果你手上有 MMoE、PLE 这类多任务底座但效果已经顶到天花板如果你对门控机制、特征交互、embedding 个性化这些方向感兴趣那这篇会很有参考价值。我会从架构动机讲到核心细节再给出可直接抄的 PyTorch 实现和训练配置最后重点说说我踩过的坑——那些论文里不会写、但上线一定会遇到的问题。2. PEPNet 的架构拆解PPNet 与 EPNet 分别在做什么2.1 先看清楚它要改造的两个对象要理解 PEPNet得先明确它动刀子的两个地方一个是DNN 的隐层参数一个是底层的 embedding 表。在常规多任务模型里这两块通常是全局共享的——所有域、所有任务共用同一份。PEPNet 的切入点就在这它给这两块各配了一个门控网络分别叫 PPNetParameter Personalized Network参数个性化网络和 EPNetEmbedding Personalized Network嵌入个性化网络。名字里的Personalized不是泛指个性化推荐而是强调门控信号来自用户和域的个性化先验。2.2 PPNet给每一层 DNN 输出乘一道门PPNet 的做法是在共享 DNN 的每一层后面插入一次逐元素乘法。设第 i 层的线性变换输出为 $h_i W_i h_{i-1} b_i$PPNet 引入一个由先验特征 $x_p$ 生成的门控向量 $g_i(x_p)$把这一层的输出改成 $h_i g_i(x_p) \odot h_i$。这里的 $\odot$ 是逐元素相乘维度对齐到该层输出维度。关键点在于门控不是加一个偏置而是做乘法缩放。乘法的好处是它能保留原始信息的方向只是调整每一维的强度。如果某一维在当前用户/域下很重要门控值接近 1信息几乎原样通过如果这一维在当前场景下是噪声门控值被压到接近 0就相当于把这条通路逐渐关掉。这比加法更容易学到门开多大这种相对关系也更符合我们想让共享参数被差异化使用的直觉。门控网络本身很轻。论文里的做法是用一个小的 MLP输入是先验特征输出维度等于对应层的输出维度。为了不让参数量爆炸实践中普遍会做低秩分解先降到一个较小的中间维度再升回到目标维度。我在实现时一般把中间维度控制在该层输出维度的四分之一左右配上 SiLU 激活再过一个 Sigmoid把门控值约束在 (0,1) 之间。2.3 EPNet让每个特征域的 embedding 也被缩放如果说 PPNet 管的是高层抽象怎么组合EPNet 管的就是底层原材料怎么加权。在多域模型里底层 embedding 表是所有域共用的不同域的用户对同一个 ID 类特征的语义理解可能完全不同。比如同一个视频 ID在推荐信息流里代表内容偏好在直播场景里可能代表主播关联直接共用一份 embedding语义就会打架。EPNet 的思路是对每个特征域field的 embedding 单独生成一个门控做逐元素或逐域的缩放。生成门控的输入同样是先验特征 $x_p$输出维度对应特征域的数量。这样同一个底层 embedding 在不同域下会被缩放成不同的版本既保留了共享带来的泛化和冷启动优势又给了模型差异化表达的空间。2.4 两种门控的协同关系PPNet 和 EPNet 不是二选一而是可以叠加使用的。EPNet 作用在输入侧决定了底层特征以什么强度进入网络PPNet 作用在网络内部决定了每层抽象以什么方式被组合。我个人的经验是如果你的主要矛盾是不同域的特征语义冲突优先上 EPNet如果主要矛盾是高层表达被主域主导那 PPNet 的收益会更明显。两者同时上通常还能有额外增益但训练难度也会上升需要更细的调参。模块作用对象门控粒度主要解决的问题参数开销EPNet底层 embedding按特征域field跨域特征语义冲突、共享 embedding 被稀释低与 field 数相关PPNetDNN 每层输出按输出维度高层表达被主域主导、共享参数同质化中与层宽和层数相关二者叠加embedding 隐层双层门控同时优化输入侧和内部表达中高需调参配合3. 门控网络的设计细节为什么这么设计才有效3.1 先验特征到底放什么门控的效果八成取决于先验特征选得对不对。这里的先验特征 $x_p$ 不是随便拿几个用户特征塞进去就行它需要包含两类信息用户侧的个性化信息和场景/域侧的上下文信息。具体来说用户 ID 的 embedding、用户画像标签年龄、性别、活跃度分档、实时行为统计近 1 小时点击次数、近 7 天某类目曝光占比再加上域标识domain id、当前请求来源、时段等构成一个比较完整的先验向量。我特别想强调域标识这个特征。很多同学做多域模型时把 domain id 只放在最终的 task tower 里觉得底层不用感知域。但在 PEPNet 里恰恰相反域信息必须进到门控网络因为门控的职责就是根据当前是哪个域来调整共享参数的用法。如果不给域特征门控网络就失去了最关键的判别信号学出来的门控会退化成对所有域都差不多的形状效果大打折扣。这是我在第一次复现时踩的坑加回 domain 特征后离线 AUC 直接涨了一截。3.2 为什么用乘法而不是加法为什么门控要归一化先说乘法的选择。假设门控做成加法$h_i h_i g_i(x_p)$那它本质上就是给每层加了一个依赖上下文的偏置项。加法的问题是它不改变各维度之间的相对重要性只是整体平移信息通路该通的还是通该淹的还是会淹。而乘法是尺度变换能直接调节每一维的话语权更接近我们想要的效果。再说归一化。门控值用 Sigmoid 压到 (0,1)主要目的是稳定训练。如果不加约束门控值可能学得特别大或特别小导致梯度爆炸或消失。压到 (0,1) 之后门控的物理意义也清晰了0 表示完全关闭1 表示完全放行中间值表示部分保留。有些实现会用 2×Sigmoid 把范围放到 (0,2)允许一定程度的放大这个看具体场景我一般先用 (0,1)不够再放宽。注意门控网络不需要太深一般两层就够。层数一多门控自己就成了一个复杂模型既拖慢训练又容易过拟合先验特征反而削弱了共享底座的价值。3.3 低秩分解省参数也稳训练PPNet 每一层都要一个门控如果门控网络是全连接的参数量是先验维度 × 层输出维度层一多就非常可观。所以实践中几乎都会做低秩分解先降维到一个较小的隐藏维度再升回目标维度。这样参数从 $d_p \times d_h$ 降到 $d_p \times r r \times d_h$其中 $r \ll d_h$。除了省参数低秩还有一个隐性好处——它对门控网络的表达能力做了平滑约束不容易学出特别尖锐、特别依赖个别样本的门控形状泛化会更稳。3.4 初始化与激活的取舍门控网络的初始化很关键。如果最后一层初始化为全 0Sigmoid 输出就是 0.5相当于所有维度初始都被打五折虽然能保证训练初期稳定但会拖慢收敛。我一般让最后一层的权重初始化为接近 0 的小值、偏置初始化为一个正数比如 1.0 左右这样初始门控值偏大等价于先基本放行再慢慢学该关哪里。激活用 SiLU也叫 Swish比 ReLU 更平滑在门控这种需要连续调节的结构里表现通常更稳。4. 从零实现一个可跑的 PEPNet代码、数据与训练配置4.1 数据准备与特征划分在动手写模型之前数据层面有两件事必须理清楚。第一域标识怎么构造。如果你的数据本身带业务线字段直接用如果没有可能需要根据请求来源、内容类型等规则打标。第二先验特征和普通特征怎么分。我把特征分成三组普通特征走 embedding 后进主网络先验特征单独抽出来去重后进一个专门的先验处理分支域特征同时进先验分支和任务塔。别小看这个划分它决定了后面所有门控输入的正确性我在项目里专门写了一个特征配置表来管理避免上线时漏字段。# 特征配置示例注意哪些特征进先验分支 feature_config { user_id: {type: categorical, bucket: 2_000_000, to_prior: True}, domain_id: {type: categorical, bucket: 32, to_prior: True, to_tower: True}, age_bucket: {type: categorical, bucket: 10, to_prior: True}, hour_of_day: {type: categorical, bucket: 24, to_prior: True}, item_id: {type: categorical, bucket: 5_000_000, to_prior: False}, item_cate: {type: categorical, bucket: 5000, to_prior: False}, }4.2 EPNet 的实现EPNet 的输入是每个 field 的 embedding 和先验向量输出是缩放后的 embedding。实现上要特别注意维度的对齐门控输出是 field 数量需要 broadcast 到 embedding 维度上。import torch import torch.nn as nn import torch.nn.functional as F class EPNet(nn.Module): def __init__(self, num_fields, prior_dim, hidden64): super().__init__() self.num_fields num_fields # 为每个 field 生成一个标量门控input 先验output field 数 self.gate nn.Sequential( nn.Linear(prior_dim, hidden), nn.SiLU(), nn.Linear(hidden, num_fields), nn.Sigmoid(), ) # 让初始门控偏大先基本放行 nn.init.constant_(self.gate[-2].bias, 1.0) def forward(self, embs, prior_vec): # embs: [B, num_fields, emb_dim] g self.gate(prior_vec) # [B, num_fields] g g.unsqueeze(-1) # [B, num_fields, 1] return embs * g # broadcast 相乘4.3 PPNet 的实现PPNet 是对共享 DNN 的包装每一层线性输出后乘门控。这里用低秩分解控制参数量。class PPNetLayer(nn.Module): def __init__(self, in_dim, out_dim, prior_dim, rank_ratio0.25): super().__init__() self.linear nn.Linear(in_dim, out_dim) rank max(int(out_dim * rank_ratio), 8) self.gate_net nn.Sequential( nn.Linear(prior_dim, rank), nn.SiLU(), nn.Linear(rank, out_dim), nn.Sigmoid(), ) nn.init.constant_(self.gate_net[-2].bias, 1.0) def forward(self, x, prior_vec): h self.linear(x) g self.gate_net(prior_vec) # [B, out_dim] return F.silu(h * g) class PPNet(nn.Module): def __init__(self, in_dim, hidden_dims, prior_dim): super().__init__() dims [in_dim] hidden_dims self.layers nn.ModuleList([ PPNetLayer(dims[i], dims[i 1], prior_dim) for i in range(len(hidden_dims)) ]) def forward(self, x, prior_vec): for layer in self.layers: x layer(x, prior_vec) return x4.4 与 MMoE 底座集成PEPNet 通常不会单独用而是作为 MMoE 或 PLE 的底座。集成顺序是embedding 先过 EPNet再进 PPNetPPNet 输出作为 MMoE 的专家输入最后各任务塔出预测。class PEPNetMMoE(nn.Module): def __init__(self, num_fields, emb_dim, prior_dim, num_tasks2, num_experts8, expert_dim128): super().__init__() self.emb_dim emb_dim self.epnet EPNet(num_fields, prior_dim) total_dim num_fields * emb_dim self.ppnet PPNet(total_dim, [256, 128], prior_dim) self.experts nn.ModuleList([ nn.Sequential(nn.Linear(128, expert_dim), nn.SiLU()) for _ in range(num_experts) ]) self.gates nn.ModuleList([ nn.Linear(128, num_experts) for _ in range(num_tasks) ]) self.towers nn.ModuleList([ nn.Sequential(nn.Linear(expert_dim, 64), nn.SiLU(), nn.Linear(64, 1)) for _ in range(num_tasks) ]) def forward(self, embs, prior_vec): e self.epnet(embs, prior_vec) # [B, F, D] e e.flatten(start_dim1) h self.ppnet(e, prior_vec) # [B, 128] expert_out torch.stack([ex(h) for ex in self.experts], dim1) # [B, E, D] task_logits [] for i in range(len(self.towers)): w F.softmax(self.gates[i](h), dim-1) # [B, E] mixed torch.einsum(be,bed-bd, w, expert_out) task_logits.append(self.towers[i](mixed)) return torch.cat(task_logits, dim-1)4.5 训练配置与调参心得训练上我给几个具体的建议值都是实测比较稳的起点。优化器用 Adam学习率门控分支可以和主网络分开设——主网络 1e-3门控网络我用 5e-4因为门控学得太快反而会不稳定。Batch size 根据显存开大一点2048 起步多域场景下 batch 内最好能覆盖到多个域可以用分域采样保证每个 batch 里各域都有样本。正则方面门控网络加一点 weight decay但不要太大1e-5 到 1e-4 之间。配置项建议值说明优化器Adam门控分支单独设更小学习率主网络学习率1e-3常规起点门控网络学习率5e-4学快了会震荡Batch size2048保证 batch 内多域覆盖门控 rank 比例0.25太低表达不足太高易过拟合weight decay1e-5~1e-4门控网络加一点即可5. 上线前后容易踩的坑与排查手册5.1 门控值塌陷所有输出都趋同这是最常见也最让人抓狂的问题。训练一段时间后去看门控输出发现几乎所有样本、所有维度都稳定在 0.5 附近方差极小门控基本失效。原因通常有三个一是先验特征太弱门控网络学不出差异二是门控网络的正则太强把权重压平了三是最后一层初始化不当导致梯度信号传不进去。排查顺序我一般是这样先看先验特征的分布确认域特征和用户特征确实有区分度再检查初始化把最后一层 bias 调大一点最后适当调小门控的学习率别让它跑得太快。注意门控均值偏离 0.5 很多、但方差很小说明模型偏置了均值接近 0.5、方差也很小才是真正塌陷。这两种情况的处理方式不一样前者调 bias后者查特征。5.2 多域数据不均衡导致的偏斜多域场景里主域样本可能占了七成以上。如果直接混采门控网络会被主域主导学出来的门控对长尾域几乎没有区分能力。解决办法有两个层面数据层面做分域采样人为提升长尾域在 batch 里的占比损失层面可以做分域加权或者对每个域单独监控指标。我实际用下来分域采样是最直接有效的但要注意别采得太狠不然主域指标会掉需要在上线前做 A/B 平衡。5.3 训练不稳与梯度爆炸加了双层门控之后梯度回传路径变长容易出现梯度爆炸。排查时重点看门控网络的梯度范数。如果发现某些层梯度特别大可以加梯度裁剪一般 clip 到 1.0 或 5.0。另外门控的乘法会改变梯度尺度如果主网络学习率设得比较激进配合门控后等效学习率会被放大所以门控上线后适当把主学习率调低一点是很实用的一个技巧。5.4 线上推理的性能开销门控网络虽然轻但每个请求都要多跑几层小 MLP对延迟敏感的业务来说这部分开销不能忽略。我做过一次统计PPNet 在两层 128 维的底座上会带来大概 10% 到 15% 的推理耗时增加。优化思路有几种把门控网络和主网络做算子融合减少 kernel 启动次数把门控的中间维度进一步压小或者对先验特征做缓存避免重复计算。上线前一定要做压测别等出了问题再回头优化。问题现象可能原因排查方向解决手段门控值全部趋同先验特征弱/正则过强/初始化差看特征分布、查初始化增强特征、调 bias、降学习率长尾域指标下滑主域样本主导门控统计各域 batch 占比分域采样、损失加权训练梯度爆炸双层门控路径长监控梯度范数梯度裁剪、降主学习率线上延迟明显上升门控推理开销压测拆解耗时算子融合、压缩中间维度5.5 我的几点实操体会第一个体会是别一上来就同时上 PPNet 和 EPNet。先在单层门控上验证思路确认先验特征和域特征能带来收益再逐步加复杂度。第二个体会是域特征的构造质量比模型结构更影响最终效果——我见过结构一模一样只因为域划分粒度不同效果差出一大截的情况。第三个体会是门控的监控一定要做细致不能只看最终 AUC。我通常会把门控输出的均值、方差、按域分组的分布都打点监控一旦某个域的门控均值突然漂移往往就是数据出问题的前兆。第四个体会是关于冷启动的共享底座配合门控对新域其实相当友好因为新域可以复用主域学到的底层表达只需要用少量样本让门控网络学会该开多大这一点在多域扩张时特别有价值。最后分享一个小技巧在 debug 阶段可以把门控网络临时固定为输出常量 1跑一遍完整的训练流程作为基线对照。这样你能清晰看到门控到底带来了多少增量也能快速判断问题出在门控本身还是主网络。这个方法我在好几个项目里都用过定位问题的效率能提升不少。