皆さんこんにちは
GPU使って学習機動かしてみたいけど、CuDAとか入ってないしなぁ、とか考えていたら、WebGLを利用して計算をGPUにやらしてしまおうというライブラリがあるとのこと。
今回はそんなdeeplearn.jsをつかって、何ができるかっていうのを検証してみました。
いやぁ、さすがはGoogleさん傘下のプロジェクトだけあって、APIドキュメントが絶妙でして、「書いて覚えろ!」みたいな側面が強くて、なんというか...
とにかくやってみましょう
deeplearn.js
deeplearn.js は機械学習をブラウザ上で動かすためのライブラリですが、ディープラーニングに特化しているわけではなく、どちらかと言うとnumpyとTensorFlowの一部をJavaScriptに移植したという感じが強いです。
kerasのように層を追加していくというよりは関数を組み立てていくというイメージのほうが強い気がします。
また、TypeScriptで書くことができるので、ついでにTypeScriptの練習にもなります。
モチベーション
私が、deeplearnjsを使用するモチベーションはだいたい以下のような感じです
- JavaScriptで機械学習したい
- オンボのグラボでもGPU使って機械学習機を動かしたい
- TypeScriptを使ってみたい
- TensorFlowを使ってみたい
deeplearnjsを始める
deeplearnjsのインストール
早速deeplearnjsを使っていこうと思うのですが、なんと、starter projectという、deeplearn.jsを始めるためのテンプレートが用意されています!
https://github.com/PAIR-code/deeplearnjs/tree/master/starter/typescript
こいつを適当なところにコピって、そこで作業を開始しましょう。
コピったpackage.json
のあるディレクトリ上で、
$ yarn prep
と打ち込んで必要なパッケージを導入する
自分はDockerを使うけど、最近のnode公式イメージはyarnがはじめから入っているので、イメージをそのまま使っています
以下のような感じですね。
$ docker run -it --rm -v `pwd`:/srv node:alpine sh
# cd srv
# yarn prep
※windowsのshared folderではbinのリンクができないので、VMたてて、その中で作業する必要があるかも
package jsonのdeeplearnのバージョンが古い可能性があるので、作業時の最新版のパッケージを入れるのに
# yarn add deeplearn@0.4.1
も実行しました。
コード監視する
start projectのpackage.json にはすでに必要なコマンドが登録されています。
以下のコマンドを叩くことで、ファイルの更新にともなって、自動的にビルドしてくれるようになります。
$ yarn watch
Dockerを使っている場合はpackage.json
に--poll
オプションを追加する必要があるかもしれません
"scripts": {
"prep": "yarn && mkdirp dist",
- "watch": "watchify main.ts -p [tsify] -v --debug -o dist/bundle.js",
+ "watch": "watchify main.ts -p [tsify] -v --debug -o dist/bundle.js --poll",
"build": "browserify main.ts -p [tsify] -o dist/bundle.js",
"lint": "tslint -p . -t verbose",
実行する
監視が動くとdist/bundle.js
が生成されるので、あとはindex.htmlをブラウザで読み込んでやりましょう。
index.html
は一行だけで書かれていて、
<script src="dist/bundle.js"></script>
これで、TypeScriptからコンパイルしたJavaScriptファイルを読み出して、内容を実行しています
NDArrayおよびmathの使用
deeplearn.jsを使う際に、まず知っておかなければならないのはNDArrayという多次元配列を扱うクラスと、mathという数値計算ライブラリの使用方法です。
NDArray と math
NDArrayはn階のテンソルを表すためのクラスです。0階のテンソルはスカラー(Scalar
)、1階のテンソルはベクトル(Array1D
)、2階のテンソルは行列(Array2D
)になります。ナンノコッチャと思った場合は、とりあえずn次元配列だと考えればよいかと
一方で、mathはNDArray
同士の演算をサポートしてくれるライブラリであり、更にバックエンドでGPUを使った計算をしてくれる便利な道具です。
まずはこれらを使ってどのような計算ができるか見てみましょう。
今回はここにおいたコードを、calc.ts
として保存した後、package.json
のmain.ts
をcalc.ts
に書き直してからあらためてwatchしています。
NDArrayの定義
まずは単純にNDArrayを定義しましょう。
// n階テンソルの定義
const a = Scalar.new(4)
const b = Array1D.new([2, 3])
const c = Array2D.new([3, 2], [1, 2, 3, 4, 5, 6])
ここで、Array2D の定義のときに、2つのパラメータをしていますが、この時の一つ目のパラメータはshapeと呼ばれる、配列の形を表しています。
shape=[3,2]
の場合、配列は[[1, 2], [3, 4], [5, 6]]
となっているのに対し、shape=[2, 3]
のときは配列は[[1, 2, 3], [4, 5, 6]]
となります。
データの取り出し
NDArray からデータを取り出すときは、基本的には非同期関数によって取得するようになります。TypeScriptではJSのasync/await
が使えるので、まずは以下の雛形を作っておきます。
async function calc() {
// 計算処理
}
calc()
これから先の処理は全て// 計算処理
の部分に書いていきます。
まずは現在のデータをコンソールに出力してみましょう。
// データの出力
console.log(await a.data())// Float32Array [4]
console.log(await b.data())// Float32Array(2) [2, 3]
console.log(await c.data())// Float32Array(6) [1, 2, 3, 4, 5, 6]
足し算
演算をしてみましょう。
ここからはmathライブラリを使用します。
// スカラーとテンソルの足し算
const d = math.add(a, b)
const e = math.add(a, c)
console.log(await d.data())// Float32Array(2) [6, 7]
console.log(await e.data())// Float32Array(6) [5, 6, 7, 8, 9, 10]
// テンソル同士の足し算
const f = math.add(c, b)
console.log(await f.data())// Float32Array(6) [3, 5, 5, 7, 7, 9]
ここで見てわかるように、math.add
をしただけでは計算実施用のオブジェクトが帰るだけで、計算結果自体はまだ出ていません。
data()
メソッドを実行することで、初めて計算が実施され、値が取り出されます。
テンソルにスカラーを単純に足すと、テンソルの各要素にスカラーが足されています。
テンソル同士の足し算はちょっと厄介ですが、b=[2, 3]
をc=[[1, 2], [3, 4], [5, 6]]
に各要素に順次足している感じですね。
掛け算
掛け算は高校でスカラー積とか行列とベクトルの掛け算とかをやっているとイメージがつかみやすいかもです。
// テンソル同士の掛け算
const g = math.multiply(b, a)
const h = math.multiply(c, a)
const i = math.multiply(c, b)
console.log(await g.data())// Float32Array(2) [8, 12]
console.log(await h.data())// Float32Array(6) [4, 8, 12, 16, 20, 24]
console.log(await i.data())// Float32Array(6) [2, 6, 6, 12, 10, 18]
// テンソル同士の積
const j = math.matrixTimesVector(c, b)
console.log(await j.data())// Float32Array(3) [8, 18, 28]
掛け算の中で、math.multiply(c, b)
は感覚的には不思議な感じがしますね。
チュートリアルの学習機
機械学習の意図
PHPカンファレンスのときに喋ったのですが、機械学習というのは、結局のところ適当な関数を現実のデータに従って修正して、何らかの法則性を見つけ出し、その法則に則って未知のデータを予測するというものです。
具体的な手順としては
- 適当な関数を仮定する
- 関数の中で変更可能なパラメータを、教師データと呼ばれるサンプルを元に調整する
という、至って簡単なものです。
別にこの時設定する適当な関数がニューラルネットでできている必要すらなく、自分が最適だと思う関数を適当に選んでやればいいわけです。
関数の構築
ではdeeplearn.jsではどうやればいいのかという例題が、プロジェクトに用意されています。
https://github.com/PAIR-code/deeplearnjs/tree/ccd491b5cbc59c86b5741254f5dadccf174b47b8/demos/ml_beginners
私が手習いで作ったものがこちらにおいてあるので、今回はこれを元に解説を行います。
ここでは4つの入力と出力のセットを用意し、
y = ax^2 + bx + c
という二次関数に従ったデータであると仮定して、その4つのデータを使用して、各パラメータ$a, b, c$を調整しています。
さて、これをdeeplearn.jsのコードに落とすと、こんなふうに書けます。
const graph = new Graph()
// 入力のプレースホルダー作成
const x: Tensor = graph.placeholder('x', [])
// 各係数を定義
const a: Tensor = graph.variable('a', Scalar.new(Math.random()))
const b: Tensor = graph.variable('b', Scalar.new(Math.random()))
const c: Tensor = graph.variable('c', Scalar.new(Math.random()))
// 各項を定義
const order2: Tensor = graph.multiply(a, graph.square(x))
const order1: Tensor = graph.multiply(b, x)
// 関数を定義
// y = ax^2 + bx + c
const y: Tensor = graph.add(graph.add(order2, order1), c)
ただ一個の数式を作るだけなのに、随分と仰々しいように思いますね。
しかし、数式はその中に幾つもの処理が含まれています。
これらを全部扱いやすい単位に分解して、再度組み立てているのが上のコードになります。
数式を分解してつなぎ合わせたものを計算グラフというようで、deeplearn.jsでもGraphというクラスを使用していますね。
計算グラフの話はこのへんとか読むといいのではと思います。
また、計算グラフの構成要素の中にはplaceholder
とvariable
と言うものがあります。
placeholder
は関数定義の中では$x$に当たる部分で、入力データを代入する部分となっています。
一方、variable
と言うのは、関数定義の中の$a, b, c$に当たる部分で、学習過程によって調整される部分となります。
学習
関数が組み立てられたら、次は実データを使って関数を正しい形に調節してやります。
まず、先のコードで出力されるy
に対して、実データで出力されている値を比較しなければなりません。
そのために、もう一つのplaceholder
を作ります
// y実測値のプレースホルダー作成
const yLabel: Tensor = graph.placeholder('y label', [])
// 誤差関数を定義
const cost: Tensor = graph.meanSquaredCost(y, yLabel)
// セッション生成
const session = new Session(graph, math)
yLabel
が定義できると、学習に向けた準備が始められます。
まずは誤差関数を作って、関数が導き出したy
が、実測値yLabel
に対してどれだけ離れているかを評価できるようにします。
次に、学習を実行するセッションを作ります。
今回使う実データはたった4点で、これらをシャッフルして使用します。
// 入力サンプルを定義
const xs: Scalar[] = [
Scalar.new(0), Scalar.new(1), Scalar.new(2), Scalar.new(3)
]
// 出力のサンプルを定義
const ys: Scalar[] = [
Scalar.new(1.1), Scalar.new(5.9), Scalar.new(16.8), Scalar.new(33.9)
]
// 入出力の組をシャッフルする機構を作成
const shuffledInputProviderBuilder = new InCPUMemoryShuffledInputProviderBuilder([xs, ys])
const [xProvider, yProvider] = shuffledInputProviderBuilder.getInputProviders()
データの供給元が完成したので、早速学習を実施します。
// バッチの試行回数とバッチサイズを定義
// バッチサイズは入力サンプルのサイズと同じ ( 普通のバッチ学習 )
const NUM_BATCHES = 20
const BATCH_SIZE = xs.length
// 学習率を定義
const LEARNING_RATE = 0.01
// 学習を実施
for (let i = 0; i < NUM_BATCHES; i++) {
const costValue = session.train(cost, [{tensor: x, data: xProvider}, {tensor: yLabel, data: yProvider}], BATCH_SIZE, optimizer, CostReduction.MEAN)
console.log(`average cost: ${costValue.get()}`)
}
今回は単純なバッチ学習で、20回まわして終わりです。
チュートリアルどおりにコードを書いて、ブラウザで動かすと一瞬で終わり、予測結果がコンソール上に出力されます。
いつものドーナツ領域問題
チュートリアルでは普通の関数へのフィッティングがあったわけですが、ではパーセプトロンを実装するにはどうしたらいいのかわからなかったので、よく知っている例を使って実装してみました。
私が機械学習系の話題を書くときにいつも使っている、ドーナツ領域問題を例題にしてみましょう。
詳しい問題設定は前に書いた記事を参照します。要は以下の図の領域に、指定した座標が入っているかどうかを判別するというものですね。
これをいつものように多層パーセプトロンで解いてみます。
詳しいコードはこちらを参照してもらうとして、核となるモデル構築部分を抜粋します。
const x0: Tensor = graph.placeholder('input', [input_dim])
const a1: Tensor = graph.variable('a1', Array2D.randNormal([64, input_dim]))
const b1: Tensor = graph.variable('b1', Array1D.zeros([64]))
const y1: Tensor = graph.matmul(a1, x0)
const x1: Tensor = graph.relu(graph.add(y1, b1))
const a2: Tensor = graph.variable('a2', Array2D.randNormal([128, 64]))
const b2: Tensor = graph.variable('b2', Array1D.zeros([128]))
const y2: Tensor = graph.matmul(a2, x1)
const x2: Tensor = graph.tanh(graph.add(y2, b2))
const a3: Tensor = graph.variable('a3', Array2D.randNormal([label_dim, 128]))
const b3: Tensor = graph.variable('b3', Array1D.zeros([label_dim]))
const y3: Tensor = graph.matmul(a3, x2)
const x4: Tensor = graph.softmax(graph.add(y3, b3))
const y : Tensor = graph.placeholder('label', [label_dim])
const cost: Tensor = graph.softmaxCrossEntropyCost(x4, y)
一つの全結合層を定義するときは、まずshapeが([出力次元], [入力次元])
となる2階テンソルを入力値にかけて( grap.matmul
)一旦yn
として出力した後に、yn
に活性化関数( graph.relu
やgraph.tanh
)を施したものを次の層の入力xn
としています。
$y = ax^2 + bx + c$ の数式を計算グラフ化したときと、手間というか複雑さはそんなに変わってないですね。
こんな適当なんでいいのかとは思いましたが、実際に動かしてみたところ、
という感じで、ある程度いい感じにドーナツ型領域を描画できたので、これでいいかなって思いました。
この問題を解く際に、バッチサイズを128, 4万点のデータを12000回繰り返して学習したので、epoch数換算だと38epochぐらい回した感じです。
所感
若干書きにくいですが、ブラウザで学習・推量できるということで、JSで機械学習やりたいって人や、とにかくGPU使いたいって人にはいいんじゃないかって思いました。
TypeScript使っているので、エディターと組み合わせると、その関数を使えるかどうかとか、変数の型が違うので計算できないとかが予めわかって便利です。
また、ブラウザで処理をさせるので、Dockerで開発している私としては、学習処理のせいでテストが遅くなるとかの弊害もなくてありがたかったかなと。
ただ、GPU使っている割にはそんなに早いと感じなかったことと、マシンがものすごい轟音を立てつつ発熱していたので、やはり学習回すよりも既知のモデルを使って推論するのが主な使い方になるのかと。
まだ未搭載ですが、いずれはTensorFlowで生成したモデルを読み込む機能も作るとのこと( 現在はモデルのパラメータのみ読み込み、モデルの形状自体はdeeplearn.js側で別途構築する )で、将来が楽しみですね!
ロードマップを見ると、将来こんなことできそう、みたいな夢が広がります。
まとめ
というわけで、何番煎じかわからないですが、deeplearn.jsを使ってみた記録でした。
( まだやってた )音楽自動生成の学習のために、めっちゃ重いAutoencoder をGPUでできないかなっておもって調べたのですが、1d-combolution のやり方がよくわからなかったので、使い方を知るついでに紹介記事書いてみました。
TypeScriptの練習にもなったので、使えるかどうかはともかく、面白いのではと思います。
今回はこんなところです。