BERT 是如何分词的
目录
BERT 表示 Bidirectional Encoder Representations from Transformers,是 Google 于 2018 年发布的一种语言表示模型。该模型一经发布便成为争相效仿的对象,相信大家也都多少听说过研究过了。本文主要聚焦于 BERT 的分词方法,模型实现细节解读见 BERT 是如何构建模型的。
BERT 源码中 tokenization.py
就是预处理进行分词的程序,主要有两个分词器:BasicTokenizer
和 WordpieceTokenizer
,另外一个 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 | example = convert_to_unicode(example) |
去除各种奇怪字符
去除各种奇怪字符对应于 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 类别是Cc
和Cf
的字符。可以使用unicodedata.category(c)
来查看c
的 Unicode 类别。代码中用_is_control(char)
来判断char
是不是控制字符。 - 将所有空白字符转换为一个空格,包括标准空格、
\t
、\r
、\n
以及 Unicode 类别为Zs
的字符。代码中用_is_whitespace(char)
来判断char
是不是空白字符。
经过这步后,example
中的 \r\n
被替换成两个空格:
1 | example = _clean_text(example) |
处理中文
处理中文对应于 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 | def _is_chinese_char(self, cp): |
经过这步后,中文被按字分开,用空格分隔,但英文数字等仍然保持原状:
1 | example = _tokenize_chinese_chars(example) |
空格分词
空格分词对应于 whitespace_tokenize(text)
函数。首先对 text
进行 strip()
操作,去掉两边多余空白字符,然后如果剩下的是一个空字符串,则直接返回空列表,否则进行 split()
操作,得到最初的分词结果 orig_tokens
。
经过这步后,example
变成一个列表:
1 | example = whitespace_tokenize(example) |
去除多余字符和标点分词
接下来是针对 orig_tokens
的分词结果进一步处理,代码如下:
1 | for token in orig_tokens: |
逻辑不复杂,我在这里主要说下 _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 Chollet
,résumé
变成 resume
,á
变成 a
。该方法代码不长,如下:
1 | def _run_strip_accents(self, text): |
使用列表推导式代码还可以进一步精简为:
1 | def _run_strip_accents(self, text): |
这段代码核心就是 unicodedata.normalize
和 unicodedata.category
两个函数。前者返回输入字符串 text
的规范分解形式(Unicode 字符有多种规范形式,本文默认指 NFD
形式,即规范分解),后者返回输入字符 char
的 Unicode 类别。下面我举例说明一下两个函数的作用。
假如我们要处理 āóǔè
,其中含有变音符号,这种字符其实是由两个字符组成的,比如 ā
(码位 0x101)是由 a
(码位 0x61)和 上面那一横(码位 0x304)组成的,通过 unicodedata.normalize
就可以把这两者拆分出来:
1 | import unicodedata # unicodedata 是内置库 |
unicodedata.category
用来返回各个字符的类别:
1 | ' '.join(unicodedata.category(c) for c in s_norm) |
Ll
类别 表示 Lowercase Letter,小写字母。Mn
类别 表示的是 Nonspacing Mark,非间距标记,变音字符就属于这类,所以我们可以根据类别直接去掉变音字符:
1 | ''.join(c for c in s_norm if unicodedata.category(c) != 'Mn') |
_run_split_on_punc(text)
是标点分词,按照标点符号分词。
_run_split_on_punc(text)
方法是针对上一步空格分词后的每个 token 的。
在说这个方法之前,先说一下判断一个字符是否是标点符号的函数:_is_punctuation(char)
。该函数代码不长,我放到下面:
1 | def _is_punctuation(char): |
通常我们会用一个类似词库的文件来存放所有的标点符号,而 _is_punctuation
函数是通过码位来判断的,这样更灵活,也不必保留一个额外的词库文件。具体是有两种情况会视为标点:ASCII 中除了字母和数字意外的字符和以 P 开头的 Unicode 类别中的字符。第一种情况总共有 32 个字符,如下:
1 | !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ |
_run_split_on_punc
的总体过程就是:
- 首先设置
start_new_word=True
和output=[]
,output
就是最终的输出 - 对
text
中每个字符进行判断,如果该字符是标点,则output.append([char])
,并设置start_new_word=True
- 如果不是标点且
start_new_word=True
,那么意味着这是新一段的开始,直接output.append([])
,然后再设置start_new_word = False
,并在刚才 append 的空列表上加上当前字符:output[-1].append(char)
现在得到的 output
是一个嵌套列表,其中每一个列表都是被标点分开的一段,最后把每个列表 join 拼接一下,拉平 output
即可。
经过这步后,原先没有被分开的字词标点(例如 ONEIROS(Open-ended
)、没有去掉的变音符号(例如 ç
)都被相应处理:
1 | example |
再次空格分词
这句对应于如下代码:
1 | output_tokens = whitespace_tokenize(" ".join(split_tokens)) |
很简单,就是先用标准空格拼接上一步的处理结果,再执行空格分词。(But WHY?)
经过这步后,和上步结果一样:
1 | example |
这就是 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
为例:
注:
- 蓝色底色表示当前子字符串,对应于代码中的
cur_substr
- 当从第一个位置开始遍历时,不需要在当前字串前面加
##
,否则需要
大致流程说明(虽然我相信上面那个 GIF 够清楚了):
- 从第一个位置开始,由于是最长匹配,结束位置需要从最右端依次递减,所以遍历的第一个子词是其本身
unaffable
,该子词不在词汇表中 - 结束位置左移一位得到子词
unaffabl
,同样不在词汇表中 - 重复这个操作,直到
un
,该子词在词汇表中,将其加入output_tokens
,以第一个位置开始的遍历结束 - 跳过
un
,从其后的a
开始新一轮遍历,结束位置依然是从最右端依次递减,但此时需要在前面加上##
标记,得到##affable
不在词汇表中 - 结束位置左移一位得到子词
##affabl
,同样不在词汇表中 - 重复这个操作,直到
##aff
,该字词在词汇表中,将其加入output_tokens
,此轮遍历结束 - 跳过
aff
,从其后的a
开始新一轮遍历,结束位置依然是从最右端依次递减。##able
在词汇表中,将其加入output_tokens
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
- [1810.04805] BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding
- bert/tokenization.py at master · google-research/bert
- How to replace accented characters in python? - Stack Overflow
- What is the best way to remove accents in a Python unicode string? - Stack Overflow
- Accents & Accented Characters - Fonts.com | Fonts.com
- Common accented characters | Butterick’s Practical Typography