从零实现transformer 1:word embedding, position embedding, encoder-self-attention-mask
是跟着这个视频学习的: Transformer模型Encoder原理精讲及其PyTorch逐行实现
Word Embedding
首先假设模型的输入是两句话,假设每个字母都是一个token
input_ = [['a','d','c','b'],
['a','c','d','z','y','x']]
这里用1代替a,2代替b…,26代替z,那么等价的情况是
input_ = [[1,4,3,2],
[1,3,4,26,25,24]]
我们要保证每句话的长度是相同的,因此要把每句话填充至最大长度,这里设置每句话的最大长度为10,之前用1代替第一个字母a,所以0可以用来作为padding符号。
input_1 = torch.Tensor([1,4,3,2]).to(torch.int32)
len1 = len(input_1)
max_len = 10
F.pad(input_1,(0,max_len-len1))
#tensor([1, 4, 3, 2, 0, 0, 0, 0, 0, 0], dtype=torch.int32)
于是input和output就会是这样的格式,每一个不同的数字代表一个不同的token,也就是实际模型输入中的一个单词符号或者别的什么
这里的input_和output_是二维的,batchsize是2,每一行代表一句话,每一行里的每一个元素代表一个token,现在我们要把每一个token用一个很多维度的向量来表示(word2vec),可以理解为一维升高到多维(?),因为用一个维度来表示一个独一无二的token实在是太困难了。用多个维度表示一个token,那么这个token在多维空间中的方向就可以(?)暗含token的语义信息。
embedding即将出场
假如我们要把一个总共26种token的输入进行embedding,并且每个token被映射成8维,那么只需要设置
embedding_token_dim = 8
em = nn.Embedding(27,embedding_token_dim)
input_ = em(input_)
此时input_里包含的必须是索引,这也是为什么之前把a~z映射成int型的1 ~ 26,并且用0作为padding的value。 这样nn.Embedding的参数设置成27,刚好对应上0 ~ 26的索引值。如果此时input里包含1.5, 27, 101, -1等等输入,那么就会报错。此时em 会把0 ~26的每个int数字映射成8维向量.
embedding_token_dim = 8
em = nn.Embedding(27,embedding_token_dim)
print(input_[0])
print(em(input_[0]))
(后面的6个0被映射成了相同的向量)
word embedding就这样简单的完成了(至少形式上完成了)
Position Embedding
接下来就是位置的编码了,毕竟transformer所有的token都是平等没有接收顺序的,位置编码能告诉模型这些token的位置。
简单的说,word embedding将每一个token映射成了一个向量,那么位置编码就是将这个token所处的位置(也就是0,1,2,…,max_len-1中的一个数)编码成一个相同维度的向量,然后加在token的向量中去。
也就是embedding(‘a’) = word_embedding(‘a’) + pos_embedding(pos(‘a’))
其实也很相似
word_embedding是看token的种类有n个,然后将0 ~ n这n+1个数(padding也要有索引和映射),每个数映射成一个向量
pos_embedding是看输入的每句话的最大长度是m,然后将0~m-1这m个数,每个数映射成一个向量
transformer的初始版本里,pos_embedding是人为设置的固定的不可学习的。我们可以自行生成pos_embedding的权重然后使用。根据论文中的公式,pos_embedding是这样的
max_len = 10
embedding_token_dim = 8
pos_mat = torch.arange(max_len).reshape([-1,1])
i_mat = torch.pow(10000, torch.arange(0,embedding_token_dim,2).reshape(1,-1)/embedding_token_dim)
pos_embedding_w = torch.zeros(max_len, embedding_token_dim)
pos_embedding_w[:,0::2] = torch.sin(pos_mat/i_mat)
pos_embedding_w[:,1::2] = torch.cos(pos_mat/i_mat)
pos_embedding_w
上面第i行表示每句话第i个位置的token所要加上的pos_embedding的值,此时将它直接加在word_embedding上就可以了。因为word_embedding每一句话同样遵循相同的顺序:第i行表示第i个token的word_embedding的值。每一句话的word_embedding的大小形状都与这里的pos_embedding相同。并且由于广播机制,pos_embedding会自动与每一句话的word_embedding相加。
可以看到,第一句话的后面6个0由于位置编码的关系,变得不同了,也就是它们有了先后顺序的区别,模型之后便可以学习到这种先后关系。
当然我们也可以把pos_embedding_w当做参数创建一个nn.Embedding(),然后根据句子的长度实时生成对应的pos_embedding
pos_embedding = nn.Embedding(max_len, embedding_token_dim)
pos_embedding.weight = nn.Parameter(pos_embedding_w, requires_grad = False)
pos_embedding(torch.arange(len(input_[0])))
Encoder-self-attention-mask
self-attention mask
encoder的self-attention mask大概长这样
encoder_mask = (input_ > 0).int()
encoder_mask = encoder_mask.reshape(2,1,-1)
encoder_mask = ~torch.bmm(mask.transpose(1,2),mask).bool()
encoder_mask
这里是一个三维向量,外层的维度为2,表示这是input的两句话。上面的矩阵是第一句话,它的长度只有4个token,其它的部分都是用0来padding的。所以encoder_mask里为True的地方,实际上是不会提供任何信息的,因为本来就没有这些token。因此之后计算softmax的时候,可以把encoder_mask为True的地方设置成 -inf,经过softmax计算后就是0,不会干扰网络的正常训练。
为啥gpt3的encoder用三角掩码而transformer原文用矩形掩码
这里我产生过一个疑问,我之前看gpt3的视频,说编码器都是三角编码mask的,但是这里却用了矩形编码mask。问了下deepseek后才知道两种encoder编码方式都存在。gpt3是生成式模型,任务是文本生成,所以限制它看到未来信息(而且好像是因为gpt3对于输入数据的每一个词都会进行预测,此时使用矩形编码就会泄露未来信息。)
生成式任务和机器翻译任务还是存在区别。我问deepseek可不可以让gpt3用半句话作为训练输入,后半句话作为输出,貌似这也是一个可以研究的问题。
deepseek:
结论
你的设计思路完全合理,并且已经被多种先进模型采用。与纯GPT相比:
优势:对输入前缀的理解更深,适合需要强上下文的任务
代价:实现复杂度更高,推理效率略低
最终建议:
如果做领域特定生成(医疗/法律/代码),推荐尝试混合架构
如果做通用开放生成,纯GPT架构仍是更安全的选择
这种架构选择本质上是在理解深度和生成自由度之间寻找平衡点。
也就是说三角矩阵掩码和矩形矩阵掩码都只是一种选择,甚至还可以有更加不同的掩码方案,这是一个可以研究的问题,唯一的红线是不能在预测的时候看到泄露未来信息。gpt3选择了纯三角掩码,是一种严格自回归
严格自回归(纯单向)是生成任务的黄金标准,因其:
保证训练/推理一致性
避免信息泄漏风险
翻译任务则可能更加需要理解上下文语意,且输入是具有完整语意的句子,这也是transformer原论文使用矩形encoder掩码的原因。