Different approaches to computing minimum control energy

As discussed in Theory, there is more than one way to estimate the minimum control energy associated with a set of state transitions. Here, we compare our standard approach for calculating minimum control energy to an approach that leverages a shortcut based on vectorization. “why would I want to calculate control energy this way?” I hear you ask. The answer is simple: speed! As you’ll see below, the vectorization approximation provides highly convergent estimates of energy ~300 times faster than the standard approach.

The data used here are structural connectomes taken from the Philadelphia Neurodevelopmental Cohort.

Here, our Python workspace contains a single structural connectome stored in A, a numpy.array with 200 nodes along dimensions 0 and 1.

print(A.shape)
Out:
(200, 200)

Before we calculate minimum control energy, we first need to define some brain states. Here, we’ll use binary brain states, where each state comprises a set of brain regions that are designated as “on” (activity = 1) while the rest of the brain is “off” (activity = 0). Just for illustrative purposes, we’ll define these brain states arbitrarily by grouping the rows/columns of A into equally-sized non-overlapping subsets of regions.

# setup states
n_nodes = A.shape[0]
n_states = int(n_nodes/10)
state_size = int(n_nodes/n_states)

states = np.array([])
for i in np.arange(n_states):
    states = np.append(states, np.ones(state_size) * i)
states = states.astype(int)

The above code simply generates a vector of integers, stored in states, that designates which of 20 states each brain region belongs to. Owing to the fact that n_nodes equals 200 here, each state comprises 10 nodes. Note, no nodes are assigned to multiple states.

print(states)
Out:
[ 0  0  0  0  0  0  0  0  0  0  1  1  1  1  1  1  1  1  1  1  2  2  2  2
  2  2  2  2  2  2  3  3  3  3  3  3  3  3  3  3  4  4  4  4  4  4  4  4
  4  4  5  5  5  5  5  5  5  5  5  5  6  6  6  6  6  6  6  6  6  6  7  7
  7  7  7  7  7  7  7  7  8  8  8  8  8  8  8  8  8  8  9  9  9  9  9  9
  9  9  9  9 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11
 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 14 14 14 14
 14 14 14 14 14 14 15 15 15 15 15 15 15 15 15 15 16 16 16 16 16 16 16 16
 16 16 17 17 17 17 17 17 17 17 17 17 18 18 18 18 18 18 18 18 18 18 19 19
 19 19 19 19 19 19 19 19]

So the first 10 nodes of A belong to state 0, the next 10 to state 1, and so on and so forth. Using these states, we’ll first compute the minimum control energy required to transition between all possible pairs using our standard function, nctpy.energies.get_control_inputs().

from nctpy.utils import matrix_normalization
from nctpy.energies import get_control_inputs, integrate_u

# settings
# time horizon
T = 1
# set all nodes as control nodes
B = np.eye(n_nodes)
# normalize A matrix for a continuous-time system
system = 'continuous'
A_norm = matrix_normalization(A, system=system)

import time
from tqdm import tqdm
start_time = time.time()  # start timer

# settings for minimum control energy
S = np.zeros((n_nodes, n_nodes))  # x is not constrained
xr = 'zero'  # x and u constrained toward zero activity

e = np.zeros((n_states, n_states))
for i in tqdm(np.arange(n_states)):
    x0 = states == i  # get ith initial state
    for j in np.arange(n_states):
        xf = states == j  # get jth target state
        x, u, n_err = get_control_inputs(A_norm=A_norm, T=T, B=B, x0=x0, xf=xf, system=system, xr=xr, S=S,
                                         expm_version='eig')  # get control inputs using minimum control
        e[i, j, :] = np.sum(integrate_u(u))

e = e / 1000  # divide e by 1000 to account for dt=0.001 in get_control_inputs

end_time = time.time()  # stop timer
elapsed_time = end_time - start_time
print('time elapsed in seconds: {:.2f}'.format(elapsed_time))  # print elapsed time
Out:
100%|██████████| 20/20 [01:35<00:00,  4.78s/it]
time elapsed in seconds: 95.68

The standard approach took ~95 seconds to calculate the control energy associated with completing 400 (20 x 20) state transitions. Now we’ll compare that to our alternative approach, which is implemented in nctpy.energies.minimum_energy_fast().

In order to use this variant of minimum control energy, we first have to use our nctpy.utils.expand_states() function to convert states into a pair of boolean matrices, x0_mat and xf_mat, that together encode all possible pairwise state transitions.

from nctpy.utils import expand_states
x0_mat, xf_mat = expand_states(states)
print(x0_mat.shape, xf_mat.shape)
Out:
(200, 400) (200, 400)

The rows of x0_mat and xf_mat correspond to the nodes of our system and the columns correspond to the states we defined above. Critically, x0_mat and xf_mat are paired; if you take the same column across both matrices you will end up with the initial state (x0_mat[:, 0]) and the target state (xf_mat[:, 0]) that comprise a specific state transition. Note, nctpy.utils.expand_states() only works for binary brain states. If you have non-binary brain states you’ll have to create x0_mat and xf_mat on your own. Equipped with these state transition matrices, let’s compute energy again!

from nctpy.energies import minimum_energy_fast

start_time = time.time()  # start timer

e_fast = minimum_energy_fast(A_norm=A_norm, T=T, B=B, x0=x0_mat, xf=xf_mat)
e_fast = e_fast.transpose().reshape(n_states, n_states, n_nodes)
e_fast = np.sum(e_fast, axis=2)  # sum over nodes

end_time = time.time()  # stop timer
elapsed_time = end_time - start_time
print('time elapsed in seconds: {:.2f}'.format(elapsed_time))  # print elapsed time
Out:
time elapsed in seconds: 0.29

This time we managed to compute all of our transition energies in less than half a second! So our vectorization approach is fast, but is it equivalent?

print(np.max(e - e_fast))
Out:
2.7267077484793845e-11

The largest difference between energies is tiny! Great, they’re pretty much the same. Let’s also visualize the energies using a correlation plot for good measure.

import matplotlib.pyplot as plt
from nctpy.plotting import set_plotting_params, reg_plot
set_plotting_params()

# plot
f, ax = plt.subplots(1, 1, figsize=(4, 4))

# correlation between whole-brain energy across state transitions
reg_plot(x=e.flatten(), y=e_fast.flatten(), xlabel='minumum energy', ylabel='minumum energy (fast)',
         ax=ax, add_spearman=True, kdeplot=False, regplot=False)
plt.show()
../../_images/minimum_energy_fast.png

Note, there are several caveats to consider when using the above approach. First, nctpy.energies.minimum_energy_fast() only works for continuous time systems. Second, the function does not output the control inputs (u), state trajectory (x), or the numerical errors (n_err) provided by nctpy.energies.get_control_inputs(). That is, you will only get node-level energy. Finally, as mentioned above, nctpy.utils.expand_states() only works for binary brain states. This is trivial however; if you have non-binary brain states you’ll just have to create x0_mat and xf_mat on your own.