エイエイレトリック

なぐりがき

NLP2024 読んだ・聴いた論文メモ

いつの間にか月が変わってましたが、2024年3月に言語処理学会第30回年次大会 に参加したので、論文紹介をします。 言語資源・評価手法関連が多いです。

サイトの上から順に選び、聴講の際も時系列に沿ってメモしていたので、それに従った順番に紹介します。 カテゴリは口頭発表に基づいてつけていますが、ポスター発表は自分の想像でつけています。

簡単な紹介しかしていないので、詳細を知りたい場合はリンク先の論文を読んでください。 間違いがないよう、なるべく論文の表現を引用しています。

P1-7 「昭和・平成書き言葉コーパス」の語彙統計情報の公開

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P1-7.pdf

言語資源・アノテーション

  • 公開データ: a1da4/shc-data
  • 「昭和・平成書き言葉コーパス」の n-gram 情報と共起情報を公開
  • データは雑誌・書籍・新聞
  • 共起情報で昭和から平成で意味が変化したかどうかの分析が可能

P1-10 日本語意味変化検出の評価セットの拡張と検出手法の評価

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P1-10.pdf

言語資源・アノテーション

  • 公開データ: tmu-nlp/JapaneseLSCDataset
  • 意味変化検出タスク向けの日本語の評価用単語リストを拡張
    • 先行研究を含めて合計20単語 (意味変化ありと意味変化なし)
  • 比較は 明治・大正、昭和・平成、平成 の3つのコーパスで行う

P2-8 計量テキスト分析のための文埋め込みによる探索的カテゴリ化

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P2-8.pdf

実社会応用

P4-25 文法誤り訂正の自動評価のための原文・参照文・訂正文間のN-gram F-score

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P4-25.pdf

文法誤り訂正

  • 自動評価尺度 GREEN の提案。計算が O(k) と高速で人手評価に近い評価を実現
  • 原文、参照文、訂正文を n-gram の多重集合として扱い、原文→参照文 と 原文→訂正文を比較する
    • 同じ操作・過剰な操作・操作不足
    • TruePositive, FalsePositive, FalseNegative が集計でき、F値を求めることができる

E6-2 意味変化の統計的法則は1000年成り立つ

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/E6-2.pdf

言語学自然言語処理

  • 聖書をコーパスとして使い、長期間の意味変化を調査する
  • ラテン語ロマンス語 (フランス語・イタリア語など) で1000年以上の期間があっても「意味変化の統計的法則」(下記) が成立する
    • 高頻度語ほど意味変化の度合いが小さい
    • 多義語ほど意味変化の度合いが大きい

E6-4 意味の集中度に基づいた意味変化検出

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/E6-4.pdf

言語学自然言語処理

  • 意味変化の検出に意味の集中度という指標を用いる
    • 意味の変化だけでなく、広がりも判定できる
  • 単語ベクトルが様々な方向を向いているほど多様な意味を持つ。常に同じ意味の場合は一点に集中する。

C7-1 音声認識を用いた青空文庫振り仮名注釈付き音声コーパスの構築の試み

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/C7-1.pdf

言語資源・アノテーション

C7-5 J-UniMorph: 日本語の形態論における意味分類の体系化

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/C7-5.pdf

言語資源・アノテーション

  • 公開データ: cl-tohoku/J-UniMorph
  • UniMorph という、「原形,語形,特徴ラベル」の3つのラベルを付与するプロジェクトがあり、その日本語版を作成する
    • 形態素解析の辞書定義と異なり、言語を横断して共通のラベルなので他の言語と対応付けられる
  • 基本的な動詞を使ってデータを構築

P9-22 英語中心の大規模言語モデルの言語横断汎化能力

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P9-22.pdf

LLM分析評価

  • 大規模言語モデルの事前学習データがほとんど英語でも、他の言語にも対応できる (言語横断汎化) のはなぜか
  • 実験1: 英語で instruction tuning したモデルを多言語で評価
    • instruction tuningした方が性能が向上する
  • 実験2: 英語とそれ以外の言語の対訳ペアの分埋め込み表現を獲得、instruction tuning前後の類似度を計算
    • 類似度の変化は小さい
    • → instruction tuning を通じて「事前学習時に既に獲得していた多言語表現に基づき,言語横断的なタスクを解く能力を学習した」と推測

P10-13 機密情報検知における生成AIを用いたデータ拡張

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P10-13.pdf

LLM分析評価

  • 個人・顧客の機密情報の漏えいを防ぐため、固有表現抽出 (NER) の考え方をベースに機密情報検知モデルを構築したい。学習 (主にfine-tuning) には高品質なデータセットが必要。
  • 生成 AI のハルシネーションを活用し、データ拡張する
    • 周辺文脈の拡張: 企業名を含む文をLLMが生成
    • エンティティの拡張: 架空の企業名をLLMが生成し、元データの企業名と置換する
  • 拡張したデータで学習することで精度が向上

