Confused about Spiking Neurons and Supervised Learning Concepts

Hey folks!
I am new to Nengo and confused about some concepts pertaining to supervised learning and spiking neurons. It’d be great if I could get answers to some of these doubts:

  1. What is the difference between Connection(ens1, ens2) and Connection(ens1.neurons,ens2.neurons), and how does this change the network and outputs?
  2. If I want to train a Fully Connected model, using specific input-output pairs (as is done in non-spiking neural nets in tensorflow - model.train(x=x_train, y=y_train)) what should I do? I want to do this using Nengo and not Nengo-DL.
  3. How do I extract weights from above model?
  4. How do I give vector inputs as spikes directly to neurons, to get the outputs as spikes on the second ensemble?
  5. How do I change the spiking rate of neurons? Also, why is the spiking rate for all neurons not the same in an ensemble?

Hi @RohanAj, and welcome to the Nengo forums!

I’ll attempt to address your questions below:

The difference between these two connection types has to do with the way the Neural Engineering Framework (NEF) formulates connections. In the first case, Nengo is constructing a connection where the connection weight matrix has effectively been factored into multiple parts: encoders, decoders, and a transformation matrix. You can read more about what these components do here and here.

Conversely, the connection with the .neuron attribute makes a connection from to/from an ensemble of neurons bypassing the encoders and/or decoders for that ensemble. Note that Nengo allows for the .neuron attribute to be used as the source or target (or both) of the connection, as discussed here.

If the two connection methods are created with mathematically identical connection matrices (with the .neurons connections, you’ll need to account for the encoders and decoders in the connection weight matrix), then there is no difference between the network outputs. Computationally, the networks will differ slightly as the neuron-to-neuron connection requires Nengo to use the entire connection weight matrix to compute the connections while encoded-decoded connections can use the factored encoder and decoder weights, which is more efficient.

The Nengo documentation includes several examples of how to include learning in your Nengo networks. To learn input-output pairs, this example in particular may interest you.

The code below illustrates how learning can be done with fully connected ensembles in Nengo.

with nengo.Network() as model:
    sin = nengo.Node(lambda t: np.sin(t * 4))

    pre = nengo.Ensemble(100, 1)
    post = nengo.Ensemble(100, 1)
    error = nengo.Ensemble(100, 1)

    nengo.Connection(sin, pre)
    nengo.Connection(sin, error, transform=-1)
    nengo.Connection(post, error)

    conn = nengo.Connection(pre, post, solver=nengo.solvers.LstsqL2(weights=True),
                            transform=0)
    # The solver argument, where `weights=True` initializes this connection as a
    # fully connected weight matrix connection.
    # Transform is set to 0 to initialize all connection weights to 0
    conn.learning_rule_type = nengo.PES()

    nengo.Connection(error, conn.learning_rule)

To extract the weights from the example code above, you’ll first have to create a Nengo simulator object, and then run the simulator. The code below does this, then extracts the code after the simulation has completed:

# Create and run the Nengo simulation
with nengo.Simulator(model) as sim:
    sim.run(10)

# Extract the weights after the simulation run
learned_weights = sim.model.params[conn].weights

You can input spikes to an ensemble of neurons using a nengo.Node. Keep in mind that when you do this, the spike value should be 1/dt, where dt is the dt used for your Nengo simulation (defaults to 0.001s) The following code is an example of how to do this (the spike_func is just a random spike generator that doesn’t really do anything but produce random spikes for the first 0.25s of the simulation)

def spike_func(t):
    if np.random.random() < 0.5:
        return 1 / dt and t < 0.25
    else:
        return 0


with nengo.Network() as model:
    s_in = nengo.Node(spike_func)
    ens = nengo.Ensemble(1, 1, intercepts=[0])

    nengo.Connection(s_in, ens.neurons)

To record spikes from any ensemble in a Nengo model, use the nengo.Probe functionality, like so (this carries from the previous example code):

with model:
    p_spikes = nengo.Probe(ens.neurons)

By default, Nengo generates randomized values for the biases and gains for each neuron in an ensemble. This in turn randomly generates maximum spike rates and intercepts for the neural response curves.

You can change the spike rate of neurons by specifying the max_rates parameter when creating the neural ensemble. The following code generates an ensemble of 50 neurons with firing rates from 100-200 Hz.

from nengo.dists import Uniform

with nengo.Network() as model:
    ens = nengo.Ensemble(50, 1, max_rates=Uniform(100, 200))

You can fix all of the neuron firing rates to the same value by using the Choice distribution. The code below shows how to create an ensemble of 25 neurons, all of them with maximum rates of 50 Hz.

from nengo.dists import Choice

with nengo.Network() as model:
    ens = nengo.Ensemble(25, 1, max_rates=Choice([50]))

1 Like

Hey @xchoo! Thanks a lot for the detailed explanation and the examples. Found it really helpful :smile:

I wanted to follow up on your answer to my Question 4 (recording spikes of an ensemble’s neurons).

Using the Probe function, how would I get which neurons have spiked in a specific time interval?
Say in time (t>3 and t<3.2) I would like to know which neurons have spiked and which ones haven’t, and then convert this to a binary vector using some condition: if (spike(neuron[i])==True):output[i] =1 (for example, for the ith neuron)

The format of the probed spike output looks something like this:

[[0, 0, 1000.0, 0, 1000.0, 0, ....],
 [0, 1000.0, 0, 0, 0, 0, ....],
 ...,
 [0, 0, 0, 0, 0, 1000.0, ....]]

The shape of the spikes output data is $T\times N$, where $T$ is the number of timesteps the simulation has taken, and $N$ is the number of neurons in the probed ensemble. Thus, each row of the data corresponds to each timestep (i.e., the first row is for $t=dt$, the second row for $t=2dt$, and so forth), and each column of the data represents the spike output for a specific neuron. If the neuron has spiked, a value of $1/dt$ is recorded, otherwise, $0$ is recorded (in the example above, $dt=0.001s$)

Since the returned probed data is a numpy matrix, you can use numpy’s conditional indexing to get information for specific values of $t$. As an example, to get all spiking data for $3>t<3.2$, you would do:

all_spike_data = sim.data[p_spikes]
sliced_spike_data = all_spike_data[(sim.trange() > 3) & (sim.trange() < 3.2), :]

The shaped of sliced_spiked_data will then be $Q \times N$ where $Q$ is the number of timesteps between $t=3s$ and $t=3.2s$.

The probed spiking output is already essentially in binary form, just that the values are $1/dt$ instead of just 1’s and 0’s. Thus, to convert the probed spiking output to binary form, you’ll just need to multiply the data with $dt$.

As a note, you can specify the value of $dt$ used in a Nengo simulation by providing the nengo.Simulator object with a dt parameter. The default is shown below:

with nengo.Simulator(model, dt=0.001) as sim:
    ....

@xchoo thank you so much! That’s really helpful!