0.前言爽玩了一个星期感觉整个人都放松了。收收心新的一周又开始了这个一期打算用很通俗的方法把transformer的结构讲一下中间我会用我自己对这个东西的理解去讲解一下。如果有不对的地方请批评指正。没看过上一篇请跳转【1】LLM篇 事无巨细手把手带你用transformer做一个中英翻译模型从原理到实战一网打尽-CSDN博客1. transformer结构讲模型其实很多博主或者论文讲的东西其实都有点问题误区一他会告诉你这个结构怎么怎么样有什么什么效果。如果不是很权威实验这些基本都是它瞎猜的模型训练出来的结构真正赋予它意义的是损失函数而非模型本身。模型只是做一些简单的矩阵运算是损失函数强制让真值往最小的损失方向去靠模型不具备这个能力。很多水yolo模型的论文可以看到越前面的卷积拥有丰富的位置信息后面的卷积拥有丰富的语义信息。那我随机从backbone里抽出一个和head抽出一个我不见得谁比谁卷积多了什么他们是相互的一个整体。你又要问增加注意力模型涨点了怎么解释。涨点说明模块在参数更新的时候让模型更加偏向于val数据集的真实分布而非你认为模型有什么语义信息所以增加模块会怎么样。这纯属胡扯。误区二模型并不是学习只是在用各种函数去拟合你给的训练集的特征分布你训练一个模型认识苹果他只会让这个函数提取一张具有真实苹果的图片来拟合苹果这个标签。推理的时候模型遇到一个图片放入函数的时候如果和标准答案分布很像的时候就会告诉你这是苹果。而且赋予模型拟合的东西叫做损失函数损失函数约定谁和谁的差值才是这个模型真正拟合的东西。所以在我的博客中我会告诉你这是在训练过程还是推理过程还会用通俗易懂的例子让你去记住这个是什么东西。1.1 整体看一下这个模型对于最开始的模型来说他有编码器也有解码器。但是这个参数大推理缓慢所以最后就分为两大阵营encoder和decoder今天的话我们还是讲一讲地地道道的transformer后续在讲一讲他的变体。1.2 词嵌入上一期我们已经讲到数据主要被分为了一串固定id的数字比如【256,125,365,00】这时候其实模型也没法训练你说256和125有啥关系呢。为啥没关系就是因为没有特征还是用简单的分类模型来说[b,c,h,w]是里面的数据结构既然一张图h和w就可以告诉你是一张图了。为啥要c这个维度因为c是每个图的特征。比如h和w都取第一个和最后一个像素点他俩都是0.7抛去卷积有平移不变性这两个0.7除了表示亮度能区他俩这个0.7有不同吗是不是不能那如果c这个维度上有512个值最后一个像素点和第一个像素点就有512个值。即使大家都是0.7但是后续511个值都不一样就可以有区分。19和18固然近但是19和17也一样近你不能说18和17对应的字是一样的吧。所以我们要想办法让这些一连串的数字增加一个维度让他们不再是一维坐标里的值。这时候就引用一个东西词嵌入在pytorch就是一行 nn.embedding(vocal,dim)内部结构呢其实就是查表。比如说现在就训练了10个词每个词4个维度。这时候5和6出现先把5和6变成onehot然后你去和矩阵相乘查表你会发现只有5号和6号的维度是起作用的其他都为0所以反向传播的时候之后5和6会被更新其他的不变。就那么简单。没那么多弯弯绕绕。1.3位置编码还是一样为啥要位置编码。类比卷积我们知道卷积具有平移不变性先卷左上角再卷了右下角结果绝对是左上角还是在右下角的上面偏左。这就是位置关系他不会因为你卷了谁位置关系变了。但是transformer没有它是注意力机制后面讲到注意力的时候我再详细的说运算过程。反正就是做完注意力的时候他们字和字之间相互计算相关性并不会考虑第一个字应该在第二个字左边或者先说出来他们只知道第二个字和第一个字有多少相关性。比如 爱你 这两个相关性0.8那反过来 你爱 相关性一样是0.8就这个意思。所以在计算注意力得分之前就要赋予位置信息。这边还是用正余弦编码以后讲llama再说旋转位置编码。位置编码就一个要求在 位置 和 维度 两个方向上不能重复。如下面的图横向的是维度纵向的是位置就是一句话的第几个次就那么简单。这个代码是去年实习的时候自己手敲的现在我自己都敲不出来了哈哈哈哈class Positionembedding(nn.Module): def __init__(self, len_max, emb_dim): super(Positionembedding, self).__init__() self.len_max len_max self.emb_dim emb_dim self.block torch.zeros(len_max, emb_dim) pos torch.arange(0, len_max).unsqueeze(1) # - (len_max, 1) emb_i torch.pow(1000, (torch.arange(0, emb_dim, 2) / emb_dim)).unsqueeze(0) # -(1, emb_dim) self.block[:, 0::2] torch.sin(pos / emb_i) self.block[:, 1::2] torch.cos(pos / (emb_i if emb_dim % 2 0 else emb_i[0, :-1])) def forward(self, x): x.shape - (b, len_q, emb_dim) len_q x.shape[-2] block self.block[0:len_q, :] return (x block)1.4 自注意力机制这个算重中之中了论文的核心。但是先别急就记住几句话然后我再细讲。1.计算注意力得分的时候没有任何的参数更新包括反向传播只是矩阵的信息聚合2.多头只是切割维度方向不增加额外的参数3.注意力机制前后张量维度不发生改变。4.在解码器中你的由于是一个字一个字的解码所有q决定字的长度可以随意变化不影响相关性的计算。就这三句话直接让你彻底明白注意力机制在干什么。首先对于参数qkv三个键的参数是在输入到注意力模块之前要经过一个线性层形成的三个qkv矩阵更新的是这个矩阵并非在计算相关性的时候有什么参数可以更新。然后qkv可以这样理解q是一个查询的人kv是一本书k是书的目录里面有大致内容然后q和k做相关性计算相关性高的就是我要找的内容然后再乘以v这样结果就得到了。从上面的图来看假如输入是一句话4个词每个词6个字。经过三个不同的nn.linear(6,6)就会得到qkv三个值。这个nn.linear(6,6)就是我们要更新的参数绿色部分后续那些变化和参数更新无关然后你对k进行转置和q相乘就是我说的q去查询k相关的部分得到一个4*4的矩阵然后再去和v相乘得到具体的值。就那么简单总结出几个规律1. 输入维度是什么输出维度是什么比如输入4*6输出就是4*62.k和q的相关性意思就是q去查询k纵坐标每一行代表一个字和上面一一对应3.q的句子长度可以变化如果q是3*6kv是4*6输出必然是3*6你自己验证一下按照上面的图1.5多头注意力机制这个更简单了纯矩阵的运算推导。你自己尝试一下把维度就是上面的4*6拆成两个4*3是不是输出还是两个4*3最后拼接在一起就变成了4*6啥意思呢就是你有几个头那就把dim分成几份。蓝色部分就重复几次最后把他们拼接在一起通过一个线性层就可以直接输出啦。它也是同样输入是什么维度输出就是什么维度head的多少不影响参数的变化head多可以捕捉到不同的相关性但是是啥就不确定了。比如我爱你这三个字第一个head捕捉到的是主谓宾第二个是出现概率反正就是巴拉巴拉。这时候你就说啊head越多不越好吗。那512个维度你除以512个头不又变成了最初说的问题低纬度很难表示复杂信息的关系。所以这个head的数量需要你自己探索多了也不好少了也不好。class MutilHeadAttention(nn.Module): 这里的自注意机制输入等于输出 def __init__(self, dim, dim_qNone, dim_vNone, num_heads 1, dropout0.1): super(MutilHeadAttention, self).__init__() dim_q dim if dim_q None else dim_q # 在这里三元表达式写错了 dim_v dim if dim_v None else dim_v self.dim dim self.dim_q dim_q self.dim_v dim_v self.num_heads num_heads # 一般来说KQ的dim输出是相同的,v根据任务调整 self.Linear_k nn.Linear(dim, dim_q) self.Linear_q nn.Linear(dim, dim_q) self.Linear_v nn.Linear(dim, dim_v) self.Dropout nn.Dropout(dropout) def forward(self, K, Q, V, maskNone): KQV -(B, len_q, emb_dim) len_K K.shape[-2] len_Q Q.shape[-2] # 这里必须是[],不能呢个() # (B, len_q, emb_dim) - (B, len_q, head, dim_q/head) -- (B, head, len_q, dim_q/head) K self.Linear_k(K).view(-1, len_K, self.num_heads, self.dim_q // self.num_heads).transpose(1, 2) # view必须整处 V self.Linear_v(V).view(-1, len_K, self.num_heads, self.dim_v // self.num_heads).transpose(1, 2) Q self.Linear_q(Q).view(-1, len_Q, self.num_heads, self.dim_q // self.num_heads).transpose(1, 2) # q求相似度(q*kt)(B, head, len_q, dim_q/head) - (B, head, len_q, len_k) atten torch.matmul(Q, K.transpose(-2, -1)) / (self.dim ** 0.5) if mask is not None: atten atten.transpose(0, 1).masked_fill(mask, float(-1e20)).transpose(0, 1) atten torch.softmax(atten, dim-1) atten self.Dropout(atten) # 使得softmax的得分部分为0 output torch.matmul(atten, V).transpose(1,2).contiguous() # (B, head, len_q, len_q) - (B, head, len_q, dim_q/head) _(B, len_q, head, dim_q/head) output output.view(-1, len_Q, self.dim_q) # (B, len_q, head, dim_q/head) - (B, len_q, dim_q) return output1.6掩码对于纯种transformer来说有三个地方会出现两种掩码。1.在编码器的自注意力机制中存在填充掩码编码器的填充2.在解码器的自注意力机制中存在因果掩码填充掩码推理过程没有填充掩码3.在解码器的交叉注意力机制中存在填充掩码编码器的填充我们一层一层来讲。填充掩码是什么意思呢上一篇文章中说了对于一个batch来说如果句子的长度不同我们就要对短的句子进行填充pad这个pad和文字之间可没有什么关系所以我们就要对他进行填充一个无穷小的数意思就是让他们之间不存在关系。因果掩码是什么东西呢就是在训练过程中transformer可不是一个字一个字的找相关性她是一整句话全部放进去然后再相关性计算的过程中让未来的词语进行遮蔽这样比如一句话5个字第一行是第一个字和自己做计算第二行是第二个字和第一个第二个字做计算以此类推一次性就会计算5次相关性。就像这样1和1做2和12做3和123做这样效率直接爆炸提升为啥推理不需要呢因推理过程中没有真实值我下一个字是需要我预测出来的训练过程下一个字是知道的所以要遮蔽。ok两种掩码说完了那就说说位置喽编码器因为一个batch全丢进去所以必须存在填充掩码。解码器的自注意力机制因为需要屏蔽未来值还需要屏蔽batch里面真值不同的长度的句子的pad所以两个填充都需要。交叉注意力机制它的q来自于解码器kv来自于编码器所以编码器自带的mask必须存在但是q就不需要了因为q需要去查询整个编码器编码的信息。注意在算法实现中有几个点1.传入模型里面的因果掩码一般是二维的。传入的填充掩码是一维的。想想看为什么呢。2.对于填充掩码其实只屏蔽列不屏蔽行。列是被查询者行是查询者。我的mask本身就确实可以看到所有信息为什么在运算中要屏蔽它。最后计算loss的时候在忽略不就行了所以在计算相关性得分的时候不屏蔽行。class mask_utils: def __init__(self, len_now, src_maskNone): self.len len_now self.len_now len_now.shape[1] self.src_mask src_mask def atten_mask(self): mask torch.triu(torch.ones(self.len_now, self.len_now), 1).bool() # 生成上三角,意思就是保留上部分及其中心线 下三角是torch.tril return mask def mask_padding(self, mask1, mask2): mask (mask1.unsqueeze(-1) * mask2.unsqueeze(1)).bool() mask ~mask return mask def mask_all(self): if self.src_mask None: enc_mask self.atten_mask() src_mask None cross_mask None return enc_mask, src_mask, cross_mask if self.src_mask ! None: enc_mask self.atten_mask() src_mask self.mask_padding(self.src_mask, self.src_mask) cross_mask self.mask_padding(self.len, self.src_mask) return src_mask, enc_mask, cross_mask1.7前馈神经网络和层归一化这个都没啥讲的我觉得前馈神经网络ffn就是一个线性层。就这样层归一化还是说一下吧。我们之前在分类网络中都知道需要归一化分类的归一化啥意思呢。它其实是让每一层进行归一化比如RGB三个通道R通道所在在h*w进行归一化。。。这样是不是每一个通道所在的层都是在0-1之间喽。那放在文本特征来说可以做到吗应该不行因为文本没有通道这个概念。所以我们就要在特征维度进行归一化就更加合理。比如3个字每个字512个特征那这个单独的字512维度上进行归一化把它归一化到0-1不就行了。这就是层归一化。class FeedForward(nn.Module): def __init__(self, dim, hidden_dim, dropout0.5): super(FeedForward, self).__init__() self.dim dim self.hidden_dim hidden_dim self.Dropout nn.Dropout(dropout) self.Linear_in nn.Linear(dim, hidden_dim) self.Linear_out nn.Linear(hidden_dim, dim) self.act nn.ReLU() def forward(self, x): x self.act(self.Linear_in(x)) x self.Dropout(x) x self.Linear_out(x) return x2.实战实战!!! 未完待续...