【tensorflow.js x kerasの使い方】データの非線形回帰解析③ ~ Fitを使わないで最適化する方法


2020/12/16

前回はカスタムレイヤークラスを独自実装させて、非線形回帰用のソルバーを使って正規分布関数の解析を行う手順を説明しました。

じゃぁ全てはこの方法を使ってれば非線形の問題も全部解けるようになるのかというと全然ダメで、むしろ現実の世界には解析的に解けない問題の方が圧倒的に多いため、この計算手法はあまり応用向きではないと言えます。

今回からはもっと応用できる使い方を模索して、できれば簡単な偏微分方程式を解けるようなところまでパワーアップさせて行こうかと思います。


fit無しでも最適化 ~ 機能モデルのススメ

今回はtensorflow.jsは、本家pythonのtensorflowでは中上級者向けのテクニックになるFuctional API モデルに相当する機能モデルを使ってみたいと思います。

通常よく用いられる
FitメソッドOptimizer.minimizeメソッドを利用しない独自に低レベルAPIから、柔軟に最適解を求めるための学習モデルの構築〜利用方法までをダイジェストで解説していきます。

今回説明する主なtensorflow.js(keras)の機能モデルを利用するチェック項目は以下のような内容です。

            
            + 学習モデルの作成:
    機能モデルはtf.modelで作成する。

+ レイヤーの追加:
    機能モデルはapplyによって直列でも並列でも如何様にも接続できる。

+ 最適化の手順:
    機能モデルでの最適化とは、損失関数をtf.variableGradsから偏微分し、
    optimizer.applyGradientsからモデルへ変分量を渡す作業を指す。

+ レイヤーの再構築:
    学習済のレイヤだけを抽出・再構成して、ミニマルなソルバーモデル(完成品)だけを
    別のモデルとして切り出し、そのモデルで予測させることもできる。
        
では以降で、tensorflow.jsでの機能モデルの構築手順をじっくり説明していくことにします。


非線形回帰には機能モデルを使うべし

非線形回帰などでは、一般に解析解をもたない偏微分方程式などを扱いたいときもあります。

tensorflow.js/kerasでいうところの高レベルAPIである(線形スタック型)シーケンシャルモデルでは元々、inputsと呼ばれる入力テンソルと、labelsと呼ばれる出力テンソルが対で存在しているような解析的な手法を前提しています。

なので、数値解析を行いたい場合、訓練メソッド(fitやoptimizeなど)や、デフォルトの損失関数などでは立ち打ちできないときがあります。

ではtensorflow.jsでは数値解析ができない?ということでもなく、もっと低位のAPIである機能モデルというのでもっと柔軟な学習モデルを構築してあげることで上手く計算できる場合があります。

ではまずシーケンシャルモデルと機能モデルの違いを比較してみます。

シーケンシャルモデル

通常のリファレンスや参考サイトなどで見かけるtf.sequentialメソッドから生成する線形スタックモデルをシーケンシャルモデルと呼んでいます。

addメソッドで先入れする順番で入力層から出力層まで繋いて作る学習モデルで、どのような学習モデル構造になっているかは一目瞭然です。

            
            const model = tf.sequential();

model.add(tf.layers.dense({inputShape: [4], units: 8, activation: 'relu'}));
model.add(tf.layers.dense({units: 2, activation: 'softmax'}));
        
ちなみにtf.sequentialメソッドの引数のオブジェクトのlayersプロパティへ、レイヤの配列をしても同じようにシーケンシャルモデルが作成できます。

            
            const model = tf.sequential({
    layers: [
        tf.layers.dense({inputShape: [3], units: 16}),
        tf.layers.dense({units: 4, activation: 'tanh'}),
    ]
});
        
シーケンシャルモデルの作成と利用の注意点としては、モデルの最初に指定したレイヤーはinputLayerとなり、それ以降のレイヤーとは区別されます。

どう特別に扱われるのかというと、入力するデータテンソルの
inputShapeを指定する作業が必要になります。

例えば、A市、B市、C市という場所の日ごとの平均気温を、
[[19.1, 14.5, 13.0], [15.1, 11.1, 13.3], ...]というデータテンソルで表現するとします。

