推荐系统做到一定阶段,多数团队都会撞上一堵墙:模型的离线指标怎么调都涨不动了,特征该加的也加了,样本量也够大,但AUC就是卡在某个数位上。这时候问题往往不在特征的数量,而在特征的组织方式。HHFT(Hierarchical Heterogeneous Feature Transformer)这套结构,就是我在处理这类瓶颈时反复打磨出来的一个方案,它把推荐系统里那些天生异构、天生带层级的特征,用一种更贴近它们本来面目的方式喂给Transformer,而不是简单粗暴地把所有特征拼成一条长向量丢进MLP。如果你正在做CTR/CVR预估、正在被特征交互的高阶组合问题困扰、或者手上有一堆行为序列不知道怎么和静态画像融合,那这套思路值得花时间看一遍。下面我会从设计动因、架构细节、PyTorch实现到工程避坑,完整讲一遍。
1. 推荐系统的特征到底"异构"在哪:HHFT要解决的问题
先把术语说清楚。所谓异构特征,指的是推荐场景里同时存在好几种性质完全不同的字段,它们的取值空间、语义粒度、统计分布都不一样,硬塞进同一个处理流程里必然有一方吃亏。所谓层级特征,指的是这些字段天然存在分组关系和嵌套关系,比如"用户-行为-物品"这三层,或者"域-字段-取值"这三层。HHFT的核心主张就是:异构决定每类字段要用不同的Token化方式,层级决定注意力要分两级来做,而不是一次性拍平。
1.1 四类特征字段,各有各的脾气
我习惯把线上特征分成四类来对待。第一类是类别型单值字段,比如用户性别、城市等级、物品一级类目、品牌ID,这类字段值域有限但分布极度倾斜,头部几个ID覆盖八成流量,长尾ID可能一周才出现几次。第二类是数值型字段,比如价格、历史点击率、商品评分、用户活跃天数,这类字段的麻烦在于分布长尾且量纲不一,直接做标准化在推荐场景里效果往往不如分桶离散化。第三类是多值字段,比如物品标签、用户的兴趣类目集合、多级类目路径,这类字段的取值是个集合,长度可变。第四类是行为序列字段,比如最近N次点击、最近N次加购,这类字段既有顺序信息又有时间间隔信息。
这四类字段如果统一用embedding lookup处理,数值型字段会丢掉连续性的信息,多值字段会被截断或者填充,序列字段的顺序会被打乱。我早期就干过这种蠢事,把价格当成类别ID做embedding,结果模型对价格的响应完全是阶梯式的,涨十块钱和涨一百块钱在模型眼里没有区别,离线AUC看着还行,线上价格敏感的商品推荐就明显不对劲。后来改成先分桶再做桶内embedding,桶边界用分位数采样确定,才算把这段价格响应救回来。所以HHFT里每一类字段都有独立的Token化分支,这是整个结构的地基。
处理这四类字段时有个容易被忽略的细节:字段的缺失本身也是信息。用户没填年龄、物品没有品牌,这些缺失不能简单用0填充或者用UNK token糊弄过去,因为"缺失"和"恰好取到UNK这个值"是两回事。我的做法是给每个字段额外留一个独立的缺失embedding,和UNK token的embedding分开,实测在冷启动流量上差异很明显,缺失率高的字段尤其敏感。
1.2 两个层级的结构信息,怎么用起来
层级这件事,在推荐特征里至少体现为两个层面。第一个层面是特征域到字段的层级:用户画像域下面有年龄、性别、城市、注册天数这些字段;物品内容域下面有类目、品牌、标签、价格这些字段;上下文域下面有时段、设备、场景这些字段。同一域内的字段语义相近、交互频繁,跨域的字段交互相对稀疏但价值更高。第二个层面是序列到元素的层级:一条点击序列内部元素之间有时序依赖,序列整体又能和当前候选物品产生交叉。
传统做法比如DeepFM,是把所有字段的embedding拼在一起,然后FM部分做二阶交叉、DNN部分做高阶交叉。问题在于DNN那部分是全连接,字段两两之间不管有没有交互价值都被强行连上,参数利用率低,而且跨域和域内的交互权重是一起学的,域内的强交互信号容易被跨域的噪声稀释。AutoInt用自注意力做特征交互算是往前走了一步,但它是把所有字段拍平成一条序列,序列长度直接等于字段总数,字段上百个的时候注意力矩阵就是上万规模,而且域结构信息完全丢失了。
HHFT的做法是分两级:先在每个域内部做自注意力,把域内字段交互压缩成一个域向量;再在所有域向量之间做一次自注意力,捕捉跨域高阶交互。这样域内的交互和域间的交互各用一套参数,互不干扰,而且计算量从字段数的平方降到域数的平方加上字段数的平方(分域后),结构上更合理。这个设计选择背后的直觉很朴素:同一批特征里,域内交互是高频强信号,域间交互是低频但高价值的信号,两者混在一起学,梯度尺度都不一样。分开之后两个编码器可以各用各的学习率、各用各的层数,调参空间也打开了。
1.3 为什么不是直接上大Transformer
有人会问,既然Transformer这么强,为什么不用一个足够大的Transformer把所有字段全塞进去,靠数据和参数硬吃?这个思路我试过,结论是:在推荐系统的样本规模和特征结构下,直接堆大Transformer的性价比很低。原因有三个。第一是计算复杂度,字段数N直接决定注意力矩阵是N²,特征工程做得越细N越大,线上推理延迟撑不住。假设你有一百个字段,N²就是一万,如果字段数到两百,N²就是四万,而分层结构下这个量级能压下来一个数量级。第二是异构字段的Token语义不对齐,数值字段和类别字段经过同样的embedding层之后,向量空间的尺度不一致,自注意力学出来的相似度矩阵会被数值字段的高方差主导,类别字段的信息被淹没。必须先用不同的Token化分支把尺度对齐,才谈得上公平交互。第三是过拟合风险,推荐系统的标签噪声很大,负样本里混着大量"没曝光但可能感兴趣"的样本,模型容量远超数据能支撑的量级时,很快就记住训练集了。
我自己的经验法则是:在特征数超过50个、且有明显域结构的时候,分层比拍平更划算;特征数少于20个、结构松散的时候,直接拍平反而简单有效。HHFT是为前者设计的,不要无脑套用。
2. HHFT架构拆解:从字段Token化到两级注意力
把设计动因讲完之后,这部分进入正题,我会一层一层拆HHFT的架构。整体数据流是:原始特征 → 分类型Token化 → 域内自注意力 → 域内聚合 → 域间自注意力 → 全局池化 → 多任务MLP。下面每一层我都会说明它为什么这么设计,以及有哪些容易踩的细节。
2.1 统一Token化:让数值、类别、多值、序列说同一种语言
Token化的目标是把所有字段都映射到同一个维度d_model的空间里,但同时保留各自的特性。类别型字段用标准的embedding lookup,embedding_dim直接设成d_model,初始化用小的正态分布(标准差0.02左右),这点很重要,初始化太大会导致训练初期注意力分布过于尖锐。数值型字段我用的是"分位数分桶 + embedding查表 + 原始值线性投影"的双通道:分桶通道负责捕捉非线性的阶梯响应,线性投影通道负责保留连续变化的细粒度信息,两条通道相加再过一层LayerNorm。桶的数量我一般设32到64之间,太少区分度不够,太多每个桶的样本稀疏、embedding学不充分。
多值字段的处理要小心,简单的mean pooling会让长集合和短集合的表示尺度不一致,我更喜欢用attention pooling:给集合里每个元素算一个重要性权重,权重由该元素和一个可学习的query向量点积得到,归一化后加权求和。这样模型能自己决定哪些标签更重要,比如一个商品有十个标签,其中两个是强相关的,attention会自动给它们更高权重。序列字段的Token化除了embedding之外,必须叠加位置编码或时间间隔编码,否则序列和集合没区别。位置编码我倾向用可学习的位置embedding而不是正弦编码,因为行为序列长度通常有限(截断到50到100),可学习的位置embedding拟合得更充分。
注意:所有字段的Token在进入注意力之前,建议统一乘一个字段级的可学习缩放系数,或者各自过一层独立的LayerNorm。这一步在论文里经常被一笔带过,但实测对收敛稳定性帮助很大,尤其是当字段里混有量纲悬殊的数值特征时。
2.2 域内编码器:先把同类特征搅匀
域内编码器的输入是同一个域里所有字段的Token序列,长度等于该域的字段数。因为同一域内字段数通常不多(5到20个),这里用标准的Transformer Encoder层就够了,多头数一般设4到8,前馈维度设成d_model的4倍,层数1到2层足矣。用两层以上的域内编码器收益递减很快,因为域内字段本来语义就相近,一层自注意力基本能把主要交互捕捉到,再加层主要是增加过拟合风险。
域内编码器的参数是否跨域共享,是个值得权衡的点。共享参数的版本更省,而且能让样本量在域之间互通,对字段数少的域特别友好;独立参数的版本表达能力强,但小域的样本撑不起来。我的折中做法是共享主干、保留域专属的偏置和LayerNorm参数,这样既利用了共享带来的泛化,又保留了域之间的差异。实测下来这个折中比全共享和全独立都好一些,尤其是在有十来个域、域之间样本量差距较大的场景。
域内的聚合方式也有讲究。最简单的mean pooling会丢掉字段重要性差异,我一般用一个可学习的CLS Token,把它拼在字段序列最前面,经过自注意力之后直接取CLS位置的输出当作域向量。CLS Token的初始化用截断正态,效果比mean pooling稳定,代价是多了一个位置的注意力计算,几乎可以忽略。
2.3 域间编码器:跨域高阶交互的轻量做法
域向量的数量等于域的个数,通常十来二十个,所以域间自注意力的矩阵规模很小,可以放心地堆层数,我一般用2到3层,头数8。这里有一个设计选择:域间注意力的位置关系要不要编码?因为域之间其实没有严格的顺序关系,用位置编码反而可能引入错误的先验,所以域间编码器里我一般不加位置编码,只保留token本身的表示。但如果域是按照某种业务顺序排列的(比如用户域、物品域、上下文域这种固定顺序),加一个轻量的位置embedding也无妨,这个可以当成超参去试。
域间编码器的输出是一个域向量序列,最后的聚合方式有几种:取CLS、取所有域向量的拼接、或者用一个可学习的加权求和。我实测下来,直接拼接所有域向量再送进MLP,效果和取CLS差不多,但可解释性更好,因为每个域的贡献是显式的,做特征重要性分析的时候能直接看到哪个域在起作用。如果线上对延迟极敏感,可以把拼接换成加权求和,把维度降下来。
还有一个容易忽略的细节:域间编码器的层数不要太多。跨域交互的信息增益是有限的,层数堆到4层以上时,模型开始倾向于退化成近似恒等映射,各域的表示趋同,反而丢掉了域的个性。我踩过这个坑,加了层数之后离线AUC持平但线上掉点,最后砍回两层才恢复。
2.4 输出层与多任务头设计
输出层这边我做了两件事。第一是把域间编码器的输出和原始Token化结果做一次残差连接,保证浅层信息不会在多层注意力中被磨掉。具体做法是把所有字段Token做一次mean pooling得到一个全局向量,和域间输出拼接起来送进MLP。第二是多任务头共享底层、独立顶层,因为CTR、CVR、停留时长这些目标的相关性不一样,硬用一个头预测所有目标会互相拖累,但底层表示共享能提升样本效率。
多任务的loss加权是另一个坑。简单把几个loss相加,通常会被量级最大的那个目标主导。我用的是动态权重调整,每轮根据各任务loss的下降速率反比调整权重,或者用不确定性加权的做法给每个任务学一个可学习的方差参数。这个部分在后面的训练章节会展开讲。
3. PyTorch实现:把HHFT跑起来
理论讲完,直接上代码。下面的实现是我简化过的版本,去掉了公司内部的业务细节,保留核心结构,你可以直接拿去改。整体分四块:特征Schema定义、Token化层、两级编码器、训练循环。
3.1 特征Schema与Embedding层
先把特征结构用配置声明出来,这样加字段、改字段类型不用动模型代码。我用一个列表描述每个字段的类型和所属域,然后按类型建不同的分支。
import torch import torch.nn as nn import torch.nn.functional as F class FeatureSchema: def __init__(self, fields): # fields: list of dict(name, domain, type, vocab_size, bucket_num) self.fields = fields self.domains = sorted({f["domain"] for f in fields}) self.domain2fields = {d: [] for d in self.domains} for i, f in enumerate(fields): self.domain2fields[f["domain"]].append(i) class FieldTokenizer(nn.Module): def __init__(self, schema, d_model): super().__init__() self.schema = schema self.d_model = d_model self.emb_layers = nn.ModuleDict() self.num_projs = nn.ModuleDict() for i, f in enumerate(schema.fields): key = f"f{i}" if f["type"] in ("categorical", "multivalue"): # 多留一个位置给缺失值:vocab_size + 1 self.emb_layers[key] = nn.Embedding(f["vocab_size"] + 1, d_model) nn.init.normal_(self.emb_layers[key].weight, std=0.02) elif f["type"] == "numeric": self.emb_layers[key] = nn.Embedding(f["bucket_num"], d_model) self.num_projs[key] = nn.Linear(1, d_model) elif f["type"] == "sequence": self.emb_layers[key] = nn.Embedding(f["vocab_size"] + 1, d_model) self.norm = nn.LayerNorm(d_model) def forward(self, batch): tokens = [] for i, f in enumerate(self.schema.fields): key = f"f{i}" v = batch[key] if f["type"] == "categorical": t = self.emb_layers[key](v) elif f["type"] == "numeric": bucket = batch[key + "_bucket"].clamp(min=0) t = self.emb_layers[key](bucket) + self.num_projs[key](v.unsqueeze(-1)) elif f["type"] == "multivalue": emb = self.emb_layers[key](v) # [B, L, D] mask = (v != 0).float().unsqueeze(-1) # 0 视为 padding t = (emb * mask).sum(1) / mask.sum(1).clamp(min=1.0) elif f["type"] == "sequence": emb = self.emb_layers[key](v) # [B, L, D] t = emb.mean(1) # 序列信息在域内编码器里再处理 tokens.append(self.norm(t)) return tokens数值字段的分桶值我在数据管道里预先算好,模型里只做查表和投影,避免在训练循环里做分位数计算。多值字段里我把索引0当作padding,实际ID从1开始,这样mask逻辑很简单。
3.2 两级编码器的核心代码
域内编码器负责把每个域的字段Token压成一个域向量,域间编码器负责跨域交互。注意这里我用的是norm_first=True,也就是Pre-LN结构,训练早期比Post-LN稳定很多,尤其是学习率偏大的时候。
class HierarchicalEncoder(nn.Module): def __init__(self, schema, d_model=128, intra_layers=1, inter_layers=2, nhead=8, dropout=0.1): super().__init__() self.schema = schema self.d_model = d_model intra_layer = nn.TransformerEncoderLayer( d_model, nhead, dim_feedforward=4 * d_model, dropout=dropout, batch_first=True, norm_first=True) self.intra = nn.TransformerEncoder(intra_layer, intra_layers) self.domain_cls = nn.Parameter(torch.randn(1, 1, d_model) * 0.02) inter_layer = nn.TransformerEncoderLayer( d_model, nhead, dim_feedforward=4 * d_model, dropout=dropout, batch_first=True, norm_first=True) self.inter = nn.TransformerEncoder(inter_layer, inter_layers) self.global_cls = nn.Parameter(torch.randn(1, 1, d_model) * 0.02) def forward(self, tokens): # 1) 每个域内做自注意力,取CLS作为域向量 domain_vecs = [] for d in self.schema.domains: idx = self.schema.domain2fields[d] seq = torch.stack([tokens[i] for i in idx], dim=1) # [B, n_f, D] cls = self.domain_cls.expand(seq.size(0), -1, -1) seq = torch.cat([cls, seq], dim=1) out = self.intra(seq) domain_vecs.append(out[:, 0]) # [B, D] # 2) 域间自注意力 dom_seq = torch.stack(domain_vecs, dim=1) # [B, M, D] gcls = self.global_cls.expand(dom_seq.size(0), -1, -1) dom_seq = torch.cat([gcls, dom_seq], dim=1) out = self.inter(dom_seq) global_vec = out[:, 0] domain_out = out[:, 1:].reshape(out.size(0), -1) # [B, M*D] return global_vec, domain_out两级编码器的层数分配上,我建议域内1层、域间2层作为起点。域内字段本来强相关,一层足够;跨域交互更复杂,两层比较合适。如果你的域特别多(超过20个),可以把域间层数加到3层试试。
3.3 训练循环与损失配置
多任务部分我用不确定性加权来平衡loss,这样不用手工调权重。核心思路是给每个任务学一个可学习的log方差参数,loss写成0.5 * exp(-s) * task_loss + 0.5 * s,这样量级大的任务方差大自然被压低权重。
class HHFT(nn.Module): def __init__(self, schema, d_model=128, num_tasks=2): super().__init__() self.tokenizer = FieldTokenizer(schema, d_model) self.encoder = HierarchicalEncoder(schema, d_model) self.num_tasks = num_tasks in_dim = d_model + d_model * len(schema.domains) self.heads = nn.ModuleList([ nn.Sequential(nn.Linear(in_dim, 256), nn.GELU(), nn.Dropout(0.1), nn.Linear(256, 1)) for _ in range(num_tasks) ]) self.log_vars = nn.Parameter(torch.zeros(num_tasks)) def forward(self, batch): tokens = self.tokenizer(batch) gvec, dvec = self.encoder(tokens) feat = torch.cat([gvec, dvec], dim=-1) logits = [h(feat).squeeze(-1) for h in self.heads] return logits def multitask_loss(logits, labels): losses = [] for i, (lg, lb) in enumerate(zip(logits, labels)): losses.append(F.binary_cross_entropy_with_logits(lg, lb, reduction="none")) stack = torch.stack(losses, dim=0) # [T, B] log_vars = model.log_vars weighted = 0.5 * torch.exp(-log_vars).unsqueeze(1) * stack + 0.5 * log_vars.unsqueeze(1) return weighted.mean()训练超参我一般这么起步:AdamW,学习率2e-3(注意是Transformer的常用量级,不是1e-4),weight decay 1e-5,warmup 1000步,batch size 2048到4096。学习率做cosine衰减到初始值的十分之一。梯度裁剪阈值设1.0,防止早期梯度爆炸。embedding层的weight decay要单独关掉,否则ID embedding会被拉向0,长尾ID本来样本就少,再被正则压制基本学不到东西。
3.4 参数量与显存估算
上线前必须算清楚资源账。假设100个字段,10个域,d_model=128,域内1层、域间2层。Embedding表是大头,假设ID类特征总词表五千万,加上序列特征的核心词表三千万,总共八千万个embedding,每个128维float32,就是8000万 × 128 × 4字节 ≈ 41GB,这个量级必须用分片embedding加优化器状态的稀疏更新方案,否则单机根本放不下。Transformer本体部分反而很小,两层encoder大概几百万参数。
激活显存估算:batch 2048,域内序列最长20个token(CLS加字段),10个域并行,加上域间20个token(10个域加CLS),单层注意力的中间激活大概几十MB量级,整体比较轻松。真正的瓶颈在embedding梯度的通信和聚合上,分布式训练时稀疏梯度的传输效率往往比计算本身更耗时间,这块要用AllReduce的稀疏版本或者参数服务器方案。
4. 落地避坑:数据、训练、推理三个环节
模型能跑通只是第一步,能上线并且稳定涨点才是目的。这一章讲的都是我实际踩过的坑,很多在论文里根本不会提。
4.1 特征穿越:最贵的一类bug
特征穿越指的是训练时用到了预测时刻还不可知的信息。这个问题在序列特征上尤其隐蔽。比如你用"用户最近一次点击的物品ID"做特征,如果没有严格按照样本的时间戳去截取历史,很容易把当前样本之后的点击也带进来。表现是离线AUC涨得飞起,线上直接掉点。我的做法是所有特征都带一个生效时间戳,训练时只允许访问样本时间戳之前的数据,并且在数据管道里做断言校验。还有一个更隐蔽的情况:统计类特征(比如物品的七天点击率)如果在离线计算时用了全量数据,那这个特征本身就穿越了,必须用滑动窗口按小时或按天滚动计算,保证线上线下的计算口径一致。
注意:序列特征的截断策略要和线上保持一致。离线用最近50个行为训练,线上也必须取最近50个,不能离线用全量、线上只取最近20个,否则特征分布偏移,模型上线就崩。
4.2 冷启动与ID特征退化
HHFT里ID类特征占比很高,冷启动是绕不开的问题。新用户、新物品的ID没出现过,embedding就是随机初始化的小向量,等于噪声。我的处理有几个层次:第一,ID做hash分桶,把超长尾的ID映射到有限个桶里,比如十万个桶,这样至少能共享一些统计信息。第二,内容特征兜底,物品类目、品牌这些属性特征的embedding是全局共享的,新物品即使ID是新的,靠属性也能得到一个合理表示。第三,加一个ID置信度门控,ID出现次数少的时候自动降低ID特征的权重,让模型更多依赖属性特征。这个门控用一个简单的sigmoid函数实现,输入是该ID在过去一段时间的曝光次数。
4.3 训练不稳定与梯度异常
Transformer训推荐模型最常见的两个问题是loss震荡和梯度爆炸。除了标准的Pre-LN、warmup、梯度裁剪之外,还有几个细节。embedding初始化方差要小,0.02这个经验值可以用。注意力logits要做缩放,标准Transformer里是除以根号d_k,这一步千万别漏。数值特征进模型前要做clipping,把极端值截断到分位数的1%和99%之间,否则个别异常样本会把梯度带偏。多值字段的mask除法要加clamp,避免空集合导致除零。
如果训练过程中发现loss突然变成一个很大的值,八成是某条样本的数值特征异常或者某个embedding查到了未初始化的区域。我的排查方法是先在小批量数据上跑几个epoch确认能过拟合,再逐步扩大到全量,这样能快速定位是数据问题还是模型问题。
4.4 线上推理延迟的优化路径
HHFT的推理可以拆成用户侧和物品侧两部分。用户侧的域(画像、行为序列)变化频率低,可以做缓存,用户向量每几分钟更新一次,请求时直接取缓存。这样每次请求实际只需要算物品域和上下文域,再和缓存的用户域向量做一次域间注意力,计算量小很多。序列特征是最耗时的,线上可以做长度截断,最近N个行为用完整注意力,更早的行为用简单加权池化降级处理。
实测下来这套优化能把P99延迟控制在几十毫秒量级,具体数值取决于域数和序列长度。如果延迟还是紧张,可以考虑把域间编码器蒸馏成一个小MLP,用大模型的输出做软标签训练,效果损失很有限,延迟能降一个量级。
5. 效果验证与调参速查
最后讲讲怎么验证效果、怎么调参。这部分是最容易自作欺人的环节,指标涨了不代表模型真的好,必须设计严格的对照实验。
5.1 离线评估怎么设计才不骗自己
离线评估我不只看AUC,还会看GAUC(按用户分组的AUC,能过滤掉用户本身偏好强弱带来的干扰)、LogLoss(反映概率校准)、以及预测均值与真实CTR的偏差(校准度)。推荐场景里AUC涨0.002可能是噪声,GAUC涨0.003以上才值得关注。消融实验一定要做,至少要验证三个点:去掉域间编码器、把分层结构换成拍平、去掉时间间隔编码。这三个消融能告诉你涨点到底来自哪里。还有一点,离线评估必须按时间切分而不是随机切分,随机切分会让未来信息泄漏到训练集,指标虚高。
5.2 超参调优的先后顺序
调参不要一把梭,按敏感度排序,先调影响最大的。我习惯的顺序是:先定embedding维度和d_model(64到256之间扫,通常128是性价比拐点),再定两级层数(域内1到2层,域间1到3层),然后调学习率和warmup步数,最后调dropout和weight decay做正则。batch size在显存允许范围内尽量大,配合学习率同步放大。域数多的时候,域间层数可以适当加,但超过3层基本没收益。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 训练loss不下降 | 学习率过小或初始化方差过大 | 先在小数据集上验证能否过拟合 |
| loss震荡剧烈 | 学习率过大或batch过小 | 降学习率、加warmup、加梯度裁剪 |
| 离线AUC高但线上掉点 | 特征穿越或线上线下口径不一致 | 检查时间戳对齐和统计特征窗口 |
| 长尾ID效果差 | embedding正则过强或初始化不当 | 关掉embedding的weight decay,加置信度门控 |
| 推理延迟超标 | 序列过长或用户侧未缓存 | 序列截断、用户向量缓存、模型蒸馏 |
| 多任务互相拖累 | loss权重量级失衡 | 用不确定性加权替代手工权重 |
| 域间注意力学不出差异 | 域间层数过多导致表示趋同 | 减到2层,加域专属偏置 |
| 显存爆炸 | embedding表过大或batch过大 | 分片embedding、稀疏优化器、减batch |
这份表基本覆盖了我遇到过的八九成问题。剩下那一两成往往是数据本身的问题,比如样本偏置、标签延迟、曝光偏差,这些不是模型结构能解决的,得从数据管道和采样策略上去修。
5.4 后续可以继续深挖的方向
如果HHFT的基础版本已经跑顺,还有几个方向可以继续挖。一个是把序列编码器换成更精细的结构,比如在序列域内部也用多级注意力,先做局部窗口再做全局,对长序列特别有效。另一个是引入对比学习做辅助任务,用同一用户相邻两次行为构造正样本,能显著提升序列表示的鲁棒性,尤其在行为稀疏的用户上。还有就是模型蒸馏到轻量结构,把HHFT当作teacher,用它的中间层输出指导学生模型,能在延迟受限的场景里保住大部分效果。这几个方向我自己试过前两个,收益都比较实在,后面有机会再详细拆。
我自己在做这类结构时最大的体会是:结构设计一定要服务于数据和业务,而不是反过来。HHFT分层这套东西之所以有效,根本原因在于推荐特征本身就有域结构和层级关系,模型只是把这个先验显式地编码进去了。如果换成图像、文本这类本身没有明显域划分的输入,这套分层反而会引入无意义的约束。所以动手之前先花时间看清楚你的特征到底长什么样,比急着调模型结构重要得多。