深度学习进阶:自然语言处理
上QQ阅读APP看书,第一时间看更新

2.4 基于计数的方法的改进

上一节我们创建了单词的共现矩阵,并使用它成功地将单词表示为了向量。但是,这个共现矩阵还有许多可以改进的地方。本节我们将对其进行改进,并使用更实用的语料库,获得单词的“真实的”分布式表示。

2.4.1 点互信息

上一节的共现矩阵的元素表示两个单词同时出现的次数。但是,这种“原始”的次数并不具备好的性质。如果我们看一下高频词汇(出现次数很多的单词),就能明白其原因了。

比如,我们来考虑某个语料库中the和car共现的情况。在这种情况下,我们会看到很多“...the car...”这样的短语。因此,它们的共现次数将会很大。另外,car和drive也明显有很强的相关性。但是,如果只看单词的出现次数,那么与drive相比,the和car的相关性更强。这意味着,仅仅因为the是个常用词,它就被认为与car有很强的相关性。

为了解决这一问题,可以使用点互信息(Pointwise Mutual Information, PMI)这一指标。对于随机变量xy,它们的PMI定义如下(关于概率,将在3.5.1节详细说明):

其中,Px)表示x发生的概率,Py)表示y发生的概率,Px, y)表示xy同时发生的概率。PMI的值越高,表明相关性越强。

在自然语言的例子中,Px)就是指单词x在语料库中出现的概率。假设某个语料库中有10000个单词,其中单词the出现了100次,则P("the")==0.01。另外,Px, y)表示单词xy同时出现的概率。假设the和car一起出现了10次,则

现在,我们使用共现矩阵(其元素表示单词共现的次数)来重写式(2.2)。这里,将共现矩阵表示为C,将单词xy的共现次数表示为Cx, y),将单词xy的出现次数分别表示为Cx)、Cy),将语料库的单词数量记为N,则式(2.2)可以重写为:

根据式(2.3),可以由共现矩阵求PMI。下面我们来具体地算一下。这里假设语料库的单词数量(N)为10000,the出现100次,car出现20次, drive出现10次,the和car共现10次,car和drive共现5次。这时,如果从共现次数的角度来看,则与drive相比,the和car的相关性更强。而如果从PMI的角度来看,结果是怎样的呢?我们来计算一下。

结果表明,在使用PMI的情况下,与the相比,drive和car具有更强的相关性。这是我们想要的结果。之所以出现这个结果,是因为我们考虑了单词单独出现的次数。在这个例子中,因为the本身出现得多,所以PMI的得分被拉低了。式中的“≈”(near equal)表示近似相等的意思。

虽然我们已经获得了PMI这样一个好的指标,但是PMI也有一个问题。那就是当两个单词的共现次数为0时,log20=-∞。为了解决这个问题,实践上我们会使用下述正的点互信息(Positive PMI,PPMI)。

根据式(2.6),当PMI是负数时,将其视为0,这样就可以将单词间的相关性表示为大于等于0的实数。下面,我们来实现将共现矩阵转化为PPMI矩阵的函数。我们把这个函数称为ppmi(C, verbose=False, eps=1e-8) (common/util.py)。

          def ppmi(C, verbose=False, eps=1e-8):
              M = np.zeros_like(C, dtype=np.float32)
              N = np.sum(C)
              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表示共现矩阵,verbose是决定是否输出运行情况的标志。当处理大语料库时,设置verbose=True,可以用于确认运行情况。在这段代码中,为了仅从共现矩阵求PPMI矩阵而进行了简单的实现。具体来说,当单词xy的共现次数为Cx, y)时,,,,进行这样近似并实现。另外,在上述代码中,为了防止np.log2(0)=-inf而使用了微小值eps。

在2.3.5节中,为了防止“除数为0”的错误,我们给分母添加了一个微小值。这里也一样,通过将np.log(x)改为np.log(x + eps),可以防止对数运算发散到负无穷大。

现在将共现矩阵转化为PPMI矩阵,可以像下面这样进行实现(ch02/ppmi.py)。

        import sys
        sys.path.append('..')
        import numpy as np
        from common.util import preprocess, create_co_matrix, cos_similarity,
              ppmi

        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)

