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

virtual-dom のイベント処理

仮想 DOM を扱うための JavaScript 用ライブラリ virtual-dom では ev-xxx (例. ev-click) でイベント処理を扱えるようになっていますが、実際に機能させるには dom-delegator が必要なようです。

virtual-dom は bower install が可能で Web ブラウザ上で直接使えるファイル (dist/virtual-dom.js) を用意してくれていますが、今のところ dom-delegator はそうなっていません。(dom-delegator は npm install する)

名称 bower install Web ブラウザで直接使用
virtual-dom
dom-delegator × ×

そこで、今回は下記ツールを用いて virtual-dom で ev-click が機能するようにしてみます。

事前準備

Gulp・Bower・Browserify をインストールします。

インストール例
> npm install -g gulp bower browserify

virtual-dom のイベント処理サンプル

virtual-dom で ev-click を実施するサンプルを作成します。

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

(1) virtual-dom インストール

virtual-dom を Bower でインストールします。

bower init と virtual-dom インストール例
> bower init
・・・

> bower install virtual-dom --save
・・・

(2) dom-delegator インストール

dom-delegator は npm でインストールします。 (dom-delegator は Bower でインストールできないため ※)

※ dom-delegator の git リポジトリを bower install する事は可能ですが、
   その場合は依存ライブラリを手動でインストールする事になります
npm init と dom-delegator インストール例
> npm init
・・・

> npm install dom-delegator --save
・・・

(3) gulp-flatten インストール

Gulp でファイルをコピーする際にディレクトリ構成をフラット化するため gulp-flatten パッケージをインストールしておきます。

gulp-flatten インストール例
> npm install gulp-flatten --save-dev

Gulp で以下のように bower_components/virtual-dom/dist/virtual-dom.js を js ディレクトリへコピーすると、js/virtual-dom/dist/virtual-dom.js が作られます。

Gulp によるコピー例
gulp.task('js-copy', () => {
   gulp.src('bower_components/*/dist/*.js')
        .pipe(gulp.dest('js'));
});

今回は、js/virtual-dom/dist/virtual-dom.js では無く js/virtual-dom.js へコピーしたかったので gulp-flatten を使いました。

(4) Gulp ビルド定義作成と実行

以下のような処理を行う gulpfile.js を作成します

  • (a) bower_components/virtual-dom/dist/virtual-dom.js を js/virtual-dom.js へコピー
  • (b) dom-delegator を Browserify で処理して js/dom-delegator.js へ出力 (Web ブラウザ上で DOMDelegator という名称で扱えるように standalone も設定)
gulpfile.js
var fs = require('fs');

var gulp = require('gulp');
var browserify = require('browserify');

var flatten = require('gulp-flatten');

// (a)
gulp.task('js-copy', () => {
    gulp.src('bower_components/*/dist/*.js')
        .pipe(flatten()) // gulp-flatten でディレクトリをフラット化
        .pipe(gulp.dest('js'));
});

// (b)
gulp.task('browserify', () => {
    browserify({
        require: 'dom-delegator',
        standalone: 'DOMDelegator'
    }).bundle().pipe(fs.createWriteStream('js/dom-delegator.js'));

});

gulp.task('default', ['js-copy', 'browserify']);

js ディレクトリを手動で作成した後、gulp コマンドを実行します。

gulp 実行例
> mkdir js
> gulp

[00:19:17] Using gulpfile ・・・\virtual-dom\gulpfile.js
[00:19:17] Starting 'js-copy'...
[00:19:17] Finished 'js-copy' after 15 ms
[00:19:17] Starting 'browserify'...
[00:19:17] Finished 'browserify' after 17 ms
[00:19:17] Starting 'default'...
[00:19:17] Finished 'default' after 6.16 μs

これで下記のファイルが作成されます。

  • js/virtual-dom.js
  • js/dom-delegator.js

(5) virtual-dom の ev-click サンプル作成と実行