D11-1 テキスト編集事例の編集操作への自動分解

https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/D11-1.pdf

人間と計算機のことばの評価

  • テキストの編集操作系列を生成する
    • source から target に変換するとき、最小のまとまりごと操作したと考える

Scientists who study the brain

→ Researchers who study the brain

→ Brain researchers

  • 編集操作を同定することでシステムの振る舞いを理解できる
  • 提案手法: ラティス生成 (中間文候補の生成とフィルタリングの繰り返し) とパスの探索

余談: 神戸の思い出

毎日ポートライナーに乗って会場まで行きました。 通勤時間帯は満員電車でヤバいと聞いていたのですが、東京都心の通勤時間帯に比べると余裕があった気がします。

ポートアイランドのような人工島がある港町で、横浜・京浜工業地帯っぽさがあるのですが、六甲山がすぐそばにあるのが景観的に大きな違いだなと思いました。 会場から遠くて行く余裕がなかったため、今度神戸周辺に行く機会があればチャレンジしたい。

ポーアイから三宮方面(1) by 神戸市 is licensed under CC BY-NC-SA 4.0

また、帰る前にメリケンパークにある 神戸港震災メモリアルパーク に立ち寄りました。 神戸もまた、震災から復興した都市であることを思い出しました。

ちょうど金ローの すずめの戸締まり で、がっつり神戸が出てきたので思い出しがてらの余談でした。

# Spacy + fastAPI に locust で負荷試験を実行する

前回の記事で、 Spacy のモデルがメモリリークすることを調べました。

fastAPI で Spacy を動かしたとき、メモリがどれぐらい増加するのか確認します。

コードは Github にあげています。設定を諸々変えたので現状プルリクのままマージしていません。

github.com

設定

fastAPI + Docker

fastAPI のコードは cookiecutter-spacy-fastapi をベースにしています。 Spacy の結果から固有表現 (Named entity) を返却するAPIです。

Spacyのモデルには日本語のなかで一番軽量な "ja_core_news_sm" を指定しています。

クラウド上にデプロイして動かすことを想定し、利用可能なメモリに制限をかけます。 今回はDocker コンテナの設定を利用します。

AWS で t3.small 相当の1G (mem_limit:1g) を設定しました。 mem_limit は version 3 で対応していないのでバージョンを下げています。

// docker-compose.yml
version: "2"
services:
  app:
    container_name: spacy_fastapi
    build: .
    volumes:
      - ./:/usr/src/
    ports:
      - "8080:8080"
    command: uvicorn main:app --reload --host 0.0.0.0 --port 8080
    mem_limit: 1g

コンテナごとのメモリの使用量は docker stats でわかるので、ログとして残しておきます。

% docker stats spacy_fastapi  --no-stream
CONTAINER ID   NAME            CPU %     MEM USAGE / LIMIT   MEM %     NET I/O     BLOCK I/O     PIDS
6be01b84084b   spacy_fastapi   0.49%     170MiB / 1GiB       16.60%    876B / 0B   8.16MB / 0B   12

ファイルの出力は こちらの記事 を参考にしました。 扱いやすいようにjson で出力します。

 while true; do docker stats --no-stream --format "{{ json . }}" |tee -a stats.txt; sleep 10; done

locust

負荷試験にはPython製の locust を使います。 固有表現一覧を返すエンドポイント /entities に対してリクエストするコードを作成しました。

locustのドキュメントにある負荷試験と異なり、API に POST で渡す文書データが必要です。

前回の記事で Spacy は Vocab をキャッシュすることがわかっており、同じデータを繰り返し渡すだけでは正しく検証できません。

大量のユニークデータで検証するために、Wikipediaの記事を使います。

今回は Wikipedia日英京都関連文書対訳コーパス を使います。 14,111ファイル と数時間のテストには十分な量です。 本来の用途とは異なりますが、コーパスの日本語部分のみ利用させてもらいました。

(Wikidump でも問題ないのですが、ストレージを圧迫しそうだったので……)

負荷試験

  • spacy_fastapi を動かす docker-compose up
  • メモリの使用量をログ出力する docker stats --no-stream --format "{{ json . }}"
  • locust を起動する poetry run locust -f locustfile.py

http://localhost:8089 で UI を起動し、実行します。

ユーザー数を5とし、1時間実行します。

locustのUI

試験の結果

1時間の結果は以下の通りです。1.71%のリクエストに失敗していることがわかります。

Type     Name         # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|-----------|-------|-------------|-------|-------|-------|-------|--------|-----------
POST     /entities      7437   127(1.71%) |    411      18   10101    120 |    2.07        0.04

エラーとしては ReadTimeout (タイムアウト) と RemoteDisconnected の2つです。

5    POST    /entities   RemoteDisconnected('Remote end closed connection without response')
122 POST    /entities   ReadTimeout(ReadTimeoutError("HTTPConnectionPool(host='localhost', port=8080): Read timed out. (read timeout=10)"))

失敗しているタイミングをチャートで確認してみると、後半ずっと失敗していることがわかります。

また、 RPS は成功している時間帯で 2 程度になりました。 Wikipediaのページをまるごと使っているので、短い文だともう少し捌けるかもしれません。

locust report

参考: Docker のエラーログ

docker logs では FastAPI のログを遡れるのですが、 500 Internal Server Error 以外の表示がないため、どの部分でのエラーかわかりませんでした。

コード部分で例外時のログを設定する必要がありそうです。

INFO:     192.168.65.1:63904 - "POST /entities HTTP/1.1" 200 OK
INFO:     192.168.65.1:63914 - "POST /entities HTTP/1.1" 200 OK
INFO:     192.168.65.1:63915 - "POST /entities HTTP/1.1" 500 Internal Server Error
INFO:     192.168.65.1:63904 - "POST /entities HTTP/1.1" 200 OK
INFO:     192.168.65.1:64213 - "POST /entities HTTP/1.1" 200 OK

メモリ使用率

docker stats から使用率だけ抽出します。

出力が 171MiB / 1GiB と分母の数値を含むので、簡単にフォーマットを調整しました。

import json

mem_usage_list = []
with open("stats.txt") as f:
    for line in f:
        data = json.loads(line.strip())
        if data["Name"] == "spacy_fastapi":
            mem_usage = data["MemUsage"].split("/")[0].strip()[:-3]
            mem_usage_list.append(mem_usage)

with open("stats_memory.txt", "w")as f:
    f.writelines("\n".join(mem_usage_list))

スプレッドシートでグラフにしたものが以下の通りです。 時間が経つごとにメモリ使用量が増えていき、 メモリ制限の 1GiB 相当の 1000MiB のまま動きません。

メモリ使用量

本番運用する場合はメモリを解放するため Spacy モデルか API自体を定期的にリロードしたいです。

リロードに関しては Gunicorn やその他の設定できそうなので、詳しく調べようと思います。

参考資料

前回 eieito.hatenablog.com

# Spacy で実行のたびにメモリ消費量が増える問題

簡単に調査したのでまとめます。

tl;dr

  • Spacy は語彙をハッシュ値に変換し管理するVocab クラスを持つ
  • 未知語があるたび Vocab のデータは増えていく
  • 現時点 (v3.7.2) で Vocab をリセットする方法はない
  • メモリを圧迫しないように、APIに載せる場合は定期的にモデルをリロードする必要がある

導入

Spacy model を動かす APIDjango で実装しクラウドにデプロイしたところ、 定期的に uWSGI の [deadlock-detector] が発生しました。

メモリが足りずプロセスが再起動しているようでした。

サイズを増やしても解消しなかったので、メモリリークを疑い調査することにしました。

Spacy の Vocab クラス

Vocab は Language クラスの要素です。 初期化の時点ではサイズは0です。

import spacy

nlp = spacy.load("ja_core_news_sm")

print(nlp.vocab, len(nlp.vocab))
>>> <spacy.vocab.Vocab object at 0xXXXX> 0

解析するとサイズが増えます。

Vocab には 語彙をkey にしたLexeme が格納されます。 このハッシュテーブルを利用し、処理を高速化しているようです。

doc = nlp("初期化の時点ではサイズは0です。")

print(len(nlp.vocab))
>>> 10

nlp.vocab["です"]
>>> <spacy.lexeme.Lexeme object at 0xXXXX>

下記ドキュメントの文言通り、 nlp.vocab は未知語が出現する限り増えます。

Note that a Vocab instance is not static. It increases in size as texts with new tokens are processed. Some models may have an empty vocab at initialization.

Memory Leak 問題

Vocab は少量のデータを分析する分には問題ないです。

しかし、Django などAPI上でモデルを動かし続けた場合、vocabは増大しメモリを圧迫します。 最悪の場合、メモリが溢れて API が落ちます。

Spacy の issue でも vocab が原因のメモリの増加に関する問題がいくつか上がっています。

今のところ vocab のテーブルを削除する機能はないです。(Vocab._reset_cacheはあるものの実装されていない)

おそらく一番上の issue の回答が今現在の最適解と思われます。

The recommended solution if the memory usage is a problem is to periodically reload the pipeline with spacy.load.

Spacy のメモリ消費量と実行時間

定期的に spacy.load する、といってもモデルを毎回ロードすると実行速度に影響が出そうに見えます。

特に API として運用したい場合、レスポンスが遅いのは困ります。

実行時間とメモリ消費量について簡単に調査しました。 Google Colab (CPU) 上で Spacy を実行します。

データはnltk_dataでダウンロードしたbrown corpusを使いました。 データ数が多いので500ファイルのうち100ファイルだけ使っています。

# ダウンロードは下記スクリプトを実行
# python -m nltk.downloader brown

from nltk.corpus import brown
len(brown.sents())
>> 57340

len(brown.fileids())
>> 500

def iter_file():
  """file ごと返却
  :return: List[str]
  """
  for fileid in brown.fileids()[:100]:
    sents = brown.sents(fileids=fileid)
    yield [" ".join(words) for words in sents]

実験コード

Spacy の実行条件とコードは以下です。 メモリの消費量の調査については tracemalloc のサンプルをそのまま使っています。

設定1: 同じモデルを使い続ける

%%time

tracemalloc.start()

snapshot1 = tracemalloc.take_snapshot()
snapshot1.dump("/content/same_model_per_file_1")
nlp = spacy.load("en_core_web_sm")
result = []
for sents in iter_file():
  for doc in nlp.pipe(sents):
    result.append(doc)

snapshot2 = tracemalloc.take_snapshot()
snapshot2.dump("/content/same_model_per_file_2")
top_stats = snapshot2.compare_to(snapshot1, 'lineno')

print("vocab {}".format(len(nlp.vocab)))
print("[ Top 10 differences ]")
for stat in top_stats[:10]:
    print(stat)

設定2: ファイルごとモデルをリロードする

%%time
tracemalloc.start()

snapshot1 = tracemalloc.take_snapshot()
snapshot1.dump("/content/reload_model_per_file_1")
result = []
for sents in iter_file():
  nlp = spacy.load("en_core_web_sm")
  for doc in nlp.pipe(sents):
    result.append(doc)

snapshot2 = tracemalloc.take_snapshot()
snapshot2.dump("/content/reload_model_per_file_2")
top_stats = snapshot2.compare_to(snapshot1, 'lineno')

print("[ Top 10 differences ]")
for stat in top_stats[:10]:
    print(stat)

結果

※ 1回だけの結果なので参考程度に

設定1:同じモデルを使い続ける

Wall time: 1min 17s

と、実行時間が「ファイルごと」より少ないです。

モデルのロードは時間がかかることがわかります。

Vocab のサイズは 22695 まで増えました。

しかし、メモリの消費量で一番多いのは thinc という spacy が内部で使っているパッケージでした。

/usr/local/lib/python3.10/dist-packages/thinc/layers/residual.py:50: size=103 MiB (+103 MiB), count=200 (+200), average=527 KiB
/usr/local/lib/python3.10/dist-packages/spacy/language.py:1113: size=73.5 MiB (+73.5 MiB), count=299717 (+299717), average=257 B
/usr/local/lib/python3.10/dist-packages/spacy/language.py:2131: size=13.6 MiB (+13.6 MiB), count=233845 (+233845), average=61 B
/usr/local/lib/python3.10/dist-packages/thinc/model.py:673: size=12.4 MiB (+12.4 MiB), count=244 (+244), average=51.8 KiB

設定2: ファイルごとモデルをリロードする

Wall time: 10min 47s

と 「設定1」の10倍の実行時間がかかりました。

やはりモデルは毎回読み込むものではないです。

メモリの消費量も、最大のものが 1355 MiB とかなり多く、メモリリークが疑われます。 「設定1」でも 13.6 MiB と消費量が多い spacy/language.py:2131 です。

調べたところ language#from_disk の呼び出し部分のようです。

データ数が100なので、 13.6 MiB * 100 ≒ 1355 MiB とメモリが解放されてないことがわかります。

/usr/local/lib/python3.10/dist-packages/spacy/language.py:2131: size=1355 MiB (+1341 MiB), count=23383114 (+23149269), average=61 B
/usr/local/lib/python3.10/dist-packages/srsly/msgpack/__init__.py:79: size=117 MiB (+109 MiB), count=1973505 (+1843350), average=62 B
/usr/local/lib/python3.10/dist-packages/spacy/language.py:2141: size=75.7 MiB (+74.3 MiB), count=534481 (+517674), average=148 B
/usr/local/lib/python3.10/dist-packages/spacy/language.py:115: size=60.8 MiB (+60.1 MiB), count=521874 (+515909), average=122 B

続く

今回は Google Colab で tracemalloc を使って調べました。

API として使う場合は勝手が変わってくるので、実運用に近い設定でメモリの消費も調べます。

ひとまずの調査としての記録でした。

# Django と DRF の TestCase を使いこなす

Django Advent Calendar 2023 の12日目の記事です。

前日は @ryu22e さんの Django 5.0 主な変更点まとめ #Python - Qiita でした。

問題意識

普段から DjangoDjango REST Framework (DRF) を使っているのですが、テストケースを書く際、いろんな TestCase クラスの選択肢があり、いつもその場のノリで選んでしまっています。

この場を借りてどのクラスを使うのがベストなのか考えます。

// 動作確認に利用したパッケージ
Django              4.1.2
djangorestframework 3.14.0

いろんな TestCase

Django もしくは DRF でクラスベースのテストを書く際の選択肢は以下です

下の2つは Python 標準ライブラリを継承したクラスであることは予想できます。 実際にどう違うのか調べます。

unittest.TestCase

Django に限らず、 Pythonユニットテストで利用できるクラスです。

self.assertHogehoge というアサートメソッドを持っています。

django.test.TestCase

unittest.TestCase を継承したDjango のテストケースクラスですが、実は直接継承しているわけではありません。

https://docs.djangoproject.com/en/5.0/topics/testing/tools/#provided-test-case-classes

つまり、 DjangoTestCaseSimpleTestCaseTransactionTestCase を継承したクラスです。

ドキュメント ではこう記述しています。

あなたの Django アプリケーションがデータベースを使用しない場合は、SimpleTestCase を使ってください。

特定のデータベーストランザクションの振る舞いをテストしたい場合は、TransactionTestCase を使ってください。

データベースを使い、かつトランザクション関連のテストが必要ない場合は django.test.TestCase を使うのがよさそうです。

unittest.TestCase or SimpleTestCase

データベースを使わないテストは SimpleTestCase とあるが、unittest.TestCase でいいのでは?と思ったので差分を確認します。

該当コード (GitHub)

  • Client クラスを扱える: 初期化時に self.client を設定
    • エンドポイントへのリクエストに利用
  • Field クラスや response のための アサーション

アサーションは特殊な関数が多いので、 Client クラスが Django のテストクラスにとって重要な存在と言えそうです。

Viewクラスに対する簡単なテストケースを書いてみました。 unittest.TestCase だと初期化関数 (setUp) で self.client の設定が必要ですが、 SimpleTestCase では不要です。

import unittest
from django.test import SimpleTestCase
from django.test.client import Client

# python 標準
class TestConvertViewUnittest(unittest.TestCase):
    def setUp(self):
        self.client = Client()

    def test_get_ok(self):
        response = self.client.get("/api/converter/", data={"text": "test"})
        self.assertEqual(response.status_code, 200)

# Django
class TestConvertViewSimple(SimpleTestCase):
    def test_get_ok(self):
        response = self.client.get("/api/converter/", data={"text": "test"})
        self.assertEqual(response.status_code, 200)
        # SimpleTestCase のアサーション
        # status と レスポンスのテキストを同時にアサート
        self.assertContains(response, text="ティーイーエスティー", status_code=200)
        # response.json() とすれば assertDict で代替可能
        self.assertJSONEqual(response.content, {"text": "ティーイーエスティー"})

rest_framework.test.APITestCase

DRF のテストクラスは Django のテストクラスと対応しています。

  • SimpleTestCase -> APISimpleTestCase
  • TransactionTestCase -> APITransactionTestCase
  • TestCase -> APITestCase

Django のテストクラスとの差は主に client に APIClient が設定されている部分です。

REST framework includes the following test case classes, that mirror the existing Django's test case classes, but use APIClient instead of Django's default Client. 該当コード (GitHub)

APIClient

APIClient は基本的には Django の Client を継承したクラスです。

django.SimpleTestCase の場合は データベースを利用しないため特に影響はありません。

class TestConvertViewDRF(APISimpleTestCase):
    def test_get_ok(self):
        response = self.client.get("/api/converter/", data={"text": "test"})
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, text="ティーイーエスティー", status_code=200)
        self.assertJSONEqual(response.content, {"text": "ティーイーエスティー"})

データベースを利用し、 User モデル による認証が必要な場合、 APIClient が役に立ちます。

force_authenticate でリクエストを強制的に実行できるからです。

以下のように authentication_classespermission_classes が設定されているViewクラスを例にテストクラスを考えます。

# view.py
from rest_framework.generics import RetrieveAPIView
from rest_framework.authentication import BasicAuthentication, SessionAuthentication
from rest_framework.permissions import IsAuthenticated


class AlphabetView(RetrieveAPIView):
    serializer_class = AlphabetRequestSerializer
    authentication_classes = [BasicAuthentication, SessionAuthentication]
    permission_classes = [IsAuthenticated]

データベースを利用するため Simple なしのクラス APITestCase を使います。

認証用のユーザーを作成し、 リクエストを実行前に force_authenticate で作成したユーザーを渡すことで認証が可能になります。

from django.contrib.auth import get_user_model
from rest_framework.test import APITestCase


class TestAlphabetViewDRF(APITestCase):
    def setUp(self):
        #  get_user_model は django.contrib.auth.models.User と同じ
        self.user = get_user_model().objects.create_user(username="test_user")

    def test_get_ok(self):
        self.client.force_authenticate(user=self.user)
        response = self.client.get("/api/alphabet/")
        self.assertEqual(response.status_code, 200)

    def test_get_ng(self):
        # 認証なし
        response = self.client.get("/api/alphabet/")
        self.assertEqual(response.status_code, 401)

まとめ

  • Django のテストケースクラスは self.client を使ってリクエストのテストができる
    • データベースを使わないときは SimpleTestCase
    • 使う場合は TestCase
  • DRF のテストケースクラスは Django のとほぼ同じ
    • 認証が必要な場合の設定は APITestCase を使うと楽 (かもしれない)

参考資料

# 日本語係り受け解析器 J.DepP を google colab で動かす

高速で実行可能な日本語の係り受け解析ライブラリがないか調べていたところ、 J.DepP という 日本語係り受け解析器を見つけたので、紹介します。

C++で実装されているのもあり、かなり高速に動作する印象です。

サイトに記載されている通りに実行してもインストールできなかったので、google colab上で動作確認しました。 コードは Github の gist としてアップロードしています。

gist はこちら: https://gist.github.com/shihono/bc2a144c64d485439a0671b33fdeb220

インストール方法

分かち書き (mecab形態素解析) なしで実行したい場合

wget https://www.tkl.iis.u-tokyo.ac.jp/~ynaga/jdepp/jdepp-2022-03-18.tar.gz
tar zxvf jdepp-2022-03-18.tar.gz
cd ./jdepp-2022-03-18
./configure -disable-autopos-train CXX=/usr/bin/clang++
make model
make install

分かち書き (mecab形態素解析) も同時に実行する設定でインストールしたい場合

mecab と juman辞書 のインストールが必要

wget https://www.tkl.iis.u-tokyo.ac.jp/~ynaga/jdepp/jdepp-2022-03-18.tar.gz
tar zxvf jdepp-2022-03-18.tar.gz
cd ./jdepp-2022-03-18
./configure --enable-standalone CXX=/usr/bin/clang++
make model
make install

詳しい説明は gist にあるので、詳細はそちらを参考にしてください。

要するに clang++ を指定してビルドすれば動きます。

C++コンパイラがデフォルトで clang であれば CXX=/usr/bin/clang++ の指定は必要ないと思われます。

Mac OS の場合もclang指定にすれば動きます。

自分のMac book (macOS Monterey) は brewgcc を設定しているのですが、google colabと同様のエラーが出ました。

留意事項

4テーマ(京都観光、携帯電話、スポーツ、グルメ)、249記事、4,186文の解析済みブログコーパス形態素、構文、格・省略・照応、評判情報がアノテーションされている。 https://nlp.ist.i.kyoto-u.ac.jp/kuntt/

  • cabocha と同様に 京都大学テキストコーパス (kyoto) も学習データとして指定できる。
    • ただし、自分でデータを用意する必要がある。毎日新聞のデータなので、個人ではちょっと難しい。
  • macの場合、MacPorts から簡単にインストールできるが、mecabの設定が J.DepP 用に上書きされる(globalに)。
    • すでにmecabをインストールしている環境にはオススメできない。

J.DepP という名前ゆえか、ググってもあまり情報が出てこないのでまとめました。

参考

# difflib の Differ, HtmlDiff を使ったテキストの差分出力

日本語テキストの差分を python で可視化する方法を考えます。

ほぼ同じだけど微妙に違うファイル同士を比較する時、目diffだと限界があるので可視化するのが良いです。

なるべく楽に、 標準ライブラリの difflib で実装することを考えます。 この記事は python3.9 のドキュメント、コードの出力に基づきます。

Differ / ndiff

人が読むことのできる差分を作成します。 Differ クラスは SequenceMatcher クラスを利用して、行からなるシーケンスを比較したり、(ほぼ)同一の行内の文字を比較したりします。

https://docs.python.org/ja/3/library/difflib.html#difflib.Differ

一般的な context diff, unified diff (後述) に比べて、目視で差分が見やすいのが Differ です。 変更前/変更後をインラインで表示するため、対応関係がわかりやすいです。

行の最初の文字で追加・削除を判定できます。 - は変更前のみある行、+ は変更後のみある行を示します。 また、 ? からはじまる行で差分部分を指し示します。

import difflib
a = ["apple", "banana", "chololate", "dounuts"]
b = ["apples", "banana", "chololete", "dounut", "egg"]

for d in difflib.Differ().compare(a, b):
    print(d)

>>
- apple
+ apples
?      +

  banana
- chololate
?       ^

+ chololete
?       ^

- dounuts
?       -

+ dounut
+ egg

上の例だと

  • - apple : 変更前の行
  • + apples : 変更後の行
  • ? + : 変更後の追加である s+ で指し示している

といった感じです。

? の行を無視すればユニファイド形式に近い出力とみなせます。 ですが、ユニファイド形式と異なり、対応する変更前・変更後のペアで出力されるため、微妙な差分も把握しやすいです。

ちなみにドキュメントでは別の場所に記載されている difflib.ndiff ですが、中身は Differ().compare(a, b) と同じです。 (自分は気が付くまで時間がかかった)

しかしながら、Differは日本語の場合、全半角の関係でうまく示せない問題があります。

実装 (cpython/Lib/difflib.py) を見ればわかる通り、半角スペースで差分の位置を調整しているのが原因です。

print(
    "\n".join(difflib.ndiff(
        ["アップル", "バナナ", "チョコト", "ドーナツ", "English", "フォトグラフィ"],
        ["アップル", "ナナ", "チョコレート", "ドーナッツ", "EngIish", "フォトダラフィ"]
    ))
)

>>
  アップル
- バナナ
? -

+ ナナ
- チョコト
+ チョコレート
?    ++

- ドーナツ
+ ドーナッツ
?    +

- English
?    ^

+ EngIish
?    ^

- フォトグラフィ
?    ^

+ フォトダラフィ
?    ^

無理やり全角に対応する

unicodedata.east_asian_width() を使い、対応する文字が全角の場合は2文字分表示することで、日本語でも多少差分がわかりやすくなります。

コードは以下にまとめました。全角にのみ対応しているので、タブ文字など特殊な文字が含まれる場合はやはりズレます。

gist.github.com

参考: https://note.nkmk.me/python-unicodedata-east-asian-width-count/

HtmlDiff

直感的にわかりやすいのはやはり色付きの diff です。

HtmlDiff はHTMLで行間・行内の差分を出力できます。

make_table で表のHTML のみ、 make_file で完全な HTML ファイルを出力します。

make_tablemake_file の違いは、make_file の方が差分の色分けの説明が記述されるか否かぐらいです。

以下は jupyter notebook での make_file の出力です。

from IPython.display import HTML
import difflib
a = ["apple", "banana", "chololate", "dounuts"]
b = ["apples", "banana", "chololete", "dounut", "egg"]

HTML(
    difflib.HtmlDiff().make_file(a, b)
)

jupyter notebook だと HTML で結果を埋め込めるので可視化としても便利です。 また、文字を直接指定するため、全半角関係なく正しく差分をみることができます。

HTML(
    difflib.HtmlDiff().make_table(
        ["アップル", "バナナ", "チョコト", "ドーナツ", "English", "フォトグラフィ"],
        ["アップル", "ナナ", "チョコレート", "ドーナッツ", "EngIish", "フォトダラフィ"]
    )
)

make_file, make_table のjupyter notebook上での表示

context diff, unified diff

diff コマンド でも指定できる出力形式。

同じ行の微妙な差分を出力するにはあまり向いていないため、詳細は割愛します。

context_diff

コンテキスト形式は、変更があった行に前後数行を加えてある、コンパクトな表現方法です。変更箇所は、変更前/変更後に分けて表します。

https://docs.python.org/ja/3/library/difflib.html#difflib.context_diff

import difflib
from pprint import pprint

a = ["apple", "banana", "chololate", "dounuts"]
b = ["apples", "banana", "chololete", "dounut", "egg"]

pprint(list(difflib.context_diff(a, b)))

>>
['*** \n',
 '--- \n',
 '***************\n',
 '*** 1,4 ****\n',
 '! apple',
 '  banana',
 '! chololate',
 '! dounuts',
 '--- 1,5 ----\n',
 '! apples',
 '  banana',
 '! chololete',
 '! dounut',
 '! egg']

unified_diff

ユニファイド形式は変更があった行にコンテキストとなる前後数行を加えた、コンパクトな表現方法です。変更箇所は (変更前/変更後を分離したブロックではなく) インラインスタイルで表されます。

https://docs.python.org/ja/3/library/difflib.html#difflib.unified_diff

pprint(list(difflib.unified_diff(a, b)))

>>
['--- \n',
 '+++ \n',
 '@@ -1,4 +1,5 @@\n',
 '-apple',
 '+apples',
 ' banana',
 '-chololate',
 '-dounuts',
 '+chololete',
 '+dounut',
 '+egg']

# Next.js で TinySegmenter と kuromoji.js を実行する

以前、 Next.js で作成したページを versel にデプロイしました。

eieito.hatenablog.com

せっかくなので、 javascript を使った NLP を色々試しています。 コードは private repository で管理しているので、ブログで実装を紹介します。

ちなみに Next.jsのチュートリアル に従ってコードは TypeScript 化したので、 紹介するコードは TypeScript で書いています。また、コードは一部抜粋しています。

TinySegmenter

手始めに form に入力したテキストに対して TinySegmenter を実行するページを作りました。

実際のページ: https://shihono-nextjs-test.vercel.app/misc/tiny-segmenter

