做CTR(点击率)预估的同行应该都有一个共同感受:特征交叉是永远绕不开的命题。用户年龄段和商品类目组合起来,往往比单独看任何一个特征都有更强的区分度;但人工去挖这些交叉规律既慢又容易漏,还很难穷举到三阶、四阶的组合。Deep&Cross Network(DCN)恰好就是为解决这个问题而生的——它用一层结构极简的Cross Network显式学习高阶特征交叉,再配一个标准的Deep Network捕捉隐式关系,适合广告、推荐、搜索里绝大多数CTR预估场景。
我第一次完整读DCN的论文(Google 2017年,arXiv:1708.05123)时有点意外:这么出名的模型,核心部分居然只有几行公式。但越往后用越觉得,这种"简单但数学上严谨"的设计才是工业界真正需要的。这篇文章我会把DCN的原理掰开揉碎讲清楚,给出可复现的TensorFlow实现,再分享一些我在实际业务中踩过的坑和排查经验。无论你是刚接触CTR方向的学生,还是在做推荐广告系统的算法工程师,都应该能从中找到有用的东西。
1. 为什么CTR预估需要Deep&Cross Network
1.1 CTR问题的本质和两个核心难点
CTR预估的任务说起来就一句话:给定用户U、物品I和上下文C,预测用户点击的概率。但落到真实数据上,问题立刻变得棘手。
第一个难点是特征极度稀疏。线上CTR特征里,用户ID、商品ID、品牌ID这类离散特征是绝对主力,取值空间动辄百万、千万甚至上亿。做one-hot之后,输入向量维度很高,但每个样本里只有极少数的位置非零。在这种稀疏表达下,模型既要记住大量ID对应的高维向量,又不能因为数据稀疏而欠拟合。
第二个难点是特征交叉的重要性。广告领域最经典的经验之一就是"用户兴趣标签 x 广告行业"远比单独的特征管用。比如一个用户过去一周频繁浏览数码测评,那么"数码类广告"对他的吸引力会明显上升。这种关系不是线性的,而是两个甚至多个特征叠加之后产生的。再往深了说,"新用户 + 低客单价商品 + 晚间时段"这种三阶组合也能预测出比较高的点击可能性。人工做特征工程当然可以做出一部分,但组合空间是指数增长的,靠人和规则根本挖不干净。
传统LR只学习每个特征独立的权重,对交叉无能为力;FM系列能学到二阶交叉,但再往上扩展计算量就上去了。这也是DCN这类深度模型出现的直接原因:让网络自己去学高阶组合,而不是靠人肉枚举。
1.2 Wide&Deep的突破和它的"人工味"
2016年Google提出的Wide&Deep把模型分成两路:Wide侧用LR处理低阶交叉(通常输入手工设计的交叉特征),Deep侧用DNN捕捉隐式高阶特征。这个框架在Google Play等场景效果很好,但用过的人都知道,Wide侧的交叉特征基本还是靠业务经验写的,比如"安装应用 x 用户曝光序列"。业务一变,交叉规则就要跟着改,维护成本很高。
DeepFM算是把"自动交叉"往前推了一步:用FM替换掉Wide侧,实现二阶交叉的自动学习。但DeepFM或者更早的FM,交叉阶数基本被限制在二阶。业务里"用户属性+商品属性+场景属性"叠起来产生高维组合的情况,二阶模型仍然是表达不充分的。DCN的意义就在于,提供了一个不需要人工设计、能自动学到高阶交叉,而且计算开销又非常可控的通用方案。
1.3 DCN的思路:一个"跨界融合"的极简方案
DCN的核心创新是把特征交叉变成了一个可叠加的网络模块——Cross Network。它最大的特点是:每一层的输出都是对输入x0的高阶多项式变换,但每层只引入O(d)个参数,计算代价和线性层几乎一样。而另一边照搬成熟的DNN做深度表达,最后把两条路的输出拼起来做预测。
这个设计最打动我的一点是"显式"。DNN里的高阶特征交互是隐式的,没人能说清第几层第几个神经元到底在交叉哪些特征;而Cross Network在数学上保证了输出是x0各维度的高阶多项式组合,真真切切地在做"交叉"。这一点既让模型有理论保证,也让工程师调试时有抓手。
2. 网络结构深度拆解:Cross和Deep是怎么工作的
2.1 输入层:从稀疏ID到稠密Embedding
CTR模型的输入绝大多数是稀疏categorical特征。以一条广告曝光样本为例,特征可能是:
- 用户特征:user_id、年龄分桶、性别、历史点击类目序列
- 广告特征:ad_id、广告主id、行业id、素材类型
- 上下文特征:小时、星期、设备、网络类型
直接把这些特征one-hot塞进DCN是不现实的——维度太高,而且每个ID变成互相独立的维度,一开始就丢掉了"相似ID应该有相似表达"的信息。所以标准做法是先做embedding查找:每个categorical特征值映射成一个稠密向量,比如维度16或32,然后把所有特征的embedding向量拼接起来,得到一个整体输入x0。
数值特征也在这个阶段统一处理,比如价格取log、时长做z-score归一化。这一步特别重要,我后面会讲为什么它直接影响Cross Network的训练稳定性。简单说,Cross Layer的输出没有上界,如果输入里混着量级相差几十倍的原始数值字段,训练时梯度很容易失控。
2.2 Cross Layer的数学原理
Cross Network的核心计算是这一行公式:
x_{l+1} = x0 * (x_l^T w_l) + b_l + x_l
拆开看很直观:
- x_l^T w_l 是一个标量,等价于对当前状态做一次加权求和
- x0 * 标量 表示用这个标量去"缩放"原始输入
- 再加上偏置b_l和上一步的x_l,形成残差
为什么这样就能表达特征交叉?关键在于x0直接参与每一层的乘法。第0层输入是x0本身,那么x1里带x0各项的一次项和二次项组合;x2里又会在x1的基础上再乘一次x0,于是产生三次项。推下去,L层Cross Network的输出就是x0各维度累乘出来的、最高L+1阶的多项式组合。这正是论文里说的"bounded-degree polynomial approximation"。
还有一个很妙的点:由于每层都有形如"x + 残差"的结构,信息可以从第一层直接流到最后一层,缓解了深层网络常见的梯度衰减问题。
参数量就更值得说一说了。每个Cross Layer只有w(d维)和b(d维),一共2d个参数。3层cross就是6d个参数。对比一下,一个d×d全连接层参数是d²,d=1024时就是100万级别。Cross Layer的参数量只有它的千分之几,但表达的高阶交叉能力却非常强。
需要说明的是,这种参数压缩是有代价的——Cross Network能表达的是"以x0为公因子、逐层加残差"的一类多项式子空间,并不是所有高阶多项式都能覆盖。原始DCN每一层本质上是一个rank-1变换,这也是后来DCN-V2要做改进的动因。
2.3 Deep Network和输出融合
Deep部分就是一个标准的MLP:输入同样是拼接后的稠密特征,经过若干层ReLU全连接层,通常配合BatchNorm和Dropout使用。它的作用是捕捉隐式的、非线性的特征关系。论文里对Deep部分的要求不高,常规2-3层,每层256到1024个神经元已经够用,加太深不仅训练慢,还容易过拟合。
Cross和Deep两个子网络的输出会拼接成一个向量,再过一层线性层加sigmoid得到最终的点击率p。训练时用二分类交叉熵(LogLoss)作为损失函数。这个设计和Wide&Deep的输出结构是一致的,区别只在于把Wide侧换成了Cross Network。
2.4 从DCN到DCN-V2:升级点在哪
DCN-V2是Google在2020年提出的改进版(arXiv:2008.13535),主要解决原始DCN"表达能力受限"的问题。原始版本的w是d维向量,每层本质上是一个rank-1的变换,能表达的交叉模式还是有限。DCN-V2把w升级成d×d的矩阵W,公式变成:
x_{l+1} = x0 ⊙ (W_l x_l + b_l) + x_l
其中⊙是逐元素乘。
这让每一层可以做更细粒度的交互——每个输出维度都能被独立缩放,而不是全部共享同一个标量。代价是参数量从2d升到d²。对线上超大规模模型来说,d²的参数量还是偏贵,所以DCN-V2又提供了低秩分解方案:把W拆成U V^T,其中U、V都是d×k的矩阵,k一般取16到32。实际计算时先算V^T x,再乘U,参数量降到2dk,计算速度也快得多。
我在项目里试过DCN-V2的低秩版本,在同参数预算下确实比原始DCN高出一点AUC,尤其在特征维度较大时优势更明显。不过原始DCN胜在实现简单、调试容易,很多业务里它的收益已经足够。具体怎么选,我放到后面"选型"部分细说。
3. DCN从零实现:TensorFlow代码与实操记录
3.1 数据准备和评估设计
先明确实验目标。我拿一份典型的电商广告CTR数据集来演示:样本是用户在某次曝光中对广告是否点击。字段包括user_id、ad_id、行业、价格分桶、时段等,标签是0/1。
处理流程按这个顺序来做:
- 按时间切分。CTR建模最忌随机切分,因为广告主的投放策略、用户的兴趣都会随时间漂移。我习惯按曝光时间排序,前7天训练、第8天做验证、第9-10天做测试。
- categorical字段做哈希分桶或低频合并。取值超过百万的ID字段用hash bucketing处理,把维度卡在一个上限,避免embedding表无限膨胀;低频值合并成一个"稀有"取值。
- 数值字段做归一化或log变换。价格、点击间隔这类长尾分布明显的字段,先log1p再z-score,能显著提升Cross Network的稳定性。
评估指标上,离线阶段必看AUC和LogLoss。AUC衡量排序能力,LogLoss衡量概率校准度。如果业务要看分组效果,还要算GAUC(按用户分组加权平均AUC)——模型要让每个用户内部的点击优先排前面,而不是只保证全局排序对。
3.2 用Keras手写一个DCN
理解了Cross Layer之后,实现其实很薄。完整代码可以直接拷下来改改特征数量就能用。
import tensorflow as tf from tensorflow.keras import layers class CrossLayer(layers.Layer): """DCN原始版Cross Layer x_{l+1} = x0 * (x_l^T w) + b + x_l """ def __init__(self, **kwargs): super().__init__(**kwargs) def build(self, input_shape): # 输入是 [x0, x],取最后一个维度的长度作为特征维度 dim = input_shape[0][-1] self.w = self.add_weight( name="w", shape=(dim, 1), initializer="glorot_uniform", regularizer=tf.keras.regularizers.l2(1e-5), ) self.b = self.add_weight( name="b", shape=(dim,), initializer="zeros", ) super().build(input_shape) def call(self, inputs): x0, x = inputs xw = tf.matmul(x, self.w) # (batch, 1) return x0 * xw + self.b + x def build_dcn(feature_dim, num_cross_layers=3, deep_units=(256, 128)): inputs = tf.keras.Input(shape=(feature_dim,), name="dense_input") # Cross Network x0 = inputs x = x0 for _ in range(num_cross_layers): x = CrossLayer()([x0, x]) # Deep Network deep = inputs for units in deep_units: deep = layers.Dense(units, activation="relu")(deep) deep = layers.BatchNormalization()(deep) deep = layers.Dropout(0.3)(deep) # 拼接 + 输出 concat = layers.Concatenate()([x, deep]) output = layers.Dense(1, activation="sigmoid")(concat) model = tf.keras.Model(inputs, output) return model几个实现细节值得说明:
- CrossLayer里没有激活函数。这就是设计本意,交叉能力来自x0的反复相乘,强行加ReLU反而会破坏多项式结构。
- w加了L2正则。Cross Layer的输出没有上限,如果w值偏大,x0又比较大,数值会迅速膨胀。L2正则、梯度裁剪、输入特征标准化三件套缺一不可。
- 输入直接给拼接好的稠密向量,省去在模型内部做embedding的复杂度。实践中embedding查表和拼接通常在预处理阶段并行完成,用TF Record存好训练样本,训练时只读稠密向量就行。
如果数据量不大(比如几百万样本),直接用fit也行。但CTR场景样本动辄上亿,我更推荐用tf.data切batch流式喂入,避免内存爆掉。
3.3 超参数怎么定
下面这组参数是我在多个项目里跑出来的起点,不是绝对最优,但基本稳:
| 超参数 | 建议范围 | 我的通常值 |
|---|---|---|
| Embedding维度 | 8-64 | 16 |
| Cross层数 | 1-6 | 3 |
| Deep层数 | 2-3 | 2 |
| Deep神经元数 | 256-1024 | 256, 128 |
| Dropout | 0.1-0.5 | 0.3 |
| 优化器 | Adam/Adagrad | Adam |
| 学习率 | 1e-4 - 1e-3 | 5e-4 |
| Batch size | 512-8192 | 2048 |
| L2正则系数 | 1e-6 - 1e-4 | 1e-5 |
Cross层数我个人很少超过4层。有次我为了追求高阶交叉硬把cross加到6层,离线AUC反而掉了。特征交叉不是层数越多越好,更高阶的组合在数据稀疏时几乎学不到有效信息,白白增加过拟合风险。判断的标准还是验证集AUC,不要被"阶数越高越强"的直觉带偏。
3.4 和几代模型的对比实验
我在一份约500万样本、50个原始特征(拼接后稠密维度约500)的CTR数据上做过一组对照实验,供参考:
| 模型 | AUC | LogLoss | 说明 |
|---|---|---|---|
| LR | 0.762 | 0.1132 | 基线 |
| FM | 0.774 | 0.1101 | 二阶交叉 |
| Wide&Deep | 0.781 | 0.1086 | Wide侧用了3个人工交叉 |
| DeepFM | 0.786 | 0.1078 | FM替换wide部分 |
| DCN (3层cross+2层deep) | 0.789 | 0.1072 | 无人工交叉特征 |
| DCN-V2 low-rank | 0.791 | 0.1069 | rank=16 |
结论很清晰:在这份数据上,DCN以零人工交叉成本追平甚至超过了DeepFM和手工Wide&Deep,DCN-V2再往上抬一点。AUC提升0.003-0.005在学术数据集上看着不大,但换到每天千万级流量的线上,折算成点击量和GMV相当可观。
4. 工程落地:训练、稳定性和线上推理
4.1 让Cross Network稳定训练的几条纪律
先说我踩过的最大一个坑——训练发散。Cross Layer的输出没有上界,一旦w的某些分量和输入值同时偏大,几层下来数值直接爆炸,loss变成NaN。尤其当输入里混着没做标准化的数值特征时(比如价格从几块到几万),几乎必炸。
我后来总结成一条铁律:进Cross Network的向量必须先做标准化或归一化。具体操作是,embedding本身量级受控没问题,数值特征统一log1p+z-score,极端值再做截断。再配合Adam自带的梯度裁剪(clipnorm=1.0)和w的L2正则,稳定训练基本无忧。
BatchNorm在Deep部分照用,但别把它加在Cross Layer内部。Cross层的设计依赖原始的乘加结构,BatchNorm会破坏x0和x_l之间的比例关系,反而让交叉能力被打折。
还有学习率的设置,建议从一个较小的值开始。我习惯用5e-4起步,观察前几个epoch的LogLoss,如果下降太慢可以加速到1e-3,一旦出现震荡就降回5e-4。不要一上来就1e-2,Cross部分的梯度尺度比纯DNN更敏感。
4.2 线上推理:计算量和延迟
DCN的线上serving比很多模型都要友好,原因就是Cross Layer太轻了。一次前向推理,Cross部分就是L次"矩阵乘法+逐元素乘加",计算量约O(Ld),基本可以忽略不计。真正吃资源的是Embedding lookup和Deep部分的矩阵运算。
部署时通常有两条路:
- 如果团队有标准的TensorFlow Serving,直接导出SavedModel就能用。模型结构不复杂,TFServing的batch推理吞吐完全够。
- 如果延迟要求到10毫秒以内,或者想压缩模型,可以把Cross Layer手写成C++实现,真就是几个for循环的事。Deep部分用ONNX或TensorRT加速,embedding表用内存映射分片加载。
有一个容易被忽略的点:把Cross Layer和Deep Layer的权重合在一起做模型量化时,Cross部分的精度敏感度比Deep低很多。我实测用int8量化Deep部分、保留Cross部分float32,AUC几乎不降,但模型体积能压缩一半。这算是个性价比很高的优化手段。
4.3 特征口径和数据漂移
工业CTR建模的另一半工作量在特征侧。DCN对特征交互的学习是自动的,但前提是训练和线上特征口径完全一致。我见过不止一次,训练时用了"下单后"的回看特征,上线后特征管道里取不到这个值,结果线上效果崩掉。
做法上可以用两个手段兜底:
- 特征上线前做离线回放,用线上日志重算一遍特征,和训练时对比分布。
- 对ID类特征做频率统计和生命周期管理,比如7天活跃用户、30天活跃广告,超期的hash分桶自动失效,避免embedding表无脑膨胀之后id碰撞率失控。
数据漂移同样要监控。用户的兴趣分布会随时间变,广告计划也在调整,DCN学到的交叉权重可能过时。常规做法是定期用近N天数据重训模型,并且每天监控线上特征分布和模型AUC,出现明显下滑就触发诊断流程。
5. 常见问题与排查实践
5.1 训练不收敛或loss变NaN
先检查输入数据:数值特征是否标准化?有没有空值和异常大值?再用gradient clipping把梯度范数压到1.0以内。再看Cross Layer的w是否出现很大的L2范数,如果权重爆炸,降低L2正则系数没用的话,就把Cross Layer的输入再做一次scale,通常可以解决。最后,把初始学习率降到1e-4试一轮,逐步往上加。
5.2 Cross部分几乎没效果
这是被问得最多的问题之一。模型整体AUC和纯Deep网络差不多,加不加Cross都一样。我这里有一个经验checklist:
- Embedding维度是否太小?如果只给4或8维,信息容量太小,高阶交叉学不出来。试着提到16或32。
- 输入特征是否都是相关性很低的字段?Cross学的是特征之间乘法关系,如果特征本身和标签的关系都是独立的加法关系,交叉收益自然不明显。这时候该质疑的不是DCN,而是特征质量。
- Cross层数够不够?二阶交叉用1-2层就够,三阶以上需要3层起步。
- 是不是已经接近数据的天花板?如果纯DNN的AUC已经很高,交叉带来的增量空间就有限。换DCN-V2低秩版本再试一次,如果还没有提升,基本可以判定这个场景交叉信号本身较弱。
5.3 DCN、DeepFM、xDeepFM怎么选
这个问题我经常被问到。说下我的个人经验:
| 模型 | 显式交叉阶数 | 交叉单位 | 参数量/计算量 | 适合场景 |
|---|---|---|---|---|
| DeepFM | 二阶 | 向量内积(field-wise) | 低 | 推荐、广告通用基线 |
| DCN | 可到高阶 | 逐位(bit-wise)标量缩放 | 极低 | 组合特征强、强调推理性能 |
| xDeepFM | 可到高阶 | 向量级(vector-wise) | 较高 | 数据集大、追求更高精度上限 |
粗看的话,DCN的交叉是逐位(bit-wise)的,FM系是field-wise的向量交互,xDeepFM的CIN是vector-wise的高阶交互。理论上vector-wise对特征的语义表达更友好,但计算代价也更高。实际业务中,如果资源不是瓶颈、样本上亿,xDeepFM这类复杂模型值得试;如果追求扩展性、低延迟和低维护成本,DCN是性价比之王。
5.4 常见问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| loss变NaN | 输入数值特征未标准化、w爆炸 | 标准化输入、加L2、梯度裁剪、降学习率 |
| 训练慢 | 特征维度太大、batch太小 | 降embedding维度、升batch size、加GPU |
| 过拟合 | 模型太深、数据太少 | 加Dropout、减Cross层/Deep层、早停 |
| 离线AUC高但线上差 | 特征口径不一致、样本选择偏差 | 时间切分、特征回放、线上A/B |
| Cross无收益 | 特征本身无交叉信号、维度太小 | 提embedding维度、加cross层数、换DCN-V2 |
| 延迟超标 | Embedding表过大、Deep矩阵大 | hashing截断、量化、C++手写serving |
5.5 一个小经验:先跑通DCN再换花活
每次团队接入新业务,我的建议都是先上一个结构最简单、训练最稳的DCN做基线,把特征管道、评估体系和上线流程全部打通。很多团队一上来就追SOTA,模型越搞越复杂,最后发现54%的提升来自特征字段的补全和清洗,而不是模型结构。DCN的"简单够用"在这里体现了很大价值:它足够稳,出了问题能快速定位,也为后面的DCN-V2、xDeepFM等复杂模型提供了扎实的对比基线。
最后说点个人的体会。模型选型这件事,我一直信奉先解决有没有,再解决好不好。DCN最让我喜欢的特质是它不折腾:公式简单、实现快、理论清晰、上线稳定。它未必在所有数据上都拿第一,但它能在绝大多数业务里用一个很低的成本把特征交叉这个老大难问题解决掉七八成,剩下的增量再交给更复杂的模型去抠。如果你正在为CTR模型选型拿不定主意,从DeepFM和DCN里挑一个做基线,大概率不会错。
再补充一个平时用得上的小技巧:上线DCN之后,可以在TensorBoard里把不同类目、不同用户的embedding投影出来做可视化,对比Cross Layer前后的分布变化。你会很直观地看到原本分散的点在交叉后逐渐聚成有区分度的簇——这种可视化带来的模型理解,比只看AUC数字要深刻得多。