资讯动态

子类API实现WideDeep模型:推荐系统CTR预估实战

发布时间:2026/10/17 1:31:11 来源:尧图企业网站定制
1. 为什么子类API才是widedeep的正确打开方式如果你之前用Keras的Sequential或者Functional API搭过widedeep大概率会遇到一个尴尬的局面wide部分和deep部分的输入特征不一样wide侧需要的是稀疏的交叉特征deep侧需要的是稠密embedding而这两条路要在最后汇合。Functional API虽然能做多输入但一旦特征工程逻辑复杂起来整个计算图会变得非常臃肿调试的时候你根本分不清哪一层对应哪个特征。子类APISubclassing API解决的就是这个问题。它把模型定义完全交给Python类你可以在__init__里声明所有层在call里写前向传播逻辑整个过程就像写普通Python代码一样自然。对于widedeep这种需要精细控制两条路径的模型结构子类API的灵活度是其他两种方式给不了的。我最初接触widedeep是在推荐系统场景里当时用Functional API硬拼了一个版本结果wide侧的离散特征处理代码写了将近两百行每次改特征都要重新捋一遍计算图。后来换成子类API重写代码量直接砍半而且逻辑清晰到可以一边打电话一边改。这篇文章会从零开始用TensorFlow 2.x的子类API完整实现一个widedeep模型。我会把重点放在三个地方一是为什么widedeep需要两条独立的路径二是子类API在实现时有哪些容易踩的坑三是怎么验证你的模型确实在按预期工作。如果你正在做推荐系统、CTR预估或者任何需要同时利用记忆能力和泛化能力的任务这篇内容应该能帮你省下不少试错时间。2. widedeep的双路径设计到底在解决什么问题2.1 记忆与泛化的矛盾推荐系统里有一个经典难题模型既要记住历史数据中频繁共现的特征组合又要有能力泛化到没见过的组合。wide部分负责记忆它本质上是一个广义线性模型通过交叉特征来捕捉稀疏模式。比如“用户安装了某类应用”和“用户点击了某类广告”这两个特征交叉后模型能直接学到“装了A类应用的人大概率会点B类广告”这种强规则。deep部分负责泛化它通过embedding把高维稀疏特征映射到低维稠密空间然后让多层神经网络去学习特征之间的非线性关系。embedding的好处是即使某个特征组合在训练数据里没出现过只要它的embedding向量和已知组合的向量接近模型也能给出合理预测。这两条路单独用都有明显短板。只用wide模型完全没有泛化能力遇到新组合就抓瞎。只用deep模型容易过拟合而且对低频特征的学习效果很差。widedeep的核心思想就是让两条路各司其职最后把输出加起来一起优化。2.2 子类API为什么比Functional API更适合这个结构Functional API的本质是“用层来搭计算图”你定义的是层与层之间的连接关系。这在处理多输入多输出时确实方便但widedeep有一个特殊需求wide侧和deep侧需要共享一部分原始特征但各自又需要不同的预处理。比如同一个类别特征wide侧需要做one-hot编码后直接输入线性层deep侧需要先做embedding再输入全连接层。用Functional API实现时你不得不把特征预处理也塞进计算图里导致整个图变得非常复杂。而子类API允许你在call方法里用纯Python逻辑做特征分流预处理可以在进入模型之前就完成模型内部只负责前向传播。这种职责分离让代码可读性和可维护性都提升了一个档次。还有一个实际开发中的痛点widedeep的wide侧通常需要动态生成交叉特征。比如用户ID和物品ID的交叉如果用户有10万个、物品有1万个交叉后的特征维度是10亿。这种特征不可能在计算图里静态定义必须在训练时动态构造。子类API的call方法天然支持这种动态逻辑而Functional API处理起来就非常别扭。2.3 一个容易忽略的细节两条路径的输出尺度wide侧输出的是一个标量logitdeep侧输出的也是一个标量logit两者相加后过sigmoid得到最终概率。这里有一个很多人会踩的坑wide侧和deep侧的初始输出尺度可能差很多。wide侧因为输入是稀疏的one-hot特征权重初始化后输出通常很小deep侧经过多层网络后输出可能偏大。如果直接相加deep侧会主导梯度wide侧几乎学不到东西。我在第一次实现时就遇到了这个问题训练了十几个epoch后wide侧的权重几乎没变化。后来在wide侧输出上加了一个可学习的缩放系数让模型自己调整两条路径的贡献比例效果立刻好转。这个技巧在原始论文里没有提但在实际工程中非常关键。3. 用子类API搭建widedeep的完整实操3.1 环境准备与数据模拟先确认环境。TensorFlow 2.x的版本建议在2.4以上子类API的稳定性在后续版本里提升明显。我用的组合是Python 3.8 TensorFlow 2.8 NumPy 1.21这个组合在多个项目里跑下来最稳。import tensorflow as tf from tensorflow.keras import layers, Model import numpy as np print(tf.__version__)数据方面为了演示方便我构造一份模拟数据。假设有1000个用户和500个物品每个用户有年龄、性别两个稠密特征每个物品有类别、价格两个特征。wide侧用用户ID和物品ID的交叉特征deep侧用所有特征的embedding。num_users 1000 num_items 500 num_samples 10000 # 模拟原始特征 user_ids np.random.randint(0, num_users, num_samples) item_ids np.random.randint(0, num_items, num_samples) user_ages np.random.rand(num_samples).astype(np.float32) user_genders np.random.randint(0, 2, num_samples) item_categories np.random.randint(0, 20, num_samples) item_prices np.random.rand(num_samples).astype(np.float32) # 模拟标签用户ID和物品ID的某种组合决定点击概率 labels ((user_ids item_ids) % 7 3).astype(np.float32)这份数据里标签和用户ID、物品ID的模运算有关wide侧的交叉特征能直接捕捉这个模式deep侧则需要通过embedding去学习。这样设计能直观看出两条路径是否都在工作。3.2 wide侧的特征工程与实现wide侧的核心是交叉特征。在真实场景里交叉特征通常是“用户ID_物品ID”这种字符串拼接后做哈希。这里为了简化直接用用户ID乘以物品ID的数值作为交叉特征索引。cross_feature user_ids * num_items item_ids这个交叉特征的维度是num_users * num_items也就是50万维。如果直接用one-hot编码内存会爆掉。所以wide侧通常用tf.nn.embedding_lookup_sparse或者直接用Embedding层配合sparse输入。但更简单的做法是把交叉特征当作一个超大词表的类别特征用一个Embedding层查表后求和。class WidePart(layers.Layer): def __init__(self, cross_dim): super(WidePart, self).__init__() # 用Embedding模拟线性层每个交叉特征对应一个权重 self.linear layers.Embedding(cross_dim, 1, embeddings_initializerzeros) def call(self, cross_input): # cross_input shape: (batch, 1) return self.linear(cross_input) # (batch, 1, 1)这里用Embedding而不是Dense的原因是交叉特征维度太高Dense会创建一个巨大的权重矩阵而Embedding只会在前向传播时查找对应的行内存效率高得多。初始化用zeros是为了让wide侧从零开始学习避免初始随机权重干扰训练。注意wide侧的权重初始化不要用随机值。widedeep的原始论文里wide侧是线性模型权重初始化为零能让模型先依赖deep侧然后wide侧逐步学习残差。如果wide侧初始权重太大训练初期会引入噪声。3.3 deep侧的embedding与全连接设计deep侧需要处理所有特征。对于类别特征用户ID、物品ID、性别、物品类别用Embedding层映射到低维向量对于数值特征年龄、价格直接做归一化后输入。class DeepPart(layers.Layer): def __init__(self, num_users, num_items, num_categories, embed_dim16, hidden_units[64, 32]): super(DeepPart, self).__init__() self.user_embed layers.Embedding(num_users, embed_dim) self.item_embed layers.Embedding(num_items, embed_dim) self.gender_embed layers.Embedding(2, 4) self.category_embed layers.Embedding(num_categories, 8) self.dense_layers [] for units in hidden_units: self.dense_layers.append(layers.Dense(units, activationrelu)) # 数值特征归一化层 self.age_norm layers.Normalization() self.price_norm layers.Normalization() def call(self, inputs): user_id, item_id, gender, category, age, price inputs user_vec self.user_embed(user_id) item_vec self.item_embed(item_id) gender_vec self.gender_embed(gender) category_vec self.category_embed(category) # 数值特征归一化后扩展维度 age_vec tf.expand_dims(age, -1) price_vec tf.expand_dims(price, -1) # 拼接所有特征 concat tf.concat([ user_vec, item_vec, gender_vec, category_vec, age_vec, price_vec ], axis-1) x concat for layer in self.dense_layers: x layer(x) return xembedding维度怎么选有一个经验公式embed_dim min(50, (num_categories 1) // 2)。对于用户ID这种大基数特征16到32维通常够用对于性别这种二值特征4维就足够了。维度太高会导致过拟合太低则表达能力不足。全连接层的结构也有讲究。widedeep的deep侧通常用2到3层每层神经元数量逐层递减。我试过[64, 32]、[128, 64, 32]、[256, 128, 64]几种组合在模拟数据上[64, 32]的效果最好再深就容易过拟合。真实场景里可以根据数据量调整数据量越大可以适当加深。3.4 把两条路拼成一个完整模型现在把wide侧和deep侧组合起来。关键点在于wide侧和deep侧的输出要相加然后过sigmoid。同时加一个可学习的缩放系数来平衡两条路径。class WideAndDeep(Model): def __init__(self, num_users, num_items, num_categories, cross_dim, embed_dim16, hidden_units[64, 32]): super(WideAndDeep, self).__init__() self.wide WidePart(cross_dim) self.deep DeepPart(num_users, num_items, num_categories, embed_dim, hidden_units) # 可学习的缩放系数初始值为1.0 self.wide_scale tf.Variable(1.0, trainableTrue, namewide_scale) self.deep_scale tf.Variable(1.0, trainableTrue, namedeep_scale) def call(self, inputs): cross_input, user_id, item_id, gender, category, age, price inputs wide_out self.wide(cross_input) # (batch, 1, 1) wide_out tf.squeeze(wide_out, axis[-1, -2]) # (batch,) deep_out self.deep([user_id, item_id, gender, category, age, price]) # (batch, last_units) deep_out tf.reduce_sum(deep_out, axis-1) # (batch,) # 两条路径加权求和 logits self.wide_scale * wide_out self.deep_scale * deep_out return tf.sigmoid(logits)这里有一个细节deep侧的输出是一个向量最后一层有32个神经元需要先reduce_sum变成一个标量。也可以加一个Dense(1)层来做降维但reduce_sum更简单而且效果差不多。wide侧的输出经过squeeze后也是标量。wide_scale和deep_scale这两个变量是可训练的模型会在训练过程中自动调整它们的值。如果wide侧学得快wide_scale会增大如果deep侧更重要deep_scale会增大。这个设计比固定权重灵活得多。4. 训练过程中那些文档不会告诉你的坑4.1 自定义训练循环还是model.fit子类API的模型可以用model.fit训练但需要确保call方法的输入顺序和tf.data.Dataset的输出顺序一致。我建议用自定义训练循环因为widedeep的输入结构比较复杂用fit容易在数据管道上出问题。optimizer tf.keras.optimizers.Adam(learning_rate0.001) loss_fn tf.keras.losses.BinaryCrossentropy() tf.function def train_step(inputs, labels): with tf.GradientTape() as tape: predictions model(inputs, trainingTrue) loss loss_fn(labels, predictions) gradients tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(gradients, model.trainable_variables)) return loss用tf.function装饰训练步骤能显著提升速度但要注意call方法里不能有Python的副作用操作比如print否则每次追踪都会执行。如果需要在训练时打印中间值用tf.print而不是print。4.2 特征顺序错位导致的静默失败这是我最想强调的一个坑。子类API的call方法接收一个元组元组里每个元素的顺序必须和模型定义时完全一致。如果顺序错了TensorFlow不会报错但模型会学到完全错误的东西。比如你把user_id和item_id的顺序搞反了模型照样能训练loss照样下降但wide侧的交叉特征就完全错了。这种错误在调试时极难发现因为没有任何报错信息。我的做法是在call方法开头加一个断言检查。def call(self, inputs): cross_input, user_id, item_id, gender, category, age, price inputs # 断言检查特征范围 tf.debugging.assert_less(user_id, num_users) tf.debugging.assert_less(item_id, num_items) ...tf.debugging.assert_less会在运行时检查张量的值是否在合法范围内如果越界会直接报错。这个检查在训练初期能帮你快速定位特征错位问题。虽然会稍微增加计算开销但比起调试几天找不到问题这点开销完全值得。4.3 embedding层的梯度稀疏问题widedeep的embedding层有一个特点每次只有少数几个embedding向量会被更新。比如用户ID有1000个一个batch里可能只出现50个不同的用户剩下950个用户的embedding在这一步不会收到梯度。这本身不是问题但如果你用了Adam优化器它的动量累积会对所有参数生效包括那些没有梯度的embedding。这会导致未出现的embedding向量也在缓慢变化长期下来会引入噪声。解决方案是用tf.keras.optimizers.Adam的sparse版本或者手动把embedding层的梯度设为稀疏。更简单的做法是在Embedding层里设置mask_zeroTrue这样padding的embedding不会参与梯度更新。但最彻底的方式是用tf.nn.embedding_lookup_sparse配合SparseTensor不过这会增加代码复杂度。我在实际项目里的折中方案是用普通的Embedding层但在训练循环里对embedding的梯度做裁剪把小于某个阈值的梯度置零。这样既能保持代码简洁又能减少噪声。4.4 验证模型是否真的在学wide和deep训练几个epoch后怎么知道wide侧和deep侧都在工作最直接的方法是看wide_scale和deep_scale的值。如果其中一个一直是初始值附近说明那条路径没有学到东西。print(fwide_scale: {model.wide_scale.numpy():.4f}) print(fdeep_scale: {model.deep_scale.numpy():.4f})另一个方法是分别计算wide侧和deep侧的输出看它们的分布。如果wide侧的输出全是零说明交叉特征没有生效如果deep侧的输出方差很小说明embedding没有学好。我还会做一个消融实验把wide侧的输出强制置零只训练deep侧看loss能降到多少然后反过来只训练wide侧。两个单独的loss和联合训练的loss对比就能看出两条路径各自的贡献。5. 从模拟数据到真实场景的迁移要点5.1 特征哈希与维度控制真实场景里交叉特征的维度往往大到无法直接建embedding表。比如用户ID有百万级物品ID有十万级交叉后就是千亿级。这时候必须用特征哈希把交叉特征映射到固定维度。class HashedWidePart(layers.Layer): def __init__(self, hash_bucket_size100000): super(HashedWidePart, self).__init__() self.hash_bucket_size hash_bucket_size self.linear layers.Embedding(hash_bucket_size, 1, embeddings_initializerzeros) def call(self, user_id, item_id): # 用简单的哈希函数把交叉特征映射到桶 cross user_id * 1000003 item_id hashed tf.math.floormod(cross, self.hash_bucket_size) return self.linear(hashed)哈希桶的大小需要权衡太小会导致哈希冲突严重不同特征共享同一个权重太大会增加内存占用。经验值是总特征数的1到2倍。如果交叉特征有1000万种可能哈希桶设1000万到2000万比较合适。5.2 数值特征的归一化策略deep侧的数值特征必须做归一化否则量纲差异会让模型难以收敛。年龄范围是0到100价格范围可能是0到10000如果不处理价格特征会主导梯度。我通常用layers.Normalization层做在线归一化它会在训练时统计均值和方差。但要注意Normalization层需要先adapt数据。age_norm layers.Normalization() age_norm.adapt(age_data) # 用训练数据适配如果数据量太大无法一次性adapt可以用tf.data.Dataset的batch方法分批adapt。另外对于长尾分布的数值特征比如价格先做log变换再归一化效果更好。5.3 线上服务的性能考量widedeep模型在线上服务时wide侧的embedding查表是性能瓶颈。因为交叉特征的embedding表可能非常大每次请求都要查表。优化方法有两个一是把embedding表放在内存里用tf.lookup.StaticHashTable做快速查找二是对wide侧的输出做缓存因为很多用户的交叉特征组合是重复的。deep侧的计算量主要在embedding查表和全连接层。如果embedding维度是16用户ID和物品ID各查一次再加上其他特征总计算量其实不大。真正耗时的是全连接层如果hidden_units设得太大推理延迟会明显增加。线上服务时建议把hidden_units控制在[64, 32]以内。还有一个工程细节widedeep的两条路径可以并行计算因为wide侧和deep侧没有依赖关系。在服务端可以用多线程同时计算两条路径最后合并结果。这个优化在高并发场景下能提升30%左右的吞吐量。6. 几个让模型效果更好的微调技巧6.1 学习率的分组设置wide侧和deep侧的学习率可以不同。wide侧是线性模型学习率可以大一些deep侧有embedding和多层网络学习率需要小一些。在TensorFlow里可以用optimizer的learning_rate配合tf.GradientTape的gradient方法实现分组学习率。wide_vars model.wide.trainable_variables deep_vars model.deep.trainable_variables wide_optimizer tf.keras.optimizers.Adam(0.01) deep_optimizer tf.keras.optimizers.Adam(0.001) tf.function def train_step(inputs, labels): with tf.GradientTape() as tape: predictions model(inputs, trainingTrue) loss loss_fn(labels, predictions) wide_grads tape.gradient(loss, wide_vars) deep_grads tape.gradient(loss, deep_vars) wide_optimizer.apply_gradients(zip(wide_grads, wide_vars)) deep_optimizer.apply_gradients(zip(deep_grads, deep_vars)) return loss这个技巧在wide侧特征非常稀疏时特别有用。wide侧的学习率设大一点能让它快速学到交叉特征的权重而deep侧保持小学习率避免过拟合。6.2 正则化的差异化应用wide侧和deep侧的正则化策略也应该不同。wide侧是线性模型L1正则化能让不重要的交叉特征权重直接归零起到特征选择的作用。deep侧的embedding层适合用L2正则化防止embedding向量过大。# wide侧用L1正则化 self.linear layers.Embedding(cross_dim, 1, embeddings_initializerzeros, embeddings_regularizertf.keras.regularizers.l1(0.01)) # deep侧用L2正则化 self.user_embed layers.Embedding(num_users, embed_dim, embeddings_regularizertf.keras.regularizers.l2(0.001))L1正则化的系数需要调太大会导致wide侧所有特征都被压成零太小则没有效果。我通常从0.001开始试根据wide侧非零权重的比例来调整。如果非零权重太少说明正则化太强如果几乎全是非零说明正则化太弱。6.3 早停与模型检查点widedeep模型容易在训练后期过拟合尤其是deep侧。早停是必须的。用tf.keras.callbacks.EarlyStopping监控验证集的AUC如果连续3个epoch没有提升就停止训练。callbacks [ tf.keras.callbacks.EarlyStopping( monitorval_auc, patience3, modemax, restore_best_weightsTrue ), tf.keras.callbacks.ModelCheckpoint( best_model.h5, monitorval_auc, save_best_onlyTrue, modemax ) ]注意restore_best_weightsTrue这个参数它会在训练结束后自动恢复到验证集效果最好的那组权重。如果不加这个参数早停后模型保留的是最后一个epoch的权重可能已经过拟合了。6.4 特征重要性的可视化训练完之后怎么知道哪些特征对wide侧最重要wide侧的embedding权重就是每个交叉特征的系数可以直接取出来排序。wide_weights model.wide.linear.get_weights()[0].flatten() top_indices np.argsort(np.abs(wide_weights))[-20:] print(Top 20 important cross features:) for idx in top_indices: print(fFeature {idx}: weight {wide_weights[idx]:.4f})这个分析能帮你验证特征工程是否合理。如果排在前面的交叉特征都是你预期的重要特征说明模型学到了正确的模式如果是一些无意义的特征排在前列可能需要检查特征构造逻辑。对于deep侧可以用梯度方法计算每个输入特征对输出的贡献。但更简单的方法是做消融实验把某个特征的embedding置零看AUC下降多少。下降越多说明该特征越重要。7. 子类API实现widedeep的边界与取舍子类API不是银弹。它最大的问题是模型结构无法序列化。用Functional API搭的模型可以保存成SavedModel格式加载时不需要原始代码。但子类API的模型保存后加载时必须提供模型类的定义否则无法重建。这意味着如果你的模型需要跨团队部署或者需要长期维护子类API会增加耦合。我的建议是在实验阶段用子类API快速迭代确定模型结构后如果部署环境对模型格式有要求再考虑转成Functional API或者用tf.function导出计算图。另一个边界是分布式训练。子类API的模型在MirroredStrategy下训练时call方法里的Python逻辑会在每个设备上执行。如果逻辑里有随机操作或者状态更新需要特别小心。widedeep的call方法里没有随机操作所以分布式训练没问题但如果你在call里加了dropout或者其他随机层需要确保随机种子的一致性。最后说一个实际项目里的取舍widedeep的结构虽然经典但并不是所有场景都适用。如果特征之间几乎没有交叉模式wide侧就是多余的如果数据量很小deep侧的多层网络容易过拟合。我在数据量少于10万条时通常只用wide侧或者一个浅层deep模型效果反而更好。widedeep适合的是数据量大、特征交叉模式明显的场景比如推荐系统和广告CTR预估。我在最近一个项目里把widedeep和子类API结合使用训练了大约50万条样本wide侧的交叉特征用了哈希桶deep侧用了两层全连接。最终验证集AUC比单独的deep模型提升了2.3个百分点比单独的wide模型提升了5.1个百分点。这个提升幅度在推荐场景里已经相当可观了。训练过程中最大的收获是wide_scale和deep_scale这两个可学习参数确实有用模型自动学到的比例是wide侧占0.3、deep侧占0.7和人工调参的结果接近但省去了大量实验时间。

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

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

免费获取报价 →
↑