最近用BERT做了一个文本分类的项目,从数据预处理到模型训练,再到部署上线,踩了不少坑。今天记录一下整个过程中的经验和教训,包括数据处理、微调技巧、性能优化、部署等方面,希望能帮到大家。

先简单介绍一下项目背景。我们公司有一个客服系统,每天会收到大量的用户咨询,需要对这些咨询进行分类,比如产品问题、账户问题、支付问题、物流问题等,然后分配给对应的客服人员处理。以前是人工分类,效率低,而且容易出错。我们想用BERT做一个自动分类的模型,替代人工分类,提高效率。

BERT(Bidirectional Encoder Representations from Transformers)是Google在2018年发布的预训练语言模型,它通过在大规模文本上进行预训练,学习到了丰富的语言表示,然后在下游任务上进行微调,就能取得很好的效果。BERT的出现,彻底改变了自然语言处理(NLP)领域,现在已经成为NLP任务的标配。

我们用的是BERT-base中文模型,12层Transformer,768维隐藏状态,12个注意力头,大概1.1亿参数。在我们的标注数据上进行微调,做文本分类。

整个项目做了大概一个月,从数据准备到模型训练,再到部署上线,中间踩了不少坑。今天就把这些经验和教训记录下来,分享给大家。

一、数据预处理的坑

数据是模型的基础,数据处理不好,模型再强也没用。我们在数据预处理阶段,踩了不少坑。

坑1:数据清洗不彻底

我们的原始数据是客服系统的历史记录,里面有很多噪音,比如HTML标签、特殊字符、表情符号、乱码、重复数据、错误标注等。一开始我们没有做太仔细的清洗,直接用原始数据训练,结果模型效果很差,准确率只有70%左右。

后来我们花了很多时间做数据清洗:

  • 去除HTML标签和特殊字符
  • 统一全角半角字符
  • 去除乱码和无法识别的字符
  • 去除重复数据
  • 修正错误标注
  • 过滤掉太短或太长的文本

清洗之后,数据质量提升了很多,模型的准确率也提升到了85%以上。所以,数据清洗非常重要,不要偷懒,一定要把数据清洗干净。

坑2:数据不平衡

我们的分类数据,各个类别的样本数量很不均匀。比如,产品问题有几万条,而物流问题只有几千条,差了一个数量级。数据不平衡会导致模型偏向样本多的类别,样本少的类别准确率很低。

一开始我们没有处理数据不平衡,直接训练,结果样本少的类别(比如物流问题)准确率只有50%,而样本多的类别准确率有90%。整体准确率看起来还行,但实际使用的时候,物流问题经常被分错,用户体验很差。

后来我们用了一些方法来处理数据不平衡:

  • 过采样:对样本少的类别,重复采样,增加样本数量
  • 欠采样:对样本多的类别,随机采样,减少样本数量
  • 类别权重:在损失函数中,给样本少的类别更高的权重
  • 数据增强:对样本少的类别,用同义词替换、回译等方法生成新的样本

我们最后用的是类别权重+数据增强的组合,效果比较好,样本少的类别准确率提升到了80%以上,整体的F1值也提升了不少。

坑3:文本长度处理不当

BERT的最大输入长度是512个token(包括<[BOSneverused_51bce0c785ca2f68081bfa7d91973934]>和[SEP]),超过的部分会被截断。我们的文本,大部分在100到300个token之间,但有一些很长的文本,超过了512个token,需要截断。

一开始我们简单地从前面截断,只保留前512个token。但后来发现,有些文本的关键信息在后面,前面都是一些客套话,从前面截断会丢失关键信息,影响分类效果。

后来我们试了几种方法:

  • 从前面截断:保留前512个token,适合关键信息在前面的文本
  • 从后面截断:保留后512个token,适合关键信息在后面的文本
  • 首尾截断:保留前128个和后382个token,兼顾开头和结尾
  • 滑动窗口:把长文本分成多个片段,分别预测,然后综合结果

我们最后用的是首尾截断的方法,效果比较好,既保留了开头的信息,也保留了结尾的信息,而且实现简单,推理速度快。对于特别长的文本,我们用滑动窗口的方法,但这种情况不多。

另外,对于太短的文本(比如只有几个字),我们也做了处理,要么过滤掉,要么和上下文拼接,因为太短的文本信息太少,模型很难分类。

坑4:分词和编码的问题

