从‘Hello World’到Self-Attention:手把手用PyTorch实现一个迷你Transformer,拆解QKV矩阵计算
从‘Hello World’到Self-Attention手把手用PyTorch实现一个迷你Transformer拆解QKV矩阵计算在自然语言处理领域Transformer架构已经彻底改变了游戏规则。不同于传统的RNN或CNN模型Transformer完全依赖注意力机制来捕捉序列中的长距离依赖关系。而理解Transformer的核心就在于掌握其自注意力机制中Q(Query)、K(Key)、V(Value)这三个神秘矩阵的运作原理。本文将带你从零开始用PyTorch实现一个迷你Transformer通过实际代码演示和可视化深入理解QKV矩阵的计算过程及其在自注意力机制中的作用。我们以一个简单句子Hello, how are you?为例逐步拆解每个计算步骤让你不仅能看懂理论更能亲手实现并观察中间结果。1. 环境准备与基础概念在开始编码之前我们需要确保开发环境配置正确并回顾一些关键概念。本教程假设你已经具备基本的PyTorch使用经验并熟悉张量操作和神经网络的基本原理。首先安装必要的库pip install torch matplotlib numpy自注意力机制的核心思想是序列中的每个元素都可以关注序列中的其他元素并根据这种关注程度来加权组合信息。这与人类的阅读方式类似——当我们理解一个句子时会根据上下文动态调整对每个词的关注程度。QKV三个矩阵分别代表Q(Query): 表示当前词想要查询的信息K(Key): 表示其他词提供的可被查询的信息V(Value): 实际包含的内容信息这三个矩阵都是通过线性变换从输入序列得到的这使得模型可以学习如何最佳地提取和组合信息。2. 构建词嵌入与位置编码Transformer处理文本的第一步是将词语转换为向量表示。我们首先定义一个简单的词汇表和对应的嵌入维度。import torch import torch.nn as nn import math # 定义句子和词汇表 sentence Hello , how are you ? vocab {word: idx for idx, word in enumerate(set(sentence.split()))} vocab_size len(vocab) embedding_dim 16 # 为了演示使用较小的维度 # 构建嵌入层 embedding nn.Embedding(vocab_size, embedding_dim) # 将句子转换为索引序列 input_ids torch.tensor([vocab[word] for word in sentence.split()], dtypetorch.long) # 获取词嵌入 word_embeddings embedding(input_ids) # shape: (seq_len, embedding_dim)由于Transformer没有内置的顺序信息我们需要添加位置编码来注入序列的位置信息# 位置编码实现 def positional_encoding(seq_len, d_model): position torch.arange(seq_len).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model)) pe torch.zeros(seq_len, d_model) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) return pe # 添加位置编码 pos_enc positional_encoding(len(input_ids), embedding_dim) input_embeddings word_embeddings pos_enc现在我们得到了包含语义和位置信息的完整输入表示shape为(6, 16)对应6个词和16维的嵌入。3. 初始化QKV矩阵自注意力机制的核心是通过三个可学习的权重矩阵将输入映射到Q、K、V空间。让我们在PyTorch中实现这一过程# 定义线性变换层 d_k 8 # Q,K的维度 d_v 8 # V的维度 W_Q nn.Linear(embedding_dim, d_k) W_K nn.Linear(embedding_dim, d_k) W_V nn.Linear(embedding_dim, d_v) # 计算Q, K, V Q W_Q(input_embeddings) # shape: (seq_len, d_k) K W_K(input_embeddings) # shape: (seq_len, d_k) V W_V(input_embeddings) # shape: (seq_len, d_v)这三个矩阵虽然都来自相同的输入但经过不同的线性变换后各自承担不同的角色Q矩阵表示每个词想要查询什么信息K矩阵表示每个词可以提供什么信息V矩阵包含实际的内容信息这种分离的设计允许模型更灵活地学习不同方面的信息。4. 计算注意力分数与权重有了Q、K、V矩阵后我们可以计算注意力分数了。注意力分数表示一个词应该关注其他词的程度。# 计算注意力分数 attention_scores torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k) # shape: (seq_len, seq_len) # 应用softmax得到注意力权重 attention_weights torch.softmax(attention_scores, dim-1)让我们可视化这个6x6的注意力权重矩阵看看Hello这个词关注了句子的哪些部分import matplotlib.pyplot as plt plt.imshow(attention_weights.detach().numpy(), cmapviridis) plt.colorbar() plt.xlabel(Key Positions) plt.ylabel(Query Positions) plt.title(Attention Weights) plt.xticks(range(len(sentence.split())), sentence.split()) plt.yticks(range(len(sentence.split())), sentence.split()) plt.show()在训练初期这个矩阵可能看起来比较均匀但随着模型学习它会逐渐形成有意义的模式——某些词对之间的注意力权重会变得更高。5. 计算加权输出与多头注意力最后一步是使用注意力权重对V矩阵进行加权求和# 计算加权输出 output torch.matmul(attention_weights, V) # shape: (seq_len, d_v)这就是自注意力机制的基本计算流程。但在实际Transformer中我们通常使用多头注意力让模型可以同时关注不同方面的信息# 实现多头注意力 class MultiHeadAttention(nn.Module): def __init__(self, embed_dim, num_heads): super().__init__() self.embed_dim embed_dim self.num_heads num_heads self.head_dim embed_dim // num_heads self.W_Q nn.Linear(embed_dim, embed_dim) self.W_K nn.Linear(embed_dim, embed_dim) self.W_V nn.Linear(embed_dim, embed_dim) self.W_O nn.Linear(embed_dim, embed_dim) def forward(self, x): batch_size, seq_len, _ x.shape # 线性变换并分头 Q self.W_Q(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) K self.W_K(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) V self.W_V(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) # 计算注意力分数 scores torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.head_dim) weights torch.softmax(scores, dim-1) # 计算加权输出 output torch.matmul(weights, V) output output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.embed_dim) # 最终线性变换 return self.W_O(output)多头注意力的优势在于允许模型在不同位置共同关注来自不同表示子空间的信息提供更丰富的表示能力类似于CNN中的多通道概念6. 完整迷你Transformer实现现在我们将所有部分组合成一个完整的迷你Transformer模块class MiniTransformer(nn.Module): def __init__(self, vocab_size, embed_dim, num_heads): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) self.pos_encoder PositionalEncoding(embed_dim) self.attention MultiHeadAttention(embed_dim, num_heads) self.fc nn.Linear(embed_dim, vocab_size) def forward(self, x): x self.embedding(x) x self.pos_encoder(x) x self.attention(x) return self.fc(x) # 位置编码模块 class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) pe pe.unsqueeze(0) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:, :x.size(1)]这个迷你Transformer虽然简单但包含了完整Transformer的核心组件。你可以用它来进行简单的序列建模任务或者作为理解更复杂模型的基础。7. QKV矩阵的深入理解通过前面的实现我们已经看到了QKV矩阵的计算过程但为什么要设计这三个独立的矩阵呢让我们从几个角度深入理解角色分离带来灵活性Q负责我想要什么K负责我能提供什么V是我实际有什么这种分离允许模型更灵活地学习不同方面的关系。打破对称性如果只用单个矩阵注意力分数将是对称的这会限制模型的表达能力。独立的Q和K矩阵打破了这种对称性。信息流动控制QK点积决定信息流动的方向和强度V提供实际流动的内容这种分离类似于数据库查询中的索引和数据的分离。让我们通过一个具体例子来说明。考虑句子The animal didnt cross the street because it was too tired当处理it时模型需要决定它指代的是animal还是street通过QKV机制模型可以学习到it的Q应该与动物相关的K有高相似度从animal的V中获取相关信息这种指代关系的解析完全是通过学习QKV的变换实现的在实际项目中调试Transformer模型时检查QKV矩阵的变化常常能提供有价值的洞察。例如你可以可视化不同层的注意力模式分析特定词对的QK相似度跟踪V矩阵的内容变化这些技术可以帮助你理解模型是如何处理和组合信息的对于调试和改进模型非常有帮助。