Deeplearning4J で iris を分類

Deeplearning4J (DL4J) を使って 「ConvNetJS で iris を分類」 と同様に iris を分類してみました。

今回は Groovy を使って実行します。

ソースは http://github.com/fits/try_samples/tree/master/blog/20160412/

準備

iris データセット

iris のデータセットorg.deeplearning4j.datasets.DataSets.iris() メソッドで取得できるため、ConvNetJS の時のようにダウンロードする必要はありません。

OpenBlas のインストール(Windows

Deeplearning4J が利用する ND4J を効果的に使うには OpenBLAS などのネイティブの BLAS ライブラリが必要です。

MNIST データセットのパース」 でも少し書きましたが、OpenBLAS を Windows 環境へインストールするには以下のようにします。

また、JniLoader が netlib-native_system-win-x86_64.dll を %TEMP% ディレクトリへダウンロードするのを防止するには、この dll も環境変数 PATH へ設定した場所へ配置しておきます。 (dll は Maven の Central Repository からダウンロードできます)

今回は、ND4J_Win64_OpenBLAS-v0.2.14.zip の解凍先へ netlib-native_system-win-x86_64.dll も配置しました。

  • C:\ND4J_Win64_OpenBLAS-v0.2.14
    • libblas3.dll
    • libgcc_s_seh-1.dll
    • libgfortran-3.dll
    • liblapack3.dll
    • libopenblas.dll
    • libquadmath-0.dll
    • netlib-native_system-win-x86_64.dll
環境変数 PATH 設定例
> set PATH=C:\ND4J_Win64_OpenBLAS-v0.2.14;%PATH%

共通処理の作成

Deeplearning4J には学習モデルを JSON 化するような処理は用意されていないようです。

ただ、MultiLayerNetwork 自体が Serializable なので、今回は Javaシリアライズ機能を使って保存等を行いました。

1. 学習モデルのセーブ・ロード処理

ObjectInputStream・ObjectOutputStream を使って MultiLayerNetwork を保存・復元する処理です。

ModelManager.groovy
@Grab('org.deeplearning4j:deeplearning4j-core:0.4-rc3.8')
@Grab('org.nd4j:nd4j-x86:0.4-rc3.8')
import org.deeplearning4j.nn.conf.MultiLayerConfiguration
import org.deeplearning4j.nn.multilayer.MultiLayerNetwork

abstract class ModelManager extends Script {

    // MultiLayerNetwork の復元
    def loadModel(String fileName) {
        new File(fileName).withObjectInputStream(this.class.classLoader) {
            it.readObject() as MultiLayerNetwork
        }
    }

    // MultiLayerNetwork の生成と保存
    def saveModel(String fileName, MultiLayerConfiguration conf) {
        // MultiLayerNetwork の生成と初期構築
        def model = new MultiLayerNetwork(conf)
        model.init()

        new File(fileName).withObjectOutputStream {
            it.writeObject model
        }
    }
}

2. 学習・評価処理

以前 ConvNetJS で実装したものと同じような出力結果となるように実装してみました。

今回使用した MultiLayerNetworkメソッドは以下の通りです。

メソッド 備考
fit 学習の実施(誤差の算出、重みの調整等)
score 誤差の取得(引数次第で誤差の算出も実施)
output ニューラルネットの処理結果を取得

output は正解率の算出に使うだけなので、第 2引数を false にして評価モード(TrainingMode.TEST)で実行します。

今回は、学習時と評価時の処理を共通化するため score メソッドで誤差を算出しましたが、setListeners メソッドScoreIterationListener を設定すれば誤差をログ出力できます。

Evaluationeval メソッドへ正解のラベルデータとニューラルネットの処理結果を渡すと正解率などを算出してくれ、accuracy メソッドで正解率を取得できます。 (stats メソッドを使えば結果を文字列で取得する事もできます)

org.nd4j.linalg.dataset.api.DataSetsplitTestAndTrain メソッドで学習用と評価用にデータを分割 ※、batchBy メソッドでミニバッチへ分割できます。

 ※ getTrain で学習用、getTest で評価用のデータセットを取得できます
iris_train.groovy
@Grab('org.deeplearning4j:deeplearning4j-core:0.4-rc3.8')
@Grab('org.nd4j:nd4j-x86:0.4-rc3.8')
import org.deeplearning4j.datasets.DataSets
import org.deeplearning4j.eval.Evaluation
import org.nd4j.linalg.dataset.SplitTestAndTrain

import groovy.transform.BaseScript

@BaseScript ModelManager baseScript

def epoch = args[0] as int
def trainRate = 0.7
def batchSize = 1

def modelFile = args[1]

// 誤差・正解率の算出
class SimpleEvaluator {
    private def model
    private def ev = new Evaluation(3) // iris の品種の数 3 を設定
    private def lossList = []

    SimpleEvaluator(model) {
        this.model = model
    }

    def eval(d) {
        // 誤差の算出とリストへの追加
        lossList << model.score(d)
        // 正解率などの算出
        ev.eval(d.labels, model.output(d.featureMatrix, false))
    }
    // 誤差(平均値)
    def loss() {
        lossList.sum() / lossList.size()
    }
    // 正解率
    def accuracy() {
        ev.accuracy()
    }
}

// 学習モデル (MultiLayerNetwork) のロード
def model = loadModel(modelFile)
// iris データセットの取得
def data = DataSets.iris()

(0..<epoch).each {
    def ev = [
        train: new SimpleEvaluator(model),
        test: new SimpleEvaluator(model)
    ]

    data.shuffle()

    // 学習用とテスト用にデータセットを分割
    def testAndTrain = data.splitTestAndTrain(trainRate)

    // 学習用データセットをミニバッチへ分割
    testAndTrain.train.batchBy(batchSize).each {
        ev.train.eval(it)
        // 学習
        model.fit(it)
    }

    // テスト用データセットを評価
    ev.test.eval(testAndTrain.test)

    // 結果の出力
    println([
        ev.train.loss(), ev.train.accuracy(),
        ev.test.loss(), ev.test.accuracy()
    ].join(','))
}

また、デフォルトではログを標準出力するので、今回は設定ファイルを用意して無効化しました。

logback.xml
<configuration>
  <root level="OFF"></root>
</configuration>

(a) 単純な構成 (入力層 - 出力層)

入力層と出力層だけの単純なニューラルネットを試します。

iris データセットは、4つの変数を使って 3つの品種に分類する事になりますので、OutputLayer.Builder へ以下のように設定します。

  • nIn へ変数の数 4 を設定
  • nOut へ分類の数 3 を設定

ソフトマックスと 3種類以上の交差エントロピー(Cross Entropy)を実施するには、以下のように設定すれば良いみたいです。

  • activation へ softmax を設定
  • OutputLayer.BuilderLossFunctions.LossFunction.MCXENT (Multiclass Cross Entropy) を設定

また、list には設定するレイヤーの数 (以下では 1) を設定します ※。

 ※ ただし、最新のソースで list(int) は Deprecated となっており、
    代わりにレイヤー数を指定しなくても済む list() を使うようです

    また、list(int) は list() を呼ぶだけの実装へ変わっていました
create_iris_hnn1.groovy
@Grab('org.deeplearning4j:deeplearning4j-core:0.4-rc3.8')
@Grab('org.nd4j:nd4j-x86:0.4-rc3.8')
import org.deeplearning4j.nn.conf.NeuralNetConfiguration
import org.deeplearning4j.nn.conf.Updater
import org.deeplearning4j.nn.conf.layers.OutputLayer
import org.nd4j.linalg.lossfunctions.LossFunctions

import groovy.transform.BaseScript

@BaseScript ModelManager baseScript

def learningRate = args[0] as double
def updateMethod = Updater.valueOf(args[1].toUpperCase())
def destFile = args[2]

def conf = new NeuralNetConfiguration.Builder()
    .iterations(1) // 最適化の繰り返し回数(デフォルト設定は 5)
    .updater(updateMethod)
    .learningRate(learningRate)
    .list(1)
    .layer(0, new OutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
        .nIn(4)
        .nOut(3)
        .activation('softmax')
        .build()
    )
    .build()

// 保存
saveModel(destFile, conf)

学習・評価

更新方法 (updateMethod) だけを変えたモデルを作って学習・評価を実施してみました。

(a-1) learningRate = 0.01, updateMethod = adam, epoch = 50

実行例
> groovy create_iris_hnn1.groovy 0.01 adam models/a-1_adam.ser
・・・
> groovy iris_train.groovy 50 models/a-1_adam.ser > results/a-1.csv
・・・

f:id:fits:20160412211058p:plain

(a-2) learningRate = 0.01, updateMethod = adadelta, epoch = 50

実行例
> groovy create_iris_hnn1.groovy 0.01 adadelta models/a-2_adadelta.ser
・・・
> groovy iris_train.groovy 50 models/a-2_adadelta.ser > results/a-2.csv
・・・

f:id:fits:20160412211111p:plain

(a-3) learningRate = 0.01, updateMethod = sgd, epoch = 50

実行例
> groovy create_iris_hnn1.groovy 0.01 sgd models/a-3_sgd.ser
・・・
> groovy iris_train.groovy 50 models/a-3_sgd.ser > results/a-3.csv
・・・

f:id:fits:20160412211124p:plain

(b) 隠れ層を追加 (入力層 - 隠れ層 - 出力層)

次に、隠れ層を追加してみます。

DenseLayer を追加して nInnOut を調整します。

create_iris_hnn2.groovy
@Grab('org.deeplearning4j:deeplearning4j-core:0.4-rc3.8')
@Grab('org.nd4j:nd4j-x86:0.4-rc3.8')
import org.deeplearning4j.nn.conf.NeuralNetConfiguration
import org.deeplearning4j.nn.conf.Updater
import org.deeplearning4j.nn.conf.layers.DenseLayer
import org.deeplearning4j.nn.conf.layers.OutputLayer
import org.nd4j.linalg.lossfunctions.LossFunctions

import groovy.transform.BaseScript

@BaseScript ModelManager baseScript

def learningRate = args[0] as double
def updateMethod = Updater.valueOf(args[1].toUpperCase())

// 隠れ層のニューロン数
def fcNeuNum = args[2] as int
// 隠れ層の活性化関数
def fcAct = args[3]

def destFile = args[4]

def conf = new NeuralNetConfiguration.Builder()
    .iterations(1)
    .updater(updateMethod)
    .learningRate(learningRate)
    .list(2)
    // 隠れ層
    .layer(0, new DenseLayer.Builder()
        .nIn(4)
        .nOut(fcNeuNum)
        .activation(fcAct)
        .build()
    )
    .layer(1, new OutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
        .nIn(fcNeuNum)
        .nOut(3)
        .activation('softmax')
        .build()
    )
    .build()

// 保存
saveModel(destFile, conf)

学習・評価

隠れ層の活性化関数 (fcAct) だけを変えたモデルを作って学習・評価を実施してみました。

(b-1) fcNeuNum = 8, fcAct = relu, learningRate = 0.01, updateMethod = adam, epoch = 50

実行例
> groovy create_iris_hnn2.groovy 0.01 adam 8 relu models/b-1_adam_relu.ser
・・・
> groovy iris_train.groovy 50 models/b-1_adam_relu.ser > results/b-1.csv
・・・

f:id:fits:20160412211144p:plain

(b-2) fcNeuNum = 8, fcAct = sigmoid, learningRate = 0.01, updateMethod = adam, epoch = 50

実行例
> groovy create_iris_hnn2.groovy 0.01 adam 8 sigmoid models/b-2_adam_sigmoid.ser
・・・
> groovy iris_train.groovy 50 models/b-2_adam_sigmoid.ser > results/b-2.csv
・・・

f:id:fits:20160412211155p:plain

ConvNetJS で MNIST を分類2 - 畳み込みニューラルネット

前回 の続きです。 今回は畳み込みニューラルネットを使って MNIST の手書き数字を分類してみます。

ソースは http://github.com/fits/try_samples/tree/master/blog/20160328/

準備

誤差・正解率のグラフ化と畳み込みフィルタの画像化を行うため、前回の構成へ d3 等を追加しています。

package.json
{
  "name": "convnetjs_mnist_conv_sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "dependencies": {
    "basic-csv": "0.0.2",
    "bluebird": "^3.3.4",
    "convnetjs": "^0.3.0",
    "d3": "^3.5.16",
    "jsdom": "^8.1.0",
    "shuffle-array": "^0.1.2"
  }
}
インストール例
> npm install

