推荐系统(三)—— 深度学习基础模型

从 MLP 到 Embedding,再到 NeuMF、YouTube DNN、Wide & Deep —— 用渐进的方式讲清深度学习推荐系统的每一块基石,附经过原文核对的架构图和可直接运行的 PyTorch 代码。

2016 年 6 月,Google 在一篇短短一页的会议论文里悄悄改写了推荐系统的版图。那篇论文叫 Wide & Deep Learning,描述的模型当时正在 Google Play 应用商店里跑——一个用户量上十亿的产品。一年之内,主流厂商都把深度模型推上了线;到 2019 年,行业默认值已经变了:矩阵分解只是 baseline,不再是系统。

发生了什么?多层神经网络带来了四件经典方法做不到的事:

  • 表征是学出来的。Embedding 取代 one-hot,模型从点击数据里端到端地长出语义。
  • 交互是非线性的。两层带 ReLU 的 MLP 能拟合 XOR;点积不行。
  • 多模态可以融合。文本、图像、行为序列都走同一条梯度。
  • 优化是端到端的。再也不用人手设计交叉特征,损失函数自己挑。

本文沿着 dot(p_u, q_i) 一路走到 NeuMF、YouTube DNN 和 Wide & Deep。架构都按原论文核对过,每一步配可直接跑的 PyTorch 代码。

你将建立直觉的部分

  • MLP 直觉:为什么"Linear + ReLU"叠起来就是一台通用的交互引擎。
  • Embedding:不只是 nn.Embedding 怎么调,而是为什么梯度会把相似 ID 拉到一起。
  • NeuMF(He 等,WWW 2017):两条路径,一个目标。
  • YouTube DNN(Covington 等,RecSys 2016):当今几乎所有大规模推荐系统的两阶段模板。
  • Wide & Deep(Cheng 等,DLRS 2016):教科书级别的"记忆 + 泛化"融合。

前置知识

  • 熟悉 PyTorch 基础(nn.Module、autograd、DataLoader)。
  • 看过本系列第二篇 ,理解矩阵分解和隐式/显式反馈。
  • 基本线性代数:点积、矩阵向量乘法。

一、为什么是深度学习,为什么是现在

经典方法撞到的天花板

深度模型在 CTR 任务上对比 MF、FM、Wide & Deep、DeepFM、DIN 的 AUC,深度模型比 MF baseline 高 5%-13%

上面的数字是公开 CTR 数据集(Criteo、Avazu、隐式反馈版的 MovieLens-1M)上的典型表现。结论很一致:每多一层非线性就多换来几个 AUC 百分点,而几个 AUC 百分点在 GMV 上意义重大。

要看清为什么,得先看清每种经典方法到底能表达什么。

矩阵分解用一个点积来预测评分:

$$\hat{r}_{ui} = \mathbf{p}_u^\top \mathbf{q}_i$$

直白讲:每个用户、每个物品都是一个短向量,预测就是它们的"对齐程度"。漂亮,但是线性的——没法表达"我同时喜欢科幻动作,但单独一个都不行"这种交互。

**因子分解机(FM)**加上了二阶交互:

