☰
TensorFlow子类API实战:从零实现widedeep推荐模型
2026/10/16 11:52:21 网站建设 项目流程

1. 为什么还要折腾子类API

TensorFlow 2.0 把tf.keras扶正之后,搭模型的路子一下子多了起来:Sequential、Functional、Subclassing 三种写法并存。很多刚上手的朋友会问,既然 Sequential 三行就能堆一个网络,Functional 画个图就能搞定多输入多输出,为什么还要费劲去写子类?这个问题我在第一次接触 wide&deep 的时候也纠结过。

先说结论:wide&deep 这种"两条腿走路"的结构,用子类 API 写是最顺手的。原因不复杂——wide 部分和 deep 部分吃的是不同的特征,走的是不同的前向路径,最后才把 logits 加起来。Functional API 当然也能写,但一旦你想在 forward 里塞点自定义逻辑(比如动态加权、条件分支、特征交叉的中间处理),Functional 就会开始别扭,而子类 API 天然就是"我自己定义call里发生什么",自由度最高。

这篇文章我打算把子类 API 实现 wide&deep 这件事从头到尾捋一遍。适合谁看:已经会写 Sequential、知道Dense、Embedding大概怎么回事,但一碰到"多输入 + 自定义前向"就卡壳的朋友。看完你应该能自己动手搭一个能跑的 wide&deep,并且知道每一行为什么这么写。我会把踩过的坑、参数怎么定、调试怎么查都摊开讲,尽量做到你照着抄就能跑通。

wide&deep 这个模型本身是推荐系统里的经典结构,2016 年那篇论文之后被广泛用在 CTR 预估场景。它的核心思想用一句话概括:wide 部分负责记忆(memorization),记住那些高频共现的特征组合;deep 部分负责泛化(generalization),通过 embedding 把稀疏特征映射到低维稠密空间,挖掘没见过的组合。两者互补,一个管"见过的",一个管"没见过的"。

2. wide&deep 的结构拆解与选型考量

2.1 wide 侧和 deep 侧到底各管什么

先把结构讲清楚,不然后面写代码就是照猫画虎。

wide 侧本质上是一个广义线性模型,输入是原始特征和交叉特征,输出是w·x + b。它的强项是记忆能力:比如"用户装了某类应用 AND 当前是晚上八点"这个组合在训练集里出现过很多次且转化率高,wide 侧就能通过这个交叉特征直接把这个模式记下来。缺点是它没法泛化,训练集里没出现过的组合,权重就是零,预测不出来。

deep 侧是一个前馈网络,稀疏的类别特征先过Embedding层变成低维稠密向量,然后拼接所有特征,过几层全连接。它的强项是泛化:即使某个特征组合没在训练集里出现过,embedding 之间的相似性也能让模型给出一个合理的估计。缺点是容易过拟合,而且对高频的、明确的特征组合记忆不如 wide 侧精准。

两者最后把输出 logits 相加,过一个 sigmoid 得到最终概率。注意是logits 相加,不是概率相加,这个细节很多人第一次写会搞错。

2.2 为什么选子类 API 而不是 Functional

我拿 Functional API 试过同一个结构,能写,但有两个地方很难受。

第一个是特征处理逻辑的嵌入。wide 侧经常需要做特征交叉,比如把"城市"和"品类"两个类别特征拼成一个新特征。在 Functional 里你得在输入层外面先做好交叉,再喂进去,模型定义和数据处理被割裂成两块。子类 API 里你可以在call里直接对输入做处理,模型是自包含的。

第二个是动态结构。有些场景下 wide 侧的权重需要根据 batch 内的统计量动态调整,或者 deep 侧的层数想根据配置灵活变化。Functional 是静态图,定义完就固定了;子类 API 的call是每次前向都执行的 Python 代码,想加什么逻辑加什么逻辑。

当然子类 API 也有代价:它不能直接用model.summary()看到完整结构,因为 Keras 不知道你的call里到底连了什么。想看结构得自己打印,或者用tf.keras.utils.plot_model配合expand_nested,但效果也一般。这是自由度的代价,心里有数就行。

2.3 输入数据的组织方式

wide&deep 是多输入模型,wide 侧和 deep 侧吃的特征不一样。我一般这样组织:

  • wide 输入:稀疏的、需要交叉的类别特征,通常用one-hot或者multi-hot表示,维度可能很高但很稀疏。
  • deep 输入:需要 embedding 的类别特征 + 连续的数值特征。

在子类 API 里,call的签名可以写成call(self, inputs),inputs是一个字典或者列表。我个人偏好字典,因为可读性好,inputs['wide']、inputs['deep_cat']、inputs['deep_num']一目了然,调试的时候不容易搞混顺序。列表虽然省事,但一旦特征多了,第几个对应哪个全靠记,容易出错。

3. 子类 API 实现的核心细节

3.1 类的骨架怎么搭

先看整体骨架,我习惯把 wide 和 deep 拆成两个子模块,主模型负责调度。

import tensorflow as tf from tensorflow.keras import layers class WidePart(layers.Layer): def __init__(self, wide_dim, **kwargs): super().__init__(**kwargs) self.wide_dim = wide_dim self.linear = layers.Dense(1, use_bias=True, name='wide_linear') def call(self, wide_inputs): # wide_inputs: [batch, wide_dim] return self.linear(wide_inputs) class DeepPart(layers.Layer): def __init__(self, vocab_sizes, embed_dim, num_dense, hidden_units, **kwargs): super().__init__(**kwargs) self.embeddings = [] for i, vs in enumerate(vocab_sizes): self.embeddings.append( layers.Embedding(input_dim=vs, output_dim=embed_dim, name=f'emb_{i}') ) self.dense_layers = [layers.Dense(u, activation='relu') for u in hidden_units] self.out = layers.Dense(1, name='deep_out') def call(self, cat_inputs, num_inputs): # cat_inputs: list of [batch, 1] tensors emb_list = [] for i, x in enumerate(cat_inputs): emb_list.append(self.embeddings[i](x[:, 0])) x = tf.concat(emb_list + [num_inputs], axis=-1) for layer in self.dense_layers: x = layer(x) return self.out(x)

这里有个细节值得说:Embedding层的输入维度是词表大小,输出维度是 embedding 维度。词表大小怎么定?如果你的类别特征已经做了 label encoding,最大值是 999,那input_dim至少是 1000。我一般会留一点余量,写成max_id + 2,防止边界情况。embedding 维度有个经验公式:min(50, (vocab_size + 1) // 2),但实际用的时候 8 到 32 之间比较常见,太大容易过拟合,太小表达不够。

3.2 主模型的 call 里发生了什么

主模型把两边拼起来:

class WideAndDeep(tf.keras.Model): def __init__(self, wide_dim, vocab_sizes, embed_dim, num_dense, hidden_units, **kwargs): super().__init__(**kwargs) self.wide = WidePart(wide_dim) self.deep = DeepPart(vocab_sizes, embed_dim, num_dense, hidden_units) def call(self, inputs): wide_logit = self.wide(inputs['wide']) deep_logit = self.deep(inputs['deep_cat'], inputs['deep_num']) logits = wide_logit + deep_logit return tf.nn.sigmoid(logits)

注意最后返回的是 sigmoid 之后的概率。如果你用tf.keras.losses.BinaryCrossentropy(from_logits=False),那就返回概率;如果from_logits=True,那call里就别加 sigmoid,直接返回 logits,让损失函数内部处理。这两种写法数值稳定性不一样,from_logits=True更稳,因为 sigmoid 和交叉熵合并计算能避免溢出。我一般推荐后者。

3.3 参数初始化和正则化

wide 侧的线性层,权重初始化用zeros其实也行,但更常见的是glorot_uniform。deep 侧的 embedding 初始化,默认是uniform,范围是[-0.05, 0.05],这个范围对大多数场景够用。如果你发现训练初期 loss 震荡厉害,可以把 embedding 的初始化范围调小一点。

正则化方面,wide 侧一般不加 dropout,因为它就是个线性层,加了反而影响记忆能力。deep 侧可以在每个全连接后面加Dropout(0.2~0.5),具体多少看数据量。数据量小就加大一点,数据量大就小一点。L2 正则我一般加在 embedding 上,embeddings_regularizer=tf.keras.regularizers.l2(1e-5),防止 embedding 向量长得太大。

4. 完整实操流程与关键环节

4.1 数据准备与特征工程

wide&deep 的效果,七分靠特征,三分靠模型。我拿一个模拟的推荐场景举例:假设有用户 ID、物品 ID、用户所在城市、物品品类四个类别特征,外加一个连续特征"用户历史点击率"。

wide 侧我准备这样构造输入:用户 ID 和物品 ID 做交叉,城市和品类做交叉,再加上原始的四个特征,全部 one-hot 后拼成一个大向量。假设用户 1000 个、物品 500 个、城市 50 个、品类 20 个,交叉后维度是1000*500 + 50*20 + 1000 + 500 + 50 + 20,大概 50 万维。这个维度很高,但因为是 one-hot,实际非零的没几个,用稀疏张量存就行。

deep 侧就简单了:四个类别特征各自 label encoding 后过 embedding,连续特征直接拼上去。

import numpy as np def make_dummy_data(n_samples=10000): np.random.seed(42) user_id = np.random.randint(0, 1000, n_samples) item_id = np.random.randint(0, 500, n_samples) city = np.random.randint(0, 50, n_samples) category = np.random.randint(0, 20, n_samples) hist_ctr = np.random.rand(n_samples).astype(np.float32) label = (np.random.rand(n_samples) > 0.7).astype(np.float32) # wide 侧:简化为拼接的 id 组合,实际项目里用 tf.feature_column 或自己 one-hot wide = np.stack([user_id, item_id, city, category], axis=1).astype(np.float32) deep_cat = [user_id.reshape(-1,1), item_id.reshape(-1,1), city.reshape(-1,1), category.reshape(-1,1)] deep_num = hist_ctr.reshape(-1,1) return {'wide': wide, 'deep_cat': deep_cat, 'deep_num': deep_num}, label

这里为了 demo 简单,wide 侧直接用了原始 id 拼接,实际项目里应该做 one-hot 或者用tf.feature_column的crossed_column。注意 wide 侧的输入维度要和WidePart里的wide_dim对上,我这里wide_dim=4,但真实场景可能是几十万,所以 wide 侧通常用tf.sparse或者Embedding加sum的方式来实现,避免稠密大矩阵。

4.2 模型编译与训练

编译的时候有几个参数要定:

model = WideAndDeep( wide_dim=4, vocab_sizes=[1000, 500, 50, 20], embed_dim=16, num_dense=1, hidden_units=[128, 64] ) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss=tf.keras.losses.BinaryCrossentropy(from_logits=False), metrics=[tf.keras.metrics.AUC(name='auc')] )

优化器选 Adam 是常规操作,学习率 1e-3 起步。如果 wide 侧维度特别高,稀疏梯度多,可以考虑Adagrad或者FTRL,这两个对稀疏特征更友好。损失函数用二分类交叉熵,评估指标用 AUC,因为 CTR 场景下正负样本极不均衡,准确率没意义。

训练的时候,batch_size一般 256 到 1024 之间。太小梯度噪声大,太大收敛慢。epochs看早停,我一般设 20 然后加EarlyStopping(patience=3)。

callbacks = [ tf.keras.callbacks.EarlyStopping(monitor='val_auc', patience=3, mode='max', restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=2) ] history = model.fit( train_inputs, train_labels, validation_data=(val_inputs, val_labels), batch_size=512, epochs=20, callbacks=callbacks )

4.3 训练过程中的监控要点

训练时重点看三个东西:训练 loss 和验证 loss 的差距、AUC 的走势、wide 侧和 deep 侧各自的贡献。

第一个看是否过拟合。如果训练 loss 一直降但验证 loss 开始升,说明 deep 侧过拟合了,加 dropout 或者减层数。第二个看 AUC 是否还在涨,涨不动了就早停。第三个比较有意思:你可以在call里把 wide_logit 和 deep_logit 分别返回,训练时观察两者的量级。如果 wide 侧的输出一直是零附近,说明 wide 侧没学到东西,可能是特征交叉没做好,或者学习率对 wide 侧不合适。

我踩过一个坑:wide 侧和 deep 侧共用一个优化器,但两边梯度量级差很多,导致 wide 侧几乎不更新。后来给 wide 侧单独设了更大的学习率,或者用tf.keras.optimizers.Adam配合梯度裁剪,才正常。如果你的 wide 侧不 work,先查梯度。

5. 常见问题与排查技巧实录

5.1 维度对不上怎么办

这是新手最容易遇到的报错。Embedding层的input_dim如果小于实际最大 id,会报InvalidArgumentError: indices[0] = 1000 is not in [0, 1000)。解决办法是训练前先扫一遍数据,拿到每个类别特征的最大值,input_dim设成max_id + 1。

wide 侧的维度对不上,通常是Dense层的units和输入维度不匹配。子类 API 里Dense会自动推断输入维度,但如果你的 wide 输入是稀疏张量,得先tf.sparse.to_dense或者用tf.sparse.sparse_dense_matmul。我一般建议 wide 侧也用Embedding加sum的方式,把稀疏 one-hot 转成稠密向量,这样和 deep 侧的处理逻辑统一,调试也方便。

5.2 模型不收敛的几种可能

现象可能原因排查方法
loss 一直是 0.69 左右输出层没接对,或者标签没对齐打印model(inputs)看输出范围
loss 震荡剧烈学习率太大,或 batch_size 太小降学习率到 1e-4,增大 batch
AUC 一直 0.5特征没信息量,或 embedding 没更新检查特征和标签的相关性
验证 loss 上升过拟合加 dropout、L2、减层数
wide 侧梯度为零稀疏特征没被激活检查 one-hot 是否全零

5.3 子类 API 调试的独门技巧

子类 API 最大的痛点是看不到中间张量。我的做法是在call里临时加tf.print,或者把中间结果存到self.debug_dict里,训练完再取出来看。

def call(self, inputs, training=None): wide_logit = self.wide(inputs['wide']) deep_logit = self.deep(inputs['deep_cat'], inputs['deep_num']) if training: tf.print('wide_logit mean:', tf.reduce_mean(wide_logit)) tf.print('deep_logit mean:', tf.reduce_mean(deep_logit)) return tf.nn.sigmoid(wide_logit + deep_logit)

tf.print只在training=True时打印,避免推理时刷屏。另外,model.summary()在子类 API 下只显示顶层 layer,想看细节可以用model.layers遍历,或者给每个子 layer 起个有意义的名字,summary里至少能看到名字。

还有一个技巧:先用小数据跑通再上全量。我一般会造 100 条样本,跑 2 个 epoch,确认没有维度错误、没有 NaN,再换全量数据。这样调试成本低很多。

5.4 性能优化的一些经验

wide 侧如果维度真的到了百万级,稠密矩阵存不下,必须用稀疏表示。tf.sparse.SparseTensor配合tf.sparse.sparse_dense_matmul可以做稀疏矩阵乘法。但注意,稀疏操作在 GPU 上支持有限,如果 wide 侧特别稀疏,放 CPU 上算可能更快。

deep 侧的 embedding 如果词表特别大(比如千万级),可以用tf.keras.layers.Embedding配合input_dim大值,但显存会吃紧。这时候可以考虑 hash embedding,把大词表哈希到固定大小的桶里,代价是会有冲突,但实际效果通常可以接受。

混合精度训练在 TF 2.0 里用tf.keras.mixed_precision.set_global_policy('mixed_float16')就能开,能省显存、加速训练,但要注意 loss scaling,不然梯度会下溢。我一般只在模型比较大、显存吃紧的时候开。

6. 从 wide&deep 延伸出去的几个方向

wide&deep 是个很好的起点,理解了它,很多后续模型都能顺藤摸瓜。比如DeepFM把 wide 侧的线性部分换成了 FM,自动做二阶特征交叉,省去了人工交叉的麻烦。DCN用 cross network 显式做高阶交叉。AutoInt用 attention 做特征交互。这些模型的结构都比 wide&deep 复杂,但核心思想一脉相承:记忆和泛化要兼顾,稀疏特征要 embedding,交叉特征要显式建模。

如果你想在 wide&deep 上继续折腾,我建议从这几个方向入手:一是把 wide 侧的人工交叉换成自动交叉,试试 FM 或者 cross layer;二是给 deep 侧加 attention,让模型自己学哪些特征重要;三是把 embedding 换成预训练的,用物品的 side information 初始化,冷启动场景下效果提升明显。

最后分享一个我在实际项目里的体会:wide&deep 的调参优先级是 特征 > 结构 > 超参。特征工程做得好,一个简单的 wide&deep 就能打败精心调参的复杂模型。所以别一上来就纠结 hidden_units 设多少、embed_dim 设多少,先把特征交叉做扎实,把类别特征的覆盖率提上去,效果自然就来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询