mesa icon indicating copy to clipboard operation
mesa copied to clipboard

Find single agent in agentset

Open Corvince opened this issue 1 year ago • 11 comments

What's the problem this feature will solve? As @rht pointed out in https://github.com/projectmesa/mesa/pull/2314#discussion_r1769636029 There is currently no easy way to retrieve a single agent by it's I'd.

Describe the solution you'd like I see two ways to solve this

  1. Add an "agents" property (in sense of @property) that is a dict so users can do agentset.agents[unique_id]
  2. Add a "find" function to agentset that works like "select", but returns a single agent or None.

Of course it would also be possible to add both.

Corvince avatar Sep 22 '24 07:09 Corvince

Thanks for the write-up. Slightly related, I have a sample feature on my TODO list, which is a fast combination of shuffle and select. Sampling one could be one of the possibilities here.

  1. Add an "agents" property (in sense of @property) that is a dict so users can do agentset.agents[unique_id]

Directly accessing by unique_id or a range of unique_ids would be very interesting! Maybe even allow a range for slicing?

One consideration is if we want to access by unique_id, or in the order of the agents in the AgentSet (after a sort or a shuffle).

Maybe the AgentSet should have a boolean attribute called in_order, which states if the AgentSet is in order of unique_id or not.

But I think we have to make a choice here if we want to allow accessing by unique_id (dict like) or allow accessing/slicing by position (list like).

  1. Add a "find" function to agentset that works like "select", but returns a single agent or None.

For reference, this feature would be roughly equivalent to NetLogo's one-of. They actually have a lot of these kind of functions: max-n-of max-one-of member? min-n-of min-one-of n-of neighbors neighbors4 one-of up-to-n-of who-are-not with with-max with-min.

EwoutH avatar Sep 22 '24 09:09 EwoutH

Just some questions, trying to understand better what this will solve:

  1. What is the use case for this feature? I agree the feature does not exist directly (but is trivial via .select). But I need help understanding when you would want to use it. When would you know the unique_id but not have the agent itself?

2

Directly accessing by unique_id or a range of unique_ids would be very interesting! Maybe even allow a range for slicing?

A few thoughts. First, tyou can already slice an agentset because it implements Sequence. So you can do my_agentset[0:10], which will give you the first ten entries in the set. Second, slicing based on unique_id is not well defined. You have no idea what IDs are in the set, nor can you slice on them in a meaningful way unless the envisioned property is constantly sorting based on unique_id. But even then, there might be missing IDs/ IDs that might not be consecutive. Third, there already is a .sort method available if, for whatever use case, you want to sort based on unique_id. So again, why would we need this?

  1. Seeing the range of netlogo functions, that is bad design. How can a user ever hope to remember so many different functions?

quaquel avatar Sep 22 '24 10:09 quaquel

From https://github.com/projectmesa/mesa/pull/2317#discussion_r1770559352

If __getitem__ becomes def __getitem__(self, unique_id): return self._agents[unique_id] as the default, it would simplify the documentation here.

I also just note that I object fundamentally to this. It brakes the Sequence protocol.

  1. I think the agent ordering in an AgentSet is a less useful construct. In any practical models, most of the time, they would be shuffled anyway when a model step happens.
  2. model.agents[10:15] would be changing all the time, making it impossible to keep track of specific agents for data collection and debugging. If you want to sample 5 random agents every single time, you can do model.agents.select(at_most=5)
  3. the underlying data structure of model._agents is that of a dict, which has to be converted to list for every __getitem__. This is an expensive way to implement Sequence.

Seeing the range of netlogo functions, that is bad design. How can a user ever hope to remember so many different functions?

Digression: Having lots of functions (incidentally, the - separator in the naming) seem to be derived from LISP/Scheme. The list of functions in Scheme R7RS standard libraries may be small, but for a specific implementation, the list of functions is huge. There are a dozen functions just for amap:

amap->alist. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
amap-args . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 211
amap-clean!. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 211
amap-clear!. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 211
amap-comparator . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 210
amap-contains? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 210
amap-copy . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
amap-count . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
amap-delete! . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 211
amap-difference! . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
amap-empty-copy . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
amap-empty?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 210
amap-entries . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 212
amap-find . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
amap-fold . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 212
amap-for-each . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 212

