简介:基于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,老代码直接报TypeError;torch从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 torch==2.1.0 transformers==4.31.0 flask==2.3.0 pip install pandas scikit-learn安装后再执行pip freeze > requirements.txt,把依赖固定下来。这里有两个注意点:如果机器没有NVIDIA显卡,torch==2.1.0默认会装CUDA版本,体积大且用不上,可以把安装命令换成pip install torch==2.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(drop=True) train, test = train_test_split( df, test_size=0.2, stratify=df["label"], random_state=42, ) train.to_csv("data/train.csv", index=False) test.to_csv("data/test.csv", index=False)这里stratify=df["label"]是最关键的一个参数,它保证训练集和测试集里每个类别的比例和原始数据一致。如果不分层,随机切的时候小类目可能全被切到测试集里,训练时这个类目完全没见过。random_state=42是随机数种子,目的是让每次切分结果一致。drop_duplicates(subset=["text"])去重之后还要reset_index(drop=True),否则索引会出现空洞,后面切片时会踩坑。
有些项目说明里会告诉你新闻数据已经分好了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_dir=cache_dir) model = AutoModelForSequenceClassification.from_pretrained(model_name, cache_dir=cache_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_length=128, padding="max_length", truncation=True, ) return { "input_ids": torch.tensor(enc["input_ids"]), "attention_mask": torch.tensor(enc["attention_mask"]), "labels": torch.tensor(self.labels[idx], dtype=torch.long), }这里的max_length=128对新闻文本来说是一个比较均衡的取值。新闻标题加部分正文通常不到128个token,超过的部分会被truncation=True截断。如果你的业务文本是长文,比如整篇新闻正文,可以提高到256或512,但显存占用会成倍增长。padding="max_length"让同batch内所有样本长度一致,省去collator的额外逻辑,但它也会让“你好”这种短文本padding到128个token id,稍微浪费显存。如果想更节省,可以改用padding=True,再用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_labels=len(id2label), id2label=id2label, label2id=label2id, )加载时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_strategy="epoch", save_strategy="epoch", per_device_train_batch_size=16, per_device_eval_batch_size=16, learning_rate=2e-5, num_train_epochs=5, weight_decay=0.01, warmup_ratio=0.1, logging_dir="./logs", logging_steps=50, save_total_limit=2, load_best_model_at_end=True, metric_for_best_model="eval_accuracy", fp16=True, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=valid_dataset, compute_metrics=compute_metrics, callbacks=[EarlyStoppingCallback(early_stopping_patience=2)], ) trainer.train() trainer.save_model("./models/news_bert_final") tokenizer.save_pretrained("./models/news_bert_final")这里evaluation_strategy="epoch"表示每个epoch结束用验证集算一次指标;save_strategy="epoch"配合load_best_model_at_end=True,会让trainer自动把验证集指标最好的那个epoch模型恢复回来。save_total_limit=2限制最多只保留两个checkpoint,避免训练5个epoch就把磁盘占满。fp16=True在NVIDIA显卡上把训练精度改成半精度,能减少显存占用并加速,CPU环境必须删掉这个参数。
如果你不想用Trainer,最小训练循环也不复杂,但需要自己处理梯度清零、设备转移和模型状态切换:
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-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_length=128, truncation=True, return_tensors="pt", ) with torch.no_grad(): logits = model(**enc).logits pred_id = logits.argmax(dim=-1).item() return model.config.id2label[pred_id]推理时不需要padding="max_length",因为只处理一条文本,tokenizer会按实际长度返回;但truncation=True必须保留,否则超长文本会直接报错。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(force=True) text = data.get("text", "").strip() if not text: return jsonify({"error": "text is required"}), 400 enc = tokenizer( text, max_length=128, truncation=True, return_tensors="pt", ) 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(force=True)意味着就算调用方漏掉了Content-Type: application/json,Flask也尝试把body按JSON解析。这个参数的缺点是它会掩盖调用方的错误,所以如果你希望接口更规范,可以去掉force=True,让错误的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_length=128, padding=True, truncation=True, return_tensors="pt", ) 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})这里padding=True是让batch内按最长的文本补齐,而不是把所有sample都pad到128。因为调用方传入的文本长度不一,动态padding能省下不少计算。要注意的是,批量接口的max_length=128依然生效,太长的文本照样截断;调用方如果传了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_length=256和max_length=128显存占用不是两倍而是四倍左右。batch size 32加上256长度,12GB显存很容易被占满。transformers在反向传播时还会额外保存激活值,这部分开销经常被低估。
解决:先检查max_length,新闻标题分类完全不需要256,128足够;再把per_device_train_batch_size降到8或16,用gradient_accumulation_steps=2补偿梯度更新频率。如果还想更大批量,打开fp16=True,半精度能把显存占用再压掉近一半。最后的手段是启用梯度检查点,model.gradient_checkpointing_enable(),它用计算换显存,训练时间会变长,但小显存机器真的能救命。
5.2 标签错位:训练集用1到10,接口返回0却没有对应类别
现象:训练时准确率很高,但模型保存后调用接口,返回的label_id=0,查id2label发现根本没有0这个键。
原因:很多人整理数据时直接用“1到10”给新闻打标,然后把这个数字原样当成labels喂给模型。但分类头的num_labels=10对应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_names=labels)) print(pd.DataFrame(confusion_matrix(y_true, y_pred), index=labels, columns=labels))如果某个类目的样本很容易被分错,不要急着换模型,先找出错分的样例人工看一眼文本,经常是这类样本本身就有歧义。把这类样本加入训练集做一次增量训练,比换一个更大的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的概率输出,让业务方自己决定要不要用第二名。这个习惯救过我很多次,算是我这些年做过最值得的一个小设计。希望帮到你。
本文还有配套的精品资源,点击获取