ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WideDeep模型详解:记忆与泛化平衡的推荐算法基石

WideDeep模型详解:记忆与泛化平衡的推荐算法基石 WideDeep这个名字做推荐算法的同行应该都不陌生。Google在2016年提出这个模型的时候就是为了解决推荐系统里一个很实在的问题既要让模型记住用户已有的行为模式又要能对没见过的特征组合做出合理推断。我在实际项目里用过不少推荐模型从LR到FM再到各种深度模型坦白讲WideDeep更像是一个“承上启下”的关键节点理解了它后面看DeepFM、DCN、xDeepFM这些模型都会顺畅很多。这篇文章我会从推荐系统面临的核心矛盾讲起然后拆解Wide和Deep两个组件各自负责什么再给出完整的代码实现和训练细节最后把我踩过的坑一并写出来。不管你是刚接触推荐算法的学生还是已经在做相关业务的工程师这篇文章应该都能给你一些参考。1. 理解WideDeep先搞懂推荐系统的两个核心能力1.1 为什么说“记忆”和“泛化”是推荐系统的两个基本能力推荐系统要做的事情本质上是从用户的历史行为中找规律然后用这个规律预测用户接下来可能喜欢什么。但这里有个天然的矛盾用户的兴趣既有非常稳定、明确的部分也有不断变化、难以捉摸的部分。所谓“记忆”就是模型能够学到高频、确定性的规则。比如一个用户在过去一个月里点了30篇关于数码产品的文章那模型应该“记住”这个用户对数码类内容有强偏好。这类规则靠线性模型或者简单的特征交叉就能搞定特征出现得越频繁权重学得越准。但“记忆”有个致命缺陷它只能对见过的特征组合生效。如果用户今天突然对某个从未出现过的内容类别产生了兴趣或者一个从来没来过的新用户第一次进入系统模型就完全无法处理因为对应的特征组合在训练集里根本没出现过。这就是冷启动问题的一个模型层面的根源。所谓“泛化”就是模型能够根据特征之间的相似性把从已有数据中学到的规律迁移到新的特征组合上。比如一个用户喜欢看“iPhone 15评测”模型通过Embedding能够知道“iPhone 15”和“手机”、“数码”这些概念是相近的那么即使用户从未看过“小米14评测”模型也能推断出他大概率会喜欢。深度学习里的Embedding层和MLP结构就是干这个事的。WideDeep模型的核心理念就是用Wide部分负责“记忆”用Deep部分负责“泛化”两边同时训练各司其职最后把两边的输出拼接起来做预测。这个思路今天看好像很自然但在当时确实是一个很巧妙的设计。1.2 Wide部分和Deep部分各自的职责划分Wide部分是一个广义线性模型也就是经典的Logistic Regression。它的输入通常包括原始特征和手工构造的交叉特征。比如对于“用户已安装App”和“用户当前浏览App”这两个特征Wide侧可以构造一个交叉特征“已安装App某游戏 AND 当前浏览App某工具”这个交叉特征在训练样本中出现的次数足够多时模型就能很准确地学到“装了某游戏的人也容易浏览某工具”这个具体规则。Deep部分是一个前馈神经网络。它的输入是稀疏的类别特征比如用户ID、物品ID、用户行为序列里的物品ID这些特征先经过Embedding层映射成稠密向量然后拼接起来送入若干个全连接层。全连接层通过非线性激活函数可以自动学习特征之间的高阶交互关系从而实现泛化能力。简单来说Wide侧更“死板”Deep侧更“灵活”。两者结合既保证了模型在已有的高频模式上足够精准又让模型具备处理新情况的能力。这个“精准性泛化性”的组合就是WideDeep落地效果好的根本原因。1.3 Joint Training和Ensemble有什么本质区别很多人会把WideDeep和“把LR和DNN做ensemble”混为一谈这个理解其实是有偏差的。Ensemble是两个模型分别独立训练最后把预测结果做加权平均而WideDeep用的是Joint Training也就是联合训练。联合训练的特点是Wide侧和Deep侧在训练过程中是同时优化同一个损失函数的。Wide侧的梯度会通过交叉层同时更新Wide参数和Deep参数Deep侧的梯度也会影响Wide侧的特征权重。这样做的好处是两侧的参数在训练过程中是相互协调的Wide侧需要多大的权重、Deep侧需要多大的权重都由模型自己学出来不需要我们手动调ensemble的权重系数。当然联合训练对Wide侧的优化器有要求这一点后面讲训练细节的时候我会展开。现在先把模型的整体结构搞明白就够了。2. 模型结构拆解Wide侧、Deep侧和交叉层的实现细节2.1 Wide侧线性模型为什么还需要手工特征交叉Wide侧的数学形式很简单就是 ( y w^T x b )其中x是特征向量w是权重。但如果我们只把原始特征直接丢进去这个线性模型能学到的模式非常有限因为它无法表达特征之间的交互关系。举个例子。在App推荐场景里如果用户已经安装了“美团”那么他很可能也会点击“大众点评”因为这两个App功能高度重合。但如果模型只使用“已安装App美团”和“当前曝光App大众点评”这两个独立特征线性模型会把它们的权重分别学习而无法精确表达“这两个特征同时出现时点击率会显著升高”这个规律。这时就需要我们手工构造交叉特征安装美团AND曝光大众点评。Wide侧的价值就在于它可以把这类高频、有明确业务含义的特征交叉直接建模进去。这种特征交叉往往是业务经验的沉淀比如“在搜索广告中用户查询词和广告关键词是否完全匹配”、“在电商场景中用户性别和商品类目是否匹配”等。把这些规则显式地放进模型训练效率高线上表现稳定这是纯深度模型很难做到的。不过也要注意一个局限性Wide侧需要人工构造交叉特征而且一旦特征数量巨大这个权重矩阵的规模也会变得非常庞大。2.2 Deep侧Embedding加MLP是如何处理稀疏特征的Deep侧的输入通常是高维稀疏的类别特征。以视频推荐为例特征可能包括用户ID、视频ID、用户最近观看的10个视频ID、用户所在城市、设备型号等。这些特征的取值空间动辄百万甚至千万级别如果直接做one-hot编码特征维度会爆炸。Embedding层就是用来解决这个问题的。它的本质是一个查找表Lookup Table每个离散的ID对应一个固定长度的稠密向量比如128维。训练开始时这些向量随机初始化训练过程中不断调整。最终得到的Embedding向量能够反映ID之间的语义相似度相似的用户在向量空间中距离更近相似的物品也是同理。所有特征的Embedding向量拼接在一起就得到了一个固定长度的输入向量随后送入若干层全连接网络。每层全连接网络做一次线性变换加非线性激活比如ReLU使得模型能够表达非常复杂的非线性函数。Deep侧的能力边界在于它可以自动学习任意阶的特征交互。特征是“用户ID12345”物品是“视频ID67890”表面上这两个ID没有任何共同点但通过Embedding模型知道用户12345和一群喜欢科技视频的用户很接近视频67890和一群科技视频在向量空间里聚在一起于是就能推断出这个用户大概率会喜欢这个视频。这种从ID到语义空间的映射就是泛化能力的来源。2.3 交叉层Wide和Deep怎么“汇合”做最终预测Wide和Deep两个部分的输出最后要在交叉层汇合。Wide侧的输出是一个标量Deep侧的输出是一个向量最后一层全连接层的输出比如维度是64。两者直接拼接在一起送入一个输出维度为1的全连接层最后经过Sigmoid激活函数得到点击概率。用公式表示就是# 伪代码示例 wide_output wide_logits # shape (batch_size, 1) deep_output deep_last_layer # shape (batch_size, 64) combined tf.concat([wide_output, deep_output], axis-1) # (batch_size, 65) logits tf.layers.dense(combined, units1) # (batch_size, 1) prob tf.sigmoid(logits)这个交叉层是全连接层意味着Deep侧输出的64维向量和Wide侧的那个标量在最终预测时的权重是由模型自动学习的。如果某个样本的特征交叉关系主要靠Wide侧的显式特征发挥作用那Wide输出对应的权重会比较大反之如果主要靠Deep侧的泛化能力那Deep输出的权重会比较大。整个过程不需要人为干预。2.4 模型结构的演进WideDeep如何影响后续模型家族WideDeep提出之后业界很快意识到了这个“记忆泛化”框架的价值后续涌现出了一大批改进模型。比如DeepFM用FM替换了Wide侧的线性模型让交叉特征可以被自动学习而不只是依赖手工构造DCN在Deep部分旁边增加了Cross Network用显式的特征交叉层来增强模型的表达能力。我之所以强调这个演进过程是因为理解了WideDeep这个基座再看这些后续模型会非常轻松。它们本质上都还是在回答同一个问题如何既保留对高频模式的精准记忆又具备对新模式的良好泛化。从这个角度看WideDeep可以说是推荐系统深度学习时代的基石之一。3. 实操过程用TensorFlow/Keras实现一个可用的WideDeep3.1 数据准备和特征工程哪些特征该进Wide哪些该进Deep先说结论规模大、取值稀疏、有明确业务含义的类别特征一般进Deep侧走Embedding而需要精确记忆的高频特征交叉进Wide侧。我以一个真实的资讯推荐项目为例来说明。我们的特征大概分几类用户侧特征用户ID、用户性别、用户年龄分桶、用户注册时长分桶、用户活跃度等级物品侧特征文章ID、文章类目、文章关键词ID列表、文章作者ID上下文特征当前时间戳按小时分桶、用户当前所在城市、设备型号交叉特征用户最近点击类目和当前文章类目是否一致、用户性别和文章类目组合等在这个设定下我把用户ID、文章ID、类目、城市、设备型号这些稀疏离散特征放到了Deep侧每个特征都做Embedding处理。把“用户最近点击类目是否等于当前文章类目”这类带有明确业务规则的0/1特征以及几个关键交叉特征放到了Wide侧。整个训练管线我通常会用TensorFlow的tf.keras来实现因为它的API设计非常直观而且方便部署到线上服务。3.2 完整代码实现从Embedding到模型输出的全程解读下面是一份完整的WideDeep实现。我删减掉了部分数据处理细节保留核心模型结构。这份代码在TensorFlow 2.x下可以直接运行。import tensorflow as tf from tensorflow.keras import layers # 定义一些超参数 EMBEDDING_DIM 32 DEEP_LAYERS [128, 64] LEARNING_RATE 0.001 class WideAndDeep(tf.keras.Model): def __init__(self, vocab_sizes, wide_feature_num): vocab_sizes: dict, 每个离散特征的词汇表大小 wide_feature_num: int, Wide侧特征数量 super(WideAndDeep, self).__init__() # Deep侧为每个离散特征创建Embedding层 self.embeddings {} for feat_name, vocab_size in vocab_sizes.items(): self.embeddings[feat_name] layers.Embedding( input_dimvocab_size, output_dimEMBEDDING_DIM, embeddings_initializeruniform, namefembedding_{feat_name} ) # Deep侧全连接层 self.deep_dense_layers [] input_dim EMBEDDING_DIM * len(vocab_sizes) for units in DEEP_LAYERS: self.deep_dense_layers.append(layers.Dense(units, activationrelu)) # Wide侧直接输入的特征向量 self.wide_dense layers.Dense(1, use_biasTrue) # 最终输出层 self.output_layer layers.Dense(1, activationsigmoid) def call(self, inputs, trainingFalse): # 分离Wide和Deep的输入 wide_input inputs[wide] # shape: (batch_size, wide_feature_num) deep_inputs inputs[deep] # dict of tensors # Deep侧Embedding - Concat - Dense deep_embeddings [] for feat_name, feat_tensor in deep_inputs.items(): embedding self.embeddings[feat_name](feat_tensor) # (batch_size, EMBEDDING_DIM) deep_embeddings.append(embedding) deep_concat tf.concat(deep_embeddings, axis-1) # (batch_size, EMBEDDING_DIM * num_feats) deep_out deep_concat for dense_layer in self.deep_dense_layers: deep_out dense_layer(deep_out) # Wide侧 wide_out self.wide_dense(wide_input) # 拼接 combined tf.concat([wide_out, deep_out], axis-1) # (batch_size, 1 last_deep_dim) # 输出概率 return self.output_layer(combined)这里有几个细节值得展开说一下。第一个细节是Embedding输入的问题。在实际工程中Embedding层接受的往往是稀疏的整数ID张量比如tf.SparseTensor也可以用tf.string_to_hash_bucket_fast先对字符串ID做哈希映射。使用哈希映射要注意设置一个足够大的桶数量尽量避免不同特征值映射到同一个桶导致冲突。第二个细节是全连接层的设计。WideDeep原论文中Deep侧使用三层全连接每层的维度在几十到几百之间激活函数用ReLU。具体维度要根据数据量和特征规模来调节并不是越大越好。如果特征比较少128维就已经绰绰有余如果特征非常多比如有几十个Embedding向量需要拼接那首层维度适当增加是可以的。第三个细节是输出层。原论文在线性部分和深度部分汇合后使用Sigmoid函数输出点击率这是标准的二分类设定。如果是多目标场景比如同时预测点击和转化那需要把最后一层换成多个输出头这是WideDeep的后续扩展了。3.3 训练配置batch大小、优化器选择和正则化策略WideDeep联合训练时Wide侧和Deep侧使用的优化器是不同的。Wide侧由于特征是手工构造的高维稀疏交叉特征我们通常使用FTRL优化器。FTRL是一种在线学习优化算法它对稀疏特征的权重更新有很好的效果能够在线性模型上做到非常强的稀疏性。Deep侧则使用AdaGrad或者Adam因为深度网络的参数更新需要更稳定的梯度优化策略。在TensorFlow中实现不同优化器分区更新参数一般做法是使用多个Optimizer分别作用于Wide部分和Deep部分的变量。# 将模型参数按名称分组 wide_vars [var for var in model.trainable_variables if wide in var.name] deep_vars [var for var in model.trainable_variables if embedding in var.name or deep_dense in var.name] # 定义两个优化器 wide_optimizer tf.keras.optimizers.FTRL(learning_rate0.01, l1_regularization_strength1.0) deep_optimizer tf.keras.optimizers.Adam(learning_rate0.001) tf.function def train_step(batch_data): with tf.GradientTape() as tape: loss compute_loss(batch_data) grads tape.gradient(loss, model.trainable_variables) # 分别应用梯度 wide_grads [grad for grad, var in zip(grads, model.trainable_variables) if wide in var.name] deep_grads [grad for grad, var in zip(grads, model.trainable_variables) if embedding in var.name or deep_dense in var.name] wide_optimizer.apply_gradients(zip(wide_grads, wide_vars)) deep_optimizer.apply_gradients(zip(deep_grads, deep_vars))关于batch size我试验下来的经验是128到512之间比较合适。过大的batch size会导致模型收敛变慢而且对Wide侧稀疏特征的更新不友好过小的batch size会让训练过程变得不稳定。还有一个容易踩坑的地方是Embedding向量的正则化。如果直接对Embedding矩阵做L2正则会把所有向量往零向量方向压缩导致不同ID的向量区分度降低泛化能力反而下降。更好的做法是只在Deep侧的全连接层上做Dropout或者L2正则Embedding层保持原样不参与正则化。3.4 线上部署要点模型导出和服务化推理模型训练好之后要真正应用到推荐线上还需要做模型导出和服务化推理。线上推理时输入特征是实时拼接的。用户的特征和物品的特征要分别从特征服务中获取然后组装成模型所需的输入格式。这里要特别注意的是训练时的特征处理逻辑和线上服务时保持一致比如Embedding的哈希桶大小、特征的归一化方式等任何一处不一致都会导致线上效果大幅下降。模型导出我一般使用tf.saved_model格式。这种方式能够保留完整的计算图方便线上服务直接加载而且支持后续的模型版本管理。如果推理性能是瓶颈可以考虑两个优化方向。一个是对Embedding查询做缓存把热点物品的Embedding向量直接放在内存中省去每次查询的成本另一个是使用TensorFlow Serving等专业的推理服务框架它支持批量推理和GPU加速吞吐量远高于简单的在线函数调用。4. 训练细节与调参心得真实项目中的经验总结4.1 为什么Wide侧选FTRLDeep侧选AdaGrad或Adam这个问题面试中经常被问到实际项目中也是很多人容易忽略的点。Wide侧的特征是高维稀疏的而且很多特征的出现频率极低比如某个交叉特征在全部训练样本中只出现了几百次。如果使用普通的SGD更新这些低频特征的权重几乎得不到有效学习。FTRL的优势在于它通过L1正则和截断策略能够让大量无效特征的权重精确地变为0。这样一来模型在线上推理时可以直接跳过权重为0的特征大幅减少计算量。同时稀疏性也让模型的可解释性更好。Deep侧的参数是稠密的Embedding向量和全连接层权重这些参数需要精细调整Adam这种自适应学习率的方法在实践中表现更稳定。如果你硬要在Deep侧也用FTRL也不是完全不行但收敛速度会明显变慢而且很难调到理想的效果。联合训练还有一个需要留意的点Wide侧的学习率通常要比Deep侧大。因为Wide侧的特征交叉信号更强、更直接需要更大的步长去快速收敛Deep侧则是通过多层网络间接学习特征交互学习率过大会导致震荡。我在项目里通常把Wide侧学习率设为Deep侧的5到10倍这是一个比较稳妥的起点。4.2 Embedding维度怎么选不是越大越好Embedding维度是影响模型效果和参数量的关键超参数。很多初学者会习惯性地把Embedding维度设得很大比如128维甚至256维觉得“维度越高表达越丰富”但这是一个常见的误区。Embedding维度应该根据特征的实际情况来选。对于取值非常稀疏的特征比如用户ID如果全站只有几万个用户那么32维的Embedding已经足够表达用户之间的差异如果物品数量达到了百万级别那种情况下Embedding维度可以适当提高到64到128维。判断维度是否合适一个比较直接的方法是看训练集规模。如果总样本量很大但Embedding维度也很大模型容易过拟合如果Embedding维度太小又可能欠拟合导致泛化能力不足。我的习惯是从32维开始试观察验证集表现如果没有明显改善就尝试其他方向。另外一个经验是不要对所有的特征都用同样的Embedding维度。比如用户ID的取值空间是10万物品ID的取值空间也是10万但用户ID的语义复杂度往往比物品ID高可以适当给用户ID分配更大的Embedding维度。这种非对称的设计在实际场景中常常能带来收益。4.3 特征归一化和连续性特征处理一个容易忽略的细节Wide侧的线性模型对特征尺度非常敏感。如果某个特征的取值范围是0到100另一个特征的取值范围是0到1那么线性模型在训练时前者对应的权重会被压缩得很小后者对应的权重会被放大整体模型的可解释性会变差。所以连续性特征在进入Wide侧之前一定要做归一化处理。常见的归一化方式有min-max归一化和z-score标准化。在推荐场景里像“用户注册时长”、“物品发布时间距今间隔”这类特征我一般使用分桶加归一化的组合方式。先把连续值划分成若干个区间然后对桶序号做归一化。这样可以保留非线性信息同时又不会因为个别极端值把整个特征的尺度拉偏。另外还有一个经验年龄、收入、时长这类偏态分布明显的特征直接归一化效果并不好。更稳妥的做法是先做log变换再归一化。这一点在处理真实业务数据的时候非常常见值得留意一下。5. 常见问题与排查技巧实录5.1 线上效果与离线指标不一致怎么排查我碰到过很多次这种情况离线AUC涨了整整两个点兴冲冲上线结果线上指标纹丝不动甚至还有下跌。这个问题在WideDeep模型上出现的概率尤其高因为模型对特征输入的一致性要求非常严格。排查思路基本按照下面几个方向展开首先检查线上特征和训练特征是否完全一致。比如训练时用户的性别特征做了分箱处理线上也要做同样的处理。任何细微的差异都会导致模型输入分布漂移。其次检查特征覆盖率和缺失值处理。线上可能遇到新用户、新物品它们对应的特征在训练集中从未出现过。Deep侧的Embedding对这种情况还算宽容会映射到随机初始化的向量但Wide侧的交叉特征对新组合完全没有泛化能力这时候就要靠Deep侧来兜底。最后检查是否时序不一致。推荐系统的样本通常有很强的时间属性训练数据里的用户行为是过去某个时间段的但线上预测时用户的行为模式可能已经发生了变化。这也解释了为什么需要定期增量训练。5.2 Wide侧特征过于稀疏导致训练慢怎么办Wide侧最怕的是特征稀疏到“几乎每个特征的爆光次数都很少”。这种情况下FTRL虽然有L1正则帮忙做特征选择但大部分特征的权重仍然很难学会训练过程也会变慢。我的解决办法通常是对交叉特征做频率截断出现次数少于某阈值的交叉特征直接丢弃。阈值一般根据训练样本总量来定比如样本量在千万级时阈值设为100是比较合理的。这么做虽然丢掉了一部分长尾信息但模型整体稳定性和收敛速度都会明显提升。另外还可以考虑使用Hash Trick。如果不方便维护一个全局特征映射表可以对交叉特征做哈希映射到固定长度的特征空间比如10万维。这样虽然会引入少量哈希冲突但工程上实现简单效果损失在可控范围内。5.3 冷启动场景下WideDeep表现不佳有什么改进办法Wide侧对冷启动几乎无能为力因为新用户和新物品没有历史行为数据交叉特征全部为空或者趋近于默认值。Deep侧的表现会好一些但也仅仅是依靠Embedding的相似性做推断效果有限。在实践中我常用的方式有几种。一是在特征侧补充内容属性特征。比如新物品虽然没有用户行为但它的类目、关键词、作者等属性是齐全的这些属性特征可以帮助Deep侧快速找到它和已有物品的相似性。二是利用全局统计特征兜底比如物品在同类目下的平均点击率、平均收藏率这些统计值可以作为一个基线填充给冷启动物品。三是把WideDeep升级为包含记忆网络或者图神经网络的版本用物品之间的关系图谱来增强冷启动时的信息传递不过这个改动量就比较大了。遇到冷启动场景我的建议是不要指望单靠模型结构解决先确认特征侧有没有把该有的信息都加进来再考虑模型层面的改动。5.4 一个容易被忽视的训练细节特征穿越问题特征穿越Feature Leakage是推荐系统训练中非常隐蔽但后果严重的错误。直观理解就是训练时使用了未来信息导致离线指标虚高但线上根本不存在这些信息。举个我遇到过的例子。某个项目在构造训练样本时用用户“后续是否点击某篇文章”作为标签但同时又把“这篇文章后续的所有统计数据”比如48小时后的总阅读量作为训练特征。这个特征和标签高度相关离线AUC一下子冲到0.9以上但线上完全复现不了这个信息效果自然崩盘。排查特征穿越需要非常谨慎把所有特征按照“特征产生的时间点”和“标签产生的时间点”逐一比对。尤其是统计类特征要确保统计窗口截止时间早于样本曝光时间。这个工作虽然琐碎但值得认真做一遍。6. 写在最后WideDeep带给我的几点收获如果你看完这篇文章只记住一句话我希望是“记忆和泛化的平衡”。WideDeep并没有引入多么高深的理论它最大的价值是把两个早就存在的模型用一个非常朴素的思路组合起来并且在实际业务中证明了这套组合的有效性。我在实际项目中最深的一个体会是对于推荐系统来说模型结构往往不是效果的瓶颈数据和特征才是。WideDeep给了我们一个很好用的框架但如果特征工程做得不够扎实再好的模型结构也发挥不出来。另一点是不要盲目追求新模型。我见过不少团队一上来就尝试各种最前沿的模型结构最后还不如把WideDeep配合扎实的特征工程调优效果好。把基础模型用透很多时候比追新模型更能带来实在的收益。最后分享一个小技巧做WideDeep实验时建议同时保留一个纯Deep版本作为对照组。当你想确认某个Wide侧交叉特征到底有没有价值时对比一下两个版本的效果差异就很直观了。这个习惯能帮你少走很多弯路。推荐系统的世界很大WideDeep只是起点。把它的设计哲学真正理解透彻你会发现后面研究DeepFM、DCN、DIN这些模型时很多东西都是共通的。希望这篇文章对你有帮助。
返回列表