Gulp で生成した js ファイルを使い virtual-dom で ev-click を扱う単純なサンプルを作成し動作確認してみます。

virtual-dom は概ね以下のようにして使います。

  • (1) virtualDom.diff 関数で 2つの VNode (VirtualNode) の差分を VPatch (VirtualPatch) として抽出
  • (2) virtualDom.patch 関数で VPatch の内容を実際の DOM へ反映
index.html
<!DOCTYPE html>
<html>
<script src="js/virtual-dom.js"></script>
<script src="js/dom-delegator.js"></script>
<body>
    <h1>virtual-dom click sample</h1>

    <div id="ct"></div>

    <script>
        (function() {
            // ev-xxx イベントの有効化 (dom-delegator)
            new DOMDelegator();

            var h = virtualDom.h;
            var create = virtualDom.create;
            var diff = virtualDom.diff;
            var patch = virtualDom.patch;

            var index = 1;
            var tree = h();

            var render = function(v) {
                // 仮想 DOM の作成
                return h('div', [
                    h('button', 
                        {
                            // click イベントの設定
                            'ev-click': function(ev) { update(index++) }
                        },
                        ['countUp']
                    ),
                    h('br'),
                    'counter: ' + String(v)
                ]);
            }

            var update = function(v) {
                var newTree = render(v);

                // tree と newTree の差分を実際の DOM へ反映
                patch(document.getElementById('ct'), diff(tree, newTree));

                tree = newTree;
            };

            update(index++);
        })();

    </script>
</body>
</html>

Web ブラウザで実行し countUp ボタンを押下すると counter の数値がカウントアップする事を確認できました。

Web ブラウザ表示例

f:id:fits:20160107023648p:plain

virtual-dom イベント処理の仕組み

最後に、virtual-dom と dom-delegator がどのようにイベント処理を行っているのか簡単に書いておきます。

まず、VNode の作成時に ev- で始まるプロパティは EvHook へ変換されます。(virtual-hyperscript/index.js の transformProperties)

patch 関数で実際の DOM へ反映する際に、対象の DOM ノードへ特殊なプロパティ __EV_STORE_KEY@7 ※ を追加して EvHook の内容 (イベント名とイベント処理関数) を設定します。 (ev-store の機能)

※ プロパティ名は ev-store の index.js で定義されており、
   ev-store 7.0.0 では __EV_STORE_KEY@7 となっています
__EV_STORE_KEY@7 が設定されている DOM ノード例

f:id:fits:20160107023717p:plain

dom-delegator は "blur", "change", "click" 等の多数のイベントを document.documentElement で listen するように処理し、イベント発生時に対象 DOM ノードの __EV_STORE_KEY@7 プロパティの内容をチェックして、該当するイベントが設定されていればその処理関数を実行します。

つまり、上記のようなイベント処理を自前で行えば dom-delegator を使わなくても ev-click を機能させる事が可能です。

index2.html (dom-delegator を使わずに ev-click を有効化する例)
<!DOCTYPE html>
<html>
<script src="js/virtual-dom.js"></script>
<body>
    <h1>virtual-dom click sample2</h1>

    <div id="ct"></div>

    <script>
        (function() {
            // dom-delegator の代用処理
            document.addEventListener('click', function(ev) {
                var store = ev.target['__EV_STORE_KEY@7'];

                if (store && store[ev.type]) {
                    // 該当するイベント処理関数の実行
                    store[ev.type](ev);
                }
            }, true);

            var h = virtualDom.h;
            ・・・
            var render = function(v) {
                return h('div', [
                    h('button', 
                        {
                            'ev-click': function(ev) { update(index++) }
                        },
                        ['countUp']
                    ),
                    h('br'),
                    'counter: ' + String(v)
                ]);
            }
            ・・・
        })();

    </script>
</body>
</html>

pulp を使った PureScript の開発

PureScript 用のビルドツールpulp があります。

pulp を使えば PureScript v0.7 から多少面倒になったビルドや実行が比較的容易になります。

