1. 为什么把迁移学习和多任务学习拧在一起
1.1 两个技术各自在解决什么问题
单看迁移学习,它要解决的是"数据不够、从零训练不划算"这件事。手里有一个在超大规模数据上训好的骨干网络,它已经学到了相当通用的特征表达能力,我们只需要在自己的小数据集上做微调,就能用很小的代价换到不错的精度。这里的关键假设是:源域和目标域共享一部分底层特征分布,比如边缘、纹理、局部结构这些东西是跨任务通用的。
单看多任务学习,它要解决的是"多个相关任务各自训练、各自浪费"这件事。一个模型挂多个任务头,共享底层表示,用任务之间的归纳偏置互相正则化。理论上,任务A的数据也在帮任务B学习更好的共享特征,相当于变相扩了样本量。
这两件事单独拿出来都不新鲜,但真正在工程里把它们拼到一起,问题就冒出来了:迁移学习过来的骨干参数量大、对学习率敏感,而多任务学习要求共享层同时照顾好几个目标,梯度方向经常打架。前者怕"训崩",后者怕"训偏",两件事叠在一起,调参空间直接翻倍。
我在一个跨模态内容理解的模拟项目里第一次把这两套东西硬拼,最初的做法就是加载预训练权重、挂三个头、损失直接相加。结果第一个epoch看着还挺正常,第三个epoch开始主任务指标掉头向下,两个辅助任务倒是涨得欢——典型的共享层被辅助任务带跑偏。后来花了大概两周时间才把训练流程理顺,这段经历基本构成了后面所有内容的来源。
1.2 组合之后到底换来什么,边界又在哪里
基于迁移学习的多任务学习,一句话概括就是:用一个预训练好的通用骨干做共享底座,在上面挂多个任务专属的输出头,用多任务联合训练的方式做下游适配。它换来的收益主要有三块。
第一块是样本效率。小样本任务最怕过拟合,多任务联合训练相当于给共享层加了正则,让模型不至于死记某一个任务的噪声。实测下来,在主任务只有两三万标注样本的情况下,联合训练比单任务微调的验证集指标通常能高出一到三个点,任务相关性越强,收益越明显。
第二块是推理成本摊薄。三个任务一个模型一次前向,部署的时候只有一个骨干的开销,比起维护三个独立模型,显存占用和延迟都能压下来一大截。这在边缘设备或者高并发场景里是实打实的收益。
第三块是表示质量。辅助任务往往能提供主任务缺失的监督信号,比如做内容分类的时候顺带做一下关键词抽取,能逼着共享层去关注更细粒度的语义单元,而不是只盯着粗粒度的整体分布。
但边界也很清楚,我一般用三个条件判断该不该上这套方案:
| 判断维度 | 适合上 | 不建议上 |
|---|---|---|
| 任务相关性 | 共享底层语义,标签定义有交集 | 任务目标几乎无关,比如一个做分类一个做回归到不同量纲 |
| 数据量结构 | 主任务少、辅助任务多 | 每个任务数据都很少且互不重叠 |
| 算力预算 | 有预训练权重可直接加载 | 只能从零训练,且显存吃紧 |
| 迭代节奏 | 任务版本可以同步发布 | 各任务由不同团队独立高频迭代 |
最后一列那个"迭代节奏",是纯工程视角的坑。多任务模型有个隐性成本:任何一个任务改了标签体系,整个共享骨干都得重训一遍。如果各任务归属不同团队、发布周期完全错开,这套架构会变成拖累,不如老老实实拆成多个单任务模型。
1.3 选型时我优先看的三个信号
真正落地之前,我会先做三件小事来降低试错成本。
一是看梯度冲突。拿单任务微调出来的模型,在共享层上算各任务梯度的余弦相似度,如果大面积是负值,说明任务之间在抢特征,硬共享大概率出问题,得考虑软共享或者梯度手术。
二是看单任务基线。所有多任务实验都必须有单任务微调的对照,没有对照就无法判断收益来自多任务还是来自预训练权重本身。这一条我吃过亏,早期把一个纯预训练带来的提升误记成了多任务的功劳,后来补了消融才发现多任务其实贡献是负的。
三是看标签覆盖率。多任务联合训练要求同一条样本能同时提供多个任务的标签,但现实数据里往往是"这条有分类标签没关键词标签"。覆盖率低于某个阈值时,需要考虑部分监督的训练方式,或者干脆按任务分采样器,别指望一次前向拿全部loss。
2. 整体架构设计:从骨干到任务头的取舍
2.1 硬共享、软共享与专家混合怎么选
结构层面最核心的决策是共享方式。
硬共享是最简单的一版:底层完全共享,顶层分叉出多个任务头。参数量最省,训练最快,任务之间耦合最紧。它的隐含假设是任务足够相关,共享层能学到一套对所有任务都有用的表示。任务相关性高的时候这版效果最好,也最容易调。
软共享是每个任务有自己的一套参数,但通过正则项或者表示约束让它们彼此靠近。灵活度高,能缓解任务冲突,代价是参数量上去,且正则系数不好调。
专家混合(MMoE那一路)是折中方案:底层放若干个专家网络,每个任务有一个门控网络去加权组合专家输出。它的好处是允许任务之间部分共享、部分独立,任务相关性差异大的时候表现更稳。缺点是门控容易塌缩——训练一段时间后所有任务都只激活同一两个专家,等于退化成硬共享,还白搭了参数量。
我自己的经验是:任务数在三到五个、相关性明确的时候,先用硬共享把baseline跑通,这套结构调参成本最低,出问题也最容易定位。只有当硬共享出现明显的任务跷跷板(一个涨一个掉)且梯度冲突指标很难看时,才考虑上专家混合。直接上手复杂结构,往往连问题出在结构还是数据上都分不清。
2.2 迁移策略的三条路径与组合方式
预训练权重怎么用,也有讲究,我一般按三种粒度来设计。
整网微调是最常见的:加载全部权重,所有层都参与训练,只是给骨干和任务头设置不同的学习率。骨干用小学习率(1e-5到2e-5量级),任务头用大学习率(1e-3量级),原因很直白——骨干已经是一个比较好的解,大学习率会把预训练学到的特征直接冲掉;而任务头是随机初始化的,需要快速收敛。
分层冻结是在数据量特别少的时候用的:冻结骨干的前若干层(这些层提取的是通用低级特征),只训后面几层加任务头。冻结层数取决于目标域和源域的分布距离,分布越接近,可以冻结得越少。我一般从冻结前1/3开始试。
分层学习率衰减是介于两者之间的方案:不冻结,但让浅层学习率远小于深层,比如每往浅一层学习率乘0.5或者0.8。这样既保留了微调能力,又避免浅层通用特征被破坏。
实际项目里我通常是组合使用:骨干浅层冻结、深层小学习率、任务头正常学习率,再配一个短warmup。这个组合在小样本场景下最稳,不容易在训练前期就把预训练权重毁掉。有个细节容易忽略——BatchNorm层的统计量。多任务联合训练时,不同任务的数据分布会污染共享BN的running mean/var,导致推理阶段行为异常。我的处理方式是骨干里的BN全部冻结(只保留推理模式),换成GroupNorm或者LayerNorm更省心;如果必须用BN,至少保证每个batch里各任务样本比例稳定。
2.3 损失权重:从手调到自适应
多任务训练里最玄学的部分就是损失权重。三个任务,损失量纲可能差着两个数量级,直接相加等于让量级大的任务独占梯度。
最朴素的做法是手动调权重。我的经验是先做损失归一化,把每个任务的loss除以它训练初期的滑动平均值,让它们都落到1附近,再手动给权重。这样至少起点是公平的,调起来有方向感。
不确定性加权是更优雅的方案,让模型自己学权重。核心思路是给每个任务引入一个可学习的噪声参数σ,损失写成L_i / (2σ_i²) + log σ_i。直观理解是:任务越难学、噪声越大,σ越大,权重自动降低;同时log项防止σ无限增大导致任务被彻底忽略。实现上一般用log σ作为可学习参数来保证σ恒正。
GradNorm的思路是看梯度而不是看loss,让各任务在共享层上的梯度范数以相近的速度下降,避免某个任务训得太快霸占共享层。实现复杂度高一些,需要反向传播两次,但对任务不均衡的场景效果更明显。
我的建议是:先用损失归一化加手动权重跑通,再考虑不确定性加权。不确定性加权虽然省事,但它学出来的权重不一定是业务想要的——比如业务上辅助任务本来就该弱一点,模型可能给它更高的权重,最后还得手动干预。
2.4 任务采样:被严重低估的一环
这件事很多教程不讲,但实际影响巨大。多任务训练时,如果每个batch简单地把各任务数据按1:1:1混合,样本量大的任务会被严重欠采样,样本量小的任务被反复过拟合。反过来如果按数据量比例采样,小任务几乎学不动。
我常用的是温度采样:任务i的采样概率p_i = n_i^α / Σ n_j^α,α在0到1之间,α=1是按数据量比例,α=0是均分。给个具体例子,三个任务的样本量分别是12万、3万、1.5万:
| α取值 | 任务A概率 | 任务B概率 | 任务C概率 | 适用场景 |
|---|---|---|---|---|
| 1.0 | 0.727 | 0.182 | 0.091 | 任务量级接近、小任务数据也够 |
| 0.5 | 0.539 | 0.270 | 0.191 | 常用默认值,兼顾均衡与效率 |
| 0.0 | 0.333 | 0.333 | 0.333 | 小任务数据极少,需要重点照顾 |
我一般从α=0.5开始,观察小任务的验证指标。如果小任务收敛太慢,往0.3调;如果大任务指标明显下滑,往0.7调。这个参数比学习率更值得花时间调,因为它直接决定了共享层看到的数据分布。
3. 核心细节解析与实操要点
3.1 标签空间对齐:多任务的第一道坎
多任务训练的输入是"一条样本 + 多个标签",现实里最麻烦的是标签缺失。分类任务有标签、抽取任务没标、相关性任务只标了一部分,这种情况非常普遍。
我的处理分三层。
第一层是掩码机制。每个任务算loss的时候乘一个0/1掩码,没标签的样本不贡献梯度。这个实现最简单,但要注意:如果某个任务在一个batch里全都没标签,它的loss是0,梯度也是0,等于这个batch白采了。所以采样器要保证每个batch至少包含该任务的正样本。
第二层是任务采样与样本采样解耦。先按温度采样选任务,再在该任务的有标签样本池里采样本。这样避免了"按样本采、结果采到的样本恰好没这个任务的标签"的浪费。
第三层是部分监督的降级方案。当某任务标签覆盖率低于30%时,我会把它从"联合训练任务"降级为"辅助正则任务",给它一个很小的固定权重,只当正则项用,不指望它产出可用的预测结果。
这里有个实操心得:别把标签缺失当成异常,要当成常态去设计数据管道。我第一版代码里默认所有样本三标签齐全,结果上线前的真实数据一跑,70%的样本缺标签,整个采样逻辑推倒重来。
3.2 共享层的"表示污染"与隔离技巧
多任务共享底层,最大的隐患不是梯度冲突,而是表示污染——某个任务的噪声特征混进了共享表示,反而拖累其他任务。典型场景是某个任务的标注质量差、标签噪声大,它的梯度会持续往共享层注入错误信号。
应对手段我总结了几个:
梯度裁剪分任务做。不要全局裁剪,而是每个任务的梯度单独裁剪到同一个范数上限,再做聚合。这样能防止某个任务的异常大梯度一次性污染共享层。
梯度手术。算各任务梯度在共享层的投影,如果两个任务梯度夹角大于90度(余弦相似度为负),就把其中一个梯度中与另一个冲突的分量投影掉再相加。这招对缓解跷跷板效果明显,代价是每个step要多算几次投影,训练速度大概慢20%到30%。我一般只在检测到明显冲突的层上启用。
任务专属的归一化层。共享层的输出进入任务头之前,给每个任务单独挂一个LayerNorm。这个小改动成本极低,但能有效隔离不同任务的激活分布,是我现在默认会加的一层。
噪声标签任务的降权。如果一个任务的数据质量明显差,与其花时间去清洗,不如直接给它一个衰减权重,让它随着训练逐步退到很低的水平,相当于软性地退出联合训练。
3.3 学习率与warmup的配置逻辑
预训练权重的加载,让学习率配置变得比单任务更微妙。
骨干学习率我一般按预训练数据的规模来定:源数据规模越大、预训练越充分,骨干学习率就该越小。因为大模型在大数据上学到的表示更"珍贵",轻易改动反而丢信息。经验值是骨干学习率取任务头的1/50到1/100。
warmup是必须的,尤其在任务头是多层结构的时候。前几百步任务头的参数还是随机的,梯度又大又乱,如果直接全速训练,会通过反向传播把共享层也带乱。我的做法是前300到500步线性warmup,warmup期间任务头的学习率从0线性升到目标值,骨干保持一个极小的常数学习率。
还有一个细节是学习率衰减策略。多任务场景下我不太用step decay,因为各任务收敛节奏不一致,硬阶梯式衰减容易在某个任务还没收敛时就把学习率砍了。用cosine衰减配一个比较长的周期更稳,最后留一个很小的下限(比如初始值的1%)做精细调整。
3.4 训练监控:该盯哪几个指标
多任务训练的调试,靠肉眼看总loss是完全不够的,因为总loss下降完全可能是大任务拉动、小任务在恶化。我会专门做几个监控项:
单任务loss曲线,每个任务单独画,同时画它的滑动平均和方差。方差突然变大往往意味着该任务开始和别的任务抢特征了。
单任务验证指标,每个epoch在各自的验证集上算,跟单任务基线并排对比。这一项是判断有没有负迁移的唯一可靠依据。
梯度余弦相似度,按层采样统计共享层上各任务梯度的夹角。持续为负说明冲突,接近1说明任务高度冗余(这时候多任务收益有限,可以考虑合并任务头)。
专家激活分布(如果用MMoE),看门控输出的熵和最大值。熵快速下降意味着塌缩,需要加负载均衡损失。
共享层特征的有效秩或者特征方差。这个指标偏研究向,但很能说明问题:如果共享层输出在某个维度上方差趋近于0,说明这个维度被压死了,表示能力在退化。
4. 实操过程与核心环节实现
4.1 环境与数据管道的准备
代码基于PyTorch风格写,结构上分成五块:数据管道、骨干加载、任务头、损失聚合、训练循环。数据管道我建议先写死一个配置文件,把任务名、样本数、标签覆盖率、采样温度、损失权重全列进去,避免后面改参数改到代码里到处都是。
# 任务配置示例 TASKS = { "cls": {"num_samples": 120000, "label_ratio": 1.00, "weight": 1.0, "alpha": 0.5}, "kw": {"num_samples": 30000, "label_ratio": 0.60, "weight": 0.5, "alpha": 0.5}, "rel": {"num_samples": 15000, "label_ratio": 0.35, "weight": 0.3, "alpha": 0.5}, }这个配置表看着简单,作用很大:后面调采样概率、算有效样本量、做权重归一化,全靠它。我第一次做的时候把这些参数散落在各个类里,调一次参要改五个文件,效率极低。
数据加载器要支持按任务独立采样。做法是给每个任务建一个Dataset,然后用一个自定义的BatchSampler按温度概率从任务池里抽任务、再从该任务的样本池里抽batch。这里注意一个坑:如果直接给DataLoader配多个Dataset再concat,采样是均匀的,温度采样就失效了,必须自己实现BatchSampler。
4.2 骨干加载与分层学习率配置
骨干加载的关键不是"能加载上",而是加载后能识别出哪些层属于哪一层级。我的做法是按参数名前缀分组,给每组配一个学习率倍率。
import torch from torch import nn def build_param_groups(backbone, heads, backbone_lr=2e-5, head_lr=1e-3): groups = [] # 浅层冻结或极低学习率 for name, p in backbone.named_parameters(): if name.startswith(("stem", "layer1", "layer2")): p.requires_grad = False # 浅层冻结 elif name.startswith(("layer3", "layer4")): groups.append({"params": [p], "lr": backbone_lr * 0.5}) else: groups.append({"params": [p], "lr": backbone_lr}) groups.append({"params": heads.parameters(), "lr": head_lr}) return groups这段代码里有两个我自己踩过的点。一是冻结的参数仍然会占显存,只是不参与反向传播,省的是计算不是显存,别指望靠冻结解决显存不足。二是冻结层里的BN必须在推理模式,否则前向还是会更新running statistics,把预训练统计量搞乱。要么统一.eval(),要么直接换成GroupNorm。
学习率的量级我按这个原则定:骨干学习率取预训练阶段最终学习率的1/10左右,任务头取骨干的50到100倍。如果发现训练前几个epoch骨干的权重变化幅度超过5%,说明学习率过大,要往下调。
4.3 任务头与损失聚合的实现
任务头的结构不用太复杂,两三层全连接或者一个小型注意力池化就够。核心是每个任务头前面挂一个独立的归一化层,把共享层的输出先标准化再进任务分支。
class MultiTaskHead(nn.Module): def __init__(self, in_dim, task_dims): super().__init__() self.norms = nn.ModuleDict({t: nn.LayerNorm(in_dim) for t in task_dims}) self.heads = nn.ModuleDict({ t: nn.Sequential( nn.Linear(in_dim, in_dim // 2), nn.GELU(), nn.Dropout(0.1), nn.Linear(in_dim // 2, d) ) for t, d in task_dims.items() }) def forward(self, shared_feat, task_name): return self.heads[task_name](self.norms[task_name](shared_feat))损失聚合部分,我建议把"归一化"和"加权"分成两步写,便于单独调试:
class MultiTaskLoss(nn.Module): def __init__(self, task_names, init_weights): super().__init__() self.log_sigma = nn.Parameter(torch.zeros(len(task_names))) # 不确定性加权 self.task_names = task_names self.register_buffer("ema", torch.ones(len(task_names))) self.base_weights = init_weights def forward(self, losses, use_uncertainty=False): losses = torch.stack(losses) self.ema = 0.98 * self.ema + 0.02 * losses.detach() # 滑动平均归一化 normed = losses / (self.ema + 1e-8) if use_uncertainty: w = torch.exp(-self.log_sigma) / 2 total = (w * normed).sum() + self.log_sigma.sum() else: total = (torch.tensor(self.base_weights) * normed).sum() return total滑动平均归一化这一步很关键,它保证各任务loss的量级始终可比。如果直接除当前batch的loss,噪声会很大;如果不除,量级差两个数量级时小任务直接被淹没。
4.4 梯度冲突处理的最小实现
梯度手术不需要写得很复杂,核心就两步:算各任务梯度在共享参数上的投影,冲突时做正交化。
def pcgrad_step(shared_params, task_grads): """task_grads: List[List[Tensor]],每个任务在共享参数上的梯度""" g = [gr.clone() for gr in task_grads[0]] for i in range(1, len(task_grads)): gi = task_grads[i] dot = sum((a * b).sum() for a, b in zip(g, gi)) if dot < 0: norm_sq = sum((b * b).sum() for b in gi) + 1e-12 proj = (dot / norm_sq).clamp(max=0) gi = [b - proj * a for a, b in zip(g, gi)] g = [a + b for a, b in zip(g, gi)] for p, gr in zip(shared_params, g): p.grad = gr / len(task_grads)实际用的时候我不会全程开启,因为每个step要做多次反向传播,速度掉得厉害。我的策略是:前20%的step开梯度手术,帮模型度过冲突最激烈的阶段;后面关掉,用普通加权求和。这个折中的实测效果跟全程开启差别很小,但训练时间能省20%以上。
4.5 训练循环与早停策略
训练循环里,我加了三处非标准逻辑:任务顺序轮换、按任务分别记录最佳指标、以及一个显存的保险机制。
for epoch in range(epochs): model.train() for batch in sampler: task = batch["task"] out = model(batch["x"]) loss = criterion(out, batch["y"], task) scaler.scale(loss).backward() # 梯度裁剪:按参数范数,阈值 1.0 scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True) # 每个任务独立记录指标 tracker.update(task, out.detach(), batch["y"])早停这块,我用的是按任务加权的复合指标,而不是总loss。原因是总loss被大任务主导,它不涨了不代表都收敛了。复合指标给主任务高权重、辅助任务低权重,任一个任务连续三个epoch明显恶化就触发回退,回退到该任务上表现最好的checkpoint。
显存保险机制指的是动态调整batch里的任务数:如果检测到显存使用超过阈值,就把当前batch里样本量最大的任务拆成两个小batch分步前向。这招在混合长度序列的任务里特别管用,能避免OOM导致的训练中断。
5. 常见问题与排查技巧实录
5.1 负迁移:怎么确认,怎么止损
负迁移的典型表现是:多任务模型在主任务验证集上,比单任务微调还低。注意一定要跟同样的预训练权重 + 同样的数据 + 同样的训练步数的单任务版本对比,否则结论不可靠。
确认之后,我会按这个顺序排查:
先看任务相关性。把每个任务的标签分布和共享层输出的相似度算一下,如果两个任务的样本在特征空间里几乎不重叠,硬共享本身就是错的,要么拆模型,要么换成MMoE。
再看损失量纲。检查各任务loss是否做了归一化。这一步能把80%的"伪负迁移"解决掉——很多情况根本不是任务冲突,就是一个loss量级太大把共享层带偏了。
然后看训练步数分配。主任务是不是被采样得太少?把温度系数往1.0调,或者给主任务一个更高的最低采样比例。
最后才考虑结构改造,比如加梯度手术、上专家混合。顺序别反,先改数据和权重,再改结构,因为结构改造成本最高、定位最难。
5.2 任务跷跷板:一个涨一个必掉
这几乎是多任务训练的默认状态,尤其是在两个任务标签定义有交集但不完全一致的时候。我遇到过最严重的一次,分类任务涨了2个点,抽取任务掉了5个点,两边怎么调权重都在跷跷板。
最后起作用的组合是三招:
一,把冲突任务的头部分开得更彻底。原来两个任务共享最后一层transformer block,改成各自独占一个block,只共享前面几层。参数量增加不多,冲突立刻缓解。
二,任务交替更新。不是每个step都更新所有任务,而是按任务轮换,一个batch只算一个任务的loss。这样共享层每次只接收一个方向的梯度,冲突从"同时发生"变成"交替发生"。训练总步数要相应增加,但稳定性提升明显。
三,给冲突任务设一个"保护期"。训练前期只训主任务,等共享层稳定了再引入辅助任务,引入时用很小的权重慢慢加。这个技巧本质上是把多任务变成"渐进式多任务",牺牲一点效率换稳定性。
5.3 训练不稳定与显存爆炸
不稳定的表现是loss抖动大、梯度范数忽大忽小、偶尔出现NaN。我总结的原因按出现频率排序:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| loss突然NaN | 混合精度下loss scale溢出 | 降低初始scale,或改用bfloat16 |
| 梯度范数剧烈抖动 | 某任务标签噪声大 | 该任务梯度单独裁剪到更小阈值 |
| 显存逐step上升 | 计算图未释放,通常是监控代码持有引用 | 监控指标一律用.detach() |
| 显存陡增 | batch里混入了超长序列 | 按长度分桶,或动态拆分batch |
| 指标先涨后崩 | 学习率过大破坏预训练权重 | 骨干学习率降到1/5,加长warmup |
显存这块我补充一个具体经验:多任务模型的显存峰值往往不在前向,而在多个任务头的loss反传叠加时。如果用梯度累积模拟大batch,注意累积的是梯度,不是激活,显存并不会因为累积步数增加而暴涨;但如果为了多任务并行把多个任务的输出都留在显存里,峰值会明显上去。我的做法是逐个任务算loss并反传,累积完再step,峰值能降下来不少。
5.4 上线前的验收清单
模型训完不等于能用,我一般跑一遍这个清单:
- 逐任务对比单任务基线,任何一个任务明显低于基线就要给出解释,不能默认"辅助任务差点没事"。
- 关掉辅助任务做消融,确认收益确实来自多任务,而不是来自更长的训练时间或更大的有效batch。
- 标签缺失场景回放,用真实数据里标签不完整的样本跑一遍,看预测是否退化。
- 推理一致性检查,训练用混合精度、推理用fp32时,输出差异是否在可接受范围。
- 单任务独立推理验证,多任务模型可以只调某个头输出,确认不会因为其他头的影响产生异常值。
- 长尾样本抽查,多任务模型容易在长尾上表现更差,因为共享层被高频模式主导。
6. 我个人在几个项目里沉淀下来的判断
这套技术栈最容易被低估的不是模型结构,而是任务定义本身。我见过太多次,两个任务看着相关,实际标签定义互相矛盾,比如一个任务的"正例"在另一个任务里被标成"负例",这种冲突再怎么调结构和权重都救不回来。所以我现在做多任务之前,会先花半天时间把各任务的标签定义摊开逐条对照,找出显式矛盾的样本,这一步的收益比调参大得多。
另一个体会是关于训练时长的分配。多任务模型的收敛曲线跟单任务不一样,它不是单调上升然后平台,而是经常出现"这个任务涨、那个任务平"的交替。所以不要用过短的训练预算去判断方案好坏,我一般会把多任务版本训练到单任务版本收敛步数的1.5倍左右,再下结论,否则容易误杀。
最后分享一个很省事的小技巧:给每个任务单独存checkpoint。训练的时候按任务分别记录最佳模型,最后如果某个任务在多任务模型里始终不如单任务,就直接把它摘出来单独训一个,主任务继续用多任务模型。混合部署听起来不优雅,但在实际项目里,它往往比死磕一个"完美统一"的模型更快拿到可用结果。这套东西没有标准答案,能稳定复现、能解释清楚收益来源,就已经比大多数方案扎实了。