☰
CTR预估特征协同实战:CAN模型原理、工程落地与踩坑指南
2026/10/14 13:35:25 网站建设 项目流程

1. 从"单打独斗"到"团队作战":CTR预估为什么需要特征协同

点击率预测这个领域,做了几年的人都会有一个共同的感受:模型结构换了一茬又一茬,从最早的逻辑回归到后来的FM、FFM,再到DeepFM、xDeepFM、DIN、DIEN,每次新模型出来都宣称AUC涨了几个千分点,但真正上线之后发现,效果提升往往没有论文里那么惊艳。问题出在哪?很多时候不是模型不够深,而是特征之间的协同关系没有被真正挖掘出来。

什么叫特征协同?举个很朴素的例子。在电商场景里,"用户性别=女"这个特征单独看,对点击率的区分度可能很弱;"商品类目=口红"这个特征单独看,区分度也就那样。但这两个特征组合在一起,就形成了一个非常强的信号——女性用户看到口红类商品的点击概率,远高于男性用户看到口红,也远高于女性用户看到五金工具。这种"1+1>2"的效应,就是特征协同作用。

传统的做法是把这类交叉特征人工设计出来,喂给模型。但人工设计有几个致命问题:第一,你不可能穷举所有有意义的组合,特征一多组合空间就爆炸;第二,很多协同关系是隐式的、非线性的,靠人眼根本看不出来;第三,业务在变,今天有效的交叉特征明天可能就失效了,维护成本极高。

CAN(Co-Action Network,特征协同网络)这个模型就是冲着这个问题去的。它的核心思路是:不再把特征当成孤立的ID embedding然后简单拼接或做内积,而是让两个特征之间发生"共作用"——一个特征作为主体,另一个特征作为条件,通过一个专门的协同网络来建模它们之间的交互。这个设计在思路上和FM系列有本质区别,FM本质上是给每对特征学一个标量权重,而CAN是给每对特征学一个映射函数。

我最初接触CAN是在一个推荐系统的CTR预估项目里,当时线上模型是DeepFM,特征交叉部分基本靠FM的二阶项撑着,但业务方反馈说"用户活跃度"和"内容时效性"这两个维度的交互效果一直不理想。后来把这两个特征单独拎出来用CAN的方式建模,离线AUC涨了0.4个点,线上CTR涨了大概2.3%。这个收益在成熟业务里已经算相当可观了。

这篇文章我会从CAN的核心机制讲起,把它的设计动机、网络结构、和FM/DeepFM的本质区别、实际落地时的工程细节、以及我踩过的坑都掰开揉碎讲清楚。不管你是刚入行做推荐算法的同学,还是已经在CTR预估上摸爬滚打几年的老手,应该都能从中找到对自己有用的东西。

2. CAN的核心机制:共作用网络到底在算什么

2.1 从FM的二阶项说起:为什么标量权重不够用

要理解CAN,得先理解FM的局限性。FM的二阶项公式是:

y_FM = sum_i sum_j <v_i, v_j> * x_i * x_j

其中<v_i, v_j>是两个特征embedding的内积,本质上是一个标量。这意味着FM对每一对特征交互的建模能力,被压缩成了一个数。这个数只能表达"这两个特征一起出现时,对最终结果的贡献是正还是负,以及多大",但它没法表达更复杂的关系。

打个比方,FM就像给每对特征配了一个固定的"合作系数"。但现实中,两个特征之间的交互方式可能是高度非线性的。比如"用户年龄"和"商品价格"的交互,年轻用户对低价商品敏感,中年用户对高价商品反而更愿意点击,这种关系不是一个简单的系数能刻画的。

CAN的做法是:把内积换成一个神经网络。对于特征i和特征j,不再计算<v_i, v_j>,而是把v_i和v_j送进一个小的MLP,输出一个交互表示。这个表示可以是向量,也可以是标量,取决于具体设计。这样一来,特征之间的交互就从"线性加权"升级成了"非线性映射"。

2.2 Co-Action的具体计算过程

CAN的核心计算单元叫Co-Action。给定两个特征的主体embeddinge_i和e_j,Co-Action的计算大致是这样的:

# 伪代码示意 def co_action(e_i, e_j): # e_i: 主体特征的embedding, shape [batch, d] # e_j: 条件特征的embedding, shape [batch, d] # 方式一:拼接后过MLP concat = tf.concat([e_i, e_j], axis=-1) # [batch, 2d] hidden = tf.layers.dense(concat, units=hidden_dim, activation='relu') output = tf.layers.dense(hidden, units=1) # [batch, 1] # 方式二:element-wise交互后过MLP interaction = e_i * e_j # [batch, d] hidden = tf.layers.dense(interaction, units=hidden_dim, activation='relu') output = tf.layers.dense(hidden, units=1) return output

这里有个关键设计选择:谁是主体,谁是条件。在CAN的原始论文里,作者把特征分成了两类,一类是"目标特征",一类是"协同特征"。目标特征的embedding会作为主体,协同特征的embedding作为条件,通过Co-Action网络生成交互表示。这个交互表示再和其他特征一起送进后续的网络。

我个人的理解是,这种主客体的区分其实是在给模型一个"注意力"的引导。比如在新闻推荐里,用户特征是主体,新闻特征是条件,模型学到的就是"这个用户在这条新闻上的偏好"。反过来如果新闻是主体,用户是条件,学到的就是"这条新闻对这个用户的吸引力"。两者在数学上可能等价,但在实际训练中,由于初始化和梯度传播的差异,效果会有区别。

2.3 多特征协同:从两两交互到分组交互

实际业务里,特征数量动辄几十上百个,不可能对所有特征对都做Co-Action,那样计算量会爆炸。CAN的做法是分组:把特征分成若干组,组内做Co-Action,组间用常规方式融合。

比如可以把特征分成"用户侧"、"商品侧"、"上下文侧"三组。用户侧内部做Co-Action,商品侧内部做Co-Action,然后三组的输出再拼接起来送进DNN。这样既控制了计算量,又保留了组内特征之间的深度交互。

这里有个工程上的经验:分组不是随便分的,要按业务逻辑来分。我见过有人把用户年龄和商品价格分到不同组,结果模型学了半天也没学到这两个特征的交互,因为它们在网络结构上根本没有直接交互的机会。正确的做法是把有潜在交互关系的特征放在同一组,比如"用户消费能力"和"商品价格"就应该在一组。

3. CAN与FM、DeepFM的本质差异:不只是换个网络结构

3.1 参数空间与表达能力对比

很多人第一次看CAN的论文会觉得"这不就是把FM的内积换成了MLP吗",但实际用下来会发现差异远不止于此。最核心的区别在参数空间上。

FM的二阶项参数总量是O(n*d),其中n是特征数,d是embedding维度。每个特征只有一个embedding向量,所有和它相关的交互都共享这个向量。这是一种很强的参数共享,好处是泛化性好、不容易过拟合,坏处是表达能力受限。

CAN的Co-Action网络是独立的一套参数,对于每一组特征对,都有一个专门的MLP。参数总量是O(k*hidden_dim),其中k是特征对的数量,hidden_dim是MLP的隐藏层维度。这意味着CAN有更多的参数来刻画交互关系,表达能力更强,但也更容易过拟合。

实际选型的时候,我的经验是:如果特征数量不多(比如几十个),且样本量足够大(千万级以上),CAN的优势会很明显;如果特征数量上千,样本量又不够,CAN很容易过拟合,这时候FM或者DeepFM反而更稳。

3.2 梯度传播路径的差异

另一个容易被忽略的差异是梯度传播。在FM里,特征i的embeddingv_i的梯度来自所有和i相关的交互项,梯度信号是累加的。而在CAN里,e_i的梯度只来自它参与的那些Co-Action网络,梯度路径更短、更直接。

这个差异带来的实际影响是:CAN对embedding的更新更"精准",但也更"局部"。如果某个特征在Co-Action里出现的次数少,它的embedding可能训练不充分。我在实际项目里遇到过这个问题:一个长尾特征在CAN里的embedding几乎没怎么更新,导致模型对这个特征的预测基本靠偏置项撑着。

解决办法有两个:一是对长尾特征做embedding预热,先用FM或DeepFM训练一轮,把embedding初始化好再上CAN;二是对Co-Action网络加正则,防止它过度依赖少数几个高频特征。

3.3 实际效果对比:我在项目里的实测数据

说理论可能不够直观,直接上我在一个电商CTR项目里的实测数据。数据集是两周的曝光点击日志,约8000万条样本,特征维度约120个,AUC评估。

模型离线AUC线上CTR提升训练耗时(相对值)
LR0.7213基线1.0
FM0.7386+1.8%1.5
DeepFM0.7452+2.6%2.3
xDeepFM0.7468+2.9%3.1
CAN(分组Co-Action)0.7514+3.7%2.8

可以看到CAN的离线AUC比DeepFM高了0.6个点,线上CTR提升多了1.1个百分点。训练耗时比xDeepFM还低一些,因为CAN的分组设计控制了计算量。

但这里要强调一点:这个结果是在特征分组合理的前提下得到的。我后来试过随机分组,AUC直接掉到0.7430,比DeepFM还差。所以CAN的效果高度依赖于分组策略,不是无脑上就能涨点的。

4. 落地CAN时必须搞清楚的工程细节

4.1 特征分组策略:按业务逻辑还是按统计相关性

特征分组是CAN落地最关键的决策,没有之一。我试过三种分组方式,效果差异很大。

第一种是按业务逻辑分组,比如用户属性一组、商品属性一组、行为序列一组。这种方式的好处是可解释性强,每组内部的交互有明确的业务含义。坏处是可能漏掉跨组的交互,比如"用户属性"和"商品属性"之间的交互就被结构性地排除了。

第二种是按统计相关性分组。先算特征之间的互信息或卡方值,把相关性高的特征分到一组。这种方式更数据驱动,但有个问题:统计相关性高不代表交互作用强。两个特征可能高度相关但交互效应很弱,分到一组反而浪费了Co-Action的容量。

第三种是混合策略,先按业务逻辑粗分,再在组内按统计相关性微调。这是我现在最常用的方式。具体操作是:先用业务逻辑把特征分成5-8个大组,然后在每个大组内部,用互信息矩阵做一次聚类,把互信息高的特征对单独拎出来做Co-Action,其余特征走常规路径。

实操提示:分组数量不要太多,一般控制在4-8组。组太多会导致每组内的特征太少,Co-Action学不到东西;组太少又退化成全连接,失去CAN的意义。

4.2 Co-Action网络的维度设计

Co-Action网络本身的结构也有讲究。原始论文里用的是两层MLP,隐藏层维度等于embedding维度。我实际试下来,隐藏层维度设为embedding维度的1.5-2倍效果更好,但再大就过拟合了。

激活函数方面,ReLU是默认选择,但在一些稀疏特征场景下,GELU的表现更稳。我做过一组对比实验,在行为序列特征上,GELU比ReLU的AUC高了0.15个点,但在用户属性特征上两者差不多。所以如果特征里有大量稀疏的序列特征,可以试试GELU。

还有一个细节是是否加BatchNorm。我的经验是:如果batch size比较大(比如4096以上),加BN有帮助;如果batch size小,BN反而会引入噪声。另外,Co-Action网络的输出最好加一个LayerNorm,稳定训练过程。

4.3 训练技巧:学习率、正则与embedding初始化

CAN的训练比DeepFM要敏感一些,主要是因为它参数更多、结构更复杂。我总结了几条经验:

学习率方面,CAN适合用比DeepFM更小的学习率。DeepFM我用0.001通常没问题,但CAN用0.001容易震荡,0.0005或者0.0003更稳。如果用了warmup,可以适当放大一点。

正则方面,Co-Action网络的L2正则系数要比主网络大。我一般设主网络1e-5,Co-Action网络1e-4。另外dropout在Co-Action里也很重要,rate设0.1-0.3之间,具体看数据量。

Embedding初始化方面,前面提过长尾特征的问题。我的做法是:先用FM预训练一轮,把embedding存下来,然后CAN加载这些embedding作为初始化。这样长尾特征的embedding至少有个合理的起点,不会完全随机。

# 预训练embedding加载示意 def load_pretrained_embedding(model, pretrain_path): pretrain_vars = tf.train.load_variables(pretrain_path) for var in model.embedding_vars: if var.name in pretrain_vars: var.assign(pretrain_vars[var.name]) return model

4.4 线上 Serving 的性能考量

CAN的线上推理比DeepFM要重一些,因为Co-Action网络是额外的计算。如果特征分组多、每组特征多,推理延迟会明显上升。

我的优化经验是:第一,Co-Action网络尽量用小维度,隐藏层不要超过128;第二,可以把Co-Action的计算做成并行,不同组的Co-Action互不依赖,可以同时算;第三,如果延迟实在压不下来,可以考虑把Co-Action网络量化成int8,精度损失通常在0.1个点以内,但推理速度能快2-3倍。

