ValueError: Equations of type 'parameter' cannot have a flag 'linked', only the following flags are allowed: ['constant', 'shared']

Description of problem

I am trying to implement the homeostatic inhibitory STDP rule used in Zenke (2015) in Brian2. The rule uses a symmetric STDP kernel that is modulated in size and sign by a global factor G, which corresponds to the difference between a (smoothed) estimate of the population firing rate H and a desired firing rate gamma. I am trying to implement this rule using a synapse model with a linked variable. However, my current attempt results in a cryptic error message.

Full traceback of error (if relevant)

Traceback (most recent call last):
  File "/home/paul/work/gothard/code/inhibitory_homeostatic_plasticity_with_shared_global_factor.py", line 61, in <module>
    synapses = b2.Synapses(
               ^^^^^^^^^^^^
  File "/home/paul/opt/miniconda3/envs/brian2/lib/python3.11/site-packages/brian2/synapses/synapses.py", line 853, in __init__
    model.check_flags(
  File "/home/paul/opt/miniconda3/envs/brian2/lib/python3.11/site-packages/brian2/equations/equations.py", line 1234, in check_flags
    raise ValueError(
ValueError: Equations of type 'parameter' cannot have a flag 'linked', only the following flags are allowed: ['constant', 'shared']

Minimal code to reproduce problem

"""
This script implements a STDP rule with a global/shared factor as in Zenke et al. (2015).
"""

import numpy as np
import matplotlib.pyplot as plt
import brian2 as b2

b2.start_scope()

net = b2.Network(b2.collect())

# set (target) population firing rate; 
# in a non-toy simulation, spikes would be recorded from a large pyramidal neuron population
total_epochs = 11
epoch_length = 1 * b2.second
population_size = 1
spike_rates = np.geomspace(1, 50, total_epochs)
spike_rates_array = b2.TimedArray(spike_rates * b2.Hz, dt=epoch_length)
spike_generators = b2.PoissonGroup(population_size, rates="spike_rates_array(t)")
net.add(spike_generators)

# compute global factor G
gamma = 5 * b2.Hz
tau_H = 10 * b2.second
global_factor_model = """
G = H - gamma : Hz
dH/dt = -H/tau_H : Hz
"""
global_factor = b2.NeuronGroup(1, model=global_factor_model)
net.add(global_factor)
spike_aggregator = b2.Synapses(spike_generators, global_factor, on_pre="H_post += 1/(tau_H * population_size)")
spike_aggregator.connect()
net.add(spike_aggregator)

# initialize the pre- and post-synaptic neuron
indices = np.zeros(total_epochs)
times = epoch_length * np.arange(total_epochs) + 0.5 * epoch_length
presynaptic_neuron = b2.SpikeGeneratorGroup(1, indices, times)
net.add(presynaptic_neuron)
delta = 10 * b2.msecond
postsynaptic_neuron = b2.SpikeGeneratorGroup(1, indices, times + delta)
net.add(postsynaptic_neuron)

# initialize the synapse
tau_stdp = 20. * b2.msecond
eta = 1. * b2.nsiemens * b2.second
synapse_model = """
G : Hz (linked)
wij : siemens
dzi/dt = -zi / tau_stdp : 1 (event-driven)
dzj/dt = -zj / tau_stdp : 1 (event-driven)
"""
on_pre = """
zi += 1.
wij += eta * G * zj
"""
on_post = """
zj += 1.
wij += eta * G * (zi + 1)
"""
synapses = b2.Synapses(
    presynaptic_neuron, postsynaptic_neuron,
    model=synapse_model, on_pre=on_pre, on_post=on_post
)
synapses.connect(i=0, j=0)
# synapses.G = b2.linked_var(global_factor.G)
synapses.G = b2.linked_var(global_factor, "G")
synapses.wij[:] = 0 * b2.nsiemens
net.add(synapses)

# monitor synaptic variables
monitor = b2.StateMonitor(synapses, ["G", "wij"], record=True, dt=epoch_length)
net.add(monitor)

# run
net.run(total_epochs * epoch_length)

# plot
fig, axes = plt.subplots(2, 1, sharex=True)
axes[0].plot(np.squeeze(monitor.G / b2.Hz), color="gray")
axes[0].set_ylabel("Global factor [Hz]")
axes[1].plot(np.diff(np.squeeze(monitor.wij / b2.nsiemens)), color="gray")
axes[1].set_ylabel("Weight [nS]")
axes[-1].set_xlabel("Epoch")
plt.show()

Any insight or workaround would be appreciated.

Hi @paulbrodersen,
great question (and an example reproducing something from Zenke (2015) would be a nice addition to the documentation :blush: ). The reason for the error is that the linked flag is only allowed in NeuronGroup equations, it cannot be used in Synapses. This is because when we establish a link with linked_var, we establish a mapping of indices between the two groups – this does not work for synapses in the general case, since their number is not fixed. However, this is an issue for non-trivial mappings, and we could actually allow linked variables in Synapses for linking to groups of size 1 (as in your example), where there’s no ambiguity about the mapping.

Until we add this feature, there’s a workaround you can use: the linked variable mechanism does several checks, verifies that the mapping is feasible and so on, and then establishes an internal reference to the variable from the other group. You can skip the linked variable mechanism and directly use the internal mechanism. For that, you have to remove G : Hz (linked) from the synaptic equations, and replace the synapses.G = ... assignment by:

synapses.variables.add_reference("G", group=global_factor, index='0')

(the index='0' is a special case for scalar/shared variables, that are always index as [0]).

Hope that works for you!

Hope that works for you!

It does. As always: thank you for the fast and informative response!

(and an example reproducing something from Zenke (2015) would be a nice addition to the documentation :blush: ).

Happy to contribute whatever I can. Would a sanitized version of the code above suffice (as a proof-of-principle), or would you rather see it being used in anger as a homeostatic mechanism in a simulation with large neuronal populations (which invariably will incur additional complexity).

Hi @paulbrodersen – either would be nice, actually. Something like your example above would be a nice addition to the synapses section of the documentation, demonstrating how one could set up such a mechanism (similar in spirit to Example: spike_based_homeostasis — Brian 2 2.8.0.2 documentation). Reproducing one of the figures from Zenke (2015) would be great for the frompapers section, but is maybe a bit too much work.

Made a PR with a slightly sanitized version of the script above.

Necroing this one a bit, but wanted to flag a follow-up gotcha for anyone landing here with the same pattern of having an homeostatic/global factor linked into a Synapses object, as OP.

The add_reference(..., index="0")/linked_var() trick from @mstimberg above does get the link wired up fine. Reading/writing the linked value from within synapse equations works perfectly.
But if you try to read that linked variable directly from Python through the Synapses object itself (synapses.H, or anything going through get_states()), it blows up as soon as you have more than 1 synapse with IndexError: index 1 is out of bounds for axis 0 with size 1

Minimal example:

import brian2 as b2
import numpy as np

gf = b2.NeuronGroup(1, "dH/dt = -H/tau_hom : Hz\ntau_hom : second (constant)", method="exact")
gf.tau_hom = 0.5*b2.second

src = b2.PoissonGroup(3, rates=10*b2.Hz)
dst = b2.PoissonGroup(3, rates=10*b2.Hz)
syn = b2.Synapses(src, dst, model="w:1\nH : Hz (shared, linked)")
syn.connect(i=[0,1,2], j=[0,1,2])
syn.variables.add_reference("H", gf, "H", index="0")
# OR:
# syn.H = b2.linked_var(gf, "H")
syn.H[:]  # IndexError

Dug into it a bit, with the help of Claude, and indeed Group/Indexing (used by plain NeuronGroup) has a fast path (if index_var == "0": return 0) that makes index="0" links resolve to a scalar correctly. But Synapses uses a different indexing class (SynapticIndexing) for resolving slices/arrays, and it completely ignores index_var there. For this reason, by default with an empty slice, it always builds one index per synapse (arange(N_synapses)) and explodes past position 0.

Subexpressions that use the linked var (like G in the OP’s code) don’t hit this because they go through a different, compiled path that checks .scalar at the point of use, not through per-synapse index resolution.

For now, my workaround, is to avoid reading/writign the linked variable through the Synapses object at all, and go through the real owning group directly.

Do you reckon i should open an issue about this behaviour of SynapticIndexing?

Hi @carlo.castoldi, thanks for bringing this up. It has been a while, so I’m not 100% sure, but I think we’ve since fixed the underlying restriction for linked variables (see Extend linked variables functionality - Pull Request #1584 - brian-team/brian2 - GitHub) so this workaround shouldn’t be necessary anymore – you can directly use (linked) in the synaptic equations and then use linked_var to link it to the global factor (which has to be a scalar/“shared” variable) in the NeuronGroup. Any Brian version ≥ 2.9 should support this (Release notes — Brian 2 2.10.1 documentation).

If this works for you, I’d say it is not worth investigating the issue with the previous workaround further. If it doesn’t please let us know!

mmh that PR improved a lot the interface, however I am afraid that the bug still stands, when using linked_var() in the above minimal example. I think that at its root, it’s just a matter of how SynapticIndexing works in this very unique sort-of-shared-variable scenario.

gf.H above can’t be a proper (shared) variable because it must be a differential equation. In that case, from my very own understanding, linked_var() (or add_reference(index="0")) is guessing that H, being a lenght-1 source, is meant to be a broadcast/one-shared-value. Hence SynapticIndexing will have index_var == "0". However, SynapticIndexing doesn’t handle this case and it ends up with a IndexError.

I have also tested a quick fix by adding the missing case and that appears to have fixed the error for me.
May I try to work on my first PR with it?

EDIT: i ended up opening over here Fix SynapticIndexing when index_var=="0" - Pull Request #1870 - brian-team/brian2 - GitHub

Ok thanks for the follow-up, you are probably right that my previous PR only took “real” shared variables, not those from a group of size 1, into account. I will have a look at your PR very soon!