news 2026/9/30 5:44:48

微调BERT实现提取式摘要:句子分类与ROUGE评估实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微调BERT实现提取式摘要:句子分类与ROUGE评估实战

简介:面向自然语言处理开发者的BERT摘要生成实战代码包,基于Python实现论文中的BertSum方案,解决如何利用预训练BERT完成抽取式摘要任务。资源分为数据预处理、模型构建、训练评估三大模块:数据端需将原始文本转换成BERT可识别的Token IDs、Segment IDs与Mask IDs,并完成分词、特殊标记添加及长度填充;模型端基于Hugging Face的Transformers库加载预训练权重,并在顶部接上序列到序列解码结构,配合交叉熵损失与Adam优化器进行微调。压缩包共36个文件,以20个py脚本为主,辅以7个txt映射配置、2个json参数文件、1个Markdown说明及1个License许可证,整体约14.99MB,目录结构清晰易查。已有1523人学习浏览。通过该资源可完整走通从数据准备到ROUGE评估与摘要后处理的实验链路,具体涉及分词与特殊标记处理、预训练权重加载、Seq2Seq层搭建、损失计算与学习率调整等环节,适合想要结合论文源码、深入理解微调细节的开发者。

1. 微调BERT做提取式摘要:这份论文代码到底能复现什么

拿BERT做提取式摘要,是很多NLP论文里最常见也最容易复现出偏差的路线。这份Python微调BERT用于提取摘要的论文代码,走的是句级二分类思路:先把原文切成句子,对每个句子预测“保留/不保留”,最后按原顺序拼出摘要。比起直接用T5或Pegasus做生成式摘要,它需要的显存更小、训练更快,也更容易在普通单卡GPU上跑通,特别适合刚接触大模型微调、想把手上的论文结构迁移到实际数据集的从业者。代码覆盖数据清洗、标签构造、训练参数、ROUGE评估和断点恢复,适合作为基线项目复现后继续改结构。

2. 为什么拿BERT做提取式摘要:选型逻辑与运行环境

提取式摘要的本质,是把“摘要生成”转化成“句子排序+句子筛选”。它不产生新文本,只从原文里挑高价值句子,所以不需要很强的语言生成能力,更看重对上下文语义的建模和对句子重要性的判断。BERT这类编码器模型天然适合这个任务:它能同时看到句子周边的段落信息,双向注意力对句子级分类任务非常友好。相比之下,Pegasus或BART虽然摘要效果更好,但生成阶段的推理耗时和显存开销比我这边常见方案高一到两个量级。对于论文复现场景,先用BERT跑通基线、再决定是否换生成式模型,是更稳的路径。

2.1 任务定义与数据集选择

提取式摘要的标准输入是“整篇文档”,输出是“若干句子的组合”。在句子分类方案里,标签不是整篇文档的,而是每个句子独立的0/1标记。训练目标就是让BERT学会:哪些句子适合作为摘要的一部分。

数据集方面,英文最常用的是CNN/DailyMail,中文可以用LCSTS或者自己业务里积累的“标题+正文”数据。CNN/DailyMail里有一条article和一条highlights,highlights就是人工摘要,可以作为标签生成的参照物。要注意的是,这份数据集的原始版本有部分数据源链接失效,用Hugging Face datasets库加载时建议显式指定版本号,避免拉到格式不一致的数据。

from datasets import load_dataset # 只取前1000条做冒烟测试,验证完再放开 ds = load_dataset("cnn_dailymail", "3.0.0", split="train[:1000]") print(ds[0].keys()) docs = [d["article"] for d in ds] highlights = [d["highlights"] for d in ds] print(len(docs), len(highlights))

这里3.0.0是CNN/Dailymail的稳定版本号,取前1000条是为了快速验证链路。跑通后建议用全量训练集,我一般会保留500条作为验证集,不参与标签构造和训练。负载均衡也很关键:如果文档本身很长,句子数可能超过80,那就要对句子数做截断或动态采样,否则每个batch的样本长度差异会特别大,训练效率明显下降。

