日本語の文章をいい感じに文区切りするライブラリを作った

# はじめに
昨今、自然言語処理技術の発展はめざましく、様々な分野で応用が進められています。
そんな私も自然言語処理技術やAIを活用した業務をこなすことが多いのですが、その中でとりわけめんどくさい（しかし重要な）作業は、様々な前処理に関するものです。

大抵のタスクで実施することになる主な前処理としては、以下のようなものがあります。

* クリーニング
    * HTMLタグや記号等、テキスト中のノイズを除去
* 正規化(normalization)
    * 全角・半角や大文字・小文字等の統一
* **文区切り(sentence segmentation)**
    * 文と文の区切りを検出し分割
* 単語分割(tokenization)
    * 文を単語の列に分割
* ストップワードの除去
    * 解きたいタスクに不要な単語を除去

私は主にPythonを利用しているのですが、これらの中で**日本語の文区切り**については適当なライブラリが無く、毎回似たようなコードを書く羽目になっていました。
きっと同じような悩みを抱えた人は世の中に100人くらいはいるんじゃ無いかと思ったので、いっそのこと自分でライブラリを書いてOSSとして公開しようかと一念発起したのが、たしか2019年もはじめのころです。
しかし、なかなか時間とやる気を確保できず延び延びになっていたのですが、Advent Calendarに記事を書くというリミットをもうけることでようやく着手することができました。

# 具体的な文区切りの課題

単純な文の区切り方としては、以下がもっとよく利用されると思います。

* 改行で区切る
* 記号（。！？等）で区切る

ただ、現実の文書では上記の単純なルールだけではうまく区切れないものが多くあります。

## 「」や（）内に句点や感嘆符がある
例えば、`私は「はい。そうです。」と答えた。` のようなテキストを単純に句点で区切ると、以下のように分割されます。

* 私は「はい。
* そうです。
* 」と答えた。

これでよい場面もあるかと思いますが、`私は「はい。そうです。」と答えた。`という1文として扱いたい場合もあります。

## 文の途中で改行されている
例えば、一画面に収まらない等の理由から、以下のように文の途中で改行することがあるかと思います（特に企業内の文書には頻出でしょう）。

```
自然言語処理においては、 ～省略～ の
利用が一般的である。
```

これを改行で区切ると2文に分割されてしまいますが、`自然言語処理においては、 ～省略～ の利用が一般的である。`という1文として区切りたいこともあるでしょう。

上記の例ならいったん改行を消してから句点で区切ればどうにかなるのですが、**きちんと句点がついていない文が含まれている**と面倒くささが格段に上がります。
（~~お願いだから句点くらいつけてくれよ・・・~~）

## メール等の引用ブロック
```
>> 私は明日床屋に行く予定でしたが、「急遽
>> 予定を変更します。つきましては、会議の
>> 日程を変更させてください。」とのこと。

承知しました。
```
文の途中で改行＆行頭に不要な記号があるケース。企業内の文書で最も多いケースという説あり（主観）。
最初に記号と改行を除去してから処理するアプローチが一番楽だと思われます。
ですが、不要な記号を除去しつつ、ひとかたまりの引用ブロックであるという情報は残しながら結合したいこともまれによくあります。

# 関連手法

## GiNZA
Pythonで日本語の文区切りにも使えるライブラリとしては、[GiNZA](https://megagonlabs.github.io/ginza/)があります。
GiNZAを用いた文区切りは以下のように行えます。

```Python
import spacy
nlp = spacy.load('ja_ginza')
doc = nlp('私は「あなたの思いに答えられない。他を当たってほしい。」と言われました！呆然として\nその場にたたずむしかありませんでしたそれでも私は信じたい！')
for sent in doc.sents:
  print(sent)
```
```
私は「あなたの思いに答えられない。
他を当たってほしい。
」と言われました！
呆然として その場にたたずむしかありませんでした
それでも私は信じたい！
```

GiNZAを用いた場合の長所としては、きちんと係り受け解析等をしているので文の途中で改行していたり、句点を省いていたりといった場合でも精度高く文の区切りを検出できる点が挙げられます。
重量級ですが、GiNZAの他の機能も併せて利用するならよい選択肢だと思います。

## sentence-splitter
Node.js製のツールですが、[sentence-splitter](https://github.com/azu/sentence-splitter)というものもあります。

```bash
echo -e "は「あなたの思いに答えられない。他を当たってほしい。」と言われました！呆然として\nその場にたたずむしかありませんでしたそれでも私は信じたい！" | sentence-splitter 
```
```
Sentence 0: 私は「あなたの思いに答えられない。他を当たってほしい。」と言われました！
Sentence 1: 呆然として
その場にたたずむしかありませんでしたそれでも私は信じたい！
```
このツールも、[textlint](https://github.com/textlint/textlint)内部で用いられているパーサを使って高度な解析をしているため、文の途中で改行されているケースでも精度高く分割できています。
また、「」等の扱いが私好みなことと、処理性能が結構早いことも魅力です。
（Node.jsじゃ無ければ採用してたなぁ）

## Pragmatic Segmenter
Rubyのライブラリですが、[Pragmatic Segmenter](https://github.com/diasks2/pragmatic_segmenter)というものがあります。
ルールベースによる文区切りを行うライブラリで、**多言語対応**している点が大きな長所です。また、複雑な解析をしないため処理が早い点も魅力です。

日本語の文区切りルールが私の好みに近いため、今回のツール開発においては「日本語の文章について、Pragmatic Segmenterと同等以上の文区切りができること」を目標にしています。

このツールについては[Live Demo](https://www.tm-town.com/natural-language-processing)が用意されているため、そこで試した結果を以下に示します。

```
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<wrapper>

私は「あなたの思いに答えられない。他を当たってほしい。」と言われました！


呆然として


その場にたたずむしかありませんでしたそれでも私は信じたい！

</wrapper>
```

ちなみに、このPragmatic SegmenterのPython移植版が[pySBD](https://github.com/nipunsadvilkar/pySBD)として開発されています。
残念ながら、日本語用ルールの移植はまだのようです。

# 作ったもの
ということで、今回私が作ったライブラリは↓で公開してます。
https://github.com/wwwcojp/ja_sentence_segmenter

ライブラリを作るにあたり、以下を目標として開発しました。

* 上記で挙げたケースの文区切りを処理できること
* ある程度カスタマイズできる柔軟さ
* 依存ライブラリは極力少なく
* 処理速度はそこそこ、メモリ使用量は抑えたい
* 型ヒントも付与して、静的検査可能に


# 使い方

## インストール
[PyPIに公開](https://pypi.org/project/ja-sentence-segmenter/)しているので、pipで簡単にインストールできます。
Python3.6以上に対応しており、依存ライブラリはいまのところありません。

```bash
$ pip install ja-sentence-segmenter
```

## 実行

### 「」や（）内に句点や感嘆符がある ＆ 文の途中で改行されている
```Python
import functools

from ja_sentence_segmenter.common.pipeline import make_pipeline
from ja_sentence_segmenter.concatenate.simple_concatenator import concatenate_matching
from ja_sentence_segmenter.normalize.neologd_normalizer import normalize
from ja_sentence_segmenter.split.simple_splitter import split_newline, split_punctuation

split_punc2 = functools.partial(split_punctuation, punctuations=r"。!?")
concat_tail_te = functools.partial(concatenate_matching, former_matching_rule=r"^(?P<result>.+)(て)$", remove_former_matched=False)
segmenter = make_pipeline(normalize, split_newline, concat_tail_te, split_punc2)

text1 = """
私は「あなたの思いに答えられない。他を当たってほしい。」と言われました！呆然として
その場にたたずむしかありませんでしたそれでも私は信じたい！
"""
print(list(segmenter(text1)))
```
```
['私は「あなたの思いに答えられない。他を当たってほしい。」と言われました!', '呆然としてその場にたたずむしかありませんでしたそれでも私は信じたい!']
```

### メールの引用ブロック

```Python
import functools

from ja_sentence_segmenter.common.pipeline import make_pipeline
from ja_sentence_segmenter.concatenate.simple_concatenator import concatenate_matching
from ja_sentence_segmenter.normalize.neologd_normalizer import normalize
from ja_sentence_segmenter.split.simple_splitter import split_newline, split_punctuation

split_punc2 = functools.partial(split_punctuation, punctuations=r"。!?")
concat_mail_quote = functools.partial(concatenate_matching,
  former_matching_rule=r"^(\s*[>]+\s*)(?P<result>.+)$",
  latter_matching_rule=r"^(\s*[>]+\s*)(?P<result>.+)$",
  remove_former_matched=False,
  remove_latter_matched=True)
segmenter = make_pipeline(normalize, split_newline, concat_mail_quote, split_punc2)

text2 = """
>> 私は明日床屋に行く予定でしたが、「急遽
>> 予定を変更します。つきましては、会議の
>> 日程を変更させてください。」とのこと。

承知しました。
"""

print(list(segmenter(text2)))
```
```
['>>私は明日床屋に行く予定でしたが、「急遽予定を変更します。つきましては、会議の日程を変更させてください。」とのこと。', '承知しました。']
```

# 今後の課題
そろそろ力尽きてきたので、今後の課題を述べて終わりにしようと思います。

* 各ツールとの比較検証
    * 機能・性能の比較表を作りたい
* ドキュメントの整備
    * 正直、汎用性に振りすぎて直感的に使えないライブラリだと思うので、用途に応じたレシピ集とかも用意したい
* 機能追加
    * 係り受け解析ライブラリ等を用いたより高度な文区切り
* そのほかの前処理も含めたライブラリの整備

# 感想
~~何でAdvent Calendarの記事でこんな地味すぎることやってんだろう・・・~~