☰
BERT中文情感分析实战:从数据预处理到模型微调的完整指南
2026/10/9 6:42:26 网站建设 项目流程

简介:一套基于BERT的中文情感分析数据分类Python源码,面向毕业设计、期末大作业与课程设计场景,适合需要快速搭建情感分析模型并冲击高分项目的学习者。代码由个人手打完成,全程带有注释,新手也能看懂关键逻辑,下载后简单部署即可运行。压缩包共包含74个文件,主要有30个Python源码、25个CSV数据文件,以及文本说明、Shell脚本、Markdown文档等,整体大小约53.23MB,其中Python文件覆盖数据处理、模型构建、训练、预测等完整流程,CSV文件提供可直接使用的标注数据,脚本与文档辅助环境搭建和快速启动。目前已有255人学习下载,可用于毕业设计参考、课程作业提交或作为进一步研究的基础项目;除可运行代码外,资源还包含预处理工具、模型测试脚本和清晰的目录结构,方便逐模块理解BERT中文分类的实现细节,也便于替换自有数据后重新训练与评估。

1. 为什么人都说 BERT 做中文情感分析“没有门槛”,但真跑起来却天天翻车

用 BERT 做一个中文情感分析数据分类,标题看起来是一个理所应当的标准模型,但实际跑起来,模型代码通常只占整个项目不到 25%。我拿到过不少学生项目,模型都是 BERT,语言都是 Python,最后翻车的地方却高度一致:数据划分不严谨、tokenization 和 label 对不齐、训练时学习率过大导致 loss 起飞。这篇文章不打算从 Transformer 的论文讲起,重点是把你真正需要的环境、数据管线、训练循环和评估逻辑串起来,最后产出一个能交作业、也能应付“你这模型还能不能改”追问的完整 python 源码结构。适合从 baseline 跑通到想调优的人对照着改。

2. 不从头训练:先把 BERT 在项目里的角色拆清楚

2.1 BERT 是特征提取器,最终分类靠的是你加的那层分类头

BERT 本身不是分类器,它是一个预训练的语言表征模型。输入是一段文本,输出是每个 token 对应的上下文向量,以及整个句子对应的[CLS]向量。做情感分析时,常见做法是取[CLS]位置的输出,再把它丢进一个全连接层,全连接层的输出维度等于你的类别数。

理解这个结构有一个好处:你真的遇到“自定义分类头”的需求时,不会被 transformers 库里现成的类绑死。我自己写项目时习惯先看一眼BertModel的隐藏层输出有哪些,再决定从哪个位置取特征。常见的接口有三种:last_hidden_state是最后一层每个 token 的向量,pooler_output是经过 BERT 内置全连接和 tanh 的[CLS]向量,hidden_states是每一层的完整输出。

情感分析这种句子级别任务,绝大多数项目直接用pooler_output就够了。但有人会踩坑:用last_hidden_state[:, 0]取出[CLS]位置向量,和pooler_output是两套东西,混着用会莫名多一条维度对不上的报错。按我的习惯,二分类、三分类这种任务直接用官方分类头,省事。

2.2 用 PyTorch 把预训练权重和分类头拼起来

如果你不想用BertForSequenceClassification这种封装好的类,可以自己写一个浅层封装,代码不复杂,关键在于理解权重从哪来、梯度往哪传。

import torch.nn as nn from transformers import BertModel class BertClassifier(nn.Module): def __init__(self, pretrained_path="bert-base-chinese", num_labels=3): super().__init__() self.bert = BertModel.from_pretrained(pretrained_path) self.dropout = nn.Dropout(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 ) # pooler_output 已经经过 tanh,直接接分类头 pooled = outputs.pooler_output pooled = self.dropout(pooled) logits = self.classifier(pooled) return logits

这段代码里最值得留意的参数是num_labels。二分类写 2,三分类写 3,多标签任务不能用这种方式。dropout我一般放在 0.3 左右,情感分析数据量不大,太大容易让模型在验证集上表现不稳定。BertModel.from_pretrained("bert-base-chinese")会联网拉取预训练权重,国内部分网络环境会卡在这一步,后面踩坑章节专门说。

2.3 为什么我坚持加载“中文版”预训练权重

