1. 多任务学习:从“单打独斗”到“团队协作”的进化

如果你做过推荐系统或者广告点击率预估,肯定遇到过这样的场景:一个用户刷短视频,平台不仅想猜他会不会点赞,还想猜他会不会转发、会不会关注作者,甚至会不会看完整个视频。以前的做法很直接,每个目标单独训练一个模型,点赞一个模型,转发一个模型,关注又是一个模型。我刚开始做的时候也这么干过,觉得逻辑清晰,各司其职。但很快问题就来了,模型越堆越多,线上推理的时候,为了给一个用户做一次完整的预测,得把这好几个模型都跑一遍,服务器压力大,响应速度也慢。更头疼的是,维护成本直线上升,今天改个特征,所有模型都得重新训练和上线,简直是运维的噩梦。

这其实就是多任务学习(Multi-Task Learning, MTL) 要解决的核心问题。它的想法很聪明:我们能不能只训练一个模型,让它同时学会干好几件事?就像培养一个“多面手”员工,既能写代码又能做设计,还能跟客户沟通。这样做的好处显而易见:模型数量锐减,计算和运维成本大幅降低。但更关键的一个隐藏好处,是我在实际项目中感触最深的:数据共享带来的“知识迁移”

想象一下,预测“点赞”这个任务,正样本(用户点了赞)可能相对丰富。但预测“关注作者”这个任务,正样本可能就少得可怜,因为用户不会轻易关注。如果单独训练“关注”模型,它很容易学偏或者过拟合。但在多任务学习的框架下,模型在从海量的“点赞”、“转发”数据中学习用户泛化兴趣的同时,这些学到的“知识”也能潜移默化地帮助它理解更稀疏的“关注”行为。这就好比一个学生,同时学习数学和物理,数学里的逻辑思维能帮助他更好地理解物理公式。这种隐式的正则化效果,对于处理现实世界中普遍存在的数据稀疏长尾分布问题,简直是雪中送炭。

当然,理想很丰满,现实一开始却很骨感。最直接的想法,就是让所有任务在模型的底层共享同一个网络,上面再各自接一个任务专属的小网络。这个结构叫做 Shared-Bottom(共享底层)模型。它结构简单,是MTL的“Hello World”。但我和很多同行一样,在真实业务数据上一试,经常发现效果还不如每个任务单独建模呢!问题出在哪?就出在这个“硬共享”的底层网络上。

2. 共享底层的困境与MMoE的破局

Shared-Bottom模型要求所有任务在最初阶段就共用同一套参数。这就像让一个团队所有人共用同一套思维模式去解决不同问题。如果任务之间高度相关,比如预测“点击”和预测“购买”,那没问题,目标一致,劲儿往一处使。但如果任务之间差异很大,甚至存在冲突呢?比如在内容推荐里,预测“点击”(用户可能因为标题党而点击)和预测“完播率”(用户是否真的喜欢并看完了内容),这两个目标的优化方向可能就不完全一致。

这时候,共享的底层参数就会陷入“精神分裂”。它更新时收到的梯度信号来自所有任务,这些梯度可能方向相反、互相打架。最终的结果是,参数在“撕扯”中缓慢更新,或者干脆偏向于样本更多、梯度更强的那个任务,导致其他任务的效果被牺牲。这就是所谓的任务冲突(Task Conflict)跷跷板现象(Seesaw Phenomenon):一个任务指标上去了,另一个可能就下来了。

2018年谷歌提出的 MMoE(Multi-gate Mixture-of-Experts) 模型,就是为了解决这个痛点。它的设计思想非常巧妙,可以概括为“共享专家池,任务自选套餐”。

2.1 MMoE的核心思想:从“大锅饭”到“自助餐”

MMoE不再让所有任务粗暴地共享一个“大锅饭”式的底层网络。它引入了两个关键概念:

  1. 专家(Expert):你可以把它想象成一个个身怀绝技的“专业顾问”。模型底层有多个这样的专家网络(比如8个),每个专家都是一个独立的全连接层。所有任务都可以访问这个共享的专家池
  2. 门控网络(Gate):这是MMoE的灵魂。每个任务都拥有自己专属的门控网络。这个门控网络就像一个“智能配餐师”,它的职责是根据当前输入的样本特征,计算出一个权重分布。这个分布决定了该任务应该从每个“专家”那里听取多少意见。

