[tensorflow.js x kerasの使い方] tensorflow.jsで初めてのLSTM入門


2020/12/25

Qiitaなどの技術系コミティサイトでtensorflow/kerasのLSTMの解説記事を探すとかなり沢山あります。

なので若干今更感がハンパないですが、tensorflow.jsでの技術資料は意外と少なかったので、知識共有を目的にjs版LSTMの使いこなしの記事を残しておきたいと思います。

いかんせん時系列分析系の機械学習の勉強にはこのテーマは避けられないので、この辺でいっぺん手頃な例題を交えてtensorflow.js/kerasのLSTMの使い方を復習もかねてじっくり解説していきます。


データ処理は全部シェルで! tensorflow.jsは解析だけに注力

この記事の前半ではまずシェルスクリプト(主にawk)で学習用・検証用・予測用データの生成する方法を紹介します。

これは以前の記事(👇のリンクで翔ます)で紹介した手法のおさらいです。

シェルスクリプトによるデータ処理方法を身につけることで、流行り廃れのトレンドが大きい機械学習用のプログラミング言語分野においても、臨機応変に対応することができるはずです。

後半ではtensorflow.js/kerasを用いたLSTMモデルの構築方法にスポットを当てながら解説を進めていきます。

なお、このサイトではLSTMの中身の原理的な説明は控えめにしておきます。

それは深く話していくととても長くなりますし、巷のサイトでも『LSTMとは?』『RNNとは?』などで検索していただくと詳しく解説して下さっている方も多くおられます。

例えば
こちらのサイトがとても分かりやすく図解されています。

なので、LSTMを学習モデルで使うための方法論的、技巧的な面に重きをおきながら具体例でやっていきます。


実践例〜正弦波の推定

簡単な例として公開されていたこちらの正弦波の予想をtensorflow.js版で移植する感じにやっていきます。

Awkでデータセットの生成

まずはawkを使ってノイズを含む単純なsin波形を含ませたデータセットを以下のシェルスクリプトで作成します。

            
            $ awk -v seed="${RANDOM}" '
    function sin_with_noise(ampl_, arr_len_, period_) {
        PI = 3.14159265359;
        count = 0;
        while(count < arr_len_) {
            x1 = rand();
            tmp_rand_val = 2.0 * (x1 - 0.5);
            label_arr[count] = sin(2 * PI * count / period_) + ampl_ * tmp_rand_val;
            count++;
        }
    }
    BEGIN{
        OFS=",";
        srand(seed);
        sin_with_noise(0.05, 200, 100);
        arr_len = length(label_arr);
        for (i = 0; i < arr_len; i++) {
            print i, label_arr[i];
        }
    }
' > sin_rawdata.csv
        
念の為、このcsvのデータを一度チャート化して確認してみると以下のような図になっています。

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

ちゃんと微小なノイズありの正弦波になっているようです。

ここで出力したデータ
sin_rawdata.csvは以降の節でまた使うので一旦csvファイルにて保存しておきます。

LSTM用のデータセット整形

KerasモデルのLSTMレイヤーに入力する際のデータ形状は、時系列を考慮したものにしないといけません。

通常のKerasモデルのレイヤーであれば、単一の出力層のノードを
ユニットと呼んで、このユニット数を増やしてたり、活性化関数を適切に選択したりして、学習精度を向上させることができました。また同レイヤーのユニット同士は互いに独立しており、横の繋がりはないのが特徴として挙げられます。

対して、LSTMモデルを扱うときには、出力層のユニット数と併せて、入力層のノードにあたる
ステップシークエンスなどと呼ばれるノードのサイズも定義しておかないと正しく使うことができません。またLSTMの中の中間層にある隠れノードが横の広がりを持って接続されており、その一つのユニットの内部でも複数のノードで構成される特別な構造を形成されていますが、最初のうちは何かブラックボックスと考えておいたほうが幸せになれます。

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

ちなみに今回はリターンシークエンス機能は使いませんので、説明はとりあえず省略します。

