🏠 总目录📚 本教程 08 · 深度学习模型
📑 本页目录(点开跳转)

08 · 深度学习推荐模型

30 分钟 | ⭐ 核心 | 🔨 有代码

这节最长,建议拆成两次看。中间有明确的 🛑 断点。


🎯 一句话

深度推荐模型的全部套路就一句:所有特征查 Embedding → 拼起来 → 过神经网络 → 输出概率。 不同模型的区别只在于「怎么拼」和「加了什么特殊结构」。

篮球鞋0.82运动袜0.46笔记本0.06护腕0.58咖啡机0.04球衣0.74用户的历史行为(颜色深浅 = 被激活的程度)加权求和 → 用户表示候选物品:篮球和「篮球」相关的历史被激活;咖啡机、笔记本几乎被忽略同一个用户,面对不同候选物品,「他是谁」应该是不一样的⭐ 传统做法把历史行为【平均】成一个固定向量 —— 那样「买过咖啡机」会一直稀释「爱打篮球」DIN 让候选物品去「点名」相关的历史,用户表示因此随候选而变
面对候选「篮球」,历史里的篮球鞋、球衣被高度激活,咖啡机几乎被忽略。⭐ 传统做法把历史行为平均成一个固定向量 —— 那样「买过咖啡机」会一直稀释「爱打篮球」。DIN 让候选去点名相关历史,用户表示因此随候选而变
用户特征全连接全连接用户向量用户塔物品特征全连接全连接物品向量物品塔点积相似度两座塔【各算各的】,最后才用一个点积碰头关键就在这个「最后才碰头」⭐ 因为物品向量和用户无关 → 可以【离线全部算好、建索引】线上只算一次用户塔,再做向量检索 —— 这才能在 1 亿物品里做到毫秒级⚠️ 代价:两塔在碰头前互相看不见,学不到细粒度交叉特征 —— 所以它用在召回,不用在精排
两座塔各算各的,最后才用一个点积碰头 —— 关键就在这个「最后」。⭐ 因为物品向量和用户无关,可以离线全部算好建索引,线上只算一次用户塔。⚠️ 代价是两塔碰头前互相看不见,学不到细粒度交叉 —— 所以它用在召回不用在精排。

🧱 万能模板:所有深度推荐模型的骨架

输入:user_id, item_id, 年龄段, 类目, 时间, 历史序列 …① Embedding 层每个稀疏特征 → 一个稠密向量user_id → 32 维 | 类目 → 16 维② 特征交互层【各模型的差异,全在这一层】直接拼接 → Deep | 两两点积 → FM显式交叉 → DCN | 注意力加权序列 → DIN用户塔 / 物品塔分开算 → 双塔③ MLP[512] → [256] → [128] → [1]ReLU / PReLU + BatchNorm④ Sigmoid → pCTR0 ~ 1 之间的一个概率损失:Binary Cross Entropy(点击 = 1,曝光未点 = 0)
所有深度推荐模型都是这四段:Embedding → 特征交互 → MLP → Sigmoid。⭐ 只有第 ② 段有分歧 —— 后面六个模型,你只需要记「它改了哪一层」

💡 记住这个模板,后面 6 个模型你只需要记「它改了哪一层」。


📜 模型演进图谱(一张表看完历史)

年份 模型 一句话核心 解决了什么
2010 FM 隐向量点积做二阶交叉 交叉特征稀疏学不动
2016 Wide&Deep (Google) 线性部分记忆 + 深度部分泛化 记忆与泛化的平衡
2016 YouTube DNN 召回+排序两阶段的深度化 工业界落地范式 ⭐
2017 DeepFM (华为) FM 和 DNN 共享 Embedding Wide 部分不用人工特征了
2017 DCN (Google) Cross Network 显式高阶交叉 高阶交叉的高效实现
2018 DIN (阿里) 注意力:历史行为按候选物品加权 用户兴趣是多样且动态的 ⭐
2018 MMoE (Google) 多专家+门控 多目标任务冲突
2019 DIEN (阿里) GRU + 兴趣演化 兴趣会随时间漂移
2019 BST (阿里) Transformer 建模行为序列 序列的顺序信息
2020 DCN-V2 低秩分解的 Cross DCN 的工业级改良
2024+ HSTU / 生成式 (Meta) 推荐当作序列生成任务 Scaling Law(第 14 节)⭐

① Wide & Deep:记忆 vs 泛化 ⭐

