はじめてのPythonモジュール:AI的言語処理その3

日本語形態素分析システムJUMANのPythonモジュールを作りました。

そのモジュールの簡単な紹介と、作成の上で得た教訓(lessons learned)を記します。

はじめに

前回日本語形態素分析システムJUMANのWindows用UTF-8対応版をビルドしました。

JUMANのソースでは、ライブラリ層(ディレクトリlib)とアプリケーション層(ディレクトリjuman)が切り分けられています。スパゲッティではありませんが、共有バッファでそこはかとなくは結合しています。

「Strings」というバッファもライブラリ層のバッファの一つです。アプリケーション層では、標準入力をStringsに直接取得することで、無駄なバッファコピーをなくしています。

エンコーディングを変更できる改造を加える際に、ライブラリ(juman_lib.c)に入力用バッファを取得するAPIを加えました。

入力のエンコーディングがUTF-8でないときは新たなバッファを取得して、このAPIでStringsの代わりに返してあげています。(エンコーディング変換したUTF-8文字列をStringsに書くため。)

そんなこんなで、ふと思いました。

 

「Pythonでこの入力バッファ(Strings)に直接書き込んであげれば速いのではないか?解析結果も内部の構造体のままPythonが受け取れば速いのではないか?品詞とかコードのままでかまわないし。」

 

それで、JUMANにさらに拡張APIを追加、ライブラリ層を共有ライブラリにビルド、そしてそれをハンドルするPythonモジュールを作ることにしました。

尚、以下は全てPython3の上での話です。

jumany

結果、Pythonモジュールはできました。jumanyという名前にしました。

インストールは「pip install jumany」、詳しい説明はここからです。

(或はここからダウンロードして「pip install jumany-.tar.gz」)

使用法は以下です。

import jumany
jumany.open_lib()
print( jumany.analyze("吾輩は猫である。"))

実際には戻り値を見てのエラー処理も必要でしょう。__main__.pyも作りましたので、そちらの後半もご参照下さい。

__main__.pyは、juman.exeのような対話型のスクリプトです。

コマンドプロンプトにて、MinicondaのPython3.3環境をActivateし、このスクリプトを実行した結果を示します。

(Pythonがエンコード変換してくれるので、exeにエンコード変換は必要ありませんでしたね。)

教訓(lessons learnd)

前回のように学んだことを記しておきたいと思います。

共有ライブラリのビルド

コンパイル、リンクはlibtoolを使っているので、通常ならば共有ライブラリ(.so)も作ってくれるのですが、MinGWでは作ってくれません。

「シンボルが足りねえ」的なWarningが出てしまいます。その足りないものをスタティックリンクするために、前回示したように、configureオプションのLIBSにライブラリを指定しているのですが…。

仕方がないので、lib/Makefile.inにlibtoolを介さないでlibjuman.soを作るルールを追加しました。MinGW以外では呼ぶ必要のないルールです。

こんな感じ。

libjuman.so: $(libjuman_OBJECTS)
	$(CC) -shared -Wl,--enable-auto-image-base $(LDFLAGS) \
		-Wl,--enable-auto-image-base -Xlinker --out-implib -Xlinker $@ \
		-o $@ $(libjuman_OBJECTS) $(LIBS)
		cp $@ $(DESTDIR)$(libdir)

$(CC)には「gcc」、$(LDFLAGS)$(LIBS)には、configureで指定したそれぞれ「-Wl,-Bstatic」「-lregex -ltre -lintl -liconv」が展開されます。$(libjuman_OBJECTS)はオブジェクト.oファイルの羅列です。

直接書いているオプションは、libtoolがWarningを出さなければ出力するであろうコマンドのオプションです。詳細は正確には理解していません。-sharedさえ付いていれば何とかなるように思います。

そしてもうinstall関係なく、--prefixに指定したディレクトリのlibの中にコピーしてしまいます。

参考:JM Project:LD, LIBTOOL

Pythonからの共有ライブラリの呼び出し

Pythonからlibjuman.soの呼び出しには、シンプルにctypesを使いました。

参考:Python 3.6.1 ドキュメント:16.16. ctypes

主たる部分を書き下すと、こんな感じです。実際に行っていることは、juman_ext.pyをご参照下さい。

from ctypes import cdll, Structure, POINTER, byref, cast
from ctypes import c_int, c_char_p, c_size_t, c_byte

LIBS=cdll.LoadLibrary(os.path.dirname(__file__), "libjuman.so")

# EXT_RES_CODE ext_init(const char *s_rcfile, size_t word_buf_size, int max_mrphs);
LIBC.ext_init.restype = c_int
LIBC.ext_init.argtypes = (c_char_p, c_size_t, c_int)

res = LIBC.ext_init(rc_path.encode('UTF-8'), 0, 0)

1, 2行でctypesの中の必要なシンボルをimportしています。「from ctypes import *」としてもよいのですが、pylintさんが激おこですし、自分以外の人が読みにくいので列記したほうがよいですね。全ての名前に「ctypes.」をつける手間を惜しまないのでしたら、もちろん「import ctypes」でもOKです。

4行にてlibjuman.soを読み込み、LIBSにインスタンスを設定しています。(cdll.LoadLibrary()の代わりにCDLL()でもいけます。)

これによって、6行にコメントしたようなライブラリ内の関数を、10行のように呼ぶことが出来ます。

7,8行はそれぞれ、restypeargtypesというプロパティに対応するクラスを設定することで、関数の戻り値の型と引数の型をライブラリのインスタンスに教えてあげています。これにより不適切なやり取りはエラーになります。ちなみに戻り値がないvoidの場合はrestypeにNoneを設定します。

呼出しは10行です。intのような簡単な型は、9行の第2、第3引数と戻り値のように、感覚的な書き方で問題ありません。

 

関数ext_initの第一引数は、C言語では文字列を示す「const char *」です。Pythonでは7行のようにクラスc_char_pに割り当てています。

Pythonのstr型からは、c_char_pに直接変換してはくれません。内部でbytes型を格納しているようですので、9行のように「.encode('UTF-8')」としてstr型をbytes型に変換することで渡すことができます。

入力バッファの取得と書き込み

共有ライブラリから入力バッファを取得するコードは以下の様です。

# char * ext_get_input_buff(size_t *psize);
LIBC.ext_get_input_buff.restype = POINTER(c_byte)
LIBC.ext_get_input_buff.argtypes = (POINTER(c_size_t),)

size = c_size_t()
_WRITE_BUF = LIBC.ext_get_input_buff(byref(size))
_BUF_SIZE = size.value
_WRITE_BUF = cast(_WRITE_BUF, POINTER(c_byte * _BUF_SIZE))

C言語のインタフェイスは1行のような形です。

バッファのポインタは戻り値として返し、そのサイズは引数で渡されたポインタに書いて戻します。

 

まずは引数のポインタ渡しについて書きます。

Pythonでは最初に3行のように、argtypesに「POINTER(c_size_t)」というクラスを設定して、引数はsize_tのポインタだと教えてあげます。

5行でサイズを受け取るsizeにc_size_tのインスタンスを設定し、それを6行のように「byref()」で括ってあげて指定します。これで概念上のポインタ渡しになり、値を受け取ることができます。

その値は6行のように「value」というプロパティでint型として参照できました。

次に戻り値ですが、これを「c_char_p」或いは「POINTER(c_char)」と教えてあげてもうまくいきません。これらは文字列を表すようです。

バッファの場合は2行のように「POINTER(c_byte)」とします。POINTER(c_ubyte)c_void_pでも可能です。

これを6行で一旦_WRITE_BUFに受けとります。7行でサイズがわかったら、8行のように、要素数がサイズ分の、クラスc_byteの配列のポインタとしてキャストします。これは後にリストのコピーを使うためです。

 

バッファに文字列を書き込むのには、以下のようにしています。

data = text.encode(“UTF-8”)
data_len = len(data)
_WRITE_BUF.contents[:data_len] = data[:]
_WRITE_BUF.contents[data_len] = 0

1行のtextがstr型の入力文字列です。これをencodeでbytes型に変換して、3 行のように、サイズ分のリストになっている「contents」というプロパティに転記します。最後にヌルターミネート。

(実際は2行と3行の間に、data_lenが_WRITE_BUFのサイズ以上の場合の処理が入ります。)