具体是怎么运作的呢?我结合代码来拆解一下,这样更直观。假设我们有任务A(点赞)和任务B(关注),共享3个专家(E1, E2, E3)。

import torch
import torch.nn as nn

class Expert(nn.Module):
    """定义一个专家网络,就是一个简单的MLP"""
    def __init__(self, input_dim, expert_dim):
        super().__init__()
        self.fc = nn.Linear(input_dim, expert_dim)
        self.relu = nn.ReLU()

    def forward(self, x):
        return self.relu(self.fc(x))

class MMoE(nn.Module):
    def __init__(self, input_dim, num_experts, expert_dim, num_tasks, tower_dims):
        super().__init__()
        self.num_experts = num_experts
        self.num_tasks = num_tasks

        # 创建共享专家池
        self.experts = nn.ModuleList([Expert(input_dim, expert_dim) for _ in range(num_experts)])

        # 为每个任务创建专属的门控网络
        self.gates = nn.ModuleList([nn.Linear(input_dim, num_experts) for _ in range(num_tasks)])

        # 每个任务自己的塔式网络(Tower)
        self.towers = nn.ModuleList([nn.Sequential(
            nn.Linear(expert_dim, tower_dims[i]),
            nn.ReLU(),
            nn.Linear(tower_dims[i], 1),
            nn.Sigmoid() # 假设是二分类输出
        ) for i in range(num_tasks)])

    def forward(self, x):
        # 1. 所有专家处理输入
        expert_outputs = torch.stack([e(x) for e in self.experts], dim=1) # [batch_size, num_experts, expert_dim]

        final_outputs = []
        for i in range(self.num_tasks):
            # 2. 任务i的门控网络计算权重
            gate_weights = torch.softmax(self.gates[i](x), dim=-1) # [batch_size, num_experts]

            # 3. 加权融合专家输出
            task_representation = torch.bmm(gate_weights.unsqueeze(1), expert_outputs).squeeze(1) # [batch_size, expert_dim]

            # 4. 送入任务专属的塔网络得到最终预测
            task_output = self.towers[i](task_representation)
            final_outputs.append(task_output)

        return final_outputs # 返回一个列表,包含每个任务的预测值

看上面的代码和流程,MMoE的精髓就清晰了:

  • 对于喜欢娱乐内容的用户,任务A(点赞)的门控可能给“娱乐内容专家”很高的权重(比如0.8),给“知识类专家”很低的权重(比如0.1)。
  • 对于同一个用户,任务B(关注)的门控可能给出完全不同的权重分配,因为它要判断的是长期兴趣,可能会更依赖“用户长期兴趣专家”。

这样一来,MMoE通过门控机制,动态地、灵活地为不同任务组合不同的专家知识,而不是强迫所有任务使用同一套底层表示。这极大地缓解了任务冲突。论文里的实验也证实,即使任务相关性变弱,MMoE的性能也比Shared-Bottom稳定得多。

2.2 MMoE的局限性与新的挑战

MMoE确实是个巨大的进步,我们在很多业务场景上线后都取得了不错的效果。但用久了,尤其是在任务数量多、差异大的复杂推荐系统里,那个恼人的“跷跷板”现象并没有完全消失,只是被减弱了。有时候为了提升核心任务的指标(比如GMV),调整模型,会发现一些次要任务(比如互动率)的指标会有轻微下滑。

问题的根源在于,MMoE的专家池仍然是所有任务完全共享的。门控机制虽然能让任务选择专家,但专家本身在训练时,会接收到来自所有任务的梯度进行更新。这就像一群顾问(专家)虽然被不同部门(任务)征询,但他们为了服务所有部门,最终会形成一种“综合性的、折中的”知识体系。当两个任务的目标存在根本性冲突时,共享专家依然会被“拉扯”,无法完全专精于某个特定任务的需求。专家们变得“全能”但也“平庸”,难以在某个极端但重要的方向上做到极致。

