CAN模型解析:用参数生成与特征调制突破CTR预估的表示瓶颈

发布时间:2026/10/28 0:56:30
CAN模型解析:用参数生成与特征调制突破CTR预估的表示瓶颈

做点击率预测这几年,我越来越觉得,特征交叉才是模型效果的分水岭。CTR预估领域里有一个模型叫CAN——Co-Action Network,专门用来建模特征协同作用(Feature Co-action)。它不靠隐式交叉,也不靠简单的embedding内积,而是把特征之间的相互作用直接参数化:一个特征生成变换参数,去调制另一个特征的表示。这篇文章我会从问题背景、核心设计、训练细节到实际踩坑,完整拆一遍CAN,适合正在做推荐系统或广告CTR模型的算法工程师,也适合想系统了解特征交叉建模的同学。

1. 先把问题说透:CTR预估里的特征协同到底是什么

1.1 一个特征单独看不值钱,协同才值钱

广告和推荐场景里的CTR预估,本质上是这样一个问题:给定一个用户、一个商品(或广告),以及当前上下文环境,预测用户点击的概率。输入特征通常分几类:用户侧特征(年龄、性别、历史点击类目分布)、商品侧特征(类目、品牌、价格带)、上下文特征(时间、渠道、位置)。

关键问题在于:任何一个特征单独拿出来,其实都说明不了太多。光看"性别=男"没有意义,只有把性别和商品类目放在一起才有判断价值——男性看电竞类目可能点得很欢,看美妆类目大概率不点。这就是特征协同作用:多个特征联合在一起,才产生完整的预测信号。

用生活里的例子最好理解:盐本身不是菜,和白菜搭配是腌菜,和牛肉搭配是酱牛肉,和冰激凌放在一起就是黑暗料理。同一个特征,在不同的"搭配"下语义完全不同。CTR模型要做的,就是把这种搭配关系学出来。

实际业务里,特征协同的收益很大。我曾经做过一个电商场景的模型,单纯加一个"用户活跃层级×商品价格带"的交叉特征,离线AUC就能涨千分之三到五。在几亿用户的量级下,这个涨点对应的可能是千万级的GMV差异。所以行业里从不缺做特征交叉的模型,从FM到DeepFM,再到各种attention方案,都是为了把协同作用榨干。

1.2 老方法的问题:表示瓶颈和参数共享

为什么有了FM和DeepFM,还要折腾新结构?因为现有方法有两个绕不过去的坎。

第一个坎叫表示瓶颈(representation bottleneck)。FM系列的做法是把特征映射成固定维度的embedding,然后对两个特征算内积,把协同作用压缩成一个标量。问题是,一个32维的向量内积就是一个数,它能携带的信息量极其有限。两个特征之间的协同,理论上是一个高维的、细粒度的交互,用一个标量去概括,丢掉的细节远比留下来的多。就好比两个人聊了半天,最后只总结出一个"嗯",信息全丢了。

第二个坎是参数共享过于粗放。DeepFM、DCN这类模型把交叉交给MLP隐式学习,理论上MLP能逼近任意函数,但实际训练中,MLP要同时学所有特征的交叉模式,参数是全局共享的。某个特定"男性用户×电竞类目"组合的专属规律,没有一个专门的参数去表达,它必须和几万个其他组合挤在同一个网络里。数据量足够大的组合也许能学出来,数据量不够的组合就学不好。

DIN、Target Attention这类方法用attention机制,算是往前走了一步。但它们做的事情本质上是"从行为序列里加权组合已有表征",而不是为每一对特征生成新的、专属的变换参数。attention学到的是"加权重要性",CAN想学的是"这个组合到底应该产生一个什么样的协同向量"。

1.3 CAN一句话解题

CAN的核心思路,如果用一句话概括,就是:把特征对之间的协同作用,建模成"一个特征生成变换参数,去调制另一个特征的表征"。

拿"用户性别"和"商品类目"举例:用户性别作为触发特征,先生成一组变换参数(缩放权重和偏置),然后作用到"商品类目"的目标特征embedding上。不同的性别取值,生成的变换参数不同;再往上组合,不同上下文里同一取值也会产生不同结果。于是每一对特征组合得到的不是一个标量相似度,而是一个完整的、带着专属调制的协同向量,这个向量再喂给后续MLP处理,信息量比一个内积标量大得多。

这个思路在WSDM 2023上由工业界团队提出后,在推荐圈讨论度不低。原因不难理解:它没有引入特别复杂的结构,却精准打在了"表示瓶颈"这个要害上。和CAN容易混淆的是汽车电子里的CAN总线协议,平时聊到这个名字要留意语境,搜索资料时建议加上"CTR""点击率"这类限定词。

2. CAN核心设计拆解:协同作用单元是怎么工作的

2.1 整体网络结构

CAN整体上还是一个"Embedding + 多层网络 + 输出层"的框架,并没有跳出常见CTR模型的骨架。它的特殊之处在中间多了一层"协同作用层"(Co-Action Layer)。

输入的类别特征(包括用户侧、商品侧、上下文侧)先查表得到各自embedding。然后,按事先选好的特征对,比如"用户性别×商品类目",把这两个embedding送进协同作用单元,计算出一个协同向量。所有协同向量拼接起来,再和原始embedding(或部分原始embedding)一起送入多层全连接网络,最后接sigmoid做二分类,用交叉熵损失训练。

这个结构和DeepFM最大的区别,就是中间那层协同作用层在显式计算特征对的交互,而且交互结果是向量,不是标量。后续的MLP不需要再从零开始猜测交叉规律,它只需要在已经完成协同调制的向量上做更高层的组合。

2.2 触发特征和目标特征:谁调制谁

在CAN里,一个特征对被拆成两个角色:触发特征(trigger)和目标特征(target)。

对于"用户性别×商品类目"这对,用户性别作为触发特征,商品类目作为目标特征。触发特征的embedding不直接参与协同向量计算,而是先生成一组变换参数;目标特征的embedding带着原始信息,被这组参数调制,得到协同向量。

为什么要有角色之分,而不是对称地做内积?因为内积对称的代价是把交互压成一个标量。如果让一边显式地"生成参数"、另一边"被参数调制",交互的高维空间就被保留下来了。实际使用时,角色分配通常按业务方向来定:用户特征触发、商品特征被调制很常见;反过来做也可以;同一对特征双向各算一次再拼接也行,但计算量翻倍。

我从实践角度建议:如果不是对某个特征对特别有把握,先做单向就够了。双向当然信息更足,但训练成本和推理成本都会明显上涨,很多场景下用户到商品的方向已经完全够用。

2.3 两个基本操作:缩放(weight)和偏置(bias)

协同作用单元内部核心是两个生成操作。设触发特征embedding为 e_i,目标特征embedding为 e_j,维度都是d。

触发特征的embedding经过两个参数生成网络(一般就是小MLP),分别生成一个缩放向量 w 和一个偏置向量 b:

  • w = G_w(e_i),维度d,代表"触发特征对目标特征各维度的加权系数"
  • b = G_b(e_i),维度d,代表"触发特征为这个组合添加的偏移"

然后,目标特征embedding被这样调制:

  • z_ij = w ⊙ e_j + b

其中 ⊙ 是逐元素乘,最终得到 z_ij 就是特征对 (i,j) 的协同向量。

拆开看每个部分的含义:w ⊙ e_j 相当于根据触发特征把目标特征embedding的每个语义维度重新加权——"男性用户"在"商品类目"的语义空间里,把"电竞"相关的维度放大,把"美妆"相关维度压低。b 则是加性修正,捕捉那些不能被缩放表达的偏移。整个过程下来,一个特征对得到一个d维向量,每个维度都有明确的语义调制,而不是一个干巴巴的相似度分数。