$$\hat{y}(\mathbf{x}) = w_0 + \sum_i w_i x_i + \sum_{i它是 MF 的真超集,但也只到二阶。“年轻用户 × 周五晚上 × 惊悚片"这种三阶组合,得人手设计。

协同过滤直接绕过建模,找"和你像的人喜欢什么”。在矩阵稀疏到一定程度之前都好用——而生产环境里,矩阵总会稀疏。

三者的共同短板是:最多到二阶,并且都需要人来设计正确的特征。

深度学习补上的部分

带非线性的两层网络在理论上是通用函数逼近器。落到实践,就是:

  • 一个建在 Embedding 之上的 MLP,可以拟合数据所支撑的任意阶交互。
  • 同一套骨干可以同时吃图像(CNN)、文本(Transformer)、序列(RNN)—— 联合训练。
  • 冷启动有了抓手:新物品只要有内容(标题、图像),预训练编码器就能给它一个像样的初始向量。

代价是更多算力、更差的可解释性、更多超参。第七节会讲让这笔买卖划算的工程纪律。


二、MLP 直觉:从点积到非线性交互

在认识具体模型之前,先内化一件事:MLP 比点积多给了你什么。

点积 $\mathbf{p}^\top \mathbf{q} = \sum_k p_k q_k$ 是把每个维度的乘积加起来。它对称、线性,无法表达"特征 A 只在特征 B 也激活时才有用"。

把 $[\mathbf{p}; \mathbf{q}]$ 拼起来过一层 Linear → ReLU → Linear

$$f(\mathbf{p}, \mathbf{q}) = \mathbf{w}^\top \, \text{ReLU}\!\big(\mathbf{W} [\mathbf{p}; \mathbf{q}] + \mathbf{b}\big)$$

ReLU 会根据"哪些输入维度组合被激活"来开关每个隐藏单元。隐藏单元够多,就是通用逼近定理。交互形式不再是写死的公式,而是学出来的。

把"点积换成 MLP"这一刀就是种子,NeuMF、YouTube DNN、Wide & Deep 都从这里长出来。


三、Embedding:从稀疏 ID 到语义表征的桥梁

One-hot 是敌人

想象一个目录:1000 万用户、100 万物品。one-hot 编码下,每个用户是一个 1000 万维的向量,只有一个位置是 1。三件事同时出问题:

  1. 存储和计算爆炸——光一个用户输入就是 40 MB 的浮点向量。
  2. 信息密度塌陷——99.99999% 的位置都是零。
  3. 任意两个 one-hot 的距离都相等:$\|\mathbf{e}_i - \mathbf{e}_j\|_2 = \sqrt{2}$。用户 42 和用户 43 的距离,跟用户 42 和用户 9,999,999 的距离没区别。

Embedding 一次性解掉这三件事。它把每个 ID 映射到一个 64 维左右的稠密向量。训练完之后,口味相似的用户在这个 64 维空间里挨着

类比:one-hot 像一本通讯录,每个人都住在一座孤岛上,岛与岛距离相等。Embedding 是地理学家把岛重排,让相关的人住的岛挨在一起。“找相似用户"突然就变成了一次最近邻查询。

Embedding 到底是什么

数学上,Embedding 层就是一个可学习的矩阵 $\mathbf{P} \in \mathbb{R}^{m \times d}$,第 $i$ 行就是用户 $i$ 的向量。“查表"操作 $\mathbf{p}_i = \mathbf{P}[i, :]$ 等价于 $\mathbf{P}^\top \mathbf{e}_i$,但实现成行索引来跑得快。

代码上一行就够:

1
2
3
4
5
import torch
import torch.nn as nn

user_embedding = nn.Embedding(num_embeddings=10_000_000, embedding_dim=64)
user_vec = user_embedding(torch.LongTensor([42]))   # shape: [1, 64]

梯度是怎么教会 Embedding 有意义的

Embedding 一开始是随机的。它能变得有语义,全靠梯度推它。

拿一个点击预测的损失来说:用户 $u$ 点了物品 $i$,损失就告诉优化器"让 $\mathbf{p}_u^\top \mathbf{q}_i$ 变大”。反向传播会把 $\mathbf{p}_u$ 微微往 $\mathbf{q}_i$ 的方向推,反之亦然。在百万次点击之后,这一条简单规则就能产出令人惊讶的结构:受众重叠的物品会聚在一起,常被一起买的物品会聚在一起,同一个艺术家的歌会聚在一起。

你从没告诉模型"流派"是什么。是梯度的几何结构自己发现了它。

把它学到的东西画出来

训练得到的物品 Embedding 经过 t-SNE 投影后形成科幻、动作、纪录片、爱情喜剧、恐怖等聚类,跨类型物品落在簇之间

把训练好的物品 Embedding 矩阵用 t-SNE 投到二维,通常会看到这样一幅图:同类物品紧紧聚成一团,混合类物品在相关簇之间架起桥梁(“科幻动作"就坐在科幻和动作之间),长尾物品散落在外围。这是判断模型"到底有没有学到东西"的最佳诊断。

维度怎么选

物品规模推荐 $d$备注
<10 万8 – 32再大就过拟合
10 万 – 100 万32 – 64多数场景的甜点
100 万 – 1 亿64 – 128128 之上收益递减
网级128 – 256只有交互上百亿才合算

YouTube 论文里、后续工作里反复印证的一条经验法则:从 $d = 32$ 起步,翻倍直到验证 AUC 提升不到 ~0.5%,停。 内存和服务延迟随 $d$ 线性增长,质量却是凹的。

一个干净、可复用的 Embedding 层

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import torch
import torch.nn as nn

class IdEmbedding(nn.Module):
    """带 Xavier 初始化和可选 padding 的 Embedding 层。"""

    def __init__(self, vocab_size: int, dim: int, padding_idx: int | None = None):
        super().__init__()
        self.emb = nn.Embedding(vocab_size, dim, padding_idx=padding_idx)
        nn.init.xavier_uniform_(self.emb.weight)
        if padding_idx is not None:
            with torch.no_grad():
                self.emb.weight[padding_idx].zero_()

    def forward(self, ids: torch.LongTensor) -> torch.Tensor:
        return self.emb(ids)

多字段类别特征(用户 ID、物品 ID、类目、城市……)每个字段一张表,再 stack 起来:

1
2
3
4
5
6
7
8
class MultiFieldEmbedding(nn.Module):
    def __init__(self, field_dims: list[int], dim: int):
        super().__init__()
        self.tables = nn.ModuleList([IdEmbedding(v, dim) for v in field_dims])

    def forward(self, x: torch.LongTensor) -> torch.Tensor:
        # x: [batch, num_fields] -> [batch, num_fields, dim]
        return torch.stack([t(x[:, i]) for i, t in enumerate(self.tables)], dim=1)

这个 [batch, num_fields, dim] 的张量,就是下面所有模型期望的输入形状。


四、神经协同过滤(NCF 与 NeuMF)

想法:把点积换成更聪明的东西

He、Liao、Zhang、Nie、Hu、Chua 在 WWW 2017 上提出 NCF。卖点朴素得让人意外:矩阵分解其实是网络的特例,把它一般化就能做得更好。

论文里一共有三兄弟:

  • GMF(Generalized Matrix Factorization):可学习权重的点积。
  • MLP:纯粹的拼接 + 深度网络,不预设"内积"这种归纳偏置。
  • NeuMF:把 GMF 和 MLP 融合起来,每条路径用各自独立的 Embedding。

实践里 NeuMF 才是真正重要的那个。下面这张架构图忠于原论文。

NeuMF 架构

NeuMF 架构图:左侧 GMF 路径用元素积融合用户/物品 Embedding,右侧 MLP 路径用拼接 + ReLU 层,顶部融合后接 sigmoid 输出

从下往上看:

  1. 每边两张 Embedding 表。GMF 和 MLP 共享 Embedding——论文里强调这一点很关键。每条路径学自己擅长的表征。
  2. GMF 路径:元素积 $\mathbf{p}_u^\text{GMF} \odot \mathbf{q}_i^\text{GMF}$。在它上面再乘一个可学习权重,就是点积的推广。
  3. MLP 路径:把 $[\mathbf{p}_u^\text{MLP}; \mathbf{q}_i^\text{MLP}]$ 拼起来,过 2–3 层 Dense + ReLU(典型尺寸:$128 \to 64 \to 32$)。
  4. 融合:把两条路径的输出拼起来,再投影成一个标量过 sigmoid:
$$\hat{y}_{ui} = \sigma\!\left(\mathbf{h}^\top \begin{bmatrix} \mathbf{p}_u^\text{GMF} \odot \mathbf{q}_i^\text{GMF} \\ \mathbf{z}_L^\text{MLP} \end{bmatrix}\right)$$

对隐式反馈(点击、播放、购买),损失是二元交叉熵:

$$\mathcal{L} = -\sum_{(u, i) \in \mathcal{D}^+ \cup \mathcal{D}^-} \big[ y_{ui} \log \hat{y}_{ui} + (1 - y_{ui}) \log(1 - \hat{y}_{ui}) \big]$$

负样本集 $\mathcal{D}^-$ 由采样得到——通常每个正样本配 4 个负样本。

NeuMF 端到端实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import torch
import torch.nn as nn

class NeuMF(nn.Module):
    """NeuMF:融合 GMF 与 MLP,两条路径独立 Embedding(He 等,2017)。"""

    def __init__(
        self,
        num_users: int,
        num_items: int,
        embedding_dim: int = 32,
        mlp_layers: tuple[int, ...] = (128, 64, 32),
        dropout: float = 0.0,
    ):
        super().__init__()
        # 两条路径独立 Embedding——NeuMF 的关键设计。
        self.user_gmf = nn.Embedding(num_users, embedding_dim)
        self.item_gmf = nn.Embedding(num_items, embedding_dim)
        self.user_mlp = nn.Embedding(num_users, embedding_dim)
        self.item_mlp = nn.Embedding(num_items, embedding_dim)

        # MLP 塔,输入是拼接后的用户/物品向量。
        layers, in_dim = [], embedding_dim * 2
        for out_dim in mlp_layers:
            layers += [nn.Linear(in_dim, out_dim), nn.ReLU()]
            if dropout > 0:
                layers.append(nn.Dropout(dropout))
            in_dim = out_dim
        self.mlp = nn.Sequential(*layers)

        # 输出头看到的是 [GMF 向量;MLP 向量]。
        self.head = nn.Linear(embedding_dim + mlp_layers[-1], 1)

        for emb in (self.user_gmf, self.item_gmf, self.user_mlp, self.item_mlp):
            nn.init.normal_(emb.weight, std=0.01)

    def forward(self, users: torch.LongTensor, items: torch.LongTensor) -> torch.Tensor:
        gmf = self.user_gmf(users) * self.item_gmf(items)           # [B, d]
        mlp_in = torch.cat([self.user_mlp(users), self.item_mlp(items)], dim=-1)
        mlp_out = self.mlp(mlp_in)                                  # [B, mlp_layers[-1]]
        logits = self.head(torch.cat([gmf, mlp_out], dim=-1)).squeeze(-1)
        return torch.sigmoid(logits)


# 冒烟测试
model = NeuMF(num_users=10_000, num_items=5_000, embedding_dim=32, dropout=0.2)
users = torch.randint(0, 10_000, (8,))
items = torch.randint(0, 5_000, (8,))
labels = torch.randint(0, 2, (8,)).float()
preds = model(users, items)
loss = nn.BCELoss()(preds, labels)
print(f"preds={preds.detach().numpy().round(3)}  loss={loss.item():.4f}")

NeuMF 训练里容易被忽略的细节

论文里有三件事经常被人忘掉:

  • 两条路径分别预训练。先单独训 GMF,再单独训 MLP,最后把两份权重拿来初始化 NeuMF。论文里光这个 trick 就有 ~2% 的 AUC 提升。
  • 负样本比例要选对。“1 正 4 负"是经典默认;1:1 欠拟合,1:10 浪费算力。
  • bias 不要做 L2。给偏置项加 weight decay 会掉点,要在优化器的参数组里把它们排除。

五、YouTube DNN:撑起半个互联网的两阶段流水线

Covington、Adams、Sargin(RecSys 2016)是深度学习时代被引最多的工业推荐论文。它的"召回(candidate generation)+ 排序(ranking)“两阶段拆分,是后来几乎所有大规模推荐系统的模板:抖音、Spotify、Pinterest、Instagram、淘宝。

为什么要两阶段

每次请求都给十亿个视频打分根本不现实。给十亿个物品都喂丰富特征实时打分也不现实。解法是:

  1. 召回阶段用便宜的模型和便宜的特征,把候选集从百万缩到几百。
  2. 排序阶段用很重的模型和丰富特征,对这几百个候选精打分,挑 top-K。

架构

YouTube DNN 两阶段流水线:左侧召回塔输入观看历史、搜索 token、地域、年龄、性别;右侧排序塔用更丰富的特征预测期望观看时长

召回被建模成一个"超级多分类"问题:给定用户当前状态,预测他下一个会看的视频是哪一个,类别数等于候选视频总数(百万级)。用户塔把最近看过的视频 Embedding 平均池化、和人口学特征拼接,过三层 ReLU($1024 \to 512 \to 256$),输出一个 256 维的用户向量。训练用 sampled softmax;上线时把视频 Embedding 灌进 ANN 索引(HNSW、ScaNN),用户向量就是一次最近邻查询——十亿物品也只要个位数毫秒。

排序是一个更深的前馈网络($1024 \to 512 \to 256 \to 128$),吃的特征也多得多:曝光视频自身的 Embedding、同频道历史观看的 Embedding、距离上次观看的时间在 feed 中的位置、语言匹配等等。关键是输出头用了带权重的 logistic 回归,目标是预测期望观看时长,而不是点击。论文证明这个目标比纯 CTR 更对齐长期满意度。

YouTube 论文里值得抄的设计

三个老化得很好的选择:

  • 用平均池化压缩用户最近行为。便宜、并行、强 baseline。序列模型(第六篇)只有在数据足够多时才赢得了它。
  • 召回当分类做,不要当回归做。Sampled softmax + ANN 上线,是行业的统治性范式。
  • 认真选标签。“观看时长”、“长点击”(停留 >30 秒)、“完播"都比裸点击泛化得好。损失函数就是产品规格。

PyTorch 里召回塔的骨架:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import torch
import torch.nn as nn
import torch.nn.functional as F

class YouTubeCandidateTower(nn.Module):
    """召回塔:产出可以直接进 ANN 的用户向量。"""

    def __init__(
        self,
        num_videos: int,
        num_searches: int,
        num_geos: int,
        embedding_dim: int = 64,
        user_dim: int = 256,
    ):
        super().__init__()
        self.video_emb = nn.Embedding(num_videos, embedding_dim, padding_idx=0)
        self.search_emb = nn.Embedding(num_searches, embedding_dim, padding_idx=0)
        self.geo_emb = nn.Embedding(num_geos, embedding_dim)

        in_dim = 3 * embedding_dim + 2  # 视频池化 + 搜索池化 + 地域 + 年龄 + 性别
        self.tower = nn.Sequential(
            nn.Linear(in_dim, 1024), nn.ReLU(),
            nn.Linear(1024, 512), nn.ReLU(),
            nn.Linear(512, user_dim), nn.ReLU(),
        )

    def forward(self, watched, searched, geo, age, gender):
        # watched, searched: [B, L],padding 过;对非 padding 的位置做 mean pool。
        v = self._masked_mean(self.video_emb(watched), watched != 0)
        s = self._masked_mean(self.search_emb(searched), searched != 0)
        g = self.geo_emb(geo)
        x = torch.cat([v, s, g, age.unsqueeze(-1), gender.unsqueeze(-1)], dim=-1)
        return F.normalize(self.tower(x), dim=-1)  # 归一化的用户向量,配 cosine 检索

    @staticmethod
    def _masked_mean(emb: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
        m = mask.unsqueeze(-1).float()
        return (emb * m).sum(1) / m.sum(1).clamp(min=1.0)

训练时把这个用户向量和一组采样的候选视频 Embedding 拼起来过 softmax;上线时直接对 ANN 索引做点积。这一座塔,就是当今工业级召回的主力机型。


六、Wide & Deep:记忆遇上泛化

洞察

Cheng 等人(DLRS 2016)注意到,深度模型虽然泛化好,但有时会"过度推荐”——因为 Embedding 把一切都抹平了,它会推一些"听起来合理但其实不对"的物品。反过来,带交叉特征的线性模型能完美记住具体共现,但没法外推。

类比:记忆是那个会跟你说"你喜欢 Inception,那一定喜欢 Tenet"的朋友——具体、准确,但永远不冒险。泛化是那个会跟你说"你喜欢烧脑悬疑片,试试 Primer"的朋友——视野更宽,偶尔翻车,但能给你惊喜。一个好的推荐系统应该同时是这两个朋友。

他们的解法:联合训练,两边的分数在 sigmoid 之前直接相加。

架构

Wide & Deep 架构:左侧 Wide 线性模型输入稀疏特征和交叉特征,右侧 Deep MLP 输入稠密 Embedding,两者求和后过 sigmoid

Wide 部分:在原始稀疏特征和人手设计的交叉特征 $\phi(\mathbf{x})$ 上的线性层,比如 installed_app=Pandora AND impression_app=YouTube。输出 $\hat{y}^w = \mathbf{w}^\top [\mathbf{x}, \phi(\mathbf{x})] + b$。

Deep 部分:每个类别字段都过 Embedding,拼接后过 3 层 ReLU($256 \to 128 \to 64$)。输出 $\hat{y}^d$。

联合输出头:$\hat{y} = \sigma(\hat{y}^w + \hat{y}^d)$。两边都从同一个损失收到梯度,由优化器决定各自贡献多少。

论文用了一个刻意的拆分:Wide 端用带 L1 的 FTRL(稀疏、可解释、自动选特征),Deep 端用 AdaGrad(稠密、平滑)。这种"两套优化器"的设置很关键——一套吃到底会掉点。

实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import torch
import torch.nn as nn

class WideAndDeep(nn.Module):
    """Wide & Deep(Cheng 等,2016),二分类 CTR 预测。"""

    def __init__(
        self,
        wide_dim: int,                  # 稀疏 + 交叉特征向量的维度
        field_dims: list[int],          # Deep 端每个类别字段的词表大小
        embedding_dim: int = 32,
        deep_layers: tuple[int, ...] = (256, 128, 64),
        dropout: float = 0.0,
    ):
        super().__init__()
        # Wide 端:一个大的稀疏线性层。
        self.wide = nn.Linear(wide_dim, 1)

        # Deep 端:每字段 Embedding + MLP。
        self.embeddings = nn.ModuleList([nn.Embedding(v, embedding_dim) for v in field_dims])
        in_dim = len(field_dims) * embedding_dim
        layers = []
        for out_dim in deep_layers:
            layers += [nn.Linear(in_dim, out_dim), nn.ReLU()]
            if dropout > 0:
                layers.append(nn.Dropout(dropout))
            in_dim = out_dim
        layers.append(nn.Linear(in_dim, 1))
        self.deep = nn.Sequential(*layers)

    def forward(self, x_wide: torch.Tensor, x_deep: torch.LongTensor) -> torch.Tensor:
        wide_score = self.wide(x_wide).squeeze(-1)
        deep_in = torch.cat([emb(x_deep[:, i]) for i, emb in enumerate(self.embeddings)], dim=-1)
        deep_score = self.deep(deep_in).squeeze(-1)
        return torch.sigmoid(wide_score + deep_score)


# 双优化器训练——很多教程会漏掉这个生产级细节。
model = WideAndDeep(wide_dim=10_000, field_dims=[50_000, 10_000, 100, 20])
opt_wide = torch.optim.Adagrad(model.wide.parameters(), lr=0.05)
opt_deep = torch.optim.Adam(
    [p for n, p in model.named_parameters() if not n.startswith("wide.")],
    lr=1e-3, weight_decay=1e-5,
)

实践里你也可以一把 Adam 训完,模型也能跑出来——但 Wide+Deep 的联合损失才是模型真正叫 Wide & Deep 的原因。它不是把两个独立训完的模型 ensemble 在一起。 Wide 和 Deep 的参数能看到彼此的梯度,这种交互正是要害。

直系后辈

Wide & Deep 衍生出了一整支族系,把人手交叉特征自动化掉:

模型“Wide"被替换为年份
DeepFMFM 层,自动学二阶交叉2017
DCNCross Network,每多一阶只多 $O(d)$ 参数2017
xDeepFMCIN(Compressed Interaction Network),显式高阶交叉2018
AutoInt在特征 Embedding 上做自注意力2019

本系列第四篇 会一一讲它们。


七、训练纪律:决定上面这些到底好不好用

架构对只是必要条件,远不是充分条件。下面这些细节经常比"换模型"更能动 AUC。

训练损失和验证 AUC 曲线:MF、NCF、Wide & Deep 在 50 个 epoch 上的对比,深度模型收敛到更低损失、更高 AUC,配合 early stopping

负采样

对隐式反馈,每个用户都有上千"负样本”(没看到过的物品)。三种采样策略,按精细程度排序:

  • 均匀随机。默认,意外地难被打败,召回阶段尤其稳。
  • 按热度加权。热门物品被采到的概率更大——用户略过一个爆款,是很强的负信号。
  • batch 内 / 困难负样本。把同一个 batch 里别人的正样本当自己的负样本;或者周期性从当前模型里捞高分负样本。提升判别力,但温度要调好。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import numpy as np

def sample_negatives(user_history: set[int], catalog_size: int, k: int = 4) -> list[int]:
    """均匀随机采样负样本,命中用户历史则拒绝。"""
    out = []
    while len(out) < k:
        cand = int(np.random.randint(catalog_size))
        if cand not in user_history:
            out.append(cand)
    return out

优化器、调度、正则

90% 以上的推荐模型都能用的合理默认:

1
2
3
4
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode="min", factor=0.5, patience=3
)
  • Dropout 0.2 – 0.3,加在 MLP 塔里。再大就掉排序质量。
  • 梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)):看到 loss 跳就开。
  • Early stopping:监控验证 AUC,patience 5–10 个 epoch。

