ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于Python的GPT-2中文摘要生成模型代码实现与调优

基于Python的GPT-2中文摘要生成模型代码实现与调优

简介:面向需要快速实现中文文本摘要的Python开发者,这套代码基于GPT-2模型提供完整可运行的中文摘要生成实现。内容覆盖transformers与torch环境配置、预训练中文模型加载、文本分句编码、generate参数调优以及tokenizer解码等关键环节,并额外讨论Finetuning、BERTSUM、Lead-3等优化思路,适合NLP入门者或希望在项目中落地摘要能力的技术人员参考。包内共108个文件,以105个Python脚本为主,辅以2个说明文档和1个模型配置文件,压缩包仅458KB;py文件按功能拆分,模块边界清晰,便于逐个调试与二次开发。资源已有3580人下载学习,可直接在本地运行并替换任意Hugging Face中文GPT-2模型权重,帮助理解摘要生成全链路,也可作为课程设计或工程实践的起步模板。

1. 为什么“基于 Python 的 GPT2 中文摘要生成模型代码实现”值得自己跑一遍

写摘要这件事,很多团队第一反应是调大模型 API,但落到具体场景——新闻聚合、客服工单归纳、内部文档缩减——你会发现通用接口要么贵,要么格式不合。于是自然想到一个问题:用 GPT-2 这种开源模型,自己在 Python 里跑一套中文摘要生成,到底可不可行?答案是可不可行取决于你怎么用。GPT-2 不是专门为摘要设计的,但它作为 decoder-only 的语言模型,在微调之后做生成式摘要,效果能超过不少团队用 BERT 拼接出的“抽取式”方案。这条路适合有 Python 基础、熟悉基础 NLP、想绕过 API 依赖的开发者。下面这套代码和参数,基本能让你从一个空白目录跑到能出摘要的检查点。

2. 选型与数据准备:GPT-2 凭什么能做中文摘要

2.1 生成式和抽取式的差别,以及为什么这里选生成式

文本摘要分两类。抽取式从原文里挑几句话拼成摘要,实话说实现简单,训练也快,但句子级别拼接容易读不通。生成式要模型自己写新句子,难度上一个台阶,可摘要质量上限高得多。GPT-2 属于生成式阵营,它没有 encoder 模块,输入直接是 token 序列,预测下一个 token。摘要任务在它眼里就是“给定原文 + 起始提示,续写出概括内容”。这个建模方式有个好处:模型从头到尾用的是同一套语言建模损失,不需要像 BERT 那样为分类头单独加任务层。

中文场景下,选择 GPT-2 还有个现实理由:中文不需要刻意做形态还原,词边界本身就是个问题,而 GPT-2 用 tokenizer 把文本切成子词,天然避开“词表爆炸”。对比 T5 和 BART,这两者做摘要效果确实更强,但它们属于 encoder-decoder 结构,训练开销比 GPT-2 大得多。如果你只有一块消费级显卡,GPT-2 的 decoder-only 结构能在可控资源下跑出可用结果。

2.2 中文数据清洗:三个步骤决定训练成败

数据比模型更决定摘要的下限。中文摘要数据集常见的格式是一组 JSON 行,每条有source和summary两个字段。清洗时我一般会做三件事。第一,去除重定向字符和 HTML 标签,中文新闻源里的<p>标签经常混进来;第二,统一标点,全角半角混合会让 tokenizer 把同一句话切出两种表达;第三,长度过滤,原文超过 512 token 的干脆丢掉,摘要短于 10 个字或长于原文一半的也不留。

这里有个容易忽略的点:GPT-2 的 tokenizer 会把连续数字或英文切成子词,中文标点另占 token。所以清洗时把数字统一成半角写法,能避免同一个数字被切出多种组合,变相减少模型要记忆的无关模式。清洗完的数据集尽量按 95:5 切分训练和验证集,验证集要保证抽样,不要取最后一段,否则摘要主题都集中在某个领域。

2.3 构建训练样本:从文本到 input_ids

GPT-2 训练摘要的基本输入形式是“原文 + 分隔符 + 摘要 + 结束符”。分隔符我在实践里直接用[SEP],在 tokenizer 里注册成特殊 token。代码里最核心的是截断策略。

# build_samples.py # 把 (原文, 摘要) 对拼成 GPT-2 接受的文本序列并截断 import json import torch from transformers import GPT2Tokenizer SEP = "[SEP]" def build_samples(data_path, tokenizer, max_src_len=512, max_tgt_len=128): samples = [] with open(data_path, "r", encoding="utf-8") as f: for line in f: item = json.loads(line) src = item["source"] tgt = item["summary"] # 拼接后一次性编码,避免两次 encode 在边界处产生冗余空格 text = src + SEP + tgt + SEP ids = tokenizer.encode(text) # 整体超过上限时,从尾部截断会丢摘要,所以先截原文 if len(ids) > max_src_len + max_tgt_len: src_ids = tokenizer.encode(src, truncation=True, max_length=max_src_len) tgt_ids = tokenizer.encode(tgt + SEP, truncation=True, max_length=max_tgt_len) ids = src_ids + tgt_ids samples.append(ids) return samples

这段代码先对整个拼接文本做一次编码,超长时再分别截断原文和摘要。原因是直接tokenizer.encode(text, max_length=...)在超出长度时只保留前max_length个 token,摘要作为后半部分基本会被截没。分开截断至少保证摘要尾部最关键的结尾标记还在。注册[SEP]的方法是用这两种 tokenizer 方法之一:tokenizer.add_special_tokens({"additional_special_tokens": [SEP]}),随后一定要重置语言模型的 token 嵌入尺寸,否则模型不知道新 token 的嵌入向量。

注意:截断对象不同,训练效果差别很大。把摘要截没等于给模型灌了“原文+原文”,它会学成复读机,这是后面避坑章节里最常看到的病。

3. 训练脚本怎么搭:加载 GPT-2、写 DataLoader、跑通最小训练循环

3.1 用 HuggingFace 加载 GPT-2 与中文分词器

加载模型用transformers库里的GPT2LMHeadModel,中文预训练权重通常选uer/gpt2-chinese-cluecorpussmall,这个权重在中文语料上做过继续预训练,直接用它微调摘要任务比随机初始化省一个月时间。加载完成后的第一件大事是设置pad_token,GPT-2 原始权重里只定义了eos_token,没有 pad token,DataLoader 里做 padding 时会直接报错。

# load_model.py # 加载 GPT-2 中文模型并完成必要设置 from transformers import GPT2LMHeadModel, GPT2Tokenizer tokenizer = GPT2Tokenizer.from_pretrained("uer/gpt2-chinese-cluecorpussmall") model = GPT2LMHeadModel.from_pretrained("uer/gpt2-chinese-cluecorpussmall") # GPT-2 没有 pad_token,需要手动用 eos_token 充当 tokenizer.pad_token = tokenizer.eos_token model.resize_token_embeddings(len(tokenizer))

第二行resize_token_embeddings是把模型的词嵌入矩阵扩大到当前 tokenizer 的词表大小。即便你没有新增任何 token,执行这一步也是安全的。真正要注意的是:pad_token和eos_token用同一个 token 时,标签里如果有 padding 位置,模型学习从 padding 位置预测 eos_token,训练损失会有轻微污染。后面写训练循环时会把 padding 位置在 loss 计算中屏蔽掉。

3.2 最小训练循环:loss 要自己盯

摘要训练的核心是语言建模损失。因为 GPT-2 是因果语言模型,输入input_ids同时也作为标签,模型对每个位置预测下一个 token。但这里有个专业细节:全序列都参与 loss,会让模型把重点放在原文预测上,摘要区域的信息反而淹没在长序列里。实践中最常见的做法是只对摘要部分计算 loss。

