做了这么多年搜索广告和推荐系统的点击率预估,我最头大的事情之一就是特征工程。早期模型大量依赖人工拼特征组合,比如"性别=男 & 品类=游戏"、"城市=一线 & 时段=晚间"这种二阶甚至三阶交叉,每次上新策略都要靠业务分析和线上AB实验反复验证,费时费力还不一定能找到有效组合。后来读到微软提出的Deep Crossing模型,才发现"自动特征组合"这条路可以直接交给网络自己学,把离散稀疏特征映射成稠密向量后,通过网络深层的非线性变换来自动发现高阶交叉,效果还往往优于手工特征工程。这篇文章就把我对Deep Crossing的理解、复现过程以及落地时踩过的一些坑整理出来,希望能给正在做CTR预估或者推荐召回/排序的读者一些参考。
1. 特征工程的旧时代:为什么"手工拼特征"注定走不通
在讨论Deep Crossing之前,得先搞清楚一个问题:为什么我们这么需要自动特征组合?不是大家不想做特征工程,而是传统方法在特征组合这件事上的天花板太低,低到你只能靠人工去碰运气。
1.1 LR与FM的边界:二阶交互已经是天花板
先看最经典的逻辑回归。LR对每个特征学一个独立权重,预测时把特征加权求和再套sigmoid,本质上是一个线性模型。线性模型的好处是简单、可控、容易解释,但坏处也很明显:它默认特征之间是相互独立的。如果用户"刚看完汽车评测"和"广告位在首页首屏"这两个信号单独看都不强,但组合起来对点击有强拉动,LR永远学不到这个规律,除非你在训练样本里额外构造一个"汽车评测+首页首屏"的交叉特征。
所以传统做法是人工枚举交叉特征。二阶交叉还能枚举,到了三阶、四阶就指数爆炸。而且很多交叉特征在样本里非常稀疏,比如"男性+25岁+科技频道+晚间+"这类深度组合,可能全量样本里只有几百个正例,模型学起来噪声极大。
FM通过隐向量内积实现了自动二阶交叉,这是一个重要突破。每个特征不再是一个标量权重,而是一个k维向量,两个特征的交叉权重由两个向量内积得到。FM能自动学到"哪个组合重要",不需要人工枚举。这里最让我欣赏的是它的工程技巧:FM把二阶交叉参数的从nn压缩到nk,泛化能力也更强。
但FM的边界非常明显:它只能建模二阶交互。你想让它自动发现"性别+品类+时段"三阶组合,它无能为力。虽然理论上可以把二阶项堆成高阶,也就是使用更高阶的交互项,但参数复杂度、训练难度都会失控,实践中极少有团队真的去跑三阶FM。
1.2 GBDT+LR的临时方案与它的先天问题
工业界很长一段时间的主流方案是GBDT+LR,这个组合解决的问题也是"自动特征组合"。原理很巧妙:GBDT每棵树的叶子节点代表一个数据分区路径,训练完成后,把样本落入的叶子节点位置做成one-hot向量,再丢给LR学习权重。相当于GBDT把原始特征空间切成了很多高阶非线性区域,LR负责在这些区域上学习打分。
这个方案在当时算是"用一个模型自动发现高阶组合"的可行路径,我也在不少项目里用过,效果确实比纯LR好。但它有几个先天问题让我始终觉得别扭:
第一,GBDT对超高维稀疏类别特征很不友好。ID类特征比如用户ID、广告ID、商品ID,做one-hot或hash后会形成极稀疏的高维输入,GBDT的树分裂在这个空间里效果很差。所以实际业务里通常只把数值型特征、枚举型特征放进GBDT,而那些最重要的ID类特征还是留给LR单独处理。
第二,GBDT+LR是两阶段串行训练,目标不一致。GBDT训练时以预测类别为准,它内部的叶子划分不一定是为了LR分类最优而生成,再加上GBDT和LR用了不同目标函数,整个系统没有端到端联合优化。
第三,特征组合不可控。GBDT生成的组合数量由叶子节点决定,叶子多了容易过拟合,叶子少了组合能力不够,调起来非常麻烦。而且模型已经生成,你没法判断每个组合的业务含义。
所以我把这套方案归为"过渡产物"。Deep Crossing打动我的一点,就是它彻底放下了"先把特征组合好,再学权重"的思路,把特征Embedding、特征交叉、目标优化全部放进同一个神经网络里,端到端地完成自动特征组合。
2. Deep Crossing的四层结构:把"特征组合"从人肉活变成网络行为
Deep Crossing的网络结构非常简洁,一共就四层:Embedding层、Stacking层、多个Residual Unit组成的残差层,最后是Scoring层。
整个前向过程可以理解为:先把所有高维稀疏特征压成低维稠密向量,拼接成一个长向量,然后通过多层残差网络逐层提炼特征交互,最后一层输出点击率预估值。
2.1 Embedding层:从稀疏one-hot到稠密向量,这里藏着真正的玄机
Embedding层最容易被忽略,因为很多人觉得它只是"降维"或者"查表"。但Deep Crossing里Embedding承担的是语义向量化的任务。
原始输入可能是用户ID、广告ID、创意ID、行业ID、设备类型等类别特征。在做独热之后,每个特征的维度可以是几百万甚至上亿,直接输入深度网络是灾难。Deep Crossing为每个特征类别单独维护一个Embedding矩阵,通过查表把one-hot索引转换成一个固定维度的稠密向量。
这里有一个值得展开的细节:为什么要每个类别单独一个Embedding矩阵,而不是把所有类别特征拼成一个共享词典?比如"用户ID=123"和"广告ID=456"如果放在同一个Embedding空间,学出来的向量会混合两种不同语义,网络无法区分。"用户ID"和"广告ID"本身就是完全不同空间的对象,强制它们共享向量空间会破坏各自的结构。所以每个类别一个独立的Embedding矩阵,让每个ID在自己的空间里单独表达。
Embedding维度怎么选?我的经验是16~32维起步。维度太小表达力不够,学不出复杂语义;维度太大带来过拟合,因为CTR场景的ID类特征请求频次是幂律分布,绝大多数长尾ID在训练中出现的次数很少,给它们太多维度只会让模型去硬记训练样本。另外建议Embedding维度选4的倍数,方便硬件对齐,推理时也能快一些。
训练完成后,Embedding向量本身就是很有价值的副产物。语义相近的广告ID会在向量空间里靠得更近,可以直接拿来做召回的向量化索引。这一点是我在业务落地时才意识到的,Deep Crossing除了解决排序问题,还顺手给了你一套可复用的稠密特征表达。
2.2 Stacking与Residual Unit:为什么非要用"残差"
Stacking层没什么好神秘的,就是把所有经过Embedding的特征向量横向拼接成一个长向量。这里要提醒的是,拼接顺序必须固定,否则线上推理和训练时向量位置的错位会让Embedding查表完全失效。我自己在早期原型里就吃过这个亏,把拼接顺序写死在配置里,才避免线上出大问题。
真正值得深挖的是Residual Unit。Deep Crossing的核心创新其实不在Embedding,而在它把残差网络引入了CTR预估这类稀疏场景。
Residual Unit的基础结构是两层全连接+ReLU,形式上可以写成:
h = ReLU(W2 * ReLU(W1 * x + b1) + b2) y = h + x这里的"y = h + x"就是残差连接的关键。如果没有这条恒等映射路径,多层网络在训练时梯度要逐层回传,层数一多很容易出现梯度消失,CTR数据又是海量稀疏样本,收敛极慢。
加了残差连接后,梯度可以通过恒等路径直接回传到浅层,整个深层网络被拆解成一系列"增量学习"模块。每个残差单元不需要从零学出一个完整映射,只需要在输入基础上学一个小的修正量。这好比做菜时不是每次从洗菜切菜开始,而是在上一道工序基础上加一点调味,最终成品的复杂度由多个模块共同叠加出来。
在CTR场景里,这种增量映射尤其适用。底层Embedding已经包含了单特征语义,残差单元第一层负责在稠密向量上做非线性变换,第二层把变换结果再加回原始向量。堆叠多个残差单元后,网络就能逐步组合出二阶、三阶甚至更高阶的特征交互。读者可以把每个残差单元想象成一个"特征组合机",两个向量在这个单元里被挤压、变换、融合,再把融合结果传给下一个单元。
实验里我用到的通用配置是堆叠2~3个Residual Unit,每个单元隐层宽度256~512。堆太多并不总是好事,这个后面聊过拟合时细说。
2.3 Scoring层与损失设计:点击率预估的最后一步
Scoring层结构上就是一层线性变换,把残差网络输出的深层特征向量映射成一个logit,再经过sigmoid得到点击概率。训练目标用二元交叉熵损失,这也是CTR预估的标配。
端到端的意义在这里真正体现出来。损失从最终输出回传时,每一层的参数,包括Embedding矩阵、残差单元权重,都会向着"降低预测误差"的方向一起更新。手工特征工程时代,你只能在模型外围绕弯去构造特征;Deep Crossing把特征表达和特征组合统一进了同一个目标函数里,这是它和所有两阶段方案的本质区别。
还有一个小细节:Score层之前是否需要额外的全连接层做"过渡"?论文里没有特别强调,我的实践是在残差单元之后通常不再加额外全连接,直接线性映射到1维即可,减少参数,也让残差网络输出的高层语义直接参与决策。模型是否要增加这一层,可以在验证集上看AUC变化来决定。
3. 动手复现:一个可落地的Deep Crossing实现
理论说再多,不如直接跑一个能用的实现。下面我用PyTorch给出一份可以在公开数据集(比如Criteo或者MovieLens转化率数据)上直接跑的代码框架。重点不是把代码贴满,而是把每个环节的关键决策讲清楚。
3.1 数据准备:稀疏特征与连续特征怎么喂进模型
实际数据集里特征一般分两类:类别特征和连续特征。以广告点击日志为例,类别特征包括用户ID、广告ID、素材ID、行业ID、设备型号等;连续特征包括出价、曝光时长、历史点击率等。
我的做法是:
- 类别特征先做ID映射:每个特征维护一个字典,把原始字符串映射成连续整数ID,未出现的值统一映射到0。
- 数值特征做归一化:连续特征用min-max或者log变换压缩到[0,1]区间,避免量纲差异影响网络训练。
- 准备样本时,类别特征保存的是ID整数,而不是one-hot向量;真正转one-hot的工作交给Embedding层完成。
这里有个很关键的工程习惯:在训练开始前,把每个类别特征的词典大小统计出来,因为nn.Embedding初始化需要知道词表大小。如果是全部特征共享一个词表,那就是"共享Embedding"模式;Deep Crossing建议每个类别特征独立词表,所以你需要为每个类别特征单独建一个nn.Embedding。
3.2 核心代码:Residual Unit与整体结构
下面是一个简洁的PyTorch实现,把Deep Crossing缩成几个可以直接改的类。
import torch import torch.nn as nn import torch.nn.functional as F class ResidualUnit(nn.Module): def __init__(self, hidden_units, dropout=0.0): super().__init__() self.fc1 = nn.Linear(hidden_units, hidden_units) self.fc2 = nn.Linear(hidden_units, hidden_units) self.dropout = nn.Dropout(dropout) def forward(self, x): h = F.relu(self.fc1(x)) h = self.fc2(h) h = self.dropout(h) return h + x class DeepCrossing(nn.Module): def __init__(self, feature_dims, embed_dim=32, hidden_units=256, num_residual_units=3, dropout=0.0): super().__init__() # feature_dims: dict,例如 {"user_id": 100000, "ad_id": 50000} self.embeddings = nn.ModuleDict({ name: nn.Embedding(num_embeddings=dim, embedding_dim=embed_dim) for name, dim in feature_dims.items() }) total_dim = len(feature_dims) * embed_dim self.residual_blocks = nn.Sequential(*[ ResidualUnit(hidden_units, dropout) for _ in range(num_residual_units) ]) self.projection = nn.Linear(total_dim, hidden_units) self.scoring = nn.Linear(hidden_units, 1) def forward(self, inputs): # inputs: dict of LongTensor,每个都对应一个类别特征ID embedding_list = [self.embeddings[name](inputs[name]) for name in inputs] stacked = torch.cat(embedding_list, dim=1) x = F.relu(self.projection(stacked)) x = self.residual_blocks(x) logit = self.scoring(x).squeeze(1) return logit有几个地方要特别说明。
第一个是projection层。残差单元要求输入输出维度相等,而Embedding拼接后的维度(比如4个特征每个32维,拼接后128维)通常不等于后面残差单元的宽度256。所以需要先用一个投影层把拼接向量映射到统一宽度,再接残差块。
第二个是残差块内部不降维。我倾向于所有残差单元的隐藏宽度都保持一致,否则残差连接两侧维度对不上,实现起来会很别扭。
第三个是Embedding的初始化。PyTorch默认Embedding初始化是标准正态分布,实际训练中我建议把Embedding参数换成更小的均匀分布初始化,比如uniform_(-0.05, 0.05)。CTR特征ID大量是低频的,初始化分布太宽会导致低频ID的向量在训练初期乱撞,后续很难收敛。
训练循环的代码反而没什么特别,用二元交叉熵加Adam优化器就可以跑起来:
model = DeepCrossing(feature_dims={"user_id": 200000, "ad_id": 80000}, ...) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)3.3 训练细节:优化器、学习率、批量大小
Deep Crossing论文里使用的优化器是Adagrad,初始学习率设在0.01量级。我在自己的数据集上对比过Adam和Adagrad,发现Adam通常收敛更快,尤其是在Embedding参数多、学习率敏感的情况下,Adam的逐参数自适应特性让调参过程更省心。想完全复现论文的读者可以用Adagrad,工程落地用Adam问题不大。
batch size的选择对CTR模型影响很大。我的建议是从1024起步,数据量特别大时可以用4096。batch太小,梯度噪声大,Embedding更新不稳定;batch太大,训练速度慢,且模型容易收敛到尖锐极小值,泛化能力差一点。另外注意训练时的负采样比例,正负样本比例严重失衡会让模型输出的概率整体偏低,统计AUC时还好看,但线上校准就很痛苦。建议训练集内保证正样本比例不低于5%到10%。
训练到最后一个epoch时,不妨把Embedding学习率调低一个量级。这一步对Embedding向量的质量有明显帮助,可以让低频ID在后期微调得更稳定,我当时试了之后AUC有小幅提升,你也可以试试。
4. 实际业务落地时踩过的坑
纸上谈兵阶段一切都很美好,真正放到业务里跑,各种"小问题"就冒出来了。我把自己踩过或者帮朋友排查过的坑,挑几个最典型的写在这里。
4.1 低频特征不收敛:Embedding的冷启动问题
CTR场景最大的特点之一就是幂律分布。大部分用户ID、广告ID出现的次数极少。一个Embedding向量如果只被更新过几十次,基本无法学到有效语义,它相当于一个随机向量,不仅对预测没有帮助,还会给后续网络带来噪声。
我当时在一个广告推荐项目里发现,线上新广告ID带来的曝光很少,离线训练时所有低频广告ID的Embedding几乎都停留在初始值附近。这直接导致模型对新广告毫无区分能力,冷启动阶段AUC下跌明显。
几个实测有效的做法:
- 对出现次数小于阈值的ID做统一替换,并入一个
<UNK>桶,让模型至少学到一个"未知ID"的通用表示。 - 使用特征hash,把原始ID映射到固定大小的hash空间,用hash碰撞来共享参数,适合需要及时上线新ID的广告场景。
- 对低频ID的Embedding做正则约束,例如限制L2范数,避免它们偏离初始值太远。
- 在训练时对ID做概率性dropout,让模型不依赖单个ID,对冷启动也有微弱帮助。
4.2 深层残差网络也会过拟合
第一次把残差单元从2层加到5层时,离线AUC涨了一点,我当时还挺开心,结果上了AB实验,线上实际CTR反而没涨。复盘时才意识到,离线AUC的上涨部分来自模型对训练集里高维稀疏ID组合的"死记硬背",而这种记忆并不能泛化到未见过的新组合上。
控制过拟合的思路,按我的经验排序:
第一,优先减少残差单元层数或隐藏宽度。Deep Crossing本身就不是越深越好,2~3个残差单元已经能覆盖足够高的特征组合阶数,再增加层数只是让模型容量增大。
第二,在Embedding输出上添加Dropout。有论文和工程实践都表明,在Embedding层做dropout能显著提升稀疏模型的泛化能力。我在残差单元内部加了0.1~0.3的dropout,效果比在最后加Dropout更直接。
第三,对Embedding参数做L2约束。这个操作相当于限制了ID向量的范数,尤其是低频ID,能有效减少"记住噪声"。
4.3 线上pipeline与训练pipeline的一致性问题
这个坑几乎每个团队都会遇到。训练时用的特征ID字典是按历史日志构建的,线上推理时会出现训练期间从未见过的ID。如果线上直接把未知ID映射成0(通常对应<UNK>),理论上没问题,但危险的是很多人没有在训练数据里显式构造"未知ID"的样本,导致模型根本没见过这个桶,线上遇到时只能输出随机初始化Embedding对应的结果。
我的建议是:训练时留出一部分最低频的ID,把它们强制替换为<UNK>桶编号,让模型在训练阶段就学会"未知ID该给出什么样的保守预测"。同时,线上与训练共用同一份Embedding表,不要各自维护一份词典,否则一定会出现字典对不上的事故。用版本号管理Embedding表和特征映射配置,线上加载时强制校验版本号,是我目前用过最稳妥的方式。
5. 复盘:Deep Crossing的历史坐标和后来者
很多东西的价值要放到历史坐标里看才清楚。Deep Crossing提出的2016年,深度神经网络在推荐系统领域刚开始大规模出现,之后的FNN、PNN、Wide&Deep、DeepFM等模型一个接一个涌出来。回头看,Deep Crossing最大的贡献未必是它自身的效果,而是它证明了"Embedding+多层非线性+残差结构"这个组合可以在大规模稀疏场景下稳定落地。
5.1 和同期模型的定位差异
为了讲清楚这些模型的区别,我列一张自己整理的对比表:
| 模型 | 特征组合方式 | 显式低阶项 | 关键结构 |
|---|---|---|---|
| Deep Crossing | 深层残差网络自动组合 | 无 | Embedding + 残差单元 |
| FNN | 先FM得到Embedding,再上层DNN | 无(依赖预训练Embedding) | FM初始化Embedding |
| PNN | 在Embedding后加Product层做向量内积/外积 | 有一定二阶显式交叉 | Embedding + Product Layer |
| Wide&Deep | Wide线性部分+Deep隐式交叉 | 有(Wide线性组合) | Wide + Deep双分支 |
| DeepFM | FM捕获低阶,DNN捕获高阶 | 有(FM代替Wide) | FM + Deep,端到端联合训练 |
这张表的读法是:Wide&Deep和DeepFM都保留了显式的低阶特征项,用来弥补深度网络对强记忆信号的弱化;PNN则努力在Embedding向量之间直接引入乘积操作,一步到位建模二阶向量交互;DeepCrossing最"纯粹",它相信只要网络足够深,高阶组合都能自动学出来。
5.2 它的局限
纯粹是把双刃剑。Deep Crossing的局限也很典型。
第一,没有显式的低阶交叉。残差网络虽然能学高阶组合,但低阶的强信号(比如"某广告本身很优质"这种主效应)被淹没在深层非线性变换里,反而没有线性模型那么敏锐。实践中观察到,如果数据集里低阶信号主导,Deep Crossing在AUC上可能只比LR高一点,而DeepFM因为带着FM的低阶项,往往表现更稳健。
第二,Embedding维度成为表达瓶颈。整个模型的信息容量很大程度由各Embedding向量维度决定,而残差单元是在拼接后的长向量上做变换,计算量和参数量都随着特征数量线性膨胀。特征数量一多,训练和推理成本明显上升。
第三,没有注意力机制。今天大家习以为常的"根据用户历史行为动态加权不同特征"的机制,在Deep Crossing里是缺席的。它把每个特征视为同等参与组合,不会刻意强化当前样本更需要哪些特征。
5.3 对今天模型的遗产
尽管Deep Crossing现在看起来结构简单,但它的范式影响力无处不在。把稀疏ID转换为Embedding,再通过多层非线性网络做特征交互,这个过程几乎成为了推荐/广告深度学习模型的"标准底座"。今天的主流模型,要么在这个底座上加了注意力,比如注意力跨网络模型;要么加了序列建模,把行为序列通过GRU/Transformer编码后拼进来;要么加了低阶结构修正,比如DeepFM的FM分支、Wide&Deep的Wide分支。
我自己在后续好几个项目里做模型迭代时,也是以Deep Crossing的Embedding+残差网络为起点,逐步加入行为序列、注意力、多任务塔。这个演进路径说明了它作为"地基"的可靠性:你不需要推翻它,只需要在它之上按场景叠加新机制。
从实践角度看,如果你刚开始尝试自动特征组合,我建议直接复现一个Deep Crossing作为baseline,跑通后再对比DeepFM等进阶模型。这样你能清楚地看到,低阶项、交互项、注意力机制分别带来了多少增量收益,而不是一开始就上一个黑盒大模型。
最后说一点个人体会。如果你刚接触CTR模型,不要急着引入各种花哨的新结构。先把Deep Crossing这类基础模型吃透,理解Embedding、Stacking、残差单元各自到底在解决什么问题,再去看后面的模型就有一种"万变不离其宗"的感觉。我至今仍会在我负责的系统中保留一个Deep Crossing版本作为长期对照模型,用来衡量新增模块的真实效果。它的价值不会随时间褪色,反而会成为你理解整个深度推荐模型演化史的坐标系。