说在前面的话:
找工作面试不是特别顺利。进了目标公司的二面,但是一面面试官问的一些“新技术”问题答得不太好,尤其是transformer相关的。这一点确实是自己的问题,在工作后总是面向业务学习,对很多算法都是处于“听说过但没用过”的状态。VIT/SWIN/Convnext之前明明都专门去学习过,但是因为没有深入了解,所以也没有留下比较深刻的记忆。
加油啊!
参考教程:
- bilibili 迪哥的人工智能课
- WORD EMBEDDINGS: ENCODING LEXICAL SEMANTICS
- https://zhuanlan.zhihu.com/p/48731949
文章目录
- 从Embedding开始
- 词的向量化
- 词向量模型
- 过程介绍
- 数据的获取
- word2vec
- 代码介绍
- torch.nn.Embedding()
- 代码实现
- Transformer 与 self-attention
- self-attention
- multi-head attention
- position-embedding
- Transformer
- Encoder
- Decoder
- 代码实现
- 整体结构
- input-part
- attention and multi-head attention
- LN + add
- encoderlayer
从Embedding开始
词的向量化
transformer最初主要还是用于NLP任务中。在NLP任务中,对于一个文本的输入,通常会使用embedding对它进行一个向量化的表示。
embedding的目标,就是用一组向量来刻画现有的数据集。那么就对这个embedding有了一点要求。
- 要有词的先后顺序。
- 相似的词在向量空间的表达应该也是相似的。
- 同一类的词在空间内应该是相近的。
第一个要求很好理解,比如你可以说“这个房子里有一条狗”但是你不能说“这条狗里有一个房子”。顺序的改变是会影响文本的含义的,因此我们希望对文本中单词进行编码时,能记录他们的位置信息。
第二个要求举个例子,“月亮”和“月球”两个词虽然组成它们的文字不一样,但是它们都是“月”的一个称呼,意思也都是“月”。因此我们希望它们的向量也比较相近。
第三个要求是限制同一类词的距离,比如说“篮球,足球,泰山”。那么篮球和足球作为一种体育用品,并且都是球类,它们两者在向量空间内肯定是比它们和泰山的距离要近的。泰山和这两个词本来就是八竿子打不着,如果它距离篮球比足球距离篮球还要近,这就很违背常人的逻辑印象。
同时呢,我们也希望得到的这个embedding的向量表示的维度也相对高一点,维度高意味着信息更多,那么在使用这个向量表示时,我们得到的结果也会相对更可靠一些。
词向量模型
我们的词向量,也是是文本中词汇的embedding化表示,是可以通过多种方法实现的。比如说简单的one-hot编码,又或者使用频率表示。但是这两种方法都是有缺陷的。
- 使用one-hot编码,假如你的词库非常大,那么为了表示一个单词,就需要一个极其长的向量表示,造成极大的内存浪费。
- 频率是通过用某单词在文章中出现次数除以文章的单词个数获得的,它的优点是简单快速,但是忽略了词的位置信息和相关性。
我们可以通过所谓的词向量模型来获得词向量。
词向量模型是一种需要训练的模型,使用这个模型,可以帮助我们获得对词库中的单词的向量化表达。这种模型的输入也是一个embedding,可以理解成对指定单词的一个标志,用于把单词区分开来。而它的输出结果是一个概率,表示在给定输入的情况下,最可能返回的单词是什么。
当然我们想要的并不是这个输出的结果,而是藏在模型中间的embedding。
整体的过程可以理解为,我们想要的embedding是这个模型的一个中间结果,它会随着模型的更新迭代而变化,我们判断这个embedding是否好用的根据是模型的输出结果是否准确。
而在这个任务中,模型的输入是给定的文本编码,模型的输出是目标单词出现的概率。所以它其实是一个多分类的问题,最终的概率是由softmax函数得到的。在你的分类目标很多的情况下,softmax计算会很麻烦,因此又有一个优化的方法:
输入A和B两个单词,判断这两个词是不是前后邻居。
这样这个任务就变成了二分类的任务。
过程介绍
数据的获取
常用的方法是:
使用滑动窗口构建输入数据。
从别的地方偷了一张图。这里就给出了一个很直观的例子。
在上面一节我们说词向量的模型输入是给定的文本编码,输出是目标单词的概率。而图中就是一个用前文预测下文的例子,它的前文单词数长度为2,也就说说用前两个词预测第三个词。
对于一段文本,从第一个单词开始,用连续的两个词预测下一个词,所以才会有inputs是“thou shalt”,output(target)是“not”。
具体input和output是如何获得的,还是由使用者自行决定的。除了用前n个词预测下一个词外,也可以使用前n个加后n个词,来预测中间的词。
word2vec
常用的两种模型介绍
- CBOW:输入目标单词前面和后面的n个词,进行单词本身的预测。
- skip-gram:输入一个单词,预测它前n个词和后n个词。
下方左边就是CBOW的例子,右边是Skip-gram的例子。
两个模型的原理是相似的。
对于CBOW,我们传入四个单词的编码(这里的编码不是我们想要的embedding,只是为了区分不同单词的标号),通过模型获得四个单词的embedding,对这个embedding进行处理,并用于预测四个单词正中间的单词,获得“目标单词为词库中的第n个词的概率”,最大概率对应的单词就是我们的输出。
对于Skip-gram,我们传入中间单词的编码,通过模型获得embedding,再用于预测它周围的四个单词。
代码介绍
torch.nn.Embedding()
torch.nn.Embedding(num_embeddings, embedding_dim, padding_idx=None, max_norm=None, norm_type=2.0, scale_grad_by_freq=False, sparse=False, _weight=None, _freeze=False, device=None, dtype=None)
先来看一下这个class中的参数都有什么用。
- 第一个参数 num_embeddings:代表你的embedding有多少个,也可以等价于你期望的词库大小。
- 第二个参数embedding_dim是你希望得到的embedding向量的维度。
- padding_idx:可以指定不用于梯度更新的index的位置。也就是在模型训练的过程中,别的位置会随着backward更新,而这个位置不会。
- max-norm:用于对你的vector进行归一化
- p-norm:归一化使用L2还是L1。
- _weight: 你可以自己传入一个weights,这里的weight就是生成的embedding矩阵的值,如果这个weight和你预设的大小不一致,也会重新生成。
它的前向传播得到的结果是
def forward(self, input: Tensor) -> Tensor:
return F.embedding(
input, self.weight, self.padding_idx, self.max_norm,
self.norm_type, self.scale_grad_by_freq, self.sparse)
对于在前向传播中使用的函数F.embedding,它的解释是This module is often used to retrieve word embeddings using indices. The input to the module is a list of indices, and the embedding matrix, and the output is the corresponding word embeddings. 也就是说我们传入的input是一组index,F.embedding会根据这个index去我们的weight中取vector。
比如你用的index是[1,3,5],返回结果就是self.weights的第二行,第四行和第六行,三行长度为embedding_dim的向量。
代码实现
代码实现可以直接参考pytorch tutorial中给出的例子,在这里会对代码进行解释。
下面是一个CBOW的例子
首先获取我们的数据。完成输入输出对的构建。
context_size = 2 # 代表我们会使用前面和后面各两个单词在预测中间的词。
raw_text = """...""".split() # 原始文章的string,这里因为字数太多没有列出来,可以直接替换成自己的
vocab = set(raw_text) # 文章中都使用了那些单词,这个也就是我们的词库
vocab_size = len(vocab) # 我们的词库大小
word_to_ix = {word:i for i, word in enumerate(vocab)} # 这里构建了一个字典,该字典是key是单词,value是单词的index。
# 下一步来构建我们的dataset
for i in range(context_size, len(raw_text) - context_size):
# 遍历整个文章
context = (
[raw_text[i-j-1] for j in range(context_size)] # 第i个单词的前context_size个词
+ [raw_text[i+j+1] for j in range(context_size)] # 第i个单词的后context_size个词
)
target = raw_text[i] # 第i个单词
data.append((context, target)) # 将我们的input和target按对放入data中
然后定义我们的模型。
在这里给出一个forward流程的更详细的介绍。
假如我们输入的是[2,1,3,4]。target是0。预设的vocab_size = 500, embedding_dim = 64。
那么我们期望得到的一个整个词库的embedding大小就是500*64,也就是nn.Embedding中weights的大小。
现在输入[2,1,3,4],经过第一步self.embeddings(inputs),得到的结果大小为4*64。然后求和并用view(1,-1)后,得到的结果为1*64,也就是四个embedding的融合。
经过两个线性层后,得到的out大小是1*500。
class CBOW(nn.Module):
# 定义CBOW类,继承nn.Module
def __init__(self, vocab_size, embedding_dim):
super(CBOW,self).__init__()
# vocab_size: 我们的词库的大小,因为输出结果是在每个词上的概率,所以如果直接用softmax求概率,我们的输出维度应该=词库大小。
# embedding_dim:你自己决定的embedding向量的长度。
self.embeddings = nn.Embedding(vocab_size, embedding_dim) # 我们想要的embedding结果在这里,它可以看作是一个高为vocab_size, 高为embedding_dim的矩阵。
self.proj = nn.Linear(embedding_dim, 128) # 中间层
self.output = nn.Linear(128, vocab_size) # 用于输出的全连接层,对vocab_size个类别进行预测。
def forward(self, inputs):
embeds = sum(self.embeddings(inputs)).view(1,-1) # 找到inputs的embedding并求和处理
out = F.relu(self.proj(embeds)) # 中间结果
out = self.output(out) # 预测的pred
prob = F.log_softmax(out, dim=-1) # 这里直接进行了softmax计算。
return prob
在上面的部分中解释过这里的nn.Embedding()在进行前向传播时,传入的input是词的index。而我们刚刚构建的dataset中datat[i][0]是输入的词,data[i][1]是对应的target。并不是index。所以我们要对输入再转换一下。
def make_context_vector(context, word_to_ix):
idxs = [word_to_ix[w] for w in context] # 从我们之前创建的word_to_index的dict中根据word取index
return torch.tensor(idxs, dtype=torch.long) # 要注意输入是tensor
make_context_vector(data[0][0], word_to_ix) # example
可以看一下转换的结果
下方就是一个比较常规的训练代码。
EMBEDDING_DIM = 64
loss_function = nn.NLLLoss()
model = CBOW(len(vocab), EMBEDDING_DIM)
optimizer = optim.SGD(model.parameters(), lr=0.001)
for epoch in range(10):
total_loss = 0
for context, target in data:
context_idxs = make_context_vector(context, word_to_ix)
optimizer.zero_grad()
log_probs = model(context_idxs)
loss = loss_function(log_probs, torch.tensor([word_to_ix[target]], dtype=torch.long))
loss.backward()
optimizer.step()
total_loss += loss.item()
这里给出了一个可视化的例子,因为训练的轮数比较少,得到的embedding可能不是很准确,但是也勉强可以看到单词‘we’和‘We’比‘computer’要更接近。
Transformer 与 self-attention
self-attention
传统的向量会遇到一个问题,就是在生成了embedding后,这个embedding不会发生变化了。那么在不同的语境情况下,固定的embedding肯定是不合适的,它代表的语义不会随着情境改变,就会产生错误。
注意力的加入可以帮助解决这个问题,这样在不同的情境下,语句内部的元素的关注度是不一样的。
传统的注意力机制是发生在input和output之间的,而self-attention则是发生在输入的内部,或者输出的内部。相当于你输入一长串文字时,每个文字对上下文元素的关注度是不一样的,你可以通过self-attention来得到元素间的关注度。
在self-attention中有三个很重要的矩阵,分别是queries, keys, values。也就是上图中的q, k, v。
- queries: 你要去查询的向量。
- keys:要被查询的向量。
- values: 实际的特征信息。
这个解释可能比较难理解。具体来讲,qi是你的目标,你想知道q对别的元素的关注度attention,那么你就用qi和别的所有的k进行比较,当然也包括了ki。
它们的获得方法也很简单,就是与权重矩阵相乘。下图为一个比较完整的步骤。
第一步,对于输入的向量I,分别与Wq, Wk和Wv相乘,得到备用的Q, K, V。
具体来说,假如你现在有N个1*C的输入,W系列的权重大小均是C*V,那么你分别会得到N个大小为1*V的向量。也就是你的{q1…qn},{k1…kn},{v1…vn}。
第二步,用Q与K进行内积,算出匹配度。上图给了更具体的细节,对于输入向量a1,除了用它的q1和k1做内积外,还要用它的q1和别的输入向量的k做内积。
因为一共有n个k,所以这一步完成后,你得到的是一个大小为1*n的向量。因为你除了a1外还有n-1个输出,所以整体的结果应该是一个n*n的矩阵,反应的是n个输入之间彼此的相关性。这个结果会用softmax处理,得到一个概率分布,代表每个元素相对当前目标的重要程度。
s
o
f
t
m
a
x
(
Q
×
K
T
d
k
)
softmax(\frac{Q\times K^T}{\sqrt{dk}})
softmax(dkQ×KT)
补充一下为什么要除以根号dk,因为长度很大的时候,点积的结果也会很大,那么在大数和小数差距很大的情况下,softmax容易被推到梯度很小的区域,所以这里除以dk来平衡一下。
第三步,将v按照上一步得到的结果进行加权求和后得到一个新的输出。
multi-head attention
一组q,k,v能得到一个当前特征的表达。假如有多个q, k, v 就能得到多个表达。每一组的计算过程仍然是一样的。
对于多组的输出,将结果直接concat在一起。
具体如下图所示。
我们的bi,1是通过[qi1ki1,qi1kj1]与vi1,vj1结果加权求和得到的。
我们的bi,2是通过[qi2ki2, qi2kj2]与vi2,vj2结果加权求和得到的。
两个结果concat起来,再使用全连接降维,就得到最终的输出。
position-embedding
上面讲到的输入a1, a2, … an,我们是可以知道顺序的,但是在模型的计算中没有反应出这种顺序,甚至也利用不到这个信息。因此为了体现出前后顺序的区别,可以在输入ai中加入位置的编码信息。
Transformer
可以直接参考:https://zhuanlan.zhihu.com/p/460588687
我感觉解释得比较详细。
一个完整的transformer结构,包括了encoder和decoder两部分。
这两部分都是由堆叠的 multi-head attention + BN组成的。当然还有不可忽略的残差结构。
Encoder
编码器在原始输入序列中进行了两个预处理:
- 词嵌入:将词转为成向量。
- 位置编码:将词在序列中的位置信息加入考量。
预处理的结果会被传入到block里,一个block里面有两个子层。
- 多头注意力+残差和+层归一化
- 全连接+残差和+层归一化
写一个公式化的流程
# 输入ai 并生成position embedding pi
ai = ai + pi # 把ai和pi合成一个新的ai
while n: # 假设有n个block
x = multi_head_attention(ai)
x = layer_norm(x + ai)
x2 = feed_forward(x)
x2 = layer_norm(x2 + x)
ai = x
n-=1
Decoder
解码器虽然和编码器看起来很相似,但是还是有一点区别。
-
Masked Multi-Head Attention
Decoder中第一个注意力机制使用的Masked Multi-Head Attention,Masked Multi-Head attention的计算顺序其实是和Decoder的串行计算顺序相对应。
因为在进行预测的时候,我们并不是能直接看见所有的文本的,文本的结果是一个一个出现的,因此masked multi-head attention就是为了模拟这个过程【我是这么理解的,也可能不对】。
你只能使用已知的信息做预测,所以masked multi-head attention遮住了理论上应该未知的信息。 -
Cross Attention
在这个地方,queries, keys, values来源不同。- keys: encoder输出中计算
- values:encoder输出中计算
- queries:decoder输出中计算。
代码实现
参考教程:https://zhuanlan.zhihu.com/p/339207092
跟着教程写一遍,加深理解。
整体结构
首先要思考一下在这个过程中都需要完成哪些组件。
- embedding,我们encoder和decoder的输入都是词向量,而非词本身,所以我们最开始要使用embeddinglayer进行单词的转换。
- positionembedding,我们的输入要带有位置信息,因此还需要和位置进行编码。
- 网络由多个block组成,每个block都是一样的结构。
- multi-head attention和attention。
- layernorm 用于对输出做归一化
- 残差结构,输出要和输入相加。
- feedforward部分,有的网络中也会使用更高级的forward,我还没有学到那里。
- 对于decoder,它使用的是带mask的attention。
在这里就不多关注decoder了。
input-part
在数据传入网络前,我们要对它进行编码,并加上位置信息。因此这里需要一个embedding和一个positionembedding。
class Embedding(nn.Module):
def __init__(self, embed_dim, vocab):
super(Embedding, self).__init__()
self.embed = nn.Embedding(vocal, d_model)
self.embed_dim = embed_dim
def forward(self, x):
return self.embed(x)*math.sqrt(self.embed_dim)
# 这里用权重乘以向量长度开根,其实不是很理解为啥。
def PositionalEncoding(nn.Module):
def __init__(self, embed_dim, dropout, max_len = 5000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, embed_dim)
# pe是我们的位置编码,它是max_len个长度为embed_dim的vector。
# 因为pe的位置编码是要和词向量直接相加的,所以他们的长度也要保持一致。
position = torch.arange(0, max_len).unsqueeze(1)
div_term = torch.exp(torch.arange(0, embed_dim, 2)*
(-math.log(10000.0)/embed_dim))
pe[:,0::2] = torch.sin(position * div_term)
pe[:,1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0)
def forward(self, x):
# 这里的上限x.size(1)应该是怕出现维度过长的情况。
# 可以看到这里是直接相加的,那么x应该是词向量。
x = x + Variable(self.pe[:, :x.size(1)], requires_grad = False)
return self.dropout(x)
attention and multi-head attention
attention的计算是比较好理解的,对于得到的q,k,v。q和k做内积和softmax求概率值,然后和v加权求和。
def attention(query, key, value, mask = None, dropout = None):
# 向量长度
d_k = query.size(-1)
# 内积
scores = torch.matmul(query, key.transpose(-2,-1))/math.sqrt(d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
p_attn = F.softmax(scores, dim = -1) # 求得不同元素相对目标的重要性
if dropout is not None:
p_attn = dropout(p_attn)
# 特征加权求和。得到输出
return torch.matmul(p_attn, value), p_attn
多头attention会把每个attention的结果concat在一起然后使用全连接层。多头attention计算的时候也是直接使用的矩阵,它的效率更高。并不需要我们用什么遍历的方法一个一个计算。
在这里的concat,也只是把结果reshape了一下就可以得到了。
class MultiHeadAttention(nn.Module):
def __init__(self, h, embed_dim, dropout = 0.1):
super(MultiHeadAttention, self).__init__()
self.d_k = embed_dim//h # 这里是每个head关注embedding的一部分
self.h = h # head的个数
self.q = nn.Linear(embed_dim, embed_dim)
self.k = nn.Linear(embed_dim, embed_dim)
self.v = nn.Linear(embed_dim, embed_dim)
self.linear = nn.Linear(embed_dim, embed_dim) # 全连接的部分
# 用于计算Q,K,V
self.attn = None
self.dropout = nn.Dropout(p=dropout)
def forward(self, q, k, v, mask = None):
if mask is not None:
mask = mask.unsqueeze(1)
nbatches = query.size(0)
query = self.q(q).view(nbatches, -1, self.h, self.d_k).transpose(1,2)
key= self.k(k).view(nbatches, -1, self.h, self.d_k).transpose(1,2)
value = self.v(v).view(nbatches, -1, self.h, self.d_k).transpose(1,2)
x, self.attn = attention(query, key, value, mask = mask, dropout = self.dropout)
x = x.transpose(1,2).contiguout().view(nbatches, -1, self.h * self.d_k)
return self.linear(x)
LN + add
LN的作用是对同一物体的不同维度进行归一化。
class LayerNorm(nn.Module):
def __init__(self, features, eps = 1e-6):
super(LayerNrom,self).__init__()
self.gamma = nn.Parameter(torch.ones(features))
self.beta = nn.Parameter(torch.zeros(features))
self.eps = eps
def forward(self, x):
mean = x.mean(-1, keepdim = True)
std = x.std(-1, keepdim = True)
return self.gamma * (x - mean) / (std+self.eps) + self.beta
add是一个残差结构,将输入和输出直接相加在一起。
class SublayerConnection(nn.Module):
def __init__(self, size, dropout):
super(SublayerConnection, self).__init__()
self.norm = LayerNorm(size)
self.dropout = nn.Dropout(dropout)
def forward(self, residual, x):
return self.norm(x + residual)
# def forward(self, x, sublayer):
# return x + self.dropout(sublayer(self.norm(x)))
# 在教程提供的代码中,x可以理解成上一层的未经过norm的输出。所以在这里先做了norm。
# 然后这里的sublayer是我们的的多头注意力,所以这里返回的是未作norm的残差和
encoderlayer
class EncoderLayer(nn.Module):
def __init__(self, size, self_attn, feed_dorward, dropout):
super(EncoderLayer, self).__init__()
self.self_attn = self_attn # 这个self_attn就是我们的多头attention
self.feed_forward = feed_forward
self.sublayer1 = SublayerConnection(size, dropout) # 我们的sublayer1是多头+add+norm
self.sublayer2 = SublayerConnection(size, dropout) # 我们的sublayer2是feedforward+add+norm
self.size = size
def forward(self, x, mask):
x1 = self.self_attn(x, x, x, mask)
# 得到新的value
x1 = self.sublayer1(x, x1)
x2 = self.feed_forward(x1)
x2 = self.sublayer2(x1, x2)
return x2