☰
Python实现BERT中文文本情感分类:从环境搭建到微调实战
2026/10/7 3:05:41 网站建设 项目流程

简介:面向计算机相关专业学生的一款毕业设计项目,基于BERT模型实现中文文本情感分类,随包提供Python源码与完整的操作过程说明。设计经导师指导并获评审98分,源码已在本地编译调试通过,可直接运行,适合正在完成毕业设计、课程作业或需要项目实战练习的读者,也适合有一定Python与机器学习基础、希望快速上手深度学习文本分类的中级学习者;操作过程说明覆盖环境配置、依赖安装、数据准备、模型训练、结果评估等关键环节,便于按步骤复现。资源包共22个文件,约2.26MB,文件类型以Python脚本为主,共11个py文件,对应数据处理、特征抽取、模型定义、分类训练与预测等模块;另有2个Shell脚本便于一键执行训练/预测,2个CSV数据集、若干TXT/Markdown文档辅助理解流程与记录要点;目前已有75人浏览学习。使用者可获得一整套可运行的中文情感分类解决方案,既能学习BERT微调与文本分类的完整工程实现,也能参考项目的目录结构和文档组织方式,并借助操作说明降低复现门槛,加快自身毕业设计进度。

1. 中文文本情感分类这件事,为什么换 BERT 之后才真正“能落地”

第一次用 Python 做中文情感分类时,我照着许多教程先跑了词频 + 朴素贝叶斯,准确率在 82% 左右,看起来不错,可一换数据集立刻掉到 70% 以下。后来换成基于 BERT 的微调方案,不自己训练词向量,也不用手工设计特征,只把标注好的评论整理成文本和标签两列,训练三个 epoch,就在酒店和电商两个数据集上稳定跑到了 90% 上下。这里最关键的不是“BERT 比传统模型强”这种结论,而是数据处理、分词边界和训练参数这三个环节,任何一个做错都会让结果突然回到“玄学”。

这篇笔记就是围绕“Python 实现基于 BERT 模型的中文文本情感分类”这条主线写的,从环境搭建、数据组织、模型加载到训练评估,每一步给出能直接改着用的代码,再把最常见的翻车现场列出来。适合做毕业设计、课程项目,或者想快速在小规模数据上验证 BERT 效果的同学。

2. 环境与数据准备:先把能跑的底座搭稳,再谈模型效果

2.1 Python 依赖版本搭配:torch、transformers、scikit-learn 怎么选才不打架

做 BERT 微调的时候,常见做法是用 Hugging Face 的 transformers 库加载预训练权重,底层用 PyTorch 跑训练。这个组合最大的问题是版本之间兼容性比较敏感,尤其是 transformers 更新很快,API 在 4.x 之后已经稳定了许多,但我仍然建议先把版本钉住,避免刚装完环境就遇到“某个参数被移除了”这种报错。

我一般会先建一个干净的虚拟环境,再一次性安装依赖:

python -m venv .venv source .venv/bin/activate # Windows 上为 .venv\Scripts\activate pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.36.2 datasets==2.16.1 scikit-learn==1.3.2 pandas==2.1.4

这段命令里,PyTorch 我指定了 CUDA 11.8 版本,如果你的机器没有 NVIDIA 显卡,或者不确定驱动支持情况,可以直接改为pip install torch==2.1.2,CPU 版本也能跑,只是训练会慢几倍。transformers负责加载 BERT 权重和分词器,datasets用来做数据切分,scikit-learn主要为了计算 F1 分数和混淆矩阵,pandas负责读取 CSV。

参数上需要注意的有两点。第一,transformers==4.36.2里AutoModelForSequenceClassification的接口已经非常稳定,但如果你装的是更老的 3.x 版本,很多代码写法会不兼容。第二,PyTorch 和 transformers 的版本不要盲目追新,只要符合“torch 2.x + transformers 4.3x + datasets 2.x”这个组合,踩坑概率会小很多。装完之后可以用一行命令验证:

python -c "from transformers import AutoModel; m = AutoModel.from_pretrained('bert-base-chinese'); print(m.config.hidden_size)"

如果能看到输出768,说明模型成功加载,同时也说明你这台机器能够访问 Hugging Face 的模型仓库,或者本地已经缓存了权重文件。这一步虽然简单,却是整个项目里最容易被忽略的“黑匣子”:很多同学后面代码写对了,却卡在下载权重这一步上。

2.2 中文标注数据的组织方式:编码格式、标签分布与切分逻辑

