1. 从“特征交叉”这个老难题说起
做推荐系统和点击率预估的朋友,大概率都绕不开一个词——特征交叉。简单说,就是让模型自己去发现“男性”和“篮球鞋”这两个特征放在一起时,点击概率会显著提升,而单独看“男性”或“篮球鞋”时,这个信号是弱甚至没有的。这就是交叉特征的价值:它捕捉的是特征之间的联合模式,而不是各自独立的贡献。
传统做法靠人工做交叉,比如把“性别×品类”这种组合直接拼成一个新特征喂给模型。但人工交叉有两个硬伤:一是组合爆炸,几十个特征两两交叉就是上千维,三维以上更夸张;二是很多交叉是无意义的,白白增加噪声和计算量。于是大家开始琢磨,能不能让模型自己学出有用的交叉?PNN(Product-based Neural Network)就是在这个背景下被提出来的,它的核心思路很直接——用**内积(Inner Product)和外积(Outer Product)**这两种乘积操作,在嵌入层之后显式地构造特征交叉,再交给全连接层去筛选。
我最早接触PNN是在一个CTR预估的项目里,当时Embedding+MLP的基线模型AUC卡在0.78上不去,加了人工交叉特征效果也不稳定。换成PNN之后,同样的数据和特征,AUC涨了将近1.5个点,而且训练收敛速度明显更快。这篇文章就把PNN的来龙去脉、核心机制、实操细节和我踩过的坑,完整地梳理一遍。不管你是刚入门推荐算法的新手,还是想找个靠谱baseline的老手,应该都能从中拿到能直接用的东西。
2. PNN到底在解决Embedding+MLP的什么短板
2.1 先看清楚基线模型的瓶颈在哪
Embedding+MLP的结构大家都很熟:稀疏的类别特征先查表变成低维稠密向量,然后直接拼接(concatenate)成一个长向量,丢进几层全连接网络。这个结构的问题在于,拼接操作本身是“加法式”的——它只是把各个特征的向量并排放置,并没有在输入层就告诉模型“这两个特征之间可能有关系”。交叉的活儿全甩给了后面的MLP,而MLP要学出二阶甚至高阶交叉,需要足够的宽度和深度,还得有充足的数据支撑。
打个比方,这就像你把一堆食材原封不动摆进锅里,指望厨师(MLP)自己判断哪些该搭配。厨师当然能做到,但他得尝很多次、浪费很多料。PNN的做法相当于在备菜阶段就先做一轮“预搭配”——把食材两两组合试一下,把可能的好组合直接摆到厨师面前。这样厨师的工作量小了,出菜也更快更稳。
2.2 乘积层:PNN的“预搭配”台
PNN在嵌入层和全连接层之间插入了一个乘积层(Product Layer),这是它区别于普通MLP的关键。乘积层分两部分:
- 线性部分(linear part):就是原始的嵌入向量直接保留,记作 ( l_z )。这部分保证模型至少不退化,能学到一阶信息。
- 乘积部分(product part):对嵌入向量做两两乘积操作,生成交叉特征,记作 ( l_p )。
最终乘积层的输出是这两部分拼接后送进MLP。乘积部分怎么算,就分出了PNN的两个变体:IPNN(Inner Product)和OPNN(Outer Product)。内积得到的是标量,外积得到的是矩阵。这个区别直接决定了模型的参数量、计算复杂度和表达能力,后面会详细拆。
2.3 为什么乘积操作能捕捉交叉
从数学上看,两个嵌入向量的内积 ( \langle f_i, f_j \rangle ) 衡量的是它们在语义空间里的相似度或共现强度。如果“男性”和“篮球鞋”的嵌入向量内积很大,说明这两个特征在训练数据里经常一起出现且对label有共同影响,模型就会给这个交叉一个较大的权重。外积则更“暴力”,它生成一个矩阵,保留了每一维之间的交互,表达能力更强,但参数量也更大。
这里有个容易混淆的点:PNN的乘积层和FM(因子分解机)的二阶项看起来都在做特征交叉,但FM是给每个交叉配一个标量权重,而PNN是把交叉结果作为新特征送进MLP继续加工。换句话说,FM是“交叉完直接加权求和”,PNN是“交叉完再让神经网络去挑”。这个差异让PNN在高阶交叉和非线性建模上更有余地。
3. IPNN与OPNN:两种乘积方式的取舍逻辑
3.1 IPNN的内积计算与复杂度控制
IPNN对每一对嵌入向量做内积,得到一个标量。假设有 ( N ) 个特征域,每个域嵌入维度为 ( M ),那么两两组合有 ( N(N-1)/2 ) 个内积结果。这些标量组成一个向量,就是乘积部分的输出。
朴素实现下,计算所有内积的复杂度是 ( O(N^2 M) ),当特征域很多时(比如上百个),这个开销不可忽视。原论文里用了一个技巧:把内积运算转化成矩阵乘法。具体来说,把所有嵌入向量拼成一个矩阵 ( P \in \mathbb{R}^{N \times M} ),那么所有两两内积可以用 ( P P^T ) 的上三角部分表示。这样就能利用GPU的矩阵运算加速,实际跑起来比循环快得多。
我在实操中的经验是:特征域数量在50以内时,IPNN的复杂度完全可以接受;超过100个域,就要考虑先做特征筛选或者降维,否则乘积层会成为瓶颈。另外,内积结果是有正负的,负值代表负相关,这个信息对模型有用,不要自作主张加ReLU把它截断。
3.2 OPNN的外积与参数膨胀问题
OPNN对每一对嵌入向量做外积,得到一个 ( M \times M ) 的矩阵。所有对的外积求和,得到一个 ( M \times M ) 的矩阵作为乘积部分输出。这个矩阵保留了每一维之间的交互,表达能力比内积强很多。
但代价是参数量。外积本身不引入参数,但后续全连接层要处理这个 ( M \times M ) 的矩阵,如果 ( M=10 ),就是100维;如果 ( M=50 ),就是2500维。全连接层的权重矩阵会迅速膨胀。原论文里为了控制复杂度,对OPNN做了简化,用求和池化把外积矩阵压成一个向量,或者用堆叠的方式减少参数。
实测下来,OPNN在小规模特征(域数少于30,嵌入维度小于20)时效果确实比IPNN好,AUC能高0.3到0.5个点。但一旦特征规模上去,OPNN的训练时间和显存占用会明显拖后腿,这时候IPNN反而是更务实的选择。我的建议是:先用IPNN跑通baseline,如果效果不够再试OPNN,并且一定要监控显存和单步耗时。
3.3 一张表看清两种变体的差异
| 对比维度 | IPNN | OPNN |
|---|---|---|
| 乘积操作 | 内积,输出标量 | 外积,输出矩阵 |
| 交叉表达能力 | 中等,捕捉相似度 | 强,保留维度级交互 |
| 参数量 | 较小 | 较大,随嵌入维度平方增长 |
| 计算复杂度 | ( O(N^2 M) ) | ( O(N^2 M^2) ) |
| 适合场景 | 特征域多、嵌入维度大 | 特征域少、追求极致效果 |
| 显存占用 | 低 | 高 |
| 训练速度 | 快 | 慢 |
这张表不是让你死记,而是帮你在做技术选型时快速定位。我见过有同学一上来就用OPNN,结果在几百个特征域的数据上跑不动,又回头改IPNN,白白浪费两天。先评估数据规模,再选变体,这个顺序不能反。
4. 从零搭建PNN:结构拆解与关键代码
4.1 输入层与嵌入层的处理细节
PNN的输入是稀疏的类别特征,通常用one-hot或者multi-hot表示。嵌入层就是查表,把每个特征域映射成 ( M ) 维向量。这里有几个实操细节值得说:
- 嵌入维度怎么定:经验公式是 ( M = \lfloor \text{域基数}^{0.25} \rfloor ),但实际中我一般取8到16之间,再根据验证集效果微调。维度太小欠拟合,太大容易过拟合且拖慢乘积层。
- 特征域的顺序:乘积层是对所有域两两做乘积,所以域的顺序不影响结果,但为了方便调试,建议按业务含义分组排列,比如用户特征在前、物品特征在后。
- 缺失值处理:类别特征缺失时,单独给一个“未知”的嵌入向量,不要用零向量,否则乘积结果会恒为零,丢失信息。
代码上,用PyTorch实现嵌入层很直接:
import torch import torch.nn as nn class EmbeddingLayer(nn.Module): def __init__(self, field_dims, embed_dim): super().__init__() self.embedding = nn.Embedding(sum(field_dims), embed_dim) # 计算每个域的偏移量,用于查表 self.offsets = [0] + list(torch.cumsum(torch.tensor(field_dims[:-1]), 0)) self.offsets = torch.tensor(self.offsets) def forward(self, x): # x shape: [batch, num_fields] x = x + self.offsets.unsqueeze(0).to(x.device) return self.embedding(x) # [batch, num_fields, embed_dim]这段代码的关键是offsets,它让不同域共享一个大的嵌入表,避免为每个域单独建表带来的管理麻烦。查表后得到的张量是三维的:批次、域数、嵌入维度。
4.2 乘积层的实现与矩阵化加速
乘积层是PNN的核心。以IPNN为例,最直观的实现是双重循环:
def inner_product_pairs(embeddings): # embeddings: [batch, num_fields, embed_dim] num_fields = embeddings.size(1) products = [] for i in range(num_fields): for j in range(i+1, num_fields): prod = torch.sum(embeddings[:, i, :] * embeddings[:, j, :], dim=1, keepdim=True) products.append(prod) return torch.cat(products, dim=1)但双重循环在域数多时非常慢。更好的做法是用矩阵乘法:
def inner_product_matrix(embeddings): # embeddings: [batch, num_fields, embed_dim] # 计算所有两两内积 gram = torch.bmm(embeddings, embeddings.transpose(1, 2)) # [batch, num_fields, num_fields] # 取上三角(不含对角线) num_fields = embeddings.size(1) triu_indices = torch.triu_indices(num_fields, num_fields, offset=1) products = gram[:, triu_indices[0], triu_indices[1]] # [batch, num_pairs] return productstorch.bmm是批量矩阵乘法,能充分利用GPU并行。实测在50个域、16维嵌入下,矩阵化版本比循环版本快20倍以上。这个优化不是可选项,是必选项,否则训练一轮要等太久。
对于OPNN,外积可以用torch.einsum实现:
def outer_product_sum(embeddings): # embeddings: [batch, num_fields, embed_dim] # 所有对外积求和 outer = torch.einsum('bim,bjm->bij', embeddings, embeddings) # [batch, embed_dim, embed_dim] return outer.view(outer.size(0), -1) # 展平注意这里einsum的写法,bim,bjm->bij表示对每个批次,把第i个和第j个嵌入向量做外积,然后对所有i,j求和。这个操作显存占用较大,嵌入维度超过32时要谨慎。
4.3 全连接层与输出层的设计
乘积层的输出(线性部分+乘积部分)拼接后,送进几层全连接。这里有几个设计选择:
- 激活函数:ReLU是默认选择,但我在一些数据集上发现PReLU效果更稳,尤其是乘积部分有负值时。
- Dropout:乘积层输出后加Dropout,比例0.2到0.5,能有效缓解过拟合。注意Dropout要加在拼接之后、第一层全连接之前。
- BatchNorm:如果训练不稳定,可以在全连接层前加BatchNorm,但会稍微增加推理延迟。
输出层就是二分类的sigmoid,损失函数用交叉熵。整体结构串起来:
class PNN(nn.Module): def __init__(self, field_dims, embed_dim, mlp_dims, dropout=0.3, use_outer=False): super().__init__() self.embedding = EmbeddingLayer(field_dims, embed_dim) self.use_outer = use_outer num_fields = len(field_dims) if use_outer: product_dim = embed_dim * embed_dim else: product_dim = num_fields * (num_fields - 1) // 2 input_dim = num_fields * embed_dim + product_dim layers = [] for dim in mlp_dims: layers.append(nn.Linear(input_dim, dim)) layers.append(nn.ReLU()) layers.append(nn.Dropout(dropout)) input_dim = dim layers.append(nn.Linear(input_dim, 1)) self.mlp = nn.Sequential(*layers) def forward(self, x): emb = self.embedding(x) # [batch, num_fields, embed_dim] batch_size = emb.size(0) linear_part = emb.view(batch_size, -1) if self.use_outer: product_part = outer_product_sum(emb) else: product_part = inner_product_matrix(emb) concat = torch.cat([linear_part, product_part], dim=1) return torch.sigmoid(self.mlp(concat)).squeeze(1)这个实现可以直接跑,但生产环境还要考虑特征预处理、负采样、增量训练等,后面会提。
5. 训练调参中那些文档不会写的事
5.1 学习率与优化器的选择
PNN的乘积层对学习率比较敏感。我用Adam做优化器,学习率从1e-3开始,如果验证集AUC震荡就降到5e-4。SGD也能用,但需要更精细的调参,而且收敛慢,不推荐新手一上来就用。
有个细节:乘积层的参数(如果有的话)和嵌入层的学习率可以分开设。嵌入层通常用较大的学习率(1e-3),乘积层和全连接层用较小的(1e-4),这样能防止交叉特征过早过拟合。PyTorch里可以通过参数组实现:
optimizer = torch.optim.Adam([ {'params': model.embedding.parameters(), 'lr': 1e-3}, {'params': model.mlp.parameters(), 'lr': 1e-4} ])这个技巧在特征稀疏、嵌入表很大的时候特别有用。
5.2 批次大小与训练轮次
批次大小建议从256或512开始。太小(如64)会导致梯度噪声大,乘积层的交叉信号不稳定;太大(如4096)会降低泛化,而且显存吃紧。训练轮次上,PNN通常比普通MLP收敛快,10到20轮就能看到验证集AUC plateau。我一般设早停,patience=3,监控验证集AUC。
这里有个坑:PNN在训练初期AUC可能先降后升,因为乘积层在随机初始化时引入的交叉是噪声。不要看到前两轮下降就停掉,至少跑5轮再判断。我遇到过好几次,第3轮AUC突然跳升,差点误杀。
5.3 正则化与过拟合的博弈
PNN的参数量比普通MLP大,过拟合风险更高。除了Dropout,还可以用:
- L2正则:对嵌入层和全连接层权重加L2,系数1e-5到1e-4。
- 嵌入层Dropout:对嵌入向量做Dropout,而不是对拼接后的向量,能更细粒度地防止共适应。
- 早停:最有效的手段,没有之一。
如果发现训练集AUC远高于验证集(差距超过0.05),优先加Dropout和早停,而不是加L2。L2调起来慢,效果也不如Dropout直接。
6. 踩过的坑与排查实录
6.1 乘积层输出全为零的诡异现象
有一次训练PNN,loss一直不降,打印乘积层输出发现全是零。排查了半天,最后定位到嵌入层初始化:我用了nn.Embedding的默认初始化,它服从正态分布,但某些域的特征非常稀疏,训练初期嵌入向量接近零向量,内积自然接近零。乘积部分失效,模型退化成纯MLP。
解决办法:对嵌入层用均匀分布初始化,范围稍大一点,比如[-0.05, 0.05],并且对稀疏域单独提高学习率。另外,可以在乘积层后加一个可学习的缩放因子,让模型自己调整交叉信号的强度。
6.2 显存爆炸与梯度累积
OPNN在嵌入维度32、域数50时,外积矩阵是32×32=1024维,批次512的话,中间激活值占用显存很大。我遇到过一次显存溢出,报错指向einsum操作。解决办法有两个:一是减小批次到128,用梯度累积模拟大批次;二是把外积改成求和池化,直接压成向量,牺牲一点表达能力换显存。
梯度累积的代码很简单:
accum_steps = 4 for i, batch in enumerate(loader): loss = model(batch) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()这样等效批次是原来的4倍,但显存占用不变。
6.3 特征域顺序变化导致效果波动
PNN理论上对特征域顺序不敏感,因为两两乘积覆盖了所有组合。但我在一次实验中打乱特征域顺序,AUC波动了0.2个点。原因是嵌入表的初始化对顺序有隐式依赖,而且矩阵化内积时上三角的取法会受顺序影响。虽然差异不大,但为了实验可复现,建议固定特征域顺序,并在预处理阶段就排好。
7. 和DeepFM、xDeepFM的横向对比
PNN不是唯一做特征交叉的模型。DeepFM用FM做二阶交叉,xDeepFM用CIN做高阶交叉。它们和PNN的区别在于:
- DeepFM:FM部分做二阶交叉,参数量小,训练快,但高阶交叉靠MLP隐式学。适合特征域多、对训练速度敏感的场景。
- PNN:乘积层显式做二阶交叉,IPNN和OPNN可选,灵活度高。适合想要显式控制交叉强度、且特征域数量中等的场景。
- xDeepFM:CIN模块做高阶交叉,表达能力最强,但计算复杂度也最高。适合数据量大、追求极致效果的场景。
我在同一个数据集上做过对比:DeepFM的AUC是0.801,IPNN是0.806,OPNN是0.809,xDeepFM是0.811。差距不大,但训练时间DeepFM最短,xDeepFM最长。所以选型时不要只看效果,还要看工程约束。如果线上要求低延迟,DeepFM或IPNN更务实;如果离线场景可以慢慢跑,xDeepFM值得一试。
8. 上线部署时的几个工程考量
8.1 模型导出与推理优化
PNN的乘积层在推理时可以用矩阵运算加速,但要注意导出ONNX时的算子支持。torch.bmm和einsum在较老的ONNX版本里可能不支持,需要替换成matmul和mul+sum的组合。我一般先在PyTorch里验证,再导出ONNX,用onnxruntime跑一遍对比输出,确保数值一致。
推理延迟方面,IPNN在CPU上单条样本大概2到5毫秒,OPNN要10毫秒以上。如果QPS要求高,建议用IPNN,或者把乘积层的结果离线预计算一部分。
8.2 特征版本管理与线上一致性
PNN对特征顺序和嵌入表版本敏感。上线时一定要保证训练和推理用的特征预处理逻辑完全一致,包括缺失值填充、归一化参数、特征域顺序。我见过一次线上AUC暴跌,最后发现是推理时特征域顺序和训练时不一致,乘积层算出来的交叉全乱了。解决办法是把特征预处理逻辑封装成一个独立的模块,训练和推理共用同一份代码。
8.3 增量训练与嵌入表更新
线上新用户和新物品不断出现,嵌入表需要定期更新。全量重训成本高,可以用增量训练:固定全连接层,只更新嵌入层和乘积层。但要注意,增量训练容易导致嵌入表漂移,建议每周做一次全量重训,增量训练作为日常补充。
9. 一些个人体会
PNN这个模型,结构不复杂,但细节很多。我最大的体会是:乘积层的矩阵化实现是必选项,不是优化项。早期我用循环写内积,训练一轮要半小时,改成矩阵乘法后降到一分钟以内,实验迭代速度完全不一样。另外,IPNN和OPNN的选择要看数据规模,不要盲目追求OPNN的表达能力,工程约束往往比理论效果更重要。
还有一点,PNN的乘积层输出可以拿来做特征重要性分析。比如计算每个交叉对的平均内积绝对值,绝对值大的说明这个交叉对label影响大,可以反哺人工特征工程。我在一个项目里用这个方法发现了几个业务上没想到的交叉组合,后来加进规则引擎,效果不错。
最后,如果你正在做CTR预估,PNN是一个很好的baseline。它比纯MLP强,比xDeepFM简单,调参空间适中。先把IPNN跑通,再根据效果决定要不要上OPNN或换更复杂的模型。这个路径我走过很多次,稳。