ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

CAN特征协同建模:从注意力机制到CTR预估的工程实践

CAN特征协同建模:从注意力机制到CTR预估的工程实践

1. 从"热词"出发:为什么CAN突然和点击率预测扯上了关系

先说个有意思的现象。我最近在复盘CTR预估相关的技术方案时,发现"CAN"这个词的热度涨得离谱,但搜出来的结果五花八门——一会儿是can总线,一会儿是can协议报文解析,一会儿又是STM32通信连不上。看上去跟点击率预测八竿子打不着,但偏偏在推荐系统圈子里,CAN正作为一个"特征协同作用"的建模方法被反复讨论。

这里必须先做个区分:我们说的CAN是Click Attention Network,不是Controller Area Network,也不是can协议栈。后者是汽车电子、嵌入式通信里的东西,前者才是搜索推荐广告场景下的点击率预估模型。很多朋友第一次看到这个缩写都会懵,我在几个技术群里也见过有人把两篇文章丢混,讨论半天发现说的根本不是同一个东西。

那CAN在CTR预估里到底解决什么问题?简单说,传统模型里特征交叉要么靠FM/DeepFM这种显式二阶交叉,要么靠DNN隐式高阶交叉,但都忽略了"用户当前点击行为"对候选item打分的影响。CAN的做法是:把用户历史点击过的item序列当作"注意力池",让当前候选item跟历史点击序列做attention交互,从而把"特征协同作用"落到行为序列层面。

这篇我就结合实际业务场景,把CAN的建模思路、和主流模型的关系、踩过的坑、还有在工程落地时的关键细节一次讲透。后面还会顺便把热词里那些"can总线"之类的干扰项做一次彻底厘清,免得你搜索时被带偏。

2. 特征协同作用这件事,为什么值得单独建模

2.1 传统特征交叉模型的"天花板"

先说说CTR预估里的老问题。像FM、FFM这类模型,本质上是把每个特征学一个隐向量,然后通过内积来模拟特征之间的二阶交叉。DeepFM在此基础上加了DNN部分,理论上高阶交叉也有了,但实际训练中DNN对高阶组合的拟合效率并不高,尤其是特征特别稀疏的时候,很多组合在训练集里压根没出现过,模型根本学不到有效表征。

我在实际项目中就遇到过这种情况:用户是"美妆达人",item是"某品牌口红新品",按理说这个组合应该有很强的信号,但因为历史数据里"美妆达人"和这个新品的共现次数极少,FM的二阶项几乎不起作用,DNN也拟合不出有效的高阶表达。最后模型给的分偏低,曝光少,恶性循环。

这不是调参能解决的问题,而是建模范式的缺陷——特征协同作用没有被"显式地、有结构地"建模出来。

2.2 协同作用的本质是"在什么上下文下,什么信号更重要"

后来业界开始反思:特征交叉的价值,其实取决于上下文。同样是"用户性别=女",在"母婴用品"场景下是强信号,在"数码配件"场景下几乎没用。这种"条件相关性"用静态的向量内积是很难刻画的——因为内积是一次性的、无条件的。

CAN的思路是换个角度看问题:把"上下文"换成了"用户行为序列"。当前候选item是什么,决定了我们应该重点看用户历史行为里的哪些部分。这不就是特征协同吗?只不过协同的对象从"静态特征字段"变成了"动态行为序列"。

用大白话讲:你给一个用户推一款机械键盘,如果只看他"性别=男、年龄=25"这些静态特征,信息量有限;但如果他的历史点击序列里全是电竞椅、显示器、游戏鼠标,那"机械键盘"和"电竞外设"就形成了强协同信号。CAN要学的,正是这种"候选item和历史行为之间的协同权重"。

2.3 为什么叫"注意力"而不是"池化"

这里要解释一下为什么叫Attention Network。早期做法比如DIN,是把历史行为序列做sum pooling或mean pooling,得到一个固定的用户向量。但pooling的问题是:它对所有候选item都用同一个用户向量,等于说用户对所有商品"一视同仁"地表达兴趣——这显然不合理。

CAN的Attention机制是:每个候选item都带自己的query,去和历史行为序列的keys做相似度计算,得到的权重再对values加权求和。这样每个候选item都会拿到一个"定制化"的用户兴趣表达。同样是那个电竞用户,推机械键盘时attention会集中在"电竞椅、游戏鼠标"上;推办公键盘时attention会更多落在"办公桌、文件架"上。