一个完整的训练循环

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def train(model, train_loader, val_loader, *, epochs=50, patience=8, device="cuda"):
    model = model.to(device)
    bce = nn.BCELoss()
    opt = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)
    sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, mode="min", patience=3, factor=0.5)

    best_val, bad = float("inf"), 0
    for epoch in range(1, epochs + 1):
        model.train()
        train_loss = 0.0
        for users, items, labels in train_loader:
            users, items, labels = users.to(device), items.to(device), labels.to(device)
            opt.zero_grad()
            preds = model(users, items)
            loss = bce(preds, labels)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
            opt.step()
            train_loss += loss.item() * len(labels)
        train_loss /= len(train_loader.dataset)

        model.eval()
        val_loss = 0.0
        with torch.no_grad():
            for users, items, labels in val_loader:
                users, items, labels = users.to(device), items.to(device), labels.to(device)
                val_loss += bce(model(users, items), labels).item() * len(labels)
        val_loss /= len(val_loader.dataset)
        sched.step(val_loss)

        if val_loss < best_val - 1e-4:
            best_val, bad = val_loss, 0
            torch.save(model.state_dict(), "best.pt")
        else:
            bad += 1
            if bad >= patience:
                print(f"early stop at epoch {epoch}; best val_loss={best_val:.4f}")
                break
        print(f"epoch {epoch:>3}  train={train_loss:.4f}  val={val_loss:.4f}")