这里要注意,CAN的交互方式是vector-wise的:整个embedding作为整体被调制,不是把embedding拍平后做bit-wise交叉。这个区别在xDeepFM那篇论文里讨论过,vector-wise操作能更好保留embedding内部的语义结构,这也是CAN在实践里表现扎实的原因之一。

2.4 参数规模怎么控:低秩和共享

一个需要正视的问题:如果触发特征生成的是一个完整的变换矩阵 W ∈ R^{d×d},变量规模会非常吓人。假设d=32,一个矩阵就是1024个参数,几十个特征对、每个特征又有几十上百个取值,参数量会迅速失控。

CAN的做法分两条路。第一,把完整的矩阵换成对角矩阵(也就是逐元素缩放),参数从d×d降到d;第二,如果确实需要更丰富的变换,用低秩分解把W拆成两个低秩矩阵的乘积,比如 W ≈ U V^T,U和V的维度各取4或8,参数量就明显可控。

参数生成网络本身可以跨特征对共享。也就是说,所有特征对共用同一套G_w和G_b的MLP参数,区分度来自输入e_i的差异。这样一来,增加一个特征对,新增的参数主要就是那对特征自身的embedding,整体参数增长是可控的。

我实际算过一笔账:d=32、生成网络两层MLP(32→64→64),一个参数生成网络的参数量大约几千;整个模型如果embedding参数占大头,协同层增加的参数量占比可能不到5%,但带来的AUC提升往往在千分之几量级。这个性价比在工业界是很划算的。

2.5 多个特征对怎么组合

实际模型不可能只做一对特征的协同。一般会按业务经验选3到10个特征对,比如:

  • 用户性别 × 商品类目
  • 用户年龄段 × 商品价格带
  • 用户城市等级 × 商品品牌
  • 用户历史点击类目分布 × 商品类目
  • 上下文渠道 × 商品类目

每个特征对各算各的协同向量,然后把所有向量拼接成长向量,喂给最终的MLP。有些实现还会把原始embedding一并拼进去,相当于给高层一个"直连"通路,防止调制过程把原始信息带偏——这个思路类似ResNet的残差结构,实测对收敛速度有帮助。

这里要提醒一点:特征对不是越多越好。协同层是显式建模,每一对都有计算成本,而且冗余特征对会让模型学到重复信息,离线指标并不一定继续涨。我在一个项目里从5对加到8对,AUC基本没动,训练时间涨了30%。后面我就学乖了:先按业务直觉选少数几对,跑通后做ablation逐对看贡献,有价值的留下,没贡献的删掉。

3. 训练策略与工程落地:从论文到真正能上线

3.1 两阶段训练:预训练与微调

CAN论文里提到了一个很实际的问题:协同参数如果只靠CTR主任务从头学,低频特征对(比如冷门用户群体×冷门商品类目)会学得稀烂。所以它设计了两阶段训练思路。

第一阶段是预训练,目标是让协同作用单元在大规模语料上先把"普遍规律"学会。这个阶段可以理解为先把特征共现模式见个够,把参数生成网络训练得足够稳定。第二阶段是微调,在目标任务数据上继续训练,让模型逐步适配具体场景和新鲜数据。

工业落地时这个思路很实用。我见过不少团队直接用"全量数据预训练+近期数据微调"的方式来做,效果比单阶段好,而且收敛快不少。一个关键细节是:预训练阶段学习率可以稍大,微调阶段要降下来,通常降一个数量级,否则容易把预训练学到的稳定结构冲坏。

3.2 Embedding维度、初始化与训练参数

说几个实操层面的关键参数。

Embedding维度,建议从32起步。CAN的target embedding会被逐元素调制,维度太小时调制作用在过低的维度上,信息不够用;维度太大,参数生成网络也跟着变大,训练变慢。16维和64维我都试过,32到48维是比较舒服的区间,具体看特征规模。

