【Python】高速で省メモリな新しい日本語全角／半角変換モジュール Habachen

## はじめに
日本語の全角／半角を相互変換する Python のライブラリやスニペットは様々なものが公開されています。 Pure Python のライブラリですと、 [jaconv](https://github.com/ikegami-yukino/jaconv/) が有名ですね。最近だと、 [Utsuho](https://github.com/juno-rmks/utsuho/) が活発に開発されているようです。異字体セレクタも考慮されている点が、これまでのライブラリには無かった特徴だと思います。

ネイティブ拡張を使った CPython 用の高速な日本語の全角／半角変換モジュールと言えば、 [mojimoji](https://github.com/studio-ousia/mojimoji) が挙げられますが、 [issue](https://github.com/studio-ousia/mojimoji/issues/21) が立てられてから1年以上アクティビティがないということもあり[^1]、文字コードの勉強も兼ねて自分で同等のものを実装してみることにしたのですが、その内にいつの間にか mojimoji よりも速いものが出来ていたので、ここに公開したいと思います。

## Habachen について
文字の幅をチェンジするから「はばちぇん」です。なお、後からひらがな・カタカナの変換機能も追加したため、ライブラリ全体の実態に合わなくなってしまっています。正直、自分でもどうなんだろうというネーミングですが、開発途中から仮称として自分の中で使っていて、他に良さそうな候補も思いつかなかったので、正式名称と相成りました。C 言語で書かれています。

### 特徴
- Pure Python 実装より速いのはもちろん、Cython と C++ のunordered map を用いた mojimoji よりも高速に動作します。
- 全角／半角に加え、ひらがな／カタカナの相互変換もサポートしています。
- 他のサードパーティーライブラリに依存しません。

### 注意点
* <INS> C コンパイラが必要（な場合がある） </INS>
<font color="Red">バージョン 0.4.0 から、Windows, macOS, Linux 用の wheel （ビルド済みパッケージ）の提供を始めました！（2023/11/2 追記）</font>
環境によっては、ビルドのために C コンパイラや CPython ヘッダファイル `Python.h` を別途導入しておく必要があります。
- <INS> ラテン1補助に含まれる文字はサポートしていない </INS>
それに伴い、全角の円マーク `'￥'` (U+FFE5) と半角の円マーク `'¥'` (U+00A5) は相互変換されないことに注意してください。

### インストール方法
```sh
pip install habachen
```

### 利用できる関数
4つの関数実装に対して、それぞれ3種類の名前が用意されています。基本的に、有名どころの日本語文字列変換ライブラリと同じ感覚で使用できるようになっています。

- `habachen.zenkaku_to_hankaku()`
- `habachen.to_hankaku()`
- `habachen.zen_to_han()`
全角を半角に変換します。シグネチャは以下の通りです：
```py
def zenkaku_to_hankaku(
        text: str, /, *,
        ascii: bool = True, digit: bool = True, kana: bool = True) -> str:
    ...
```

- `habachen.hankaku_to_zenkaku()`
- `habachen.to_zenkaku()`
- `habachen.han_to_zen()`
半角を全角に変換します。シグネチャは以下の通りです：
```py
def hankaku_to_zenkaku(
        text: str, /, *,
        ascii: bool = True, digit: bool = True, kana: bool = True) -> str:
    ...
```

- `habachen.hiragana_to_katakana()`
- `habachen.to_katakana()`
- `habachen.hira_to_kata()`
ひらがなをカタカナに変換します。第二引数 *ignore* で無視する文字を指定できます。シグネチャは以下の通りです：
```py
def hiragana_to_katakana(
        text: str, /,
        ignore: Iterable[str] = '', *, hankaku: bool = False) -> str:
    ...
```

- `habachen.katakana_to_hiragana()`
- `habachen.to_hiragana()`
- `habachen.kata_to_hira()`
全角カタカナをひらがなに変換します。第二引数 *ignore* で無視する文字を指定できます。シグネチャは以下の通りです：
```py
def katakana_to_hiragana(
        text: str, /, ignore: Iterable[str] = '') -> str:
    ...
```

※半角カタカナからひらがなへの直接変換はサポートしていません。

### 使用例
```py
>>> import habachen
>>> habachen.zen_to_han('ａｂｃ１２３ゼンカク')   # 全角→半角
'abc123ｾﾞﾝｶｸ'
>>> habachen.han_to_zen('abc123ﾊﾝｶｸﾓｼﾞ')         # 半角→全角
'ａｂｃ１２３ハンカクモジ'
>>> habachen.to_katakana('ひらがな')             # ひらがな→カタカナ
'ヒラガナ'
>>> habachen.to_hiragana('カタカナ')             # カタカナ→ひらがな
'かたかな'
```

### ドキュメント
https://hizuru3.github.io/python-habachen/

## ベンチマーク
本当に速いの？ということで簡単なベンチマークを掲載します。結果は[こちら](#結果短文-140文字)。

ここでの計測に使われているものと同じデータを用います。

https://qiita.com/yukinoi/items/42f8b5461dc1b62db7f9

実行は、 Google Colab 上で行います。テキストデータは、対象の zip ファイルを一旦ローカル環境に落として解凍したものをストレージにアップロードして利用しています。
ベンチマークの方法についてです。マジックコマンドの `%timeit` は手軽に使えるのですが、どうも現行の仕様だと不適切な時間の測り方をしているようで（[参考](https://blog.neko-ni-naritai.com/entry/2019/04/02/004538)）、今回は標準ライブラリの `timeit.repeat()` を直接呼び出して、その返り値の中から最小のものを採用することにします[^2] [^3]。

#### 使用する文章
夢野久作『ドグラ・マグラ』
http://www.aozora.gr.jp/cards/000096/files/2093_ruby_28087.zip

#### Python 本体のバージョン
Python 3.10.12

#### ライブラリのバージョン
- habachen-0.3.0
- mojimoji-0.0.12
- jaconv-0.3.4

※ mojimoji はネイティブ拡張による実装 (Cython と C++)、jaconv は Pure Python 実装。

#### 結果（短文 140文字）
単位はマイクロ秒 (µs) に統一してあります。

| | habachen | mojimoji | jaconv |
|---|---|---|---|
| 全角→半角 | **1.319 µs** | 11.92 µs | 11.22 µs |
| 半角→全角 | **1.147 µs**  | 10.15 µs | 26.49 µs |
| ひらがな→カタカナ | **0.3674 µs** |  | 11.22 µs |
| カタカナ→ひらがな | **0.3542 µs**  |  | 10.97 µs |

#### 結果（全文 468996文字）
単位はミリ秒 (ms) に統一してあります。

| | habachen | mojimoji | jaconv |
|---|---|---|---|
| 全角→半角 | **2.607 ms** | 55.07 ms | 40.36 ms |
| 半角→全角 | **1.832 ms**  | 33.89 ms | 57.16 ms |
| ひらがな→カタカナ | **0.711 ms** |  | 38.72 ms |
| カタカナ→ひらがな | **0.755 ms**  |  | 40.36 ms |


全角→半角で、 mojimoji が jaconv よりも遅い結果になっているのが気になりますが、何度か試してみても同じような傾向でした[^4]。

データの性質や実行する環境によっては大きく異なる実行結果が出ることも考えられますが、少なくとも今回行ったベンチマークにおいては、短文・全文いずれにおいても、 habachen は他の文字列変換ライブラリの数倍から数十倍の速度で処理できることが分かりました。


## なんで速いの？
高速化のために行ったことを手短に紹介します。特に目新しいことはないと思います。正直まだ最適化の余地はありそうな感じです。

- unordered map を使わない

mojimoji では unordered map を使うことで Python 実装よりも高速化していますが、 habachen では unordered map を**使わない** [^5]ことで高速化を実現しています。

Python のオブジェクトを格納する必要がない場合、 unboxing の必要がない分、STL のコンテナは Python のものよりも高速に動作しますが、それでもオーバーヘッドは存在します。また、C++ の unordered map はハッシュテーブルとして必ずしも効率的な実装ではないというのもあります。

Habachen では、変換後の文字コードがコードポイントの単純な差分の計算だけで求まる場合にはこれを行い、そうでない場合には、文字の対応に関する情報を格納した静的配列を参照することで、テーブルの間接参照によるオーバーヘッドを削減しています。

- 文字列型オブジェクトにインプレースで書き込む

ネイティブ拡張を作る際に Cython や pybind11 を活用することで、CPython の実装詳細に由来する煩わしさはかなり軽減できるんですが、一方で、 CPython API を直接叩くことにより他では難しい最適化が可能になることもあります。

Python の文字列型 (str) はイミュータブルですが、C レイヤーにおいて、他から参照されていない場合に限り安全に書き換えることが出来ます。入力と出力のサイズが同じケースでは、一時バッファを作らず、戻り値となる文字列型オブジェクトに直接変換後の値を入れていくことで、余計なメモリアロケーションとコピーを回避でき、若干の効率化が期待できます。ただし、最終的に出来上がったオブジェクトが CPython 文字列として「正しい」ことを拡張ライブラリ側で保証する必要があります。

含まれる文字集合に応じて、CPython の文字列型オブジェクトはいくつかの内部表現を持ちますが、Habachen では「日本語の文章に含まれる文字コードの多くは U+3000～U+FFFF の範囲内に収まっている」という一般的な傾向に基づき、出力となる文字列データの内部表現は UCS2 であると最初に仮定し、問題が無ければそのように扱います。いわゆるヒューリスティックですが、うまくいけば無駄なコピーを抑えつつ 1 パスで変換を完了できます。

## 最後に
半角／全角の変換は、多くの場合、 Pure Python を使ったコードでも十分高速ですし、自然言語処理だと他の部分が多くの時間を費やしていて、前処理の速度を改善しても、全体としてはほとんど時間短縮にならなかったりすることもあるかと思います。

とはいえ、同じ内容の処理であれば、速ければ速いほど良いというのもまた事実だと思いますし、気になった方は Habachen を試してもらえたらと思います。

## 外部リンク
- PyPI

https://pypi.org/project/habachen/

- GitHub

https://github.com/Hizuru3/python-habachen/


[^1]: メモリ関係のバグがあるのも気になっていたところです。 詳しくは[こちら](https://qiita.com/Hizuru/items/6ded43201b0c93a310fd)（2023/10/15 追記）。
[^2]: Python CLI の `timeit` で使われているのと同じ手法です。
[^3]: 検証に使ったスクリプトは[こちら](https://github.com/Hizuru3/python-habachen/blob/main/benchmarks/dogura.ipynb)。
[^4]: 環境によっては、普通に mojimoji の方が jaconv よりも速いようです。
[^5]: 最終的に C++ ではなく C で書いています。