指标要对得上任务

场景主指标它真正度量的是
CTR 预测AUC模型能不能把正样本排在负样本前面
CTR 预测LogLoss预测概率是否校准
Top-K 召回Recall@KHitRate@K正确物品有没有出现在前 K 个里
Top-K 排序NDCG@K正确物品排得有多靠前
评分预测RMSE预测分数的平方误差均值

如果你用 RMSE 衡量 CTR、用 AUC 衡量评分预测,那就拧错了优化的旋钮。一开始就选定,写下来,永远别偷偷换。


八、常见问题,认真回答

Q:Embedding 维度该多大? 1M 以下物品规模从 32 起步,再大从 64 起步。翻倍直到验证 AUC 提升不到 ~0.5%。$d$ 超过 256 几乎都会过拟合,除非你有上百亿次交互。

Q:是不是永远应该用 NeuMF 取代 MF? 不是。在 ~100 万次交互以下,调好正则的 MF 经常打过 NeuMF——深度模型在那个数据量下会过拟合。NeuMF 真正稳定胜出,是在 ~1000 万次交互、且有丰富 side feature 之上。

Q:Wide & Deep 能不能只要 Deep,不要 Wide? 可以,很多人就是这么做的(DeepFM、DCN)。Wide 端给纯 Deep 模型不能给的东西,是对高基数共现的精确记忆——“装了 X 应用的用户也会装 Y 应用”。如果你的业务依赖这种具体共现被精确捕捉,留着 Wide 端。否则用自动化的交叉网络(DeepFM、DCN)通常更划算。