2.2 GPU环境与transformers版本:先用小模型跑通

微调BERT对环境的要求不算苛刻。BERT-base大约110M参数,单卡12GB显存的GPU足够训练,batch size小一些甚至8GB也能跑。但transformers、torch、datasets三个库的版本必须匹配,否则会遇到key名称不一致甚至前向传播直接报错的问题。常见做法是用一个独立的conda环境,把依赖固定在兼容区间。

conda create -n bert-summary python=3.9 -y conda activate bert-summary pip install torch==1.13.1 --index-url https://download.pytorch.org/whl/cu117 pip install transformers==4.27.0 datasets==2.10.0 rouge-score==0.1.2

torch 1.13.1对应CUDA 11.7,这套组合在几十个不同项目里验证过,稳定性比较高。如果你机器上是更新的驱动,也可以用torch 2.x配transformers 4.31+,但BERT相关接口行为基本没变。最重要的不是版本最新,而是确认BertForSequenceClassification的输入输出格式与你写的训练循环一致。

BERT参数下载这个问题,第一次跑的人容易卡住。模型权重由from_pretrained自动下载,默认存到~/.cache/huggingface。如果网络受限,可以手动下载pytorch_model.bin、config.json和vocab.txt,放到本地目录后用from_pretrained("./bert-base-uncased")加载。我一般会在项目里保留一个model_path变量,方便切换本地路径或远程路径。

2.3 模型加载:BERT加分类头,而不是整句生成

实现上用BertForSequenceClassification比手写BERT再加Linear层更省事,它内部已经在pooler输出上接了分类头,并且封装了loss计算。注意num_labels=2,两个类别分别对应“不保留”和“保留”。

from transformers import BertTokenizer, BertForSequenceClassification model_path = "bert-base-uncased" # 也可以是本地目录 tokenizer = BertTokenizer.from_pretrained(model_path) model = BertForSequenceClassification.from_pretrained( model_path, num_labels=2 ) model.cuda() print(model.config.hidden_size)

num_labels必须等于标签类别数,这里是2。如果误设成1,模型的输出维度会变成[batch, 1],后续计算softmax就会出维度错误。很多复现翻车不是模型结构的问题,而是from_pretrained加载时没有关掉原始的分类头参数,导致预训练权重里的classifier.weight形状不匹配。解决方式是加载后打印model.classifier,确认输入输出尺寸,必要时重新初始化这一个模块。

这里还有一个容易被忽略的点:BertForSequenceClassification默认用[CLS]的pooler输出做分类,这对句子级任务其实够用。但如果你是做整篇文档级别的摘要,单条句子长度超过512会直接截断,需要考虑滑窗或层次化编码。论文代码通常不处理超长文档,这在实际业务里是个明显边界。

3. 数据清洗与标签构造:把文档切成句子再逐个打分

提取式摘要最耗时间的部分往往不是模型训练,而是标签生成。BERT的输入是一个一个句子,但数据集的原始标签是整篇摘要文本,所以必须算出“每个句子与摘要的相似度”,超过阈值就标为1,否则标为0。这个相似度评估指标最常用的是ROUGE-L F值,它能衡量句子和摘要之间的最长公共子序列覆盖程度,比简单算词重叠更稳定。

3.1 切句:先修基础文本,再按标点拆

切句不是简单用split(".")就完事。论文里摘要句通常在30到80字符之间,但文章里经常出现缩写、数字、引号,直接把点当作分隔符会切出大量无意义片段。常见做法是先用正则清理HTML标签和多余空白,再借助nltk的PunktSentenceTokenizer按句子边界切分。

import re from nltk.tokenize import PunktSentenceTokenizer def clean_text(text): text = re.sub(r"<[^>]+>", " ", text) # 去HTML标签 text = re.sub(r"\s+", " ", text).strip() # 合并空白 return text def split_sentences(text): text = clean_text(text) tokenizer = PunktSentenceTokenizer() return tokenizer.tokenize(text) sample_sents = split_sentences(docs[0]) print(sample_sents[:5], len(sample_sents))

