在自然语言处理(NLP)的任务中,为了训练和预测,神经网络模型依赖于一个词表来表征句子。传统上,词表是通过分词处理句子并挑选出现频率最高的前 N 个词汇构建的。例如,在英文中,词汇总量通常介于 17 万至 100 万个不等。但是,基于计算效率的原因,选取的 N 值通常不能涵盖训练集中的所有单词。这种构建词表的方法会遇到几个问题:

  • 在实际使用中,模型会遇到词表之外的新词汇(即Out Of Vocabulary, OOV),这些词汇无法被模型识别或生成;
  • 词表中那些出现次数较少的词汇在训练时无法得到足够的训练,导致模型不能完全理解它们的语义;
  • 由于单词的不同变形形态,如“look”可能变为“looks”,“looking”,或“looked”,尽管这些变形词意义相近,它们却被视为不同的词条,这不仅增加了训练的复杂性,也加剧了词汇量的膨胀问题。

一种解决方案是采用字符级别的词表表示,这可以解决OOV问题,但这会导致单词的语义信息丢失,并且会使模型的输入序列变得更长,从而增加了模型训练的难度和复杂性。

针对这些挑战,子词模型应运而生。它采用的分割粒度位于完整单词和单个字符之间。例如,“looking”可以被分解为“look”和“ing”两个子词。这些子词又可以用来构造其他单词,比如“look”和“ed”可以组合成“looked”。因此,子词方法不仅显著减少了词典的体积,也能更有效地处理语义相近的词汇。

Byte Pair Encoding(BPE)

BPE 最早是一种数据压缩算法,由 Sennrich 等人于 2015 年引入到 NLP 领域并很快得到推广。该算法简单有效,因而目前它是最流行的方法。

执行步骤:

  1. 准备足够大的训练语料,并确定期望的 Subword 词表大小;
  2. 将单词拆分为成最小单元,比如英文中 26 个字母加上各种符号,作为初始词表;
  3. 在语料上统计单词内相邻单元对的频数,选择频数最高的单元对合并成新的 Subword 单元;
  4. 重复第 3 步,直到达到第 1 步设定的 Subword 词表大小或下一个最高频数为 1(即无法再合并)。

BPE 的优点在于它可以减少词汇表的大小,同时能够处理未见过的单词。

关于 BPE 可视化过程介绍请阅读这篇博客 NLP三大Subword模型详解:BPE、WordPiece、ULM - 阿北的文章 - 知乎

实现方式

以下是一个简化的 BPE 算法实现,演示了 BPE 如何从一个小的训练语料库中学习合并规则,并用这些规则来分割新单词。

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import re, collections

def get_stats(vocab):
pairs = collections.defaultdict(int)
for word, freq in vocab.items():
symbols = word.split()
for i in range(len(symbols)-1):
pairs[symbols[i],symbols[i+1]] += freq
return pairs

def merge_vocab(pair, v_in):
v_out = {}
bigram = re.escape(' '.join(pair))
p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
for word in v_in:
w_out = p.sub(''.join(pair), word)
v_out[w_out] = v_in[word]
return v_out

def get_vocab(text):
vocab = collections.defaultdict(int)
for word in text.strip().split():
vocab[' '.join(word) + ' </w>'] += 1
return vocab

# 示例文本
text = 'hug hugging hugged huggers'

# 获取初始词汇表(词汇及其频率)
vocab = get_vocab(text)
print('Initial vocab:', vocab)

# BPE算法主体
num_merges = 10 # 这里设置执行合并操作的次数
for i in range(num_merges):
pairs = get_stats(vocab)
if not pairs:
break
best = max(pairs, key=pairs.get)
vocab = merge_vocab(best, vocab)
print(f'Step {i + 1}:')
print(best)
print(vocab)

# 假设我们学习到的合并规则保存在merge_rules变量中
merge_rules = [pair for pair in pairs]

# 使用学习到的规则来分割一个新单词
def bpe_tokenize(word, rules):
if word.endswith('</w>'):
word = word[:-4]
else:
word = word + ' </w>'
word = ' '.join(word)

for rule in rules:
word = merge_vocab(rule, {word: 1}).popitem()[0]
return word.split()

# 测试分割
new_word = 'hugger'
print('Tokenizing new word:', new_word)
print(bpe_tokenize(new_word, merge_rules))

Byte-fallback BPE

Byte-fallback BPE(Byte-Pair Encoding with byte-level fallback)这是一种结合了 Byte-Pair Encoding(BPE)算法和字节级回退机制的编码方法。

在传统的 BPE 算法中,词汇被分解成一系列的子词单元。这种方法通过迭代地合并频繁出现的字符对来创建一个固定大小的词汇表。BPE 在处理未知词(OOV)问题上有一定的效果,因为它可以将未见过的词汇分解为已知的子词单元。

如果将“Byte-fallback”这个概念应用到 BPE,那么它可能涉及的是当一个词汇即使在子词级别也无法表示时,算法会进一步退回到字节级别的表示。在这种情况下,任何词汇都可以用字节序列来表示,因为字节(通常是8位)是计算机处理文本的基本单位。这样的方法可以确保模型对于任何输入都有一种表示方式,从而完全规避了OOV问题。

在实际应用中,这种方法可能会涉及到字符编码的知识,如 UTF-8 编码,其中每个字符可能由一个到多个字节组成。在字节级别上进行编码意味着模型可以处理任何形式的文本输入,包括罕见字符和特殊符号。

要注意的是,这种方法可能会导致模型输入的长度显著增加,因为每个字符可能需要多个字节来表示。这会增加模型处理长文本的复杂度,可能导致训练和推理速度变慢。

注意事项:以上是 GPT-4 回答。

WordPiece

WordPiece 算法是一种用于自然语言处理中的子词分割技术,它与 Byte Pair Encoding(BPE)算法有相似之处,但也有关键的区别。WordPiece 通常用于提升神经网络模型在处理语言数据时的效率和效果,尤其是在处理词汇表之外的单词(OOV)时。

工作原理

WordPiece 算法从单个字符开始,迭代地创建一个子词词汇表。每次迭代中,它会选择一个新的子词来添加到该词汇表中,该子词是通过组合已有词汇表中的子词来形成的,并且这个新子词的选择基于能够最大程度提升语言模型的似然(likelihood)。

执行步骤

  1. 初始化:开始时,词汇表仅包含单个字符。
  2. 迭代增长词汇表:在每次迭代中,算法会尝试将现有词汇表中的子词组合成新的子词。它会评估所有可能的新子词,并选择那个能够最大化模型似然的子词加入到词汇表。
  3. 最大化模型似然:该步骤是 WordPiece 与 BPE 的主要不同点。BPE 选择频率最高的相邻子词对进行合并,而 WordPiece 选择的是增加模型整体似然度最多的相邻子词对
  4. 结束条件:该过程会持续进行,直到达到预定的词汇表大小或者进一步增加新子词不再显著提升模型似然。

一旦词汇表创建完成,分词过程会使用贪心算法将单词分割为词汇表中的子词。分词从最长的子词开始匹配,并逐步向下,直到单词被完全分割。

问题:模型整体似然度是什么?该如何评估?

回答:假设句子 $S = (t_1, t_2, \ldots, t_n)$ 由 n 个子词组成,$t_i$ 表示子词,且假设各个子词之间是独立存在的,则句子 S 的语言模型似然度等价于所有子词概率的乘积。

$$
logP(S) = \sum_{i=1}^n log P(t_i)
$$

假设把相邻位置的 x 和 y 这两个子词合并,合并后产生的子词记为 z,此时句子 S 的似然度的变化可以表示为:

$$
log P(t_z) - (log P(t_x) + log P(t_y)) = log(\frac{P(t_z)}{P(t_x)P(t_y)})
$$

从上面的公式很容易就发现,似然读的变化就是两个子词之间的互信息。简而言之,WordPiece 每次选择合并的两个子词它们具有最大的互信息值,也就是两子词在语言模型上具有较强的关联性,经常在语料中以相邻方式同时出现。

应用场景

WordPiece 算法在谷歌的一些 NLP 模型中得到了广泛的应用,包括 BERT。BERT 模型使用 WordPiece 分词来处理输入文本,这有助于模型理解和生成更加精细的语义表征,特别是在处理罕见词汇或是多种语言混合的情况下。

算法优点

  • 更好的 OOV 处理:WordPiece 使模型能够处理未出现在训练数据中的单词,因为它可以将这些单词分解为已知的子词。
  • 提升模型性能:通过优化模型的似然,WordPiece 能够提升模型在语言理解任务上的性能。
  • 减少词汇表大小:WordPiece 通过使用子词来表示单词,能够有效减少需要直接处理的词汇表大小。

Unigram Language Model(ULM)

ULM 算法是一种用于自然语言处理中的子词分割技术,与 WordPiece 和 BPE 等算法不同,ULM 不是基于子词对的频率来决定合并操作,而是基于单个子词的概率来构建词汇表,并用于分词。这种模型主要用于语言模型中,特别是在统计机器翻译和文本生成中。

BPE 和 WordPiece 算法的词表大小都是从小到大,属于增量法。而 ULM 则是减量法,即先初始化一个大词表,根据评估准则不断丢弃词表,直到满足限定条件。

工作原理

ULM 的基本思想是将文本中的每个单词视为一个由一系列子词组合而成的序列,并为每个可能的子词分配一个概率。这些概率共同定义了一个生成模型,该模型可以用来评估任何给定句子的概率。在训练阶段,ULM 的目标是找到一组子词概率,使得整个训练语料库的概率最大化。

执行步骤

对于句子$S, x = (x_1, x_2, \ldots, x_m)$为句子的一个分词结果,由 m 个子词组成。所以,当前分词下句子 S 的似然值可以表示为:

$$
P(x) = \prod_{i=1}^m P(x_i)
$$

对于句子 S,挑选似然值最大的作为分词结果,则可以表示为:

$$
x^* = arg \ max_{x \in U(x)} P(x)
$$

这里 U(x) 包含了句子的所有分词结果。在实际应用中,词表大小有上万个,直接罗列所有可能的分词组合不具有操作型。针对该问题,可以通过维特比算法来解决。

问题:怎么求解每个字词的概率$P(x_i)$呢?

回答:ULM 通过 EM 算法来估计,假设当前词表 V,则 M 步最大化的对象是如下似然函数。

$$
L = \sum_{s=1}^{|D|} log (P(X)^{(s)}) = \sum_{s=1}^{|D|} log (\sum_{x \in U(X^{(s)})} P(x))
$$

其中,|D| 是语料库中语料数量。上述公式的一个直观理解是,将语料库中所有句子的所有分词组合形成的概率相加。

  1. 初始化词汇表:从一个大型的初始词汇表开始,这个词汇表可能包含所有单词的所有可能分割方式,也可以通过 BPE 算法初始化。
  2. 计算子词概率:针对当前词表,用 EM 算法求解每个子词的概率。
  3. 优化词汇表:对于每个字词,计算当该子词被从词表中移除时,总 loss 降低了多少,记为该子词的 loss。将子词按照 loss 大小进行排序,一些概率非常低的子词会被移除,而保留下来的子词概率会被重新估计。需要注意的是,单字符不能被丢弃,这是为了避免 OOV 情况。
  4. 结束条件:重复步骤 2 和 3,直到词表大小减少到设定范围。

算法优点

  • 灵活性:ULM 能够生成大量潜在的子词分割方案,并通过概率模型来选择最佳的分割。
  • 生成模型:ULM 本质上是一个生成模型,可以用来生成文本,这在某些类型的语言处理任务中非常有用。
  • 适应性:ULM 可以很好地适应不同的语料库,因为它不依赖于固定的合并规则,而是基于实际数据中子词的分布。

参考资料