spaCyで文書分類モデル作成→prodigyに組み込んでアノテーションを効率化（前編）

# はじめに
こんにちは。メディア研究開発センターの杉野です。
普段の業務でprodigyというアノテーションツールを使っているのですが、そこにspaCyモデルを組み込んで、欲しいデータを効率的に収集するための試みを紹介したいと思います。

全て説明すると長くなるので、前編後編に分け、今回は「spaCyのプロジェクトテンプレから文書分類モデル作成」までをご紹介したいと思います。

spaCyとはオープンソースの自然言語処理ライブラリ、prodigyは有償のアノテーションツールです。これらのツールはどちらもExplosion社により開発されており、双方のツールを連携させて使うことができるようになっています。

https://spacy.io/

https://prodi.gy/

またprodigyについては、以前noteの記事でも紹介していますのでよければご覧ください。

https://note.com/asahi_ictrad/n/n2201dad206bd

# 課題
我がチームには、長期的に取り組んでいるとあるアノテーションタスクがあります。コツコツと取り組んでいるものの、対象データにおける正例の出現率が非常に小さいため、アノテーションをやってもやっても欲しいデータの取れ高はごくわずかという問題があります。

その対策としては、あらかじめ二値分類モデルを作っておいてアノテーション前のデータに推論ラベルを付与し、正例が優先的に取得できるようにデータをソートしておくという方法をとっています。

願わくば、アノテーションデータがある程度たまるたびにモデルの更新→アノテーション前のデータの再ソートを行い、もっとデータ取得の効率を上げていきたいところなのですが、他の業務もありどうしても後回しになってしまっていました。

そこで今回取り組んだのが、prodigyの`textcat.correct`というレシピの実装になります。

このレシピは、アノテーションループに分類モデルを組み込むことで、推論結果の閾値の設定により優先的にアノテーションすべきサンプルの選択をコントロールします。また取得したアノテーション結果を使って分類モデルの更新も行えるという、一挙両得なものになります。

https://prodi.gy/docs/recipes#textcat

ただし、このprodigyに組み込む機械学習モデルはspaCyモデルである必要があります。spaCyを使ったモデル作成はやったことがなかったので、まずはテンプレートを使って手っ取り早くモデル作成してみるところから取り組んだ次第です。

# 前提
Ubuntu 20.04.5
環境構築：pyenv + poetryで構築
使用データセット：livedoorニュースコーパス（手順説明用に、今回はこちらのデータセットを使いました）
python 3.9.15
spaCy 3.7.2
prodigy 1.14.2

# 全体の流れ
1. spaCyのプロジェクトテンプレートを取得
    - あらかじめ用意されているプロジェクトテンプレートから、文書分類のテンプレートを使ってみました。他にもさまざまなタスクのテンプレートがあります。
    - リポジトリ：https://github.com/explosion/projects/tree/v3<br>
2. まずはテンプレート通りにトレーニングしてみる
3. 日本語、spacy-transformers対応にカスタムしてトレーニング
    - 用意されていた英語データを日本語データに差し替え、ついでにspacy-transformersを使ってみたかったので、config.cfgやproject.ymlの書き換えを行いました。
    - spaCy-transformersについてはこちらの記事で解説されており、大いに参考にさせていただきました。
    
https://www.ogis-ri.co.jp/otc/hiroba/technical/similar-document-search/part15.html

（前編はここまで、以下は後編で触れます）
4. 作成したモデルをprodigyの`textcat.correct`に組み込んでアノテーションに使う

# セットアップ
### spaCyのインストール
ここから任意の設定をしてインストールコマンドを取得します。

https://spacy.io/usage

今回は、下記のような条件で設定しました。（CUDAのバージョンなど、適宜確認ください）

![スクリーンショット 2023-10-22 18.30.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2737805/4696d83f-5d8c-2d76-f7c3-58cb4eb516c7.png)


生成されたコマンドをコピーして使います。
```
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
```:CUDA11.3の場合
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
```
取得したデータ形式を確認しておきます。
```jsonl:./assets/docs_issues_eval.jsonl
{'_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番号に書き換えます。
```yml:project.yml
  gpu_id: -1 =>　任意のgpu番号を指定
```
複数のステップからなるワークフローを順番に実行します。
project.ymlには、データ形式をSpaCy用に変換する前処理、トレーニング、評価までのワークフローが定義されており、この１行でそれらが走る形です。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
```
学習済みモデルのロードと使用
```python:
#分類
import spacy

my_model = "./training/model-best"
#GitHub issueをランダムに収集。１例目は正例（Docsラベル付）、２例目は負例
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をランダムに収集し分類してみました。１文目はDocsタグがついた正例でscore0.9、２文目は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"
```
```python:
# ファイルを解凍し、カテゴリー数と内容を確認
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ニュースコーパスは９種のラベルがある排他的な多値分類であり、`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'
```

```python:
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を作成しました。
```python:
%%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行目あたりを適宜実行環境に合わせて書き換えます。
```yml:project.yml
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"
```
### 複数のステップからなるワークフローを順番に実行
あとは、先ほどのテンプレートと同じ手順で以下の１行を実行するだけです。
```
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
```

### 学習済みモデルのロードと使用
```python:
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ニュースコーパスのタイトルからカテゴリを推論するモデルを作成しました。
後編では、この学習済みモデルを、アノテーションツールに組み込んでアノテーションを効率化する方法をご紹介できればと思います。