Techブログ - MNTSQ, Ltd.

リーガルテック・カンパニー「MNTSQ(モンテスキュー)」のTechブログです。

日本語でHugging Face Tokenizersを動かす

MNTSQ Tech Blog TOP > 記事一覧 > 日本語でHugging Face Tokenizersを動かす

f:id:kzinmr:20201216144605p:plain

前回記事に続いてHugging Faceネタです。Transformers本体ではなく、分かち書きを行うTokenizersライブラリの紹介をします。

Hugging Faceが開発しているTransformersでは、事前学習モデルと用いた分かち書き処理を同梱して配布している。 機械学習モデルの学習時と推論時の間で分かち書き設定が異なったり、分かち書き済み公開データと分かち書き設定が揃っていなかったりすると、モデルの挙動が正しく再現できないので、この設定が揃うように仕組みで吸収できる良いプラクティスといえる。

比較的古いバージョン*1のTransformersが用いるトークナイザは、ライブラリ内に同梱されるPython実装のものであった。 日本語で配布されているTransformersモデルの事例でいうと、例えば東北大学の乾研究室から公開されている日本語BERTモデルでは、Transformers内のトークナイザ(BertTokenizer, WordpieceTokenizer)を継承したMeCabのtokenizerが定義・配布されている。これにより、学習時に用いられたtokenizerが何であるか、どういった前処理を経ているかということが追跡可能なだけでなく、ユーザーが特別意識しなくても使われるようになっている。

github.com

一方でちょうど一年ほど前に、Transformersが用いるトークナイザはRust実装の別ライブラリ——Tokenizersとして分離された。 Transformers内ではFastTokenizerという呼称で既存のPython実装トークナイザと区別され、v4.0.0以降ではデフォルトで使用されるようになっている:

github.com

当エントリでは、なぜ新しいTokenizersを使ってみたかったのかという動機の説明と、日本語で使えるようにするための解説を行う。 筆者が自分で事前学習モデルを学習する際に、Hugging Face Tokenizersに準拠した日本語の分かち書きを行おうとしてみて、いくつかの点で躓いたのでその点が解説の中心になる。 掲載しているコードは、tokenizers v0.10.0にて動作確認を行った。 *2

まえおき: 日本語のBERTトークナイザ事情

Tokenizersを導入することで嬉しい点がいくつかある:

  • 日本語には余計なBERTの前処理を引数経由で簡単に外せる
  • サブワード分割の学習が1ライブラリ内で完結し、各種のサブワード分割手法が容易に使える
  • Rust実装でトークナイズ部が速くなる
  • 出力結果のデータ型に便利な処理が備わっている
    • 古いtokenizerと異なり、文字-単語間のアラインメントを保持したトークナイズを行ってくれる*3
    • 文字列-idの相互変換をライブラリが管理してくれる
    • truncate, padding等の深層学習NLPで必要な処理を便利に行ってくれる

当節では最初の2点について解説する。

日本語には余計なBERTの前処理

BERT派生のモデルやライブラリにおいては、GoogleのBERTの元実装のtokenizerが、前処理の細かい設定に至るまでコピーされていることが多く、Hugging Face Tokenizersもこの例外ではない。実験再現性を優先するための状況だが、日本語の分かち書きを行う上ではデフォルト設定だと困った挙動をしてしまうことが知られている。*4 日本語のオプションがおざなりになっている背景としては、Google版BERTの日本語モデル配布がもともと多言語モデルとして公開されたのが最初だったため、特定言語間で有効な前処理の設定が優先されていたのであろうと推測される。

具体的には以下の2点である:

  • ひらがな・カタカナの濁点が除去されてしまう(ウムラウトなどUnicodeベースのアクセント記号除去として)
  • 漢字が必ず一文字に分割されてしまう(多言語モデルでCJK文字のうち中国語が重要視された結果のデフォルト設定)

文書分類のようにトークナイズ結果がどう処理されようが、良い最終出力が得られれば問題ないタスクにおいてはこの点は無視できるかもしれないが、前処理を適正化することで日本語のタスク性能が上がることもある。 また、固有表現抽出や抽出型質問応答タスクのような、入力テキストの一部を出力するようなタスクに取り組む際にこの点が気になることもある。

