# Source code for neet.sensitivity

"""
.. currentmodule:: neet.sensitivity

.. testsetup:: sensitivity

from neet.boolean.examples import c_elegans, s_pombe
from neet.sensitivity import *

Sensitivity
===========

The :mod:neet.sensitivity module provides a collection of functions for
computing measures of sensitivity of networks, i.e. the degree to which
perturbations of the network state propogate and spread. This module also
provides a collection of functions for identifying "canalizing edges": edges
for which a state of the source node uniquely determines the state of the
target regardless of other sources.

API Documentation
-----------------
"""
from .interfaces import is_boolean_network
from .synchronous import transitions

import copy
import numpy as np
import numpy.linalg as linalg

[docs]def sensitivity(net, state, transitions=None):
"""
Calculate Boolean network sensitivity, as defined in [Shmulevich2004]_

The sensitivity of a Boolean function :math:f on state vector :math:x
is the number of Hamming neighbors of :math:x on which the function
value is different than on :math:x.

This calculates the average sensitivity over all :math:N boolean
functions, where :math:N is the size of net.

.. rubric:: Examples

.. doctest:: sensitivity

>>> sensitivity(s_pombe, [0, 0, 0, 0, 0, 1, 1, 0, 0])
1.0
>>> sensitivity(s_pombe, [0, 1, 1, 0, 1, 0, 0, 1, 0])
0.4444444444444444
>>> sensitivity(c_elegans, [0, 0, 0, 0, 0, 0, 0, 0])
1.75
>>> sensitivity(c_elegans, [1, 1, 1, 1, 1, 1, 1, 1])
1.25

:param net: :mod:neet boolean network
:param state: a single network state, represented as a list of node states
:param transitions: a list of precomputed state transitions (*optional*)
:type transitions: list or None
"""
if not is_boolean_network(net):
raise(TypeError("net must be a boolean network"))

# list Hamming neighbors
neighbors = _hamming_neighbors(state)

nextState = net.update(state)

# count sum of differences found in neighbors of the original
s = 0.
for neighbor in neighbors:
if transitions is not None:
newState = transitions[_fast_encode(neighbor)]
else:
newState = net._unsafe_update(neighbor)
s += _boolean_distance(newState, nextState)

return s / net.size

[docs]def difference_matrix(net, state, transitions=None):
"""
Returns matrix answering the question: Starting at the given state, does
flipping the state of node j change the state of node i?

.. rubric:: Examples

.. doctest:: sensitivity

>>> difference_matrix(s_pombe, [0, 0, 0, 0, 0, 0, 0, 0, 0])
array([[0., 0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 1., 1., 1., 0., 0., 0., 0.],
[0., 0., 1., 0., 0., 0., 0., 0., 1.],
[0., 0., 0., 1., 0., 0., 0., 0., 1.],
[0., 0., 0., 0., 0., 1., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 1., 0.],
[0., 0., 0., 0., 0., 0., 1., 0., 1.],
[0., 1., 0., 0., 0., 0., 0., 1., 0.],
[0., 0., 0., 0., 1., 0., 0., 0., 0.]])
>>> difference_matrix(c_elegans, [0, 0, 0, 0, 0, 0, 0, 0])
array([[1., 0., 0., 0., 0., 0., 0., 1.],
[0., 0., 1., 1., 0., 0., 0., 0.],
[0., 0., 1., 0., 1., 0., 0., 0.],
[0., 0., 1., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 1., 1., 0., 1.],
[0., 0., 0., 0., 0., 1., 1., 0.],
[1., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 1.]])

:param net: a :mod:neet boolean network
:param state: the starting state
:param transitions: a precomputed list of state transitions (*optional*)
:type transitions: list or None
"""
# set up empty matrix
N = len(state)
Q = np.empty((N, N))

# list Hamming neighbors (in order!)
neighbors = _hamming_neighbors(state)

nextState = net.update(state)

# count differences found in neighbors of the original
for j, neighbor in enumerate(neighbors):
if transitions is not None:
newState = transitions[_fast_encode(neighbor)]
else:
newState = net._unsafe_update(neighbor)
Q[:, j] = [(nextState[i] + newState[i]) % 2 for i in range(N)]

return Q

