Sango is a compositional spiking neural network domain specific language that is implemented as an internal DSL within Python, introducing a number of high level classes for compositionally constructing networks, and enabling flexible code reuse.
The name Sango is inspired by the Japanese word for coral, a common habitat for pufferfish (see the Fugu project), and admits the backronym Structured Abstractions for Network Group Organization.
The Sango package requires Python 3.9 or above (for more advanced dataclass support). There are also two main dependencies that are currently required: NumPy and NetworkX. The features used by these external packages are fairly generic, so versioning should not be an issue. Installation proceeds as with any standard python package.
git clone https://github.com/sandialabs/Sango.git
cd Sango
python -m pip install -e .
At the core of Sango is the structural organization of networks analogous to a directory listing of folders (networks themselves), files (topological components), and their contents (nodes and edges). Additionally, alias classes (analogous to symlinks) provide a degree of indirection and introduce reference dependencies that allows for the procedural construction of complex network topologies.
The main classes that accomplish this are:
Network: the main container that is built out of high-level topological components (including other networks)NodeGroup: a group of instantiated nodes sharing the same node model (e.g. a population of neurons)EdgeGroup: a group of instantiated edges between two sets of nodes (e.g. a projection of synapses)NodePort: an alias class pointing to set of (external) nodes (e.g. placeholder nodes for network inputs)NodeList: an alias class with a list of (pointers to) nodes (e.g. a collection of nodes for network outputs)
The construction of a network proceeds through creating network objects and adding topological components to them. Syntactically, these topological components are assigned as attributes to a Network object instance and accessed through dot notation. Additionally, lists of any of these classes may be assigned and are indexed through bracket notation.
The individual nodes and edges within their groups are also indexed through bracket notation. This combination of dot and bracket notation determines the "path name" of instantiated nodes, and the pair of source and target "path names" identify instantiated edges.
net = Network() # instantiate network
net.layer = [NodeGroup(LIF(), 32), # add a list of node groups
NodeGroup(LIF(), 10)]
net.dense = EdgeGroup(net.layer[0], # add an edge group connecting
net.layer[1], # the node groups
PSP(), edges=...)
net.inp = NodePort() # add an input port (not linked)
net.out = NodeList(net.layer[1][::2]) # add an output list (slicing)After a network topology has been defined, it can be "built" accordingly. This process is performed iteratively and recursively for any child networks within the topology to resolve any dependencies introduced through placeholder node ports (implementing a topological sort). The path structure through the network topology is also "flattened" to generate the full path names for the instantiated nodes. More complex example networks are provided in the notebooks.
net.build() # build network topology
print(net) # print the topological components
# Network Topology:
# (node) layer[0]
# (node) layer[1]
# (edge) dense: (node) layer[0] -> (node) layer[1]
# (port) inp <- (no link)
# (list) outIt is possible and encouraged to develop subclasses of network for organization and code reuse. This allows you more easily to use networks in the construction of nested structures.
The definition of a network is split into two main methods:
__init__(): this is where node ports and any relevant network parameters are providedbuild(): this is where topological components are assigned (potentially procedurally)
The use of node ports additionally allows for variably sized networks that are determined at build time. During the automated iterative and recursive build process, any dependencies that were generated by unsized node ports in the network initialization method are resolved before the build method is called.
class Linear(Network):
def __init__(self, size=64):
super().__init__() # base class initialization (required)
self.size = size # user-defined network parameters
self.inp = NodePort() # unsized node port (generates dependency)
self.ctrl = NodePort(1) # sized node port (no dependency)
def build(self):
# Layers (using supplied parameters)
self.layer = NodeGroup(LIF(), self.size)
# Procedurally generating edges (using resolved port information)
edges = [(i,j) for i,j in itertools.product(
range(self.inp.size),range(self.layer.size))]
# Connections (using node port placeholder)
self.dense = EdgeGroup(self.inp, self.layer, PSP(), edges=edges)
returnPort size resolution is generally performed by connecting/linking a node port with a sized node group/list (from elsewhere in the network topology), or by manually specifying its size. In the case that node port dependencies cannot be resolved (e.g. for cyclic structures), the user will need to provide some additional information to break the dependency chain.
net = Network() # instantiate network
net.inp = NodeGroup(IN(), 12) # add an input node group
net.ff = [Linear(32), # add a list of networks
Linear(10)]
# connect topological components (using dot notation)
net.connect(net.inp, net.ff[0].inp)
net.connect(net.ff[0].layer, net.ff[1].inp)
net.build() # build network topology
print(net) # print the topological components
# Network Topology:
# (node) inp
# (port) ff[0].inp <- (node) inp
# (node) ff[0].layer
# (edge) ff[0].dense: (port) ff[0].inp <- (node) inp -> (node) ff[0].layer
# (port) ff[1].inp <- (node) ff[0].layer
# (node) ff[1].layer
# (edge) ff[1].dense: (port) ff[1].inp <- (node) ff[0].layer -> (node) ff[1].layerThe various node and edge models (LIF and PSP in the example, respectively) are defined as Python dataclasses, where each attribute may be assigned a default value for initialization of state variables (e.g. as ints, floats). The use of tuples indicate shared parameters with respect to a node group.
When a node/edge group is defined, a node/edge model instance is provided (allowing it to have different defaults than in the class definition by providing keyword arguments). Data within a node/edge group are stored as numpy arrays to take advantage of its shared memory property (so that changing parameters at either the group or individual node/edge level will be reflected coherently). These can be accessed using node/edge model attribute name at the group level.
@dataclass
class LIF(Neuron):
model: str = 'LIF' # model name
voltage: float = 0.0 # individual parameter
threshold: float = 1.0
reset: float = 0.0, # shared parameter (note: this is for illustration, the
leak: float = 1.0 # basic LIF model provided by Sango has
# 'reset' as an individual parameter)
res = NodeGroup(LIF(threshold=0.9), # model parameter default
size=3, # node group size
voltage=0.6, # bulk parameter assignment
leak=[0.5, 0.4, 0.3]) # individual parameter assignment
res.reset = 0.1 # shared parameter assignment
res[1].voltage = 0.8 # individual node parameter assignment
print(res.model) # ['LIF']
print(res.voltage) # [0.6 0.8 0.6]
print(res.threshold) # [0.9 0.9 0.9]
print(res.reset) # [(0.1,)]
print(res.leak) # [0.5 0.4 0.3]@dataclass
class PSP(Synapse):
model: str = 'PSP'
delay: float = 1.0
weight: float = 1.0
rr = EdgeGroup(res, res, PSP(delay=2.0), # model parameter default
edges=[(0,1), (1,2), (2,0)], # list of directed tuples (source, target)
weight=[1.0, 2.0, 3.0]) # individual parameter assignment
rr[(1,2)].delay = 3.0 # individual edge parameter assignment (by tuple)
rr[0].weight = 1.5 # individual edge parameter assignment (by index)
print(rr.delay) # [2.0 3.0 2.0]
print(rr.weight) # [1.5 2.0 3.0]
# Edge groups have a mapping from directed tuples to linear indexes
print(rr.edge_map) # {(0, 1): 0, (1, 2): 1, (2, 0): 2}
print(rr.target_index) # [1, 2, 0]Node groups require a node model, and are preferably also instantiated with a size (as opposed to incrementally adding nodes). By default, if no size is provided, a single node will be instantiated.
Edge groups require an edge model and source/target nodes (which may be node groups, node lists, or node ports), and are preferably also instantiated with a list of edges. This is a list of tuples of pairs of source/target indexes that are local with respect to the source/target sets of nodes, respectively. By default, if no edge list is provided, an edge with the source/target pair of (0,0) will be instantiated.
In addition to defining networks, it is often useful to simulate the constructed network. There is currently some simulation support provided through the Brian 2 and STACS spiking neural network simulators (installed separately). Here, Sango may be thought of as the "frontend" interface to the "backend" simulator (or potentially neuromorphic hardware platform). This decoupling between frontend and backend is important for maintaining flexibility in the high-level network descriptions and for portability to different low-level network implementations.
# Import the desired simulator backend
from sango.backend import SimBrian
sim = SimBrian(net) # Pass the built network to the backend translation layer
sim.compile() # Convert the network onto the backend execution model
sim.run(10.0) # Simulate the network (arguments may be backend-specific)Custom user inputs into a network may be provided through input node models. In particular, there is a simple spike generator input model that takes a list of spike times (which specifies when the node emits spikes). This can be wrapped in a node group to provide a list of input nodes, and further wrapped in a network for compositional reuse.
class Input(Network):
def __init__(self, spike_times):
super().__init__()
self.spike_times = spike_times # number of nodes x lists of times
def build(self):
# Spike generator
self.spikegen = NodeGroup(IN(), len(self.spike_times), times=self.spike_times)
return
# Define spiking inputs as a list of lists
input_vec = [[2, 4, 5], # 0,1,1,0,1,0,0 (represented as bit strings,
[0, 1, 4, 6]] # 1,0,1,0,0,1,1 least significant bit first)
net = Network() # instantiate network
net.inp = Input(spike_times=input_vec) # add an input networkSimulation outputs are similarly provided as a spike list of times (per node in the network). The mapping between a node in Sango and the corresponding node in the backend is provided through a "node map" which is generated during the compilation process. There are also some convenience functions to plot the resulting spike raster. Additional features, such as recording state variables, depend on the backend that is used.
spike_list = sim.get_spikes() # Get the spike list for each node
node_index = sim.node_map['inp.spikegen[1]'] # Find the index of a node by name
inp1_spike = spike_list[node_index] # Extract the node spike times
# Plot the spike raster (this uses matplotlib's eventplot)
sim.plot_spikes(tick_names=True)Sango currently provides a method for converting its network object into a NetworkX directed graph for ease of translation. Nodes and edges are simply identified by their flattened "path name", and any associated data are provided as additional attributes. While the network descriptions in Sango are fairly flexible and open-ended, it does not prescribe node/edge model dynamics or how those computations should be implemented. Their translation with respect to a backend simulator is mediated through a "model registry" which provides the necessary information for mapping models. This is also intended to provide a degree of extensibility for custom user models.
There is existing backend support for these basic node and edge models: LIF (leaky integrate-and-fire neuron model), PSP (post-synaptic potential synapse model), and IN (simple spike generator input model). There is also support for probabilistic spiking: pLIF (probabilistic LIF neuron model, which is used in Fugu). Support for additional model types may require updating the model registry.