Python深度学习进阶-第四章

Python深度学习进阶-第四章

Word2vec的高速化

第三章我们学习了word2vec的原理,并实现了CBOW模型

但是,上一章还是遗留了一个问题的嘞:

就是随着语料库的处理词汇量增加,计算量也会随之增加的,如果不进行解决的话

那么,还是有可能在语料库非常大的时候,让机器学习的效率大幅度下降的。

举个极端的例子:如果一组语料库要处理的内容需要的期限100万年,咱们等不起哇

因为这里可不是至尊宝和紫霞仙子的爱你一万年哇!

这时呢,就要对上一章的简单的word2vec进行两点改进:

1、引入名为Embedding层的新层

2、引入名为Negative Sampling 的新的损失函数

根据上一章的CBOW模型接收2个单词的上下文,并基于他们预测1个单词(目标词)

三个计算大户:

1、H = Win x

2、S = H Wout 

3、Y = Softmax with loss(s)

4.1  word2vec 的改进

4.1.1 Embedding层

针对计算大户:

通俗理解:

看上述图片,可以轻松发现:

我们需要的东西其实可以在Win层直接取出来的,所以呢

曾今我们用于理解的dot(也就是相乘层)

那就是可有可无啦,遵循多一事不如少一事的原则,那就把它给删掉吧

直接取出我们想要的数据就够啦(想要的数据为图中颜色最深的那一排数据)

这时呢,我们亲切的称呼这个省事儿方案为:Embedding层

4.1.2 Embedding 层的实现

import numpy as np
W = np.arrange(21).reshape(7,3)
print(W)

实现Embedding层

Embedding 层的正向传播和反向传播的示例图

正向传播代码实现:


# in 代表输入,out 代表输出
# in
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  # 加逗号竟然可以将array外面的方括号去掉,厉害
        self.idx = idx
        out = W[idx]
        return out
# Out
W = np.arrange(21).reshape(7, 3)
embed = Embedding(W)
embed.forward(2)

#输出得到的结果:
#array([6, 7, 8])

# in 
embed.idx
# Out 2

Embedding的函数代码实现:

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   #加逗号把array外面的方括号去点了
        self.idx = idx
        out = W[idx]
        return out
    
    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)
        return None

总结:

1.这里,取出权重梯度dW,通过dW[…]=0,将dW的元素设为0(并不是将dW设为0,而是保持dW的形状保持不变,将他的元素设为0)

然后,将上一层传来的梯度dout写入idx指定的行。

这里我们看到,0的位置的梯度累加了

2.此外for循环语句的实现也可以通过Numpy的np.add.at()进行。

np.add.at(A,idx,B)将B加到A上,此时可以通过idx指定A中需要进行加法

新的收获:

通常情况下,NumPy 的内置方法比 Python 的 for循环处理更快。 这是因为 NumPy 的内置方法在底层做了高速化和提高处理效率的 优化。因此,上面的代码如果使用 np.add.at()来实现,效率会比 使用 for循环处理高得多。

下图是词汇量为100万时的word2vec:上下文是you和goodbye,目标词是say

的示意图:

softmax原理图:

在机器学习尤其是深度学习中,softmax是个非常常用而且比较重要的函数,尤其在多分类的场景中使用广泛。他把一些输入映射为0-1之间的实数,并且归一化保证和为1,因此多分类的概率之和也刚好为1。
首先我们简单来看看softmax是什么意思。顾名思义,softmax由两个单词组成,其中一个是max。对于max我们都很熟悉,比如有两个变量a,b。如果a>b,则max为a,反之为b。用伪码简单描述一下就是 if a > b return a; else b
另外一个单词为soft。max存在的一个问题是什么呢?如果将max看成一个分类问题,就是非黑即白,最后的输出是一个确定的变量。更多的时候,我们希望输出的是取到某个分类的概率,或者说,我们希望分值大的那一项被经常取到,而分值较小的那一项也有一定的概率偶尔被取到,所以我们就应用到了soft的概念,即最后的输出是每个分类被取到的概率。

公式:

总结:

softmax就是将概率较大的事件极端化,概率较小的事件极小化,但是呢,他们也保留了全部元素的概率,就是说最小的元素也是有取到的可能的,就像彩票一样,概率虽然小,但是还是有可能中奖的呢。

4.2.2 从多分类到二分类

考虑二分类拟合多分类:

多分类: 给you和goodbye,目标词是谁?

二分类:当上下文是you和goodbye时,目标词又是谁?

二分类原理:

二分类是用来处理只有两种情况:Yes/No的问题,例如:这个数字是7吗?等这类问题。

其他的则不可以用二分类啦。

这样的话我们的问题就变成了下图的原理图

特点:

输出的神经元只有一个,因此呢,要计算中间层和输出层的权重矩阵的乘积,只需要提取say对应的列(单词向量(单词的坐标嘛))并用它与中间层的神经元计算内积即可啦!(这样只需要处理一个say,压力瞬间减少不少,清爽!)

下图是仅仅计算目标得分的神经网络:

4.2.3  sigmoid函数和交叉熵误差

​ 要使用神经网络解决二分类问题,需要使用 sigmoid 函数将得分转化为 概率。为了求损失,我们使用交叉熵误差作为损失函数。这些都是二分类神经网络的老套路。

理解:

交叉熵误差做损失函数(损失函数越小,则说明机器学习的越好的呢!)

在多分类的情况下,输出层使用Softmax函数将得分转化为概率,损 失函数使用交叉熵误差。在二分类的情况下,输出层使用sigmoid 函数, 损失函数也使用交叉熵误差。

4.2.4  多分类到二分类的实现

1.回顾一下多分类

2.转换成进行二分类的神经网络图例:

在图 4-12 中,向 Sigmoid with Loss 层输入正确解标签 1,这意 味着现在正在处理的问题的答案是“Yes”。当答案是“No”时, 向 Sigmoid with Loss 层输入 0。

实现这个层:

函数代码:
 

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   #加逗号把array外面的方括号去点了
        self.idx = idx
        out = W[idx]
        return out
    
    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)
        return None

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)
        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)
        dh = dout * target_W
        return dh

4.2.5 负采样

下图是仅学习了正确案例(正确答案)

反例与正例的对比图例数据

提问:

我们需要对全部负例进行学习吗?

答案是:NO

其实,我们只需要选择几个负例即可了的

例如下图:

负采样的采样方法:

随机取样

基于语料库的统计抽样

这里呢,我们选择第二种

原理:高频词汇抽到的概率大,低频词汇抽到的概率小

基于语料库中各个单词的出现次数求出概率分布后,只需根据这个概率 分布进行采样就可以了。通过根据概率分布进行采样,语料库中经常出现的 单词将容易被抽到,而“稀有单词”将难以被抽到

负采样应当尽可能多地覆盖负例单词,但是考虑到计算的复杂度, 有必要将负例限定在较小范围内(5 个或者 10 个)。

这里,如果只选 择稀有单词作为负例会怎样呢?结果会很糟糕。因为在现实问题中, 稀有单词基本上不会出现。也就是说,处理稀有单词的重要性较低。 相反,处理好高频单词才能获得更好的结果。

下面我们看一下用Python怎么实现:

word2vec中提到的负采样对刚才的概率分布增加了一个步骤:

这里对概率做了一个平滑处理: 类似于Softmax

使用这种方法的原因:

是为了防止低频词被忽略的

通过取0.75次方,低频单词的概率将会稍微提高的,避免低频词等于零的尴尬

下面我们可以举一个例子:

0.75这个例子并不能作为代表,所以我们可以随便取值的哈!

可以看下代码实现:
 

import sys
sys.path.append('..')
from common.np import *  # import numpy as np
from common.layers import Embedding, SigmoidWithLoss
import collections


