资讯详情

基于Python的seq2seq知识库问答:从数据准备到注意力机制

📅 2026/9/11 4:13:56 | 华诺云谱 👁 阅读
基于Python的seq2seq知识库问答:从数据准备到注意力机制
简介这套基于Python的Seq2Seq问答代码包面向希望动手实现知识库问答系统的NLP学习者与开发者围绕编码器-解码器框架串起数据预处理、分词与词表构建、序列填充、模型构建、损失计算、训练优化以及最终答案生成等完整链路。压缩包共21个文件大小仅3.39MB其中7个.py脚本构成核心工程涵盖模型定义、训练与预测等模块4个train和4个test文本数据文件用于训练与验证2个json文件提供WebQuestions问答样例2个vocab文件保存源/目标词表2个sta文件记录训练测试统计信息目录结构清晰便于按需改造替换。已有2089人学习下载适合已有一定Python基础、想深入了解Seq2Seq在知识库问答中应用的读者。通过对照代码可掌握LSTM/GRU编码器、注意力机制、交叉熵损失和Adam优化等实现细节并利用simple数据集与WebQuestions实例将知识库检索与答案生成结合起来为后续扩展到Transformer或BERT等更强模型打下坚实基础。1. 基于Python的知识库问答seq2seq模型要解决什么问题多数组件在面对一个小型知识库几百条三元组或FAQ记录时第一反应通常是实体链接加关系分类。这套流程在大规模知识图谱上确实成熟但落到实际数据上往往跑不动训练样本稀疏、关系类型难以枚举问题表达又非常灵活分类器很难覆盖所有问法。seq2seq模型提供了一条更短的技术路径把知识库问答当作一个文本到文本的生成问题输入是自然语言问题输出是自然语言答案。它不要求显式识别实体和关系只需要把知识库整理成“问题-答案对”交给一个标准的Python训练pipeline就能跑起来。需要说明的是这种方案的边界在于适合单一事实型问答和自然语言变体多的场景不适合多跳推理或需要逻辑计算的问答。下面从数据准备开始把每一块代码和参数讲清楚。2. 知识库问答的数据准备从三元组到 seq2seq 训练集2.1 从知识库三元组生成问题-答案对知识库里的数据无论来自知识图谱还是FAQ最终都要转成“问题-答案”的平行语料。如果原始数据是三元组比如(北京, 是, 中国的首都)手工维护一份关系到问法模板的映射表是常见做法。关系问题模板is_capital_of{subj}是什么的首都/{subj}是哪里的首都founded_in{subj}的成立时间是/{subj}是什么时候成立的located_in{subj}位于哪里/{subj}在什么地方模板数量太少会让模型过拟合到固定句式太多容易造出语义重复的废样本。一般每个关系配置 24 个模板就够重点是把答案列规范化多数情况下答案是一个实体少数是一个短句。如果答案超过 10 个 tokenseq2seq 训练时收敛会明显变慢建议在数据阶段就把长答案拆开或改写。# kb_to_qa.py def build_qa_pairs(triples, relation_templates): qa_pairs [] for subj, rel, obj in triples: templates relation_templates.get(rel) if templates: for template in templates: question template.format(subjsubj, objobj) qa_pairs.append((question, obj)) return qa_pairs templates { is_capital_of: [ {subj}是什么的首都, {subj}是哪个国家的首都, ], }代码里obj直接作为答案。如果知识库本身就是问答对文件可以跳过三元组转换这一步。需要提醒的是模板映射表和知识库实体名要保持一致否则生成的问题里会出现“{subj}”这种未替换文本而这个脏数据在序列到序列模型里会被当成一个真正的词学进词表后期去掉非常麻烦。2.2 构建词表控制词表规模与特殊token调 seq2seq 模型时最容易被忽视的坑是词表。知识库问答的词表通常不大常见做法是只保留出现频率大于等于min_freq的词其余统一映射为unk。起始值建议设min_freq2如果训练数据只有几千条可以降到 1。# vocab.py class Vocab: def __init__(self, min_freq1): self.word2idx {pad: 0, sos: 1, eos: 2, unk: 3} self.idx2word {v: k for k, v in self.word2idx.items()} self.freq {} self.min_freq min_freq def fit(self, sentences): for s in sentences: for w in s.split(): self.freq[w] self.freq.get(w, 0) 1 for w, f in self.freq.items(): if f self.min_freq: self.add_word(w) def add_word(self, word): if word not in self.word2idx: idx len(self.word2idx) self.word2idx[word] idx self.idx2word[idx] word def encode(self, sentence, max_len): toks sentence.split() ids [self.word2idx.get(t, self.word2idx[unk]) for t in toks] ids [self.word2idx[sos]] ids [self.word2idx[eos]] ids ids[:max_len] pad_len max_len - len(ids) mask [1] * len(ids) [0] * pad_len ids ids [self.word2idx[pad]] * pad_len return ids, maskencode返回 token id 序列和 maskmask 中 1 对应真实 token0 对应 padding。后面编码器的pack_padded_sequence和解码器的注意力都要用这个 mask所以统一在词表类里维护最省事。这里有一个 KBQA 特有的问题fit之后最好把知识库里所有实体列表补充进词表。如果某个答案实体在训练数据里没出现足够次数解码器可能永远无法生成它导致模型对常见实体泛化还行、对冷门实体完全失灵。2.3 批量 padding 与 DataLoader 注意事项训练 seq2seq 时同一个 batch 内序列长度必须对齐padding 位置需要配合 mask 使用。下面是一个collate_fn的实现DataLoader 每取一个 batch 就调用它一次。# dataset.py def collate_fn(batch, vocab, src_max_len, tgt_max_len): src_ids, src_mask [], [] tgt_ids, tgt_mask [], [] for question, answer in batch: q_ids, q_mask vocab.encode(question, src_max_len) a_ids, a_mask vocab.encode(answer, tgt_max_len) src_ids.append(q_ids) src_mask.append(q_mask) tgt_ids.append(a_ids) tgt_mask.append(a_mask) return ( torch.tensor(src_ids, dtypetorch.long), torch.tensor(src_mask, dtypetorch.float32), torch.tensor(tgt_ids, dtypetorch.long), torch.tensor(tgt_mask, dtypetorch.float32), )src_mask传给注意力机制tgt_mask在计算 loss 或做其他自定义操作时用。src_max_len和tgt_max_len建议在预处理时统计问题与答案的分位数长度取 95 分位再加 2给sos和eos留位置。DataLoader 参数建议值说明batch_size32小数据集用 1632大数据集可到 64drop_lastTrue防止最后一个 batch 过小影响 bn 或梯度pin_memoryTrueGPU 训练时减少拷贝耗时collate_fn见上方代码手动做 padding实际运行中可以先按问题长度排序再组 batch。虽然pack_padded_sequence支持enforce_sortedFalse但无序输入会引入额外排序开销排序后训练能快 20% 左右。3. seq2seq 模型代码实现编码器、解码器与注意力机制3.1 编码器双向 LSTM 与 pack 机制编码器的作用是把问题变成一组向量。在知识库问答里问题短、语义集中所以 BiLSTM 足够用。双向的好处是让每个时间步同时看到前后文例如“中国首都是什么”里的“首都”和“中国”在向量表示里能发生交互。以下代码不依赖额外算法库python 环境里装好 PyTorch 即可运行。# model.py import torch import torch.nn as nn from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence class EncoderRNN(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, dropout0.2): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.dropout nn.Dropout(dropout) self.lstm nn.LSTM(embedding_dim, hidden_size, 1, batch_firstTrue, bidirectionalTrue) def forward(self, src_ids, src_lengths): embedded self.dropout(self.embedding(src_ids)) packed pack_padded_sequence(embedded, src_lengths.cpu(), batch_firstTrue, enforce_sortedFalse) packed_output, (h_n, c_n) self.lstm(packed) encoder_outputs, _ pad_packed_sequence(packed_output, batch_firstTrue) return encoder_outputs, (h_n, c_n)src_lengths来自src_mask.sum(dim1)它让 LSTM 只在真实序列长度内运行padding 位置不参与状态传递。这样h_n不会受到 padding 污染。注意nn.LSTM(dropout...)在单层时不生效所以代码里在 embedding 之后手动加了一个Dropout。这是小规模任务里更可控的做法。3.2 解码器Bahdanau 注意力与掩码计算解码器是一个带注意力的 LSTM。这里实现拼接加性注意力也就是 Bahdanau Attention。在 KBQA 中答案的每个词通常只依赖问题里少数几个词注意力需要能直接体现这种对齐关系。class AttnDecoderRNN(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, dropout0.2): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.dropout nn.Dropout(dropout) self.lstm nn.LSTM(embedding_dim, hidden_size, 1, batch_firstTrue) self.Wa nn.Linear(hidden_size * 2, hidden_size, biasFalse) self.Ua nn.Linear(hidden_size, hidden_size, biasFalse) self.v nn.Linear(hidden_size, 1, biasFalse) self.fc_out nn.Linear(hidden_size * 2, vocab_size) def forward(self, decoder_input, encoder_outputs, h_prev, src_maskNone): embedded self.dropout(self.embedding(decoder_input)) lstm_out, h_state self.lstm(embedded, h_prev) attn_scores self.v( torch.tanh(self.Wa(encoder_outputs.unsqueeze(1)) self.Ua(lstm_out).unsqueeze(2)) ).squeeze(-1) # (batch, tgt_len, src_len) if src_mask is not None: attn_scores attn_scores.masked_fill(src_mask.unsqueeze(1) 0, -1e9) attn_weights torch.softmax(attn_scores, dim-1) context torch.bmm(attn_weights, encoder_outputs) # (batch, tgt_len, hidden*2) combined torch.cat((lstm_out, context), dim-1) logits self.fc_out(combined) return logits, h_state, attn_weights关键在维度处理encoder_outputs.unsqueeze(1)变成(batch, 1, src_len, hidden*2)self.Ua(lstm_out).unsqueeze(2)变成(batch, tgt_len, 1, hidden)广播相加后通过v得到每个目标位置对每个源位置的分数。mask 的填充值建议用-1e9而不是 0。softmax 之后 0 位置的注意力不会被完全清掉而负无穷可以让 padding 位置的注意力严格为 0。训练时如果出现 NaN优先检查是不是某一行 mask 全部被遮掉了。3.3 模型参数设定与解码器初始化KBQA 的 seq2seq 参数不能照搬机器翻译配置。机器翻译词表大、句子长需要宽 embedding 和多层 LSTMKBQA 词表小、句子短简化反而更稳定。参数初始值说明embedding_dim128词表低于 5000 时不需要更高hidden_size128句长 15 以内足够num_layers1小数据加深层数容易过拟合dropout0.20.3主要正则化手段beam_width35推理阶段使用解码器初始状态由编码器 LSTM 最后一层的双向隐状态拼接得到。decoder_init_fc nn.Linear(hidden_size * 2, hidden_size) # encoder 返回的 encoder_states[0] 是 h_nshape (2, batch, hidden) h_n encoder_states[0] h0 decoder_init_fc(torch.cat((h_n[-2], h_n[-1]), dim-1)) c0 torch.zeros_like(h0)h_n[-2]是前向隐状态h_n[-1]是反向隐状态。把它们拼起来过一个线性层可以让解码器从编码器整体语义状态开始生成。c0直接归零这个处理对这个任务规模已经够用不必做更复杂的初始化。4. 训练 seq2seq 模型teacher forcing、损失函数与评估指标4.1 训练循环teacher forcing 与梯度裁剪训练 seq2seq 时最关键的策略是 teacher forcing。简单理解训练时把真实目标词喂给解码器模型只负责预测下一词。这种方式收敛很快但推理时解码器输入来自上一步自己的输出一旦犯错就会沿着序列传播这被称为 exposure bias。解决办法是在概率上混用真实词和预测词。teacher_forcing_ratio0.5表示每个 batch 有一半概率走 teacher forcing另一半概率走自由运行。经验上训练初期可以设高一点后期降到 0.3 以下。import random def train_one_batch(batch, encoder, decoder, decoder_init_fc, optimizer, criterion, teacher_forcing_ratio0.5): src_ids, src_mask, tgt_ids, tgt_mask [x.to(device) for x in batch] src_lens src_mask.sum(dim1).long().cpu() batch_size, tgt_len tgt_ids.size() optimizer.zero_grad() encoder_outputs, encoder_states encoder(src_ids, src_lens) h_n encoder_states[0] h0 decoder_init_fc(torch.cat((h_n[-2], h_n[-1]), dim-1)) c0 torch.zeros_like(h0) decoder_hidden (h0, c0) use_teacher_forcing random.random() teacher_forcing_ratio loss 0.0 if use_teacher_forcing: logits, _, _ decoder(tgt_ids[:, :-1], encoder_outputs, decoder_hidden, src_mask) loss criterion(logits.reshape(-1, vocab_size), tgt_ids[:, 1:].reshape(-1)) else: decoder_input tgt_ids[:, :1] # sos for t in range(tgt_len - 1): logits, decoder_hidden, _ decoder( decoder_input, encoder_outputs, decoder_hidden, src_mask) loss loss criterion(logits.reshape(-1, vocab_size), tgt_ids[:, t 1]) decoder_input logits.argmax(dim-1) loss loss / (tgt_len - 1) loss.backward() torch.nn.utils.clip_grad_norm_( list(encoder.parameters()) list(decoder.parameters()), 5.0) optimizer.step() return loss.item()teacher forcing 分支里把整个目标序列tgt_ids[:, :-1]传入解码器一次计算所有时间步的 logits比逐 token 循环更快。自由运行分支必须逐个时间步计算因为下一步输入依赖上一步的 argmax 结果无法向量化。clip_grad_norm_的max_norm设置为 5.0这一步非常关键。KBQA 数据偶尔会有脏样本loss 会出现瞬时 spike没有梯度裁剪时整个训练曲线会抖动甚至发散。注意src_lens是浮点数 mask 按行求和的结果必须调用.long().cpu()再传给pack_padded_sequence。这个类型问题在 CPU 上可能不报错但到 GPU 上会直接抛异常。4.2 损失函数交叉熵、label smoothing 与 padding 处理损失函数用标准交叉熵并建议开启 label smoothing。原因是 KBQA 中“什么”“的”“是”这类高频词出现次数很多模型容易把概率过度堆到这些词上产生过自信的错误。label smoothing 让模型不要过分相信训练集里的分布对没见过的问法更鲁棒。criterion nn.CrossEntropyLoss(ignore_index0, label_smoothing0.1)ignore_index0的前提是pad在词表中的索引确实为 0前面的 Vocab 类已经按这个设定实现。如果调整了词表顺序这里要同步修改。label_smoothing会让模型对正确答案的置信度目标从 1.0 降到 0.9其余概率分布在其他词上这在几百到几千样本的小数据集上效果很明显。4.3 评估指标BLEU 不适合作为唯一标准很多人用 BLEU 评测 KBQA但经常出现调参后 BLEU 涨了几个点、实际回答反而变差的情况。原因在于 BLEU 看重 n-gram 精确率而 KBQA 的正确答案往往是一个实体符号不对齐时 BLEU 就是 0。更合理的做法是同时看精确匹配 EM 和 token 级 F1。def token_f1(pred, gold): pred_tokens set(pred.split()) gold_tokens set(gold.split()) if not pred_tokens or not gold_tokens: return 0.0 common pred_tokens gold_tokens pre len(common) / len(pred_tokens) rec len(common) / len(gold_tokens) return 2 * pre * rec / (pre rec) if (pre rec) 0 else 0.0指标适用场景注意点EM答案是一个实体最严格适合单一事实F1答案是短句对表达方式有容忍度BLEU长答案生成短实体答案下参考意义不大人工抽检所有场景最终必须做的动作训练过程中可以每个 epoch 在验证集保存两个 checkpointEM 最高和 F1 最高各一份最后人工抽查决定用哪个。5. 推理时的三个改进技巧beam search、长度归一化与未登录词兜底5.1 用 beam search 替代 greedy 解码训练完后推理时greedy 解码每一步取最大概率词但一步错容易步步错。beam search 维护多个候选序列每步扩展后只保留分数最高的前beam_width个最后再选出最优序列。def beam_search_decode(encoder, decoder, decoder_init_fc, src_ids, src_mask, beam_width3, max_len30): src_lens src_mask.sum(dim1).long().cpu() encoder_outputs, encoder_states encoder(src_ids, src_lens) h_n encoder_states[0] h0 decoder_init_fc(torch.cat((h_n[-2], h_n[-1]), dim-1)) c0 torch.zeros_like(h0) beams [{ input: torch.tensor([[1]], devicesrc_ids.device), hidden: (h0, c0), score: 0.0, sequence: [], }] for _ in range(max_len): candidates [] for b in beams: if b[sequence] and b[sequence][-1] 2: # eos candidates.append(b) continue logits, hidden, _ decoder(b[input], encoder_outputs, b[hidden], src_mask) log_probs torch.log_softmax(logits[:, -1, :], dim-1) top_p, top_i log_probs.topk(beam_width) for k in range(beam_width): token top_i[0, k].item() candidates.append({ input: torch.tensor([[token]], devicesrc_ids.device), hidden: hidden, score: b[score] top_p[0, k].item(), sequence: b[sequence] [token], }) beams sorted(candidates, keylambda x: x[score], reverseTrue)[:beam_width] if all(b[sequence] and b[sequence][-1] 2 for b in beams): break best max(beams, keylambda x: x[score]) return best[sequence]每个 beam 都独立维护 LSTM hidden 状态这是 beam search 正确工作的前提。beam_width 取 3 到 5 之间比较平衡超过 5 之后质量提升不明显耗时却接近线性增长。5.2 长度惩罚、重复惩罚与未登录词兜底beam search 有一个固有缺陷累积 log 概率对长度是负相关的模型会偏向生成短答案。在 KBQA 里结果可能从“中国首都是北京”被压缩成“北京”。所以需要对得分做长度归一化。def normalized_score(beam, alpha0.7): return beam[score] / (len(beam[sequence]) ** alpha)alpha的取值逻辑是期望答案短就调到 1.01.2期望答案完整就调 0.6。可以先在验证集上跑几个取值对比 F1 再定。重复惩罚可以放在 beam 扩展阶段对当前候选词在已有序列中的出现次数做扣分。repeat_count b[sequence].count(token) penalty 1.0 0.3 * repeat_count candidate_score b[score] top_p[0, k].item() - penalty未登录词兜底针对的是模型输出里出现unk的情况。常见做法是把unk直接过滤或者从输入问题中找一个知识库实体替代。def replace_unk(answer_ids, vocab, question_ids, entity_id_set): result [] for token in answer_ids: if token vocab.word2idx[unk]: fallback next((t for t in question_ids if t in entity_id_set), None) result.append(fallback if fallback is not None else vocab.word2idx[unk]) else: result.append(token) return resultentity_id_set可以预先用知识库中所有实体词表构建成集合。这个替换逻辑只解决“生成了 unk 但问题里恰好有正确实体”的情况更复杂的情况需要配合实体链接但后者的工程量会大很多。5.3 三个排查点loss、注意力与重复词训练效果不对时按下面顺序排查不要直接改模型结构。第一loss 下降到一定值后不再动先把src_lens打印出来看是不是所有问题长度都集中在很短的范围。如果是说明数据生成模板太单一需要扩充问法。第二展示注意力权重矩阵确认生成每个答案词时注意力是否落到正确的源 token 上。如果注意力分散在多个无关词上多半是 padding 没有正确 mask。第三检查输出里的重复词。如果不同问题反复生成同一个答案通常不是模型问题而是训练数据里同一关系对应了多种正确答案模型学到了高频答案而不是语义映射。如果注意力权重在 padding 位置不是接近 0第一个要查的就是collate_fn返回的张量顺序和 decoder forward 参数是否对齐尤其注意src_mask和tgt_mask是否传反。把注意力权重按批次保存为热力图对照问题中实体词的边界一般能立刻找出是数据问题还是模型初始化问题。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。