协同作用由此变得"按需分配"——这正是CAN核心的设计动机。

3. CAN的网络结构拆解:从Embedding到注意力输出

3.1 整体架构一览

CAN的输入通常包含四类特征:

  • 用户静态特征(性别、年龄、城市等级等)
  • 用户行为序列特征(历史点击/加购/购买的item_id序列)
  • 候选item特征(item_id、类目、价格档位等)
  • 上下文特征(时段、设备、位置等)

整个网络分三层:

第一层是Embedding层,把稀疏ID映射成稠密向量,行为序列里的每个item也各自映射成一个向量。

第二层是Attention层,候选item的向量作为query,行为序列向量作为keys和values,计算加权求和后得到用户行为表征。

第三层是融合层,把Attention输出和静态特征embedding拼起来,送进MLP做最终打分。

注意:CAN和DIN在第二层有本质区别。DIN的attention计算用的是候选item embedding和历史item embedding的点积/拼接再经过DNN的方式,CAN则通常强调"query-key-value"的标准attention结构,实现上会更接近Transformer里的单头/多头注意力,只不过没有位置编码那一套。

3.2 Attention计算的细节

假设候选item的embedding是 $$q$$,历史行为序列有n个item,它们的embedding分别是 $$k_1, k_2, ..., k_n$$。

每个历史item的注意力权重是这样算的:

$$w_i = \frac{\exp(\text{score}(q, k_i))}{\sum_{j=1}^{n} \exp(\text{score}(q, k_j))}$$

score函数可以有几种选择:

  • 直接点积:$$score(q, k_i) = q \cdot k_i$$
  • 拼接后过DNN:$$score(q, k_i) = \text{MLP}([q; k_i])$$
  • 带缩放的点积:$$score(q, k_i) = \frac{q \cdot k_i}{\sqrt{d}}$$

我在实验里对比过,点积速度最快但表达能力有限;MLP打分效果最好,但参数量和计算量都上去了;缩放点积是性价比最高的选择,尤其当embedding维度超过64时建议加上缩放因子,否则softmax会过于尖锐,权重几乎变成one-hot分布,梯度也不好传。

得到权重后,行为序列的表征就是:

$$u = \sum_{i=1}^{n} w_i \cdot v_i$$

注意这里的 $$v_i$$ 可以等于 $$k_i$$,也可以单独学一套value embedding,后者自由度更高,效果通常会好一点,但参数量线性增加,是否采用要看你的训练数据量。

3.3 序列长度和mask的处理

行为序列长度不固定,实际工程里会做截断或padding到固定长度L。这里有个容易踩的坑:padding部分不参与attention计算,必须在softmax之前做mask,把padding位的score设为负无穷或非常大的负数,这样softmax之后权重接近0。

我第一次实现时忘了mask,结果模型学出来的attention权重大量落在padding位上,AUC掉了将近两个点。后来排查了很久才发现是mask写错了——padding位置用的是0初始化embedding,点积结果不是很大,但从分布上看它还是参与了归一化,把正常位置的权重整体拉低了。

正确做法是在算完score之后:

mask = (seq_ids != padding_id).float() scores = scores + (1 - mask) * -1e9 probs = torch.softmax(scores, dim=-1)

这个写法比scores.masked_fill更稳,不容易出现把mask值本身当成score传下去的问题。

4. CAN在模型家族中的位置:与DIN、DIEN、BST的关系

4.1 一张表看清区别

CAN不是凭空出现的,它和一系列以"用户行为序列建模"为核心的模型有血缘关系。我整理了一个对比表:

模型核心机制对行为序列的处理优势局限性
DIN候选item与行为序列的局部激活attention加权求和计算快,效果提升明显未建模行为时序
CAN标准QKV注意力结构多头/单头加权求和协同作用更细粒度计算复杂度略高
DIEN行为序列用GRU建模兴趣演化序列过GRU后取最后状态能捕捉时序演化训练慢,GRU易过拟合
BSTTransformer的self-attention行为序列内部交互行为之间也能交叉序列长时计算开销大
SIM通过搜索先筛相关子序列长序列分段支持百万级序列工程复杂度高

4.2 CAN和DIN的"出生顺序"问题

这里说个容易混淆的点:DIN最早是阿里在2018年提出的,核心是"局部激活单元"(Local Activation Unit),它可以看成一种轻量级的attention变体。CAN在结构上更接近标准的Transformer Attention,可以把DIN看作是CAN的一个特例——如果attention里的score函数直接用点积、value共享key,CAN就退化成DIN的简化形态。

