diff --git a/docs/conf.py b/docs/conf.py index 102ce58fe79..79610fe2250 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,6 +42,7 @@ "sphinx.ext.mathjax", "sphinx.ext.ifconfig", "sphinx.ext.viewcode", + "nbsphinx", ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/index.rst b/docs/index.rst index 94fd22f89d0..52a2ccee9e0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -95,8 +95,8 @@ ABM features users have shared that you may want to use in your model :maxdepth: 7 Mesa Overview - tutorials/intro_tutorial - tutorials/adv_tutorial + tutorials/intro_tutorial.ipynb + tutorials/adv_tutorial.ipynb Best Practices Useful Snippets API Documentation diff --git a/docs/tutorials/adv_tutorial.rst b/docs/tutorials/adv_tutorial.rst deleted file mode 100644 index 5f23a0e864b..00000000000 --- a/docs/tutorials/adv_tutorial.rst +++ /dev/null @@ -1,560 +0,0 @@ -Advanced Tutorial -================= - -Adding visualization -~~~~~~~~~~~~~~~~~~~~ - -So far, we’ve built a model, run it, and analyzed some output -afterwards. However, one of the advantages of agent-based models is that -we can often watch them run step by step, potentially spotting -unexpected patterns, behaviors or bugs, or developing new intuitions, -hypotheses, or insights. Other times, watching a model run can explain -it to an unfamiliar audience better than static explanations. Like many -ABM frameworks, Mesa allows you to create an interactive visualization -of the model. In this section we’ll walk through creating a -visualization using built-in components, and (for advanced users) how to -create a new visualization element. - -**Note for Jupyter users: Due to conflicts with the tornado server Mesa -uses and Jupyter, the interactive browser of your model will load but -likely not work. This will require you to run the code from .py -files. The Mesa development team is working to develop a** `Jupyter -compatible interface `_. - -First, a quick explanation of how Mesa’s interactive visualization -works. Visualization is done in a browser window, using JavaScript to -draw the different things being visualized at each step of the model. To -do this, Mesa launches a small web server, which runs the model, turns -each step into a JSON object (essentially, structured plain text) and -sends those steps to the browser. - -A visualization is built up of a few different modules: for example, a -module for drawing agents on a grid, and another one for drawing a chart -of some variable. Each module has a Python part, which runs on the -server and turns a model state into JSON data; and a JavaScript side, -which takes that JSON data and draws it in the browser window. Mesa -comes with a few modules built in, and let you add your own as well. - - -Grid Visualization -^^^^^^^^^^^^^^^^^^ - -To start with, let’s have a visualization where we can watch the agents -moving around the grid. For this, you will need to put your model code -in a separate Python source file; for example, ``MoneyModel.py``. Next, -either in the same file or in a new one (e.g. ``MoneyModel_Viz.py``) -import the server class and the Canvas Grid class (so-called because it -uses HTML5 canvas to draw a grid). If you’re in a new file, you’ll also -need to import the actual model object. - -.. code:: ipython3 - - import mesa - - # If MoneyModel.py is where your code is: - # from MoneyModel import MoneyModel - -``CanvasGrid`` works by looping over every cell in a grid, and -generating a portrayal for every agent it finds. A portrayal is a -dictionary (which can easily be turned into a JSON object) which tells -the JavaScript side how to draw it. The only thing we need to provide is -a function which takes an agent, and returns a portrayal object. Here’s -the simplest one: it’ll draw each agent as a red, filled circle which -fills half of each cell. - -.. code:: ipython3 - - def agent_portrayal(agent): - portrayal = { - "Shape": "circle", - "Color": "red", - "Filled": "true", - "Layer": 0, - "r": 0.5, - } - return portrayal - -In addition to the portrayal method, we instantiate a canvas grid with -its width and height in cells, and in pixels. In this case, let’s create -a 10x10 grid, drawn in 500 x 500 pixels. - -.. code:: ipython3 - - grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500) - -.. code:: ipython3 - - """ - The full code should now look like: - """ - # from MoneyModel import * - import mesa - - - def agent_portrayal(agent): - portrayal = { - "Shape": "circle", - "Filled": "true", - "Layer": 0, - "Color": "red", - "r": 0.5, - } - return portrayal - - - grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500) - server = mesa.visualization.ModularServer( - MoneyModel, [grid], "Money Model", {"N": 100, "width": 10, "height": 10} - ) - server.port = 8521 # The default - server.launch() - -Now we create and launch the actual server. We do this with the -following arguments: - -- The model class we’re running and visualizing; in this case, - ``MoneyModel``. -- A list of module objects to include in the visualization; here, just - ``[grid]`` -- The title of the model: “Money Model” -- Any inputs or arguments for the model itself. In this case, 100 - agents, and height and width of 10. - -Once we create the server, we set the port for it to listen on (you can -treat this as just a piece of the URL you’ll open in the browser). -Finally, when you’re ready to run the visualization, use the server’s -``launch()`` method. - -.. code:: python - - server = ModularServer(MoneyModel, - [grid], - "Money Model", - {"N":100, "width":10, "height":10}) - server.port = 8521 # The default - server.launch() - -The full code should now look like: - -.. code:: python - - from MoneyModel import * - import mesa - - - def agent_portrayal(agent): - portrayal = {"Shape": "circle", - "Filled": "true", - "Layer": 0, - "Color": "red", - "r": 0.5} - return portrayal - - grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500) - server = mesa.visualization.ModularServer( - MoneyModel, [grid], "Money Model", {"N": 100, "width": 10, "height": 10} - )server = ModularServer(MoneyModel, - [grid], - "Money Model", - {"N":100, "width":10, "height":10}) - server.port = 8521 # The default - server.launch() - -Now run this file; this should launch the interactive visualization -server and open your web browser automatically. (If the browser doesn’t -open automatically, try pointing it at http://127.0.0.1:8521 manually. -If this doesn’t show you the visualization, something may have gone -wrong with the server launch.) - -You should see something like the figure below: the model title, an -empty space where the grid will be, and a control panel off to the -right. - -.. figure:: files/viz_empty.png - :alt: Empty Visualization - - Empty Visualization - -Click the ‘reset’ button on the control panel, and you should see the -grid fill up with red circles, representing agents. - -.. figure:: files/viz_redcircles.png - :alt: Redcircles Visualization - - Redcircles Visualization - -Click ‘step’ to advance the model by one step, and the agents will move -around. Click ‘run’ and the agents will keep moving around, at the rate -set by the ‘fps’ (frames per second) slider at the top. Try moving it -around and see how the speed of the model changes. Pressing ‘pause’ will -(as you’d expect) pause the model; presing ‘run’ again will restart it. -Finally, ‘reset’ will start a new instantiation of the model. - -To stop the visualization server, go back to the terminal where you -launched it, and press Control+c. - -Changing the agents -^^^^^^^^^^^^^^^^^^^ - -In the visualization above, all we could see is the agents moving around -– but not how much money they had, or anything else of interest. Let’s -change it so that agents who are broke (wealth 0) are drawn in grey, -smaller, and above agents who still have money. - -To do this, we go back to our ``agent_portrayal`` code and add some code -to change the portrayal based on the agent properties. - -.. code:: python - - def agent_portrayal(agent): - portrayal = {"Shape": "circle", - "Filled": "true", - "r": 0.5} - - if agent.wealth > 0: - portrayal["Color"] = "red" - portrayal["Layer"] = 0 - else: - portrayal["Color"] = "grey" - portrayal["Layer"] = 1 - portrayal["r"] = 0.2 - return portrayal - -Now launch the server again - this will open a new browser window -pointed at the updated visualization. Initially it looks the same, but -advance the model and smaller grey circles start to appear. Note that -since the zero-wealth agents have a higher layer number, they are drawn -on top of the red agents. - -.. figure:: files/viz_greycircles.png - :alt: Greycircles Visualization - - Greycircles Visualization - -Adding a chart -^^^^^^^^^^^^^^ - -Next, let’s add another element to the visualization: a chart, tracking -the model’s Gini Coefficient. This is another built-in element that Mesa -provides. - -The basic chart pulls data from the model’s DataCollector, and draws it -as a line graph using the `Charts.js `__ -JavaScript libraries. We instantiate a chart element with a list of -series for the chart to track. Each series is defined in a dictionary, -and has a ``Label`` (which must match the name of a model-level variable -collected by the DataCollector) and a ``Color`` name. We can also give -the chart the name of the DataCollector object in the model. - -Finally, we add the chart to the list of elements in the server. The -elements are added to the visualization in the order they appear, so the -chart will appear underneath the grid. - -.. code:: python - - chart = mesa.visualization.ChartModule([{"Label": "Gini", - "Color": "Black"}], - data_collector_name='datacollector') - - server = mesa.visualization.ModularServer(MoneyModel, - [grid, chart], - "Money Model", - {"N":100, "width":10, "height":10}) - -Launch the visualization and start a model run, and you’ll see a line -chart underneath the grid. Every step of the model, the line chart -updates along with the grid. Reset the model, and the chart resets too. - -.. figure:: files/viz_chart.png - :alt: Chart Visualization - - Chart Visualization - -**Note:** You might notice that the chart line only starts after a -couple of steps; this is due to a bug in Charts.js which will hopefully -be fixed soon. - -Building your own visualization component -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -**Note:** This section is for users who have a basic familiarity with -JavaScript. If that’s not you, don’t worry! (If you’re an advanced -JavaScript coder and find things that we’ve done wrong or inefficiently, -please `let us know `__!) - -If the visualization elements provided by Mesa aren’t enough for you, -you can build your own and plug them into the model server. - -First, you need to understand how the visualization works under the -hood. Remember that each visualization module has two sides: a Python -object that runs on the server and generates JSON data from the model -state (the server side), and a JavaScript object that runs in the -browser and turns the JSON into something it renders on the screen (the -client side). - -Obviously, the two sides of each visualization must be designed in -tandem. They result in one Python class, and one JavaScript ``.js`` -file. The path to the JavaScript file is a property of the Python class. - -For this example, let’s build a simple histogram visualization, which -can count the number of agents with each value of wealth. We’ll use the -`Charts.js `__ JavaScript library, which is -already included with Mesa. If you go and look at its documentation, -you’ll see that it had no histogram functionality, which means we have -to build our own out of a bar chart. We’ll keep the histogram as simple -as possible, giving it a fixed number of integer bins. If you were -designing a more general histogram to add to the Mesa repository for -everyone to use across different models, obviously you’d want something -more general. - -Client-Side Code -^^^^^^^^^^^^^^^^ - -In general, the server- and client-side are written in tandem. However, -if you’re like me and more comfortable with Python than JavaScript, it -makes sense to figure out how to get the JavaScript working first, and -then write the Python to be compatible with that. - -In the same directory as your model, create a new file called -``HistogramModule.js``. This will store the JavaScript code for the -client side of the new module. - -JavaScript classes can look alien to people coming from other languages -– specifically, they can look like functions. (The Mozilla `Introduction -to Object-Oriented -JavaScript `__ -is a good starting point). In ``HistogramModule.js``, start by creating -the class itself: - -.. code:: javascript - - const HistogramModule = function(bins, canvas_width, canvas_height) { - // The actual code will go here. - }; - -Note that our object is instantiated with three arguments: the number of -integer bins, and the width and height (in pixels) the chart will take -up in the visualization window. - -When the visualization object is instantiated, the first thing it needs -to do is prepare to draw on the current page. To do so, it adds a -`canvas `__ -tag to the page. It also gets the canvas' context, which is required for doing -anything with it. - -.. code:: javascript - - const HistogramModule = function(bins, canvas_width, canvas_height) { - // Create the canvas object: - const canvas = document.createElement("canvas"); - Object.assign(canvas, { - width: canvas_width, - height: canvas_height, - style: "border:1px dotted", - }); - // Append it to #elements: - const elements = document.getElementById("elements"); - elements.appendChild(canvas); - - // Create the context and the drawing controller: - const context = canvas.getContext("2d"); - }; - - -Look at the Charts.js `bar chart -documentation `__. -You’ll see some of the boilerplate needed to get a chart set up. -Especially important is the ``data`` object, which includes the -datasets, labels, and color options. In this case, we want just one -dataset (we’ll keep things simple and name it “Data”); it has ``bins`` -for categories, and the value of each category starts out at zero. -Finally, using these boilerplate objects and the canvas context we -created, we can create the chart object. - -.. code:: javascript - - const HistogramModule = function(bins, canvas_width, canvas_height) { - // Create the canvas object: - const canvas = document.createElement("canvas"); - Object.assign(canvas, { - width: canvas_width, - height: canvas_height, - style: "border:1px dotted", - }); - // Append it to #elements: - const elements = document.getElementById("elements"); - elements.appendChild(canvas); - - // Create the context and the drawing controller: - const context = canvas.getContext("2d"); - - // Prep the chart properties and series: - const datasets = [{ - label: "Data", - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,0.8)", - highlightFill: "rgba(151,187,205,0.75)", - highlightStroke: "rgba(151,187,205,1)", - data: [] - }]; - - // Add a zero value for each bin - for (var i in bins) - datasets[0].data.push(0); - - const data = { - labels: bins, - datasets: datasets - }; - - const options = { - scaleBeginsAtZero: true - }; - - // Create the chart object - const chart = new Chart(context, {type: 'bar', data: data, options: options}); - - // Now what? - }; - -There are two methods every client-side visualization class must -implement to be able to work: ``render(data)`` to render the incoming -data, and ``reset()`` which is called to clear the visualization when -the user hits the reset button and starts a new model run. - -In this case, the easiest way to pass data to the histogram is as an -array, one value for each bin. We can then just loop over the array and -update the values in the chart’s dataset. - -There are a few ways to reset the chart, but the easiest is probably to -destroy it and create a new chart object in its place. - -With that in mind, we can add these two methods to the class: - -.. code:: javascript - - const HistogramModule = function(bins, canvas_width, canvas_height) { - // ...Everything from above... - this.render = function(data) { - datasets[0].data = data; - chart.update(); - }; - - this.reset = function() { - chart.destroy(); - chart = new Chart(context, {type: 'bar', data: data, options: options}); - }; - }; -Note the ``this``. before the method names. This makes them public and -ensures that they are accessible outside of the object itself. All the -other variables inside the class are only accessible inside the object -itself, but not outside of it. - -Server-Side Code -^^^^^^^^^^^^^^^^ - -Can we get back to Python code? Please? - -Every JavaScript visualization element has an equal and opposite -server-side Python element. The Python class needs to also have a -``render`` method, to get data out of the model object and into a -JSON-ready format. It also needs to point towards the code where the -relevant JavaScript lives, and add the JavaScript object to the model -page. - -In a Python file (either its own, or in the same file as your -visualization code), import the ``VisualizationElement`` class we’ll -inherit from, and create the new visualization class. - -.. code:: python - - from mesa.visualization.ModularVisualization import VisualizationElement, CHART_JS_FILE - - class HistogramModule(VisualizationElement): - package_includes = [CHART_JS_FILE] - local_includes = ["HistogramModule.js"] - - def __init__(self, bins, canvas_height, canvas_width): - self.canvas_height = canvas_height - self.canvas_width = canvas_width - self.bins = bins - new_element = "new HistogramModule({}, {}, {})" - new_element = new_element.format(bins, - canvas_width, - canvas_height) - self.js_code = "elements.push(" + new_element + ");" - -There are a few things going on here. ``package_includes`` is a list of -JavaScript files that are part of Mesa itself that the visualization -element relies on. You can see the included files in -`mesa/visualization/templates/ `__. -Similarly, ``local_includes`` is a list of JavaScript files in the same -directory as the class code itself. Note that both of these are class -variables, not object variables – they hold for all particular objects. - -Next, look at the ``__init__`` method. It takes three arguments: the -number of bins, and the width and height for the histogram. It then uses -these values to populate the ``js_code`` property; this is code that the -server will insert into the visualization page, which will run when the -page loads. In this case, it creates a new HistogramModule (the class we -created in JavaScript in the step above) with the desired bins, width -and height; it then appends (``push``\ es) this object to ``elements``, -the list of visualization elements that the visualization page itself -maintains. - -Now, the last thing we need is the ``render`` method. If we were making -a general-purpose visualization module we’d want this to be more -general, but in this case we can hard-code it to our model. - -.. code:: python - - import numpy as np - - class HistogramModule(VisualizationElement): - # ... Everything from above... - - def render(self, model): - wealth_vals = [agent.wealth for agent in model.schedule.agents] - hist = np.histogram(wealth_vals, bins=self.bins)[0] - return [int(x) for x in hist] - -Every time the render method is called (with a model object as the -argument) it uses numpy to generate counts of agents with each wealth -value in the bins, and then returns a list of these values. Note that -the ``render`` method doesn’t return a JSON string – just an object that -can be turned into JSON, in this case a Python list (with Python -integers as the values; the ``json`` library doesn’t like dealing with -numpy’s integer type). - -Now, you can create your new HistogramModule and add it to the server: - -.. code:: python - - histogram = mesa.visualization.HistogramModule(list(range(10)), 200, 500) - server = mesa.visualization.ModularServer(MoneyModel, - [grid, histogram, chart], - "Money Model", - {"N":100, "width":10, "height":10}) - server.launch() - -Run this code, and you should see your brand-new histogram added to the -visualization and updating along with the model! - -.. figure:: files/viz_histogram.png - :alt: Histogram Visualization - - Histogram Visualization - -If you’ve felt comfortable with this section, it might be instructive to -read the code for the -`ModularServer `__ -and the -`modular_template `__ -to get a better idea of how all the pieces fit together. - -Happy Modeling! -~~~~~~~~~~~~~~~ - -This document is a work in progress. If you see any errors, exclusions -or have any problems please contact -`us `__. diff --git a/docs/tutorials/intro_tutorial.ipynb b/docs/tutorials/intro_tutorial.ipynb index 2fe16594e62..927c7f3353d 100644 --- a/docs/tutorials/intro_tutorial.ipynb +++ b/docs/tutorials/intro_tutorial.ipynb @@ -36,6 +36,29 @@ "\n", "Despite its simplicity, this model yields results that are often unexpected to those not familiar with it. For our purposes, it also easily demonstrates Mesa's core features.\n", "\n", + "We import these libraries here to use in the tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "import mesa\n", + "import pandas as pd\n", + "import numpy as np\n", + "import seaborn as sns\n", + "\n", + "# Set the default figure size for all seaborn plots throughout the tutorial\n", + "sns.set(rc={\"figure.figsize\": (6, 3)})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "Let's get started." ] }, @@ -50,20 +73,22 @@ "To install Mesa, simply:\n", "\n", "```bash\n", - " $ pip install mesa\n", + " $ pip install --upgrade mesa\n", "```\n", "\n", "When you do that, it will install Mesa itself, as well as any dependencies that aren't in your setup yet. Additional dependencies required by this tutorial can be found in the **examples/boltzmann_wealth_model/requirements.txt** file, which can be installed directly form the github repository by running:\n", "\n", "```bash\n", - " $ pip install -r https://raw.githubusercontent.com/projectmesa/mesa/main/examples/boltzmann_wealth_model/requirements.txt\n", + " $ pip install --upgrade -r https://github.com/projectmesa/mesa-examples/blob/main/examples/boltzmann_wealth_model_network/requirements.txt\n", "```\n", "\n", "This will install the dependencies listed in the requirements.txt file which are: \n", - "- jupyter (Ipython interactive notebook) \n", - "- matplotlib (Python's visualization library) \n", - "- mesa (this ABM library -- if not installed) \n", - "- numpy (Python's numerical python library) " + "- mesa (this ABM library -- if not installed)\n", + "- jupyter (Ipython interactive notebook)\n", + "- matplotlib (Python's visualization library)\n", + "- seaborn (high-level visualization library)\n", + "- numpy (Python's numerical python library)\n", + "- pandas (Data analysis and manipulation library)" ] }, { @@ -104,9 +129,6 @@ "metadata": {}, "outputs": [], "source": [ - "import mesa\n", - "\n", - "\n", "class MoneyAgent(mesa.Agent):\n", " \"\"\"An agent with fixed initial wealth.\"\"\"\n", "\n", @@ -302,14 +324,15 @@ "metadata": {}, "outputs": [], "source": [ - "# For a jupyter notebook add the following line:\n", - "%matplotlib inline\n", - "\n", - "# The below is needed for both notebooks and scripts\n", - "import matplotlib.pyplot as plt\n", - "\n", + "# Create a list of agent wealth\n", "agent_wealth = [a.wealth for a in model.schedule.agents]\n", - "plt.hist(agent_wealth)" + "print(agent_wealth)\n", + "\n", + "# Create a histogram with seaborn\n", + "g = sns.histplot(agent_wealth, discrete=True)\n", + "g.set(\n", + " title=\"Wealth distribution\", xlabel=\"Wealth\", ylabel=\"Number of agents\"\n", + "); # The semicolon is just to avoid printing the object representation" ] }, { @@ -344,7 +367,9 @@ " for agent in model.schedule.agents:\n", " all_wealth.append(agent.wealth)\n", "\n", - "plt.hist(all_wealth, bins=range(max(all_wealth) + 1))" + "# Use seaborn\n", + "g = sns.histplot(all_wealth, discrete=True)\n", + "g.set(title=\"Wealth distribution\", xlabel=\"Wealth\", ylabel=\"Number of agents\");" ] }, { @@ -521,7 +546,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Let's create a model with 50 agents on a 10x10 grid, and run it for 20 steps." + "Let's create a model with 100 agents on a 10x10 grid, and run it for 20 steps." ] }, { @@ -530,7 +555,7 @@ "metadata": {}, "outputs": [], "source": [ - "model = MoneyModel(50, 10, 10)\n", + "model = MoneyModel(100, 10, 10)\n", "for i in range(20):\n", " model.step()" ] @@ -548,18 +573,16 @@ "metadata": {}, "outputs": [], "source": [ - "import numpy as np\n", - "\n", "agent_counts = np.zeros((model.grid.width, model.grid.height))\n", "for cell in model.grid.coord_iter():\n", " cell_content, x, y = cell\n", " agent_count = len(cell_content)\n", " agent_counts[x][y] = agent_count\n", - "plt.imshow(agent_counts, interpolation=\"nearest\")\n", - "plt.colorbar()\n", "\n", - "# If running from a text editor or IDE, remember you'll need the following:\n", - "# plt.show()" + "# Same plot using seaborn, size 5 by 5\n", + "g = sns.heatmap(agent_counts, cmap=\"viridis\", annot=True, cbar=False, square=True)\n", + "g.figure.set_size_inches(4, 4)\n", + "g.set(title=\"Number of agents on each cell of the grid\");" ] }, { @@ -635,11 +658,14 @@ " y = self.random.randrange(self.grid.height)\n", " self.grid.place_agent(a, (x, y))\n", "\n", + " # Create a DataCollector to collect model-level and agent-level data\n", " self.datacollector = mesa.DataCollector(\n", " model_reporters={\"Gini\": compute_gini}, agent_reporters={\"Wealth\": \"wealth\"}\n", " )\n", "\n", " def step(self):\n", + " \"\"\"Advance the model by one step.\"\"\"\n", + " # Before we step, collect data\n", " self.datacollector.collect(self)\n", " self.schedule.step()" ] @@ -680,7 +706,7 @@ "outputs": [], "source": [ "gini = model.datacollector.get_model_vars_dataframe()\n", - "gini.plot()" + "gini.head(3)" ] }, { @@ -697,7 +723,34 @@ "outputs": [], "source": [ "agent_wealth = model.datacollector.get_agent_vars_dataframe()\n", - "agent_wealth.head()" + "agent_wealth.head(3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the DataFrame has a multi-level index, with the first level being the step number, and the second level being the agent ID. This is because the data collector stores the data in a dictionary, with the step number as the key, and a dictionary of agent ID and variable value pairs as the value. The data collector then converts this dictionary into a DataFrame, which is why the index is a multi-level index." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Selecting and visualizing the data\n", + "\n", + "A simple visualization of the Gini coefficient over time:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Plot the Gini coefficient over time\n", + "g = sns.lineplot(data=gini)\n", + "g.set(title=\"Gini Coefficient over Time\", ylabel=\"Gini Coefficient\");" ] }, { @@ -713,8 +766,28 @@ "metadata": {}, "outputs": [], "source": [ - "end_wealth = agent_wealth.xs(99, level=\"Step\")[\"Wealth\"]\n", - "end_wealth.hist(bins=range(agent_wealth.Wealth.max() + 1))" + "# Get the last step\n", + "last_step = agent_wealth.index.get_level_values(\"Step\").max()\n", + "print(f\"Last step: {last_step}\")\n", + "\n", + "# Create a Pandas Series of the wealth at the last step\n", + "end_wealth = agent_wealth.xs(last_step, level=\"Step\")[\"Wealth\"]\n", + "end_wealth.head(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a histogram of wealth at the last step\n", + "g = sns.histplot(end_wealth, discrete=True)\n", + "g.set(\n", + " title=\"Distribution of wealth at model's end\",\n", + " xlabel=\"Wealth\",\n", + " ylabel=\"Number of agents\",\n", + ");" ] }, { @@ -730,14 +803,82 @@ "metadata": {}, "outputs": [], "source": [ + "# Get the wealth of agent 14 over time\n", "one_agent_wealth = agent_wealth.xs(14, level=\"AgentID\")\n", - "one_agent_wealth.Wealth.plot()" + "\n", + "# Plot the wealth of agent 14 over time\n", + "g = sns.lineplot(data=one_agent_wealth, x=\"Step\", y=\"Wealth\")\n", + "g.set(title=\"Wealth of agent 14 over time\");" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ + " You can also plot a reporter of multiple agents over time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "agent_list = [3, 14, 25]\n", + "\n", + "# Get the wealth of multiple agents over time\n", + "multiple_agents_wealth = agent_wealth[\n", + " agent_wealth.index.get_level_values(\"AgentID\").isin(agent_list)\n", + "]\n", + "\n", + "# Plot the wealth of multiple agents over time\n", + "g = sns.lineplot(data=multiple_agents_wealth, x=\"Step\", y=\"Wealth\", hue=\"AgentID\")\n", + "g.set(title=\"Wealth of agents 3, 14 and 25 over time\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also plot the average of all agents, with a 95% confidence interval for that average." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Transform the data to a long format\n", + "agent_wealth_long = agent_wealth.T.unstack().reset_index()\n", + "agent_wealth_long.columns = [\"Step\", \"AgentID\", \"Variable\", \"Value\"]\n", + "agent_wealth_long.head(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Plot the average wealth over time\n", + "g = sns.lineplot(data=agent_wealth_long, x=\"Step\", y=\"Value\", errorbar=(\"ci\", 95))\n", + "g.set(title=\"Average wealth over time\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Which is exactly 1, as expected in this model, since each agent starts with one wealth unit, and each agent gives one wealth unit to another agent at each step." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Saving data\n", + "\n", "You can also use pandas to export the data to a CSV (comma separated value), which can be opened by any common spreadsheet application or opened by pandas.\n", "\n", "If you do not specify a file path, the file will be saved in the local directory. After you run the code below you will see two files appear (*model_data.csv* and *agent_data.csv*)" @@ -756,6 +897,31 @@ "agent_wealth.to_csv(\"agent_data.csv\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can read the model again, using:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Read the model data from the CSV file\n", + "gini = pd.read_csv(\"model_data.csv\", index_col=0)\n", + "gini.head(3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is especially useful with models that run for a long time, as it allows you to save the data and then analyze it later, without having to re-run the model." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -772,6 +938,17 @@ "The batch runner also requires an additional variable `self.running` for the MoneyModel class. This variable enables conditional shut off of the model once a condition is met. In this example it will be set as True indefinitely." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Additional agent reporter\n", + "\n", + "To make the results a little bit more interesting, we will also calculate the number of consecutive time steps an agent hasn't given any wealth as an agent reporter.\n", + "\n", + "This way we can see how data is handled when multiple reporters are used." + ] + }, { "cell_type": "code", "execution_count": null, @@ -805,53 +982,71 @@ " self.grid.place_agent(a, (x, y))\n", "\n", " self.datacollector = mesa.DataCollector(\n", - " model_reporters={\"Gini\": compute_gini}, agent_reporters={\"Wealth\": \"wealth\"}\n", + " model_reporters={\"Gini\": compute_gini},\n", + " agent_reporters={\"Wealth\": \"wealth\", \"Steps_not_given\": \"steps_not_given\"},\n", " )\n", "\n", " def step(self):\n", " self.datacollector.collect(self)\n", - " self.schedule.step()" + " self.schedule.step()\n", + "\n", + "\n", + "class MoneyAgent(mesa.Agent):\n", + " \"\"\"An agent with fixed initial wealth.\"\"\"\n", + "\n", + " def __init__(self, unique_id, model):\n", + " super().__init__(unique_id, model)\n", + " self.wealth = 1\n", + " self.steps_not_given = 0\n", + "\n", + " def move(self):\n", + " possible_steps = self.model.grid.get_neighborhood(\n", + " self.pos, moore=True, include_center=False\n", + " )\n", + " new_position = self.random.choice(possible_steps)\n", + " self.model.grid.move_agent(self, new_position)\n", + "\n", + " def give_money(self):\n", + " cellmates = self.model.grid.get_cell_list_contents([self.pos])\n", + " if len(cellmates) > 1:\n", + " other = self.random.choice(cellmates)\n", + " other.wealth += 1\n", + " self.wealth -= 1\n", + " self.steps_not_given = 0\n", + " else:\n", + " self.steps_not_given += 1\n", + "\n", + " def step(self):\n", + " self.move()\n", + " if self.wealth > 0:\n", + " self.give_money()\n", + " else:\n", + " self.steps_not_given += 1" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ + "#### Batch Run\n", + "\n", "We call `batch_run` with the following arguments:\n", "\n", "* `model_cls`\n", - "

