Repository: projectmesa/mesa
Pull request #2351 (2024-10-13 14:55:36)

Add `create_agent` factory method to Agent
This is a first attempt at addressing #2221. This version allows for the creation of N agents using either a given argument/keyword argument for all agents, or by specifying the argument/keyword argument for all agents, or combinations of these (e.g., a default argument for all agents, and a list of values (so one for each agent) for another argument). 

The current code is WIP and needs testing. There are some obvious issues with it that I am aware of (e.g., if an argument is a sequence but should be the same for all agents, the current code will fail). I deliberately did not include support for passing distributions etc. for arguments and keyword arguments because that would make the code even more complex. 

I have updated 2 benchmark models to showcase the API and test its peformance. 

@EwoutH is this roughly in line with your ideas as voiced in #2221? 

github-actions[bot]: Performance benchmarks:

| Model           | Size   | Init time [95% CI]         | Run time [95% CI]        |
|-----------------|--------|----------------------------|--------------------------|
| BoltzmannWealth | small  | 🔴 +15.9% [+15.4%, +16.4%] | 🔵 -0.2% [-0.3%, -0.1%]  |
| BoltzmannWealth | large  | 🔴 +16.7% [+16.2%, +17.1%] | 🔵 +0.1% [-0.0%, +0.3%]  |
| Schelling       | small  | 🔵 +0.5% [-0.1%, +1.0%]    | 🔵 -0.2% [-0.4%, +0.0%]  |
| Schelling       | large  | 🔵 +0.9% [-0.3%, +2.1%]    | 🔵 -0.1% [-0.6%, +0.5%]  |
| WolfSheep       | small  | 🔴 +3.8% [+3.5%, +4.0%]    | 🟢 -7.3% [-10.0%, -4.2%] |
| WolfSheep       | large  | 🔵 +3.3% [+2.9%, +3.7%]    | 🔵 +2.3% [+0.3%, +4.6%]  |
| BoidFlockers    | small  | 🔵 -0.2% [-0.6%, +0.1%]    | 🔵 +0.3% [-0.3%, +1.1%]  |
| BoidFlockers    | large  | 🔵 +0.0% [-0.8%, +1.1%]    | 🔵 +0.7% [+0.3%, +1.1%]  |

github-actions[bot]: Performance benchmarks:

| Model           | Size   | Init time [95% CI]         | Run time [95% CI]        |
|-----------------|--------|----------------------------|--------------------------|
| BoltzmannWealth | small  | 🔴 +11.6% [+10.6%, +12.6%] | 🟢 -3.9% [-4.0%, -3.6%]  |
| BoltzmannWealth | large  | 🔴 +15.7% [+15.3%, +16.3%] | 🔵 -0.3% [-0.8%, +0.1%]  |
| Schelling       | small  | 🔵 +0.9% [+0.3%, +1.4%]    | 🔵 +0.1% [-0.1%, +0.3%]  |
| Schelling       | large  | 🔵 +1.4% [+0.3%, +2.6%]    | 🔵 +1.3% [-0.6%, +3.4%]  |
| WolfSheep       | small  | 🔴 +4.1% [+3.8%, +4.5%]    | 🟢 -7.6% [-10.8%, -4.5%] |
| WolfSheep       | large  | 🔴 +5.7% [+4.1%, +8.1%]    | 🔵 +3.7% [+0.7%, +7.4%]  |
| BoidFlockers    | small  | 🔵 +1.2% [+0.7%, +1.6%]    | 🔵 -0.8% [-1.5%, -0.2%]  |
| BoidFlockers    | large  | 🔵 +2.4% [+1.2%, +3.7%]    | 🔵 +0.4% [-0.1%, +0.9%]  |

EwoutH: Thanks for taking a shot as this, I’m intrigued why it increases code complexity in the examples instead of decreases it. Probably because of the lists that need to be created before create is called.

Let me think a bit about this. Maybe some elegant AgentSet stuff can help here.

Talking about that, would we like to (optionally) return an AgentSet with the just created Agents? Then you can directly start chaining.
quaquel: > Thanks for taking a shot as this, I’m intrigued why it increases code complexity in the examples instead of decreases it. Probably because of the lists that need to be created before create is called.

Lines of code and code complexity are not the same thing. But yes, since we are using python.random rather than numpy.random, we need list expressions. This adds a bit of overhead because we iterate seperately over n_agents for all arguments. 

> Talking about that, would we like to (optionally) return an AgentSet with the just created Agents? Then you can directly start chaining.

I have been wondering about this as well. On the hand hand, it woudl be convenient to have it. On the other hand, it would add overhead and I can't immediately think of a use case were you want to operate on the agents immediately in `Model.__init__`

quaquel: I have updated the code a little bit. You can now pass either a sequence of length n (i.e., the number of agents to create) or any other object as argument/keyword argument. If a given argument is a sequence of length n the code assumes that each entry is for a different agent. Otherwise, the code assumes that the entire object needs to be passed to each Agent. 
quaquel: I updated the benchmarks to use `self.rgn`. This cleans up the number of lines of code a bit and is quite convenient. 

