Sat Dec 07 2019

参加するアドベントカレンダーが無かったので今作りました. これは Shell Script Advent Calendar 2019 の 7 日目の記事です.

日記 - Python 以外でディープラーニングしたい

まえおき

機械学習界隈で、ことディープラーニングとなると、Python を書かないとまともなプログラムが組めない。 これは大変、おかしな事態である。 ディープラーニング以外であれば必ずしも Python に限らなかったはずだ。 SVM を動かすのに必ずしも scikit learn である必要はなく、libsvm でも liblinear でもよかった。 それは単体のコマンドとして提供されているので、シェルが動く環境なら試すことが出来た。 シェルコマンドとして提供されていさえすれば、大抵の環境で触ることが出来る。

ところで、ディープラーニングとは順伝播のことを考える限り、基本的には一列に前から後ろにデータを流す処理に他ならない。 それはシェルスクリプトのパイプによく似ている。 とは言え、途中で枝分かれをしたり合流をしたくなる。 それはディープラーニングであっても、シェルコマンドであってもだ。 ましてやディープラーニングでは逆伝播などという、逆流をしたいというのだ。 シェルスクリプトではまんま逆流をしたいということはなかなか無いだろうが、しかしそのような機構が提供されていないわけではない。 名前付きパイプという機能がある。

ここで名前付きパイプの説明を始める

( echo 1 ) | ( head -1 | awk '{ print $1 * 2 }' )

これはパイプを通じて左から右にただデータを流す例だ。 1 というデータを流して、それを二倍して出力している。 パイプ | の右側からは左側から流れるデータのサイズがわからないので head -1 によって、データが一行に限られることを明示している。

mkfifo tunnel

これは tunnel という名前のパイプを作る。 パイプとはまさに | のことだが、tunnel はあたかもそういう名前のファイルのように見える。

echo hello > tunnel

これは

echo hello |

に相当する。| の右側には通常、入力 hello を受け取る役が必要なはずだ。 だからさっきの echo hello > tunnel は受け取る役が現れるまでシェルをブロックする。 そこでまた別なターミナルを開いて、 tunnel を受け取ってみる。

cat tunnel

これはまさに

| cat

に相当する。パイプは無事、入力から出力へと通じ、パイプへの入力のブロックは解消され、出力側には hello の表示がなされる。

この 名前付きパイプ は今、ターミナルさえも跨って入出力が行われた。 ということはだ。 通常のパイプ | の後ろから前へ跨ることすら、容易に実現するのではないか。 試してみよう。

( echo 1; cat tunnel >&2 ) | ( head -1 | awk '{ print $1 * 2 }' > tunnel )

2 と出力されたはずだ。 この出力はパイプ | の左にある cat tunnel より行われた。 信用できないならさらにこれを加工して、

( echo 1; cat tunnel | awk '{print $1 * 3}' >&2 ) | ( head -1 | awk '{ print $1 * 2 }' > tunnel )

と、三倍してみる。すると 6 と出力された。 どうやら本当に左側の cat tunnel からの出力であるらしい。

注意点が2つある。 左側で >&2 としてある。&2 は標準エラー出力のことである。 もしこれを省いてただの標準出力とすると | の右側にただ渡されるので、ユーザーには 6 の出力が見えない。 もう一点として、パイプ | の右側で初めに head -1 してあるが、これが無いと困ったことになる(かもしれない)。 左側では echo 1 が終わってもその後に cat tunnel しようとする。 パイプの右側はパイプの左側の cat tunnel による標準出力を期待して待つ結果、ブロックし、プログラムが終わらないかもしれない。 というかたぶん終わらない。

最近やってる話

長い前置きだったが。 要するにだ。 シェルスクリプトは普通にパイプを使えば一列に左から右へのデータのストリームが表現できるし、 そのつもりになれば、枝分かれだって合流だって、逆流だって何でも出来る。 じゃあ、シェルスクリプトでディープラーニングしてみようというのも自然な発想だ。

ディープラーニングにおけるノードとして次のようなものがある。

ディープラーニングのフレームワークは例えばこれらを一列に並べる。 一列に限らないけど、大抵の場合は一列だ。 関数と活性化を交互に並べて、一番最後に損失関数を置く。

シェルスクリプトがどうのこうの言ってたので、話は大体見えてると思う。 このノード1個1個をコマンドとして定義して、パイプでつなごうという話だ。

実装

cympfh/dlsh

入力は全てテキストで、頭にサイズが明記されいてる。 プロコン形式と言ったほうが分かる人には分かるだろう。 それから今は各入力は行列に限っている。 行列の高さはインスタンスの個数で、幅が各インスタンスの次元。

3 5
-5.5869226 2.384049 -3.234392 0.4512289 -5.5657387
-1.0818138 -1.1542585 3.5078242 1.8856971 -1.1181798
1.7649012 3.585418 11.034574 3.6150548 1.7477741

これは3つのインスタンスで各行が各インスタンスの5次元データを表している。

   cat << EOM > x
3 5
-5.5869226 2.384049 -3.234392 0.4512289 -5.5657387
-1.0818138 -1.1542585 3.5078242 1.8856971 -1.1181798
1.7649012 3.585418 11.034574 3.6150548 1.7477741
EOM

   cat x | linear --dim 4 -w fc