初始化是重灾区。参数生成网络G_w的输出如果随机初始化(比如默认的均匀分布),训练初期 w ⊙ e_j 等于把目标embedding随机打乱再叠加随机偏置,模型开局就是一团噪声,loss下降特别慢。一定要把G_w最后一层的权重初始化成输出接近1(至少让w的均值落在1附近),G_b的输出初始化成接近0。这样训练初期协同向量约等于原始目标embedding,模型在一个合理起点上开始学习。这个坑我踩过,改了初始化后收敛速度快了将近一倍。

训练参数上没有太特殊的要求:Adam优化器,学习率1e-3起步配warmup,batch size尽量大(4096以上),损失函数用标准BCE。如果发现训练不稳定,先看是不是初始化问题,再看学习率,最后查数据里有没有异常稀疏的特征。

3.3 特征对选取的三个原则

前面提了特征对数量,这里展开说选取原则。

第一,业务优先。用户侧×商品侧是默认高价值区域,因为CTR的本质就是"谁在什么情况下看到什么商品点不点"。上下文和商品的配对也值得做,比如"渠道×类目"在信息流广告里能抓到很强的位置/场景效应。

第二,注意触发特征的稀疏度。触发特征是生成网络的输入,如果取值分布太稀疏,比如某个取值只出现几百次,生成网络根本学不出稳定参数。处理办法是把低频取值做桶化,或者用embedding校验/聚类合并相似取值,实在不行就换一对特征。

第三,用模型辅助验证。我习惯先用GBDT之类的树模型跑一版,看哪些特征组合分裂增益高,再把这些组合拿去做CAN的特征对。树模型对特征交叉的发现能力很强,拿它来筛候选特征对,比拍脑袋靠谱得多。

3.4 PyTorch简化实现参考

下面给一个简化实现,方便理解核心逻辑。真实业务里还会有特征Hash、embedding查找、多特征对管理这些工程细节。

import torch import torch.nn as nn class CoActionLayer(nn.Module): """ 协同作用单元:触发特征生成缩放和偏置,调制目标特征。 简化版,只演示核心计算。 """ def __init__(self, embed_dim=32, hidden_dim=64): super().__init__() self.embed_dim = embed_dim # 两个参数生成网络:输出缩放w、偏置b self.weight_net = nn.Sequential( nn.Linear(embed_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, embed_dim) ) self.bias_net = nn.Sequential( nn.Linear(embed_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, embed_dim) ) # 关键:把权重网初始化为输出接近1,偏置网输出接近0 for m in self.weight_net: if isinstance(m, nn.Linear): nn.init.normal_(m.weight, std=0.01) nn.init.zeros_(m.bias) # 简化示意:让最后一层输出接近1 nn.init.ones_(self.weight_net[-1].weight) nn.init.zeros_(self.weight_net[-1].bias) def forward(self, e_trigger, e_target): # e_trigger: [batch, d], e_target: [batch, d] w = self.weight_net(e_trigger) b = self.bias_net(e_trigger) return w * e_target + b

模型里对每个选定特征对调用一次协同层,然后把所有协同向量concat起来:

# 伪代码:对两个特征对做协同 z1 = co_action(user_gender_emb, item_category_emb) z2 = co_action(user_age_emb, item_price_emb) # 和原始embedding一起拼接 x = torch.cat([z1, z2, user_gender_emb, item_category_emb], dim=-1) out = mlp(x)

这里的初始化方式只是示意,具体要根据激活函数和网络结构调整。核心原则就一条:保证训练初期输出接近"不调制"的状态。

4. 实操中的常见问题与排查实录

4.1 离线涨点,线上没效果

这是CTR模型最常见的坑,CAN也不例外。训练和线上特征产出不一致时,协同层会放大这种差异,因为输入上的任何小偏差,经过参数生成后都可能被放大到整个协同向量上。

排查顺序我一般是这样:先检查特征一致性,确认训练日志里用的特征和线上打分用的特征同源、同时段、同口径;再查特征穿越,看看有没有用到点击之后才产生的信息;最后拉一个简单模型(比如去掉协同层的同结构模型)做在线对照,确认增量到底来自协同层。

还有一个容易忽视的点:线上推理时,某些特征值(比如新上线的类目)在训练集里低频,协同参数学得不好。这种情况建议在特征工程层面做兜底,把新值映射到"unknown"桶,而不是让模型处理从未见过的embedding。

4.2 训练初期不稳定、loss降不下去

如果遇到这个问题,先检查协同层的初始化。之前说过,w输出接近1、b输出接近0很关键。如果训练刚开始一两千步loss基本不动,大概率是初始化没做好。

其次是学习率。生成网络虽然小,但对学习率敏感,建议配合warmup。我试过直接把学习率从1e-3调到1e-4,训练稳定了但收敛变慢;正确做法是1e-3起步、前1000到2000步线性warmup,再正常衰减。

最后,梯度裁剪可以兜底。CTR这类稀疏特征模型偶尔出现超大梯度很正常,不影响大局,但加上clip能减少训练抖动。

4.3 低频特征对的协同学不出来

CAN的一个天然弱点是触发特征的值分布。如果某个特征取值在训练集里只有几千次曝光,生成网络给它算出的变换参数基本就是噪声。我的处理办法有三个:

第一,做频率桶化,把出现次数少于阈值的取值映射到共享的"低频桶"embedding;第二,用预训练阶段多学习这类低频组合,至少让它们在"普遍规律"层面是合理的;第三,实在不行就放弃这对特征,用其他特征对替代。别恋战,不是每对特征都适合做显式协同。

4.4 计算量太大,上线扛不住

CAN的协同层参数量可控,但计算量是实打实的:每个特征对都要跑生成网络,在线推理时特征对一多,时延压力不小。

有一个工程上很好用的优化:触发特征的取值数量是有限的(比如性别就几个值,城市等级就几档),可以提前把每个触发值对应的w和b算好缓存。在线推理时直接查表拿到w和b,再对目标embedding做逐元素乘加,整个协同层退化成两次向量运算,生成网络的MLP直接省掉。实测可以把协同层单次推理耗时压到微秒级别。另一个方案是量化生成网络到低精度,但一般用不到,缓存大法已经够香。

4.5 常见问题速查表

现象可能原因处理建议
离线AUC涨,线上无变化特征穿越/训练线上特征不一致查特征一致性和数据穿越,跑简单模型在线对照
loss初期不降协同层随机初始化破坏target embeddingw初始化输出接近1,b初始化为0
训练波动大学习率过高/稀疏特征噪声加warmup,降低学习率,梯度裁剪
低频特征协同无效触发特征取值太稀疏频率桶化、预训练兜底、删除该特征对
推理时延高协同层生成网络在线计算预计算触发值的w、b并缓存查表
参数量增长快特征对过多/生成矩阵过大低秩分解、共享生成网络、控制特征对数量

5. 最后说点个人体会

CAN这个模型,我最初以为又是一个堆结构刷AUC的paper,真正在自己数据集上跑过之后,我改变了一点看法。它最值得学习的不是某个具体结构,而是"把特征交互显式参数化"这个建模思路——在很多场景下,显式的、可解释的协同建模,确实比让MLP在暗处瞎猜要可靠。

如果想把CAN用到自己的模型里,我的建议很朴素:先把特征对选到最少,用最简单的基础版本跑通,把初始化、预训练、缓存这些工程细节做扎实,再逐步加对。不要一上来就堆十个八个特征对,那样你根本说不清涨点来自哪里。我自己还有个习惯,会把每个协同向量单独拉出来看统计分布,如果某个特征对的协同向量方差明显异常,通常说明那对特征的数据质量或者取值分布有问题,比盲目调结构有效得多。

特征协同这件事,模型结构只是放大器,真正的基础还是对业务的理解。谁和谁在一起会产生特殊的化学反应,这个问题业务同学最清楚,算法工程师要做的,是找到一类能够把这种化学反应用显式结构表达出来的模型。CAN给了我们一个很好的角度,这个方向后面还可以继续往多阶协同、序列场景延伸,值得持续跟踪。