この点を修正するためには、BERT元実装や古いバージョンのTransformers同梱のtokenizerでは、モジュールのトークナイザ部分を直に書き直す必要があった。 しかし、新しいTransformers同梱のtokenizer実装では、この前処理が以下のような引数で詳細に制御できるように改善されていて、ライブラリ利用者としてはこちらを使用したい。

  • 漢字を一文字分割しない: tokenize_chinese_chars=False
  • 濁点を除去させない: strip_accents=False
    • 古いバージョンでアクセント除去を無効化するには、 do_lower_case=False オプションでまるっとしか制御できなかったが、新しい版ではlower処理とアクセント除去処理の制御が分離されている。

https://huggingface.co/transformers/model_doc/bert.html#transformers.BertTokenizer

他にも細かい点だが、デフォルトで有効なunicode正規化も場合によっては行ってほしくないことがある。 最近筆者が遭遇した事例としては、⑰のような1文字が '17' の2文字に分割されてしまい、文字列長が処理前後で変化してしまう罠にハマったりした。 後述するようにTokenizersライブラリでは前処理のパイプラインをユーザーが宣言的に定義できるので、この点も助かる。

サブワード分割の訓練が任意の別ライブラリに依存

BERT派生のモデルでは従来の単語分かち書きだけでなく、さらに細かい分かち書き単位であるサブワード分割という処理が適用される。 モデル的にはこの処理は必須というわけではないものの、報告されているタスク性能のほとんどがサブワード分割を入れた上での性能値なので、特別な理由がなければ入れておきたい。

サブワード分割を行うためには、特定コーパスに依存したサブワード分割パターンを学習・記憶するプロセスが必要になる。 vocabularyファイルがサブワード分割の学習の結果生成され*5、サブワード分割を行う際に必要になる。 事前学習モデルに入力されるトークンはこのvocabularyファイルに依存して決まるため、自分で事前学習を行う場合には、用いるコーパス上でのサブワード分割を学習した上で使用するのが推奨される。

GoogleのBERT元実装や従来のTransformers同梱のtokenizerでは、サブワード分割の学習結果は外部から与えられるものとされており、サブワード分割手法に応じた外部ライブラリの選択はユーザーに委ねられていた。そのため、事前学習の訓練スクリプトの他に、サブワード学習のためのスクリプトや外部ライブラリを追加する必要があった。 また、BERT論文で用いているとされるWordPieceの元実装にアクセスしづらかった状況から、日本語では実装にアクセスしやすいBPEやsentencepieceといったサブワード分割手法が選ばれてきた印象がある:

この点に対して、Hugging Face TokenizersではWordPiece, BPE, SentencePieceなどを含む各種のサブワード分割を学習する実装が同梱されており、Tokenizersライブラリだけでサブワード学習が完結するようになった。*6

日本語でHugging Face Tokenizersを動かす

検索に微妙にhitしづらいのでドキュメントへのリンクを掲載する。これをざっと読めばライブラリ構成の概要は把握できる。

huggingface.co

Hugging Face TokenizersにおけるTokenizerオブジェクトとは、以下の要素からなる各種処理のパイプラインコンテナである。 Encode方向での利用、つまり事前学習モデルに入力可能なトークン列を生成する方向では、最終結果がEncodingオブジェクトとして得られる。 このデータに元のトークン列、モデル特有の特殊文字トークン-id対応、トークン-元の文字列対応といったデータが格納されている。

Encode方向: 文字列 → 事前学習モデル入力可能なEncodingオブジェクト

  • Normalizer: 文字列正規化の前処理(Unicode正規化や小文字化など)
  • PreTokenizer: 文字列→基本トークン(単語)の変換
  • Model: 基本トークン→トークン(サブワード)の変換;学習済みvocabularyが必要な箇所
  • PostProcessor: 最終的なEncodingデータを生成する後処理(BERTの特殊トークンの追加など)

Decode方向: トークン列 (Encoding)→ 元の文字列

  • Decoder: トークン-元の文字列対応情報を用いて各トークンが位置する元の文字列上の位置情報を復元できる

注意すべき点としては、TokenizersにおけるTokenというのは、いわゆる単語ではなくサブワードのことである。 つまり、従来の日本語形態素解析器は、TokenizersパイプラインにおけるPreTokenizerに位置づけられる(英語ではカンマなどを考慮したwhite space分割などに相当)。

PreTokenizerにMeCabを差し込めば良さそうというところまではすんなり理解できるのだが、バックエンドがRustでPythonバインディング提供という点から、Pythonでやるにはこの先が思ったよりすんなり行かなかった。

結論から書くと、PreTokenizerをPython側では継承定義することができず、既存のTokenizerに対して、以下のような custom PreTokenizerとして注入する必要がある:

from tokenizers.implementations import BertWordPieceTokenizer
from tokenizers.pre_tokenizers import BertPreTokenizer, PreTokenizer
...
    # 既存Tokenizer
    tokenizer = BertWordPieceTokenizer(
        handle_chinese_chars=False,  # for japanese
        strip_accents=False,  # for japanese
    )
    # ユーザー定義のcustom PreTokenizer(MecabPreTokenizer)を注入
    tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer())
...

この点はdocumentに書かれておらず、以下のissueからやり方を把握した(多言語対応は現状だとPoC機能に位置付けられている模様):

github.com

コメントにあるように、custom PreTokenizerの作り方は中国語custom PreTokenizerのサンプルを参考にした。

ただし、日本語では単語から元の文字列上のspanアラインメントを保持する機能は自分で追加してやる必要がある(中国語形態素解析器のjiebaでは便利なことに標準機能のようだ)。 このアラインメント処理がTokenizersの機能要件に入っていることで、トークンと元の文字列の行き来が相当スムーズになるので、この労力は払う価値があると考えている。 この点が便利なケースについては、前回のエントリを参照いただきたい。

トークンと元の文字列のアラインメント処理については pytextspan を使用した。 ほぼ最小限に近い日本語custom PreTokenizerのサンプル実装は以下のようになる:

from typing import List, Optional
from MeCab import Tagger
import textspan
from tokenizers import NormalizedString, PreTokenizedString

class MecabPreTokenizer:
    def __init__(
        self,
        mecab_dict_path: Optional[str] = None,
    ):
        """ Construct a custom PreTokenizer with MeCab for huggingface tokenizers.
        """

        mecab_option = (
            f"-Owakati -d {mecab_dict_path}"
            if mecab_dict_path is not None
            else "-Owakati"
        )
        self.mecab = Tagger(mecab_option)

    def tokenize(self, sequence: str) -> List[str]:
        return self.mecab.parse(sequence).strip().split(" ")

    def custom_split(
        self, i: int, normalized_string: NormalizedString
    ) -> List[NormalizedString]:
        """ See. https://github.com/huggingface/tokenizers/blob/b24a2fc/bindings/python/examples/custom_components.py
        """
        text = str(normalized_string)
        tokens = self.tokenize(text)
        tokens_spans = textspan.get_original_spans(tokens, text)
        return [
            normalized_string[st:ed]
            for char_spans in tokens_spans
            for st, ed in char_spans
        ]

    def pre_tokenize(self, pretok: PreTokenizedString):
        pretok.split(self.custom_split)

これで問題は8割ほど解決したが、作成した日本語custom PreTokenizerを備えたTokenizerを使って、サブワード分割学習・シリアライズ・ロード、と使ってみようとすると、custom PreTokenizerはシリアライズできないと怒られる。 この点についてはまだissueに積まれている状況で、ひとまず動かす目的としてはシリアライズ可能なノンカスタムのPreTokenizerをダミーで代入してシリアライズすることで回避した(custom PreTokenizerは実際にテキストが入力されるタイミングで有効になっていさえすれば良い)。 設定ファイルからワンラインでロードできない状況なので、この点が解決しない限り日本語含む非英語言語に完全対応したとは言えなさそうだと感じているが、これでひとまず学習・推論まで正しく動作するTokenizerが得られた。

