Can't modify synaptic group attribute using run_args in standalone mode

The synaptic group attribute seems not being able to be modified when using run_args in standalone mode., although I don’t see the same behavior for neuron group. The following code demonstrate this issue. Is this a feature or can be bypassed?

from brian2 import *

set_device('cpp_standalone', build_on_run=False)

eqs = '''
dv/dt = (I-v)/tau : 1
I : 1
tau : second
'''
G = NeuronGroup(3, eqs, threshold='v>1', reset='v = 0', method='exact')
G.I = [2, 0, 0]
G.tau = [10, 100, 100]*ms

S = Synapses(G, G, 'w : 1', on_pre='v_post += w')
S.connect(i=0, j=[1, 2])
S.w = 'j*0.2'
S.delay = 'j*2*ms'
S.pre.delay = '3*ms'

M = StateMonitor(G, 'v', record=True)

run(10*ms)  # will not call device.build/device.run

device.build(run=False)  # Compile the code

# Do 10 runs without recompiling, each time initializing v differently
for idx in range(10):
    device.run(run_args={G.v: np.arange(3)*0.01 + 0.1*idx})

    print('1----: ', S.w[:])
    S.w[:] = 0
    print('2----: ', S.w[:])

I manually set S.w[:] = 0 but the value of the array remain unchanged.

   1----:  [0.2 0.4]
   2----:  [0.2 0.4]

Looks like one need to do the assignment in the run_args. I updated the code this way as follows and it does the changes, there are errors like:

    Error reading 'static_arrays/init_synapses_w_d41d8cd98f00b204e9800998ecf8427e.dat': file size 0 does not match expected size 16

The following is the update code:

 from brian2 import *

 set_device('cpp_standalone', build_on_run=False)

 eqs = '''
 dv/dt = (I-v)/tau : 1
 I : 1
 tau : second
 '''
 G = NeuronGroup(3, eqs, threshold='v>1', reset='v = 0', method='exact')
 G.I = [2, 0, 0]
 G.tau = [10, 100, 100]*ms

 S = Synapses(G, G, 'w : 1', on_pre='v_post += w')
 S.connect(i=0, j=[1, 2])
 S.w = 'j*0.2'
 S.delay = 'j*2*ms'
 S.pre.delay = '3*ms'

 M = StateMonitor(G, 'v', record=True)

 run(10*ms)  # will not call device.build/device.run

 device.build(run=False)  # Compile the code

 w = np.full(S.w.shape, 0.01)
 for idx in range(5):
     print('0--- idx: ', idx)
     arguments={G.v: np.arange(3)*0.01 + 0.1*idx}
     arguments.update({
         S.w: w[:],
         })
     device.run(run_args=arguments)

     w = np.full(S.w.shape, 0.0)
     print('1----: ', S.w[:])

The output of the running is:

0--- idx:  0
Error reading 'static_arrays/init_synapses_w_d41d8cd98f00b204e9800998ecf8427e.dat': file size 0 does not match expected size 16
1----:  [0.2 0.4]
0--- idx:  1
Error reading 'static_arrays/init_synapses_w_d41d8cd98f00b204e9800998ecf8427e.dat': file size 0 does not match expected size 16
1----:  [0.2 0.4]
0--- idx:  2
1----:  [0. 0.]
0--- idx:  3
1----:  [0. 0.]
0--- idx:  4
1----:  [0. 0.]

The error shows up for idx = 0 and idx = 1. After that, the result is correct as expected.

Hi @DavidKing2020. Standalone mode can be a bit confusing here, especially when you re-run simulations with the new run_args feature. You’ll have to keep in mind that a device.build creates the complete code to run a simulation, and initializations like S.w = … are part of this code. In your original question, you set S.w[:] = 0 after the call to build, and this will not affect the next run (which instead executes the S.w = 'j*0.2' initialization). I think it would be better if we actually raised an error here to avoid this confusion. So if you want to change the value of S.w for a new run, you need indeed set it via run_args.

The reason for the error in your second code is that you refer to S.w.shape. Before you execute the code with device.run, the synapses have not yet been created, and S.w.shape is therefore still 0. You therefore try to set S.w to an empty array. It would be nice to have a better error message here, but the issue is a bit tricky: in general, we do not know how big the array you give as an argument to run_args has to be, before we execute the code that generates the synapses. Your code will run if you directly use the correct size, e.g. np.full(2, 0.01). Actually, in your specific case we could know the size of the synapses without executing the code, since your connect statement does not use code. In cases like this, you can actually use len(S) or S.N before running the simulation, and it will give you the correct value. Asking for the shape of a variable like S.w is more tricky : we do know the shape of this variable in your example, but we don’t know its value (since j*0.2 hasn’t been evaluated yet). Not sure whether it makes sense to add this functionality. On the other hand, we should probably make S.w.shape throw an error in the same way that using S.w[:].shape or len(S.w) would do here (not quite sure, why it doesn’t, actually…).