# train_gpt2_summary.py # 训练循环:只计算摘要区域 token 的 loss import torch from torch.utils.data import DataLoader from transformers import GPT2LMHeadModel, GPT2Tokenizer, AdamW device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = GPT2LMHeadModel.from_pretrained("uer/gpt2-chinese-cluecorpussmall") tokenizer = GPT2Tokenizer.from_pretrained("uer/gpt2-chinese-cluecorpussmall") tokenizer.pad_token = tokenizer.eos_token model.to(device) # 假设 batch 里每个样本是 dict: {"input_ids": tensor, "attention_mask": tensor, "summary_start": int} def compute_loss(batch): input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) logits = model(input_ids, attention_mask=attention_mask).logits shift_logits = logits[..., :-1, :].contiguous() shift_labels = input_ids[..., 1:].contiguous() loss_fct = torch.nn.CrossEntropyLoss(reduction="none") loss = loss_fct(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1)) loss = loss.view(shift_labels.size(0), shift_labels.size(1)) # 摘要开始前的位置 loss 置 0,只保留摘要后缀区域 for i in range(loss.size(0)): start_pos = batch["summary_start"][i] loss[i, :start_pos - 1] = 0.0 # padding 为 eos_token 的位置贡献了 eos 预测,不影响摘要主体 return loss.sum() / loss.size(0) optimizer = AdamW(model.parameters(), lr=5e-5) for epoch in range(3): for step, batch in enumerate(dataloader): loss = compute_loss(batch) optimizer.zero_grad() loss.backward() optimizer.step() if step % 100 == 0: print(f"epoch {epoch} step {step} loss {loss.item():.4f}")

这段循环里最关键的是summary_start这个字段。训练前构建样本时,每个样本记录[SEP]首次出现的位置,也就是摘要开头。计算 loss 时把该位置之前的损失全部清零,让模型专注于学习“从[SEP]之后怎么续写摘要”。如果你的 GPU 显存足够大,想省事的直接传入labels=input_ids也能跑,但那个 loss 里一半是原文文本预测,收敛效果会比较奇怪。嫌循环写得麻烦的人偶尔直接丢官方Trainer,但Trainer默认不按区域算 loss,自定义 loss 反而隐藏得深,调试成本不低。

3.3 检查点保存与恢复

训练断掉是常态,不是意外。我一般每 500 步存一次检查点,至少保留模型权重和tokenizer 配置两个目录。恢复训练时从检查点加载权重,再把学习率降一半。这里有个很多新手会遇到的坑:加载时直接model = GPT2LMHeadModel.from_pretrained(checkpoint_dir),却忘记重新注册pad_token,结果继续训练时 DataLoader 的 collate 函数因为 pad 行为不一致产生 tensor 尺寸报错。保存和恢复用同一个脚本是最稳的,把 tokenizer 设置写进恢复流程里。

# 保存目录结构建议 # ./checkpoints/epoch2_step500/ # ├── pytorch_model.bin # ├── config.json # └── tokenizer files

恢复训练时resize_token_embeddings这一步不能漏。模型词表和 tokenizer 词表如果尺寸不一致,加载权重时尾部向量被随机初始化,训练会突然出现非预期的 loss 异常。

4. 生成摘要时的参数怎么调:beam search、重复惩罚、长度上限

4.1 先弄清 beam search 和采样哪个更适合摘要

训练完模型,生成阶段的参数选择比训练超参更容易直接影响观感。摘要生成有一个隐含要求:确定性输出。同一篇新闻,业务方不希望每次返回的摘要措辞不同。所以默认用 beam search 而不是temperature采样。beam search 维护num_beams条候选序列,每步保留累计概率最高的若干路径,最后挑整体概率最高的一条。num_beams=1就退化成贪心解码,速度快但容易过早进入局部重复。

采样虽然能增加多样性,更适合写文案、续写故事这类创意场景,对摘要来说代价是“不同次调用结果不同”。如果一定要用采样跑摘要,把temperature调到 0.7 以下并加top_p=0.9,能压低随机性。不过我的习惯是能 beam 就 beam,没有 param 的随机性,好过偶尔蹦出惊艳但常常失稳的输出。

4.2 三个必调参数:beam width、no_repeat_ngram、repetition_penalty

