【BERT】BERT 是如何分詞的

來自 |?知乎
地址 |?https://zhuanlan.zhihu.com/p/132361501
作者 |?Alan Lee
BERT 表示 Bidirectional Encoder Representations from Transformers,是 Google 于 2018 年發(fā)布的一種語言表示模型。該模型一經(jīng)發(fā)布便成為爭相效仿的對象,相信大家也都多少聽說過研究過了。本文主要聚焦于 BERT 的分詞方法,后續(xù)再談模型實(shí)現(xiàn)細(xì)節(jié)。
BERT 源碼中?tokenization.py?就是預(yù)處理進(jìn)行分詞的程序,主要有兩個分詞器:BasicTokenizer?和?WordpieceTokenizer,另外一個?FullTokenizer?是這兩個的結(jié)合:先進(jìn)行?BasicTokenizer?得到一個分得比較粗的 token 列表,然后再對每個 token 進(jìn)行一次WordpieceTokenizer,得到最終的分詞結(jié)果。
為了能直觀看到每一步處理效果,我會用下面這個貫穿始終的例子來說明,該句修改自 Keras 的維基百科介紹:
example = "Keras是ONEIROS(Open-ended Neuro-Electronic Intelligent Robot Operating System,開放式神經(jīng)電子智能機(jī)器人操作系統(tǒng))項(xiàng)目研究工作的部分產(chǎn)物[3],主要作者和維護(hù)者是Google工程師Fran?ois Chollet。\r\n"對于中文來說,一句話概括:BERT 采取的是「分字」,即每一個漢字都切開。
BasicTokenizer
BasicTokenizer(以下簡稱 BT)是一個初步的分詞器。對于一個待分詞字符串,流程大致就是轉(zhuǎn)成 unicode -> 去除各種奇怪字符 -> 處理中文 -> 空格分詞 -> 去除多余字符和標(biāo)點(diǎn)分詞 -> 再次空格分詞,結(jié)束。
大致流程就是這樣,還有很多細(xì)節(jié),下面我依次說下。
轉(zhuǎn)成 unicode
轉(zhuǎn)成 unicode?這步對應(yīng)于?convert_to_unicode(text)?函數(shù),很好理解,就是將輸入轉(zhuǎn)成 unicode 字符串,如果你用的 Python 3 而且輸入是?str?類型,那么這點(diǎn)無需擔(dān)心,輸入和輸出一樣;如果是 Python 3 而且輸入類型是?bytes,那么該函數(shù)會使用?text.decode("utf-8", "ignore")?來轉(zhuǎn)成 unicode 類型。如果你用的是 Python 2,那么請看 Sunsetting Python 2 support 和 Python 2.7 Countdown ,Just drop it。
經(jīng)過這步后,example?和原來相同:
>>> example = convert_to_unicode(example)
>>> example
'Keras是ONEIROS(Open-ended Neuro-Electronic Intelligent Robot Operating System,開放式神經(jīng)電子智能機(jī)器人操作系統(tǒng))項(xiàng)目研究工作的部分產(chǎn)物[3],主要作者和維護(hù)者是Google工程師Fran?ois Chollet。\r\n'去除各種奇怪字符
去除各種奇怪字符對應(yīng)于 BT 類的?_clean_text(text)?方法,通過 Unicode 碼位(Unicode code point,以下碼位均指 Unicode 碼位)來去除各種不合法字符和多余空格,包括:
Python 中可以通過?ord(c)?來獲取字符?c?的碼位,使用?chr(i)?來獲取碼位為?i?的 Unicode 字符,$0 \leq i \leq \text{0x10ffff}$,即十進(jìn)制的 $[0, 1114111]$。
碼位為 0 的?
\x00,即空字符(Null character),或叫結(jié)束符,肉眼不可見,屬于控制字符,一般在字符串末尾。注意不是空格,空格的碼位是 32碼位為 0xfffd(十進(jìn)制 65533)的?
?,即替換字符(REPLACEMENT CHARACTER),通常用來替換未知、無法識別或者無法表示的字符除?
\t、\r?和?\n?以外的控制字符(Control character),即 Unicode 類別是?Cc?和?Cf的字符??梢允褂?unicodedata.category(c)?來查看?c?的 Unicode 類別。代碼中用_is_control(char)?來判斷?char?是不是控制字符將所有空白字符轉(zhuǎn)換為一個空格,包括標(biāo)準(zhǔn)空格、
\t、\r、\n?以及 Unicode 類別為?Zs的字符。代碼中用?_is_whitespace(char)?來判斷?char?是不是空白字符
經(jīng)過這步后,example?中的?\r\n?被替換成兩個空格:
>>> example = _clean_text(example)
>>> example
'Keras是ONEIROS(Open-ended Neuro-Electronic Intelligent Robot Operating System,開放式神經(jīng)電子智能機(jī)器人操作系統(tǒng))項(xiàng)目研究工作的部分產(chǎn)物[3],主要作者和維護(hù)者是Google工程師Fran?ois Chollet。'處理中文
處理中文對應(yīng)于 BT 類的?_tokenize_chinese_chars(text)?方法。對于?text?中的字符,首先判斷其是不是「中文字符」(關(guān)于中文字符的說明見下方引用塊說明),是的話在其前后加上一個空格,否則原樣輸出。那么有一個問題,如何判斷一個字符是不是「中文」呢?
_is_chinese_char(cp)?方法,cp?就是剛才說的碼位,通過碼位來判斷,總共有 81520 個字,詳細(xì)的碼位范圍如下(都是閉區(qū)間):
[0x4E00, 0x9FFF]:十進(jìn)制 [19968, 40959]
[0x3400, 0x4DBF]:十進(jìn)制 [13312, 19903]
[0x20000, 0x2A6DF]:十進(jìn)制 [131072, 173791]
[0x2A700, 0x2B73F]:十進(jìn)制 [173824, 177983]
[0x2B740, 0x2B81F]:十進(jìn)制 [177984, 178207]
[0x2B820, 0x2CEAF]:十進(jìn)制 [178208, 183983]
[0xF900, 0xFAFF]:十進(jìn)制 [63744, 64255]
[0x2F800, 0x2FA1F]:十進(jìn)制 [194560, 195103]
其實(shí)我覺得這個范文可以再精簡下,因?yàn)橛袔讉€區(qū)間是相鄰的,下面三個區(qū)間:
[0x2A700, 0x2B73F]:十進(jìn)制 [173824, 177983]
[0x2B740, 0x2B81F]:十進(jìn)制 [177984,?178207]
[0x2B820, 0x2CEAF]:十進(jìn)制 [178208, 183983]
可以精簡成一個:
[0x2A700, 0x2CEAF]:十進(jìn)制 [173824, 183983]
原來的 8 個區(qū)間精簡成 6 個,至于原來為什么寫成 8 個,I don't know 啊
關(guān)于「中文字符」的說明:按照代碼中的定義,這里說的「中文字符」指的是 CJK Unicode block 中的字符,包括現(xiàn)代漢語、部分日語、部分韓語和越南語。但是根據(jù) CJK Unicode block 中的定義,這些字符只包括第一個碼位區(qū)間([0x4E00, 0x9FFF])內(nèi)的字符,也就是說代碼中的字符要遠(yuǎn)遠(yuǎn)多于 CJK Unicode block 中包括的字符,這一點(diǎn)暫時有些疑問。我把源碼關(guān)于這塊的注釋引用過來如下:
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經(jīng)過這步后,中文被按字分開,用空格分隔,但英文數(shù)字等仍然保持原狀:
>>> example = _tokenize_chinese_chars(example)
>>> example
'Keras 是 ONEIROS(Open-ended Neuro-Electronic Intelligent Robot Operating System, 開 放 式 神 經(jīng) 電 子 智 能 機(jī) 器 人 操 作 系 統(tǒng) ) 項(xiàng) 目 研 究 工 作 的 部 分 產(chǎn) 物 [3], 主 要 作 者 和 維 護(hù) 者 是 Google 工 程 師 Fran?ois Chollet。'空格分詞
空格分詞對應(yīng)于?whitespace_tokenize(text)?函數(shù)。首先對?text?進(jìn)行?strip()?操作,去掉兩邊多余空白字符,然后如果剩下的是一個空字符串,則直接返回空列表,否則進(jìn)行?split()?操作,得到最初的分詞結(jié)果?orig_tokens。
經(jīng)過這步后,example?變成一個列表:
>>> example = whitespace_tokenize(example)
>>> example
['Keras', '是', 'ONEIROS(Open-ended', 'Neuro-Electronic', 'Intelligent', 'Robot', 'Operating', 'System,', '開', '放', '式', '神', '經(jīng)', '電', '子', '智', '能', '機(jī)', '器', '人', '操', '作', '系', '統(tǒng)', ')', '項(xiàng)', '目', '研', '究', '工', '作', '的', '部', '分', '產(chǎn)', '物', '[3],', '主', '要', '作', '者', '和', '維', '護(hù)', '者', '是', 'Google', '工', '程', '師', 'Fran?ois', 'Chollet。']去除多余字符和標(biāo)點(diǎn)分詞
接下來是針對?orig_tokens?的分詞結(jié)果進(jìn)一步處理,代碼如下:
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))邏輯不復(fù)雜,我在這里主要說下?_run_strip_accents?和?_run_split_on_punc。
_run_strip_accents(text)?方法用于去除 accents,即變音符號,那么什么是變音符號呢?像 Keras 作者 Fran?ois Chollet 名字中些許奇怪的字符??、簡歷的英文 résumé 中的?é?和中文拼音聲調(diào)?á?等,這些都是變音符號 accents,維基百科中描述如下:
附加符號或稱變音符號(diacritic、diacritical mark、diacritical point、diacritical sign),是指添加在字母上面的符號,以更改字母的發(fā)音或者以區(qū)分拼寫相似詞語。例如漢語拼音字母“ü”上面的兩個小點(diǎn),或“á”、“à”字母上面的標(biāo)調(diào)符。
常見 accents 可參見 Common accented characters。
_run_strip_accents(text)?方法就是要把這些 accents 去掉,例如?Fran?ois Chollet?變成Francois Chollet,résumé?變成?resume,á?變成?a。該方法代碼不長,如下:
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)使用列表推導(dǎo)式代碼還可以進(jìn)一步精簡為:
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.normalize?和?unicodedata.category?兩個函數(shù)。前者返回輸入字符串?text?的規(guī)范分解形式(Unicode 字符有多種規(guī)范形式,本文默認(rèn)指?NFD?形式,即規(guī)范分解),后者返回輸入字符?char?的 Unicode 類別。下面我舉例說明一下兩個函數(shù)的作用。
假如我們要處理?āóǔè,其中含有變音符號,這種字符其實(shí)是由兩個字符組成的,比如?ā(碼位 0x101)是由?a(碼位 0x61)和 上面那一橫(碼位 0x304)組成的,通過unicodedata.normalize?就可以把這兩者拆分出來:
>>> import unicodedata # unicodedata 是內(nèi)置庫
>>> s = 'āóǔè'
>>> s_norm = unicodedata.normalize('NFD', s)
>>> s_norm, len(s_norm)('āóǔè', 8) # 看起來和原來的一摸一樣,但是長度已經(jīng)變了
unicodedata.category
用來返回各個字符的類別:
>>> ' '.join(unicodedata.category(c) for c in s_norm)
'Ll Mn Ll Mn Ll Mn Ll Mn'
>Ll 類別 表示 Lowercase Letter,小寫字母。>Mn 類別 表示的是 Nonspacing Mark,非間距標(biāo)記,變音字符就屬于這類,所以我們可以根據(jù)類別直接去掉變音字符:
>>> ''.join(c for c in s_norm if unicodedata.category(c) != 'Mn')
'aoue'_run_split_on_punc(text)?是標(biāo)點(diǎn)分詞,按照標(biāo)點(diǎn)符號分詞。
_run_split_on_punc(text)?方法是針對上一步空格分詞后的每個 token 的。
在說這個方法之前,先說一下判斷一個字符是否是標(biāo)點(diǎn)符號的函數(shù):_is_punctuation(char)。該函數(shù)代碼不長,我放到下面:
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通常我們會用一個類似詞庫的文件來存放所有的標(biāo)點(diǎn)符號,而?_is_punctuation?函數(shù)是通過碼位來判斷的,這樣更靈活,也不必保留一個額外的詞庫文件。具體是有兩種情況會視為標(biāo)點(diǎn):ASCII中除了字母和數(shù)字意外的字符和以 P 開頭的 Unicode 類別中的字符。第一種情況總共有 32 個字符,如下:
!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~_run_split_on_punc?的總體過程就是:
1. 首先設(shè)置?
start_new_word=True?和?output=[],output?就是最終的輸出2. 對?
text?中每個字符進(jìn)行判斷,如果該字符是標(biāo)點(diǎn),則?output.append([char]),并設(shè)置start_new_word=True3. 如果不是標(biāo)點(diǎn)且?
start_new_word=True,那么意味著這是新一段的開始,直接output.append([]),然后再設(shè)置?start_new_word = False,并在剛才 append 的空列表上加上當(dāng)前字符:output[-1].append(char)
現(xiàn)在得到的?output?是一個嵌套列表,其中每一個列表都是被標(biāo)點(diǎn)分開的一段,最后把每個列表 join 拼接一下,拉平?output?即可。
經(jīng)過這步后,原先沒有被分開的字詞標(biāo)點(diǎn)(例如?ONEIROS(Open-ended)、沒有去掉的變音符號(例如??)都被相應(yīng)處理:
>>> example
['keras', '是', 'oneiros', '(', 'open', '-', 'ended', 'neuro', '-', 'electronic', 'intelligent', 'robot', 'operating', 'system', ',', '開', '放', '式', '神', '經(jīng)', '電', '子', '智', '能', '機(jī)', '器', '人', '操', '作', '系', '統(tǒng)', ')', '項(xiàng)', '目', '研', '究', '工', '作', '的', '部', '分', '產(chǎn)', '物', '[', '3', ']', ',', '主', '要', '作', '者', '和', '維', '護(hù)', '者', '是', 'google', '工', '程', '師', 'francois', 'chollet', '。']再次空格分詞
這句對應(yīng)于如下代碼:
output_tokens = whitespace_tokenize(" ".join(split_tokens))很簡單,就是先用標(biāo)準(zhǔn)空格拼接上一步的處理結(jié)果,再執(zhí)行空格分詞。(But WHY?)
經(jīng)過這步后,和上步結(jié)果一樣:
>>> example
['keras', '是', 'oneiros', '(', 'open', '-', 'ended', 'neuro', '-', 'electronic', 'intelligent', 'robot', 'operating', 'system', ',', '開', '放', '式', '神', '經(jīng)', '電', '子', '智', '能', '機(jī)', '器', '人', '操', '作', '系', '統(tǒng)', ')', '項(xiàng)', '目', '研', '究', '工', '作', '的', '部', '分', '產(chǎn)', '物', '[', '3', ']', ',', '主', '要', '作', '者', '和', '維', '護(hù)', '者', '是', 'google', '工', '程', '師', 'francois', 'chollet', '。']這就是 BT 最終的輸出了。
WordpieceTokenizer
WordpieceTokenizer(以下簡稱 WPT)是在 BT 結(jié)果的基礎(chǔ)上進(jìn)行再一次切分,得到子詞(subword,以?##?開頭),詞匯表就是在此時引入的。該類只有兩個方法:一個初始化方法__init__(self, vocab, unk_token="[UNK]", max_input_chars_per_word=200),一個分詞方法tokenize(self, text)。
對于中文來說,使不使用 WPT 都一樣,因?yàn)橹形慕?jīng)過 BasicTokenizer 后已經(jīng)變成一個字一個字了,沒法再「子」了?
__init__(self, vocab, unk_token="[UNK]", max_input_chars_per_word=200):vocab?就是詞匯表,collections.OrderedDict()?類型,由?load_vocab(vocab_file)?讀入,key 為詞匯,value 為對應(yīng)索引,順序依照?vocab_file?中的順序。有一點(diǎn)需要注意的是,詞匯表中已包含所有可能的子詞。unk_token?為未登錄詞的標(biāo)記,默認(rèn)為?[UNK]。max_input_chars_per_word?為單個詞的最大長度,如果一個詞的長度超過這個最大長度,那么直接將其設(shè)為?unk_token。
tokenize(self, text):該方法就是主要的分詞方法了,大致分詞思路是按照從左到右的順序,將一個詞拆分成多個子詞,每個子詞盡可能長。按照源碼中的說法,該方法稱之為 greedy longest-match-first algorithm,貪婪最長優(yōu)先匹配算法。
開始時首先將?text?轉(zhuǎn)成 unicode,并進(jìn)行空格分詞,然后依次遍歷每個詞。為了能夠清楚直觀地理解遍歷流程,我特地制作了一個 GIF 來解釋,以?unaffable?為例:

注:
藍(lán)色底色表示當(dāng)前子字符串,對應(yīng)于代碼中的?
cur_substr當(dāng)從第一個位置開始遍歷時,不需要在當(dāng)前字串前面加?
##,否則需要
大致流程說明(雖然我相信上面那個 GIF 夠清楚了):
1.? 從第一個位置開始,由于是最長匹配,結(jié)束位置需要從最右端依次遞減,所以遍歷的第一個子詞是其本身?
unaffable,該子詞不在詞匯表中2.? 結(jié)束位置左移一位得到子詞?
unaffabl,同樣不在詞匯表中3.? 重復(fù)這個操作,直到?
un,該子詞在詞匯表中,將其加入?output_tokens,以第一個位置開始的遍歷結(jié)束4.? 跳過?
un,從其后的?a?開始新一輪遍歷,結(jié)束位置依然是從最右端依次遞減,但此時需要在前面加上?##?標(biāo)記,得到?##affable?不在詞匯表中5.? 結(jié)束位置左移一位得到子詞?
##affabl,同樣不在詞匯表中6.? 重復(fù)這個操作,直到?
##aff,該字詞在詞匯表中,將其加入?output_tokens,此輪遍歷結(jié)束7.? 跳過?
aff,從其后的?a?開始新一輪遍歷,結(jié)束位置依然是從最右端依次遞減。##able?在詞匯表中,將其加入?output_tokensable?后沒有字符了,整個遍歷結(jié)束
將 BT 的結(jié)果輸入給 WPT,那么?example?的最終分詞結(jié)果就是
['keras', '是', 'one', '##iros', '(', 'open', '-', 'ended', 'neu', '##ro', '-', 'electronic', 'intelligent', 'robot', 'operating', 'system', ',', '開', '放', '式', '神', '經(jīng)', '電', '子', '智', '能', '機(jī)', '器', '人', '操', '作', '系', '統(tǒng)', ')', '項(xiàng)', '目', '研', '究', '工', '作', '的', '部', '分', '產(chǎn)', '物', '[', '3', ']', ',', '主', '要', '作', '者', '和', '維', '護(hù)', '者', '是', 'google', '工', '程', '師', 'franco', '##is', 'cho', '##llet', '。']至此,BERT 分詞部分結(jié)束。
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
↓掃描關(guān)注本號↓
