跟着问题学9——基础语言模型&word2vec详解及代码实战

词向量与word2vec

学习神经网络模型的核心就是抓住数据流,即数据的形式和维度,一般来说,输入都是矩阵/向量,这时就要重点关注经过每个模块后数据的维度变化。神经网络模型输入的形式是矩阵/向量,而现实中的数据是图片或句子或声音等模拟信号,所以第一步重要的就是把这些模拟信号转化成数字信号。在 CV 中,我们通常将输入图片转换为4维(batch, channel, height, weight)张量来表示。那么NLP中 如何将句子/单词编码数字化呢?

one-hot编码——(维度灾难)——>分布式编码————>神经网络语言模型:CBOW、skipgram——(输出softmax,耗时)——>逻辑回归模型(标签值:值为0或1的新列,0=“不是邻居”,1=“邻居”)——>负采样——(在数据集中引入负样本:不是邻居的单词样本)——>

1. 词向量基础

向量空间模型长期以来一直被用于分布式语义的目的,它以向量的形式表示文本文档和查询语句。通过以向量空间模型在N维空间中来表示单词,可以帮助不同的NLP算法实现更好的结果,因此这使得相似的文本在新的向量空间中组合在一起。

自然语言是一套用来表达含义的复杂系统。在这套系统中,词是表义的基本单元。在机器学习中,如何使用向量表示词?顾名思义,词向量,又名词嵌入(Word Embedding),是用来表示词的向量,通常也被认为是词的特征向量。也就是说,根据词向量我们可以判断表示的是特定的词,那么本质就可以有两类表示方法,一类是基于词本身的特征(比如最简单的独热编码中词在词汇表中的位置),另一类是基于和其它词的关系来表示。近年来,词向量已逐渐成为自然语言处理的基础知识。

用词向量来表示词并不是word2vec的首创,在很久之前就出现了,表示方式:

1.1 独热编码One-Hot Encoding

 一种最简单的词向量方式是one-hot representation,就是用一个很长的向量来表示一个词,向量长度是预定义的词汇表(想象一本词典)中拥有的单词量,向量在这一维中的值只有一个位置是1,其余都是0,1对应的位置就是词汇表中表示这个单词的地方。

例如词汇表中有5个词,第3个词表示“你好”这个词,那么该词对应的 one-hot 编码即为 00100(第3个位置为1,其余为0)。

这种 One-hot Representation 采用稀疏向量方式存储,会是非常的简洁:也就是给每个词分配一个数字 ID。比如刚才的例子中,“你好”记为 4。如果要编程实现的话,用 Hash 表给每个词分配一个编号就可以了。这么简洁的表示方法配合上最大熵、SVM、CRF 等等算法已经很好地完成了 NLP 领域的各种主流任务。

不过这种词向量表示有两个缺点:

(1)维度灾难:词汇表一般都非常大,比如词汇表如果有 10k 个词,那么一个词向量的长度就需要达到 10k,而其中却仅有一个位置是1,其余全是0,内存占用过高且表达效率很低。

(2)无法体现出词与词之间的关系:比如 “爱” 和 “喜欢” 这两个词,它们的意思是相近的,但基于 one-hot 编码后的结果取决于它们在词汇表中的位置,不能很好地刻画词与词之间的相似性。因为任何两个one-hot向量之间的内积都是0,很难区分他们之间的差别。

1.2 分布式表示:词向量/词嵌入Word Embedding

  Distributed representation可以解决One hot representation的问题,它最早是 Hinton 于 1986 年提出的。它的思路是通过训练,将每个词都映射到一个较短的词向量上来,能够体现词与词之间的关系,所有的这些词向量就构成了向量空间,进而可以用普通的统计学的方法来研究词与词之间的关系。这就是word embedding,即指的是将词转化成一种分布式表示,又称词向量。分布式表示将词表示成一个定长的连续的稠密向量,这个较短的词向量维度是多大呢?这个一般需要我们在训练时自己来指定。

那么应该如何设计这种方法呢?最方便的途径是设计一个可学习的权重矩阵 W(这个矩阵是致密的),将词向量与这个矩阵进行点乘,即得到新的表示结果,使得意思相近的词有相近的表示结果。下面章节会详细介绍如何生成词向量。

假设 “爱” 和 “喜欢” 这两个词经过 one-hot 后分别表示为 10000 (1*5)和 00001,权重矩阵设计如下(5*3):

[ w00, w01, w02

  w10, w11, w12

  w20, w21, w22

  w30, w31, w32

  w40, w41, w42 ]

那么两个词点乘后的结果分别是 [w00, w01, w02] 和 [w40, w41, w42],在网络学习过程中(这两个词后面通常都是接主语,如“你”,“他”等,或者在翻译场景,它们被翻译的目标意思也相近,它们要学习的目标一致或相近),权重矩阵的参数会不断进行更新,从而使得 [w00, w01, w02] 和 [w40, w41, w42] 的值越来越接近。

其实,可以将这种方式看作是一个 lookup table:对于每个 word,进行 word embedding 就相当于一个lookup操作,在表中查出一个对应结果。

另一方面,对于以上这个例子,我们还把向量的维度从5维压缩到了3维。因此,word embedding 还可以起到降维的效果。一个2x5的矩阵,乘上一个5x3的矩阵,变成一个2x3的矩阵。

A∗B=C

   在上述公式中,一个10个元素的A矩阵变成C中6个元素的矩阵,直观上大小缩小了近一半。

假设一个100Wx10W的矩阵,乘上一个10Wx20的矩阵,可以把它降到100Wx20的矩阵,降低10w/20=5000倍。

总结:在某种程度上,Embedding层实现了降维的作用,降维的原理是根据矩阵乘法。

Embedding就是用一个低维稠密的向量表示一个对象,这里的对象可以是一个词(Word2vec),也可以是一个物品(Item2vec),亦或是网络关系中的节点(Graph Embedding)。

在 Pytorch 框架下,可以使用 torch.nn.Embedding来实现 word embedding:

class Embeddings(nn.Module):

    def __init__(self, d_model, vocab):

        super(Embeddings, self).__init__()

        self.lut = nn.Embedding(vocab, d_model)

        self.d_model = d_model

    def forward(self, x):

        return self.lut(x) * math.sqrt(self.d_model)

其中,vocab 代表词汇表中的单词量,one-hot 编码后词向量的长度就是这个值;d_model代表权重矩阵的列数,通常为512,就是要将词向量的维度从 vocab 编码到 d_model

 Embedding向量能够表达对象的某些特征,两个向量之间的距离反映了对象之间的相似性。也就是说,将所有这些向量放在一起形成一个词向量空间,而每一向量则为该空间中的一个点,在这个空间上的词向量之间的距离度量也可以表示对应的两个词之间的“距离”。所谓两个词之间的“距离”,就是这两个词之间的语法,语义之间的相似性。

简单的说,Embedding就是把一个东西映射到一个向量X。如果这个东西很像,那么得到的向量x1和x2的欧式距离很小。

有了用Distributed Representation表示的较短的词向量,我们就可以较容易的分析词之间的关系了,比如我们将词的维度降维到2维,有一个有趣的研究表明,用下图的词向量表示我们的词时,我们可以发现:

分布式表示优点:

(1) 词之间存在相似关系: 词之间存在“距离”概念,这对很多自然语言处理的任务非常有帮助。

 (2) 包含更多信息: 词向量能够包含更多信息,并且每一维都有特定的含义。在采用one-hot特征时,可以对特征向量进行删减,词向量则不能。      

1.3 词向量可视化

如果我们能够学习到一个 300 维的特征向量,或者说 300 维的词嵌入,通常我们可以做一件事,把这 300 维的数据嵌入到一个二维空间里,这样就可以可视化了。常用的可视化算法是 t-SNE 算法,来自于 Laurens van der Maaten 和 Geoff Hinton 的论文。

 t-SNE 算法:所做的就是把这些 300 维的数据用一种非线性的方式映射到 2 维平面上,可以得知 t-SNE 中这种映射很复杂而且很非线性。

词嵌入算法对于相近的概念,学到的特征也比较类似,在对这些概念可视化的时候,这些概念就比较相似,最终把它们映射为相似的特征向量。如果观察这种词嵌入的表示方法,会发现 man 和 woman 这些词聚集在一块(上图编号 1 所示), king 和queen 聚集在一块(上图编号 2 所示),这些都是人,也都聚集在一起(上图编号 3 所示)。动物都聚集在一起(上图编号 4 所示),水果也都聚集在一起(上图编号 5 所示),像 1、2、 3、 4 这些数字也聚集在一起(上图编号 6 所示)。如果把这些生物看成一个整体,他们也聚集在一起(上图编号 7 所示)。

这种表示方式用的是在 300 维空间里的特征表示,这叫做嵌入( embeddings)。之所以叫嵌入的原因是,可以想象一个 300 维的空间,这里用个 3 维的代替(上图编号 8 所示)。现在取每一个单词比如 orange,它对应一个 3 维的特征向量,所以这个词就被嵌在这个 300 维空间里的一个点上了(上图编号 9 所示), apple 这个词就被嵌在这个 300 维空间的另一个点上了(上图编号 10 所示)。为了可视化, t-SNE 算法把这个空间映射到低维空间,你可以画出一个 2 维图像然后观察,这就是这个术语嵌入的来源。

1.4 词嵌入用做迁移学习

如果对于一个命名实体识别任务,只有一个很小的标记的训练集,训练集里可能没有某些词,但是如果有一个已经学好的词嵌入,就可以用迁移学习,把从互联网上免费获得的大量的无标签文本中学习到的知识迁移到一个命名实体识别任务中。

如果从某一任务 A 迁移到某个任务 B,只有 A 中有大量数据,而 B 中数据少时,迁移的过程才有用。

用词嵌入做迁移学习的步骤:

(1)先从大量的文本集中学习词嵌入,或者可以下载网上预训练好的词嵌入模型。

(2)把这些词嵌入模型迁移到新的只有少量标注训练集的任务中。