运行该文件,可以得到下述结果。

        covariance matrix
        [[0 1 0 0 0 0 0]
         [1 0 1 0 1 1 0]
         [0 1 0 1 0 0 0]
         [0 0 1 0 1 0 0]
         [0 1 0 1 0 0 0]
         [0 1 0 0 0 0 1]
         [0 0 0 0 0 1 0]]
        --------------------------------------------------
        PPMI
        [[ 0.      1.807  0.      0.      0.      0.      0.   ]
         [ 1.807  0.      0.807  0.      0.807  0.807  0.   ]
         [ 0.      0.807  0.      1.807  0.      0.      0.   ]
         [ 0.      0.      1.807  0.      1.807  0.      0.   ]
         [ 0.      0.807  0.      1.807  0.      0.      0.   ]
         [ 0.      0.807  0.      0.      0.      0.      2.807]
         [ 0.      0.      0.      0.      0.      2.807  0.   ]]

这样一来,我们就将共现矩阵转化为了PPMI矩阵。此时,PPMI矩阵的各个元素均为大于等于0的实数。我们得到了一个由更好的指标形成的矩阵,这相当于获取了一个更好的单词向量。

但是,这个PPMI矩阵还是存在一个很大的问题,那就是随着语料库的词汇量增加,各个单词向量的维数也会增加。如果语料库的词汇量达到10万,则单词向量的维数也同样会达到10万。实际上,处理10万维向量是不现实的。

另外,如果我们看一下这个矩阵,就会发现其中很多元素都是0。这表明向量中的绝大多数元素并不重要,也就是说,每个元素拥有的“重要性”很低。另外,这样的向量也容易受到噪声影响,稳健性差。对于这些问题,一个常见的方法是向量降维。

2.4.2 降维

所谓降维(dimensionality reduction),顾名思义,就是减少向量维度。但是,并不是简单地减少,而是在尽量保留“重要信息”的基础上减少。如图2-8所示,我们要观察数据的分布,并发现重要的“轴”。

图2-8 降维示意图:发现重要的轴(数据分布广的轴),将二维数据表示为一维数据

在图2-8中,考虑到数据的广度,导入了一根新轴,以将原来用二维坐标表示的点表示在一个坐标轴上。此时,用新轴上的投影值来表示各个数据点的值。这里非常重要的一点是,选择新轴时要考虑数据的广度。如此,仅使用一维的值也能捕获数据的本质差异。在多维数据中,也可以进行同样的处理。

向量中的大多数元素为0的矩阵(或向量)称为稀疏矩阵(或稀疏向量)。这里的重点是,从稀疏向量中找出重要的轴,用更少的维度对其进行重新表示。结果,稀疏矩阵就会被转化为大多数元素均不为0的密集矩阵。这个密集矩阵就是我们想要的单词的分布式表示。

降维的方法有很多,这里我们使用奇异值分解(Singular Value Decomposition,SVD)。SVD将任意矩阵分解为3个矩阵的乘积,如下式所示:

如式(2.7)所示,SVD将任意的矩阵X分解为USV这3个矩阵的乘积,其中UV是列向量彼此正交的正交矩阵,S是除了对角线元素以外其余元素均为0的对角矩阵。图2-9中直观地表示出了这些矩阵。

图2-9 基于SVD的矩阵变换(白色部分表示元素为0)

在式(2.7)中,U是正交矩阵。这个正交矩阵构成了一些空间中的基轴(基向量),我们可以将矩阵U作为“单词空间”。S是对角矩阵,奇异值在对角线上降序排列。简单地说,我们可以将奇异值视为“对应的基轴”的重要性。这样一来,如图2-10所示,减少非重要元素就成为可能。

图2-10 基于SVD的降维示意图

如图2-10所示,矩阵S的奇异值小,对应的基轴的重要性低,因此,可以通过去除矩阵U中的多余的列向量来近似原始矩阵。用我们正在处理的“单词的PPMI矩阵”来说明的话,矩阵X的各行包含对应的单词ID的单词向量,这些单词向量使用降维后的矩阵U′表示。