摘要生成过几个参数,效果判断非常直接。第一个是num_beams,推荐 3 到 5。再大收益有限,速度翻倍;对中文这种短句密集的语言,beam 太宽反而容易摆出低概率的整段模板语。第二个是no_repeat_ngram_size=3,这是克制复读的关键,它禁止生成任意 3-gram 重复两次以上,代价是可能把长摘要里合理的同类表述也禁掉,所以摘要较长时建议只设到 4。第三个是repetition_penalty=1.2,它的作用是每生成一个 token 时,给历史出现过的 token 的概率打折扣。这两个参数并存的必要性在于:一个管 n-gram 硬约束,一个管全文重复偏好。实际效果上,no_repeat_ngram_size对短摘要立竿见影,repetition_penalty对长摘要更平滑。

长度控制同样别忽略。max_new_tokens是相对输入长度新增的 token 数,比max_length直观。中文摘要平均每个字对应 1 到 1.5 个 token,设置 100 到 150 的max_new_tokens通常足够。低于 50 会看到大量后半句没说完就强制截断,高于 200 则容易跑题。

4.3 一段可以直接拿去用的推理函数

把上面的参数整合,就是一个标准的摘要生成函数。

# inference.py # 加载微调后的模型生成摘要 from transformers import GPT2LMHeadModel, GPT2Tokenizer device = torch.device("cuda" if torch.cuda.is_available() else "cpu") SEP = "[SEP]" model = GPT2LMHeadModel.from_pretrained("./gpt2_summary_checkpoint") tokenizer = GPT2Tokenizer.from_pretrained("./gpt2_summary_checkpoint") tokenizer.pad_token = tokenizer.eos_token model.to(device) def summarize(text, max_new_tokens=120, num_beams=4): prompt = text + SEP inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512) # 强制不输出 eos_token,避免摘要提前结束 forced_ids = [] outputs = model.generate( inputs["input_ids"].to(device), max_new_tokens=max_new_tokens, num_beams=num_beams, no_repeat_ngram_size=3, repetition_penalty=1.2, early_stopping=True, forced_bos_token_id=0, # 用不到但保留占位 ) # 从 [SEP] 之后开始解码,去掉原文部分 generated = tokenizer.decode(outputs[0], skip_special_tokens=True) if SEP in generated: return generated.split(SEP)[-1] return generated

这里有个细节值得展开:直接在outputs上split(SEP),是因为我训练时的分隔符被注册成了特殊 token,skip_special_tokens=True会把它们全删掉,导致你拿回纯文本后反而找不准摘要起点。所以在split(SEP)[-1]是拿原文+摘要这个整体里最后一次出现[SEP]之后的文本,这依赖“原文里不出现[SEP]字符串”这个前提。如果你构造训练数据时已经在原文里出现过[SEP],这里就失效了。更可靠的做法是不用skip_special_tokens,先按 token id 找到[SEP]的位置再解码,只是代码长一点。业务 demo 阶段用 split 足够,踩过坑的人自然会改成 token id 定位。

5. 训练 GPT2 摘要模型的避坑清单:5 个最容易翻车的地方

5.1 现象:loss 降了但生成的摘要全是原文片段复读

原因最隐蔽的一条。loss 从 5.2 降到 1.8,看起来训练顺利,但生成结果是“引用原文开头几个词”,根本看不出概括成分。第一反应怀疑模型容量,其实多半是训练数据里原文和摘要重叠度太高,比如新闻类数据集里第一句往往是“本报讯 X 报道”,而摘要直接改写这句。模型学到了捷径:抄第一句就能过训练目标,于是生成时复读原文。解决方式有两种。一是在数据清洗阶段删掉“摘要和原文首句字面相似度超过 0.7”的样本,二是在 loss 里把原文区域的损失权重压得更低,让模型找不到复读的诱导信号。

5.2 现象:中文被 tokenizer 切成乱码或整句黏成一坨

中文预训练权重已内置中文 tokenizer,如果换用英文 GPT-2 再加载中文文本,会把每个汉字拆成奇怪的字节组合。另一个常见情况是训练时一切正常,部署环境里transformers版本升级,tokenizer 对空格的处理变化,导致同一句话预测的 token 序列不同。解决方式是锁定依赖版本,保存模型检查点的时候顺带保存 tokenizer 配置,不依赖环境的默认版本行为。我在项目里习惯把所有 tokenizer 文件复制到检查点目录,部署时只从检查点目录加载。