Hope that makes things a bit clearer, let me know whether that fixes your issue!

Thanks@mstimberg, I updated the code accordingly and that solved the error issue, but I have a follow-up question. The updated code is:

 from brian2 import *

 set_device('cpp_standalone', build_on_run=False)

 eqs = '''
  dv/dt = (I-v)/tau : 1
  I : 1
  tau : second
  '''

 G = NeuronGroup(3, eqs, threshold='v>1', reset='v = 0', method='exact')
 G.I = [2, 0, 0]
 G.tau = [10, 100, 100]*ms

 S = Synapses(G, G, 'w : 1', on_pre='v_post += w')
 S.connect(i=0, j=[1, 2])
 S.w = 'j*0.2'
 S.delay = 'j*2*ms'
 S.pre.delay = '3*ms'

 w = np.full(len(S), 0.01)

 M = StateMonitor(G, 'v', record=True)

 run(10*ms)  # will not call device.build/device.run
 device.build(run=False)  # Compile the code

 for idx in range(5):
     arguments={G.v: np.arange(3)*0.01 + 0.1*idx}
     arguments.update({
         S.w: w[:],
         })
     device.run(run_args=arguments)

     w = np.full(len(S), 0.0)
     print(f'idx: {idx}, S.w.shape: {S.w.shape}, S.w: {S.w[:]}')

The output of the code is:

idx: 0, S.w.shape: (0,), S.w: [0.01 0.01]
idx: 1, S.w.shape: (2,), S.w: [0. 0.]
idx: 2, S.w.shape: (2,), S.w: [0. 0.]
idx: 3, S.w.shape: (2,), S.w: [0. 0.]
idx: 4, S.w.shape: (2,), S.w: [0. 0.]

I was expecting the S.w.shape at idx=0 is (2,) but it printed (0,). However, the S.w[:] does have 2 elements, i.e. [0.01, 0.01].

Another issue encountered in my actual code is that the len(S) is not accessible before the run. In this case, the synapses object is defined in a class as “self.S_input2e[0]”, when trying to print out the len(self.S_input2e[0]), I got the following error. I haven’t been able to duplicate the issue with the simple example yet but some hint from you could be helpful.

 File <ipython-input-1-b57892e015b4>:534, in Spike_MNIST_Nlayer.run(self, input_data)
     532 def run(self, input_data):
     533     # if a model is not successfully created, don't run
 --> 534     print('0----: ', len(self.S_input2e[0]))
     535     #print('1----: ', self.S_input2e[0].N))
     536     #S_input2e_w = np.full(len(self.S_input2e[0]), 0.001)
     537     if self.model_success == False:

 File ~/local_pkgs/anaconda3/envs/brian2/lib/python3.11/site-packages/brian2/groups/group.py:690, in VariableOwner.__len__(self)
     689 def __len__(self):
 --> 690     return self.variables["N"].item()

 File ~/local_pkgs/anaconda3/envs/brian2/lib/python3.11/site-packages/brian2/core/variables.py:521, in ArrayVariable.item(self)
     519 def item(self):
     520     if self.size == 1:
 --> 521         return self.get_value().item()
     522     else:
     523         raise ValueError("can only convert an array of size 1 to a Python scalar")

 File ~/local_pkgs/anaconda3/envs/brian2/lib/python3.11/site-packages/brian2/core/variables.py:517, in ArrayVariable.get_value(self)
     516 def get_value(self):
 --> 517     return self.device.get_value(self)

 File ~/local_pkgs/anaconda3/envs/brian2/lib/python3.11/site-packages/brian2/devices/cpp_standalone/device.py:577, in CPPStandaloneDevice.get_value(self, var, access_data)
     575     var.size = len(data)
     576     return data
 --> 577 raise NotImplementedError(
     578     "Cannot retrieve the values of state "
     579     "variables in standalone code before the "
     580     "simulation has been run."
     581 )

 NotImplementedError: Cannot retrieve the values of state variables in standalone code before the simulation has been run.

Here is the code to demo the issue of len(self.S) not accessible before run.
test.py (2.4 KB)

