BPEでサブワード分割することでDistilBERTに未知語が入力されるのを防ぐ方法

BPEでサブワード分割することでDistilBERTに未知語が入力されるのを防ぐ方法

最近案件でBERTを使う機会がありました。

Hugging Face社が公開している英語版のBERT東北大の乾・鈴木研究室が公開している日本語版BERTであれば自前のtokenizerで学習あるいは推論対象のテキストを単語分割しても問題ありません。
一方、京大の黒橋・褚・村脇研究室が公開しているBERTだったり、バンダイナムコ社が公開しているDistilBERTを使用する場合、自前のtokenizerではテキストをうまく単語分割できません。

そこで、JumanやMecabなどの形態素解析器で分割した単語をBERTに入力するのですが、そもそもBERTの事前学習済モデルではサブワード分割された単語を辞書に持つため、形態素解析器で分割した単語と一致しない場合が多々あります。
これを解決するためにはBERTに入力する単語もサブワード分割されている必要があります。

今回はBPEによって低頻度の単語をサブワード分割することで、BERTに入力した単語が未知語として検出されることをどれだけ防げるのかを検証します。

なお、紹介するコードはGoogle Colaboratoryでの実行を想定しています。

BPEとは

BPEとはByte Pair Encodingの頭文字であり、文書における低頻度の単語をさらに分割することで、低頻度の単語もうまく扱えるようにする手法です。
この低頻度の単語をさらに分割した単語をサブワードと呼びます。
例えば、「契約」という単語が文書内で低頻度だった場合、「契」と「##約」というサブワードに分割する事で、文書内における低頻度な単語の頻度が向上します。

自然言語処理の大半のモデルでは低頻度な単語はをそのまま入力してしまうと学習過程で捨てられてしまいますが、サブワードに分割することで、いわゆる未知語として扱わずに済むようになります。

BPEのルーツやアルゴリズムを知りたい方はこちらの記事が参考になります。

BPEで単語分割する方法

subword-nmtライブラリでお手軽に実装可能で、pipでインストールできます。

pip install subword-nmt

ちなみに京大の黒橋・褚・村脇研究室が公開している事前学習済BERTはこのライブラリを使用してサブワード分割しています。

入力テキストにJuman++ (v2.0.0-rc2)で形態素解析を行い、さらにBPEを適用しsubwordに分割
出典:http://nlp.ist.i.kyoto-u.ac.jp/index.php?BERT%E6%97%A5%E6%9C%AC%E8%AA%9EPretrained%E3%83%A2%E3%83%87%E3%83%AB(2020/10/19時点)

ただし、subword-nmtはコマンドライン実行を想定したライブラリなので、READMEに記載している通りのコマンドを前処理に組み込むのは面倒です。
なので、subprocessライブラリでPythonから呼べるようにメソッド化します。

import os
from pyknp import Juman
import subprocess

def learn_bpe(
    txt: str, output: str, symbols: int=10000, min_frequency: int=2, num_workers: int=1):
    """
    サブワードを学習し、code.txtを出力する
    """
    juman = Juman()
    midasi_list = list()
    for t in txt.split('\n'):
        result = juman.analysis(t)
        midasi_list.extend([mrph.midasi for mrph in result.mrph_list()])
    # listからスペース区切りの文字列に変換
    wakati = ' '.join(midasi_list)
    # BPEコード生成
    args = ['subword-nmt', 'learn-bpe', '-s', f'{symbols}']
    res = subprocess.run(args, input=wakati, stdout=subprocess.PIPE, encoding='UTF-8')
    with open(output, 'w') as f:
        f.write(res.stdout)


def tokenize_bpe(result, code_file: str=None) -> list:
    """
    Jumann++の形態素解析結果をもとに見出語listを取得する
    """
    tokens = [mrph.midasi for mrph in result.mrph_list()]
    # listからスペース区切りの文字列に変換
    wakati = ' '.join(tokens)
    # BPEによるサブワード分割
    res = subprocess.run(['subword-nmt', 'apply-bpe', '-c', code_file], input=wakati, stdout=subprocess.PIPE, encoding='UTF-8')
    # 標準出力結果からBERTのvocabに対応できるようにサブワードの記号を置換
    tokens = res.stdout.replace('@@ ', ' ##').split(' ')
    return tokens

このようにメソッド定義することで学習やサブワード分割をPythonコード内で記述できるようになります。
INPUT:

# 単語分割対象のテキスト
text = '明治3年9月24日(グレゴリオ暦1870年10月18日)に、日本で初めての近代的生産統計である府県物産表に関する太政官布告が公布されたことが由来となっており'
code_path = '/content/code.txt'
# BPEコード出力
learn_bpe(txt=text, output=code_path)
# Jumanによる形態素解析
res = Juman().analysis(text)
# サブワード分割結果出力
print(tokenize_bpe(res, code_path))

OUTPUT:

['明', '##治', '3', '年', '9', '月', '2', '##4', '日', '(', 'グ', '##レ', '##ゴ', '##リ', '##オ', '暦', '1', '##8', '##7', '##0', '年', '1', '##0', '月', '1', '##8', '日', ')', 'に', '、', '日', '##本', 'で', '初', '##め', '##て', 'の', '近', '##代', '的', '生', '##産', '統', '##計', 'で', '##あ', '##る', '府', '##県', '物', '##産', '表', 'に', '関', '##す', '##る', '太', '##政', '##官', '布', '##告', 'が', '公', '##布', 'さ', 'れ', '##た', 'こ', '##と', 'が', '由', '##来', 'と', 'な', '##っ', '##て', 'お', '##り']

DistilBERTにサブワーブ分割した単語を入力してみる

Jumanでトークン分割した場合のBERTで[UNK]と判定される数と、BPEでサブワード分割した場合の[UNK]の総数を比較します。

対象のテキストデータはlivedoorニュースの独女通信の記事を使用します。
また、今回はバンダイナムコ社が公開しているDistilBERTを使用します。

なお、実行環境はGoogle Colaboratoryとしています。

ライブラリ

以下のライブラリをインストールしておきます。

pip install subword-nmt
pip install mojimoji

学習データの読み込み

セルで以下のコマンドを実行し、テキストデータをダウンロード、解凍します。

# URLより圧縮されたテキストファイルをダウンロード
!wget https://www.rondhuit.com/download/ldcc-20140209.tar.gz
# 解凍
!tar zxvf ldcc-20140209.tar.gz

tar.zipファイルを解凍後、独女通信のテキストファイルだけを読み込みます。
INPUT:

import glob
# テキストファイルパスリストを取得
files = [file for file in glob.glob("./text/dokujo-tsushin/*")]
# テキストリスト取得
docs = list()
for file in files:
    with open(file, 'r', encoding='utf-8') as f:
        docs.append(f.read())
print(f'document size: {len(docs)}')

doc = '\n'.join(docs)
print(f'length:{len(doc)}')

OUTPUT:

document size: 871
length:1401391

Jumanのインストール

colaboratoryへのJumanのダウンロード&インストールですが、ファイル容量が大きいため自分のGoogleアカウントのDriveにマウントし、Drive上に実行ファイルをダウンロードしておくと便利です。
こちらの記事に詳しいやり方が記載されています。

DistilBERTの読み込み

まずこちらからDistileBERT事前学習済みモデルをダウンロードし、Google Driveに保存しておきます。
Google Driveにマウント後、以下によりDistilBERTモデルをメモリにロードします。

from transformers import (
    BertTokenizer, BertForMaskedLM, BertConfig,
    DistilBertTokenizer, DistilBertForMaskedLM, DistilBertConfig)
import torch

model_path = '/content/drive/My Drive/Colab Notebooks/DistilBERT-base-jp'
# Distilbert
config = DistilBertConfig.from_json_file(f'{model_path}/config.json')
model = DistilBertForMaskedLM.from_pretrained(f'{model_path}/pytorch_model.bin', config=config)
tokenizer = DistilBertTokenizer(f'{model_path}/vocab.txt', do_lower_case=False, do_basic_tokenize=False)

自前のTokenizerとJumanでの単語分割の比較

ここでは一旦、モデル内部のTokenizerとJumanでの単語分割の出力結果を確認します。
それぞれのtokenizerで単語を分割後、実際にBERTに読み込んだときにどれだけ未知語になるのかを見てみます。
INPUT:

from pyknp import Juman

# 単語分割対象のテキスト
text = '明治3年9月24日(グレゴリオ暦1870年10月18日)に、日本で初めての近代的生産統計である府県物産表に関する太政官布告が公布されたことが由来となっており'

# DistilBERTの自前のTokenizerを使用
tokens = tokenizer.tokenize(text)
token_ids = tokenizer.convert_tokens_to_ids(tokens)
print(f'自前のTokenizer:{tokenizer.convert_ids_to_tokens(token_ids)}')

# Jumanで単語分割
res = Juman().analysis(text)
tokens = [mrph.midasi for mrph in res.mrph_list()]
token_ids = tokenizer.convert_tokens_to_ids(tokens)
print(f'Jumanの単語分割:{tokenizer.convert_ids_to_tokens(token_ids)}')

OUTPUT:

自前のTokenizer:['[UNK]']
Jumanの単語分割:['明治', '3', '年', '9', '月', '24', '日', '[UNK]', 'グレゴリオ', '暦', '1870', '年', '10', '月', '18', '日', '[UNK]', 'に', '、', '日本', 'で', '初めて', 'の', '近代', '的', '生産', '統計', '[UNK]', '府県', '物産', '表', 'に', '関する', '太政官', '布告', 'が', '公布', 'さ', '[UNK]', 'こと', 'が', '由来', 'と', '[UNK]', 'おり']

DistilBERTの自前のTokenizerが全く機能していませんね。
一方、JumanのTokenizerで分割した単語はそこそこDistilBERT内の辞書にも存在しているようですが、いくつかの単語は[UNK](未知語)として認識されてしまっています。

BPEでサブワード分割した場合との比較

livedoorニュースの記事データをもとに、Jumanで単語分割した場合とBPEでサブワード分割した場合で、どれほどDistilBERTへの単語入力で未知語になるのを防げるのかを比較します。

import mojimoji

all_token_juman = list()
token_cnt_juman = 0
all_token_juman_bpe = list()
token_cnt_bpe = 0
juman = Juman()
error_cnt = 0

for doc in docs:
    # 改行を削除
    doc = doc.replace('\n', '')
    # 半角から全角に置換
    doc = mojimoji.han_to_zen(doc)
    # Jumanppでパースするとエラーになる文字列が含まれる場合はskip
    try:
        res = juman.analysis(doc)
        # Jumanによる単語分割
        tokens_juman = [mrph.midasi for mrph in res.mrph_list()]
        token_ids_juman = tokenizer.convert_tokens_to_ids(tokens_juman)
        # Jumanによる単語分割+BPEによるサブワード分割
        tokens_juman_bpe = tokenize_bpe(res, code_path)
        token_ids_juman_bpe = tokenizer.convert_tokens_to_ids(tokens_juman_bpe)
        # DistilBERTの辞書に変換
        all_token_juman.extend(tokenizer.convert_ids_to_tokens(token_ids_juman))
        all_token_juman_bpe.extend(tokenizer.convert_ids_to_tokens(token_ids_juman_bpe))
        # 単語数のカウント
        token_cnt_juman += len(tokens_juman)
        token_cnt_bpe += len(tokens_juman_bpe)
    except:
        error_cnt += 1
print(f'[UNK] counts:{round(len([i for i in tmp_juman if i == "[UNK]"])/token_cnt_juman, 3)}, error_cnt:{error_cnt}')
print(f'[UNK] counts:{round(len([i for i in tmp_juman_bpe if i == "[UNK]"])/token_cnt_bpe, 3)}, error_cnt:{error_cnt}')
[UNK] counts:0.247, error_cnt:639
[UNK] counts:0.202, error_cnt:639

全単語に占める未知語の割合は、Jumanでの単語分割の場合は25%、BPEによるサブワード分割後は20%となりました。
どうやらサブワード分割した方がDistilBERTへの入力単語が未知語となるのを防げるようです。

とはいえ、サブワード分割で使用しているsubword-nmtライブラリには他にも様々なパラメータ設定が必要であり、また、事前学習時のテキストデータをもとにBPEコードを出力しているわけでもないので、事前学習モデルとは別のサブワード分割になってしまいます。
本来的には事前学習時と同一のサブワード分割になることが望ましいのですが、まあHugging Face社のライブラリが整うまで待つことにしましょう。