Ruby
Python
機械学習
PyCall

pycallrb_logo.png    pycall公式ロゴ

はじめに

PyCallは mrkn さんが開発されている、Python のライブラリを Ruby や Julia で利用するためのブリッジライブラリです。

https://github.com/mrkn/pycall.rb/

PyCallの解説やインストール方法は mrkn さん始め諸賢のドキュメントが多く有りますので、ここでは私がRuby版PyCallを使ってきてのTipsを書いていきます。(2018年2月時点の最新版 PyCall 1.0.3 に基づく)

Tips

Python ライブラリのロード

Python のライブラリを使う場合、次の2行を書いておく

require 'pycall/import'
include PyCall::Import

Python のライブラリは次のようにロードする

# Pythonだと、 import matplotlib
pyimport :matplotlib
pyimport 'matplotlib'  # 上ではシンボルを使ったが、文字列でも同じ

# Python だと import matplotlib.pyplot as plt
pyimport 'matplotlib.pyplot', as: :plt
plt = PyCall.import_module('matplotlib.pyplot')  # 上はこのような記法でも同じ

# Python だと from janome.tokenizer import Tokenizer
pyfrom 'janome.tokenizer', import: :Tokenizer

# Python だと from keras.layers import Dense, Dropout
pyfrom 'keras.layers', import: ['Dense', 'Dropout']

全般的に気をつけること

  • Pythonから返ってきたオブジェクトの型には気をつけましょう。 - PythonのいろいろなライブラリからRubyに戻されるオブジェクトには、一見数値やArrayやHashのように思えるものが有りますが、実は違っている場合が有ります。
# RubyのArrayといくつかのArray風のオブジェクトの違いを調べます
r_array = [0.0, 1.0, 2.0]                # RubyのArray(配列)を作る
p_list = PyCall.eval("[0.0, 1.0, 2.0]")  # Python のリストを作る
require 'numpy'                          # numpy は配列を扱うPythonの定番ライブラリ
np_array = Numpy.array([0.0, 1.0, 2.0])  # numpy のarrayを作る
pyfrom('collections', import: :deque)    # dequeはappendやpopを高速に行えるリスト風のコンテナ
p_deque = deque.([0.0, 1.0, 2.0])        # deque を作る

# 上で作ったArray風のオブジェクトが [] でアクセスできることを確認します。またクラスを調べます
p r_array[1], r_array.class      # -> 1.0, Array
p p_list[1], p_list.class        # -> 1.0, <class 'list'>
p np_array[1], np_array.class    # -> 1.0, <class 'numpy.ndarray'>
p p_deque[1], p_deque.class      # -> 1.0, Object

# Array風のオブジェクトで each が使えるかどうかの確認
r_array.each{|x| p x}      # OK
p_list.each{|x| p x}       # OK
np_array.each{|x| p x}     # OK
p_deque.each{|x| p x}      # エラー! PyCall::List.(p_deque)とすればリストにキャストできる。でも計算コストを考えると高速性がウリのdequeをリストにする意味はないだろう

# Array風のオブジェクトで delete_if が使えるかどうかの確認
r_array.delete_if{|x| x>1.5 }     # OK
p_list.delete_if{|x| x>1.5 }      # エラー! - p_list.to_a とすればArrayにキャストできる
np_array.delete_if{|x| x>1.5 }    # エラー! - PyCall::List.(np_array).to_aとすればArrayにキャストできる
p_deque.delete_if{|x| x>1.5 }     # エラー! - PyCall::List.(p_deque).to_aとすればArrayにキャストできる
  • Pythonオブジェクトに渡す引数の()の前にはピリオドを入れますが、よく使うオブジェクトでは不要です。 ("よく使う" というのはPythonの基本的なオブジェクトやnumpy のようにPyCall側でラッパーメソッドが用意されているもの、という意味です) ピリオドが抜けた場合、"ArgumentError" か "NoMethodError" が返ってきます。
# 引数の()の前にピリオドを入れる例
pyfrom 'collections', import: :deque
p_deque = deque.([0.0, 1.0, 2.0, 3.0])   # deque と ( の間に . を入れる

# 引数の()の前にピリオドを入れない例
require 'numpy'
a = Numpy.array([1,2,3], dtype=np.float32)  # array と ( の間に . を入れない

Numpy の使用

numpyは PyCall と同じ mrkn さんの作成によるgemがあります。 gem install numpy でインストールしましょう。
Rubyプログラムから使う時は、

require 'numpy'
np = Numpy
a = np.array([1,2,3], dtype=np.float32)

などとして使用します。 pyimport :numpy, as: :np とするより良いです。

# pyimport で numpy をロードする時の落とし穴
pyimport :numpy, as: :np    # これでも良いように思えるが...
a = np.array([1,2], dtype=np.float32)
b = a[0] + a[1]
p b                      # 3.0 が表示される。当たり前じゃないか
p b.class                # Object と表示される。 float じゃないんだ
p "bは3だよ" if b==3.0    # "bは3だよ" と表示される。当たり前じゃないか
p "bは3だよ" if b==5.0    # "bは3だよ" と表示される!? why!?  (b==5.0) は false じゃなくFalseだから。Rubyはfalseとnil以外は真です