BERT用的是WordPiece分词(中文用的是字级分词),有自己的词表。我们一开始用了自己的分词工具(jieba),然后再编码,结果和BERT的分词不一致,导致模型效果很差。

后来我们才明白,BERT有自己的分词器(tokenizer),必须用BERT的分词器来分词和编码,不能用自己的分词工具。BERT的中文分词器是按字分词的,每个汉字是一个token,还有一些特殊的token(比如<[BOSneverused_51bce0c785ca2f68081bfa7d91973934]>、[SEP]、[PAD]、[UNK])。

我们用HuggingFace的transformers库,里面有现成的BertTokenizer,直接用就可以了,非常方便。分词和编码的事情,交给tokenizer就好,不要自己造轮子。

二、模型微调的坑

数据处理好之后,就是模型微调了。BERT的微调看起来简单,就是在预训练模型上面加一个分类头,然后训练,但实际上有很多细节需要注意。

坑1:学习率设置不当

BERT的微调,学习率非常关键。学习率太大,模型会震荡,甚至不收敛;学习率太小,模型收敛慢,而且容易过拟合。

一开始我们用了默认的学习率(2e-5),结果模型收敛很慢,训练了10个epoch还在缓慢提升。后来我们把学习率调大到5e-5,结果模型震荡,准确率忽高忽低。最后我们试了很多次,发现3e-5的学习率比较合适,收敛快,而且稳定。

另外,学习率的调度也很重要。我们用的是线性warmup + 线性衰减的调度方式,先在warmup阶段把学习率从0升到目标学习率,然后再线性衰减到0。这种调度方式比固定学习率效果好,能让模型更稳定地收敛。

还有,不同的层可以用不同的学习率。BERT的底层学习到的是通用的语言表示,不需要太大的调整,学习率可以小一点;顶层学习到的是任务相关的表示,需要更大的调整,学习率可以大一点;分类头是从零开始训练的,学习率可以更大。我们用的是分层学习率,底层1e-5,顶层3e-5,分类头1e-4,效果比统一学习率好。

坑2:batch size和梯度累积

BERT模型很大,训练的时候很占显存。我们用的是一张16GB显存的GPU,batch size只能设到16,再大就OOM(显存不足)了。batch size太小的话,模型训练不稳定,效果也不好。

一开始我们就用batch size=16训练,结果模型效果一般,而且训练过程中loss波动很大。后来我们用了梯度累积(gradient accumulation)的方法,把batch size等效扩大。比如,设置accumulation_steps=4,就是每4个step更新一次梯度,等效的batch size就是16*4=64。

用了梯度累积之后,等效batch size变大了,模型训练更稳定,效果也提升了。而且,梯度累积不需要额外的显存,只需要多花一点时间,非常适合显存不足的情况。

当然,梯度累积也不是完美的,它会让训练速度变慢,因为更新次数少了。而且,如果batch norm的话,梯度累积会影响batch norm的统计量,不过BERT用的是layer norm,不是batch norm,所以没有这个问题。

坑3:过拟合和正则化

BERT的参数量很大(1.1亿),而我们的训练数据只有几万条,很容易过拟合。一开始我们训练的时候,训练集的准确率很快就到了95%以上,但验证集的准确率只有85%,而且训练了几个epoch之后,验证集的准确率开始下降,明显过拟合了。

后来我们用了一些正则化的方法来防止过拟合:

  • Dropout:在分类头前面加了dropout,dropout rate设为0.1或0.2
  • 权重衰减(weight decay):在优化器中加了权重衰减,设为0.01
  • 早停(early stopping):监控验证集的准确率,如果连续几个epoch没有提升,就停止训练
  • 数据增强:对训练数据做一些增强,比如同义词替换、随机删除、随机交换等,增加数据的多样性
  • 冻结底层:训练的时候先冻结BERT的底层,只训练顶层和分类头,等收敛之后再解冻底层,用小学习率微调

用了这些方法之后,过拟合的问题得到了缓解,验证集的准确率提升到了90%以上,而且训练更稳定了。

坑4:多类分类的损失函数

我们的任务是多类分类,一开始用的是普通的交叉熵损失函数。后来发现,因为类别之间有一定的相关性(比如产品问题和支付问题有重叠),普通的交叉熵效果不是最好。

我们试了几种方法:

  • Label Smoothing:把硬标签变成软标签,比如把0变成0.1,把1变成0.9,防止模型过于自信,提高泛化能力
  • Focal Loss:降低易分类样本的权重,让模型更关注难分类的样本,适合数据不平衡的场景
  • 对比学习:在损失函数中加入对比损失,让同类的样本表示更接近,不同类的样本表示更疏远

