Lee's Space Station

BERT 是如何分词的

2019/10/16 Share

BERT 表示 Bidirectional Encoder Representations from Transformers,是 Google 于 2018 年发布的一种语言表示模型。该模型一经发布便成为争相效仿的对象,相信大家也都多少听说过研究过了。本文主要聚焦于 BERT 的分词方法,后续再谈模型实现细节。

BERT 源码tokenization.py 就是预处理进行分词的程序,主要有两个分词器:BasicTokenizerWordpieceTokenizer,另外一个 FullTokenizer 是这两个的结合:先进行 BasicTokenizer 得到一个分得比较粗的 token 列表,然后再对每个 token 进行一次 WordpieceTokenizer,得到最终的分词结果。

为了能直观看到每一步处理效果,我会用下面这个贯穿始终的例子来说明,该句修改自 Keras 的维基百科介绍:

1
example = "Keras是ONEIROS(Open-ended Neuro-Electronic Intelligent Robot Operating System,开放式神经电子智能机器人操作系统)项目研究工作的部分产物[3],主要作者和维护者是Google工程师François Chollet。\r\n"

对于中文来说,一句话概括:BERT 采取的是「分字」,即每一个汉字都切开。

BasicTokenizer

BasicTokenizer(以下简称 BT)是一个初步的分词器。对于一个待分词字符串,流程大致就是转成 unicode -> 去除各种奇怪字符 -> 处理中文 -> 空格分词 -> 去除多余字符和标点分词 -> 再次空格分词,结束。

大致流程就是这样,还有很多细节,下面我依次说下。

转成 unicode

转成 unicode 这步对应于 convert_to_unicode(text) 函数,很好理解,就是将输入转成 unicode 字符串,如果你用的 Python 3 而且输入是 str 类型,那么这点无需担心,输入和输出一样;如果是 Python 3 而且输入类型是 bytes,那么该函数会使用 text.decode("utf-8", "ignore") 来转成 unicode 类型。如果你用的是 Python 2,那么请看 Sunsetting Python 2 support Python 2.7 Countdown ,Just drop it。

经过这步后,example 和原来相同:

1
2
3
>>> example = convert_to_unicode(example)
>>> example
'Keras是ONEIROS(Open-ended Neuro-Electronic Intelligent Robot Operating System,开放式神经电子智能机器人操作系统)项目研究工作的部分产物[3],主要作者和维护者是Google工程师François Chollet。\r\n'

去除各种奇怪字符

去除各种奇怪字符对应于 BT 类的 _clean_text(text) 方法,通过 Unicode 码位(Unicode code point,以下码位均指 Unicode 码位)来去除各种不合法字符和多余空格,包括:

Python 中可以通过 ord(c) 来获取字符 c 的码位,使用 chr(i) 来获取码位为 i 的 Unicode 字符,$0 \leq i \leq \text{0x10ffff}$,即十进制的 $[0, 1114111]$。

  • 码位为 0 的 \x00,即空字符(Null character),或叫结束符,肉眼不可见,属于控制字符,一般在字符串末尾。注意不是空格,空格的码位是 32
  • 码位为 0xfffd(十进制 65533)的 ,即替换字符)(REPLACEMENT CHARACTER),通常用来替换未知、无法识别或者无法表示的字符
  • \t\r\n 以外的控制字符(Control character),即 Unicode 类别是 CcCf 的字符。可以使用 unicodedata.category(c) 来查看 c 的 Unicode 类别。代码中用 _is_control(char) 来判断 char 是不是控制字符
  • 将所有空白字符转换为一个空格,包括标准空格、\t\r\n 以及 Unicode 类别为 Zs 的字符。代码中用 _is_whitespace(char) 来判断 char 是不是空白字符

经过这步后,example 中的 \r\n 被替换成两个空格:

1
2
3
>>> example = _clean_text(example)
>>> example
'Keras是ONEIROS(Open-ended Neuro-Electronic Intelligent Robot Operating System,开放式神经电子智能机器人操作系统)项目研究工作的部分产物[3],主要作者和维护者是Google工程师François Chollet。 '

处理中文

处理中文对应于 BT 类的 _tokenize_chinese_chars(text) 方法。对于 text 中的字符,首先判断其是不是「中文字符」(关于中文字符的说明见下方引用块说明),是的话在其前后加上一个空格,否则原样输出。那么有一个问题,如何判断一个字符是不是「中文」呢?

