MNTSQ Techブログ

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

Transformersを用いた固有表現抽出のtips

MNTSQ Tech Blog TOP > 記事一覧 > Transformersを用いた固有表現抽出のtips

TL;DR

  • TransformersのNERではFast Tokenizerを使うことで、サブトークン ↔ ラベルのアラインメントが実装できる。
  • 長いテキスト入力については、無駄なpaddingを最小限にとどめて高速処理するために、入力を固定長分割するのが良い。
    • 検出漏れが問題になるようであれば、ストライド付きのwindow処理を追加するのが良い。
  • サンプル実装: github.com

背景

この記事を目に留めていただいた方にはおそらくおなじみであろう Hugging Face の Transformers *1。 BERT等のTransformer素子ベース事前学習モデルを用いた転移学習が容易に実験できるライブラリである。 最新モデルのモジュールがすごいスピードで実装されることに加えて、事前学習モデルおよび依存するトークナイザが一緒に管理・ダウンロードできる点がご利益として特に大きい。 文書分類やNERのような下流タスクは、転移学習を用いることでフルスクラッチ教師あり学習に比べて少ないデータで同等以上の認識性能が達成できることが多く、TransformersはNLPの転移学習ツールのデファクトになりつつある*2

日本語データに対してexampleに同梱されているコードをかけようとすると(=事前学習モデル部分だけ書き換えて実行すると)文書分類はかんたんに通るが、NERはそのままではうまく行かない。要因としては大きく2点ある:

  • 分かち書き済みデータが仮定されており、トークン-ラベル対応を保持するのが大変。
    • サブワードとワード単位ラベルとのアラインメントが必要。
    • 事前学習モデルのトークナイザと異なる分かち書き単位のデータセットを使用する場合には、トークン間アラインメントによる変換(retokenization)も必要。
  • 最大長を超える長いテキストについてはうまく事前分割しないと扱えない。

これらは固有表現抽出を行う際の頻出の課題であり、retokenization以外の論点についてはLightTagのブログ記事に素晴らしい解説がある。 www.lighttag.io

当記事ではこの頻出の問題に対するtipsを解説し、それを考慮したTransformersベースの日本語固有表現抽出のサンプル実装を行った。

Tips

2点のつまづきポイントで必要になる処理を整理すると以下のようになる。

  • ラベルアラインメント

    • トークン単位ラベルを、サブワードトークン単位ラベルに変換するアラインメント処理。
    • NERは系列入力-系列出力のタスクであり、トークンとラベルが同じ長さに対応付く必要があるため。
  • 長いテキストの分割処理

    • 入力系列をすべて処理したいが、メモリ都合の最大系列長制限があるため必要になる処理。
    • 系列入力-値出力のタスクである文書分類では、雑に 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) の計算複雑度が膨らむ。

    • 非一様な可変長ブロックを生じる分割なので、テキスト長が長くなればなるほどこの点は悪化する。
  • 認識性能向上のコスパ:分割処理の適切さが担保できない点が厄介。

    • 文分割は一般に難しい問題である。
      • ルールでやるには限界があり、言語モデルやMLで解こうとすると開発コストや速度の問題が生じる。
    • アノテーションに基づく動的分割はヒューリスティックで良さが容易には定まらない。
      • B-タグやL-タグの周辺何トークンを残せば十分な文脈といえるかのチューニングが挟まる。

固定長分割処理のメリット

単純な点に加えて、トークンのテンソル化に無駄がないため高速であることが挙げられる。 ただし、分割境界にアノテーションが位置する場合、予測ができず検出漏れを生じる点については気をつけなければならない。 エンティティがそこそこ密に分布する長文テキストに対しては、これによる認識性能低下は無視したくないこともよくある。

その場合、ストライド付きのウィンドウ分割処理を挟むことで、境界検出漏れ問題を緩和することができる。*3

ストライド付きの固定長ウィンドウ分割処理では、ウィンドウ長とストライド長というパラメータが生じるが、以下のように速度と認識性能をバランスするように設定する必要がある。

  • 速度:ウィンドウ長はトークン長なので2乗オーダーで小さいほど速く、ストライド長は生成されるウィンドウ数に線形比例するので大きいほうが速い。
  • 認識性能:ウィンドウ長が大きいほど考慮できる文脈が長く(おそらく)良く、ストライド長が小さいほど境界検出漏れが減って良い。

例えば、まず速度影響の大きいウィンドウ長を性能が減りすぎない程度に小さくし、アノテーショントークン長の分布を考慮しつつストライド長をある程度小さく設定するなどの工夫が考えられる。

サンプル実装

Transformers + PyTorch-Lightning*4 + MLflow Tracking *5 *6 の構成でサンプル実装を作成した。

github.com

参考にした実装.

自動ダウンロードする日本語データセット:

文書分類との差分

用意した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株式会社では契約書解析を高度化する自然言語処理エンジニアを募集しています:

hrmos.co

この記事を書いた人

kzinmr

稲村和樹

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

*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をそのまま処理すればよい。