☰
天池中文NLP三任务实战:数据不均衡与预训练模型泛化拆解
2026/10/8 1:04:50 网站建设 项目流程

简介:本资源是阿里云天池中文自然语言处理预训练模型泛化能力挑战赛的完整解决方案,面向具备一定深度学习基础、希望系统实践中文NLP多任务建模的开发者与参赛者。方案围绕TNEWS新闻分类、OCEMOTION情感分析、OCNLI自然语言推理三大任务展开,重点处理多标签不均衡数据,并给出基于预训练模型的深度学习实现路径。压缩包共18个文件,约6.47MB,包含6个csv数据文件、4个Python脚本、3个Shell脚本及说明文档、依赖清单等,覆盖数据生成、模型训练、预测与提交全流程,目录结构清晰,便于按任务模块快速定位与复现。目前已有89人学习下载。读者可从中获得赛题数据组织方式、多标签不均衡的重采样与权重调整思路、分类模型训练与推理脚本,以及可直接运行的提交方案,适合作为中文NLP竞赛入门与工程落地的参考。

1. 天池中文NLP三任务挑战赛:从数据不均衡到预训练模型泛化的实战拆解

TNEWS新闻分类、OCEMOTION情感分析、OCNLI自然语言推理,这三个任务放在同一个赛题里,最直接的问题不是模型选哪个,而是三份数据的标签分布完全不一样。TNEWS的类别相对均衡,OCEMOTION的类别极度倾斜,OCNLI的推理标签又涉及语义蕴含和中立判断的边界模糊。很多队伍在单任务上能刷到不错分数,但三个任务联合提交后总分被拉垮,根因往往出在数据处理阶段没有针对每个任务做差异化处理。

这篇文章面向的是已经跑通过BERT或RoBERTa中文预训练模型微调、准备在天池这类多任务赛题里拿名次的从业者。我会把三任务的数据清洗、不均衡处理、模型选型和泛化验证拆成可复现的步骤,重点讲清楚每个环节的参数怎么定、为什么这么定,以及我踩过的那些坑。如果你正在做中文NLP课程设计或者准备类似的多任务挑战赛,这套流程可以直接迁移。

2. 三任务数据特性与预训练模型选型:为什么不能一套预处理走到底

2.1 TNEWS、OCEMOTION、OCNLI的数据分布差异

TNEWS是短新闻标题分类,通常15到30个字,类别覆盖体育、财经、科技、娱乐等,单标签多分类。OCEMOTION是情感分析,文本长度中等,标签包括积极、消极、中性等,但实际数据里积极和消极的样本量可能是中性的好几倍。OCNLI是自然语言推理,输入是两个句子,输出是蕴含、矛盾、中立三分类,难点在于中立样本的判定标准很主观,标注一致性本身就有限。

这三个任务如果共用一套tokenizer和max_length,OCNLI会因为句子对拼接后长度翻倍而被截断,TNEWS则可能因为max_length设得太大浪费显存。常见做法是给每个任务单独配置max_length:TNEWS用64,OCEMOTION用96,OCNLI用128。这个数值不是拍脑袋,而是根据各自数据里95%分位的token长度来定的。

# 分别统计三个任务的长度分布,确定max_length import numpy as np from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') def get_length_stats(texts, tokenizer, percentile=95): lengths = [len(tokenizer.encode(t, add_special_tokens=True)) for t in texts] return { 'mean': np.mean(lengths), 'p95': np.percentile(lengths, percentile), 'max': np.max(lengths) } # 假设tnews_texts, ocemotion_texts, ocnli_pairs已加载 # ocnli_pairs是(premise, hypothesis)元组列表 tnews_stats = get_length_stats(tnews_texts, tokenizer) ocemotion_stats = get_length_stats(ocemotion_texts, tokenizer) ocnli_stats = get_length_stats( [p + '[SEP]' + h for p, h in ocnli_pairs], tokenizer ) print(f"TNEWS: {tnews_stats}") print(f"OCEMOTION: {ocemotion_stats}") print(f"OCNLI: {ocnli_stats}")

这段代码的作用是拿到每个任务的实际长度分布,p95的值向上取整到8的倍数就是比较合理的max_length。参数说明:percentile取95是经验值,取99会把max_length拉得很大,取90又可能截断太多信息。add_special_tokens=True必须开,因为[CLS]和[SEP]会占2到3个token。

2.2 RoBERTa中文预训练模型的选型理由