单词的共现矩阵是正方形矩阵,但在图2-10中,为了和之前的图一致,画的是长方形。另外,这里对SVD的介绍仅限于最直观的概要性的说明。想从数学角度仔细理解的读者,请参考文献[20]等。

2.4.3 基于SVD的降维

接下来,我们使用Python来实现SVD,这里可以使用NumPy的linalg模块中的svd方法。linalg是linear algebra(线性代数)的简称。下面,我们创建一个共现矩阵,将其转化为PPMI矩阵,然后对其进行SVD (ch02/count_method_small.py)。

          import sys
          sys.path.append('..')
          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)

SVD执行完毕。上面的变量U包含经过SVD转化的密集向量表示。现在,我们来看一下它的内容。单词ID为0的单词向量如下。

          print (C[0])  # 共现矩阵
          # [0 1 0 0 0 0 0]

          print (W[0])  # PPMI矩阵
          # [ 0.      1.807  0.      0.      0.      0.      0.  ]

          print (U[0])  # SVD
          # [ 3.409e-01 -1.110e-16 -1.205e-01 -4.441e-16  0.000e+00 -9.323e-01
          #   2.226e-16]

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

        print (U[0, :2])
        # [ 3.409e-01 -1.110e-16]

这样我们就完成了降维。现在,我们用二维向量表示各个单词,并把它们画在图上,代码如下。

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

plt.annotate(word, x, y)函数在2D图形中坐标为(x, y)的地方绘制单词的文本。执行上述代码,结果如图2-11所示根据操作系统的种类或Matplotlib版本的不同,输出的图可能和图2-11所有不同。

图2-11 对共现矩阵执行SVD,并在图上绘制各个单词的二维向量(i和goodbye重叠)

观察该图可以发现,goodbye和hello、you和i位置接近,这是比较符合我们的直觉的。但是,因为我们使用的语料库很小,有些结果就比较微妙。下面,我们将使用更大的PTB数据集进行相同的实验。首先,我们简单介绍一下PTB数据集。

如果矩阵大小是N,SVD的计算的复杂度将达到ON3)。这意味着SVD需要与N的立方成比例的计算量。因为现实中这样的计算量是做不到的,所以往往会使用Truncated SVD[21]等更快的方法。Truncated SVD通过截去(truncated)奇异值较小的部分,从而实现高速化。下一节,作为另一个选择,我们将使用sklearn库的Truncated SVD。

2.4.4 PTB数据集

到目前为止,我们使用了非常小的文本数据作为语料库。这里,我们将使用一个大小合适的“真正的”语料库——Penn Treebank语料库(以下简称为PTB)。

PTB语料库经常被用作评价提案方法的基准。本书中我们将使用PTB语料库进行各种实验。

我们使用的PTB语料库在word2vec的发明者托马斯·米科洛夫(Tomas Mikolov)的网页上有提供。这个PTB语料库是以文本文件的形式提供的,与原始的PTB的文章相比,多了若干预处理,包括将稀有单词替换成特殊字符<unk>(unk是unknown的简称),将具体的数字替换成“N”等。下面,我们将经过这些预处理之后的文本数据作为PTB语料库使用。作为参考,图2-12给出了PTB语料库的部分内容。

如图2-12所示,在PTB语料库中,一行保存一个句子。在本书中,我们将所有句子连接起来,并将其视为一个大的时序数据。此时,在每个句子的结尾处插入一个特殊字符<eos>(eos是end of sentence的简称)。

图2-12 PTB语料库(文本文件)的例子

本书不考虑句子的分割,将多个句子连接起来得到的内容视为一个大的时序数据。当然,也可以以句子为单位进行处理,比如,以句子为单位计算词频。不过,考虑到简单性,本书不进行以句子为单位的处理。

在本书中,为了方便使用Penn Treebank数据集,我们准备了专门的Python代码。这个文件在dataset/ptb.py中,并假定从章节目录(ch01、ch02、...)使用。比如,我们将当前目录移到ch02目录,并在这个目录中调用pythonshow_ptb.py。使用ptb.py的例子如下所示(ch02/show_ptb.py)。

        import sys
        sys.path.append('..')
        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'])

后面再具体解释这段代码,我们先来看一下它的执行结果。

        corpus size: 929589
        corpus[:30]: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
        19 20 21 22 23
         24 25 26 27 28 29]

        id_to_word[0]: aer
        id_to_word[1]: banknote
        id_to_word[2]: berlitz

        word_to_id['car']: 3856
        word_to_id['happy']: 4428
        word_to_id['lexus']: 7426

语料库的用法和之前一样。corpus中保存了单词ID列表,id_to_word是将单词ID转化为单词的字典,word_to_id是将单词转化为单词ID的字典。

如上面的代码所示,使用ptb.load_data()加载数据。此时,指定参数’train'、'test’和’valid’中的一个,它们分别对应训练用数据、测试用数据和验证用数据中的一个。以上就是ptb.py文件的使用方法。

2.4.5 基于PTB数据集的评价

下面,我们将基于计数的方法应用于PTB数据集。这里建议使用更快速的SVD对大矩阵执行SVD,为此我们需要安装sklearn模块。当然,虽然仍可以使用基本版的SVD(np.linalg.svd()),但是这需要更多的时间和内存。我们把源代码一并给出,如下所示(ch02/count_method_big.py)。

        import sys
        sys.path.append('..')
        import numpy as np
        from common.util import most_similar, create_co_matrix, ppmi
        from dataset import ptb
        window_size = 2
        wordvec_size = 100

        corpus, word_to_id, id_to_word = ptb.load_data('train')
        vocab_size = len(word_to_id)
        print ('counting  co-occurrence ...')
        C = create_co_matrix(corpus, vocab_size, window_size)
        print ('calculating PPMI ...')
        W = ppmi(C, verbose=True)

        print ('calculating SVD ...')
        try:
            # truncated SVD (fast!)
            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 (slow)
            U, S, V = np.linalg.svd(W)

        word_vecs = U[:, :wordvec_size]

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

这里,为了执行SVD,我们使用了sklearn的randomized_svd()方法。该方法通过使用了随机数的Truncated SVD,仅对奇异值较大的部分进行计算,计算速度比常规的SVD快。剩余的代码和之前使用小语料库时的代码差不太多。执行代码,可以得以下结果(因为使用了随机数,所以在使用Truncated SVD的情况下,每次的结果都不一样)。

        [query] you
         i: 0.702039909619
         we: 0.699448543998
         've: 0.554828709147
         do: 0.534370693098
         else: 0.512044146526

        [query] year
         month: 0.731561990308
         quarter: 0.658233992457
         last: 0.622425716735
         earlier: 0.607752074689
         next: 0.601592506413

        [query] car
         luxury: 0.620933665528
         auto: 0.615559874277
         cars: 0.569818364381
         vehicle: 0.498166879744
         corsica: 0.472616831915

        [query] toyota
         motor: 0.738666107068
         nissan: 0.677577542584
         motors: 0.647163210589
         honda: 0.628862370943
         lexus: 0.604740429865

观察结果可知,首先,对于查询词you,可以看到i、we等人称代词排在前面,这些都是在语法上具有相同用法的词。再者,查询词year有month、quarter等近义词,查询词car有auto、vehicle等近义词。此外,将toyota作为查询词时,出现了nissan、honda和lexus等汽车制造商名或者品牌名。像这样,在含义或语法上相似的单词表示为相近的向量,这符合我们的直觉。

我们终于成功地将单词含义编码成了向量,真是可喜可贺!使用语料库,计算上下文中的单词数量,将它们转化PPMI矩阵,再基于SVD降维获得好的单词向量。这就是单词的分布式表示,每个单词表示为固定长度的密集向量。

在本章的实验中,我们只看了一部分单词的近义词,但是可以确认许多其他的单词也有这样的性质。期待使用更大的语料库可以获得更好的单词的分布式表示!