Skip to content

Add descriptors for CellPosition#2875

Closed
quaquel wants to merge 27 commits intomesa:mainfrom
quaquel:location_descriptors
Closed

Add descriptors for CellPosition#2875
quaquel wants to merge 27 commits intomesa:mainfrom
quaquel:location_descriptors

Conversation

@quaquel
Copy link
Copy Markdown
Member

@quaquel quaquel commented Nov 3, 2025

This PR adds 2 descriptors: CellPosition and FixedCellPosition. These allow users to add support to agents that are present in multiple spaces.

It implements some of the ideas discussed here

@quaquel quaquel requested a review from EwoutH November 3, 2025 18:25
@github-actions
Copy link
Copy Markdown

github-actions bot commented Nov 3, 2025

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔴 +12.1% [+11.0%, +13.0%] 🔴 +3.8% [+3.6%, +3.9%]
BoltzmannWealth large 🔴 +7.8% [+6.6%, +8.7%] 🔴 +11.7% [+9.6%, +13.8%]
Schelling small 🔴 +4.9% [+4.7%, +5.2%] 🔵 +2.6% [+2.4%, +2.8%]
Schelling large 🔴 +3.9% [+3.5%, +4.3%] 🔴 +4.2% [+3.2%, +5.3%]
WolfSheep small 🔴 +10.5% [+10.3%, +10.7%] 🔴 +5.0% [+4.9%, +5.2%]
WolfSheep large 🔴 +11.0% [+10.4%, +11.7%] 🔴 +9.3% [+8.8%, +9.8%]
BoidFlockers small 🔵 +0.1% [-0.2%, +0.4%] 🔵 -0.7% [-0.9%, -0.6%]
BoidFlockers large 🔵 +0.1% [-0.5%, +0.7%] 🔵 -0.5% [-0.8%, -0.3%]

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Nov 3, 2025

If I'm correct, by shifting from inheritance-based mixins to descriptors, this enables to define agents like this, right?

# Before (Inheritance)
class CellAgent(Agent, HasCell, BasicMovement):
    # HasCell provides cell property via inheritance
    pass
# After (Descriptors)
class CellAgent(Agent, BasicMovement):
    cell = HasCell()  # Descriptor manages cell attribute

Would this enable composition-based approaches (as discussed)?

# Future possibility: Multiple locations via descriptors
class HybridAgent(Agent):
    physical_cell = HasCell()
    logical_cell = HasCell()
    
    def step(self):
        # Agent can exist in multiple discrete spaces
        neighbors_physical = self.physical_cell.get_neighborhood()
        neighbors_logical = self.logical_cell.get_neighborhood()

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Nov 3, 2025

Yes, the code example you have given will work fine with the new descriptor.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Nov 5, 2025

I had a look at creating a HasPosition descriptor for the new experimental ContinuousSpace. The problem here is the following. We currently have the following properties:

    @property
    def position(self) -> np.ndarray:
        """Position of the agent."""
        return self.space.agent_positions[self.space._agent_to_index[self]]

    def position(self, value: np.ndarray) -> None:
        if not self.space.in_bounds(value):
            if self.space.torus:
                value = self.space.torus_correct(value)
            else:
                raise ValueError(f"point {value} is outside the bounds of the space")

        self.space.agent_positions[self.space._agent_to_index[self]] = value

Note how these properties rely on self.space. Because of this reliance of knowing the space, we cannot straightforwardly create a HasLocation descriptor. The following is a straightforward translation of the properties into a descriptor

class HasPosition:

    def __get__(self.obj: Agent, type=None)
        """Position of the agent."""
        return obj.space.agent_positions[obj.space._agent_to_index[self]]

    def __set__(self, obj: Agent, value: np.ndarray) 
        if not obj.space.in_bounds(value):
            if obj.space.torus:
                value = obj.space.torus_correct(value)
            else:
                raise ValueError(f"point {value} is outside the bounds of the space")

        obj.space.agent_positions[self.space._agent_to_index[self]] = value
 

Note how we are using obj.space repeatedly. This means that we have now hardcoded the name of the attribute to which the space should be assigned inside the agent. This also means that this descriptor is not compatible with multiple spaces. So can we do better? One solution is to do

class HasPosition:
    def __init__(self, space_attribute_name:stre):
        self.space_attribute_name = space_attribute_name

    def __get__(self.obj: Agent, type=None)
        """Position of the agent."""
        space = getattr(obj, self.space_attribute_name)
        return space.agent_positions[space._agent_to_index[self]]

    def __set__(self, obj: Agent, value: np.ndarray) 
    	space = getattr(obj, self.space_attribute_name)
        if not space.in_bounds(value):
            if space.torus:
                value = space.torus_correct(value)
            else:
                raise ValueError(f"point {value} is outside the bounds of the space")

        space.agent_positions[self.space._agent_to_index[self]] = value
  