中文情感分类的数据集一般就是一个 CSV 文件,里面包含两列:一列是评论文本,一列是标签。我这里以二分类为例,标签用0表示负面,用1表示正面。也可以用0/1/2做三分类,但二分类更容易把整体流程跑通。

第一步是把原始数据读进来,做基本清洗和标签映射:

import pandas as pd df = pd.read_csv("reviews.csv", encoding="utf-8") df = df[["review", "label"]].dropna() df["label"] = df["label"].astype(int) df = df[df["review"].str.strip() != ""] # 查看标签分布,避免数据严重不平衡 print(df["label"].value_counts(normalize=True))

这段代码看起来简单,实际上有两个容易被忽视的坑。

第一个坑是 CSV 编码。中文数据经常是utf-8或gbk保存的,如果读取时出现乱码或UnicodeDecodeError,多半是编码判断反了。我一般会先打印前五行,确认中文显示正常才继续。第二个坑是dropna()只删掉了空值,但有些评论文本可能是纯空格,所以后面还要加str.strip()把空白字符过滤掉。

数据准备好之后,建议先用train_test_split切分,而不是自己手写滑窗采样:

from sklearn.model_selection import train_test_split train_texts, val_texts, train_labels, val_labels = train_test_split( df["review"].tolist(), df["label"].tolist(), test_size=0.2, random_state=42, stratify=df["label"].tolist(), )

这里stratify参数非常关键。如果不加它,在样本量小或正负样本比例差异大的情况下,验证集里很可能全部都是正面评论,训练出来的模型在验证集上直接“翻车”。test_size=0.2表示 20% 的数据留给验证,random_state=42则是固定随机种子,保证每次跑出来的切分结果一致,这是做对比实验的基本要求。

另外,我强烈建议不要在数据清洗阶段过度处理中文文本。很多从传统 NLP 转过来的同学会习惯性去停用词、去标点,但 BERT 使用的是字级别的分词方式,去掉标点反而会让模型丢失语气信息,比如“好棒!!!”和“好棒”的情感强度完全不同。关于这一点,我在后面的避坑章节还会再提。

3. 加载 BERT 中文模型与 tokenizer:词汇表、CLS 与 512 上限

3.1 为什么默认选择 bert-base-chinese,而不是用翻译模型或英文 BERT

中文 BERT 最常用的预训练权重就是bert-base-chinese,它的词典是基于汉字构建的,每个汉字基本对应一个 token,少数常用词会拆成多个 token 的组合。直接拿它做中文情感分类,省去了自己训练分词模型的成本。

也许有人会问:能不能用bert-base-uncased跑中文?答案是可以,但效果通常很差。因为英文 BERT 的 tokenizer 会把中文字符切成[UNK],相当于模型什么都看不见,只能靠训练数据硬学,这样的做法已经脱离了预训练模型的语义基础。还有一条路径是使用chinese-roberta-wwm-ext这类权重,它和bert-base-chinese的代码写法完全一致,只是预训练时用了“全词掩码”策略,在情感分类这种任务上通常有 1 到 2 个百分点的提升。我给毕业设计做方案时,一般会把两个模型都跑一遍,选验证集 F1 更高的那个。

加载模型的代码非常短:

from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=2, )

num_labels=2指定了分类头输出的类别数量,模型内部会自动把 BERT 的最后一层隐藏状态送到一个线性层上做分类。不需要在代码里手动拼接 MLP,这是 transformers 封装好的能力。

如果机器显存不足,还可以在加载时加上torch_dtype="auto"让权重自动适配精度,或者在训练时使用梯度累积。不过最关键的一点是:AutoTokenizer和AutoModelForSequenceClassification必须使用同一个model_name,否则会出现 tokenizer 的词汇表与模型 embedding 维度对不上,报错信息很隐晦,后面排查起来非常耗时。

3.2 Tokenizer 的正确用法:padding、truncation 与返回张量类型的细节

加载完 tokenizer 之后,很多人直接拿中文句子去调用encode_plus,但有两个参数经常被忽略,一个是truncation=True,一个是padding=True。BERT 的输入序列长度上限是 512 个 token,如果一条评论超过这个长度,不截断就会报错;同一个 batch 里句子长度不一样,不填充就无法拼成张量。

下面这段代码展示了单条样本的编码过程:

sentence = "这家店的饭菜味道很好,但是服务态度真的太差了" encoded = tokenizer.encode_plus( sentence, max_length=128, truncation=True, padding="max_length", add_special_tokens=True, return_tensors="pt", ) print(encoded["input_ids"].shape) # torch.Size([1, 128]) print(encoded["attention_mask"].shape) # torch.Size([1, 128]) print(tokenizer.decode(encoded["input_ids"][0]))