3. PLE:化“共享”为“专享”与“共享”的有机结合

面对MMoE的遗留问题,腾讯在2020年提出了更进一步的解决方案——PLE(Progressive Layered Extraction,渐进分层提取)。PLE的哲学不再是简单的“共享”或“选择”,而是**“分离与融合”**。它的核心思想是:明确区分哪些知识是任务间共享的,哪些知识是任务独有的,并设计一个渐进式的网络结构来分层提取和融合这些知识。

3.1 CGC:PLE的基石——定制化门控

在理解多层的PLE之前,必须先搞懂它的单层版本:CGC(Customized Gate Control,定制化门控)。CGC的结构是理解PLE的关键一跳。

CGC在MMoE的基础上做了一个看似微小但至关重要的改动:它为每个任务引入了专属的专家(Task-Specific Experts),并与共享专家(Shared Experts)并列存在。

组件MMoECGC (PLE的单层)
专家类型只有共享专家(所有任务共用)共享专家 + 每个任务的专属专家
门控输入所有共享专家共享专家 + 该任务自己的专属专家
信息融合对象任务门控只对共享专家加权任务门控对共享专家+自身专属专家一起加权

举个例子,假设有任务A和任务B,共享专家有3个(S1, S2, S3),CGC会为任务A创建2个专属专家(A1, A2),为任务B创建2个专属专家(B1, B2)。

  • 对于任务A,它的门控网络看到的候选专家是 [S1, S2, S3, A1, A2] 这5个。门控网络会学习为这5个专家分配权重。
  • 对于任务B,它的门控网络看到的候选专家是 [S1, S2, S3, B1, B2] 这5个。

这样做的好处是革命性的:

  1. 缓解梯度冲突:任务A的专属专家(A1, A2)只接收来自任务A的梯度进行更新,可以心无旁骛地学习任务A独有的、最精细的特征模式,不会被任务B干扰。共享专家则继续学习任务间的共性知识。
  2. 提供更丰富的选择:任务的门控现在可以从“共性知识库”(共享专家)和“个性知识库”(专属专家)中自由挑选和组合,表达能力更强。
  3. 结构清晰,可解释性增强:我们可以通过分析门控权重,直观地看到某个任务更依赖于共享知识还是自有知识。这在业务分析中非常有用。

我在一个视频推荐项目里将MMoE替换为CGC后,点赞率和关注率之间的“跷跷板”效应明显减弱,而且关注率这个原本稀疏的任务指标提升更为显著,这说明专属专家确实捕捉到了该任务特有的信号。

3.2 PLE的多层精华:渐进式分层提取

CGC已经很强了,但PLE认为一层结构还不够。它提出了一个更宏观的架构:将多个CGC模块堆叠起来,形成一种渐进式的分层提取网络。你可以把它想象成一个多阶段的决策流水线。

第一层Extraction Network(提取网络)就是刚才介绍的CGC。它接收原始的输入特征,输出每个任务融合了共享和专属知识的初步表示。

关键来了:第二层及以后的Extraction Network,它们的输入不再是原始特征,而是前一层的输出。更具体地说:

  • 对于任务专属路径:任务A在第二层的输入,是任务A在第一层的输出(即经过第一层门控加权融合后的表示)。
  • 对于共享路径:第二层共享专家的输入,是所有任务在第一层的输出经过一个共享门控融合后的结果。这个共享门控学习如何聚合所有任务的信息,形成更深层的共性知识。

这个过程层层递进。在每一层,任务都能接触到两种信息流:

  1. 来自上一层的、经过提炼的自身专属信息
  2. 来自上一层的、融合了所有任务信息的深层共享信息

然后通过本层的任务专属门控,再次将这两种信息进行加权融合,输入到本层的任务专属塔网络,产出更精炼的表示,传递给下一层。

这种结构的好处是实现了知识的逐层提炼与分离

  • 在浅层,网络可能学习一些基础的、通用的特征交互(比如用户基础属性与物品类别的交叉)。
  • 在深层,共享路径可以学习到非常抽象和高级的跨任务模式(比如“用户沉浸度模式”),而专属路径则可以学习到针对特定任务的、极其专精的高阶模式(比如“引发关注行为的独特内容模式”)。

