はじめに
みなさんこんにちは。エンジニアの佐藤です。今回はTensorflowの話を書かせていただきたいと思います。
みなさんもきっと既にご存知でしょう「Tensorflow」、ディープラーニングのフレームワークとして最もポピュラーなもののひとつで、昨今のAIブームの折、その応用技術を身に着けたいと思っている方もきっと多いことでしょう。筆者もその一人です。ポピュラーでありますから、ちょっとググればさまざまな事例や解説が山のように出てきます。このいくつかを読めば、だいたい勘所がわかるだろう。そう筆者は思っていました。 ところが、、、読めども読めども今ひとつよくわからないのでした。いえ、大体はわかるのですよ。筆者は機械学習についての基本的概念は理解しているつもりで、ニューラルネットワークも、業務で取り組んできた経験があります。なのでTensorflowの事例も、概念や考え方については何の疑問もありません。 わからないのは、Tensorflowがどうやって、人間の思考をコードに落としているかです。つまりプログラムがはいスタートとなってから、CPUがどういう順番でライブラリの中を駆け巡って、最終的なゴールにたどり着くのか。その実行フローのイメージが、まるでつかめなかった。しかし、公式サイトをほとんど読了したある日、ようやくわかったような気がしました。 おそらくTensorflowの世界に足を踏み入れた時、私と同じ悩みを抱えるシステム開発系の開発技術者は結構多いのでは?と思い、このブログを書かせていただきました。
ミニマムで考えてみる
私の理解では、機械学習とは「データでアルゴリズムを削り出す」作業です。難しい話は他へ譲るとして、ミニマムな事例を考えてみましょう。
「1 + x = 2。 xに入る数字は?」
言うまでもなく、答えは1です。我々はすぐにわかります。しかし、「データでアルゴリズムを削りだす」ポリシーを貫くとすると、どうでしょうか。 「何かを足すが、それはいくつなのか?」というのが課題で、データとはすなわち「足す相手が1だった場合、足し算の結果は2になる。」という事前情報です。 この事前情報があれば、試行錯誤が可能になります。「10を足すのかな?」「0.5を足すのかな?」というトライアルが計画でき、このトライアルと事前情報を比較することで、「2であるべき足し算の結果が、12になってしまった。10多かった。」「同、1.5になってしまった。0.5少なかった。」という評価があり、評価を繰り返しフィードバックすることで、「答えは1」に近づくことができます。 少々くどいですが、機械学習のミニマムストーリーができました。
では、機械学習のフレームワークであるTensorflowで、やってみましょう。 「やってみる」とはつまり、以下のような処理計画の実行のことです。
- この課題と事前情報をインプットして、
- トライアルと評価結果のフィードバックを繰り返し行うプログラムフローを記述し
- 実行して動作の過程と結果を観察すること。
プログラム開発の経験がある皆さんなら、プログラムの構想はすぐに立つでしょう。そう、終了条件を定めてループで回せばいいのですよ。
そしてですが、Tensorflowでこのミニマムストーリーを記述すると、こうなります。
1 import tensorflow as tf 2 3 x = tf.constant(1.0) 4 y_true = tf.constant(2.0) 5 6 w = tf.get_variable('w', shape=()) 7 y_pred = x + w 8 9 loss = tf.losses.mean_squared_error(labels=y_true, predictions=y_pred) 10 optimizer = tf.train.GradientDescentOptimizer(0.1) 11 train = optimizer.minimize(loss) 12 13 with tf.Session() as sess: 14 sess.run(tf.global_variables_initializer()) 15 while True: 16 _, loss_value, val_w = sess.run((train, loss, w)) 17 print('loss:{} w:{}'.format( 18 str(loss_value), 19 str(val_w) 20 )) 21 if loss_value < 0.01: 22 print('END') 23 break
「わかるような、わからないような」と感じられる方も多いのではないでしょうか。私も今でこそ課題設定とコードの対応関係がはっきりわかりますが、初めはさっぱりわかりませんでした。疑問の中心は、「試行錯誤の過程では、事前情報の参照とトライアル結果の評価があり、想定値(この場合は1へ近づいていく数)の修正があるはずだが、唯一のループ処理(15行目〜)の中にはこれらを実行している気配がない」点です。そんなはずはありません。当然仕組まれています。
順番に見ていきましょう。最初の行は
3 x = tf.constant(1.0) 4 y_true = tf.constant(2.0)
で、これは「事前情報のインプット」に相当する処理なのですが、この行の処理はプログラム処理言語の「代入処理」 ではありません。 実はこの行「x が参照された時、常に数値1.0を供給する約束をするが、その供給処理の実行はこの時点では保留」という意味を持っています(y_trueについても同様)。どうしてこんなに回りくどいことになっているのか。それはその後を見ていくと徐々にわかると思います。
いきなり?なことを言ってしまったかもしれませんが、次も結構敷居の高い内容です。
6 w = tf.get_variable('w', shape=())
get_variableだから、wは変数です。しかし、このget_variableで返されるw、 ただのプログラム言語の変数ではありません。 「オプティマイザが調整できる変数」なのです。オプティマイザについてはこのあと解説します。
そして次が、Tensorflowの中でもCoreと呼ばれる基本機能のひとつで、Tensorflowを理解する上での肝(と筆者が思う部分)です。
7 y_pred = x + w
この行は、「足してみる」というトライアル内容に相当する部分です。が、 ここで足し算が実行されるわけではなく、y_predに計算結果が代入されるわけでもありません。y_predに代入されるのは、上記の通り固定的に1.0が返されるxと、他から調整されるwとの足し算を「将来実行する約束」です。 「+」演算子はPython言語の機能で再定義され、元々の算術演算子とは違うものになっているのです。
次の行は関数実行で、その役割はトライアル結果の数値化を「将来実行する約束」を返すことです。
9 loss = tf.losses.mean_squared_error(labels=y_true, predictions=y_pred)
「mean square」ですから、簡単に言えば、その数値化方法は正解とトライアル結果の距離の計測であり、引数もその通りの内容となっています。引数はいずれも「数字を出力する約束」のオブジェクトで、なので結果は必然的に、変数lossに代入されるのは、またしても「評価を将来実行する約束」です。
次の行はアルゴリズムの選択です。
10 optimizer = tf.train.GradientDescentOptimizer(0.1)
まぁ、よくあるまともなアルゴリズムですということで、これ以上は追求しないことにしたいと思います。
これまで「約束、約束」と連鎖してきましたが、次でようやく終着点です。 次の行は直前行で選択したアルゴリズム(GradientDescentOptimizer)で、「最適化」を「実行する約束を」するメソッド呼び出しです。
11 train = optimizer.minimize(loss)
ところで、何を最適化するのでしょうか?それは試しに足してみる数値「w」以外にありません。しかし、オブジェクトoptimizerは、変数wを参照できるのでしょうか?普通にプログラムを見ているとできないような気がしますが、それが可能なのです。 これまでつないできた「約束の連鎖」が、optimizerとxをつないでいるのです。 この部分がTensorflowのサンプルを読むときの一つの重要留意点と筆者は思っています。なんとなく代入が繰り返され、その終端変数だけポンと後処理に回されることが多いのですが、実はこの 終端変数には、これまで代入されてきた変数がすべて、背後で紐づけられているのです。
では、約束の連鎖を駆動するのはいつなのか?Session.runがそれにあたります。このメソッドの引数に列挙された「約束」が、紐解かれ、実行されるのです。 Session.runは2箇所で実行されています。
14 sess.run(tf.global_variables_initializer()) ... 16 _, loss_value, val_w = sess.run((train, loss, w))
14行目はTensorflowで実際の計算処理をスタートする際のおまじないと思ってスキップしてください。 本丸は16行目です。ここで引数に渡されるのは train, loss, w で、いずれも何らかの計算を実行する「約束」です。 Session.runでは、その約束を実際に実行し、最後の計算結果を戻り値で出力します。 この場合
- trainはopitimizer.minimizeを実行し、loss値を小さくするためにloss値に関する唯一の変数wを変更します。(戻り値は捨てられます)
- lossは、この変更されたwでloss値を計算した結果を出力(loss_value)し、
- wは、この変更されたw自身を、戻り値として出力(val_w)します。
そしてこの trainを繰り返し実行することで、変数wが徐々に調整され、つまり機械学習アルゴリズムが良くなっていく というのが、いわゆる「機械学習モデルのトレーニング」そのものなのです。
このコードを実行すると、結果は以下のようになります。(実際の数値はwの初期値によって異なり、この場合初期値はランダムですので、場合によって違います。)
loss:4.47416 w:-0.692177 loss:2.86346 w:-0.353741 loss:1.83262 w:-0.082993 loss:1.17287 w:0.133606 loss:0.750639 w:0.306885 loss:0.480409 w:0.445508 loss:0.307462 w:0.556406 loss:0.196776 w:0.645125 loss:0.125936 w:0.7161 loss:0.0805993 w:0.77288 loss:0.0515836 w:0.818304 loss:0.0330135 w:0.854643 loss:0.0211286 w:0.883715 loss:0.0135223 w:0.906972 loss:0.00865427 w:0.925577 END
wは、我々人間が一瞬で見抜いた答え「1」に徐々に近づき、十分(loss < 0.01)近づいたところでプログラム終了です。
これまでの過程で、Tensorflowが計算する約束を「一旦実行保留のまま配置」し、のちにそれらの約束を実行することで目的の計算処理を実行していることがおわかりいただけたと思います。実際には機械学習の課題はもっともっと複雑なのが普通ですし、実装も様々に高抽象度化されていますから、ここまでコードフローを細かく気にする必要は無いことが多いのが現実でしょう。しかしそれらの場合も、地下で動いている仕掛けの基本は同じはずです。この基本を押さえておくことで、目に見えているコードの実行順序をより的確に判断できるようになるのではないかと思います。
ところで、なぜこんな回りくどい実装になっているのでしょうか。筆者の考えでは、それは「実装を変更することなく分散処理環境へ移行するため」だと思います。機械学習モデルのトレーニングは、今日ではGPU上で実行することが一般的です。しかし小規模な試行錯誤はCPU上で実行されるでしょうから、これまで見てきたように計算の約束をTensorflowのオブジェクトの相互参照という形で一旦表現し、Session.runとなったところで適切な実行環境へそれぞれ「展開」できるようになったのでしょう。
最後まで読んでいただき、ありがとうございました。