Skip to content

Commit

Permalink
Add cell-centric discrete spaces (experimental) (projectmesa#1994)
Browse files Browse the repository at this point in the history
## Summary
This PR introduces an alteranative implementation for discrete spaces. This implementation centers on the explicit inclusion of a Cell class. Agents can occupy cells. Cells have connections, specifying their neighbors. The resulting classes offer a cell centric API where agents interact with a cell, and query the cell for its neighbors. 

To capture a collection of cells, and their content (_i.e._, Agents), this PR adds a new CellCollection class. This is an immutable collection of cells with convenient attribute accessors to the cells, or their agents. 

This PR also includes a CellAgent class which extends the default Agent class by adding a `move_to` method that works in conjunction with the new discrete spaces. 

From a performance point of view, the current code is a bit slower in building the grid and cell data structure, but in most use cases this increase in time for model initialization will be more than offset by the faster retrieval of neighboring cells and the agents that occupy them.

## Motive
The PR emerged out of various experiments aimed at improving the performance of the current discrete space code. Moreover, it turned out that a cell centric API resolved various open issues (_e.g._, projectmesa#1900, projectmesa#1903, projectmesa#1953). 

## Implementation
The key idea is to have Cells with connections, and using this to generate neighborhoods for a given radius. So all discrete space classes are in essence a [linked data structure](https://en.wikipedia.org/wiki/Linked_data_structure).

The cell centric API idea is used to implement 4 key discrete space classes: OrthogonalMooreGrid, OrthogonalVonNeumannGrid (alternative for SingleGrid and MultiGrid, and moore and von Neumann neighborhood) , HexGrid (alternative for SingleHexGrid and MultiHexGrid), and Network (alternative for NetworkGrid). Cells have a capacity, so there is no longer a need for seperating Single and Multi grids. Moore and von Neumann reflect different neighborhood connections and so are now implemented as seperate classes. 

---------

Co-authored-by: Jan Kwakkel <j.h.kwakkel@tudelft.nl>
  • Loading branch information
Corvince and quaquel committed Apr 9, 2024
1 parent a21704f commit 5e49e4a
Show file tree
Hide file tree
Showing 11 changed files with 1,136 additions and 56 deletions.
51 changes: 29 additions & 22 deletions benchmarks/Schelling/schelling.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import mesa
from mesa import Model
from mesa.experimental.cell_space import CellAgent, OrthogonalMooreGrid
from mesa.time import RandomActivation


class SchellingAgent(mesa.Agent):
class SchellingAgent(CellAgent):
"""
Schelling segregation agent
"""

def __init__(self, unique_id, model, agent_type):
def __init__(self, unique_id, model, agent_type, radius, homophily):
"""
Create a new Schelling agent.
Args:
Expand All @@ -16,31 +18,32 @@ def __init__(self, unique_id, model, agent_type):
"""
super().__init__(unique_id, model)
self.type = agent_type
self.radius = radius
self.homophily = homophily

def step(self):
similar = 0
for neighbor in self.model.grid.iter_neighbors(
self.pos, moore=True, radius=self.model.radius
):
neighborhood = self.cell.neighborhood(radius=self.radius)
for neighbor in neighborhood.agents:
if neighbor.type == self.type:
similar += 1

# If unhappy, move:
if similar < self.model.homophily:
self.model.grid.move_to_empty(self)
if similar < self.homophily:
self.move_to(self.model.grid.select_random_empty_cell())
else:
self.model.happy += 1


class Schelling(mesa.Model):
class Schelling(Model):
"""
Model class for the Schelling segregation model.
"""

def __init__(
self,
width=40,
height=40,
width=40,
homophily=3,
radius=1,
density=0.8,
Expand All @@ -51,35 +54,40 @@ def __init__(
Create a new Schelling model.
Args:
width, height: Size of the space.
height, width: Size of the space.
density: Initial Chance for a cell to populated
minority_pc: Chances for an agent to be in minority class
homophily: Minimum number of agents of same class needed to be happy
radius: Search radius for checking similarity
seed: Seed for Reproducibility
"""
super().__init__(seed=seed)
self.width = width
self.height = height
self.width = width
self.density = density
self.minority_pc = minority_pc
self.homophily = homophily
self.radius = radius

self.schedule = mesa.time.RandomActivation(self)
self.grid = mesa.space.SingleGrid(width, height, torus=True)
self.schedule = RandomActivation(self)
self.grid = OrthogonalMooreGrid(
[height, width],
torus=True,
capacity=1,
random=self.random,
)

self.happy = 0

# Set up agents
# We use a grid iterator that returns
# the coordinates of a cell as well as
# its contents. (coord_iter)
for _, pos in self.grid.coord_iter():
for cell in self.grid:
if self.random.random() < self.density:
agent_type = 1 if self.random.random() < self.minority_pc else 0
agent = SchellingAgent(self.next_id(), self, agent_type)
self.grid.place_agent(agent, pos)
agent = SchellingAgent(
self.next_id(), self, agent_type, radius, homophily
)
agent.move_to(cell)
self.schedule.add(agent)

def step(self):
Expand All @@ -93,13 +101,12 @@ def step(self):
if __name__ == "__main__":
import time

# model = Schelling(seed=15, width=40, height=40, homophily=3, radius=1, density=0.625)
# model = Schelling(seed=15, height=40, width=40, homophily=3, radius=1, density=0.625)
model = Schelling(
seed=15, width=100, height=100, homophily=8, radius=2, density=0.8
seed=15, height=100, width=100, homophily=8, radius=2, density=0.8
)

start_time = time.perf_counter()
for _ in range(100):
model.step()

print(time.perf_counter() - start_time)
68 changes: 34 additions & 34 deletions benchmarks/WolfSheep/wolf_sheep.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,41 +9,40 @@
Northwestern University, Evanston, IL.
"""

import mesa
import math

from mesa import Model
from mesa.experimental.cell_space import CellAgent, OrthogonalVonNeumannGrid
from mesa.time import RandomActivationByType

class Animal(mesa.Agent):
def __init__(self, unique_id, model, moore, energy, p_reproduce, energy_from_food):

class Animal(CellAgent):
def __init__(self, unique_id, model, energy, p_reproduce, energy_from_food):
super().__init__(unique_id, model)
self.energy = energy
self.p_reproduce = p_reproduce
self.energy_from_food = energy_from_food
self.moore = moore

def random_move(self):
next_moves = self.model.grid.get_neighborhood(self.pos, self.moore, True)
next_move = self.random.choice(next_moves)
# Now move:
self.model.grid.move_agent(self, next_move)
self.move_to(self.cell.neighborhood().select_random_cell())

def spawn_offspring(self):
self.energy /= 2
offspring = self.__class__(
self.model.next_id(),
self.model,
self.moore,
self.energy,
self.p_reproduce,
self.energy_from_food,
)
self.model.grid.place_agent(offspring, self.pos)
offspring.move_to(self.cell)
self.model.schedule.add(offspring)

def feed(self):
...

def die(self):
self.model.grid.remove_agent(self)
self.cell.remove_agent(self)
self.remove()

def step(self):
Expand All @@ -67,8 +66,9 @@ class Sheep(Animal):

def feed(self):
# If there is grass available, eat it
agents = self.model.grid.get_cell_list_contents(self.pos)
grass_patch = next(obj for obj in agents if isinstance(obj, GrassPatch))
grass_patch = next(
obj for obj in self.cell.agents if isinstance(obj, GrassPatch)
)
if grass_patch.fully_grown:
self.energy += self.energy_from_food
grass_patch.fully_grown = False
Expand All @@ -80,8 +80,7 @@ class Wolf(Animal):
"""

def feed(self):
agents = self.model.grid.get_cell_list_contents(self.pos)
sheep = [obj for obj in agents if isinstance(obj, Sheep)]
sheep = [obj for obj in self.cell.agents if isinstance(obj, Sheep)]
if len(sheep) > 0:
sheep_to_eat = self.random.choice(sheep)
self.energy += self.energy_from_food
Expand All @@ -90,7 +89,7 @@ def feed(self):
sheep_to_eat.die()


class GrassPatch(mesa.Agent):
class GrassPatch(CellAgent):
"""
A patch of grass that grows at a fixed rate and it is eaten by sheep
"""
Expand All @@ -100,7 +99,7 @@ def __init__(self, unique_id, model, fully_grown, countdown):
Creates a new patch of grass
Args:
grown: (boolean) Whether the patch of grass is fully grown or not
fully_grown: (boolean) Whether the patch of grass is fully grown or not
countdown: Time for the patch of grass to be fully grown again
"""
super().__init__(unique_id, model)
Expand All @@ -117,7 +116,7 @@ def step(self):
self.countdown -= 1


class WolfSheep(mesa.Model):
class WolfSheep(Model):
"""
Wolf-Sheep Predation Model
Expand All @@ -126,7 +125,6 @@ class WolfSheep(mesa.Model):

def __init__(
self,
seed,
height,
width,
initial_sheep,
Expand All @@ -136,7 +134,7 @@ def __init__(
grass_regrowth_time,
wolf_gain_from_food=13,
sheep_gain_from_food=5,
moore=False,
seed=None,
):
"""
Create a new Wolf-Sheep model with the given parameters.
Expand All @@ -152,6 +150,7 @@ def __init__(
once it is eaten
sheep_gain_from_food: Energy sheep gain from grass, if enabled.
moore:
seed
"""
super().__init__(seed=seed)
# Set parameters
Expand All @@ -161,24 +160,25 @@ def __init__(
self.initial_wolves = initial_wolves
self.grass_regrowth_time = grass_regrowth_time

self.schedule = mesa.time.RandomActivationByType(self)
self.grid = mesa.space.MultiGrid(self.height, self.width, torus=False)
self.schedule = RandomActivationByType(self)
self.grid = OrthogonalVonNeumannGrid(
[self.height, self.width],
torus=False,
capacity=math.inf,
random=self.random,
)

# Create sheep:
for _ in range(self.initial_sheep):
pos = (
self.random.randrange(self.width),
self.random.randrange(self.height),
)
energy = self.random.randrange(2 * sheep_gain_from_food)
sheep = Sheep(
self.next_id(),
self,
moore,
energy,
sheep_reproduce,
sheep_gain_from_food,
self.next_id(), self, energy, sheep_reproduce, sheep_gain_from_food
)
self.grid.place_agent(sheep, pos)
sheep.move_to(self.grid[pos])
self.schedule.add(sheep)

# Create wolves
Expand All @@ -189,21 +189,21 @@ def __init__(
)
energy = self.random.randrange(2 * wolf_gain_from_food)
wolf = Wolf(
self.next_id(), self, moore, energy, wolf_reproduce, wolf_gain_from_food
self.next_id(), self, energy, wolf_reproduce, wolf_gain_from_food
)
self.grid.place_agent(wolf, pos)
wolf.move_to(self.grid[pos])
self.schedule.add(wolf)

# Create grass patches
possibly_fully_grown = [True, False]
for _agent, pos in self.grid.coord_iter():
for cell in self.grid:
fully_grown = self.random.choice(possibly_fully_grown)
if fully_grown:
countdown = self.grass_regrowth_time
else:
countdown = self.random.randrange(self.grass_regrowth_time)
patch = GrassPatch(self.next_id(), self, fully_grown, countdown)
self.grid.place_agent(patch, pos)
patch.move_to(cell)
self.schedule.add(patch)

def step(self):
Expand All @@ -213,7 +213,7 @@ def step(self):
if __name__ == "__main__":
import time

model = WolfSheep(15, 25, 25, 60, 40, 0.2, 0.1, 20)
model = WolfSheep(25, 25, 60, 40, 0.2, 0.1, 20, seed=15)

start_time = time.perf_counter()
for _ in range(100):
Expand Down
4 changes: 4 additions & 0 deletions mesa/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
from .jupyter_viz import JupyterViz, make_text, Slider # noqa
from mesa.experimental import cell_space


__all__ = ["JupyterViz", "make_text", "Slider", "cell_space"]
23 changes: 23 additions & 0 deletions mesa/experimental/cell_space/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from mesa.experimental.cell_space.cell import Cell
from mesa.experimental.cell_space.cell_agent import CellAgent
from mesa.experimental.cell_space.cell_collection import CellCollection
from mesa.experimental.cell_space.discrete_space import DiscreteSpace
from mesa.experimental.cell_space.grid import (
Grid,
HexGrid,
OrthogonalMooreGrid,
OrthogonalVonNeumannGrid,
)
from mesa.experimental.cell_space.network import Network

__all__ = [
"CellCollection",
"Cell",
"CellAgent",
"DiscreteSpace",
"Grid",
"HexGrid",
"OrthogonalMooreGrid",
"OrthogonalVonNeumannGrid",
"Network",
]
Loading

0 comments on commit 5e49e4a

Please sign in to comment.