Boids
To get started with agent-based modelling, we’ll recreate the classic Boids simulation of flocking behavior. This is a relatively simple example but it produces very pleasing visualizations.
Setup
We’re assuming you’ve read through the material in
getting started and are working in your
vivarium_examples
package. If not, you should go there first.
Todo
package setup with __init__ and stuff
Building a population
Create a file called population.py
with the following content:
1from typing import Any, Dict, List
2
3import numpy as np
4import pandas as pd
5
6from vivarium import Component
7from vivarium.framework.engine import Builder
8from vivarium.framework.population import SimulantData
9
10
11class Population(Component):
12 ##############
13 # Properties #
14 ##############
15 configuration_defaults = {
16 "population": {
17 "colors": ["red", "blue"],
18 }
19 }
20 columns_created = ["color", "entrance_time"]
21
22 #####################
23 # Lifecycle methods #
24 #####################
25
26 def setup(self, builder: Builder) -> None:
27 self.colors = builder.configuration.population.colors
28
29 ########################
30 # Event-driven methods #
31 ########################
32
33 def on_initialize_simulants(self, pop_data: SimulantData) -> None:
34 new_population = pd.DataFrame(
35 {
36 "color": np.random.choice(self.colors, len(pop_data.index)),
37 "entrance_time": pop_data.creation_time,
38 },
39 index=pop_data.index,
40 )
41 self.population_view.update(new_population)
Here we’re defining a component that generates a population of boids. Those boids are then randomly chosen to be either red or blue.
Let’s examine what’s going on in detail, as you’ll see many of the same patterns repeated in later components.
Imports
1from typing import Any, Dict, List
2
3import numpy as np
4import pandas as pd
5
6from vivarium import Component
7from vivarium.framework.engine import Builder
8from vivarium.framework.population import SimulantData
NumPy is a library for doing high performance
numerical computing in Python. pandas is a set
of tools built on top of numpy that allow for fast
database-style querying and aggregating of data. Vivarium uses
pandas.DataFrame
objects as it’s underlying representation of the
population and for many other data storage and manipulation tasks.
By convention, most people abbreviate these packages as np
and pd
respectively, and we’ll follow that convention here.
Population class
Vivarium components are expressed as Python classes. You can find many resources on classes and object-oriented programming with a simple Google search. We’ll assume some fluency with this style of programming, but you should be able to follow along with most bits even if you’re unfamiliar.
Configuration defaults
In most simulations, we want to have an easily tunable set up knobs to adjust
various parameters. Vivarium accomplishes this by pulling those knobs out as
configuration information. Components typically expose the values they use in
the configuration_defaults
class attribute.
15configuration_defaults = {
16 "population": {
17 "colors": ["red", "blue"],
18 }
19}
We’ll talk more about configuration information later. For now observe that we’re exposing a set of possible colors for our boids.
The setup
method
Almost every component in Vivarium will have a setup method. The setup method
gives the component access to an instance of the
Builder
which exposes a handful of tools
to help build components. The simulation framework is responsible for calling
the setup method on components and providing the builder to them. We’ll
explore these tools that the builder provides in detail as we go.
26def setup(self, builder: Builder) -> None:
27 self.colors = builder.configuration.population.colors
Our setup method is pretty simple: we just save the configured colors for later use.
The component is accessing the subsection of the configuration that it cares about.
The full simulation configuration is available from the builder as
builder.configuration
. You can treat the configuration object just like
a nested python
dictionary
that’s been extended to support dot-style attribute access. Our access here
mirrors what’s in the configuration_defaults
at the top of the class
definition.
The columns_created
property
20columns_created = ["color", "entrance_time"]
The columns_created
property tells Vivarium what columns (or “attributes”)
the component will add to the population table.
See the next section for where we actually create these columns.
Note
The Population Table
When we talk about columns in the context of Vivarium, we are typically talking about the simulant attributes. Vivarium represents the population of simulants as a single pandas DataFrame. We think of each simulant as a row in this table and each column as an attribute of the simulants.
The on_initialize_simulants
method
Finally we look at the on_initialize_simulants
method,
which is automatically called by Vivarium when new simulants are
being initialized.
This is where we should initialize values in the columns_created
by this component.
33def on_initialize_simulants(self, pop_data: SimulantData) -> None:
34 new_population = pd.DataFrame(
35 {
36 "color": np.random.choice(self.colors, len(pop_data.index)),
37 "entrance_time": pop_data.creation_time,
38 },
39 index=pop_data.index,
40 )
41 self.population_view.update(new_population)
We see that like the setup
method, on_initialize_simulants
takes in a
special argument that we don’t provide. This argument, pop_data
is an
instance of SimulantData
containing a
handful of information useful when initializing simulants.
The only two bits of information we need for now are the
pop_data.index
, which supplies the index of the simulants to be
initialized, and the pop_data.creation_time
which gives us a
representation (typically an int
or pandas.Timestamp
) of the
simulation time when the simulant was generated.
Note
The Population Index
The population table we described before has an index that uniquely identifies each simulant. This index is used in several places in the simulation to look up information, calculate simulant-specific values, and update information about the simulants’ state.
Using the population index, we generate a pandas.DataFrame
on lines 34-40
and fill it with the initial values of ‘entrance_time’ and ‘color’ for each
new simulant. Right now, this is just a table with data hanging out in our
simulation. To actually do something, we have to tell Vivarium’s population
management system to update the underlying population table, which we do
on line 41.
Putting it together
Vivarium supports both a command line interface and an interactive one. We’ll look at how to run simulations from the command line later. For now, we can set up our simulation with the following code:
from vivarium import InteractiveContext
from vivarium_examples.boids.population import Population
sim = InteractiveContext(
components=[Population()],
configuration={'population': {'population_size': 500}},
)
# Peek at the population table
print(sim.get_population().head())
tracked entrance_time color
0 True 2005-07-01 blue
1 True 2005-07-01 red
2 True 2005-07-01 red
3 True 2005-07-01 red
4 True 2005-07-01 red
Movement
Before we get to the flocking behavior of boids, we need them to move.
We create a Movement
component for this purpose.
It tracks the position and velocity of each boid, and creates an
acceleration
pipeline that we will use later.
from typing import Any, Dict, List
import numpy as np
import pandas as pd
from vivarium import Component
from vivarium.framework.engine import Builder
from vivarium.framework.population import SimulantData
class Movement(Component):
##############
# Properties #
##############
configuration_defaults = {
"field": {
"width": 1000,
"height": 1000,
},
"movement": {
"max_speed": 2,
},
}
columns_created = ["x", "vx", "y", "vy"]
#####################
# Lifecycle methods #
#####################
def setup(self, builder: Builder) -> None:
self.config = builder.configuration
self.acceleration = builder.value.register_value_producer(
"acceleration", source=self.base_acceleration
)
##################################
# Pipeline sources and modifiers #
##################################
def base_acceleration(self, index: pd.Index) -> pd.DataFrame:
return pd.DataFrame(0.0, columns=["x", "y"], index=index)
########################
# Event-driven methods #
########################
def on_initialize_simulants(self, pop_data: SimulantData) -> None:
count = len(pop_data.index)
# Start randomly distributed, with random velocities
new_population = pd.DataFrame(
{
"x": self.config.field.width * np.random.random(count),
"y": self.config.field.height * np.random.random(count),
"vx": ((2 * np.random.random(count)) - 1) * self.config.movement.max_speed,
"vy": ((2 * np.random.random(count)) - 1) * self.config.movement.max_speed,
},
index=pop_data.index,
)
self.population_view.update(new_population)
def on_time_step(self, event):
pop = self.population_view.get(event.index)
acceleration = self.acceleration(event.index)
# Accelerate and limit velocity
pop[["vx", "vy"]] += acceleration.rename(columns=lambda c: f"v{c}")
speed = np.sqrt(np.square(pop.vx) + np.square(pop.vy))
velocity_scaling_factor = np.where(
speed > self.config.movement.max_speed,
self.config.movement.max_speed / speed,
1.0,
)
pop["vx"] *= velocity_scaling_factor
pop["vy"] *= velocity_scaling_factor
# Move according to velocity
pop["x"] += pop.vx
pop["y"] += pop.vy
# Loop around boundaries
pop["x"] = pop.x % self.config.field.width
pop["y"] = pop.y % self.config.field.height
self.population_view.update(pop)
You’ll notice that some parts of this component look very similar to our population component. Indeed, we can split up the responsibilities of initializing simulants over many different components. In Vivarium we tend to think of components as being responsible for individual behaviors or attributes. This makes it very easy to build very complex models while only having to think about local pieces of it.
However, there are also a few new Vivarium features on display in this component. We’ll step through these in more detail.
Value pipelines
A value pipeline is like a column in the population table, in that it contains information about our simulants (boids, in this case). The key difference is that it is not stateful – each time it is accessed, its values are re-initialized from scratch, instead of “remembering” what they were on the previous timestep. This makes it appropriate for modeling acceleration, because we only want a boid to accelerate due to forces acting on it now. You can find an overview of the values system here.
The Builder class exposes an additional property for working with value pipelines:
vivarium.framework.engine.Builder.value()
.
We call the vivarium.framework.values.ValuesInterface.register_value_producer()
method to register a new pipeline.
34 self.acceleration = builder.value.register_value_producer(
35 "acceleration", source=self.base_acceleration
36 )
This call provides a source
function for our pipeline, which initializes the values.
In this case, the default is zero acceleration:
42def base_acceleration(self, index: pd.Index) -> pd.DataFrame:
43 return pd.DataFrame(0.0, columns=["x", "y"], index=index)
This may seem pointless, since acceleration will always be zero. Value pipelines have another feature we will see later: other components can modify their values. We’ll create components later in this tutorial that modify this pipeline to exert forces on our boids.
The on_time_step
method
This is a lifecycle method, much like on_initialize_simulants
.
However, this method will be called on each step forward in time, not only
when new simulants are initialized.
It can use values from pipelines and update the population table. In this case, we change boids’ velocity according to their acceleration, limit their velocity to a maximum, and update their position according to their velocity.
To get the values of a pipeline such as acceleration
inside on_time_step,
we simply call that pipeline as a function, using event.index
,
which is the set of simulants affected by the event (in this case, all of them).
63def on_time_step(self, event):
64 pop = self.population_view.get(event.index)
65
66 acceleration = self.acceleration(event.index)
67
68 # Accelerate and limit velocity
69 pop[["vx", "vy"]] += acceleration.rename(columns=lambda c: f"v{c}")
70 speed = np.sqrt(np.square(pop.vx) + np.square(pop.vy))
71 velocity_scaling_factor = np.where(
72 speed > self.config.movement.max_speed,
73 self.config.movement.max_speed / speed,
74 1.0,
75 )
76 pop["vx"] *= velocity_scaling_factor
77 pop["vy"] *= velocity_scaling_factor
78
79 # Move according to velocity
80 pop["x"] += pop.vx
81 pop["y"] += pop.vy
82
83 # Loop around boundaries
84 pop["x"] = pop.x % self.config.field.width
85 pop["y"] = pop.y % self.config.field.height
86
87 self.population_view.update(pop)
Putting it together
Let’s run the simulation with our new component and look again at the population table.
from vivarium import InteractiveContext
from vivarium_examples.boids.population import Population
from vivarium_examples.boids.movement import Movement
sim = InteractiveContext(
components=[Population(), Movement()],
configuration={'population': {'population_size': 500}},
)
# Peek at the population table
print(sim.get_population().head())
tracked color entrance_time vy vx x y
0 True red 2005-07-01 -1.492285 -1.546289 786.157545 686.064077
1 True blue 2005-07-01 0.360843 1.662424 530.867936 545.621217
2 True red 2005-07-01 -0.369045 -1.747372 779.830506 286.461394
3 True red 2005-07-01 -1.479211 0.659691 373.141406 740.640070
4 True red 2005-07-01 1.143885 0.258908 20.787001 878.792517
Our population now has initial position and velocity!
Now, we can take a step forward with sim.step()
and “see” our boids’ positions change,
but their velocity stay the same.
sim.step()
# Peek at the population table
print(sim.get_population().head())
tracked color entrance_time vy vx x y
0 True red 2005-07-01 -1.388859 -1.439121 784.718424 684.675217
1 True blue 2005-07-01 0.360843 1.662424 532.530360 545.982060
2 True red 2005-07-01 -0.369045 -1.747372 778.083134 286.092349
3 True red 2005-07-01 -1.479211 0.659691 373.801097 739.160859
4 True red 2005-07-01 1.143885 0.258908 21.045909 879.936402
Visualizing our population
Now is also a good time to come up with a way to plot our boids. We’ll later use this to generate animations of our boids moving around. We’ll use matplotlib for this.
Making good visualizations is hard, and beyond the scope of this tutorial, but
the matplotlib
documentation has a large number of
examples and
tutorials that should be
useful.
For our purposes, we really just want to be able to plot the positions of our boids and maybe some arrows to indicated their velocity.
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation
def plot_boids(simulation, plot_velocity=False):
width = simulation.configuration.field.width
height = simulation.configuration.field.height
pop = simulation.get_population()
plt.figure(figsize=[12, 12])
plt.scatter(pop.x, pop.y, color=pop.color)
if plot_velocity:
plt.quiver(pop.x, pop.y, pop.vx, pop.vy, color=pop.color, width=0.002)
plt.xlabel("x")
plt.ylabel("y")
plt.axis([0, width, 0, height])
plt.show()
We can then visualize our flock with
from vivarium import InteractiveContext
from vivarium_examples.boids.population import Population
from vivarium_examples.boids.movement import Movement
from vivarium_examples.boids.visualization import plot_boids
sim = InteractiveContext(
components=[Population(), Movement()],
configuration={'population': {'population_size': 500}},
)
plot_boids(sim, plot_velocity=True)
(Source code
, png
, hires.png
, pdf
)
Calculating neighbors
The steering behavior in the Boids model is dictated by interactions of each boid with its nearby neighbors. A naive implementation of this can be very expensive. Luckily, Python has a ton of great libraries that have solved most of the hard problems.
Here, we’ll pull in a KDTree from SciPy and use it to build a component that tells us about the neighbor relationships of each boid.
from typing import Any, Dict, List, Optional
import pandas as pd
from scipy import spatial
from vivarium import Component
from vivarium.framework.engine import Builder
from vivarium.framework.event import Event
from vivarium.framework.population import SimulantData
class Neighbors(Component):
##############
# Properties #
##############
configuration_defaults = {"neighbors": {"radius": 60}}
columns_required = ["x", "y"]
#####################
# Lifecycle methods #
#####################
def setup(self, builder: Builder) -> None:
self.radius = builder.configuration.neighbors.radius
self.neighbors_calculated = False
self._neighbors = pd.Series()
self.neighbors = builder.value.register_value_producer(
"neighbors", source=self.get_neighbors
)
########################
# Event-driven methods #
########################
def on_initialize_simulants(self, pop_data: SimulantData) -> None:
self._neighbors = pd.Series([[]] * len(pop_data.index), index=pop_data.index)
def on_time_step(self, event: Event) -> None:
self.neighbors_calculated = False
##################################
# Pipeline sources and modifiers #
##################################
def get_neighbors(self, index: pd.Index) -> pd.Series:
if not self.neighbors_calculated:
self._calculate_neighbors()
return self._neighbors[index]
##################
# Helper methods #
##################
def _calculate_neighbors(self) -> None:
# Reset our list of neighbors
pop = self.population_view.get(self._neighbors.index)
self._neighbors = pd.Series([[] for _ in range(len(pop))], index=pop.index)
tree = spatial.KDTree(pop[["x", "y"]])
# Iterate over each pair of simulants that are close together.
for boid_1, boid_2 in tree.query_pairs(self.radius):
# .iloc is used because query_pairs uses 0,1,... indexing instead of pandas.index
self._neighbors.iloc[boid_1].append(self._neighbors.index[boid_2])
self._neighbors.iloc[boid_2].append(self._neighbors.index[boid_1])
This component creates a value pipeline called neighbors
that other components
can use to access the neighbors of each boid.
Note that the only thing it does in on_time_step
is self.neighbors_calculated = False
.
That’s because we only want to calculate the neighbors once per time step. When the pipeline
is called, we can tell with self.neighbors_calculated
whether we need to calculate them,
or use our cached value in self._neighbors
.
Swarming behavior
Now we know which boids are each others’ neighbors, but we’re not doing anything with that information. We need to teach the boids to swarm!
There are lots of potential swarming behaviors to play around with, all of which change the way that boids clump up and follow each other. But since that isn’t the focus of this tutorial, we’ll implement separation, cohesion, and alignment behavior identical to what’s in this D3 example (Internet Archive version), and we’ll gloss over most of the calculations.
We define a base class for all our forces, since they will have a lot in common. We won’t get into the details of this class, but at a high level it uses the neighbors pipeline to find all the pairs of boids that are neighbors, applies some force to (some of) those pairs, and limits that force to a maximum magnitude.
1from abc import ABC, abstractmethod
2from typing import Any, Dict, List, Optional
3
4import numpy as np
5import pandas as pd
6
7from vivarium import Component
8from vivarium.framework.engine import Builder
9from vivarium.framework.population import SimulantData
10
11
12class Force(Component, ABC):
13 ##############
14 # Properties #
15 ##############
16 @property
17 def configuration_defaults(self) -> Dict[str, Any]:
18 return {
19 self.__class__.__name__.lower(): {
20 "max_force": 0.03,
21 },
22 }
23
24 columns_required = ["x", "y", "vx", "vy"]
25
26 #####################
27 # Lifecycle methods #
28 #####################
29
30 def setup(self, builder: Builder) -> None:
31 self.config = builder.configuration[self.__class__.__name__.lower()]
32 self.max_speed = builder.configuration.movement.max_speed
33
34 self.neighbors = builder.value.get_value("neighbors")
35
36 builder.value.register_value_modifier(
37 "acceleration",
38 modifier=self.apply_force,
39 )
40
41 ##################################
42 # Pipeline sources and modifiers #
43 ##################################
44
45 def apply_force(self, index: pd.Index, acceleration: pd.DataFrame) -> pd.DataFrame:
46 neighbors = self.neighbors(index)
47 pop = self.population_view.get(index)
48 pairs = self._get_pairs(neighbors, pop)
49
50 raw_force = self.calculate_force(pairs)
51 force = self._normalize_and_limit_force(
52 force=raw_force,
53 velocity=pop[["vx", "vy"]],
54 max_force=self.config.max_force,
55 max_speed=self.max_speed,
56 )
57
58 acceleration.loc[force.index] += force[["x", "y"]]
59 return acceleration
60
61 ##################
62 # Helper methods #
63 ##################
64
65 @abstractmethod
66 def calculate_force(self, neighbors: pd.DataFrame):
67 pass
68
69 def _get_pairs(self, neighbors: pd.Series, pop: pd.DataFrame):
70 pairs = (
71 pop.join(neighbors.rename("neighbors"))
72 .reset_index()
73 .explode("neighbors")
74 .merge(
75 pop.reset_index(),
76 left_on="neighbors",
77 right_index=True,
78 suffixes=("_self", "_other"),
79 )
80 )
81 pairs["distance_x"] = pairs.x_other - pairs.x_self
82 pairs["distance_y"] = pairs.y_other - pairs.y_self
83 pairs["distance"] = np.sqrt(pairs.distance_x**2 + pairs.distance_y**2)
84
85 return pairs
86
87 def _normalize_and_limit_force(
88 self,
89 force: pd.DataFrame,
90 velocity: pd.DataFrame,
91 max_force: float,
92 max_speed: float,
93 ):
94 normalization_factor = np.where(
95 (force.x != 0) | (force.y != 0),
96 max_speed / self._magnitude(force),
97 1.0,
98 )
99 force["x"] *= normalization_factor
100 force["y"] *= normalization_factor
101 force["x"] -= velocity.loc[force.index, "vx"]
102 force["y"] -= velocity.loc[force.index, "vy"]
103 magnitude = self._magnitude(force)
104 limit_scaling_factor = np.where(
105 magnitude > max_force,
106 max_force / magnitude,
107 1.0,
108 )
109 force["x"] *= limit_scaling_factor
110 force["y"] *= limit_scaling_factor
111 return force[["x", "y"]]
To access the value pipeline we created in the Neighbors component, we use
builder.value.get_value
in the setup method. Then, as we saw with the acceleration
pipeline, we simply call that pipeline as a function inside on_time_step
to retrieve
its values for a specified index.
The major new Vivarium feature seen here is that of the value modifier,
which we register with vivarium.framework.values.ValuesInterface.register_value_modifier()
.
As the name suggests, this allows us to modify the values in a pipeline,
in this case adding the effect of a force to the values in the acceleration
pipeline.
We register that the apply_force
method will modify the acceleration values like so:
36 builder.value.register_value_modifier(
37 "acceleration",
38 modifier=self.apply_force,
39 )
Once we start adding components with these modifiers into our simulation, acceleration won’t always be zero anymore!
We then define our three forces using the Force
base class.
We won’t step through what these mean in detail.
They mostly only override the _calculate_force
method that calculates the force between a pair
of boids.
The separation force is a bit special in that it also defines an extra configurable
parameter: the distance within which it should act.
117class Separation(Force):
118 """Push boids apart when they get too close."""
119
120 configuration_defaults = {
121 "separation": {
122 "distance": 30,
123 "max_force": 0.03,
124 },
125 }
126
127 def calculate_force(self, neighbors: pd.DataFrame):
128 # Push boids apart when they get too close
129 separation_neighbors = neighbors[neighbors.distance < self.config.distance].copy()
130 force_scaling_factor = np.where(
131 separation_neighbors.distance > 0,
132 ((-1 / separation_neighbors.distance) / separation_neighbors.distance),
133 1.0,
134 )
135 separation_neighbors["force_x"] = (
136 separation_neighbors["distance_x"] * force_scaling_factor
137 )
138 separation_neighbors["force_y"] = (
139 separation_neighbors["distance_y"] * force_scaling_factor
140 )
141
142 return (
143 separation_neighbors.groupby("index_self")[["force_x", "force_y"]]
144 .sum()
145 .rename(columns=lambda c: c.replace("force_", ""))
146 )
147
148
149class Cohesion(Force):
150 """Push boids together."""
151
152 def calculate_force(self, pairs: pd.DataFrame):
153 return (
154 pairs.groupby("index_self")[["distance_x", "distance_y"]]
155 .sum()
156 .rename(columns=lambda c: c.replace("distance_", ""))
157 )
158
159
160class Alignment(Force):
161 """Push boids toward where others are going."""
162
163 def calculate_force(self, pairs: pd.DataFrame):
164 return (
165 pairs.groupby("index_self")[["vx_other", "vy_other"]]
166 .sum()
167 .rename(columns=lambda c: c.replace("v", "").replace("_other", ""))
168 )
For a quick test of our swarming behavior, let’s add in these forces and check in on our boids after 100 steps:
from vivarium import InteractiveContext
from vivarium_examples.boids.population import Population
from vivarium_examples.boids.movement import Movement
from vivarium_examples.boids.neighbors import Neighbors
from vivarium_examples.boids.forces import Separation, Cohesion, Alignment
from vivarium_examples.boids.visualization import plot_boids
sim = InteractiveContext(
components=[Population(), Movement(), Neighbors(), Separation(), Cohesion(), Alignment()],
configuration={'population': {'population_size': 500}},
)
sim.take_steps(100)
plot_boids(sim, plot_velocity=True)
(Source code
, png
, hires.png
, pdf
)
Viewing our simulation as an animation
Great, our simulation is working! But it would be nice to see our boids moving around instead of having static snapshots. We’ll use the animation features in matplotlib to do this.
Add this method to visualization.py
:
def plot_boids_animated(simulation):
width = simulation.configuration.field.width
height = simulation.configuration.field.height
pop = simulation.get_population()
fig = plt.figure(figsize=[12, 12])
ax = fig.add_subplot(111)
s = ax.scatter(pop.x, pop.y, color=pop.color)
plt.xlabel("x")
plt.ylabel("y")
plt.axis([0, width, 0, height])
frames = range(2_000)
frame_pops = []
for _ in frames:
simulation.step()
frame_pops.append(simulation.get_population()[["x", "y"]])
def animate(i):
s.set_offsets(frame_pops[i])
return FuncAnimation(fig, animate, frames=frames, interval=10)
Then, try it out like so:
from vivarium import InteractiveContext
from vivarium_examples.boids.population import Population
from vivarium_examples.boids.movement import Movement
from vivarium_examples.boids.neighbors import Neighbors
from vivarium_examples.boids.forces import Separation, Cohesion, Alignment
from vivarium_examples.boids.visualization import plot_boids_animated
sim = InteractiveContext(
components=[Population(), Movement(), Neighbors(), Separation(), Cohesion(), Alignment()],
configuration={'population': {'population_size': 500}},
)
anim = plot_boids_animated(sim)
Viewing this animation will depend a bit on what software you have installed. If you’re running Python in the terminal, this will save a video file:
anim.save('boids.mp4')
In IPython, this will display the animation:
HTML(anim.to_html5_video())
Either way, it will look like this: