RejectKaigi 2017で『Rubyで機械学習を行うための「巨人の肩に乗る」別の方法』という発表をしました

RubyKaigi 2017 に CFP を出したところ残念ながら Reject されてしまい、この内容を発表する場が無いかなと考えていたところ、
RejectKaigi 2017 に発表の機会を頂きましたので、お話させて頂きました。

スライドは下記に公開してますので、ご覧ください。

www.slideshare.net

お話した内容は

Ruby機械学習をしたいと思った場合、現状では不可能なため、下記の三つの内容について Ruby コミュニティで取り組みが行われており、

  1. 既存のgemをなんとかすること
  2. Rubyのための仕組みをつくっていくこと
  3. 巨人 (Python) の肩に乗る方法

この中の現実的な解が「巨人 (Python) の肩に乗る方法」を実現しつつある PyCall になります。

PyCall の詳細は過去の記事をご覧ください。

www.s-itoc.jp

getnews.jp

Ruby機械学習を行うための「巨人の肩に乗る」別の方法』

とは

  • Ruby から PyCall を経由してPython の機能をを呼び出す」

という方法とは異なり、

  • Pythonのライブラリを Ruby に移植」

することで、Python のノウハウ、知見を Ruby で活用したい
そのためにはどうすれば良いか

というお話になります。

今回、移植の対象としてChainerを選択しました、理由は

  • tensorflow.rb はすでに移植作業が行われているため、対象外。PyCallでまだ動作していないChainerがいいのではないか
  • (GPU対応を保留にすれば)主な依存性が numpy に絞られる Chainer について、numpy 部分の機能を Numo::NArray に置き換える形で移植を行えば移植のハードルが比較的低い

になります。

移植の現実性

について検討したところ、Chainer 2.0 : 合計 86.3 Ks (本体 49.8 Ks/テストコード 36.4 Kstep) とかなりの規模になり、自分が過去に行なったRBPDF(TCPDFからの PHP => Ruby移植。Redmine のPDF出力で使用されている gem です。)が約15 Ksで移植作業に約2年かかったことから、86.3 Ks/(15 Ks/2年)=11年必要となり、仮に完成したとしても時代遅れになります。

そのため、自動で Python のコードを Ruby に変換するツールを探したところ、5つのトランスレータが見つかりました。

  • https://bitbucket.org/snej/py2rb/src/tip/py2.rb
    • 正規表現でパースしてRubyに置換する。精度甘めのため、小規模ソースコード量ならよいが、大規模だと修正が多すぎるため断念
  • Grumpy(Go running Python)
    • Goに変換して実行。実行(非移植)が目的のためメソッドがインライン展開されるため、機械に優しく人に優しくなコードになるため断念
  • py2js.py
    • Python AST(Abstract Syntax Tree: 抽象構文木)ベースでJavaScriptに正確な変換をする、テストコードの仕組みも充実しておりかなりよい
  • py2cpp
    • Python ASTベースでC++に変換、結構変換してくれる
  • HOLY
    • Python ASTベースでRubyに変換し、今回の目的に合致したのですが、POCが目的であり実装範囲が狭くそのままでは使えない。

ここで、py2js.py をベースに Python から Ruby に変換するツールをまず開発すればいいのではという結論になりました。

py2rb.py

というわけで、 py2rb.py という Python ASTベースで Ruby に変換するツールを作っています。

github.com

  • Python ASTベースで1行単位にRubyへ翻訳
    1. Python => Ruby (メソッド,クラス,変数) : 大分動作
    2. Python標準ライブラリ=>Ruby標準ライブラリ : (Chainer で使われている 73 モジュール のうち 8モジュールを仮実装)
  • ドメインが同じライブラリ間でPython=>Rubyに置き換え
    1. numpy => Numo::NArray (Chainer で使われている 116 メソッド のうち 8メソッドを仮実装)
    2. unittest => test::unit (Chainer で使われている 33 メソッド のうち 4メソッドを仮実装)

このように単純なソースコード変換だけでなく、下回りのライブラリのドメインが同じ場合、それに置き換えることで品質をある程度確保した移植を行うことを考えています。

主な特徴として、下記を備えています。

  • Python 3.5 => Ruby 2.4 変換
  • クラス継承
  • モジュール呼び出し(import => require)
  • PythonRuby のメソッドのキーワード引数差異吸収

py2rb.py を用いた移植作業の流れ下記を想定しており、このうち 2, 3の作業を支援できることを想定しています。

  1. 移植対象を決定 (移植可能か見極め)
  2. プロダクションコードを1行単位で移植
  3. テストコードを1行単位で移植 (テストコードが無いツールの移植はお勧めしません)
  4. テストをパスするようプロダクションコード修正 (機能が動くことを担保するために必要な作業)
  5. 必要に応じてリファクタリング (コードのメンテナンス性向上や、性能を確保するために必要な作業)

py2rb.py 開発方針として下記を考えています。

  • あくまで移植の支援ツール と割り切る (PythonRuby で言語思想の違いからどうしても非互換部分が避けられないため)
  • 移植対象が使用していない機能は保留
  • ライブラリ部分の変換定義をYAMLで指定 (変換ツール内部に手を入れなくても、ライブラリ部の変換定義を追加可能にするため)
  • Rubyの黒魔術でPythonとの互換性を向上 (互換性確保のため py-builtins.rb を用意)

