1. 从标题拆解:ETA 到底想解决什么问题
第一次看到“ETA:End-to-end Target Attention”这个标题,很多人会下意识想到“预计到达时间”。但在推荐系统和计算广告的语境里,ETA 是End-to-end Target Attention的缩写,核心落点是“端到端”和“目标注意力”这两个词。它要处理的是一个非常具体、也非常棘手的工程问题:当候选物料规模从万级膨胀到百万级甚至千万级时,如何在不牺牲精度的前提下,把真正与当前用户相关的目标物料快速筛出来。
传统做法通常分两段走。第一段用轻量模型做粗排,第二段用复杂模型做精排。粗排阶段为了速度,往往会把用户侧特征和物料侧特征做非常粗糙的交互,甚至只做内积。问题在于,用户的历史行为序列里藏着大量细粒度偏好,比如“最近三天反复看某类内容”“对某个品牌有持续点击”,这些信号在粗排阶段几乎被压平了。ETA 的思路是:既然注意力机制在精排里已经被验证能有效捕捉用户兴趣,那能不能把它直接搬到召回或粗排阶段,并且做到端到端可训练?
这个问题的难点不在于“能不能加注意力”,而在于“加了之后还能不能跑得动”。标准的多头注意力复杂度是序列长度的平方级,用户行为序列动辄几百上千,候选物料又是百万级,直接算注意力矩阵,显存和延迟都会爆炸。所以 ETA 真正要回答的是:如何设计一种目标感知的注意力结构,让计算量只与候选目标数量线性相关,同时保留端到端梯度回传的能力。这也是为什么热词里同时出现了 CTR、SimHash 和 LSH——它们分别对应效果指标、候选压缩和近似检索三个关键环节。
适合读这篇内容的人,大概有三类。第一类是做推荐系统召回或粗排的算法工程师,正在被“精度不够”和“延迟超标”两头夹击;第二类是对注意力机制有基础理解、想看看它怎么在工业级规模下落地的人;第三类是做 CTR 预估相关项目,需要理解目标注意力与用户序列建模之间关系的人。下面我会按实际项目落地的顺序,把 ETA 的设计思路、核心细节、实操步骤和踩坑经验完整拆一遍。
2. 整体设计思路:为什么是端到端加目标注意力
2.1 从两阶段到端到端的动机
推荐系统经典架构是“召回—粗排—精排—重排”四级漏斗。这个结构的好处是每一级都可以独立优化,坏处是误差会逐级累积。召回阶段漏掉的物料,后面再强的精排也救不回来。更麻烦的是,粗排和精排的目标函数往往不一致:粗排追求的是“把可能相关的都捞回来”,精排追求的是“把最相关的排前面”。这种目标错位会导致粗排阶段学到的表示,对精排来说并不是最优的输入。
ETA 选择端到端,本质上是想让召回或粗排阶段的表示直接接受最终 CTR 目标的监督。换句话说,用户序列的注意力权重不再由某个中间代理任务决定,而是由“这个物料最终会不会被点击”来反向调整。这样做的好处很明显:梯度信号从最终目标一路传回注意力计算模块,用户兴趣的粒度可以细到“针对当前候选物料,我该关注历史行为里的哪几条”。但代价也很直接:训练时每个样本都要对候选集合做注意力,计算图会变得很大,对工程实现要求极高。
我个人的判断是,端到端不是银弹。它适合候选规模在十万到百万级、且用户行为序列长度可控的场景。如果候选规模上亿,或者序列长度超过两千,端到端训练的成本会迅速超过收益。所以 ETA 在实际落地时,通常会配合 SimHash 或 LSH 做候选预压缩,把真正参与注意力计算的候选数量控制在一个合理区间。
2.2 目标注意力的核心设计
“目标注意力”这个词的重点在“目标”上。普通注意力是用户序列内部的自注意力,或者用户序列对候选物料的交叉注意力。ETA 的做法更激进:它把候选物料当作 Query,把用户历史行为序列当作 Key 和 Value,计算每个候选物料对历史行为的注意力分布。这样一来,同一个用户面对不同候选物料时,注意力权重是完全不同的。
举个例子。用户历史里既有“篮球鞋”又有“咖啡机”。当候选物料是“运动护膝”时,注意力应该集中在篮球鞋相关的行为上;当候选物料是“咖啡豆”时,注意力应该转移到咖啡机相关的行为上。普通粗排模型可能给用户一个固定的兴趣向量,无法区分这两种情况。ETA 通过目标注意力,让用户表示随候选动态变化,精度提升的来源就在这里。
但这里有一个工程上的关键取舍:如果对每个候选都单独算一遍注意力,计算量就是候选数乘以序列长度。百万候选乘以五百序列,就是五亿次注意力计算,完全不现实。所以 ETA 必须做两件事:第一,用 SimHash 或 LSH 把候选聚类,同一簇内共享部分注意力计算;第二,把注意力计算拆成可并行的矩阵运算,利用 GPU 批量处理。这两点后面会展开讲。
2.3 与 SimHash、LSH 的配合逻辑
热词里出现 SimHash 和 LSH,不是偶然。SimHash 是一种局部敏感哈希,能把高维向量映射成固定长度的指纹,相似向量的指纹汉明距离小。LSH 则是更一般的近似最近邻检索框架。在 ETA 里,它们主要承担两个角色。
第一个角色是候选去重和聚类。百万级候选里,很多物料在 embedding 空间里非常接近。如果对每个候选都算目标注意力,大量计算是冗余的。用 SimHash 把候选分成若干桶,同一桶内只对桶中心算一次注意力,桶内其他候选复用这个注意力分布,可以大幅降低计算量。实测下来,在候选相似度较高的场景里,这种做法能把注意力计算量压到原来的十分之一,而 CTR 损失通常不到千分之三。
第二个角色是训练时的负采样。端到端训练需要正负样本对比,如果负样本随机采,很多负样本和正样本差异太大,梯度信号很弱。用 LSH 可以采到“难负样本”,也就是和正样本在 embedding 空间接近但实际未点击的物料。这样训练出来的注意力模块判别力更强。这里要注意,LSH 的哈希函数数量和桶宽需要根据实际数据分布调,桶太宽难负样本不“难”,桶太窄又容易采到噪声。
3. 核心细节解析与实操要点
3.1 用户行为序列的预处理
ETA 的效果高度依赖用户行为序列的质量。原始日志里的行为序列通常包含大量噪声:误点击、爬虫流量、时间跨度极长的陈旧行为。如果直接拿原始序列做注意力,模型会学到很多虚假关联。我在实际项目里的做法是分三步清洗。
第一步,按时间窗口截断。通常保留最近 30 天或最近 200 条行为,取先到者为准。时间窗口的选择要看业务周期,电商类 30 天比较合理,内容类 7 天可能就够了。截断之后,序列长度分布会集中很多,便于后续批量计算。
第二步,去重和合并。同一物料在短时间内的重复点击,往往只算一次有效兴趣。可以用物料 ID 加时间桶做去重,时间桶粒度取小时或天。合并之后,序列里保留的是“用户对某物料在某个时间段内有行为”这样的聚合信号。
第三步,行为类型编码。点击、收藏、加购、分享这些行为的强度不同,不能一视同仁。常见做法是给每种行为一个可学习的 embedding,然后和物料 embedding 相加或拼接。这里有个细节:行为类型的 embedding 维度不要太大,8 到 16 维足够,太大会过拟合。
注意:序列预处理一定要在训练和线上服务之间保持一致。我见过太多项目,离线训练用了一套清洗逻辑,线上服务用了另一套,结果离线 AUC 涨了,线上 CTR 反而跌了。建议把清洗逻辑封装成统一模块,离线和线上共用同一份代码。
3.2 目标注意力的计算拆解
目标注意力的标准形式可以写成:对候选物料 ( t ),用户序列 ( S = {s_1, s_2, ..., s_n} ),计算注意力权重 ( \alpha_i = \text{softmax}(f(t, s_i)) ),然后加权求和得到用户表示 ( u_t = \sum \alpha_i s_i )。这里的 ( f ) 是打分函数,常见的是内积或小型 MLP。
工程实现上,关键是把 ( f(t, s_i) ) 的计算批量化。假设候选集合大小是 ( B ),序列长度是 ( n ),embedding 维度是 ( d )。如果逐个候选算,复杂度是 ( O(Bnd) )。但把候选矩阵 ( T \in \mathbb{R}^{B \times d} ) 和序列矩阵 ( S \in \mathbb{R}^{n \times d} ) 做矩阵乘法,得到 ( B \times n ) 的打分矩阵,复杂度还是 ( O(Bnd) ),但 GPU 并行效率高很多。真正的瓶颈在显存:( B=10000 ),( n=500 ),打分矩阵就是五百万个浮点数,约 20MB,还能接受。如果 ( B=100000 ),就是 200MB,加上反向传播的中间变量,显存会非常紧张。
所以 ETA 在实际部署时,通常会把候选分批,每批几千个,算完注意力后只保留 top-k 候选的表示进入后续网络。这样显存占用可控,延迟也能压在几十毫秒以内。分批的大小需要根据 GPU 显存和延迟要求调,A100 上批大小 4096 到 8192 比较常见,T4 上可能只能到 1024。
3.3 SimHash 与 LSH 的参数选择
SimHash 的核心参数是指纹长度和哈希函数数量。指纹长度通常取 64 位或 128 位。64 位在千万级候选里碰撞概率已经很低,128 位更安全但存储和计算成本翻倍。哈希函数数量一般取指纹长度的一半左右,比如 64 位指纹用 32 个哈希函数。每个哈希函数是一个随机超平面,把 embedding 投影到一维后取符号位。
LSH 用于难负采样时,需要控制桶的数量和每个桶的大小。桶数量太少,每个桶里样本太多,采样效率低;桶数量太多,很多桶是空的,浪费存储。经验值是桶数量取候选总数的平方根左右。比如百万候选,取一千个桶,平均每个桶一千个样本。桶宽需要根据 embedding 的范数分布调,通常取平均范数的 0.5 到 1 倍。
这里有个容易踩的坑:SimHash 和 LSH 的哈希函数是随机生成的,不同随机种子会导致不同的分桶结果。如果训练时用一套哈希函数,线上服务时重新生成了一套,分桶结果对不上,注意力复用就会出错。正确做法是把哈希函数矩阵持久化,训练和线上共用同一份。我一般会把哈希矩阵存成 numpy 文件,加载时固定随机种子。
4. 实操过程与核心环节实现
4.1 数据准备与特征工程
假设我们有一个电商推荐场景,日志包含用户 ID、物料 ID、行为类型、时间戳。第一步是构造训练样本:正样本是有点击的“用户—物料”对,负样本是曝光未点击的“用户—物料”对。负样本比例通常控制在 1:4 到 1:10 之间,太多负样本会让训练变慢,太少又学不好判别边界。
用户侧特征包括:基础属性(年龄、性别等,如果有)、历史行为序列、统计特征(近 7 天点击次数、近 30 天活跃天数等)。物料侧特征包括:类目、品牌、价格区间、内容 embedding。交叉特征在 ETA 里不是重点,因为目标注意力本身就在做用户和物料的动态交叉。
行为序列的构造要注意时间顺序。注意力机制对顺序敏感,如果打乱顺序,模型学到的兴趣演化就错了。我通常按时间倒序排列,最近的在前,这样注意力更容易关注近期行为。序列长度统一截断到 200,不足的用 padding 补齐,padding 位置的注意力 mask 掉。
# 序列构造示例 def build_sequence(user_actions, max_len=200): # user_actions 按时间倒序 seq = user_actions[:max_len] item_ids = [a['item_id'] for a in seq] behavior_types = [a['behavior_type'] for a in seq] # padding pad_len = max_len - len(item_ids) item_ids += [0] * pad_len behavior_types += [0] * pad_len mask = [1] * len(seq) + [0] * pad_len return item_ids, behavior_types, mask4.2 模型结构搭建
ETA 的模型结构可以分成四层。第一层是 embedding 层,把用户 ID、物料 ID、行为类型映射成稠密向量。第二层是序列编码层,通常用一层 Transformer 或 GRU 对行为序列做初步编码,捕捉序列内部依赖。第三层是目标注意力层,用候选物料作为 Query,序列编码结果作为 Key 和 Value,计算动态用户表示。第四层是预测层,把动态用户表示、候选物料 embedding、统计特征拼接后过几层 MLP,输出 CTR 预估值。
目标注意力层的实现是核心。下面是一个简化版的 PyTorch 实现:
import torch import torch.nn as nn class TargetAttention(nn.Module): def __init__(self, dim, num_heads=4): super().__init__() self.num_heads = num_heads self.dim = dim self.query_proj = nn.Linear(dim, dim) self.key_proj = nn.Linear(dim, dim) self.value_proj = nn.Linear(dim, dim) self.out_proj = nn.Linear(dim, dim) def forward(self, target, sequence, mask): # target: [B, d] # sequence: [B, n, d] # mask: [B, n] B, n, d = sequence.shape Q = self.query_proj(target).view(B, 1, self.num_heads, d // self.num_heads) K = self.key_proj(sequence).view(B, n, self.num_heads, d // self.num_heads) V = self.value_proj(sequence).view(B, n, self.num_heads, d // self.num_heads) Q = Q.transpose(1, 2) # [B, heads, 1, d/heads] K = K.transpose(1, 2) # [B, heads, n, d/heads] V = V.transpose(1, 2) scores = torch.matmul(Q, K.transpose(-2, -1)) / (d // self.num_heads) ** 0.5 scores = scores.masked_fill(mask.unsqueeze(1).unsqueeze(2) == 0, -1e9) attn = torch.softmax(scores, dim=-1) out = torch.matmul(attn, V) # [B, heads, 1, d/heads] out = out.transpose(1, 2).contiguous().view(B, 1, d) out = self.out_proj(out).squeeze(1) return out这段代码里,mask 的处理很关键。padding 位置必须 mask 掉,否则 softmax 会把注意力分配到无意义的位置。另外,多头注意力的头数不要太多,4 到 8 头足够,太多头会让每个头的维度太小,表达能力下降。
4.3 训练流程与参数配置
训练 ETA 时,batch size 不能太大,因为每个样本都要算目标注意力,显存占用和 batch size 成正比。我通常用 256 到 512 的 batch size,配合梯度累积来模拟更大的 batch。学习率用 1e-3 到 1e-4,配合 warmup 和 cosine decay。优化器用 AdamW,权重衰减设 1e-5 到 1e-4。
损失函数用标准的二分类交叉熵。但这里有个细节:由于负样本是采样得到的,预测值会有偏。如果要做校准,需要在最后加一个保序回归或 Platt scaling。不过大多数推荐场景只关心排序,不做校准也可以。
训练过程中要监控两个指标:离线 AUC 和注意力熵。注意力熵反映注意力分布的集中程度。熵太低说明注意力塌缩到少数几个行为上,可能是过拟合;熵太高说明注意力太分散,目标注意力的优势没发挥出来。理想情况下,熵应该随着训练缓慢下降,最后稳定在一个中等水平。
# 训练命令示例 python train_eta.py \ --batch_size 512 \ --lr 5e-4 \ --epochs 10 \ --seq_len 200 \ --num_heads 4 \ --embed_dim 64 \ --simhash_bits 64 \ --lsh_buckets 1024 \ --gpu 04.4 线上服务与延迟优化
线上服务时,ETA 的延迟主要来自三部分:特征获取、注意力计算、后续 MLP。特征获取通常走特征存储,延迟在几毫秒到十几毫秒。注意力计算是瓶颈,需要重点优化。
第一个优化是候选分批。把候选分成每批 2048 个,算完注意力后只保留 top-500 进入后续网络。这样注意力计算量降到原来的五百分之一,延迟大幅下降。第二个优化是 SimHash 预分桶。线上先对候选做 SimHash,同一桶内只算一次注意力,桶内候选复用。第三个优化是模型量化。把 embedding 和注意力计算从 FP32 降到 FP16 或 INT8,延迟能再降 30% 到 50%,精度损失通常在千分之一以内。
提示:线上服务一定要做降级预案。如果注意力计算超时,直接退化成内积粗排,保证服务可用。我见过因为注意力模块故障导致整个推荐流不可用的案例,教训很深刻。
5. 常见问题与排查技巧实录
5.1 训练不收敛或 AUC 波动大
这是最常见的问题。原因通常有三个。第一,学习率太大,注意力层的梯度爆炸。解决办法是加梯度裁剪,把梯度范数限制在 1.0 以内,同时降低学习率。第二,序列 mask 没处理好,padding 位置参与了注意力计算。检查 mask 的维度是否正确,mask 为 0 的位置在 softmax 前要填 -1e9。第三,负样本采样有偏,难负样本比例太高,模型学不到粗粒度判别边界。可以先用随机负样本训练几个 epoch,再逐步加入难负样本。
5.2 注意力权重可解释性差
目标注意力的一个卖点是可解释,但实际训练出来的注意力权重往往很分散,看不出明显规律。这通常是因为序列编码层太强,把序列内部依赖都学完了,目标注意力层没什么可学的。解决办法是削弱序列编码层,比如把 Transformer 层数从 2 层降到 1 层,或者直接用平均池化代替。另外,可以在损失函数里加一个注意力稀疏正则,鼓励注意力集中。
5.3 SimHash 分桶后效果下降
SimHash 分桶本质上是近似,同一桶内候选共享注意力,精度必然有损失。如果下降太多,说明桶内候选差异太大。可以增加指纹长度,从 64 位加到 128 位,或者减少每个桶的容量。另一个办法是分层分桶:先粗分桶,桶内再按 embedding 距离细分,只有距离足够近的候选才共享注意力。
5.4 线上延迟超标
延迟问题要分情况排查。如果是特征获取慢,检查特征存储的 QPS 和缓存命中率。如果是注意力计算慢,先看候选分批大小是否合理,再看是否用了 FP16。如果是后续 MLP 慢,检查 MLP 层数和隐藏单元数,通常两层 256 单元就够了,不要堆太深。下面是一个常见问题速查表:
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 训练 loss 不下降 | 学习率过大或 mask 错误 | 打印梯度范数和 mask 分布 | 降低学习率,修复 mask |
| AUC 波动大 | 负样本分布不稳定 | 统计每批负样本的 embedding 范数 | 固定负采样策略,增加 batch size |
| 注意力熵过低 | 过拟合或序列编码过强 | 监控训练集和验证集熵差异 | 加 dropout,削弱序列编码层 |
| 线上延迟高 | 候选分批过大或未量化 | 分阶段打点计时 | 减小分批,启用 FP16 |
| SimHash 后效果跌 | 桶内候选差异大 | 抽样检查桶内候选相似度 | 增加指纹长度,分层分桶 |
5.5 端到端训练显存不足
端到端训练要把整个计算图保留在显存里,显存占用是普通训练的好几倍。如果显存不够,可以尝试梯度检查点,用时间换空间。具体做法是在注意力层和前馈层之间插入检查点,反向传播时重新计算中间激活,显存能降 50% 以上,训练速度慢 20% 左右。另一个办法是混合精度训练,用 FP16 存激活,FP32 存参数,显存也能降不少。
6. 一些实操心得与后续扩展方向
我在实际项目里落地 ETA 最大的体会是:端到端不是目的,而是手段。如果业务场景的候选规模不大,或者用户行为序列很短,传统两阶段方案可能更稳、更省资源。ETA 真正发挥价值的场景,是候选规模在十万到百万级、用户行为丰富、且对精度有极致要求的推荐流。这种场景下,目标注意力带来的动态用户表示,确实能把 CTR 提升几个百分点。
另一个心得是关于 SimHash 和 LSH 的使用。很多人把它们当成纯粹的工程优化,觉得精度损失可以接受。但实际上,哈希函数的选择和参数调优对最终效果影响很大。我建议在项目初期就花时间调好哈希参数,不要等到线上效果不好再回头改,那时候改造成本会高很多。
后续如果想继续扩展,有两个方向值得尝试。第一个方向是把目标注意力从单目标扩展到多目标,比如同时优化点击和转化,用多任务学习的方式共享注意力模块。第二个方向是引入序列建模的预训练,用大规模无标签行为序列预训练一个序列编码器,再在 ETA 里微调。这两个方向在工业界都有初步尝试,效果还有很大提升空间。
最后分享一个小技巧:调试注意力模块时,可以把注意力权重可视化出来,随机抽几个用户和候选,看看注意力到底落在哪些行为上。很多时候,模型学到的模式和业务直觉不一致,这种不一致往往就是优化的切入点。比如你发现模型总是关注很久以前的行为,而业务上近期行为更重要,那就说明位置编码或时间特征没做好,需要针对性调整。