☰
中文文本分类大作业:从BERT微调到改进Transformer的落地路线
2026/10/7 3:03:19 网站建设 项目流程

简介:这份资源是面向计算机、人工智能及相关专业学生与教师的中文文本分类课程设计项目,基于改进的Transformer模型实现,适合作为自然语言处理大作业、毕业设计或项目立项演示的参考方案。项目在原始Transformer基础上,分别尝试了加入正则化、替换GELU激活函数以及两者结合三种改进思路,并配有完整源码、文档说明与训练好的模型权重,数据集采用THUCNews抽取的20万条新闻标题,文本长度集中在20到30之间。压缩包共99个文件,包含24个Python源码、16个文本文件、16张结果图、8个模型检查点及npz、pkl等数据文件,整体约128.1MB,目录按不同改进版本分文件夹组织,便于对比实验。目前已有189人学习。读者可借此掌握Transformer文本分类的完整流程、正则化与激活函数改进的对比方法,并直接运行验证或在此基础上二次开发。

1. 中文文本分类大作业:从 BERT 微调到改进 Transformer 的落地路线

课程设计里选中文文本分类,十有八九是因为它看起来“有现成模型可调”。真动手才发现,数据清洗、分词、类别不均衡、显存不够、训练不收敛,每一步都能卡住两三天。这个标题指向的是一套完整交付物:Python 源码、文档说明、训练好的模型权重,核心是用改进的 Transformer 做中文文本分类。适合正在做 NLP 课程设计的学生,也适合想从 TF-IDF + 朴素贝叶斯切换到预训练模型的一线开发者。我下面按“先跑通基线,再改结构,最后调参压坑”的顺序拆,每一步都给可复现的命令和参数,不堆概念。

2. 基线先立住:用 HuggingFace 跑通中文文本分类最小闭环

2.1 环境安装与依赖版本锁定

课程设计翻车最常见的原因不是模型不行,是环境版本打架。transformers、torch、datasets三个库的版本必须对齐,否则Trainer会在某个你意想不到的地方报AttributeError。我一般用 Python 3.8 或 3.9,太新的版本反而容易碰到 CUDA 轮子不匹配。

# 创建独立环境,避免污染系统 Python conda create -n chn_cls python=3.9 -y conda activate chn_cls # 锁定版本,这几个组合我实测稳定 pip install torch==1.13.1+cu117 -f https://download.pytorch.org/whl/torch_stable.html pip install transformers==4.28.1 pip install datasets==2.12.0 pip install scikit-learn pandas numpy tqdm

逻辑说明:torch单独用官方索引装,是因为 pip 默认源里的 torch 经常是 CPU 版,训练时你会发现 GPU 利用率始终为 0。transformers锁 4.28 是因为 4.30 之后Trainer的evaluation_strategy参数改名,网上大部分教程还是旧写法,照抄会报错。参数上,cu117对应 CUDA 11.7,用nvidia-smi确认驱动支持的最高 CUDA 版本,不要超过它。

2.2 数据格式与 Dataset 封装

中文文本分类的数据集通常是label\ttext或 CSV 两列。我习惯统一转成 HuggingFaceDataset,这样Trainer能直接吃。下面是一个可复用的封装脚本。

import pandas as pd from datasets import Dataset from transformers import AutoTokenizer # 读取原始数据,假设是 label,text 两列 df = pd.read_csv("data/train.csv") df = df.dropna(subset=["text", "label"]) df["text"] = df["text"].astype(str).str.strip() df = df[df["text"].str.len() > 2] # 过滤空文本和单字噪声 # 标签映射,保证 id 连续 labels = sorted(df["label"].unique()) label2id = {l: i for i, l in enumerate(labels)} df["label_id"] = df["label"].map(label2id) # 划分训练验证集,stratify 保证类别比例一致 from sklearn.model_selection import train_test_split train_df, val_df = train_test_split( df, test_size=0.15, random_state=42, stratify=df["label_id"] ) tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") def tokenize(batch): return tokenizer( batch["text"], padding="max_length", truncation=True, max_length=128, # 中文短文本 128 足够,长文本再调 ) train_ds = Dataset.from_pandas(train_df[["text", "label_id"]]) val_ds = Dataset.from_pandas(val_df[["text", "label_id"]]) train_ds = train_ds.map(tokenize, batched=True) val_ds = val_ds.map(tokenize, batched=True) train_ds = train_ds.rename_column("label_id", "labels") val_ds = val_ds.rename_column("label_id", "labels") train_ds.set_format("torch", columns=["input_ids", "attention_mask", "labels"]) val_ds.set_format("torch", columns=["input_ids", "attention_mask", "labels"])

逻辑说明:stratify是关键,中文分类数据集经常有类别不均衡,不分层会导致验证集里某个类只有两三条,评估指标剧烈波动。max_length=128是经验值,中文一个字一个 token 左右,128 覆盖绝大多数短文本;如果你的数据是长评论,改成 256 但注意显存翻倍。rename_column是因为Trainer默认认labels字段,不改会报KeyError。

2.3 训练脚本与关键超参

from transformers import AutoModelForSequenceClassification, Trainer, TrainingArguments from sklearn.metrics import accuracy_score, f1_score import numpy as np model = AutoModelForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=len(labels) ) def compute_metrics(eval_pred): logits, labels = eval_pred preds = np.argmax(logits, axis=-1) return { "acc": accuracy_score(labels, preds), "f1": f1_score(labels, preds, average="macro"), } args = TrainingArguments( output_dir="./ckpt", learning_rate=2e-5, # BERT 微调黄金区间 1e-5 ~ 5e-5 per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=5, weight_decay=0.01, evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", logging_steps=50, fp16=True, # 有 GPU 就开,省显存提速 ) trainer = Trainer( model=model, args=args, train_dataset=train_ds, eval_dataset=val_ds, compute_metrics=compute_metrics, ) trainer.train()

逻辑说明:learning_rate=2e-5是 BERT 微调的经典值,太大不收敛,太小训不动。metric_for_best_model="f1"而不是 acc,是因为类别不均衡时 acc 会骗人。fp16=True在支持 Tensor Core 的显卡上能省近一半显存,但如果你用的是老卡(如 GTX 10 系),开了可能出 NaN,关掉即可。load_best_model_at_end配合save_strategy="epoch"保证最后拿到的是验证集最优权重,而不是最后一轮的。

3. 改进 Transformer:在分类头上做文章的三个方向

3.1 为什么原始 BERT 分类头不够用

AutoModelForSequenceClassification默认只取[CLS]位置的向量接一个线性层。这个设计在通用语料上没问题,但中文短文本分类有两个痛点:一是[CLS]对局部关键词不敏感,二是类别边界模糊时单一向量表达力不足。常见改进思路有三类:池化策略改进、注意力增强、多特征融合。我下面给一个可落地的池化改进方案,改动小、收益稳。

3.2 加权池化 + 注意力池化的实现

import torch import torch.nn as nn from transformers import BertModel, BertPreTrainedModel class ImprovedBertClassifier(BertPreTrainedModel): def __init__(self, config): super().__init__(config) self.bert = BertModel(config) self.num_labels = config.num_labels # 注意力池化:学习每个 token 的权重 self.attn = nn.Sequential( nn.Linear(config.hidden_size, 128), nn.Tanh(), nn.Linear(128, 1), ) # 融合 [CLS] + 注意力池化 + 平均池化 self.classifier = nn.Sequential( nn.Linear(config.hidden_size * 3, config.hidden_size), nn.ReLU(), nn.Dropout(0.3), nn.Linear(config.hidden_size, config.num_labels), ) def forward(self, input_ids, attention_mask=None, labels=None): outputs = self.bert(input_ids, attention_mask=attention_mask) seq = outputs.last_hidden_state # [B, L, H] cls_vec = seq[:, 0] # [CLS] # 注意力权重,mask 掉 padding attn_score = self.attn(seq).squeeze(-1) # [B, L] if attention_mask is not None: attn_score = attn_score.masked_fill( attention_mask == 0, -1e9 ) attn_weight = torch.softmax(attn_score, dim=-1) attn_vec = torch.bmm(attn_weight.unsqueeze(1), seq).squeeze(1) # 平均池化,同样 mask 掉 padding if attention_mask is not None: mask = attention_mask.unsqueeze(-1).float() mean_vec = (seq * mask).sum(1) / mask.sum(1).clamp(min=1e-9) else: mean_vec = seq.mean(1) fused = torch.cat([cls_vec, attn_vec, mean_vec], dim=-1) logits = self.classifier(fused) loss = None if labels is not None: loss_fn = nn.CrossEntropyLoss() loss = loss_fn(logits, labels) return {"loss": loss, "logits": logits}

逻辑说明:masked_fill把 padding 位置的注意力分数压到负无穷,softmax 后权重为 0,避免 padding 污染池化结果。三路融合的动机是:[CLS]抓全局语义,注意力池化抓关键词,平均池化保留整体分布,三者互补。Dropout(0.3)比默认 0.1 大,是因为融合后特征维度变高,过拟合风险上升。参数上,中间层hidden_size可以改成 256 或 512,显存换效果,我一般先用默认值跑通再调。

3.3 替换模型后的训练差异

用自定义模型时,Trainer依然能用,但要注意from_pretrained的加载方式变了。

from transformers import BertConfig config = BertConfig.from_pretrained( "bert-base-chinese", num_labels=len(labels) ) model = ImprovedBertClassifier.from_pretrained( "bert-base-chinese", config=config )

逻辑说明:from_pretrained会加载 BERT 主干权重,新增的attn和classifier层随机初始化。这样既复用了预训练知识,又让改进部分从头学。注意config必须显式传num_labels,否则分类头维度对不上。训练超参和基线一致即可,但学习率可以稍微降到 1.5e-5,因为新增层对学习率更敏感。

4. 避坑与排查:中文文本分类训练中最容易翻车的五件事

4.1 损失不下降,acc 卡在多数类比例

现象:训练 loss 从 0.69 缓慢降到 0.68,验证 acc 始终等于多数类占比。原因:类别极度不均衡,模型学会了全预测多数类。解决:先用class_weight给 CrossEntropyLoss 加权,权重取类别频率的倒数;或者用WeightedRandomSampler在 DataLoader 层面过采样少数类。我一般先看混淆矩阵,确认是不是全预测一个类。

4.2 验证集 F1 比训练集低 20 个点

现象:训练集 F1 0.95,验证集 0.72。原因:过拟合,常见于数据量小于 5000 条时。解决:把Dropout从 0.1 提到 0.3,weight_decay从 0.01 提到 0.05,num_train_epochs从 5 降到 3。如果还不行,冻结 BERT 前 6 层只训后 6 层和分类头。

4.3 显存溢出 OOM

现象:RuntimeError: CUDA out of memory。原因:max_length太大或 batch_size 太大。解决:先把max_length从 256 降到 128,再把per_device_train_batch_size从 16 降到 8,配合gradient_accumulation_steps=2保持等效 batch。fp16=True也能省显存,但老卡慎用。

4.4 中文标点和特殊符号导致 tokenizer 报错

现象:Token indices sequence length is longer than...或直接抛异常。原因:数据里有全角空格、emoji、乱码。解决:在 tokenize 前做一轮清洗,用正则去掉非中文、非英文、非数字的字符,但保留常用标点。我一般加一行re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9,。!?、;:]", "", text)。

4.5 保存的模型加载后预测结果全一样

现象:训练时指标正常,保存后重新加载预测,所有输入输出同一类别。原因:保存的是Trainer的 checkpoint,但加载时用了AutoModelForSequenceClassification,而实际是自定义模型,分类头没加载上。解决:保存时用model.save_pretrained,加载时用对应的自定义类ImprovedBertClassifier.from_pretrained,确保类定义在加载脚本里可见。

5. 进阶技巧:用对抗训练和阈值搜索把 F1 再抬两个点

5.1 FGM 对抗训练的最小实现

对抗训练在中文分类上性价比很高,改动不到 20 行,F1 通常能涨 1 到 2 个点。核心思路是在 embedding 上加扰动,让模型对微小变化鲁棒。

class FGM: def __init__(self, model, epsilon=1.0): self.model = model self.epsilon = epsilon self.backup = {} def attack(self): for name, param in self.model.named_parameters(): if "embedding" in name and param.requires_grad: 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 = {}

逻辑说明:epsilon=1.0是扰动幅度,太大训练不稳定,太小没效果,0.5 到 1.0 之间试。用法是在每个 batch 的反向传播后、优化器 step 前调用attack(),然后再前向一次算对抗 loss,累加后restore()再正常 step。注意只对 embedding 层加扰动,其他层不动。

5.2 分类阈值搜索

多分类默认取 argmax,但类别不均衡时,对少数类调低阈值能提升召回。做法是在验证集上对每个类扫一遍阈值,选 macro F1 最高的组合。

import numpy as np from sklearn.metrics import f1_score def search_thresholds(logits, labels, num_classes): probs = torch.softmax(torch.tensor(logits), dim=-1).numpy() best_thresholds = np.ones(num_classes) * (1.0 / num_classes) best_f1 = 0 for _ in range(3): # 迭代三轮粗调 for c in range(num_classes): for t in np.arange(0.05, 0.6, 0.05): thresholds = best_thresholds.copy() thresholds[c] = t preds = (probs > thresholds).argmax(axis=-1) f1 = f1_score(labels, preds, average="macro") if f1 > best_f1: best_f1 = f1 best_thresholds = thresholds return best_thresholds, best_f1

逻辑说明:这个搜索是贪心的,每轮固定其他类阈值,只调当前类。虽然不保证全局最优,但比 argmax 稳定提升。np.arange(0.05, 0.6, 0.05)是搜索范围,类别多时可以缩小步长。搜完后把阈值存成 json,推理时加载使用。

5.3 我踩过的坑和固定习惯

对抗训练和阈值搜索别同时上,我试过一起加,验证集 F1 涨了但测试集掉了,原因是阈值在验证集上过拟合。现在我的习惯是:先跑基线,加改进结构,再加 FGM,最后如果还有余力才做阈值搜索,且阈值只在验证集上搜一次,不迭代。另外,每次改完结构一定重新跑一遍基线对比,不然你分不清涨点是来自改进还是随机种子。课程设计报告里把消融实验列成表格,比堆一堆模型结构图更有说服力。希望帮到你。

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

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

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

立即咨询