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

Open
wants to merge 49 commits into
base: main
Choose a base branch
from

Conversation

quaquel
Copy link
Member

@quaquel quaquel commented Dec 30, 2024

This is a WIP PR. Feedback is already welcome

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 = space.get_distances([0.5, 2])  

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

nearest_neighbor = agent.get_nearest_neigbors()
neighbors = 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 to small, it is expanded by adding 20% more rows to it. Most of this will be fine tuned further, and will become 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 numpy.linalg.norm in the torus case.

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.

@quaquel quaquel added the trigger-benchmarks Special label that triggers the benchmarking CI label Jan 2, 2025
@quaquel quaquel added trigger-benchmarks Special label that triggers the benchmarking CI and removed trigger-benchmarks Special label that triggers the benchmarking CI labels Jan 2, 2025
@quaquel quaquel removed the trigger-benchmarks Special label that triggers the benchmarking CI label Jan 3, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants