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

\[\begin{split}\tau_\mathrm{e}\frac{dE}{dt} = -E + f(w_\mathrm{ee} E + w_\mathrm{ei} + P)\\ \tau_\mathrm{i}\frac{dI}{dt} = -I + f(w_\mathrm{ii} I + w_\mathrm{ie} + Q)\end{split}\]

with \(E\) and \(I\) being the activation rates of the excitatory and inhibitory populations, \(\tau_\mathrm{x}\) decay rates, the coupling terms between populations,

\[\begin{split}w_\mathrm{ee} = 12\\ w_\mathrm{ei} = 10\\ w_\mathrm{ie} = 15\\ w_\mathrm{ii} = 0\end{split}\]

and \(P = 1.25\) and \(Q = 0\) external inputs to the respective populations. The activation function is chosen as

\[f(x) = \frac{1}{1 + \exp(-a(x - m))}\]

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

../_images/co-sim-01.svg

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

  1. 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.

  2. Arbor will periodically – whenever an epoch is complete – send a control message.

  3. 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_spikes and 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.

  4. Once the simulation is complete, a done signal is sent.

  5. 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

../_images/co-sim-02.svg

compared to the pure Wilson-Cown model from before

../_images/co-sim-01.svg

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

References