
做过多任务项目的同学大概都经历过这种拧巴手里七八个任务每个任务单独拉一条训练流水线数据量都不大模型各自欠拟合想着干脆合并成一个模型共享底层训出来的结果却是强的任务更强、弱的任务更弱甚至有两个任务互相拖累指标比单任务还差。这时候大多数人的第一反应是再调调学习率但真正的问题往往不在学习率上——而在共享什么、共享多少、怎么让不同任务的梯度别打架。基于迁移学习的多任务学习技术解决的正是这一类问题它用预训练阶段积累的通用表征给多任务模型一个高起点再用多任务之间的归纳偏置互相补足数据稀疏让数据少、任务多这个组合从劣势变成优势。这篇内容适合已经能跑通单任务训练、准备把多个相关任务合并到一个模型的读者也适合那些模型已经合并、但被负迁移折磨得想拆回去的同行。我会按结构怎么搭—损失怎么配—迁移怎么落—数据怎么喂—出问题怎么查的顺序,把踩过的坑和能直接抄的配置一并写清楚。1. 先分清两种范式在哪一层真正咬合很多人把迁移学习和多任务学习当成两个可以简单叠加的模块于是做法变成先加载预训练权重再加几个任务头结果发现效果平平就下结论说这两个技术不搭。问题在于这两个范式的作用层面不同简单相加只利用了它们最表层的那一点点价值。1.1 迁移学习提供的是起点多任务学习优化的是路径迁移学习的本质是把源域数据充足的大规模语料、通用图像库、通用行为日志上学到的参数分布作为目标任务的初始化或者特征提取器。它只关心一件事让模型在目标域上的收敛起点更好、所需样本更少。多任务学习关心的则是另一件事在同一个参数空间里让多个任务的梯度共同塑造共享表征使这个表征对每个任务都是有用的中间表示。打个比方迁移学习像是给一个刚入职的人一份行业通用培训多任务学习像是让他同时跟三个项目组干活。前者决定他上手快不快后者决定他最后长成什么形状的专才。两者叠加的正确姿势是让共享表征先继承源域的通用结构再被多个任务的梯度细调而不是加载完权重就当万事大吉。从优化视角看这两个过程对应两个不同的阶段源域预训练阶段在优化 $P(\theta \mid D_{src})$多任务阶段在优化 $\sum_t \lambda_t P(\theta \mid D_t)$。前者的价值在于给后者一个位于低损失盆地附近的初始点使得多任务阶段的梯度不需要去翻越大量无关的坏局部极小。理解这一点你就知道为什么冻结主干只训头这种常见做法在多任务场景里经常失效——它把多任务阶段唯一能重塑共享表征的通道给关死了。1.2 三种耦合深度对应三类完全不同的工程量按共享参数的范围和梯度的流动方式实际项目里常见三种耦合深度选错了后面全是返工。耦合方式共享内容梯度流动适合场景典型副作用只共享初始化预训练权重作为各自模型的起点不共享各训各的任务相关性弱、算力充足没有真正的多任务收益只是省了训练时间共享主干 独立任务头backbone 全部参数共享头部独立全量共享层回传任务相关、数据规模相近任务间梯度冲突弱任务被压制部分共享 门控/适配模块浅层共享深层或特定模块任务专属分层选择性回传任务相关性中等、存在明显域差异参数量上升调参复杂度提高第一类是伪多任务本质上就是并行训练我在早期项目里用过收益基本等于少写几份数据加载代码。第二类是主流做法也就是常说的硬共享hard sharing收益明显但对任务配比极其敏感。第三类介于两者之间工程实现上要额外引入适配层adapter或者任务门控成本更高但当任务之间存在明显分布差异时它往往是唯一能稳住的选择。判断该选哪一档我一般看两个量任务之间的梯度余弦相似度以及各任务单独训练时的收敛难度差异。前者接近正数就放心硬共享出现持续为负的就该考虑软共享后者差距超过一个数量级说明任务容量需求不同此时共享主干会被强需求任务独占必须靠门控或者分容量来缓解。1.3 一个容易忽略的前提任务之间必须存在可共享的结构多任务学习能带来增益的前提不是任务看起来像而是它们的解在参数空间里存在重叠的、可复用的中间表示。这个前提在很多场景下并不成立。比如一个是需要细粒度局部纹理的检测任务一个是依赖全局语义分布的排序任务它们的浅层特征看起来都能用但深层的表征需求完全不同硬共享到深层就变成了互相破坏。一个可操作的检验方法先把所有任务合并成一个模型训练到收敛然后对每个任务做一次冻结主干、只微调头的评估。如果某个任务冻结主干后的指标比它单任务训练还差很多说明共享主干学到的表征对它有害如果冻结后指标接近甚至更好说明它的任务头已经足够主干里属于它的那部分信息正在被别的任务挤掉。这个简单实验基本上能告诉你该共享到第几层。提示不要把任务相关性高当成一个主观判断。相关性要用数据说话最直接的是看各任务标签的互信息或者共享特征上的线性探针linear probe精度而不是凭业务经验拍脑袋。2. 共享骨架怎么搭硬共享、软共享与任务头的容量分配结构设计这一步决定了后面所有调参的上限。我见过太多项目在这里偷懒直接用一个 backbone 加 N 个全连接头的模板然后花两周时间在损失权重上打转其实只要把头部的容量和正则做对一半的问题根本不会出现。2.1 硬共享的最小可用实现与两个必改细节硬共享的实现非常简洁一个共享编码器每个任务一个解码头。但直接这么写会踩两个坑。第一个坑是任务头的初始化尺度。因为共享编码器的输出维度通常较大512 到 1024如果每个任务头都用默认初始化多个头的初始梯度量级会不一致训练初期就是强任务抢先主导共享层。我的做法是给每个头加一层 LayerNorm并把初始学习率按头部参数量做一次缩放。第二个坑是共享层的 BatchNorm。多任务训练时不同任务的数据分布差异会让 BN 的 running statistics 混在一起尤其当某些任务的 batch 内样本数很少时统计量会被抖动得很厉害。稳妥的做法是把共享主干里的 BN 换成 GroupNorm 或者干脆冻结 BN 的统计量更新只让它做归一化。这个改动在我的经验里能稳定带来 1 到 3 个点的提升而且几乎不需要额外调参。import torch import torch.nn as nn class SharedTrunk(nn.Module): def __init__(self, in_dim768, hidden512, num_tasks4): super().__init__() self.trunk nn.Sequential( nn.Linear(in_dim, hidden), nn.GroupNorm(8, hidden), # 替代 BN避免多任务统计量污染 nn.GELU(), nn.Dropout(0.1), nn.Linear(hidden, hidden), nn.GroupNorm(8, hidden), nn.GELU(), ) # 每个任务一个轻量头先归一化再降维稳定初始梯度量级 self.heads nn.ModuleList([ nn.Sequential( nn.LayerNorm(hidden), nn.Linear(hidden, hidden // 4), nn.GELU(), nn.Linear(hidden // 4, 1), ) for _ in range(num_tasks) ]) def forward(self, x): z self.trunk(x) return [head(z) for head in self.heads]这段代码里GroupNorm(8, hidden)的组数不是随便写的习惯上取 8 或 16组数太多接近 InstanceNorm太少接近 LayerNorm8 是一个在多种特征维度下都比较稳的经验值。头部中间层降到hidden // 4是我常用的压缩比既保留非线性拟合能力又限制了单任务头对共享表征的过度索取。2.2 软共享的两种落地形态适配层与门控当硬共享出现明显负迁移时软共享是首选。软共享的核心思想是共享大部分、保留小部分专属实现上有两条路。第一条是适配层adapter在共享块的输出后插入一个低秩的任务专属变换形式是 $h_t h W_t^{down} \cdot \text{act}(W_t^{up} h)$其中 up 投影到很小的瓶颈维度比如 hidden 的 1/8 或 1/16。第二条是门控gating用一个任务相关的门控向量对共享特征做逐维加权$\tilde{h}_t g_t \odot h$$g_t$ 由任务 ID 的嵌入经一个小 MLP 生成。适配层适合任务差异体现在特征怎么组合上门控适合差异体现在哪些特征维度重要上。我在一个多任务排序场景里两种都试过最终选的是门控因为那几个任务共享的底层特征维度是同一批只是各任务看重的维度不同。适配层那次的收益不明显反而让参数量涨了 30%推理延迟也跟着上去了。成本上要有心理准备适配层会给每个共享块增加约 $2 \times d \times r$ 的参数$d$ 是隐藏维、$r$ 是瓶颈维门控只增加 $d$ 维加一个小 MLP。如果部署端对延迟敏感优先门控。2.3 任务头容量不要平均分配一个很反直觉但实测有效的经验任务头的容量不该按任务数量平均分而该按任务难度和标签噪声水平分。分类类别多、边界模糊的任务需要更大的头标签噪声大或者本身信号弱的任务头越大越容易过拟合到噪声上反而拖累共享层。我的做法是先给所有头一个基线容量然后按单任务独立训练时的收敛损失排序对收敛损失明显偏高说明任务难的给一倍容量对收敛损失很低但波动大的说明任务简单但噪声大减半并加 dropout。这个操作听起来粗糙但比统一容量稳定得多尤其是在任务数量超过 5 个之后。注意头部的独立性强弱要和权重衰减配套。头越大、正则越弱它就越倾向于把共享层的输出抄成一个恒等映射多任务的正则化收益就消失了。经验配置是头部的 weight decay 取共享层的 2 到 3 倍。3. 损失配比从手工权重到梯度层面的冲突处理多任务训练里最容易失控的地方就是损失聚合。手工设一组固定权重训到中期发现某个任务已经收敛、梯度趋近于零另一个任务还在大幅震荡共享层就被后者单方面拖着走。这不是权重设错了是静态权重本身就不适合多任务这种动态过程。3.1 为什么固定权重几乎注定失效设总损失 $L \sum_t w_t L_t$梯度是 $\nabla L \sum_t w_t \nabla L_t$。在训练早期各任务损失量级差异可能达到两个数量级到了中后期先收敛的任务梯度范数下降一个数量级后收敛的任务仍保持高位。固定 $w_t$ 在这两个阶段都无法维持平衡。更麻烦的是量纲问题。分类用交叉熵、回归用 MSE、排序用 pairwise loss三者的梯度范数天然不在一个量级上。我见过最典型的错误是直接把 MSE 和交叉熵按 1:1 相加结果 MSE 那一项因为数值大把整个共享层拽去拟合回归任务分类任务的指标直接崩掉。一个必要的预处理是在训练前 100 到 200 步只记录各任务损失和梯度范数的均值不做参数更新然后按 $\text{scale}_t 1 / \overline{|\nabla L_t|}$ 做一次归一化。这个预热统计步骤非常简单但能省掉大量试错。3.2 三种自适应加权方案的适用边界学术界给了不少方案落到工程上常用的就三种各有明确的适用场景。方案核心思想额外开销适合情况不适合情况不确定性加权学习每个任务的对数方差参数自动平衡每任务 1 个参数任务噪声水平差异大任务数很多时方差参数难收敛GradNorm让各任务梯度范数按训练速率动态对齐每步需算各任务梯度范数任务收敛速度差异明显batch 小、梯度噪声大梯度投影PCGrad 类冲突梯度做投影消除负相关分量任务数平方级的两两计算任务数少≤5且冲突严重任务多时计算成本过高不确定性加权的实现最省事一行代码的改动量class UncertaintyWeighting(nn.Module): def __init__(self, num_tasks): super().__init__() self.log_sigma nn.Parameter(torch.zeros(num_tasks)) def forward(self, losses): total 0.0 for i, l in enumerate(losses): precision torch.exp(-2 * self.log_sigma[i]) total total 0.5 * precision * l self.log_sigma[i] return total注意这里用的是 $0.5 \cdot e^{-2s} L s$ 的形式$s$ 是 $\log \sigma$。加号后面的 $s$ 是正则项防止模型把所有 $\sigma$ 推向无穷大来关掉所有任务。这个细节很多复现版本会漏掉漏掉之后的典型症状是训练前期损失平稳下降中期突然所有任务一起变差因为模型发现把所有任务的权重调小反而能让总损失下降。梯度投影类方法我一般只在任务数不超过 4 个、且已经确认存在严重梯度冲突时才用。它的计算成本随任务数平方增长8 个任务时每步要做 28 次两两投影训练时间直接翻倍收益却未必覆盖成本。3.3 权重之外把冲突处理下沉到参数组比调权重更彻底的做法是在参数组层面做隔离。具体来说把共享主干按层切成若干段每段对不同任务的梯度做一次系数调节浅层对所有任务等权通用特征中层按任务相关性加权深层只让相关任务参与更新。这本质上是把共享多少从结构设计延伸到优化过程。实现上不难用参数组配合梯度钩子就能做到def layer_wise_scaling(model, task_weights, layer_prefix): # task_weights: {task_id: {layer_name: weight}} for name, param in model.named_parameters(): if not name.startswith(layer_prefix): continue if param.grad is None: continue # 按层聚合各任务的加权梯度需要提前在 backward 中按任务累积 param.grad sum(w[name] * g for w, g in zip(task_weights, param.task_grads))这段是示意性的真正的难点在于按任务累积梯度需要多次 backward 或者用torch.autograd.grad逐任务求导显存开销会上升。我通常只在最后两三层做这种精细控制前面的层用统一的加权策略这样显存压力可控收益也集中体现在最容易冲突的深层。4. 迁移的落点预训练权重从哪来、解冻到什么程度把预训练权重接进多任务模型看起来只是load_state_dict一行实际上从哪一层开始解冻这个决策对最终效果的影响比大多数超参都大。4.1 分层解冻的节奏设计一次性全量解冻是新手最常见的做法问题在于预训练权重携带的通用结构与多任务的新梯度会立刻发生剧烈冲突尤其在目标任务数据量小的时候前几百步会把好不容易学到的表征破坏掉。稳妥的做法是分阶段解冻每个阶段用不同的学习率。我的常用节奏是三轮第一轮只训任务头共享主干冻结学习率取常规值的 10 倍因为只有头在学可以激进些训 1 到 2 个 epoch 让头先适配共享特征的分布第二轮解冻主干的后 1/3主干学习率设为头部的 1/10第三轮全量解冻主干学习率设为头部的 1/50 到 1/100。这个比例不是拍脑袋而是遵循预训练参数越靠近输入越应该保持原样的普遍经验——浅层学的是边缘、纹理、词形这类通用模式改动它们的收益远小于风险。分层学习率在 PyTorch 里用参数组配置即可def build_param_groups(model, base_lr, unfreeze_depth): head_params, trunk_params [], [] for name, p in model.named_parameters(): if heads in name: head_params.append(p) else: trunk_params.append(p) return [ {params: head_params, lr: base_lr}, # 主干学习率按解冻深度衰减 {params: trunk_params, lr: base_lr * (0.1 ** unfreeze_depth)}, ]unfreeze_depth取 1、2、3 分别对应解冻后 1/3、2/3、全部实际项目里我一般只在 1 和 3 之间切中间那档收益有限但要多跑一轮实验。4.2 灾难性遗忘的量化监测多任务训练加上微调最容易出现的问题是新任务学会了、预训练阶段的通用能力丢了。这在下游指标上不一定立刻显现但对线上稳定性影响很大。我一般会在训练过程中挂两个探针任务一个是预训练阶段就有的辅助任务比如掩码预测或者对比学习目标另一个是固定的小验证集上的零样本指标。这两个指标在训练中的走势就是遗忘程度的直接读数。如果探针指标在第二轮解冻后下降超过 5%说明解冻太激进我会退回上一阶段的检查点把主干学习率再降一档。这个回退—降速的循环我跑过很多次比一次性设一个保守学习率更高效因为你能看到模型到底能承受多大的改动。提示混合精度训练下主干学习率过低容易被梯度缩放吃掉有效更新。如果发现主干参数几乎不动检查一下 GradScaler 的初始 scale 是不是太大必要时给主干单独设一个更小的 scale 或者干脆让主干走 FP32。4.3 预训练权重的选择比解冻策略更关键一个残酷的事实如果预训练权重的源域和你的多任务场景差得太远再精细的解冻策略也救不回来。我做过一个对比用通用图像库预训练的骨干去接医疗影像的多任务效果不如直接在目标域上从头训一个小模型换成同域但任务不同的预训练权重之后同样的解冻策略立刻带来 6 个点的提升。判断源域是否合适我有个土办法拿预训练模型在目标域的小验证集上做一次线性探针冻结主干只训一个线性分类器如果准确率显著高于随机说明表征有迁移价值如果接近随机趁早换权重别在解冻策略上浪费时间。5. 数据侧任务配比、采样顺序与缺失标签的处理多任务训练的很多玄学问题根子都在数据侧。同一个模型结构换个采样策略指标能差出好几个点这比调损失权重有效得多。5.1 任务级采样的三种策略与选择依据最朴素的做法是把所有任务的数据拼成一个大池子随机打乱这叫样本级均匀采样。它的问题是会严重偏向数据量大的任务小任务在一个 epoch 里可能只出现几次。第二种是任务级轮转每个 batch 轮着从不同任务取保证每个任务的出现频率相同。第三种是按比例采样让每个任务在一个 epoch 内被采样的总样本数与其数据量的平方根成正比——这个平方根是经验做法目的是在不被大任务淹没和不让小任务过采样到过拟合之间取平衡。策略小任务表现大任务表现适用情况样本级均匀容易被淹没充分学习各任务数据量接近任务级轮转学习充分可能欠拟合任务数少、数据量差距大平方根比例平衡平衡通用默认选项我现在的默认配置是平方根比例只有在任务数少于 4 个且数据量差距悬殊时才切到轮转。轮转策略的一个隐藏代价是同一个任务连续出现多次会让共享层在短时间内被同一分布占据梯度方向来回摆动训练损失曲线会很毛糙。5.2 标签缺失下的掩码损失真实场景里几乎不可能所有任务在所有样本上都有标签。比如一个内容理解系统有的样本有分类标签、有的有质量分标签、有的只有点击标签。这时候必须做掩码损失否则缺失位置会被当成负样本直接教坏模型。def masked_loss(logits, targets, mask, loss_fn): # mask: (batch,)1 表示该样本对本任务有标签 if mask.sum() 0: return None valid_logits logits[mask.bool()] valid_targets targets[mask.bool()] return loss_fn(valid_logits, valid_targets)关键点是mask.sum() 0时要返回 None 并在聚合时跳过否则会得到一个 0 损失却仍参与梯度计算除零或者 NaN。另一个细节是如果某个任务的 batch 内有效样本太少比如少于 4 个这一批的梯度噪声会非常大我的处理是直接跳过该任务的这次更新等下一个 batch 有足够样本再算。这个最小有效样本数的阈值我一般设成 4 到 8视 batch size 而定。5.3 课程式排序先易后难还是先难后易多任务场景下任务本身也可以当作课程来排。常见的两种排法是先训简单任务收敛快的、再引入困难任务以及反过来。我实测下来的结论是先训收敛快的任务但要保留一个困难任务的低权重通道从头参与。纯先易后难的排法有个明显缺陷共享层被简单任务塑形之后再引入困难任务时会发生剧烈偏移前面训好的头部全部需要重新适应。保留低权重通道的作用是让共享层从一开始就知道困难任务的存在虽然它的梯度贡献很小但足以让表征不往完全不利于它的方向跑。这个技巧我在三个项目里用过效果都比重排任务顺序稳。具体实现上困难任务的初始权重取 0.1每过一个阶段比如 2 个 epoch乘以 1.5直到达到目标权重同时简单任务的权重按相同比例反向衰减。整个过程不需要改代码结构只改权重调度表。6. 负迁移排查一套能复现的诊断链路负迁移不是训崩了这么模糊的现象它有一组可观测的特征某个任务的验证指标在训练中期出现与训练损失背离的上升、共享层的梯度范数持续偏向少数任务、冻结主干后某些任务反而变好。下面是我实际用的一套排查链路按顺序走基本能定位到原因。6.1 第一步确认是不是真的负迁移先做三个对照实验缺一个都可能误判。实验 A 是各任务单任务独立训练得到基线指标实验 B 是多任务联合训练取每个任务的最好检查点实验 C 是多任务结构但每个任务只用自己的数据更新共享层即共享结构的独立训练。如果 B 差于 A 且差于 C说明共享确实带来了伤害是真正的负迁移如果 B 差于 A 但接近 C说明问题在联合优化而不是结构如果 B 好于 A那只是某些任务调得不够好不用往负迁移上想。这三个实验的成本不低但比盲目调参省时间。我一般用缩小版数据10% 到 20%来跑对照趋势已经足够明显。6.2 第二步看梯度层面的冲突信号确认存在负迁移之后下一步是定位冲突发生在哪一层。做法很简单对每个任务单独求一次共享层参数的梯度然后计算两两之间的余弦相似度。def pairwise_grad_cosine(model, task_losses, shared_param_names): grads {} for i, loss in enumerate(task_losses): g torch.autograd.grad(loss, [p for n, p in model.named_parameters() if n in shared_param_names], retain_graphTrue, allow_unusedTrue) grads[i] torch.cat([x.flatten() for x in g if x is not None]) cos {} for i in range(len(task_losses)): for j in range(i 1, len(task_losses)): cos[(i, j)] torch.nn.functional.cosine_similarity( grads[i], grads[j], dim0).item() return cos判读标准我总结成一张表余弦相似度判读处理方向 0.3强正相关共享充分可以加大共享深度0 到 0.3弱正相关正常维持现状微调权重-0.2 到 0轻微冲突调整损失权重或引入软共享 -0.2严重冲突必须做结构隔离或梯度投影要注意的是这个指标要分层看。我遇到过的典型情况是浅层余弦相似度在 0.4 以上、深层掉到 -0.3这就非常明确地告诉你共享到中层深层分开。6.3 第三步定位到具体任务与具体样本梯度整体冲突只是宏观信号真正难修的是局部冲突——某几个任务在特定样本子集上打架。定位方法是按样本的共享特征做聚类然后统计每个簇内各任务的梯度方向方差。方差特别大的簇往往对应着一批语义模糊的样本比如同时具备两个任务标签特征的边界样本。这类样本的处理方式不是删掉而是降权。我的做法是给每个样本算一个任务歧义度用两个任务头对它的预测置信度差异来近似歧义度高的样本在该任务的损失里乘一个 0.5 到 0.8 的系数。这个操作很轻量但在类别边界模糊的场景里能带来稳定的收益因为它减少了模型在两不像样本上的无效拉扯。注意歧义度降权会让模型对这些样本的预测趋于保守如果业务上这批样本恰好是高价值的长尾降权就是负优化。上线前一定要按业务分层看指标别只看总体平均。6.4 第四步验证修复方案的最小改动定位清楚之后修复要遵循最小改动原则。我见过太多人一上来就换结构把硬共享改成适配层参数翻倍、训练时间翻倍最后指标只涨了 0.3 个点还引入了新的不稳定。正确的顺序是先调损失权重和采样比例零结构改动再调解冻策略和学习率零结构改动最后才动结构。我在最近一个项目里的实际修复路径是先按梯度余弦相似度发现深层冲突严重把深层的损失权重按任务收敛速度重设指标恢复了大半剩下的差距通过把最后一层共享改为门控解决参数量只增加了 3%。整个过程没有重新设计结构只做了两处定点改动。7. 训练之后评估口径、推理拆分与模型瘦身多任务模型的评估和部署有一套独立于训练的问题很多人训完就直接上结果线上表现和离线对不上。7.1 评估必须按任务分开看且要看方差联合训练的模型在验证集上给一个总体指标是没有意义的因为各任务的量纲和基线不同。必须每个任务单独算而且要看多次运行的方差。多任务训练对随机种子比单任务敏感得多我通常跑 3 到 5 个种子取均值和标准差只有均值提升超过 1 倍标准差才认为有效。另一个容易被忽略的点是任务间指标的相关性。如果两个任务的指标在同一次运行里总是同涨同跌说明它们在共享层高度耦合未来调整一个很可能影响另一个这种耦合要在架构文档里标注清楚避免后续维护时踩雷。7.2 推理时按需拆分别为不用的任务付算力线上请求往往不是所有任务都要。一个内容系统可能只在特定场景才需要质量分大部分请求只需要分类结果。这时候如果每次都跑全部任务头算力浪费很明显。做法是把共享主干的输出缓存下来任务头按需调用。共享主干算一次各任务头是轻量的通常只占总计算量的 5% 到 15%所以拆分的关键是把主干输出做成可复用的一层接口。class MultiTaskServing(nn.Module): def __init__(self, trunk, heads, task_names): super().__init__() self.trunk trunk self.heads heads self.name2idx {n: i for i, n in enumerate(task_names)} torch.no_grad() def forward(self, x, tasks): z self.trunk(x) # 主干只算一次 return {t: self.heads[self.name2idx[t]](z) for t in tasks}这个接口看起来很朴素但它让同一次请求跑多个任务的边际成本降到接近零也方便做灰度和 A/B——新增任务只需要注册一个新头主干完全不动。7.3 蒸馏回单任务多任务做老师单任务做学生多任务模型的一个现实问题是它往往比单任务模型更重、更难维护。一个折中方案是用多任务模型做教师把它的表征和软标签蒸馏到更轻的单任务学生模型里。做法是让学生模型除了拟合真实标签还要拟合教师模型在共享特征上的投影用一层线性映射对齐维度以及教师输出的软标签。这条路我在两个项目里跑通过学生模型的体积能压到多任务模型的 30% 到 40%而单任务指标通常能保住多任务模型的 98% 以上。代价是多一次训练但换来的是部署简单、故障隔离清晰——一个任务出问题不会影响其他任务。蒸馏时有个细节值得说温度参数的设置要和教师模型输出的置信度分布匹配。多任务模型的输出往往比单任务模型更软因为它见过更多样的数据这时候温度取 1 到 2 就够取太大会让学生学到过于平滑的分布反而损失判别力。最后分享一个我在实际项目里反复验证的体会多任务学习的效果上限绝大部分不取决于你用了多先进的自适应加权算法而取决于任务的选择和共享结构的颗粒度。我见过用最朴素的固定权重加平方根采样跑出比复杂方法更好的结果原因就是它的任务切分足够合理共享层和中层的重复度很高。反过来的例子也很多——任务切得互相打架再精细的梯度手术也只能把损失从 5% 压到 3%。所以每次动手加任务之前我都会先问一句这个新任务和现有任务在中间表示上真的有重叠吗答不上来的时候宁可不加。