(b) 畳み込みニューラルネット

畳み込みニューラルネットでは畳み込み層とプーリング層を組み合わせてレイヤーを構築します。 (実際は全結合層も使います)

名称 処理 ConvNetJS の layer_type
畳み込み層 入力画像へフィルターを適用し特徴量を抽出 conv
プーリング層 入力画像へプーリング演算(フィルター内)を適用 pool

ConvNetJS の畳み込み層・プーリング層は以下のように設定します。

  • sxsy でフィルターのサイズを指定 (sy を省略すると sx と同じ値を適用)
  • pad で入力画像の周囲にゼロパディング(0埋め)する数を指定
  • stride でフィルタの適用位置を縦横に移動する数を指定 (1 の場合は縦横に 1画素ずつずらしてフィルターを適用)

畳み込み層では filters で適用するフィルターの数を指定します。

プーリング層では 「最大プーリング」 (フィルターの値の最大値を採用) を行うようになっており、今回試したバージョンでは (「平均プーリング」等へ) プーリング方法を変更する機能は無さそうでした。

今回は、以下のように畳み込み層・プーリング層が 2回続くような構成にしてみました。

create_layer_conv.js (畳み込みニューラルネットのモデル構築と保存処理)
'use strict';

// 畳み込み層の活性化関数
const act = process.argv[2];
// 出力ファイル名
const jsonDestFile = process.argv[3];

require('./save_model').saveModel(
    [
        { type: 'input', out_sx: 28, out_sy: 28, out_depth: 1 },
        // 1つ目の畳み込み層
        { type: 'conv', sx: 5, filters: 8, stride: 1, pad: 2, activation: act },
        // 1つ目のプーリング層
        { type: 'pool', sx: 2, stride: 2 },
        // 2つ目の畳み込み層
        { type: 'conv', sx: 5, filters: 16, stride: 1, pad: 2, activation: act },
        // 2つ目のプーリング層
        { type: 'pool', sx: 3, stride: 3 },
        { type: 'softmax', num_classes: 10 }
    ],
    jsonDestFile
);

活性化関数へ relu を指定した場合の内部的なレイヤー構成は以下のようになりました。

学習モデルの内部的なレイヤー構成例
input -> conv -> relu -> pool -> conv -> relu -> pool -> fc -> softmax

各レイヤーの出力サイズは以下の通りです。

layer_type out_sx out_sy out_depth
input 28 28 1
conv 28 28 8
relu 28 28 8
pool 14 14 8
conv 14 14 16
relu 14 14 16
pool 4 4 16
fc 1 1 10
softmax 1 1 10

学習と評価

前回 作成した共通処理 (learn_mnist.js 等) を使って学習と評価を実施します。

学習回数を前回と同じ 15回にすると相当時間がかかってしまうので、今回は以下の 4種類で学習・評価を試してみました。

  1. 活性化関数 = relu, 学習回数 = 5
  2. 活性化関数 = relu, 学習回数 = 10
  3. 活性化関数 = sigmoid, 学習回数 = 5
  4. 活性化関数 = sigmoid, 学習回数 = 10

学習回数以外は前回と同じパラメータを使います。

  • 学習回数 = 15
  • バッチサイズ = 100
  • 学習係数 = 0.001
  • 学習係数の決定方法 = adadelta

処理時間は学習回数 5回で 1.5時間、10回で 3時間程度でした。

PC の性能にも依存すると思いますが、1つの CPU で処理するので比較的遅めだと思います。

1. 活性化関数 = relu, 学習回数 = 5 (バッチサイズ = 100, 学習係数 = 0.001, adadelta)

> node create_layer_conv.js relu models/conv_relu.json

> node learn_mnist.js 5 100 0.001 adadelta models/conv_relu.json results/b-1_conv_relu.json > logs/b-1_conv_relu.log

> node validate_mnist.js results/b-1_conv_relu.json

data size: 10000
accuracy: 0.9785
学習時の誤差と正解率

f:id:fits:20160328195647p:plain

学習後のフィルター

f:id:fits:20160328195700p:plain

2. 活性化関数 = relu, 学習回数 = 10 (バッチサイズ = 100, 学習係数 = 0.001, adadelta)

> node learn_mnist.js 10 100 0.001 adadelta models/conv_relu.json results/b-2_conv_relu.json > logs/b-2_conv_relu.log

> node validate_mnist.js results/b-2_conv_relu.json

data size: 10000
accuracy: 0.9786
学習時の誤差と正解率

f:id:fits:20160328195714p:plain

学習後のフィルター

f:id:fits:20160328195724p:plain

3. 活性化関数 = sigmoid, 学習回数 = 5 (バッチサイズ = 100, 学習係数 = 0.001, adadelta)

> node create_layer_conv.js sigmoid models/conv_sigmoid.json

> node learn_mnist.js 5 100 0.001 adadelta models/conv_sigmoid.json results/b-3_conv_sigmoid.json > logs/b-3_conv_sigmoid.log

> node validate_mnist.js results/b-3_conv_sigmoid.json

data size: 10000
accuracy: 0.9812
学習時の誤差と正解率

f:id:fits:20160328195741p:plain

学習後のフィルター

f:id:fits:20160328195749p:plain

4. 活性化関数 = sigmoid, 学習回数 = 10 (バッチサイズ = 100, 学習係数 = 0.001, adadelta)

> node learn_mnist.js 10 100 0.001 adadelta models/conv_sigmoid.json results/b-4_conv_sigmoid.json > logs/b-4_conv_sigmoid.log

> node validate_mnist.js results/b-4_conv_sigmoid.json

data size: 10000
accuracy: 0.9862
学習時の誤差と正解率

f:id:fits:20160328195802p:plain

学習後のフィルター

f:id:fits:20160328195811p:plain

結果のまとめ

番号 活性化関数 学習回数 正解率
1 relu 5 0.9785
2 relu 10 0.9786
3 sigmoid 5 0.9812
4 sigmoid 10 0.9862

前回の結果よりも高い正解率となりました。

補足

(1) 誤差と正解率のグラフ化

誤差と正解率のログは以下のようなスクリプトでグラフ化しました。

誤差の値が Infinity となる事があったので ※、その場合はとりあえず固定値(以下では 1000)で置換するようにしています。

 ※ ただし、Infinity となったのは前回の階層型ニューラルネットの結果で、
    畳み込みニューラルネットの結果では発生していません
line_chart.js
'use strict';

const Promise = require('bluebird');
const d3 = require('d3');
const jsdom = require('jsdom').jsdom;

const readCSV = Promise.promisify(require('basic-csv').readCSV);

const w = 300;
const h = 300;
const margin = { top: 20, bottom: 50, left: 50, right: 20 };

const xLabels = ['バッチ回数', 'バッチ回数'];
const yLabels = ['誤差', '正解率'];

readCSV(process.argv[2]).then( ds => {
    const document = jsdom();

    const chartLayout = (xnum, w, h, margin) => {
        const borderWidth = w + margin.left + margin.right;
        const borderHeight = h + margin.top + margin.bottom;

        const svg = d3.select(document.body).append('svg')
            .attr('xmlns', 'http://www.w3.org/2000/svg')
            .attr('width', xnum * borderWidth)
            .attr('height', borderHeight);

        return Array(xnum).fill(0).map( (n, i) =>
            svg.append('g')
                .attr('transform', `translate(${i * borderWidth + margin.left}, ${margin.top})`)
        );
    };

    const xDomain = [0, ds.length];
    const yDomain = [1, 0];

    // スケールの定義
    const x = d3.scale.linear().range([0, w]).domain(xDomain);
    const y = d3.scale.linear().range([0, h]).domain(yDomain);

    // 軸の定義
    const xAxis = d3.svg.axis().scale(x).orient('bottom').ticks(5);
    const yAxis = d3.svg.axis().scale(y).orient('left');

    // 折れ線の作成
    const createLine = d3.svg.line()
        .x((d, i) => x(i + 1))
        .y(d => {
            // Infinity の際に固定値を設定
            if (d == 'Infinity') {
                d = 1000;
            }
            return y(d);
        });

    // 折れ線の描画
    const drawLine = (g, data, colIndex, color) => {
        g.append('path')
            .attr('d', createLine(data.map(d => d[colIndex])))
            .attr('stroke', color)
            .attr('fill', 'none');
    };

    const gs = chartLayout(2, w, h, margin);

    // X・Y軸の描画
    gs.forEach( (g, i) => {
        g.append('g')
            .attr('transform', `translate(0, ${h})`)
            .call(xAxis)
            .append('text')
                .attr('x', w / 2)
                .attr('y', 35)
                .style('font-family', 'Sans')
                .text(xLabels[i]);

        g.append('g')
            .call(yAxis)
            .append('text')
                .attr('x', -h / 2)
                .attr('y', -35)
                .attr('transform', 'rotate(-90)')
                .style('font-family', 'Sans')
                .text(yLabels[i]);
    });

    drawLine(gs[0], ds, 2, 'blue');
    drawLine(gs[1], ds, 3, 'blue');

    return document.body.innerHTML;

}).then( html => 
    console.log(html)
);

このスクリプトを使って svg ファイルへ出力し、ImageMagickpng ファイルへ変換しました。

実行例
> node line_chart.js logs/b-1_conv_relu.log > img/b-1.svg

> convert img/b-1.svg img/b-1.png

(2) 畳み込み層のフィルターを svg

学習後の畳み込み層のフィルターを以下のようなスクリプトで画像化(svg)してみました。

実際のフィルターサイズは 5x5 と小さくて分かり難いので、d3.scale を使って 50x50 へ拡大しています。

また、フィルターを可視化するため、d3.scale でフィルターの値が 0 ~ 255 となるように変換しています。

conv_filter_svg.js
'use strict';

const Promise = require('bluebird');
const convnetjs = require('convnetjs');
const d3 = require('d3');
const jsdom = require('jsdom').jsdom;

const readFile = Promise.promisify(require('fs').readFile);

const size = 50;
const margin = 5;

const modelJsonFile = process.argv[2];

// フィルター内の最小値と最大値を抽出
const valueRange = fs => fs.reduce( (acc, f) =>
    [
        Math.min(acc[0], Math.min.apply(null, f.w)),
        Math.max(acc[1], Math.max.apply(null, f.w))
    ], 
    [0, 0]
);

readFile(modelJsonFile).then( json => {
    const net = new convnetjs.Net();
    net.fromJSON(JSON.parse(json));

    return net.layers;
}).then( layers =>
    layers.reduce( (acc, v) => {
        // 畳み込み層のフィルターを抽出
        if (v.layer_type == 'conv' && v.filters) {
            acc.push( v.filters );
        }

        return acc;
    }, [])
).then( filtersList => {
    const document = jsdom();

    const svg = d3.select(document.body)
                    .append('svg')
                    .attr('xmlns', 'http://www.w3.org/2000/svg');

    filtersList.forEach( (fs, j) => {
        const yPos = (size + margin) * j;

        // フィルターの数値を 0 ~ 255 の値へ変換
        const pixelScale = d3.scale.linear()
                            .range([0, 255]).domain(valueRange(fs));

        fs.forEach( (f, i) => {
            const xPos = (size + margin) * i;

            const g = svg.append('g')
                        .attr('transform', `translate(${xPos}, ${yPos})`);

            const xScale = d3.scale.linear()
                            .range([0, size]).domain([0, f.sx]);

            const yScale = d3.scale.linear()
                            .range([0, size]).domain([0, f.sy]);

            for (let y = 0; y < f.sy; y++) {
                for (let x = 0; x < f.sx; x++) {
                    const p = pixelScale( f.get(x, y, 0) );

                    g.append('rect')
                        .attr('x', xScale(x))
                        .attr('y', yScale(y))
                        .attr('width', xScale(1))
                        .attr('height', yScale(1))
                        .attr('fill', d3.rgb(p, p, p));
                }
            }
        });
    });

    return document.body.innerHTML;

}).then( svg =>
    console.log(svg)
).catch( e => 
    console.error(e)
);

こちらも、svg ファイルとして出力し、ImageMagickpng ファイルへ変換しました。

実行例
> node conv_filter_svg.js results/b-1_conv_relu.json > img/b-1_filters.svg

> convert img/b-1_filters.svg img/b-1_filters.png

ConvNetJS で MNIST を分類1 - 階層型ニューラルネット

Node.js で ConvNetJS を使って MNIST の手書き数字を分類してみます。

今回は階層型ニューラルネット、次回は畳み込みニューラルネットを試す予定です。

ソースは http://github.com/fits/try_samples/tree/master/blog/20160322/

準備

npm で convnetjs 等のモジュールをインストールします。(概ね「ConvNetJS で iris を分類 」と同じ構成です)

package.json
{
  "name": "convnetjs_mnist_sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "dependencies": {
    "bluebird": "^3.3.4",
    "convnetjs": "^0.3.0",
    "shuffle-array": "^0.1.2"
  }
}
インストール例
> npm install

共通処理

ConvNetJS には、ニューラルネットの学習モデルを JSON で入出力する機能(fromJSONtoJSON)がありますので、今回はこの機能を使って学習前と後のモデルを JSON ファイルで扱う事にします。

MNIST には学習用のデータセット 6万件と評価用のデータセット 1万件がそれぞれ用意されていますので、今回は 6万件の学習データセットを全て学習に使い、1万件の評価データセットで評価する事にしました。

今回と次回で共通に使う処理として以下のようなスクリプトを作成しました。

(1) MNIST データセットのロード

MNIST の学習・評価データセットをロードする処理です。(処理内容に関しては 前回 を参照)

load_mnist.js
'use strict';

const Promise = require('bluebird');
const convnetjs = require('convnetjs');
const fs = require('fs');

const readFile = Promise.promisify(fs.readFile);
const readToBuffer = file => readFile(file).then(r => new Buffer(r, 'binary'));

const loadImages = file =>
    readToBuffer(file)
        .then(buf => {
            const magicNum = buf.readInt32BE(0);

            const num = buf.readInt32BE(4);
            const rowNum = buf.readInt32BE(8);
            const colNum = buf.readInt32BE(12);

            const dataBuf = buf.slice(16);

            const res = Array(num);

            let offset = 0;

            for (let i = 0; i < num; i++) {
                const data = new convnetjs.Vol(colNum, rowNum, 1, 0);

                for (let y = 0; y < rowNum; y++) {
                    for (let x = 0; x < colNum; x++) {

                        const value = dataBuf.readUInt8(offset++);

                        data.set(x, y, 0, value);
                    }
                }

                res[i] = data;
            }

            return res;
        });

const loadLabels = file =>
    readToBuffer(file)
        .then(buf => {
            const magicNum = buf.readInt32BE(0);

            const num = buf.readInt32BE(4);

            const dataBuf = buf.slice(8);

            const res = Array(num);

            for (let i = 0; i < num; i++) {
                res[i] = dataBuf.readUInt8(i);
            }

            return res;
        });

module.exports.loadMnist = (imgFile, labelFile) => 
    Promise.all([
        loadImages(imgFile),
        loadLabels(labelFile)
    ]).spread( (r1, r2) => 
        r2.map((label, i) => new Object({ values: r1[i], label: label }))
    );

(2) 学習モデルの保存

ニューラルネットの学習モデルを JSON ファイルへ保存する処理です。

save_model.js
'use strict';

const Promise = require('bluebird');
const convnetjs = require('convnetjs');

const writeFile = Promise.promisify(require('fs').writeFile);

module.exports.saveModel = (layers, destFile) => {

    const net = new convnetjs.Net();
    // 内部的なレイヤーの構築
    net.makeLayers(layers);

    // JSON 化してファイルへ保存
    writeFile(destFile, JSON.stringify(net.toJSON()))
        .catch( e => console.error(e) );
};

(3) 学習

指定の学習モデル (JSON) を MNIST の学習データセットで学習する処理です。

処理の進行状況を確認できるように batchSize 毎に誤差の平均と正解率を出力するようにしました。 (ただし、以下の方法では batchSize 次第で Trainer によるパラメータの更新タイミングと合わなくなります)

また、MNIST データセットの配列を直接シャッフルする代わりに、0 ~ 59999 のインデックス値から成る配列を用意して、それをシャッフルするようにしています。

learn_mnist.js
'use strict';

const Promise = require('bluebird');
const fs = require('fs');
const shuffle = require('shuffle-array');
const convnetjs = require('convnetjs');
const readFile = Promise.promisify(fs.readFile);
const writeFile = Promise.promisify(fs.writeFile);

const mnist = require('./load_mnist');

const epoch = parseInt(process.argv[2]);
const batchSize = parseInt(process.argv[3]);
const learningRate = parseFloat(process.argv[4]);
const trainMethod = process.argv[5];

const modelJsonFile = process.argv[6];
const modelJsonDestFile = process.argv[7];

// 0 ~ n - 1 を要素とする配列作成
const range = n => {
    const res = Array(n);

    for (let i = 0; i < n; i++) {
        res[i] = i;
    }

    return res;
};
// 指定サイズ毎に誤差の平均と正解率を出力する処理
const createLogger = (logSize, logFunc) => {
    let list = [];
    let counter = 0;

    return (loss, accuracy) => {
        list.push({loss: loss, accuracy: accuracy});

        const size = list.length;

        if (size >= logSize) {
            const res = list.reduce(
                (acc, d) => {
                    acc.loss += d.loss;
                    acc.accuracy += d.accuracy;

                    return acc;
                },
                { loss: 0.0, accuracy: 0 }
            );
            // 出力処理の実行
            logFunc(
                res.loss / size,
                res.accuracy / size,
                counter++
            );

            list = [];
        }
    };
};


Promise.all([
    readFile(modelJsonFile),
    mnist.loadMnist('train-images.idx3-ubyte', 'train-labels.idx1-ubyte')
]).spread( (json, data) => {
    const net = new convnetjs.Net();
    // JSON から学習モデルを復元
    net.fromJSON(JSON.parse(json));

    const trainer = new convnetjs.Trainer(net, {
        method: trainMethod, 
        batch_size: batchSize, 
        learning_rate: learningRate
    });

    range(epoch).forEach(ep => {
        // ログ出力処理の作成
        const log = createLogger(batchSize, (loss, acc, counter) =>
            console.log( [ep, counter, loss, acc].join(',') )
        );

        // インデックス値の配列を作成しシャッフル
        shuffle(range(data.length)).forEach(i => {
            // 該当するデータを取得
            const d = data[i];
            // 学習
            const stats = trainer.train(d.values, d.label);

            log(
                stats.loss,
                (net.getPrediction() == d.label)? 1: 0
            );
        });
    });

    return net;

}).then( net => 
    // 学習モデルの保存
    writeFile(modelJsonDestFile, JSON.stringify(net.toJSON()))
).catch( e => 
    console.error(e)
);

(4) 評価(テスト)

指定の学習モデル (JSON) で MNIST の評価データセットを処理し、正解率を出力する処理です。

validate_mnist.js
'use strict';

const Promise = require('bluebird');
const convnetjs = require('convnetjs');
const readFile = Promise.promisify(require('fs').readFile);

const mnist = require('./load_mnist');

const modelJsonFile = process.argv[2];

Promise.all([
    readFile(modelJsonFile),
    mnist.loadMnist('t10k-images.idx3-ubyte', 't10k-labels.idx1-ubyte')
]).spread( (json, data) => {

    const net = new convnetjs.Net();
    // JSON から学習モデルを復元
    net.fromJSON(JSON.parse(json));

    const accuCount = data.reduce((acc, d) => {
        net.forward(d.values);
        // 正解数のカウント
        return acc + (d.label == net.getPrediction()? 1: 0);
    }, 0);

    console.log(`data size: ${data.length}`);
    // 正解率の出力
    console.log(`accuracy: ${accuCount / data.length}`);
});

(a) 階層型ニューラルネット

MNIST の画像サイズは 28x28 のため、入力層 (type = input) の out_sxout_sy へそれぞれ 28 を設定し、画素値は 1バイトのため out_depth へ 1 を設定します。

出力層 (type = output)は 0 ~ 9 の分類 (10種類) となるため、typesoftmax にして num_classes へ 10 を設定します。

今回は、隠れ層を 1層にして活性化関数とニューロン数をコマンドライン引数で指定できるようにしました。

create_layer_hnn.js
'use strict';

const act = process.argv[2];
const numNeurons = parseInt(process.argv[3]);
const jsonDestFile = process.argv[4];

require('./save_model').saveModel(
    [
        { type: 'input', out_sx: 28, out_sy: 28, out_depth: 1 },
        { type: 'fc', activation: act, num_neurons: numNeurons },
        { type: 'softmax', num_classes: 10 }
    ], 
    jsonDestFile
);

例えば、上記の活性化関数へ relu を指定した場合の makeLayers の結果は下記のようになります。

学習モデルの内部的なレイヤー構成例
input -> fc -> relu -> fc -> softmax

type へ softmax を指定した場合、fc 層が差し込まれるようになっています。

学習と評価

今回は以下の 4種類で学習・評価を試してみました。

  1. 活性化関数 = relu, ニューロン数 = 50
  2. 活性化関数 = relu, ニューロン数 = 300
  3. 活性化関数 = sigmoid, ニューロン数 = 50
  4. 活性化関数 = sigmoid, ニューロン数 = 300

学習回数などのパラメータはとりあえず下記で実行します。

  • 学習回数 = 15
  • バッチサイズ = 100
  • 学習係数 = 0.001
  • 学習係数の決定方法 = adadelta

バッチサイズを 100 とする事で、学習データ 100件毎にパラメータ(重み)の更新が実施されます。

なお、処理時間はニューロン数や学習回数・バッチサイズなどに影響されます。(今回、ニューロン数 50 では 10分程度、300 では 50分程度でした)

1. 活性化関数 = relu, ニューロン数 = 50 (学習回数 = 15, バッチサイズ = 100, 学習係数 = 0.001, adadelta)

> node create_layer_hnn.js relu 50 models/relu_50.json

> node learn_mnist.js 15 100 0.001 adadelta models/relu_50.json results/a-1_relu_50.json > logs/a-1_relu_50.log

> node validate_mnist.js results/a-1_relu_50.json

data size: 10000
accuracy: 0.9455
学習時の誤差と正解率

f:id:fits:20160324201523p:plain

2. 活性化関数 = relu, ニューロン数 = 300 (学習回数 = 15, バッチサイズ = 100, 学習係数 = 0.001, adadelta)

> node create_layer_hnn.js relu 300 models/relu_300.json

> node learn_mnist.js 15 100 0.001 adadelta models/relu_300.json results/a-2_relu_300.json > logs/a-2_relu_300.log

> node validate_mnist.js results/a-2_relu_300.json

data size: 10000
accuracy: 0.965
学習時の誤差と正解率

f:id:fits:20160324201542p:plain

3. 活性化関数 = sigmoid, ニューロン数 = 50 (学習回数 = 15, バッチサイズ = 100, 学習係数 = 0.001, adadelta)

> node create_layer_hnn.js sigmoid 50 models/sigmoid_50.json

> node learn_mnist.js 15 100 0.001 adadelta models/sigmoid_50.json results/a-3_sigmoid_50.json > logs/a-3_sigmoid_50.log

> node validate_mnist.js results/a-3_sigmoid_50.json

data size: 10000
accuracy: 0.9368
学習時の誤差と正解率

f:id:fits:20160324201601p:plain

4. 活性化関数 = sigmoid, ニューロン数 = 300 (学習回数 = 15, バッチサイズ = 100, 学習係数 = 0.001, adadelta)

> node create_layer_hnn.js sigmoid 300 models/sigmoid_300.json

> node learn_mnist.js 15 100 0.001 adadelta models/sigmoid_300.json results/a-4_sigmoid_300.json > logs/a-4_sigmoid_300.log

> node validate_mnist.js results/a-4_sigmoid_300.json

data size: 10000
accuracy: 0.9631
学習時の誤差と正解率

f:id:fits:20160324201617p:plain

結果のまとめ

番号 活性化関数(隠れ層) ニューロン数(隠れ層) 正解率
1 relu 50 0.9455
2 relu 300 0.965
3 sigmoid 50 0.9368
4 sigmoid 300 0.9631

MNIST データセットをパースする

MNIST データセットは、THE MNIST DATABASE of handwritten digits からダウンロード可能な手書き数字のデータです。

機械学習ライブラリ等に標準で用意されてたりしますが、 今回は Node.js と Java でパースしてみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20160307/

概要

MNIST は以下のようなデータです。

  • 0 ~ 9 の手書き数字のグレースケール画像
  • 1つの画像は 28x28 のサイズ
  • 学習用に 6万件、テスト用に 1万件が用意されている
  • 画像データとラベルデータのファイルが対になっている

下記のように 4種類のファイルが用意されています。

画像データファイル ラベルデータファイル
学習用 train-images.idx3-ubyte train-labels.idx1-ubyte
テスト用 t10k-images.idx3-ubyte t10k-labels.idx1-ubyte

ファイルフォーマットは以下の通りです。

画像データのファイルフォーマット

オフセット タイプ 内容
0 32bit integer 2051 マジックナンバー
4 32bit integer 60000 or 10000 画像の数
8 32bit integer 28 行の数
12 32bit integer 28 列の数
16~ unsigned byte 0~255 ※ 1バイトずつピクセル値が連続

先頭 16バイトがヘッダー部分、それ以降に 1画像 28 x 28 = 784 バイトのデータが 6万件もしくは 1万件続きます。

 ※ 0 が白、255 が黒

ラベルデータのファイルフォーマット

オフセット タイプ 内容
0 32bit integer 2049 マジックナンバー
4 32bit integer 60000 or 10000 ラベルの数
8~ unsigned byte 0~9 1バイトずつラベル値が連続

先頭 8バイトがヘッダー部分、それ以降にラベルデータが 6万件もしくは 1万件続きます。

(a) ConvNetJS 用に変換 (Node.js)

それでは、Node.js を使ってパースしてみます。

今回は ConvNetJS で使えるように、画像データを convnetjs.Vol として作成するようにしました。

Buffer を使ってバイナリデータを処理します。

MNIST のデータはビッグエンディアンのようなので 32bit integer を読み込む際は readInt32BE を使います。

なお、マジックナンバーに関してはチェックしていません。

