
简介一份面向自然语言处理初学者的完整实验资料以《2021医学健康数据分析与挖掘》课程论文为背景围绕BERT模型在20NewsGroups数据集上的新闻分类任务提供从数据预处理、模型微调到评估优化的全流程实现。压缩包共18个文件包含5个Python源码模型构建、数据加载、配置与工具模块、5个文本数据文件、训练日志、项目说明及课程论文PDF整体大小14.42MB结构清晰便于对照学习。资料展示了如何利用PyTorch和Transformers库构建分类模型涵盖Tokenization、[CLS]标记输出、交叉熵损失与Adam优化等关键环节测试集上可复现准确率、精确率、召回率和F1分数等指标。已有503人学习下载适合希望快速上手BERT文本分类、完成课程设计或拓展医学文本挖掘实践的读者。1. 这个课程论文很多人做过但我为什么还推荐你写搜“数据挖掘课程论文”时20NewsGroups几乎是被提到最多的数据集量级不大、类别清楚、sklearn还自带下载接口看起来很适合摸鱼。但真正把“基于BERT的20NewsGroups数据集新闻分类实验”当作题目后第一步拦住大多数人的反而不是BERT模型有多难而是数据集怎么接进模型、预训练参数怎么落盘、训练完怎么评估。这篇文章按我完整跑过一轮的结课实验流程来写不跳步也不假设你已经搭好环境。它适合正在写课程论文的人、打算用预训练模型做文本分类练手的开发者以及想知道BERT数值实验从哪下手的入门者。2. BERT的文本表示机制与20NewsGroups数据集结构先搞懂这两个主角动手之前至少要把两件事说清楚BERT怎么把新闻文本变成向量20NewsGroups又到底想让你分什么类。这两个问题不解决后面很难判断代码里的一个小改动是优化还是玄学。2.1 BERT为什么让新闻分类不再怕一词多义传统新闻分类里TF-IDF加朴素贝叶斯是标配。它的原理很简单统计每个词在当前文档里出现的频率再用逆文档频率打压“the”“and”这类普遍出现的词最后把每个新闻表示成一个稀疏向量。问题也出在这里——同一个词不管出现在什么上下文里它的特征权重都一样。比如“Trump”在“Trump warns”和“Trump card”里是同一个向量模型只能靠周围其它词来猜猜错的概率不低。Word2Vec比TF-IDF进了一步它把每个词映射成一个固定向量但一个词仍然只有一个向量。英文新闻里大量存在一词多义和同义改写固定向量解决不了这个问题。BERT的思路完全不同每个词在进入模型后会通过多层Transformer的注意力机制反复“看”它前后的词最终得到的是当前这句话里的动态向量。同样是“Trump”在“Donald Trump said”和“taught Trump something”两种语境里最后一层输出的向量差异很明显。这个差异对新闻分类是决定性的。20NewsGroups讨论的是真实新闻组里的帖子标题、正文、引用混在一起一个词在不同主题下含义不同模型必须依赖上下文而不是词表。BERT把预训练阶段学会的通用语法和语义知识迁移过来再在新闻数据集上做少量微调就能拿到比传统词向量好一大截的分类效果。这也是课程论文里把“数据挖掘”方法升级成“BERT”的核心逻辑换的不是数据集而是文本表示方式。2.2 20NewsGroups数据集20个类别与数据划分的不成文规矩20NewsGroups是公开新闻组语料的经典子集包含大约两万篇文档分20个新闻主题比如计算机相关的comp.、娱乐相关的rec.、科学相关的sci.*、政治相关的talk.*等。sklearn的fetch_20newsgroups接口可以直接拉取不需要手工去网站爬这对课程论文很友好。数据划分有一个不成文的惯例官方把18846篇分给训练集7532篇分给测试集按时间顺序切分而不是随机切分。为什么要强调时间顺序因为新闻组讨论会有主题漂移比如同一个话题在早期帖子里的词汇和后期不同。按时间切分能更真实地模拟“拿过去的数据训练预测未来的新闻”这个场景。课程论文里如果没有额外说明直接用subsettrain和subsettest就行不要自己随机重排否则答辩时老师按官方口径问两句就可能对不上。20个类别的样本量大致在500到1000之间整体比较均衡。这意味着你可以用整体准确率作为主要指标但后面会讲到有些类别语义太接近光看准确率会掩盖模型在细分类别上的偏置。数据挖掘实验里这种“看似均衡、实则有小类重叠”的数据集最适合练习看混淆矩阵。2.3 为什么选bert-base-uncased而不是更大更全的模型BERT官方发布了多个版本课程实验最常见的选择是bert-base-uncased。uncased的意思是模型会把所有大写字母转成小写同时去掉重音符号。对英文新闻来说这有一个小代价人名和专有名词的大小写信息会丢失。但换来的是词表更紧凑、训练更稳定对学生机器来说省下的显存和训练时间很实在。还有一个选项是bert-base-chinese但它针对中文语料预训练用在英文新闻上效果会打折扣。20NewsGroups是纯英文所以选uncased版本更合适。如果你去看HuggingFace上的模型仓库会发现bert-base-uncased的配置文件里写着12层Transformer、768维隐藏层、12个注意力头参数量大约1.09亿。这个规模用一张入门级显卡就能微调哪怕只有CPU也能跑完全程只是时间会久一点。bert-large-uncased参数翻了三倍多准确率确实更高但课程实验通常没有那个硬件预算。我的建议是先把base版本完整跑通如果时间富余再把bert-large-uncased作为对照组写进论文讨论区。这样既控制成本又能体现你对模型规模的思考。3. 从20NewsGroups到BERT输入预处理与最小可复现训练流程多数教程会在这一步直接贴模型代码但新闻分类真正翻车的地方全在数据准备。fetch_20newsgroups返回的是原始字符串里面混着邮件头、签名和引用正文直接拿去BERT训练模型学到的很可能不是新闻主题而是发件人ID。3.1 用fetch_20newsgroups拉数据remove参数为什么必须有下面这段代码是所有实验的第一步建议放在脚本最前面并固定随机种子。import random import numpy as np import torch from sklearn.datasets import fetch_20newsgroups random.seed(42) np.random.seed(42) torch.manual_seed(42) def load_newsgroups(subset: str train, clean: bool True): remove (headers, footers, quotes) if clean else () data fetch_20newsgroups(subsetsubset, removeremove) return data.data, data.target, data.target_names train_texts, train_labels, target_names load_newsgroups(train, cleanTrue) test_texts, test_labels, _ load_newsgroups(test, cleanTrue) print(f训练集样本数: {len(train_texts)}) print(f测试集样本数: {len(test_texts)}) print(f类别数: {len(target_names)})subset参数决定取训练集、测试集还是全集remove是一个元组可以包含headers邮件头、footers签名和quotes引用正文。我一般把clean设成True因为新闻组帖子里的签名和引用会泄露很多与主题无关的信息。举个例子某个类别的活跃发帖人经常在签名里写一句固定的格言模型只要记住这句话就能把帖子分对根本不用理解新闻内容。这在数据挖掘里叫特征泄漏会让训练集准确率虚高一上测试集就露馅。如果你想把数据整理成pandas DataFrame方便查看可以用pd.DataFrame({text: train_texts, label: train_labels})。这一步不是必须的但建议先打印几条样本看看去掉了什么。很多时候一个“明显不合理的误分类”回头一查就是数据没洗干净。3.2 用AutoTokenizer把新闻变成input_ids和attention_maskBERT不能直接吃字符串它需要两样东西input_ids和attention_mask。分词器负责把文本切碎再映射成词表里的数字ID。这里我建议用AutoTokenizer它不用你手动指定“应该用哪种分词算法”直接按模型名称加载即可。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(bert-base-uncased, cache_dir./bert_cache) print(f词表大小: {tokenizer.vocab_size}) print(fpadding token id: {tokenizer.pad_token_id}) print(fcls token id: {tokenizer.cls_token_id})cache_dir指定预训练模型下载的本地目录。我习惯每次都在代码里显式写这个参数防止模型文件被默认下载到用户主目录下后面换机器或清理环境时搞得一团糟。如果你之前已经通过HuggingFace下载过bert-base-uncased这里会直接命中缓存不会重复下载。真正编码时最常踩的坑是padding和truncation的取值。如果一个batch里每条新闻长度不同PyTorch的Tensor要求形状统一所以必须填充到一样长。我建议直接用固定长度max_length 256 encoded_train tokenizer( train_texts, max_lengthmax_length, paddingmax_length, truncationTrue, return_tensorspt ) print(encoded_train[input_ids].shape) print(encoded_train[attention_mask].shape)paddingmax_length会把每一条样本都填充到256的长度truncationTrue会把超过256的部分截掉return_tensorspt直接返回PyTorch张量。有些教程写paddingTrue意思是按当前batch里的最长样本填充这样每个batch长度不同DataLoader拼起来效率低而且后面做推理时容易因为长度不一致导致结果漂移。课程实验里统一用固定长度最省心。还需要留意BERT在句子开头会自动加上[CLS]标记分类任务最终只取这个位置的输出。分词器已经自动做了这件事你不用手动加。3.3 用PyTorch写一个能跑通的微调循环先把数据集包成PyTorch的Dataset再交给DataLoader做批处理。这是数据挖掘实战里比较标准的流程和图像分类的写法几乎一样。from torch.utils.data import Dataset, DataLoader class NewsDataset(Dataset): def __init__(self, input_ids, attention_mask, labels): self.input_ids input_ids self.attention_mask attention_mask self.labels labels def __len__(self): return len(self.labels) def __getitem__(self, idx): return { input_ids: self.input_ids[idx], attention_mask: self.attention_mask[idx], labels: torch.tensor(self.labels[idx], dtypetorch.long), } train_dataset NewsDataset(encoded_train[input_ids], encoded_train[attention_mask], train_labels) train_loader DataLoader(train_dataset, batch_size8, shuffleTrue)__getitem__返回的是字典PyTorch的DataLoader会自动把dict里的张量按batch维度叠起来。shuffleTrue很关键它让每个epoch的训练顺序都不同防止模型记住固定的batch顺序。然后是模型加载和训练循环部分。from transformers import AutoModelForSequenceClassification, AdamW from tqdm import tqdm device torch.device(cuda if torch.cuda.is_available() else cpu) model AutoModelForSequenceClassification.from_pretrained( bert-base-uncased, num_labels20, cache_dir./bert_cache ) model.to(device) optimizer AdamW(model.parameters(), lr2e-5) model.train() for epoch in range(3): total_loss 0 progress_bar tqdm(train_loader, descfEpoch {epoch 1}) for batch in progress_bar: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) optimizer.zero_grad() output model(input_idsinput_ids, attention_maskattention_mask, labelslabels) loss output.loss loss.backward() optimizer.step() total_loss loss.item() progress_bar.set_postfix(lossloss.item()) print(fEpoch {epoch 1} 平均损失: {total_loss / len(train_loader):.4f})AutoModelForSequenceClassification会在BERT顶部加一个线性分类头num_labels20告诉它输出20个类别的概率。AdamW是BERT微调最常用的优化器它比普通Adam多了一个解耦的权重衰减能缓解Transformer微调时容易出现的过拟合。学习率2e-5是BERT论文和transformers官方示例里的经验值比这个数值大一两个数量级会导致训练曲线震荡小太多则收敛过慢。我先用epoch3跑通再按实际曲线调整。如果你机器比较慢可以先用前2000条样本跑一个快速验证train_dataset NewsDataset( encoded_train[input_ids][:2000], encoded_train[attention_mask][:2000], train_labels[:2000] )这个技巧在课程论文里很值得用它能帮你在一两分钟内发现代码逻辑错误而不是等半小时后才发现模型输出了NaN。3.4 参数表batch_size、max_length、学习率与epoch的经验值下面这张表是我在类似课程实验里经常用的参数也是transformers官方文本分类示例的常见配置。它不是唯一答案但能让你第一次跑就稳定出结果。参数经验值说明batch_size8或16显存紧张时优先8全量数据训练也更稳定max_length256覆盖多数英文新闻正文512会显著增加显存占用learning_rate2e-5超过5e-5容易在Transformer上震荡epochs3课程实验常用2到5更多轮次容易过拟合优化器AdamW比SGD快比Adam更适合Transformer微调weight_decay0.01配合AdamW使用默认即可如果你用的是1080Ti或以上显卡batch_size可以试试16。如果显存只有6GB8更稳。max_length256对20NewsGroups的大部分新闻正文够用但某些长帖会被截断截断过多会丢失关键论据导致分类失败。遇到这种情况可以把max_length提到384同时把batch_size降到4两者是联动关系。4. BERT新闻分类的四个高频踩坑点与排查方法跑通一遍不难难的是每个跑过的人几乎都被同一批问题卡过。这一章列的是我在复现这个实验时实际遇到的四个高频问题按“现象 → 原因 → 解决”来写。4.1 预训练模型下载失败先查镜像再查缓存现象执行AutoTokenizer.from_pretrained或AutoModelForSequenceClassification.from_pretrained时程序卡住很久然后报连接错误或者下载到一半中断。原因BERT模型权重文件不算小bert-base-uncased的pytorch_model.bin体积接近420MB它托管在HuggingFace的模型仓库里。受当前网络环境影响访问这个仓库经常超时。这个问题和代码逻辑无关属于数据挖掘实验里最容易被低估的一类环境问题。解决有两招。第一招是使用镜像源把HF_ENDPOINT环境变量指向镜像站后再运行代码。在命令行里执行export HF_ENDPOINThttps://hf-mirror.com然后重新跑脚本能解决大部分下载超时。第二招是手动下载模型文件。你需要拿到config.json、pytorch_model.bin、tokenizer.json、vocab.txt这几个文件放到本地某个目录比如./bert-base-uncased/然后把代码里的from_pretrained(bert-base-uncased)改成from_pretrained(./bert-base-uncased)。这个做法不用你自己去逐层重建模型transformers会按目录里的文件自动加载。排查建议在代码里加上cache_dir把下载过程收敛到项目目录内方便随时检查缓存是否完整。一旦下载成功后续运行不再依赖网络这也是为什么我每次都把cache_dir写死。4.2 CUDA out of memorybatch_size与max_length联动调整现象训练循环跑到第二个batch时直接报RuntimeError: CUDA out of memory有时连第一个epoch的一半都撑不过去。原因BERT的显存占用主要由三部分构成输入序列长度、batch大小、模型自身参数。bert-base-uncased的中间激活值会随max_length和batch_size线性增长256的长度加16的batch一张6GB显存的卡很容易爆掉。解决第一步把batch_size降到4如果还报错把max_length从256降到128。这两个参数是联动的不要只降一个。还有一个技巧是开启梯度累积保持batch_size为4但每4个batch累积一次梯度再更新参数等价于batch_size为16的效果显存负担却小得多。梯度累积的写法是把loss.backward()后面先不调用optimizer.step()累计到accumulation_steps次后再更新accumulation_steps 4 optimizer.zero_grad() for step, batch in enumerate(train_loader): loss model(...).loss loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()这里需要注意把loss除以累积步数是为了保证梯度总体量级和原始batch_size一致不除的话等效学习率会被放大训练容易发散。4.3 训练集准确率虚高先怀疑特征泄漏再怀疑过拟合现象训练集准确率达到99%以上测试集只有80%上下而且测试集准确率在第二个epoch后不再上升甚至开始下降。原因这个实验有两个常见成因。第一数据没清洗干净remove参数没设置或只去掉了headers签名和引用里的发帖人信息把类别“告诉”了模型。第二即便数据清洗了训练轮次过多也会让BERT记住训练集里的特定表达方式从而在测试集上泛化变差。解决先检查数据清洗逻辑把load_newsgroups里的clean设成True并手动打印几条样本确认签名已经被移除。如果数据没问题把epoch降到2或者在验证集上做早停每个epoch结束后保存当前模型验证集指标不再提升就停止训练。更稳妥的做法是每次训练都同时打印训练集和测试集准确率当两者的差距开始扩大时说明模型开始记忆训练数据而不是理解新闻主题。4.4 tokenizer与标签错位的隐蔽问题现象代码看起来完全正常训练几次后准确率很低而且每次跑输出的指标都不太一样甚至个别类别的precision为0。原因这类问题往往不在模型而在数据准备阶段。一种情况是训练集和测试集用了不同的tokenizer实例加载或者在中途重新from_pretrained了另一个模型导致两边的input_ids不是同一套词表。另一种情况是在构造Dataset时标签索引错位比如DataLoader返回的batch顺序被shuffle过但labels还是按原顺序取的。解决全局只初始化一次tokenizer并把它传给训练和评估两个流程。把标签列表封装进Dataset后不要再在外层做任何按索引的切片操作。如果确实需要对数据抽样就在构造Dataset之前完成抽样然后一次性传入。排查时可以用一个最简单的方法验证取训练集前4条样本打印input_ids[0][:10]对应的token再打印labels[0]看分词结果和类别编号是否能对上直觉。这个检查只要几秒钟却能省掉后面大量排查时间。5. 结果验证阶段混淆矩阵与macro-F1比整体准确率更值得看模型跑完3个epoch后很多模板会直接打印一行准确率。对20NewsGroups这么均衡的数据集来说整体准确率确实有参考性但如果你想在答辩里讲清模型到底学到了什么混淆矩阵是绕不开的第一步。5.1 混淆矩阵看类别偏置而不是只看acc数字先写一个简单的评估函数遍历完测试集后输出预测结果和真实标签from tqdm import tqdm def predict(model, data_loader, device): model.eval() predictions [] true_labels [] with torch.no_grad(): for batch in tqdm(data_loader, descEvaluating): input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) output model(input_idsinput_ids, attention_maskattention_mask) preds torch.argmax(output.logits, dim-1) predictions.extend(preds.cpu().tolist()) true_labels.extend(labels.cpu().tolist()) return predictions, true_labels这段代码有两个细节值得说明。model.eval()会关闭Dropout等训练期才启用的随机层让推理结果稳定torch.no_grad()告诉PyTorch不需要为反向传播保存中间梯度大幅降低显存占用。拿到预测后画混淆矩阵import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix cm confusion_matrix(true_labels, predictions) fig, ax plt.subplots(figsize(12, 10)) im ax.imshow(cm, cmapBlues) ax.set_xticks(range(len(target_names))) ax.set_yticks(range(len(target_names))) ax.set_xticklabels(target_names, rotation90) ax.set_yticklabels(target_names) ax.set_xlabel(Predicted Label) ax.set_ylabel(True Label) fig.colorbar(im) plt.tight_layout() plt.show()看混淆矩阵时我习惯先找矩阵中偏离对角线的亮色块。比如comp.windows.x和comp.os.ms-windows.misc这两类在词面上都跟Windows有关BERT容易把它们互相混淆那说明这两类文本本身语义边界就模糊属于数据本身的问题不是模型bug。另一种情况是某个类别的整行颜色都很浅那说明这类样本量少或特征不突出模型基本没学会需要回到训练数据里看这个类别是否有足够多的独特术语。5.2 训练过程曲线用matplotlib记录loss与acc课程论文里训练曲线比最终指标更能说明问题。把每个epoch的平均损失存下来画一条下降曲线老师一眼就能看出训练过程是否正常。import matplotlib.pyplot as plt train_losses [] model.train() for epoch in range(3): total_loss 0 for batch in train_loader: ... loss.backward() optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) train_losses.append(avg_loss) print(fEpoch {epoch 1}: loss {avg_loss:.4f}) plt.plot(range(1, len(train_losses) 1), train_losses, markero) plt.xlabel(Epoch) plt.ylabel(Average Loss) plt.title(BERT Fine-tuning Loss Curve) plt.grid(True) plt.show()如果第1个epoch的loss下降很快、第2个epoch后趋于平缓这是正常现象说明模型正在收敛。如果loss先降后升大概率是学习率过大或训练轮次过多。如果loss在第1个epoch内反复横跳先检查batch_size是否太小再检查学习率是否超过了5e-5。曲线能帮你区分“代码写错了”和“参数没调好”两类问题。5.3 多分类指标口径precision、recall和macro-F120NewsGroups有20个类别整体准确率只能反映平均情况无法展示模型在“易分”和“难分”类别上的差异。建议用classification_report一次输出所有类别的precision、recall和F1from sklearn.metrics import classification_report report classification_report( true_labels, predictions, target_namestarget_names, digits4 ) print(report)这里要理解宏平均与微平均的区别。macro-F1是对20个类别的F1做算术平均每个类别的权重相同micro-F1按样本量加权样本多的类别贡献更大。20NewsGroups各类别样本量接近两者差异不会太大但课程论文里建议以macro-F1为主因为它更能体现模型在困难小类上的表现。我见过不少人只打印准确率就收尾直到答辩时被问到“为什么comp.sys.ibm.pc.hardware和comp.hardware容易混淆”才发现模型当成了“几乎相同的两类”。这就是整体指标的盲区。加上混淆矩阵和macro-F1之后你可以把这些现象写进分析讨论反而成为实验报告的加分项。提示保存预测结果时不要只存labels把原始新闻文本前100个字符一并存下来。这样回看误判样本时你能直接看到模型到底被哪些句子带偏了。6. 让BERT新闻分类结果能复现的三个习惯seed、checkpoint与Trainer课程答辩和项目验收有个共性被问到最多的问题是“你这个结果换个机器能复现吗”很多人在自己电脑上跑出82%的准确率换到实验室另一台机器上就变成76%并不是代码不同而是缺少三个习惯。第一个习惯是固定随机种子。PyTorch、numpy和Python自带random三个库分别维护自己的随机状态不全部固定数据加载、参数初始化和训练顺序都会变动。在脚本最开头加一段import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42)这样做以后同一套代码在同一环境下跑出的loss曲线基本一致老师拿你脚本复现时不会出现“过程对但数字对不上”的尴尬。第二个习惯是把训练参数集中管理并保存checkpoint。不要在校验环节临时改学习率改动前先留下能复现当前结果的现场config { model_name: bert-base-uncased, max_length: 256, batch_size: 8, lr: 2e-5, epochs: 3, seed: 42, } model.save_pretrained(./news_bert_ckpt) tokenizer.save_pretrained(./news_bert_ckpt)save_pretrained会同时保存模型结构和权重tokenizer.save_pretrained保存分词配置之后用AutoModelForSequenceClassification.from_pretrained(./news_bert_ckpt)即可恢复。有了checkpoint你可以在测试集上反复做评估而不必每次重新训练。第三个习惯是尝试用transformers的Trainer替代手写训练循环。手写循环的好处是每一步可控坏处是重复代码多还容易漏写warmup等细节。Trainer封装了梯度累积、日志保存、评估回调等功能代码量少很多。from transformers import Trainer, TrainingArguments training_args TrainingArguments( output_dir./results, num_train_epochs3, per_device_train_batch_size8, learning_rate2e-5, save_strategyepoch, seed42, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, tokenizertokenizer, ) trainer.train()我第一次使用Trainer时最大的不适是看不到每个batch的loss但后来发现它在./results里自动保存了训练日志反而比手写print更完整。建议第一次实验用手写循环理解过程复现和调参时换成Trainer两者搭配能兼顾“看懂”和“省时间”。几年里我做过不少类似的分类实验最大的教训是能复现的实验才有机会被讨论。种子、参数、checkpoint这三件事看着琐碎却是这次课程论文里性价比最高的投入。希望帮到你。本文还有配套的精品资源点击获取