BERT中文文本分类实战:从训练到HTTP接口部署
发布时间:2026/9/12 21:52:26来源:尧图网络
简介一套基于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_mask0表示padding位置不参与注意力计算。核心流程如下from transformers import BertTokenizer from torch.utils.data import Dataset class NewsDataset(Dataset): def __init__(self, file_path, tokenizer_path, max_len128): self.tokenizer BertTokenizer.from_pretrained(tokenizer_path) self.texts [] self.labels [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line or \t not in line: continue text, label line.split(\t, maxsplit1) 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], truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt, ) return { input_ids: encoded[input_ids].squeeze(0), attention_mask: encoded[attention_mask].squeeze(0), labels: torch.tensor(int(self.labels[idx]), dtypetorch.long), }这段逻辑里有几个直接决定训练效果的点。truncationTrue会把超过max_len的部分截掉对新闻文本来说如果只截开头重要信息可能丢在末尾。常见做法是把max_len设成128或256超过长度的部分只保留开头和结尾但BERT本身对位置编码有512上限所以这不是无限加长就能解决的。paddingmax_length会把短文本补齐到固定长度代价是batch里所有样本都按最长长度计算显存会多消耗一部分如果数据长短差异大可以改用paddingTrue让一个batch内动态对齐能节省显存。2.4 config.py里的关键参数怎么调项目里的config.py是训练入口参数调整直接影响能否复现效果。下面列几个最常用的配置项参数常见取值作用与调整建议max_len128 / 256新闻正文较长可设256太大会拖慢训练且显存占用高batch_size8 / 16 / 32显存不足时优先调小配合梯度累积使用epochs3 / 5BERT微调一般不需要跑很多轮3轮左右足够learning_rate2e-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_idsinput_ids, attention_maskattention_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, averagemacro ) p_weighted, r_weighted, f1_weighted, _ precision_recall_fscore_support( y_true, y_pred, averageweighted ) return { accuracy: acc, macro_f1: f1_macro, weighted_f1: f1_weighted, }averagemacro对每个类别算完指标再取平均不关心类别样本量小类错误会被放大averageweighted按样本量加权更适合类别分布不均衡的新闻分类。比如体育类新闻有5000条科技类只有2000条weighted结果更接近业务体感但毕设答辩时最好两个都给出。如果发现宏平均F1比准确率低很多基本可以断定小类分不好。3.3 trainer.py训练循环里那道护城河trainer.py的核心是训练循环但真正的差异体现在梯度裁剪和学习率调度上。直接贴一个能支撑两万条数据训练的最小循环optimizer torch.optim.AdamW(model.parameters(), lrconfig.lr) total_steps len(train_loader) * config.epochs scheduler torch.optim.lr_scheduler.LinearLR( optimizer, start_factor0.1, total_iterstotal_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_norm1.0) optimizer.step() scheduler.step()AdamW和Adam的区别在于权重衰减的实现方式BERT微调场景下用AdamW是标配。start_factor0.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, encodingutf-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_locationcpu)) self.model.eval() def predict(self, text, top_k1): encoded self.tokenizer( text, truncationTrue, paddingmax_length, max_length128, return_tensorspt, ) 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_locationcpu让代码在无GPU机器上也能做预测部署阶段不一定要显卡。取法含义适用场景pooler_outputBERT池化后的句子向量默认分类任务首选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-chinesemax_len128、batch_size16时一个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/跑完会得到类似下面的结果类别precisionrecallf1-score体育0.92310.91020.9166财经0.85430.86710.8606科技0.87200.88120.8765教育0.81340.79220.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 OOMbatch_size降到4或8打开梯度累积每4步更新一次训练太慢max_len从256降到128换更小的中文预训练模型val_acc明显低于train_accdropout提到0.4降低epoch轮数各类别F1差距大改用weighted指标观察给小类别增加权重或扩增样本梯度累积的实现是在optimizer.zero_grad()之前加一个判断条件累积到固定步数再optimizer.step()。不要为了追验证集准确率无限调大epochBERT微调在中等规模数据集上一般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_pathoutput/best_model.pt, bert_pathbert-base-chinese, label_to_index_pathlabel_to_index.json, ) app.route(/predict, methods[POST]) def predict(): data request.get_json(forceTrue) text data.get(text, ).strip() if not text: return jsonify({code: 400, message: text is required}) result predictor.predict(text, top_kdata.get(top_k, 1)) return jsonify({code: 0, result: result})这里request.get_json(forceTrue)会强制把请求体当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(threadedTrue)支持并发但性能有限真正压测或对外提供接口时可以换成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(weightclass_weights)需要提醒的是加样本权重后宏平均F1通常会上升但整体准确率可能略微下降。毕设答辩或项目演示时至少要同时给出调整前后的准确率和宏平均F1解释清楚这是用一部分全局准确率换来了小类别的识别能力。6.3 推理加速可以交给量化与batch推理HTTP接口如果每来一条请求就做一次前向传播吞吐量会受限于模型计算量。常见做法是接口内部增加一个batch聚合层收集短时间窗口内的多条请求凑成一个batch做一次推理。对于BERT模型批大小从1提升到8时单条延迟基本不变吞吐量却接近线性提升。如果还嫌慢可以把模型导出为ONNX格式在CPU上做动态量化速度能提升两到三倍但需要重新验证精度损失。这些优化在项目原始代码里不一定包含但作为展示工程能力的方向很有价值。最后一个建议接口返回的置信度在业务侧最好设置阈值比如只有得分大于0.6才认为是可信分类否则返回“无法判断”这比强行给出一个错误标签体感要好得多。本文还有配套的精品资源点击获取
网站建设高端定制企业官网