Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reimplementation of Continuous Space #2584

Merged
merged 90 commits into from
Jan 10, 2025
Merged

Conversation

quaquel
Copy link
Member

@quaquel quaquel commented Dec 30, 2024

This reimplements continuous space and adds the basic starting point for an agent-centric API analogous to what is possible with cell spaces.

API examples
This reimplements ContinuousSpace in line with the API design established for cell spaces. A key design choice of the cell spaces is that movements become agent-centric (i.e., agent.cell = some_cell). This PR does the same but for continuous spaces. So, you can do agent.position += speed * heading. Likewise, you can do agent.get_nearest_neighbors(k=5) or agent.get_neigbors_in_radius(radius=3.1415).

space = ContinuousSpace([ [0, 1], [0, 1] ], torus=True, random=model.random)

space.agent_positions  # the numpy array with all agent positions
distances, agents = space.calculate_distances[0.5, 2])  

agents, distances = space.get_agents_in_radius([0.5, 0.5], radius=3.1415)
agents, distances = space.get_k_nearest_agents([0.5, 0.5], k=2)

agent = ContinousAgent(model, space)
agent.position = [agent.random.random(), agent.random.random()]
agent.position += [0.1, 0.05]

nearest_neighbor, distance = agent.get_nearest_neigbors()
neighbors, distances = agent.get_neigbors_in_radius(radius=0.5)

Implementation details
In passing, this PR contains various performance improvements and generalizes continuous spaces to n-d.

agent.position is a view into the numpy array with all agent positions. This is analogous to how cells access their value in a property layer. In contrast to the current implementation, the numpy array with all agent positions is never fully rebuilt. Rather, it is a masked array with possible empty rows. If the numpy array becomes too small, it is expanded by adding 20% more rows to it. Most of this will be fine-tuned further, becoming user controllable.

Regarding performance, I have spent most of last week reading up on r-trees, ball trees, and kd trees. Moreover, I ran various performance tests comparing trees against brute force distance calculations. It seems that brute force wins for MESA's use case. Why? Trees are amazing if you need to do frequent lookups and have comparatively few updates of locations. In MESA, however, we will often have many updates of locations (any agent movement will trigger an update of the location and, thus, an update of the tree). Once I established that brute force was the way to go, Next, I compared various ways of calculating Euclidian distances. I settled on using scipy.spatial.cdist for non-torus and and a for loop over the columns for manual Euclidian distance calculations in case of torus.

Copy link

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 -1.1% [-2.1%, -0.0%] 🔵 +0.2% [+0.0%, +0.4%]
BoltzmannWealth large 🔵 -0.3% [-1.5%, +0.5%] 🔵 +0.4% [-0.9%, +1.5%]
Schelling small 🔵 -0.1% [-0.3%, +0.1%] 🔵 -0.5% [-0.6%, -0.3%]
Schelling large 🔵 -0.2% [-0.7%, +0.2%] 🔵 -0.7% [-1.5%, +0.1%]
WolfSheep small 🔵 -0.9% [-1.5%, -0.3%] 🔵 +2.5% [-2.0%, +6.9%]
WolfSheep large 🔵 -0.9% [-1.9%, +0.3%] 🔵 -0.8% [-1.9%, +0.7%]
BoidFlockers small 🔵 -1.5% [-2.1%, -0.7%] 🔵 -1.6% [-2.5%, -0.8%]
BoidFlockers large 🔵 -3.0% [-3.9%, -2.0%] 🔵 -2.4% [-2.7%, -2.0%]

@EwoutH
Copy link
Member

EwoutH commented Dec 30, 2024

Thanks for working on this!

A key design choice of the cell spaces is that movements become agent-centric (i.e., agent.cell = some_cell). This PR does the same but for continuous spaces. So, you can do agent.position += speed * heading. Likewise, you can do agent.get_nearest_neighbors(k=5) or agent.get_neigbors_in_radius(radius=3.1415).

Absolutely awesome, I fully support this design direction.

I will do an API / conceptual level review tomorrow. Let me know when you would like to have a code/implementation level review.

@quaquel
Copy link
Member Author

quaquel commented Dec 30, 2024

  1. I need to write all the tests, once those are done, it is ready for a code review. API feedback at this point, however, is very much welcome.
  2. One thing I am struggling with is how to get a clean separation between the space and the agent. In cell spaces, we could do this via the cell class, but there is no real equivalent here. So, there is a rather tight coupling between a ContinuousSpaceAgent and the ContinousSpace.
  3. I want to expand the Agent classes so we have a variety of agents with different degrees of movement. For this, I'll look at ABM language #1354 and Agent spatial methods from GaelLucero #2149, and try to develop a logical progression of agent classes with increasing support for movement. Again, further ideas on this are welcome.
  4. I want to redo the boid example and see what the performance difference is.

