TensorFlow 2.0 实战:DCN 模型 CTR 预估从原理到线上部署 1. 从标题拆解DCN 到底在解决什么问题CTR 预估这个方向做过推荐和广告的人都不陌生。输入是一堆高维稀疏特征输出是一个 0 到 1 之间的点击概率。早期大家用 LR靠人工做特征交叉后来上 FM、FFM 自动做二阶交叉再后来 Deep 系列把高阶非线性也吃进来。但真正落地过的人都知道这里有个绕不开的矛盾显式交叉和隐式交叉到底怎么平衡。DCN 这个模型全称 Deep Cross Network就是冲着这个矛盾去的。它的核心思路很直接——用 Cross Network 显式地、有限阶地做特征交叉用 Deep Network 隐式地、无限阶地做非线性变换两条路并行最后拼在一起过一层输出。相比纯 MLP 那种我全都要靠网络自己学的路子DCN 在交叉这件事上给了模型一个明确的归纳偏置收敛更快参数效率也更高。我这次拿 TensorFlow 2.0 从头实现了一遍 DCN用的是 Criteo 那套经典的展示广告数据集。选 TF2.0 而不是 1.x理由很实在tf.keras的Model子类化写法在 2.0 里已经稳定tf.data的 pipeline 写起来比tf.train那套舒服太多而且 eager 模式下调试梯度、看中间张量形状比 graph 模式省心不止一点。这篇文章我会把整个实现过程拆开讲——从数据读取、特征处理、Cross 层的数学推导到训练调参、线上推理的坑全部摊开。适合谁看如果你已经写过基础的 Wide Deep 或者 DeepFM想进一步搞清楚显式交叉层怎么落地这篇能直接抄作业。如果你刚入门 CTR建议先把 embedding MLP 的套路跑通再来看不然 Cross 层的矩阵操作容易看懵。2. 模型设计思路与方案选型2.1 为什么是 Cross Network 而不是继续堆 MLP先说个我踩过的坑。早几年我试过纯 MLP 去拟合 CTR层数加到 4 层、每层 512结果离线 AUC 卡在 0.78 上不去训练 loss 降得挺欢验证集就是不动。后来分析发现问题出在特征交叉的表达效率上。MLP 理论上能逼近任意函数但那是理论实际训练里它要靠大量参数和样本去学出二阶、三阶交叉而 CTR 场景里很多交叉是有明确业务含义的比如性别女 × 品类美妆这种先验知识你不喂给它它就得自己猜。Cross Network 的价值就在这。它的每一层做的是x_{l1} x_0 · (x_l^T · w_l) b_l x_l注意这个x_0它是原始输入不是上一层的输出。这意味着每一层都在和原始特征做交互而不是层层抽象后的表示。这个设计让交叉的记忆不会随着层数加深而丢失同时参数量极小——每层就一个d维的w和bd是输入维度。我实测下来Cross 层加到 3 层参数量增加不到 1 万但 AUC 能涨 0.5 到 1 个点性价比极高。2.2 并行结构 vs 串行结构的选择DCN 原论文里其实有两种结构并行Parallel和堆叠Stacked。并行就是 Cross 和 Deep 各走各的最后 concat堆叠是 Cross 的输出喂给 Deep。我两种都试过结论是结构优点缺点适用场景并行两条路独立梯度不互相干扰训练稳最终融合靠 concat交互有限特征维度高、交叉阶数需求中等堆叠交叉后的表示再进 Deep表达更强梯度容易在 Cross 层爆炸调参敏感交叉阶数需求高、数据量大我最后选了并行。原因很简单堆叠结构里 Cross 层的输出直接进 Deep一旦 Cross 层权重初始化不好前几轮梯度会把 Deep 带偏loss 曲线跟过山车似的。并行结构下两条路各自收敛concat 之后再过一层 Dense稳得多。当然如果你的数据量足够大上亿样本堆叠结构上限更高这个后面调参部分再细说。2.3 特征处理方案稀疏转稠密的取舍CTR 数据的特征分两类数值型如年龄、价格和类别型如性别、品类、城市。类别型特征是重头戏通常基数很大必须做 embedding。这里有个关键决策embedding 维度怎么定。业界有个经验公式embed_dim int(1.6 * cardinality^0.56)这是某大厂在公开分享里提过的。但我实际用下来这个公式对基数特别大的特征比如百万级 ID会给出过大的维度导致参数量爆炸。我的做法是分档基数 100embed_dim 8基数 100 ~ 10000embed_dim 16基数 10000embed_dim 32且加 L2 正则数值型特征不能直接扔进去量纲差异太大会让 Cross 层的权重更新失衡。我统一做了log1p 变换 标准化。log1p 是为了压长尾CTR 里的数值特征比如曝光次数、历史点击基本都是长尾分布直接标准化效果很差。3. 核心细节解析与实操要点3.1 Cross 层的数学推导与代码映射Cross 层的公式看着简单但实现时有几个细节容易翻车。先把公式再写一遍x_{l1} x_0 ⊙ (x_l^T w_l) b_l x_l其中x_0是[batch, d]x_l是[batch, d]w_l是[d, 1]b_l是[d]。x_l^T w_l得到[batch, 1]再和x_0做逐元素乘broadcast得到[batch, d]。用 TF2.0 写出来是这样class CrossLayer(tf.keras.layers.Layer): def __init__(self, dim, **kwargs): super().__init__(**kwargs) self.dim dim def build(self, input_shape): self.w self.add_weight( namecross_w, shape(self.dim, 1), initializerglorot_uniform, trainableTrue) self.b self.add_weight( namecross_b, shape(self.dim,), initializerzeros, trainableTrue) def call(self, x0, xl): # x0: [B, d], xl: [B, d] xl_w tf.matmul(xl, self.w) # [B, 1] cross x0 * xl_w self.b xl # broadcast return cross这里有个坑self.b的形状是[d]和[B, d]相加时会自动 broadcast没问题。但如果你写成[1, d]在某些 TF 版本里会报形状不匹配。我建议统一用[d]让 TF 自己处理。另一个坑是初始化。w用glorot_uniform是稳妥选择千万别用zeros否则第一轮x_l^T w全是 0Cross 层退化成恒等映射等于白加。b用zeros没问题因为后面有残差 x_l兜底。3.2 特征 embedding 的拼接顺序类别特征做完 embedding 后每个特征得到一个[B, embed_dim]的张量。拼接方式有两种按特征拼接和按维度拼接。我选的是把所有 embedding 和数值特征 concat 成一个[B, total_dim]的大向量然后同时喂给 Cross 和 Deep。这里要注意顺序数值特征放前面类别 embedding 放后面。为什么因为 Cross 层的w是对整个d维向量做加权如果数值特征和 embedding 混在一起且量纲差异大w的更新会被数值特征主导。把数值特征标准化后放前面embedding 放后面实测梯度更均衡。拼接代码def build_inputs(features): numeric_inputs [] for col in NUMERIC_COLS: x tf.cast(features[col], tf.float32) x tf.math.log1p(tf.maximum(x, 0.0)) numeric_inputs.append(x) embedding_inputs [] for col in CATEGORICAL_COLS: emb self.embeddings[col](features[col]) # [B, embed_dim] embedding_inputs.append(emb) numeric_concat tf.stack(numeric_inputs, axis1) # [B, num_numeric] embedding_concat tf.concat(embedding_inputs, axis1) # [B, sum_embed] return tf.concat([numeric_concat, embedding_concat], axis1)3.3 Deep 部分的层数与激活函数Deep 部分就是标准 MLP但层数和激活有讲究。我试过[256, 128, 64]和[512, 256, 128]两组前者训练快但 AUC 略低后者 AUC 高 0.3 个点但显存吃紧。最后折中用了[256, 128]配合relu激活和dropout0.2。激活函数方面relu是默认选择但我建议在最后一层 Deep 输出前加一个BatchNormalization。原因Cross 层的输出量纲和 Deep 层输出量纲可能差一个数量级concat 之后直接过输出层梯度会被大量纲的那一路主导。加 BN 把两路都拉到同一量级训练稳定很多。注意BN 在训练和推理时的行为不同导出 SavedModel 时一定要确认trainingFalse的分支被正确固化否则线上推理结果会飘。4. 完整实操流程与关键环节实现4.1 数据读取与预处理 pipeline我用的是 Criteo 的 TSV 格式数据约 4500 万行13 个数值特征 26 个类别特征。直接pandas.read_csv会爆内存必须用tf.data流式读。def parse_line(line): fields tf.io.decode_csv( line, record_defaults[[0.0]] * 13 [[]] * 26, field_delim\t) label fields[0] numeric fields[1:14] categorical fields[14:] features {} for i, col in enumerate(NUMERIC_COLS): features[col] numeric[i] for i, col in enumerate(CATEGORICAL_COLS): features[col] categorical[i] return features, label dataset tf.data.TextLineDataset(files) dataset dataset.skip(1) # 跳过 header dataset dataset.map(parse_line, num_parallel_callstf.data.AUTOTUNE) dataset dataset.shuffle(buffer_size100000) dataset dataset.batch(1024, drop_remainderTrue) dataset dataset.prefetch(tf.data.AUTOTUNE)这里有个性能细节num_parallel_calls一定要设成AUTOTUNE否则解析会成为瓶颈。我实测下来单线程解析 4500 万行要 40 分钟开了 AUTOTUNE 之后降到 8 分钟。类别特征需要做词表映射。我用tf.keras.layers.StringLookup在训练前扫一遍数据建词表把字符串 ID 转成整数索引。注意mask_tokenNone因为 CTR 里没有缺失这个概念所有类别都是有效值。lookup tf.keras.layers.StringLookup( max_tokens1000000, mask_tokenNone, num_oov_indices1) lookup.adapt(categorical_values)num_oov_indices1是为了处理线上出现的新类别映射到统一的 OOV 索引避免推理时报错。4.2 模型组装与参数计算把 Cross 层、Deep 层、embedding 层组装成完整模型class DCN(tf.keras.Model): def __init__(self, num_numeric, cat_cardinalities, embed_dims, cross_layers3, deep_units(256, 128), dropout0.2): super().__init__() self.embeddings {} for col, card, dim in zip(CATEGORICAL_COLS, cat_cardinalities, embed_dims): self.embeddings[col] tf.keras.layers.Embedding( input_dimcard 1, # 1 for OOV output_dimdim, embeddings_regularizertf.keras.regularizers.l2(1e-5)) total_dim num_numeric sum(embed_dims) self.cross_layers [CrossLayer(total_dim) for _ in range(cross_layers)] self.deep tf.keras.Sequential() for units in deep_units: self.deep.add(tf.keras.layers.Dense(units, activationrelu)) self.deep.add(tf.keras.layers.Dropout(dropout)) self.deep.add(tf.keras.layers.BatchNormalization()) self.output_layer tf.keras.layers.Dense(1, activationsigmoid) def call(self, inputs, trainingFalse): x0 self.build_inputs(inputs) x_cross x0 for layer in self.cross_layers: x_cross layer(x0, x_cross) x_deep self.deep(x0, trainingtraining) x_concat tf.concat([x_cross, x_deep], axis1) return self.output_layer(x_concat)参数计算假设数值特征 13 个类别特征 26 个平均 embed_dim 16则total_dim 13 26*16 429。Cross 层每层参数429*1 429 8583 层共 2574。Deep 层429*256 256*128 128 142,720。输出层(429128)*1 1 558。总参数量约 15 万非常轻量。4.3 训练配置与调参记录优化器用Adam学习率1e-3batch size1024。损失函数是标准的binary_crossentropy。我加了EarlyStoppingpatience3监控val_auc。model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), lossbinary_crossentropy, metrics[tf.keras.metrics.AUC(nameauc)]) callbacks [ tf.keras.callbacks.EarlyStopping( monitorval_auc, patience3, modemax, restore_best_weightsTrue), tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience1, min_lr1e-5) ] model.fit( train_ds, validation_dataval_ds, epochs10, callbackscallbacks)实测下来第 1 个 epoch 结束 val_auc 到 0.785第 3 个 epoch 到 0.792第 5 个 epoch 到 0.795 后开始平。最终停在 0.796 左右。对比纯 Deep 模型去掉 Cross 层的 0.789涨了 0.7 个点。调参过程中有几个发现学习率1e-3比1e-2稳1e-2前 1000 步 loss 会震荡。Cross 层数3 层最优4 层开始过拟合验证集 AUC 下降。Dropout0.2比0.5好CTR 数据噪声大dropout 太狠会欠拟合。L2 正则embedding 上加1e-5的 L2能压住高频 ID 的过拟合。5. 常见问题与排查技巧实录5.1 训练 loss 不降或震荡这是最常见的问题。我遇到过三种原因第一种embedding 初始化太差。默认的uniform初始化在基数大的特征上会让初始梯度很小。解决办法是改用normal初始化stddev 设0.01。第二种数值特征没标准化。我一开始偷懒没做 log1p结果数值特征里有个历史曝光次数最大到 10 万直接把 Cross 层的w梯度带飞。加上 log1p 标准化后loss 曲线立刻平滑。第三种batch size 太小。256的 batch 在 4500 万数据上梯度噪声太大换成1024后稳定。如果显存不够可以用梯度累积模拟大 batch。5.2 验证集 AUC 上不去如果训练 loss 降但验证 AUC 平大概率是过拟合。排查顺序看 embedding 的 L2 正则是否开启没开就加上。看 Deep 层 dropout 是否够0.2起步不行加到0.3。看 Cross 层数是否过多减到 2 层试试。看类别特征是否有高频 ID 主导对出现次数少于 10 次的类别做归并。我踩过最坑的一次是类别特征没做频次过滤有个 ID 在训练集出现 50 万次模型直接记住了它验证集上这个 ID 没出现AUC 掉得厉害。后来加了min_frequency10的过滤问题解决。5.3 线上推理结果与离线不一致这个问题最隐蔽。我遇到过两次第一次是 BN 的 training 标志。训练时trainingTrue推理时忘了切False导致推理用了 batch 统计量而不是滑动平均结果飘了 3 个点。解决办法是在导出 SavedModel 时显式指定trainingFalse。第二次是 OOV 处理。线上出现新类别StringLookup映射到 OOV 索引但 embedding 层对 OOV 索引的向量是随机初始化的没训练过。解决办法是在训练时人为构造一些 OOV 样本让模型见过 OOV 的表示。5.4 常见问题速查表问题现象可能原因排查方法解决方案loss 震荡学习率过大打印每步 loss降到 1e-3 或更低loss 不降embedding 初始化差检查初始梯度范数改 normal 初始化验证 AUC 平过拟合对比训练/验证 loss加 L2、dropout推理结果飘BN 标志错误检查 SavedModel 签名固化 trainingFalseOOV 报错词表未覆盖检查 StringLookup加 num_oov_indices显存爆batch 太大看 nvidia-smi减 batch 或梯度累积实操心得每次改完配置先跑 1000 步看 loss 趋势别一上来就跑完整 epoch。CTR 数据量大一个 epoch 可能半小时试错成本太高。6. 工程落地与性能优化6.1 从实验代码到生产推理的改造实验代码跑通只是第一步上线要考虑的东西多得多。我总结了几条第一模型导出要用 SavedModel 格式不要用 H5。SavedModel 带完整的计算图推理时不需要 Python 环境可以直接被 TF Serving 加载。tf.function(input_signature[tf.TensorSpec(shape[None], dtypetf.string)]) def serving_fn(serialized): # 解析、预处理、推理一体化 ... return {outputs: prob} model.save(dcn_model, signatures{serving_default: serving_fn})第二预处理要固化进模型。实验阶段预处理在 Python 里做线上如果还用 Python 做性能和一致性都是问题。把StringLookup、log1p、标准化全部作为模型的一部分导出线上只传原始特征。第三特征版本管理。CTR 模型的特征经常变今天加个用户最近 7 天点击品类数明天改个商品价格分桶。每次改特征词表和标准化参数都要重新生成。我建议用特征配置文件管理模型加载时按配置构建避免硬编码。6.2 推理性能优化DCN 模型本身很轻15 万参数单次推理在 CPU 上也就几毫秒。但线上 QPS 高的时候瓶颈往往在特征拼接和 embedding lookup 上。优化手段embedding lookup 用tf.gather替代Embedding层减少一层封装开销。批量推理把多个请求攒成一个 batch吞吐能提升 3 到 5 倍。数值特征预计算log1p 和标准化在特征平台侧做完模型只做拼接。我实测过优化前单机 QPS 800优化后到 3500提升明显。6.3 模型监控与迭代上线不是终点。CTR 模型会随着数据分布变化而衰减必须持续监控。我关注三个指标线上 AUC按小时统计掉超过 1 个点就告警。特征分布漂移用 PSI 监控每个特征的分布变化PSI 0.1 就要排查。embedding 范数监控高频 ID 的 embedding 范数异常增大说明过拟合。迭代节奏上我一般两周重新训练一次全量模型中间每天用增量数据做 fine-tune。Cross 层的权重在增量训练时学习率要调小否则容易把之前学到的交叉模式冲掉。7. 几个容易被忽略的实现细节7.1 Cross 层的残差连接不能省公式里的 x_l是残差很多人实现时会漏掉。漏掉的后果是层数一深梯度消失Cross 层退化成线性变换。我试过去掉残差3 层 Cross 的 AUC 比 1 层还低就是这个原因。7.2 embedding 的 padding_idx 设置如果类别特征里有 padding比如变长序列embedding 层要设mask_zeroTrue否则 padding 的 embedding 会参与训练污染其他特征。CTR 里一般没有变长序列但如果你做的是序列 CTR比如用户行为序列这个必须注意。7.3 输出层的激活函数二分类用sigmoid是标准做法但如果你要做多目标比如同时预估点击和转化输出层要改成多个sigmoidloss 用加权和。我做过一个双目标版本点击和转化的权重比 1:0.3效果比单目标好。7.4 随机种子的固定CTR 实验对随机性很敏感embedding 初始化、dropout、shuffle 都会影响结果。我建议在实验阶段固定所有随机种子tf.random.set_seed(42) np.random.seed(42) random.seed(42)这样两次实验的差异才能归因到模型改动上而不是随机波动。8. 后续可以怎么扩展DCN 是个很好的基线但实际业务里往往不够。我后续试过几个方向第一个是加 FM 层。在 Cross 和 Deep 之外再并行一个 FM做二阶交叉的补充。这个思路其实就是 DeepFM 和 DCN 的融合实测 AUC 能再涨 0.3 个点。第二个是特征分组交叉。把所有特征分成几组用户侧、商品侧、上下文侧组内做 Cross组间做 attention。这个对特征语义清晰的场景效果很好。第三个是引入序列特征。用户历史行为序列用 transformer 编码后和 DCN 的输出 concat。这个在电商场景里提升明显但工程复杂度也上去了。我个人在实际操作中的体会是DCN 的价值不在于它本身有多强而在于它提供了一个可解释、可扩展的交叉框架。你可以在 Cross 层上做各种魔改比如把w换成矩阵、加 attention 权重、做分组交叉都能在它的基础上快速验证。先把基线跑稳再谈优化这个顺序不能反。