资讯动态

TensorFlow 2.0实战:WideDeep模型原理与代码实现

发布时间:2026/10/16 17:37:14 来源:尧图企业网站定制
1. 从逻辑回归到 widedeep为什么需要两条腿走路先说一个我自己折腾出来的领悟。早期做点击率预估的时候我用过纯 LR、纯 FM、纯 DNN 这几套方案效果各有瓶颈。纯 LR 靠人工特征工程能保留记忆能力但泛化全靠人肉组合维护成本极高纯 DNN 端到端学特征交互泛化能力强但对稀疏 id 类特征容易学飞出现过拟合和记忆不足的问题。后来接触到 widedeep我的第一反应就是这不就是把线性模型和深度模型拼一起嘛能有多大区别真正在 TensorFlow 2.0 里实现之后才意识到它的价值不在于拼接这个动作而在于它同时解决了两类根本不同的学习目标——记忆memorization和泛化generalization这正是本篇文章要聊的东西。这篇文章适合谁我觉得是两类人一类是已经用 TensorFlow 2.0 做过基础模型但还没有系统性接触过推荐/排序模型结构的同学另一类是正在做点击率预估、搜索排序这类任务想知道 widedeep 在 TensorFlow 2.0 里到底怎么落地、有哪些坑的从业者。我会从原理、架构、代码实现、训练技巧几个角度完整展开路过的基础概念也会顺手解释清楚尽量让你读完之后能直接照着写一个可用的模型出来。所谓记忆指的是模型能直接从历史数据里学到某个特征组合出现时结果常常是什么。最典型的就是协同过滤里的共现关系比如用户 A 点过物品 X同时用户 B 也点过物品 X那用户 A 和用户 B 的相似性就是一条记忆。所谓泛化指的是模型能从已有特征中推断出没见过的新组合比如从来没有出现过年轻男性数码产品这条样本但模型根据年轻和数码各自的表征能推测出这条组合大概率是值得推荐的。wide 部分擅长记忆因为它本质是线性模型对稀疏特征的权重学习非常直接deep 部分擅长泛化因为它通过 embedding 把离散特征映射到稠密向量在向量空间里做非线性组合。我在实际项目中最直观的感受是只靠 wide 容易死记硬背特征组合爆炸之后人力根本维护不过来只靠 deep 容易自作聪明对一些低频但高置信的记录反而学得不够死。widedeep 把两者的输出叠加让模型自己决定每个样本该偏重记忆还是偏重泛化这个自己决定的过程就是联合训练。这也是它和单纯的特征拼接后喂给一个模型最本质的区别。2. 模型结构拆解wide 端、deep 端、以及它们是怎么融合的2.1 wide 端线性模型的极限在哪wide 端在原始论文里就是广义线性模型形式是 y w^T x b其中 x 通常包含原始特征和交叉特征。交叉特征在经典实现里是特征叉积cross product transformation比如特征 A 取值为北京特征 B 取值为男性那么交叉特征就是北京男性这个组合是否出现稀疏 id 类特征之间做叉积会产生大量稀疏二元特征这在线性模型里就是天然的记忆。在 TensorFlow 2.0 里最省事的做法是用tf.feature_column.crossed_column它会帮你做哈希交叉避免手工枚举所有组合。需要注意的一点是crossed_column 的 hash_bucket_size 需要根据实际特征基数来定设太小会导致不同组合被哈希到同一个桶里产生碰撞影响记忆的精确性设太大则会浪费内存。我的经验是先用一个偏大的值跑一版观察特征重要性和 loss 收敛情况再调整不要让哈希碰撞成为 wide 端效果差的原因。wide 端的另一个细节是它不需要 embedding特征直接以稀疏方式参与线性计算。这也是它在工业界能落地的关键——稀疏特征不需要全部转成稠密向量存储和计算开销都比较可控。但也正因为如此wide 端的表达能力有限它只能学到某个组合是否存在对结果的影响学不到组合之间的相似性。比如北京男性和上海男性在 wide 端眼里是完全独立的两个特征它们不会有任何参数共享。这个特性决定了 wide 端适合承担精确记忆的角色但不可能独立完成泛化。2.2 deep 端embedding 与多层非线性变换deep 端做的事情是把每个离散特征映射成一个低维稠密向量embedding然后把所有特征的 embedding 拼接在一起作为多层神经网络的输入。这里的核心思想是相似的实体在 embedding 空间里距离更近因此模型可以学会见过的组合去推断没见过的组合。具体到 TensorFlow 2.0 的实现可以用tf.feature_column.embedding_column把 categorical_column 转成 embedding然后用tf.keras.layers.DenseFeatures统一处理特征列再接几个全连接层。这里有一个非常容易踩的坑embedding 维度怎么选。特征基数大、样本量大的场景embedding 维度可以适当高一些比如 64 或 128但也不是越高越好。维度太高会导致参数量爆炸而且在小数据集上容易过拟合维度太低则表达力不足。我一般遵循的经验是先按特征基数的开方左右设置跑通之后再根据验证集指标微调而不是一开始就拍脑袋定一个固定值。deep 端的网络层数同样有讲究。原始论文用的是 3 层隐藏层每层 1024 个神经元但那是针对 Google Play 的应用商店推荐场景。实际使用中如果你只有几百万样本直接照搬 1024 层宽的网络很容易过拟合。一个更稳妥的做法是从 3 层、每层 256-512 开始配合 dropout 和早停观察训练集和验证集的差距来决定是否增加容量。别忘了在最后一层隐藏层和输出层之间加上 BatchNormalization 或者层归一化否则深层网络在稀疏特征输入下容易训练不稳定。2.3 融合层joint training 与 sum 输出的玄机wide 端和 deep 端最终怎么合到一起原始论文的做法是把 wide 端的输出一个标量和 deep 端最后一层隐藏层的输出一个向量拼接然后接一个线性层得到最终 logit再经过 sigmoid 得到概率。TensorFlow 2.0 里可以用tf.keras.layers.concatenate实现拼接也可以用更简洁的tf.keras.layers.Add直接对两个输出做求和。具体采用哪种方式取决于你是否希望 deep 端最后一层的维度是 1。我在实践中更常用的是拼接后接线性层因为这样模型能有更多的参数来决定 wide 端和 deep 端各自的权重而不是简单相加。不过要注意wide 端输出是一个标量deep 端输出如果是 512 维的向量拼接后维度是 513再接一个线性层输出 1 维 logit。这比直接 Add 多了一些可学习参数但也更灵活。所谓 joint training联合训练指的是 wide 端和 deep 端在同一个损失函数下、同一个优化器里同步更新。这和 pipeline 式的两阶段训练完全不同——wide 端和 deep 端不是分开训练的而是它们的梯度在反向传播时同时更新各自参数。这样做的意义在于wide 端可以在训练过程中感知到 deep 端哪些地方泛化得不够好从而加强记忆deep 端也可以感知到 wide 端哪些地方记忆得过于死板从而尝试泛化。这个互相纠错的机制是 widedeep 能同时兼顾记忆和泛化的关键。2.4 从原始论文到 TensorFlow 2.0 的映射关系为了让你对论文和代码之间的对应关系更清楚我整理了一个简表原始论文概念TensorFlow 2.0 实现方式备注wide 端的交叉特征tf.feature_column.crossed_column哈希交叉需设置 hash_bucket_sizewide 端的连续特征直接作为 numeric_column 输入可以做 bucketized 分桶deep 端的 embeddingtf.feature_column.embedding_column由 categorical_column 转换而来deep 端的多层网络tf.keras.Sequential或自定义tf.keras.Model推荐自定义 Model方便融合融合层tf.keras.layers.concatenatetf.keras.layers.Dense(1)也可以直接 Add损失函数tf.keras.losses.BinaryCrossentropy二分类场景优化器tf.keras.optimizers.Adam或Ftrl推荐 wide 端用 Ftrldeep 端用 Adam这里多说一句优化器的选择。原始论文在 wide 端使用 FTRL在 deep 端使用 AdaGrad目的是让 wide 端这种稀疏线性模型在大量稀疏特征下更快收敛、同时保持稀疏性。TensorFlow 2.0 里你可以用一个优化器训整个模型但如果你想更贴近论文效果可以用两个优化器分别更新 wide 端和 deep 端的参数这在 TensorFlow 2.0 里实现起来稍微麻烦一点需要手动控制tf.GradientTape的变量列表但效果确实更好。后面我会给出一份单优化器的简洁实现再补充说明双优化器的改法。3. 环境的准备TensorFlow 2.0 版本与依赖的关键点3.1 版本选择与安装TensorFlow 2.0 已经是很成熟的版本了但我还是建议在动手前确认一下你的环境。这里说的TensorFlow 2.0在实际使用中我一般指tensorflow2.x的某个具体小版本因为 2.0 到 2.16 之间 API 变动比较大特别是 feature_column 和 keras 的交互方式。我的建议是选择 2.10 左右的稳定版本因为这是最后一个对 feature_column 支持比较完善的版本区间而且在大多数平台上都能顺利安装。安装命令很简单pip install tensorflow2.10如果你用的是 GPU 版本需要额外留意 CUDA 和 cuDNN 的版本匹配。我踩过的坑是在 Windows 上装了 GPU 版 TensorFlow结果加载模型的时候报cudart64_110.dll not found折腾了半天发现是 CUDA 版本不对。建议直接用tensorflow[and-cuda]这种打包方式或者严格参照官方版本对应表不要自行猜测 CUDA 版本。3.2 feature_column 在 2.x 版本中的变化feature_column 在 TensorFlow 2.x 里经历了不少变化其中最影响 widedeep 实现的是tf.feature_column.crossed_column的输入格式。在 2.0 早期版本里crossed_column 可以直接接受两个 categorical_column但后来某些版本要求先通过tf.feature_column.categorical_column_with_hash_bucket处理再传给 crossed_column。如果你遇到TypeError或者维度不匹配的报错优先检查这里。另一个变化是tf.keras.layers.DenseFeatures取代了旧的tf.feature_column.input_layer。如果你在网上搜索 widedeep 实现会看到很多老代码用input_layer在新版本里已经 deprecated。统一用DenseFeatures就好它的行为基本一致但和 Keras 的配合更好。我还建议把tf.keras.mixed_precision环境变量检查一遍。如果你的 GPU 支持混合精度可以在训练时启用mixed_float16能显著加速。不过在 wide 端的稀疏特征场景下混合精度偶尔会导致数值不稳定建议先在验证集上做一轮对比再决定是否开启。4. 手写一个 widedeep基于 TensorFlow 2.0 的完整代码实现4.1 数据集准备与特征列定义下面我用一个模拟的点击率预估场景来演示。假设我们有用户特征、物品特征和上下文特征目标变量是是否点击0/1。这里我生成一份模拟数据特征包括user_id用户 id离散特征基数较大item_id物品 id离散特征基数较大category物品类目离散特征基数中等hour小时可以分桶user_item_cross用户和物品的交叉特征用于 wide 端记忆。首先定义特征列import tensorflow as tf # 离散特征列 user_id tf.feature_column.categorical_column_with_hash_bucket( keyuser_id, hash_bucket_size100000 ) item_id tf.feature_column.categorical_column_with_hash_bucket( keyitem_id, hash_bucket_size100000 ) category tf.feature_column.categorical_column_with_hash_bucket( keycategory, hash_bucket_size500 ) # wide 端交叉特征 user_item_cross tf.feature_column.crossed_column( keys[user_id, item_id], hash_bucket_size500000 ) # deep 端 embedding 特征列 deep_columns [ tf.feature_column.embedding_column(user_id, dimension32), tf.feature_column.embedding_column(item_id, dimension32), tf.feature_column.embedding_column(category, dimension8), ] # wide 端特征列 wide_columns [ user_item_cross, tf.feature_column.numeric_column(hour, default_value0), ]这里我特意把hour同时放进了 wide 端但没有放进 deep 端。要让它也参与 deep 端可以加一个tf.feature_column.numeric_column(hour)到 deep_columns 里。注意numeric_column在 deep 端和 wide 端都可以用区别在于 wide 端直接线性加权deep 端则作为实数特征输入神经网络。4.2 构建模型的两种方式Sequential 与自定义 Model在 TensorFlow 2.0 里实现 widedeep 有两条路一条路是用tf.keras.Sequential把DenseFeatures(wide_columns)和DenseFeatures(deep_columns)分别作为输入层然后用Concatenate融合。但这种写法有个问题Sequential模型要求单输入单输出而 widedeep 天然是双输入你需要先把两个输入层合并成一个比较别扭。我更推荐自定义tf.keras.Model逻辑清晰也方便之后扩展。下面是完整实现class WideDeepModel(tf.keras.Model): def __init__(self, wide_columns, deep_columns, hidden_units(256, 128, 64)): super(WideDeepModel, self).__init__() self.wide_input tf.keras.layers.DenseFeatures(wide_columns, namewide_input) self.deep_input tf.keras.layers.DenseFeatures(deep_columns, namedeep_input) self.hidden_layers [tf.keras.layers.Dense(units, activationrelu) for units in hidden_units] self.dropout tf.keras.layers.Dropout(0.3) self.fusion tf.keras.layers.Dense(1, activationNone, namefusion) def call(self, inputs, trainingFalse): wide_out self.wide_input(inputs) deep_out self.deep_input(inputs) for layer in self.hidden_layers: deep_out layer(deep_out) deep_out self.dropout(deep_out, trainingtraining) concat tf.keras.layers.concatenate([wide_out, deep_out]) logits self.fusion(concat) return logits这段代码里有几个细节DenseFeatures接收的是一个 dict 类型的输入key 是特征名value 是对应的 Tensor。所以在调用模型时要给一个 dict而不是单个 Tensor。wide_out的形状是(batch_size, len(wide_columns))因为DenseFeatures会把所有 wide 特征拼接成一维向量而不是直接输出一个标量。这也是我为什么在融合层用concatenate而不是直接Add——如果你想用 Add需要在这个 wide_out 上再做一次 Dense(1)。deep_out是经过多层全连接后的向量维度是 hidden_units 最后一层的维度这里是 64。dropout只在训练时生效所以call方法里需要传入training参数。为了更贴近wide 端输出标量的原始设计你也可以把 wide 端单独接一个Dense(1)再和 deep 端的深层次输出相加。下面给出这个变体它更符合论文描述的 sum 融合class WideDeepSumModel(tf.keras.Model): def __init__(self, wide_columns, deep_columns, hidden_units(256, 128, 64)): super(WideDeepSumModel, self).__init__() self.wide_input tf.keras.layers.DenseFeatures(wide_columns) self.wide_fc tf.keras.layers.Dense(1, activationNone, namewide_fc) self.deep_input tf.keras.layers.DenseFeatures(deep_columns) self.hidden_layers [tf.keras.layers.Dense(units, activationrelu) for units in hidden_units] self.deep_fc tf.keras.layers.Dense(1, activationNone, namedeep_fc) def call(self, inputs, trainingFalse): wide_out self.wide_fc(self.wide_input(inputs)) deep_out self.deep_input(inputs) for layer in self.hidden_layers: deep_out layer(deep_out) deep_out self.deep_fc(deep_out) return wide_out deep_out两种写法我都用过前一种拼接方式更常见后一种更贴近论文。实际效果上差异没有想象中那么大但在特征工程比较粗糙的时候拼接方式往往略好因为额外参数给了模型更多自由。4.3 损失函数、优化器与训练循环对于点击率预估这种二分类任务损失函数用BinaryCrossentropy优化器可以先用Adam。下面是一个完整的训练流程model WideDeepModel(wide_columns, deep_columns) model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), losstf.keras.losses.BinaryCrossentropy(from_logitsTrue), metrics[tf.keras.metrics.AUC(nameauc)], ) # 假设 train_ds 和 val_ds 是 tf.data.Dataset每个样本是 (feature_dict, label) model.fit(train_ds, validation_dataval_ds, epochs10, batch_size1024)这里有几个容易踩的坑from_logitsTrue一定要设置对。我们的输出层没有加 sigmoid所以是 logitsBinaryCrossentropy 需要知道这一点否则会算二次 sigmoid导致损失异常。推荐使用AUC作为评价指标而不是只用准确率。在点击率预估这种正负样本极不平衡的场景下准确率几乎没有参考价值AUC 能更好地反映排序能力。batch_size可以设大一点。widedeep 在工业场景下样本量巨大小 batch 反而导致训练不稳定1024 甚至 4096 都可以尝试。不过要注意记忆体占用尤其是 wide 端交叉特征哈希桶较大时。如果你想用双优化器wide 端 FTRLdeep 端 Adam可以这样改训练循环wide_optimizer tf.keras.optimizers.Ftrl(learning_rate0.1) deep_optimizer tf.keras.optimizers.Adam(learning_rate0.001) tf.function def train_step(features, labels): with tf.GradientTape() as tape: logits model(features, trainingTrue) loss loss_fn(labels, logits) grads tape.gradient(loss, model.trainable_variables) # 手动按变量名分组 wide_grads [(g, v) for g, v in zip(grads, model.trainable_variables) if v.name.startswith(wide)] deep_grads [(g, v) for g, v in zip(grads, model.trainable_variables) if v.name.startswith(deep)] wide_optimizer.apply_gradients(wide_grads) deep_optimizer.apply_gradients(deep_grads)但这里有个问题命名不是那么可靠。更稳妥的做法是在自定义模型里把 wide 端和 deep 端的可训练变量分别收集成两个列表然后在 train_step 里直接按列表应用梯度。具体做法是给模型增加一个wide_variables属性和deep_variables属性这里先不展开后面讲优化时会给出更优雅的写法。4.4 数据管道的构建在 TensorFlow 2.0 里数据管道建议用tf.data来搭建。对于 widedeep 这种多特征输入关键是构造一个返回(feature_dict, label)的 Dataset。下面是一个简单示例假设我们有一个 CSV 文件或者 Pandas DataFramedef df_to_dataset(dataframe, label_col, batch_size1024, shuffleTrue): dataframe dataframe.copy() labels dataframe.pop(label_col) ds tf.data.Dataset.from_tensor_slices((dict(dataframe), labels)) if shuffle: ds ds.shuffle(buffer_sizelen(dataframe)) ds ds.batch(batch_size) return ds train_ds df_to_dataset(train_df, label_colclicked, batch_size1024) val_ds df_to_dataset(val_df, label_colclicked, batch_size1024, shuffleFalse)这里有一个细节tf.data.Dataset.from_tensor_slices接受的 dict 的 value 必须是同类型的 Tensor如果你的 DataFrame 里有的列是字符串有的列是整数需要先统一转成tf.string或tf.int64。categorical_column_with_hash_bucket接受字符串特征所以你可以把所有离散特征先astype(str)再用tf.strings处理。我在实际使用中更喜欢用TFRecord格式尤其是数据集很大的时候。CSV 读起来太慢而且重复解析开销大。如果你只是做实验df_to_dataset就够了如果你想上生产建议把特征先转成 TFRecord然后用tf.data.TFRecordDataset读取。这个转换过程不复杂但会让你的训练速度快不少尤其是在多 GPU 或 TPU 环境下。5. 训练中的性能优化过拟合、特征碰撞与收敛问题5.1 过拟合的判断与应对widedeep 模型参数量大尤其在 deep 端 embedding 多层全连接的双重加持下过拟合几乎是必然要面对的问题。判断过拟合不能光看训练集 loss要同时观察训练集和验证集的 AUC 曲线。我的经验法则是如果训练集 AUC 一路飙升到 0.95 以上而验证集 AUC 在某个 epoch 后开始下降那基本可以确定过拟合了。应对手段无非几种加大 dropout特别是 deep 端最后一层和 fusion 层之前我一般从 0.3 开始调过拟合明显时调到 0.5。减小 embedding 维度。如果你发现某个特征基数不大但 embedding 维度设得过高参数量会白白浪费而且很容易过拟合。早停。用tf.keras.callbacks.EarlyStopping(monitorval_auc, modemax, patience2)这种方式不要等到验证集指标已经恶化到不可收拾才停。L2 正则。虽然 wide 端天然适合 L1 正则以保持稀疏性但 deep 端更适合 L2。在Dense层里直接设置kernel_regularizertf.keras.regularizers.l2(1e-5)就行。我在实际项目中遇到过一种比较隐蔽的过拟合不是深层的过拟合而是 wide 端交叉特征的哈希碰撞导致的假记忆。当某个交叉特征在训练集里出现过、在验证集里因为哈希碰撞而被误认为另一个特征时模型会对这个错误的记忆过度自信。这种问题很难从 loss 曲线里直接看出来只能通过检查特征重要性和哈希桶分布来定位。5.2 哈希碰撞与 hash_bucket_size 的调参策略哈希碰撞是 wide 端一个绕不开的话题。crossed_column的 hash_bucket_size 决定了交叉特征哈希桶的数量桶越多碰撞越少但内存和参数也越多。如何平衡我的经验是先看你的特征基数。假设 user_id 有 10 万item_id 有 20 万那么理论上用户-物品交叉就有 20 亿种组合你不可能为每种组合都建一个特征所以必须哈希。那 hash_bucket_size 设多大呢一个可参考的经验是设为希望模型能记忆的高频组合数量的 2-5 倍。如果你的业务里高频组合大约几百万个那设 500 万到 1000 万都是合理的。设得太小会怎样比如只有 10 万个桶那大量不相关的用户-物品组合会被映射到同一个桶模型会对这些组合给出几乎一样的预测。这时候你看到的效果是AUC 死活上不去而且 wide 端学习到的权重稀疏性很差几乎每个桶都有非零权重。设得太大又会怎样参数数量剧增训练变慢而且很多桶在训练集里可能一次都没出现过白白浪费内存。所以 hash_bucket_size 不是越大越好要配合数据规模和业务特点来定。一个实用技巧是在调大 hash_bucket_size 之前先用tf.linalg.norm看一下 wide 权重的分布。如果权重的 L2 范数很大说明模型在强记某些交叉特征这时候适当减小桶数、增加正则反而能提升泛化。5.3 特征分桶bucketization与连续特征的处理widedeep 对连续特征的处理有一个容易被忽略的细节连续特征直接进入 wide 端在线性模型里就是一条直线效果往往一般。更常见的做法是先做分桶bucketized把连续值变成离散值再参与交叉或 embedding。比如hour特征可以设boundaries[0, 6, 12, 18, 24]分成几个时间段而不是直接用 0-23 的原始值。hour_bucket tf.feature_column.bucketized_column( source_columntf.feature_column.numeric_column(hour), boundaries[0, 6, 12, 18, 24], )分桶的意义在于引入了非线性模型不再假设凌晨 1 点和凌晨 2 点的点击率差异是线性的而是每个时间段独立学习一个权重。这在很多场景下都更合理。但分桶也会增加特征维度所以桶的数量要适中不要每 1 分钟一个桶除非你有特别强的业务理由。在 deep 端连续特征可以先做标准化tf.feature_column.numeric_columnnormalizer_fn再作为神经网络输入。标准化很重要尤其是当你混合了 embedding 特征和数值特征时如果数值特征的尺度过大会干扰 embedding 的梯度更新。我通常会对数值特征做 z-score 标准化或者用tf.clip_by_value限制极端值。5.4 模型训练的稳定性问题widedeep 在训练过程中还容易遇到一个问题wide 端的 FTRL 优化器和 deep 端的 Adam 优化器学习率差异很大如果把两者合在一个模型里用一个优化器可能会出现 fine-tuning 式的互相干扰。即使你不用双优化器只用 Adam 训整个模型wide 端的稀疏特征也会因为梯度稀疏而更新缓慢。我之前遇到过一个非常诡异的场景训练 loss 一直下降但 AUC 几乎没有变化。后来排查发现是 wide 端和 deep 端的输出尺度差异过大——wide_out 是一个很稀疏的线性组合值可能很大deep_out 经过多层网络后值域相对稳定。二者在融合层直接相加时wide 端的梯度完全主导了融合层的学习deep 端的贡献被压制了。解决方案是在融合前对 wide 端输出做一次归一化或者给 wide 端加一个BatchNormalization再或者使用拼接方式而不是直接求和。这也是为什么我前面推荐默认使用拼接方式它对尺度不匹配更宽容。6. 进阶优化双优化器、embedding 维度调优与线上推理6.1 双优化器的优雅实现前面提到双优化器更贴近论文但直接按变量名分组不可靠因为DenseFeatures内部创建的变量命名可能随版本变化。更优雅的做法是在自定义模型里显式地把 wide 端和 deep 端的变量分开收集。下面是一个参考实现class WideDeepModel(tf.keras.Model): def __init__(self, wide_columns, deep_columns, hidden_units(256, 128, 64)): super().__init__() self.wide_input tf.keras.layers.DenseFeatures(wide_columns, namewide_features) self.deep_input tf.keras.layers.DenseFeatures(deep_columns, namedeep_features) self.hidden_layers [tf.keras.layers.Dense(u, activationrelu) for u in hidden_units] self.fusion tf.keras.layers.Dense(1, activationNone, namefusion) def build_for_train(self, dummy_features): _ self(dummy_features, trainingFalse) self.wide_vars [] self.deep_vars [] for var in self.trainable_variables: if wide_features in var.name: self.wide_vars.append(var) else: self.deep_vars.append(var)然后训练循环里wide_opt tf.keras.optimizers.Ftrl(learning_rate0.1) deep_opt tf.keras.optimizers.Adam(learning_rate0.001) tf.function def train_step(features, labels): with tf.GradientTape() as tape: logits model(features, trainingTrue) loss tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(labels, logits)) grads tape.gradient(loss, model.trainable_variables) grads_and_vars_wide [(g, v) for g, v in zip(grads, model.trainable_variables) if v in model.wide_vars] grads_and_vars_deep [(g, v) for g, v in zip(grads, model.trainable_variables) if v not in model.wide_vars] wide_opt.apply_gradients(grads_and_vars_wide) deep_opt.apply_gradients(grads_and_vars_deep)这里build_for_train必须在训练前调用一次因为DenseFeatures的变量是在第一次调用时创建并初始化的调用前trainable_variables还是空的。这个细节特别容易忽略我一开始调试双优化器时就在这里卡了很久。6.2 embedding 维度调优的方法论embedding 维度是 deep 端最核心的超参数之一但它和模型整体性能的关系不是线性的。我先说我的经验对于基数 1 万以内的特征8-16 维足够基数 10 万-100 万的特征32-64 维比较稳妥基数千万级以上的超高基数特征128 维甚至更高也可以考虑但需要足够多的样本支撑。这里有一个反直觉的结论embedding 维度并不是越大越好。维度越大参数量线性增长但信息增益会越来越小。我在一个项目里把某个特征的 embedding 从 32 调到 128AUC 只提升了 0.001但训练时间翻了 3 倍显存占用翻倍完全得不偿失。更有效的方式是先跑一组小实验画出embedding 维度 vs AUC的曲线找到拐点再确定最终维度。另一个技巧是不同特征可以用不同 embedding 维度不必统一。embedding_column的 dimension 参数本来就是每个特征独立设置的。比如 user_id 用 64 维category 用 8 维。这样可以在不显著增加参数量地情况下让高基数特征拥有更强的表达力。6.3 线上推理的细节embedding lookup 与特征服务训练完成之后线上推理是一个容易被忽略但非常重要的环节。在 TensorFlow 2.0 里DenseFeatures会直接做 embedding lookup但如果你在线上环境使用需要注意几点特征字典的顺序DenseFeatures对输入 dict 的 key 顺序并不敏感但 key 必须完整缺失的 key 会直接报错。线上服务时建议统一用一个 feature_spec 来约束特征名和类型。embedding 的 hash 映射categorical_column_with_hash_bucket的哈希函数在训练和推理时必须一致。TensorFlow 会在同一个模型文件里保存这些映射所以只要你导出的是同一个模型线上加载后行为一致。但如果你的线上特征服务是用另一套代码独立做哈希那么两端使用的 hash 算法和 hash_bucket_size 必须完全一致否则特征就会对不上预测结果会非常混乱。模型导出推荐用tf.saved_model.save导出而不是只保存权重。这样线上可以用tf.saved_model.load直接加载而且DenseFeatures的预处理逻辑也会被完整保留在 SavedModel 里你不需要在线上单独实现特征到张量的转换。tf.saved_model.save(model, wide_deep_model, signaturesmodel.call.get_concrete_function(...))这里get_concrete_function需要在保存前先跑一次 dummy 输入否则 TensorFlow 不知道输入 signature。具体怎么构造 dummy dict和你的特征列一一对应即可。6.4 从 widedeep 到后续模型演进的思考当你把 widedeep 跑通之后会发现它像一块积木可以继续演进。一个很自然的扩展方向是 DeepFM它用 FM 层替代了 wide 端的部分交叉特征自动学习二阶特征交互省去大量人工交叉。另一个方向是 DINDeep Interest Network针对用户历史行为序列建模引入注意力机制让模型关注与当前候选物品更相关的历史行为。还有一个方向是 DCNDeep Cross Network用 cross network 显式学习高阶特征交叉比 wide 端的线性交叉表达力更强。但从工程经验看我建议不要一上来就上复杂的模型。widedeep 最大的价值在于结构清晰、训练稳定、可解释性强。先把 wide 端的特征工程做好把 deep 端的 embedding 调优到位再去叠加更复杂的机制性价比会高很多。很多团队在迁移到 DeepFM 或 DCN 时发现模型结构的增益远不如特征工程和样本清洗带来的增益大。这个行业就是这样——没有银弹但有系统性优化的空间。7. 一个完整的上手清单从零开始跑通 widedeep7.1 最小运行环境为了让你能快速复现我整理一份最简单可行的环境清单项目推荐值说明Python3.8-3.10TensorFlow 2.10 支持的范围TensorFlow2.10稳定feature_column 支持好训练硬件8G 显存即可小数据量不需要顶尖 GPU数据集规模10 万级样本起步太小学不出效果太大耗时特征数量3-5 个离散特征 1-2 个连续特征可以跑通但不建议过多如果只是想验证代码正确性我甚至建议用 CPU 跑一遍完整流程再切换到 GPU。CPU 跑小数据集可以避免很多环境兼容问题尤其是 GPU 和 CUDA 版本不对时的烦躁排查。7.2 快速验证脚本下面给出一个可以直接粘贴运行的脚本骨架包含数据的生成、模型构建、训练和评估import numpy as np import pandas as pd import tensorflow as tf # 生成模拟数据 np.random.seed(42) n 100000 df pd.DataFrame({ user_id: np.random.randint(0, 10000, n).astype(str), item_id: np.random.randint(0, 20000, n).astype(str), category: np.random.randint(0, 100, n).astype(str), hour: np.random.randint(0, 24, n), clicked: np.random.binomial(1, 0.3, n), }) # 特征列 user_id tf.feature_column.categorical_column_with_hash_bucket(user_id, 10000) item_id tf.feature_column.categorical_column_with_hash_bucket(item_id, 20000) category tf.feature_column.categorical_column_with_hash_bucket(category, 100) cross tf.feature_column.crossed_column([user_id, item_id], 100000) wide_features [cross, tf.feature_column.numeric_column(hour)] deep_features [ tf.feature_column.embedding_column(user_id, 16), tf.feature_column.embedding_column(item_id, 16), tf.feature_column.embedding_column(category, 8), tf.feature_column.numeric_column(hour), ] # 数据管道 def df_to_ds(dataframe, batch_size256): labels dataframe.pop(clicked) ds tf.data.Dataset.from_tensor_slices((dict(dataframe), labels)) ds ds.shuffle(len(dataframe)).batch(batch_size) return ds train_ds df_to_ds(df.sample(frac0.8)) val_ds df_to_ds(df.drop(train_ds), batch_size256) # 模型 model WideDeepModel(wide_features, deep_features) model.compile(optimizeradam, losstf.keras.losses.BinaryCrossentropy(from_logitsTrue), metrics[AUC]) model.fit(train_ds, validation_dataval_ds, epochs5)运行这个脚本你应该能看到 AUC 在 0.6-0.7 之间波动。模拟数据没有真实规律这个结果本身没有业务意义但足以验证你的 TensorFlow 环境、特征列定义、模型结构和数据管道全部通了。我强烈建议你跑通这一步之后再接入真实数据否则真实数据的复杂问题会和环境问题混在一起排查难度翻倍。7.3 常见报错与解决办法我把自己遇到过的几个典型报错整理成表供你参考报错信息可能原因解决办法TypeError: Input y of Sub Op has type float32 that does not match type int32标签或特征类型不一致把 labels 统一tf.cast(labels, tf.float32)KeyError: user_id输入 dict 缺少特征列检查df_to_ds是否把 user_id 也 pop 掉了ValueError: Shape must be rank 2 but is rank 1DenseFeatures输出和自定义层不匹配检查输入 batch 维度通常需要tf.expand_dims或确认 shapeNotFoundError: No OpKernel was registeredCUDA 或 cuDNN 版本问题重新安装匹配版本的 TensorFlow GPU 版Unable to launch cuBLAS显存不足或 cuBLAS 版本问题减小 batch_size或升级驱动这里最让我记忆深刻的是KeyError: user_id这个看似简单的问题。我一度以为是数据管道写错了查了很久才发现是pd.DataFrame.pop默认返回的是 Series而from_tensor_slices对 dict 的 value 类型有要求某些列被隐式转换后 key 对不上了。解决方案是在构建 dict 前先确认 DataFrame 的列名和特征列 key 完全一致最好打印一行print(df.columns)对照检查。8. 我在实际项目中踩过的三个大坑与最终经验最后分享三个我认为最有价值的实战教训。第一个坑是wide 端特征列太少导致 wide 端形同虚设。我在一个项目里把大部分特征都喂给了 deep 端wide 端只留了一个交叉特征结果发现模型效果和纯 DNN 几乎没有区别。这提醒我widedeep 的价值在于两条腿走路wide 端一定要承担足够多的显式交叉和记忆型特征否则不如直接用 DNN。合理的做法是把业务上已知的强共现关系、强规则特征、低频但高置信的特征组合都放到 wide 端。第二个坑是训练和推理时特征处理不一致。我在线上部署时数据管道里对某个连续特征做了 z-score 标准化但线上特征服务用的是另一套标准化参数导致线上 AUC 比离线低了十几个点。这个问题非常隐蔽因为它不会报错只是预测结果整体偏移。解决方案是把特征处理逻辑全部封装进模型内部比如直接用tf.keras.layers.Normalization或者在call方法里做确保训练和推理共用同一套处理逻辑。用tf.saved_model.save导出的模型天然具备这个性质所以我强调线上部署一定要走 SavedModel。第三个坑是贪心调大隐藏层结果过拟合到怀疑人生。我有一次把 hidden_units 从 (256, 128, 64) 改成 (1024, 512, 256, 128)训练集 AUC 飙到接近 1但验证集 AUC 反而下降。这说明参数容量远超过了数据能支撑的信息量。后来我回退到小网络配合 dropout 和 embedding 降维验证集效果反而更好。这件事让我彻底放弃了网络越大越好的想法。在推荐模型里特征工程和样本质量往往比模型容量更重要。先把特征做扎实再谈模型复杂度这条原则到现在依然有效。如果你正准备在自己的项目里落地 widedeep我建议你按这个顺序做事先跑通最小脚本再逐步加入真实特征每次只改动一个变量观察 AUC 和 loss 的变化。不要一开始就追求复杂模型和完美效果先把流程跑顺再谈优化。这样你会少走很多弯路也能真正理解 widedeep 的设计哲学——记忆和泛化两条腿走路缺一不可。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