ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

seq2seq文本摘要实战:从数据预处理到注意力机制与Beam Search

seq2seq文本摘要实战:从数据预处理到注意力机制与Beam Search 简介汽车大师问答摘要与推理比赛参赛源码与项目说明是一套围绕 seq2seq 与 seq2seq_attention 模型展开的完整自然语言处理实战资料主要面向 NLP 方向学习者、算法竞赛参赛者以及需要完成课程设计、毕业设计的学生。压缩包共 37 个文件包含 28 个 Python 脚本、7 个 Jupyter Notebook 交互式笔记、1 份 Markdown 说明文档及少量配置文件整体仅 128KB体积小巧但模块划分清晰。Notebook 部分从数据预处理入手逐步演示编码器-解码器搭建、Transformer 对比实验、beam search 集束搜索解码等关键环节Python 脚本则覆盖训练、测试、模型定义、GPU 配置、词向量加载、数据加载、参数设置与训练辅助工具构建起从数据到推理的完整链路。通过这套资料读者不仅能复现问答摘要赛题的完整流程还能学习到注意力机制与集束搜索的实现细节便于在此基础上改进模型结构或迁移到其他文本生成任务。目前已有 99 人浏览学习适合用作算法研究与项目实战的参考样例。1. 汽车大师问答摘要与推理这份 seq2seq 源码包到底能拿来干什么我最初拿到这份压缩包时以为是某个课程的大作业拼凑。但拆开看完整目录后发现它把一条完整的文本摘要生产链路——从 JSON 原始语料清洗、分词、词表构建到 seq2seq 训练、Beam Search 解码、ROUGE 评估——全部跑通了。如果你正在做中文短文本摘要、问答对压缩、或者想从零搭一套带注意力机制的生成式模型这份源码的价值不只是能跑通而是每一步都能对照原文改出自己的版本。它以汽车大师问答为场景但数据处理和模型主体与具体领域解耦换到商品评论摘要、工单标题压缩同样适用。适合两类人一是刚看完 seq2seq 论文但还没写过完整训练的入门者二是在公司里需要快速搭一个文本生成基线做效果对比的算法工程师。我不打算把里面的代码逐行复述一遍——那没有意义你解压后自己会看。这篇文章只讲三件事数据怎么变成模型能吃的样子、两个模型基础 seq2seq 和 seq2seq_attention的区别在哪、以及跑训练和预测时真正会遇到的坑。2. 数据预处理从 JSON 到 batch 的完整流程这份源码的数据处理部分写得很老实没有用 HuggingFace 的 Dataset 封装全部是手写的数据加载器依赖面很窄。这意味着迁移到自己的数据集时逻辑透明、容易改。整个流程分四步读取 JSON、按长度过滤、分词并构建词表、按 batch 生成训练样本。2.1 目录结构与数据流解压后的目录不算大核心文件集中在src/下。训练入口是train.py模型定义在seq2seq_model.py和seq2seq_attention_model.py数据处理统一走data_loader.py预测和评估在predict.py或同目录的脚本里。首次拿到源码建议先按顺序读这三个文件搞清楚数据是怎么流入模型的。数据的原始格式是 JSON每条样本包含问题和答案两个字段。汽车大师这个场景里问题往往是用户对故障现象的描述答案是技师给出的维修建议。摘要任务在这里表现为给定问题原文让模型生成一条浓缩的答案。需要注意源码默认把“答案”作为生成目标而不是把“问题答案”拼起来再压缩。这一点和常见的摘要任务定义稍有出入但它简化了训练目标——模型只需要学会从问题映射到答案不需要处理输入太长的问题。2.2 预处理主流程下面是数据加载器的骨架逻辑我在原版基础上加了注释方便你对照自己的数据改造import json import jieba def load_json_data(file_path: str): 加载原始 JSON 问答对 samples [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue obj json.loads(line) samples.append({ question: obj.get(question, ), answer: obj.get(answer, ) }) return samples def filter_and_segment(samples, max_len50, min_len3): 过滤过长/过短样本并用 jieba 分词 pairs [] for s in samples: q s[question].strip() a s[answer].strip() # 过滤空串和超长样本 if not q or not a: continue if len(q) max_len or len(a) max_len: continue if len(q) min_len or len(a) min_len: continue pairs.append((list(jieba.cut(q)), list(jieba.cut(a)))) return pairs def build_vocab(pairs, min_freq2): 构建词表保留高频词 from collections import Counter counter Counter() for q_tokens, a_tokens in pairs: counter.update(q_tokens) counter.update(a_tokens) vocab {pad: 0, bos: 1, eos: 2, unk: 3} for word, freq in counter.most_common(): if freq min_freq: break vocab[word] len(vocab) return vocab这段代码里最需要注意的是jieba.cut()的默认模式。默认是精确模式分词粒度比较细像“发动机故障灯亮”会被切成“发动机 / 故障灯 / 亮”。如果你希望保留更多语义完整的短语可以考虑改用jieba.cut(s, cut_allFalse)并配合jieba.add_word加入汽车领域的专有词比如“正时链条”、“氧传感器”。词表构建时的min_freq2是个关键参数——太小会引入大量只在一条样本中出现过的噪声词模型容易过拟合到分词碎片太大又会把低频但语义明确的专业词滤掉。汽车维修领域很特殊很多故障描述词汇本身就是长尾的我在自己的数据集上试过min_freq1生成结果里“ ”占比明显上升最后还是回到min_freq2。2.3 Batch 生成与 pad 策略分词和词表完成之后下一步是把 token 序列转成 ID并按 batch 做 padding。这里有个容易踩坑的点源码里默认是同一个 batch 内 pad 到当前 batch 的最大长度而不是 pad 到数据集全局最大长度。这样做能节省显存但如果 batch 内样本长度方差很大GPU 利用率会明显波动。def pad_sequence(seq_ids, max_len, pad_id0): if len(seq_ids) max_len: return seq_ids[:max_len] return seq_ids [pad_id] * (max_len - len(seq_ids)) def make_batch(pairs, vocab, batch_size64): 生成模型输入的 batch目标序列自动加上 bos 和 eos batches [] for i in range(0, len(pairs), batch_size): batch_pairs pairs[i:i batch_size] q_ids_list, a_ids_list [], [] q_len, a_len 0, 0 # 先扫一遍确定当前 batch 的 max len for q_tokens, a_tokens in batch_pairs: q_ids [vocab.get(w, vocab[unk]) for w in q_tokens] a_ids [vocab[bos]] [vocab.get(w, vocab[unk]) for w in a_tokens] [vocab[eos]] q_ids_list.append(q_ids) a_ids_list.append(a_ids) q_len max(q_len, len(q_ids)) a_len max(a_len, len(a_ids)) # pad 到当前 batch 的 max len q_padded [pad_sequence(ids, q_len) for ids in q_ids_list] a_padded [pad_sequence(ids, a_len) for ids in a_ids_list] batches.append({ q_tensor: torch.LongTensor(q_padded), a_tensor: torch.LongTensor(a_padded) }) return batches这里必须说明一个关键细节目标序列a_ids在最前面插入了bos在最后补上了eos。训练时损失函数计算的是从bos之后的每一个 token 的预测概率也就是说模型看到a_ids[:-1]预测a_ids[1:]。如果你自己改写数据管线忘记加bos或eos训练时模型会学习不到文本终止的边界。推理时如果不生成eos就停止会出现整段预测文本无限循环或一直吐到 max_len 的问题——这两个符号是序列生成任务的“句号”不能省。另外注意一个细节pad_sequence里没有记录每条样本的真实长度。这意味着在计算 loss 时需要屏蔽掉 padding 位置否则模型会因为多预测了 pad 符号而把 teacher forcing 的平均损失拉低。源码里是否做了 mask我暂时没在骨架里写训练时会单独说明。3. 两个模型对比基础 seq2seq 与带注意力机制的版本这份源码包最有价值的部分在于可以横向对比注意力机制带来的效果差异。两个模型共享编码器结构但在解码端有天壤之别。我不展开公式推导只讲源码实现里你必须理解的结构差异和对应参数。3.1 编码器共享的 BiGRU 设计无论是seq2seq_model.py还是seq2seq_attention_model.py编码器都是双向 GRU。选择 GRU 而不是 LSTM是因为在这个参数量级下GRU 少一个门控训练速度更快、收敛更稳。源码里默认的隐藏层维度是 256embedding 维度也是 256两层双向。class Encoder(nn.Module): 双向 GRU 编码器把输入序列压缩成上下文向量序列 def __init__(self, vocab_size, embed_size256, hidden_size256, num_layers2, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.gru nn.GRU(embed_size, hidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) # 把双向的输出压低回 hidden_size便于解码器使用 self.fc nn.Linear(hidden_size * 2, hidden_size) def forward(self, x): embedded self.embedding(x) # [batch, seq_len, embed_size] outputs, hidden self.gru(embedded) # outputs: [batch, seq_len, hidden*2] # 对双向 output 做一次线性压缩 outputs self.fc(outputs) return outputs, hidden关键在bidirectionalTrue这一行。双向 GRU 意味着对每个 token 都能看到左边的上下文和右边的上下文这对文本摘要来说非常重要——因为我们要压缩的信息可能依赖前文才出现的指代关系。但双向编码带来的问题是最终输出的outputs在最后一维是hidden_size * 2如果不做投影压缩解码器的 attention 计算维度会不匹配。所以上面加了self.fc把维度压回hidden_size。如果看不到这行压缩后续 attention score 计算时dim对不上会直接报错。还有一个细节是hidden的形状。双向 GRU 的hidden是[num_layers * 2, batch, hidden]表示每一层两个方向的最终隐状态。解码器的初始状态需要从这个张量中拼接两个方向再经过一个线性层得到初始状态。源码里通常的做法是把最后一层的两个方向拼接后送进一个nn.Linear(hidden_size * 2, hidden_size)。有些实现偷懒直接取前向方向的隐状态对效果影响不大但理论上丢失了反向信息。3.2 解码器从普通 GRU 到注意力机制普通 seq2seq 解码器每一步只看上一步的隐状态和上一步输出的 embedding编码器信息只在初始状态注入一次。当句子长度超过 15 个词时这种结构生成的效果会明显下降因为初始隐状态里的信息随着时间步的前进被逐渐稀释。class DecoderWithoutAttention(nn.Module): 基础解码器只依赖上一步隐状态 def __init__(self, vocab_size, embed_size256, hidden_size256, max_len50): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.gru nn.GRU(embed_size, hidden_size, batch_firstTrue) self.fc_out nn.Linear(hidden_size, vocab_size) def forward(self, decoder_input, decoder_hidden): embedded self.embedding(decoder_input) # [batch, 1, embed] output, decoder_hidden self.gru(embedded, decoder_hidden) logits self.fc_out(output.squeeze(1)) # [batch, vocab] return logits, decoder_hidden而带注意力的解码器每一步都会重新计算编码器所有时间步输出的加权和class AttentionDecoder(nn.Module): 带 attention 的解码器每一步重新聚焦到编码器的相关部分 def __init__(self, vocab_size, embed_size256, hidden_size256): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.gru nn.GRU(embed_size hidden_size, hidden_size, batch_firstTrue) self.attn nn.Linear(hidden_size * 2, 1) # 简易加性注意力 self.fc_out nn.Linear(hidden_size * 2, vocab_size) def forward(self, decoder_input, decoder_hidden, encoder_outputs): embedded self.embedding(decoder_input) # [batch, 1, embed] # 计算 attention 权重 # 这里 encoder_outputs: [batch, seq_len, hidden] # decoder_hidden[0]: [1, batch, hidden] dec_hidden_expanded decoder_hidden[0].transpose(0, 1) # [batch, 1, hidden] attn_input torch.cat([encoder_outputs, dec_hidden_expanded.expand(-1, encoder_outputs.size(1), -1)], dim-1) attn_weights torch.softmax(self.attn(attn_input).squeeze(-1), dim1) # [batch, seq_len] context torch.bmm(attn_weights.unsqueeze(1), encoder_outputs).squeeze(1) # [batch, hidden] gru_input torch.cat([embedded, context.unsqueeze(1)], dim-1) # [batch, 1, embedhidden] output, decoder_hidden self.gru(gru_input, decoder_hidden) logits self.fc_out(torch.cat([output.squeeze(1), context], dim-1)) return logits, decoder_hidden注意self.attn的输入维度是hidden_size * 2这是因为我把encoder_outputshidden和扩展后的decoder_hiddenhidden拼接在一起。这个简化版注意力没有经过 tanh 投影直接是单层线性加 softmax属于加性注意力的一种轻量变体。它的优点是显存占用小训练快缺点是在长文本上效果的稳定性不如标准的 Bahdanau 注意力score 向量要经过 tanh 投影和另一层权重映射。如果你想快速改进可以直接把attn改成两层 MLP中间加 ReLU维度不变效果会更接近原版论文。但看比赛源码的目的是理解完整流程不是拿最高分所以我建议先跑通这个简化版再逐步替换。另外一个值得注意的差异是解码器 GRU 的输入维度。普通解码器输入是embed_size而注意力解码器的输入是embed_size hidden_size——因为把 context 向量拼到了 embedding 后面。这个拼接维度在改代码时非常容易忽略。如果你单独替换了解码器文件但忘记改输入维度PyTorch 会在第一次 forward 时报线性层维度不匹配的错误。3.3 推理阶段的 Beam Search 参数理解源码里提供的推理脚本支持贪心解码和 Beam Search 两种模式。Beam Search 的基本含义是每一步不只保留得分最高的一个 token而是保留前 beam_size 个候选序列最后从完整路径中选总得分最高的一条。def beam_search_decode(model, input_seq, beam_size3, max_len30): 简易 Beam Search 解码不考虑长度惩罚 model.eval() with torch.no_grad(): # 编码器前向 encoder_outputs, hidden model.encoder(input_seq) # 初始化 beam 序列 sequences [[[vocab[bos]], 0.0, hidden]] completed [] for _ in range(max_len): new_sequences [] for seq, score, h in sequences: if seq[-1] vocab[eos]: completed.append((seq, score)) continue # 解码一步 input_t torch.LongTensor([[seq[-1]]]) logits, new_h model.decoder(input_t, h, encoder_outputs) probs torch.log_softmax(logits, dim-1).squeeze(0) top_probs, top_idxs torch.topk(probs, beam_size) for i in range(beam_size): new_seq seq [top_idxs[i].item()] new_score score top_probs[i].item() new_sequences.append((new_seq, new_score, new_h)) # 全局排序取前 beam_size new_sequences.sort(keylambda x: x[1], reverseTrue) sequences new_sequences[:beam_size] if completed: completed.sort(keylambda x: x[1], reverseTrue) best_seq completed[0][0] else: best_seq sequences[0][0] return best_seq这段实现里没有长度惩罚。实际生成短文本如问答摘要时不加入长度惩罚问题不大因为目标序列本身就比较短。但如果你要迁移到摘要任务且目标摘要可能长达 100 字以上建议加上 length penaltyscore score prob / ((5 len(seq)) / 6) ** alpha其中 alpha 一般取 0.61.0。不加长度惩罚时Beam Search 倾向于偏好短序列因为概率连乘时越长分数越低。但如果加了过强的惩罚模型又可能产生冗余重复的内容。我做过对比实验alpha0.6 对短文本比较友好可以防止生成结果过短。Beam size 的选择也很影响效果。beam_size1 就是贪心解码beam_size5 一般能带来可感知的 ROUGE 提升但推理时间会线性增长。在汽车大师这类短文本上beam_size3 性价比最高beam_size8 以上提升非常有限只会白白增加耗时。4. 训练与验证参数怎么设、损失怎么降、模型怎么存4.1 训练脚本核心参数训练脚本里涉及几个关键超参数batch size、learning rate、teacher forcing ratio、clip norm、epochs。源码的默认配置我看起来是“能跑通但不一定最优”的水平所以针对你的机器条件我给出调整优先级。参数默认值调整建议batch_size64显存不足时先降到 32但梯度要随之调小learning_rate0.001Adam 优化器下一般不用调但如果 loss 震荡降低到 0.0005teacher_forcing_ratio0.5通常在 0.5 开始训练后期降到 0.3能提升生成稳定性clip_norm5.0文本生成任务梯度爆炸常见clip 到 5.0 很稳hidden_size256数据量在 10 万条以下建议保持 2564.2 训练循环里的两个关键细节第一是 teacher forcing ratio 的实现方式它决定训练时解码器的输入有多少概率来自真实标签而不是模型自己的预测。常见做法是每次训练迭代用 random.random() 生成一个概率低于 ratio 就喂真实目标 token否则喂上一步模型输出的 token。源码里如果做的是 random 判断存在一个潜在问题每个 batch 甚至每个样本的概率不同这本身是正常设计不用改。第二个关键是 loss 的 mask 处理。由于我们用pad填充过短的样本计算交叉熵时必须把 padding 位置的 loss 置零只统计有效 token 的 loss。如果直接对 padded 后的全部 token 计算平均损失模型会学到“大多数时候输出pad就能拿到低损失”最终生成结果会出现大量 pad 标签而不是真实文本。def masked_cross_entropy(logits, targets, mask): logits: [batch, seq_len, vocab_size] targets: [batch, seq_len] mask: [batch, seq_len], 1 表示有效位置, 0 表示 padding 位置 loss nn.functional.cross_entropy( logits.reshape(-1, logits.size(-1)), targets.reshape(-1), reductionnone ) mask mask.reshape(-1).float() masked_loss loss * mask return masked_loss.sum() / mask.sum()上面的实现是逐 token 计算 loss 后乘以 mask再除以 mask 总和。这里有一个非常常见的误区不能直接对masked_loss.mean()因为除以的是所有 token 的数量包括 padding 位置。如果 padding 占比高真实 loss 会被严重低估训练出来的模型会“偷懒”。我习惯在训练循环里打印“真实 token 数 / 总 token 数”的比例如果小于 0.6就要考虑按长度重新调整 batch 的样本排列尽量把长度接近的样本放在同一 batch。在训练过程中建议每 500 个 step 打一次 log记录训练 loss 和当前 learning rate。如果看到 loss 持续下降但验证集 ROUGE 不升大概率是过拟合了。此时优先降低 hidden_size 或增大 dropout而不是加正则。4.3 验证与 checkpoint 保存源码的模型保存逻辑是保存整个 model.state_dict()并附带 vocab.json 和 config.json。如果你要恢复训练或做推理三样必须齐全。我自己习惯保存成三个独立文件避免加载时混淆python train.py \ --data_dir ./data \ --save_dir ./checkpoints \ --model_type attention \ --epochs 20 \ --batch_size 64 \ --beam_size 3 \ --eval_every 1000eval_every1000表示每 1000 步在验证集上跑一次 ROUGE 评估。这个评估不是必需的但是判断模型是否收敛的重要参考。因为文本生成任务只看 loss 不够直观有时候 loss 下降但生成结果反而变差这就是评估 ROUGE 的意义所在。训练结束后checkpoints 目录下会生成多个 epoch 的模型文件。不要只保留最后一个建议保留验证集 ROUGE 最高的那个——loss 最低的模型不一定生成效果最好这是文本生成任务的“玄学”之一。5. 避坑指南从数据到推理的五个经典问题这个部分是我拆解源码和复现实验过程中真实踩过的坑每个都配了现象、原因和解决方式。5.1 分词不一致导致推理结果中出现大量unk现象模型在训练集上 loss 正常但用 predict.py 做推理时生成的文本里出现很多unk。原因训练前构建词表用的分词器和推理时用的分词器版本不一致。比如训练时用的是 jieba 0.42.1推理时换了 jieba 0.35.1同一个词被切成不同粒度词汇表里查不到。另外 jieba 的词典会随版本更新变化即使代码一样不同环境下切分结果也可能不同。解决我通常把 build_vocab 和 predict 脚本里jieba.cut的结果打印出来对比如果发现差异就锁定 jieba 版本并在项目里加requirements.txt固定版本号。如果不方便锁版本更稳妥的方案是用训练时的词表反向映射——推理时遇到分出来的新词先尝试原词匹配匹配不到再映射到unk。5.2 训练时 loss 降到 1.5 左右就不再下降生成内容全是重复短句现象loss 停在 1.5 附近生成结果是“是的是的是的”或者反复重复同一句话。原因大概率是 teacher forcing ratio 设置过高导致模型过度依赖真实标签。训练时每一步都看到正确答案解码器没有学会纠错。推理时生成的 token 一旦出现偏差后续步骤会在错误的基础上继续错累积误差导致重复。解决我一般从 0.5 开始训练每 5 个 epoch 下降 0.1最终降到 0.2。如果不想动态调整恒定为 0.4 也比默认的 0.5 效果好。另外把 max_len 设小一点比如原问题的 0.8 倍长度模型被迫学习更紧凑的表达重复短句的问题也能缓解。5.3 Beam Search 推理非常慢GPU 利用率忽高忽低现象beam_size 调到 5 之后单条推理时间从 20ms 涨到 150ms批量预测时显存占用波动大。原因beam search 每一步都要维护 beam_size 条序列相当于每一步做了 beam_size 次解码计算量大是必然的。更严重的是源码里如果对每条序列单独调用模型 forwardbatch 维度会变成 1GPU 并行性完全浪费。解决把 beam search 改成 batch 模式把所有 beam 序列拼成一个 batch 一起送入解码器即 batch 维度是 beam_size而不是逐条循环。另外在文本长度较短时用贪心解码做线上服务beam search 只用于离线评估。实测线上 qps 从 20 提升到 80生成质量几乎无差别。5.4 验证集 loss 和训练集 loss 差距很大但 ROUGE 分数反而更高现象训练集 loss 0.6验证集 loss 1.6ROUGE-1 却达到 0.42比训练集上的 0.35 还高。原因loss 计算包含词汇预测的难易度验证集中存在大量 OOV 词所以验证 loss 天然偏高。而 ROUGE 只看 n-gram 重叠模型学会输出高频关键词就能拿分所以两者并不完全正相关。这个现象不是 bug是评估指标的错位。解决不要只看 ROUGE 决定保存哪个 checkpoint把训练 loss、验证 loss、ROUGE-1 三条曲线打出来一起看。如果 ROUGE 高但验证 loss 高说明模型学到了“套路词”泛化能力一般。我自己的习惯是同时看 ROUGE-2 和 ROUGE-L它们对语序和长度更敏感更接近真实质量。5.5 模型推理时生成的句子过长evaluation 时 ROUGE 被拉低现象答案标准长度是 20 个词模型输出 50 个词即使内容包含标准答案ROUGE 分也很低。原因训练时目标序列的eos在 padded 后被当作普通 token 处理模型没有学到在/eos出现后停止。推理时如果没有显式停止条件模型会一直生成到 max_len导致过长的输出。解决推理时设置两个停止条件生成到eos或达到max_len就终止。同时训练时可以把a_tensor中超过target_max_len的样本直接截断而不是保留到 max_len 再 pad。经验值 target_max_len 设为问题长度的 0.7 到 0.9 比较合适。6. 把注意力权重可视化验证模型是否真的学到了对齐关系如果你已经跑通了训练和推理下一步最值得做的不是调参而是把注意力权重导出来看一眼。这能帮你判断模型学到的是否是真正的“摘要对齐”还是只停留在机械的拷贝式生成。毕竟注意力可视化的代码大约四十行但带来的模型判断价值远超想象。带注意力的解码器在每一步生成 token 时都会产生一组对编码器各位置权重的概率分布——形状是[1, seq_len]。把这组概率画成热力图横轴是输入问题分词纵轴是生成答案分词就能直观看到每个输出词主要关注了输入的哪些部分。你大概率会发现两类问题一是某些输出词对pad位置分配了高权重说明模型没学会屏蔽 padding 位置二是某些高频功能词“的”、“是”权重分布非常分散这倒不一定是问题可能是因为这些词本身不依赖特定输入。import matplotlib.pyplot as plt import numpy as np def visualize_attention(question_tokens, answer_tokens, attn_weights): attn_weights: [answer_len, question_len]已做过 softmax 归一化 fig, ax plt.subplots(figsize(12, 8)) im ax.imshow(attn_weights, cmapBlues, aspectauto) ax.set_xticks(np.arange(len(question_tokens))) ax.set_yticks(np.arange(len(answer_tokens))) ax.set_xticklabels(question_tokens, rotation45, haright) ax.set_yticklabels(answer_tokens) ax.set_xlabel(Input Question Tokens) ax.set_ylabel(Generated Answer Tokens) ax.set_title(Seq2Seq Attention Visualization) plt.colorbar(im) plt.tight_layout() plt.savefig(attention_vis.png, dpi150) # plt.show()使用方式是在 beam search 的解码循环里把每一步的attn_weights[0]存下来收集完整条序列的所有向量然后调用上面的函数。注意attn_weights的维度可能是[batch, seq_len]在单条样本推理时取[0]即可。保存的字节大小取决于输入序列长度一般不会超过几十 KB。如果热力图上出现“答案中的名词对齐到了输入中的无关位置”说明模型没学到真正的对齐可能的原因是训练数据中问题与答案的匹配本身就比较弱。这时候有两个改进方向一是清洗数据把问题和答案相关性差的样本过滤掉二是调整注意力实现把简化的单层线性注意力换成标准 Bahdanau两层 MLP tanh用更多的参数换取更强的对齐表达能力。我也用过一种快速验证方法随机选 5 条生成的样本手动检查热力图和真实文本的关系——如果 3 条以上都出现“答案关键词对齐到问题的正确位置”说明模型是可信的可以做后续应用。不要把注意力可视化当成严格评测手段它是模型 debug 的辅助工具主要用来发现明显的语义错位而不是用来证明模型有效。从那次之后我每次训练完摘要模型都会强制把注意力热力图导出来走一遍检查流程——再做任何参数调整或上线决策。这样做虽然耗时但能避免很多后期返工。希望帮到你。本文还有配套的精品资源点击获取
返回列表