pulp インストール

Node.js の npm で purescript (コンパイラ) と pulp をインストールします。

pulp インストール例
> npm install -g purescript pulp

今回インストールした PureScript コンパイラpulp のバージョンは以下の通りです。

なお、PureScript コンパイラに関しては https://github.com/purescript/purescript/releases/ から各 OS 用のバイナリを直接取得する方法もあります。

npm でインストールしたものも実際は node_modules/purescript/vendor ディレクトリへ配置された各 OS 用のバイナリファイル (例. psc.exe) を使っているようです。

pulp を使った開発

今回作成したソースは http://github.com/fits/try_samples/tree/master/blog/20160105/

プロジェクトの作成

任意のディレクトリ内で pulp init を実行すると、必要最小限のファイルが生成されます。

その際に Bower を使って PureScript の主要ライブラリ (以下) を自動的に取得しますので、git コマンドを使えるようにしておく必要があります。

  • purescript-console
  • purescript-eff
  • purescript-prelude
プロジェクト作成例
> pulp init

* Generating project skeleton in ・・・
bower cached        git://github.com/purescript/purescript-console.git#0.1.1
bower validate      0.1.1 against git://github.com/purescript/purescript-console.git#^0.1.0
bower cached        git://github.com/purescript/purescript-eff.git#0.1.2
bower validate      0.1.2 against git://github.com/purescript/purescript-eff.git#^0.1.0
bower cached        git://github.com/purescript/purescript-prelude.git#0.1.3
bower validate      0.1.3 against git://github.com/purescript/purescript-prelude.git#^0.1.0
bower install       purescript-console#0.1.1
bower install       purescript-eff#0.1.2
bower install       purescript-prelude#0.1.3

purescript-console#0.1.1 bower_components\purescript-console
└── purescript-eff#0.1.2

purescript-eff#0.1.2 bower_components\purescript-eff
└── purescript-prelude#0.1.3

ディレクトリ・ファイル構成は以下のようになります。

  • bower_components
    • purescript-console
    • purescript-eff
    • purescript-prelude
  • src/Main.purs
  • test/Main.purs
  • .gitignore
  • bower.json

デフォルトで用意されている src/Main.purs の内容は以下の通りです。

src/Main.purs
module Main where

import Prelude
import Control.Monad.Eff
import Control.Monad.Eff.Console

main :: forall e. Eff (console :: CONSOLE | e) Unit
main = do
  log "Hello sailor!"

ライブラリの追加には pulp dep install <ライブラリ名> を実行します。 そうすると bower install が実施されます。

bower.json の依存パッケージ設定へエントリを追加するには --save オプションを付けます。

purescript-tuples の追加例
> pulp dep install purescript-tuples --save

・・・
bower install       purescript-control#0.3.2
bower install       purescript-invariant#0.3.0

purescript-tuples#0.4.0 bower_components\purescript-tuples
└── purescript-foldable-traversable#0.4.2

purescript-foldable-traversable#0.4.2 bower_components\purescript-foldable-traversable
├── purescript-bifunctors#0.4.0
└── purescript-maybe#0.3.5

purescript-maybe#0.3.5 bower_components\purescript-maybe
└── purescript-monoid#0.3.2

purescript-bifunctors#0.4.0 bower_components\purescript-bifunctors
└── purescript-control#0.3.2

purescript-monoid#0.3.2 bower_components\purescript-monoid
├── purescript-control#0.3.2
└── purescript-invariant#0.3.0

purescript-control#0.3.2 bower_components\purescript-control
└── purescript-prelude#0.1.3

purescript-invariant#0.3.0 bower_components\purescript-invariant
└── purescript-prelude#0.1.3

ビルドと実行

Main.purs を以下のようにタプルを使った処理に書き換えて実行してみます。

src/Main.purs
module Main where

import Prelude
import Control.Monad.Eff
import Control.Monad.Eff.Console
import Data.Tuple

