
简介本资源是阿里云天池中文自然语言处理预训练模型泛化能力挑战赛的完整解决方案面向具备一定深度学习基础、希望系统实践中文NLP多任务建模的开发者与参赛者。方案围绕TNEWS新闻分类、OCEMOTION情感分析、OCNLI自然语言推理三大任务展开重点处理多标签不均衡数据并给出基于预训练模型的深度学习实现路径。压缩包共18个文件约6.47MB包含6个csv数据文件、4个Python脚本、3个Shell运行脚本以及说明文档、依赖清单与项目说明等覆盖数据生成、模型训练、预测与提交全流程。已有89人学习下载。读者可从中获取赛题数据处理思路、多标签不均衡的应对策略、分类模型训练与推理脚本以及可直接复用的工程目录结构适合作为中文NLP竞赛入门与方案复现的参考。1. 天池中文NLP三任务挑战赛从TNEWS到OCNLI泛化能力到底卡在哪如果你最近在搜「阿里云天池」「TNEWS」「OCEMOTION」这几个词大概率是遇到了同一个场景一个比赛里塞了三个中文任务——TNEWS新闻分类、OCEMOTION情感分析、OCNLI自然语言推理还要求用同一套预训练模型骨架去扛。很多人第一反应是直接上roberta中文预训练模型微调结果单任务刷得挺高一合起来泛化就崩尤其是OCNLI的准确率死活上不去。问题往往不在模型本身而在数据层TNEWS是短文本多分类且类别不均衡OCEMOTION是六类情感且标注噪声大OCNLI是句对推理且正负样本比例悬殊。这三者的标签空间、文本长度、语义粒度完全不同硬拼在一起训练模型会偏向样本多的任务和类别。这篇笔记就按我实际打这类比赛的路子把多标签不均衡数据处理、三任务统一建模、以及泛化能力验证的完整链路拆开讲适合已经会跑transformers微调、但卡在「单任务还行、多任务就翻车」的从业者。2. 三任务数据先摸清底细TNEWS、OCEMOTION、OCNLI的标签分布与长度差异2.1 为什么不能直接把三个任务拼成一个DataLoaderTNEWS是新闻标题短文本分类通常十几到三十几个字类别数在十几个量级且头部类别占比可能超过三成尾部类别只有几十条。OCEMOTION是情感六分类文本偏口语化、带表情符号和网络用语标注一致性比新闻分类差不少。OCNLI是句对推理输入是「前提假设」输出是三分类蕴含、中立、矛盾正负样本比例经常是2:1甚至更悬殊。这三个任务如果直接拼成一个batch模型会学到「短文本走TNEWS头、长文本走OCNLI头」这种捷径而不是真正理解语义。常见做法是共享底层编码器但每个任务独立采样、独立计算loss再按任务权重加权回传。2.2 用几行pandas把不均衡程度量化出来动手之前先别急着写模型把三个任务的数据分布跑一遍。下面这段代码是我每次打天池NLP比赛必跑的第一步输出每个任务的类别频次和文本长度分位数。import pandas as pd import numpy as np def profile_task(df, text_col, label_col, name): print(f {name} ) # 类别分布 vc df[label_col].value_counts() print(类别数:, len(vc)) print(头部3类占比:, (vc.head(3).sum() / len(df)).round(4)) print(尾部3类样本数:, vc.tail(3).values) # 文本长度分位数 lens df[text_col].astype(str).str.len() print(长度分位 P50/P90/P99:, np.percentile(lens, [50, 90, 99]).round(1)) # 不均衡比 print(最大/最小类频次比:, round(vc.max() / max(vc.min(), 1), 2)) print() # 假设三个任务已经读成DataFrame # profile_task(tnews_df, text, label, TNEWS) # profile_task(ocemotion_df, text, label, OCEMOTION) # profile_task(ocnli_df, sentence1, label, OCNLI)逻辑说明value_counts拿类别频次头部占比看是否超过30%尾部样本数低于100的基本要特殊处理。长度分位数决定max_len设多少——TNEWS一般64够OCNLI建议128起步。不均衡比超过20倍就必须上重采样或focal loss否则模型直接躺平预测头部类。参数说明text_col和label_col按实际列名改OCNLI是句对长度要算两句拼接后的总长。这一步跑完你会对「哪个任务最难」有个数通常OCNLI的标签不均衡最隐蔽因为三分类看起来还好但「中立」类往往占一半以上。2.3 三个任务的max_len和batch_size怎么定TNEWS短max_len64batch_size可以开到64甚至128。OCEMOTION长度中等max_len96batch_size32。OCNLI最长max_len128batch_size16因为句对编码显存占用翻倍。如果共享编码器建议按任务分别建DataLoader每个step轮流取一个任务的batch而不是混在一起。这样每个任务的梯度更新次数可控也方便单独调任务权重。3. 多标签不均衡数据处理重采样、focal loss与标签平滑的组合拳3.1 先分清「多标签」和「多分类不均衡」的区别标题里写的是「多标签不均衡数据处理」但TNEWS和OCEMOTION其实是多分类每个样本一个标签OCNLI也是三分类。真正的多标签场景是「一个样本同时属于多个类」。这里的不均衡处理思路是通用的要么在采样层面让每个类出现频次接近要么在loss层面给尾部类更高权重。我一般两个都上采样做粗调loss做细调。3.2 用WeightedRandomSampler做任务内类别均衡PyTorch的WeightedRandomSampler是最省事的方案按类别频次倒数给每个样本算权重采样时尾部类被抽中的概率提高。import torch from torch.utils.data import WeightedRandomSampler from collections import Counter def make_balanced_sampler(labels): # labels: list of int count Counter(labels) # 每个类权重 总样本数 / 该类样本数 class_weight {c: len(labels) / n for c, n in count.items()} # 每个样本权重 其类别的权重 sample_weights [class_weight[y] for y in labels] sampler WeightedRandomSampler( weightstorch.DoubleTensor(sample_weights), num_sampleslen(sample_weights), replacementTrue ) return sampler # train_loader DataLoader(dataset, batch_size32, samplermake_balanced_sampler(train_labels))逻辑说明replacementTrue表示有放回采样尾部类会被重复抽到一个epoch内总样本数不变但分布更均匀。注意验证集绝对不能用这个sampler否则评估指标会虚高。参数说明num_samples一般设成原始训练集大小想更激进可以设成头部类样本数×类别数。如果某个类只有个位数样本采样后会严重过拟合这种类建议直接合并或删掉。3.3 focal loss在OCEMOTION上的参数怎么调OCEMOTION的难点是标注噪声和类别重叠比如「开心」和「惊讶」在某些句子里边界模糊。focal loss通过降低易分样本的loss权重让模型聚焦难样本。我一般用gamma1.5到2.0alpha按类别频次倒数归一化。import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alphaNone, gamma2.0, reductionmean): super().__init__() self.alpha alpha # tensor, shape[num_classes] self.gamma gamma self.reduction reduction def forward(self, logits, targets): # logits: [B, C], targets: [B] ce F.cross_entropy(logits, targets, weightself.alpha, reductionnone) pt torch.exp(-ce) # 预测正确的概率 focal ((1 - pt) ** self.gamma) * ce if self.reduction mean: return focal.mean() return focal.sum() # alpha按类别频次倒数归一化 # counts np.bincount(train_labels) # alpha torch.tensor(1.0 / counts, dtypetorch.float) # alpha alpha / alpha.sum() * len(counts) # criterion FocalLoss(alphaalpha, gamma1.8)逻辑说明pt是模型对真实类的预测概率越容易分对的样本pt越接近1(1-pt)^gamma就越小loss被压低。alpha给尾部类更大权重和采样形成互补。参数说明gamma0时退化成带权cross entropy。gamma太大3会导致训练不稳定loss震荡。alpha不要设得太极端归一化后最大最小比值控制在10倍以内否则头部类直接学不动。3.4 标签平滑在OCNLI上的作用OCNLI的「中立」类样本多且边界模糊硬标签会让模型过度自信。标签平滑把one-hot的1改成0.9其余类分0.1/(C-1)能缓解过拟合。在transformers里直接改loss计算即可或者用label_smoothing0.1的CrossEntropyLoss。提示标签平滑和focal loss不要同时上两者都在改loss分布叠加后容易欠拟合。OCNLI用标签平滑OCEMOTION用focal lossTNEWS用带权CE这是我试下来最稳的组合。4. 三任务统一建模共享编码器加任务头的结构与训练策略4.1 共享roberta中文预训练模型底座的选型理由三个任务都是中文语义理解共享底层编码器能学到通用表示任务头各自轻量。底座选roberta中文预训练模型如roberta-wwm-ext或类似中文预训练权重比bert在长文本和语义推理上更稳。如果显存够base版够用追求极致可以上large但OCNLI的batch_size会压到8以下训练时间翻倍。我一般先用base跑通全流程再考虑换large。4.2 多任务模型结构一个encoder加三个classification headimport torch import torch.nn as nn from transformers import AutoModel, AutoConfig class MultiTaskModel(nn.Module): def __init__(self, model_name, num_labels_dict, dropout0.1): super().__init__() self.config AutoConfig.from_pretrained(model_name) self.encoder AutoModel.from_pretrained(model_name, configself.config) hidden self.config.hidden_size self.dropout nn.Dropout(dropout) # 三个任务头 self.heads nn.ModuleDict({ task: nn.Linear(hidden, num_labels) for task, num_labels in num_labels_dict.items() }) def forward(self, input_ids, attention_mask, token_type_idsNone, tasktnews): outputs self.encoder( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) # 取[CLS]向量 cls outputs.last_hidden_state[:, 0, :] cls self.dropout(cls) logits self.heads[task](cls) return logits # model MultiTaskModel( # hfl/chinese-roberta-wwm-ext, # {tnews: 15, ocemotion: 6, ocnli: 3} # )逻辑说明AutoModel加载预训练权重heads是三个独立线性层。forward时通过task参数选择走哪个头。OCNLI需要token_type_ids区分前提和假设TNEWS和OCEMOTION不用。参数说明num_labels_dict按实际类别数填TNEWS常见14到15类OCEMOTION六类OCNLI三类。dropout设0.1到0.3数据量小就调大。如果三个任务共享encoder导致互相干扰可以给每个任务加一层adapter但会增参数量。4.3 任务采样权重和loss加权怎么设训练时每个step轮流从三个任务的DataLoader取batch或者按比例混合。我一般按任务难度设采样比TNEWS : OCEMOTION : OCNLI 1 : 1 : 1.5因为OCNLI最难。loss加权上OCNLI给1.2另外两个给1.0。如果某个任务指标明显掉先调它的采样比再调loss权重。# 简化训练循环示意 task_weights {tnews: 1.0, ocemotion: 1.0, ocnli: 1.2} optimizer torch.optim.AdamW(model.parameters(), lr2e-5, weight_decay0.01) for step, batch_dict in enumerate(multi_task_loader): total_loss 0 for task, (batch, criterion) in batch_dict.items(): logits model(batch[input_ids], batch[attention_mask], batch.get(token_type_ids), tasktask) loss criterion(logits, batch[labels]) total_loss task_weights[task] * loss total_loss.backward() optimizer.step() optimizer.zero_grad()逻辑说明每个step累加三个任务的加权loss再统一backward这样encoder收到的是三个任务的联合梯度。如果显存不够可以一个step只跑一个任务轮流更新。参数说明lr用2e-5是roberta微调的常规起点OCNLI可以降到1e-5避免震荡。weight_decay 0.01防过拟合。warmup比例设0.1训练3到5个epoch看验证集。4.4 验证集怎么按任务分别评估每个epoch结束三个任务各自在验证集上算指标TNEWS看macro-F1因为不均衡OCEMOTION看加权F1OCNLI看准确率。不要只看总平均否则一个任务崩了会被另外两个掩盖。我一般记录三个任务的指标取macro-F1和OCNLI准确率的调和平均作为模型选择依据。5. 避坑与排查三任务联合训练里最容易翻车的5个点5.1 现象OCNLI准确率卡在50%上下loss不降原因句对输入没有正确传token_type_ids或者两句拼接时没加[SEP]分隔模型把前提和假设当成一句话。解决检查tokenizer的encode_plus是否传了text_pair确认token_type_ids前段为0后段为1。用tokenizer.decode打印一条样本肉眼确认[SEP]位置。5.2 现象TNEWS尾部类F1为0模型全预测头部类原因采样权重不够或loss权重没加模型直接躺平。解决先跑2.2的分布分析确认尾部类样本数。如果少于50条用WeightedRandomSampler把采样比拉到10倍以上同时alpha给尾部类5到10倍权重。还不行就合并极尾部类。5.3 现象OCEMOTION验证集F1高但测试集掉10个点原因验证集和测试集分布不一致或者标签平滑/focal loss在验证集上过拟合。解决检查验证集是否也做了重采样绝对不能确认验证集是原始分布。如果测试集噪声大降低focal loss的gamma到1.0加标签平滑0.05。5.4 现象三个任务一起训单任务指标比单独训低3到5个点原因任务间负迁移共享encoder被某个任务主导。解决给每个任务加adapter层或者用PCGrad做梯度投影。简单做法是调采样比让难任务多跑同时把encoder的lr调低到1e-5任务头lr保持2e-5。5.5 现象训练到第2个epoch loss突然变nan原因focal loss的alpha权重过大导致梯度爆炸或者OCNLI的max_len128时显存溢出后梯度异常。解决梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)alpha归一化后检查最大值是否超过10。显存不够就降batch_size或max_len。6. 泛化能力验证与提分技巧对抗验证加伪标签的实操6.1 用对抗验证判断测试集和训练集是否同分布比赛里最怕的是测试集分布和训练集不一样模型泛化直接崩。对抗验证的做法是把训练集和测试集混在一起训练一个二分类器判断样本来自哪个集合。如果AUC明显高于0.5说明分布有差异需要针对性处理。from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score from sklearn.feature_extraction.text import TfidfVectorizer import numpy as np def adversarial_validation(train_texts, test_texts): # 构造二分类数据 X train_texts test_texts y [0] * len(train_texts) [1] * len(test_texts) # TF-IDF特征 vec TfidfVectorizer(max_features5000) X_vec vec.fit_transform(X) # 交叉验证AUC clf LogisticRegression(max_iter1000) auc cross_val_score(clf, X_vec, y, cv3, scoringroc_auc).mean() print(对抗验证AUC:, round(auc, 4)) return auc # auc adversarial_validation(tnews_train_texts, tnews_test_texts)逻辑说明AUC越接近0.5说明分布越一致超过0.7就要警惕。如果某个任务AUC高检查测试集文本长度、类别词分布是否偏移。参数说明max_features按数据量调5000到20000。cv3够用数据大用5。这个验证不直接提分但能告诉你该不该上伪标签。6.2 伪标签在OCNLI上的使用条件和阈值伪标签适合测试集分布和训练集接近、且模型在验证集上已经比较稳的情况。做法是用训练好的模型预测测试集取置信度高于阈值的样本加入训练。OCNLI上我一般取softmax概率0.9的样本阈值低于0.8会引入噪声。# 伪标签生成示意 model.eval() pseudo_samples [] with torch.no_grad(): for batch in test_loader: logits model(batch[input_ids], batch[attention_mask], batch.get(token_type_ids), taskocnli) probs torch.softmax(logits, dim-1) max_probs, preds probs.max(dim-1) # 只保留高置信度 mask max_probs 0.9 for i in range(len(preds)): if mask[i]: pseudo_samples.append({ text: batch[raw_text][i], label: preds[i].item() }) print(伪标签样本数:, len(pseudo_samples))逻辑说明高置信度样本加入训练能扩大数据覆盖但阈值不能低。伪标签一般只跑一轮多轮会放大噪声。参数说明阈值0.9是OCNLI的经验值OCEMOTION可以降到0.85因为情感标注本身模糊。伪标签样本在loss里权重设0.5不要和真实样本同权。6.3 模型融合三个任务各自训多个seed再投票单模型泛化不稳最稳的提分手段是融合。每个任务用3到5个不同seed训roberta推理时对logits取平均。OCNLI上三seed融合通常能涨1到2个点。如果时间够再叠一个不同底座比如bert和roberta各训一个做异质融合。6.4 我踩过的最大坑验证集指标虚高有次OCEMOTION验证集F1到0.72测试集只有0.61。排查发现验证集里「开心」类占比比测试集高15个点模型在验证集上被头部类带偏了。后来每次划分验证集都按类别分层采样并且用对抗验证确认分布。这个习惯让我后面几次比赛再没出现过验证集和测试集差10个点的情况。打这类多任务比赛数据分布比模型结构重要得多先把三个任务的分布摸透再谈泛化。希望帮到你。本文还有配套的精品资源点击获取