简介基于BERT模型的深度学习中文文本分类项目面向计算机、人工智能、数据科学等相关专业的在校生与从业者适用于课程设计、毕业设计及算法实战练习。资源将完整Python源码、项目说明、20000条新闻训练与测试集整合在一起并提供简单HTTP接口可快速部署并体验文本分类服务。包体共18个文件以Python脚本为核心覆盖配置管理、数据预处理、BERT模型构建、训练评估、预测推理与服务封装等环节同时辅以shell脚本、Jupyter Notebook、JSON标签映射、Markdown说明与gitignore配置整体压缩后约1MB轻量易用。代码均经过测试运行Notebook可逐步骤展示BERT分类流程从原始新闻数据到训练集构建、模型调参、评估指标再到HTTP调用均有清晰示例。已有350人学习下载适合希望快速复现中文NLP分类任务并在此基础上进行二次开发的学习者。1. 这个压缩包解决什么问题从20000条新闻到可调用的文本分类接口基于BERT模型的深度学习中文文本分类算法加上20000条新闻训练和测试集、python源码和HTTP接口这几样东西拼在一起才是一个能真正交付的中文文本分类项目。很多人一看到“BERT”就觉得是学术玩具但实际业务里新闻自动打标、评论情感判断、工单自动归档用的都是同一套路。这个方案适合两种人一是刚入门深度学习、想用中文数据完整跑一遍微调流程的二是需要把分类能力做成接口给其他系统调用的。它解决的问题是“从数据到服务”的全链路而不是只给你一个精度数字。下面我会从环境搭建讲到接口压测再把训练和服务阶段最容易翻车的位置单独拎出来说。2. 先搭环境再碰数据固定版本、切分数据集和目录规划2.1 python版本和依赖安装为什么把torch和transformers版本锁死文本分类方案的运行环境决定了你后面是否要花一下午去排查“为什么昨天还能跑今天报错”。标题里的python源码并不是只能在特定环境跑但BERT微调对版本组合比较敏感。最典型的问题是transformers新版本把evaluation_strategy改名成eval_strategy老代码直接报TypeErrortorch从1.x升到2.x之后部分自定义训练循环里model(**batch)返回的字段也发生了变化。所以我的习惯是第一次搭建就用与项目说明一致或接近的版本并且把关键依赖的版本号写进requirements.txt不要把环境做成“能用但不可复现”。常见做法是使用python3.10 torch2.1 transformers4.31的组合因为这套版本配合CUDA 12.x、GPU驱动和多数中文BERT预训练模型都比较顺。创建虚拟环境并安装依赖可以这样写python3.10 -m venv venv source venv/bin/activate pip install --upgrade pip pip install torch2.1.0 transformers4.31.0 flask2.3.0 pip install pandas scikit-learn安装后再执行pip freeze requirements.txt把依赖固定下来。这里有两个注意点如果机器没有NVIDIA显卡torch2.1.0默认会装CUDA版本体积大且用不上可以把安装命令换成pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cpu后面训练会慢一些但20000条新闻规模还是能跑完的。另一个是transformers依赖的tokenizers库版本不能太低否则加载bert-base-chinese时可能遇到protobuf报错。出现这类问题不要硬扛直接按报错信息升级对应子依赖。2.2 20000条新闻怎么切标签分布和分层抽样数据这一层最容易被低估。标题里写的是“20000条新闻的训练和测试集”但直接拿到数据后我一般不会立刻训练而是先打开看一眼标签分布。新闻类文本常常按“体育、财经、科技、娱乐、健康”等类目打标如果某个类目只有一百条训练出来的分类器会一边倒。另一个常见问题是重复文本爬虫抓取的新闻里经常有同一篇文章反复出现在列表页和详情页不去重就会让模型在验证集上“作弊”。用pandas读入后先做几件小事去重、看类别数量、按stratify分层切分。代码可以写成import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(data/news.csv) print(df.head()) print(df[label].value_counts()) df df.drop_duplicates(subset[text]).reset_index(dropTrue) train, test train_test_split( df, test_size0.2, stratifydf[label], random_state42, ) train.to_csv(data/train.csv, indexFalse) test.to_csv(data/test.csv, indexFalse)这里stratifydf[label]是最关键的一个参数它保证训练集和测试集里每个类别的比例和原始数据一致。如果不分层随机切的时候小类目可能全被切到测试集里训练时这个类目完全没见过。random_state42是随机数种子目的是让每次切分结果一致。drop_duplicates(subset[text])去重之后还要reset_index(dropTrue)否则索引会出现空洞后面切片时会踩坑。有些项目说明里会告诉你新闻数据已经分好了train和test但如果你要复现我仍然建议自己做一次分层切分。因为原始训练集和测试集的分布可能来自不同时间段比如训练集是上半年新闻、测试集是下半年新闻这本身没有问题但类目比例漂移会直接拉低准确率。你只要确认这一点就不至于在调参时被一个“训练集涨点、验证集掉点”的现象带偏。2.3 bert参数下载与离线加载把预训练模型固定到本地数据切完之后下一步是拉预训练模型。中文文本分类默认选项是bert-base-chinese由transformers库提供12层transformer、隐藏层768维、参数量大约110M。这个尺寸对20000条新闻来说足够既不会像BERT-large那样显存吃紧也不会像albert-tiny那样在长文本上欠拟合。如果你没有特殊要求直接用它作为微调起点就好。在实际下载时很多机器第一次执行from_pretrained会卡在连接阶段网络超时或证书校验失败都有可能这不是代码逻辑问题。常见做法是先用一条代码把模型下载到本地缓存之后再每次都从本地读取。比如from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name bert-base-chinese cache_dir ./models/bert-cache tokenizer AutoTokenizer.from_pretrained(model_name, cache_dircache_dir) model AutoModelForSequenceClassification.from_pretrained(model_name, cache_dircache_dir) model.save_pretrained(./models/news_bert) tokenizer.save_pretrained(./models/news_bert)这里cache_dir指定缓存目录避免占满home分区save_pretrained是把权重和配置文件一起存到项目内后续训练脚本和接口脚本都指向./models/news_bert不再访问网络。我习惯把预训练模型原样保留一份再在另一个目录保存微调后的模型这样搞砸了还能有后悔药吃。注意保存模型时不要只保存model.state_dict()会把config信息丢掉要保存整个目录包括config.json和tokenizer相关文件。2.4 目录结构规划把数据、模型、接口脚本拆开放目录结构看起来是小事但到了接口阶段最怕的就是“在项目根目录能跑换个目录就跑不动”。需要把数据、模型、脚本、输出分开所有相对路径都从一个固定的项目根目录推导。常见的做法是这样project/ ├── models/ │ ├── bert-cache/ │ └── news_bert/ ├── data/ │ ├── news.csv │ ├── train.csv │ └── test.csv ├── scripts/ │ ├── preprocess.py │ ├── train.py │ └── app.py └── output/在代码里用pathlib定义根目录而不是依赖os.getcwd()。比如from pathlib import Path BASE_DIR Path(__file__).resolve().parent.parent DATA_DIR BASE_DIR / data MODEL_DIR BASE_DIR / models / news_bert OUTPUT_DIR BASE_DIR / output这样无论你用python scripts/train.py启动还是用gunicorn启动scripts.app:app路径都不会跑偏。很多同学踩过“训练时模型输出在output里接口脚本却去models目录找”的坑其实只要目录规划统一这个坑就不会出现。3. 训练一套中文新闻分类模型从tokenizer到模型保存3.1 tokenizer编码max_length、attention_mask和中文子词BERT模型读的不是中文句子本身而是把句子切成一串token id。bert-base-chinese的tokenizer会把每个汉字当做一个基本单元遇到英文和数字时会切成更细的子词。这个过程看起来像分词但和jieba那种按词切分的思路不同所以不要自己先分好词再传给BERT。直接把原始文本交给tokenizer让它自己处理是正确且省力的做法。在准备训练数据集时我习惯在Dataset的__getitem__里做编码而不是提前把所有文本一次性编码成id。原因有两条一是内存占用低二是可以灵活调整max_length。一个简单的Dataset可以这样写import torch from torch.utils.data import Dataset from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(./models/news_bert) class NewsDataset(Dataset): def __init__(self, df): self.texts df[text].tolist() self.labels df[label_id].tolist() def __len__(self): return len(self.texts) def __getitem__(self, idx): enc tokenizer( self.texts[idx], max_length128, paddingmax_length, truncationTrue, ) return { input_ids: torch.tensor(enc[input_ids]), attention_mask: torch.tensor(enc[attention_mask]), labels: torch.tensor(self.labels[idx], dtypetorch.long), }这里的max_length128对新闻文本来说是一个比较均衡的取值。新闻标题加部分正文通常不到128个token超过的部分会被truncationTrue截断。如果你的业务文本是长文比如整篇新闻正文可以提高到256或512但显存占用会成倍增长。paddingmax_length让同batch内所有样本长度一致省去collator的额外逻辑但它也会让“你好”这种短文本padding到128个token id稍微浪费显存。如果想更节省可以改用paddingTrue再用transformers的DataCollatorWithPadding动态padding。这里有一个新手常犯的错误把labels直接放进batch然后在训练循环里又手动取outputs.logits和batch[labels]再算一遍loss。对AutoModelForSequenceClassification来说labels本来就是它的输入参数模型会自己计算交叉熵loss你不需要额外写loss。如果你自己写了结果往往也对但属于重复劳动。3.2 构建分类模型num_labels与id2label的关系预训练BERT的输出是768维向量文本分类需要在这个向量后面接一个分类头把768维映射到类别数量。AutoModelForSequenceClassification做的就是这件事它在BERT上叠了一层全连接分类器。微调过程中分类头是从随机初始化开始学习的而BERT底座拿着已经学好的中文语义表示只需要做小幅更新。这就是“微调”的含义也是新闻这类中小规模数据集上最可靠的训练方式。之前2.3节保存的./models/news_bert只是原始预训练模型还没有分类头。要在它上面挂分类头加载时要显式指定num_labels。比如新闻数据有“体育、财经、科技、娱乐、健康”五个类目就写成from transformers import AutoModelForSequenceClassification id2label {0: 体育, 1: 财经, 2: 科技, 3: 娱乐, 4: 健康} label2id {v: k for k, v in id2label.items()} model AutoModelForSequenceClassification.from_pretrained( ./models/news_bert, num_labelslen(id2label), id2labelid2label, label2idlabel2id, )加载时transformers会提示Some weights of BertForSequenceClassification were not initialized这是正常的说明分类头是随机初始化的。重点是id2label和label2id它们会写进config.json保存模型后接口脚本加载同一个目录时会自动知道“0对应体育1对应财经”。如果你训练时不传这两个参数模型照样能训练但保存后再加载就只能看到数字还得在代码里手工维护一份映射表容易出问题。不使用BertForSequenceClassification而是用AutoModelForSequenceClassification的原因也很简单加载的是BERT权重但模型代码会根据config.json里的architectures字段自动选用BertForSequenceClassification你在代码里不用关心它内部是BERT还是别的结构。这个习惯在以后换roberta-wwm-ext、albert、macbert时完全不用改业务代码。3.3 训练参数为什么学习率必须是2e-5这种级别BERT微调最关键的参数是学习率。预训练模型已经在一个很大规模的中文语料上收敛过了如果用0.01这种常规学习率去更新全部参数预训练学到的语义信息会被几百个step冲掉训练集上可能很快收敛到100%验证集却一塌糊涂。文本分类的常见做法是把学习率设在2e-5到5e-5之间配合warmup让学习率先从小到大地爬升再缓慢降低。batch size一般取8或16如果显卡显存不够就减小batch size用gradient_accumulation_steps凑等效batch size。用transformers Trainer可以省去大量自定义训练循环的边界处理。下面这段是把前面构建好的Dataset直接喂给Trainer训练的方式from transformers import Trainer, TrainingArguments, EarlyStoppingCallback training_args TrainingArguments( output_dir./output, evaluation_strategyepoch, save_strategyepoch, per_device_train_batch_size16, per_device_eval_batch_size16, learning_rate2e-5, num_train_epochs5, weight_decay0.01, warmup_ratio0.1, logging_dir./logs, logging_steps50, save_total_limit2, load_best_model_at_endTrue, metric_for_best_modeleval_accuracy, fp16True, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_datasetvalid_dataset, compute_metricscompute_metrics, callbacks[EarlyStoppingCallback(early_stopping_patience2)], ) trainer.train() trainer.save_model(./models/news_bert_final) tokenizer.save_pretrained(./models/news_bert_final)这里evaluation_strategyepoch表示每个epoch结束用验证集算一次指标save_strategyepoch配合load_best_model_at_endTrue会让trainer自动把验证集指标最好的那个epoch模型恢复回来。save_total_limit2限制最多只保留两个checkpoint避免训练5个epoch就把磁盘占满。fp16True在NVIDIA显卡上把训练精度改成半精度能减少显存占用并加速CPU环境必须删掉这个参数。如果你不想用Trainer最小训练循环也不复杂但需要自己处理梯度清零、设备转移和模型状态切换optimizer torch.optim.AdamW(model.parameters(), lr2e-5) model.to(device) for epoch in range(3): model.train() for batch in train_dataloader: batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) outputs.loss.backward() optimizer.step() optimizer.zero_grad()手动循环的好处是每一步都看得懂坏处是容易漏掉warmup、梯度裁剪和best checkpoint逻辑。对第一次跑这套项目的读者我更推荐直接用Trainer它的默认行为已经帮你挡掉了一批低级错误。等你想做样本采样、对抗训练这类自定义逻辑时再改回手动循环不迟。3.4 保存与加载只保存权重还是保存整个模型目录训练完成后trainer.save_model(./models/news_bert_final)这一行会同时写出config.json、model.safetensors和tokenizer相关文件。为什么强调“整个目录”因为分类模型除了权重还必须包含num_labels和id2label这些配置。只保存model.state_dict()的话下次加载要自己把模型类重新搭出来少传一个参数就前功尽弃。加载和推理时代码要尽量和训练保持一致model AutoModelForSequenceClassification.from_pretrained(./models/news_bert_final) tokenizer AutoTokenizer.from_pretrained(./models/news_bert_final) model.eval() def predict(text): enc tokenizer( text, max_length128, truncationTrue, return_tensorspt, ) with torch.no_grad(): logits model(**enc).logits pred_id logits.argmax(dim-1).item() return model.config.id2label[pred_id]推理时不需要paddingmax_length因为只处理一条文本tokenizer会按实际长度返回但truncationTrue必须保留否则超长文本会直接报错。model.eval()和torch.no_grad()这两个缺一不可前者关闭dropout和BatchNorm的随机行为后者告诉torch不要保存反向传播图。4. HTTP接口封装把模型推理变成一个可调用的服务4.1 Flask最小服务模型加载一次接口只做推理训练好的模型如果只存在notebook里对业务系统没有直接价值。标题里特意写了HTTP接口说明这个压缩包希望解决的核心问题是“让其他系统能调用分类能力”。用Flask提供接口是最成熟、最容易被接手的方式代码量少部署时也能和gunicorn配合。接口服务里最需要注意的一点是模型必须在服务启动时加载一次绝不能在每个请求里重新from_pretrained。一个最小服务可以写成from flask import Flask, request, jsonify import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer app Flask(__name__) model None tokenizer None def load_model(): global model, tokenizer model AutoModelForSequenceClassification.from_pretrained(./models/news_bert_final) tokenizer AutoTokenizer.from_pretrained(./models/news_bert_final) model.eval() if torch.cuda.is_available(): model model.to(cuda) load_model() app.route(/health, methods[GET]) def health(): return jsonify({status: ok, model: news_bert_final})有了/health接口部署脚本就可以在启动后轮询这个地址确认模型真的加载成功再对外给流量。model.to(cuda)是迁移到显卡model.eval()是进入推理模式。如果你把model.to(cuda)放在加载之后、但忘记model.eval()前几次推理结果可能带着训练时的dropout随机性输出概率每次都不一样。这个问题很隐蔽接口阶段排查起来也费劲。4.2 请求参数与返回格式JSON约定和错误码接口和调用方之间的约定必须明确。我通常给/predict设计成POST请求body是JSON至少包含text字段。返回体里要有三样东西标签编号、标签中文名、置信度。其中置信度用softmax后的概率值而不是logits原始值因为调用方经常需要拿这个数值做阈值判断。app.route(/predict, methods[POST]) def predict(): data request.get_json(forceTrue) text data.get(text, ).strip() if not text: return jsonify({error: text is required}), 400 enc tokenizer( text, max_length128, truncationTrue, return_tensorspt, ) if torch.cuda.is_available(): enc {k: v.to(cuda) for k, v in enc.items()} with torch.no_grad(): logits model(**enc).logits probs torch.softmax(logits, dim-1) confidence, pred_id torch.topk(probs, 1) return jsonify({ label_id: pred_id.item(), label: model.config.id2label[pred_id.item()], confidence: round(confidence.item(), 4), })request.get_json(forceTrue)意味着就算调用方漏掉了Content-Type: application/jsonFlask也尝试把body按JSON解析。这个参数的缺点是它会掩盖调用方的错误所以如果你希望接口更规范可以去掉forceTrue让错误的Content-Type直接返回415。torch.topk(probs, 1)取概率最大的那一项比argmax多返回一个概率值省得再算一次softmax。round(confidence.item(), 4)把概率保留四位小数返回给前端的JSON不会变成一长串浮点数。类目映射直接从model.config.id2label读不需要在接口文件里再写一份dict。这样训练脚本改标签名时接口代码不用同步改。这是一个容易被忽略的设计细节接口应该和模型配置共用一份标签映射而不是各写各的。4.3 用curl和python requests验证接口别等到联调才暴露问题接口写完后先用命令行验证再用代码验证。命令行只验证服务和网络通不通代码验证才是模拟真实调用。常见的验证方式如下curl -X POST http://127.0.0.1:5000/predict \ -H Content-Type: application/json \ -d {text: 美联储宣布加息全球股市震荡}如果返回的JSON里label是财经说明接口链路是通的。但只测一条正面样例不够我一般会把测试集里抽几条代表性文本分别用requests批量打一遍import requests samples [ 苹果发布新款手机供应链公司股价大涨, 欧冠决赛今晚打响球迷期待巅峰对决, 国家卫健委发布新版健康指南, ] for text in samples: resp requests.post(http://127.0.0.1:5000/predict, json{text: text}) print(text, resp.json())这里用json{text: text}而不是data...由requests自动处理编码和Content-Type。如果你非要用data就得自己手动加headers{Content-Type: application/json}并且确认字符串是UTF-8编码。中文文本在手动拼JSON时最常见的翻车就是编码问题用requests的json参数可以绕开一大半。另一个容易被忽略的点是接口验证不要只关注分类对不对还要看边界输入。空字符串、超长文本、没有text字段的请求都应该返回4xx而不是让模型直接抛异常。把这类用例也跑一遍接口才敢交给前端。4.4 性能优化单条推理改成批量推理单条推理在GPU上其实很浪费因为BERT的attention计算是按batch并行进行的一条文本和十条文本一起过模型耗时差距远小于十倍。如果你的调用方经常一次要判断几十条新闻接口可以增加一个/batch_predict接口一次接收文本列表。app.route(/batch_predict, methods[POST]) def batch_predict(): texts request.get_json().get(texts, []) if not texts: return jsonify({error: texts is required}), 400 enc tokenizer( texts, max_length128, paddingTrue, truncationTrue, return_tensorspt, ) if torch.cuda.is_available(): enc {k: v.to(cuda) for k, v in enc.items()} with torch.no_grad(): logits model(**enc).logits probs torch.softmax(logits, dim-1) results [] for prob in probs: confidence, pred_id torch.topk(prob, 1) results.append({ label_id: pred_id.item(), label: model.config.id2label[pred_id.item()], confidence: round(confidence.item(), 4), }) return jsonify({results: results})这里paddingTrue是让batch内按最长的文本补齐而不是把所有sample都pad到128。因为调用方传入的文本长度不一动态padding能省下不少计算。要注意的是批量接口的max_length128依然生效太长的文本照样截断调用方如果传了1000条这个接口可能耗时几十秒需要配合超时机制和队列。生产环境建议用gunicorn启动Flask并把worker数设为2到4个而不是用Flask自带的开发服务器暴露到公网。5. 避坑清单从训练到HTTP接口最容易翻车的五个位置5.1 CUDA out of memory训练到第二个epoch直接崩掉现象训练脚本第一个epoch正常第二个epoch中途报CUDA out of memory重试几次结果一致把batch size调小后又能训练但速度明显变慢。原因BERT的attention复杂度是文本长度的平方max_length256和max_length128显存占用不是两倍而是四倍左右。batch size 32加上256长度12GB显存很容易被占满。transformers在反向传播时还会额外保存激活值这部分开销经常被低估。解决先检查max_length新闻标题分类完全不需要256128足够再把per_device_train_batch_size降到8或16用gradient_accumulation_steps2补偿梯度更新频率。如果还想更大批量打开fp16True半精度能把显存占用再压掉近一半。最后的手段是启用梯度检查点model.gradient_checkpointing_enable()它用计算换显存训练时间会变长但小显存机器真的能救命。5.2 标签错位训练集用1到10接口返回0却没有对应类别现象训练时准确率很高但模型保存后调用接口返回的label_id0查id2label发现根本没有0这个键。原因很多人整理数据时直接用“1到10”给新闻打标然后把这个数字原样当成labels喂给模型。但分类头的num_labels10对应logits索引0到9模型输出的是0到9的下标不是数据里的1到10。训练时如果label是1到10模型会一直把10当成一个额外类别等到推理时argmax得到0自然没有映射。解决在预处理阶段就把原始标签转成从0开始的连续整数。一行代码就能完成df[label_id] df[label].astype(category).cat.codes然后打印一下label_id和label的对应关系确认从0开始。训练前把id2label传进from_pretrained让模型自己保存映射接口阶段只读model.config.id2label就不会出现“知道了类别却没名字”的情况。5.3 中文乱码控制台能跑接口返回\u4f53\u80b2现象本地训练脚本用print打印文本正常但HTTP接口返回的JSON里中文变成了\u4f53\u80b2前端拿到后显示成转义字符串。更麻烦的是直接在URL上拼中文参数调用接口时接口收到的文本是乱码。原因Flask的jsonify会默认把中文转成Unicode转义序列这在协议上是合法的前端如果正确解析不会出问题但很多人直接看响应体就觉得是乱码。URL拼中文则涉及URL编码和Flask路由解码只要有一层没按UTF-8处理中文就变成一堆%E4%BD%93%E8%82%B2。解决想让人眼可读可以在Flask app配置里设置app.config[JSON_AS_ASCII] False更推荐的做法是让前端用fetch的response.json()解析不要手动处理转义。调用接口时始终把文本放在POST body里用requests的json参数发送不放在URL query string里。如果只能用GET就需要先做urllib.parse.quote再拼接并在对端URL解码但这类接口设计从一开始就不值得推荐。5.4 准确率虚高验证集96%线上分类全偏向某一个类目现象训练时验证集准确率96%拿一批真实新闻去测发现大部分文本被分到“科技”连体育新闻都带一点科技标签。查看样本分布训练集里科技类占了一半。原因准确率不是衡量类别不均衡数据的可靠指标。当科技类有5000条、其他类各500条时模型只要把所有样本预测成科技准确率就已经有50%再学一点皮毛就能到90%以上。真实线上数据分布如果和训练集不一致模型会更明显暴露出“学习到了训练集的先验概率”这一层问题。解决先看混淆矩阵和每类的precision/recall/F1而不是只盯准确率。处理手段有三条路一是用stratify重新分层抽样至少保证测试集能反映真实分布二是给样本少的类目设置class_weight或者用WeightedRandomSampler做有放回采样三是调整最终判定阈值把低置信度样本设为uncertain而不是硬分到一个类别。这三条里第一条成本最低建议先做。5.5 换了一台机器加载模型后预测结果和原来不一样现象把保存的news_bert_final目录拷贝到另一台服务器同样输入、同样代码预测结果却有几条不一致甚至加载时报some weights not used或unexpected key。原因最常见的是transformers或torch版本不一致模型权重文件可能在老版本下加载也可能遇到safetensors库缺失或版本过旧另一个原因是拷贝时只拷了model.safetensors漏了config.json加载时用代码里的默认配置重新初始化了分类头等于模型结构变了。解决拷贝模型目录时整目录复制确认里面有config.json、model.safetensors、vocab.txt和tokenizer_config.json。在新环境里先按2.1节的requirements.txt安装依赖不要装最新版。如果加载时出现“unexpected key”多半是原模型用state_dict保存了额外权重去原训练环境里用save_pretrained重新保存一次不要手动改权重文件名。最后用一条固定文本在新旧环境各跑十遍确认输出概率完全相同再对外服务。6. 进阶把“能跑”的模型变成“能用”的模型6.1 验证方法和混淆矩阵先看错在哪一类训练结束后除了看Trainer打印的验证集指标我会单独拿测试集跑一遍预测用sklearn生成混淆矩阵和classification_report。这两张表能直接看到“财经”和“科技”是不是互相打架“娱乐”是不是被吞并。简单代码from sklearn.metrics import classification_report, confusion_matrix import pandas as pd y_true test_df[label_id].tolist() y_pred [predict(text)[label_id] for text in test_df[text].tolist()] print(classification_report(y_true, y_pred, target_nameslabels)) print(pd.DataFrame(confusion_matrix(y_true, y_pred), indexlabels, columnslabels))如果某个类目的样本很容易被分错不要急着换模型先找出错分的样例人工看一眼文本经常是这类样本本身就有歧义。把这类样本加入训练集做一次增量训练比换一个更大的BERT模型更省钱。6.2 给接口加阈值低置信度样本不硬分类新闻文本有很多本身就是中性的比如“某公司在京发布春季新品”既可能被分到科技也可能被分到财经。我在接口里会加一个阈值置信度低于0.6时返回uncertain而不是强行给出一个标签。这样业务系统拿到低置信度文本时可以走人工复核流程而不是被错误标签带到沟里。confidence prob.max().item() if confidence 0.6: return jsonify({label_id: -1, label: uncertain, confidence: round(confidence, 4)})这个阈值不是拍脑袋定的需要拿测试集上误分类样本的置信度分布去选。一般来说0.5到0.7之间值得多试几个值再对比“被拒识的文本比例”和“剩余文本的准确率”两个指标。我现在做类似的项目时都会在接口里默认保留top5的概率输出让业务方自己决定要不要用第二名。这个习惯救过我很多次算是我这些年做过最值得的一个小设计。希望帮到你。本文还有配套的精品资源点击获取