深度学习原理篇 第一章:transformer入门

简介: 简要介绍词向量和transformer原理。

参考教程:

从Embedding开始

词的向量化

transformer最初主要还是用于NLP任务中。在NLP任务中,对于一个文本的输入,通常会使用embedding对它进行一个向量化的表示。
embedding的目标,就是用一组向量来刻画现有的数据集。那么就对这个embedding有了一点要求。

  • 要有词的先后顺序。
  • 相似的词在向量空间的表达应该也是相似的。
  • 同一类的词在空间内应该是相近的。

第一个要求很好理解,比如你可以说“这个房子里有一条狗”但是你不能说“这条狗里有一个房子”。顺序的改变是会影响文本的含义的,因此我们希望对文本中单词进行编码时,能记录他们的位置信息。
第二个要求举个例子,“月亮”和“月球”两个词虽然组成它们的文字不一样,但是它们都是“月”的一个称呼,意思也都是“月”。因此我们希望它们的向量也比较相近。
第三个要求是限制同一类词的距离,比如说“篮球,足球,泰山”。那么篮球和足球作为一种体育用品,并且都是球类,它们两者在向量空间内肯定是比它们和泰山的距离要近的。泰山和这两个词本来就是八竿子打不着,如果它距离篮球比足球距离篮球还要近,这就很违背常人的逻辑印象。

同时呢,我们也希望得到的这个embedding的向量表示的维度也相对高一点,维度高意味着信息更多,那么在使用这个向量表示时,我们得到的结果也会相对更可靠一些。

词向量模型

我们的词向量,也是是文本中词汇的embedding化表示,是可以通过多种方法实现的。比如说简单的one-hot编码,又或者使用频率表示。但是这两种方法都是有缺陷的。

  • 使用one-hot编码,假如你的词库非常大,那么为了表示一个单词,就需要一个极其长的向量表示,造成极大的内存浪费。
  • 频率是通过用某单词在文章中出现次数除以文章的单词个数获得的,它的优点是简单快速,但是忽略了词的位置信息和相关性。

我们可以通过所谓的词向量模型来获得词向量。
词向量模型是一种需要训练的模型,使用这个模型,可以帮助我们获得对词库中的单词的向量化表达。这种模型的输入也是一个embedding,可以理解成对指定单词的一个标志,用于把单词区分开来。而它的输出结果是一个概率,表示在给定输入的情况下,最可能返回的单词是什么。

当然我们想要的并不是这个输出的结果,而是藏在模型中间的embedding。

整体的过程可以理解为,我们想要的embedding是这个模型的一个中间结果,它会随着模型的更新迭代而变化,我们判断这个embedding是否好用的根据是模型的输出结果是否准确。

而在这个任务中,模型的输入是给定的文本编码,模型的输出是目标单词出现的概率。所以它其实是一个多分类的问题,最终的概率是由softmax函数得到的。在你的分类目标很多的情况下,softmax计算会很麻烦,因此又有一个优化的方法:

输入A和B两个单词,判断这两个词是不是前后邻居。

这样这个任务就变成了二分类的任务。

过程介绍

数据的获取

常用的方法是:
使用滑动窗口构建输入数据。
image.png

从别的地方偷了一张图。这里就给出了一个很直观的例子。
在上面一节我们说词向量的模型输入是给定的文本编码,输出是目标单词的概率。而图中就是一个用前文预测下文的例子,它的前文单词数长度为2,也就说说用前两个词预测第三个词。
对于一段文本,从第一个单词开始,用连续的两个词预测下一个词,所以才会有inputs是“thou shalt”,output(target)是“not”。

具体input和output是如何获得的,还是由使用者自行决定的。除了用前n个词预测下一个词外,也可以使用前n个加后n个词,来预测中间的词。

word2vec

常用的两种模型介绍

  • CBOW:输入目标单词前面和后面的n个词,进行单词本身的预测。
  • skip-gram:输入一个单词,预测它前n个词和后n个词。

下方左边就是CBOW的例子,右边是Skip-gram的例子。
image.png

两个模型的原理是相似的。

对于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

可以看一下转换的结果
image.png

下方就是一个比较常规的训练代码。

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’要更接近。
image.png

Transformer 与 self-attention

self-attention

传统的向量会遇到一个问题,就是在生成了embedding后,这个embedding不会发生变化了。那么在不同的语境情况下,固定的embedding肯定是不合适的,它代表的语义不会随着情境改变,就会产生错误。

注意力的加入可以帮助解决这个问题,这样在不同的情境下,语句内部的元素的关注度是不一样的。

传统的注意力机制是发生在input和output之间的,而self-attention则是发生在输入的内部,或者输出的内部。相当于你输入一长串文字时,每个文字对上下文元素的关注度是不一样的,你可以通过self-attention来得到元素间的关注度。

image.png

在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处理,得到一个概率分布,代表每个元素相对当前目标的重要程度。
$$ softmax(\frac{Q\times K^T}{\sqrt{dk}}) $$
补充一下为什么要除以根号dk,因为长度很大的时候,点积的结果也会很大,那么在大数和小数差距很大的情况下,softmax容易被推到梯度很小的区域,所以这里除以dk来平衡一下。

第三步,将v按照上一步得到的结果进行加权求和后得到一个新的输出。
image.png

multi-head attention