同样是bert-base-*,中英文任务不能混用。这里用bert-base-chinese是中文预训练权重,词表是中文的,tokenizer 会把一句话拆成一个个汉字,模型在中文语料上做过掩码语言模型预训练。如果你用bert-base-uncased去做中文情感分析,输入文本基本全部变成[UNK],模型等于在看一堆空符号,效果不会好。

还有个容易被忽略的点:分词粒度。英文 BERT 用 WordPiece,中文 BERT 直接按字切。所以你的中文数据不需要像传统 NLP 那样先经过 jieba 分词,直接丢给 tokenizer 反而是更好更省心的做法。刚上手的人总是习惯先把句子分个词,然后再丢给 BERT,结果把 token 之间的边界信息打乱了。中文 BERT 是把“[CLS] 我 爱 北 京 [SEP]”这种字序列作为输入。

3. 把一句话变成 BERT 认识的编号:Tokenizer 与 Dataset 的完整代码

3.1 中文 Tokenizer 的用法和几个关键参数

数据处理是整个 BERT 项目中最容易出错的地方。先记住一个原则:训练、验证、预测时用同一个 tokenizer,并且参数保持一致,否则模型看到的特征空间都不一样。

from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") text = "这家店的火锅味道不错,但是排队太久了。" encoded = tokenizer( text, max_length=128, padding="max_length", truncation=True, return_tensors="pt" ) print(encoded["input_ids"]) print(encoded["attention_mask"])

代码里的max_length=128是个需要结合数据实际情况调整的参数。酒店评论、电商评论通常 128 够用,有些长评会到两三百字,截断以后信息会损失。padding="max_length"表示把短句补齐到 128 长度,这里需要注意:补齐的部分在attention_mask里会标记为 0,模型计算注意力时会忽略这些位置。truncation=True解决超长文本问题,代价是超过 max_length 的尾部文字会被直接丢弃。如果你的数据里长句很多,优先把 max_length 调到 256,而不是靠截断硬撑。

3.2 写一个返回 PyTorch 张量的 Dataset 类

数据从 CSV 读进来以后,不要直接写循环一条一条喂给模型。正确做法是把数据封装成torch.utils.data.Dataset,好处是配合 DataLoader 做 shuffle、batch 和并行读取都很方便。

import torch from torch.utils.data import Dataset class SentimentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_length=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_length = max_length def __len__(self): return len(self.texts) def __getitem__(self, idx): text = str(self.texts[idx]) label = self.labels[idx] encoded = self.tokenizer( text, max_length=self.max_length, padding="max_length", truncation=True, return_tensors="pt" ) return { "input_ids": encoded["input_ids"].squeeze(0), "attention_mask": encoded["attention_mask"].squeeze(0), "labels": torch.tensor(label, dtype=torch.long) }

这段代码里最常见的坑是return_tensors="pt"会多出一个 batch 维度,squeeze(0)就是把这个维度去掉,否则 DataLoader 拼接 batch 时会出现维度不一致的问题。labels必须转成torch.long,否则后面 CrossEntropyLoss 会直接报类型错误。文本转成str是为了防止 CSV 里出现空值或者 NaN 导致 tokenizer 崩溃。

3.3 标签分布和训练集划分

写 Dataset 之前先看一眼标签分布。这里不是空话,情感分析数据集的标签往往极其不均衡,比如“好评”占八成,“差评”占一成,“中评”占一成。如果不处理,模型只要全部预测成“好评”就有 80% 准确率,看着指标很高,实际上没有区分能力。

你的程序里需要处理两件事。第一,用sklearn.model_selection.train_test_split按标签做分层划分,也就是stratify参数,保证训练集和验证集里的类别比例大致一致。第二,如果正负样本比例超过 3:1,给 CrossEntropyLoss 传入weight参数,让少数类在损失函数中占比更高。

from sklearn.model_selection import train_test_split train_texts, val_texts, train_labels, val_labels = train_test_split( texts, labels, test_size=0.2, random_state=42, stratify=labels )

关键参数是random_state=42。固定随机种子以后,每次跑出来的训练集和验证集划分是一致的,这能保证你调整超参数时看起来的差异不是数据划分造成的。stratify 必须传,否则划分出的类别分布会和原始数据分布不一致,模型训练和评估的基准就不对。

