やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

Elixirからpythonを呼び出してJanomeで形態素解析してみた

ErlPortなるものを発見

色々とネットサーフィンしてたらErlPortというErlangのライブラリを発見
なんとErlang環境からpythonRubyを実行できる模様...凄すぎる
ErlangのモジュールはElixirから呼び出し可能なので勝ちました

前回の記事で作成したparser関数を(janomeを使った形態素解析)Elixirから呼び出すことを目標にErlPortを触ってみます

www.okb-shelf.work

新規プロジェクトの立ち上げ

mix使って新規プロジェクトを立ち上げます
mixについてざっくり以下の記事で触れているのでよろしければご覧ください

さっそくプロジェクトを立ち上げます

> mix new call_python
> cd call_python

ErlPortをインストールするようにmix.exsに記述します
./call_python/mix.exs

 defp deps do
    [
      {:erlport, "~> 0.9"} , # <-- ここに追加
      # {:dep_from_hexpm, "~> 0.3.0"},
      # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
    ]
  end

以下のコマンドで外部ライブラリをダウンロードします
コンパイルに関しては自動でやってくれるのでスルーします

> mix deps.get

これでプロジェクトの準備は完了

.pyファイルの用意

今回はプロジェクトディレクトリの直下にpython_filesというディレクトリを置き
そこに.pyファイルを配置します
このパスがErlPortを使用する際に必要となります

で、ここに前回記述したparser関数を書いときます
ついでにチュートリアル的な引数を持たない最もシンプルなhello関数も作っときます
./call_python/python_files/janome_parser.py

from janome.tokenizer import Tokenizer

def parser(value_str, tag=u"名詞"):
    #elixirから文字列を受け取る際にはutf-8へのdecodeが必要
    decode_from_elixir = value_str.decode("utf-8")
    t = Tokenizer()
    res = t.tokenize(decode_from_elixir)
    if isinstance(tag, list):
        return [token.surface for token in res if token.part_of_speech.split(",")[0] in tag]
    else:
        return [token.surface for token in res if token.part_of_speech.split(",")[0] == tag]

def hello():
  print("hello world")

pythonファイルでの準備はこれでOKです

Elixir側から呼び出す

ではついにpythonで作成した関数を呼び出しましょう
公式ドキュメントを見るとこんな記述があります

1> {ok, PythonInstance1} = python:start().
{ok,<0.34.0>}
2> {ok, PythonInstance2} = python:start().
{ok,<0.36.0>}
3> python:stop(PythonInstance1)
ok
4> python:stop(PythonInstance2)
ok

python:start()というメゾットを呼び出すことでpython実行環境を持つプロセスを作成しているようです
これはErlangの記述なのでElixirの場合は

:python.start()

とします。またstart()にディレクトリのパスをリストで渡すことが可能です
バイナリで渡す必要があるので注意
他言語の癖でうっかり「"」を使うとエラーになります

:python.start([python_path: 'python_files'])