参数说明如下:max_length设置为 128,表示超过 128 个 token 的部分会被截断,这个值需要根据实际评论文本长度来调整,我后面会专门讲;truncation=True必须显式声明,否则在 transformers 4.x 里某些模型会默认不截断;padding="max_length"表示把所有序列都补到 128 的长度,这样同 batch 内的 tensor 形状才能一致;add_special_tokens=True会自动在句首加[CLS],句尾加[SEP],这两个特殊 token 在情感分类里面意义重大。

[CLS]位置的输出向量会被模型用来做最终的分类判断。这是 BERT 预训练时设计好的机制:分类 token 聚合了整句话的语义表示。我在实际项目中通常不会手动取第二层或者平均池化输出,直接用model(...).logits就能拿标准结果,这也是AutoModelForSequenceClassification默认封装好的。

数据量大的时候,逐条调用encode_plus会非常慢,更高效的做法是用tokenizer直接批量编码:

train_encodings = tokenizer( train_texts, max_length=128, truncation=True, padding=True, return_tensors="pt", )

这里padding=True会自动把当前 batch 内最长的句子作为填充基准,而不是固定成 128。它的好处是节省算力,坏处是每个 batch 的长度不一致,在 DataLoader 里需要配合collate_fn或提前固定长度。为了减少可变长度带来的麻烦,我一般直接用padding="max_length",虽然多算了点空字符,但代码简单很多,速度损失可接受。

4. 微调训练与评估:从 loss 到 F1 的完整脚本

4.1 训练循环的数据流:把 tokenizer 结果封装成 Dataset 和 DataLoader

数据流的设计思路是:先拿到上一步的train_encodings,把它和标签一起拼成一个自定义 Dataset,再交给 DataLoader 按 batch 循环取数据。这个封装过程是 PyTorch 的标准套路,但有一个细节值得注意:attention_mask必须参与训练,否则模型不知道哪些位置是真实文本,哪些是填充符号。

import torch from torch.utils.data import Dataset, DataLoader class SentimentDataset(Dataset): def __init__(self, encodings, labels): self.encodings = encodings self.labels = labels def __getitem__(self, idx): item = {key: val[idx] for key, val in self.encodings.items()} item["labels"] = torch.tensor(self.labels[idx], dtype=torch.long) return item def __len__(self): return len(self.labels) train_dataset = SentimentDataset(train_encodings, train_labels) val_dataset = SentimentDataset(val_encodings, val_labels) train_loader = DataLoader( train_dataset, batch_size=16, shuffle=True, ) val_loader = DataLoader( val_dataset, batch_size=32, shuffle=False, )

这里batch_size是影响显存占用最直接的参数。如果显卡只有 4GB 显存,batch_size=16加max_length=128通常是安全的;如果显存不足,优先把 batch_size 降到 8 或 4,而不是去改模型结构。shuffle=True只在训练集使用,验证集要保持原始顺序,这样才能和标签做一一对应。

DataLoader 输出的每个 batch 是一个字典,里面包含input_ids、attention_mask和labels三个键。input_ids是 token 在词汇表中的索引,attention_mask是 0/1 掩码,labels是真实分类标签。训练时把这些全部传给模型,BERT 会自动计算交叉熵损失。

4.2 模型训练参数:学习率、epoch、AdamW 与线性衰减的原理

BERT 微调最常用的优化器是AdamW,它和普通 Adam 的区别在于权重衰减的实现方式。普通 Adam 会把权重衰减耦合在动量计算中,而 AdamW 把权重衰减单独拿出来,对分类头这种随机初始化的参数起到更好的正则化作用。学习率一般设置在2e-5到5e-5之间,这个范围是我们常说的“合适区间”,太大会导致预训练权重被快速破坏,太小则微调效果不明显。

from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * 3 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps, )

total_steps的计算方式是batch 数量 * epoch 数量,我这里按 3 个 epoch 计算。num_warmup_steps设为总步数的 10%,意思是训练刚开始的 10% 步数内,学习率从 0 线性升到设定值,之后再线性衰减到 0。这个 warmup 机制对 BERT 微调很重要,能避免在一开始就迈出太大步长导致 loss 剧烈震荡。

训练循环本身不复杂,但需要明确一点:模型返回的loss已经是所有样本的平均交叉熵,不需要自己再算一遍。完整训练代码如下:

model.train() for epoch in range(3): total_loss = 0 for batch in train_loader: optimizer.zero_grad() outputs = model(**batch) loss = outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) print(f"epoch: {epoch}, loss: {avg_loss:.4f}")

clip_grad_norm_这一行是很多人容易漏掉的反悔药。BERT 在训练后期偶尔会出现梯度范数突然变大,loss 瞬间变成 NaN 的情况,梯度裁剪把梯度的二范数限制到 1.0 以内,可以极大降低这种问题出现的概率。model.train()必须写,它让 dropout 层处于激活状态;评估的时候要改成model.eval(),否则可以得到“难以置信的好结果”,但是推理时又恢复原形。

4.3 评估脚本:准确率只是及格线,F1 才能反映出真实水平

训练结束后,要在验证集上跑一遍评估。二分类情感数据里,如果正面样本占了 80%,随便猜个“正面”就能拿到 80% 准确率,所以只看准确率会严重高估模型能力。我习惯同时打印准确率、精确率、召回率和 F1 分数。

from sklearn.metrics import classification_report model.eval() preds, true_labels = [], [] with torch.no_grad(): for batch in val_loader: outputs = model(**batch) logits = outputs.logits batch_preds = torch.argmax(logits, dim=-1) preds.extend(batch_preds.tolist()) true_labels.extend(batch["labels"].tolist()) report = classification_report( true_labels, preds, target_names=["negative", "positive"], digits=4, ) print(report)

torch.no_grad()强制在推理时关闭梯度计算,既省显存又提速。torch.argmax(logits, dim=-1)取出每个样本概率最大的类别下标,得到的preds是 0/1 数组,再与真实标签比较。classification_report的好处是把四个指标一次性输出,不用自己装表格。

如果打印出来的 F1 在 0.5 附近晃动,说明模型退化成了随机猜测;如果 F1 能到 0.88 以上,这个模型基本可以拿来做后续的演示界面。另外,epoch 数不要盲从“3 个”这个标准值。小数据集上 2 个 epoch 可能就饱和了,大数据集上 5 个 epoch 也可能还在涨。我会把每个 epoch 的验证 F1 打印下来,选择最高的那一个作为最终模型。

5. 避坑:BERT 情感分类常见的 5 个翻车现场

5.1 乱码与编码问题:CSV 读进来全是“锟斤拷”

现象是读入 CSV 后打印数据,中文全部变成乱码,或者程序直接抛UnicodeDecodeError。最常见的原因是把gbk编码的文件用utf-8去读,或者反过来。解决方法是先判断文件真实编码,再读取:

with open("reviews.csv", "rb") as f: raw = f.read() print(raw[:100])

看前 100 个字节里汉字是\xe4\xb8\xad还是\xd6\xd0,前者是 UTF-8,后者是 GBK。确定后再在read_csv里显式写encoding="gbk"或encoding="utf-8"。我踩过最狠的一次是同一个文件夹里有三个 CSV,两个 UTF-8 一个 GBK,全部用同一种编码读,结果有一个文件疯狂报错。后来我直接在数据准备阶段把所有文件统一转成 UTF-8,再往下走。

5.2 标签不平衡导致验证集“看起来很好,实际没法用”

现象是训练完打印报告,positive那一类的 F1 达到 0.96,但negative的召回率只有 0.3。原因是原始数据里负面样本太少,切分时又没有用stratify,导致验证集里几乎全是正面样本。解决方法是三个动作一起做:切分时必加stratify,训练时给少数类更大的权重,或使用weighted sampler。最简单的做法是在定义模型时带上类别权重:

from torch.nn import CrossEntropyLoss class_weights = torch.tensor([2.0, 1.0], device="cuda") loss_fn = CrossEntropyLoss(weight=class_weights)

但注意,AutoModelForSequenceClassification内部默认的 loss 是等权重的,想用类别权重就得在训练循环里自己取outputs.logits手动算 loss,不再使用outputs.loss。这点容易造成误用,如果发现权重没起作用,多半是还在用默认损失。

5.3 显存不足:max_length觉得无所谓,直接把程序跑死

现象是刚跑第一个 batch 就报CUDA out of memory。原因是把所有句子都按 512 长度编码,或者 batch_size 设置过大。我遇到一个数据集,评论平均长度只有 30 个字,但最长的有 800 字,之前直接套max_length=512,2GB 显存直接崩溃。解决方法是先统计文本长度分布,再按 90% 分位点设max_length:

