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.00.7270.1820.091任务量级接近、小任务数据也够0.50.5390.2700.191常用默认值兼顾均衡与效率0.00.3330.3330.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步线性warmupwarmup期间任务头的学习率从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_lr2e-5, head_lr1e-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_uncertaintyFalse): 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(max0) 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_noneTrue) # 每个任务独立记录指标 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。训练的时候按任务分别记录最佳模型最后如果某个任务在多任务模型里始终不如单任务就直接把它摘出来单独训一个主任务继续用多任务模型。混合部署听起来不优雅但在实际项目里它往往比死磕一个完美统一的模型更快拿到可用结果。这套东西没有标准答案能稳定复现、能解释清楚收益来源就已经比大多数方案扎实了。
阅读完成 · 觉得有帮助?