PunktSentenceTokenizer虽然好用,但第一次运行需要下载punkt模型文件,如果没有提前下载会报LookupError。我一般会在代码里加一句nltk.download("punkt"),保证离线环境也能顺利跑。切句后还需要过滤掉长度小于10个字符的句子,这些多半是页眉页脚或导航文本,留着只会增加噪声。

3.2 生成标签:ROUGE-L分数阈值怎么定

标签构造的思路是:把每个候选句子与gold summary比较,如果句子和摘要的ROUGE-L F值达到阈值,就认为这个句子值得保留。阈值一般取0.4到0.6之间。阈值过高,正样本太少,模型容易学成“全不保留”;阈值过低,标签太松散,模型会挑选大量冗余句子。

from rouge_score import rouge_scorer scorer = rouge_scorer.RougeScorer(["rouge1", "rouge2", "rougeL"], use_stemmer=True) def sentence_label(sent, summary, threshold=0.45): if len(sent) < 10: return 0 score = scorer.score(sent, summary)["rougeL"].fmeasure return 1 if score >= threshold else 0 labels = [sentence_label(s, highlights[0]) for s in sample_sents] print(sum(labels), labels[:10])

use_stemmer=True会把词的形态变化归一化,比如“running”和“run”算同源词,这在摘要匹配里更合理。threshold=0.45是针对CNN/Dailymail这类新闻文本比较稳的起始值。遇到长句多的文档,可以把它调到0.5以上,反之降到0.4。

有个值得留意的点:ROUGE-L只看最长公共子序列,不要求子序列连续,所以“足够相似但顺序不同”的句子也可能拿到高分。这在提取式摘要里是符合直觉的:只要句子覆盖了摘要的关键内容,顺序其实可以后续重排。如果你未来把同一个标签构造逻辑用到中文数据集,别忘了把rouge_scorer换成中文分词版本,默认的是按英文空白切的。

3.3 构建DataLoader:句子级输入而不是文档级输入

这里有个常见误区:不少人把整篇文档拼成一个超长序列喂给BERT,然后再做句子级分类。这样既浪费显存,又让模型在512长度限制下丢掉大量句子。正确做法是把每个句子当作一条独立样本,输入格式是[CLS] + 句子 + [SEP],标签是0/1。

from torch.utils.data import Dataset, DataLoader class SentenceDataset(Dataset): def __init__(self, sentences, labels, tokenizer, max_len=128): self.sentences = sentences self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.sentences) def __getitem__(self, idx): encoding = self.tokenizer( self.sentences[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), "label": self.labels[idx] } all_sentences = [] all_labels = [] for doc, summary in zip(docs, highlights): sents = split_sentences(doc) labs = [sentence_label(s, summary) for s in sents] all_sentences.extend(sents) all_labels.extend(labs) dataset = SentenceDataset(all_sentences, all_labels, tokenizer) loader = DataLoader(dataset, batch_size=32, shuffle=True)

max_len=128是经验值。新闻句子平均长度在20个词左右,128个token已经能覆盖绝大多数情况。如果你的数据里经常出现超过200词的句子,建议先做子句拆分,而不是把max_len拉高。padding="max_length"意味着每个batch大小固定,但也会让短句子产生大量无意义填充,训练速度会慢一些。如果显存紧张,可以把max_len降到96,效果差别通常不大。

这里还要注意类别不平衡。保留句子一般只占全文的10%到20%,正样本比例偏低。我在训练时不会简单用原始交叉熵,而是给正样本加权重,把pos_weight设为负样本数除以正样本数。这个细节很多论文代码没写,但对最终摘要质量影响很大,尤其是Threshold偏低导致正样本变多的场景。

4. 训练策略与参数:从学习率到梯度累积一次配齐