表示部分としては、 form の onSubmit に textarea のテキストに対し、TinySegmenter を実行する関数 handlerTokenizer を定義しています。

  return (
    <Layout>
      <article>
        <section>
          <form onSubmit={handleTokenizer}>
            <label htmlFor="body">Text</label>
            <textarea
              name="body"
              id="text"
              defaultValue="ここにテキストを入力してください。"
              ref={ref}
            ></textarea>
            <button type="submit">Submit</button>
          </form>
        </section>
        <div>
          <p>{result}</p>
        </div>
      </article>
    </Layout>
  );

formを使ったページの実装自体は Data Fetching: Building Forms | Next.js のコードをそのまま流用しています。

関数 handleTokenizer では TinySegmenter.segment分かち書きを実行し、結果を setResult で表示します。

TinySegmenter に関しては、公式ページのコードは ESmodule に対応していないようだったので code4fukui/TinySegmenterlib/TinySegmenter.js として配置し、 import しています。

import { TinySegmenter } from '../../lib/TinySegmenter';

export default function TokenizeWithTinySegmenter() {
  const [result, setResult] = React.useState('ここに結果が出力されます。');
  const ref = useRef(null);

  const handleTokenizer = async (event: any) => {
    event.preventDefault();

    const data = ref.current.value;
    if (data) {
      try {
        const segs = TinySegmenter.segment(data);
        setResult(segs.join(' / '));
      } catch (error) {
        alert('Fail to tokenize text');
      }
    }
  };

実際の画面を触ってみるとわかりますが、本当に実行されているのか?と不安になるほど 高速に動きます。

TinySegmenter はフロントだけで完結でき、簡単に実装できるのがいいですね。

kuromoji.js

つづいて kuromoji.js も実行できるようにしました。 ちょうど TinySegmenter と kuromoji.js を比較する機会があったので、2つの結果を並べて表示し、可視化します。

実際のページ: https://shihono-nextjs-test.vercel.app/misc/compare-tokenizer

kuromoji.js は別の機能として切り出して実装することにしました。

具体的には pages/api/ ページに kuromoji.js の分かち書き結果を返すエンドポイント kuromojiSurface.ts を実装し、 pages 側でそのエンドポイントにリクエストする仕組みにしています。

api には非同期に対応した wrapper である kuromojin を使いました。

kuromoji.js は mecab と同様に品詞や読みも取得できますが、 今回は TinySegmenter との比較ができればよいため、分かち書き結果の表層 (surface_form) だけ返すようにしています。

// pages/api/kuromojiSurface.ts
import { tokenize } from 'kuromojin';
import { NextApiRequest, NextApiResponse } from 'next';

export type ResponseData = {
  result: string[];
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseData>
) {
  const text = req.body.text;
  tokenize(text)
    .then((tokens) => {
      const surfaceList = tokens.map((data, _) => data.surface_form);
      res.status(200).json({ result: surfaceList });
    })
    .catch((e) => {
      console.error(e);
      res.status(500).end();
    });
}

画面側では TinySegmenter のページと同様、 form の onSubmit分かち書きを実行する関数 CompareTokenizers をセットします。 apiのレスポンスを使っているため、エラーハンドリングも足しておきました。

export default function CompareTokenizers() {
  const [kResult, setKResult] = React.useState('ここにkuromojiの結果が出力されます。');
  const [tResult, setTResult] = React.useState('ここにtiny segmenterの結果が出力されます。');
  const ref = useRef(null);

  const handleTokenizer = async (event: any) => {
    event.preventDefault();

    const data = ref.current.value;
    if (data) {
      const kuromojiResult = await fetch('/api/kuromojiSurface', {
        method: 'POST',
        body: JSON.stringify({ text: data }),
        headers: { 'Content-Type': 'application/json' },
      });
      if (kuromojiResult.ok) {
        const kuromojiSegs: ResponseData = await kuromojiResult.json();
        setKResult(`${kuromojiSegs['result'].join('/')}`);
      }
      const segs = TinySegmenter.segment(data);
      setTResult(segs.join('/'));
    }
  };

kuromoji.js で辞書をロードするため、レスポンスが少し遅いです。

以下のように結果が表示されるようになりました。

出力イメージ

差分が直感的にわかるよう、表示部分は今後修正する予定です。

細々とした説明・懸念点

  • 上記 kuromojiSurface.ts のコードでは省略してますが、 externalResolver: true をセットして warning を消しています
  • ローカルで動作確認をしたくて pages/api に kuromoji.js を切り出しましたが、libs/ に実装しても問題ない気がします
    • ディレクトリごとの切り分け方針がまだよくわかってません
  • kuromojin は (textlint での利用を考えてか) 結果をキャッシュします
    • 最初は自力で kuromoji.js を非同期化しようと思ったのですが、力尽きました
    • 結果として自力で実装するよりメリットが多いのでよしとします