3 4
-0.06950497 0.0535341 0.024406236 0.146945
0.026739486 0.044979442 -0.06527659 -0.006031174
0.11721376 -0.09086315 -0.104535446 -0.10547167

linear コマンドは先述した全結合層である。 入力が5次元で --dim 4 なので、5 次元を 4 次元に、行列を掛け算することで変換している。 もっと厳密に言うとバイアスがあるので 6x4 行列を右から掛けることで 4 次元にしている。 まあ、だいたいそんな感じ。

fc というのがその行列を保存するファイル名。 ファイルが既に存在すればそれを読む(その場合は読めばサイズが分かるので --dim は不要)し、存在しない場合はランダムに初期化する(今は適当な正規分布からサンプリングして作ってる)。

   cat x | linear --dim 4 -w fc | sigmoid | linear --dim 1 -w fc2 | sigmoid
3 1
0.5011598
0.5012751
0.5012964

同じノリで。 linear で4次元にして、 シグモイド して、また linear で一次元にして、またシグモイドする。 二個目の linaer は1個目と違う行列を使うので違うファイル名 fc2 を指定している。

学習

さっきのコマンドで次のような値を出力してほしいと願うことにする。

   cat <<EOM > y
3 1
1
0
0.5
EOM

まず、 3x1 というサイズは先程のコマンドの出力のサイズと一致している。 これは一致する必要がある。 そして各値は今適当に決めた。 たぶん適当な値でもどうせ大丈夫だから。

mse で誤差を計測することにしよう。 mse は mean square error の略称で、目的の値との差の自乗の平均を意味する。 今データは 4 インスタンスあるので、それらの平均なんだけど、今はちょっと平均はとってないや。

   cat x | linear --dim 4 -w fc | sigmoid | linear --dim 1 -w fc2 | sigmoid | mse -t y
3 1
0.2490536
0.2509501
0.0000009557709

例えばこの1個目の mse は 0.2490... で大体 1/4 くらい。 これは mse への入力、つまり最後の sigmoid の出力が 0.5011598 だったのに対して期待が 1 だった差の 0.5 の自乗がそのくらいだから。

そして学習とはこの誤差をゼロにすること。

誤差逆伝播を行う。 各コマンドはその直後の誤差を受け取って、それをゼロに近づけるようにパラメータを変更すればいい。 それより後ろの誤差を前へ受け渡すのに、先ほど説明した名前付きパイプを使う。

   mkfifo A B C D

   cat x | linear --dim 4 -w fc -i D | sigmoid -i C -o D | linear --dim 1 -w fc2 -i B -o C | sigmoid -i A -o B | mse -t y -o A
3 1
0.25347143
0.2464506
0.000013369168

一度の誤差伝播では学習は完了せず、何度か繰り返し行うことで収束する。 また更新をどのくらいの大きさ行うかのパラメータ --lr もある。 この値は大きければ大きいほど更新を積極的に行うので収束を早めるが、大きすぎると値が壊れる(誤差爆発)。

while :; do
    cat x |
        linear --dim 4 -w fc -i D --lr 0.2 |
        sigmoid -i C -o D |
        linear --dim 1 -w fc2 -i B -o C --lr 0.2 |
        sigmoid -i A -o B |
        mse -t y -o A
done

出力される値がゼロに近づいていて、十分小さくなったら止めて良い。 もし値が大きくなっていったら、 lr の値が大きいということだから小さくする必要がある。

収束を確認したら、改めて出力を確認する。

   cat x | linear -w fc | sigmoid | linear -w fc2| sigmoid
3 1
0.9915571
0.029510455
0.4975897

   cat y
3 1
1
0
0.5

確かに期待する値に近くなっている。

ところで、パイプ | の直前と直後で逆伝播するのは頻出だろう。 そのためんだけに mkfifo をし、 -i-o と指定するのは面倒だ。 だから bp というコマンドを用意した。 これは | に代わって + というパイプを提供する。

cat x |
    bp linear --dim 4 -w fc --lr 0.2 + \
    sigmoid + \
    linear --dim 1 -w fc2 --lr 0.2 + \
    sigmoid + \
    mse -t y

と書ける。

感想

実用の域に達するためにはいくつか課題がある。

まずは速度面。 入出力が全てテキストベースなのはUNIX的だが、速度では不利かもしれない。 或いはテキストベースだとしてもストリーム的に処理するようにするだけでも違うだろう。

それから、なんと言っても逆伝播の記法。 最後に紹介した bp コマンドと + パイプはなかなか苦し紛れである。 パイプの直後から直前にデータを流す特別なパイプを発明した方が早く、そうなると結局専用のシェル自体を作ることになる。 なんだか本末転倒に思える。

しかしパイプをすっ飛ばし、コマンドを跨ぐような逆伝播をするような時に、名前付きパイプを利用出来るのは大変自然だし利点だと思う。 そもそも順伝播とはストリーム処理なのだから、シェルスクリプトで表現するのは自分にとってはあまりに自然で、何故誰もやらないのか分からない。 そんなに Python が好きなのか。

簡単な実験をするのにいちいち、プログラムを書きたくない、というのがある。 シェルスクリプトはスクリプトだからプログラムと言えばプログラムなのだが、あまりにも手に馴染んでいるので、プログラムを書いているという感覚がない。 シェルスクリプトで機械学習をするというのは、 マウスを動かしダブルクリックをする感覚でディープラーニングが出来る、と謳うソフトウェアと同じなのだと思う。