深度学习进阶自然语言处理

2. 自然语言和单词的分布式表示

单词含义的表示方法有3种

  • 基于同义词词典的方法
  • 基于计数的方法
  • 基于推理的方法

2.2 同义词词典

p61

难以顺应时代变化,人力成本高,无法表示单词的微妙差异

2.3 基于计数的方法

p63

这里需要使用语料库(corpus)

2.3.1 基于python的语料库的预处理

p63

text  = 'You say goodbye and I say hello.'
text = text.lower()
text = text.replace('.',' .')
text # 'you say goodbye and i say hello .'

words = text.split(' ')
words # ['you', 'say', 'goodbye', 'and', 'i', 'say', 'hello', '.']

现在单词拿到了,但是直接拿文本操作,还是有些不方便,我们需要给单词标上ID

word_to_id = {}
id_to_word = {}

for word in words:
    if word not in word_to_id:
        new_id = len(word_to_id)
        word_to_id[word] = new_id
        id_to_word[new_id] = word
        
print(word_to_id)
print(id_to_word)

#{'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}
#{0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}

将单词列表转换我单词ID列表

import numpy as np
corpus = [word_to_id[w] for w in words]
corpus = np.array(corpus)
corpus
# array([0, 1, 2, 3, 4, 1, 5, 6])

封装成一个函数

text  = 'You say goodbye and I say hello.'
def preprocess(text):
    text = text.lower()
    text = text.replace('.', ' .')
    words = text.split(' ')
    
    word_to_id = {}
    id_to_word = {}
    for word in words:
        if word not in word_to_id:
            new_id = len(word_to_id)
            word_to_id[word] = new_id
            id_to_word[new_id] = word
    
    corpus = np.array([word_to_id[w] for w in words])
    
    return corpus,word_to_id,id_to_word

corpus,word_to_id,id_to_word = preprocess(text)
print(corpus) # [0 1 2 3 4 1 5 6]
print(word_to_id) # {'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}
print(id_to_word) # {0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}

2.3.4 共现矩阵

p69

基于分布式假设(用向量表示单词p67)表示单词,最直接的实现方法是对其周围的单词的数量进行计数,然后汇总。这里设窗口为1,you旁边为1的窗口,只有say这个单词,设其为1,其余的词为0,就有you的one-hot向量[0,1,0,0,0,0]

image-20220918180005378

同样把say旁边的单词表示出来

image-20220918184818115

把剩下的单词表示出来,这个就叫做共现矩阵

image-20220918185038839

我们创建一个可以根据语料库生成(p72)

def create_co_matrix(corpus,vocab_size,window_size=1):
    corpus_size = len(corpus)
    co_matrix = np.zeros((vocab_size,vocab_size),dtype=np.int32)
    
    for idx,word_id in enumerate(corpus):
        for i in range(1,window_size+1):
            left_idx = idx - i
            right_idx = idx + i 
            if left_idx >= 0:
                left_word_id = corpus[left_idx]
                co_matrix[word_id,left_word_id] += 1
                
            if right_idx < corpus_size:
                right_word_id = corpus[right_idx]
                co_matrix[word_id,right_word_id] += 1
    return matrix

2.3.5 向量间的相似度

p72

余弦相似度是最常用的,表示了“两个向量在多大程度上指向同一方向”,当两个向量完全指向相同的方向时,余弦相似度为1,完全指向相反的方向,余弦相似度指向-1

\[similarity(x,y)=\frac{x\cdot y}{\left \| x \right \| \left \| y\right \| } =\frac{x_{1}y_{1}+\cdots +x_{n}y_{n} }{\sqrt{x_{1}^{2}+\cdots+x_{n}^{2} }\sqrt{y_{1}^{2}+\cdots+y_{n}^{2} } } \]

其中||x||表示范数,这里求的是L2范数,首先正则化,然后求它们的内积

代码实现(p73)

def cos_similarity(x,y):
    nx = x / np.sqrt(np.sum(x**2))
    ny = y / np.sqrt(np.sum(y**2))
    return np.dot(nx,ny)

# 改进版,防止除0

def cos_similarity(x,y,eps=1e-8):
    nx = x / (np.sqrt(np.sum(x**2)) + eps)
    ny = y / (np.sqrt(np.sum(y**2)) + eps)
    return np.dot(nx,ny)

利用这个函数,可以求得单词向量间的相似度,这里我们求you和i的相似度

text  = 'You say goodbye and I say hello.'
corpus,word_to_id,id_to_word = preprocess(text)
vocab_size = len(word_to_id)
C = create_co_matrix(corpus,vocab_size)

c0 = C[word_to_id['you']]
c1 = C[word_to_id['i']]
print(cos_similarity(c0,c1))

# 0.7071067691154799

2.3.6 相似单词的排序

p74

