sentencepiece APIの詳細を調べる (bert-japanese関連)

bert-japaneseのモデルを使っているとsentencepieceへの入力と出力が異なる場合がしばしばあって、文字数のずれが気になったのでsentencepieceについてもう少し調べた。

sentencepieceのNormalizerは何をしてる

sentencepieceのテキストのノーマライズ処理はunicodedata関係とその他の処理がbuilder.ccとnormalizer.ccに記述。

sentencepiece_model.proto内のNormalizerSpecでノーマライズ処理の指定が定義されている。

NormalizerSpecのフィールド
名称 説明
name 名前
precompiled_charsmap ユーザ定義以外の変換規則
add_dummy_prefix 先頭に追加でスペースをつけるか(デフォルト=True)
escape_whitespaces スペースをu2581にエスケープするか(デフォルト=True)
remove_extra_whitespace 先頭、文中、末尾のスペースを1つに集約するか(デフォルト=True)
normalization_rule_tsv ユーザ定義以外の変換規則

また、NormalizeSpecとは別にtreat_whitespace_as_suffix_というフラグもある。

これらの指定はモデルの学習前に決めておく必要あり。

unicodedata関係

デフォルトのnmtNFKCはunicodedataに基づいたNFKCのマッピングを基本とし、一部変更を加えてprecompiled_charsmapに保持。 (NFKCとは大雑把に、「は+゜」⇔「ぱ」のように修飾文字と被修飾文字を分けて書くこともできる部分を、分けないでコード化する正規化の方式)

具体的な変更は以下の規則の追加と削除。

(https://github.com/google/sentencepiece/blob/master/src/builder.cc)

util::Status Builder::BuildNmtNFKCMap(CharsMap *chars_map) {
#ifdef ENABLE_NFKC_COMPILE
  LOG(INFO) << "Running BuildNmtNFKCMap";

  CharsMap nfkc_map;
  RETURN_IF_ERROR(Builder::BuildNFKCMap(&nfkc_map));

  // Other code points considered as whitespace.
  nfkc_map[{0x0009}] = {0x20};  // TAB
  nfkc_map[{0x000A}] = {0x20};  // LINE FEED
  nfkc_map[{0x000C}] = {0x20};  // FORM FEED
  nfkc_map[{0x000D}] = {0x20};  // CARRIAGE RETURN
  nfkc_map[{0x1680}] = {0x20};  // OGHAM SPACE MARK
  nfkc_map[{0x200B}] = {0x20};  // ZERO WIDTH SPACE
  nfkc_map[{0x200E}] = {0x20};  // LEFT-TO-RIGHT MARK
  nfkc_map[{0x200F}] = {0x20};  // RIGHT-TO-LEFT MARK
  nfkc_map[{0x2028}] = {0x20};  // LINE SEPARATOR
  nfkc_map[{0x2029}] = {0x20};  // PARAGRAPH SEPARATOR
  nfkc_map[{0x2581}] = {0x20};  // LOWER ONE EIGHT BLOCK
  nfkc_map[{0xFEFF}] = {0x20};  // ZERO WIDTH NO-BREAK
  nfkc_map[{0xFFFD}] = {0x20};  // REPLACEMENT CHARACTER
  nfkc_map[{0x200C}] = {0x20};  // ZERO WIDTH NON-JOINER
  nfkc_map[{0x200D}] = {0x20};  // ZERO WIDTH JOINER

  // Ascii Control characters
  nfkc_map[{0x0001}] = {};
  nfkc_map[{0x0002}] = {};
  nfkc_map[{0x0003}] = {};
  nfkc_map[{0x0004}] = {};
  nfkc_map[{0x0005}] = {};
  nfkc_map[{0x0006}] = {};
  nfkc_map[{0x0007}] = {};
  nfkc_map[{0x0008}] = {};
  nfkc_map[{0x000B}] = {};
  nfkc_map[{0x000E}] = {};
  nfkc_map[{0x000F}] = {};
  nfkc_map[{0x0010}] = {};
  nfkc_map[{0x0011}] = {};
  nfkc_map[{0x0012}] = {};
  nfkc_map[{0x0013}] = {};
  nfkc_map[{0x0014}] = {};
  nfkc_map[{0x0015}] = {};
  nfkc_map[{0x0016}] = {};
  nfkc_map[{0x0017}] = {};
  nfkc_map[{0x0018}] = {};
  nfkc_map[{0x0019}] = {};
  nfkc_map[{0x001A}] = {};
  nfkc_map[{0x001B}] = {};
  nfkc_map[{0x001C}] = {};
  nfkc_map[{0x001D}] = {};
  nfkc_map[{0x001E}] = {};
  nfkc_map[{0x001F}] = {};

  //  <control-007F>..<control-009F>
  nfkc_map[{0x007F}] = {};
  nfkc_map[{0x008F}] = {};
  nfkc_map[{0x009F}] = {};

  // Do not normalize FULL_WIDTH TILDE, since FULL_WIDTH TILDE
  // and HALF_WIDTH TILDE are used differently in Japanese.
  nfkc_map.erase({0xFF5E});

  RETURN_IF_ERROR(RemoveRedundantMap(&nfkc_map));

  *chars_map = std::move(nfkc_map);

#else
  LOG(ERROR) << "NFKC compile is not enabled."
             << " rebuild with ./configure --enable-nfkc-compile";
#endif

  return util::OkStatus();
}

主にスペースの半角スペースへの集約と制御文字の除去が定義されているが、突然日本語のローカルルールが出てきていて趣き深い。

その他の処理

NormalizeSpecのフィールドのprecompiled_charsmap以外の部分とtreat_whitespace_as_suffix_で挙動が決まる。

treat_whitespace_as_suffix_

これがTrueの時は「word1_, word2_, word3_」のように単語の後ろにスペースが付くようにノーマライズされる。逆にFalseだと先頭につく。デフォルトはFalse。

add_dummy_prefix

「word1 word2 word3」を「word1, _word2, _word3」とノーマライズすると先頭の単語だけスペースが付かない状態になる。 先頭に_をつけてやることですべての単語を先頭にスペースを付けて扱えるようにするためにadd_dummy_prefixをつける(treat_whitespace_as_suffix_=Trueの時は最後にスペースが付くように書かれている)。

日本語はスペースで区切る言語ではないのでadd_dummy_prefix=Trueにすると逆に文頭のトークンだけスペースがついてしまう。だから、日本語の場合はadd_dummy_prefix=Falseで良いと思われる。

処理まとめ

ここまでの話をまとめると、完全に同一にできているかテストはしていないが、pythonでnmtNormalizeを模倣すると次のような感じになりそう。

import unicodedata

nmt_norm_map = str.maketrans({
        # Other code points considered as whitespace.
        '\u0009':'\u0020',  # TAB
        '\u000A':'\u0020',  # LINE FEED
        '\u000C':'\u0020',  # FORM FEED
        '\u000D':'\u0020',  # CARRIAGE RETURN
        '\u1680':'\u0020',  # OGHAM SPACE MARK
        '\u200B':'\u0020',  # ZERO WIDTH SPACE
        '\u200E':'\u0020',  # LEFT-TO-RIGHT MARK
        '\u200F':'\u0020',  # RIGHT-TO-LEFT MARK
        '\u2028':'\u0020',  # LINE SEPARATOR
        '\u2029':'\u0020',  # PARAGRAPH SEPARATOR
        '\u2581':'\u0020',  # LOWER ONE EIGHT BLOCK
        '\uFEFF':'\u0020',  # ZERO WIDTH NO-BREAK
        '\uFFFD':'\u0020',  # REPLACEMENT CHARACTER
        '\u200C':'\u0020',  # ZERO WIDTH NON-JOINER
        '\u200D':'\u0020',  # ZERO WIDTH JOINER
        
        # Ascii Control characters
        '\u0001':'',
        '\u0002':'',
        '\u0003':'',
        '\u0004':'',
        '\u0005':'',
        '\u0006':'',
        '\u0007':'',
        '\u0008':'',
        '\u000B':'',
        '\u000E':'',
        '\u000F':'',
        '\u0010':'',
        '\u0011':'',
        '\u0012':'',
        '\u0013':'',
        '\u0014':'',
        '\u0015':'',
        '\u0016':'',
        '\u0017':'',
        '\u0018':'',
        '\u0019':'',
        '\u001A':'',
        '\u001B':'',
        '\u001C':'',
        '\u001D':'',
        '\u001E':'',
        '\u001F':'',
        
        #  <control-007F>..<control-009F>
        '\u007F':'',
        '\u008F':'',
        '\u009F':'',
    })

def normalize_with_nmt_NFKC(
        text,
        treat_whitespace_as_suffix_=False,
        add_dummy_prefix=True,
        remove_extra_whitespaces=True,
        escape_whitespaces=True,
    ):
    
    # custom mapping for nmt
    text = text.translate(SentencePieceTokenizer.nmt_norm_map)
    # tilde protection (storing)
    tildes = filter(lambda c: c == '\uFF5E' or c == '\u007E', text)
    # nfkc normalization
    text = unicodedata.normalize('NFKC', text)
    # tilde protection (restoring)
    text = ''.join([c if c != '\u007E' else next(tildes) for c in text])
    
    # triming extra spaces
    if remove_extra_whitespaces:
        text = re.sub('\u0020+', '\u0020', text.strip())
    # dummy space
    if add_dummy_prefix:
        if treat_whitespace_as_suffix_:
            text = text + '\u0020'
        else:
            text = '\u0020' + text
    # escaping spaces
    if escape_whitespaces:
        text = text.replace('\u0020', '\u2581')
    
    return text

これを施したテキストにアノテーションすれば文字数のずれは起きないはず。

[蛇足] bert-japaneseの学習済みsentencepieceの分割への後処理

sentencepieceのNormalizerが何をしているか分かったところでbert-japaneseに戻す。

デフォルトで学習しているようなのでadd_dummy_prefix=Trueだ。 前回見たように、一部の語彙が先頭にスペースが付いた状態で登録されていて、スペースをとった部分が語彙に含まれていなかったことの要因はこの設定の影響もあると考えられる。

先頭にスペースがないと使えなくなってしまう語彙があるのは微妙なので何とかしたい。しかし、学習済みモデルの語彙に大幅な変更は加えないほうがいい。

そこで、トークン化の後処理で全ての先頭のスペースを別トークンとして分離してみる。

  • スペースを分離した結果、対応するトークンがなくなってしまう175語彙は、もともとスペースが先頭にあった語彙の.vocabの表層からスペースを消してスペースがなかったことにする。
  • ついでに、「、」で始まるトークンにも同様の処理を行う。

.vocabの書き換え: replace_sp_vocab.py
後処理をするtokenizer: tokenization_sp_mod.py

実験

  • 書き換えた.vocabと後処理を行うtokenizerでLivedoorニュースコーパスの分類タスクを再び転移学習。
  • 他の条件は前回の転移学習と同一。

結果

全ての学習データ4421記事のうち、今回の変更で影響を受けたトークンがあった記事は4224記事。変えたところが使われなかったということはない。アルファベットのトークンから先頭のスペースが分離されたパターンが多かった。

用いた学習データの割合 前回のF値 今回のF値
100% 0.965 0.965
5% 0.857 0.847

→ 書き換える前と比べて変化はなし。

感想

  • 今回のデータセットは記事の全体的な傾向から分類するものだと思うので影響がないのは、おかしくなさそう。逆に差が出るタスクはある?

  • 今のところ言えることは、トークン化にはbert-japanese標準/一部書き換え、好きなほうを使えば良い。

  • 我ながら、やっていることが細かい。