diff --git a/mesa/examples/basic/alliance_formation_model/Readme.md b/mesa/examples/basic/alliance_formation_model/Readme.md new file mode 100644 index 00000000000..4cd0edab7b7 --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/Readme.md @@ -0,0 +1,40 @@ +# Alliance Formation Model + +## Summary +This model demonstrates Mesa's ability to dynamically create new classes of agents that are composed of existing agents. These meta-agents inherits functions and attributes from their sub-agents and users can specify new functionality or attributes they want the meta agent to have. For example, if a user is doing a factory simulation with autonomous systems, each major component of that system can be a sub-agent of the overall robot agent. Or, if someone is doing a simulation of an organization, individuals can be part of different organizational units that are working for some purpose. + +To provide a simple demonstration of this capability is an alliance formation model. + +In this simulation n agents are created, who have two attributes (1) power and (2) preference. Each attribute is a number between 0 and 1 over a gaussian distribution. Agents then randomly select other agents and use the [bilateral shapley value](https://en.wikipedia.org/wiki/Shapley_value) to determine if they should form an alliance. If the expected utility support an alliances, the agent creates a meta-agent. Subsequent steps may add agents to the meta-agent, create new instances of similar hierarchy, or create a new hierarchy level where meta-agents form an alliance of meta-agents. In this visualization of this model a new meta-agent hierarchy will be a larger node and a new color. + +In its current configuration, agents being part of multiple meta-agents is not supported + +## Installation + +This model requires Mesa's recommended install and scipy +``` + $ pip install mesa[rec] scipy +``` + +## How to Run + +To run the model interactively, in this directory, run the following command + +``` + $ solara run app.py +``` + +## Files + +* ``model.py``: Contains creation of agents, the network and management of agent execution. +* ``agents.py``: Contains logic for forming alliances and creation of new agents +* ``app.py``: Contains the code for the interactive Solara visualization. + +## Further Reading + +The full tutorial describing how the model is built can be found at: +https://mesa.readthedocs.io/en/latest/tutorials/intro_tutorial.html + +An example of the bilateral shapley value in another model: +[Techno-Social Energy Infrastructure Siting: Sustainable Energy Modeling Programming (SEMPro)](https://www.jasss.org/16/3/6.html) + diff --git a/mesa/examples/basic/alliance_formation_model/__init__.py b/mesa/examples/basic/alliance_formation_model/__init__.py new file mode 100644 index 00000000000..49a80b627ee --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/__init__.py @@ -0,0 +1,10 @@ +import logging + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) + +# Example usage of logging +logger = logging.getLogger(__name__) +logger.info("Logging is configured and ready to use.") diff --git a/mesa/examples/basic/alliance_formation_model/agents.py b/mesa/examples/basic/alliance_formation_model/agents.py new file mode 100644 index 00000000000..38ff5ff92f8 --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/agents.py @@ -0,0 +1,71 @@ +import mesa +from mesa.experimental.meta_agents import create_meta_agent + + +def calculate_shapley_value(calling_agent, other_agent): + """ + Calculate the Shapley value of the two agents + """ + new_position = 1 - abs(calling_agent.position - other_agent.position) + potential_utility = (calling_agent.power + other_agent.power) * 1.1 * new_position + value_me = 0.5 * calling_agent.power + 0.5 * (potential_utility - other_agent.power) + value_other = 0.5 * other_agent.power + 0.5 * ( + potential_utility - calling_agent.power + ) + + # Determine if there is value in the alliance + if value_me > calling_agent.power and value_other > other_agent.power: + if other_agent.hierarchy > calling_agent.hierarchy: + hierarchy = other_agent.hierarchy + elif other_agent.hierarchy == calling_agent.hierarchy: + hierarchy = calling_agent.hierarchy + 1 + else: + hierarchy = calling_agent.hierarchy + + return (potential_utility, new_position, hierarchy) + else: + return None + + +class AllianceAgent(mesa.Agent): + """ + Agent has three attributes power (float), position (float) and hierarchy (int) + + """ + + def __init__(self, model, power, position, hierarchy=0): + super().__init__(model) + self.power = power + self.position = position + self.hierarchy = hierarchy + + def form_alliance(self): + # Randomly select another agent of the same type + other_agents = [ + agent for agent in self.model.agents_by_type[type(self)] if agent != self + ] + + # Determine if there is a beneficial alliance + if other_agents: + other_agent = self.random.choice(other_agents) + shapley_value = calculate_shapley_value(self, other_agent) + if shapley_value: + class_name = f"MetaAgentHierarchy{shapley_value[2]}" + meta = create_meta_agent( + self.model, + class_name, + {other_agent, self}, + meta_attributes={ + "hierarchy": shapley_value[2], + "power": shapley_value[0], + "position": shapley_value[1], + }, + ) + + # Update the network if a new meta agent instance created + if meta: + self.model.network.add_node( + meta.unique_id, + size=(meta.hierarchy + 1) * 300, + hierarchy=meta.hierarchy, + ) diff --git a/mesa/examples/basic/alliance_formation_model/app.py b/mesa/examples/basic/alliance_formation_model/app.py new file mode 100644 index 00000000000..cfdadcf2da9 --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/app.py @@ -0,0 +1,74 @@ +import matplotlib.pyplot as plt +import networkx as nx +import solara +from matplotlib.figure import Figure +from model import AllianceModel + +from mesa.mesa_logging import DEBUG, log_to_stderr +from mesa.visualization import SolaraViz +from mesa.visualization.utils import update_counter + +log_to_stderr(DEBUG) + +model_params = { + "seed": { + "type": "InputText", + "value": 42, + "label": "Random Seed", + }, + "n": { + "type": "SliderInt", + "value": 50, + "label": "Number of agents:", + "min": 10, + "max": 100, + "step": 1, + }, +} + +# Create visualization elements. The visualization elements are solara components +# that receive the model instance as a "prop" and display it in a certain way. +# Under the hood these are just classes that receive the model instance. +# You can also author your own visualization elements, which can also be functions +# that receive the model instance and return a valid solara component. + + +@solara.component +def plot_network(model): + update_counter.get() + g = model.network + pos = nx.kamada_kawai_layout(g) + fig = Figure() + ax = fig.subplots() + labels = {agent.unique_id: agent.unique_id for agent in model.agents} + node_sizes = [g.nodes[node]["size"] for node in g.nodes] + node_colors = [g.nodes[node]["size"] for node in g.nodes()] + + nx.draw( + g, + pos, + node_size=node_sizes, + node_color=node_colors, + cmap=plt.cm.coolwarm, + labels=labels, + ax=ax, + ) + + solara.FigureMatplotlib(fig) + + +# Create initial model instance +model = AllianceModel(50) + +# Create the SolaraViz page. This will automatically create a server and display the +# visualization elements in a web browser. +# Display it using the following command in the example directory: +# solara run app.py +# It will automatically update and display any changes made to this file +page = SolaraViz( + model, + components=[plot_network], + model_params=model_params, + name="Alliance Formation Model", +) +page # noqa diff --git a/mesa/examples/basic/alliance_formation_model/model.py b/mesa/examples/basic/alliance_formation_model/model.py new file mode 100644 index 00000000000..16c66fb987c --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/model.py @@ -0,0 +1,39 @@ +import networkx as nx +import numpy as np +from agents import AllianceAgent + +import mesa + + +class AllianceModel(mesa.Model): + def __init__(self, n=50, mean=0.5, std_dev=0.1, seed=42): + super().__init__(seed=seed) + self.population = n + self.network = nx.Graph() # Initialize the network + self.datacollector = mesa.DataCollector(model_reporters={"Network": "network"}) + + # Create Agents + power = np.random.normal(mean, std_dev, n) + power = np.clip(power, 0, 1) + position = np.random.normal(mean, std_dev, n) + position = np.clip(position, 0, 1) + AllianceAgent.create_agents(self, n, power, position) + agent_ids = [ + (agent.unique_id, {"size": 300, "hierarchy": 0}) for agent in self.agents + ] + self.network.add_nodes_from(agent_ids) + + def add_link(self, meta_agent, agents): + for agent in agents: + self.network.add_edge(meta_agent.unique_id, agent.unique_id) + + def step(self): + for agent_class in list( + self.agent_types + ): # Convert to list to avoid modification during iteration + self.agents_by_type[agent_class].shuffle_do("form_alliance") + + # Update graph + if agent_class is not AllianceAgent: + for meta_agent in self.agents_by_type[agent_class]: + self.add_link(meta_agent, meta_agent.agents) diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py new file mode 100644 index 00000000000..43f238bf253 --- /dev/null +++ b/mesa/experimental/meta_agents/__init__.py @@ -0,0 +1,25 @@ +"""This method is for dynamically creating new agents (meta-agents). + +Meta-agents are defined as agents composed of existing agents. + +Meta-agents are created dynamically with a pointer to the model, name of the meta-agent,, +iterable of agents to belong to the new meta-agents, any new functions for the meta-agent, +any new attributes for the meta-agent, whether to retain sub-agent functions, +whether to retain sub-agent attributes. + +Examples of meta-agents: +- An autonomous car where the subagents are the wheels, sensors, +battery, computer etc. and the meta-agent is the car itself. +- A company where the subagents are employees, departments, buildings, etc. +- A city where the subagents are people, buildings, streets, etc. + +Currently meta-agents are restricted to one parent agent for each subagent/ +one meta-agent per subagent. + +Goal is to assess usage and expand functionality. + +""" + +from .meta_agents import create_meta_agent + +__all__ = ["create_meta_agent"] diff --git a/mesa/experimental/meta_agents/meta_agents.py b/mesa/experimental/meta_agents/meta_agents.py new file mode 100644 index 00000000000..3dc26696234 --- /dev/null +++ b/mesa/experimental/meta_agents/meta_agents.py @@ -0,0 +1,158 @@ +"""This method is for dynamically creating meta-agents that represent groups of agents with interdependent characteristics. + +The new meta-agent class is created dynamically using the provided name and +unique attributes and functions. + +Currently restricted to one parent agent and one meta-agent per agent. +Goal is to assess usage and expand functionality. + +Method has three paths of execution: +1. Add agents to existing meta-agent +2. Create new meta-agent instance of existing meta-agent class +3. Create new meta-agent class + +See alliance formation model in basic examples for usage. + +""" + +from types import MethodType + + +def create_meta_agent( + model, + new_agent_class: str, + agents, + meta_attributes=dict(), # noqa B006 + meta_functions=dict(), # noqa B006 + retain_subagent_functions=True, + retain_subagent_attributes=False, +): + """Dynamically create a new meta-agent class and instantiate agents in that class. + + Parameters: + model (Model): The model instance. + new_agent_class (str): The name of the new meta-agent class. + agents (Iterable[Agent]): The agents to be included in the meta-agent. + meta_attributes (dict): Attributes to be added to the meta-agent. + meta_functions (dict): Functions to be added to the meta-agent. + retain_subagent_functions (bool): Whether to retain functions from sub-agents. + retain_subagent_attributes (bool): Whether to retain attributes from sub-agents. + + Returns: + - None if adding agent(s) to existing class + - New class instance if created a new instance of a dynamically + created agent type + - New class instance if created a new dynamically created agent type + """ + from mesa import ( + Agent, # Import the Agent class from Mesa locally to avoid circular import + ) + + # Convert agents to set to ensure uniqueness + agents = set(agents) + + def add_agents(meta_agent, new_agents: set[Agent]): + """Update agents' meta-agent attribute and store agent's meta-agent. + + Parameters: + meta_agent (MetaAgent): The meta-agent instance. + new_agents (Set[Agent]): The new agents to be added. + """ + meta_agent.agents.update(new_agents) + for agent in new_agents: + agent.meta_agent = meta_agent + + def add_functions(meta_agent_instance, agents, meta_functions): + """Add functions to the meta-agent instance. + + Parameters: + meta_agent_instance (MetaAgent): The meta-agent instance. + agents (Iterable[Agent]): The agents to derive functions from. + meta_functions (dict): Functions to be added to the meta-agent. + """ + if retain_subagent_functions: + agent_classes = {type(agent) for agent in agents} + for agent_class in agent_classes: + for name in dir(agent_class): + if callable(getattr(agent_class, name)) and not name.startswith( + "__" + ): + original_method = getattr(agent_class, name) + meta_functions[name] = original_method + + if meta_functions: + for name, func in meta_functions.items(): + bound_method = MethodType(func, meta_agent_instance) + setattr(meta_agent_instance, name, bound_method) + + def add_attributes(meta_agent_instance, agents, meta_attributes): + """Add attributes to the meta-agent instance. + + Parameters: + meta_agent_instance (MetaAgent): The meta-agent instance. + agents (Iterable[Agent]): The agents to derive attributes from. + meta_attributes (dict): Attributes to be added to the meta-agent. + """ + if retain_subagent_attributes: + subagent_attributes = { + k: v + for agent in agents + for k, v in agent.__dict__.items() + if not callable(v) + } + meta_attributes.update(subagent_attributes) + + if meta_attributes: + for key, value in meta_attributes.items(): + setattr(meta_agent_instance, key, value) + + # Path 1 - Add agents to existing meta-agent + subagents = [a for a in agents if hasattr(a, "meta_agent")] + if len(subagents) > 0: + if len(subagents) == 1: + add_agents(subagents[0].meta_agent, agents) + else: + subagent = model.random.choice(subagents) + agents = set(agents) - set(subagents) + add_agents(subagent.meta_agent, agents) + # TODO: Add way for user to specify how agents join meta-agent instead of random choice + else: + # Path 2 - Create a new instance of an existing meta-agent class + agent_class = next( + ( + agent_type + for agent_type in model.agent_types + if agent_type.__name__ == new_agent_class + ), + None, + ) + + if agent_class: + meta_agent_instance = agent_class(model, agents) + add_attributes(meta_agent_instance, agents, meta_attributes) + add_functions(meta_agent_instance, agents, meta_functions) + add_agents(meta_agent_instance, agents) + model.register_agent(meta_agent_instance) + return meta_agent_instance + else: + # Path 3 - Create a new meta-agent class + class MetaAgentClass(Agent): + def __init__(self, model, agents): + super().__init__(model) + self.agents = agents + + meta_agent_class = type( + new_agent_class, + (MetaAgentClass,), + { + "unique_id": None, + "agents": None, + }, + ) + + meta_agent_instance = meta_agent_class(model=model, agents=agents) + add_attributes(meta_agent_instance, agents, meta_attributes) + add_functions(meta_agent_instance, agents, meta_functions) + model.register_agent(meta_agent_instance) + add_agents(meta_agent_instance, agents) + return meta_agent_instance diff --git a/tests/test_meta_agents.py b/tests/test_meta_agents.py new file mode 100644 index 00000000000..dc03d27484c --- /dev/null +++ b/tests/test_meta_agents.py @@ -0,0 +1,115 @@ +"""Tests for the meta_agents module.""" + +import pytest + +from mesa import Agent, Model +from mesa.experimental.meta_agents.meta_agents import create_meta_agent + + +@pytest.fixture +def setup_agents(): + """Set up the model and agents for testing. + + Returns: + tuple: A tuple containing the model and a list of agents. + """ + model = Model() + agent1 = Agent(model) + agent2 = Agent(model) + agent3 = Agent(model) + agent4 = Agent(model) + agent4.custom_attribute = "custom_value" + agent4.custom_function = lambda: "custom_function" + agents = [agent1, agent2, agent3, agent4] + return model, agents + + +def test_create_meta_agent_new_class(setup_agents): + """Test creating a new meta-agent class and test inclusion of attributes and functions. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = create_meta_agent( + model, + "MetaAgentClass", + agents, + meta_attributes={"attribute1": "value1"}, + meta_functions={"function1": lambda self: "function1"}, + ) + assert meta_agent is not None + assert meta_agent.attribute1 == "value1" + assert meta_agent.function1() == "function1" + assert meta_agent.agents == set(agents) + assert hasattr(meta_agent, "custom_attribute") + assert meta_agent.custom_attribute == "custom_value" + assert hasattr(meta_agent, "custom_function") + assert meta_agent.custom_function() == "custom_function" + + +def test_create_meta_agent_existing_class(setup_agents): + """Test creating new meta-agent instance with an existing meta-agent class. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + + # Create Met Agent Class + meta_agent = create_meta_agent( + model, + "MetaAgentClass", + [agents[0], agents[2]], + meta_attributes={"attribute1": "value1"}, + meta_functions={"function1": lambda self: "function1"}, + ) + + # Create new meta-agent instance with existing class + meta_agent2 = create_meta_agent( + model, + "MetaAgentClass", + [agents[1]], + meta_attributes={"attribute2": "value2"}, + meta_functions={"function2": lambda self: "function2"}, + ) + assert meta_agent is not None + assert meta_agent2.attribute2 == "value2" + assert meta_agent.function1() == "function1" + assert meta_agent.agents == {agents[2], agents[0]} + assert meta_agent2.function2() == "function2" + assert meta_agent2.agents == {agents[1]} + assert hasattr(meta_agent2, "custom_attribute") + assert meta_agent2.custom_attribute == "custom_value" + assert hasattr(meta_agent2, "custom_function") + assert meta_agent2.custom_function() == "custom_function" + + +def test_add_agents_to_existing_meta_agent(setup_agents): + """Test adding agents to an existing meta-agent instance. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + + meta_agent1 = create_meta_agent( + model, + "MetaAgentClass", + [agents[0], agents[2]], + meta_attributes={"attribute1": "value1"}, + meta_functions={"function1": lambda self: "function1"}, + ) + + create_meta_agent( + model, + "MetaAgentClass", + [agents[1], agents[0], agents[2]], + ) + assert meta_agent1.agents == {agents[0], agents[1], agents[2]} + assert meta_agent1.function1() == "function1" + assert meta_agent1.attribute1 == "value1" + assert hasattr(meta_agent1, "custom_attribute") + assert meta_agent1.custom_attribute == "custom_value" + assert hasattr(meta_agent1, "custom_function") + assert meta_agent1.custom_function() == "custom_function" diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py index 3b8d82fb7bc..a84fc910364 100644 --- a/tests/test_solara_viz.py +++ b/tests/test_solara_viz.py @@ -92,7 +92,8 @@ def Test(user_params): assert slider_int.step is None -def test_call_space_drawer(mocker): # noqa: D103 +def test_call_space_drawer(mocker): + """Test the call to space drawer.""" mock_space_matplotlib = mocker.spy( mesa.visualization.components.matplotlib_components, "SpaceMatplotlib" ) @@ -153,7 +154,8 @@ def drawer(model): ) -def test_slider(): # noqa: D103 +def test_slider(): + """Test the Slider component.""" slider_float = Slider("Agent density", 0.8, 0.1, 1.0, 0.1) assert slider_float.is_float_slider assert slider_float.value == 0.8 @@ -167,7 +169,9 @@ def test_slider(): # noqa: D103 assert slider_dtype_float.is_float_slider -def test_model_param_checks(): # noqa: D103 +def test_model_param_checks(): + """Test the model parameter checks.""" + class ModelWithOptionalParams: def __init__(self, required_param, optional_param=10): pass