资讯详情

资讯详情

NLP新手入门:用BERT微调实现中文情感分类与文本分类

前阵子有位刚入门的朋友问我“做NLP到底从哪里开始网上教程一会儿讲词向量一会儿讲LSTM越看越乱。”我跟他说如果让我重新规划学习路径我建议直接把BERT当作第一站。BERTBidirectional Encoder Representations from Transformers虽然不是新出的模型但它恰恰是理解现代预训练语言模型的最佳切片。这个系列计划用几条完整的NLP用例带大家从原理到代码把BERT吃透。第一篇先做最常见的文本分类用Python配合深度学习框架训练一个中文情感分类模型。整个流程会覆盖环境搭建、数据准备、token化、模型微调和效果评估最终你会得到一个可以直接投入使用的分类器同时也会搞明白每一步为什么要这么做。1. 为什么我坚持让新手用BERT入门而不是先啃传统路线1.1 传统NLP方法的最大痛点一个词放之四海而皆准我先说说自己最初学NLP时踩过的弯路。最早做文本分类我用的词袋模型把句子拆成词汇统计词频再用TF-IDF给常见词降权、给稀有词提权。这样确实能把文本转成数字特征也能训练分类器但问题极其明显。举个例子我用银行卡转账和河水漫过堤岸这两句话里英文都是bank这个词。词袋模型和静态词向量给它的表示完全一样模型根本无法区分银行和河岸两种含义。中文也一样苹果手机和苹果很好吃里的苹果一个是品牌一个是水果。传统方法只能给苹果一个固定向量语义信息在进入模型前就丢了一半。更麻烦的是句子表示。当时常用的做法是把词向量平均成句子向量句子越长每个词的信息被稀释得越严重。我用这套思路做新闻分类折腾了很久准确率卡在88%怎么也上不去。问题的根源不是数据量而是表示方法的上限就在那里。1.2 预训练加微调解决的是“数据不够”和“语义动态变化”两个难题BERT带来的是范式级别的变化先用海量无标注语料学习语言本身得到一个对词语在不同上下文中的含义非常敏感的模型然后在下游任务上做轻量微调。这相当于模型在小学阶段读了海量书籍积累了强大的语感进入工作岗位后只需要稍稍培训就能上手。这个范式最直接的好处是下游任务不再需要百万级标注数据。做文本分类几千条甚至几百条质量不错的标注数据就能取得不错效果。因为通用语言知识已经在预训练阶段学过了微调阶段只需要把已经具备的理解能力适配到你的具体任务。我整理过传统方法和预训练模型的差异这样看更直观维度传统词向量方法BERT预训练加微调词表示一个词一个固定向量同一个词在不同上下文中有不同向量句子表示词向量简单相加或平均整个句子的上下文建模对标注数据需求通常需要大量数据少量数据即可微调训练成本每个任务从头训练预训练一次下游迁移可迁移性差换任务要重建强同一套模型换分类头即可1.3 这一篇系列打算覆盖什么这个系列我计划写好几篇第一篇聚焦文本分类这个最稳的切入点因为它的任务定义清晰、评估简单、数据容易找。跑通文本分类之后后面的文章会继续把同样的模型底座迁移到命名实体识别、文本相似度判断、句对关系分类等任务上。你会发现换任务时BERT主体几乎不动变的只是一层很小的任务头。所以第一篇的定位是地基把token化、模型加载、训练循环、评估指标这些核心路径走一遍。地基打牢了后面的每个用例都只是换数据、换任务头的问题。2. BERT的骨架拆解注意力机制、双向编码和两个预训练任务2.1 “双向”到底解决了什么问题在BERT之前很多语言模型都是从左到右单向预测的也就是看前文猜后文。这种设计适合生成任务但对于理解类任务有个天然缺陷理解一个词往往需要同时看它左边和右边的信息。我今天去银行办了一张信用卡这句话里判断银行不是河岸靠的是信用卡这个出现在右边的词。单向模型从左往右读读到银行时还没看到信用卡信息是不完整的。BERT的做法是用Transformer编码器架构让每个位置的表示都能同时看到整个句子。注意力机制相当于给每个词开了一个全局视野让它按重要程度聚合其他所有词的信息。这比传统的固定窗口方案灵活得多长距离依赖也能直接被建模到。2.2 两个预训练任务掩码语言模型和下句预测双向模型面临一个训练难题如果把语言模型设计成看全文猜某个词信息会从要预测的词本身泄漏。BERT用了掩码语言模型来绕开这个矛盾随机遮住句子中15%的token让模型根据上下文预测被遮住的词。这里有个重要的实现细节。15%的token中并不是全部被替换成[MASK]符号。实际做法是80%替换成[MASK]10%替换成随机词10%保持原样。这样设计是为了让模型在微调阶段不依赖[MASK]标记因为下游任务输入的文本通常没有[MASK]模型必须学会预测真实文本中的任何位置。第二个任务是下句预测给模型两个句子让它判断第二个句子是不是第一个句子的后续。这个任务看起来很简单但它逼迫模型学习句子之间的关系为后续处理文本对任务打下基础。2.3 输入编码的三段式结构BERT输入不是简单地把词表ID拼在一起它由三个向量相加组成token embedding、segment embedding、position embedding。token embedding负责表示词本身segment embedding负责区分两个句子position embedding负责记录位置信息。开头固定放一个[CLS]标记句子之间放[SEP]分隔符。文本分类任务会把[CLS]的输出向量作为整个句子的表示再接一层分类器。这也是BERT官方推荐的用法虽然技术上有争议但实践下来简单可靠。3. 练手项目选型为什么第一个任务选情感分类3.1 数据集和任务设计文本分类是所有NLP任务里最先成熟的一类。情感分类又是文本分类里最顺手的因为目标直观一条评论判断它是正面还是负面。数据不需要复杂标注二分类足够说明问题跑通后再换成多分类也只是小改动。数据集我用过几个不同来源效果都比较理想。如果你没有现成的数据可以从公开的中文评论语料里获取比如电商商品评论、影评、酒店评价等。首次建议用二分类数据集挑选标签质量高、类别相对均衡的样本。拿到数据后先做样本分析我通常看三件事总样本量、正负样本比例、文本长度分布。这三个指标决定后续的预处理策略和训练参数。我第一次做的时候没看长度分布默认把输入截断到128个token结果一些长评论的关键信息被切掉分类效果一直不稳。3.2 工具链和版本选型环境这块我踩过不少坑直接说结论。开发机装好Python 3.8以上的版本深度学习框架选择你熟悉的即可模型来自某个开源预训练模型库这些都能在PyPI上直接安装。硬件方面BERT-base规模的模型在CPU上也能训练但速度太慢。实测下来CPU训练一个小的二分类任务一轮要跑很久换成普通显卡后速度提升是几十倍的量级。所以有条件尽量用GPU没有的话可以先用小模型变体在CPU上验证流程。我给第一次做的朋友推荐的环境清单是这样的Python 3.8或3.10配好虚拟环境深度学习框架及其配套的计算包开源模型库及其分词器组件CUDA版本和框架版本要匹配否则会出现CUDA不可用的假象这里特别提醒先把CUDA和框架的兼容关系查清楚再安装。我见过某同学花了半天装环境最后发现是CUDA版本太新框架不支持。环境问题占掉了整个项目的一大半时间很不划算。4. 完整微调链路拆解从一行中文评论到分类结果4.1 数据清洗与划分数据清洗看起来琐碎但对中文评论影响很大。我一般用正则把连续空白合并去除非中文和数字之外的特殊符号。这里要注意不要把有用的语义符号全干掉比如网络用语里的数字、某些专业缩写保留它们反而对分类有帮助。import re import pandas as pd df pd.read_csv(review_data.csv, encodingutf-8) # 假设字段为 content, labellabel 0表示差评1表示好评 def clean_text(text): text re.sub(r\s, , text) # 连续空白合并成一个空格 text re.sub(r[^\u4e00-\u9fff0-9a-zA-Z], , text) # 标点替换成空格 return text.strip() df[clean_content] df[content].map(clean_text) df df[[clean_content, label]].dropna()清洗完之后按8:2划分训练集和验证集。划分时记得设置随机种子保证每次实验结果可复现。我建议不要用简单的前后切分而是用打乱后的随机切分避免数据在文件中按标签顺序排列造成偏差。4.2 Token化与输入构造token化是BERT使用中最重要的环节。中文BERT的词表是按字切分的因此不需要额外分词。直接用分词器把文本转成input_ids和attention_mask再统一padding到固定长度。长度选择上128是性价比很高的默认值。绝大多数短文本评论在30到80个字之间128已经足够。max_len设得太长训练速度和显存占用都会显著上升但效果提升有限。如果是长文本任务再考虑256或512。from model_lib import load_tokenizer tokenizer load_tokenizer(chinese_bert_base) MAX_LEN 128 def encode_text(text): encoded tokenizer( text, max_lengthMAX_LEN, paddingmax_length, truncationTrue, return_tensorspt ) return encoded如果你自己写数据加载器需要实现一个数据集类把清洗后的文本和标签包装成统一的tensor格式。我第一次写的时候容易漏掉attention_mask结果模型把padding部分也当作真实内容去计算注意力效果莫名其妙变差。from torch.utils.data import Dataset import torch class ReviewDataset(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 str(self.texts[idx]) encoded self.tokenizer( text, max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoded[input_ids].squeeze(0), attention_mask: encoded[attention_mask].squeeze(0), labels: torch.tensor(self.labels[idx], dtypetorch.long) }4.3 模型加载与训练参数分类任务只需要在预训练模型上加一个分类头。模型库通常提供了封装好的分类版本加载时指定标签数量即可。没有封装好的话手动做法是取出BERT的[CLS]输出接一个全连接层映射到2个类别。训练参数我第一次是照搬别人的后来逐个调整过最终这套参数在中小规模数据集上表现稳定训练轮数3轮batch_size16学习率2e-5warmup比例0.1weight_decay0.01优化器AdamW很多人对2e-5这个学习率有疑惑。BERT已经是预训练好的模型微调阶段参数只需要轻微调整学习率过大容易把学到的语言知识破坏掉。4.4 训练循环与评估指标训练循环本身并不复杂核心是前向传播算损失反向传播求梯度优化器更新参数调度器调整学习率。每轮结束用验证集做一次评估记录准确率和F1方便判断模型是否过拟合。def train_one_epoch(model, train_loader, optimizer, scheduler): model.train() total_loss 0 for batch in train_loader: input_ids batch[input_ids] attention_mask batch[attention_mask] labels batch[labels] outputs model( input_idsinput_ids, attention_maskattention_mask, labelslabels ) loss outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() return total_loss / len(train_loader)初学阶段我只看准确率后来发现不够。情感分类如果正负样本不平衡准确率会失真。比如95%样本是好评模型全预测好评也有95%准确率看起来很高实际完全没有区分能力。建议同时看精确率、召回率和F1F1是两者平衡后的综合指标。4.5 用交叉验证验证稳定性单次划分训练集和验证集结果容易受随机性影响。我后来习惯在最终实验中加入五折交叉验证把数据分成五份轮流拿四份训练、一份验证最终取五次结果的平均值。这个方法多花一点时间但能显著提升结论的可信度。两次运行的准确率差2个百分点到底是模型进步还是数据划分的随机波动五折交叉验证能给出更准确的判断。对入门项目来说这一步能培养很好的实验习惯。5. 实测中容易翻车的五个地方5.1 max_len设置不当长文本信息被截断我最初用128的max_len处理所有的文本不管样本实际长度。验证的时候发现有些长评论语义核心出现在后半段被强制截断之后模型判断完全偏离。解决办法是训练前统计文本长度分布。如果90%的样本都集中在100个token以内128就够如果大量样本超过180就要把max_len调到256。不要盲目加大因为BERT的注意力计算量随长度平方增长长度翻倍显存占用和训练时间远不止翻倍。5.2 学习率过山车模型直接训飞把学习率从2e-5调到1e-4验证损失直接飙升训练过程像坐过山车一样上下震荡调到1e-3之后几分钟内loss变成nan。这就是典型的大学习率破坏预训练权重。小学习率是BERT微调的底线原则。如果你发现loss下降很慢优先怀疑是不是batch_size太小或者数据量太少而不是急着把学习率调大。实在要调用1e-5到5e-5这个区间内小心试探。5.3 类别不平衡准确率很高但F1很难看有一次我拿到的数据里只有约10%的差评模型训练出来的准确率高达93%但差评的召回率只有15%。也就是说几乎把所有差评都误判成了好评。遇到这种情况先看类别分布再用分层抽样保证训练集和验证集的类别比例一致。如果极端不平衡可以考虑用加权交叉熵损失给少数类更大的权重。分类任务里单看准确率是远远不够的。5.4 显存不足batch_size和梯度累积batch_size32在贵的显卡上直接显存溢出报错信息会提示你CUDA out of memory。常规解法是把batch_size降到8同时用梯度累积模拟更大的batch。梯度累积的思路是反向传播时不立即更新参数累积几步之后再统一更新。实际效果接近大batch训练但显存占用保持在低水平。另外记得把不需要的中间变量及时释放或者启用梯度检查点这些技巧能在显存和性能之间取得平衡。5.5 数据噪声比想象中更影响效果中文评论文本里充满表情符号、重复标点、错别字和广告信息。清洗不干净模型会学到噪声模式。有一次我把很好清理成很好反而发现模型更稳定了。更隐蔽的是标签噪声数据集里存在少量错标的样本。手动清洗成本和收益不成比例更实际的做法是先用一版模型预测人工检查预测错误且置信度很高的样本这些大概率是标签标错的候选。反复迭代两三轮数据质量会有明显提升。6. 第一篇跑通之后这些能力如何迁移6.1 换任务其实就是换任务头跑通情感分类后你会意识到整个流程里真正跟任务相关的只有两部分数据集的标签定义和模型最后一层的分类头。BERT本体、token化、训练循环、评估脚本几乎原封不动。比如做命名实体识别只需要把输出层从整句分类改成每个token打标签输入仍然是token序列训练逻辑仍然是一样的。做文本相似度判断输入从单句变成句对用[SEP]连接输出层改成二分类判断是否相似。这种迁移能力是预训练范式最大的红利。6.2 沉淀一套自己的训练模板我强烈建议把第一篇的代码整理成一个通用模板包含数据加载、token化、训练循环、评估指标四块。后续每次换数据只需要修改数据读取部分。我自己现在做文本类任务时习惯先把数据量和标签分布打印出来再跑一个极小的基线比如只用200条数据训练一轮确认流程能通再放开到全量数据。这个方法能快速排除代码层面的低级错误省去反复试错的烦恼。6.3 后续系列的规划这一篇只是起点。我后续会写BERT在更多NLP用例中的实战如何做多标签分类、如何处理长文档、如何结合领域数据进行继续预训练以及模型部署时常见的坑。每次写这类记录我都会把当时的报错信息、排查思路和最终结论一起保留下来。技术文章最好的价值不是只告诉别人这样能成功而是让人知道哪些路径是死胡同、为什么是死胡同。如果你也正在用BERT做第一个NLP任务按照这个链路跑通一遍再回到这篇记录对照一下细节应该能帮你避开大部分新手期的坑。祝训练过程顺利跑出自己满意的效果。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →