« ^ »

Evolution Gymステップバイステップ

所要時間: 約 11分

Evolution Gymは進化的アルゴリズムやその実装を検証するためのプラットフォームとして作られている。OpenGLで実装された環境内を、エージェントと呼ばれる動くモノが、アルゴリズムに従い活動する。

Evolution GymはOpenAI Gymを利用して実装されており1、それらの機能を利用できる。またNEAT-Pythonといったアルゴリズムを実装するライブラリを組み込む事で、アルゴリズムの品質を確認できる。

僕はしばらく前からこのEvolution Gymを使って遊んでいるのだが、未だに仕組みが理解 できない。難しいと感じる。しかし難しいと感じるものであっても、全く理解できないというものは本当に少なくて、その実像はただややこしいだけであったり、手順が多いだけだったり、いろんなものに包まれていて大切な部分が見えなくなっているだけだったりする。

Evolution GymはOpen AIの機能に、更にシミュレータ部分、それを利用するためのライブラリ部分、そして必要なオブジェクトを適切に管理し実装方法を取り決めるためのフレームワーク部分に分ける事ができる2。Evolution Gymの使い方を知るための道順には様々な道があると思う。フレームワーク部分だけを先に押さえる方法もあるが、僕にはどうやらそれは性に合っていないようだった。

それを踏まえ、ここではライブラリ部分に焦点を当てつつ、1歩ずつ操作できるオブジェクトを増やしていきながら、小さなプログラムを書く事で1歩つずつ足元を固め、理解を深める事にする。

シミュレータを表示させる

シミュレータを表示させる最小元のコードから始めよう。世界の要素を管理する evogym.world.EvoWorld 、 シミューレションを行う evogym.sim.EvoSim 、 シミュレーション結果を可視化する evogym.viewer.EvoViewer 、これらのクラスを使うことで、Evolution Gymの画面を表示できる。

クラス用途
evogym.world.EvoWorld世界の要素を管理する
evogym.sim.EvoSimシミューレションを行う
evogym.viewer.EvoViewerシミュレーション結果を可視化する

それぞれのクラスを使い表示を行う。EvoViewerクラスのviewerメソッドを呼び出す事で画面が表示される。

from evogym import EvoSim, EvoViewer, EvoWorld

w = EvoWorld()
s = EvoSim(w)
v = EvoViewer(s)
v.render()
input("END")

このコードを実行すると、真っ白い画面が表示される。これはOpenGLで構成された画面であり、ここにエージェントや地面といった要素を追加していく。今は何も追加していないので、空の世界が表示されているだけだ。

地面を作る

何もない世界に地面を追加する。地面もこの世界では要素の一つとして表現する。要素は evogym.world.WorldObject のインスタンスとして作成し、 EvoWorld に追加する。

クラス用途
evogym.world.WorldObject世界の要素。構造、位置などの属性を持つ

numpy.ndarrayによって形を表現し、その中に設定する値で、どのような性質のものを使うかを指定する。この構成要素の事をボクセルと呼ぶ。ボクセルには6種類の異なる性質があり、それぞれ数値で表現されている。この種類の事をボクセルタイプと呼ぶ。ボクセルタイプには、位置が固定されるものから、動きを付ける事ができるものまである。地形の場合、動きを付けたくないので FIXED(5) を使用する。 この値は evogym.utils.VOXEL_TYPES に定義されている。

キー
EMPTY0
RIGID1
SOFT2
H_ACT3
V_ACT4
FIXED5
ボクセルタイプ

これらの機能と値を使用し地形を作る。

import numpy as np
from evogym.sim import EvoSim
from evogym.utils import Pair
from evogym.viewer import EvoViewer
from evogym.world import EvoWorld, WorldObject


data = np.array([
    [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5], 
])

o = WorldObject()
o.load_from_array(name="terrain", structure=data)
# o.pos = Pair(0, 0)

w = EvoWorld()
w.add_object(o)

s = EvoSim(w)
v = EvoViewer(s)

v.render("screen")
input("END")

注意点としては、シミュレータにワールドオブジェクトを追加する前に、要素をワールドに追加しておく必要がある。

エージェントを追加する

シミュレータの中の世界に動きを与えるには、動きを付ける事ができるボクセルを使用する。動きを付ける事ができるボクセルをアクチュエータと呼ぶ。動きを付ける事ができると言っても、縦横無尽に意図した方向に動かせるわけではなく、アクチュエータを膨張させたり、収縮させたりできるだけだ。

アクチュエータは、横方向の膨張収縮を行う H_ACT と、縦方向の膨張収縮を行う V_ACT の2つとなる。どこか筋肉のような趣きを感じさせる。この膨張と収縮の結果として力が伝わり、エージェントが動き出す。例として H_ACT のボクセル1つだけで構成されたエージェントを追加し、シミュレータを実行する。

import time
import numpy as np
from evogym.sim import EvoSim
from evogym.utils import Pair, get_full_connectivity, has_actuator, is_connected
from evogym.viewer import EvoViewer
from evogym.world import EvoWorld, WorldObject

data = np.array([
    [3],
])

assert has_actuator(data)
assert is_connected(data)

o = WorldObject()
o.load_from_array(name="robot", structure=data)
o.pos = Pair(0, 0)

w = EvoWorld()
w.add_object(o)

s = EvoSim(w)
v = EvoViewer(s)

d = s.get_dim_action_space("robot")
for i in range(100):
    n = i % 2
    p = np.full(shape=(d,), dtype=int, fill_value=n*2)
    s.set_action("robot", p)
    s.step()
    v.render("screen")
    time.sleep(0.1)
input("END")

ここで使用している数値3はH_ACTの値で、横方向への動きを付ける事ができる。エージェントとして動作させるには、アクチュエータを持っており、きちんと連結した構造をしている必要がある。それらの状態は evogym.utils.has_actuatorevogym.utils.is_connected によってチェックする事ができ、適切ではない状態であればFalseの値を返す。それらの値をチェックした後 load_from_array によって、 robot という名前でエージェントを登録している。この名前は、アクチュエータの次元数を取得したり、アクチュエータに力を伝達するために、シミュレータに問い合わせるために使用する。

力の伝達は set_action によって行われる。第1引数はオブジェクトの名前、第2引l数は各アクチュエータに伝達するための力を表現したnumpy.ndarrayとなる。例では100ステップ実行していて、偶数ステップの場合は0を、奇数ステップの場合は2を伝達している。 s.step() によってシミュレーションを行われ、 v.render("screen") によって描画を更新している。

ループの中で0.1秒スリープしているため、力がアクチュエータにどのように伝達され、その結果としてエージェントが動く様子を目視で確認できるだろう。試しにアクチュエータを H_ACT から V_ACT に変更したり、数を増やしたりすると、エージェントの動作が変わる事がわかる。

オブジェクトの状態を取得する

オブジェクトの状態は、シミュレータのメソッドを経由して取得できる。主に取得できる値は位置、角度、加速度だ。それぞれ以下のメソッドにより取得する。

  • s.object_orientation_at_time(1, "robot")
  • s.object_pos_at_time(1, "robot")
  • s.object_vel_at_time(1, "robot")
  • s.pos_at_time(1)

例えば次の例では、画面の真ん中あたりにエージェントが来るように、アクチュエータへの力の入れ具合を左右で切り替えている。

import time
import numpy as np
from evogym.sim import EvoSim
from evogym.utils import Pair, get_full_connectivity, has_actuator, is_connected
from evogym.viewer import EvoViewer
from evogym.world import EvoWorld, WorldObject

data = np.array([
    [3,2,2,2,3],
])

assert has_actuator(data)
assert is_connected(data)

o = WorldObject()
o.load_from_array(name="robot", structure=data)
o.pos = Pair(0, 0)

w = EvoWorld()
w.add_object(o)

s = EvoSim(w)
v = EvoViewer(s)

d = s.get_dim_action_space("robot")
for i in range(10000):
    t = s.get_time()
    if i % 2:
        p = np.array([0, 0])
    elif s.object_pos_at_time(t, "robot")[0][0] > 5:
        p = np.array([2, 0])
    else:
        p = np.array([0, 2])
    s.step()
    s.set_action("robot", p)
    v.render("screen")
input("END")

エージェントが左に寄り過ぎている場合には右に、右に寄り過ぎている場合には左に移動するようにしている。このような制御はフィードバック制御と呼ばれたりする。簡単なロジックであれば、例のように具体的に実装する事もできる。しかし現実世界では単純なロジックで動作させてしまうと、事故が発生したり、問題を引き起きしたりする事の方が多いだろう。このロジックの部分を機械学習によって決定する事で、より複雑な問題に対処できるようになる。

複数のエージェント

エージェントを2つ以上作成する事もできる。先程行ったエージェントを追加する方法を、同じように2つ用意してやればよい。例として H_ACT 1つだけのエージェントAと V_ACT 1つだけのエージェントBを追加する方法を示す。

import time
import numpy as np
from evogym.sim import EvoSim
from evogym.utils import Pair, get_full_connectivity, has_actuator, is_connected
from evogym.viewer import EvoViewer
from evogym.world import EvoWorld, WorldObject

w = EvoWorld()

agent_a_body = np.array([
    [3],
])
assert has_actuator(agent_a_body)
assert is_connected(agent_a_body)
agent_a = WorldObject()
agent_a.load_from_array(name="agent_a", structure=agent_a_body)
agent_a.pos = Pair(0, 0)
w.add_object(agent_a)


agent_b_body = np.array([
    [4],
])
assert has_actuator(agent_b_body)
assert is_connected(agent_b_body)
agent_b = WorldObject()
agent_b.load_from_array(name="agent_b", structure=agent_b_body)
agent_b.pos = Pair(1, 0)
w.add_object(agent_b)

s = EvoSim(w)
v = EvoViewer(s)

agent_a_dim = s.get_dim_action_space("agent_a")
agent_b_dim = s.get_dim_action_space("agent_b")


for i in range(10000):
    n = i % 2
    s.set_action("agent_a", np.full(shape=(agent_a_dim,), dtype=int, fill_value=n))
    s.set_action("agent_b", np.full(shape=(agent_b_dim,), dtype=int, fill_value=n))
    s.step()
    v.render("screen")
input("END")

2つのエージェントの初期の位置が重ならないように配置する必要がある。シミュレーション中に2つのエージェントが接触すると、お互いが反発しあう事が分かる。

複数のシミュレータ

エージェントが複数用意できるなら、シミュレータも複数用意できる。シミュレータが分かれるため、結果が同じにはならないが、1つのエージェント用の WorldObject を両方のシミュレータに読み込ませる事もできる。例として、1つの EvoWorld に1つの WorldObject を追加し、シミュレータを2つ実行する方法を示す。

import time
import numpy as np
from evogym.sim import EvoSim
from evogym.utils import Pair, get_full_connectivity, has_actuator, is_connected
from evogym.viewer import EvoViewer
from evogym.world import EvoWorld, WorldObject

from evogym import EvoSim, EvoViewer, EvoWorld

agent_body = np.array([
    [3],
])
assert has_actuator(agent_body)
assert is_connected(agent_body)
agent = WorldObject()
agent.load_from_array(name="agent", structure=agent_body)
agent.pos = Pair(0, 0)

# 1つめ
w_a = EvoWorld()
w_a.add_object(agent)
s_a = EvoSim(w_a)
v_a = EvoViewer(s_a)
v_a.render()

# 2つめ
w_b = EvoWorld()
w_b.add_object(agent)
s_b = EvoSim(w_b)
v_b = EvoViewer(s_b)
v_b.render()


agent_a_dim = s_a.get_dim_action_space("agent")
agent_b_dim = s_b.get_dim_action_space("agent")
for i in range(10000):
    n = i % 2
    
    s_a.set_action("agent", np.full(shape=(agent_a_dim,), dtype=int, fill_value=n))
    s_b.set_action("agent", np.full(shape=(agent_b_dim,), dtype=int, fill_value=n))
    
    s_a.step()
    s_b.step()

    v_a.render("screen")
    v_b.render("screen")

input("END")

WorldObject が保持している値がどのようなものか、シミュレーション時にどのような影響を受けるのかまで調べられなかったが、この方法はあまり好ましくないかもしれない。それぞれのシミュレータ用に、それぞれの WorldObject を生成した方が良いかもしれない。

地形をファイルから読み込む

EvoWorld は、地形情報をファイルから読み込むためのメソッド from_json を提供している。ここでは、JSON形式のファイルから地形を読み込む例を示す。

import itertools

import numpy as np
from evogym import (EvoSim, EvoViewer, EvoWorld, get_full_connectivity,
                    has_actuator, is_connected)

world = EvoWorld.from_json("evo-ground.json")

robot_body = np.array(
    [
        [1, 2],
        [3, 4],
    ]
)
assert is_connected(robot_body)
assert has_actuator(robot_body)
connections = get_full_connectivity(robot_body)
world.add_from_array("robot", robot_body, 1, 5, connections)

sim = EvoSim(world)
viewer = EvoViewer(sim)

actuator_indices = sim.get_dim_action_space("robot")
for i in itertools.count():
    # アクチュエータごとの入れ具合をランダムにする
    action = np.random.random(actuator_indices)
    action_smooth = np.clip(action, 0.6, 1.6)  # 肩の力を抜く
    sim.set_action("robot", action_smooth)  # 力を入れる
    done = sim.step()
    viewer.render("screen")  # 描画を更新する
evo.py

地形データはファイルから読み込む事にした。

{
  "grid_width": 10,
  "grid_height": 10,
  "objects": {
    "ground0": {
      "indices": [0,1,2,3,4,5],
      "types": [1,1,1,1,1,1],
      "neighbors": {
        "0": [1],
        "1": [0,2],
        "2": [1,3],
        "3": [2,4],
        "4": [3,5],
        "5": [4]
      }
    }
  }
}
evo-ground.json

できあがりは、こんな感じ。四角い何かが微振動する。

https://res.cloudinary.com/symdon/image/upload/v1684057717/blog.symdon.info/1684057426/game_b2rq03.gif

環境について

ここまでの説明の中で evogym.envs について全く触れなかった。これは、その方が理解しやすいと考えたため意図的にそうした。 evogym.envs にはEvolution Gymで使用する所謂 "環境" が定義されている。まず環境の基底クラスとなる evogym.envs.base.EvoGymBase が定義されている。これは gym.Env を継承している。 gym.Env はOpenAI Gymが提供している環境用クラスだ。そのためOpenAI Gymの環境の機能を使用できる。そしてベンチマークを行うためのユーティリティ的なメソッドを備えた evogym.envs.base.BenchmarkBase があり、これは evogym.envs.base.EvoGymBase を継承している。

個別の目的で環境を新たに実装する場合、多くは evogym.envs.base.BenchmarkBase を継承し、必要な関数をオーバーライドする形で実装する。 evogym.envs.base.EvoGymBase は初期化時に、シミュレータ(EvoSim)をビューア(EvoViewer)をインスタンス化し、メンバー変数に保持する。つまり、これまで直接記述していたシミュレータとビューアの処理を、環境側が行ってくれる。

これは本当に分かりやすいのだろうか。多くの人にとっては、ここからスタートする方が分かりやすいのかもしれない。しかし、僕にはどうもそのように思えなかった。このメンバー変数に格納されているオブジェクトは、どこで生成された何なんだろう。本当は誰が持っているオブジェクトなんだろう。どの順序でメソッドが呼ばれていくのだろう。こんな風に考えてしまう。どうも実際の動きをみようとした時に、靄をかけられたように実体を掴む事ができなかった。

シミュレータ及びビューア起動後にオブジェクトの追加や形状変更はできなかった

状況に応じて、地形やエージェントの形状を変化させる事で、難易度を挙げたり、新たな能力を獲得したりといった事をしたくなるだろう。これまでは予め記述しておいた構造のオブジェクト(WorldObject)を世界(EvoWorld)に登録し、それをシミュレータ(EvoSim)に読み込ませ実行してきた。そしてWorldObjectの計上は voxel 属性によって、決定する事を確認してきた。ここでは、この voxel を動的に変更する事で、オブジェクトの形状を変化させる方法を確認する事にした。しかし、世界をシミュレータに登録する前に、オブジェクトを世界に登録しておかないといけないらしい。また一度登録し、シミュレーションを開始したオブジェクトを除去する事もできなかった。 EvoWorld.remove_objects() というそれらしいメソッドは用意されているが、オブジェクトを削除した後、再描画してもオブジェクトを消し去る事はできなかった。

またビューアを終了する事もできず、一度インスタンス化してしまうとプロセスが生きている限り、生存し続けるようだ。そのため、シミュレータやビューアを破棄するためには、サブプロセスとして起動し、そのプロセスを終了させるしかないようだ。

おわりに

Evolution Gymを使ってみて特に、フレームワークとして実装されている箇所で、全体を把握しにくいと感じた事が、この文章を書くきっかけになった。文章を書いて、コードを読んで、実装してみてかなり理解が進んだ。またできそうでできない事などを把握する事もできた。あくまでアルゴリズムの検証用プラットフォームであるため、それ以上の機能は必要なかったのだろう。

くどくどとしたこの文章に付き合い、ここまで辿り着いた人は、根気がありますね。ここで新ためて evogym.envs 配下のクラスの実装を見て欲しい。様々な目的に応じた環境のクラスが実装されている。シミュレータやビューアの扱い方やアクチュエータへの力の伝達を押えていると、そこそこ動きが把握できるのではないだろうか。

この文章が誰かの役に立つ事があるのかは分からないけれど、僕自身がEvolution Gymに対する理解を深める役には立った。だから、ここに痕跡として残しておく。


1

OpenAI Gymは既にメンテナンスモードとなっている。OpenAI GymはGymnasiumという名前でフォークされ、コミュニティによって開発が継続している。Evolution Gymは今のところGymnasiumを使用していないが、いずれ切り替えるのかもしれない。

2

これは僕がそう考えているだけであって、Evolution Gym自体がそういう区分をしている訳ではない。