【tensorflowjs入門】Nexeでシェルスクリプトからデータ解析できるツールを作成する


2022/08/22
【tensorflowjs入門】機械学習の処理時間の短縮化の話〜SIMD処理とWebGLの比較

かねてよりtensorflowjsをNexe化して、Linuxコマンドと連携してコンソールから動かしてみたいと考えておりました。

ということで、今回はtensorflowjsが手軽に動かせるNexeアプリの導入方法をじっくりと考えてみたいと思います。


合同会社タコスキングダム|蛸壺の技術ブログ

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

tensorflowjsをNexeを使ったバイナリビルド

以前の記事で説明したように、Nexeを使って「Hello World」的なバイナリアプリを作る方法を解説していました。

合同会社タコスキングダム|蛸壺の技術ブログ
【簡単nodejsアプリ開発】NexeでCLI版スネークゲームを作ってみる・前編

nodejsのユーティリティ・nexeを使って、簡単なCLIコンソールゲームのスネークゲームを作ってみます。

Nexeプロジェクトの作成はそちらの記事で確認いただくとして、以降の内容ではNexeアプリのリソースの実装に関してのみ解説します。

なお、今回のプロジェクト程度のレベルではjavascriptでも十分なのですが、Typescriptベースで話を進めています。

「Typescriptのビルド環境が正常にインストールされている」ものと、させていただきます。

まずはtensorflowjsをインストールします。

            
            $ yarn add @tensorflow/tfjs
        
ここで試しに、プロジェクトのエンドポイントのリソースファイルとしてindex.ts

            
            import * as tf from '@tensorflow/tfjs';

(() => {
    const t = tf.tensor([-2, 1, 0, 5]);
    const o = tf.layers.activation({activation: 'relu'}).apply(t);
    (o as any).print();
})();
        
としてみます。

これを
tscでTypescriptビルドして、出力されたdist/index.jsを更にnexeアプリビルドに掛けます。

            
            $ tsc
$ nexe dist/index.js --target=linux-x64-14.15.3 -o nexe-app
        
ここでのターゲットはLinuxOS x64向けです。

では早速ビルドできたNexeアプリを起動してみると、

            
            $ ./nexe-app
============================
Hi there 👋. Looks like you are running TensorFlow.js in Node.js. To speed things up dramatically, install our node backend, which binds to TensorFlow C++, by running npm i @tensorflow/tfjs-node, or npm i @tensorflow/tfjs-node-gpu if you have CUDA. Then call require('@tensorflow/tfjs-node'); (-gpu suffix for CUDA) at the start of your program. Visit https://github.com/tensorflow/tfjs-node for more details.
============================
Tensor
    [0, 1, 0, 5]
        
Nexeを使っても特に問題もなく、tensorflowjsがバイナリとして動作することが分かります。


合同会社タコスキングダム|蛸壺の技術ブログ

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

tensorflowjsで解析する入力データの準備

tensorflowが提供している解析法の種類はここでは挙げきれないほど多岐に渡ります。

以前の話で、tensorflowjsで
「線形回帰」させて、結果をchartjsで視覚化するまでの話をしたことがありました。

合同会社タコスキングダム|蛸壺の技術ブログ
【tensorflowjs & kerasの使い方】燃費を予測するサンプルを理解する〜非線形回帰解析①

tensorflowjsとkerasで、データを非線形回帰解析する手順を考えていきます。

今回もそちらの内容に沿う形で、視覚化は抜きにしてコマンドラインから線形回帰解析の結果だけを得られるようなNexeアプリに仕上げることを目指します。

フィッテングデータのツールアプリへの入力ファイル

まずは、解析で使えるように入力データを準備していきます。

作業ルートにtmpフォルダを作り、そこに代表的なサンプルとして公開されているAuto MPG用のデータセットをダウンロード・データの整理を一括して行います。

            
            $ mkdir ./tmp
$ cd tmp && wget https://archive.ics.uci.edu/ml/machine-learning-databases/auto-mpg/auto-mpg.data
$ cat auto-mpg.data | sed -r '/\?/d' | sed -r 's/\t/,/' | sed -r 's/ {2,}/,/g' > output.csv
        
なお、データの整理はデータ構造の特性を考慮してSedで一括して行いましたが、整理内容の詳しい中身は以前の記事で解説していますので、興味があれば一読ください。

今回も
以前と同様にデータ5列目の「車の重量」をx軸にとり、データ1列目の「燃費」をy軸で線形回帰させてみます。

ということで、解析に必要なデータを重量でソート・抽出するには、

            
            $ cat output.csv | sort -k5 -t, | awk -F, '{print $5","$1}'
1613.,35.0
1649.,31.0
1755.,39.1
#...中略
5140.,13.0
        
このCSV形式のデータ列をNexeアプリで扱えるようにしていくことを以降で考えていきます。


合同会社タコスキングダム|蛸壺の技術ブログ

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

自作のNexeアプリもパイプ処理できるようにシェルスクリプト化する

Nexeアプリへのデータの読ませ方・入力方法はいくつかあって、もっとも利用されるのはfs.readFile(ファイル名)からデータファイルを直接読み込むことになると思います。

他には、データサーバーのバックエンドにRestAPIなどの仕組みが提供されていれば、
fetchなどでデータの中身を取得できるかもしれません。

ここではNexeでtensorflowjsをCLIとしてシェルスクリプト化できるという特色を最大限に活かし、catコマンドなどから読み込んだ入力データを標準出力でパイプし、後段のNexe化したコマンドでさらに標準入力として受け取るように工夫してみましょう。

利用イメージとしては、

            
            $ cat input.csv | ./nexe-app
#解析結果が表示される
        

Nexeアプリでコマンド引数を受け取る

Nexeといえど、nodejsコマンドと考え方はほぼ同じです。

つまり、
process.argv[引数番号]でコマンド引数を受け取ることができます。

例えば、
index.tsを一旦以下のようにしてみましょう。

            
            (() => {
    console.log(process.argv[0]);
    console.log(process.argv[1]);
    console.log(process.argv[2]);
    console.log(process.argv[3]);
})();
        
これで、Nexeアプリをビルドし、以下のコマンドで利用してみます。

            
            $ ./nexe-app '太郎, 花子' '次郎, 良子'
/[現在の作業ディレクトリ]/nexe-app
/[現在の作業ディレクトリ]/dist/index.js
太郎, 花子
次郎, 良子
        
というように表示されるはずです。

nodejsコマンド同様に注意が必要なのは、
process.argv[0]がアプリケーション名で、process.argv[1]がエンドポイントとなっているメインターゲットのjsファイル名になります。

つまり、コマンド引数自体は、
process.argv[2]以降から引き出せるようになっています。

Nexeをパイプ処理できるようにする

自作のシェルスクリプトでパイプするには、以前解説したテクニックを利用することになります。

合同会社タコスキングダム|蛸壺の技術ブログ
【シェルスクリプトツール作成の基本】自作のシェルスクリプトでパイプを使えるようにする

前段で実行したコマンドの標準出力から、自作したシェルスクリプトでもパイプでつないで処理をするための方法を考察します。

以下のようにパイプを補助してくれるシェルスクリプトを作成してみましょう。

            
            #!/bin/bash

if [ -p /dev/stdin ]; then
    _str=$(cat -)
else
    _str=$@
fi

./nexe-app "${_str}"
        
これで通常の使い方もパイプもできているか確認してみます。

            
            $ chmod +x nexe-pipe.sh

$./nexe-pipe.sh '太郎, 花子'
/[現在の作業ディレクトリ]/nexe-app
/[現在の作業ディレクトリ]/dist/index.js
太郎, 花子 次郎, 良子
undefined

$ echo '太郎, 花子' | ./nexe-pipe.sh
/home/taconocat/tacochart/tacochart_viewer/tca/hurst_analysis/hurst-app
/home/taconocat/tacochart/tacochart_viewer/tca/hurst_analysis/dist/index.js
太郎, 花子
undefined
        
ちゃんと、コマンド引数が通常のやり方とパイプからでも処理されています。

なお、今回は引数が1つしか使いませんが、複数の引数を使いたい場合には、同じく解説した
こちらの方法をお試しください。


合同会社タコスキングダム|蛸壺の技術ブログ

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

CLI版tensorflowjsで線形回帰する

では入力データと、Nexeアプリへの引数の渡し方も押さえていただいたところで、本題のtensorflowjsでの線型回帰のコードを作成していきます。

引数から渡した生データを配列化した数値に復元する

まずはコマンド引数で与える生のcsv文字列から、javascriptで使える2次元配列形式に変換する関数を作成します。

index.tsを以下のように修正してみましょう。

            
            function csv2Array(str: string): number[][] {
    const csvData = [];
    const lines = str.split('\n');
    for (let i = 0; i < lines.length; ++i) {
        if (lines[i] == '') { continue; }
        const cells = lines[i].split(',');
        csvData.push(cells.map(v => parseFloat(v)));
    }
    return csvData;
}

(() => {
    const rawCSV = process.argv[2];
    if (rawCSV) console.dir(csv2Array(rawCSV));
})();
        
これをビルドし、生成されたNexeアプリをこれまで解説してきたテクニックで統合して使ってみます。

            
            $ cat tmp/output.csv | sort -k5 -t, | awk -F, '{print $5","$1}' | ./nexe-pipe.sh
[
  [ 1613, 35 ],   [ 1649, 31 ],   [ 1755, 39.1 ], [ 1760, 35.1 ], [ 1773, 31 ],
  [ 1795, 33 ],   [ 1795, 33 ],   [ 1800, 36.1 ], [ 1800, 36.1 ], [ 1825, 29.5 ],
#...中略
  [ 2220, 23 ],   [ 2220, 25 ],   [ 2223, 25 ],   [ 2226, 21 ],   [ 2228, 25 ],
  ... 292 more items
]
        
ちゃんと入力データが数値の2次元配列となっていることが分かります。

線型回帰解析のフィッティングパラメータを得る

tensorflowjsのkerasモデルの活性関数「linear」一層で訓練することは、すなわち線形回帰で最適のパラメーター(比例項とバイアス項)を得ること、と同義です。

このことを念頭に、
index.tsは最終的に以下のように拡張します。

            
            import * as tf from '@tensorflow/tfjs';

function csv2Array(str: string): number[][] {
    const csvData = [];
    const lines = str.split('\n');
    for (let i = 0; i < lines.length; ++i) {
        if (lines[i] == '') { continue; }
        const cells = lines[i].split(',');
        csvData.push(cells.map(v => parseFloat(v)));
    }
    return csvData;
}

async function linearRegression(rawData: number[][]) {
    const model = tf.sequential();

    //👇入力層兼フィッテング解析関数
    model.add(tf.layers.dense({inputShape: [1], units: 1, useBias: true, activation: 'linear'}));

    console.log('Training started.');

    const originalData = [];
    for (const row in rawData) {
        originalData.push({x: rawData[row][0], y: rawData[row][1]});
    };

    //👇テンソルに変換・データの正規化
    const {inputs, labels, inputMax, inputMin, labelMax, labelMin} = convertToTensor(originalData);

    //👇モデルをトレーニング(=線型回帰解析)
    await trainModel(model, inputs, labels);

    //👇解析前に正規化・標準化していたデータを考慮して、元のXY空間に復元
    const xmax = inputMax.dataSync()[0];
    const xmin = inputMin.dataSync()[0];
    const ymax = labelMax.dataSync()[0];
    const ymin = labelMin.dataSync()[0];
    let prop = model.layers[0].getWeights()[0].dataSync()[0];
    let bias = model.layers[0].getWeights()[1].dataSync()[0];
    bias = ymin + (ymax - ymin) * (bias - prop * xmin / (xmax - xmin));
    prop = prop * (ymax - ymin) / (xmax - xmin);

    console.log(`推定曲線: y = ${prop} x + ${bias}`);

    console.log('Fitting has done.');
}