numpy の : は 代わりに範囲演算子 .. / ... を使います

# 配列へのアクセス例
m = np.arange(0,100).reshape(10,10)   #まずnumpyの配列を作ります
      => array([[ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9],
                [10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
                [20, 21, 22, 23, 24, 25, 26, 27, 28, 29],
                           ...
                [90, 91, 92, 93, 94, 95, 96, 97, 98, 99]])

# 特定の行、列へのアクセス
m[2,1]  => 21    # m が RubyのArrayだったら [20, 21, ... 28, 29] となる

# 最後の行へのアクセス pythonだと m[-1,:] と書くところ
m[-1] => array([90, 91, 92, 93, 94, 95, 96, 97, 98, 99])

# 最後の列へのアクセス pythonだと m[:,-1] と書くところ
m[0..-1,-1]  => array([ 9, 19, 29, 39, 49, 59, 69, 79, 89, 99])

# 部分へのアクセス python だと m[5:7,2:4] と書くところ
m[5...7,2...4]  => array([[52, 53],
                           [62, 63]])

# python のスライスを使うこともできます
# 6行目から最後の行までの偶数行の、3列目から5列目までを選択する例
sl = PyCall::Slice.(6,-1,2)     # 6行目から最後の行まで2行ごとに行を選択するためのスライスを作成
m[sl, 3..5]   => array([[63, 64, 65],
                       [83, 84, 85]])

Pythonモジュールを読むディレクトリの指定

PythonがデフォルトでサーチしないディレクトリにPythonモジュールが書かれたファイルを置く場合、次のような感じで置いたディレクトリを指定する

PyCall.sys.path.append(__dir__)   # Rubyプログラムと同じディレクトリにある場合
hoge = PyCall.import_module('hoge')

PyCall.sys.path.append('/home/boo/foo')   # ディレクトリを指定する場合
woo = PyCall.import_module('woo')

Ruby のクラスの中で PyCall を使う

クラスの中で、あるメソッドに "pyimport :tensorflow, as: :tf" のように書いても "tf" は別のメソッドの名前空間には存在しない。
次の2つの例のいずれかが使える

class Boo
  module Py
    extend PyCall::Import
    pyimport :tensorflow, as: :tf
  end
  ...
  tf = Py.tf    # 記法1 クラスの中で"tf"のインスタンスをたくさん作るならこの記法が見やすい?
  ...
end

class Foo
  ...
  tf = PyCall.import_module(:tensorflow)    # 記法2
  ...
end

Ruby プログラムの中に Python の関数を作る

2種の記法例で、引数を倍にする関数を示します

#記法 1
PyCall.exec(<<PYTHON)
def double(object):
  return object * 2
PYTHON

a = 5
p PyCall.eval("double(#{a})")   # -> 10
c = "'!!'"
p PyCall.eval("double(#{c})")   # -> "!!!!"

#記法 2
double = PyCall.eval(<<PYTHON)
  lambda object: object *2
PYTHON

a = 5
p double.(a)   # -> 10
c = '!!'
p double.(c)   # -> "!!!!"

Ruby プログラムの中に Python のクラスを作る

次のサイコロのクラスを参考にしてください。PyCall.exec(script) はPythonのscript を実行します。
でもRuby プログラムの中に大きなクラスを作るのはデバッグが面倒なのでおすすめしません。大きなPythonのクラスを作る時は別ファイルに作って、Pythonでデバッグするのが良いと思います。

PyCall.exec(<<PYTHON)
import random
class Die():
  def __init__(self,x):            # サイコロを作るよ
    if x == 0:
      self.val = self.roll()       # 0 が与えられたら最初の目はランダムに決める
    else:
      self.val = x                 # 最初の目を指定された値にする

  def roll(self):
    self.val = random.randint(1,6)  # サイコロを転がすよ
    return self.val

  def look(self):
    return self.val               # サイコロの目を確認するよ

  def change(self, x):
    self.val = x                  # サイコロの目を指定された値にするよ
    return self.val
PYTHON

die = PyCall.eval('Die').(4)     # PyCall.eval('Die').new(4) とも書ける
die.look        # -> 4
die.roll        # -> 1
i = 5
die.change(i)   # -> 5

set

Python のsetオブジェクトに対する演算子、 &, |, -, ^ , > をRubyでも使うことができる

# Python の set オブジェクトを作る
set1 = PyCall.eval("{1,2,3,4}")
set2 = PyCall.eval("{0,2,4,6}")

# set オブジェクトの演算例
set1 & set2     # -> {2, 4}
set1 | set2     # -> {0, 1, 2, 3, 4, 6}
set1 - set2     # -> {1, 3}
set1 ^ set2     # -> {0, 1, 3, 6}
set1 > set2     # -> false

TensorFlow Eager Execution

TensorFlow の バージョン1.5からデフォルトで使えるようになったEager Executionは python コードの冒頭に次のようなおまじないを書きます。

# Eager Execution を使う python のコード冒頭部
import tensorflow as tf
import tensorflow.contrib.eager as tfe
tfe.enable_eager_execution()

私には理屈がわからないのですが、これを単純にpyimportに置き換えてもrubyでは動きません。次のように書く必要があるようです。

# Eager Execution を使う ruby のコード冒頭部
pyimport :tensorflow, as: :tf
tf.contrib.eager.enable_eager_execution()

余談になりますが、 Eager Execution を使うとグラフを動的に変えることができるし、irbやpryのデバッグ中にtensorflow オブジェクトの中身を簡単に確認できてよさげです。

#  Eager Execution でtensorflow オブジェクトを操作する例
x = [[1.0, -1.0], [-1.0, 1.0]]
y = [[0.25, 0.5], [0.75, 1.0]]   # xとyはrubyのArray
tm = tf.nn.tanh(tf.matmul(x,y))  # rubyのArrayを何も考えずにtensorflowの計算に使える
p tm       # 結果は随時確認できる
           # <tf.Tensor: id=8, shape=(2, 2), dtype=float32, numpy=array([[-0.46211717,-0.46211717],[ 0.46211717,  0.46211717]], dtype=float32)>
numpy_m = tm.numpy()        # numpy へのキャストも簡単

with

PyCall.with() を使う

# Python の io.open と with を使って、ファイルの各行を空行が出てくるまで読む例

a = []    # ファイルの行を格納するArray
file_name = 'hoge.txt'
PyCall.with(io.open(file_name, "r")){|fp|     # fp は Pythonのファイルポインタです
  while (line = fp.readline()) != ""          # readline()はPythonの io::readline です
      a << line
  end
}   # with を使ってるのでファイルをcloseしなくて良い

キーワード引数

キーワードで引数をわたす Pyrhon のメソッドには、ハッシュで引数を渡す

# Python では w2v.Word2Vec(st, size=200, sg=1) と書くところ
w2v.Word2Vec.(st, {size: 200, sg: 1})

キャスト 型変換

  • Python のオブジェクトから Ruby のオブジェクトへ

    • タプルから: to_a で RubyのArray(配列)が作られる
    • リストから: to_a で RubyのArray(配列)が作られる
    • 辞書(dict)から: to_h で Rubyのハッシュが作られる
  • Python のオブジェクトを Pythonの タプル、リスト、辞書へ

    • タプルへ: PyCall.eval("tuple(hoge)") もしくは PyCall::Tuple.(hoge)
    • リストへ: PyCall.eval("list(hoge)") もしくは PyCall::List.(hoge)
    • 辞書(dict)へ: PyCall.eval("dict(hoge)") もしくは PyCall::Dict.(hoge)
# numpy オブジェクトを Python の リストにする例
PyCall::List.(np_array)
# numpy オブジェクトを Ruby の Array にする例
PyCall::List.(np_array).to_a
  • Ruby から Python のオブジェクトを作る
PyCall::List.([0,1,2])                    # Ruby の Array から Pythonのリストを作る
PyCall::Tuple.([0,1,2])                   # Array から タプルを作る
PyCall::Dict.({zero: 0, one: 1, two: 2})  # ハッシュから 辞書(dict)を作る
# Python の Slice オブジェクトの作成と使用例
sl = PyCall::Slice.(1,3,nil)                 # slice(1,3,None) ができる。 nilは省略可
np.array([0,1,2,3,4], dtype=np.float32)[sl]  # -> array([1., 2.], dtype=float32)

辞書(dict)にイテレータを使う

each は辞書で使える。使えないイテレータは to_h して使う

python_dict.to_h.delete_if{|k,v| v > 1.5}

タプルにイテレータを使う

タプルに each や inject や zip みたいなRubyのArrayのメソッドが使いたい!って時は、to_a でArrayにする

python_tuple.to_a.each{|x| p x}

タプルの戻り値

Python のオブジェクトがタプルで戻り値を返す場合、to_a で配列に変換して受け取る

a, b = pythonObj.tuple_return_method.(arg).to_a

内包表記を使う

PyCall.eval() を使う

size = 3
x = PyCall.eval("[ i*2 for i in range(#{size}) ]")       # -> [0,2,4]
 # ここで x は to_a しないとRubyのArrayではなくPythonのリストであることに注意

Ruby と Python を結びつけるその他のアプローチ

なぜPyCall

tensorflow を使うため初めて Python に触れ、tensorflow みたいなフレームワークには Python がぴったりだよな、と思いつつ Python でいろいろプログラムを書いてみるものの、 Ruby のほうが書いてて楽しいし、慣れてるせいもあって効率も良い。機械学習のプログラムを書くと言っても、tensorflow のグラフまわりのコードは全体の1割か2割位で、データを収集したり加工するコードのほうが遥かに多いんだ、ということが判って私は機械学習のプログラムを基本は Ruby で書いて tensorflow まわりのコードだけ Python で書くことにしました。でもその場合 Ruby と Python での間でデータを どのようにやり取りするかが問題だよな。 という経緯で PyCall を使ってみたのですがなかなか具合が良いです。

謝辞

素晴らしいライブラリをご提供くださっている mrkn さんに改めて感謝の意を表します