一组q,k,v能得到一个当前特征的表达。假如有多个q, k, v 就能得到多个表达。每一组的计算过程仍然是一样的。
对于多组的输出,将结果直接concat在一起。
具体如下图所示。
我们的bi,1是通过[qi1ki1,qi1kj1]与vi1,vj1结果加权求和得到的。
我们的bi,2是通过[qi2ki2, qi2kj2]与vi2,vj2结果加权求和得到的。
image.png

两个结果concat起来,再使用全连接降维,就得到最终的输出。

position-embedding

上面讲到的输入a1, a2, ... an,我们是可以知道顺序的,但是在模型的计算中没有反应出这种顺序,甚至也利用不到这个信息。因此为了体现出前后顺序的区别,可以在输入ai中加入位置的编码信息。
image.png

Transformer

可以直接参考:https://zhuanlan.zhihu.com/p/460588687
我感觉解释得比较详细。
一个完整的transformer结构,包括了encoder和decoder两部分。
这两部分都是由堆叠的 multi-head attention + BN组成的。当然还有不可忽略的残差结构。

Encoder

编码器在原始输入序列中进行了两个预处理:

  • 词嵌入:将词转为成向量。
  • 位置编码:将词在序列中的位置信息加入考量。

预处理的结果会被传入到block里,一个block里面有两个子层。

  1. 多头注意力+残差和+层归一化
  2. 全连接+残差和+层归一化

写一个公式化的流程

# 输入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

image.png

Decoder

解码器虽然和编码器看起来很相似,但是还是有一点区别。

  1. Masked Multi-Head Attention
    Decoder中第一个注意力机制使用的Masked Multi-Head Attention,Masked Multi-Head attention的计算顺序其实是和Decoder的串行计算顺序相对应。
    因为在进行预测的时候,我们并不是能直接看见所有的文本的,文本的结果是一个一个出现的,因此masked multi-head attention就是为了模拟这个过程【我是这么理解的,也可能不对】。
    你只能使用已知的信息做预测,所以masked multi-head attention遮住了理论上应该未知的信息。

  2. Cross Attention
    在这个地方,queries, keys, values来源不同。

    • keys: encoder输出中计算
    • values:encoder输出中计算
    • queries:decoder输出中计算。
      image.png

代码实现

参考教程: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
相关文章
|
12天前
|
机器学习/深度学习 人工智能 TensorFlow
人工智能浪潮下的自我修养:从Python编程入门到深度学习实践
【10月更文挑战第39天】本文旨在为初学者提供一条清晰的道路,从Python基础语法的掌握到深度学习领域的探索。我们将通过简明扼要的语言和实际代码示例,引导读者逐步构建起对人工智能技术的理解和应用能力。文章不仅涵盖Python编程的基础,还将深入探讨深度学习的核心概念、工具和实战技巧,帮助读者在AI的浪潮中找到自己的位置。
|
11天前
|
机器学习/深度学习 人工智能 自然语言处理
探索深度学习中的Transformer模型
探索深度学习中的Transformer模型
23 1
|
1月前
|
机器学习/深度学习 人工智能 监控
深入理解深度学习中的卷积神经网络(CNN):从原理到实践
【10月更文挑战第14天】深入理解深度学习中的卷积神经网络(CNN):从原理到实践
98 1
|
15天前
|
机器学习/深度学习 自然语言处理 计算机视觉
探索深度学习中的Transformer架构
探索深度学习中的Transformer架构
33 0
|
1月前
|
机器学习/深度学习 自然语言处理 并行计算
|
1月前
|
机器学习/深度学习 PyTorch 算法框架/工具
深度学习入门案例:运用神经网络实现价格分类
深度学习入门案例:运用神经网络实现价格分类
|
2月前
|
机器学习/深度学习 人工智能 自然语言处理
软件工程师,入门下深度学习吧
软件工程师,入门下深度学习吧
57 9
|
1月前
|
机器学习/深度学习 人工智能 自然语言处理
深度学习中的卷积神经网络(CNN)入门与实践
【8月更文挑战第62天】本文以浅显易懂的方式介绍了深度学习领域中的核心技术之一——卷积神经网络(CNN)。文章通过生动的比喻和直观的图示,逐步揭示了CNN的工作原理和应用场景。同时,结合具体的代码示例,引导读者从零开始构建一个简单的CNN模型,实现对图像数据的分类任务。无论你是深度学习的初学者还是希望巩固理解的开发者,这篇文章都将为你打开一扇通往深度学习世界的大门。
|
2月前
|
机器学习/深度学习 人工智能 自然语言处理
深度学习的奥秘:探索神经网络背后的原理与实践
【9月更文挑战第29天】本文将带你深入理解深度学习的核心概念,从基础理论到实际应用,逐步揭示其神秘面纱。我们将探讨神经网络的工作原理,并通过实际代码示例,展示如何构建和训练一个简单的深度学习模型。无论你是初学者还是有一定经验的开发者,这篇文章都将为你提供宝贵的知识和技能。
40 2
|
1月前
|
机器学习/深度学习 自然语言处理 并行计算
探索深度学习中的Transformer模型及其在自然语言处理中的应用
【10月更文挑战第6天】探索深度学习中的Transformer模型及其在自然语言处理中的应用
104 0
下一篇
无影云桌面