出力バッファの取得と読み取り

解析結果も共有ライブラリのバッファから取得しています。そのバッファのフォーマットは一つの形態素を表す構造体の配列であり、構造体はC言語では以下のように宣言されています。

typedef struct ext_mrph_t {
	const char * midasi1;
	const char * yomi;
	const char * midasi2;
	int hinsi;
	int bunrui;
	int katuyou1;
	int katuyou2;
} EXT_MRPH_T;

Pythonでは、これをctypes. Structureの派生クラスとして表します。

class MrphT(Structure):
    """ Type of morphese """
    _fields_ = [
        ("midasi1", c_char_p),
        ("yomi", c_char_p),
        ("midasi2", c_char_p),
        ("hinsi", c_int),
        ("bunrui", c_int),
        ("katuyou1", c_int),
        ("katuyou2", c_int)
    ]

関数のインタフェイスの指示と出力バッファの取得は以下の様です。

# EXT_MRPH_T * ext_get_mrph_buff();
LIBC.ext_get_mrph_buff.restype = POINTER(MrphT)
LIBC.ext_get_mrph_buff.argtypes = ()

_READ_BUF = LIBC.ext_get_mrph_buff()

1行がC言語でのインタフェイスです。戻り値は構造体のポインタで、解析後にこのアドレス以降に複数の形態素が格納されます。(別関数で形態素の数を取得する仕様なので、ここでバッファのサイズは知る必要はありません。)

2行のように、戻り値の型は「POINTER(MrphT)」と教えてあげます。

バッファも5行のように自然に受け取ります。

 

このバッファを一要素ずつ読み出し、タプルにしてリストmrphsに格納する部分は以下のようになっています。ここに入る前に、違う関数でバッファ内の要素数をc_intのインスタンスであるnumに受け取っています。

mrphs = []
for i in range(0, num.value):
    mrph = _READ_BUF[i]
    mrphs.append((
        mrph.midasi1.decode("UTF-8"), mrph.yomi.decode("UTF-8"),
        mrph.midasi2.decode("UTF-8"), mrph.hinsi,
        mrph.bunrui, mrph.katuyou1, mrph.katuyou2
    ))

ループ内、3行で(sのつかない)mrphにバッファ内の一要素であるMrphTのインスタンスを設定します。

後はこれをタプル内に展開するだけです。(5-7行)

c_char_pのプロパティは「.decode("UTF-8")」でstr型に変換し、c_intのプロパティはそのままでint型です。

 

以上、ctypesについてでした。

ctypesを使う際には、戻り値のどこに何が入っているか、デバッガで確認することになると思います。(インタプリタでも可能とは思いますが。)

__init__.pyでのimport

モジュール内の処理はjuman_ext.pyに書いてあるのですが、最初に読み込まれるのは__init__.pyなので、この中で公開するものだけimportしています。

__init__.pyとjuman_ext.pyは同じディレクトリにあるとして、__init__.py からimportできる、できないは以下のようです。

  • ×:from juman_ext import get_error_msg
  • ○:from .juman_ext import get_error_msg

ピリオドにご注目ください。__init__.pyを直接読み込んだときの結果とは異なるのでややこしいです。

setup.pyの作成

Pythonモジュールが出来たら、配布とインストールを考えます。

setup.pyを作るツールはたくさんあるようですが、最初から入っているdistutilsを使うことにします。(distutilsを取り込んだらしい、setuptoolsの方が良いかもしれません。というかsetuptoolsに変えます。)

setup.pyの主要な部分はこんな感じです。

from distutils.core import setup
setup (
    name='jumany',
    version='0.2',
    description='Interface for JUMAN Morphological analysis system',
    url='https://github.com/yujakudo/jumany',
    author='yujakudo',
    packages=['jumany'],
    package_dir={'jumany': 'python_module'},
    package_data={'jumany': [
        'COPYING', 'README.md', '*.txt', '*.so', 'jumanrc',
        'dics/*/*.pat', 'dics/*/*.mat', 'dics/*/*.dat', 'dics/*/*.tab',
        'dics/*/JUMAN.*'
    ]},
)

