文本分类大概是NLP里最常被拉出来练手的任务了。做推荐系统的拿它给内容打标,做客服机器人的拿它识别用户意图,做舆情监测的拿它判断情感倾向。早些年我用词典和TF-IDF硬凑特征的时候,怎么也没想到,一个叫BERT的模型会把这件事彻底改成另一套玩法。
这个系列我打算分上、中、下三篇来写。上篇先解决一个很多人没真正想明白的问题:BERT是怎么“读”懂一句话的?为什么它在文本分类这类的判别式任务上这么稳?如果你刚刚开始做文本分类,或者已经会用BERT但一直对内部机制半懂不懂,这篇就是给你准备的。我会用尽可能直白的语言,把判别式路线、双向编码、自注意力、CLS向量这些概念串起来讲清楚,顺便带上一些实测经验。
1. 判别式与生成式:为什么BERT天生适合做分类
很多初学者看到“大语言模型”这个词,第一反应是ChatGPT那类能聊天的模型。但在文本分类这件事上,两条技术路线非常不一样,选错方向会带来一连串麻烦。BERT走的是判别式路线,GPT走的是生成式路线,二者的出发点在数学上就有本质区别。
1.1 两条路线:学“边界”和学“分布”
判别式模型建模的是条件概率 P(标签|文本),翻译成大白话就是:给定一句话,模型直接判断它属于哪个类别,或者说模型学习的是类别之间的决策边界。生成式模型建模的则是联合概率 P(文本, 标签),更进一步说,它试图学习文本本身的结构和分布,标签更像是生成文本时的一个条件。
用一个水果的例子来说明。判别式模型像是尝过苹果和橘子之后,学会用颜色、形状、表皮粗糙程度来判断手里这个果子到底是苹果还是橘子。生成式模型则像是吃透了两类水果的所有特征,你给它说“画一个苹果”,它能画出一只完整的苹果来。分类任务只需要判个归属,不需要想象力和创造力,判别式模型天然更匹配这个目标。
这直接影响了部署成本和稳定性。同样一个分类任务,BERT这类判别式模型把输入句子编码成一个向量,再接一个简单的线性分类器就能出结果,训练流程短,推理速度快。生成式模型则要“更重”,内部要维护极其庞大的语言生成能力,往往需要更大的模型、更高的显存,还可能出现输出格式不稳定、token长度消耗过多的问题。
1.2 预训练与双向编码:BERT的底气
BERT能成为文本分类这条线上的老江湖,靠的是一套“预训练+微调”的范式。预训练阶段,它在大规模无标注语料上做自监督学习,学到的是通用语言知识。到了你的分类任务上,只需要在BERT的顶部加一个小分类头,然后在你自己的标注数据上做微调就行,不需要从零开始学习语言。
这里有一个非常关键的设计,也是BERT和GPT最大路线分歧点:BERT用的是双向编码。GPT用的是单向编码,也就是从左到右逐个词地预测;BERT的预训练目标是掩码语言模型(MLM),训练时随机把句子中15%的词换成[MASK],让模型根据整个句子的上下文去猜被遮住的词是什么。因为猜测的时候能看到这个词左边和右边的所有内容,模型被迫建立了真正的双向语义理解。
双向到底有多重要,做一个非常直观的对比就知道。这句话:“他因为没赶上火车,错过了今天的会议。”如果只从左往右读,读到“错过了”三个字时,你并不知道错过的是什么,如果只从右往左读,早期信息也会丢失。双向模型一次看到完整上下文,所以“火车”“会议”这两个词能够共同参与理解整件事。对文本分类来说,很多类别的判断依据藏在句子的多个位置,双向编码几乎是一个刚需。
1.3 双向编码到底解决了什么问题
文本分类里最常见的两个难点:一是同词多义,二是远距离依赖。双向编码对这两个问题的解决效果非常明显。
先说同词多义。“苹果”在“今天买了两斤苹果”里是水果,在“库克刚发布了新款苹果手机”里是品牌。单向模型读到第一个“苹果”时,还看不到后面的“手机”,容易误判;双向模型则能用右侧信息反推。BERT的做法本质上就是通过上下文来动态调整每个词的表示,这比传统Word2Vec那种一个词一个固定向量的思路先进太多。
再说远距离依赖。做情感分析时经常出现这种句子:“这家餐厅的装修我很喜欢,服务员态度也好,就是菜品实在不敢恭维。”最终判断是负面,但依据在最后半句,前面都是干扰信息。BERT的自注意力机制允许任意两个位置直接建立联系,哪怕“菜品”和“恭维”隔着12个字,也能在一次计算中产生交互。这是传统RNN/CNN模型很难做到的,也是BERT在长距离语义捕捉上一个很大的优势。
2. 自注意力与位置编码:BERT的“读”法
清楚了BERT走的是双向预训练路线之后,接下来就要看它具体怎么把一个句子变成计算机能计算的向量序列。这部分涉及三个核心机制:输入表示、自注意力、位置编码。这三个机制结合起来,构成了BERT“读”懂一句话的基本方式。
2.1 自注意力:一句话同时入眼
BERT的核心是Transformer的编码器部分,其中最要紧的模块就是自注意力(Self-Attention)。自注意力做的事情很直观:在处理每个词的时候,同时考虑句子里的所有其他词,计算出每个其他词跟当前词的相关性权重,再带着这些权重去融合相关信息。
我习惯用一个生活化类比:你在一场多人会议上,需要理解某人说的某一句话。你不会只盯住发言者的嘴,你会扫视在场所有人的反应,谁点头、谁皱眉、谁打断,这些都会帮你判断这句话的真实含义。自注意力干的就是这件事。差分是它不需要真的“扫视”,而是通过矩阵计算,为每个词对计算一个注意力分数。
具体实现可以简化理解:每个词生成三个向量,Query(查什么)、Key(能被谁查)、Value(实际提供的信息)。当前词的Query去和句子所有词的Key做点积,得到注意力分数,再经Softmax归一化,最后把每个词的Value按这个分数加权求和,就得到当前词的新表示。这个过程就是一次自注意力计算,BERT-base的配置是768维隐藏层、12个头、12层,规模说大不大,说小也不小,但在文本分类场景下效果足够稳。
2.2 位置编码:没有顺序就没有语义
Transformer有一个天然的短板:它是并行计算的,输入的所有词同时进入模型,模型本身并不知道哪个词在前、哪个词在后。如果完全不管顺序,“我打你”和“你打我”传入模型后初始表示是一样的,句子就成了一个词袋。
为了解决这个问题,BERT在输入层面引入了位置编码(Position Embedding)。BERT-base用的是可学习的位置嵌入,也就是给不同位置分配一组可训练的参数向量;后来的很多模型会改用正弦余弦函数来生成位置信息。无论哪种方式,目的都是把“每个词在句子中的位置”这个信息注入到词向量里,这样模型才能感知到先后顺序和相对距离。
更细一点说,BERT的输入表示是三层向量相加的产物:Token Embedding(词本身的意思)、Segment Embedding(区分句子A/B,在分类任务里通常是整句输入)、Position Embedding(位置信息)。三者相加得到初始向量,再送入后续的Transformer层。这里有个实操提示:在做微调时,tokenizer的职责不只是切词,它还会把输入转成input_ids、attention_mask、token_type_ids三样东西,这三个就是BERT输入层的直接来源。
2.3 多层堆叠:从词法到语义的逐级抽象
BERT一共有12层(以base版为例),每一层都由多头注意力和前馈网络组成。这里有一个非常有意思的特性:不同层学习的语言知识层次并不一样。很多研究做过探针实验,发现底层更关注词法层面的信息,比如词性、单复数;中层开始捕捉语法结构,比如主谓宾关系;高层才更贴近语义和上下文判断,也是分类真正依赖的那一层。
这一点对实际操作有指导意义。如果你做的是句子级别的分类,决定用哪一层的输出是有讲究的,后面我会专门对比CLS向量和池化方案。如果你做的是token级别的任务,比如命名实体识别,则需要从底层或中间层抽取更细粒度的特征。很多朋友一上来就知道用最后一层输出,这其实不一定是最优解,后面细说。
3. 从整句话到向量:CLS token与池化策略实战
BERT输出的是每个位置的向量,但文本分类是一个句子级任务,需要把一句话压缩成一个固定的向量,再喂给分类器。最常见的方案是使用[CLS]位置的输出,也就是开头那个特殊token的向量。这个方案确实简单,但实际用起来有一些坑,不同池化策略之间也存在真实的性能差异。
3.1 [CLS]是什么,为什么它能在句子开头
BERT在处理任意输入前,会在句子开头加上一个特殊的[CLS] token。在预训练的时候,它被设计成可以汇聚整句话有效信息的位置。配合下一句预测任务(NSP,判断句子A和句子B是否相邻)等训练目标,[CLS]位置的最终隐藏状态被训练成可以作为“句子整体表示”使用。
这不是说这个词本身有什么含义,它是一个容器。在12层Transformer的计算过程中,每层做自注意力时,[CLS]都会去“看”句子里的所有词,从它们那里收集信息。层数越深,[CLS]向量里沉淀的句子级语义越多。文本分类的做法通常就是对[CLS]向量做一个dropout,然后接到一个线性层上,输出维度是类别数,再接一个Softmax。
3.2 CLS、平均池化、最大池化,到底怎么选
理论上[CLS]很好用,但我在真实数据集上对比下来,发现一个很微妙的事实:在部分任务里,直接取最后一层的[CLS]效果并不总是最好的。我自己在一份中文新闻分类语料上做过一次小对比,效果趋势大致是这样:
| 池化策略 | 使用位置 | 我的实测印象 |
|---|---|---|
| CLS向量 | 最后一层 | 最省事,大部分任务够用,但少数字段有信息丢失 |
| 平均池化 | 最后一层或最后四层拼接 | 在很多短文本分类里能反超CLS |
| 最大池化 | 最后一层 | 对关键词敏感型任务友好,但容易丢失上下文细节 |
| 最后四层加权 | 多个层输出加权拼接 | 稳定,但显存和速度稍微吃亏 |
为什么会出现这样的差异?因为[CLS]的预训练目标和你的分类目标并不完全一致。对某些任务来说,分类判断依赖的是若干局部强特征,比如“贵”“太慢”“不推荐”这些词,平均池化可以把这些局部特征很好地保留下来;而[CLS]在高层可能已经把细节“抹平”的更多。当然,[CLS]的优势是稳定地学习了句子级表征,在多数规范任务里仍然是最省心的起点。
我的建议是:先跑一个CLS版本作为基线,再跑一个平均池化版本对比几轮。如果两个版本差异在1个点以内,用哪一个都无所谓;如果差异明显,就选效果更好的那个。不要在项目一开始就在池化策略上过多纠结,先把整体流程跑通更重要。
3.3 不止最后一层:多层输出能救急
不少朋友遇到过这种怪事:无论怎么调微调超参,模型在某个验证集指标上就是上不去,而且不同训练轮次之间波动特别大。这时候可以考虑一个进阶操作——从多层抽取输出。
BERT的中间层同样包含丰富的语义信息。常见的做法是取最后四层的输出做拼接或加权平均,再把结果喂给分类器。有人觉得这样会让向量维度膨胀,确实,最后四层拼接的话,768维就变成3072维,但接一个线性层把维度压回类别数,计算量增加并不多,在文本分类这种场景里完全可接受。
我做项目X的时候遇到过一个小样本分类问题,总共只有四五百条标注数据,直接用最后一层CLS只有72%的准确率,换成最后四层加权池化后提到了76%。原因也好理解,小样本微调时高层向量更容易发生过拟合,多层融合等于拿更丰富的中间特征做缓冲。当然,这个方案也不是百试百灵,如果数据集本身很大,差异会缩小,这时就更倾向于用便宜的做法。
4. 把BERT接到分类器:微调与超参数实操
前面讲的都是模型本身怎么理解一句话,真正落地到文本分类任务时,还需要一个分类头,还要处理微调这件事。很多入门同学在这一步犯的错误是:把微调想得太复杂,一上来就搞各种奇技淫巧,结果反而不稳定。我的经验是先按最朴素的方式搭起来,再根据具体问题做针对性的调整。
4.1 分类头:别一上来就做复杂结构
所谓分类头,其实就是BERT顶上接出的那一层。最常见的做法是把CLS位置的向量拿出来,经过dropout,然后接一个线性层,线性层输入维度是BERT的hidden_size,输出维度是类别数。以BERT-base为例,就是768维映射到N类,N你定。
如果类别数非常多,比如几千个标签的多标签分类任务,可以在这个线性层后面再加一个sigmoid而不是softmax,多标签场景下每个标签都要独立判断二分类概率。这个细节看着简单,但忘了改激活函数,指标会比预期差不少。
有没有必要在这个头里加一层隐藏层?我的答案是可以试,但别拿第一版项目冒险。一个隐藏层最多加到256维或者128维再做输出,能带来一定的非线性表达能力。但也会引入额外的过拟合风险。我做文本分类的默认做法是:单层线性头,dropout设0.1到0.3之间先跑通。只有数据量足够大,比如几万条起步,我才会认真考虑加深分类头。
4.2 学习率、max len、batch size怎么定
微调BERT最典型的特征是:预训练已经把模型参数带到了一个很好的位置,微调时步子要小。BERT官方推荐的微调学习率在2e-5到5e-5之间,这跟训练一个普通神经网络动辄1e-3的学习率有数量级差异。从实际表现来看,学习率给大了,损失下降速度看上去很快,但验证指标往往在震荡中停滞,甚至直接发散。
我说一个常见的组合,适用于大多数中文文本分类任务:
| 参数 | 推荐值 | 我的备注 |
|---|---|---|
| 学习率 | 2e-5到5e-5 | 做一次小范围扫描,先选3e-5 |
| batch size | 16或32 | 显存不够就减batch,配合梯度累积 |
| max len | 128到512之间 | 太短会截掉关键信息,太长显存受不了 |
| epoch数 | 3到10 | 必须配合早停,多看验证集 |
| warmup比例 | 前10%的step | 让模型先平稳进入状态 |
| weight decay | 0.01到0.1 | 对长文本数据效果好 |
max len这个参数有个很常见的误用。有些人为了追求信息完整,直接把max len设成512,导致显存吃紧,训练速度大幅下降。实际上,如果你的数据平均长度只有100到150个字,那max len设成128就够了。如果必需要长文本,可以先做一个长度统计,看看90分位点在哪个范围,再决定截断阈值。不要拍脑袋选一个很大的值,要让数据告诉你答案。
4.3 过拟合控制与调试心得
微调BERT,尤其是在小数据集上微调,过拟合现象来得非常快。有时候到第二个epoch,训练loss还在降,验证集指标已经在下滑了。很多人第一次观察到这个现象时觉得模型出了问题,其实这是BERT微调的正常表现,预训练模型容量大,标注数据少,根本没有机会把分布外的噪声彻底学完之前就已经开始记住了。
面对这个情况,有几个我经常用的方法:
一是早停,这是性价比最高的。设定一个忍耐区间,比如连续3个epoch的验证集指标不再提升,就停止训练并恢复最佳checkpoint。二是冻结底层参数,新数据量特别少时,可以把BERT前几层冻结,只微调后面几层和分类头,这样大幅减少可训练参数量,过拟合风险明显下降。冻结到底从第几层开始,要根据任务调,一般从第8层往后保留就行。三是分层学习率,分类头用相对更“大”一点的学习率,BERT主体用更小学习率,用一个参数区分,比如分类头的5e-4和BERT的1e-4分开设置。
小样本项目还有一个很实用的技巧:用对抗训练或者时序性增强的方式制造更多变体。比如在Embedding层加一点随机噪声,或者对样本做同义词替换扩充。这个办法不能保证每轮收益都明显,但在某些对措辞高度敏感的分类任务里,稳定性能确实能提升不少。
5. 常见问题与排查思路
做文本分类,尤其是把BERT接到自己的业务数据上时,几乎每天都要面对各种报错和效果问题。这里把我在多个项目里踩过的坑整理一下,按常见程度排序,方便你快速对照。
5.1 样本太少、标签不均衡、长文本怎么办
第一个高频问题:数据量很少,只有几百条,模型怎么都过拟合。这个场景非常常见,比如做某个垂直行业的意图分类,标注成本太高,能拿出手的数据极其有限。这种情况下不要直接硬训整个BERT,推荐的做法有三种:一是冻结BERT主体,只训练分类头,把BERT当成一个特征抽取器;二是用一些开源的大模型接口先做数据增强,比如同义改写;三是干脆换用更小的预训练模型,比如6层的小模型或者蒸馏版本,参数少很多,小数据上反而更稳。
第二个高频问题:标签分布严重不均衡。新闻分类里体育类的样本可能几万条,艺术类只有几百条。直接上交叉熵会让模型把所有样本都推向多数类。解决方案不外乎几类:用类别权重加权交叉熵,给小类更大的损失权重;用Focal Loss降低易分样本的权重,让模型更关注难分样本;或者反过来,对小类做上采样、对大类做欠采样作为简单的数据层处理。我的实测感受是,加权交叉熵改动最小、收到效果也快,Focal Loss要看任务类型,在标签噪声较大的场景里往往效果更好。
第三个高频问题:长文本怎么办。BERT的输入长度上限是512个token,但很多业务文本,比如风控领域的合同条款、医疗领域的病历,动不动就超长。我处理这类问题时通常考虑两个方向:一是分段截取,把长文本切成多个片段,每个片段分别过BERT得到各自的CLS向量,再做平均或加权汇总;二是只抽取关键区域,比如用句子的Attention分数或者TextRank选最相关的几句,再把这几句拼起来放入BERT。后者在主题集中的长文本上效果更明显。
5.2 训练异常与显存不足的定位方法
先说训练不收敛的问题。BERT微调很少出现不收敛,因为预训练权重已经很强了,通常“不收敛”都是配置错误。先检查分词结果,看看特殊token有没有被错误截掉,再看attention_mask是否正确,尤其是多段拼接时,mask没处理好会让模型把填充符也当成有效内容,表现就是loss剧烈震荡。如果这些都没问题,再看数据标签是不是有噪声,我遇到过一次验证集乱跳,最后发现是标注样本里有几条标签标反了。
再说显存不足(OOM)。模型装不下的第一反应是减小batch size,但如果小到4甚至2,BN等结构可能就不稳定了。更好的组合方案是:batch size维持在8或16,梯度累积次数设成4或8,让等效batch size保持不变;同时把max len尽可能压到数据允许的下限;再加上混合精度训练(fp16),显存占用一般能砍掉一半左右。我用某跨平台系统做长文本分类时,这几个优化一起上,单卡显存占用从接近爆掉降到了66%左右,训练速度还快了不少。
还有一个经常被忽略的点:显存不足不一定是模型太大,也可能是数据加载环节出了问题。比如某个字段没做截断,某几条异常长文本被直接塞进了batch,导致瞬间显存峰值爆掉。排查方法是打印每个batch输入的最大长度,看是否超出预期。我曾经排查过一个问题,就是数据里有几条几万字的纯文本,tokenizer之后长度远超max len,但因为我忘了做长度截断处理,某几个batch直接把显存顶穿。
写在最后的小经验
BERT做文本分类这件事,我最大的体会是:它是一个极其稳定的老江湖,你不需要玩出太多花活,把数据清洗干净、把超参设置合理、把过拟合控制住,效果就能超过绝大多数非预训练方案。如果你碰到某个数据集上效果不如预期,先不要怀疑模型不行,多数时候是数据输入的头没开好,或者标签噪声超出了合理范围。
文本分类这个方向,BERT哪怕放到今天也不是过时方案,尤其在数据量不算特别大的业务场景里,它的性价比依然很高。后面中篇我会继续写微调时的更多进阶技巧,以及如何把BERT模型收得更小、跑得更快,下篇再聊蒸馏和部署层面的实战心得。如果你正准备做自己的文本分类任务,可以先从BERT基线开始,顺着这篇文章的思路,把流程完整跑一遍再说。