\n", - "The model class that is used for the batch run.\n", - "

\n", - "\n", + " The model class that is used for the batch run.\n", "* `parameters`\n", - "

\n", - "A dictionary containing all the parameters of the model class and desired values to use for the batch run as key-value pairs. Each value can either be fixed ( e.g. `{\"height\": 10, \"width\": 10}`) or an iterable (e.g. `{\"N\": range(10, 500, 10)}`). `batch_run` will then generate all possible parameter combinations based on this dictionary and run the model `iterations` times for each combination.\n", - "

\n", - "\n", + " A dictionary containing all the parameters of the model class and desired values to use for the batch run as key-value pairs. Each value can either be fixed ( e.g. `{\"height\": 10, \"width\": 10}`) or an iterable (e.g. `{\"N\": range(10, 500, 10)}`). `batch_run` will then generate all possible parameter combinations based on this dictionary and run the model `iterations` times for each combination.\n", "* `number_processes`\n", - "

\n", - "If not specified, defaults to 1. Set it to `None` to use all the available processors.\n", - "

\n", - "Note: Multiprocessing does make debugging challenging. If your parameter sweeps are resulting in unexpected errors set `number_processes = 1`.\n", - "

\n", - "\n", + " If not specified, defaults to 1. Set it to `None` to use all the available processors.\n", + " Note: Multiprocessing does make debugging challenging. If your parameter sweeps are resulting in unexpected errors set `number_processes=1`.\n", "* `iterations`\n", - "

\n", - "The number of iterations to run each parameter combination for. Optional. If not specified, defaults to 1.

\n", + " The number of iterations to run each parameter combination for. Optional. If not specified, defaults to 1.\n", "* `data_collection_period`\n", - "

\n", - "The length of the period (number of steps) after which the model and agent reporters collect data. Optional. If not specified, defaults to -1, i.e. only at the end of each episode.\n", - "

\n", - "\n", + " The length of the period (number of steps) after which the model and agent reporters collect data. Optional. If not specified, defaults to -1, i.e. only at the end of each episode.\n", "* `max_steps`\n", - "

\n", - "The maximum number of time steps after which the model halts. An episode does either end when `self.running` of the model class is set to `False` or when `model.schedule.steps == max_steps` is reached. Optional. If not specified, defaults to 1000.\n", - "

\n", - "\n", + " The maximum number of time steps after which the model halts. An episode does either end when `self.running` of the model class is set to `False` or when `model.schedule.steps == max_steps` is reached. Optional. If not specified, defaults to 1000.\n", "* `display_progress`\n", - "

\n", - "Display the batch run progress. Optional. If not specified, defaults to `True`." + " Display the batch run progress. Optional. If not specified, defaults to `True`." ] }, { @@ -862,10 +1057,10 @@ "\n", "We want to keep track of\n", "\n", - "1. the Gini coefficient value and\n", - "2. the individual agent's wealth development.\n", + "1. the Gini coefficient value at each time step, and\n", + "2. the individual agent's wealth development and steps without giving money.\n", "\n", - "Since for the latter changes at each time step might be interesting, we set `data_collection_period = 1`.\n", + "**Important:** Since for the latter changes at each time step might be interesting, we set `data_collection_period=1`. By default, it only collects data at the end of each episode.\n", "\n", "Note: The total number of runs is 245 (= 49 different populations * 5 iterations per population). However, the resulting list of dictionaries will be of length 6186250 (= 250 average agents per population * 49 different populations * 5 iterations per population * 101 steps per iteration). " ] @@ -891,7 +1086,7 @@ " iterations=5,\n", " max_steps=100,\n", " number_processes=1,\n", - " data_collection_period=1,\n", + " data_collection_period=1, # Important, otherwise the datacollector will only collect data of the last time step\n", " display_progress=True,\n", ")" ] @@ -909,16 +1104,18 @@ "metadata": {}, "outputs": [], "source": [ - "import pandas as pd\n", - "\n", + "# Convert results dictionary to a Pandas DataFrame\n", "results_df = pd.DataFrame(results)\n", - "print(results_df.keys())" + "print(f\"Column names in the dataframe: {results_df.columns.tolist()}\")\n", + "results_df.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ + "#### Analyzing model reporters\n", + "\n", "First, we want to take a closer look at how the Gini coefficient at the end of each episode changes as we increase the size of the population. For this, we filter our results to only contain the data of one agent (the Gini coefficient will be the same for the entire population at any time) at the 100th step of each episode and then scatter-plot the values for the Gini coefficient over the the number of agents. Notice there are five values for each population size since we set `iterations=5` when calling the batch run." ] }, @@ -928,20 +1125,56 @@ "metadata": {}, "outputs": [], "source": [ + "# Filter the results to only contain the data of one agent (the Gini coefficient will be the same for the entire population at any time) at the 100th step of each episode\n", "results_filtered = results_df[(results_df.AgentID == 0) & (results_df.Step == 100)]\n", - "N_values = results_filtered.N.values\n", - "gini_values = results_filtered.Gini.values\n", - "plt.scatter(N_values, gini_values)" + "results_filtered[[\"iteration\", \"N\", \"Gini\"]].reset_index(drop=True).head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a scatter plot\n", + "g = sns.scatterplot(data=results_filtered, x=\"N\", y=\"Gini\")\n", + "g.set(\n", + " xlabel=\"Number of agents\",\n", + " ylabel=\"Gini coefficient\",\n", + " title=\"Gini coefficient vs. number of agents\",\n", + ");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can create differnt kinds of plot from this filtered DataFrame. For example, a point plot with error bars." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a point plot with error bars\n", + "g = sns.pointplot(data=results_filtered, x=\"N\", y=\"Gini\", join=False)\n", + "g.figure.set_size_inches(8, 4)\n", + "g.set(\n", + " xlabel=\"Number of agents\",\n", + " ylabel=\"Gini coefficient\",\n", + " title=\"Gini coefficient vs. number of agents\",\n", + ");" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Second, we want to display the agent's wealth at each time step of one specific episode. To do this, we again filter our large data frame, this time with a fixed number of agents and only for a specific iteration of that population.\n", - "To print the results, we convert the filtered data frame to a string specifying the desired columns to print. \n", + "#### Analyzing model reporters: Comparing 5 scenarios\n", "\n", - "Pandas has built-in functions to convert to a lot of different data formats. For example, to display as a table in a Jupyter Notebook, we can use the `to_html()` function which takes the same arguments as `to_string()` (see commented lines)." + "Other insight might be gathered when we compare the Gini coefficient of different scenarios. For example, we can compare the Gini coefficient of a population with 25 agents to the Gini coefficient of a population with 400 agents. While doing this, we increase the number of iterations to 25 to get a better estimate of the Gini coefficient for each population size and get usable error estimations." ] }, { @@ -950,24 +1183,68 @@ "metadata": {}, "outputs": [], "source": [ - "# First, we filter the results\n", - "one_episode_wealth = results_df[(results_df.N == 10) & (results_df.iteration == 2)]\n", - "# Then, print the columns of interest of the filtered data frame\n", - "print(\n", - " one_episode_wealth.to_string(\n", - " index=False, columns=[\"Step\", \"AgentID\", \"Wealth\"], max_rows=25\n", - " )\n", + "params = {\"width\": 10, \"height\": 10, \"N\": [5, 10, 20, 40, 80]}\n", + "\n", + "results_5s = mesa.batch_run(\n", + " MoneyModel,\n", + " parameters=params,\n", + " iterations=100,\n", + " max_steps=120,\n", + " number_processes=1,\n", + " data_collection_period=1, # Important, otherwise the datacollector will only collect data of the last time step\n", + " display_progress=True,\n", ")\n", - "# For a prettier display we can also convert the data frame to html, uncomment to test in a Jupyter Notebook\n", - "# from IPython.display import display, HTML\n", - "# display(HTML(one_episode_wealth.to_html(index=False, columns=['Step', 'AgentID', 'Wealth'], max_rows=25)))" + "\n", + "results_5s_df = pd.DataFrame(results_5s)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Again filter the results to only contain the data of one agent (the Gini coefficient will be the same for the entire population at any time)\n", + "results_5s_df_filtered = results_5s_df[(results_5s_df.AgentID == 0)]\n", + "results_5s_df_filtered.head(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a lineplot with error bars\n", + "g = sns.lineplot(\n", + " data=results_5s_df,\n", + " x=\"Step\",\n", + " y=\"Gini\",\n", + " hue=\"N\",\n", + " errorbar=(\"ci\", 95),\n", + " palette=\"tab10\",\n", + ")\n", + "g.set(\n", + " title=\"Gini coefficient for different population sizes\", ylabel=\"Gini coefficient\"\n", + ");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this case it looks like the Gini coefficient increases slower for smaller populations. This can be because of different things, either because the Gini coefficient is a measure of inequality and the smaller the population, the more likely it is that the agents are all in the same wealth class, or because there are less interactions between agents in smaller populations, which means that the wealth of an agent is less likely to change." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Lastly, we want to take a look at the development of the Gini coefficient over the course of one iteration. Filtering and printing looks almost the same as above, only this time we choose a different episode." + "#### Analyzing agent reporters\n", + "\n", + "From the agents we collected the wealth and the number of consecutive rounds without a transaction. We can compare the 5 different population sizes by plotting the average number of consecutive rounds without a transaction for each population size.\n", + "\n", + "Note that we're aggregating multiple times here: First we take the average of all agents for each single replication. Then we plot the averages for all replications, with the error band showing the 95% confidence interval of that first average (over all agents). So this error band is representing the uncertainty of the _mean_ value of the number of consecutive rounds without a transaction for each population size." ] }, { @@ -976,10 +1253,54 @@ "metadata": {}, "outputs": [], "source": [ - "results_one_episode = results_df[\n", - " (results_df.N == 10) & (results_df.iteration == 1) & (results_df.AgentID == 0)\n", - "]\n", - "print(results_one_episode.to_string(index=False, columns=[\"Step\", \"Gini\"], max_rows=25))" + "# Calculate the mean of the wealth and the number of consecutive rounds for all agents in each episode\n", + "agg_results_df = (\n", + " results_5s_df.groupby([\"iteration\", \"N\", \"Step\"])\n", + " .agg({\"Wealth\": \"mean\", \"Steps_not_given\": \"mean\"})\n", + " .reset_index()\n", + ")\n", + "agg_results_df.head(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a scatter plot\n", + "g = sns.lineplot(\n", + " data=agg_results_df, x=\"Step\", y=\"Steps_not_given\", hue=\"N\", palette=\"tab10\"\n", + ")\n", + "g.figure.set_size_inches(8, 4)\n", + "g.set(\n", + " title=\"Average number of consecutive rounds without a transaction for different population sizes\",\n", + " ylabel=\"Consecutive rounds without a transaction\",\n", + ");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It can be clearly seen that the lower the number of agents, the higher the number of consecutive rounds without a transaction. This is because the agents have less interactions with each other and therefore the wealth of an agent is less likely to change." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### General steps for analyzing results\n", + "\n", + "Many other analysis are possible based on the policies, scenarios and uncertainties that you might be interested in. In general, you can follow these steps to do your own analysis:\n", + "\n", + "1. Determine which metrics you want to analyse. Add these as model and agent reporters to the datacollector of your model.\n", + "2. Determine the input parameters you want to vary. Add these as parameters to the batch_run function, using ranges or lists to test different values.\n", + "3. Determine the hyperparameters of the batch_run function. Define the number of iterations, the number of processes, the number of steps, the data collection period, etc.\n", + "4. Run the batch_run function and save the results.\n", + "5. Transform, filter and aggregate the results to get the data you want to analyze. Make sure it's in long format, so that each row represents a single value.\n", + "6. Choose a plot type, what to plot on the x and y axis, which columns to use for the hue. Seaborn also has an amazing [Example Gallery](https://seaborn.pydata.org/examples/index.html).\n", + "7. Plot the data and analyze the results." ] }, { @@ -988,7 +1309,7 @@ "source": [ "### Happy Modeling!\n", "\n", - "This document is a work in progress. If you see any errors, exclusions or have any problems please contact [us](https://github.com/projectmesa/mesa/issues)." + "This document is a work in progress. If you see any errors, exclusions or have any problems please contact [us](https://github.com/projectmesa/mesa/issues)." ] }, { @@ -1006,7 +1327,7 @@ "metadata": { "anaconda-cloud": {}, "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -1019,8 +1340,7 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.6" + "pygments_lexer": "ipython3" }, "widgets": { "state": {}, diff --git a/mesa/datacollection.py b/mesa/datacollection.py index fcaedc7c8c8..a7bc561969b 100644 --- a/mesa/datacollection.py +++ b/mesa/datacollection.py @@ -222,7 +222,9 @@ def get_model_vars_dataframe(self): "No model reporters have been defined in the DataCollector, returning empty DataFrame." ) - return pd.DataFrame(self.model_vars) + df = pd.DataFrame(self.model_vars) + df.index.name = "Step" + return df def get_agent_vars_dataframe(self): """Create a pandas DataFrame from the agent variables. diff --git a/setup.py b/setup.py index 6760e56ef9b..6303b7fd43a 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ from setuptools import find_packages, setup -requires = ["click", "cookiecutter", "networkx", "numpy", "pandas", "tornado", "tqdm"] +requires = ["click", "cookiecutter", "networkx", "numpy", "pandas", "tornado", "tqdm", "seaborn"] extras_require = { "dev": [ @@ -19,7 +19,9 @@ "pytest-cov", "sphinx", ], - "docs": ["sphinx", "ipython"], + # Constrain sphinx version until https://github.com/readthedocs/readthedocs.org/issues/10279 + # is fixed. + "docs": ["sphinx<7", "ipython", "nbsphinx"], } version = ""