-
Notifications
You must be signed in to change notification settings - Fork 923
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
base: main
Are you sure you want to change the base?
Conversation
Performance benchmarks:
|
for more information, see https://pre-commit.ci
Thanks for working on this!
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. |
|
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.
# 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
# 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).
# 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 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).
# 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.
# 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.
# 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 ( Really amazing! |
Thanks! Some quick reactions
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
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
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 One option might be to add this as a third method?
The method changes the values that you pass, while this name suggests a boolean as a return. |
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 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
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 (
Internally it can be different code paths. |
Regarding a single 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]] |
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. |
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
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. |
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
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 doagent.position += speed * heading
. Likewise, you can doagent.get_nearest_neighbors(k=5)
oragent.get_neigbors_in_radius(radius=3.1415)
.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 andnumpy.linalg.norm
in the torus case.