简介:基于BERT模型的中文文本情感分类完整项目,专为计算机相关专业毕业设计、课程设计或期末大作业场景打造,面向具备Python基础并希望深入学习NLP与BERT应用的学习者,旨在解决微博等中文短文本的情感极性判别问题。资源整合模型源码、中文预训练权重、约10万条微博情感标注数据集及配套运行脚本,覆盖数据预处理、模型微调、分类评估与推理全流程,下载解压后即可在配置好依赖的环境中直接运行。资源包共37个文件,以Python脚本为主体:16个.py程序文件对应建模、优化、预处理、预测等核心模块,4个Shell脚本用于一键启动训练或测试,4个CSV数据文件已按训练集与测试集划分,另含使用说明、依赖清单、项目介绍等文档,整体约383.81MB。代码模块划分清晰,训练与测试数据划分明确,附带预训练模型压缩包,可支持替换自有数据集进行迁移学习,同时经过严格调试,下载即可运行。目前已有1070人学习下载,项目既能作为毕业设计完整源码参考,也能用于课程设计与期末大作业,帮助读者高效完成中文情感分类实战任务。
1. 用BERT做中文情感分类:毕业设计里最值得复用的落地方案
如果你正在为毕业设计选方向,又不想做一个纯调包的"玩具系统",那Python实现基于BERT模型的中文文本情感分类项目是一个性价比很高的选择。它在学术上踩中了深度学习与自然语言处理的交叉点,在工程上又能完整覆盖数据清洗、模型训练、评估导出全流程,答辩时随便抽出哪一环都有得讲。拆开这个项目你会发现,核心工作就四块:把中文文本编码成BERT能读的token序列、用预训练权重做迁移学习、把最后一层换成二分类输出、用真实标注数据把模型从"通用"微调成"专用"。下面我按自己落地这个方向的经验,把这四块逐步讲透,顺带把那些不复现一遍根本意识不到的坑挑明。
2. 环境搭建与预训练权重获取:先从这三件事开始
2.1 Python版本与依赖安装:别让环境问题消耗半天
做BERT项目,第一步不是import torch,而是把Python环境控制在一个能稳定跑transformers的范围内。根据社区里大量报错帖来看,Python 3.8到3.10是最稳的区间,3.11以上某些旧版本torch没有预编译wheel,源码编译会浪费大量时间。我一般用conda建独立环境,避免和系统Python互相污染。
conda create -n bert_sentiment python=3.9 conda activate bert_sentiment pip install torch==2.0.1 pip install transformers==4.38.2 pip install pandas numpy scikit-learn pip install tqdm依赖版本这里有个关键细节:transformers 4.x和torch 2.x要配套。如果torch是2.0以上而transformers还停在3.x,调用BertTokenizer.from_pretrained时会出现key相关警告甚至直接报错。如果全程用CPU训练,还需要额外装torchvision吗?不需要,那是图像用的。项目里只需要torch、transformers、pandas、numpy、sklearn和tqdm六个核心库。
安装完可以快速验证一下GPU是否可用:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.device_count() if torch.cuda.is_available() else 0)这段验证的意义在于:BET全称是Bidirectional Encoder Representations from Transformers,参数量在1.1亿左右,用GPU训练一个epoch大约只需要几分钟,而CPU可能要半小时起步。如果你看到cuda.is_available()返回False,说明torch装的是CPU版,需要到PyTorch官网按CUDA版本重新安装。根据经验,CUDA 11.8对应torch 2.0.1,CUDA 12.1对应torch 2.1以上,先把这套对应关系搞清楚,能少踩很多坑。
2.2 预训练模型选择与下载:从HuggingFace拿代码,从国内镜像拿权重
BERT模型本身分两种获取路径:一种是通过transformers库在线下载,代码会自动到HuggingFace仓库里拉权重和配置文件;另一种是手动下载模型文件放到本地目录。国内网络环境下,直接from_pretrained经常卡在连接超时,所以更可靠的训练做法是先用镜像源把模型拉下来,再加载本地路径。
# 使用hf-mirror镜像下载bert-base-chinese pip install -U huggingface_hub export HF_ENDPOINT=https://hf-mirror.com python -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='bert-base-chinese', local_dir='./bert-base-chinese')"下载完成后,本地目录里应该包含config.json、pytorch_model.bin、vocab.txt和tokenizer_config.json四个文件。config.json记录的是模型结构参数,比如num_hidden_layers等于12、hidden_size等于768,这些参数决定了你后面输出层怎么接。vocab.txt是中文词表,里面每行一个词,总计21128行。
提示:如果项目数据量只有几万条电影评论、酒店评论,用
bert-base-chinese就足够了。RoBERTa-wwm-ext在中文任务上通常能比base高出1到2个点的F1,但它的权重文件更大,训练速度更慢。毕业设计不追求极限精度的话,base版是性价比之王。
加载本地模型时,代码长这样:
from transformers import BertTokenizer, BertForSequenceClassification model_path = "./bert-base-chinese" tokenizer = BertTokenizer.from_pretrained(model_path) model = BertForSequenceClassification.from_pretrained(model_path, num_labels=2)BertForSequenceClassification这个类会在预训练BERT的[CLS]输出之上自动加一个分类头,默认就是线性层加Dropout。如果你传了num_labels=2,输出的logits维度就是[batch_size, 2]。这一步已经替你完成了90%的模型改造工作,剩下要操心的就是数据怎么喂进去。
2.3 数据准备与标签分布:先看类别再动手
拿到标注数据后不要直接扔进模型,第一件事是统计标签分布。中文情感分类的数据集通常是(text, label)的CSV格式,label取0或1,0代表负向情感,1代表正向情感。
import pandas as pd df = pd.read_csv("data.csv") print(df.head()) print(df["label"].value_counts()) print(df["text"].str.len().describe())这段统计有三个作用。第一,确认标签是均衡的,如果负向样本9000条、正向只有1000条,模型会学出"永远预测负向"的懒策略,F1照样能到0.6以上但实际毫无用处。第二,查看文本长度分布,BERT的输入上限是512个token,如果大多数文本长度在200字以内,那截断策略可以保守一些。第三,检查是否存在空文本、重复文本,这类脏数据会在训练时造成梯度异常,还会让混淆矩阵看起来奇怪。
如果标签极不均衡,常见做法是过采样少数类。但要注意,过采样之后的验证集必须保持原始分布,不能把增强数据混进去再切分,否则评估结果虚高,答辩时一追问就露馅。
3. 数据预处理与模型输入:文本到Tensor的完整管道
3.1 tokenizer编码:从字符串到input_ids的完整映射
BERT不直接吃中文句子,它需要先把句子切分成token,再映射成词表里的数字ID。BertTokenizer做这一步是自动的,但你要清楚它背后发生了什么,因为很多诡异的训练表现都来自这一步的细微差别。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("./bert-base-chinese") text = "这家餐厅的菜很好吃,但服务态度太差了。" encoded = tokenizer( text, max_length=128, truncation=True, padding="max_length", return_tensors="pt" ) print(encoded["input_ids"]) print(encoded["attention_mask"])max_length=128表示序列最多保留128个token,超过的部分截掉;truncation=True开启截断,它默认截断尾部,即保留句子开头的信息;padding="max_length"把短文本补齐到128个token,补的位置在attention_mask里被标记为0,模型计算注意力时会自动忽略这些位置。
提示:
[CLS]和[SEP]这两个特殊token固定占据序列开头和结尾。[CLS]位置的输出向量会被BERT当作整个句子的语义表示用于分类,[SEP]标记句子边界。所以实际能容纳的有效文本只有126个token。
这里有一个新手经常忽略的点:attention_mask不是"哪些词重要",而是"哪些位置是真实内容"。padding出来的0在后续对齐时要格外小心——如果自己动手实现计算准确率的逻辑,mask了的位置的预测结果不能计入统计,否则准确率会被虚高几个点。
3.2 自定义Dataset类:构建PyTorch数据管道
把原始DataFrame转成PyTorch能迭代的Dataset类,是训练前必须完成的一步。这里我建议不要直接使用transformers自带的TextClassificationDataset,因为自定义类能让你在预处理阶段加入调试打印、数据清洗逻辑,后续接验证集也灵活。
import torch from torch.utils.data import Dataset class SentimentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = str(self.texts[idx]) label = int(self.labels[idx]) encoding = self.tokenizer( text, max_length=self.max_len, truncation=True, padding="max_length", return_tensors="pt" ) return { "input_ids": encoding["input_ids"].squeeze(0), "attention_mask": encoding["attention_mask"].squeeze(0), "labels": torch.tensor(label, dtype=torch.long) }Dataset类核心逻辑就是__getitem__:给定一条索引,返回一个字典,里面包含模型需要的三个张量。squeeze(0)的作用是把[1, 128]压成[128],因为tokenizer加了return_tensors="pt"之后返回的是带batch维度的张量,在__getitem__里保留batch维度会造成后续DataLoader多套一层维度。
3.3 DataLoader参数设置:batch_size与shuffle的配合
from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=0 ) val_loader = DataLoader( val_dataset, batch_size=32, shuffle=False, num_workers=0 )batch_size=32是BERT微调里比较平衡的默认值,在16GB显存上运行稳定。如果你用的是8GB显存,建议降到16。shuffle=True只在训练集上开,验证集保持False,否则评估结果因样本顺序变化产生微小波动,不易复现。num_workers在Windows上建议设为0,Linux下可以设成4或8加速数据读取,但设太高反而会让主进程等待,训练曲线出现锯齿状波动。
4. 训练与评估:让BERT在中文语料上真正“跑起来”
4.1 模型加载与输出层改造:分类头到底改了什么
BertForSequenceClassification是一个封装得很好的类,它把BERT主干和分类头串成了前向传播的一条线。上一步from_pretrained的时候,模型会先加载预训练权重,然后随机初始化一个新的分类头。注意,分类头的随机初始化意味着第一次前向传播的logits基本是均匀分布的噪声,loss在初值附近震荡是正常现象。
如果不想用封装类,也可以自己手动改造:
from transformers import BertModel import torch.nn as nn class BERTClassifier(nn.Module): def __init__(self, bert_path, num_labels=2): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout = nn.Dropout(p=0.3) self.classifier = nn.Linear(768, num_labels) def forward(self, input_ids, attention_mask): outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask ) pooled = outputs.pooler_output logits = self.classifier(self.dropout(pooled)) return logitsoutputs.pooler_output取自[CLS]位置经过全连接层和tanh激活的向量,维度是[batch_size, 768]。nn.Linear(768, num_labels)把768维压缩成2维logits。dropout设置在0.3,这是HuggingFace默认值,也是实践下来比较稳的系数,太大容易欠拟合,太小则过拟合。自己定义模型的好处是可以清晰控制每一层,坏处是要手动处理保存与加载的细节,封装类则全自动。毕业设计阶段我更推荐用BertForSequenceClassification,省下的时间可以用来调参和写分析。
4.2 训练循环:优化器、学习率与loss计算
训练BERT的核心参数就是学习率。因为BERT已经是高度收敛的预训练模型,微调时学习率过大直接覆盖原有知识,过小则训练半天没动静。经验区间是2e-5到5e-5,我通常从3e-5起步。
from transformers import AdamW from tqdm import tqdm import torch.nn.functional as F device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) optimizer = AdamW(model.parameters(), lr=3e-5, weight_decay=0.01) total_steps = len(train_loader) * 3 # 3 epochs from transformers import get_linear_schedule_with_warmup scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps ) for epoch in range(3): model.train() total_loss = 0 for batch in tqdm(train_loader, desc=f"Epoch {epoch+1}"): input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) outputs = model(input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss loss.backward() # 梯度裁剪:防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() print(f"Epoch {epoch+1} train loss: {total_loss / len(train_loader):.4f}")AdamW与普通Adam的区别在于权重衰减的时机:AdamW把L2正则从梯度计算里拆出来,在参数更新时单独做衰减,这让BERT微调时的泛化性更好。get_linear_schedule_with_warmup实现了学习率先线性升后线性降,前10%的step从接近0升到3e-5,之后逐步降到0。学习率曲线的意义在于:训练早期用较小的学习率稳定方向,中期用峰值学习率快速收敛,后期降低学习率帮助收敛到更优的局部最优点。
model(input_ids, attention_mask=attention_mask, labels=labels)这一行是HuggingFace封装的便利之处——传入labels后,返回的outputs.loss自动已经计算了交叉熵,不用自己再写F.cross_entropy。如果你用的是自己定义的模型类,就要手动算了:
logits = model(input_ids, attention_mask=attention_mask) loss = F.cross_entropy(logits, labels)两种方式本质是同一个loss函数,差别只在于封装程度。
4.3 评估指标:准确率之外还要看F1
训练结束后验证集上的准确率只是一个维度。当两个类别样本数量不均衡时,准确率会失真。比如负样本占90%时,模型全部预测负样本就有90%准确率,但正向样本一个没抓住。所以评估阶段至少要看准确率、精确率、召回率和F1四个指标。
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix def evaluate(model, val_loader, device): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for batch in val_loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) outputs = model(input_ids, attention_mask=attention_mask) logits = outputs.logits if hasattr(outputs, "logits") else outputs preds = torch.argmax(logits, dim=-1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) print(f"Accuracy: {accuracy_score(all_labels, all_preds):.4f}") print(f"Precision: {precision_score(all_labels, all_preds):.4f}") print(f"Recall: {recall_score(all_labels, all_preds):.4f}") print(f"F1 Score: {f1_score(all_labels, all_preds):.4f}") print(f"Confusion Matrix:\n{confusion_matrix(all_labels, all_preds)}") return f1_score(all_labels, all_preds)torch.argmax(logits, dim=-1)做的事情是对每个样本的2维logits取最大值所在的位置,0或1就是预测类别。no_grad上下文管理器让PyTorch跳过梯度计算,推理时显存占用大幅下降,速度也快很多。
提示:F1是精确率和召回率的调和平均,它在类别不均衡时比准确率更能反映模型真实水平。毕业设计答辩时,主动把F1和混淆矩阵贴在文档里,比单独报一个95%准确率更有说服力,因为混淆矩阵能展示模型在正负两类上的实际表现差异。
5. 避坑指南:跑通容易、跑好很难的高频踩坑点
5.1 现象:loss震荡不降,甚至越训越高
训练前几个step的loss在1.0附近徘徊,后面不降反升,训练曲线像锯齿一样剧烈抖动。这个现象多见于学习率设置过大、批次内噪声过多,或者是batch_size太小导致梯度方向不稳。
原因排查顺序:先看学习率,5e-5以上在中文BERT微调时就容易震荡,降到2e-5通常立竿见影。如果学习率没问题,看数据——文本里是否混入了大量空字符串,空文本在tokenizer处理后只剩[CLS]和[SEP]两个token,模型会把这些样本学成同一个向量,造成梯度方向冲突。解决方法是训练前过滤掉文本长度小于5的样本,因为过短的文本本身情感信息就不完整,标注质量也存疑。
5.2 现象:训练集准确率99%,验证集只有70%
这是典型的过拟合信号。BERT参数量很大,几万条样本不足以约束它。如果训练集数据只有5000条以下,模型可以把训练样本“背下来”。过拟合不一定是坏事,敏感度取决于差距幅度。差距在10个点以内可以接受,超过15个点就要干预。
解决手段有三个方向:第一,加大dropout概率,从0.3提到0.5;第二,缩小模型输入长度,max_length从128降到64,减少模型可“背诵”的信息量;第三,增加数据量,采集更多标注样本做补充。还有一个隐性手段是加大weight_decay,从0.01提到0.1,对BERT这种大模型来说强正则会影响表示空间的质心分布,效果有时比dropout更明显。
5.3 现象:显存不够,batch_size一调就OOM
16GB显存跑batch_size=32、max_length=128通常没问题,如果你用的是8GB甚至6GB显存,OOM几乎是必然的。此时一定要按顺序调整:先降batch_size到16或8,如果还OOM,再降max_length到64。
# 查看GPU显存占用 nvidia-smi如果你发现是其他进程占用了显存,可以用kill -9 PID清理。训练中途OOM还有一种情况是PyTorch缓存了历史计算图,此时在训练循环里加一句torch.cuda.empty_cache()会有帮助,但要放在optimizer.step()之后,用来清空中间变量缓存。OOM频繁还有一个容易被忽视的原因是padding="max_length"把短文本也补齐到128个token,这浪费了显存。更高效的做法是使用padding=True做动态padding,让DataLoader按batch内最长文本补齐,但由于自定义Dataset返回的张量已经固定形状,需要配合collate_fn使用。
5.4 现象:模型预测结果全部是0
训练完做推理,发现模型把所有文本都判成负向(类别0),或全部判成正向。出现这种“单边预测”时首先要检查混淆矩阵:如果模型在训练时看到的负样本占80%以上,它学会了“全猜负”这个偷懒策略。
另一个更隐蔽的原因是标签映射错位——比如CSV里label字段是字符串“0”和“1”,但数据里混入了空值,导致某一条样本的label被int()解析失败,转成了默认值。排查方法是打印一下train_dataset.labels的前100个值和set(train_dataset.labels),确认只有0和1。
5.5 现象:保存的模型重新加载后结果完全变了
训练完保存model.save_pretrained("sentiment_model"),下次加载再推理,输出的概率分布和训练时完全不一样。最常见原因是from_pretrained时忘记传num_labels,模型默认加载了原始BERT的MLM分类头,输出维度是词表大小21128而不是2,argmax结果自然乱套。
# 正确加载方式 model = BertForSequenceClassification.from_pretrained( "sentiment_model", num_labels=2 )如果模型保存的是整个配置加权重,model.config.num_labels应该已经被写入config.json,重新加载时可以不带num_labels。但你一旦用了自定义模型类,保存时就要torch.save(model.state_dict(), "model.pt"),加载时先实例化模型再load_state_dict,顺序反了会报Missing key(s) in state_dict错误。
6. 进阶技巧:把F1从90%推到95%以上的三个微调方法
第一个技巧是分层学习率。BERT底层捕捉的是通用语法和语义特征,顶层更接近任务特定特征,所以可以给底层更小的学习率。实现上用torch.optim的参数分组功能,把embedding层和浅层Transformer层的学习率设为1e-5,深层和分类头设为3e-5,让底层“微动”、顶层“多动”。
no_decay = ["bias", "LayerNorm.weight"] optimizer_grouped_parameters = [ { "params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay) and "bert.encoder.layer.0" in n], "lr": 1e-5, "weight_decay": 0.01 }, { "params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay) and "classifier" in n], "lr": 3e-5, "weight_decay": 0.01 } ]第二个技巧是数据增强。对中文文本情感分类而言,简单可靠的增强方式是同义词替换。比如“高兴”换成“开心”,“糟糕”换成“很差”,这种方法能提升文本多样性,但要注意替换后句子的情感极性不能反转。人工逐条审核增强样本太耗时,实践中我倾向于只对训练集中的少数类做增强,把正向样本用同义词替换扩增一倍,帮助模型摆脱类别偏置的惰性。
第三个技巧是伪标签半监督。用训练好的模型对无标注数据做预测,把高置信度(概率大于0.95)的样本打上伪标签混入训练集再微调一轮。这个技巧在数据量有限时效果突出,前提是伪标签准确率足够高,否则会引入噪声反而拖低F1。
我自己的习惯是:每换一组参数都保留一个带参数名后缀的模型文件,比如model_lr3e5_ep3_bs32_f1_0.93.pt。这算是我被“跑了一夜训练忘记录参数”坑过之后养成的不能算技巧的教训。后续想复习某个实验配置时,直接看文件名就能回忆全部关键设定。这套方案对文本长度在20到200字的中文短文本情感分类场景立得比较稳,如果遇到更长篇幅要自己按语境微调截断窗口,希望帮到你。
本文还有配套的精品资源,点击获取