但CAN提出时更强调的是"特征协同作用"这个宏观框架,它不只是改attention结构,还把"user behavior sequence"和"候选item"之间的协同关系做了体系化建模。所以在论文或技术分享里,CAN通常被归到"特征协同"方向,而不是单纯的"attention模型"。

4.3 实际选型建议

我在业务里做选型时,一般按这个规则走:

  • 数据量中等(日活百万级),序列长度50以内:优先DIN,做线上基线
  • 序列长度100以上,要求效果极致的:上CAN或BST
  • 对实时性要求高、推理延迟敏感的:慎用CAN,因为attention计算随序列长度线性增长,序列长时会吃掉不少CPU/GPU预算
  • 序列本身有时间衰减特性的:考虑DIEN或在CAN基础上加一个时间衰减因子到attention score里

经验:CAN不是万能的。如果业务场景里用户行为序列本身就很稀疏(比如新用户、低频用户),CAN的attention权重学不出来,效果反而不如直接用DNN拼接。此时更好的做法是先做行为补全,或者用MAMoE这类多任务模型,把不同活跃度的用户分开建模。

5. 手把手实现一个CAN模型(PyTorch版)

5.1 数据准备和特征工程

先定义我们要用的字段。为了演示,我构造一个简化版的电商场景:

  • 候选item:item_id(1~10000),category_id(1~100),price_tier(1~5)
  • 用户静态特征:user_id,gender,age_bucket
  • 行为序列:用户最近点击过的item_id序列,长度固定为50(不足补0,超过截断最近的50个)

特征处理要注意几点:

  • 所有ID类特征都需要构造各自的embedding表,不要共享,除非你有明确的特征关联性
  • 行为序列里的item_id和候选item的item_id必须用同一张embedding表,否则attention的query和key不在同一向量空间,相似度计算毫无意义
  • category_id如果也参与行为序列,需要单独一套序列embedding,可以和候选item的category共享,但别和item_id混用

5.2 完整模型代码

下面这个实现是单头的CAN,兼顾效果和效率:

import torch import torch.nn as nn import torch.nn.functional as F class CANModel(nn.Module): def __init__(self, item_num, cat_num, user_num, item_dim=64, cat_dim=16, seq_len=50, hidden_size=256, output_dim=1): super().__init__() # embedding层 self.item_emb = nn.Embedding(item_num, item_dim, padding_idx=0) self.cat_emb = nn.Embedding(cat_num, cat_dim, padding_idx=0) self.user_emb = nn.Embedding(user_num, item_dim) # 候选item特征映射:item + cat -> attention query self.query_proj = nn.Linear(item_dim + cat_dim, item_dim) # attention的score函数:这里用加性attention,比点积表达力更强 self.score_net = nn.Sequential( nn.Linear(item_dim * 2, hidden_size), nn.ReLU(), nn.Linear(hidden_size, 1) ) # 融合层 和输出层 self.fusion_net = nn.Sequential( nn.Linear(item_dim * 2 + cat_dim + item_dim + 4, hidden_size * 2), nn.ReLU(), nn.Linear(hidden_size * 2, hidden_size), nn.ReLU(), nn.Linear(hidden_size, output_dim) ) self.seq_len = seq_len def forward(self, user_id, item_id, item_cat, price_tier, seq_item_ids, seq_cat_ids): # 候选embedding item_vec = self.item_emb(item_id) # [B, D] cat_vec = self.cat_emb(item_cat) # [B, C] user_vec = self.user_emb(user_id) # [B, D] # query query = self.query_proj(torch.cat([item_vec, cat_vec], dim=-1)) # [B, D] # 行为序列embedding seq_item_vec = self.item_emb(seq_item_ids) # [B, L, D] seq_cat_vec = self.cat_emb(seq_cat_ids) # [B, L, C] seq_vec = torch.cat([seq_item_vec, seq_cat_vec], dim=-1) # [B, L, D+C] # 将seq压缩到D维 seq_proj = nn.Linear(cat_vec.shape[-1] + item_vec.shape[-1], item_vec.shape[-1]).to(seq_vec.device) seq_vec = seq_proj(seq_vec) # attention query_expanded = query.unsqueeze(1).expand(-1, self.seq_len, -1) # [B, L, D] score_input = torch.cat([query_expanded, seq_vec], dim=-1) # [B, L, 2D] scores = self.score_net(score_input).squeeze(-1) # [B, L] # mask padding mask = (seq_item_ids != 0).float() scores = scores + (1 - mask) * -1e9 attn_w = F.softmax(scores, dim=-1) # [B, L] # 加权求和 attn_output = (attn_w.unsqueeze(-1) * seq_vec).sum(dim=1) # [B, D] # 拼接特征 price_feat = F.one_hot(price_tier.squeeze(-1), num_classes=5).float() # [B, 5] fusion_input = torch.cat([user_vec, item_vec, attn_output, price_feat], dim=-1) output = self.fusion_net(fusion_input) return output

这段代码有几个设计细节说明一下:

  • 我用加性attention,而不是简单点积,因为实测在CTR场景里加性attention比点积效果好,但训练速度会慢10%~20%
  • price_tier这类低基数的类别特征用one-hot就行,没必要再套embedding,省参数
  • seq_proj把item和cat的拼接映射回D维,保证attention计算的向量维度统一
  • padding mask的判断条件是seq_item_ids != 0,如果你的padding_id不是0,记得改

5.3 损失函数和训练细节

CTR预估本质是二分类任务,用BCEWithLogitsLoss,注意模型最后一层不要再加sigmoid,让损失函数内部处理,数值更稳定。

优化器我用AdamW,初始学习率1e-3,配合余弦退火调度。batch_size一般取256~1024,看显存。

有一个训练上的坑:attention权重在训练初期很容易学成均匀分布,因为网络还没收敛时score_net的输出都在0附近,softmax自然接近均匀。如果发现前期attention权重完全没区分度,不用慌,多跑几个epoch,等loss降下来之后attention分布会慢慢分化。如果训练了很久还是均匀,那就要怀疑score_net是不是因为梯度消失没学到东西——在score_net里加个BatchNorm1d能缓解这个问题。

5.4 评估指标的注意事项

线上评估用AUC,但AUC对CTR预估的置信区间差异不敏感。我更习惯额外看两个指标:

  • GAUC:按用户分组计算AUC再加权平均,更能反映个性化排序质量
  • 点击率提升的相对百分比:这个最贴近业务收益

CAN上线后,我观察到的典型提升在GAUC上大约比DeepFM高0.5%~1.5%,具体幅度取决于行为序列的丰富度。如果序列长度平均不到10,提升可能只有0.1%,这时候就要考虑是不是数据本身撑不起attention建模。

6. 工程落地的三个关键抉择

6.1 多兴趣建模:要不要换多头attention

CAN默认是单头,即一个query对应整个行为序列。但如果用户行为跨度大、兴趣多——比如既看母婴又看数码——单头attention会把这两类兴趣揉在一起,互相干扰。

解决办法是用多头attention:每个头关注一个兴趣子空间,最后把多个头的输出拼接或加权融合。这个做法在Transformer里已经很成熟,CAN同样适用。

我的实践是:兴趣比较垂直的品类(如母婴、图书)用单头就够了;垂直度低、跨类目严重的用户,多头收益明显。但多头也带来一个副作用——序列里每个item都要和多头分别算相似度,计算量翻倍。如果线上推理用GPU,多2个头还能接受;用CPU的话,建议最多2个头。

6.2 序列长度与截断策略

序列长度L的选择取决于算力和效果之间的权衡。

L太短(比如10),行为信息不够,attention学不充分;L太长(比如200),GPU显存和耗时都会明显上升。我建议从50起步,观察attention权重的分布:如果权重集中在最近10~20个item上,说明L=50足够了;如果权重分布均匀且靠前的item也有较大权重,可以试试把L提到100。

还有一个工程小技巧:离线分析用户点击序列的"有效长度分布",一般CTR场景下用户平均有效行为不会超过30,所以L=50基本能覆盖90%以上的用户。直接用覆盖率达到95%的长度作为L,是最省钱的方案。

6.3 线上推理性能优化

CAN的推理瓶颈主要在attention计算,因为每个候选item都要和整个序列算一遍score。线上如果一次请求要打分几百上千个item,开销很大。

我常用的优化手段有这三个:

  1. 序列embedding预计算:行为序列的embedding可以不实时算,改用一个低维度的"用户行为摘要向量"做注意力的近似。这个向量可以每天离线算好存Redis,线上直接用。
  2. 剪枝:score排序后只保留top-k个高权重item,然后把剩下的权重归一化。实际测试top-k=16时,AUC损失只有0.05%以内,但耗时能省50%。
  3. 并行化:用户的行为序列向量是固定的,对所有候选item共享同一份key/value,只是query不同。所以可以把所有候选item的attention计算矩阵化,一次性算完,用batch matmul代替循环。

注意:剪枝方案必须在离线先验证top-k = 16、32、64对效果的影响,不能一上来就激进剪枝。不同业务的数据分布不一样,我见过一个模型top-k从32降到16的时候AUC直接掉了0.3%,细看发现是长尾item对attention权重的贡献被砍掉了。

7. 从CAN到"特征协同"体系的延伸思考

7.1 特征协同不止于行为序列

CAN把协同作用建模在"候选item和用户行为序列"之间,但特征协同的边界其实更广。比如候选item和当前上下文(时间段、设备、位置)之间也存在协同:同一个"烧烤架",晚上8点推和早上8点推,效果肯定不同;同一个"游戏手柄",在"游戏手机详情页"里推和"办公用品列表页"里推,转化率也差很多。

我没少在项目里见到一种思维误区:觉得用了CAN就等于解决了特征协同。其实CAN只是把最动态、信息量最大的一部分协同建模了,静态特征之间的协同还是得靠FM、DNN这些基础模块来兜底。最稳的做法是把CAN和DeepFM揉在一起:底层继续保留二阶交叉,上层用CAN做行为协同。

7.2 长序列场景下的演进:SIM

如果用户行为序列特别长——比如三个月、半年跨度——直接算attention不现实。这时候业界有SIM这种粗筛+精排的两阶段方案:先用候选item的类目或关键词把长序列里相关的item粗筛出来,再对这些子序列做attention。本质上是"先用搜索降低注意力计算范围,再在子集上精算协同"。

SIM和CAN不是互斥关系,SIM的下游模块就可以用CAN或DIN。两者配合,既能覆盖长序列的信息量,又能保留精确的协同建模能力。

7.3 冷启动用户的协同兜底

CAN依赖用户历史行为,新用户自然吃亏。我处理冷启动时的做法是:如果用户行为长度小于阈值(比如5),直接走规则或简单模型兜底,不让CAN参与打分;如果行为长度在5~10之间,可以加一个全局统计量(比如该品类下所有用户最常点击的top item序列)作为"伪行为序列",让CAN有东西可attend。

这个伪序列的做法离线验证下来效果还不错,尤其在新用户占比超过20%的渠道上,冷启AUC能提升0.3%左右。要注意的是,伪序列里的item必须是从真实统计中采样的,不能用随机item,否则会把attention权重整体打散。

8. 关于热词干扰的说明:CAN搜索,请务必分清上下文

最后单独说说开篇那些热门搜索词。CAN这个缩写撞车非常严重,我在搜索时也经常被引到can总线、can协议栈那些内容上,白白浪费时间。

可能的场景包括:

  • can协议报文解析、can协议栈、can总线仲裁:这是汽车电子/嵌入式通信领域,解决的是车载ECU之间的通信问题
  • STM32 CAN通信突然连不上:这是单片机开发中的硬件调试问题
  • error from provider、can't initialize iptables table、404 not found这些:这是开发环境、网络服务配置问题
  • 我们这里讨论的CAN:点击率预测中的特征协同作用模型,属于搜索推荐广告方向

所以如果你是为了CTR预估来搜CAN,建议在关键词里追加上下文词:CAN点击率、CAN特征协同、CAN DIN、CAN attention CTR,这样能过滤掉大部分干扰。如果你误入了一篇讲can总线的文章,也不用急着划走——那篇文章里的"仲裁""握手""优先级"这些概念,换个角度看你反而能理解attention权重的"竞争分配"逻辑,两者在思想层面其实有相通之处。

我自己就经历过一次:排查一个CAN模型线上效果波动,调试了一天没头绪,后来发现是另一套基于can总线通信的系统出了故障,影响了日志链路,导致行为序列数据缺失。两个领域的CAN在同一个项目里同时出现,还挺戏剧性。

这也算是个实用提醒:技术方案命名重复是常事,关键是建模前先确认好"我们说的CAN到底是哪一个",否则再精细的特征协同,也架不住理解错方向带来的返工。

...

如果看完这篇,你正准备把CAN应用到自己的CTR场景,我最后的建议是:先别急着改模型结构,拿一周时间把行为序列的覆盖度、有效长度、兴趣分布这几个基础指标摸底一遍。CAN的上限很高,但它的地基是"用户历史行为足够有信息量"。地基不牢,再花哨的注意力机制都是空中楼阁。

返回列表