☰
CAN特征协同网络:CTR预估中显式建模特征交互的工程实践
2026/10/27 3:30:19 网站建设 项目流程

1. 从一次A/B实验的尴尬结果说起

去年下半年,我接手了一个电商推荐场景的CTR预估优化任务。当时线上主模型是经典的DeepFM,离线AUC已经刷到0.81左右,团队觉得再往上提千分位都很难。我们尝试了各种常规手段:加特征、调embedding维度、换优化器、做特征分桶,折腾了将近三周,线上CTR只涨了不到0.3%,基本在噪声范围内。

真正让我重新思考整个建模思路的,是一次特征重要性的复盘。我们把用户侧和行为侧的特征做了交叉分析,发现一个很有意思的现象:单独看"用户历史点击品类"和"当前曝光商品类目"这两个特征,各自的区分度都不算特别强,但两者组合起来,对点击行为的解释力却远超预期。换句话说,模型真正需要的不是更多的特征,而是特征之间那种"1+1>2"的协同关系。

这就是CAN(Co-Action Network,特征协同作用网络)要解决的核心问题。传统CTR模型里,特征交互要么靠人工设计交叉特征(费时费力还容易漏),要么靠DNN隐式学习(参数量大、收敛慢、可解释性差)。CAN的思路很直接:把特征交互这件事从"隐式学习"变成"显式建模",用一个小型的协同网络专门负责捕捉两个特征之间的交互信号。

这篇文章我会从工程落地的角度,把CAN的来龙去脉、核心原理、代码实现、调参经验和踩过的坑完整讲一遍。适合正在做CTR预估、推荐系统排序、广告点击率优化的同学参考,不管你是刚入门想搞懂特征交互,还是已经在用DIN/DeepFM想进一步优化,应该都能找到有用的东西。

2. 为什么传统特征交互方案不够用

2.1 从人工交叉到自动学习的演进逻辑

CTR预估领域对特征交互的探索其实经历了几个阶段。最早是人工特征工程,比如"用户性别×商品类目"这种笛卡尔积,直接做成离散特征喂给LR。这种方式的问题很明显:组合爆炸。假设有100个特征,两两交叉就是近5000种组合,三阶交叉更是天文数字,而且大部分组合是稀疏的、没有统计意义的。

后来FM(Factorization 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维,过一层全连接W(2d×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%。当然外积的代价是计算量,d=16时d²=256,还好;d=64时就是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_dim=16, coaction_pairs=None, hidden_dims=[128, 64], dropout=0.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(i+1, 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, dim=1) emb_flat = emb.view(emb.size(0), -1) fusion_input = torch.cat([coaction_concat, emb_flat], dim=1) logit = self.fusion(fusion_input) return torch.sigmoid(logit)

4.2 关键参数的计算与选择

embed_dim的选择。这个参数直接决定了外积后的维度。我的经验是:特征域少于20个时用16,20-50个用8-12,超过50个用4-8。原因是外积维度是d²,d=16时是256,d=32时就是1024,计算量和显存都会吃不消。如果一定要用大d,可以用低秩分解,把外积近似成两个小矩阵相乘。

协同网络的数量。理论上所有两两组合是C(n,2),n=35时是595个,太多了。实际做法是只选Top-K重要的特征对。怎么选?两个方法:一是用先验知识(比如用户侧特征×商品侧特征),二是用FM预训练得到特征对的交互权重,取Top-50到Top-100。

隐藏层维度。协同网络内部不需要太宽,64→32就够了。融合层可以宽一点,128→64比较稳妥。dropout建议0.2-0.3,太高会欠拟合。

4.3 训练技巧与调参经验

学习率。CAN的参数量比DeepFM大,学习率要相应调小。我用Adam,初始lr=1e-3,配合余弦退火,效果比固定lr好。如果发现loss震荡,降到5e-4。

Batch size。建议2048-4096。太小的话协同网络的梯度噪声大,太大收敛慢。我用4096,A100上单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初始化范围调小(比如用正态分布std=0.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参数量训练时间/epoch
LR0.78210.76540.1M5s
FM0.79340.77891.2M12s
DeepFM0.80120.78914.5M22s
DCN-V20.80450.79235.8M30s
xDeepFM0.80510.79307.2M38s
CAN0.80780.79656.2M28s

CAN在两个数据集上都取得了最好的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上体现得淋漓尽致。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询