跳转至

Transformer

直观认识


结束符 词语接龙解码器
why do we work? 翻译成 为什么我们要工作?
S => 为
S为 => 什
S为什 => 么
...
S 为什么我们要工作? => E

这个结构又和chatgpt有什么关系?


不再是S
为什么我们要工作? => 为
为什么我们要工作?为 => 了
为什么我们要工作?为了 => 实
....
为什么我们要工作?为了实现个人目标 => E
为了实现个人目标

这就是我们GPT的结构

前提知识

  • seq2seq
  • 注意力机制

seq2seq是解决什么问题的
机器翻译任务:
输入是一段英文,输出是一段中语,输入和输出皆不定长
eg. 英语 5:Why do we work ? 中文 8:我们为什么工作?
当输入输出序列都是不定长时,我们可以使用编码器 - 解码器(encoder-decoder)或者说 seq2seq。

注意力机制是解决什么问题的?
输入序列 "Why do we work " 和输出序列 "我们为什么工作?",
我们词的语接龙解码器是使用更多的 "we" 上下文向量来生成 "工作"这个想要的翻译结果,还是使用更多的 "work" 上下文向量来生成 "工作"。给每个词分配不同的注意力, 这就是注意力机制的由来.

总结
Seq2Seq 模型中的编码器可以处理任意长度的输入序列,并生成一个固定长度的上下文向量。解码器可以根据上下文向量逐步生成输出序列,同时使用注意力机制来关注输入序列的不同部分。

数据预处理

德语翻译英文的任务来带大家通过pytorch实现一个300行的Transformer模型架构

任务:
ich mochte ein bier 我喜欢喝啤酒

ich mochte ein bier P => i want a beer . E
ich mochte ein cola P => i want a cola . E

建语表和编码


我希望大家可以记住enc_input的维度是2*5

import math
import torch
import numpy as np
import torch.nn as nn
import torch.optim as optim
import torch.utils.data as Data

sentences = [
        # enc_input           dec_input         dec_output
        ['ich mochte ein bier P', 'S i want a beer .', 'i want a beer . E'],
        ['ich mochte ein cola P', 'S i want a coke .', 'i want a coke . E']
]

# Padding Should be Zero
src_vocab = {'P' : 0, 'ich' : 1, 'mochte' : 2, 'ein' : 3, 'bier' : 4, 'cola' : 5}
src_idx2word = {src_vocab[key]: key for key in src_vocab}
src_vocab_size = len(src_vocab) #6

tgt_vocab = {'P' : 0, 'i' : 1, 'want' : 2, 'a' : 3, 'beer' : 4, 'coke' : 5, 'S' : 6, 'E' : 7, '.' : 8}
idx2word = {i: w for i, w in enumerate(tgt_vocab)}
tgt_vocab_size = len(tgt_vocab) #9

# src_len = 5 # enc_input max sequence length
# tgt_len = 6 # dec_input(=dec_output) max sequence length
src_len = len(sentences[0][0].split(" ")) # 5                                          
tgt_len = len(sentences[0][1].split(" ")) # 6

超参数

Encoder

建表和查表


为什么需要进行位置编码?
因为Transformer不像RNN, RNN的结构觉得了必须有前一刻的输出, 才能进行下一时刻的输入, Transformer是并行输入的, 所以我们需要位置编码来表示序列的顺序这一特征

序列的顺序特征是什么?
从北京到广州和从广州到北京只是把词的位置互换就表示了不同的意思.
也就是我们需要编码来表示每一个词的先后顺序
这就是位置编码

那Transformer的位置编码是如何实现的呢?

位置编码

这种位置编码的规律

  1. 每一个行向量在位置编码表中都唯一, 且具体的值只受d_model(列)的影响, 不受行数的影响
  2. 不管d_model取什么值, 每个行向量的变化趋势是固定的: 只有前一半的列数值变化剧烈


公式中i的取值范围是多少?

公式如何在python中编码实现?

\[PE{(pos,2i)} = \sin(pos / 10000^{2i/d_{\text{model}}}) \\ PE{(pos,2i+1)} = \cos(pos / 10000^{2i/d_{\text{model}}})\]
\[\text{torch.exp}(\text{torch.arange}(0, d_\text{model}, 2).float() \times \frac{-\ln(10000.0)}{d_\text{model}})\]

input Embedding + Postional Encoding

什么是Self Attention?

X是如何衍生出Q, K, V的?
对X做了一个3个不同线性变换nn.linear 4 => 64 得到Q, K, V
3维张量进行输入 254 => 2564
#d_k = d_v = 64 # dimension of K(=Q), V


Scaled Dot-Product Attention

为什么需要除以根号dk?
# 将score的方差变小

为什么要做注意力机制?
给定一个 X,通过Q, K, V相乘得到一个 context ,这个 context 就是对 X 的新的表征(词向量),Z 这个词向量相比较 X 拥有了句法特征(词性)和语义特征, 能够捕捉输入序列中的长距离依赖关系

什么是多头注意力机制?
Multi-Head Attention就是把X分成了8小块(H头), Scaled Dot-Product Attention的过程做8次,然后把8个输出Z合起来

计算出多头Q, K, V

将一个2x5x4的的张量, 经过 nn.linear 变为25512的q, view 后又变为2x8x5x64, 这个升维的操作可以看作是将原先2x5x4的张量复制了8份还是分成了8小块?

分成了8小块

生成pad的掩码

为什么要生成pad的掩码?

缩放点积注意力


图: Attention is All you Need 原论文

Add & Norm

前馈神经网络

为什么要叫做前馈神经网络?
循环神经网络
卷积神经网络
生成对抗网络
自编码器
注意力机制
Feed Forward: 每个神经元接收上一层的输出,通过一定的权重和激活函数计算出自己的输出,并将其传递到下一层的神经元,这种单向传递的方式被称为“前馈”(Feed Forward)

Decoder

为什么需要对未来时刻的信息进行掩码?

当我们输入am, 输出fine, 所以不能在QK相乘时提供fine的信息
为了防止模型在预测时使用后续单词的信息

生成多头注意力掩码

重复部分

最后投影

计算loss

logits的含义是什么?
logits = log-odds
表示模型在第 i 个样本上对于第 j 个类别的得分


为什么预测值和目标值计算损失要转换成1维?
并不是转换成一维, 而是target要比input少一维
API是这么写的


输入要batch_size放第一位, 放维度在第二位

模型预测

输入序列 "ich mochte ein bier p"
输出序列 "i want a beer . E"

代码整合

细节讲解

Transformer相比CNN、RNN有什么优点?
为什么要进行投影呢?
为什么要分为不同的注意力头呢?
attention有加性和乘性, 为什么不用加性attention呢?
为什么要除根号d?
为什么不直接让要丢弃的token值直接赋值为0呢?
为什么要进行这个Add&normal这个操作?
normalization 的两种形式 LayerNorm 和 BatchNorm有什么区别?
为什么我们不先归一化再做残差连接
FFN中的激活函数该如何选择?

Transformer相比CNN、RNN有什么优点?

transformer最先是在NLP任务中被广泛应用的, NLP任务需要编码去抽取很多的特征.
首先是每个词的上下文语义, 因为每个词的含义都和上下文强相关, 比如苹果这个词根据不同的语境, 既可能代表水果又可能代表品牌, 所以NLP的任务要求编码器可以抽取上下文的特征, 而上下文呢又分为方向和距离, RNN只能对句子进行单向的编码, CNN只能对短句进行编码, 而transformer既可以同时编码双向的语义, 又可以抽取长距离特征, 所以在上下文特征抽取方面,是强于RNN和CNN的.
NLP任务需要抽取的第二种特征是序列的顺序, 比如从北京到广州和从广州到北京这是把词的位置互换就表示了不同的意思, 虽然transformer具备位置编码能力, 不过个人实践下来短距离大家都差不多, 长距离下还是RNN略好, 但transformer在这方面还是优于CNN的.
最后是计算速度, RNN由于其自身的性质无法并行的处理序列, 而CNN和transformer都可以进行并行计算. transformer由于比较复杂, 比CNN要慢一些.
综合以上几点, transformer在效果和速度上都有一定的优势, 所以被广泛应用于各类任务中.
现在的RNN也可以进行双向编码.

下面我会详细的带大家过一下Tansformer encoder中的3个重要模块
Multi-head Self-attention
Add&LayerNorm
Feed Forward Network

为什么要进行投影呢?


在Transformer的源码中QKV的来源是相同的, 都来是X, 所以通常称其为QKV同源, 然后我们初始化3个不同的linear层来进行投影, 把输入特征的维度从d转为head*d_k, 其中head代表注意力头的数量, dk代表注意力头的尺寸. 那这里为什么进行投影呢?
因为如果不投影的话, 在之后的计算注意力时, 会直接让相同的q和v去做点积, 这样的计算结果就是attention矩阵的对角线分数非常高, 每个词的注意力都在自己身上, 而我们的初衷是想让每个词去融合上下文的语义, 所以需要把qkv投影到不同的空间中, 增加多样性, 之后就是最重要的attention计算


