简介:这份课程论文配套资源围绕20Newsgroups新闻分类实验展开,面向医学健康数据分析与挖掘课程的本科生或入门NLP学习者,用于理解BERT预训练模型在文本分类中的完整应用流程。压缩包共18个文件,以Python源码、txt数据、训练日志和PDF论文为主,具体包括5个py脚本(模型、配置、数据加载、主程序与工具函数)、5个txt数据文件(20news训练/测试及标签)、2个log训练日志以及1份课程论文PDF,整体约14.42MB,目录结构清晰。目前已有503人浏览学习。通过这套资料,读者可掌握基于PyTorch和Hugging Face Transformers加载预训练BERT、完成数据预处理与微调的方法;也可参考作者在医学健康数据课程中的实验思路,快速复现20Newsgroups分类任务,并迁移到其他文本分类场景中。
1. 基于BERT的20NewsGroups新闻分类:一份能直接照跑的课程实验
BERT在20NewsGroups上做新闻分类,是我在医学健康数据分析与挖掘课上反复打磨过的实验。第一次看到这份资源时,真正打动我的不是论文PDF,而是那两套完整可对照的训练日志:Base和Large各留了一份log、一个checkpoint。这意味着不需要从零调参撞墙,只要把环境和数据路径对齐,就能复现两版实验结果,还能顺着日志反向推导当时的调参思路。它适合两类人:刚接触NLP、想看到一条完整分类管道的学习者,以及被微调玄学反复折磨、想有个稳定实验基准的从业者。如果你想找的只是一个“BERT demo跑通教程”,这份资源对你反而是浪费——它最有价值的是那些能支撑对比分析、排障和二次开发的细节。
2. 数据解析与样本构造:把20NewsGroups喂给BERT之前的三个动作
2.1 读数据文件前先看三行:train/test/label的对应关系
data目录下的三件套,20news.train.txt、20news.test.txt和label.txt,是全部样本的入口。常见约定是一条样本占一行,字段用制表符分隔;label.txt按类别顺序列出20个新闻组名。但这里有个很多人踩过的坑:不同人打包数据时,文本和标签的顺序可能不一样,有的版本是“文本\t标签”,有的版本是“标签\t文本”。如果不看文件头就写解析代码,后面所有label都会错位,而且训练时不一定报错。
我一般会先打印前三条样本,确认列顺序再动手。
with open("data/20news.train.txt", "r", encoding="utf-8", errors="ignore") as f: head = [f.readline() for _ in range(3)] for idx, line in enumerate(head): print(idx, repr(line[:120]))这段代码的作用是快速展示原始行的结构。errors="ignore"用于兜底Windows环境下可能混入的非法编码字符,少了它程序极有可能在某个奇怪位置抛UnicodeDecodeError。打印前120个字符足以看出分隔符是制表符还是空格,以及标签到底排在文本前还是文本后。
确定列顺序后,再去读label.txt建立稳定的类别映射:
def load_label_map(label_path="data/label.txt"): with open(label_path, "r", encoding="utf-8") as f: labels = [line.strip() for line in f if line.strip()] label2id = {name: i for i, name in enumerate(labels)} id2label = {i: name for i, name in enumerate(labels)} return label2id, id2label参数说明:strip()去掉行尾换行符,但不会动文件里本来存在的空格,所以如果label.txt里混入了空行或异常空格,映射数量会少于20。更隐蔽的是类别顺序问题——顺序乱了训练不报错,可最后输出的混淆矩阵、分类报告全部对不上号。
2.2 编码函数:tokenizer参数别乱改
文件读进来之后,文本必须过一遍BertTokenizer才能变成BERT能接受的张量。这里用Hugging Face Transformers库是标准做法,资源里的代码也明确依赖它。需要仔细斟酌的参数只有两个:max_length和truncation,其余用官方默认就好。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-uncased") def encode_text(text, max_length=256): encoded = tokenizer( text, max_length=max_length, padding="max_length", truncation=True, return_tensors="pt", ) return { "input_ids": encoded["input_ids"].squeeze(0), "attention_mask": encoded["attention_mask"].squeeze(0), "token_type_ids": encoded["token_type_ids"].squeeze(0), }逻辑说明:BERT的输入是三个张量。input_ids是词在词表中的索引,attention_mask标出哪些位置是真实token、哪些是padding补出来的,token_type_ids在单句分类任务里参与度不高,但保留它能让代码以后改造成句对任务时不用大动。padding="max_length"把短样本补到固定长度,目的是让同一个batch内的样本形状一致,否则DataLoader没法拼张量。truncation=True负责切掉超过max_length的部分,这一步不能省——不截断的话,长新闻会让序列长度远超设定值,最后在batch拼接时报RuntimeError。
注意:如果换成bert-base-cased或中文预训练模型,词表和tokenizer行为都不一样,不能拿这段代码直接跨模型跑,至少要先确认特殊token是否一致。
max_length取128还是256,取决于显存和任务粒度。20NewsGroups的新闻平均长度在200词左右,128会切掉不少尾部语义,256基本够用,但序列长度直接决定显存占用和训练速度。显存小就先按128把pipeline跑通,调优阶段再拉回256。
2.3 collate_fn:把样本拼成batch的最后一公里
样本编码完毕后,进入DataLoader。PyTorch默认的collate_fn能处理形状一致的tensor,但这个自定义字典结构里还是自己写一个更可靠。不带collate_fn时,DataLoader会用默认逻辑合并list,遇到字符串字段直接抛异常;更麻烦的是字典里多个tensor的stack顺序,不同版本PyTorch的行为有差异。
def news_collate_fn(batch): return { "input_ids": torch.stack([item["input_ids"] for item in batch]), "attention_mask": torch.stack([item["attention_mask"] for item in batch]), "token_type_ids": torch.stack([item["token_type_ids"] for item in batch]), "labels": torch.tensor([item["label"] for item in batch], dtype=torch.long), }torch.stack会把形状为(max_length,)的向量拼成(batch_size, max_length)的二维张量,这一步不参与梯度计算,纯粹是数据搬运。labels单独用torch.tensor包一层并指定dtype=torch.long,是因为交叉熵损失要求标签是长整型。如果在这里忘了指定类型,会在loss.backward()时见到RuntimeError: expected scalar type Long but found Float,这属于那种报错位置和原因相距很远的问题,排查起来很费时间。
3. 模型构建与训练主流程:Base和Large两套配置的代码级对照
3.1 config.py:用一张表锁死两套实验差异
config.py的价值在于收口。资源同时跑了Base和Large两套模型,日志和checkpoint都按模型类型分别落盘,如果超参数散落在各个文件里,对照实验很容易做成糊涂账。一个常见的做法是把关键参数暴露成命令行参数,并在读取时做联动修正。
import argparse def get_config(): parser = argparse.ArgumentParser() parser.add_argument("--model_type", type=str, default="base", choices=["base", "large"]) parser.add_argument("--model_name", type=str, default="bert-base-uncased") parser.add_argument("--max_length", type=int, default=256) parser.add_argument("--batch_size", type=int, default=16) parser.add_argument("--learning_rate", type=float, default=2e-5) parser.add_argument("--num_epochs", type=int, default=3) parser.add_argument("--warmup_ratio", type=float, default=0.1) parser.add_argument("--seed", type=int, default=42) args = parser.parse_args() if args.model_type == "large": args.model_name = "bert-large-uncased" args.batch_size = 8 args.learning_rate = 1e-5 return args这段代码的关键点是后半段的联动修正。BERT-large参数量接近base的三倍,显存占用和收敛行为都不一样。如果只传--model_type而不改batch_size和learning_rate,大概率在前几轮就显存溢出,或者因为学习率过高导致loss震荡。warmup_ratio是线性预热比例,0.1代表训练前10%的step让学习率从0平缓升到设定值,这能显著降低微调早期的不稳定性。
两套模型在资源里的典型配置差异,可以整理成下面这张表:
| 参数 | Base配置 | Large配置 | 说明 |
|---|---|---|---|
| model_name | bert-base-uncased | bert-large-uncased | 是否区分大小写和参数量 |
| max_length | 256 | 256 | 序列截断长度,显存小可降为128 |
| batch_size | 16 | 8 | Large显存占用翻倍,必须降 |
| learning_rate | 2e-5 | 1e-5 | Large需要更小lr防震荡 |
| num_epochs | 3 | 3 | 微调期数不宜过多 |
| warmup_ratio | 0.1 | 0.1 | 学习率预热 |
3.2 model.py:取[CLS]输出接分类头
model.py做的事很标准:加载预训练BERT,取[CLS]位置的输出,接dropout和线性分类头。这里常有人纠结pooler_output和last_hidden_state[:, 0]用哪个。就文本分类任务来说,两者差距通常在一个点以内,用pooler_output即可,它内部已经过了tanh和一层线性变换。
import torch.nn as nn from transformers import BertModel class BertClassifier(nn.Module): def __init__(self, model_name="bert-base-uncased", num_labels=20, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(model_name) self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask, token_type_ids=None): outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, ) pooled = outputs.pooler_output pooled = self.dropout(pooled) logits = self.classifier(pooled) return logits参数说明:num_labels直接由label.txt的行数决定,在这里是20。hidden_size对base是768,对large是1024,from_pretrained加载后会自动填对。dropout建议不低于0.1,0.1到0.3之间对结果影响不大,但不加dropout的话训练集acc会冲得很高、验证集明显回落,过拟合信号来得又快又准。
这里有个隐蔽问题:attention_mask不是给分类头用的,它是传给BERT主干、告诉自注意力机制哪些位置是padding的。如果把attention_mask全置为1,模型会去关注pad位置,训练不报错但精度下降,而且这个错误在测试时会被同样的错误掩盖住,让你很难察觉。
3.3 main.py:训练循环里的checkpoint与日志落盘
main.py是工程骨架,把训练和验证串成闭环。资源里同时保留了Base和Large两个checkpoint、两个日志文件,说明代码从一开始就是按对比实验设计的。值得抄的细节是:checkpoint里同时保存model_state_dict和config,而不是只存权重。
best_val_acc = 0.0 for epoch in range(config.num_epochs): model.train() total_loss, correct, total = 0.0, 0, 0 for batch in 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() optimizer.step() preds = logits.argmax(dim=-1) correct += (preds == labels).sum().item() total += labels.size(0) train_acc = correct / total val_acc = evaluate(model, val_loader, criterion, device) if val_acc > best_val_acc: best_val_acc = val_acc torch.save({ "model_state_dict": model.state_dict(), "config": vars(config), "best_val_acc": best_val_acc, }, f"checkpoint.{config.model_type}.txt")逻辑说明:每个batch先forward算出logits,再和labels计算交叉熵,反向传播更新参数。acc用argmax取logits里数值最大的索引作为预测类别,再和真实标签逐元素比较。保存checkpoint的条件是val_acc超过历史最优值,而不是只认最后一个epoch,这能防止最后几轮过拟合把之前的好结果覆盖掉。
代码里有个容易被忽略的细节:.to(device)尽量在拿到batch后立刻做,而不是在collate_fn里做。DataLoader的worker是独立进程,在worker里调用cuda容易触发CUDA初始化错误,把设备迁移放主循环是最稳的写法。
3.4 日志文件怎么读:从Base与Large记录看收敛差异
两个log文件,train-BertClassifier.Base.log和train-BertClassifier.Large.log,是最有价值的学习材料。每行一个epoch,记录训练集loss、acc和验证集acc。你可以直接用来判断两套模型在这个任务上的真实差距,而不需要重跑一遍。
# 只看验证集acc的变化轨迹 grep "val_acc" train-BertClassifier.Base.log grep "val_acc" train-BertClassifier.Large.log如果Base日志三到四个epoch就收敛,而Large需要更长的训练期数才稳定,这就是典型的大模型收敛慢现象:参数更多,在同样学习率下更新幅度相对小,需要更多步数走到同样的损失水平。这也解释了为什么config里把large的学习率调得更低——大参数空间在高学习率下容易loss震荡。
4. 避坑:从训练日志反推的四条经典翻车记录
4.1 训练acc冲到0.99,测试集却接近随机
现象:训练过程loss正常下降,acc漂亮到接近0.99,但把模型放到测试集上,准确率只有三成出头,跟随机猜差不多。
原因:多数情况是数据读取时标签错位。常见两种:一是label.txt的行顺序和train文件里的标签列不一致,前几类恰好能对上,后面整体错位;二是train和test各自用了不同的标签映射,两边从某个类别起就对不上。这种错位不会报错,因为它只是在按错误的方式学习一个自洽的映射。
解决:先看数据再动手解析。打印train文件前三行确认列顺序,再拿一条已知样本验证映射后的类别是否正确。同时确认验证集划分没有把某些小类别全部留在训练集里,必要时用sklearn的train_test_split加stratify=y。
4.2 训练到一半显存爆掉
现象:训练跑到第1个epoch末尾或第2个epoch中途,突然报CUDA out of memory,程序直接退出。
原因:max_length、batch_size和模型规模三者叠加过猛。资源里Base配合batch_size=16、max_length=256是刚好能跑的状态,如果换成Large还不降batch_size,显存就会不够。另一个因素是训练在forward阶段建的中间激活值比backward阶段更占显存,但很多人误以为是模型本身太大。
解决:梯度累积,用时间换显存。
accumulation_steps = 4 optimizer.zero_grad() for step, batch in enumerate(train_loader): logits = model(**batch) loss = criterion(logits, batch["labels"]) / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()逻辑说明:把loss除以accumulation_steps,相当于把一个较大的batch拆成4个小batch分别算梯度,再在累计梯度上统一更新一次。实际生效的batch_size等于原值乘4,但峰值显存只占一个小batch的量。代价是训练时间变长,因为梯度更新频率降低了。
提示:遇到out of memory,先看报错栈发生在forward还是backward。在forward挂,砍max_length;在backward挂,砍batch_size,这样定位效率最高。
4.3 换了服务器复现,acc掉了5个点
现象:同一份代码、同一个数据集,在自己机器上复现,最终验证集acc比日志里记录的少了5个点以上,而且每次跑结果还有浮动。
原因:这是微调实验最折磨人的问题。PyTorch版本、CUDA版本、GPU型号都会带来微小差异,如果你连随机种子都没固定,结果浮动3到5个点完全正常。另外cuDNN的benchmark模式会动态选择算法,这也会让结果轻微漂移。
解决:把能固定的东西全部固定下来,减少变量。
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 torch.backends.cudnn.benchmark = False逻辑说明:benchmark=True会让cuDNN根据输入形状动态选择最快算法,性能好但结果有微小浮动;deterministic=True关闭这种选择,保证同一个seed下权重初始化、卷积顺序完全一致。代价是速度略慢,但复现实验时值得。
4.4 加载Large的checkpoint报size mismatch
现象:把checkpoint.Large.txt里的权重加载到BertClassifier上,立刻报size mismatch,而且错误信息里列出一大堆key不匹配。
原因:bert-large的hidden_size是1024,base是768,两套模型的线性层、自注意力投影层维度完全不同。直接把Large权重套到Base结构上,从第一层开始就对不上。
解决:先用config里的model_name重建对应模型,再加载对应checkpoint,必要时用strict=False。
config.model_name = "bert-large-uncased" checkpoint = torch.load("checkpoint.Large.txt", map_location="cpu") model = BertClassifier(config.model_name, num_labels=20) model.load_state_dict(checkpoint["model_state_dict"], strict=False)strict=False允许缺失或多余的key,但只适合小范围容错,比如你改了分类头或dropout层。如果是结构性层数不一致,丢失的权重会保留随机初始化,整体性能明显下降。遇到这类问题,先看error信息里列出的key差异,再决定是丢弃层还是重建模型。
5. 进阶:用现成日志与checkpoint做二次微调
5.1 把log画成曲线,判断什么时候该停
训练日志如果只是滚屏过去就浪费了。我习惯把Base和Large两个log文件的acc曲线画在同一张图里,这是判断健康度最快的方式。解析日志时要注意每行格式,最好先打印一行再写正则。
import matplotlib.pyplot as plt def parse_log(path): epochs, train_accs, val_accs = [], [], [] with open(path) as f: for line in f: if "train_acc" not in line: continue # 按实际日志格式调整分隔符 epoch = int(line.split("Epoch ")[1].split("/")[0]) train_acc = float(line.split("train_acc=")[1].split(",")[0]) val_acc = float(line.split("val_acc=")[1].strip()) epochs.append(epoch) train_accs.append(train_acc) val_accs.append(val_acc) return epochs, train_accs, val_accs epochs, train_accs, val_accs = parse_log("train-BertClassifier.Base.log") plt.plot(epochs, train_accs, label="train_acc") plt.plot(epochs, val_accs, label="val_acc") plt.legend() plt.savefig("base_acc_curve.png")判断标准:训练acc持续上升而验证acc在第2个epoch后开始回落,就是过拟合信号,此时应回退到之前保存的最优checkpoint。相对地,如果训练和验证acc都在同步缓慢爬升,说明学习率偏低,可以提高一点让训练更快。两文件对比时,如果Large的val_acc始终没超过Base,不要急着下结论——先确认是不是epoch数不够,大模型收敛更慢,需要更长训练时间。
5.2 从checkpoint续训,把已有权重当新任务的起点
续训是这份资源另一个值得开发的用法。checkpoint里保存的不只是最终权重,还有当时的超参数配置,这等于给实验留了后悔药。在新数据上继续微调时,加载方式如下:
checkpoint = torch.load("checkpoint.base.txt", map_location="cpu") config = checkpoint["config"] # 新任务类别数变了,只用BERT主干的权重,分类头重新初始化 model = BertClassifier(config["model_name"], num_labels=20) model.bert.load_state_dict(checkpoint["model_state_dict"]) # 续训前把学习率调低一个数量级,避免破坏已学到的表示 optimizer = AdamW(model.parameters(), lr=1e-5)这里有个重要细节:加载时只取bert子模块的state_dict,而不是整个model的state_dict。因为新任务类别数大概率不是20,全量加载会把旧的线性层权重也带进来,导致输出维度对不上。即使对得上,把旧任务训练好的分类头直接用于新任务也不是好习惯,最好让分类头重新训练。学习率建议降到1e-5,主干表示已经收敛,过大的lr会把它打回预训练前状态。
我从这份资源里学到最重要的一个习惯:每次实验结束,同时保留checkpoint、日志和一条可复现命令,三样缺一不可。之后我跑任何分类实验都会强制走一遍“先看三行数据、固定seed、按best_acc保存checkpoint”的动作。希望帮到你。
本文还有配套的精品资源,点击获取