subword系列算法

不同于传统分词的新奇方法

Posted by OD on November 10, 2020

subword系列算法

1. 前言

nlp领域目前已经发展到一个非常高的层次了,这个层次不仅仅是模型和数据方面,还涌现出了很多非常巧妙的trick,这篇文章就记录一下关于tokenization方面的工作。

 所谓的tokenization其实就是将文本切分成words或者subwords,然后转成ids以便模型处理。最初的nlp分词非常简单,对于英语类以空格分割的语言来说,简单的以空格进行分割就行,不过这样简单的处理还存在一些问题,比如说“Don’t you love 🤗 Transformers? We sure do.”,如果简单分割,那么就会得到[“Don’t”, “you”, “love”, “🤗”, “Transformers?”, “We”, “sure”, “do.”],对于“Transformers?” 或者 “do.”这样的分词结果,显然是不合适,所以还需要考虑一下符号的影响,这可以通过现在很多开源工具实现,比如spaCy或者Moses等等。不过很明显会有个问题,那就是你切分的越细,得到的vocabulary越大,对于存储空间的要求也越大,虽然大部分情况下我们的词典都不会特别大,但是总是存在意外情况,尤其是Bert这类对于语料越大效果越好的模型的出现,更是加剧了这种情况。如果切分成words会导致内存问题,那么一个想法就是做character级别的tokenization,对于英文来说只有26个,那么算上大小写也只有52个,这可以大大缓解过大的embedding matrix问题,但是,character级别需要模型去从序列顺序中学习word的表示,模型在这方面显然不如直接切分word来的直观,所以这类方法会有性能方面的损失。在这种情况下,混合了wordcharacter的一些新方法应运而出,这类方法被称为subword

2.subword tokenization

2.1 Byte Pair Encoding

 这种算法其实很简单,从其名字基本可以了解到一些东西,最显眼的就是pair这个词。它的做法是每次使用频次最高的一个字节对来替换这两个字节。

 算法流程:

  1. 准备足够大的训练语料
  2. 确定期望的subword词表大小
  3. 将单词拆分为字符序列并在末尾添加后缀“ </ w>”(这里不是必须,可以添加任意可以代表结束的字符),统计单词频率。 本阶段的subword的粒度是字符。 例如,“ low”的频率为5,那么我们将其改写为“ l o w </ w>”:5
  4. 统计每一个连续字节对的出现频率,选择最高频者合并成新的subword
  5. 重复第4步直到达到第2步设定的subword词表大小或下一个最高频的字节对出现频率为1

2.1.1 构建subword字典

 有输入的word和其频次,首先将word分成character,并将subword词典初始化为character

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w e s t </w>': 6, 'w i d e s t </w>': 3}

Iter 1, 最高频连续字节对”e“和”s“出现了6+3=9次,合并成”es“,将”es“加入subword词典。输出:

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3}

Iter 2, 最高频连续字节对”es“和”t“出现了6+3=9次, 合并成”est“,并将”est“加入subword词典。输出:

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est </w>': 6, 'w i d est </w>': 3}

 Iter 3, 以此类推,最高频连续字节对为”est“和”</ w>” 输出,同样加入subword词典:

{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}

 ……

 Iter n, 继续迭代直到达到预设的subword词表大小或下一个最高频的字节对出现频率为1。

2.1.2 编码

 根据2.1.1得到了subword的字典,对单词就可以进行编码了,将字典按照字符长度递减排列,然后依次遍历词典中的subword,如果单词包含了该subword,那么就用该subword加入单词的分解集合中,对剩余的字符进行迭代。这里将词典首先排序,便可以保证最先加入集合的subword是最后生成的。若最后还有一些字符序列无法在字典中找到,则替换为特殊的subword,比如< unk >.

 同样举个例子, 已有排好序的词典:

[“errrr</w>”, “tain</w>”, “moun”, “est</w>”, “high”, “the</w>”, “a</w>”]

 对单词”mountain</ w>“,遍历词典,首先看到“tain</ w>”出现在了单词中,加入该subword,对剩余的”moun“继续便利,下一个subwordmoun”匹配,加入,那么便得到了单词的分解结果为:[“moun”, “tain</ w>”]

2.1.3 解码

 将tokens拼接在一起即可,注意”< /w>”代表句子结束。

# 编码序列
[“the</w>”, “high”, “est</w>”, “moun”, “tain</w>”]

# 解码序列
“the</w> highest</w> mountain</w>”

2.2 WordPiece

wordpiece思路基本与Byte Pair Encoding,唯一的不同就是在进行合并tokens对的时候不是使用max frequency,而是使用概率来确定合并哪两个tokens对,计算公式为:

\(score(A,B)=Frequency(A,b)/(Frequency(A)*Frequency(B))\)  取score最大的tokens对进行合并,这么做也很直观,类似于词袋模型中的tfidf。

transformers 库中的BERT tokenizer即是使用的这种方法:

>>> from transformers import BertTokenizer
>>> tokenizer = BertTokenizer.from_pretrained("bert-base-cased")

>>> sequence = "A Titan RTX has 24GB of VRAM"
>>> tokenized_sequence = tokenizer.tokenize(sequence)
>> print(tokenized_sequence)
['A', 'Titan', 'R', '##T', '##X', 'has', '24', '##GB', 'of', 'V', '##RA', '##M']

 ”##”代表当前序列是前面字节的后缀,比如’R’, ‘##T’, ‘##X’三个序列,应该拼合为”RTX”.这里没有使用结束符号,而是使用拼接符号来确保单词何时结束。

2.3 Unigram Language Model

 这是一种基于语言模型的tokenization方式,不同于BPE或者WordPiece,该方法不是从基础字符开始然后通过一定的规则进行合并,这种方法通过一个很大的词典开始,然后渐进的缩减词典规模。生成词典的具体流程如下:

P(x):P(subwords), V:词典, L:Likelihood to be maximized

其中,L的计算公式为:

其中,$|D|$为词典的大小,$S(x)$为词x的所有可能的tokenization组合集合。

 得到词典以后,就是进行tokenization了,不过要注意的是tokenization的方式可能不止一种,比如有以下的词典:

['b', 'g', 'h', 'n', 'p', 's', 'u', 'ug', 'un', 'hug']

那么对于单词“hugs” ,可以有如下的几种组合: ['hug', 's'], ['h', 'ug', 's'] 或者 ['h', 'u', 'g', 's'],由于生成字典的时候每个subword都是有对应的概率的,所以可以取tokens概率乘积最大的组合或者以概率随机取一个组合。

2.4 SentencePiece

 仔细看一下上面的几种方法可以发现,他们都是需要实现构建一个词典,然后迭代,而且上面几种方法对于没有分隔符的语言比如中文来说并不适用,因为对于中文这类语言来说,单个字已经是最小单元了,无法细分为更小的单元了。所以上面几种方法都是与语言相关的,那么,有没有language-independent 的方法呢?当当当,这时候就到了SentencePiece粉墨登场的时候了。

SentencePiece主要由4个模块组成:Normalizer,Trainer,Encoder, 以及DecoderNormalizer是一个将语义等价的Unicode字符归一化为规范形式的模块。 Trainer从归一化语料中训练subword分割模型,一般使用BPE或者unigram。 Encoder内部执行Normalizer对输入文本进行归一化,并将输入文本用Trainer训练的subword模型标记成subword序列。 Decodersubword序列转换为归一化文本。这四个模块有如下的关系:Decoder( Encoder (Normalizer(corpus))) = Normalizer (corpus),作者称这为lossless tokenization。通过这些模块,SentencePiece实现了一种端到端的tokenization

transformers库的XlNetALBERT等使用了这种方法:

>>> from transformers import XLNetTokenizer
>>> tokenizer = XLNetTokenizer.from_pretrained('xlnet-base-cased')
>>> tokenizer.tokenize("Don't you love 🤗 Transformers? We sure do.")
['▁Don', "'", 't', '▁you', '▁love', '▁', '🤗', '▁', 'Transform', 'ers', '?', '▁We', '▁sure', '▁do', '.']

其中的 ‘▁’ 代表空格,在decode的时候将该符号替换回空格即可。

3.中文实践

3.1 中文bpe算法

'''
中文bpe算法
'''

def get_pairs(word):
  '''
  :param word: tokens序列,对于中文而言,直接tuple(text)处理即可,处理后中文就变成了字的序列。
  '''
  
  pairs = set()
  prev_char = word[0]
  for char in word[1:]:
      pairs.add((prev_char, char))
      prev_char = char
  return pairs


class Encoder:
    def __init__(self, encoder, bpe_merges):
      '''
      :param encoder: subword词典
      :param bpe_merges: 需要合并的两个subwords
      '''
      
      self.encoder = encoder
      self.decoder = {v: k for k, v in self.encoder.items()}
      self.bpe_ranks = dict(zip(bpe_merges, range(len(bpe_merges))))
      self.cache = {}
      self.max_len = 0

    def bpe(self, token):
      '''
      :param token: 要处理的文本
      '''
      
      if token in self.cache:
        return self.cache[token]
      word = tuple(token)
      # 获取文本的所有字
      
      pairs = get_pairs(word)
      if not pairs:
        return token

      while True:
        # 获取需要合并的subword对,注意这里已经排序
        
        bigram = min(pairs, key=lambda pair: self.bpe_ranks.get(pair, float('inf')))
        # 不再继续可以合并
        
        if bigram not in self.bpe_ranks:
          break
        first, second = bigram
        new_word = []
        # i代表first subword左边的所有subword序列
        
        i = 0
        while i < len(word):
          try:
            # 找到了可以合并的subword pair,先将first subword左边的所有subword加入list
            
            j = word.index(first, i)
            new_word.extend(word[i:j])
            i = j
          except:
            # 没有找到可以合并的subword pair
            
            new_word.extend(word[i:])
            break
          
          if word[i] == first and i < len(word) - 1 and word[i + 1] == second:
            # 找到了可以合并的subword对
            
            new_word.append(first + second)
            i += 2
          else:
            new_word.append(word[i])
            i += 1
        new_word = tuple(new_word)
        word = new_word
        if len(word) == 1:
          break
        else:
          pairs = get_pairs(word)
      word = ' '.join(word)
      self.cache[token] = word
      return word

    def encode(self, text):
        return [self.encoder.get(token, 1) for token in self.tokenize(text)]

    def decode(self, tokens):
        text = ''.join([self.decoder[token] for token in tokens])
        return text

    def tokenize(self, text):
        bpe_tokens = []
        bpe_tokens.extend(bpe_token for bpe_token in self.bpe(text).split(' '))
        return bpe_tokens

    def convert_tokens_to_ids(self, tokens):
        return [self.encoder.get(token, 1) for token in tokens]

4.总结

  • 传统词表示方法无法很好的处理未知或罕见的词汇(OOV问题)

  • 传统词tokenization方法不利于模型学习词缀之间的关系

    • E.g. 模型学到的“old”, “older”, andoldest”之间的关系无法泛化到“smart”, “smarter”, and “smartest”。
  • Character embedding作为OOV的解决方法粒度太细

  • Subword粒度在词与字符之间,能够较好的平衡OOV问题