How to use MPI to generate coupled simulations at different scales¶
In this tutorial we are going to discuss how to use Arbor and MPI to drive simulations at different scales or levels of abstraction. This requires an MPI-enabled build of Arbor. This allows you to build compound simulations that focus effort on detailed models (in Arbor) where needed and use more abstract models to embed the more complex structures. In general, throughout this tutorial, we are assuming you are comfortable with the basics of Arbor (cells, recipes, and networks), Python package management, and MPI, as wells as installing software on your system. All source code for all intermediate steps can be in the directory python/example/cosim of the Arbor source tree.
Setup¶
Since we need an MPI-capable build, we need to install from source, which is
simple, but might require some guidance. We demonstrate it using the uv
package manager. Before starting, you should install MPI using your system’s
method, e.g. using brew install (MacOS) or apt install (Ubuntu), CMake,
and a C++ compiler. If these are missing, you will encounter errors during the
build step. Begin by setting up the environment
uv venv
source .venv/bin/activate
uv pip install numpy mpi4py polars scikit-build-core matplotlib seaborn scipy pybind11-stubgen
then install Arbor
CMAKE_ARGS="-DPYTHON_EXECUTABLE=`which python3` -DARB_VECTORIZE=ON -DARB_ARCH=native -DARB_WITH_MPI=ON" uv pip install --no-cache-dir --no-binary arbor --no-build-isolation arbor
This will install a vectorised (making better use of modern CPUs) and MPI-enabled version of Arbor. You can test it using a file
#!/usr/bin/env python3
import arbor as A
print(A.config())
which should print
{
'mpi': True,
'mpi4py': True,
'gpu': None,
'vectorize': True,
# ... more fields elided
}
Overview¶
We will create two models, one a spiking neural network in Arbor, the second a simple neural mass model, and then connect them over an interface. Both models will be basic, to emphasise the scaffolding, but can either be iterate upon or swapped for more interesting alternatives without touching the framework. Then, we will build the scaffolding itself, based on MPI, which is largely independent of the models. Finally, with all parts in hand, we will show how to wired them in to a coherent global model.
Creating an Arbor model¶
For co-simulation, we need a network model in Arbor and some disjoint model that
receives and produces spikes in exchange. We will, for sake of the
demonstration, use a simple ring of leaky-integrate-and-fire cells, but the
concepts translate to cable cells and more complex networks straightforwardly.
Save this script in ring.py for later use.
We import the usual libraries
1import arbor as A
2from arbor import units as U
and create a recipe with a configurable number of lif cells
5class recipe(A.recipe):
6 def __init__(self, *, n_cell=4):
7 A.recipe.__init__(self)
8 self.n_cell = n_cell
9
10 def num_cells(self):
11 return self.n_cell
12
13 def cell_kind(self, _):
14 return A.cell_kind.lif
15
16 def cell_description(self, _):
17 return A.lif_cell("src", "tgt")
that connect in a ring
19 def connections_on(self, gid):
20 src = (gid - 1) % self.n_cell
21 return [A.connection((src, "src"), "tgt", weight=200, delay=0.5 * U.ms)]
At time 0.1 ms we inject a single spike into the first cell
23 def event_generators(self, gid):
24 if gid == 0:
25 return [
26 A.event_generator(
27 target="tgt", weight=200, sched=A.explicit_schedule([0.1 * U.ms])
28 )
29 ]
30 return []
that should propagate indefinitely. To confirm, we set up the simulation
and run it for 10ms, printing out spikes at the end:
33if __name__ == "__main__":
34 rec = recipe(n_cell=4)
35 sim = A.simulation(rec)
36 sim.record(A.spike_recording.all)
37 sim.run(10 * U.ms, 10 * U.us)
38 for (gid, _), time in sim.spikes():
39 print(f"{time:5.3}ms gid={gid:3d}")
and receive
0.1ms gid= 0
0.6ms gid= 1
1.1ms gid= 2
1.6ms gid= 3
2.1ms gid= 0
2.6ms gid= 1
3.1ms gid= 2
3.6ms gid= 3
4.1ms gid= 0
4.6ms gid= 1
5.1ms gid= 2
5.6ms gid= 3
6.1ms gid= 0
6.6ms gid= 1
7.1ms gid= 2
7.6ms gid= 3
8.1ms gid= 0
8.6ms gid= 1
9.1ms gid= 2
9.6ms gid= 3
For reference, the full file:
1import arbor as A
2from arbor import units as U
3
4
5class recipe(A.recipe):
6 def __init__(self, *, n_cell=4):
7 A.recipe.__init__(self)
8 self.n_cell = n_cell
9
10 def num_cells(self):
11 return self.n_cell
12
13 def cell_kind(self, _):
14 return A.cell_kind.lif
15
16 def cell_description(self, _):
17 return A.lif_cell("src", "tgt")
18
19 def connections_on(self, gid):
20 src = (gid - 1) % self.n_cell
21 return [A.connection((src, "src"), "tgt", weight=200, delay=0.5 * U.ms)]
22
23 def event_generators(self, gid):
24 if gid == 0:
25 return [
26 A.event_generator(
27 target="tgt", weight=200, sched=A.explicit_schedule([0.1 * U.ms])
28 )
29 ]
30 return []
31
32
33if __name__ == "__main__":
34 rec = recipe(n_cell=4)
35 sim = A.simulation(rec)
36 sim.record(A.spike_recording.all)
37 sim.run(10 * U.ms, 10 * U.us)
38 for (gid, _), time in sim.spikes():
39 print(f"{time:5.3}ms gid={gid:3d}")
Creating an simple Neural Mass Model (NMM)¶
Next, as the co-simulation partner, we will implement a simple two-population model, the Wilson–Cowan [1] equations
with \(E\) and \(I\) being the activation rates of the excitatory and inhibitory populations, \(\tau_\mathrm{x}\) decay rates, the coupling terms between populations,
and \(P = 1.25\) and \(Q = 0\) external inputs to the respective populations. The activation function is chosen as
with gain \(a = 1.4\) and threshold \(m = 4\).
For this excercise, we implement the model from scratch, using scipy, however in real-world scenarios, one can use tools like TVB specialising in NMM simulation. We write a simple simulation
1from dataclasses import dataclass
2from scipy.integrate import solve_ivp
3import numpy as np
by noting the parameters
6@dataclass
7class Params:
8 # coupling
9 w_ee: float = 12
10 w_ei: float = 10
11 w_ie: float = 15
12 w_ii: float = 0
13 # input
14 P: float = 1.25
15 Q: float = 0
16 # decay
17 tau_e: float = 10 # ms
18 tau_i: float = 10 # ms
and the sigmoid activation function
21def f(x: float, a: float = 1.3, m: float = 4.0):
22 return 1.0 / (1.0 + np.exp(-a * (x - m)))
Next, the model is defined as the temporal derivative
25def wilson_cowan(t, y, ps: Params):
26 E, I = y
27
28 dE = (-E + f(ps.w_ee * E - ps.w_ei * I + ps.P)) / ps.tau_e
29 dI = (-I + f(ps.w_ie * E - ps.w_ii * I + ps.Q)) / ps.tau_i
30
31 return [dE, dI]
and the step function wrapping the SciPy integrator
34def step(t, dt, y, ps: Params):
35 sol = solve_ivp(fun=wilson_cowan, t_span=[t, t + dt], y0=y, args=(ps,))
36 return sol.y[:, 1]
For testing, we simulate for 100ms and plot
39if __name__ == "__main__":
40 import matplotlib.pyplot as plt
41
42 y = np.array([0.2, 0.1])
43 dt = 0.01
44 ps = Params()
45
46 t = 0
47 ts = [t]
48 Es = [y[0]]
49 Is = [y[1]]
50 while t < 100:
51 y = step(0, dt, y, ps)
52 t += dt
53 ts.append(t)
54 Es.append(y[0])
55 Is.append(y[1])
56
57 fg, ax = plt.subplots()
58 ax.plot(ts, Es, label="E")
59 ax.plot(ts, Is, label="I")
60 ax.set_ylim(0, 0.5)
61 ax.set_xlim(0, 100)
62 ax.set_xlabel("Time ($t/ms$)")
63 ax.legend()
64 fg.savefig("wilson-cowan.svg")
receiving
We called this file wilson_cowan.py and will use it later.
1from dataclasses import dataclass
2from scipy.integrate import solve_ivp
3import numpy as np
4
5
6@dataclass
7class Params:
8 # coupling
9 w_ee: float = 12
10 w_ei: float = 10
11 w_ie: float = 15
12 w_ii: float = 0
13 # input
14 P: float = 1.25
15 Q: float = 0
16 # decay
17 tau_e: float = 10 # ms
18 tau_i: float = 10 # ms
19
20
21def f(x: float, a: float = 1.3, m: float = 4.0):
22 return 1.0 / (1.0 + np.exp(-a * (x - m)))
23
24
25def wilson_cowan(t, y, ps: Params):
26 E, I = y
27
28 dE = (-E + f(ps.w_ee * E - ps.w_ei * I + ps.P)) / ps.tau_e
29 dI = (-I + f(ps.w_ie * E - ps.w_ii * I + ps.Q)) / ps.tau_i
30
31 return [dE, dI]
32
33
34def step(t, dt, y, ps: Params):
35 sol = solve_ivp(fun=wilson_cowan, t_span=[t, t + dt], y0=y, args=(ps,))
36 return sol.y[:, 1]
37
38
39if __name__ == "__main__":
40 import matplotlib.pyplot as plt
41
42 y = np.array([0.2, 0.1])
43 dt = 0.01
44 ps = Params()
45
46 t = 0
47 ts = [t]
48 Es = [y[0]]
49 Is = [y[1]]
50 while t < 100:
51 y = step(0, dt, y, ps)
52 t += dt
53 ts.append(t)
54 Es.append(y[0])
55 Is.append(y[1])
56
57 fg, ax = plt.subplots()
58 ax.plot(ts, Es, label="E")
59 ax.plot(ts, Is, label="I")
60 ax.set_ylim(0, 0.5)
61 ax.set_xlim(0, 100)
62 ax.set_xlabel("Time ($t/ms$)")
63 ax.legend()
64 fg.savefig("wilson-cowan.svg")
MPI Setup¶
Now, we need to create a bulk-synchronous (i.e. MPI) programm in which one set
of ranks drives Arbor and the other our custom model. Up front, from here on out
you will need mpi4py and MPI installed. Also, the script needs to be run
via mpirun, i.e.
mpirun -n 4 python mpi.py
We store this in mpi.py for later use of the communicators. First, we grab
mpi4py and the WORLD communicator
from mpi4py import MPI
world = MPI.COMM_WORLD
Then, we need to determine which ranks (in WORLD!) belong to which group
(dubbed color in MPI parlance) in the MPI documentation, here we assume all
ranks but the first will run Arbor. Likewise, we need to select a rank in
WORLD as the leader in the respective group (the group’s rank 0).
5color = None
6leader = None
7if world.rank == 0:
8 color = 0
9 leader = 1
10else:
11 color = 1
12 leader = 0
Then we cleave the WORLD in twain
14group = world.Split(color)
Now, group is an MPI communicator similar to world, but it collects
ranks providing the same color value, that is, the actual object returned in
group depends on the rank (or color). Thus, there’s two values for rank: one
in world and one in group. Additionally two ranks in different groups
cannot communicate using group, only world which is still valid. For
example, a sum reduction over group will provide the sum over (world) rank
zero to (world) rank zero and the sum over all other ranks to (world) rank one,
since we elected it as the group’s leader. Next, we create a bridge between the
halves (the first argument is for tweaking the rank ids within the new
communicator and the last one is a tag for avoiding collisions. You can ignore
both for now)
15inter = group.Create_intercomm(0, world, leader, 42)
the communicator inter is a different beast than world and group;
called an intercommunicator. It joins the two groups. It behaves especially
interestingly in conjunction with collective operations, however, it behaves in
exactly the right way we need to enable conjoined simulations. There are other
ways to create an intercommunicator, in particular ones that do not require a
single programm and splitting of communicators. However, the presented approach
is the most portable one.
Then, we print our identity
17if __name__ == "__main__":
18 print(f"{world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
19
20 if color == 0:
21 print(f"[NMM] {world.rank:2d}/{world.size:2d}")
22 else:
23 print(f"[ARB] {world.rank:2d}/{world.size:2d}")
This concludes setting up MPI functionality. Next, we will incrementally add functionality for actually running the simulation in tandem. The full file is reproduced below
1from mpi4py import MPI
2
3world = MPI.COMM_WORLD
4
5color = None
6leader = None
7if world.rank == 0:
8 color = 0
9 leader = 1
10else:
11 color = 1
12 leader = 0
13
14group = world.Split(color)
15inter = group.Create_intercomm(0, world, leader, 42)
16
17if __name__ == "__main__":
18 print(f"{world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
19
20 if color == 0:
21 print(f"[NMM] {world.rank:2d}/{world.size:2d}")
22 else:
23 print(f"[ARB] {world.rank:2d}/{world.size:2d}")
Running two simulations in tandem¶
The core idea here is to write a single script that looks at its MPI rank and either runs the Wilson-Cowan NMM or the Arbor ring model. The next section will then iterate and show how to utilise the MPI intercommunicator from the last section to pass messsages between the models.
We now import the custom Wilson-Cowan model and the Arbor ring model along with the libraries
1# import our previous code
2from wilson_cowan import step, Params
3from ring import recipe
4from mpi import world, group
5import arbor as A
6from arbor import units as U
and from mpi.py we fetch the communicators
7import numpy as np
Now, we will start running the two simulations in parallel using two nested
timesteps. The smaller step sizes will drive the internal integration and must
divided into the larger step size. The second, larger, step will be used to
exchange data between the simulations, we call it the epoch. As long as the
larger step is an integer multiple of both integration steps, the latter can be
different. We will run the simulations for 100ms
13dt_arb = 0.005 # ms
14dt_nmm = 0.01 # ms
15dt_com = 0.5 # ms
16T = 100 # ms
First, the neural mass model simulation is largely lifted from the previous example code, except the nested time loop
20 if world.rank == 0:
21 print(f"[NMM] {world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
22
23 y = np.array([0.2, 0.1])
24 ps = Params()
25
26 t = 0
27 ts = [t]
28 Es = [y[0]]
29 Is = [y[1]]
30 while t < T:
31 epoch = 0
32 while epoch < dt_com:
33 y = step(0, dt_nmm, y, ps)
34 t += dt_nmm
35 epoch += dt_nmm
36 ts.append(t)
37 Es.append(y[0])
38 Is.append(y[1])
for the Arbor simulation, we will use the group communicator to create a
context, which is then passed to the simulation as an additional argument
39 else: # rank != 0
40 print(f"[ARB] {world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
41 rec = recipe(n_cell=4)
42 ctx = A.context(mpi=group)
43 sim = A.simulation(rec, context=ctx)
44 sim.record(A.spike_recording.all)
45 sim.run(T * U.ms, dt_arb * U.ms)
For all intents and purposes group behaves like world for Arbor, it just
has a different number of ranks. Finally, we print out the ring’s spikes:
47 rates = np.zeros(int(T / dt_com))
48 for _, time in sim.spikes():
49 idx = int(time / dt_com)
50 rates[idx] += 1
51 print(rates)
This gives us parallel simulations, but not yet co-simulation, as the two models cannot exchange information. The full file is reproduced below
1# import our previous code
2from wilson_cowan import step, Params
3from ring import recipe
4from mpi import world, group
5import arbor as A
6from arbor import units as U
7import numpy as np
8
9
10assert world.size >= 2, "Need two or more ranks"
11
12
13dt_arb = 0.005 # ms
14dt_nmm = 0.01 # ms
15dt_com = 0.5 # ms
16T = 100 # ms
17
18
19if __name__ == "__main__":
20 if world.rank == 0:
21 print(f"[NMM] {world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
22
23 y = np.array([0.2, 0.1])
24 ps = Params()
25
26 t = 0
27 ts = [t]
28 Es = [y[0]]
29 Is = [y[1]]
30 while t < T:
31 epoch = 0
32 while epoch < dt_com:
33 y = step(0, dt_nmm, y, ps)
34 t += dt_nmm
35 epoch += dt_nmm
36 ts.append(t)
37 Es.append(y[0])
38 Is.append(y[1])
39 else: # rank != 0
40 print(f"[ARB] {world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
41 rec = recipe(n_cell=4)
42 ctx = A.context(mpi=group)
43 sim = A.simulation(rec, context=ctx)
44 sim.record(A.spike_recording.all)
45 sim.run(T * U.ms, dt_arb * U.ms)
46
47 rates = np.zeros(int(T / dt_com))
48 for _, time in sim.spikes():
49 idx = int(time / dt_com)
50 rates[idx] += 1
51 print(rates)
Joining the two simulations¶
How, we need to transfer information from one model to the other. Copy the
previous step into a new file cosim.py. From here on out, we will assert
that the simulation is run on exactly two ranks. This not required and
asymmetric rank counts are absolutely possible and extremely useful since Arbor
models are (usually) computationally intensive. Thus, our CLI invocation looks
like this
mpirun -n 2 python simulations.py
We are going to use the code we have built during the last section and edit the
two parts of the if-else construct deciding which simulation to run. We will
be starting with the else (Arbor) part, as it is a bit simpler.
If we strip out all recording and error handling it looks like this:
if world.rank == 0: # first rank, running W-C
y = np.array([0.2, 0.1])
ps = Params()
t = 0
while t < T: # all other ranks, running Arbor
epoch = 0
while epoch < dt_com:
y = step(0, dt_nmm, y, ps)
t += dt_nmm
epoch += dt_nmm
else: # rank != 0
rec = recipe(n_cell=4)
ctx = A.context(mpi=group) # `group` connects all ranks except 0
sim = A.simulation(rec, context=ctx)
sim.run(T * U.ms, dt_arb * U.ms)
We set up the co-simulation by importing our previous work and set the constants
2from wilson_cowan import step, Params
3from ring import recipe
4from mpi import world, group, inter
5import arbor as A
6from arbor import units as U
7import numpy as np
8
9
10assert world.size == 2, "Need exactly two ranks"
11
12
13dt_arb = 0.005 # ms
14dt_nmm = 0.01 # ms
15dt_com = 0.5 # ms
16T = 100 # ms
Enabling co-simulation for Arbor¶
For the Arbor model, we need to tweak the recipe a bit, which is
best done by inheritance
19class corecipe(recipe):
20 def __init__(self, *, n_cell=16, ext_weight=0.0):
21 recipe.__init__(self, n_cell=n_cell)
22 self.ext_weight = ext_weight
we also define a weight for the connections incoming from the neural mass model. These are defined by adding a new callback to the recipe
24 def external_connections_on(self, gid):
25 # TODO Customization point: assuming all external gids are connected to all internal gids
26 return [
27 A.external_connection(
28 (ext, 0), "tgt", weight=self.ext_weight, delay=dt_com * U.ms
29 )
30 for ext in range(2)
31 ]
The delay for these connections is identical to the epoch time, in real models
this not needed, but the minimum delay over all Arbor connections (internal and
external) will be used to compute the epoch. Here, we encounter the central
issue in this kind of simulation: As the NMM has only the notion of aggregate
populations, how do we define where a connection comes from? Worse: we get a
rate from the NMM, but Arbor handles discrete spikes exclusively. Without
further information, like postulating a probability distribution, this is
intractable. Thus, this must be defined as part of the overall model. One option
is to assign a cell count to each population and assume distributions for the
spiking activity, then drawing random numbers to generate spikes from the
distributions according to the activity rates. For now, we will set the external
weights to zero and return to the problem later. To keep things simple for this
demonstration, we allocate a single virtual gid per remote population, i.e.
assume both the excitatory and inhibitory population are represented by exactly
one cell (line highlighted above).
Next, we modify the simulation setup in Arbor using our newly derived
corecipe to the end of the file.
101 else: # rank != 0
102 print(f"[ARB] {world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
103 rec = corecipe(n_cell=16)
104 ctx = A.context(mpi=group, inter=inter)
105 sim = A.simulation(rec, context=ctx)
106 sim.record(A.spike_recording.all)
107 sim.run(T * U.ms, dt_arb * U.ms)
The addition of the inter intercommunicator kicks off Arbor’s internal
co-simulation protocols
Arbor will find the epoch by scanning both the internal and external connections for the minimum value over the delays. Half of the minimum delay is used as the epoch length.
Arbor will periodically – whenever an epoch is complete – send a control message.
If it receives a goahead reply to the control message, the simulation will proceed to the next epoch. In this case, Arbor waits for an exchange of spikes for the last epoch next. All ranks of the NMM model must call
gather_spikesand pass in a list of the spikes produced on their rank during the last epoch. In exchange each such ranks receives all list of all spikes occurring on the Arbor ranks during the last epoch.Once the simulation is complete, a done signal is sent.
Internal errors will result in an abort message specifying the reason.
Enabling co-simulation for Wilson-Cowan¶
With Arbor set up, we need to set up the connection infrastructure from the NMM side. Arbor has this already built-in, so this part is a bit more verbose (which is why we have put it second). Arbor, as outlined above, will progress its simulation and send messages periodically. Thus, we need to handle these messages on the ranks driving the NMM. Arbor functions as the controlling entity here, in the NMM simulation we will loop forever until we receive a message signalling termination. This loop replaces the outer loop from the previous section, i.e.
30 while t < T:
31 epoch = 0
32 while epoch < dt_com:
33 y = step(0, dt_nmm, y, ps)
34 t += dt_nmm
35 epoch += dt_nmm
36 ts.append(t)
37 Es.append(y[0])
38 Is.append(y[1])
is replaced by the code in the remainder of this section
45 while True:
46 print("[NMM] Ask for CTRL")
47 msg = A.remote.exchange_ctrl(A.remote.msg_epoch(t, t + dt_com), inter)
In case of failure, we abort and print the reason. If Arbor sends the done signal we terminate the loop since the final step has been executed.
48 if isinstance(msg, A.remote.msg_abort):
49 print(f"[NMM] Arbor sent an abort {msg.reason}")
50 world.Abort()
51 elif isinstance(msg, A.remote.msg_done):
52 print("[NMM] Done")
53 break
Finally, we handle the epoch message which requests proceeding into the next epoch. First, we send the list of spikes from this rank using the intercommunicator. For now, we’ll leave it empty.
54 elif isinstance(msg, A.remote.msg_epoch):
55 print(f"[NMM] Next epoch {msg}")
56 from_arb = A.remote.gather_spikes([], inter)
In return, we received the list of spikes from all Arbor ranks. Next, we will
convert this into two rates by counting the spikes by their origin. We are going
to assign gid=0..11 as connected to the excitatory population,
gid=12..15, and leave the rest unconnected. Thus, we allocate two arrays of
zeros such that we have one entry for each NMM timestep in the epoch.
58 nmm_steps_per_epoch = int(dt_com / dt_nmm)
59 for_e = np.zeros(nmm_steps_per_epoch)
60 for_i = np.zeros(nmm_steps_per_epoch)
Then, we sort the spikes into the bins
62 for spk in from_arb:
63 gid = spk.gid
64 time = spk.time
65
66 idx = int((time - t) / dt_nmm) // 2
67 if 0 <= gid <= 11:
68 for_e[idx] += 1
69 elif 12 <= gid <= 16:
70 for_i[idx] += 1
71 else:
72 pass
Now, we can proceed with the integration of the neural mass model; we choose
here to add the rates we computed in the binning step directly to the variable
E and I for that timestep
74 epoch = 0
75 idx = 0
76 while epoch < dt_com / 2:
77 y[0] += 0.01 * for_e[idx]
78 y[1] += 0.005 * for_i[idx]
79 y = step(t, dt_nmm, y, ps)
80 epoch += dt_nmm
81 t += dt_nmm
82 idx += 1
83 ts.append(t)
84 Es.append(y[0])
85 Is.append(y[1])
Then, we increment all timings and the bin index, record the models values, and proceed to the next timestep until we have completed the epoch.
86 else:
87 print("[NMM] Unknown message type")
88 world.Abort()
Unknown message types cause an abort.
Results¶
After the simulation, we plot the recorded variables E and I and see this
compared to the pure Wilson-Cown model from before
we clearly see the impact of the spiking rate of from the ring model. You can
find this script in cosim.py:
1# import our previous code
2from wilson_cowan import step, Params
3from ring import recipe
4from mpi import world, group, inter
5import arbor as A
6from arbor import units as U
7import numpy as np
8
9
10assert world.size == 2, "Need exactly two ranks"
11
12
13dt_arb = 0.005 # ms
14dt_nmm = 0.01 # ms
15dt_com = 0.5 # ms
16T = 100 # ms
17
18
19class corecipe(recipe):
20 def __init__(self, *, n_cell=16, ext_weight=0.0):
21 recipe.__init__(self, n_cell=n_cell)
22 self.ext_weight = ext_weight
23
24 def external_connections_on(self, gid):
25 # TODO Customization point: assuming all external gids are connected to all internal gids
26 return [
27 A.external_connection(
28 (ext, 0), "tgt", weight=self.ext_weight, delay=dt_com * U.ms
29 )
30 for ext in range(2)
31 ]
32
33
34if __name__ == "__main__":
35 if world.rank == 0:
36 print(f"[NMM] {world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
37
38 y = np.array([0.2, 0.1])
39 ps = Params()
40
41 t = 0
42 ts = [t]
43 Es = [y[0]]
44 Is = [y[1]]
45 while True:
46 print("[NMM] Ask for CTRL")
47 msg = A.remote.exchange_ctrl(A.remote.msg_epoch(t, t + dt_com), inter)
48 if isinstance(msg, A.remote.msg_abort):
49 print(f"[NMM] Arbor sent an abort {msg.reason}")
50 world.Abort()
51 elif isinstance(msg, A.remote.msg_done):
52 print("[NMM] Done")
53 break
54 elif isinstance(msg, A.remote.msg_epoch):
55 print(f"[NMM] Next epoch {msg}")
56 from_arb = A.remote.gather_spikes([], inter)
57
58 nmm_steps_per_epoch = int(dt_com / dt_nmm)
59 for_e = np.zeros(nmm_steps_per_epoch)
60 for_i = np.zeros(nmm_steps_per_epoch)
61
62 for spk in from_arb:
63 gid = spk.gid
64 time = spk.time
65
66 idx = int((time - t) / dt_nmm) // 2
67 if 0 <= gid <= 11:
68 for_e[idx] += 1
69 elif 12 <= gid <= 16:
70 for_i[idx] += 1
71 else:
72 pass
73
74 epoch = 0
75 idx = 0
76 while epoch < dt_com / 2:
77 y[0] += 0.01 * for_e[idx]
78 y[1] += 0.005 * for_i[idx]
79 y = step(t, dt_nmm, y, ps)
80 epoch += dt_nmm
81 t += dt_nmm
82 idx += 1
83 ts.append(t)
84 Es.append(y[0])
85 Is.append(y[1])
86 else:
87 print("[NMM] Unknown message type")
88 world.Abort()
89
90 import matplotlib.pyplot as plt
91
92 fg, ax = plt.subplots()
93 ax.plot(ts, Es, label="E")
94 ax.plot(ts, Is, label="I")
95 ax.set_ylim(0, 0.5)
96 ax.set_xlim(0, 100)
97 ax.set_xlabel("Time ($t/ms$)")
98 ax.legend()
99 fg.savefig("wilson-cowan-cosim.svg")
100
101 else: # rank != 0
102 print(f"[ARB] {world.rank:2d}/{world.size:2d} {group.rank:2d}/{group.size:2d}")
103 rec = corecipe(n_cell=16)
104 ctx = A.context(mpi=group, inter=inter)
105 sim = A.simulation(rec, context=ctx)
106 sim.record(A.spike_recording.all)
107 sim.run(T * U.ms, dt_arb * U.ms)
Summary¶
We have seen how to an extremely simple model in Arbor can interact with a neural mass model over an MPI-mediated bridge by looking at each component in turn. We have also implemented a trivial neural mass model from scratch. It is important to note that both models – Arbor and NMM – are individually as expressive as they would be on their own. However, when joined into a single model and enriched with additional dynamics – the connections in between and the conversions from rate to discrete spikes and back – we are able to model more complex scenarios at reasonable computational cost. We have also seen that the adding co-simulation to Arbor is quite efficient in term of code: adding external connections and passing the intercommunicator. The parts that require care – translating from and to spikes and modeling the virtual cells from populations – also require the bulk of the code. This also means that adding co-simulation an existing Arbor model into this framework is straightforward.
What now?¶
From here, you can a variety of ways towards more realistic models:
Generate spikes for Arbor from rates using an assumed distribution and random numbers.
Refine the rate calculation, e.g., by using a smoothing filter.
Use another model than Wilson-Cowan.
Use TVB or similar tools to simulate the NMM.
Iterate on the Arbor network model by
using cable cells
increasing network complexity
add plasticity
We invite you to read our paper on the subject; you can find the accompanying source code in our contrib section. All source code for all intermediate steps can be in the directory python/example/cosim of the Arbor source tree. More information is found in the documentation on interconnectivity. For the usage of MPI in Python see MPI4Py and for MPI in general the MPI Forum