def _states_limited(nodes, state):
"""
All possible states that vary only nodes with given indices.
"""
if len(nodes) == 0:
return [state]
for i in nodes:
stateFlipped = copy.copy(state)
stateFlipped[nodes[0]] = (stateFlipped[nodes[0]] + 1) % 2

left_rec = _states_limited(nodes[1:], state)
right_rec = _states_limited(nodes[1:], stateFlipped)
return left_rec + right_rec

[docs]def average_difference_matrix(net, states=None, weights=None, calc_trans=True):
"""
Averaged over states, what is the probability
that node i's state is changed by a single bit flip of node j?

.. rubric:: Examples

.. doctest:: sensitivity

>>> average_difference_matrix(s_pombe)
array([[0.    , 0.    , 0.    , 0.    , 0.    , 0.    , 0.    , 0.    ,
0.    ],
[0.    , 0.    , 0.25  , 0.25  , 0.25  , 0.    , 0.    , 0.    ,
0.    ],
[0.25  , 0.25  , 0.25  , 0.    , 0.    , 0.25  , 0.    , 0.    ,
0.25  ],
[0.25  , 0.25  , 0.    , 0.25  , 0.    , 0.25  , 0.    , 0.    ,
0.25  ],
[0.    , 0.    , 0.    , 0.    , 0.    , 1.    , 0.    , 0.    ,
0.    ],
[0.    , 0.    , 0.0625, 0.0625, 0.0625, 0.    , 0.0625, 0.0625,
0.    ],
[0.    , 0.5   , 0.    , 0.    , 0.    , 0.    , 0.5   , 0.    ,
0.5   ],
[0.    , 0.5   , 0.    , 0.    , 0.    , 0.    , 0.    , 0.5   ,
0.5   ],
[0.    , 0.    , 0.    , 0.    , 1.    , 0.    , 0.    , 0.    ,
0.    ]])
>>> average_difference_matrix(c_elegans)
array([[0.25  , 0.25  , 0.    , 0.    , 0.    , 0.25  , 0.25  , 0.25  ],
[0.    , 0.    , 0.5   , 0.5   , 0.    , 0.    , 0.    , 0.    ],
[0.5   , 0.    , 0.5   , 0.    , 0.5   , 0.    , 0.    , 0.    ],
[0.    , 0.    , 1.    , 0.    , 0.    , 0.    , 0.    , 0.    ],
[0.    , 0.3125, 0.3125, 0.3125, 0.3125, 0.3125, 0.    , 0.3125],
[0.5   , 0.    , 0.    , 0.    , 0.    , 0.5   , 0.5   , 0.    ],
[1.    , 0.    , 0.    , 0.    , 0.    , 0.    , 0.    , 0.    ],
[0.    , 0.    , 0.    , 0.    , 0.    , 0.    , 0.5   , 0.5   ]])

:param states: If None, average over all possible states. Otherwise,
providing a list of states will calculate the average over
only those states.
:type states: list or None
:param calc_trans: Optionally pre-calculate all transitions. Only used
when states or weights argument is not None.
:type calc_trans: bool
:return: boolean numpy array
"""
N = net.size
Q = np.zeros((N, N))

if (states is not None) or (weights is not None):
# explicitly calculate difference matrix for each state

# optionally pre-calculate transitions
if calc_trans:
trans = list(transitions(net))
else:
trans = None

# currently changes state generators to lists.
# is there a way to avoid this?
if states is None:
states = list(net.state_space())
else:
states = list(states)

if weights is None:
weights = np.ones(len(states))
else:
weights = list(weights)

if np.shape(weights) != (len(states),):
msg = "Weights must be a 1D array with length same as states"
raise(ValueError(msg))

norm = np.sum(weights)
for i, state in enumerate(states):
Q += weights[i] * difference_matrix(net, state, trans) / norm

else:  # make use of sparse connectivity to be more efficient
state0 = np.zeros(N, dtype=int)

for i in range(N):
nodesInfluencingI = list(net.neighbors_in(i))
for jindex, j in enumerate(nodesInfluencingI):

# for each state of other nodes, does j matter?
otherNodes = list(copy.copy(nodesInfluencingI))
otherNodes.pop(jindex)
otherNodeStates = _states_limited(otherNodes, state0)
for state in otherNodeStates:
# might be able to do faster by calculating transitions
# once for each i also we only need the update for node i

