はじめに
こんにちは。メディア研究開発センターの杉野です。
普段の業務でprodigyというアノテーションツールを使っているのですが、そこにspaCyモデルを組み込んで、欲しいデータを効率的に収集するための試みを紹介したいと思います。
全て説明すると長くなるので、前編後編に分け、今回は「spaCyのプロジェクトテンプレから文書分類モデル作成」までをご紹介したいと思います。
spaCyとはオープンソースの自然言語処理ライブラリ、prodigyは有償のアノテーションツールです。これらのツールはどちらもExplosion社により開発されており、双方のツールを連携させて使うことができるようになっています。
またprodigyについては、以前noteの記事でも紹介していますのでよければご覧ください。
課題
我がチームには、長期的に取り組んでいるとあるアノテーションタスクがあります。コツコツと取り組んでいるものの、対象データにおける正例の出現率が非常に小さいため、アノテーションをやってもやっても欲しいデータの取れ高はごくわずかという問題があります。
その対策としては、あらかじめ二値分類モデルを作っておいてアノテーション前のデータに推論ラベルを付与し、正例が優先的に取得できるようにデータをソートしておくという方法をとっています。
願わくば、アノテーションデータがある程度たまるたびにモデルの更新→アノテーション前のデータの再ソートを行い、もっとデータ取得の効率を上げていきたいところなのですが、他の業務もありどうしても後回しになってしまっていました。
そこで今回取り組んだのが、prodigyのtextcat.correct
というレシピの実装になります。
このレシピは、アノテーションループに分類モデルを組み込むことで、推論結果の閾値の設定により優先的にアノテーションすべきサンプルの選択をコントロールします。また取得したアノテーション結果を使って分類モデルの更新も行えるという、一挙両得なものになります。
ただし、このprodigyに組み込む機械学習モデルはspaCyモデルである必要があります。spaCyを使ったモデル作成はやったことがなかったので、まずはテンプレートを使って手っ取り早くモデル作成してみるところから取り組んだ次第です。
前提
Ubuntu 20.04.5
環境構築:pyenv + poetryで構築
使用データセット:livedoorニュースコーパス(手順説明用に、今回はこちらのデータセットを使いました)
python 3.9.15
spaCy 3.7.2
prodigy 1.14.2
全体の流れ
- spaCyのプロジェクトテンプレートを取得
- あらかじめ用意されているプロジェクトテンプレートから、文書分類のテンプレートを使ってみました。他にもさまざまなタスクのテンプレートがあります。
- リポジトリ:https://github.com/explosion/projects/tree/v3
- まずはテンプレート通りにトレーニングしてみる
- 日本語、spacy-transformers対応にカスタムしてトレーニング
- 用意されていた英語データを日本語データに差し替え、ついでにspacy-transformersを使ってみたかったので、config.cfgやproject.ymlの書き換えを行いました。
- spaCy-transformersについてはこちらの記事で解説されており、大いに参考にさせていただきました。
(前編はここまで、以下は後編で触れます)
4. 作成したモデルをprodigyのtextcat.correct
に組み込んでアノテーションに使う
セットアップ
spaCyのインストール
ここから任意の設定をしてインストールコマンドを取得します。
今回は、下記のような条件で設定しました。(CUDAのバージョンなど、適宜確認ください)
生成されたコマンドをコピーして使います。
pip install -U pip setuptools wheel
git clone https://github.com/explosion/spaCy
cd spaCy
pip install -r requirements.txt
pip install --no-build-isolation --editable '.[cuda11x,transformers,lookups,ja]'
python -m spacy download en_core_web_trf
python -m spacy download ja_core_news_trf
注意:CUDAのバージョンの確認方法はいくつかあるのですが、方法によりうまくいかない時があるようです。こちらの記事の4番目のコマンドがうまくいくようです。
参考記事:https://blog.mktia.com/get-cuda-and-cudnn-version/
#CUDAバージョン確認
/usr/local/cuda/bin/nvcc --version
spaCy-transformersのセットアップ
spaCy公式:https://spacy.io/usage/embeddings-transformers
上記spaCyのインストールと一部ダブっている気もするのですが、追加で必要なものもありそうなので、このページのドキュメントに従って追加でインストールします。
#pytorchのインストール torch1.12とCUDA11.3の場合
pip install torch==1.12.0+cu113 torchvision==0.12.0+cu113 torchaudio==0.11.0+cu113 -f https://download.pytorch.org/whl/cu113/torch_stable.html
#CUDAとトランスフォーマーの追加機能を加えたspaCyのインストール
export CUDA_PATH="/opt/nvidia/cuda"
pip install -U spacy[cuda113,transformers]
#sentencepieceのインストール
pip install transformers[sentencepiece]
その他必要となるライブラリのインストール
途中で追加インストールしたものを挙げておきます。
pip install jsonlines
pip install scikit-learn
pip install fugashi
pip install ipadic
CuPy(上記のセットアップがうまくいっていれば不要かもしれません)
CuPy公式:https://docs.cupy.dev/en/stable/install.html
pip install cupy-cuda11x
spaCyのプロジェクトテンプレートを取得
プロジェクトのタスク:GitHubのissueがドキュメントに関するものかどうかを予測する (二値の文書分類)
リポジトリの手順に従って、分類モデルの作成を進めていきます。
リポジトリ:https://github.com/explosion/projects/tree/v3
cd ..
python -m spacy project clone tutorials/textcat_docs_issues
project.ymlで定義されたアセット(データ、重み) を取得(英語のデータセット)。
cd textcat_docs_issues
python -m spacy project assets
取得したデータ形式を確認しておきます。
{'_input_hash': 1711076198,
'_task_hash': -1378074282,
'answer': 'reject',
'label': 'DOCUMENTATION',
'meta': {'source': 'GitHub',
'url': 'https://github.com/googleapis/artman/issues/245'},
'text': 'Node.js: Produce docs files'}
これはprodigyでアノテーションして得られるデータ形式で、この後のトレーニングに必要となるのはlabel
、text
、answer
キーです。
answer
キーが独特なのですが、例えばこの「GitHubのissueがドキュメントに関するものかどうか」という二値分類タスクの場合は、以下のような値でアノテーション結果を表現します。
'label': 'DOCUMENTATION',
'answer': 'accept',
'label': 'DOCUMENTATION',
'answer': 'reject',
まずはテンプレート通りにトレーニングしてみる
project.ymlで定義されたコマンドを実行(preprocess)
python -m spacy project run preprocess
注意:デフォルト設定ではCPUを使う設定になっています。GPUを使う場合は、project.ymlの10行目を任意のgpu番号に書き換えます。
gpu_id: -1 => 任意のgpu番号を指定
複数のステップからなるワークフローを順番に実行します。
project.ymlには、データ形式をSpaCy用に変換する前処理、トレーニング、評価までのワークフローが定義されており、この1行でそれらが走る形です。config.cfgとproject.ymlの設定を適切に行うことさえできればとても便利です。
python -m spacy project run all
トレーニングの結果はこんな感じになりました。
================================== Results ==================================
TOK 100.00
TEXTCAT (macro AUC) 88.39
SPEED 20015
=========================== Textcat F (per label) ===========================
P R F
DOCUMENTATION 76.58 73.33 74.92
======================== Textcat ROC AUC (per label) ========================
ROC AUC
DOCUMENTATION 0.88
学習済みモデルのロードと使用
#分類
import spacy
my_model = "./training/model-best"
#GitHub issueをランダムに収集。1例目は正例(Docsラベル付)、2例目は負例
texts = ['should update README with "supported configuration" section', 'create and maintain brew tap to install specific vagrant versions']
# Loading the best model from output_updated folder
nlp = spacy.load(my_model)
for text in texts:
demo = nlp(text)
print(demo.cats)
{'DOCUMENTATION': 0.9024757742881775}
{'DOCUMENTATION': 0.11329010874032974}
GitHubのissueをランダムに収集し分類してみました。1文目はDocsタグがついた正例でscore0.9、2文目はDocsタグがないものでscoreが0.1とうまく推論できていそうです。
日本語、spacy-transformers対応にカスタムする
livedoorニュースコーパスをダウンロード
今回は、livedoorニュースコーパスのデータを使って、タイトルから9種のカテゴリラベルを推論するモデルを作成してみます。
コーパスのダウンロードと前処理手順はこちらを参考にさせていただきました。
参考記事:https://qiita.com/sugulu_Ogawa_ISID/items/697bd03499c1de9cf082
# Livedoorニュースのファイルをダウンロード
! wget "https://www.rondhuit.com/download/ldcc-20140209.tar.gz"
# ファイルを解凍し、カテゴリー数と内容を確認
import tarfile
import os
# 解凍
tar = tarfile.open("ldcc-20140209.tar.gz", "r:gz")
tar.extractall("./data/livedoor/")
tar.close()
# フォルダのファイルとディレクトリを確認
files_folders = [name for name in os.listdir("./data/livedoor/text/")]
print(files_folders)
# カテゴリーのフォルダのみを抽出
categories = [name for name in os.listdir(
"./data/livedoor/text/") if os.path.isdir("./data/livedoor/text/"+name)]
print("カテゴリー数:", len(categories))
print(categories)
['CHANGES.txt', 'dokujo-tsushin', 'it-life-hack', 'kaden-channel', 'livedoor-homme', 'movie-enter', 'peachy', 'README.txt', 'smax', 'sports-watch', 'topic-news']
カテゴリー数: 9
['dokujo-tsushin', 'it-life-hack', 'kaden-channel', 'livedoor-homme', 'movie-enter', 'peachy', 'smax', 'sports-watch', 'topic-news']
spaCyでのトレーニング用にデータを整形
先ほどのGitHubのサンプルデータ形式を参考に、データを整形します。answer
のようなprodigy独自のキーを使わなくてもトレーニングできそうな気がしますが、今回は一旦テンプレート通りに進めていきます。
また、先に使ったテンプレートは二値分類でしたが、livedoorニュースコーパスは9種のラベルがある排他的な多値分類であり、answer
キーのaccept
とreject
ではラベル情報が表せません。
prodigyのアノテーションで作られるデータを参考にしてみると、ラベルがバイナリではなくマルチの場合は、ラベルに関するキーは以下のような形式になります。
'options': [{'id': 'LABELA', 'text': 'LABELA'},
{'id': 'LABELB', 'text': 'LABELB'},
{'id': 'LABELC', 'text': 'LABELC'},
{'id': 'LABELD', 'text': 'LABELD'},
{'id': 'LABELE', 'text': 'LABELE'}],
'answer': 'accept',
'accept': ['LABELA'],
options
は全種類のラベルを表し、answer
はすべてaccept
と記録され、アノテーションで選択されたラベルはaccept
キーに格納されます。
もっとシンプルな形式で訓練できないか試してみたところ、以下の形式でトレーニングが可能でしたので、今回はラベルまわりはこのように整形しました。
'answer': 'accept',
'label': 'LABELA'
import glob
import jsonlines
from sklearn.model_selection import train_test_split
def main():
#辞書データを作成
data = make_data()
#train,val,testに分割
train, test_valid = train_test_split(data, test_size=0.4, random_state=1) #80% 20%に分割
test, valid = train_test_split(test_valid, test_size=0.5, random_state=1) # 20%を半分に分割
#assetsに格納
train_path = "./assets/livedoor_train.jsonl"
valid_path = "./assets/livedoor_val.jsonl"
test_path = "./assets/livedoor_test.jsonl"
print(f"all_data:{len(data)}")
print(f"{train_path}:{len(train)}")
print(f"{valid_path}:{len(valid)}")
print(f"{test_path}:{len(test)}")
print("データ形式")
print(test[:3])
out_jsonl(train_path, train)
out_jsonl(valid_path, valid)
out_jsonl(test_path, test)
# 本文を取得する前処理関数を定義
def extract_main_txt(file_name):
with open(file_name) as text_file:
#タイトル行のみ取得
text = text_file.readlines()[2:3]
text = ''.join(text)
text = text.translate(str.maketrans(
{'\n': '', '\t': '', '\r': '', '\u3000': ''})) # 改行やタブ、全角スペースを消す
return text
#辞書データを作成
def make_data():
# リストに前処理した本文と、カテゴリーのラベルを追加していく
list_text = []
list_label = []
for cat in categories:
text_files = glob.glob(os.path.join("./data/livedoor/text", cat, "*.txt"))
# 前処理extract_main_txtを実施してタイトルを取得
title = [extract_main_txt(text_file) for text_file in text_files]
label = [cat] * len(title) # titleの数文だけカテゴリー名のラベルのリストを作成
list_text.extend(title)
list_label.extend(label)
#prodigyのアノテーションデータ形式にする
data = []
for label, text in zip(list_label, list_text):
data.append({'label':label, 'answer':'accept', 'text':text})
return data
#jsonlの書き出し
def out_jsonl(out_path, data):
with jsonlines.open(out_path, mode="w")as f:
f.write_all(data)
main()
all_data:7376
./assets/livedoor_train.jsonl:4425
./assets/livedoor_val.jsonl:1476
./assets/livedoor_test.jsonl:1475
データ形式見本
[
{'label': 'sports-watch', 'answer': 'accept', 'text': '【Sports Watch】中村憲剛が“澤は怪物”発言を真っ向から否定'},
{'label': 'peachy', 'answer': 'accept', 'text': '【終了しました】幸せを運んでくれるかも? 南米パラグアイの銀細工ピアスを3名様にプレゼント'},
{'label': 'it-life-hack', 'answer': 'accept', 'text': 'グラボを買うとBDプレーヤーが当たる!Sapphire製グラボ購入キャンペーン本日開始'}
]
config.cfgをカスタム
spaCy公式から任意の設定でconfigが取得できます(日本語対応もあり)。
spaCy公式:https://spacy.io/usage/training#quickstart
が、以下の記事によると取得したconfigはさらに一部書き換えた方がよさそうです。
参考記事:https://www.ogis-ri.co.jp/otc/hiroba/technical/similar-document-search/part15.html
筆者が試した時点ではWebのUIは多言語版のBERTを使う設定になってしまうので一部書き換えて以下のようにしています。
(中略)
Quickstart の生成内容との大きな違いは以下の二か所ですね。
[nlp.tokenizer]
@tokenizers = "spacy.ja.JapaneseTokenizer"
split_mode = "A"
[components.transformer.model]
@architectures = "spacy-transformers.TransformerModel.v1"
name = "cl-tohoku/bert-base-japanese-whole-word-masking"
tokenizer_config = {"use_fast": false}
一つ目は spaCy のトークナイザの設定で明示的に設定しておきました(これは不要かもしれません)。二つ目では Transformers で使用するモデルを "cl-tohoku/bert-base-japanese-whole-word-masking" に変更したうえで、 BertJapaneseTokenizer では Fast 実装がサポートされていなかった気がしたので、Fast 実装を使わない設定を追加してあります。
というわけで上記の書き換えを参考に、以下のconfigを作成しました。
%%writefile ./configs/my_project.cfg
[paths]
train = null
dev = null
[system]
gpu_allocator = "pytorch"
[nlp]
lang = "ja"
pipeline = ["transformer", "textcat"]
batch_size = 64
[nlp.tokenizer]
@tokenizers = "spacy.ja.JapaneseTokenizer"
split_mode = "A"
[components]
[components.transformer]
factory = "transformer"
[components.transformer.model]
@architectures = "spacy-transformers.TransformerModel.v1"
name = "cl-tohoku/bert-base-japanese-whole-word-masking"
tokenizer_config = {"use_fast": false}
[components.transformer.model.get_spans]
@span_getters = "spacy-transformers.strided_spans.v1"
window = 512
stride = 384
[components.textcat]
factory = "textcat"
[components.textcat.model]
@architectures = "spacy.TextCatEnsemble.v2"
nO = null
[components.textcat.model.tok2vec]
@architectures = "spacy-transformers.TransformerListener.v1"
grad_factor = 1.0
[components.textcat.model.tok2vec.pooling]
@layers = "reduce_mean.v1"
[components.textcat.model.linear_model]
@architectures = "spacy.TextCatBOW.v1"
exclusive_classes = true
ngram_size = 1
no_output_layer = false
[corpora]
[corpora.train]
@readers = "spacy.Corpus.v1"
path = ${paths.train}
max_length = 0
[corpora.dev]
@readers = "spacy.Corpus.v1"
path = ${paths.dev}
max_length = 0
[training]
accumulate_gradient = 3
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
[training.optimizer]
@optimizers = "Adam.v1"
[training.optimizer.learn_rate]
@schedules = "warmup_linear.v1"
warmup_steps = 250
total_steps = 20000
initial_rate = 5e-5
[training.batcher]
@batchers = "spacy.batch_by_padded.v1"
discard_oversize = true
size = 2000
buffer = 256
[initialize]
vectors = ${paths.vectors}
書き換えたconfigをベースに残りのデフォルト値を入力し、config.cfgを完成させます。
python -m spacy init fill-config configs/my_project.cfg configs/config.cfg
project.ymlを書き換える
特に4行目から11行目あたりを適宜実行環境に合わせて書き換えます。
vars:
config: "config.cfg"
name: "textcat_docs" => 任意の名前に変更(そのままでもOK)
version: "0.0.0"
train: "livedoor_train" => 先に用意したtrainデータのファイル名に変更
dev: "livedoor_val" => 先に用意したvalidデータのファイル名に変更
gpu_id: 0 => 使用するgpu番号(CPUは-1)
spacy_version: ">=3.0.6,<4.0.0"
複数のステップからなるワークフローを順番に実行
あとは、先ほどのテンプレートと同じ手順で以下の1行を実行するだけです。
python -m spacy project run all
結果
================================== Results ==================================
TOK 99.96
TEXTCAT (macro F) 86.87
SPEED 5314
=========================== Textcat F (per label) ===========================
P R F
peachy 71.57 82.95 76.84
sports-watch 98.26 88.95 93.37
topic-news 81.21 88.32 84.62
kaden-channel 94.97 92.39 93.66
dokujo-tsushin 87.10 86.54 86.82
livedoor-homme 85.71 67.92 75.79
movie-enter 83.25 84.13 83.68
it-life-hack 89.08 92.26 90.64
smax 97.02 95.88 96.45
======================== Textcat ROC AUC (per label) ========================
ROC AUC
peachy 0.95
sports-watch 1.00
topic-news 0.99
kaden-channel 0.99
dokujo-tsushin 0.99
livedoor-homme 0.95
movie-enter 0.98
it-life-hack 0.99
smax 1.00
学習済みモデルのロードと使用
import spacy
import pprint
from sklearn import preprocessing
my_model = "./training/model-best"
text = "女性のロングヘアーは何歳までOKなのか?" # testセットより抜粋。正解ラベルは'dokujo-tsushin'
mm = preprocessing.MinMaxScaler()
# Loading the best model from output_updated folder
nlp = spacy.load(my_model)
doc = nlp(text)
score_mm = preprocessing.minmax_scale(list(doc.cats.values()))#推論スコアをわかりやすく正規化
doc_cats = {}
for k, s in zip(doc.cats.keys(), score_mm):
score = f"{s:.3f}"
doc_cats.update({k : score})
doc2 = sorted(doc_cats.items(), key=lambda x:x[1], reverse=True)#推論スコアの高い順にソート
pprint.pprint(doc2)
[('dokujo-tsushin', '1.000'),
('peachy', '0.000'),
('sports-watch', '0.000'),
('topic-news', '0.000'),
('kaden-channel', '0.000'),
('livedoor-homme', '0.000'),
('movie-enter', '0.000'),
('it-life-hack', '0.000'),
('smax', '0.000')]
出来上がったモデルをロードして推論まで試してみましたが、無事日本語対応、spacy-transformers利用で学習ができていそうです。
以上、spaCyのプロジェクトテンプレートをカスタマイズして、livedoorニュースコーパスのタイトルからカテゴリを推論するモデルを作成しました。
後編では、この学習済みモデルを、アノテーションツールに組み込んでアノテーションを効率化する方法をご紹介できればと思います。