//👇学習データをTensor型に変換する関数
function convertToTensor(data: Array<{x:number, y:number}>) {
    return tf.tidy(() => {
        //👇学習データをシャッフル
        tf.util.shuffle(data);

        //👇xyデータ配列をNx1テンソルに変換
        const inputs = data.map(d => d.x)
        const labels = data.map(d => d.y);
        const inputTensor = tf.tensor2d(inputs, [inputs.length, 1]);
        const labelTensor = tf.tensor2d(labels, [labels.length, 1]);

        //👇入力データの正規化
        const inputMax = inputTensor.max();
        const inputMin = inputTensor.min();
        const labelMax = labelTensor.max();
        const labelMin = labelTensor.min();
        const normalizedInputs = inputTensor.sub(inputMin).div(inputMax.sub(inputMin));
        const normalizedLabels = labelTensor.sub(labelMin).div(labelMax.sub(labelMin));

        return {
            inputs: normalizedInputs,
            labels: normalizedLabels,
            inputMax,
            inputMin,
            labelMax,
            labelMin
        }
    });
}

//👇モデルの学習を行う関数(=線型回帰解析本体)
async function trainModel(model: tf.Sequential, inputs: tf.Tensor, labels: tf.Tensor) {
    //👇モデルをコンパイル => 学習方法を指定
    model.compile({
        optimizer: tf.train.adam(),
        loss: tf.losses.meanSquaredError,
        metrics: ['mse'],
    });

    //👇バッチサイズ(16~32くらいで良い)
    const batchSize = 32;
    //👇エポック数
    const epochs = 500;
    //👇エポック回数の学習を実行する
    return await model.fit(inputs, labels, {
        batchSize,
        epochs,
        shuffle: true,
        callbacks: {
            onEpochEnd: async(epoch, logs) => {
                //👇繰り返し回数と損失をコンソール出力
                console.log(`Epoch#${epoch} : Loss(${logs.loss}) : mse(${logs.mse})`);
            }
        }
    });
}

(async () => {
    const rawCSV = process.argv[2];
    if (rawCSV) {
        const csvData = csv2Array(rawCSV);
        await linearRegression(csvData);
    }
})();
        
これをビルドしてtensorflowjsを組み込んだNexeアプリで線型回帰解析ができるかを確認してみます。

            
            $ cat tmp/output.csv | sort -k5 -t, | awk -F, '{print $5","$1}' | ./nexe-pipe.sh

============================
Hi there 👋. Looks like you are running TensorFlow.js in Node.js. To speed things up dramatically, install our node backend, which binds to TensorFlow C++, by running npm i @tensorflow/tfjs-node, or npm i @tensorflow/tfjs-node-gpu if you have CUDA. Then call require('@tensorflow/tfjs-node'); (-gpu suffix for CUDA) at the start of your program. Visit https://github.com/tensorflow/tfjs-node for more details.
============================
Training started.
Epoch#0 : Loss(0.4150969684123993) : mse(0.4150969684123993)
Epoch#1 : Loss(0.40245047211647034) : mse(0.40245047211647034)
Epoch#2 : Loss(0.3915448486804962) : mse(0.3915448486804962)
#...中略
Epoch#498 : Loss(0.013235836289823055) : mse(0.013235836289823055)
Epoch#499 : Loss(0.013220091350376606) : mse(0.013220091350376606)
推定曲線: y = -0.0076351825945764045 x + 46.188474321707034
Fitting has done.
        
今回はエポックを500回程度にしてますが、残渣(エラー)関数は十分収束していることも分かります。

肝心の結果は、
y = -0.0076x + 48.2くらいに落ち着くようです。

これは
前回解説したとき同様に、以下の図のような一次直線とほぼ一致する結果になると思います。

合同会社タコスキングダム|蛸壺の技術ブログ


合同会社タコスキングダム|蛸壺の技術ブログ

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

まとめ

今回は以前紹介したtensorflowjsによる「線型回帰解析」をシェルコマンド化して、ツールとしてさらに使いやすくするためのガイダンスのような内容で説明していきました。

話の題材を簡単にするため、線型回帰でやりましたが、tensorflowjsで応用できるテーマはほとんどNexeツール化できると思います。

今後も面白い応用を思いついたらこのブログ記事で紹介してみようと思います。