BMTK Builder (A Quick Introduction)

The Brain Modeling Toolkit (bmtk) was designed to handle large-scale network simulations with pre-set connectivity matrices. Whereas other simulation tools will build and simulate a network in a single script, the bmtk splits up these two processes by saving networks to a file. The advantages of doing it this way includes: * Significantly faster when running multiple simulations on the same network. * Easy to update and adjust parameters with little-to-no programming required. * Improves reproducability of simulations.

Before running a simulation, users should either obtain existing network model files, or as described in this tutorial use the BMTK Builder to create their own from scratch. By default, the bmtk uses the recently developed SONATA dataformat to represent networks and network parameters - for a further information please see the SONATA documentation.

Nodes

Brain networks are represented with directed graph so every network needs nodes. (The simplest network consisting of one node and no edges, usually for single-cell simulations). The bmtk is designed to work across different levels of abstraction, so a node can represent a single biophysically detailed cell, a point-cell model, a population of cells, or even an entire brain region.

To create our node(s) we use the NetworkBuilder class in bmtk.builder, then simply build and save the network.

[1]:
from bmtk.builder import NetworkBuilder

# Initialize our network
net = NetworkBuilder("mcortex")

# Add a population of 10 nodes (all of which share model_type, dynamics_params, etc.)
net.add_nodes(N=10, pop_name='Scnn1a',
              mem_potential='e',
              model_type='biophysical',
              model_template='ctdb:Biophys1.hoc',
              model_processing='aibs_perisomatic',
              dynamics_params='472363762_fit.json',
              morphology='Scnn1a_473845048_m.swc')

# If needed we can add more populations
# net.add_nodes(N, ...)

# Builds the network files
net.build()

# Save the network into the specificed directory
net.save_nodes(output_dir='network')

When the NetworkBuilder is instantiated we pass in a name, in this case calling it “mcortex” because we will be using mouse-cortex models - But you can use any name you want. Just be careful, as often a complete simulation will contain multiple networks (the bmtk/SONATA was designed largely to allow different parts of the network to be built indepenently), so having descriptive naming convention is important.

The add_nodes method is then used to add nodes to the network. The first parameter, N=10, indicates that we are adding 10 individual nodes each sharing the same pop_name, mem_potential, model_type etc.

All of the other parameters are completely dependent on the type of network we are looking to build. In this case we want to build a network that runs in BioNet so the parameters are carefully choosen with: * pop_name, mem_potential - optional parameters, not directly used by BioNet but will be helpfull in the descripion * model_type, model_template, model_processing - Attributes used by BioNet as instructions on how to build our NEURON-based cell models. All our cells are biophysically-detailed models, using customized templates and functions to build each cell. * dynamics_params, morphology - Indicates to BioNet the electrophysiological and morphology files used to build each cell. These files can be downloaded from the Allen Cell-Types Database.

However the NetworkBuilder is simulator agnositc allowing modelers to choose whatever parameters they need depending on simulator and/or required to describe the models. For example the following could be used by another simulator to build a network describing 100 Izhikevich point neurons. Notice we no longer need parameters like morphology or model_processing, but have new parameter a, b, c, and d which would be required by any Izhikevich neuron model.

net.add_nodes(N=100,
              model_type='point_process',
              model_template='nrn:Izhikevich.hoc',
              param_a=0.05, param_b=0.25, param_c=-55.0, parm_d=10)

Finally the network is built and saved into the network/ folder. When we look in the network folder there are two different files - mcortex_nodes.h5 and mcortex_node_types.csv. The individual cells are stored in the *nodes.h5 file. But properties that are shared by a group of nodes are stored in the *node_types.csv. Node types not only makes the format more compact and faster to read (probably not important for 10 cells, but very important when trying to run a simulation of 100K+ cells), and it also makes easier to change properties (like updating ephys params or using a different morphology) between simulations.

Looking at the files

[3]:
from bmtk.analyzer import nodes_table
print('mcortex_nodes.h5')
nodes_table(nodes_file='network/mcortex_nodes.h5', population='mcortex')
mcortex_nodes.h5
[3]:
node_id node_type_id
0 0 100
1 1 100
2 2 100
3 3 100
4 4 100
5 5 100
6 6 100
7 7 100
8 8 100
9 9 100
[4]:
from bmtk.analyzer import node_types_table
print('mcortex_node_types.h5')
node_types_table(node_types_file='network/mcortex_node_types.csv', population='mcortex')
mcortex_node_types.h5
[4]:
node_type_id dynamics_params model_type model_processing pop_name model_template morphology mem_potential
0 100 472363762_fit.json biophysical aibs_perisomatic Scnn1a ctdb:Biophys1.hoc Scnn1a_473845048_m.swc e

The nodes and node-types are linked together by the node_type_id foreign key. In this case all information (expect each of the 10 unique cell ids, which were autogenerated during the build processes) is stored in the node_types file because all the properties are shared among every node.

Unique node properties

Suppose we have some properties that are unqiue to each individual nodes within a node-type. Instead of calling add_nodes N times, we can just pass in a list of size N.

In the following example we have two types of nodes, 10 biophysical pyramidal type cells and 5 point izhikevich type cells. For the pyramidal cells we have a new parameter ‘tuning_angle’ which is uniquly assigned a different value to each cell. Similarly for the Izhikevich cells the param_a and param_b parameters are now unqily assigned.

[8]:
import numpy as np

net = NetworkBuilder("mcortex2")
net.add_nodes(N=10, pop_name='pyr',
              model_type='biophysical',
              model_template='ctdb:Biophys1.hoc',
              dynamics_params='pyr_ephys.json',
              morphology='pyr_morph.swc',
              tuning_angle=np.linspace(0.0, 360.0, num=10, endpoint=False))

net.add_nodes(N=5, pop_name='izh',
              model_type='point_process',
              model_template='nrn:Izhikevich.hoc',
              param_a=[0.01, 0.02, 0.03, 0.04, 0.05],
              param_b=np.random.rand(5),
              param_c=-55.0,
              d=10)

net.build()
net.save_nodes(output_dir='network')

Now when we look at the nodes.h5 file

[9]:
from bmtk.analyzer import nodes_table
print('mcortex_nodes.h5')
nodes_table(nodes_file='network/mcortex2_nodes.h5', population='mcortex2')
mcortex_nodes.h5
[9]:
node_id node_type_id tuning_angle param_a param_b
0 0 100 0.0 NaN NaN
1 1 100 36.0 NaN NaN
2 2 100 72.0 NaN NaN
3 3 100 108.0 NaN NaN
4 4 100 144.0 NaN NaN
5 5 100 180.0 NaN NaN
6 6 100 216.0 NaN NaN
7 7 100 252.0 NaN NaN
8 8 100 288.0 NaN NaN
9 9 100 324.0 NaN NaN
10 10 101 NaN 0.01 0.166588
11 11 101 NaN 0.02 0.460439
12 12 101 NaN 0.03 0.075776
13 13 101 NaN 0.04 0.911263
14 14 101 NaN 0.05 0.496744
[10]:
from bmtk.analyzer import node_types_table
print('mcortex_node_types.h5')
node_types_table(node_types_file='network/mcortex2_node_types.csv', population='mcortex')
mcortex_node_types.h5
[10]:
node_type_id dynamics_params d pop_name model_type model_template morphology param_c
0 100 pyr_ephys.json NaN pyr biophysical ctdb:Biophys1.hoc pyr_morph.swc NaN
1 101 NaN 10.0 izh point_process nrn:Izhikevich.hoc NaN -55.0

We see that tuning_angle, param’s a and b are stored individually for each node where applicable.

Edges

After building our nodes we want to create directed connections between them by using the add_edges method. For the simpliest types of edges, we just need to specify the number of connections between any subset of source and target nodes. For instance if we want to create synaptic connections from every pyr cell to every izh cell:

[10]:
# Create connections between pyr --> izh cells
net.add_edges(source={'pop_name': 'pyr'}, target={'pop_name': 'izh'},
              connection_rule=12,
              syn_weight=5.0e-03,
              dynamics_params='AMPA_ExcToExc.json',
              model_template='Exp2Syn',
              delay=2.0)

# Build and save our network
net.build()
net.save_edges(output_dir='network')

Breaking this down, first we specify which nodes to use as sources and which to use as targets by filtering on the parameters of our choice

source={'pop_name': 'pyr'}, target={'pop_name': 'izh'}

We could also filter by more than one parameter if we wanted

source={'pop_name': 'pyr'}, target={'pop_name': 'izh', 'param_a': 0.01, ...}

If source and/or target are not specified it uses all possible nodes.

Next we want to specify the number of connections using the connection_rule parameter.

connection_rule=12,

In this example there are (10 pyr) x (5 izh) = 50 source-target pairs, each with 12 synaptic connection. You can pass in a list of 50 integers. And in the next sections we will show how to customized connection property.

Finally we add edge properties

syn_weight=5.0e-03,
dynamics_params='ExcToExc.json',
model_template='Exp2Syn',
delay=2.0)

These are properties used by BioNet/NEURON to build synapses. Like with nodes, these properties are the same for all 50 x 12 = 600 synapses. These parameters are optional, and depending on the requirements of the model/simulator, we can add or remove parameters are needed.

After building and saving the edges, you’ll see in the network folder two edge files were created; mocrtex2_mcortex2_edges.h5 and mocrtex2_mcortex2_edge_types.csv. Like nodes and node-types, we have edges and edge-types - although adding individual edge properties is a bit more complex.

Custom connection rules

In the previous example the number of synapses/connections between any pair of source/target nodes was constant. In many cases we will need to adjust the number of connections based on the types of cells, distance between source and target, etc. Instead of passing an integer to connection_rule we can pass in a function:

[12]:
def like2like(source, target, min_syns, max_syns):
    """A simple function for setting # of synaptic connections based on pop-name attribute. """
    if source['node_id'] == target['node_id']:
        # No autapses
        return 0

    # favor like-to-like connections
    if source['pop_name'] == target['pop_name']:
        return max_syns
    else:
        return min_syns


net.add_edges(source={'pop_name': 'pyr'}, target={'pop_name': 'izh'},
              connection_rule=like2like, # Note that we are passing in the function name but not calling it
              connection_params={'min_syns': 6, 'max_syns': 12}, # are used to set min_syns, max_syns params in like2like function
              syn_weight=5.0e-03,
              dynamics_params='AMPA_ExcToExc.json',
              model_template='Exp2Syn',
              delay=2.0)

# Build and save connections
net.build()

The like2like() function will be called during the build process for every possible source-target pair (for very larget networks with complex rules the buidling of the edges can take a very long time). All custom functions take as the first two parameters source and target. These two parameters can be used like dictionary objects to get any node property. Using the source and target properties, the function should return an integer representing the number of synapses/connections. If there are no connections for a pair the function should return 0.

Besides source and target, the function also has params min_syns and max_syns. These are optional params and set through connection_params in add_edges. This will allow modelers to reuse the same connection_rule for different edge-types.

Individual edge properties

So far the edge properties like syn_weight and delay are being stored in the edge_types.cvs file. This means that all synapses created by the same add_edges call will share these values. Often this is desirable - the bmtk simulators have a way of altering synaptic weight during run-time. But other times we may want to have a unique syn_weight, or other property, for each individual connection.

Unfortantely we can’t just pass in a list since, if you’re using a custom connection_rule, it’s hard to predetermine the total number of connections. Instead we need to again create a custom rule for setting individual synaptic parameters inside the edges.h5 file

[3]:
import numpy as np

def rand_syn_weight(source, target, min_weight, max_weight):
    # Do some logic here
    return np.random.uniform(min_weight, max_weight)

conn = net.add_edges(connection_rule=12,
                     dynamics_params='AMPA_ExcToExc.json',
                     model_template='Exp2Syn',
                     delay=2.0)

conn.add_properties('syn_weight',
                    rule=rand_syn_weight,
                    rule_params={'min_weight': 1.0e-06, 'max_weight': 1.0e-03},
                    dtypes=np.float)
net.build()

Once again the rand_syn_weight function will be called during the build process. The function will pass in the source and target nodes for every connection, along with any option parameters set in rule_params. The function must return a numerical value according to the specified dtypes. You can also specifiy multiple params in the same call.

conn.add_properties(['syn_weight', 'sec_id'], rule=my_fnc, dtypes=[np.float, np.uint])

def my_fnc(source, target):
    syn_weight = ... # float
    sec_id = ... # integer
    return syn_weight, sec_id

Note: Using add_properties will slow-down the network build-time and increase the size of the resulting network. If a source-target pair has N synpatic connection, the builder will call my_fnc N times, and it will store N different synaptic connections in the edges.h5. It is usually best not to use add_properties and instead let BioNet, PointNet, etc. adjust syn_weight at run-time

[ ]: