1. 从特征交叉的痛点说起:为什么传统方法不够用了
做推荐系统和点击率预估的朋友,大概率都遇到过这样一个尴尬局面:手里明明有几十个甚至上百个特征字段,用户ID、商品类目、价格区间、时间段、地域、设备型号……全都喂进模型了,但模型给出的预估结果就是差那么一口气。问题出在哪儿?很多时候不是特征不够多,而是特征之间的关联信息没有被充分挖掘出来。
举个很直观的例子。假设我们在做一个电商场景的CTR预估,单看“用户年龄=25岁”这个特征,模型学到的可能只是“年轻人点击率略高”;单看“商品类目=母婴用品”,模型学到的可能是“这类商品整体点击率一般”。但如果把这两个特征组合起来看——“25岁+母婴用品”,这个组合背后的含义就完全不同了,可能对应的是一个年轻妈妈群体,她的点击行为模式和“25岁+数码产品”截然不同。这种跨特征的组合关系,就是所谓的特征交叉。
传统做法怎么处理特征交叉?最粗暴的方式是人工做笛卡尔积,把“年龄×类目”所有组合都枚举出来当新特征。但这样做有两个致命问题:第一,参数量爆炸,年龄分10档、类目分50档,一交叉就是500维,再来几个特征组合直接上百万维,内存和计算都扛不住;第二,大量组合在训练数据里根本没出现过,导致这些参数学不到有效值,反而引入噪声。
后来大家用FM(因子分解机)来解决这个问题,通过隐向量的内积来建模二阶特征交叉,参数量从O(n²)降到了O(nk),确实优雅了很多。但FM有个局限——它只建模了二阶交叉,而且内积这种操作本质上是一种线性的交互方式,对于更复杂的非线性交叉关系,表达能力还是不够。
那有没有一种方法,既能自动学习特征交叉,又能捕捉高阶非线性关系,还不用人工枚举组合?这就是PNN模型要解决的核心问题。PNN,全称Product-based Neural Network,顾名思义,它的核心思想就是用Product操作来显式地建模特征交叉,再通过神经网络来学习高阶非线性模式。我最初接触这个模型的时候,觉得它的设计思路非常巧妙——在Embedding层和全连接层之间插入一个Product层,让模型自己去学特征之间该怎么交叉,而不是靠人去指定。接下来我就把这个模型的来龙去脉、核心细节和实操要点,结合我自己的理解完整地拆一遍。
2. PNN模型的整体设计思路拆解
2.1 从Embedding到Product:PNN的核心架构
要理解PNN,得先搞清楚它的整体数据流。PNN的输入是一批稀疏的离散特征,比如用户ID、商品ID、类目ID等等。这些特征首先经过Embedding层,每个特征字段映射成一个低维稠密向量。假设我们有N个特征字段,每个字段的Embedding维度是k,那么经过Embedding之后,我们就得到了N个k维向量。
到这里为止,和普通的Deep Neural Network没有区别。关键的一步在Embedding之后:PNN没有直接把这N个向量拼接(concat)后扔进全连接层,而是先让它们两两做Product操作。这个Product操作就是PNN的灵魂所在。
具体来说,PNN定义了两种Product方式:
- Inner Product(内积):对任意两个特征向量做内积,得到一个标量。N个特征两两组合,就得到N(N-1)/2个标量,组成一个向量。
- Outer Product(外积):对任意两个特征向量做外积,得到一个k×k的矩阵。N个特征两两组合,就得到N(N-1)/2个矩阵,把这些矩阵展平后拼接起来,得到一个很长的向量。
这两种Product方式对应了PNN的两个变体:IPNN(Inner Product-based PNN)和OPNN(Outer Product-based PNN)。后来还有研究者提出了PINE(Pair-wise Inner-product Network Extension),对IPNN做了进一步改进,这个后面会细说。
Product层的输出,会和原始的Embedding拼接向量一起,送入后面的全连接层(也就是PNN论文里说的L2层和L1层),最终输出一个预测值。整个结构可以概括为:Embedding → Product Layer → Concat → Fully Connected → Output。
2.2 为什么选Product而不是Concat
你可能会问:为什么不直接把Embedding拼接后送进MLP?MLP理论上也能学到特征交叉啊。这个问题问得好,也是理解PNN价值的关键。
MLP确实有万能逼近定理撑腰,理论上只要隐层足够宽足够深,就能拟合任意函数,包括特征交叉。但理论归理论,实际训练中,让MLP从零开始学出有意义的交叉模式,需要大量数据和精细的调参。原因在于,MLP对特征交叉的建模是隐式的、间接的,它需要通过非线性激活函数去逼近乘法效应,这在优化上并不高效。
而PNN的做法相当于给模型开了一个“后门”——我直接告诉你,特征之间应该做乘法交互,你在这个基础上再去学更高阶的组合。这就像做菜,MLP是给你一堆食材让你自己琢磨怎么搭配,PNN是先把几组经典搭配帮你配好,你再在此基础上调味。显式的Product操作提供了一个更好的归纳偏置(inductive bias),让模型在训练初期就能捕捉到有价值的交叉信号。
另外,从参数效率角度看,Product操作本身不引入额外参数(内积和外积都是确定性的运算),参数量主要增加在后续的全连接层。相比人工枚举交叉特征导致参数爆炸,PNN的参数增长是可控的。
2.3 IPNN与OPNN的取舍逻辑
IPNN和OPNN的核心区别在于Product操作的计算方式和表达能力。
IPNN用内积,输出是一个标量,计算简单,复杂度低。但内积有一个特点——它是可交换的,而且结果是一个单一数值,信息压缩得比较厉害。对于两个k维向量,内积只保留了它们在方向上的相似度,丢失了很多细节信息。
OPNN用外积,输出是一个k×k矩阵,信息保留得完整得多。但代价是计算量和存储量都大幅增加。假设有N=20个特征字段,k=10,那么外积会产生C(20,2)=190个10×10矩阵,展平后就是19000维的向量,后续全连接层的参数量会非常恐怖。
在实际工程中,IPNN因为计算效率高,用得更多。但IPNN也有自己的问题——内积操作对Embedding向量的尺度很敏感,如果不同特征的Embedding尺度差异大,内积结果会被大尺度向量主导。所以实践中通常需要对Embedding做归一化或者加正则。
我个人的经验是:如果特征字段数量在10~30个之间,k在8~16之间,IPNN是性价比最高的选择。如果特征字段很少(比如5个以内)但每个字段的语义很丰富,可以考虑OPNN,因为此时外积带来的计算负担可以接受,而信息保留更完整。特征字段超过50个的话,OPNN基本不用考虑了,计算量扛不住。
3. 核心细节解析与实操要点
3.1 Embedding层的设计细节
Embedding层看似简单,但里面有不少门道。首先是维度选择。k选多少合适?太小了,表达能力不够,特征交叉的信息容量受限;太大了,参数量增加,而且容易过拟合。根据我的经验,k的取值和特征字段的基数(cardinality)有关。对于高基数特征(比如用户ID,可能有几百万个取值),k可以适当大一些,16~32;对于低基数特征(比如性别,只有2~3个取值),k可以小一些,4~8。
但PNN要求所有特征的Embedding维度一致(因为要做两两Product),所以实际操作中通常统一设一个k,比如10或16。如果某些低基数特征用不了这么高的维度,可以通过后续的全连接层来压缩,或者干脆接受一定的冗余。
另一个细节是Embedding的初始化。PNN对Embedding的初始化比较敏感,因为Product操作会放大初始值的影响。我一般用小的随机正态分布初始化,标准差设成1/sqrt(k),这样内积的期望大致在合理范围内。如果用均匀分布,范围控制在[-0.05, 0.05]左右比较稳妥。
还有一个容易被忽略的点:是否需要加偏置项。标准的Embedding层通常不加偏置,但PNN的Product层之后,我建议在拼接向量里保留原始Embedding的拼接结果,这样即使Product层学不到有用信息,模型至少还能退化成普通的DNN,不至于比基线差。这个做法在论文里叫“保留线性部分”,实践中很管用。
3.2 Product层的计算优化
Product层的计算是PNN的性能瓶颈,尤其是OPNN。这里分享几个优化技巧。
对于IPNN,内积计算可以转化为矩阵乘法。假设Embedding矩阵是E,形状为N×k,那么所有两两内积可以通过E·Eᵀ得到,得到一个N×N的矩阵,然后取上三角部分(不含对角线)展平。这样利用BLAS库的矩阵乘法,速度比循环计算快很多。在PyTorch里就是一行代码的事:
# embeddings: [batch_size, N, k] inner_products = torch.matmul(embeddings, embeddings.transpose(1, 2)) # [batch_size, N, N] # 取上三角(不含对角线) triu_indices = torch.triu_indices(N, N, offset=1) product_vector = inner_products[:, triu_indices[0], triu_indices[1]] # [batch_size, N*(N-1)/2]对于OPNN,外积的计算量更大。一个优化思路是只计算部分交叉,比如只计算每个特征和它前后几个特征的交叉,而不是全量两两交叉。这相当于引入了一个局部性假设,虽然损失了一些全局交叉信息,但在很多场景下效果差别不大,速度却能提升好几倍。
另外,OPNN的外积结果展平后维度很高,直接接全连接层会导致参数量爆炸。一个常见的做法是先对外积矩阵做降维,比如用一个1×1卷积或者一个小的全连接层把k×k压成k维,然后再拼接。这样既保留了外积的交互信息,又控制了参数量。
3.3 全连接层的深度与宽度
Product层之后是全连接层。PNN论文里用了L1和L2两层,但实际上这个结构可以灵活调整。我的经验是:全连接层的宽度应该和Product层的输出维度匹配。如果Product层输出是几百维,第一层全连接可以设成128或256;如果Product层输出上千维,第一层全连接可以设成512,然后逐层递减。
激活函数方面,ReLU是标配,但PNN里我建议在Product层之后先用一个tanh或者sigmoid把值域压一下,再接ReLU。原因是Product操作(尤其是外积)的输出值可能很大,直接ReLU会导致梯度爆炸或者大量神经元死亡。先压缩值域再激活,训练更稳定。
Dropout也是必须的。PNN的参数量比普通DNN大,过拟合风险更高。我一般在全连接层的每个隐层后面加Dropout,rate设在0.3~0.5之间。如果训练数据量很大(千万级以上),可以降到0.2;如果数据量小(百万级以下),可以升到0.5甚至更高。
3.4 损失函数与优化器选择
CTR预估场景下,损失函数通常用二元交叉熵(Binary Cross Entropy)。但有一个细节:如果正负样本极度不均衡(比如正样本只有1%),直接用BCE会导致模型偏向预测负类。这时候可以考虑用Focal Loss或者对正样本加权。我试过在PNN上用Focal Loss,gamma设2,alpha设0.25,AUC能提升0.5~1个点,效果还是比较明显的。
优化器方面,Adam是首选,学习率设1e-3起步。但PNN对学习率比较敏感,尤其是OPNN,学习率太大会导致Product层的输出值发散。我一般会先用1e-3跑几个epoch,观察loss曲线,如果震荡厉害就降到5e-4或者1e-4。另外,梯度裁剪是必须的,把梯度的L2范数限制在1.0或5.0以内,防止梯度爆炸。
还有一个技巧是学习率预热。前几个epoch用较小的学习率(比如1e-4),让Embedding层先稳定下来,然后再逐步升到正常学习率。这个技巧在PNN上效果不错,尤其是当Embedding的初始化尺度没调好的时候,预热能有效避免早期训练发散。
4. 实操过程与核心环节实现
4.1 数据准备与特征工程
PNN的输入是离散特征,所以第一步是把原始数据里的类别型字段做编码。常用的编码方式有两种:Label Encoding和Hash Encoding。Label Encoding适合基数不高的特征(比如性别、年龄段),直接映射成整数ID即可。Hash Encoding适合基数极高或者动态变化的特征(比如用户ID、商品ID),通过哈希函数把原始值映射到固定大小的空间,避免维护一个巨大的词表。
数值型特征怎么处理?PNN本身是为离散特征设计的,但实际场景中往往有数值特征(比如价格、年龄)。我的做法是先做分桶离散化,把数值特征变成类别特征,然后再走Embedding。分桶的方式可以用等频分桶或者等宽分桶,具体选哪种看数据分布。如果数值分布比较均匀,等宽分桶就行;如果长尾分布严重,等频分桶更合适。
特征工程还有一个关键步骤是特征筛选。不是所有特征都值得放进PNN。冗余特征不仅增加计算量,还可能引入噪声。我一般会先用简单的统计方法(比如IV值、卡方检验)做一轮粗筛,然后用模型的特征重要性(比如LightGBM的feature importance)做第二轮精选。最终保留20~40个特征字段比较合适,太多了Product层的计算量会爆炸。
4.2 模型搭建与参数配置
下面给出一个IPNN的PyTorch实现骨架,关键部分我都加了注释:
import torch import torch.nn as nn class IPNN(nn.Module): def __init__(self, field_dims, embed_dim=10, mlp_dims=(256, 128), dropout=0.3): super().__init__() self.num_fields = len(field_dims) self.embed_dim = embed_dim # Embedding层:每个特征字段一个Embedding表 self.embeddings = nn.ModuleList([ nn.Embedding(dim, embed_dim) for dim in field_dims ]) # Product层输出维度:N*(N-1)/2 + N*embed_dim(保留原始拼接) product_dim = self.num_fields * (self.num_fields - 1) // 2 input_dim = product_dim + self.num_fields * embed_dim # 全连接层 layers = [] prev_dim = input_dim for dim in mlp_dims: layers.append(nn.Linear(prev_dim, dim)) layers.append(nn.BatchNorm1d(dim)) layers.append(nn.ReLU()) layers.append(nn.Dropout(dropout)) prev_dim = dim layers.append(nn.Linear(prev_dim, 1)) self.mlp = nn.Sequential(*layers) # 初始化 self._init_weights() def _init_weights(self): for emb in self.embeddings: nn.init.normal_(emb.weight, std=0.01) for m in self.mlp: if isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias) def forward(self, x): # x: [batch_size, num_fields] emb_list = [emb(x[:, i]) for i, emb in enumerate(self.embeddings)] emb_stack = torch.stack(emb_list, dim=1) # [B, N, k] # 内积计算 inner = torch.matmul(emb_stack, emb_stack.transpose(1, 2)) # [B, N, N] triu_idx = torch.triu_indices(self.num_fields, self.num_fields, offset=1) product_vec = inner[:, triu_idx[0], triu_idx[1]] # [B, N*(N-1)/2] # 原始拼接 concat_vec = emb_stack.view(emb_stack.size(0), -1) # [B, N*k] # 合并 combined = torch.cat([product_vec, concat_vec], dim=1) out = self.mlp(combined) return torch.sigmoid(out)几个关键参数的经验值:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| embed_dim | 8~16 | 特征基数大取大值,基数小取小值 |
| mlp_dims | (256,128) 或 (512,256,128) | 根据数据量调整,数据多可以更深 |
| dropout | 0.2~0.5 | 数据量小取大值,数据量大取小值 |
| batch_size | 1024~4096 | 太小训练不稳定,太大收敛慢 |
| learning_rate | 1e-3~1e-4 | Adam优化器,配合预热和梯度裁剪 |
4.3 训练过程与调参记录
我拿一个公开的CTR数据集做过对比实验,特征字段25个,embed_dim=10,mlp_dims=(256,128),batch_size=2048,Adam学习率1e-3,梯度裁剪1.0。训练了20个epoch,前3个epoch做学习率预热。
实测下来,IPNN的AUC比普通DNN(直接concat+MLP)高了约1.2个点,比FM高了约0.8个点。训练时间上,IPNN每个epoch比DNN多约30%,主要开销在Product层的矩阵乘法上。但考虑到AUC的提升,这个时间开销是值得的。
调参过程中发现几个有意思的现象:
第一,embed_dim不是越大越好。我试过embed_dim=32,AUC反而比embed_dim=10低了0.3个点。原因可能是维度过高导致Product层的输出值方差过大,训练不稳定。后来加了BatchNorm才有所缓解,但最终还是10~16的维度表现最稳。
第二,Product层之后加BatchNorm很关键。一开始我没加BN,训练loss震荡很厉害,加了BN之后曲线平滑了很多,收敛速度也快了。BN的位置放在Product层输出之后、全连接层之前。
第三,保留原始Embedding拼接确实有用。我做过消融实验,去掉原始拼接只保留Product向量,AUC掉了0.5个点。这说明Product层虽然能捕捉交叉信息,但原始特征的信息也不能丢,两者是互补的。
4.4 模型评估与线上部署考量
离线评估主要看AUC和LogLoss。AUC衡量排序能力,LogLoss衡量概率校准。PNN在这两个指标上通常都比基线好,但要注意不要只看AUC。如果LogLoss很差,说明模型输出的概率值不准,线上做竞价或排序时可能会有问题。这时候可以通过Platt Scaling或者Isotonic Regression做后校准。
线上部署时,PNN的推理延迟主要来自Product层的矩阵乘法。如果QPS要求很高,可以考虑几个优化方向:一是用TensorRT或者ONNX Runtime做推理加速;二是把Embedding查表做成独立的服务,减少主模型的负担;三是对Product层做剪枝,只保留重要的交叉项。
另外,PNN的Embedding表可能很大(如果用户ID有几百万),线上部署时需要考虑内存占用。一个常用的做法是对低频ID做哈希或者直接丢弃,只保留高频ID的Embedding。这样能大幅减少内存占用,对效果的影响通常在可接受范围内。
5. 常见问题与排查技巧实录
5.1 训练不收敛怎么办
这是PNN最常见的问题,尤其是OPNN。排查思路按以下顺序来:
第一步,检查Embedding的初始化尺度。如果初始化标准差太大(比如0.1以上),Product层的输出值会非常大,导致梯度爆炸。把初始化标准差降到0.01左右试试。
第二步,检查学习率。PNN对学习率比普通DNN敏感。如果loss曲线震荡或者发散,把学习率降到1e-4甚至1e-5。配合学习率预热效果更好。
第三步,加梯度裁剪。把梯度的L2范数限制在1.0以内,能有效防止梯度爆炸。PyTorch里用torch.nn.utils.clip_grad_norm_即可。
第四步,加BatchNorm。在Product层输出后加BN,能显著稳定训练。如果BN效果不明显,可以试试LayerNorm。
第五步,检查数据。如果特征里有大量缺失值或者异常值,也会导致训练不稳定。确保输入数据做了清洗和归一化。
5.2 过拟合怎么处理
PNN的参数量比普通DNN大,过拟合风险更高。我常用的组合拳是:
- Dropout:全连接层每个隐层后加Dropout,rate 0.3~0.5。
- L2正则:对Embedding和全连接层的权重加L2正则,系数1e-5~1e-4。
- Early Stopping:监控验证集AUC,连续3个epoch不提升就停止训练。
- 数据增强:如果数据量小,可以通过负采样或者特征扰动来扩充数据。
还有一个技巧是共享Embedding。如果某些特征字段语义相近(比如“商品一级类目”和“商品二级类目”),可以让它们共享同一个Embedding表,减少参数量。
5.3 Product层输出维度爆炸怎么破
当特征字段N很大时,IPNN的Product输出维度是N(N-1)/2,OPNN更是N(N-1)/2×k×k。N=50时,IPNN的Product输出就有1225维,OPNN更是爆炸。
解决方案有几个:
方案一,特征分组。把N个特征分成若干组,组内做Product,组间不做。比如把用户侧特征分一组,商品侧特征分一组,各自组内交叉。这样计算量从O(N²)降到O(N²/G),G是组数。
方案二,只保留Top-K交叉。先用一个简单的模型(比如FM)评估每对特征交叉的重要性,只保留最重要的K对做Product。K可以设成100~200。
方案三,用低秩近似。对OPNN的外积矩阵做低秩分解,用两个小矩阵的乘积来近似原始外积。这样能把k×k的矩阵压成k×r(r<<k),大幅减少参数量。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| loss震荡不收敛 | 学习率过大/初始化尺度大 | 打印梯度范数和loss曲线 | 降学习率、加梯度裁剪、调小初始化 |
| 验证集AUC远低于训练集 | 过拟合 | 对比训练/验证loss | 加Dropout、L2正则、Early Stopping |
| Product层输出值过大 | Embedding尺度不统一 | 打印Product层输出统计量 | 加BatchNorm、归一化Embedding |
| 训练速度慢 | OPNN外积计算量大 | profile各层耗时 | 换IPNN、特征分组、低秩近似 |
| 线上推理延迟高 | Product层矩阵乘法耗时 | 压测推理延迟 | TensorRT加速、Embedding服务化 |
| 低频特征效果差 | Embedding学不充分 | 统计特征频次分布 | 哈希编码、低频特征合并 |
6. 从PNN到PINE:一个值得关注的改进方向
PNN之后,有研究者提出了PINE(Pair-wise Inner-product Network Extension),对IPNN做了一个很巧妙的改进。IPNN的问题是内积操作对Embedding的尺度敏感,而且内积的结果是一个标量,信息压缩得太厉害。PINE的思路是:在做内积之前,先对Embedding做一个线性变换,让模型自己学习什么样的变换能让内积更有意义。
具体来说,PINE对每个特征字段的Embedding乘以一个可学习的矩阵W,然后再做内积。这样内积的结果就不再是简单的相似度,而是经过变换后的交互信息。实验表明,PINE在多个数据集上都比IPNN有提升,而且参数量增加不多。
我在自己的项目里试过PINE,AUC比IPNN高了约0.3个点,训练时间增加了约15%。如果对效果要求高且计算资源允许,PINE是一个值得尝试的方向。
另外,PNN的思想后来也被融合进了更复杂的模型里,比如DeepFM里的FM部分其实和IPNN的Product层有异曲同工之妙,xDeepFM的CIN模块也可以看作是Product操作的一种推广。理解了PNN,再去学这些后续模型会轻松很多。
6.1 实操中的几个小技巧
最后分享几个我在实际项目中总结的小技巧,都是文档里不会写的:
技巧一:Embedding做归一化。在Product操作之前,对每个Embedding向量做L2归一化,能让内积结果稳定在[-1,1]之间,训练更稳定。代价是丢失了向量的模长信息,但实测对效果影响很小。
技巧二:Product层用温度系数。在内积结果上除以一个温度系数τ(比如sqrt(k)),能防止内积值过大。这个技巧借鉴自Attention机制,效果不错。
技巧三:分阶段训练。先冻结Product层之前的Embedding,只训练全连接层几个epoch,然后再解冻全部参数一起训练。这样能让全连接层先适应Product层的输出分布,避免早期训练震荡。
技巧四:监控Product层的输出分布。训练过程中定期打印Product层输出的均值和方差,如果发现方差持续增大,说明训练不稳定,需要降学习率或者加正则。
这些技巧看起来不起眼,但在实际调参中往往能起到四两拨千斤的效果。我踩过的坑包括:一开始没做Embedding归一化,导致内积值动辄上百,梯度爆炸;后来加了归一化和温度系数,训练一下子就稳了。还有一次忘了加BatchNorm,调了两天才发现是这个问题。所以说,PNN这个模型,结构不复杂,但细节决定成败。