模型、数据和标签都准备好之后,训练阶段最重要的不是堆训练轮数,而是把优化器、学习率、batch size、梯度累积和保存策略一次配齐。BERT微调的标准学习率是2e-5到5e-5,比从头训练小一个量级。过大的学习率会让预训练权重迅速被破坏,过小则会让模型卡在局部最优,摘要句子总是挑不出重点。

4.1 优化器与学习率调度

我一般固定使用AdamW加线性warmup,warmup步数设为总训练步数的5%左右。这样做的原因是BERT在微调前期非常敏感,直接快速更新会导致损失函数在前几百步剧烈波动。warmup让学习率从0平滑上升到目标值,之后再线性衰减到0,这在序列标注和分类任务上都非常稳定。

from transformers import AdamW, get_linear_schedule_with_warmup epochs = 3 total_steps = len(loader) * epochs warmup_steps = int(total_steps * 0.05) optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps )

weight_decay=0.01只对非bias和非LayerNorm参数生效是最常见的写法,但AdamW默认会应用到所有参数上。想要更精细,可以用optimizer_grouped_parameters把所有bias和LayerNorm的权重排除在外。实际项目中这两种写法差异不大,但如果你的数据集很小,建议排除掉,能减少过拟合。

4.2 训练循环:一个batch里发生了什么

单步训练看上去只是“前向、算loss、反向、更新”,但其中有几个细节直接决定模型能不能收敛。

from tqdm import tqdm model.train() for epoch in range(epochs): for step, batch in enumerate(tqdm(loader)): input_ids = batch["input_ids"].cuda() attention_mask = batch["attention_mask"].cuda() labels = batch["label"].cuda() outputs = model( input_ids=input_ids, attention_mask=attention_mask, labels=labels ) loss = outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 200 == 0: print(f"epoch {epoch} step {step} loss {loss.item():.4f}")

梯度裁剪max_grad_norm=1.0是我必加的,它可以避免个别batch里的极端outlier把权重更新方向带偏。在BERT微调中,即使不裁剪,多数情况下也不会炸,但只要遇到一条长且奇怪的句子,loss就可能突然跳到几十,后面很难恢复。optimizer.zero_grad()放在optimizer.step()之后,是PyTorch训练的标准顺序,不要提前清空。

4.3 batch大小与梯度累积

如果显存只够跑batch size 8,但经验值要求32,那就要用梯度累积。做法是每4个batch累积一次梯度,再统一更新参数。

accumulation_steps = 4 for step, batch in enumerate(loader): input_ids = batch["input_ids"].cuda() attention_mask = batch["attention_mask"].cuda() labels = batch["label"].cuda() outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()

注意loss在反向传播前一定要除以accumulation_steps,否则实际梯度相当于被放大了4倍,学习率隐式变成8e-5,特别容易跑飞。如果你想偷懒不除,那就需要把学习率对应调低到5e-6,但这种做法可解释性差,我建议还是按标准方式写。

4.4 checkpoint与恢复机制

训练中段断电或OOM是家常便饭。只保存最后一个模型等于把前面十几个小时的工作清零。我一般每个epoch结束后保存一次,并且把优化器和学习率调度器状态一起存下来,这样可以从断点精确恢复。

checkpoint = { "epoch": epoch, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "scheduler_state_dict": scheduler.state_dict(), "rouge_val": best_rouge, } torch.save(checkpoint, f"checkpoint_epoch_{epoch}.pt")

加载恢复时要先重建模型,再load_state_dict加载模型权重,然后逐个加载优化器和调度器状态。如果只保存model.state_dict(),恢复后续训练时学习率会从初始步数重新计算,warmup阶段被打断,模型行为会变得很奇怪。

4.5 验证时计算ROUGE而不是只看loss

训练loss下降不代表提取出的摘要好。我每一步验证时,会把验证集里每篇文档的预测句子按原顺序拼起来,然后和gold summary计算ROUGE-1、ROUGE-2和ROUGE-L。这比直接看accuracy更接近任务目标。

