つづいて、モデルを作成し、行動を組み込んでいきます。ただし通常と異なり、モデルにはオプティマイザーは指定せず、コンパイルもしません。
const buildModel = () => {
const model = tf.sequential();
model.add(tf.layers.dense({
units: 4,
activation: 'elu',
inputShape: [4]
}));
model.add(tf.layers.dense({
units: 1
}));
model.summary();
return model;
}
モデルの最初の層に指定している活性化関数のeluは、次のグラフで表されます。
ELU関数には、xが正のときはそのままxを出力し、xが0のときは0を、負のときは、x=0の近くで滑らかに減少し、後は-1ほどの一定の値を出力する、という特徴があります。
モデルには、カートの位置と速度、ポールの角度と角速度のデータが時々刻々、入力として入るので、inputShapeは[4]になります。
またcurrentActions_という名前の変数を、上記buildModel()関数などの外に宣言しておきます。これは、tfjs-examples/cart-poleサンプルでは、index.jsに含まれるPolicyNetworkクラスの、外部からアクセスしないプライベートなプロパティとして扱われている変数で、現在のアクションを保持する変数として使用します。
// 現在のアクションを保持する変数。
// tfjs-examples/cart-poleサンプルでは、index.jsに含まれるPolicyNetworkクラスの
// 外部からアクセスしないプライベートなプロパティとして扱われている。
let currentActions_;
メインのJavaScriptではbuildModel()関数でモデルを作成し、forループに入ります。まず必要なのは、各ステップでのカートポールの状態なので、内側のforループで、次のコードを追加または変更ます。
for (let j = 0; j < maxStepsPerGame; ++j) {
...
// 次の段階で使用する
const gradients = tf.tidy(() => {
const inputTensor = cartPoleSystem.getStateTensor();
// カートポールの現在のinputTensorから、勾配を得る
const {
value, grads
} = getGradientsAndSaveActions(inputTensor);
return grads;
});
const action = currentActions_[0];
...
}
ここで追加しているgetGradientsAndSaveActions()関数は、カートポールの現在のinputTensorから勾配を計算して返す新しい関数です。この関数の中では、変数currentActions_に現在の行動の値(Int32Array [0]かInt32Array [1])が入れられるので、前は暫定的に割り当てていた変数actionに、正式な値が代入できます。
getGradientsAndSaveActions()関数はvalueとgradsを返すので、ここではgradsを変数gradientsに割り当てています。 gradientsは以降の段階で使用します。
grads、つまり勾配の中身は、たとえば次のコードで調べることができます。
Object.keys(grads).forEach((varName) => {
console.log(varName);
console.log(grads[varName].dataSync());
});
2つのconsole.log()からは次のような結果が得られます。
dense_Dense1/kernel
Float32Array(16) [-0.03…といった数値を16個含む配列]
dense_Dense1/bias
Float32Array(4) [0.26…といった数値を4個含む配列]
dense_Dense2/kernel
Float32Array(4) [-0.08…といった数値を4個含む配列]
dense_Dense2/bias
Float32Array [-0.53…といった数値を1個含む配列]
getGradientsAndSaveActions()は次の関数です。
const getGradientsAndSaveActions = (inputTensor) => {
const f = () => tf.tidy(() => {
const [logits, actions] = getLogitsAndActions(inputTensor);
currentActions_ = actions.dataSync();
const labels = tf.sub(1, tf.tensor2d(currentActions_, actions.shape, 'float32'));
return tf.losses.sigmoidCrossEntropy(labels, logits).asScalar();
});
return tf.variableGrads(f);
}
ここではまず、fという名前の関数を定義しています。f()関数は、getGradientsAndSaveActions()が受け取ったカートポールの状態のデータ(inputTensor)を、また別のgetLogitsAndActions()関数に渡して、そこから確率の数値(logits)と行動の数値(actions)を得ます。そしてactionsの数値を変数currentActions_に保持し、 1からcurrentActions_を引いたものを変数labelsに代入します。currentActions_はInt32Array [0]かInt32Array [1]なので、currentActions_が0のときlabelsは1(1-1)、1のときは0(1-1)になります。これはつまり、正解は今の行動の逆ということです。f()関数は、この正解(labels)と現状の数値(logits)を最小化した結果を返します。
f()関数の定義後、getGradientsAndSaveActions()関数は、tf.variableGrads()関数にf()関数を渡した結果を返します。tf.variableGrads()は、訓練可能な変数について渡された関数fの勾配を計算し、{value: tf.Scalar, grads: {[name: string]: tf.Tensor}}というオブジェクトを返します。
tf.train.Optimizerのminimize()メソッドは、tf.variableGrads()関数とtf.train.OptimizerのapplyGradients()メソッドで、書き換えることができます。applyGradients()は後の段階で呼び出します。
// モデルを訓練する
for (let i = 0; i < 100; i++) {
// optimizer.minimize()の書き換え
// f()を実行し、訓練可能な変数について、f()のスカラー出力の勾配を計算する
const {
value, grads
} = tf.variableGrads(
() => loss(f(xs), ys)
);
// 変数を、計算した勾配を使って更新する。
optimizer.applyGradients(grads);
}
getGradientsAndSaveActions()関数で呼び出しているgetLogitsAndActions()は次の関数です。
const getLogitsAndActions = (inputs) => {
return tf.tidy(() => {
// inputs(カートの位置、カートの速度、ポールの角度、ポールの角速度)からモデルの推測を実行
const logits = model.predict(inputs);
// モデルの推測結果に非線形のsigmoid関数を適用し、その結果を左方行動の確率とする。
const leftProb = tf.sigmoid(logits);
// 1から左方行動の確率を引いて、右方行動の確率とする
const rightProb = tf.sub(1, leftProb);
// 左方と右方行動の確率をaxis=1で連結。これにより[[左方の確率], [右方の確率]]という形になる。
const leftRightProbs = tf.concat([leftProb, rightProb], 1);
// 多項分布から抽出された値を使ってTensorを作成する。
const actions = tf.multinomial(leftRightProbs, 1, null, true);
// actionsはInt32Array [1]かInt32Array [0]になる。
return [logits, actions];
});
}
この関数は、カートポールからのカートの位置、カートの速度、ポールの角度、ポールの角速度のデータを受け取り、モデルの予測とカートポールが取る行動を決めて返します。1行めでは受け取った入力(inputs)をモデルのpredict()に渡して予測させていますが、今の段階ではモデルは何も学習していないので、適当な数値が返されるだけです。次の行では、tf.sigmoid()関数を使って、モデルの予測した数値を0と1の間に収めています。
tf.multinomial()は、渡された確率を使って実際にサンプリングを行う関数です。たとえば、さいころを1回投げてどの目が出るかは、次のコードで算出できます。
const res1 = tf.multinomial([1/6, 1/6, 1/6, 1/6, 1/6, 1/6], 1, null, true);
ここでは、(現時点では無理やり計算している)モデルの推測を元に、左方向と右方向の確率を求め、その確率にもとづいた行動を決めている、ということになります。