main :: forall e. Eff (console :: CONSOLE | e) Unit
main = do
  let t = Tuple "two" 2
  print t
  
  let r = t >>= \x -> Tuple " + 1" (x + 1)
  print r

pulp run を実行するとビルドした後に処理を実施します。
ビルドだけを実施したい場合は pulp build を使います。

実行
> pulp run

* Building project in ・・・\20160105\purescript
psc: No files found using pattern: src/**/*.js
* Build successful.
Tuple ("two") (2)
Tuple ("two + 1") (3)

ビルド結果は output ディレクトリへ生成されます。

パッケージング

pulp browserify を実行すると Browserify を使って output ディレクトリ内のファイルをパッケージングしてくれます。

browserify によるパッケージング
> pulp browserify > sample.js

* Browserifying project in ・・・\20160105\purescript
* Project unchanged; skipping build step.
* Browserifying...

パッケージングしたファイル (sample.js) の実行結果は以下の通りです。

実行結果
> node sample.js

Tuple ("two") (2)
Tuple ("two + 1") (3)

Web ブラウザで実行する事もできます。

index.html
<!DOCTYPE html>
<html>
<script src="sample.js"></script>
</html>

f:id:fits:20160105001028p:plain

備考 - pulp を使わない場合

最後に pulp を使わない場合のビルド・実行方法も書いておきます。

まずは、Bower を使って purescript-console 等の必要なライブラリを手動でインストールします。

ライブラリのインストール例
> bower install purescript-console --save

src/Main.purs を psc コマンドでビルドするには以下のようにします。

psc によるビルド例
> psc src/Main.purs bower_components/purescript-*/src/**/*.purs --ffi bower_components/purescript-*/src/**/*.js

bower_components 内の .purs ファイルと --ffi オプションで bower_components 内の .js ファイルを指定します。

ビルド結果はデフォルトで output ディレクトリへ生成されます。(-o オプションで変更する事も可能)

実行する場合は、NODE_PATH 環境変数へ output ディレクトリを設定し、node コマンドで require('Main/index.js').main() を実行します。

実行例
> set NODE_PATH=output
> node -e "require('Main/index.js').main()"

Tuple ("two") (2)
Tuple ("two + 1") (3)

Google Cloud Print を Web API で操作 - Unirest 使用

リフレッシュトークンを使って Google Cloud Print を Web API で操作してみます。

以前、「Google アカウントで Google API を利用 - google-api-services-gmail」 では Apache HTTPClient を使いましたが、今回は Unirest を使っています。

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

はじめに

事前準備としてリフレッシュトークンを取得しておきます。

今回は、「Google アカウントで・・・」 にて発行したクライアント ID をそのまま使用します。

(1) コードの取得

下記パラメータを付け https://accounts.google.com/o/oauth2/auth へ Web ブラウザ等でアクセスし API の利用を許可する事でコードを取得します。

パラメータ
response_type code
client_id クライアント ID の client_id 値(※1)
scope https://www.googleapis.com/auth/cloudprint
redirect_uri oob (※2)
(※1)クライアント ID 発行時にダウンロードした JSON ファイルの client_id 値

(※2)リダイレクトしない場合の設定。
       何らかのアプリケーション内から実行する際は
       それに合わせたリダイレクト先を指定する

例えば、以下のような URL へアクセスします。

URL 例
https://accounts.google.com/o/oauth2/auth?redirect_uri=oob&response_type=code&client_id=xxx.apps.googleusercontent.com&scope=https://www.googleapis.com/auth/cloudprint

Google アカウントでログインすると以下のような画面が表示されますので、「許可」ボタンを押下します。

f:id:fits:20151221233721p:plain

コードが表示されるのでコピーしておきます。

f:id:fits:20151221233746p:plain

(2) リフレッシュトークンの取得

次に、下記パラメータを https://www.googleapis.com/oauth2/v3/token へ POST し、リフレッシュトークンを含んだ JSON を取得します。