4. 训练脚本:从 loss 回传、学习率调整到保存最好的 checkpoint

4.1 最小可运行的训练循环

模型和数据都准备好以后,训练代码可以写得很短,但里面的细节决定你是跑通还是跑炸。下面是一段我常用的训练主循环,过滤掉了日志和可视化,保留最核心的逻辑。

from transformers import BertForSequenceClassification, AdamW from transformers import get_linear_schedule_with_warmup from torch.utils.data import DataLoader import torch.nn as nn model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=3 ) train_loader = DataLoader( train_dataset, batch_size=16, shuffle=True, num_workers=2 ) optimizer = AdamW(model.parameters(), lr=2e-5) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps ) loss_fn = nn.CrossEntropyLoss() model.train() for batch in train_loader: input_ids = batch["input_ids"] attention_mask = batch["attention_mask"] labels = batch["labels"] outputs = model( input_ids=input_ids, attention_mask=attention_mask, labels=labels ) loss = outputs.loss loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad()

这段代码里,clip_grad_norm_这行很多人会删掉,但建议保留。BERT 这类大模型在训练初期偶尔会出现梯度爆炸,梯度裁剪等于给梯度设了一个范围,超过这个范围就缩放回去,能在一定程度上避免 loss 突然变 NaN。warmup_steps设置为总步数 10%,目的是让模型在开头几步用较小的学习率试探,然后再逐渐升到设定值,避免一开始就大步更新把预训练权重冲坏。

4.2 几个必调的参数和它们的实际效果

BERT 微调不是把模型加载出来就完事,参数组合对结果的影响,在情感分析这种任务上往往比“换一个更大的模型”更明显。我把默认值和我自己的偏好放在一起做一个参考表。

参数常见范围我一般用影响
learning_rate1e-5 ~ 5e-52e-5过大会导致 loss 震荡,过小训练速度慢到无法接受
batch_size8 ~ 3216受显存限制,Batch 越大收敛越稳定
max_length64 ~ 256128太短截断关键信息,太长浪费显存
epochs2 ~ 53多了容易过拟合,少了欠拟合
warmup_steps0 ~ 20%10%让训练前期学习率平滑上升
dropout0.1 ~ 0.50.3防止分类头过拟合

batch_size和max_length直接决定显存占用。如果你用本地电脑跑,显卡只有 6G 显存,max_length=128配batch_size=16不一定跑得动,最稳妥的办法是先把 batch_size 降到 8,或者把 max_length 降到 96。记住一个顺序:优先降 max_length,再降 batch_size,因为短文本截断的影响通常没有 batch 太小带来的梯度噪声大。

4.3 验证阶段:每个 epoch 算一次准确率和混淆矩阵

训练不是跑完几个 epoch 就完事,一定要在验证集上看每一轮的表现。验证时要把模型切到 eval 模式,否则 dropout 还在工作,同一个样本每次预测的结果都会不一样。

model.eval() correct = 0 total = 0 with torch.no_grad(): for batch in val_loader: input_ids = batch["input_ids"] attention_mask = batch["attention_mask"] labels = batch["labels"] logits = model(input_ids, attention_mask=attention_mask).logits preds = torch.argmax(logits, dim=1) correct += (preds == labels).sum().item() total += labels.size(0) val_acc = correct / total print(f"val_acc: {val_acc:.4f}")

torch.no_grad()这里非常关键,它告诉 PyTorch 不需要计算梯度,既省显存又能加速推理。计算准确率时,我一般会顺便保存每个样本的预测结果和真实标签,最后用 sklearn 的confusion_matrix输出一张矩阵,这比单看一个准确率数字能发现更多问题,比如模型是不是把所有中评都预测成了好评。

5. 从做这个项目里踩出来的 5 个坑:乱码、显存溢出和学习率玄学

5.1 loss 一开始就是 NaN

现象:训练第一轮打印 loss,发现是nan,或者训练到中途 loss 变成了nan。

原因:最常见的还不是学习率太大,而是数据里有脏字符。中文数据集里混入无法被 tokenizer 识别的字符,可能会被转成[UNK],但如果 CSV 文件编码不对,整个字符串会被解析成无效的 Unicode,模型输入张量直接变成异常的填充值。