# we use this accordingly

def MyAgent(Agent)
    my_location = HasPosition("my_space")

    def __init__(self, model):
        super().__init__(model)
        self.my_space = model.space

We now pass the name of the space attribute as a string to HasPosition, and we must ensure that we assign the correct space to this attribute. This approach will work with multiple spaces:

def MyAgent(Agent)
    my_location = HasPosition("my_space")
    my_other_location = HasPosition("my_other_space")

    def __init__(self, model):
        super().__init__(model)
        self.my_space = model.space_1
        self.my_other_space = model.space_2

The drawback of this is the fact that it's up to the user to ensure that the string and the attribute name in the init match. Another drawback is that it's not easy to do neighborhood stuff in this way. All of this will have to be defined at the agent level. So an alternative solution is to introduce a new Location class, which would be used like this

def MyAgent(Agent)
    my_location = HasPosition()

    def __init__(self, mode, position:np.ndarrayl):
        super().__init__(model)
        self.my_location = Location(position, space=model.space)

I haven't fully fleshed out the details of this new Location class, but in my view, the following things should all work

self.location  += [0.1, 0.1]
self.location  *=  2
self.location  = self.location + time_delta * velocity
self.location.get_neighbors_in_radius(5)
self.location.get_nearest_neighbors()

So my 2 questions:

  1. What do people think of the use of a string to specify the space attribute?
  2. What do people think about a new Location class

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Nov 7, 2025

I think we're converging towards the same ideas. I don't like the string to specify the space attribute, but I think it's necessary evil. You have to link them somehow.

It would be ideal though, if one location could be linked to multiple spaces.

A magic hack we could (I'm not saying should!) do, is defaulting to HasPosition(space="space"). This way using .space in the model magically works, while it's still flexible to modify. However it does violate explicit over implicit.

I don't understand the the Location class fully, but if it can work as in the API usage snippet you shared, that would be great.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Nov 9, 2025

  1. I think there are two separate issues here. The first issue is to make it possible to have a model with multiple spaces and agents having separate locations in each of these spaces. The second is having a single location that is somehow valid in multiple "spaces". This draft PR primarily focuses on addressing the first issue, rather than the second.
  2. Yes, it will be trivial to implement a Location in line with the sketched API. The advantage is that in that case, we don't need the string.
  3. Having a location that is somehow valid in multiple "spaces" is, at face value, similar to what we have already done with property layers for grids. The question is whether we can generalize the idea of property layers and create "derived spaces". That is, we have a primary space in which the location is defined, and we have other derived spaces that use the location from the main space but might have their own custom distance metric that determines neighborhood relations between locations.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Nov 9, 2025

3. The question is whether we can generalize the idea of property layers and create "derived spaces".

Interesting idea of having a "primary" space and derived ones. The primary automatically feels like it should be a space with resolution equal or higher than all other spaces. The continuous space has infinite resolution, so it seems logical that a grid-based space derives its location from there. You move in continous space, but can check in which cells of discrete spaces you are. Cell centroids and network nodes and edges could also help "guide" you towards/on a specific location.

@wang-boyu have you been following the recent spaces discussions?

@tpike3
Copy link
Copy Markdown
Member

tpike3 commented Nov 16, 2025

@quaquel I think this idea is great! It would be awesome to be able to see an agent form multiple perspectives at any given moment.

My preference would be for the Location class instead of the string. I think it will provide greater flexibility and extensibility in the end.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Dec 18, 2025

Thanks for the feedback on the Location idea. I'd like to move this PR forward and keep it atomic with just the change for discrete spaces. I have stared work on the Location class but its a bit trickier that I would have liked so I'll leave that for a future PR. This is now ready for review.

@quaquel quaquel marked this pull request as ready for review December 18, 2025 09:49
@quaquel quaquel requested a review from tpike3 December 18, 2025 09:49
@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔴 +7.1% [+6.1%, +8.0%] 🔴 +7.2% [+7.0%, +7.4%]
BoltzmannWealth large 🔴 +9.3% [+8.1%, +10.7%] 🔴 +8.3% [+4.6%, +12.1%]
Schelling small 🔵 +3.9% [+3.0%, +5.0%] 🔵 +2.8% [+2.1%, +3.7%]
Schelling large 🔵 +0.5% [-2.2%, +2.2%] 🔵 +0.8% [-2.1%, +3.4%]
WolfSheep small 🔴 +5.7% [+3.6%, +7.6%] 🔵 +3.2% [+2.9%, +3.5%]
WolfSheep large 🔴 +11.9% [+9.7%, +16.0%] 🔴 +4.2% [+3.1%, +5.4%]
BoidFlockers small 🔵 +0.7% [+0.2%, +1.2%] 🔵 +1.2% [+1.0%, +1.5%]
BoidFlockers large 🔵 +0.2% [-0.4%, +0.9%] 🔵 +0.9% [+0.7%, +1.1%]

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Dec 18, 2025

I will try to review tomorrow.

@EwoutH EwoutH added trigger-benchmarks Special label that triggers the benchmarking CI and removed trigger-benchmarks Special label that triggers the benchmarking CI labels Dec 23, 2025
@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Dec 23, 2025

That code block can indeed be removed (I had already done so locally, but forgot to push the change).

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Dec 23, 2025

It would be really nice to have this feature. The only thing I'm concerned about is the performance.

If I'm correct:

# Mixin approach
agent.cell  # Direct attribute access

# Descriptor approach  
agent.cell  # Calls __get__ → getattr(obj, "_cell") → exception handling

BoltzmannWealth shows ~+8% runtime increase. Could be worse, but not ideal, especially since not all models will benefit from this (only if using multiple spaces).

@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔴 +8.0% [+7.5%, +8.6%] 🔴 +8.5% [+7.6%, +9.4%]
BoltzmannWealth large 🔴 +8.9% [+7.5%, +11.3%] 🔴 +4.9% [+4.5%, +5.1%]
Schelling small 🔴 +5.5% [+4.4%, +6.5%] 🔵 +3.5% [+3.0%, +3.9%]
Schelling large 🔴 +4.9% [+3.8%, +6.6%] 🔵 +1.9% [+1.6%, +2.2%]
WolfSheep small 🔴 +10.9% [+10.1%, +11.6%] 🔵 +3.9% [+3.0%, +5.0%]
WolfSheep large 🔴 +9.6% [+5.6%, +11.8%] 🔴 +5.8% [+3.8%, +8.1%]
BoidFlockers small 🔵 +1.4% [+1.1%, +1.7%] 🔵 -0.2% [-0.4%, +0.0%]
BoidFlockers large 🔵 +1.1% [+0.7%, +1.5%] 🔵 -0.6% [-0.8%, -0.5%]

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Dec 23, 2025

The current implementation uses a property, so its not entirely direct attribute access, but yes using a descriptor adds overhead. So the question here is whether being able to have an agent in multiple discrete spaces out of the box is worth the performance overhead? There is another option: keep the old HasCell and simply add a HasCellDescriptor. This maintains the performance boost for 80% of use cases (single space) while allowing multiple spaces to be specified via the descriptor.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Dec 24, 2025

The current HasCell isn’t used by users directly, right? Can we make this fast option private and use it internally, and expose the new slower but more flexible option to users that want to use multiple spaces?

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Dec 24, 2025

The current HasCell isn’t used by users directly, right? Can we make this fast option private and use it internally, and expose the new slower but more flexible option to users that want to use multiple spaces?

At present, on main, HasCell is already not included in __all__ for mesa.discrete_space, so it is not part of the public API. So, yes, we could just add a new HasCellDescriptor while keeping HasCell and use it only internally.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Dec 24, 2025

Then personally I think this is the way to go, even if it means some code duplication.

add a new HasCellDescriptor

For naming, would something like CellLocation make sense? And then in the future we could add ContinuousLocation and NetworkLocation. Or maybe -Position.

@quaquel quaquel changed the title Turn HasCell and FixedCell into descriptors Add descriptors for CellPosition Dec 24, 2025
@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Dec 24, 2025

@EwoutH , I updated the PR by adding 2 descriptors: FixedCellPosition and CellPosition. These descriptors serve the same purpose as the mixins: HasCell and FixedCell, which are used internally for performance reasons.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Dec 24, 2025

Awesome! I think this is a clear API.

Do we want to make _HasCell and _FixedCell private?

Further, since we're in the stable space, some tests might be nice. Especially one with the intended purpose of having multiple position variables in an Agent, and maybe even being present in multiple spaces.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Dec 24, 2025

Do we want to make _HasCell and _FixedCell private?

The use of underscores is for private methods and attributes (at least to the best of my knowledge). By not including HasCell and FixedCell in mesa.discrete_space.__init__.__all__, we are already explicitly making them not part of the public API, thus achieving the same effect.

Further, since we're in the stable space, some tests might be nice. Especially one with the intended purpose of having multiple position variables in an Agent, and maybe even being present in multiple spaces.

I agree, will try to fix that later today.

@quaquel quaquel closed this Feb 16, 2026
@quaquel quaquel deleted the location_descriptors branch February 16, 2026 15:11
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.

3 participants