HELLO CYBERNETICS

深層学習、機械学習、強化学習、信号処理、制御工学、量子計算などをテーマに扱っていきます

Python利用者がJuliaに入門してみる

 

 

follow us in feedly

はじめに

正直Juliaを利用する機会は今のところありません。 それでも新し目の言語が、どのような機能を有しているのかとか、プログラミングのパラダイムはどこへ向かうのか、ということを掴みたいという思いで 近年話題のRustあるいはJuliaあたりは触れていたいなという思いが以前からありました(両者とも、ガッチガチな関数型言語ではないのだけど、関数型の思考を含んでおり、クラスを廃してポストOOP的な方面を切り開いていくのであろう、というふうに思ってます)。

RustかJuliaかは迷いましたが、結局はJuliaの方を触れてみることにしました。 Juliaは導入が非常に簡単でかつ、仮に現状の言語(PythonやR)を実際に置き換えていこうという場合にも、実際にその移行がしやすそうというふうに考えました(あくまでC++をRustに移行するのは、C++が担っている役割を考えるとかなり重い。一方、データ分析やちょっとアルゴリズムを試してみようとかいうレベルならば、現状PythonでやっていることをJuliaでやるようにする、というのは至って個人レベルでの話なので、そういう面でも始めやすかった)。Rustは次の機会あるいは必要に迫られたときに動き出せる準備はしておこうと思います。

とりあえずこれまではPython(とC++)を利用してきた私がJuliaに入門してみるという過程を軽く書いておこうと思い記事にしました。 同じようにPythonからJuliaに移ってみようかな、という人の参考になれば良いと思います。

関数の書き方

簡単な足し算、引き算、複数の戻り値

まずPythonで簡単な関数を書いてみます。 足し算をする関数、引き算をする関数、そして戻り値が複数ある関数を書いてみます。

def add(x, y):
    return x + y

def sub(x, y):
    return x - y

def add_sub(x, y):
    return add(x, y), sub(x, y)

一応、add_sub関数だけ実行してみて、その戻り値を確認してみると下記のように、2つの戻り値はタプルに格納されて返ってきます。

add_sub(5, 2)
# (7, 3)

タプルで返ってくることがわかっていれば、ret_add, ret_sub = add_sub(5, 2) と最初から対応する個数の引数を割り当てて受け取ることができるのでした。 この書き方はJuliaでもそのまま同様に行なえます。

function add(x, y)
    return x + y
end

function sub(x, y)
    return x - y
end

function add_sub(x, y)
    return add(x, y), sub(x, y)
end

と定義したのに対して、下記を実行すると

add_sub(5, 2)
# (7, 3)

とPython同様にタプルで返ってきます。Python並に簡単に書ける、というのを標榜するだけあります。

デフォルト引数とキーワード引数

pythonでもJuliaでもデフォルト引数が同じように設定できます。

function add(x, y=5)
    return x + y
end

add(4)
# 9

さて上記の関数呼び出しを使いこなすのは難しくありません。しかし、もっと引数が多くなってくるとその順番を覚えるのが大変になります。

そこでpythonでは関数の引数は自動的にキーワード引数となっており、呼び出し時に add(x=5, y=3) あるいは add(y=3, x=5) などとできました。 関数がオプショナルな引数をたくさん持っていても、よく使う引数名のみ覚えていれば、(順番を覚えていなくても)それをキーワードつきで渡すことができ便利というわけです。

しかし、Juliaでは通常、キーワード引数は適用されておらず、

add(x=1, y=6)

MethodError: no method matching add(; x=1, y=6)
Closest candidates are:
  add(::Any, ::Any) at In[2]:1 got unsupported keyword arguments "x", "y"

とエラーが吐かれてしまいます。キーワード引数を与える場合には、キーワード引数以降をセミコロンで区切る必要があります。

function add(x; y=5)
    return x + y
end

add(4, y=2)
# 6

ここでは y だけがキーワード引数になっていることに注意しましょう。もしすべてをキーワード引数にしたい場合は function add(; x, y=6) などと定義します。すると、xy もキーワード引数になり、かつ y はデフォルト引数を持つという定義が行なえます。

型指定

function add(x::Float64, y::Float64)
    return x + y
end

と書いた場合には、引数で許されるのは Float64 型だけです。整数型を入れるとエラーになります。型を指定しない場合は、入力された型に応じてjitコンパイルが実行されます。別の型が入ってくるたびjitコンパイルが走り、仮に定義した処理が行えないような型の場合、そこでエラーが生じます。同じような計算を別の型で定義し直すのは非常に面倒であるため、通常は型指定をせずに使うことになるでしょう。しかし、明らかに渡してほしくない型、例えば文字列型が入ることはありませんということであれば、例えば

function add(x::Real, y::Real)
    return x + y
end

などと指定することができます。整数型も浮動小数点型もReal という型のsubtypeであり、subtype以外を弾いてくれるように実装ができたということになります。 正直、この例ではあまり実感がわきませんが、自分で型を作ったりしていくと引数の型を制限するという機能は重要になってくると思われます。

クラスの書き方

これ以上基本的なことを長々と続けるのは面白くないので、とりあえずクラスの書き方に入ります。 そして結論を言うとJuliaにはクラスという概念がないので、クラスそのものを書くことができません。ただし、あたかもクラスのインスタンスであり、内部変数を持ち、メソッドを持つような何かを作ることはできます。しかし、Juliaの本来の機能を損なうような書き方になるため、基本的には推奨されないと思われます。

とりあえず題材として順伝播 fowrard メソッドと、重みを設定する set_weightメソッドが備わっている、内部状態 W を持つ全結合層クラスらしきものを書いてみましょう。

構造体に変数と関数をもたせる

Pythonで今回の課題を書くのは簡単です。 PyTorchやTensorFlowでは行志向で (batch, feature_dim) という形状がAPI上では利用されていますが、今回は単純に線形変換を $y = W x$ と行うような層を書いています。

class Dense:
    def __init__(self, in_dim, out_dim):
        self.W = np.random.randn(out_dim, in_dim)
   
    def forward(self, x):
        return self.W @ x
    
    def set_weight(self, W):
        self.W = W

dense = Dense(5, 3)
x = np.random.randn(5)
y = dense.forward(x)
y
# array([-1.18976837, -1.79377336, -0.88161864])

Numpyでは一次元配列は自動で一行の配列扱いなので、縦ベクトル的な扱いをしたい場合は別途 reshape(-1, 1) とするなどが必要です。PythonとNumpyに慣れている人にとっては何も難しくない話ですね。 上記のようなクラスと同じ振る舞いをするものをJuliaで実装しようとすると下記のようになります。

mutable struct Dense
    W::Matrix{Float64}
    set_weight::Function
    forward::Function
    function Dense(in_dim::Int, out_dim::Int)
        self = new()
        self.W = randn(out_dim, in_dim)
        self.set_weight = (W -> (self.W = W))
        self.forward = (x -> self.W * x)
        return self
    end
end

dense = Dense(5, 3);
x = randn(5);
y = dense.forward(x)

#3-element Vector{Float64}:
#  0.3219856388525193
# -0.3694496184657423
# -1.651604771936517

Juliaは線形代数の計算を強く意識しており、配列が縦志向でVectorという形式の取り回しがデフォルトで行われます。今回の計算結果もVector型で返ってきています。これはJuliaでは3行1列の配列とは別の型であるので注意が必要です(型指定などをあえてしっかり書いていこうとすると、Numpyに慣れている場合かなりエラーを吐かれます)。

numpy.arrayVector, Matrix 型との比較などは別途他の記事などに譲るとして、上記のコードの説明を続けます。 まずJuliaの構造体は基本的にイミュータブルなので、内部変数をあとで変更するような処理を行う場合は mutable struct として定義します。

その後、この構造体が持つ変数を書き下す必要があります。具体的な定義や実体はこの時点では必要ありません。 構造体のコンストラクタ function Dense を書くときに、その中でひたすらに各変数に値を代入していきます。まず構造体自身は new() で作成できます。その後、W には正規乱数で要素を埋めた Matirix を代入し、set_weightforward は関数型なので無名関数を代入します。そしてコンストラクタの戻り値を self にしておけば、上記の代入をし終えた構造体を Dense(::Int, ::Int) によって作ることができます。

このように書くと、ひずまずPythonと同じような形式でオブジェクトを取り回すことができます。 しかし、この書き方だと、Juliaの多重ディスパッチを十分に活用し切ることができません。 Juliaらしく実装するためには下記のようにするのが一般的です。

構造体に変数をもたせ、関数を分離する

mutable struct Dense
    W::Matrix{Float64}
    Dense(in_dim, out_dim) = new(randn(out_dim, in_dim))
end
function set_weight!(m::Dense, W) m.W = W end
function forward(m::Dense, x) m.W * x end

このコードではDenseという構造体にパラメータ W だけを持たせています。コンストラクタにはパラメータの次元を渡せるように実装しておきます。 そしてこれとは別にパラメータをセットする関数と、計算を実行する関数をそれぞれ実装するという形式を取ることにします。

ここで2つポイントがあります。まずset_weight! という命名です。関数の末尾に!がある場合には、引数に取った変数の中身を書き換えるという意味が込められます。例えば配列の末尾に値を追加するpush!append! 等も標準でこのように命名されます。 これによって関数の仕様を遡らなくても、関数名だけでその処理が変数の値を書き換えてしまう危険な処理であるのかが判断できるというわけです。

次に、関数の引数に Dense のインスタンスを渡すようにしている点です。クラスでの実装に慣れている人にとっては、結局のところこの部分が冗長に感じるところでしょう。 その関数がどのオブジェクトに紐付いたものであるのかを、クラスにメンバ関数をもたせるという仕組みでは、自動的に判断できます。 一方で、Juliaらしい実装ではそれを引数という形で明示的に与えなければならないと言っていることになります。これは面倒なことのようにも見えます。

しかし実際には、Pythonのクラスなどを見てみると

class Dense:
    def __init__(self, in_dim, out_dim):
        self.W = np.random.randn(out_dim, in_dim)
   
    def forward(self, x):
        return self.W @ x
    
    def set_weight(self, W):
        self.W = W

ご覧の通り、各メンバ関数の第一引数には self が与えられており、結局のところ関数に引数を明示的に与えているのです。これが C++ などの場合は暗黙的に与えられる仕様なので、第一引数には自動的に自分自身のインスタンスを入れてくれるのですが、兎にも角にも、実際には、関数の引数としてオブジェクトを渡していることに違いはないのです。

すると、表面上の違いは、単にクラスの中に関数を実装しているか、外に実装しているかの違い(関数の属するスコープの違い)と、使うときに obj.method(args) と呼び出せるのか method(obj, args) と呼び出せるかの違いになってきます。obj.method(args) はメソッドがどの obj の操作であるのかが一目瞭然で非常にわかりやすいのですが、これは一種の慣れで、method(obj, args) だって引数を見れば、その obj に対する操作であるというのはわかるはずです(といっても、左から右に文字を読む習慣である以上、前者の書き方のほうが、objを意識するのが若干早いだろうが)。

ここで多重ディスパッチという機能を活かすことを考えるとJuliaの書き方は理にかなっているのです。

最後に

今回はPythonで書くであろう標準的なやり方をJuliaで書くなら…という視線で見てきました。自分自身、今でも圧倒的にPythonのほうがよく使いますし、そもそもJuliaを実用しているわけでもないので多重ディスパッチの恩恵を多大に受けるようなコードのケースに触れられていないです。そういう場面に出くわし、まとまりがついたら記事を続けて書いてみようかと思います。型推論と多重ディスパッチを柔軟に使いこなせば、Pythonの手軽さとC++の関数オーバーロード及びテンプレートのような手段を同時に持ち合わせることができたということになりそうです。

また、所感的ですが、何でもよしなに型推論して曖昧なままコードを書けるPythonに加え(と言っても型アノテーションがあるのだが)、Juliaは型推論に任せた書き方も型を指定した書き方もでき柔軟性があると思います。そしてPython的な書き方もしようと思えばできるという点も見れば、エコシステムの規模を除けばすでに十分にPythonを代替しうる(そのエコシステムがPython最大の武器なのだが)ものだと思われます。加えて、Julia的な書き方では(Classがそもそも無いのですが)巨大クラス(巨大構造体)を書くのは骨が折れるので、自然と小さなモジュールに分けて書くスタイルが身につく気がします。