_is_chinese_char(cp) 方法,cp 就是刚才说的码位,通过码位来判断,总共有 81520 个字,详细的码位范围如下(都是闭区间):

  • [0x4E00, 0x9FFF]:十进制 [19968, 40959]
  • [0x3400, 0x4DBF]:十进制 [13312, 19903]
  • [0x20000, 0x2A6DF]:十进制 [131072, 173791]
  • [0x2A700, 0x2B73F]:十进制 [173824, 177983]
  • [0x2B740, 0x2B81F]:十进制 [177984, 178207]
  • [0x2B820, 0x2CEAF]:十进制 [178208, 183983]
  • [0xF900, 0xFAFF]:十进制 [63744, 64255]
  • [0x2F800, 0x2FA1F]:十进制 [194560, 195103]

其实我觉得这个范文可以再精简下,因为有几个区间是相邻的,下面三个区间:

  • [0x2A700, 0x2B73F]:十进制 [173824, 177983]
  • [0x2B740, 0x2B81F]:十进制 [177984, 178207]
  • [0x2B820, 0x2CEAF]:十进制 [178208, 183983]

可以精简成一个:

  • [0x2A700, 0x2CEAF]:十进制 [173824, 183983]

原来的 8 个区间精简成 6 个,至于原来为什么写成 8 个,I don’t know 啊 😂

关于「中文字符」的说明:按照代码中的定义,这里说的「中文字符」指的是 CJK Unicode block) 中的字符,包括现代汉语、部分日语、部分韩语和越南语。但是根据 CJK Unicode block) 中的定义,这些字符只包括第一个码位区间([0x4E00, 0x9FFF])内的字符,也就是说代码中的字符要远远多于 CJK Unicode block 中包括的字符,这一点暂时有些疑问。我把源码关于这块的注释引用过来如下:

1
2
3
4
5
6
7
8
9
10
11
12
def _is_chinese_char(self, cp):
"""Checks whether CP is the codepoint of a CJK character."""
# This defines a "chinese character" as anything in the CJK Unicode block:
# https://en.wikipedia.org/wiki/CJK_Unified_Ideographs_(Unicode_block)
#
# Note that the CJK Unicode block is NOT all Japanese and Korean characters,
# despite its name. The modern Korean Hangul alphabet is a different block,
# as is Japanese Hiragana and Katakana. Those alphabets are used to write
# space-separated words, so they are not treated specially and handled
# like the all of the other languages.

pass

经过这步后,中文被按字分开,用空格分隔,但英文数字等仍然保持原状:

1
2
3
>>> example = _tokenize_chinese_chars(example)
>>> example
'Keras 是 ONEIROS(Open-ended Neuro-Electronic Intelligent Robot Operating System, 开 放 式 神 经 电 子 智 能 机 器 人 操 作 系 统 ) 项 目 研 究 工 作 的 部 分 产 物 [3], 主 要 作 者 和 维 护 者 是 Google 工 程 师 François Chollet。 '

空格分词

空格分词对应于 whitespace_tokenize(text) 函数。首先对 text 进行 strip() 操作,去掉两边多余空白字符,然后如果剩下的是一个空字符串,则直接返回空列表,否则进行 split() 操作,得到最初的分词结果 orig_tokens

经过这步后,example 变成一个列表:

1
2
3
>>> example = whitespace_tokenize(example)
>>> example
['Keras', '是', 'ONEIROS(Open-ended', 'Neuro-Electronic', 'Intelligent', 'Robot', 'Operating', 'System,', '开', '放', '式', '神', '经', '电', '子', '智', '能', '机', '器', '人', '操', '作', '系', '统', ')', '项', '目', '研', '究', '工', '作', '的', '部', '分', '产', '物', '[3],', '主', '要', '作', '者', '和', '维', '护', '者', '是', 'Google', '工', '程', '师', 'François', 'Chollet。']

去除多余字符和标点分词

接下来是针对 orig_tokens 的分词结果进一步处理,代码如下:

1
2
3
4
5
for token in orig_tokens:
if self.do_lower_case:
token = token.lower()
token = self._run_strip_accents(token)
split_tokens.extend(self._run_split_on_punc(token))

逻辑不复杂,我在这里主要说下 _run_strip_accents_run_split_on_punc

_run_strip_accents(text) 方法用于去除 accents,即变音符号,那么什么是变音符号呢?像 Keras 作者 François Chollet 名字中些许奇怪的字符 ç、简历的英文 résumé 中的 é 和中文拼音声调 á 等,这些都是变音符号 accents,维基百科中描述如下:

附加符号或称变音符号(diacritic、diacritical mark、diacritical point、diacritical sign),是指添加在字母上面的符号,以更改字母的发音或者以区分拼写相似词语。例如汉语拼音字母“ü”上面的两个小点,或“á”、“à”字母上面的标调符。

常见 accents 可参见 Common accented characters

_run_strip_accents(text) 方法就是要把这些 accents 去掉,例如 François Chollet 变成 Francois Cholletrésumé 变成 resumeá 变成 a。该方法代码不长,如下:

1
2
3
4
5
6
7
8
9
10
def _run_strip_accents(self, text):
"""Strips accents from a piece of text."""
text = unicodedata.normalize("NFD", text)
output = []
for char in text:
cat = unicodedata.category(char)
if cat == "Mn":
continue
output.append(char)
return "".join(output)

使用列表推导式代码还可以进一步精简为:

1
2
3
4
5
def _run_strip_accents(self, text):
"""Strips accents from a piece of text."""
text = unicodedata.normalize("NFD", text)
output = [char for char in text if unicodedata.category(char) != 'Mn']
return "".join(output)

这段代码核心就是 unicodedata.normalizeunicodedata.category 两个函数。前者返回输入字符串 text 的规范分解形式(Unicode 字符有多种规范形式,本文默认指 NFD 形式,即规范分解),后者返回输入字符 charUnicode 类别。下面我举例说明一下两个函数的作用。

假如我们要处理 āóǔè,其中含有变音符号,这种字符其实是由两个字符组成的,比如 ā(码位 0x101)是由 a(码位 0x61)和 上面那一横(码位 0x304)组成的,通过 unicodedata.normalize 就可以把这两者拆分出来:

1
2
3
4
5
>>> import unicodedata  # unicodedata 是内置库
>>> s = 'āóǔè'
>>> s_norm = unicodedata.normalize('NFD', s)
>>> s_norm, len(s_norm)
('āóǔè', 8) # 看起来和原来的一摸一样,但是长度已经变了

unicodedata.category 用来返回各个字符的类别:

1
2
>>> ' '.join(unicodedata.category(c) for c in s_norm)
'Ll Mn Ll Mn Ll Mn Ll Mn'

Ll 类别 表示 Lowercase Letter,小写字母。Mn 类别 表示的是 Nonspacing Mark,非间距标记,变音字符就属于这类,所以我们可以根据类别直接去掉变音字符:

1
2
>>> ''.join(c for c in s_norm if unicodedata.category(c) != 'Mn')
'aoue'

_run_split_on_punc(text) 是标点分词,按照标点符号分词。

_run_split_on_punc(text) 方法是针对上一步空格分词后的每个 token 的。

在说这个方法之前,先说一下判断一个字符是否是标点符号的函数:_is_punctuation(char)。该函数代码不长,我放到下面:

1
2
3
4
5
6
7
8
9
10
def _is_punctuation(char):
"""Checks whether `chars` is a punctuation character."""
cp = ord(char)
if ((cp >= 33 and cp <= 47) or (cp >= 58 and cp <= 64) or
(cp >= 91 and cp <= 96) or (cp >= 123 and cp <= 126)):
return True
cat = unicodedata.category(char)
if cat.startswith("P"):
return True
return False

通常我们会用一个类似词库的文件来存放所有的标点符号,而 _is_punctuation 函数是通过码位来判断的,这样更灵活,也不必保留一个额外的词库文件。具体是有两种情况会视为标点:ASCII 中除了字母和数字意外的字符和以 P 开头的 Unicode 类别中的字符。第一种情况总共有 32 个字符,如下:

1
!"#$%&'()*+,-./:;<=>[email protected][\]^_`{|}~

_run_split_on_punc 的总体过程就是:

  1. 首先设置 start_new_word=Trueoutput=[]output 就是最终的输出
  2. text 中每个字符进行判断,如果该字符是标点,则 output.append([char]),并设置 start_new_word=True
  3. 如果不是标点且 start_new_word=True,那么意味着这是新一段的开始,直接 output.append([]),然后再设置 start_new_word = False,并在刚才 append 的空列表上加上当前字符:output[-1].append(char)

现在得到的 output 是一个嵌套列表,其中每一个列表都是被标点分开的一段,最后把每个列表 join 拼接一下,拉平 output 即可。

