ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PNN网络详解:从特征交叉到CTR预估的工程实践

PNN网络详解:从特征交叉到CTR预估的工程实践 做推荐系统的人几乎都绕不过CTR预估。我最早接触PNN网络Product-based Neural Network是在一次召回排序改造的离线对比实验里。当时团队想在Deep Crossing的基础上把特征交叉做得更“显式”一些翻了不少论文最后在ICDM 2016上看到了PNN这篇工作。说实话光看名字很容易以为它只是给神经网络加了个乘积操作真正动手复现、调参、和FM、DeepFM这些模型做对比之后我才意识到它解决的问题其实非常朴素也非常关键神经网络的确能自动学习特征组合但如果你不帮它一把它很容易在“隐式交叉”里绕远路尤其是在高维稀疏场景下。这篇文章我尽量不念论文就从“为什么要做Product”“内积和外积到底差在哪”“怎么用PyTorch把PNN跑起来”“上线时容易踩哪些坑”这几个角度把我自己的理解、复现经验和踩坑记录完整写出来。不管你是刚开始看CTR模型还是已经用过DeepFM、xDeepFM应该都能在里面找到一些值得落地的细节。1. PNN网络到底在解决什么问题1.1 从LR到FNN神经网络为什么没把这些特征抓牢总差点意思CTR预估场景里输入特征最大的特点就是“又高又稀疏”用户ID、商品ID、类目ID、城市ID每个字段动辄几十万上百万的取值。最早大家靠LR加人工特征工程效果好不好完全取决于你手搓特征的能力。后来FM火起来因为它能用隐向量做二阶特征交叉而且推理时不需要把所有交叉对都显式列出来参数量从组合爆炸降到了O(nk)这点在当时非常惊艳。到了深度学习时代FNN就是直接把FM预训练好的隐向量拿来做Embedding然后接一个DNNDeep Crossing更进一步把Embedding和DNN端到端一起训练让网络自己学特征交互。但问题也随之而来DNN对特征交叉的学习是“隐式”的它通过每一层的非线性变换去拟合特征之间的关系理论上逼近任意函数实际上在高维稀疏输入下它要自己摸索哪些特征组合是有意义的收敛慢不说还容易陷在次优解里。我当时做过一个对比实验同样的数据和训练轮数Deep Crossing的离线AUC往往到后面还在缓慢爬升感觉就是网络在“一点点试探”特征关系而不是一开始就奔着交叉特征去。PNN的思路就很直白既然DNN隐式学交叉不够高效那我就在Embedding层和DNN之间专门加一层显式的Product操作把特征两两之间的交互先算出来再喂给DNN。这样模型既保留DNN的深度表达能力又强制注入了一组先验特征之间需要成对建模。这也是为什么我一直认为理解PNN的关键不是记住它的网络结构图而是要理解它是在给DNN“递小抄”告诉它重点看哪些位置。1.2 Product-based Neural Network的整体结构速览PNN的完整结构可以拆成四段输入层、Embedding层、Product层、全连接输出层。输入层对每个特征Field做One-Hot或者Multi-Hot编码然后每个Field独立查表得到对应的稠密Embedding向量。这里要注意每个Field的Embedding维度可以不一样但实际工程里通常统一取一个固定维度比如16维、32维或者64维方便矩阵运算。Embedding层之后就是PNN的精华Product层。这一层会接收所有Field的Embedding向量然后计算两两之间的“乘积”。乘积的定义有两种一种叫内积IPNN一种叫外积OPNN。内积就是把两个Embedding逐位相乘再相加输出是一个标量外积则是把一个列向量和一个行向量做矩阵乘法输出是一个m×m的矩阵m是Embedding维度。无论是内积还是外积最终会把所有Field对的乘积结果拼接起来和原始的Embedding向量或者经过简单变换的“线性信号”一起送入后面的全连接层最后经过Sigmoid输出点击概率。这个结构看起来不复杂但它解决了之前两个痛点。第一显式建模了特征交互不再是全靠DNN黑盒瞎猜第二Product层的计算是可以并行化的尤其是IPNN的内积本质上就是Embedding矩阵乘以它自己的转置GPU上非常友好。我在工程里第一次用IPNN替换掉Deep Crossing的纯Concat结构时离线AUC提升了大概0.3个百分点不算夸张但考虑到实验特征还没做太多工程优化这个提升已经足够说明方向是对的。1.3 “Product一层”到底乘了什么内积与外积的直觉很多人看论文里“内积”“外积”的公式会有点晕我换个生活化的方式来理解。假设每个特征Field的Embedding是一条“特征描述向量”比如用户的兴趣偏好是[动作片, 科幻片, 喜剧片]商品的属性是[特效, 剧情, 搞笑]。内积计算的是这两个描述向量的“相似度”数值越大说明这个用户和这个商品越匹配外积计算的则是两个向量每个维度之间的“两两共现关系”得到一个矩阵矩阵里每个元素代表“用户的某个兴趣”和“商品的某个属性”之间是否存在关联信号。放在推荐场景里内积更适合建模“匹配度”这类关系比如用户向量和商品向量方向是否一致外积则能捕捉更丰富的组合信号比如“喜欢科幻片的用户是否更容易被有特效标签的商品吸引”。但外积也有代价输出维度是m×m如果Embedding是32维一个Field对就是1024维Field一多直接拼接所有外积结果会把模型撑爆。所以OPNN在实际实现里通常不会直接拼接所有外积结果而是对全部外积矩阵做sum pooling相当于把所有Field对的交互信号压成一个总的m×m矩阵再接全连接层。这个细节特别重要等下写代码时还会再提。2. PNN核心环节拆解从公式到手工推导2.1 Embedding层稀疏特征变成稠密向量的那一步Embedding层在PNN里承担的任务比在普通DNN里更重。因为Product层的输出质量完全取决于Embedding向量的表达能力。每个Field的稀疏输入经过一个可训练的查找表映射成一个稠密向量。这里的核心参数是Embedding维度m它既决定特征表达的容量也直接影响Product层的计算复杂度。选择m的时候没有绝对标准我的经验是先小后大。比如先用16维跑通流程确认模型可以收敛再尝试32、64维看AUC是否还有明显提升。大多数情况下32维是一个性价比比较高的点再往上维度的边际收益会迅速下降而且OPNN的参数量会随m呈平方级增长显存压力很大。还有一个细节是不同Field的取值数量差异巨大有的Field只有几十个取值有的Field有上百万个取值。如果统一用相同维度低频但有价值的特征可能学不好高频特征又可能容量不足。实际工程中可以对低频Field适当降维或者对ID类特征做hash分桶后再查表这些都能在保持效果的前提下压低Embedding层的内存占用。2.2 Product层的数学细节IPNN、OPNN与FM的关系PNN论文里对Product层给出了很清楚的数学定义。假设输入有N个Field每个Field的Embedding向量记为f_i维度为m。那么Product层的输出分两部分一部分叫“线性信号”l_z一部分叫“二阶信号”l_p。l_z实际上就是对所有Embedding向量做加权求和保留每个Field自身的信息l_p则是所有成对交互的结果。对于IPNNl_p里的每一项是内积f_i, f_j这其实和FM的二阶项非常像。FM里预测分数的二阶部分是所有隐向量两两内积之和而IPNN是把这些内积结果作为一个向量再经过权重映射到下一层。换句话说FM可以被看作是IPNN的一个特例只保留一阶和二阶内积不接多层DNN最后直接做Sigmoid输出。这也是为什么很多讲PNN的文章会说FM是PNN的简化版本反过来你也可以把PNN理解为FM的深度化版本用DNN去拟合内积交叉之外的更高阶非线性关系。OPNN则不同l_p里的每一项是外积f_i * f_j^T得到的是一个m×m矩阵。这里如果不做处理直接把所有Field对的矩阵展平拼接参数量是O(N^2m^2)几乎不可接受。论文里提出的一种做法是用sum pooling把所有外积矩阵逐元素相加得到一个总的m×m矩阵再展平。这个操作既保留了交互信息又把维度从N^2m^2降到了m^2。我一开始写OPNN代码时没注意这点直接对每一对都计算外积并保存结果才十几个Field就把显存耗光了。后来改成逐对计算后累加到同一个矩阵里内存占用瞬间小了一个数量级。2.3 输出与损失怎么把概率预测和训练目标串起来Product层输出的连接结果会进入若干层全连接网络。这里有一个细节容易被忽略Product层本身输出的维度可能已经很高了尤其是IPNN如果有N个Field内积对的数量是N*(N-1)/2N为50时就是1225维再加上原始Embedding的拼接输入给DNN的维度会是相当大的。所以PNN在接DNN之前通常会加一层全连接做降维把Product层的输出压缩到几百维或者几十维再继续做非线性变换。最终的输出层是一个神经元加Sigmoid激活表示点击概率。训练目标一般用交叉熵损失函数配合Adam或者AdamW优化器。这里我要提醒一个点CTR预估里的正样本比例通常很低如果直接拿全部样本训练模型很容易偏向预测为0。我的做法是先对负样本做降采样让正负样本比例维持在1:2到1:5之间然后用一个校准系数把预测分数拉回真实的点击率水平。这个校准步骤在PNN里同样适用因为它本质上只影响最终输出的阈值不影响模型学习特征交互的能力。2.4 为什么说PNN是Deep Crossing、FNN之后的关键一跃从模型演进的脉络来看FNN用FM的隐向量初始化Embedding但特征交互还是靠DNN自己隐式学效果上限受限于预训练的质量Deep Crossing把端到端训练做起来了但同样没有显式的成对交互结构WideDeep把LR的“Wide部分”和DNN的“Deep部分”组合起来算是用工程方式弥补了DNN对低阶特征记忆的不足但两个部分还是割裂的。PNN最大的价值是把“特征交互”作为一个独立的、可计算的结构放进了神经网络的主干里而且提供了内积和外积两种显式建模方案。后来出现的DeepFM其实可以看作把FM的二阶部分直接嵌入到WideDeep的Wide侧和PNN的IPNN在数学上有很强的关系xDeepFM里的CINCompressed Interaction Network则继承了OPNN中“外积压缩”的思路只不过把“压缩”操作从一层扩展到了多层。我自己做模型选型时会把PNN当成一个很好的“基线增强器”如果你已经有一个纯DNN模型加一层Product操作往往能在不大动框架的情况下看到稳定的AUC提升。这种“关键一跃”的定位让PNN即使现在看起来结构简单依然很有工程价值。3. 实操用PyTorch从零搭一个PNN3.1 数据准备构造一个适合验证的点击率预估小样本为了把代码跑通我先构造一个小型点击率预估数据集。假设有4个Field用户ID、商品ID、类目ID、城市ID每个Field的取值数量分别设置为5000、8000、200、50。训练样本数量设置到20000条正样本占比控制在30%左右。虽然这个数据规模远远达不到真实工业场景但用来验证PNN结构是否正常工作、内积和外积的实现是否合理已经足够了。数据准备的关键是编号连续性。所有Field的取值都要从0开始连续编号否则Embedding层查表时会出现索引越界。我在实践中习惯给每个Field单独做一个序号映射器测试集里如果出现训练集没见过的取值直接映射到一个特殊的“未知”编号防止模型在serving时崩溃。比较稳妥的做法是给每个Field预留一个额外的Embedding槽位专门吸收那些低频或者未见过的取值。3.2 模型代码IPNN与OPNN的完整实现下面给出一个基于PyTorch的PNN模型实现。代码里用参数pnn_type来控制使用内积还是外积默认使用内积因为内积的计算效率和收敛稳定性都更好。import torch import torch.nn as nn class PNN(nn.Module): def __init__(self, field_dims, embed_dim16, hidden_dims(128, 64), dropout0.2, pnn_typeipnn): super(PNN, self).__init__() self.field_dims field_dims # 每个field的取值数量列表 self.num_fields len(field_dims) self.embed_dim embed_dim self.pnn_type pnn_type # Embedding层每个field一张独立的查找表 self.embeddings nn.ModuleList([ nn.Embedding(field_dim 1, embed_dim, padding_idxfield_dim) for field_dim in field_dims ]) # 线性信号保留每个field自身的信息 self.linear_weight nn.Parameter(torch.zeros(embed_dim, 1)) # Product层后的全连接网络 input_dim self._compute_product_dim() layers [] for hidden_dim in hidden_dims: layers.append(nn.Linear(input_dim, hidden_dim)) layers.append(nn.ReLU()) layers.append(nn.Dropout(dropout)) input_dim hidden_dim layers.append(nn.Linear(input_dim, 1)) self.mlp nn.Sequential(*layers) # 初始化参数 for name, param in self.named_parameters(): if weight in name and param.dim() 1: nn.init.xavier_normal_(param) def _compute_product_dim(self): n self.num_fields if self.pnn_type ipnn: # 内积输出维度所有pair数再加线性信号 return n * (n - 1) // 2 n else: # 外积使用sum pooling输出维度是embed_dim * embed_dim再加线性信号 return self.embed_dim * self.embed_dim n def forward(self, x): # x shape: (batch_size, num_fields) # 得到所有field的embedding向量 emb_list [emb(x[:, i]) for i, emb in enumerate(self.embeddings)] # emb_list中每个元素shape: (batch_size, embed_dim) # 线性信号对所有embedding做加权求和 emb_matrix torch.stack(emb_list, dim1) # (batch_size, num_fields, embed_dim) linear_signal emb_matrix.sum(dim1) * self.linear_weight # (batch_size, embed_dim) if self.pnn_type ipnn: # 内积计算所有pair的内积 pair_vectors [] for i in range(self.num_fields): for j in range(i 1, self.num_fields): pair_vectors.append((emb_list[i] * emb_list[j]).sum(dim1, keepdimTrue)) product_signal torch.cat(pair_vectors, dim1) # 拼接线性信号 concat_vector torch.cat([product_signal, linear_signal], dim1) else: # 外积使用sum pooling避免显存爆炸 outer_sum torch.zeros((x.size(0), self.embed_dim, self.embed_dim), devicex.device) for i in range(self.num_fields): for j in range(i 1, self.num_fields): outer_sum outer_sum torch.bmm( emb_list[i].unsqueeze(2), emb_list[j].unsqueeze(1) ) outer_flat outer_sum.reshape(x.size(0), -1) concat_vector torch.cat([outer_flat, linear_signal], dim1) output self.mlp(concat_vector) return torch.sigmoid(output).squeeze(1)这段代码里我刻意把内积和外积实现分成两个分支。内积分支直接循环取pair对好处是逻辑清晰计算量是O(N^2)Field数量不超过几十个的时候性能完全OK。外积分支用了一个临时变量outer_sum来累加所有外积结果这是最关键的小细节它避免了显式保存所有Field对外积矩阵导致的显存爆炸。如果Field数量较多比如超过30个单纯的双层循环在GPU上还是会有些浪费可以用矩阵乘法的形式改写本质上是把Embedding矩阵与其转置相乘能进一步加速但可读性会差一些我建议先把基础版本跑通再去优化。3.3 训练与评估参数、维度、损失函数的选择训练代码相对常规但有几个参数值得特意调。先定义Loss为BCEWithLogitsLoss注意不要在输出层先做Sigmoid再算交叉熵直接用logits算数值更稳定。优化器我习惯用Adam初始学习率设在1e-3左右配合一个cosine或者step decay。CTR预估场景里学习率太大很容易导致Embedding层震荡太小则收敛太慢1e-3是一个不错的起点。训练的时候还要额外监控两个东西。第一个是AUC尤其是每个Epoch结束后的验证集AUC如果连续多个Epoch不涨就该考虑是否过拟合或者学习率是否偏大。第二个是Embedding向量的L2范数分布如果某些Field的Embedding范数持续膨胀说明该Field可能过于稀疏且含有噪声需要加一些L2约束或者对出现次数太少的取值做过滤。我实际跑这个小数据集时IPNN在20轮左右验证AUC就稳定在0.78左右OPNN表现略低一点大约是0.76。这个差距不能说明OPNN不行因为数据集太小、Field数太少外积的表达优势完全没有发挥空间。在真实场景里Field数量几十上百的情况下OPNN往往能捕获更多细节交互但需要更充足的数据和更谨慎的正则。4. 训练调参与工程落地中的经验笔记4.1 特征与Embedding维度怎么定更稳PNN对特征质量的要求其实比普通DNN更高。因为Product层直接放大特征两两之间的关系如果一个Field包含大量噪声它会影响所有和其他Field的交互项污染范围比纯DNN场景大得多。所以在特征筛选上我会更激进地过滤低覆盖率的特征值。比如出现次数少于几十次的ID类取值直接归入“未知”桶或者丢弃这能让Embedding矩阵中的大量行不至于训练不充分同时减少过拟合风险。Embedding维度方面我上面提过32维是性价比比较高的起点。这里再补充一个具体判断方法先记录训练集和验证集AUC的差值。如果训练AUC明显高于验证AUC说明模型容量过剩可以降维或者加大Dropout如果两边AUC都低且差距不大说明容量不足可以加维或加深DNN。用这个差值作为调节信号比单纯凭经验拍脑袋稳得多。4.2 内积、外积怎么选很多人的理解是反的很多人默认外积比内积更强因为外积捕捉的信息量更大。但从实战角度看未必。内积计算的是一个标量信号非常集中优化器很容易找到方向外积计算的是一个矩阵信息更丰富但噪声也更多尤其是在数据量不够大的情况下外积矩阵里的很多元素对应的其实是无意义的偶发共现反而干扰DNN的学习。我的选择逻辑是这样的如果你的Field数量多但每个Field的有效信息量不大首选IPNN收敛快、稳定、效果好如果Field数量适中且你有比较强的特征语义先验觉得某些Field的维度间确实存在“交叉涌现”的关系再用OPNN也不迟。另外还有一个折中方案叫PNN的变体在外积部分加一层逐元素的注意力机制或者Gate选择合适的交互子集来建模。这个方法在论文里不常被提及但我在自己的实验里试过确实能缓解外积噪声过大的问题。4.3 从离线实验到线上服务PNN上线避坑清单离线实验效果不错不代表线上一定能拿到同样涨幅。PNN上线时最常遇到的坑有三个。第一个坑是特征一致性。离线训练时你用了一堆特征在线serving时如果其中某个特征没有及时更新查表返回默认值0那么Product层计算出来的交互项就会瞬间失真。我的解决办法是在特征管道里加一层“新鲜度检查”每个Field都要带时间戳超过存活期的特征值直接标记为未知而不是当作0来处理。第二个坑是Embedding表的存储和加载。真实场景里ID类特征的取值量可能上亿Embedding表的大小动辄几十GB。PNN对Embedding的访问模式是随机查表这对线上缓存非常不友好。我一般会对高频特征做Cache低频特征走全量参数服务器两者结合才能压住P99延迟。第三个坑是模型导出。PyTorch模型上线时Embedding层需要处理“训练时没有见过的ID”这对Embedding的padding_idx设置要求很高。我在代码里特意把field_dims加1留出一个未知槽位就是为了应对这个情况。上线前一定要用线上日志回放去跑一遍模型确认没有索引越界和NaN输出。5. 常见问题与排查技巧实录5.1 加了Product层反而掉点了问题出在哪这种情况我遇到不止一次。加Product层掉点多数时候不是PNN本身的问题而是“输入信号的尺度”出了问题。内积和外积的输出尺度受Embedding向量的L2范数影响很大。如果某些Embedding向量范数很大内积会剧烈波动DNN的第一层很容易被某些大数值神经元主导导致梯度不稳定。解决思路是给Product层输出做Normalization。我试过两种方案一是对每个Field的Embedding做L2 Norm让所有向量的模长固定为1这样内积的数值范围就严格控制在[-1,1]之间二是在Product层之后加BatchNorm或者LayerNorm把进入DNN的向量重新拉回一个合理区间。两种方案在我的实验里都能稳定提升收敛速度第二种更通用因为它不做任何特征假设只是把优化地形修平了。5.2 外积实现太慢/显存爆炸怎么办外积实现有两个典型的性能问题。第一是显存爆炸原因就是我前面说的把每个Field对的外积矩阵都显式保存了下来。解决办法是循环累加或者等价的协方差矩阵计算法。第二是GPU利用率低这通常出现在Field数量较大的场景双层for循环会频繁启动小算子导致kernel launch开销远大于实际计算量。优化方案是把外积计算改写为矩阵乘法。思路是把所有Field的Embedding拼成一个矩阵Eshape为(B, N, m)那么所有Field对的外积求和等价于计算E与其转置的乘积E * E^T得到shape为(B, N, N)的矩阵再对某个维度做池化。更准确地说论文里sum pooling外积的操作可以通过构造矩阵实现把复杂度从O(N^2 * m^2)降到O(N^2 * m)。工程实现上可以调用torch.bmm来做批量矩阵乘GPU利用率会高很多。不过这种优化对新手不太友好我建议先跑通朴素版再根据线上延迟要求决定是否做这一步。5.3 PNN、DeepFM、xDeepFM到底怎么选这三个模型经常被放在一起比较。PNN通过Product层显式建模二阶交互结构简洁是很好的模型底座DeepFM把FM的二阶特征交互作为单独一路和Deep部分并行适合那些想保留FM可解释性又需要深度表达的场景xDeepFM则通过CIN模块逐层建模高阶特征交互理论上能力最强适合Field语义丰富、数据量巨大的场景。我的选型思路是先看数据规模。数据量在千万级别以下PNN往往就够了因为复杂模型在数据不足时只会过拟合。再看业务需求。如果业务方要求能解释“为什么这个用户看到了这个广告”那就选DeepFM或者FM这类带有线性可解释支路的模型。最后看工程成本。xDeepFM的CIN模块实现和调参复杂度远高于PNN如果团队没有专人负责排序模型优化坚持用PNN加一些特征工程反而更容易拿到正向效果。模型不是越复杂越好能在你的数据、算力、工程资源约束下稳定产出的模型才是好模型。我在实际使用中发现PNN最容易被低估的地方是它的“兼容性”。它不是一个必须完整替换现有模型的结构而是可以作为一个轻量模块插入到任何Embedding加DNN的框架里。哪怕你现在的模型叫DeepFM或者DIN只要你觉得现有的特征交互不够直接都可以在Embedding之后加一层内积Product往往几行代码就能带来可感知的提升。最后分享一个小技巧内积Product层的输出在接DNN之前先拼上原始Embedding的池化向量再经过一层低维全连接这个操作对最终效果的影响经常比调DNN层数还明显。希望这篇拆解能帮你把PNN的每个环节都吃透少走一些我当初走过的弯路。
返回列表