やわらかテック

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

【サンプルコード有り】Elixirでのhttpリクエストを簡単な叩き方

公開APIについて

以前「清流elixir」の勉強会でこの公開API、通称ジブリAPI(勝手に命名)を
Enumとパイプ演算子を使って遊ぶ予定だったんですけど
当日になってcurlの戻り値が%{}(マップ形式)となっていないことに気づく(そりゃそう

知見としてもかなり強力なのでElixirからAPIを叩けるようにしないとなーと
やってみたらめちゃくちゃ簡単でした

せっかくなので戻り値から特定のデータを抽出するところまで触れてみます

プロジェクトの準備

mixとmixを使ったプロジェクトの立ち上げについてはこの記事で詳しく触れているので省略します
いつものようにmix newコマンドを叩く

mix new call_api
cd call_api

今回の使用するライブラリは以下の2つ

  • httpoison(Httpクライアント)
  • poison(Jsonを解析するやつ)

いつものように./call_api/mix.exsのdpsに上2つを記述します
./call_api(project_name)/mix.exs

defp deps do
    [
      {:httpoison, "~> 1.4"},
      {:poison, "~> 3.1"}
    ]
end

からの

mix deps.get

でライブラリをダウンロードする
準備はこれで完了

使用するエンドポイント

ドキュメントにあるこのエンドポイントをGETでcallします

https://ghibliapi.herokuapp.com/films

こんな感じでリスト内にJson形式のデータが複数返ってくる

[
  {
    "id": "2baf70d1-42bb-4437-b551-e5fed5a87abe",
    "title": "Castle in the Sky",
    "description": "The orphan Sheeta inherited a mysterious crystal that links her to the mythical sky-kingdom of Laputa. With the help of resourceful Pazu and a 
    rollicking band of sky pirates, she makes her way to the ruins of the once-great civilization. Sheeta and Pazu must outwit the evil Muska, who plans to use 
    Laputa's science to make himself ruler of the world.",
    "director": "Hayao Miyazaki",
    "producer": "Isao Takahata",
    "release_date": "1986",
    "rt_score": "95"
  },
  {
    "id": "12cfb892-aac0-4c5b-94af-521852e46d6a",
    "title": "Grave of the Fireflies",
    "description": "In the latter part of World War II, a boy and his sister, orphaned when their mother is killed in the firebombing of Tokyo, are left to survive on their 
    own in what remains of civilian life in Japan. The plot follows this boy and his sister as they do their best to survive in the Japanese countryside, battling hunger, 
    prejudice, and pride in their own quiet, personal battle.",
    "director": "Isao Takahata",
    "producer": "Toru Hara",
    "release_date": "1988",
    "rt_score": "97"
  }
  :
  :
]

さっそくコードを書いていく

今回の目的を達成するために必要な流れはこんな感じ
1. APIをcallして戻り値を得る(Httpoison)
2. 戻り値を解析する(Poison)
3. 解析した結果から特定のデータのみを抽出する(パターンマッチ)

ここまで考えたらあとはコードに落としていくのみ
とりあえず戻り値からtitleを抽出してみる
./call_api/lib/call_api.ex

def fetch_ghibli_films() do
    #Httpoison使ってAPIをcall
    {status, res} = HTTPoison.get("https://ghibliapi.herokuapp.com/films")
    case status do
      :ok ->
        #戻り値のbody(json)を解析
        Poison.Parser.parse!(res.body)
          |> Enum.map(&(&1["title"]))
      :error -> :error
    end
end

前回のAmazonレビューのスクレイピングと異なり
今回はHttpoison.get!()を使わずにget()を使ってます
get()の戻り値には {:ok, return} もしくは {:error reason}という値が返ってくるので
APIのcallの成否をstatusのアトム値をcase文使って分岐させています

この時点でresにはこんなような値が入っている

%HTTPoison.Response{
  body: "[\n  {\n    ...,
  headers: [
    {"Server", "Cowboy"},
    :
  ],
  request: %HTTPoison.Request{
    body: "",
    headers: [],
    method: :get,
    options: [],
    params: %{},
    url: "https://ghibliapi.herokuapp.com/films"
  },
  request_url: "https://ghibliapi.herokuapp.com/films",
  status_code: 200
}

bodyの値(json)をPoison使って解析します
これでようやくElixirで扱える形になった

Poison.Parser.parse!(res.body)

[
  %{
    "description" => "The orphan...",
    "director" => "Hayao Miyazaki",
    "id" => "2baf70d1-42bb-4437-b551-e5fed5a87abe",
    "locations" => ["https://ghibliapi.herokuapp.com/locations/"],
    "people" => ["https://ghibliapi.herokuapp.com/people/"],
    "producer" => "Isao Takahata",
    "release_date" => "1986",
    "rt_score" => "95",
    "species" => ["https://ghibliapi.herokuapp.com/species/af3910a6-429f-4c74-9ad5-dfe1c4aa04f2"],
    "title" => "Castle in the Sky",
    "url" => "https://ghibliapi.herokuapp.com/films/2baf70d1-42bb-4437-b551-e5fed5a87abe",
    "vehicles" => ["https://ghibliapi.herokuapp.com/vehicles/"]
  },
  :
  :
]

マップのパターンマッチについて

あとは上記のリストinマップからtitleの値をどうやってパターンマッチさせるかですね
思いつくのはこれぐらい

info = %{"title" => "Castle in the Sky"}

#超シンプルなやつ
info["title"] #Castle in the Sky

#これだとエラー
info[title] #値がねーよと怒られる。そりゃ当然

#エラーにはならないが戻り値が空
info[:title]
info["new_title"]
#"title"というkeyがあるか
%{"title" => title} = info #title -> Castle in the Sky

#atom形式でないからこの書き方はできない
%{"title": title} = info 

#存在しないkeyを指定するとerror
%{"new_title": title} = info #error
#Mapモジュールを使用する
Map.get(info, "title") #Castle in the Sky

#存在しないkeyをしてもerrorにはならず空
Map.get(info, "new_title")

とりあえず一番シンプルな方法を今回は選択した
あとはリスト内のマップに対して操作を適用したいのでいつも通りにEnum.map()を使えば良い

Enum.map(&(&1["title"]))

お待たせしました
ではfetch_ghibli_films() を呼び出してみる

iex(8)> CallApi.fetch_ghibli_films()
["Castle in the Sky", "Grave of the Fireflies", "My Neighbor Totoro",
 "Kiki's Delivery Service", "Only Yesterday", "Porco Rosso", "Pom Poko",
 "Whisper of the Heart", "Princess Mononoke", "My Neighbors the Yamadas",
 "Spirited Away", "The Cat Returns", "Howl's Moving Castle",
 "Tales from Earthsea", "Ponyo", "Arrietty", "From Up on Poppy Hill",
 "The Wind Rises", "The Tale of the Princess Kaguya", "When Marnie Was There"]

お、良い感じに取れてますね
無事にAPIをcallすることが出来た上に、簡単な解析まで完了しました

実はもっと頑張ると3行で書くことが出来ます

def fetch_ghibli_films() do
    HTTPoison.get!("https://ghibliapi.herokuapp.com/films").body
      |> Poison.Parser.parse!()
      |> Enum.map(&(&1["title"]))
end

おまけのコーナー

directorが"Hayao Miyazaki"である作品のtitleを抽出してみる

def fetch_ghibli_films() do
    HTTPoison.get!("https://ghibliapi.herokuapp.com/films").body
      |> Poison.Parser.parse!()
      |> Enum.filter(&(&1["director"] == "Hayao Miyazaki"))
      |> Enum.map(&(&1["title"]))
end

#result
#["Castle in the Sky", "My Neighbor Totoro", "Kiki's Delivery Service",
# "Porco Rosso", "Princess Mononoke", "Spirited Away", "Howl's Moving Castle",
# "Ponyo", "The Wind Rises"]

この辺りの操作はやっぱりElixirは強力ですね

ErlPortでpythonのクラスメゾット呼び出しとElixir側にAtomを返す

前回のはなし

www.okb-shelf.work

Elixirからpythonの関数を呼び出して、最終的にjanomeを使って形態素解析を行いました

本当はpythonのclassメゾットの呼び出しとElixirに対して
Atomのデータを返すということがやりたかったんですけど
そこそこ記事が長くなってしまったのでカット

今回の記事で触っていく
ErlPortの公式にclassメゾットの呼び出しについてほとんど記述が無かったので割としんどかった

python側での準備

単純なclassメゾットを用意

class EasyClassMethod:
  def output_msg(self, msg):
    decode_msg = msg.decode("utf-8")
    print(decode_msg)
    return True

classなのにただ文字列を出力するだけのカス
文字列のdecodeについては前回の記事で触れてるのでどうぞ

Elixir側での準備

ほとんど前の記事のものと同じで異なるのは:python.callを使用して
クラスのインスタンスをさりげなく作っているところです

demodule CallPython do
  def call_esay_method(msg) do
    {:ok, py_processs} = :python.start([python_path: 'python_files'])
    #EasyClassMethodのインスタンスを作ってる(object)
    obj = :python.call(py_processs, :test_call, :EasyClassMethod, [])
    #呼び出す関数はアトムの文字列で与える必要あり,引数の第1にobjectを渡す
    res = :python.call(py_processs, :test_call, :"EasyClassMethod.output_msg", [obj, msg])
    IO.puts(res)
    :python.stop(py_processs)
    :ok
  end
end

とりえあず動作を見てみると

iex(3)> CallPython.call_esay_method("hello world")
hello world
true
:ok

いい感じですね

それぞれの出力値を追っていくと
obj
謎のバイナリーデータが入っている

#objのreturn
{:"$erlport.opaque", :python,
 <<128, 2, 99, 116, 101, 115, 116, 95, 99, 97, 108, 108, 10,
   69, 97, 115, 121, 67, 108, 97, 115, 115, 77, 101, 116, 104,
   111, 100, 10, 113, 0, 41, 129, 113, 1, 46>>}

res
メゾットの戻り値が来ている。いいね

true

クラス化したJanomeParserを呼び出す

とりあえず基礎の部分は抑えたのでこの記事で作成したclassメゾットを呼び出してみる
python

class JanomeParser:
    def __init__(self):
        print("come here: init method")
        self.t = Tokenizer()
        
    def parser(self, value_str, tag=u"名詞"):
        res = self.t.tokenize(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]

Elixir

def class_caller(value_str, tag) do
    {:ok, py_process} = :python.start([python_path: 'python_files'])
    obj = :python.call(py_process, :test_call, :JanomeParser, [])
    res = :python.call(py_process, :test_call, :"JanomeParser.parser", [obj, value_str, tag])
      |> Enum.map(&(to_string(&1)))
    :python.stop(py_process)
    res
end

今まで通り呼び出すが、実は2点問題がある
とりあえず実行結果を先にお見せします

iex(3)> CallPython.class_caller("言葉一つで足りないくらい全部壊れてしまうような", "名詞")
come here: init method
** (ErlangError) Erlang error: {:python, :"builtins.ValueError", 'unsupported data type: <class \'test_call.JanomeParser\'>',
:
:
    (call_python) lib/call_python.ex:46: CallPython.class_caller/2

どうやらinitiを呼び出しまでは問題なく行われているが

self.t = Tokenizer()

ってのが上手く行かないっぽい
あとparserに渡した文字列を毎度のごとくdecodeする必要がある
ということでdecodeをしつつ、initでinstanceを生成するのを止めたら動いた

class JanomeParser(object):
  def __init__(self):
    print("come here")

  def parser(self, 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]
iex(5)> CallPython.class_caller("言葉一つで足りないくらい全部壊れてしまうような", "名詞")
come here
["言葉", "一つ", "全部", "よう"]

いい感じだけど、class化した意味があんまりないのがつらい
外部でインスタンス作ってinitに渡してあげればいいかもなと思い試したけどダメでした(デデドン

class JanomeParser(object):
  def __init__(self, tokenizer):
    print("come here")
    self.t = tokenizer

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

    tag = PART_OF_SPPECH.get(decoder(tag))
    res = self.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]


def create_tokenizer_instance():
  return Tokenizer()
def class_caller(value_str, tag) do
    {:ok, py_process} = :python.start([python_path: 'python_files'])
    tokenizer = :python.call(py_process, :test_call, :create_tokenizer_instance, [])
    obj = :python.call(py_process, :test_call, :JanomeParser, [tokenizer])
    res = :python.call(py_process, :test_call, :"JanomeParser.parser", [obj, value_str, tag])
      |> Enum.map(&(to_string(&1)))
    :python.stop(py_process)
    res
end

実行結果

iex(8)> CallPython.class_caller("言葉一つで足りないくらい全部壊れてしまうような", "名詞")
come here: init method
** (ErlangError) Erlang error: {:python, :"builtins.ValueError", 'unsupported data type: <class \'test_call.JanomeParser\'>',
:
:
    (call_python) lib/call_python.ex:46: CallPython.class_caller/2

さっきと同じエラーが。要研究ですね

pythonからElixirにAtomを返す

python側の関数から

:ok
{:ok, value}
{:error, reason}

のようなAtomを返すようなことがしたい場合があるかもしれない
実際にElixirでパターンマッチする際にはAtomの:okやら:errorをよくマッチさせるので使い道は多そう

Elixir側でやることはほとんどなくpython側でのごちゃごちゃが必要になる
公式ドキュメントをみると細々と記述されていた

まずは「erlport」をpython側でインストールする

pip install erlport
pip3 install erlport

こうすることでAtom型をpythonで作成可能となる

from erlport.erlterms import Atom

def return_atom_to_elixir():
  #Atomメゾットには文字列を与える
  return Atom("ok")

あとはこいつをElixirから呼び出すだけなのでいつも通りですね
Elixirとpythonの型の対応は公式ドキュメントの下の方に記述されています
ほとんどの型はそのまま受け渡しできますが

に関してはこのように関数を使って定義する必要があります
他にもpython側のNoneはElixir側ではundefinedになるなど多少異なる点があるので注意
いまさらですがErlangでは文字列はなく全てbinaryとして扱われるのでpython側でdecode, encodeが必要です

感想

classメゾットの呼び出しで結構つまづいた
もっとスッキリ使えればと思う一方、ErlPortの仕組みが気になる

そういえばErlang作者の1人であるJoe Armstrong's さんが亡くなったそうです
ElixirがあるのはErlangのおかげ。ErlangがあるのはJoe Armstrong'sのおかげです
残念です。少しでも多くの人にElixirを触ってもらえるようにアウトプット頑張ります

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]

【サンプルコード有り】形態素解析をササッと試すならMecabよりもJanomeが良い感じ

Janomeとは

mocobeta.github.io

Pure Python(通常のpythonのみ)で書かれている日本語の形態素解析のためのパッケージです。自然言語処理で主に行われる形態素解析を気軽に行うことが出来ます。そもそも「形態素解析って何?」という方のために形態素解析結果をお見せしますと以下のようになります。

(B'z 今宵月の見える丘により)

手をつないだら行ってみよう

形態素解析の結果

手 名詞,一般,*,*,*,*,手,テ,テ  
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ  
つない 動詞,自立,*,*,五段・ガ行,連用タ接続,つなぐ,ツナイ,ツナイ  
だら  助動詞,*,*,*,特殊・タ,仮定形,だ,ダラ,ダラ  
行っ  動詞,自立,*,*,五段・カ行促音便,連用タ接続,行く,イッ,イッ  
て 助詞,接続助詞,*,*,*,*,て,テ,テ  
みよ  動詞,非自立,*,*,一段,未然ウ接続,みる,ミヨ,ミヨ  
う 助動詞,*,*,*,不変化型,基本形,う,ウ,ウ  

Janomeではこのような結果が返ってきます。形態素解析の対象とした文章(今回は「手をつないだら行ってみよう 」)を品詞ごとに区切り、それぞれの品詞の情報を教えてくれると言えば良いでしょうか。自分は古典の活用形を覚えるのが苦手だったので、形態素解析結果のそれぞれの品詞がどのような特性を持つのかは全く分かりませんが、重要なのは形態素解析パッケージによって文章を品詞に分解出来る」という点です。

僕自身は元々、Mecabという形態素解析エンジンものを使っていましたが、必要な外部パッケージのインストール、ユーザー辞書の準備がそこそこ面倒で毎度、環境構築するたびに結構苦しめられました。
そしたら何とpipのみでササッとインストールできるJanomeを教えてもらいまして、開発環境の準備がめちゃ楽になりました。

とはいいつつも、Janomeの内部ではMecabipadicを使っているようなので、僕の苦労をJanomeが負担してくれていることですね。
圧倒的感謝...

Janomeをインストールする

Pythonはインストール済みであり、ver3.4以上を想定しています

(すでにJanomeがインストール済みの方はこの章をすっとばして下さい)

仮想環境を作成するためのpythonの標準パッケージである、venvを使用してプロジェクト仮想環境を作りJanomeをインストールする場合は以下を参照下さい。ローカル環境にグローバルにJanomeをインストールはこの項目を飛ばしてください。特に理由がなければローカル環境を可能な限りクリーンに保つために仮想環境を作成したインストールをオススメします。

venvを使って仮想環境を作成してJanomeをインストール

まずは適当な名前でディレクトリを作成します。

$ mkdir janome_sample
$ ls

janome_sample

作成したディレクトリの内部に移動します。

$ cd janome_sample

venvを使って仮想環境を作成しましょう。.venvの部分は任意の名前で問題ありません。

$ python3 -m venv .venv

無事に仮想環境が作成されたかを確認します。

$ ls -a | grep ".venv"

.venv

仮想環境を立ち上げて、pipを使用して、Janomeをインストールします。

$ source .venv/bin/activate (.venv) $ pip install janome

Collecting janome
  Cache entry deserialization failed, entry ignored
  Downloading https://files.pythonhosted.org/packages/79/f0/bd7f90806132d7d9d642d418bdc3e870cfdff5947254ea3cab27480983a7/Janome-0.3.10-py2.py3-none-any.whl (21.5MB)
    100% |████████████████████████████████| 21.5MB 53kB/s
Installing collected packages: janome
Successfully installed janome-0.3.10

無事にインストールされたかを確認します。

$ pip freeze

Janome==0.3.10

venvで作成した仮想環境にpip経由でJanomeがインストールされていることを確認出来ました。

ローカル環境にグローバルにインストール

$ pip install janome

or

$ pip3 install janome

たったこれだけ、もう終わり。Mecabが使えるようになるまでのステップと比べると圧倒的に楽です。
qiita.com

形態素解析をするまで

全体のコード(jupyter notebook)をgitに上げていますのでご参照下さい。
github.com

最も基本的な部分のみを試してみます。

# janomeのTokenizerをインポート
from janome.tokenizer import Tokenizer

t = Tokenizer()
res = t.tokenize("手をつないだら行ってみよう")
print(res)

#result: この時点で結果が思った様に表示されない
# [<janome.tokenizer.Token at 0x112151710>,
# :
# :
#  <janome.tokenizer.Token at 0x112151908>]

forを使えば先ほどの様な解析結果を覗くことが出来ます。

for r in res:
    print(r)

#result:
# 手  名詞,一般,*,*,*,*,手,テ,テ
# を  助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
# つない    動詞,自立,*,*,五段・ガ行,連用タ接続,つなぐ,ツナイ,ツナイ
# だら   助動詞,*,*,*,特殊・タ,仮定形,だ,ダラ,ダラ
# 行っ   動詞,自立,*,*,五段・カ行促音便,連用タ接続,行く,イッ,イッ
# て  助詞,接続助詞,*,*,*,*,て,テ,テ
# みよ   動詞,非自立,*,*,一段,未然ウ接続,みる,ミヨ,ミヨ
# う  助動詞,*,*,*,不変化型,基本形,う,ウ,ウ

特定の品詞に該当する単語だけを抽出する

解析結果の配列(先ほどの例でいうres)に対して.part_of_speechメゾットを呼び出すことで、それぞれの単語の品詞を確認する事が出来ます。

res[0].part_of_speech
# '名詞,一般,*,*'

この.part_of_speechの戻り値('名詞,一般,*,*'という情報)はただの文字列なので,でsplitして、内包表記で上手く処理してあげることで各単語の品詞情報だけを取得する処理を記述する事が出来ます。

[f.part_of_speech.split(",")[0] for f in res]
# ['名詞', '助詞', '動詞', '助動詞', '動詞', '助詞', '動詞', '助動詞']

自然言語処理の前処理で頻出するのが名詞だけ取得したい、名詞形容詞だけ取得したいという作業です。特定の品詞だけを取り出しつつ .surfaceメゾットを使って解析結果から該当する品詞の単語の一覧を取得します。

#1つの品詞だけ
[token.surface for token in res if token.part_of_speech.split(",")[0] == u"名詞"]
# ['手']

#複数の品詞
target = [u"名詞", u"動詞"]
[token.surface for token in res if token.part_of_speech.split(",")[0] in target]
# ['手', 'つない', '行っ', 'みよ']

可能か限り、内包表記を使用して記述しているのは少しでも速度を落とさないようにするためです。この手の自然言語処理での前処理は何万というデータに対して行うことが多いため、極力速度が落ちるような記述(通常のfor文など)は避けています。

良い感じですね。使い勝手がいいように関数化しておきましょう。

from janome.tokenizer import Tokenizer

def parser(value_str, tag=u"名詞"):
    t = Tokenizer()
    res = t.tokenize(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]
    
parser("手をつないだら行ってみよう")
#result: ['手']

この関数はこのままでは、呼び出されるたびにTokenizerインスタンスを作ってしまい、パフォーマンスが悪そうなので一度だけTokenizerインスタンスを作れば良いように、クラス内の関数にしておきます。

class JanomeParser:
    def __init__(self):
        self.t = Tokenizer()
        
    def parser(self, value_str, tag=u"名詞"):
        res = self.t.tokenize(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]
        
j = JanomeParser()
j.parser("手をつないだら行ってみよう")
#result: ['手']

これでクラス化も完了しました。

ユーザー辞書の作成と読み込み

Mecab.csv形式で作成したファイルを.dic形式にcompileする必要がないため、pandasを使って簡単にユーザー辞書を作成することが出来ます。以下のコードは単語のリスト([]string)からJanome指定の.csv形式のファイルを作成する関数になります。

コピペで使えます~

# pandasがインストールされていない場合はインストールしてください
# > pip install pandas
import pandas pd

def create_user_dic(words, dic_name):
    """
      TASK: 指定された単語群からJanome指定のユーザー辞書.csvファイルを作成する
      words: []string -> 辞書に登録したい単語群
      dic_name: string -> __.csvに該当するファイル名
      return void
    """
    df = words_to_df(words)
    df = to_janome_csv_style(df)
    save_df_to_csv(df, dic_name)

def words_to_df(words):
    """
      TASK: 単語リストをpandas.DataFrame形式に変換する
      wrods: []string -> 対象の単語群
      return pandas.DataFrame
    """
    to_df_list = [[w] for w in words]
    return pd.DataFrame(words, columns=["単語"])

def to_janome_csv_style(df):
    """
      TASK: 読み込まれたdfをjanomeが指定するユーザー辞書.csv形式に変換する
      df: pandas.DataFrame -> 単語リストから生成されたdf
      return pandas.DataFrame
    """
    return df.assign(
    a=df.pipe(lambda df: -1),
    b=df.pipe(lambda df: -1),
    c=df.pipe(lambda df: 1000),
    d=df.pipe(lambda df: "名詞"),
    e=df.pipe(lambda df: "一般"),
    f=df.pipe(lambda df: "*"),
    g=df.pipe(lambda df: "*"),
    h=df.pipe(lambda df: "*"),
    i=df.pipe(lambda df: "*"),
    j=df.pipe(lambda df: df["単語"]),
    k=df.pipe(lambda df: "*"),
    l=df.pipe(lambda df: "*"),
)

def save_df_to_csv(df, file_name):
    """
      TASK: dfを.csv形式で保存する
      df: pandas.DataFrame -> to_janome_csv_style()の戻り値のdf
      file_name: ファイルの保存名(拡張子は含まない)
      return void
    """
    df.to_csv(f"{file_name}.csv", header=False, index=False, encoding="cp932")

使い方は簡単でcreate_user_dicに辞書として登録したいワードとファイル保存名(.csvを除いた)を渡すだけになります。

words = ["果てない思い", "ウルトラソウル"]
create_user_dic(words, "B'z_dic")

関数を実行したカレントディレクトリに.csv形式のファイルが生成されているかと思います。

$ ls

"B'z_dic.csv"

これでユーザー辞書の作成は完了です。

ユーザー辞書の読み込み

ユーザー辞書が読み込まれていない状態だと以下のような結果になります。

# 名詞だけを取得
parser("果てない思い")
# ['思い']

parser("ウルトラソウル")
# ['ウルトラ', 'ソウル']

せっかくユーザー辞書を作成したので読み込むようにしましょう。めちゃくちゃ簡単です。Tokenizerインスタンスの生成時に、ユーザー辞書.csvのパスを引数に指定するだけで読み込まれます。

# 作成したユーザー辞書へのパス
USER_DIC_PATH = "B'z_dic.csv"

# 簡略化のため定数値からユーザー辞書を指定
def parser(value_str, tag=u"名詞"):
    t = Tokenizer(USER_DIC_PATH, udic_enc="cp932")
    res = t.tokenize(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]

実行結果

parser("果てない思い")
# ['果てない思い']
parser("ウルトラソウル")
# ['ウルトラソウル']

ユーザー辞書を読み込むように更新して、単語が分割されないようになりました。無事にユーザー辞書の読み込みに成功しているということになります。コード量が多くなってしまうため、ブログの方には貼りませんが、jupyter notebook内にてインスタンス生成時に、ユーザー辞書のパスを指定するものも作成しましたので、よければ覗いて見てください。

github.com

まとめ

Janomeはいかがでしたか。インストールも簡単で使い方もシンプル。特に使用するまでに外部のパッケージをあれこれいれる必要がなく、「形態素解析」の実行にまで非常にスムーズに辿り着くことが出来ます。速度に関してはMecabには劣ってしまうのですが、形態素解析を少し試した時にはJanomeが非常に良いのではないでしょうか。

こちらの記事では紹介していないJanomeの様々な便利機能がまだまだあり、こちらで非常に丁寧に紹介されていますので、ぜひご覧ください。私も非常に勉強させて頂きました。

note.nkmk.me

おまけ: 関数とクラス内関数で実行速度を比較

1万回の実行結果の平均値を比べてみました。コードはjupyter notebookの一番下のところにあります。割と量が多くなってしまったのでリンクを再度、貼っておきます。

実行形式 試行回数 平均値
関数 10,000 0.08182s
クラス内関数 10,000 0.08891s

速度に関しては若干、関数形式の方が勝る結果もありましたが、ほとんど誤差といっても良いかと思います。実際は速度以外にもメモリ使用率の問題などもあるので、一概にこの結果だけからどちらが良いかとは言えませんが、速度上では関数形式の方が速いということが分かりました。

参考文献

cloud-functionにFirebaseの値を取得して返すAPIをデプロイしてみた

前回

www.okb-shelf.work

前回の記事でcloud-functionを使って、元から記述されていたサンプルコードをデプロイするところまで触りました
今回はもう少し面白みのありそうなことをやってみたので記事にしました
こちらの動画を元にやってきます

やること

cloud-functionのセットアップに関しては前の記事をご覧ください
今回はFirebaseのクラウドのDatabaseに保存されている値をGETメゾット使ってAPI経由で取得します
実際の実務でもよくやることなのでcloud-functionだとどれぐらいの手間で作れるのか楽しみですね

APIの作成経験ですが

ぐらいしか触った事がないので最終的にデプロイまでの楽さを比較しようかと思ってます

FirebaseのDatabaseにデータを追加する

Firebaseのプロジェクトページの左メニューの

開発-> Databaseを選択

からDatabaseの編集が可能です

今回はデータを作成(追加)するだけなので

  • コレクションを追加(分かる範囲で適当にネーミング)
  • ドキュメントを追加(同上。適当にフィールドも設定)

こんな感じになってればOKです

f:id:takamizawa46:20190416073608p:plain
Dtabaseにデータを作成完了

cloudのDatabaseからデータをfetchする

特に難しい部分もないので、先にコード全部載せておきます

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin'; //adminをimport

//この1行ないとエラーになるので注意
admin.initializeApp();

//Firebaseから特定パスの情報を取得する
export const fetchCityInfomation = functions.https.onRequest((_request, response) => {
  //doc()に取得先のパスを記述
  admin.firestore().doc('cities-weather/japan-data').get()
    //promiseをthen, catchで処理
    .then(snapshot => {
      const data = snapshot.data();
      response.send(data);
    })
    .catch(error => {
      console.log("error: ", error);
      response.status(500).send(error);
    })
})

はい、たったこれだけです。楽すぎやろ〜
クラウドにデプロイする前にローカルで動作を試してみます
ただコマンド3つ叩くのがだるいので.shファイルにしておきます

./functions/local-test.sh

npm run-script lint #tslintを実行(構文チェック)
npm run-script build #tsをjsにbuild
firebase serve --only functions --project cloud-function-114514 #ローカルでrunserverして動作チェック

全てが問題なくsucsessになるとこんな感じでlocalにapiが立ちます

 ❯  bash local-test.sh

> functions@ lint ./Desktop/firecast/functions
> tslint --project tsconfig.json


> functions@ build ./Desktop/firecast/functions
> tsc


=== Serving from './Desktop/firecast'...

i  functions: Preparing to emulate functions.
Warning: You're using Node.js v10.9.0 but Google Cloud Functions only supports v6.11.5.

##ここに注目!!
✔  functions: getWeatherInfo: http://localhost:5000/cloud-function-114514/us-central1/getWeatherInfo
info: User function triggered, starting execution
info: Execution took 1781 ms, user function completed successfully

記述されたエンドポイントを見に行ってみると...

{"city":"tokyo","temp":23}

うまく取得出来てますね

値を抽出する場合に

response.send(data.city);

とするとundifinedの可能性があるでって怒られるので色々試してみる
三項演算子だとこれで通る

.then(snapshot => {
      const data = snapshot.data();
      const returnStr = data !== undefined ? data.city: null;
      response.send(returnStr);
    })

ダセーけど普通にifでもいける

.then(snapshot => {
      const data = snapshot.data();
      if(data !== undefined){
        response.send(data.city);
      }
      response.send(data);
    })

これでbashからshell叩いてエンドポイントを改めて見に行くと

tokyo

となっています。良いですね

localでの動作が確認できたのでデプロイします
どっちかのコマンドでいけます
僕は下じゃないとエラーになりますので...

firebase deploy
firebase deploy --project cloud-function-114514

まとめ: 他のAPI作成と比べて

  • 新規プロジェクトの作成のしやすさ
  • シンプルなモデル(Database)の作成
  • パスの設定
  • デプロイの手軽さ

で圧倒的に楽ですね
ただ大規模アプリには向いていない気もする
まぁ小出しに作ればええやろ?って思想なんだと思います

問題なのは料金と把握しきれるか(共通問題)ぐらいでしょうか
この機能だけcloud-functionで爆速実装なんてのは方針としては良さげですね