Q:YouTube DNN 在我只有几百万用户的场景里要不要用? 两阶段在 ~10 万物品以下属于杀鸡用牛刀。先用一个 ranker 对全目录打分。当全目录打分跟不上你的延迟预算时,再上两阶段。

Q:全新物品(没任何交互)怎么处理? 按效果递减排:

  1. 基于内容初始化——用预训练编码器(BERT、CLIP)从物品文本/图像算出 Embedding。
  2. 类目均值初始化——取同类目物品 Embedding 的平均。
  3. 多臂老虎机探索——把新物品给一小部分流量,攒初始信号。

Q:怎么防止深度推荐模型过拟合? 分层防御:Embedding 上加 $10^{-5}$ 的 weight decay,MLP 里 0.2–0.3 的 dropout,验证 AUC 上的 early stopping,实在不行减小 $d$。只在验证指标真的能跟着动的时候才加复杂度。

Q:怎么加速训练? 80/20 列表:先上 GPU(10–100 倍),再加大 batch(吃满 GPU),再混合精度(torch.cuda.amp,~2 倍),最后 DataLoader(num_workers > 0)。所有特征离线预计算,永远不要在训练循环里做 join。


九、走到这里我们看到了什么

深度学习没有发明推荐系统,它改了三个约束:

  • 表征是学出来的,不是设计出来的。Embedding 取代 one-hot,梯度发现你写不出来的结构。
  • 交互是任意阶的,不再止步于二阶。MLP 拟合数据所支撑的任何形式。
  • 流水线是端到端的,不再是几段拼起来。损失从预测一路流回原始 ID。

NeuMF 证明了"学出来的交互"打得过"写死的内积”。YouTube DNN 展示了如何在十亿物品的尺度上把召回和排序拆开做。Wide & Deep 告诉我们记忆和泛化最好联合学,而不是分别学完再 ensemble。

本系列第四篇 接着往下走:CTR 预估模型如何把 Wide & Deep 还在依赖的人手交叉自动化掉,包括 DeepFM、DCN、xDeepFM、AutoInt、FiBiNet。


系列导航

本文是推荐系统系列的第 3 篇,共 16 篇。

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub