From be1fad778db1f9983ef737733e3ce613de500e30 Mon Sep 17 00:00:00 2001 From: Tom Pike Date: Mon, 30 Dec 2024 19:00:32 -0500 Subject: [PATCH] refactor meta-agent - allow for deliberate meta-agents creation - allow for combinatorics - allow for dynamic agent creation fix methods-functions; add tests --- mesa/examples/__init__.py | 4 +- .../basic/alliance_formation_model/Readme.md | 2 +- .../alliance_formation_model/__init__.py | 10 -- .../basic/alliance_formation_model/agents.py | 65 +------ .../basic/alliance_formation_model/app.py | 10 +- .../basic/alliance_formation_model/model.py | 163 ++++++++++++++++-- mesa/experimental/meta_agents/__init__.py | 5 +- mesa/experimental/meta_agents/meta_agent.py | 148 ++++++++++++++++ mesa/experimental/meta_agents/meta_agents.py | 159 ----------------- mesa/experimental/meta_agents/multi_levels.py | 144 ++++++++++++++++ tests/test_examples.py | 6 +- tests/test_meta_agents.py | 162 +++++++++++++++-- 12 files changed, 612 insertions(+), 266 deletions(-) create mode 100644 mesa/experimental/meta_agents/meta_agent.py delete mode 100644 mesa/experimental/meta_agents/meta_agents.py create mode 100644 mesa/experimental/meta_agents/multi_levels.py diff --git a/mesa/examples/__init__.py b/mesa/examples/__init__.py index a2b9ff9080c..0422b232994 100644 --- a/mesa/examples/__init__.py +++ b/mesa/examples/__init__.py @@ -2,7 +2,7 @@ from mesa.examples.advanced.pd_grid.model import PdGrid from mesa.examples.advanced.sugarscape_g1mt.model import SugarscapeG1mt from mesa.examples.advanced.wolf_sheep.model import WolfSheep -from mesa.examples.basic.alliance_formation_model.model import AllianceModel +from mesa.examples.basic.alliance_formation_model.model import MultiLevelAllianceModel from mesa.examples.basic.boid_flockers.model import BoidFlockers from mesa.examples.basic.boltzmann_wealth_model.model import BoltzmannWealth from mesa.examples.basic.conways_game_of_life.model import ConwaysGameOfLife @@ -10,11 +10,11 @@ from mesa.examples.basic.virus_on_network.model import VirusOnNetwork __all__ = [ - "AllianceModel", "BoidFlockers", "BoltzmannWealth", "ConwaysGameOfLife", "EpsteinCivilViolence", + "MultiLevelAllianceModel", "PdGrid", "Schelling", "SugarscapeG1mt", diff --git a/mesa/examples/basic/alliance_formation_model/Readme.md b/mesa/examples/basic/alliance_formation_model/Readme.md index 4cd0edab7b7..cf60ab4a2f7 100644 --- a/mesa/examples/basic/alliance_formation_model/Readme.md +++ b/mesa/examples/basic/alliance_formation_model/Readme.md @@ -13,7 +13,7 @@ In its current configuration, agents being part of multiple meta-agents is not s This model requires Mesa's recommended install and scipy ``` - $ pip install mesa[rec] scipy + $ pip install mesa[rec] ``` ## How to Run diff --git a/mesa/examples/basic/alliance_formation_model/__init__.py b/mesa/examples/basic/alliance_formation_model/__init__.py index 49a80b627ee..e69de29bb2d 100644 --- a/mesa/examples/basic/alliance_formation_model/__init__.py +++ b/mesa/examples/basic/alliance_formation_model/__init__.py @@ -1,10 +0,0 @@ -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 index 38ff5ff92f8..4f33bb5b9a3 100644 --- a/mesa/examples/basic/alliance_formation_model/agents.py +++ b/mesa/examples/basic/alliance_formation_model/agents.py @@ -1,71 +1,20 @@ 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) + Agent has three attributes power (float), position (float) and level (int) """ - def __init__(self, model, power, position, hierarchy=0): + def __init__(self, model, power, position, level=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 - ] + self.level = level - # 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], - }, - ) + """ + For this demo model agent only need attributes. - # 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, - ) + More complex models could have functions that define agent behavior. + """ diff --git a/mesa/examples/basic/alliance_formation_model/app.py b/mesa/examples/basic/alliance_formation_model/app.py index ba536a92533..9df3116dc54 100644 --- a/mesa/examples/basic/alliance_formation_model/app.py +++ b/mesa/examples/basic/alliance_formation_model/app.py @@ -3,13 +3,10 @@ import solara from matplotlib.figure import Figure -from mesa.examples.basic.alliance_formation_model.model import AllianceModel -from mesa.mesa_logging import DEBUG, log_to_stderr +from mesa.examples.basic.alliance_formation_model.model import MultiLevelAllianceModel from mesa.visualization import SolaraViz from mesa.visualization.utils import update_counter -log_to_stderr(DEBUG) - model_params = { "seed": { "type": "InputText", @@ -37,7 +34,7 @@ def plot_network(model): update_counter.get() g = model.network - pos = nx.kamada_kawai_layout(g) + pos = nx.fruchterman_reingold_layout(g) fig = Figure() ax = fig.subplots() labels = {agent.unique_id: agent.unique_id for agent in model.agents} @@ -58,13 +55,14 @@ def plot_network(model): # Create initial model instance -model = AllianceModel(50) +model = MultiLevelAllianceModel(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], diff --git a/mesa/examples/basic/alliance_formation_model/model.py b/mesa/examples/basic/alliance_formation_model/model.py index a3b05156813..d1f8e891881 100644 --- a/mesa/examples/basic/alliance_formation_model/model.py +++ b/mesa/examples/basic/alliance_formation_model/model.py @@ -3,10 +3,25 @@ import mesa from mesa.examples.basic.alliance_formation_model.agents import AllianceAgent +from mesa.experimental.meta_agents.meta_agent import find_combinations +from mesa.experimental.meta_agents.multi_levels import multi_level_agents -class AllianceModel(mesa.Model): +class MultiLevelAllianceModel(mesa.Model): + """ + Model for simulating multi-level alliances among agents. + """ + def __init__(self, n=50, mean=0.5, std_dev=0.1, seed=42): + """ + Initialize the model. + + Args: + n (int): Number of agents. + mean (float): Mean value for normal distribution. + std_dev (float): Standard deviation for normal distribution. + seed (int): Random seed. + """ super().__init__(seed=seed) self.population = n self.network = nx.Graph() # Initialize the network @@ -19,21 +34,147 @@ def __init__(self, n=50, mean=0.5, std_dev=0.1, seed=42): 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 + (agent.unique_id, {"size": 300, "level": 0}) for agent in self.agents ] self.network.add_nodes_from(agent_ids) def add_link(self, meta_agent, agents): + """ + Add links between a meta agent and its constituent agents in the network. + + Args: + meta_agent (MetaAgent): The meta agent. + agents (list): List of agents. + """ for agent in agents: self.network.add_edge(meta_agent.unique_id, agent.unique_id) + def calculate_shapley_value(self, agents): + """ + Calculate the Shapley value of the two agents. + + Args: + agents (list): List of agents. + + Returns: + tuple: Potential utility, new position, and level. + """ + positions = agents.get("position") + new_position = 1 - (max(positions) - min(positions)) + potential_utility = agents.agg("power", sum) * 1.2 * new_position + + value_0 = 0.5 * agents[0].power + 0.5 * (potential_utility - agents[1].power) + value_1 = 0.5 * agents[1].power + 0.5 * (potential_utility - agents[0].power) + + if value_0 > agents[0].power and value_1 > agents[1].power: + if agents[0].level > agents[1].level: + level = agents[0].level + elif agents[0].level == agents[1].level: + level = agents[0].level + 1 + else: + level = agents[1].level + + return potential_utility, new_position, level + + def only_best_combination(self, combinations): + """ + Filter to keep only the best combination for each agent. + + Args: + combinations (list): List of combinations. + + Returns: + dict: Unique combinations. + """ + best = {} + # Determine best option for EACH agent + for group, value in combinations: + agent_ids = sorted(group.get("unique_id")) # by default is bilateral + # Deal with all possibilities + if ( + agent_ids[0] not in best and agent_ids[1] not in best + ): # if neither in add both + best[agent_ids[0]] = [group, value, agent_ids] + best[agent_ids[1]] = [group, value, agent_ids] + elif ( + agent_ids[0] in best and agent_ids[1] in best + ): # if both in, see if both would be trading up + if ( + value[0] > best[agent_ids[0]][1][0] + and value[0] > best[agent_ids[1]][1][0] + ): + # Remove the old alliances + del best[best[agent_ids[0]][2][1]] + del best[best[agent_ids[1]][2][0]] + # Add the new alliance + best[agent_ids[0]] = [group, value, agent_ids] + best[agent_ids[1]] = [group, value, agent_ids] + elif ( + agent_ids[0] in best + ): # if only agent_ids[0] in, see if it would be trading up + if value[0] > best[agent_ids[0]][1][0]: + # Remove the old alliance for agent_ids[0] + del best[best[agent_ids[0]][2][1]] + # Add the new alliance + best[agent_ids[0]] = [group, value, agent_ids] + best[agent_ids[1]] = [group, value, agent_ids] + elif ( + agent_ids[1] in best + ): # if only agent_ids[1] in, see if it would be trading up + if value[0] > best[agent_ids[1]][1][0]: + # Remove the old alliance for agent_ids[1] + del best[best[agent_ids[1]][2][0]] + # Add the new alliance + best[agent_ids[0]] = [group, value, agent_ids] + best[agent_ids[1]] = [group, value, agent_ids] + + # Create a unique dictionary of the best combinations + unique_combinations = {} + for group, value, agents_nums in best.values(): + unique_combinations[tuple(agents_nums)] = [group, value] + + return unique_combinations.values() + 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) + """ + Execute one step of the model. + """ + # Get all other agents of the same type + agent_types = list(self.agents_by_type.keys()) + + for agent_type in agent_types: + similar_agents = self.agents_by_type[agent_type] + + # Find the best combinations using find_combinations + if ( + len(similar_agents) > 1 + ): # only form alliances if there are more than 1 agent + combinations = find_combinations( + self, + similar_agents, + size=2, + evaluation_func=self.calculate_shapley_value, + filter_func=self.only_best_combination, + ) + + for alliance, attributes in combinations: + class_name = f"MetaAgentLevel{attributes[2]}" + meta = multi_level_agents( + self, + class_name, + alliance, + meta_attributes={ + "level": attributes[2], + "power": attributes[0], + "position": attributes[1], + }, + ) + + # Update the network if a new meta agent instance created + if meta: + self.network.add_node( + meta.unique_id, + size=(meta.level + 1) * 300, + level=meta.level, + ) + self.add_link(meta, meta.subset) diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index 43f238bf253..420f4ad34b1 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -20,6 +20,7 @@ """ -from .meta_agents import create_meta_agent +from .meta_agent import MetaAgent +from .multi_levels import multi_level_agents -__all__ = ["create_meta_agent"] +__all__ = ["MetaAgent", "multi_level_agents"] diff --git a/mesa/experimental/meta_agents/meta_agent.py b/mesa/experimental/meta_agents/meta_agent.py new file mode 100644 index 00000000000..b1882c51195 --- /dev/null +++ b/mesa/experimental/meta_agents/meta_agent.py @@ -0,0 +1,148 @@ +"""Implementation of Mesa's meta agent capability. + +This contains two helper functions and a MetaAgent class that can be used to create agents that contain other agents as components. + +Helper functions: +1 - find_combinations: Find combinations of agents to create a meta-agent subset. +2- evaluate_combination: Evaluate combinations of agents by some user based criteria to determine if it should be a subset. + +Meta-Agent class (MetaAgent): An agent that contains other agents as components. +""" + +import itertools +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor + +from mesa.agent import Agent, AgentSet + + +def evaluate_combination( + candidate_group: tuple[Agent, ...], + model, + evaluation_func: Callable[[AgentSet], float] | None, +) -> tuple[AgentSet, float] | None: + """Evaluate a combination of agents. + + Args: + candidate_group (Tuple[Agent, ...]): The group of agents to evaluate. + model: The model instance. + evaluation_func (Optional[Callable[[AgentSet], float]]): The function to evaluate the group. + + Returns: + Optional[Tuple[AgentSet, float]]: The evaluated group and its value, or None. + """ + group_set = AgentSet(candidate_group, random=model.random) + if evaluation_func: + value = evaluation_func(group_set) + return group_set, value + return None + + +def find_combinations( + model, + group: AgentSet, + size: int | tuple[int, int] = (2, 5), + evaluation_func: Callable[[AgentSet], float] | None = None, + filter_func: Callable[[list[tuple[AgentSet, float]]], list[tuple[AgentSet, float]]] + | None = None, +) -> list[tuple[AgentSet, float]]: + """Find valuable combinations of agents in this set. + + Args: + model: The model instance. + group (AgentSet): The set of agents to find combinations in. + size (Union[int, Tuple[int, int]], optional): The size or range of sizes for combinations. Defaults to (2, 5). + evaluation_func (Optional[Callable[[AgentSet], float]], optional): The function to evaluate combinations. Defaults to None. + filter_func (Optional[Callable[[List[Tuple[AgentSet, float]]], List[Tuple[AgentSet, float]]]], optional): The function to filter combinations. Defaults to None. + + Returns: + List[Tuple[AgentSet, float]]: The list of valuable combinations. + """ + combinations = [] + with ThreadPoolExecutor() as executor: + futures = [] + # Allow one size or range of sizes to be passed + size_range = (size, size + 1) if isinstance(size, int) else size + + candidate_groups = itertools.chain.from_iterable( + itertools.combinations(group, size) for size in range(*size_range) + ) + for candidate_group in candidate_groups: + futures.append( + executor.submit( + evaluate_combination, candidate_group, model, evaluation_func + ) + ) + + for future in futures: + group_set, result = future.result() + if result: + combinations.append((group_set, result)) + + if len(combinations) > 0 and filter_func: + filtered_combinations = filter_func(combinations) + return filtered_combinations + + return combinations + + +class MetaAgent(Agent): + """A MetaAgent is an agent that contains other agents as components.""" + + def __init__(self, model, agents: set[Agent] | None = None): + """Create a new MetaAgent. + + Args: + model: The model instance. + agents (Optional[set[Agent]], optional): The set of agents to include in the MetaAgent. Defaults to None. + """ + super().__init__(model) + self._subset = AgentSet(agents or [], random=model.random) + + # Add ref to meta_agent in subagents + for agent in self._subset: + agent.meta_agent = self # TODO: Make a set for meta_agents + + def __len__(self) -> int: + """Return the number of components.""" + return len(self._subset) + + def __iter__(self): + """Iterate over components.""" + return iter(self._subset) + + def __contains__(self, agent: Agent) -> bool: + """Check if an agent is a component.""" + return agent in self._subset + + @property + def subset(self) -> AgentSet: + """Read-only access to components as an AgentSet.""" + return self._subset + + def add_subagents(self, new_agents: set[Agent]): + """Add agents as components. + + Args: + new_agents (set[Agent]): The agents to add to MetaAgent subset. + """ + for agent in new_agents: + self._subset.add(agent) + agent.meta_agent = self # TODO: Make a set for meta_agents + + def remove_subagents(self, remove_agents: set[Agent]): + """Remove agents as components. + + Args: + remove_agents (set[Agent]): The agents to remove from MetaAgent. + """ + for agent in remove_agents: + self._subset.discard(agent) + agent.meta_agent = None # TODO: Remove meta_agent from set + + def step(self): + """Perform the agent's step. + + Override this method to define the meta agent's behavior. + By default, does nothing. + """ diff --git a/mesa/experimental/meta_agents/meta_agents.py b/mesa/experimental/meta_agents/meta_agents.py deleted file mode 100644 index 75048c46533..00000000000 --- a/mesa/experimental/meta_agents/meta_agents.py +++ /dev/null @@ -1,159 +0,0 @@ -"""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: - for agent in agents: - for name, value in agent.__dict__.items(): - if not callable(value): - meta_attributes[name] = value - - 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_attributes(subagents[0].meta_agent, agents, meta_attributes) - add_functions(subagents[0].meta_agent, agents, meta_functions) - add_agents(subagents[0].meta_agent, agents) - else: - subagent = model.random.choice(subagents) - agents = set(agents) - set(subagents) - add_attributes(subagent.meta_agent, agents, meta_attributes) - add_functions(subagent.meta_agent, agents, meta_functions) - 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/mesa/experimental/meta_agents/multi_levels.py b/mesa/experimental/meta_agents/multi_levels.py new file mode 100644 index 00000000000..6238c53b9d6 --- /dev/null +++ b/mesa/experimental/meta_agents/multi_levels.py @@ -0,0 +1,144 @@ +"""This function is for dynamically growing multiple levels of meta-agents. + +Each new level creates a new meta-agent class that is created dynamically using the provided name and +unique attributes and methods. + +Currently restricted to one parent agent and one meta-agent per agent. +Goal is to assess usage and expand functionality. + +Function 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 multi-level alliance formation model in basic examples for usage. + +""" + +from collections.abc import Callable, Iterable +from types import MethodType +from typing import Any + +from mesa.experimental.meta_agents.meta_agent import MetaAgent + + +def multi_level_agents( + model: Any, + new_agent_class: str, + agents: Iterable[Any], + meta_attributes: dict[str, Any] = dict(), # noqa B006 + meta_methods: dict[str, Callable] = dict(), # noqa B006 + retain_subagent_methods: bool = False, + retain_subagent_attributes: bool = False, +) -> Any | None: + """Dynamically create a new meta-agent class and instantiate agents in that class. + + Parameters: + model (Any): The model instance. + new_agent_class (str): The name of the new meta-agent class. + agents (Iterable[Any]): The agents to be included in the meta-agent. + meta_attributes (Dict[str, Any]): Attributes to be added to the meta-agent. + meta_methods (Dict[str, Callable]): Methods 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: + Optional[Any]: + - 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 + """ + # Convert agents to set to ensure uniqueness + agents = set(agents) + + def add_methods( + meta_agent_instance: Any, + agents: Iterable[Any], + meta_methods: dict[str, Callable], + ) -> None: + """Add functions to the meta-agent instance. + + Parameters: + meta_agent_instance (Any): The meta-agent instance. + agents (Iterable[Any]): The agents to derive functions from. + meta_methods (Dict[str, Callable]): Functions to be added to the meta-agent. + """ + if retain_subagent_methods: + agent_classes = {type(agent) for agent in agents} + for agent_class in agent_classes: + for name in agent_class.__dict__: + if callable(getattr(agent_class, name)) and not name.startswith( + "__" + ): + original_method = getattr(agent_class, name) + meta_methods[name] = original_method + + for name, meth in meta_methods.items(): + bound_method = MethodType(meth, meta_agent_instance) + setattr(meta_agent_instance, name, bound_method) + + def add_attributes( + meta_agent_instance: Any, agents: Iterable[Any], meta_attributes: dict[str, Any] + ) -> None: + """Add attributes to the meta-agent instance. + + Parameters: + meta_agent_instance (Any): The meta-agent instance. + agents (Iterable[Any]): The agents to derive attributes from. + meta_attributes (Dict[str, Any]): Attributes to be added to the meta-agent. + """ + if retain_subagent_attributes: + for agent in agents: + for name, value in agent.__dict__.items(): + if not callable(value): + meta_attributes[name] = value + + 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_attributes(subagents[0].meta_agent, agents, meta_attributes) + add_methods(subagents[0].meta_agent, agents, meta_methods) + subagents[0].meta_agent.add_subagents(agents) + + else: + subagent = model.random.choice(subagents) + agents = set(agents) - set(subagents) + add_attributes(subagent.meta_agent, agents, meta_attributes) + add_methods(subagent.meta_agent, agents, meta_methods) + subagent.meta_agent.add_subagents(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_methods(meta_agent_instance, agents, meta_methods) + return meta_agent_instance + else: + # Path 3 - Create a new meta-agent class + meta_agent_class = type( + new_agent_class, + (MetaAgent,), + { + "unique_id": None, + "_subset": None, + }, + ) + + meta_agent_instance = meta_agent_class(model, agents) + add_attributes(meta_agent_instance, agents, meta_attributes) + add_methods(meta_agent_instance, agents, meta_methods) + return meta_agent_instance diff --git a/tests/test_examples.py b/tests/test_examples.py index 8e923e64419..f9e020cbde2 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,10 +1,10 @@ # noqa: D100 from mesa.examples import ( - AllianceModel, BoidFlockers, BoltzmannWealth, ConwaysGameOfLife, EpsteinCivilViolence, + MultiLevelAllianceModel, PdGrid, Schelling, SugarscapeG1mt, @@ -114,7 +114,9 @@ def test_alliance_formation_model(): # noqa: D103 app.page # noqa: B018 - model = AllianceModel(50, seed=42) + model = MultiLevelAllianceModel(50, seed=42) for _i in range(10): model.step() + + assert len(model.agents) == len(model.network.nodes) diff --git a/tests/test_meta_agents.py b/tests/test_meta_agents.py index 62284ff7177..09f9fbcc72b 100644 --- a/tests/test_meta_agents.py +++ b/tests/test_meta_agents.py @@ -3,7 +3,12 @@ import pytest from mesa import Agent, Model -from mesa.experimental.meta_agents.meta_agents import create_meta_agent +from mesa.experimental.meta_agents.meta_agent import ( + MetaAgent, + evaluate_combination, + find_combinations, +) +from mesa.experimental.meta_agents.multi_levels import multi_level_agents @pytest.fixture @@ -30,18 +35,18 @@ def test_create_meta_agent_new_class(setup_agents): setup_agents (tuple): The model and agents fixture. """ model, agents = setup_agents - meta_agent = create_meta_agent( + meta_agent = multi_level_agents( model, "MetaAgentClass", agents, meta_attributes={"attribute1": "value1"}, - meta_functions={"function1": lambda self: "function1"}, + meta_methods={"function1": lambda self: "function1"}, retain_subagent_attributes=True, ) assert meta_agent is not None assert meta_agent.attribute1 == "value1" assert meta_agent.function1() == "function1" - assert meta_agent.agents == set(agents) + assert meta_agent._subset == set(agents) assert hasattr(meta_agent, "custom_attribute") assert meta_agent.custom_attribute == "custom_value" @@ -54,30 +59,30 @@ def test_create_meta_agent_existing_class(setup_agents): """ model, agents = setup_agents - # Create Met Agent Class - meta_agent = create_meta_agent( + # Create Meta Agent Class + meta_agent = multi_level_agents( model, "MetaAgentClass", [agents[0], agents[2]], meta_attributes={"attribute1": "value1"}, - meta_functions={"function1": lambda self: "function1"}, + meta_methods={"function1": lambda self: "function1"}, ) # Create new meta-agent instance with existing class - meta_agent2 = create_meta_agent( + meta_agent2 = multi_level_agents( model, "MetaAgentClass", [agents[1], agents[3]], meta_attributes={"attribute2": "value2"}, - meta_functions={"function2": lambda self: "function2"}, + meta_methods={"function2": lambda self: "function2"}, retain_subagent_attributes=True, ) 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_agent._subset == {agents[2], agents[0]} assert meta_agent2.function2() == "function2" - assert meta_agent2.agents == {agents[1], agents[3]} + assert meta_agent2._subset == {agents[1], agents[3]} assert hasattr(meta_agent2, "custom_attribute") assert meta_agent2.custom_attribute == "custom_value" @@ -90,23 +95,150 @@ def test_add_agents_to_existing_meta_agent(setup_agents): """ model, agents = setup_agents - meta_agent1 = create_meta_agent( + meta_agent1 = multi_level_agents( model, "MetaAgentClass", [agents[0], agents[3]], meta_attributes={"attribute1": "value1"}, - meta_functions={"function1": lambda self: "function1"}, + meta_methods={"function1": lambda self: "function1"}, retain_subagent_attributes=True, ) - create_meta_agent( + multi_level_agents( model, "MetaAgentClass", [agents[1], agents[0], agents[2]], retain_subagent_attributes=True, ) - assert meta_agent1.agents == {agents[0], agents[1], agents[2], agents[3]} + assert meta_agent1._subset == {agents[0], agents[1], agents[2], agents[3]} assert meta_agent1.function1() == "function1" assert meta_agent1.attribute1 == "value1" assert hasattr(meta_agent1, "custom_attribute") assert meta_agent1.custom_attribute == "custom_value" + + +def test_meta_agent_integration(setup_agents): + """Test the integration of MetaAgent with the model. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + + meta_agent = multi_level_agents( + model, + "MetaAgentClass", + agents, + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + retain_subagent_attributes=True, + ) + + model.step() + + assert meta_agent in model.agents + assert meta_agent.function1() == "function1" + assert meta_agent.attribute1 == "value1" + assert hasattr(meta_agent, "custom_attribute") + assert meta_agent.custom_attribute == "custom_value" + + +def test_evaluate_combination(setup_agents): + """Test the evaluate_combination function. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + + def evaluation_func(agent_set): + return len(agent_set) + + result = evaluate_combination(tuple(agents), model, evaluation_func) + assert result is not None + assert result[1] == len(agents) + + +def test_find_combinations(setup_agents): + """Test the find_combinations function. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + agent_set = set(agents) + + def evaluation_func(agent_set): + return len(agent_set) + + def filter_func(combinations): + return [combo for combo in combinations if combo[1] > 2] + + combinations = find_combinations( + model, + agent_set, + size=(2, 4), + evaluation_func=evaluation_func, + filter_func=filter_func, + ) + assert len(combinations) > 0 + for combo in combinations: + assert combo[1] > 2 + + +def test_meta_agent_len(setup_agents): + """Test the __len__ method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + assert len(meta_agent) == len(agents) + + +def test_meta_agent_iter(setup_agents): + """Test the __iter__ method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + assert list(iter(meta_agent)) == list(meta_agent._subset) + + +def test_meta_agent_contains(setup_agents): + """Test the __contains__ method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + for agent in agents: + assert agent in meta_agent + + +def test_meta_agent_add_subagents(setup_agents): + """Test the add_subagents method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, {agents[0], agents[1]}) + meta_agent.add_subagents({agents[2], agents[3]}) + assert meta_agent._subset == set(agents) + + +def test_meta_agent_remove_subagents(setup_agents): + """Test the remove_subagents method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + meta_agent.remove_subagents({agents[2], agents[3]}) + assert meta_agent._subset == {agents[0], agents[1]}