(3)考虑是否微调,用新的数据调整词嵌入。当在新的任务上训练模型时,如命名实体识别任务上,只有少量的标记数据集上,可以自己选择要不要继续微调,用新的数据调整词嵌入。实际中,只有第二步中有很大的数据集时才会这样做,如果你标记的数据集不是很大,通常不建议在微调词嵌入上费力气。

2. 生成词向量的方式

 可见我们只要得到了词汇表里所有词对应的词向量,那么我们就可以做很多有趣的事情了。不过,怎么训练得到合适的词向量呢?训练方法较多,word2vec是其中一种。还要注意的是每个词在不同的语料库和不同的训练方法下,得到的词向量可能是不一样的。由于是用向量表示,而且用较好的训练算法得到的词向量的向量一般是有空间上的意义的。

一个比较实用的场景是找同义词,得到词向量后,假如想找出与“爱”相似的词,建立好词向量后,对计算机来说,只要拿这个词的词向量跟其他词的词向量一一计算欧式距离或者cos距离,得到距离小于某个值的那些词,就是它的同义词。

2.1. 基于统计方法

1. 共现co-occurrence矩阵

通过统计一个事先指定大小的窗口内的word共现次数,以word周边的共现词的次数做为当前word的vector。这种方法就是利用周围的词的关系来表示某个词,比如小明最好的朋友是小红,那就用“小明最好的朋友”来表示“小红”。具体来说,我们通过从大量的语料文本中构建一个共现矩阵来定义word representation。

例如,有语料如下:

         I like deep learning.

         I like NLP.

         I enjoy flying.

则其共现矩阵如下:

矩阵定义的词向量在一定程度上缓解了one-hot向量相似度为0的问题,但没有解决数据稀疏性和维度灾难的问题。

2. SVD(奇异值分解)

既然基于co-occurrence矩阵得到的离散词向量存在着高维和稀疏性的问题,一个自然而然的解决思路是对原始词向量进行降维,从而得到一个稠密的连续词向量。 对共现矩阵进行SVD分解,得到正交矩阵U,对U进行归一化得到矩阵如下:        

SVD得到了word的稠密(dense)矩阵,该矩阵具有很多良好的性质:语义相近的词在向量空间相近,甚至可以一定程度反映word间的线性关系

2.2 基于语言模型(language model)

语言模型生成词向量是通过训练神经网络语言模型NNLM(neural network language model),词向量作为语言模型的附带产出。NNLM背后的基本思想是对出现在上下文环境里的词进行预测,这种对上下文环境的预测本质上也是一种对共现统计特征的学习。 较著名的采用neural network language model生成词向量的方法有:Skip-gram、CBOW、LBL、NNLM、C&W、GloVe等。接下来,以word2vec为例,讲解基于神经网络语言模型的词向量生成。

3. CBOW与Skip-Gram模型用于神经网络语言模型

在word2vec出现之前,已经有用神经网络DNN来用训练词向量进而处理词与词之间的关系了。采用的方法一般是一个三层的神经网络结构(当然也可以多层),分为输入层,隐藏层和输出层(softmax层)。

