森羅2020-JPでNER入門
はじめに
概要
本記事では、森羅プロジェクトで開催されている日本語Wikipediaを対象とした属性抽出タスクについて、学習データの取得からモデルの構築までの流れを簡単に解説します。 具体的には、以下のことについて説明していきます。
- 森羅プロジェクトの概要
- 属性抽出のアプローチ
- データの取得・前処理
- モデルの構築
今回のコードはこちらに公開しています。
対象読者
- 森羅プロジェクトに参加してみたいけど、どういう流れで進めていいか分からない人
- BERTを使って日本語で固有表現抽出してみたい人
- pytorchでの深層学習における基本的な流れを知りたい人
前提知識
- python(できればpytorchも)の基礎知識
- ニューラルネットワークの基礎知識(なんとなく)
実行環境
著者は以下の環境で実行しました。
- python: 3.8.2
torch==1.7.1 transformers==4.3.3
少なくともtransformers>=3.0.1
ではある必要があると思います。環境構築が難しそうな方は、こちらのDockerfileで今回の環境を構築していただけます。
森羅プロジェクト
森羅プロジェクトって?
森羅プロジェクトは、RIKEN AIPが進めているリソース構築プロジェクトです。公式ページでは以下のように説明されています。
森羅プロジェクトは2017年にスタートしたリソース構築プロジェクトで、人が読むことを想定して書かれたWikipediaの知識を計算機が扱える形に構造化することを目指し、「協働によるリソース構築(Resource by Collaborative Contribution(RbCC))」という枠組みで、評価型タスクとリソース構築を同時に進めています。
要するに、「Wikipediaの記事を、パソコンがわかるように整理したい!」というプロジェクトです。
詳しく見るために、松山市のWikipediaを例にとってみていきましょう。松山市の記事には、概要として以下の内容が記述されています。
約50万9千人の人口を有する四国最大の都市であるが、日本の1地方の最大の都市では唯一の100万人以下で政令指定都市ではない都市である[* 2]。中四国においては、政令指定都市である広島市・岡山市に次ぐ3番目の人口規模を有する。都市圏人口は、総務省統計局の定義における「松山都市圏]」が70万6883人(2015年)、都市雇用圏の「松山都市圏」が64万2841人(2010年)である。
ここから人間は「松山は四国にある」「人口は約50万9千人」など、多くの情報を得ることができます。しかし、コンピュータはそうはいきません。これらの情報を情報技術で活用しようと思うと、以下のように、もっと明示的に情報を与えてやる必要があります。
属性 | 情報 |
---|---|
所在地 | 四国 |
人口 | 約50万9千人 |
Wikipediaでは、これらの情報はinfoボックスとして部分的に提供されていますが、多くの場合、記事本体に文として記述されています。そのため、テキスト(非構造化データ)から表(構造化データ)に変換する必要があり、このタスクを構造化
タスクと言います。
森羅プロジェクトは、wikipediaの記事を表形式にまとめる(構造化する)プロジェクトと言えます。
日本語構造化タスク
概要
森羅プロジェクトは前述の構造化を進めるため、シェアドタスク(kaggleのコンペのようなもの)を主催しています。このタスクでは、各カテゴリ
のwikipediaの記事から拡張固有表現で定義された属性
に対応する文字列を抽出します。拡張固有表現については説明を省きますが、上述の表における属性の集合と思っていただいて構いません。
ここで、このタスクにおけるカテゴリ
・属性
を整理しておきます。すごく大雑把にいうと、wikipediaの各記事は、カテゴリ
にあらかじめ分類されており、そのカテゴリ
に応じて抽出すべき属性
が定義されています。
例えば、City
カテゴリには所在地
や人口
などの属性が定義されています。
先程の「松山市」という記事はCity
カテゴリに分類されているため、これらの属性に対応して「四国」「約50万9千人」を抜き取ってきます。
このタスクでは、「人名」や「化合物名」、「選挙名」などさまざまなカテゴリについて学習データが提供されているため、色々なドメインのデータについてモデルを試すことができます。詳細は公式ページを参照してください。
タスクの流れ
タスクは以下のような流れで進んでいきます。
- 森羅プロジェクトへの登録
- 学習データの取得
- モデルの構築
- 結果の提出
なお、タスクとしての評価はカテゴリ
が複数個集まったグループ
単位で行われるようです。結果として、抽出した文字列だけでなく、データ中の文字の位置(オフセット)も合わせて提出する必要があります。
属性抽出のアプローチ
ここまでみてきたように、属性抽出(情報抽出)は「文字列を抽出する」ことで実現されます。(多くの場合「抽出した文字列を正規化する(normalize)」処理が入りますが、本タスクの範囲外のため割愛します。)
本節では、情報抽出の基本的なアプローチについて説明していきます。「固有表現抽出なんてもう知ってるよ!」という方はまるっとここを飛ばしてください。
固有表現抽出
「固有表現抽出」は、テキストから対象となる文字列を抽出できる技術です。ここでは特に、各トークン(文字or単語or形態素)にラベルを付与していく方法を紹介します。一般的な表記にならい、固有表現抽出はNamed Entity Recognition (NER)
、抽出する文字列を固有表現
と表現します。
トークンの分類
この方法では、文をトークン列に分解し、それぞれのトークンについて分類問題を解きます。分類により付与されるラベルによって、どの部分トークン列が固有表現かどうかを判断します。
例として、松山市はとても住みやすい
という文から松山市
という都市名を抽出することを考えます。この文をMeCabで形態素解析すると松山 市 は とても いい ところ だ 。
になります。この時、各形態素に以下のようなラベルを振っていきます。
松山 | 市 | は | とても | いい | ところ | だ | 。 |
---|---|---|---|---|---|---|---|
B-CITY | I-CITY | O | O | O | O | O | O |
ここで、B-
は固有表現の先頭、I-
は固有表現がそのトークンまで続いていること、O
は固有表現でないトークンを表します。このラベル付さえできてしまえば、B-
から連続してI-
にラベル付されたトークンを選ぶことで、固有表現を抽出することができます。
今回のラベルのフォーマットをIOB2
と呼びます。他にもIOB1
やBIOES
といったフォーマットがあり、それぞれに利点がありますが、広く用いられているのはIOB2
のようです。
分類手法
各トークンのラベルの分類手法として、最も素朴には、各トークンを独立に分類する手法が考えられます。これは、各トークンに対して特徴量を抽出し、その特徴量のみから分類する手法です。本記事でもこの手法をとります。
より具体的に、特徴量の抽出器としてRNNなどの時系列を扱う深層学習モデルを考えます。ここでは、モデルはブラックボックスとして扱いますので、気になる方は参考記事を参照してください。この手法では、図のように、あるモデルから得た各トークンの特徴量を、それぞれ重みを共有した出力層に流すことで各トークンを分類します。
発展
各トークンの分類は、トークン列全体として最適化する系列ラベリングとして定義されます。これは、他のトークンの分類結果に依存するためです。よくConditional Random Field (CRF)が用いられ、深層学習モデルとCRFを組み合わせる手法も提案されており [1]、AllenNLPなどで簡易に扱うことができます。
データの取得・前処理
学習データをダウンロード、処理していきます。
データの取得
データのダウンロードはこちらから行えます。ダウンロードするためには、アカウント登録する必要があります。ダウンロードページに、アカウント登録ページがあるので、各自登録してください。氏名やメールアドレスなどの登録だけなので、すぐに済みます。
配布データとして、「学習データ・ターゲットデータ」と「学習データ・ターゲットデータ(トークナイズ)」があります。「学習データ・ターゲットデータ(トークナイズ)」は、あらかじめ記事をトークン列に分解し、テキストオフセットと対応付けられたデータです。 データが膨大でトークナイズはかなり時間がかかる+テキストオフセットとトークンの対応付けが若干めんどくさいので、余程の理由がない限り「学習データ・ターゲットデータ(トークナイズ)」をダウンロードすることをお勧めします。今回は、東北大BERTを使用するので「MeCab (IPA辞書)+BPE」をダウンロードします。
データ形式はトークナイザのgithubに詳しく記載されています。(トークナイザ前データの詳細はこちらにある通りです。)データは、アノテーションされたデータ(学習データ)とされていないデータ(テストデータ)の2種類があります。アノテーションの情報は*_dist.json
にまとまっていますので、ここに情報のない記事はテストデータとなります。
データの注意点
今回のデータはエンティティが入れ子になることがあります。 前節の固有表現抽出の枠組みではエンティティの範囲は重複しない想定だったので、それらに対応する必要があります。
今回は各属性ごとに分類層を用意することで入れ子のエンティティに対応することにして、それに応じてデータの前処理をしていきます。 つまり、「City」や「Popularity」ごとに独立にIOBタグの分類を行います。
前処理
配布フォーマットはアノテーションデータとトークンデータが分かれているため、前節で見たように、NER用に(トークン、IOB2ラベル)のペアに変換する必要があります。
元データ
(カテゴリ名)_dist.json
{"page_id": "3507880", "title": "アンパーラ空港", "attribute": "別名", "html_offset": {"start": {"line_id": 39, "offset": 395}, "end": {"line_id": 39, "offset": 406}, "text": "SLAF Ampara"}, "text_offset": {"start": {"line_id": 39, "offset": 61}, "end": {"line_id": 39, "offset": 72}, "text": "SLAF Ampara"}, "ENE": "1.6.5.3", "token_offset": {"start": {"line_id": 39, "offset": 7}, "end": {"line_id": 39, "offset": 9}, "text": "SLAF Ampara"}}
(page_id).txt
717,0,2 11,3,4 580,4,8 20,8,9 23,9,10 240,10,12 510,12,13 32,0,1 30,1,2 3812,2,5 30,5,6 5056,6,9 245,9,10 494,10,12 832,12,14 1117,0,2 30,2,3 5207,3,6 657,6,7
作りたいデータ形式(IOB2形式)
tokens: ["松山", "市", "は", ...] label1( = City): ["B", "I", "O", ...] label2( = Event): ["O", "O", "O", ...]
これは泥臭く変換するだけですので、前処理がめんどくさい方は私が書いたコードを使ってください。ダウンロードしたデータのパスを指定すれば、属性ごとにIOB系列への変換などをしてくれます。
自分で変換コードを書く際の注意点として、配布データは文字列ではなくIDで単語を管理されています。これはデータ容量を減らすためです。IDと単語のマッピングはvocab.txt
で提供されています。(参照)
モデルの構築
いよいよモデルを構築していきます。 ここからはこのコードから説明に必要な部分を適宜抜粋する形で進めていきます。
基本的な流れは以下の通りです。
- データの読み込み
- モデルの作成
- 学習
それぞれ大雑把に見ていきます。コードの詳細には言及しませんが、補足となるリンク等をつけていますので、詳しく知りたい方はそちらをご確認ください。
データの読み込み
まず、データを読み込む必要があります。今回はpytorch
で提供されているDataset
クラスを利用します。Dataset
クラスでデータセットを読み込んでおけば、データセットの部分集合を取ったりバッチ処理をするのが楽になります。
Dataset
クラスの自作については、このリンクがわかりやすいです。
Datasetの作成
今回のデータを読み込むNerDataset
クラスを試しに実装してみます。
入力はトークンID、単語のインデックス、ラベルの辞書のリストです。
class NerDataset(Dataset): label2id = { "O": 0, "B": 1, "I": 2 } # datas = [{"tokens": , "word_idxs": , "labels": }, ...] def __init__(self, data, tokenizer): self.tokenizer = tokenizer self.data = data def __len__(self): return len(self.data) def __getitem__(self, item): input_ids = ["[CLS]"] + self.data[item]["tokens"][:510] + ["[SEP]"] input_ids = self.tokenizer.convert_tokens_to_ids(input_ids) word_idxs = [idx+1 for idx in self.data[item]["word_idxs"] if idx <= 510] labels = self.data[item]["labels"] if labels is not None: # truncate label using zip(_, word_idxs) labels = [[self.label2id[l] for l, _ in zip(label, word_idxs)] for label in labels] return input_ids, word_idxs, labels
Dataset
クラスで必要なのは、__len__
と__getitem__
です。dataset
インスタンスがあった時、__len__((self))
はlen(dataset)
、__getitem__(self, item)
はdataset[item]
の動作を定義します。
NerDataset
を使って実際にEvent/Event_Other
カテゴリのデータを読み込んでみます。
from transformers import BertJapaneseTokenizer tokenizer = BertJapaneseTokenizer.from_pretrained('cl-tohoku/bert-base-japanese') shinra_dataset = ShinraData.from_shinra2020_format("/path/to/Event/Event_Other")[0] dataset = NerDataset(shinra_dataset[0].ner_inputs, tokenizer)
dataset[0]
の結果(1文目のデータ)は以下のようになります。
word_idx
はサブワード列における各単語の開始位置です。BERTはサブワード(単語をさらに分解したもの)を入力としますが、単語の途中で固有表現が始まるとは考えにくいため、後で単語単位の系列にもどしてやるために使います。
labels
は属性ごとにIOBタグがふられています。このデータでは全てO
のようです。
DataLoaderによるバッチ処理
PyTorch
には、いくつかのデータをまとめてロードしてくれるDataLoader
クラスが提供されています。
Dataset
を入れてやることで、勝手にバッチ処理をしてくれるようになります。
from torch.utils.data import DataLoader def ner_collate_fn(batch): tokens, word_idxs, labels = list(zip(*batch)) if labels[0] is not None: labels = [[label[idx] for label in labels] for idx in range(len(labels[0]))] return {"tokens": tokens, "word_idxs": word_idxs, "labels": labels} dataloader = DataLoader(dataset, batch_size=16, collate_fn=my_collate_fn)
DataLoader
クラスをそのまま使うと、各データの次元を揃えろと怒られてしまうので、collate_fn
を指定する必要があります。画像だと元からデータの次元が揃っているので必要ありませんが、自然言語処理などのように可変長データの場合は自作して渡してあげましょう。
パディング
PyTorch
の実装上、全てのデータの次元が揃った状態でモデルに入力を渡さなければなりません。各データは可変長のため、入力次元を揃えるために0
埋めしてやる必要があります。この処理をパディングといいます。PyTorch
ではpad_sequence
が提供されています。このサイトがわかりやすいです。
import torch from torch.nn.utils.rnn import pad_sequence for inputs in dataloader: input_ids = inputs["tokens"] word_idxs = inputs["word_idxs"] labels = inputs["labels"] labels = [pad_sequence([torch.tensor(l) for l in label], padding_value=-1, batch_first=True).to(device) for label in labels] input_ids = pad_sequence([torch.tensor(t) for t in input_ids], padding_value=0, batch_first=True).to(device)
ハマりポイントとして、batch_first
があります。batch_first
はデフォルトでFalse
となっていますが、False
だと(sequence length, batch size, input size)
でtensorが返ってきます。(batch size, sequence length, input size)
にするためにはこれをTrue
にする必要があります。
ここで、labels
のpadding_value
を-1
にしています。この値は損失関数であるnn.CrossEntropyLoss
のignore_idx
と対応させた値にします。こうすることで、ignore_idx
に設定したトークンは損失計算で無視されます。
モデル
今回はモデルにBERT [2]を使います。BERTは大規模なテキストで事前学習した汎用言語モデルで、さまざまなタスクで高い精度が確認されています。巷にたくさん解説記事があるので、気になる方はそちらをご参照ください。参考記事
PyTorchでは、haggingfaceが提供するtransformersというライブラリで簡単にBERTを試すことができます。
しかも、BERTを使った簡単な分類モデルやNERモデルも、ワンラインで読み込むことができ、NERにはBertForTokenClassification
が使えます。
が、今回はBertForTokenClassification
から以下の変更を加える必要があります。
- 各属性ごとに独立した分類層を用意する
- サブワード単位ではなくワード単位で分類を行う
基本的にはBertForTokenClassification
の元コードに改変を加える形で実装していきます。要点だけかいつまんでいるので、詳細はコードを参照してください。
分類層の追加
出力層を属性の数だけ増やしてあげます。以下のコードはモデルの__init__.py
の抜粋です。List[nn.Module]
はnn.ModuleList[nn.Module]
にしなければモデルにパラメータとして認識されないことに注意してください。
# classifier that classifies token into IOB tag (B, I, O) for each attribute output_layer = [nn.Linear(768, 768) for i in range(attribute_num)] self.output_layer = nn.ModuleList(output_layer) self.relu = nn.ReLU() # classifier that classifies token into IOB tag (B, I, O) for each attribute classifiers = [nn.Linear(768, 3) for i in range(attribute_num)] self.classifiers = nn.ModuleList(classifiers)
forward
ではそれぞれの分類層にBERTの出力を流し込んでいきます。
hiddens = [self.relu(layer(sequence_output)) for layer in self.output_layer] logits = [classifier(hiddens) for classifier, hiddens in zip(self.classifiers, hiddens)] logits = [classifier(sequence_output) for classifier in self.classifiers]
単語単位へのプーリング
BERTでは単語をさらに分割してサブワードという単位で処理します。例えば「松山」という単語を「松」「##山」に分割する、という具合です。なので、当然特徴量もサブワード単位で出てきて、分類もサブワード単位になってしまいます。
とはいっても、固有表現が単語の途中で区切られるとは考えにくいので、固有表現抽出としては単語単位で処理をするのが一般的です。そのため、サブワード単位の特徴量から単語単位の特徴量に変換してやる必要があります。先頭サブワードを取ったり平均を取ったりしますが、今回は先頭サブワードを単語の特徴量とみなしたいと思います。
実装は色々考えられますが、プーリングのための行列を用意して、BERTの出力にかけてやることで単語単位の特徴量に変換することにします。具体的には、以下のような計算で任意のベクトルをとってきます。行列をうまく設計すれば、平均など色々な変換も同様の計算でできるようになります。
実装としては、torch.bmm(pooling_matrix, sequence_output)
のように積をとるだけです。pooling_matrix
を作るのは少し面倒ですがcreate_pooler_matrix
を参考にしてください。
学習
先程作ったモデルを使って学習していきます。学習の流れは以下の通りです
- 勾配の初期化(
optimizer.zero_grad()
) - lossの計算(
output = model(input_x, labels=input_y, attention_mask=mask)
) - 誤差の逆伝播(
loss.backward()
) - パラメータの更新(
optimizer.step()
)
基本的に、このサイクルをepoch
* batch
分繰り返します。
以下は学習コードから抜粋(と少し改変)したものです。
import torch import torch.optim as optim from torch.utils.data import DataLoader from torch.nn.utils.rnn import pad_sequence optimizer = optim.AdamW(model.parameters(), lr=1e-5) train_dataloader = DataLoader(train_dataset, batch_size=args.bsz, collate_fn=ner_collate_fn, shuffle=True) losses = [] for e in range(args.epoch): total_loss = 0 for step, inputs in enumerate(train_dataloader): input_ids = inputs["tokens"] word_idxs = inputs["word_idxs"] labels = inputs["labels"] labels = [pad_sequence([torch.tensor(l) for l in label], padding_value=-1, batch_first=True).to(device) for label in labels] input_ids = pad_sequence([torch.tensor(t) for t in input_ids], padding_value=0, batch_first=True).to(device) attention_mask = input_ids > 0 pooling_matrix = create_pooler_matrix(input_ids, word_idxs, pool_type="head").to(device) outputs = model( input_ids=input_ids, attention_mask=attention_mask, word_idxs=word_idxs, labels=labels, pooling_matrix=pooling_matrix) loss = outputs[0] loss.backward() total_loss += loss.item() optimizer.step() optimizer.zero_grad()
今回はBERTを使っているため、attention_mask
が入力として増えています。これは、パディングに使用されたトークン(ここでは[PAD]
)にアテンションがかからないように設定します。
実際のコードでは、過学習を防ぐためにearly stoppingを入れたりしています。
予測
学習したモデルを使って、予測していきます。
基本的にはモデルの出力でlogits
(softmaxする前の値)が出てくるので、最も大きい値を持つラベルでデコードします。ただ、系列ラベリングでの固有表現抽出の場合、次にとりうるタグが前のタグによって制限されることがあります。例えばO
の次にI
が来ることはタグの設計上あり得ません。
そのため、Viterbiアルゴリズムと呼ばれるアルゴリズムで制約を考慮しつつタグの予測を行います。
今回はO
からI
への遷移(0
から2
)の場合について、I
に対応するlogits
をとても小さい値にすることでI
が選ばれないようにします。
def viterbi(self, logits, penalty=float('inf')): num_tags = 3 # 0: O, 1: B, 2: I penalties = torch.zeros((num_tags, num_tags)) penalties[0][2] = penalty all_preds = [] for logit in logits: pred_tags = [0] for l in logit: transit_penalty = penalties[pred_tags[-1]] l = l - transit_penalty tag = torch.argmax(l, dim=-1) pred_tags.append(tag.item()) all_preds.append(pred_tags[1:]) return all_preds def predict( self, input_ids=None, attention_mask=None, word_idxs=None, pooling_matrix=None, ): logits = self.forward( input_ids=input_ids, attention_mask=attention_mask, pooling_matrix=pooling_matrix)[0] labels = [self.viterbi(logit.detach().cpu()) for logit in logits] truncated_labels = [[label[:len(word_idx)-1] for label, word_idx in zip(attr_labels, word_idxs)] for attr_labels in labels] return truncated_labels
それを踏まえながら、データをモデルに流してラベルを予測していきます。torch.no_grad()
をすると、自動勾配をオフにできるので、計算時間とメモリの節約になります。
model.eval() dataloader = DataLoader(dataset, batch_size=8, collate_fn=ner_collate_fn) with torch.no_grad(): for step, inputs in enumerate(dataloader): input_ids = inputs["tokens"] word_idxs = inputs["word_idxs"] input_ids = pad_sequence([torch.tensor(t) for t in input_ids], padding_value=0, batch_first=True).to(device) attention_mask = input_ids > 0 preds = model.predict( input_ids=input_ids, attention_mask=attention_mask, word_idxs=word_idxs, )
提出フォーマットへの変換
森羅プロジェクトでは、IOBタグではなく配布データのアノテーション形式に合わせたJSON形式で提出する必要があります。テキストのオフセットや行番号、記事番号などをつけないといけないので少し面倒です。
自分で実装するのが面倒な人はこちらを使ってください。ShinraDataset
で配布形式からIOB形式(ner_inputs
)、IOB形式から配布形式(add_nes_from_iob
)への変換ができます。予測のサンプルコードも参照するとわかりやすいかもしれません。
ただ、入出力が今回の問題設定用なので、他の設定でやる方は自分で書いたほうが早いかもしれません.
改善点
- 簡単のため、入れ子に関してはmulti-label分類で解いてしましました。他にもスパンの分類問題として解いたり[4]できます。
- 前後のラベルとの関係も考慮するためにCRFを使うことができます。AllenNLPで比較的簡単に実装できます。
- 属性は記事との関係性により定義されています。例えば「松山市」というWikipedia記事の「人口」という属性を取ってくる場合、四国全体の人口はとってきたくないわけです。そのため、うまく記事タイトルなどを考慮してタスクを解く必要があります。
- NERによるアプローチ以外にも、機械読解によるアプローチも提案されています。こちらの方が自然な問題設定かも?[5]
おわりに
今回は、森羅プロジェクトの日本語構造化タスクを使って、日本語Wikipediaから属性抽出しました。日本語構造化タスクではリーダーボードも開催されていますので、ぜひ参加して見て下さい!手軽に情報抽出モデルを試せる良いタスクだと思います。
参考文献
[1] Ma, Xuezhe, and Eduard Hovy. 2016. “End-to-End Sequence Labeling via Bi-Directional LSTM-CNNs-CRF.” In Proceedings of the ACL, 1064–74.
[2] Devlin J, Chang M-W, Lee K, Toutanova K. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. In Proceedings of the NAACL-HLT; 2019:4171-4186.
[3] Jana Strakova´ and Milan Straka and Jan Hajic. 2019. Neural Architectures for Nested NER through Linearization. In Proceedings of the ACL, 5326–5331.
[4] Congying Xia, Chenwei Zhang, Tao Yang, Yaliang Li, Nan Du, Xian Wu, Wei Fan, Fenglong Ma, and Philip Yu. 2019. Multi-grained named entity recognition. In Proceedings of ACL, pages 1430–1440.
[5] 石井 愛, 機械読解によるWikipediaからの情報抽出. In the proceedings of the 言語処理学会第25回年次大会.# BERTでwikipediaからNER