class UnigramSampler:
    def __init__(self, corpus, power, sample_size):
        self.sample_size = sample_size
        self.vocab_size = None
        self.word_p = None

        counts = collections.Counter()
        for word_id in corpus:		#统计每个单词出现的次数
            counts[word_id] += 1

        vocab_size = len(counts)		#代表一共有多少个单词,
        self.vocab_size = vocab_size

        self.word_p = np.zeros(vocab_size)
        for i in range(vocab_size):
            self.word_p[i] = counts[i]

        self.word_p = np.power(self.word_p, power)
        self.word_p /= np.sum(self.word_p)

    def get_negative_sample(self, target):
        batch_size = target.shape[0]

        if not GPU:
            negative_sample = np.zeros((batch_size, self.sample_size), dtype=np.int32)

            for i in range(batch_size):
                p = self.word_p.copy()
                target_idx = target[i]
                p[target_idx] = 0
                p /= p.sum()
                negative_sample[i, :] = np.random.choice(self.vocab_size, size=self.sample_size, replace=False, p=p)
        else:
            # 在用GPU(cupy)计算时,优先速度
            # 有时目标词存在于负例中
            negative_sample = np.random.choice(self.vocab_size, size=(batch_size, self.sample_size),
                                               replace=True, p=self.word_p)

        return negative_sample


在进行初始化时,UnigramSampler 类取 3 个参数,分别是单词 ID 列表 格式的 corpus、对概率分布取的次方值 power(默认值是 0.75)和负例的采 样个数 sample_size。

UnigramSampler 类有 get_negative_sample(target) 方法, 该方法以参数 target 指定的单词 ID 为正例,对其他的单词 ID 进行采样。

代码实现:

简单实现:

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])

#GPU=False
negative_sample = sampler.get_negative_sample(target)
print(negative_sample)


# 输出内容如下
[[0 3]
[1 2]
[2 3]]

这里,考虑将 [1, 3, 0] 这 3 个数据的 mini-batch 作为正例。此时,对 各个数据采样 2 个负例。在上面的例子中,可知第 1 个数据的负例是 [0, 3], 第 2 个是 [1, 2],第 3 个是 [2, 3]。这样一来,我们就完成了负采样。

4.2.7  负采样的实现

最后,我们来实现负采样。我们把它实现为 NegativeSamplingLoss 类, 首先从初始化开始

代码:

class NegativeSamplingLoss:
    def __init__(self, W, corpus, power=0.75, sample_size=5):
        #输出侧权重的 W、语料库(单词 ID 列表)corpus、概率分布的次方值 power 和负例的采样数 sample_size。
        self.sample_size = sample_size
        #生成上一节所说的 UnigramSampler 类,并使用成员变量 sampler 保存
        self.sampler = UnigramSampler(corpus, power, sample_size)
        #成员变量 loss_layers 和 embed_dot_layers 中以列表格式保存了必要的层。
        #在这两个列表中生成 sample_size + 1 个层,这是因为需要生成一个正例用的层和 sample_size 个负例用的层。
        self.loss_layers = [SigmoidWithLoss() for _ in range(sample_size + 1)]
        self.embed_dot_layers = [EmbeddingDot(W) for _ in range(sample_size + 1)]

        self.params, self.grads = [], []			#params存储的是w的值
        for layer in self.embed_dot_layers:
            self.params += layer.params
            self.grads += layer.grads

   #接收的参数是中间层的神经元 h 和正例目标词 target。         
    def forward(self, h, target):
        batch_size = target.shape[0]
        negative_sample = self.sampler.get_negative_sample(target)

        # 正例的正向传播
        score = self.embed_dot_layers[0].forward(h, target)
        correct_label = np.ones(batch_size, dtype=np.int32)	#正确解标签都是1
        loss = self.loss_layers[0].forward(score, correct_label)

        # 负例的正向传播
        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

​ 成员变量 loss_layers 和 embed_dot_layers 中以列表格式保存了必要的 层。在这两个列表中生成 sample_size + 1 个层,这是因为需要生成一个正 例用的层和 sample_size 个负例用的层。这里,我们假设列表的第一个层处 理正例。也就是说,loss_layers[0] 和 embed_dot_layers[0] 是处理正例的层。 然后,将 embed_dot_layers 层使用的权重和梯度分别保存在数组中。

​ forward(h, target) 方法接收的参数是中间层的神经元 h 和正例目标 词 target。这里进行的处理是,首先使用 self.sampler 采样负例,并设为 negative_sample。然后,分别对正例和负例的数据进行正向传播,求损失的 和。具体而言,通过 Embedding Dot 层的 forward 输出得分,再将这个得 分和标签一起输入 Sigmoid with Loss 层来计算损失。这里需要注意的是, 正例的正确解标签为 1,负例的正确解标签为 0。

​ 反向传播的实现非常简单,只需要以与正向传播相反的顺序调用各层的 backward() 函数即可。在正向传播时,中间层的神经元被复制了多份,这相 当于 1.3.4.3 节中介绍的 Repeat 节点。因此,在反向传播时,需要将多份梯 度累加起来。以上就是负采样的实现的说明。

这样的负采样改为四个

4.3 改进版本的word2vec的学习

目前为止,我们上述已经学习了embedding 层的实现,又介绍了负采样法的方法。

之后,我们对他两个进行了代码实现

现在冲锋的号角已经打响,到了最后关头

战士们,Never give up

我们最后来进一步实现这些改进的神经网络,并在PTB数据集上进行学习,以用来获得更加是用的单词分布式表示。

4.3.1 CBOW模型的实现

改进版本的CBOW类

import sys
sys.path.append('..')
from common.np import *  # import numpy as np
from common.layers import Embedding
from ch04.negative_sampling_layer import NegativeSamplingLoss


class CBOW:
    def __init__(self, vocab_size, hidden_size, window_size, corpus):
        #vocab_size 是词汇量,hidden_size 是中间层的神经元个数,corpus 是单词 ID 列表。
        #window_size 指定上下文的大小,即上下文包含多少个周围单词。如果 window_size 是 2,则目标词的左右 2 个单词(共 4 个单词)将成为上下文。
     
        V, H = vocab_size, hidden_size

        # 初始化权重
        W_in = 0.01 * np.random.randn(V, H).astype('f')
        W_out = 0.01 * np.random.randn(V, H).astype('f')

        # 生成层
        #创建 2 * window_size 个Embedding 层,并将其保存在成员变量 in_layers 中。然后,创建 Negative Sampling Loss 层。
        
        self.in_layers = []
        for i in range(2 * window_size):
            layer = Embedding(W_in)  # 使用Embedding层
            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, contexts, target):
        h = 0
        for i, layer in enumerate(self.in_layers):
            h += layer.forward(contexts[:, i])
        h *= 1 / len(self.in_layers)
        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


在 SimpleCBOW类(改进前的实现)中,输入侧的权重和输出侧的 权重的形状不同,输出侧的权重在列方向上排列单词向量。而 CBOW类的输出侧的权重和输入侧的权重形状相同,都在行方向 上排列单词向量。这是因为 NegativeSamplingLoss类中使用了 Embedding 层

1.这个初始化方法有 4 个参数。vocab_size 是词汇量,hidden_size 是中间 层的神经元个数,corpus 是单词 ID 列表。另外,通过 window_size 指定上下 文的大小,即上下文包含多少个周围单词。如果 window_size 是 2,则目标 词的左右 2 个单词(共 4 个单词)将成为上下文

2.在权重的初始化结束后,继续创建层。这里,创建 2 * window_size 个 Embedding 层,并将其保存在成员变量 in_layers 中。然后,创建 Negative Sampling Loss 层

3.在创建好层之后,将神经网络中使用的参数和梯度放入成员变量 params 和 grads 中。另外,为了之后可以访问单词的分布式表示,将权重 W_in 设置 为成员变量 word_vecs。

4.这里的实现只是按适当的顺序调用各个层的正向传播(或反向传播), 这是对上一章的 SimpleCBOW 类的自然扩展。不过,虽然 forward (contexts, target) 方法取的参数仍是上下文和目标词,但是它们是单词 ID 形式的(上 一章中使用的是 one-hot 向量,不是单词 ID),具体示例如图 4-19 所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y3kXhN2s-1639141023224)(4/31.png)]

4.3.2  CBOW模型的学习代码

最后,我们来实现 CBOW 模型的学习部分。其实只是复用一下神经网络的学习,

import sys
sys.path.append('..')
from common import config
# 在用GPU运行时,请打开下面的注释(需要cupy)
# ===============================================
# config.GPU = True
# ===============================================
from common.np import *
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
from cbow import CBOW
from skip_gram import SkipGram
from common.util import create_contexts_target, to_cpu, to_gpu
from dataset import ptb


# 设定超参数
window_size = 5
hidden_size = 100
batch_size = 100
max_epoch = 10

# 读入数据
corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)

contexts, target = create_contexts_target(corpus, window_size)
if config.GPU:
    contexts, target = to_gpu(contexts), to_gpu(target)

# 生成模型等
model = CBOW(vocab_size, hidden_size, window_size, corpus)
# model = SkipGram(vocab_size, hidden_size, window_size, corpus)
optimizer = Adam()
trainer = Trainer(model, optimizer)

# 开始学习
trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()

# 保存必要数据,以便后续使用
word_vecs = model.word_vecs
if config.GPU:
    word_vecs = to_cpu(word_vecs)
params = {}
params['word_vecs'] = word_vecs.astype(np.float16)
params['word_to_id'] = word_to_id
params['id_to_word'] = id_to_word
pkl_file = 'cbow_params.pkl'  # or 'skipgram_params.pkl'
with open(pkl_file, 'wb') as f:
    pickle.dump(params, f, -1)

本次的 CBOW 模型的窗口大小为 5,隐藏层的神经元个数为 100。虽 然具体取决于语料库的情况,但是一般而言,当窗口大小为 2 ~ 10、中间 层的神经元个数(单词的分布式表示的维数)为50~500时,结果会比较好。

​ 这次我们利用的 PTB 语料库比之前要大得多,因此学习需要很长时间 (半天左右)。作为一种选择,我们提供了使用 GPU 运行的模式。如果要使 用 GPU 运行,需要打开顶部的“# config.GPU = True”。不过,使用 GPU 运行需要有一台安装了 NVIDIA GPU 和 CuPy 的机器。

​ 在学习结束后,取出权重(输入侧的权重),并保存在文件中以备后用 (用于单词和单词 ID 之间的转化的字典也一起保存)。这里,使用 Python 的 pickle 功能进行文件保存。pickle 可以将 Python 代码中的对象保存到文 件中(或者从文件中读取对象)。

4.3.3  CBOW模型的评价

现在,我们来评价一下上一节学习到的单词的分布式表示。这里我们 使用第 2 章中实现的 most_similar() 函数,显示几个单词的最接近的单词

import sys
sys.path.append('..')
from common.util import most_similar, analogy
import pickle	


pkl_file = 'cbow_params.pkl'			#cbow_params.pkl
# pkl_file = 'skipgram_params.pkl'

with open(pkl_file, 'rb') as f:
    params = pickle.load(f)
    word_vecs = params['word_vecs']
    word_to_id = params['word_to_id']
    id_to_word = params['id_to_word']

# most similar task
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
    most_similar(query, word_to_id, id_to_word, word_vecs, top=5)

# analogy task
print('-'*50)
analogy('king', 'man', 'queen',  word_to_id, id_to_word, word_vecs)
analogy('take', 'took', 'go',  word_to_id, id_to_word, word_vecs)
analogy('car', 'cars', 'child',  word_to_id, id_to_word, word_vecs)
analogy('good', 'better', 'bad',  word_to_id, id_to_word, word_vecs)

4.4 wor2vec相关的其他话题

4.4.1  word2vec的应用例

4.4.2  单词向量的评价方法

基于类推问题可以在一定程度上衡量“是否正确理解了单词含义或语法 问题”。

因此,在自然语言处理的应用中,能够高精度地解决类推问题的单 词的分布式表示应该可以获得好的结果。

但是,单词的分布式表示的优劣对 目标应用贡献多少(或者有无贡献),取决于待处理问题的具体情况,

比如 应用的类型或语料库的内容等。

也就是说,不能保证类推问题的评价高,目 标应用的结果就一定好。这一点请一定注意

4.5 小结

本章所学的内容
  • Embedding 层保存单词的分布式表示,在正向传播时,提取单词 ID 对应的向量
  • 因为 word2vec 的计算量会随着词汇量的增加而成比例地增加,所以 最好使用近似计算来加速
  • 负采样技术采样若干负例,使用这一方法可以将多分类问题转化为 二分类问题进行处理
  • 基于 word2vec 获得的单词的分布式表示内嵌了单词含义,在相似的 上下文中使用的单词在单词向量空间上处于相近的位置
  • word2vec 的单词的分布式表示的一个特性是可以基于向量的加减法 运算来求解类推问题
  • word2vec 的迁移学习能力非常重要,它的单词的分布式表示可以应 用于各种各样的自然语言处理任务
  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值