Google 2016 年的经典,思想比模型本身重要。

输出 (pCTR)【Wide 部分】线性模型 (LR)人工设计的交叉特征【Deep 部分】MLPEmbedding + 全连接「记忆」「泛化」记住训练集里见过的强规则组合例:装了 Netflix 且看过 Pandora→ 大概率会装 Pandora对没见过的组合也能给出合理预测例:(用户A, 物品B) 从未同时出现但两者 Embedding 接近 → 可能喜欢⭐ Wide & Deep = 死记硬背 + 举一反三,两者都要痛点:Wide 的交叉特征还得人工设计 → 于是有了 DeepFM
Wide & Deep 就是两条支路在输出前汇合:左边负责记忆,右边负责泛化。⭐ 纯记忆无法举一反三,纯泛化会推出莫名其妙的东西,所以两边都要。痛点是 Wide 的交叉特征仍需人工设计。

💡 人话

Wide = 死记硬背(「买过尿布的人会买啤酒」这种硬规则,记住就行) Deep = 举一反三(没见过的组合,靠 Embedding 相似度推测) 两者都要,因为纯记忆无法泛化,纯泛化会推出一些莫名其妙的东西。

Wide&Deep 的痛点:Wide 部分的交叉特征还得人工设计。→ 于是有了 DeepFM。


② DeepFM:把 Wide 换成 FM

输出 (pCTR)FM 部分自动二阶交叉,免人工特征DNN 部分高阶非线性交叉⭐ 共享同一份 Embedding参数少、训练快、Embedding 学得更充分原始特征两个改动:Wide 换成 FM + FM 与 DNN 共享 Embedding
DeepFM 只改了左半边:人工交叉的 LR 换成自动二阶交叉的 FM。⭐ 但真正的关键在底下那层 —— FM 和 DNN 吃同一份 Embedding,所以参数更少、训练更快、Embedding 学得更充分。

两个关键改进: 1. Wide 部分从「人工交叉的 LR」换成「自动二阶交叉的 FM」→ 不需要人工特征工程了 2. FM 和 DNN 共享 Embedding → 参数少、训练快、Embedding 学得更充分

📌 DeepFM 长期是工业界的默认 baseline。新做一个推荐系统,先上 DeepFM,再谈其他。


③ DIN:用户的兴趣不是一个向量 ⭐⭐

阿里 2018,这是概念上最重要的一个模型

问题

传统做法把用户历史行为做平均池化

  用户看过: [篮球鞋, 口红, 笔记本电脑, 键盘, 面膜]
                    ↓ 平均
             一个"四不像"的用户向量
                    ↓
  现在要判断他会不会点【机械键盘】
  → 口红和面膜的信息只是噪声,还稀释了键盘的信号

DIN 的解法:注意力

根据当前候选物品,动态决定历史行为的权重。

候选物品:机械键盘和每个历史行为算「相关度」篮球鞋权重 0.05口红权重 0.02笔记本电脑权重 0.55键盘权重 0.35面膜权重 0.03加权求和一个"针对键盘"的用户兴趣向量
同一段历史,一碰上「机械键盘」这个候选,权重立刻裂成两档:笔记本电脑 0.55、键盘 0.35,而口红加面膜合起来不到 0.05。⭐ 该看的就是这个落差 —— DIN 做的全部事情,就是让不相关的历史自动趋近于 0。

💡 人话「判断你会不会买键盘时,我只重点看你买过的电子产品,化妆品那部分先放一边。」

关键洞察同一个用户,面对不同候选物品,应该有不同的表示。 这个思想至今仍是精排模型的核心。

🔨 代码:DIN 的核心 —— 注意力单元

import torch
import torch.nn as nn