我们最后用的是Label Smoothing + 交叉熵的组合,效果比普通交叉熵好一点,验证集准确率提升了1到2个百分点。Focal Loss在我们的数据集上效果不明显,可能是因为我们的数据不平衡不是特别严重。

三、模型评估的坑

模型训练好之后,需要评估模型的效果。评估的时候,也有一些坑需要注意。

坑1:只看准确率,不看其他指标

一开始我们只看准确率(accuracy),觉得准确率高就好。但后来发现,准确率高不代表模型好用,尤其是在数据不平衡的情况下。

比如,我们的数据有10个类别,其中最大的类别占了40%。如果模型把所有样本都预测成最大的类别,准确率也有40%,但这个模型根本没用。所以,只看准确率是不够的,还要看精确率(precision)、召回率(recall)、F1值,尤其是每个类别的精确率和召回率。

我们最后用的是宏平均F1(macro F1)作为主要的评估指标,因为宏平均F1对每个类别一视同仁,不会被样本多的类别主导,更能反映模型在各个类别上的整体表现。同时,我们也会看每个类别的精确率和召回率,确保每个类别的效果都达标。

坑2:验证集和测试集的划分

一开始我们随机划分训练集、验证集和测试集,结果发现验证集和测试集的效果很好,但上线之后效果很差。后来才发现,我们的数据有时间相关性,同一个用户的咨询可能在时间上很接近,随机划分会导致训练集和测试集有重叠,模型其实是在"作弊"。

后来我们改成了按时间划分,用前80%时间的数据作为训练集,中间10%作为验证集,最后10%作为测试集。这样,测试集的数据是模型完全没有见过的,更能反映模型在真实场景下的效果。

另外,我们还做了用户级的划分,确保同一个用户的咨询不会同时出现在训练集和测试集中,避免数据泄露。

划分方式改了之后,测试集的准确率下降了不少,但更真实了,上线之后的效果和测试集的效果基本一致。

坑3:混淆矩阵和错误分析

模型评估的时候,不要只看数字,还要看混淆矩阵和错误案例。混淆矩阵能让你看到模型容易把哪些类别搞混,错误案例能让你看到模型为什么会分错。

我们通过混淆矩阵发现,模型容易把"支付问题"和"账户问题"搞混,因为这两个类别的文本确实很相似。通过错误分析,我们发现,很多分错的案例,其实标注本身就有问题,或者文本信息太少,确实很难分类。

根据错误分析的结果,我们做了一些改进:

  • 修正了标注错误的数据
  • 对容易混淆的类别,增加了更多的训练样本
  • 对信息太少的文本,增加了一些规则,先做预处理,再交给模型分类
  • 对模型不确定的样本(预测概率低于某个阈值),转人工处理

做了这些改进之后,模型的实际使用效果提升了很多,用户满意度也提高了。

四、模型部署的坑

模型训练好、评估好之后,就是部署上线了。部署的时候,也有很多坑。

坑1:推理速度慢

BERT模型很大,推理速度比较慢。一开始我们直接用PyTorch的模型推理,单条文本的推理时间大概是100毫秒,我们的QPS(每秒查询数)要求是100,这样的话需要10个GPU才能满足,成本太高了。

后来我们做了一些推理速度的优化:

  • 模型量化:把模型从FP32量化成INT8,推理速度提升了2到3倍,精度损失很小
  • 模型剪枝:剪掉一些不重要的注意力头和层,减少模型大小,提升推理速度
  • ONNX导出:把PyTorch模型导出成ONNX格式,用ONNX Runtime推理,速度比PyTorch快
  • TensorRT:用NVIDIA的TensorRT优化模型,推理速度进一步提升
  • 批量推理:把多条文本组成一个batch,一起推理,提高GPU利用率
  • 缓存:对重复的请求,缓存结果,不用重复推理

我们最后用的是ONNX + 量化 + 批量推理的组合,单条文本的推理时间降到了20毫秒左右,QPS提升了5倍,用2个GPU就能满足需求了,成本降低了不少。

坑2:显存占用大

BERT模型本身就大,加上推理的时候的中间变量,显存占用很大。一开始我们用PyTorch推理,一个模型就占了2GB显存,加上批量推理,占的更多,一张16GB的GPU只能跑几个模型实例。

