CAN:用查表方式建模CTR交叉特征,兼顾效果与serving开销
最近在梳理点击率预测里的交叉特征建模时,我把CAN这篇工作又翻了一遍,顺手在一侧推荐场景里做了轮对比实验。趁理解还热乎,把它的核心思想、工程落地细节和踩坑经验整理出来,给同样在做CTR预估、广告排序或者电商推荐的同学一个参考。
CAN全称是Co-Action Network,中文直译是协同作用网络,名字不重要,重要的是它解决的问题。点击率预测里有一个很经典的现象:用户会不会点击某个商品,很大程度上取决于这个商品和用户历史行为里的商品是否“搭”。这个“搭”的语义,业内叫特征协同作用。如何低成本、又不过拟合地去建模这种协同作用,就是CAN最核心的命题。这篇工作最打动我的一个点,是它把“用户历史行为和当前候选商品之间的交叉信号”从动态计算变成了一张可查询的记忆表,让训练和在线serving解耦,既控制住了模型大小,又把推理开销压到了接近查字典的级别。
1. 先说结论:CAN到底在解决什么问题
1.1 点击率预测里最难啃的那块骨头
现在常规的CTR模型,基本都是一个“塔式结构”:底层喂用户画像、商品属性、上下文这些特征,中间经过embedding层、若干交叉层和全连接层,最后输出一个点击概率。这类模型真正拉开差距的地方,往往不在底层特征本身,而在“特征之间怎么交互”。
举个具体的例子。用户张三最近点过五件商品,其中三件是和户外露营相关的,两件是厨具。现在来了一个候选商品,是防潮垫。你猜张三点不点?大概率会点,因为他历史行为里有“露营”这个强信号,而且防潮垫和帐篷、睡袋这类商品天然强相关。这种“用户行为”和“目标商品”之间的关联,就是最典型的特征协同作用,英文里叫feature co-action。
难点在于这种协同作用不是靠简单拼接就能学出来的。如果你把用户行为商品ID向量和目标商品ID向量拼在一起丢给MLP,模型确实理论上能学,但实践中会发现两个问题:一是ID组合空间巨大,行为序列里的商品和目标商品的有效组合量是亿级别的,MLP很难在有限样本里把每种组合的模式都记住;二是即使记住了,也都是死记硬背,碰到没见过的新组合就抓瞎。所以大家才会专门设计结构去“显式”建模协同作用,而不是等着全连接层自己隐式拟合。
CAN要解决的,本质上是这个问题:在推荐/广告场景里,“用户历史行为”与“候选物品”之间的交叉特征,到底应该用什么结构去建模,才能做到既能捕捉有效协同信号,又不让参数量爆炸、不出现在线推理扛不住的情况。
1.2 这篇工作适合谁
我认真读完CAN之后判断,它的适用范围其实比论文标题看起来更宽。如果你是下面这三类人,我建议你重点看一下。
第一类是正在用DIN、DIEN这套动态注意力机制做行为建模的人。这类方案的推荐效果确实好,但线上serving开销也不小,每次请求都要把用户全部历史行为拉出来,跟目标商品做一轮注意力计算。如果你已经被线上算力压得头疼,CAN这种“训练时动态建模、serving时静态查表”的思路会是个不错的替代或补充。
第二类是在中小规模场景里试过独立交叉网络、最后被过拟合折磨的人。比如有的团队直接把用户行为商品和目标商品做外积,再拿外积特征喂给深层网络,样本一稀疏,AUC很快就掉下来。CAN用一张共享的记忆张量把“所有pair组合”压缩到固定规模的参数空间里,本质上是让模型在交叉特征上学了一个低秩近似,天然带正则效果,对这个痛点非常对症。
第三类是做特征工程做得快吐了的人。协同作用类特征,人工构造要处理组合爆炸,踩完一轮坑还要担心线下和线上特征不一致。CAN的特征入口只需要行为ID和目标ID,模型内部自己算交叉,你几乎不需要额外准备离线特征表,上线成本低了一个档次。
2. 从DIN到CAN:建模“用户行为×目标商品”的演进
2.1 attention方案的甜与痛
DIN出现之前,主流做法是直接把用户行为序列里的所有商品embedding求和或者求平均,然后再和目标商品embedding拼接。这种做法的毛病在于,它把行为序列里每个商品的权重看成一样,但真实场景里显然不是这样。用户点过十件商品,其中可能只有两件跟当前候选商品相关,这两件才应该主导预测结果。
DIN的解法是引入一个local activation unit,说白了就是做个注意力加权:对目标商品embedding和行为序列里每个商品embedding算一个相关性分数,再按分数加权求和。这样“行为序列中被目标商品激活的部分”就能被强调出来,效果也确实立竿见影。
但这个方案的痛点也很直接。线上推理时,目标商品是不确定的,每来一个请求,用户行为序列里每个商品都得和目标商品重新算一遍相似度,这个计算量和行为序列长度是正相关的。序列短还好,序列一长,比如电商场景里用户半年点过的商品可能有几百上千个,那每次预估都是一场不小的矩阵运算。在工业级流量下,这个成本是很敏感的。
更要命的是,attention机制虽然灵活,但它的训练信号完全来自“当前这个目标商品与历史行为关系”的监督。如果某个用户的行为序列本身很稀疏,比如新用户只有一两次浏览记录,那attention权重学得就不稳定,反而容易在某几个样本上过拟合。DIN的很多变体不断加结构、加辅助loss,本质上都是在补这个洞。
2.2 朴素交叉网络为什么在工业场景翻车
与attention这类动态建模路径不同,还一派做法是“静态交叉”路线。典型形态是:单独拉出一个交叉模块,输入用户行为商品ID和目标商品ID,输出一个交叉向量,再塞进主模型。这个模块通常是多层的MLP,也可以直接做外积、双线性插值,公式上大致长这样:
f(u, v) = MLP(concat(embedding(u), embedding(v))) 或者 f(u, v) = embedding(u) ⊗ embedding(v)
思路很直白,但一落地就出事。拿电商场景来说,行为侧商品ID如果是十万级,目标侧候选商品ID也是十万级,pair组合就是百亿级。你让一个MLP去拟合百亿组合空间,每个组合平均分到的训练样本可能连个位数都不到,模型很容易把注意力放在“记住了谁和谁一起出现在训练集里”,而不是真正泛化地学到“什么样的商品搭配合理”。
另一个工程问题是这类模块通常只对“出现过的pair”起作用。线上出现一个新的pair组合时,这个模块给出的输出基本等于在插值,效果并不可控。很多团队到最后不得不给模块加各种约束、正则、dropout,换来多少提升不好说,但训练稳定性先下降了不少。
CAN论文里提到,这类静态交叉模块在小规模数据上明显过拟合,简单说就是“训练集AUC很好看,测试集和线上全都拉胯”。我自己的经验也印证了这点:独立交叉网络的有效参数过多,而CTR场景里交叉特征又是出了名的稀疏,两者放一起就是灾难。
2.3 CAN核心思路:压缩-查表
CAN在这两条路线之间找到了一条折中的路:把“协同作用向量”做成一个可以学习的记忆张量,用行为商品ID和目标商品ID的pair作为地址去查表。查出来的向量,就是这个pair的协同作用表示。
用人话说,就是准备一本大字典,字典的词条是“历史商品+目标商品”的组合,每个词条的内容是一个K维向量。训练的时候,模型看到一条行为记录和一个目标商品,就去查这本字典里对应的那个词条,把查出来的向量喂给后续网络。字典内容随训练不断更新。部署之后,字典内容固定下来,线上请求过来直接查对应行,拿到向量就继续走网络,完全不需要再做历史行为与目标的实时交互计算。
“压缩-查表”这个思路的精妙之处在于,它用地址共享解决了组合爆炸。真正常见的历史商品和目标商品组合,远远小于笛卡尔积的全集。与其为每个组合都准备独立参数,不如让它们在同一个有限大小的记忆张量里按地址映射,高低频pair自动共享部分表达。这就把“百亿组合”压缩成了“百万槽位”,参数量可控,泛化能力反而更强,因为每个槽位都被更多样本训练得更充分。
3. 核心设计拆解:Co-Action Memory是怎么工作的
3.1 整体架构与数据流
CAN在模型层面最大的改动,是在原CTR网络基础上增加了一个“记忆模块”,这个模块就叫Co-Action Memory。它不是一个独立跑完再拼接的离线公式,而是端到端训练的一部分。
先说输入侧。常规CTR模型会有三路核心输入:用户行为序列(比如点击过的商品ID序列)、目标商品特征(候选商品的ID和属性)、以及用户/上下文特征。CAN的交叉建模只关心前两路的交互。对行为序列里的每一个商品ID u,以及目标商品ID v,模型生成一个pair,然后对这个pair做记忆查询,得到协同作用向量。
这个协同作用向量的去向,论文里大致给了两种用法。一种是把查询到的向量重新注入到行为序列侧,序列里的每个行为商品向量“增广”了一层来自交叉记忆的信息,后续再用attention也好、pooling也好,看到的信号更充沛。另一种是把查询到的向量注入到目标侧,目标商品embedding会重新融合行为侧带来的反馈,再进入全连接部分。实际工程里也可以两路都放,关键是看你的主模型结构适合在哪一步吸收这个向量。渠道基本就一句话:协同作用向量是锦上添花的附加特征,放在哪一层,取决于它和已有模块的衔接顺不顺。
整个模型还是走标准的CTR训练范式。输入一批样本,前向算交叉记忆查询、拼接、送入主网络、输出点击概率,然后和真实点击标签算logloss,反向传播更新所有embedding和记忆张量。不需要额外设计复杂的辅助目标,也不要求你准备额外的联合特征表,对已经有一套CTR pipeline的团队来说,改造点非常集中。
3.2 记忆张量的查询过程
记忆张量M可以看成是一个形状为L×D的矩阵,L是槽位数量,D是每个协同作用向量的维度。查询的核心动作,是把行为商品ID u和目标商品ID v这一对,通过某个映射函数变成一个整数索引,然后用这个索引去M里取对应的那一行向量。
最朴素的映射方式,是对u和v分别做一次哈希,然后把两个哈希值的组合映射到槽位空间。比如:
c(u, v) = M[ (hash(u) * P + hash(v)) mod L ]
这个写法够简单,但哈希冲突会影响效果。工程上更稳的做法是多重哈希,查询K次,取K行的平均或拼接。公式上像这样:
c(u, v) = mean( M[ h_1(u,v) mod L ], M[ h_2(u,v) mod L ], ..., M[ h_K(u,v) mod L ] )
用K个相互独立的哈希函数,同一个pair可能映射到K个不同槽位,即使单个槽位发生冲突,多槽位平均之后,冲突带来的噪声会被摊薄。理论上的hash trick在NLP的n-gram模型里已经用了很多年,搬到交叉记忆上来是顺理成章的事,只是这里每个槽位存的不再是标量权重,而是一个向量。
也可以用更“深度”的方式做查询,不直接取整数值索引,而是让两个embedding先做可学习的映射,输出一个在L槽位上的软分布,再把记忆张量L行做加权求和。这样整个查询过程对u、v的embedding是可微的,模型可以更平滑地学习让哪个槽位“负责”哪种pair。代价是查询计算量变大,对线上性能不友好,所以工业落地大多还是用哈希多槽位方案。
说个我自己的理解:CAN记忆张量的查询过程,本质上是在学一个受限的三角关系。维度D是协同作用向量长度,槽位L是交叉模式的容量。槽位越多,模型能记住的不同交叉模式越多,但也越容易过拟合;维度越大,每个模式内的表达越丰富,但训练所需样本也越多。这两个数不是越大越好,和你的样本量、行为集合大小强相关。
3.3 为什么训练和serving能解耦
CAN最讨喜的地方,是训练时那个“记忆张量”可以随便更新迭代,但模型一旦定稿,线上serving时它就是一个固定数组。
拿它和attention方法做个直观对比。DIN在serving时,每来一个请求,都要把候选商品v的embedding和用户行为序列里所有商品u_j的embedding算一遍点积、softmax、加权求和,这整套计算必须在请求实时路径里完成。而CAN在serving时,行为商品ID和目标商品ID之间的协同作用向量已经被“固化”在记忆张量里了,线上只需要查表,查出来的向量直接拼到特征里,不用再算任何pair级别的交互函数。
这意味着,CAN的serving耗时基本不随行为序列长度增长,行为序列就算从几十涨到几百,多出来的也只是多查几次表,而attention多出来的是一整轮矩阵运算。这一点对超大规模推荐场景是实打实的收益。
我专门在内部做了一次粗略benchmark。同样一个基础模型,加DIN的serving p99耗时比加CAN高出大约30%到60%,序列越长差距越明显。当然这个数字跟实现细节有很大关系,但量级上的差异是稳定的。如果你正处在“为了提点而加attention模块,结果线上机器成本翻倍”的处境里,CAN这个方向是值得认真考虑的。
4. 从论文到落地:实操步骤与调参心得
4.1 最小化接入路径
如果你的团队已经有一套基础的CTR模型,接入CAN不需要把模型推倒重来。我整理了一条最小改动路径,照着做基本能跑通:
- 第一步,确认基础特征入口。你至少需要用户行为侧的商品ID序列,以及目标侧的商品ID。如果两侧ID空间不是同一个,可以各自做频次截断映射到连续整数空间,这一步是为了控制哈希空间的上界。
- 第二步,初始化记忆张量M。形状为L×D,随机初始化,也可以用预训练的item embedding做一个低维初始化,加速收敛。
- 第三步,实现多重哈希索引函数。选择K个独立哈希函数,对(u, v) pair生成K个槽位索引,取M对应K行求平均或拼接。
- 第四步,把协同作用向量接入主模型。最省事的做法是拼到行为序列每个行为商品的embedding后面,或者拼到目标商品embedding后面,然后正常走你原有的pooling和全连接层。
- 第五步,和主模型一起端到端训练。loss用你原来的CTR loss就可以,记忆张量作为普通参数参与BP更新。
整个改造成本大约就是:一个可学习的Embedding矩阵、一个哈希函数模块、几行拼接代码。比起重训一个复杂attention网络,动的东西少得多,而且非常容易回滚——把协同作用向量的入口拔掉,模型就直接回到原结构。
需要提醒的是,别一上来就追求论文里的完整CAN+形态,先把最简版本上线到离线实验,确认AUC有正收益之后再增量做复杂融合。工程上我一直信奉“先证明结构有价值,再证明结构能扩展”。
4.2 关键参数选择与我的调参心得
我在实验里趟过一遍后发现,CAN相对敏感的四个参数是槽位数量L、向量维度D、哈希函数个数K、以及协同作用向量的接入位置。下面对照表整理了它们的影响和我的经验值参考。
| 参数 | 影响方向 | 我常用的起点 | 调整原则 |
|---|---|---|---|
| 槽位数量L | 记忆容量,决定能区分多少交叉模式 | 行为集合大小的平方根×16左右 | 样本量大可增大,样本稀缺要减小,观察冲突率 |
| 向量维度D | 每个交叉模式的表达能力 | 16~32 | 和主模型其他embedding维度对齐,过大会过拟合 |
| 哈希函数个数K | 查表稳定性和冲突平抑能力 | 2~4 | K越大查询越稳定,但计算量线性上涨 |
| 接入位置 | 影响信息如何流动 | 行为侧或目标侧任选先试 | 以离线AUC为准,两侧都加不一定更好 |
槽位数量的选择我自己有个习惯:先用频次截断后的行为商品数做估算。假设行为侧商品截断后剩约50万,平方根是700多,乘16就得到1万2左右的槽位初始值。这个量级不会大得让模型记不住,也不会小得让冲突爆炸。之后在离线实验里做一轮L的网格搜索,看AUC随L的变化曲线,找到平台期就行。
维度D不要想当然调大。D的本质是每个交叉槽位里“存多细的信息”,D越大,每个槽位需要越多样本支撑。在样本量不变的情况下,D翻倍可以让离线AUC略涨,但测试集AUC往往掉得更快,这就是典型的过拟合信号。和DIN里的hidden size不同,CAN的D更偏向“够用就好”。
哈希函数K建议至少取2。K=1时,如果某个槽位成了热点,所有映射到该槽位的pair都被同一个向量表达,质量直接拖垮模型。K=2或K=4之后,热点效应大幅缓解,继续增大收益递减,但serving耗时和训练显存会跟着涨。
接入位置没有绝对标准。行为侧的好处是后续attention/pooling能带着协同信号继续聚合,适合序列信息丰富的场景;目标侧的优点是直接增强目标表征,适合目标侧特征较弱、主要靠行为特征打分的场景。我建议你两个位置都跑一轮离线,选AUC高的那路,别两边同时上都塞,容易让梯度信号混乱。
4.3 与attention/DIN共存的工程方案
很多同学会问一个直击灵魂的问题:我线上已经有DIN了,还有必要上CAN吗?
我的答案是:可以共存,而且共存时两者各司其职,效果通常比单用更好。AUC不一定每次都能大涨,但serving性能和鲁棒性上的收益是确定的。
理解共存之前,要先分清这两类模块的定位。DIN这类attention机制,捕捉的是“这一次”目标商品与行为序列之间的动态相关性。同样是用户买过帐篷和锅,当候选商品是防潮垫时,帐篷被激活得多;当候选商品是炒锅时,锅被激活得多。这种“按需要变权重”的能力,是attention天生的强项。CAN捕捉的是“这一类”行为商品和目标商品之间的静态协同倾向,比如“露营类商品”和“防潮垫”之间存在稳定的正协同,“厨具类”和“防潮垫”的协同就弱一些。一个偏动态、一个偏静态,二者在信息层面是互补的。
共存时的典型结构是:行为序列里的每个行为商品embedding,先和通过CAN查表得到的协同作用向量拼接或相加,得到增广后的行为embedding,再送入DIN的attention单元做动态加权。这样DIN在算权重的时候,看到的每个行为商品不再只有孤零零的ID信息,还带了一层“和目标商品的默契度”先验,注意力分配会更准确。
反过来用也成立:CAN的协同作用向量可以先和目标商品embedding融合,作为attention里的query,让目标侧在计算相关性之前就吸收了行为侧的反馈信息。
工程上要注意控制两部分的特征比例。我遇到过一上来就把协同作用向量维度设成和原embedding一样长,结果CAN信号直接压倒行为ID信息,DIN变成了摆设。稳妥做法是协同作用向量先经一层线性压缩到原embedding维度的一半,再和原embedding做concat或add,让模型自己决定权重。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
实验阶段大家会碰到的问题高度相似,我把自己的排查经验整理成表格,按症状定位原因,再给解法。
| 症状 | 可能原因 | 解法 | 补充说明 |
|---|---|---|---|
| 离线AUC没有变化 | 协同作用向量被后续网络学成无效特征 | 检查向量接入位置,尝试注入目标侧 | 基础模型太强时,边际收益会变小,这是正常现象 |
| 训练AUC高、测试AUC掉 | 槽位太多或向量维度太大导致过拟合 | 缩小L或D,增加哈希数K | 尤其要注意样本量较小的场景,L必须相应缩水 |
| 行为序列一长,训练显存溢出 | 每个行为item都做一次CAN查询,累计量太大 | 对序列做截断或负采样,不要在全集上做 | 线上serving不受影响,因为查表是轻量的 |
| 冷启动商品完全学不到信号 | 商品ID太稀疏,pair几乎没有样本 | 用类目ID或粗粒度ID替代部分细粒度ID做pair查询 | 相当于做了特征泛化,牺牲精度换覆盖度 |
| 与DIN共存后AUC反而降 | 协同向量淹没了行为ID信息 | 降低协同向量维度,或改成add而非concat | 让attention有空间继续发挥作用 |
补充说一点排查逻辑:AUC没变不一定代表CAN没用,可能是增益被其他强特征吸收了。我建议在看总体AUC的同时,单独观察“新pair组合”的预测稳定性,或者按行为长度分桶看指标。CAN在行为稀疏桶上的增益往往更明显,因为它的参数共享机制对这些样本更友好。
5.2 我踩过的三个坑
第一个坑,是直接用原始ID空间做pair索引。我第一版实验直接用线上几千万商品ID做哈希查询,槽位无论怎么配,离线一测都有明显的冲突噪声。后来发现正确的做法是做频次截断,只保留高频商品ID进入记忆查询,低频长尾ID统一映射到一个共享“未知”槽位。这一步做完,实验指标直接上了一个台阶。
第二个坑,是把协同作用向量维度设得和主线一样大。当时想的是信息多存一点总没坏处,结果离线训练AUC和验证AUC之间的gap迅速拉大。后来压到16维,肉眼可见收敛变稳。原因是每个槽位向量的样本支撑有限,维度越高,需要越多样本去填满它,CTR训练样本虽然多,但落到具体某一个pair组合上还是稀疏。
第三个坑,是让CAN和DIN在行为侧同时叠加但没做特征分离。第一版代码里,我把协同作用向量直接拼到行为embedding上,然后一起送进DIN的attention计算。结果attention权重开始被协同向量主导,行为ID自身的信号反而弱了,整体效果不如只用CAN。后来让CAN向量走独立的gate层,再和原行为embedding融合,问题才解决。这也印证了一个经验:新模块加入时,先验证它独立有用,再验证它组合有效,不要默认“越多越强”。
最后说一点个人体会。CAN这个思路之所以让我觉得有价值,就是它找到了“动态交叉建模效果好但贵”和“静态交叉建模便宜但容易过拟合”这两极之间的平衡点。它不是要淘汰attention,而是重新分配了哪些信号需要动态算、哪些信号可以静态存。这套取舍哲学,其实比CAN本身更值得借鉴。毕竟在真实业务里,模型设计永远是效果、成本、稳定性三者的博弈,能用查表解决的事,就没必要每次都重新算一遍。