简介:这份资源面向计算机、人工智能及相关专业的在校学生与教师,提供一套可直接运行的新闻文本分类完整方案,适合作为毕业设计、课程设计或项目立项的参考实现。项目以BERT预训练模型为核心,覆盖数据预处理、模型训练、单条与批量预测等环节,并附带爬虫脚本与Web演示界面,便于理解从数据采集到分类推理的全流程。压缩包共72个文件,约39.26MB,包含16个Python脚本承担训练与推理逻辑,21个JavaScript与4个Vue文件支撑前后端交互,另有json配置、xlsx数据集及说明文档等,结构清晰、模块分明。目前已有615人学习下载。读者可据此掌握BERT文本分类的工程落地方式,并在此基础上修改以适配其他分类任务。
1. 新闻文本分类为什么总在长尾类别上翻车
做毕业设计选新闻文本分类,很多人第一反应是拿朴素贝叶斯或 TextCNN 跑个准确率交差。但真把数据摊开看,体育、财经这类头部类别能到 95% 以上,可一旦碰上"法治""农业""环保"这种样本量只有几百条的长尾类别,F1 直接掉到 0.4 上下。这不是模型不行,是词袋和静态词向量根本抓不住"碳中和""碳达峰"这类新词在上下文里的语义漂移。
BERT 这类预训练语言模型的价值就在这:它用海量通用语料先学好了词与词之间的上下文关系,你只需要在新闻分类任务上做一次轻量微调,长尾类别的召回率通常能比 TextCNN 高出 15 到 25 个百分点。这篇笔记面向的是正在做毕业设计、手头有 Python 环境但不确定怎么把 BERT 跑通的同学,也适合已经跑过 baseline 想换模型的从业者。我会按"数据准备 → 模型微调 → 训练排错 → 推理部署"的顺序,把每一步的命令、参数和踩坑点讲清楚,代码可以直接抄。
2. 从原始新闻语料到 BERT 能吃的格式:数据管道怎么搭
2.1 先搞清楚你的数据长什么样
新闻分类的数据集通常有两种来源:一种是爬下来的原始文本,字段可能是title、content、category;另一种是已经切好词的公开语料。不管哪种,BERT 需要的是"一段文本 + 一个整数标签"的配对。常见做法是先把标题和正文拼接,中间用[SEP]隔开,但要注意 BERT 的最大输入长度是 512 个 token,新闻正文动辄上千字,直接截断会丢掉关键信息。
我一般会先做一轮长度统计,看看有多少样本超过 512。如果超过 30%,就考虑只取标题加正文前 200 字,或者用滑动窗口切分后取平均池化。下面这段代码用来统计长度分布并生成标签映射:
import pandas as pd from transformers import BertTokenizer # 加载原始数据,假设字段为 title, content, category df = pd.read_csv("news_raw.csv") df["text"] = df["title"].fillna("") + "。" + df["content"].fillna("") # 标签映射:把中文类别名转成 0,1,2... label_list = sorted(df["category"].unique()) label2id = {label: idx for idx, label in enumerate(label_list)} df["label"] = df["category"].map(label2id) # 用 bert-base-chinese 的分词器统计 token 长度 tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") lengths = [len(tokenizer.encode(t, max_length=512, truncation=True)) for t in df["text"]] print("超过 512 token 的样本占比:", sum(l > 512 for l in lengths) / len(lengths)) print("标签映射:", label2id)这段代码的关键在tokenizer.encode的truncation=True,它会自动截断到 512。但统计时不要截断,否则你看到的长度全是 512,失去参考意义。label2id的顺序必须固定,后面训练和推理要共用同一份映射,建议存成 JSON 文件。
2.2 构建 Dataset 和 DataLoader 的四个参数
PyTorch 的Dataset类需要实现__len__和__getitem__。这里最容易翻车的是attention_mask的处理:padding 的位置必须置 0,否则模型会把填充符当成真实 token 参与注意力计算。下面是一个可直接复用的实现:
import torch from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer class NewsDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=256): self.texts = texts self.labels = labels 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], max_length=self.max_len, padding="max_length", truncation=True, return_tensors="pt" ) return { "input_ids": encoding["input_ids"].squeeze(0), "attention_mask": encoding["attention_mask"].squeeze(0), "token_type_ids": encoding["token_type_ids"].squeeze(0), "label": torch.tensor(self.labels[idx], dtype=torch.long) } tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") dataset = NewsDataset(df["text"].tolist(), df["label"].tolist(), tokenizer, max_len=256) loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=2)max_len设 256 是折中方案:新闻标题加前 200 字通常能覆盖核心信息,显存占用也比 512 低一半。batch_size=16在 8GB 显存的卡上比较稳,如果报 OOM 就降到 8。num_workers在 Windows 上建议设 0,否则容易卡在数据加载。
2.3 划分训练集和验证集时别按顺序切
新闻数据往往按时间或来源排列,如果直接取前 80% 做训练、后 20% 做验证,验证集可能全是某个类别的样本,导致评估失真。正确做法是用train_test_split的stratify参数按标签分层:
from sklearn.model_selection import train_test_split train_texts, val_texts, train_labels, val_labels = train_test_split( df["text"].tolist(), df["label"].tolist(), test_size=0.2, random_state=42, stratify=df["label"] )stratify保证每个类别在训练集和验证集中的比例一致。random_state固定后结果可复现,毕业设计答辩时能省去很多解释成本。
3. 微调 BERT 做新闻分类:模型结构、训练循环与参数设置
3.1 在 BERT 顶上接一个分类头
BERT 的输出是每个 token 的 768 维向量,做分类需要取[CLS]位置的向量,再经过一个全连接层映射到类别数。下面这个模型类把预训练模型和分类头封装在一起:
import torch.nn as nn from transformers import BertModel class BertNewsClassifier(nn.Module): def __init__(self, num_classes, dropout=0.3): super().__init__() self.bert = BertModel.from_pretrained("bert-base-chinese") self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(768, num_classes) def forward(self, input_ids, attention_mask, token_type_ids): outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids ) pooled = outputs.pooler_output # [CLS] 经过线性层和 tanh 后的结果 return self.classifier(self.dropout(pooled))dropout=0.3是经验值,新闻分类任务过拟合风险中等,0.2 到 0.5 之间都可以试。pooler_output是 BERT 自带的[CLS]变换结果,也可以手动取last_hidden_state[:, 0, :],两者差别不大。
3.2 训练循环里必须监控的三个量
训练 BERT 不像训练小模型,学习率要设得很小,通常 2e-5 到 5e-5。优化器用 AdamW,权重衰减设 0.01。下面是一个完整的训练循环:
from transformers import AdamW, get_linear_schedule_with_warmup from tqdm import tqdm device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = BertNewsClassifier(num_classes=len(label2id)).to(device) optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) epochs = 4 total_steps = len(loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps ) criterion = nn.CrossEntropyLoss() for epoch in range(epochs): model.train() total_loss, correct, total = 0, 0, 0 for batch in tqdm(loader): input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) token_type_ids = batch["token_type_ids"].to(device) labels = batch["label"].to(device) optimizer.zero_grad() logits = model(input_ids, attention_mask, token_type_ids) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() total_loss += loss.item() correct += (logits.argmax(dim=1) == labels).sum().item() total += labels.size(0) print(f"Epoch {epoch+1} | Loss {total_loss/len(loader):.4f} | Acc {correct/total:.4f}")这里有三个关键点:clip_grad_norm_把梯度范数限制在 1.0,防止梯度爆炸;get_linear_schedule_with_warmup让学习率在前 10% 步数里线性上升再下降,这是 BERT 微调的标准做法;scheduler.step()必须放在optimizer.step()之后,顺序反了学习率会乱掉。
3.3 验证集评估要看分类报告而不是只看准确率
训练完每个 epoch 后要在验证集上跑一遍,用classification_report看每个类别的精确率、召回率和 F1。长尾类别的 F1 才是决定毕业设计能不能拿高分的关键:
from sklearn.metrics import classification_report model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch in val_loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) token_type_ids = batch["token_type_ids"].to(device) logits = model(input_ids, attention_mask, token_type_ids) all_preds.extend(logits.argmax(dim=1).cpu().numpy()) all_labels.extend(batch["label"].numpy()) print(classification_report(all_labels, all_preds, target_names=label_list))如果某个类别 F1 低于 0.5,先别急着调模型,回去看训练样本是不是少于 100 条。少于 100 条时,BERT 也救不了,需要考虑数据增强或者合并类别。
4. 训练过程中最常见的五类翻车与排查路径
4.1 Loss 不下降反而震荡
现象:训练头两个 epoch loss 在 2.3 附近来回跳,准确率跟随机猜差不多。原因通常是学习率太大或者数据标签没对齐。先检查label2id和df["label"]是否一致,再确认lr是不是误设成了 1e-3。解决:把学习率降到 2e-5,重新跑一个 epoch 看 loss 是否稳定下降。
4.2 验证集准确率远低于训练集
现象:训练集准确率 0.98,验证集只有 0.72。这是典型过拟合。原因可能是训练轮数太多或者 dropout 太小。解决:把epochs从 4 降到 3,dropout从 0.3 提到 0.5,同时加早停策略——验证集 F1 连续两个 epoch 不提升就停。
4.3 显存溢出(CUDA out of memory)
现象:跑几个 batch 后报 OOM。原因通常是max_len或batch_size太大。解决:先把batch_size减半,如果还不行就把max_len从 256 降到 128。另外检查num_workers是否设得过高,Windows 上设 0 最稳。
4.4 中文分词器报 KeyError
现象:加载bert-base-chinese时提示找不到 vocab 文件。原因一般是网络问题导致模型没下载完整。解决:手动从镜像站下载pytorch_model.bin、vocab.txt、config.json三个文件放到本地目录,然后用BertTokenizer.from_pretrained("./local_bert")加载。
4.5 推理时预测结果全是同一个类别
现象:模型在测试集上所有样本都预测成"体育"。原因可能是训练时类别极度不均衡,模型学会了偷懒。解决:在CrossEntropyLoss里加weight参数,给少数类更高的权重:
from sklearn.utils.class_weight import compute_class_weight import numpy as np class_weights = compute_class_weight("balanced", classes=np.unique(train_labels), y=train_labels) weights = torch.tensor(class_weights, dtype=torch.float).to(device) criterion = nn.CrossEntropyLoss(weight=weights)balanced模式会根据类别频率自动计算权重,频率越低权重越高。这一步对长尾类别 F1 的提升通常有 5 到 10 个百分点。
5. 把模型推到能用的程度:推理封装与效果验证
5.1 写一个单条文本预测函数
训练完的模型要能对外提供服务,最少得有一个接收字符串、返回类别和置信度的函数:
def predict(text, model, tokenizer, label_list, max_len=256): model.eval() encoding = tokenizer( text, max_length=max_len, padding="max_length", truncation=True, return_tensors="pt" ) with torch.no_grad(): logits = model( encoding["input_ids"].to(device), encoding["attention_mask"].to(device), encoding["token_type_ids"].to(device) ) probs = torch.softmax(logits, dim=1) pred_id = probs.argmax(dim=1).item() return label_list[pred_id], probs[0][pred_id].item() # 测试 text = "某地新增三个新能源汽车充电站,预计年底投入使用" label, confidence = predict(text, model, tokenizer, label_list) print(f"预测类别:{label},置信度:{confidence:.4f}")这个函数里padding="max_length"和训练时保持一致,否则attention_mask的长度对不上会报错。torch.no_grad()关闭梯度计算,推理速度能快一倍。
5.2 用混淆矩阵定位系统性错误
单看准确率不够,混淆矩阵能告诉你模型把哪些类别搞混了。比如"财经"和"科技"经常互相误判,因为两个领域的新闻都涉及"公司""融资""产品"这些词。下面这段代码生成混淆矩阵:
from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt cm = confusion_matrix(all_labels, all_preds) plt.figure(figsize=(10, 8)) sns.heatmap(cm, annot=True, fmt="d", xticklabels=label_list, yticklabels=label_list, cmap="Blues") plt.xlabel("预测类别") plt.ylabel("真实类别") plt.title("新闻分类混淆矩阵") plt.tight_layout() plt.savefig("confusion_matrix.png", dpi=150)如果发现某两个类别互相误判严重,可以考虑在特征层面加关键词规则做后处理,或者把这两个类别的样本拿出来做针对性增强。
5.3 保存和加载模型的正确姿势
毕业设计最后要交源码,模型保存不能只存state_dict,还要把标签映射和分词器一起打包:
import os, json, torch save_dir = "bert_news_model" os.makedirs(save_dir, exist_ok=True) torch.save(model.state_dict(), os.path.join(save_dir, "model.pt")) tokenizer.save_pretrained(save_dir) with open(os.path.join(save_dir, "label2id.json"), "w", encoding="utf-8") as f: json.dump(label2id, f, ensure_ascii=False, indent=2) # 加载时 model = BertNewsClassifier(num_classes=len(label2id)) model.load_state_dict(torch.load(os.path.join(save_dir, "model.pt"), map_location=device)) model.to(device) tokenizer = BertTokenizer.from_pretrained(save_dir)map_location=device保证在 CPU 上也能加载 GPU 训练的权重。label2id.json用ensure_ascii=False保存,中文类别名不会变成乱码。
5.4 一个提升长尾类别的小技巧
如果时间允许,可以在微调前先用训练集做一轮掩码语言模型的自适应预训练,让 BERT 先适应新闻领域的用词习惯。具体做法是用BertForMaskedLM在训练集上跑 1 到 2 个 epoch,学习率设 5e-5,然后再接分类头微调。这个技巧对长尾类别的 F1 通常还有 3 到 5 个百分点的提升,代价是多花半小时训练时间。
我自己做这类任务时有个习惯:每次改完参数先跑一个 epoch 看 loss 曲线,确认下降趋势正常再跑完整训练。这样能省下大量等结果的时间,也避免跑了一晚上发现参数设错。希望帮到你。
本文还有配套的精品资源,点击获取