1. 从一次A/B实验的尴尬结果说起去年下半年我接手了一个电商推荐场景的CTR预估优化任务。当时线上主模型是经典的DeepFM离线AUC已经刷到0.81左右团队觉得再往上提千分位都很难。我们尝试了各种常规手段加特征、调embedding维度、换优化器、做特征分桶折腾了将近三周线上CTR只涨了不到0.3%基本在噪声范围内。真正让我重新思考整个建模思路的是一次特征重要性的复盘。我们把用户侧和行为侧的特征做了交叉分析发现一个很有意思的现象单独看用户历史点击品类和当前曝光商品类目这两个特征各自的区分度都不算特别强但两者组合起来对点击行为的解释力却远超预期。换句话说模型真正需要的不是更多的特征而是特征之间那种112的协同关系。这就是CANCo-Action Network特征协同作用网络要解决的核心问题。传统CTR模型里特征交互要么靠人工设计交叉特征费时费力还容易漏要么靠DNN隐式学习参数量大、收敛慢、可解释性差。CAN的思路很直接把特征交互这件事从隐式学习变成显式建模用一个小型的协同网络专门负责捕捉两个特征之间的交互信号。这篇文章我会从工程落地的角度把CAN的来龙去脉、核心原理、代码实现、调参经验和踩过的坑完整讲一遍。适合正在做CTR预估、推荐系统排序、广告点击率优化的同学参考不管你是刚入门想搞懂特征交互还是已经在用DIN/DeepFM想进一步优化应该都能找到有用的东西。2. 为什么传统特征交互方案不够用2.1 从人工交叉到自动学习的演进逻辑CTR预估领域对特征交互的探索其实经历了几个阶段。最早是人工特征工程比如用户性别×商品类目这种笛卡尔积直接做成离散特征喂给LR。这种方式的问题很明显组合爆炸。假设有100个特征两两交叉就是近5000种组合三阶交叉更是天文数字而且大部分组合是稀疏的、没有统计意义的。后来FMFactorization Machine出现了用隐向量内积来近似特征交互权重参数量从O(n²)降到O(nk)。这个思路很优雅但FM本质上还是二阶交互而且对所有特征对一视同仁没有考虑不同特征对的重要性差异。再往后就是DeepFM、DCN、xDeepFM这一批模型用DNN或者Cross Network来做高阶交互。DNN的问题是交互过程完全黑盒你没法控制它学到了什么Cross Network虽然显式建模了高阶交叉但每一层的交互形式是固定的元素级乘法灵活性有限。2.2 现有方案的三个核心痛点我在实际项目中总结下来传统方案有三个绕不过去的痛点第一参数效率低。DeepFM里每个特征域都要维护一个embedding表特征域一多embedding参数量就爆炸。而且DNN部分的参数量跟特征域数量的平方成正比训练慢、容易过拟合。第二交互信号被稀释。当特征域很多的时候DNN要把所有特征concat在一起做全连接两个特定特征之间的交互信号很容易被其他特征淹没。这就像在一个嘈杂的房间里说悄悄话信息传不到。第三冷启动和长尾问题。对于低频特征组合embedding学不充分交互权重估计不准。而恰恰是这些长尾组合往往蕴含着差异化的用户意图。CAN的设计动机就是针对这三点用独立的协同网络处理特征对交互参数量与特征域数量解耦让交互信号在专门的子网络里充分表达通过共享底层embedding缓解长尾问题。2.3 CAN的核心设计哲学CAN最本质的创新在于它把特征交互从一个全局操作变成了局部操作。传统模型是所有特征一起交互CAN是选两个特征用一个专门的网络算它们的交互。这个转变带来的好处是连锁性的。首先协同网络的输入只有两个特征的embedding参数量可控其次这个网络可以设计得很深很复杂因为输入维度低不会带来计算负担最后不同特征对可以用不同的协同网络实现差异化的交互建模。打个比方传统DNN像是一个大食堂所有食材一起下锅炒味道混在一起CAN像是小灶每两个食材单独配一个厨师专门研究它们怎么搭配最好吃。3. CAN的网络结构与核心机制拆解3.1 整体架构从特征对到协同网络CAN的整体架构可以分成三层来理解底层是特征embedding层跟常规CTR模型一样每个离散特征映射成一个d维向量。这部分是共享的所有协同网络都从这里取输入。中间是协同网络层这是CAN的核心。对于每一对需要建模交互的特征(i, j)取出它们的embedding向量e_i和e_j输入到一个专门的协同网络f_ij中输出一个交互表示向量。顶层是融合层把所有协同网络的输出以及可能的单特征embedding拼接起来过几层全连接最后sigmoid输出点击概率。这里有个关键设计选择协同网络的输入不是简单的concat而是做了外积或者元素级交互。常见做法是计算e_i和e_j的外积得到一个d×d的矩阵然后flatten或者用卷积处理。这样做的好处是保留了特征间的细粒度交互信息而不是简单拼接后让MLP去猜。3.2 协同网络内部为什么用外积而不是拼接我一开始也有疑问为什么不直接把两个embedding拼起来过MLP后来做了对比实验才明白差别。假设e_i和e_j都是d维拼接后是2d维过一层全连接W2d×h输出h维。这个过程中e_i和e_j的交互是通过W的权重隐式实现的W的参数量是2d×h。而外积得到d×d矩阵参数量是d²但交互是显式的、逐维的。更关键的是外积保留了维度间的对应关系。比如e_i的第k维和e_j的第k维相乘表示这两个特征在第k个隐语义上的交互强度。这种结构化信息在拼接方案里是丢失的。实际实验中外积方案的AUC比拼接方案高0.4-0.6个百分点而且收敛速度快了将近30%。当然外积的代价是计算量d16时d²256还好d64时就是4096需要做降维或者用低秩近似。3.3 参数共享策略哪些该共享哪些该独立CAN里有个容易踩坑的地方是参数共享的粒度。我的经验是分三层来看Embedding层必须共享。同一个特征在不同协同网络里应该用同一个embedding否则参数量翻倍不说还会导致特征表示不一致。协同网络的底层可以共享顶层必须独立。我试过所有协同网络完全独立结果参数量太大小数据集上直接过拟合也试过完全共享结果不同特征对的交互模式被强行统一效果反而下降。折中方案是前几层共享提取通用交互模式最后一层独立输出特征对特有的交互表示。融合层的参数独立。融合层负责把不同协同网络的输出整合成最终预测这部分参数量不大独立训练效果更好。下面这张表是我在某个数据集上做的消融实验供参考共享策略参数量离线AUC训练时间/epoch完全独立12.8M0.803245s底层共享顶层独立6.2M0.807828s完全共享3.1M0.801518s不共享embedding9.5M0.799835s可以看到底层共享顶层独立的方案在效果和效率之间取得了最好的平衡。4. 手把手实现一个CAN模型4.1 环境准备与数据说明我用的是PyTorch 1.12 CUDA 11.3数据集是Criteo的采样版本约1000万条特征域35个其中13个数值特征做了分桶离散化。如果你用TensorFlow也可以核心逻辑一样。先说一下数据格式每行是label, feat_0, feat_1, ..., feat_34离散特征已经做了Label Encoding数值特征做了等频分桶。训练集/验证集按9:1划分。import torch import torch.nn as nn import torch.nn.functional as F class CAN(nn.Module): def __init__(self, field_dims, embed_dim16, coaction_pairsNone, hidden_dims[128, 64], dropout0.2): super().__init__() self.embed_dim embed_dim self.num_fields len(field_dims) # 共享embedding层 self.embedding nn.Embedding(sum(field_dims), embed_dim) self.offsets [0] list(np.cumsum(field_dims)[:-1]) # 协同网络为每个特征对建一个 if coaction_pairs is None: # 默认所有两两组合 coaction_pairs [(i, j) for i in range(self.num_fields) for j in range(i1, self.num_fields)] self.coaction_pairs coaction_pairs # 共享底层 self.shared_fc nn.Sequential( nn.Linear(embed_dim * embed_dim, 64), nn.ReLU(), nn.Dropout(dropout) ) # 每个特征对独立的顶层 self.coaction_tops nn.ModuleList([ nn.Linear(64, 32) for _ in coaction_pairs ]) # 融合层 fusion_input_dim len(coaction_pairs) * 32 self.num_fields * embed_dim self.fusion nn.Sequential( nn.Linear(fusion_input_dim, hidden_dims[0]), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dims[0], hidden_dims[1]), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dims[1], 1) ) def forward(self, x): # x: [batch, num_fields] emb self.embedding(x x.new_tensor(self.offsets)) # [B, F, D] coaction_outputs [] for idx, (i, j) in enumerate(self.coaction_pairs): e_i emb[:, i, :] # [B, D] e_j emb[:, j, :] # [B, D] # 外积 outer torch.bmm(e_i.unsqueeze(2), e_j.unsqueeze(1)) # [B, D, D] outer outer.view(outer.size(0), -1) # [B, D*D] shared self.shared_fc(outer) top self.coaction_tops[idx](shared) coaction_outputs.append(top) coaction_concat torch.cat(coaction_outputs, dim1) emb_flat emb.view(emb.size(0), -1) fusion_input torch.cat([coaction_concat, emb_flat], dim1) logit self.fusion(fusion_input) return torch.sigmoid(logit)4.2 关键参数的计算与选择embed_dim的选择。这个参数直接决定了外积后的维度。我的经验是特征域少于20个时用1620-50个用8-12超过50个用4-8。原因是外积维度是d²d16时是256d32时就是1024计算量和显存都会吃不消。如果一定要用大d可以用低秩分解把外积近似成两个小矩阵相乘。协同网络的数量。理论上所有两两组合是C(n,2)n35时是595个太多了。实际做法是只选Top-K重要的特征对。怎么选两个方法一是用先验知识比如用户侧特征×商品侧特征二是用FM预训练得到特征对的交互权重取Top-50到Top-100。隐藏层维度。协同网络内部不需要太宽64→32就够了。融合层可以宽一点128→64比较稳妥。dropout建议0.2-0.3太高会欠拟合。4.3 训练技巧与调参经验学习率。CAN的参数量比DeepFM大学习率要相应调小。我用Adam初始lr1e-3配合余弦退火效果比固定lr好。如果发现loss震荡降到5e-4。Batch size。建议2048-4096。太小的话协同网络的梯度噪声大太大收敛慢。我用4096A100上单epoch约28秒。正则化。除了dropout我还加了embedding的L2正则系数1e-5和协同网络权重的L2系数1e-4。注意embedding的正则系数要小否则会压制特征表示。特征对的选择策略。这是CAN效果的关键。我试过三种全组合、Top-K FM权重、人工指定。实测Top-100 FM权重人工补充10个业务强相关特征对效果最好。全组合反而因为噪声交互太多AUC掉了0.3个点。5. 实操中的问题排查与避坑指南5.1 训练不收敛的三种典型情况情况一loss一开始就nan。大概率是外积后的数值太大。解决办法是在外积后加一个LayerNorm或者把embedding初始化范围调小比如用正态分布std0.01而不是默认的0.1。情况二loss下降但AUC不涨。这是过拟合的典型信号。检查一下协同网络是不是太多了或者隐藏层太宽。我遇到过一次把协同网络从200个减到80个AUC反而涨了0.5个点。情况三训练集AUC很高但验证集很低。除了过拟合还要检查特征对的选择是不是用了验证集的信息数据泄露。特征对的选择必须只在训练集上做。5.2 线上推理性能优化CAN的推理延迟主要来自外积计算和多个协同网络的前向传播。我做了几个优化第一协同网络并行化。把多个协同网络的计算合并成批量矩阵运算而不是for循环。PyTorch里可以用torch.einsum或者把权重堆叠成一个大矩阵。第二外积降维。用随机投影或者PCA把d²降到d精度损失很小AUC掉0.1个点以内但推理速度提升3倍。第三模型蒸馏。把CAN蒸馏成一个小的MLP线上用蒸馏模型离线用CAN。这个方案适合延迟要求极高的场景。优化前后的对比如下优化手段推理延迟(P99)离线AUC备注原始版本45ms0.8078基线并行化22ms0.8078无精度损失并行化外积降维8ms0.8069可接受蒸馏模型3ms0.8042适合高并发5.3 常见问题速查表问题现象可能原因排查方法解决方案训练loss震荡学习率过大打印每步loss降低lr或加warmup验证AUC不涨过拟合对比训练/验证曲线减协同网络数、加正则推理延迟高外积计算量大profile各层耗时并行化、降维、蒸馏冷启动效果差embedding学不充分看低频特征覆盖率共享embedding、加先验特征对选择困难组合爆炸统计FM交互权重Top-K人工筛选提示特征对的选择是CAN效果的生命线。我的经验是宁可少选几个高质量的特征对也不要贪多。80个精选特征对的效果往往好过200个随便选的特征对。6. 效果对比与适用场景分析6.1 与主流模型的离线对比我在Criteo和自家业务数据集上做了完整对比结果如下模型Criteo AUC业务AUC参数量训练时间/epochLR0.78210.76540.1M5sFM0.79340.77891.2M12sDeepFM0.80120.78914.5M22sDCN-V20.80450.79235.8M30sxDeepFM0.80510.79307.2M38sCAN0.80780.79656.2M28sCAN在两个数据集上都取得了最好的AUC而且参数量比xDeepFM少训练时间也更短。这个优势在特征域多、交互复杂的场景下更明显。6.2 什么场景适合用CAN根据我的实践经验CAN在以下几类场景收益最大特征域多且交互复杂的场景。比如电商推荐用户侧、商品侧、上下文侧特征加起来几十个特征间交互模式多样。CAN的显式交互建模比DNN的隐式学习更高效。对可解释性有要求的场景。CAN的协同网络输出可以单独分析你能看到哪对特征的交互对最终预测贡献大。这在广告审核、风控等场景很有价值。冷启动和长尾问题突出的场景。共享embedding独立协同网络的组合对低频特征组合更友好。反过来如果特征域很少少于10个或者交互模式很简单CAN的优势就不明显用DeepFM甚至FM就够了没必要上这么复杂的结构。6.3 后续优化方向CAN不是终点。我目前在探索几个方向一是动态特征对选择根据用户上下文动态决定激活哪些协同网络二是多阶协同不只做两两交互还做三阶、四阶但用张量分解控制参数量三是与序列模型结合把用户行为序列的时序交互也纳入协同网络。这些方向有的还在实验阶段有的已经有初步效果。等跑通了再单独写一篇分享。最后说个实操心得CAN的调参空间比DeepFM大但核心就三个——特征对选得好不好、embed_dim合不合适、协同网络共享策略对不对。这三个搞定了剩下的都是微调。我踩过最大的坑是一开始贪心选了300个特征对结果训练慢、效果差后来砍到80个AUC反而涨了。所以做特征交互少即是多这句话在CAN上体现得淋漓尽致。
阅读完成 · 觉得有帮助?