Pythonで狼と羊の生態系マルチエージェントシミュレーション(MAS)を作成した

狼と羊の生態系マルチエージェントシミュレーションを作成しましたので公開します。

できる限り解説コメントを付けていますので、ご参考にどうぞ。

# 狼と羊の生態系マルチエージェントシミュレーション(MAS)とは何か

**簡単に言うと弱肉強食（predator-prey）の生態系をプログラムで再現するものです。**

　狼と羊はランダムにマップ上に置かれます。各ステップごとに狼と羊は移動し、体力が1減少します。
体力が０になると死んでしまうため、各エージェント(狼や羊)は羊や牧草を食べて体力を回復しなければなりません。
　また、人口規模を維持するために、各エージェント(狼や羊)はステップごとに数％の確率で繁殖します。

注：このモデルの原典は米国タフツ大STARLogoTで公開されている「オオカミとヒツジ」モデルです。


**MASに採用している条件**
・エージェント(狼や羊)がフィールドの壁に向かって移動すると、その壁と対になる壁から出現する。


# 実行結果

実行するとグラフが表示され、20秒ほどかけて1000ステップ更新します。
グラフでは**生きている**羊と狼それぞれの数を比較できます。
![____________________________2024-08-17_17.59.06_720.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552198/fff474f1-7d3d-6d41-935b-3c9d2a7cd450.png)
~~~
実行時間：20.775272846221924
開始羊数 400 最終羊数： 344 累積羊数： 4595
開始狼数 100 最終狼数： 35 累積狼数： 4316
~~~

# コードの工夫

**エージェント情報を辞書で管理し、MASの高速化を図る**
->これは辞書に保存されたエージェントの探索がリストに比べて高速に行えるためです。(筆者のPC環境でリストと辞書の実行時間に約5倍の差が出ました)

**エージェントにクラスメソッドを使用して、オブジェクト生成時に毎回特定の辞書へ格納するコードを記述する手間を省く**
->筆者はエージェントのオブジェクト生成時に格納する辞書の名前をいちいち覚えていられない為、自動で格納されるようにしています。

**エージェントの親クラスAgentを設ける**
->コードが短くなりました

**リアルタイムで更新されるグラフを表示する**
->生きているエージェント数を比較する折れ線グラフが自動生成されるようにしました。



# コード本文

コメントでできる限りの解説を行っています。是非お読みください。

コードに違いや無駄があった場合はコメントで教えて頂けると助かります。
注意：実行には数十秒かかります。

追記：MASのプログラミングの仕方はたくさんあります。
・エージェントを2次元配列で管理する大域変数を設ける
・エージェントは他のエージェントの情報(メンバ変数など)をいじらない
など様々な設計思想がありますが、今回紹介するプログラムには上記の制約を一切反映しておりません。
まだまだ勉強中の身ですのでどうかご容赦をお願いします。

コード内で使用しているグラフライブラリmatplotlibの詳細は下記を参照してください

https://ai-inter1.com/python-matplotlib/#st-toc-h-1

~~~狼と羊のMAS.py
import random
import time
import matplotlib.pyplot as plt

# エージェントが移動するエリアの縦、横の上限を設定
CELL_WIDTH = 50
CELL_HEIGHT = 50

START_SHEEP_COUNT = 400 # 最初の羊の数
NOW_SHEEP_COUNT = 0   # 現在の羊の累積数(死んでいる羊もカウントしている)
START_WOLF_COUNT = 100   # 最初の狼の数
NOW_WOLF_COUNT = 0    # 現在の狼の累積数(死んでいる狼もカウントしている)

STEP = 1000 # ステップ数

ETABLE_COUNT = 20 # 牧草が食べられる状態になるまでに必要なステップ数

SHEEP_RECOVERY_AMOUNT = 5 # 羊が牧草を食べたときのhp回復量
WOLF_RECOVERY_AMOUNT = 30 # 狼が羊を食べたときのhp回復量

SHEEP_BIRTH_RATE = 4 # 羊の出産率(単位：％)
WOLF_BIRTH_RATE = 4  # 狼の出産率(単位：％)


