Repository: JoelForamitti/agentpy Branch: master Commit: 42f5d498677c Files: 76 Total size: 27.8 MB Directory structure: gitextract_t5qr14uy/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .readthedocs.yml ├── LICENSE ├── README.md ├── agentpy/ │ ├── __init__.py │ ├── agent.py │ ├── datadict.py │ ├── examples.py │ ├── experiment.py │ ├── grid.py │ ├── model.py │ ├── network.py │ ├── objects.py │ ├── sample.py │ ├── sequences.py │ ├── space.py │ ├── tools.py │ ├── version.py │ └── visualization.py ├── docs/ │ ├── Makefile │ ├── _static/ │ │ └── css/ │ │ └── custom.css │ ├── about.rst │ ├── agentpy_button_network.ipynb │ ├── agentpy_demo.py │ ├── agentpy_flocking.ipynb │ ├── agentpy_forest_fire.ipynb │ ├── agentpy_segregation.ipynb │ ├── agentpy_virus_spread.ipynb │ ├── agentpy_wealth_transfer.ipynb │ ├── changelog.rst │ ├── conf.py │ ├── contributing.rst │ ├── guide.rst │ ├── guide_ema.ipynb │ ├── guide_interactive.ipynb │ ├── guide_random.ipynb │ ├── index.rst │ ├── installation.rst │ ├── make.bat │ ├── model_library.rst │ ├── overview.rst │ ├── reference.rst │ ├── reference_agents.rst │ ├── reference_data.rst │ ├── reference_environments.rst │ ├── reference_examples.rst │ ├── reference_experiment.rst │ ├── reference_grid.rst │ ├── reference_model.rst │ ├── reference_network.rst │ ├── reference_other.rst │ ├── reference_sample.rst │ ├── reference_sequences.rst │ ├── reference_space.rst │ └── reference_visualization.rst ├── paper/ │ ├── paper.bib │ └── paper.md ├── setup.cfg ├── setup.py └── tests/ ├── __init__.py ├── test_datadict.py ├── test_examples.py ├── test_experiment.py ├── test_grid.py ├── test_init.py ├── test_model.py ├── test_network.py ├── test_objects.py ├── test_sample.py ├── test_sequences.py ├── test_space.py ├── test_tools.py └── test_visualization.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: # Check for updates to GitHub Actions every week interval: "weekly" ================================================ FILE: .github/workflows/publish.yml ================================================ # This workflow will upload a Python Package using Twine when a release is created # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. name: Build and upload package on: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@d7edd4c95736a5bc1260d38b5523f5d24338bc25 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ name: Run tests on: [push, pull_request, workflow_dispatch] permissions: contents: read jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install pytest python -m pip install . if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with pytest run: pytest ================================================ FILE: .gitignore ================================================ # Agentpy ap_output/ docs/ap_output/ # Pycharm .idea/ # Frome here-on generated automatically # by https://www.toptal.com/developers/gitignore/api/python # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ pytestdebug.log # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ doc/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ pythonenv* # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # profiling data .prof ================================================ FILE: .readthedocs.yml ================================================ version: 2 formats: all build: os: ubuntu-22.04 # Or ubuntu-20.04 tools: python: "3.9" # Adjust based on your project sphinx: "5.0" # Specify a compatible Sphinx version python: install: - method: pip path: . extra_requirements: ["docs"] sphinx: # Path to your Sphinx configuration file. configuration: docs/conf.py ================================================ FILE: LICENSE ================================================ BSD 3-Clause License Copyright (c) 2020-2021 Joël Foramitti Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ # AgentPy - Agent-based modeling in Python [![PyPI](https://img.shields.io/pypi/v/agentpy)](https://pypi.org/project/agentpy/) [![GitHub](https://img.shields.io/github/license/joelforamitti/agentpy)](https://github.com/JoelForamitti/agentpy/blob/master/LICENSE) [![Documentation Status](https://readthedocs.org/projects/agentpy/badge/?version=latest)](https://agentpy.readthedocs.io/en/latest/?badge=latest) [![DOI](https://joss.theoj.org/papers/10.21105/joss.03065/status.svg)](https://doi.org/10.21105/joss.03065) AgentPy is an open-source library for the development and analysis of agent-based models in Python. The framework integrates the tasks of model design, interactive simulations, numerical experiments, and data analysis within a single environment. The package is optimized for interactive computing with [IPython](http://ipython.org/) and [Jupyter](https://jupyter.org/). **Note:** AgentPy is no longer under active development. For new projects, we recommend using [MESA](https://mesa.readthedocs.io/stable/). **Installation:** `pip install agentpy` **Documentation:** https://agentpy.readthedocs.io **JOSS publication:** https://doi.org/10.21105/joss.03065 Please cite this software as follows: Foramitti, J., (2021). AgentPy: A package for agent-based modeling in Python. Journal of Open Source Software, 6(62), 3065, https://doi.org/10.21105/joss.03065 ================================================ FILE: agentpy/__init__.py ================================================ """ Agentpy - Agent-based modeling in Python Copyright (c) 2020-2021 Joël Foramitti Documentation: https://agentpy.readthedocs.io/ Examples: https://agentpy.readthedocs.io/en/latest/model_library.html Source: https://github.com/JoelForamitti/agentpy """ __all__ = [ '__version__', 'Model', 'Agent', 'AgentList', 'AgentDList', 'AgentSet', 'AgentIter', 'AgentDListIter', 'AttrIter', 'Grid', 'GridIter', 'Space', 'Network', 'AgentNode', 'Experiment', 'DataDict', 'Sample', 'Values', 'Range', 'IntRange', 'gridplot', 'animate', 'AttrDict' ] from .version import __version__ from .model import Model from .agent import Agent from .sequences import AgentList, AgentDList, AgentSet from .sequences import AgentIter, AgentDListIter, AttrIter from .grid import Grid, GridIter from .space import Space from .network import Network, AgentNode from .experiment import Experiment from .datadict import DataDict from .sample import Sample, Values, Range, IntRange from .visualization import gridplot, animate from .tools import AttrDict ================================================ FILE: agentpy/agent.py ================================================ """ Agentpy Agent Module Content: Agent Classes """ from .objects import Object from .sequences import AgentList from .tools import AgentpyError, make_list class Agent(Object): """ Template for an individual agent. Arguments: model (Model): The model instance. **kwargs: Will be forwarded to :func:`Agent.setup`. Attributes: id (int): Unique identifier of the agent. log (dict): Recorded variables of the agent. type (str): Class name of the agent. model (Model): The model instance. p (AttrDict): The model parameters. vars (list of str): Names of the agent's custom variables. """ def __init__(self, model, *args, **kwargs): super().__init__(model) self.setup(*args, **kwargs) ================================================ FILE: agentpy/datadict.py ================================================ """ Agentpy Output Module Content: DataDict class for output data """ import pandas as pd import os from os import listdir, makedirs from os.path import getmtime, join from SALib.analyze import sobol from .tools import AttrDict, make_list, AgentpyError import json import numpy as np class NpEncoder(json.JSONEncoder): """ Adds support for numpy number formats to json. """ # By Jie Yang https://stackoverflow.com/a/57915246 def default(self, obj): if isinstance(obj, np.integer): return int(obj) elif isinstance(obj, np.floating): return float(obj) elif isinstance(obj, np.ndarray): return obj.tolist() elif isinstance(obj, np.bool_): return bool(obj) else: return super(NpEncoder, self).default(obj) def _last_exp_id(name, path): """ Identifies existing experiment data and return highest id. """ output_dirs = listdir(path) exp_dirs = [s for s in output_dirs if name in s] if exp_dirs: ids = [int(s.split('_')[-1]) for s in exp_dirs] return max(ids) else: return None # TODO Create DataSubDict without methods class DataDict(AttrDict): """ Nested dictionary for output data of simulations. Items can be accessed like attributes. Attributes can differ from the standard ones listed below. Attributes: info (dict): Metadata of the simulation. parameters (DataDict): Simulation parameters. variables (DataDict): Recorded variables, separatedper object type. reporters (pandas.DataFrame): Reported outcomes of the simulation. sensitivity (DataDict): Sensitivity data, if calculated. """ def __repr__(self, indent=False): rep = "" if not indent: rep += "DataDict {" i = ' ' if indent else '' for k, v in self.items(): rep += f"\n{i}'{k}': " if isinstance(v, (int, float, np.integer, np.floating)): rep += f"{v} {type(v)}" elif isinstance(v, str): x0 = f"(length {len(v)})" x = f"...' {x0}" if len(v) > 20 else "'" rep += f"'{v[:30]}{x} {type(v)}" elif isinstance(v, pd.DataFrame): lv = len(list(v.columns)) rv = len(list(v.index)) rep += f"DataFrame with {lv} " \ f"variable{'s' if lv != 1 else ''} " \ f"and {rv} row{'s' if rv != 1 else ''}" elif isinstance(v, DataDict): rep += f"{v.__repr__(indent=True)}" elif isinstance(v, dict): lv = len(list(v.keys())) rep += f"Dictionary with {lv} key{'s' if lv != 1 else ''}" elif isinstance(v, list): lv = len(v) rep += f"List with {lv} entr{'ies' if lv != 1 else 'y'}" else: rep += f"Object of type {type(v)}" if not indent: rep += "\n}" return rep def _short_repr(self): len_ = len(self.keys()) return f"DataDict {{{len_} entr{'y' if len_ == 1 else 'ies'}}}" def __eq__(self, other): """ Check equivalence of two DataDicts.""" if not isinstance(other, DataDict): return False for key, item in self.items(): if key not in other: return False if isinstance(item, pd.DataFrame): if not self[key].equals(other[key]): return False elif not self[key] == other[key]: return False return True def __ne__(self, other): return not self.__eq__(other) # Data analysis --------------------------------------------------------- # @staticmethod def _sobol_set_df_index(df, p_keys, reporter): df['parameter'] = p_keys df['reporter'] = reporter df.set_index(['reporter', 'parameter'], inplace=True) def calc_sobol(self, reporters=None, **kwargs): """ Calculates Sobol Sensitivity Indices using :func:`SALib.analyze.sobol.analyze`. Data must be from an :class:`Experiment` with a :class:`Sample` that was generated with the method 'saltelli'. If the experiment had more than one iteration, the mean value between iterations will be taken. Arguments: reporters (str or list of str, optional): The reporters that should be used for the analysis. If none are passed, all existing reporters except 'seed' are used. **kwargs: Will be forwarded to :func:`SALib.analyze.sobol.analyze`. Returns: DataDict: The DataDict itself with an added category 'sensitivity'. """ if not self.parameters.log['type'] == 'saltelli': raise AgentpyError("Sampling method must be 'saltelli'.") if self.info['iterations'] == 1: reporters_df = self.reporters else: reporters_df = self.reporters.groupby('sample_id').mean() # STEP 1 - Load salib problem from parameter log param_ranges_salib = self.parameters.log['salib_problem'] calc_second_order = self.parameters.log['calc_second_order'] # STEP 2 - Calculate Sobol Sensitivity Indices if reporters is None: reporters = reporters_df.columns if 'seed' in reporters: reporters = reporters.drop('seed') elif isinstance(reporters, str): reporters = [reporters] p_keys = self._combine_pars(sample=True, constants=False).keys() dfs_list = [[] for _ in range(4 if calc_second_order else 2)] for reporter in reporters: y = np.array(reporters_df[reporter]) si = sobol.analyze(param_ranges_salib, y, calc_second_order, **kwargs) # Make dataframes out of S1 and ST sensitivities keyss = [['S1', 'ST'], ['S1_conf', 'ST_conf']] for keys, dfs in zip(keyss, dfs_list[0:2]): s = {k[0:2]: v for k, v in si.items() if k in keys} df = pd.DataFrame(s) self._sobol_set_df_index(df, p_keys, reporter) dfs.append(df) # Make dataframes out S2 sensitivities if calc_second_order: for key, dfs in zip(['S2', 'S2_conf'], dfs_list[2:4]): df = pd.DataFrame(si[key]) self._sobol_set_df_index(df, p_keys, reporter) dfs.append(df) # Combine dataframes for each reporter self['sensitivity'] = sdict = DataDict() sdict['sobol'] = pd.concat(dfs_list[0]) sdict['sobol_conf'] = pd.concat(dfs_list[1]) if calc_second_order: # Add Second-Order to self dfs_si = [sdict['sobol'], pd.concat(dfs_list[2])] dfs_si_conf = [sdict['sobol_conf'], pd.concat(dfs_list[3])] sdict['sobol'] = pd.concat(dfs_si, axis=1) sdict['sobol_conf'] = pd.concat(dfs_si_conf, axis=1) # Create Multi-Index for Columns arrays = [["S1", "ST"] + ["S2"] * len(p_keys), [""] * 2 + list(p_keys)] tuples = list(zip(*arrays)) index = pd.MultiIndex.from_tuples(tuples, names=["order", "parameter"]) sdict['sobol'].columns = index sdict['sobol_conf'].columns = index.copy() return self # Data arrangement ------------------------------------------------------ # def _combine_vars(self, obj_types=True, var_keys=True): """ Returns pandas dataframe with combined variables """ # Retrieve variables if 'variables' in self: vs = self['variables'] else: return None if len(vs.keys()) == 1: return list(vs.values())[0] # Return df if vs has only one entry elif isinstance(vs, DataDict): df_dict = dict(vs) # Convert to dict if vs is DataDict # Remove dataframes that don't include any of the selected var_keys if var_keys is not True: df_dict = {k: v for k, v in df_dict.items() if any(x in v.columns for x in make_list(var_keys))} # Select object types if obj_types is not True: df_dict = {k: v for k, v in df_dict.items() if k in make_list(obj_types)} # Add 'obj_id' before 't' for model df model_type = self.info['model_type'] if model_type in list(df_dict.keys()): df = df_dict[model_type] df['obj_id'] = 0 indexes = list(df.index.names) indexes.insert(-1, 'obj_id') df = df.reset_index() df = df.set_index(indexes) df_dict[model_type] = df # Return none if empty if df_dict == {}: return None # Create dataframe df = pd.concat(df_dict) # Dict keys (obj_type) will be added to index df.index = df.index.set_names('obj_type', level=0) # Rename new index # Select var_keys if var_keys is not True: # make_list prevents conversion to pd.Series for single value df = df[make_list(var_keys)] return df def _dict_pars_to_df(self, dict_pars): n = self.info['sample_size'] if 'sample_size' in self.info else 1 d = {k: [v] * n for k, v in dict_pars.items()} i = pd.Index(list(range(n)), name='sample_id') return pd.DataFrame(d, index=i) def _combine_pars(self, sample=True, constants=True): """ Returns pandas dataframe with parameters and sample_id """ # Cancel if there are no parameters if 'parameters' not in self: return None dfp = pd.DataFrame() if sample and 'sample' in self.parameters: dfp = self.parameters.sample.copy() if constants and 'constants' in self.parameters: for k, v in self.parameters.constants.items(): dfp[k] = v elif constants and 'constants' in self.parameters: dfp = self._dict_pars_to_df(self.parameters.constants) # Cancel if no parameters have been selected if dfp is None or dfp.empty is True: return None # Remove seed parameter as the actually used seed is reported per run if 'seed' in dfp: del dfp['seed'] return dfp def arrange(self, variables=False, reporters=False, parameters=False, constants=False, obj_types=True, index=False): """ Combines and/or filters data based on passed arguments. Arguments: variables (bool or str or list of str, optional): Key or list of keys of variables to include in the dataframe. If True, all available variables are selected. If False (default), no variables are selected. reporters (bool or str or list of str, optional): Key or list of keys of reporters to include in the dataframe. If True, all available reporters are selected. If False (default), no reporters are selected. parameters (bool or str or list of str, optional): Key or list of keys of parameters to include in the dataframe. If True, all non-constant parameters are selected. If False (default), no parameters are selected. constants (bool, optional): Include constants if 'parameters' is True (default False). obj_types (str or list of str, optional): Agent and/or environment types to include in the dataframe. If True (default), all objects are selected. If False, no objects are selected. index (bool, optional): Whether to keep original multi-index structure (default False). Returns: pandas.DataFrame: The newly arranged dataframe. """ dfv = dfm = dfp = df = None # Step 1: Variables if variables is not False: dfv = self._combine_vars(obj_types, variables) # Step 2: Measures if reporters is not False: dfm = self.reporters if reporters is not True: # Select reporter keys # make_list prevents conversion to pd.Series for single value dfm = dfm[make_list(reporters)] # Step 3: Parameters if parameters is True: dfp = self._combine_pars(constants=constants) elif parameters is not False: dfp = self._combine_pars() dfp = dfp[make_list(parameters)] # Step 4: Combine dataframes if dfv is not None and dfm is not None: # Combine variables & measures index_keys = dfv.index.names dfm = dfm.reset_index() dfv = dfv.reset_index() df = pd.concat([dfm, dfv]) df = df.set_index(index_keys) elif dfv is not None: df = dfv elif dfm is not None: df = dfm if dfp is not None: if df is None: df = dfp else: # Combine df with parameters if df is not None and isinstance(df.index, pd.MultiIndex): dfp = dfp.reindex(df.index, level='sample_id') df = pd.concat([df, dfp], axis=1) if df is None: return pd.DataFrame() # Step 6: Reset index if not index: df = df.reset_index() return df def arrange_reporters(self): """ Common use case of :obj:`DataDict.arrange` with `reporters=True` and `parameters=True`. """ return self.arrange(variables=False, reporters=True, parameters=True) def arrange_variables(self): """ Common use case of :obj:`DataDict.arrange` with `variables=True` and `parameters=True`. """ return self.arrange(variables=True, reporters=False, parameters=True) # Saving and loading data ----------------------------------------------- # def save(self, exp_name=None, exp_id=None, path='ap_output', display=True): """ Writes data to directory `{path}/{exp_name}_{exp_id}/`. Works only for entries that are of type :class:`DataDict`, :class:`pandas.DataFrame`, or serializable with JSON (int, float, str, dict, list). Numpy objects will be converted to standard objects, if possible. Arguments: exp_name (str, optional): Name of the experiment to be saved. If none is passed, `self.info['model_type']` is used. exp_id (int, optional): Number of the experiment. Note that passing an existing id can overwrite existing data. If none is passed, a new id is generated. path (str, optional): Target directory (default 'ap_output'). display (bool, optional): Display saving progress (default True). """ # Create output directory if it doesn't exist if path not in listdir(): makedirs(path) # Set exp_name if exp_name is None: if 'info' in self and 'model_type' in self.info: exp_name = self.info['model_type'] else: exp_name = 'Unnamed' exp_name = exp_name.replace(" ", "_") # Set exp_id if exp_id is None: exp_id = _last_exp_id(exp_name, path) if exp_id is None: exp_id = 1 else: exp_id += 1 # Create new directory for output directory = f'{exp_name}_{exp_id}' path_dir = f'{path}/{directory}' if directory not in listdir(path): makedirs(path_dir) # Save experiment data for key, output in self.items(): if isinstance(output, pd.DataFrame): output.to_csv(f'{path_dir}/{key}.csv') elif isinstance(output, DataDict): for k, o in output.items(): if isinstance(o, pd.DataFrame): o.to_csv(f'{path_dir}/{key}_{k}.csv') elif isinstance(o, dict): with open(f'{path_dir}/{key}_{k}.json', 'w') as fp: json.dump(o, fp, cls=NpEncoder) else: # Use JSON for other object types try: with open(f'{path_dir}/{key}.json', 'w') as fp: json.dump(output, fp, cls=NpEncoder) except TypeError as e: print(f"Warning: Object '{key}' could not be saved. " f"(Reason: {e})") os.remove(f'{path_dir}/{key}.json') # TODO Support grids & graphs # elif t == nx.Graph: # nx.write_graphml(output, f'{path}/{key}.graphml') if display: print(f"Data saved to {path_dir}") def _load(self, exp_name=None, exp_id=None, path='ap_output', display=True): def load_file(path, file, display): if display: print(f'Loading {file} - ', end='') i_cols = ['sample_id', 'iteration', 'obj_id', 't'] ext = file.split(".")[-1] path = path + file try: if ext == 'csv': obj = pd.read_csv(path) # Convert .csv into DataFrane index = [i for i in i_cols if i in obj.columns] if index: # Set potential index columns obj = obj.set_index(index) elif ext == 'json': # Convert .json with json decoder with open(path, 'r') as fp: obj = json.load(fp) # Convert dict to AttrDict if isinstance(obj, dict): obj = AttrDict(obj) # TODO Support grids & graphs # elif ext == 'graphml': # self[key] = nx.read_graphml(path) else: raise ValueError(f"File type '{ext}' not supported") if display: print('Successful') return obj except Exception as e: print(f'Error: {e}') # Prepare for loading if exp_name is None: # Choose latest modified experiment exp_names = listdir(path) paths = [join(path, d) for d in exp_names] latest_exp = exp_names[paths.index(max(paths, key=getmtime))] exp_name = latest_exp.rsplit('_', 1)[0] exp_name = exp_name.replace(" ", "_") if exp_id is None: exp_id = _last_exp_id(exp_name, path) if exp_id is None: raise FileNotFoundError(f"No experiment found with " f"name '{exp_name}' in path '{path}'") path = f'{path}/{exp_name}_{exp_id}/' if display: print(f'Loading from directory {path}') # Loading data for file in listdir(path): if 'variables_' in file: if 'variables' not in self: self['variables'] = DataDict() ext = file.split(".")[-1] key = file[:-(len(ext) + 1)].replace('variables_', '') self['variables'][key] = load_file(path, file, display) elif 'parameters_' in file: ext = file.split(".")[-1] key = file[:-(len(ext) + 1)].replace('parameters_', '') if 'parameters' not in self: self['parameters'] = DataDict() self['parameters'][key] = load_file(path, file, display) else: ext = file.split(".")[-1] key = file[:-(len(ext) + 1)] self[key] = load_file(path, file, display) return self @classmethod def load(cls, exp_name=None, exp_id=None, path='ap_output', display=True): """ Reads data from directory `{path}/{exp_name}_{exp_id}/`. Arguments: exp_name (str, optional): Experiment name. If none is passed, the most recent experiment is chosen. exp_id (int, optional): Id number of the experiment. If none is passed, the highest available id used. path (str, optional): Target directory (default 'ap_output'). display (bool, optional): Display loading progress (default True). Returns: DataDict: The loaded data from the chosen experiment. """ return cls()._load(exp_name, exp_id, path, display) ================================================ FILE: agentpy/examples.py ================================================ import agentpy as ap import numpy as np def gini(x): """ Calculate Gini Coefficient """ # By Warren Weckesser https://stackoverflow.com/a/39513799 x = np.array(x) mad = np.abs(np.subtract.outer(x, x)).mean() # Mean absolute difference rmad = mad / np.mean(x) # Relative mean absolute difference return 0.5 * rmad class WealthAgent(ap.Agent): """ An agent with wealth """ def setup(self): self.wealth = 1 def wealth_transfer(self): if self.wealth > 0: partner = self.model.agents.random() partner.wealth += 1 self.wealth -= 1 class WealthModel(ap.Model): """ Demonstration model of random wealth transfers. See Also: Notebook in the model library: :doc:`agentpy_wealth_transfer` Arguments: parameters (dict): - agents (int): Number of agents. - steps (int, optional): Number of time-steps. """ def setup(self): self.agents = ap.AgentList(self, self.p.agents, WealthAgent) def step(self): self.agents.wealth_transfer() def update(self): self.gini = gini(self.agents.wealth) self.record('gini') def end(self): self.report('gini') class SegregationAgent(ap.Agent): def setup(self): """ Initiate agent attributes. """ self.grid = self.model.grid self.random = self.model.random self.group = self.random.choice(range(self.p.n_groups)) self.share_similar = 0 self.happy = False def update_happiness(self): """ Be happy if rate of similar neighbors is high enough. """ neighbors = self.grid.neighbors(self) similar = len([n for n in neighbors if n.group == self.group]) ln = len(neighbors) self.share_similar = similar / ln if ln > 0 else 0 self.happy = self.share_similar >= self.p.want_similar def find_new_home(self): """ Move to random free spot and update free spots. """ new_spot = self.random.choice(self.model.grid.empty) self.grid.move_to(self, new_spot) class SegregationModel(ap.Model): """ Demonstration model of segregation dynamics. See Also: Notebook in the model library: :doc:`agentpy_segregation` Arguments: parameters (dict): - want_similar (float): Percentage of similar neighbors for agents to be happy - n_groups (int): Number of groups - density (float): Density of population - size (int): Height and length of the grid - steps (int, optional): Maximum number of steps """ def setup(self): # Parameters s = self.p.size n = self.n = int(self.p.density * (s ** 2)) # Create grid and agents self.grid = ap.Grid(self, (s, s), track_empty=True) self.agents = ap.AgentList(self, n, SegregationAgent) self.grid.add_agents(self.agents, random=True, empty=True) def update(self): # Update list of unhappy people self.agents.update_happiness() self.unhappy = self.agents.select(self.agents.happy == False) # Stop simulation if all are happy if len(self.unhappy) == 0: self.stop() def step(self): # Move unhappy people to new location self.unhappy.find_new_home() def get_segregation(self): # Calculate average percentage of similar neighbors return round(sum(self.agents.share_similar) / self.n, 2) def end(self): # Measure segregation at the end of the simulation self.report('segregation', self.get_segregation()) ================================================ FILE: agentpy/experiment.py ================================================ """ Agentpy Experiment Module Content: Experiment class """ import warnings import pandas as pd import random as rd from os import sys from .version import __version__ from datetime import datetime, timedelta from .tools import make_list from .datadict import DataDict from .sample import Sample, Range, IntRange, Values from joblib import Parallel,delayed class Experiment: """ Experiment that can run an agent-based model over for multiple iterations and parameter combinations and generate combined output data. Arguments: model (type): The model class for the experiment to use. sample (dict or list of dict or Sample, optional): Parameter combination(s) to test in the experiment (default None). iterations (int, optional): How often to repeat every parameter combination (default 1). record (bool, optional): Keep the record of dynamic variables (default False). randomize (bool, optional): Generate different random seeds for every iteration (default True). If True, the parameter 'seed' will be used to initialize a random seed generator for every parameter combination in the sample. If False, the same seed will be used for every iteration. If no parameter 'seed' is defined, this option has no effect. For more information, see :doc:`guide_random` . **kwargs: Will be forwarded to all model instances created by the experiment. Attributes: output(DataDict): Recorded experiment data """ def __init__(self, model_class, sample=None, iterations=1, record=False, randomize=True, **kwargs): self.model = model_class self.output = DataDict() self.iterations = iterations self.record = record self._model_kwargs = kwargs self.name = model_class.__name__ # Prepare sample if isinstance(sample, Sample): self.sample = list(sample) self._sample_log = sample._log else: self.sample = make_list(sample, keep_none=True) self._sample_log = None # Prepare runs len_sample = len(self.sample) iter_range = range(iterations) if iterations > 1 else [None] sample_range = range(len_sample) if len_sample > 1 else [None] self.run_ids = [(sample_id, iteration) for sample_id in sample_range for iteration in iter_range] self.n_runs = len(self.run_ids) # Prepare seeds if randomize and sample is not None \ and any(['seed' in p for p in self.sample]): if len_sample > 1: rngs = [rd.Random(p['seed']) if 'seed' in p else rd.Random() for p in self.sample] self._random = { (sample_id, iteration): rngs[sample_id].getrandbits(128) for sample_id in sample_range for iteration in iter_range } else: p = list(self.sample)[0] seed = p['seed'] ranges = (Range, IntRange, Values) if isinstance(seed, ranges): seed = seed.vdef rng = rd.Random(seed) self._random = { (None, iteration): rng.getrandbits(128) for iteration in iter_range } else: self._random = None # Prepare output self.output.info = { 'model_type': model_class.__name__, 'time_stamp': str(datetime.now()), 'agentpy_version': __version__, 'python_version': sys.version[:5], 'experiment': True, 'scheduled_runs': self.n_runs, 'completed': False, 'random': randomize, 'record': record, 'sample_size': len(self.sample), 'iterations': iterations } self._parameters_to_output() def _parameters_to_output(self): """ Document parameters (separately for fixed & variable). """ df = pd.DataFrame(self.sample) df.index.rename('sample_id', inplace=True) fixed_pars = {} for col in df.columns: s = df[col] if len(s.unique()) == 1: fixed_pars[s.name] = df[col][0] df.drop(col, inplace=True, axis=1) self.output['parameters'] = DataDict() if fixed_pars: self.output['parameters']['constants'] = fixed_pars if not df.empty: self.output['parameters']['sample'] = df if self._sample_log: self.output['parameters']['log'] = self._sample_log @staticmethod def _add_single_output_to_combined(single_output, combined_output): """Append results from single run to combined output. Each key in single_output becomes a key in combined_output. DataDicts entries become dicts with lists of values. Other entries become lists of values. """ for key, value in single_output.items(): if key in ['parameters', 'info']: # Skip parameters & info continue if isinstance(value, DataDict): # Handle subdicts if key not in combined_output: # New key combined_output[key] = {} # as dict for obj_type, obj_df in single_output[key].items(): if obj_type not in combined_output[key]: # New subkey combined_output[key][obj_type] = [] # as list combined_output[key][obj_type].append(obj_df) else: # Handle other output types if key not in combined_output: # New key combined_output[key] = [] # as list combined_output[key].append(value) def _combine_dataframes(self, combined_output): """ Combines data from combined output. Dataframes are combined with concat. Dicts are transformed to DataDict. Other objects are kept as original. Combined data is written to self.output. """ for key, values in combined_output.items(): if values and all([isinstance(value, pd.DataFrame) for value in values]): self.output[key] = pd.concat(values) # Df are combined elif isinstance(values, dict): # Dict is transformed to DataDict self.output[key] = DataDict() for sk, sv in values.items(): if all([isinstance(v, pd.DataFrame) for v in sv]): self.output[key][sk] = pd.concat(sv) # Df are combined else: # Other objects are kept as original TODO TESTS self.output[key][sk] = sv elif key != 'info': # Other objects are kept as original TODO TESTS self.output[key] = values def _single_sim(self, run_id): """ Perform a single simulation.""" sample_id = 0 if run_id[0] is None else run_id[0] parameters = self.sample[sample_id] model = self.model(parameters, _run_id=run_id, **self._model_kwargs) if self._random: results = model.run(display=False, seed=self._random[run_id]) else: results = model.run(display=False) if 'variables' in results and self.record is False: del results['variables'] # Remove dynamic variables from record return results # TODO AgentPy 0.2.0 - Remove pool argument def run(self, n_jobs=1, pool=None, display=True, **kwargs): """ Perform the experiment. The simulation will run the model once for each set of parameters and will repeat this process for the set number of iterations. Simulation results will be stored in `Experiment.output`. Parallel processing is supported based on :func:`joblib.Parallel`. Arguments: n_jobs (int, optional): Number of processes to run in parallel (default 1). If 1, no parallel processing is used. If -1, all CPUs are used. Will be forwarded to :func:`joblib.Parallel`. pool (multiprocessing.Pool, optional): [This argument is depreciated. Please use 'n_jobs' instead.] Pool of active processes for parallel processing. If none is passed, normal processing is used. display (bool, optional): Display simulation progress (default True). **kwargs: Additional keyword arguments for :func:`joblib.Parallel`. Returns: DataDict: Recorded experiment data. Examples: To run a normal experiment:: exp = ap.Experiment(MyModel, parameters) results = exp.run() To use parallel processing on all CPUs with status updates:: exp = ap.Experiment(MyModel, parameters) results = exp.run(n_jobs=-1, verbose=10) """ if display: n_runs = self.n_runs print(f"Scheduled runs: {n_runs}") t0 = datetime.now() # Time-Stamp Start combined_output = {} # Parallel processing with joblib if n_jobs != 1: # output_list = pool.map(self._single_sim, self.run_ids) output_list = Parallel(n_jobs=n_jobs, **kwargs)( delayed(self._single_sim)(i) for i in self.run_ids) for single_output in output_list: self._add_single_output_to_combined( single_output, combined_output) # Normal processing elif pool is None: i = -1 for run_id in self.run_ids: self._add_single_output_to_combined( self._single_sim(run_id), combined_output) if display: i += 1 td = (datetime.now() - t0).total_seconds() te = timedelta(seconds=int(td / (i + 1) * (n_runs - i - 1))) print(f"\rCompleted: {i + 1}, " f"estimated time remaining: {te}", end='') if display: print("") # Because the last print ended without a line-break # Parallel processing with multiprocessing (TODO to depreciate) else: warnings.warn( "The argument 'pool' in Experiment.run() is depreciated. " "Please use 'n_jobs' instead.") if display: print(f"Using parallel processing.") print(f"Active processes: {pool._processes}") output_list = pool.map(self._single_sim, self.run_ids) #Parallel(n_jobs=num_cores)(delayed(job)(BoidsModel,5,i) for i in tqdm(sample)) #Parallel(n_jobs=1)(delayed(sqrt)(i**2) for i in range(10)) for single_output in output_list: self._add_single_output_to_combined( single_output, combined_output) self._combine_dataframes(combined_output) self.end() self.output.info['completed'] = True self.output.info['run_time'] = ct = str(datetime.now() - t0) if display: print(f"Experiment finished\nRun time: {ct}") return self.output def end(self): """ Defines the experiment's actions after the last simulation. Can be overwritten for final calculations and reporting.""" pass ================================================ FILE: agentpy/grid.py ================================================ """ Agentpy Grid Module Content: Class for discrete spatial environments """ import itertools import numpy as np import random as rd import collections.abc as abc import numpy.lib.recfunctions as rfs from .objects import SpatialEnvironment from .tools import make_list, make_matrix, AgentpyError, ListDict from .sequences import AgentSet, AgentIter, AgentList class _IterArea: """ Iteratable object that takes either a numpy matrix or an iterable as an input. If the object is an ndarray, it is flattened and iterated over the contents of each element chained together. Otherwise, it is simply iterated over the object. Arguments: area: Area of sets of elements. exclude: Element to exclude. Assumes that element is in area. """ def __init__(self, area, exclude=None): self.area = area self.exclude = exclude def __len__(self): if isinstance(self.area, np.ndarray): len_ = sum([len(s) for s in self.area.flat]) else: len_ = len(self.area) if self.exclude: len_ -= 1 # Assumes that exclude is in Area return len_ def __iter__(self): if self.exclude: if isinstance(self.area, np.ndarray): return itertools.filterfalse( lambda x: x is self.exclude, itertools.chain.from_iterable(self.area.flat) ) else: return itertools.filterfalse( lambda x: x is self.exclude, self.area) else: if isinstance(self.area, np.ndarray): return itertools.chain.from_iterable(self.area.flat) else: return iter(self.area) class GridIter(AgentIter): """ Iterator over objects in :class:`Grid` that supports slicing. Examples: Create a model with a 10 by 10 grid with one agent in each position:: model = ap.Model() agents = ap.AgentList(model, 100) grid = ap.Grid(model, (10, 10)) grid.add_agents(agents) The following returns an iterator over the agents in all position:: >>> grid.agents GridIter (100 objects) The following returns an iterator over the agents in the top-left quarter of the grid:: >>> grid.agents[0:5, 0:5] GridIter (25 objects) """ def __init__(self, model, iter_, items): super().__init__(model, iter_) object.__setattr__(self, '_items', items) def __getitem__(self, item): sub_area = self._items[item] return GridIter(self._model, _IterArea(sub_area), sub_area) class Grid(SpatialEnvironment): """ Environment that contains agents with a discrete spatial topology, supporting multiple agents and attribute fields per cell. For a continuous spatial topology, see :class:`Space`. This class can be used as a parent class for custom grid types. All agentpy model objects call the method :func:`setup` after creation, and can access class attributes like dictionary items. Arguments: model (Model): The model instance. shape (tuple of int): Size of the grid. The length of the tuple defines the number of dimensions, and the values in the tuple define the length of each dimension. torus (bool, optional): Whether to connect borders (default False). If True, the grid will be toroidal, meaning that agents who move over a border will re-appear on the opposite side. If False, they will remain at the edge of the border. track_empty (bool, optional): Whether to keep track of empty cells (default False). If true, empty cells can be accessed via :obj:`Grid.empty`. check_border (bool, optional): Ensure that agents stay within border (default True). Can be set to False for faster performance. **kwargs: Will be forwarded to :func:`Grid.setup`. Attributes: agents (GridIter): Iterator over all agents in the grid. positions (dict of Agent): Dictionary linking each agent instance to its position. grid (numpy.rec.array): Structured numpy record array with a field 'agents' that holds an :class:`AgentSet` in each position. shape (tuple of int): Length of each dimension. ndim (int): Number of dimensions. all (list): List of all positions in the grid. empty (ListDict): List of unoccupied positions, only available if the Grid was initiated with `track_empty=True`. """ @staticmethod def _agent_field(field_name, shape, model): # Prepare structured array filled with empty agent sets array = np.empty(shape, dtype=[(field_name, object)]) it = np.nditer(array, flags=['refs_ok', 'multi_index']) for _ in it: array[it.multi_index] = AgentSet(model) return array def __init__(self, model, shape, torus=False, track_empty=False, check_border=True, **kwargs): super().__init__(model) self._track_empty = track_empty self._check_border = check_border self._torus = torus self.positions = {} self.grid = np.rec.array(self._agent_field('agents', shape, model)) self.shape = tuple(shape) self.ndim = len(self.shape) self.all = list(itertools.product(*[range(x) for x in shape])) self.empty = ListDict(self.all) if track_empty else None self._set_var_ignore() self.setup(**kwargs) @property def agents(self): return GridIter(self.model, self.positions.keys(), self.grid.agents) # Add and remove agents ------------------------------------------------- # def _add_agent(self, agent, position, field): position = tuple(position) self.grid[field][position].add(agent) # Add agent to grid self.positions[agent] = position # Add agent position to dict def add_agents(self, agents, positions=None, random=False, empty=False): """ Adds agents to the grid environment. Arguments: agents (Sequence of Agent): Iterable of agents to be added. positions (Sequence of positions, optional): The positions of the agents. Must have the same length as 'agents', with each entry being a tuple of integers. If none is passed, positions will be chosen automatically based on the arguments 'random' and 'empty': - random and empty: Random selection without repetition from `Grid.empty`. - random and not empty: Random selection with repetition from `Grid.all`. - not random and empty: Iterative selection from `Grid.empty`. - not random and not empty: Iterative selection from `Grid.all`. random (bool, optional): Whether to choose random positions (default False). empty (bool, optional): Whether to choose only empty cells (default False). Can only be True if Grid was initiated with `track_empty=True`. """ field = 'agents' if empty and not self._track_empty: raise AgentpyError( "To use 'Grid.add_agents()' with 'empty=True', " "Grid must be iniated with 'track_empty=True'.") # Choose positions if positions: pass elif random: n = len(agents) if empty: positions = self.model.random.sample(self.empty, k=n) else: positions = self.model.random.choices(self.all, k=n) else: if empty: positions = list(self.empty) # Soft copy else: positions = itertools.cycle(self.all) if empty and len(positions) < len(agents): raise AgentpyError("Cannot add more agents than empty positions.") if self._track_empty: for agent, position in zip(agents, positions): self._add_agent(agent, position, field) if position in self.empty: self.empty.remove(position) else: for agent, position in zip(agents, positions): self._add_agent(agent, position, field) def remove_agents(self, agents): """ Removes agents from the environment. """ for agent in make_list(agents): pos = self.positions[agent] # Get position self.grid.agents[pos].remove(agent) # Remove agent from grid del self.positions[agent] # Remove agent from position dict if self._track_empty: self.empty.append(pos) # Add position to free spots # Move and select agents ------------------------------------------------ # @staticmethod def _border_behavior(position, shape, torus): # Connected - Jump to other side if torus: new_position = tuple(x % x_max for x, x_max in zip(position, shape)) # Not connected - Stop at border else: new_position = tuple(np.clip(position, 0, np.array(shape)-1)) return new_position def move_to(self, agent, pos): """ Moves agent to new position. Arguments: agent (Agent): Instance of the agent. pos (tuple of int): New position of the agent. """ pos_old = self.positions[agent] if pos != pos_old: # Grid options if self._check_border: pos = self._border_behavior(pos, self.shape, self._torus) if self._track_empty: if len(self.grid.agents[pos_old]) == 1: if pos in self.empty: self.empty.replace(pos, pos_old) else: self.empty.append(pos_old) elif pos in self.empty: self.empty.remove(pos) self.grid.agents[pos_old].remove(agent) self.grid.agents[pos].add(agent) self.positions[agent] = pos def move_by(self, agent, path): """ Moves agent to new position, relative to current position. Arguments: agent (Agent): Instance of the agent. path (tuple of int): Relative change of position. """ pos = [p + c for p, c in zip(self.positions[agent], path)] self.move_to(agent, tuple(pos)) def neighbors(self, agent, distance=1): """ Select neighbors of an agent within a given distance. Arguments: agent (Agent): Instance of the agent. distance (int, optional): Number of cells to cover in each direction, including diagonally connected cells (default 1). Returns: AgentIter: Iterator over the selected neighbors. """ pos = self.positions[agent] # TODO Change method upon initiation # Case 1: Toroidal if self._torus: slices = [(p-distance, p+distance+1) for p in pos] new_slices = [] for (x_from, x_to), x_max in zip(slices, self.shape): if distance >= x_max//2 : sl_tupl = [(0, x_max)] elif x_to > x_max: sl_tupl = [(x_from, x_max), (0, x_to - x_max)] elif x_from < 0: sl_tupl = [(x_max + x_from, x_max), (0, x_to)] else: sl_tupl = [(x_from, x_to)] new_slices.append(sl_tupl) areas = [] for slices in itertools.product(*new_slices): slices = tuple(slice(*sl) for sl in slices) areas.append(self.grid.agents[slices]) # TODO Exclude in every area inefficient area_iters = [_IterArea(area, exclude=agent) for area in areas] # TODO Can only be iterated on once return AgentIter(self.model, itertools.chain.from_iterable(area_iters)) # Case 2: Non-toroidal else: slices = tuple(slice(p-distance if p-distance >= 0 else 0, p+distance+1) for p in pos) area = self.grid.agents[slices] # Iterator over all agents in area, exclude original agent return AgentIter(self.model, _IterArea(area, exclude=agent)) # Fields and attributes ------------------------------------------------- # def apply(self, func, field='agents'): """ Applies a function to each grid position, end returns an `numpy.ndarray` of return values. Arguments: func (function): Function that takes cell content as input. field (str, optional): Field to use (default 'agents'). """ return np.vectorize(func)(self.grid[field]) def attr_grid(self, attr_key, otypes='f', field='agents'): """ Returns a grid with the value of the attribute of the agent in each position, using :class:`numpy.vectorize`. Positions with no agent will contain `numpy.nan`. Should only be used for grids with zero or one agents per cell. Other kinds of attribute grids can be created with :func:`Grid.apply`. Arguments: attr_key (str): Name of the attribute. otypes (str or list of dtypes, optional): Data type of returned grid (default float). For more information, see :class:`numpy.vectorize`. field (str, optional): Field to use (default 'agents'). """ f = np.vectorize( lambda x: getattr(next(iter(x)), attr_key) if x else np.nan, otypes=otypes) return f(self.grid[field]) def add_field(self, key, values=None): """ Add an attribute field to the grid. Arguments: key (str): Name of the field. values (optional): Single value or :class:`numpy.ndarray` of values (default None). """ if not isinstance(values, (np.ndarray, list)): values = np.full(np.product(self.shape), fill_value=values) if len(values.shape) > 1: values = values.reshape(-1) # Create attribute as a numpy field self.grid = rfs.append_fields( self.grid, key, values, usemask=False, asrecarray=True ).reshape(self.grid.shape) # Create attribute as reference to field setattr(self, key, self.grid[key]) def del_field(self, key): """ Delete a attribute field from the grid. Arguments: key (str): Name of the field. """ self.grid = rfs.drop_fields( self.grid, key, usemask=False, asrecarray=True) delattr(self, key) ================================================ FILE: agentpy/model.py ================================================ """ Agentpy Model Module Content: Main class for agent-based models """ import numpy as np import pandas as pd import random from os import sys from datetime import datetime from .version import __version__ from .datadict import DataDict from .objects import Object from .network import Network from .sample import Range, Values from .grid import Grid from .space import Space from .tools import AttrDict, AgentpyError, make_list, InfoStr from .sequences import AgentList class Model(Object): """ Template of an agent-based model. Arguments: parameters (dict, optional): Dictionary of the model's parameters. Default values will be selected from entries of type :class:`Range`, :class:`IntRange`, and :class:`Values`. The following parameters will be used automatically: - steps (int, optional): Defines the maximum number of time-steps. If none is passed, there will be no step limit. - seed (int, optional): Used to initiate the model's random number generators. If none is passed, a random seed will be generated. - report_seed (bool, optional): Whether to document the random seed used (default True). **kwargs: Will be forwarded to :func:`Model.setup`. Attributes: type (str): The model's class name. info (InfoStr): Information about the model's current state. p (AttrDict): The model's parameters. t (int): Current time-step of the model. id (int): The model's object id, which will always be zero. random (random.Random): Random number generator. nprandom (numpy.random.Generator): Numpy random number generator. var_keys (list): Names of the model's custom variables. running (bool): Indicates whether the model is currently running. log (dict): The model's recorded variables. reporters (dict): The model's documented reporters. output (DataDict): Output data after a completed simulation. Examples: To define a custom model with a custom agent type:: class MyAgent(ap.Agent): def setup(self): # Initialize an attribute with a parameter self.my_attribute = self.p.my_parameter def agent_method(self): # Define custom actions here pass class MyModel(ap.Model): def setup(self): # Called at the start of the simulation self.agents = ap.AgentList(self, self.p.agents, MyAgent) def step(self): # Called at every simulation step self.agents.agent_method() # Call a method for every agent def update(self): # Called after setup as well as after each step self.agents.record('my_attribute') # Record variable def end(self): # Called at the end of the simulation self.report('my_reporter', 1) # Report a simulation result To run a simulation:: parameters = { 'my_parameter': 42, 'agents': 10, 'steps': 10 # Used automatically to define simulation length } model = MyModel(parameters) results = model.run() """ def __init__(self, parameters=None, _run_id=None, **kwargs): # Prepare parameters self.p = AttrDict() if parameters: for k, v in parameters.items(): if isinstance(v, (Range, Values)): v = v.vdef self.p[k] = v # Iniate model as model object with id 0 self._id_counter = -1 super().__init__(self) # Simulation attributes self.t = 0 self.running = False self._run_id = _run_id # Random number generators # Can be re-initiated with seed by Model.run() self.random = random.Random() self.nprandom = np.random.default_rng() # Recording self._logs = {} self.reporters = {} self.output = DataDict() self.output.info = { 'model_type': self.type, 'time_stamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 'agentpy_version': __version__, 'python_version': sys.version[:5], 'experiment': False, 'completed': False } # Private variables self._steps = None self._partly_run = False self._setup_kwargs = kwargs self._set_var_ignore() def __repr__(self): return self.type # Class Methods --------------------------------------------------------- # @classmethod def as_function(cls, **kwargs): """ Converts the model into a function that can be used with the `ema_workbench `_ library. Arguments: **kwargs: Additional keyword arguments that will passed to the model in addition to the parameters. Returns: function: The model as a function that takes parameter values as keyword arguments and returns a dictionary of reporters. """ superkwargs = kwargs def agentpy_model_as_function(**kwargs): model = cls(kwargs, **superkwargs) model.run(display=False) return model.reporters agentpy_model_as_function.__doc__ = f""" Performs a simulation of the model '{cls.__name__}'. Arguments: **kwargs: Keyword arguments with parameter values. Returns: dict: Reporters of the model. """ return agentpy_model_as_function # Properties ------------------------------------------------------------ # @property def info(self): rep = f"Agent-based model {{" items = list(self.__dict__.items()) for k, v in items: if k[0] != '_': v = v._short_repr() if '_short_repr' in dir(v) else v rep += f"\n'{k}': {v}" rep += '\n}' return InfoStr(rep) # Handling object ids --------------------------------------------------- # def _new_id(self): """ Returns a new unique object id (int). """ self._id_counter += 1 return self._id_counter # Recording ------------------------------------------------------------- # def report(self, rep_keys, value=None): """ Reports a new simulation result. Reporters are meant to be 'summary statistics' or 'evaluation measures' of the simulation as a whole, and only one value can be stored per run. In comparison, variables that are recorded with :func:`Model.record` can be recorded multiple times for each time-step and object. Arguments: rep_keys (str or list of str): Name(s) of the reporter(s) to be documented. value (int or float, optional): Value to be reported. The same value will be used for all `rep_keys`. If none is given, the values of object attributes with the same name as each rep_key will be used. Examples: Store a reporter `x` with a value `42`:: model.report('x', 42) Define a custom model that stores a reporter `sum_id` with the sum of all agent ids at the end of the simulation:: class MyModel(ap.Model): def setup(self): agents = ap.AgentList(self, self.p.agents) def end(self): self.report('sum_id', sum(self.agents.id)) Running an experiment over different numbers of agents for this model yields the following datadict of reporters:: >>> sample = ap.sample({'agents': (1, 3)}, 3) >>> exp = ap.Experiment(MyModel, sample) >>> results = exp.run() >>> results.reporters sum_id run_id 0 1 1 3 2 6 """ for rep_key in make_list(rep_keys): if value is not None: self.reporters[rep_key] = value else: self.reporters[rep_key] = getattr(self, rep_key) # Placeholder methods for custom simulation methods --------------------- # def setup(self): """ Defines the model's actions before the first simulation step. Can be overwritten to initiate agents and environments.""" pass def step(self): """ Defines the model's actions during each simulation step (excluding `t==0`). Can be overwritten to define the models' main dynamics.""" pass def update(self): """ Defines the model's actions after each simulation step (including `t==0`). Can be overwritten for the recording of dynamic variables. """ pass def end(self): """ Defines the model's actions after the last simulation step. Can be overwritten for final calculations and reporting.""" pass # Simulation routines (in line with ipysimulate) ------------------------ # def set_parameters(self, parameters): """ Adds and/or updates the parameters of the model. """ self.p.update(parameters) def sim_setup(self, steps=None, seed=None): """ Prepares time-step 0 of the simulation. Initiates (additional) steps and the two random number generators, and then calls :func:`Model.setup` and :func:`Model.update`. """ # Prepare random number generators if initial run if self._partly_run is False: if seed is None: if 'seed' in self.p: seed = self.p['seed'] # Take seed from parameters else: seed = random.getrandbits(128) if not ('report_seed' in self.p and not self.p['report_seed']): self.report('seed', seed) self.random = random.Random(seed) npseed = self.random.getrandbits(128) self.nprandom = np.random.default_rng(seed=npseed) # Prepare simulation steps if steps is None: self._steps = self.p['steps'] if 'steps' in self.p else np.nan else: self._steps = self.t + steps # Initiate simulation self.running = True self._partly_run = True # Execute setup and first update self.setup(**self._setup_kwargs) self.update() # Stop simulation if t too high if self.t >= self._steps: self.running = False def sim_step(self): """ Proceeds the simulation by one step, incrementing `Model.t` by 1 and then calling :func:`Model.step` and :func:`Model.update`.""" self.t += 1 self.step() self.update() if self.t >= self._steps: self.running = False def sim_reset(self): """ Reset model to initial conditions. """ # TODO Remove attributes self.record = super().record self.__init__(parameters=self.p, _run_id=self._run_id, **self._setup_kwargs) # Main simulation method for direct use --------------------------------- # def stop(self): """ Stops :meth:`Model.run` during an active simulation. """ self.running = False def run(self, steps=None, seed=None, display=True): """ Executes the simulation of the model. Can also be used to continue a partly-run simulation for a given number of additional steps. It starts by calling :func:`Model.run_setup` and then calls :func:`Model.run_step` until the method :func:`Model.stop` is called or `steps` is reached. After that, :func:`Model.end` and :func:`Model.create_output` are called. The simulation results can be found in :attr:`Model.output`. Arguments: steps (int, optional): Number of (additional) steps for the simulation to run. If passed, the parameter 'Model.p.steps' will be ignored. The simulation can still be stopped with :func:'Model.stop'. seed (int, optional): Seed to initialize the model's random number generators. If none is given, the parameter 'Model.p.seed' is used. If there is no such parameter, a random seed will be used. For a partly-run simulation, this argument will be ignored. display (bool, optional): Whether to display simulation progress (default True). Returns: DataDict: Recorded variables and reporters. """ dt0 = datetime.now() self.sim_setup(steps, seed) while self.running: self.sim_step() if display: print(f"\rCompleted: {self.t} steps", end='') self.end() self.create_output() self.output.info['completed'] = True self.output.info['created_objects'] = self._id_counter self.output.info['completed_steps'] = self.t self.output.info['run_time'] = ct = str(datetime.now() - dt0) if display: print(f"\nRun time: {ct}\nSimulation finished") return self.output # Data management ------------------------------------------------------- # def create_output(self): """ Generates a :class:`DataDict` with dataframes of all recorded variables and reporters, which will be stored in :obj:`Model.output`. """ def output_from_obj_list(self, log_dict, columns): # Aggregate logs per object type # Log dict structure: {obj_type: obj_id: log} obj_types = {} for obj_type, log_subdict in log_dict.items(): if obj_type not in obj_types.keys(): obj_types[obj_type] = {} for obj_id, log in log_subdict.items(): # Add object id/key to object log log['obj_id'] = [obj_id] * len(log['t']) # Add object log to aggregate log for k, v in log.items(): if k not in obj_types[obj_type]: obj_types[obj_type][k] = [] obj_types[obj_type][k].extend(v) # Transform logs into dataframes for obj_type, log in obj_types.items(): if obj_type == self.type: del log['obj_id'] index_keys = ['t'] else: index_keys = ['obj_id', 't'] df = pd.DataFrame(log) for k, v in columns.items(): df[k] = v # Set additional index columns df = df.set_index(list(columns.keys()) + index_keys) self.output['variables'][obj_type] = df # 1 - Document parameters if self.p: self.output['parameters'] = DataDict() self.output['parameters']['constants'] = self.p.copy() # 2 - Define additional index columns columns = {} if self._run_id is not None: if self._run_id[0] is not None: columns['sample_id'] = self._run_id[0] if len(self._run_id) > 1 and self._run_id[1] is not None: columns['iteration'] = self._run_id[1] # 3 - Create variable output if self._logs: self.output['variables'] = DataDict() output_from_obj_list(self, self._logs, columns) # 4 - Create reporters output if self.reporters: d = {k: [v] for k, v in self.reporters.items()} for key, value in columns.items(): d[key] = value df = pd.DataFrame(d) if columns: df = df.set_index(list(columns.keys())) self.output['reporters'] = df ================================================ FILE: agentpy/network.py ================================================ """ Agentpy Network Module """ import itertools import networkx as nx from .objects import Object from .sequences import AgentList, AgentIter, AttrIter from .tools import make_list class AgentNode(set): """ Node of :class:`Network`. Functions like a set of agents. """ # TODO Connector between AgentNode attributes and the networkx attr dict def __init__(self, label): self.label = label def __hash__(self): return id(self) def __repr__(self): return f"AgentNode ({self.label})" class Network(Object): """ Agent environment with a graph topology. Every node of the network is a :class:`AgentNode` that can hold multiple agents as well as node attributes. This class can be used as a parent class for custom network types. All agentpy model objects call the method :func:`setup` after creation, and can access class attributes like dictionary items. Arguments: model (Model): The model instance. graph (networkx.Graph, optional): The environments' graph. Can also be a DiGraph, MultiGraph, or MultiDiGraph. Nodes will be converted to :class:`AgentNode`, with their original label being kept as `AgentNode.label`. If none is passed, an empty :class:`networkx.Graph` is created. **kwargs: Will be forwarded to :func:`Network.setup`. Attributes: graph (networkx.Graph): The network's graph instance. agents (AgentIter): Iterator over the network's agents. nodes (AttrIter): Iterator over the network's nodes. """ def __init__(self, model, graph=None, **kwargs): super().__init__(model) self._i = -1 # Node label counter self.positions = {} # Agent Instance : Node reference if graph is None: self.graph = nx.Graph() else: nodes = graph.nodes self._i = len(nodes) mapping = {i: AgentNode(label=i) for i in nodes} self.graph = nx.relabel_nodes(graph, mapping=mapping) self._set_var_ignore() self.setup(**kwargs) @property def agents(self): return AgentIter(self.model, self.positions.keys()) @property def nodes(self): return AttrIter(self.graph.nodes) # Add and remove nodes -------------------------------------------------- # def add_node(self, label=None): """ Adds a new node to the network. Arguments: label (int or string, optional): Unique name of the node, which must be different from all other nodes. If none is passed, an integer number will be chosen. Returns: AgentNode: The newly created node. """ self._i += 1 if label is None: label = self._i node = AgentNode(label=label) self.graph.add_node(node) return node def remove_node(self, node): """ Removes a node from the network. Arguments: node (AgentNode): Node to be removed. """ self.remove_agents(node) self.graph.remove_node(node) # Add and remove agents ------------------------------------------------- # def add_agents(self, agents, positions=None): """ Adds agents to the network environment. Arguments: agents (Sequence of Agent): Instance or iterable of agents to be added. positions (Sequence of AgentNode, optional): The positions of the agents. Must have the same length as 'agents', with each entry being an :class:`AgentNode` of the network. If none is passed, new nodes will be created for each agent. """ if positions is None: for agent in agents: node = self.add_node() node.add(agent) self.positions[agent] = node else: for agent, node in zip(agents, positions): node.add(agent) self.positions[agent] = node def remove_agents(self, agents): """ Removes agents from the network. """ for agent in make_list(agents): self.positions[agent].remove(agent) del self.positions[agent] # Move and select agents ------------------------------------------------ # def move_to(self, agent, node): """ Moves agent to new position. Arguments: agent (Agent): Instance of the agent. node (AgentNode): New position of the agent. """ node.add(agent) self.positions[agent].remove(agent) self.positions[agent] = node def neighbors(self, agent): """ Select agents from neighboring nodes. Does not include other agents from the agents' own node. Arguments: agent (Agent): Instance of the agent. Returns: AgentIter: Iterator over the selected neighbors. """ # TODO Improve nodes = self.graph.neighbors(self.positions[agent]) return AgentIter(self.model, itertools.chain.from_iterable(nodes)) ================================================ FILE: agentpy/objects.py ================================================ """ Agentpy Objects Module Content: Base classes for agents and environment """ from .sequences import AgentList from .tools import AgentpyError, make_list class Object: """ Base class for all objects of an agent-based models. """ def __init__(self, model): self._var_ignore = [] self.id = model._new_id() # Assign id to new object self.type = type(self).__name__ self.log = {} self.model = model self.p = model.p def __repr__(self): return f"{self.type} (Obj {self.id})" def __getattr__(self, key): raise AttributeError(f"No attribute '{key}'.") def __getitem__(self, key): return getattr(self, key) def __setitem__(self, key, value): setattr(self, key, value) def _set_var_ignore(self): """Store current attributes to separate them from custom variables""" self._var_ignore = [k for k in self.__dict__.keys() if k[0] != '_'] @property def vars(self): return [k for k in self.__dict__.keys() if k[0] != '_' and k not in self._var_ignore] def record(self, var_keys, value=None): """ Records an object's variables at the current time-step. Recorded variables can be accessed via the object's `log` attribute and will be saved to the model's output at the end of a simulation. Arguments: var_keys (str or list of str): Names of the variables to be recorded. value (optional): Value to be recorded. The same value will be used for all `var_keys`. If none is given, the values of object attributes with the same name as each var_key will be used. Notes: Recording mutable objects like lists can lead to wrong results if the object's content will be changed during the simulation. Make a copy of the list or record each list entry seperately. Examples: Record the existing attributes `x` and `y` of an object `a`:: a.record(['x', 'y']) Record a variable `z` with the value `1` for an object `a`:: a.record('z', 1) Record all variables of an object:: a.record(a.vars) """ # Initial record call # Connect log to the model's dict of logs if self.type not in self.model._logs: self.model._logs[self.type] = {} self.model._logs[self.type][self.id] = self.log self.log['t'] = [self.model.t] # Initiate time dimension # Perform initial recording for var_key in make_list(var_keys): v = getattr(self, var_key) if value is None else value self.log[var_key] = [v] # Set default recording function from now on self.record = self._record # noqa def _record(self, var_keys, value=None): for var_key in make_list(var_keys): # Create empty lists if var_key not in self.log: self.log[var_key] = [None] * len(self.log['t']) if self.model.t != self.log['t'][-1]: # Create empty slot for new documented time step for v in self.log.values(): v.append(None) # Store time step self.log['t'][-1] = self.model.t if value is None: v = getattr(self, var_key) else: v = value self.log[var_key][-1] = v def setup(self, **kwargs): """This empty method is called automatically at the objects' creation. Can be overwritten in custom sub-classes to define initial attributes and actions. Arguments: **kwargs: Keyword arguments that have been passed to :class:`Agent` or :func:`Model.add_agents`. If the original setup method is used, they will be set as attributes of the object. Examples: The following setup initializes an object with three variables:: def setup(self, y): self.x = 0 # Value defined locally self.y = y # Value defined in kwargs self.z = self.p.z # Value defined in parameters """ for k, v in kwargs.items(): setattr(self, k, v) class SpatialEnvironment(Object): def record_positions(self, label='p'): """ Records the positions of each agent. Arguments: label (string, optional): Name under which to record each position (default p). A number will be added for each coordinate (e.g. p1, p2, ...). """ for agent, pos in self.positions.items(): for i, p in enumerate(pos): agent.record(label+str(i), p) ================================================ FILE: agentpy/sample.py ================================================ """ Agentpy Sampling Module Content: Sampling functions """ # TODO Latin Hypercube # TODO Random distribution samples # TODO Store meta-info for later analysis import itertools import random import numpy as np from SALib.sample import saltelli from .tools import param_tuples_to_salib, InfoStr, AgentpyError class Range: """ A range of parameter values that can be used to create a :class:`Sample`. Arguments: vmin (float, optional): Minimum value for this parameter (default 0). vmax (float, optional): Maximum value for this parameter (default 1). vdef (float, optional): Default value. Default value. If none is passed, `vmin` is used. """ def __init__(self, vmin=0, vmax=1, vdef=None): self.vmin = vmin self.vmax = vmax self.vdef = vdef if vdef else vmin self.ints = False def __repr__(self): return f"Parameter range from {self.vmin} to {self.vmax}" class IntRange(Range): """ A range of integer parameter values that can be used to create a :class:`Sample`. Similar to :class:`Range`, but sampled values will be rounded and converted to integer. Arguments: vmin (int, optional): Minimum value for this parameter (default 0). vmax (int, optional): Maximum value for this parameter (default 1). vdef (int, optional): Default value. If none is passed, `vmin` is used. """ def __init__(self, vmin=0, vmax=1, vdef=None): self.vmin = int(round(vmin)) self.vmax = int(round(vmax)) self.vdef = int(round(vdef)) if vdef else vmin self.ints = True def __repr__(self): return f"Integer parameter range from {self.vmin} to {self.vmax}" class Values: """ A pre-defined set of discrete parameter values that can be used to create a :class:`Sample`. Arguments: *args: Possible values for this parameter. vdef: Default value. If none is passed, the first passed value is used. """ def __init__(self, *args, vdef=None): self.values = args self.vdef = vdef if vdef else args[0] def __len__(self): return len(self.values) def __repr__(self): return f"Set of {len(self.values)} parameter values" class Sample: """ A sequence of parameter combinations that can be used for :class:`Experiment`. Arguments: parameters (dict): Dictionary of parameter keys and values. Entries of type :class:`Range` and :class:`Values` will be sampled based on chosen `method` and `n`. Other types wil be interpreted as constants. n (int, optional): Sampling factor used by chosen `method` (default None). method (str, optional): Method to use to create parameter combinations from entries of type :class:`Range`. Options are: - ``linspace`` (default): Arange `n` evenly spaced values for each :class:`Range` and combine them with given :class:`Values` and constants. Additional keyword arguments: - ``product`` (bool, optional): Return all possible combinations (default True). If False, value sets are 'zipped' so that the i-th parameter combination contains the i-th entry of each value set. Requires all value sets to have the same length. - ``saltelli``: Apply Saltelli's sampling scheme, using :func:`SALib.sample.saltelli.sample` with `N=n`. This enables the analysis of Sobol Sensitivity Indices with :func:`DataDict.calc_sobol` after the experiment. Additional keyword arguments: - ``calc_second_order`` (bool, optional): Whether to calculate second-order indices (default True). randomize (bool, optional): Whether to use the constant parameter 'seed' to generate different random seeds for every parameter combination (default True). If False, every parameter combination will have the same seed. If there is no constant parameter 'seed', this option has no effect. **kwargs: Additional keyword arguments for chosen `method`. """ def __init__(self, parameters, n=None, method='linspace', randomize=True, **kwargs): self._log = {'type': method, 'n': n, 'randomized': False} self._sample = getattr(self, f"_{method}")(parameters, n, **kwargs) if 'seed' in parameters and randomize: ranges = (Range, IntRange, Values) if not isinstance(parameters['seed'], ranges): seed = parameters['seed'] self._log['randomized'] = True self._log['seed'] = seed self._assign_random_seeds(seed) def __repr__(self): return f"Sample of {len(self)} parameter combinations" def __iter__(self): return iter(self._sample) def __len__(self): return len(self._sample) # Sampling methods ------------------------------------------------------ # def _assign_random_seeds(self, seed): rng = random.Random(seed) for parameters in self._sample: parameters['seed'] = rng.getrandbits(128) @staticmethod def _linspace(parameters, n, product=True): params = {} for k, v in parameters.items(): if isinstance(v, Range): if n is None: raise AgentpyError( "Argument 'n' must be defined for Sample " "if there are parameters of type Range.") if v.ints: p_range = np.linspace(v.vmin, v.vmax+1, n) p_range = [int(pv)-1 if pv == v.vmax+1 else int(pv) for pv in p_range] else: p_range = np.linspace(v.vmin, v.vmax, n) params[k] = p_range elif isinstance(v, Values): params[k] = v.values else: params[k] = [v] if product: # All possible combinations combos = list(itertools.product(*params.values())) sample = [{k: v for k, v in zip(params.keys(), c)} for c in combos] else: # Parallel combinations (index by index) r = range(min([len(v) for v in params.values()])) sample = [{k: v[i] for k, v in params.items()} for i in r] return sample def _saltelli(self, params, n, calc_second_order=True): # STEP 0 - Find variable parameters and check type param_ranges_tuples = {} for k, v in params.items(): if isinstance(v, Range): if v.ints: # Integer conversion rounds down, +1 includes last integer param_ranges_tuples[k] = (v.vmin, v.vmax+1) else: param_ranges_tuples[k] = (v.vmin, v.vmax) elif isinstance(v, Values): param_ranges_tuples[k] = (0, len(v)) # STEP 1 - Convert param_ranges to SALib Format param_ranges_salib = param_tuples_to_salib(param_ranges_tuples) # STEP 2 - Create SALib Sample salib_sample = saltelli.sample(param_ranges_salib, n, calc_second_order) # STEP 3 - Convert back to Agentpy Parameter Dict List and adjust values ap_sample = [] for param_instance in salib_sample: parameters = {} parameters.update(params) for i, key in enumerate(param_ranges_tuples.keys()): p = param_instance[i] # Convert to integer if isinstance(params[key], Range) and params[key].ints: p = int(p) - 1 if p == params[key].vmax+1 else int(p) # Convert to value if isinstance(params[key], Values): p = int(p) - 1 if p == len(params[key]) else int(p) p = params[key].values[p] # Find value parameters[key] = p ap_sample.append(parameters) # STEP 4 - Log self._log['salib_problem'] = param_ranges_salib self._log['calc_second_order'] = calc_second_order return ap_sample ================================================ FILE: agentpy/sequences.py ================================================ """ Agentpy Lists Module Content: Lists for objects, environments, and agents """ import itertools import agentpy as ap import numpy as np from .tools import AgentpyError, ListDict from collections.abc import Sequence class AgentSequence: """ Base class for agenpty sequences. """ def __repr__(self): len_ = len(list(self)) s = 's' if len_ != 1 else '' return f"{type(self).__name__} ({len_} object{s})" def __getattr__(self, name): """ Return callable list of attributes """ if name[0] == '_': # Private variables are looked up normally # Gives numpy conversion correct error for __array_struct__ lookup super().__getattr__(name) else: return AttrIter(self, attr=name) def _set(self, key, value): object.__setattr__(self, key, value) @staticmethod def _obj_gen(model, n, cls, *args, **kwargs): """ Generate objects for sequence. """ if cls is None: cls = ap.Agent if args != tuple(): raise AgentpyError( "Sequences no longer accept extra arguments without a keyword." f" Please assign a keyword to the following arguments: {args}") for i in range(n): # AttrIter values get broadcasted among agents i_kwargs = {k: arg[i] if isinstance(arg, AttrIter) else arg for k, arg in kwargs.items()} yield cls(model, **i_kwargs) # Attribute List ------------------------------------------------------------ # class AttrIter(AgentSequence, Sequence): """ Iterator over an attribute of objects in a sequence. Length, items access, and representation work like with a normal list. Calls are forwarded to each entry and return a list of return values. Boolean operators are applied to each entry and return a list of bools. Arithmetic operators are applied to each entry and return a new list. If applied to another `AttrList`, the first entry of the first list will be matched with the first entry of the second list, and so on. Else, the same value will be applied to each entry of the list. See :class:`AgentList` for examples. """ def __init__(self, source, attr=None): self.source = source self.attr = attr def __repr__(self): return repr(list(self)) @staticmethod def _iter_attr(a, s): for o in s: yield getattr(o, a) def __iter__(self): """ Iterate through source list based on attribute. """ if self.attr: return self._iter_attr(self.attr, self.source) else: return iter(self.source) def __len__(self): return len(self.source) def __getitem__(self, key): """ Get item from source list. """ if self.attr: return getattr(self.source[key], self.attr) else: return self.source[key] def __setitem__(self, key, value): """ Set item to source list. """ if self.attr: setattr(self.source[key], self.attr, value) else: self.source[key] = value def __call__(self, *args, **kwargs): return AttrIter([func_obj(*args, **kwargs) for func_obj in self]) def __eq__(self, other): return [obj == other for obj in self] def __ne__(self, other): return [obj != other for obj in self] def __lt__(self, other): return [obj < other for obj in self] def __le__(self, other): return [obj <= other for obj in self] def __gt__(self, other): return [obj > other for obj in self] def __ge__(self, other): return [obj >= other for obj in self] def __add__(self, v): if isinstance(v, AttrIter): return AttrIter([x + y for x, y in zip(self, v)]) else: return AttrIter([x + v for x in self]) def __sub__(self, v): if isinstance(v, AttrIter): return AttrIter([x - y for x, y in zip(self, v)]) else: return AttrIter([x - v for x in self]) def __mul__(self, v): if isinstance(v, AttrIter): return AttrIter([x * y for x, y in zip(self, v)]) else: return AttrIter([x * v for x in self]) def __truediv__(self, v): if isinstance(v, AttrIter): return AttrIter([x / y for x, y in zip(self, v)]) else: return AttrIter([x / v for x in self]) def __iadd__(self, v): return self + v def __isub__(self, v): return self - v def __imul__(self, v): return self * v def __itruediv__(self, v): return self / v # Object Containers --------------------------------------------------------- # def _random(model, gen, obj_list, n=1, replace=False): """ Creates a random sample of agents. Arguments: n (int, optional): Number of agents (default 1). replace (bool, optional): Select with replacement (default False). If True, the same agent can be selected more than once. Returns: AgentIter: The selected agents. """ if n == 1: selection = [gen.choice(obj_list)] elif replace is False: selection = gen.sample(obj_list, k=n) else: selection = gen.choices(obj_list, k=n) return AgentIter(model, selection) class AgentList(AgentSequence, list): """ List of agentpy objects. Attribute calls and assignments are applied to all agents and return an :class:`AttrIter` with the attributes of each agent. This also works for method calls, which returns a list of return values. Arithmetic operators can further be used to manipulate agent attributes, and boolean operators can be used to filter the list based on agents' attributes. Standard :class:`list` methods can also be used. Arguments: model (Model): The model instance. objs (int or Sequence, optional): An integer number of new objects to be created, or a sequence of existing objects (default empty). cls (type, optional): Class for the creation of new objects. **kwargs: Keyword arguments are forwarded to the constructor of the new objects. Keyword arguments with sequences of type :class:`AttrIter` will be broadcasted, meaning that the first value will be assigned to the first object, the second to the second, and so forth. Otherwise, the same value will be assigned to all objects. Examples: Prepare an :class:`AgentList` with three agents:: >>> model = ap.Model() >>> agents = model.add_agents(3) >>> agents AgentList [3 agents] The assignment operator can be used to set a variable for each agent. When the variable is called, an :class:`AttrList` is returned:: >>> agents.x = 1 >>> agents.x AttrList of 'x': [1, 1, 1] One can also set different variables for each agent by passing another :class:`AttrList`:: >>> agents.y = ap.AttrIter([1, 2, 3]) >>> agents.y AttrList of 'y': [1, 2, 3] Arithmetic operators can be used in a similar way. If an :class:`AttrList` is passed, different values are used for each agent. Otherwise, the same value is used for all agents:: >>> agents.x = agents.x + agents.y >>> agents.x AttrList of 'x': [2, 3, 4] >>> agents.x *= 2 >>> agents.x AttrList of 'x': [4, 6, 8] Attributes of specific agents can be changed through setting items:: >>> agents.x[2] = 10 >>> agents.x AttrList of 'x': [4, 6, 10] Boolean operators can be used to select a subset of agents:: >>> subset = agents(agents.x > 5) >>> subset AgentList [2 agents] >>> subset.x AttrList of attribute 'x': [6, 8] """ def __init__(self, model, objs=(), cls=None, *args, **kwargs): if isinstance(objs, int): objs = self._obj_gen(model, objs, cls, *args, **kwargs) super().__init__(objs) super().__setattr__('model', model) super().__setattr__('ndim', 1) def __setattr__(self, name, value): if isinstance(value, AttrIter): # Apply each value to each agent for obj, v in zip(self, value): setattr(obj, name, v) else: # Apply single value to all agents for obj in self: setattr(obj, name, value) def __add__(self, other): agents = AgentList(self.model, self) agents.extend(other) return agents def select(self, selection): """ Returns a new :class:`AgentList` based on `selection`. Arguments: selection (list of bool): List with same length as the agent list. Positions that return True will be selected. """ return AgentList(self.model, [a for a, s in zip(self, selection) if s]) def random(self, n=1, replace=False): """ Creates a random sample of agents. Arguments: n (int, optional): Number of agents (default 1). replace (bool, optional): Select with replacement (default False). If True, the same agent can be selected more than once. Returns: AgentIter: The selected agents. """ return _random(self.model, self.model.random, self, n, replace) def sort(self, var_key, reverse=False): """ Sorts the list in-place, and returns self. Arguments: var_key (str): Attribute of the lists' objects, based on which the list will be sorted from lowest value to highest. reverse (bool, optional): Reverse sorting (default False). """ super().sort(key=lambda x: x[var_key], reverse=reverse) return self def shuffle(self): """ Shuffles the list in-place, and returns self. """ self.model.random.shuffle(self) return self class AgentDList(AgentSequence, ListDict): """ Ordered collection of agentpy objects. This container behaves similar to :class:`AgentList` in most aspects, but comes with additional features for object removal and lookup. The key differences to :class:`AgentList` are the following: - Faster removal of objects. - Faster lookup if object is part of group. - No duplicates are allowed. - The order of agents in the group cannot be changed. - Removal of agents changes the order of the group. - :func:`AgentDList.buffer` makes it possible to remove objects from the group while iterating over the group. - :func:`AgentDList.shuffle` returns an iterator instead of shuffling in-place. Arguments: model (Model): The model instance. objs (int or Sequence, optional): An integer number of new objects to be created, or a sequence of existing objects (default empty). cls (type, optional): Class for the creation of new objects. **kwargs: Keyword arguments are forwarded to the constructor of the new objects. Keyword arguments with sequences of type :class:`AttrIter` will be broadcasted, meaning that the first value will be assigned to the first object, the second to the second, and so forth. Otherwise, the same value will be assigned to all objects. """ def __init__(self, model, objs=(), cls=None, *args, **kwargs): if isinstance(objs, int): objs = self._obj_gen(model, objs, cls, *args, **kwargs) self._set('model', model) self._set('ndim', 1) self._set('items', []) self._set('item_to_position', {}) self.model = model self.item_to_position = {} self.items = [] for obj in objs: self.append(obj) def __setattr__(self, name, value): if isinstance(value, AttrIter): # Apply each value to each agent for obj, v in zip(self, value): setattr(obj, name, v) else: # Apply single value to all agents for obj in self: setattr(obj, name, value) def __add__(self, other): agents = AgentDList(self.model, self) agents.extend(other) return agents def random(self, n=1, replace=False): """ Creates a random sample of agents. Arguments: n (int, optional): Number of agents (default 1). replace (bool, optional): Select with replacement (default False). If True, the same agent can be selected more than once. Returns: AgentIter: The selected agents. """ return _random(self.model, self.model.random, self.items, n, replace) def select(self, selection): """ Returns a new :class:`AgentList` based on `selection`. Arguments: selection (list of bool): List with same length as the agent list. Positions that return True will be selected. """ return AgentList( self.model, [a for a, s in zip(self.items, selection) if s]) def sort(self, var_key, reverse=False): """ Returns a new sorted :class:`AgentList`. Arguments: var_key (str): Attribute of the lists' objects, based on which the list will be sorted from lowest value to highest. reverse (bool, optional): Reverse sorting (default False). """ agentlist = AgentList(self.model, self) agentlist.sort(var_key=var_key, reverse=reverse) return agentlist def shuffle(self): """ Return :class:`AgentIter` over the content of the group with the order of objects being shuffled. """ return AgentDListIter(self.model, self, shuffle=True) def buffer(self): """ Return :class:`AgentIter` over the content of the group that supports deletion of objects from the group during iteration. """ return AgentDListIter(self.model, self, buffer=True) class AgentSet(AgentSequence, set): """ Unordered collection of agentpy objects. Arguments: model (Model): The model instance. objs (int or Sequence, optional): An integer number of new objects to be created, or a sequence of existing objects (default empty). cls (type, optional): Class for the creation of new objects. **kwargs: Keyword arguments are forwarded to the constructor of the new objects. Keyword arguments with sequences of type :class:`AttrIter` will be broadcasted, meaning that the first value will be assigned to the first object, the second to the second, and so forth. Otherwise, the same value will be assigned to all objects. """ def __init__(self, model, objs=(), cls=None, *args, **kwargs): if isinstance(objs, int): objs = self._obj_gen(model, objs, cls, *args, **kwargs) super().__init__(objs) super().__setattr__('model', model) super().__setattr__('ndim', 1) class AgentIter(AgentSequence): """ Iterator over agentpy objects. """ def __init__(self, model, source=()): object.__setattr__(self, '_model', model) object.__setattr__(self, '_source', source) def __getitem__(self, item): raise AgentpyError( 'AgentIter has to be converted to list for item lookup.') def __iter__(self): return iter(self._source) def __len__(self): return len(self._source) def __setattr__(self, name, value): if isinstance(value, AttrIter): # Apply each value to each agent for obj, v in zip(self, value): setattr(obj, name, v) else: # Apply single value to all agents for obj in self: setattr(obj, name, value) def to_list(self): """Returns an :class:`AgentList` of the iterator. """ return AgentList(self._model, self) def to_dlist(self): """Returns an :class:`AgentDList` of the iterator. """ return AgentDList(self._model, self) class AgentDListIter(AgentIter): """ Iterator over agentpy objects in an :class:`AgentDList`. """ def __init__(self, model, source=(), shuffle=False, buffer=False): object.__setattr__(self, '_model', model) object.__setattr__(self, '_source', source) object.__setattr__(self, '_shuffle', shuffle) object.__setattr__(self, '_buffer', buffer) def __iter__(self): if self._buffer: return self._buffered_iter() elif self._shuffle: items = self._source.items.copy() self._model.random.shuffle(items) return iter(items) else: return iter(self._source) def buffer(self): object.__setattr__(self, '_buffer', True) return self def shuffle(self): object.__setattr__(self, '_shuffle', True) return self def _buffered_iter(self): """ Iterate over source. """ items = self._source.items.copy() if self._shuffle: self._model.random.shuffle(items) for a in items: if a in self._source: yield a ================================================ FILE: agentpy/space.py ================================================ """ Agentpy Space Module Content: Class for continuous spatial environments """ # TODO Add option of space without shape (infinite) # TODO Custom iterator for neighbors() & select() for performance import itertools import numpy as np import random as rd import collections.abc as abc from scipy import spatial from .objects import SpatialEnvironment from .tools import make_list, make_matrix from .sequences import AgentList, AgentIter class Space(SpatialEnvironment): """ Environment that contains agents with a continuous spatial topology. To add new space environments to a model, use :func:`Model.add_space`. For a discrete spatial topology, see :class:`Grid`. This class can be used as a parent class for custom space types. All agentpy model objects call the method :func:`setup` after creation, and can access class attributes like dictionary items. Arguments: model (Model): The model instance. shape (tuple of float): Size of the space. The length of the tuple defines the number of dimensions, and the values in the tuple define the length of each dimension. torus (bool, optional): Whether to connect borders (default False). If True, the space will be toroidal, meaning that agents who move over a border will re-appear on the opposite side. If False, they will remain at the edge of the border. **kwargs: Will be forwarded to :func:`Space.setup`. Attributes: agents (AgentIter): Iterator over all agents in the space. positions (dict of Agent): Dictionary linking each agent instance to its position. shape (tuple of float): Length of each spatial dimension. ndim (int): Number of dimensions. kdtree (scipy.spatial.cKDTree or None): KDTree of agent positions for neighbor lookup. Will be recalculated if agents have moved. If there are no agents, tree is None. """ def __init__(self, model, shape, torus=False, **kwargs): super().__init__(model) self._torus = torus self._cKDTree = None self._sorted_agents = None self._sorted_agent_points = None self.positions = {} self.shape = tuple(shape) self.ndim = len(self.shape) self._set_var_ignore() self.setup(**kwargs) @property def agents(self): return AgentIter(self.model, self.positions.keys()) @property def kdtree(self): # Create new KDTree if necessary if self._cKDTree is None and len(self.agents) > 0: self._sorted_agents = [] self._sorted_agent_points = [] for a in self.agents: self._sorted_agents.append(a) self._sorted_agent_points.append(self.positions[a]) if self._torus: self._cKDTree = spatial.cKDTree(self._sorted_agent_points, boxsize=self.shape) else: self._cKDTree = spatial.cKDTree(self._sorted_agent_points) return self._cKDTree # Return existing or new KDTree # Add and remove agents ------------------------------------------------- # def add_agents(self, agents, positions=None, random=False): """ Adds agents to the space environment. Arguments: agents (Sequence of Agent): Instance or iterable of agents to be added. positions (Sequence of positions, optional): The positions of the agents. Must have the same length as 'agents', with each entry being a position (array of float). If none is passed, all positions will be either be zero or random based on the argument 'random'. random (bool, optional): Whether to choose random positions (default False). """ self._cKDTree = None # Reset KDTree if not positions: n_agents = len(agents) if random: positions = [[self.model.random.random() * d_max for d_max in self.shape] for _ in range(n_agents)] else: positions = [np.zeros(self.ndim) for _ in range(n_agents)] for agent, pos in zip(agents, positions): pos = pos if isinstance(pos, np.ndarray) else np.array(pos) self.positions[agent] = pos # Add pos to agent_dict def remove_agents(self, agents): """ Removes agents from the space. """ self._cKDTree = None # Reset KDTree for agent in make_list(agents): del self.positions[agent] # Remove agent from env # Move and select agents ------------------------------------------------ # @staticmethod def _border_behavior(position, shape, torus): # Border behavior # Connected - Jump to other side if torus: for i in range(len(position)): while position[i] > shape[i]: position[i] -= shape[i] while position[i] < 0: position[i] += shape[i] # Not connected - Stop at border else: for i in range(len(position)): if position[i] > shape[i]: position[i] = shape[i] elif position[i] < 0: position[i] = 0 def move_to(self, agent, pos): """ Moves agent to new position. Arguments: agent (Agent): Instance of the agent. pos (array_like): New position of the agent. """ self._cKDTree = None # Reset KDTree self._border_behavior(pos, self.shape, self._torus) self.positions[agent][...] = pos # In-place def move_by(self, agent, path): """ Moves agent to new position, relative to current position. Arguments: agent (Agent): Instance of the agent. path (array_like): Relative change of position. """ pos = [p + c for p, c in zip(self.positions[agent], path)] self.move_to(agent, pos) def neighbors(self, agent, distance): """ Select agent neighbors within a given distance. Takes into account wether space is toroidal. Arguments: agent (Agent): Instance of the agent. distance (float): Radius around the agent in which to search for neighbors. Returns: AgentIter: Iterator over the selected neighbors. """ list_ids = self.kdtree.query_ball_point( self.positions[agent], distance) agents = [self._sorted_agents[list_id] for list_id in list_ids] agents.remove(agent) # Remove original agent return AgentIter(self.model, agents) def select(self, center, radius): """ Select agents within a given area. Arguments: center (array_like): Coordinates of the center of the search area. radius (float): Radius around the center in which to search. Returns: AgentIter: Iterator over the selected agents. """ if self.kdtree: list_ids = self.kdtree.query_ball_point(center, radius) agents = [self._sorted_agents[list_id] for list_id in list_ids] return AgentIter(self.model, agents) else: return AgentIter(self.model) ================================================ FILE: agentpy/tools.py ================================================ """ Agentpy Tools Module Content: Errors, generators, and base classes """ from numpy import ndarray from collections.abc import Sequence class AgentpyError(Exception): pass def make_none(*args, **kwargs): return None class InfoStr(str): """ String that is displayed in user-friendly format. """ def __repr__(self): return self def make_matrix(shape, loc_type=make_none, list_type=list, pos=None): """ Returns a nested list with given shape and class instance. """ if pos is None: pos = () if len(shape) == 1: return list_type([loc_type(pos+(i,)) for i in range(shape[0])]) return list_type([make_matrix(shape[1:], loc_type, list_type, pos+(i,)) for i in range(shape[0])]) def make_list(element, keep_none=False): """ Turns element into a list of itself if it is not of type list or tuple. """ if element is None and not keep_none: element = [] # Convert none to empty list if not isinstance(element, (list, tuple, set, ndarray)): element = [element] elif isinstance(element, (tuple, set)): element = list(element) return element def param_tuples_to_salib(param_ranges_tuples): """ Convert param_ranges to SALib Format """ param_ranges_salib = { 'num_vars': len(param_ranges_tuples), 'names': list(param_ranges_tuples.keys()), 'bounds': [] } for var_key, var_range in param_ranges_tuples.items(): param_ranges_salib['bounds'].append([var_range[0], var_range[1]]) return param_ranges_salib class AttrDict(dict): """ Dictionary where attribute calls are handled like item calls. Examples: >>> ad = ap.AttrDict() >>> ad['a'] = 1 >>> ad.a 1 >>> ad.b = 2 >>> ad['b'] 2 """ def __init__(self, *args, **kwargs): if args == (None, ): args = () # Empty tuple super().__init__(*args, **kwargs) def __getattr__(self, name): try: return self.__getitem__(name) except KeyError: # Important for pickle to work raise AttributeError(name) def __setattr__(self, name, value): self.__setitem__(name, value) def __delattr__(self, item): del self[item] def _short_repr(self): len_ = len(self.keys()) return f"AttrDict ({len_} entr{'y' if len_ == 1 else 'ies'})" class ListDict(Sequence): """ List with fast deletion & lookup. """ # H/T Amber https://stackoverflow.com/a/15993515/14396787 def __init__(self, iterable): self.item_to_position = {} self.items = [] for item in iterable: self.append(item) def __iter__(self): return iter(self.items) def __len__(self): return len(self.items) def __getitem__(self, item): return self.items[item] def __contains__(self, item): return item in self.item_to_position def extend(self, seq): for s in seq: self.append(s) def append(self, item): if item in self.item_to_position: return self.items.append(item) self.item_to_position[item] = len(self.items)-1 def replace(self, old_item, new_item): position = self.item_to_position.pop(old_item) self.item_to_position[new_item] = position self.items[position] = new_item def remove(self, item): position = self.item_to_position.pop(item) last_item = self.items.pop() if position != len(self.items): self.items[position] = last_item self.item_to_position[last_item] = position def pop(self, index): """ Remove an object from the group by index. """ self.remove(self[index]) ================================================ FILE: agentpy/version.py ================================================ try: from importlib import metadata except ImportError: # Running on pre-3.8 Python import importlib_metadata as metadata # noqa __version__ = metadata.version('agentpy') ================================================ FILE: agentpy/visualization.py ================================================ """ Agentpy Visualization Module Content: Animations and Gridplot """ import numpy as np import pandas as pd import matplotlib.pyplot as plt from matplotlib.colors import to_rgba from matplotlib.animation import FuncAnimation from SALib.analyze import sobol from .tools import make_list, param_tuples_to_salib def animate(model, fig, axs, plot, steps=None, seed=None, skip=0, fargs=(), **kwargs): """ Returns an animation of the model simulation, using :func:`matplotlib.animation.FuncAnimation`. Arguments: model (Model): The model instance. fig (matplotlib.figure.Figure): Figure for the animation. axs (matplotlib.axes.Axes or list): Axis or list of axis of the figure. plot (function): Function that takes the arguments `model, axs, *fargs` and creates the desired plots on each axis at each time-step. steps(int, optional): Number of (additional) steps for the simulation to run. If passed, the parameter 'Model.p.steps' will be ignored. The simulation can still be stopped with :func:'Model.stop'. If there is no step-limit through either this argument or the parameter 'Model.p.steps', the animation will stop at t=10000. seed (int, optional): Seed for the models random number generators. If none is given, the parameter 'Model.p.seed' will be used. If there is no such parameter, a random seed will be used. skip (int, optional): Steps to skip before the animation starts (default 0). fargs (tuple, optional): Forwarded fo the `plot` function. **kwargs: Forwarded to :func:`matplotlib.animation.FuncAnimation`. Examples: An animation can be generated as follows:: def my_plot(model, ax): pass # Call pyplot functions here fig, ax = plt.subplots() my_model = MyModel(parameters) animation = ap.animate(my_model, fig, ax, my_plot) One way to display the resulting animation object in Jupyter:: from IPython.display import HTML HTML(animation.to_jshtml()) """ model.sim_setup(steps, seed) model.create_output() pre_steps = 0 for _ in range(skip): model.sim_step() def frames(): nonlocal model, pre_steps if model.running is True: while model.running: if pre_steps < 2: # Frames iterates twice before starting plot pre_steps += 1 else: model.sim_step() model.create_output() yield model.t else: # Yield current if model stops before the animation starts yield model.t def update(t, m, axs, *fargs): # noqa nonlocal pre_steps for ax in make_list(axs): # Clear axes before each plot ax.clear() plot(m, axs, *fargs) # Perform plot save_count = 10000 if model._steps is np.nan else model._steps + 1 ani = FuncAnimation( fig, update, frames=frames, fargs=(model, axs, *fargs), save_count=save_count, # Limits animation to 100 steps otherwise **kwargs) # noqa plt.close() # Don't display static plot return ani def _apply_colors(grid, color_dict, convert): if color_dict is not None: if None in color_dict: def func(v): return color_dict[None] if np.isnan(v) else color_dict[v] else: def func(v): return np.nan if np.isnan(v) else color_dict[v] grid = np.vectorize(func)(grid) if convert is True: def func(v): # TODO Can be improved if isinstance(v, str): if v == 'nan': return 0., 0., 0., 0. else: return to_rgba(v) elif np.isnan(v): return 0., 0., 0., 0. else: return to_rgba(v) grid = np.vectorize(func)(grid) grid = np.moveaxis(grid, 0, 2) return grid def gridplot(grid, color_dict=None, convert=False, ax=None, **kwargs): """ Visualizes values on a two-dimensional grid with :func:`matplotlib.pyplot.imshow`. Arguments: grid (numpy.array): Two-dimensional array with values. numpy.nan values will be plotted as empty patches. color_dict (dict, optional): Dictionary that translates each value in `grid` to a color specification. If there is an entry `None`, it will be used for all NaN values. convert (bool, optional): Convert values to rgba vectors, using :func:`matplotlib.colors.to_rgba` (default False). ax (matplotlib.pyplot.axis, optional): Axis to be used for plot. **kwargs: Forwarded to :func:`matplotlib.pyplot.imshow`. Returns: :class:`matplotlib.image.AxesImage` """ # TODO Make feature for legend if color_dict is not None or convert: grid = _apply_colors(grid, color_dict, convert) if ax: im = ax.imshow(grid, **kwargs) else: im = plt.imshow(grid, **kwargs) return im ================================================ FILE: docs/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: docs/_static/css/custom.css ================================================ /* Increase max width */ .wy-nav-content { max-width: 850px !important; } /* For alignment of jshtml animations */ .anim-state label { display:unset !important } ================================================ FILE: docs/about.rst ================================================ .. currentmodule:: agentpy ===== About ===== Agentpy has been created by Joël Foramitti and is available under the open-source `BSD 3-Clause `_ license. Source files can be found on the `GitHub repository `_. Thanks to everyone who has contributed or supported the developement of this package: - Jeroen C.J.M. van den Bergh - Ivan Savin - James Millington - Martí Bosch - Sebastian Benthall - Bhakti Stephan Onggo This project has benefited from an ERC Advanced Grant from the European Research Council (ERC) under the European Union's Horizon 2020 research and innovation programme (grant agreement n° 741087). Parts of this package where created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. .. _Cookiecutter: https://github.com/audreyr/cookiecutter .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage ================================================ FILE: docs/agentpy_button_network.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Button network\n", "\n", "This notebook presents an agent-based model of randomly connecting buttons.\n", "It demonstrates how to use the [agentpy](https://agentpy.readthedocs.io) package\n", "to work with networks and visualize averaged time-series for discrete parameter samples." ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "# Model design\n", "import agentpy as ap\n", "import networkx as nx\n", "\n", "# Visualization\n", "import seaborn as sns" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## About the model\n", "\n", "This model is based on the [Agentbase Button model](http://agentbase.org/model.html?f4c4388138450bdf9732) by Wybo Wiersma and the following analogy from [Stuart Kauffman](http://www.pbs.org/lifebeyondearth/resources/intkauffmanpop.html): \n", "\n", "> \"Suppose you take 10,000 buttons and spread them out on a hardwood floor. You have a large spool of red thread. Now, what you do is you pick up a random pair of buttons and you tie them together with a piece of red thread. Put them down and pick up another random pair of buttons and tie them together with a red thread, and you just keep doing this. Every now and then lift up a button and see how many buttons you've lifted with your first button. A connective cluster of buttons is called a cluster or a component. When you have 10,000 buttons and only a few threads that tie them together, most of the times you'd pick up a button you'll pick up a single button. \n", ">\n", ">As the ratio of threads to buttons increases, you're going to start to get larger clusters, three or four buttons tied together; then larger and larger clusters. At some point, you will have a number of intermediate clusters, and when you add a few more threads, you'll have linked up the intermediate-sized clusters into one giant cluster.\n", ">\n", ">So that if you plot on an axis, the ratio of threads to buttons: 10,000 buttons and no threads; 10,000 buttons and 5,000 threads; and so on, you'll get a curve that is flat, and then all of a sudden it shoots up when you get this giant cluster. This steep curve is in fact evidence of a phase transition.\n", ">\n", ">If there were an infinite number of threads and an infinite number of buttons and one just tuned the ratios, this would be a step function; it would come up in a sudden jump. So it's a phase transition like ice freezing.\n", ">\n", ">Now, the image you should take away from this is if you connect enough buttons all of a sudden they all go connected. To think about the origin of life, we have to think about the same thing.\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Model definition" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "class ButtonModel(ap.Model):\n", " \n", " def setup(self):\n", " \n", " # Create a graph with n agents\n", " self.buttons = ap.Network(self)\n", " self.agents = ap.AgentList(self, self.p.n)\n", " self.buttons.add_agents(self.agents)\n", " self.agents.node = self.buttons.nodes\n", " self.threads = 0\n", " \n", " def update(self):\n", " \n", " # Record size of the biggest cluster\n", " clusters = nx.connected_components(self.buttons.graph)\n", " max_cluster_size = max([len(g) for g in clusters]) / self.p.n\n", " self.record('max_cluster_size', max_cluster_size)\n", " \n", " # Record threads to button ratio\n", " self.record('threads_to_button', self.threads / self.p.n)\n", " \n", " def step(self):\n", " \n", " # Create random edges based on parameters\n", " for _ in range(int(self.p.n * self.p.speed)): \n", " self.buttons.graph.add_edge(*self.agents.random(2).node)\n", " self.threads += 1 " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Multi-run experiment" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 75\n", "Completed: 75, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:36.012666\n" ] } ], "source": [ "# Define parameter ranges\n", "parameter_ranges = {\n", " 'steps': 30, # Number of simulation steps\n", " 'speed': 0.05, # Speed of connections per step\n", " 'n': ap.Values(100, 1000, 10000) # Number of agents\n", "}\n", "\n", "# Create sample for different values of n\n", "sample = ap.Sample(parameter_ranges) \n", "\n", "# Keep dynamic variables\n", "exp = ap.Experiment(ButtonModel, sample, iterations=25, record=True) \n", "\n", "# Perform 75 separate simulations (3 parameter combinations * 25 repetitions)\n", "results = exp.run() " ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "image/png": "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hBD1kiQhhBCiXpIkhBBC1EuShIhqu3btonfv3s22RPZP3XPPPTzxxBMt8rNrO+ecc/j8889bOgzRDkmSEFGnLb0hRsNzOVSii4a4ROsgSUK0KS3V4hCirZIkIaLKb3/7W3bv3s1NN93EgAEDePfddwGYP38+Z511FkOGDOG5556rPv8vf/kLt99+O7/5zW849dRTeeuttygpKeG+++5j2LBhDB8+nCeeeALTNAHYsWMHV199NUOGDGHIkCH8+te/pri4uPrx1q1bx4UXXsiAAQO444478Pv91ccKCwv5xS9+waBBgxg8eDCTJk3CsqwGP5cXX3wRgI8++ojzzjuPQYMGcdVVV7F58+YG/W7Wrl3L2LFjOe2007j33nurY/v3v//N5ZdfXufc3r17s337dv7f//t/zJ8/n5deeokBAwZw0003HVVc55xzDi+99BLjxo1j4MCBB/1uRBvW0uuCCPFTZ599tvrss8+UUkrt3LlT9erVS/3+979XFRUVav369apfv37qhx9+UErZ692ccMIJ6oMPPlCmaaqKigp1yy23qAceeECVlZWpffv2qYsvvlj985//VEoptW3bNrVs2TLl9/tVQUGBmjRpknrooYeUUkr5/X511llnqdmzZ6tAIKDeffdddcIJJ6jHH39cKaXUn/70J/XAAw+oQCCgAoGA+vLLL5VlWQ1+LkoptWXLFnXyySerZcuWqUAgoF544QU1cuRI5ff7j/g45513ntq9e7cqKipSEydOrI7rzTffVJdddlmd83v16qW2bdumlFLq7rvvrj73aOM6++yz1cUXX6z27NmjioqK1OjRo9Vrr7122JhF2yAtCdEq3HrrrXg8Hvr06UOfPn3YsGFD9bFTTjmFkSNHous6paWlfPLJJ9x33334fD5SUlK49tprWbhwIQDHHXccZ555Ji6Xi+TkZK677jq+/PJLAL799luCwSDXXHMNTqeT0aNHc9JJJ1X/HIfDwd69e9m9ezdOp5NBgwahaVpEz2PRokX8/Oc/58wzz8TpdHLDDTdQWVnJN998c8Rrr7jiCjIzM0lMTOTmm2+ufk6NoSFxXXXVVWRkZJCYmMjZZ5/N+vXrG+3ni+jVbvaTEK1bampq9fder5fy8vLq27V3G9u9ezehUIhhw4ZV32dZVvVeDfv27eOPf/wjX331FWVlZSiliI+PByA/P5+MjIw6b/y190e44YYbeOaZZ7j++usBmDhxYvVeAA2Vn59f5zF1Xa/eX+JIau83kZWVRX5+fkQ/+1jjqtrqFez/g8b8+SJ6SZIQrV7tN/UOHTrgcrlYvnw5DsfBf96PP/44mqYxf/58EhMT+fDDD/nDH/4A2G+CeXl5KKWqH3P37t107twZgNjYWO655x7uueceNm3axDXXXMNJJ53E0KFDGxxreno6mzZtqr6tlKrezvRIam9vunv3btLT0wH7DbuysrL62N69e+tc15DWzrHEJdo26W4SUSc1NZWdO3ce1bXp6emceeaZPPLII5SWlmJZFjt27GDlypUAlJWV4fP5iIuLIy8vj//7v/+rvvaUU07B4XAwZ84cgsEg77//PmvXrq0+/vHHH7N9+3aUUsTFxWEYxhHfgH/6XMaMGcMnn3zCF198QTAY5O9//zsul4sBAwYc8bm99tpr7Nmzh/379/P8888zduxYAPr06cP333/P+vXr8fv9/OUvf6lzXUpKCrt27WqyuETbJklCRJ0pU6bw3HPPMWjQIN57772Ir3/00UcJBoPVI4Fuv/326k/Xt956K+vWrWPQoEFMmTKFUaNGVV/ncrn4y1/+wltvvcXgwYNZtGgR5557bvXx7du3c9111zFgwAAmTpzI5Zdfzumnn97g5/LSSy/RvXt3HnvsMR588EFOP/10Pv74Y55//nlcLtcRn1dOTg7XX389I0eOpEuXLtx8880AdOvWjV/+8pdce+21jBo1ioEDB9a5bsKECfzwww8MGjSIW265pdHjEm2bbDokhBCiXtKSEEIIUS8pXAtxDHbv3s155513yGMLFy6sM2KoOR5HiMYm3U1CCCHqJd1NQggh6iVJQgghRL0kSQghhKiXJAkhhBD1kiQhhBCiXv8fNmlnDWkQrm8AAAAASUVORK5CYII=\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Plot averaged time-series for discrete parameter samples\n", "sns.set_theme() \n", "sns.lineplot(\n", " data=results.arrange_variables(), \n", " x='threads_to_button', \n", " y='max_cluster_size', \n", " hue='n'\n", ");" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.5" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: docs/agentpy_demo.py ================================================ import agentpy as ap class MoneyAgent(ap.Agent): def setup(self): self.wealth = 1 def wealth_transfer(self): if self.wealth == 0: return a = self.model.agents.random() a.wealth += 1 self.wealth -= 1 class MoneyModel(ap.Model): def setup(self): self.agents = ap.AgentList( self, self.p.n, MoneyAgent) def step(self): self.agents.record('wealth') self.agents.wealth_transfer() # Perform single run parameters = {'n': 10, 'steps': 10} model = MoneyModel(parameters) results = model.run() # Perform multiple runs variable_params = { 'n': ap.IntRange(10, 500), 'steps': 10 } sample = ap.Sample(variable_params, n=49) exp = ap.Experiment( MoneyModel, sample, iterations=5, record=True ) results = exp.run() ================================================ FILE: docs/agentpy_flocking.ipynb ================================================ [File too large to display: 21.4 MB] ================================================ FILE: docs/agentpy_forest_fire.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Forest fire" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This notebook presents an agent-based model that simulates a forest fire.\n", "It demonstrates how to use the [agentpy](https://agentpy.readthedocs.io) package to work with a spatial grid and create animations, and perform a parameter sweep. " ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "# Model design\n", "import agentpy as ap\n", "\n", "# Visualization\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", "import IPython" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## About the model\n", "\n", "The model ist based on the [NetLogo FireSimple model](http://ccl.northwestern.edu/netlogo/models/FireSimple) by Uri Wilensky and William Rand, who describe it as follows:\n", "\n", "> \"This model simulates the spread of a fire through a forest. It shows that the fire's chance of reaching the right edge of the forest depends critically on the density of trees. This is an example of a common feature of complex systems, the presence of a non-linear threshold or critical parameter. [...] \n", ">\n", "> The fire starts on the left edge of the forest, and spreads to neighboring trees. The fire spreads in four directions: north, east, south, and west.\n", ">\n", ">The model assumes there is no wind. So, the fire must have trees along its path in order to advance. That is, the fire cannot skip over an unwooded area (patch), so such a patch blocks the fire's motion in that direction.\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Model definition" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "class ForestModel(ap.Model):\n", " \n", " def setup(self):\n", " \n", " # Create agents (trees) \n", " n_trees = int(self.p['Tree density'] * (self.p.size**2))\n", " trees = self.agents = ap.AgentList(self, n_trees)\n", " \n", " # Create grid (forest)\n", " self.forest = ap.Grid(self, [self.p.size]*2, track_empty=True) \n", " self.forest.add_agents(trees, random=True, empty=True)\n", " \n", " # Initiate a dynamic variable for all trees\n", " # Condition 0: Alive, 1: Burning, 2: Burned\n", " self.agents.condition = 0 \n", " \n", " # Start a fire from the left side of the grid\n", " unfortunate_trees = self.forest.agents[0:self.p.size, 0:2]\n", " unfortunate_trees.condition = 1 \n", " \n", " def step(self):\n", " \n", " # Select burning trees\n", " burning_trees = self.agents.select(self.agents.condition == 1)\n", "\n", " # Spread fire \n", " for tree in burning_trees:\n", " for neighbor in self.forest.neighbors(tree):\n", " if neighbor.condition == 0:\n", " neighbor.condition = 1 # Neighbor starts burning\n", " tree.condition = 2 # Tree burns out \n", " \n", " # Stop simulation if no fire is left\n", " if len(burning_trees) == 0: \n", " self.stop()\n", " \n", " def end(self):\n", " \n", " # Document a measure at the end of the simulation\n", " burned_trees = len(self.agents.select(self.agents.condition == 2))\n", " self.report('Percentage of burned trees', \n", " burned_trees / len(self.agents))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Single-run animation" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "# Define parameters\n", "\n", "parameters = {\n", " 'Tree density': 0.6, # Percentage of grid covered by trees\n", " 'size': 50, # Height and length of the grid\n", " 'steps': 100,\n", "}" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/html": [ "\n", "\n", "\n", "\n", "\n", "\n", "
\n", " \n", "
\n", " \n", "
\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
\n", "
\n", " \n", " \n", " \n", " \n", " \n", " \n", "
\n", "
\n", "
\n", "\n", "\n", "\n" ], "text/plain": [ "" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Create single-run animation with custom colors\n", "\n", "def animation_plot(model, ax):\n", " attr_grid = model.forest.attr_grid('condition')\n", " color_dict = {0:'#7FC97F', 1:'#d62c2c', 2:'#e5e5e5', None:'#d5e5d5'}\n", " ap.gridplot(attr_grid, ax=ax, color_dict=color_dict, convert=True)\n", " ax.set_title(f\"Simulation of a forest fire\\n\"\n", " f\"Time-step: {model.t}, Trees left: \"\n", " f\"{len(model.agents.select(model.agents.condition == 0))}\")\n", "\n", "fig, ax = plt.subplots() \n", "model = ForestModel(parameters)\n", "animation = ap.animate(model, fig, ax, animation_plot)\n", "IPython.display.HTML(animation.to_jshtml(fps=15))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Parameter sweep" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "# Prepare parameter sample\n", "parameters = {\n", " 'Tree density': ap.Range(0.2, 0.6), \n", " 'size': 100\n", "}\n", "sample = ap.Sample(parameters, n=30)" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 1200\n", "Completed: 1200, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:04:23.286950\n" ] } ], "source": [ "# Perform experiment\n", "exp = ap.Experiment(ForestModel, sample, iterations=40)\n", "results = exp.run()" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Data saved to ap_output/ForestModel_1\n", "Loading from directory ap_output/ForestModel_1/\n", "Loading parameters_constants.json - Successful\n", "Loading parameters_sample.csv - Successful\n", "Loading parameters_log.json - Successful\n", "Loading reporters.csv - Successful\n", "Loading info.json - Successful\n" ] } ], "source": [ "# Save and load data\n", "results.save()\n", "results = ap.DataDict.load('ForestModel')" ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "image/png": "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ceNGpk6d2u1xnfh57dmzh7KyMs4999y4x1VYWMjWrVt59913effdd5k/fz7f+973WLZsGdB9x9fp4urO46u9v1V3fh/bi6s7P6/p06fzwQcfYJom4XCYLVu2cMEFFwBn6ft4BhXvPcJXX31lzpkzx5wyZYo5Z84c88CBA6ZpmuZtt91m7ty50zRN07zuuuvMSy+91Jw5c2b0/969e03TNE2v12vec8895qRJk8wpU6aY//d//5cQcc2fP9+cPn26OWPGDPP73/++uWPHji6L6z//8z/Na665xpw5c6Y5Y8YM84UXXog+X9M08+GHHzYnTpxoTpw40XzppZcSIq4HHnjAnDZtmjljxgzzuuuuM997770ui+tE/9xCpjuPr/bi6s7jq72/VXd+Xu3F1Z2fl67r5vLly82SkhLzmmuuMZcvX27qum6a5tn5PsqkQ0IIIdrUq4ubhBBCnBlJEkIIIdokSUIIIUSbJEkIIYRokyQJIYQQbZIkIUQcLVq0iF/96ldx3ce0adPYunVrXPch+i4ZlkP0SqNGjYre9/v92O326BAJjz76KDNnzuyu0M66N954I3r/ySef5PDhw/zyl7/sxohEbyJJQvRKJw6+NmHCBB577DEuv/zyk7bTNA2rVb4GQrRFiptEn7J161bGjRvH008/zRVXXMFDDz2EYRg8/fTTTJo0iUsvvZT77ruv1ZDsn332Gddffz2jR49m5syZ7Rbt7N69m9mzZzNq1Ch+/OMfEwwGW63ftGkTs2bNYvTo0Vx//fXs3bs3um7ChAk899xzzJgxg4svvrjV8+vq6rjjjjsYPXo0Y8aMYd68eRiGEX3eRx99xPvvv89///d/s379ekaNGsXMmTNZv3491113XasYVq1axY9+9KMz/ShFHyFJQvQ5x44do7GxkU2bNrFs2TJefPFFNm7cyOrVq9m8eTNpaWksXboUiIzJc8cdd/CjH/2Ibdu28eCDD3Lvvfeecq6BUCjEXXfdxaxZs9i2bRslJSVs2LAhun737t0sXryYpUuXsnXrVubOncudd95JKBSKbrN+/XqeffZZ3nnnHb788kv++te/ApEf9ry8PD7++GM+/PBD7r///pPmyhg3bhx33HEHV199NTt27OC1115j4sSJlJaWthrX6tVXXz1rI4SK3k+ShOhzVFXl3nvvxW6343Q6eemll1i4cCH5+fnY7Xbuvvtu3n77bTRN49VXX2XcuHFceeWVqKrKFVdcwYUXXsjf/va3k173888/JxwOM3/+fGw2GyUlJQwfPjy6/uWXX2bu3LlcdNFFWCwWZs+ejc1m47PPPotuc9NNN5GXl0d6ejpXXXUVe/bsAcBqtVJTU0N5eTk2m43Ro0fHNKGS3W7n6quv5rXXXgNg//79lJWVcdVVV53hpyj6CimMFX1ORkZGdOYuIDr3xPGhniGSSGpraykvL+ett95qNZeypmlceumlJ71udXU1eXl5rX68T5zwpby8nLVr17J69erosnA4THV1dfTx8dnFAFwuV3Tdv/3bv/Gb3/yGW2+9FYC5c+dy++23x/R+Z8+ezf3338+Pf/xjXn31Va6++mrsdntMzxVCkoToc/75DDw/P5/ly5dz8cUXn7RtQUEBs2bN4rHHHjvt6+bk5FBVVYVpmtF9lJeX079//+hr/fCHP+xUfUBKSgqLFi1i0aJF7Nu3j/nz5zN8+HAuu+yydt8bwMiRI7HZbHz66aesW7dOWj6JDpHiJtHnff/73+fXv/51dDKb4+PuA8ycOZNNmzaxefNmdF0nGAyydevWU04oM3LkSKxWKy+88ALhcJgNGzawa9eu6Prvfve7vPTSS3z++eeYponP5+O9997D4/GcNsZNmzZx+PBhTNPE7XZjsVhOmRCysrIoKyuLVmofd+2117J06VKsViujR4/u0Ocj+jZJEqLP+8EPfsCECRO49dZbGTVqFN/73vdazez11FNP8d///d9cdtllXHnllTz33HMn/QhDpPz/ySef5JVXXmHMmDG8+eabTJ48Obp++PDhLFu2jKVLl3LJJZcwZcqUaMX06Rw+fJhbbrmFUaNGMXfuXL7//e/zne9856TtSkpKALj00kuZPXt2dPmsWbPYv39/r+ofIrqGzCchRB8QCAS47LLLeOWVVxg4cGB3hyN6ELmSEKIP+NOf/sTw4cMlQYgOk4prIXq5CRMmYJomK1eu7O5QRA8kxU1CCCHaJMVNQggh2iRJQgghRJskSQghhGiTJAkhhBBtkiQhhBCiTZIkhBBCtOn/AwvoqDOiIngNAAAAAElFTkSuQmCC\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Plot sensitivity\n", "sns.set_theme()\n", "sns.lineplot(\n", " data=results.arrange_reporters(), \n", " x='Tree density', \n", " y='Percentage of burned trees'\n", ");" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.5" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: docs/agentpy_segregation.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Segregation" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This notebook presents an agent-based model of segregation dynamics.\n", "It demonstrates how to use the [agentpy](https://agentpy.readthedocs.io) package to work with a spatial grid and create animations. " ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "# Model design\n", "import agentpy as ap\n", "\n", "# Visualization\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", "import IPython" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## About the model\n", "\n", "The model is based on the [NetLogo Segregation model](http://ccl.northwestern.edu/netlogo/models/Segregation) from Uri Wilensky, who describes it as follows:\n", "\n", ">This project models the behavior of two types of agents in a neighborhood. The orange agents and blue agents get along with one another. But each agent wants to make sure that it lives near some of \"its own.\" That is, each orange agent wants to live near at least some orange agents, and each blue agent wants to live near at least some blue agents. The simulation shows how these individual preferences ripple through the neighborhood, leading to large-scale patterns." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Model definition" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To start, we define our agents who initiate with a random group\n", "and have two methods to check whether they are happy and to \n", "move to a new location if they are not." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "class Person(ap.Agent):\n", " \n", " def setup(self):\n", " \"\"\" Initiate agent attributes. \"\"\"\n", " self.grid = self.model.grid\n", " self.random = self.model.random\n", " self.group = self.random.choice(range(self.p.n_groups))\n", " self.share_similar = 0\n", " self.happy = False\n", " \n", " def update_happiness(self):\n", " \"\"\" Be happy if rate of similar neighbors is high enough. \"\"\"\n", " neighbors = self.grid.neighbors(self)\n", " similar = len([n for n in neighbors if n.group == self.group])\n", " ln = len(neighbors)\n", " self.share_similar = similar / ln if ln > 0 else 0\n", " self.happy = self.share_similar >= self.p.want_similar\n", " \n", " def find_new_home(self):\n", " \"\"\" Move to random free spot and update free spots. \"\"\"\n", " new_spot = self.random.choice(self.model.grid.empty)\n", " self.grid.move_to(self, new_spot)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Next, we define our model, which consists of our agens and a spatial grid environment.\n", "At every step, unhappy people move to a new location.\n", "After every step (update), agents update their happiness.\n", "If all agents are happy, the simulation is stopped." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "class SegregationModel(ap.Model):\n", " \n", " def setup(self): \n", "\n", " # Parameters\n", " s = self.p.size \n", " n = self.n = int(self.p.density * (s ** 2))\n", " \n", " # Create grid and agents\n", " self.grid = ap.Grid(self, (s, s), track_empty=True)\n", " self.agents = ap.AgentList(self, n, Person)\n", " self.grid.add_agents(self.agents, random=True, empty=True) \n", "\n", " def update(self):\n", " # Update list of unhappy people\n", " self.agents.update_happiness()\n", " self.unhappy = self.agents.select(self.agents.happy == False)\n", " \n", " # Stop simulation if all are happy\n", " if len(self.unhappy) == 0: \n", " self.stop() \n", " \n", " def step(self):\n", " # Move unhappy people to new location\n", " self.unhappy.find_new_home() \n", " \n", " def get_segregation(self):\n", " # Calculate average percentage of similar neighbors\n", " return round(sum(self.agents.share_similar) / self.n, 2)\n", " \n", " def end(self): \n", " # Measure segregation at the end of the simulation\n", " self.report('segregation', self.get_segregation())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Single-run animation" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Uri Wilensky explains the dynamic of the segregation model as follows:\n", "\n", ">Agents are randomly distributed throughout the neighborhood. But many agents are \"unhappy\" since they don't have enough same-color neighbors. The unhappy agents move to new locations in the vicinity. But in the new locations, they might tip the balance of the local population, prompting other agents to leave. If a few agents move into an area, the local blue agents might leave. But when the blue agents move to a new area, they might prompt orange agents to leave that area.\n", ">\n", ">Over time, the number of unhappy agents decreases. But the neighborhood becomes more segregated, with clusters of orange agents and clusters of blue agents.\n", ">\n", ">In the case where each agent wants at least 30% same-color neighbors, the agents end up with (on average) 70% same-color neighbors. So relatively small individual preferences can lead to significant overall segregation.\n", "\n", "To observe this effect in our model, we can create an animation of a single run.\n", "To do so, we first define a set of parameters." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "parameters = {\n", " 'want_similar': 0.3, # For agents to be happy\n", " 'n_groups': 2, # Number of groups\n", " 'density': 0.95, # Density of population\n", " 'size': 50, # Height and length of the grid\n", " 'steps': 50 # Maximum number of steps\n", " }" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can now create an animation plot and display it directly in Jupyter as follows." ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/html": [ "\n", "\n", "\n", "\n", "\n", "\n", "
\n", " \n", "
\n", " \n", "
\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
\n", "
\n", " \n", " \n", " \n", " \n", " \n", " \n", "
\n", "
\n", "
\n", "\n", "\n", "\n" ], "text/plain": [ "" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "def animation_plot(model, ax):\n", " group_grid = model.grid.attr_grid('group')\n", " ap.gridplot(group_grid, cmap='Accent', ax=ax)\n", " ax.set_title(f\"Segregation model \\n Time-step: {model.t}, \"\n", " f\"Segregation: {model.get_segregation()}\")\n", "\n", "fig, ax = plt.subplots() \n", "model = SegregationModel(parameters)\n", "animation = ap.animate(model, fig, ax, animation_plot)\n", "IPython.display.HTML(animation.to_jshtml())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Interactive simulation" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "An interactive simulation of this model can be found in [this guide](https://agentpy.readthedocs.io/en/stable/guide_interactive.html)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Multi-run experiment" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To explore how different individual preferences lead to different average levels of segregation, we can conduct a multi-run experiment.\n", "To do so, we first prepare a parameter sample that includes different values for peoples' preferences and the population density." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "parameters_multi = dict(parameters)\n", "parameters_multi.update({\n", " 'want_similar': ap.Values(0,0.125, 0.25, 0.375, 0.5, 0.625), \n", " 'density': ap.Values(0.5, 0.7, 0.95),\n", "})\n", "sample = ap.Sample(parameters_multi)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We now run an experiment where we simulate each parameter combination in our sample over 5 iterations." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 90\n", "Completed: 90, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:56.914258\n" ] } ], "source": [ "exp = ap.Experiment(SegregationModel, sample, iterations=5)\n", "results = exp.run()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Finally, we can arrange the results from our experiment into a dataframe with measures and variable parameters, \n", "and use the seaborn library to visualize the different segregation levels over our parameter ranges." ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "image/png": "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oGkan376KQB79uwhGo0ycuRIxo0bx6uvvsrKlSuZO3cud911F4lEou06S5YsYc6cOaxevZpzzz23rbndr371K4qLi3n88cdZs2YNN998c6e2vPLKK4TDYd577z1WrFjBAw88gNPp7PDYtWvXMnLkSFasWMHcuXP57ne/y7p163jnnXf41a9+xU9+8hMikchh119WVsZzzz3Hxx9/zB133MHdd99NbW1tu9cpKyvjww8/5LbbbkvnRyo5gZEiITnuKCsrw+PxsHHjRlavXs0ZZ5xBfn4+FRUVrFy5ksmTJ6OqKvPmzSM7Oxur1coNN9xAMplkx44dbdeZPHky06dPx2KxMG/ePDZt2tRlW6xWK4FAgF27dmGxWBg7dixer7fDY0tLS/nSl76ExWJh9uzZVFVV8c1vfhO73c4ZZ5yB3W5n9+7dh33NCy+8kIKCAlRVZfbs2QwcOLBda+n8/HyuvfZarFZrp4IlkbQiA9eS45KpU6eycuVKdu3axdSpU/H5fKxatYpPP/2UadOmAfDUU0/x4osvUltbi6IohMNhmpqa2q6Rl5fX9tjpdJJIJNB1vUtjYefNm0d1dTXf/e53CQaDXHzxxXznO99pm3FwILm5ue1e74s2OByOtHYSr776KgsWLGDfvn0ARKPRduvqL/NRJMcGcichOS6ZNm0aK1as4OOPP2batGlMmzaNVatWsXLlSqZOncrq1at58skn+d///V9WrVrF6tWr8fl83T4W0mazcccdd7Bo0SL+/ve/89577/Hqq69262scyL59+/jv//5v7rnnHlasWMHq1asZPnx4u2P6y6xtybGBFAnJccnUqVNZsWIF8XicwsJCpkyZwtKlSwkEAowePZpIJILFYiEnJwdd1/nd735HOBxO+/p5eXns2bPnsMctX76czZs3YxgGXq8Xq9WKqvbcn10sFkNRlLbBNS+99FK/mcQmOTaRIiE5Lhk8eDAej4cpU6YA4PV6KS0tZdKkSVgsFs444wzOPPNMZs6cybnnnovD4ejSGNhbbrmFP/zhD0yZMoWnnnqq0+Pq6+u58847mTx5MrNnz2batGnMmzfvqNfXGcOGDeOGG27g6quv5rTTTmPLli1MmjSpx15Pcvwjhw5JJBKJpFPkTkIikUgknSKzmySSo+Sf//wn991330HPFxcX8/rrr/eBRRJJ9yHdTRKJRCLpFOlukkgkEkmnSJGQSCQSSadIkZBIJBJJp0iRkEgkEkmnSJGQSCQSSaf8fx6yNepFon6kAAAAAElFTkSuQmCC\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "sns.set_theme()\n", "sns.lineplot(\n", " data=results.arrange_reporters(), \n", " x='want_similar', \n", " y='segregation', \n", " hue='density'\n", ");" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.5" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: docs/agentpy_virus_spread.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Virus spread" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This notebook presents an agent-based model that simulates the propagation of a disease through a network.\n", "It demonstrates how to use the [agentpy](https://agentpy.readthedocs.io) package to create and visualize networks, use the interactive module, and perform different types of sensitivity analysis. " ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "# Model design\n", "import agentpy as ap\n", "import networkx as nx \n", "import random \n", "\n", "# Visualization\n", "import matplotlib.pyplot as plt \n", "import seaborn as sns\n", "import IPython" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## About the model\n", "\n", "The agents of this model are people, which can be in one of the following three conditions: susceptible to the disease (S), infected (I), or recovered (R). The agents are connected to each other through a small-world network of peers. At every time-step, infected agents can infect their peers or recover from the disease based on random chance." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Defining the model" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "We define a new agent type :class:`Person` by creating a subclass of :class:`Agent`.\n", "This agent has two methods: :func:`setup` will be called automatically at the agent's creation,\n", "and :func:`being_sick` will be called by the :func:`Model.step` function.\n", "Three tools are used within this class:\n", "\n", "- :attr:`Agent.p` returns the parameters of the model\n", "- :func:`Agent.neighbors` returns a list of the agents' peers in the network\n", "- :func:`random.random` returns a uniform random draw between 0 and 1" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "class Person(ap.Agent):\n", " \n", " def setup(self): \n", " \"\"\" Initialize a new variable at agent creation. \"\"\"\n", " self.condition = 0 # Susceptible = 0, Infected = 1, Recovered = 2\n", " \n", " def being_sick(self):\n", " \"\"\" Spread disease to peers in the network. \"\"\"\n", " rng = self.model.random\n", " for n in self.network.neighbors(self): \n", " if n.condition == 0 and self.p.infection_chance > rng.random():\n", " n.condition = 1 # Infect susceptible peer\n", " if self.p.recovery_chance > rng.random(): \n", " self.condition = 2 # Recover from infection" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "Next, we define our model :class:`VirusModel` by creating a subclass of :class:`Model`.\n", "The four methods of this class will be called automatically at different steps of the simulation,\n", "as described in :ref:`overview_simulation`." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "class VirusModel(ap.Model):\n", " \n", " def setup(self):\n", " \"\"\" Initialize the agents and network of the model. \"\"\"\n", " \n", " # Prepare a small-world network\n", " graph = nx.watts_strogatz_graph(\n", " self.p.population, \n", " self.p.number_of_neighbors, \n", " self.p.network_randomness)\n", " \n", " # Create agents and network\n", " self.agents = ap.AgentList(self, self.p.population, Person)\n", " self.network = self.agents.network = ap.Network(self, graph)\n", " self.network.add_agents(self.agents, self.network.nodes)\n", " \n", " # Infect a random share of the population\n", " I0 = int(self.p.initial_infection_share * self.p.population)\n", " self.agents.random(I0).condition = 1 \n", "\n", " def update(self): \n", " \"\"\" Record variables after setup and each step. \"\"\"\n", " \n", " # Record share of agents with each condition\n", " for i, c in enumerate(('S', 'I', 'R')):\n", " n_agents = len(self.agents.select(self.agents.condition == i))\n", " self[c] = n_agents / self.p.population \n", " self.record(c)\n", " \n", " # Stop simulation if disease is gone\n", " if self.I == 0:\n", " self.stop()\n", " \n", " def step(self): \n", " \"\"\" Define the models' events per simulation step. \"\"\"\n", " \n", " # Call 'being_sick' for infected agents\n", " self.agents.select(self.agents.condition == 1).being_sick()\n", " \n", " def end(self): \n", " \"\"\" Record evaluation measures at the end of the simulation. \"\"\"\n", " \n", " # Record final evaluation measures\n", " self.report('Total share infected', self.I + self.R) \n", " self.report('Peak share infected', max(self.log['I']))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Running a simulation" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To run our model, we define a dictionary with our parameters. \n", "We then create a new instance of our model, passing the parameters as an argument, \n", "and use the method :func:`Model.run` to perform the simulation and return it's output. " ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Completed: 77 steps\n", "Run time: 0:00:00.152576\n", "Simulation finished\n" ] } ], "source": [ "parameters = { \n", " 'population': 1000,\n", " 'infection_chance': 0.3,\n", " 'recovery_chance': 0.1,\n", " 'initial_infection_share': 0.1,\n", " 'number_of_neighbors': 2,\n", " 'network_randomness': 0.5 \n", "}\n", "\n", "model = VirusModel(parameters)\n", "results = model.run() " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Analyzing results" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "The simulation returns a :class:`DataDict` of recorded data with dataframes:" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "DataDict {\n", "'info': Dictionary with 9 keys\n", "'parameters': \n", " 'constants': Dictionary with 6 keys\n", "'variables': \n", " 'VirusModel': DataFrame with 3 variables and 78 rows\n", "'reporters': DataFrame with 2 variables and 1 row\n", "}" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To visualize the evolution of our variables over time, we create a plot function." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": { "needs_background": "light" }, "output_type": "display_data" } ], "source": [ "def virus_stackplot(data, ax):\n", " \"\"\" Stackplot of people's condition over time. \"\"\"\n", " x = data.index.get_level_values('t')\n", " y = [data[var] for var in ['I', 'S', 'R']]\n", " \n", " sns.set()\n", " ax.stackplot(x, y, labels=['Infected', 'Susceptible', 'Recovered'],\n", " colors = ['r', 'b', 'g']) \n", " \n", " ax.legend()\n", " ax.set_xlim(0, max(1, len(x)-1))\n", " ax.set_ylim(0, 1)\n", " ax.set_xlabel(\"Time steps\")\n", " ax.set_ylabel(\"Percentage of population\")\n", "\n", "fig, ax = plt.subplots()\n", "virus_stackplot(results.variables.VirusModel, ax)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Creating an animation" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "We can also animate the model's dynamics as follows.\n", "The function :func:`animation_plot` takes a model instance \n", "and displays the previous stackplot together with a network graph. \n", "The function :func:`animate` will call this plot\n", "function for every time-step and return an :class:`matplotlib.animation.Animation`." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [], "source": [ "def animation_plot(m, axs):\n", " ax1, ax2 = axs\n", " ax1.set_title(\"Virus spread\")\n", " ax2.set_title(f\"Share infected: {m.I}\")\n", " \n", " # Plot stackplot on first axis\n", " virus_stackplot(m.output.variables.VirusModel, ax1)\n", " \n", " # Plot network on second axis\n", " color_dict = {0:'b', 1:'r', 2:'g'}\n", " colors = [color_dict[c] for c in m.agents.condition]\n", " nx.draw_circular(m.network.graph, node_color=colors, \n", " node_size=50, ax=ax2)\n", "\n", "fig, axs = plt.subplots(1, 2, figsize=(8, 4)) # Prepare figure \n", "parameters['population'] = 50 # Lower population for better visibility \n", "animation = ap.animate(VirusModel(parameters), fig, axs, animation_plot)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Using Jupyter, we can display this animation directly in our notebook." ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/html": [ "\n", "\n", "\n", "\n", "\n", "\n", "
\n", " \n", "
\n", " \n", "
\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
\n", "
\n", " \n", " \n", " \n", " \n", " \n", " \n", "
\n", "
\n", "
\n", "\n", "\n", "\n" ], "text/plain": [ "" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "IPython.display.HTML(animation.to_jshtml()) " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Multi-run experiment" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To explore the effect of different parameter values, \n", "we use the classes :class:`Sample`, :class:`Range`, and :class:`IntRange`\n", "to create a sample of different parameter combinations." ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [], "source": [ "parameters = {\n", " 'population': ap.IntRange(100, 1000),\n", " 'infection_chance': ap.Range(0.1, 1.),\n", " 'recovery_chance': ap.Range(0.1, 1.),\n", " 'initial_infection_share': 0.1,\n", " 'number_of_neighbors': 2,\n", " 'network_randomness': ap.Range(0., 1.)\n", "}\n", "\n", "sample = ap.Sample(\n", " parameters, \n", " n=128, \n", " method='saltelli', \n", " calc_second_order=False\n", ")" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "We then create an :class:`Experiment` that takes a model and sample as input.\n", ":func:`Experiment.run` runs our model repeatedly over the whole sample \n", "with ten random iterations per parameter combination." ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 7680\n", "Completed: 7680, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:04:55.800449\n" ] } ], "source": [ "exp = ap.Experiment(VirusModel, sample, iterations=10)\n", "results = exp.run()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Optionally, we can save and load our results as follows:" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Data saved to ap_output/VirusModel_1\n" ] } ], "source": [ "results.save()" ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Loading from directory ap_output/VirusModel_1/\n", "Loading parameters_constants.json - Successful\n", "Loading parameters_sample.csv - Successful\n", "Loading parameters_log.json - Successful\n", "Loading reporters.csv - Successful\n", "Loading info.json - Successful\n" ] } ], "source": [ "results = ap.DataDict.load('VirusModel')" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "The measures in our :class:`DataDict` now hold one row for each simulation run." ] }, { "cell_type": "code", "execution_count": 13, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "DataDict {\n", "'parameters': \n", " 'constants': Dictionary with 2 keys\n", " 'sample': DataFrame with 4 variables and 768 rows\n", " 'log': Dictionary with 5 keys\n", "'reporters': DataFrame with 2 variables and 7680 rows\n", "'info': Dictionary with 12 keys\n", "}" ] }, "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "We can use standard functions of the pandas library like \n", ":func:`pandas.DataFrame.hist` to look at summary statistics." ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [ { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "results.reporters.hist();" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Sensitivity analysis" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "The function :func:`DataDict.calc_sobol` calculates `Sobol sensitivity\n", "indices `_ \n", "for the passed results and parameter ranges, using the \n", "`SAlib `_ package. " ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "DataDict {\n", "'parameters': \n", " 'constants': Dictionary with 2 keys\n", " 'sample': DataFrame with 4 variables and 768 rows\n", " 'log': Dictionary with 5 keys\n", "'reporters': DataFrame with 2 variables and 7680 rows\n", "'info': Dictionary with 12 keys\n", "'sensitivity': \n", " 'sobol': DataFrame with 2 variables and 8 rows\n", " 'sobol_conf': DataFrame with 2 variables and 8 rows\n", "}" ] }, "execution_count": 15, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.calc_sobol()" ] }, { "cell_type": "raw", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "This adds a new category `sensitivity` to our results, which includes:\n", "\n", "- :attr:`sobol` returns first-order sobol sensitivity indices\n", "- :attr:`sobol_conf` returns confidence ranges for the above indices\n", "\n", "We can use pandas to create a bar plot that visualizes these sensitivity indices." ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [ { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "def plot_sobol(results):\n", " \"\"\" Bar plot of Sobol sensitivity indices. \"\"\"\n", " \n", " sns.set()\n", " fig, axs = plt.subplots(1, 2, figsize=(8, 4))\n", " si_list = results.sensitivity.sobol.groupby(by='reporter')\n", " si_conf_list = results.sensitivity.sobol_conf.groupby(by='reporter')\n", "\n", " for (key, si), (_, err), ax in zip(si_list, si_conf_list, axs):\n", " si = si.droplevel('reporter')\n", " err = err.droplevel('reporter')\n", " si.plot.barh(xerr=err, title=key, ax=ax, capsize = 3)\n", " ax.set_xlim(0)\n", " \n", " axs[0].get_legend().remove()\n", " axs[1].set(ylabel=None, yticklabels=[]) \n", " axs[1].tick_params(left=False)\n", " plt.tight_layout()\n", " \n", "plot_sobol(results)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Alternatively, we can also display sensitivities by plotting \n", "average evaluation measures over our parameter variations. " ] }, { "cell_type": "code", "execution_count": 17, "metadata": {}, "outputs": [ { "data": { "image/png": "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7dsXgwYNwuuvvw6g6hWYrVmd2Vn83//9H4YOHYohQ4Zg8ODB2L59OwDXqZ87seZzWbZsGQYOHIhBgwZh2LBh2LVrV80X1E62fO/Onj2L1q1bIyEhoeYKKJG19duyZQsGDRqE2NhYDBo0CFeuXKnZgtrJmvplZ2dj0qRJGDRoEAYMGIDZs2fDaDTWfGEdQahcbm6u2Ldvn+Xfb775ppg5c6YwmUyiT58+4uDBg0IIIZYtWyZmzJghhBBVvuaM/vrrL/H444+Lnj17ir///tul6iaEEG+88YaYP3++MJvNQgghsrKyhBBCjBkzRmzcuFEIIcTGjRvFmDFjLPtU9ZozMZvNokOHDuLvv/8WQghx4sQJ0aZNG2EymVyifu7Gms9l586doqioSAhR+nm3b99eFBcX12g57WXt985oNIrRo0eL559/Xrz55ps1WURJrKnf0aNHxYABA0RmZqYQQoirV6+KkpKSGi2nvayp37x58yyfmV6vFw899JD44YcfarScjqL6hOZmW7duFY8++qhISkoSAwcOtPw9OztbtGnTRgghqnzN2eh0OjFy5EiRmppqSWhcpW5CCFFQUCDat28vCgoKyv39ypUron379sJoNAohSgNo+/btRXZ2dpWvORuz2SxiYmLEoUOHhBBCHDhwQPTt29dl6udO7PlczGazaNeunUhLS6upYtrNlvotX75crFq1SixevFg1CY219Xv++efF+vXrlSiiJNbWb/78+eL1118XJpNJFBQUiCFDhljik9qpvsvpRmazGWvXrkWvXr1uWZk3JCQEZrMZeXl5Vb7mbN577z0MHjwYDRo0sPzNVeoGAKmpqQgKCsLSpUsxbNgwjBkzBocOHapyBeaqXnM2Go0GixYtwlNPPYWePXvi6aefRkJCgsvUz53Y87ls3LgRjRo1UsWaU9bW7+TJk9i9ezfGjRunQCntZ239zpw5g9TUVPz73//Ggw8+iOXLl0OoYP5Za+v31FNPISUlBV27drX8r3379koUWXYuldC88cYb8Pf3x+jRo5UuiiyOHDmCv/76C3FxcUoXxWFMJhNSU1PRsmVLbNiwAS+88AKeeeYZFBUVKV00WRiNRnzwwQdYvnw5/u///g/vv/8+pk2b5jL1o8odOHAA7733Ht555x2liyIbg8GA119/HXPmzLH8cLoak8mEv//+Gx9//DE+++wz7Ny5E999953SxZLN1q1b0bx5c+zevRs7d+7EoUOHsHXrVqWLJQtPpQsgl4SEBJw/fx4rVqyAVqu9ZWXenJwcaLVaBAUFVfmaMzl48CDOnDmD3r17AwDS09Px+OOPY8yYMaqvW5nIyEh4enoiNjYWANC6dWsEBwfD19fXsgKzh4dHuRWYhRCVvuZsTpw4gczMTMsdUPv27eHn5wcfHx+XqJ87uXFV8Oo+lyNHjuDFF1/E8uXL0bRpUwVKaztr6peVlYULFy5g0qRJAICrV69CCIGCggK88cYbShXdKtZ+flFRUejfvz+8vb3h7e2N3r174+jRoxg6dKgyBbeStfVLTEzEggULoNVqERgYiF69emH//v3o37+/QiWXj0u00CxcuBB//fUXli1bBm9vbwDAXXfdhZKSEhw6dAgA8OWXX1o+sKpecyaTJk3C7t278euvv+LXX39FvXr1sHr1akyYMEH1dSsTEhKCTp06WRYcTElJQXZ2Npo0aVLpCszWrs7sDOrVq4f09HScPXsWQGlzdnZ2Nho3buwS9XMn1n4uR48exXPPPYfFixfjzjvvVKKodrGmflFRUdi/f78lJj366KMYOXKk0yczgPWfX2xsLHbv3g0hBAwGA/bt24cWLVooUWSbWFu/Bg0aYOfOnQAAvV6PvXv34o477qjx8jqC6henPH36NGJjY9GkSRP4+voCKP3Ali1bhsOHDyM+Ph46nQ7169fHf//7X9StWxcAqnzNWfXq1QsrVqxAs2bNXKpuqampeOWVV5CXlwdPT09MmzYN3bt3r3QFZqDy1Zmd0ffff4+VK1dCo9EAAJ599ln06dPHZernTqxZFXz48OG4dOkSIiIiLPu99dZbaN68uYIlt4419bvRkiVLUFRUhJdfflmhEtvGmvqZzWYkJCRg586d0Gq16Nq1K15++WVotc5//29N/S5cuID4+HhcuXIFJpMJnTp1wquvvgpPT/V32Kg+oSEiIiJy/pSTiIiIqBpMaIiIiEj1mNAQERGR6jGhISIiItVjQkNERESqx4SGHG7MmDFYv369XftevnwZbdu2hclkkrlURETkSpjQkFPp1asXfv/9d8u/o6KicOTIEZedZp3IWQwcOBD79++vdruzZ89iyJAhaNu2LdasWSNrGdq2bYvU1FRZj2mN/fv3o1u3bjV+XpKX+mfSISIiyX744Qertlu1ahU6deokeX2jMWPGYPDgwRgxYoTlb0eOHJF0THJvbKFxM7169cIHH3yABx54AB07dsTMmTOh0+kAAOvWrcP999+PmJgYTJ48GRkZGZb9mjdvjjVr1qB3797o1KkTEhISYDabAZTOFvrCCy9Ytr148SKaN28Oo9F4y/kvXLiAsWPHolOnTujUqROmT5+Oq1evAgBefPFFXL58GZMnT0bbtm2xcuXKW46VkZGByZMnIyYmBvfffz/WrVtnOfaSJUswdepUvPTSS2jbti0GDhyIY8eOyf8mErmxy5cvu8xU+eRamNC4oU2bNmH16tX46aefkJKSguXLl2Pv3r145513sGjRIuzevRv169fH888/X26/n376Cd988w2+/fZb/Prrr/jmm29sPrcQAk888QR27dqFH3/8Eenp6ViyZAkA4L///S+ioqKwYsUKHDlyBBMnTrxl/+effx716tXDrl27sHjxYixcuBB79+61vP7rr79i4MCBOHToEHr16qWKNWaInEFZd29VNwZjx47F/v37MXfuXLRt2xYpKSnQ6/VISEhAjx49cO+992LWrFkoKSmxHPfnn3/GkCFD0K5dO/Tp0wc7d+7Eu+++i0OHDlmOM3fuXAClN07nz58HAFy7dg0vvfQS7rnnHvTs2RPLly+33ERt2LABjzzyCBISEtCxY0f06tULO3bsqLaOeXl5mDlzJrp27YqOHTviqaeeKvf6Rx99hM6dO6Nr167l4ttvv/2GoUOHol27dujevbslZgH/3MB9++236NGjBzp16oT333/f8rrJZMKKFSvQp08ftG3bFsOGDUNaWhqA0qUKHnvsMcTExKBfv37YsmWLTZ8Z3USQW+nZs6f44osvLP/+7bffRO/evcXMmTNFQkKC5e8FBQWiZcuWIjU1VQghRLNmzcSOHTssrycmJoqxY8cKIYRYvHixmD59uuW11NRU0axZM2EwGIQQQowePVqsW7euwvL89NNPYsiQIeXKt2fPngqPdfnyZdGiRQtx7do1y+tvv/22ePnlly3lePTRRy2vnT59WrRq1crq94bInZVde4sXLxZ33XWX+O2334TRaBRvv/22GDFihGW7m6/n+fPniyeeeELk5uaKa9euiSeeeEK8/fbbQgghkpKSRLt27cTu3buFyWQS6enp4n//+1+FxxGiNM6cO3dOCCHEiy++KCZP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+8R9990nVq9eLe655x7RpUsX8fXXX1uOpdPpxJtvvim6d+8uOnfuLF5//XVRXFwshBAiOztbTJo0SbRv31507NhRPPLII5b3/IMPPhBdu3YVbdq0EX379hW///57le+fO2BCowI9e/YU69evF0KUfolfeOGFcq+PHz9ebNiwQWRkZIjmzZuLvLy8W44xc+ZMkZCQYPl3QUGBaNmypUhNTRVms1l0795dHDhwQAhReiGPGTNGCCHEn3/+Kbp3717uWCtWrBAzZswQQpQGjbLEqMwPP/wgRo0aJYQQwmg0invvvVckJSVVWcfx48eLTz75pNL6b9y40fLvhIQE8frrr1e4bXJysujQoYPl36NHjxbLli2z/DsxMVGMHz/eUo+nnnrqlmOYzWbRunVrcf78ecvfDh8+bAmiRM6sshuA48ePi3vuuUf8+eefwmg0ig0bNoiePXtafnh79uwp9uzZYznO+vXrxRNPPCGEEOL7778XvXv3FlOnTrW8NnnyZCFE1TdLQpT++I8bN07k5uZafqjLEpodO3aIbt26VRsfKjvOxo0bRU5OjjAYDGL16tXi3nvvtSRvixcvFnfddZf47bffhNFoFG+//bYYMWKEEKI0iejRo4f4+OOPhV6vFz/++KNo2bKlJaGxpk6TJ08W165dE6dOnRJ33nmnGDt2rLhw4YK4evWqGDBggNiwYYMQojShiY6OFosWLRJ6vV789ttv4u6777bE6apunt5++23x+uuvC71eL/R6vTh48KAwm83izJkzolu3bpYkKzU1tVy8clfsclKJsqXeL1++jK1bt6JDhw6W//3xxx/IyspCeno66tSpgzp16tyyf2ZmJurXr2/5d61atRAUFISMjAxoNBo88MADltVYN23ahEGDBgEALl26hMzMzHLnW7FiBa5cuWI5Vr169cqdq3fv3jhz5gxSU1OxZ88eBAQE4O67766yfmlpaWjUqFGlr4eFhVn+28/PD0VFRQCA4uJizJo1Cz179kS7du3w73//G1evXoXJZKp238rOmZOTg+LiYgwbNsxS5wkTJpTrRiNyZmPGjEFERASCgoLQs2dPnDhxAl999RVGjRqF1q1bw8PDAw8++CC8vLzw559/VniMmJgY/PHHHzCbzTh48CAmTJiAw4cPAwAOHjyImJgYAKXxYvjw4bjzzjvh7e2N559/Hn/++ScuXrxoOdakSZMQFBQEX19fy9+2bt2K+Ph4rFy5str4UNlxhgwZguDgYHh6emL8+PHQ6/VISUmxbN++fXt0794dHh4eGDJkCE6ePAkASEpKgsFgwKOPPgovLy/0798frVq1suxnTZ0mTJiAgIAA3HHHHWjWrBm6dOmChg0bIjAwEN26dUNycrJlW09PTzz99NPw8vJC9+7d4e/vj5SUFAghsG7dOrzyyisICgpCQEAAnnjiCfzwww+W/bKysnD58mV4eXmhQ4cO0Gg08PDwgF6vx5kzZ2AwGNCgQYMq46e78FS6AGQdjUYDoDSxGTJkCObNm3fLNpmZmcjPz8fVq1dRu3btcq+Fh4fj0qVLln8XFRUhLy8PERERAIDY2FiMHz8ekyZNwtGjR7Fs2TLL+Ro0aIDt27dXW7YyPj4+GDBgAL7//nucPXsWQ4YMqbZ+kZGRuHDhQrXb3eyjjz5CSkoK1q1bh7CwMJw4cQJDhw6FsGLN1cjISBw9evSWvwcHB8PX1xc//PCD5f0hUpObk/iy2LBx40YkJiZaXjMYDMjMzKzwGI0aNYKfnx9OnDiBP/74A08//TS+/vprnD17FgcPHsSYMWMAlMadO++807LfjTdLDRo0APDPDdmNPv30UwwZMgTNmjWzul43H2f16tX4+uuvkZmZCY1Gg4KCgnI3HnXr1rX8t6+vL3Q6HYxGIzIzMxEREVEudkVFRVn+25o63XhsHx+fW/59401fUFAQPD3/+bktu7G68eapjBACZrMZAPD4449j6dKlGD9+PABg1KhRmDRpEho3boxXXnkFS5Yswf/+9z907doVM2bMcPt4xRYalRk8eDD+7//+D7t27YLJZIJOp8P+/fuRnp6O8PBwdOvWDXPmzEF+fj4MBgMOHjwIoDRh2bBhA06cOAG9Xo+FCxfi7rvvtlycLVu2RHBwMF577TV07drVkhDdfffdqFWrFj788EOUlJTAZDLh1KlTFSYCNxoyZAi+/fZb/Prrr1YlNA899BA++ugj/PXXXxBC4Pz58+USsMoUFhbCx8cHtWvXRl5eHpYuXVrtPmUGDRqE33//HVu2bIHRaERubi5OnDgBrVaLESNGYMGCBcjOzgZQOjB6165dVh+byNlERkZi8uTJOHTokOV/SUlJiI2NrXSfjh07Ytu2bTAYDIiIiEDHjh2xceNG5OfnIzo6GkD1N0vArTc9APDee+/hl19+waeffmp1HW48zqFDh7Bq1SosWrQIBw8exKFDhxAYGGjVzUxYWBgyMjLKbXv58mXLf1tTJzncePNU9pn88ccfOHLkCAAgICAAM2bMwC+//IL3338fH3/8Mfbu3QugNH6tXbsW//d//weNRoO3335b1rKpERMalYmMjMTy5cvxwQcfoHPnzujevTtWr15tyejfeusteHp6YsCAAbj33nstweLee+/F1KlT8cwzz6Br165ITU29Zc6F2NhY/P777+UCnIeHB1asWIGTJ0+id+/euOeee/Daa6+hoKCgynK2b98eWq0Wd955Z7mursoMGDAAkydPxvTp09GuXTs8/fTTyM/Pr3a/Rx99FDqdDvfccw9GjRqF++67r9p9ykRFRWHlypX4+OOPERMTg6FDh1qapF988UU0btwYI0eORLt27TBu3LhyTdlEajNixAh8+eWXSEpKghACRUVF+O233yzXct26dZGamlpun5iYGCQmJqJDhw4AgE6dOiExMRHt27eHh4cHgOpvlioTHh6OTz75BGvWrMEXX3xhc30KCwvh4eGBkJAQGI1GLF26tNq4VKZNmzbw9PTEmjVrYDAYsH37dhw7dszyur11slV1N0//93//h/Pnz0MIgcDAQHh4eECj0eDs2bPYu3cv9Ho9vL294ePjA62WP+ccFEwOM2bMGLFu3Tqli0HkVm4e3Lt48WIxffp0IYQQO3bsEMOGDRPt27cXXbp0Ec8884y4du2aEEKIn376SXTv3l20b99erFq1SgghxJkzZ0SzZs0sA1yvXr0qoqOjxQcffFDunF988YXo3bu36Nixo5g0aZJIS0uzvHbjE00V/e3ChQuiR48e1caKm49jNBrFjBkzRNu2bUWXLl3Ehx9+WK7uN9ZbiNKBs82aNRMGg0EIIcTRo0fFkCFDLE85TZ06tdxTTrbU6eGHHxbffPON5d8LFy4Ur7zyihDin6ecbnRjOUtKSsQ777wjevXqJdq2bSv69+8vPv30UyGEEB9//LHo2bOnaN26tbjvvvvE0qVLhRBCnDhxQgwfPly0adPGUr4bn8JyVxohrGifI7LR0aNHMX78ePz2228ICAhQujhEROTiOCiYZPfyyy/j559/xquvvloumZk1axY2bdp0y/aDBg3C3Llza7KIRETkYthCQ0REijt06BAmTpxY4Wtlg2SJqsKEhoiIiFSPw6KJiIhI9ZjQEBERkeoxoSEiIiLVY0JDREREqseEhoiIiFTv/wG927okEBp8FwAAAABJRU5ErkJggg==\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "def plot_sensitivity(results):\n", " \"\"\" Show average simulation results for different parameter values. \"\"\"\n", " \n", " sns.set()\n", " fig, axs = plt.subplots(2, 2, figsize=(8, 8))\n", " axs = [i for j in axs for i in j] # Flatten list\n", " \n", " data = results.arrange_reporters().astype('float')\n", " params = results.parameters.sample.keys() \n", " \n", " for x, ax in zip(params, axs):\n", " for y in results.reporters.columns:\n", " sns.regplot(x=x, y=y, data=data, ax=ax, ci=99, \n", " x_bins=15, fit_reg=False, label=y) \n", " ax.set_ylim(0,1)\n", " ax.set_ylabel('')\n", " ax.legend()\n", " \n", " plt.tight_layout()\n", "\n", "plot_sensitivity(results)" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.10" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: docs/agentpy_wealth_transfer.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Wealth transfer" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This notebook presents a tutorial for beginners on how to create a simple agent-based model with the [agentpy](https://agentpy.readthedocs.io) package. \n", "It demonstrates how to create a basic model with a custom agent type, run a simulation, record data, and visualize results." ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "# Model design\n", "import agentpy as ap\n", "import numpy as np \n", "\n", "# Visualization\n", "import seaborn as sns" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## About the model\n", "\n", "The model explores the distribution of wealth under a trading population of agents. \n", "Each agent starts with one unit of wealth. \n", "During each time-step, each agents with positive wealth \n", "randomly selects a trading partner and gives them one unit of their wealth.\n", "We will see that this random interaction will create an inequality of wealth that \n", "follows a [Boltzmann distribution](http://www.phys.ufl.edu/~meisel/Boltzmann.pdf).\n", "The original version of this model been written in [MESA](https://mesa.readthedocs.io/) \n", "and can be found [here](https://mesa.readthedocs.io/en/master/tutorials/intro_tutorial.html)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Model definition" ] }, { "cell_type": "markdown", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "We start by defining a new type of `Agent` with the following methods:\n", "\n", "- `setup()` is called automatically when a new agent is created and initializes a variable `wealth`.\n", "- `wealth_transfer()` describes the agent's behavior at every time-step and will be called by the model." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "class WealthAgent(ap.Agent):\n", "\n", " \"\"\" An agent with wealth \"\"\"\n", "\n", " def setup(self):\n", "\n", " self.wealth = 1\n", "\n", " def wealth_transfer(self):\n", "\n", " if self.wealth > 0:\n", "\n", " partner = self.model.agents.random()\n", " partner.wealth += 1\n", " self.wealth -= 1" ] }, { "cell_type": "markdown", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "Next, we define a method to calculate the [Gini Coefficient](https://en.wikipedia.org/wiki/Gini_coefficient), \n", "which will measure the inequality among our agents." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "def gini(x):\n", "\n", " \"\"\" Calculate Gini Coefficient \"\"\"\n", " # By Warren Weckesser https://stackoverflow.com/a/39513799\n", " \n", " x = np.array(x)\n", " mad = np.abs(np.subtract.outer(x, x)).mean() # Mean absolute difference\n", " rmad = mad / np.mean(x) # Relative mean absolute difference\n", " return 0.5 * rmad " ] }, { "cell_type": "markdown", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "Finally, we define our [`Model`](https://agentpy.readthedocs.io/en/stable/reference_models.html) with the following methods:\n", "\n", "- `setup` defines how many agents should be created at the beginning of the simulation. \n", "- `step` calls all agents during each time-step to perform their `wealth_transfer` method. \n", "- `update` calculates and record the current Gini coefficient after each time-step. \n", "- `end`, which is called at the end of the simulation, we record the wealth of each agent." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "class WealthModel(ap.Model):\n", "\n", " \"\"\" A simple model of random wealth transfers \"\"\"\n", "\n", " def setup(self):\n", "\n", " self.agents = ap.AgentList(self, self.p.agents, WealthAgent)\n", "\n", " def step(self):\n", "\n", " self.agents.wealth_transfer()\n", "\n", " def update(self):\n", "\n", " self.record('Gini Coefficient', gini(self.agents.wealth))\n", "\n", " def end(self):\n", "\n", " self.agents.record('wealth')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Simulation run" ] }, { "cell_type": "markdown", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To prepare, we define parameter dictionary with a [random seed](https://agentpy.readthedocs.io/en/stable/guide_random.html), the number of agents, and the number of time-steps." ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "parameters = {\n", " 'agents': 100,\n", " 'steps': 100,\n", " 'seed': 42,\n", "}" ] }, { "cell_type": "markdown", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To perform a simulation, we initialize our model with a given set of parameters and call [`Model.run()`](https://agentpy.readthedocs.io/en/stable/reference_models.html)." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Completed: 100 steps\n", "Run time: 0:00:00.124199\n", "Simulation finished\n" ] } ], "source": [ "model = WealthModel(parameters)\n", "results = model.run()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Output analysis" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The simulation returns a [`DataDict`](https://agentpy.readthedocs.io/en/stable/reference_output.html) with our recorded variables." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "DataDict {\n", "'info': Dictionary with 9 keys\n", "'parameters': \n", " 'constants': Dictionary with 3 keys\n", "'variables': \n", " 'WealthModel': DataFrame with 1 variable and 101 rows\n", " 'WealthAgent': DataFrame with 1 variable and 100 rows\n", "}" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The output's `info` provides general information about the simulation." ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'model_type': 'WealthModel',\n", " 'time_stamp': '2021-05-28 09:33:50',\n", " 'agentpy_version': '0.0.8.dev0',\n", " 'python_version': '3.8.5',\n", " 'experiment': False,\n", " 'completed': True,\n", " 'created_objects': 100,\n", " 'completed_steps': 100,\n", " 'run_time': '0:00:00.124199'}" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.info" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To explore the evolution of inequality,\n", "we look at the recorded [`DataFrame`](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html) of the model's variables." ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
Gini Coefficient
t
00.0000
10.5370
20.5690
30.5614
40.5794
\n", "
" ], "text/plain": [ " Gini Coefficient\n", "t \n", "0 0.0000\n", "1 0.5370\n", "2 0.5690\n", "3 0.5614\n", "4 0.5794" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.variables.WealthModel.head()" ] }, { "cell_type": "markdown", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To visualize this data, \n", "we can use [`DataFrame.plot`](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.plot.html)." ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [ { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": { "needs_background": "light" }, "output_type": "display_data" } ], "source": [ "data = results.variables.WealthModel\n", "ax = data.plot()" ] }, { "cell_type": "markdown", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To look at the distribution at the end of the simulation, \n", "we visualize the recorded agent variables with [seaborn](https://seaborn.pydata.org/)." ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [ { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": { "needs_background": "light" }, "output_type": "display_data" } ], "source": [ "sns.histplot(data=results.variables.WealthAgent, binwidth=1);" ] }, { "cell_type": "markdown", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "The result resembles a [Boltzmann distribution](http://www.phys.ufl.edu/~meisel/Boltzmann.pdf)." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.5" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: docs/changelog.rst ================================================ .. currentmodule:: agentpy ========= Changelog ========= 0.1.5 (December 2021) --------------------- - :func:`Experiment.run` has a new argument 'n_jobs' that allows for parallel processing with :func:`joblib.Parallel`. - Two new methods - :func:`Grid.record_positions` and :func:`Space.record_positions` - can be used to record agent positions. - :func:`Model.run` can now continue simulations that have already been run. The steps defined in the argument 'steps' now reflect additional steps, which will be added to the models current time-step. Random number generators will not be re-initialized in this case. - :func:`animate` has been improved. It used to stop the animation one step too early, which has been fixed. Two faulty import statements have been corrected. And, as above, the argument 'steps' now also reflects additional steps. - :func:`Grid.add_field` has been fixed. Single values can now be passed. 0.1.4 (September 2021) ---------------------- - :class:`AttrIter` now returns a new :class:`AttrIter` when called as a function. - :func:`gridplot` now returns an :class:`matplotlib.image.AxesImage` - :func:`DataDict.save` now supports values of type :class:`numpy.bool_` and can re-write to existing directories if an existing `exp_id` is passed. - :func:`DataDict.load` now supports the argument `exp_id = 0`. - :func:`animate` now supports more than 100 steps. - :class:`AttrIter` now returns a new :class:`AttrIter` when called as a function. - :class:`Model` can take a new parameter `report_seed` (default True) that indicates whether the seed of the current run should be reported. 0.1.3 (August 2021) ------------------- - The :class:`Grid` functionality `track_empty` has been fixed to work with multiple agents per cell. - Getting and setting items in :class:`AttrIter` has been fixed. - Sequences like :class:`AgentList` and :class:`AgentDList` no longer accept `args`, only `kwargs`. These keyword arguments are forwarded to the constructor of the new objects. Keyword arguments with sequences of type :class:`AttrIter` will be broadcasted, meaning that the first value will be assigned to the first object, the second to the second, and so forth. Otherwise, the same value will be assigned to all objects. 0.1.2 (June 2021) ----------------- - The property :attr:`Network.nodes` now returns an :class:`AttrIter`, so that network nodes can be assigned to agents as follows:: self.nw = ap.Network(self) self.agents = ap.AgentList(self, 10) self.nw.add_agents(self.agents) self.agents.node = self.nw.nodes - :class:`AgentIter` now requires the model to be passed upon creation and has two new methods :func:`AgentIter.to_list` and :func:`AgentIter.to_dlist` for conversion between sequence types. - Syntax highlighting in the documentation has been fixed. 0.1.1 (June 2021) ----------------- - Marked release for the upcoming JOSS publication of AgentPy. - Fixed :func:`Grid.move_to`: Agents can now move to their current position. 0.1.0 (May 2021) ---------------- This update contains major revisions of most classes and methods in the library, including new features, better performance, and a more coherent syntax. The most important API changes are described below. Object creation ............... The methods :func:`add_agents`, :func:`add_env`, etc. have been removed. Instead, new objects are now created directly or through :doc:`reference_sequences`. This allows for more control over data structures (see next point) and attribute names. For example:: class Model(ap.Model): def setup(self): self.single_agent = ap.Agent() # Create a single agent self.agents = ap.AgentList(self, 10) # Create a sequence of 10 agents self.grid = ap.Grid(self, (5, 5)) # Create a grid environment Data structures ............... The new way of object creation makes it possible to choose specific data structures for different groups of agents. In addition to :class:`AgentList`, there is a new sequence type :class:`AgentDList` that provides increased performance for the lookup and deletion of agents. It also comes with a method :func:`AgentDList.buffer` that allows for safe deletion of agents from the list while it is iterated over :class:`AttrList` has been replaced by :class:`AttrIter`. This improves performance and makes it possible to change agent attributes by setting new values to items in the attribute list (see :class:`AgentList` for an example). In most other ways, the class still behaves like a normal list. There are also two new classes :class:`AgentIter` and :class:`AgentDListIter` that are returned by some of the library's methods. Environments ............ The three environment classes have undergone a major revision. The :func:`add_agents` functions have been extended with new features and are now more consistent between the three environment classes. The method :func:`move_agents` has been replaced by :func:`move_to` and :func:`move_by`. :class:`Grid` is now defined as a structured numpy array that can hold field attributes per position in addition to agents, and can be customized with the arguments `torus`, `track_empty`, and `check_border`. :func:`gridplot` has been adapted to support this new numpy structure. :class:`Network` now consists of :class:`AgentNode` nodes that can hold multiple agents per node, as well as node attributes. Environment-agent interaction ............................. The agents' `env` attribute has been removed. Instead, environments are manually added as agent attributes, giving more control over the attribute name in the case of multiple environments. For example, agents in an environment can be set up as follows:: class Model(ap.Model): def setup(self): self.agents = ap.AgentList(self, 10) self.grid = self.agents.mygrid = ap.Grid(self, (10, 10)) self.grid.add_agents(self.agents) The agent methods `move_to`, `move_by`, and `neighbors` have also been removed. Instead, agents can access these methods through their environment. In the above example, a given agent `a` could for example access their position through `a.mygrid.positions[a]` or their neighbors through calling `a.mygrid.neighbors(a)`. Parameter samples ................. Variable parameters can now be defined with the three new classes :class:`Range` (for continuous parameter ranges), :class:`IntRange` (for integer parameter ranges), and :class:`Values` (for pre-defined of discrete parameter values). Parameter dictionaries with these classes can be used to create samples, but can also be passed to a normal model, which will then use default values. The sampling methods :func:`sample`, :func:`sample_discrete`, and :func:`sample_saltelli` have been removed and integrated into the new class :class:`Sample`, which comes with additional features to create new kinds of samples. Random number generators ........................ :class:`Model` now contains two random number generators `Model.random` and `Model.nprandom` so that both standard and numpy random operations can be used. The parameter `seed` can be used to initialize both generators. :class:`Sample` has an argument `randomize` to vary seeds over parameter samples. And :class:`Experiment` has a new argument `randomize` to control whether to vary seeds over different iterations. More on this can be found in :doc:`guide_random`. Data analysis ............. The structure of output data in :class:`DataDict` has been changed. The name of `measures` has been changed to `reporters`. Parameters are now stored in the two categories `constants` and `sample`. Variables are stored in separate dataframes based on the object type. The dataframe's index is now separated into `sample_id` and `iteration`. The function :func:`sensitivity_sobol` has been removed and is replaced by the method :func:`DataDict.calc_sobol`. Interactive simulations ....................... The method :func:`Experiment.interactive` has been removed and is replaced by an interactive simulation interface that is being developed in the separate package `ipysimulate `_. This new package provides interactive javascript widgets with parameter sliders and live plots similar to the traditional NetLogo interface. Examples can be found in :doc:`guide_interactive`. 0.0.7 (March 2021) ------------------ Continuous space environments ............................. A new environment type :class:`Space` and method :func:`Model.add_space` for agent-based models with continuous space topologies has been added. There is a new demonstration model :doc:`agentpy_flocking` in the model library, which shows how to simulate the flocking behavior of animals and demonstrates the use of the continuous space environment. Random number generators ........................ :class:`Model` has a new property :obj:`Model.random`, which returns the models' random number generator of type :func:`numpy.random.Generator`. A custom seed can be set for :func:`Model.run` and :func:`animate` by either passing an argument or defining a parameter :attr:`seed`. All methods with stochastic elements like :func:`AgentList.shuffle` or :func:`AgentList.random` now take an optional argument `generator`, with the model's main generator being used if none is passed. The function :func:`AgentList.random` now uses :func:`numpy.random.Generator.choice` and has three new arguments 'replace', 'weights', and 'shuffle'. More information with examples can be found in the API reference and the new user guide :doc:`guide_random`. Other changes ............. * The function :func:`sensitivity_sobol` now has an argument :attr:`calc_second_order` (default False). If True, the function will add second-order indices to the output. * The default value of :attr:`calc_second_order` in :func:`sample_saltelli` has also been changed to False for consistency. * For consistency with :class:`Space`, :class:`Grid` no longer takes an integer as argument for 'shape'. A tuple with the lengths of each spatial dimension has to be passed. * The argument 'agents' has been removed from :class:`Environment`. Agents have to be added through :func:`Environment.add_agents`. Fixes ..... * The step limit in :func:`animate` is now the same as in :func:`Model.run`. * A false error message in :func:`DataDict.save` has been removed. 0.0.6 (January 2021) -------------------- * A new demonstration model :doc:`agentpy_segregation` has been added. * All model objects now have a unique id number of type :class:`int`. Methods that take an agent or environment as an argument can now take either the instance or id of the object. The :attr:`key` attribute of environments has been removed. * Extra keyword arguments to :class:`Model` and :class:`Experiment` are now forwarded to :func:`Model.setup`. * :func:`Model.run` now takes an optional argument `steps`. * :class:`EnvDict` has been replaced by :class:`EnvList`, which has the same functionalities as :class:`AgentList`. * Model objects now have a property :attr:`env` that returns the first environment of the object. * Revision of :class:`Network`. The argument `map_to_nodes` has been removed from :func:`Network.add_agents`. Instead, agents can be mapped to nodes by passing an AgentList to the agents argument of :func:`Model.add_network`. Direct forwarding of attribute calls to :attr:`Network.graph` has been removed to avoid confusion. * New and revised methods for :class:`Grid`: * :func:`Agent.move_to` and :func:`Agent.move_by` can be used to move agents. * :func:`Grid.items` returns an iterator of position and agent tuples. * :func:`Grid.get_agents` returns agents in selected position or area. * :func:`Grid.position` returns the position coordinates for an agent. * :func:`Grid.positions` returns an iterator of position coordinates. * :func:`Grid.attribute` returns a nested list with values of agent attributes. * :func:`Grid.apply` returns nested list with return values of a custom function. * :func:`Grid.neighbors` has new arguments `diagonal` and `distance`. * :func:`gridplot` now takes a grid of values as an input and can convert them to rgba. * :func:`animate` now takes a model instance as an input instead of a class and parameters. * :func:`sample` and :func:`sample_saltelli` will now return integer values for parameters if parameter ranges are given as integers. For float values, a new argument `digits` can be passed to round parameter values. * The function :func:`interactive` has been removed, and is replaced by the new method :func:`Experiment.interactive`. * :func:`sobol_sensitivity` has been changed to :func:`sensitivity_sobol`. 0.0.5 (December 2020) --------------------- * :func:`Experiment.run` now supports parallel processing. * New methods :func:`DataDict.arrange_variables` and :func:`DataDict.arrange_measures`, which generate a dataframe of recorded variables or measures and varied parameters. * Major revision of :func:`DataDict.arrange`, see new description in the documentation. * New features for :class:`AgentList`: Arithmethic operators can now be used with :class:`AttrList`. 0.0.4 (November 2020) --------------------- First documented release. ================================================ FILE: docs/conf.py ================================================ # Configuration file for the Sphinx documentation builder. # https://www.sphinx-doc.org/en/master/usage/configuration.html # Written for sphinx 3.2.1 import sys import os from agentpy import __version__ # Agentpy must be installed first # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -- Project information ----------------------------------------------------- project = 'agentpy' copyright = '2020-2021, Joël Foramitti' author = 'Joël Foramitti' # The full version, including alpha/beta/rc tags release = __version__ # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx.ext.viewcode', "sphinx.ext.intersphinx", 'sphinx_rtd_theme', # Read the docs theme 'nbsphinx' # Support jupyter notebooks ] # Remove blank pages latex_elements = { 'extraclassoptions': 'openany,oneside' } # Define master file master_doc = 'index' # Display class attributes as variables napoleon_use_ivar = True # Jupyter notebook settings (nbsphinx) html_sourcelink_suffix = '' nbsphinx_prolog = """ .. currentmodule:: agentpy .. note:: You can download this demonstration as a Jupyter Notebook :download:`here<{{ env.doc2path(env.docname,base=None) }}>` """ # Connect to other docs intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), 'ipywidgets': ('https://ipywidgets.readthedocs.io/en/latest/', None), 'IPython': ('https://ipython.readthedocs.io/en/stable/', None), 'matplotlib': ('https://matplotlib.org/', None), 'numpy': ('https://numpy.org/doc/stable/', None), 'salib': ('https://salib.readthedocs.io/en/latest/', None), 'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None), 'pandas': ('https://pandas.pydata.org/docs/', None), 'joblib': ('https://joblib.readthedocs.io/en/latest/', None), } # Remove module name before elements add_module_names = False # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # 'alabaster' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] html_css_files = ['css/custom.css'] ================================================ FILE: docs/contributing.rst ================================================ .. currentmodule:: agentpy ========== Contribute ========== Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: Types of contributions ---------------------- Report bugs ~~~~~~~~~~~ Report bugs at https://github.com/JoelForamitti/agentpy/issues. If you are reporting a bug, please include: * Your operating system name and version. * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. Fix bugs ~~~~~~~~ Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. Implement features ~~~~~~~~~~~~~~~~~~ Look through the GitHub issues and `discussion forum `_ for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. Write documentation ~~~~~~~~~~~~~~~~~~~ Agentpy could always use more documentation, whether as part of the official agentpy docs, in docstrings, or even on the web in blog posts, articles, and such. Contributions of clear and simple demonstration models for the :doc:`model_library` that illustrate a particular application are also very welcome. Submit feedback ~~~~~~~~~~~~~~~ The best way to send feedback is to write in the agentpy discussion forum at https://github.com/JoelForamitti/agentpy/discussions. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions are welcome :) How to contribute ----------------- Ready to contribute? Here's how to set up `agentpy` for local development. 1. Fork the `agentpy` repository on GitHub: https://github.com/JoelForamitti/agentpy 2. Clone your fork locally: .. code-block:: console $ git clone git@github.com:your_name_here/agentpy.git 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development: .. code-block:: console $ mkvirtualenv agentpy $ cd agentpy/ $ pip install -e .['dev'] 4. Create a branch for local development: .. code-block:: console $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass the tests and that the new features are covered by the tests: .. code-block:: console $ coverage run -m pytest $ coverage report 6. Commit your changes and push your branch to GitHub: .. code-block:: console $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. Pull request guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. For more information, check out the tests directory and https://docs.pytest.org/. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to docs/changelog.rst. 3. The pull request should pass the automatic tests on travis-ci. Check https://travis-ci.com/JoelForamitti/agentpy/pull_requests and make sure that the tests pass for all supported Python versions. ================================================ FILE: docs/guide.rst ================================================ =========== User Guides =========== This section contains interactive notebooks with common applications of the agentpy framework. If you are interested to add a new article to this guide, please visit :doc:`contributing`. If you are looking for examples of complete models, take a look at :doc:`model_library`. To learn how agentpy compares with other frameworks, take a look at :doc:`comparison`. .. :caption: Contents: .. toctree:: :caption: Contents :maxdepth: 1 guide_interactive guide_random guide_ema ================================================ FILE: docs/guide_ema.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "id": "convenient-principle", "metadata": {}, "source": [ "# Exploratory modelling and analysis (EMA)" ] }, { "cell_type": "markdown", "id": "suspected-unknown", "metadata": {}, "source": [ "This guide shows how to use agentpy models together with the [EMA Workbench](https://emaworkbench.readthedocs.io/). Similar to the agentpy `Experiment` class, this library can be used to perform experiments over different parameter combinations and multiple runs, but offers more advanced tools for parameter sampling and analysis with the aim to support decision making under deep uncertainty." ] }, { "cell_type": "markdown", "id": "eligible-hungarian", "metadata": {}, "source": [ "## Converting an agentpy model to a function" ] }, { "cell_type": "markdown", "id": "independent-gateway", "metadata": {}, "source": [ "Let us start by defining an agent-based model. Here, we use the wealth transfer model from the [model library](https://agentpy.readthedocs.io/en/stable/model_library.html)." ] }, { "cell_type": "code", "execution_count": 1, "id": "conceptual-length", "metadata": {}, "outputs": [], "source": [ "import agentpy as ap\n", "from agentpy.examples import WealthModel" ] }, { "cell_type": "markdown", "id": "velvet-trick", "metadata": {}, "source": [ "To use the EMA Workbench, we need to convert our model to a function that takes each parameter as a keyword argument and returns a dictionary of the recorded evaluation measures." ] }, { "cell_type": "code", "execution_count": 2, "id": "organic-keeping", "metadata": {}, "outputs": [], "source": [ "wealth_model = WealthModel.as_function()" ] }, { "cell_type": "code", "execution_count": 3, "id": "differential-baltimore", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Help on function agentpy_model_as_function in module agentpy.model:\n", "\n", "agentpy_model_as_function(**kwargs)\n", " Performs a simulation of the model 'WealthModel'.\n", " \n", " Arguments:\n", " **kwargs: Keyword arguments with parameter values.\n", " \n", " Returns:\n", " dict: Reporters of the model.\n", "\n" ] } ], "source": [ "help(wealth_model)" ] }, { "cell_type": "markdown", "id": "sonic-matter", "metadata": {}, "source": [ "Let us test out this function:" ] }, { "cell_type": "code", "execution_count": 4, "id": "committed-witch", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'gini': 0.32}" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "wealth_model(agents=5, steps=5)" ] }, { "cell_type": "markdown", "id": "blessed-conditions", "metadata": {}, "source": [ "## Using the EMA Workbench" ] }, { "cell_type": "markdown", "id": "structural-magazine", "metadata": {}, "source": [ "Here is an example on how to set up an experiment with the EMA Workbench. For more information, please visit the [documentation](https://emaworkbench.readthedocs.io/) of EMA Workbench." ] }, { "cell_type": "code", "execution_count": 9, "id": "appointed-operation", "metadata": {}, "outputs": [], "source": [ "from ema_workbench import (IntegerParameter, Constant, ScalarOutcome, \n", " Model, perform_experiments, ema_logging)" ] }, { "cell_type": "code", "execution_count": 6, "id": "intimate-comfort", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "performing 100 scenarios * 1 policies * 1 model(s) = 100 experiments\n", "performing experiments sequentially\n", "10 cases completed\n", "20 cases completed\n", "30 cases completed\n", "40 cases completed\n", "50 cases completed\n", "60 cases completed\n", "70 cases completed\n", "80 cases completed\n", "90 cases completed\n", "100 cases completed\n", "experiments finished\n" ] } ], "source": [ "if __name__ == '__main__':\n", " \n", " ema_logging.LOG_FORMAT = '%(message)s'\n", " ema_logging.log_to_stderr(ema_logging.INFO)\n", "\n", " model = Model('WealthModel', function=wealth_model)\n", " model.uncertainties = [IntegerParameter('agents', 10, 100)]\n", " model.constants = [Constant('steps', 100)]\n", " model.outcomes = [ScalarOutcome('gini')]\n", "\n", " results = perform_experiments(model, 100)" ] }, { "cell_type": "code", "execution_count": 7, "id": "mobile-ideal", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
agentsscenariopolicymodel
070.00NoneWealthModel
144.01NoneWealthModel
277.02NoneWealthModel
387.03NoneWealthModel
451.04NoneWealthModel
...............
9538.095NoneWealthModel
9626.096NoneWealthModel
9759.097NoneWealthModel
9894.098NoneWealthModel
9975.099NoneWealthModel
\n", "

100 rows × 4 columns

\n", "
" ], "text/plain": [ " agents scenario policy model\n", "0 70.0 0 None WealthModel\n", "1 44.0 1 None WealthModel\n", "2 77.0 2 None WealthModel\n", "3 87.0 3 None WealthModel\n", "4 51.0 4 None WealthModel\n", ".. ... ... ... ...\n", "95 38.0 95 None WealthModel\n", "96 26.0 96 None WealthModel\n", "97 59.0 97 None WealthModel\n", "98 94.0 98 None WealthModel\n", "99 75.0 99 None WealthModel\n", "\n", "[100 rows x 4 columns]" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results[0]" ] }, { "cell_type": "code", "execution_count": 10, "id": "coated-fence", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'gini': array([0.67877551, 0.61880165, 0.6392309 , 0.62491743, 0.65820838,\n", " 0.62191358, 0.61176471, 0.66986492, 0.6134068 , 0.63538062,\n", " 0.69958848, 0.63777778, 0.61862004, 0.6786 , 0.6184424 ,\n", " 0.61928474, 0.6446281 , 0.6358 , 0.7283737 , 0.60225922,\n", " 0.6404321 , 0.59729448, 0.63516068, 0.515 , 0.58301785,\n", " 0.66780045, 0.6321607 , 0.58131488, 0.6201873 , 0.70083247,\n", " 0.7 , 0.58666667, 0.58131382, 0.5964497 , 0.56014692,\n", " 0.6446281 , 0.59146814, 0.70919067, 0.61592693, 0.59736561,\n", " 0.52623457, 0.64604402, 0.56790123, 0.65675193, 0.49905482,\n", " 0.55250979, 0.62606626, 0.49864792, 0.63802469, 0.62722222,\n", " 0.65500945, 0.69010417, 0.64160156, 0.67950052, 0.60207612,\n", " 0.63115111, 0.64246914, 0.65162722, 0.65759637, 0.66392948,\n", " 0.63971072, 0.57375 , 0.55310287, 0.58692476, 0.59410431,\n", " 0.61950413, 0.6228125 , 0.52444444, 0.59119898, 0.63180975,\n", " 0.6592 , 0.6540149 , 0.60133914, 0.67884977, 0.57852447,\n", " 0.58739596, 0.52040816, 0.52077562, 0.66304709, 0.59750567,\n", " 0.57692308, 0.65189289, 0.64697266, 0.68507561, 0.66874582,\n", " 0.67857143, 0.59410431, 0.55953251, 0.63651717, 0.62809917,\n", " 0.61111111, 0.6328 , 0.64003673, 0.65140479, 0.65972222,\n", " 0.62465374, 0.65384615, 0.64464234, 0.61588954, 0.63111111])}" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results[1]" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.5" } }, "nbformat": 4, "nbformat_minor": 5 } ================================================ FILE: docs/guide_interactive.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "id": "economic-purple", "metadata": {}, "source": [ "# Interactive simulations" ] }, { "cell_type": "markdown", "id": "warming-vegetable", "metadata": {}, "source": [ "The exploration of agent-based models can often be guided through an interactive simulation interface that allows users to visualize the models dynamics and adjust parameter values while a simulation is running. Examples are the traditional interface of [NetLogo](https://ccl.northwestern.edu/netlogo/), or the browser-based visualization module of [Mesa](https://mesa.readthedocs.io/). \n", "\n", "This guide shows how to create such interactive interfaces for agentpy models within a Jupyter Notebook by using the libraries [IPySimulate](https://github.com/JoelForamitti/ipysimulate), [ipywidgets](https://ipywidgets.readthedocs.io/) and [d3.js](https://d3js.org/). This approach is still in an early stage of development, and more features will follow in the future. Contributions are very welcome :)" ] }, { "cell_type": "code", "execution_count": 1, "id": "higher-dietary", "metadata": {}, "outputs": [], "source": [ "import agentpy as ap\n", "import ipysimulate as ips\n", "\n", "from ipywidgets import AppLayout\n", "from agentpy.examples import WealthModel, SegregationModel" ] }, { "cell_type": "markdown", "id": "published-liberal", "metadata": {}, "source": [ "## Lineplot" ] }, { "cell_type": "markdown", "id": "maritime-wichita", "metadata": {}, "source": [ "To begin we create an instance of the [wealth transfer model](https://agentpy.readthedocs.io/en/stable/agentpy_wealth_transfer.html) (without parameters)." ] }, { "cell_type": "code", "execution_count": 2, "id": "protective-export", "metadata": {}, "outputs": [], "source": [ "model = WealthModel()" ] }, { "cell_type": "markdown", "id": "maritime-salad", "metadata": {}, "source": [ "Parameters that are given as ranges will appear as interactive slider widgets. The parameter `fps` (frames per second) will be used automatically to indicate the speed of the simulation. The third value in the range defines the default position of the slider." ] }, { "cell_type": "code", "execution_count": 3, "id": "ultimate-saint", "metadata": {}, "outputs": [], "source": [ "parameters = {\n", " 'agents': 1000,\n", " 'steps': 100,\n", " 'fps': ap.IntRange(1, 20, 5),\n", "}" ] }, { "cell_type": "markdown", "id": "formal-cycling", "metadata": {}, "source": [ "We then create an ipysimulate control panel with the model and our set of parameters. We further pass two variables `t` (time-steps) and `gini` to be displayed live during the simulation." ] }, { "cell_type": "code", "execution_count": 4, "id": "operational-genetics", "metadata": {}, "outputs": [], "source": [ "control = ips.Control(model, parameters, variables=('t', 'gini'))" ] }, { "cell_type": "markdown", "id": "ideal-melbourne", "metadata": {}, "source": [ "Next, we create a lineplot of the variable `gini`:" ] }, { "cell_type": "code", "execution_count": 5, "id": "following-jackson", "metadata": {}, "outputs": [], "source": [ "lineplot = ips.Lineplot(control, 'gini')" ] }, { "cell_type": "markdown", "id": "sporting-table", "metadata": {}, "source": [ "Finally, we want to display our two widgets `control` and `lineplot` next to each other. For this, we can use the [layout templates](https://ipywidgets.readthedocs.io/en/stable/examples/Layout%20Templates.html) from ipywidgets. " ] }, { "cell_type": "code", "execution_count": 6, "id": "tribal-porcelain", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { "model_id": "ae1490124d2048c6bd8ba0ce9e9f1ba1", "version_major": 2, "version_minor": 0 }, "text/plain": [ "AppLayout(children=(Control(layout=Layout(grid_area='left-sidebar'), parameters={'agents': 1000, 'steps': 100,…" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "AppLayout(\n", " left_sidebar=control,\n", " center=lineplot,\n", " pane_widths=['125px', 1, 1], \n", " height='400px'\n", ")" ] }, { "cell_type": "markdown", "id": "infinite-spread", "metadata": {}, "source": [ "Note that this widget is not displayed interactively if viewed in the docs. To view the widget, please download the Jupyter Notebook at the top of this page or launch this notebook as a binder. Here is a screenshot of an interactive simulation:" ] }, { "cell_type": "markdown", "id": "measured-machinery", "metadata": {}, "source": [ "![Interactive simulation interface with a lineplot](graphics/ips_wealth.png)" ] }, { "cell_type": "markdown", "id": "structured-simpson", "metadata": {}, "source": [ "## Scatterplot" ] }, { "cell_type": "markdown", "id": "opponent-inspiration", "metadata": {}, "source": [ "In this second demonstration, we create an instance of the [\n", "segregation model](https://agentpy.readthedocs.io/en/stable/agentpy_segregation.html):" ] }, { "cell_type": "code", "execution_count": 7, "id": "parental-concentration", "metadata": {}, "outputs": [], "source": [ "model = SegregationModel()" ] }, { "cell_type": "code", "execution_count": 8, "id": "statutory-destination", "metadata": {}, "outputs": [], "source": [ "parameters = {\n", " 'fps': ap.IntRange(1, 10, 5),\n", " 'want_similar': ap.Range(0, 1, 0.3),\n", " 'n_groups': ap.Values(2, 3, 4),\n", " 'density': ap.Range(0, 1, 0.95),\n", " 'size': 50,\n", "}" ] }, { "cell_type": "code", "execution_count": 9, "id": "recovered-mapping", "metadata": {}, "outputs": [], "source": [ "control = ips.Control(model, parameters, ('t'))\n", "scatterplot = ips.Scatterplot(\n", " control, \n", " xy=lambda m: m.grid.positions.values(),\n", " c=lambda m: m.agents.group\n", ")" ] }, { "cell_type": "code", "execution_count": 10, "id": "stylish-syndication", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { "model_id": "2d6a671a7cf64599a55ac801b5433c6d", "version_major": 2, "version_minor": 0 }, "text/plain": [ "AppLayout(children=(Control(layout=Layout(grid_area='left-sidebar'), parameters={'fps': 5, 'want_similar': 0.3…" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "AppLayout(left_sidebar=control,\n", " center=scatterplot,\n", " pane_widths=['125px', 1, 1], \n", " height='400px')" ] }, { "cell_type": "markdown", "id": "passing-specific", "metadata": {}, "source": [ "Note that this widget is not displayed interactively if viewed in the docs. To view the widget, please download the Jupyter Notebook at the top of this page or launch this notebook as a binder. Here is a screenshot of an interactive simulation:" ] }, { "cell_type": "markdown", "id": "social-twenty", "metadata": {}, "source": [ "![Interactive simulation interface with a scatterplot](graphics/ips_segregation.png)" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.5" } }, "nbformat": 4, "nbformat_minor": 5 } ================================================ FILE: docs/guide_random.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "id": "headed-amino", "metadata": {}, "source": [ "# Randomness and reproducibility" ] }, { "cell_type": "markdown", "id": "adverse-honolulu", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "Random numbers and [stochastic processes](http://www2.econ.iastate.edu/tesfatsi/ace.htm#Stochasticity)\n", "are essential to most agent-based models.\n", "[Pseudo-random number generators](https://en.wikipedia.org/wiki/Pseudorandom_number_generator)\n", "can be used to create numbers in a sequence that appears \n", "random but is actually a deterministic sequence based on an initial seed value.\n", "In other words, the generator will produce the same pseudo-random sequence \n", "over multiple runs if it is given the same seed at the beginning.\n", "Note that is possible that the generators will draw the same number repeatedly, \n", "as illustrated in this [comic strip](https://dilbert.com/strip/2001-10-25) from Scott Adams:\n", "\n", "![Alt text](graphics/dilbert_rng.gif)" ] }, { "cell_type": "code", "execution_count": 1, "id": "color-summit", "metadata": {}, "outputs": [], "source": [ "import agentpy as ap\n", "import numpy as np\n", "import random" ] }, { "cell_type": "markdown", "id": "ambient-slovakia", "metadata": {}, "source": [ "## Random number generators" ] }, { "cell_type": "markdown", "id": "coordinated-release", "metadata": {}, "source": [ "Agentpy models contain two internal pseudo-random number generators with different features:\n", "\n", "- `Model.random` is an instance of `random.Random` (more info [here](https://realpython.com/python-random/))\n", "- `Model.nprandom` is an instance of `numpy.random.Generator` (more info [here](https://numpy.org/devdocs/reference/random/index.html))" ] }, { "cell_type": "markdown", "id": "efficient-continent", "metadata": {}, "source": [ "To illustrate, let us define a model that uses both generators to draw a random integer:" ] }, { "cell_type": "code", "execution_count": 2, "id": "organized-discretion", "metadata": {}, "outputs": [], "source": [ "class RandomModel(ap.Model):\n", " \n", " def setup(self):\n", " self.x = self.random.randint(0, 99)\n", " self.y = self.nprandom.integers(99)\n", " self.report(['x', 'y'])\n", " self.stop() " ] }, { "cell_type": "markdown", "id": "greatest-satellite", "metadata": {}, "source": [ "If we run this model multiple times, we will likely get a different series of numbers in each iteration:" ] }, { "cell_type": "code", "execution_count": 3, "id": "indirect-atlantic", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 5\n", "Completed: 5, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.027836\n" ] } ], "source": [ "exp = ap.Experiment(RandomModel, iterations=5)\n", "results = exp.run()" ] }, { "cell_type": "code", "execution_count": 4, "id": "canadian-longitude", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
seedxy
iteration
0163546198553218547629179155646693947592751
12484131019818601913821155174000040924705761
2711821260064245140483305344006988007959637
33195053568933306948507691466666663395848995
4642818251031249778926054093250929576463784
\n", "
" ], "text/plain": [ " seed x y\n", "iteration \n", "0 163546198553218547629179155646693947592 75 1\n", "1 248413101981860191382115517400004092470 57 61\n", "2 71182126006424514048330534400698800795 96 37\n", "3 319505356893330694850769146666666339584 89 95\n", "4 64281825103124977892605409325092957646 37 84" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.reporters" ] }, { "cell_type": "markdown", "id": "driven-values", "metadata": {}, "source": [ "## Defining custom seeds" ] }, { "cell_type": "markdown", "id": "mexican-radius", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "If we want the results to be reproducible, \n", "we can define a parameter `seed` that \n", "will be used automatically at the beginning of a simulation\n", "to initialize both generators." ] }, { "cell_type": "code", "execution_count": 5, "id": "opposite-cooper", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 5\n", "Completed: 5, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.039785\n" ] } ], "source": [ "parameters = {'seed': 42}\n", "exp = ap.Experiment(RandomModel, parameters, iterations=5)\n", "results = exp.run()" ] }, { "cell_type": "markdown", "id": "ethical-equipment", "metadata": {}, "source": [ "By default, the experiment will use this seed to generate different random seeds for each iteration:" ] }, { "cell_type": "code", "execution_count": 6, "id": "circular-pitch", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
seedxy
iteration
02523365606935405339358810682988252020772668
147482295457342411543800303662309855831709
22520361725545148523799170737164355749535866
32009341894354935092458768405237799243044877
4318828394973076304960075763008606744579465
\n", "
" ], "text/plain": [ " seed x y\n", "iteration \n", "0 252336560693540533935881068298825202077 26 68\n", "1 47482295457342411543800303662309855831 70 9\n", "2 252036172554514852379917073716435574953 58 66\n", "3 200934189435493509245876840523779924304 48 77\n", "4 31882839497307630496007576300860674457 94 65" ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.reporters" ] }, { "cell_type": "markdown", "id": "stock-suggestion", "metadata": {}, "source": [ "Repeating this experiment will yield the same results:" ] }, { "cell_type": "code", "execution_count": 7, "id": "grand-costume", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 5\n", "Completed: 5, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.047647\n" ] } ], "source": [ "exp2 = ap.Experiment(RandomModel, parameters, iterations=5)\n", "results2 = exp2.run()" ] }, { "cell_type": "code", "execution_count": 8, "id": "necessary-relationship", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
seedxy
iteration
02523365606935405339358810682988252020772668
147482295457342411543800303662309855831709
22520361725545148523799170737164355749535866
32009341894354935092458768405237799243044877
4318828394973076304960075763008606744579465
\n", "
" ], "text/plain": [ " seed x y\n", "iteration \n", "0 252336560693540533935881068298825202077 26 68\n", "1 47482295457342411543800303662309855831 70 9\n", "2 252036172554514852379917073716435574953 58 66\n", "3 200934189435493509245876840523779924304 48 77\n", "4 31882839497307630496007576300860674457 94 65" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results2.reporters" ] }, { "cell_type": "markdown", "id": "imposed-illinois", "metadata": {}, "source": [ "Alternatively, we can set the argument `randomize=False` so that the experiment will use the same seed for each iteration:" ] }, { "cell_type": "code", "execution_count": 9, "id": "aggregate-farming", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 5\n", "Completed: 5, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.021621\n" ] } ], "source": [ "exp3 = ap.Experiment(RandomModel, parameters, iterations=5, randomize=False)\n", "results3 = exp3.run()" ] }, { "cell_type": "markdown", "id": "portable-hardwood", "metadata": {}, "source": [ "Now, each iteration yields the same results:" ] }, { "cell_type": "code", "execution_count": 10, "id": "rocky-species", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
seedxy
iteration
0423539
1423539
2423539
3423539
4423539
\n", "
" ], "text/plain": [ " seed x y\n", "iteration \n", "0 42 35 39\n", "1 42 35 39\n", "2 42 35 39\n", "3 42 35 39\n", "4 42 35 39" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results3.reporters" ] }, { "cell_type": "markdown", "id": "educational-yield", "metadata": {}, "source": [ "## Sampling seeds" ] }, { "cell_type": "markdown", "id": "senior-particle", "metadata": {}, "source": [ "For a sample with multiple parameter combinations, we can treat the seed like any other parameter.\n", "The following example will use the same seed for each parameter combination:" ] }, { "cell_type": "code", "execution_count": 11, "id": "gothic-lunch", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[{'p': 0, 'seed': 0}, {'p': 1, 'seed': 0}]" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "parameters = {'p': ap.Values(0, 1), 'seed': 0}\n", "sample1 = ap.Sample(parameters, randomize=False)\n", "list(sample1)" ] }, { "cell_type": "markdown", "id": "found-macro", "metadata": {}, "source": [ "If we run an experiment with this sample,\n", "the same iteration of each parameter combination will have the same seed (remember that the experiment will generate different seeds for each iteration by default):" ] }, { "cell_type": "code", "execution_count": 12, "id": "female-bahrain", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 4\n", "Completed: 4, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.052923\n" ] } ], "source": [ "exp = ap.Experiment(RandomModel, sample1, iterations=2)\n", "results = exp.run()" ] }, { "cell_type": "code", "execution_count": 13, "id": "noble-bridges", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
seedxy
sample_iditeration
003029343076716675314132578535486434856456831
13285306774944983978594706515072559729495530
103029343076716675314132578535486434856456831
13285306774944983978594706515072559729495530
\n", "
" ], "text/plain": [ " seed x y\n", "sample_id iteration \n", "0 0 302934307671667531413257853548643485645 68 31\n", " 1 328530677494498397859470651507255972949 55 30\n", "1 0 302934307671667531413257853548643485645 68 31\n", " 1 328530677494498397859470651507255972949 55 30" ] }, "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.reporters" ] }, { "cell_type": "markdown", "id": "binding-terminal", "metadata": {}, "source": [ "Alternatively, we can use `Sample` with `randomize=True` (default)\n", "to generate random seeds for each parameter combination in the sample." ] }, { "cell_type": "code", "execution_count": 14, "id": "endangered-pound", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[{'p': 0, 'seed': 302934307671667531413257853548643485645},\n", " {'p': 1, 'seed': 328530677494498397859470651507255972949}]" ] }, "execution_count": 14, "metadata": {}, "output_type": "execute_result" } ], "source": [ "sample3 = ap.Sample(parameters, randomize=True)\n", "list(sample3)" ] }, { "cell_type": "markdown", "id": "chicken-brass", "metadata": {}, "source": [ "This will always generate the same set of random seeds:" ] }, { "cell_type": "code", "execution_count": 15, "id": "induced-hughes", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[{'p': 0, 'seed': 302934307671667531413257853548643485645},\n", " {'p': 1, 'seed': 328530677494498397859470651507255972949}]" ] }, "execution_count": 15, "metadata": {}, "output_type": "execute_result" } ], "source": [ "sample3 = ap.Sample(parameters)\n", "list(sample3)" ] }, { "cell_type": "markdown", "id": "agreed-current", "metadata": {}, "source": [ "An experiment will now have different results for every parameter combination and iteration:" ] }, { "cell_type": "code", "execution_count": 16, "id": "expanded-spiritual", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 4\n", "Completed: 4, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.050806\n" ] } ], "source": [ "exp = ap.Experiment(RandomModel, sample3, iterations=2)\n", "results = exp.run()" ] }, { "cell_type": "code", "execution_count": 17, "id": "retained-edinburgh", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
seedxy
sample_iditeration
001899260227676406082965813744696713221485318
1179917731653904247792112551705722901296360
102554378196541474999633788223136665948558362
1688716843562567836182964896188779519828068
\n", "
" ], "text/plain": [ " seed x y\n", "sample_id iteration \n", "0 0 189926022767640608296581374469671322148 53 18\n", " 1 179917731653904247792112551705722901296 3 60\n", "1 0 255437819654147499963378822313666594855 83 62\n", " 1 68871684356256783618296489618877951982 80 68" ] }, "execution_count": 17, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.reporters" ] }, { "cell_type": "markdown", "id": "statutory-jones", "metadata": {}, "source": [ "Repeating this experiment will yield the same results:" ] }, { "cell_type": "code", "execution_count": 18, "id": "running-affair", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 4\n", "Completed: 4, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.037482\n" ] } ], "source": [ "exp = ap.Experiment(RandomModel, sample3, iterations=2)\n", "results = exp.run()" ] }, { "cell_type": "code", "execution_count": 19, "id": "middle-regard", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
seedxy
sample_iditeration
001899260227676406082965813744696713221485318
1179917731653904247792112551705722901296360
102554378196541474999633788223136665948558362
1688716843562567836182964896188779519828068
\n", "
" ], "text/plain": [ " seed x y\n", "sample_id iteration \n", "0 0 189926022767640608296581374469671322148 53 18\n", " 1 179917731653904247792112551705722901296 3 60\n", "1 0 255437819654147499963378822313666594855 83 62\n", " 1 68871684356256783618296489618877951982 80 68" ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.reporters" ] }, { "cell_type": "markdown", "id": "intensive-benjamin", "metadata": {}, "source": [ "## Stochastic methods of AgentList" ] }, { "cell_type": "markdown", "id": "primary-rendering", "metadata": {}, "source": [ "Let us now look at some stochastic operations that are often used in agent-based models. \n", "To start, we create a list of five agents:" ] }, { "cell_type": "code", "execution_count": 20, "id": "false-theorem", "metadata": {}, "outputs": [], "source": [ "model = ap.Model()\n", "agents = ap.AgentList(model, 5)" ] }, { "cell_type": "code", "execution_count": 21, "id": "fitted-flesh", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "AgentList (5 objects)" ] }, "execution_count": 21, "metadata": {}, "output_type": "execute_result" } ], "source": [ "agents" ] }, { "cell_type": "markdown", "id": "crucial-penny", "metadata": {}, "source": [ "If we look at the agent's ids, we see that they have been created in order:" ] }, { "cell_type": "code", "execution_count": 22, "id": "stone-fighter", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[1, 2, 3, 4, 5]" ] }, "execution_count": 22, "metadata": {}, "output_type": "execute_result" } ], "source": [ "agents.id" ] }, { "cell_type": "markdown", "id": "parallel-gardening", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To shuffle this list, we can use `AgentList.shuffle`:" ] }, { "cell_type": "code", "execution_count": 23, "id": "satisfied-tongue", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[3, 2, 1, 4, 5]" ] }, "execution_count": 23, "metadata": {}, "output_type": "execute_result" } ], "source": [ "agents.shuffle().id" ] }, { "cell_type": "markdown", "id": "needed-spanish", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "To create a random subset, we can use `AgentList.random`:" ] }, { "cell_type": "code", "execution_count": 24, "id": "collected-bumper", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[2, 1, 4]" ] }, "execution_count": 24, "metadata": {}, "output_type": "execute_result" } ], "source": [ "agents.random(3).id" ] }, { "cell_type": "markdown", "id": "palestinian-address", "metadata": {}, "source": [ "And if we want it to be possible to select the same agent more than once:" ] }, { "cell_type": "code", "execution_count": 25, "id": "attended-investor", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[5, 3, 2, 5, 2, 3]" ] }, "execution_count": 25, "metadata": {}, "output_type": "execute_result" } ], "source": [ "agents.random(6, replace=True).id" ] }, { "cell_type": "markdown", "id": "digital-johnson", "metadata": {}, "source": [ "## Agent-specific generators" ] }, { "cell_type": "markdown", "id": "illegal-wallace", "metadata": { "raw_mimetype": "text/restructuredtext" }, "source": [ "For more advanced applications, we can create separate generators for each object.\n", "We can ensure that the seeds of each object follow a controlled pseudo-random sequence by using the models' main generator to generate the seeds." ] }, { "cell_type": "code", "execution_count": 26, "id": "active-conducting", "metadata": {}, "outputs": [], "source": [ "class RandomAgent(ap.Agent):\n", " \n", " def setup(self):\n", " seed = self.model.random.getrandbits(128) # Seed from model\n", " self.random = random.Random(seed) # Create agent generator\n", " self.x = self.random.random() # Create a random number\n", " \n", "class MultiRandomModel(ap.Model):\n", " \n", " def setup(self):\n", " self.agents = ap.AgentList(self, 2, RandomAgent)\n", " self.agents.record('x')\n", " self.stop()" ] }, { "cell_type": "code", "execution_count": 27, "id": "expressed-hospital", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 2\n", "Completed: 2, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.033219\n" ] } ], "source": [ "parameters = {'seed': 42}\n", "exp = ap.Experiment(\n", " MultiRandomModel, parameters, iterations=2, \n", " record=True, randomize=False)\n", "results = exp.run()" ] }, { "cell_type": "code", "execution_count": 28, "id": "intimate-logistics", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
x
iterationobj_idt
0100.414688
200.591608
1100.414688
200.591608
\n", "
" ], "text/plain": [ " x\n", "iteration obj_id t \n", "0 1 0 0.414688\n", " 2 0 0.591608\n", "1 1 0 0.414688\n", " 2 0 0.591608" ] }, "execution_count": 28, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.variables.RandomAgent" ] }, { "cell_type": "markdown", "id": "certified-worst", "metadata": {}, "source": [ "Alternatively, we can also have each agent start from the same seed:" ] }, { "cell_type": "code", "execution_count": 29, "id": "facial-least", "metadata": {}, "outputs": [], "source": [ "class RandomAgent2(ap.Agent):\n", " \n", " def setup(self):\n", " self.random = random.Random(self.p.agent_seed) # Create agent generator\n", " self.x = self.random.random() # Create a random number\n", " \n", "class MultiRandomModel2(ap.Model):\n", " \n", " def setup(self):\n", " self.agents = ap.AgentList(self, 2, RandomAgent2)\n", " self.agents.record('x')\n", " self.stop()" ] }, { "cell_type": "code", "execution_count": 30, "id": "quarterly-bacon", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Scheduled runs: 2\n", "Completed: 2, estimated time remaining: 0:00:00\n", "Experiment finished\n", "Run time: 0:00:00.033855\n" ] } ], "source": [ "parameters = {'agent_seed': 42}\n", "exp = ap.Experiment(\n", " MultiRandomModel2, parameters, iterations=2, \n", " record=True, randomize=False)\n", "results = exp.run()" ] }, { "cell_type": "code", "execution_count": 31, "id": "shared-trust", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
x
iterationobj_idt
0100.639427
200.639427
1100.639427
200.639427
\n", "
" ], "text/plain": [ " x\n", "iteration obj_id t \n", "0 1 0 0.639427\n", " 2 0 0.639427\n", "1 1 0 0.639427\n", " 2 0 0.639427" ] }, "execution_count": 31, "metadata": {}, "output_type": "execute_result" } ], "source": [ "results.variables.RandomAgent2" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.10" }, "toc-autonumbering": false, "toc-showcode": false, "toc-showmarkdowntxt": false }, "nbformat": 4, "nbformat_minor": 5 } ================================================ FILE: docs/index.rst ================================================ .. currentmodule:: agentpy ======================================== AgentPy - Agent-based modeling in Python ======================================== .. image:: https://img.shields.io/pypi/v/agentpy.svg :target: https://pypi.org/project/agentpy/ .. image:: https://img.shields.io/github/license/joelforamitti/agentpy :target: https://github.com/JoelForamitti/agentpy/blob/master/LICENSE .. image:: https://readthedocs.org/projects/agentpy/badge/?version=latest :target: https://agentpy.readthedocs.io/en/latest/?badge=latest .. image:: https://joss.theoj.org/papers/10.21105/joss.03065/status.svg :target: https://doi.org/10.21105/joss.03065 .. raw:: latex \chapter{Introduction} AgentPy is an open-source library for the development and analysis of agent-based models in Python. The framework integrates the tasks of model design, interactive simulations, numerical experiments, and data analysis within a single environment. The package is optimized for interactive computing with `IPython `_ and `Jupyter `_. **Note:** AgentPy is no longer under active development. For new projects, we recommend using [MESA](https://mesa.readthedocs.io/stable/). .. rubric:: Quick orientation - To get started, please take a look at :doc:`installation` and :doc:`overview`. - For a simple demonstration, check out the :doc:`agentpy_wealth_transfer` tutorial in the :doc:`model_library`. - For a detailled description of all classes and functions, refer to :doc:`reference`. - To learn how agentpy compares with other frameworks, take a look at :doc:`comparison`. - If you are interested to contribute to the library, see :doc:`contributing`. .. rubric:: Citation Please cite this software as follows: .. code-block:: text Foramitti, J., (2021). AgentPy: A package for agent-based modeling in Python. Journal of Open Source Software, 6(62), 3065, https://doi.org/10.21105/joss.03065 .. only:: html .. rubric:: Table of contents .. toctree:: :maxdepth: 2 installation overview guide model_library reference changelog contributing about .. only:: html .. rubric:: Indices and tables * :ref:`genindex` * :ref:`search` ================================================ FILE: docs/installation.rst ================================================ .. currentmodule:: agentpy .. highlight:: shell ============ Installation ============ To install the latest release of agentpy, run the following command on your console: .. code-block:: console $ pip install agentpy Dependencies ------------ Agentpy supports Python 3.6 and higher. The installation includes the following packages: - `numpy `_ and `scipy `_, for scientific computing - `matplotlib `_, for visualization - `pandas `_, for data manipulation - `networkx `_, for networks/graphs - `SALib `_, for sensitivity analysis - `joblib `_, for parallel processing These optional packages can further be useful in combination with agentpy: - `jupyter `_, for interactive computing - `ipysimulate `_ >= 0.2.0, for interactive simulations - `ema_workbench `_, for exploratory modeling - `seaborn `_, for statistical data visualization Development ----------- The most recent version of agentpy can be cloned from Github: .. code-block:: console $ git clone https://github.com/JoelForamitti/agentpy.git Once you have a copy of the source, you can install it with: .. code-block:: console $ pip install -e To include all necessary packages for development & testing, you can use: .. code-block:: console $ pip install -e .['dev'] .. _Github repository: https://github.com/JoelForamitti/agentpy ================================================ FILE: docs/make.bat ================================================ @ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ================================================ FILE: docs/model_library.rst ================================================ ============= Model Library ============= Welcome to the agentpy model library. Below you can find a set of demonstrations on how the package can be used. All of the models are provided as interactive `Jupyter Notebooks `_ that can be downloaded and experimented with. .. :caption: Contents: .. toctree:: :caption: Models :maxdepth: 1 agentpy_wealth_transfer agentpy_virus_spread agentpy_flocking agentpy_segregation agentpy_forest_fire agentpy_button_network ================================================ FILE: docs/overview.rst ================================================ .. currentmodule:: agentpy ======== Overview ======== This section provides an overview over the main classes and functions of AgentPy and how they are meant to be used. For a more detailed description of each element, please refer to the :doc:`guide` and :doc:`reference`. Throughout this documentation, AgentPy is imported as follows:: import agentpy as ap Structure ######### The basic structure of the AgentPy framework has four levels: 1. The :class:`Agent` is the basic building block of a model 2. The environment types :class:`Grid`, :class:`Space`, and :class:`Network` contain agents 3. A :class:`Model` contains agents, environments, parameters, and simulation procedures 4. An :class:`Experiment` can run a model multiple times with different parameter combinations All of these classes are templates that can be customized through the creation of `sub-classes `_ with their own variables and methods. Creating models ############### A custom agent type can be defined as follows:: class MyAgent(ap.Agent): def setup(self): # Initialize an attribute with a parameter self.my_attribute = self.p.my_parameter def agent_method(self): # Define custom actions here pass The method :func:`Agent.setup` is meant to be overwritten and will be called automatically after an agent's creation. All variables of an agents should be initialized within this method. Other methods can represent actions that the agent will be able to take during a simulation. All model objects (including agents, environments, and the model itself) are equipped with the following default attributes: - :attr:`model` the model instance - :attr:`id` a unique identifier number for each object - :attr:`p` the model's parameters - :attr:`log` the object's recorded variables Using the new agent type defined above, here is how a basic model could look like:: class MyModel(ap.Model): def setup(self): """ Initiate a list of new agents. """ self.agents = ap.AgentList(self, self.p.agents, MyAgent) def step(self): """ Call a method for every agent. """ self.agents.agent_method() def update(self): """ Record a dynamic variable. """ self.agents.record('my_attribute') def end(self): """ Repord an evaluation measure. """ self.report('my_measure', 1) The simulation procedures of a model are defined by four special methods that will be used automatically during different parts of a simulation. - :class:`Model.setup` is called at the start of the simulation (`t==0`). - :class:`Model.step` is called during every time-step (excluding `t==0`). - :class:`Model.update` is called after every time-step (including `t==0`). - :class:`Model.end` is called at the end of the simulation. If you want to see a basic model like this in action, take a look at the :doc:`agentpy_wealth_transfer` demonstration in the :doc:`model_library`. .. _overview_agents: Agent sequences ############### The :doc:`reference_sequences` module provides containers for groups of agents. The main classes are :class:`AgentList`, :class:`AgentDList`, and :class:`AgentSet`, which come with special methods to access and manipulate whole groups of agents. For example, when the model defined above calls :func:`self.agents.agent_method`, it will call the method :func:`MyAgentType.agent_method` for every agent in the model. Similar commands can be used to set and access variables, or select subsets of agents with boolean operators. The following command, for example, selects all agents with an id above one:: agents.select(agents.id > 1) Further examples can be found in :doc:`reference_sequences` and the :doc:`agentpy_virus_spread` demonstration model. .. _overview_environments: Environments ############ :doc:`reference_environments` are objects in which agents can inhabit a specific position. A model can contain zero, one or multiple environments which agents can enter and leave. The connection between positions is defined by the environment's topology. There are currently three types: - :class:`Grid` n-dimensional spatial topology with discrete positions. - :class:`Space` n-dimensional spatial topology with continuous positions. - :class:`Network` graph topology consisting of :class:`AgentNode` and edges. Applications of networks can be found in the demonstration models :doc:`agentpy_virus_spread` and :doc:`agentpy_button_network`; spatial grids in :doc:`agentpy_forest_fire` and :doc:`agentpy_segregation`; and continuous spaces in :doc:`agentpy_flocking`. Note that there can also be models without environments like in :doc:`agentpy_wealth_transfer`. Recording data ############## There are two ways to document data from the simulation for later :ref:`analysis `. The first way is to record dynamic variables, which can be recorded for each object (agent, environment, or model) and time-step. They are useful to look at the dynamics of individual or aggregate objects over time and can be documented by calling the method :meth:`record` for the respective object. Recorded variables can at run-time with the object's `log` attribute. The second way is to document reporters, which represent summary statistics or evaluation measures of a simulation. In contrast to variables, reporters can be stored only for the model as a whole and only once per run. They will be stored in a separate dataframe for easy comparison over multiple runs, and can be documented with the method :meth:`Model.report`. Reporters can be accessed at run-time via :attr:`Model.reporters`. .. _overview_simulation: Running a simulation #################### To perform a simulation, we initialize a new instance of our model type with a dictionary of parameters, and then use the function :func:`Model.run`. This will return a :class:`DataDict` with recorded data from the simulation. A simple run can be prepared and executed as follows:: parameters = { 'my_parameter':42, 'agents':10, 'steps':10 } model = MyModel(parameters) results = model.run() A simulation proceeds as follows (see also Figure 1 below): 0. The model initializes with the time-step :attr:`Model.t = 0`. 1. :func:`Model.setup` and :func:`Model.update` are called. 2. The model's time-step is increased by 1. 3. :func:`Model.step` and :func:`Model.update` are called. 4. Step 2 and 3 are repeated until the simulation is stopped. 5. :func:`Model.end` is called. The simulation of a model can be stopped by one of the following two ways: 1. Calling the :func:`Model.stop` during the simulation. 2. Reaching the time-limit, which be defined as follows: - Defining :attr:`steps` in the paramater dictionary. - Passing :attr:`steps` as an argument to :func:`Model.run`. Interactive simulations ####################### Within a Jupyter Notebook, AgentPy models can be explored as an interactive simulation (similar to the traditional NetLogo interface) using `ipysimulate `_ and `d3.js `_. For more information on this, please refer to :doc:`guide_interactive`. .. _overview_experiments: Multi-run experiments ##################### The :doc:`reference_sample` module provides tools to create a :class:`Sample` with multiple parameter combinations from a dictionary of ranges. Here is an example using :class:`IntRange` integer ranges:: parameters = { 'my_parameter': 42, 'agents': ap.IntRange(10, 20), 'steps': ap.IntRange(10, 20) } sample = ap.Sample(parameters, n=5) The class :class:`Experiment` can be used to run a model multiple times. As shown in Figure 1, it will start with the first parameter combination in the sample and repeat the simulation for the amount of defined iterations. After, that the same cycle is repeated for the next parameter combination. .. figure:: graphics/simulation_flow.png :alt: Chain of events in Model and Experiment Figure 1: Chain of events in :class:`Model` and :class:`Experiment`. Here is an example of an experiment with the model defined above. In this experiment, we use a sample where one parameter is kept fixed while the other two are varied 5 times from 10 to 20 and rounded to integer. Every possible combination is repeated 2 times, which results in 50 runs:: exp = ap.Experiment(MyModel, sample, iterations=2, record=True) results = exp.run() For more applied examples of experiments, check out the demonstration models :doc:`agentpy_virus_spread`, :doc:`agentpy_button_network`, and :doc:`agentpy_forest_fire`. An alternative to the built-in experiment class is to use AgentPy models with the EMA workbench (see :doc:`guide_ema`). Random numbers ############## :class:`Model` contains two random number generators: - :attr:`Model.random` is an instance of :class:`random.Random` - :attr:`Model.nprandom` is an instance of :class:`numpy.random.Generator` The random seed for these generators can be set by defining a parameter `seed`. The :class:`Sample` class has an argument `randomize` to control whether vary seeds over different parameter combinations. Similarly, :class:`Experiment` also has an argument `randomize` to control whether to vary seeds over different iterations. More on this can be found in :doc:`guide_random`. .. _overview_analysis: Data analysis ############# Both :class:`Model` and :class:`Experiment` can be used to run a simulation, which will return a :class:`DataDict` with output data. The output from the experiment defined above looks as follows:: >>> results DataDict { 'info': Dictionary with 5 keys 'parameters': 'constants': Dictionary with 1 key 'sample': DataFrame with 2 variables and 25 rows 'variables': 'MyAgent': DataFrame with 1 variable and 10500 rows 'reporters': DataFrame with 1 variable and 50 rows } All data is given in a :class:`pandas.DataFrame` and formatted as `long-form data `_ that can easily be used with statistical packages like `seaborn `_. The output can contain the following categories of data: - :attr:`info` holds meta-data about the model and simulation performance. - :attr:`parameters` holds the parameter values that have been used for the experiment. - :attr:`variables` holds dynamic variables, which can be recorded at multiple time-steps. - :attr:`reporters` holds evaluation measures that are documented only once per simulation. - :attr:`sensitivity` holds calculated sensitivity measures. The :class:`DataDict` provides the following main methods to handle data: - :func:`DataDict.save` and :func:`DataDict.load` can be used to store results. - :func:`DataDict.arrange` generates custom combined dataframes. - :func:`DataDict.calc_sobol` performs a Sobol sensitivity analysis. Visualization ############# In addition to the :doc:`guide_interactive`, AgentPy provides the following functions for visualization: - :func:`animate` generates an animation that can display output over time. - :func:`gridplot` visualizes agent positions on a spatial :class:`Grid`. To see applied examples of these functions, please check out the :doc:`model_library`. ================================================ FILE: docs/reference.rst ================================================ .. currentmodule:: agentpy ============= API Reference ============= .. :caption: Contents: .. toctree:: :maxdepth: 3 reference_model reference_agents reference_sequences reference_environments reference_sample reference_experiment reference_data reference_visualization reference_examples reference_other ================================================ FILE: docs/reference_agents.rst ================================================ .. currentmodule:: agentpy ====== Agents ====== Agent-based models can contain multiple agents of different types. This module provides a base class :class:`Agent` that is meant to be used as a template to create custom agent types. Initial variables should be defined by overriding :func:`Agent.setup`. .. autoclass:: Agent :members: :inherited-members: ================================================ FILE: docs/reference_data.rst ================================================ .. currentmodule:: agentpy ============= Data analysis ============= This module offers tools to access, arrange, analyse, and store output data from simulations. A :class:`DataDict` can be generated by the methods :func:`Model.run`, :func:`Experiment.run`, and :func:`DataDict.load`. .. autoclass:: DataDict Data arrangement ################ .. automethod:: DataDict.arrange .. automethod:: DataDict.arrange_reporters .. automethod:: DataDict.arrange_variables Analysis methods ################ .. automethod:: DataDict.calc_sobol Save and load ############# .. automethod:: DataDict.save .. automethod:: DataDict.load ================================================ FILE: docs/reference_environments.rst ================================================ .. currentmodule:: agentpy ============ Environments ============ Environments are objects in which agents can inhabit a specific position. The connection between positions is defined by the environment's topology. There are currently three types: - :class:`Grid` n-dimensional spatial topology with discrete positions. - :class:`Space` n-dimensional spatial topology with continuous positions. - :class:`Network` graph topology consisting of :class:`AgentNode` and edges. All three environment classes contain the following methods: - :func:`add_agents` adds agents to the environment. - :func:`remove_agents` removes agents from the environment. - :func:`move_to` changes an agent's position. - :func:`move_by` changes an agent's position, relative to their current position. - :func:`neighbors` returns an agent's neighbors within a given distance. .. toctree:: :hidden: reference_grid reference_space reference_network ================================================ FILE: docs/reference_examples.rst ================================================ .. currentmodule:: agentpy.examples ======== Examples ======== The following example models are presented in the :doc:`model_library`. To use these classes, they have to be imported as follows:: from agentpy.examples import WealthModel .. autoclass:: WealthModel .. autoclass:: SegregationModel ================================================ FILE: docs/reference_experiment.rst ================================================ .. currentmodule:: agentpy =========== Experiments =========== .. autoclass:: Experiment :members: ================================================ FILE: docs/reference_grid.rst ================================================ .. currentmodule:: agentpy ====================== Discrete spaces (Grid) ====================== .. autoclass:: Grid :members: :inherited-members: .. autoclass:: GridIter :members: :inherited-members: ================================================ FILE: docs/reference_model.rst ================================================ .. currentmodule:: agentpy ================== Agent-based models ================== The :class:`Model` contains all objects and defines the procedures of an agent-based simulation. It is meant as a template for custom model classes that override the `custom procedure methods`_. .. autoclass:: Model Simulation tools ################ .. automethod:: Model.run .. automethod:: Model.stop .. _custom procedure methods: Custom procedures ################# .. automethod:: Model.setup .. automethod:: Model.step .. automethod:: Model.update .. automethod:: Model.end Data collection ############### .. automethod:: Model.record .. automethod:: Model.report Conversion ########## .. automethod:: Model.as_function ================================================ FILE: docs/reference_network.rst ================================================ .. currentmodule:: agentpy ========================== Graph topologies (Network) ========================== .. autoclass:: Network :members: :inherited-members: .. autoclass:: AgentNode :members: :inherited-members: ================================================ FILE: docs/reference_other.rst ================================================ .. currentmodule:: agentpy ===== Other ===== .. autoclass:: AttrDict :members: ================================================ FILE: docs/reference_sample.rst ================================================ .. currentmodule:: agentpy ================= Parameter samples ================= Value sets and ranges ##################### .. autoclass:: Range .. autoclass:: IntRange .. autoclass:: Values Sample generation ################# .. autoclass:: Sample :members: ================================================ FILE: docs/reference_sequences.rst ================================================ .. currentmodule:: agentpy ========= Sequences ========= This module offers various data structures to create and manage groups of both agents and environments. Which structure best to use depends on the specific requirements of each model. - :class:`AgentList` is a list of agentpy objects with methods to select and manipulate its entries. - :class:`AgentDList` is an ordered collection of agentpy objects, optimized for removing and looking up objects. - :class:`AgentSet` is an unordered collection of agents that can access agent attributes. - :class:`AgentIter` and :class:`AgentDListIter` are a list-like iterators over a selection of agentpy objects. - :class:`AttrIter` is a list-like iterator over the attributes of each agent in a selection of agentpy objects. All of these sequence classes can access and manipulate the methods and variables of their objects as an attribute of the container. For examples, see :class:`AgentList`. Containers ########## .. autoclass:: AgentList :members: .. autoclass:: AgentDList :members: .. autoclass:: AgentSet :members: Iterators ######### .. autoclass:: AgentIter :members: .. autoclass:: AgentDListIter :members: .. autoclass:: AttrIter :members: ================================================ FILE: docs/reference_space.rst ================================================ .. currentmodule:: agentpy ========================= Continuous spaces (Space) ========================= .. autoclass:: Space :members: :inherited-members: ================================================ FILE: docs/reference_visualization.rst ================================================ .. currentmodule:: agentpy ============= Visualization ============= .. autofunction:: animate .. autofunction:: gridplot ================================================ FILE: paper/paper.bib ================================================ % Encoding: windows-1252 @Article{Madsen2019, author = {Jens Koed Madsen and Richard Bailey and Ernesto Carrella and Philipp Koralus}, title = {Analytic Versus Computational Cognitive Models: Agent-Based Modeling as a Tool in Cognitive Sciences}, journal = {Current Directions in Psychological Science}, year = {2019}, volume = {28}, number = {3}, pages = {299-305}, doi = {10.1177/0963721419834547}, } @Article{DeAngelis2019, author = {DeAngelis, Donald L. and Diaz, Stephanie G.}, title = {Decision-Making in Agent-Based Modeling: A Current Review and Future Prospectus}, journal = {Frontiers in Ecology and Evolution}, year = {2019}, volume = {6}, pages = {237}, doi = {10.3389/fevo.2018.00237}, } @Article{Farmer2009, author = {Farmer, J Doyne and Foley, Duncan}, title = {The economy needs agent-based modelling}, journal = {Nature}, year = {2009}, volume = {460}, number = {7256}, pages = {685--686}, doi = {10.1038/460685a}, } @Article{Castro2020, author = {Castro, Juana and Drews, Stefan and Exadaktylos, Filippos and Foramitti, Joël and Klein, Franziska and Konc, Théo and Savin, Ivan and van den Bergh, Jeroen}, title = {A review of agent-based modeling of climate-energy policy}, journal = {WIREs Climate Change}, year = {2020}, volume = {11}, number = {4}, pages = {e647}, doi = {10.1002/wcc.647}, } @Article{Bianchi2015, author = {Bianchi, Federico and Squazzoni, Flaminio}, title = {Agent-based models in sociology}, journal = {WIREs Computational Statistics}, year = {2015}, volume = {7}, number = {4}, pages = {284-306}, doi = {10.1002/wics.1356}, } @Article{Abar2017, author = {Sameera Abar and Georgios K. Theodoropoulos and Pierre Lemarinier and Gregory M.P. O’Hare}, title = {Agent Based Modelling and Simulation tools: A review of the state-of-art software}, journal = {Computer Science Review}, year = {2017}, volume = {24}, pages = {13 - 33}, doi = {10.1016/j.cosrev.2017.03.001}, } @Misc{Netlogo, author = {Wilensky, U}, title = {NetLogo}, year = {1999}, publisher = {Center for Connected Learning and Computer-Based Modeling, Northwestern University. Evanston, IL.}, url = {http://ccl.northwestern.edu/netlogo/}, } @book{North2007, title={Managing business complexity: discovering strategic solutions with agent-based modeling and simulation}, author={North, Michael J and Macal, Charles M}, year={2007}, publisher={Oxford University Press}, doi={10.1093/acprof:oso/9780195172119.001.0001}, } @article{Arthur2021, title={Foundations of complexity economics}, author={Arthur, W Brian}, journal={Nature Reviews Physics}, volume = {3}, pages = {136--145}, year={2021}, publisher={Nature Publishing Group}, doi={10.1038/s42254-020-00273-3} } @InProceedings{ Mesa2015, author = { {D}avid {M}asad and {J}acqueline {K}azil }, title = { {M}esa: {A}n {A}gent-{B}ased {M}odeling {F}ramework }, booktitle = { {P}roceedings of the 14th {P}ython in {S}cience {C}onference }, pages = { 51 - 58 }, year = { 2015 }, editor = { {K}athryn {H}uff and {J}ames {B}ergstra }, doi = { 10.25080/Majora-7b98e3ed-009 } } ================================================ FILE: paper/paper.md ================================================ --- title: 'AgentPy: A package for agent-based modeling in Python' tags: - Agent-based modeling - Complex systems - Computer simulation - Interactive computing - Python authors: - name: Joël Foramitti orcid: 0000-0002-4828-7288 affiliation: "1, 2" affiliations: - name: Institute of Environmental Science and Technology, Universitat Autònoma de Barcelona, Spain index: 1 - name: Institute for Environmental Studies, Vrije Universiteit Amsterdam, The Netherlands index: 2 date: 16.01.2020 bibliography: paper.bib --- # Introduction Agent-based models allow for computer simulations based on the autonomous behavior of heterogeneous agents. They are used to generate and understand the emergent dynamics of complex systems, with applications in fields like ecology [@DeAngelis2019], cognitive sciences [@Madsen2019], management [@North2007], policy analysis [@Castro2020], economics [@Arthur2021; @Farmer2009], and sociology [@Bianchi2015]. AgentPy is an open-source library for the development and analysis of agent-based models. It aims to provide an intuitive syntax for the creation of models together with advanced tools for scientific applications. The framework is written in Python 3, and optimized for interactive computing with [IPython](http://ipython.org/) and [Jupyter](https://jupyter.org/). A reference of all features as well as a model library with tutorials and examples can be found in the [documentation](https://agentpy.readthedocs.io/).[^1] # Statement of Need There are numerous modeling and simulation tools for agent-based models, each with their own particular focus and style [@Abar2017]. Notable examples are [NetLogo](https://ccl.northwestern.edu/netlogo/) [@Netlogo], which is written in Scala/Java and has become the most established tool in the field; and [Mesa](https://mesa.readthedocs.io/) [@Mesa2015], a more recent framework that has popularized the development of agent-based models in Python. AgentPy's main distinguishing feature is that it integrates the many different tasks of agent-based modeling within a single environment for interactive computing. This includes the creation of custom agent and model types, interactive simulations (\autoref{fig:interactive}) similar to the traditional NetLogo interface, numeric experiments over multiple runs, and the subsequent data analysis of the output. All of these can be performed within a [Jupyter Notebook](https://jupyter.org/). The software is further designed for scientific applications, and includes tools for parameter sampling (similar to NetLogo's BehaviorSpace), Monte Carlo experiments, random number generation, parallel computing, and sensitivity analysis. Beyond these built-in features, AgentPy is also designed for compatibility with established Python libraries like [EMA Workbench](https://emaworkbench.readthedocs.io/), [NetworkX](https://networkx.org/), [NumPy](https://numpy.org/), [pandas](https://pandas.pydata.org/), [SALib](https://salib.readthedocs.io/), [SciPy](https://www.scipy.org/), and [seaborn](https://seaborn.pydata.org/). ![An interactive simulation of Schelling's segregation model in a Jupyter Notebook.\label{fig:interactive}](ips_segregation.png){ width=80% } # Basic structure The AgentPy framework follows a nested structure that is illustrated in \autoref{fig:structure}. The basic building blocks are the agents, which can be placed within (multiple) environments with different topologies such as a network, a spatial grid, or a continuous space. Models are used to initiate these objects, perform a simulation, and record data. Experiments can run a model over multiple iterations and parameter combinations. The resulting output data can then be saved and re-arranged for analysis and visualization. ![Nested structure of the AgentPy framework.\label{fig:structure}](structure.png){ width=80% } # Model example The following code shows an example of a simple model that explores the distribution of wealth under a randomly trading population of agents. The original version of this model was written in Mesa, allowing for a comparison of the syntax between the two frameworks.[^3] To start, we import the AgentPy library as follows: ```python import agentpy as ap ``` We then define a new type of [`Agent`](https://agentpy.readthedocs.io/en/stable/reference_agents.html). The method [`setup`](https://agentpy.readthedocs.io/en/stable/reference_agents.html#agentpy.Agent.setup) will be called automatically at the agent's creation. Each agent starts with one unit of wealth. `wealth_transfer` will be called by the model during each time-step. When called, the agent randomly selects a trading partner and hands them one unit of their wealth, given that they have one to spare. ```python class WealthAgent(ap.Agent): def setup(self): self.wealth = 1 def wealth_transfer(self): if self.wealth > 0: partner = self.model.agents.random() partner.wealth += 1 self.wealth -= 1 ``` Next, we define a [`Model`](https://agentpy.readthedocs.io/en/stable/reference_model.html). The method [`setup`](https://agentpy.readthedocs.io/en/stable/reference_model.html#agentpy.Model.setup) is called at the beginning the simulation, [`step`](https://agentpy.readthedocs.io/en/stable/reference_model.html#agentpy.Model.step) is called during each time-step, and [`end`](https://agentpy.readthedocs.io/en/stable/reference_model.html#agentpy.Model.end) is called after the simulation has finished. An [`AgentList`](https://agentpy.readthedocs.io/en/stable/reference_sequences.html) is used to create a set of agents that can then be accessed as a group. The attribute `p` is used to access the model's parameters. And the method [`record`](https://agentpy.readthedocs.io/en/stable/reference_agents.html#agentpy.Agent.record) is used to store data for later analysis. ```python class WealthModel(ap.Model): def setup(self): self.agents = ap.AgentList(self, self.p.n, WealthAgent) def step(self): self.agents.wealth_transfer() def end(self): self.agents.record('wealth') ``` To run a simulation, a new instance of the model is created with a dictionary of parameters. While the parameter `n` is used in the model's setup, the parameter `steps` automatically defines the maximum number of time-steps. Alternatively, the simulation could also be stopped with [`Model.stop`](https://agentpy.readthedocs.io/en/stable/reference_model.html#agentpy.Model.stop). To perform the actual simulation, one can use [`Model.run`](https://agentpy.readthedocs.io/en/stable/reference_model.html#agentpy.Model.run). ```python parameters = {'n': 100, 'steps': 100} model = MoneyModel(parameters) results = model.run() ``` Parameters can also be defined as ranges and used to generate a [`Sample`](https://agentpy.readthedocs.io/en/stable/reference_sample.html). This sample can then be used to initiate an [`Experiment`](https://agentpy.readthedocs.io/en/stable/reference_experiment.html) that can repeatedly run the model over multiple parameter combinations and iterations. In the following example, the parameter `n` is varied from 1 to 100 and the simulation is repeated 10 times for each value of `n`. ```python parameters = {'n': ap.IntRange(1, 100), 'steps': 100} sample = ap.Sample(parameters, n=10) exp = ap.Experiment(MoneyModel, sample, iterations=10, record=True) results = exp.run() ``` The output of both models and experiments is given as a [`DataDict`](https://agentpy.readthedocs.io/en/stable/reference_data.html) with tools to save, arrange, and analyse data. Here, we use the seaborn library to display a histogram of the experiment's output. The plot is presented in \autoref{fig:boltzmann}. It shows that the random interaction of the agents creates an inequality of wealth that resembles a Boltzmann-Gibbs distribution. ```python import seaborn as sns sns.histplot(data=results.variables.MoneyAgent, binwidth=1) ``` ![Histogram of the agents' wealth in the model example.\label{fig:boltzmann}](boltzmann.pdf){ width=60% } More examples - including spatial environments, networks, stochastic processes, interactive simulations (see \autoref{fig:interactive}), animations, and sensitivity analysis - can be found in the [model library](https://agentpy.readthedocs.io/en/stable/model_library.html) and [user guides](https://agentpy.readthedocs.io/en/stable/guide.html) of the documentation. For questions and ideas, please visit the [discussion forum](https://github.com/JoelForamitti/agentpy/discussions).[^4] [^1]: Link to the AgentPy documentation: [https://agentpy.readthedocs.io](https://agentpy.readthedocs.io) [^3]: For a direct comparison, see: [https://agentpy.readthedocs.io/en/stable/comparison.html](https://agentpy.readthedocs.io/en/stable/comparison.html) [^4]: Link to the AgentPy dicussion forum: [https://github.com/JoelForamitti/agentpy/discussions](https://github.com/JoelForamitti/agentpy/discussions) # Acknowledgements This study has received funding through an ERC Advanced Grant from the European Research Council (ERC) under the European Union's Horizon 2020 research and innovation programme (grant agreement n° 741087). I thank Jeroen C.J.M van den Bergh, Ivan Savin, Martí Bosch, and James Millington for their helpful comments. # References ================================================ FILE: setup.cfg ================================================ [metadata] version = 0.1.6.dev [options] packages = find: python_requires = >=3.6 setup_requires = pytest-runner tests_require = pytest, pytest-cov install_requires = importlib-metadata ~= 1.0 ; python_version < "3.8" numpy >= 1.19 scipy >= 1.5.2 matplotlib >= 3.3.3 networkx >= 2.5 pandas >= 1.1.3 SALib >= 1.3.7 joblib >= 1.1.0 # Sphinx requirement temporary fix for nbsphinx compatibility [options.extras_require] docs = sphinx==4.0.2 nbsphinx >= 0.7.1 sphinx_rtd_theme >= 0.4.3 IPython >= 7.19.0 dev = setuptools >= 51.3.3 pytest >= 5.4.3 coverage >= 5.3 sphinx >= 3.2.1 nbsphinx >= 0.7.1 sphinx_rtd_theme >= 0.4.3 ================================================ FILE: setup.py ================================================ import setuptools with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="agentpy", license="BSD 3-Clause", author="Joël Foramitti", author_email="joel.foramitti@uab.cat", description="Agent-based modeling in Python", long_description=long_description, long_description_content_type="text/markdown", url="https://agentpy.readthedocs.io/", download_url="https://github.com/JoelForamitti/agentpy", classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: BSD License", "Intended Audience :: Science/Research", "Operating System :: OS Independent" ], ) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/test_datadict.py ================================================ import pytest import agentpy as ap import numpy as np import pandas as pd import shutil import os from agentpy.tools import AgentpyError from SALib.sample import saltelli from SALib.analyze import sobol def test_combine_vars(): model = ap.Model() model.record('test', 1) results = model.run(1, display=False) assert results._combine_vars().shape == (1, 1) model = ap.Model() agents = ap.AgentList(model, 1) agents.record('test', 1) results = model.run(1, display=False) assert results._combine_vars().shape == (1, 1) model = ap.Model() agents = ap.AgentList(model, 1) model.record('test', 1) agents.record('test', 2) results = model.run(1, display=False) assert results._combine_vars().shape == (2, 1) model = ap.Model() agents = ap.AgentList(model, 1) model.record('test', 1) agents.record('test', 2) results = model.run(1, display=False) assert results._combine_vars(obj_types="Model").shape == (1, 1) model = ap.Model() agents = ap.AgentList(model, 1) model.record('test', 1) agents.record('test', 2) results = model.run(1, display=False) assert results._combine_vars(obj_types="Doesn't exist") is None model = ap.Model() results = model.run(1, display=False) assert results._combine_vars() is None assert results._combine_pars() is None model = ap.Model({'test': 1}) results = model.run(1, display=False) assert results._combine_pars(constants=False) is None repr = """DataDict { 'info': Dictionary with 12 keys 'parameters': 'constants': Dictionary with 2 keys 'sample': DataFrame with 1 variable and 10 rows 'log': Dictionary with 3 keys 'variables': 'Agent': DataFrame with 1 variable and 10 rows 'MyModel': DataFrame with 1 variable and 10 rows 'reporters': DataFrame with 1 variable and 10 rows }""" class MyModel(ap.Model): def step(self): self.report('x', self.p.x) self.agents = ap.AgentList(self, 1) self.agents.record('id') self.record('id') self.stop() def test_repr(): param_ranges = {'x': ap.Range(0., 1.), 'y': 1, 'report_seed': False} sample = ap.Sample(param_ranges, n=10) results = ap.Experiment(MyModel, sample, record=True).run() assert results.__repr__() == repr class AgentType1(ap.Agent): def setup(self): self.x = 'x1' def action(self): self.record('x') class AgentType2(AgentType1): def setup(self): self.x = 'x2' self.y = 'y2' def action(self): self.record(['x', 'y']) class EnvType3(ap.Agent): def setup(self): self.x = 'x3' self.z = 'z4' def action(self): self.record(['x', 'z']) class EnvType4(ap.Agent): def setup(self): self.z = 'z4' def action(self): self.record(['z']) class ModelType0(ap.Model): def setup(self): self.E31 = EnvType3(self) self.E41 = EnvType4(self) self.E42 = EnvType4(self) self.agents1 = ap.AgentList(self, 2, AgentType1) self.agents2 = ap.AgentList(self, 2, AgentType2) self.agents = ap.AgentList(self, self.agents1 + self.agents2) self.envs = ap.AgentList(self, [self.E31, self.E41, self.E42]) def step(self): self.agents.action() self.envs.action() def end(self): self.report('m_key', 'm_value') def test_testing_model(): parameters = {'steps': 2, 'px': ap.Values(1, 2), 'report_seed': False} sample = ap.Sample(parameters) settings = {'iterations': 2, 'record': True} pytest.model_instance = model = ModelType0(list(sample)[0]) pytest.model_results = model_results = model.run(display=False) exp = ap.Experiment(ModelType0, sample, **settings) pytest.exp_results = exp_results = exp.run(display=False) type_list = ['AgentType1', 'AgentType2', 'EnvType3', 'EnvType4'] assert list(model_results.variables.keys()) == type_list assert list(exp_results.variables.keys()) == type_list def arrange_things(results): return (results.arrange(variables='x'), results.arrange(variables=['x']), results.arrange(variables=['x', 'y']), results.arrange(variables='z'), results.arrange(parameters='px'), results.arrange(reporters='m_key'), results.arrange(variables=True, parameters=True, reporters=True), results.arrange()) def test_datadict_arrange_for_single_run(): results = pytest.model_results data = arrange_things(results) x_data, x_data2, xy_data, z_data, p_data, m_data, all_data, no_data = data assert x_data.equals(x_data2) assert list(x_data['x']) == ['x1'] * 4 + ['x2'] * 4 + ['x3'] * 2 assert x_data.shape == (10, 4) assert xy_data.shape == (10, 5) assert z_data.shape == (6, 4) assert p_data.shape == (1, 2) assert m_data.shape == (1, 2) assert all_data.shape == (15, 8) assert no_data.empty is True def test_datadict_arrange_for_multi_run(): results = pytest.exp_results data = arrange_things(results) x_data, x_data2, xy_data, z_data, p_data, m_data, all_data, no_data = data assert x_data.equals(x_data2) assert x_data.shape == (40, 6) assert xy_data.shape == (40, 7) assert z_data.shape == (24, 6) assert p_data.shape == (2, 2) assert m_data.shape == (4, 3) assert all_data.shape == (60, 10) assert no_data.empty is True def test_datadict_arrange_measures(): results = pytest.exp_results mvp_data = results.arrange(reporters=True, parameters=True) mvp_data_2 = results.arrange_reporters() assert mvp_data.equals(mvp_data_2) def test_datadict_arrange_variables(): results = pytest.exp_results mvp_data = results.arrange(variables=True, parameters=True) mvp_data_2 = results.arrange_variables() assert mvp_data.equals(mvp_data_2) def test_automatic_loading(): if 'ap_output' in os.listdir(): shutil.rmtree('ap_output') results = pytest.model_results results.info['test'] = False results.save(exp_name="a") results.save(exp_name="b", exp_id=1) results.info['test'] = True results.save(exp_name="b", exp_id=3) results.info['test'] = False results.save(exp_name="c") results.save(exp_name="b", exp_id=2) loaded = ap.DataDict.load() shutil.rmtree('ap_output') # Latest experiment is chosen (b), # and then highest id is chosen (3) assert loaded.info['test'] is True def test_saved_equals_loaded(): results = pytest.exp_results results.save() loaded = ap.DataDict.load('ModelType0') shutil.rmtree('ap_output') assert results == loaded # Test that equal doesn't hold if parts are changed assert results != 1 loaded.reporters = 1 assert results != loaded results.reporters = 1 assert results == loaded loaded.info = 1 assert results != loaded del loaded.info assert results != loaded class WeirdObject: pass def test_save_load(): dd = ap.DataDict() dd['i1'] = 1 dd['i2'] = np.int64(1) dd['f1'] = 1. dd['f2'] = np.float32(1.1) dd['s1'] = 'test' dd['s2'] = 'testtesttesttesttesttest' dd['l1'] = [1, 2, [3, 4]] dd['l2'] = np.array([1, 2, 3]) dd['wo'] = WeirdObject() dd.save() dl = ap.DataDict.load() with pytest.raises(FileNotFoundError): assert ap.DataDict.load("Doesn't_exist") shutil.rmtree('ap_output') with pytest.raises(FileNotFoundError): assert ap.DataDict.load("Doesn't_exist") assert dd.__repr__().count('\n') == 10 assert dl.__repr__().count('\n') == 9 assert len(dd) == 9 assert len(dl) == 8 assert dl.l1[2][1] == 4 def test_load_unreadable(): """ Unreadable entries are loaded as None. """ path = f'ap_output/fake_experiment_1/' os.makedirs(path) f = open(path + "unreadable_entry.xxx", "w+") f.close() dl = ap.DataDict.load() shutil.rmtree('ap_output') assert dl.unreadable_entry is None class SobolModel(ap.Model): def step(self): self.report('x', self.p.x) self.stop() def test_calc_sobol(): # Running a demo problem with salib problem = {'num_vars': 1, 'names': ['x'], 'bounds': [[0, 1]]} param_values = saltelli.sample(problem, 8) si = sobol.analyze(problem, param_values.T[0])['S1'] parameters = {'x': ap.Range(0., 1.)} sample = ap.Sample(parameters, n=8, method='saltelli', calc_second_order=False) results = ap.Experiment(SobolModel, sample).run(display=False) results.calc_sobol(reporters='x') assert results.sensitivity.sobol['S1'][0] == si # Test if a non-varied parameter causes errors parameters = {'x': ap.Range(0., 1.), 'y': 1} sample = ap.Sample(parameters, n=8, method='saltelli', calc_second_order=False) results = ap.Experiment(SobolModel, sample).run(display=False) results.calc_sobol() assert results.sensitivity.sobol['S1'][0] == si # Test wrong sample type raises error parameters = {'x': ap.Range(0., 1.), 'y': 1} sample = ap.Sample(parameters, n=8) results = ap.Experiment(SobolModel, sample).run(display=False) with pytest.raises(AgentpyError): results.calc_sobol() # Test merging iterations # TODO Improve parameters = {'x': ap.Range(0., 1.)} sample = ap.Sample(parameters, n=8, method='saltelli', calc_second_order=False) results = ap.Experiment(SobolModel, sample, iterations=10).run(display=False) results.calc_sobol(reporters='x') assert results.sensitivity.sobol['S1'][0] == si # Test calc_second_order parameters = {'x': ap.Range(0., 1.), 'y': 1} sample = ap.Sample(parameters, n=8, method='saltelli', calc_second_order=True) results = ap.Experiment(SobolModel, sample).run(display=False) results.calc_sobol() assert results.sensitivity.sobol[('S2', 'x')][0].__repr__() == 'nan' ================================================ FILE: tests/test_examples.py ================================================ import pytest import agentpy as ap from agentpy.examples import * # Test that examples run without errors def test_WealthModel(): parameters = { 'seed': 42, 'agents': 1000, 'steps': 100, } model = WealthModel(parameters) results = model.run(display=False) assert model.reporters['gini'] == 0.627486 def test_SegregationModel(): parameters = { 'seed': 42, 'want_similar': 0.3, 'n_groups': 2, 'density': 0.95, 'size': 50 } model = SegregationModel(parameters) results = model.run(display=False) assert model.reporters['segregation'] == 0.78 ================================================ FILE: tests/test_experiment.py ================================================ import pytest import agentpy as ap import pandas as pd import shutil import os import multiprocessing as mp from agentpy.tools import AgentpyError class MyModel(ap.Model): def setup(self): self.report('measured_id', self.model._run_id) self.record('t0', self.t) def test_basics(): exp = ap.Experiment(MyModel, [{'steps': 1}] * 3) results = exp.run() assert 'variables' not in results assert exp.name == 'MyModel' exp = ap.Experiment(MyModel, [{'steps': 1}] * 3, record=True) results = exp.run() assert 'variables' in results def test_parallel_processing(): exp = ap.Experiment(MyModel, [{'steps': 1, 'report_seed': False}] * 3) pool = mp.Pool(mp.cpu_count()) results = exp.run(pool=pool) exp2 = ap.Experiment(MyModel, [{'steps': 1, 'report_seed': False}] * 3) results2 = exp2.run() exp3 = ap.Experiment(MyModel, [{'steps': 1, 'report_seed': False}] * 3) results3 = exp3.run(n_jobs=-1) del results.info del results2.info del results3.info assert results == results2 assert results2 == results3 def test_random(): parameters = { 'steps': 0, 'seed': ap.Values(1, 1, 2) } class Model(ap.Model): def setup(self): self.report('x', self.model.random.random()) sample = ap.Sample(parameters) exp = ap.Experiment(Model, sample, iterations=2, randomize=True) results = exp.run() l = list(results.reporters['x']) assert l[0] != l[1] assert l[0:2] == l[2:4] assert l[0:2] != l[4:6] sample = ap.Sample(parameters) exp = ap.Experiment(Model, sample, iterations=2, randomize=False) results = exp.run() l = list(results.reporters['x']) assert l[0] == l[1] assert l[0:2] == l[2:4] assert l[0:2] != l[4:6] exp = ap.Experiment(Model, parameters, iterations=2) results = exp.run() l1 = list(results.reporters['x']) assert l1 == [0.03542265363082542, 0.08363464439430013] parameters['seed'] = 1 exp = ap.Experiment(Model, parameters, iterations=2) results = exp.run() l2 = list(results.reporters['x']) assert l1 == l2 ================================================ FILE: tests/test_grid.py ================================================ import pytest import agentpy as ap import numpy as np from agentpy.tools import AgentpyError def make_grid(s, n=0, track_empty=False, agent_cls=ap.Agent): model = ap.Model() agents = ap.AgentList(model, n, agent_cls) grid = ap.Grid(model, (s, s), track_empty=track_empty) grid.add_agents(agents) return model, grid, agents def test_general(): model, grid, agents = make_grid(2) assert grid.shape == (2, 2) assert grid.ndim == 2 def test_add_agents(): model = ap.Model() grid = ap.Grid(model, (2, 2)) agents = ap.AgentList(model, 5) grid.add_agents(agents) assert grid.apply(len).tolist() == [[2, 1], [1, 1]] # Passed positions model = ap.Model() grid = ap.Grid(model, (2, 2)) agents = ap.AgentList(model, 2) grid.add_agents(agents, [[0, 0], [1, 1]]) assert grid.apply(len).tolist() == [[1, 0], [0, 1]] model = ap.Model() model.sim_setup(seed=1) grid = ap.Grid(model, (2, 2)) agents = ap.AgentList(model, 5) grid.add_agents(agents, random=True) assert grid.apply(len).tolist() == [[0, 3], [1, 1]] with pytest.raises(AgentpyError): # Can't add more agents than empty positions model = ap.Model() model.sim_setup(seed=1) grid = ap.Grid(model, (2, 2), track_empty=True) agents = ap.AgentList(model, 5) grid.add_agents(agents, empty=True) with pytest.raises(AgentpyError): # Can't use empty if track_empty is False model = ap.Model() model.sim_setup(seed=1) grid = ap.Grid(model, (2, 2)) agents = ap.AgentList(model, 5) grid.add_agents(agents, empty=True) model = ap.Model() model.sim_setup(seed=1) grid = ap.Grid(model, (2, 2), track_empty=True) agents = ap.AgentList(model, 2) grid.add_agents(agents, empty=True) agents = ap.AgentList(model, 2) grid.add_agents(agents, empty=True) assert grid.apply(len).tolist() == [[1, 1], [1, 1]] model = ap.Model() model.sim_setup(seed=1) grid = ap.Grid(model, (2, 2), track_empty=True) agents = ap.AgentList(model, 2) grid.add_agents(agents) agents = ap.AgentList(model, 2) grid.add_agents(agents) assert grid.apply(len).tolist() == [[2, 2], [0, 0]] model = ap.Model() model.sim_setup(seed=2) grid = ap.Grid(model, (2, 2), track_empty=True) agents = ap.AgentList(model, 2) grid.add_agents(agents, empty=True) agents = ap.AgentList(model, 1) grid.add_agents(agents, random=True, empty=True) assert grid.apply(len).tolist() == [[1, 1], [0, 1]] model = ap.Model() model.sim_setup(seed=2) grid = ap.Grid(model, (2, 2), track_empty=True) agents = ap.AgentList(model, 2) grid.add_agents(agents, empty=True) agents = ap.AgentList(model, 1) grid.add_agents(agents, random=True) assert grid.apply(len).tolist() == [[2, 1], [0, 0]] def test_remove(): model = ap.Model() agents = ap.AgentList(model, 2) grid = ap.Grid(model, (2, 2)) grid.add_agents(agents) grid.remove_agents(agents[0]) assert grid.apply(len).tolist() == [[0, 1], [0, 0]] # With track_empty model = ap.Model() agents = ap.AgentList(model, 2) grid = ap.Grid(model, (2, 2), track_empty=True) grid.add_agents(agents) assert list(grid.empty) == [(1, 1), (1, 0)] grid.remove_agents(agents[0]) assert list(grid.empty) == [(1, 1), (1, 0), (0, 0)] def test_grid_iter(): model = ap.Model() agents = ap.AgentList(model, 4) grid = ap.Grid(model, (2, 2)) grid.add_agents(agents) assert len(grid.agents) == 4 assert len(grid.agents[0:1, 0:1]) == 1 def test_attr_grid(): model, grid, agents = make_grid(2, 4) assert grid.attr_grid('id').tolist() == [[1, 2], [3, 4]] def test_apply(): model, grid, agents = make_grid(2, 4) assert grid.apply(len).tolist() == [[1, 1], [1, 1]] def test_move(): model, grid, agents = make_grid(2, 2, track_empty=True) agent = agents[0] assert grid.attr_grid('id').tolist()[0] == [1., 2.] grid.move_to(agent, (1, 0)) # Move in absolute terms grid.move_to(agent, (1, 0)) # Moving to same pos causes no error assert grid.attr_grid('id').tolist()[0][1] == 2.0 assert grid.attr_grid('id').tolist()[1][0] == 1.0 assert np.isnan(grid.attr_grid('id').tolist()[1][1]) assert list(grid.empty) == [(1, 1), (0, 0)] grid.move_by(agent, (-1, 0)) # Move in relative terms assert grid.attr_grid('id').tolist()[0] == [1., 2.] assert list(grid.empty) == [(1, 1), (1, 0)] def test_move_empty_multiple_agents(): model = ap.Model() grid = ap.Grid(model, (2, 2), track_empty=True) agents = ap.AgentList(model, 3) agent = agents[0] grid.add_agents(agents, [(0, 0), (0, 0), (0, 1)]) assert list(grid.empty) == [(1, 1), (1, 0)] grid.move_to(agent, (1, 1)) assert list(grid.empty) == [(1, 0)] grid.move_to(agent, (0, 0)) assert list(grid.empty) == [(1, 0), (1, 1)] grid.move_to(agent, (0, 1)) assert list(grid.empty) == [(1, 0), (1, 1)] def test_move_torus(): model = ap.Model() agents = ap.AgentList(model, 1) agent, = agents grid = ap.Grid(model, (4, 4), torus=True) grid.add_agents(agents, [[0, 0]]) assert grid.positions[agent] == (0, 0) grid.move_by(agent, [-1, -1]) assert grid.positions[agent] == (3, 3) grid.move_by(agent, [1, 0]) assert grid.positions[agent] == (0, 3) grid.move_by(agent, [0, 1]) assert grid.positions[agent] == (0, 0) model = ap.Model() agents = ap.AgentList(model, 1) agent, = agents grid = ap.Grid(model, (4, 4), torus=False) grid.add_agents(agents, [[0, 0]]) assert grid.positions[agent] == (0, 0) grid.move_by(agent, [-1, -1]) assert grid.positions[agent] == (0, 0) grid.move_by(agent, [6, 6]) assert grid.positions[agent] == (3, 3) def test_neighbors(): model, grid, agents = make_grid(5, 25) a = agents[12] assert list(grid.neighbors(a)) == list(grid.neighbors(a)) assert len(grid.neighbors(a, distance=1)) == 8 assert len(grid.neighbors(a, distance=2)) == 24 def test_neighbors_with_torus(): model = ap.Model() agents = ap.AgentList(model, 5) grid = ap.Grid(model, (4, 4), torus=True) grid.add_agents(agents, [[0, 0], [1, 3], [2, 0], [3, 2], [3, 3]]) grid.apply(len).tolist() assert list(grid.neighbors(agents[0]).id) == [5,2] model = ap.Model() agents = ap.AgentList(model, 5) grid = ap.Grid(model, (4, 4), torus=True) grid.add_agents(agents, [[0, 1], [1, 3], [2, 0], [3, 2], [3, 3]]) grid.apply(len).tolist() assert list(grid.neighbors(agents[0]).id) == [4] assert list(grid.neighbors(agents[1]).id) == [3] for d in [2, 3, 4]: model = ap.Model() agents = ap.AgentList(model, 5) grid = ap.Grid(model, (4, 4), torus=True) grid.add_agents(agents, [[0, 1], [1, 3], [2, 0], [3, 2], [3, 3]]) grid.apply(len).tolist() assert list(grid.neighbors(agents[0], distance=d).id) == [2, 3, 4, 5] assert list(grid.neighbors(agents[1], distance=d).id) == [1, 3, 4, 5] def test_field(): model = ap.Model() grid = ap.Grid(model, (2, 2)) grid.add_field('f1', np.array([[1, 2], [3, 4]])) grid.add_field('f2', 5) assert grid.f1.tolist() == [[1, 2], [3, 4]] grid.f1[1, 1] = 8 assert grid.f1.tolist() == [[1, 2], [3, 8]] assert grid.f2.tolist() == [[5, 5], [5, 5]] assert grid.grid.f2.tolist() == grid.f2.tolist() grid.del_field('f2') with pytest.raises(AttributeError): grid.f2 with pytest.raises(AttributeError): grid.grid.f2 def test_record_positions(): model = ap.Model() grid = ap.Grid(model, (2, 2)) agents = ap.AgentList(model, 3) grid.add_agents(agents) grid.record_positions() results = model.run(0, display=False) assert np.all(results.variables.Agent.values == [[0, 0], [0, 1], [1, 0]]) ================================================ FILE: tests/test_init.py ================================================ import agentpy as ap import pkg_resources def test_version(): assert ap.__version__ == pkg_resources.get_distribution('agentpy').version ================================================ FILE: tests/test_model.py ================================================ import pytest import numpy as np import agentpy as ap import random from agentpy.tools import AgentpyError def test_run(): """ Test time limits. """ # Parameter step limit model = ap.Model() assert model.t == 0 model.p.steps = 0 model.run() assert model.t == 0 model.p.steps = 1 model.run() assert model.t == 1 # Passed (additional) steps model = ap.Model({'steps': 1}) model.run() assert model.t == 1 model.run(steps=2) assert model.t == 3 def test_default_parameter_choice(): parameters = {'x': ap.Range(1, 2), 'y': ap.Values(1, 2)} model = ap.Model(parameters) assert model.p == {'x': 1, 'y': 1} def test_report_and_as_function(): class MyModel(ap.Model): def setup(self, y): self.x = self.p.x self.report('x') self.report('y', y) self.stop() parameters = {'x': 1, 'report_seed': False} model = MyModel(parameters, y=2) model.run(display=False) assert model.reporters == {'x': 1, 'y': 2} model_func = MyModel.as_function(y=2) assert model_func(x=1, report_seed=False) == {'x': 1, 'y': 2} def test_update_parameters(): parameters = {'x': 1, 'y': 2} model = ap.Model(parameters) assert model.p == {'x': 1, 'y': 2} parameters2 = {'z': 4, 'y': 3} model.set_parameters(parameters2) assert model.p == {'x': 1, 'y': 3, 'z': 4} def test_sim_methods(): class MyModel(ap.Model): def setup(self, y): self.x = self.p.x self.y = y def step(self): self.x = False self.y = False self.stop() parameters = {'x': True} model = MyModel(parameters, y=True) assert model.t == 0 model.sim_setup() assert model.x is True assert model.y is True model.sim_step() assert model.x is False assert model.y is False model.sim_reset() model.sim_setup() assert model.x is True assert model.y is True def test_run_seed(): """ Test random seed setting. """ rd = random.Random(1) npseed = rd.getrandbits(128) nprd = np.random.default_rng(seed=npseed) n1 = rd.randint(0, 100) n2 = nprd.integers(100) model = ap.Model({'seed': 1}) model.run(steps=0, display=False) assert model.random.randint(0, 100) == n1 assert model.nprandom.integers(100) == n2 model = ap.Model() model.run(seed=1, steps=0, display=False) assert model.random.randint(0, 100) == n1 assert model.nprandom.integers(100) == n2 def test_stop(): """ Test method Model.stop() """ class Model(ap.Model): def step(self): if self.t == 2: self.stop() model = Model() model.run() assert model.t == 2 def test_setup(): """ Test setup() for all object types """ class MySetup: def setup(self, a): self.a = a + 1 class MyAgentType(MySetup, ap.Agent): pass class MySpaceType(MySetup, ap.Space): pass class MyNwType(MySetup, ap.Network): pass class MyGridType(MySetup, ap.Grid): pass model = ap.Model() agents = ap.AgentList(model, 1, b=1) agents.extend(ap.AgentList(model, 1, MyAgentType, a=1)) model.S1 = MySpaceType(model, shape=(1, 1), a=2) model.G1 = MyGridType(model, shape=(1, 1), a=3) model.N1 = MyNwType(model, a=4) # Standard setup implements keywords as attributes # Custom setup uses only keyword a and adds 1 assert agents[0].b == 1 assert agents[1].a == 2 assert model.S1.a == 3 assert model.G1.a == 4 assert model.N1.a == 5 def test_create_output(): """ Should put variables directly into output if there are only model variables, or make a subdict if there are also other variables. """ model = ap.Model() model.record('x', 0) model.run(1) assert list(model.output.variables.Model.keys()) == ['x'] model = ap.Model(_run_id=(1, 2)) model.agents = ap.AgentList(model, 1) model.agents.record('x', 0) model.record('x', 0) model.run(1) assert list(model.output.variables.keys()) == ['Agent', 'Model'] # Run id and scenario should be added to output assert model.output.variables.Model.reset_index()['sample_id'][0] == 1 assert model.output.variables.Model.reset_index()['iteration'][0] == 2 def test_report_seed(): model = ap.Model() results = model.run(steps=0, display=False) assert 'reporters' in results and 'seed' in results.reporters model = ap.Model({'report_seed': True}) results = model.run(steps=0, display=False) assert 'reporters' in results and 'seed' in results.reporters model = ap.Model({'report_seed': 1}) results = model.run(steps=0, display=False) assert 'reporters' in results and 'seed' in results.reporters model = ap.Model({'report_seed': False}) results = model.run(steps=0, display=False) assert not ('reporters' in results and 'seed' in results.reporters) model = ap.Model({'report_seed': 0}) results = model.run(steps=0, display=False) assert not ('reporters' in results and 'seed' in results.reporters) ================================================ FILE: tests/test_network.py ================================================ import pytest import networkx as nx import agentpy as ap def test_add_agents(): # Add agents to existing nodes graph = nx.Graph() graph.add_node(0) graph.add_node(1) model = ap.Model() env = ap.Network(model, graph=graph) agents = ap.AgentList(model, 2) env.add_agents(agents, positions=env.nodes) for agent in agents: agent.pos = env.positions[agent] agents.node = env.nodes env.graph.add_edge(*agents.pos) # Test structure assert list(agents.pos) == list(agents.node) assert env.nodes == env.graph.nodes() assert list(env.graph.edges) == [tuple(agents.pos)] assert list(env.neighbors(agents[0]).id) == [3] # Add agents as new nodes model2 = ap.Model() agents2 = ap.AgentList(model2, 2) env2 = ap.Network(model2) env2.add_agents(agents2) for agent in agents2: agent.pos = env2.positions[agent] env2.graph.add_edge(*agents2.pos) # Test if the two graphs are identical assert env.graph.nodes.__repr__() == env2.graph.nodes.__repr__() assert env.graph.edges.__repr__() == env2.graph.edges.__repr__() def test_move_agent(): # Move agent one node to another model = ap.Model() graph = ap.Network(model) n1 = graph.add_node() n2 = graph.add_node() a = ap.Agent(model) graph.add_agents([a], positions=[n1]) assert len(n1) == 1 assert len(n2) == 0 assert graph.positions[a] is n1 graph.move_to(a, n2) assert len(n1) == 0 assert len(n2) == 1 assert graph.positions[a] is n2 def test_remove_agents(): model = ap.Model() agents = ap.AgentList(model, 2) nw = ap.Network(model) nw.add_agents(agents) agent = agents[0] node = nw.positions[agent] nw.remove_agents(agent) assert len(nw.agents) == 1 assert len(nw.nodes) == 2 nw.remove_node(node) assert len(nw.agents) == 1 assert len(nw.nodes) == 1 agent2 = agents[1] nw.remove_node(nw.positions[agent2]) assert len(nw.agents) == 0 assert len(nw.nodes) == 0 ================================================ FILE: tests/test_objects.py ================================================ import pytest import agentpy as ap from agentpy.tools import AgentpyError def test_basics(): model = ap.Model() agent = ap.Agent(model) agent.x = 1 agent['y'] = 2 assert agent['x'] == 1 assert agent.y == 2 assert agent.__repr__() == "Agent (Obj 1)" assert model.type == 'Model' assert model.__repr__() == "Model" assert isinstance(model.info, ap.tools.InfoStr) with pytest.raises(AttributeError): assert agent.z def test_record(): """ Record a dynamic variable """ model = ap.Model() model.var1 = 1 model.var2 = 2 model.record(['var1', 'var2']) model.record('var3', 3) assert len(list(model.log.keys())) == 3 + 1 # One for time assert model.log['var1'] == [1] assert model.log['var2'] == [2] assert model.log['var3'] == [3] def test_record_all(): """ Record all dynamic variables """ model = ap.Model() model.var1 = 1 model.var2 = 2 model.record(model.vars) assert len(list(model.log.keys())) == 3 assert model.log['var1'] == [1] assert model.log['var2'] == [2] ================================================ FILE: tests/test_sample.py ================================================ import pytest import agentpy as ap from SALib.sample import saltelli from agentpy.tools import AgentpyError def test_repr(): v = ap.Values(1, 2) assert v.__repr__() == "Set of 2 parameter values" r = ap.Range(1, 2) assert r.__repr__() == "Parameter range from 1 to 2" ir = ap.IntRange(1, 2) assert ir.__repr__() == "Integer parameter range from 1 to 2" s = ap.Sample({'x': r}, 10) assert s.__repr__() == "Sample of 10 parameter combinations" def test_seed(): parameters = {'x': ap.Range(), 'seed': 1} sample = ap.Sample(parameters, 2, randomize=True) assert list(sample) == [{'x': 0.0, 'seed': 272996653310673477252411125948039410165}, {'x': 1.0, 'seed': 40125655066622386354123033417875897284}] sample = ap.Sample(parameters, 2, randomize=False) assert list(sample) == [{'x': 0.0, 'seed': 1}, {'x': 1.0, 'seed': 1}] def test_errors(): parameters = {'x': ap.Range(), 'seed': 1} with pytest.raises(AgentpyError): sample = ap.Sample(parameters) def test_linspace_product(): parameters = { 'a': ap.IntRange(1, 2), 'b': ap.Range(3, 3.5), 'c': ap.Values(*'xyz'), 'd': True } sample = ap.Sample(parameters, n=3) assert list(sample) == [ {'a': 1, 'b': 3.0, 'c': 'x', 'd': True}, {'a': 1, 'b': 3.0, 'c': 'y', 'd': True}, {'a': 1, 'b': 3.0, 'c': 'z', 'd': True}, {'a': 1, 'b': 3.25, 'c': 'x', 'd': True}, {'a': 1, 'b': 3.25, 'c': 'y', 'd': True}, {'a': 1, 'b': 3.25, 'c': 'z', 'd': True}, {'a': 1, 'b': 3.5, 'c': 'x', 'd': True}, {'a': 1, 'b': 3.5, 'c': 'y', 'd': True}, {'a': 1, 'b': 3.5, 'c': 'z', 'd': True}, {'a': 2, 'b': 3.0, 'c': 'x', 'd': True}, {'a': 2, 'b': 3.0, 'c': 'y', 'd': True}, {'a': 2, 'b': 3.0, 'c': 'z', 'd': True}, {'a': 2, 'b': 3.25, 'c': 'x', 'd': True}, {'a': 2, 'b': 3.25, 'c': 'y', 'd': True}, {'a': 2, 'b': 3.25, 'c': 'z', 'd': True}, {'a': 2, 'b': 3.5, 'c': 'x', 'd': True}, {'a': 2, 'b': 3.5, 'c': 'y', 'd': True}, {'a': 2, 'b': 3.5, 'c': 'z', 'd': True}, {'a': 2, 'b': 3.0, 'c': 'x', 'd': True}, {'a': 2, 'b': 3.0, 'c': 'y', 'd': True}, {'a': 2, 'b': 3.0, 'c': 'z', 'd': True}, {'a': 2, 'b': 3.25, 'c': 'x', 'd': True}, {'a': 2, 'b': 3.25, 'c': 'y', 'd': True}, {'a': 2, 'b': 3.25, 'c': 'z', 'd': True}, {'a': 2, 'b': 3.5, 'c': 'x', 'd': True}, {'a': 2, 'b': 3.5, 'c': 'y', 'd': True}, {'a': 2, 'b': 3.5, 'c': 'z', 'd': True} ] def test_linspace_zip(): parameters = { 'a': ap.Range(), # 0, 1 Default 'b': ap.Values(3, 4), } sample = ap.Sample(parameters, n=2, product=False) assert list(sample) == [{'a': 0.0, 'b': 3}, {'a': 1.0, 'b': 4}] def test_sample_saltelli(): parameters = { 'a': ap.IntRange(1, 2), 'b': ap.Range(3, 3.5), 'c': ap.Values(*'xyz'), 'd': True } sample = ap.Sample(parameters, n=2, method='saltelli', calc_second_order=False) problem = { 'num_vars': 3, 'names': ['a', 'b', 'c'], 'bounds': [[1., 3.], [3., 3.5], [0., 3.], ] } param_values = saltelli.sample(problem, 2, calc_second_order=False) for s1, s2 in zip(sample, param_values): assert s1['a'] == int(s2[0]) assert s1['b'] == s2[1] assert s1['c'] == parameters['c'].values[int(s2[2])] assert s1['d'] == parameters['d'] def test_sample_saltelli_second(): parameters = { 'a': ap.IntRange(1, 2), 'b': ap.Range(3, 3.5), 'c': ap.Values(*'xyz'), 'd': True } sample = ap.Sample(parameters, n=2, method='saltelli', calc_second_order=True) problem = { 'num_vars': 3, 'names': ['a', 'b', 'c'], 'bounds': [[1., 3.], [3., 3.5], [0., 3.], ] } param_values = saltelli.sample(problem, 2, calc_second_order=True) for s1, s2 in zip(sample, param_values): assert s1['a'] == int(s2[0]) assert s1['b'] == s2[1] assert s1['c'] == parameters['c'].values[int(s2[2])] assert s1['d'] == parameters['d'] ================================================ FILE: tests/test_sequences.py ================================================ import pytest import agentpy as ap import numpy as np from agentpy.tools import AgentpyError def test_basics(): model = ap.Model() l1 = ap.AgentList(model, 0) l2 = ap.AgentList(model, 1) l3 = ap.AgentList(model, 2) assert l1.__repr__() == "AgentList (0 objects)" assert l2.__repr__() == "AgentList (1 object)" assert l3.__repr__() == "AgentList (2 objects)" agentlist = ap.AgentList(model, 2) AgentDList = ap.AgentDList(model, 2) agentiter = ap.AgentIter(model, agentlist) AgentDListiter = ap.AgentDListIter(agentlist) attriter = agentiter.id assert np.array(agentlist).tolist() == list(agentlist) assert np.array(AgentDList).tolist() == list(AgentDList) assert np.array(agentiter).tolist() == list(agentiter) assert np.array(AgentDListiter).tolist() == list(AgentDListiter) assert np.array(attriter).tolist() == list(attriter) with pytest.raises(AgentpyError): _ = agentiter[2] # No item lookup allowed # Seta and get attribute for agents in [agentlist, AgentDList, agentiter]: agents.x = 1 agents.y = 1 assert list(agents.x) == [1, 1] agents.x += agents.x assert list(agents.x) == [2, 2] def test_kwargs(): model = ap.Model() attrs = [1, 2] agents = ap.AgentList(model, 2, x=attrs) assert list(agents.x) == [[1, 2], [1, 2]] model = ap.Model() attrs = ap.AttrIter([1, 2]) agents = ap.AgentList(model, 2, x=attrs) assert list(agents.x) == [1, 2] with pytest.raises(AgentpyError): agents = ap.AgentList(model, 2, ap.Agent, 'arg w/o keyword') def test_add(): model = ap.Model() agents1 = ap.AgentList(model, 2) agents2 = ap.AgentList(model, 2) agents3 = agents1 + agents2 assert list(agents3.id) == [1, 2, 3, 4] agents4 = agents3 + [ap.Agent(model)] assert list(agents4.id) == [1, 2, 3, 4, 5] model = ap.Model() agents1 = ap.AgentDList(model, 2) agents2 = ap.AgentDList(model, 2) agents3 = agents1 + agents2 assert list(agents3.id) == [1, 2, 3, 4] agents4 = agents3 + [ap.Agent(model)] assert list(agents4.id) == [1, 2, 3, 4, 5] def test_agent_group(): class MyAgent(ap.Agent): def method(self, x): if self.id == 2: self.model.agents.pop(x) self.model.called.append(self.id) # Delete later element in list model = ap.Model() model.called = [] model.agents = ap.AgentDList(model, 4, MyAgent) model.agents.buffer().method(2) assert model.called == [1, 2, 4] # Delete earlier element in list model = ap.Model() model.called = [] model.agents = ap.AgentDList(model, 4, MyAgent) model.agents.buffer().method(0) assert model.called == [1, 2, 3, 4] # Incorrect result without buffer model = ap.Model() model.called = [] model.agents = ap.AgentList(model, 4, MyAgent) model.agents.method(0) assert model.called == [1, 2, 4] # Combine with buffer - still number 3 that gets deleted model = ap.Model() model.run(seed=2, steps=0, display=False) model.called = [] model.agents = ap.AgentDList(model, 4, MyAgent) model.agents.shuffle().buffer().method(2) assert model.called == [2, 4, 1] # Combination order doesn't matter model = ap.Model() model.run(seed=2, steps=0, display=False) model.called = [] model.agents = ap.AgentDList(model, 4, MyAgent) model.agents.buffer().shuffle().method(2) assert model.called == [2, 4, 1] def test_attr_list(): model = ap.Model() model.agents = ap.AgentList(model, 2) model.agents.id[1] = 5 assert model.agents.id[1] == 5 model.agents.x = 1 model.agents.f = lambda: 2 assert list(model.agents.x) == [1, 1] assert list(model.agents.f()) == [2, 2] with pytest.raises(AttributeError): assert list(model.agents.y) # Convert to list to call attribute with pytest.raises(TypeError): assert model.agents.x() # noqa model = ap.Model() l3 = ap.AgentList(model, 2) assert l3.id == [1, 2] assert l3.id.__repr__() == "[1, 2]" assert l3.p.update({1: 1}) == [None, None] assert l3.p == [{1: 1}, {1: 1}] # Attribute list with attribute key # sets/gets attr like a normal list al = l3.id + 1 al[1] = 0 assert al[1] == 0 def test_select(): """ Select subsets with boolean operators. """ model = ap.Model() model.agents = ap.AgentList(model, 3) selection1 = model.agents.id == 2 selection2 = model.agents.id != 2 selection3 = model.agents.id < 2 selection4 = model.agents.id > 2 selection5 = model.agents.id <= 2 selection6 = model.agents.id >= 2 assert selection1 == [False, True, False] assert selection2 == [True, False, True] assert selection3 == [True, False, False] assert selection4 == [False, False, True] assert selection5 == [True, True, False] assert selection6 == [False, True, True] assert list(model.agents.select(selection1).id) == [2] model = ap.Model() model.agents = ap.AgentDList(model, 3) selection1 = model.agents.id == 2 assert selection1 == [False, True, False] assert list(model.agents.select(selection1).id) == [2] def test_random(): """ Test random shuffle and selection. """ # Agent List model = ap.Model() model.run(steps=0, seed=1, display=False) model.agents = ap.AgentList(model, 10) assert list(model.agents.random())[0].id == 2 assert model.agents.shuffle()[0].id == 9 assert list(model.agents.random(11, replace=True).id)[0] == 6 assert list(model.agents.random(2).id) == [9, 5] # Test with single agent model = ap.Model() agents = ap.AgentList(model, 1) assert agents.shuffle()[0] is agents[0] assert list(agents.random())[0] is agents[0] # Agent Group model = ap.Model() model.run(steps=0, seed=1, display=False) model.agents = ap.AgentDList(model, 10) assert list(model.agents.random())[0].id == 2 assert list(model.agents.shuffle())[0].id == 9 def test_sort(): """ Test sorting method. """ model = ap.Model() model.agents = ap.AgentList(model, 2) model.agents[0].x = 1 model.agents[1].x = 0 model.agents.sort('x') assert list(model.agents.x) == [0, 1] assert list(model.agents.id) == [2, 1] model = ap.Model() model.agents = ap.AgentDList(model, 2) model.agents[0].x = 1 model.agents[1].x = 0 model.agents = model.agents.sort('x') # Not in-place assert list(model.agents.x) == [0, 1] assert list(model.agents.id) == [2, 1] def test_arithmetics(): """ Test arithmetic operators """ model = ap.Model() model.agents = ap.AgentList(model, 3) agents = model.agents agents.x = 1 assert agents.x.attr == "x" assert list(agents.x) == [1, 1, 1] agents.y = ap.AttrIter([1, 2, 3]) assert list(agents.y) == [1, 2, 3] agents.x = agents.x + agents.y assert list(agents.x) == [2, 3, 4] agents.x = agents.x - ap.AttrIter([1, 1, 1]) assert list(agents.x) == [1, 2, 3] agents.x += 1 assert list(agents.x) == [2, 3, 4] agents.x -= 1 assert list(agents.x) == [1, 2, 3] agents.x *= 2 assert list(agents.x) == [2, 4, 6] agents.x = agents.x * agents.x assert list(agents.x) == [4, 16, 36] agents.x = agents.x / agents.x assert list(agents.x)[0] == pytest.approx(1.) agents.x /= 2 assert list(agents.x)[0] == pytest.approx(0.5) def test_remove(): model = ap.Model() agents = ap.AgentList(model, 3, ap.Agent) assert list(agents.id) == [1, 2, 3] agents.remove(agents[0]) assert list(agents.id) == [2, 3] model = ap.Model() agents = ap.AgentDList(model, 3, ap.Agent) assert list(agents.id) == [1, 2, 3] agents.remove(agents[0]) assert list(agents.id) == [3, 2] model = ap.Model() agents = ap.AgentDList(model, 3, ap.Agent) assert list(agents.id) == [1, 2, 3] agents.pop(0) assert list(agents.id) == [3, 2] model = ap.Model() agents = ap.AgentSet(model, 3, ap.Agent) assert set(agents.id) == set([1, 2, 3]) agents.remove(next(iter(agents))) assert len(agents.id) == 2 ================================================ FILE: tests/test_space.py ================================================ import pytest import agentpy as ap import numpy as np import scipy def make_space(s, n=0, torus=False): model = ap.Model() agents = ap.AgentList(model, n) space = ap.Space(model, (s, s), torus=torus) space.add_agents(agents) for agent in agents: agent.pos = space.positions[agent] return model, space, agents def test_general(): model, space, agents = make_space(2) assert space.shape == (2, 2) assert space.ndim == 2 def test_KDTree(): model, space, agents = make_space(2) assert space.kdtree is None assert len(space.select((1, 1), 2)) == 0 space.add_agents([ap.Agent(model)]) assert isinstance(space.kdtree, scipy.spatial.cKDTree) assert len(space.select((1, 1), 2)) == 1 def test_add_agents_random(): model, space, agents = make_space(2) model.run(steps=0, seed=1, display=False) agent = ap.Agent(model) space.add_agents([agent], random=True) assert list(space.positions[agent]) == [1.527549237953228, 0.5101380514788434] def test_remove(): model, space, agents = make_space(2, 2) agent = agents[0] space.remove_agents(agent) assert len(space.positions) == 1 assert len(space.agents) == 1 assert list(space.agents)[0].id == 2 def test_positions(): # Disconnected space model, space, agents = make_space(2) a1 = ap.Agent(model) a2 = ap.Agent(model) space.add_agents([a1]) space.add_agents([a2], positions=[(1, 2)]) # Position reference assert list(space.positions[a1]) == [0, 0] assert list(space.positions[a2]) == [1, 2] assert [list(x) for x in space.positions.values()] == [[0, 0], [1, 2]] # Get agents assert len(space.select((1, 1), 0.9)) == 0 assert len(space.select((1, 1), 1)) == 1 assert len(space.select((1, 1), 1.4)) == 1 assert len(space.select((1, 1), 1.42)) == 2 # Get neighbors assert len(space.neighbors(a1, distance=2.0)) == 0 assert len(space.neighbors(a1, distance=2.5)) == 1 assert list(space.neighbors(a1, distance=2.5))[0] is a2 # Movement restricted by border space.move_by(a2, (2, -3)) assert list(space.positions[a2]) == [2, 0] # Move directly space.move_to(a2, (1, 1)) assert list(space.positions[a2]) == [1, 1] # Connected space (toroidal) model, space, agents = make_space(2, torus=True) a1 = ap.Agent(model) a2 = ap.Agent(model) space.add_agents([a1]) space.add_agents([a2], positions=[(0, 1.9)]) assert list(space.neighbors(a1, distance=0.11))[0] == a2 # Movement over border space.move_by(a2, (-3, 1.1)) assert list(space.positions[a2]) == [1, 1] ================================================ FILE: tests/test_tools.py ================================================ import pytest import agentpy as ap from agentpy.tools import * def test_InfoStr(): assert InfoStr('yay').__repr__() == 'yay' def test_make_list(): make_list = ap.tools.make_list assert make_list('123') == ['123'] assert make_list(['123']) == ['123'] assert make_list(None) == [] assert make_list(None, keep_none=True) == [None] def test_make_matrix(): class MyList(list): def __repr__(self): return f"mylist {super().__repr__()}" m = make_matrix([2, 2], list_type=MyList) assert m.__repr__() == "mylist [mylist [None, None], mylist [None, None]]" def test_attr_dict(): ad = ap.AttrDict({'a': 1}) ad.b = 2 assert ad.a == 1 assert ad.b == 2 assert ad.a == ad['a'] assert ad.b == ad['b'] assert ad._short_repr() == "AttrDict (2 entries)" assert AttrDict(None) == {} # Initialize with None def test_ListDict(): x = ListDict([1, 2, 3, 4, 5]) assert list(x) == [1, 2, 3, 4, 5] x.replace(3, 8) assert list(x) == [1, 2, 8, 4, 5] x.append(9) assert list(x) == [1, 2, 8, 4, 5, 9] x.append(9) assert list(x) == [1, 2, 8, 4, 5, 9] x.remove(2) assert list(x) == [1, 9, 8, 4, 5] x.pop(0) assert list(x) == [5, 9, 8, 4] ================================================ FILE: tests/test_visualization.py ================================================ import pytest import agentpy as ap import numpy as np import matplotlib.pyplot as plt def test_gridplot(): """Test only for errors.""" # Use cmap values grid1 = np.array([[1, 1], [1, np.nan]]) ap.gridplot(grid1) # Use RGB values x = (1., 1., 0.) grid2 = np.array([[x, x], [x, x]]) ap.gridplot(grid2) # Use color dict and convert cdict = {1: 'g'} ap.gridplot(grid1, color_dict=cdict, convert=True) # Use only convert grid3 = [['g', 'g'], ['g', np.nan]] ap.gridplot(grid3, convert=True) # Assign to axis fig, ax = plt.subplots() ap.gridplot(grid1, ax=ax) assert True def test_animation(): """Test only for errors.""" def my_plot(model, ax): ax.set_title(f"{model.t}") fig, ax = plt.subplots() my_model = ap.Model({'steps': 2}) animation = ap.animate(my_model, fig, ax, my_plot, skip=1) animation.to_jshtml() # Stop immediately my_model = ap.Model({'steps': 0}) animation = ap.animate(my_model, fig, ax, my_plot) animation.to_jshtml() # Skip more than steps my_model = ap.Model({'steps': 0}) animation = ap.animate(my_model, fig, ax, my_plot, skip=1) animation.to_jshtml() # Try additional steps model = ap.Model({'steps': 1}) animation = ap.animate(model, fig, ax, my_plot) animation.to_jshtml() assert model.t == 1 animation = ap.animate(model, fig, ax, my_plot, steps=2) animation.to_jshtml() assert model.t == 3