做推荐系统的人大概率都绕不开YoutubeDNN。这个模型是召回方向的经典之作从论文角度讲它并不复杂甚至可以说相当朴素——Embedding、MLP、Softmax再无其他。但恰恰是这份朴素让它在工业界落地时成了“万能起手式”换掉输入特征、改一下负采样就能扩展到商品推荐、内容推荐、广告召回。真到了自己动手复现才发现论文里轻描淡写的几句话代码里处处是坑。本文就围绕YoutubeDNN的代码实现展开从样本构造、模型结构到线上serving把每一段关键代码掰开揉碎讲清楚也把我实际复现时踩过的坑、验证过的经验一并写出来。1. 先搞清楚YoutubeDNN到底在解决什么问题1.1 从召回角度理解“多分类任务”YoutubeDNN本质上是召回模型解决的是“从百万/千万级视频候选集中快速选出用户可能感兴趣的几个或几十个视频”的问题。理解这一点很重要因为很多人会下意识把它当成一个点击率预估模型然后陷入困惑怎么输出只有一个softmax没有sigmoid打分实际上召回阶段的目标不是精确预估点击概率而是做粗筛。我们用itemId做一个超大规模多分类任务给定用户当前的状态预测用户在接下来一段时间内最可能观看的“下一个视频”。这正好对应一个softmax分类器类别数就是全部视频数量。1.2 为什么要用“用户向量 候选视频向量”的结构代码里最核心的抽象是把所有候选视频映射成一个向量矩阵用户侧的各种特征通过DNN映射成同样维度的用户向量。最后将用户向量和每个候选视频向量做内积输入softmax得到概率。这种设计的好处非常明显——线上推理不用依赖训练框架。训练时我们可以在全量视频上做softmax但上线时用户向量和视频向量已经独立成型视频向量只需要保存在向量检索引擎里用户向量由DNN计算出来剩下的就是用最近邻ANN去检索相似视频。这也是YoutubeDNN代码和很多其他深度模型最大的区别训练时是分类模型上线时是双塔向量检索模型。1.3 论文中最容易被忽略的两阶段设计原文提到候选集生成candidate generation和排序ranking两个阶段代码详解通常只讲候选集生成部分。但我想强调一点召回模型的评估指标不是AUC而是精度、召回率、覆盖度正样本也不再是曝光点击样本而是“观看序列的下一跳”。意味着样本构造的思路完全不同。我见过不少人在实现时把论文的核心给丢了——用曝光日志里的点击率任务去训练YoutubeDNN。不能说完全无用但至少没有发挥它真正的威力。YoutubeDNN的样本逻辑是一条观看序列前半段作为输入后半段作为预测目标做的是next-item prediction。这一点在代码里直接决定了训练数据的组织方式。2. 训练样本构造负采样和采样修正的实现细节2.1 正样本来自观看序列而不是曝光日志先看正样本。假设我们有用户的观看序列视频ID序列 [v_302, v_45, v_98, v_231, v_67]每条样本通常用前若干个视频预测后一个视频。比如窗口为3那么可以生成两条样本输入[v_302, v_45, v_98]预测v_231输入[v_45, v_98, v_231]预测v_67代码里一般会用滑动窗口把用户行为序列切成定长序列如果不足长度就做padding。以下是我常用的构造方式def build_next_item_samples(seq_ids, seq_len32): samples [] for i in range(min(len(seq_ids), 100) - 1): if i seq_len - 1: hist seq_ids[:i][-(seq_len - 1):] # padding左对齐 hist [0] * (seq_len - 1 - len(hist)) hist samples.append((hist, seq_ids[i])) return samples这里有个细节历史序列要不要包含目标视频不要。预测下一个视频时目标视频本身对预测结果没有信息量而且会带来严重的数据泄漏问题。我第一次实现时不小心把目标也塞进了历史序列模型效果看起来很好评估指标全线拉满但线上效果直接崩塌。2.2 负样本不能均匀随机采样多分类任务中类别数可能有几百万甚至上千万我们不可能对每个类别都计算softmax分母。标准做法是负采样每一批数据只保留一个正样本再随机采样一批负样本用sampled softmax loss近似全量softmax。这里最容易踩的坑是负样本的采样分布。很多人的第一反应是从全量视频里均匀随机采样没问题能跑通但学出来的embedding会偏。原因是热门视频在训练样本中出现的频次远高于冷门视频如果均匀采样负样本等于告诉模型“所有视频出现的先验概率都一样”而实际并不是。最终热门视频的嵌入向量会没有区分度冷门视频又学不充分。2.3 采样修正公式与代码论文里提到了一个关键修正使用负采样概率的logit修正为了让模型预估的softmax概率仍然对应全量候选下的概率。具体来说假设负样本是按概率P(v)采样的那么在计算loss时需要对每个负样本的score做一个修正log(q(v)) - log(P(v))其中q(v)是模型预测的未归一化logitP(v)是该样本被采样到的概率。修改后的代码伪代码如下import tensorflow as tf def sampled_softmax_loss(user_vec, item_vec, item_freq, num_sampled100): # user_vec: [batch, emb_dim] # item_vec: 正样本视频的向量 [batch, emb_dim] # item_freq: 正样本视频在全局的采样概率log形式 sampled_ids, true_expected_count, sampled_expected_count tf.random.fixed_unigram_candidate_sampler( true_classestf.expand_dims(tf.argmax(item_ids), 1), num_true1, num_samplednum_sampled, uniqueFalse, range_maxvocab_size, unigramsitem_freq_list) positive_logits tf.reduce_sum(user_vec * item_vec, axis1) - true_expected_count[:, 0] sampled_logits tf.matmul(user_vec, sampled_item_vec, transpose_bTrue) - sampled_expected_count logits tf.concat([tf.expand_dims(positive_logits, 1), sampled_logits], axis1) labels tf.concat([tf.ones([batch, 1]), tf.zeros([batch, num_sampled])], axis1) return tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(labels, logits))代码里true_expected_count和sampled_expected_count就是修正项记录的是本次采样过程中每个正/负样本被期望采到的次数一般是batch中样本数 * 该item的采样概率。减掉这个修正项等价于把采样偏差纠正回全量softmax。实践中很容易犯的错误是拿着网上别人的简化版loss不做修正直接softmax短期内看loss也在收敛但做向量召回时结果会明显偏向热门物品。提示修正项不是微调用的trick而是数学上保证一致性的一部分。不做的后果是训练时的loss和线上检索排序口径不一致线下指标虚高线上A/B完全没有优势。3. DNN模型主体的代码拆解从输入层到Sampled Softmax3.1 输入特征的组织方式YoutubeDNN被说得最多的就是它对特征的朴实处理视频ID做embedding用户看过哪些视频就取这些视频embeddings做平均实际上是平均后再拼上其他特征统一过DNN。以下是Keras/TensorFlow 2风格的输入定义def build_model(vocab_size, embedding_dim128, seq_max_len32): seq_input tf.keras.Input(shape(seq_max_len,), nameseq) item_input tf.keras.Input(shape(1,), nameitem) context_input tf.keras.Input(shape(num_context_features,), namectx) item_embedding tf.keras.layers.Embedding(vocab_size, embedding_dim, mask_zeroTrue, nameitem_emb) seq_emb item_embedding(seq_input) user_vector tf.keras.layers.GlobalAveragePooling1D()(seq_emb) concat tf.keras.layers.Concatenate()([user_vector, context_input]) dnn_out tf.keras.Sequential([ tf.keras.layers.Dense(256, activationrelu, namefc1), tf.keras.layers.BatchNormalization(), tf.keras.layers.Dense(128, activationrelu, namefc2), tf.keras.layers.BatchNormalization(), tf.keras.layers.Dense(embedding_dim, activationNone, nameuser_out) ])(concat) item_vec item_embedding(item_input)[:, 0, :] logits tf.keras.layers.Dot(axes-1)([dnn_out, item_vec]) return tf.keras.Model(inputs[seq_input, item_input, context_input], outputslogits)这个结构里有几个值得展开的细节第一Embedding层设置mask_zeroTrue。因为序列做了padding0要掩掉平均时必须排除padding项否则没有观看行为的位置会拉低用户向量。用GlobalAveragePooling1D配合mask_zero就能自动处理但很多人用自定义平均时忘记过滤0模型悄悄退化却不自知。第二DNN最后一层输出维度必须和item embedding维度一致。因为在最后要通过dot来做内积。如果维度不一致要么加一个全连接层适配要么直接报错。实际工程中建议在代码里显式断言防止后续改维度时漏改。第三为什么要有BatchNormalization推荐场景特征稀疏且数值范围差异大BN能明显加速收敛、减少训练对学习率的敏感性。不过要注意BN在召回模型里的隐患——训练和推理时的batch统计量不一致如果feature分布在线下和线上有差异用户向量会偏移。我一般会谨慎使用或者只在输入侧不使用BN在DNN中层使用。3.2 Sampled Softmax Loss的训练方式上面模型输出的logits需要配合sampled softmax。直接对全量候选做softmax在百万级词表上是不可行的因此我只是用了一个用户向量和正样本向量做内积再加上随机抽样的负样本向量组合成[batch, 1 num_sampled]的小矩阵输入softmax。这里有一个训练上的细节负样本不仅仅是随机视频我建议采用“随机采样 一定比例的热门采样”做法。论文里提到从高频视频中采样实际操作中我们通常按热度分布采样热门视频有更高概率被采样为负样本这样做可以让模型更快地学会区分热门内容。但又要配合前面的logit修正否则会过度打压热门视频。3.3 正样本loss的权重不少人在正样本上做了加权比如按观看时长加权、按是否完整观看加权。代码实现很简单sample_weight tf.where(is_full_watch, 1.0, 0.5) loss tf.reduce_mean(sampled_softmax_loss(...) * sample_weight)在视频推荐场景里这是值得做的。按论文的表述训练目标是“为用户预测下一次观看”但如果不加权很多“划过就走”的视频和完整观看视频在loss里权重一样模型会偏向点击诱惑型内容而不是深度消费内容。加权后用户向量会更偏向长期偏好。注意加权操作只影响loss不影响负采样概率。不要误以为加权后的loss本身已经天然解决了热门偏差问题这两件事互不替代。4. 特征工程里最容易被忽视的细节4.1 视频ID以外搜索词和历史序列怎么拼论文里的用户侧特征远不止一个“历史看过视频ID序列”还包括了搜索词序列、人口属性、地理信息等。其中最值得注意的实现是搜索词不是直接embedding成一个小向量和序列向量拼接而是先对搜索词做embedding再做平均或者直接当成多个ID序列一起处理。如果看很多开源的代码解释其实常常会做简化——只保留视频序列和上下文特征。但从复现论文的角度说我建议至少保留两个序列特征观看序列、搜索词序列因为搜索意图能极大提升短会话场景的召回质量。代码上可以给每类序列各自建embedding层然后concat或addwatch_seq_emb tf.keras.layers.Embedding(vocab_size, emb_dim, mask_zeroTrue)(watch_seq) search_seq_emb tf.keras.layers.Embedding(search_vocab_size, emb_dim, mask_zeroTrue)(search_seq) watch_pool tf.keras.layers.GlobalAveragePooling1D()(watch_seq_emb) search_pool tf.keras.layers.GlobalAveragePooling1D()(search_seq_emb) user_vec_pre tf.keras.layers.Concatenate()([watch_pool, search_pool])4.2 时序特征观看时间、间隔、位置的编码推荐系统里有一个经常被别人忽略的特征目标视频和用户当前时刻之间的时间差以及用户历史上每个观看行为距离当前时刻的时间间隔。论文里把这类特征直接输入DNN。代码实现时通常做对数变换delta_hours (current_timestamp - watch_timestamp) / 3600.0 delta_feat tf.math.log1p(tf.clip_by_value(delta_hours, 0.0, 24 * 365))为什么要log一下因为时间差分布是长尾的近几小时的行为和几天前行为差异很大而几天前和几个月前的差异相对没有那么大。log压缩后特征值分布更平稳梯度也更容易优化。4.3 example age一个专门对抗时间漂移的特征论文里让我印象最深的一个细节是example age即样本距离当前时间的小时数训练时作为特征输入。为什么要有它因为训练数据里包含了一段时间内的历史日志模型学到的“当前时刻用户偏好”其实是过去一段时间的平均值。如果训练集中用户的视频分布随时间漂移模型就无法感知“现在是什么时间”。训练时加上example age后可以把这个时间偏置显式建模线上推理时把example age设为0或者负的小值相当于把模型拉回到“对最新时刻的预测”。代码实现很简单example_age tf.keras.Input(shape(1,), nameexample_age) # 直接作为上下文特征concat进DNN concat tf.keras.layers.Concatenate()([user_vector, context_input, example_age])不过我实测发现这个特征对训练数据跨度超过一周的场景效果比较明显如果只用最近三天的数据训练建模这个特征的意义就小很多。复现时可以先用开关控制不必一开始就加。4.4 要不要放类别特征ID很多复现代码会直接忽略人口属性、地域这些特征。这点我不太赞同。即使样本量不大地域特征也能帮冷启动用户快速建立基本兴趣画像。比如一个用户完全没有历史行为只有“北京、手机设备、凌晨”这几个上下文特征DNN仍然能输出一个平均水平的向量。所以推荐的实现策略是物品侧只保留视频ID embedding用户侧至少保留“历史序列特征 少量上下文特征”而不是裸用序列。5. 训练和上线的差异Softmax输出到最近邻检索5.1 为什么线上不做全量Softmax很多人第一次看到YoutubeDNN召回线上用最近邻检索时第一反应是“为什么不在线算一遍所有候选的softmax概率”答案很简单如果你有1000万个候选计算1000万个内积再全部过softmax单次请求的耗时是不可接受的。而向量检索可以做到毫秒级返回TopN并且如果用户向量和视频向量是同一网络训练出来的两者在向量空间中是天然对齐的用内积作为相似度在召回阶段足够。5.2 线上怎么拿用户向量和视频向量训练结束后用户向量来自DNN最后一个全连接层的输出。视频向量则来自Embedding矩阵中的每一行。在serving时视频向量离线全部导出到向量检索引擎比如常见的HNSW索引用户向量通过线上实时特征计算得到。导出代码如下示意# 假设已经训练好的model user_model tf.keras.Model( inputsmodel.inputs, outputsmodel.get_layer(user_out).output ) item_embeddings model.get_layer(item_emb).get_weights()[0]这里有一个细节很多人搞错导出item向量时取的是embedding层权重而不是某个dense层的输出。如果代码里对item embedding又做了一些变换那serving端必须同步这个变换。否则“训练的口径”和“检索的口径”不一致效果直接打折。5.3 一致性问题的严重性我复现这个模型时吃过一次“训练和serving不一致”的亏。具体表现是离线测试时AUC和召回都很好但上线后整体指标无变化甚至变差。排查了一整天发现原因是训练时由于方便我在user vector和item vector最后都接了一个L2正则化而导出item embedding时却漏了L2归一化导致两个向量空间的尺度完全错位。这个经验我在很多文章里都反复强调过只要serving链路和训练链路有任何一步不一致哪怕是多一个dropout没关、少一个归一化都会让模型的线上表现崩掉。重要训练时所有对向量施加的变换serving时都要原样记录并复现。建议写一段单测训练完随机抽几个用户样本走训练图和serving图分别算向量断言误差在1e-5以内。5.4 召回TopN后还需要重排最后说句题外话。YoutubeDNN召回输出的是几百个候选后续通常还会接一个精排模型比如CTR/CVR模型做排序。召回阶段的目标是“别丢掉正确答案”精排阶段才追求“把准确顺序排出来”。因此不要用ROC/AUC这种排序指标去压召回模型更值得看的是RecallK、精确率以及覆盖率和多样性。6. 复现过程中我踩过的坑以及一套可落地的配置6.1 样本偏差只看行为日志忽略内容供给我最早用统计日志训练YoutubeDNN时只从用户行为表里取样后来发现效果很差用户最终看的视频和召回结果高度重合但新增视频几乎永远无法被召回。原因很简单行为日志只能覆盖“已经曝光且被点击”的视频那些还没被曝光的好视频永远没有机会进入训练样本。解决方向有两个一是样本里融合一定比例的全局曝光未点击视频作为负样本二是定期用召回结果做探索流量把新内容引入训练分布。前者改动小后者需要产品配合。6.2 Embedding维度和网络宽度怎么定YoutubeDNN论文里没有给出确切的维度很多人问应该设多少。我的经验是视频数量在百万量级时embedding维度一般在128到256之间。小于64表达能力偏弱容易欠拟合大于512训练和存储成本上升明显而且召回精度不再线性增长。MLP方面我常用的配置是[256, 128]或两层[1024, 512]取决于特征数量。如果只有视频ID序列特征两层256就够如果视频ID外加了搜索词、上下文特征则可以加宽到1024。注意模型的复杂度并不是越高越好召回模型的目标是分出候选不是精确预测过拟合的风险反而容易出现在后续排序阶段。6.3 我自己的一套落地配置参考给想复现的读者一份我最近使用的配置清单配置项取值说明embedding维度128百万级视频量历史序列长度32超过32的视频行为做截断不足做padding负采样个数500采样数越多softmax近似越准但计算量变大正负样本采样分布按热度分布频率越高采样概率越高需配合logit修正DNN层数2层256128每层后接BN ReLU优化器Adam学习率0.001经常配合每隔一个epoch衰减batch size1024大batch对embedding的更新更稳定训练时长8~10轮后观察召回率收敛不宜训练过多轮容易过拟合历史行为这套配置在我的场景里离线Recall100为27%左右相比随机召回提升明显。如果你的数据差异大不要照抄重点是根据负采样数和embedding维度做几组实验再定。6.4 最后一个扩展建议双塔共享还是独立塔不少人在YoutubeDNN基础上做扩展时会把用户塔和视频塔改成结构一致的双塔模型共享底层embedding或部分参数。我这里提醒一下如果只是把YoutubeDNN简化成“用户embedding 候选embedding点积”很容易丢掉用户侧特征的丰富度。YoutubeDNN的价值在于它对用户侧的建模是完整的用DNN把多类异构特征映射成用户向量而不是简单把用户向量等同为历史视频平均。如果要做共享参数的双塔建议共享低频商品的embedding表、不共享高频商品的embedding具体效果需要通过实验验证。我做过的实验是共享embedding时收敛更快但最终效果略低于独立塔因为两个塔的学习目标不完全一致——用户塔要融合上下文视频塔只表示内容本身。写到这里核心代码和踩坑点基本都覆盖了。YoutubeDNN不是那种看一遍论文就能写好代码的模型最大的门槛恰恰在样本构造和serving一致性上。如果这两点想明白了代码本身反而是最简单的那部分。我自己每次复现这个模型都会先在离线把向量检索做通再回头调整训练逻辑这比直接训练完再造线上链路要省心得多。