パラメータ
code (1) で取得したコード
client_id クライアント ID の client_id 値
client_secret クライアント ID の client_secret 値
grant_type authorization_code
redirect_uri oob (※1)
(※1)(1) の場合と同様です。

       ただし、以下のスクリプトでは
       urn:ietf:wg:oauth:2.0:oob を設定しています

Groovy スクリプト化すると以下のようになります。

get_refresh-token.groovy
@Grab('com.mashape.unirest:unirest-java:1.4.9')
import com.mashape.unirest.http.Unirest
import groovy.json.JsonSlurper

addShutdownHook {
    Unirest.shutdown()
}

def json = new JsonSlurper()

def conf = json.parse(new File(args[0])).installed
def code = args[1]

def res = Unirest.post('https://www.googleapis.com/oauth2/v3/token')
    .field('code', code)
    .field('client_id', conf.client_id)
    .field('client_secret', conf.client_secret)
    .field('grant_type', 'authorization_code')
    .field('redirect_uri', conf.redirect_uris[0])
    .asJson()

println res.body

クライアント ID 発行時にダウンロードした JSON (ここでは client_secret.json) を第1引数、(1) で取得したコードを第2引数へ指定して実行します。

取得したリフレッシュトークン JSON は後で使うので保存しておきます。 (ここでは token.json へ保存)

実行例
> groovy get_refresh-token.groovy client_secret.json 4/9ic・・・ > token.json

プリンタ一覧の取得

リフレッシュトークンを使う場合は、以下のような手順で API を呼び出します。

  1. リフレッシュトークンからアクセストークンを取得
  2. アクセストークンを使って API を実行

リフレッシュトークンからアクセストークン取得

アクセストークンを取得するには、下記パラメータを https://www.googleapis.com/oauth2/v3/token へ POST します。

パラメータ
client_id クライアント ID の client_id 値
client_secret クライアント ID の client_secret 値
grant_type refresh_token
refresh_token リフレッシュトークンの値 (※1)
(※1)リフレッシュトークン取得で保存した JSON の refresh_token 値

この処理は共通的に使用するため、Groovy の BaseScript として定義しました。

TokenScript.groovy
import com.mashape.unirest.http.Unirest
import groovy.json.JsonSlurper

abstract class TokenScript extends Script {
    // アクセストークンの取得
    def accessToken(String clientId, String clientSecret, String refreshToken) {
        def res = Unirest.post('https://www.googleapis.com/oauth2/v3/token')
            .field('client_id', clientId)
            .field('client_secret', clientSecret)
            .field('grant_type', 'refresh_token')
            .field('refresh_token', refreshToken)
            .asJson()

        res.body.object
    }
}

API 実行 (プリンタの一覧取得)

アクセストークンは HTTP ヘッダーへ Authorization: Bearer <アクセストークン> のように設定して使います。(例. Authorization: Bearer ya26.pw・・・)

クラウドプリンタ一覧は https://www.google.com/cloudprint/search へ GET すれば取得できます。 (q や type パラメータを付けて条件検索することも可能)

get_printers.groovy
@Grab('com.mashape.unirest:unirest-java:1.4.9')
import com.mashape.unirest.http.Unirest
import groovy.json.JsonSlurper
import groovy.transform.BaseScript

// TokenScript を BaseScript へ設定
@BaseScript TokenScript baseScript

addShutdownHook {
    Unirest.shutdown()
}

def json = new JsonSlurper()

def conf = json.parse(new File(args[0])).installed
def token = json.parse(new File(args[1]))

// アクセストークン取得
def newToken = accessToken(
    conf.client_id,
    conf.client_secret,
    token.refresh_token
)

// API の実行
def printers = Unirest.get('https://www.google.com/cloudprint/search')
    .header('Authorization', "${newToken.token_type} ${newToken.access_token}")
    .asJson()

println printers.body

実行結果は以下の通りです。
まだプリンタを登録していないためデフォルトの 「ドライブに保存」 しかありません。

