RFC: Nengo 4.0 API changes

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

  1. Improve the Nengo user experience for those not using the NEF.
  2. 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 in Connection
    and the use of Neurons.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.

  1. It is not clear what should happen when pre is an ensemble and the transform is not of type NEF.

  2. Similarly, it is not clear what should happen when pre is not an ensemble and the transform is of type NEF.

  3. It is not clear how we could set a default value for transform such that Connection(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())

Thanks for posting this, @tbekolay (and thanks to everyone for working on this proposal!)

As a quick summary of the above, the main user-facing changes for existing Nengo users are:

  1. There will be a nengo.Neurons object that is just a layer of neurons, so people no longer have to do something like nengo.Ensemble(n_neurons=10, dimensions=1, gain=[1]*10, bias=[0]*10) and can instead do just nengo.Neurons(n_neurons=10).
  2. Instead of nengo.Connection(a, b, function=myfunc) the syntax will change to nengo.Connection(a.decode(myfunc), b). We think this encapsulates what’s going on a bit better than what we had before.

Looking forward to seeing people’s comments and thoughts!

Looks good to me. :slight_smile:

1 Like

Thank you @tcstewar and @tbekolay for the excellent explanation. This seems fine to me.

1 Like

Hi all,

Thanks for asking.

For my two cents I have to confess that I much prefer the earlier syntax. I have been fond of what seemed to me a very explicit way of creating the connection.

nengo.Connection(a, b, function=myfunc) has many advantanges: it is flat, is makes intuitive sense (at least to me), it makes explicit that myfunc is immanent in the connection.

The alternative a.decode(myfunc) seems overzealous and unnecessarily embedded. Not easy to parse at a glance, and will require the user to go to the documentation of a.decode.

Thanks for the comment!

The fact that the original Connection syntax is flat definitely appeals to me. One big reason that we are considering this change, though, is that the flatness of the Connection means that it has a large number of possible arguments, and half of those arguments are only relevant if pre is a nengo.Ensemble. For example, you can’t do nengo.Connection(a.neurons, b, function=myfunc). The same is true for the eval_points, scale_eval_points, and solver arguments. So our feeling was it’s just kinda weird to have a bunch of arguments that you can only use sometimes. And since all of those arguments are all things to do with decoding out of the pre part of the Connection, it seemed natural to encapsulate them with that part.

But, you are quite right that nengo.Connection(a.decode(myfunc), b) is a more nested/embedded syntax than nengo.Connection(a, b, function=myfunc), which might make it less readable. One big advantage of the current syntax is that people can pretty much figure out what that line means without reading any documentation at all. It’s hard for me to tell whether the new syntax would be as intuitive to read – it might also be perfectly readable if that’s what people get introduced to first, but it might also be that the extra nesting makes things more confusing than it needs to be.

Hmm, it might even be a good excuse for some good simple A/B user testing… Grab some people who haven’t used nengo before, show them some code, and ask what they think it means…

I will also note that whether or not to do the Connection change can be considered independently of the Neurons change. Personally, I think the Neurons change is a huge improvement for nengo, and that the Connection change is only a slight improvement. (Although now that you have pointed out the potential for it being less intuitive for new users, I’d want to get some more feedback on that idea, as my intuitions about what’s intuitive for new users probably aren’t that accurate.) So I do wonder whether the Connection change is worth the development effort.

Hi @mrio, thanks for weighing in.

I’m curious what you mean by “overzealous,” are you referring to the explicitness of the NEF decode operation? I’m curious what it is we’re being zealous about, basically.

I have several thoughts in regards to the flat vs. embedded discussion. The first is technical, which is that you can certainly do everything in such a way that there are no calls within calls (which is what I assume you mean by embedded):

a = nengo.Ensemble(80, dimensions=2)
b = nengo.Ensemble(40, dimensions=1)
a_product = a.decode(lambda x: x[0] * x[1])
nengo.Connection(a_product, b)

I would argue that this is equally easy to parse at a glance, and equivalent in terms of ushering people to documentation. No one is going to know how to do these things without either seeing an example, so we can assume that they have seen this snippet before heading to the documentation. Then once you’re there, you’re either going to the Ensemble.decode documentation or the nengo.Connection documentation. Our goal here is to make the nengo.Connection documentation simpler because everyone needs to go there whether they’re using the NEF or not; only NEF users will need to go to the Ensemble.decode docs. This also improves the quality of documentation for Ensemble.decode because the fact that we are decoding from an ensemble of neurons is part of the syntax itself, rather than being something that we have to explain is a result of passing in a function.

My second thought is that the proposed syntax is more readable from the perspective that an NEF connection is a mathematical transformation. It’s far easier for us humans to read mathematical equations with infix notation (2 + 2 rather than + 2 2 or 2 2 + – note that prefix and postfix notation are far easier for machines to parse). Similarly, this API change lets us describe an NEF transformation in an infix-like way: nengo.Connection(a.decode(lambda x: x[0] * x[1]), b) gives me all of the left-hand side of the equation before the right-hand side. With the current “flat” syntax, you define it in parts, you say “here’s the left-hand side (pre), here’s the right-hand side (post), then also here’s some more information about the left-hand side (function)”.

Another way to think of my infix argument above is how you would say this connection out loud to someone looking over your shoulder. In the current flat syntax, say you have

nengo.Connection(pre, post, function=lambda x: x[0] * x[1])

I would say something like “I’m connecting my pre ensemble to the post ensemble, and across that connection I’m multiplying the two dimensions of pre.” That probably makes good sense to you if you know the NEF pretty well.

In the new proposed syntax, you would have

nengo.Connection(pre.decode(lambda x: x[0] * x[1]), post)

I would say something like “I’m sending the value I get from multiplying the two dimensions of pre to post.” The emphasis here is (appropriately, IMO) on what is happening in pre, and for post all that matters is that it is receiving that value. I would argue that you don’t really need to know the NEF as intimately in order to explain this, but that’s a subjective thought. You could say that I’m setting up a bit of a strawman here because there’s a better way to explain the current “flat” version of this line, and I would definitely be interested to hear that better explanation because I always find it hard to phrase, personally.

My final though is a bit more conceptual, and touches on your comment that the function is “immanent in the connection.” In part what we are trying to achieve with the proposal is to demystify Connection. Right now, I believe that the connection feels very magical because it can do so many different things depending on what things are provided as pre and post, and what combinations of other arguments you provide. There’s a pretty big conceptual difference (again, in my opinion) between sending some direct current stimulation to a set of neurons (which would be a node to neuron connection) and doing a communication channel between two ensembles (which is an ensemble to ensemble connection). Right now, those two cases look the same, and that’s sort of by design because we originally wanted to hide the complexity of the decoding process in the connection builder. I would still be okay with that if NEF decoding was just one of many instances where a lot of complexity is hidden, but in actuality the NEF decode is the only thing like it inside the builder. The rest of the builder logic works the same regardless of the types involved (even things like the new Convolution transform). So, my opinion is that the Connection only seems magical because sometimes it does an NEF decode and sometimes it doesn’t. Removing that ambiguity makes the Connection easy to understand. By making the modeler be the one that removes that ambiguity (except in the special case of the communication channel) the Connection is straightforward, and doesn’t require any knowledge of the NEF to explain.

The point I’m trying to get across from that last rambly paragraph is that encapsulating all that logic inside the Ensemble leads to a better mental model for NEF users overall because it makes the Connection easier to understand for NEF and non-NEF users alike. There may be a need for existing NEF users to modify their mental model of how things are working, but I believe that the modified mental model is closer to what is actually happening and what should actually be happening.

1 Like