def most_similiar(query,word_to_id,id_to_word,word_matrix,top=5):
    # 取出查询词
    if query not in word_to_id:
        print('%s is not found' % query)
        return
    print('\n[query] ' + query)
    query_id = word_to_id[query]
    query_vec = word_matrix[query_id]
    
    # 计算余弦相似度
    vocab_size = len(id_to_word)
    similarity = np.zeros(vocab_size)
    for i in range(vocab_size):
        similarity[i] = cos_similarity(word_matrix[i],query_vec)
        
    
    # 基于余弦相似度,按降序输出值
    count = 0
    for i in (-1 * similarity).argsort():
        # 与自己做余弦相似度时,跳过
        if id_to_word[i] == query:
            continue
        print(' %s: %s' % (id_to_word[i],similarity[i]))
        
        count += 1
        if count >= top:
            return
  • argsort()
x = np.array([100,-20,2])
x.argsort()
# array([1, 2, 0], dtype=int64)
(-x).argsort() # 负号表示反着来,就是降序
# array([0, 2, 1], dtype=int64)

显示和you最为相近的单词p76

import sys
sys.path.append('..')
from common.util import preprocess,create_co_matrix,most_similar

text = "you say goodbye and i say hello."
corpus,word_to_id,id_to_word = preprocess(text)

corpus
# array([0, 1, 2, 3, 4, 1, 5, 6])
word_to_id
#{'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}
id_to_word
#{0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}

vocab_size = len(word_to_id)
C = create_co_matrix(corpus,vocab_size)

most_similar('you',word_to_id,id_to_word,C,top=5)

image-20220919144646773

可以看出you和hello和goodbye和i最为接近

2.4 基于计数的方法的改进

2.4.1 点互信息

p77

通常我们会看到the car这样的短语,thecar共现的次数很大。但是并不意味着thecar的关联度就很高,drive相比the具有更高的关联度。但是如果只看短语出现的次数来认定它们之间的关联度就不太恰当,为了解决这个问题,可以用点互信息(PMI)。定义如下:

\[PMI(x,y) = log_{2}\frac{P(x,y)}{P(x)P(y)} \]

P(x,y)是联合概率,也可以写作P(x∩y)或者P(xy)

image-20220908214612454

P(x),P(y)分别表示x和y发生的概率

PMI的值越高,表示相关性越高,这里举个例子:

语料库中一共有10000个词,the出现的次数是100,那么p("the")=\(\frac{100}{10000}=0.01\),假设thecar出现的次数是10,那么p("the","car") = \(\frac{10}{10000}=0.001\)

现在,使用共现矩阵C,来表示PMI,单词x和y共现次数表示为C(x,y),将x和y出现的次数分别表示为C(x),C(y)。带入PMI式子中得:

\[PMI(x,y) = log_{2}\frac{P(x,y)}{P(x)P(y)}=log_{2}\frac{\frac{C(x,y)}{N}}{\frac{C(x)}{N}\frac{C(y)}{N}}=log_{2}\frac{C(x,y) \cdot N}{C(x)C(y)} \]

这里假设语料库(corpus)的数量(N)为10000,the出现1000次,car出现20次,drive出现10次,the和car共现10次car和drive共现5次。如果只从共现的次数来看,the和car的相关性更高。如果从PMI的角度来看,就不一样

\[PMI("the","car") = log_{2}\frac{10 \cdot 10000}{1000 \cdot 20} \approx 2.32 \]

\[PMI("car","drive") = log_{2}\frac{5 \cdot 10000}{20 \cdot 10} \approx 7.97 \]

car和drive的相关性就比the高,但是有个问题是如果两个单词共现次数是0时,log20 = -∞。为了解决这个问题,我们会使用正的点互信息(PPMI)

\[PPMI(x,y) = max(0,PMI(x,y)) \]

代码实现

p79