The current implementation needs further refinement. I check explicitly for each argument and keyword argument if it is a list and if its length equals the number of agents to be created. This is too restrictive. For example, a tuple or ndarray fails on this test. It also rules out generator objects (as in the boltzman position example where I have to wrap zip inside list). However, I also think this might become quite a convenient way to instantiate many agents. 

What do others think? I am mainly looking for feedback at the API level and input to develop this generator method one step further. 
quaquel: I have picked this up again, and it's ready for a review. 

It now supports `list`, `numpy array`, and `tuples`. Since the code relies on checking whether the length of each keyword argument is equal to `n`, it is not possible to support generators directly. You can check the benchmark models for examples on how to use it, but for convenience, I put it here as well:


```python

    Wolf.create_agents(
        self,
        self.initial_wolves,
        self.rng.random(self.initial_wolves) * 2 * wolf_gain_from_food,
        wolf_reproduce,
        wolf_gain_from_food,
        [
            self.grid.all_cells.select_random_cell()
            for _ in range(self.initial_wolves)
        ],  # this is why I want to add draw_cells or something like that to CellCollection in a seperate PR
    )

   # in the simple case of width == height, you could reduce this to a one-liner
    positions = list(
        zip(
            self.rng.integers(0, self.grid.width, n),
            self.rng.integers(0, self.grid.height, n),
        )
    )
    MoneyAgent.create_agents(self, self.num_agents, pos=positions)

```
EwoutH: Could you rebase this PR and resolve conflicts? The benchmark models got removed, for example.

Sorry for the trouble.
quaquel: done
quaquel: > What might be really useful if it returns a convenient AgentSet with the just created Agents.

I have been going back and forth on this. It's easy to add and might be useful in some cases, but I'm not sure it would be used a lot. Happy to add it in if you think it is useful.
EwoutH: Can would personally like it. I can also add it tomorrow.
quaquel: just added it

Patch:
diff --git a/mesa/agent.py b/mesa/agent.py
index 044b40ac..5cfc1131 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -85,6 +85,54 @@ class Agent:
     def advance(self) -> None:  # noqa: D102
         pass
 
+    @classmethod
+    def create_agents(cls, model: Model, n: int, *args, **kwargs) -> AgentSet[Agent]:
+        """Create N agents.
+
+        Args:
+            model: the model to which the agents belong
+            args: arguments to pass onto agent instances
+                  each arg is either a single object or a sequence of length n
+            n: the number of agents to create
+            kwargs: keyword arguments to pass onto agent instances
+                   each keyword arg is either a single object or a sequence of length n
+
+        Returns:
+            AgentSet containing the agents created.
+
+        """
+
+        class ListLike:
+            """Helper class to make default arguments act as if they are in a list of length N."""
+
+            def __init__(self, value):
+                self.value = value
+
+            def __getitem__(self, i):
+                return self.value
+
+        listlike_args = []
+        for arg in args:
+            if isinstance(arg, (list | np.ndarray | tuple)) and len(arg) == n:
+                listlike_args.append(arg)
+            else:
+                listlike_args.append(ListLike(arg))
+
+        listlike_kwargs = {}
+        for k, v in kwargs.items():
+            if isinstance(v, (list | np.ndarray | tuple)) and len(v) == n:
+                listlike_kwargs[k] = v
+            else:
+                listlike_kwargs[k] = ListLike(v)
+
+        agents = []
+        for i in range(n):
+            instance_args = [arg[i] for arg in listlike_args]
+            instance_kwargs = {k: v[i] for k, v in listlike_kwargs.items()}
+            agent = cls(model, *instance_args, **instance_kwargs)
+            agents.append(agent)
+        return AgentSet(agents, random=model.random)
+
     @property
     def random(self) -> Random:
         """Return a seeded stdlib rng."""


Test patch:
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 70183ca0..b1ca0ba0 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -170,6 +170,30 @@ def test_agent_rng():
     assert agent.rng is model.rng
 
 
+def test_agent_create():
+    """Test create agent factory method."""
+
+    class TestAgent(Agent):
+        def __init__(self, model, attr, def_attr, a=0, b=0):
+            super().__init__(model)
+            self.some_attribute = attr
+            self.some_default_value = def_attr
+            self.a = a
+            self.b = b
+
+    model = Model(seed=42)
+    n = 10
+    some_attribute = model.rng.random(n)
+    a = tuple([model.random.random() for _ in range(n)])
+    TestAgent.create_agents(model, n, some_attribute, 5, a=a, b=7)
+
+    for agent, value, a_i in zip(model.agents, some_attribute, a):
+        assert agent.some_attribute == value
+        assert agent.some_default_value == 5
+        assert agent.a == a_i
+        assert agent.b == 7
+
+
 def test_agent_add_remove_discard():
     """Test adding, removing and discarding agents from AgentSet."""
     model = Model()