いよいよカートポールサンプルの核心に入っていきます。ここでは、「報酬を割引き、それを反映した勾配を重みに適用する」ということを行います。具体的には、「割引率と、全ゲームで集めた全報酬から、報酬の割引を実行する。そして報酬を正規化して、その比率に応じて勾配を計算し重みに適用する」ということを行います。とはいえもはや、これは日本語になっていません。
メインのJavaScriptのforループに入る前で、次の修正を行います。モデル、というよりこのアプリにいよいよ学習させるので、maxStepsPerGameとnumGamesの回数を増やし、オプティマイザーを設定します。また”割引率”も決めます。
// 回数を増やしている。
const maxStepsPerGame = 500;
const numGames = 20;
const cartPoleSystem = new CartPole(true);
const model = buildModel();
const allGradients = [];
const allRewards = [];
const gameSteps = [];
// オプティマイザーを設定
const learningRate = 0.05;
const optimizer = tf.train.adam(learningRate);
// 割引率
const discountRate = 0.95;
そして外側にループが終わる前に、「13_5:勾配と報酬の記録」で得たallRewardsとallGradientsを使用します。
...
allRewards.push(gameRewards);
tf.tidy(() => {
// 1. 報酬の割引を実行する。言い換えると、新しい報酬の数をそれより前の報酬よりも多くする。
// これにより、多くのステップを経たゲームからの報酬値は、より少ないステップ数のゲームからの
// 報酬値よりも大きくなる、という効果が生まれる。
// 2. 報酬を正規化する。つまり、報酬の全体的な平均値を引き、その結果を、報酬の全体的な標準偏差で割る。
// 1と合わせて実行することで、長く継続するゲームの報酬は正に、長くつづかないゲームの報酬は負になる。
// 3. 勾配を、正規化した報酬値の比率に応じて決める(スケーリング)。
const normalizedRewards = discountAndNormalizeRewards(allRewards, discountRate);
// スケーリングした勾配を、重みに追加する。これにより、ゲームが将来も長く継続する選択をする
// 可能性が高まる(これこそがRLアルゴリズムの真髄)。
const gradients = scaleAndAverageGradients(allGradients, normalizedRewards);
optimizer.applyGradients(gradients);
});
await tf.nextFrame();
} // 外側のループの終わり
tf.dispose(allGradients);
getGradientsAndSaveActions()関数では、tf.variableGrads()関数を使ってf()に関するこのアプリの変数(重みとバイアス)の勾配を得ており、その数値はallGradientsが持っています。これを報酬の考えを含めた独自の関数で処理し、そこから得た勾配をoptimizer.applyGradients()に与えることで、このアプリの重みとバイアスは計算されます。
新しく登場している1つめのdiscountAndNormalizeRewards()は次の関数です。これは、「cartpoleサンプル」のindex.jsに書かれています。
/**
* 報酬値を割引き、正規化する。
*
* この関数は次の2つを実行する。
*
* 1. discountRateを使って、報酬値を割引く。
* 2. 報酬値を、報酬全体の平均と標準偏差を使って正規化する。
*
* @param {number[][]} rewardSequences 報酬値のシーケンス
* @param {number} discountRate 割引率:0と1の間の数値。0.95など。
* @returns {tf.Tensor[]} 割引かれ正規化された報酬値、tf.Tensorの配列。
*/
discountAndNormalizeRewards = (rewardSequences, discountRate) => {
return tf.tidy(() => {
const discounted = [];
// rewardSequencesの個々の要素を反復処理
for (const sequence of rewardSequences) {
const discountratedRewardTF = discountRewards(sequence, discountRate);
// discounted配列に、報酬値のtf.Tensorを追加
discounted.push(discountratedRewardTF)
}
// 割引いた報酬値全体の平均と標準偏差を計算する
const concatenated = tf.concat(discounted);
// 平均
const mean = tf.mean(concatenated);
// 標準偏差
const std = tf.sqrt(tf.mean(tf.square(concatenated.sub(mean))));
// 求めた平均と標準偏差を使って、報酬値のシーケンスを正規化する。
const normalized = discounted.map((rs) => {
return rs.sub(mean).div(std);
})
return normalized;
});
}
関数に送られてくるrewardSequencesは[[1,1,…,0], [1,1,1..,0],…]といった配列です。ここではfor-ofループを使って個々の要素([1,1,…,0]や[1,1,1..,0]など)をdiscountRewards()関数に割引率とともに渡し、その戻り値を配列に追加しています。
その後は、いわゆる正規化(標準化)の処理です。データからその平均を引き、それを標準偏差で割ることで、平均が0、分散が1のデータに均されるので、扱いやすくなります。
discountRewards()は次の関数です。
/**
* 報酬値を割引く
*
* @param {number[]} rewards 割引かれる報酬値
* @param {number} discountRate 割引率:0と1の間の数値、例えば0.95。
* @returns {tf.Tensor} 割引かれた報酬値、1Dのtf.Tensor。
*/
discountRewards = (rewards, discountRate) => {
const discountedBuffer = tf.buffer([rewards.length]);
let prev = 0;
for (let i = rewards.length - 1; i >= 0; --i) {
const current = discountRate * prev + rewards[i];
discountedBuffer.set(current, i);
prev = current;
}
return discountedBuffer.toTensor();
}
”報酬を割引く”というと、1の報酬を0.95に減らすような気がしますが、ここではそうではなく、送られてくるrewards、たとえば[1,1,1,1,0]を[3.70, 2.85, 1.95, 1, 0]にしています。報酬は1なので、本来なら[4, 3, 2, 1, 0]となるところ、1つ前の報酬値に割引率を掛けた分だけ増やす計算で少し小さくしています。tf.buffer()関数は、指定されたshapeを持つtf.Tensorオブジェクトの入れ物です。ここではset()メソッドを使って、報酬値を設定しています。
discountRewards()関数は簡単にいうと、ポールが倒れずカートが範囲を超えずにより長持ちした試行に対してより大きく長い配列を返します。具体的に言うと、すぐに終わった試行[1,0]には[1,0]を返し、少しもった試行[1,1,0]には[1.95, 1, 0]を返し、もっと長持ちした試行[1,1,1,0]には[2.85,1.95,1,0]を返します。
scaleAndAverageGradients()は次の関数です。
/**
* 勾配を、正規化した報酬値の比率に応じて求め、平均を計算する。
*
* 勾配値を、正規化された報酬値によってスケーリングする。
* その後、全ゲームと全ステップを通して平均する。
*
* @param {{[varName: string]: tf.Tensor[][]}} allGradients
* 変数名から、全ゲームと全ステップに渡るその変数の全勾配値へのマップ
* steps.
* @param {tf.Tensor[]} normalizedRewards
* 全ゲームについての、正規化した報酬値の配列。
* 配列の各要素は、ゲーム内のステップ数に等しい長さを持つ1D tf.Tensor.
* @returns {{[varName: string]: tf.Tensor}} 変数についてスケーリングし平均した勾配
*/
scaleAndAverageGradients = (allGradients, normalizedRewards) => {
return tf.tidy(() => {
const gradients = {};
// allGradientsが持つプロパティに対して以下の処理をする。
for (const varName in allGradients) {
// gradientsオブジェクトに、allGradientsが持つ重みとバイアスのデータを持たせる
gradients[varName] = tf.tidy(() => {
// 勾配を配列にまとめる
const varGradients = allGradients[varName].map((varGameGradients) => {
return tf.stack(varGameGradients);
})
// 次元を拡張し、ブロードキャストを使った乗算の準備をする。
const expandedDims = [];
for (let i = 0; i < varGradients[0].rank - 1; ++i) {
expandedDims.push(1);
}
// rankが3の場合、expandedDimsは[1,1]
// rankが2の場合、expandedDimsは[1]
const reshapedNormalizedRewards = normalizedRewards.map((rs) => {
// rsの要素数と[1, 1]か[1]を連結する
// rs.shapeはArrayなので、concat()が使える
// [要素数, 1]か[要素数,1,1]になる
const concated = rs.shape.concat(expandedDims);
// rsのshapeをconcatedに変える。
const reshapedNormalizedRewards = rs.reshape(concated);
return reshapedNormalizedRewards;
});
for (let g = 0; g < varGradients.length; ++g) {
// このmul()の呼び出しはブロードキャストを使う
varGradients[g] = varGradients[g].mul(reshapedNormalizedRewards[g]);
}
// スケーリングした勾配を連結し、全ゲームの全ステップに渡って平均する
return tf.mean(tf.concat(varGradients, 0), 0);
});
}
return gradients;
});
}
for-inループのverNameには、dense_Dense1/kernelとdense_Dense1/bias、dense_Dense2/kernelとdense_Dense2/biasという4つの値が入ります。これらは初めの2つが1つめのDenseレイヤーの重みとバイアス、後の2つが2つめのDenseレイヤーの重みとバイアスを参照する変数です。ここでは、その名前をプロパティ名として、gradientsオブジェクトに設定しています。
これら4つのプロパティの値は基本的に、勾配に正規化した報酬値を掛けたものになりますが、normalizedRewardsには、重みのデータ(dense_Dense1/kernelとdense_Dense2/kernel)とバイアスのデータ(dense_Dense1/biasとdense_Dense2/bias)が混在しており、shapeが異なります。
たとえば、9回めのステップで1ゲームを終えたときのvarGradientsのデータとnormalizedRewardsのデータを掛けると、shapeが9,4,4と9なのでオペランド(左辺と右辺)をブロードキャストできない、というエラーが発生します。
varGradients[g] = varGradients[g].mul(normalizedRewards[g]);
Error: Operands could not be broadcast together with shapes 9,4,4 and 9.
normalizedRewardsに混在する重みのデータとバイアスのデータを各々、適切にvarGradientsのデータと掛けるために行っているのが、const expandedDims = []以下のコードです。