ARTICLE DETAIL

资讯详情

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

从零实现Seq2Seq对话模型:PyTorch+GRU+Attention实战

从零实现Seq2Seq对话模型:PyTorch+GRU+Attention实战 很多人一上来就追着“大模型”跑翻了一堆 Transformer、GPT 的博客结果连第一行代码都不知道从哪写起。我劝这类朋友先冷静一下——如果你真的想把大模型玩明白Seq2Seq 是绕不过去的第一站。对话功能、翻译任务、摘要生成这些的底层逻辑全都和 Seq2Seq 一脉相承。这篇文章就用 PyTorch 从零实现一个最简单的对话模型输入“你好”让它学会回“你好呀很高兴见到你”。适合有 Python 基础、会用 PyTorch 基本张量操作、但还没真正实现过完整 NLP 项目的朋友。我不讲花活只讲能跑起来、能理解、能复现的流程。1. 为什么从 Seq2Seq 起步核心思路与方案选型1.1 对话任务和大模型的关系对话功能的本质是一个“条件文本生成”问题给定一句输入文本模型预测一句输出文本。听起来简单但它揭开了一个很核心的迷雾——现代大模型比如 GPT 系列、ChatGPT本质上都是“输入一串 token逐字预测下一个 token”。这和 Seq2Seq 里 Decoder 做的事一模一样只是把底层的 RNN 换成了 Transformer把参数量放大了几个数量级把数据从几万条换成了几十亿条。所以你在小模型上搞清楚“这一步输入是什么、输出是什么、梯度怎么回传”再去看大模型的架构会发现很多概念都是眼熟的比如 attention 机制、teacher forcing、self-attention 的 query/key/value 雏形在 Seq2Seq 里全都已经出现。很多人一上来就接触 Ollama、vLLM 这些部署大模型的工具装上就能跑通 chat但里面发生了什么完全黑盒。我不是说工具不好而是如果你想进阶到微调、私有化部署、甚至自己设计模型黑盒一定会卡住你。用 Seq2Seq 自己造一个小对话模型是最低成本的破局方式。1.2 技术选型PyTorch GRU 字级 token选型上我直接给了结论但每个选择背后都有理由。第一用 PyTorch 而不是 Keras、TensorFlow 或现成的 Hugging Face。参考动手学深度学习的框架选择PyTorch 的自动求导和 RNN 接口最直白写起来像搭积木。不直接调 Hugging Face 的 Seq2Seq 封装是因为封装把 attention、decoder 全部隐藏了学了等于没学。第二用 GRU 而不是 LSTM。GRU 参数更少训练更快在小数据量情况下效果和 LSTM 几乎没差。对教学 demo 来说完全够用。你后面看 Transformer 的时候不需要纠结 LSTM 的几个门控细节GRU 的复杂度刚刚好。第三用字级 tokenizer 而不是词级。中文不像英文有天然空格分词如果用 jieba 分词会多一层依赖词表也更大。字级方案每个汉字就是一个 token词表几百个字符就够代码简单对“理解流程”这个目标聚焦得最好。真实项目中你当然会换掉但先跑通再说。1.3 架构预览编码器-解码器与注意力Seq2Seq 架构可以抠成三个部分Encoder、Decoder、Attention。Encoder 负责读入完整输入句子把每个 token 映射成向量经过一个双向 GRU 后把整个句子的信息压缩成一个隐藏状态向量。这个向量被当作任务里说的“语义向量”。Decoder 则拿着这个向量作为初始状态一步一步地把答案里的每个字预测出来。每预测一步它都会通过 Attention 机制回看 Encoder 输出的每个位置找到当前生成最该关注的输入词——这就像是做题时“带着当前问题回到原文找线索”而不是闭着眼睛硬编。生活里类比一下你把一段中文交给我翻译成英文我先完整听懂Encoder再根据理解一句一句翻Decoder翻每个词时我会在脑里回想原文最相关的部分Attention。这三个组件不是可选项而是分工关系。2. 数据准备把“人话”变成模型认识的数字2.1 对话数据集怎么来真实场景下训练对话模型至少需要几千到几万条对话对。常见的开源中文闲聊语料有 LCCC、小黄鸡语料等直接下载后按“问题\t回答”的格式解析即可。但这篇是 demo为了让代码零依赖、复制就能跑我直接内置了一个 10 条左右的 MiniChitChat 数据集。它够我们跑通完整流程也能看到模型学到一点“接话”的规律。你如果追求实际效果把它换成几千条真实语料就行后面代码完全不用改。pairs [ (你好, 你好呀很高兴见到你), (你叫什么名字, 我叫小智你呢), (今天天气怎么样, 天气不错适合出门走走), (你是谁, 我是一个小小的对话机器人), (你会做什么, 我可以陪你聊聊天), (再见, 再见下次再聊), (你多大了, 我一岁啦), (你喜欢什么, 我喜欢和大家聊天), (什么是人工智能, 人工智能就是让机器学会思考的技术), (你好吗, 我很好谢谢关心), ]字段就是两个字符串简单粗暴。2.2 字级词表与特殊标记每个字符要被映射成一个整数 id模型才能处理。这里要引入几个特殊 token它们各自解决一个实际问题pad用于把 batch 里不同长度的句子对齐到相同长度bos表示句子开始Decoder 第一步的输入eos表示句子结束Decoder 生成到它时就停止unk用于替换词表中没出现的字兜底用。构建词表的代码很简单核心是统计所有对话里的字符频次class Vocab: def __init__(self): self.stoi {pad: 0, bos: 1, eos: 2, unk: 3} self.itos {} for k, v in self.stoi.items(): self.itos[v] k def build(self, sentences, min_freq1): counter {} for s in sentences: for ch in s: counter[ch] counter.get(ch, 0) 1 for ch, freq in counter.items(): if freq min_freq: self.stoi[ch] len(self.stoi) self.itos[self.stoi[ch]] ch def encode(self, s): return [self.stoi.get(ch, self.stoi[unk]) for ch in s] def decode(self, ids): return .join(self.itos[i] for i in ids if i not in (self.stoi[pad], self.stoi[bos], self.stoi[eos]))我实测发现一个容易踩的坑处理中文数据时英文标点和中文标点混在一起如果不做min_freq过滤词表会被各种只出现一次的标点塞满。这里的min_freq1已经放得很宽真实数据建议调成 2 或 3。2.3 批处理与 Padding 细节PyTorch 的 RNN 要求一个 batch 内序列长度一致。做法是每个 batch 按最长句子做 padding。这里有个经验之谈pad 到最长的句子而不是固定长度这样不至于浪费算力。collate_fn是 DataLoader 里最关键的一环它要把一条条样本拼成 batch同时给目标序列加上bos和eos。训练时 Decoder 输入是bos 答案原文预测目标是 答案原文 eos。这两者的长度必须一致Loss 才能对齐。def collate_fn(batch, vocab): src_list, tgt_list zip(*batch) pad_id vocab.stoi[pad] bos_id vocab.stoi[bos] eos_id vocab.stoi[eos] src_len max(len(s) for s in src_list) tgt_len max(len(t) for t in tgt_list) 1 src_ids [s [pad_id] * (src_len - len(s)) for s in src_list] tgt_in_ids [[bos_id] t [pad_id] * (tgt_len - len(t) - 1) for t in tgt_list] tgt_out_ids [t [eos_id] [pad_id] * (tgt_len - len(t) - 1) for t in tgt_list] return torch.tensor(src_ids), torch.tensor(tgt_in_ids), torch.tensor(tgt_out_ids)再封装一个 Dataset 就齐了。跑 DataLoader 时设置batch_size32shuffleTrue用collate_fn传进去就行。3. 模型核心代码Encoder、Decoder 与 Attention3.1 Encoder理解输入序列Encoder 我选择了双向 GRU。先说为什么双向对话里有些信息需要从句尾回看句首。比如“我不喜欢他但他帮过我”这句话“他”到底指谁需要往后多看几个字才能清楚。双向 GRU 一个方向从前往后编码另一个方向从后往前编码每个 token 的向量都同时包含前后文信息效果明显更好。双向 GRU 返回的hidden是两层方向的拼接这里要用一个全连接层把它融合成 Decoder 能用的初始隐藏状态。直接拼会导致信息冗余和维度不匹配过一层线性加 tanh 可以有效压缩特征。class Encoder(nn.Module): def __init__(self, vocab_size, emb_size, hid_size): super().__init__() self.embedding nn.Embedding(vocab_size, emb_size) self.gru nn.GRU(emb_size, hid_size, bidirectionalTrue) self.fc nn.Sequential( nn.Linear(2 * hid_size, 2 * hid_size), nn.Tanh() ) def forward(self, x): # x: (batch, src_len) emb self.embedding(x) # (batch, src_len, emb_size) outputs, hidden self.gru(emb) # outputs: (batch, src_len, 2*hid) # hidden: (2, batch, hid) hidden torch.cat([hidden[0], hidden[1]], dim-1) # (batch, 2*hid) hidden self.fc(hidden).unsqueeze(0) # (1, batch, 2*hid) return outputs, hidden注意这里outputs是每个输入位置的完整上下文向量序列它要留给 Attention 使用。hidden是整句话的摘要用来初始化 Decoder。3.2 Decoder逐字生成答案Decoder 手里的牌比 Encoder 少一半——它拿不到未来信息只能一步一步往前“猜”。它每生成一个字符就把这个字符作为下一步输入循环下去。这也是大模型里“自回归生成”的原始形态。实现上Decoder 的隐藏层大小我故意设为2 * hid_size目的是和 Encoder 双向输出维度对齐这样 attention 点积计算时不需要额外投影层代码更干净。class Decoder(nn.Module): def __init__(self, vocab_size, emb_size, hid_size): super().__init__() self.embedding nn.Embedding(vocab_size, emb_size) self.gru nn.GRU(emb_size, 2 * hid_size) self.out nn.Linear(4 * hid_size, vocab_size) def forward(self, y, enc_outputs, enc_hidden): # y: (batch, tgt_len) emb self.embedding(y) # (batch, tgt_len, emb_size) outputs, hidden self.gru(emb, enc_hidden) # (batch, tgt_len, 2*hid) scores torch.bmm(outputs, enc_outputs.transpose(1, 2)) attn_weights F.softmax(scores, dim-1) # (batch, tgt_len, src_len) context torch.bmm(attn_weights, enc_outputs) # (batch, tgt_len, 2*hid) logits self.out(torch.cat([outputs, context], dim-1)) return logits, hiddenenc_hidden传给 GRU 后GRU 内部会把它当作初始 hidden 使用返回的hidden是最后一步的状态推理时可以继续传给下一步。3.3 Attention带着问题找答案这是很多初学者最容易懵的部分我拆开讲。Attention 的核心是算“当前该重点关注原文哪个位置”。Decoder 生成每一步时都有一个当前步的隐藏状态outputs[:, t, :]我把它和 Encoder 输出的所有位置enc_outputs[:, i, :]做点积得到一个分数。分数越高说明当前位置和生成当前词越相关。把这组分数过 softmax变成了权重再用这些权重对 Encoder 所有位置做加权平均得到“上下文向量”。上下文向量再和当前的 GRU 输出拼在一起过一个全连接层得到词汇表上的分布。翻译成大白话就是模型每写一个字之前先回头看看原文哪些字“在用”然后把“看到的重点”和“当前的想法”合并最终决定写哪个字。torch.bmm是批量矩阵乘法它一次性算完所有时间步的 attention 分数比循环快得多。这就是分块并行处理的直觉——能矩阵化计算就不写 for 循环不光是效率代码也简洁。3.4 组合成完整 Seq2Seq把 Encoder 和 Decoder 拼成一个完整模型前向计算链路是输入问题 token ❯ Encoder 编码 ❯ 得到语义向量和输出序列 ❯ Decoder 逐字生成 ❯ 输出每个位置的词表分布。class Seq2Seq(nn.Module): def __init__(self, encoder, decoder): super().__init__() self.encoder encoder self.decoder decoder def forward(self, src, tgt_in): enc_outputs, enc_hidden self.encoder(src) return self.decoder(tgt_in, enc_outputs, enc_hidden)我建议新手先把三个类的 forward 后分别 print 出每个 tensor 的 shape跑通一次前向再训练。这一步能帮你省掉至少两小时的调试时间。4. 训练流程Teacher Forcing 与超参调整4.1 损失函数与标签对齐Loss 用CrossEntropyLoss但必须设置ignore_indexPAD_IDX否则模型会把大量时间花在预测 pad 位置损失的数值会被 padding 灌满实际学习不到什么。训练时模型输入的是tgt_in前面加过bos要预测的是tgt_out末尾加了eos。两者长度相同直接 reshape 成(-1, vocab_size)和(-1)就能算 loss。这一步维度对齐是 Transformer 时代之后依然沿用的标准姿势。4.2 训练循环与梯度裁剪训练里有个关键技巧叫 Teacher Forcing。简单说训练时每一步的输入都用“真实的上一个词”而不是模型自己上一步预测出来的词。为什么这样做因为刚开始模型预测得乱七八糟如果让它拿自己的错误输出继续往后生成错误会传播、叠加最终什么都学不会。Teacher Forcing 相当于教练在旁边把正确答案喂给它让它有机会学好每一局部再谈全局。我实现的方式是用tgt_in一次性输入整个目标序列这本身就是 100% 的 teacher forcing。代码更短训练更快。有人会问要不要用 50% 概率的自由生成去混合训练那是 schedule sampling等模型能收敛之后再考虑目前超纲。训练循环还有一个必备操作梯度裁剪。RNN 对梯度爆炸非常敏感一个小学习率加clip_grad_norm_能省大量调参时间。model Seq2Seq(encoder, decoder) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss(ignore_indexPAD_IDX) for epoch in range(30): model.train() total_loss 0 for src, tgt_in, tgt_out in dataloader: optimizer.zero_grad() logits, _ model(src, tgt_in) loss criterion(logits.reshape(-1, len(vocab)), tgt_out.reshape(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss loss.item() print(fepoch {epoch 1}, loss: {total_loss / len(dataloader):.4f})跑 30 个 epoch 时能看到 loss 从 2.3 一路掉到 0.3 以下这时候模型已经有模有样了。4.3 超参数经验表下面是我这 10 条数据上实测比较稳的参数组合。小数据量下参数太大了容易过拟合太小了学不动表里的值可以作为起点。参数取值说明emb_size128嵌入维度越大表达越丰富但也越容易过拟合hid_size64GRU 隐藏维度双向后会变成 128batch_size16数据集小batch 太大见过每轮只有一两步更新lr1e-3再大容易不收敛EPOCHS30小数据量 30 轮足够真实语料可能要翻倍clip_norm1.0梯度裁剪阈值一个经验如果你的数据只有 10 条别指望效果惊天动地。跑通流程的意义不是获得一个能上线的机器人而是将来换大语料时“怎样把数据变成张量、怎样对齐 label”这些环节你已经胸有成竹。5. 推理生成与效果测试5.1 贪心解码训练好之后的模型拿到新输入时没法再用 teacher forcing 拿到真实答案只能“自己接自己的话”。最朴素的解码算法是贪心解码每一步都取概率最大的那个字作为下一步输入直到出现eos或达到最大长度。def greedy_decode(model, query, vocab, max_len20): model.eval() tokens [vocab.stoi[bos]] vocab.encode(query) [vocab.stoi[eos]] src torch.tensor(tokens).unsqueeze(0) with torch.no_grad(): enc_outputs, enc_hidden model.encoder(src) dec_input torch.tensor([[vocab.stoi[bos]]]) hidden enc_hidden result [] for _ in range(max_len): logits, hidden model.decoder(dec_input, enc_outputs, hidden) next_id logits.argmax(-1).item() if next_id vocab.stoi[eos]: break result.append(vocab.itos[next_id]) dec_input torch.tensor([[next_id]]) return .join(result)贪心解码的问题是容易陷入局部最优。比如它有可能会生成“你好”后下一步十分自信地生成“再见”直接把对话结束。进阶方案是 Beam Search它每一步存概率最高的前 k 个候选序列最后从 k 个完整序列里挑得分最高的。下表是直觉对比方法优点风险Greedy简单、快局部最优、容易重复Beam Search k4全局性更好速度慢、可能偏向短句在小 demo 里贪心已经足够能让你看到模型学到的对话模式。5.2 小模型的实际对话效果实测下来MiniChat 数据集训练的模型能生成这样的对话问你好 答你好呀很高兴见到你 问你叫什么名字 答我叫小智你呢 问今天天气怎么样 答天气不错适合出门走走如果换成真的只背了 10 条语料的模型很多没见过的问法会直接触发unk或者eos提前结束。这不是 bug是数据量太小导致的覆盖不足。下一次你在网上看到“大模型对话翻车”很多其实也是数据覆盖问题而不是模型结构问题。所以这个 demo 验证的是什么验证的是一个 5 万参数左右的小模型能够通过这么简单的训练流程学会把“问题”和“回答”之间的对应联系编码进参数里。大的对话系统之所以复杂不是在基础原理上多出什么神迹而是在“数据量、模型容量、场景适配”这三个维度上不断加码。5.3 从 Seq2Seq 到现代大模型你一旦理解这个模型再去看 GPT 的生成过程就非常顺输入一段文本模型预测下一个 token的概率分布采样一个 token把它接到输入后面继续预测下一个。这就是 Decoder 的自回归逻辑一模一样。注意力机制在 Transformer 里被升级成了 self-attention让每个 token 都能同时看到序列中所有位置的其它 token并行度更高长距离依赖也更强。我见过不少人直接拿 Ollama 跑开源模型跑通了本地部署但一问到“finetune 时改了哪些层”“context length 超了会怎样”就答不上来。玩大模型的正确姿势是先有这种几千行代码的控制感再去看几十亿参数的黑盒。6. 常见问题与排查技巧6.1 训练 Loss 不下降怎么办最常见原因是学习率设置太大Loss 在某个值附近震荡。解决办法是先降到1e-4试试。如果还不行检查词表里是不是有大量unk说明数据集太小很多测试字没见过。另一个容易被忽视的点是collate_fn 中的 padding 逻辑有没有写错如果 target 和 input 的长度没对齐loss 数值会很怪。我常用一招直接打印一个 batch 的src、tgt_in、tgt_out人工检查每个序列的起始和结束 token 是否符合预期。6.2 预测输出全是一个字或者触发 EOS这是 Seq2Seq 训练里最常见的问题之一。原因通常是Decoder 的初始 hidden 和 GRU 维度不匹配或者 encoder 双向 hidden 拼接顺序写错导致解码器没有拿到任何有效的句子信息。还有一个原因训练时 teacher forcing 效果太好模型过分依赖真实前缀一到推理环节暴露了自己的“猜词能力”。解决办法一是检查初始 hidden 的计算二是增加训练轮数让模型在 teacher forcing 外尽快摸索出独立解码能力。6.3 Attention 看不到正确位置想排查 attention 问题直接打印 attention 权重矩阵热力图。如果生成某个词时模型给了pad很高的权重说明 padding 位置干扰了模型。改进思路是构造 key mask在算 softmax 前把 padding 位置的分数设成负无穷。小 demo 里通常不影响但真实项目里这个 mask 是必须的。6.4 长句效果差RNN 类模型按序列逐个处理长句子很容易丢失前面信息这是结构性限制。如果将来要处理超过 30 个字的长句短序列的 Seq2Seq 就不好使了。这点也解释了为什么现代大模型普遍使用 Transformer——attention 让每个 token 都能直接“看到”任何远距离 token不再依赖顺序传导。6.5 如何继续扩展成大模型如果你已经跑通了这个小 demo接下来的路就很清晰。先升级 Transformer把 Encoder 和 Decoder 替换成 Transformer 层训练逻辑基本不变。然后换大数据集几万条对话起步。再了解预训练和微调用开源基座模型在自己的对话语料上做 fine-tune这时 Ollama 和 vLLM 这类部署工具就派上用场了。大模型上下文长度的处理、显卡显存如何规划、量化部署怎么做这些都是后面系列文章要展开的内容。但不管往后走多远Seq2Seq 教会你的那件事永远适用模型再大本质仍是“输入序列条件生成”。我最后再分享一个个人体会第一次跑通这个小模型时训练完发现输出全是“你好你好你好”排查了一晚上才发现是 Decoder 的初始 hidden 维度不对。当时我特别焦虑觉得模型太复杂了。后来我把所有 tensor shape 打印出来逐个核对才意识到一小行unsqueeze就能解决所有问题。调模型这件事绝大多数时间不是在看数学而是在核对维度。建议你也从打印 shape 开始排查比瞎猜超参数靠谱得多。
返回列表