sentencepiece APIの詳細を調べる (bert-japanese関連)
bert-japaneseのモデルを使っているとsentencepieceへの入力と出力が異なる場合がしばしばあって、文字数のずれが気になったのでsentencepieceについてもう少し調べた。
sentencepieceのNormalizerは何をしてる
sentencepieceのテキストのノーマライズ処理はunicodedata関係とその他の処理がbuilder.ccとnormalizer.ccに記述。
sentencepiece_model.proto内の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 |
→ 書き換える前と比べて変化はなし。