# 牧草
class Grass:
    # オブジェクト生成時自動格納する辞書を設定する関数
    @classmethod
    def set_dict(cls, dict):
        cls.dict = dict

        
    def __init__(self, pos:tuple, eat_step:int = random.randint(1, 9)):
        self.pos = pos # 位置座標
        self.dict[str(self.pos)] = self # 辞書に登録 Key: 位置座標, Value: オブジェクトそのもの
        # ステップごとにこの値(eat_step)が1増加し、10以上になると羊が食べる。
        #その後、また0からステップごとに1増加するを繰り返す。
        self.eat_step = eat_step

        
    # 可食カウントを進める
    def add_eat_step(self):
        self.eat_step += 1

        
# エージェントの親クラス(羊、狼)
class Agent:
    # オブジェクト生成時自動格納する辞書を設定する関数
    @classmethod
    def set_dict(cls, dict, grasses):
        cls.dict = dict # Agent(Sheep, Wolf)のオブジェクトを保存するための辞書。Key: クラス名 + 個体識別番号, Value: オブジェクトそのもの
        # Sheepが牧草を食べる際、参照が必要になるため
        #あらかじめ牧草オブジェクトが保存されている辞書(Key: 位置座標(?, ?), Value: オブジェクトそのもの)を保存しておく
        cls.grasses = grasses

        
    def __init__(self, id:int, pos:tuple, hp:int, maxhp:int):
        self.id = id   # 個体識別番号
        self.pos = pos # 位置座標
        self.hp = hp   # ヒットポイント(毎ターン減少し続け0になると死亡、何かを食べることでこの数値が増える)
        self.maxhp = maxhp # ヒットポイントの上限(エージェント(狼や羊)が食い溜めできる量には限界がある)

        
    # Agentクラスの辞書にこのSheep, Wolfオブジェクトを格納する予定であるため、
    # 各クラスのオブジェクトの見分けがつくようにするための関数。
    # 後に記述するSheep,　Wolfそれぞれでオーバーライドしている
    def isSheep(self):
        return False

        
    def isWolf(self):
        return False

        
    # 移動してhpを1減らす
    def move_random(self):
        # 1:"上", 2:"下", 3:"左", 4:"右"
        # 左上が(1,1), 右下が(CELL_WIDTH CELL_HEIGHT)
        dir = random.randint(1, 4)
        if(dir == 1):# 上
            # print("上に移動")
            if(self.pos[1] - 1 >= 1):# 壁にぶつからないならば
                self.pos = (self.pos[0], self.pos[1] - 1)
            else:# 壁にぶつかったならその壁と対になる壁から出現する
                self.pos = (self.pos[0], CELL_HEIGHT)
                
        elif(dir == 2):# 下
            # print("下に移動")
            if(self.pos[1] + 1 <= CELL_HEIGHT):# 壁にぶつからないならば
                self.pos = (self.pos[0], self.pos[1] + 1)
            else:# 壁にぶつかったならその壁と対になる壁から出現する
                self.pos = (self.pos[0], 1)
                
        elif(dir == 3):# 左
            # print("左に移動")
            if(self.pos[0] - 1 >= 1):# 壁にぶつからないならば
                self.pos = (self.pos[0] - 1, self.pos[1])
            else:# 壁にぶつかったならその壁と対になる壁から出現する
                self.pos = (CELL_WIDTH, self.pos[1])
                
        else:# 右
            # print("右に移動")
            if(self.pos[0] + 1 <= CELL_WIDTH):# 壁にぶつからないならば
                self.pos = (self.pos[0] + 1, self.pos[1])
            else: # 壁にぶつかったならその壁と対になる壁から出現する
                self.pos = (1, self.pos[1])
                
        # お腹が減る
        self.hp -= 1
        if(self.hp <= 0):
            del self.dict[str(self.__class__.__name__) + str(self.id)]

            
# 羊
class Sheep(Agent):
    def __init__(self, id:int, pos:tuple, hp: int, maxhp: int):
        super().__init__(id, pos, hp, maxhp) # 親クラス(Agent)のコンストラクタ(_init_)関数を起動
        # 自身のオブジェクトを辞書(親クラスのset_dict関数で設定)に保存する
        self.dict[str(self.__class__.__name__) + str(self.id)] = self

        
    # Agentクラスの辞書にこのSheepオブジェクトを格納する予定であるため、
    # Wolfクラスのオブジェクトと見分けがつくようにするための関数
    def isSheep(self):
        return True

        
    # 牧草を食べてhp回復
    def eat(self):
        if(self.hp > 0):
            grass = self.grasses[str(self.pos)] # 直下の牧草オブジェクトを取得
            
            # 牧草が食べられる状態か判断する
            if(grass.eat_step < ETABLE_COUNT):
                pass
                # print("羊%s：草がない:hp = %s"%(self.id, self.hp))
                
            else:
                if(self.hp + SHEEP_RECOVERY_AMOUNT > self.maxhp):
                    self.hp = self.maxhp
                else:
                    self.hp += SHEEP_RECOVERY_AMOUNT # 体力回復
                #print("羊%s：草を食べる:hp = %s"%(self.id, self.hp))
                grass.eat_step = 0

                
    # 4%の確率で出産する
    def born(self):
        global NOW_SHEEP_COUNT # NOW_SHEEP_COUNTの値を変更するため、gobal宣言が必要
        # 繁殖
        if(random.randint(1,100) <= SHEEP_BIRTH_RATE):
            #print("羊%sが繁殖した"%(self.id))
            self.hp = int(self.hp/2)
            NOW_SHEEP_COUNT += 1 # 累積羊数の増加
            Sheep(NOW_SHEEP_COUNT, self.pos, random.randint(1, 10), 100)

            
# 狼
class Wolf(Agent):
    def __init__(self, id:int, pos:tuple, hp:int, maxhp:int):
        super().__init__(id, pos, hp, maxhp) # 親クラス(Agent)のコンストラクタ(_init_)関数を起動
        # 自身のオブジェクトを辞書(親クラスのset_dict関数で設定)に保存する
        self.dict[str(self.__class__.__name__) + str(self.id)] = self

        
    # Agentクラスの辞書にこのSheepオブジェクトを格納する予定であるため、
    # Wolfクラスのオブジェクトと見分けがつくようにするための関数
    def isWolf(self):
        return True

        
    # 羊を食べてhp回復
    def eat(self):
        if(self.hp > 0):
            # 自身の座標と同じ座標に羊がいたなら食べる
            for sheep in self.dict.values():
                if(sheep.pos == self.pos and sheep.isSheep()):
                    #print("狼%sが羊%sを食べた！"%(self.id, sheep.id))
                    if(self.hp + WOLF_RECOVERY_AMOUNT > self.maxhp):
                        self.hp = self.maxhp
                        
                    else:
                        self.hp += WOLF_RECOVERY_AMOUNT # 体力回復
                    del self.dict["Sheep" + str(sheep.id)] # 食べた羊を削除
                    break

                    
    # 2%の確率で出産する
    def born(self):
        global NOW_WOLF_COUNT # NOW_WOLF_COUNTの値を変更するため、gobal宣言が必要
        # 繁殖
        if(random.randint(1,100) <= WOLF_BIRTH_RATE):
            #print("狼%sが繁殖した"%(self.id))
            self.hp = int(self.hp/2)
            NOW_WOLF_COUNT += 1 # 累積狼数の増加
            Wolf(NOW_WOLF_COUNT, self.pos, random.randint(1, 60), 300)

            
# このプログラムの実行時間を計測開始
start = time.time()

# 初期設定
# オブジェクト生成時に自動格納される辞書を設定
Agents = {}  # エージェント(Sheep, Wolf)のオブジェクト, Key:クラス名 + 個体識別番号, Value: Agentを継承したクラス(例:Sheep, Wolf)のオブジェクト
Grasses = {} # 牧草(Grass)オブジェクトを保存, Key:座標(?, ?), Value: Grassオブジェクト
Grass.set_dict(Grasses)
Agent.set_dict(Agents, Grasses)# 牧草オブジェクトの情報を使用するため第2引数が必要

# 牧草オブジェクト作成
for w in range(CELL_WIDTH):
    for h in range(CELL_HEIGHT):
        Grass((w + 1, h + 1)) # 引数：位置座標(1, 1) ~ (CELL_WIDTH, CELL_HEIGHT)
        
