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

$$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 样本选择偏差问题提出的一种方案。架构本身并不复杂,核心在于 监督信号的设计和位置。

核心思路#
不要直接在“点击样本”上训练 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 则维护一个专家子网络池,让每个任务自己学习如何软性选择专家组合。

办一场家庭聚会,需要完成三件事:做饭、布置、放音乐。与其请一个全能选手(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 提升多少,观看时长就下降多少。

核心思路#
把共享和私有分开,明确职责。每一层包含两类专家:
- 共享专家:所有任务都能访问,学习跨任务的通用模式。
- 任务特定专家:只服务于单个任务,完全隔离其他任务的梯度。
每个任务的门控负责从共享专家和自己的任务特定专家中挑选特征。堆叠几层后,模型实现渐进提取:底层做通用特征提取,高层专注任务特化。
实现代码#
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$
趋向无穷大。下图左侧展示了理想的结果:简单任务的归一化权重逐渐上升,而困难任务的权重逐渐下降。

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 前沿:在这条线上,无法在不牺牲一个指标的情况下提升另一个指标。

实际生产中,这更多是一个产品决策问题:业务希望在这条曲线上选择哪个点?
为什么任务会打架:梯度冲突#
讲了这么多架构,问题的根源在哪?因为共享参数会被同时拉向两个方向。随便挑一个共享权重 $\theta$
:任务 A 要求“增大”,任务 B 却要求“减小”。联合更新是两个相反方向的叠加——结果往往是步长比任何一方期望的都短,方向也不符合任何一方的需求。

右侧这张图才是生产环境中真正需要监控的指标:跟踪共享主干上两个任务梯度的余弦相似度变化。如果这个值长期为负,说明模型正在经历梯度冲突。这时就应该考虑用 PLE(拆分专家网络)或者 PCGrad(在更新前投影掉冲突分量)。计算这个指标成本很低,但能提供的信息远比看总损失曲线有用得多。
工业参考点#
| 公司 | 架构 | 场景 | 提升效果 |
|---|
| 阿里 | ESMM | 淘宝 CVR | 比有偏 CVR 基线提升 ~2–3% AUC |
| Google | MMoE | YouTube 排序 | 多目标优化全面优于 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 模型?#
按优先级排序:
- 把每个 Head 和它的单任务基线对比。如果某个 Head 的表现不如单任务模型,说明存在负迁移。
- 绘制门控权重(MMoE / PLE)。任务是否按照预期路由到了不同的专家?
- 监控共享主干上不同任务损失的梯度余弦相似度。如果长期为负,考虑使用 PLE 或 PCGrad。
- 做消融实验。去掉一个任务、去掉一个专家、去掉损失平衡,如果去掉某部分反而让效果变好,那这部分就是模型不需要的。
Shared-Bottom → ESMM → MMoE → PLE 这条架构演进路线,核心在于 越来越直面任务之间互相冲突的事实。Shared-Bottom 假装任务之间没有冲突。ESMM 绕过了一个特定的偏差问题。MMoE 让模型自己决定哪些部分需要共享。PLE 则明确把共享和私有部分从结构上区分开来。选对架构,搭配合理的损失平衡策略(比如从 Uncertainty Weighting 开始),盯紧梯度余弦相似度,就能搭建出一个在生产环境中稳定运行的多任务学习系统。