lengths = df["review"].map(lambda x: len(tokenizer.encode(x, add_special_tokens=True))) print(lengths.describe(percentiles=[0.5, 0.9, 0.95]))

如果 90% 分位点是 90,那max_length=128就足够,除了少数超长句子会被截断,其余样本完全保留。显存仍然不够时,把 batch_size 调小到 8 或 4,并用梯度累积补足批大小,而不是直接换更大的显卡。

5.4 灾难性遗忘:学习率调到 5e-4,把 BERT 训成了“复读机”

现象是训练 loss 刚开始下降很快,到第二个 epoch 突然猛涨,或者验证集 F1 从 0.91 跌到 0.6。原因是学习率设置过大,微调过程把 BERT 预训练知识覆盖掉了。我见过别人直接把学习率套用 CNN 的1e-3,结果前 200 步 loss 还正常,之后整个模型输出趋近同一个 label。解决方法是把学习率严格控制在2e-5附近,必要时结合get_linear_schedule_with_warmup做衰减。如果学习率降了仍然震荡,就把 batch_size 和 warmup 比例同时调大,不要只调学习率。

5.5 加载模型时卡在下载权重,后续代码全不动

现象是from_pretrained一直卡在进度条,或者显示Connection error。在实验室环境、国内网络环境下这非常常见。解决方法是先把权重下载到本地,然后指定本地路径:

model_dir = "./bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_dir) model = AutoModelForSequenceClassification.from_pretrained(model_dir, num_labels=2)

from_pretrained会自动接受本地路径,前提是文件夹里面包含config.json、vocab.txt、pytorch_model.bin这些文件。这样做还有一个额外好处:之后每次跑代码都不用再依赖网络,复现环境更稳定。如果连下载本身都困难,可以考虑配置HF_ENDPOINT环境变量指向镜像站点,但这一点要根据你所在网络环境自行判断,我这里不展开。

6. 从“能跑”到“好用”:冻结参数、快速推理与一条保存检查清单

训练完模型后,离交付还有一步:把模型保存、加载,并写一个可以直接输入一句话就返回情感标签的推理函数。保存模型的常见做法是:

model.save_pretrained("sentiment_model") tokenizer.save_pretrained("sentiment_model")

这样目录下会生成pytorch_model.bin、config.json和vocab.txt,之后换机器部署时只需要重新载入:

model = AutoModelForSequenceClassification.from_pretrained("sentiment_model") tokenizer = AutoTokenizer.from_pretrained("sentiment_model")

如果你只是做毕业设计演示,不是追求极致推理速度,可以再做一个冻结 BERT 参数、只训练分类头的版本。做法很简单:在训练前把模型中所有 BERT 层参数设为requires_grad=False,只保留classifier层的梯度。这样训练速度会快不少,显存占用也下降,得到的分类头效果对数据集较小的情况反而更稳定。

for name, param in model.named_parameters(): if "classifier" not in name: param.requires_grad = False

推理函数是最后的验证入口。不要直接拿测试集的随机几条数据看效果,要拿完全没见过的评论来测。我习惯准备 10 条标准数据,其中包含“好、差、一般、带讽刺语气、混合情感”这几类,逐条检查输出:

model.eval() def predict(sentence): encoded = tokenizer.encode_plus( sentence, max_length=128, truncation=True, padding="max_length", add_special_tokens=True, return_tensors="pt", ) with torch.no_grad(): logits = model(**encoded).logits pred = torch.argmax(logits, dim=-1).item() return "positive" if pred == 1 else "negative" print(predict("这家店味道不错,下次还会来")) print(predict("等了一个小时还没上菜,太失望了"))

在写这篇操作过程之前,我其实也经历过跑通一个模型就把训练脚本丢在一边的坏习惯,后来做第二次项目时想复用,发现当时的max_length、随机种子、数据切分方式全都没记下来,只能凭着当时的输出反推。后来我改成每次程序跑完,顺手把超参数和最终 F1 记在一个简单的experiment_log.txt里,这个习惯帮我省掉了大量重复调参的时间。

现在你手里的这套流程已经覆盖了从环境准备到推理验证的完整链路,剩下要做的就是在自己的数据集上跑一遍,先跑通,再调参,把最关键的 F1 指标记下来。如果训练过程中遇到这里没有覆盖到的报错,优先去看 tokenizer 和模型加载那两段日志,八成问题出在版本或路径上。希望这篇笔记能帮到你,让你少走几个我踩过的弯路。

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

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

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

立即咨询