class AttentionUnit(nn.Module):
    """DIN 的核心:算历史行为对当前候选物品的注意力权重"""
    def __init__(self, emb_dim, hidden=[64, 32]):
        super().__init__()
        # 输入是 4 段拼接:候选、历史、差、积 → 显式提供交互信号
        layers, in_dim = [], emb_dim * 4
        for h in hidden:
            layers += [nn.Linear(in_dim, h), nn.PReLU()]
            in_dim = h
        layers.append(nn.Linear(in_dim, 1))
        self.mlp = nn.Sequential(*layers)

    def forward(self, query, keys, mask):
        """
        query: (B, D)      候选物品 Embedding
        keys:  (B, T, D)   历史行为 Embedding 序列
        mask:  (B, T)      1=真实行为, 0=padding
        """
        B, T, D = keys.shape
        q = query.unsqueeze(1).expand(-1, T, -1)          # (B,T,D)
        # ⭐ 拼接四种交互形式,让 MLP 自己学怎么算相关性
        x = torch.cat([q, keys, q - keys, q * keys], dim=-1)
        scores = self.mlp(x).squeeze(-1)                   # (B,T)

        # padding 位置置为极小值,softmax 后接近 0
        scores = scores.masked_fill(mask == 0, -1e9)
        weights = torch.softmax(scores, dim=1)             # (B,T)

        # 加权求和 → 针对该候选物品的用户兴趣向量
        return torch.bmm(weights.unsqueeze(1), keys).squeeze(1)   # (B,D)


class DIN(nn.Module):
    def __init__(self, n_items, n_cats, emb_dim=32):
        super().__init__()
        self.item_emb = nn.Embedding(n_items, emb_dim, padding_idx=0)
        self.cat_emb  = nn.Embedding(n_cats,  emb_dim, padding_idx=0)
        self.attn = AttentionUnit(emb_dim * 2)
        self.mlp = nn.Sequential(
            nn.Linear(emb_dim * 2 * 3, 200), nn.PReLU(),
            nn.Linear(200, 80), nn.PReLU(),
            nn.Linear(80, 1),
        )

    def forward(self, hist_items, hist_cats, cand_item, cand_cat, mask):
        hist = torch.cat([self.item_emb(hist_items), self.cat_emb(hist_cats)], -1)
        cand = torch.cat([self.item_emb(cand_item),  self.cat_emb(cand_cat)], -1)

        interest = self.attn(cand, hist, mask)             # 动态兴趣向量
        # 拼接:候选 + 兴趣 + 交互
        x = torch.cat([cand, interest, cand * interest], dim=-1)
        return torch.sigmoid(self.mlp(x)).squeeze(-1)

🛑 中场休息点

上半场讲的是「精排模型」。下半场讲「召回模型」——它们的设计约束完全不同。

累了就停这。⚡ 回来时看这句:

Wide&Deep = 记忆+泛化;DeepFM = 把 Wide 换成 FM 且共享 Embedding;DIN = 用注意力让用户表示随候选物品变化。


④ 双塔模型 (DSSM):召回的标准答案 ⭐⭐⭐

为什么召回不能用 DIN?

DIN 需要「用户和候选物品一起过网络」→ 每个候选都要跑一次模型。 1 亿个候选 = 跑 1 亿次。不可能。

双塔的解法:让用户和物品「分开算」

用户塔物品塔user_id画像特征(年龄 / 地域 / 性别)行为序列(近 500 次)item_id类目 / 标签 / 作者内容 Embedding(图文 / 视频)MLPMLP用户向量 u64 维物品向量 v64 维score = u · v两塔唯一的接触点⭐ 两塔之间一根连线都没有 —— 物品向量才能离线批量算好
两座塔结构完全对称:各吃各的特征、各过各的 MLP、各吐一个 64 维向量,直到最后才用一次点积碰头。⭐ 塔与塔之间一根连线都没有 —— 这正是物品向量能离线批量算完、线上只算用户塔的原因。

🔑 关键:两个塔「老死不相往来」,这带来了巨大的工程红利

  【离线,每天跑一次】
     所有 1 亿个物品过物品塔 → 1 亿个向量 → 存入向量索引 (Faiss/HNSW)

  【线上,每次请求】
     只跑一次用户塔  → 一个用户向量 u   (~5ms)
     在向量索引里找和 u 最近的 1000 个 → ANN 检索  (~5ms)

     总共 10ms,从 1 亿里选出 1000 个! ✅

⚠️ 代价:用户和物品只能在最后点积一次,无法做特征交叉。 这就是双塔的天花板——它换来了速度,牺牲了精度。所以它做召回,不做精排。

🔨 代码:双塔 + In-batch 负采样

import torch
import torch.nn as nn
import torch.nn.functional as F

class TwoTower(nn.Module):
    def __init__(self, n_users, n_items, emb_dim=64, out_dim=64):
        super().__init__()
        self.user_emb = nn.Embedding(n_users, emb_dim)
        self.item_emb = nn.Embedding(n_items, emb_dim)
        self.user_tower = nn.Sequential(
            nn.Linear(emb_dim, 128), nn.ReLU(), nn.Linear(128, out_dim))
        self.item_tower = nn.Sequential(
            nn.Linear(emb_dim, 128), nn.ReLU(), nn.Linear(128, out_dim))

    def encode_user(self, u):
        # ⭐ L2 归一化:让点积等价于余弦相似度,训练更稳定
        return F.normalize(self.user_tower(self.user_emb(u)), dim=-1)

    def encode_item(self, i):
        return F.normalize(self.item_tower(self.item_emb(i)), dim=-1)


def in_batch_softmax_loss(model, users, pos_items, temperature=0.05,
                          item_freq=None):
    """
    In-batch 负采样:同一批次里其他人的正样本,就是我的负样本
    好处:不用单独采负样本,一个 batch 就能产生 B×(B-1) 个负样本对
    """
    u = model.encode_user(users)          # (B, D)
    v = model.encode_item(pos_items)      # (B, D)

    logits = u @ v.T / temperature        # (B, B) 每个用户对所有物品的分

    # ⭐ 关键修正:LogQ 校正
    # 热门物品更容易出现在 batch 里,会被过度当成负样本 → 减去 log(采样概率)
    if item_freq is not None:
        logits = logits - torch.log(item_freq[pos_items]).unsqueeze(0)

    labels = torch.arange(len(users), device=users.device)  # 对角线是正样本
    return F.cross_entropy(logits, labels)

⚠️ 双塔的三个必踩的坑

说明 解法
负样本选错 用「曝光未点击」当负样本 → 训出来的模型只会区分精排级别的细微差别,不会从全库里挑 ⭐ 召回的负样本必须是全库随机采样(+ 少量困难负样本),因为线上面对的是全库
热门物品支配 In-batch 负采样时热门物品出现频率高,被过度惩罚 LogQ 校正(上面代码里那行)
温度系数 τ 太大分布太平,太小训练不稳 从 0.05 开始调

🔑 「训练和线上的候选分布要一致」是召回模型的第一原则。 记住这句,能少走一年弯路。


⑤ YouTube DNN:工业界落地的范式

2016 年的这篇论文,教会了整个行业「深度学习推荐系统怎么落地」。

它的召回模型:本质是双塔的前身 - 输入:观看历史 Embedding 平均 + 搜索历史 + 人口统计 + 视频年龄 - 输出:把召回当成超大规模多分类问题(几百万个视频 = 几百万个类别) - 线上:用户向量做 ANN 检索

三个被反复引用的工程细节 ⭐:

  1. Example Age(视频年龄)特征 训练时输入视频发布至今的时长,预测时统一填 0。 → 让模型学会「新内容更受欢迎」这个规律,预测时假装所有内容都是最新的。 这是处理「新鲜度偏置」的经典技巧。

  2. 每个用户固定的样本数 防止重度用户主导训练。一个每天看 500 个视频的人,不应该贡献 500 倍的梯度。

  3. 不用「随机留一个」做验证,用「留最后一次」 避免未来信息泄漏(对应第 3 节的数据泄漏坑)。


🗺️ 该用哪个模型?决策表

场景 推荐模型 理由
刚起步,数据少 ItemCF + 热门 别上深度学习,收益不划算
有百万级样本 DeepFM / DCN-V2 稳、易训、baseline
精排,有丰富行为序列 DIN / DIEN / BST 序列是最大的信息源
召回 双塔 DSSM 唯一能扛 1 亿候选的架构
多目标(点击+时长+点赞) MMoE / PLE 缓解任务冲突
转化率预估(样本极稀疏) ESMM 解决样本选择偏差
超大规模 + 长序列 HSTU / 生成式(第 14 节) 有 Scaling Law

🔧 训练的实用细节(这些没人写在论文里)

项目 建议
优化器 Adam (lr=1e-3);Embedding 层可单独用更大 lr 或 Adagrad
Embedding 维度 16–64。不是越大越好,通常 32 够用
Batch Size 1024–8192。双塔用 in-batch 负采样时,batch 越大负样本越多,效果越好
训练轮数 推荐系统数据量大,通常只训 1 个 epoch(甚至流式训练不分 epoch)
正负样本比 精排通常 1:3 ~ 1:10 做负采样,注意打分要做校准还原真实概率
过拟合信号 训练 AUC 一路涨、验证 AUC 第 2 个 epoch 就掉 → 加 Dropout / 减 Embedding 维度 / 只训 1 epoch
在线学习 工业界普遍做「天级全量 + 小时级/分钟级增量」,模型永远追着最新数据跑