解决:读取 CSV 时显式指定编码,不要依赖系统默认。我自己一般在pd.read_csv("data.csv", encoding="utf-8")后面再加一句清洗逻辑,把不是常见中文和标点的字符全部替换成空字符串。

5.2 在 predict 时忘了 model.eval()

现象:训练完手动拿一条测试句子去预测,结果每次跑出来的概率都不一样。

原因:模型还处于训练模式,Dropout 层在持续随机丢弃部分神经元,每次前向传播网络结构都有微小差异。

解决:在预测代码前加model.eval(),然后在torch.no_grad()上下文里执行推理。这两行是聊胜于无的保底操作,但很多人就是会忘。

5.3 GPU 显存溢出

现象:训练刚开始没有任何报错,跑到第一个 epoch 的一半弹出CUDA out of memory。

原因:显存占用是batch_size × max_length × hidden_size的倍数关系,BERT 本身 12 层 Transformer 的中间激活值很大,加载预训练权重之后显存已经所剩不多。

解决:先检查batch_size,从 8 开始;如果还溢出,把max_length从 128 降到 64;再不行就把num_workers改小。还有一个选项是开启model.gradient_checkpointing_enable(),用时间换显存,训练会慢一些。

5.4 验证集分数很高,到新数据上表现崩塌

现象:在划分出的验证集上准确率到 90% 以上,但拿几条真实新评论测试时,模型表现很差。

原因:数据划分时没有把同一来源的重复文本处理掉。爬取的数据里经常有大量重复评论,或者近乎重复的句子,训练集和验证集都含有同一批模板文本,模型学会了“背答案”而不是“读情感”。

解决:训练之前先对文本做去重,建议同时去掉字符级别完全一样的样本和只相差一个标点的近似重复样本。两种方法就是先转成集合再去重,再用difflib做一个简单模糊匹配,把相似度高于 0.9 的过滤掉。

5.5 多分类只支持二分类,模型预测只有 0 和 1

现象:数据集明明有“正面、负面、中性”三个类别,但训练出来模型预测不出中性的样本。

原因:标签编码那里出了问题。常见情况是 CSV 里标签列是字符串,比如“好评”“差评”,但在划分训练集时忘了把所有字符串转换成统一的数字编码,导致模型把某两个标签当成同一个类别。另一个情况是数据集本身中性样本极少,模型在训练阶段根本没学到中性的边界。

解决:在数据预处理阶段用独立的字典把字符串标签映射成索引,并检查训练集和验证集的标签集合是否一致。这个部分最好写一个简单的断言,发现不一致立刻停掉。

6. 离线跑通之后,把预测代码包装成一个别人能直接调用的函数

很多毕业设计到了这一步就停了,模型训练完,测试集上输出了几行准确率,然后就没有然后了。但一个能展示的完成度更高的项目,应该有一个独立存在的预测模块,让其他人拿一条文本就能拿到分类结果,而不是重新打开 Jupyter Notebook。

import torch from transformers import BertTokenizer, BertForSequenceClassification model_path = "./checkpoint/best_model" tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model = BertForSequenceClassification.from_pretrained(model_path) model.eval() def predict_sentiment(text): encoded = tokenizer( text, max_length=128, padding="max_length", truncation=True, return_tensors="pt" ) with torch.no_grad(): logits = model( input_ids=encoded["input_ids"], attention_mask=encoded["attention_mask"] ).logits pred = torch.argmax(logits, dim=1).item() label_map = {0: "负面", 1: "中性", 2: "正面"} return label_map[pred]

这个函数的核心价值不在代码量,而在于你已经把模型加载、预处理和推理放在了同一个函数里。以后你想接一个 Web 接口,直接在predict_sentiment外面套一层 Flask 路由就行。我自己走了一遍之后,最大的习惯变化是每个epoch都保存一次验证集最优的 checkpoint,而不是训练完只留最后一版,因为最后一版过拟合的风险很高。你如果也想在答辩时说清楚“为什么这样调参”,最好在本地把每个 checkpoint 对应的混淆矩阵打印出来,选出一个在验证集上综合表现最好的版本,而不是盲目追求准确率最高。

希望这个完整的 BERT 中文情感分析 python 源码路线能帮你少走一段弯路,把精力真正放到数据质量与评估的细节上。

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

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

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

立即咨询