热搜里经常出现roberta中文预训练模型这个词,实际选型时要注意:哈工大讯飞联合发布的RoBERTa-wwm-ext和RoBERTa-wwm-ext-large是两个不同量级。base版本参数量约102M,large版本约325M。在天池这种有提交次数限制的比赛里,large版本单次推理时间长,调试阶段用base版本快速迭代,最终提交前再用large版本跑一遍是更稳妥的策略。

选RoBERTa而不是BERT的核心原因是中文RoBERTa用了全词掩码(whole word masking),对中文分词后的词边界保留更好。TNEWS里大量新闻专有名词,OCNLI里需要判断词级别的语义关系,全词掩码带来的增益在验证集上通常有0.5到1个百分点的提升。但这不是绝对的,如果比赛数据量很小,BERT和RoBERTa的差距可能被噪声淹没。

from transformers import BertForSequenceClassification, RobertaForSequenceClassification # TNEWS和OCEMOTION用单句分类头 model_tnews = BertForSequenceClassification.from_pretrained( 'hfl/chinese-roberta-wwm-ext', num_labels=15 # TNEWS类别数按实际数据调整 ) # OCNLI用句子对分类,结构相同但输入格式不同 model_ocnli = BertForSequenceClassification.from_pretrained( 'hfl/chinese-roberta-wwm-ext', num_labels=3 )

这里没有用AutoModel,而是显式指定BertForSequenceClassification,原因是RoBERTa在HuggingFace里的类名继承自Bert系列,用AutoModelForSequenceClassification也可以,但显式写出来在排查问题时更清楚加载的是哪个类。num_labels必须和实际标签数一致,TNEWS的类别数不同年份可能不同,要以比赛页面为准。

2.3 多任务学习的共享与独立策略

三任务要不要共享底层参数?我的经验是:如果三个任务的数据量都在万级以上,共享底层RoBERTa编码器、各自接分类头,通常比三个独立模型分别训练效果更好,因为底层语义表示能互相增强。但如果某个任务数据量特别小(比如OCNLI只有几千条),共享底层反而会被大任务主导,这时候给每个任务单独微调更稳。

具体实现时可以用一个共享的RoBERTa encoder加三个分类头,loss是三个任务loss的加权和。权重怎么定?简单做法是按数据量比例反比设置,数据少的任务权重高一些。更精细的做法是用不确定性加权(uncertainty weighting),但那个实现复杂度高,比赛时间紧的话不建议上。

3. 不均衡数据处理:OCEMOTION和OCNLI的标签倾斜怎么破

3.1 先量化不均衡程度再决定策略

不均衡处理最忌讳一上来就上focal loss或者过采样。先算每个任务的标签分布,看最大类和最小类的比例。如果比例在3:1以内,通常不需要特殊处理,调一下class weight就够了。如果超过10:1,才需要考虑重采样或focal loss。

from collections import Counter import pandas as pd def check_imbalance(labels, task_name): counter = Counter(labels) df = pd.DataFrame({ 'label': list(counter.keys()), 'count': list(counter.values()) }).sort_values('count', ascending=False) df['ratio'] = df['count'] / df['count'].min() print(f"\n{task_name} 标签分布:") print(df.to_string(index=False)) return df # 分别检查三个任务 tnews_dist = check_imbalance(tnews_labels, 'TNEWS') ocemotion_dist = check_imbalance(ocemotion_labels, 'OCEMOTION') ocnli_dist = check_imbalance(ocnli_labels, 'OCNLI')

输出里ratio列就是最大类和最小类的倍数关系。OCEMOTION常见的情况是积极和消极各占40%左右,中性只占20%,ratio在2:1左右,这种用class weight就能解决。OCNLI如果中立类特别多,ratio可能到5:1,需要更激进的策略。

3.2 class weight的计算与在PyTorch中的接入

class weight的核心思想是让loss对少数类样本更敏感。计算方式是总样本数除以类别数再除以每个类别的样本数,然后归一化。PyTorch的CrossEntropyLoss直接支持weight参数。

import torch import numpy as np from sklearn.utils.class_weight import compute_class_weight def get_class_weights(labels): classes = np.unique(labels) weights = compute_class_weight( class_weight='balanced', classes=classes, y=labels ) return torch.tensor(weights, dtype=torch.float) # OCEMOTION的class weight ocemotion_weights = get_class_weights(ocemotion_labels) print(f"OCEMOTION class weights: {ocemotion_weights}") # 在训练循环中使用 criterion = torch.nn.CrossEntropyLoss(weight=ocemotion_weights.cuda())

compute_class_weight的balanced模式会自动按样本数反比计算权重。注意weight tensor必须和模型输出在同一设备上,GPU训练时要加.cuda()。如果某个类别样本数为0,这个函数会报错,赛前要检查数据里有没有空类别。

3.3 过采样与focal loss的适用边界

过采样在NLP里不像CV那么直接,因为文本不能简单复制,复制会导致过拟合。常见做法是用回译或者EDA做数据增强,但回译需要额外的翻译模型,比赛时间紧的话不划算。我一般只在ratio超过10:1时才考虑对少数类做同义词替换增强。

focal loss适合难样本挖掘,它的两个参数alpha和gamma需要调。gamma控制对难样本的聚焦程度,常用值2.0;alpha控制类别权重,可以设成class weight。但focal loss在预训练模型微调里不一定比class weight好,因为预训练模型本身已经有很强的表示能力,focal loss的梯度特性可能和AdamW优化器配合不好。我的建议是先用class weight跑一版baseline,如果少数类的F1明显偏低再试focal loss。

class FocalLoss(torch.nn.Module): def __init__(self, alpha=None, gamma=2.0): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, inputs, targets): ce_loss = torch.nn.functional.cross_entropy( inputs, targets, weight=self.alpha, reduction='none' ) pt = torch.exp(-ce_loss) focal_loss = ((1 - pt) ** self.gamma * ce_loss).mean() return focal_loss # 使用示例 focal_criterion = FocalLoss(alpha=ocemotion_weights.cuda(), gamma=2.0)

gamma=2.0是原论文的推荐值,实际比赛里可以试1.5和2.5。alpha传class weight tensor。注意focal loss的reduction要先设none再手动mean,否则算pt的时候维度对不上。

3.4 OCNLI中立样本的阈值调整策略

OCNLI的三分类里,中立类往往是最难学的,因为蕴含和矛盾的边界相对清晰,中立则什么都不是。除了class weight,还可以在推理阶段调整中立类的预测阈值。具体做法是:模型输出logits后,如果中立类的概率没有超过某个阈值,就把它归到概率最高的非中立类。

def adjust_neutral_predictions(logits, neutral_idx=2, threshold=0.5): """ logits: (batch, 3) 模型原始输出 neutral_idx: 中立类在标签映射中的索引 threshold: 中立类概率阈值 """ probs = torch.softmax(logits, dim=-1) neutral_prob = probs[:, neutral_idx] # 中立概率低于阈值的样本,取非中立类中概率最大的 mask = neutral_prob < threshold non_neutral_probs = probs.clone() non_neutral_probs[:, neutral_idx] = -1 # 屏蔽中立类 non_neutral_pred = non_neutral_probs.argmax(dim=-1) final_pred = probs.argmax(dim=-1) final_pred[mask] = non_neutral_pred[mask] return final_pred

threshold需要根据验证集上的F1来调,一般从0.4试到0.7。这个后处理技巧在OCNLI上通常能带来0.3到0.8个百分点的提升,但前提是模型本身已经训练得比较充分,否则只是拆东墙补西墙。

4. 训练流程与泛化能力验证:从单任务baseline到多任务联合

4.1 单任务baseline的快速搭建

在搞多任务之前,每个任务先单独跑一个baseline。baseline的配置要固定:学习率2e-5,batch size 32,epoch 3到5,warmup比例0.1,权重衰减0.01。这些是RoBERTa微调的常用起点,不是最优但足够稳。

from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir='./baseline_tnews', num_train_epochs=4, per_device_train_batch_size=32, per_device_eval_batch_size=64, learning_rate=2e-5, warmup_ratio=0.1, weight_decay=0.01, evaluation_strategy='epoch', save_strategy='epoch', load_best_model_at_end=True, metric_for_best_model='f1_macro', logging_steps=50, fp16=True )

metric_for_best_model设成f1_macro而不是accuracy,因为不均衡数据下accuracy会被多数类主导。fp16=True在支持混合精度的GPU上能省显存,但要注意有些操作在fp16下会溢出,如果loss出现nan就关掉。

4.2 多任务联合训练的loss加权与梯度累积

多任务联合训练时,三个任务的batch可能来自不同数据集,需要自定义DataLoader或者用ConcatDataset。loss加权我一般用简单的线性加权:总loss = w1 * loss_tnews + w2 * loss_ocemotion + w3 * loss_ocnli。权重按数据量反比设置,比如TNEWS有5万条、OCEMOTION有3万条、OCNLI有2万条,那权重可以设成0.2、0.33、0.5。

