做点击率预估这么多年,我有个很深的体会:模型结构的升级往往不是靠换一个更复杂的 Tower——DeepFM、DCN、DIN 这些框架带来的提升大家都看得见,但真正让离线指标和线上效果拉开差距的,反而是特征怎么进网络这个环节。拿 embedding 维度来说,我早期项目里习惯按特征出现的频率分档,头部特征给 32 维、中腰部给 16 维、长尾给 8 维。听起来合理,实际跑一段时间就会发现,今天还是头部热门的特征,下周可能就没人点了;而那些刚开始低频的冷门特征,一旦遇到大规模拉新活动,流量瞬间涨上来,固定维度根本来不及跟着“扩容”。
后来我接触到自适应参数生成网络(APG)这个思路,终于找到了一个不那么依赖人工去反复调档位的解法。简单说,APG 在传统 CTR 模型内部加了一个“参数生成”的分支,让网络自己根据当前特征和上下文,动态决定到底给这个特征分配多大的容量、用什么样的参数组合。这篇文章不打算复述论文里的全部公式,而是从我实际搭建、训练、上线这套结构的经历出发,把 APG 的关键设计和容易踩的坑讲清楚。
我会先解释为什么固定 embedding 对 CTR 预测并不公平,然后拆解 APG 的网络结构,接着给出训练和推理阶段的稳定落地细节,最后分享我在生产环境踩过的几个真实问题。如果你已经熟悉 DeepFM、DCN 这类主流 CTR 模型,但一直在为特征分桶和内存分配发愁,这篇文章应该能给你一个可以直接参考的动手思路。
1. 特征幂律分布下,固定 embedding 的“隐性不公”
1.1 高频特征和低频特征根本不应该用同一套容量策略
CTR 预估里有一个很普遍的现象:一小部分头部特征贡献了绝大多数点击样本,剩下绝大多数长尾特征只出现寥寥几次。如果所有特征都用同一个 embedding 维度,就会出现两种尴尬局面。
一种局面是维度设置偏高,比如统一用 64 维。稀疏的长尾特征在训练中只被更新过几次,携带的信息量根本撑不起 64 维空间,结果就是维度大的那部分成分基本在记忆噪声,线下 AUC 看起来还行,线上遇到没见过的样本就严重过拟合。另一种局面是维度设置偏低,比如统一用 8 维。头部特征明明有海量样本可以学习更丰富的语义信息,却因为容量不够,把 ID 之间的细微关系都挤扁了,直接影响交互层的表达能力,这一点在电商、短视频的信息流场景里尤其明显。
我做一个简单的估算对比。假设模型里有 100 万个特征 ID,统一维度取 16,那 embedding 参数是 1600 万个浮点数。如果按照频率把其中 1% 的高频特征升到 64 维,剩下的降到 8 维,整体参数数量大概是:
头部特征参数量 = 10000 × 64 = 640000
长尾特征参数量 = 990000 × 8 = 7920000
总参数量 ≈ 8560000
相比统一 16 维,内存直接省了接近一半,同时头部特征的容量还翻了两倍。这个账谁都会算,真正难的是:频率分档的阈值是变化的,今天划分出的头部集合,下个月就不是那个头部集合了。人工分桶的做法需要每周更新特征分桶表、重新导出 vocab,很容易变成一种机械的重复劳动。
1.2 人工分桶为什么总是在“追着数据跑”
我自己最开始的做法非常朴素:离线统计近三十天的特征频率,把样本数量超过某个阈值的特征划入高容量桶,低于阈值的放入低容量桶。上线第一周效果不错,融入了人工经验的注意力机制让头部特征的表达明显变好。但到了第二周,一次投放活动带来了大量全新的商品 ID 和用户互动行为,新增特征全部落进了低容量桶,而且因为 hash 冲突比较多,新建的 bucket 还被老特征挤占,不少新特征实际上没有拿到独立的 embedding 空间。
这时候我才意识到,频率只是一个静态快照,CTR 场景里的特征分布是动态变化的。真正的需求不是让人反复去算“哪些特征重要”,而是让网络在训练过程中自己知道“当前这个特征需要多少容量”,以及“在给定上下文的情况下,哪些参数组合最合适”。这就是 APG 想解决的问题。
1.3 APG 的思路本质:把“容量分配”做成一个可学习的子任务
APG 的出发点并不是替换整个 CTR 模型,而是给模型增加一个轻量级的生成器网络。这个生成器的输入是特征 ID、上下文特征以及模型中间层的某些统计信息,输出则是目标模型真正用到的参数:可能是 embedding 维度选择,可能是门控权重,也可能是一组与上下文相关的条件 MLP 权重。
一句话概括它的作用:传统模型训练出来的是一个静态参数矩阵,APG 训练出来的是一个“会按需调整参数的参数生成器”。这样原本靠人工完成的特征分桶工作,变成了一个端到端优化问题,并且能随着训练数据的变化不断自我修正。
2. APG 的生成器结构:从动态嵌入门控到条件权重
2.1 三个核心组件:特征编码、生成器、目标模型
把 APG 拆开看,它其实由三部分组成。稀疏特征和上下文先进入一个低成本的编码模块,压缩成一串上下文向量 c;然后生成器网络根据这个 c 和维度选择日志,输出一个多项式分布或者软门控向量;最后目标模型再根据这个门控向量去选择或组合参数。
我在这里用伪代码说明一个比较典型的实现方式:
def apg_forward(feature_ids, context_features): # 1. 用一个小型网络编码当前样本的上下文 ctx = context_encoder(context_features) # shape: (batch, C) # 2. 生成器根据特征 ID 和上下文产生 K 个容量选项的得分 logits = weight_generator(feature_ids, ctx) # shape: (batch, K) # 3. 用 Gumbel-Softmax 得到一个可导的硬选择或软选择 scores = gumbel_softmax(logits, tau=0.5, hard=True) # shape: (batch, K) # 4. 从共享 embedding 库里组合出该特征真正的 embedding embedding = torch.stack(embed_lib, dim=0) # shape: (K, D_max) selected_emb = torch.sum(scores.unsqueeze(-1) * embedding.unsqueeze(0), dim=1) # 5. 后续接主 CTR 模型的交互层、MLP 塔 logit = ctr_tower(selected_emb, other_features) return logit, scores这里 K 的意思是生成器有 K 个容量档次可以选。举一个便于理解的类比:传统做法是给每个人固定发一套工具箱,里面所有扳手都是同一个尺寸;APG 的做法是多备了几种尺寸的扳手,网络根据当前要拧的螺丝大小临时挑一把,挑完再干活。这个“挑选”动作发生在每个样本上,所以不同样本、不同特征会走不同的参数路径。
2.2 嵌入维数动态化:硬选择与软选择的取舍
把 embedding 库按 K 个档次拆开之后,最关键的一个设计决策是:让生成器输出硬选择还是软选择。
硬选择是指生成器最终选一个或两个最优维度的 embedding;软选择则是把多个维度的 embedding 加权求和。硬选择的稀疏性更好,线上推理时可以直接跳过没有被选中的维度,节省计算量。软选择的梯度传播更稳定,训练初期不容易出现维度跳变导致的 loss 震荡。
我的实际经验是:训练阶段用软选择可以让模型更快收敛,但训练后期需要把 softmax 的温度系数调低,逐步逼近硬选择。如果全程用硬选择,模型在初期会频繁在不同的 embedding 空间之间跳来跳去,相当于每几个 batch 就换一次特征表示,底层模型根本来不及稳定。如果全程用软选择,模型容量变大、延迟变高,线上收益又会被多出的计算代价抵消。
实践中可以给损失函数加一个熵正则项,鼓励生成器的输出分布尽量集中。Gumbel-Softmax 本身就带温度参数 τ,τ 越小,输出越接近 one-hot。我会在训练初期设 τ=0.5,在第五个 epoch 左右逐步降到 0.1,这样既保留了可导性,又让最终部署时行为更接近硬选择。
2.3 不只嵌入维度:生成条件权重解决“用户上下文漂移”
嵌入维度选择只是 APG 的一个基础用法。更进一步,生成器还可以输出一组条件权重,直接调制 MLP 层的激活值或者特征交叉层的权重。这个思路和超网络(HyperNetwork)有相似之处,但超网络通常生成的是整个目标网络的参数,APG 更强调生成的是与特征和上下文相关的局部参数。
为什么需要条件权重?还是回到分布漂移的问题。一个典型的 CTR 特征比如用户当前正在浏览的类目,在晚上和白天、周末和工作日,它的最佳表达方式并不相同。固定网络只有一套权重,无法区分这种上下文差异。APG 可以先生成一个与上下文相关的门控向量,再对 MLP 某一层的隐向量做缩放或偏移,让模型在界面模式之间切换得更加平滑。实际工程里,我不建议对每一层都做条件权重生成,这不仅增加训练成本,更容易导致过拟合。通常只对最靠近特征输入的那一层和最后一层输出层做调制就够了。
3. 训练 APG 的节奏与损失设计:稳定收敛靠的是细节
3.1 损失函数不只有点击损失,还有容量代价
APG 的学习目标天然包含两个方向:预测要准,同时参数使用要省。如果只优化点击损失,生成器会发现把所有特征的维度都开到最大最省事,因为容量越大表达能力越强,模型肯定能拟合得更好,但这违背了我们引入 APG 的初衷。
所以损失函数需要写成:
总损失 = 点击交叉熵损失 + λ × 预估容量代价
容量代价的计算方式和具体场景绑定。最简单的做法是统计每个特征的维度选择,乘上单位维度对应的计算开销,得到一个近似 FLOPs 的累加值。也可以更直接一些,把当前 batch 实际用到的总 embedding 参数量作为惩罚项。λ 的取值需要借助实验来调,我在 CTL 项目里一般从 1e-5 起步,每次翻倍,在离线数据上观察 AUC 和平均维度两个指标的变化。
这里有一个反直觉的现象:λ 过大时,生成器会把几乎所有特征都压到最低容量,AUC 掉得并不严重,甚至在部分长尾分布很大的数据集上还略有提升。原因是最低容量档位可以看作一种正则,压制噪声特征对整体预测的干扰。但压得太狠会让头部特征也失去表达能力,最终在线上表现为对热门内容的预测偏保守,对突发流量反应迟钝。因此评估时要同时看平均维度和头部特征维度的分布,不能只看 AUC。
3.2 两阶段训练:先喂饱主模型,再让生成器说话
APG 最容易出现的训练问题,是前期生成器还在探索阶段就把主模型的 embedding 空间搅乱了。
我早期直接把 APG branch 和主网络一起训练,结果第一轮 epoch 的 loss 曲线像过山车,因为生成器的选择几乎完全随机,主模型在反复变换输入表示的情况下经常发疯。后来参考一些网络架构搜索的成熟做法,改成两阶段训练:
- 第一阶段:固定生成器的输出为均匀分布,也就是暂时关闭自适应能力,只训练主模型和共享 embedding 库,让它们收敛到一个具备基本表达能力的初始点。
- 第二阶段:放开生成器的梯度,让生成器和主模型联合训练。
第一阶段通常跑两到三个 epoch 就够,不需要完全收敛。第二阶段开始后,生成器基于一个有效的基础表示做维度选择,选择的语义才比较可靠。如果直接从随机初始化就联合训练,生成器很容易陷入“总是选择中间档位”的平庸解,那个解既不是最优容量,也失去了 APG 的价值。
3.3 推理阶段的优化:把参数生成变成查表操作
很多人看完 APG 的结构会担心上线成本,因为每次请求都要跑一遍生成器网络去算 embedding 维度,听起来就很贵。但实际部署时完全不需要这么做。
生成器的输入本质上是特征 ID 和上下文特征。对于大多数 CTR 场景,上下文特征可以离散成有限的状态,比如用户分群、时间槽位、页面位置、商品类目这种组合。在模型更新之后,我可以离线把所有常见特征和上下文组合跑一遍生成器,把生成的维度选择和门控权重存成一个查找表。线上请求进来时,只需要根据特征 ID 和上下文状态去查表,拿到对应的权重或维度索引,然后走主模型推理即可,生成器本身不需要参与在线计算。
这会带来一个工程上的重要简化:生成器不是线上服务的关键路径,不再贡献额外延迟。查表结构的代价是更新频率不足,如果新特征在模型更新后立刻涌入,查找表中没有它的记录,就需要有一个兜底策略。我的做法是给新特征分配一个默认的低容量档位,并维护一个待更新队列,等下一次离线刷表时再补充完整的生成结果。
4. 生产环境落地 APG 的四个真实踩坑记录
4.1 新特征冷启动导致的选维崩溃
上线 APG 后遇到的第一个坑,就是新特征处理。前面提到查找表存在覆盖不足的问题,不过我最初想的兜底方案是用默认低容量档位。结果线上出现了很诡异的现象:一批新上架的商品 ID 在预测时,点击率预估经常走极端,有时接近于零,有时突然冲高。
排查后发现,问题出在生成器对未见过的特征返回了一个无意义的高门控分数——因为生成器训练时从没见过这些新 ID,它的 embedding 是随机初始化的,输出的 logits 方差特别大。某些 ID 不幸落入了高容量档位,而对应 embedding 库中的那个向量还是随机噪声,自然会把预测结果搅乱。
修复方案其实不复杂:在生成器的输入侧做一个“特征存在性”标志,如果特征 ID 不在训练 vocab 中,就强制生成器输出最低容量档位,并且在 embedding 更新时给新特征单独分配一组较小的随机向量。同时,新特征需要积累一定曝光量之后才能进入正常的自适应评估流程。
4.2 长尾特征的门控概率频繁跳变
第二个坑关于长尾特征。低频特征本来就没有足够的训练样本支撑稳定梯度,生成器给它们的门控输出就会在几个容量档位之间来回横跳。这种横跳在离线评估时几乎看不出来,因为 batch 聚合后平均维度依然差不多;但到了线上,同一个特征在相邻请求中的 embedding 选择差异很大,会直接破坏用户点击序列的稳定性。
我的解决办法是给生成器输出加一个滑动平均预测。训练时不用 step 的实时 logits 直接做 Gumbel-Softmax,而是维护一个指数移动平均的概率分布,再用这个平滑后的分布做选择。这个操作会稍微降低 APG 的灵敏度,但换来的是线上行为的稳定性。对于 CTR 预估这种对一致性要求很高的场景,稳定性往往比峰值点击率更重要。
4.3 上下文特征与 ID 特征的交互把生成器带偏了
APG 最大的卖点之一是利用上下文来动态调整参数,但我刚开始把用户实时行为特征、页面场景特征一股脑全部送入生成器时,效果并没有提升。原因在于上下文特征里包含的信息强度比普通 ID 特征高得多,生成器很容易被“用户上一小时点击率很高”这种强信号主导,从而大规模提高所有特征的维度选择,彻底忽略了特征本身的稀疏性。
这暴露了一个设计边界:上下文信息应该是辅助信号,而不是主导信号。我在实践里做了一个修正:给上下文特征进入生成器的通路加上一个降采样和梯度裁剪,限制它对维度选择的边际贡献。理想状态是让上下文只在边界情况下起作用——比如用户状态发生明显变化、当前场景发生切换时,生成器才介入调整;在稳定场景下,生成器更依赖特征本身的统计信息做出选择。
4.4 容量没减少,网络反而更重了
APG 因为引入了一个生成器网络和 K 个 embedding 库,本身是有参数成本的。如果不加控制,会出现一个搞笑的结果:主模型确实通过 APG 把各特征维度压低了,但生成器和 embedding 库加起来的参数量比省下来的还多。
比如原始模型有 10 万个特征,每个特征 8 维,那是 80 万参数。APG 如果设置 K=8 个候选档位,每个档位对应一个 16 维的 embedding 库,这个库就有 8×16=128 个维度参数,但生成器网络如果做得很宽,一层 64 维、一层 32 维的小神经网络也要近一万参数。刚看上去不算多,但如果特征量扩大到 1000 万,共享库的几何级增长就会吞掉所有收益。
应对措施是把 embedding 库本身做成低秩形式:与其存 K 个完整的 16 维向量,不如存一个共享的 4 维基向量和 K 组用于线性组合的系数。这样库参数量从 K×D 降到 K×r + D,r 通常取 2 到 4,既保留不同档位的表达差异,又显著减少内存开销。
5. APG 与 AutoDim、固定分桶方案的取舍对照
5.1 三条路线的本质区别
过去解决“特征 embedding 维度分配”问题主要有两种方案。一种是人工固定分桶,根据频率或业务经验把特征分到几个档位,这种方案简单直接,但需要频繁维护分桶表。另一种是 AutoDim 这类自动化维度搜索方案,通常通过强化学习或架构搜索来寻找每个特征的最优维度,相比人工方案已经自动化了很多,但搜索空间设计得好不好会严重影响效果,而且搜索过程往往要在若干次重训循环里进行。
APG 和这两者的本质区别在于“参数生成”四个字:它不是在一个固定的离散空间里搜索到最优维度就停止,而是在每个样本的推理过程中动态地生成参数组合。换句话说,AutoDim 更像是离线找到一把适合所有样本的通用钥匙;APG 则是随身带了一套钥匙生成器,根据当前开的锁现做一把。
5.2 三种方式怎么选
我把三种方式的适用情况整理成一个对照表,方便你快速判断:
| 方案 | 维护成本 | 在线推理额外开销 | 动态适应新数据 | 适合场景 |
|---|---|---|---|---|
| 人工固定分桶 | 高,需要定期人工更新分桶表 | 几乎为零 | 弱,新特征往往落入错误分桶 | 特征体系非常稳定、业务规则明确的场景 |
| AutoDim 离线搜索 | 中,进行一次搜索训练,之后固定维度 | 较低,维度固定后就是一个普通模型 | 中可以,但每次数据分布变化需重新搜索 | 特征分布相对稳定,但希望摆脱人工分桶的场景 |
| APG 自适应参数生成 | 低,生成器在线学习 | 中,部署为查表结构后接近固定模型 | 强,生成器随训练数据持续更新 | 特征分布变化快、上下文信息丰富的 CTR 场景 |
从上线效果看,APG 比人工分桶更省心的地方在于:特征分布变化不需要人为介入,生成器会自己把新涌现的热门特征引导到高容量档位。和 AutoDim 相比,APG 更有优势的地方在于它能结合上下文信息做动态切换,而 AutoDim 给出的维度通常是全局静态的,面对场景切换比较僵硬。
5.3 什么时候不该上 APG
如果业务规模不大、特征只有几十万且分布非常稳定,直接人工分桶加一个相当 NEC 的静态 embedding,运营成本反而更低。APG 毕竟增加了一个生成器和配套的查找表系统,团队如果缺少做训练链路改造的经验,初期推进成本可能比收益还大。
另外,如果模型原本就使用了非常激进的 embedding 压缩策略,比如所有特征共享一张小表,APG 的收益空间会被明显压缩。APG 更适合那种“每个特征有独立语义表达空间,但特征之间频率差异极大”的场景。判断标准很简单:如果当前模型的内存瓶颈主要来自于特征的独立 embedding 数量,并且你每周都需要手工调整分桶表,APG 就值得认真考虑;如果模型本来就是轻量级共享嵌入,倒不如把精力放在特征筛选上。
6. 从离线实验到全量上线的检查清单
6.1 离线评估阶段至少观察三组指标
引入 APG 之后,不能只用 AUC 一个指标来评价模型好坏。我在项目里会额外关注平均维度、头部特征维度的分布变化,以及线上预估稳定性的模拟指标。平均维度可以从宏观上看容量节省效果,头部特征维度分布则能反映生成器有没有“偏心”——如果所有头部特征维度都远超尾部特征,说明生成器在过度崇拜高频信号。线上预估稳定性的模拟方式,是将同一批次样本在相邻两次模型快照下的输出误差统计出来,误差越大说明生成器的动态变化越剧烈,要警惕后端的查找表更新带来线上行为突变。
6.2 灰度策略:先放宽成本系数,再收紧容量
全量上线前,我建议先用一个较小的 λ 跑灰度。原因前面提到过,λ 太大会导致生成器过度压缩,出现长尾特征全被压到最低容量、头部特征也不得不省着用的局面。从小 λ 开始,观察线上 CTR 是不是持平或提升,内存是不是明显下降,再逐步调高 λ 到目标容量压缩比例。这个过程中,线上 CTR 偶尔下降不超过 1% 是可以接受的,因为容量压缩本身就能带来内存和延迟收益,两者之间需要做一个综合衡量。
6.3 一个简单的调参起点
如果你打算自己试 APG,我提供一个经过验证的初始参数组合:K 取 8,embedding 库的基础维度 D_max 取 32,低秩因子 r 取 3,λ 从 1e-4 开始。训练阶段用 Adam 优化器,学习率 1e-3,生成器分支的学习率可以比主模型略高,比如 3e-3,避免生成器收敛太慢导致整个模型表现像是“没加 APG”。温度 τ 从 0.5 起步,每两个 epoch 衰减一次,最终稳定在 0.1 附近。
我一开始把生成器和主模型用同一个学习率训练,结果生成器学到第十个 epoch 还是像没动一样。后来发现,主模型 embedding 的学习率是 1e-3,而生成器最终只想学习一个概率分布,学习率太小梯度很容易被淹没。把生成器学习率单独调高三倍后,维度选择才开始明显分化,头部特征和长尾特征终于表现出差异。
6.4 上线后的运维要点
APG 上了生产,最需要关注的是查找表刷新的时效性。我一般用离线任务每半小时或每小时刷新一次容量选择表,如果业务对时效性要求更高,可以把刷新周期压缩到分钟级。这里有一个隐含成本:每次刷表都要跑一遍生成器,也就是把当前模型权重导出后批量推理一遍,任务本身不重,但如果特征量大,还是要做好分布式 batch 推理的规划。
另外,当主模型发生版本更新,生成器也需要同步更新。我的建议是把生成器权重和主模型权重打包成一个整体做 AB 实验,不要单独升级其中一个,因为两者的配合是在训练阶段共同优化的,分开部署容易破坏整体效果。
这套方案在我维护的信息流 CTR 项目里跑了半年多,整体容量压缩了大约三成,线上 CTR 基本持平,内存和延迟都有改善。最让我满意的其实不是那些指标,而是它省掉了每周人工调整特征分桶表的重复劳动。把“什么特征重要、给多大容量”这件事交给生成器之后,我才有时间去做更值得做的精细化特征工程和业务分析。如果你也在跟特征 embedding 维度做斗争,APG 值得你花一个迭代周期认真试试。