行业资讯
从Seq2Seq到Transformer:注意力机制原理与PyTorch实现详解
1. 从“注意力”到“注意力机制”一个概念的进化史如果你在深度学习圈子里待过一阵子肯定无数次听过“Attention”这个词。它就像是一个无处不在的“明星”从机器翻译到图像识别从语音合成到推荐系统几乎每个前沿的模型架构里都能看到它的身影。但说实话我第一次接触这个概念时感觉它被说得神乎其神各种论文和教程里充斥着“Query, Key, Value”、“自注意力”、“多头注意力”这些术语看完了好像懂了但一合上文章脑子里只剩下一团模糊的“加权求和”。直到后来我亲手用代码实现了一个最简单的注意力层并在一个具体任务上看到了它带来的性能飞跃那种“原来如此”的感觉才真正到来。今天我们不堆砌公式也不罗列论文就从一个最朴素的人类认知行为出发来拆解这个“大名鼎鼎的注意力”그 이름도 유명한 어텐션。想象一下你正在一个嘈杂的咖啡馆里和朋友聊天。背景里有咖啡机的蒸汽声、其他人的谈话声、音乐声。但你神奇地能够把“注意力”聚焦在你朋友的语音上自动过滤掉其他噪音。这个过程本质上就是你的大脑在对所有输入信息听觉信号进行“加权”给朋友声音的权重接近1给背景噪音的权重接近0。深度学习中的注意力机制最初就是想用数学来模拟这种能力。所以这篇文章的目标很明确让你看完之后不仅能说出注意力是什么更能直观地理解它为什么有效以及如何在代码层面亲手搭建它。我们会从最经典的Seq2Seq模型中的瓶颈说起看到注意力机制如何像“救星”一样出现然后深入到Transformer的核心——自注意力机制最后我们会一起动手用PyTorch实现一个完整的、可运行的注意力层并观察它在一个小任务上的表现。无论你是刚刚入门深度学习的新手还是想巩固基础的中级开发者我相信这个从原理到实操的旅程都会对你有所启发。2. 注意力机制的诞生解决Seq2Seq模型的“记忆瓶颈”要理解注意力为什么被发明出来我们必须回到它诞生之前的时代。在自然语言处理NLP领域循环神经网络RNN及其变体LSTM、GRU曾长期是处理序列数据如文本、语音的主流模型。其中Seq2SeqEncoder-Decoder架构在机器翻译等任务上取得了巨大成功。2.1 Seq2Seq模型的经典结构与固有缺陷经典的Seq2Seq模型工作流程非常直观编码器Encoder一个RNN如LSTM逐词读入源语言句子例如“I love deep learning”并将每个时间步的输入信息逐步编码进一个固定维度的上下文向量Context Vector中。你可以把这个向量想象成整个输入句子的“摘要”或“记忆”。解码器Decoder另一个RNN接收这个固定的上下文向量作为初始状态然后开始逐词生成目标语言句子例如“我热爱深度学习”。这个架构简洁有力但它存在一个致命的缺陷我称之为“记忆瓶颈”。问题在于那个固定长度的上下文向量。无论输入句子是“Hello”这样的短句还是包含几十个词的长篇大论编码器都必须把它们压缩进一个维度固定的向量里。这导致两个严重问题信息丢失对于长句子模型很难把所有的细节信息都无损地塞进一个固定大小的向量里。就像让你用一句话总结一本长篇小说你肯定会丢失大量情节和人物细节。对齐困难在翻译时目标语的某个词往往只与源语句子的某几个词高度相关。例如翻译“深度学习”时模型应该更关注源句中的“deep learning”而不是“I”或“love”。但固定的上下文向量平等地混合了所有源语词的信息解码器在生成每个目标词时无法动态地“聚焦”到源句最相关的部分。在实际训练中这直接表现为模型在处理长句子时性能急剧下降。我早期在尝试用 vanilla Seq2Seq 做文本摘要时就深受其害。生成的摘要对于短新闻尚可一旦文章稍长就经常出现事实错误、遗漏关键信息或者生成重复、无意义的片段。2.2 注意力机制的核心思想动态上下文注意力机制的提出完美地解决了这个瓶颈。它的核心思想可以概括为一句话在解码器生成每一个目标词的时候允许它回头去“看”一遍编码器在所有时间步的完整输出序列并动态地决定当前应该更“关注”源序列的哪些部分。这彻底改变了游戏规则。上下文不再是编码器末尾那个单一的、静态的向量而是变成了一个动态生成的向量。对于解码器要生成的每一个目标词我们都会计算一个新的上下文向量。这个动态生成的过程就是注意力机制的精髓它主要包含三个步骤对应三个核心概念Query, Key, Value。Value可以简单理解为编码器在每个时间步的隐藏状态。它是信息的“原始仓库”。对于源句子“I love deep learning”编码器会产生四个隐藏状态分别对应四个词。Key通常与Value相同在基础注意力中或者由Value经过一个线性变换得到。你可以把它理解为每个Value的“索引标签”或“特征描述”用于和Query进行匹配。Query来自解码器当前时间步的隐藏状态。它代表了“解码器当前想知道什么”。比如当解码器准备生成“深度学习”这个词时它的Query就蕴含着“我需要寻找与复杂技术概念相关的源语信息”这样的意图。注意力机制的工作就是让当前的Query去和所有的Key进行“匹配度”计算得到一个权重分布注意力分数然后用这个权重对所有的Value进行加权求和最终得到当前时刻的动态上下文向量。注意这里有一个非常重要的理解点。注意力权重不是模型额外学习的参数而是根据当前的Query和Keys实时计算出来的。这使得模型具有了前所未有的灵活性。2.3 一个生活化的类比图书馆查资料让我们用一个更生活的例子来巩固这个抽象概念。假设你要写一篇关于“注意力机制”的报告解码器生成目标句。传统的Seq2Seq相当于你在动笔前让一位助手编码器通读图书馆里所有相关的书籍和论文然后让他用一句话固定上下文向量告诉你所有内容的概要。你只能基于这一句话来写整篇报告。结果可想而知细节全无还可能跑偏。带注意力机制的Seq2Seq现在你坐在书桌前准备写报告的某一章比如“发展历史”。你向助手注意力机制提出一个问题Query“我需要关于注意力机制起源和早期论文的资料”。助手会拿着这个问题去翻阅图书馆里所有书籍的目录Keys找出哪些书的目录与“起源”、“早期”最相关并给出一个相关度评分注意力分数。然后他根据这个评分从对应的书籍Values中抽取相关的段落汇总成一份针对你当前问题的详细资料动态上下文向量递给你。当你写下一章“数学原理”时你会提出一个新的Query助手又会去生成一份全新的资料。这个过程中助手并没有提前准备好一份固定的资料而是根据你写作的实时需求动态地从海量资料库中检索和整合信息。这就是注意力机制带来的根本性改变。3. 注意力机制的数学实现从分数到权重理解了核心思想我们来看看这个“动态关注”的过程是如何用数学实现的。这是将直觉转化为代码的关键一步。整个过程可以分解为四个清晰的步骤。3.1 第一步计算注意力分数Score这是匹配度的量化。给定解码器当前时刻的查询向量query(维度为d_k) 和编码器所有时刻的键向量keys(一个矩阵每一行是一个key维度也是d_k)我们需要计算一个匹配分数。最常用的计算方式有三种点积注意力score query · key^T。这是最简单高效的方式要求query和key的维度必须相同。其几何意义是计算两个向量的余弦相似度在向量模长经过规范化处理后。缩放点积注意力score (query · key^T) / sqrt(d_k)。这是Transformer论文中采用的方法。引入缩放因子sqrt(d_k)是为了防止当d_k较大时点积的结果绝对值过大导致经过Softmax后的梯度变得非常小梯度消失问题。加性注意力score v^T * tanh(W_q * query W_k * key)。早期论文中常用它引入了一个小的神经网络来计算分数更灵活但计算量更大。在实际中缩放点积注意力因其高效和良好的效果成为了目前最主流的选择。我们后续的实现也将基于此。假设编码器有n个时间步源句长度为n那么我们会得到n个分数形成一个分数向量scores其长度为n。3.2 第二步归一化得到注意力权重Weights第一步得到的分数可能有正有负且绝对值范围不确定。我们需要将它们转化为一个概率分布即所有权重之和为1且每个权重在0到1之间代表“关注”的程度。这里使用的就是经典的Softmax函数。对分数向量scores应用Softmaxweights softmax(scores)经过Softmax之后weights就变成了一个概率分布向量。分数最高的那个key对应的权重将接近1分数很低的key对应的权重将接近0。这就模拟了“选择性关注”。3.3 第三步加权求和生成上下文向量Context Vector现在我们有了权重weights形状为[n]以及编码器所有时间步的值向量values形状为[n, d_v]d_v是值向量的维度通常与编码器隐藏层维度相同。上下文向量context就是所有value的加权和context sum(weights[i] * values[i]) for i in range(n)这个context向量的维度是d_v。它不再是所有信息的简单混合而是根据当前查询query的需求从所有信息中提取出的“精华摘要”。3.4 第四步将上下文向量输入解码器最后这个动态生成的context向量会被送到解码器当前时间步。通常我们会将它和解码器当前的隐藏状态拼接concatenate在一起然后通过一个全连接层来预测当前时刻要输出的词的概率分布。为什么这样做有效因为解码器在预测“深度学习”时它拿到的context向量里已经富含了源句中“deep”和“learning”的信息而“I”和“love”的信息被极大地弱化了。这极大地改善了长距离依赖的建模和对齐问题。下表总结了注意力计算的全过程步骤输入操作输出说明1. 计算分数query(1 x d_k),keys(n x d_k)点积后缩放scores(1 x n)衡量query与每个key的相关性2. 归一化权重scores(1 x n)Softmaxweights(1 x n)将分数转化为概率分布和为13. 加权求和weights(1 x n),values(n x d_v)加权求和context(1 x d_v)生成聚焦于相关信息的动态上下文4. 解码预测context, 解码器隐藏态拼接、线性层、Softmax目标词概率分布基于动态上下文进行预测4. 从“注意力”到“自注意力”Transformer的基石注意力机制最初是作为Seq2Seq模型的增强组件出现的。但很快研究者们发现这种“查询-键-值”的架构本身就是一个强大的序列建模工具完全可以不依赖RNN。这就是“自注意力”的由来也是革命性的Transformer模型的核心。4.1 什么是自注意力在原始的Encoder-Decoder注意力中Query来自解码器Key和Value来自编码器这是跨序列的注意力。而自注意力顾名思义是序列内部的注意力。在一个句子中每个词都会生成自己的Query, Key, Value向量。然后每个词用自己的Query去和句子中所有词包括自己的Key计算注意力分数最后加权聚合所有词的Value信息得到该词新的表示。这带来了一个巨大的优势它能够直接捕获序列中任意两个位置之间的依赖关系无论它们相距多远。在RNN中信息需要一步一步顺序传递距离远的词之间要建立联系需要经过很多步容易导致信息衰减或梯度消失/爆炸。而自注意力通过一次计算就让每个词都“看到”了全局信息。4.2 多头注意力并行化的多视角理解单一的注意力机制可以看作是从一个特定的“视角”或“子空间”去理解序列关系。为了提升模型的容量Transformer引入了“多头注意力”。其思想很简单与其只做一次注意力计算不如把Query, Key, Value向量通过不同的线性投影矩阵映射到多个例如8个不同的子空间即多个“头”。在每个头里独立地进行前面所述的点积注意力计算。最后把所有头的输出拼接起来再经过一次线性变换得到最终的多头注意力输出。这样做的直观解释是不同的头可以学习关注不同类型的关系。例如在翻译句子“The animal didnt cross the street because it was too tired”时一个头可能专门学习将“it”与“animal”对齐指代关系另一个头可能学习“tired”与“animal”的修饰关系。多头机制让模型能够同时从多个维度理解上下文。4.3 自注意力在Transformer中的角色替代RNN在Transformer的编码器和解码器中自注意力层完全取代了RNN用于捕获序列内部的依赖关系。编码器自注意力让输入序列的每个词都能关注到序列中的所有词从而获得富含全局上下文的词表示。解码器自注意力为了让解码过程是自回归的只能看到已生成的部分解码器的自注意力层加入了掩码确保在生成第t个词时只能关注到第1到t-1个词而不能“偷看”未来的词。编码器-解码器注意力这就是我们最初讨论的跨序列注意力Query来自解码器Key和Value来自编码器的输出。它连接了源语言和目标语言的信息。通过堆叠多层这样的注意力层和前馈神经网络Transformer构建了一个极其强大的序列到序列模型并在并行计算效率上远超RNN因为它消除了序列的时序依赖所有位置的注意力计算可以同时进行。5. 动手实现用PyTorch构建一个可运行的注意力层理论说了这么多是时候动手了。我将带你用PyTorch实现一个完整的缩放点积注意力模块并把它嵌入一个极简的Seq2Seq模型中在一个小任务上验证其效果。我们将使用一个简单的“日期格式转换”任务作为例子。5.1 任务定义与数据准备我们的任务是将“DD-MM-YYYY”格式的日期如“25-12-2023”转换为“YYYY年MM月DD日”的中文格式如“2023年12月25日”。这个任务虽然简单但包含了序列对齐年、月、日位置的对应和格式转换非常适合演示注意力的作用。首先我们创建一些模拟数据import torch import torch.nn as nn import torch.optim as optim import numpy as np from torch.utils.data import Dataset, DataLoader # 定义字符集 input_characters [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -] output_characters [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 年, 月, 日] # 创建编码解码映射 input_char2idx {ch: i for i, ch in enumerate(input_characters)} input_idx2char {i: ch for i, ch in enumerate(input_characters)} output_char2idx {ch: i for i, ch in enumerate(output_characters)} output_idx2char {i: ch for i, ch in enumerate(output_characters)} # 生成模拟数据函数 def generate_date_pair(): day np.random.randint(1, 32) month np.random.randint(1, 13) year np.random.randint(2000, 2031) input_str f{day:02d}-{month:02d}-{year} output_str f{year}年{month:02d}月{day:02d}日 return input_str, output_str # 自定义数据集 class DateDataset(Dataset): def __init__(self, num_samples5000): self.num_samples num_samples self.data [generate_date_pair() for _ in range(num_samples)] def __len__(self): return self.num_samples def __getitem__(self, idx): input_str, output_str self.data[idx] # 将字符串转换为索引序列 input_seq [input_char2idx[ch] for ch in input_str] output_seq [output_char2idx[ch] for ch in output_str] # 添加序列开始符SOS和结束符EOS output_seq [output_char2idx[SOS]] output_seq [output_char2idx[EOS]] return torch.tensor(input_seq, dtypetorch.long), torch.tensor(output_seq, dtypetorch.long) # 注意我们需要在output_characters中添加SOS和EOS output_characters [SOS, EOS] output_characters # 重新建立映射... output_char2idx {ch: i for i, ch in enumerate(output_characters)} output_idx2char {i: ch for i, ch in enumerate(output_characters)}5.2 实现缩放点积注意力模块现在我们来编写核心的注意力模块。这个模块将接收一组Keys、Values和一个Query并输出加权后的上下文向量和注意力权重可用于可视化。class ScaledDotProductAttention(nn.Module): 实现缩放点积注意力。 输入: query: [batch_size, 1, d_k] 或 [batch_size, seq_len_q, d_k] keys: [batch_size, seq_len_k, d_k] values: [batch_size, seq_len_v, d_v] (通常 seq_len_k seq_len_v) mask: 可选用于在解码时屏蔽未来信息 [batch_size, 1, seq_len_k] 输出: context: [batch_size, 1, d_v] 或 [batch_size, seq_len_q, d_v] attention_weights: [batch_size, 1, seq_len_k] 或 [batch_size, seq_len_q, seq_len_k] def __init__(self, dropout0.1): super(ScaledDotProductAttention, self).__init__() self.dropout nn.Dropout(dropout) def forward(self, query, keys, values, maskNone): d_k keys.size(-1) # 获取键向量的维度 # 1. 计算点积分数 # query: [B, L_q, D], keys: [B, L_k, D] - scores: [B, L_q, L_k] scores torch.matmul(query, keys.transpose(-2, -1)) # 2. 缩放 scores scores / torch.sqrt(torch.tensor(d_k, dtypetorch.float32)) # 3. 应用掩码如果提供 if mask is not None: # 将mask中为True的位置需要屏蔽置为一个非常大的负数这样经过softmax后权重接近0 scores scores.masked_fill(mask 0, -1e9) # 4. 通过Softmax得到注意力权重 attention_weights nn.functional.softmax(scores, dim-1) attention_weights self.dropout(attention_weights) # 可选防止过拟合 # 5. 加权求和得到上下文向量 context torch.matmul(attention_weights, values) # [B, L_q, D_v] return context, attention_weights这个类非常简洁它只包含了前向传播的逻辑。mask参数在实现解码器的自注意力时至关重要它确保了模型在训练时不会“作弊”地看到未来的信息。5.3 构建带注意力的Seq2Seq模型接下来我们将这个注意力模块整合进一个完整的、但结构简化的Seq2Seq模型中。为了聚焦于注意力本身我们使用单层GRU作为编码器和解码器的基础RNN单元。class AttentionSeq2Seq(nn.Module): def __init__(self, input_vocab_size, output_vocab_size, hidden_size, embedding_dim): super(AttentionSeq2Seq, self).__init__() self.hidden_size hidden_size # 编码器 self.encoder_embedding nn.Embedding(input_vocab_size, embedding_dim) self.encoder_gru nn.GRU(embedding_dim, hidden_size, batch_firstTrue) # 解码器 self.decoder_embedding nn.Embedding(output_vocab_size, embedding_dim) self.decoder_gru nn.GRU(embedding_dim hidden_size, hidden_size, batch_firstTrue) # 输入是embedding和上下文向量的拼接 self.attention ScaledDotProductAttention() # 输出层将解码器GRU输出映射到词汇表概率 self.fc_out nn.Linear(hidden_size, output_vocab_size) def forward(self, src, trg, teacher_forcing_ratio0.5): # src: [batch_size, src_len] # trg: [batch_size, trg_len] batch_size src.size(0) trg_len trg.size(1) output_vocab_size self.fc_out.out_features # 初始化输出张量 outputs torch.zeros(batch_size, trg_len, output_vocab_size).to(src.device) # --- 编码阶段 --- embedded_src self.encoder_embedding(src) # [B, SRC_LEN, EMBED_DIM] encoder_outputs, hidden self.encoder_gru(embedded_src) # encoder_outputs: [B, SRC_LEN, HIDDEN] # 解码器的初始隐藏状态使用编码器的最后隐藏状态 decoder_hidden hidden # 解码器的第一个输入是SOS token decoder_input trg[:, 0].unsqueeze(1) # [B, 1] # --- 解码阶段自回归--- for t in range(1, trg_len): # 1. 解码器嵌入 embedded_dec self.decoder_embedding(decoder_input) # [B, 1, EMBED_DIM] # 2. 计算注意力 # query: 解码器上一时刻的隐藏状态 decoder_hidden (需要调整形状为 [B, 1, HIDDEN]) # keys values: 编码器的所有输出 encoder_outputs query decoder_hidden.transpose(0, 1) # GRU的hidden是 [1, B, HIDDEN], 转置为 [B, 1, HIDDEN] context, attention_weights self.attention(query, encoder_outputs, encoder_outputs) # 3. 将上下文向量与解码器嵌入拼接作为GRU的输入 gru_input torch.cat((embedded_dec, context), dim-1) # [B, 1, EMBED_DIM HIDDEN] # 4. 通过GRU单元 decoder_output, decoder_hidden self.decoder_gru(gru_input, decoder_hidden) # decoder_output: [B, 1, HIDDEN] # 5. 预测下一个词 output self.fc_out(decoder_output.squeeze(1)) # [B, VOCAB_SIZE] outputs[:, t, :] output # 6. 决定下一个输入Teacher Forcing teacher_force np.random.random() teacher_forcing_ratio top1 output.argmax(1) # 模型预测的下一个词索引 decoder_input trg[:, t].unsqueeze(1) if teacher_force else top1.unsqueeze(1) return outputs def inference(self, src, max_len15): 推理模式用于生成序列 with torch.no_grad(): batch_size src.size(0) # 编码 embedded_src self.encoder_embedding(src) encoder_outputs, hidden self.encoder_gru(embedded_src) # 准备解码 decoder_hidden hidden decoder_input torch.tensor([[output_char2idx[SOS]]] * batch_size).to(src.device) # [B, 1] generated [] for t in range(max_len): embedded_dec self.decoder_embedding(decoder_input) query decoder_hidden.transpose(0, 1) context, _ self.attention(query, encoder_outputs, encoder_outputs) gru_input torch.cat((embedded_dec, context), dim-1) decoder_output, decoder_hidden self.decoder_gru(gru_input, decoder_hidden) output self.fc_out(decoder_output.squeeze(1)) next_token output.argmax(1).item() if next_token output_char2idx[EOS]: break generated.append(output_idx2char[next_token]) decoder_input torch.tensor([[next_token]]).to(src.device) return .join(generated)这个模型的关键在于解码器的每一步获取解码器上一时刻的隐藏状态作为query。用query和编码器所有输出encoder_outputs作为keys和values计算注意力得到当前时刻的动态context向量。将context向量和解码器当前输入词的嵌入向量拼接一起送入GRU。用GRU的输出预测下一个词。5.4 训练模型与观察注意力权重现在让我们训练这个模型并观察注意力权重是如何工作的。# 超参数 HIDDEN_SIZE 128 EMBEDDING_DIM 64 BATCH_SIZE 32 NUM_EPOCHS 20 LEARNING_RATE 0.001 # 准备数据 dataset DateDataset(3000) # 3000个样本 train_loader DataLoader(dataset, batch_sizeBATCH_SIZE, shuffleTrue) # 初始化模型、损失函数和优化器 model AttentionSeq2Seq(len(input_characters), len(output_characters), HIDDEN_SIZE, EMBEDDING_DIM) criterion nn.CrossEntropyLoss(ignore_indexoutput_char2idx[PAD]) # 需要处理padding本例中未使用简化处理 optimizer optim.Adam(model.parameters(), lrLEARNING_RATE) # 训练循环 for epoch in range(NUM_EPOCHS): model.train() total_loss 0 for batch_idx, (src_batch, trg_batch) in enumerate(train_loader): optimizer.zero_grad() # trg_batch 包含 SOS 和 EOS output model(src_batch, trg_batch, teacher_forcing_ratio0.5) # [B, TRG_LEN, VOCAB] # 计算损失时忽略第一个token(SOS)因为它是输入 output output[:, 1:].reshape(-1, output.shape[-1]) # [B*(TRG_LEN-1), VOCAB] target trg_batch[:, 1:].reshape(-1) # [B*(TRG_LEN-1)] loss criterion(output, target) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1) # 梯度裁剪防止爆炸 optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) print(fEpoch [{epoch1}/{NUM_EPOCHS}], Loss: {avg_loss:.4f# 1. 两数之和 ## 题目 给定一个整数数组 nums 和一个整数目标值 target请你在该数组中找出 和为目标值 target 的那 两个 整数并返回它们的数组下标。 你可以假设每种输入只会对应一个答案。但是数组中同一个元素在答案里不能重复出现。 你可以按任意顺序返回答案。 ## 思路 * 使用哈希表 将数组中的元素和下标存储到哈希表中 * 遍历数组 对于每一个元素 计算target - nums[i] 判断这个值是否在哈希表中 * 如果在哈希表中 并且下标不是当前元素的下标 那么返回结果 ## 代码 cpp class Solution { public: vectorint twoSum(vectorint nums, int target) { unordered_mapint,int map; for(int i 0; i nums.size(); i) { // 将元素和下标存储到哈希表中 map.insert(pairint,int(nums[i],i)); } for(int i 0; i nums.size(); i) { auto iter map.find(target - nums[i]); if(iter ! map.end() iter-second ! i) { return {i,iter-second}; } } return {}; } };
郑州网站建设
网页设计
企业官网