エイエイレトリック

なぐりがき

# 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 を非同期化しようと思ったのですが、力尽きました
    • 結果として自力で実装するよりメリットが多いのでよしとします

# Next.js のチュートリアルがよかった

Next.js のチュートリアルページ (https://nextjs.org/learn/foundations/about-nextjs) で Next.js を一通り勉強しました。

そもそも JavaScript でデータ分析・解析の結果をインタラクティブに表示できるようにしたいと思って JS について半年ぐらい前から少しずつ勉強してました。

実際に開発するには TypeScript の知識も必要と思い サバイバルTypeScriptTypeScript Deep Dive にざっと目を通していたところ、React や Next.js の存在を把握し、まずは公式のチュートリアルからはじめてみた次第です。

React公式のチュートリアルページ (https://react.dev/learn) も目を通したのですが、component や JSX が便利というのはわかったものの、実際どう使うかイメージが湧かなくて、ある程度 JS での開発経験がある人向けという印象を受けました。

そんな状況で始めた Next.js のチュートリアルは工夫が凝らされておりとてもよかったです。

Next.js はReactのフレームワークなので、HTML・CSSJavaScriptだけでなく、React の知識も必要なため、必要な前提知識がかなり多いです。

それもあってか、Next.js のチュートリアルJavaScript → React → Next.js の共通点・違いについてかなり丁寧に書かれています。

まず前提知識が何かを提示した上で、新しい概念を提供するスタイルのため、概念同士のつながりを把握しやすいです。

ハンズオン形式でコードを書き換えていくのと同時並行で説明があると差分がよくわかっていいですね。

React の説明 (https://nextjs.org/learn/foundations/from-javascript-to-react) に関しては React公式のチュートリアルよりも分かりやすくて、こっちを先にやっていればよかったと思いました。

また、セクションごとに Quick Review というクイズが時々挟まれていて、右上のスコアに反映されるのがモチベーションの維持によいなと思いました。

ちなみにこのスコアは読了して次のセクションに遷移しただけでも上がるようになっていることに途中で気がつきました。

間違えても褒めてくれる。褒めて伸ばすスタイル。

そんなこんなで進めて、完成品を Versel にデプロイするところまで終わりました。

https://shihono-nextjs-test.vercel.app/

公開できると、やっぱり達成感がありますね。

無料で使えるホスティングサービスは初心者にはありがたい存在。

一部読み飛ばしたのでスコアは 720 points でした。 全部終えると 1000 points になりそう?

最終スコア

通してかかった時間としては5〜6時間ぐらいだと思います。

開発に必要な知識は一通り把握できたので、これから色々試したいです。

備忘録がわりのブログでした

ここ数ヶ月の間、ブログをまったく更新していないので、やったことを備忘録がわりに投稿してみました。

定期的にアウトプットしたいけど、ついつい長文のブログになりがちなので、短めでも投稿することに慣れていこうと思います。