⛩️

京大BERTをファインチューニングして固有表現抽出モデルをつくってみた

2021/11/15に公開

こにゃにゃちは、ken11です。
今日は京都大学 黒橋・褚・村脇研究室が公開しているBERT日本語Pretrainedモデルのファインチューニングをして固有表現抽出モデルをつくってみたのでその話です。

なにをやったのか

京都大学 黒橋・褚・村脇研究室が公開しているBERT日本語Pretrainedモデルをベースにストックマーク株式会社が公開しているner-wikipedia-datasetでファインチューニングしました。

固有表現抽出(NER)は自然言語処理のタスクでもごく一般的な部類ではないかと思います。
今回別に固有表現抽出モデルをつくる大きな理由があったわけではないんですが、ちょっと個人的につくってみたかったというのと、日本語BERTモデルのファインチューニングというとベースが東北大になりがちなので、たまには東北大ではないモデルをベースにファインチューニングしてみたかったというのが大きな理由です。

というわけで、今回はhuggingfaceで公開されている東北大のモデルではなく、京大のモデルを使ってファインチューニングしてみました。

成果物

できあがったモデルはこちら
https://huggingface.co/ken11/bert-japanese-ner

その学習コードはこちら
https://github.com/ken11/bert-japanese-ner-finetuning/blob/master/bert-japanese-ner-finetuning.ipynb

学習のポイント

Tokenizer

まず、transformersのBERTにはBertJapaneseTokenizerというクラスがあるんですが、今回はこれは利用できません。
現状このBertJapaneseTokenizerで利用できるのは東北大のモデルだけだと思います。
transformersを使って学習する例の多くが東北大モデルを利用しているのはこの辺も背景にあると思います。つまり簡単にトークナイズすることができるのが現状東北大モデルだから、と。。

対して今回使った京大のモデルは、トークナイズ処理にJuman++を使います。
なので事前にJuman++をインストールする必要があったり、さらにそれをPythonの中で使うにはpyknpをインストールする必要があったりします。
一応、今回の学習ではベースの京大モデル作成時に使用されている 2.0.0.rc2 を利用するようにしています。

このJuman++でトークナイズした後に、BertTokenizerで処理することで、ようやくinputsとして利用できる形にできます。
コードとしては以下のような感じです(詳細はノートブックをご確認ください)

from transformers import BertTokenizer
from pyknp import Juman


jumanpp = Juman()
tokenizer = BertTokenizer.from_pretrained("ダウンロードした京大モデルのファイルパス")

text = "なにか文章"
juman_result = jumanpp.analysis(text)
tokenized_text = [mrph.midasi for mrph in juman_result.mrph_list()]
inputs = tokenizer(tokenized_text, return_tensors="pt", padding='max_length', truncation=True, max_length=64, is_split_into_words=True)

Juman++で事前にトークナイズしているので、Tokenizerのオプション is_split_into_words=True を使うのがコツです。

学習データの前処理

NERタスクあるあるだと思うんですが、学習データの前処理大変じゃないですか?
conll形式だったりいろいろあると思うんですが。

学習で利用するにはだいたいにおいて

x = [['明日', 'は', '田中', 'さん', 'に', '会う']]
y = [['O', 'O', 'U-PERSON', 'O', 'O', 'O']]

みたいなデータにしたくなると思います。
conll形式はまだそれがやりやすい気がするんですが、spanのものだと変換にも一苦労だと思っていて、今回は昔つくったライブラリを利用しています。
https://github.com/ken11/noyaki

大したことはしていません。
READMEにあるとおりです。
今回学習データとして利用させていただいたner-wikipedia-datasetのjsonも、このライブラリを使ってBILUOのラベルにしています。

def load_from_json(path: str) -> list:
    jumanpp = Juman()
    json_dict = json.load(open(path, "r"))
    features = []
    for unit in json_dict:
        result = jumanpp.analysis(unit["text"])
        tokenized_text = [mrph.midasi for mrph in result.mrph_list()]
        spans = []
        for entity in unit["entities"]:
            span_list = []
            span_list.extend(entity["span"])
            span_list.append(entity["type"])
            spans.append(span_list)
        label = noyaki.convert(tokenized_text, spans)
        features.append({"x": tokenized_text, "y": label})
    return features

modelのconfigにラベル情報をもたせる

