资讯详情

TensorFlow 2.0实现WideDeep多输入多输出模型实战

📅 2026/10/16 11:57:43 | 华诺云谱 👁 阅读
TensorFlow 2.0实现WideDeep多输入多输出模型实战
1. 为什么需要多输入多输出widedeep的工程化实践1.1 记忆与泛化widedeep模型的核心思想widedeep这个结构最早是为了解决搜索场景里的点击率预估问题。它把模型拆成两条通道一条wide通道一条deep通道各自干各自擅长的事。wide通道是线性模型输入通常是经过交叉组合后的稀疏特征。它学的是“记忆”只要某个特征组合在训练样本里反复出现过模型就能以极快的速度把这种组合的权重记下来。打个比方你第一次吃宫保鸡丁觉得好吃以后再看到“鸡丁花生辣椒”这个组合就知道大概率好吃这就是记忆。wide通道擅长处理这类高置信度的历史模式。deep通道是神经网络输入会经过embedding层映射成稠密向量再通过多层全连接做非线性变换。它学的是“泛化”哪怕某个特征组合在训练里完全没有出现过只要跟已有组合在语义上相似模型也能推测出结果。比如你吃过宫保鸡丁第一次看到“鸡脆骨花生辣椒”的组合虽然没吃过但根据食材相似性能猜出味道不错这就是泛化。两条通道的输出合并后接上输出层。这个设计的关键在于单一模型同时保留了“快速记忆”和“泛化推断”两种能力比单独使用线性模型或深度模型效果都更稳。这也是widedeep多年来在推荐、广告、搜索领域长盛不衰的核心原因。1.2 多输入多输出的真实业务价值我在做内容分发项目时最头疼的不是模型效果而是业务目标太多。用户可能点一个物品也可能看完之后花很长时间停留还可能点了之后立即返回。如果每个目标都单独训练一个模型线上要维护两套Service特征要做两份训练流程也得并行跑。更麻烦的是两个模型的底层信息并不共享模型A学到的用户表示模型B完全用不上。多输入多输出就是为了解决这类问题。所谓多输入就是模型接收多个输入张量每个输入张量对应一路特征来源。用户侧特征是一路物品侧特征是一路上下文特征又是另一路给每一路单独定义Input节点模型内部按照各自的方式处理最后汇总。所谓多输出就是一个模型同时输出多个预测。比如同时输出点击概率二分类、停留时长回归、分享概率二分类。这些预测共享底层的embedding和全连接表示只是到最后才分出各自的任务头。这样做的好处很直接部署时只需一个模型、一个推理接口主任务和辅助任务共享特征表示样本量少的一方也能受益训练一次搞定多个目标运维复杂度大大降低我见过很多入门教程里的widedeep实现都是把特征拼成一个大宽表然后一个输入一个输出跑通demo就完事。但真实业务里的数据流不是一张大宽表而是多路并行。所以这一篇想带你做的就是把玩具模型升级成能应对真实场景的多输入多输出版本。2. 数据与特征怎么拆多输入多输出的前置设计2.1 特征分组哪些进Wide哪些进Deep在动手写代码之前得先想清楚特征往哪条通道放。放错了模型效果会大打折扣。wide侧适合放强记忆类特征尤其是业务规则特征。比如“用户性别男 且 物品类目游戏”这类组合特征在历史日志里反复出现点击率明显高于其他组合就应该放到wide侧。这类特征通常是one-hot编码后的稀疏向量维度可以很大但实际非零位置很少。wide侧的线性加权就是对这些稀疏向量做直接记忆。deep侧适合放稠密特征和需要泛化的离散特征。连续特征比如用户年龄、物品价格、历史点击次数直接归一化后拼进去。离散ID类特征比如用户ID、物品ID需要经过embedding层映射成稠密向量。embedding的好处是可以把相似的物品映射到相近的向量空间从而产生泛化能力。我整理了一个常用的分组表格特征类型放入通道常见特征示例处理方式交叉组合特征Wide性别×类目、年龄段×品牌、城市等级×类目手工构造后直接输入离散ID特征Deep用户ID、物品ID、店铺IDEmbedding映射连续数值特征Deep价格、时长、点击次数标准化后拼接业务规则特征Wide新品标记、黑名单标记、活动标记直接输入或交叉后输入注意一点wide侧的交叉特征需要在数据预处理阶段就构造好模型本身不会自动做特征交叉。比如“男性用户对游戏类目”这个组合特征你要在样本里先把这列算出来性别为男且类目为游戏时置1否则置0。如果你的输入是用户特征表、物品特征表两份数据可以先各自处理再在外层做交叉拼接。连续特征进deep侧之前一定要做归一化。我见过有人直接把原始价格和embedding向量拼接结果训练初期loss震荡得厉害。原因很简单价格数值可能是几百上千而embedding向量通常在[-1, 1]附近两者拼接后梯度方向被数值大的特征完全主导。建议用标准化减均值除标准差或者做log变换压缩尺度。2.2 多输出的目标设计与搭配多输出不是随便堆两个头目标之间的相关性直接决定模型能不能训好。最常见的搭配是“主任务辅助任务”。主任务是业务最关心的目标比如点击率辅助任务是跟主任务相关的次目标比如点后停留时长、点后是否下单、是否点赞。辅助任务通常样本量更大、噪声相对低可以通过共享表示帮助主任务收敛。但也有坑。我踩过一次主任务预测点击率辅助任务预测“用户是否在30天内复访”结果辅助任务一直不收敛主任务也跟着掉点。原因是“是否复访”跟点击行为的相关性很弱而且受太多外部因素影响强行让模型学这个目标相当于往共享embedding里塞了很大的噪声。多输出目标设计时建议把握几个原则目标语义相关性辅助任务必须和主任务共享某些底层逻辑比如都是围绕“用户对物品的兴趣”展开样本口径一致同一个样本主标签和辅标签必须来自同一次曝光点击上下文不能拿不同时间窗口的数据乱拼任务难度匹配辅助任务如果太难比如极高稀疏的购买预测可以用分桶或者回归拟合平滑版本不要硬上原始标签损失函数匹配二分类用binary_crossentropy多分类用categorical_crossentropy回归用mse或mae我当时负责的电商内容流场景最终选了主任务CTR 辅助任务“平均播放时长”的组合。播放时长是个连续值很多样本是0点了没播几秒所以我对时长做了log1p变换把长尾压缩了一下再用mse回归效果明显比直接回归原始秒数稳定。3. 基于TensorFlow 2.0的函数式API实现多输入多输出3.1 为什么必须用函数式APITensorFlow 2.0里搭模型有三种方式Sequential、函数式API、子类化。Sequential只能支持单输入单输出一层层往后堆适合最简单的全连接网络。多输入多输出场景下Sequential直接排不上用场。子类化虽然灵活但需要自己写call方法和训练循环代码量大不利于快速迭代。函数式API是这题的最优解。它的核心是用张量作为连接器你定义Input张量然后把这个张量传给各层各层返回新的张量最后通过keras.Model(inputs..., outputs...)声明模型。这种声明式写法天然支持多输入多输出、共享层、分支结构而且结构一目了然。3.2 定义输入层命名是后续一切的关键先定义输入层。这里我用四个输入模拟一个典型的推荐场景user_id_input用户ID整数序列shape(1,)item_id_input物品ID整数序列shape(1,)cross_feat_inputwide侧的交叉组合特征比如5维稠密向量dense_feat_inputdeep侧的连续特征比如8维向量import tensorflow as tf from tensorflow import keras from tensorflow.keras import layers user_id_input keras.Input(shape(1,), dtypeint32, nameuser_id) item_id_input keras.Input(shape(1,), dtypeint32, nameitem_id) cross_feat_input keras.Input(shape(5,), dtypefloat32, namecross_feat) dense_feat_input keras.Input(shape(8,), dtypefloat32, namedense_feat)这里的name参数非常关键。后面用model.fit传入训练数据时输入字典的key必须严格等于name比如x{user_id: ..., item_id: ..., cross_feat: ..., dense_feat: ...}。一旦有一个对不上Keras会直接报错而且报错信息有时候不够直观排查起来很费劲。shape(1,)表示每个样本是一个长度为1的序列这个shape对Embedding层正好合适。如果误写成shape(1, 1)Embedding后张量维度会多一层后面做Flatten和Concatenate时很容易出现维度不匹配。3.3 Wide侧线性通道的两种写法wide侧在原始论文里就是一个线性模型输入稀疏交叉特征输出加权的标量logit。在Keras里最直接的写法是wide_logits layers.Dense(1, use_biasTrue)(cross_feat_input)Dense(1)把5维交叉特征压缩成1个标量这就是wide侧的记忆输出。有些工程实现会在wide侧也加一层ReLU激活wide_hidden layers.Dense(32, activationrelu)(cross_feat_input) wide_logits layers.Dense(1)(wide_hidden)这样做的好处是wide侧也能学到一定的非线性表达能力坏处是会削弱“记忆”的纯粹性而且增加过拟合风险。我的建议是如果交叉特征已经构造得比较充分用最简单的线性Dense(1)就够了如果你手头特征质量一般可以试着加一层非线性做缓冲然后用验证集对比效果决定去留。还有一个容易忽略的细节wide侧的输入如果也是稀疏的ID类特征需要先做one-hot或用DenseFeatures处理。我这里直接用稠密化的交叉特征向量省掉one-hot那一步工程上更省内存。3.4 Deep侧Embedding、拼接与全连接deep侧先处理两个ID类特征。Embedding层的作用是把离散ID映射成稠密向量user_embed layers.Embedding(input_dim10000, output_dim32, nameuser_embedding)(user_id_input) item_embed layers.Embedding(input_dim50000, output_dim32, nameitem_embedding)(item_id_input) user_vec layers.Flatten()(user_embed) item_vec layers.Flatten()(item_embed)这里有一个关键点Embedding层输入是三维张量(?, 1, 32)必须Flatten成(?, 32)才能和后面的连续特征拼接。不Flatten直接Concat维度不匹配Keras会报错。然后把所有deep侧的特征拼接成一个向量。注意拼接顺序可以任意但后续模型一旦训练必须保持同样的顺序否则线上推理结果会乱。deep_concat layers.Concatenate()([user_vec, item_vec, dense_feat_input]) deep_hidden layers.Dense(128, activationrelu)(deep_concat) deep_hidden layers.Dense(64, activationrelu)(deep_hidden) deep_logits layers.Dense(1)(deep_hidden)关于中间层大小128、64只是我常用的起步值具体要看你拼接后的特征维度。大原则是第一层不要比输入维度小太多否则信息压缩太狠。比如拼接后特征维度是72第一层用128是合理的如果拼接后只有20维第一层用128反而容易过拟合。连续特征dense_feat_input在拼接前务必确认已经做了标准化。我见过不少新手在预处理阶段忘了这步训练时看到模型loss不降第一反应是改模型结构调了半天才发现是特征尺度问题。3.5 组装多输出共享层、分支层多输出的组装方式直接影响两个任务之间的信息共享程度。第一种方式贴合原始论文wide的logit和deep的logit直接相加再过sigmoid作为CTR输出。辅助任务从deep分支单独拉出merged_logits layers.Add()([wide_logits, deep_logits]) ctr_output layers.Activation(sigmoid, namectr)(merged_logits) duration_branch layers.Dense(32, activationrelu)(deep_hidden) duration_output layers.Dense(1, activationlinear, nameduration)(duration_branch) model keras.Model( inputs[user_id_input, item_id_input, cross_feat_input, dense_feat_input], outputs[ctr_output, duration_output] )这个结构里CTR任务看到了wide侧的记忆信息和deep侧的泛化信息而duration任务只用了deep侧的表示。适合那种“辅助任务跟记忆特征关系不大更依赖泛化特征”的场景。第二种方式工程里更常见先concat再分叉combined layers.Concatenate()([deep_hidden, wide_logits]) ctr_output layers.Dense(1, activationsigmoid, namectr)(combined) duration_branch layers.Dense(32, activationrelu)(combined) duration_output layers.Dense(1, activationlinear, nameduration)(duration_branch)这个结构里两个任务共享了wide和deep的全部信息然后再各自用独立的全连接层做差异化。适合两个任务相关性较强、可以共享大部分底层的场景。分支位置是个可以调的超参。分支越早各任务独立性越强越不容易互相干扰分支越晚共享信息越多但梯度冲突的风险也越大。我一般会先在共享层后面直接分叉然后观察辅助任务的收敛情况如果不理想再考虑加深共享层或提前分支。多输出还有一个输出头设计细节二分类输出头用sigmoid回归输出头用linear。我见过有人把回归任务的输出也随手上了sigmoid结果预测值永远被压到0到1之间而真实标签是几十秒的播放时长怎么训都学不动。3.6 编译与训练多损失、多指标与字典数据流多输出模型的编译需要给每个输出指定损失函数和评估指标。Keras支持通过字典方式精确配置model.compile( optimizerkeras.optimizers.Adam(learning_rate0.001), loss{ ctr: binary_crossentropy, duration: mse }, loss_weights{ ctr: 1.0, duration: 0.5 }, metrics{ ctr: [keras.metrics.AUC(nameauc), accuracy], duration: [keras.metrics.MAE(namemae)] } )这里loss_weights是调参重点。两个损失函数的值域不一样binary_crossentropy通常在0到1之间而mse对播放时长这种大数值标签初始时可能动辄几十上百。如果不设loss_weightsmse会主导梯度更新CTR任务很难收敛。我的经验是先跑一个batch看两个loss的初始量级然后通过loss_weights把两者拉到一个数量级。比如mse初始值是50binary_crossentropy初始值是0.7那么mse的权重就应该是0.01左右让梯度贡献大致均衡。当然这不是固定的随着训练推进loss会变化也可以在训练中期回调里动态调整。模型训练时输入和标签都传字典model.fit( x{ user_id: user_ids, item_id: item_ids, cross_feat: cross_features, dense_feat: dense_features }, y{ ctr: click_labels, duration: duration_labels }, validation_split0.2, epochs10, batch_size256 )如果你用tf.data.Dataset构建训练管道也要保持同样的字典结构。比如dataset tf.data.Dataset.from_tensor_slices(( {user_id: user_ids, item_id: item_ids, cross_feat: cross_features, dense_feat: dense_features}, {ctr: click_labels, duration: duration_labels} )).batch(256)多输出模型的评估会同时打印所有输出对应的指标训练日志里能同时看到ctr_auc、duration_mae这对监控多任务是否均衡很有帮助。4. 多输入多输出的踩坑实录与排查技巧4.1 fit输入字典key与Input名称对不上典型报错是ValueError: Unknown entry in x dictionary: xxx。几乎每个接触多输入的新手都会踩一次。原因很简单fit传字典的key必须和Input层的name完全一致。比如Input的name是user_id你传x{user_id_array: ...}就报错。排查方法也很直接打印model.input_names逐个对照fit里的键名。这里再提醒一个容易忽略的地方如果Input层没有显式指定name参数Keras会自动按顺序命名为input_1、input_2所以最好全部显式命名否则后面维护代码时根本记不住哪个是哪个。4.2 Embedding维度设置错误导致越界Embedding层的input_dim必须大于等于特征的最大索引值加一。因为索引从0开始如果用户ID最大是999input_dim至少要是1000。写成inf或小于999会直接报IndexError或者数值表现诡异。更隐蔽的问题是训练时出现了没见过的ID。这种情况在线上很常见新用户注册、新物品上架。如果直接喂给Embedding层Keras会报类似Invalid argument: indices[xx] is not in [0, 10000)的错误。解决办法有两种预处理阶段把所有未知ID哈希到一个固定的“未知桶”比如hash(user_id) % 10000Embedding层里设置mask_zero或单独加一个未知向量兜底推荐第一种简单可控。具体做法是在构造训练数据时先对ID做数字化然后记录ID最大值再在数据管道里加一步unknown_id的哈希映射。4.3 loss_weights的数值敏感度问题多输出模型最常见的“看似训好、实则没训好”状态就是某个任务先收敛另一个任务一直不动。这时候优先检查loss_weights是不是被数值大的任务主导了。我之前做过一个模型的CTR loss初始0.6播放时长loss初始25开始用等权重1:1结果前几个epoch CTR auc不仅不涨还在掉。打印日志发现反向传播的梯度大部分来自mse。把loss_weights改成1.0:0.05之后两个任务才同步收敛。进阶方案是用不确定性加权为每个输出学一个可训练的对数方差参数log_var_ctr tf.Variable(0.0, trainableTrue) log_var_duration tf.Variable(0.0, trainableTrue) loss 0.5 * ctr_loss / tf.exp(log_var_ctr) log_var_ctr 0.5 * duration_loss / tf.exp(log_var_duration) log_var_duration这种方式训练初期就能自动平衡两个任务的权重省去手动调参的功夫。代价是需要自定义训练循环代码复杂度上了一个台阶。新手建议先手动调loss_weights等熟练了再上不确定性加权。4.4 多输出头激活函数与评估指标不匹配输出头的激活函数选错是另一个常见问题。分类任务用sigmoid作为最后一层激活输出落在(0,1)之间配合binary_crossentropy损失。回归任务用linear不加激活输出可以是任意实数配合mse或mae损失。如果分类任务用linear输出模型也能训练但数值极不稳定容易出现loss爆炸。如果回归任务用sigmoid输出预测范围被限制在(0,1)大数值标签基本学不动。评估指标设置也要注意metrics字典里的key同样必须是输出层name。比如输出层叫train_ctrmetrics里写成ctr指标不会生效训练日志里看不到。设置完可以用model.metrics_names检查是否生效。4.5 多任务互相干扰时怎么办多任务模型的一个经典现象是跷跷板效应一个任务指标上升另一个任务掉。这通常发生在两个任务在共享层传递信息时产生了梯度冲突。遇到这种情况我有几个排查顺序先看两个任务的loss值域是否在同一量级不是就先调loss_weights把共享层宽度增大给两个任务更多的表达空间把分支位置往前移降低共享程度给每个输出加独立的塔结构而不是直接从共享层接输出还可以用梯度归一化。原理不复杂分别计算两个任务的梯度方向如果它们之间的夹角大于阈值就只更新与夹角较小的那个任务相关的梯度。实现上要自己写优化器或训练循环适合对多任务有深入理解后再尝试新手先用前面的办法。4.6 模型导出与线上推理的边界问题多输入多输出模型的训练和保存其实跟单输出模型没有本质区别model.save(wide_deep_mimo.keras)线上加载时如果用model.predict要把输入组织成字典格式输出会按output层的顺序返回多个结果。如果你用的是TensorFlow Serving需要确认请求里输入字段名跟Input层name一致否则服务端会返回字段不匹配的错误。我导出模型后一般会先做一次本地预测把单样本走一遍确认输出顺序。这个习惯帮我避免了好几次线上事故。因为model.predict返回的是列表列表里每个元素对应一个输出头顺序按outputs定义时的顺序排列而不是按name。如果你不确定用model.output_names打印一眼。5. 一点个人体会多输入多输出不是把模型变复杂而是让模型结构更贴近真实业务的数据流。我在内容分发项目里把单任务模型切换成widedeep多输入多输出之后线上点击率提升了一个明显幅度因为辅助的播放时长任务帮CTR任务学到了一些短期内点击率表达不了的兴趣信号。但前提是辅助任务和主任务确实有语义关联否则反而拖后腿。调参方面的经验是先跑通再调优。第一版用最简单的线性wide、单层共享、固定loss权重把全链路跑通记录各任务的指标第二版再逐步加复杂度对照验证集决定每个改动是去是留。多任务模型的参数空间很大一次加太多变量出了性能倒退都定位不到原因。另外一个容易被忽略的点是数据管道的设计。多输入多输出模型对数据格式要求严格输入和标签都要是字典结构。建议在写模型之前先把数据管道跑一遍打印一个batch出来确认shape和key都是对的再开始搭模型这样可以省出大半天调试时间。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