
简介面向中文情感分析学习与研究者的完整可运行项目专注于基于预训练语言模型的文本情感分类任务。项目整合BERT、ERNIE、Bert_CNN等多种主流模型通过简单命令即可完成训练与预测并附带训练好的模型权重便于直接测试效果。资源体量不大但结构完整共19个文件压缩包约814KB其中11个Python脚本覆盖模型构建、训练评估、数据加载与预测推理全流程4个Markdown说明文档帮助理解目录与配置4个TXT文件提供训练集、验证集和测试集示例方便快速复现实验。代码模块划分清晰BERT与ERNIE的预训练权重、源码及训练结果分别收纳既适合初学者上手也能用于二次开发。目前已有3717人学习下载对需要快速落地预训练模型情感分析场景具有较高的实用价值。1. 基于 BERT 的文本情感分析从 80% 到 93%微调路线怎么走接到一个文本情感分析需求1.2 万条中文商品评论按正面、负面分成两类。之前用 TF-IDF 加逻辑回归准确率卡在 80% 上不去同义词一多标签就乱跳。换成 BERT 微调同样的数据跑 3 个 epoch验证集准确率能到 93%代价是显存暴涨、训练时间拉长还踩了一串以前没见过的坑。这篇文章是 BERT 模型实操的完整拆解用 bert-base-chinese 做文本情感分类涵盖模型选型、数据处理、微调训练和部署验证代码基于 HuggingFace Transformers。适合有 Python 和 PyTorch 基础、想直接复现的读者也适合想看清 BERT 在做分类任务时边界在哪的人。2. BERT 做情感分类的原理与选型任务怎么定模型怎么选2.1 情感分类的任务定义一个 [CLS] 接全连接层先明确一件事BERT 文本情感分析本质是文本分类输入一个句子输出一个类别。对二分类来说就是 P(正面|sentence) 和 P(负面|sentence)。模型内部的处理是句子经过 Tokenizer 变成 token 序列前面加 [CLS] 标记经过 12 层 Transformer 编码最后取 [CLS] 位置的输出向量接一个全连接层映射到 2 个类别 logits再用 softmax 得到概率。所以你不需要自己改 BERT 的 attention 结构关键只在于把最后那个分类头换成你自己的 num_labels。为什么是 [CLS] 而不是最后一个 token因为 BERT 在预训练的时候[CLS] 位置的输出被显式训练成“整句语义的聚合表示”。到了分类任务里这个向量天然适合做句子级判断。HuggingFace 把这件事封得只剩一行AutoModelForSequenceClassification.from_pretrained(bert-base-chinese, num_labels2)。我接触过不少直接用 BERT 裸模型做情感分析的项目最后都要自己写分类头其实没必要。SequenceClassification 这个类内部已经加好了 Dropout 和 Linear 层直接用就行。这个设计很重要微调的时候预训练权重作为初始化分类头是随机初始化所以前几个 step 的 loss 会偏大别慌。2.2 微调 vs 特征提取为什么标注数据够就得选 fine-tuneBERT 落地有两条路特征提取和微调。特征提取是把 BERT 当固定编码器把 [CLS] 向量或最后一层平均池化作为句子向量喂给逻辑回归或 SVM微调是让预训练参数的梯度参与反向传播分类任务本身也会反向改写 BERT 内部的注意力权重。选择标准不是“哪个更高大上”是数据量。我一般这样定标注样本少于 2000 条先用特征提取搭基线因为微调容易过拟合数据在 5000 条往上全量微调性价比明显更高。情感分析这种任务语义变化多同义表达换来换去冻结 BERT 只能学到浅层模式微调能针对领域语料重新调整注意力分布。本文后续默认全量微调数据量就是 1.2 万条足够支撑。还有一个容易被忽略的点微调改动的不仅是分类头。情感判断依赖“转折”“否定”这类结构信息比如“虽然电池还行但屏幕坏了”如果 BERT 内部不更新光靠最后一层向量的线性组合很难抓住这种组合语义。这也是为什么数据充足时全量微调比特征提取稳。代价是训练时间更长、对显存要求更高。2.3 快速跑通基线空模型先跑通 forward正式处理数据之前我建议先干一件“没效率但很值”的事加载一个空模型造两条假数据把 forward 跑通。这一步能检验环境、版本、模型下载链路也能让你提前看到输出维度。import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained(model_name, num_labels2) model.eval() texts [这个手机续航很棒, 屏幕用了三天就花屏差评] inputs tokenizer(texts, paddingTrue, truncationTrue, max_length128, return_tensorspt) with torch.no_grad(): outputs model(**inputs) print(outputs.logits.shape) # 期望 (2, 2)代码逻辑tokenizer 一次处理两条文本paddingTrue 会自动把短句补到和长句一样的长度truncationTrue 超过 max_length 部分切掉return_tensorspt 返回 PyTorch 张量。输出 logits 的形状是 (batch_size, num_labels)两条样本变 2 行二分类每个样本 2 列。要注意 tokenizer 和 model 的 name 必须一致。如果模型用 bert-base-uncasedTokenizer 也必须是对应版本混用的话输入 id 和词表对不上训练也不会收敛后面常见的坑里会细说。跑通这一步后你已经有一条完整的推理链路接下来只差把真实数据换进去。3. 把评论变成 BERT 的输入Tokenizer、Dataset 与数据处理链3.1 中文 Tokenizer 的细节按字切别上 jiebaBERT 自己的分词器是 WordPiece中文版本用的是基本汉字字符切分“手机”会拆成“手”“机”两个 token。这跟以前做中文 NLP 的习惯不一样不要先把句子用 jieba 分词再喂给 BERT。bert-base-chinese 的 tokenizer 内部已经处理好了空格和特殊标记你只需要保证原始文本是干净的字符串。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) encoded tokenizer(这个手机续航很棒, add_special_tokensTrue) print(encoded[input_ids]) # 第一位置一定是 101[CLS]最后是 102[SEP]add_special_tokens 默认就是 True会自动加 [CLS] 和 [SEP]。input_ids 是 token 在词表中的索引attention_mask 是哪些位置是真实 token1哪些是填充0。这两个张量在训练和推理时必须同时给模型缺一个模型会跑出完全错误的结果。我见过把 attention_mask 忘掉的坑表现是训练 loss 不降、验证结果随机还以为是超参数没调好。中文文本里经常混着数字、英文和标点。我建议在进入 tokenizer 之前做一次最基础的清洗去掉不可见字符、统一全角半角、把空文本过滤掉。这里有个操作上的细节BERT 的 tokenizer 对不可见字符会产出 [UNK]如果数据里这类 token 太多模型等于在看噪声。可以用 tokenizer.decode(encoded[input_ids]) 回看一遍发现 [UNK] 比例异常就说明清洗不够。3.2 用 HuggingFace Dataset 构造训练集map batch数据一般长这样一列是 text一列是 label。我一般直接用 datasets 库因为它和 Transformers 配合得最自然多线程 map 也省事。from datasets import Dataset raw_data { text: [这个手机续航很棒, 屏幕用了三天就花屏差评, 物流很快不错], label: [1, 0, 1] } dataset Dataset.from_dict(raw_data) def tokenize_fn(batch): enc tokenizer(batch[text], paddingmax_length, truncationTrue, max_length128) return {input_ids: enc[input_ids], attention_mask: enc[attention_mask]} tokenized_dataset dataset.map(tokenize_fn, batchedTrue, remove_columns[text]) print(tokenized_dataset[0])map 的 batchedTrue 会把一个批次文本一次性传给 tokenizer而不是一条条循环速度差很多。paddingmax_length 是拿最大长度 128 补齐所有样本paddinglongest 则是按当前批次最长样本补。训练时用 longest 更省显存但如果一个批次刚好都特别长容易 OOM所以我训练时更倾向固定 max_length推理时也保持一致这个细节能避免不少线上翻车。remove_columns[text] 是把原始文本删掉保留 input_ids、attention_mask、label 三列。这里要注意一个容易踩的点如果你的 label 字段名不叫 label比如叫 sentimentmap 之后这个字段还在没问题但 DataLoader 取值时要同步改。我习惯在数据进入模型前统一重命名省得文本里叫 sentiment、代码里叫 label最后两个字段都传进模型报了 Unknown keyword argument 才回头查。3.3 标签分布检查先看类别再谈训练数据处理好之后我干的第二件事是打印各类别数量。这是血泪教训换来的习惯类别不平衡会导致模型全部预测多数类准确率看着不低F1 一塌糊涂。import collections, torch label_list tokenized_dataset[label] counter collections.Counter(label_list) print(counter) # 如果不平衡优先用重采样不急着给 loss 加权重 from torch.utils.data import WeightedRandomSampler labels_tensor torch.tensor(label_list) class_counts torch.bincount(labels_tensor) weights 1.0 / class_counts.float() sample_weights weights[labels_tensor] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue)WeightedRandomSampler 会给少数类更多采样机会replacementTrue 表示可重复采样相当于把少数类复制了几份。这个处理做在 DataLoader 阶段比在 loss 里改权重直观也方便随时对比“重采样前 vs 后”的效果。但如果你的业务是多分类且类别本身有先验差异可以保留原始分布训练配合 macro-F1 评估。这里只是提供一个多数做法最终以你的验证集表现为准。我一般会同时跑两个版本一个重采样一个原始分布对比验证集 macro-F1 再定。3.4 从 Dataset 到 DataLoadermax_length、截断方向与批次大小到这一步数据还差一个格式转换。datasets 库可以直接把列转成 torch 张量省掉自己写 Dataset 子类的样板代码。tokenized_dataset.set_format(torch, columns[input_ids, attention_mask, label]) from torch.utils.data import DataLoader train_loader DataLoader(tokenized_dataset, batch_size16, shuffleTrue)set_format 之后每次取一条样本都是 dictkey 是 input_ids、attention_mask、labelvalue 是 torch.Tensor。DataLoader 会在此基础上自动组 batch。如果你的数据本来就是 pandas DataFrame也可以走 datasets 的 from_pandas差别只在读入方式。max_length 的选择不能拍脑袋。我会先看训练集的长度分布比如用 tokenizer 跑一遍之后统计 input_ids 的长度选出能覆盖 95% 样本的最小值。商品评论通常几十个字max_length128 够用如果是长文本比如客服会话或新闻评论可能要 256 或 512。长度每翻一倍显存消耗不是线性增长Transformer 的 attention 是平方级的所以能在满足覆盖率的条件下选短的性价比最高。截断方向同样值得看一眼。默认 truncation_sideright也就是把句子后半段切掉。对情感分析来说如果结论经常出现在句尾比如“电池不错但屏幕太烂”从右截断可能把“太烂”切掉模型就会看到一条错误的句子。遇到这种情况我会把 truncation_side 设为 left或者干脆调整最大长度保证关键信息保留在序列内。4. 微调训练与参数调优学习率、批次与训练循环里的细节4.1 超参数怎么定2e-5 起步小步慢走BERT 微调并非没有超参最核心的是学习率。对大规模预训练模型推荐 2e-5 到 5e-5。这个值的直觉预训练权重已经落在一个不错的位置反向传播的梯度如果步子迈太大会把注意力权重踢出预训练的盆地模型直接“失忆”。所以 learning rate 要小通常跑 3 个 epoch 就够。我常用的配置参数取值备注learning_rate2e-5小数据/长文本可以降到 1e-5batch_size16显存不够就降到 8 配合梯度累积max_length128根据长度分布调整epochs3配合早停和最佳 checkpoint 保存weight_decay0.01AdamW 默认推荐warmup_ratio0.1前 10% 的 steps 线性升温from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup total_steps len(train_loader) * epochs warmup_steps int(total_steps * 0.1) optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) scheduler get_linear_schedule_with_warmup(optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps)AdamW 里 weight_decay 默认 0.01它和 Adam 的 L2 正则不完全一样对 Embedding 和偏置项也会加权重衰减实际效果通常更稳。warmup 的作用是让训练前期梯度不猛冲尤其分类头是随机初始化的时候一上来就大步更新容易撞坏。如果你看到 loss 在第一个 epoch 里先涨后降不要慌这是分类头在适应预训练特征的正常现象如果两个 epoch 还不降那就过头了。4.2 手工训练循环看清每一步在干什么Trainer 虽然省事但排错时像个黑匣子。我通常第一版用手工循环跑把 loss、梯度范数、每步消耗时间都打印出来确认链路没问题后再改成 Trainer。下面是核心训练循环。from tqdm import tqdm import torch.nn.utils as nn_utils model.train() global_step 0 for epoch in range(epochs): progress tqdm(train_loader, descfepoch {epoch 1}) for batch in progress: batch {k: v.to(device) for k, v in batch.items()} outputs model(input_idsbatch[input_ids], attention_maskbatch[attention_mask], labelsbatch[label]) loss outputs.loss loss.backward() nn_utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() global_step 1 progress.set_postfix(lossloss.item(), lrscheduler.get_last_lr()[0])clip_grad_norm_ 是把全部梯度 L2 范数限制在 1.0 以内防止单条离谱样本把参数推飞。labels 直接传给模型内部会算交叉熵不用自己手动接 nn.CrossEntropyLoss。注意 optimizer.zero_grad() 在 step 之后调用避免把上一轮的梯度累进来。训练过程中我会同时盯着两样东西loss 是否在下降以及学习率曲线是否符合预期。如果 loss 在某个值附近震荡了 1000 步都不动大概率是数据里标签有噪声或者 max_length 太小导致关键信息被截断这时候调学习率意义不大回去查数据更有效。4.3 评估指标该看什么别把准确率当唯一标准情感分类如果是二分类且大致平衡accuracy 够用一旦类别不平衡必须上 macro-F1。sklearn 的 f1_score 对少数类和多数类一视同仁能更真实反映少数类能力。from sklearn.metrics import accuracy_score, f1_score pred_list, true_list [], [] model.eval() with torch.no_grad(): for batch in val_loader: batch {k: v.to(device) for k, v in batch.items()} logits model(input_idsbatch[input_ids], attention_maskbatch[attention_mask]).logits preds logits.argmax(dim-1).cpu().tolist() pred_list.extend(preds) true_list.extend(batch[label].cpu().tolist()) acc accuracy_score(true_list, pred_list) macro_f1 f1_score(true_list, pred_list, averagemacro) print(faccuracy{acc:.4f}, macroF1{macro_f1:.4f})评估时有几个点要注意model.eval() 之后 Dropout 自动关闭但 attention_mask 仍然必须传。只要你的验证集和训练集来自同一分布这个值基本能反映上线效果。如果验证集准确率一直小幅波动可以取最佳 macro-F1 对应的 checkpoint 作为最终模型而不是最后一个 epoch。保存最佳模型这一步特别容易忽略。很多教程只在最后 save_pretrained 一次结果训练完发现验证集已经过拟合只能重新训练。我一般按“验证 macro-F1 提升才覆盖保存”的方式写if macro_f1 best_f1: best_f1 macro_f1 model.save_pretrained(best_bert_sentiment) tokenizer.save_pretrained(best_bert_sentiment)4.4 显存不足时的梯度累积与梯度检查点BERT base 在 8G 显存的卡上微调过很多次。Batch size 16 到 max_length 128 大约占 8-9G如果 max_length 拉到 256基本就爆了。两种降显存的办法我都用过。第一种是梯度累积accumulation_steps 4 for batch in train_loader: outputs model(**batch) loss outputs.loss / accumulation_steps loss.backward() if (global_step 1) % accumulation_steps 0: optimizer.step() scheduler.step() optimizer.zero_grad()注意 loss 要除以 accumulation_steps等效于把 batch size 从 4 扩成 16但梯度在多个 mini-batch 上平均。真实 batch size 变大后学习率可以适当调大一点但我一般保守不动。第二种是开启 gradient checkpointing在加载模型后调用 model.gradient_checkpointing_enable()。它会以重算前向激活为代价换取显存速度变慢 20%-30%但显存能省将近一半。如果两个方法都用10G 以内的文本分类任务基本不会爆卡。还有一个冷门但有效的操作DataLoader 里给 tokenizer 传 return_tensorspt 时数据已经在 GPU 上又拉了一份如果预处理在 CPU 上做、只把 input_ids 移到 GPU显存占用会小不少。5. 常见问题与排查BERT 情感分析微调中的 5 个翻车现场5.1 排查问题的顺序先跑单批过拟合拿到一个微调代码我不会直接看哪里报错先做一件事拿出 10 条样本反复训练看模型能不能把 loss 打到接近 0。如果模型连这 10 条都记不住说明代码链路有问题如果能记住再换全量数据大概率是数据或超参问题。这条经验替我排掉了大量“玄学”问题。small_dataset tokenized_dataset.select(range(10)) small_loader DataLoader(small_dataset, batch_size2) # 在同一份小数据上反复训练 20 个 epoch观察 loss 能否降到接近 0这个测试成本很低跑完就能把“代码 bug”和“数据问题”分离。如果小数据集上 loss 降不到很低检查是不是梯度没回传、模型处于 eval 模式、或者 optimizer 参数没接全。如果 loss 能下去但全量数据不行那问题基本在数据分布和超参上。5.2 五个高频踩坑记录5.2.1 训练 loss 变 NaN现象前几个 step loss 正常之后突然出现 nan之后所有吞吐都是 nan。原因学习率过大或者是文本里有非常长的连续符号被 tokenizer 切出异常 token也可能是数据里有 None 或浮点型 label 被直接当张量传进 CrossEntropyLoss。解决把学习率降到 1e-5 再跑一轮同时打印 label 的类型和取值范围检查训练集里有没有空字符串空字符串经过 tokenizer 会得到只有 [CLS] 和 [SEP] 的序列模型照样能算但梯度容易爆。清洗数据时直接做 dropna 和 strip。也可以把梯度裁剪的 max_norm 从 1.0 调到 0.5能兜住大部分梯度爆炸。5.2.2 验证集准确率高线上表现差现象离线验证 95%上线一测真实业务数据只有 80%。原因验证集和训练集分布接近但线上数据里表达方式更多样特征分布发生偏移。常见的错误做法是训练时 max_length128推理时直接传 512导致模型看到的输入长度分布完全不同。解决上线前留一部分最新数据做时间切片验证推理时把 max_length 与训练时保持一致。如果线上有大量新词、表情符号、特殊符号检查 [UNK] 占比必要时扩充词表并继续预训练但那个成本很高简单场景下先把文本规范化做好更实在。5.2.3 几乎全预测为多数类现象二分类里 label 0 占 90%模型在验证集上准确率挺高但把少数类全漏掉。原因交叉熵默认把梯度重心放在多数类上少数类的 logit 推不动模型偷懒直接走“全部预测 0”这条路。解决最直接的办法是 WeightedRandomSampler 重采样或者给损失函数传 class_weight。我习惯先重采样因为训练曲线更像平衡分布如果重采样后过拟合再换成加权 loss。判断标准是 macro-F1不是 accuracy。5.2.4 CUDA out of memory现象跑到某个 batch 突然 OOM前面都好好的。原因要么 max_length 太大要么某条样本特别长padding 按全局最大长度补也可能是前一个实验的 GPU 显存没释放干净。解决先 nvidia-smi 看显存占用来源再把 batch_size 减半开 gradient checkpointing。如果只是个别超长样本导致可以在数据预处理阶段把长度超过 max_length 的样本单独截断而不是直接丢弃。还有一种情况是 DataLoader 里 num_workers 设置过大每个 worker 都复制一份模型状态导致显存叠加把 num_workers 降到 2 或 0 也能缓解。5.2.5 中文效果明显不如预期现象同样的代码换成中文数据集F1 比英文低一大截。原因模型用错了版本比如没有换成 bert-base-chinese 而用了英文的 bert-base-uncased也可能是数据处理直接小写化把中文标点或数字搞坏。解决确认 model_name 是 bert-base-chinese检查 input_ids 解码回文本是否和原文一致。可以用 tokenizer.decode(encoded[input_ids]) 看一眼中文空格和 [UNK] 出现频率过高就说明分词表不匹配。5.3 验证习惯保存错误样本比保存模型更重要最后加一个我很受用的习惯每一轮验证后把预测错的样本单独落一份。错样本集合比任何指标都更能说明问题。import json errors [] for text, true_label, pred_label in zip(val_texts, true_list, pred_list): if true_label ! pred_label: errors.append({text: text, true: true_label, pred: pred_label}) with open(errors.json, w, encodingutf-8) as f: json.dump(errors[:200], f, ensure_asciiFalse, indent2)如果发现大部分错的是包含“但是”的转折句下一步不是改超参而是先确认是不是截断把后半句切掉了。如果错的是缺乏常识的样本比如“充电口松了但不影响使用”被判成负面那就要考虑业务上到底算正面还是负面这就是一个标注一致性问题了。这个过程没法自动化但能帮你把调参时间省下来。6. 快速验证与部署一条链路写预测函数把 BERT 导成 ONNX6.1 落地上线前的稳定性验证模型微调完第一步不是接服务框架而是写一个带预处理包裹的推理函数一次性跑 1000 条真实样本统计平均耗时和内存占用。def predict(texts): inputs tokenizer(texts, paddingTrue, truncationTrue, max_length128, return_tensorspt) inputs {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): logits model(**inputs).logits preds logits.argmax(-1).cpu().tolist() return [正面 if p 1 else 负面 for p in preds]这里 paddingTrue 按批次最长补不影响精度适合批量场景。模型一次处理 128 个句子大概在几十毫秒量级瓶颈不在算力而在 Python 对象和 tokenize 的时间所以批量预测比一条条循环划算得多。6.2 把 PyTorch 模型导出 ONNX推理提速与工程解耦我的习惯是如果这个模型要进 Java 或 Golang 服务里不会直接调 PyTorch而是导成 ONNX 交给 onnxruntime。这样做脱离了 Python 环境也省掉了一半左右的推理时间。导出命令如下import torch model.eval() dummy_input { input_ids: torch.ones(1, 128, dtypetorch.long), attention_mask: torch.ones(1, 128, dtypetorch.long), } torch.onnx.export( model, (dummy_input,), bert_sentiment.onnx, input_names[input_ids, attention_mask], output_names[logits], dynamic_axes{ input_ids: {0: batch_size}, attention_mask: {0: batch_size}, logits: {0: batch_size}, }, opset_version11, )dynamic_axes 里的第 0 维是 batch_size允许推理时传入任意条数。opset_version 用 11再低的版本对 transformers 里的某些算子支持不完整。导出后用 onnxruntime 加载推理import onnxruntime as ort sess ort.InferenceSession(bert_sentiment.onnx) out sess.run([logits], { input_ids: input_ids.numpy(), attention_mask: attention_mask.numpy(), })注意导出时用的是 CPU 还是 GPU 环境onnxruntime 有对应版本别在 CPU 上导完拿到 GPU 上去跑算子不匹配的情况很常见。导出后一定要用同一批测试数据对拍PyTorch logits 和 onnx logits 最大误差在 1e-4 以内才放心误差再大就要检查算子兼容性。说实话BERT 微调本身没什么神秘真正的收益来自数据处理与稳定的验证习惯。从那以后我每次做文本情感分析微调都会强制走一遍先跑单批过拟合、打印类别分布、统一 max_length、保存错样本。这套流程至少帮我少熬两个夜。希望帮到你。本文还有配套的精品资源点击获取