
简介本资源是阿里云天池中文自然语言处理预训练模型泛化能力挑战赛的完整解决方案面向具备一定深度学习基础、希望系统实践中文NLP多任务建模的开发者与参赛者。方案围绕TNEWS新闻分类、OCEMOTION情感分析、OCNLI自然语言推理三大任务展开重点处理多标签不均衡数据并给出基于预训练模型的深度学习实现思路。压缩包共18个文件约6.47MB包含6个csv数据文件、4个Python脚本、3个Shell运行脚本以及说明文档、依赖清单与项目说明等覆盖数据生成、模型训练、预测与提交全流程。已有89人学习下载。读者可从中获取赛题数据组织方式、多标签不均衡的重采样与权重调整策略、分类模型训练与推理脚本以及一键运行和提交的工程化配置适合作为中文NLP多任务学习与竞赛复现的参考模板。1. 拆开这个天池 NLP 三任务方案一份能直接跑的中文预训练微调工程中文 NLP 的竞赛方案满天飞但真正能 clone 下来、改两行路径就跑通的并不多。这份基于阿里云天池「中文预训练模型泛化能力挑战赛」的解决方案把 TNEWS 新闻分类、OCEMOTION 情感分析、OCNLI 自然语言推理三个任务打包成了一个统一工程核心卖点不是模型多花哨而是把多标签不均衡数据处理和预训练模型微调的完整链路做成了可复现的脚本。如果你正在做中文文本分类、情感分析或 NLI 相关的课程设计或者想找一个结构清晰的 BERT/RoBERTa 微调模板这份资源值得花时间拆一遍。它适合已经了解 Transformer 基本结构、但还没亲手跑通过一套完整竞赛 pipeline 的从业者也适合想看看别人怎么组织多任务代码的熟手。2. 工程结构与数据流从 CSV 到模型输入的完整链路拿到一个压缩包第一件事不是急着跑run_all.sh而是先搞清楚每个文件在链路里的位置。这份工程的文件组织不算复杂但有几个命名容易让人混淆的地方拆清楚之后后面调参才不会迷路。2.1 目录文件清单与职责划分解压后根目录下大致是这些内容文件/目录类型职责tianchi_ft-main/目录主工程目录核心代码都在里面data/目录存放三个任务的训练集和验证集 CSVOCNLI_train1128.csv数据OCNLI 推理任务训练集约 1128 条OCEMOTION_train1128.csv数据OCEMOTION 情感分析训练集TNEWS_train1128.csv数据TNEWS 新闻分类训练集OCNLI_a.csv/OCEMOTION_a.csv/TNEWS_a.csv数据对应任务的测试集 A 榜文件run_classifier.py代码模型训练与评估主入口predict.py代码生成提交文件的推理脚本dataset.py代码数据加载与预处理gen_data.py代码数据格式转换与增强run_classifier.sh脚本单任务训练启动脚本run_all.sh脚本三任务串行执行入口submit.sh脚本提交文件打包requirements.txt配置Python 依赖清单这里有个容易翻车的点data/目录和根目录下的 CSV 文件可能存在重复或软链接关系不同解压方式下表现不一致。我一般会先ls -la确认一下如果根目录 CSV 是实体文件而data/是空的就需要手动把 CSV 移进去或者改dataset.py里的路径常量。2.2 数据加载与多标签不均衡处理dataset.py是整个工程里最值得细看的部分因为它直接决定了三个任务能不能共用一套训练框架。三个任务的数据格式差异很大TNEWS 是单标签多分类OCEMOTION 是单标签情感三分类或更细粒度OCNLI 是三分类推理蕴含/中立/矛盾。工程的做法是用一个统一的TextDataset类通过传入不同的label_encoder来适配。# dataset.py 核心逻辑示意 import pandas as pd import torch from torch.utils.data import Dataset, WeightedRandomSampler from transformers import BertTokenizer class TextDataset(Dataset): def __init__(self, csv_path, tokenizer, max_len128, task_typetnews): self.df pd.read_csv(csv_path) self.tokenizer tokenizer self.max_len max_len self.task_type task_type # 根据任务类型选择文本列和标签列 if task_type ocnli: self.texts list(zip(self.df[sentence1], self.df[sentence2])) self.labels self.df[label].values else: self.texts self.df[text].tolist() self.labels self.df[label].values def __len__(self): return len(self.labels) def __getitem__(self, idx): text self.texts[idx] label self.labels[idx] if self.task_type ocnli: encoding self.tokenizer( text[0], text[1], max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) else: encoding self.tokenizer( text, max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoding[input_ids].squeeze(), attention_mask: encoding[attention_mask].squeeze(), labels: torch.tensor(label, dtypetorch.long) }这段代码的关键参数是max_len默认 128 对 TNEWS 和 OCEMOTION 够用但 OCNLI 的句子对拼接后容易超长建议调到 256。另外task_type这个字段决定了文本列的读取方式OCNLI 需要同时读sentence1和sentence2如果你的 CSV 列名不是这两个需要在这里改。不均衡处理没有直接写在Dataset里而是在训练循环中通过WeightedRandomSampler实现。工程里有一个compute_class_weights函数根据每个类别的样本数计算权重样本少的类别权重高。这个做法比简单的过采样更稳因为不会引入重复样本导致的过拟合。# 计算类别权重并构建采样器 from sklearn.utils.class_weight import compute_class_weight import numpy as np def build_sampler(labels): classes np.unique(labels) weights compute_class_weight(balanced, classesclasses, ylabels) class_weight_dict dict(zip(classes, weights)) sample_weights [class_weight_dict[label] for label in labels] sampler WeightedRandomSampler( weightssample_weights, num_sampleslen(sample_weights), replacementTrue ) return samplercompute_class_weight的balanced模式会自动按n_samples / (n_classes * np.bincount(y))计算权重。如果你的某个类别只有个位数样本这个权重会非常大可能导致训练震荡。我一般会加一个上限截断比如min(weight, 10.0)避免个别类别主导梯度。2.3 训练脚本的参数组织与启动方式run_classifier.py用的是 HuggingFaceTrainer还是自定义训练循环决定了你改代码的难度。从工程结构看它更可能是自定义循环因为需要兼容三个任务的不同输出维度。核心参数通过argparse传入# run_classifier.sh 典型内容 python run_classifier.py \ --task_name tnews \ --data_dir ./data \ --model_name_or_path bert-base-chinese \ --max_seq_length 128 \ --batch_size 32 \ --learning_rate 2e-5 \ --num_train_epochs 3 \ --output_dir ./output/tnews \ --do_train \ --do_evalmodel_name_or_path可以换成hfl/chinese-roberta-wwm-ext或bert-base-chinese前者在中文任务上通常高 1-2 个点。learning_rate对预训练模型微调很敏感2e-5 是安全起点如果 loss 不降可以试 1e-5 或 3e-5。batch_size受显存限制32 在 8G 显存上跑 128 长度基本是上限。run_all.sh就是把三个任务的run_classifier.sh串起来中间用连接。注意如果某个任务失败后面的不会执行调试阶段建议分开跑。3. 三任务微调实战TNEWS、OCEMOTION、OCNLI 的差异化处理三个任务虽然共用一套代码框架但每个任务的标签体系、评估指标和训练细节都不一样。这一章按任务拆开讲每个任务给出可复现的配置和关键参数说明。3.1 TNEWS 新闻分类类别不均衡下的权重调整TNEWS 是典型的单标签多分类类别数在 15 左右涵盖政治、经济、科技、体育等领域。这个任务的主要问题是类别分布不均某些热门类别样本可能是冷门类别的几十倍。训练时除了用WeightedRandomSampler还可以在损失函数里加class_weight。工程里用的是CrossEntropyLoss可以这样改# 在训练循环中构建带权重的损失函数 import torch.nn as nn def get_loss_fn(labels, device): classes np.unique(labels) weights compute_class_weight(balanced, classesclasses, ylabels) weight_tensor torch.tensor(weights, dtypetorch.float).to(device) return nn.CrossEntropyLoss(weightweight_tensor)weight_tensor的长度必须等于类别数且顺序要和标签编码一致。如果用了LabelEncoder确保classes_的顺序和权重顺序对应否则权重会错位效果反而更差。评估指标用 accuracy 和 macro-F1。macro-F1 对不均衡更敏感如果 accuracy 高但 macro-F1 低说明模型偏向多数类需要加大权重或换采样策略。3.2 OCEMOTION 情感分析细粒度情感与标签平滑OCEMOTION 的情感标签比常见的二分类更细可能包含高兴、悲伤、愤怒、恐惧等多个类别。细粒度情感分析的难点在于类别边界模糊比如「惊讶」和「恐惧」在某些文本里很难区分。工程里可能用了标签平滑Label Smoothing来缓解这个问题。标签平滑把硬标签变成软标签比如[0, 1, 0]变成[0.05, 0.9, 0.05]防止模型对某个类别过度自信。# 标签平滑实现 class LabelSmoothingCrossEntropy(nn.Module): def __init__(self, smoothing0.1): super().__init__() self.smoothing smoothing def forward(self, pred, target): n_classes pred.size(-1) log_preds torch.log_softmax(pred, dim-1) loss -log_preds.sum(dim-1).mean() # 平滑项 smooth_loss -log_preds.mean(dim-1).mean() loss (1 - self.smoothing) * loss self.smoothing * smooth_loss / n_classes return losssmoothing0.1是常用值太大如 0.3会导致模型欠拟合。如果情感类别之间区分度本来就高可以不用标签平滑直接用标准交叉熵。OCEMOTION 的评估除了 accuracy还应该看每个类别的 recall。如果某个情感类别的 recall 特别低说明模型没学到这个类别的特征可能需要检查该类别样本是否太少或者文本长度是否被截断丢失了关键情感词。3.3 OCNLI 自然语言推理句子对编码与推理增强OCNLI 是三个任务里最难的因为它要求模型理解两个句子之间的逻辑关系。工程里用的是 BERT 的句子对输入格式[CLS] 句子1 [SEP] 句子2 [SEP]。# OCNLI 的 tokenizer 调用方式 encoding tokenizer( sentence1, sentence2, max_length256, # NLI 任务建议加长 paddingmax_length, truncationonly_first, # 优先截断第一个句子 return_tensorspt )truncationonly_first在 OCNLI 里要慎用因为假设句sentence2通常比前提句短如果截断策略不当可能把关键假设截掉。更稳的做法是truncationTrue让 tokenizer 自动按比例截断或者手动控制两个句子的最大长度。OCNLI 的评估指标是 accuracy但要注意标签分布。如果「中立」类别占大多数模型全猜中立也能有不错的 accuracy所以要看混淆矩阵。工程里可能在run_classifier.py的评估部分输出了分类报告如果没有可以自己加from sklearn.metrics import classification_report # 在评估循环结束后 print(classification_report(all_labels, all_preds, target_names[entailment, neutral, contradiction]))如果「蕴含」和「矛盾」的 F1 明显低于「中立」说明模型没有真正学会推理只是学到了表面模式。这时候可以尝试用roberta-large或者加对抗训练FGM但计算成本会上去。4. 避坑与排查跑通这套代码常见的五个翻车点这套工程看起来结构清晰但实际跑起来有几个地方特别容易卡住。下面是我自己踩过或者见别人踩过的坑按「现象 → 原因 → 解决」整理。4.1 路径不对导致 FileNotFoundError现象运行run_classifier.sh时报FileNotFoundError: ./data/TNEWS_train1128.csv。原因解压后 CSV 文件在根目录但脚本里写的是./data/路径。不同解压工具对目录结构的处理不一样有的会把tianchi_ft-main里的内容直接铺到当前目录有的会多一层嵌套。解决先find . -name TNEWS_train1128.csv确认文件实际位置然后要么把 CSV 移到data/下要么改run_classifier.sh里的--data_dir参数。我一般会在脚本开头加一行DATA_DIR$(dirname $(find . -name TNEWS_train1128.csv | head -1))自动定位。4.2 显存不足导致 CUDA OOM现象训练开始几秒后报RuntimeError: CUDA out of memory。原因batch_size32加max_seq_length128在 8G 显存上跑 BERT-base 刚好卡在边缘如果 OCNLI 任务把长度调到 256显存直接翻倍。解决优先降batch_size到 16同时把gradient_accumulation_steps设为 2保持等效 batch size。如果还不行开启混合精度训练from torch.cuda.amp import autocast, GradScaler scaler GradScaler() with autocast(): outputs model(**inputs) loss loss_fn(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度通常能省 30%-40% 显存但要注意 loss scaling 可能带来的数值不稳定如果 loss 出现 NaN把GradScaler的init_scale调低。4.3 标签编码不一致导致权重错位现象加了类别权重后模型效果反而比不加更差某些类别完全预测不出来。原因compute_class_weight返回的权重顺序是按np.unique(labels)排的但LabelEncoder的classes_顺序可能不同。如果训练时用的标签是字符串权重计算时转成了整数顺序就乱了。解决统一用LabelEncoder先 fit 所有标签然后用encoder.transform得到整数标签再传给compute_class_weight。确保权重数组的索引和标签整数一一对应。可以在训练前打印class_weight_dict和encoder.classes_对照检查。4.4 OCNLI 截断策略导致推理信息丢失现象OCNLI 任务 accuracy 卡在 0.5 左右上不去混淆矩阵显示「蕴含」和「矛盾」几乎全预测成「中立」。原因max_seq_length128对 OCNLI 太短很多样本的句子对拼接后超过 128被截断后假设句丢失模型只能看到前提句自然无法推理。解决把 OCNLI 的max_seq_length调到 256 或 384同时用truncationlongest_first让 tokenizer 自动平衡两个句子的截断比例。如果显存不够用batch_size8加梯度累积。另外检查一下 CSV 里sentence1和sentence2的列名是否和dataset.py里读的一致列名不对会直接报 KeyError。4.5 随机种子未固定导致结果不可复现现象同样的代码跑两次accuracy 差 2-3 个点提交结果不稳定。原因PyTorch、NumPy、Python 的随机种子没有全部固定数据 shuffle、dropout、权重初始化都有随机性。解决在run_classifier.py开头加统一的种子设置import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)cudnn.deterministicTrue会稍微降低训练速度但能保证卷积操作的可复现性。如果用了DataLoader的shuffleTrue还需要设置generatortorch.Generator().manual_seed(seed)。5. 进阶技巧用对抗训练和模型融合把分数再推一截跑通基础版本之后如果想把三个任务的分数再往上提有两个方向性价比最高对抗训练和模型融合。这一章给一个具体的 FGM 实现和融合策略都是我在类似竞赛里验证过有效的。5.1 FGM 对抗训练给 embedding 加扰动FGMFast Gradient Method的核心思想是在 embedding 层加一个小的扰动让模型对扰动鲁棒从而提升泛化能力。实现起来不复杂在训练循环里加几行class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data self.backup[name] self.backup {}用法是在正常loss.backward()之后、optimizer.step()之前插入# 正常前向传播 outputs model(**inputs) loss loss_fn(outputs, labels) loss.backward() # FGM 对抗 fgm.attack() outputs_adv model(**inputs) loss_adv loss_fn(outputs_adv, labels) loss_adv.backward() fgm.restore() optimizer.step()epsilon1.0是常用值太大如 2.0会导致训练不稳定。FGM 会让训练时间增加约 50%但通常能带来 0.5-1.5 个点的提升。注意attack里只对 embedding 层加扰动不要对所有参数加否则计算量太大。5.2 多模型融合投票与加权平均三个任务都可以用模型融合来提分。最简单的做法是训练多个不同预训练模型的 checkpoint然后在预测时做软投票# 软投票融合示例 def ensemble_predict(models, dataloader, device): all_probs [] for model in models: model.eval() probs [] with torch.no_grad(): for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) outputs model(input_ids, attention_maskattention_mask) probs.append(torch.softmax(outputs.logits, dim-1).cpu()) all_probs.append(torch.cat(probs, dim0)) # 平均概率 avg_probs torch.stack(all_probs, dim0).mean(dim0) return avg_probs.argmax(dim-1)融合的模型建议选不同预训练权重的比如bert-base-chinesehfl/chinese-roberta-wwm-exthfl/chinese-roberta-wwm-ext-large。同架构不同 seed 的融合也有用但提升幅度小一些。如果显存不够同时加载多个模型可以逐个推理保存概率再离线融合。5.3 验证集划分与早停策略竞赛里验证集的划分方式直接影响模型选择。工程里如果用的是固定划分建议改成 5 折交叉验证每折训练一个模型最后融合。这样既能充分利用数据又能得到更稳的融合效果。早停策略看验证集 loss 或 macro-F1连续 2 个 epoch 不提升就停。run_classifier.py里如果没实现早停可以加一个简单的计数器best_f1 0 patience 2 no_improve 0 for epoch in range(num_epochs): train_loss train_one_epoch(model, train_loader, optimizer) val_f1 evaluate(model, val_loader) if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), f{output_dir}/best_model.pt) no_improve 0 else: no_improve 1 if no_improve patience: print(fEarly stopping at epoch {epoch}) breakpatience2对 BERT 微调够用如果数据集特别小比如 OCNLI 只有 1128 条可以设patience3给模型更多机会。从那以后我每次拿到一个新的 NLP 竞赛工程都会先固定随机种子、确认数据路径、跑一个最小 batch 的 sanity check再开始正式训练。这套流程帮我省了很多「跑了一晚上发现路径错了」的后悔药。希望这份拆解能帮你少走几个弯路顺利把三个任务的 baseline 跑起来。本文还有配套的精品资源点击获取