资讯动态

从TF-IDF到BERT:文本分类实战迁移与微调全记录

发布时间:2026/10/21 11:57:49 来源:尧图企业网站定制
这一篇Python实战日志第21篇来填一个拖了很久的坑把我手头一个内部工具项目里的文本分类模块从TF-IDF加规则匹配的老方案彻底迁移到BERT预训练模型上。这篇日志不会只贴代码跑个流程而是把从选型、环境准备、数据预处理、微调、部署到回头补课理解的完整过程都写清楚包括我实际踩过的坑和反复确认过的细节。如果你正准备用BERT做文本分类、意图识别、语义相似度这类任务或者已经在用但想知道背后那些参数到底在干嘛这篇应该对你有用。先说背景。我一直在维护一个工单自动分派工具输入是用户反馈文本输出是“物流咨询”“退换货”“支付问题”这五类标签。旧方案上线半年测试集准确率稳定在78%左右听着还行但一放到线上就露馅同一种意思换个说法就分错“我的货怎么还没到”和“快递卡在路上了”明明是一个意思模型硬是给分到两个类别里。原因很清楚TF-IDF看的是字面重复度语义稍微绕一下就不认了。这个项目正好成了我练手预训练模型的试验场。我花了一个周末把BERT跑通并上线替换准确率提升到91%左右更重要的是那些“换说法不分家”的case终于被兜住了。这5天里的调参、踩坑、看日志、再补基础理论的过程我尽量原原本本写下来。1. 为什么这个项目最终选了BERT当基线1.1 旧的文本表示方式撑不住了在我换模型之前这套工单分派工具用的是最经典的“句子向量化分类器”组合先对所有语料做TF-IDF统计得到每个词的权重然后把一条文本表示成一个几千维的稀疏向量最后丢给线性分类器。它的优点是训练快、部署轻一个CPU进程就能扛住线上压力。缺点是语义天花板太低TF-IDF本质上只统计“这个词在文档里出现得重不重要”完全没有词序、语法和上下文信息。举个具体例子。“这家奶茶店的服务态度是真的好”和“好是真的这家奶茶店服务态度”这两句话TF-IDF算出来的向量几乎一样但人类读起来完全不是一件事。再比如“我没想到最终结果会这么差”“差”是负面的但模型如果只盯着“没想到”“好”这类词很容易误判成正面。后者这类带转折、带反讽的表达在客服语料里大量存在老方案基本无解。我当时也考虑过Word2vec方案先用大规模语料训练词向量然后把句子里的词向量取平均或加权平均作为句子向量喂给分类器。这能解决一部分“同义词”问题但平均池化把词序彻底抹掉了动词宾语关系、否定副词修饰范围全丢了。LSTM加注意力虽然能用但我没有足够的标注数据去训练一个足够深的循环网络效果也不会比BERT好到哪去。所以当项目数据量涨到2万条时我决定直接用预训练模型不再自己从零学语言知识。1.2 “预训练微调”到底省了多少事Transformer类模型最大的优势是把“学语言”和“做任务”拆成了两步。预训练阶段在海量通用文本上学习语法、语义、常识这个阶段通常要耗费几千张显卡和几周时间普通开发者根本没有这个资源微调阶段把已经学会语言的模型拿出来在你自己几千条标注数据上做小幅度调整只需要一张普通显卡甚至CPU就能跑。我自己的体会是这就像雇了一个读过千万本书的实习生你只需要花几天教他你们公司客服话术里的特殊口径他就能上岗干活了。相比之下老方案是从零开始教一个孩子认字还得同时教他处理客服业务两边都学不好。这也是“预训练模型”这四个字里最值钱的部分语言通用能力已经被前人花钱花时间替你学完了你站在肩膀上解决问题即可。1.3 选型对比表为什么先是BERT而不是其他这里我列一下自己当时比较过的模型给到正在选型的同学参考。注意我讨论的是“中文文本分类”这个具体场景换场景结论会变。模型上下文建模方式适合场景资源消耗我的结论静态词向量 平均池化无上下文词义固定短文本、对精度要求不高极低语义能力太弱放弃LSTM Attention自左向右长距离依赖弱序列标注、小规模中等标注数据不够训练不稳BERT双向Transformer全词注意力文本分类、匹配、抽取较高但可控基线首选生成式大模型差不多规模的自回归单向文本生成、对话更高分类任务杀鸡用牛刀我用的是一个中文预训练模型的base版12层Transformer隐层768维参数量1亿多。这个体量在今天看来不算大一张几年前的入门级显卡就能微调推理时用CPU跑单条文本也在可接受范围内。选它而不是更大的模型是因为任务本身没那么难太大反而容易过拟合延迟也上不去。2. 初始化环境与离线模型加载这两处最容易翻车2.1 依赖装哪些、版本怎么锁正式动手前先记录一下我用的环境Python 3.10PyTorch 2.1transformers 4.36datasets 2.16accelerate 0.26。为什么特意把版本写出来因为我这次就吃过版本太新的亏。transformers每隔几周就会发一版API偶尔有破坏性变化比如训练参数里的优化器名称在不同版本里写法不一样网上很多人分享的代码是基于老版本的直接复制过来会报错。我建议做项目时不要追求最新锁定一个大版本内的稳定版就够用。安装命令很简单pip install torch2.0 transformers4.36 datasets2.14 accelerate0.26如果只是推理不训练可以只装transformers和torch。另外要说明一点transformers底层会用到tokenizers这是个独立的库安装的时候会被自动带上尽量不要手动改它的版本否则容易出现“tokenizer和model版本不匹配”的诡异报错。2.2 一个预训练模型目录里到底放了什么很多第一次接触BERT的人会以为下载模型就是下载一个几百MB的bin文件跑通之后才发现少了文件加载失败。我来拆一下目录结构你以后排查问题就有方向了。一个完整的模型目录通常包含config.json模型配置层数、隐层维度、注意力头数、词表大小都在里面model.safetensors或pytorch_model.bin权重文件通常几百MBvocab.txt词表每个token对应一个idtokenizer_config.json、special_tokens_map.json分词器配置和特殊符号映射tokenizer.json可选新版分词器会用这个文件加快加载。如果你用AutoModel.from_pretrained(某个模型名)库会自动从缓存里找这些文件。但实际项目里我强烈建议把模型先下载到项目目录然后指向本地路径加载。好处是版本可控、部署可以离线、中间不会因为网络问题重新下载。2.3 下载失败和加载报错的三个常见处理我在一台新服务器上部署时遇到过三类问题一并写出来第一是网络原因导致下载中断。解决方法是手动把模型文件完整下载后解压到项目目录再设置环境变量或直接用本地路径完全不依赖在线下载。设置本地路径方式如下from transformers import AutoTokenizer, AutoModelForSequenceClassification model_path ./models/bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_path) model AutoModelForSequenceClassification.from_pretrained(model_path, num_labels5)第二是磁盘空间不足。有些模型权重文件达到1GB以上下载前用df -h检查一下缓存目录所在分区。如果缓存目录在系统盘而项目数据在数据盘可以设置HF_HOME环境变量把缓存路径指过去。第三是加载时提示缺少tokenizer.json通常是因为模型发布了新格式但旧缓存里没有。解决办法是删除旧的本地模型目录重新完整下载一份确认tokenizer.json存在后再加载。3. Tokenizer不是简单的切词搞清楚这几个数组才能不挨坑3.1 从一行字符串到input_ids的全流程BERT的输入不是直接给模型一长串文字而是要先经过分词器变成数字。这个过程有三个关键步骤切分、映射、补全。我用一句话示例拆开看你马上就能明白它和“按空格切词”的本质区别。sentence 这家店的奶茶真的很好喝 tokens tokenizer.tokenize(sentence) print(tokens) # 输出[这, 家, 店, 的, 奶, 茶, 真, 的, 很, 好, 喝] input_ids tokenizer.convert_tokens_to_ids(tokens) print(input_ids) # 输出[683, 702, 701, 738, 742, 719, 767, 738, 749, 726, 735]中文BERT的词表基本按字切分所以每个汉字对应一个独立的token id。英文模型则会把不常见的单词切成subword比如“unhappiness”可能被切成[un, happiness]这样既控制了词表大小又能处理没见过的词。这是Word2vec做不到的也是预训练模型能应对开放词汇的重要原因。真正送入模型之前还要在最前面加上[CLS]、在句尾加上[SEP]两个特殊token。[CLS]对应的输出向量会被当作整句话的表征用于分类[SEP]用来分隔句子在做句子对任务时非常重要。3.2 三个关键数组input_ids、token_type_ids、attention_mask用分词器处理句子时它返回的是一个字典里面包含三个数组。新手最容易忽视它们各自的含义导致微调或推理时结果莫名其妙。input_ids是上面说的token id序列。attention_mask是长度和input_ids相同的01序列1表示真实token需要模型关注0表示padding填充位置不需要关注。第三个是token_type_ids单句分类任务里通常全是0句子对任务里第一句为0、第二句为1用来告诉模型哪部分属于哪句话。你可以试着打印一个padding后的结果一眼就明白了inputs tokenizer(sentence, paddingmax_length, max_length16, truncationTrue) print(inputs[input_ids]) print(inputs[attention_mask]) print(inputs[token_type_ids])attention_mask里那些0的位置在进入Transformer后会把对应位置的注意力计算结果变成负无穷这样softmax之后权重趋近于0padding就不会干扰真实token的语义表达。3.3 静态填充还是动态填充别凭感觉选做训练时一个batch内的句子长度不一样必须把它们填充到一样长才能组成矩阵。常见做法有两种固定长度填充和动态填充。固定长度是提前设max_length128所有句子无论多短都强行pad到128动态填充是每个batch内部只pad到本batch最长句子的长度。我一开始图省事用了固定填充后来发现GPU显存白白浪费了一半因为我的工单文本平均长度只有三四十个字全部pad到128模型在padding位置上的计算完全是空转。改成动态填充之后一方面显存占用下降不少另一方面训练速度也有提升。动态填充用官方提供的DataCollatorWithPadding就能实现from transformers import DataCollatorWithPadding data_collator DataCollatorWithPadding(tokenizertokenizer, paddinglongest)需要留个心眼的是如果你的数据里有特别长的文本动态填充会突然把一个batch撑得很大导致OOM。稳妥做法是同时设置truncationTrue和合理的max_length保住动态填充收益的同时防止极端情况。4. 微调流程从“能跑”到“跑好”的实操记录4.1 先跑通用Trainer完成意图识别微调这部分是我的核心代码。我给五类工单构建了一个文本分类模型加载预训练权重后在2万条标注数据上做微调。import torch from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer, DataCollatorWithPadding ) from datasets import Dataset model_path ./models/bert-base-chinese labels [物流咨询, 退换货, 支付问题, 商品咨询, 售后投诉] id2label {i: label for i, label in enumerate(labels)} label2id {label: i for i, label in enumerate(labels)} tokenizer AutoTokenizer.from_pretrained(model_path) model AutoModelForSequenceClassification.from_pretrained( model_path, num_labelslen(labels), id2labelid2label, label2idlabel2id ) def preprocess(example): return tokenizer( example[text], truncationTrue, max_length128, ) train_dataset Dataset.from_list(train_data).map(preprocess) eval_dataset Dataset.from_list(eval_data).map(preprocess) data_collator DataCollatorWithPadding(tokenizertokenizer) training_args TrainingArguments( output_dir./checkpoints, learning_rate2e-5, per_device_train_batch_size32, per_device_eval_batch_size64, num_train_epochs3, weight_decay0.01, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelaccuracy, fp16True, logging_steps50, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, tokenizertokenizer, data_collatordata_collator, compute_metricscompute_metrics, ) trainer.train()几个参数我单独说一下。学习率2e-5不是拍脑袋定的预训练模型已经收敛得很好了微调阶段学习率大了容易把学到的通用语义冲掉小了又训不动。fp16True只在支持半精度的GPU上有效能明显省显存并加速训练。weight_decay0.01是Transformer训练里常用的防止过拟合手段。我用的是原始文本不预处理的方式真的只把文本交给tokenizer就不管了标点、数字、大小写都有模型自己扛。这一点和老方案完全不同以前恨不得把文本清洗成标准模板才敢送进模型现在反而要尽量减少规则干预让模型自己去学习语料里的真实分布。4.2 训练日志里最该看哪几个数字训练过程中我盯的主要是三个指标训练集loss、验证集loss、验证集准确率。第一次跑的时候loss从0.82开始第一个epoch结束降到0.31验证集准确率0.87第二个epoch结束loss到0.18准确率0.90第三个epoch结束准确率到0.91但验证集loss不再明显下降。这说明模型已经进入平台期再训下去大概率过拟合。我再补充一个容易忽略的点如果你的验证集loss开始上升而训练集loss还在下降这是过拟合的明确信号应该立刻停止训练。我习惯在TrainingArguments里设置load_best_model_at_endTrue这样训练结束时自动加载验证集上表现最好的那一轮模型而不是最后一轮的模型。另外一个很常见的坑是类别不平衡。我最初直接训练模型对样本量最大的“商品咨询”类拟合得很好但对“售后投诉”这种只占5%的类别几乎视而不见。后来我在compute_loss里给少数类加权或者简单粗暴地对少数类做重复采样情况才好转。预训练模型基础能力很强但对不均衡数据一样会偷懒。4.3 数据质量才是真正的天花板调参只是锦上添花回看整个过程最影响最终效果的不是模型参数而是数据。我第一版微调效果只有82%排查发现是标注数据里有一大批标签本身就是错的——很多工单既包含物流问题又包含商品问题标注员只看了前半句就给了标签。我花了一个晚上把这类“多意图”样本单独筛出来要么拆成两条要么标成主要意图再重训后效果直接涨7个百分点。如果你发现模型在验证集上始终差口气先别折腾学习率和层数打开你的数据看100条。该是两个不同类别的句子是不是长得太像是不是有大量噪声标签是不是存在一个类别只有几十条样本。这些问题的优先级都高于调参。4.4 手写训练循环和Trainer的差别我为什么推荐先用Trainer有人会觉得Trainer封装太多不自由。我这次前期也手写过几段训练循环后来发现要自己处理的东西实在太多了学习率warmup规划、梯度裁剪、梯度累积、分布式训练时的进程同步、评估时的batch整理。这些功能Trainer全部内置好了直接传参数就行。手写循环唯一的优势是灵活比如你想在中间某个step插入自定义操作。但如果你只是做常规分类任务不需要那种自由度Trainer是最高效的选择。等到你对整个训练流程已经熟到需要自定义loss的时候再手写也不迟。5. 从验证集走向真实服务推理阶段显存和速度都要管5.1 用pipeline快速把模型包成服务接口微调完成后我先用pipeline验证一遍推理效果。pipeline会帮我把tokenizer调用和模型前向计算全部封装好适合先确认模型行为是否正常。from transformers import pipeline classifier pipeline( text-classification, model./checkpoints/best_model, tokenizer./checkpoints/best_model, ) result classifier(我的订单两天没更新了到底什么时候发货) print(result) # 输出[{label: 物流咨询, score: 0.97}]score输出0.97说明模型对这个case非常自信。实际测试里我把老方案分错的30条典型case全部拿出来跑了一遍新模型分对了其中的27条剩下的3条连我都觉得有歧义算是数据本身的问题。5.2 batch推理的显存实测和延迟建议我上线前做了一组简单压测记录不同配置下的显存占用。测试机器是一张Tesla T4显卡16GB显存。结论是单条文本推理时显存占用很小模型本身权重占1.2GB剩余都是计算图临时开销但如果把max_length从32提到256显存占用几乎翻倍因为注意力矩阵大小是序列长度的平方。在线场景里我最终选了max_length64因为我的工单文本95%都能在64个字内说清楚再长基本是废话。batch推理时的策略是优先保证时延用batch_size1只有做离线批量扫描时才把batch_size调到32甚至64。如果直接用大batch跑在线接口一个慢请求会拖累整个batch反而增加尾延迟。我还发现一个有意思的点在T4上用FP16推理比FP32快将近一倍显存也小一半。如果你的GPU支持半精度推理时直接把模型转成model.half()就可以效果几乎无损。但CPU推理不支持这么做只在支持半精度的GPU上有收益。5.3 把模型转成ONNXCPU推理也能更快我有一部分流量是跑在纯CPU机器上的。BERT在CPU上推理偏慢单条要一两百毫秒。后来我把模型转成ONNX格式配合onnxruntime的CPU优化单条延迟从180ms降到了大概90ms左右效果还是可感知的。导出过程不复杂关键是设置好动态维度让不同长度的输入都能跑。给个参考代码import torch dummy_input tokenizer(测试用的短文本, return_tensorspt) torch.onnx.export( model.cpu().eval(), (dummy_input[input_ids], dummy_input[attention_mask]), bert_intent.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_version14, )导出后的ONNX文件在GPU上跑也不会有太大损失而且在一些边缘设备上反而更方便。如果你主要用PyTorch生态也可以考虑torch.compile但我实测在T4上加速效果有限ONNX这条路更成熟稳定。5.4 实际部署时的服务化处理我最终没有直接用pipeline上线而是拆成两步先把文本转成tokenizer输出的字典再把字典交给模型推理。这样做的原因是pipeline在批量处理不同长度句子时会重新做padding没办法严格控制预处理逻辑。生产环境里我倾向于用tokenizer.batch_encode_plus统一处理一批请求再用torch.no_grad()包裹推理最后按请求ID把结果对应回去。服务化时还要注意超时设置。BERT推理不像查数据库一个复杂句子的处理时间可能波动很大。我给下游接口设置的超时是500ms超过就返回降级结果避免一个慢请求把整个服务拖垮。6. 跑完一整套流程后回头再看BERT的那几件关键事6.1 attention_mask的位置它不是可有可无的网上很多简化教程会把attention_mask省略掉只传input_ids也能跑通但这是不对的。我后来做可视化的时候发现如果不传maskpadding位置会作为普通token参与注意力计算模型会从这些无意义的0向量上学到“这几个位置总是无信息”结果就是对真实长度变化比较敏感。更本质地说Transformer的核心操作是两两token之间计算相似度并加权聚合。如果不告诉它哪些token是填充的它就要白白计算大量无意义的相关性既拖慢速度又干扰语义。这也解释了为什么padding到不同长度结果会有轻微差异——因为你改变了一批假token的个数。6.2 双向上下文与MLM预训练任务BERT里的“双向”一直是一个需要理解的词。它不是说模型能倒着读文本而是说在预训练阶段模型预测一个被遮盖的token时可以同时看到它左边和右边的所有token。具体做法是随机把15%的token替换成[MASK]让模型根据上下文猜出被盖住的是什么。这个过程叫Masked Language Model简称MLM。这个设计和传统语言模型很不一样。传统语言模型只能从左往右预测下一个词所以每个词只能看到前面的词。双向注意力意味着“银行”这个词在一句话里到底是“银行账户”还是“河边的银行”模型可以根据后面的词语立刻做出判断。很多自然语言理解任务需要的就是这种全句理解能力这也是BERT在分类、匹配、抽取任务上比单向模型默认效果更好的原因。6.3 Embedding可视化看模型到底“学会”了什么训练结束后我顺手做了一个小实验用微调后的模型把测试集里的句子全部编码成768维向量然后用t-SNE降维到2D直接画散点图。不同类别的点明显聚成几团同类别的句子挨得很近跨类别的句子距离很远。这说明模型学到的不再是“哪些词重复了”而是“这句话属于哪个语义空间”。提取句子向量时有个细节直接拿[CLS]位置的输出向量有时不够稳我更喜欢对整个序列的隐层向量做mean pooling把attention_mask作为权重只对真实token做平均。下面是核心代码片段def encode_sentence(sent, tokenizer, model): inputs tokenizer(sent, return_tensorspt, max_length128, truncationTrue) with torch.no_grad(): outputs model(**inputs, output_hidden_statesTrue) hidden outputs.hidden_states[-2] mask inputs[attention_mask].unsqueeze(-1) sentence_vec (hidden * mask).sum(dim1) / mask.sum(dim1) return sentence_vec[0].numpy()这个向量之后可以直接用来做语义相似度计算或者作为特征喂给其他模型。我目前就在用这个向量做重复工单召回效果比旧方案稳定很多。6.4 预训练任务里的NSP了解它对你更好选模型原版BERT还用了Next Sentence Prediction这个预训练任务输入两句话让模型判断后者是不是前者的合理后续。后来研究普遍认为这个任务对大多数下游任务帮助不大甚至会让模型对单句理解产生偏差所以后续很多模型要么去掉它要么换成更合理的句子顺序预测任务。这块知识对实战的意义在于当你选择预训练模型时别只看名字里是不是带BERT要看一下它用的是哪些预训练目标和数据。同样是1亿参数不同预训练方案的模型在下游任务上的表现可能差两三个点。我的建议是找针对自己领域语料继续训练过的版本在同一批测试集上多跑几个模型选效果最稳的那个不要迷恋原版。写到这整个迁移过程算是完整记录了一条。最后分享一个我在这个项目里反复用上的一句话BERT再强也只是你的特征提取器决定它能不能发挥价值的是你对数据的理解和对训练细节的耐心。如果你刚准备上手建议先拿小数据集跑通全流程再一步步扩大数据规模。过程里遇到报错先看模型文件和tokenizer配置大部分问题都出在这两处。我后续打算把这一套流程迁移到另一个多语言场景到时候再写一篇关于跨语言预训练模型的实战对比。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