100日分のデータ数(=バッチサイズ)を取り込むとすると、このデータセットの形状(Shape)は
[100, 3]となりますが、シーケンシャルモデルのinputLayerには原則としてバッチサイズを除外して指定しないといけないようです。

よってこの場合は
inputShape: [3]というフィールドでinputLayerを作成することになります。

※ どうしても入力層にバッチサイズを厳密に組み入れたい場合には、
batchInputShapebatchSizeといったフィールド値を使って設定することもできますが、バッチサイズは入力されるデータ側で整形・調整すべきではあります。

メリット・デメリットを簡単にかいつまむと、

            
            + ウェブ検索で判例がたくさん出てくるので、tensorflow.js & kerasの初学者には便利
+ 公式のtensorflow.jsのAPIリファレンスにも詳しく解説してあるので用法が理解しやすい
+ 学習モデルのレイヤー構造が直感で分かりやすい
        

            
            - 学習レイヤーを入力層から出力層まで直列にしか繋げない単純な構造しか作れない
- そのため複雑なアルゴリズムをもつ深層学習モデルを作るのが難しい
- 数学的なモデルを解くとき、解析的な手法での求解にしか向かない
        
シーケンシャルモデルではレイヤーが複雑な枝分かれをもつ分岐構造のモデルには不向きということが言えます。

機能モデル

学習レイヤモデルを作成するには、もう一つ別の方法があり、先程のシーケンシャルモデルと区別して機能モデルと呼ばれています。

こちらは
tf.modelメソッドからモデルインスタンスを作成して使用します。

この
tf.modelメソッドを使用することで学習レイヤーの任意のグラフ構造を構築することが出来るようになります。

            
            const input = tf.input({shape: [3]});

const dense1 = tf.layers.dense({units: 16, activation: 'relu'}).apply(input);
const dense2 = tf.layers.dense({units: 3, activation: 'softmax'}).apply(dense1);

const model = tf.model({inputs: input, outputs: dense2});
        
一つのレイヤで得た出力を、また別のレイヤーに入力接続するためには、applyメソッドでレイヤーのネットワークを柔軟に構築できるのがこの機能モデルの大きな特徴です。

ちなみに
applyメソッドの返り値はSymbolicTensor型ですので、これはTensor型ように振る舞う抽象なクラスです。

シーケンシャルモデルの場合には最初のレイヤーにinputShapeを作成しなければなりませんでしたが、機能モデルでは入力として
tf.inputメソッドからSymbolicTensor型を定義して利用することになります。

applyメソッドは汎用性の高いメソッドで、引数に具体的なTensor型のインスタンスを渡すと、個別のレイヤーから内部のユニットから出力されたTensor型インスタンスを返すこともできます。

例えば以下のようにすると、各レイヤーを個別に分離し出力テストをするときにも便利になります。

            
            const t = tf.tensor([-2, 1, 0, 5]);
const o = tf.layers.activation({activation: 'relu'}).apply(t);
o.print(); // [0, 1, 0, 5]
        
このapplyメソッドはモデル全体にもに使うこと出来ます。

このとき各レイヤーの内部ユニット全体から出力を得るという結果だけみると、modelクラスの
predictメソッドと同様の作用ができるようになっています。

            
            const x = tf.input({shape: [32]});
const y = tf.layers.dense({units: 2, activation: 'softmax'}).apply(x);
const model = tf.model({inputs: x, outputs: y});

model.apply(tf.ones([3, 32])).print();
// model.predict(tf.ones([3, 32])).print();としても作用としては同じ
// [[0.0701714, 0.9298285], [0.0701714, 0.9298285], [0.0701714, 0.9298285]]
        
機能モデルを使ったメリット・デメリットを簡単にかいつまむと、

            
            + より複数で高度なモデルが構築できる
+ 偏微分方程式などのような数学的なモデルの数値解を求めるときに便利
        

            
            - ウェブ検索ではあまりサンプルコードが見つからない