@EwoutH
Copy link
Member

EwoutH commented Dec 31, 2024

Okay, first of all, this is excellent work. While I could probably fledge our the API, your implementations are simply superior because you have considered every angle and detail. Especially the array stuff, I'm impressed.

Let me review the API, comparing how to do stuff in the old and new continuous spaces.

  1. Creating a Space and Agents:
# Current Implementation
space = ContinuousSpace(
    x_max=1.0, 
    y_max=1.0,
    torus=True,
    x_min=0.0,
    y_min=0.0
)

class MyAgent(Agent):
    def __init__(self, unique_id, model):
        super().__init__(unique_id, model)

agent = MyAgent(1, model)
# New Implementation 
space = ContinuousSpace(
    dimensions=[[0, 1], [0, 1]],  # More flexible, supports n-dimensions
    torus=True,
    random=model.random
)

class MyAgent(ContinuousSpaceAgent):
    def __init__(self, space, model):
        super().__init__(space, model)

agent = MyAgent(space, model)

The new dimensions parameter is way more elegant and extensible, and I like the syntax. I'm not sure having to pass the space to the agent constructor is ideal, it might be able to be derived from the model. Also, an Agent might want to be part of multiple spaces (albeit on the same coordinate/position).

Maybe the model can track which spaces there are. If there's one that could be the default, I will loop back to this later.

Also for random, can we use model.random by default?

  1. Placing/Moving Agents:
# Current Implementation
space.place_agent(agent, pos=(0.5, 0.5))
space.move_agent(agent, pos=(0.6, 0.6))
current_pos = agent.pos  # Returns tuple (x,y)
# New Implementation
agent.position = [0.5, 0.5]  # Direct assignment
agent.position += [0.1, 0.1]  # Vector arithmetic
current_pos = agent.position  # Returns numpy array

The new vector-based approach is much more intuitive and powerful, especially for physics-based simulations. The ability to use numpy array operations directly is a big win.

I like the API and syntax, good stuff. The square brackets make it also feel more like an position/coordinate (probably very personal).

  1. Getting Neighbors:
# Current Implementation
neighbors = space.get_neighbors(
    pos=(0.5, 0.5),
    radius=0.2,
    include_center=True
)

# No built-in nearest neighbors functionality
# New Implementation
# From agent's perspective
neighbors = agent.get_neigbors_in_radius(radius=0.2)
nearest = agent.get_nearest_neighbors(k=5)

# From space's perspective
distances = space.calculate_distances([0.5, 0.5])

Great to have this build in. The agent-centric approach is really elegant here. I would go with a single get_neighbors method however. It can have both a radius and at_most argument, just like we have in the AgentSet.select(). We can extend it in the future with filtering by type and Agent property. Or if it returns an AgentSet we can apply an select() over it, or let the user do that. Many possibilities here!

This also might be a place where we want to input the space or list of spaces (optionally). That way an Agent can search in a particular space. If there's only one space, we can default to that (and/or to the first space added to the model).

  1. Accessing All Agents/Positions:
# Current Implementation
# Need to rebuild cache first
space._build_agent_cache()
all_positions = space._agent_points  # numpy array
agents = list(space._agent_to_index.keys())
# New Implementation
all_positions = space.agent_positions  # Direct access to numpy array
agents = space.agents  # Returns AgentSet

The new implementation is much cleaner and more efficient. No need to manually rebuild cache, and proper encapsulation of internal details. The AgentSet integration is a good choice. The direct access to positions through a property is more intuitive.

Great stuff.

  1. Removing Agents:
# Current Implementation
space.remove_agent(agent)
agent.pos = None  # Must manually clear position
# New Implementation
agent.remove()  # Handles everything automatically

The new implementation is clearly superior. This will save us a lot of bug reports in the long term.

  1. Checking Bounds/Torus Adjustments:
# Current Implementation
is_valid = not space.out_of_bounds(pos=(0.5, 1.2))
adjusted_pos = space.torus_adj((1.2, 1.3))
# New Implementation
is_valid = space.in_bounds([0.5, 1.2])
adjusted_pos = space.torus_correct([1.2, 1.3])

I haven't used this feature often, but the new method names feel more intuitive (in_bounds vs not out_of_bounds). The torus_correct name is also clearer than torus_adj, but I would also consider torus_valid.

Really amazing!

@quaquel
Copy link
Member Author

quaquel commented Dec 31, 2024

Thanks!

Some quick reactions

I'm not sure having to pass the space to the agent constructor is ideal

This is indeed one of the places were I struggled to cleanly seperate everything. However, I don't see any other way of doing it. I prefer explicit over implicit. Thus, I want to avoid having to make guesses about the attribute name of the space. I am skeptical about agents being in multiple spaces at the same time. Regardless, if a user want this, this is still possible by subclassing Agent.

