目录

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

BERT 表示 Bidirectional Encoder Representations from Transformers,是 Google 于 2018 年发布的一种语言表示模型。该模型一经发布便成为争相效仿的对象,相信大家也都多少听说过研究过了。本文主要聚焦于 BERT 的分词方法,模型实现细节解读见 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
!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~

_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

注:

  • 蓝色底色表示当前子字符串,对应于代码中的 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