☰
Python实战:BERT微调文本分类全流程解析
2026/10/21 7:34:30 网站建设 项目流程

把 BERT 写进 Python 实战日志,其实是我自己从“会调用模型”到“会改模型”的一次转折。第 21 篇选预训练模型,不是因为它新,而是因为很多 NLP 项目跑到这一步,才真正开始有落地感:输入一段文本,转成定长向量,再挂一个分类头,就能处理情感判断、文本审核、客服意图识别这类实际任务。这个项目要解决的核心问题,可以一句话概括:用最低的成本,把 BERT 从“听说过的名词”变成“能跑通的代码”,并且知道每一步为什么要这样做。

如果你已经写过不少 Python,也处理过文本数据,但还没认真碰过预训练模型,这篇正好适合你。文章里不会堆砌论文术语,重点是代码能跑通、参数有依据、踩坑可排查。即使你只有 CPU,也可以用一个小数据集完成整个流程,最多就是训练时间慢一些,不影响理解原理。

1. 项目整体设计与思路拆解

1.1 为什么这个项目值得单独记一篇

我在之前的实战里用过 TF-IDF 加线性分类器,也用过 Word2Vec 加 LSTM。这类方案有一个共同瓶颈:模型没见过“上下文”。同一个词在不同的句子里,语义可能是反的,比如“我喜欢这家店的空调”和“这个人真会空调”,如果你只用静态词向量,两个“空调”在模型里是同一个向量,后面的语义差异完全学不到。

遇到这类问题,传统做法是堆特征、做规则,或者换一个更强的序列模型。但效果天花板非常明显。后来我把模型换成 BERT,只做了最基础的微调,同一个测试集上的准确率从 86% 左右跳到 93% 以上。这个提升不是因为我调参厉害,而是预训练模型本身已经在大规模语料上见过足够多的语言规律,我们只需要在它的基础上做“最后一步适配”。

所以这篇日志记录的,其实是一套可复用的技术路径:加载预训练模型,完成文本向量化,微调出一个具体的分类器。这套路径可以迁移到很多场景,比如评论情感分析、工单自动分类、违规内容识别。

1.2 技术选型背后的取舍

做这个项目之前,我犹豫过两套方案:一是直接用别人封装好的文本分类接口,二是自己从零搭 BERT 微调流程。

调用现成接口确实快,但有个问题:你拿不到模型内部的行为,也很难针对自己的数据做调整。比如业务里有一些特殊黑话和简称,通用接口不认识,你就只能干瞪眼。自己微调看起来麻烦,但数据是自定义的,模型可以学到你业务里的表达方式,后续上线也更可控。最终我选择的是后者,也就是在这篇日志里展示的路线。

另一个取舍是模型规模。BERT 的 base 版本已经有 1 亿多个参数,再往上还有 large 版本,但显存需求成倍增长。对于大多数文本分类任务,base 版本的性能已经足够,而且社区里现成的中文权重很多。先跑通全流程,如果效果不够,再考虑更大模型,这才是务实的选择。

2. BERT 原理拆解:为什么它这么能打

2.1 双向编码到底改了什么事情

在 BERT 出现之前,很多模型读句子是“从左往右”读的,或者“从右往左”读。这种方式有个天然缺陷:模型在看到“苹果”这个词的时候,左边的“苹果”和右边的“苹果”只能看到一半上下文。如果句子是“苹果发布了新手机”,模型不知道后面可能有“手机”这个线索,容易把“苹果”理解成水果。

BERT 的做法是让模型同时看到左右两侧的上下文。它把每个词的位置上叠了很多层注意力机制,每个词都能“关注”到句子里其他所有词,再根据这些关注结果更新自己的表示。这样,“苹果发布新手机”里的“苹果”就能从“手机”那里获得信息,知道这是一个品牌而不是水果。

这个设计的实现方式叫“掩码语言模型”,听起来复杂,实际可以理解成完形填空。训练的时候,随机遮住句中一部分词,让模型根据剩余内容去猜被遮住的是什么。猜得越多,模型对词汇、语法、语义关系的理解就越深。

2.2 预训练和微调为什么是两回事

很多人第一次看到“预训练模型”这几个字,以为是别人已经帮我们把任务训练好了,直接拿过来用就行。这个理解不完全对。

预训练阶段,模型只是在“学语言”,并没有针对你的分类任务训练。它做的事情是读大量的文本,完成上面说的完形填空,以及判断两个句子是不是前后连贯。这个阶段非常消耗算力,通常需要使用大规模服务器集群,普通人没必要自己重做。

微调阶段,才是我们真正参与的部分。我们把预训练好的模型参数作为初始值,然后在自己的标注数据上继续训练。数据量不需要像预训练那么大,几百条到几千条,只要质量不差,往往就能有不错的效果。这相当于一个已经读过很多书的人,你只需要给他看几个工作样例,他就知道你的业务该怎么处理。

