Help us understand the problem. What is going on with this article?

ニューラルネットの重みを実数値遺伝的アルゴリズムで最適化してみる ~その2:倒立振子~

More than 1 year has passed since last update.

はじめに

ニューラルネットの重みを実数値遺伝的アルゴリズム(以下、実数値GA)も用いて最適化するという手法(NeuroEvolution)を強化学習に適用してみました。

強化学習の対象は、古典的なベンチマーク問題である倒立振子を選択しました。台車駆動型倒立振子といわれている方です。
はじめは自分でシミュレート環境を作成していましたが、OpenAI Gymにも倒立振子の環境があるようなのでそちらを用いました。

OpenAI Gymとは

イーロン・マスクらが参加しているOpen AIが提供している強化学習アルゴリズムの検証プラットフォームです。
https://gym.openai.com/

各ユーザのスコアは以前はAPIでOpenAIのサーバに直接送信して掲載していたようですが、現在(2019/7)ではgithubのwikiを編集して入力するようになっているようです。
https://github.com/openai/gym/wiki/Leaderboard

OpenAI Gymのインストール

Windows Anaconda環境で、「Anaconda Prompt」から以下のコマンドでインストールするだけで、倒立振子は動作することまで確認できました。アタリのゲームや3Dを使う場合は他のライブラリのインストールが必要かもしれません。

pip install gym

Google Colaboratoryでは、初期環境でインストール済みでした。

倒立振子

OpenAI Gymの倒立振子のソースは以下にあります。
https://github.com/openai/gym/blob/master/gym/envs/classic_control/cartpole.py

レギュレーション

ソースのコメントとソースから読み取れる主なレギュレーションは以下の通りです。

  • 状態(State)
No 状態名 最小値 最大値
0 カートの位置(x) -4.8 4.8
1 カートの速度(x') -∞
2 ポールの角度(θ) -24deg 24deg
3 ポールの速度(θ') -∞
  • 行動(Action)
    • 0 : 左移動 (左方向に10Nの力を加える)
    • 1 : 右移動 (右方向に10Nの力を加える)
  • 状態初期値 : 各状態で[-0.05~0.05]でランダムで。各状態の最小最大の5%ではないようです。なので、カートの位置では誤差のレベル、ポールの角度では最悪値で0.05[rad]→2.8[deg]になります。
  • 終了条件
    • ポールの角度が-12~12deg外
    • カートの位置が-2.4~2.4外
    • 200step到達(1stepは0.02秒なので、4秒)
  • 報酬(Reward):1step毎に報酬+1 。なので、最大200の報酬が得られることになります。

学習モデル

ニューラルネットの構成

3層のニューラルネットを用いました。
- 入力層 :4ニューロン
- 隠れ層 :10ニューロン (活性化関数:シグモイド関数)
- 出力層 :2ニューロン (活性化関数:Softmax関数)

ニューラルネット_倒立振子.JPG

重み・バイアスの範囲:-5.0~5.0

実装

nn.py
import numpy as np

# シグモイド 関数
def sigmoid(x):
    sigmoid_range = 34.538776394910684
    #オーバーフロー対策
    if x.any() <= -sigmoid_range:
        return 1e-15
    elif x.any() >= sigmoid_range:
        return 1.0 - 1e-15
    else:
        return 1.0 / (1.0 + np.exp(-x) )


# ソフトマックス 関数
def softmax(x):
    xmax = np.max(x) 
    exp_x = np.exp(x - xmax) 
    y = exp_x / np.sum(exp_x)
    return y

# Neural Network
class NN():

    #int    layer       : 層の数
    #int    input_size  : 入力層のニューロン数
    #int[]  hidden_size : 隠れ層のニューロン数
    #int    output_size : 出力層のニューロン数
    def __init__(self ,layer , input_size , hidden_size , output_size ):
        assert (layer >= 2 and input_size > 0 and output_size > 0) , 'ERROR:NN.init()'
        assert (layer-2 == len(hidden_size)) , 'ERROR:NN.init() hidden_size size'

        self.layer = layer
        self.input_unit  = input_size
        self.hidden_unit = hidden_size
        self.output_unit = output_size

        self.params = {}
        self.params['W1'] = np.zeros((input_size , hidden_size[0]) , dtype = 'float32')
        self.params['b1'] = np.zeros( hidden_size[0] , dtype = 'float32')
        self.params['W2'] = np.zeros((hidden_size[0] ,output_size), dtype = 'float32')
        self.params['b2'] = np.zeros( output_size , dtype = 'float32')


    #重み、バイアスの合計
    def get_weights_size(self) :
        w = 0
        for l in range(self.layer - 1):
            w_name = 'W' + str(l + 1)
            b_name = 'b' + str(l + 1)
            w += self.params[w_name].size
            w += self.params[b_name].size

        return w

    #重みの セット
    #numpy.ndarray weights : 重みとバイアス
    def set_weights(self , weights):
        w = 0

        for l in range(self.layer - 1):
            w_name = 'W' + str(l + 1)
            b_name = 'b' + str(l + 1)
            weight = weights[w: w+self.params[w_name].size]
            self.params[w_name] = weight.reshape(self.params[w_name].shape)
            w += self.params[w_name].size

            bias = weights[w: w+self.params[b_name].size]
            self.params[b_name] = bias.reshape(self.params[b_name].shape)
            w += self.params[b_name].size

        assert(weights.size == w) , \
            'Error:set_weights() weight size is invalid  weights = %d  nn = %d' %(weights.size , w)

    # 推論 

    def predict(self , x):
        W1 = self.params['W1']
        b1 = self.params['b1']
        W2 = self.params['W2']
        b2 = self.params['b2']

        i1 = np.dot(x,W1) + b1
        o1 = sigmoid(i1)
        i2 = np.dot(o1,W2) + b2
        y  = softmax(i2)

        return y

実数値GAの構成

  • 世代交代モデル:MGG
    • 世代交代数:2個体(エリート選択:1個体、ルーレット選択:1個体)
    • 致死個体(任意のパラメータが探索範囲を超えた個体) への対応:評価対象から外す
  • 交叉法:UNDX (α=0.5、β=0.35)
  • 学習パラメータ
    • 最大世代数:1000世代
    • 集団数:50個体
    • 生成子個体数: 100個体/世代
  • 適合度:最小化問題にするために、OpenAI Gymの報酬をマイナスにしています。

実装

nnga.py
import numpy as np
#OpenAIGym
import gym

from nn import NN

#重み の範囲
WEIGHT_MIN  = -5.0
WEIGHT_MAX  =  5.0

#評価値小 = 優良個体
EVAL_TYPE_MIN = 0
#評価値大 = 優良個体
EVAL_TYPE_MAX = 1

#層の数
LAYER_SIZE = 3
#入力層のニューロン数
INPUT_SIZE = 4
#出力層のニューロン数
OUTPUT_SIZE = 2

HIDDEN_SIZE = [10]

# 20step
SIMULATE_STEP = 200


#角度[rad]を-π~πに変換
def rad_conversion(rad):
    rad += np.pi;
    rad %= 2*np.pi;
    if rad < 0:
        rad += np.pi
    else:
        rad -= np.pi
    return rad


# Neural Network
class NNGA():
    #探索範囲取得
    def get_range() :
        ret = [WEIGHT_MIN , WEIGHT_MAX]
        return ret

    #評価タイプ取得
    def get_eval_type() :
        ret = EVAL_TYPE_MIN
        return ret

    def __init__(self ):
        self.nn = NN(LAYER_SIZE , INPUT_SIZE ,  HIDDEN_SIZE , OUTPUT_SIZE )

        print("Use Open AI Gym CartPole")
        self.env = gym.make('CartPole-v0')

    #重み、バイアスの合計
    def get_weights_size(self) :
        return self.nn.get_weights_size()

    #評価
    #weights : 重み
    def eval_NNGA(self ,weights ) :
        #重みの セット
        self.nn.set_weights(weights)

        #評価用シミュレート
        eval = self.eval_simulate()
        return eval

    #最終結果シミュレート
    #weights : 重み
    def finally_simulate(self , weights , filename, use_gym_render = False):
        #重みの セット
        self.nn.set_weights(weights)

        # シミュレート
        self.save_simulate( filename , use_gym_render)

    ##########################################################
    ### 倒立振子
    ##########################################################
    # 1stepシミュレート
    def simulate_1step(self , step ):
        inputs = np.reshape( self.state , (1 , INPUT_SIZE))

        #NNを使って推論 
        y = self.nn.predict(inputs)
        # 振り子アクション判定
        if(y[0][0] > y[0][1]):
            action = 0
        else:
            action = 1

        # 振り子シミュレート
        state , reward ,done ,dummy = self.env.step(action)
        state[2] =  rad_conversion(state[2])
        reward = -reward

        return state , reward

    #評価
    def eval_simulate(self ) :
        #振り子初期化
        self.state = self.env.reset()

        eval = 0
        for step in range(SIMULATE_STEP):
            self.state ,reward = self.simulate_1step(step )
            eval += reward

        return eval


    #シミュレート 動画作成
    def save_simulate(self ,  filename , use_gym_render) :
        #振り子初期化
        self.state = self.env.reset()

        eval = 0
        for step in range(SIMULATE_STEP):
            if (use_gym_render == True):
                self.env.render()

            self.state , reward=self.simulate_1step(step)
            eval += reward

            print('step={0}  state={1} reward={2}'.format(step,self.state,eval))

mgg_test.py
import numpy as np

from nnga import NNGA
from Individual import Population
import crossover as cross
from mgg import MGG

# 交叉方法指定
crossType = cross.CrossoverType.UNDX
#致死個体処置方法
deadlyType = MGG.DeadlyIndividualType.Remove

#GAパラメータ
MAX_GENERATION  = 1000   #最大世代数
CROSS_TIMES     = 50    #交差回数
POPULATION_SIZE = 50    #個体サイズ

#MGG パラメータ
#エリート選択数
MGG_ELITE_SELECT_SIZE = 1

#ChoromIndex
CROME_SIZE = 1     #染色体(Chromosome) の 個数
VALUE_SIZE = 1     #評価値の数
cIndex = 0


if __name__ == "__main__":
    # NN
    nnga = NNGA()
    PARAMETER_SIZE = nnga.get_weights_size()

    # 評価関数に従った設定を取得
    dRange   = NNGA.get_range()
    evalType = NNGA.get_eval_type()

    mgg = MGG(dRange[0],dRange[1],dRange[0],dRange[1], crossType , 
              CROSS_TIMES , MGG_ELITE_SELECT_SIZE , deadlyType)

    #集団
    population = Population(POPULATION_SIZE , PARAMETER_SIZE , CROME_SIZE , VALUE_SIZE)

    # 交差方法初期化
    mgg.init_cross([PARAMETER_SIZE])

    #集団の初期化
    mgg.init_population(population)

    #初期集団 評価
    for ind in population.fIndividual:
        ind.fValue = nnga.eval_NNGA(ind.fChrom[cIndex])

    # 実行
    for gen  in  range(MAX_GENERATION+1):
        #MGGと交叉の実行
        mgg.make_children(population) 

        # 作成した子の評価
        for ind in mgg.fChildren.fIndividual:
            ind.fValue = nnga.eval_NNGA(ind.fChrom[cIndex])

        #MGGと交叉の実行
        mgg.select_children(population , evalType )

    #最終集団 評価
    for ind in population.fIndividual:
        ind.fValue = nnga.eval_NNGA(ind.fChrom[cIndex])
    #最終結果シミュレート
    bestIndex    = population.get_best_individual(evalType )
    nnga.finally_simulate (population.fIndividual[bestIndex].fChrom[cIndex] , 'sim' , True)

実数値GAの実装は過去の記事と同じです。

学習結果

初期集団

初期集団の中の最良個体です。少しがんばりますが、どこかに行ってしまいます。
NNGA_CartPoleGym_L4-[10]-2_g1000_UNDX_20190720_2244-0gen_Gym.gif

1000世代

うまくバランスが取れました!!
NNGA_CartPoleGym_L4-[10]-2_g1000_UNDX_20190720_2244-F_Gym.gif

開始早々20世代目には、最良個体は-200(最良値)になってます。
NNGA_CartPoleGym_L4-[10]-2_g1000_UNDX_20190720_2244.png

致死個体(交差の結果、重み・バイアスが-5.0~5.0から外れた個体)は評価対象から外すとしているので、この間の評価回数は187回でした。遺伝的アルゴリズムはどうしても無駄な評価が増えるのですが、他の人のスコアと比べて、比較的ましな回数でした。

まとめ

初期角度もほぼ垂直ですし、各stepでの外乱もないので、比較的簡単に解けてしまいました。
次はもうちょっとハードな環境でやってみたいと思います。。

SwitchBlade
業務は組込み系の開発を中心にやってきましたが、2020年7月~株式会社マイスター・ギルドにてWeb系の開発を始めました。
m-gild
最先端技術のMEISTERを目指し、お互い切磋琢磨するGUILD。Webシステム/サービス開発、スマホアプリ開発、AR/VR/MR開発など、様々なニーズに応えます。
https://www.m-gild.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away