做推荐、广告、搜索相关的同学对 widedeep 这个名字应该不陌生。2016 年那篇论文出来之后它在业内几乎是“记忆与泛化结合”的标准模板原理也很直白线性模型负责记忆深度网络负责泛化两条路径最后汇合输出。但很多教程写到这里就停了给的 demo 都是单输入单输出的玩具版本——一个特征向量进去一个点击率出来干净是干净可真到了业务里根本不是这么回事。推荐系统的真实输入往往同时有用户侧、物品侧、上下文侧三拨特征输出侧也不止一个目标既想看点击率又想知道播放时长这就自然引出了多输入多输出结构。本节对应 TensorFlow 2.0 入门到进阶系列的 2.14 部分我们就认真拆一下怎么用 TensorFlow 2.0 把多输入多输出的 widedeep 模型完整搭起来从思路到数据再到代码一条线走通。1. widedeep 模型的结构逻辑与多输入多输出的现实意义1.1 为什么要让“记忆”和“泛化”两条腿走路widedeep 的核心思想用大白话讲就是一个模型里同时装两种学习能力。wide 部分是线性模型擅长“记忆”。什么是记忆就是直接捕捉用户行为里的强相关性。比如一个用户之前装过修图软件你再给他推修图软件他大概率会点这种“见过所以熟悉”的模式线性模型配合人工交叉特征就能学得很好。它的特点是简单、可解释、响应快但坏处是没法举一反三——没见过的特征组合它基本给不出有效预测。deep 部分是深度神经网络擅长“泛化”。它通过 embedding 把离散特征映射成稠密向量再经过多层非线性变换去挖掘那些“表面看不出来但底层有关系”的模式。用户装过修图软件deep 模型可能捕捉到他其实对“创意工具”这个隐向量空间感兴趣于是修图之外还能推剪辑、推海报工具。泛化能力强但对长尾的强记忆模式反而容易学得不够直接。把两者拼接起来本质上是让模型既记住已经发生过的强关联又具备推断未见过组合的能力。当时论文里的场景是做应用商店推荐你装了什么应用、模型看了什么应用、它们之间交叉关系长什么样正是这种“既有记忆需求、又有探索需求”的典型场景。放到今天这套结构在信息流推荐、电商召回、广告 CTR 预估里依然是常青树只不过大家会在 wide 和 deep 的连接方式上做各种变体。1.2 从单输入单输出到多输入多输出业务形态逼着你这么做很多教材里 widedeep 的示意图很简单底部一个特征向量中间 wide 和 deep 两条腿顶部一个输出。这个结构放在教学里没问题但放到真实业务里立刻会撞上两个现实问题。第一输入侧根本不是单一特征向量。用户特征、物品特征、上下文特征这是三个天然独立的来源。用户特征里面有年龄、性别、历史行为统计物品特征里面有 ID、类目、属性标签上下文特征有当前时间、渠道、网络环境。这些特征在建模时往往要走完全不同的处理路径用户 ID 和物品 ID 这类高基数类别特征要过 embedding年龄时长这类数值特征要归一化后直接输入时间这种周期特征甚至要拆成周期编码。把这几路特征生硬地拼成一个向量再塞进模型不是不行但每一路特征的加工方式就被锁死了后续想对某一路单独调整也很别扭。多输入结构本质上不是炫技而是业务特征的天然形态倒逼出来的结果。第二输出侧往往也不止一个目标。还是拿视频推荐举例你既希望用户点进来又希望他看得久甚至希望他看完之后点赞关注。点击率和播放时长是两个性质完全不同的目标——前者是 0/1 分类后者是连续回归。传统的做法是分别训练两个模型各自预测一个目标但这样两个模型底层学到的特征表示是重复的而且线上要维护两套系统成本和延迟都翻倍。多输出结构则让两个任务共享底层的 embedding 和隐藏层只在上层分叉出不同的输出头。共享表示的好处是点击率任务学到的用户兴趣向量播放时长任务也能借力尤其当某个任务的训练样本比较少时共享底层能明显缓解冷启动问题。所以多输入多输出不是“为了高级而高级”而是当特征来源天然分路、业务目标天然多个时最合理的建模方式。搞懂这一点后面写代码才有方向。2. 数据侧准备多输入特征与多输出标签怎么组织2.1 三类输入特征用户、物品与上下文的处理口径动手写模型前先把数据形态理清楚。多输入不是随便拆几个输入层就完了每一路输入的处理方式要和特征的业务含义对齐。我按最常见的三类来拆。用户侧特征最典型的是用户 ID、年龄段、性别、历史统计量。用户 ID 是典型的高基数类别特征有几万甚至几百万个取值这种特征不能直接当数值用要过 embedding。年龄段、性别这类低基数类别特征可以直接做 one-hot 后进线性模型也可以做小维度 embedding。历史统计量比如“过去 7 天点击率”“平均播放时长”这些是连续值要做归一化或者分桶。物品侧特征最常见的是物品 ID、品类 ID、关键词向量。物品 ID 同样高基数必须 embedding。品类 ID 是低基数类别特征通常也走 embedding 但维度可以小一些。如果物品本身有文本描述那可能还要接一个文本编码分支这里先不展开。上下文侧特征典型的是当前小时、星期几、设备类型、网络环境。小时和星期有周期性直接当数值输入会有“23 点和 0 点差别巨大”的假象实际它们应该首尾相连。处理上可以拆成 sin/cos 编码也可以先分桶再 embedding。设备类型、网络环境是低基数类别特征做法和品类类似。我给一个直观的分组表特征分组典型特征处理方式进哪条分支用户 ID 类user_idEmbedding Flattendeep用户数值类age、历史点击率归一化wide deep物品 ID 类item_idEmbedding Flattendeep物品属性类category_idEmbedding Flattendeep上下文数值类hour、day_of_week归一化/周期编码wide deep交叉特征类用户历史类目与当前物品类目是否一致0/1 数值wide注意最后一行wide 部分真正的价值体现在交叉特征上。标准论文里wide 侧会构造类似“用户安装过的应用 A 与当前曝光应用 B 是否同属一个类目”这样的交叉信号这类特征的表达能力来自人工设计而不是模型自己学。代码演示里我会用一组简化特征代替但你要明白这个设计意图。2.2 多输出标签的构造与预处理输出侧我按视频推荐的经典多任务来定一个 CTR 二分类标签一个播放时长回归标签。CTR 标签很好处理曝光没点就是 0点了就是 1直接喂 binary_crossentropy。但播放时长需要注意两点。第一播放时长的分布通常很偏。短视频场景下大量用户要么不点时长为 0要么看几秒就划走少数人看到最后。如果直接拿原始秒数做 MSE 回归长尾样本会把梯度拉飞模型很容易只学会预测均值。实际做法通常是对时长做变换常见的有 log1p 变换后回归或者除以某个最大时长做归一化。我这节为了代码简洁用“除以 300 秒”做一个粗略归一化让你先跑通流程真实项目里强烈建议用 log1p。第二多任务标签存在天然的不对齐。一条曝光样本一定有 CTR 标签但不一定有时长标签——用户压根没点进来哪来的播放时长。这种“部分标签缺失”是多任务落地时绕不开的问题。TensorFlow 2.0 里的处理办法是缺失的标签位置填一个任意值然后给对应损失加 sample_weight 置 0。不过这样代码会复杂不少这节先假设每条样本都有完整的两个标签把主流程讲透缺失标签的权重方案放到后面的坑位章节里补充。2.3 用 numpy 生成一份可复现的模拟数据集没有真实数据的时候用模拟数据跑通流程是最好的学习方式。下面这份数据覆盖了我们前面说的三路输入和两路输出。import numpy as np np.random.seed(42) n_samples 50000 n_users 10000 n_items 50000 n_categories 100 # 输入特征 user_ids np.random.randint(0, n_users, size(n_samples, 1)) user_age np.random.randint(18, 60, size(n_samples, 1)).astype(np.float32) user_age (user_age - 18.0) / 42.0 # 简单归一化 item_ids np.random.randint(0, n_items, size(n_samples, 1)) item_categories np.random.randint(0, n_categories, size(n_samples, 1)) hour np.random.randint(0, 24, size(n_samples, 1)).astype(np.float32) hour hour / 23.0 # 归一化到 [0, 1] # 模拟一个交叉特征用户历史类目是否与当前物品类目一致 clicked_categories np.random.randint(0, n_categories, size(n_samples, 1)) cross_feature (clicked_categories item_categories).astype(np.float32) # 输出标签 ctr_label np.random.randint(0, 2, size(n_samples, 1)).astype(np.float32) # 播放时长除以 300 做归一化 duration_label np.random.randint(0, 300, size(n_samples, 1)).astype(np.float32) / 300.0这份模拟数据的关键在于数值特征我已经做了归一化类别特征保持了原始 ID 形态让模型内部做 embedding。你拿到真实数据后套用同样的数据结构就行——类别特征保持整数 ID数值特征归一化到大致 [0,1] 或 [-1,1] 区间这一步直接决定后面训练是否稳定。交叉特征我特意单独列了一列就是为了给你演示 wide 侧核心输入长什么样。3. 模型代码实现函数式 API 搭建多输入多输出 widedeep3.1 为什么必须用函数式 APISequential 为什么不行TensorFlow 2.0 里搭建模型有 Keras 的 Sequential 和函数式 API 两种主流方式到了多输入多输出这个场景Sequential 基本可以直接排除。Sequential 的本质是“一层接一层堆叠”它假设模型是一个单进单出的线性栈。可我们的模型结构是多个输入分支先各自处理然后在中间汇合wide 和 deep 又各走各路最后还要分叉成两个输出。这个结构是个有向无环图不是一条线Sequential 根本表达不了。函数式 API 的表达方式是把每一层都当成一个可调用的函数输入张量经过某层处理后得到新的张量然后你可以自由地把这些张量拼接、相加、分叉。它提供的是“张量流动”的视角非常贴合 widedeep 这种多分支结构。我自己在带新手时经常打个比方Sequential 是流水线作业一个工件从头走到尾函数式 API 是车间里的并行加工网络零件可以分组加工最后再总装。多输入多输出就是典型的并行加工场景。3.2 输入层与 embedding 的搭建细节先定义五个输入层。这里有个细节直接影响后续训练的写法每个 Input 都必须显式命名因为后面 fit 的时候要用字典形式传数据字典的键必须和 Input 的 name 一一对应。import tensorflow as tf from tensorflow.keras.layers import ( Input, Embedding, Flatten, Concatenate, Dense, Dropout, Add, Activation ) from tensorflow.keras.models import Model # 输入层 user_id_input Input(shape(1,), nameuser_id) item_id_input Input(shape(1,), nameitem_id) item_cat_input Input(shape(1,), nameitem_category) user_age_input Input(shape(1,), nameuser_age) hour_input Input(shape(1,), namehour) cross_input Input(shape(1,), namecross_feature)然后是 embedding 层。Embedding 的输入必须是整数 ID输出是一个 (batch, 1, emb_dim) 的张量所以要先用 Flatten 压成 (batch, emb_dim) 才能参与后续拼接。embedding 维度的选择有点讲究。用户 ID 有一万个取值物品 ID 有五万个取值我分别取了 64 和 128经验规律大致是“取类别数的 4 次方根左右再在 8 到 128 之间调”。维度太小装不下足够的语义信息维度太大参数量涨得飞快还容易过拟合。类目 ID 只有 100 个取值16 维足够了。# Embedding 层 user_emb Flatten()(Embedding(n_users, 64, nameuser_emb)(user_id_input)) item_emb Flatten()(Embedding(n_items, 128, nameitem_emb)(item_id_input)) cat_emb Flatten()(Embedding(n_categories, 16, namecat_emb)(item_cat_input))3.3 wide 分支、deep 分支与多输出头设计wide 部分的设计我在前面强调过它本质是线性模型。这里我用了一个简化处理把归一化后的数值特征、交叉特征以及三路 embedding 结果拼在一起然后过一个不带激活函数的 Dense 层。严格来说标准 wide 侧的特征应该是稀疏的 one-hot 或分桶特征直接走线性层。但为了在 Keras 里统一处理高基数 ID我用 embedding 结果代替原始 one-hot这在工程上是一种常见的 pragmatic 做法。代价是 wide 部分不再有真正的“稀疏线性可解释性”但结构上仍然保留了一条更短的、偏向线性组合的路径。deep 部分的输入也是同样的五路特征。它和 wide 的差别不在于输入而在于中间经过了几层非线性变换。deep 侧我把三路 embedding 和数值特征拼接后连续过 128、64、32 三个全连接层中间加 Dropout 防止过拟合。这里每层神经元数量习惯性从大到小压缩让信息逐层抽象。关键的多输出设计来了。我在 deep 的最后一层分出两条路一路经过 Dense(1) 得到 deep 侧的 logit另一路直接接一个duration_output用来回归播放时长。而 wide 侧的输出wide_out和 deep 侧的 logit 做 Add 相加得到 CTR 的最终 logit再套 sigmoid 激活。这样设计的好处是CTR 任务同时利用了 wide 的记忆能力和 deep 的泛化能力而时长任务只依赖 deep 的深层表示——因为播放时长本质是个性化兴趣问题几乎不需要人工交叉特征的“记忆”信号。# Wide 分支 wide_concat Concatenate(namewide_concat)( [user_emb, item_emb, cat_emb, user_age_input, hour_input, cross_input] ) wide_out Dense(1, namewide_out)(wide_concat) # Deep 分支 deep_concat Concatenate(namedeep_concat)( [user_emb, item_emb, cat_emb, user_age_input, hour_input, cross_input] ) deep_hidden Dense(128, activationrelu)(deep_concat) deep_hidden Dropout(0.3)(deep_hidden) deep_hidden Dense(64, activationrelu)(deep_hidden) deep_hidden Dense(32, activationrelu)(deep_hidden) deep_out Dense(1, namedeep_out)(deep_hidden) # 多输出头 ctr_logits Add(namectr_logits)([wide_out, deep_out]) ctr_output Activation(sigmoid, namectr_output)(ctr_logits) duration_output Dense(1, activationrelu, nameduration_output)(deep_hidden)看到这里你可能会问wide_out 和 deep_out 都是 (batch, 1) 的张量直接相加要求形状完全一致这里没问题。但训练初期 wide 和 deep 的数值量级如果差异很大Add 之后的结果会被量级大的那方主导。所以我在代码里刻意把所有数值特征都归一化到 [0,1] 附近embedding 在初始化时也是小数值这就避免了一方压过另一方的问题。3.4 完整可运行代码把模型组装起来这一步就完成了模型定义部分。我用字典形式声明 inputsoutputs 用列表这样结构一目了然。# 组装模型 model Model( inputs{ user_id: user_id_input, item_id: item_id_input, item_category: item_cat_input, user_age: user_age_input, hour: hour_input, cross_feature: cross_input, }, outputs[ctr_output, duration_output] ) model.summary()执行model.summary()你会看到输入层是六个不同的 Input中间两个大分支最后两个输出。整个参数量主要由两个大 embedding 撑起来这个数字在真实场景里可能更大需要关注显存占用。这份代码我建议你直接复制到本地跑。跑通之后可以自己改一版“wide 不接 embedding、只接数值和交叉特征”的版本对比一下参数量和效果差异。只有亲手把这两种结构都搭过一遍你才能真正理解 wide 侧特征设计的含义而不是停留在看图的层面。4. 编译训练与多任务权重调节4.1 loss、loss_weights、metrics 三个配置怎么填模型定义好了接下来是 compile。多输出模型在 compile 时最核心的是三个参数loss、loss_weights、metrics。它们都必须用字典或列表来指定而且字典的键必须是输出层的 name不能随便写。model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), loss{ ctr_output: binary_crossentropy, duration_output: mse, }, loss_weights{ ctr_output: 1.0, duration_output: 1.0, }, metrics{ ctr_output: [accuracy, tf.keras.metrics.AUC(nameauc)], duration_output: [mae], } )loss 和 loss_weights 的匹配规则是最终的总损失等于各任务损失乘上对应权重的加权和。比如duration_output的 mse 乘以 1.0ctr_output的 binary_crossentropy 乘以 1.0相加后作为总 loss 反传。如果你的某个任务明显更难学就可以把它对应的权重调大。为什么时长任务的 mse 前面我用了relu激活的输出因为播放时长归一化后取值范围在 [0,1] 区间relu 能保证预测值非负不会出现“预测 -0.3 秒”这种毫无意义的结果。但要注意relu 输出会导致部分神经元梯度断开如果你的数据里时长大量为 0可能要考虑改用 sigmoid 输出乘以最大时长或者干脆用linear激活配合 log1p 变换的标签。这些细节都会显著影响训练效果。4.2 用 fit 训练时输入输出如何命名对齐compile 里字典的键要和输出层 name 对齐fit 里也一样输入数据的键要和 Input 层的 name 对齐。我这里直接把 x 和 y 都写成字典顺序无所谓TensorFlow 会自动按 name 匹配。history model.fit( x{ user_id: user_ids, item_id: item_ids, item_category: item_categories, user_age: user_age, hour: hour, cross_feature: cross_feature, }, y{ ctr_output: ctr_label, duration_output: duration_label, }, epochs10, batch_size1024, validation_split0.2, )训练日志里你会看到一长串 loss 指标loss是加权后的总损失ctr_output_loss是点击率任务单独损失duration_output_loss是时长任务单独损失后面还会跟着 accuracy、auc、mae 这些各自任务的指标。这里有个很多人第一次见到会懵的点loss并不是ctr_output_loss和duration_output_loss的简单平均而是按loss_weights加权后的和。如果你把某个权重设为 2.0总 loss 会明显变大这属于正常现象不用慌。训练过程中我建议主要盯auc和duration_output_mae这两个指标比 loss 本身更能体现模型真实效果。ctr 的 accuracy 在正负样本不均衡时参考意义很小如果正样本只占 10%模型全预测负样本也有 90% 准确率所以 AUC 才是更可靠的判断依据。4.3 预测与模型保存导出训练完之后预测的输出格式需要提前了解。多输出模型的 predict 返回的是一个列表列表里每个元素对应一个输出层的结果。pred_ctr, pred_duration model.predict({ user_id: user_ids[:10], item_id: item_ids[:10], item_category: item_categories[:10], user_age: user_age[:10], hour: hour[:10], cross_feature: cross_feature[:10], }) print(pred_ctr.shape) # (10, 1) print(pred_duration.shape) # (10, 1)pred_ctr里的值是 sigmoid 输出的概率落在 [0,1] 区间pred_duration是归一化后的时长预测线上使用时需要反归一化乘回 300 秒才能得到真实播放时长预估值。很多刚上手多输出模型的同学在这里容易栽跟头只取pred[0]当成唯一预测却不知道pred[1]对应第二个输出输出顺序和模型定义时 outputs 列表的顺序一致不是字典。模型保存同样简单model.save(wide_deep_multi_output.keras)加载后直接可以预测输入输出结构和保存前完全一致。如果后续要转成线上 serving 格式注意给输入输出张量都保留清晰的 name线上引擎依赖这些名字做请求字段映射。我在实际项目里就吃过“输入层 name 不规范”的亏导出后调试了半天才发现字段对应错了。5. 常见坑位与排查记录5.1 输入键名与 Input 层 name 不匹配这是多输入模型新手最容易踩的坑。报错信息通常会提示某个输入缺失或者直接给出一个非常隐晦的 shape 不匹配错误。比如你把fit里的字典键写成item_id_input而 Input 层定义的是nameitem_id训练就会崩。排查方法只有一个把model.summary()打印出来的 Input 层 name 和 fit 传入的字典键逐一比对一个都不能差。我在写代码时有个习惯所有 Input 层的 name 全部用简洁的下划线命名比如user_id、item_categoryfit 传数据时严格复制粘贴这些名字绝不手动敲从根源上杜绝拼写不一致。5.2 多任务训练中的“跷跷板”问题多任务模型训练到中途经常会出现一个任务指标在涨、另一个任务指标在跌的“跷跷板”现象。原因很多最常见的是两个任务的 loss 量级和梯度量级差异太大。比如 binary_crossentropy 的 loss 通常在 0.3 到 0.7 之间而时长任务的 MSE 如果没做归一化可能是几百上千的数值后者梯度直接把前者淹没。解决思路就两个方向一是保证任务量级相近把回归标签归一化或 log1p 变换这是数据侧能做的二是在 loss_weights 上做文章把量级小的任务权重调高量级大的调低。我在模拟数据里把时长归一化到 [0,1]就是为了让两个 loss 天然处于同一个量级省去很多调权重的麻烦。如果调权重还是不稳定可以试试更进阶的不确定性加权把每个任务的权重也作为可学习参数让模型自己学出哪个任务应该更重要。这个方法在真实项目里我用过效果比手动调参稳定不少但前提是你的样本量和任务数量都合适样本太少时学出来的权重方差很大。5.3 稀疏特征 embedding 的维度与越界陷阱embedding 维度选错是另一个高频问题。维度太小模型学不到足够丰富的语义维度太大参数量爆炸且容易过拟合。我的经验值是几万基数的 ID 取 64 到 128 维几百基数的类目取 8 到 16 维。你可以把它当成一个起点然后通过验证集效果微调。还有一个非常隐蔽的坑是 ID 越界。Embedding 层的输入必须是[0, vocab_size)范围内的整数真实数据经过处理后会存在 ID 映射但训练集和测试集映射不一致、或者线上来了新 ID都会导致 embeddings 矩阵下标越界。处理办法是在 ID 映射时统一保留一个“未知 ID”槽位把所有没见过的新 ID 都映射进去避免训练测试阶段崩在数据处理上。5.4 不均衡样本与训练不稳定CTR 任务的样本天然不均衡正样本可能只有 5% 到 10%。如果你直接训练模型会倾向于把所有样本都预测成负样本AUC 看起来不高不低实际上根本没学到东西。解决手段有三个一是对负样本做下采样二是给少数类样本更高的 class_weight三是换成更关注排序指标的 loss比如 focal loss。class_weight { 0: 1.0, 1: 5.0 # 正样本少提高权重 }注意 class_weight 是按类别索引设置的所以只对分类任务生效时长回归任务不受影响。多输出模型里不同输出各吃各的参数这点在配置时要心里有数。训练不稳定还有个容易被忽略的来源batch 里全是不点击的样本时模型不会朝任何有效方向更新。我在项目里会开着 TensorBoard 实时看梯度直方图如果发现 ctr 分支的梯度经常为零那大概率是样本组成或者学习率设置出了问题这时候把初始学习率从 0.001 降到 0.0003往往能立刻看到改善。最后说一点我反复踩坑后的体会。widedeep 多输入多输出的代码框架并不复杂真正难的是想清楚每一路特征应该走 wide 还是走 deep、多任务权重怎么配、以及当任务指标冲突时你怎么取舍。我第一次搭这类模型时把 user_id 直接 one-hot 后扔进 wide参数量爆炸效果还不如一个简单的 LR。后来老老实实把 ID 类特征全部转 embedding 进 deep、把人工交叉特征留给 wide参数和效果才平衡下来。所以如果你现在也在搭这个模型我建议你先跑通上面的模拟数据再逐步把真实特征替换进去每一步都盯住验证集指标变化——这个过程里踩的坑比看十遍论文都有价值。
阅读完成 · 觉得有帮助?