# ランダムに500個を食べられる牧草オブジェクトにする
eatable = 0
while(eatable < 500):
    x = random.randint(1,CELL_WIDTH)
    y = random.randint(1,CELL_HEIGHT)
    n = Grasses[str((x, y))]# ランダムに選ばれた牧草オブジェクト
    if(n.eat_step != 10):
        n.eat_step = 10
        eatable += 1
        
# 羊を初期生成
while(NOW_SHEEP_COUNT < START_SHEEP_COUNT):
    x = random.randint(1,CELL_WIDTH)
    y = random.randint(1,CELL_HEIGHT)
    NOW_SHEEP_COUNT += 1
    # 個体識別番号, 位置座標, 体力, 最大体力
    Sheep(NOW_SHEEP_COUNT, (x, y), random.randint(1, 10), 100)
    
# 狼を初期生成
while(NOW_WOLF_COUNT < START_WOLF_COUNT):
    x = random.randint(1,CELL_WIDTH)
    y = random.randint(1,CELL_HEIGHT)
    NOW_WOLF_COUNT += 1
    # 個体識別番号, 位置座標, 体力, 最大体力
    Wolf(NOW_WOLF_COUNT, (x, y), random.randint(10, 60), 300)

    
######### グラフ描画の準備 #############
# エージェント数を記録するリスト
sheep_counts = []
wolf_counts = []
# グラフの初期設定
plt.ion()  # インタラクティブモードをオンにする
fig, ax = plt.subplots()
line1, = ax.plot([], [], label='Sheep Count', color='green')
line2, = ax.plot([], [], label='Wolf Count', color='red')
ax.set_xlim(0, STEP)
ax.set_ylim(0, max(START_SHEEP_COUNT, START_WOLF_COUNT) * 1.5)
ax.set_xlabel('Step')
ax.set_ylabel('Count')
ax.legend()
def update_graph(step, sheep_count, wolf_count):
    sheep_counts.append(sheep_count)
    wolf_counts.append(wolf_count)
    line1.set_data(range(len(sheep_counts)), sheep_counts)
    line2.set_data(range(len(wolf_counts)), wolf_counts)
    ax.set_xlim(0, max(len(sheep_counts), STEP))
    ax.set_ylim(0, max(max(sheep_counts), max(wolf_counts)) * 1.5)
    plt.draw()
    plt.pause(0.01)
######################################


# (main文)ステップ数だけ回す
for step in range(STEP):
    # 捕食
    for n in Agents.copy().values(): # n.move_eat()でエージェントを削除することがあるため、Agents辞書のコピーを作成する
        n.eat()
        
    # ランダムに移動
    for n in Agents.copy().values(): # n.move_random()でエージェントを削除することがあるため、Agents辞書のコピーを作成する
        n.move_random()
        
    # エージェントの出産判定
    for n in Agents.copy().values(): # n.born()でエージェントをAgents辞書へ追加することがあるため、Agents辞書のコピーを作成する
        n.born()
        
    # 牧草の更新
    for n in Grasses.values():
        n.add_eat_step()
        
    # 現在生きているエージェント(羊、狼)の数を数える
    live_sheep_count = 0
    live_wolf_count = 0
    for x in Agents.values():
        if(x.isSheep()):
            live_sheep_count += 1
        elif(x.isWolf()):
            live_wolf_count += 1
            
    # グラフを更新
    update_graph(step, live_sheep_count, live_wolf_count)
    
end = time.time()
print("実行時間：%s"%(end- start))

# 現在生きているエージェント(羊、狼)の数を数える
live_sheep_count = 0
live_wolf_count = 0
for x in Agents.values():
    if(x.isSheep()):
        live_sheep_count += 1
    elif(x.isWolf()):
        live_wolf_count += 1
print("開始羊数",START_SHEEP_COUNT,"最終羊数：", live_sheep_count, "累積羊数：",NOW_SHEEP_COUNT)
print("開始狼数",START_WOLF_COUNT,"最終狼数：", live_wolf_count, "累積狼数：",NOW_WOLF_COUNT)

# プログラム終了後にグラフを表示したままにする
plt.ioff()
plt.show()


~~~