经过这步后,原先没有被分开的字词标点(例如 ONEIROS(Open-ended)、没有去掉的变音符号(例如 ç)都被相应处理:

1
2
>>> example
['keras', '是', 'oneiros', '(', 'open', '-', 'ended', 'neuro', '-', 'electronic', 'intelligent', 'robot', 'operating', 'system', ',', '开', '放', '式', '神', '经', '电', '子', '智', '能', '机', '器', '人', '操', '作', '系', '统', ')', '项', '目', '研', '究', '工', '作', '的', '部', '分', '产', '物', '[', '3', ']', ',', '主', '要', '作', '者', '和', '维', '护', '者', '是', 'google', '工', '程', '师', 'francois', 'chollet', '。']

再次空格分词

这句对应于如下代码:

1
output_tokens = whitespace_tokenize(" ".join(split_tokens))

很简单,就是先用标准空格拼接上一步的处理结果,再执行空格分词。(But WHY?)

经过这步后,和上步结果一样:

1
2
>>> example
['keras', '是', 'oneiros', '(', 'open', '-', 'ended', 'neuro', '-', 'electronic', 'intelligent', 'robot', 'operating', 'system', ',', '开', '放', '式', '神', '经', '电', '子', '智', '能', '机', '器', '人', '操', '作', '系', '统', ')', '项', '目', '研', '究', '工', '作', '的', '部', '分', '产', '物', '[', '3', ']', ',', '主', '要', '作', '者', '和', '维', '护', '者', '是', 'google', '工', '程', '师', 'francois', 'chollet', '。']

这就是 BT 最终的输出了。

WordpieceTokenizer

WordpieceTokenizer(以下简称 WPT)是在 BT 结果的基础上进行再一次切分,得到子词(subword,以 ## 开头),词汇表就是在此时引入的。该类只有两个方法:一个初始化方法 __init__(self, vocab, unk_token="[UNK]", max_input_chars_per_word=200),一个分词方法 tokenize(self, text)

对于中文来说,使不使用 WPT 都一样,因为中文经过 BasicTokenizer 后已经变成一个字一个字了,没法再「子」了 😂

__init__(self, vocab, unk_token="[UNK]", max_input_chars_per_word=200)vocab 就是词汇表,collections.OrderedDict() 类型,由 load_vocab(vocab_file) 读入,key 为词汇,value 为对应索引,顺序依照 vocab_file 中的顺序。有一点需要注意的是,词汇表中已包含所有可能的子词。unk_token 为未登录词的标记,默认为 [UNK]max_input_chars_per_word 为单个词的最大长度,如果一个词的长度超过这个最大长度,那么直接将其设为 unk_token

tokenize(self, text):该方法就是主要的分词方法了,大致分词思路是按照从左到右的顺序,将一个词拆分成多个子词,每个子词尽可能长。按照源码中的说法,该方法称之为 greedy longest-match-first algorithm,贪婪最长优先匹配算法。

开始时首先将 text 转成 unicode,并进行空格分词,然后依次遍历每个词。为了能够清楚直观地理解遍历流程,我特地制作了一个 GIF 来解释,以 unaffable 为例:

longest-match-first.gif

注:

  • 蓝色底色表示当前子字符串,对应于代码中的 cur_substr
  • 当从第一个位置开始遍历时,不需要在当前字串前面加 ##,否则需要

大致流程说明(虽然我相信上面那个 GIF 够清楚了):

  1. 从第一个位置开始,由于是最长匹配,结束位置需要从最右端依次递减,所以遍历的第一个子词是其本身 unaffable,该子词不在词汇表中
  2. 结束位置左移一位得到子词 unaffabl,同样不在词汇表中
  3. 重复这个操作,直到 un,该子词在词汇表中,将其加入 output_tokens,以第一个位置开始的遍历结束
  4. 跳过 un,从其后的 a 开始新一轮遍历,结束位置依然是从最右端依次递减,但此时需要在前面加上 ## 标记,得到 ##affable 不在词汇表中
  5. 结束位置左移一位得到子词 ##affabl,同样不在词汇表中
  6. 重复这个操作,直到 ##aff,该字词在词汇表中,将其加入 output_tokens,此轮遍历结束
  7. 跳过 aff,从其后的 a 开始新一轮遍历,结束位置依然是从最右端依次递减。##able 在词汇表中,将其加入 output_tokens
  8. able 后没有字符了,整个遍历结束

将 BT 的结果输入给 WPT,那么 example 的最终分词结果就是

1
['keras', '是', 'one', '##iros', '(', 'open', '-', 'ended', 'neu', '##ro', '-', 'electronic', 'intelligent', 'robot', 'operating', 'system', ',', '开', '放', '式', '神', '经', '电', '子', '智', '能', '机', '器', '人', '操', '作', '系', '统', ')', '项', '目', '研', '究', '工', '作', '的', '部', '分', '产', '物', '[', '3', ']', ',', '主', '要', '作', '者', '和', '维', '护', '者', '是', 'google', '工', '程', '师', 'franco', '##is', 'cho', '##llet', '。']

至此,BERT 分词部分结束。

Reference

END

CATALOG
  1. 1. BasicTokenizer
    1. 1.1. 转成 unicode
    2. 1.2. 去除各种奇怪字符
    3. 1.3. 处理中文
    4. 1.4. 空格分词
    5. 1.5. 去除多余字符和标点分词
    6. 1.6. 再次空格分词
  2. 2. WordpieceTokenizer
  3. 3. Reference
  4. 4. END