📑 本页目录(点开跳转)
08 · 深度学习推荐模型
⏱ 30 分钟 | ⭐ 核心 | 🔨 有代码
这节最长,建议拆成两次看。中间有明确的 🛑 断点。
🎯 一句话
深度推荐模型的全部套路就一句:所有特征查 Embedding → 拼起来 → 过神经网络 → 输出概率。 不同模型的区别只在于「怎么拼」和「加了什么特殊结构」。
🧱 万能模板:所有深度推荐模型的骨架
💡 记住这个模板,后面 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 年的经典,思想比模型本身重要。
💡 人话:
Wide = 死记硬背(「买过尿布的人会买啤酒」这种硬规则,记住就行) Deep = 举一反三(没见过的组合,靠 Embedding 相似度推测) 两者都要,因为纯记忆无法泛化,纯泛化会推出一些莫名其妙的东西。
Wide&Deep 的痛点:Wide 部分的交叉特征还得人工设计。→ 于是有了 DeepFM。
② DeepFM:把 Wide 换成 FM
两个关键改进: 1. Wide 部分从「人工交叉的 LR」换成「自动二阶交叉的 FM」→ 不需要人工特征工程了 2. FM 和 DNN 共享 Embedding → 参数少、训练快、Embedding 学得更充分
📌 DeepFM 长期是工业界的默认 baseline。新做一个推荐系统,先上 DeepFM,再谈其他。
③ DIN:用户的兴趣不是一个向量 ⭐⭐
阿里 2018,这是概念上最重要的一个模型。
问题
传统做法把用户历史行为做平均池化:
用户看过: [篮球鞋, 口红, 笔记本电脑, 键盘, 面膜]
↓ 平均
一个"四不像"的用户向量
↓
现在要判断他会不会点【机械键盘】
→ 口红和面膜的信息只是噪声,还稀释了键盘的信号
DIN 的解法:注意力
根据当前候选物品,动态决定历史行为的权重。
💡 人话:「判断你会不会买键盘时,我只重点看你买过的电子产品,化妆品那部分先放一边。」
关键洞察:同一个用户,面对不同候选物品,应该有不同的表示。 这个思想至今仍是精排模型的核心。
🔨 代码: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 亿次。不可能。
双塔的解法:让用户和物品「分开算」
🔑 关键:两个塔「老死不相往来」,这带来了巨大的工程红利
【离线,每天跑一次】
所有 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 检索
三个被反复引用的工程细节 ⭐:
-
Example Age(视频年龄)特征 训练时输入视频发布至今的时长,预测时统一填 0。 → 让模型学会「新内容更受欢迎」这个规律,预测时假装所有内容都是最新的。 这是处理「新鲜度偏置」的经典技巧。
-
每个用户固定的样本数 防止重度用户主导训练。一个每天看 500 个视频的人,不应该贡献 500 倍的梯度。
-
不用「随机留一个」做验证,用「留最后一次」 避免未来信息泄漏(对应第 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 表是怎么被稀疏梯度更新的 |
| 全景导论 02 | DIN 的注意力就是 attention 的推荐版:候选物品当 query,历史行为当 key/value |
| Kaggle 19 · 排序与多目标 | ⭐ 本章讲了 DCN 这一条显式交叉的路,另外两条(AutoInt 的自注意力交叉、xDeepFM 的向量级交叉)在那边——三者放一起看才知道「显式交叉」有几种做法 |
| Kaggle 19 · 多目标损失权重调优 | 本章讲了 MMoE/PLE 怎么共享参数,但多个目标的 loss 该按什么比例相加没展开。GradNorm / DWA 这类自动配权方法在那一节 |
| 全景导论 10 · 多模态 | ⭐⭐ 你在这一章学的双塔,换个模态就是 CLIP——用户塔换成图片塔、物品塔换成文字塔,in-batch 负采样一模一样,温度系数 τ 也一模一样。那边给了 InfoNCE 的公式形式和「为什么 batch size 对对比学习特别关键」,是同一个机制的另一面,看完会回过头加深对这一章的理解 |
✅ 检查点
- 所有深度推荐模型的通用骨架是什么?
- Wide 和 Deep 分别负责什么?
- DIN 的核心洞察是什么?为什么平均池化不够?
- 为什么召回必须用双塔,不能用 DIN?双塔的代价是什么?
- 召回模型的负样本应该怎么采?为什么不能用「曝光未点击」?
- 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