詳しい構造はさておき、各ステップは入力される時系列データの各段階の要素をそれぞれ担当するように割り振られ、時系列的にデータの古い順で各ステップへ要素が送られていくということがLSTMを利用する際の重要なポイントです。

例えば先ほどの
sin_rawdata.csvの中身でいうと、データ200点を(x,y)(x,y)のデータ形状になるようにcsv形式で吐き出したものです。

            
            $ cat sin_rawdata.csv
0,0.0469849
1,0.0711396
2,0.0916004
3,0.170817
4,0.265518
5,0.268593
6,0.324074
#...中略
199,-0.022768
        
このままのデータ形状だと、LSTMモデルの入力には当然利用できません。

まずは、LSTMレイヤーのステップ数を決める必要があります。

例えばステップ数を5に設定してみると、時系列データで連続する5個分を、LSTM層内部のステップ0からステップ4へ順に食わせるようにデータを成形する必要が出てきます。上のデータセットでいうと具体的には下のような感じです。

            
            #ステップ0,ステップ1,ステップ2,ステップ3,ステップ4
0.0469849,0.0711396,0.0916004,0.170817,0.265518
0.0711396,0.0916004,0.170817,0.265518,0.268593
0.0916004,0.170817,0.265518,0.268593,0.324074
#...以下略
        
というのがLSTMの基本的な入力データ整形の方針になりますが、時系列順を崩さずに、古いデータから順に決まった数だけ左にずらしていくだけの機械的な作業と考えればさほど難しく考える必要はありません。

以下、
sin_rawdata.csvのデータセットをステップ数25でLSTM用に整形させるシェルスクリプトの例です。

            
            $ cat sin_rawdata.csv | awk -v stepsize=25 -F "," '
    BEGIN { OFS="," }
    { original_arr[$1] = $2; }
    END {
        arr_len = length(original_arr);
        for (j = 0; j < arr_len - stepsize + 1; j++) {
            rslt = original_arr[j];
            for (i = 1 ; i < stepsize ; i++) {
                rslt = rslt "," original_arr[j+i]
            }
            print rslt;
        }
    }
' > sin_lstm.csv
        
吐き出されたsin_lstm.csvというファイルの中身を見てみると、

            
            $ cat dist/sin_lstm.csv
0.0469849,0.0711396,0.0916004,0.170817,0.265518,0.268593,0.324074,0.396819,0.472588,0.57795,0.561532,0.620256,0.725495,0.715577,0.798626,0.777591,0.808541,0.88629,0.887139,0.907561,0.998604,0.94918,0.995441,0.979329,0.990558
0.0711396,0.0916004,0.170817,0.265518,0.268593,0.324074,0.396819,0.472588,0.57795,0.561532,0.620256,0.725495,0.715577,0.798626,0.777591,0.808541,0.88629,0.887139,0.907561,0.998604,0.94918,0.995441,0.979329,0.990558,0.954538
#...以下略
        
のように時系列データがちゃんと並んでいるように出来ました。

LSTM用のデータセットはその性質上長く、容量が膨大になりがちですが、シェルスクリプトでデータ成形するように統一して管理していれば、元データからいつでも同一のデータが生成可能ですので、データ保守の面でも優れています。

正解ラベル列のデータセット

LSTMレイヤーへの入力データセットは無事に仕上がりましたが、訓練用の正解ラベル列がありませんでしたので、このままでは学習モデルの評価のしようがありません。

LSTMレイヤーを含んだモデルでの
fitメソッドを利用した訓練で特に理解しておかないといけないのが、入力インプットデータと入力ラベルデータの次元(テンソル形状)についてです。

今回はステップ数25で、単なるスカラー値(一次元)のデータを入力として扱いたい場合、

            
            model.fit(
    inputs, // [N x 25 x 1]
    label, // [N x 1]
    {
    //...オプション以下略
        
という感じに使うようにします。

こちらの解説記事の中でKerasのLSTMモデルでは入出力形式が詳しく論じられていますが、

            
            入力:
    [batchSize, timesteps, inputDim]
出力:
    returnSequencesがfalse(デフォルト):
        [batchSize, units]
    returnSequencesがTrue:
        [batch_size, timesteps, units]
        
という図式が特に重要になります。

今回はリターンシークエンスは使いませんが、注目すべきはLSTMの前後でデータが1次元(timesteps)潰されてしまいます。

ここが理解できないままfitを使うと、inputsとlabelを同じ3次元データにしているはずなのに、内部での評価関数の計算前に「インプットとラベルの次元が違う」とシステム側から怒られてしまいます。

もう既に上に答えを書きましたが、inputsを3次元テンソルに、labelは2次元テンソルにそれぞれ変換しないといけませんのでご注意ください。

そして先程のデータ生成のスクリプトの使うときに
ステップ数 + 1で走らせれば、一番右端の列データが正解ラベル列として使うことができます。もちろんLSTM用の入力データとラベルデータを別々に分けても良いのですが、シャッフルする場合には少し取扱いが面倒になります。

先程のステップ数25の例でいうと、データセットの1~25列目までが入力用の時系列データで、26列目が正解ラベル列です。

学習用データの分ける意味

学習モデルの訓練用には70%、訓練後の検証用に20%、本番前の予想用に10%のデータを使うことが一般のようです。

しかし、絶対に正しいデータの切り分け比率などが決まっているわけではないので、必ずしも拘る必要はないと個人的には思います。

今回はただただスクリプトでデータを無尽蔵に生成できるので、こういうシビアなデータ分配の作業は行いませんが、現実のデータは有限でかなり少ないデータから最高のパフォーマンスを引き出さないといけない場面がほとんどだと思います。

そこはシャッフルを使うなどで擬似データを生成しながら創意工夫で学習モデルをより良く訓練させる必要もでてきます。

また、限られた実測データを使って、より効率に学習してくれるモデル自体を構築させることも機械学習の重要な応用テーマになります。こちらの方法の場合、数学モデルをきちんと確立してからでないと利用することが難しいですが、ガチッとハマれば少ない訓練で、最高のパフォーマンスが得られるモデルにすることも可能かと思います。

下は簡単な正規分布を予測させた際の関連記事です。参考までに。

tensorflow.jsの学習モデルによるデータ解析

データの生成の説明に大分時間を割いてしまいましたが、後半の訓練・検証・予測の作業をtensorflow.jsで行っていきます。

実際、移植とはいっても本家pythonのtensorflowとは、細かいパラメータの取扱の作法や構文が違うのでjs/tsへの移行作業も一筋縄といかず、コード描き直しに近い感覚です。

勿体つけるのも回りくどいですので、以下が今回の学習モデル+訓練用プログラムになります。

まずはLSTMモデルの計算部分として、
calcLstm関数の設計をしてみます。

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

async function calcLstm(
    rawData: number[][],
    testData: number[][]
) {
    //①シーケンシャルモデルを構築
    const model = tf.sequential();
    model.add(tf.layers.lstm({
        units: 32,
        returnSequences: false,
        inputShape: [25, 1],
    }));
    model.add(tf.layers.dense({ units: 128, activation: "relu" }));
    model.add(tf.layers.dense({ units: 1, activation: "linear", useBias: true }));
    model.summary();

    model.compile({
        optimizer: tf.train.adam(3e-4),
        loss: tf.losses.meanSquaredError,
        metrics: ['accuracy']
    });

    //②生のデータセット配列から入力データとラベルデータをテンソル形式で返す関数
    //ついでにシャッフルも行う
    //(実装は後述)
    const {inputs, labels} = await convertToTensor(rawData);

    //③モデルの訓練を開始
    console.log('Training started...');
    await model.fit(inputs, labels, {
        epochs: 50,
        shuffle: false,
        callbacks: [
            tf.callbacks.earlyStopping({
                monitor: 'loss',
                mode: 'auto',
                patience: 20
            }),
            new tf.CustomCallback({
                onEpochEnd: async(epoch: any, logs: any) => {
                    console.log(`Epoch#${epoch} : Loss(${logs.loss}) : acc(${logs.acc})`);
                },
            })
        ]
    });
    console.log('Training has done.');

    //④訓練済みのモデルで予想データを計算(検証用)
    //実装は後述
    const inputs4eval = await convertToTensorForValidation(testData);
    return await testModel(model, inputs4eval, 25);
}
        
上のコードで補助的なメソッドとして使ったconvertToTensorconvertToTensorForValidationtestModelの実装は以下です。

            
            // 元データの配列の列の長さは26で固定
// [0:24] > LSTM層の入力データ, [25] > ラベル列
async function convertToTensor(data: number[][], stepNum: number = 26): any {
    return tf.tidy(() => {
        tf.util.shuffle(data);
        const [input2d, labelTensor] = tf.tensor2d(data, [data.length, stepNum]).split([stepNum - 1, 1], 1);
        const inputTensor: any = (input2d as tf.Tensor).reshape([data.length, stepNum - 1, 1]);

        const inputMax = inputTensor.max();
        const inputMin = inputTensor.min();
        const labelMax = labelTensor.max();
        const labelMin = labelTensor.min();

        // それぞれの最大・最小でデータ正規化(数値0から1の範囲へ変換)を行う
        // =Leru等の非負数を出力にする活性化関数を適用させる目的
        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
        }
    });
}

//こちらはシャッフル無しの検証用テンソルを返す
//こちらはLSTMモデルに入力するための検証用データセット(25列使用)を利用
async function convertToTensorForValidation(
    data: number[][],
    stepNum: number = 25
) {
    return tf.tidy(() => {
        const input2d = tf.tensor2d(postData, [postData.length, stepNum]);
        const inputTensor = input2d.reshape([postData.length, stepNum, 1]);

        const inputMax = inputTensor.max();
        const inputMin = inputTensor.min();

        inputMax.print();
        inputMin.print();

        // それぞれの最大・最小でデータ正規化(数値0から1の範囲へ変換)を行う
        const normalizedInputs = inputTensor.sub(inputMin).div(inputMax.sub(inputMin));

        return {
            inputs: normalizedInputs,
            inputMax,
            inputMin
        }
    });
}

async function testModel(
    model: any,
    normalizationData: any, // 入力データは正規化した集合[0,1]^Nであることに注意
    offset: number = 1 //x軸方向のオフセット(チャート描画用)
) : Array<{x: number, y: number}> {
    const {
        inputs, //正規化済みの集合I: [0,1]^N
        inputMax,
        inputMin
    } = normalizationData;

    const [preds] = tf.tidy(() => {
        const preds = model.predict(inputs);
        const unNormPreds = preds.mul(inputMax.sub(inputMin)).add(inputMin);
        return [unNormPreds.dataSync()];
    });

    const predictedPoints = Array.from(preds).map((val, i) => {
        return {
            x: i + offset,
            y: val as number
        }
    });
    return validatedPoints;
}
        
さて、calcLstmを使って計算させてみますが、例えば計算を走らせるスクリプトは以下のようになります。

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

const data4Train: number[][] = //...訓練用の時系列入力データ&ラベル列を持つcsvから配列を返す関数
const data4Test: number[][] = //...モデル評価用に時系列入力データ列を持つcsvから配列を返す関数

const result = await calcLstm(data4Train, data4Test); //検証用の予想データを得られる
        
LSTM用の訓練用、検証用のデータセットに関しては上記で説明したスクリプトで何回でも生成できますので、今回はデータ分割する必要もありません。csvデータから二次元配列にする方法はお手元の環境によって適した実装がありそうなので、ここではお茶を濁しておきます。

ということで、データ全てをバッチに割り当てて、エポックを控えめに50回ほどにして計算を回した結果が以下の図のようになります。

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

図中の青点の散布図が
x=nx = n時点の実際の(前節のシェルスクリプトで生成した)観測データで、黒の実線が訓練済みのLSTM層を含んだ学習モデルで、過去の実データx=n24x = n - 24から現在のx=nx = nまでの25個を元に算出したx=n+1x = n+1の予想点を繋いだカーブになっています。

この予想カーブをよく見ると実際のデータよりも右にシフトしているように見えます。

これは予測点が実測点よりも少し遅れて後追いになってしまう(ラグタイムが発生する)ことから、
ラグモデル(Lag model)と呼ばれている現象のようです。

ラグモデルは時系列データでの訓練があまり十分でない場合のLSTMからの結果では良くみられるようです。ラグモデルはエポック数を大きくすると次第に実測データに近づき緩和される傾向になるようですので、訓練が十分出来ているかどうかの指標になるように思います。(長くなりそうですので今回の記事では訓練精度に関してまでは解説しません。)

ついでに計算中のブラウザのコンソール出力は以下のようになります。

            
            _________________________________________________________________
Layer (type)                 Output shape              Param #   
=================================================================
lstm_LSTM1 (LSTM)            [null,32]                 4352      
_________________________________________________________________
dense_Dense1 (Dense)         [null,128]                4224      
_________________________________________________________________
dense_Dense2 (Dense)         [null,1]                  129       
=================================================================
Total params: 8705
Trainable params: 8705
Non-trainable params: 0
_________________________________________________________________
Training started...
Epoch#0 : Loss(0.15472638607025146) : mse(0.30945494771003723)
Epoch#1 : Loss(0.11926285922527313) : mse(0.23852571845054626)
Epoch#2 : Loss(0.0869017168879509) : mse(0.1738034337759018)
#...中略
Epoch#49 : Loss(0.00028000862221233547) : mse(0.0005600172444246709)
Training has done.
done!
        
今回は計算した例がとても簡単なモデルだったのですが、50回程度エポックを回すだけでもまぁまぁ周期性くらいは見えてくるのではないかとおもいます。

数周期先までの予想

最後に学習済のモデルを使って、正弦波モドキがどこまで再現されるかをテストしてみます。

今回のモデルは、過去25点の実測点の値がないと予測出来ないので、予測後に生成された値を新しい実測点とみなして、さらに予測...を繰り返してできた曲線です。

コードの実装はさほど難しい拡張ではないので各自の宿題とさせていただきまして、ここでは省略します。

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

少し位相にズレがあるものの、割ときれいに再現させるまで結構苦労しました。

一度やってみるとわかるのですが、モデルが学習不足の場合にも、過学習してしまった場合にも、かなり歪んだ曲線形状となるようですので、パラメータの微妙な調整が必要です。

以下は忘備録的に調整した項目を書き出しておきます。

            
            + LSTM層のユニット数はあまり増やし過ぎても精度は向上しない。
    100個も以上はあまり意味がないように感じた
+ (入力データの正規化前提の話で)LSTM層以下の中間層にLeruを
    活性化関数に仕込んでユニット数を増やした時が一番精度が良さそうだった
    ...何故でしょう
+ 最適化はADAMを使って、学習率を1e-4程度に持っていく方が
    安定して損失関数が下がっていった
+ 損失関数は色々変えてみたがこの場合はmeanSquaredError
    で弄らない方が良さそう
+ 過学習を抑えるためにearlyStoppingを仕込んでいるが、
    とりあえずpatienceが20程度で落ち着いた
        
とにかく評価関数の最適化は非常に各パラメータにセンシティブな問題なので、いくらやってもキリが無いように思いますので、いいところの結果で妥協しておきましょう。

今回は確認してませんが、LSTMのステップ数を長くとることで、周期性も振幅の再現精度も向上するというお話ですが、この辺は後日どこかでお話できたらいいなと思います。


まとめ

今回はtensorflow.js/Kerasの(ステートレスな)LSTMモデルの基本的な使い方に関して、使いこなしの基礎を解説してみました。

また具体的な例としてLSTMに時系列データを学習させて、正弦波を再現することもやってみました。

この辺はまだ色々と応用が効きそうですが、今後暇を見ながらステートフルなLSTMモデルとの比較もやってみると面白いかもと感じました。