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.8M | 0.8032 | 45s |
| 底层共享+顶层独立 | 6.2M | 0.8078 | 28s |
| 完全共享 | 3.1M | 0.8015 | 18s |
| 不共享embedding | 9.5M | 0.7998 | 35s |
可以看到,底层共享+顶层独立的方案在效果和效率之间取得了最好的平衡。
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 | 备注 |
|---|---|---|---|
| 原始版本 | 45ms | 0.8078 | 基线 |
| 并行化 | 22ms | 0.8078 | 无精度损失 |
| 并行化+外积降维 | 8ms | 0.8069 | 可接受 |
| 蒸馏模型 | 3ms | 0.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 |
|---|---|---|---|---|
| LR | 0.7821 | 0.7654 | 0.1M | 5s |
| FM | 0.7934 | 0.7789 | 1.2M | 12s |
| DeepFM | 0.8012 | 0.7891 | 4.5M | 22s |
| DCN-V2 | 0.8045 | 0.7923 | 5.8M | 30s |
| xDeepFM | 0.8051 | 0.7930 | 7.2M | 38s |
| CAN | 0.8078 | 0.7965 | 6.2M | 28s |
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上体现得淋漓尽致。