def ppmi(C,verbose=False,eps=1e-8):
    # 创建一个和共现矩阵形状一样的全0矩阵
    M = np.zeros_like(C,dtype=np.float32)
    N = np.sum(C)
	print(N) # 14 N是共现矩阵的值
    S = np.sum(C,axis=0)
    total = C.shape[0] * C.shape[1]
    cnt = 0
    
    for i in range(C.shape[0]):
        for j in range(C.shape[1]):
            pmi = np.log2(C[i,j] * N / (S[j]*S[i]) + eps)
            M[i,j] = max(0,pmi)
            
            if verbose:
                cnt += 1
                if cnt % (total//100+1) == 0:
                    print('%.1f%% done' % (100*cnt/total))
    
    return M
  • C

C是共现矩阵

  • verbose

决定是否输出运行情况的标志,处理大语料库时,设置verbose=True

  • C(x),C(y),C(x,y)

C(x) = \(\sum_{i}C(i,x)\),C(y) = \(\sum_{i}C(i,y)\),N = \(\sum_{i}\sum_{j}C(i,j)\)


import numpy as np
from common.util import preprocess,create_co_matrix,cos_similarity

text = "you say goodbye and i say hello."
corpus,word_to_id,id_to_word = preprocess(text)
vocab_size = len(word_to_id)
C = create_co_matrix(corpus,vocab_size)
W = ppmi(C)

np.set_printoptions(precision=3) # 有效位数为3位
print('covariance matrix')
print(C)
print('-'*50)
print('PPMI')
print(W)

image-20220919165838146

但是,PPMI还有问题是,PPMI矩阵会随着语料库的词汇量增加,单词向量的维数同样增加

2.4.2 降维

p81

减少维度,不是简单的减少,而是尽量保留“重要信息"的基础上减少。我们要观察数据的分布,从而找到重要的”轴“image-20220919170557733

选择新轴是,需要考虑数据的广度,这样就可以用一维的值也能捕获数据的本质差异,在多维数据中,也可以进行同样的处理。

稀疏矩阵中,要找出重要的轴,用更少的维度对其进行重新表示。结果,稀疏矩阵就会被转化成大多数元素均不为0的密集矩阵。这个密集矩阵就是单词的分布式表示。

降维的方法有很多,这里使用SVD(奇异值分解),将任何矩阵分解为3个矩阵的乘积。

\[X = USV^{T} \]

SVD将任意的矩阵X分解为U、S、V这个3个矩阵的乘积,其中U和V是列向量相互正交的正交矩阵,S是除了对角线元素以外均为0的对角矩阵

image-20220919182225486

U是正交矩阵。这个正交矩阵构成了一些空间中的基轴(基向量),把U称为“单词空间”。S是对角矩阵,奇异值在对角线上降序排列。简单说,可以将奇异值视为“对应的基轴”

  • 基向量

基向量是这个向量空间里最最基本的组成部分【因为基向量必定线性无关】,由这部分可以表示该向量空间的每一个向量,不就是母体嘛

  • 奇异值

可以理解奇异值为矩阵中重要信息,其重要程度与奇异值大小息息相关

奇异值的物理意义是什么? - 知乎 (zhihu.com)

image-20220919184830105

矩阵的S的奇异值越小,对应的基轴的重要性越低,因此,可以通过去出矩阵U中多余的列向量来近似原始矩阵(如果饶话,可以看这个链接奇异值的物理意义是什么? - 知乎 (zhihu.com),看为什么图片越来越清晰)

2.4.3 基于SVD的降维

p84

import numpy as np
import matplotlib.pyplot as plt
from common.util import preprocess,create_co_matrix,ppmi

text  = 'You say goodbye and I say hello.'
corpus,word_to_id,id_to_word = preprocess(text)
vocab_size = len(id_to_word)
# 共现矩阵
C = create_co_matrix(corpus,vocab_size,window_size=1)
W = ppmi(C)

# SVD
U,S,V = np.linalg.svd(W)

共现矩阵

print(C[0])
# [0 1 0 0 0 0 0]

ppmi矩阵

print(W[0]) 
# [0.        1.8073549 0.        0.        0.        0.        0.       ]

SVD

print(U[0])
# [-1.1102230e-16  3.4094876e-01 -1.2051624e-01 -3.8857806e-16
#  0.0000000e+00 -9.3232495e-01  8.7683712e-17]

稀疏向量W[0]经过SVD被转换成了密集向量U[0],如果要对这个密集向量降维,比如把它降维到二维向量,取出前两个元素即可

print(U[0,:2])
# [-1.1102230e-16  3.4094876e-01]

这样我们完成了降维,现在,我们用二维向量表示各个单词

for word,word_id in word_to_id.items():
    plt.annotate(word,(U[word_id,0],U[word_id,1]))
    
plt.scatter(U[:,0],U[:,1],alpha=0.5)
plt.show()

image-20220919193046666

  • plt.annotate(word,x,y)

在坐标(x,y)处,绘制单词的文本

  • plt.scatter()

散点图

2.4.4 PTB数据集

PTB语料库中,多了若干预处理,包括将稀有单词替换成特殊字符(unkown),将具体的数字替换成“N”。在PTB中一行保存一个句子。每个句子结尾处插入一个特殊字符(end of sentence)

from dataset import ptb

corpus,word_to_id,id_to_word = ptb.load_data('train')

print('corpus size:',len(corpus))
print('corpus[:30]:',corpus[:30])
print()
print('id_to_word[0]:',id_to_word[0])
print('id_to_word[1]:',id_to_word[1])
print('id_to_word[2]:',id_to_word[2])
print()
print("word_to_id['car']:",word_to_id['car'])
print("word_to_id['happy']:",word_to_id['happy'])
print("word_to_id['lexus']:",word_to_id['lexus'])

image-20220920141738414

  • plt.load_data()

使用plt.load_data()加载数据时,需要指定参数'train','test','valid'

2.4.5 基于PTB数据集的评价

p88

corpus,word_to_id,id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)

print(corpus.shape) # (929589,)
print(len(word_to_id)) # 10000

print('counting co-occurrence .....')
C = create_co_matrix(corpus,vocab_size,window_size)
print(C.shape) # (10000, 10000)

print('calculating PPMI .....')
W = ppmi(C,verbose=True)

print('calculating SVD ...')
try:
    # 使用truncated SVD更快
    from sklearn.utils.extmath import randomized_svd
    U,S,V = randomized_svd(W,n_components=wordvec_size,n_iter=5,random_state=None)

except ImportError:
    # SVD比上面的慢些
    U,S,V = np.linalg.svd(W)

# 取出U中较大的基轴
word_vecs = U[:,:wordvec_size]
print('-----word_vecs-------------')
print(word_vecs.shape) # (10000, 100)

querys = [ta']
for query in querys:
   	# most_similar的作用是,将query转换为词向量和PPMI矩阵(奇异值分解后)做余弦相似度
    most_similar(query,word_to_id,id_to_word,word_vecs,top=5)

image-20220920145836349

将单词含义编码成向量,基于语料库,计算单词出现次数,然后转成PPMI矩阵,再基于SVD降维得到的单词向量,这就是单词的分布式表示。

总结

  • 基于同义词词典的方法,费时费力,且不能表示单词之间细微的差别
  • 基于计数的方法,从语料库中自动提取单词含义,并将其表示为向量,首先先创建共现矩阵,将其转换成PPMI矩阵,再基于SVD降维得到单词的分布式表示

3. word2vec

p93

这里使用的word2vec是基于计数方法的改进版,是基于推理的方法,这里的推理机制使用的是神经网络

3.1 基于推理的方法和神经网络

p93

3.1.1 基于计数的方法的问题

p94

现实中,语料库处理的单词数量非常大,比如英文单词可能在100万,基于计数的方法要创建一个100万×100万的庞大矩阵,这么庞大的矩阵对执行SVD显然是不现实的。

image-20220920153736599

基于计数的方法一次性处理全部学习数据,基于推理的方法通常在mini-batch数据上进行学习。这意味着神经网络一次只需要看一部分学习数据(mini-batch),并反复更新权重,逐步学习。神经网络的学习可以使用多台机器,多个GPU并行执行,加速学习过程

3.1.2 基于推理的方法的概要

p95

基于推理的方法的主要操作是“推理”。例如,给出上下文,预测处出现什么单词。

1663660009008

基于推理的方法,引入某种模型,这个模型接收上下文信息作为输入,并输出可能的单词的出现概率

3.1.3 神经网络中单词的处理方法

p96

神经网络无法直接这些单词,需要先将单词转换成固定长度的向量

image-20220920161508848

输入层由7个神经元表示,分别对应7个单词(第一个神经元对应于you,第二个对应于say)

3.2 简单的word2vec

p101

3.2.1 CBOW模型的推理

p101

CBOW模型是根据上下文预测目标词的神经网络(目标词是指中间的单词,它周围的单词是“上下文”)。CBOW模型的输入是上下文。这个上下文用['you','goodbye'](处上下文单词是you和goodbye)这样的单词列表表示。我们将其表示为one-hot表示。CBOW模型的网络可以画成下图

image-20220920164432786

因为,这里上下文仅考虑两个单词,所以输入层有两个。如果对上下文考虑N个单词,则输入层由N个。

这个中间层神经元是各个输入层经全连接层变换后得到的值的“平均”。输出层就是各个单词的得分。值越大,出现概率越高。

从输入层到中间层的变换由全连接层(权重是Win)完成。此时,全连接层的权重Win是一个7×3的矩阵。这个矩阵就是单词的分布式表示

image-20220920170300955

如上图所示,权重Win的各行保存着各个单词的分布式表示。通过不断学习,不断更新单词的分布式表示。就是word2vec的全貌。

中间层的神经元数量比如输入层少,这一点很重要,中间层需要将预测单词所需的信息压缩保存,从而产生密集的向量表示。


image-20220920171607524

如上图所示,CBOW模型有两个MatMul层,这两个MatMul的输出被加在一起。然后乘以0.5求平均。得到中间层的神经元。最后,将另一个MatMul层应用于中间层的神经元,得到输出得分。

代码实现

import numpy as np
from common.layers import MatMul

# 样本的上下文数据
c0 = np.array([[1,0,0,0,0,0,0]])
c1 = np.array([[0,0,1,0,0,0,0]])

# 权重的初始化
W_in = np.random.randn(7,3)
W_out = np.random.randn(3,7)

# 生成层
in_layer0 = MatMul(W_in)
in_layer1 = MatMul(W_in)
out_layer = MatMul(W_out)

# 正向传播
h0 = in_layer0.forward(c0)
h1 = in_layer1.forward(c1)
h = 0.5 * (h0 + h1)
s = out_layer.forward(h)

print(s.shape) # (1, 7)
print(s) 
# [[ 0.33901709  0.73718094  2.10042435 -3.27163436 -0.5974134   1.48779824
#   1.44842419]]

将权重初始化后,生成与上下文单词数量等量(这里是2个)的处理输入层的MatMul层。输出那边只生成一个MatMul层,输入层的MatMul层共享权重W_in

3.3 学习数据的准备

p110

3.3.1 上下文和目标词

p110

我们从语料库中生成上下文和目标词image-20220920193443575

然后,生成contexts和targetimage-20220920193917636

代码实现

from common.util import preprocess

text = "You say googbye and I say hello."
corpus,word_to_id,id_to_word = preprocess(text)
print('-----------------corpus-------------------')
print(corpus)
print('-----------------id_to_word-------------------')
print(id_to_word)

def create_contexts_target(corpus,window_size=1):
    target = corpus[window_size:-window_size]
    contexts = []
    
    for idx in range(window_size,len(corpus) - window_size):
        cs = []
        # 每次取target值的前一个和后一个值
        for t in range(-window_size,window_size + 1):
            if t == 0:
                continue
            cs.append(corpus[idx + t])
        contexts.append(cs)
    return np.array(contexts),np.array(target)

contexts,target = create_contexts_target(corpus)
print('------------contexts----------------')
print(contexts)
print('-------------target-----------')
print(target)

image-20220920195522325

3.3.2 转换为one-hot表示

p113

image-20220921080609651

from common.util import preprocess,create_contexts_target,convert_one_hot

text = "You say googbye and I say hello."
corpus,word_to_id,id_to_word = preprocess(text)

contexts,target = create_contexts_target(corpus,window_size=1)

vocab_size = len(word_to_id)
target = convert_one_hot(target,vocab_size)
contexts = convert_one_hot(contexts,vocab_size)
print('--------------contexts-----------------')
print(contexts)
print(contexts.shape)
print('--------------targets-------------')
print(target)
print(target.shape)

image-20220921084407508

3.4 CBOW模型的实现

p114

image-20220921085303990

  • 反向传播是输入乘以局部导数

代码实现

import numpy as np
from common.layers import MatMul,SoftmaxWithLoss

class SimpleCBOW:
    def __init__(self,vocab_size,hidden_size):
        # vocab_size:神经元个数
        # hidden_size:神经元个数
        V,H = vocab_size,hidden_size
        
        # 初始化权重
        W_in = 0.01 * np.random.randn(V,H).astype('f') # 32位浮点型
        W_out = 0.01 * np.random.randn(H,V).astype('f')
        
        # 生成层
        self.in_layer0 = MatMul(W_in)
        self.in_layer1 = MatMul(W_in)
        self.out_layer = MatMul(W_out)
        self.loss_layer = SoftmaxWithLoss()
        
        # 将所有的权重和梯度整理到列表中
        layers = [self.in_layer0,self.in_layer1,self.out_layer]
        self.params,self.grads = [],[]
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads
        
        # 将单词的分布式表示设置为成员变量
        self.word_vecs = W_in
    
    def forward(self,contexts,target):
        h0 = self.in_layer0.forward(contexts[:,0])
        h1 = self.in_layer1.forward(contexts[:,1])
        h = (h0+h1)*0.5
        score = self.out_layer.forward(h)
        loss = self.loss_layer.forward(score,target)
        return loss
    
    def backward(self,dout=1):
        ds = self.loss_layer.backward(dout)
        da = self.out_layer.backward(ds)
        da *= 0.5
        self.in_layer0.backward(da)
        self.in_layer1.backward(da)
        return None
    

window_size = 1
hidden_size = 5
batch_size = 3
max_epoch = 1000

text = "You say googbye and I say hello."
corpus,word_to_id,id_to_word = preprocess(text)

vocab_size = len(word_to_id)
contexts,target = create_contexts_target(corpus,window_size)
target = convert_one_hot(target,vocab_size)
contexts = convert_one_hot(contexts,vocab_size)

model = SimpleCBOW(vocab_size,hidden_size)
optimizer = Adam()
trainer = Trainer(model,optimizer)
trainer.fit(contexts,target,max_epoch,batch_size)
trainer.plot()

image-20220921091905754

通过权重查看每个单词的分布式表示

word_vecs = model.word_vecs
for word_id,word in id_to_word.items():
    print(word,word_vecs[word_id])

image-20220921100430552

3.5 word2vec的补充说明

3.5.1 CBOW模型和概率

p121

对第t个单词,考虑窗大小为1的上下文

\[w_{1}w_{2}\dots w_{t-1}w_{t}w_{t+1} \dots w_{T-1}w_{T} \]

表示当前给定上下文wt-1和wt+1时目标词为wt的概率,使用后验概率,有

\[P(w_{t}|w_{t-1},w_{t+1})\qquad (3,1) \]

CBOW模型可以上面的式子建模为式。CBOW模型的损失函数为交叉熵损失函数。式子如下

\[L = -\sum_{k}t_{k}log_{y_{k}} \]

其中yk表示第k个事件发生的概率。tk是监督标签,它是one-hot向量的元素。wt发生,这一事件是正确解,它对应的one-hot向量的元素是1,其他元素都是0(wt之外的事件发生时,对应的one-hot向量的元素均为0)。根据这一点,可以推导出下式

\[L = -logP(w_{t}|w_{t-1},w_{t+1}) \quad(3.2) \]

CBOW模型的损失函数只是对\(P(w_{t}|w_{t-1},w_{t+1})\)取log,并加上负号,这也成为负对数似然。式(3.2)是一笔样本数据的损失函数。如果将其扩展到整个语料库,则损失函数是

\[L = -\frac{1}{T}\sum_{t=1}^{T}logP(w_{t}|w_{t-1},w_{t+1}) \quad(3.3) \]

3.5.2 skip-gram模型

p122

word2vec有两个模型,一个是CBOW,一个是skip-gram。skip-gram是反转了CBOW的。

image-20220922171444566

CBOW是根据上下文预测中间的词,而skip-gram是通过中间的词去预测上下文。skip-gram结构如下

image-20220922171633357

skip-gram输入层只有一个,输出层的数量与上下文单词数量相等。可以建立skip-gram的式子

\[P(w_{t-1},w_{t+1}|w_{t}) \quad (3.4) \]

对上面的式子进行分解得

\[P(w_{t-1},w_{t+1}|w_{t}) = P(w_{t-1}|w_{t})P(w_{t+1}|w_{t}) \quad (3.5) \]

将(3.5)式子带入交叉熵误差函数,可以推出skip-gram模型的损失函数

\[\begin{aligned} L &= -logP(w_{t-1},w_{t+1}|w_{t}) \\ &= -logP(w_{t-1}|w_{t})P(w_{t+1}|w_{t}) \\ &= -(logP(w_{t-1}|w_{t})+logP(w_{t+1}|w_{t})) \end{aligned} \]

扩展到整个语料库 上就是

\[L = -\frac{1}{T}\sum_{t=1}^{T}(logP(w_{t-1}|w_{t})+logP(w_{t+1}|w_{t})) \]

skip-gram模型需要求对上下文对应各个单词的损失总和,而CBOW模型只需要求目标单词的损失。但是,我们还是要用skip-gram模型。

因为,大多数情况下,skip-gram模型的效果更好。

总结

word2vec是基于推理的方法,由简单的2层神经网络构成

word2vec有skip-gram和cbow模型

CBOW模型从多个单词(上下文)预测一个单词

skip-gram从一个单词预测多个单词(上下文)

4. word2vec的高速化

4.1 word2vec的改进

p129

前面的输入层只有7个神经元,这好弄,但是如果词汇量有100万个,CBOW模型的中间的神经元只有100个,输入层和输出层存在100万个神经元。这么多神经元的情况下,中间的计算过程需要很长时间。也会出现两个问题

  • 输入层的one-hot表示和权重矩阵win的乘积
  • 中间层和权重矩阵wout的乘积及softmax层的计算

第一个问题与输入层的one-hot表示有关,随着单词的增加,one-hot表示的向量大小也会增加。如果有100万个单词,那么one-hot就需要100万个,计算这就很麻烦。需要大量的计算资源,解决这个问题可以使用Embedding层,解决第二个问题可以使用Negative Sampling损失函数解决。

4.1.1 Embedding层

p132

这里考虑单词量是100万个情况下,中间层有100个神经元。MatMul的结构如下

image-20220923142710533

我们发现,如果语料库词汇量有100万个,则单词的one-hot表示的维数也会是100万,我们需要计算这个巨大向量和权重矩阵的乘积。但是上图中,无非是从Win中取一行而已,而我却需要花费这么大计算量,很不必要。我们创建一个从参数权重中抽取“单词ID对应的向量”的层,叫做Embedding层。

从权重矩阵中取一行很简单,例如:W[索引]就可以取到某一行

import numpy as np

class Embedding:
    def __init__(self,W):
        self.params = [W]
        self.grads = [np.zeros_like[W]]
        self.idx = None
    
    def forward(self,idx):
        W, = self.params
        self.idx = idx
        out = W[idx]
        return out

Embedding的正向传播只是从权重矩阵W中提取特定的行,并将该行的神经元原样传给下一层。

在反向传播时,输出侧的层传过来的梯度将原样传给输入层。不过,从输出层传过来的梯度会被应用到权重梯度的特定行(idx),

我们可以一次取多行的索引(前提是mini-batch,mini-batch可以用GPU进行并行计算)

image-20220923155555229

反向传播的实现,如果我们一次取多行的索引的话,比如取索引[0,1,0,2]的话第0行会被写入两次,这里的解决方法是将那行的值进行累加

def backward(self,dout):
    dW, = self.grads
    dW[...] = 0

    for i,word_id in enumerate(self.idx):
        dW[word_id] += dout[i]
        
	# np.add.at(dW,self.idx,dout) 比for循环更快
    
    return None

我们可以将MatMul层换成Embedding层。这样一来,既能减少内存开销,又能避免不必要的计算

4.2 word2vec的改进

p137

4.2.1 中间层之后的计算问题

word2vec的第二个改进是中间层之后的处理,即矩阵乘积和softmax层的计算。这里采用的方法是负采样。使用Negative Sampling替代Softmax,无论词汇多么大,都可以使计算量保持较低或恒定。

image-20220923171654528

这里有两个问题

  • 中间层的神经元和权重矩阵(Wout)的乘积
  • Softmax层的计算

第一个问题是巨大的矩阵计算,需要对矩阵进行轻量化。其次softmax随着单词数的增加,计算会变得慢起来。

4.2.2 从多分类到二分类

p139

负采样的关键思想在于二分类问题,更准确的说,是用二分类问题拟合多分类问题。(二分类问题就是回答yes或no的问题)

我们可以把从100万个单词中选择1个正确单词的任务,当做二分类问题。

假如,现在上下文是you和goodbye,我想知道say的得分的话,其实,输出层只要一个神经元就可以了。如下图所示

image-20220924184658166

注意,这里CBOW模型规定you和goodbye乘以Win后会加在一块,然后取平均,也就是最后shape是(1,100)

如上图所示,输出层的神经元只有一个。要想计算中间层和输出层的权重,只需要提取say对应的列,然后与中间层的神经元计算内积就可以了。计算过程如下

image-20220924190332587

4.2.3 sigmoid函数和交叉熵误差

p141

要使用神经网络解决二分类问题,需要使用sigmoid函数将得分转换为概率。交叉熵作为损失函数。通过sigmoid函数得到概率y后,可以由概率y计算损失,用于sigmoid的损失函数也是交叉熵误差,其数学式如下

\[L = -(tlogy \ + \ (1-t)log(1-y)) \]

y是sigmoid的输出,t是正确解标签,取值为0或者1。1为Yes,0为No。sigmoid函数和交叉熵误差的组合产生了y-t这样漂亮的结果,softmax函数和交叉熵误差的组合,或者恒等函数和均方误差的组合也会在反向传播时传播y-t。

4.2.4 多分类到二分类的实现

p144

二分类的网络结构图,dot:在原来的位置上乘,还有就是之前是多分类问题,target可以是0和1以外的其它的值,现在变为二分类问题,target(正确解标签)变为1,这点注意

image-20220924193807461

后半部分可以画成下图

image-20220924194656751

EmbeddingDot实现

import numpy as np

class EmbeddingDot:
    def __init__(self,W):
        self.embed = Embedding(W)
        self.params = self.embed.params
        self.grads = self.embed.grads
        self.cache = None
        
    def forward(self,h,idx):
        target_W = self.embed.forward(idx)
        # 因为算的是内积,target_w * h 和 h * target_w 没有区别
        out = np.sum(target_W * h ,axis=1)
        
        # 保存正向传播的结果
        self.cache = (h,target_W)
        return out
    
    def backward(self,dout):
        h,target_W = self.cache
        dout = dout.reshape(dout.shape[0],1)
        
        dtarget_W  = dout * h
        self.embed.backward(dtarget_W)l
        dh = dout * target_W
        return dh

内积的先后顺序不影响

image-20220925130655317

可以通过下图理解

image-20220924202601040

4.2.5 负采样

p148

当前的神经网络只学习了正例say,但是对say之外的负例一无所知。我们要做的是让正例的sigmoid输出无限接近1,负例的sigmoid输出无限接近0。为了把多分类问题转换为二分类问题,能够正确的处理正例和负例,需要同时考虑正例和负例。当然这里只考虑少量负例,然后将正例和负例的损失累加起来。

image-20220925145403131

4.2.6 负采样的采样方法(没弄懂)

p151

如何抽取负例,基于语料库的统计数据进行采样的方法比随机抽样要好,具体就是,让高频词容易被抽到,低频词不容易被抽到,有必要将负例限定在较小范围内(5个或10个)。

在采样之前,我们先用下np.random.choice()方法

# 从0到9中随机挑选一个数字
np.random.choice(10) # 9

# 随机选一个元素
words = ['you','say','goodbye','I','hello','.']
np.random.choice(words) # hello

# 有放回采样5次
np.random.choice(words,size=5) # array(['goodbye', '.', 'you', 'I', '.'], dtype='<U7')

# 无放回采样5次
np.random.choice(words,size=5,replace=False) # array(['say', 'hello', 'goodbye', 'you', '.'], dtype='<U7')

# 基于概率分布进行采样
p = [0.5,0.1,0.05,0.2,0.05,0.1]
np.random.choice(words,p=p) # 'I'

word2vec中提出的负采样对刚才的概率分布增加了一个步骤,对原来的概率分布取0.75次方

\[P'(w_{i})=\frac{P(w_{i})^{0.75}}{\sum_{j}^{n}P(w_{j})^{0.75}} \]

通过这个0.75次方可以让低频单词的概率稍微变高

p = [0.7,0.29,0.01]
new_p = np.power(p,0.75)
new_p /= np.sum(new_p)
print(new_p) # [0.64196878 0.33150408 0.02652714]

至于为啥取0.75,这个随缘取的哈

corpus = np.array([0,1,2,3,4,1,2,3])
power = 0.75
sample_size = 2 

sampler = UnigramSampler(corpus,power,sample_size)
target = np.array([1,3,0])# 这里的target的作用是在embedding层,现在的正确解是1,不是其它
negative_sample = sampler.get_negative_sample(target)
print(negative_sample)
# [[0 3]
#  [1 2]
#  [2 3]]

这个UnigramSampler()作用生成负例样本

class NegativeSamplingLoss:
    def __init__(self, W, corpus, power=0.75, sample_size=5):
        self.sample_size = sample_size
        self.sampler = UnigramSampler(corpus, power, sample_size)
        self.loss_layers = [SigmoidWithLoss() for _ in range(sample_size + 1)]  # 5个负例样本,一个正例,所以+1
        self.embed_dot_layers = [EmbeddingDot(W) for _ in range(sample_size + 1)]

        self.params, self.grads = [], []
        for layer in self.embed_dot_layers:
            self.params += layer.params 
            self.grads += layer.grads

    def forward(self, h, target):
        # target是一维的,batch_size就是target的个数
        batch_size = target.shape[0]
        # 根据target个数,生成相应的负样本
        negative_sample = self.sampler.get_negative_sample(target)

        # 正例的正向传播,target是向量,h计算完后,h分别与正例和负例进行dot运算,正例和负例都是从w_out来的,不同h对应不同的target,不然为什么h和target是dot运算,不懂看上面的图4-14,那个batch_size是3,h是3×3
        score = self.embed_dot_layers[0].forward(h, target)
        # 正确标签是1
        correct_label = np.ones(batch_size, dtype=np.int32)
        # target是向量,正确标签是1,计算它们之间的loss,算完后还是向量
        loss = self.loss_layers[0].forward(score, correct_label)

        # 负例的正向传播,h与w_out的embedding中负样本(这里的负样本是随机选的)进行dot运算
        negative_label = np.zeros(batch_size, dtype=np.int32)
        for i in range(self.sample_size):
            negative_target = negative_sample[:, i]
            score = self.embed_dot_layers[1 + i].forward(h, negative_target)
            loss += self.loss_layers[1 + i].forward(score, negative_label)

        return loss

    def backward(self, dout=1):
        dh = 0
        for l0, l1 in zip(self.loss_layers, self.embed_dot_layers):
            dscore = l0.backward(dout)
            dh += l1.backward(dscore)

        return dh

4.3 改进版word2vec的学习

p156

class CBOW:
    def __init__(self,vocab_size,hidden_size,window_size,corpus):
        V,H = vocab_size,hidden_size
        
        # 初始化权重
        W_in = 0.01 * np.random.randn(V,H).astype('f')
        W_out = 0.01 * np.random.randn(H,V).astype('f')
        
        # 生成层
        self.in_layers = []
        for i in range(2 * window_size):
            layer = Embedding(W_in)
            self.in_layers.append(layer)
        self.ns_loss = NegativeSamplingLoss(W_out,corpus,power=0.75,sample_size=5)
        
        # 将所有的权重和梯度整理到列表中
        layers = self.in_layers + [self.ns_loss]
        self.params,self.grads = [],[]
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads
        
        # 将单词的分布式表示设置为成员变量
        self.word_vecs = W_in
       
    def forward(self,context,target):
        h = 0
        for i,layer in enumerate(self.in_layers):
            h += layer.forward(contexts[0][i])
        h *= 1 / len(self.in_layers) # 对应计算图的中乘以0.5
        loss = self.ns_loss.forward(h,target)
        return loss
    
    def backward(self,dout=1):
        dout = self.ns_loss.backward(dout)
        dout *= 1 / len(self.in_layers)
        for layer in self.in_layers:
            layer.backward(dout)
        return None

vocab_size:词汇量

hidden_size:中间层的神经元个数

corpus:单词ID列表

window_size:上下文的大小

posted @ 2022-10-21 09:21  放学别跑啊  阅读(157)  评论(0编辑  收藏  举报