the output error is:

      File "/mnt/c/Users/puppy/Desktop/test.py", line 78, in run
      print(len(self.S))
            ^^^^^^^^^^^
    File "/home/wxie/local_pkgs/anaconda3/envs/brian2/lib/python3.11/site-packages/brian2/groups/group.py", line 690, in __len__
      return self.variables["N"].item()
             ^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/home/wxie/local_pkgs/anaconda3/envs/brian2/lib/python3.11/site-packages/brian2/core/variables.py", line 521, in item
      return self.get_value().item()
             ^^^^^^^^^^^^^^^^
    File "/home/wxie/local_pkgs/anaconda3/envs/brian2/lib/python3.11/site-packages/brian2/core/variables.py", line 517, in get_value
      return self.device.get_value(self)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/home/wxie/local_pkgs/anaconda3/envs/brian2/lib/python3.11/site-packages/brian2/devices/cpp_standalone/device.py", line 577, in get_value
      raise NotImplementedError(
    NotImplementedError: Cannot retrieve the values of state variables in standalone code before the simulation has been run.

I need to look into this in more detail, but I think it is a bug. You can use S.w[:].shape instead.

Right, the difference here is that the simple example uses arrays of indices to connect:

In your test.py example, you are using self.S.connect(), which is internally implemented as self.S.connect("True"), i.e. needs code to run. We could potentially add this as yet another special case for pre-calculating the number of synapses. Until then, the easiest solution is to pre-calculate the number of connections yourself, i.e. instead of using len(S), you’d use len(self.input)*len(self.neurons). With small/simple connectivity like in your test.py, you could also use self.S.connect(i=np.arange(N), j=0), which again will make len(S) work.

Thanks @mstimberg. I have a follow up question on the similar issue. Attached please find a different example
example.py (3.7 KB)

In this case, I know the size of the S_input2e[0] is 7840 and assign a “np.full((1,7840), 0)” to the weight of the S_input2e[0]. The following error show up:

  Error reading 'static_arrays/init_s_input2e0_w_71f3cf874d5491f8320be8f53eebfcb9.dat': file size -1 does not match expected size 62720

Further hints would be appreciated.

Hi @DavidKing2020,
ugh, this is a silly issue, I don’t quite know how this crept in… For some reason, the run_args code assumes that the internal name of the object is lower case, so the fact that you name it as

brian2.Synapses(self.input_neurons, self.exci_neurons[ih], self.eqs_syn, on_pre=self.STDP_pre_action,
                on_post=self.STDP_post_action, name='S_input2e'+str(ih))

triggers the issue (your error message shows that Brian tries to find a file named init_s_input2e0, but it is actually named init_S_input2e0). As a simple workaround, you can rename your objects to use lower case, e.g. with name='s_input2e'+str(ih).

1 Like

Thanks @mstimberg . This solved the problem :+1: :+1:

1 Like

For reference, the issue has been fixed in Brian’s development version via this PR: Do not convert run_args values to lower case by mstimberg · Pull Request #1533 · brian-team/brian2 · GitHub

1 Like

Hi @mstimberg . Here is another issue related to run_args with random numbers. The example code is attached here
test0.py (3.7 KB)

In the code, the self.S_input2e is connect with a probability = 0.1. When looping over the samples through run_args, I was expecting the len(self.S_input2e) to be a fixed value but it’s actually changing as shown in the following output

 len(self.S_input2e[0].w):  789
 len(self.S_input2e[0].w):  826
 len(self.S_input2e[0].w):  789
 len(self.S_input2e[0].w):  791
 len(self.S_input2e[0].w):  812
 len(self.S_input2e[0].w):  795
 len(self.S_input2e[0].w):  789
 len(self.S_input2e[0].w):  811
 len(self.S_input2e[0].w):  846
 len(self.S_input2e[0].w):  766

It seems that S_input2e.connect(p=0.1) is called for every call of run_args. Is it possible to fix the length once the brian2.device.build is done? Thanks

Hi @DavidKing2020. This is the correct/expected behavior – one reason to run a simulation multiple times could be to have different random connections each time. There are two ways to get fixed connectivity:

  1. You can create the connectivity yourself (with numpy.random, etc.), and pass an array of connections to Synapses.connect
  2. You use the seed function to fix the random seed.

For 2, you can have multiple seed calls, including some that have no arguments (which means to be random across runs). This allows you to have some parts of your simulation deterministic across runs (e.g. the synaptic connectivity), while others are varying (e.g. random inputs or noise currents). Here’s a simple example demonstrating the idea: connections will be deterministic, but the initial values of v are random:

from brian2 import *
set_device('cpp_standalone', build_on_run=False)
group = NeuronGroup(10, 'v : 1')
synapses = Synapses(group, group, '')
seed(1234)
synapses.connect(p=0.5)
seed()
group.v = 'rand()'
device.build()

for _ in range(10):
  device.run()
  print(len(synapses), group.v[:])

This gives

43 [0.43284816 0.33844977 0.58711362 0.6734541  0.9442762  0.98781517 0.27758241 0.50595582 0.73861382 0.1344551 ]
43 [0.60013557 0.47044358 0.99766325 0.25872126 0.56732693 0.61091626 0.86496502 0.2648502  0.1687029  0.00178931]

Thanks @mstimberg . This will solve my problem. By the way, it might be convenient for users to have an option on run_args to switch ON/OFF this random behavior. A related question, I have the following definition of the synaptic connection:

prob = 'exp(-((x_pre-x_post)**2 + (y_pre-y_post)**2)/(2*(sigma)**2))'
self.S_input2e.connect(p = prob)

Is there a eqivalent command using np.random?

If you get the variables of the pre-/post-synaptic variables in the correct shape for numpy’s broadcoasting, then the code looks fairly similar to the Brian connection string:

x_pre = G.x[:][:, None]
y_pre = G.y[:][:, None]
x_post = G.x[:][None, :]
y_post = G.y[:][None, :]
i, j = G.i[:][:, None], G.i[:][None, :]
do_connect = (i != j) & (np.random.rand(len(G), len(G)) < 1.5 * np.exp(-((x_pre-x_post)**2 + (y_pre-y_post)**2)/(2*(60*umeter)**2)))
sources, targets = np.nonzero(do_connect)
S_deterministic.connect(i=sources, j=targets)

The above is adapted code from Example: spatial_connections — Brian 2 2.6.0 documentation, full example below:

Full Example code
from brian2 import *
set_device('cpp_standalone', build_on_run=False)

rows, cols = 20, 20
G = NeuronGroup(rows * cols, '''x : meter
                                y : meter''')
# initialize the grid positions
grid_dist = 25*umeter
G.x = (G.i[:] // rows) * grid_dist - rows/2.0 * grid_dist
G.y = (G.i[:] % rows) * grid_dist - cols/2.0 * grid_dist

# Random connections (will stay the same for every run)
S_deterministic = Synapses(G, G)
# Make views of the variables of the  pre- and post-synaptic neurons so that they can be
# broadcasted against each other
x_pre = G.x[:][:, None]
y_pre = G.y[:][:, None]
x_post = G.x[:][None, :]
y_post = G.y[:][None, :]
i, j = G.i[:][:, None], G.i[:][None, :]
do_connect = (i != j) & (np.random.rand(len(G), len(G)) < 1.5 * np.exp(-((x_pre-x_post)**2 + (y_pre-y_post)**2)/(2*(60*umeter)**2)))
sources, targets = np.nonzero(do_connect)
S_deterministic.connect(i=sources, j=targets)

# Random connections (will change with every run)
S_stochastic = Synapses(G, G)
S_stochastic.connect('i != j',
                     p='1.5 * exp(-((x_pre-x_post)**2 + (y_pre-y_post)**2)/(2*(60*umeter)**2))')

device.build()

for _ in range(3):
     device.run()
     figure(figsize=(12, 6))

     # Show the connections for some neurons in different colors
     for neuron_idx, color in zip([3, 167, 309], ['g', 'b', 'm']):
          subplot(1, 2, 1)
          plot(G.x[neuron_idx] / umeter, G.y[neuron_idx] / umeter, 'o', mec=color,
               mfc='none')
          plot(G.x[S_deterministic.j[neuron_idx, :]] / umeter,
               G.y[S_deterministic.j[neuron_idx, :]] / umeter, color + '.')
          subplot(1, 2, 2)
          plot(G.x[neuron_idx] / umeter, G.y[neuron_idx] / umeter, 'o', mec=color,
               mfc='none')
          plot(G.x[S_stochastic.j[neuron_idx, :]] / umeter,
               G.y[S_stochastic.j[neuron_idx, :]] / umeter, color + '.')

     for idx, t in enumerate(['determininstic connections',
                              'random connections']):
          subplot(1, 2, idx + 1)
          xlim((-rows/2.0 * grid_dist) / umeter, (rows/2.0 * grid_dist) / umeter)
          ylim((-cols/2.0 * grid_dist) / umeter, (cols/2.0 * grid_dist) / umeter)
          title(t)
     xlabel('x')
     ylabel('y', rotation='horizontal')
     axis('equal')

     tight_layout()
show()
1 Like