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

2.3 基于计数的方法

从介绍基于计数的方法开始,我们将使用语料库(corpus)。简而言之,语料库就是大量的文本数据。不过,语料库并不是胡乱收集数据,一般收集的都是用于自然语言处理研究和应用的文本数据。

说到底,语料库只是一些文本数据而已。不过,其中的文章都是由人写出来的。换句话说,语料库中包含了大量的关于自然语言的实践知识,即文章的写作方法、单词的选择方法和单词含义等。基于计数的方法的目标就是从这些富有实践知识的语料库中,自动且高效地提取本质。

自然语言处理领域中使用的语料库有时会给文本数据添加额外的信息。比如,可以给文本数据的各个单词标记词性。在这种情况下,为了方便计算机处理,语料库通常会被结构化(比如,采用树结构等数据形式)。这里,假定我们使用的语料库没有添加标签,而是作为一个大的文本文件,只包含简单的文本数据。

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

自然语言处理领域存在各种各样的语料库。说到有名的语料库,有Wikipedia和Google News等。另外,莎士比亚、夏目漱石等伟大作家的作品集也会被用作语料库。本章我们先使用仅包含一个句子的简单文本作为语料库,然后再处理更实用的语料库。

现在,我们使用Python的交互模式,对一个非常小的文本数据(语料库)进行预处理。这里所说的预处理是指,将文本分割为单词(分词),并将分割后的单词列表转化为单词ID列表。

下面,我们一边确认一边实现。首先来看一下作为语料库的样本文章。

        >>> text = 'You say goodbye and I say hello.'

这里我们使用由单个句子构成的文本作为语料库。本来文本(text)应该包含成千上万个(连续的)句子,但是,考虑到简洁性,这里先对这个小的文本数据进行预处理。下面,我们对上面的text进行分词。

        >>> 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', '.']

首先,使用lower()方法将所有字母转化为小写,这样可以将句子开头的单词也作为常规单词处理。然后,将空格作为分隔符,通过split(' ')切分句子。考虑到句子结尾处的句号(.),我们先在句号前插入一个空格(即用“ .”替换“.”),再进行分词。

这里,在进行分词时,我们采用了一种在句号前插入空格的“临时对策”,其实还有更加聪明、更加通用的实现方式,比如使用正则表达式。通过导入正则表达式的re模块,使用re.split('(\W+)?', text)也可以进行分词。关于正则表达式的详细信息,可以参考文献[15]。

现在,我们已经可以将原始文章作为单词列表使用了。虽然分词后文本更容易处理了,但是直接以文本的形式操作单词,总感觉有些不方便。因此,我们进一步给单词标上ID,以便使用单词ID列表。为此,我们使用Python的字典来创建单词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

变量id_to_word负责将单词ID转化为单词(键是单词ID,值是单词), word_to_id负责将单词转化为单词ID。这里,我们从头开始逐一观察分词后的words的各个元素,如果单词不在word_to_id中,则分别向word_to_id和id_to_word添加新ID和单词。另外,我们将字典的长度设为新的单词ID,单词ID按0, 1, 2,···逐渐增加。