from transformers import BertForSequenceClassification from rouge_score import rouge_scorer def predict_summary(model, tokenizer, doc, max_sentences=3): model.eval() sents = split_sentences(doc) scores = [] for sent in sents: enc = tokenizer(sent, max_length=128, padding="max_length", truncation=True, return_tensors="pt") with torch.no_grad(): logits = model( input_ids=enc["input_ids"].cuda(), attention_mask=enc["attention_mask"].cuda() ).logits probabilities = torch.softmax(logits, dim=-1) scores.append(probabilities[0, 1].item()) top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:max_sentences] top_idx = sorted(top_idx) return " ".join([sents[i] for i in top_idx])

注意top_idx第一次排序是为了选分数最高的N句,第二次排序是为了让它们回到原文档中的顺序。如果没有第二次排序,摘要内容虽然是对的,但读起来会像一句话跳来跳去,ROUGE分数也会因为顺序错乱而偏低。

5. 复现避坑:显存、标签漂移和“全不保留”都是怎么来的

论文代码能在作者的GPU上跑通,换到你的机器上不一定。这里挑几个我踩过且最有代表性的坑,按“现象—原因—解决”的方式写出来,对照排查可以省下大量时间。

5.1 现象:训练到一半显存直接OOM

现象:刚开始能正常跑,第几百步之后报CUDA out of memory。原因:最常见的是padding策略选的不是max_length,而是longest。这样每个batch的序列长度不定,BERT在计算注意力矩阵时,显存占用随序列长度平方增长,碰到一篇长文档密集出现的batch就爆了。解决:把所有样本统一pad到固定长度,比如128或256,并在DataLoader里设置pin_memory=True、num_workers=4。如果还是OOM,就把batch size减半,并用梯度累积补足。

5.2 现象:loss突然变成NaN

现象:前几个batch正常,后面loss直接显示nan,GPU显存疑似被释放,卡死。原因:绝大多数是学习率过大或某个极端样本在反向传播时梯度爆炸,少数情况是AdamW的epsilon与float16混用导致。解决:先把学习率从2e-5降到1e-5;如果用了AMP混合精度,检查torch.cuda.amp.GradScaler是否在每个step都调用了scaler.update()。最快速的排查方法是在训练循环里加一句if not torch.isfinite(loss): print(step); break,定位到具体是哪个batch触发。

5.3 现象:标签生成结果与论文描述不一致

现象:训练前统计正样本比例,发现比论文提到的20%高出一倍甚至更多。原因:标签阈值设置或ROUGE计算口径不同。论文里可能用的是ROUGE-1 F值,你用的是ROUGE-L,两者对长句子的打分会差0.1以上;也可能论文在算分数前先把句子做了小写和去停用词处理。解决:复现阶段不要只照抄阈值,先抽10篇文档,人工检查表里“哪些句子被标为1”,确认这些句子确实覆盖摘要核心内容,再正式训练。

5.4 现象:训练完,预测摘要全为空或只有一个句子

现象:验证集上每篇文档输出的摘要都是第一个句子,ROUGE-L几乎等于0。原因:模型把所有样本都预测成了0类别,也就是“全不保留”。本质上是正负样本极度不平衡,正样本占比过低,模型发现全预测0的loss只比预测正确小一点点,于是偷懒。解决:给loss加正样本权重,或者在DataLoader里做正样本过采样。另一个辅助手段是把“预测为正的概率阈值”从0.5下调到0.3,并同时设置“至少保留1个句子”的规则,保证输出不会为空。

5.5 现象:训练集指标很高,验证集摘要完全不可读

现象:训练集ROUGE-L有0.5,验证集只有0.2,且摘要句子之间完全没有衔接。原因:句子级分类任务天然有过拟合风险,模型学到的是“只要包含某些高频词就保留”,而不是真正理解语义。解决:把max_len从128下调到96,增加dropout到0.2,并减少训练轮数到2。还有一个容易被忽视的点:验证集里的文本分布和训练集差距太大时,不要直接继续训练,先把验证集切一部分进训练集,保证两边新闻主题分布一致。

6. 验证一套就够:先把“最小链路”跑通再上全量数据

