系列 · 推荐系统 · 第 9 篇

推荐系统(九)—— 多任务学习与多目标优化

线上推荐从来不是优化一个数。本文从样本选择偏差、负迁移、梯度冲突这些真实痛点出发,把 Shared-Bottom、ESMM、MMoE、PLE 四种工业级架构讲透,配上完整 PyTorch 代码、损失平衡策略与可上线的训练流水线。

刷淘宝时,你看到的每个商品图片背后,模型其实同时在解决好几个问题:你会不会点击、点击后会不会加购物车、加购后会不会付款、付款后会不会退货、买了之后会不会给好评。这些问题对应不同的 任务,虽然数据分布、稀疏程度和业务意义各不相同,但它们之间紧密相关——愿意点击的人更可能下单,下单的人更可能复购,而高 CTR 的吸睛缩略图反而可能降低用户观看时长。

工业界的解决方案是 多任务学习(Multi-Task Learning,MTL)。与其为每个目标单独训练一个模型再拼接分数,不如用一个神经网络输出多个目标,让共享的主干网络同时学习服务所有任务的表示。难点在于如何让各任务头在共享参数上协同优化,避免相互干扰。

这篇文章是我的思维框架和可运行代码,带你了解工业界常用的四种架构:Shared-Bottom、ESMM、MMoE、PLE。我将解释为什么简单方案会失败(如负迁移、梯度冲突、样本选择偏差),以及不确定性加权、GradNorm 和 Pareto 折衷如何解决这些问题。


你将学到什么#

  • 排序为什么天生是多目标问题,忽略这一点会带来什么问题
  • CVR 预估中的 样本选择偏差 以及 ESMM 如何用链式法则解决它
  • 四种架构:Shared-Bottom、ESMM、MMoE、PLE,分别在什么情况下表现最佳
  • 损失平衡方法:不确定性加权、GradNorm、Pareto 前沿
  • 一段可以直接复用到项目中的 PyTorch 训练代码

前置知识#

  • PyTorch 基础(Module、前向传播、损失函数)
  • CTR 预估概念(第四篇
  • Embedding 层(第五篇

推荐系统为何天然是多目标#

单目标优化是错的题#

想象一个餐厅推荐系统。如果只优化 点击率,模型会偏向推荐夸张的美食图片和吸睛标题,导致转化率大幅下降;如果只优化 预订量,推荐结果将集中在稳妥的连锁店,缺乏多样性和惊喜感;如果只优化 评分,推荐的全是昂贵的高档餐厅,但没人真的会去。真正合理的目标应该是一组指标:

  • 用户会不会 点击?(参与度)
  • 他们会不会实际 到店?(转化率)
  • 到店后会不会 满意?(满意度)
  • 之后会不会 回头?(留存率)

这一模式在多个业务场景中普遍存在:

业务常见目标
电商CTR、CVR、单次曝光 GMV、退货率、评价质量
短视频CTR、播放时长、点赞/分享率、关注率
广告CTR、CVR、CPA、用户生命周期价值

这些指标 相关但不相同。MTL 模型的任务是利用它们的相关性(如 CTR 信号帮助 CVR Head 理解用户意图),同时避免某个目标完全压倒其他目标。

样本选择偏差:Naive CVR 为什么行不通#

ESMM 的核心动机来自一个细节问题。假设我要建一个 转化预估 模型:给定用户和商品,预测 $P(\text{购买} \mid \text{曝光})$

  • 训练数据只有点击样本——只有用户点击后才能观察到购买行为。
  • 线上需要对所有候选打分——包括用户从未点击的商品。

在“被点击的曝光”子集上训练,但在线上却要预测整个曝光集合。这就是典型的 样本选择偏差:训练分布和线上分布不一致。

$$P(\text{购买} \mid \text{曝光}) = P(\text{点击} \mid \text{曝光}) \cdot P(\text{购买} \mid \text{点击})$$

用人话说就是:“看到商品后买它的概率,等于点它的概率乘以点了之后买的概率。” 第一项(CTR)和乘积(CTCVR)都可以在 完整曝光空间 上观测。直接监督这两个目标,CVR 就能作为副产品自然学出来,不再依赖有偏差的切片数据。

MTL 的好处与代价#

好处

  • 数据效率高:稀疏任务(比如购买、关注)可以借助密集任务(比如曝光、点击)的信号。
  • 隐式正则化:跨任务共享参数天然防止过拟合。
  • 推理成本低:一次前向计算就能得到排序所需的所有分数。
  • 泛化能力强:联合训练更容易学到超越单一目标的通用表示。

代价

  • 负迁移:不同任务可能把共享参数拉向相反方向,导致每个任务的表现都不如单任务基线。
  • 损失平衡难:CTR 损失可能是 0.3,而营收 MSE 可能达到 100,直接相加会让某些任务主导整个优化。
  • 架构设计复杂:哪些层共享、哪些独立,几乎没有理论指导,全靠经验摸索。

下面四种架构本质上回答的是同一个问题:共享多少?共享在哪?


架构一:Shared-Bottom#

Shared-Bottom 架构示意:一条共享 MLP 主干分叉为多个任务塔

$$h = f_{\text{shared}}(x), \qquad \hat{y}_k = f_k(h), \quad k=1,\dots,K$$

所有任务共享 同一个 $h$ 。这是它唯一的设计假设,也是它最大的问题所在。

实现#

 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
53
import torch
import torch.nn as nn
import torch.nn.functional as F

class SharedBottomMTL(nn.Module):
    """Shared-Bottom:一根主干 + K 个任务塔。

    任务目标一致时效果不错;目标冲突时,主干被拉扯,导致每个任务都比单任务基线差。
    """

    def __init__(self, input_dim, shared_hidden_dims, task_hidden_dims,
                 num_tasks, task_types):
        super().__init__()
        self.num_tasks = num_tasks
        self.task_types = task_types

        # ---- 共享主干
        shared, prev = [], input_dim
        for h in shared_hidden_dims:
            shared += [nn.Linear(prev, h), nn.BatchNorm1d(h),
                       nn.ReLU(), nn.Dropout(0.2)]
            prev = h
        self.shared_bottom = nn.Sequential(*shared)

        # ---- 每个任务独立的塔
        self.task_towers = nn.ModuleList()
        for k in range(num_tasks):
            layers, prev = [], shared_hidden_dims[-1]
            for h in task_hidden_dims:
                layers += [nn.Linear(prev, h), nn.BatchNorm1d(h),
                           nn.ReLU(), nn.Dropout(0.1)]
                prev = h
            layers.append(nn.Linear(prev, 1))
            if task_types[k] == 'binary':
                layers.append(nn.Sigmoid())
            self.task_towers.append(nn.Sequential(*layers))

    def forward(self, x):
        h = self.shared_bottom(x)
        return [tower(h) for tower in self.task_towers]

# 三个任务:CTR(二分类)、CVR(二分类)、Revenue(回归)
model = SharedBottomMTL(
    input_dim=128,
    shared_hidden_dims=[256, 128, 64],
    task_hidden_dims=[32, 16],
    num_tasks=3,
    task_types=['binary', 'binary', 'regression'],
)

x = torch.randn(32, 128)
ctr, cvr, rev = model(x)
print(ctr.shape, cvr.shape, rev.shape)  # 三个都是 (32, 1)

为什么最终会失效#

举个例子:CTR 偏好“新颖、吸睛”的特征,CVR 偏好“稳定、可预期”的特征——两边对共享表示的需求完全相反。主干被迫妥协,结果两个任务的表现都比单任务模型差。这就是 负迁移,也是后续所有架构设计的核心动机。


架构二:ESMM(Entire Space Multi-Task Model)#

ESMM 是阿里巴巴为解决 CVR 样本选择偏差问题提出的一种方案。架构本身并不复杂,核心在于 监督信号的设计和位置

ESMM 架构:共享 Embedding 同时喂给 CTR 塔和 CVR 塔,CTCVR 是两者乘积,在全曝光空间上监督

核心思路#

不要直接在“点击样本”上训练 CVR。改为在 完整曝光空间 上训练两个目标:

  • pCTR = $P(\text{点击} \mid \text{曝光})$ ,用点击标签作为监督信号
  • pCTCVR = $P(\text{点击且购买} \mid \text{曝光})$ ,用 点击 AND 购买 作为监督信号

CVR 通过隐式约束 $\text{pCTCVR} = \text{pCTR} \times \text{pCVR}$ 被间接监督。反向传播会优化 CVR 塔的参数,但它从头到尾都不需要拟合有偏的标签。

实现代码#

 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
53
54
55
class ESMM(nn.Module):
    """Entire-Space Multi-Task Model。

    分解 P(购买|曝光) = P(点击|曝光) * P(购买|点击)。
    CTR 和 CTCVR 在完整曝光空间上训练;CVR 通过乘积间接监督,没有选择偏差。
    """

    def __init__(self, input_dim, hidden_dims):
        super().__init__()
        self.embedding = nn.Sequential(
            nn.Linear(input_dim, hidden_dims[0]),
            nn.ReLU(), nn.Dropout(0.2),
        )
        self.ctr_tower = self._tower(hidden_dims)
        self.cvr_tower = self._tower(hidden_dims)

    def _tower(self, dims):
        layers, prev = [], dims[0]
        for h in dims[1:]:
            layers += [nn.Linear(prev, h), nn.ReLU(), nn.Dropout(0.2)]
            prev = h
        layers += [nn.Linear(prev, 1), nn.Sigmoid()]
        return nn.Sequential(*layers)

    def forward(self, x):
        h = self.embedding(x)
        pctr = self.ctr_tower(h)            # P(点击 | 曝光)
        pcvr = self.cvr_tower(h)            # P(购买 | 点击)
        pctcvr = pctr * pcvr                # P(点击且购买 | 曝光)
        return pctr, pcvr, pctcvr

def esmm_loss(pctr, pcvr, pctcvr, click_label, buy_label):
    """ESMM 损失函数:

    - CTR 损失作用于所有曝光样本(每条曝光都有点击标签)。
    - CTCVR 损失作用于所有曝光样本("点击 AND 购买"对所有曝光都可观测)。
    - CVR 没有直接损失——梯度通过乘积反向传播到 CVR 塔。
    """
    ctcvr_label = click_label * buy_label  # 仅"点击且购买"为 1
    loss_ctr = F.binary_cross_entropy(pctr, click_label.float())
    loss_ctcvr = F.binary_cross_entropy(pctcvr, ctcvr_label.float())
    return loss_ctr + loss_ctcvr

# 简化的训练步骤
model = ESMM(input_dim=128, hidden_dims=[256, 128, 64])
opt = torch.optim.Adam(model.parameters(), lr=1e-3)

x = torch.randn(32, 128)
click = torch.randint(0, 2, (32, 1)).float()
buy = torch.randint(0, 2, (32, 1)).float()  # 仅在 click=1 时有意义

pctr, pcvr, pctcvr = model(x)
loss = esmm_loss(pctr, pcvr, pctcvr, click, buy)
loss.backward(); opt.step()
print(f"loss = {loss.item():.4f}")

为什么有效#

  • 无偏差切片:CTR 和 CTCVR 都基于完整曝光空间训练,CVR 不需要拟合“只在点击后出现”的标签。
  • 数学一致性:通过 $\text{pCTCVR} = \text{pCTR} \times \text{pCVR}$ 的构造,线上三个分数不会互相矛盾。
  • 部署成本低:模型结构与 Shared-Bottom 类似,只是多了两个二分类 Head,排序流水线几乎无需改动。

阿里原论文中,这一方法在淘宝将 CVR AUC 提升了 2–3 个百分点。对于一个简单的架构调整来说,效果非常显著。


架构三:MMoE(多门控混合专家)#

Shared-Bottom 把所有任务都挤进同一个瓶颈。MMoE 则维护一个专家子网络池,让每个任务自己学习如何软性选择专家组合。

MMoE 架构:一池专家 MLP,每个任务都有自己的门控网络

类比#

办一场家庭聚会,需要完成三件事:做饭、布置、放音乐。与其请一个全能选手(Shared-Bottom),不如请四个专家。每件事配一位经理(门控),由经理决定听谁的意见更多。做饭经理更依赖大厨和侍酒师,布置经理更依赖花艺师和灯光师。但大厨也可能对摆盘有想法——经理可以自由混搭。

数学#

$$f_k(x) = \sum_{i=1}^{n} g_k^{(i)}(x) \cdot E_i(x), \qquad g_k(x) = \mathrm{softmax}(W_k x)$$

每个门控是一个简单的“线性层 + Softmax”小网络。冲突的任务会学会指向不同的专家,而协作的任务会学会共享同一批专家。

实现#

 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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class Expert(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.1),
            nn.Linear(hidden_dim, output_dim), nn.ReLU(),
        )

    def forward(self, x):
        return self.net(x)

class MMoE(nn.Module):
    """Multi-gate Mixture-of-Experts。

    一池 n 个专家;K 个任务,每个任务有自己的门控来对专家做软加权混合。
    冲突任务可以自动路由到不同专家——由模型自己学习。
    """

    def __init__(self, input_dim, num_experts, expert_hidden_dim,
                 expert_output_dim, num_tasks, task_hidden_dims, task_types):
        super().__init__()
        self.num_tasks = num_tasks

        self.experts = nn.ModuleList([
            Expert(input_dim, expert_hidden_dim, expert_output_dim)
            for _ in range(num_experts)
        ])
        self.gates = nn.ModuleList([
            nn.Sequential(nn.Linear(input_dim, num_experts), nn.Softmax(dim=1))
            for _ in range(num_tasks)
        ])

        self.task_towers = nn.ModuleList()
        for k in range(num_tasks):
            layers, prev = [], expert_output_dim
            for h in task_hidden_dims:
                layers += [nn.Linear(prev, h), nn.ReLU(), nn.Dropout(0.1)]
                prev = h
            layers.append(nn.Linear(prev, 1))
            if task_types[k] == 'binary':
                layers.append(nn.Sigmoid())
            self.task_towers.append(nn.Sequential(*layers))

    def forward(self, x):
        # 把所有专家堆成一个张量:(B, n_experts, expert_dim)
        E = torch.stack([e(x) for e in self.experts], dim=1)

        outputs, gates_out = [], []
        for k in range(self.num_tasks):
            w = self.gates[k](x)               # (B, n_experts)
            gates_out.append(w)
            mix = (w.unsqueeze(2) * E).sum(1)  # (B, expert_dim)
            outputs.append(self.task_towers[k](mix))
        return outputs, gates_out

model = MMoE(
    input_dim=128, num_experts=4, expert_hidden_dim=64,
    expert_output_dim=32, num_tasks=3, task_hidden_dims=[16],
    task_types=['binary', 'binary', 'regression'],
)
x = torch.randn(32, 128)
outs, gates = model(x)

# 看路由:往往任务 1 和任务 3 偏好的专家子集就不一样
print("Task 1 gate weights:", gates[0][0].detach().round(decimals=2))
print("Task 3 gate weights:", gates[2][0].detach().round(decimals=2))

什么时候用 MMoE#

  • 怀疑某些任务相互冲突,但不确定具体是哪些。
  • 想要一种架构能同时优雅处理协作与对抗两种情况。
  • 门控权重本身就是有用的可观测指标——画出来就能看出任务实际共享了哪些专家。

架构四:PLE(渐进式分层提取)#

腾讯提出的 PLE 模型解决了 MMoE 的一个痛点:即使有门控机制,共享专家池仍然可能让一个任务的梯度破坏另一个任务依赖的特征。这就是著名的 跷跷板现象——CTR 提升多少,观看时长就下降多少。

PLE 架构:共享专家 + 任务特定专家,每个任务通过门控组合自己可见的专家

核心思路#

把共享和私有分开,明确职责。每一层包含两类专家:

  • 共享专家:所有任务都能访问,学习跨任务的通用模式。
  • 任务特定专家:只服务于单个任务,完全隔离其他任务的梯度。

每个任务的门控负责从共享专家和自己的任务特定专家中挑选特征。堆叠几层后,模型实现渐进提取:底层做通用特征提取,高层专注任务特化。

实现代码#

 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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class PLELayer(nn.Module):
    """单层 PLE:共享专家 + 任务特定专家 + 任务级门控。"""

    def __init__(self, input_dim, num_shared, num_per_task,
                 expert_hidden, expert_out, num_tasks):
        super().__init__()
        self.num_tasks = num_tasks
        self.shared_experts = nn.ModuleList([
            Expert(input_dim, expert_hidden, expert_out)
            for _ in range(num_shared)
        ])
        self.task_experts = nn.ModuleList([
            nn.ModuleList([
                Expert(input_dim, expert_hidden, expert_out)
                for _ in range(num_per_task)
            ])
            for _ in range(num_tasks)
        ])
        total = num_shared + num_per_task
        self.gates = nn.ModuleList([
            nn.Sequential(nn.Linear(input_dim, total), nn.Softmax(dim=1))
            for _ in range(num_tasks)
        ])

    def forward(self, x):
        shared = torch.stack([e(x) for e in self.shared_experts], dim=1)
        out = []
        for k in range(self.num_tasks):
            task_e = torch.stack([e(x) for e in self.task_experts[k]], dim=1)
            pool = torch.cat([shared, task_e], dim=1)
            w = self.gates[k](x).unsqueeze(2)
            out.append((w * pool).sum(1))
        return out

class PLE(nn.Module):
    """Progressive Layered Extraction:显式分离共享和任务特定专家。"""

    def __init__(self, input_dim, num_layers, num_shared, num_per_task,
                 expert_hidden, expert_out, num_tasks,
                 task_hidden_dims, task_types):
        super().__init__()
        self.num_tasks = num_tasks
        self.layers = nn.ModuleList()
        prev = input_dim
        for _ in range(num_layers):
            self.layers.append(PLELayer(
                prev, num_shared, num_per_task,
                expert_hidden, expert_out, num_tasks,
            ))
            prev = expert_out

        self.task_towers = nn.ModuleList()
        for k in range(num_tasks):
            layers, p = [], expert_out
            for h in task_hidden_dims:
                layers += [nn.Linear(p, h), nn.ReLU(), nn.Dropout(0.1)]
                p = h
            layers.append(nn.Linear(p, 1))
            if task_types[k] == 'binary':
                layers.append(nn.Sigmoid())
            self.task_towers.append(nn.Sequential(*layers))

    def forward(self, x):
        # 每个任务沿着层栈各自携带表示往上走
        reps = [x] * self.num_tasks
        for layer in self.layers:
            # 每个任务把自己的当前表示送进下一层
            reps = [layer(reps[k])[k] for k in range(self.num_tasks)]
        return [self.task_towers[k](reps[k]) for k in range(self.num_tasks)]

model = PLE(
    input_dim=128, num_layers=2,
    num_shared=2, num_per_task=2,
    expert_hidden=64, expert_out=32,
    num_tasks=3, task_hidden_dims=[16],
    task_types=['binary', 'binary', 'regression'],
)
out = model(torch.randn(32, 128))
print([o.shape for o in out])

为什么这种设计有效#

任务特定专家只接收来自它所属任务的梯度。当 CTR 的梯度试图往 CVR 不喜欢的方向调整时,影响被限制在共享专家里,CVR 的私有专家完全不受干扰。腾讯的视频推荐实验表明,PLE 在多个指标上比 MMoE 多提升了约 0.4% 的 AUC,并显著缓解了跷跷板效应。


如何选择架构#

场景推荐理由
任务目标一致,工程资源有限Shared-Bottom简单、快速、可靠的基线
CVR 预估,标签仅在点击样本中ESMM通过设计消除选择偏差
任务间可能相关也可能冲突MMoE门控机制自动学习最优路由
明确存在共享和冲突模式PLE强制分离避免负迁移

实际项目中的合理演进路径:先上线 Shared-Bottom;如果某个任务的 MTL 模型效果不如单任务基线,切换到 MMoE;如果发现跷跷板现象,再切换到 PLE。

损失平衡:别让一个任务吃掉其他任务#

CTR 损失大概在 0.3,CVR 在 0.05,营收的 MSE 则高达 100。如果直接相加,回归任务会完全压制其他任务。损失平衡不是锦上添花,而是决定模型到底是在学两个任务,还是只学了一个半任务的关键。

不确定性加权(Kendall et al., 2018)#

$$\mathcal{L}_{\text{total}} = \sum_k \frac{1}{2\sigma_k^2}\, \mathcal{L}_k + \log \sigma_k$$

难任务的 $\sigma_k$ 会自然增大,从而降低权重;$\log \sigma_k$ 项则防止 $\sigma_k$ 趋向无穷大。下图左侧展示了理想的结果:简单任务的归一化权重逐渐上升,而困难任务的权重逐渐下降。

不确定性加权与 GradNorm:训练过程中权重和梯度范数如何被自动调整

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class UncertaintyWeighting(nn.Module):
    """学习每个任务的 log-variance,自动降低难任务的权重。"""

    def __init__(self, num_tasks):
        super().__init__()
        self.log_var = nn.Parameter(torch.zeros(num_tasks))  # = 2 log sigma

    def forward(self, task_losses):
        total = 0.0
        for k, loss in enumerate(task_losses):
            precision = torch.exp(-self.log_var[k])  # 1 / sigma^2
            total += 0.5 * precision * loss + 0.5 * self.log_var[k]
        return total

GradNorm#

不确定性加权平衡的是 损失值,而 GradNorm 平衡的是共享主干上的 梯度范数。直观来说,哪个任务的梯度范数最大,它就在主导主干的学习方向。GradNorm 调整任务权重,让每个任务对主干的“拉力”大致相等,还可以选择偏向那些学习较慢的任务。上图右侧展示了未加 GradNorm 时各任务梯度范数逐渐分离,加入后被拉回到相近水平的过程。

Pareto 优化#

当任务之间存在真实冲突时,不存在所谓的“最优”加权方式,只有不同的权衡。将所有可能的 (CTR-AUC, CVR-AUC) 点画出来,外缘就是 Pareto 前沿:在这条线上,无法在不牺牲一个指标的情况下提升另一个指标。

CTR AUC 与 CVR AUC 之间的 Pareto 前沿,标出了三个典型工作点

实际生产中,这更多是一个产品决策问题:业务希望在这条曲线上选择哪个点?

为什么任务会打架:梯度冲突#

讲了这么多架构,问题的根源在哪?因为共享参数会被同时拉向两个方向。随便挑一个共享权重 $\theta$ :任务 A 要求“增大”,任务 B 却要求“减小”。联合更新是两个相反方向的叠加——结果往往是步长比任何一方期望的都短,方向也不符合任何一方的需求。

共享参数上两个任务的梯度可能方向冲突;训练过程中梯度余弦相似度甚至会变为负值

右侧这张图才是生产环境中真正需要监控的指标:跟踪共享主干上两个任务梯度的余弦相似度变化。如果这个值长期为负,说明模型正在经历梯度冲突。这时就应该考虑用 PLE(拆分专家网络)或者 PCGrad(在更新前投影掉冲突分量)。计算这个指标成本很低,但能提供的信息远比看总损失曲线有用得多。

工业参考点#

公司架构场景提升效果
阿里ESMM淘宝 CVR比有偏 CVR 基线提升 ~2–3% AUC
GoogleMMoEYouTube 排序多目标优化全面优于 Shared-Bottom
腾讯PLE腾讯视频比 MMoE 提升 ~0.4% AUC,缓解了跷跷板效应

数据来自原始论文,实际生产系统会进一步迭代优化。


一个完整的训练流水线#

把 MMoE 和不确定性加权结合起来,搭建一个可以直接运行的训练流程:

 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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
import numpy as np

class RecDataset(Dataset):
    def __init__(self, features, labels):
        self.x = torch.FloatTensor(features)
        self.y = {k: torch.FloatTensor(v) for k, v in labels.items()}
        self.tasks = list(labels.keys())

    def __len__(self):
        return len(self.x)

    def __getitem__(self, i):
        return {'x': self.x[i], **{t: self.y[t][i] for t in self.tasks}}

def train_epoch(model, loader, opt, balancer, tasks, device):
    model.train()
    running = {t: 0.0 for t in tasks}
    total = 0.0
    for batch in loader:
        x = batch['x'].to(device)
        y = {t: batch[t].to(device) for t in tasks}

        outs, _ = model(x)

        losses = []
        for k, t in enumerate(tasks):
            if t in ('ctr', 'cvr'):
                losses.append(F.binary_cross_entropy(outs[k].squeeze(), y[t]))
            else:
                losses.append(F.mse_loss(outs[k].squeeze(), y[t]))
            running[t] += losses[-1].item()

        loss = balancer(losses) if balancer else sum(losses)
        opt.zero_grad()
        loss.backward()
        opt.step()
        total += loss.item()

    n = len(loader)
    return total / n, {t: v / n for t, v in running.items()}

# ------ 开始训练
N = 10_000
features = np.random.randn(N, 128).astype(np.float32)
labels = {
    'ctr': np.random.randint(0, 2, N).astype(np.float32),
    'cvr': np.random.randint(0, 2, N).astype(np.float32),
    'revenue': (np.random.rand(N) * 100).astype(np.float32),
}
loader = DataLoader(RecDataset(features, labels), batch_size=64, shuffle=True)

model = MMoE(
    input_dim=128, num_experts=4, expert_hidden_dim=64,
    expert_output_dim=32, num_tasks=3, task_hidden_dims=[16],
    task_types=['binary', 'binary', 'regression'],
)
balancer = UncertaintyWeighting(num_tasks=3)
opt = optim.Adam(list(model.parameters()) + list(balancer.parameters()), lr=1e-3)

for epoch in range(5):
    loss, per_task = train_epoch(model, loader, opt, balancer,
                                 ['ctr', 'cvr', 'revenue'], 'cpu')
    print(f"epoch {epoch+1}: loss={loss:.4f} | "
          f"ctr={per_task['ctr']:.4f} | cvr={per_task['cvr']:.4f} | "
          f"rev={per_task['revenue']:.4f}")

常见问题#

什么时候 MTL 真的值得增加复杂度?#

当任务之间共享底层的用户/物品结构,同时至少有一个稀疏任务能从密集任务的信号中获益,并且我在意推理成本时,MTL 是值得的。如果这些条件都不满足——比如任务完全独立,或者推理预算充足——分开建模反而更简单,效果也往往更好。

MMoE / PLE 中应该设置多少个专家?#

经验法则:至少和任务数一样多。实际操作中,2–3 个任务用 2–4 个专家,更多任务用 4–8 个。专家太少会导致无法充分分化,太多则容易过拟合,门控权重也会集中到少数几个专家上。

标签缺失怎么处理?#

对损失进行掩码处理。每条样本只计算有标签的任务对应的损失。这是 CVR 类稀疏标签与 CTR 类密集标签共存时的标准做法。

MTL 对冷启动有帮助吗?#

有,但间接的。密集任务(点击)塑造的表示可以被稀疏任务(购买、关注)直接利用。新用户即使只有几次点击行为,也能得到合理的 CVR 预估;而单任务 CVR 模型在这种情况下基本无能为力。

如何调试 MTL 模型?#

按优先级排序:

  1. 把每个 Head 和它的单任务基线对比。如果某个 Head 的表现不如单任务模型,说明存在负迁移。
  2. 绘制门控权重(MMoE / PLE)。任务是否按照预期路由到了不同的专家?
  3. 监控共享主干上不同任务损失的梯度余弦相似度。如果长期为负,考虑使用 PLE 或 PCGrad。
  4. 做消融实验。去掉一个任务、去掉一个专家、去掉损失平衡,如果去掉某部分反而让效果变好,那这部分就是模型不需要的。

收尾#

Shared-Bottom → ESMM → MMoE → PLE 这条架构演进路线,核心在于 越来越直面任务之间互相冲突的事实。Shared-Bottom 假装任务之间没有冲突。ESMM 绕过了一个特定的偏差问题。MMoE 让模型自己决定哪些部分需要共享。PLE 则明确把共享和私有部分从结构上区分开来。选对架构,搭配合理的损失平衡策略(比如从 Uncertainty Weighting 开始),盯紧梯度余弦相似度,就能搭建出一个在生产环境中稳定运行的多任务学习系统。

本系列

推荐系统 16 篇

  1. 01 推荐系统(一)—— 入门与基础概念
  2. 02 推荐系统(二)—— 协同过滤与矩阵分解
  3. 03 推荐系统(三)—— 深度学习基础模型
  4. 04 推荐系统(四)—— CTR 预估与点击率建模
  5. 05 推荐系统(五)—— Embedding 表示学习
  6. 06 推荐系统(六)—— 序列推荐与会话建模
  7. 07 推荐系统(七)—— 图神经网络与社交推荐
  8. 08 推荐系统(八)—— 知识图谱增强推荐系统
  9. 09 推荐系统(九)—— 多任务学习与多目标优化 当前
  10. 10 推荐系统(十)—— 深度兴趣网络与注意力机制
  11. 11 推荐系统(十一)—— 对比学习与自监督学习
  12. 12 推荐系统(十二)—— 大语言模型与推荐系统
  13. 13 推荐系统(十三)—— 公平性、去偏与可解释性
  14. 14 推荐系统(十四)—— 跨域推荐与冷启动解决方案
  15. 15 推荐系统(十五)—— 实时推荐与在线学习
  16. 16 推荐系统(十六)—— 工业级架构与最佳实践

读有所得?

GitHub 关注我 → 新文周更

GitHub