パッケージ名はjumanyとしましたが、ディレクトリはpython_moduleですので、「package_dir={'jumany': 'python_module'}」と書いて教えてあげています。
(リポジトリの名前が既にjumanyでプロジェクトルートの名前にもなっていますので、ここのディレクトリ名もjumanyにするとややこしいです。)

 

最後の方に書いているpackage_dataでは、辞書ファイルをワイルドカードで指定しています。

そもそも辞書ファイルは、JUMANのビルドによって、別のインストールディレクトリ以下に作成されます。

最初は、引数data_filesを使って直接パッケージに入れようとしました。しかしインストール後のパスがうまく指定できません。なのでもう辞書はインストールして欲しい階層になるよう、python_module以下に予めコピーすることにしました。

またMANIFEST.inに書いてみると、パッケージには入りますがインストールはされませんでした。

 

実際のsetup.pyはこんな感じになっています。(過去のバージョンで、現在は結構変わっています。)

setupの引数は環境によって編集しそうなので、最初に変数_SET_UP_ARGSに設定しています。

_SET_UP_ARGS={...}」という初期化よりも「_SET_UP_ARGS=dict(...)」という書き方が、引数の書き方と同じになるので良いですね。(どこかのsetup.pyを参考にしました。)

参考:Python 3.6.1 ドキュメント:Python モジュールの配布 (レガシーバージョン)

コマンドラインオプションの取得

Pythonにはargparseという組み込みモジュールがありコマンドライン引数の解析が簡単、ということで、先に示した対話型のスクリプト__main__.pyで使ってみました。

該当部分を抜き出すと以下です。

import argparse
parser = argparse.ArgumentParser(
    description="""Results are shown in columns by default.
    Specify delimiter not to show in columns."""
)
parser.add_argument(
    '-d', dest='delimiter', nargs='?', const=' ',
    help='delimiter of parameters. default is a space'
)
parser.add_argument(
    '-r', dest='rcf', help='path to resource file'
)
args = parser.parse_args()

これだけでコマンドラインオプションをargs.delimiterargs. rcfのように取得できます。(-h,–helpオプションとヘルプ表示も。)

6行で設定している-dオプションは、「nargs='?', const=' '」と指定して、オプションがあるのに続けて値が指定されていない場合はデフォルト値としてスペースを設定するようにしています。

最初はconstでなくdefaultに設定して失敗しました。

(「nargs='?'」は引数を取る場合は危険ですね。)

参考:Python 3.6.1 ドキュメント:16.4. argparse

文字列表示幅の取得

対話型スクリプトでは、先の画面のように列を揃えて表示しています。

最初はstr.format()を使ってみたのですが、全角の文字では揃いません。それで文字列の幅を取得し、列幅に合わせての調節が必要になりました。

以下のコードでは、wordに入った文字列の幅をwidthに取得します。

width = sum([2 if unicodedata.east_asian_width(x) in "FWA" else 1 for x in word])

文字幅が全角か半角かはunicodedataモジュールのeast_asian_width()で判定できるということで、使ってみました。

unicodedata.east_asian_width()は文字幅の情報を文字列で返すのですが、戻り値が”F”か”W”の時は幅広文字です。日本語では恐らく全角。

戻り値が”A”のときは「曖昧」ということですが、たぶん全角?

ということで、unicodedata.east_asian_width()の戻り値が”FWA”のいずれかの時は文字幅2、それ以外は文字幅1としています。

他の言語ではこれをループで足し算するのですが、Pythonではこうも書けます。

Windows(MinGW)以外でのsetup

今後の課題ですが、Windows以外のPOSIXシステムへの対応を考えています。

共有ライブラリのビルドは既にJUMANそのもので対応しており、プロジェクトルートでのconfigureとlibの下でMakeをするだけです。

しかしsetup.pyの中でどうしたものやら。

公式ドキュメントにはdistutilsのExtensionに書く方法が書かれていますが、これは面倒そうです。

まとめ

なんやかんやで多少は速そうなJUMANのPythonモジュールができました。

しかし5年前の辞書が同梱されたPythonモジュール、需要はあるのか?

最新の辞書が供給されているMeCabでよいのではないか?

疑問は残りつつもTo be continued→(POSIX対応編へ)

コメントを残す