我第一次复现这类论文代码时,直接拿全量训练集开跑,结果等了三个小时才在验证集上看到一个接近随机的分数。问题不是代码错了,而是我根本没有验证“数据管道→模型前向→标签订阅”这三段是否真的对齐。从那以后,我给自己定了条规矩:任何论文代码到手,先构造一个10条样本的最小数据集,强制跑完整个训练和验证流程。

具体做法是先取10条文档,生成句子级样本,把batch size设为2、训练步数设为50步。跑完后再用同一个验证集脚本查看ROUGE。这个流程能一次性暴露三类问题:数据加载维度不对、tokenizer输出和模型输入不匹配、标签类型不是LongTensor。等最小链路跑通,再把数据集换成1000条,训练1个epoch看loss曲线和ROUGE是否同步变化。

触发了保存checkpoint的习惯后,我在每个epoch结束时还会输出一个predictions.json,里面保存每篇文档的原始句子、模型打分和最终选择的句子。用肉眼扫一遍这个文件,比盯loss曲线更能发现问题:如果模型总是选第一句,大概率是位置偏差;如果选中句子全是长句,可能是ROUGE标签构造时偏好长句。这套验证习惯,我每次复现开源代码都会强制走一遍,哪怕只是换一个数据集,也至少跑一轮最小链路再放开全量。

希望这份微调BERT提取摘要的经验,能帮你在复现和改造时少走几步弯路;拿到论文代码后先把环境、标签、最小训练链路这三件事确认清楚,剩下的调参都只是时间问题。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 5:44:33

WorkBuddy定时任务+微信推送:打造每日AI日报自动化流程

1. 为什么我要给 WorkBuddy 设一个"十点半闹钟"每天早上到工位&#xff0c;第一件事不是泡咖啡&#xff0c;而是打开各种信息源翻一遍&#xff1a;行业新闻、竞品动态、技术社区热帖、昨天没看完的文档更新。这套动作熟练之后大概要花二十分钟&#xff0c;但问题是它…

作者头像 李华
网站建设 2026/9/30 5:44:12

TensorFlow工程实践:从安装陷阱到生产级部署全链路指南

1. 这不是“又一个深度学习框架”——TensorFlow 的真实定位与误用陷阱 很多人第一次听说 TensorFlow&#xff0c;是在某篇“AI入门指南”里看到它和 PyTorch 并列排在“主流框架”名单上&#xff1b;也有人是在公司技术选型会上&#xff0c;听到架构师说“我们用 TensorFlow …

作者头像 李华
网站建设 2026/9/30 5:43:57

判断型AI与TypeSafe结构化输出:从概念到Jev在Codex中的实战指南

没用过Jev之前&#xff0c;我一直觉得"AI大模型"和"写代码调接口"是两套完全不同的思维模式。生成式AI给你一段流畅文本&#xff0c;你得自己去解析、清洗、抽取、判断&#xff0c;然后才能喂给下游程序。而第一次接触Jev那种"TypeSafe判断型AI"…

作者头像 李华
网站建设 2026/9/30 5:42:50

500万元以上信息系统集成项目验收全流程指南

简介&#xff1a;本资源是一份面向信息系统集成项目管理人员、系统架构师及软考高级&#xff08;信息系统项目管理师&#xff09;备考人员的实务指南&#xff0c;聚焦500万元以上大型项目的规范化验收全流程。内容覆盖验收前提条件、组织流程、文档清单、18类核心验收表格模板&…

作者头像 李华
网站建设 2026/9/30 5:42:18

DeepSeek-R1 微调实战:5G 基站侧 LoRA 排障模型训练与部署

简介&#xff1a;这份PDF文档面向电信网络优化工程师、5G基站部署人员及对AI模型落地感兴趣的开发者&#xff0c;聚焦DeepSeek-R1模型在5G基站部署场景中的微调技巧&#xff0c;帮助读者解决网络覆盖、容量与质量优化中的实际难题。资源包共1个PDF文件&#xff0c;大小约1.73MB…

作者头像 李华