Tutorial ######## Create and Simulate a Simple Network ************************************ Just like ``PymoNNto``, each ``Network`` in ``PymoNNtorch`` is composed of ``NeuronGroup`` s, which indicates the neuronal population, and possible ``SynapticGroup`` (s), which defines the synaptic connection between the ``NeuronGroup`` s. ``Network`` is the main container of all network objects (including itself) and hence, should be defined first. For instance, to create a network of 1000 neurons with regressive synaptic connections, we write: :: from pymonntorch import * net = Network() ng = NeuronGroup(net=net, size=1000, behavior={}) syn = SynapseGroup(net=net, src=ng, dst=ng, tag='GLUTAMATE') So far, ``ng`` has been added to network ``net`` and synaptic connection has been defined to connect ``ng`` to itself, i.e. both afferent and efferent synapses of ``ng`` are ``syn``. By default, each network and its components are created on CPU and the data type of any tensor inside the objects is set to ``torch.float32``. To change these settings on creation, simply, fill the arguments of the network with your desired device and dtype. To have a functioning network, we can write ``Behavior`` (s) for different network objects to define dynamics and attributes for them. To do so, we can proceed as follows: :: class BasicBehavior(Behavior): # Any user-defined behavior should inherit pymonntorch.NetworkCore.Behavior # This behavior is defining dynamics for NeuronGroups def initialize(self, neurons): # override this method to define and initialize attributes. This is called upon calling Network's initialize method. super().initialize(neurons) # Always remember to call the super method to ensure all objects and tensors are located on the same device. neurons.voltage = neurons.vector(mode="zeros") self.threshold = 2.0 def forward(self, neurons): # override this method to define what happens in each iteration of the simulation. firing = neurons.voltage >= self.threshold neurons.spike = firing.byte() neurons.voltage[firing] = 0.0 # reset neurons.voltage *= 0.9 # voltage decay neurons.voltage += neurons.vector(mode="uniform", density=0.1) Now, we can modify the instantiation of ``ng`` by:: ng = NeuronGroup(net=net, size=1000, behavior={1: BasicBehavior()}) Note that each behavior is given an index upon being assigned to a network object. This indexing scheme indicates the order in which the behaviors should be initialized and then, simulated in each iteration. For instance, assume we have also defined a behavior as follows: :: class InputBehavior(Behavior): def initialize(self, neurons): super().initialize(neurons) for synapse in neurons.afferent_synapses['GLUTAMATE']: synapse.W = synapse.matrix('uniform', density=0.1) synapse.enabled = synapse.W > 0 def forward(self, neurons): for synapse in neurons.afferent_synapses['GLUTAMATE']: neurons.voltage += synapse.W@synapse.src.spike.float() / synapse.src.size * 10 Now, assume we have defined ``ng`` by:: ng = NeuronGroup(net=net, size=1000, behavior={ 1: BasicBehavior(), 2: InputBehavior() }) In this manner, the attributes defined in ``BasicBehavior`` are initialized, and then the ones defined in ``InputBehavior``. Moreover, in each iteration of the network simulation, the dynamics defined in ``BasicBehavior``'s ``forward`` method are applied prior to the ones for ``InputBehavior``. To initialize and simulate the network for 1s, we write: :: net.initialize() net.simulate_iterations(1000) Monitoring The Simulation ************************* In most simulations, we need to keep track of variables through time. To do so, the so-called ``Recorder`` modules is useful, as it saves a buffer of the variable(s) we intend to track throughout the simulation. Let's add recorders to track the voltage and spike activity of neurons in ``ng`` in above example: :: net = Network() ng = NeuronGroup(net=net, size=1000, behavior={ 1: BasicBehavior(), 2: InputBehavior(), 9: Recorder(['voltage', 'torch.mean(voltage)']), 10: EventRecorder(['spike']) }) SynapseGroup(ng, ng, net, tag='GLUTAMATE') net.initialize() net.simulate_iterations(1000) ``EventRecorder`` is a subclass of ``Recorder`` which facilitates tracking of sparse boolean tensors by saving them in memory-efficient way (key-value scheme). Note that we have defined the two recorders in some late indices (9 & 10) to ensure every change has taken place on the desired variables. We can now plot the recorded variables to observe their behavior throughout the simulation: :: import matplotlib.pyplot as plt plt.plot(net['voltage', 0][:,0:10]) plt.plot(net['torch.mean(voltage)', 0], color='black') plt.axhline(ng['BasicBehavior', 0].threshold, color='black', linestyle='--') plt.xlabel('iterations') plt.ylabel('voltage') plt.title('Voltage of first 10 neurons') plt.show() plt.plot(net['spike.t', 0], net['spike.i', 0], '.k') plt.xlabel('iterations') plt.ylabel('neuron index') plt.title('Raster Plot') plt.show() .. image:: _images/voltage.png :width: 600 .. image:: _images/spike.png :width: 600