実行結果 (出力結果は加工済、実際は改行・字下げは無し)
> groovy get_printers.groovy client_secret.json token.json

{
    "request":{・・・},
    "printers":[{
        "isTosAccepted":false,
        "displayName":"ドライブに保存",
        "capsHash":"",
        "description":"ドキュメントを Google ドライブで PDF として保存します",
        "updateTime":"1370287324050",
        "ownerId":"cloudprinting@gmail.com",
        "type":"DRIVE",
        "tags":["save","docs","pdf","google","__google__drive_enabled"],
        "proxy":"google-wide",
        "ownerName":"Cloud Print",
        "createTime":"1311368403894",
        "defaultDisplayName":"ドライブに保存",
        "connectionStatus":"ONLINE",
        "name":"Save to Google Docs",
        "id":"__google__docs",
        "accessTime":"1316132041869",
        "status":""
    }],
    "success":true,
    ・・・
}

また、https://www.google.com/cloudprint/list?proxy=<プロキシ> へ GET すると、指定プロキシへ属するプリンタ一覧を取得できます。

印刷処理 (Google Drive へ保存)

最後に、「ドライブに保存」 プリンタへファイルを印刷してみます。 (実際は Google Drive へファイル保存)

印刷は、下記の必須パラメータ(他にもパラメータあり)を multipart/form-datahttps://www.google.com/cloudprint/submit へ POST します。

パラメータ
printerid プリンタ ID (※1)
title 印刷タイトル
ticket 印刷設定 (※2)
content 印刷するファイルの内容
(※1)今回は __google__docs を使用
(※2)CJT (Cloud Job Ticket) フォーマットで印刷オプションなどを指定
submit_file.groovy
@Grab('com.mashape.unirest:unirest-java:1.4.9')
import com.mashape.unirest.http.Unirest
import groovy.json.JsonSlurper
import groovy.json.JsonBuilder
import groovy.transform.BaseScript

@BaseScript TokenScript baseScript

addShutdownHook {
    Unirest.shutdown()
}

def ticketBuilder = new JsonBuilder()

// ticket の内容
ticketBuilder (
    version: '1.0',
    print: {}
)

def json = new JsonSlurper()

def conf = json.parse(new File(args[0])).installed
def token = json.parse(new File(args[1]))
def file = new File(args[2])

// アクセストークンの取得
def newToken = accessToken(
    conf.client_id,
    conf.client_secret,
    token.refresh_token
)

// ticket の JSON 文字列化
def ticket = ticketBuilder.toString()

// 印刷
def res = Unirest.post('https://www.google.com/cloudprint/submit')
    .header('Authorization', "${newToken.token_type} ${newToken.access_token}")
    .field('printerid', '__google__docs')
    .field('title', file.name)
    .field('ticket', ticket)
    .field('content', file)
    .asJson()

println res.body

PDF ファイルを POST した結果は以下の通りです。
Google Drive で確認すると sample1.pdf ファイルが保存されていました。

実行結果 (出力結果は加工済、実際は改行・字下げは無し)
> groovy submit_file.groovy client_secret.json token.json data/sample1.pdf

{
    "request":{・・・},
    "success":true,"xsrf_token":"AIp・・・",
    "message":"印刷ジョブが追加されました。",
    "job":{
        "ticketUrl":"https://www.google.com/cloudprint/ticket?format=xps&output=xml&jobid=0db・・・",
        "printerName":"",
        "errorCode":"",
        ・・・
        "title":"sample1.pdf",
        "tags":["^own"],
        ・・・
        "printerid":"__google__docs",
        ・・・
        "contentType":"application/pdf",
        "status":"DONE"
    }
}

また、試しに以下のような拡張子のファイルを POST してみたところ、自動的に PDF へ変換され Google Drive へ保存されました。 (例えば sample1.html は sample1.html.pdf で保存)

  • .html
  • .docx
  • .odt