class MultiTaskModel(torch.nn.Module): def __init__(self, encoder_name, num_labels_dict): super().__init__() self.encoder = AutoModel.from_pretrained(encoder_name) self.classifiers = torch.nn.ModuleDict({ task: torch.nn.Linear(self.encoder.config.hidden_size, n) for task, n in num_labels_dict.items() }) def forward(self, input_ids, attention_mask, token_type_ids, task): outputs = self.encoder( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids ) pooled = outputs.last_hidden_state[:, 0] # [CLS] logits = self.classifiers[task](pooled) return logits

这个结构里encoder是共享的,classifiers是每个任务独立的。forward的时候通过task参数选择对应的分类头。注意token_type_ids在RoBERTa里通常全0,但保留这个参数是为了兼容BERT系列。

4.3 用对抗验证检测训练集和测试集的分布差异

泛化能力的一个隐藏杀手是训练集和测试集分布不一致。对抗验证的做法是:把训练集和测试集混在一起,训练一个二分类器去区分样本来自哪个集合。如果AUC明显高于0.5,说明两个集合分布有差异,模型在测试集上的表现可能比验证集差很多。

from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score from sklearn.feature_extraction.text import TfidfVectorizer def adversarial_validation(train_texts, test_texts): # 构造二分类数据 all_texts = train_texts + test_texts labels = [0] * len(train_texts) + [1] * len(test_texts) # 用TF-IDF做特征 vectorizer = TfidfVectorizer(max_features=5000) X = vectorizer.fit_transform(all_texts) # 交叉验证AUC clf = LogisticRegression(max_iter=1000) auc_scores = cross_val_score(clf, X, labels, cv=5, scoring='roc_auc') return auc_scores.mean() # 对每个任务分别做 auc_tnews = adversarial_validation(tnews_train_texts, tnews_test_texts) print(f"TNEWS对抗验证AUC: {auc_tnews:.4f}")

AUC在0.5到0.6之间说明分布基本一致,0.6到0.7要警惕,超过0.7说明差异明显,需要考虑用领域适应或者重新划分验证集。这个技巧在比赛里经常被忽略,但能提前发现很多泛化问题。

4.4 交叉验证与提交策略

天池比赛通常有提交次数限制,不能无限试。我的策略是:用5折交叉验证跑一版,看每折的验证集F1波动。如果波动小于0.5个百分点,说明模型稳定,可以直接用全量数据训练一版提交。如果波动大,说明数据划分有问题或者模型对某些样本敏感,需要检查数据清洗是否到位。

from sklearn.model_selection import StratifiedKFold def run_cv(texts, labels, n_splits=5): skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) fold_scores = [] for fold, (train_idx, val_idx) in enumerate(skf.split(texts, labels)): train_texts = [texts[i] for i in train_idx] val_texts = [texts[i] for i in val_idx] train_labels = [labels[i] for i in train_idx] val_labels = [labels[i] for i in val_idx] # 训练和评估逻辑省略 score = train_and_evaluate(train_texts, train_labels, val_texts, val_labels) fold_scores.append(score) print(f"Fold {fold}: F1={score:.4f}") print(f"CV均值: {np.mean(fold_scores):.4f} ± {np.std(fold_scores):.4f}") return fold_scores

StratifiedKFold保证每折的标签分布和整体一致,random_state固定后结果可复现。如果CV标准差超过1个百分点,优先检查数据里有没有重复样本或者标注错误的样本。

5. 避坑与排查:三任务联合提交时最容易翻车的五个点

5.1 现象:OCNLI验证集F1很高但测试集F1骤降

原因:OCNLI的中立样本标注一致性低,验证集如果和训练集来自同一批标注,模型可能学到了标注者的偏好而不是真正的语义关系。测试集换了标注批次后,这种偏好就失效了。

解决:在训练集里做标注一致性检查,把多个标注者意见不一致的样本降权或者剔除。具体做法是如果有原始标注记录,计算每条样本的标注一致率,一致率低于0.6的样本在loss里乘0.5的权重。

5.2 现象:TNEWS训练loss正常下降但验证集accuracy卡在某个值不动

原因:TNEWS的类别数可能设错了。比赛页面的类别数和实际数据里的unique label数不一致,比如页面写15类但数据里只有14类,多出来的那个类永远没有正样本,模型学不到。

解决:训练前先打印labels的unique值,和比赛说明对照。如果发现类别数不匹配,检查是不是有类别被合并或者标签编码从1开始而不是0。

5.3 现象:多任务联合训练时某个任务的loss突然变成nan

原因:不同任务的loss量级差异大,加权求和后梯度爆炸。比如OCNLI的loss在2.0左右,TNEWS的loss在0.5左右,如果权重没调好,OCNLI的梯度会主导更新。

