1. 多任务学习到底在解决什么工程问题
先说结论:多任务学习不是"把几个loss加起来一起训"这么简单,它本质上是一场参数预算与梯度方向的资源分配问题。我见过太多团队一上手就把三四个任务的loss直接相加,跑了两周发现主任务指标掉了两个点,然后得出"多任务学习没用"的结论。问题从来不在方法本身,而在于没有搞清楚任务之间到底在争抢什么。
1.1 从单任务堆叠到共享骨干的现实动机
大部分项目最初的形态都是单任务模型堆叠:一个分类模型、一个检测模型、一个特征抽取模型,各自一套权重、各自一套推理服务。这种结构在任务数量少的时候没什么感觉,等到任务数上来,显存占用、推理延迟、运维成本就会同时爆掉。假设你有4个任务,每个任务的骨干网络占800MB显存,推理时每个请求要串行过4个模型,延迟叠加、显存叠加,服务端的卡数很快就不够用。
多任务学习的核心动机就是把重复的特征提取工作合并。底层的卷积或注意力模块学到的边缘、纹理、局部结构这类通用表征,对多个任务来说高度重合。共享这部分参数,能在显存和计算量上拿到实打实的收益。这也是为什么在真实业务里,多任务学习往往先是一个成本优化方案,其次才是一个精度优化方案——这个认知顺序很重要,如果一开始就指望它提升主任务精度,大概率会失望。
1.2 硬共享、软共享与专家混合的取舍逻辑
共享方式决定了任务之间如何"分摊"参数,常见有三条路线:
| 共享方式 | 结构特点 | 适用场景 | 主要风险 |
|---|---|---|---|
| 硬共享 | 底层骨干完全共用,仅在头部按任务分支 | 任务相关性强、数据量差异不大 | 任务冲突直接传导到骨干 |
| 软共享 | 各任务有独立骨干,通过约束让参数靠近 | 任务差异中等,需要一定独立性 | 约束权重难调,收益不稳定 |
| 专家混合 | 多个子网络(专家)由门控网络按输入加权 | 任务差异大、样本分布异质 | 门控容易塌缩到少数专家 |
硬共享是绝大多数项目的起点,实现成本最低,PyTorch里无非是定义一个共享backbone,然后forward里分叉出多个head。它的隐性代价是梯度冲突:任务A想让某组参数往左走,任务B想往右走,最后谁也没走好。软共享本质上是在缓解这个问题,但引入了额外的超参数。专家混合则把"共享什么"这件事交给模型自己学,代价是训练稳定性变差、显存开销回升。
我的建议是先跑硬共享拿到一个baseline,观察各任务指标的相对变化。如果某个任务明显被拖累,再考虑局部解冻——把骨干的浅层共享、深层按任务拆开,这通常比直接上复杂结构更划算。
1.3 任务权重的本质是梯度尺度的平衡
多任务训练最容易被忽视的细节是不同任务的loss量级天然不同。一个回归任务的MSE可能在0.01量级,一个分类任务的交叉熵在2.0量级,直接相加的话,分类任务的梯度会完全压制回归任务。这不是模型结构的问题,是数值尺度的问题。
处理方式有几类:手工调权重(最简单,也最容易过拟合验证集)、基于不确定性加权(把每个任务的噪声方差也作为可学习参数)、基于梯度归一化(按各任务梯度范数动态缩放)。手工权重在实际项目里用得最多,但要注意权重不是固定的,训练早期和后期的最优比例往往不同。
import torch import torch.nn as nn class MultiTaskLoss(nn.Module): def __init__(self, task_names, init_weights=None): super().__init__() self.task_names = task_names if init_weights is None: init_weights = {n: 1.0 for n in task_names} # 用log参数化,保证权重恒为正,且优化时更平滑 self.log_vars = nn.ParameterDict({ n: nn.Parameter(torch.tensor(float(init_weights[n])).log()) for n in task_names }) def forward(self, preds, targets, losses): total = 0.0 detail = {} for n in self.task_names: w = torch.exp(self.log_vars[n]) total = total + w * losses[n] detail[n] = (losses[n].item(), w.item()) return total, detail这段代码把权重参数化在log空间,好处是优化过程中权重不会变成负数,学习率对它的影响也更线性。实际用的时候我会把detail打日志,观察各任务权重随step的变化曲线——如果某个权重一路涨到很大,通常说明那个任务的loss一直降不下去,模型在"抢救"它,这时候要回头查那个任务的数据管道。
2. 骨干网络的切分位置:一个被低估的设计决策
共享到什么深度,比"要不要共享"更影响最终效果。这个问题没有通用答案,但有一套可操作的判断依据。
2.1 按特征抽象层级决定分叉点
骨干网络的浅层学的是通用特征,深层学的是任务相关特征。所以分叉点越靠后,共享程度越高;分叉点越靠前,各任务的独立性越强。分类和检测这类任务,通常在骨干的最后一个stage之后分叉;而像语义分割和边缘检测这种对低级特征敏感的,往往需要在中间层就把特征引出来做辅助监督。
实操上一个很有效的技巧是多尺度特征引出:不只从最后一层拿特征,而是从中间几个stage各接一个轻量head做辅助任务。这种做法在分割类项目里非常常见,它让浅层也被监督信号约束住,避免浅层特征过度偏向主任务。代价是显存增加,因为中间特征图分辨率高、通道数不小。
class SharedBackboneWithTaps(nn.Module): def __init__(self, backbone, tap_indices): super().__init__() self.stages = nn.ModuleList(list(backbone.children())) self.tap_indices = set(tap_indices) def forward(self, x): feats = [] for i, stage in enumerate(self.stages): x = stage(x) if i in self.tap_indices: feats.append(x) return x, feats这段代码把骨干拆成stage列表,按索引收集中间特征。注意backbone.children()返回的顺序依赖具体实现,拿ResNet来说最后一个child可能是池化层而不是stage,接之前必须打印一遍确认顺序,否则tap到的特征维度会和你预期的不一致。这个坑我踩过一次,排查了半天才发现是收错了层。
2.2 通道注意力作为任务路由的轻量方案
如果不想引入完整的专家网络,一个折中方案是在共享特征上挂任务专属的通道注意力。做法是每个任务学一组通道权重,对共享特征做加权后再送进各自的head。这样参数的增量很小(每个任务就几组全连接层),但给了模型按任务筛选通道的能力。
这招在任务数量3到5个、且任务之间存在部分冲突时特别好用。相比直接拆分支,它保留了骨干参数的完全共享,显存占用几乎不增加。局限是它只能调整通道维度的重要性,无法改变空间维度的特征分布,对空间结构需求差异大的任务(比如一个要全局语义、一个要像素级细节)帮助有限。
2.3 分叉过深的隐形成本
有一个反直觉的经验:分叉过早往往比共享过度更糟。原因在于,一旦分叉太早,各任务的骨干实际上退化成了独立模型,多任务学习的收益基本消失,只剩下一个统一接口的形式主义。更麻烦的是,每个分支的参数都要单独更新,梯度无法互相提供正则化信号,小样本任务的表现会明显不如共享方案。
判断标准可以看各任务的数据量比例。如果主任务数据量是辅助任务的十倍以上,共享骨干能让辅助任务搭上主任务的表征红利;反过来,如果辅助任务数据量也很大,共享带来的冲突可能超过收益,这时候更适合拆开或者用软共享。这个比例没有硬性阈值,我给的经验值是主辅数据量差距在5倍以内时优先尝试深度共享,超过20倍时考虑早分叉。
3. 并行处理的三个层次与PyTorch对应手段
多任务和并行是两件独立但经常被混为一谈的事。多任务解决的是"一个模型干多件事",并行解决的是"算得更快"。实际项目里两者经常组合出现,但优化手段完全不同。
3.1 数据并行:最基础也最容易踩显存坑
DistributedDataParallel(DDP)是现在的标准选择,DataParallel基本可以不用了,后者在单进程多线程下会遇到GIL和主卡负载不均的问题。DDP的核心是所有进程各持一份完整模型,前向各自算,反向时对梯度做all-reduce。
显存上的坑在于:DDP不省显存,只省时间。每个进程都要放一份完整模型和优化器状态,如果模型本身就接近单卡上限,多卡并行反而会OOM。这时候要考虑的是模型并行或者ZeRO类的优化器分片方案,而不是盲目加卡。
import os import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP def setup(rank, world_size): os.environ["MASTER_ADDR"] = "127.0.0.1" os.environ["MASTER_PORT"] = "29500" dist.init_process_group("nccl", rank=rank, world_size=world_size) torch.cuda.set_device(rank) def wrap(model, rank): model = model.to(rank) return DDP(model, device_ids=[rank], find_unused_parameters=False)find_unused_parameters=False这个设置值得单独说。多任务模型里经常有某些任务在当前batch没有样本的情况,如果某个head的参数没参与前向,DDP的反向会报错说梯度未同步。默认True能绕过这个问题,但会带来额外的图遍历开销,训练速度明显下降。正确做法是保证每个任务在每个batch都有样本,通过采样器控制,而不是依赖这个开关。
3.2 任务并行的取舍:显存换吞吐的边界
当一个请求需要同时过多个head时,串行执行会让GPU利用率上不去——每个head的计算量可能都不大,但kernel启动开销是固定的。这时候可以把各任务的前向并行起来,也就是在同一个batch上同时算多个head。
实现上有两种思路。一种是把所有head的输入拼接成一个大批次,一次前向算完再拆开,这种做法kernel调用次数最少,但要求各任务的输入形状一致。另一种是把head分组,用多个CUDA stream并发执行,适合形状不一致的场景。
streams = [torch.cuda.Stream() for _ in range(len(heads))] outputs = [None] * len(heads) for i, (head, feat) in enumerate(zip(heads, feats)): with torch.cuda.stream(streams[i]): outputs[i] = head(feat) torch.cuda.current_stream().wait_stream(streams[-1])需要注意,多stream只在计算量足够大、且各stream之间没有数据依赖时才有收益。如果head本身很小,切换stream的开销可能比并行省下的时间还多。我实测下来,单个head前向耗时低于1ms时基本没有收益,反而因为同步点多导致延迟抖动。
3.3 流水线并行与任务调度的实际收益
流水线并行是把模型按层切成几段放到不同设备上,适合单层参数巨大、单卡放不下的情况。它的问题在于流水线气泡:前面的stage在算的时候,后面的stage在等,除非有足够多的micro-batch填充。
在多任务场景里,流水线并行有个额外的好处是可以按任务划分stage。比如把共享骨干放前面几个设备,各任务的head放后面,天然形成一种任务级的流水。但这种划分对batch调度要求高,各任务样本数不均衡时容易产生空闲。我的实践是用一个统一的采样器按比例混合各任务样本,保证每个micro-batch都包含多个任务的样本,减少尾部等待。
4. 训练稳定性:多任务里最容易翻车的地方
多任务训练的失败很少是"完全不收敛",更多是某个任务悄悄崩掉,而总loss看起来还在降。这种隐性失败最消耗时间。
4.1 梯度冲突的检测与量化
梯度冲突不是玄学,可以用余弦相似度量化。取两个任务对共享参数的梯度,算它们的方向夹角,夹角超过90度说明方向相反,正在互相抵消。
def grad_cosine(model, loss_a, loss_b): params = [p for p in model.parameters() if p.requires_grad] g_a = torch.autograd.grad(loss_a, params, retain_graph=True, allow_unused=True) g_b = torch.autograd.grad(loss_b, params, allow_unused=True) va = torch.cat([g.flatten() for g in g_a if g is not None]) vb = torch.cat([g.flatten() for g in g_b if g is not None]) return torch.nn.functional.cosine_similarity(va, vb, dim=0).item()这个指标建议在训练早期每个epoch采样几次记录,画出曲线。如果相似度长期是负值且绝对值较大,说明冲突严重,该调整权重或考虑拆分支了。如果只是训练初期为负、后期转正,那属于正常现象,不用干预。
4.2 任务采样比例的调整节奏
样本采样比例直接决定了各任务梯度在总梯度中的占比,它比loss权重的影响更直接。常见做法是按数据量的倒数采样,让每个任务在batch里的出现次数接近。但这个策略有个问题:小任务被过度采样后容易过拟合。
更稳的做法是分阶段调整。训练前期按数据量比例采样,让模型先把主任务学好;训练中后期逐步提高小任务的采样比例,让它精细调整。切换点可以用主任务验证指标的提升幅度来判断,一般设在主任务指标接近收敛的80%时。
4.3 共享参数的学习率差异化
共享骨干和任务head对学习率的敏感度不一样。骨干参数被多个任务同时更新,等效学习率偏高,容易震荡;head参数只被自己的任务更新,需要更大的步长才能跟上。实践中我会给骨干设置一个折扣系数,通常是head学习率的0.3到0.5倍。
backbone_params = list(model.backbone.parameters()) head_params = [p for n, p in model.named_parameters() if not n.startswith("backbone")] optimizer = torch.optim.AdamW([ {"params": backbone_params, "lr": 1e-4}, {"params": head_params, "lr": 5e-4}, ], weight_decay=0.01)这个差异化在新任务加入时尤其重要。增量加任务的时候,骨干已经训好了,如果还用同一个学习率,新任务的梯度会把骨干带偏,老任务的指标立刻掉。把骨干学习率调低甚至冻结若干个epoch,是保住老任务最直接的手段。
5. 一次真实的能力退化排查过程
说个具体的排查经历,细节做了脱敏。某次在共享骨干上从2个任务扩到4个任务,训练脚本跑通、loss曲线漂亮,但上线前的评估发现原来的两个任务指标各掉了1.5到2个点。总loss是下降的,所以自动化告警完全没触发。
5.1 从总loss下降但单任务退化的矛盾入手
第一步是拆开看各任务的loss曲线。结果发现新加的两个任务loss降得很快,老任务的loss在中期出现了明显的上翘。这说明总loss的下降主要由新任务贡献,老任务在被牺牲。这就是典型的梯度尺度失衡加上采样比例不当的双重作用。
第二步确认是不是权重问题。我临时把老任务的权重调高3倍重跑了一个短周期,老任务指标回升了0.8个点,但新任务崩了。这证明权重调整只能转移损害,不能消除损害,根子在结构。
5.2 定位到共享层通道冲突的证据链
接着做梯度余弦分析,发现新任务中的某一路和老任务在骨干第三个stage的梯度相似度长期在-0.4左右。把这个stage的梯度范数打出来,新任务的梯度范数比老任务高一个量级。到这里基本可以确认:新任务的数据分布和老任务差异较大,在共享层上争夺同一批通道。
进一步验证的方式是把骨干第三个stage之后的特征可视化,做通道激活统计。老任务依赖的通道和新任务依赖的通道重合度很高,但激活动作相反——一个要激活、一个要抑制。这就是通道冲突的直接证据。
5.3 拆分支与加任务门控的对比验证
两个方案并行验证。方案A是把第三个stage之后拆成两组分支,方案B是保留完全共享但在该stage加任务专属的通道门控。
方案A的效果:老任务指标完全恢复,新任务略降0.3个点,显存增加约12%,单次前向耗时增加约8%。
方案B的效果:老任务恢复0.7个点,新任务基本不变,显存增加约2%,耗时增加约2%。
最终选了方案B,因为业务上老任务的指标损失0.7个点是可以接受的,而耗时和显存更敏感。这个案例说明方案选择要和业务约束绑定,纯看指标的话A更优,但综合成本和收益B更合适。
6. 推理侧的多任务调度与延迟控制
训练阶段的优化做完,推理侧还有一堆事。多任务模型的推理经常被误以为"比多个单任务模型快",实际上如果调度不当,可能更慢。
6.1 动态任务选择与稀疏前向
线上请求往往不需要所有任务的结果。比如一个内容理解服务,可能只需要分类结果,不需要标签预测。如果每次都把所有head跑一遍,纯属浪费。做法是给请求带上需要哪些任务的标记,forward时按需执行head。
def forward(self, x, task_mask=None): feat = self.backbone(x) outs = {} for name, head in self.heads.items(): if task_mask is None or name in task_mask: outs[name] = head(feat) return outs这样骨干只跑一次,head按需跑,省下的主要是head的计算和后续的后处理。需要提醒的是,这种动态分支会让torch.compile或TensorRT这类图优化工具的处理变复杂,因为输入形状和执行路径不固定,建议在导出前把常用的task组合做成固定子图。
6.2 batch内多任务的混合调度策略
线上不同任务的请求量差异很大,如果按任务分别组batch,小任务的batch会很小,GPU利用率低。更好的做法是混合组batch:把一个batch内的样本按任务打标,骨干统一前向,head部分用掩码或者分组计算。
分组计算的做法是把同任务的样本索引出来,各head只处理自己那部分样本,这样计算量正比于实际样本数,不会浪费。实现上要注意索引操作的连续性,用index_select比用布尔掩码快,因为前者产生的张量是连续的。
6.3 显存与延迟的联合调优实测数据
在单卡上做过一组对比测试,模型是共享骨干加4个head,输入分辨率固定:
| 调度方式 | 平均延迟 | 峰值显存 | GPU利用率 |
|---|---|---|---|
| 全部head串行 | 42ms | 3.1GB | 61% |
| 全部head多stream | 36ms | 3.4GB | 74% |
| 按需head + 混合batch | 24ms | 2.6GB | 79% |
数字只能看相对关系,具体值依赖硬件和模型规模。但趋势是清楚的:按需执行加混合batch的收益最大,多stream的收益有限且会推高显存。所以优化顺序应该是先做任务级裁剪,再考虑算子级并行。
7. 几个我认为值得强调的实践经验
第一,多任务学习第一次上线不要贪多。从两个任务开始,把共享结构、采样比例、权重调参这套流程跑通,再加第三个。一次性上四个任务,出了问题很难定位是哪个组合导致的冲突。
第二,务必建立单任务基线。每个任务单独训一个模型,指标记录下来。没有这个基线,你根本无法判断多任务模型里某个任务的表现是"被拖累"还是"本来就这样"。这个工作看起来笨,但省下的排查时间远超投入。
第三,权重和采样比例这两个东西不要同时调。它们的影响高度耦合,同时动的话观察到的现象无法归因。先固定采样比例调权重,稳定后再调采样。
第四,日志里一定要分任务记录loss和指标,不要只记总loss。总loss是个会骗人的指标,它下降不代表所有任务都在变好。我在项目里会额外记录各任务梯度的范数,这个指标能提前预警失衡。
第五,增量加任务时,先冻结骨干只训新head若干epoch,再解冻用低学习率联合训练。直接联合训练几乎必然导致老任务退化,这个顺序不能省。
多任务学习这套东西,方法论层面其实不复杂,难的是把每个环节的数值行为摸清楚。共享结构怎么切、权重怎么配、采样怎么排,这些问题在文档里找不到答案,只能靠一次次带监控的训练去试。把监控做扎实,比套用任何先进结构都管用。