# start with two states, one with j on and one with j off
jOff = copy.copy(state)
jOff[j] = 0
jOffNext = net._unsafe_update(jOff)[i]
jOn = copy.copy(state)
jOn[j] = 1
jOnNext = net._unsafe_update(jOn)[i]
# are the results different?
Q[i, j] += (jOffNext + jOnNext) % 2
Q[i, j] /= float(len(otherNodeStates))

return Q

[docs]def is_canalizing(net, node_i, neighbor_j):
"""
Determine whether a given network edge is canalizing: if node_i's
value at :math:t+1 is fully determined when neighbor_j's value has
a particular value at :math:t, regardless of the values of other nodes,
then there is a canalizing edge from neighbor_j to node_i.

According to (Stauffer 1987), "A rule ... is called forcing, or
canalizing, if at least one of its :math:K arguments has the property
that the result of the function is already fixed if this argument has
one particular value, regardless of the values for the :math:K-1 other
arguments."  Note that this is a definition for whether a node's rule is
canalizing, whereas this function calculates whether a specific edge is
canalizing.  Under this definition, if a node has any incoming canalizing
edges, then its rule is canalizing.

.. rubric:: Examples

.. doctest:: sensitivity

>>> is_canalizing(s_pombe, 1, 2)
True
>>> is_canalizing(s_pombe, 2, 1)
False
>>> is_canalizing(c_elegans, 7, 7)
True
>>> is_canalizing(c_elegans, 1, 3)
True
>>> is_canalizing(c_elegans, 4, 3)
False

:param net: a :mod:neet boolean network
:param node_i: target node index
:param neighbor_j: source node index
:return: True if the edge (neighbor_j, node_i) is canalizing, or
None if the edge does not exist

.. seealso:: :func:canalizing_edges
.. seealso:: :func:canalizing_nodes
"""
nodesInfluencingI = list(net.neighbors_in(node_i))

if (neighbor_j not in nodesInfluencingI) or (node_i not in range(net.size)):
# can't be canalizing if j has no influence on i
return None  # or False?
else:
jindex = nodesInfluencingI.index(neighbor_j)

# for every state of other nodes, does j determine i?
otherNodes = list(copy.copy(nodesInfluencingI))
otherNodes.pop(jindex)
otherNodeStates = _states_limited(
otherNodes, np.zeros(net.size, dtype=int))

jOnForced, jOffForced = True, True
jOnForcedValue, jOffForcedValue = None, None
stateindex = 0
while (jOnForced or jOffForced) and stateindex < len(otherNodeStates):

state = otherNodeStates[stateindex]

# first hold j off
if jOffForced:
jOff = copy.copy(state)
jOff[neighbor_j] = 0
jOffNext = net.update(jOff)[node_i]
if jOffForcedValue is None:
jOffForcedValue = jOffNext
elif jOffForcedValue != jOffNext:
# then holding j off does not force i
jOffForced = False

# now hold j on
if jOnForced:
jOn = copy.copy(state)
jOn[neighbor_j] = 1
jOnNext = net.update(jOn)[node_i]
if jOnForcedValue is None:
jOnForcedValue = jOnNext
elif jOnForcedValue != jOnNext:
# then holding j on does not force i
jOnForced = False

stateindex += 1

# if we have checked all states, then the edge must be forcing
# print "jOnForced,jOffForced",jOnForced,jOffForced
return jOnForced or jOffForced

[docs]def canalizing_edges(net):
"""
Return a set of tuples corresponding to the edges in the network that
are canalizing. Each tuple consists of two node indices, corresponding
to an edge from the second node to the first node (so that the second node
controls the first node in a canalizing manner).

.. rubric:: Examples

.. doctest:: sensitivity

>>> canalizing_edges(s_pombe)
{(1, 2), (5, 4), (0, 0), (1, 3), (4, 5), (5, 6), (5, 7), (1, 4), (8, 4), (5, 2), (5, 3)}
>>> canalizing_edges(c_elegans)
{(1, 2), (3, 2), (1, 3), (7, 6), (6, 0), (7, 7)}

:param net: a :mod:neet boolean network
:return: the set of canalizing edges as in the form (target, source)

.. seealso:: :func:is_canalizing
.. seealso:: :func:canalizing_nodes
"""
canalizingList = []
for indexi in range(net.size):
for neighborj in net.neighbors_in(indexi):
if is_canalizing(net, indexi, neighborj):
canalizingList.append((indexi, neighborj))
return set(canalizingList)

