余弦相似度

什么是余弦相似度

 
余弦相似度就是两个向量之间的夹角的余弦值
有的地方也叫余弦距离,
夹角为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

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变换的方式不一样,各有其巧妙之处 

多头注意力+mask

序列到序列简单示例,以此为背景求解自注意力

 
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)

参考