资讯动态

基于BERT的中文情感分类实验设计与优化实践

发布时间:2026/9/15 3:37:45 来源:尧图企业网站定制
简介基于BERT的中文情感分类实验完整源码与文档包定位于自然语言处理教学与科研实践适合具备一定Python基础、希望深入理解BERT在中文文本分类应用的学习者与研究者。资源共22个文件压缩包4.87MB包含11个Python脚本、4个文本说明、2个Shell脚本、2个CSV数据文件以及1个Markdown笔记、1个gitignore和1个编译文件类型齐全便于按需查阅。脚本覆盖数据预处理、模型定义、训练预测、分词评估等完整流程包括预训练数据生成、模型结构搭建、分类训练、结果预测、中文分词等功能Shell脚本可一键完成训练与预测。CSV文件提供情感分类样例数据文档详细记录实验设计与参数配置便于复现。目前已有312人学习这套资源既适合完整复现也支持在此基础上扩展自己的分类任务可帮助读者从零搭建中文情感分类实验掌握数据处理到模型评估的关键技术。1. 基于BERT的中文情感分类实验到底要设计什么中文情感分类最常见的几个坑第一是分词误差被下游模型放大第二是小规模标注数据训不动深层模型第三是测试集指标好看但上线后在新话术上掉点。基于BERT的中文情感分类实验本质上是在解决这三件事用预训练模型替代人工特征、用微调替代从零训练、用一套可复现的实验流程把模型行为看清楚。这个实验既不是拿transformers库调个接口跑个准确率也不是自己写Transformer训练一个大模型而是围绕一个有标注的中文评论数据集完成数据预处理、模型加载、微调训练、评估对比四个环节最终产出的是可解释的实验报告和一版能推理的模型。适合两类人刚入门NLP但已经会用Python的工程师以及在公司里做文本挖掘、想验证BERT相比传统方案收益边界的技术负责人。2. BERT在中文情感任务里的工作方式与选型2.1 从静态词向量到上下文动态表示BERT改变了什么情感分类的传统做法是分词后映射到词向量再丢给CNN或LSTM。这类模型的问题在于词向量是上下文无关的“厉害”在“这家店真厉害”和“这病厉害”里是同一个向量模型只能靠周边窗口硬猜。BERT通过双向Transformer把每个token的表示变成它在句子里的函数具体来说12层Transformer编码器逐层做self-attention每层都有完整的上下文信息所以同一个词在不同句子里得到不同的向量。情感分类用的是BERT输出的[CLS]向量这是有讲究的。输入序列的第一个token永远是[CLS]经过多层Transformer后它的注意力已经聚合了整个序列的信息后面接一个分类头即可。在实现上AutoModelForSequenceClassification会在BERT输出后接一个dropout和一个线性层线性层的输入维度是hidden_size输出维度是num_labels。这个线性层就是所谓的分类头微调时它和BERT的12层参数一起更新。2.2 中文输入构造与bert-base-chinese的分词逻辑中文BERT用的是WordPiece分词和jieba这类词级分词不同。WordPiece按照词表把文本切成一个一个的subword中文场景基本切到字级别比如“这部电影很好看”会被切成“这”、“部”、“电”、“影”、“很”、“好”、“看”。这带来一个好处不需要额外分词器也就不存在分词误差向下传播的问题。输入到模型的ID序列需要按规则构造[CLS] token_ids [SEP]token_type_ids区分两个句子但情感分类通常是单句所以全部置0。attention_mask标记哪些位置是真实的token、哪些是padding出来的训练时不参与注意力计算。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) encoded tokenizer( 这家餐厅的服务非常差等了一个小时才上菜。, max_length128, truncationTrue, paddingmax_length, return_tensorspt ) print(encoded[input_ids][0][:10]) print(encoded[attention_mask][0][:10])逻辑说明truncationTrue表示超过max_length的部分截断paddingmax_length把所有样本统一补齐到同一长度这样能拼成batch矩阵喂给GPU。return_tensorspt返回PyTorch张量后续可直接进模型。参数说明max_length128是经过取舍的中文评论平均长度在50到80字之间128能覆盖95%以上的样本同时训练显存可控。如果文本更长不直接加长而是采用后文会说的截断策略。这里没有拆出token_type_ids单句分类场景下全零即可。2.3 实验选型上的三个选项BERT、TextCNN和LLM做中文情感分类实验前要回答一个问题为什么选BERT而不是别的。TextCNN的优势是训练快、部署体积小在短文本分类上很能打但它的感受野固定对否定词和转折句式“虽然贵但好吃”的建模能力弱。LLM如ChatGPT、Qwen等做意图识别或情感分类效果不差但推理延迟和调用成本摆在那里批量离线标注可以用线上实时判定成本偏高。BERT介于两者之间微调后的单次前向推理在CPU上约几十到几百毫秒GPU上可以做到毫秒级适合作为生产环境的情感分类基线模型。从工程角度看bert-base-chinese是Hugging Face上使用最广的中文预训练模型权重基于中文维基百科语料训练参数量约1.02亿单卡消费级GPU即可微调。相比使用LLM做意图识别BERT在固定分类标签、数据规模中等几万条以内的场景下训练可控性和复现性都更好。方案参数量/成本训练数据需求推理延迟适合场景TF-IDF LR/SVM极小千级微秒级强基线、冷启动TextCNN百万级万级毫秒级短文本、低延迟BERT1.02亿万级10-100ms(GPU)有语义理解要求标签固定LLM开源/API十亿级以上几乎不需要秒级标签开放、多轮对话这个选型表就是实验设计的第一块基石。如果目标是线上低延迟批量打分BERT微调比LLM更划算如果想对比深度学习相对统计学习的收益TextCNN是必须有的中间档。3. 实验数据准备与中文预处理流程3.1 数据分布、文件格式和label映射实验数据用有标注的中文评论集二分类正向/负向或三分类正向/中性/负向都可以。文件采用CSV格式两列text和label。拿到原始数据后第一步不是分词而是做标签分布统计。import pandas as pd # CSV: columns: text,label;label为0(负向)或1(正向) df pd.read_csv(sentiment_train.csv, encodingutf-8) print(df[label].value_counts(normalizeTrue)) # 类别不平衡时做映射修正 label2id {负面: 0, 中性: 1, 正面: 2} df[label] df[label].map(label2id) print(df[label].isna().sum())逻辑说明用value_counts(normalizeTrue)查看类别的比例如果最少的类别占比低于10%训练时需要在loss里加类别权重否则模型倾向把所有样本判成大类别。map方法把业务标签转成整型id模型输出logits后取argmax得到id再反向映射回业务标签。参数说明encodingutf-8必须显式指定Windows环境默认可能读成GBK导致乱码。isna().sum()用于排查映射失败的脏数据业务数据里常出现“正面 ”带空格、繁体字等情况这一步漏了后面评估会百思不得其解。3.2 序列长度、padding和attention_mask的关系BERT的输入长度上限是512个subword但实验时绝不直接拉满。在2.2节设置过max_length128这里补一个统计依据对训练集所有样本做tokenize统计len分布。lengths [] for text in df[text].tolist(): length len(tokenizer(text, truncationFalse)[input_ids]) lengths.append(length) import numpy as np lengths np.array(lengths) print(fmean{lengths.mean():.1f}, p95{np.percentile(lengths, 95):.0f}, max{lengths.max()})逻辑说明统计句子长度的真实分布后max_length应该设在p95附近。设在p50会导致15%的样本被截断丢失尾部语义设在p99会牺牲训练速度。统计得到的p95就是第2章里128或类似值的选择依据而不是拍脑袋。attention_mask在损失计算和模型前向中都起作用。BERT在计算self-attention时padding位置的mask为0注意力权重被mask掉不参与语义计算。有的实现里padding位置对应的label设成了-100配合CrossEntropyLoss的ignore_index跳过这些位置但在序列分类任务中只有一个[CLS]参与损失计算这个操作不是必须的。3.3 继承Dataset类封装训练数据完成预处理后把tokenize结果封装成PyTorch的Dataset子类不直接用dict喂DataLoader因为__getitem__可以动态做必要的数据增强或重采样。from torch.utils.data import Dataset class SentimentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_length128): 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]) encoded self.tokenizer( text, truncationTrue, paddingmax_length, max_lengthself.max_length, return_tensorspt ) return { input_ids: encoded[input_ids].squeeze(0), attention_mask: encoded[attention_mask].squeeze(0), labels: torch.tensor(self.labels[idx], dtypetorch.long) }逻辑说明squeeze(0)把tokenizer返回的形状[1, seq_len]压缩成[seq_len]否则DataLoader拼接batch时会变成[batch, 1, seq_len]导致模型报维度错误。labels用torch.tensor转成张量在训练时直接传入模型AutoModelForSequenceClassification内部会自己算cross entropy loss。Dataset类的好处是在__getitem__里可以按需做数据采样逻辑比如负样本太少时用一定概率复制负样本。数据不平衡的后续调整不需要改动训练循环只改这里。4. 源码实现加载模型、训练循环与超参配置4.1 加载预训练模型与dropout参数模型加载的核心是AutoModelForSequenceClassificationnum_labels从预训练默认的2改成实际分类数。加载时顺手拿到BERT内部的配置方便调整隐藏层的dropout。from transformers import AutoConfig, AutoModelForSequenceClassification num_labels 3 # 负面/中性/正面 config AutoConfig.from_pretrained( bert-base-chinese, num_labelsnum_labels, hidden_dropout_prob0.2, attention_probs_dropout_prob0.2 ) model AutoModelForSequenceClassification.from_pretrained( bert-base-chinese, configconfig ) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device)逻辑说明hidden_dropout_prob和attention_probs_dropout_prob在预训练时是0.1微调小数据集时0.2能抑制过拟合。如果训练集超过5万条保持0.1即可dropout过大会欠拟合。num_labels改变后分类头的线性层随机初始化BERT主干权重保持预训练值这是微调的标准做法。4.2 优化器、warmup和梯度累积微调BERT的默认优化器是AdamW区别在于它把weight decay和L2正则分开做防止decay作用到bias和LayerNorm参数上。初始学习率按惯例取2e-5到5e-5BERT的参数共享程度高学习率太大导致灾难性遗忘。from transformers import get_scheduler from torch.optim import AdamW optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) num_epochs 4 total_steps len(train_dataloader) * num_epochs scheduler get_scheduler( linear, optimizeroptimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps )参数说明num_warmup_steps设为总步数的10%前10%步学习率从0线性升到2e-5避免大学习率直接打在刚接触数据分布的分类头上。weight_decay0.01只作用于权重矩阵bias和LayerNorm.weight会被AdamW内部自动排除在decay之外。如果GPU显存小于12GBbatch size设不了太大这时用梯度累积模拟大batch。accumulation_steps表示累积多少个step才更新一次参数。accumulation_steps 2 for batch in train_dataloader: batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) loss outputs.loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() scheduler.step() optimizer.zero_grad()逻辑说明loss除以累积步数使得accumulation_steps次反向传播累加的梯度均值等价于batch size扩大两倍的梯度均值。scheduler.step()只在真正更新参数时调用否则学习率消耗速度是预期的两倍。4.3 混合精度训练与显存监控消费级显卡训练BERT常用混合精度把前向和反向计算的FP32张量转成FP16减少显存占用并利用Tensor Core加速。PyTorch自带的torch.cuda.amp可以零成本接入。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for epoch in range(num_epochs): for idx, batch in enumerate(train_dataloader): batch {k: v.to(device) for k, v in batch.items()} with autocast(): outputs model(**batch) loss outputs.loss / accumulation_steps scaler.scale(loss).backward() if (idx 1) % accumulation_steps 0: scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad()逻辑说明GradScaler解决的是FP16梯度下溢问题。scaler.scale(loss).backward()把损失放大若干倍再反传scaler.step(optimizer)在更新前先判断梯度是否含有NaN/Inf没有才真正更新。scaler.update()按动态逻辑调整下次的缩放系数。用了混合精度后lr偶尔需要稍微调大一点FP16带来的数值扰动在极小学习率下会放大。4.4 训练过程的三个关键监控指标训练不能只盯着loss三个指标决定了什么时候停、什么时候调参监控指标观察方式异常时怎么处理训练loss每个step打印不下降说明lr太低或数据label噪声大验证集F1epoch结束计算上升后回落说明过拟合改为早停学习率曲线配合scheduler打印偏高导致震荡收敛不了验证集不在训练循环里单独写一个evaluate函数每epoch结束跑一遍。def evaluate(model, dataloader, device): model.eval() predictions [] true_labels [] for batch in dataloader: batch {k: v.to(device) for k, v in batch.items()} with torch.no_grad(): outputs model(**batch) logits outputs.logits preds torch.argmax(logits, dim-1).cpu().tolist() predictions.extend(preds) true_labels.extend(batch[labels].cpu().tolist()) return true_labels, predictions逻辑说明model.eval()关闭dropout和BatchNorm的随机行为保证推理结果可复现。torch.no_grad()关闭梯度追踪显存占用和速度都有改善。返回的true_labels和predictions直接用于第5章的指标计算。5. 评估、误差分析与对比实验5.1 多分类评估指标准确率、宏F1与混淆矩阵情感分类的label分布经常不平衡只看accuracy会被大类别带偏。评估主要用宏F1和混淆矩阵宏F1是每个类别的F1取平均不偏向任何类别。from sklearn.metrics import classification_report, confusion_matrix, f1_score true_labels, predictions evaluate(model, valid_dataloader, device) print(classification_report(true_labels, predictions, digits4)) print(confusion_matrix(true_labels, predictions))逻辑说明classification_report会输出三个类别各自的precision、recall、f1-score以及宏平均和加权平均一眼就能看出哪个类别的召回偏低。confusion_matrix的每一行是真实label每一列是预测label矩阵中第i行第j列表示真实类别i被预测成j的样本数。对角线越密集分类越准。最关键的是看“负面”类的召回率。情感分类落地时漏判负面评论比误判正面评论风险高得多。如果负面类的recall低于85%就需要进入5.3的阈值调整。5.2 对比实验TextCNN、TF-IDF基线怎么搭要让基于BERT的实验有说服力基线不能省。TextCNN和TF-IDF是两个必须跑的对照。TextCNN用jieba分词初始化一个200维word2vec向量卷积核用2/3/4每类256个filter。import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim200, num_filters256, num_labels3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k) for k in (2, 3, 4) ]) self.fc nn.Linear(num_filters * 3, num_labels) def forward(self, input_ids): emb self.embedding(input_ids).transpose(1, 2) convs [torch.relu(conv(emb)) for conv in self.convs] pools [torch.max_pool1d(c, c.size(2)).squeeze(2) for c in convs] concat torch.cat(pools, dim1) return self.fc(concat)逻辑说明padding_idx0让padding位的embedding固定为0不参与训练。三个不同宽度的卷积核并行提取2-gram、3-gram、4-gram特征max-pooling取每个feature map的最大值作为该特征的强度。这个模型参数量不到BERT的1%能明确反映出语义建模能力差异。典型实验结果如下模型准确率宏F1推理时间(CPU单条)TF-IDF LR0.870.850.1msTextCNN0.910.900.5msBERT-base-Chinese0.950.9450msBERT在准确率上的确有3-4个点的提升但代价是推理延迟增加两个数量级。如果业务要求CPU上单条10ms以内BERT的收益会被延迟吞掉。这就是对比实验的意义实验报告里必须写清楚延迟和显存开销而不是只报准确率。5.3 阈值校正用验证集搜最优分类阈值BERT输出的logits通过softmax得到概率分布默认取最大概率对应的类别。当“负面”类precision高但recall低时可以调低“负面”类别的判定阈值。import torch import numpy as np def find_best_threshold(probs, labels, target_label0, thresholdsnp.arange(0.3, 0.7, 0.02)): best_f1, best_threshold 0, 0.5 for t in thresholds: preds np.argmax(probs, axis1) preds[probs[:, target_label] t] target_label f1 f1_score(labels, preds, labels[target_label], averagemacro) if f1 best_f1: best_f1, best_threshold f1, t return best_threshold逻辑说明阈值扫描的逻辑是默认预测最大概率对应的类别但将这个最大概率的索引修正为“该样本在目标类别上的概率超过阈值t则强制判为目标类别”。这样在“负面”类分布偏低时换取更高的召回率。阈值只应在验证集上搜索再到测试集上验证避免在测试集上调参导致指标虚高。6. 长文本截断策略、掉点排查与推理效率优化长文本是BERT情绪分类最容易被忽视的坑。直接truncationTrue会保留开头的128个token但中文评论吊诡之处在于结论往往在最后一句比如“环境很好菜品精致然而服务员态度太差”这种转折开头是正面信息负面的落在句尾。一截断模型看到的是完美的正面评价。常见做法是头和尾各保留一部分把前64个token和后64个token拼起来舍去中间部分。BERT对位置编码有感知位置ID仍然是0到127不额外标注“这是拼接文本”模型也不会意识到文本被截断但这个策略能保住评论的结论。另一个可靠的做法是把整篇评论切分重叠窗口每个窗口单独过BERT然后对各个窗口的类别概率做加权平均。重叠度设为窗口的50%避免切断关键词。def predict_long_text(model, tokenizer, text, max_length128, overlap64): tokens tokenizer(text, truncationFalse)[input_ids] if len(tokens) max_length: return predict_single(text) step max_length - overlap windows [tokens[i:imax_length] for i in range(0, max(1, len(tokens)-max_length1), step)] probs_sum 0 for w in windows: w w[:max_length-2] encoded tokenizer.prepare_for_model(w, truncationTrue, max_lengthmax_length, return_tensorspt) logits model(**encoded.to(device)).logits probs_sum torch.softmax(logits, dim-1).detach().cpu() return torch.argmax(probs_sum, dim-1).item()逻辑说明prepare_for_model自动补[CLS]和[SEP]窗口切分在原始的input_ids级别进行。每个窗口独立过模型概率在类别维度上求和后取平均。这个方法的代价是推理时间随窗口数量线性增长但召回率通常能提升2-3个百分点。实验过程中的掉点排查顺序按优先级来第一看attention_mask是否传入模型没传的话padding位置会被当成真实token参与注意力等于模型无端多看了一些空白第二看labels的dtype必须是torch.long而不是float否则CrossEntropyLoss直接报类型错误或静默给出错误loss第三看学习率和warmup是否配置BERT微调前500步loss不降往往不是模型问题而是学习率太小或warmup步数过长。第四才轮到数据层面检查label是否错标、类别是否极度不平衡。推理效率这块微调好的BERT可以导出成ONNX在CPU上跑。参考热词里bert和textcnn的对比如果延迟是核心指标模型蒸馏是另一个方向用BERT的输出作为teacher信号训练一个小TextCNN把BERT的语义理解能力蒸馏到轻量模型上。一个能用的蒸馏方案是冻结训练好的BERT用它的logits做软标签TextCNN的损失函数改成alpha * KL(student_logits, teacher_logits) (1-alpha) * CE(student_logits, hard_labels)alpha取0.7。这样能在參数量缩小100倍的同时把TextCNN的准确率从0.91拉到0.93左右而延迟仍然保持在毫秒级。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价