尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从Seq2Seq到注意力机制:深度学习序列建模的核心演进与PyTorch实现

从Seq2Seq到注意力机制:深度学习序列建模的核心演进与PyTorch实现 1. 从“编码-解码”到“注意力”Seq2Seq模型的演进脉络如果你在2015年前后接触过机器翻译或者聊天机器人那你大概率听说过Seq2Seq这个名字。它就像一个万能翻译官能把一串输入序列比如一句英文变成另一串输出序列比如一句中文。这个想法在当时非常酷因为它用一个统一的框架解决了从文本翻译到语音识别、再到文本摘要等一系列“序列到序列”的转换问题。我最早用它来做智能客服的问答生成感觉就像给机器装上了一套可以自己组装的乐高积木输入问题输出答案逻辑上无比顺畅。但真正上手后你会发现最初的Seq2Seq模型有个很要命的问题它记性不好。具体来说模型的核心是一个编码器Encoder和一个解码器Decoder。编码器像是一个阅读者把整个输入句子比如“How are you?”读一遍理解后压缩成一个固定长度的向量我们称之为“上下文向量”或“思想向量”。然后解码器也就是那个写作的人就只看着这个浓缩的“思想向量”开始一个词一个词地生成输出句子比如“你好吗”。问题就出在这个“固定长度的向量”上。想象一下你要用一句话总结一本长篇小说所有的情节、人物关系和细节无论小说是100页还是1000页你都只能用固定长度的句子来总结。结果就是短篇小说可能总结得还行长篇小说的大量细节必然丢失。在Seq2Seq里这就导致了“长距离依赖”问题当输入序列很长时编码器开头的信息在传到解码器末端时已经衰减或丢失得差不多了生成的输出质量会急剧下降。所以当2014年注意力机制Attention Mechanism被引入Seq2Seq时整个领域都为之振奋。它相当于给了解码器一项“特权”在生成每一个输出词的时候不再只死盯着那个单一的、浓缩的上下文向量而是可以回过头去“瞥一眼”编码器处理每一个输入词时的中间状态并动态地决定当前应该更“关注”输入序列的哪一部分。这就像翻译时译员不是背下整段话再翻译而是边听边译听到某个复杂名词时可以特别关注一下原文中对该名词的解释部分。这个机制彻底解放了Seq2Seq模型处理长序列的能力也为其后Transformer架构的诞生埋下了伏笔。今天虽然最先进的模型已经超越了传统的Seq2SeqAttention架构但理解它仍然是理解现代自然语言处理基石的关键。2. Seq2Seq模型的核心架构拆解编码器与解码器如何协同工作要理解Seq2Seq我们必须深入它的两个核心部件编码器和解码器。在深度学习框架如PyTorch、TensorFlow中实现时这两者通常是两个独立的神经网络模块但通过一套严密的逻辑紧密耦合。2.1 编码器从序列到上下文的理解者编码器的任务是将变长的输入序列映射为一个固定长度的上下文向量。最经典和常用的编码器结构是基于循环神经网络RNN特别是其变体LSTM或GRU因为它们能更好地捕捉长距离依赖。假设我们的输入序列是X (x1, x2, ..., xT)其中T是序列长度。编码器是一个RNN它按时间步逐步处理每个输入xt。在每一个时间步tRNN单元会接收两个输入当前时间步的输入词嵌入xt以及上一个时间步的隐藏状态h_{t-1}。然后它会产生一个新的隐藏状态ht。这个计算过程可以形式化为ht RNN(xt, h_{t-1})。这里的RNN函数代表了LSTM或GRU单元内部复杂的门控计算。当处理完最后一个输入词xT后编码器会输出最终的隐藏状态hT。在最初的Seq2Seq论文中这个hT就被直接用作代表整个输入序列的“上下文向量”C即C hT。同时编码器在每个时间步t产生的隐藏状态ht也被保留下来它们构成了一个隐藏状态序列(h1, h2, ..., hT)。在引入注意力机制之前只有hT被传递给解码器引入注意力后这个完整的隐藏状态序列将成为解码器“关注”的宝藏。注意在实际实现中我们通常使用双向RNN作为编码器。这意味着我们会有两个RNN同时运行一个从前向后正向一个从后向前反向。最终每个时间步的隐藏状态是正向和反向隐藏状态的拼接。这样做的目的是让每个词的编码都能同时包含其左侧和右侧的上下文信息理解更充分。例如在句子“苹果很好吃”中双向编码能让模型在编码“苹果”时既知道前面没有上下文句首也知道后面跟着“很好吃”从而更准确地判断这里的“苹果”是水果而不是公司。2.2 解码器从上下文到序列的生成者解码器是另一个RNN它的任务是利用编码器提供的上下文信息逐个时间步地生成输出序列Y (y1, y2, ..., yT)。解码器的工作始于一个特殊的符号通常是sosstart of sequence。在第一个时间步解码器RNN的初始隐藏状态s0被设置为编码器最终的上下文向量C即s0 C。同时解码器的第一个输入是sos标记。在解码器的每一个时间步i它接收两个输入上一个时间步预测出的词的嵌入在训练时为了稳定有时也会使用真实的上一个词即“教师强制”以及自己上一个时间步的隐藏状态s_{i-1}。然后它计算当前时间步的隐藏状态sisi RNN(y_{i-1}, s_{i-1})。接下来关键的一步来了我们需要基于当前的隐藏状态si来预测当前时间步应该输出哪个词。这通过一个“输出层”通常是一个全连接层Softmax来完成。该层将si映射到整个输出词表大小的向量上并通过Softmax函数计算每个词的概率分布P(yi | y1, ..., y_{i-1}, X) Softmax(W * si b)。我们选择概率最大的词作为当前时间步的输出yi。这个过程循环进行直到解码器输出一个特殊的结束符号eosend of sequence或者达到预设的最大生成长度。2.3 编码器与解码器的信息流瓶颈从上面的描述可以看出在基础Seq2Seq中编码器和解码器之间唯一的信息传递纽带就是那个初始的上下文向量C。所有的输入序列信息无论多长多复杂都必须被压缩进这一个固定维度的向量中。解码器在生成每一个词时所能依赖的关于输入序列的全部信息都来源于此。这就造成了几个明显的问题信息压缩损失长序列的丰富信息无法无损存入固定大小的向量。对齐困难解码器在生成输出序列的不同部分时可能需要对输入序列的不同部分给予不同程度的关注。但基础模型没有这种能力。梯度消失对于很长的序列误差梯度在从解码器末端反向传播回编码器前端时可能会变得非常微弱导致模型难以训练。正是这些瓶颈催生了注意力机制的诞生它通过建立一条解码器到编码器所有隐藏状态的“直达通道”完美地解决了上述问题。3. 注意力机制让解码器学会“动态聚焦”注意力机制是Seq2Seq模型的“点睛之笔”。它的核心思想非常直观在解码器生成每一个词的时候允许它直接访问编码器所有时间步的隐藏状态并计算一个“注意力权重”这个权重决定了解码器当前应该对输入序列的每个部分投入多少“注意力”。3.1 注意力权重的计算过程我们以解码器第i个时间步为例详细拆解注意力权重的计算。假设编码器提供了隐藏状态序列H (h1, h2, ..., hT)解码器当前时间步的隐藏状态是si。第一步计算对齐分数首先我们需要评估解码器当前状态si与编码器每一个隐藏状态hj的相关性。这个相关性分数称为对齐分数。有多种计算方式常见的有点积注意力score(si, hj) si^T * hj。最简单高效要求si和hj维度相同。加性注意力score(si, hj) v^T * tanh(W1*si W2*hj)。通过一个小的前馈网络计算更灵活但参数更多。缩放点积注意力score(si, hj) (si^T * hj) / sqrt(d_k)其中d_k是向量的维度。这是Transformer中使用的目的是防止点积结果过大导致Softmax梯度太小。第二步归一化为注意力权重得到所有T个对齐分数后我们通过Softmax函数将它们归一化得到权重分布α_{i,j}α_{i,j} exp(score(si, hj)) / sum_{k1}^{T}(exp(score(si, hk)))这里α_{i,j}是一个介于0和1之间的数且所有j上的α_{i,j}之和为1。它精确地表示了在生成第i个输出词时模型对输入第j个词的关注程度。第三步计算上下文向量现在我们用这些权重对编码器的隐藏状态进行加权求和得到一个新的、针对当前解码步的“上下文向量”c_ic_i sum_{j1}^{T} (α_{i,j} * hj)这个c_i不再是编码器末尾那个固定的C而是一个动态的、聚焦于当前生成任务最相关输入部分的摘要。第四步结合上下文进行预测最后我们将解码器自己的隐藏状态si和这个动态生成的上下文向量c_i结合起来通常是拼接在一起然后送入输出层进行最终的词汇预测output_input concat(si, c_i)P(yi) Softmax(W * output_input b)3.2 注意力机制带来的革命性改变引入注意力后模型的行为发生了根本变化解决信息瓶颈解码器在每一步都能直接看到完整的输入序列信息无需再依赖一个压缩的向量。实现软对齐模型自动学会了输出序列和输入序列之间的对齐关系。在机器翻译中你可以清晰地看到当解码器生成某个目标语言词时其注意力权重会高亮源语言句子中对应的词或短语。这提供了一种宝贵的模型可解释性。改善梯度流由于解码器的每一步预测都直接与编码器的许多状态相连梯度可以通过多条路径反向传播缓解了梯度消失问题使模型更容易训练。在PyTorch中实现一个通用的注意力模块并不复杂。下面是一个加性注意力模块的简化示例代码它可以直接集成到你的Seq2Seq解码器中import torch import torch.nn as nn import torch.nn.functional as F class BahdanauAttention(nn.Module): def __init__(self, hidden_size): super(BahdanauAttention, self).__init__() # 用于将解码器隐藏状态和编码器隐藏状态映射到同一空间并相加 self.W1 nn.Linear(hidden_size, hidden_size) self.W2 nn.Linear(hidden_size, hidden_size) self.v nn.Linear(hidden_size, 1) def forward(self, decoder_hidden, encoder_outputs): decoder_hidden: [1, batch_size, hidden_size] (解码器当前步的隐藏状态) encoder_outputs: [src_len, batch_size, hidden_size] (编码器所有输出) src_len encoder_outputs.shape[0] # 重复解码器状态以便与每个编码器输出计算分数 repeated_decoder_hidden decoder_hidden.repeat(src_len, 1, 1) # [src_len, batch_size, hidden_size] # 计算加性注意力分数 # tanh(W1*h_enc W2*h_dec) energy torch.tanh(self.W1(encoder_outputs) self.W2(repeated_decoder_hidden)) # [src_len, batch_size, hidden_size] energy self.v(energy).squeeze(2) # [src_len, batch_size] # 计算注意力权重 (batch_size, src_len) attention_weights F.softmax(energy, dim0).permute(1, 0) # 在序列维度做softmax然后转置 # 计算上下文向量 # encoder_outputs 需要转置为 [batch_size, src_len, hidden_size] 以便bmm encoder_outputs encoder_outputs.permute(1, 0, 2) context torch.bmm(attention_weights.unsqueeze(1), encoder_outputs) # [batch_size, 1, hidden_size] context context.squeeze(1) # [batch_size, hidden_size] return context, attention_weights这个模块可以在解码器的每一步被调用传入当前解码器隐藏状态和编码器所有输出它返回动态的上下文向量和注意力权重。4. 使用PyTorch和LSTM构建一个完整的Seq2Seq模型理论讲清楚了我们动手实现一个用于数字序列反转任务的简易Seq2Seq模型。这个任务虽然简单但包含了所有核心要素变长序列处理、编码器-解码器结构、教师强制训练以及贪婪解码推理。4.1 任务定义与数据准备我们的任务是学习将一个随机长度的数字序列反转。例如输入[1, 2, 3, 4, 5]模型应输出[5, 4, 3, 2, 1]。我们定义数字0-9为词汇并使用sos和eos标记。import torch import torch.nn as nn import torch.optim as optim import numpy as np from torch.utils.data import Dataset, DataLoader import random # 定义常量 VOCAB_SIZE 12 # 0-9 加上 sos 和 eos SOS_TOKEN 10 EOS_TOKEN 11 MAX_LENGTH 10 HIDDEN_SIZE 128 # 生成随机序列对的数据集 class ReverseDataset(Dataset): def __init__(self, num_samples10000): self.data [] for _ in range(num_samples): length random.randint(1, MAX_LENGTH) # 输入序列: 随机数字 0-9 inp [random.randint(0, 9) for _ in range(length)] # 目标序列: 反转输入并加上EOS target inp[::-1] [EOS_TOKEN] # 输入序列也加上EOS可选这里不加仅作为结束标志 self.data.append((inp, target)) def __len__(self): return len(self.data) def __getitem__(self, idx): inp, target self.data[idx] return torch.tensor(inp, dtypetorch.long), torch.tensor(target, dtypetorch.long) # 创建数据加载器需要自定义collate_fn处理变长序列 def collate_fn(batch): # batch是list of (inp, target) tuples inputs, targets zip(*batch) # 对输入序列进行填充并打包 inputs_padded nn.utils.rnn.pad_sequence(inputs, batch_firstTrue, padding_value0) targets_padded nn.utils.rnn.pad_sequence(targets, batch_firstTrue, padding_value0) return inputs_padded, targets_padded train_dataset ReverseDataset(5000) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, collate_fncollate_fn)4.2 构建编码器编码器使用单向LSTM处理填充后的序列并返回最终的隐藏状态和所有输出用于注意力。class EncoderLSTM(nn.Module): def __init__(self, input_size, hidden_size): super(EncoderLSTM, self).__init__() self.hidden_size hidden_size self.embedding nn.Embedding(input_size, hidden_size) self.lstm nn.LSTM(hidden_size, hidden_size, batch_firstTrue) def forward(self, input_seq): # input_seq: [batch_size, seq_len] embedded self.embedding(input_seq) # [batch_size, seq_len, hidden_size] # 使用pack_padded_sequence处理变长序列提高效率 lengths (input_seq ! 0).sum(dim1).cpu() # 计算非填充长度 packed_embedded nn.utils.rnn.pack_padded_sequence(embedded, lengths, batch_firstTrue, enforce_sortedFalse) packed_outputs, (hidden, cell) self.lstm(packed_embedded) # 解包输出用于注意力 outputs, _ nn.utils.rnn.pad_packed_sequence(packed_outputs, batch_firstTrue) # hidden/cell: [1, batch_size, hidden_size] return outputs, hidden, cell4.3 构建带注意力的解码器解码器每一步都使用上一步的隐藏状态和输出结合注意力机制计算出的上下文向量来预测下一个词。class AttnDecoderLSTM(nn.Module): def __init__(self, output_size, hidden_size): super(AttnDecoderLSTM, self).__init__() self.hidden_size hidden_size self.output_size output_size self.embedding nn.Embedding(output_size, hidden_size) self.lstm nn.LSTM(hidden_size * 2, hidden_size, batch_firstTrue) # 输入是[嵌入, 上下文] self.attention BahdanauAttention(hidden_size) # 使用前面定义的注意力模块 self.fc_out nn.Linear(hidden_size * 2, output_size) # 输出层输入是[LSTM输出, 上下文] def forward(self, decoder_input, prev_hidden, prev_cell, encoder_outputs): decoder_input: [batch_size, 1] (上一个时间步的输出或sos) prev_hidden, prev_cell: [1, batch_size, hidden_size] encoder_outputs: [batch_size, src_len, hidden_size] # 1. 嵌入输入 embedded self.embedding(decoder_input) # [batch_size, 1, hidden_size] # 2. 计算注意力上下文向量 # prev_hidden 是 [1, batch, hidden]需要squeeze掉第一维给注意力模块 context, attn_weights self.attention(prev_hidden.squeeze(0), encoder_outputs.permute(1, 0, 2)) context context.unsqueeze(1) # [batch_size, 1, hidden_size] # 3. 将嵌入和上下文拼接作为LSTM输入 lstm_input torch.cat((embedded, context), dim2) # [batch_size, 1, hidden_size*2] # 4. 通过LSTM单元 output, (hidden, cell) self.lstm(lstm_input, (prev_hidden, prev_cell)) # output: [batch_size, 1, hidden_size] # 5. 将LSTM输出和上下文再次拼接通过全连接层预测 output output.squeeze(1) # [batch_size, hidden_size] context context.squeeze(1) # [batch_size, hidden_size] fc_input torch.cat((output, context), dim1) # [batch_size, hidden_size*2] prediction self.fc_out(fc_input) # [batch_size, output_size] return prediction, hidden, cell, attn_weights4.4 训练循环与教师强制训练Seq2Seq模型的一个关键技巧是“教师强制”。在训练时解码器每一步的输入不是它上一步的预测结果这可能在初期错误百出导致训练不稳定而是使用真实目标序列中对应位置的上一个词作为输入。这极大地加速了模型的收敛。encoder EncoderLSTM(VOCAB_SIZE, HIDDEN_SIZE) decoder AttnDecoderLSTM(VOCAB_SIZE, HIDDEN_SIZE) encoder_optimizer optim.Adam(encoder.parameters(), lr0.001) decoder_optimizer optim.Adam(decoder.parameters(), lr0.001) criterion nn.CrossEntropyLoss(ignore_index0) # 忽略填充索引0的损失 def train_step(input_tensor, target_tensor): encoder_optimizer.zero_grad() decoder_optimizer.zero_grad() batch_size input_tensor.size(0) target_len target_tensor.size(1) vocab_size decoder.output_size loss 0 # 1. 编码器前向传播 encoder_outputs, encoder_hidden, encoder_cell encoder(input_tensor) # encoder_outputs: [batch, src_len, hidden] # 2. 解码器初始化 decoder_input torch.tensor([[SOS_TOKEN]] * batch_size, deviceinput_tensor.device) # [batch, 1] decoder_hidden encoder_hidden decoder_cell encoder_cell # 3. 教师强制训练使用真实目标词作为下一步的输入 use_teacher_forcing True if random.random() 0.5 else False # 随机使用教师强制增加鲁棒性 if use_teacher_forcing: for di in range(target_len): decoder_output, decoder_hidden, decoder_cell, _ decoder( decoder_input, decoder_hidden, decoder_cell, encoder_outputs ) loss criterion(decoder_output, target_tensor[:, di]) decoder_input target_tensor[:, di].unsqueeze(1) # 下一个输入是真实目标词 else: for di in range(target_len): decoder_output, decoder_hidden, decoder_cell, _ decoder( decoder_input, decoder_hidden, decoder_cell, encoder_outputs ) topv, topi decoder_output.topk(1) # 获取预测值最大的词 decoder_input topi.detach() # 下一个输入是预测的词detach切断梯度 loss criterion(decoder_output, target_tensor[:, di]) if decoder_input.item() EOS_TOKEN: break loss.backward() encoder_optimizer.step() decoder_optimizer.step() return loss.item() / target_len # 训练循环 num_epochs 20 for epoch in range(num_epochs): total_loss 0 for batch_idx, (input_batch, target_batch) in enumerate(train_loader): loss train_step(input_batch, target_batch) total_loss loss print(fEpoch {epoch1}, Average Loss: {total_loss / len(train_loader):.4f})4.5 推理与贪婪解码训练完成后我们需要一个推理函数来使用模型。在推理时没有真实目标序列所以我们只能使用模型自己的预测作为下一步的输入这被称为“自回归生成”。最简单的策略是贪婪解码每一步都选择概率最高的词。def evaluate(input_sequence): encoder.eval() decoder.eval() with torch.no_grad(): input_tensor torch.tensor(input_sequence, dtypetorch.long).unsqueeze(0) # [1, src_len] encoder_outputs, encoder_hidden, encoder_cell encoder(input_tensor) decoder_input torch.tensor([[SOS_TOKEN]], deviceinput_tensor.device) decoder_hidden encoder_hidden decoder_cell encoder_cell decoded_words [] attention_weights_seq [] for di in range(MAX_LENGTH * 2): # 最大生成长度设为输入的两倍 decoder_output, decoder_hidden, decoder_cell, attn_weights decoder( decoder_input, decoder_hidden, decoder_cell, encoder_outputs ) attention_weights_seq.append(attn_weights.squeeze().cpu().numpy()) topv, topi decoder_output.topk(1) if topi.item() EOS_TOKEN: decoded_words.append(EOS) break else: decoded_words.append(topi.item()) decoder_input topi return decoded_words, np.array(attention_weights_seq) # 测试 test_input [1, 2, 3, 4, 5] output_seq, attn evaluate(test_input) print(fInput: {test_input}) print(fOutput: {output_seq}) # 理想输出应为 [5, 4, 3, 2, 1, EOS]通过这个完整的例子你可以清晰地看到数据如何流动编码器和解码器如何协作注意力权重如何计算和应用以及训练和推理的完整流程。虽然这是一个玩具任务但将其中的词汇表、嵌入维度、任务目标替换成真实的文本数据你就得到了一个可用于机器翻译或对话生成的Seq2Seq模型骨架。
返回列表