これもNERタスクあるあるだと思うんですが、推論結果のラベルidをどうやってstrな情報(B-PERSONみたいなラベルテキスト)に変換するのか悩みませんか?
テキストのlistにしておいてそのindexをidとして使うとか、あるいはidとテキストのdictをつくっておくとか、それをjson.dumpしておくとかpickleするとか…
ラベル用のクラスつくっておいてencode/decodeメソッドつくっておくのもあるかと思います。

あんまり事例を見かけないんですが、transformersのconfigにはこの情報を持たせておく仕組みがあります。
それがid2labellabel2idです。
デフォルトだとLABEL1みたいな名前で入ってるはず。

せっかくなので今回はこれを活用することにしました。
(というかhuggingfaceで公開されているNERのモデルはだいたいそうなってる?)

def create_label_vocab(features: list):
    labels = [f["y"] for f in features]
    unique_labels = list(set(sum(labels, [])))
    label2id = {}
    for i, label in enumerate(unique_labels):
        label2id[label] = i
    id2label = {v: k for k, v in label2id.items()}
    return label2id, id2label

label2id, id2label = create_label_vocab(features)
config = BertConfig.from_pretrained("ダウンロードした京大モデルのファイルパス", label2id=label2id, id2label=id2label)
model = BertForTokenClassification.from_pretrained("ダウンロードした京大モデルのファイルパス", config=config)

こんな感じで、学習データのラベルをユニークにしてからid2labellabel2idのdictをつくって、configに渡してあげるだけです。

ネットで見かける事例ではわりかし

config = BertConfig.from_pretrained("ダウンロードした京大モデルのファイルパス", num_labels=1234)
model = BertForTokenClassification.from_pretrained("ダウンロードした京大モデルのファイルパス", config=config)

みたいにnum_labelsだけ指定するものが多いような気がします。
(気のせいだったらすみません)

configが持ってくれるとなにが楽かって、推論のときに「ラベルidのデコードどうするんだっけ」とならずに済むことです。
つまり

model.config.id2label[label_id]

みたいな感じで、model自身でデコードできちゃうので非常に管理が楽になります。

(2021/11/05 16:30 追記)サブワードどうする?

Juman++でトークナイズしただけではサブワードになっていません。
たとえば学習データに田中太郎という文字列があったとき、Juman++でトークナイズした結果が["田中", "太郎"]なんだけど、使用しているTokenizer的には["田中", "太", "##郎"]みたいな場合、今のコードでは"太郎"の方が[UNK]扱いになってしまいます。
(これはあくまでたとえばの話なのでホントにそうなるかは別として)
そんなのお前ちゃんとJuman++のあとにサブワードにしろよ当たり前だろって思うじゃないですか。

juman_result = jumanpp.analysis("田中太郎")
word_list = [mrph.midasi for mrph in result.mrph_list()]
# ここでword_listは["田中", "太郎"]
tokenized_text = []
for word in word_list:
    tokenized_text.extend(tokenizer.tokenize(word))
# tokenized_textは["田中", "太", "##郎"]

こんな感じで処理してやればちゃんとサブワードにすることもできます。
なので、それも試してみたんですけどね…なんでか精度がめちゃくちゃ下がるんですね…
気を遣ってサブワードをちゃんと処理するとかえって精度がさがるっていう…
まあ理由はいくらか考えられるんですが、サブワード化されることで固有名詞の多くが細切れになってしまいがちだからかなあと予想しています。
つまり人名なんかは一文字ごとのバラバラになってしまったりもするので、かえって複雑化しちゃってるんじゃないかなあと。。

いずれにせよ、正しいのはちゃんとサブワードにしてなるべくTokenizerのボキャブラリーに存在する単語にしていくことなんですが、今回はしませんでした。

感想

と、まあこんな感じで固有表現抽出のモデルをつくってみたのでした。
一応このモデルで推論するとこんな感じです(Wikipediaの織田信長の一文を読ませてみた)

パッと見いい感じですけどね、まだまだ精度が粗いですから、参考程度にご活用ください。

今回京大のBERTをベースに使いましたが、スムーズに学習できてよかったです。
この辺の取り回しのよさはさすがhuggingface transformersですね…便利な時代だ。

Juman++とか普通に戸惑ったので、普段いかに慣れたものしか使ってないかもよくわかりました。。BertJapaneseTokenizer便利や。。

前回は日→英翻訳モデルで今回は固有表現抽出モデル、次はなにやりましょうかね〜
年始につくって精度が納得いってないGPT-2をまたやろうかなあ

Discussion