简介:本资源是阿里云天池中文自然语言处理预训练模型泛化能力挑战赛的完整解决方案,面向具备一定深度学习基础、希望系统实践中文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 为什么不能直接把三个任务拼成一个DataLoader
TNEWS是新闻标题短文本分类,通常十几到三十几个字,类别数在十几个量级,且头部类别占比可能超过三成,尾部类别只有几十条。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_len=64,batch_size可以开到64甚至128。OCEMOTION长度中等,max_len=96,batch_size=32。OCNLI最长,max_len=128,batch_size=16,因为句对编码显存占用翻倍。如果共享编码器,建议按任务分别建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( weights=torch.DoubleTensor(sample_weights), num_samples=len(sample_weights), replacement=True ) return sampler # train_loader = DataLoader(dataset, batch_size=32, sampler=make_balanced_sampler(train_labels))逻辑说明:replacement=True表示有放回采样,尾部类会被重复抽到,一个epoch内总样本数不变但分布更均匀。注意验证集绝对不能用这个sampler,否则评估指标会虚高。
参数说明:num_samples一般设成原始训练集大小,想更激进可以设成头部类样本数×类别数。如果某个类只有个位数样本,采样后会严重过拟合,这种类建议直接合并或删掉。
3.3 focal loss在OCEMOTION上的参数怎么调
OCEMOTION的难点是标注噪声和类别重叠,比如「开心」和「惊讶」在某些句子里边界模糊。focal loss通过降低易分样本的loss权重,让模型聚焦难样本。我一般用gamma=1.5到2.0,alpha按类别频次倒数归一化。
import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=None, gamma=2.0, reduction='mean'): 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, weight=self.alpha, reduction='none') 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, dtype=torch.float) # alpha = alpha / alpha.sum() * len(counts) # criterion = FocalLoss(alpha=alpha, gamma=1.8)逻辑说明:pt是模型对真实类的预测概率,越容易分对的样本pt越接近1,(1-pt)^gamma就越小,loss被压低。alpha给尾部类更大权重,和采样形成互补。
参数说明:gamma=0时退化成带权cross entropy。gamma太大(>3)会导致训练不稳定,loss震荡。alpha不要设得太极端,归一化后最大最小比值控制在10倍以内,否则头部类直接学不动。
3.4 标签平滑在OCNLI上的作用
OCNLI的「中立」类样本多且边界模糊,硬标签会让模型过度自信。标签平滑把one-hot的1改成0.9,其余类分0.1/(C-1),能缓解过拟合。在transformers里直接改loss计算即可,或者用label_smoothing=0.1的CrossEntropyLoss。
提示:标签平滑和focal loss不要同时上,两者都在改loss分布,叠加后容易欠拟合。OCNLI用标签平滑,OCEMOTION用focal loss,TNEWS用带权CE,这是我试下来最稳的组合。
4. 三任务统一建模:共享编码器加任务头的结构与训练策略
4.1 共享roberta中文预训练模型底座的选型理由
三个任务都是中文语义理解,共享底层编码器能学到通用表示,任务头各自轻量。底座选roberta中文预训练模型(如roberta-wwm-ext或类似中文预训练权重),比bert在长文本和语义推理上更稳。如果显存够,base版够用;追求极致可以上large,但OCNLI的batch_size会压到8以下,训练时间翻倍。我一般先用base跑通全流程,再考虑换large。
4.2 多任务模型结构:一个encoder加三个classification head
import torch import torch.nn as nn from transformers import AutoModel, AutoConfig class MultiTaskModel(nn.Module): def __init__(self, model_name, num_labels_dict, dropout=0.1): super().__init__() self.config = AutoConfig.from_pretrained(model_name) self.encoder = AutoModel.from_pretrained(model_name, config=self.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_ids=None, task='tnews'): outputs = self.encoder( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_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(), lr=2e-5, weight_decay=0.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"), task=task) 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看加权F1,OCNLI看准确率。不要只看总平均,否则一个任务崩了会被另外两个掩盖。我一般记录三个任务的指标,取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_len=128时显存溢出后梯度异常。解决:梯度裁剪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_features=5000) X_vec = vec.fit_transform(X) # 交叉验证AUC clf = LogisticRegression(max_iter=1000) auc = cross_val_score(clf, X_vec, y, cv=3, scoring='roc_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。cv=3够用,数据大用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"), task="ocnli") 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个点的情况。打这类多任务比赛,数据分布比模型结构重要得多,先把三个任务的分布摸透,再谈泛化。希望帮到你。
本文还有配套的精品资源,点击获取