简介:这份压缩包是一套面向毕业设计的中文文本情感分类项目实现,基于BERT预训练模型与Python开发,适合自然语言处理入门者及需要快速完成课程设计的学生。项目覆盖数据准备、Tokenizer词元化、BERT微调、分类层设计与输出预测等关键环节,可使用THUCNews、ChnSentiCorp等常见数据集进行训练与评估。资源共23个文件,其中11个Python脚本为主要源码,覆盖模型定义、优化、特征提取与推理模块;2个sh脚本用于一键训练和预测,2个csv与2个txt提供数据样例,另有1个txt为依赖清单,2个md为使用文档,压缩包仅2.42MB,整体轻量且目录清晰。目前已有993人浏览学习,适合参考完整源码、数据样例及操作说明来复现实验,或在此基础上针对特定场景调整模型、更换数据集进行二次开发。
1. 这届毕业设计,凭什么选BERT做中文文本情感分类
很多人拿到这份《毕业设计-Python实现基于BERT模型的中文文本情感分类项目源码+操作过程.zip》时,第一反应都是解压、装包、跑起来。但真正动手后会发现自己卡住的往往不是BERT本身,而是conda环境、数据编码、label映射这类不起眼的小问题。这篇文章就是照着这个标题,把从数据准备、tokenizer、训练循环到工程化推理的完整路径拆开讲,也顺便说清楚在什么场景下BERT确实是值得选的那条路,什么时候又该换个更轻量的方案。无论是毕业设计要交代码的同学,还是想借这个项目做BERT模型实操入门的中文NLP开发者,按着下面的步骤走完,就能从“解压了一个zip”变成“真正改得动、训得起、能交付”的BERT情感分类项目。
2. BERT模型实操前先想清的事:为什么选它、选哪个预训练权重、CLS向量够用吗
2.1 为什么中文情感分类尤其值得选BERT,而不是LSTM或TextCNN
中文情感分类不像新闻分类那样靠几个主题词就能套对。情感往往藏在转折、否定和反讽里,“这家店真行”可能是夸服务,也可能是气到语无伦次;“太好吃了”也可能是“太差了”的方言表达。这类判断依赖整句话的上下文,而BERT的双向Encoder在掩码预训练时同时看过两个方向的词,天然能把“前面在铺垫、后面才是真情绪”这种结构存进表示里。LSTM虽然也能建模上下文,但它的状态是单向传递,对长句子里的远距离负向线索很容易衰减;TextCNN擅长抓“垃圾”“难吃”这类局部强信号,可一旦句子变成“虽然没有想象中难吃,但也不算好吃”,仅靠n-gram特征就很难稳定判断极性。
另一个现实问题是中文分词。如果走传统流程,先分词、再训练词向量、再搭分类网络,分词错误会一路传递到分类结果,词表外的词直接变OOV。BERT用的是WordPiece切分,字符级上做BytePair编码,不会把“凡尔赛”切成“凡/尔赛”后丢掉语义,也不依赖你额外维护一份词典。这个特性在社交媒体文本上特别有用,因为网络表达每天都在造新词。所以做毕业设计也好,做真实业务验证也好,BERT在这个任务上的投入产出比是最合适的:你几乎不需要自己做特征工程,把注意力放到数据上就行,模型本身已经把中文语法的坑填了大半。
2.2 预训练权重怎么选:bert-base-chinese、RoBERTa-wwm 还是其他
网上能下载到的主流中文预训练权重不少,常见就几种,选错最多是效果差一两个点,但选对能少走很多弯路。我一般按下面这张表来定:
| 权重名称 | 参数量 | 特点 | 适合场景 |
|---|---|---|---|
| bert-base-chinese | 约108M | 词表全、文档最多、出错少 | 毕业设计、通用文本、第一次跑通 |
| hfl/chinese-roberta-wwm-ext | 约108M | 全词掩码,词边界更准 | 数据量中等,想让指标好看一点 |
| hfl/chinese-roberta-wwm-ext-large | 约330M | 效果更强 | 有GPU且显存充裕,追求极值 |
| albert_chinese_tiny | 约4M | 体积小、速度快 | 演示demo、无GPU环境 |
默认选择是bert-base-chinese,理由很简单:它是在全量中文语料上预训练的,很多第三方库和教程都以它为例,源码即使有版本差异也能查得到对应文档。如果训练数据量有两三万条以上,换hfl/chinese-roberta-wwm-ext往往能比base高1到2个点的F1,因为全词掩码让它在中文词边界上学得更细。换权重不需要改代码结构,只需要把from_pretrained("bert-base-chinese")里的名字换成上面表格里的对应值,但要注意第一次运行会重新下载权重,显存占用也会跟着模型大小一起变。
2.3 CLS向量直接接分类头够不够用
BERT模型的输出里,每个token都对应一个上下文向量,第一位的[CLS]通常被当作整句语义的汇总表示。做情感分类时最常见的做法就是取这个CLS向量接一个全连接层,或者更省事一点,直接用BertForSequenceClassification,它会替你把CLS取出来、过Dropout再过线性层。但别把CLS当成一个“读出来就能分类”的黑匣子,预训练阶段它并没有专门被训练来压缩情感信息,只有经过几轮下游微调后,CLS里才会慢慢形成对当前任务有用的聚合特征。
实际使用时有三个容易忽略的细节。第一,必须做微调,直接拿预训练权重预测是几乎随机的,因为任务目标完全不一致;第二,分类头里的Dropout不要省,尤其是在数据量只有几千条的时候,没有Dropout很容易让CLS被少量训练样本带偏;第三,如果任务里出现过拟合,不要急着换更大的模型,先看训练和验证loss的差距,差距大说明分类头在“背题”,而不是BERT没有理解语义。这个判断方法比盯着准确率曲线管用得多。
3. 落地第一步:Python环境、依赖与中文数据集的准备
3.1 从zip开始:解压、目录约定与环境隔离
拿到项目压缩包后,我建议先别急着双击运行,先把目录规范好。第一步是解压到纯英文路径下,比如~/workspace/bert-sentiment,不要放在“桌面/新建文件夹(2)”这种带中文和空格的路径里。原因有两个:一是一些脚本用os.path拼路径时遇到中文容易在Windows上出现编码问题;二是你后面要用虚拟环境,路径越干净越不容易踩雷。解压后先打开源码看一眼,通常会有训练入口和预测入口两个角色,但先不要改代码,把依赖环境搭好再动。
我习惯把项目根目录分成四块:data/放原始和清洗后的数据,src/放源码,checkpoints/放模型权重,logs/放训练日志。这样做的最大好处是后续做实验、对比模型、回滚代码时,所有产物都有明确去处,不会出现“模型文件散落在home目录各个角落”的局面。然后创建虚拟环境,隔离项目依赖,避免和你平时写爬虫或做数据分析的Python环境互相污染。
# 在项目根目录执行 cd ~/workspace/bert-sentiment # 创建并激活虚拟环境 python3 -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate.bat # 安装核心依赖,torch 和 transformers 按当前最新稳定版即可 pip install torch transformers datasets scikit-learn这几条命令里,venv是为了让Python包依赖变成项目私有,装坏了直接删目录重建,不用动全局环境。torch和transformers是跑BERT的底座,datasets用来做数据加载和切分,scikit-learn用来算准确率和混淆矩阵。如果你在官网下载Python时选择了系统自带版本,建议先确认python3 --version是不是3.8到3.11之间,太老的版本跑transformers 4.x会报一堆奇怪的类型错误。
3.2 数据准备的三种来源与最小格式
做中文文本情感分类,数据无非三个来源:直接用公开标注好的影评或商品评论;自己爬虫抓的评论再人工打标;第三方标注平台回传的数据。无论哪种,最终都要转成同一个最小格式:两列,一列是文本,一列是标签。标签建议直接用数字0和1,0代表负面,1代表正面,后续做三分类时再加一个2代表中性。不要用“正面”“负面”这类中文字符串直接喂给模型,因为分类层输出的是整数索引,字符串要先映射成索引,映射的时机越早越好。
import csv def load_data(path, label2id={"负面": 0, "正面": 1}): texts, labels = [], [] with open(path, encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: if not row["text"] or not row["label"]: continue texts.append(row["text"].strip()) labels.append(label2id.get(row["label"], -1)) # 扔掉无法识别的标签,避免训练时出现脏label valid = [(t, l) for t, l in zip(texts, labels) if l != -1] return [t for t, _ in valid], [l for _, l in valid]这里编码用utf-8-sig而不是utf-8,是因为Windows下用Excel另存的CSV很可能带BOM头,直接按utf-8读的话第一列列名会变成“text\ufeff”之类的东西,字段匹配不上,数据量直接少一行。这个坑在真实项目中碰到过不止一次,属于典型的“代码没问题但数据有编码”翻车情况。标签映射为什么要提前做:BERT训练时label必须是从0开始的连续整数,如果你在循环里一边读数据一边转,shuffle之后映射关系错乱,后果会在第5章看到。
3.3 让tokenizer替你做分词:max_len、padding、truncation
BERT接收的输入不是原始字符串,而是一串已经映射成id的token。tokenizer就是负责这个转换的组件,它内部有一个词表和一堆切分规则。用的时候不用自己调分词算法,但必须理解三个参数:max_length代表每条样本最多保留多少个token,truncation=True表示超长部分直接截断,padding=True表示把不足长度的部分用0补齐。中文单字成词密度高,一句话平均20个字左右,50个token通常够用;但评论类文本经常一段写到100字以上,所以128是一个在效果和速度之间比较平衡的默认值。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") sample = "这家店的酸辣粉太香了,下次还要来" encoded = tokenizer( sample, max_length=128, truncation=True, padding="max_length", return_tensors="pt", ) print(tokenizer.tokenize(sample)) print(encoded.input_ids[0][:15])第一次运行from_pretrained("bert-base-chinese")时会自动下载词表到本地缓存,如果网络不太好会卡在进度条上,这种情况可以先把下载任务完成再继续,或者把模型权重从别人那拷到本地路径直接加载。tokenizer.tokenize打印出来的结果会看到中文被拆成一个个单字或字块,这正是BERT处理中文的底层方式,不需要再外挂任何分词工具。return_tensors="pt"的意思是返回PyTorch张量格式,后面可以直接喂给模型,少一次手动转换。
4. 核心实现:Dataset、训练循环与参数调优(含可直接抄的代码)
4.1 构建Dataset类:把text和label接入到模型
PyTorch训练的第一步是写一个Dataset类,它负责按索引取出一条样本,并转成模型能吃的张量。不要图省事把整个列表都交给模型,因为训练时会随机采样、打乱顺序、分批读取,Dataset类在数据量大时会明显节省内存。下面是这次项目里最核心的Dataset实现,直接替换成你自己的数据路径就能用。
import torch from torch.utils.data import Dataset class TextDataset(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 = self.texts[idx] label = self.labels[idx] enc = self.tokenizer( text, max_length=self.max_len, truncation=True, padding="max_length", return_tensors="pt", ) return { "input_ids": enc["input_ids"].squeeze(0), "attention_mask": enc["attention_mask"].squeeze(0), "label": torch.tensor(label, dtype=torch.long), }逻辑说明:__getitem__每取一条样本就调用一次tokenizer,把中文文本转成input_ids和attention_mask。squeeze(0)是为了去掉batch维,因为return_tensors="pt"返回的shape是(1, seq_len),但Dataset的单条样本应该只有(seq_len)。label转成torch.long是分类损失函数的硬性要求,交叉熵不接受float类型标签。参数上max_len在初始化时统一传入,方便后续实验时在128和256之间切换,不用改内部代码。
4.2 用Trainer跑最小训练:参数设置与评估指标
如果从头手写训练循环,要处理梯度、学习率调度、日志、评估、模型保存,这些逻辑写完就上百行。用Hugging Face的Trainer可以省掉这部分重复劳动,它和BertForSequenceClassification配合很顺。下面是一份能直接跑通的代码。
from transformers import ( BertForSequenceClassification, TrainingArguments, Trainer, ) from sklearn.metrics import accuracy_score, f1_score model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=2, ) train_args = TrainingArguments( output_dir="checkpoints", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=5, weight_decay=0.01, warmup_ratio=0.1, logging_steps=50, save_strategy="no", evaluation_strategy="epoch", report_to="none", ) def compute_metrics(eval_pred): logits, labels = eval_pred preds = logits.argmax(axis=-1) return { "accuracy": accuracy_score(labels, preds), "f1": f1_score(labels, preds), } trainer = Trainer( model=model, args=train_args, train_dataset=train_set, eval_dataset=dev_set, compute_metrics=compute_metrics, tokenizer=tokenizer, ) trainer.train()参数说明:learning_rate=2e-5是微调BERT最常用的起点,太大会破坏预训练权重学到的表示,太小则收敛慢,新手不要轻易改成1e-3这种量级。num_train_epochs=5在情感分类这类小数据集上属于合理范围,训练到第3轮后如果验证集F1不再上升反而下降,就是过拟合信号,需要早停。save_strategy="no"表示训练结束后再手动保存,避免每500步写一个检查点把磁盘撑爆。evaluation_strategy="epoch"表示每轮结束自动评估一次。如果你用的transformers版本较新,IDE可能会提示evaluation_strategy已改名成eval_strategy,把参数名换成eval_strategy即可,不影响逻辑。
compute_metrics里同时返回accuracy和F1的原因很直接:如果正负样本数量不均衡,比如负面评论只占20%,模型全猜“正面”也能有80%准确率,但F1会很低。只看accuracy做决策很容易被表面数字骗过去。
4.3 如果你不想用Trainer:手动训练循环怎么改
Trainer虽然方便,但它把细节包得太严实,有时候想打印中间层输出、加特殊约束,反而很难插进去。如果源码里用的是手动训练,或者你想自己控制每个batch的细节,下面是最小可用的手动循环骨架。注意这套写法只是为了让你看明白反向传播的流程,生产环境里还是优先用Trainer或者在此基础上补全学习率调度。
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5) scheduler = torch.optim.lr_scheduler.LinearLR( optimizer, start_factor=1.0, end_factor=0.0, total_iters=5 ) model.train() for epoch in range(5): total_loss = 0 for batch in train_loader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss loss.backward() optimizer.step() optimizer.zero_grad() total_loss += loss.item() scheduler.step() print(f"epoch {epoch} loss {total_loss / len(train_loader):.4f}")这套循环的运行逻辑是:每次从train_loader取一个batch,把tensor搬到GPU,前向得到loss,反向传播算梯度,optimizer.step()更新权重,zero_grad()清空梯度防止累加。LinearLR的作用是在训练后期把学习率逐渐降到0,避免参数在最优解附近来回震荡。很多新手在手动循环里最常见的错误是忘了optimizer.zero_grad(),导致每一batch的梯度叠加在一起,loss曲线忽高忽低像过山车。如果遇到这种情况,先检查这一步。
5. 常见问题与避坑:从label错位到显存溢出的五条血泪经验
5.1 label映射不一致,训练loss像开奖一样乱跳
现象:训练开始时loss一直在2.0附近徘徊,偶尔跳到0.7又弹回去,验证集准确率稳定在30%或50%这种“等比瞎猜”的水平。原因:把字符串标签转整数时用了不稳定的办法,比如在循环里对每个字符串调用hash(),或者当天用了“负面=2、正面=0”的映射,第二天又改成“负面=0、正面=1”。解决:在所有数据处理前定义一次label2id字典,统一转好后检验一下唯一标签值是不是{0, 1},再去build Dataset。如果标签里混入了-1或2,BertForSequenceClassification的输出维度是2,loss依然会算,但梯度方向完全是错的。
5.2 GPU显存直接爆炸,batch还没跑完就被OOM中断
现象:CUDA out of memory,训练刚开始没几步就崩了。原因:max_len=512、batch_size=32、模型权重又占了一大块显存,几个因素叠加起来直接超限。解决:先把max_len降到128,再看per_device_train_batch_size,8到16之间比较稳;如果还想加大有效batch,用gradient_accumulation_steps把几个小batch的梯度累加起来再更新,效果等同于大batch但内存压力小得多。注意gradient_accumulation_steps>1时,optimizer.step()要在累计够之后再执行,手动循环里需要额外维护一个计数器,别让每个batch都更新一次。
5.3 训练时acc很高,重新加载模型后推理效果却明显下滑
现象:训练日志里acc接近0.98,保存模型后重开一个新文件加载,预测结果跟随机差不多。原因:最常见的有两种,一个是训练和推理时分别用了不同的tokenizer名字,比如训练用的bert-base-chinese,推理时加载成了别的权重;另一个是标签映射没一起保存,训练时认为label是0负面、1正面,推理时却把0当成正面。解决:把保存和加载写成一对固定动作,用model.save_pretrained("saved_model")和tokenizer.save_pretrained("saved_model"),推理时用saved_model同时加载模型和tokenizer;同时把label2id.json放在同目录,加载时先读它。不要用torch.save(model.state_dict())单独存权重,容易漏掉模型配置。
5.4 Windows下解压zip后文件名变成乱码,路径怎么都找不到
现象:从压缩包解压出来的文件,有些文件名是“銆愭搷浣溿€戝伐绋�”这种火星文,双击也打不开。原因:zip压缩包里的中文文件名有一部分是用GBK编码写入的,而Python的zipfile模块按UTF-8去解码,两者对不上。解决:解压前把整个压缩包路径改成纯英文,解压后马上排查根目录下的文件名;如果有乱码文件,手动重命名为英文字母。更根本的做法是让源码里的所有路径都写相对路径,而不是C:\Users\张三\Desktop\...这种绝对路径。这套规则同样适用于把模型文件从Windows拷贝到Linux服务器的场景,文件名一乱,服务端的加载代码必然跟着乱。
5.5 from_pretrained下载权重卡住不动或反复超时
现象:第一次运行BertTokenizer.from_pretrained("bert-base-chinese")时卡在下载进度条,半个小时后提示超时。原因:默认从Hugging Face服务器下载,网络链路一旦不稳定就会一直重试。解决:设置环境变量指向国内镜像,在命令行执行export HF_ENDPOINT=https://hf-mirror.com,然后重新运行脚本。下载完成后权重会缓存到本地目录,后续即使断网也能用。项目zip里如果自带模型权重文件,可以直接把from_pretrained里面的名字改成权重文件所在的本地目录,比如from_pretrained("./model/bert-base-chinese"),这样完全绕开下载步骤,也方便做离线部署。
6. 进阶用法:让情感分类模型真正能拿出来用
6.1 封装推理类并做置信度过滤
训练完成只是第一阶段,真正把它交出去或者在业务里用,还需要一个稳定的推理接口。最简单的做法是用pipeline,它能自动加载模型、tokenizer、做预处理和后处理。但要拿到所有类别的概率,需要设置top_k=None,不然默认只返回概率最高的那一个。
from transformers import pipeline clf = pipeline( "text-classification", model="./saved_model", tokenizer="./saved_model", top_k=None, ) result = clf("今天又被甲方气到了,改了三版方案全部推翻") print(result)这段代码输出的是所有类别的得分列表,比如正面0.12、负面0.88,方便你设定“置信度阈值”。我一般把0.7作为默认门槛,低于这个值的样本不直接进入下游流程,而是转人工复核。原因很简单,情感分类模型在明显正负样本上通常很自信,但遇到反讽、省略号、网络黑话时会处于模棱两可状态,这时硬给一个标签不如把它筛出来,宁可让人看三条模糊样本,也好过让一条错误标签污染后续统计。
6.2 验证模型的最快方法:不要只盯着准确率
很多人训练完只看准确率一点:“0.92,可以了。”实际上如果数据里90%是正面评论,模型全猜正面都能达到0.9,这不能说明模型学到了情感。正确做法是打印出混淆矩阵和每一类的precision、recall、F1。下面这段代码虽然简单,但每次训练完都要跑一遍,比日志里的acc值更有说服力。
from sklearn.metrics import classification_report, confusion_matrix print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred))之前做类似项目时,我一度被0.95的准确率蒙住,后来检查混淆矩阵才发现负面样本的召回率只有0.35,模型把大量负面评论都归类成了正面。原因是训练数据不均衡,模型学到的决策边界整体偏向多数类。后来加了类别权重和分层采样,F1才真正提上去。这次做BERT情感分类时我学乖了,每次trainer评估完都会把验证集的预测结果落盘,再去翻一下被预测错的样本原文,看是数据标注问题还是模型理解问题。
做中文文本情感分类,技术本身并不是壁垒,壁垒在数据质量和验证习惯。拿到源码后不要一上来就追求调参涨点,先把最小流程跑通,再把训练集缩小到100条调试代码,最后用全量数据训练,这是最省时间的节奏。这几年下来我最大的教训是:BERT的坑大多不在模型结构,而在数据编码、标签映射和版本差异这些旁路节点上。希望帮到你。
本文还有配套的精品资源,点击获取