简介:面向自然语言处理开发者与研究者的微调BERT提取摘要项目代码包,基于BertSum实现,完整覆盖从数据预处理、预训练模型加载到序列到序列微调、损失优化、ROUGE评估及后处理的流程。压缩包共36个文件,以Python脚本为主(20个py文件),并配有txt数据映射、json配置、markdown说明与license等,整体约14.99MB,目录结构清晰,便于按模块阅读与复现。已有1523人学习下载,适合希望深入理解BERT微调原理并动手实践自动摘要任务的读者。代码中展示了将原始文本转换为Token IDs、Segment IDs和Mask IDs的具体做法,以及如何用Hugging Face Transformers加载预训练模型并在顶部构建生成摘要的解码层,同时提供训练与评估脚本,能帮助快速入门并迁移到其他NLP任务。
1. 当摘要遇上BERT:这套论文代码到底在拆什么
BERT类模型做抽取式摘要,核心思路不是“生成”句子,而是“挑句子”——把原文切成句子后,用BERT给每个句子打分,再按分数挑出Top-N组合成摘要。这个思路在自然语言处理领域已经算是成熟基线,但要真正落地复现,绕不开数据构造、微调参数、推理去重这三道坎。这份论文代码(Python开发方向)的完整价值就在于此:它不只是一份调包脚本,而是把“微调BERT用于提取摘要”的全流程——从训练样本生成到推理输出——都串起来了。适合三类人:刚入坑NLP想做文本摘要的学生、要快速搭摘要基线的算法工程师、以及想验证BERT微调细节的论文复现党。接下来我直接按“数据准备、微调配置、推理优化”的顺序拆,每步都能照做。
2. 训练样本构造:从原始文章到BERT能吃的“句子+标签”
2.1 先理解抽取式摘要的任务形态
抽取式摘要不是让模型写一句话,而是让模型决定“哪些句子应该留下来”。所以训练数据的基本形态是:原文句子序列 + 每个句子的二分类标签(1 表示该句子属于摘要)。这份项目代码的数据处理部分就是干这个的。
通常输入是一篇长文(若干段落),输出是句级标签数组。比如一篇新闻有 12 个句子,人工摘要恰好覆盖其中 4 句,那么标签就是[0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0]。
有人会问:人工摘要里的句子并不一定和原文句子一一对应,这个标签怎么来?这里有一个行业里默认的近似做法——用词级别或句子级别的匹配度来对齐:把摘要句子和原文每个句子做向量或词重叠的相似度计算,超过阈值的原文句子标记为 1。这个项目代码里走的也是这个路线,通常用 ROUGE 或者简单的 TF 重叠就能完成粗对齐。
2.2 句子切分与样本构造:一个可以直接改的脚本
项目里数据预处理的典型步骤,剥开看就是三步:干净化、切句、构造标签。下面给一个简化的数据处理脚本,结构和项目里的prepare_data.py基本一致:
import re import json def split_sentences(text: str, max_sent: int = 64) -> list: """按中文标点切句,并截断过长文档。""" # 先按句号、感叹号、问号切分,保留边界符 parts = re.split(r'(?<=[。!?!?])', text.strip()) sents = [p.strip() for p in parts if len(p.strip()) > 2] return sents[:max_sent] def build_label(sentences: list, summary_sentences: list, threshold=0.4) -> list: """ 用简单词重叠率计算每个句子是否入选摘要。 threshold 是判断重叠率高低的阈值,推荐 0.3~0.5 之间。 """ def tokenize_set(s): return set(re.findall(r'[\w\u4e00-\u9fa5]+', s)) summary_tokens = set() for ss in summary_sentences: summary_tokens |= tokenize_set(ss) labels = [] for sent in sentences: sent_tokens = tokenize_set(sent) if not sent_tokens: labels.append(0) continue overlap = len(sent_tokens & summary_tokens) / len(sent_tokens) labels.append(1 if overlap >= threshold else 0) return labels def process_one_sample(raw: dict) -> dict: """单篇文档处理:切句 + 打标签。""" sents = split_sentences(raw["document"]) labels = build_label(sents, raw["summary"]) return { "doc_id": raw["doc_id"], "sentences": sents, "labels": labels, } # 读取项目里的原始 json 数据 with open("data/raw_samples.json", "r", encoding="utf-8") as f: samples = json.load(f) processed = [process_one_sample(s) for s in samples] with open("data/train_samples.json", "w", encoding="utf-8") as f: json.dump(processed, f, ensure_ascii=False, indent=2)逻辑说明:split_sentences用正则的 lookbehind 保留句子结束符,这一步比直接split('。')稳妥——它不会丢掉标点,后面送入 BERT 时句子边界更自然。build_label是做“伪标签”用的,真实论文数据集里常见做法是直接用摘要原文与句子做匹配,而不是人工逐句标注。
参数说明:threshold控制标签的松紧,值越低,正样本越多;max_sent控制每篇最多保留多少句子,这是为控制训练时的最大序列长度服务的。如果max_sent=64但句子普遍很短,实际 token 量可能仍然不超 512,BERT 还能吃得下。
2.3 数据编码:BERT输入格式与 attention_mask
拿到句子和标签后,下一步是把句子拼成一个超长序列送进 BERT。这里有个关键设计——每个句子前插入一个[CLS]标记,微调时我们提取每个[CLS]对应的输出向量作为该句子的表示。下面是编码部分逻辑:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def encode_document(sentences: list, max_len: int = 512): input_ids = [] token_type_ids = [] attention_mask = [] sent_positions = [] # 记录每个句子对应的 [CLS] 位置 for sent in sentences: # 为每个句子单独加 [CLS] 和 [SEP] enc = tokenizer.encode_plus( sent, add_special_tokens=True, # 自动加 [CLS] 和 [SEP] max_length=64, truncation=True, padding="max_length", # 固定长度,让 batch 内 shape 统一 ) start = len(input_ids) input_ids += enc["input_ids"] token_type_ids += ([0] * len(enc["input_ids"])) attention_mask += enc["attention_mask"] sent_positions.append(start) # 该句 [CLS] 在长序列中的位置 if len(input_ids) >= max_len: break # 截断到 max_len input_ids = input_ids[:max_len] ... return { "input_ids": input_ids, "attention_mask": attention_mask, "sent_positions": sent_positions, "labels": labels, }逻辑说明:这里的思路是逐句编码后拼成一个长序列,并记住每个[CLS]的位置。这种方式比“每句单独过 BERT 再池化”更高效——一次前向就能得到所有句子的表征,这也是论文代码里最常见的实现方式之一。sent_positions是后续提取句子向量的索引,少了它,微调时你根本不知道向量对应哪句话。
参数说明:max_length=64是单句截断,太长的句子我会压到 64 个 token,丢掉后半段;太长反而会让较短句子的表示被填充 token 干扰。padding="max_length"虽然浪费显存,但能省掉动态 padding 的麻烦,工程上先跑通再优化。实际项目里这批数据大约 5000 篇文档,处理完是 100MB 左右的 JSON,后续训练直接从缓存读,不再重复清洗。
3. 微调BERT:模型结构、损失函数与训练参数
3.1 为什么用BERT做抽取式摘要而不是直接上生成模型
绕不开的一个问题是:“摘要不是生成式才有灵魂吗?”论文代码选抽取式,核心原因其实是可控性和效率。生成式模型(比如T5、BART)确实上限更高,但训练成本和对齐难度都大,而且摘要结果完全不可控——你无法指定它必须保留原文的某些句子。
抽取式更香的点在于:决策边界清晰。模型预测的是 0/1 或句子重要性分数,可以直接用 ROUGE 指标对比基线,也能很方便地加入业务规则(比如最多取 N 句、必须包含第一段)。BERT 在这个场景下的角色是句子编码器——它比单纯用 TF-IDF 或 LSTM 编码能捕捉到更充分的上下文语义。
项目代码的模型结构也很典型:BERT 主干 + 一层线性分类头,输出的 logits 经过 sigmoid 得到每个句子的重要性概率。没有花哨的跨句注意力,因为论文里已经验证过,在大多数新闻数据集上,单纯对 [CLS] 向量做分类已经足够打平复杂结构。
3.2 核心模型定义:用transformers实现的最小闭环
下面是一个精简但可以直接练的模型定义,和项目源码里的model.py等价:
import torch import torch.nn as nn from transformers import BertModel class BertForExtractiveSummarization(nn.Module): def __init__(self, pretrained_path="bert-base-chinese", hidden_size=768): super().__init__() self.bert = BertModel.from_pretrained(pretrained_path) # 句子分类头:从768维到1维logit self.classifier = nn.Linear(hidden_size, 1) def forward(self, input_ids, attention_mask, sent_positions): # 走一遍 BERT outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask, return_dict=True, ) # 取出每个句子的 [CLS] 向量 (batch, seq, hidden) last_hidden = outputs.last_hidden_state sent_vecs = [] for b_idx, positions in enumerate(sent_positions): vecs = last_hidden[b_idx, positions, :] # (num_sents, hidden) sent_vecs.append(vecs) # 变回 batch 维度,pad 到相同句数 max_sents = max(v.size(0) for v in sent_vecs) padded = torch.zeros(len(sent_vecs), max_sents, hidden_size) for i, v in enumerate(sent_vecs): padded[i, :v.size(0), :] = v logits = self.classifier(padded).squeeze(-1) # (batch, max_sents) return logits逻辑说明:sent_positions是上一节编码时记录的[CLS]索引列表,前向时通过这个索引把对应位置的 BERT 输出拉出来,形成一个 batch 内的句子向量矩阵。因为每篇文档句子数不一样,这里做了max_sentspadding,计算损失时需要通过labels_mask把无效位置遮掉。
参数说明:hidden_size=768是 BERT-base 的标准配置,如果你换 BERT-large 要改成 1024,同时分类头输入维度也要跟着变。这个模型里没有做句间注意力,不是不能做,而是论文代码为了凸现 BERT 基线的对比效果,刻意保持了极简结构。我自己的习惯是先在最小结构上跑通,再决定要不要加跨句 Transformer 层。
3.3 损失函数与训练配置:正负样本失衡是个坑
摘要场景里正样本(属于摘要的句子)通常只有 20%~30%,这会导致模型动辄把所有句子都预测为 0。训练损失这块,项目里用的是带权重 BCE,而不是普通交叉熵:
class WeightedBCELoss(nn.Module): def __init__(self, pos_weight=2.0): super().__init__() self.pos_weight = torch.tensor(pos_weight) def forward(self, logits, labels, mask): loss_fct = torch.nn.BCEWithLogitsLoss(pos_weight=self.pos_weight) # 只对有效句子位置算 loss active_logits = logits[mask == 1] active_labels = labels[mask == 1].float() return loss_fct(active_logits, active_labels)逻辑说明:BCEWithLogitsLoss把 sigmoid 和交叉熵合在一起,数值上比分开算更稳定。pos_weight人为调高正样本权重,这里设 2.0,意思是“漏掉一个正句子的代价等于误判两个负句子”。这个值不是拍脑袋定的,是通过统计数据集正负比例后算出的近似值——正样本占比约 28% 时,2.0 是经验里比较稳的起点。
参数说明:mask来自编码阶段的sent_positions长度记录,padding 出来的位置 mask 为 0。如果不遮掉这些位置,模型会对一堆全零向量做预测,损失会虚低,指标虚高,这个我在刚复现时踩过,后面避坑章节细说。
训练超参我按项目里的配置给出参考:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| learning_rate | 2e-5 | BERT 微调的标准起点,太大容易灾难性遗忘 |
| batch_size | 8 | 单卡 16G 显存刚好,batch 大点训练更稳 |
| max_seq_len | 512 | BERT 的硬上限,超过会被截断 |
| epochs | 3 | 复现实验结果,3 个 epoch 基本收敛 |
| warmup_ratio | 0.1 | 前 10% 的 step 线性预热 |
| scheduler | linear decay | 配合 warmup 是最通用组合 |
训练循环本身没有太特别的地方,无非是optimizer.zero_grad()、loss.backward()、optimizer.step()三连。需要留意的是max_grad_norm=1.0,BERT 微调不设梯度裁剪,炸掉 loss 的概率会明显增加,这是我在实际训练中反复验证过的。
4. 避坑指南:从数据到推理的五个经典翻车现场
4.1 坑一:模型训练完,摘要全是开头几句话
现象:验证集 ROUGE-1 分数还可以,但人工看一眼生成的摘要,发现永远是原文前 2~3 句,后面的句子完全没有被选进来。
原因:绝大多数训练数据里,人工摘要确实偏好覆盖开头句子,这导致正样本极度集中在文档头部。模型学到的是“位置先验”而不是“语义重要性”。
解决:训练数据构造阶段加入随机文档截断——随机扔掉开头或中间的部分句子再训练。我在项目里加了一个random_crop_sentences的步骤:以 0.7 概率随机删掉前 20% 的句子。这样模型必须靠语义判断,而不是位置判断。加了这步之后,验证集的人工评测明显改善。
4.2 坑二:标签构造得太紧,模型召回率极低
现象:验证集 ROUGE-2 只有个位数,而且模型几乎把所有句子都判成 0。
原因:标签生成时用的词重叠阈值太高(比如 0.6),导致正样本太少。BERT 微调阶段正负样本比例失衡,模型干脆“摆烂”全预测为 0。
解决:把阈值降到 0.35,同时限定一个下限——每篇文档至少要有 2 个正样本。如果匹配结果不足 2 句,直接取重叠率最高的两个句子作为正样本。这个“保底策略”是文本摘要数据构造里常用的经验做法,效果立竿见影。
4.3 坑三:显存溢出,单卡根本跑不起来
现象:batch_size=4 时显存就满了,报CUDA out of memory,明明数据量不大。
原因:坑在数据编码阶段的padding="max_length"。假设每句固定 64 token,一篇 30 句的文档按 64 长句编码,总序列长度已经是 64×30=1920,远超 512,然后在 truncation 时只有尾部被截掉,前面的 padding 和真实句混在一起,显存就被无效位吃掉了。
解决:切换到动态 padding,也就是说batch内所有序列统一 padding 到当前 batch 最长序列的长度,而不是全局固定 512。项目代码里如果默认固定 padding,建议改成padding="longest"。另外,用gradient_accumulation_steps=4配合batch_size=2也是跑通大模型的标准姿势。
4.4 坑四:训练损失收敛了,但验证指标乱跳
现象:训练 loss 平滑下降,但每跑完一个 epoch 验证 ROUGE 波动很大,甚至一个 epoch 比另一个 epoch 低 3 分以上。
原因:验证集太小,且评测时用贪心解码(每次取概率最高的句子)导致方差大。摘要任务的验证集一般要至少 500 篇文档,低于这个量级,ROUGE 指标很容易“抽风”。
解决:评估时用多个随机种子跑三遍取平均,或者直接改用更稳的指标组合,比如 ROUGE-1 和 ROUGE-L 一起看。此外确认验证时的句子打分是否经过 sigmoid 后再排序——如果直接排 logits,训练和推理的分布不一致,指标自然会乱。
4.5 坑五:用last_hidden_state取句子向量,结果句间区分度很差
现象:模型输出的句子向量两两之间余弦相似度全在 0.9 以上,分类头怎么训都分不开。
原因:直接用 BERT 最后一层的[CLS]向量做句子表示时,BERT 本身的各向异性(anisotropy)会让所有句子向量挤在一个狭窄锥形空间里。这是 BERT 类模型的已知问题,不加处理,句子区分度很低。
解决:在取向量之前加一层简单的标准化——nn.LayerNorm作用到句子向量上。这是个很小的改动,但实验里能明显提升分类头的收敛速度。如果项目代码里没有这层,建议加上;加了之后 F1 通常能涨 1~2 个点。
5. 推理与进阶:把分数变成可读摘要的两个细节
训练结束后只完成了一半,真正的效果在推理阶段见分晓。抽取式摘要的推理不是简单“取 Top-N”,有两个细节会直接影响最终输出质量。
第一个细节是句子顺序还原。模型按重要性排序后,不能按分数从高到低拼接摘要,而是要把选中的句子放回原文位置,按原文顺序输出。虽然看起来是小事,但漏掉这一步的代码生成出来的摘要读起来常常逻辑混乱——比如先讲结论再讲背景。项目代码里的restore_order函数就干这个事:
def generate_summary(sentences, scores, top_k=3): # 按分数取 top_k 句子的下标 top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k] # 关键:按原文顺序重新排序 top_indices.sort() summary = "".join([sentences[i] for i in top_indices]) return summary第二个细节是冗余控制。新闻摘要里,前两句和后两句经常讲的是同一件事——比如“某公司发布财报”和“该公司净利润同比增长20%”信息高度重叠。直接按分数挑,最终摘要里可能连续三句在说同一件事。我一般会在推理后加一个简单的文本相似度过滤,相邻句子的余弦相似度超过 0.7 时,只保留分数高的那个。这种做法不算多高大上,但能让摘要的“信息密度”明显改善,在ROUGE-L这类指标上也能看到稳定提升。
评测时除了 ROUGE,我建议顺带用 BERTScore 看一眼语义保持度。ROUGE 纯粹看词面重叠,有时候模型抽出的句子词都对但语义歪了,两个指标一起看才不容易被单指标带偏。后来我形成了个习惯:每次微调 BERT 做摘要,我都会固定跑一遍同样的验证流程——先检查正样本比例,再看 top 句是否全是开头句,然后才看 ROUGE 分数。这三步走完,项目的质量大概心里有数了。希望这份拆解能帮你少走些弯路,更快把资源用起来。
本文还有配套的精品资源,点击获取