5.3 现象:训练到一半 CUDA OOM,进程直接挂掉

摘要训练常见的显存瓶颈来自输入长度。batch size 设成 8,但输入序列长度全部按 512 处理,一张 8G 显存的卡基本撑不住。解决方式不是单纯调小 batch size,而是检查你的 DataLoader 是否做了动态 padding:按每个 batch 内部最长序列 padding,而不是全部 pad 到 512。用collate_fn实现动态 padding 后,8G 显存能跑的 batch size 可以翻倍。如果还是不够,把max_src_len从 512 降到 384,很多新闻摘要任务的原文保留到 384 token 就足够,毕竟关键信息通常分布在前半部分。

5.4 现象:生成的摘要末尾挂着

用generate时不传truncation相关参数,模型可能在某个 token 位置循环输出eos_token,最终代码解码出半截文本加一个多余标记。原因一是训练数据里摘要结尾没有统一加[SEP],模型没学好“结束”的触发模式;原因二是推理时no_repeat_ngram_size和长度提升相互冲突,模型被迫填满max_new_tokens也不停。解决方式:数据清洗保证每条摘要都以[SEP]结尾,推理时把forced_eos_token_id设为tokenizer.eos_token_id,或将early_stopping打开。如果你的模型在[SEP]之后还继续生成别的内容,说明摘要结束符没学会,需要回炉检查训练样本。

5.5 现象:微调后模型通用生成能力明显下降

在单个摘要数据集上训练 3 个 epoch 后,模型原本会写邮件、续写故事的能力急剧退化。这是典型的灾难性遗忘。解决方式分三个层级:最轻的是把学习率降到 2e-5 以下,让微调只做小幅修正;中间的是在训练数据里混入 10% 通用中文语料(随便拿新闻、维基文本做生成式续写样本即可);最重的是用 LoRA 之类的参数高效微调方法,冻结主模型权重只训练少量低秩矩阵,既保留通用能力又完成摘要任务适配。我自己一般从第二个层级起步,成本低效果好。

6. 摘要质量怎么验证才不靠玄学:ROUGE 打分加人工抽检

自动评估摘要最常见的是 ROUGE 系列指标,ROUGE-L 能同时反映短语和句子级匹配。调用datasets库里的load_metric("rouge")可以快速计算,实测对一个 2000 条验证集做打分,能帮你发现回归:同一套参数改完,分数掉了 0.1 立刻警觉。但 ROUGE 有个知名缺陷——它看重字面重合,改写能力强的模型可能得分偏低,复读原文的模型反而得分偏高。所以你光看指标上升就高兴,可能恰恰训出一个复读机。

我的验证习惯是两步走。自动打分用来做回归基线,人工抽检用来做最后裁判。每次训练完,从验证集里随机拿 10 条输入,跑一遍生成,然后把原文、参考摘要、模型摘要并排打印出来。判断一个标准的尺度很简单:摘要能看懂、关键信息不缺失、没有病句和复读,就算及格。人工抽检的这个结果不值得记录,但很值得在训练参数调整前后对比着看,特别是repetition_penalty和no_repeat_ngram_size的调整,ROUGE 分数往往不变但观感变化巨大。

另一个有效技巧是给模型做“压力测试”:特意喂一篇包含长数字、多省份名单、并列结构的新闻原文,看摘要能否保持数字准确。摘要生成模型最容易在数字上取近似值,系统内部对数字精度要求高的话,光靠 ROUGE 根本看不出来。这项踩坑经验是我在协助做证券研报摘要时留下的,从那以后所有摘要模型的评估脚本里必然加一条数字一致性计数。

最后说一个习惯:模型上线前,把这些验证片段和生成结果放进一张 Markdown 表格,存为某次训练的附属说明。翻车的时候回看历史数据,比看tensorboard的曲线能更快定位问题出在数据还是参数。希望这套流程对你的 GPT-2 中文摘要项目有实际帮助。

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

返回列表