BERT 表示 Bidirectional Encoder Representations from Transformers,是 Google 於 2018 年發佈的一種語言表示模型。該模型一經發布便成爲爭相效仿的對象,相信大家也都多少聽說過研究過了。本文主要聚焦於 BERT 的分詞方法,後續再談模型實現細節。
BERT 源碼中 tokenization.py
就是預處理進行分詞的程序,主要有兩個分詞器:BasicTokenizer
和 WordpieceTokenizer
,另外一個 FullTokenizer
是這兩個的結合:先進行 BasicTokenizer
得到一個分得比較粗的 token 列表,然後再對每個 token 進行一次 WordpieceTokenizer
,得到最終的分詞結果。
爲了能直觀看到每一步處理效果,我會用下面這個貫穿始終的例子來說明,該句修改自 Keras 的維基百科介紹:
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
和原來相同:
>>> 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 的
\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
被替換成兩個空格:
>>> 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 中包括的字符,這一點暫時有些疑問。我把源碼關於這塊的註釋引用過來如下:
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
經過這步後,中文被按字分開,用空格分隔,但英文數字等仍然保持原狀:
>>> 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
變成一個列表:
>>> example = whitespace_tokenize(example)
>>> example
['Keras', '是', 'ONEIROS(Open-ended', 'Neuro-Electronic', 'Intelligent', 'Robot', 'Operating', 'System,', '開', '放', '式', '神', '經', '電', '子', '智', '能', '機', '器', '人', '操', '作', '系', '統', ')', '項', '目', '研', '究', '工', '作', '的', '部', '分', '產', '物', '[3],', '主', '要', '作', '者', '和', '維', '護', '者', '是', 'Google', '工', '程', '師', 'François', 'Chollet。']
去除多餘字符和標點分詞
接下來是針對 orig_tokens
的分詞結果進一步處理,代碼如下:
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 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)
使用列表推導式代碼還可以進一步精簡爲:
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
兩個函數。前者返回輸入字符串 text
的規範分解形式(Unicode 字符有多種規範形式,本文默認指 NFD
形式,即規範分解),後者返回輸入字符 char
的 Unicode 類別。下面我舉例說明一下兩個函數的作用。
假如我們要處理 āóǔè
,其中含有變音符號,這種字符其實是由兩個字符組成的,比如 ā
(碼位 0x101)是由 a
(碼位 0x61)和 上面那一橫(碼位 0x304)組成的,通過 unicodedata.normalize
就可以把這兩者拆分出來:
>>> import unicodedata # unicodedata 是內置庫
>>> s = 'āóǔè'
>>> s_norm = unicodedata.normalize('NFD', s)
>>> s_norm, len(s_norm)
('āóǔè', 8) # 看起來和原來的一摸一樣,但是長度已經變了
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,非間距標記,變音字符就屬於這類,所以我們可以根據類別直接去掉變音字符:
>>> ''.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)
。該函數代碼不長,我放到下面:
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 個字符,如下:
!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
_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
)、沒有去掉的變音符號(例如 ç
)都被相應處理:
>>> example
['keras', '是', 'oneiros', '(', 'open', '-', 'ended', 'neuro', '-', 'electronic', 'intelligent', 'robot', 'operating', 'system', ',', '開', '放', '式', '神', '經', '電', '子', '智', '能', '機', '器', '人', '操', '作', '系', '統', ')', '項', '目', '研', '究', '工', '作', '的', '部', '分', '產', '物', '[', '3', ']', ',', '主', '要', '作', '者', '和', '維', '護', '者', '是', 'google', '工', '程', '師', 'francois', 'chollet', '。']
再次空格分詞
這句對應於如下代碼:
output_tokens = whitespace_tokenize(" ".join(split_tokens))
很簡單,就是先用標準空格拼接上一步的處理結果,再執行空格分詞。(But WHY?)
經過這步後,和上步結果一樣:
>>> 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
爲例:
注:
- 藍色底色表示當前子字符串,對應於代碼中的
cur_substr
- 當從第一個位置開始遍歷時,不需要在當前字串前面加
##
,否則需要
大致流程說明(雖然我相信上面那個 GIF 夠清楚了):
- 從第一個位置開始,由於是最長匹配,結束位置需要從最右端依次遞減,所以遍歷的第一個子詞是其本身
unaffable
,該子詞不在詞彙表中 - 結束位置左移一位得到子詞
unaffabl
,同樣不在詞彙表中 - 重複這個操作,直到
un
,該子詞在詞彙表中,將其加入output_tokens
,以第一個位置開始的遍歷結束 - 跳過
un
,從其後的a
開始新一輪遍歷,結束位置依然是從最右端依次遞減,但此時需要在前面加上##
標記,得到##affable
不在詞彙表中 - 結束位置左移一位得到子詞
##affabl
,同樣不在詞彙表中 - 重複這個操作,直到
##aff
,該字詞在詞彙表中,將其加入output_tokens
,此輪遍歷結束 - 跳過
aff
,從其後的a
開始新一輪遍歷,結束位置依然是從最右端依次遞減。##able
在詞彙表中,將其加入output_tokens
able
後沒有字符了,整個遍歷結束
將 BT 的結果輸入給 WPT,那麼 example
的最終分詞結果就是
['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