后来我们做了一些优化:

  • 用ONNX Runtime推理,显存占用比PyTorch小
  • 模型量化,INT8模型比FP32小一半
  • 动态batch,根据请求量动态调整batch size,避免显存浪费
  • 用CPU推理:如果QPS不高,可以用CPU推理,虽然慢一点,但成本低,而且不需要GPU

我们最后用的是GPU + ONNX + 量化的方案,显存占用降到了1GB左右,一张GPU能跑更多的实例,利用率更高。

坑3:服务稳定性

模型服务上线之后,稳定性非常重要。一开始我们的服务经常出问题,比如OOM、超时、依赖冲突等。

后来我们做了一些改进:

  • 容器化部署:用Docker打包模型服务,环境一致,部署方便
  • 限流和熔断:对请求做限流,防止流量过大把服务打垮;服务出问题的时候自动熔断,返回默认结果
  • 健康检查:定期检查服务的健康状态,出问题自动重启
  • 日志和监控:记录详细的日志,监控服务的QPS、延迟、错误率等指标,出问题及时告警
  • 灰度发布:新模型先灰度发布,小流量验证,没问题再全量上线
  • 回滚机制:新模型出问题的时候,能快速回滚到旧版本

做了这些改进之后,服务的稳定性提升了很多,上线之后很少出问题。

坑4:模型更新和迭代

模型上线之后,不是就完事了,还需要持续更新和迭代。随着业务的发展,会出现新的类别、新的表达方式,模型的效果会慢慢下降,需要定期更新。

我们建立了一套模型迭代的流程:

  • 数据收集:定期收集线上的用户咨询和人工修正的数据,作为新的训练数据
  • 模型重训:定期用新的数据重新训练模型,评估效果
  • 模型对比:新模型和旧模型做对比,确保新模型的效果不低于旧模型
  • 灰度发布:新模型先灰度发布,小流量验证
  • 全量上线:验证没问题之后,全量上线
  • 效果监控:上线之后持续监控模型的效果,如果效果下降,及时回滚或者重新训练

有了这套流程,模型就能持续迭代,保持好的效果。

五、一些经验和建议

最后,分享一些BERT实战的经验和建议。

1. 先跑通baseline,再逐步优化

不要一开始就追求最完美的方案,先跑通一个简单的baseline,看看效果怎么样,然后再逐步优化。很多时候,简单的baseline效果就已经不错了,不需要太复杂的优化。

2. 数据比模型更重要

BERT模型已经很强了,很多时候,效果不好不是模型的问题,而是数据的问题。把数据清洗干净,标注准确,处理好数据不平衡,比换更复杂的模型效果更明显。所以,要把更多的精力放在数据上。

3. 不要盲目追求大模型

BERT-base已经很强了,对于大多数任务来说,BERT-base完全够用。不要盲目追求BERT-large或者更大的模型,大模型训练慢、推理慢、成本高,而且效果提升有限。如果BERT-base效果不好,先看看是不是数据的问题,或者微调的方法不对,不要急着换大模型。

4. 善用开源工具和库

不要自己造轮子,HuggingFace的transformers库已经很成熟了,里面有各种预训练模型和工具,直接用就好。还有很多其他的开源工具,比如模型量化的、部署的、监控的,都可以直接用,能节省很多时间。

5. 重视模型评估和错误分析

不要只看准确率,要全面评估模型的效果,看精确率、召回率、F1值,看混淆矩阵,看错误案例。通过错误分析,找到模型的不足,然后针对性地改进,这样才能真正提升模型的效果。

6. 部署的时候考虑性能和成本

模型训练的时候可以不计成本,但部署的时候一定要考虑性能和成本。推理速度、显存占用、QPS、成本,这些都是需要考虑的。要做模型压缩、量化、优化,让模型在满足效果的前提下,尽可能快、尽可能省。

六、写在最后

BERT是一个非常强大的预训练模型,它让NLP任务变得简单了很多。但BERT实战,不是简单地调个库就完事了,从数据预处理到模型微调,再到评估和部署,每一步都有很多细节需要注意,都有很多坑需要踩。

这次项目,让我对BERT有了更深入的理解,也积累了很多实战经验。虽然踩了不少坑,花了不少时间,但最终的结果还是好的,模型上线之后,分类准确率达到了90%以上,替代了大部分人工分类,大大提高了客服的效率。

希望我的这些经验和教训,能对正在做BERT相关项目的朋友有所帮助。如果有什么问题或者不同的看法,欢迎在评论区交流。