这样一来,我们就创建好了单词ID和单词的对应表。下面,我们来实际看一下它们的内容。

        >>> id to word
        {0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6:'.'}
        >>> word to id
        {'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}

使用这些词典,可以根据单词检索单词ID,或者反过来根据单词ID检索单词。我们实际尝试一下,如下所示。

        >>> id to word[1]
        'say'
        >>> word to id['hello']
        5

最后,我们将单词列表转化为单词ID列表。这里,我们使用Python的列表解析式将单词列表转化为单词ID列表,然后再将其转化为NumPy数组。

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

列表解析式(list comprehension)或字典解析式(dict comprehension)是一种便于对列表或字典进行循环处理的写法。比如,要创建元素为列表xs = [1,2,3,4]中各个元素的平方的新列表,可以写成 [x**2 for x in xs]。

至此,我们就完成了利用语料库的准备工作。现在,我们将上述一系列处理实现为preprocess()函数( common/util.py)。

        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

使用这个函数,可以按如下方式对语料库进行预处理。

        >>> text = 'You say goodbye and I say hello.'
        >>> corpus , word to id , id to word = preprocess(text)

到这里,语料库的预处理就结束了。这里准备的corpus、word_to_id和id_to_word这3个变量名在本书接下来的很多地方都会用到。corpus是单词ID列表,word_to_id是单词到单词ID的字典,id_to_word是单词ID到单词的字典。

现在,我们已经做好了操作语料库的准备,接下来的目标就是使用语料库提取单词含义。为此,本节我们将考察基于计数的方法。采用这种方法,我们能够将单词表示为向量。

2.3.2 单词的分布式表示

世界上存在各种各样的颜色,有的颜色被赋予了固定的名字,比如钴蓝(cobalt blue)或者锌红(zinc red);颜色也可以通过RGB(Red/Green/Blue)三原色分别存在多少来表示。前者为不同的颜色赋予不同的名字,有多少种颜色,就需要有多少个不同的名字;后者则将颜色表示为三维向量。

需要注意的是,使用RGB这样的向量表示可以更准确地指定颜色,并且这种基于三原色的表示方式很紧凑,也更容易让人想象到具体是什么颜色。比如,即便不知道“深绯”是什么样的颜色,但如果知道它的(R, G, B)=(201, 23, 30),就至少可以知道它是红色系的颜色。此外,颜色之间的关联性(是否是相似的颜色)也更容易通过向量表示来判断和量化。

那么,能不能将类似于颜色的向量表示方法运用到单词上呢?更准确地说,可否在单词领域构建紧凑合理的向量表示呢?接下来,我们将关注能准确把握单词含义的向量表示。在自然语言处理领域,这称为分布式表示

单词的分布式表示将单词表示为固定长度的向量。这种向量的特征在于它是用密集向量表示的。密集向量的意思是,向量的各个元素(大多数)是由非0实数表示的。例如,三维分布式表示是[0.21,-0.45,0.83]。如何构建这样的单词的分布式表示是我们接下来的一个重要课题。

2.3.3 分布式假设

在自然语言处理的历史中,用向量表示单词的研究有很多。如果仔细看一下这些研究,就会发现几乎所有的重要方法都基于一个简单的想法,这个想法就是“某个单词的含义由它周围的单词形成”,称为分布式假设(distributional hypothesis)。许多用向量表示单词的近期研究也基于该假设。

分布式假设所表达的理念非常简单。单词本身没有含义,单词含义由它所在的上下文(语境)形成。的确,含义相同的单词经常出现在相同的语境中。比如“I drink beer.”“We drink wine.”,drink的附近常有饮料出现。另外,从“I guzzle beer.”“We guzzle wine.”可知,guzzle和drink所在的语境相似。进而我们可以推测出,guzzle和drink是近义词(顺便说一下,guzzle是“大口喝”的意思)。

从现在开始,我们会经常使用“上下文”一词。本章说的上下文是指某个单词(关注词)周围的单词。在图2-3的例子中,左侧和右侧的2个单词就是上下文。

图2-3 窗口大小为2的上下文例子。在关注goodbye时,将其左右各2个单词用作上下文

如图2-3所示,上下文是指某个居中单词的周围词汇。这里,我们将上下文的大小(即周围的单词有多少个)称为窗口大小(window size)。窗口大小为1,上下文包含左右各1个单词;窗口大小为2,上下文包含左右各2个单词,以此类推。

这里,我们将左右两边相同数量的单词作为上下文。但是,根据具体情况,也可以仅将左边的单词或者右边的单词作为上下文。此外,也可以使用考虑了句子分隔符的上下文。简单起见,本书仅处理不考虑句子分隔符、左右单词数量相同的上下文。

2.3.4 共现矩阵

下面,我们来考虑如何基于分布式假设使用向量表示单词,最直截了当的实现方法是对周围单词的数量进行计数。具体来说,在关注某个单词的情况下,对它的周围出现了多少次什么单词进行计数,然后再汇总。这里,我们将这种做法称为“基于计数的方法”,在有的文献中也称为“基于统计的方法”。

现在,我们就来看一下基于计数的方法。这里我们使用2.3.1节的语料库和preprocess()函数,再次进行预处理。

        import sys
        sys.path.append('..')
        import numpy as np

        from common.util import preprocess

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

        print (corpus)
        # [0 1 2 3 4 1 5 6]

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

从上面的结果可以看出,词汇总数为7个。下面,我们计算每个单词的上下文所包含的单词的频数。在这个例子中,我们将窗口大小设为1,从单词ID为0的you开始。

从图2-4可以清楚地看到,单词you的上下文仅有say这个单词。用表格表示的话,如图2-5所示。

图2-4 单词you的上下文

图2-5 用表格表示单词you的上下文中包含的单词的频数

图2-5表示的是作为单词you的上下文共现的单词的频数。同时,这也意味着可以用向量[0, 1, 0, 0, 0, 0, 0]表示单词you。

接着对单词ID为1的say进行同样的处理,结果如图2-6所示。

图2-6 用表格表示单词say的上下文中包含的单词的频数

从上面的结果可知,单词say可以表示为向量[1, 0, 1, 0, 1, 1, 0]。对所有的7个单词进行上述操作,会得到如图2-7所示的结果。

图2-7 用表格汇总各个单词的上下文中包含的单词的频数

图2-7是汇总了所有单词的共现单词的表格。这个表格的各行对应相应单词的向量。因为图2-7的表格呈矩阵状,所以称为共现矩阵(co-occurence matrix)。

接下来,我们来实际创建一下上面的共现矩阵。这里,将图2-7的结果按原样手动输入。

        C = np.array([
              [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],
          ], dtype=np.int32)

这就是共现矩阵。使用这个共现矩阵,可以获得各个单词的向量,如下所示。

        print (C[0]) # 单词ID为0的向量
        # [0 1 0 0 0 0 0]

        print (C[4]) # 单词ID为4的向量
        # [0 1 0 1 0 0 0]

        print (C[word_to_id['goodbye']]) # goodbye的向量
        # [0 1 0 1 0 0 0]

至此,我们通过共现矩阵成功地用向量表示了单词。上面我们是手动输入共现矩阵的,但这一操作显然可以自动化。下面,我们来实现一个能直接从语料库生成共现矩阵的函数。我们把这个函数称为create_co_matrix(corpus, vocab_size, window_size=1),其中参数corpus是单词ID列表,参数vocab_size是词汇个数,window_size是窗口大小(common/util.py)。

        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 co_matrix

首先,用元素为0的二维数组对co_matrix进行初始化。然后,针对语料库中的每一个单词,计算它的窗口中包含的单词。同时,检查窗口内的单词是否超出了语料库的左端和右端。

这样一来,无论语料库多大,都可以自动生成共现矩阵。之后,我们都将使用这个函数生成共现矩阵。

2.3.5 向量间的相似度

前面我们通过共现矩阵将单词表示为了向量。下面,我们看一下如何测量向量间的相似度。

测量向量间的相似度有很多方法,其中具有代表性的方法有向量内积或欧式距离等。虽然除此之外还有很多方法,但是在测量单词的向量表示的相似度方面,余弦相似度(cosine similarity)是很常用的。设有x=(x1, x2, x3,···, xn)和y=(y1, y2, y3,···, yn)两个向量,它们之间的余弦相似度的定义如下式所示。

在式(2.1)中,分子是向量内积,分母是各个向量的范数。范数表示向量的大小,这里计算的是L2范数(即向量各个元素的平方和的平方根)。式(2.1)的要点是先对向量进行正规化,再求它们的内积。

余弦相似度直观地表示了“两个向量在多大程度上指向同一方向”。两个向量完全指向相同的方向时,余弦相似度为1;完全指向相反的方向时,余弦相似度为-1。

现在,我们来实现余弦相似度。基于式(2.1),代码如下所示。

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

这里,我们假定参数x和y是NumPy数组。首先对向量进行正规化,然后求两个向量的内积。这里余弦相似度的实现虽然完成了,但是还有一个问题。那就是当零向量(元素全部为0的向量)被赋值给参数时,会出现“除数为0”(zero division)的错误。

解决此类问题的一个常用方法是,在执行除法时加上一个微小值。这里,通过参数指定一个微小值eps(eps是epsilon的缩写),并默认eps=1e-8(=0.00000001)。这样修改后的余弦相似度的实现如下所示(common/util.py)。

          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)

这里我们用了1e-8作为微小值,在这么小的值的情况下,根据浮点数的舍入误差,这个微小值会被其他值“吸收”掉。在上面的实现中,因为这个微小值会被向量的范数“吸收”掉,所以在绝大多数情况下,加上eps不会对最终的计算结果造成影响。而当向量的范数为0时,这个微小值可以防止“除数为0”的错误。

利用这个函数,可以如下求得单词向量间的相似度。这里,我们尝试求you和i(=I)的相似度(ch02/similarity.py)。

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

        c0 = C[word_to_id['you']]  # you的单词向量
        c1 = C[word_to_id['i']]     # i的单词向量
        print (cos_similarity(c0, c1))
        # 0.7071067691154799

从上面的结果可知,you和i的余弦相似度是0.70...。由于余弦相似度的取值范围是-1到1,所以可以说这个值是相对比较高的(存在相似性)。

2.3.6 相似单词的排序

余弦相似度已经实现好了,使用这个函数,我们可以实现另一个便利的函数:当某个单词被作为查询词时,将与这个查询词相似的单词按降序显示出来。这里将这个函数称为most_similar(),通过下列参数进行实现(表2-1)。

        most_similar(query, word_to_id, id_to_word, word_matrix, top=5)

表2-1 most_similar()函数的参数

这里我们直接给出most_similar()函数的实现,如下所示(common/util.py)。

        def most_similar(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

上述实现按如下顺序执行。

取出查询词的单词向量。

分别求得查询词的单词向量和其他所有单词向量的余弦相似度。

基于余弦相似度的结果,按降序显示它们的值。

我们仅对步骤❸进行补充说明。在步骤❸中,将similarity数组中的元素索引按降序重新排列,并输出顶部的单词。这里使用argsort()方法对数组的索引进行了重排。这个argsort()方法可以按升序对NumPy数组的元素进行排序(不过,返回值是数组的索引)。下面是一个例子。

        >>> x = np.array([100 , -20 , 2])
        >>> x.argsort()
        array([1, 2, 0])

上述代码对NumPy数组[100,-20, 2]的各个元素按升序进行了排列。此时,返回的数组的各个元素对应原数组的索引。上述结果的顺序是“第1个元素(-20)”“第2个元素(2)”“第0个元素(100)”。现在我们想做的是将单词的相似度按降序排列,因此,将NumPy数组的各个元素乘以-1后,再使用argsort()方法。接着上面的例子,有如下代码。

        >>> (-x).argsort()
        array([0, 2, 1])

使用这个argsort(),可以按降序输出单词相似度。以上就是most_similar()函数的实现,下面我们来试着使用一下。这里将you作为查询词,显示与其相似的单词,代码如下所示(ch02/most_similar.py)。

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

执行代码后,会得到如下结果。

        [query] you
         goodbye: 0.7071067691154799
         i: 0.7071067691154799
         hello: 0.7071067691154799
         say: 0.0
         and: 0.0

这个结果只按降序显示了you这个查询词的前5个相似单词,各个单词旁边的值是余弦相似度。观察上面的结果可知,和you最接近的单词有3个,分别是goodbye、i(=I)和hello。因为i和you都是人称代词,所以二者相似可以理解。但是,goodbye和hello的余弦相似度也很高,这和我们的感觉存在很大的差异。一个可能的原因是,这里的语料库太小了。后面我们会用更大的语料库进行相同的实验。

如上所述,我们通过共现矩阵成功地将单词表示为了向量。至此,基于计数的方法的基本内容就介绍完了。之所以说“基本”,是因为还有许多事情需要讨论。下一节,我们将说明当前方法的改进思路,并实现这个改进思路。