2.3 tokenizer 为什么是关键中的关键

模型只能吃数字,不能吃原始文字,所以第一步必须把文本转成 ID。这个过程不是简单地“按字编码”,而是有一套自己的逻辑。

BERT 家族常用的是 WordPiece 或 BPE 这类子词切分方法。英文里会把“playing”切成“play”和“##ing”,中文模型则大多直接按汉字切分。这么做的好处是:即使遇到生僻词,也能通过子词单元组合出大致含义,不会因为词表里没有就直接当成“未知”。

这里有一个新手最常忽略的点:训练时用的 tokenizer,推理时也必须用同一个。如果训练时用中文 BERT 自带的分词器,线上推理时换了一个自己写的按空格切分的 tokenizer,input_id 对不上,模型输出会完全乱掉。

3. 实操:环境准备与模型加载

3.1 安装依赖

这个项目用到的核心库是 PyTorch 和 transformers,前者负责张量计算,后者负责模型结构和参数加载。额外加一个 scikit-learn 用来算分类指标。

pip install torch transformers datasets scikit-learn

如果你的电脑上有 NVIDIA 显卡,建议安装对应版本的 CUDA 版 PyTorch,训练速度会快很多。如果只有 CPU,也可以跑,只是训练时间长一些。我建议第一次跑通时,哪怕用 CPU,也把整个流程走一遍,确认代码没有问题再上 GPU。

transformers 库本身迭代很快,不同版本的 API 略有差异。我写这篇时用的版本跑通上述代码没有问题,但如果你安装的是过于老旧或者刚发布的新版本,个别参数名可能变化。遇到报错时,优先看提示里的属性名,再对着官方文档确认一下。

3.2 加载模型和分词器

这里我以中文 BERT 为例。加载模型时,可以直接传一个模型名,transformers 会自动从服务器下载权重和配置文件。

from transformers import BertTokenizer, BertModel model_name = "bert-base-chinese" tokenizer = BertTokenizer.from_pretrained(model_name) bert_model = BertModel.from_pretrained(model_name)

如果你是做英文任务,可以换成对应的英文模型名。如果公司内部有离线权重包,第二个参数也可以填本地路径,比如:

tokenizer = BertTokenizer.from_pretrained("./bert_model/") bert_model = BertModel.from_pretrained("./bert_model/")

这在实际项目中很常见,因为线上服务器不一定能访问外部网络。下载好的模型目录里一般包括配置文件、词表文件和权重文件,拷贝到目标机器后,用本地路径加载即可。

加载完成后,建议先跑一个简单测试,确认模型能正常前向计算:

test_text = "这门课程虽然很难,但收获很大。" encoded = tokenizer.encode_plus( test_text, add_special_tokens=True, max_length=128, padding="max_length", truncation=True, return_tensors="pt", ) print(encoded["input_ids"]) print(encoded["attention_mask"])

运行后会看到一串数字 ID,以及对应位置的注意力掩码。注意力掩码里,1 表示真实文本位置,0 表示 padding 补充的位置。这个 mask 必须带给模型,否则模型会把没有意义的填充位也当成文本内容来理解。

如果你需要输出 token_type_ids,这个参数在较早版本里会自动生成,当前版本的接口里默认也会返回。它用来区分第一句和第二句,如果是单句分类任务,全部都是 0,不传也没有关系。

3.3 拿到文本向量

加载好模型后,很多人想知道“一段文本的向量到底长什么样”。BERT 的输入经过多层 Transformer 后,会输出一个三维张量,形状是(batch_size, sequence_length, hidden_size)。以 BERT-base 为例,hidden_size 是 768,也就是说,每一个 token 位置都对应一个 768 维向量。

最常用的向量有两种取法。第一种是直接取[CLS]位置对应的向量,也就是序列开头的那个特殊 token。这个 token 在预训练时被设计成聚合整句信息的角色,所以很多分类模型会把[CLS]向量接上分类层。第二种是对所有 token 的向量做平均池化,这种操作在文本相似度任务里更常见。

with torch.no_grad(): outputs = bert_model(**encoded) last_hidden_state = outputs.last_hidden_state cls_vector = last_hidden_state[:, 0, :] sentence_vector = last_hidden_state.mean(dim=1) print(cls_vector.shape) print(sentence_vector.shape)

注意,这里我用了torch.no_grad(),因为只是提取特征,不需要计算梯度。如果你后面要微调模型,就不能用 no_grad,否则梯度传不回来。

4. 微调一个句子分类模型

4.1 准备自己的数据集

这里我们做一个非常标准的二分类任务,假设输入是一些用户评论,输出是“正面”或“负面”。实际业务里可能是“垃圾”和“正常”,原理完全一样。

先构造一个简单的数据列表:

texts = [ "发货很快,质量也很好", "客服态度差,申请退货还被拒绝", "性价比很高,推荐购买", "包装破损,商品已经摔坏了", ] labels = [1, 0, 1, 0]

真实项目里不会是这么点数据,但结构是一样的。你需要把文本和标签整理成两个对齐的列表。注意,训练数据和测试数据最好按类别均匀划分,不能全部是正面样本用于训练,否则模型学不到负面样本的规律。

接下来封装成一个 PyTorch Dataset:

import torch from torch.utils.data import Dataset class TextDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = self.texts[idx] encoded = self.tokenizer.encode_plus( text, max_length=self.max_len, padding="max_length", truncation=True, return_tensors="pt", ) return { "input_ids": encoded["input_ids"].squeeze(0), "attention_mask": encoded["attention_mask"].squeeze(0), "labels": torch.tensor(self.labels[idx], dtype=torch.long), }

这里的关键是truncation=True。如果某条文本超过最大长度,直接截断,否则模型会因为长度不一致而报错。max_length 我通常先设 128,大部分评论和短工单都够用。如果业务文本很长,比如有几页合同,再考虑增大到 256 或 512,但显存开销会变大。

4.2 用 DataLoader 批量喂数据

PyTorch 的 DataLoader 会自动把样本拼成 batch。因为我们已经在 tokenizer 里做了 padding,batch 内所有样本长度一致,不需要额外写复杂的 collate 函数。

from torch.utils.data import DataLoader train_dataset = TextDataset(train_texts, train_labels, tokenizer, max_len=128) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)

如果原始样本长短差异很大,而且你想省显存,也可以不做 max_length padding,而是在 DataLoader 的 collate_fn 里动态补齐到当前 batch 的最长长度。不过第一次跑通时,我建议用固定 padding 方式,逻辑简单且不容易出错。

4.3 加载带分类头的模型

刚才用的是 BertModel,它只能输出文本向量,不会输出类别概率。做分类任务,应该用 BERT 家族里自带分类头的版本。

from transformers import BertForSequenceClassification num_labels = 2 model = BertForSequenceClassification.from_pretrained( model_name, num_labels=num_labels, )

这行代码会自动把预训练好的 BERT 权重加载进来,同时在顶部接一个分类层。分类层的参数是随机初始化的,后续微调会重点学习这部分。我们可以通过打印模型结构来确认:

print(model)

输出里能看到 bert 和 classifier 两个模块。如果你只想冻结 BERT 部分,只训练分类头,可以设置for param in model.bert.parameters(): param.requires_grad = False。但对小数据集来说,全量微调通常效果更好。因为 BERT 的参数已经很强,即便全量微调,学习率调小一点,也不容易把学到的知识冲掉。

4.4 训练循环和关键参数

训练预训练模型和训练普通网络不太一样,最重要的一个原则是:学习率要小。BERT 微调里常用的学习率是 2e-5 到 5e-5,这个数值比普通神经网络常用的 1e-3 小很多。如果沿用习惯里的 1e-3,模型很可能会迅速震荡,loss 不降反升。

优化器我一般用 AdamW。它是在 Adam 基础上修正了权重衰减的实现方式,对 transformer 这类大模型更友好。

from transformers import BertForSequenceClassification import torch optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5) epochs = 3 total_steps = len(train_loader) * epochs def lr_lambda(step): progress = step / total_steps return 1.0 - progress scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_lambda)

训练时,每个 batch 都要经过清零梯度、前向传播、计算 loss、反向传播、更新参数这几步。唯一要注意的是,BERT 的输出对象里已经包含 loss,只要传入了 labels,outputs.loss 就可以直接用。

model.train() for epoch in range(epochs): for step, batch in enumerate(train_loader): optimizer.zero_grad() batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss loss.backward() # 梯度裁剪,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() if step % 20 == 0: print(f"epoch {epoch}, step {step}, loss {loss.item():.4f}")

梯度裁剪很多人会忽略,但对 transformer 模型其实很重要。当 batch 里出现一些特别长的样本,或者标签噪声很大时,梯度可能突然变大,导致 loss 跳到巨大数值。加上max_norm=1.0的约束,可以让训练稳定很多。

4.5 评估与预测

训练结束后,评估时要切到 eval 模式,并且用 no_grad 关闭梯度计算,否则推理时间会成倍增加。

from sklearn.metrics import accuracy_score model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for batch in val_loader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) logits = outputs.logits preds = torch.argmax(logits, dim=-1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(batch["labels"].cpu().tolist()) print("accuracy:", accuracy_score(all_labels, all_preds))

如果你的测试集有严重的类别不平衡,不要只看准确率,还要看精确率、召回率、F1。比如违规内容只占 1%,模型全预测正常,准确率也能到 99%,但这个模型毫无用处。在分类评估里,F1 往往比准确率更能反映真实水平。

5. 常见问题与排查技巧实录

5.1 CUDA 显存不足

这是我在微调时遇到最多的报错,提示往往写着 “CUDA out of memory”。排查思路很简单,从三个方向收敛:

方向做法
降低 batch_size从 32 降到 16,再降到 8
降低 max_length128 不够用可以 96,甚至 64
模型太大考虑换更小的蒸馏版模型

如果业务确实需要长文本和较大 batch,可以用梯度累积,也就是不每个 batch 都更新参数,攒几步再更新:

accumulation_steps = 2 loss = outputs.loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

这样做相当于用一个大的有效 batch,但显存峰值只等于一个正常 batch 所需的显存,代价是训练时间会变长。

5.2 Loss 一直不降

Loss 不降,先别急着调模型结构,按顺序检查这几项:

第一,学习率是否太大或者太小。太大表现为 loss 波动剧烈,太小表现为 loss 几乎不变。可以从 2e-5 开始,往上试 5e-5,往下试 1e-5,每次只动一个变量。

第二,tokenizer 处理后的 input_ids 是否正确。打印几条数据看一眼,常见问题是不小心把所有文本都 padding 成 0,导致模型看到的全是 padding。

第三,标签是否写反。比如数据注释里 1 是正面,但代码里读出来变成了负面,模型会学得很痛苦,因为数据本身就自相矛盾。

第四,是否在同一个 batch 里混入了没有截断的超长文本。有的 batch 特别长,导致 attention 矩阵巨大,训练不稳定。设置 max_length 并启用 truncation 通常能解决。

5.3 训练效果可以,但推理速度慢

BERT-base 的推理速度不算快。如果业务场景是实时接口,用户发出请求后需要在几百毫秒内返回结果,原版 BERT 在普通 CPU 上可能撑不住。

我的建议分三步走:先做 batch inference,也就是把多条请求凑成一个 batch 同时推理,吞吐量会明显提升。然后看能不能降低 max_length,很多文本关键信息集中在前半部分,截断到 64 或 96 损失并不大。最后,如果还是慢,考虑用蒸馏后的小模型或者对模型做量化,蒸馏版模型能保留大部分效果,但参数量和推理耗时小很多。

5.4 中文分词和输入格式问题

有朋友跑英文项目很顺,换成中文就出问题,最常见的坑是用了不支持中文的 tokenizer。加载模型时,模型名和 tokenizer 名要一致。如果用中文 BERT 权重,就一定加载对应的中文 tokenizer,不能拿英文 BERT 的分词器去切中文,那样切出来的是一堆乱码级别的子词。

另外,输入 BERT 不要把[CLS]和[SEP]自己手动加。很多人为了省事,直接在文本前面拼了一个 “CLS” 字符串,这是错的。特殊 token 必须由 tokenizer 生成,而且这些 token 在词表里有专属于自己的 ID,直接拼字符串会导致模型无法识别。只需要把add_special_tokens=True打开,其他交给 tokenizer 处理。

6. 我的几个配置习惯

做完这个项目,我留下了一套自己的默认配置,很多场景可以直接套用。

短文本分类任务,max_length 设为 128,batch_size 设为 16 或 32,学习率 2e-5,epoch 数 3 到 5。如果数据量比较少,epoch 可以适当多一点,但要观察验证集指标,防止过拟合。如果数据量很大,epoch 反而要减少,因为 BERT 拟合得很快。中长文本任务,先把 max_length 调到 256,观察显存占用。不要把长度设置成 512 当默认值,很多场景没有必要。

微调阶段,不建议一开始就冻结全部 BERT 层。如果计算资源允许,全量微调的效果最好。只有数据量非常少,或者你只是为了快速上线一个基线版本,再考虑冻结一部分底层参数。

最后一点是记录实验的习惯。每次修改参数,都要把 loss、验证集准确率、F1、训练时间记下来。我在实际跑项目时发现,很多表面上的“玄学”问题,比如换了一个模型后效果变好,其实只是因为 batch_size 或者 max_length 变了。没有对比记录,你根本说不清是哪个改动起了作用。

这个项目到这里并没有结束。你掌握了文本向量化和分类微调之后,后续可以往两个方向扩展:一是把分类头换成序列标注头,做命名实体识别;二是把 BERT 的输出用在文本匹配上,做相似问题检索。底层思路是一样的,只是输出层的设计不同。希望这篇日志能帮你把 BERT 这条路跑通,少踩一些我已经踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询