简介:本资源是一份面向NLP初学者与进阶开发者的Transformer生成式文本摘要实战代码包,聚焦自然语言处理中的核心任务——自动摘要,适用于新闻提炼、论文速读、报告精简等实际场景。压缩包共10个文件,含5个Python源码(涵盖数据预处理、模型构建、训练与推理全流程)、1个requirements.txt依赖说明、1个TSV格式示例数据集、1个README.md项目文档、1张模型结构示意图JPG及1个.gitignore配置文件,整体仅201KB,轻量易部署。已有119人学习下载,适合希望深入理解Seq2Seq+自注意力机制、掌握Hugging Face或原生PyTorch实现细节的开发者。读者可直接运行复现完整训练流程,快速构建可调优的摘要系统,并基于代码结构拓展BERT/GPT类预训练模型微调逻辑,是理论落地与工程实践结合的优质入门范例。
1. 为什么用 Transformer 做生成式文本摘要,不是“炫技”,而是解决真实翻车现场
你手头有一篇 3000 字的技术白皮书、一份 87 页的会议纪要、或 56 条用户投诉工单堆成的 raw log——传统抽取式摘要(比如 TF-IDF + TextRank)会机械地挑出高频词句,结果生成“系统:稳定;用户:满意;问题:已解决”,全是正确废话;而基于 RNN/LSTM 的生成式模型在长文本上容易遗忘开头、重复生成、甚至崩出语法正确的胡话。基于 Transformer 的生成式文本摘要,不是把《The Illustrated Transformer》抄一遍就完事,它是用自注意力机制真正建模“哪段背景决定哪句结论”,让模型学会在 500 字内复现原文的逻辑主干,而不是关键词拼贴。这个.zip包里没有花哨的 Web UI,只有可本地跑通的 PyTorch 实现:从预处理清洗到 Beam Search 解码,从 BERT-style 编码器微调到轻量级解码器设计,全部用纯 Python 写死——适合想搞懂“Transformer 怎么真正在摘要任务里干活”的工程师,也适合需要快速接入内部文档自动摘要 pipeline 的算法落地同学。不依赖 Hugging Face AutoModel 黑匣子,所有层可 inspect、可断点、可改 attention mask 逻辑。
2. 从零构建可训练的 Transformer 摘要模型:编码器-解码器结构拆解与 PyTorch 实现
2.1 为什么必须用 Encoder-Decoder 架构,而不是只用 BERT 或只用 GPT?
生成式摘要本质是“条件文本生成”:输入是源文档(source),输出是摘要(target),二者长度、语义粒度、信息密度都不同。BERT 是双向编码器,擅长理解但无法自回归生成;GPT 是单向解码器,能生成但缺乏对长源文的全局感知。Encoder-Decoder 结构(如原始 Transformer 论文中的架构)天然匹配该任务:Encoder 将源文压缩为上下文感知的隐藏状态序列,Decoder 在每个 timestep 用这些状态做 cross-attention,动态聚焦关键片段。本.zip中的model.py不直接套用transformers.T5ForConditionalGeneration,而是手写EncoderLayer和DecoderLayer,原因有三:
- 可控性:能精确控制 padding mask 在 encoder 输入和 decoder 自注意力中的不同应用方式(encoder 用
src_key_padding_mask,decoder 自注意需causal_mask + tgt_key_padding_mask); - 轻量化:去掉 T5 的 relative position bias 和 dense FFN 中的 gated linear unit,用标准 ReLU + dropout(
dropout=0.1); - 调试友好:每个
MultiheadAttention层后加register_forward_hook,可实时打印 attention weights shape([batch, head, seq_len, seq_len]),验证是否真的在关注首段背景句而非末尾标点。
提示:不要一上来就
from transformers import AutoModel。先用torch.nn.Transformer原生模块搭最小闭环,确认src和tgt的 shape 对齐([seq_len, batch, embed_dim]),再逐步替换为自定义 layer——这是避免“模型跑通但 loss 不降”的血泪经验。
2.2 数据预处理:如何把原始文本变成模型能吃的 tensor?
.zip中preprocess.py的核心不是分词,而是对齐摘要任务特性的三重裁剪:
- 源文截断策略:不简单按 token 数硬切(如
max_length=512),而是用 sentence-level 截断——先nltk.sent_tokenize(),再按 cumsum 计算每句 token 数,保留累计 ≤ 480 的前 N 句。这样避免切在句子中间导致语义断裂; - 摘要截断策略:目标摘要强制
max_length=128,但若原文摘要本身短于 30 token,则 padding 到 30(非 128),防止 decoder 在空位上瞎学; - 特殊 token 注入:在源文开头加
[CLS],结尾加[SEP];摘要开头加[BOS],结尾加[EOS];所有[PAD]用tokenizer.convert_tokens_to_ids('[PAD]')映射,确保 embedding lookup 不越界。
# preprocess.py 关键片段 def build_input_batch(sentences: List[str], summaries: List[str], tokenizer, max_src_len=480, max_tgt_len=128): src_encodings = tokenizer( sentences, truncation=True, padding='max_length', max_length=max_src_len, return_tensors='pt' ) # 注意:summary 需手动加 [BOS]/[EOS] tgt_inputs = [] for s in summaries: ids = tokenizer.encode(s, add_special_tokens=False) if len(ids) > max_tgt_len - 2: # -2 for [BOS], [EOS] ids = ids[:max_tgt_len-2] ids = [tokenizer.bos_token_id] + ids + [tokenizer.eos_token_id] ids += [tokenizer.pad_token_id] * (max_tgt_len - len(ids)) tgt_inputs.append(ids) tgt_tensor = torch.tensor(tgt_inputs) return src_encodings['input_ids'], src_encodings['attention_mask'], tgt_tensor这段代码的truncation=True是表层,深层逻辑是tokenizer必须用fast版本(如AutoTokenizer.from_pretrained('bert-base-chinese', use_fast=True)),否则encode时无法保证 token 与 subword 对齐,后续计算 loss 会因 label shift 翻车。
2.3 模型训练循环:为什么不用Trainer,而手写train_step?
.zip中train.py的train_epoch()函数刻意避开 Hugging Face Trainer,因为摘要任务有三个 Trainer 默认不处理的细节:
- label smoothing:摘要中高频词(如“的”、“了”、“是”)易过拟合,
loss = CrossEntropyLoss(label_smoothing=0.1)强制模型对低置信预测更谨慎; - gradient accumulation:显存受限时,
accumulation_steps=4,每 4 步才optimizer.step(),但scheduler.step()仍每步调用,避免学习率误降; - loss masking:
tgt_mask不仅用于 attention,还用于 loss 计算——loss_fct(ignore_index=tokenizer.pad_token_id)自动忽略 pad 位置,但需确保labels中 pad 位置值严格等于pad_token_id(不能是 0)。
# train.py 片段:带梯度累积的手写训练步 def train_step(model, batch, optimizer, scheduler, device, accumulation_steps=4): src_ids, src_mask, tgt_ids = [x.to(device) for x in batch] # tgt_ids[:, :-1] 作为 decoder 输入,tgt_ids[:, 1:] 作为 label decoder_input = tgt_ids[:, :-1] labels = tgt_ids[:, 1:] outputs = model( src=src_ids, tgt=decoder_input, src_key_padding_mask=~src_mask.bool(), # 注意:mask 为 1 表示有效,需取反 tgt_key_padding_mask=create_causal_mask(decoder_input.size(1)).to(device) ) loss = F.cross_entropy( outputs.view(-1, outputs.size(-1)), labels.reshape(-1), ignore_index=tokenizer.pad_token_id, label_smoothing=0.1 ) loss = loss / accumulation_steps loss.backward() if (batch_idx + 1) % accumulation_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() return loss.item() * accumulation_stepscreate_causal_mask()是一个上三角矩阵(torch.triu(torch.ones(sz, sz), diagonal=1)),它和tgt_key_padding_mask是叠加生效的:前者屏蔽未来 token,后者屏蔽 pad token,二者通过&运算合并。漏掉任一,decoder 就会偷看未来或 attend to pad。
3. Beam Search 解码:如何让模型生成“像人写”的摘要,而不是“像机器吐”的碎片
3.1 为什么 Greedy Search 不够用?从概率坍塌说起
Greedy Search 每步选最高概率 token,看似高效,但在摘要中极易导致“概率坍塌”:第 1 步选“本文”,第 2 步因“本文介绍了”比“本文讨论了”概率高 0.001,就锁死“介绍了”,后续无论原文讲的是“分析”还是“验证”,都强行续“介绍了……”。Beam Search 用宽度k=4维持 4 个候选序列,每步扩展所有可能 token 后重排序,最终选整体 log-prob 最高的路径。.zip中inference.py的beam_search()不调用transformers.generation,而是手写,关键在log-prob 累积方式:
# inference.py 片段:beam search 核心逻辑 def beam_search(model, src_ids, src_mask, tokenizer, beam_width=4, max_len=128): batch_size = src_ids.size(0) # 初始化:每个样本的 beam 以 [BOS] 开始 beams = [[([tokenizer.bos_token_id], 0.0)] for _ in range(batch_size)] for step in range(max_len): candidates = [[] for _ in range(batch_size)] for b in range(batch_size): for seq, score in beams[b]: if seq[-1] == tokenizer.eos_token_id: candidates[b].append((seq, score)) continue # 构造当前 step 的 decoder input tgt_input = torch.tensor(seq).unsqueeze(0).to(src_ids.device) with torch.no_grad(): logits = model( src=src_ids[b:b+1], tgt=tgt_input, src_key_padding_mask=~src_mask[b:b+1].bool(), tgt_key_padding_mask=create_causal_mask(len(seq)).to(src_ids.device) )[-1, :] # 取最后一步 logits probs = F.log_softmax(logits, dim=-1) topk_probs, topk_ids = torch.topk(probs, beam_width, dim=-1) for i in range(beam_width): new_seq = seq + [topk_ids[i].item()] new_score = score + topk_probs[i].item() candidates[b].append((new_seq, new_score)) # 每个 batch 重排 top-k beams = [] for b in range(batch_size): sorted_candidates = sorted(candidates[b], key=lambda x: x[1], reverse=True) beams.append(sorted_candidates[:beam_width]) # 返回每个样本得分最高的序列 return [beams[b][0][0] for b in range(batch_size)]注意log_softmax而非softmax:累加 log-prob 避免浮点下溢;topk_ids[i].item()强制转 int,防止 tensor device mismatch;create_causal_mask(len(seq))动态生成 mask,长度随 beam 扩展变化——这是很多开源实现漏掉的细节。
3.2 解码后处理:三个让摘要“读起来顺”的硬规则
生成的 token 序列直接tokenizer.decode()会出问题:
- 重复 n-gram 削减:同一 3-gram 连续出现 ≥2 次,删掉后续重复(如“因此因此因此” → “因此因此”);
- 标点粘连修复:中文里
,。!?;:后不应有空格,但 tokenizer 可能生成,,需正则re.sub(r'([,。!?;:])\s+', r'\1', text); - 首句强制大写:英文摘要需
text.capitalize(),中文则检查首字是否为标点,若是则跳过。
.zip中postprocess.py的refine_summary()函数封装这三步,且只在最终输出时调用,不在训练 label 中应用——因为模型需学习原始分布,后处理是部署层的事。
4. 避坑指南:训练/推理中 4 个真实踩过的坑与根因定位法
4.1 现象:训练 loss 从 8.0 降到 3.5 后卡住,validation BLEU 不升反降
原因:src_key_padding_mask传入错误。原代码用src_mask(shape[batch, seq_len])直接传给src_key_padding_mask,但 PyTorch Transformer 要求该 mask 是bool类型且True表示masked(即无效位置),而src_mask中 1 表示有效。未做~src_mask.bool()取反,导致 encoder 把所有 pad 位置当有效 token attend,梯度污染。
解决:在model.forward()中明确写src_key_padding_mask=~src_mask.bool(),并在 forward 开头加assert src_key_padding_mask.dtype == torch.bool。
4.2 现象:Beam Search 输出全是[BOS] [EOS]或无限重复单字(如“的的的的……”)
原因:tgt_key_padding_mask未与 causal mask 合并。decoder 的tgt_key_padding_mask仅屏蔽 pad,但未叠加 causal mask,导致模型在 step=2 时能 attend to step=1 的 token,却因无 causal 约束,在 step=3 时又 attend 回 step=1,形成循环。
解决:手写create_causal_mask()并与tgt_key_padding_mask逐元素&运算,确保每个 timestep 只能看到历史位置。
4.3 现象:tokenizer.encode()后input_ids长度忽长忽短,同一篇文档两次运行结果不同
原因:未固定tokenizer的padding_side='right'和truncation_side='right'。中文 tokenizer(如bert-base-chinese)默认padding_side='right',但若代码中某处误设为'left',或truncation_side未显式指定,会导致截断位置随机(如“人工智能技术发展迅速”可能被截成“人工智能技”或“术发展迅速”)。
解决:初始化 tokenizer 后立即设置tokenizer.padding_side = 'right'和tokenizer.truncation_side = 'right',并在build_input_batch()中打印len(tokenizer.convert_ids_to_tokens(ids))验证。
4.4 现象:GPU 显存 OOM,nvidia-smi显示占用 10GB,但torch.cuda.memory_allocated()仅 3GB
原因:torch.nn.Transformer的generate()或自定义 decoder 中,past_key_values缓存未及时释放。每次 decode step 新增的 KV cache 占用显存,但未在循环外del past_key_values,导致显存泄漏。
解决:在 beam search 循环内,每次model(...)调用后,显式del outputs和torch.cuda.empty_cache();更优解是改用model.forward(..., use_cache=False)彻底禁用 cache。
5. 模型轻量化与业务落地:如何把 12 层 Transformer 压到 200MB 以内并支持 50QPS
5.1 参数剪枝:不是删 layer,而是砍 attention head 和 FFN 维度
.zip中prune_model.py不用结构化剪枝库,而是直接修改config.json:
- 将
num_heads从 12 降到 4(减少 66% attention 计算); - 将
intermediate_size(FFN hidden dim)从 3072 降到 1024(减少 66% FFN 参数); - 保持
hidden_size=768不变,确保与预训练权重兼容。
# prune_model.py:修改 config 并重映射权重 def prune_transformer(config_path: str, pruned_config_path: str): with open(config_path) as f: config = json.load(f) config['num_attention_heads'] = 4 config['intermediate_size'] = 1024 config['num_hidden_layers'] = 6 # 同时减 layer 数 with open(pruned_config_path, 'w') as f: json.dump(config, f, indent=2) # 加载原始权重,只取前 4 heads 的 q/k/v 权重 state_dict = torch.load('original_model.bin') pruned_state_dict = {} for k, v in state_dict.items(): if 'self_attn' in k and ('q_proj' in k or 'k_proj' in k or 'v_proj' in k): # 原 shape [768, 768] -> 拆成 [12, 64, 768],取前 4 个 head v_reshaped = v.view(12, 64, -1) # 12 heads, head_dim=64 pruned_v = v_reshaped[:4].view(4*64, -1) # [256, 768] pruned_state_dict[k] = pruned_v elif 'fc1' in k: # FFN 第一层 pruned_state_dict[k] = v[:1024, :] # 取前 1024 行 else: pruned_state_dict[k] = v torch.save(pruned_state_dict, 'pruned_model.bin')关键点:v.view(12, 64, -1)假设hidden_size=768,head_dim=64(768/12),剪枝后head_dim变为 64(768/4),所以pruned_vshape 为[256, 768],与新 config 完全匹配。
5.2 推理加速:用 TorchScript 替代 eager mode,实测提速 2.3 倍
PyTorch eager mode 的 Python 解释器开销大,尤其在 beam search 的循环中。.zip中export_script.py将模型导出为 TorchScript:
# export_script.py model.eval() example_src = torch.randint(0, 30000, (1, 480)).long() example_tgt = torch.randint(0, 30000, (1, 128)).long() example_mask = torch.ones(1, 480).bool() # 注意:必须用 concrete example,不能用 dummy tensor traced_model = torch.jit.trace( model, (example_src, example_tgt, example_mask, None) # None for src_key_padding_mask ) traced_model.save("traced_summarizer.pt")部署时加载traced_summarizer.pt,调用model(src, tgt, mask)直接执行 C++ kernel,无需 Python GIL。实测 batch_size=1 时,eager mode 平均 182ms/次,TorchScript 79ms/次;batch_size=8 时,从 1420ms 降至 610ms。
5.3 服务化封装:一个 Flask endpoint 的最小可靠实践
.zip中app.py不用 FastAPI(依赖多),用 Flask + gunicorn + nginx:
# app.py from flask import Flask, request, jsonify import torch from model import Summarizer from preprocess import build_input_batch app = Flask(__name__) model = Summarizer.from_pretrained('pruned_model.bin') model.eval() model.to('cuda') @app.route('/summarize', methods=['POST']) def summarize(): data = request.get_json() texts = data.get('texts', []) if not texts: return jsonify({'error': 'no texts'}), 400 # 批处理:一次最多 16 条,防 OOM batches = [texts[i:i+16] for i in range(0, len(texts), 16)] results = [] with torch.no_grad(): for batch in batches: src_ids, src_mask, _ = build_input_batch( batch, [''] * len(batch), tokenizer ) src_ids = src_ids.to('cuda') src_mask = src_mask.to('cuda') summaries = beam_search(model, src_ids, src_mask, tokenizer) results.extend([tokenizer.decode(s, skip_special_tokens=True) for s in summaries]) return jsonify({'summaries': results})关键配置:
- gunicorn 启动:
gunicorn -w 4 -b 0.0.0.0:5000 --timeout 120 app:app(4 worker,防单点阻塞); - nginx 反向代理加
proxy_read_timeout 120; - 模型加载加
torch.backends.cudnn.benchmark = True,首次 infer 后提速。
我上线时吃过亏:没设--timeout,某条长文档卡住,整个 worker 挂死。现在所有线上服务必加 timeout,宁可返回 504 也不阻塞。
希望帮到你。
本文还有配套的精品资源,点击获取