这个模型是如何定义数据的输入和输出呢?一般分为CBOW(Continuous Bag-of-Words 与Skip-Gram两种模型。

3.1 CBOW模型

CBOW模型,利用上下文或周围的单词来预测中心词。

输入:某一个特征词的上下文相关对应的词向量(单词的one-hot编码);输出:这特定的一个词的词向量(单词的one-hot编码)。

词向量到底在哪?

比如下面这段话,上下文大小取值为4,特定的这个词是"Learning",也就是需要的输出词向量(单词Learning的one-hot编码),上下文对应的词有8个,前后各4个,这8个词是模型的输入(8个单词的one-hot编码)。由于CBOW使用的是词袋模型,因此这8个词都是平等的,也就是不考虑和关注的词之间的距离大小,只要在上下文之内即可。

上面CBOW的例子里,输入是8个词向量(8个单词的one-hot编码),输出是所有词的softmax概率(训练的目标是期望训练样本中心词对应的softmax概率最大);对应的CBOW神经网络模型输入层有8个神经元,输出层有词汇表大小个神经元。隐藏层的神经元个数可以自己指定。

通过DNN的反向传播算法,可以求出DNN模型的参数,同时得到所有的词对应的词向量。这样当有新的需求,要求出某8个词对应的最可能的输出中心词时,可以通过一次DNN前向传播算法并通过softmax激活函数找到概率最大的词对应的神经元即可。

输入层:

一个形状为C×V的one-hot张量,其中C代表上线文中词的个数,通常是一个偶数,我们假设为4;V表示词表大小,我们假设为5000,该张量的每一行都是一个上下文词的one-hot向量表示,比如“Pineapples, are, and, yellow”。

隐藏层:

一个形状为V×N的参数张量W1,一般称为word-embedding,N表示每个词的词向量长度,我们假设为128。输入张量和word embedding W1进行矩阵乘法,就会得到一个形状为C×N的张量。综合考虑上下文中所有词的信息去推理中心词,因此将上下文中C个词相加得一个1×N的向量,是整个上下文的一个隐含表示。

输出层:

创建另一个形状为N×V的参数张量,将隐藏层得到的1×N的向量乘以该N×V的参数张量,得到了一个形状为1×V的向量。最终,1×V的向量代表了使用上下文去推理中心词,每个候选词的打分,再经过softmax函数的归一化,即得到了对中心词的推理概率:

2. Skip-gram模型

Skip-gram模型,使用中心词来预测上下文词。

Skip-Gram模型和CBOW的思路是反着来的(互为镜像),即

输入是特定的一个词的词向量(单词的one-hot编码),

输出是特定词对应的上下文词向量(所有上下文单词的one-hot编码)。

还是上面的例子,上下文大小取值为4, 特定的这个词"Learning"是输入,而这8个上下文词是输出。

在这个Skip-Gram的例子里,输入是特定词, 输出是softmax概率排前8的8个词,对应的Skip-Gram神经网络模型输入层有1个神经元,输出层有词汇表大小个神经元。隐藏层的神经元个数可以自己指定。

通过DNN的反向传播算法,可以求出DNN模型的参数,同时得到所有的词对应的词向量。这样当有新的需求,要求出某1个词对应的最可能的8个上下文词时,可以通过一次DNN前向传播算法得到概率大小排前8的softmax概率对应的神经元所对应的词。

所以,只要简单理解为CBOW与Skip-Gram互为镜像,输入/输出都是词的one-hot编码,训练上下文->中心词/中心词->上下文词的关系权重就可以了。

以上就是神经网络语言模型中如何用CBOW与Skip-Gram来训练模型与得到词向量的大概过程。但是这和word2vec中用CBOW与Skip-Gram来训练模型与得到词向量的过程有很多的不同。

word2vec为什么不用现成的DNN模型,要继续优化出新方法呢?最主要的问题:DNN模型的这个处理过程非常耗时。词汇表一般在百万级别以上,这意味着DNN的输出层需要进行softmax计算各个词的输出概率,计算量很大。有没有简化一点点的方法呢?

3.2 word2vec中的CBOW与Skip-Gram 

word2vec是Google在2013年推出的一个NLP工具,它的特点是将所有的词向量化,这样词与词之间就可以定量的去度量他们之间的关系,挖掘词之间的联系。

        word2vec工具主要包含两个模型:跳字模型(skip-gram)和连续词袋模型(continuous bag of words,简称CBOW),以及两种高效训练的方法:负采样(negative sampling)和层序softmax (hierarchical softmax)。值得一提的是,word2vec词向量可以较好地表达不同词之间的相似和类比关系。

Skip-gram和CBOW是Word2vec架构的两种类型,可以理解为两种实现方式,不是说,word2vec包含这两个模型。word2vec自提出后被广泛应用在自然语言处理任务中。它的模型和训练方法也启发了很多后续的词向量模型。

word2vec也使用了CBOW与Skip-Gram来训练模型与得到词向量,但是并没有使用传统的DNN模型,而是对其进行了改进。最先优化使用的数据结构是用霍夫曼树来代替隐藏层和输出层的神经元。

叶子节点:起到输出层神经元的作用,叶子节点的个数即为词汇表的大小。

内部节点:起到隐藏层神经元的作用。

霍夫曼树编码方式:一般对于一个霍夫曼树的节点(根节点除外),可以约定左子树编码为0,右子树编码为1。

Word2vec中,约定编码方式和霍夫曼相反,即约定左子树编码为1,右子树编码为0,同时约定左子树的权重不小于右子树的权重。

1. CBOW模型

   连续词袋模型(Continuous Bag-of-Word Model, CBOW)是一个三层神经网络,输入已知上下文,输出对下个单词的预测:

                     

CBOW模型的第一层是输入层, 输入已知上下文的词向量;

中间一层称为线性隐含层, 它将所有输入的词向量累加求和(或累加求和取平均);

第三层是一棵哈夫曼树, 树的的叶节点与语料库中的单词一一对应, 而树的每个非叶节点是一个二分类器(一般是softmax感知机等), 树的每个非叶节点都直接与隐含层相连.也就是说每做一次判断后,都是将隐含层直接与下一个非叶节点(判断条件)相连,也就是所有非叶节点的输入都是隐含层。

将上下文的词向量输入CBOW模型, 由隐含层累加得到中间向量,将中间向量输入哈夫曼树的根节点, 根节点会将其分到左子树或右子树。每个非叶节点都会对中间向量进行分类, 直到达到某个叶节点,该叶节点对应的单词就是对下个单词的预测。

训练过程:

首先根据语料库建立词汇表, 词汇表中所有单词拥有一个随机的词向量。我们从语料库选择一段文本进行训练;

将单词W的上下文的词向量输入CBOW, 由隐含层累加, 在第三层的哈夫曼树中沿着某个特定的路径到达某个叶节点, 从给出对单词W的预测。

训练过程中已经知道了单词W, 根据W的哈夫曼编码可以确定从根节点到叶节点的正确路径, 也确定了路径上所有分类器应该作出的预测.

采用梯度下降法调整输入的词向量, 使得实际路径向正确路径靠拢。在训练结束后可以从词汇表中得到每个单词对应的词向量。

2. Skip-gram

Skip-gram模型同样是一个三层神经网络,skip-gram模型的结构与CBOW模型正好相反,skip-gram模型输入某个单词,输出对它上下文词向量的预测。

Skip-gram的核心同样是一个哈夫曼树, 每一个单词从树根开始到达叶节点,可以预测出它上下文中的一个单词。对每个单词进行N-1次迭代,得到对它上下文中所有单词的预测, 根据训练数据调整词向量得到足够精确的结果。

  负采样

通常,一个预料库不同单词数量会达到几万或者十几万,这导致softmax计算量大增。Skip-Gram对于一个训练样本就需要做多次多分类任务,因此Skip-Gram相对于CBOW的计算量更大。

Negative Sampling使用了采样的方法,减轻计算的量的思想就是将多分类转换成多个二分类,具体多少个二分类则是自己设定的,可以看成一个超参数。

比如我们有一个训练样本,中心词是w,它周围上下文共有2c个词,记为context(w)。由于这个中心词w的确和context(w)相关存在,因此它是一个真实的正例。通过Negative Sampling采样,我们得到neg个和w不同的中心词wi,i=1,2,..neg,这样context(w)和wi就组成了neg个并不真实存在的负例。利用这一个正例和neg个负例,进行二元逻辑回归,得到负采样对应每个词wi的模型参数\theta _{i},和每个词的词向量。(w和wi都是中心词)

从上面的描述可以看出,Negative Sampling由于没有采用霍夫曼树,每次只是通过采样neg个不同的中心词做负例,就可以训练模型,因此整个过程要比Hierarchical Softmax简单。

正样本和neg个负样本是什么,怎么进行二元逻辑回归?

给定一个中心词和一个需要预测的上下文词,把这个上下文词作为正样本。

通过词表随机采样的方式,选择neg个负样本。这里是将每个词汇在语料库中出现的频率作为采样的概率值,进行随机采样。

把一个大规模分类问题转化为一个2分类问题,通过这种方式优化计算速度。

以cbow模型为例,给定上下文输入,将需要预测的中心词作为正样本,除此之外,通过词表随机采样的方式,选择neg个负样本,这样在训练的时候,只需要判断输出结果是正还是负(二分类),而不是初始的将所有词的概率值都计算输出并取前面的大规模分类问题。

代码

数据处理

流程如下图所示

import torch
import torch.nn as nn
import io
import os
import sys
import requests
from collections import OrderedDict
import math
import random
import numpy as np
from tqdm import tqdm
from torch.utils.data import Dataset
from time import sleep
# #下载语料用来训练word2vec
# def download():
#     #可以从百度云服务器下载一些开源数据集(dataset.bj.bcebos.com)
#     text_url = "https://dataset.bj.bcebos.com/word2vec/text8.txt"
#     #使用python的requests包下载数据集到本地
#     web_request = requests.get(text_url)
#     text = web_request.content
#     #把下载后的文件存储在当前目录的text8.txt文件内
#     with open("./text8.txt", "wb") as f:
#         f.write(text)
#     f.close()
## #读取整个语料库数据
def load_text(filepath):
    with open(filepath,'r') as f:
       corpus=f.read()#.strip("\n")
    f.close()
    return corpus

#对语料库中的词语进行切分,去除换行符,空格等,并可以将大写转化为小写
def word_preprocess(corpus):
    # strip() 方法在没有参数的情况下,会去除字符串开头和结尾的所有空白字符(该方法只能删除开头或是结尾的字符,不能删除中间部分的字符。)
    # 有参数则只去除指定参数
    # 这包括空格制表符(\t)、换行符(\n)、回车符(\r)、换页符(\f)和垂直制表符(\v)。
    corpus=corpus.strip()#.lower()
    #通过指定分隔符对一个完整的字符串进行切片,如果参数 num 有指定值,将一个完整的字符串分隔 num+1 个子字符串
    corpus=corpus.split(" ")
    return corpus

# #在经过切词后,需要对语料进行统计,为每个词构造ID。一般来说,可以根据每个词在语料中出现的频次构造ID,频次越高,ID越小,便于对词典进行管理。
# # 构造词典,统计每个词的频率,并根据频率将每个词转换为一个整数id
def word_freq2id(corpus):
# 首先统计每个不同词的频率(出现的次数),使用一个词典记录
    word_freq_dict=dict()
    for word in corpus:
        #如果第一次不在词典里,则创建键值对,初始数字为0;
        if word not in word_freq_dict:
            word_freq_dict[word]=0
        #若已存在词典里,则数字+1
        word_freq_dict[word]+=1

    #将这个词典中的词,按照出现次数排序,出现次数越高,排序越靠前,赋予的id值越小
    # 一般来说,出现频率高的高频词往往是:I,the,you这种代词,而出现频率低的词,往往是一些名词,如:nlp
    word_freq_dict=sorted(word_freq_dict.items(),key=lambda x:x[1],reverse=True)
     # 构造3个不同的词典,分别存储,
     # 每个词到id的映射关系:word2id_dict
     # 每个id出现的频率:word2id_freq
     # 每个id到词典映射关系:id2word_dict
    word2id_dict=dict()
    word2id_freq=dict()
    id2word_dict=dict()

 # 按照频率,从高到低,开始遍历每个单词,并为这个单词构造一个独一无二的id
    for word,freq in word_freq_dict:
        #根据word词典当前存储的词数作为频率,前面已经排序,频率越高,越靠前,id越小
        curr_id=len(word2id_dict)
        word2id_dict[word]=curr_id
        word2id_freq[word2id_dict[word]]=freq
        id2word_dict[curr_id]=word
    return word2id_dict,word2id_freq,id2word_dict

# #把语料转换为id序列
def corpus2id(corpus,word2id_dict):
    # 使用一个循环,将语料中的每个词替换成对应的id,以便于神经网络进行处理
    corpus=[word2id_dict[word] for word in corpus]
    return corpus

# 使用下采样算法(subsampling)处理语料,强化训练效果
def subsampling(corpus, word2id_freq):
    # 这个discard函数决定了一个词会不会被替换,这个函数是具有随机性的,每次调用结果不同
    # 如果一个词的频率很大,那么它被遗弃的概率就很大
    def discard(word_id):
        return random.uniform(0, 1) < 1 - math.sqrt(
            1e-4 / word2id_freq[word_id] * len(corpus))

    corpus = [word for word in corpus if not discard(word)]
    return corpus

def create_context_target(corpus,window_size):
    #python x[:] x[::] x[:::]用法
    # 负数在左侧,则从后往前数n个的位置开始
    #负数在右侧,则是排除了后n个的位置结束
    #所以这里的target是把语料库前后window_size个字符排除,确保每个target都有window_size大小的上下文
    targets=corpus[window_size:-window_size]
    contexts=[]
    total=len(corpus)-window_size-window_size
    #  tqdm是Python中专门用于进度条美化的模块,通过在非while的循环体内嵌入tqdm,可以得到一个能更好展现程序运行过程的提示进度条
    #这里遍历每一个target,以其为中心,左右window_size范围寻找上下文
    for idx in tqdm(range(window_size,len(corpus)-window_size),total=total,leave=False):
        context=[]
        for t in range(-window_size,window_size+1):
            if t==0:#此时就是target,略过
                continue
            context.append(corpus[idx+t])
        #每一个target对应一个context列
        contexts.append(context)
    return contexts,targets

class NegativeSampler:
    def __init__(self,word2id_dict,word2id_freq,id2word_dict,neg_num,power=0.75):
        self.word2id_dict=word2id_dict
        self.word2id_freq=word2id_freq
        self.id2word_dict=id2word_dict
        self.neg_num=neg_num
        #计算存储所有单词的频率之和
        total_freq=0
        for word_id,freq in word2id_freq.items():
            #把每个中心词的频率进行幂计算
            new_freq=math.pow(freq,power)
            word2id_freq[word_id]=new_freq
            total_freq+=new_freq
        #存储词语总数
        self.vocab_size=len(word2id_freq)
        #存储计算每个词汇被选取成负样本的概率值,以词汇在语料库中出现的频率比值计算得到
        self.neg_word_prob=np.zeros(self.vocab_size)
        for word_id,freq in word2id_freq.items():
            self.neg_word_prob[word_id]=freq/total_freq

    def negative_sample(self,target):
       #中心词的数量
       target_size=len(target)
       #计算得到的负样本是一个target_size*neg_num的张量
       negative_sample=np.zeros((target_size,self.neg_num),dtype=np.int32)
       for i in range(target_size):
           #浅拷贝(copy):拷贝父对象,不会拷贝对象的内部的子对象。
           p=self.neg_word_prob.copy()
           target_idx=target[i]
           #中心词不会被选作负样本,概率值为0
           print(target_idx)
           p[target_idx]=0
           p/=p.sum()
           #从大小为3的np.arange(5)生生成一个非均匀的随机样本,没有替换(重复):
           #>>> np.random.choice(5, 3, replace=False, p=[0.1, 0, 0.3, 0.6, 0])
           #array([2, 3, 0])
           negative_sample[i,:]=np.random.choice(self.vocab_size,size=self.neg_num,replace=False,p=p)
       return negative_sample

#自定义数据集,继承Dataset类并重写类的三个函数
class CBOWDataset(Dataset):
    a = 0
    def __init__(self,contexts,targets,negative_sampler):
        self.contexts=contexts
        self.targets=targets
        self.negative_sampler=negative_sampler
    def __len__(self):
        return len(self.contexts)
    def __getitem__(self,idx):

        #对于一组样本,给定idx,输出对应的context(2*window_size大小)在cbow模型里作为输入,target(包含一个正样本中心词和neg个随机抽取的负样本)
        # 及标签labels(和target一一对应,正样本中心词为1,负样本为0)
        contexts=self.contexts[idx]
        targets=[self.targets[idx]]
        #这里会返回一个中心词所对应的neg个负样本
        negative_samples=self.negative_sampler.negative_sample(targets)
        #targets实际上等效于实际的输出值,包含一个正样本中心词和neg个随机抽取的负样本
        targets+=[x for x in negative_samples[0]]
        labels=[1]+[0 for _ in range(len((negative_samples[0])))]

        item={
            "contexts":contexts,
            "targets":targets,
            "labels":labels
        }

        if self.a==0:
            print("item:")
            print(item)
            self.a=1
        return item
    def generate_batch(self, item_list):
        contexts = [x["contexts"] for x in item_list]
        targets = [x["targets"] for x in item_list]
        labels = [x["labels"] for x in item_list]

        outputs = {
            "contexts": torch.LongTensor(contexts),
            "targets": torch.LongTensor(targets),
            "labels": torch.LongTensor(labels),
        }

        return outputs

class SkipGramDataset(Dataset):
    def __init__(self, contexts, centers, negative_sampler):
        self.contexts = contexts
        self.centers = centers
        self.negative_sampler = negative_sampler

    def __len__(self):
        return len(self.contexts)

    def __getitem__(self, idx):
        #skip和cbow的核心区别就是,中心词作为输入,上下文词和负采样值作为输出
        center = self.centers[idx]
        context = self.contexts[idx]
        negative_samples = self.negative_sampler.get_negative_sample(context)
        #z.reshape(-1)变成只有一行的数组
        #.tolist()将数组或矩阵转化为列表
        negative_samples = negative_samples.reshape(-1).tolist()
        label = [1] * len(context) + [0] * len(negative_samples)
        context_negative_samples = context + negative_samples

        item = {
            "center": center,
            "context": context_negative_samples,
            "label": label,
        }
        return item

    def generate_batch(self, item_list):
        center_ids = [x["center"] for x in item_list]
        context_ids = [x["context"] for x in item_list]
        labels = [x["label"] for x in item_list]

        outputs = {
            "center_ids": torch.LongTensor(center_ids),
            "context_ids": torch.LongTensor(context_ids),
            "labels": torch.LongTensor(labels),
        }

        return outputs
def test():
    import os
    import sys
    from torch.utils.data import DataLoader
    os.chdir(sys.path[0])

    filepath = "./text8.txt"
    window_size = 5
    neg_num = 3

    #读取语料库
    corpus=load_text(filepath)
    print("s1")
    print(corpus[:10])
    #语料库预处理
    corpus=word_preprocess(corpus)
    print("s2")
    print(corpus[:10])
    #词汇标签化
    word2id_dict, word2id_freq, id2word_dict=word_freq2id(corpus)
    corpus=corpus2id(corpus,word2id_dict)
    print("s3")
    print(corpus[:10])
        #下采样
   # corpus=subsampling(corpus,word2id_freq)

    #中心词及上下文配对选择
    contexts,targets=create_context_target(corpus,window_size)
    print("s6")
    print(contexts[:10])
    print("s7")
    print(targets[:10])
    #负采样
    negative_sampler=NegativeSampler(word2id_dict, word2id_freq, id2word_dict,neg_num=neg_num)
    print("s8")
  #  print(negative_sampler[:10])
    cbow_dataset=CBOWDataset(contexts,targets,negative_sampler)

    cbow_dataloader = DataLoader(
        dataset=cbow_dataset,
        batch_size=10,
        shuffle=False,
        collate_fn=cbow_dataset.generate_batch,
    )

    for batch in tqdm(cbow_dataloader, total=len(cbow_dataloader)):
        pass

    # sg_dataset = SkipGramDataset(contexts, targets, negative_sampler)
    # sg_dataloader = DataLoader(
    #     dataset=sg_dataset,
    #     batch_size=10,
    #     shuffle=False,
    #     collate_fn=sg_dataset.generate_batch,
    # )
    #
    # for batch in tqdm(sg_dataloader, total=len(sg_dataloader)):
    #     pass


if __name__ == "__main__":
    test()


训练

trainer.py训练过程可视化

import os
import torch
from tqdm import tqdm
from tensorboardX import SummaryWriter

class Trainer():
    def __init__(self,
                 model,
                 optimizer,
                 train_dataloader,
                 outputs_dir,
                 num_epochs,
                 device,
                 ):
        self.model = model
        self.optimizer = optimizer
        self.train_dataloader = train_dataloader
        self.outputs_dir = outputs_dir
        self.num_epochs = num_epochs
        self.device = device
        self.writer = SummaryWriter(outputs_dir)

    def train(self):
        model = self.model
        optimizer = self.optimizer
        train_dataloader = self.train_dataloader
        total_loss = 0

        for epoch in tqdm(range(self.num_epochs), total=self.num_epochs):
            epoch_loss = 0
            for idx, batch in tqdm(enumerate(train_dataloader), total=len(train_dataloader), leave=False,
                                   desc=f"Epoch {epoch + 1}"):
                inputs = {k: v.to(self.device) for k, v in batch.items()}

                loss = model(inputs)

                optimizer.zero_grad()
                loss.backward()
                optimizer.step()

                epoch_loss += loss.item()
                total_loss += loss.item()

                global_step = epoch * len(train_dataloader) + idx + 1
                avg_loss = total_loss / global_step
                self.writer.add_scalar("Train-Step-Loss", avg_loss, global_step=global_step)

            epoch_loss /= len(train_dataloader)
            self.writer.add_scalar("Train-Epoch-Loss", epoch_loss, global_step=epoch)
            for name, params in model.named_parameters():
                self.writer.add_histogram(name, params, global_step=epoch)

            save_name = f"model_{epoch}.pth"
            save_path = os.path.join(self.outputs_dir, save_name)
            torch.save(model.state_dict(), save_path)


 

train.py实际训练代码

import os
import sys
import time
import torch
import pickle
from torch.utils.data import DataLoader

from tools import word2vec_trainer
from models.nlp import word2vec
from tools import word2vec_build_data
from tools.word2vec_build_data import *

def train_cbow():
    #../..")))  # 返回上上个目录
    filepath = "../../tools/text8.txt"
    #超参数的设置,包括
    window_size = 5    #上下文窗口
    embed_dim = 100    #词向量维度
    batch_size = 100   #批大小
    num_epochs = 10    #训练epoch
    neg_num = 5        #负样本数
    learning_rate = 1e-3 #学习率
    #记录开始训练时间,start
    now_time = time.strftime("%Y%m%d-%H%M%S", time.localtime())
    outputs_dir = f"../outputs/cbow-{now_time}"
    os.makedirs(outputs_dir, exist_ok=True)
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    # 读取语料库
    corpus= load_text(filepath)
    # 语料库预处理
    corpus=word_preprocess(corpus)
    # 词汇标签化
    word2id_dict,word2freq_dict,id2word_dict=word_freq2id(corpus)
    corpus=corpus2id(corpus,word2id_dict)
    # 下采样
    corpus=subsampling(corpus,word2freq_dict)
    # 中心词及上下文配对选择
    contexts, targets = create_context_target(corpus, window_size)
    #计算语料库词汇总数
    vocab_size = len(word2id_dict)
    corpus_info = {
        "word2id": word2id_dict,
        "id2word": id2word_dict,
    }
    save_path = os.path.join(outputs_dir, "corpus_info.pkl")
    with open(save_path, "wb") as f:
        pickle.dump(corpus_info, f)
    #负采样
    negative_sampler = NegativeSampler(word2id_dict,word2freq_dict,id2word_dict, neg_num)
    #利用重写的数据类加载数据集
    train_dataset = CBOWDataset(
        contexts=contexts,
        targets=targets,
        negative_sampler=negative_sampler,
    )

    train_dataloader = DataLoader(
        dataset=train_dataset,
        batch_size=batch_size,
        shuffle=True,
        collate_fn=train_dataset.generate_batch,
        num_workers=0,
        pin_memory=True,
    )

    model = word2vec.CBOW(vocab_size, embed_dim)
    model = model.to(device)

    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

    trainer = word2vec_trainer.Trainer(
        model=model,
        optimizer=optimizer,
        train_dataloader=train_dataloader,
        outputs_dir=outputs_dir,
        num_epochs=num_epochs,
        device=device,
    )

    trainer.train()


def train_skipgram():
    filepath = "../../tools/text8.txt"
    window_size = 5
    embed_dim = 100
    batch_size = 100
    num_epochs = 10
    negative_sample_size = 5
    learning_rate = 1e-3
    now_time = time.strftime("%Y%m%d-%H%M%S", time.localtime())
    outputs_dir = f"../outputs/skipgram-{now_time}/"
    os.makedirs(outputs_dir, exist_ok=True)
    device = torch.device("cuda")

    corpus, word2id, id2word = load_text(filepath)
    contexts, targets = create_context_target(corpus, window_size)
    vocab_size = len(word2id)

    corpus_info = {
        "corpus": corpus,
        "word2id": word2id,
        "id2word": id2word,
        "contexts": contexts,
        "targets": targets,
    }

    with open("../../tools/text8.txt", "wb") as f:
        pickle.dump(corpus_info, f)

    negative_sampler = NegativeSampler(corpus, negative_sample_size)

    train_dataset = SkipGramDataset(
        contexts=contexts,
        centers=targets,
        negative_sampler=negative_sampler,
    )

    train_dataloader = DataLoader(
        dataset=train_dataset,
        batch_size=batch_size,
        shuffle=True,
        collate_fn=train_dataset.generate_batch,
        num_workers=0,
        pin_memory=True,
    )
    model = word2vec.SkipGram(vocab_size, embed_dim)
    model = model.to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

    # trainer = Trainer(
    #     model=model,
    #     optimizer=optimizer,
    #     train_dataloader=train_dataloader,
    #     outputs_dir=outputs_dir,
    #     num_epochs=num_epochs,
    #     device=device,
    # )
    #
    # trainer.train()

if __name__ == "__main__":
    os.chdir(sys.path[0])
    train_cbow()
   # train_skipgram()

参考资料

  1. 《动手学深度学习》 — 动手学深度学习 2.0.0 documentation

word2vec原理(一): 词向量、CBOW与Skip-Gram模型基础-CSDN博客

https://zhuanlan.zhihu.com/p/638558204

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值