什么是余弦相似度
余弦相似度就是两个向量之间的夹角的余弦值 有的地方也叫余弦距离, 夹角为0时距离为1,夹角为90度时,距离为0
余弦相似度的用途
余弦相似度可以衡量两个向量之间的差异, 就是比一比谁离谁更近,谁又离谁更远 不知你有没有这样的疑问? 向量的核心要素除了方向,不是还有大小吗? 怎么比个方向就说两个向量近似了? 这是因为此问的背景是所有向量的模已经近似/接近于1, 至少是大致相等/相近 夹角之间的差异就是两个向量之间的主要差异 如果是你想更精确一些,可以在比较之前将所有向量再次约束一次,将其模归为1
向量内积在AI中的地位
向量之间的运算,较常用的有两个: 线性运算:按位 加, 减, 数乘 内积运算:接位 相乘 再 相加 要不断尝试使用矩阵去描述及解决问题(此句的重要性属于一个学科总纲级的...), 而矩阵运算实际上就是一系列的向量内积
从向量的角度看矩阵
这里特指2维矩阵,AI最常用的也是这个, 三维矩阵实际上有一维是批次,就是多个2维矩阵 2维矩阵常说行与列, 行与列是人的视角上的一个描述, 如果写在纸上,你能看到一组类似长方形/正方形的一组数, 实际对于一组数据来说, 它们本没有什么行与列的概念,更不需要排列成什么形状, 它们通常用第m维,第n维这样纯数字的描述 对于一个矩阵来说,行与列都是它的一个维度,无区别 但对业务来说,这个数据是有行与列的差异的 我们将业务进行数字化,矩阵化之后,对应的行与列就有不同的含义 此时,用向量/向量组来描述更为合适
从向量的角度看矩阵举例
一个单词 用一个索引来表示 一句话n个单词就是一个n维向量,记作seq_len 多句话时,就是多个向量,形成一个向量组, 向量组比矩阵有更强的方向性, 更能突显 一句话对应一个向量 这个业务含义 向量组个数这个维度记作batch_size 通常情况下,批次放dim=0的维度,即[batch_size, seq_len]=A 这就是关系型数据库的表的结构 但对于一个矩阵来说,每个维度都只是一组数据而已 所以将批次放第2维,依然能有相同的含义[seq_len,batch_size]=B A[0,:]表示取一句话,B[:,0]也是取一句话 A与B都看作 每个向量维度为seq_len的向量组 A中用 行向量 表示一句话 B中用 列向量 表示一句话 后续,看具有业务含义的矩阵, 都从 向量/向量组 的角度去看, 因为业务通常要求矩阵有批次的维度 要习惯用A[0,:]取一行,而不是A[0],尽管它们取出的数据是一样的 A[0,:]在形式上就可以提醒你,dim=0的维度 取一,剩下所有维度全取
向量内积更深一层的含义
向量内积:按位 相乘 再 相加 首先,将一个事物的整体看作 1,就是我们先有了全集,将全集看作1 全集中所有的子集,事物 都小于1,是1的一部分 如果用两个圆圈表示A与B两个事物,那么重叠的部分就是交集, 类似于/近似于/等价于 两个事物相乘的结果 这个结果即属于A也属于B,是事物之间的共性 可存在这种可能,两个圆圈没有交集, 那么这样的两个圈圈对应的两个矩阵相乘的结果就是0 即乘法可以提取事物之间的共性,这不就是AI需要的吗? 将不同维度得到的共性结果 相加,就得到了两个向量之间共性的一个映射
如果一个规律足够底层,那么它在很多方面都能得到体现, 回忆欧氏距离,是自己与自己相乘再相加, 然后开方就得到了一个n维向量的模 这就是该向量的距离/长度 回忆方差计算,每个元素的误差=每个元素的值-均值, 这个误差自己跟自己相乘再相加就是整个分布的误差 回忆协方差,是两个分布之间的误差相乘再相加,再除以两个分布的模 它们都有一个共性的内核: 在各个对应的维度上 相乘,然后将不同的维度 相加起来 这不就是向量内积吗?
从解析几何的角度看向量内积, a·b = |a|·|b|·夹角余弦, 就是一个向量投影到另外一个向量上,然后再相乘 谁投影到谁上面无所谓,因为结果一样, 因为向量内积算的是共性,是交融后的结果 扯的有些远了,回收一下注意力, 这里只是为了说明向量内积可以提取特征(实际上是共性), 理论归理论, 关键是,实践也验证了,向量内积的确可以提取特征 这才是我们相信 向量内积可以提取特征 的主要原因
求矩阵A相对矩阵B的注意力
1. 矩阵A与矩阵B做 点乘,得交集矩阵C 2. 对矩阵C做softmax求 得分矩阵D 3. 得分矩阵D@矩阵A = 矩阵A相对B的上下文向量,也就是注意力
序列到序列的注意力计算
hidden.shape = [1,batch_size,hidden_size] encoder_outputs.shape = [seq_len,batch_size,hidden_size] 点乘,按位相乘再让特征相加,特征维度消失 [1,批次,特征]*[序列,批次,特征]= [序列,批次] attn_energies = torch.sum(hidden * encoder_output, dim=2) # [sel_len,batch_size] -- [batch_size,sel_len] attn_energies = attn_energies.t() # 按seq_len维度转为概率,[batch_size,seq_len] # [batch_size,seq_len] -- [batch_size, 1, seq_len] 添加这个1是为了后面进行bmm attn_weights = F.softmax(attn_energies, dim=1).unsqueeze(1) # context.shape = [batch_size,1,hidden_size] # decoder中每个单词对应一个encoder的上下文向量 # 加权平均,[1, seq_len] sum(dim=1)=1,注意力的得分之和为1,将这个得分乘到原来解码器上 context = attn_weights.bmm(encoder_outputs.transpose(0, 1))
注意力计算通俗举例
注意力指事物联系中的关键点,它通常有一个语境/提前 seq1: 老板 ,这 馒头 多少 钱 一个 ? seq2: 1块 seq1分词后得到8个词,seq2分词后得到1个词, 现在求seq1相对seq2的注意力 seq1中的每个单词与seq2的注意力都是 一个值, 两个向量进行内积运算,按位相乘再相加,得到一个值 seq1有8个词,批次计算后,seq1的shape为[8,1], 其实就是[8],一个列表8个值,设此列表为A 这里有一个大提前,文本向量化后的数据皆在[0,1]之间,或者[-1,1]之间 负号是数的一个特殊维度/方向, 若将事物的全体定为1,那么两个事物相乘就是其交集, 是两个事物共同的部分 而向量内积就是二者在各个维度上交集的和 将一个事物在各个维度上的交集强行投影到一维上,然后相加 所以,此处矩阵相乘得到的就是seq1与seq2的交集 我们的目标是求注意力, 是要看看seq1中各个单词到底哪个单词相对seq2是关键单词 孰轻孰重,若轻则轻多少,若重则重多少 要看它们之间的相对差异,用数字清晰地体现出来 差异本身是多少,不重要 重要的是,相对来说差多少 百分比的概念呼之欲出... 全体为100%,我若在你心中最重的话,那么就没有其他事物比我更重 这是其思想,极其重要, softmax是实现这种思想的“其中一种”方法 列表A做softmax后,得到 分数列表 分数列表再与单词列表,矩阵相乘,得到seq1相对seq2的上下文向量, 这就是注意力
QKV的通俗理解
求 事物A 相对 事物B 的注意力 Q Query,某个向量/矩阵,就是事物A K与V是事物B本身,或者事物B的一个线性变换结果, K与V可以一样,也可以不一样, K Key, V value,就是一对的意思, Q与K相乘再经过softmax--> 得分矩阵 得分矩阵.bmm(V) --> 注意力 这个注意力就是序列到序列中的上下文向量,一个样本对应一个向量
序列到序列的上下文向量
这里以序列到序列为背景,比如: 十一 放假 准备 去哪 玩? -- 不 出去 了 编码序列有一个上下文,涉及 旅游 这个主题, 但序列到序列中的上下文向量, 是 编码序列相对于 解码器的每个单词都有一个 上下文向量 这里,解码器有三个单词,那么就有三个上下文向量, 这三个上下文向量分别与解码器的三个单词拼接, 形成包含前置序列上下文的 新的向量 对编码器只是提取特征,解码器,才是真正推理的开始, 当前单词+由注意力机制提取的上下文向量 推理出 下一个单词, 求编码器相对下一个单词的注意力,再推理下下个单词 这样一层层推理下去 所得的上下文向量中,都有 旅游 这个主题,只是比重不一样
主体步骤
简化一下,先不说批次的维度, 将单词特征维度拆分成四份,这里 seq_len1==seq_len2,为了 视觉区分 才写成不一样的 编码序列:[seq_len1,embedding_dim] -- [4, seq_len1, embedding_dim/4 ] 解码序列:[seq_len2,embedding_dim] -- [4, seq_len2, embedding_dim/4 ] 每一小份特征与对应的一小份特征计算,相同段位进行计算,不跨段 [4, seq_len2, embedding_dim/4 ] @ [4, embedding_dim/4, seq_len1, ] -- [4, seq_len2, seq_len1] [4, seq_len2, seq_len1] 首先记住,是一个单词四分, 解码序列有seq_len2个单词,每个单词的四分之一相对编码序列的seq_len1个单词的对应的四分之一都有一个数值 [4, seq_len2, seq_len1] 做softmax 再乘回去 [4, seq_len2, seq_len1] @ [4, seq_len1, embedding_dim/4 ] -- [4, seq_len2, embedding_dim/4 ] 维度再变换回来,解码序列的每个单词相对编码序列都有一个上下文向量 [4, seq_len2, embedding_dim/4 ] -- 最终的[seq_len2, embedding_dim] 短接,这一步提供了多层深度计算的可能 最终的[seq_len2, embedding_dim] + 最初的编码序列[seq_len2,embedding_dim] 多头注意力与序列注意力shape变换的方式不一样,各有其巧妙之处
序列到序列简单示例,以此为背景求解自注意力
import jieba seq1= "你现阶段的目标是?" seq2= "不上班还有钱花" sentence1 = jieba.lcut(seq1) word2id = {} word2id["PAD"] = 0 # 补长度 是为了批次处理 len_add = len(word2id) word2id.update({word:(index+len_add) for index,word in enumerate(set(sentence1))}) sentence2 = jieba.lcut(seq2) len_add = len(word2id) word2id.update({word:(index+len_add) for index,word in enumerate(set(sentence2))})
sentence1 ['你', '现阶段', '的', '目标', '是', '?'] sentence2 ['不', '上班', '还有', '钱花'] word2id {'PAD': 0, '是': 1, '目标': 2, '现阶段': 3, '?': 4, '你': 5, '的': 6, '上班': 7, '还有': 8, '不': 9, '钱花': 10}
句子有长也有短,因此要补0
sentence = [] sentence.append([ word2id[word] for word in sentence1]) sentence.append([ word2id[word] for word in sentence2]) sentence [[5, 3, 6, 2, 1, 4], [9, 7, 8, 10]] max_seq_len = 6 index_mat = [] for word_index in sentence: word_index = word_index+[word2id["PAD"]]*max_seq_len word_index = word_index[:6] index_mat.append(word_index) index_mat [[5, 3, 6, 2, 1, 4], [9, 7, 8, 10, 0, 0]] 因为计算时皆以矩阵计算,而矩阵要求各维度上的元素个数一致,所以要补齐
目标及转化
#求sentence2,即[9, 7, 8, 10, 0, 0]对自己的注意力 sentence2 ['不', '上班', '还有', '钱花'] 该目标等价于 求向量A[9, 7, 8, 10, 0, 0]相对于向量B[9, 7, 8, 10, 0, 0]的注意力 等价于 求向量A中每个元素相对于向量B所有元素的注意力,即 9相对向量B的注意力,结果是维度为seq_len的特征向量 7相对向量B的注意力,结果是维度为seq_len的特征向量 8相对向量B的注意力,... 10相对向量B的注意力,... 0相对向量B的注意力,... 0相对向量B的注意力,... 每次计算都得到一个维度为seq_len的特征向量,最终结果是一个[seq_len,seq_len]的矩阵
补码:设置单词相对补码的注意力为0
索引向量[9, 7, 8, 10, 0, 0]对应['不', '上班', '还有', '钱花'] 最后两个0是补的,凑数的,有意义的只有前四个单词, 于是标记最后两个单词无意义,补码为[0,0,0,0,1,1] 对于每个单词,或者是每个索引都是如此, 对于9,补码是 [0,0,0,0,1,1] 对于7,补码是 [0,0,0,0,1,1] 对于8,补码是 [0,0,0,0,1,1] 对于10,补码是 [0,0,0,0,1,1] 对于0,补码是 [0,0,0,0,1,1] 对于0,补码是 [0,0,0,0,1,1] 所以,补码矩阵也是一个[seq_len,seq_len]矩阵, 等价于将一个长度为seq_len的补码向量复制seq_len份
向量内积,先扩后减的维度变换
9相对向量B的注意力,结果是维度为seq_len的特征向量 这个细节如何实现? 先将一个数值转化为一个向量,比如,这里只是比如一下,这一部分内容实际是文本向量化,也是个大课题 9转化为[0.1,0.2,0.3],那么数字9对数字9求注意力, 就转化为向量a[0.1,0.2,0.3]与向量b[0.1,0.2,0.3]的向量内积 9对9,等价于向量a[0.1,0.2,0.3]@向量b[0.1,0.2,0.3],得到的是一个数值 整个维度的变换是: 先将一个一维的数值扩展到n维,再通过向量内积化为一维 总共进行了seq_len*seq_len次向量内积运算,得到了一个[seq_len,seq_len]矩阵
补码是补在得分矩阵上
先将一个一维的数值扩展到n维,再通过向量内积化为一维 总共进行了seq_len*seq_len次向量内积运算,得到了一个[seq_len2,seq_len1]矩阵 这个矩阵就是得分矩阵 seq_len2:解码序列的各个单词, seq_len1:解码序列每个单词相对 长度为seq_len1的编码序列 的seq_len1维特征向量 索引向量[9, 7, 8, 10, 0, 0]对应['不', '上班', '还有', '钱花'] 最后两个0是补的,凑数的,有意义的只有前四个单词, 于是标记最后两个单词无意义,补码为[0,0,0,0,1,1] 按补码矩阵对得分矩阵做一次softmax, 将标记为1即补码的索引位置上的数据转化为0, 使之更贴近业务场景...
仅取得分矩阵中,单词维度上非补码的元素,这体现了位置的重要性 补码位置的元素置为0 对于补码来说,由于其所在位置的原因,就算由于某些原因,得到一些,最终也将被清零...
多头注意力的得分矩阵:单词维度是4的整倍数
一个单词32维,分成4个头/部分,每个部分8维 每个8维都是某个单词的一部分,其得分矩阵的shape都是[seq_len2,seq_len1] 当这个得分矩阵再次与编码序列相乘的时候,变换的结果就是 [4,seq_len2,seq_len1]@[4,seq_len1, 8] = [4,seq_len2,8] 如此一变换,解码矩阵的每个单词都得到了一个相对编码序列的长度为32的上下文向量 [4,seq_len2,8].shape = [1,seq_len2,32]
代码片段展示...
import torch torch.manual_seed(73) batch_size=2 head_n1=4 seq_len1=5 seq_len2=5 score = torch.randn(batch_size,head_n1,5,5) mask = torch.randint(low=0,high=2,size=(batch_size,1,1,5)) # [5,5]第二个5指一句话有5个词,第一个5指这句话被复制了5份 mask = mask.expand(batch_size,1,5,5) mask tensor([[[[0, 1, 0, 1, 1], [0, 1, 0, 1, 1], [0, 1, 0, 1, 1], [0, 1, 0, 1, 1], [0, 1, 0, 1, 1]]], [[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]]]) #按照mask为1/true位置选出score中的元素,并使用负无穷填充 # mask = [b, 1, 5, 5],[5,5]第二个5指一句话有5个词,第一个5指这句话被复制了5份 score = score.masked_fill_(mask, -float('inf')) #对score的倒数第一维,即最后一维做softmax score = torch.softmax(score, dim=-1) score.shape torch.Size([2, 4, 5, 5])
原矩阵A -- 自乘 -- 得分矩阵 -- 按mask做softmax(注意力) -- 左乘原矩阵A -- 上下文向量(特征) 短接: 原矩阵A + 注意力提取的特征(上下文向量) 这才是整个一体化的注意力提取特征,每计算一轮下来,原矩阵会在已有的基础上改变一些 同时也不会导致梯度消失,可以增加层数,也这是transformer可以有很多层的原因
索引矩阵生成
import jieba seq1= "你现阶段的目标是?" seq2= "不上班还有钱花" sentence1 = jieba.lcut(seq1) word2id = {} word2id["PAD"] = 0 # 补长度 是为了批次处理 len_add = len(word2id) word2id.update({word:(index+len_add) for index,word in enumerate(set(sentence1))}) sentence2 = jieba.lcut(seq2) len_add = len(word2id) word2id.update({word:(index+len_add) for index,word in enumerate(set(sentence2))}) sentence = [] sentence.append([ word2id[word] for word in sentence1]) sentence.append([ word2id[word] for word in sentence2]) max_seq_len = 6 index_mat = [] for word_index in sentence: word_index = word_index+[word2id["PAD"]]*max_seq_len word_index = word_index[:6] index_mat.append(word_index) print(index_mat)
使用多头注意力提取特征
from tpf.att import Attn from tpf.att import MultiHead import torch from torch import nn from tpf.nlp.mask import mask_pad #索引编码并转换shape为[batch_size,seq_len] B = torch.tensor(index_mat[1]).unsqueeze(dim=0) #B.shape = [1, 6] #对索引矩阵进行补码 mask = mask_pad(B,padding_index=0) #mask.shape = [1, 1, 6, 6] #索引向量化 embed = nn.Embedding(num_embeddings=11,embedding_dim=32,padding_idx=0) B = embed(B) #B.shape = torch.Size([1, 6, 32]) #求B相对自己的注意力 A=B #注意力最后会短接,因此输入与输出的特征维数不变 mh = MultiHead(features=32) c = mh(B,A,A,mask) c.shape
本来...本来提取特征的工作是由卷积/RNN完成了,现在换由注意力提取 注意力提取特征,全连接+激活函数进一步处理,作为神经网络的一层 这个全连接还加了norm,对每个元素,这里是每个单词做一次归一,有加速收敛,防止梯度爆炸的作用
import torch from torch import nn from tpf.att import MultiHead # 全连接输出层 class FullyConnectedOutput(torch.nn.Module): """ 使用短接进行微调 - 特征数先变大再变小 - 并且是变回原来的大小,这样才能使用短接相加 """ def __init__(self,features=32): super().__init__() self.fc = torch.nn.Sequential( torch.nn.Linear(in_features=features, out_features=features*4), torch.nn.ReLU(), torch.nn.Linear(in_features=features*4, out_features=features), torch.nn.Dropout(p=0.1) ) self.norm = torch.nn.LayerNorm(normalized_shape=features, elementwise_affine=True) def forward(self, x): # 保留下原始的x,后面要做短接用 clone_x = x.clone() # 单词维度归一化 x = self.norm(x) # 线性全连接运算 # [b, seq_len, feature_nums] -> [b, seq_len, feature_nums] out = self.fc(x) # 做短接 out = clone_x + out return out # 编码器层 class EncoderLayer(nn.Module): """ - features:数据的特征维数 - 编码层是求自注意力 """ def __init__(self,features=32): super().__init__() # 多头注意力 self.mh = MultiHead(features=features) # 全连接输出 self.fc = FullyConnectedOutput(features=features) def forward(self, x, mask): # 计算自注意力,维数不变 # [b, seq_len, feature_nums] -> [b, seq_len, feature_nums] score = self.mh(x, x, x, mask) # 全连接输出,维数不变 # [b, seq_len, feature_nums] -> [b, seq_len, feature_nums] out = self.fc(score) return out
class Encoder(torch.nn.Module): def __init__(self,features=32): super().__init__() self.layer_1 = EncoderLayer(features=features) self.layer_2 = EncoderLayer(features=features) self.layer_3 = EncoderLayer(features=features) def forward(self, x, mask): x = self.layer_1(x, mask) x = self.layer_2(x, mask) x = self.layer_3(x, mask) return x
补码回顾
这一点容易让人迷糊,所以本网站从不同的角度反复强调... 以“不 上班 0”求自注意力为例,0是补的索引/元素值 不 对应向量a1 上班 对应向量a2 0 对应向量a3 [a1,a2,a3]对[a1,a2,a3]求自注意力 实际的运算是 a1@a1 = 数值b1,代表 不 对应 不 的交融结果 a1@a2 = 数值b2,代表 不 对应 上班 的交融结果 a1@a3 = 数值b3,代表 不 对应 补0 的交融结果 b3是没有意义的,要使用补码将其舍弃 [b1,b2,b3]中按[0,0,1]中选出1的元素,并将其替换为负无穷 然后做softmax,负无穷变为0 消失的是特征维度,保留的是各自业务中单词序列的维度 [单词序列2,特征维度]@[特征维度,单词序列1] = [单词序列2,单词序列1] [单词序列2,单词序列1] 序列2的每个单词相对 序列1 都有一个数值b, 使用补码+softmax将 补码0索引位置的数值/元素 转换为0 这就是补码矩阵的作用, 针对 两个序列 单词之间重要性的一个矩阵,没有单词的索引位置,重要性为0 整合代码如下:
文本索引化
import jieba seq1= "你现阶段的目标是?" seq2= "不上班还有钱花" sentence1 = jieba.lcut(seq1) word2id = {} word2id["PAD"] = 0 # 补长度 是为了批次处理 len_add = len(word2id) word2id.update({word:(index+len_add) for index,word in enumerate(set(sentence1))}) sentence2 = jieba.lcut(seq2) len_add = len(word2id) word2id.update({word:(index+len_add) for index,word in enumerate(set(sentence2))}) sentence = [] sentence.append([ word2id[word] for word in sentence1]) sentence.append([ word2id[word] for word in sentence2]) max_seq_len = 6 index_mat = [] for word_index in sentence: word_index = word_index+[word2id["PAD"]]*max_seq_len word_index = word_index[:6] index_mat.append(word_index) print(index_mat)
索引向量化
from tpf.att import Attn from tpf.att import MultiHead import torch from torch import nn from tpf.nlp.mask import mask_pad #索引编码并转换shape为[batch_size,seq_len] B = torch.tensor(index_mat[1]).unsqueeze(dim=0) #B.shape = [1, 6] #对索引矩阵进行补码 mask = mask_pad(B,padding_index=0) #mask.shape = [1, 1, 6, 6] #索引向量化 embed = nn.Embedding(num_embeddings=11,embedding_dim=32,padding_idx=0) B = embed(B) #B.shape = torch.Size([1, 6, 32])
编码层:特征维度到特征维度的变换
from tpf.mapping import Encoder #提取特征 encoder = Encoder(features=B.shape[2]) encoder(B,mask)