
简介面向计算机相关专业学生的一款毕业设计项目基于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 torch2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers4.36.2 datasets2.16.1 scikit-learn1.3.2 pandas2.1.4这段命令里PyTorch 我指定了 CUDA 11.8 版本如果你的机器没有 NVIDIA 显卡或者不确定驱动支持情况可以直接改为pip install torch2.1.2CPU 版本也能跑只是训练会慢几倍。transformers负责加载 BERT 权重和分词器datasets用来做数据切分scikit-learn主要为了计算 F1 分数和混淆矩阵pandas负责读取 CSV。参数上需要注意的有两点。第一transformers4.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, encodingutf-8) df df[[review, label]].dropna() df[label] df[label].astype(int) df df[df[review].str.strip() ! ] # 查看标签分布避免数据严重不平衡 print(df[label].value_counts(normalizeTrue))这段代码看起来简单实际上有两个容易被忽视的坑。第一个坑是 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_size0.2, random_state42, stratifydf[label].tolist(), )这里stratify参数非常关键。如果不加它在样本量小或正负样本比例差异大的情况下验证集里很可能全部都是正面评论训练出来的模型在验证集上直接“翻车”。test_size0.2表示 20% 的数据留给验证random_state42则是固定随机种子保证每次跑出来的切分结果一致这是做对比实验的基本要求。另外我强烈建议不要在数据清洗阶段过度处理中文文本。很多从传统 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_labels2, )num_labels2指定了分类头输出的类别数量模型内部会自动把 BERT 的最后一层隐藏状态送到一个线性层上做分类。不需要在代码里手动拼接 MLP这是 transformers 封装好的能力。如果机器显存不足还可以在加载时加上torch_dtypeauto让权重自动适配精度或者在训练时使用梯度累积。不过最关键的一点是AutoTokenizer和AutoModelForSequenceClassification必须使用同一个model_name否则会出现 tokenizer 的词汇表与模型 embedding 维度对不上报错信息很隐晦后面排查起来非常耗时。3.2 Tokenizer 的正确用法padding、truncation 与返回张量类型的细节加载完 tokenizer 之后很多人直接拿中文句子去调用encode_plus但有两个参数经常被忽略一个是truncationTrue一个是paddingTrue。BERT 的输入序列长度上限是 512 个 token如果一条评论超过这个长度不截断就会报错同一个 batch 里句子长度不一样不填充就无法拼成张量。下面这段代码展示了单条样本的编码过程sentence 这家店的饭菜味道很好但是服务态度真的太差了 encoded tokenizer.encode_plus( sentence, max_length128, truncationTrue, paddingmax_length, add_special_tokensTrue, return_tensorspt, ) 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 的部分会被截断这个值需要根据实际评论文本长度来调整我后面会专门讲truncationTrue必须显式声明否则在 transformers 4.x 里某些模型会默认不截断paddingmax_length表示把所有序列都补到 128 的长度这样同 batch 内的 tensor 形状才能一致add_special_tokensTrue会自动在句首加[CLS]句尾加[SEP]这两个特殊 token 在情感分类里面意义重大。[CLS]位置的输出向量会被模型用来做最终的分类判断。这是 BERT 预训练时设计好的机制分类 token 聚合了整句话的语义表示。我在实际项目中通常不会手动取第二层或者平均池化输出直接用model(...).logits就能拿标准结果这也是AutoModelForSequenceClassification默认封装好的。数据量大的时候逐条调用encode_plus会非常慢更高效的做法是用tokenizer直接批量编码train_encodings tokenizer( train_texts, max_length128, truncationTrue, paddingTrue, return_tensorspt, )这里paddingTrue会自动把当前 batch 内最长的句子作为填充基准而不是固定成 128。它的好处是节省算力坏处是每个 batch 的长度不一致在 DataLoader 里需要配合collate_fn或提前固定长度。为了减少可变长度带来的麻烦我一般直接用paddingmax_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], dtypetorch.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_size16, shuffleTrue, ) val_loader DataLoader( val_dataset, batch_size32, shuffleFalse, )这里batch_size是影响显存占用最直接的参数。如果显卡只有 4GB 显存batch_size16加max_length128通常是安全的如果显存不足优先把 batch_size 降到 8 或 4而不是去改模型结构。shuffleTrue只在训练集使用验证集要保持原始顺序这样才能和标签做一一对应。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(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * 3 scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_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_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() avg_loss total_loss / len(train_loader) print(fepoch: {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], digits4, ) 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里显式写encodinggbk或encodingutf-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], devicecuda) loss_fn CrossEntropyLoss(weightclass_weights)但注意AutoModelForSequenceClassification内部默认的 loss 是等权重的想用类别权重就得在训练循环里自己取outputs.logits手动算 loss不再使用outputs.loss。这点容易造成误用如果发现权重没起作用多半是还在用默认损失。5.3 显存不足max_length觉得无所谓直接把程序跑死现象是刚跑第一个 batch 就报CUDA out of memory。原因是把所有句子都按 512 长度编码或者 batch_size 设置过大。我遇到一个数据集评论平均长度只有 30 个字但最长的有 800 字之前直接套max_length5122GB 显存直接崩溃。解决方法是先统计文本长度分布再按 90% 分位点设max_lengthlengths df[review].map(lambda x: len(tokenizer.encode(x, add_special_tokensTrue))) print(lengths.describe(percentiles[0.5, 0.9, 0.95]))如果 90% 分位点是 90那max_length128就足够除了少数超长句子会被截断其余样本完全保留。显存仍然不够时把 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_labels2)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_gradFalse只保留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_length128, truncationTrue, paddingmax_length, add_special_tokensTrue, return_tensorspt, ) 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 和模型加载那两段日志八成问题出在版本或路径上。希望这篇笔记能帮到你让你少走几个我踩过的弯路。本文还有配套的精品资源点击获取