Also for random, can we use model.random by default?

Again, I prefer explicit over implicit. Moreover, this is identical to how it is handled in discrete spaces so it keeps the API consistent. Note that neither DiscreteSpace nor ContinuousSpace have model as an argument for their initialization, so we cannot default to model.random.

I would go with a single get_neighbors method however.

I am open to this suggestion, but not convinced yet. In most libraries I looked at last week, they cleanly seperate both cases because implementation wise they are quite different. For example, ball tree in sklearn has query and query_radius. It also would again involve having to make guesses. For example, you do a neighborhood search for a given radius and you want 5 agents at most. What does this mean? How do you want to select if there are more agents within the radius? Should this be random, should this based on nearnes? With these two methods explicitly seperate, users are free to write their own custom follow up methods.

One option might be to add this as a third method?

but I would also consider torus_valid.

The method changes the values that you pass, while this name suggests a boolean as a return.

@EwoutH
Copy link
Member

EwoutH commented Dec 31, 2024

I am skeptical about agents being in multiple spaces at the same time.

Practical use case: I have some layer of fixed agents (trees, intersections, houses) in a cell space, but I want other agents (birds, cars, people) to move between those in continuous space. Then I would like to have a Cell Space and a ContiniousSpace in the same model.

For example, you do a neighborhood search for a given radius and you want 5 agents at most. What does this mean? How do you want to select if there are more agents within the radius? Should this be random, should this based on nearnes?

I would say random by default, with an argument to switch it to nearest. "Get 5 random agents with 100 meters of me" sounds like a common use case for me. The beautiful thing about a good single-method implementation is that you are very flexible with combinations of keyword arguments (again like we do with select).

One option might be to add this as a third method?

And then when we think of a think of another selection criterea the number of functions could double again, of they all need an additional keyword argument. While I see your side of the argument, personally, I really like having a clean function name (select, get_neighbours) with clear arguments in a logical order and with sensible defaults.

In most libraries I looked at last week, they cleanly seperate both cases because implementation wise they are quite different.

Internally it can be different code paths.

@quaquel
Copy link
Member Author

quaquel commented Dec 31, 2024

Regarding a single neighbors methods, I tried to work it out, but the code just gets confusing and messy very quickly. There are various possible code paths (at least 4 with just radius and k, one of which should raise an exception). I personally think that keeping them separate, at least for now, keeps things simpler. Note that the existing continuous space only has the radius version and not the k nearest neighbors.

However, while playing with this, I thought it might be useful to return not just the agents but also the distances. This makes it possible to do follow-up operations yourself easily:

some_list = agent.get_neigbors_in_radius(radius=3.1415)

# sort by distance assuming some_list is [(agent, distance), (agent, distance)]
some_list.sort(key=lambda element:element[1])

# get k nearest agents within radius
k_agents = [entry[0] for entry in some_list[0:k]]

@quaquel
Copy link
Member Author

quaquel commented Dec 31, 2024

Practical use case: I have some layer of fixed agents (trees, intersections, houses) in a cell space, but I want other agents (birds, cars, people) to move between those in continuous space. Then I would like to have a Cell Space and a ContiniousSpace in the same model.

I am sorry but I don't see the use case. You can just as easily have all agents with a permanent location inside the same continuous space. There is no need to use a cell space for this. Moreover, it also raises again the spectre of coordinate systems and their alignment across multiple spaces.

@EwoutH
Copy link
Member

EwoutH commented Jan 1, 2025

While this is probably a move in the right direction anyway, and could be worth exploring on its own, it might be worth fully fledging our conceptual view on spaces first. Just to prevent doing double work.

@jackiekazil
Copy link
Member

jackiekazil commented Jan 7, 2025

Discussed during dev meeting -- Related work after to address after this complete #2149, #1354, #1278

@EwoutH
Copy link
Member

EwoutH commented Jan 7, 2025

