A basic aggregation model -- Vicsek
In this document, we'll implement the basic Vicsek aggregation model and demonstrate how to code in Python along the way.
First, let's take the model description from Topaz, Chad M., Lori Ziegelmeier, and Tom Halverson. 2015. “Topological Data Analysis of Biological Aggregation Models.” PLOS ONE 10 (5): e0126383. https://doi.org/10/f7mp7k.
Each agent ("synthetic cell") in the aggregation model is described by a position vector and associated velocity vector . The velocity vector is of constant magnitude (speed) and only its angle changes: . For
The first thing to notice here is notation -- is the 'current' time, while then means "one step in future". This is similar to the definition od derivative in calculus, where
OK, so the overall structure of this dynamical system is as follows:
where FUTURE
and PRESENT
have the same structure: in our case, containing positions and orientations of the "cells". We call these vectors "states", and we talk about "initial state", "current state", and "next state". We use the name "orbit" if we want to refer to all past states -- a dynamical "movie" of evolution of our dynamical system.
We will now write Python code that simulates such a system.
Each frame ("snapshot") of our movie will be represented by a vector with rows -- this is because we need to store three numbers for each cell - orientation , and position point (two coordinates) . Since we want cells, we need elements in any snapshot.
Now, before we start to simulate anything, let's give ourselves some random starting cells. We will work with a square domain of size . Notice below that we'll have to decide what element of state vector will correspond to horizontal, vertical, and angle coordinates. So let's do this: first three elements will correspond to the first cell, second three to the second, and so on. Within each block of 3, first will be angle, second horizontal and third vertical coordinate.
Now, all zeros is a boring state. Below, I set each component to a random number - angles to random number between , and spatial coordinates to random numbers between . Each time I'll print the intermediate result to see what's going on. The random number generator returns random values between so if we want a different range, we have to multiply the output by the maximum. (If we wanted a different lower bound, we'd additionally add a number to the output)
By the way, if you wanted to get more information on any function, just type help(name)
: (for quickhelp you could also use name?
)
OK, printing cells as numbers is boring. Let's graph them.
Now, we'll likely want to plot cells over and over. So let's package all that code in a function, so we can easily plot cells.
Now we can simply plot our state vector by calling:
In the spirit of function making, let's create also a function that creates as many initial cells as we want:
Now we can very easily initialize a lot of cells and plot them.
Now it's time to start moving cells around. The "movie" of cells is called an "trajectory". Each element of the trajectory is a snapshot - we will store such a trajectory into a snapshot matrix, where each column will represent one time step.
OK, so our trajectory is pretty boring - everything just goes to zero. Instead, we have to implement our present -> future
function. Let's call that function "update rule". Remember, its mathematical form was
Let's write a first draft of this function:
We can now iterate the update rule for the trajectory and plot first three steps
We can further show the track of each individual cell as a line to see what they are doing.
We already see there are problems. First, some cells clearly go off screen. Second, cells don't really change their orientation. Let's tackle these things in order, by rewriting our update_rule
.
To prevent cells from exiting the domain, we'll just periodize the domain. This is easily done using mod()
function: mod
calculates remainder of division. So, if a cell steps into location 26
, then because 26 = 1 + 25
, mod(26,25) = 1
so the cell reappears on the other side of the domain.
Notice that now dots that go off screen appear on the other end.
Now for the second task: change in orientation. We will also add speed as the parameter in the process.
We will first add the random component and then the average angle of neighbors.
Let's rerun everything but for longer time and smaller time step, to give the cells a chance to wander about
Alright! Our cells are wandering around as if drunk, but they don't sense each other - each is doing its own thing. The synchronization comes from the neighbor part of the update rule, which we have yet to implement.
By the way, randomly moving cells don't wander far in 2D but they do in 3D. http://pi.math.cornell.edu/~mec/Winter2009/Thompson/randomwalks.html
Now, neighbors are defined by the subscript of the summation sign in that is: neighbors are those whose coordinates are closer than some distance .
We are going to implement that by cycling through the list of cells, computing the distance between current cell position and all others, and every time we notice a neighbor, add its angle to the list of angles we'll average.
Now, let's run our simulation loop one more time, with the new update rule:
This is now a mess - we have no idea where cells started, where they have ended, etc. (We used a slightly larger stepsize=5
to increase the speed.) So let's plot side by side the first frame of our simulation and the last to see if we can spot a difference in clustering.
I can't really tell if there is a significant change in clustering. Perhaps the density of cells is too small, perhaps the radius of interaction is too small (most likely). So we'll have to play with the parameters... but first, notice that the simulation took a bit of time to complete.
If we increased the number of cells and time, this simulation would take an even-longer time. This is because for every time step, we are running through cells twice - once to find neighbors, and once to update cell values. We can shorten the neighbor calculation by using scipy.spatial.distance.pdist
function, which computes pairwise distances between all cells in one go. Then we can just select those cells that are nearby.
Once we have everything coded up, we can think of this entire process of simulating cells as an experiment.
Inputs: (noise level), (box size), (neighborhood range), (number of cells), (duration), (timestep).
Outputs: plot of trajectories, plot of initial state, plot of final state.
So let's write a function experiment
that takes in these parameters and runs our usual simulation loop.
Now, we're ready to produce some experiments
I'd say that the cells look somewhat more clustered now! Now, take a look at Topaz paper and see what parameters they use, and try to recreate the pictures that they have. You won't get the exact pictures because random initial conditions will make the graphs slightly different, but at least qualitatively things should be alike.
Notice we can play with all parameters that are defined in the "signature" of the function (first line): def experiment(N=30, T=1000, R=1, D=25, eta=0.1, stepsize=1)
. For example, we can shrink the domain. With (default value), this means that cells can sense a much larger proportion of the domain than before.