以下模型为2017年google《Attention is all you need》的原始Transformer模型
输入嵌入层
由于神经网络模型不能直接处理文本,因此我们需要先将文本转换为数字,这个过程被称为编码 (Encoding),其包含两个步骤:
- 使用分词器 (tokenizer) 将文本按词、子词、字符切分为 tokens;
- 将所有的 token 映射到对应的 token ID。
一句话 ——> token(词、子词、字符) ——> 数字
分词
按词切分
比如按空格切分
这种策略的问题是会将文本中所有出现过的独立片段都作为不同的 token,从而产生巨大的词表。而实际上很多词是相关的,例如 “dog” 和 “dogs”、“run” 和 “running”,如果给它们赋予不同的编号就无法表示出这种关联性。
按字符切分
比如切成一个个字母
这种策略把文本切分为字符而不是词语,这样就只会产生一个非常小的词表,并且很少会出现词表外的 tokens。
但是从直觉上来看,字符本身并没有太大的意义,因此将文本切分为字符之后就会变得不容易理解。这也与语言有关,例如中文字符会比拉丁字符包含更多的信息,相对影响较小。此外,这种方式切分出的 tokens 会很多,例如一个由 10 个字符组成的单词就会输出 10 个 tokens,而实际上它们只是一个词。
按子词切分
广泛使用
高频词直接保留,低频词被切分为更有意义的子词。例如 “annoyingly” 是一个低频词,可以切分为 “annoying” 和 “ly”,这两个子词不仅出现频率更高,而且词义也得以保留。下图展示了对 “Let’s do tokenization!“ 按子词切分的结果:
可以看到,“tokenization” 被切分为了 “token” 和 “ization”,不仅保留了语义,而且只用两个 token 就表示了一个长词。这种策略只用一个较小的词表就可以覆盖绝大部分文本,基本不会产生 unknown token。尤其对于土耳其语等黏着语,几乎所有的复杂长词都可以通过串联多个子词构成。
tokenizer
完成从字符到数字的转变,内含attention_mask
、CLS
和SEP
的添加使用Word2Vec进行嵌入
何为EmbeddingtokenizationTransformer的后续层不会从0开始。嵌入提供了单词关联信息的单词嵌入。
Transformer的计算复杂度又是随着序列长度平方级上涨
Padding 操作
按批输入多段文本产生的一个直接问题就是:batch 中的文本有长有短,而输入张量必须是严格的二维矩形,维度为(batch size,sequence length),即每一段文本编码后的 token IDs 数量必须一样多
位置编码
在RNN模型中,句子是逐字送入学习网络的。所以蕴含了位置信息。
使用余弦和正弦函数为每个位置的单词生成不同的位置编码,加到嵌入矩阵之中。
Encoder
在Transformer原论文“Attention Is All You Need”中,作者使用了6个编码器叠加在一起。
每个编码器可以分为多头注意力层与前馈网络层
多头注意力层
自注意力机制
要理解自注意力机制,关键是要抓住“自”这个字(有点拗口),自注意力机制可以让模型理解句子中的词与词之间的关系,比如:A dog ate the food because it was hungry,句子中的代词it指的是dog还是food,自注意力机制可以让模型懂得指的是dog。
编码器可以计算出句子中每个单词的特征值,而自注意力机制可以通过词与词之间的关系来更好地理解当前词的意思。自注意力机制首先需要计算出单词A的特征值,其次计算dog的特征值,然后计算ate的特征值,以此类推。当计算每个词的特征值时,模型都需要遍历每个词与句子中其他词的关系。
自注意力机制核心就是三个矩阵的运算
三个矩阵是怎么来的?
三个矩阵:
用输入矩阵分别乘以矩阵,依次创建出查询矩阵,键矩阵,和值矩阵。
权重矩阵的初始值完全是随机的,但最优值则需要通过训练获得优值。
三个矩阵是如何运算的?
- 计算查询矩阵与键矩阵的点积,从而得到相似度分数。这有助于我们了解句子中每个词与所有其他词的相似度。
- 将除以键向量维度的平方根,以获得稳定的梯度。
- 使用softmax函数进行归一化
- 计算注意力矩阵Z,用乘以
多头注意力机制
多头注意力机制建立在自注意力机制的基础上,原本一句话对应一个注意力矩阵,现在将一句话分成多个注意力矩阵,并行计算多个小注意力矩阵,最后将所有的串联起来。
多头注意力机制的优点
- 排除循环,实现并行操作,减少训练时间
- 每个注意力机制学习同一输入序列的不同视角
掩码机制
首先要明白一点,transformer的输入是有最大长度限制的,不能任意长度,因为是有固定维度的。
当输入sequence小于最大长度限制时(实际上都是应该小于最大长度的),需要使用padding来将序列变成一个固定长度。用一个特殊的token“<ignore>”来填充。
padding的部分是没有意义的,需要让padding的部分不影响注意力矩阵的计算。
一个比较简单的做法是直接将padding部分对应的注意力矩阵()置为0.
# 定义一个函数,用于生成长度掩码 def get_len_mask(b: int, max_len: int, feat_lens: torch.Tensor, device: torch.device) -> torch.Tensor: # feat_lens中记录着序列的有效长度,一个一维序列,有b个值 # 初始化一个全为1的三维张量,形状为 (b, max_len, max_len),表示注意力掩码,放在指定的设备上 attn_mask = torch.ones((b, max_len, max_len), device=device) # 遍历每一个batch for i in range(b): # 将张量中每个序列长度部分对应的位置设为0,其余部分保持为1 attn_mask[i, :, :feat_lens[i]] = 0 # 将掩码转换为布尔类型,并返回 return attn_mask.to(torch.bool) m = get_len_mask(2, 4, torch.tensor([2, 4]), "cpu") # 为了打印方便,转为int m = m.int() # 输出 tensor([[[0, 0, 1, 1], [0, 0, 1, 1], [0, 0, 1, 1], [0, 0, 1, 1]], [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]], dtype=torch.int32)