[docs]def canalizing_nodes(net):
"""
Find the nodes of the network which have at least one incoming canalizing
edge.

.. rubric:: Examples

.. doctest:: sensitivity

>>> canalizing_nodes(s_pombe)
{0, 1, 4, 5, 8}
>>> canalizing_nodes(c_elegans)
{1, 3, 6, 7}

:param net: a :mod:neet boolean network
:return: the set indices of nodes with at least one canalizing input edge

.. seealso:: :func:is_canalizing
.. seealso:: :func:canalizing_edges
"""
nodes = [e[0] for e in canalizing_edges(net)]
return set(np.unique(nodes))

[docs]def lambdaQ(net, **kwargs):
"""
Calculate sensitivity eigenvalue, the largest eigenvalue of the
sensitivity matrix :func:average_difference_matrix.

This is analogous to the eigenvalue calculated in [Pomerance2009]_.

.. rubric:: Examples

.. doctest:: sensitivity

>>> lambdaQ(s_pombe)
0.8265021276831896
>>> lambdaQ(c_elegans)
1.263099227661824

:param net: a :mod:neet boolean network
:return: the sensitivity eigenvalue (:math:\lambda_Q) of net
"""
Q = average_difference_matrix(net, **kwargs)
return max(abs(linalg.eigvals(Q)))

def _fast_encode(state):
"""
Quickly find encoding of a binary state.

Same result as net.state_space().encode(state).
"""
out = 0
for bit in state[::-1]:
out = (out << 1) | bit
return out

def _boolean_distance(state1, state2):
"""
Boolean distance between two states.
"""
out = 0
for i in range(len(state1)):
out += state1[i] ^ state2[i]
return out

def _hamming_neighbors(state):
"""
Return Hamming neighbors of a boolean state.

.. rubric:: Examples

.. doctest:: sensitivity

>>> _hamming_neighbors([0,0,1])
array([[1, 0, 1],
[0, 1, 1],
[0, 0, 0]])
"""
state = np.asarray(state, dtype=int)
if len(state.shape) > 1:
raise(ValueError("state must be 1-dimensional"))
if not np.array_equal(state % 2, state):
raise(ValueError("state must be binary"))

repeat = np.tile(state, (len(state), 1))
neighbors = (repeat + np.diag(np.ones_like(state))) % 2

return neighbors

[docs]def average_sensitivity(net, states=None, weights=None, calc_trans=True):
"""
Calculate average Boolean network sensitivity, as defined in
[Shmulevich2004]_.

The sensitivity of a Boolean function :math:f on state vector :math:x
is the number of Hamming neighbors of :math:x on which the function
value is different than on :math:x.

The average sensitivity is an average taken over initial states.

.. rubric:: Examples

.. doctest:: sensitivity

>>> average_sensitivity(c_elegans)
1.265625
>>> average_sensitivity(c_elegans, states=[[0, 0, 0, 0, 0, 0, 0, 0],
... [1, 1, 1, 1, 1, 1, 1, 1]])
...
1.5
>>> average_sensitivity(c_elegans, states=[[0, 0, 0, 0, 0, 0, 0, 0],
... [1, 1, 1, 1, 1, 1, 1, 1]],weights=[0.9, 0.1])
...
1.7
>>> average_sensitivity(c_elegans, states=[[0, 0, 0, 0, 0, 0, 0, 0],
... [1, 1, 1, 1, 1, 1, 1, 1]], weights=[9, 1])
...
1.7

:param net: NEET boolean network
:param states: Optional list or generator of states. If None, all states
are used.
:param weights: Optional list or generator of weights for each state.
If None, each state is equally weighted. If states and
weights are both None, an algorithm is used to efficiently
make use of sparse connectivity.
:return: the average sensitivity of net
"""

if not is_boolean_network(net):
raise(TypeError("net must be a boolean network"))

Q = average_difference_matrix(net, states=states, weights=weights,
calc_trans=calc_trans)

return np.sum(Q) / net.size