🔗 这一章连到哪里

去哪为什么
ML基础 07万能骨架里那个 MLP 到底在干什么,以及为什么 LR 学不到高阶交叉
ML基础 08占了 99% 参数量的 Embedding 表是怎么被稀疏梯度更新的
全景导论 02DIN 的注意力就是 attention 的推荐版:候选物品当 query,历史行为当 key/value
Kaggle 19 · 排序与多目标⭐ 本章讲了 DCN 这一条显式交叉的路,另外两条(AutoInt 的自注意力交叉、xDeepFM 的向量级交叉)在那边——三者放一起看才知道「显式交叉」有几种做法
Kaggle 19 · 多目标损失权重调优本章讲了 MMoE/PLE 怎么共享参数,但多个目标的 loss 该按什么比例相加没展开。GradNorm / DWA 这类自动配权方法在那一节
全景导论 10 · 多模态⭐⭐ 你在这一章学的双塔,换个模态就是 CLIP——用户塔换成图片塔、物品塔换成文字塔,in-batch 负采样一模一样,温度系数 τ 也一模一样。那边给了 InfoNCE 的公式形式和「为什么 batch size 对对比学习特别关键」,是同一个机制的另一面,看完会回过头加深对这一章的理解

✅ 检查点

  1. 所有深度推荐模型的通用骨架是什么?
  2. Wide 和 Deep 分别负责什么?
  3. DIN 的核心洞察是什么?为什么平均池化不够?
  4. 为什么召回必须用双塔,不能用 DIN?双塔的代价是什么?
  5. 召回模型的负样本应该怎么采?为什么不能用「曝光未点击」?
  6. YouTube DNN 的 Example Age 特征怎么用?解决什么问题?
👀 答案 1. 特征 → Embedding → 特殊交互结构 → MLP → Sigmoid → 概率。 2. Wide 负责记忆(记住训练集里见过的强规则组合),Deep 负责泛化(对没见过的组合也能预测)。 3. 用户兴趣是多样的,面对不同候选物品应该有不同的表示。平均池化把所有兴趣糊成一团,无关兴趣变成噪声。 4. DIN 需要用户和候选一起过网络,1 亿候选要跑 1 亿次。双塔把用户塔和物品塔分开,物品向量可以离线算好存索引,线上只做一次用户塔 + ANN 检索。代价:两塔之间无法做特征交叉,精度上限低。 5. 全库随机采样(+少量困难负样本)。因为线上召回面对的是全库,训练分布必须和线上一致。用「曝光未点击」训出的模型只会做精排级别的细微区分。 6. 训练时输入视频发布时长,预测时统一填 0。让模型学会新鲜度规律,同时在预测时消除新鲜度偏置。

🛑 可以停在这里

走神救援

通用骨架:特征→Embedding→交互层→MLP→概率。精排线:Wide&Deep(记忆+泛化) → DeepFM(FM替Wide,共享Emb) → DIN(注意力,用户表示随候选变) → MMoE(多目标)。召回线:双塔DSSM,两塔分离所以物品向量可离线+ANN检索,代价是不能特征交叉。召回负样本必须全库随机采(分布一致原则),要做 LogQ 校正。Wide & Deep 的分工要记牢:Wide 侧负责记忆(背下高频共现的组合),Deep 侧负责泛化(没见过的组合靠 Embedding 推出来)——后面所有精排模型都在这条轴上演化。⭐ DIN 的真正洞见是「用户不该只有一个固定向量」:候选是泳裤时,历史里的沙滩鞋权重高、笔记本权重低;换个候选权重就变。代价是 DIN 只能做精排——用户表示依赖候选,没法离线算好存起来。⚠️ 双塔的短板正是它的长处:两塔到最后一刻才交互,所以物品向量能离线算完丢进 ANN,但用户特征和物品特征永远无法交叉。⭐ 训练细节别踩坑:推荐数据量大,通常只训 1 个 epoch,验证 AUC 第 2 轮就掉是正常现象;Embedding 维度 16–64 就够用,不是越大越好;双塔用 in-batch 负采样时 batch 越大负样本越多,效果越好。

下一节 👉 09-序列推荐.md

打卡记录保存在你的浏览器里,首页能看到总进度