TL;DR
- TransformersのNERではFast Tokenizerを使うことで、サブトークン ↔ ラベルのアラインメントが実装できる。
- 長いテキスト入力については、無駄なpaddingを最小限にとどめて高速処理するために、入力を固定長分割するのが良い。
- 検出漏れが問題になるようであれば、ストライド付きのwindow処理を追加するのが良い。
- サンプル実装: github.com
背景
この記事を目に留めていただいた方にはおそらくおなじみであろう Hugging Face の Transformers *1。 BERT等のTransformer素子ベース事前学習モデルを用いた転移学習が容易に実験できるライブラリである。 最新モデルのモジュールがすごいスピードで実装されることに加えて、事前学習モデルおよび依存するトークナイザが一緒に管理・ダウンロードできる点がご利益として特に大きい。 文書分類やNERのような下流タスクは、転移学習を用いることでフルスクラッチの教師あり学習に比べて少ないデータで同等以上の認識性能が達成できることが多く、TransformersはNLPの転移学習ツールのデファクトになりつつある*2。
日本語データに対してexampleに同梱されているコードをかけようとすると(=事前学習モデル部分だけ書き換えて実行すると)文書分類はかんたんに通るが、NERはそのままではうまく行かない。要因としては大きく2点ある:
これらは固有表現抽出を行う際の頻出の課題であり、retokenization以外の論点についてはLightTagのブログ記事に素晴らしい解説がある。 www.lighttag.io
当記事ではこの頻出の問題に対するtipsを解説し、それを考慮したTransformersベースの日本語固有表現抽出のサンプル実装を行った。
Tips
2点のつまづきポイントで必要になる処理を整理すると以下のようになる。
ラベルアラインメント
長いテキストの分割処理
- 入力系列をすべて処理したいが、メモリ都合の最大系列長制限があるため必要になる処理。
- 系列入力-値出力のタスクである文書分類では、雑に truncate & padding をすればとりあえず使える数字が出るが、NERはそうでない。
ラベルアラインメント
Transformerベースの事前学習モデルではWordPieceやSentencePieceといったサブワード化手法が用いられる(↗︎は粗いトークン粒度への変換、↘︎は細かいトークン粒度への変換を表す)。
- WordPiece encode: 文(unigrams) ↗︎ words ↘︎ subwords
- SentencePiece encode: 文(unigrams) ↗︎ subwords
単調な変換箇所(WordPiece:words→subwords, SentencePiece:unigrams→subwords)に限定することで、以下のような単純な処理で文字→単語対応を復元することがおおよそ可能ではある。
def char_to_token( tokens: List[str], text: str, token_to_id: Callable[str, int], subword_prefix: Optional[str] = None, ) -> List[int]: """ Draft of function equivalent to FastTokenizer.char_to_token for WordPiece - token_to_id: spm.piece_to_id for SentencePiece """ if subword_prefix is not None: tokens_raw = [w.replace(subword_prefix, "") for w in tokens] else: tokens_raw = tokens assert sum(map(len, tokens_raw)) == len(text) token_iter = iter(tokens_raw) w = next(token_iter) word_ix = 0 token_start = 0 charid_to_tokenid = [] for i in range(len(text)): token_end = token_start + len(w) if i >= token_end: token_start = token_end word_ix += 1 w = next(token_iter) aligned_token_id = token_to_id(tokens[word_ix]) charid_to_tokenid.append(aligned_token_id) return charid_to_tokenid
実際には空白文字のハンドルなど考慮すべき細かい点が存在し、TransformersにおいてはFast TokenizerというTokenizerを用いることでこの機能が実装されている。Fast TokenizerはTransformers付属のtokenizersモジュールに含まれており、ベースがRust実装で高速に動作する。
長いテキストの分割処理
NERのような系列入力-系列出力のタスクでは、 メモリ都合の最大系列長制限がありつつも、以下の理由で系列の truncate および padding を避けたい。
- truncate: 予測対象から漏れる系列部位が生じるのでやるべきでない。
- padding: Transformersは O(#token^2) の計算複雑度を持つので、速度低下を避けるために不要なpadding tokenは最小化したい。
この点はTransformersに限らず任意のDeep系列処理に言えるtipsであるが、以下に述べる点から固定長分割を行うこと、必要ならばウィンドウ処理を組み合わせることが最も妥当そうな妥協案といえそうである。
意味的に自然な分割処理
固定長分割と対照的な分割として、文分割の処理や、アノテーションに基づいて動的に分割する処理が挙げられる。意味的に自然であるというのは、検出漏れをなくしつつ文脈を壊しにくいという、認識性能の観点での都合である。しかしいずれの方法も以下の欠点があるためなるべく採用したくない:
速度低下:不要なpaddingを生じ、 O(#token^2) の計算複雑度が膨らむ。
- 非一様な可変長ブロックを生じる分割なので、テキスト長が長くなればなるほどこの点は悪化する。
認識性能向上のコスパ:分割処理の適切さが担保できない点が厄介。
固定長分割処理のメリット
単純な点に加えて、トークンのテンソル化に無駄がないため高速であることが挙げられる。 ただし、分割境界にアノテーションが位置する場合、予測ができず検出漏れを生じる点については気をつけなければならない。 エンティティがそこそこ密に分布する長文テキストに対しては、これによる認識性能低下は無視したくないこともよくある。
その場合、ストライド付きのウィンドウ分割処理を挟むことで、境界検出漏れ問題を緩和することができる。*3
ストライド付きの固定長ウィンドウ分割処理では、ウィンドウ長とストライド長というパラメータが生じるが、以下のように速度と認識性能をバランスするように設定する必要がある。
- 速度:ウィンドウ長はトークン長なので2乗オーダーで小さいほど速く、ストライド長は生成されるウィンドウ数に線形比例するので大きいほうが速い。
- 認識性能:ウィンドウ長が大きいほど考慮できる文脈が長く(おそらく)良く、ストライド長が小さいほど境界検出漏れが減って良い。
例えば、まず速度影響の大きいウィンドウ長を性能が減りすぎない程度に小さくし、アノテーショントークン長の分布を考慮しつつストライド長をある程度小さく設定するなどの工夫が考えられる。
サンプル実装
Transformers + PyTorch-Lightning*4 + MLflow Tracking *5 *6 の構成でサンプル実装を作成した。
参考にした実装.
- https://github.com/LightTag/sequence-labeling-with-transformers
- https://github.com/huggingface/transformers/blob/7f60e93ac5c73e74b5a00d57126d156be9dbd2b8/examples/token-classification/run_pl_ner.py *7
自動ダウンロードする日本語データセット:
- 文書分類: livedoorコーパス
- NER: GSDデータ
文書分類との差分
用意した2つのコードのdiffをとり、データセットのダウンロードロード部分を除いた本質的な差分は次のようになる。 特に、Label処理および入力系列処理の2項目が前章で解説したtipsに対応する。
差分項目 | 文書分類 | NER | 該当モジュール |
---|---|---|---|
モデル入出力 | IntList→Int | IntList→IntList | LightningDataModule Dataset/DataLoader *8 *9 |
Transformersモジュールのモデルprefix | SequenceClassification | TokenClassification | LightningModule *10 |
評価メトリクス | Accuracy | Precision/Recall/F1 | LightningModule |
Label処理 | - | subwordアラインメント | LightningDataModule Dataset |
入力系列処理 | truncate&padding (built-in) | windowing&padding | LightningDataModule Dataset |
追加の前処理 | - | ラベルスキームの変換(例 BIO→BIOLU) 分かち書き単位の変換(例 UniDic→IPADIC) |
LightningDataModule |
以上、Transformersで日本語固有表現抽出をやる際に重要なポイントを解説し、そのサンプル実装を紹介した。 この記事はMNTSQ株式会社の業務時間内に書かれた。 MNTSQ株式会社では契約書解析を高度化する自然言語処理エンジニアを募集しています:
この記事を書いた人
稲村和樹
自然言語処理エンジニア。爬虫類が好き。
*1: 🤗
*2: Flairの文字ベースLSTMのような、日本語で利用できる非Transformer系の高性能な事前学習モデルも存在する:使用例は https://github.com/kzinmr/flair_ner_ja などを参照
*3: ウィンドウごとの認識結果をマージしなければならない点がやや煩雑なため、サンプル実装では結果のハンドルまでは行っていない。
*4: PyTorch-Lightningについては マニュアル が簡潔かつ簡明なため一読されたい。サンプル実装については Getting started, Best practices, Lightning API, Optional extensions > LightningDataModule あたりをざっと目を通した上で作成した
*5: MLflowのautologging連携については マニュアル および 機能リリース記事 等を参照。
*6: PyTorch-Lightning側のLoggerとして MlflowLogger も用意されているが、インラインコメント にある理由でmlflow.pytorch利用を推奨している
*7: 記事公開時点での最新版はTransformers Trainer APIやDataset APIを用いた実装(run_ner.py)に切り替わっていたが、長文を扱いたい場合はcollatorの追加調整が依然必要である。
*8: LightningModule.modelへの入力(DataLoader)ではテンソル化により差分が吸収される(See. collate_fn=InputFeaturesBatch)
*9: Transformers が用意する Trainer API の data_collator引数で使われる DataCollatorForTokenClassification を確認したが、paddingしか行わなず長いテキストの対処は事前に外部で行う想定ぽいので使用しないことにした
*10: BertForSequenceClassification / BertForTokenClassification などTransformersがタスク向けのモデルも用意してくれており、それをそのまま利用している。PreTrainedModel層をfreezeしたい場合はissueを参照: https://github.com/huggingface/transformers/issues/400#issuecomment-557300356 。decodingを工夫したい(CRF層の追加, 禁止タグ遷移(O→L, O→I)の追加, ビームサーチ等)場合はoutput.logitsをそのまま処理すればよい。