3.3 如何实现PLE?代码拆解与核心细节

纸上得来终觉浅,我们直接看一个简化版的PLE核心层实现,来加深理解。这里我们实现一个两层的PLE。

class ExtractionNetwork(nn.Module):
    """单层提取网络,即CGC结构"""
    def __init__(self, input_dim, num_shared_experts, num_specific_experts, expert_dim, num_tasks, is_last=False):
        super().__init__()
        self.num_tasks = num_tasks
        self.is_last = is_last # 是否是最后一层

        # 创建共享专家
        self.shared_experts = nn.ModuleList([Expert(input_dim, expert_dim) for _ in range(num_shared_experts)])
        # 为每个任务创建专属专家
        self.specific_experts = nn.ModuleList([
            nn.ModuleList([Expert(input_dim, expert_dim) for _ in range(num_specific_experts)])
            for _ in range(num_tasks)
        ])

        # 每个任务的门控(输入:该任务上一层的输出)
        self.task_gates = nn.ModuleList([nn.Linear(input_dim, num_shared_experts + num_specific_experts) for _ in range(num_tasks)])

        # 如果不是最后一层,还需要一个共享门控,用于融合所有任务输出,供下一层共享专家使用
        if not self.is_last:
            self.shared_gate = nn.Linear(input_dim, num_tasks) # 输入是融合了所有任务信息的向量,这里简化处理

    def forward(self, task_inputs, shared_input=None):
        """
        task_inputs: 列表,每个元素是上一个提取网络对应任务的输出 [task1_out, task2_out, ...]
        shared_input: 上一层的共享融合输出(第一层为None)
        """
        batch_size = task_inputs[0].shape[0]
        # 本层的输入,对于任务路径是各自的task_inputs,对于共享路径是shared_input(第一层用原始输入)
        if shared_input is None:
            shared_input = task_inputs[0] # 第一层简化处理,实际论文中共享路径也有独立输入

        # 1. 计算所有专家输出
        shared_expert_outs = torch.stack([e(shared_input) for e in self.shared_experts], dim=1) # [batch, num_shared_exp, expert_dim]

        specific_expert_outs_list = []
        for i in range(self.num_tasks):
            # 每个任务的专属专家处理该任务自己的输入
            exp_outs = torch.stack([e(task_inputs[i]) for e in self.specific_experts[i]], dim=1) # [batch, num_spec_exp, expert_dim]
            specific_expert_outs_list.append(exp_outs)

        # 2. 为每个任务进行门控融合
        new_task_outputs = []
        for i in range(self.num_tasks):
            # 拼接该任务可用的所有专家输出:[共享专家们, 该任务的专属专家们]
            available_experts = torch.cat([shared_expert_outs, specific_expert_outs_list[i]], dim=1) # [batch, total_exp, expert_dim]
            total_experts = available_experts.shape[1]

            # 计算该任务的门控权重
            gate_scores = self.task_gates[i](task_inputs[i]) # [batch, total_exp]
            gate_weights = torch.softmax(gate_scores, dim=-1)

            # 加权融合
            fused_rep = torch.bmm(gate_weights.unsqueeze(1), available_experts).squeeze(1) # [batch, expert_dim]
            new_task_outputs.append(fused_rep)

        # 3. 如果不是最后一层,计算共享融合输出,用于下一层的共享路径
        new_shared_output = None
        if not self.is_last:
            # 这里简化:将本层所有任务的新输出拼接后过一个线性层作为共享门控的输入,再融合
            all_task_repr = torch.stack(new_task_outputs, dim=1) # [batch, num_tasks, expert_dim]
            # 实际论文中有更复杂的共享门控设计,这里仅为示意
            gate_for_shared = torch.softmax(self.shared_gate(shared_input), dim=-1) # [batch, num_tasks]
            new_shared_output = torch.bmm(gate_for_shared.unsqueeze(1), all_task_repr).squeeze(1) # [batch, expert_dim]

        return new_task_outputs, new_shared_output

class PLE(nn.Module):
    """两层的PLE模型"""
    def __init__(self, input_dim, num_tasks, expert_dim=64, tower_dims=[32, 32]):
        super().__init__()
        self.num_tasks = num_tasks

        # 第一层提取网络
        self.extraction_net1 = ExtractionNetwork(
            input_dim=input_dim,
            num_shared_experts=3,
            num_specific_experts=2,
            expert_dim=expert_dim,
            num_tasks=num_tasks,
            is_last=False
        )

        # 第二层提取网络(最后一层)
        self.extraction_net2 = ExtractionNetwork(
            input_dim=expert_dim, # 输入维度是专家输出的维度
            num_shared_experts=2,
            num_specific_experts=2,
            expert_dim=expert_dim,
            num_tasks=num_tasks,
            is_last=True
        )

        # 每个任务最终的塔网络
        self.towers = nn.ModuleList([
            nn.Sequential(
                nn.Linear(expert_dim, tower_dims[i]),
                nn.ReLU(),
                nn.Linear(tower_dims[i], 1),
                nn.Sigmoid()
            ) for i in range(num_tasks)
        ])

    def forward(self, x):
        # 第一层:原始输入x作为每个任务和共享路径的初始输入(此处简化,实际任务路径初始输入也是x)
        task_inputs_init = [x for _ in range(self.num_tasks)]
        task_outs_l1, shared_out_l1 = self.extraction_net1(task_inputs_init, shared_input=x)

        # 第二层:输入是第一层的输出
        task_outs_l2, _ = self.extraction_net2(task_outs_l1, shared_input=shared_out_l1)

        # 将第二层的输出送入各自的塔网络得到最终预测
        final_outputs = []
        for i in range(self.num_tasks):
            final_outputs.append(self.towers[i](task_outs_l2[i]))

        return final_outputs

这段代码清晰地展示了PLE的两阶段流动:第一层从原始特征中初步提取共享和专属信息;第二层基于第一层提炼出的、更高级的表示,进行更深层次的信息提取与融合。通过这种渐进式的结构,PLE能够更有效地区分和利用共享知识与任务特定知识。

4. 实战:从MMoE到PLE的调优心得与避坑指南

理论模型再优美,最终还是要落地到业务中看效果。根据我这些年从MMoE切换到PLE,以及在多个场景中应用MTL模型的经验,有几个实战中的关键点和坑值得分享。

4.1 如何选择与设计专家网络?

专家网络是MoE系列模型的基石。它的设计没有绝对标准,但有几个原则:

  • 专家数量:通常是一个需要调优的超参数。一般从3-8个开始尝试。太少可能表达能力不足,太多则增加计算负担且容易过拟合。我的经验是,对于任务相关性较高的场景,可以少用一些专家(如3-4个);对于任务差异大、冲突明显的场景,可以适当增加(如6-8个),给模型更多的组合自由度。
  • 专家维度:专家网络中间层的维度。通常与输入维度或任务塔网络维度相关。可以设置为输入维度的一半或与塔网络维度一致。专家维度不宜过小,否则可能成为信息瓶颈。
  • 专家激活函数:最常用的是ReLU。在一些更复杂的场景中,可以尝试Swish等平滑激活函数,可能带来微弱提升,但ReLU因其简单高效,仍然是首选。
  • 一个容易被忽略的细节专家网络的初始化。由于门控机制的存在,如果专家初始化不好,可能导致训练初期某些专家永远不被激活(“专家死亡”问题)。建议使用像Kaiming Normal这类能保持方差稳定的初始化方法。

4.2 门控网络:简单但至关重要