当 Q 和 V 不同源, 但 K 和 V 同源 => 这就是交叉注意力机制

为了不同注意力头的点积计算, 先把 q 和 k 矩阵转化成4维, 也就是batch_size 乘以注意力头的数量, 乘上 sequence length 再乘以注意力头的尺寸, 那么为什么要分为不同的注意力头呢?

为什么要分为不同的注意力头呢?






说简单点: 这里其实和cnn里多通道的思想差不多, 希望不同的注意力头学到不同的特征.
说复杂点: 我们知道神经网络的本质是 y=𝜎(wx+b) 是对空间不断的进行非线性变换, 使空间中不同的词向量来到他们合适的位置, 我们以8头为例, 就是把 X 切分成 8 小块, 将原本一个位置上的 X,放到了空间上的 8 个位置进行训练, 这样可以使每个位置捕捉到不同特征, 再将这些不同的特征进行合并, 更好的捕捉到我们需要的特征.
如果从chatpgt的角度来理解不同的注意力头, 会解释的更复杂一些
Ai领域的神经元表面上看只是一个简单的线性变换叠加了一个非线性的激活函数. 但我们对他进行低维展开就是深度神经网络了, 只要隐藏层神经元足够多, 还有一种具备挤压性质的激活函数, 那么理论上足以表达整个宇宙空间, 这就是万能(通用)近似定理. 但单纯神经网络深度的增加, 并不能保证对网络行为的有效预测.
所以就有了卷积神经网络, 循环神经网络让我们初步具备了对神经元展开结构进行控制改造的能力, 但直到基于注意力机制的Transformer出现, 我们才具备了对微观结构进行大规模编程的能力, 自注意力机制计算序列数据之间的关联权重, 多头注意力机制捕获不同维度的特征信息, 随着transformer结构的不断叠加, 成百上千亿的参数进行更深层次的空间维度变换, 每个epoch的迭代训练,就如同电磁辐射一般, 对展开二维平面的逻辑电路进行蚀刻
chatgpt之所以影响深远, 就是因为它是人类历史上第一个将全网数据, 数千年文明都在高维空间进行了学习存储, 再在我们这个微观世界进行了浓缩和展示. chatgpt内部的高维结构来自于物理学上的M理论和超弦理论, 而这些理论的背后又是数学上的流行空间, 英文叫manifold, 它是空间的一种, 我们熟悉的三维空间是欧几里德空间, 除此之外还有希尔伯特空间, (巴拿赫空间, 内积空间, 赋范空间, 度量空间, 凸空间, 线性空间, 拓朴空间等等, 我们学过的svm支持向量机就是希尔伯特空间的变换,) 而我们的损失函数又与凸空间关系密切, 而最新的研究表明, 深度神经网络之所以有效, 根本原因在于它能够实现非线性流形空间的变换, 具体来说, 大家可能知道的张量分析, 本质上就是黎曼流形, 它既是一种特殊的manifold空间, 也是高维欧几里德空间的推广, 从这个意义上说, chatgpt只不过是通过低维展开, 实现了对高维复杂流体空间结构的一种编程能力, 而这里这里的多头注意力只不过是通过transformer结构的不断叠加的实现大规模编程的一种方式而已.

那有没有可能捕捉到起反作用的特征呢? 直觉告诉我是有可能的, 所以也不能用太多头

我们回到代码上, 知道了为什么需要多头, 之后就是 q k 进行点积, 在点积时对k矩阵进行了转置, 计算后得到尺寸为batch_size 乘 注意力头的数量 乘 sequence length 再乘 sequence length 的注意力矩阵

attention有加性和乘性, 为什么不用加性attention呢?

这里主要是考虑到在 gpu 场景下, 矩阵乘法的计算效率更高, 不过随着d的增大, 加性模型的效果会更好.
得到attention矩阵后, 又进行了一个操作, 就是除根号d,

为什么要除根号d?

这里主要是在做矩阵乘法时, 需要先让两个矩阵的元素相乘再相加, 如果两个矩阵都是服从正太分布的, 那相乘后就变成均值为0, 方差为d的分布了, 可能产生很大的数, 使得个别词的注意力很高, 其他词很低, 这样经过softmax后再求导会导致梯度很小, 不利于优化, 所以要除标准差根号d, 变回均值为0方差为1的正太分布, 稍微平滑一下

