基于BERT的中文情感分类实验:从跑通到调优一篇讲透
简介基于BERT的中文情感分类实验项目面向自然语言处理初学者与研究者系统展示了从预训练数据处理、模型构建到中文情感分类训练与预测的完整流程。资源包共22个文件压缩后4.87MB主体为11个Python脚本分别承担建模、分类、分词、预训练数据生成与特征提取等关键环节另有CSV数据文件、Shell运行脚本、文本说明与Markdown文档其中CSV提供训练与评估数据Shell脚本方便一键复现文档则记录实验设计与结果。已有312人学习下载。通过这套源码读者可以掌握BERT在中文文本分类任务中的实际用法包括如何准备中文语料、完成分词与特征提取、训练分类模型并对新样本进行预测文档与数据文件同时提供了实验记录和评估依据适合作为课程设计、毕业设计或入门实践的参考模板。1. 基于BERT的中文情感分类实验从跑通到调优一篇讲透情感分类是中文自然语言处理里最常被拉出来做实验的任务而BERT几乎成了这类实验的默认起点。很多人以为加载一个预训练模型、接上分类头、跑几个epoch就算“基于BERT的中文情感分类实验设计与源码实现”完成了结果一验证发现准确率卡在80%上下不去或者训练到一半显存爆掉、loss变成NaN。这篇笔记会顺着实验设计的完整链路走一遍为什么选BERT而不是传统词向量、数据集怎么处理、源码怎么写、参数怎么调、哪些坑必须躲开。适合刚把深度学习框架装好、准备拿中文情感分类当第一个实战项目的人也适合已经跑通过基础模型、想搞清楚边界和优化方向的从业者。目标是让你看完之后能照着复现并且知道每行代码、每个超参数在干什么。2. BERT中文情感分类的实验设计先搞清楚模型在学什么2.1 为什么中文情感分类总是先想到BERT中文文本不像英文那样天然有空格分词传统做法要先分词、去停用词、构造词向量整个pipeline里的每一步都在损失信息。BERT通过子词WordPiece切分配合预训练阶段的掩码语言模型和下一句预测把中文的上下文语义提前编码进了参数里。做情感分类时我们不需要自己训练词向量只需要在BERT最后一层接一个分类头微调整个模型就行。这里有个关键认知微调不是“用BERT提取特征然后训练分类器”而是让BERT自身的参数也继续更新。很多初版实验把BERT的输出当成固定的特征向量只训练后面的全连接层效果会明显差一截。原因很简单——预训练模型学到的是通用语义而情感分类任务需要激活特定方向的语义表征比如“绝了”在“这电影绝了”里是褒义在“这服务态度绝了”里是嘲讽。只有让BERT的参数在情感语料上继续反向传播才能把这种语境差异压进模型里。另一个设计要点是输入格式。BERT的中文输入要拼成[CLS] 正文 [SEP]其中[CLS]位置的输出向量会被当作整句的语义表示。实验设计时不要把整个数据集一股脑塞进去要先确定最大序列长度。中文BERT默认支持512个token但情感分类样本通常都是短文本设成128或256既能保住信息又能大幅降低显存占用和训练时间。我一般先统计训练集的长度分布取95分位点作为max_len避免为了个别超长样本拉高全量计算成本。2.2 情感分类的标签体系与评估口径中文情感分类实验里标签体系直接决定模型结构。最简单的二分类正向/负向只需要一个线性层加sigmoid三分类正向/负向/中性或者五分类1到5星则需要softmax。设计实验前要问清楚业务上到底需要几个类别中性的定义是什么如果数据集本身是从评分映射来的1到2分是负向、3分是中性、4到5分是正向那就得注意类别不平衡问题——大部分点评数据都偏向正向负样本占比可能不到20%。评估口径上只看准确率远远不够。我习惯同时输出精确率、召回率和F1分数并且按类别分开看。比如“正向”类F1很高、“负向”类F1很低就说明模型被中性或正向样本带偏了。这会直接影响源码实现里的评估函数写法不能只算(pred label).mean()要用sklearn的classification_report或者自己逐类统计混淆矩阵。实验报告里如果只贴一个acc答辩或业务评审时大概率会被追问到说不出话。另外还要注意数据划分的随机种子。中文情感分类数据集经常出现同一条评论的重复样本或者同一用户的多条评论。按文件顺序直接切分会把重复文本同时分进训练集和测试集导致测试分数虚高。我在源码里会用train_test_split(..., shuffleTrue, random_state42)并在划分前按文本内容做去重。如果做严肃实验最好按用户ID或评论ID分组划分但这要看数据集里有没有这类字段。2.3 源码实现的整体模块划分实验源码不能只写一个训练脚本至少要拆成配置、数据、模型、训练、评估、预测这几个模块。这不是为了“软件工程”而是为了debug方便。BERT的源码实现里最容易出问题的反而不是模型结构而是数据加载的细节tokenizer的padding方式、attention_mask有没有传、label的dtype对不对。拆分模块后你能单独验证DataLoader输出是不是符合预期再进训练环节。常见做法是把超参数集中到一个config.py或者用argparse接收。BERT的源码实现里需要的参数包括预训练模型路径、max_len、batch_size、learning_rate、epoch数、warmup比例、weight_decay、dropout、输出目录等。把这些写死在训练脚本里的做法很糟糕——每次调参都要改代码很容易改坏。我一般会用dataclass写一个Config类并支持从命令行覆盖这样跑实验组时只需改启动命令。3. 数据准备与预处理把中文文本变成BERT能吃的张量3.1 加载数据集与类别映射以最常见的中文情感分类数据集为例通常是一个CSV文件包含text和label两列。第一步先把数据读进来做基本清洗去掉空行、去掉全空白文本、统一前后空格。注意不要过度清洗——不要在这里去标点、去停用词BERT不是词袋模型标点和语气词“啊”“呢”“哈哈”都可能携带情感信息。import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(sentiment.csv, encodingutf-8) df df.dropna(subset[text, label]) df[text] df[text].astype(str).str.strip() df df[df[text] ! ].reset_index(dropTrue) label2id {negative: 0, neutral: 1, positive: 2} df[label_id] df[label].map(label2id) df df.dropna(subset[label_id]).reset_index(dropTrue) train_df, valid_df train_test_split( df, test_size0.15, stratifydf[label_id], random_state42 ) print(train_df[label_id].value_counts())这段代码里最关键的是stratifydf[label_id]它保证划分后训练集和验证集的类别比例一致。如果数据集类别分布是60%正向、20%中性、20%负向不做分层抽样的话小概率会把负向样本全分进训练集验证集里就没有负向了。label2id的映射要写死不能依赖DataFrame里的顺序否则模型输出01和真实的类别对齐会错位。3.2 分词与编码tokenizer的正确用法HuggingFace的transformers库提供了现成的中文BERT tokenizer。加载时要注意模型权重类型bert-base-chinese是字级别的而bert-base-multilingual-cased是按多语言子词切分的。我见过有人混用权重和tokenizer导致max_len内塞不下、对齐错乱。源码实现里必须确保模型和tokenizer来自同一个预训练目录。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) max_len 128 def encode_text(text): encoded tokenizer( text, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt, ) return encoded sample encode_text(这家店的服务态度非常好上菜也快。) print(tokenizer.convert_ids_to_tokens(sample[input_ids][0]))这里paddingmax_length会把所有样本都pad到128的长度生成一个固定形状的批次方便矩阵运算。truncationTrue保证超长文本被截断。输出里有三个键input_ids、token_type_ids、attention_mask。attention_mask会在self-attention计算中把padding位置设为0源码里必须把它传给模型否则模型会把[PAD]位置当真词参与语义计算。3.3 构建PyTorch Dataset与DataLoader把编码后的结果封装成Dataset类这是BERT源码实现里最容易出错的地方之一Dataset返回的是tokenizer编码好的字典加上labelDataLoader在收集batch时要求所有样本的张量形状一致所以必须在编码阶段就统一max_len。另一个坑是return_tensorspt会让每个样本都带一个batch维度DataLoader又会在前面加一维最终变成三维张量。解决办法是在__getitem__里用torch.squeeze()去掉单例维度。import torch from torch.utils.data import Dataset, DataLoader class SentimentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len): self.texts list(texts) self.labels list(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] encoded self.tokenizer( text, max_lengthself.max_len, truncationTrue, paddingmax_length, return_tensorspt, ) item { input_ids: encoded[input_ids][0], token_type_ids: encoded[token_type_ids][0], attention_mask: encoded[attention_mask][0], labels: torch.tensor(label, dtypetorch.long), } return item train_dataset SentimentDataset( train_df[text], train_df[label_id], tokenizer, max_len ) train_loader DataLoader( train_dataset, batch_size16, shuffleTrue, num_workers0 )num_workers0在Windows和macOS上最稳避免多进程序列化报错。如果机器配置高Linux上可以设成4或8但要注意Python代码里Dataset构造函数传入了tokenizer对象多进程时会重复加载。我通常把tokenizer初始化放在__init__里每个worker进程都会继承一份内存占用会翻几倍所以数据集小就别开多进程。4. 模型源码实现与训练从加载BERT到输出分类结果4.1 加载预训练模型并接分类头transformers里最常用的是BertForSequenceClassification它已经内置了一个分类头直接输出每个类别的logits。这个类会加载BERT主干参数并在[CLS]输出上接一个Dropout加线性层。用的时候只需要指定num_labels。但要注意BertForSequenceClassification默认会随机初始化分类头权重而BERT主干是从预训练权重加载的。如果忘记加载预训练权重训练几千步也无法收敛。from transformers import BertForSequenceClassification model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels3, ) # 如果你想修改dropout可以调整config from transformers import BertConfig config BertConfig.from_pretrained(bert-base-chinese, num_labels3, hidden_dropout_prob0.1) model BertForSequenceClassification.from_pretrained(bert-base-chinese, configconfig)自定义分类头的情况也有。有些人想用两层全连接加Tanh激活或者想在分类之前把后几层BERT的特征拼接起来。常见做法是加载BertModel取last_hidden_state[:, 0]作为[CLS]向量然后过自己的分类器。但这样做需要额外处理池化、Dropout位置、梯度裁剪等细节对新人容易翻车。我一般建议先用官方封装好的BertForSequenceClassification跑通基线再考虑自定义结构。4.2 优化器、学习率与warmup设置BERT微调的优化器跟普通分类网络不一样。最核心的是学习率要小常见取值范围是2e-5到5e-5AdamW是默认选择。第二个关键是warmup微调的前几百步用很小的学习率让模型从预训练状态平稳过渡到任务状态避免一开始就大幅改动BERT参数。HuggingFace提供了get_linear_schedule_with_warmup它会在warmup阶段线性上升之后线性衰减到0。from transformers import AdamW, get_linear_schedule_with_warmup import torch.optim as optim optimizer AdamW(model.parameters(), lr3e-5, weight_decay0.01) num_epochs 5 total_steps len(train_loader) * num_epochs warmup_steps int(0.1 * total_steps) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps, )weight_decay0.01是常见设置但注意它不要对bias和LayerNorm参数生效否则会破坏预训练好的归一化统计量。HuggingFace的AdamW允许传入correct_biasFalse。经验上直接在模型中筛选出需要weight decay的参数是更稳妥的写法不过对情感分类这种小任务weight_decay0.01加上no_decay名单的差别不是特别大。学习率的选择要看batch size——如果显存只能塞下8个样本学习率可以相应调低到2e-5。4.3 训练循环与梯度累积训练循环本身并不复杂但有三个细节必须处理好。第一模型要.train()模式BERT内部的Dropout才会生效第二每次optimizer.zero_grad()避免梯度累加第三loss是CrossEntropyLossBertForSequenceClassification在前向传播时传入labels参数会直接返回loss不用自己再算。如果想要梯度累积来模拟更大的batch需要在累积步数达到后手动optimizer.step()和scheduler.step()。from tqdm import tqdm device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) accumulation_steps 2 # 实际生效batch batch_size * accumulation_steps for epoch in range(num_epochs): model.train() total_loss 0 progress_bar tqdm(train_loader, descfEpoch {epoch 1}) for step, batch in enumerate(progress_bar): input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) token_type_ids batch[token_type_ids].to(device) outputs model( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids, labelslabels, ) loss outputs.loss loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() * accumulation_steps progress_bar.set_postfix({loss: loss.item() * accumulation_steps})这段代码里梯度裁剪clip_grad_norm_是BERT微调里少不了的保险丝。情感分类的loss曲面有时会出现极端梯度不裁剪的话一两个批量就能把预训练权重冲飞后面loss变成NaN。max_norm1.0是常用值显存紧张或学习率调大时需要相应收紧。另外注意token_type_ids要传虽然bert-base-chinese只有一句输入时传不传影响不大但保留这个参数能让代码兼容更多场景。4.4 验证与保存别只看训练loss每轮epoch结束后要在验证集上评估。验证时模型要切换到.eval()模式并且用torch.no_grad()关闭梯度记录否则不仅慢还会在推理时保留计算图显存很快爆掉。评估指标应该包含每个类别的准确率、召回率、F1。保存模型时建议同时保存权重和tokenizer这样后续加载预测不需要重新找到原本的tokenizer路径。from sklearn.metrics import accuracy_score, f1_score, classification_report import numpy as np def evaluate(model, dataloader, device): model.eval() all_preds [] all_labels [] with torch.no_grad(): for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) outputs model(input_idsinput_ids, attention_maskattention_mask) logits outputs.logits preds torch.argmax(logits, dim-1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.cpu().numpy()) print(classification_report(all_labels, all_preds, digits4)) return accuracy_score(all_labels, all_preds), f1_score(all_labels, all_preds, averagemacro)保存模型时我习惯把整个model和tokenizer都保存到同一个目录方便部署复现save_dir bert_sentiment_model model.save_pretrained(save_dir) tokenizer.save_pretrained(save_dir) torch.save(config.__dict__, f{save_dir}/config_args.json)这里config是实验配置的dataclass保存下来是为了记录当时用的max_len、标签映射等。很多实验翻车都是因为三个月后加载模型忘了当初标签顺序预测结果和业务线完全对不上。5. 避坑与常见问题排查BERT情感分类的8个血泪经验5.1 训练loss不降或忽高忽低现象训练到第2个epochloss还在1.0附近波动验证集准确率只有50%多跟随机猜测差不多。原因最常见的三个原因——学习率太大导致参数震荡warmup比例没设置模型一上来就用大步长乱跑数据标签映射错了比如0和1对调模型学到的恰好是相反语义。解决先把学习率降到2e-5warmup占比调到0.1。再检查label2id打印train_df.head()和train_loader里第一个batch的labels手工确认某条明显正面评论的标签是2而不是0。我踩过最蠢的坑是CSV里的label列是字符串“positive”直接astype(int)报错后改成map但map时拼错了键名导致大部分样本变成NaN后被drop剩下一小撮样本硬生生训出一个“假模型”。5.2 显存溢出CUDA out of memory现象训练刚开始输出CUDA out of memory代码崩在第几个batch处。原因batch_size太大序列长度设成512并且没有关闭验证阶段的梯度记录。BERT的显存占用跟序列长度近似线性跟batch size也近似线性两者一起涨很容易爆。解决把max_len从512降到128batch_size从32降到16并确认evaluate()里用了torch.no_grad()。如果还想加大batch开显存优化用torch.utils.checkpoint对BERT的Encoder层做梯度检查点用时间换显存。另外检查代码里是否在循环内创建了model.to(device)那会保留一份CPU模型的额外开销。5.3 验证时输入padding导致的结果偏移现象训练loss正常下降但验证集F1比训练集低好几个点单独推理单条文本时结果跟验证集表现不一致。原因训练集的DataLoader默认shuffleTrue但验证集的DataLoader有时忘了设shuffleFalse模型在epoch内多次看到同一批验证样本的顺序变化影响BatchNorm统计量。另外如果源码里在tokenizer处对训练和验证用了不同的padding参数比如训练时截断、验证时不截断长度不齐的情况下模型仍能跑因BertForSequenceClassification内部会自动padding但attention_mask传错位置就会导致结果异常。解决保证训练和验证的tokenizer参数完全一致都传相同的max_len、truncationTrue、paddingmax_length。验证集务必shuffleFalse。排查时直接单步打印验证集第一个样品的input_ids和attention_mask确认padding位置是0而不是1。5.4 加载保存的模型时报键名不匹配现象from_pretrained加载训练好的模型时报Error(s) in loading state_dict而且键名差一个前缀module.或bert.。原因训练时用了DataParallel或多卡训练保存下来的权重带module.前缀或者加载的是BertModel但保存的是BertForSequenceClassification分类头的权重缺失。混合使用bert-base-chinese的tokenizer和bert-base-multilingual-cased的权重也会出现embedding维度不一致。解决训练时单卡就用torch.save(model.state_dict())加载时保证类定义和保存时完全一致。如果已经保存了module.开头的文件可以写一小段脚本把key名的前缀去掉from collections import OrderedDict state_dict torch.load(model_state.pt) new_state_dict OrderedDict() for k, v in state_dict.items(): new_key k.replace(module., ) if k.startswith(module.) else k new_state_dict[new_key] v model.load_state_dict(new_state_dict)5.5 warmup步数超过总步数现象训练结束也没到达预设的完整训练步数但scheduler报错或学习率一直很低收敛慢。原因warmup_steps设为总的训练步数甚至更多例如warmup_steps len(train_loader) * num_epochs就代表整个训练过程都在warmup模型永远到达不了预设的学习率峰值。解决warmup步数一般取总步数的5%到10%。如果总步数才几百就直接设成几十步。在打印scheduler当前学习率时确认峰值是否合理from transformers import get_linear_schedule_with_warmup total_steps len(train_loader) * num_epochs warmup_steps int(0.1 * total_steps) print(ftotal_steps{total_steps}, warmup_steps{warmup_steps})5.6 分类头随机初始化导致预测全偏向某一类现象所有验证样本都被预测成标签数量最多的那一类比如全部预测为“正向”precision为1.0但recall为0。原因一方面是类别不平衡模型学到的最优策略就是把每个样本都判为多数类另一方面BertForSequenceClassification的分类头是随机的如果训练不足这个线性层没有学到有效的特征映射。解决要么做类别加权把CrossEntropyLoss的weight参数按照类别频率的反比设置要么干脆先做欠采样/过采样。对情感分类这种任务我用过最省事的方法是class_weight传给模型class_weights torch.tensor([1.0, 2.0, 1.5]).to(device) model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels3 ) # 训练时手动计算loss loss_fct torch.nn.CrossEntropyLoss(weightclass_weights) logits model(...).logits loss loss_fct(logits.view(-1, 3), labels.view(-1))5.7 中文分字还是分词的影响现象同样一个文本用bert-base-chinese和bert-base-multilingual-cased训练出来的效果相差5个百分点以上。原因bert-base-chinese直接用单个汉字作为最小单位覆盖的词汇表是中文常用字的组合而多语言版本按子词切分中文字符可能会被拆成Unicode字节对导致“点赞”“拉黑”这些词根本没法被完整编码。中文场景下优先选择bert-base-chinese而不是英文BERT或多语言BERT。解决用BertTokenizer.from_pretrained(bert-base-chinese)别追新。如果你手里的预处理模型是RoBERTa-wwm-ext或者Bert-wwm-ext这类中文全词掩码版本tokenizer要对应使用BertTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext)不要混用。5.8 用pandas读完CSV后标签变成float导致模型报错现象加载模型后前向传播正常但计算loss时报错类型不匹配。原因CSV里的标签列有缺失值pandas会把整列读成float64比如0.0、1.0。传给CrossEntropyLoss要求是long直接转torch.long后缺失值会变成巨大的整数导致分类类别越界。解决在数据清洗时加一行df[label_id] df[label_id].astype(int)并且先丢弃空值。遇到不信任的数据集时先打印df.dtypes看到float64就该警惕。6. 进阶三招提升中文情感分类实战效果先讲第一招把阈值调参当成实验的一部分不要默认argmax就是最优策略。BERT输出的logits经过softmax后三个类别的概率分布往往不够尖锐中性类概率普遍偏高。如果业务只关心“正向/负向”二分类可以把中性类概率阈值从0.33上调到0.4低于0.4的不给判断这样虽然覆盖率降低但准确率往往能提升两到三个点。验证方法是在验证集上遍历阈值画一条准确率-覆盖率曲线找到业务能接受的拐点。有人觉得阈值拍脑袋不科学但这在线上系统里就是最直接的“后悔药”。第二招是情感分类的数据增强尤其适合数据量少于3万条的中文评论。不要用随机替换同义词这种会破坏语义的方式BERT本身对语序敏感替换后可能变成另一句话。我验证过有效的方式是回译——把中文翻译成英文再翻回来但质量受在线翻译API影响。更稳妥的是在训练阶段对[MASK]做对抗式扰动或在输入端随机遮挡5%的词元让模型不过分依赖某些情感词。不过做增强时一定要保持标签不变并且只在训练集上增强验证集保持原始文本。第三招是分层学习率。BERT底层学到的是通用语法上层学到的是任务相关语义微调时给顶层更大的学习率底层更小的学习率能减少灾难性遗忘。实现起来很简单先拿到BERT的编码器参数分组no_decay [bias, LayerNorm.weight, LayerNorm.bias] optimizer_grouped_parameters [ { params: [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay) and encoder.layer.11 not in n], weight_decay: 0.01, lr: 2e-5, }, { params: [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay) and encoder.layer.11 not in n], weight_decay: 0.0, lr: 2e-5, }, { params: [p for n, p in model.named_parameters() if encoder.layer.11 in n or classifier in n], weight_decay: 0.01, lr: 5e-5, }, ]这个写法是把最后一层Transformer和分类头单独拎出来用5e-5的大学习率其余层用2e-5。实测在小数据集上这样调能比统一学习率快一到两个epoch收敛F1有时能涨0.5到1个百分点。代价就是代码变复杂需要自己写优化器分组。验证阶段我还有个习惯保留每次epoch结束后的验证集预测结果把真实标签和预测概率存成CSV最后对比每个epoch的错分样本。你会发现有些样本整个训练过程都错比如“这家店环境不错但服务太差”这种同时含正负情绪的句子就连人判断都有分歧。这时候不要去强行拟合它而是要思考是不是标签标错了或者需要调整到更细的类别体系。模型进步的证据应该是错误集中在真正模糊的样本上而不是犯低级错误——把“太差了”预测成正向。整个实验做完建议你按这个顺序验收先跑完一个epoch用20条手工标注样本做预测确认模型基本能分对再看验证集分类报告确认每个类别的F1都明显超过随机水平最后打开预测错误的样本判断是数据问题还是模型问题。情感分类没有银弹BERT给了我们一个极强的起点但真正决定效果上限的还是你对待数据、源码细节和评估口径的态度。这些坑我都踩过一遍希望帮到你少走点弯路。本文还有配套的精品资源点击获取