TensorFlow 2.0入门到进阶系列写到现在,前面已经跑通了DNN、CNN、RNN这些基础结构。这一篇来聊一个在推荐系统、广告CTR预估场景里非常经典,而且至今还在大量使用的模型——Wide & Deep。
我最早接触这个模型,还是在某次技术交流会上,一个做应用商店推荐的哥们儿跟我抱怨:用户行为序列、上下文特征都加进去了,深度模型的AUC也提了,但线上就是觉得推荐结果"太飘",老推一些用户完全没兴趣的新东西。后来他把模型换成Wide & Deep,才把这个问题压下去。这个故事特别能说明Wide & Deep的价值:它不是要取代深度模型,而是给深度模型配一个"记忆搭档"。
这篇文章,我会帮你把Wide & Deep的核心原理拆透,把TensorFlow 2.0下的建模细节讲清楚,再把我实操中踩过的坑和排查思路整理成一份可以直接对照的清单。不管你是刚开始接触推荐模型,还是已经在用DNN做预估想进一步优化,这篇都适用。
1. Wide & Deep模型到底为什么会出现
1.1 从推荐系统的"记忆"与"泛化"说起
推荐系统里有两个词出镜率极高:记忆(Memorization)和泛化(Generalization)。很多人第一次听觉得是一对玄学概念,其实用大白话讲特别简单。
记忆,就是模型能牢牢记住那些"已经发生过、且被验证过有效"的组合。比如某个用户连续三次点击了同一款游戏,那下一次推荐里直接把这个游戏再推给他,就是典型的记忆。这类行为是稀疏的、确定性的,特征维度之间几乎没有交叉关系,就是单纯"A发生过,所以A再出现时应该被放大"。
泛化,则是模型能举一反三。用户没点过那款游戏,但他点过同类型的塔防游戏,而且那款游戏的题材跟他喜欢的科幻题材一致,模型能推测出"这个人可能也喜欢这款",这就是泛化。这类逻辑依赖特征之间的交叉组合,光靠单特征是不够的。
传统做法里,逻辑回归(LR)是记忆的扛把子。它简单、可解释性强、训练快,喂进去的稀疏特征经过手工交叉以后,它能把每一个组合的权重都老老实实学到。但它的问题也很明显,泛化能力约等于零。你不可能把所有交叉组合都提前构造出来,而且随着特征维度上升,手工特征工程的工作量会把人压垮。
深度模型(DNN)是泛化的好手。Embedding层把稀疏的高维特征压成稠密的低维向量,MLP层自动学习特征之间的非线性交互。理论上,只要Embedding表达得好,模型就能泛化到没见过的新特征组合上。但DNN的问题在于,它对稀疏的、确定的、长尾的特征组合不太敏感。比如某个用户历史上只点过一次某款小众游戏,这个信号在Embedding空间里会被稀释掉,模型反而学不干净。更麻烦的是,深度模型在冷启动或者用户行为极少时,容易推出一堆"看起来合理但用户根本不认识"的东西,这就是前面说的"推荐太飘"。
Wide & Deep的出发点,就是别让LR和DNN互相看不起,干脆把两者焊在一起。Wide侧负责记忆,Deep侧负责泛化,两头各干各的,最后拼接起来一起输出。
1.2 模型的整体结构与数据流向
Wide & Deep这个名字,从结构上就能看出来:它由一条Wide分支和一条Deep分支组成,最后汇合到同一个输出节点。
Wide侧就是一个广义线性模型,也就是LR。输入通常是稀疏的原始特征,也可以加入手工构造的交叉特征。它做的事情就是对输入特征做加权求和,然后送到输出层。这一侧的特点是简单直接,权重更新快,对高频、确定性的信号响应非常灵敏。
Deep侧是一个标准的Embedding + MLP结构。离散特征先过Embedding层变成稠密向量,连续特征做归一化后直接拼接,然后过几层全连接网络,每一层带ReLU激活和BatchNorm之类的增强。
两条分支的输出在最后一步拼接(Concat),再经过一个带Sigmoid激活的全连接层,输出一个0到1之间的预测值。对于CTR预估来说,这个值就是点击概率;对于多分类来说,可以换成Softmax输出。
整个数据流向可以用一句话概括:相同的输入样本,一份走Wide通道做线性加权,一份走Deep通道做非线性变换,最终汇合输出。训练时两个分支的梯度各自回传,互不干扰,但又共享同一个损失函数和标签。
这种设计最妙的地方在于,Wide侧和Deep侧的优化目标一致,但特征表达方式不同,所以它们在训练过程中会自然形成分工。Wide侧抓住"这个用户以前就喜欢这个",Deep侧补充"这个用户可能也喜欢这些"。两者不是简单的平均关系,而是让模型自己去学什么时候该信记忆、什么时候该靠泛化。
1.3 为什么在TensorFlow 2.0里重写
TensorFlow 1.x里实现Wide & Deep其实挺痛苦的。你要手动维护两个计算图的初始化、训练、评估流程,还要处理Estimator那套封装。记得早期还得用tf.contrib这个模块,版本一升级代码就废。做特征列的时候,稍不注意就掉进稀疏特征和Embedding的维度泥潭里。
TensorFlow 2.0全面拥抱Keras之后,这事简单了不止一个量级。函数式API(Functional API)天然支持多输入多输出,Wide和Deep两条分支可以用两个独立的输入层定义,最后合并成一个输出层,compile和fit直接把整个流程包圆。tf.feature_column也稳定了许多,数值列、类别列、Embedding列都可以直接接进Keras的Input层。
所以本节的代码示例,我全部基于TensorFlow 2.0 + Keras函数式API来写。你本地装好2.0以上版本就能直接跑,不需要任何旧版兼容代码。
2. TensorFlow 2.0中Wide & Deep建模核心细节
2.1 特征类型分析与处理方案选型
实操里决定模型效果上限的,往往是特征处理,而不是网络结构。Wide & Deep之所以灵活,就是因为它对不同类型的特征有天然的分工。
我把推荐场景里常见的特征分成三大类,每一类在Wide & Deep里的归宿都不一样。
第一类是离散ID类特征,比如用户ID、物品ID、品类ID、城市ID。这类特征的特点是基数大、取值稀疏、彼此之间没有天然的大小关系。它们的归宿是Deep侧的Embedding层。每个ID映射成一个稠密向量,向量维度一般取16到64之间。ID类特征原则上不需要进Wide侧,因为Wide侧本质是做线性加权,高基数的one-hot向量进来会让权重矩阵巨大且稀疏,训练效率很低。
第二类是连续数值类特征,比如用户活跃天数、物品的累计点击次数、价格、时长。这类特征在Deep侧直接做归一化后拼进特征向量。归一化推荐用Z-Score或者Min-Max,具体看分布形态。在Wide侧也可以放,但建议做分桶(Bucketize)处理,把连续值切成几个区间,变成离散的桶ID,再转成one-hot或embedding。这样Wide侧能捕捉到"数值落在某个区间"这种分段信号,比直接用原始数值做线性加权更稳。
第三类是交叉组合类特征,这是Wide侧的主力。比如"用户性别=男"和"物品类别=游戏"交叉成一个新特征,只有同时满足时才为1。这种特征在Deep侧也能学,但需要经过Embedding和MLP的层层传递,效率远不如在Wide侧直接塞给线性模型。手工构造少量高价值的交叉特征放进Wide侧,是Wide & Deep调优的关键操作。
特征处理方案确定之后,下一步就是在TensorFlow 2.0里把它们组织成代码。这里我强烈建议先用Pandas做探索和清洗,再用tf.feature_column做特征描述,最后组Input层。不要试图在tf.data流水线里做复杂的特征工程,调试会非常痛苦。
提示:特征列(feature_column)是连接原始数据和模型之间的桥梁。它在TensorFlow 2.0里依然是核心抽象,但API风格比1.x清爽很多。建议把每个特征的处理方式单独定义一个feature_column,方便复用和排查。
2.2 Keras函数式API实现Wide与Deep双分支
TensorFlow 2.0里实现Wide & Deep,核心是Keras的函数式API。它允许你定义多个输入,让它们各自经过不同的变换,最后汇合到一个输出上。
我先把整体骨架画出来:Wide分支的输入层接收已经完成交叉处理的稀疏特征;Deep分支的输入层接收原始特征,先经过Embedding和数值归一化处理,再过几层全连接;两个分支的输出在最后做Concatenate,接一个Dense(1)输出。
这里有一个非常容易搞错的点:Wide侧不是随便把特征拼一起就行,它拿到的输入必须是人工构造好的交叉特征。如果你直接把原始特征一股脑塞给Wide侧,那它就是一个普通的线性层,交叉能力为零。我当时第一次实现的时候也踩过这个,以为模型会自动学交叉,结果Wide侧跟摆设一样,Deep侧也没学到足够好的泛化,整体效果跟纯DNN没啥区别。
Deep侧的Embedding要注意维度设计。对于基数(vocabulary size)非常大的ID类特征,Embedding维度可以按经验公式来定:dim = 6 * log2(vocab_size)或者直接用4次方根。比如500万基数的特征,开4次方大概是47,取个64左右比较合适。维度太小会欠拟合,维度太大会让参数量爆炸,训练和上线都难受。
两条分支的汇合方式也有讲究。最常见的是Concatenate拼接,然后过一个Dense层输出。也有人在拼接后加一个BatchNorm,尤其是两边的数值尺度差异很大的时候。Wide侧输出接近线性加权的原始值,Deep侧输出是ReLU之后的特征,尺度可能完全不同,加BatchNorm能帮梯度更稳。我实测下来,加了之后收敛速度略有提升,但不加也不至于崩,可以作为一个可选配置。
2.3 损失函数、优化器与评估指标的选择
Wide & Deep最常跑的任务是CTR预估和二分类,所以损失函数用binary_crossentropy就好。TensorFlow 2.0的Keras里直接指定loss='binary_crossentropy'即可,不需要自己做one-hot编码,只要标签是0和1的数值就行。
优化器这里有个经验:不要用SGD,用Adam或者Adagrad。Wide侧的特征非常稀疏,SGD在稀疏梯度上收敛慢且容易震荡。Adagrad对稀疏特征友好,是Google原论文里用的优化器;Adam则更通用,收敛快,就算特征分布没那么极端也能稳稳跑。我的建议是优先试Adam,学习率从0.001开始,如果Wide侧贡献的信号比较多,再换Adagrad对比。
评估指标方面,除了accuracy这种全局指标,强烈建议额外输出AUC。CTR预估里数据通常极不平衡——点击率可能只有百分之几,accuracy会虚高,AUC才能真实反映排序能力。TensorFlow 2.0里可以用tf.keras.metrics.AUC(),在compile阶段以metrics列表传进去就行。如果想进一步看分层的表现,还可以按特定用户群体或物品类别分组算AUC,这对定位问题非常有帮助。
注意:accuracy只适合给你一个"模型在正常工作"的直觉判断,不能用于业务决策。排序类任务(推荐、广告)永远以AUC、GAUC这些指标为准。
3. 从数据到模型:完整实操流程
3.1 准备演示数据集与特征列配置
为了让你能直接照着跑,我构造一个简化的推荐场景数据集。假设我们要预测"用户是否会点击某个物品",样本包含:
- 用户ID(high-cardinality离散特征)
- 物品ID(high-cardinality离散特征)
- 用户活跃天数(连续特征)
- 物品平均评分(连续特征)
- 品类ID(中等基数离散特征)
标签是1表示点击,0表示未点击。
我生成一份模拟数据,共5万条样本。注意这只是一个演示用的玩具数据,生产环境的特征量和样本量都会大得多,但建模流程完全一致。代码里为了可复现我加了随机种子。
先定义特征列。这是整个建模过程中最需要耐心的部分,每一个特征怎么处理、进Wide还是Deep,都要在这里明确。
import tensorflow as tf # 假设数据集中这些列存在 user_id = tf.feature_column.categorical_column_with_vocabulary_list( 'user_id', [f'u{i}' for i in range(1000)]) item_id = tf.feature_column.categorical_column_with_vocabulary_list( 'item_id', [f'i{i}' for i in range(5000)]) category_id = tf.feature_column.categorical_column_with_vocabulary_list( 'category_id', [f'c{i}' for i in range(100)]) # Deep侧:ID类特征转成Embedding user_emb = tf.feature_column.embedding_column(user_id, dimension=16) item_emb = tf.feature_column.embedding_column(item_id, dimension=32) category_emb = tf.feature_column.embedding_column(category_id, dimension=8) # 连续特征:Deep侧直接使用 active_days = tf.feature_column.numeric_column('active_days') avg_rating = tf.feature_column.numeric_column('avg_rating') # Wide侧:手工交叉特征 # 这里做一个“品类ID + 用户活跃度分段”的交叉 active_bucket = tf.feature_column.bucketized_column( active_days, boundaries=[7, 30, 90]) cross_feature = tf.feature_column.crossed_column( [category_id, active_bucket], hash_bucket_size=500) # 汇总 deep_columns = [user_emb, item_emb, category_emb, active_days, avg_rating] wide_columns = [cross_feature]代码里有个细节值得多说一句:crossed_column会把两个离散特征交叉成一个新的稀疏ID。因为原始特征各有自己的词汇表,交叉后的组合空间巨大,所以用hash_bucket_size做哈希分桶。这个值不能太小,否则碰撞严重;也不能太大,否则Wide侧参数太多训练慢。比如这里品类100个乘以活跃分桶4个,实际有效组合只有400种,我取500留一点余量。
3.2 构建Wide & Deep模型主体
特征列定义好以后,接下来的核心工作就是用Keras函数式API把两条分支搭起来。这里要注意TensorFlow 2.0的接口习惯:keras.layers.Input创建输入层,tf.feature_column通过keras.layers.DenseFeatures接入。
from tensorflow import keras # Wide侧输入:交叉特征 wide_inputs = keras.layers.Input(shape=(1,), name='wide_input') # Deep侧输入:原始特征 deep_inputs = { 'user_id': keras.layers.Input(shape=(1,), name='user_id'), 'item_id': keras.layers.Input(shape=(1,), name='item_id'), 'category_id': keras.layers.Input(shape=(1,), name='category_id'), 'active_days': keras.layers.Input(shape=(1,), name='active_days'), 'avg_rating': keras.layers.Input(shape=(1,), name='avg_rating'), } # Wide分支:直接把交叉特征输入接一个线性层 wide_feature_layer = keras.layers.DenseFeatures(wide_columns) wide_out = wide_feature_layer({'wide_input': wide_inputs}) # Deep分支:把特征列转换为稠密特征 deep_feature_layer = keras.layers.DenseFeatures(deep_columns) deep_out = deep_feature_layer({ 'user_id': deep_inputs['user_id'], 'item_id': deep_inputs['item_id'], 'category_id': deep_inputs['category_id'], 'active_days': deep_inputs['active_days'], 'avg_rating': deep_inputs['avg_rating'], }) # Deep分支:接MLP x = keras.layers.Dense(128, activation='relu')(deep_out) x = keras.layers.BatchNormalization()(x) x = keras.layers.Dropout(0.3)(x) x = keras.layers.Dense(64, activation='relu')(x) x = keras.layers.BatchNormalization()(x) x = keras.layers.Dropout(0.3)(x) # 合并两条分支 concat = keras.layers.Concatenate()([wide_out, x]) output = keras.layers.Dense(1, activation='sigmoid')(concat) model = keras.models.Model( inputs=[wide_inputs] + list(deep_inputs.values()), outputs=output )这里有一个非常值得注意的细节:Wide分支的DenseFeatures输入,需要和前面定义的交叉特征结构一致。如果你在feature_column里对active_days做了bucketized_column,那么输入的时候应该传原始数值列,由DenseFeatures内部完成分桶转换。给Wide侧的输入是原始特征值,但对应的feature_column已经是交叉处理后的形式。很多新手在这里搞混,结果输入维度对不上,直接报错。
BatchNorm和Dropout是Deep侧的标准配置。Dropout建议放在激活之后,这跟传统理解(放在全连接层之前)稍有差别,但我在TensorFlow 2.0里实测,放在激活之后对防过拟合更有效。你也可以按自己的习惯调整,只要记住Deep侧过拟合速度比Wide侧快得多,Dropout系数设在0.2到0.5之间比较合理。
3.3 训练、评估与模型保存
模型建好之后,编译和训练就简单了。
model.compile( optimizer=keras.optimizers.Adam(learning_rate=0.001), loss='binary_crossentropy', metrics=['accuracy', keras.metrics.AUC(name='auc')] ) history = model.fit( train_dataset, validation_data=val_dataset, epochs=20, batch_size=1024 )数据集我建议用tf.data.Dataset.from_tensor_slices构造。你要确保每一步的输入字典和模型定义里Input层名称一一对应。这一步是TensorFlow 2.0里最容易翻车的地方:Keras的输入字典key必须跟keras.layers.Input的name完全一致,大小写都不能错。
训练完成后,保存模型用model.save('wide_deep_model')。这个格式是SavedModel,里面包含了网络结构和权重,上线时可以整个加载。如果你想在保存前看看模型的参数量和结构,用model.summary(),它会清清楚楚列出每一层以及参数量。
我自己在跑这个模型的时候,epochs设20,batch_size设1024,大概几轮之后训练AUC就能到0.85以上。如果你的数据更稀疏,收敛会慢一些。关键看验证集AUC是否跟着涨,如果训练AUC一路涨但验证AUC停滞甚至掉头,那大概率是过拟合了,优先加Dropout、降网络层数、加正则化。
提示:训练过程中不要频繁中断去改模型。先把一版完整流程跑完,记录指标变化,再基于实验结果做调整。我见过太多人训练到一半就去加特征、改结构,最后根本分不清是哪个操作带来的收益。
4. 从TensorFlow 1.x迁移与踩坑实录
4.1 TF 1.x到TF 2.0的核心差异对照
如果你的团队还在维护老代码,或者你是在旧项目基础上做迭代,一定会遇到从TF 1.x迁移到TF 2.0的问题。Wide & Deep在TF 1.x里最经典的是tf.estimator和tf.contrib.learn那套实现,到了TF 2.0,这些接口要么废弃,要么改头换面。
我整理了一张对照表,方便你快速定位问题:
| 功能点 | TensorFlow 1.x | TensorFlow 2.0 |
|---|---|---|
| 图构建 | tf.Graph + tf.Session | Keras Model / tf.function |
| Wide & Deep封装 | tf.contrib.learn.WideDeepModel | 手写Keras函数式API |
| 特征列 | tf.feature_column(基本一致) | tf.feature_column(API更稳定) |
| 训练 | estimator.train(...) | model.fit(...) |
| 评估 | estimator.evaluate(...) | model.evaluate(...) |
| 模型导出 | estimator.export_saved_model | model.save(...) |
| 执行模式 | 静态图 | Eager模式(动态图) |
核心差异一句话就能概括:TF 1.x你得先建图再跑数据,TF 2.0是边跑边建图。这个变化让调试友好了很多,但也让某些老接口直接失效。比如tf.contrib整个模块在TF 2.0里被移除了,你不能再指望contrib.learn提供现成的WideDeepModel,必须自己用Keras搭。
我迁移老项目的心得是:不要试图一行行改老代码,直接在TF 2.0里重新实现一份模型。老代码的逻辑可以参考,但接口替换的成本跟重写差不多,而且重写还能顺手优化结构和特征处理。
4.2 高频报错与排查方案速查表
我在反复实现和调试Wide & Deep的过程中,整理了下面这些高频问题。你可以直接对照排查。
| 问题表现 | 常见原因 | 解决方案 |
|---|---|---|
| 输入字典key不匹配报错 | 特征列名和Input层name不一致 | 统一命名规范,调试时打印feature_column名逐一核对 |
| DenseFeatures输入维度和数据不一致 | 数据里列名写错或类型不对 | 打印一条样本数据,核对列名、dtype,必要时做cast |
| 训练时AUC不涨 | 特征列配置错误,比如Embedding维度太低或Wide侧没有有效交叉特征 | 检查特征列清单,确认Wide侧进入的是交叉特征而非原始特征 |
| 验证AUC与训练AUC差距过大 | Deep侧过拟合 | 增加Dropout率、减小网络层维度、加入L2正则化 |
| 预测时输出全部为同一值 | 标签泄漏或数据处理bug | 检查训练/验证数据切分是否有时间穿越,标签是否被错误拼接到特征里 |
| 模型体积太大 | Embedding维度太高或hash_bucket_size过大 | 按经验公式缩小维度,验证对AUC影响后确定 |
| 训练速度慢 | 特征列里做了大量实时预处理 | 把能提前算的特征提前算好,Dataset用batch预取(prefetch) |
这张表里我想特别强调一个最隐蔽的坑:标签泄漏。推荐场景里,如果你不小心把"用户是否点击"这个信息以任何形式放进了特征里(比如"该物品近30天点击率"在某些实时更新的场景里可能就包含了目标信息),模型会学到"看到这个特征就输出1"的捷径,离线AUC会高得离谱,线上完全失效。排查方法很简单:对训练样本做一次特征重要性分析,如果某个特征的重要性异常突出,优先级高于所有其他特征,就要怀疑它是不是泄漏了。
4.3 训练过程中的典型诡异现象
除了能查表的报错,有些问题属于"表面正常但结果诡异"的类型,我挑两个最典型的说说。
第一个诡异现象:训练loss下降正常,但验证集AUC纹丝不动。这种一般不是模型代码的问题,而是数据分布有问题。常见于训练集和验证集来自不同时间段、不同渠道,导致两边的特征分布差异巨大。你可以用tf.data在构造验证集时做与训练集完全一致的随机打乱和时间切分,确保两边同分布。再不行就把数据集重新切分,让验证集来自跟训练集相近的时间窗口。
第二个诡异现象:Wide侧权重几乎不更新。表现为训练过程中Wide部分的梯度极小甚至为0。原因通常是交叉特征太稀疏,大部分样本的交叉ID为0,梯度传不过去。解决办法是检查crossed_column的hash_bucket_size是否合理,以及交叉特征是否真的在数据中大量出现。如果大多数样本的交叉特征为空,这条特征对模型贡献就很有限,不如删掉换一个更有区分度的交叉组合。
注意:Wide侧的作用是"强制记忆"。如果你的业务场景里用户行为非常稀疏,几乎无法形成有效的高频交叉信号,Wide侧收益会很小。这时候要把重心放在Deep侧的特征表达上,而不是硬凑交叉特征。
4.4 特征选取的原则与顺序验证
Wide & Deep调优里,特征选取的优先级跟普通DNN略有不同。我自己的经验是按下述顺序来:
- 第1步:把ID类特征和连续特征加入Deep侧,先把Deep侧跑通,得到一个基础AUC。
- 第2步:在Wide侧加入1到2个最有业务直觉的交叉特征,观察AUC是否提升。
- 第3步:确认Wide侧确实有效之后,再逐步增加交叉特征,但严格控制数量。Wide侧不是越多越好,每增加一个交叉特征,都会带来参数量和过拟合风险。
- 第4步:对Deep侧做结构调优,比如调Embedding维度、网络层数、Dropout。
- 第5步:最后再做一次全量特征审查,删除长期没有任何增益的特征。
每一步都要通过AUC变化做判断,不要凭感觉。我的一般判断标准是:验证集AUC提升0.1%以上才认为有效,低于0.05%可以直接放弃该项改动。CTR场景里0.1%的AUC提升已经足够影响业务收入了,所以这个标准并不苛刻。
5. Wide侧与Deep侧的权重平衡与业务理解
5.1 模型可解释性的价值:Wide侧的权重解读
很多人学Wide & Deep只关注AUC,忽略了它还有一个巨大优势:Wide侧权重可以被解读。这一点在实际业务里太重要了。
比如你构造了一个交叉特征"用户性别=男&物品类别=游戏",训练完成后,这个特征在Wide侧的权重如果是正的,说明男性用户对游戏类物品确实有天然偏好;如果权重接近0,说明这个交叉没有增量信息;如果是负的,说明存在负向关联。你把这些权重列出来,直接就能跟运营同学聊:"我们平台上这个交叉特征信号很强,建议把它做成运营策略。"这在以深度学习为主的黑盒模型里很难做到。
Deep侧也能做一定的解释,比如看某个特征的Embedding向量分布,或者用SHAP分析,但成本比Wide侧高得多。所以我的建议是:每做一版Wide & Deep模型,都顺手把Wide侧权重导出成一张表格留档,这对后续特征迭代、异常排查都特别有帮助。
这里有一个小技巧:TF 2.0里获取Wide侧权重,可以在model.layers里找到对应DenseFeatures层,取它的kernel变量。打印出来跟特征列名字对齐,就能看到每个交叉特征的权重。
5.2 两个分支信号冲突时的取舍策略
Wide侧和Deep侧虽然分工,但两边的预测可能出现"打架":Wide侧认为应该推荐A,Deep侧认为应该推荐B。最终输出被Sigmoid压到一起后,结果到底偏向谁,取决于权重初始化、训练数据分布和模型收敛状态。
遇到这种情况,我的处理思路不是去调网络的拼接权重,而是回到数据层面判断:这个冲突是"记忆信号"更可靠,还是"泛化信号"更可靠?
举个例子:某个用户过去10次点击全部集中在某几个固定的物品类别上,Wide侧的交叉特征会给出强信号;同时用户资料显示他最近搜索过一些新领域的关键词,Deep侧通过Embedding和MLP能捕捉到这个泛化信号。两者冲突时,我们应该相信哪个?业务上通常更倾向于相信用户的近端行为(记忆),因为搜索行为可能存在偶然性。所以我会在Wide侧增加这类高频交叉特征的权重因子,或者通过调整特征构造方式(比如把搜索行为单独做成Wide侧交叉特征)来强化它所代表的确定性信号。
如果两边信号冲突非常频繁,且难以判断哪个更合理,我会做一个消融实验:训练三个模型,一个只有Wide,一个只有Deep,一个Wide & Deep。分别看它们在具体样本上的表现差异,这样能清楚知道是哪个分支在拖后腿,还是拼接方式有问题。
提示:不要试图在模型结构里用魔法权重解决信号冲突。结构层面的调整(比如把某些特征只放进Wide侧或只放进Deep侧)比调超参数有效得多。
6. 扩展应用:从推荐系统到通用预估场景
6.1 结合某新能源车企用户分群的实际案例
Wide & Deep的应用场景远不止应用商店和广告CTR。我最近在做一个汽车行业的用户意向分群项目,就用到了它,效果比之前的纯DNN和GBDT都稳定。
项目背景是某新能源车企想根据用户的行为数据,预测用户对某一细分车型的意向等级。数据里有用户注册时长、试驾次数、浏览车型记录、所在城市级别等特征。这个任务跟CTR预估很相似,但标签不是点击,而是用户填写的意向等级(高/中/低)。
在这个场景里,Wide侧我放了几个经过业务验证的交叉特征,比如"城市级别=一线 & 关注车型=SUV"、"用户注册天数>180 & 试驾次数>=1"。这些特征来源于销售团队的业务经验,对实际转化有很强的先验关联。Deep侧则放了用户行为序列的统计量、车型偏好Embedding等特征,用来捕捉更复杂的偏好模式。
结果很有意思:纯Deep模型在整体AUC上稍好一点,但在"最近30天有试驾行为"这个高意向人群上,Wide & Deep明显更好。原因就是Wide侧把这些业务先验强特征直接焊进了模型,保证了高确定性信号的优先响应。事后分析,如果只用Deep模型,这些强业务信号会在Embedding里经过多层非线性变换后被扭曲,反而学不到位。
这个案例给我们的启发是:当业务方能够提供经过市场验证的强先验规则时,把它们做成Wide侧交叉特征,几乎总是能带来稳定提升。
6.2 冷启动场景下的Wide & Deep改进
Wide & Deep的经典短板是冷启动。新用户没有足够行为历史,Wide侧的记忆信号几乎为空;Deep侧虽然能靠用户画像泛化,但效果也有限。
针对这个问题,我见过几种有效的改造思路。
第一种是把Wide侧的特征从"用户-物品交叉"扩展为"用户侧交叉"和"物品侧交叉"。比如"新用户<7天 & 注册渠道=某渠道"、"物品新上线<3天 & 品类=某品类",这样即使没有用户-物品交互记录,Wide侧也能源源不断提供记忆信号。
第二种是给Deep侧引入更多与行为无关的静态特征,比如年龄区间、城市级别、设备型号、注册来源。这些特征容易过拟合,但由于冷启动阶段数据稀疏,Deep侧相比Wide侧更容易泛化,加这些特征能显著提升新样本的表现。
第三种是结合强化学习或探索策略的思路,在训练样本里刻意加重冷启动用户样本的权重,让模型更多地学习这类样本的模式。但这个做法要谨慎,权重设置不合理会导致模型在其他群体上衰退。
注意:冷启动阶段不是说Wide侧不重要了。恰恰相反,由于数据极少,模型能依赖的确定性知识就更珍贵。关键是交叉特征的设计要从"用户×物品"转向"用户自身×环境场景",让模型在没有行为时也能借用群体经验。
7. 几轮迭代之后的个人心得
最后聊几点我在这个模型上反复实践后形成的个人体会,都是踩坑踩出来的,可能比前面所有代码都值钱。
第一,Wide侧不要贪多。我见过一上来就堆十几个交叉特征的,结果训练慢、过拟合、权重解释性也变差。交叉特征贵精不贵多,三五个已经很多了,关键是每一个都有明确的业务假设支撑。一个冷冰冰的统计显著特征,价值远不如一个有业务逻辑的交叉特征。
第二,Deep侧再牛也别扔Wide侧。很多人到后面沉迷调Deep侧的网络结构,觉得AUC还有提升空间,就把Wide侧晾在一边。实际业务里,用户行为中存在大量确定性重复信号,这些信号由Wide侧直接捕捉,效率远高于Deep侧隐式学习。如果你发现Wide侧对最终结果几乎没贡献,问题往往不在模型结构,而在交叉特征设计上。
第三,训练过程要盯GAUC这类分组指标。整体AUC提升不代表每个细分人群都变好了。我遇到过整体AUC上涨、但某个人群(比如新用户)AUC反而下降的情况。如果业务上很看重这部分人群,就需要对训练样本做加权,或者单独评估这个人群的表现。
第四,上线推理时要特别小心。模型训练时输入的是Pandas DataFrame或者tf.data,上线时通常走的是特征平台,格式可能完全不一样。我踩过一次大坑:训练时特征列类型是int64,上线时某个ID字段被读成了string,结果线上预测结果乱飞。建议在模型上线前,构造一批和训练数据分布完全一致的测试样本,用SavedModel加载后逐条比对输出。
第五,Wide & Deep的变体很多,但你先别急着上变体。DeepFM、xDeepFM这些模型我会在后面的章节里单独展开,但它们的底层逻辑里都有Wide & Deep的影子。把Wide & Deep吃透了,后面学任何变体都是顺水推舟的事。先把这个模型的训练、调优、排查全流程跑通,再考虑复杂度升级,这是我在推荐模型路上最稳的一条路线。