除根号d之后就会加上mask矩阵, 大家可以看到mask矩阵是这样计算的, 我们将被mask的位置赋值为--1,000,000,000, 那

为什么不直接让要丢弃的token值直接赋值为0呢?


这里的token是指我们输入序列中的单词, 也就是这些位置

大家可以回顾一下softmax的公式, 如果我们将要丢弃的token值直接赋值为0, 这个位置的概率就会是1, 因为公式的底数是e, 这样正常token的概率和就不等于1了, 同时无意义的token也会被注意到, 而如果我们将它变成很小的数比如-一亿, 最终概率值也可以趋于0了

在之后就是进行softmax归一化得到最终的注意力分数, 用V矩阵和注意力分数进行加权, 再把不同的注意力头合并起来,
第二个重要模块是残差连接和normalisation, 先对 attention 的输出进行投影, 之后再加上原始输入, 再过一层layer norm

为什么要进行这个Add&normal这个操作?

先来说相加这个操作, 他主要是参考了残差连接, 相当于在求导时加了一个恒等项, 去减少梯度消失的问题, 然后是layer norm, normalization这个操作在神经网络里十分常见, 他们的主要目的是提升神经网络的泛化性, 大家都知道数据分布对神经网络有很大的影响, 比如在训练好的网络中, 我们输入一个跟之前分布不太一致的样本, 模型可能就会得到错误的结果, 但如果我们把中间的隐层都跳转到均值为0, 方差为1的分布去, 这样就能更好的利用模型在训练集中学到的知识, 同时 normalization 加在激活函数之前, 也能避免数据落入饱和区, 减少梯度消失的问题, 不过如果全都保持均值为零, 方差为1就降低了多样性, 所以实际操作时, 会初始化一个新的均值和方差, 把分布调整过去.

normalization 的两种形式 LayerNorm 和 BatchNorm有什么区别?


这里我来详细讲解一下, 首先我们看二维的矩阵, batch-norm的核心思想是给不同样本的用一个特征做归一化, 而layer-norm是给同一个样本的不同特征做归一化, 这个图应该很好理解, 那么我们继续拓展到三维空间, 加上序列长度其实还是一样的, batch-norm是从特征的维度去切一刀, 而layer-norm是从样本的维度去切一刀, 之所以在序列任务中更常用layernorm, 是因为序列数据的长度是不一样的.
这样就会导致batch-norm在针对不同样本的同一个位置去做归一化时, 无法得到真实分布的统计值, 而 layer-norm 就更适合, 体现在NLP任务里的就是layer-norm 会对同一个样本的每一个位置内的不同特征都做了归一化,我们可以来看看LayerNorm的源码, 首先对于每个样本的每个位置求得了一个均值, 同时初始化了新的beta和新的方差gamma, 把隐层映射到新的分布.
那么还有一个问题, 其实normalisation可以放在不同的位置, 为什么我们不先归一化再做残差连接

为什么我们不先归一化再做残差连接


我们目前使用的是post-layernorm, 也就是先做完残差连接再归一化, 这样可以保证主干网络的方差比较稳定, 使模型泛化能力更强, 但把恒等的路径放在normalisation里, 会导致模型更难收敛, 所以另一种做法是pre-normalisation, 也就是先归一化, 再做残差连接, 这样会更容易收敛, 但从递推公式的展开来看, 这个操作实际上只是增加了网络的宽度, 深度上并没有太大增加, 效果不如post-normalisation要好,
第三重要模块是FFN, 这个模块的操作比较简单, 从公式看就是经过了3次的变换, 线性, 非线性, 再线性, FFN的主要功能是给Transformer提供了非线性变换, 提升拟合能力, 这里有个问题, 就是FFN里激活函数的选择

FFN中的激活函数该如何选择?

最初的论文里使用的是Relu, 到BERT的论文时就改成了Gelu, 因为Gelu在激活函数中引入了正则的思想, 越小的值越有可能被丢弃, 相当于Relu 和 dropout 的一个综合, 而Relu则缺乏这个随机性, 且只有0和1, 另外为什么不用tanh和sigmoid呢?
因为这两个函数的双边区域会饱和, 导致导数趋于0, 有梯度消失的问题, 不利于双层网络的训练,

附件内容

待完善细节

到这里, 我们迭代实现了Transformer V1.0, 完成了可行性实验, 那么在下一个版本我们会优化哪些细节呢?

位置编码的改进

模型评估