- 低レベルAPIのため公式のAPIリファレンスでもほとんど解説がない
- 複雑な学習モデルを作れるが、複雑にしすぎるとレイヤー間がサイクル接続してしまう恐れがある
        

機能モデルでの訓練

機械学習での訓練とは、損失関数(評価関数)の残差を最小化していく作業です。

例えばとある損失関数を
lossとして以下のように定義します。

            
            const loss = () => tf.tidy(() => {
    //👇trainingを有効にした変数がOptimizerに渡されるとモデル内の重みが補正・更新される
    const predictedTensor = model.apply(inputTensor, {training: true});
    return actualLabelTensor.sub(predictedTensor).square().mean().asScalar();
});
        
このloss関数は、実際に得られた検証用の観測データ(actualTensor)と、変数テンソル(inputTensor)から何らかの学習モデルによって得られた予想点(predictedTensor)の残差をとる関数です。

このloss関数に従って、次のように
tf.variableGradsという組込関数が訓練可能な変数についての勾配を計算してくれます。

            
            // モデルを100回訓練する
for (let i = 0; i < 100; i++) {
    const { value, grads } = tf.variableGrads(loss);
    //👇optimizerがloss関数内で訓練可能な変数を見つけ、
    // モデル内の重み係数を補正してれる
    optimizer.applyGradients(grads);
}
        
ちなみにtf.variableGradsメソッドの返り値は{value: tf.Scalar, grads: {[name: string]: tf.Tensor}}という型のオブジェクトになり、valueの方はこの場合不要です。

訓練は適当なtf.train.Optimizerのインスタンスから呼び出された
applyGradientsメソッドが微小勾配量gradsに応じて内部で処理される仕組みになっています。

この一連の流れにおいて、variableGradsメソッドとapplyGradientsメソッドは、tf.train.Optimizerの
minimizeメソッドの書き換えと見なすことができます。


線型回帰問題を解き直してみる

以前の記事では線型回帰をシーケンシャルモデルで解く内容を解説しました。

ここでは改めて学習モデルを機能モデルで書き直してみましょう。

前回のソースコードで、
linear_regression.jsの内容でlinearRegression関数を以下のように修正してみます。

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

export async function linearRegression(rawData) {
    //👇①機能モデルで書き換え
    const inputTensor = tf.input({shape: [2], name: 'inputXY'});
    const breeder = tf.layers.dense({units: 8, activation: 'sigmoid', name: 'breeder'}).apply(inputTensor);
    const condenser = tf.layers.dense({units: 1, activation: 'linear', useBias: false, name: 'condenser'}).apply(breeder);
    const trainee = tf.layers.dense({
        units: 1,
        activation: 'linear',
        useBias: true,
        name: 'trainee',
        kernelInitializer: tf.initializers.constant({value: -1.0}),
        biasInitializer: tf.initializers.constant({value: 0.8}),
    }).apply(condenser);
    const model = tf.model({inputs: inputTensor, outputs: trainee});

    console.log('Training started.');

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

    //👇②テンソルに変換+データの標準化
    const tensorData = convertToTensor(originalData);

    //👇③機能モデルのトレーニング
    await trainModel(model, tensorData.xyInputs);
    console.log('Training has done.');
    model.summary();

    //👇④訓練後のモデルでテスト
    //traineeレイヤー > model.layers[3]
    const predictedData = testModel(model.layers[3], tensorData);
    console.log('Fitting has done.');

    return { labels, originalData, predictedData };
}
///...以下略
        
今回の機能モデルの定義部分を抜粋すると、

            
            //👇①機能モデルで書き換え
const inputTensor = tf.input({shape: [2], name: 'inputXY'});
const breeder = tf.layers.dense({units: 8, activation: 'sigmoid', name: 'breeder'}).apply(inputTensor);
const condenser = tf.layers.dense({units: 1, activation: 'linear', useBias: false, name: 'condenser'}).apply(breeder);
const trainee = tf.layers.dense({
    units: 1,
    activation: 'linear',
    useBias: true,
    name: 'trainee',
    kernelInitializer: tf.initializers.constant({value: -1.0}),
    biasInitializer: tf.initializers.constant({value: 0.8}),
}).apply(condenser);
const model = tf.model({inputs: inputTensor, outputs: trainee});
        
の部分になります。

最初のインプットレイヤーである
inputXYは、x軸の実験データの車体の重量と、y軸の実験データの燃費(MPG)を2つの数値をそのまま後段に渡す役目を担います。

ちなみに、このデータは既に標準化済みですので、xyの範囲が0から1の閉区間・
[0,1]内のどこかに存在するようにしてあることに留意してください。

次にこの入力データは最初のDenseレイヤーの
breederにapplyメソッドを使って接続されて、8個のユニットで調整を受けます。

一般的にはユニットを増やせば予想精度が上がるのですが、今回は一次直線
y=ax+by = ax + bを求めるだけですので、ユニット数を増やしたところでさほど精度の向上はありません。

breederから出力された値は次なるDenseレイヤーの
condenserに接続するようにします。これはbreederから出力された値を一纏めにして、データの形状を[N x 1]に矯正するのが役割です。

condenserの値は最後の出力層である
traineeに渡されてこのモデルはゴールです。

活性化関数をlinearにし、bias項をもたせることで、
y=ax+by = ax + bの係数aabbを擬似的に再現しています。

最終的に予想曲線をこのtraineeのレイヤーからapplyメソッドを使って引き出しますが、その際の入力データの形状が
[N x 1]にならなければいけないので、そのために一つ手前でcondenserが必要となったのでした。

さて、入力側のデータ構造が変わったので、ソースコード内の
convertToTensorメソッドも以下のように修正します。

            
            // 学習データをTensor型に変換する関数
function convertToTensor(data) {
    return tf.tidy(() => {
        tf.util.shuffle(data);

        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));

        //👇形状[N x 2]のマトリックス化
        const normalizedXYInputs = tf.stack([normalizedInputs, normalizedLabels], -1);

        return {
            inputs: normalizedInputs,
            labels: normalizedLabels,
            inputMax,
            inputMin,
            labelMax,
            labelMin,
            xyInputs: normalizedXYInputs //👈新しいフィールドとして追加
        }
    });
}
        
非線形の問題においては、xは入力でyは出力などと区別して考える必要がないので、今回のモデルでは2変数の場合の関数における方程式f(x,y)=0f(x, y) = 0を解いていると表現したほうが正しいでしょう。

この場合、入力データの形状は
[N x 2]で取り扱う必要があるため、convertToTensorメソッドからデータセットを引き出せるように修正を行いました。

次に訓練を行う
trainModelメソッドが今回の記事の大きな目玉です。

この最適化のやり方はDQNのような強化学習でも威力を発揮します。

            
            async function trainModel(model, inputs) {
    const learningRate = 0.01;
    const optimizer = tf.train.sgd(learningRate);

    //👇カスタム損失関数の定義: () => tf.Scalarの関数シグネチャに合わせる必要がある
    const customLossFunction = () => tf.tidy(() => {
        const qs = model.apply(inputs, {training: true});
        //👇predictとしてもOK
        //const qs = model.predict(inputs);

        //形状[N x 2]の生データからy軸データを抽出
        const labelList = [];
        inputs.dataSync().forEach((e, i) => {
            if(i%2 === 1) { labelList.push(e);}
        });
        //tf.lossesクラスの各損失関数に入力する際には形状[N x 1 x 1]
        //に整形しておく必要がある
        const labels = tf.tensor(labelList, [labelList.length, 1, 1]);

        return tf.losses.meanSquaredError(labels, qs).asScalar();
    });

    // モデルを訓練する
    const epochs = 1000;
    for (let i = 0; i < epochs; i++) {
        console.log(`TRAINING #${i}`);
        //👇先程の損失関数を使ってvariableGradsから偏微分を実行する
        const grads = tf.variableGrads(customLossFunction);

        //👇偏微分量grads.gradsに従ってapplyGradientsメソッドを介して
        // optimizerがmodel内の各ユニットを最適化してくれる
        optimizer.applyGradients(grads.grads);

        //gradsはもう要らないのでメモリをクリア
        tf.dispose(grads);
    }
}
        
これでmodel.fit関数を使わない低レベルAPIによる最適化が可能になりました。

最後に訓練の仕上がったtraineeレイヤーで一次直線を描くメソッド・
testModelも以下のように修正したら完了です。

            
            function testModel(trainedLayer, normalizationData) {
    const {inputMax, inputMin, labelMin, labelMax} = normalizationData;
    const [xs, preds] = tf.tidy(() => {
        const xs = tf.linspace(0, 1, 100);

        //👇訓練済みレイヤーの名前
        console.log(`Layer Name: ${trainedLayer.name}`);
        //👇標準化前の情報
        console.log(`Xmin ${inputMin.dataSync()}: Xmax ${inputMax.dataSync()}; Ymin ${labelMin.dataSync()}: Ymax ${labelMax.dataSync()}`);
        const layerWeights = trainedLayer.getWeights();
        for (let i = 0; i < layerWeights.length; i++) {
            //👇y = ax + bのうち順にa、bの重み係数
            layerWeights[i].print();
        }

        //👇訓練済みのレイヤーから出力を取り出す
        const preds: any = trainedLayer.apply(xs.reshape([100, 1]));

        const unNormXs = xs.mul(inputMax.sub(inputMin)).add(inputMin);
        const unNormPreds = preds.mul(labelMax.sub(labelMin)).add(labelMin);
        return [unNormXs.dataSync(), unNormPreds.dataSync()];
    });

    const predictedPoints = Array.from(xs).map((val, i) => {
        return {
            x: val,
            y: preds[i]
        }
    });

    return predictedPoints;
}
        
以上のソースコードを修正して再度ビルド・実行して得た解析は以下のようになります。

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

またコンソールでの出力は以下のようになります。

            
            #...中略
TRAINING #999
Tensor
    0.0363294892013073
Training has done.
_________________________________________________________________
Layer (type)                 Output shape              Param #   
=================================================================
inputXY (InputLayer)         [null,2]                  0         
_________________________________________________________________
breeder (Dense)              [null,8]                  24        
_________________________________________________________________
condenser (Dense)            [null,1]                  8         
_________________________________________________________________
trainee (Dense)              [null,1]                  2         
=================================================================
Total params: 34
Trainable params: 34
Non-trainable params: 0
_________________________________________________________________
Tensor
    [[-1.0087168],]
Tensor
    [0.7074651]
Layer Name: trainee
Xmin 1613: Xmax 5140; Ymin 9: Ymax 46.599998474121094
        
1000回程度のループ処理で損失関数の残差は0.036、a1.01a \sim -1.01b0.71b \sim 0.71程度となっています。


まとめ

今回はtensorflow.jsには本家tensorflowで言うところのFuctional API モデルに相当する機能モデルを使って、FitメソッドやOptimizer.minimizeメソッドを一切利用しない独自に低レベルAPIで非線形解析ソルバーをカスタマイズしました。

機能モデルからtensorflow/kerasの学習モデル構築~最適化するポイントをまとめますと、

            
            + 学習モデルの作成:
    シークエンシャルモデルではtf.sequentialで作成し、
    機能モデルはtf.modelで作成する

+ レイヤーの追加:
    シークエンシャルモデルはaddでモデルに直列に追加するしかないが、
    機能モデルはapplyによって直列でも並列でも自由に接続可能

+ 最適化の手順:
    シークエンシャルモデルは損失関数やオプティマイザをcompileして、
    model.fitかoptimizer.minimizeで最適化処理を行うが、
    機能モデルでは損失関数をtf.variableGradsから偏微分し、
    optimizer.applyGradientsからモデルへ変分量を渡す
        
以上が今回の記事のエッセンスになります。

今回の解析では一次直線のフィッテングを題材に使用例を挙げてみましたが、tensorflowを使って
y=f(x)y = f(x)として解くか、f(x,y)=0f(x, y) = 0として解くかでその手法は大きく異なることを頭に入れておかれると後々いろんな応用できるのでは、と思います。


参考サイト

TensorFlow For JavaScriptガイド ~ モデルとレイヤー

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。