门控网络通常设计得很简单,就是一个线性层加Softmax。但这里有几个实践技巧:

  1. 温度系数(Temperature):在Softmax中引入温度系数T,即 softmax(logits / T)。当T>1时,权重分布更平滑,鼓励探索更多专家;当T<1时,分布更尖锐,鼓励聚焦少数专家。训练初期可以使用较大的T(如1.0以上),让模型充分探索;训练后期或推理时,可以使用较小的T(如1.0或0.5),使决策更明确。这类似于知识蒸馏中的技巧。
  2. 门控网络的输入:MMoE和PLE中,门控网络的输入都是原始特征或上一层的输出。确保输入特征包含足够的信息来做出“选择专家”的决策至关重要。如果特征工程不到位,门控可能学不到有效的路由策略。
  3. 稀疏门控与负载均衡:这是工业级MoE模型必须考虑的问题。当专家数量成百上千时,我们期望每个样本只激活少数几个专家(稀疏性)以提升效率,同时希望所有专家的负载大致均衡,避免某些专家过载而某些闲置。这通常需要额外的辅助损失(如负载均衡损失)和复杂的路由算法。不过在MMoE/PLE这种专家数较少(<10)的场景下,这个问题不突出,使用简单的Softmax门控即可。

4.3 损失函数设计与调优:平衡的艺术

多任务学习的损失函数是模型训练的指挥棒。最常见的是加权求和总损失 = w1 * Loss_task1 + w2 * Loss_task2 + ... 如何设置权重 w1, w2, ... 是个大学问。

  • 等权重的陷阱:直接设为1:1通常不是最优解。因为不同任务的损失值尺度可能不同(如AUC Loss vs. MSE Loss),而且任务的重要性也不同。
  • 动态权重策略
    • 基于不确定性加权:让模型自己学习每个任务的权重,权重与任务预测的不确定性相关。不确定性大的任务,权重自动降低。这在论文《Multi-Task Learning Using Uncertainty to Weigh Losses》中有介绍,实现起来也不复杂,效果通常比固定权重好。
    • 基于梯度幅度:如GradNorm等方法,动态调整权重,使得不同任务以相近的速度学习。
    • 业务目标导向:最实用的方法。如果业务上任务A(如点击率)比任务B(如点赞率)重要10倍,那么可以尝试将A的损失权重设为B的10倍。然后围绕核心业务指标进行微调。
  • 我的常用策略:先使用不确定性加权或等权重进行初步训练,得到一个基准模型。然后根据业务核心指标(如线上A/B测试的总收益),手动微调损失权重。这是一个迭代的过程。

4.4 训练技巧与工程化考量

  1. 数据与样本权重:并非所有样本对所有任务都有效。例如,有点击但未购买的用户样本,对于购买任务来说是负样本,但可能质量不高。可以考虑给样本分配不同的权重,或者甚至采用任务专属的样本采样策略,但这会大大增加工程复杂度。初期建议从统一样本开始。
  2. 联合训练与交替训练:通常我们采用联合训练,即每个batch同时更新所有任务。但对于任务冲突极其严重的情况,可以尝试交替训练:一个epoch只训练任务A,下一个epoch只训练任务B,如此循环。这有时能缓解冲突,但需要更长的训练时间。
  3. 离线评估与在线A/B测试:离线评估时,要监控所有任务的指标(如AUC, LogLoss)。警惕“跷跷板”现象。但最终的金标准一定是在线A/B测试。多任务模型的优势在于整体系统效率和对稀疏任务的提升,这些需要在真实的线上流量和业务指标中验证。
  4. 部署性能:PLE相比MMoE结构更复杂,参数更多,推理耗时会有增加。在部署时,需要评估延迟和吞吐量的要求。可以通过模型剪枝、量化等技术对PLE模型进行优化。在实际应用中,如果CGC(单层PLE)已经能取得大部分收益,而线上延迟压力大,CGC可能是一个更好的折中选择。

从Shared-Bottom到MMoE,再到PLE,多任务学习模型的演进脉络非常清晰:从粗暴的硬共享,到通过门控进行软选择和共享,再到显式地分离共享与专属信息并分层提炼。每一次演进都是为了更好地解决任务间的冲突与关联这一核心矛盾。在实际工作中,我建议先从MMoE开始尝试,它通常能带来显著的基线提升。如果遇到明显的“跷跷板”效应或某些任务表现始终不佳,再考虑引入更复杂的PLE结构。记住,没有最好的模型,只有最适合当前业务场景和数据特性的模型。理解这些模型背后的设计思想,远比单纯调参更重要,它能帮助你在面对新的业务挑战时,设计出更合理的多任务学习方案。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