BERT 是如何分詞的

BERT 表示 Bidirectional Encoder Representations from Transformers,是 Google 於 2018 年發佈的一種語言表示模型。該模型一經發布便成爲爭相效仿的對象,相信大家也都多少聽說過研究過了。本文主要聚焦於 BERT 的分詞方法,後續再談模型實現細節。

BERT 源碼tokenization.py 就是預處理進行分詞的程序,主要有兩個分詞器:BasicTokenizerWordpieceTokenizer,另外一個 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 字符,0i0x10ffff0 \leq i \leq \text{0x10ffff},即十進制的 [0,1114111][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 被替換成兩個空格:

>>> 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 Cholletré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.normalizeunicodedata.category 兩個函數。前者返回輸入字符串 text 的規範分解形式(Unicode 字符有多種規範形式,本文默認指 NFD 形式,即規範分解),後者返回輸入字符 charUnicode 類別。下面我舉例說明一下兩個函數的作用。

假如我們要處理 āóǔè,其中含有變音符號,這種字符其實是由兩個字符組成的,比如 ā(碼位 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 的總體過程就是:

  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)、沒有去掉的變音符號(例如 ç)都被相應處理:

>>> 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 爲例:

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 的最終分詞結果就是

['keras', '是', 'one', '##iros', '(', 'open', '-', 'ended', 'neu', '##ro', '-', 'electronic', 'intelligent', 'robot', 'operating', 'system', ',', '開', '放', '式', '神', '經', '電', '子', '智', '能', '機', '器', '人', '操', '作', '系', '統', ')', '項', '目', '研', '究', '工', '作', '的', '部', '分', '產', '物', '[', '3', ']', ',', '主', '要', '作', '者', '和', '維', '護', '者', '是', 'google', '工', '程', '師', 'franco', '##is', 'cho', '##llet', '。']

至此,BERT 分詞部分結束。

Reference

END

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章