この作成したプロセスをpython.call()メゾットに渡すことで
.pyファイルに記述された関数をcallできる模様(公式より

2> python:call(P, operator, add, [2, 2]).
4

引数について

  • 1st: 生成したpython実行環境のプロセス
  • 2nd: 対象のファイル名
  • 3rd: ファイル内部の関数名
  • 4th: 与えたい引数(リストに格納)

先ほど作成したhello関数を対象に呼び出してみます

## プロセスを生成
iex(1)> {:ok, py_process} = :python.start([python_path: 'python_files'])
{:ok, #PID<0.171.0>}

## 生成したプロセスを使用してpython実装のhello関数を呼び出す
iex(2)> :python.call(py_process, :janome_parser, :hello, [])
hello world #無事に出力された!
:undefined #returnがないのでundefined

公式の通り、python.stop()でプロセスを終了させます

iex(3)> python.stop(py_process)
ok

.pyの編集が反映されない時

.pyファイルに編集を加えた際にstartでパスを渡している場合にはプロセスを最生成する必要があるようです
編集分が反映されない時はもう一度、python.start()を呼び出します

.pyファイルを編集

def hello():
  print("hello world")
  return 777

呼び出すも編集分が反映されていない

iex(4)> :python.call(py_process, :janome_parser, :hello, [])
hello world
:undefined

再度、プロセスを生成してhello()を呼び出すと反映されている

iex(6)> {:ok, py_process} = :python.start([python_path: 'python_files'])
{:ok, #PID<0.180.0>}
iex(7)> :python.call(py_process, :janome_parser, :hello, [])
hello world
777

コマンドで叩いていたものを関数化しておきましょう

def call_hello_func_in_python do
    {:ok, py_process} = :python.start([python_path: 'python_files'])
    res = :python.call(py_process, :janome_parser, :hello, [])
    IO.puts("result: #{res}")
    :python.stop(py_process)
    :ok
end

recompileをして上記の関数を呼び出してみます

iex(8)> recompile
Compiling 1 file (.ex)

iex(9)> CallPython.call_hello_func_in_python()
hello world
result: 777
:ok

良い感じですね
ここまで来たら呼び出す関数を別の物に変えるだけなので余裕
面倒なので最初から関数にしておきます
tagを渡す際に「u"名詞"」と記述しますがElixirからpythonにうまく渡せなかったので
python側でゴリ押ししました

def call_janome(value_str, tag) do
    {:ok, py_process} = :python.start([python_path: 'python_files'])
    result = :python.call(py_process, :janome_parser, :parser, [value_str, tag])
    :python.stop(py_process)
    result
end

./python_files/janome_parser.py

def parser(value_str, tag):
  decode_value_str_from_elixir = value_str.decode("utf-8")
  decode_tag_from_elixir = tag.decode("utf-8")
  #文字列をu-stringに変換する処理を追加
  if encode_tag_from_elixir == "名詞":
    tag = u"名詞"

  t = Tokenizer()
  res = t.tokenize(encode_value_str_from_elixir)
  if isinstance(tag, list):
      return [token.surface for token in res if token.part_of_speech.split(",")[0] in tag]
  else:
      return [token.surface for token in res if token.part_of_speech.split(",")[0] == tag]

こいつを実行すると

iex(23)> CallPython.call_janome("何もない午後の入江を往く船をただ見つめていた", "名詞")
[[20309], [21320, 24460], [20837, 27743], [33337]]

おや、戻り値が全てCharlistになってしまっている...
確かErlangにはStringがなくて全てbinaryになったはずなのでElixirでCharlistをStringに変換します

#resultをパイプ使ってStringに変換
result = :python.call(py_process, :janome_parser, :parser, [value_str, tag])
      |> Enum.map(&(to_string(&1)))

さぁ実行してみる

iex(25)> recompile
Compiling 1 file (.ex)
:ok
iex(26)> CallPython.call_janome("何もない午後の入江を往く船をただ見つめていた", "名詞")
["", "午後", "入江", ""]

うまくいきました!!
思ったように動作してくれるようになりました
Charlistの変換のところで割と迷った

まとめ

Elixirからpythonの関数呼ぶのすげー楽なんで流行りそう(流行れ
要はこれをElixirお得意の並列処理でゴリゴリすると...と考えると夢が広がりますね

長くなったので

  • Classの呼び出し
  • python側からAtomを返す

という処理はまた次の記事に書きます(書くとはは言っていない

おまけのコーナー

pythonのやつの記述がゴリ押しすぎたので若干修正
ましになったかも

from janome.tokenizer import Tokenizer

#ここに品詞をかいとけばu-stringに変換してくれる
PART_OF_SPPECH = {
  "名詞": u"名詞",
  "形容詞": u"形容詞",
}

def parser(value_str, tag):
  def decoder(value_str, str_code="utf-8"):
    return value_str.decode(str_code)

  tag = PART_OF_SPPECH.get(decoder(tag))
  t = Tokenizer()
  res = t.tokenize(decoder(value_str))
  if isinstance(tag, list):
      return [token.surface for token in res if token.part_of_speech.split(",")[0] in tag]
  else:
      return [token.surface for token in res if token.part_of_speech.split(",")[0] == tag]