另外,CAN对特征缺失比较敏感。如果某个协同特征在线上缺失,Co-Action的输出会偏掉。我的做法是给每个协同特征加一个默认embedding,缺失时用默认值代替,同时在模型里加一个缺失指示特征,让模型自己学怎么处理缺失。

5. 踩坑实录:那些让我加班到凌晨的CAN问题

5.1 第一个坑:Co-Action输出全为零

这个问题我印象特别深。模型训练了几个epoch,loss几乎不降,打印出来发现Co-Action层的输出全是零。排查了半天,最后发现是初始化的问题:Co-Action网络的最后一层用了零初始化,而ReLU在零附近的梯度也是零,导致整个网络死掉了。

解决办法很简单:最后一层不要用零初始化,用小的随机初始化,比如tf.random_normal_initializer(stddev=0.01)。或者把ReLU换成LeakyReLU,给负半轴一个小的斜率。这个坑在DeepFM里不会遇到,因为FM没有这种多层结构。

5.2 第二个坑:分组后AUC反而下降

前面提过随机分组会导致AUC下降,但我还遇到过一个更隐蔽的情况:分组看起来合理,但AUC就是上不去。后来分析发现,问题出在组内的特征尺度差异太大。比如一组里既有取值0-1的归一化特征,又有取值0-100的计数特征,Co-Action网络会被大尺度特征主导,小尺度特征的信息被淹没。

解决办法是在送进Co-Action之前,对每个特征的embedding做一次LayerNorm,把所有特征的尺度拉到同一水平。这个操作看起来简单,但效果立竿见影,AUC直接涨了0.3个点。

5.3 第三个坑:线上延迟超标

离线AUC涨了,兴冲冲上线,结果P99延迟从50ms涨到120ms,直接被运维打回来。排查发现是Co-Action网络的计算量比预期大,而且没有做并行。

优化过程分三步:第一步,把Co-Action的隐藏层从256降到128,AUC只掉了0.05个点,但延迟降了30%;第二步,把不同组的Co-Action做成并行计算,延迟又降了20%;第三步,把embedding lookup和Co-Action计算做流水线,延迟再降15%。最终P99延迟控制在65ms,勉强达标。

这个经历给我的教训是:CAN上线前一定要做延迟压测,不能只看离线指标。离线AUC涨0.5个点,如果延迟翻倍,业务方大概率不会接受。

5.4 第四个坑:模型更新后效果波动

CAN的另一个问题是模型更新后效果波动比DeepFM大。同样的数据、同样的超参,不同批次训练出来的模型AUC能差0.2个点。这在DeepFM里很少见。

后来发现原因是CAN的参数更多,对初始化和数据顺序更敏感。解决办法是固定随机种子,并且用更大的batch size。另外,我还会在训练时用EMA(指数移动平均)来平滑参数,这样模型更新后的波动会小很多。

6. 一些实战心得与后续优化方向

CAN这个模型,我的整体评价是:在特征交互建模上确实比FM系列有本质提升,但代价是工程复杂度更高、调参更麻烦。它不是那种"无脑替换就能涨点"的模型,需要你对业务特征有深入理解,才能把分组和网络结构设计好。

如果你正准备在自己的项目里试CAN,我的建议是:先用小规模数据跑通流程,验证分组策略是否合理;然后再上全量数据,重点调Co-Action网络的维度和正则;最后上线前一定要做延迟压测和A/B实验。不要指望一次就能调到最优,我调了大概两个月才把效果稳定下来。

后续的优化方向,我个人比较看好两个:一是把Co-Action和注意力机制结合,让模型自动学习哪些特征对值得做深度交互;二是把CAN用到多任务场景,比如同时预估CTR和CVR,让两个任务共享Co-Action网络,互相补充信息。这两个方向我都在试,有进展再跟大家分享。

最后分享一个小技巧:CAN的Co-Action网络其实可以复用。如果你有多个场景(比如首页推荐和搜索推荐),可以把Co-Action网络做成共享的,只让上层DNN场景化。这样既能共享特征交互的知识,又能保留场景差异,参数效率更高。我在一个双场景项目里试过,共享Co-Action比各自独立训练,两个场景的AUC都涨了0.1-0.2个点。

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

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

立即咨询