これにより、私以外が py2rb.py を用いて Python のライブラリ・ツールをRuby に移植したいと考えた場合でも、ライブラリ変換定義のYAML定義を追加する作業を行い、上記 1, 4, 5 の作業行うことで移植作業が可能になると考えています。

実行方法

以下のように変換対象の filename と、filename から import 指定されているモジュール名を指定します。 (モジュールとして指定したソースコードは意図せずファイルを上書きする事を避けるため、変換は行いません。モジュールを個別に filename に指定して変換することを想定しています。)
※ ここでモジュール名の指定漏れがある場合、変換精度が落ちます。

$ ./py2rb.py
Usage: py2rb.py [options] filename [module filename [module filename [..]]]

実際に変換すると下記のようになります。

$ cat chainer/chainer/variable.py

def _check_grad_type(func, x, gx):
    def make_message(message):
        if func:
            detail = 'Function `{0}` ({1}) has a bug.\n'.format(
                type(func).__name__, func.label)
            stack = func.stack
            if stack:
                detail += 'Stacktrace of the function is below:\n'
                for line in traceback.format_list(func._stack):
                    detail += line
            detail += ''' (略)'''.format(type(func).__name__, func.label)
        else:
            detail = ''
        detail += message
        return detail

$./py2rb.py --include-require chainer/chainer/variable.py chainer/chainer/utils/argument.py

def _check_grad_type(func, x, gx)
    make_message = lambda do |message|
        if is_bool(func)
            detail = "Function `{0}` ({1}) has a bug.\n".format(type(func).__name__, func.label)
            stack = func.stack
            if is_bool(stack)
                detail += "Stacktrace of the function is below:\n"
                for line in (func._stack).to_a
                    detail += line
                end
            end
            detail += "(略)".format(type(func).__name__, func.label)
        else
            detail = ""
        end
        detail += message
        return detail
    end
  • pythonクロージャー (def 内 def) をlambdaに変換することで、 def make_message の外部スコープのfuncを参照可能にしています。
  • is_bool()を用意し、Pythonとの差 (Rubyでは 0, '', [], {} は True、nil(None), false は Falseだが、Python は全て False)を吸収します。

※ まだ変換定義の実装が不足しているため、未変換で Python のコードがそのままの部分が残っている例になります。

また、テストケースとしてサンプル変換を一括実行する形式でテストを行なっています。

./run_tests.py -a

およそ 200個のテストケースを用意して変換動作を確認しています。

テストケース期待値は、

  • python のコードがrubyのコードに変換される (変換後のrubyのコードを期待値として持つ)事

ではなく

  • pythonのコードの実行結果と変換したrubyのコードの実行結果が同一になること

で、意図した変換になることを確認しています。

PythonRubyの差の影響

Python の関数は Ruby と異なりObject なのですが、変換が難しい例として、全く同じに見える foo() が実は中身によって解釈が変わる場合があります。

  1. foo() : fooが関数の場合、メソッド実行
  2. foo() : fooがクラスの場合、Rubyのnew()
  3. foo() : fooがインスタンスの場合、Rubyのfoo.call()

これは正規表現では判断は無理でASTなら判断可能です。ただし、変数に代入されるともはやASTでも判断は無理です。
なので、変換可能な範囲で変換する形に割り切っています。

他に、py2rb.py で割り切った点として

  • 多重継承は未サポート: これは根本的に無理なので、移植対象選択時、多重継承の有無を事前確認することを想定。
  • クラス内クラス
  • 変数のスコープ範囲の違い
  • デコレータ : 可能な範囲のみ。

があります。そもそも、

  • Python はストリーム : メソッド定義時にコンテキストを保持
  • Ruby はオブジェクト(型) : クラス内ではコンテキスト非保持

という差があるのでなかなか難しいものがあります。

py2rb.py で気をつけた点として

  • 動く事: 動かないとどこが問題なのか探す(デバッグ)する必要があり、不要なロスの時間は避けたい。(互換性を保つための余分な定義は不要と判断後に削除すればよい。)
  • (移植が目的なので)ソースコードをなるべく綺麗な形で変換する事。
  • 非互換を吸収する為、ソースコードが汚くなりそうな場合はPython&Ruby互換用ライブラリとして実装。(大半が互換の範囲なので、不要と判断したら using を外せばよい。)
Ruby機械学習を行うためのまとめ
  1. 既存のgemをなんとかすること
  2. Rubyのための仕組みをつくっていくこと
    • PythonからRubyへ変換ツール(py2rb.py)を用意
  3. 巨人(Python)の肩に乗ること
    • Pythonのツールを移植する事でPythonでの知見を活用する
今後の予定
  • ドックフーディングとしてChainerの移植にTry.
  • 標準ライブラリ/unittest/numpy のYAML変換定義の実装完了(Chainerの対象範囲)
  • RubyDeep Learning(本当にやりたい事)

という形で締めくくったのですが、質疑応答で Red Data Toolのプロジェクトで、すでにChainerの移植作業が始まっているという情報を頂きました。

私の手法は Python のコードの1行単位の移植になるため、Rubyらしいコードではなく Python ぽいコードになるため、最初から Ruby らしく書いた方がいいのではないかという指摘を頂きました。

また、手で作業を行なった場合、11年と見積もった作業も、一人ではなく複数人でやれば現実的な時間になるのではというお話も頂きました。
今の作業を保留して、そちらに Join すべきか、今の作業そのままやるべきか、まだ決めかねていますが、同じ目的の人がいるんだなと心強くなりました。