
1. 先聊清楚DCN到底解决了什么问题做CTR预估这些年我见过很多刚入行的同学一上来就抱着DeepFM、DIN、DIEN这些模型猛看却忽略了CTR任务里最核心的一件事——特征交叉。而Deep Cross Network以下简称DCN恰恰是把这个核心问题解决得最干脆的模型之一。CTR预估的全称是Click-Through Rate Prediction它要回答的问题很朴素给用户展示某个商品或广告用户点不点这个问题的答案几乎从来不靠单条特征决定而是靠特征之间的组合信息。举个电商场景的例子用户是“年轻女性”且“最近在搜连衣裙”这个组合下的点击概率比只看“年轻女性”或“最近搜连衣裙”任何一个单点都高得多。这里的“年轻女性 × 连衣裙”就是二阶特征交叉。再往深了说“年轻女性 × 连衣裙 × 早春上新”是更高阶的交叉。特征交叉几乎是CTR预估的全部灵魂。DCN是2017年由Google提出的论文名字叫《Deep Cross Network for Ad Click Predictions》发表时正好赶上Wide Deep和DeepFM这些模型打得火热。Wide Deep的问题在于Wide侧需要人工设计交叉特征工程成本极高DeepFM用FM替代了Wide部分能自动做二阶交叉但对于更高阶的交叉FM就有心无力了。DCN给出了一条更聪明的路用一个专门的Cross Network交叉网络来显式地、自动地学习任意高阶特征交叉同时保留一个Deep Network去学习那些隐式的、非线性的模式。两条子网络一拼接既拿到了自动交叉的能力又保证了模型的拟合能力。这篇文章面向的读者是那些对CTR基础有一定了解、想在工程里落地DCN或者想深入理解DCN原理的人。我会把模型的数学原理、网络结构、PyTorch实现、训练调参的坑一次讲清楚。文里的代码都是可以直接跑的照着抄就能用。2. 网络结构逐层拆解2.1 输入处理Embedding与拼接是怎么做的DCN的输入分两类稀疏特征和稠密特征。稀疏特征指的是像user_id、item_id、城市、品牌这类离散值基数动辄几十万甚至上千万。这类特征不能直接喂给全连接网络必须先做嵌入Embedding。做法很简单每个特征值对应一个向量。假设特征“城市”有1000个取值嵌入维度是8那这1000个取值就对应一个1000×8的嵌入矩阵每次按特征值查表取出一行向量。DCN原论文里的做法是把所有稀疏特征的Embedding向量和所有稠密特征直接拼接在一起得到模型输入向量x0。这个x0是Cross Network和Deep Network共享的输入。嵌入维度怎么定我的经验是8到16维就够用盲目调大维度除了增加参数量和过拟合风险效果几乎不会有明显提升。2018年Facebook发布的那篇关于Embedding的经典文章里也提到过类似结论对于CTR这类高基数稀疏特征低维Embedding已经具备足够的表达能力。另一个经常被忽略的点是稠密特征的预处理。CTR场景里的稠密特征往往量纲差异巨大比如用户历史点击次数可能是几百而用户平均停留时长可能是几十秒。如果不做标准化或归一化Cross Network里那一堆加法和乘法会让数值分布失控。我通常会对稠密特征做z-score标准化或者至少缩放到[-1, 1]区间。2.2 Cross Network每层都在做什么Cross Network是整个DCN的灵魂。它的每一层Cross Layer做的事情可以用一个公式说清楚x_{l1} x0 * (x_l^T * w_l) b_l x_l看着晕拆开看。x_l是当前层的输出向量x0是最初的输入向量重要每一层交叉时都用的是初始x0不是上一层的结果w_l和b_l是该层的参数维度分别是d和dd是x0的维度。这个公式到底在算什么呢关键在于x_l^T * w_l这是一个标量。这个标量再乘以x0等于把x0整体缩放了一下。再加上b_l和x_l就得到了下一层的输出。所以每一层cross layer做的事情本质上是在原始输入向量x0的方向上做一个放缩再加一个偏置最后加一个残差连接保留原信息。用一个不太严谨但特别形象的说法Cross Network每一层都在学习“x0的某个多项式倍数”。若干层堆叠之后模型就具备了拟合任意高阶特征交叉的能力。比如第一层可以学到某个一阶特征第二层能学到两个特征的乘积项第三层能学到三个特征的乘积项以此类推。这背后的数学直觉是如果展开x_{l1}会发现它始终是x0的某个标量倍数加上一堆偏置的累加。也就是说Cross Network每一层的输出向量都在x0张成的方向上变化。这一点和普通的全连接层不同——全连接层每层输出向量可以在整个R^d空间里随便变换方向而Cross Network的输出永远“绕着”x0做文章。这种设计带来的直接好处就是参数效率。一个维度为d的输入每层cross layer只需要2d个参数w_l和b_l各占d个。假设d是10010层也才2000个参数这个参数量几乎可以忽略不计。相比之下两个100维的Deep层之间的全连接权重就有10000个参数。Cross Network用极轻量的参数换来了显式的高阶特征交叉能力这就是它最大的价值。当然它也有局限。2019年Google发表的DCN V2论文里专门分析了这一点原始的DCN在每一层里其实只能学习到x0的标量倍数表达能力受限于这个方向上的变换。DCN V2引入了矩阵形式的参数允许每层在多个方向上做变换表达能力更强但参数量也大得多。工程上你想用原版DCN还是DCN V2取决于特征规模和线上延迟预算。2.3 Deep Network隐式学习非线性表达Deep Network部分没什么神秘就是普通的全连接网络通常2到5层。它负责从输入x0里学习那些深层的、非线性的隐式模式。为什么需要它因为Cross Network虽然擅长特征交叉但它的输出本质上是被限制在x0方向上的变换对于更复杂的非线性映射它的表达能力是不够的。Deep Network就像一个自由发挥的通道让模型不拘泥于交叉这个单一视角。Deep部分的宽度一般取256、512或1024每层之间加ReLU激活函数后面跟BatchNorm尤其是特征分布不均时BatchNorm能显著稳定训练再跟Dropout防止过拟合。这里要注意一个顺序细节Dropout在CTR模型里最好放在激活函数之后同时只在Deep部分做Cross部分不要加Dropout。因为Cross部分的残差结构本身就对噪声敏感加Dropout反而破坏特征交叉的连续性。我在早期踩过这个坑给Cross部分加了0.5的DropoutAUC直接掉了0.3个百分点。2.4 组合层两个子网络是怎么融合的Cross Network和Deep Network的输出向量在组合层做拼接拼接后的向量经过一个全连接层映射到1维logit再套一个sigmoid就能得到点击概率。训练时的loss是交叉熵这就不用多说了。这里有个值得聊的点两个子网络的输出维度可能差别很大。比如Cross部分输出维度是输入的特征维度可能几百而Deep部分输出维度是最后一层宽度比如1024拼接后Deep部分占主导。要不要给Cross部分更高的权重我实际测下来的结果是不需要人为设置权重只要输入x0的特征维度设计合理模型自己会学会平衡两者。但有一种情况要留意如果特征维度特别低比如只有几十维Cross部分的表达能力会偏弱这时可以考虑加宽输入特征或者换成DCN V2。3. 从零实现PyTorch代码与踩坑记录3.1 网络整体封装一个可直接复用的DCN类光看结构不算完上代码才是正经。我用PyTorch写了一个完整的DCN实现注释都写在关键位置你复制下来就能用。import torch import torch.nn as nn import torch.nn.functional as F class CrossLayer(nn.Module): DCN单层交叉层实现 def __init__(self, input_dim): super().__init__() self.w nn.Parameter(torch.nn.init.xavier_normal_( torch.empty(input_dim))) self.b nn.Parameter(torch.zeros(input_dim)) def forward(self, x0, xl): # x0: 初始输入 [batch, dim] # xl: 当前层输出 [batch, dim] # torch.dot 计算标量 xl^T * w xlw torch.matmul(xl, self.w) # [batch] cross x0 * xlw.unsqueeze(1) self.b # 广播得到 [batch, dim] return xl cross # 残差连接 class DCN(nn.Module): Deep Cross Network 参数: sparse_feat_cardinalities: list[int]每个稀疏特征的取值个数 sparse_feat_dim: int稀疏特征统一嵌入维度 dense_feat_dim: int稠密特征个数 cross_layers: intCross Network层数 deep_layers: list[int]Deep Network每层宽度 dropout: floatDeep侧Dropout比率 def __init__(self, sparse_feat_cardinalitiesNone, sparse_feat_dim8, dense_feat_dim0, cross_layers4, deep_layers(512, 256, 128), dropout0.3): super().__init__() self.sparse_feat_cardinalities sparse_feat_cardinalities or [] self.dense_feat_dim dense_feat_dim # 稀疏特征嵌入层 self.embeddings nn.ModuleList([ nn.Embedding(num_embeddingscard, embedding_dimsparse_feat_dim) for card in self.sparse_feat_cardinalities ]) input_dim len(self.sparse_feat_cardinalities) * sparse_feat_dim dense_feat_dim self.input_dim input_dim # Cross部分 self.cross_layers nn.ModuleList([ CrossLayer(input_dim) for _ in range(cross_layers) ]) # Deep部分 deep_layers_list [] in_dim input_dim for out_dim in deep_layers: deep_layers_list.append(nn.Linear(in_dim, out_dim)) deep_layers_list.append(nn.BatchNorm1d(out_dim)) deep_layers_list.append(nn.ReLU()) deep_layers_list.append(nn.Dropout(dropout)) in_dim out_dim self.deep_layers nn.Sequential(*deep_layers_list) # 输出层 self.output_layer nn.Linear(in_dim input_dim, 1) def forward(self, sparse_x, dense_xNone): # sparse_x: [batch, num_sparse_feats] LongTensor # dense_x: [batch, dense_feat_dim] FloatTensor可为None if len(self.embeddings) 0: sparse_embs [emb(sparse_x[:, i]) for i, emb in enumerate(self.embeddings)] sparse_vec torch.cat(sparse_embs, dim1) else: sparse_vec torch.empty(sparse_x.size(0), 0, devicesparse_x.device) if dense_x is not None: x0 torch.cat([sparse_vec, dense_x], dim1) else: x0 sparse_vec # Cross Network xl x0 for cross_layer in self.cross_layers: xl cross_layer(x0, xl) cross_out xl # Deep Network deep_out self.deep_layers(x0) # 拼接并预测 combined torch.cat([cross_out, deep_out], dim1) logit self.output_layer(combined) return logit.squeeze(-1)3.2 稀疏特征与稠密特征的输入组织实现里最容易被忽略的是输入的处理方式。我见过不少同学把稀疏特征当成稠密特征直接喂或者把稠密特征也硬塞进Embedding层结果模型效果惨不忍睹。我的标准做法是对于稀疏特征先用pandas或SQL按特征值做编号。注意这个编号必须从0开始自增对应nn.Embedding的索引而且一定不能有空洞。如果编号有洞Embedding层的num_embeddings会比你实际需要的多造成参数浪费和随机初始化噪声更严重的是如果某个特征值的编号超过了num_embeddings范围程序直接崩。如果原始特征基数特别大比如user_id有几千万个直接用nn.Embedding会吃掉恐怖的显存。这时候我推荐两种降基数的策略一是把出现次数少于某个阈值的特征值统一合并成“unknown”这步在数据处理阶段做二是对超高基数的ID类特征做hash trick把原始特征值hash到固定规模的空间里用空间换覆盖度。这两种做法在工业界都很常见。稠密特征则是直接拼在稀疏Embedding后面。要在喂进模型之前做好标准化和缺失值填充。缺失值填充我建议用中位数而不是均值均值容易受极端值影响。如果缺失比例很高那不如再加一个0/1的缺失指示特征让模型自己判断。3.3 训练循环、损失函数与评估指标模型定义好了训练循环这块也有几个细节值得展开。CTR场景下正负样本极度不平衡点击率常有1%甚至更低所以评估指标要看AUC而不是Accuracy。AUC对样本不均衡不敏感能反映模型把正样本排到负样本前面的能力。我训练DCN的固定套路如下稳定复现了很多次model DCN( sparse_feat_cardinalities[10000, 10000, 500, 20000, 50], sparse_feat_dim8, dense_feat_dim12, cross_layers4, deep_layers(512, 256, 128), dropout0.3 ) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-5) criterion nn.BCEWithLogitsLoss() # logit直接输入内部套sigmoid for epoch in range(20): for sparse_batch, dense_batch, labels in train_dataloader: optimizer.zero_grad() logits model(sparse_batch, dense_batch) loss criterion(logits, labels) loss.backward() optimizer.step() # 可选梯度裁剪防止出现极端大梯度 torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)有几个值得注意的细节学习率1e-3配合Adam在大多数场景下都够用。如果发现训练震荡把学习率降到3e-4或1e-4就稳了不要一上来就搞ReduceLROnPlateau那些花活。weight_decay我一般加到1e-5到1e-4之间。CTR模型特征维度高容易过拟合适度的L2正则比Dropout更管用。但别加太大不然模型会欠拟合。Batch Size建议128到512之间。如果服务器显存够1024也能跑。CTR场景下Batch Size太小的化梯度估计方差大训练不稳。训练集规模比较大的化一般15到25个epoch就能收敛。我之前在几千万样本的公开数据集上做实验第8到12个epoch之后AUC基本持平。再用Early Stopping机制卡AUC不要傻傻跑满固定轮数。3.4 从数据到模型完整实操流程纸上谈兵没意思我顺着一个真实的数据流把流程串一遍。假设我们有的原始数据长这样user_iditem_id城市用户性别商品价格曝光位置labelU1001I9988北京女199.031U1002I1234上海男59.910第一步是特征工程。user_id、item_id、城市、用户性别、曝光位置这些都是稀疏类别特征分别做编号映射。价格是稠密特征标准化处理。label就是点击与否的0/1标签。第二步是搭Dataloader。我习惯把稀疏特征拼成一个LongTensor稠密特征拼成一个FloatTensor让模型分别接收。这篇文章不展开数据加载细节核心是DCN的forward逻辑——稀疏特征通过Embedding表查询稠密特征直接进入后续拼接两条路互不干扰。第三步是训练与评估。我上面贴的代码可以直接跑。训练完成后保存模型权重线上推理时只需要加载pkl文件Embedding查表、Cross与Deep计算、sigmoid输出的全过程延迟在CPU上通常不到5毫秒足够应付绝大多数实时推荐接口的要求。4. 工程实践与调参心得4.1 超参配置的经验值照着抄也行先说结论这些是我在多个数据集上验证过的起点配置绝大多数情况下能直接出不错的效果。Cross层数3到6层。我测过1层效果弱到10层收益渐小、训练变慢在多数公开数据集上4层左右就到瓶颈了。不要迷信“层数越深越强”Cross Network的设计是逐层扩展交叉阶数的超过一定阶数后高阶交叉带来的信息增量微乎其微反而容易引入噪声。Deep层数和宽度2层(128, 64)到4层(1024, 512, 256, 128)都见过。我一般从3层(512, 256, 128)起步。Deep部分说白了是让模型有足够的拟合能力去学那些Cross学不了的复杂模式但也不是越宽越好太宽了参数量飙升、过拟合风险大。嵌入维度8到16。我之前做过一组对比实验8维、16维、32维嵌入在基础特征组合下的AUC几乎没差别。真正拉开差距的是特征工程和数据质量不是嵌入维度。Dropout0.2到0.4我默认0.3。搭配BatchNorm一起用Deep部分每层是Lin → BN → ReLU → Dropout的顺序这个顺序我验证过比较稳。也有人把Dropout放在线性层之前效果差不多但BN之后接Dropout更符合直觉。Batch Size128到512。GB级别的内存随便跑注意统一用float32不要用float64否则训练慢一倍不止。优化器Adamlr1e-3。如果你发现训练后期AUC还在一路缓慢爬升但局部有抖动可以降到5e-4继续跑10个epoch。SGD虽然理论收敛性好但CTR场景特征海量、稀疏用SGD调参成本太高不推荐。4.2 特征工程决定AUC上限的地方模型结构再花哨也救不了糟糕的特征工程。这句话我在很多文章里写过但每次都要拿出来强调一遍。DCN虽然能做自动特征交叉但也有前提——特征得先进模型。比如“用户性别”和“商品品类”都是可用的类别特征但它们的Embedding是模型自己学的学习信号完全来自训练数据。如果某个人群的行为数据特别稀疏模型很难学到有效的Embedding表示。所以落地时我会额外做几类特征工程把先验知识揉进去基于业务的统计特征。比如用户在过去7天的点击率、商品在过去24小时的曝光量、用户与该商品类目的历史交互次数。这类特征自带业务含义模型学起来比纯ID特征快得多。简单的人工交叉特征。比如“用户性别_商品品类”拼接成一个新ID或者“用户年龄段_城市”拼接成新ID。有人觉得DCN既然能自动交叉就不用手动交叉了这个想法不完全对。自动交叉是在Embedding空间的隐式交互表现力确实强但手工交叉能把先验知识显式地喂给模型两者并不冲突。用户行为序列特征。如果你的数据里有用户行为序列别浪费先做一个行为序列Embedding的均值池化或注意力池化把压缩后的行为向量作为稠密特征喂进DCN。这是DCN家族模型在真实业务里涨点最明显的技巧之一。特征交叉的有效性高度依赖特征设计。DCN里的Cross Network虽然会自动交叉但如果输入特征本身质量差交叉出来的结果也不会好到哪里去。4.3 训练与评估里容易踩的坑先说说训练稳定性。Cross Network层数偏深时会出现梯度爆炸的问题。因为残差连接会让梯度一路回传而不衰减x0本身数值要是偏大乘以标量之后再叠加数值就可能指数级膨胀。我的解决办法是把稠密特征严格标准化同时给模型加个梯度裁剪设置max_norm5.0。还有个取巧的办法是给Cross层参数加一个可学习的缩放有点类似LayerNorm的思路但不用那么重直接在CrossLayer里对输出做一次0.1倍缩放就行——我试过2行代码就能让深层训练稳定不少。再说评估指标的理解。CTR领域标准指标是AUC但AUC本身也有局限。它衡量的是排序能力不是绝对概率准确性。比如预测的用户A点击概率是0.6实际是0.9只要排序没乱AUC依然很高。所以如果你需要校准概率比如做竞价排序建议再单独看LogLoss或做Platt Scaling、Isotonic Regression这类概率校准。我在业务中就遇到过AUC刷得很高但实际出价效率一般的情况最后定位到是概率未校准的问题。另外一个容易忽略的问题是样本延迟。线上点击日志最快要几十分钟才能回流到训练数据里如果做实时训练样本分布和标签本身就有时序上的偏差。我之前踩过一次坑把当天前6小时的曝光当作正样本直接标记为label0假设没点击结果大量“还没到点击时间”的样本被当成负样本模型在线下AUC好看线上一上就崩。这种问题需要用样本延迟建模或者至少做时间窗口切分来缓解。5. 常见问题与排查技巧实录5.1 问题速查表我整理了DCN训练与在线推理中最常遇到的几个问题以及对应的排查思路。现象可能原因排查方向Loss长时间不降或卡在0.693附近特征预处理出错、Embedding没收敛、正负样本比例失衡先检查特征拼接逻辑是否正确再确认正负样本比例必要时修改loss权重训练震荡AUC忽高忽低学习率过大、Batch Size太小、稠密特征未标准化降低学习率到3e-4增大Batch Size检查特征数值范围训练AUC很高线上下降明显特征穿越用了未来信息、线上与线下特征不一致排查特征生成时间确认线上特征服务逻辑是否与训练时一致参数量爆炸显存不足稀疏特征基数过大、Embedding维度过高做低频特征合并、Hash Trick降低嵌入维度Deep部分不收敛Cross正常Deep网络过深导致梯度弥散、初始值不佳减少Deep层数加深BatchNorm或换残差结构5.2 几个值得展开的典型问题Loss卡在0.693这个现象我见过太多次了。0.693就是二分交叉熵在完全随机猜测时的loss值-ln(0.5)就是0.693。如果你的loss一直贴着这个数不动说明模型什么都没学到。最常见的元凶是特征索引对不上。比如Embedding是按0到9999编号的但特征值编号里跳过了5000这个索引或者把未登录特征塞给了索引10000那么模型在训练时有一部分Embedding参数永远收不到梯度另一部分一直在乱学。排查方法是把训练数据里特征值的min和max打出来跟Embedding的num_embeddings对一遍看有没有越界的。我早期就遇到过类似问题找了半天才发现是某列特征处理后没有重新map编号导致索引不连续。还有一类高频问题是“加了DCN之后效果反而不如纯Deep”。这种情况通常发生在特征交叉信息本身就很弱的场景。比如你的特征基本都是统计类稠密特征彼此之间几乎没有可交叉的维度那Cross Network学出来的多项式项无非就是这些统计量的互相缩放信息增益有限。这时候硬上DCN不仅没收益还多了一堆参数要调。我的建议是先跑一版纯Deep做基线再对比加了Cross部分的提升幅度。如果提升不到0.1个百分点的AUC那说明特征交叉的空间不大与其死磕模型结构不如回去做特征工程。5.3 线上推理的延迟与稳定性优化DCN在线推理最需要注意的瓶颈不是计算量而是Embedding的存储访问。稀疏类别特征规模一大Embedding表就是几个GB级别如果线上每个请求都查全量表内存带宽很快成为瓶颈。我自己实践下来可行的方案是把高频特征的Embedding直接驻留内存用哈希表索引低频特征走KV存储或者降级为一个统一的unknown向量。这样既能保证绝大多数请求的低延迟也能控制内存占用。另一个稳定性问题新出现特征怎么处理。线上用户和商品是不断新增的新特征在训练Embedding里不存在直接查表会越界。我的方法是把所有未登录特征映射到一个统一的unknown_id模型对这类特征统一学习一个嵌入向量。等积累了一定量的新特征数据后再定期重新训练模型并更新Embedding表。这个方案线下线上都验证过效果稳。6. 写在最后的实操体会DCN这个模型要说更新迭代这几年已经被DIN、DIEN、SIM这些更复杂的模型抢了不少风头但从工程落地的角度我依然觉得DCN是性价比非常高的选择。它的核心优势在于Cross Network那套显式特征交叉的思路——参数量极小、结构简单、效果可预期。在预算有限、特征规模中等的团队DCN完全可以作为主力模型跑很久。我个人的经验是想用好DCN不要一上来就去改网络结构。先把特征工程做扎实把数据质量管好再用默认参数跑出一版清晰的baseline。然后在这个基础上一个一个地方去尝试调整Cross层数、Deep宽度、嵌入维度、正则策略。每次只动一个变量用AUC和LogLoss看效果。不要同时把好几个变量一起改出了问题根本定位不了是谁干的。最后分享一个我常用的trick如果你做了多次实验发现Cross部分收益不明显可以先试试把Deep部分去掉只看Cross。如果Cross单跑的AUC已经接近完整模型的九成说明特征交叉才是你业务的主要矛盾如果Cross单跑效果很差那问题多半出在特征表达本身模型结构再调也白搭。先做这一步诊断再决定后续方向能少走很多弯路。