
简介本资源是阿里云天池中文自然语言处理预训练模型泛化能力挑战赛的完整解决方案面向具备一定深度学习基础、希望系统实践中文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_lengthOCNLI会因为句子对拼接后长度翻倍而被截断TNEWS则可能因为max_length设得太大浪费显存。常见做法是给每个任务单独配置max_lengthTNEWS用64OCEMOTION用96OCNLI用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, percentile95): lengths [len(tokenizer.encode(t, add_special_tokensTrue)) 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(fTNEWS: {tnews_stats}) print(fOCEMOTION: {ocemotion_stats}) print(fOCNLI: {ocnli_stats})这段代码的作用是拿到每个任务的实际长度分布p95的值向上取整到8的倍数就是比较合理的max_length。参数说明percentile取95是经验值取99会把max_length拉得很大取90又可能截断太多信息。add_special_tokensTrue必须开因为[CLS]和[SEP]会占2到3个token。2.2 RoBERTa中文预训练模型的选型理由热搜里经常出现roberta中文预训练模型这个词实际选型时要注意哈工大讯飞联合发布的RoBERTa-wwm-ext和RoBERTa-wwm-ext-large是两个不同量级。base版本参数量约102Mlarge版本约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_labels15 # TNEWS类别数按实际数据调整 ) # OCNLI用句子对分类结构相同但输入格式不同 model_ocnli BertForSequenceClassification.from_pretrained( hfl/chinese-roberta-wwm-ext, num_labels3 )这里没有用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, ascendingFalse) df[ratio] df[count] / df[count].min() print(f\n{task_name} 标签分布:) print(df.to_string(indexFalse)) 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_weightbalanced, classesclasses, ylabels ) return torch.tensor(weights, dtypetorch.float) # OCEMOTION的class weight ocemotion_weights get_class_weights(ocemotion_labels) print(fOCEMOTION class weights: {ocemotion_weights}) # 在训练循环中使用 criterion torch.nn.CrossEntropyLoss(weightocemotion_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.0alpha控制类别权重可以设成class weight。但focal loss在预训练模型微调里不一定比class weight好因为预训练模型本身已经有很强的表示能力focal loss的梯度特性可能和AdamW优化器配合不好。我的建议是先用class weight跑一版baseline如果少数类的F1明显偏低再试focal loss。class FocalLoss(torch.nn.Module): def __init__(self, alphaNone, gamma2.0): super().__init__() self.alpha alpha self.gamma gamma def forward(self, inputs, targets): ce_loss torch.nn.functional.cross_entropy( inputs, targets, weightself.alpha, reductionnone ) pt torch.exp(-ce_loss) focal_loss ((1 - pt) ** self.gamma * ce_loss).mean() return focal_loss # 使用示例 focal_criterion FocalLoss(alphaocemotion_weights.cuda(), gamma2.0)gamma2.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_idx2, threshold0.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_predthreshold需要根据验证集上的F1来调一般从0.4试到0.7。这个后处理技巧在OCNLI上通常能带来0.3到0.8个百分点的提升但前提是模型本身已经训练得比较充分否则只是拆东墙补西墙。4. 训练流程与泛化能力验证从单任务baseline到多任务联合4.1 单任务baseline的快速搭建在搞多任务之前每个任务先单独跑一个baseline。baseline的配置要固定学习率2e-5batch size 32epoch 3到5warmup比例0.1权重衰减0.01。这些是RoBERTa微调的常用起点不是最优但足够稳。from transformers import TrainingArguments, Trainer training_args TrainingArguments( output_dir./baseline_tnews, num_train_epochs4, per_device_train_batch_size32, per_device_eval_batch_size64, learning_rate2e-5, warmup_ratio0.1, weight_decay0.01, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelf1_macro, logging_steps50, fp16True )metric_for_best_model设成f1_macro而不是accuracy因为不均衡数据下accuracy会被多数类主导。fp16True在支持混合精度的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_idsinput_ids, attention_maskattention_mask, token_type_idstoken_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_features5000) X vectorizer.fit_transform(all_texts) # 交叉验证AUC clf LogisticRegression(max_iter1000) auc_scores cross_val_score(clf, X, labels, cv5, scoringroc_auc) return auc_scores.mean() # 对每个任务分别做 auc_tnews adversarial_validation(tnews_train_texts, tnews_test_texts) print(fTNEWS对抗验证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_splits5): skf StratifiedKFold(n_splitsn_splits, shuffleTrue, random_state42) 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(fFold {fold}: F1{score:.4f}) print(fCV均值: {np.mean(fold_scores):.4f} ± {np.std(fold_scores):.4f}) return fold_scoresStratifiedKFold保证每折的标签分布和整体一致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-5OCEMOTION分类头用5e-5。注意weight_decay对bias和LayerNorm参数要设0这是BERT微调的常规操作。6. 进阶技巧用伪标签和模型融合把泛化能力再推一档伪标签的思路是用已经在验证集上表现不错的模型对测试集做预测把高置信度的预测结果作为伪标签加入训练集重新训练。这个技巧在天池比赛里很常见但用不好会引入噪声。我的经验是只取置信度高于0.95的样本且伪标签样本在总训练数据里的占比不超过20%。def generate_pseudo_labels(model, test_dataloader, confidence_threshold0.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_samplesconfidence_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, dim0)) # 对logits取平均 avg_logits torch.stack(all_logits, dim0).mean(dim0) return avg_logits.argmax(dim-1)融合的收益在模型差异大时更明显如果三个种子训练出来的模型几乎一样融合就没意义。判断方法是看不同种子模型在验证集上的预测一致性如果一致率超过95%说明模型多样性不够需要换不同的预训练模型或者不同的数据增强策略。最后说一个我自己的习惯每次提交前把验证集上的错误样本按类别打印出来看一遍。很多时候模型犯的错是有规律的比如OCNLI里前提和假设都包含否定词时容易判错TNEWS里体育和娱乐的边界样本容易混。看到这些规律后可以针对性地补数据或者加规则后处理。这个习惯帮我省了很多次盲目调参的时间。希望帮到你。本文还有配套的精品资源点击获取