简介:面向中文文本情感分析任务的实战资源包,覆盖数据加载、模型构建、训练评估与单句预测完整流程,基于BERT与ERNIE两类预训练模型,适用于舆情分析、评论分类等场景,兼顾NLP初学者与需要快速落地情感分类项目的开发者。压缩包共19个文件,其中11个Python脚本为核心实现,涵盖模型定义(含BERT、基于CNN的BERT变体及ERNIE)、训练与评估、预测入口、数据读取等模块;4个Markdown文档用于说明环境配置、执行步骤与模型细节;4个TXT文件提供训练集、验证集、测试集和类别标签样例。整体仅814KB,轻量紧凑,便于快速下载并在本地环境运行调试。目前已有3717人学习使用。通过main.py指定--model参数即可切换BERT或ERNIE进行训练,训练完成后可调用predict.py对任意中文句子得到情感倾向结果;资源还保留预训练模型目录与加载逻辑,帮助读者理解中文句子如何经过分词、编码、BERT编码器与分类输出层完成情感判断,这个工程稍加调整即可迁移至其他短文本分类任务。
1. 基于BERT的文本情感分析:为什么词向量方法突然就不够用了
文本情感分析这个需求,从电商评论打标到舆情监控,本质上都是在回答一句话:这句话是正面、负面还是中性。早几年我们做情感分析,主流方案是Word2Vec或GloVe词向量拼特征,再喂给TextCNN、BiLSTM这类模型。问题是词向量是静态的,"好吃"和"难吃"在不同的上下文里永远拿着同一套向量,遇到"这家店真不咋地但分量足"这种带转折的句子,模型基本只能靠猜。BERT的出现把这些方案几乎全盘推翻:它用Transformer的注意力机制做双向编码,每一个词的表征都融合了它左右两侧的上下文信息。在情感分析这种高度依赖语义细粒度理解的任务上,BERT从预训练权重迁移过来的语言知识,能让模型用很小的代价就达到此前需要精心调参才能摸到的准确率。这篇文章就从零开始走一遍基于BERT的文本情感分析落地路径,覆盖环境搭建、数据准备、微调训练、参数设置和最常见的翻车点,确保你照着能跑通一个可用于真实项目的基线模型。
2. 用Transformers库加载BERT模型:最小推理脚本与三个必调参数
2.1 环境准备:torch、transformers与tokenizer版本怎么选
基于BERT做情感分析,绕不开HuggingFace的Transformers库。这个库封装了从模型权重下载、分词器到训练管线的全部接口,真正需要你自己动手写的代码其实很少。环境这块常见的坑是版本错配:transformers 4.36以上版本对torch的要求是大于等于2.0,如果你的环境里还是torch 1.12,加载bert-base-chinese时会出现各种奇怪的兼容报错,比如"Some weights of the model checkpoint were not used"或者直接抛出CUDA相关的初始化异常。
我一般会创建一个干净的conda环境,把版本一次性固定好。
conda create -n bert-sentiment python=3.9 -y conda activate bert-sentiment pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.36.0 pip install datasets==2.19.0 pip install evaluate==0.4.1 pip install accelerate==0.27.2 pip install scikit-learn这段代码的逻辑是先把Python版本锁在3.9,因为3.10以上的环境在安装一些旧版本依赖时容易遇到预编译轮子缺失的问题。torch指定2.1.2搭配CUDA 11.8,这是目前兼容性最稳的一组组合。transformers选择4.36.0而不是最新版,是因为这个版本的Trainer接口相对稳定,网上能搜到的大多数微调脚本都基于这个版本语法。datasets和evaluate是配套的数据集加载和评估工具,accelerate用于分布式训练和混合精度。装完之后建议跑一句验证代码,确认GPU可用并且transformers能正常导入。
python -c "import torch; print(torch.cuda.is_available()); import transformers; print(transformers.__version__)"如果第一行输出True,说明CUDA环境正常;第二行输出4.36.0,说明transformers安装无误。很多人在这步卡住的原因其实是pip默认装了CPU版本的torch,导致cuda.is_available()返回False,后续训练慢到怀疑人生。检查方法是在pip list里看torch的版本后缀,如果显示的是+cpu而不是+cu118,就需要重新按上面的命令安装。
2.2 加载预训练模型并跑通单条文本情感判定
环境没问题之后,先别急着想训练的事,第一步是让模型能对一条文本给出情感判断。我们用的预训练权重是bert-base-chinese,这是Google针对中文语料发布的BASE版本,层数12层、隐层维度768、12个注意力头,参数量约1.1亿。虽然现在有更轻量的蒸馏版和更大的中文预训练模型,但bert-base-chinese仍然是最稳妥的起步选择,社区资料多,打印日志时不容易出现诡异的编码问题。
加载模型的代码如下,这段代码同时也是后续微调脚本的骨架。
from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch # 加载预训练分词器和分类模型 tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese", use_fast=True) model = AutoModelForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=3, # 情感类别数:0负面 / 1中性 / 2正面 hidden_dropout_prob=0.1, # 全连接层dropout,防止过拟合 ) # 单条测试文本 text = "这家餐厅的菜味道不错,就是排队时间太长了" # 分词:把文本转成模型需要的input_ids和attention_mask inputs = tokenizer( text, max_length=128, # 超过128个token的部分截断 truncation=True, # 允许截断 padding="max_length", # 不足128的填充到128 return_tensors="pt", # 返回PyTorch的Tensor格式 ) # 推理时不计算梯度,省显存也避免误伤参数 with torch.no_grad(): logits = model(**inputs).logits # 用softmax把logits转成概率分布 probs = torch.softmax(logits, dim=-1).squeeze() label_id = torch.argmax(probs).item() print(f"情感类别: {label_id}") print(f"概率分布: 负面={probs[0]:.4f}, 中性={probs[1]:.4f}, 正面={probs[2]:.4f}")这段代码的关键在于AutoTokenizer和AutoModelForSequenceClassification会自动从HuggingFace的模型仓库里拉取与bert-base-chinese匹配的配置文件和权重,不需要手动去下载三个独立的文件。tokenizer负责把中文文本切分成token序列,bert-base-chinese用的是WordPiece分词,会把一个词切成更细的子词单元,比如"餐厅"可能被切成两个token,这也是BERT处理未登录词的底气所在。推理阶段包裹在torch.no_grad()里,核心原因是避免PyTorch为前向传播构建计算图,构建计算图会额外占用显存,而在单纯的预测场景下我们根本不需要反向传播。
初跑这个脚本时有个值得单独拿出来说的点:第一次执行会自动下载模型文件到用户目录下的.cache/huggingface文件夹,这个下载过程受网络环境影响比较大,文件总共约400MB。如果下载超时,常见做法是手动设置HF_ENDPOINT环境变量切换到镜像源,但那个方案在不同时间段稳定性差异很大,我更推荐直接找个网络状况好的时间段,或者提前把模型文件下载好放到本地目录,然后改用from_pretrained("/本地路径")加载。
2.3 最小推理脚本的三个必调参数:max_length、batch_size与num_labels
刚才的代码里其实藏着三个对效果影响最大的参数,理解和微调它们,比换模型结构还重要。
max_length控制输入序列的最大长度。BERT的Transformer层理论上可以处理任意长度,但位置编码是训练时按512个token设计的,超过这个长度效果会衰减。情感分析任务一般文本都很短,中文评论平均长度在30到60个字之间,128个token绰绰有余。但如果你做的是长文本情感分析,比如豆瓣长评,需要把max_length调到256甚至384,代价是显存占用和推理延迟同步上升。测试文本长度和max_length的关系时,可以用tokenizer(text).input_ids的长度来判断,超过就往大调,没超过就保持128,这是最省心的策略。
batch_size出现在推理脚本里就是你一次喂给模型几条文本。单条推理时batch_size=1,显存占用量大约2GB,可以接受。但实际需求往往是给一批评论打分,比如几万条电商评论要做情感聚合,就必须把多条文本拼成一个批次喂进去,这时候批量推理比循环单条推理快十倍不止。批量推理的代码如下。
texts = [ "这家餐厅的菜味道不错", "客服态度超级差,再也不来了", "产品一般般,凑合用" ] # padding=True表示按本批次最长的文本填充,而不是都填到max_length inputs = tokenizer(texts, padding=True, truncation=True, max_length=128, return_tensors="pt") with torch.no_grad(): logits = model(**inputs).logits probs = torch.softmax(logits, dim=1) preds = torch.argmax(probs, dim=1) print(preds.tolist())逻辑区别在于padding策略:单条推理用max_length可以统一维度,而批量推理用padding=True能省掉空白填充带来的计算浪费。num_labels参数则是定义模型输出层的神经元个数,情感分类你要是按负面/中性/正面分三类,num_labels就设3;如果只分正面和负面两类,就设2。这个参数必须在加载模型时就设定好,因为输出层的权重矩阵维度就是num_labels乘隐层维度。训练好后推理时num_labels也必须和训练时保持一致,否则加载权重文件会报shape不匹配的错。
3. 数据准备与训练管线:从CSV到Transformers Dataset
3.1 情感标注数据的获取与格式转换
要微调BERT做情感分析,没有适合任务的数据就是纸上谈兵。常见的公开中文情感数据集有ChnSentiCorp,这是谭松波老师整理的酒店评论语料,约7000条,分为正面和负面两类;还有电商购物评论数据、微博情感语料等。如果你做的是垂直领域的情感分析,比如金融舆情、医疗评价,公开数据集未必覆盖得到,那就得自己标注。自己标注的样本量起点建议在5000条以上,太少的话BERT的微调优势发挥不出来,模型很容易退化成记忆训练集。
无论数据来源是什么,最终都要格式化成两列:一列是评论文本,一列是情感标签。标签可以是字符串("正面""负面"),也可以是整数(0/1/2),但建议直接用整数,省去训练时再做一次map的麻烦。我一般把数据整理成CSV文件,结构如下。
text,label 这家餐厅的菜味道不错,1 客服态度超级差再也不来了,0 产品一般般凑合用,0 体验超出预期强烈推荐,1数据准备好之后,用datasets库的load_dataset函数加载,然后拆分成训练集和验证集。拆分比例建议8:2,但如果你的数据量刚过5000,验证集可以只留10%,因为BERT参数量大,验证集样本太少会导致评估指标波动剧烈,今天的准确率94%,明天重跑就变成91%,这种玄学波动很容易让你误判调参效果。
from datasets import Dataset, DatasetDict import pandas as pd # 用pandas读CSV,再转成Dataset df = pd.read_csv("sentiment_data.csv") dataset = Dataset.from_pandas(df[["text", "label"]]) # 按8:2比例切分训练集和验证集 splits = dataset.train_test_split(test_size=0.2, seed=42, stratify_by_column="label") dataset_dict = DatasetDict({ "train": splits["test"] if False else splits["train"], "eval": splits["test"] })这里有个细节值得注意:train_test_split的stratify_by_column参数是按标签比例分层抽样,保证切分后的训练集和验证集里正面、负面样本比例和原数据一致。如果你的原始数据里90%是正面评论,10%是负面评论,不按stratify切分的话,运气不好可能把仅有的负面评论全切进验证集,模型训练时根本没见过几个负面样本,验证时又看不到正面样本,整个评估过程就失去了意义。
3.2 数据预处理:用tokenizer把文本批量转成模型输入
Dataset只是把数据组织起来了,BERT还不能直接吃原始文本,需要经过tokenizer处理后才能喂进模型。这里的处理逻辑是:把每条文本转成input_ids序列,同时生成attention_mask,标识哪些token是真实的文本内容、哪些是填充位的padding符号。BERT的分词器和词表在预训练阶段就已经固定,微调时不需要再训练分词器,直接用就行。
def tokenize_function(batch): # batch是dict,包含text和label两个字段 return tokenizer( batch["text"], padding="max_length", truncation=True, max_length=128 ) # 对数据集做批量映射 tokenized_dataset = dataset_dict.map(tokenize_function, batched=True) # 验证一下处理后的字段 print(tokenized_dataset["train"].column_names) # 输出: ['text', 'label', 'input_ids', 'token_type_ids', 'attention_mask']map操作带batched=True参数,意思是一次处理一批样本,而不是一条条遍历,速度能提升好几倍。tokenizer会在每条样本上生成input_ids、token_type_ids和attention_mask三个字段。token_type_ids在单句分类任务里其实用不到,默认就全是0,但transformers的DataCollator会原样传递它,不会出问题。预处理完的数据集里text字段已经不需要了,但先留着不影响训练,只是占一点内存。
3.3 用Trainer接管训练循环:训练参数配置表与显存控制
Transformers库的Trainer类把训练循环、梯度更新、日志输出和模型保存全部接管了,你只需要配置TrainingArguments。这个设计非常实诚,省去了自己写for epoch循环的功夫,也避免了很多新手在手动实现梯度裁剪、学习率调度时的隐性bug。
from transformers import TrainingArguments, Trainer, DataCollatorWithPadding # 配置训练参数 training_args = TrainingArguments( output_dir="./bert_sentiment_checkpoint", evaluation_strategy="epoch", save_strategy="epoch", logging_strategy="steps", logging_steps=50, num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=32, learning_rate=2e-5, warmup_ratio=0.1, weight_decay=0.01, fp16=True, load_best_model_at_end=True, metric_for_best_model="eval_accuracy", save_total_limit=2, ) # 用DataCollator动态处理padding data_collator = DataCollatorWithPadding(tokenizer=tokenizer) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["eval"], data_collator=data_collator, compute_metrics=compute_metrics, # 自定义评估函数 )训练参数这块有几处新手容易踩雷。per_device_train_batch_size指每张GPU上的batch大小,如果你有2张GPU,实际总batch是32,等价于单卡batch设32的效果。fp16=True开启混合精度训练,能用半精度浮点数前向传播、全精度更新权重,显存占用直接砍半,训练速度提升约30%到50%。如果你的显卡不支持混合精度或者会报错,就改成fp16=False,但训练速度和显存占用会明显变差。weight_decay=0.01只对非bias和非LayerNorm的参数生效,Trainer内部已经做了这种精细化的处理,自己手写训练循环时很容易忽略这一点。load_best_model_at_end=True让训练结束时自动加载验证集上指标最好的那一步权重,避免拿最后一轮可能已经过拟合的权重去推理。
4. 微调训练流程与关键参数调整:把BERT调成你的情感分析专家
4.1 完整微调脚本:数据加载、评估函数与训练执行
前面的准备都是零散的,现在把它们串成一个可直接运行的完整微调脚本。这个脚本涵盖了从读CSV到训练完成保存模型的全部流程,也是我在项目里反复使用的模板。手工在BERT的顶层加一个分类头,然后让分类头的权重从头学习、BERT本体权重参数微调就可以了,不需要自己写前向传播逻辑。
import numpy as np from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer, DataCollatorWithPadding, ) from datasets import Dataset, DatasetDict from sklearn.metrics import accuracy_score, f1_score import pandas as pd import torch # 1. 加载数据 df = pd.read_csv("sentiment_data.csv") dataset = Dataset.from_pandas(df[["text", "label"]]) splits = dataset.train_test_split(test_size=0.2, seed=42, stratify_by_column="label") dataset_dict = DatasetDict({ "train": splits["train"], "eval": splits["test"], }) # 2. 加载模型和分词器 tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese", use_fast=True) model = AutoModelForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=3, ) # 3. 数据预处理 def tokenize_function(batch): return tokenizer( batch["text"], truncation=True, max_length=128, padding="max_length", ) tokenized_dataset = dataset_dict.map(tokenize_function, batched=True) # 4. 定义评估指标 def compute_metrics(eval_pred): logits, labels = eval_pred predictions = np.argmax(logits, axis=-1) return { "accuracy": accuracy_score(labels, predictions), "f1": f1_score(labels, predictions, average="weighted"), } # 5. 定义训练参数 training_args = TrainingArguments( output_dir="./bert_sentiment_checkpoint", evaluation_strategy="epoch", save_strategy="epoch", num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=32, learning_rate=2e-5, warmup_ratio=0.1, weight_decay=0.01, fp16=True, load_best_model_at_end=True, metric_for_best_model="eval_accuracy", ) # 6. 初始化Trainer并开始训练 trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["eval"], tokenizer=tokenizer, data_collator=DataCollatorWithPadding(tokenizer=tokenizer), compute_metrics=compute_metrics, ) trainer.train() # 7. 保存最终模型 trainer.save_model("./bert_sentiment_final") tokenizer.save_pretrained("./bert_sentiment_final")训练脚本里compute_metrics函数接收的是eval_pred对象,包含模型输出的logits和真实标签,我们在外层套上argmax转换成预测类别,然后分别计算accuracy和F1分数。F1用weighted平均,考虑类别样本量差异,比单纯的accuracy更能反映模型在样本不均衡时的真实表现。训练完成之后,save_model和tokenizer.save_pretrained会把模型权重和词表文件一起保存到指定目录,这个目录就是未来推理时用的完整模型包。
4.2 learning rate、warmup与weight decay:三个参数的调整思路
BERT微调领域,learning rate是最核心的超参数。和从头训练神经网络不同,BERT的权重已经经过大规模预训练,拥有很强的语言表征能力,所以微调时的学习率要小得多。常见做法是2e-5到5e-5这个区间,比这个范围大,模型很快train loss暴涨,比这个范围小,训练好几轮loss降不下去。如果你的任务和预训练语料分布差异非常大,比如做医疗文本情感分析而预训练主要覆盖新闻语料,可以试着从3e-5起步,配合更长的训练轮数。
warmup_ratio=0.1的含义是前10%的训练步数内,学习率从0线性增长到设定值,之后再按余弦曲线衰减。为什么要warmup?因为BERT在加载预训练权重后,一开始输出的梯度方向可能和分类任务最优方向有较大偏差,猛然用2e-5的满速更新容易让模型走偏,warmup给了模型一个缓冲期。weight_decay=0.01是L2正则化的变体,作用于除了bias和LayerNorm之外的参数,防止微调过程中权重变化过大导致灾难性遗忘。
这三个参数在第一次训练时不要改,全部按模板值跑完一轮,先看验证集上的准确率基线。如果准确率已经够用,就不要动这些参数,训练时间比调参时间值钱得多。如果准确率差一口气,优先动learning rate,往3e-5调一次,再看验证集F1的变化。这里有一点值得注意,金色的参数对你的任务没有通用最优解,只有相对合理区间,不要照搬别人的最优值。
4.3 早停策略与模型检查点管理
训练一个BERT模型,你真正想要的是验证集上表现最好的那一版权重,而不是最后一个epoch的权重。因为训练后期模型在训练集上的表现持续上升,但在验证集上的表现可能已经在下降,这就是过拟合的信号。TrainingArguments里的load_best_model_at_end=True已经帮我们处理了这个问题,它会根据metric_for_best_model指定的指标挑出验证集上最好的检查点。
但有一点容易忽略,就是save_total_limit=2这个参数。不设置它的话,每个epoch保存的检查点都会留在磁盘上,训练3轮就多出3个检查点文件夹,每个约400MB,一轮实验下来磁盘就被塞满了。更关键的是,如果你不限制保存数量,load_best_model_at_end会在所有检查点里搜索最优,搜索范围越大磁盘占用越大。save_total_limit=2的意思是只保留最近的2个检查点,加上最终保存的final模型,磁盘占用控制在1.2GB左右,足够安全。
关于早停,Trainer默认没有内置early stopping逻辑,需要安装transformers的callbacks模块里的EarlyStoppingCallback。
from transformers import EarlyStoppingCallback trainer.add_callback( EarlyStoppingCallback( early_stopping_patience=2, # 连续2个epoch评估指标没提升就停 early_stopping_threshold=0.001, # 提升小于0.001视为没有提升 ) )early_stopping_patience设置为2的意思是,如果验证集准确率连续2个epoch都没有超过之前的最好记录,训练就会提前终止。这个机制非常实用,尤其是当你把num_train_epochs设得比较大,比如5或者6,而模型在第三个epoch就已经收敛时,早停能帮你省下三分之一以上的训练时间。需要留意的是,EarlyStoppingCallback必须配合evaluation_strategy="epoch"使用,否则它不知道在什么时候评估模型。
4.4 让模型真正学会情感:微调流程与关键参数调整
4.1 训练脚本运行后的正常日志长什么样
第一次跑完上面的训练脚本,你会看到终端不断滚动输出类似下面的日志。
{'loss': 0.8843, 'learning_rate': 1.89e-05, 'epoch': 0.25} {'loss': 0.4125, 'learning_rate': 1.56e-05, 'epoch': 0.5} {'loss': 0.2738, 'learning_rate': 1.23e-05, 'epoch': 0.75} {'eval_loss': 0.2411, 'eval_accuracy': 0.9117, 'eval_f1': 0.9102, 'epoch': 1.0}loss从0.88稳步下降到0.27,说明模型正在正常收敛。eval_accuracy到第一个epoch结束时有0.9117,对于5000条数据的微调任务来说是一个不错的信号。这里要特别观察eval_loss和train_loss的差值:第一个epoch两者差距很小,说明模型还没有过拟合;如果训练到第三个epoch时,train_loss降到0.08而eval_loss反弹到0.35,那就说明模型已经记住了训练集中的噪音,早停机制会在这时候介入。
4.2 训练集很小或样本不均衡时,先试哪些调整
遇到训练集不足5000条、或者某个类别只占5%的场景,调learning rate不如换策略。我常用的一个落地做法是先冻结BERT主干,只训练分类头。这需要修改模型结构,但实际操作也不复杂。
# 冻结所有BERT底层参数 for param in model.bert.parameters(): param.requires_grad = False # 只训练分类头 classifier_params = [p for p in model.classifier.parameters() if p.requires_grad] optimizer = torch.optim.AdamW(classifier_params, lr=1e-3)这段代码的思想是:小数据集上让BERT的全部1.1亿参数参与微调,极大概率过拟合;而只放开分类头训练,能把可训练参数量压缩到几千个,训练难度大幅下降。等分类头收敛后,再把BERT底层参数解冻,用2e-5的学习率做一两轮全量微调,效果往往比一开始就全量微调好得多。样本不均衡方面,可以在Trainer传入compute_metrics时配合class_weight,或者在数据预处理阶段用上采样,把少数类样本复制两到三份。前者实现简单,后者效果更直接。
5. 避坑指南:基于BERT做情感分析的5个常见翻车现场
5.1 现象:CUDA out of memory,训练中途直接崩
情感分析任务文本短,很多人理所当然地以为显存不会出问题,结果训练才跑了几百步,终端直接抛出"CUDA out of memory"。
原因通常出在两个方面:一是max_length设得过大,二是per_device_train_batch_size设得过高。BERT的显存占用和输入长度成线性关系,展开成矩阵运算后,注意力矩阵是序列长度的平方。你把max_length从128调到512,显存占用可能涨了三倍以上。
解决思路是先小后大。在TrainingArguments里把per_device_train_batch_size设成8跑一次,如果显存还剩很多,再改成16。另外确认fp16=True已经开启,混合精度可以省接近一半显存。如果以上做完仍OOM,检查是不是有人在同一个GPU上跑了别的任务,nvidia-smi看一下显存占用,这是最常见的原因之一。
5.2 现象:loss不降或者loss直接变NaN
预训练模型加载出来,按理说loss应该稳步下降。如果你看到loss在0.8附近震荡十几个epoch不降,或者一轮之后直接变成nan,大概率是学习率设置出了问题。
学习率过大会让loss爆炸成nan,学习率过小则会让loss陷入平台期。解决的办法是,先按2e-5跑一次,观察loss梯度;如果loss完全没有下降趋势,把学习率提高到5e-5;如果训练中途出现nan,把学习率降到1e-5。另外检查数据里是否有标签异常值,比如label不小心写成了2.5这种非整数,或者某些文本为空字符串。空字符串在tokenizer处理后会变成全padding的输入,模型forward输出没有意义,loss自然不稳定。
5.3 现象:训练速度慢到无法接受
小数据集还好,如果数据量上了10万条,每轮训练需要半小时起步,这就不得不排查速度问题。
先确认训练用的是GPU还是CPU。很多人装的是CPU版torch,数据量一上来就原形毕露。用nvidia-smi看训练时GPU利用率,低于50%说明数据加载管线拖了后腿,很可能是dataloader的num_workers没设置。Trainer里可以通过TrainingArguments的dataloader_num_workers=4来提升数据加载并行度。还有一个容易被忽略的点:fp16=True没有开,半精度训练能在不损失太多精度的情况下把训练吞吐提升30%到50%,这是性价比最高的单项优化。
5.4 现象:训练完验证集准确率很高,但预测结果全是正类
这是一个典型的样本不均衡案例。假设你的训练数据里90%是正面评论,模型什么都不学,只要把所有样本预测成正面就能拿到90%的准确率,验证集结果当然好看。但一应用到真实场景就暴露了,负面评论全被识别成了正面。
真正有效的解决方式是换评估指标,别只看accuracy,改成F1或者AUC。同时在数据预处理阶段做类别重采样,把少数类样本复制或者用SMOTE方法合成,让训练集比例尽量均衡。这个坑最隐蔽的地方在于,如果你不打印分类报告,只看accuracy的话,永远发现不了问题。
5.5 现象:换了一个领域,模型表现断崖式下跌
用酒店评论训练的模型,拿去判断金融新闻的正面负面情绪,准确率从92%跌到60%多一点,这并不奇怪。情感表达是强领域相关的:"退退退"在电商物流语境里是正面情感,在社交媒体上可能是愤怒表达。BERT预训练阶段的语言知识是通用的,但情感判断的知识需要在特定领域上重新微调。
解决这个问题没有捷径,收集目标领域的数据再微调一轮。如果目标领域数据量太少,另一个可用的办法是先在公开的大型情感数据集上微调,再用少量领域数据做二次微调,也就是常说的两阶段微调。这个做法在金融文本、医疗文本上都有不错的实证效果。
5.6 现象:加载保存的模型推理时shape mismatch
训练完保存模型,再加载做推理时报"Error(s) in loading state_dict"或者shape不匹配。
原因几乎一定是加载模型时num_labels设置和训练时不一致。训练时你用了num_labels=3,推理时忘了设置或者设成2,输出层的权重维度从[3, 768]变成了[2, 768],自然对不上。解决方法是加载时显式传入训练时的num_labels,并保持其他配置一致。还有一个容易踩的细节是,用AutoModel.from_pretrained加载而不是用AutoModelForSequenceClassification.from_pretrained,后者会自动加载分类头,前者只加载BERT主干,两种方式得到的模型结构不同,state_dict自然对不上。
6. 把微调好的BERT模型落地推理:模型量化与分类前缘的一个实用技巧
训练只是开始,真正项目里要把模型用起来,会遇到推理速度的问题。BERT-base有1.1亿参数,单条推理在GPU上约20到40毫秒,在CPU上可能要到200到500毫秒。如果业务是离线批处理几万条评论,这个速度勉强能接受;但要是在线接口,比如用户提交一条评论就实时返回情感标签,GPU部署成本高,CPU上这个延迟又不能接受。我的习惯做法是把模型导出成ONNX格式,再用onnxruntime做CPU推理,速度比PyTorch原生推理快2到3倍。
这一步落地不难,核心工具是transformers自带的一次性导出能力。
pip install onnx onnxruntime-gpu optimum然后看下面这段导出和推理的代码。
from optimum.onnxruntime import ORTModelForSequenceClassification from transformers import AutoTokenizer # 导出ONNX模型 ort_model = ORTModelForSequenceClassification.from_pretrained( "./bert_sentiment_final", export=True, provider="CPUExecutionProvider", ) ort_model.save_pretrained("./bert_sentiment_onnx") tokenizer = AutoTokenizer.from_pretrained("./bert_sentiment_final") tokenizer.save_pretrained("./bert_sentiment_onnx") # 用onnxruntime加载并推理 from optimum.onnxruntime import ORTModelForSequenceClassification ort_model = ORTModelForSequenceClassification.from_pretrained( "./bert_sentiment_onnx", provider="CPUExecutionProvider", ) text = "这个电影太震撼了,后劲很大" inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=128) outputs = ort_model(**inputs) probs = torch.softmax(torch.tensor(outputs.logits), dim=-1) print(probs.tolist())ONNX导出的本质是把PyTorch的动态计算图转换成静态图结构,去除Python运行时依赖,然后在onnxruntime里做图优化和算子融合。实际操作中,导出的onnx模型文件大概400MB,比PyTorch权重文件略大,但推理速度的提升足够支付这个磁盘代价。
另一个更加极端的加速方案是知识蒸馏,用微调好的BERT-base作为教师模型,训练一个6层的蒸馏学生模型,比如distilbert-base-chinese。这样推理速度能再翻一倍,不过蒸馏本身需要额外的一轮训练,样本量足够大时才划算。如果项目还在验证阶段,先用ONNX方案就够了,蒸馏和量化都留给后续优化。
使用中我还有一个习惯:每次做情感分析实验,记录下学习率、batch size、数据量、验证F1这四个字段,贴在一个表格里。要调参时先翻这张表,很多坑就不必重复踩了。这里的经验是,BERT的情感分析效果上限很高,但它的成绩对数据分布非常敏感,你必须先观察数据的类别比例和文本长度分布,再去谈参数调优。希望这篇实战笔记帮你在自己的数据集上少走弯路。
本文还有配套的精品资源,点击获取