ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

BERT新闻分类实验:20NewsGroups微调全流程与避坑指南

BERT新闻分类实验:20NewsGroups微调全流程与避坑指南 简介这份课程论文配套资源围绕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, encodingutf-8, errorsignore) as f: head [f.readline() for _ in range(3)] for idx, line in enumerate(head): print(idx, repr(line[:120]))这段代码的作用是快速展示原始行的结构。errorsignore用于兜底Windows环境下可能混入的非法编码字符少了它程序极有可能在某个奇怪位置抛UnicodeDecodeError。打印前120个字符足以看出分隔符是制表符还是空格以及标签到底排在文本前还是文本后。确定列顺序后再去读label.txt建立稳定的类别映射def load_label_map(label_pathdata/label.txt): with open(label_path, r, encodingutf-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_length256): encoded tokenizer( text, max_lengthmax_length, paddingmax_length, truncationTrue, return_tensorspt, ) 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在单句分类任务里参与度不高但保留它能让代码以后改造成句对任务时不用大动。paddingmax_length把短样本补到固定长度目的是让同一个batch内的样本形状一致否则DataLoader没法拼张量。truncationTrue负责切掉超过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], dtypetorch.long), }torch.stack会把形状为(max_length,)的向量拼成(batch_size, max_length)的二维张量这一步不参与梯度计算纯粹是数据搬运。labels单独用torch.tensor包一层并指定dtypetorch.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, typestr, defaultbase, choices[base, large]) parser.add_argument(--model_name, typestr, defaultbert-base-uncased) parser.add_argument(--max_length, typeint, default256) parser.add_argument(--batch_size, typeint, default16) parser.add_argument(--learning_rate, typefloat, default2e-5) parser.add_argument(--num_epochs, typeint, default3) parser.add_argument(--warmup_ratio, typefloat, default0.1) parser.add_argument(--seed, typeint, default42) 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_namebert-base-uncasedbert-large-uncased是否区分大小写和参数量max_length256256序列截断长度显存小可降为128batch_size168Large显存占用翻倍必须降learning_rate2e-51e-5Large需要更小lr防震荡num_epochs33微调期数不宜过多warmup_ratio0.10.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_namebert-base-uncased, num_labels20, dropout0.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_idsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_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是1024from_pretrained加载后会自动填对。dropout建议不低于0.10.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, }, fcheckpoint.{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加stratifyy。4.2 训练到一半显存爆掉现象训练跑到第1个epoch末尾或第2个epoch中途突然报CUDA out of memory程序直接退出。原因max_length、batch_size和模型规模三者叠加过猛。资源里Base配合batch_size16、max_length256是刚好能跑的状态如果换成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(seed42): 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逻辑说明benchmarkTrue会让cuDNN根据输入形状动态选择最快算法性能好但结果有微小浮动deterministicTrue关闭这种选择保证同一个seed下权重初始化、卷积顺序完全一致。代价是速度略慢但复现实验时值得。4.4 加载Large的checkpoint报size mismatch现象把checkpoint.Large.txt里的权重加载到BertClassifier上立刻报size mismatch而且错误信息里列出一大堆key不匹配。原因bert-large的hidden_size是1024base是768两套模型的线性层、自注意力投影层维度完全不同。直接把Large权重套到Base结构上从第一层开始就对不上。解决先用config里的model_name重建对应模型再加载对应checkpoint必要时用strictFalse。config.model_name bert-large-uncased checkpoint torch.load(checkpoint.Large.txt, map_locationcpu) model BertClassifier(config.model_name, num_labels20) model.load_state_dict(checkpoint[model_state_dict], strictFalse)strictFalse允许缺失或多余的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, labeltrain_acc) plt.plot(epochs, val_accs, labelval_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_locationcpu) config checkpoint[config] # 新任务类别数变了只用BERT主干的权重分类头重新初始化 model BertClassifier(config[model_name], num_labels20) model.bert.load_state_dict(checkpoint[model_state_dict]) # 续训前把学习率调低一个数量级避免破坏已学到的表示 optimizer AdamW(model.parameters(), lr1e-5)这里有个重要细节加载时只取bert子模块的state_dict而不是整个model的state_dict。因为新任务类别数大概率不是20全量加载会把旧的线性层权重也带进来导致输出维度对不上。即使对得上把旧任务训练好的分类头直接用于新任务也不是好习惯最好让分类头重新训练。学习率建议降到1e-5主干表示已经收敛过大的lr会把它打回预训练前状态。我从这份资源里学到最重要的一个习惯每次实验结束同时保留checkpoint、日志和一条可复现命令三样缺一不可。之后我跑任何分类实验都会强制走一遍“先看三行数据、固定seed、按best_acc保存checkpoint”的动作。希望帮到你。本文还有配套的精品资源点击获取
返回列表