load_mnist.js
var Promise = require('bluebird');
var convnetjs = require('convnetjs');

var fs = require('fs');

var readFile = Promise.promisify(fs.readFile);
// ファイル内容から Buffer 作成
var readToBuffer = file => readFile(file).then(r => new Buffer(r, 'binary'));

// 画像データのロード
var loadImages = file =>
    readToBuffer(file)
        .then(buf => {
            var magicNum = buf.readInt32BE(0);
            var num = buf.readInt32BE(4);
            var rowNum = buf.readInt32BE(8);
            var colNum = buf.readInt32BE(12);

            // 画像データ部分を分離(ヘッダー部分を除外)
            var dataBuf = buf.slice(16);

            var res = Array(num);

            var offset = 0;

            for (var i = 0; i < num; i++) {
                var data = new convnetjs.Vol(colNum, rowNum, 1, 0);

                for (var y = 0; y < rowNum; y++) {
                    for (var x = 0; x < colNum; x++) {
                        var value = dataBuf.readUInt8(offset++);
                        data.set(x, y, 0, value);
                    }
                }

                res[i] = data;
            }

            return res;
        });

// ラベルデータのロード
var loadLabels = file =>
    readToBuffer(file)
        .then(buf => {
            var magicNum = buf.readInt32BE(0);
            var num = buf.readInt32BE(4);

            // ラベルデータ部分を分離(ヘッダー部分を除外)
            var dataBuf = buf.slice(8);

            var res = Array(num);

            for (var i = 0; i < num; i++) {
                res[i] = dataBuf.readUInt8(i);
            }

            return res;
        });

// 画像・ラベルデータをロード
module.exports.loadMnist = (imgFile, labelFile) => 
    Promise.all([
        loadImages(imgFile),
        loadLabels(labelFile)
    ]).spread( (r1, r2) => 
        r2.map((label, i) => {
            return { values: r1[i], label: label };
        })
    );

bluebirdconvnetjs パッケージを使っています。

package.json
{
  "name": "mnist_parse_sample",
  "version": "1.0.0",
  "description": "",
  "dependencies": {
    "bluebird": "^3.3.3",
    "convnetjs": "^0.3.0"
  }
}

動作確認

以下のテストコードを使って簡単な動作確認を行います。

手書き数字の大まかな形状を確認できるように、ピクセル値が 0 より大きければ # へ、それ以外は へ変換し出力してみました。

test_load_mnist.js
var mnist = require('./load_mnist');

var printData = d => {
    console.log(`***** number = ${d.label} *****`);

    var v = d.values;

    for (var y = 0; y < v.sy; y++) {
        var r = Array(v.sx);

        for (var x = 0; x < v.sx; x++) {
            // ピクセル値が 0より大きいと '#'、それ以外は ' '
            r[x] = v.get(x, y, 0) > 0 ? '#' : ' ';
        }

        // 文字で表現した画像を出力
        console.log(r.join(''));
    }

    // ピクセル値を出力
    console.log(d.values.w.join(','));
};

mnist.loadMnist(process.argv[2], process.argv[3])
    .then(ds => {
        console.log(`size: ${ds.length}`);

        printData(ds[0]);

        console.log('----------');

        printData(ds[1]);
    });

MNIST 学習用データを使った実行結果は以下の通りです。

実行結果
> node test_load_mnist.js train-images.idx3-ubyte train-labels.idx1-ubyte

size: 60000
***** number = 5 *****





            ############
        ################
       ################
       ###########
        ####### ##
         #####
           ####
           ####
            ######
             ######
              ######
               #####
                 ####
              #######
            ########
          #########
        ##########
      ##########
    ##########
    ########


0,・・・,3,18,18,18,126,136,175,26,166,255,・・・,0
----------
***** number = 0 *****




               #####
              ######
             #########
           ###########
           ###########
          ############
         #########  ###
        ######      ###
       #######      ###
       ####         ###
       ###          ###
      ####          ###
      ####        #####
      ###        #####
      ###       ####
      ###      ####
      #############
      ###########
      #########
       #######




0,・・・,51,159,253,159,50,・・・,0

(b) Deeplearning4J 用に変換 (Java

次は Deeplearning4J で使えるように Javaorg.nd4j.linalg.dataset.DataSet へ変換します。

画像データとラベルデータをそれぞれ org.nd4j.linalg.api.ndarray.INDArray として作成し、最後に DataSet へまとめます。

1件の画像データは 784 (= 28 x 28) 要素のフラットな INDArray として作成します ※。

 ※ Deeplearning4J では、ConvolutionLayerSetup を使って
    画像の高さ・幅を指定できるため、フラットなデータにして問題ありません

ラベルの値は FeatureUtil.toOutcomeVector() メソッドで作成します。

このメソッドによって、ラベルの種類と同じ数 (今回は 10) の要素を持ち、該当するインデックスの値だけが 1 で他は 0 になった配列 (INDArray) を得られます。

このように、変換後のデータセットの構造は (a) のケースとは異なります。

また、Java には unsigned byte という型がなく、ピクセル値を byte として読み込むと -128 ~ 127 になってしまいます。そこで & 0xff して正しい値 (0 ~ 255) となるように変換しています。

ちなみに、Deeplearning4J の org.deeplearning4j.datasets.DataSets.mnist() メソッドを使えば MNIST データセットを取得できますが、その場合はピクセル値が正規化 ※ されて 0 か 1 の値となります。 (シャッフルも実施される)

 ※ ピクセル値 > 30 の場合は 1、それ以外は 0 になる
src/main/java/MnistLoader.java
import org.nd4j.linalg.api.ndarray.INDArray;
import org.nd4j.linalg.dataset.DataSet;
import org.nd4j.linalg.factory.Nd4j;
import org.nd4j.linalg.util.FeatureUtil;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Paths;
import java.util.concurrent.CompletableFuture;

public class MnistLoader {
    private final static int LABELS_NUM = 10;

    public static CompletableFuture<DataSet> loadMnist(String imageFileName, String labelFileName) {

        return CompletableFuture.supplyAsync(() -> loadImages(imageFileName))
            .thenCombineAsync(
                CompletableFuture.supplyAsync(() -> loadLabels(labelFileName)),
                DataSet::new
            );
    }

    private static INDArray loadImages(String fileName) {
        try (FileChannel fc = FileChannel.open(Paths.get(fileName))) {

            ByteBuffer headerBuf = ByteBuffer.allocateDirect(16);
            // ヘッダー部分の読み込み
            fc.read(headerBuf);

            headerBuf.rewind();

            int magicNum = headerBuf.getInt();
            int num = headerBuf.getInt();
            int rowNum = headerBuf.getInt();
            int colNum = headerBuf.getInt();

            ByteBuffer buf = ByteBuffer.allocateDirect(num * rowNum * colNum);
            // 画像データ部分の読み込み
            fc.read(buf);

            buf.rewind();

            int dataSize = rowNum * colNum;

            INDArray res = Nd4j.create(num, dataSize);

            for (int n = 0; n < num; n++) {
                INDArray d = Nd4j.create(1, dataSize);

                for (int i = 0; i < dataSize; i++) {
                    // & 0xff する事で unsigned の値へ変換
                    d.putScalar(i, buf.get() & 0xff);
                }

                res.putRow(n, d);
            }
            return res;

        } catch(IOException ex) {
            throw new RuntimeException(ex);
        }
    }

    private static INDArray loadLabels(String fileName) {
        try (FileChannel fc = FileChannel.open(Paths.get(fileName))) {

            ByteBuffer headerBuf = ByteBuffer.allocateDirect(8);
            // ヘッダー部分の読み込み
            fc.read(headerBuf);

            headerBuf.rewind();

            int magicNum = headerBuf.getInt();
            int num = headerBuf.getInt();

            ByteBuffer buf = ByteBuffer.allocateDirect(num);
            // ラベルデータ部分の読み込み
            fc.read(buf);

            buf.rewind();

            INDArray res = Nd4j.create(num, LABELS_NUM);

            for (int i = 0; i < num; i++) {
                res.putRow(i, FeatureUtil.toOutcomeVector(buf.get(), LABELS_NUM));
            }

            return res;

        } catch(IOException ex) {
            throw new RuntimeException(ex);
        }
    }
}

動作確認

Gradle で以下のテストコードを実行し簡単な動作確認を行います。

src/main/java/TestMnistLoader.java
import org.nd4j.linalg.api.ndarray.INDArray;
import org.nd4j.linalg.dataset.DataSet;

import java.util.stream.IntStream;

public class TestMnistLoader {
    public static void main(String... args) {

        MnistLoader.loadMnist(args[0], args[1])
            .thenAccept(ds -> {
                System.out.println("size: " + ds.numExamples());

                printData(ds.get(0));

                System.out.println("----------");

                printData(ds.get(1));
            })
            .join();
    }

    private static void printData(DataSet d) {
        System.out.println("***** labels = " + d.getLabels());

        INDArray v = d.getFeatures();

        IntStream.range(0, 28).forEach( y -> {
            IntStream.range(0, 28).forEach ( x -> {
                System.out.print( v.getInt(x + y * 28) > 0 ? "#" : " " );
            });

            System.out.println();
        });

        System.out.println(d.getFeatures());
    }
}
build.gradle
apply plugin: 'application'

tasks.withType(AbstractCompile)*.options*.encoding = 'UTF-8'

mainClassName = 'TestMnistLoader'

repositories {
    jcenter()
}

dependencies {
    compile 'org.nd4j:nd4j-x86:0.4-rc3.8'
    runtime 'org.slf4j:slf4j-nop:1.7.18'
}

run {
    if (project.hasProperty('args')) {
        args project.args.split(' ')
    }
}

MNIST 学習用データを使った実行結果は以下の通りです。

実行結果
> gradle run -Pargs="train-images.idx3-ubyte train-labels.idx1-ubyte"

・・・
:run
3 06, 2016 10:22:09 午後 com.github.fommil.netlib.BLAS <clinit>
警告: Failed to load implementation from: com.github.fommil.netlib.NativeSystemBLAS
3 06, 2016 10:22:10 午後 com.github.fommil.jni.JniLoader liberalLoad
情報: successfully loaded ・・・\Temp\jniloader211398199777152626netlib-native_ref-win-x86_64.dll
****************************************************************
WARNING: COULD NOT LOAD NATIVE SYSTEM BLAS
ND4J performance WILL be reduced
Please install native BLAS library such as OpenBLAS or IntelMKL
See http://nd4j.org/getstarted.html#open for further details
****************************************************************
size: 60000
***** labels = [ 0.00, 0.00, 0.00, 0.00, 0.00, 1.00, 0.00, 0.00, 0.00, 0.00]





            ############
        ################
       ################
       ###########
        ####### ##
         #####
           ####
           ####
            ######
             ######
              ######
               #####
                 ####
              #######
            ########
          #########
        ##########
      ##########
    ##########
    ########



[ 0.00, ・・・, 3.00, 18.00, 18.00, 18.00, 126.00, 136.00, 175.00, 26.00, 166.00, 255.00, ・・・, 0.00]
----------
***** labels = [ 1.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]




               #####
              ######
             #########
           ###########
           ###########
          ############
         #########  ###
        ######      ###
       #######      ###
       ####         ###
       ###          ###
      ####          ###
      ####        #####
      ###        #####
      ###       ####
      ###      ####
      #############
      ###########
      #########
       #######




[ 0.00, ・・・, 51.00, 159.00, 253.00, 159.00, 50.00, ・・・, 0.00]

今回は DataSet を作っているだけなので WARNING を気にする必要は特にありませんが、WARNING を消して正しい状態で実行するには OpenBlas 等の BLAS ライブラリをインストールしてから実行します。

Windows 環境であれば、http://nd4j.org/getstarted.html#open のリンクから ND4J_Win64_OpenBLAS-v0.2.14.zip をダウンロード・解凍し、環境変数 PATH へ設定してから実行するのが簡単だと思います。

また、JniLoader が TEMP ディレクトリへ netlib-native_system-win-x86_64.dll をダウンロードするのを防止するには、この dll も環境変数 PATH へ設定した場所へ配置しておきます。 (dll は Maven の Central Repository からダウンロードできます ※)

 ※ ダウンロードしたファイル名では都合が悪いようなので、
    バージョン番号を除いた netlib-native_system-win-x86_64.dll という
    ファイル名へ変更します
OpenBlas 設定後の実行結果
> set PATH=C:\ND4J_Win64_OpenBLAS-v0.2.14;%PATH%
> gradle run -Pargs="train-images.idx3-ubyte train-labels.idx1-ubyte"

・・・
:run
3 06, 2016 11:31:22 午後 com.github.fommil.jni.JniLoader liberalLoad
情報: successfully loaded ・・・\netlib-native_system-win-x86_64.dll
size: 60000
***** labels = [ 0.00, 0.00, 0.00, 0.00, 0.00, 1.00, 0.00, 0.00, 0.00, 0.00]
・・・

D3.js による折れ線グラフ SVG の作成と PNG 変換 - Node.js

前回、(ConvNetJS による処理結果を)折れ線グラフ化した方法に関して書いておきます。

ソースは http://github.com/fits/try_samples/tree/master/blog/20160222/

準備

Node.js で D3.js を使うには、下記モジュールを npm でインストールします。

インストール例
> npm install d3 jsdom --save

D3.js は DOM を操作しますので、Node.js で使うには DOM を扱う jsdom のようなモジュールが必要となります。

(1) 折れ線グラフ(SVG)作成

前回csv ファイルの内容をグラフ化しましたが、今回は変数定義した配列の値を折れ線グラフ化する事にします。 (配列の値を縦軸、配列のインデックス値 + 1 を横軸にします)

グラフ作成の手順は以下の通りです。

  • (a) svg 要素の追加
  • (b) スケールの定義
  • (c) 軸の定義
  • (d) 軸の追加
  • (e) 折れ線の追加

(a) では console.log(document.body.innerHTML) した内容 ※ が .svg ファイルとして (Web ブラウザ等で) そのまま表示できるように xmlns を設定しています。

 ※ 今回、document.body へ追加した要素は svg 要素だけですので、
    document.body.innerHTML は svg 要素の内容となります

また、今回は svg 要素の直下ではなく、translate を使って left と top のマージン分だけ位置をずらした g 要素の下にグラフを追加するようにしています。

(b) では、描画するデータの値の範囲(domain で指定)を DOM 要素の幅・高さ(range で指定)へマッピング(変換)する関数を作成します。

今回のグラフの場合、要素の高さとグラフの縦軸で数値の増加する方向が逆になるため (高さは上から下、縦軸は下から上)、高さ 0 の位置が縦軸の最大値となるようにマッピングしています。

(c) では axis を使って縦軸や横軸を生成する関数を作成します。縦軸か横軸かは orient へ指定した値で決定されるようです。

(d) では (c) の結果を call へ指定する事で縦軸や横軸を追加できます。今回は text 要素を使って軸のラベルも追加しています。

あとは折れ線を作成する関数を定義し、path 要素の d 属性へ折れ線の作成結果を設定するだけです。(e)

line_chart.js
var d3 = require('d3');
var jsdom = require('jsdom').jsdom;

var w = 400; // 幅 (マージン部分は除く)
var h = 300; // 高さ (マージン部分は除く)
var margin = { left: 50, top: 20, bottom: 50, right: 20 };
var xDomain = [0, 10]; // 横軸の値の範囲
var yDomain = [15, 0]; // 縦軸の値の範囲

var document = jsdom();

// データ
var data = [3, 5, 4, 6, 9, 2, 8, 7, 5, 11];

// (a) svg 要素の追加
var g = d3.select(document.body).append('svg')
    .attr('xmlns', 'http://www.w3.org/2000/svg')
    .attr('width', w + margin.left + margin.right)  // svg 要素の幅
    .attr('height', h + margin.top + margin.bottom) // svg 要素の高さ
    .append('g')
        .attr('transform', `translate(${margin.left}, ${margin.top})`);

// (b) スケールの定義
var x = d3.scale.linear().range([0, w]).domain(xDomain);
var y = d3.scale.linear().range([0, h]).domain(yDomain);

// (c) 軸の定義
var xAxis = d3.svg.axis().scale(x).orient('bottom');
var yAxis = d3.svg.axis().scale(y).orient('left');

// (d1) 横軸の追加
g.append('g')
    .attr('transform', `translate(0, ${h})`)
    .call(xAxis)
    .append('text') // 横軸のラベル
        .attr('x', w / 2)
        .attr('y', 35)
        .text('番号');

// (d2) 縦軸の追加
g.append('g')
    .call(yAxis)
    .append('text') // 縦軸のラベル
        .attr('x', -h / 2)
        .attr('y', -30)
        .attr('transform', 'rotate(-90)')
        .text('個数');

// 折れ線の作成関数
var createLine = d3.svg.line()
    .x( (d, i) => x(i + 1) )
    .y( d => y(d) );

// (e) 折れ線の追加
g.append('path')
    .attr('d', createLine(data))
    .attr('stroke', 'blue')
    .attr('fill', 'none');

// SVG の出力
console.log(document.body.innerHTML);

実行結果は以下の通りです。

実行例
node line_chart.js > sample.svg
sample.svg の例 (実際は改行・字下げは無し)
<svg xmlns="http://www.w3.org/2000/svg" width="470" height="370">
  <g transform="translate(50, 20)">
    <g transform="translate(0, 300)">
      ・・・
    </g>
    ・・・
    <!-- 折れ線部分 -->
    <path d="M40,240L80,200L120,219.99999999999997L160,180L200,120L240,260L280,140L320,160L360,200L400,80" stroke="blue" fill="none"></path>
  </g>
</svg>

(2) SVGPNG へ変換

次は、(1) で出力した SVG ファイルを PNG ファイルへ変換します。

変換する方法は色々あると思いますが、今回はコマンドラインで使用する事を前提にして下記を試してみました。

(a) PhantomJS でキャプチャする

Web コンテンツを open して render する PhantomJS 用スクリプトを用意します。

capture.js
var system = require('system');

var webFile = system.args[1];
var pngFile = system.args[2];

var page = require('webpage').create();

page.open(webFile, function(status){
    if (status == 'success') {
        // ページのキャプチャをファイルへ出力
        page.render(pngFile);
    }

    phantom.exit();
});

phantomjs コマンドで以下のように実行すれば SVG ファイルを PNG ファイルへ変換できます。

実行例
> phantomjs capture.js sample.svg sample_phantom.png
sample_phantom.png

f:id:fits:20160222005216p:plain

(b) ImageMagick を使う

convert コマンドで変換すると、文字化けしました。

実行例
> convert sample.svg sample_im.png
sample_im.png

f:id:fits:20160222005231p:plain

-font オプションを使っても駄目でしたが、 以下のように SVGfont-family の style 設定を追加すると文字化けは解消しました。

line_chart_im.js
・・・
// 横軸の描画
svg.append('g')
    .attr('transform', `translate(0, ${h})`)
    .call(xAxis)
    .append('text')
        .attr('x', w / 2)
        .attr('y', 35)
        // ImageMagick による PNG 変換時の文字化け対策
        .style('font-family', 'Sans') 
        .text('番号');

// 縦軸の描画
svg.append('g')
    .call(yAxis)
    .append('text')
        .attr('x', -h / 2)
        .attr('y', -30)
        .attr('transform', 'rotate(-90)')
        // ImageMagick による PNG 変換時の文字化け対策
        .style('font-family', 'Sans')
        .text('値');
・・・
実行例2 (font-family 版)
> node line_chart_im.js > sample2.svg
> convert sample2.svg sample2_im.png
sample2_im.png

f:id:fits:20160222005249p:plain

(c) GraphicsMagick を使う

普通に gm convert すると、gm convert: Unable to read font (n019003l.pfb) というエラーが発生しました。 (Ghostscript をインストールしていない事が原因かも)

今回は -font でフォントを指定して回避しました。

実行例
> gm convert -font ipagp.ttf sample.svg sample_gm.png
sample_gm.png

f:id:fits:20160222005304p:plain

(d) Inkscape を使う

Inkscapeベクター画像編集の GUI ツールですが、コマンドラインでも実行できます。 PNG ファイルへ変換するには -e オプションを使用します。

実行例
> inkscape -f sample.svg -e sample_ink.png
sample_ink.png

f:id:fits:20160222005326p:plain

ConvNetJS で iris を分類

ニューラルネット用の JavaScript ライブラリ ConvNetJS を使って iris の分類を行ってみました。

ConvNetJS は Web ブラウザ上でも実行できますが、今回は Node.js で実行します。

ソースは http://github.com/fits/try_samples/tree/master/blog/20160215/

準備

iris データセットの取得

iris データセット (iris.data) は https://archive.ics.uci.edu/ml/datasets/iris からダウンロードできます。

iris.data の 151 行目(改行のみの行)は邪魔なので削除しておきます。

ConvNetJS インストール

今回は npm で ConvNetJS をインストールしました。

ConvNetJS インストール例
> npm install convnetjs --save

npm でインストールできるバージョンは今のところ 0.3.0 ですが、最新ソース と差異があるのでご注意ください。 (input で out_sx 等にデフォルト値が適用されない、adam に対応していない等)

また、最新ソースには bower.json が用意されていることから将来的に bower でインストールするようになるのかもしれません。

その他

今回は以下のライブラリも使いましたのでこれらもインストールしておきます。

  • basic-csv (CSV ファイルのパース)
  • shuffle-array (配列のシャッフル)
  • bluebird (Promise 対応)
> npm install basic-csv shuffle-array bluebird --save

(1) 単純な構成 (入力層 - 出力層)

まず、入力層と出力層だけの単純なニューラルネットの構成を試します。

層の構成

iris データセットの場合は、4つの変数を使って 3つの品種に分類する事になりますので、以下のように設定します。

  • 入力層の out_depth へ変数の数 4 を設定 ※
  • 出力層の type を softmax とし、num_classes で分類の数 3 を設定
 ※ out_sx と out_sy は画像データ等で使用するため
    今回のようなケースでは 1 にします。

    最新のソースでは、
    out_sx と out_sy を省略できるようですが(デフォルト値 1 を適用)
    0.3.0 では省略できないようですのでご注意下さい

convnetjs.NetmakeLayers を使ってニューラルネットを初期構築します。 (重みなどのパラメータをランダム値で初期化)

iris を分類するためのニューラルネット構築例(入力層 + 出力層)
// レイヤー構成の定義
var layer_defs = [
    // 入力層
    { type: 'input', out_sx: 1, out_sy: 1, out_depth: 4 },
    // 出力層
    { type: 'softmax', num_classes: 3 }
];

var model = new convnetjs.Net();

model.makeLayers(layer_defs);

出力層の活性化関数・誤差関数

今回のような分類を行う場合、出力層の活性化関数・誤差関数は一般的に以下を使うようです。

活性化関数 誤差関数
ソフトマックス 交差エントロピー

ConvNetJS では出力層の type を softmax とすればこれらを実施してくれます。(SoftmaxLayer の forward・backward に実装されています)

学習

学習は convnetjs.Trainer を使って実施します。

train メソッドへデータ 1件の入力値 (convnetjs.Vol) と分類結果 (数値で指定可 ※) を与えると算出した誤差を基にパラメータを調整してくれます。

 ※ 3つに分類する場合は 0 ~ 2

Trainer に batch_size (デフォルト 1) を設定すると、その回数毎にしかパラメータの調整を行わなくなり、ミニバッチを実現できます。

convnetjs.Vol の引数へ配列を与えると sx=1, sy=1, depth=配列のサイズ, w=配列の内容 で初期化した Vol オブジェクトを取得できます。

テスト(評価)

テストデータの評価は、forward へテストデータの入力値を与えた後、getPrediction で取得した結果(分類の予測結果)と実際の値を比べる事で実施できます。

サンプル作成

学習係数などのハイパーパラメータにはデフォルト値を使い、iris のデータセットを学習用(105件)とテスト用(45件)に分割して、テスト用データの正解率を出力する処理を実装してみました。

学習を何度も繰り返す事でパラメータを最適化していきますので、以下では 20回繰り返すようにしています。

sample1.js
var Promise = require('bluebird');
var basicCsv = require('basic-csv');
var shuffle = require('shuffle-array');
var convnetjs = require('convnetjs');

var readCSV = Promise.promisify(basicCsv.readCSV);

var epoch = 20; // 学習の繰り返し回数
var trainRate = 0.7; // 学習データとテストデータの分割率

var categoryMap = {
    'Iris-setosa': 0,
    'Iris-versicolor': 1,
    'Iris-virginica': 2
};

// 階層型ニューラルネットの構成
var layer_defs = [
    { type: 'input', out_sx: 1, out_sy: 1, out_depth: 4 },
    { type: 'softmax', num_classes: 3 }
];

var model = new convnetjs.Net();
model.makeLayers(layer_defs);

var trainer = new convnetjs.Trainer(model);

readCSV('iris.data')
    .then( ds => 
        // 入力データの作成
        ds.map(d => 
            new Object({
                // 4つの変数を convnetjs.Vol 化
                features: new convnetjs.Vol([ d[0], d[1], d[2], d[3] ]),
                // 分類の数値化 (0 ~ 2)
                label: categoryMap[d[4]]
            })
        )
    )
    .then( ds => {
        shuffle(ds);

        var trainSize = Math.floor(ds.length * trainRate);
        var testData = ds.splice(trainSize);

        // 学習データとテストデータへ分割
        return {train: ds, test: testData};
    })
    .then( data => {
        for (var i = 0; i < epoch; i++) {
            // 学習
            data.train.forEach( d => trainer.train(d.features, d.label) );

            // テスト(評価)
            var testAccSum = data.test.reduce( (acc, d) => {
                model.forward(d.features);

                return acc + (model.getPrediction() == d.label? 1: 0);
            }, 0);

            // テストデータの正解率を出力
            console.log(testAccSum / data.test.length);
        }
    });

実行結果は以下の通りです。

実行するたびに初期パラメータや学習・テストデータの構成が変わりますので、結果も自ずと変化します。

実行結果1
> node sample1.js

0.6222222222222222
0.7777777777777778
0.6444444444444445
・・・
0.9777777777777777
0.9777777777777777
0.9777777777777777
実行結果2
> node sample1.js

0.8888888888888888
0.8888888888888888
0.9555555555555556
・・・
0.9555555555555556
0.9555555555555556
0.9333333333333333

(2) 誤差と正解率

次は、学習・テストそれぞれの誤差(の平均)と正解率を出力するようにしてみます。

更に、学習係数 learning_rate と学習係数の調節手法 methodコマンドライン引数で指定できるようにします。

method の値

0.3.0 で使える method の値は以下の通りです。(ただし、最新のソースでは adam 等が追加されている)

  • sgd (デフォルト値)
  • adagrad
  • adadelta
  • windowgrad

誤差

誤差というのはニューラルネットで算出された結果と実際の結果(正解値)との差異で、今回は交差エントロピーの結果です。

誤差は以下のようにして取得できます。

  • 学習時は train の戻り値の loss プロパティ
  • テスト時は backward の戻り値 (先に forward を実行しておく必要あり)

誤差を最小化 (0 へ近づける) するようにパラメータが調整されていきますので、誤差の推移が重要です。

サンプル作成

sample1.js をベースにして、学習と評価の誤差と正解率を集計する処理を batchLearn へ集約し、繰り返し回数を 50 に変更しています。

sample2.js
var Promise = require('bluebird');
var basicCsv = require('basic-csv');
var shuffle = require('shuffle-array');
var convnetjs = require('convnetjs');

var readCSV = Promise.promisify(basicCsv.readCSV);

var epoch = 50;
var trainRate = 0.7;
var batchSize = 1;

var learningRate = parseFloat(process.argv[2]);
var updateMethod = process.argv[3];

・・・

// 構成
var layer_defs = [
    { type: 'input', out_sx: 1, out_sy: 1, out_depth: 4 },
    { type: 'softmax', num_classes: 3 }
];

・・・

var trainer = new convnetjs.Trainer(model, {
    batch_size: batchSize,
    learning_rate: learningRate,
    method: updateMethod
});

// 学習・評価の実施
var batchLearn = (data, learnLossFunc) => {
    var res = data.reduce(
        (acc, d) => {
            // 誤差の加算
            acc.loss += learnLossFunc(d);
            // 正解数の加算
            acc.accuracy += (model.getPrediction() == d.label)? 1: 0;

            return acc;
        },
        { loss: 0.0, accuracy: 0 }
    );

    for (var key in res) {
        // 誤差・正解数の平均値を算出
        res[key] /= data.length;
    }

    return res;
};

readCSV('iris.data')
    ・・・
    .then( data => {
        for (var i = 0; i < epoch; i++) {
            shuffle(data.train);

            // 学習
            var trainRes = batchLearn(data.train, d => 
                // 学習と誤差の取得
                trainer.train(d.features, d.label).loss
            );

            // テスト(評価)
            var testRes = batchLearn(data.test, d => {

                model.forward(d.features);
                // 誤差の取得
                return model.backward(d.label);
            });

            console.log([
                trainRes.loss, trainRes.accuracy, 
                testRes.loss, testRes.accuracy
            ].join(','));
        }
    });

実行結果

learning_rate を 3パターン (0.05, 0.01, 0.001) と method を 3パターン (sgd, adagrad, adadelta) の組み合わせ (計 9パターン) の誤差と正解率を折れ線グラフで表示してみます。

  • 青線が学習結果
  • 赤線がテスト(評価)結果

グラフ作成には D3.js を使い、SVG として出力するようにしています。(line_chart.js 参照)

実行例
> node sample2.js 0.01 sgd > results/sample2-0.01-sgd.csv

> node line_chart.js results/sample2-0.01-sgd.csv > results/sample2-0.01-sgd.csv.svg

実行の度に結果が変わる点にご注意下さい。

(a) method = sgd

learning_rate = 0.05 (sample2-0.05-sgd.csv.svg

f:id:fits:20160215002402p:plain

learning_rate = 0.01 (sample2-0.01-sgd.csv.svg

f:id:fits:20160215002629p:plain

learning_rate = 0.001 (sample2-0.001-sgd.csv.svg

f:id:fits:20160215002645p:plain

(b) method = adagrad

learning_rate = 0.05 (sample2-0.05-adagrad.csv.svg

f:id:fits:20160215002711p:plain

learning_rate = 0.01 (sample2-0.01-adagrad.csv.svg

f:id:fits:20160215002734p:plain

learning_rate = 0.001 (sample2-0.001-adagrad.csv.svg

f:id:fits:20160215002746p:plain

誤差が 1以下にならなかったので、誤差のグラフには何も描画されていません。

(c) method = adadelta

learning_rate = 0.05 (sample2-0.05-adadelta.csv.svg

f:id:fits:20160215002811p:plain

learning_rate = 0.01 (sample2-0.01-adadelta.csv.svg

f:id:fits:20160215002826p:plain

learning_rate = 0.001 (sample2-0.001-adadelta.csv.svg

f:id:fits:20160215002844p:plain

(3) 隠れ層を追加 (入力層 - 隠れ層 - 出力層)

最後に隠れ層を追加してみます。

サンプル作成

入力層と出力層の間に type: 'fc' の層を追加すれば隠れ層を追加できます。

sample3.js
・・・

var fcNeuNum = parseInt(process.argv[4]); // 隠れ層のニューロン数
var fcAct = process.argv[5]; // 隠れ層の活性化関数

・・・

// 構成
var layer_defs = [
    { type: 'input', out_sx: 1, out_sy: 1, out_depth: 4 },
    { type: 'fc', num_neurons: fcNeuNum, activation: fcAct },
    { type: 'softmax', num_classes: 3 }
];

・・・

実行結果

(2) と同様に誤差と正解率をグラフ化してみます。

隠れ層のニューロン数は 8、隠れ層の活性化関数は relu で実施しました。

実行例
> node sample3.js 0.01 sgd 8 relu > results/sample3-0.01-sgd-8-relu.csv

> node line_chart.js results/sample3-0.01-sgd-8-relu.csv > results/sample3-0.01-sgd-8-relu.csv.svg

(a) method = sgd, num_neurons = 8, activation = relu

learning_rate = 0.05 (sample3-0.05-sgd-8-relu.csv.svg

f:id:fits:20160215003106p:plain

learning_rate = 0.01 (sample3-0.01-sgd-8-relu.csv.svg

f:id:fits:20160215003119p:plain

learning_rate = 0.001 (sample3-0.001-sgd-8-relu.csv.svg

f:id:fits:20160215003131p:plain

(b) method = adagrad, num_neurons = 8, activation = relu

learning_rate = 0.05 (sample3-0.05-adagrad-8-relu.csv.svg

f:id:fits:20160215003149p:plain

learning_rate = 0.01 (sample3-0.01-adagrad-8-relu.csv.svg

f:id:fits:20160215003200p:plain

learning_rate = 0.001 (sample3-0.001-adagrad-8-relu.csv.svg

f:id:fits:20160215003219p:plain

(c) method = adadelta, num_neurons = 8, activation = relu

learning_rate = 0.05 (sample3-0.05-adadelta-8-relu.csv.svg

f:id:fits:20160215003235p:plain

learning_rate = 0.01 (sample3-0.01-adadelta-8-relu.csv.svg

f:id:fits:20160215003253p:plain

learning_rate = 0.001 (sample3-0.001-adadelta-8-relu.csv.svg

f:id:fits:20160215003311p:plain

PureScript で DOM を操作

PureScript の下記ライブラリを使って簡単な DOM 操作を試してみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20160125/

はじめに

PureScript を使って実装するものと同等の処理を JavaScript で書いてみました。 id で指定した DOM ノードの textContent を変更するだけの簡単な処理です。

sample.js
var Sample = {
    updateContent: (id, content) => {
        var node = document.getElementById(id);

        if (node) {
            node.textContent = content;
        }
    }
};

下記の HTML で実行してみます。 (PureScript の方は updateContent の呼び出し部分が少し異なります)

index.html
<!DOCTYPE html>
<html>
<body>
    <h2 id="d"></h2>

    <script src="sample.js"></script>
    <script>
        Sample.updateContent('d', 'sample javascript');
    </script>
</body>
</html>
実行結果

Web ブラウザで表示した結果は以下の通りです。

f:id:fits:20160125204319p:plain

purescript-dom の場合

pulp init でプロジェクトを作成し、pulp dep installpurescript-dom をインストールします。

なお、pulpgulp を事前にインストール (npm install) しておきます。

purescript-dom インストール
> pulp init
・・・

> pulp dep install purescript-dom --save

src/Main.purs を編集し updateContent 関数を実装します。

以下のように型まわりに注意が必要です。

  • (a) document 関数は Eff (dom :: DOM | eff) HTMLDocument を返す
  • (b) getElementById 関数の引数は ElementId と NonElementParentNode
  • (c) getElementById 関数は Eff (dom :: DOM | eff) (Nullable Element) を返す
  • (d) setTextContent 関数の引数は String と Node

(a) の結果の HTMLDocument を getElementById の引数へそのまま使えなかったので htmlDocumentToNonElementParentNode 関数で変換しています。

(c) の結果の Nullable はそのままだと使い難いので toMaybe 関数で Maybe 化し、Element も setTextContent の引数に使えなかったので elementToNode 関数で変換しています。

src/Main.purs
module Main where

import Prelude
import Control.Monad.Eff

import Data.Maybe
import Data.Nullable (toMaybe)

import DOM
import DOM.HTML.Types
import DOM.Node.Types
import DOM.HTML (window)
import DOM.HTML.Window (document)
import DOM.Node.NonElementParentNode (getElementById)
import DOM.Node.Node (setTextContent)

updateContent :: forall eff. String -> String -> Eff (dom :: DOM | eff) Unit
updateContent id content = do
    win <- window
    doc <- document win
    node <- getElementById (ElementId id) $ htmlDocumentToNonElementParentNode doc
    case (toMaybe node) of
        Just x -> setTextContent content (elementToNode x)
        _      -> return unit

今回は、gulp を使って pulp browserify を実行するように以下のような gulpfile.js を用意しました。

Sample.updateContent で関数を実行できるように --standalone を指定しています。

gulpfile.js
var gulp = require('gulp');
var child_process = require('child_process');

var pulpCmd = (process.platform == 'win32')? 'pulp.cmd': 'pulp';
var destFile = 'sample.js'

gulp.task('pulp_package', () => {
    // pulp browserify の実行
    var res = child_process.spawnSync(pulpCmd, ['browserify', '--standalone', 'Sample', '-t', destFile]);

    // 実行結果の出力
    [res.stdin, res.stdout, res.stderr].forEach( x => {
        if (x) {
            console.log(x.toString());
        }
    });
});

gulp.task('default', ['pulp_package']);

gulp コマンドを実行すると sample.js が生成されます。

ビルド例 (gulp で pulp browserify を実行)
> gulp

下記の HTML で実行してみます。

ここで、Sample.updateContent はカリー化されており function(id) { return function(content) { return function __do() { ・・・ } } } となっている点に注意。

index.html
<!DOCTYPE html>
<html>
<body>
    <h2 id="d"></h2>

    <script src="sample.js"></script>
    <script>
        Sample.updateContent('d')('sample purescript-dom')();
    </script>
</body>
</html>
実行結果

f:id:fits:20160125204344p:plain

purescript-simple-dom の場合

同じ様にして purescript-simple-dom をインストールします。

purescript-simple-dom インストール
> pulp init
・・・

> pulp dep install purescript-simple-dom --save

src/Main.purs を編集し updateContent 関数を実装します。

purescript-dom と比べると余計な型変換が不要なのでシンプルです。

src/Main.purs
module Main where

import Prelude
import Control.Monad.Eff

import DOM

import Data.Maybe
import Data.DOM.Simple.Window (document, globalWindow)
import Data.DOM.Simple.Element (getElementById, setTextContent)

updateContent :: forall eff. String -> String -> Eff (dom :: DOM | eff) Unit
updateContent id content = do
    doc <- document globalWindow
    node <- getElementById id doc

    case node of
        Just x -> setTextContent content x
        _      -> return unit

purescript-dom と同じ様に gulp で sample.js を生成しました。(gulpfile.js は同じ内容です)

HTML は以下の通りです。

index.html
<!DOCTYPE html>
<html>
<body>
    <h2 id="d"></h2>

    <script src="sample.js"></script>
    <script>
        Sample.updateContent('d')('sample purescript-simple-dom')();
    </script>
</body>
</html>
実行結果

f:id:fits:20160125204359p:plain