Neuroblox and GraphDynamics Connection Type System
This note is an introduction to the connection type system, which is important to understand when constructing circuits.
Blox can be classified into two groups: simple blox (AbstractSimple) and composites (AbstractComposite). Simple blox have internal dynamics, include neurons, receptors, neural mass models, and external stimuli, and can be declared with the @blox macro. Composite blox are composed of simple (and possibly other composite) blox connected in a graph.
There are really two graphs representing any circuit constructed in Neuroblox. At the higher level is the graph that one constructs directly, which might have both composite and simple blox. However, when simulation occurs, the composite blox must be "flattened" into their component parts. This flattened graph consists exclusively of simple blox, since simple blox are the ones that actually have dynamics.
The two types of connections in Neuroblox can be classified as one of two abstract types: DynamicalConnection and WiringRule. The former type are connections between simple blox, and will have equations associated with them. WiringRule are connections between a composite blox and any another blox, which determine how the components of the composite should be connected to the other blox when flattening occurs.
When constructing a graph, one can connect blox using either DynamicalConnection or WiringRule, depending on the kinds of blox one has. The DynamicalConnection will be unchanged by flattening (i.e. the same connection will exist in the flat graph). The WiringRule will be flattened to be a set of DynamicalConnections between the components of the composites. In the flat graph, all of the connections are DynamicalConnection. The function system_wiring_rule!(g, src::SRC, dst::DST, conn::WiringRule) defines the flattening behavior of the WiringRule between blox of type SRC and DST.
The rest of this page discusses how to define one's own subtypes of DynamicalConnection and WiringRule.
DynamicalConnection
Let's examine the definition of a DynamicalConnection by using the example of a PINGConnection, which is used for connecting PING neurons.
The PINGConnection needs to store a weight, and the reversal voltage for excitatory and inhibitory PING neurons.
struct PINGConnection <: DynamicalConnection
w::T
V_E::T
V_I::T
endOne must also define what zero means for the connection by extending Base.zero. This is because of how connection matrices for the graph are represented in GraphDynamics. A zero connection has no effect on the dynamics of the system (i.e. the ODE solution is identical to the ODE solution of the graph without that edge).
Base.zero(::Type{PINGConnection}) = PINGConnection(0.0, 0.0, 0.0)Now one can start defining the equations for the connection should have between different blox.
function (c::PINGConnection)(blox_src::Subsystem{PINGNeuronExci}, blox_dst::Subsystem{PINGNeuronInhib}, t)
(; w, V_E) = c
(; s) = blox_src
(; V) = blox_dst
(; jcn = w * s * (V_E - V))
endNote that in the method signature (src, dst, t), the type of src, dst should be Subsystem{T}, and not the type of the blox itself. The way to read this equation is that the input jcn from blox_src to blox_dst is w * s * (V_E - V).
WiringRule
One-to-one, One-to-many, and Many-to-many WiringRule
Before discussing defining wiring rules we introduce one more distinction: wiring rules that connect simples to composites (e.g. a DBS stimulus connected to the cortex), simples to simples (e.g. a connection between two neurons that has additional wiring behavior, like adding synapses), and composites to composites. Let us call these cases one-to-many (or many-to-one), one-to-one, and many-to-many.
Some wiring rules are definitionally many-to-many rules: HypergeometricRule, DensityRule, and WeightMatrixRule are examples. These wiring rules determine the topology of the graph, and are agnostic to the type of the component simples and what their connections look like. However, since these are the connections that we define at the highest level, they must carry enough information to be able to define the one-to-one rules between the component simple blox (this looks different if they are HHNeuron, LIFNeuron, PINGNeuron, etc.). Note also in some cases that these one-to-one rules might just be a regular DynamicalConnection - two PINGNeuron just need a PINGConnection, since it doesn't do any fancing wiring with learning rules or synapses.
The way that this is done is that each many-to-many wiring rule holds a NamedTuple of keyword arguments that can be used to define the one-to-one connection rule for its components. Then, the one-to-one rule is constructed by the function make_rule:
NeurobloxBase.make_rule — Function
make_rule(src, dst; kwargs...)Define the subrule that should be used to connect two simple blox. This is called internally by higher-level wiring rules in order to determine how to connect the internal simple components of the composite blox. Falls back to connecting the blox with a BasicConnection.
In the case of PINGNeuron, make_rule will return a PINGConnection; for HHNeuron, it will return a HHRule that carries information about learning rules, synapses, whether the connection is STA or GAP. The benefit of storing a set of keyword arguments, and not just a connection rule, is that it provides for cleaner constructor calls, and allows these objects to be more reusable between different kinds of composites.
DefaultRule
The default fallback for the connection between two blox is DefaultRule. The rule simply holds a set of keyword arguments. This has the following flattening behavior:
- One-to-one: determine the desired connection rule between the simples by calling
make_rule, which falls back to aBasicConnection - One-to-many: Undefined by default. If it makes sense to have some kind of default wiring, one can write a dispatch; otherwise, one should specify a different kind of
WiringRule. - Many-to-many: Undefined by default. If it makes sense to have some kind of default wiring, one can write a dispatch; otherwise, one should specify a different kind of
WiringRule.
Semantically, the DefaultRule is the "natural" or "obvious" way to connect two blox. There might not always be one, which is why it is undefined by default in many cases. But sometimes there is. Between simples, make_rule provides the natural connection rule, so DefaultRule uses whatever it returns. There is often an "obvious" wiring for many one-to-many connections; for example, connecting a DBS stimulus to a composite is just connecting it to each part of the composite. For cases like this, DefaultRule is the right rule to dispatch on. To do this, just write a method for system_wiring_rule!(g, src::T, dst::U, conn::DefaultRule).
Another nice thing about DefaultRule is that syntactically, it can be cumbersome to have to write out the name of the connection type for every connection in the @graph macro. This allows us to assume that, if the user doesn't name the type, they mean DefaultRule.
Defining Custom WiringRule
Finally we turn to defining custom WiringRule. Defining a WiringRule is simple: create a struct with all the needed information, and then write dispatches for system_wiring_rule!.
Let's take DensityRule as an example:
struct DensityRule <: WiringRule
density::Union{Float64, Vector{Float64}}
rng::AbstractRNG
kwargs::NamedTuple
end
DensityRule(; density, rng = default_rng(), kwargs...) = DensityRule(density, rng, NamedTuple(kwargs))The behavior of this wiring rule is that a connection will exist between a source neuron and destination neuron with probability density.
Now we define how this rule behaves for a given source and destination blox type.
function GraphDynamics.system_wiring_rule!(g, src::WinnerTakeAll, dst::WinnerTakeAll, conn::DensityRule)
(; density, rng, kwargs) = conn
neurons_dst = dst.excis
neurons_src = src.excis
for ns in src.excis
idxs = findall(rand(rng, N_dst) .<= density)
for i in idxs
subrule = make_rule(ns, neurons_dst[i]; kwargs...)
system_wiring_rule!(g, ns, neurons_dst[i], subrule)
end
end
endNote that we have used make_rule to define the connection rule for the neurons inside the circuits, and when we call system_wiring_rule! for the individual neurons, the subrule is what is passed in.
Suppose now that we have made some completely new neuron type which has some completely new connection rule. To make this work with the existing DensityRule, we should write dispatches to make_rule, and define how to wire our new neurons by writing a dispatch to system_wiring_rule!:
struct MyNeuron
...
end
struct MyNeuronRule
...
end
function system_wiring_rule!(src::MyNeuron, dst::MyNeuron, conn::MyNeuronRule)
...
end
make_rule(::MyNeuron, ::MyNeuron; kwargs...) = MyNeuronRule(; kwargs...)