Hi everyone,
The Nengo team would like to solicit feedback from the Nengo community regarding some planned changes for the Nengo 4.0 release.
The Nengo 3.0 release will happen soon and will include the features currently in master. Nengo 3.1 will introduce the changes described here in a backwards compatible way, then that backwards compatibility will be removed in Nengo 4.0.
The remainder of this post describes the changes that we’re planning to make. We will start implementing these changes following the Nengo 3.0 release, which could happen in a few weeks, so please share your thoughts on this proposal by September 20.
Motivation
- Improve the Nengo user experience for those not using the NEF.
- Encapsulate NEF-specific operations in NEF-specific components.
Goal
In the Nengo 4.0 release, we aim to move NEF-specific aspects of Nengo into NEF-specific encapsulations.
Specific changes
Neurons becomes a first-class object
The non-NEF aspects of nengo.Ensemble
will be moved to the nengo.Neurons
object, which will be promoted to a first-class object.
class Neurons:
def __init__(
self,
n_neurons,
neuron_type=Default<nengo.LIF()>,
noise=Default<None>,
label=Default<None>,
seed=Default<None>,
):
pass
Objects of type Neurons
can be used like we currently use Neurons
objects. The main difference is that the Neurons
class can be instantiated and used without an associated Ensemble
. This means that the .ensemble
attribute will likely be removed (possibly right away, or possibly after a deprecation process).
Ensemble contains a Neurons instance
The nengo.Ensemble
constructor will remain the same as before. The Ensemble
is not a subclass of Neurons
, but it will contain an instance of Neurons
as the .neurons
attribute.
class Ensemble:
def __init__(
self,
n_neurons,
dimensions,
radius=Default,
encoders=Default,
intercepts=Default,
max_rates=Default,
eval_points=Default,
n_eval_points=Default,
neuron_type=Default,
gain=Default,
bias=Default,
noise=Default,
normalize_encoders=Default,
label=Default,
seed=Default,
):
self.neurons = Neurons(
n_neurons, neuron_type, noise, label+".neurons", seed
)
The Ensemble
can therefore be instantiated in Nengo>=4.0 the same way as in Nengo<=3.0. The only difference is to the contained .neurons
object, which will (eventually) lose its .ensemble
back reference.
Move connection args to Ensemble.decode
nengo.Neurons
allows non-NEF users to avoid the NEF-specific parts of the Ensemble
. For the Connection
, we encapsulate its NEF-specific parts in a new object that can be used as the pre
of a Connection
.
class DecodedValue:
def __init__(
self,
ensemble,
function=lambda x: x,
eval_points=None,
n_eval_points=None,
scale_eval_points=True,
solver=nengo.solvers.LstsqL2(),
):
pass
This object is returned by the new Ensemble.decode
method.
class Ensemble:
def decode(
self,
function=lambda x: x,
eval_points=None,
n_eval_points=None,
scale_eval_points=True,
solver=nengo.solvers.LstsqL2(),
):
return DecodedValue(
self,
function,
eval_points,
n_eval_points,
scale_eval_points,
solver,
)
These are analogous to the existing ObjView
class and the __getitem__
methods that return ObjView
instances.
These parameters will be removed from nengo.Connection
, resulting in the following call signature.
class Connection:
def __init__(
self,
pre,
post,
synapse=Default<nengo.Lowpass(tau=0.005)>,
transform=Default<1.0>,
learning_rule_type=Default<None>,
label=Default<None>,
seed=Default<None>,
):
pass
This change makes it straightforward to use alternative transform
types like Sparse
and Convolution
in NEF vector space. Uses for those specific transforms might be rare, but may be more plausible for future transform types.
In the most common use cases, this means that Nengo<=3.0 and Nengo>=4.0 are the same.
# Example 1: communication channel
pre = nengo.Ensemble(n_neurons=2, dimensions=1)
post = nengo.Ensemble(n_neurons=3, dimensions=1)
nengo.Connection(pre, post)
And for other use cases, Nengo>=4.0 will require only minimal changes.
# Example 2: product
pre = nengo.Ensemble(60, dimensions=2)
post = nengo.Ensemble(30, dimensions=1)
nengo.Connection(pre.decode(lambda x: x[0] * x[1]), post)
Timeline
The Ensemble
does not change significantly, and the Connection
changes can be done in a backwards compatible way. As a result, the timeline for these changes are:
- Nengo 3.0: Includes none of the changes in this proposal.
- Nengo 3.1: Includes all changes in a backwards-compatible way.
Deprecates use of NEF-specific arguments inConnection
and the use ofNeurons.ensemble
. - Nengo 4.0: Removes backwards compatibility and deprecated items.
Alternatives considered
We considered the following alternatives to aspects of the above proposal.
NEF transform
Given the recent additions of the nengo.Convolution
and nengo.Sparse
transforms, a natural way to encapsulate NEF parameters would be to put them in a nengo.NEF
transform that would be passed to nengo.Connection
.
# NEF transform example
pre = nengo.Ensemble(60, dimensions=2)
post = nengo.Ensemble(30, dimensions=1)
nengo.Connection(
pre, post, transform=nengo.NEF(function=lambda x: x[0] * x[1])
)
This idea was rejected in favour of the .decode
method for several reasons.
-
It is not clear what should happen when
pre
is an ensemble and the transform is not of typeNEF
. -
Similarly, it is not clear what should happen when
pre
is not an ensemble and the transform is of typeNEF
. -
It is not clear how we could set a default value for
transform
such thatConnection(pre, post)
is a communication channel for ensembles and something else for non-ensembles.
Subclass instead of containment
Given that Ensemble
and Neurons
share some arguments, it may seem as though the Ensemble
should be a subclass of Neurons
rather than containing an instance of Neurons
.
This idea was rejected because our goal is to make an explicit separation between a group of neurons
and an NEF ensemble. Subclassing gives the mental model that an ensemble is a group of neurons,
but might act differently in a few situations. It is difficult to explain where they are the same and where they are different.
Using containment, we instead give the mental model that they are completely different objects, linked only by the fact that an ensemble’s underlying neurons can be accessed with .neurons
. Non-NEF users can completely ignore Ensemble
, and NEF users can largely ignore Neurons
unless they need direct access for inhibition and the like.
Mark dimensions as optional
The current RFC represents a relatively large change to the API because it makes the NEF something that users have to opt into. That is, if a user only uses Neurons
objects and no ensembles, they can use Nengo as a generic neural network simulator, which may happen much more frequently with the addition of the nengo.Convolution
transform.
A halfway measure between the current state of Nengo and this proposal would be to mark dimensions
as an optional argument to nengo.Ensemble
. By doing this, users could effectively ignore the NEF
by using default values everywhere.
This idea was rejected because it is not possible to completely ignore the NEF in machine learning models not using the NEF. Most traditional convolutional neural networks, for example, expect homogeneous layers of neurons. In order to achieve that in Nengo, one would have to pass specific values for intercepts
, max_rates
, etc., effectively requiring the model to completely opt out of the NEF. Requiring an NEF opt out goes against the motivation of this RFC and presents a poor user experience to those using Nengo for non-NEF machine learning models.
Larger examples
To further demonstrate how models will need to change as a result of this proposal, we present three longer examples, including how they are implemented today and how they will be implemented in the future.
Example 3: Controlled integrator
nengo<=3.0
import nengo
from nengo.processes import Piecewise
with nengo.Network() as net:
ens = nengo.Ensemble(225, dimensions=2, radius=1.5)
input_func = Piecewise({0: 0, 0.2: 5, 0.3: 0, 0.44: -10, 0.54: 0, 0.8: 5, 0.9: 0})
inp = nengo.Node(input_func)
tau = 0.1
nengo.Connection(inp, ens, transform=[[tau], [0]], synapse=tau)
control_func = Piecewise({0: 1, 0.6: 0.5})
control = nengo.Node(output=control_func)
nengo.Connection(control, ens[1], synapse=0.005)
nengo.Connection(ens, ens[0], function=lambda x: x[0] * x[1], synapse=tau)
ens_probe = nengo.Probe(ens, synapse=0.01)
nengo>=4.0
import nengo
from nengo.processes import Piecewise
with nengo.Network() as net:
ens = nengo.Ensemble(225, dimensions=2, radius=1.5)
input_func = Piecewise({0: 0, 0.2: 5, 0.3: 0, 0.44: -10, 0.54: 0, 0.8: 5, 0.9: 0})
inp = nengo.Node(input_func)
tau = 0.1
nengo.Connection(inp, ens, transform=[[tau], [0]], synapse=tau)
control_func = Piecewise({0: 1, 0.6: 0.5})
control = nengo.Node(output=control_func)
nengo.Connection(control, ens[1], synapse=0.005)
nengo.Connection(ens.decode(lambda x: x[0] * x[1]), ens[0], synapse=tau)
ens_probe = nengo.Probe(ens, synapse=0.01)
Diff
diff --git 1/integrator.py 2/integrator-new.py
index f98d83b..e1f9be0 100644
--- 1/integrator.py
+++ 2/integrator-new.py
@@ -15,6 +15,6 @@ with nengo.Network() as net:
control = nengo.Node(output=control_func)
nengo.Connection(control, ens[1], synapse=0.005)
- nengo.Connection(ens, ens[0], function=lambda x: x[0] * x[1], synapse=tau)
+ nengo.Connection(ens.decode(lambda x: x[0] * x[1]), ens[0], synapse=tau)
ens_probe = nengo.Probe(ens, synapse=0.01)
Example 4: Direct neural inhibition
nengo<=3.0
import numpy as np
import nengo
from nengo.processes import Piecewise
with nengo.Network() as net:
ens_a = nengo.Ensemble(30, dimensions=1)
ens_b = nengo.Ensemble(30, dimensions=1)
ens_c = nengo.Ensemble(30, dimensions=1)
ens_inhib = nengo.Ensemble(30, dimensions=1)
neuron_inhib = nengo.Ensemble(
1,
dimensions=1,
encoders=nengo.dists.Choice([[1]]),
intercepts=nengo.dists.Choice([0.1]),
)
input = nengo.Node(np.sin)
node_inhib = nengo.Node(Piecewise({0: 0, 2.5: 1, 5: 0, 7.5: 1, 10: 0, 12.5: 1}))
# Inhibited by direct node input
nengo.Connection(input, ens_a)
nengo.Connection(
node_inhib, ens_a.neurons, transform=-2.5 * np.ones((ens_a.n_neurons, 1))
)
# Inhibited by ensemble to neuron connection
nengo.Connection(input, ens_b)
nengo.Connection(input, ens_inhib)
nengo.Connection(
ens_inhib, ens_b.neurons, transform=-2.5 * np.ones((ens_b.n_neurons, 1))
)
# Inhibited by neuron to neuron connection
nengo.Connection(input, ens_c)
nengo.Connection(input, neuron_inhib)
nengo.Connection(
neuron_inhib.neurons,
ens_c.neurons,
transform=-2.5 * np.ones((ens_c.n_neurons, neuron_inhib.n_neurons)),
)
nengo>=4.0
import numpy as np
import nengo
from nengo.processes import Piecewise
with nengo.Network() as net:
ens_a = nengo.Ensemble(30, dimensions=1)
ens_b = nengo.Ensemble(30, dimensions=1)
ens_c = nengo.Ensemble(30, dimensions=1)
ens_inhib = nengo.Ensemble(30, dimensions=1)
neuron_inhib = nengo.Neurons(1)
input = nengo.Node(np.sin)
node_inhib = nengo.Node(Piecewise({0: 0, 2.5: 1, 5: 0, 7.5: 1, 10: 0, 12.5: 1}))
# Inhibited by direct node input
nengo.Connection(input, ens_a)
nengo.Connection(
node_inhib, ens_a.neurons, transform=-2.5 * np.ones((ens_a.n_neurons, 1))
)
# Inhibited by ensemble to neuron connection
nengo.Connection(input, ens_b)
nengo.Connection(input, ens_inhib)
nengo.Connection(
ens_inhib, ens_b.neurons, transform=-2.5 * np.ones((ens_b.n_neurons, 1))
)
# Inhibited by neuron to neuron connection
nengo.Connection(input, ens_c)
nengo.Connection(input, neuron_inhib)
nengo.Connection(
neuron_inhib,
ens_c.neurons,
transform=-2.5 * np.ones((ens_c.n_neurons, neuron_inhib.n_neurons)),
)
Diff
diff --git 1/inhib.py 2/inhib-new.py
index 1e81126..0acb047 100644
--- 1/inhib.py
+++ 2/inhib-new.py
@@ -9,12 +9,7 @@ with nengo.Network() as net:
ens_b = nengo.Ensemble(30, dimensions=1)
ens_c = nengo.Ensemble(30, dimensions=1)
ens_inhib = nengo.Ensemble(30, dimensions=1)
- neuron_inhib = nengo.Ensemble(
- 1,
- dimensions=1,
- encoders=nengo.dists.Choice([[1]]),
- intercepts=nengo.dists.Choice([0.1]),
- )
+ neuron_inhib = nengo.Neurons(1)
input = nengo.Node(np.sin)
node_inhib = nengo.Node(Piecewise({0: 0, 2.5: 1, 5: 0, 7.5: 1, 10: 0, 12.5: 1}))
@@ -36,7 +31,7 @@ with nengo.Network() as net:
nengo.Connection(input, ens_c)
nengo.Connection(input, neuron_inhib)
nengo.Connection(
- neuron_inhib.neurons,
+ neuron_inhib,
ens_c.neurons,
transform=-2.5 * np.ones((ens_c.n_neurons, neuron_inhib.n_neurons)),
)
Example 5: Simple converted keras model
Keras equivalent is in comments.
nengo<=3.0
# import tensorflow as tf
# from tensorflow.keras.layers import Input, Conv2D, Dense
import nengo
from nengo.transforms import Convolution
import numpy as np
class Glorot(nengo.dists.Distribution):
pass
with nengo.Network() as net:
net.config[nengo.Ensemble].neuron_type = nengo.RectifiedLinear()
net.config[nengo.Connection].synapse = None
net.config[nengo.Ensemble].gain = nengo.dists.Choice([1])
net.config[nengo.Ensemble].bias = nengo.dists.Choice([0])
# input = Input(shape=(8, 8))
input = nengo.Node(np.zeros(64))
# conv0 = Conv2D(filters=4, kernel_size=3, activation=tf.nn.relu)(input)
conv0 = nengo.Ensemble(n_neurons=36, dimensions=1)
nengo.Connection(
input,
conv0.neurons,
transform=Convolution(n_filters=4, input_shape=(8, 8), kernel_size=3),
)
# conv1 = Conv2D(filters=4, kernel_size=3, activation=tf.nn.relu)(conv0)
conv1 = nengo.Ensemble(n_neurons=16, dimensions=1)
nengo.Connection(
conv0.neurons,
conv1.neurons,
transform=Convolution(n_filters=4, input_shape=(6, 6), kernel_size=3),
)
# dense0 = Dense(units=20, activation=tf.nn.relu)(conv1)
dense0 = nengo.Ensemble(n_neurons=20, dimensions=1)
nengo.Connection(conv1.neurons, dense0.neurons, transform=Glorot())
# dense1 = Dense(units=10, activation=tf.nn.relu)(dense0)
dense1 = nengo.Ensemble(n_neurons=10, dimensions=1)
nengo.Connection(dense0.neurons, dense1.neurons, transform=Glorot())
nengo>=4.0
# import tensorflow as tf
# from tensorflow.keras.layers import Input, Conv2D, Dense
import nengo
from nengo.transforms import Convolution
import numpy as np
class Glorot(nengo.dists.Distribution):
pass
with nengo.Network() as net:
net.config[nengo.Neurons].neuron_type = nengo.RectifiedLinear()
net.config[nengo.Connection].synapse = None
# input = Input(shape=(8, 8))
input = nengo.Node(np.zeros(64))
# conv0 = Conv2D(filters=4, kernel_size=3, activation=tf.nn.relu)(input)
conv0 = nengo.Neurons(n_neurons=36)
nengo.Connection(
input,
conv0,
transform=Convolution(n_filters=4, input_shape=(8, 8), kernel_size=3),
)
# conv1 = Conv2D(filters=4, kernel_size=3, activation=tf.nn.relu)(conv0)
conv1 = nengo.Neurons(n_neurons=16)
nengo.Connection(
conv0,
conv1,
transform=Convolution(n_filters=4, input_shape=(6, 6), kernel_size=3),
)
# dense0 = Dense(units=20, activation=tf.nn.relu)(conv1)
dense0 = nengo.Neurons(n_neurons=20)
nengo.Connection(conv1, dense0, transform=Glorot())
# dense1 = Dense(units=10, activation=tf.nn.relu)(dense0)
dense1 = nengo.Neurons(n_neurons=10)
nengo.Connection(dense0, dense1, transform=Glorot())
Diff
diff --git 1/dl.py 2/dl-new.py
index 8d0c6bb..50882f5 100644
--- 1/dl.py
+++ 2/dl-new.py
@@ -10,34 +10,32 @@ class Glorot(nengo.dists.Distribution):
with nengo.Network() as net:
- net.config[nengo.Ensemble].neuron_type = nengo.RectifiedLinear()
+ net.config[nengo.Neurons].neuron_type = nengo.RectifiedLinear()
net.config[nengo.Connection].synapse = None
- net.config[nengo.Ensemble].gain = nengo.dists.Choice([1])
- net.config[nengo.Ensemble].bias = nengo.dists.Choice([0])
# input = Input(shape=(8, 8))
input = nengo.Node(np.zeros(64))
# conv0 = Conv2D(filters=4, kernel_size=3, activation=tf.nn.relu)(input)
- conv0 = nengo.Ensemble(n_neurons=36, dimensions=1)
+ conv0 = nengo.Neurons(n_neurons=36)
nengo.Connection(
input,
- conv0.neurons,
+ conv0,
transform=Convolution(n_filters=4, input_shape=(8, 8), kernel_size=3),
)
# conv1 = Conv2D(filters=4, kernel_size=3, activation=tf.nn.relu)(conv0)
- conv1 = nengo.Ensemble(n_neurons=16, dimensions=1)
+ conv1 = nengo.Neurons(n_neurons=16)
nengo.Connection(
- conv0.neurons,
- conv1.neurons,
+ conv0,
+ conv1,
transform=Convolution(n_filters=4, input_shape=(6, 6), kernel_size=3),
)
# dense0 = Dense(units=20, activation=tf.nn.relu)(conv1)
- dense0 = nengo.Ensemble(n_neurons=20, dimensions=1)
- nengo.Connection(conv1.neurons, dense0.neurons, transform=Glorot())
+ dense0 = nengo.Neurons(n_neurons=20)
+ nengo.Connection(conv1, dense0, transform=Glorot())
# dense1 = Dense(units=10, activation=tf.nn.relu)(dense0)
- dense1 = nengo.Ensemble(n_neurons=10, dimensions=1)
- nengo.Connection(dense0.neurons, dense1.neurons, transform=Glorot())
+ dense1 = nengo.Neurons(n_neurons=10)
+ nengo.Connection(dense0, dense1, transform=Glorot())