简介:面向毕业设计与自然语言处理实践的一站式源码项目,基于BERT预训练模型完成新闻文本分类任务,适合计算机、人工智能、通信工程、自动化等专业学生作为毕设、课程设计或项目初期演示参考。项目覆盖了从数据采集、清洗、标注到模型训练、评估与预测的完整流程,既提供新闻爬虫(网易、新浪、人民网等),也内置训练/测试Excel数据集与预训练模型配置,可快速启动训练;同时带有Web前后端,可将预测结果可视化展示,便于答辩演示与二次开发。
包体共72个文件,以Python脚本为核心,涵盖模型训练、数据处理、单条预测、批量预测等模块,辅以Vue前端、Node.js后端、JSON配置文件及Excel数据集等,压缩包约39.26MB,目录结构清晰,便于按功能检索。目前已有614人学习下载,适合具备一定Python基础、希望系统掌握BERT文本分类全流程的开发者参考借鉴。
1. BERT新闻文本分类:毕业设计源码包到底解决什么问题
新闻文本分类是NLP里最常拿来练手、也最容易在毕业设计答辩现场讲清楚的任务。标题里的“基于BERT构建新闻文本分类模型”,本质上就是让模型读一段新闻文字,判断它属于体育、财经、娱乐还是其他类别;相比朴素贝叶斯、TextCNN这些老方案,BERT靠预训练阶段的上下文语义表示,在长文本和语义相近的类别之间往往能拉开几个点的准确率。这套“python源码.zip”适合两类人:一是想快速把BERT跑通、拿到结果写论文的本科生,二是刚开始接触HuggingFace Transformers和PyTorch、需要一份完整工程模板的初学者。这篇文章顺着数据准备、模型搭建、训练调参、避坑排查到答辩交付,把这条完整链路讲透。
2. 数据准备:把原始新闻语料转成BERT能吃的输入格式
2.1 中文新闻数据集怎么选:THUCNews、cnews与自建语料的取舍
做新闻分类的第一件事不是写模型,而是确认用什么数据。常见的公开做法是直接使用THUCNews,这是清华大学整理的新浪新闻数据集,里面常见一个子集叫cnews,包含10个类别:体育、财经、房产、家居、教育、科技、社会、时尚、游戏、娱乐。每行数据是“标签 + 制表符 + 标题和正文”,例如:
体育 曝恒大报价巴甲神锋 对方欲卖哲科(图) 财经 央行公开市场操作 投放流动性这个格式用pandas读入非常方便。我一般会建议毕业设计直接用这种tsv或txt,不要用Excel导出的xlsx,因为编码和空格问题会浪费大量时间。读入时列名提前定好,后面模型和DataLoader都拿这两个字段说话,不要中途改名字。
import pandas as pd def load_cnews(path: str) -> pd.DataFrame: # cnews原始文件是"标签\t文本",没有表头 df = pd.read_csv( path, sep="\t", header=None, names=["label", "text"], encoding="utf-8", engine="python", ) return df full_df = load_cnews("data/cnews.train.txt") print(full_df["label"].value_counts())这里的逻辑说明:sep="\t"对应cnews的原始字段分隔方式,engine="python"可以避免混合分隔符带来的报警告;names=["label", "text"]是给两列固定命名,这个命名会贯穿后续所有代码。另一个关键点是中文BERT是字级别模型,跟传统方法不一样,它不依赖jieba分词,HuggingFace的tokenizer内部自带中文字表,会把“新闻”按字切分并映射到token id。很多第一次写BERT代码的人还抱着jieba不放,这在BERT流程里不是必须的,反而可能引入分词错误。
2.2 文本清洗与标签编码:按行洗、过滤空文本
公开数据集的文本质量总体不错,但里面仍然混着HTML标签、全角空格和连续空白。清洗这一步不做好,后面训练时会冷不丁冒出“空文本”报错,而且这种报错最难查。清洗逻辑要克制,不要用一堆复杂的正则去“美化”文本,BERT不关心排版,只关心字符序列是否完整。
import re def clean_text(text: str) -> str: if not isinstance(text, str): return "" # 去 HTML 标签 text = re.sub(r"<[^>]+>", "", text) # 全角空格转普通空格 text = text.replace(" ", " ") # 多个空白压缩成一个 text = re.sub(r"\s+", " ", text).strip() return text full_df["text"] = full_df["text"].map(clean_text) # 长度太短的文本几乎没有分类信息,直接丢掉 full_df = full_df[full_df["text"].str.len() >= 10] full_df = full_df.reset_index(drop=True)清洗之后立刻做一次长度过滤,这是血泪经验。BERT遇到长度不足的句子会把大部分位置padding成[PAD],如果一条文本只剩两三个字,模型基本是在猜类别,对训练没有任何帮助。过滤阈值设在10到20个字之间都可以,这里选10是为了保留更多样本。
类别标签需要转成整数id。常见做法是手写字典,但类别一多就容易写错位,我习惯用LabelEncoder,它天然维护了类别和id之间的双向映射:
from sklearn.preprocessing import LabelEncoder le = LabelEncoder() full_df["label_id"] = le.fit_transform(full_df["label"]) # 打印映射关系,确保后续predict阶段可以正确还原 label_map = dict(zip(le.classes_, le.transform(le.classes_))) print(label_map)这段代码的逻辑是:fit_transform自动按字母序给类别分配数字id,label_map保存映射关系。训练完做预测时,模型输出的是id,要展示成“体育”“财经”这类名字,直接le.inverse_transform([pred_id])就能还原,不需要自己维护第二份字典。
2.3 训练/验证/测试集划分:分层采样和随机种子一个都不能少
划分数据集看似简单,但新闻类别天然分布不均,如果不做分层采样,样本量少的类别可能在验证集里直接消失。直接用train_test_split的stratify参数可以解决,同时固定random_state=42,保证每次切分结果一模一样,这对答辩时复现结果非常重要。
from sklearn.model_selection import train_test_split # 先按8:2切出测试集,再把剩余部分按1:1切成验证集和训练集 train_df, tmp_df = train_test_split( full_df, test_size=0.2, random_state=42, stratify=full_df["label"] ) valid_df, test_df = train_test_split( tmp_df, test_size=0.5, random_state=42, stratify=tmp_df["label"] ) for split_name, split_df in [("train", train_df), ("valid", valid_df), ("test", test_df)]: split_df.to_csv(f"data/{split_name}.csv", index=False, encoding="utf-8-sig")参数说明:test_size=0.2等于从全量数据里先拿出20%当测试集;剩下80%再对半切,得到40%训练集和40%验证集。实际训练时如果数据量很大,可以调整成8:1:1甚至9:0.5:0.5。stratify按label列分层,确保每个类别在三个集合中的比例和全量一致。保存成csv用utf-8-sig编码是为了方便用Excel打开检查,训练脚本读取时用utf-8-sig或utf-8都能兼容。
提示:清洗和过滤必须放在划分之前。如果先切分再过滤,训练集和验证集分布会不一致,早停和最终评估都会失真。
3. 源码包结构拆解:BERT模型、tokenizer与分类头的衔接
3.1 工程目录怎么组织:一个能答辩、能扩展的python源码布局
拿到“python源码.zip”之后,第一件事应该是看目录结构。一个结构清晰的BERT新闻分类工程,解压出来通常长这样:
bert_news_classifier/ ├── config.py # 所有超参数集中管理 ├── data_loader.py # Dataset与DataLoader ├── model.py # BERT + 分类头封装 ├── train.py # 训练主循环 ├── predict.py # 单条/批量预测 ├── utils.py # 评估指标、早停、模型保存 ├── requirements.txt └── data/ ├── train.csv ├── valid.csv └── test.csvconfig.py单独拎出来不是形式主义。BERT微调的超参数很多,学习率、warmup比例、max_len、batch_size、epochs全部集中在同一个文件里,调参时不用钻进train.py里翻代码。data_loader.py负责把上一章做好的csv变成PyTorch的Dataset和DataLoader,model.py只做一件事——加载BERT并接上分类头。这样分层之后,答辩时老师问“数据怎么处理的”“模型怎么加载的”“训练循环在哪里”,每一块都能指到明确文件。
3.2 加载BERT:直接用BertForSequenceClassification,不要自己拼分类头
模型加载是整个bert模型实操里最套路化的一步。HuggingFace已经封装好了BertForSequenceClassification,它内部就是BERT encoder加一个Dropout加一个线性分类头,输出维度等于类别数。
from transformers import BertTokenizer, BertForSequenceClassification model_name = "bert-base-chinese" tokenizer = BertTokenizer.from_pretrained(model_name) model = BertForSequenceClassification.from_pretrained( model_name, num_labels=10, hidden_dropout_prob=0.1, )逻辑说明:from_pretrained会从模型仓库拉取预训练权重,第一次执行会下载几百MB的文件,之后会缓存在本地。num_labels=10对应cnews的10个类别,这个数字必须和le.classes_的数量一样,否则最后一层线性层尺寸对不上。hidden_dropout_prob=0.1是BERT默认的dropout比例,训练后期如果发现过拟合严重,可以调到0.2甚至0.3再跑一轮对比。
这里有个常见的纠结:是用BertModel自己写forward、自己加池化和全连接层,还是直接用封装好的BertForSequenceClassification?我的建议是毕业设计直接选后者。自己拼分类头多出来的代码不产生任何收益,反而容易在pooling策略上翻车——cls向量直接用还是拼接、要不要平均池化,每个选择都可能让指标掉一两个点。
3.3 从文本到input_ids:tokenize、padding、truncate的完整链路
BERT不能直接吃字符串,必须把文本转成三个东西:input_ids、attention_mask,还有单句分类用不到的token_type_ids。这里用tokenizer一次性完成截断和padding。
import torch from torch.utils.data import Dataset class NewsDataset(Dataset): def __init__(self, df, tokenizer, max_len=128): self.texts = df["text"].tolist() self.labels = df["label_id"].tolist() self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encoding = self.tokenizer( self.texts[idx], truncation=True, padding="max_length", max_length=self.max_len, return_tensors="pt", ) return { "input_ids": encoding["input_ids"].squeeze(0), "attention_mask": encoding["attention_mask"].squeeze(0), "labels": torch.tensor(self.labels[idx], dtype=torch.long), }参数说明:truncation=True会把超过max_len的文本直接截断,保留前128个token;padding="max_length"会把短文本pad到统一长度,这样DataLoader才能把一个batch堆成整齐的二维张量;return_tensors="pt"返回PyTorch张量格式。squeeze(0)很重要,因为tokenizer返回的形状是[1, seq_len],多了一个batch维度,squeeze掉才能变成[seq_len]。
这个Dataset的写法是通用的,换成英文新闻、换成长文本分类,只需要改max_len和读入的列名。max_len=128对中文新闻标题加正文来说是够用的,正文太长时截断反而能去掉无关信息;如果数据集中长文本占比很高,可以调到256,但显存占用会同步上涨。
4. 训练与调参:从baseline到指标提升的完整参数清单
4.1 优化器与学习率:为什么BERT微调默认AdamW加2e-5
BERT微调的优化器选择几乎没有悬念:AdamW。它是在Adam基础上修正了权重衰减的实现方式,HuggingFace的Trainer和绝大多数开源代码都用它。学习率是整个训练里最敏感的参数,BERT原文在微调时用的是2e-5,这个值在绝大多数文本分类任务上都能直接作为baseline。
from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps, )weight_decay=0.01是AdamW论文里的推荐值,可以抑制微调后期权重过度增长。total_steps是训练总步数,由“每个epoch的batch数 × epoch数”算出。num_warmup_steps设为总步数的10%,意思是前10%的训练步学习率从0线性升到2e-5,再逐步衰减到0。这个warmup机制能避免预训练权重在训练一开始就被过大的梯度冲坏。
学习率如果换成3e-5或5e-5,训练速度会快一点,但验证集F1可能波动更大;如果降到1e-5,收敛会变慢但更稳。毕业设计想快速出结果,我一般直接用2e-5跑第一版,后续再根据loss曲线微调。
4.2 batch_size与max_len怎么给:一张显存参数表解决选择困难
batch_size和max_len直接决定显存占用。BERT base模型本身约400MB,训练时还要存梯度和优化器状态,实际占用往往是模型参数的好几倍。跑之前先看一眼自己的显卡,再对照下面这张表选初始参数。
GPU显存 推荐batch_size 推荐max_len 备注 4G 4 128 建议开fp16混合精度 6G 8 128 梯度累积2步等效batch=16 12G 16 128 最省事的组合 12G 8 256 长文本场景 24G 32 128 梯度累积可关掉 24G 16 256 兼顾长文本和安全这个表是经验值,不同显卡和不同数据长度会有浮动。如果显存不够,优先减batch_size而不是减max_len,因为截断对分类效果的影响比小batch更明显。还有一种组合是batch_size=8配合gradient_accumulation_steps=2,等效batch_size为16,显存占用却和8一样:
# config.py 里的关键参数 batch_size = 8 max_len = 128 gradient_accumulation_steps = 2 fp16 = Truegradient_accumulation_steps的作用是攒够两步的梯度再更新一次权重,等效于把batch_size放大。fp16混合精度需要turing架构以后的显卡,开启后显存占用几乎减半,训练速度还有提升;如果不确定显卡是否支持,可以先不开,跑通后再优化。
4.3 训练循环模板:scheduler、梯度裁剪、早停与模型保存
训练循环是整套源码的核心。BERT微调最常见的两个训练问题分别是loss突变为nan和验证指标不涨,处理办法分别对应梯度裁剪和早停。
from tqdm import tqdm from torch.cuda.amp import autocast, GradScaler model.to(device) scaler = GradScaler() best_f1 = 0.0 patience = 0 for epoch in range(epochs): model.train() for step, batch in enumerate(tqdm(train_loader)): batch = {k: v.to(device) for k, v in batch.items()} with autocast(): outputs = model(**batch) loss = outputs.loss / gradient_accumulation_steps scaler.scale(loss).backward() if (step + 1) % gradient_accumulation_steps == 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad() val_f1 = evaluate(model, valid_loader, device) if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), "outputs/best_model.pt") patience = 0 else: patience += 1 if patience >= 3: print("early stop") breakmax_norm=1.0的梯度裁剪是防nan的关键,它会将整个参数的梯度范数限制在1.0以内;一旦新闻文本里混入异常长或重复的句子,梯度爆炸的概率立刻上升,没有裁剪loss会直接变成nan。验证指标选择宏平均F1而不是accuracy,因为新闻类别样本不均衡,宏F1对每个类别一视同仁。patience=3表示验证F1连续3个epoch不涨就早停,节省时间也防止过拟合。保存模型只存state_dict不存整模型,生成的文件更小,加载时先建模型再load_state_dict,答辩演示时更灵活。
提示:
outputs.loss除以gradient_accumulation_steps是为了让累积梯度取平均而不是累加,否则学习率等效变大,训练容易震荡。
5. 避坑指南:跑BERT新闻分类最常见的6个报错与玄学问题
5.1 数据与分词坑:label没对齐、空文本直接崩
现象1:训练刚跑几百步,loss直接变成nan,之后一直不恢复。
原因:清洗环节漏了空文本。cnews原始数据里偶尔有几行只有标签、没有正文,清洗后text字段变成空字符串,BERT对空输入会输出垃圾向量,交叉熵计算后梯度异常放大。
解决:在划分数据集前加上长度过滤,把空文本和过短文本直接去掉:
full_df = full_df[full_df["text"].str.len() >= 10]另外检查清洗函数是否真的执行了,很多人写了clean函数却忘了map到DataFrame上。
现象2:加载学长或网上下载的源码后,训练报错“number of classes does not match”。
原因:源码里的num_labels和当前数据的类别数不一致。有的源码写死num_labels=10,但你用的可能是14类的THUCNews子集,或者数据里某些类别样本少被清洗掉了,类别数变成了9。
解决:在加载模型前打印len(label_map),动态设置num_labels:
num_labels = len(le.classes_) model = BertForSequenceClassification.from_pretrained( model_name, num_labels=num_labels )5.2 显存与CUDA坑:out of memory、device不一致
现象1:训练到中途报CUDA out of memory,但batch_size看起来不大。
原因:文本长度超过max_len后tokenizer没有截断,导致batch里某个样本特别长,整个batch的张量尺寸被拉大。另一个常见原因是训练循环里把每个batch的loss都append进一个list,list不断变长,显存被历史tensor占满。
解决:检查tokenizer有没有写truncation=True;检查代码里是否只保留当前step的loss,不要累积所有loss用于画图。显存不足时优先将batch_size减半,同时打开gradient_accumulation_steps。
现象2:报错Expected all tensors to be on the same device。
原因:模型在cuda上,但input_ids或attention_mask还在cpu上。常见于训练循环里只对input_ids做了.to(device),漏了attention_mask,或者预测脚本里模型刚load_state_dict后没有.to(device)。
解决:
batch = {k: v.to(device) for k, v in batch.items()} model.to(device)每次模型加载和每个循环里都写上这两行,不要省略。
5.3 训练效果坑:loss不降、过拟合、指标波动大
现象1:loss在几个epoch内一直不降,或者上下震荡。
原因:学习率太大,损失函数在陡峭区域反复横跳。很多人把BERT当成普通神经网络,学习率直接设1e-3,这在BERT微调里几乎必然发散。
解决:把学习率降到2e-5这个量级,并且确认warmup生效。如果用了多卡训练或梯度累积,学习率需要相应调整,等效batch变大,学习率也可以适度提高。
现象2:训练集F1趋近于1,验证集F1却在0.85左右徘徊,明显过拟合。
原因:epoch数太多,模型把训练集细节背下来了。新闻分类数据量通常几万条,BERT base的参数量上亿,过拟合本来就是常态。
解决:早停设patience=3到5;验证集F1不再提升就停。hidden_dropout_prob从0.1调到0.2再跑一轮对比。也可以用类别加权交叉熵,让小样本类别的梯度更大,缓解类别不均衡导致的“假过拟合”。
现象3:同一个源码、同样的数据,两次训练结果差一两个点。
原因:没有固定随机种子。PyTorch的DataLoader随机打乱、Dropout的随机mask、CUDA的非确定性算法都会造成波动。
解决:脚本最开头固定所有随机源:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True set_seed(42)这属于典型的“玄学”问题,但根因其实是随机源没控制住。固定种子后,同一份代码跑两次结果应该一模一样,答辩时也能理直气壮地说“结果可复现”。
6. 评估与交付:混淆矩阵、实际预测和完整的答辩材料
6.1 混淆矩阵看分类短板:体育和游戏谁在打架
训练完成后,评估不能只看一个总准确率。用classification_report和混淆矩阵能看到每个类别的精确率、召回率,也能找到模型最容易混淆的类别对。
from sklearn.metrics import classification_report, confusion_matrix y_pred = [] y_true = [] model.eval() with torch.no_grad(): for batch in test_loader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) preds = outputs.logits.argmax(dim=-1) y_pred.extend(preds.cpu().tolist()) y_true.extend(batch["labels"].cpu().tolist()) print(classification_report(y_true, y_pred, target_names=le.classes_)) print(confusion_matrix(y_true, y_pred))argmax(dim=-1)是取最后一个维度上logits最大的索引,也就是模型预测的类别id。报告里的宏平均F1是答辩时最常被问到的指标,如果某两类互相误判严重,比如“游戏”和“娱乐”经常混在一起,可以在论文的分析部分解释为语义相关性过高、BERT对文本风格的区分能力有限,这是合理的分析而不是缺陷。
6.2 单条预测接口:把训练好的模型真正用起来
模型训练完要能对新新闻做预测,这也是导师演示时最直观的功能。
def predict_one(text: str): model.eval() encoding = tokenizer( text, truncation=True, padding="max_length", max_length=128, return_tensors="pt", ) encoding = {k: v.to(device) for k, v in encoding.items()} with torch.no_grad(): logits = model(**encoding).logits pred_id = logits.argmax(-1).item() return le.inverse_transform([pred_id])[0]这条接口的预测过程和训练时完全一致,padding="max_length"保证输入长度固定,模型接到的张量形状和训练时相同。inverse_transform([pred_id])把数字id还原成“体育”“娱乐”这样的中文类别名。预测时不需要算梯度,用torch.no_grad()包起来,省显存也提速。
6.3 一个交付习惯:模型文件、README、复现说明和基线对比要齐全
最后说一个我自己的交付习惯。毕业后源码包不能只丢一个train.py就完事,至少要补三样东西:训练好的best_model.pt、README.md、以及一份基线对比记录。基线可以简单跑一个TextCNN或逻辑回归,把准确率和F1跟BERT放在同一张表里,BERT高出的那几个点就是论文里最核心的卖点。
我当年交源码时犯过最蠢的错误,是README里写了“运行train.py自动下载BERT模型”,结果答辩演示环境没有外网,加载模型时直接崩。从那以后我习惯把模型文件单独放进model/bert-base-chinese/目录,代码里优先从本地目录加载,找不到再走联网下载:
model_name = "./model/bert-base-chinese"这个习惯救过我多次。还有一件小事:源码包里的epoch数、随机种子、数据集说明都写进README,别人拿到zip后能一步步复现出你的数字,“不能复现”在答辩时是致命的。这套流程走下来,数据、模型、训练、避坑、评估每一步我都踩过至少一遍,希望帮到你。
本文还有配套的精品资源,点击获取