简介:基于BERT模型的深度学习中文文本分类项目,面向计算机、人工智能等相关专业的学生与开发者,用于解决中文新闻文本的自动分类问题,可支撑课程设计、毕业设计及项目初期演示。压缩包内共18个文件,以Python脚本为主,其中11个py文件完整覆盖数据预处理、模型构建、训练评估、离线预测和HTTP服务接口等环节,另含说明文档、配置文件、用于训练与测试的txt语料、Shell一键启动脚本以及Jupyter交互示例,整包大小仅1008KB,结构清晰便于按需研读。配套两万条新闻训练测试集与标签映射字典,可让读者直接运行代码进行训练与验证,同时内置的HTTP接口便于二次开发集成。目前已有350人学习下载,适合具备一定深度学习基础、希望快速上手BERT中文文本分类实战的学生和算法工程师。
1. 从统计学到 BERT:中文文本分类的范式转换
中文文本分类在 BERT 出现之前,是一条“特征工程 + 浅层模型”的漫长流水线:分词、去停用词、TF-IDF 或 Word2Vec 向量化,再喂给 TextCNN、TextRNN 或 XGBoost。这条链路的问题不在于某个环节不够好,而在于每一环都在丢失信息——分词错误会直接传导到向量,词向量无法表达“苹果”在“苹果公司”和“削苹果”中的语义差异,更不用说处理“厉害了我的国”这种整体语义远大于词义之和的短句。
这个标题给出的项目把整条流水线替换成了“预训练 + 微调”范式。BERT 在海量中文语料上完成了 Masked Language Model 预训练,已经掌握了字与字之间的上下文关系;你需要做的只是接一个分类头,在 20000 条新闻数据上微调若干轮。这个方案能解决的核心问题是:在标注数据有限的情况下,如何获得一个泛化能力强、且能直接通过 HTTP 接口对外提供服务的中文分类系统。它适合正在做舆情系统、新闻聚合、评论审核的工程师,也适合想搞清楚 Hugging Face 生态如何落地的深度学习初学者。
需要说明的是,20000 条新闻对于 BERT 微调来说不算多,但足够训练出一个在五六成准确率基线之上有明显提升的模型。关键在于数据质量、类别分布和超参数的配合。下面的内容我会按数据准备、训练实现、接口封装、排错进阶的顺序,把这套方案完整走一遍。
2. 理论底座:BERT 为什么适合中文文本分类
2.1 字级输入与 WordPiece:绕开分词误差的天然优势
传统中文 NLP 的第一步永远是分词,而分词本身就是个错误源。BERT 用的是 WordPiece(中文场景实为字级 Piece),输入层直接接收的是 token ids,而非词向量。以BertTokenizer为例:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") tokens = tokenizer("华为发布了一款新手机", add_special_tokens=True) print(tokens["input_ids"]) # [101, 2621, 3303, 3303, 4638, 3300, 3322, 1450, 3614, 4686, 102]101是[CLS],102是[SEP],中间每个数字对应一个中文字符。这个设计带来的直接好处是:分词错误不再向分类任务传导。“中华杯足球赛”无论怎么切,BERT 看到的始终是同一串字符序列。代价是序列长度最多容纳 512 个 token,超出部分需要截断或分段。
这个项目里的 20000 条新闻,绝大多数长度在几十到几百字之间,截断到 200 或 256 是合理选择,既保留信息又控制显存占用。别一上来就设max_len=512,那会让 batch size 缩水到个位数,训练速度陡降。
2.2 微调 vs 特征提取:两种用法的精度与成本权衡
BERT 落地有两种主流方式。特征提取是把 BERT 当作编码器,拿到[CLS]向量或最后一层隐状态,冻结权重,只训练下游分类器;微调则是让 BERT 的全部参数参与反向传播,分类头和 Transformer 层一起更新。
| 对比项 | 特征提取(冻结) | 微调(全参数) |
|---|---|---|
| 显存占用 | 低(无需存储大量梯度) | 高(每层梯度都要保留) |
| 训练时间 | 快(只更新分类层) | 慢(全部参数更新) |
| 精度上限 | 中(语义固化,难适应领域) | 高(领域知识可注入) |
| 适用场景 | 算力受限、快速验证 | 追求分类精度、数据量充足 |
这个标题下的项目既然给了完整训练集,做微调是必然的。但要理解一个细节:BERT 的底层 Transformer 层学的是通用语法和语义,顶层更接近任务相关特征。微调时分类头和学习率设定因此有讲究——分类头可以用稍大的学习率,BERT 主体要小步更新。
2.3 中文 BERT 模型的选型:bert-base-chinese 还是 RoBERTa-wwm-ext
Hugging Face 上中文 BERT 变体极多。bert-base-chinese是 Google 原版,数据覆盖广但分词器存在 OOV 问题;哈工大的hfl/rbt3、hfl/chinese-roberta-wwm-ext用了全词掩码(Whole Word Masking),在被掩码时整个词的所有字一起被预测,强制模型学习词级语义边界。
对于新闻分类这种领域相对通用、数据量不算大的任务,我一般建议先用bert-base-chinese跑通基线,再用hfl/chinese-roberta-wwm-ext替换 backbone 看精度变化。两者在 Hugging Face 的加载方式完全一致,切换成本仅仅是改一个字符串。先跑通再换模型,是对排错最友好的路径。
3. 数据准备:20000 条新闻训练集的使用与预处理管线
拿到新闻数据后第一件事不是训练,而是打开看看。常见的数据格式是 CSV 或 JSON 文件,每条包含text和label字段,但真实数据往往存在重复、空值、类别不平衡这些基础问题。
3.1 标签分布:先摸清家底再定分类策略
import pandas as pd df = pd.read_csv("news.csv", encoding="utf-8") print(df.shape) print(df["label"].value_counts(normalize=True))运行后如果发现某一类占比超过 40%,就要意识到模型倾向于把模糊样本全判给这个大类。这时有三条路可选:对少数类做加权(class_weight)、对多数类欠采样、或在损失函数中按频率反比放大少数类梯度。新闻分类中“体育”和“娱乐”边界模糊,但“财经”和“科技”也有交叉,先看分布再谈模型是铁律。
文本字段清理分三步:去 HTML 标签(新闻原文常带p、div残留)、统一全半角符号、处理 URL 和 @ 符号。新闻不像用户评论那么脏,但也不排除爬虫抓取时混入了页面导航文本。
3.2 训练 / 验证 / 测试的划分策略
20000 条的体量,划分比例按 8:1:1 是常用做法。关键是stratify参数——按标签比例分层抽样,避免某个类别被随机划分挤占:
from sklearn.model_selection import train_test_split train_val, test = train_test_split(df, test_size=0.1, random_state=42, stratify=df["label"]) train, val = train_test_split(train_val, test_size=0.111, random_state=42, stratify=train_val["label"]) print(train["label"].value_counts(normalize=True)) print(test["label"].value_counts(normalize=True))test_size=0.111是因为第一阶段已经切走了 10%,剩余 90% 中再切 11.1% 恰好等于总数的 10%。这里容易踩的坑是忘记stratify,导致某一个类别在验证集中缺失或占比失真,训练时损失曲线看起来没问题,实际泛化能力很差。
random_state=42保证每次划分结果一致,这对复现实验结果至关重要。后续如果有人拿到代码跑出和你不同的指标,先检查划分种子。
3.3 Dataset 封装与 DataLoader 的注意力掩码
Hugging Face 的Dataset类封装了数据加载、tokenize、分批的全部细节:
from datasets import Dataset train_dataset = Dataset.from_pandas(train[["text", "label"]]) val_dataset = Dataset.from_pandas(val[["text", "label"]]) test_dataset = Dataset.from_pandas(test[["text", "label"]]) def tokenize_function(examples): return tokenizer(examples["text"], padding="max_length", truncation=True, max_length=256) train_dataset = train_dataset.map(tokenize_function, batched=True) val_dataset = val_dataset.map(tokenize_function, batched=True) test_dataset = test_dataset.map(tokenize_function, batched=True)padding="max_length"会在所有样本后补零到 256 长度,truncation=True对超长文本做截断。tokenizer 返回的attention_mask自动标出哪些位置是真实 token(1)哪些是 padding(0),模型在 Self-Attention 时会忽略 padding 位置的计算。
这里的浪费是显存层面的——短新闻也被 pad 到 256,但我不会建议在数据规模不大时做动态 padding。原因是动态 padding 需要自定义collate_fn,复杂度提升但收益只在训练时间上体现,20000 条数据的训练时间差异很难感受到。
4. 基于 BERT 的微调训练:从基线到收敛的完整实现
4.1 模型定义与输出层设计
BertForSequenceClassification已经替我们做好了“BERT 主干 + 分类头”的拼接:
from transformers import BertForSequenceClassification num_labels = len(df["label"].unique()) model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=num_labels )BertForSequenceClassification的内部逻辑是:取[CLS]位置的输出向量,经过一个 Dropout 层,再通过一个Linear(num_labels)全连接层映射为每个类别的 logits。损失函数默认是CrossEntropyLoss,from_pretrained会保留 BERT 预训练权重,分类头的权重是随机初始化的。
热词里多次出现的“李沐 bert”“动手深度学习”其实指向同一个核心认知:预训练模型微调时,底层的通用特征不该被大幅扰动。随机初始化的分类头需要较大的梯度步长来拟合任务,但如果这个扰动传回 BERT 整体,可能破坏预训练学到的语义结构。解决方式是 Parametric Efficient Fine-Tuning 的思路,即冻结部分底层参数。
4.2 训练参数选择:学习率、batch size、warmup 的相互作用
| 参数 | 建议值 | 选择理由 |
|---|---|---|
| learning_rate | 2e-5 ~ 5e-5 | 超过 5e-5 容易灾难性遗忘 |
| batch_size | 16 或 32 | 取决于显存,16 训练更稳 |
| num_epochs | 3 ~ 5 | 数据少时 3 轮足够,过拟合早停 |
| warmup_ratio | 0.1 | 前 10% 步数线性升学习率 |
| weight_decay | 0.01 | 防过拟合,BERT 微调标配 |
学习率是 BERT 微调中最敏感的参数。Transformer 层的预训练权重已经收敛到某个局部最优,过大的学习率会把它推出原有盆地;过小则分类头的拟合速度过慢。2e-5是经过大量实验验证的安全值,从它开始调试是业界惯例。
TrainerAPI 把训练循环、梯度累计、日志记录全部封装起来了:
from transformers import Trainer, TrainingArguments training_args = TrainingArguments( output_dir="./results", evaluation_strategy="epoch", save_strategy="epoch", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=3, weight_decay=0.01, warmup_ratio=0.1, logging_dir="./logs", load_best_model_at_end=True, save_total_limit=2, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, tokenizer=tokenizer, ) trainer.train()load_best_model_at_end=True会在训练结束后自动加载验证集上指标最好的 checkpoint,而不是最后一轮的结果。BERT 微调到后期往往出现过拟合,最后一轮未必是泛化最优的,这个配置能帮你省掉手动回溯 checkpoint 的步骤。
4.3 测试集评估与分类报告
训练完成后用trainer.predict()对测试集做最终评估:
predictions = trainer.predict(test_dataset) preds = np.argmax(predictions.predictions, axis=1)注意predict()返回的是PredictionOutput对象,包含predictions(logits 或概率)和label_ids(真实标签)。np.argmax沿axis=1找到每个样本概率最大的类别索引,再与真实标签对比计算准确率和混淆矩阵。
如果在验证集上准确率 95%,测试集上却只有 80%,这不是模型问题,而是数据划分泄露。可能的原因包括相似文本同时出现在训练集和测试集里,或同一新闻被做小改动后重复收录。回到数据准备阶段,先做去重(按text哈希),再重新划分。
5. HTTP 接口封装:把训练好的模型部署成可调用服务
5.1 FastAPI 推理服务的最小实现
训练完成后的模型在评估时表现优秀,但要真正产生价值,必须提供对外接口。我用的方案是 FastAPI:
import torch from fastapi import FastAPI, HTTPException from pydantic import BaseModel from transformers import BertForSequenceClassification, AutoTokenizer app = FastAPI(title="Chinese Text Classification API", version="1.0.0") model_dir = "./results/checkpoint-1250" model = BertForSequenceClassification.from_pretrained(model_dir) tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model.eval() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) class NewsRequest(BaseModel): text: str max_length: int = 256 @app.post("/predict") def predict(request: NewsRequest): if not request.text.strip(): raise HTTPException(status_code=400, detail="text cannot be empty") inputs = tokenizer( request.text, truncation=True, max_length=request.max_length, return_tensors="pt" ).to(device) with torch.no_grad(): outputs = model(**inputs) logits = outputs.logits pred_id = torch.argmax(logits, dim=-1).item() probability = torch.softmax(logits, dim=-1).tolist()[0] label_names = ["财经", "体育", "娱乐", "科技", "健康"] return { "label": label_names[pred_id], "label_id": pred_id, "probabilities": probability, "max_probability": max(probability) }torch.no_grad()是推理阶段的必要声明,它告诉 PyTorch 不需要记录梯度,显著降低显存占用并加速计算。模型的model.eval()会关闭 Dropout 层,否则每次推理结果都会因随机失活而波动。
checkpoint-1250是训练 logs 里最后的 checkpoint 目录,如果你设置了save_total_limit=2,目录下会保留最近两个 checkpoint,挑选评估指标最好的那个即可。
5.2 性能优化:动态批处理与模型缓存
单条请求走一次完整前向传播,对于 BERT 这种 12 层 Transformer 来说延迟大约 10~30ms,但这在高峰期并不足够。一个实用的优化是把多个请求合并成一个 batch:
from fastapi import BackgroundTasks import asyncio class BatchInference: def __init__(self, model, tokenizer, max_batch=32, max_wait=0.05): self.model = model self.tokenizer = tokenizer self.max_batch = max_batch self.max_wait = max_wait self.queue = [] self.lock = asyncio.Lock() async def infer(self, text): async with self.lock: future = asyncio.get_event_loop().create_future() self.queue.append((text, future)) should_flush = len(self.queue) >= self.max_batch if should_flush: loop = asyncio.get_event_loop() loop.create_task(self._flush()) return await future async def _flush(self): async with self.lock: batch = self.queue self.queue = [] if not batch: return texts = [item[0] for item in batch] inputs = self.tokenizer( texts, padding=True, truncation=True, max_length=256, return_tensors="pt" ).to(self.device) with torch.no_grad(): outputs = self.model(**inputs) probs = torch.softmax(outputs.logits, dim=-1).cpu().numpy() for i, (_, future) in enumerate(batch): future.set_result(probs[i]) await asyncio.sleep(0)这里的核心思路是“攒一批再算”。每个请求被挂起,等待队列攒到max_batch或等待时间超过max_wait才触发批量推理。深度学习框架在 batch 维度上的并行效率极高,32 条请求一起推理的耗时通常远小于 32 条单独推理的耗时总和。
这是个生产级优化手段,但调试难度也高。如果只是本地验证接口,直接使用无批处理版本的 FastAPI 就足够,先把正确性跑通再上性能优化。
5.3 API 模式请求的客户端测例
接口写好后,需要在本地验证服务确实在“按 API 模式请求”工作。用 curl 或 Python requests 发一条测试请求:
curl -X POST http://localhost:8000/predict \ -H "Content-Type: application/json" \ -d '{"text": "央行宣布下调存款准备金率0.5个百分点"}'预期返回的 JSON 中label_id对应“财经”。如果返回的是一串数字而非类别名称,说明你的label_names顺序和训练时的类别编码不一致。这是一个高频踩坑点:训练时Dataset.from_pandas会自动对字符串标签做编码,这个编码顺序可能与假设不一致。解决方法是训练时将标签映射写入 JSON 保存下来,推理时从文件加载而不是写死在代码里。
import json label_map = {i: name for i, name in enumerate(df["label"].astype("category").cat.categories)} with open("label_map.json", "w", encoding="utf-8") as f: json.dump(label_map, f, ensure_ascii=False)部署时读入label_map.json,从模型输出的索引精确反查类别名,彻底规避硬编码问题。
6. 训练与推理全链路的常见坑与验证技巧
6.1 显存溢出(CUDA Out of Memory)的排查路径
显存不足是 BERT 微调最常遇见的错误。报错信息CUDA out of memory出现时,按以下顺序排查:
os.environ["CUDA_LAUNCH_BLOCKING"] = "1"设置这个环境变量可以定位到具体是哪个操作触发了溢出,但会让训练变慢。实用方法是从小到大调整参数:先把batch_size降到 4 或 8,确认可以训练后再逐步增加。如果 batch size 必须保持 32,另一个方案是使用梯度累积:
from transformers import Trainer training_args = TrainingArguments( per_device_train_batch_size=8, gradient_accumulation_steps=4, )gradient_accumulation_steps=4表示每 8 条样本计算一次梯度,攒 4 次更新一次参数,等效 batch size 为 32。梯度累积的实现方法是参数只更新一次,但梯度在多次 backward 中累加。这里有个不易察觉的坑:BatchNorm 层在累积模式下行为与真实大 batch 不同,但 Transformer 用的是 LayerNorm,不受此影响,所以 BERT 微调中可以放心使用。
6.2 数据泄漏:乱序划分带来的虚假高分数
新闻数据按时间或按序列入库,如果直接用train_test_split不设shuffle=True,前 80% 作为训练集、后 20% 作为测试集,会形成“时间泄漏”——模型见过 2023 年的表述习惯,在 2024 年的新表述上准确率断崖式下滑。
train_test_split(df, test_size=0.1, shuffle=True, stratify=df["label"], random_state=42)shuffle=True是 sklearn 默认行为,但在自定义划分(比如按行号切片)时容易被忽略。验证方法是看训练集和测试集的标签分布是否一致,以及测试集准确率是否显著低于验证集。如果差幅超过 5 个百分点,先怀疑划分问题而不是模型问题。
6.3 混淆矩阵驱动的类别合并决策
单看整体准确率会掩盖类别间差异。绘制混淆矩阵,观察相似类别间的具体错判:
from sklearn.metrics import confusion_matrix, classification_report import matplotlib.pyplot as plt import numpy as np cm = confusion_matrix(test["label"].values, preds) print(classification_report(test["label"].values, preds, target_names=label_names)) plt.matshow(cm, cmap="Blues", alpha=0.8) plt.colorbar() labels = range(len(label_names)) plt.xticks(labels, label_names, rotation=45) plt.yticks(labels, label_names) for i in range(cm.shape[0]): for j in range(cm.shape[1]): plt.text(j, i, str(cm[i, j]), ha="center", va="center") plt.show()新闻分类中的典型混淆是“科技”与“财经”——一家互联网公司的财报新闻既涉及科技又涉及财经。你可能发现把“科技”和“财经”合并为一个类别后,模型准确率反而上升了几个点。这个决策不是模型能帮你做的,而是要有明确的业务定义:这篇新闻到底该归入哪个类,边界情况如何裁定。
从训练到部署,这个项目的每个环节都可以独立深挖。先把固定流程跑通,比如用bert-base-chinese在默认参数下拿到一个基线准确率,再去逐个尝试hfl/chinese-roberta-wwm-ext、不同max_length、不同 batch size。只有基线在手,后续每次改动才有对比基准。我最后常做的一件事是把一个测试集样本连同模型的 attention 权重可视化出来,观察模型在哪些字上分配了更高的注意力分数,这往往能直接告诉你数据预处理的下一步该往哪里改进。
本文还有配套的精品资源,点击获取