解决:对每个任务的loss做归一化,或者用GradNorm动态调整权重。简单做法是每个loss除以自己的滑动平均,让量级对齐。

5.4 现象:提交结果比验证集低很多,但对抗验证AUC只有0.55

原因:验证集的划分方式有问题。如果用随机划分,同一个新闻事件的不同标题可能同时出现在训练集和验证集,导致验证集虚高。测试集里这些事件是全新的,模型泛化不过来。

解决:按事件或者按时间做分组划分,确保验证集里的样本和训练集不共享同一事件。TNEWS可以按新闻类别加时间戳分组,OCNLI可以按前提句的语义相似度聚类后分组。

5.5 现象:OCEMOTION少数类的召回率始终上不去

原因:class weight设了但学习率没调。少数类样本少,梯度更新次数少,如果学习率和其他类一样,少数类的参数更新不充分。

解决:对分类头的少数类对应参数用更高的学习率,或者用分层学习率。HuggingFace的Trainer不直接支持分层学习率,需要自定义优化器param_groups。

# 分层学习率示例 no_decay = ['bias', 'LayerNorm.weight'] optimizer_grouped_parameters = [ { 'params': [p for n, p in model.encoder.named_parameters() if not any(nd in n for nd in no_decay)], 'lr': 2e-5 }, { 'params': [p for n, p in model.encoder.named_parameters() if any(nd in n for nd in no_decay)], 'lr': 2e-5, 'weight_decay': 0.0 }, { 'params': model.classifiers['ocemotion'].parameters(), 'lr': 5e-5 # 分类头用更高学习率 } ] optimizer = torch.optim.AdamW(optimizer_grouped_parameters)

这个配置里encoder用2e-5,OCEMOTION分类头用5e-5。注意weight_decay对bias和LayerNorm参数要设0,这是BERT微调的常规操作。

6. 进阶技巧:用伪标签和模型融合把泛化能力再推一档

伪标签的思路是用已经在验证集上表现不错的模型对测试集做预测,把高置信度的预测结果作为伪标签加入训练集重新训练。这个技巧在天池比赛里很常见,但用不好会引入噪声。我的经验是:只取置信度高于0.95的样本,且伪标签样本在总训练数据里的占比不超过20%。

def generate_pseudo_labels(model, test_dataloader, confidence_threshold=0.95): model.eval() pseudo_samples = [] with torch.no_grad(): for batch in test_dataloader: input_ids = batch['input_ids'].cuda() attention_mask = batch['attention_mask'].cuda() logits = model(input_ids, attention_mask) probs = torch.softmax(logits, dim=-1) max_probs, preds = probs.max(dim=-1) # 只保留高置信度样本 mask = max_probs > confidence_threshold for i in range(len(mask)): if mask[i]: pseudo_samples.append({ 'text': batch['text'][i], 'label': preds[i].item(), 'confidence': max_probs[i].item() }) return pseudo_samples

confidence_threshold设0.95是保守值,如果模型本身F1不高,可以降到0.9但伪标签数量要相应减少。伪标签训练时学习率要调低,比如用1e-5,因为伪标签毕竟不是真实标注。

模型融合方面,三任务可以分别训练多个随机种子的模型,推理时对logits取平均。随机种子影响的是权重初始化和数据打乱顺序,多个种子的平均能降低方差。我一般跑3个种子,融合后F1通常比单模型高0.5到1个百分点。

def ensemble_predict(models, dataloader): all_logits = [] for model in models: model.eval() logits_list = [] with torch.no_grad(): for batch in dataloader: input_ids = batch['input_ids'].cuda() attention_mask = batch['attention_mask'].cuda() logits = model(input_ids, attention_mask) logits_list.append(logits.cpu()) all_logits.append(torch.cat(logits_list, dim=0)) # 对logits取平均 avg_logits = torch.stack(all_logits, dim=0).mean(dim=0) return avg_logits.argmax(dim=-1)

融合的收益在模型差异大时更明显,如果三个种子训练出来的模型几乎一样,融合就没意义。判断方法是看不同种子模型在验证集上的预测一致性,如果一致率超过95%,说明模型多样性不够,需要换不同的预训练模型或者不同的数据增强策略。

最后说一个我自己的习惯:每次提交前,把验证集上的错误样本按类别打印出来看一遍。很多时候模型犯的错是有规律的,比如OCNLI里前提和假设都包含否定词时容易判错,TNEWS里体育和娱乐的边界样本容易混。看到这些规律后,可以针对性地补数据或者加规则后处理。这个习惯帮我省了很多次盲目调参的时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询