以下が、カスタムTokenizerの作成・サブワード分割の学習・モデルシリアライズの一連の流れを示したコードである。(tokenizer._tokenizer という箇所は、BertWordPieceTokenizerがTokenizerのラッパーになっている都合上こうなっている。)

def train_custom_tokenizer(
    files: List[str], tokenizer_file: str, **kwargs
) -> BertWordPieceTokenizer:
    """ Tokenizerの学習・保存処理:custom PreTokenizer付きのTokenizerを学習・保存する。
    """

    tokenizer = BertWordPieceTokenizer(
        handle_chinese_chars=False,  # for japanese
        strip_accents=False,  # for japanese
    )
    tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer())

    # 与えられたコーパスファイル集合からサブワード分割を学習
    tokenizer.train(files, **kwargs)

    # vocab情報に加えて、前処理等パラメータ情報を含んだトークナイザ設定のJSONを保存
    # NOTE: Pythonで書かれたcustom PreTokenizerはシリアライズできないので、RustベースのPreTokenizerをダミー注入してシリアライズ
    # JSONにはダミーのPreTokenizerが記録されるので、ロード時にcustom PreTokenizerを再設定する必要がある。
    tokenizer._tokenizer.pre_tokenizer = BertPreTokenizer()
    tokenizer.save(tokenizer_file)

    # (Optional) .txt形式のvocabファイルは f"vocab-{filename}.txt" で保存される(外部の処理で欲しい場合)
    filename = "wordpiece"
    model_files = tokenizer._tokenizer.model.save(
        str(Path(tokenizer_file).parent), filename
    )

    return tokenizer

def load_custom_tokenizer(tokenizer_file: str) -> Tokenizer:
    """ Tokenizerのロード処理:tokenizer.json からTokenizerをロードし、custome PreTokenizerをセットする。
    """
    tokenizer = Tokenizer.from_file(tokenizer_file)
    # ダミー注入したRustベースのPreTokenizerを、custom PreTokenizerで上書き。
    tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer())
    return tokenizer

参考のためにデモスクリプト全文を掲載しておく

from pathlib import Path
from typing import List, Optional

import MeCab
import textspan
from tokenizers import NormalizedString, PreTokenizedString, Tokenizer
from tokenizers.implementations import BertWordPieceTokenizer
from tokenizers.pre_tokenizers import BertPreTokenizer, PreTokenizer


class MecabPreTokenizer:
    def __init__(
        self,
        mecab_dict_path: Optional[str] = None,
    ):
        """Construct a custom PreTokenizer with MeCab for huggingface tokenizers."""

        mecab_option = (
            f"-Owakati -d {mecab_dict_path}"
            if mecab_dict_path is not None
            else "-Owakati"
        )
        self.mecab = MeCab.Tagger(mecab_option)

    def tokenize(self, sequence: str) -> List[str]:
        return self.mecab.parse(sequence).strip().split(" ")

    def custom_split(
        self, i: int, normalized_string: NormalizedString
    ) -> List[NormalizedString]:
        """See. https://github.com/huggingface/tokenizers/blob/b24a2fc/bindings/python/examples/custom_components.py"""
        text = str(normalized_string)
        tokens = self.tokenize(text)
        tokens_spans = textspan.get_original_spans(tokens, text)
        return [
            normalized_string[st:ed]
            for char_spans in tokens_spans
            for st, ed in char_spans
        ]

    def pre_tokenize(self, pretok: PreTokenizedString):
        pretok.split(self.custom_split)


def train_custom_tokenizer(
    files: List[str], tokenizer_file: str, **kwargs
) -> BertWordPieceTokenizer:
    """Tokenizerの学習・保存処理:custom PreTokenizer付きのTokenizerを学習・保存する。"""

    tokenizer = BertWordPieceTokenizer(
        handle_chinese_chars=False,  # for japanese
        strip_accents=False,  # for japanese
    )
    tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer())

    # 与えられたコーパスファイル集合からサブワード分割を学習
    tokenizer.train(files, **kwargs)

    # vocab情報に加えて、前処理等パラメータ情報を含んだトークナイザ設定のJSONを保存
    # NOTE: Pythonで書かれたcustom PreTokenizerはシリアライズできないので、RustベースのPreTokenizerをダミー注入してシリアライズ
    # JSONにはダミーのPreTokenizerが記録されるので、ロード時にcustom PreTokenizerを再設定する必要がある。
    tokenizer._tokenizer.pre_tokenizer = BertPreTokenizer()
    tokenizer.save(tokenizer_file)

    # (Optional) .txt形式のvocabファイルは f"vocab-{filename}.txt" で保存される(外部の処理で欲しい場合)
    filename = "wordpiece"
    model_files = tokenizer._tokenizer.model.save(
        str(Path(tokenizer_file).parent), filename
    )

    return tokenizer


def load_custom_tokenizer(tokenizer_file: str) -> Tokenizer:
    """Tokenizerのロード処理:tokenizer.json からTokenizerをロードし、custome PreTokenizerをセットする。"""
    tok = Tokenizer.from_file(tokenizer_file)
    # ダミー注入したRustベースのPreTokenizerを、custom PreTokenizerで上書き。
    tok.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer())
    return tok


if __name__ == "__main__":
    s = "今日はいい天気だ"
    with open("test.txt", "wt") as fp:
        fp.write(s)
        fp.write("\n")

    fnames = ["test.txt"]
    tokenizer_file = "tokenizer_test.json"
    settings = dict(
        vocab_size=30000,
        min_frequency=1,
        limit_alphabet=5000, # 日本語の文字種類数の参考値
    )
    tokenizer = train_custom_tokenizer(fnames, tokenizer_file, **settings)

    print(s)
    # tok = load_custom_tokenizer(tokenizer_file)
    tok = Tokenizer.from_file(tokenizer_file)
    print(tok.encode(s).tokens)  # ただしく分割できない
    tok.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer())
    print(tok.encode(s).tokens)

    print(tok.normalizer)
    print(tok.pre_tokenizer)
    print(tok.model)
    print(tok.decoder)

本記事の主題とはずれるが、この後段でSWIGMeCab Taggerがpickle化できないと怒られることがあり(MLFlow等)、調べたところSWIGTaggerをpickle化可能にする処理を書いてくれた方がいたのであわせて共有させていただきます(通常のTaggerをラップすれば良い):

tma15.github.io

以上、Hugging Face Tokenizersをなぜ使いたいかの動機と、日本語でHugging Face Tokenizersを動かす際に詰まった点を解説しました。 まだ開発中の色合いが強いライブラリで気軽に使用を勧められる状況とは必ずしも言えませんが、大変多くのユーザーを抱えた開発の盛んなライブラリであるため、近い将来BERT Tokenizer周りのコードはより洗練されたものになっていくことでしょう。

この記事はMNTSQ株式会社の業務時間内に書かれました。 MNTSQ株式会社では契約書解析を高度化する自然言語処理エンジニアを募集しています:

www.wantedly.com

この記事を書いた人

kzinmr

稲村和樹

自然言語処理エンジニア。爬虫類が好き。

*1:といっても2年ほど前のバージョンですが

*2:日本語に適用したサンプルは知る限りにおいては無かったです。いまだに開発が盛んなライブラリであり、日本語で使う上でのソフトウェア上の課題はまだ残っているという点を予め断っておきます。

*3:この機能が便利になる場面については、前回のエントリで解説しています。ただし、後で見るように日本語でこれを実現するには別のライブラリの力を借りています。

*4:この点への言及例としては、京都大学 黒橋研究室で公開されているBERTの注意書きを参照: http://nlp.ist.i.kyoto-u.ac.jp/?ku_bert_japanese#r6199008

*5:TFIDFやword2vecの学習でコーパスごとにvocabularyファイルが出力されるのに似ていますが、コーパスごとに適応的に決まるサブワード分割のvocabularyファイルは、サブワード分割のモデルと呼ばれます。

*6:Tokenizers実装WordPieceの日本語のサブワード分割が遅くてつらいですが、学習が速いBPE実装も追加されている模様です: https://github.com/huggingface/tokenizers/pull/165