简介:这套代码面向自然语言处理学习者和研究者,围绕BERT微调实现抽取式摘要生成,基于BertSum-master项目整理而成。内容覆盖从原始文本预处理(分词、添加[CLS]/[SEP]标记)、加载预训练模型、构建编码器-解码器结构、设计交叉熵损失与Adam优化,到训练评估与ROUGE指标计算、后处理去除特殊标记的完整流程,适合希望深入理解BERT原理并实践摘要任务的读者。压缩包共36个文件,以20个Python脚本为主体,覆盖prepro、train、models等关键模块;另有7个txt映射文件、2个json配置、README说明文档及license,整体大小约15MB,目录结构清晰、层次分明。目前已有1523人学习下载。通过研读这批代码,可掌握Tokenizer处理、微调配置、解码器设计等关键实现,还能获得从原始语料到生成摘要的全链路工程实现思路,是一份便于对照学习、二次开发及论文复现的NLP实战参考资料。
1. 项目定位:一份能直接跑通的BERT微调摘要论文代码
拿到论文代码却跑不出论文分数,是NLP复现最常见的翻车现场。这份代码不是那种只有readme的裸仓库——它把微调BERT做提取式摘要的完整链路都串起来了:从原始文档切句、生成oracle标签,到BERT二分类头训练,再到ROUGE评估和预测推理。核心思想是:把“生成摘要”降维成“给每个句子打0/1标签”,用预训练BERT做编码器,少量标注数据就能在新闻摘要、报告压缩这类任务上拿到可用效果。适合想复跑论文实验的NLP学习者,也适合需要快速搭一套文本摘要小工具的开发者。
2. 技术栈与目录结构:从模型选型到数据流
2.1 技术选型:抽取式摘要的核心假设
先明确抽取式摘要在做什么。给定一篇文档,抽取式模型从原文里挑出若干句子,按原文顺序拼成摘要。这和生成式(比如BART、T5直接生成新句子)最大的区别在于:抽取式不产生新表达,因此不容易出现事实性错误,也更适合做可控压缩——你想要两句话的摘要,就只挑两个句子拼接。论文里选BERT而不是其他模型,是因为BERT的双向编码能力能比较好地建模句子在文档中的上下文语义。判断某个句子是否重要,靠的不只是句子本身,还有它和前后句、和文档标题的关系。TextRank这类图算法虽然也能做抽取,但本质上是无监督的,靠词频和共现信息,遇到语义隐晦的句子很容易翻车;BERT微调之后学到的更多是语义层面的重要性判断。
技术栈上,这份代码走的是当前NLP实验的标准组合。模型部分基于HuggingFace Transformers加载预训练BERT权重,框架用PyTorch 2.x,数据加载用Datasets库,评估指标用rouge-score包,配置管理用YAML。整套依赖都写在requirements.txt里,我建议在跑之前用conda新建一个干净环境,别往系统Python里装。曾经有人在base环境里装,结果torch版本冲突,一跑就core dump,浪费了一个下午。
| 方案 | 是否需标注 | 是否生成新句 | 训练成本 | 适用场景 |
|---|---|---|---|---|
| TextRank图排序 | 否 | 否 | 无 | 快速基线、低资源场景 |
| BERT抽取式微调 | 是 | 否 | 单卡即可 | 新闻摘要、报告压缩 |
| BART/T5生成式微调 | 是 | 是 | 多卡或长时间 | 开放性摘要、改写 |
2.2 目录结构:配置、数据、脚本各管一摊
这份代码的目录结构属于“一看就知道在哪改配置”的类型,没有把逻辑堆在单个notebook里。第一次接触时,对着这个结构十分钟就能定位到训练入口和模型定义:
. ├── config/ │ ├── base.yaml # 默认训练参数 │ ├── experiment_1.yaml # 单次实验配置 │ └── dataset.yaml # 数据集路径与切分 ├── data/ │ ├── raw/ # 原始语料放这里 │ ├── processed/ # 预处理后的训练/验证/测试集 │ └── cache/ # tokenize之后的缓存 ├── scripts/ │ ├── prepare_data.py # 文档切句 + 生成oracle标签 │ ├── train.py # 训练入口 │ ├── evaluate.py # ROUGE评估 │ └── predict.py # 对任意文本生成摘要 ├── src/ │ ├── model.py # BERT + 分类头 │ ├── dataset.py # Dataset与DataLoader构造 │ ├── loss.py # 损失函数与类别权重 │ └── utils.py # 日志、保存、随机种子 ├── outputs/ │ ├── checkpoints/ # 模型权重 │ └── logs/ # TensorBoard日志 ├── requirements.txt └── README.md这个结构是经过几个项目磨合后的典型做法:config放参数,scripts放可执行入口,src放被调用的模块,outputs放产物。跑实验时改YAML就行,不用改代码;出问题了先看日志,权重和缓存分开,不会污染数据。我拿到别的论文代码时也会先按这个思路找入口,比直接读源码快得多。有的论文代码把train、eval、predict全塞进一个utils.py里,那才是黑匣子,改一个参数要找半天。
2.3 配置文件解析:一个YAML控制所有实验
config/base.yaml是这份代码的“总开关”。我把它拆开讲,每个参数都是一个实验变量:
model: pretrained_name: bert-base-chinese # 预训练模型名称 max_seq_len: 512 # BERT最大输入长度 hidden_dropout: 0.1 # 分类头前的dropout data: train_file: data/processed/train.jsonl valid_file: data/processed/valid.jsonl test_file: data/processed/test.jsonl max_sent_num: 100 # 每篇文档最多取多少句 train: batch_size: 8 gradient_accumulation_steps: 4 learning_rate: 2e-5 warmup_ratio: 0.1 num_epochs: 3 max_grad_norm: 1.0 positive_weight: 3.0 # 正样本类别权重 fp16: true # 混合精度训练 eval_steps: 500 # 每500步评估一次 early_stop_patience: 3 # 验证集连续3次不升就停这些参数怎么理解?learning_rate用2e-5是BERT微调的经验值,太大会把预训练权重冲坏,太小收敛慢。warmup_ratio=0.1表示前10%的step学习率线性上升,后面按余弦或线性衰减,这是Transformers训练器的标准配置。positive_weight=3.0是因为大多数句子不是摘要句,正负样本比例可能到1:10,给正样本加权能缓解不平衡。这个参数是这份代码最值得调的地方,直接决定模型会不会“偷懒”全预测为0。
max_seq_len=512是BERT的硬上限,但意味着长文档必须先截断或者走滑动窗口,我在第5章避坑里专门说这个。max_sent_num=100控制每篇文档最多取多少句,超过的部分直接丢掉,对中文新闻来说100句基本够用。
3. 核心模块拆解:从原文句子到摘要句的完整链路
3.1 数据预处理:切句与oracle标签生成
抽取式摘要训练最关键的步骤是:给每个句子准备0/1标签。人工标注成本太高,论文里的常见做法是用oracle标签——把标准摘要和每个候选句算ROUGE分数,分数超过阈值的句子标为1。这个过程是离线完成的,代码在scripts/prepare_data.py里:
import json import re from rouge_score import rouge_scorer scorer = rouge_scorer.RougeScorer(["rouge1", "rouge2", "rougeL"], use_stemmer=True) def split_sentences(document): # 按中文标点切句,保留长度>5的句子 raw = re.split(r"[。!?.!?;;]", document) return [s.strip() for s in raw if len(s.strip()) > 5] def build_oracle(document_sentences, reference_summary, threshold=0.4): labels = [] for sent in document_sentences: scores = scorer.score(reference_summary, sent) rouge1_f = scores["rouge1"].fmeasure labels.append(1 if rouge1_f >= threshold else 0) return labels这段代码的逻辑是:对文档里的每个句子,和标准摘要计算ROUGE-1的F1值,超过threshold就认为这个句子“和摘要高度重合”,可以当作正样本。threshold是个需要调的超参,设太低会把普通句子都标成正样本,设太高正样本数量又不够,一般先在0.3到0.5之间试。切句的正则只能处理中文标点,遇到“3.14”“U.S.”这类带英文缩写的文本会切错,工程化时要换成更成熟的分句库。
这个函数跑完,每条训练样本变成jsonl格式,一行一个句子:
{"doc_id": "123", "sentence": "公司发布2024年第三季度财报。", "label": 1} {"doc_id": "123", "sentence": "净利润同比增长20%。", "label": 1} {"doc_id": "123", "sentence": "会议还讨论了其他事项。", "label": 0}3.2 模型定义:BERT + 二分类头
模型结构不复杂:预训练BERT负责把句子编码成向量,一个线性层负责分类。src/model.py里的核心代码:
import torch.nn as nn from transformers import BertModel class BertExtractiveSummarizer(nn.Module): def __init__(self, pretrained_name="bert-base-chinese", hidden_dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(pretrained_name) self.dropout = nn.Dropout(hidden_dropout) self.classifier = nn.Linear(self.bert.config.hidden_size, 1) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) # 取[CLS]位置的向量作为整句表示 cls_vec = outputs.last_hidden_state[:, 0, :] logits = self.classifier(self.dropout(cls_vec)) return logits.squeeze(-1)参数说明:hidden_size在BERT-base里是768,所以classifier输入维度是768、输出维度1,得到的是该句子“是否属于摘要句”的logit分数。取[CLS]位置的向量是BERT做分类任务的标准姿势,[CLS]经过预训练后能聚合整句信息。squeeze(-1)是把形状从(batch, 1)压成(batch),方便直接和标签数组计算BCE损失。
有个细节值得注意:这里每个句子独立过BERT,没有把文档里所有句子拼在一起。好处是实现简单、显存可控,坏处是句子之间完全失去上下文关联。论文里吃句间上下文的做法是拼成“[CLS] 文档 [SEP] 句子 [SEP]”再送入BERT,但那样要处理动态padding和attention mask,代码复杂度高不少。这份代码选择了独立编码路线,我认为是在复现成本和效果之间取了平衡。
3.3 训练循环:损失、优化器与梯度累积
训练脚本train.py是整份代码最“论文风”的部分。损失用了带权重的BCEWithLogitsLoss,优化器用AdamW,调度器用线性warmup:
import torch import torch.nn as nn from transformers import AdamW, get_linear_schedule_with_warmup criterion = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([positive_weight])) optimizer = AdamW(model.parameters(), lr=learning_rate) total_steps = len(train_loader) * num_epochs // gradient_accumulation_steps scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * warmup_ratio), num_training_steps=total_steps ) for step, batch in enumerate(train_loader): input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].float().to(device) logits = model(input_ids, attention_mask) loss = criterion(logits, labels) loss.backward() if (step + 1) % gradient_accumulation_steps == 0: nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) optimizer.step() scheduler.step() optimizer.zero_grad()说明几点。pos_weight参数直接改变正样本的梯度权重,positive_weight=3.0代表正样本的loss是负样本的3倍,这比简单调整阈值稳得多。梯度累积的作用是变相增大batch size:实际生效的batch_size是8 × 4 = 32,显存不够时常用这个trick,代价是训练速度变慢。clip_grad_norm让梯度范数不超过1.0,防止个别样本把BERT权重带偏,这个值设太小会收敛慢,太大则起不到保护作用。
这里有个常见的理解偏差:梯度累积不是每步都更新权重,而是在累积满4个小batch后才step一次。所以scheduler的total_steps计算要除以gradient_accumulation_steps,否则学习率衰减速度会比预期快好几倍,模型还没训完就进入极小学习率阶段。
3.4 解码推理:从预测分数到摘要文本
训练完,predict.py加载权重对整篇文档做推理。流程是:先按标点切句,逐句过模型拿logit,再按分数排序,取top-k句按原文顺序拼接。k可以由长度预算决定:
import re import torch def predict_summary(document, tokenizer, model, k=3): sentences = re.split(r"[。!?.!?]", document) sentences = [s for s in sentences if len(s) > 5] scores = [] for sent in sentences: inputs = tokenizer(sent, max_length=512, truncation=True, return_tensors="pt") inputs = {name: tensor.to(device) for name, tensor in inputs.items()} with torch.no_grad(): logit = model(**inputs).item() scores.append(logit) top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k] top_indices = sorted(top_indices) # 按原文顺序重新排序 return "。".join(sentences[i] for i in top_indices)细节在最后两步:先按分数降序取前k个句子的下标,再把这些下标升序排列,恢复成文档中的原始顺序。如果不做第二次排序,摘要读起来就是信息拼盘,前后逻辑完全断掉。这个坑我在第一次写解码脚本时候踩过,生成的摘要像抽签抽出来的,后来才明白抽取式摘要必须保证输出顺序和原文一致。
还有一个可以改造的点:现在k是固定的,实际业务里更常见的需求是“摘要不超过300字”。可以改成循环累加句子长度,超过预算就停,这样摘要不会突然长出两行字。
4. 运行指南:从零把训练和评估跑起来
4.1 环境准备与依赖安装
这份代码依赖不算多,核心就是PyTorch和Transformers。我第一次复现的时候被torch版本坑过一次,后来固定用这套组合就没再出过问题:
conda create -n bert-summary python=3.9 -y conda activate bert-summary pip install torch==2.1.0 pip install transformers==4.36.0 datasets==2.16.0 rouge-score==0.1.2 pyyaml==6.0解释一下为什么锁版本:Transformers 4.36和Datasets 2.16这对组合在tokenize缓存上很稳定,太新的版本有时会改变缓存格式,导致二次运行时要重新处理数据。装好后用python -c "import transformers; print(transformers.version)"确认。如果机器没有GPU,把torch换成CPU版也能跑,但训练速度会慢几十倍,建议至少用一张8G显存的卡起步。
4.2 数据准备与配置调整
数据准备是这份代码最花时间的环节。我拿中文新闻摘要数据集试跑通过。先下载原始语料解压到data/raw/,然后跑预处理脚本:
python scripts/prepare_data.py --input data/raw/news.json \ --output data/processed/train.jsonl --split train python scripts/prepare_data.py --input data/raw/news.json \ --output data/processed/valid.jsonl --split valid注意:prepare_data.py会做两件事,一是对长文档做截断,二是生成oracle标签。如果你的文档特别长,先改config里的max_sent_num,把每篇文档的句子数上限调小,能明显降低数据预处理和训练的内存开销。原始语料最好切出train/valid/test三份,比例8:1:1,别把验证集和训练集混在一起。
数据格式是标准的jsonl,每行一个文档对象,包含doc_id、document、summary三个字段。如果你的数据是CSV格式,需要先转成这个结构,或者改prepare_data.py里的读取逻辑。
4.3 训练启动与日志监控
配置没问题后,一行命令启动训练:
python scripts/train.py --config config/base.yaml训练过程会打印每个step的loss和learning rate,同时把TensorBoard日志写到outputs/logs。我习惯在另一个终端开tensorboard --logdir outputs/logs看曲线。重点关注两个东西:loss是否在500步内快速下降,以及validation的ROUGE-1分数是否跟着涨。如果loss降但ROUGE不涨,先检查oracle标签生成是不是出了问题——最常见的是threshold设太高,正样本太少,模型学不到有效信号。
训练中断了也不用从头来。这份代码会在outputs/checkpoints里同时保存latest.pt和best_model.pt,重启时指定--resume outputs/checkpoints/latest.pt就能接着训。我的习惯是每跑完一次实验就把config复制一份命名成experiment的数字编号,这样事后能知道某个模型是用哪组参数训的,不至于训了十个小时忘了超参。
4.4 评估:用ROUGE验证效果
训练结束后跑评估:
python scripts/evaluate.py --checkpoint outputs/checkpoints/best_model.pt \ --config config/base.yaml评估脚本输出ROUGE-1、ROUGE-2、ROUGE-L的precision、recall、fmeasure。论文里一般报F1。这里有个常识性提醒:ROUGE分数高度依赖分词方式,中文语料最好在切分时使用相同的分词器,否则对比不同论文的数字没意义。比如一个用了jieba分词,一个用字符级切分,同一模型跑出的ROUGE-1能差3个点以上。
评估结果出来后如果和论文差得不多,说明复现成功。如果差得多,先回去检查数据预处理和oracle阈值,再做模型本身的对比。我的经验是:数据预处理造成的差异远大于模型结构差异,这个顺序不要搞反。
5. 避坑指南:BERT微调摘要的五条血泪经验
5.1 ROUGE评估分数怎么都对不上论文
复现时跑出来的ROUGE-1比论文低好几个点,模型结构、超参全部一致,最后发现是oracle标签生成标准不同。论文用的threshold可能是0.5,我用的0.3,正样本数量差了一倍,模型学到的决策边界完全不一样。解决:先复现数据预处理的每个数值,再谈模型差异。另外英文语料开use_stemmer=True,中文语料确保分句和分词一致,否则ROUGE计算本身就有偏差。
5.2 长文档一进BERT就丢信息
BERT的max_seq_len是512,直接把长文档截断,后半段的重要信息全丢。文档一长,摘要质量断崖下跌。解决:不要整篇喂BERT,先按句切分,改成句子级别的独立编码。我一般把max_sent_num设为100,配合按句编码,基本覆盖大多数新闻。如果句子本身超过512个token,再对句子内部做截断,优先保留开头和结尾。
5.3 正样本太少,模型学成“全部预测为0”
抽取式摘要的正样本比例通常只有10%~20%,不处理的话模型会“偷懒”把所有句子预测为0,训练loss照样很低,但评估时ROUGE全是0。这就是典型的“训练指标没崩、上线效果全挂”的玄学场景。解决:BCEWithLogitsLoss设置pos_weight在2~5之间,或者对负样本做下采样。判断方法是看验证集的预测分布,如果正样本预测率接近0,大概率是权重没设。
5.4 单卡16G显存照样OOM
batch_size设成16,加上梯度累积,16G显存直接爆掉。原因:BERT-base每个句子编码要占用不少中间激活值,句子长度512时batch_size能到8就不错了。解决:batch_size降到4~8,开fp16混合精度,配合gradient_accumulation_steps=4把有效batch_size补回32。显存还紧张就把max_seq_len降到256试试,对中文短句来说影响有限。
5.5 验证集ROUGE分数忽高忽低
每500步评估一次,分数波动很大,看不出模型在收敛。原因:验证集太小,或者oracle标签本身噪声大。我犯过的错是用200条做验证集,ROUGE波动上下2个点,根本判断不了早停。解决:把验证集扩到至少1000条,评估时固定随机种子,确保每次评估的样本顺序一致。如果还是波动,检查是不是有重复文档混进了训练集和验证集。
提示:这一章每一条都对应一个真实的失败记录,代码能跑不代表能复现论文效果,指标对不上先怀疑数据侧。
6. 二次开发:把摘要模型改造成业务工具
6.1 从抽取式换到生成式
业务上需要生成原文没有的新句子(比如把口语改成书面语),抽取式就不够用了。改造思路:模型换成BART或T5,训练目标从二分类改成teacher forcing的交叉熵,输入变成“文档”,输出变成“摘要文本”。数据标注不用变,还是用标准摘要,但要准备encoder端的输入和decoder端的输出两套tokenize结果。
6.2 从离线实验到在线服务
用FastAPI包一层接口,文件小、性能够用:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class DocRequest(BaseModel): text: str k: int = 3 @app.post("/summarize") def summarize(req: DocRequest): result = predict_summary(req.text, tokenizer, model, k=req.k) return {"summary": result}这个接口部署的时候可以用ONNX导出模型,推理速度提升明显。导出前记得把tokenizer一并保存,部署环境只用transformers的ONNX runtime加载,能省掉一整个torch依赖。
6.3 验证方法:回归测试与指标对比
改模型之前先保存一份baseline摘要,每次改完跑一遍对比,ROUGE不掉才能合入。我习惯把测试集固定成50条典型样本,涵盖长短文档、政治新闻之外的常规题材,跑完自动输出ROUGE对比和人工检查清单。
从那以后,每次复现NLP论文代码我都强制走一遍:先跑通小数据集、再对齐指标、最后才改结构。抽签式改参数的做法已经彻底戒掉了,宁可多花一小时看数据,也不愿意训十个小时训出一个无效模型,希望帮到你。
本文还有配套的精品资源,点击获取