简介:一套基于BERT模型的深度学习中文文本分类项目,面向计算机相关专业学生、毕业设计开发者及NLP入门者,解决中文文本分类任务从模型训练到服务部署的完整流程需求。压缩包共18个文件,核心由11个Python脚本构成,涵盖数据预处理、模型构建、训练、评估、预测以及服务调用等模块;另附ipynb交互式演示、JSON标签映射配置、Shell启动脚本及Markdown项目说明,整体体积仅1008KB,便于快速下载与本地复现。资源内置20000条新闻数据的训练集与测试集,并封装简单HTTP接口,可直接对外提供分类服务,方便集成到实际系统中。目前已有350人学习下载,项目代码经测试运行通过,结构清晰,既适合作为课程设计、毕业设计或初期项目演示,也能帮助初学者理解BERT微调、文本分类与工程化落地的关键环节。
1. 直接用起来的BERT中文文本分类:从训练到HTTP服务一次打通
很多做中文文本分类的同学卡在第一步:BERT原理看过,代码也抄过,但真正要把一份新闻数据训练成模型,再通过HTTP接口被调用,中途总会因为数据格式、标签映射、模型保存方式等细节翻车。这个基于BERT的深度学习中文文本分类Python项目,内置了20000条新闻训练测试集、完整训练评估代码、模型预测封装和一个可用的HTTP接口服务端。解压后改一下路径就能跑通训练,再启动server.py就能把模型暴露成一个简单的文本分类API。适合用来做毕业设计、课程设计,也适合作为理解BERT fine-tune全流程的实战样本。下面从原理、数据、代码、训练到部署,按实际拆项目的顺序展开。
2. BERT文本分类原理与中文新闻数据预处理
2.1 BERT做文本分类到底改了什么
BERT本身不是一个分类器,它是在大规模语料上预训练得到的语言表示模型。做中文文本分类时,常规做法是在BERT最后一层取出[CLS]位置的向量,这个向量会被当作整个句子的语义聚合表示,再接一个全连接分类头,输出每个类别的得分。对新闻文本来说,标题和正文拼接成一整段送进去,模型能同时利用上下文信息,比Word2Vec加LSTM的旧方案在长文本和近义表达上更稳。
项目里的model.py大概率就是这种结构:BertModel负责提取特征,Dropout做正则,Linear输出类别数。需要注意的是,[CLS]向量有两种取法:outputs.pooler_output是经过BERT内部tanh变换后的句子表示;outputs.last_hidden_state[:, 0]是原始隐层向量。做分类时,先试pooler_output,如果验证集效果不理想,再换成last_hidden_state[:,0]自己接一个全连接层,这个细节经常被忽略。
2.2 新闻数据格式与标签映射
拿到手的train.txt和test.txt是纯文本格式,常见做法是每行一条样本,文本和类别用\t分隔。新闻场景下,数据长这样:
火箭主场大胜勇士 取赛季最长连胜 体育 央行宣布降准 释放长期资金约8000亿 财经 某厂商发布新款折叠屏手机 售价破万 科技如果原始数据里的一行是多列,比如id\ttext\tlabel,就需要在data_helper.py里先按行切分,再取对应列。这里不能靠肉眼猜,建议解压后先看前10行数据,确认分隔符和标签位置,再动预处理代码。label_to_index.json的作用就是把中文类别映射成数字索引,比如{"体育": 0, "财经": 1, "科技": 2},训练时模型只能吃数字,推理返回结果时再反查回中文标签。
2.3 data_helper.py中的数据预处理核心
data_helper.py负责把原始文本转换成BERT能接受的输入。HuggingFace的BertTokenizer会把中文按字切开,然后转成input_ids,同时生成attention_mask,0表示padding位置不参与注意力计算。核心流程如下:
from transformers import BertTokenizer from torch.utils.data import Dataset class NewsDataset(Dataset): def __init__(self, file_path, tokenizer_path, max_len=128): self.tokenizer = BertTokenizer.from_pretrained(tokenizer_path) self.texts = [] self.labels = [] with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or "\t" not in line: continue text, label = line.split("\t", maxsplit=1) self.texts.append(text) self.labels.append(label) self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encoded = self.tokenizer( self.texts[idx], truncation=True, padding="max_length", max_length=self.max_len, return_tensors="pt", ) return { "input_ids": encoded["input_ids"].squeeze(0), "attention_mask": encoded["attention_mask"].squeeze(0), "labels": torch.tensor(int(self.labels[idx]), dtype=torch.long), }这段逻辑里有几个直接决定训练效果的点。truncation=True会把超过max_len的部分截掉,对新闻文本来说,如果只截开头,重要信息可能丢在末尾。常见做法是把max_len设成128或256,超过长度的部分只保留开头和结尾,但BERT本身对位置编码有512上限,所以这不是无限加长就能解决的。padding="max_length"会把短文本补齐到固定长度,代价是batch里所有样本都按最长长度计算,显存会多消耗一部分;如果数据长短差异大,可以改用padding=True让一个batch内动态对齐,能节省显存。
2.4 config.py里的关键参数怎么调
项目里的config.py是训练入口,参数调整直接影响能否复现效果。下面列几个最常用的配置项:
| 参数 | 常见取值 | 作用与调整建议 |
|---|---|---|
max_len | 128 / 256 | 新闻正文较长可设256,太大会拖慢训练且显存占用高 |
batch_size | 8 / 16 / 32 | 显存不足时优先调小,配合梯度累积使用 |
epochs | 3 / 5 | BERT微调一般不需要跑很多轮,3轮左右足够 |
learning_rate | 2e-5 / 3e-5 | 大于5e-5容易让预训练权重被破坏 |
bert_path | 本地目录或模型名 | 首次运行会下载模型,建议提前下载到本地目录 |
num_classes | 取决于标签数量 | 必须和label_to_index.json里的类别数一致 |
如果训练时显存报错CUDA out of memory,优先把batch_size降到4或8,同时把max_len从256改成128。需要注意的是,BERT微调的学习率比一般深度模型小很多,因为预训练权重已经收敛,学习率太大会让模型迅速偏离预训练分布,典型表现是训练集准确率很高、验证集准确率暴跌。
3. model.py、trainer.py与predictor.py拆解
3.1 分类模型定义:不要重复造轮子
项目里的model.py建议直接用HuggingFace的BertModel封装,而不是自己从头实现Transformer。核心代码大概是这样的:
import torch.nn as nn from transformers import BertModel class BertClassifier(nn.Module): def __init__(self, bert_path, num_classes): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout = nn.Dropout(0.3) self.fc = nn.Linear(self.bert.config.hidden_size, num_classes) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) cls_feat = outputs.pooler_output return self.fc(self.dropout(cls_feat))这里self.bert.config.hidden_size通常是768,对应BERT-base模型。from_pretrained(bert_path)会自动加载预训练权重,bert_path指向本地目录时,目录里需要包含config.json、pytorch_model.bin或tf_model.h5。如果bert_path填的是bert-base-chinese,第一次运行会从HuggingFace下载,网络不稳定时容易中断,建议提前手动下载后改成相对路径。
Dropout(0.3)是分类头里的正则层,如果训练数据只有两万条,dropout可以适当加大到0.4;如果模型在验证集上欠拟合,则减小到0.2。outputs.pooler_output和直接用last_hidden_state[:,0]两者对结果的影响,取决于下游任务和数据集规模。我一般会在验证集上各试一次,选择F1更高的那个。
3.2 metrics.py:宏平均还是加权平均
分类结果不能只看准确率,新闻类别如果分布不均衡,某个大类占比高就会掩盖小类的问题。项目里的metrics.py需要同时输出precision、recall和F1。一个常见实现是:
from sklearn.metrics import accuracy_score, precision_recall_fscore_support def compute_metrics(y_true, y_pred): acc = accuracy_score(y_true, y_pred) p_macro, r_macro, f1_macro, _ = precision_recall_fscore_support( y_true, y_pred, average="macro" ) p_weighted, r_weighted, f1_weighted, _ = precision_recall_fscore_support( y_true, y_pred, average="weighted" ) return { "accuracy": acc, "macro_f1": f1_macro, "weighted_f1": f1_weighted, }average="macro"对每个类别算完指标再取平均,不关心类别样本量,小类错误会被放大;average="weighted"按样本量加权,更适合类别分布不均衡的新闻分类。比如体育类新闻有5000条,科技类只有2000条,weighted结果更接近业务体感,但毕设答辩时最好两个都给出。如果发现宏平均F1比准确率低很多,基本可以断定小类分不好。
3.3 trainer.py训练循环里那道护城河
trainer.py的核心是训练循环,但真正的差异体现在梯度裁剪和学习率调度上。直接贴一个能支撑两万条数据训练的最小循环:
optimizer = torch.optim.AdamW(model.parameters(), lr=config.lr) total_steps = len(train_loader) * config.epochs scheduler = torch.optim.lr_scheduler.LinearLR( optimizer, start_factor=0.1, total_iters=total_steps ) criterion = nn.CrossEntropyLoss() for epoch in range(config.epochs): model.train() for step, batch in enumerate(train_loader): input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) logits = model(input_ids, attention_mask) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step()AdamW和Adam的区别在于权重衰减的实现方式,BERT微调场景下用AdamW是标配。start_factor=0.1表示前10%的步长用0.1倍学习率做warmup,后面线性恢复到设定的lr,这能避免模型在早期大步长下震荡。clip_grad_norm_把梯度范数限制到1.0,防止个别样本产生的异常梯度把预训练权重带偏。
3.4 predictor.py:从训练态切换到推理态
训练完模型之后,predictor.py负责把模型权重、标签映射和tokenizer整合成一个可供外部调用的预测对象。关键点有两个:一是模型需要切到eval()模式,二是推理过程要包在torch.no_grad()里。
import torch import torch.nn.functional as F from transformers import BertTokenizer from core.model import BertClassifier class Predictor: def __init__(self, model_path, bert_path, label_to_index_path): self.tokenizer = BertTokenizer.from_pretrained(bert_path) self.label_to_index = json.load(open(label_to_index_path, "r", encoding="utf-8")) self.index_to_label = {v: k for k, v in self.label_to_index.items()} self.model = BertClassifier(bert_path, len(self.label_to_index)) self.model.load_state_dict(torch.load(model_path, map_location="cpu")) self.model.eval() def predict(self, text, top_k=1): encoded = self.tokenizer( text, truncation=True, padding="max_length", max_length=128, return_tensors="pt", ) with torch.no_grad(): logits = self.model(**encoded) probs = F.softmax(logits, dim=-1).squeeze(0) scores, indices = torch.topk(probs, top_k) return [ {"label": self.index_to_label[idx.item()], "score": round(score.item(), 4)} for score, idx in zip(scores, indices) ]这里self.model.eval()必须加载之后马上调用,否则batch normalization和dropout的行为不一致,会导致推理结果抖动。top_k返回多个候选类别而不是只有一个,对新闻分类这种类别边界本身有重叠的场景很实用。加载模型时map_location="cpu"让代码在无GPU机器上也能做预测,部署阶段不一定要显卡。
| 取法 | 含义 | 适用场景 |
|---|---|---|
pooler_output | BERT池化后的句子向量 | 默认分类任务首选 |
last_hidden_state[:,0] | [CLS]位置的原始隐层向量 | 池化后效果不佳时替换 |
4. 用20000条新闻跑通训练与评估
4.1 环境准备与训练命令
这个项目的训练入口是train.py,运行前先确认依赖:transformers、torch、scikit-learn、flask。装依赖时注意transformers和torch版本不能差太多,老版本transformers可能没有BertModel.from_pretrained的对应接口。有两种启动方式:如果train.py已经封装好argparse,可以直接执行:
python train.py --train_file data/train.txt \ --valid_file data/test.txt \ --bert_path bert-base-chinese \ --max_len 128 \ --batch_size 8 \ --epochs 3 \ --output_dir output/如果项目没有暴露命令行参数,就直接改config.py里的同名变量,再执行python train.py。训练开始后,终端会逐条打印每个batch的loss和当前准确率。两万条数据在单张RTX 3060或4090上用bert-base-chinese,max_len=128、batch_size=16时,一个epoch大约需要几分钟到十几分钟不等。
4.2 训练过程中如何判断模型在变好
只看loss下降是不够的,因为训练集loss会一直下降直到过拟合。更可靠的做法是每个epoch结束后在验证集上计算准确率和F1。项目里如果没自动做验证,可以在train.py里每轮结束调用trainer.py里封装好的evaluate函数。日志输出大致需要包含这些信息:
Epoch [1/3] train_loss: 0.8321 train_acc: 0.7125 | val_acc: 0.6870 Epoch [2/3] train_loss: 0.5123 train_acc: 0.8563 | val_acc: 0.8412 Epoch [3/3] train_loss: 0.3682 train_acc: 0.9124 | val_acc: 0.8537如果发现train_acc还在涨,但val_acc开始下降,说明第二到第三轮之间已经过拟合,这时不需要跑满三个epoch,保存验证集F1最高的那一轮权重就够。项目中output/目录通常会按model_epoch_1.pt这种格式保存多轮结果,测试时指定效果最好的文件,而不是默认加载最后一轮。
4.3 测试集评估与分类报告
训练完成后,用test.py加载最优checkpoint做测试,输出一个包含每个类别precision、recall、F1的报告。命令大致如下:
python test.py --checkpoint output/model_epoch_2.pt \ --test_file data/test.txt \ --output_dir output/跑完会得到类似下面的结果:
| 类别 | precision | recall | f1-score |
|---|---|---|---|
| 体育 | 0.9231 | 0.9102 | 0.9166 |
| 财经 | 0.8543 | 0.8671 | 0.8606 |
| 科技 | 0.8720 | 0.8812 | 0.8765 |
| 教育 | 0.8134 | 0.7922 | 0.8027 |
看这个报告时,优先找recall低的类别。比如教育类recall只有0.79,说明有很多教育新闻被分到了其他类别,这时候可以检查是不是该类别训练样本偏少,或者类别之间文本相似度过高。不要只保存最终权重,torch.save(model.state_dict(), path)和torch.save(model, path)是两种不同保存方式,test.py和server.py里如果要求加载state_dict,就不要把整个模型对象存进去。
4.4 显存不足和过拟合怎么调
两万条数据不大,但BERT-base模型参数量在1亿级别,显存限制是跑实验最常见的障碍。调整方案可以按优先级排列:
| 现象 | 优先调整方案 | 备选方案 |
|---|---|---|
| CUDA OOM | batch_size降到4或8 | 打开梯度累积,每4步更新一次 |
| 训练太慢 | max_len从256降到128 | 换更小的中文预训练模型 |
| val_acc明显低于train_acc | dropout提到0.4 | 降低epoch轮数 |
| 各类别F1差距大 | 改用weighted指标观察 | 给小类别增加权重或扩增样本 |
梯度累积的实现是在optimizer.zero_grad()之前加一个判断条件,累积到固定步数再optimizer.step()。不要为了追验证集准确率无限调大epoch,BERT微调在中等规模数据集上一般3到5轮就会收敛。
5. HTTP分类接口的设计与调用
5.1 server.py:用Flask包一个分类接口
server.py是这个项目里比较亮眼的部分,训练好的模型可以立刻变成一个可调用的HTTP服务。最直接的做法是用Flask起一个轻量级Web服务。
from flask import Flask, request, jsonify from core.predictor import Predictor app = Flask(__name__) predictor = Predictor( model_path="output/best_model.pt", bert_path="bert-base-chinese", label_to_index_path="label_to_index.json", ) @app.route("/predict", methods=["POST"]) def predict(): data = request.get_json(force=True) text = data.get("text", "").strip() if not text: return jsonify({"code": 400, "message": "text is required"}) result = predictor.predict(text, top_k=data.get("top_k", 1)) return jsonify({"code": 0, "result": result})这里request.get_json(force=True)会强制把请求体当JSON解析,省去设置Content-Type的麻烦。text为空时返回400而不是直接走模型预测,避免无效请求浪费模型推理时间。top_k由调用方传入,可以让接口同时返回多个候选标签,这个设计更贴近真实场景。
5.2 启动服务与客户端调用
项目里提供了bert_as_service.sh,直接执行脚本即可启动服务:
bash bert_as_service.sh等价于手动执行python server.py --port 8080。启动后控制台会打印Running on http://0.0.0.0:8080,这时用client.py测试:
import requests resp = requests.post( "http://127.0.0.1:8080/predict", json={"text": "新能源车企6月交付量集体增长 多家创单月新高", "top_k": 2}, ) print(resp.json())返回结果示例:
{ "code": 0, "result": [ {"label": "科技", "score": 0.9234}, {"label": "财经", "score": 0.0412} ] }从分数分布可以看到,模型对这条新闻的判断非常确定。如果两条候选标签分数接近,比如0.48和0.45,说明输入文本同时包含多个类别特征,这种样本在接口层可以直接打上“ambiguous”标记,由业务方人工处理。client.py里还可以加一个超时时间限制,防止模型推理过慢拖垮调用方。
5.3 接口服务应该暴露什么能力
一个用于演示或毕设的分类接口,建议在server.py里同时提供两个接口:/predict用于单条预测,/health用于健康检查。健康检查接口不需要重新加载模型,只需要返回服务状态,方便部署脚本判断进程是否存活。
| 接口 | 入参 | 返回 | 用途 |
|---|---|---|---|
| POST /predict | {"text": "...", "top_k": 1} | 标签与置信度 | 分类推理 |
| GET /health | 无 | {"status": "ok"} | 服务探活 |
服务端加载模型后,最好把所有张量都固定不做梯度计算,Predictor内部已经调用model.eval()和torch.no_grad(),防止框架自动构建计算图导致内存泄漏。生产环境如果并发请求多,一个进程里共享同一个Predictor实例即可,没必要每个请求都重新加载模型。Flask的开发服务器app.run(threaded=True)支持并发但性能有限,真正压测或对外提供接口时可以换成gunicorn。
5.4 接口部署时绕不开的边界
模型文件、label_to_index.json和server.py之间的路径关系是部署最常见的坑。建议把模型放到固定目录,server.py用os.path.join拼绝对路径,不要依赖命令行当前工作目录。另一个边界是文本长度,调用方如果传入上万字的新闻原文,tokenizer要花大量时间做截断和padding,接口响应时间会明显变长。合适的做法是在server.py里对输入文本长度做限制,比如超过500字直接截断或返回提示。
6. 几个让项目更好用的实战调优点
6.1 换更强中文预训练模型
项目默认的bert-base-chinese已经能获得一个不错的baseline,但如果验证集F1卡在某个值上不去,可以考虑换成hfl/chinese-roberta-wwm-ext或hfl/chinese-bert-wwm-ext。这两个模型用全词掩码策略训练,对中文分词后的词级语义更友好。替换时只需要把config.py里的bert_path改成对应的模型名称或本地路径,模型输出维度仍然是768,分类头、训练代码和接口代码全部不用动。如果机器显存较小,可以尝试bert-base-chinese蒸馏版本的tiny模型,速度提升明显,但准确率会下降3到5个百分点。
6.2 类别不均衡时别急着做重采样
两万条新闻数据里,类别数量完全均衡的情况很少。如果某个类别只有其他类别的一半,可以先观察混淆矩阵,看错误是集中在相邻类别还是分散在多个类别。如果是相邻类别混淆,比如“影视”和“娱乐”,重采样未必有效,更直接的办法是把CrossEntropyLoss的weight参数设置成类别样本量的倒数。这个改动只在trainer.py里影响loss计算,训练和推理流程完全不动。
class_weights = torch.tensor( [1.0 / sample_count[i] for i in range(num_classes)] ).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)需要提醒的是,加样本权重后宏平均F1通常会上升,但整体准确率可能略微下降。毕设答辩或项目演示时,至少要同时给出调整前后的准确率和宏平均F1,解释清楚这是用一部分全局准确率换来了小类别的识别能力。
6.3 推理加速可以交给量化与batch推理
HTTP接口如果每来一条请求就做一次前向传播,吞吐量会受限于模型计算量。常见做法是接口内部增加一个batch聚合层:收集短时间窗口内的多条请求,凑成一个batch做一次推理。对于BERT模型,批大小从1提升到8时,单条延迟基本不变,吞吐量却接近线性提升。如果还嫌慢,可以把模型导出为ONNX格式,在CPU上做动态量化,速度能提升两到三倍,但需要重新验证精度损失。这些优化在项目原始代码里不一定包含,但作为展示工程能力的方向很有价值。最后一个建议:接口返回的置信度在业务侧最好设置阈值,比如只有得分大于0.6才认为是可信分类,否则返回“无法判断”,这比强行给出一个错误标签体感要好得多。
本文还有配套的精品资源,点击获取