首页 / 资讯中心 / 文章详情

Transformers库实战指南:从文本分类微调到部署优化全流程

Transformers库实战指南:从文本分类微调到部署优化全流程 ★ FEATURED ARTICLE
说实话我刚开始做 NLP 的时候是没有 Transformers 库可用的。那时候想做一个文本分类得先分词、去停用词、训词向量再塞进 LSTM 或者 CNN前前后后光数据预处理就是几百行代码模型效果还不一定好。等到后来接触到 Python 里的 Transformers 库我第一次感受到“原来做 NLP 可以像调接口一样简单”加载一个预训练权重写三行代码一个分类模型就能跑起来。这篇文章我想从一个实际使用者的角度把这个库的核心组件、微调流程、训练踩坑和部署优化都串起来讲一遍适合那些已经跑通 demo、但还没真正做过完整项目的开发者。1. 为什么说这个库解决的不只是“少写代码”很多人第一次看到 Transformers 库的快速上手示例第一反应是“这不就是把模型封装好了吗”。但用久了你会发现它带来的价值远不止省掉几行模板代码而是从根上改变了 NLP 项目的开发方式。1.1 从手写词向量和 LSTM 说起在我刚入行那会儿一个标准的文本分类项目长这样准备语料做清洗、分词、去停用词统计词频构建词表把句子转成索引序列用 gensim 或者随机初始化训一个词向量矩阵把序列送进 LSTM/GRU再接全连接层输出分类训练的时候还要处理变长序列的 padding 和 mask自己写 collate 函数。这套流程不仅步骤多而且每一步都有很多隐性坑分词器选得不好词表噪声大语料少的时候词向量训不起来LSTM 的梯度传播长度有限长文本根本记不住前后文。哪怕是一个在当时已经算“成熟”的模型换个领域、换批数据效果就可能崩掉。Transformers 库出现以后最直接的改变是省掉了“从零搭骨架”这个过程。预训练模型把通用语言知识学好了你不需要再自己训词向量也不需要自己设计编码器结构只需要在预训练权重后面接一个任务头做一轮微调。这种变化很像以前你烧饭得自己磨面粉、发面团现在你买一包预拌粉加水就能做成型最后只需要根据自己的口味调一下火候。1.2 统一模型接口才是它最值钱的设计如果只是把 BERT 封装一下那充其量是个好用的轮子。Transformers 库真正厉害的地方是定义了一套统一的模型接口不管你是 BERT、RoBERTa还是 GPT、T5几乎都用同样一套加载方式、同样一套参数接口。用AutoModel加载一个模型再用AutoTokenizer加载对应的分词器后面所有代码逻辑几乎不用改。想做文本分类就换成AutoModelForSequenceClassification想做问答就换AutoModelForQuestionAnswering。这种设计带来的好处特别实际项目初期做技术选型的时候你可以把几个不同架构的模型都跑一遍对比效果之后再定哪个上线不需要为每个模型单独写一套推理代码。这个思路其实很像统一标准化的电源插座你能买到的电器不管来自哪里只要插头标准是一致的插上去就能用。模型架构五花八门但对外暴露的接口一致下游工程就不用跟着模型换来换去。2. 先把三个核心组件的关系理清楚用这个库做项目你天天打交道的主要就是三样东西Pipeline、Tokenizer 和 Model。很多新手出问题都是因为没搞清楚这三者的边界和各自职责。2.1 Pipeline几行代码完成一次推理如果你只是想快速验证一个场景Pipeline 是最省事的入口。比如情感分析两行代码就能出结果from transformers import pipeline classifier pipeline(sentiment-analysis, modelyour_sentiment_checkpoint) result classifier(这个功能太好用了) print(result)Pipeline 的好处是它把一整条链路封装好了文本进来它会自动调用对应的 tokenizer 做切分和编码然后送进模型推理最后把 logits 转成人类可读的标签和置信度。不需要你手工处理input_ids、attention_mask也不用管输出张量的维度。但注意Pipeline 适合做验证和演示不代表它是生产环境的最优选择。生产服务往往需要精细控制 batch、超时、异常处理而且 Pipeline 内部做一些后处理比如把 logits 转概率不完全透明排查问题的时候反而多一层黑盒。我自己的习惯是项目前期用 Pipeline 快速验证思路真正写服务接口的时候会拆开用底层 API这样每一步都可控。2.2 Tokenizer模型输入的第一道关口Tokenizer 是整个流水线里最容易出问题、但又最不被重视的一环。它的职责是把原始文本转换成模型可接受的数值张量包括input_ids、attention_mask有的模型还有token_type_ids。这里的细节特别多。先明确一点tokenizer 必须跟模型配套使用。你加载了一个模型 checkpoint就必须用同一个 checkpoint 对应的 tokenizer 或者同类型同词表的 tokenizer。如果你拿模型的词表去处理另一种语言或者拿一个大词表的 tokenizer 配上小词表模型运行时会直接报索引越界或者模型读到大量[UNK]。再看两个最常用的参数padding和truncation。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(your_chinese_text_model) encoded tokenizer( [第一句话, 第二句话稍微长一些], paddingmax_length, truncationTrue, max_length128, return_tensorspt, )paddingTrue是让 batch 里的所有样本补到一样长方便矩阵运算。truncationTrue是让超过最大长度的文本被截断。这两个参数看着简单但选择哪种策略直接影响模型效果如果截断得太狠文本后半段的关键信息就丢了如果 padding 得太多算力和显存都浪费在无意义的[PAD]token 上。实际操作中我会先统计一下数据集的长度分布选一个能覆盖 90% 以上样本的长度作为max_length而不是拍脑袋定 512。2.3 Model 输出对象里到底藏了哪些东西很多初学者拿到模型的输出习惯性当成一个张量去处理打印出来发现是个对象就懵了。这个对象里其实包含了好几个字段不同字段服务于不同的用途字段含义常用场景last_hidden_state最后一层所有 token 的隐层向量形状[batch, seq_len, hidden]接自定义下游任务、做序列标注pooler_output经过池化后的句子向量形状[batch, hidden]文本分类、相似度计算logits任务头的原始输出分数分类、回归的最终输出hidden_states所有层的隐层状态集合研究模型内部表征、做特征融合attentions各层的注意力权重可视化分析、剪枝研究关键是要知道你需要的是哪个。做文本分类时如果用的是AutoModelForSequenceClassification直接拿outputs.logits就行但如果你是自己接任务头、想拿一个句向量做 embedding 检索就要用last_hidden_state或者pooler_output具体取哪个还得看你用什么池化策略。别一股脑把整个 outputs 对象塞进全连接层很容易形状对不上。3. 一个完整的中文分类项目复盘前面讲了一堆概念下面用一个具体的中文文本分类项目走一遍完整流程。我尽量按项目推进顺序写包括那些“文档里不会告诉你”的细节。3.1 数据清洗与标签设计很多人一上来就调模型其实数据准备才是微调效果的上限决定因素。这个项目里我拿到的原始数据是一批用户反馈工单需要判断类别比如“功能问题”“体验问题”“咨询问题”。原始数据的格式特别乱有的带 HTML 标签、有的带多余空格、有的全是英文符号。我的处理顺序是这样统一转成纯文本去掉特殊符号和 emoji去掉过长或过短的样本避免异常样本干扰把标签转成从 0 开始的整数索引同时保留一份 id 到标签名的映射字典。这里有个容易忽略的点标签映射一旦定下来训练和预测必须用同一份映射。我见过好几次线上预测报错最后发现是训练代码里label2id和推理代码里id2label顺序对不上。建议把映射文件单独保存为 json训练和推理都从同一个文件读取而不是在代码里写死。另外如果类别分布很不均衡先别急着用各种高级技巧。最简单有效的方式是先看数据量再看看训练集里每个类别的样本数必要时先做一轮下采样或上采样。预处理阶段的修正比模型训练阶段调class_weight要直观得多。3.2 微调训练的最小代码骨架数据准备好之后微调本身的代码其实非常套路化。用 Trainer 的话核心代码就是构造数据集、配置训练参数、定义评估函数然后调用train()。from datasets import Dataset from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer, ) model_name your_chinese_pretrained_checkpoint tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained( model_name, num_labelslen(label2id) ) def tokenize_fn(batch): return tokenizer( batch[text], paddingmax_length, truncationTrue, max_length256, ) train_ds Dataset.from_pandas(train_df).map(tokenize_fn, batchedTrue) eval_ds Dataset.from_pandas(eval_df).map(tokenize_fn, batchedTrue) training_args TrainingArguments( output_dir./checkpoints, num_train_epochs3, per_device_train_batch_size16, per_device_eval_batch_size32, learning_rate2e-5, warmup_ratio0.1, evaluation_strategyepoch, save_strategyepoch, logging_dir./logs, fp16True, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_ds, eval_dataseteval_ds, tokenizertokenizer, ) trainer.train()这段代码里per_device_train_batch_size和fp16是最容易影响训练能否跑起来的两个配置。小显存卡上 batch size 调太大直接 OOM这时候不要硬调要么减小 batch size要么开fp16半精度训练。fp16True对显存的节省非常明显速度也能快不少但如果你用的是 CPU 或者某些不支持半精度的老卡开了反而会报错。Trainer 的另一个好处是它默认帮你处理了梯度累积和 early stopping 之外的大部分琐事比如日志、checkpoint 保存、评估调度。对于很多业务项目来说这套默认流程已经完全够用。3.3 评估阶段要看什么指标微调完成后不能只看一个准确率就完了。分类任务里我至少会看三样东西整体准确率、每个类别的 precision/recall/F1以及错误样本的实际内容。对于不均衡的数据集宏平均 F1 比准确率更能反映模型真实水平。假如 90% 的样本都是“咨询问题”模型全部预测成这个类别也能有 90% 准确率但其他类别几乎全错业务根本没法用。这时候就应该以各类别 F1 的平均值作为主要优化目标。错误样本分析更重要。我训练完第一版模型之后把验证集里预测错的样本逐条看了一遍发现两个典型问题一是有些样本同时包含“功能问题”和“体验问题”两种表述单标签任务必然牺牲一部分信息二是短文本样本信息量太少模型容易猜错。看完之后我才意识到单纯优化训练参数是解决不了这些问题的需要回头调整数据标注方案或者任务定义这可能才是项目提升空间最大的地方。4. 训练时最容易翻车的四个细节跑过完整微调流程之后再回头看训练阶段翻车通常不是因为模型结构复杂而是败在一些非常不起眼的细节上。下面说的这几个问题都是我实际遇到过、或者看别人遇到过很多次的。4.1 标签映射和 num_labels 对不上AutoModelForSequenceClassification.from_pretrained的时候num_labels必须等于标签种类数。如果标签从 1 开始而不是从 0 开始训练时计算 loss 会直接报错或者得到错误的梯度。比如你有 3 个类别标签分别是 0、1、2num_labels3没问题但如果你不小心把标签设成 1、2、3模型会当作 4 个类别的问题去算loss 的形状就乱了。有个更隐蔽的坑如果用了多标签分类一个样本可能同时属于多个类别模型结构要换成problem_typemulti_label_classificationloss 函数也会从交叉熵变成 BCE。这个切换不是简单改个参数还要确认标签矩阵的形状是[batch, num_labels]每个位置表示“是否属于这个类别”。单标签和多标签的处理方式完全不同项目初期先想清楚。4.2 attention_mask 与 padding 的隐性耦合模型训练时为什么需要attention_mask因为 padding 出来的[PAD]token 是无效信息注意力机制不应该去关注它们。如果你手动构造输入忘记传attention_mask或者 mask 的值设置反了模型就会把 padding 位置也当成正常语义参与计算效果会莫名其妙变差。这个问题在做自定义模型头的时候尤其明显。如果你只是用 Trainertokenizer 返回的attention_mask会被自动使用但如果你想自己写训练循环把input_ids和attention_mask一起传给模型是必须的outputs model( input_idsbatch[input_ids], attention_maskbatch[attention_mask], labelsbatch[labels], )我还见过一种错误写法直接用 tokenizer 返回的input_ids里没有 padding 的样本拼成一个 batch然后手工用 0 补齐。这样做出来的张量里的 0 恰好对应[PAD]的 id看起来没问题但如果你的 tokenizer 不用 0 作为 pad token id那 0 可能是一个真实词模型就会读到完全错误的信息。所以不要自己去拼老老实实让 tokenizer 统一处理。4.3 学习率和 warmup 的取值经验预训练模型的微调学习率和从头训练一个模型差别很大。图像领域常见的 0.01、0.001 这些值用到预训练语言模型上通常太大。实际操作里BERT 这类模型常用2e-5到5e-5之间再大就很容易把模型学到的通用知识冲掉导致训练集 loss 下降很快但验证集效果反而变差。warmup_ratio也很重要。刚开训时模型参数离目标分布还很远直接用比较大的学习率容易震荡。warmup 会把学习率先从 0 或很小的值逐步升到设定值前几个 step 相当于热身。一般warmup_ratio取 0.1 左右也就是前 10% 的训练步数做学习率上升。如果你发现 loss 前几千步一直在乱跳先检查是不是 warmup 步数不够而不是急着改学习率本身。有一个实用技巧第一次跑任务时先用小规模子集跑两个 epoch把学习率设成1e-5、2e-5、5e-5各试一遍看训练集 loss 能不能下降到底。这个阶段不追求验证集效果只是确认训练链路没问题、梯度方向正确再上全量数据。4.4 显存不够时的处理顺序微调最常见的硬性问题就是 OOM。很多人第一步就想着换更大的卡其实有更经济的顺序可以试调小batch_size这是最直接的打开gradient_accumulation_steps用小 batch 累积若干步后再更新一次参数效果上等价于用更大的 batch打开fp16混合精度显存占用能减少接近一半打开gradient_checkpointing用计算换显存速度会变慢但是显存占用大幅下降最后才考虑换小模型或者做序列长度裁剪。gradient_accumulation_steps有个注意点它累积的是梯度不是样本。如果你的总 batch size 是 64实际每步只有 16那gradient_accumulation_steps4的话每 4 个 step 才更新一次参数相当于用 64 的等效 batch 在训。但学习率要不要随 batch size 调整取决于你的任务和数据我一般保持原配置观察训练集 loss 是否稳定下降再决定微调。5. 部署阶段的推理提速与稳定性模型训练出来只是第一步部署到线上、稳定高效地跑推理才是真正考验工程能力的部分。微调完的 PyTorch 模型直接拿来上线也能跑但往往有更好的选择。5.1 用 ONNX 导出替换 PyTorch 推理如果模型只是要跑推理、不继续训练把模型导出成 ONNX 格式再部署通常能获得比直接跑 PyTorch 更低的延迟和更可控的资源占用。导出时有个关键点要让 batch 和序列长度两个维度都是动态的而不是固定在某个具体数值上。否则你上线以后想改 batch size 或处理不同长度的文本就得重新导出一次模型。import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer model AutoModelForSequenceClassification.from_pretrained(your_checkpoint) model.eval() dummy_input tokenizer(测试文本, return_tensorspt) torch.onnx.export( model, tuple(dummy_input.values()), model.onnx, input_names[input_ids, attention_mask], output_names[logits], dynamic_axes{ input_ids: {0: batch, 1: seq_len}, attention_mask: {0: batch, 1: seq_len}, logits: {0: batch}, }, opset_version11, )导出之后用 onnxruntime 加载推理速度一般会有提升。如果在支持 CUDA 的环境部署可以指定providers[CUDAExecutionProvider, CPUExecutionProvider]让它在 GPU 可用时自动用 GPU否则回退到 CPU。5.2 服务化场景的动态分批与截断线上服务一个常见的需求是多个请求同时进来不能一个一个推得凑成 batch 提高吞吐量。但请求到达时间不一样长度也不一样强行凑 batch 就要做 padding。这时候max_length的设计就特别重要。我推荐一个稳妥策略线上设置一个队列攒够了一定数量请求再统一推理同时限制每个 batch 的 padding 到该批内最长样本即可而不是固定到模型最大长度。这样短文本多的时候batch 里的计算量会小很多显存和延迟都更友好。长文本处理也要提前想好。如果业务文本经常超过模型的最大长度简单粗暴地截断可能丢失关键信息。常见的做法是分块把长文本切成若干个不超过max_length的片段分别推理最后做结果聚合比如分类取置信度最高的片段、问答取所有片段里得分最高的答案。这个方案不完美但实现成本低适合大部分业务场景。5.3 小模型优先能量化先量化部署时我踩过的最大教训是一上来就选最大的模型结果内存装不下、延迟扛不住、GPU 费用高得离谱。业务效果提升不明显的模型再大也是浪费。合理顺序是先用一个小规模的 base 版模型跑出 baseline记录准确率和延迟如果效果不够再换大一号的模型对比。很多业务场景里base 版和 large 版的效果差距并没有想象中那么大尤其当数据量不大、任务本身不复杂的时候。模型再小一点的话可以尝试蒸馏或者量化。蒸馏需要重新训练一个轻量模型去拟合大模型的输出成本高一些量化则简单很多ONNX Runtime 提供了动态量化和静态量化可以在不改变模型结构的前提下把权重压缩到 int8。量化后模型的推理速度和内存占用都会有明显改善代价是精度可能会有小幅下降具体降多少要在自己的验证集上测了才算数。6. 几次踩坑后留下的个人习惯做完几个项目之后再回头看很多问题其实都有共性。下面这些习惯是我现在做任何 Transformers 相关项目都会坚持的不一定适合所有人但值得参考。6.1 训练前先锁定版本Transformers 库迭代速度非常快接口变化也频繁。同一个项目今天用 4.30 的版本能跑通明天升到另一个版本某些模型名称或者参数可能就变了。最稳妥的办法是把关键依赖版本写进环境配置文件锁死一整套版本组合。训练出的 checkpoint 能不能在另一台机器上正确加载往往不是模型文件的问题而是 transformers 版本不一致导致的。还有一个细节tokenizer 文件比如tokenizer_config.json、词表文件要和模型权重一起保存。只保存模型权重、忘保存 tokenizer换环境之后还得重新下载或者找配置特别容易踩坑。保存输出目录的时候整个目录一起存档不要图省事只拉模型文件。6.2 用最小子集做“冒烟测试”大模型训练时间长如果代码里有 bug跑了一两个小时才发现非常浪费。我现在每次改动代码之后都会先拿一个只有几十条样本的小数据集跑一遍完整流程确认训练、评估、保存、加载这四条链路都没问题再上全量数据。这个冒烟测试非常值得做因为很多错误比如张量形状不匹配、标签越界、batch_size导致最后一个 batch 样本数太少都能在小规模数据上快速暴露。6.3 记录一次完整训练周期的日志光看训练 loss 曲线很多问题是发现不了的。我习惯用单独的文本来记录每次实验的关键信息数据集版本、标签映射、训练超参数、验证集指标、模型输出路径、还有当时的备注比如“学习率降到 1e-5 后验证集 F1 反而掉了”。这样下次想调参的时候能直接照着之前的记录做对比而不是凭记忆乱猜。最后还有一个小技巧训练完成之后不要急着删除每一个 checkpoint。保留最后一次完整训练的最优 checkpoint和第一次训练的 baseline checkpoint这两个文件在后续排查线上问题时非常有用。能对比基线你才知道后面那些优化操作到底带来了多少提升。我现在做新的 NLP 任务基本都会先花十几分钟用 Transformers 库跑通一个最小闭环然后才放心往里填东西。这个库虽然简单但水确实很深多踩几次坑多留几份日志后面就能少走一些弯路。
阅读完成 · 觉得有帮助?
咨询建站