rht avatar Sep 22 '24 18:09 rht

Yes, using __getitem__ is slow, and I don't see it commonly used. When building AgentSet it just got added in because it was easy to add, and the internal dict is ordered already. But, likewise, I still fail to see a use case for doing agent lookups based on unique_id that are not currently already covered by select.

quaquel avatar Sep 22 '24 18:09 quaquel

But, likewise, I still fail to see a use case for doing agent lookups based on unique_id that are not currently already covered by select.

It's covered by select, but it is very lengthy for the user to do so (model.agents.select(lambda agent: agent.unique_id == 1)[0]), instead of model.agents.find(1) / model.agents.agents_dict[1] / model.agents[1].

rht avatar Sep 22 '24 18:09 rht

It's covered by select, but it is very lengthy for the user to do so (model.agents.select(lambda agent: agent.unique_id == 1)[0]), instead of model.agents.find(1) / model.agents.agents_dict[1] / model.agents[1].

Fair enough, but again: when would a user want to use this? The only use case that has been claimed, but not developed in any clear detail is debugging.

quaquel avatar Sep 22 '24 19:09 quaquel

I do have a specific example: in the past model I wrote, one class of the agents consists of systemically important banks, the other class being other financial institutions. I had to be able to track and analyze the evolution of a particular named bank (e.g. Barclays plc) under an external shock (data collection).

(This is a separate point: this also means that each agents attributes was initialized from concrete data, and the removal of explicit unique ID in Mesa 3.0 makes it harder to use the ISIN of the banks as the unique ID.)

rht avatar Sep 22 '24 19:09 rht

Ok, but then it is about finding 1 specific agent given some selection criterion (which can be unique_id). Still, it might be anything that the user wants to have as its selection criterion. I can see an argument for having a dedicated find method for this. So something like the following:


def find(self, select_function: Callable) -> Agent:
    '''Find the first agent that meets the select function.

    Args:
        select_function: A callable that is called with an agent and returns a boolean

    '''
    for agent in self._agents:
        if select_function(agent):
            return agent
    return None  # or perhaps even raise an exception?

quaquel avatar Sep 22 '24 19:09 quaquel

1. What is the use case for this feature? I agree the feature does not exist directly (but is trivial via `.select`). But I need help understanding when you would want to use it. When would you know the unique_id but not have the agent itself?

Chiming in here...

My use case involves social networks. Each of my agents is also a node in a networkx graph, which stores the indices of nodes. You can call its .neighbors(id) method to get back the ids of the nodes adjacent to id. Once I get those, however, and want to do something to the agents they represent (e.g., pass on a message from the current agent to its neighbors) I don't actually have an easy way to simply grab the Agent objects corresponding to those neighbor ids.

In the past, I could just use list operations on model.schedule.agents[ ], but now that no longer exists.

  • Stephen

divilian avatar Feb 23 '25 20:02 divilian

Thanks for this.

In the past, I could just use list operations on model.schedule.agents[ ], but now that no longer exists.

This is technically only true if you don't dynamically remove agents from the model. The moment you have agents being removed and added during runtime, this would break, also in the past. Incidentally, you could still do that currently (but note that unique_id starts from 1, while indexing starts from 0).

I see a couple of options. The obvious one, at least to me, is to assign your agents as an attribute to each of the nodes in the network. In that way, you can fully rely on networkx features to do what you want. So G.nodes[agent.unique_id]["agent"] would give you the agent, while something like [G.nodex[n][agent] for n in G.neighbors(agent.unique_id)] would give you all neighboring agents.

quaquel avatar Feb 23 '25 20:02 quaquel

... assign your agents as an attribute to each of the nodes in the network. In that way, you can fully rely on networkx features to do what you want. So G.nodes[agent.unique_id]["agent"] would give you the agent, while something like [G.nodex[n][agent] for n in G.neighbors(agent.unique_id)] would give you all neighboring agents.

Hey, that's a nice idea, thanks!

divilian avatar Feb 24 '25 16:02 divilian