@projectmesa/maintainers to review:

  1. API (see Reimplementation of Continuous Space #2584 (comment))
  2. Code implementation
  3. Example implementation

Future work: #1278, #1354 and #2149.

Copy link
Member

@EwoutH EwoutH left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few initial comments and questions on the examples. Main code will be next.

@@ -6,10 +6,10 @@

import numpy as np

from mesa import Agent
from mesa.experimental.continuous_space import ContinuousSpaceAgent
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's you long term vision on the different Agent types in Mesa? Will each space need their own Agent class?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a viable alternative at the moment other than having a set of different agent subclasses designed to work with different spaces.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I am understanding correctly, related #2585 (which is an awesome discussion BTW) then if a user has say someone in continouspace and part of network, then their agent would use inheritance to get those particular functions?

mesa/examples/basic/boid_flockers/model.py Show resolved Hide resolved
mesa/examples/basic/boid_flockers/model.py Outdated Show resolved Hide resolved
vision,
separation,
space,
position=(0, 0),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't this supposed to be a sequence/array with (like [0, 0])?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

position in newstyle is np.typing.ArrayLike, so anything castable to np.array works.

The cleaner solution is to make position a positional argument rather than a keyword argument because this avoids the need for a default value. The nice thing of a kwarg, however, is that create_agents becomes a bit more readable.

mesa/examples/basic/boid_flockers/model.py Outdated Show resolved Hide resolved
Copy link
Member

@EwoutH EwoutH left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few initial comments/questions on the spaces code.

Comment on lines 72 to 85
self._agent_positions: np.array = np.empty(
(n_agents, self.dimensions.shape[0]), dtype=float
)
# self._agents: np.array = np.zeros((n_agents,), dtype=object)

self.active_agents = []
self.agent_positions: (
np.array
) # a view on _agent_positions containing all active positions

self._n_agents = 0

self._index_to_agent: dict[int, Agent] = {}
self._agent_to_index: dict[Agent, int | None] = {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like 2 variables and 4 private variables to track agents and their positions. Could you add some docstring about this, for future reference?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure docstring is the right place for that. I can add some more comments, however.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we needs some dev docs or something. PR description could also work (for now).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some comments, let me know if this is sufficient.

mesa/experimental/continuous_space/continuous_space.py Outdated Show resolved Hide resolved
quaquel and others added 5 commits January 8, 2025 18:45
Copy link
Member

@tpike3 tpike3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@quaquel Awesome as always! I always enjoy learning from your code. I had a few questions, but fantastic!

@@ -6,10 +6,10 @@

import numpy as np

from mesa import Agent
from mesa.experimental.continuous_space import ContinuousSpaceAgent
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I am understanding correctly, related #2585 (which is an awesome discussion BTW) then if a user has say someone in continouspace and part of network, then their agent would use inheritance to get those particular functions?

@@ -58,47 +61,31 @@ def __init__(

def step(self):
"""Get the Boid's neighbors, compute the new vector, and move accordingly."""
neighbors = self.model.space.get_neighbors(self.pos, self.vision, True)
neighbors, distances = self.get_neighbors_in_radius(radius=self.vision)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very clean!


import numpy as np
from numpy.typing import ArrayLike
from scipy.spatial.distance import cdist
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am conflicted about adding a core dependency for one call, although it is scipy. Is there thoughts or plan to grow this to add more types of calculations (e.g. non Euclidian) or use scpiy in other spots?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its part of anaconda, the code is roughly 4 times faster than a numpy only version, and you can use other distances with it, so I think it is worth it, but it can be changed if others see this differently.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it's not ideal, but scipy is a common one, We could include it in the recommended (but not core) dependencies, like we do with pandas.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bias is always for faster so I say leave it in as core or add to recommended (scipy is a part of examples). (I am also tracking numpy, pandas, and tqdm as core

I also proposed adding **kwargs to calculate_distances below so user can exploit some of the other cdist capability


return delta

def calculate_distances(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For for these three functions 1- calculate_distances, 2- get_agents_in_radius, 3-get_k_nearest_neighbors it seems the return structure changes - 1- array, list, 2- (list, array), 3-list, array

This seems to violate the principle of least surprise --- is there a reason for this I am just not understanding?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is fair point; I have been wondering about that as well.

The main difference is that calculate_distances focuses on distances, so I have this as the first return. The other 4 (ContinousSpace.get_agents_in_radius, ContinousSpace.get_k_nearest_agents, and their ContinuousSpaceAgent versions ) all are about getting agents, so I return those first.

Copy link
Member

@tpike3 tpike3 Jan 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bias would be to keep them all array, list this may be because I have been burned with GIS libraries switching long, lat to Lat, long too many times. However, I will leave it up to you

To optimize the use of the scipy library would you want to add **kwargs so users could change some of the key word arguments like metric?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the kwargs idea, but this only works if torus is not true.

@quaquel
Copy link
Member Author

quaquel commented Jan 9, 2025

If I am understanding correctly, related #2585 (which is an awesome discussion BTW) then if a user has say someone in continouspace and part of network, then their agent would use inheritance to get those particular functions?

You can probably just do that with multiple inheritance at the moment, we might want to flesh that out further in the near future.

Copy link
Member

@EwoutH EwoutH left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's get this in, and start building with it.

Thanks for driving this home Jan!

@EwoutH EwoutH added feature Release notes label experimental Release notes label labels Jan 9, 2025
@quaquel quaquel merged commit 29d0f3b into projectmesa:main Jan 10, 2025
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
experimental Release notes label feature Release notes label
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants