Repository: networkx/networkx

Issue:
Trophic levels (feature suggestion)
Would it be interesting to include an algorithm to calculate trophic levels?

Related notions which could be included are trophic difference and trophic coherence - see wikipedia [1] and [2]. 

This may be a duplicate of #577 from 2012, which was closed due to inactivity.

I've made an initial attempt over at [3], adding a module at `algorithms.centrality.trophic`.

1. https://en.wikipedia.org/wiki/Trophic_coherence
2. Stephen Levine (1980) Several measures of trophic structure applicable to complex food webs. J. theor. Biol. 83, 2, 195-207. https://doi.org/10.1016/0022-5193(80)90288-X
3. https://github.com/tomalrussell/networkx/commit/e715def4abe0a184c0934aa7d6a886b6d5ffe705


Discussion:

Resolving patch:
diff --git a/networkx/algorithms/centrality/__init__.py b/networkx/algorithms/centrality/__init__.py
--- a/networkx/algorithms/centrality/__init__.py
+++ b/networkx/algorithms/centrality/__init__.py
@@ -15,4 +15,5 @@
 from .reaching import *
 from .percolation import *
 from .second_order import *
+from .trophic import *
 from .voterank_alg import *
diff --git a/networkx/algorithms/centrality/trophic.py b/networkx/algorithms/centrality/trophic.py
new file mode 100644
--- /dev/null
+++ b/networkx/algorithms/centrality/trophic.py
@@ -0,0 +1,169 @@
+"""Trophic levels"""
+import networkx as nx
+
+from networkx.utils import not_implemented_for
+
+__all__ = ['trophic_levels', 'trophic_differences',
+           'trophic_incoherence_parameter']
+
+
+@not_implemented_for('undirected')
+def trophic_levels(G, weight='weight'):
+    r"""Compute the trophic levels of nodes.
+
+    The trophic level of a node $i$ is
+
+    .. math::
+
+        s_i = 1 + \frac{1}{k_^{in}_i \sum_{j} a_{ij} s_j
+
+    where $k_^{in}_i$ is the in-degree of i
+
+    .. math::
+
+        k^{in}_i = \sum_{j} a_{ij}
+
+    and nodes with $k_^{in}_i = 0$ have $s_i = 1$ by convention.
+
+    These are calculated using the method outlined in Levine [1]_.
+
+    Parameters
+    ----------
+    G : DiGraph
+        A directed networkx graph
+
+    Returns
+    -------
+    nodes : dict
+        Dictionary of nodes with trophic level as the vale.
+
+    References
+    ----------
+    .. [1] Stephen Levine (1980) J. theor. Biol. 83, 195-207
+    """
+    try:
+        import numpy as np
+    except ImportError:
+        raise ImportError(
+            "trophic_levels() requires NumPy: http://scipy.org/")
+
+    # find adjacency matrix
+    a = nx.adjacency_matrix(G, weight=weight).T.toarray()
+
+    # drop rows/columns where in-degree is zero
+    rowsum = np.sum(a, axis=1)
+    p = a[rowsum != 0][:, rowsum != 0]
+    # normalise so sum of in-degree weights is 1 along each row
+    p = p / rowsum[rowsum != 0][:, np.newaxis]
+
+    # calculate trophic levels
+    nn = p.shape[0]
+    i = np.eye(nn)
+    try:
+        n = np.linalg.inv(i - p)
+    except np.linalg.LinAlgError as err:
+        # LinAlgError is raised when there is a non-basal node
+        msg = "Trophic levels are only defined for graphs where every " + \
+              "node has a path from a basal node (basal nodes are nodes " + \
+              "with no incoming edges)."
+        raise nx.NetworkXError(msg) from err
+    y = n.sum(axis=1) + 1
+
+    levels = {}
+
+    # all nodes with in-degree zero have trophic level == 1
+    zero_node_ids = (node_id for node_id, degree in G.in_degree if degree == 0)
+    for node_id in zero_node_ids:
+        levels[node_id] = 1
+
+    # all other nodes have levels as calculated
+    nonzero_node_ids = (node_id for node_id, degree in G.in_degree
+                        if degree != 0)
+    for i, node_id in enumerate(nonzero_node_ids):
+        levels[node_id] = y[i]
+
+    return levels
+
+
+@not_implemented_for('undirected')
+def trophic_differences(G, weight='weight'):
+    r"""Compute the trophic differences of the edges of a directed graph.
+
+    The trophic difference $x_ij$ for each edge is defined in Johnson et al.
+    [1]_ as:
+
+    .. math::
+        x_ij = s_j - s_i
+
+    Where $s_i$ is the trophic level of node $i$.
+
+    Parameters
+    ----------
+    G : DiGraph
+        A directed networkx graph
+
+    Returns
+    -------
+    diffs : dict
+        Dictionary of edges with trophic differences as the value.
+
+    References
+    ----------
+    .. [1] Samuel Johnson, Virginia Dominguez-Garcia, Luca Donetti, Miguel A.
+        Munoz (2014) PNAS "Trophic coherence determines food-web stability"
+    """
+    levels = trophic_levels(G, weight=weight)
+    diffs = {}
+    for u, v in G.edges:
+        diffs[(u, v)] = levels[v] - levels[u]
+    return diffs
+
+
+@not_implemented_for('undirected')
+def trophic_incoherence_parameter(G, weight='weight', cannibalism=False):
+    r"""Compute the trophic incoherence parameter of a graph.
+
+    Trophic coherence is defined as the homogeneity of the distribution of
+    trophic distances: the more similar, the more coherent. This is measured by
+    the standard deviation of the trophic differences and referred to as the
+    trophic incoherence parameter $q$ by [1].
+
+    Parameters
+    ----------
+    G : DiGraph
+        A directed networkx graph
+
+    cannibalism: Boolean
+        If set to False, self edges are not considered in the calculation
+
+    Returns
+    -------
+    trophic_incoherence_parameter : float
+        The trophic coherence of a graph
+
+    References
+    ----------
+    .. [1] Samuel Johnson, Virginia Dominguez-Garcia, Luca Donetti, Miguel A.
+        Munoz (2014) PNAS "Trophic coherence determines food-web stability"
+    """
+    try:
+        import numpy as np
+    except ImportError:
+        raise ImportError(
+            "trophic_incoherence_parameter() requires NumPy: " +
+            "http://scipy.org/")
+
+    if cannibalism:
+        diffs = trophic_differences(G, weight=weight)
+    else:
+        # If no cannibalism, remove self-edges
+        self_loops = list(nx.selfloop_edges(G))
+        if self_loops:
+            # Make a copy so we do not change G's edges in memory
+            G_2 = G.copy()
+            G_2.remove_edges_from(self_loops)
+        else:
+            # Avoid copy otherwise
+            G_2 = G
+        diffs = trophic_differences(G_2, weight=weight)
+    return np.std(list(diffs.values()))


Test patch:
diff --git a/networkx/algorithms/centrality/tests/test_trophic.py b/networkx/algorithms/centrality/tests/test_trophic.py
new file mode 100644
--- /dev/null
+++ b/networkx/algorithms/centrality/tests/test_trophic.py
@@ -0,0 +1,285 @@
+"""Test trophic levels, trophic differences and trophic coherence
+"""
+import pytest
+np = pytest.importorskip('numpy')
+
+import networkx as nx
+from networkx.testing import almost_equal
+
+
+def test_trophic_levels():
+    """Trivial example
+    """
+    G = nx.DiGraph()
+    G.add_edge("a", "b")
+    G.add_edge("b", "c")
+
+    d = nx.trophic_levels(G)
+    assert d == {"a": 1, "b": 2, "c": 3}
+
+
+def test_trophic_levels_levine():
+    """Example from Figure 5 in Stephen Levine (1980) J. theor. Biol. 83,
+    195-207
+    """
+    S = nx.DiGraph()
+    S.add_edge(1, 2, weight=1.0)
+    S.add_edge(1, 3, weight=0.2)
+    S.add_edge(1, 4, weight=0.8)
+    S.add_edge(2, 3, weight=0.2)
+    S.add_edge(2, 5, weight=0.3)
+    S.add_edge(4, 3, weight=0.6)
+    S.add_edge(4, 5, weight=0.7)
+    S.add_edge(5, 4, weight=0.2)
+
+    # save copy for later, test intermediate implementation details first
+    S2 = S.copy()
+
+    # drop nodes of in-degree zero
+    z = [nid for nid, d in S.in_degree if d == 0]
+    for nid in z:
+        S.remove_node(nid)
+
+    # find adjacency matrix
+    q = nx.linalg.graphmatrix.adjacency_matrix(S).T
+
+    expected_q = np.array([
+        [0, 0, 0., 0],
+        [0.2, 0, 0.6, 0],
+        [0, 0, 0, 0.2],
+        [0.3, 0, 0.7, 0]
+    ])
+    assert np.array_equal(q.todense(), expected_q)
+
+    # must be square, size of number of nodes
+    assert len(q.shape) == 2
+    assert q.shape[0] == q.shape[1]
+    assert q.shape[0] == len(S)
+
+    nn = q.shape[0]
+
+    i = np.eye(nn)
+    n = np.linalg.inv(i - q)
+    y = np.dot(np.asarray(n), np.ones(nn))
+
+    expected_y = np.array([1, 2.07906977, 1.46511628, 2.3255814])
+    assert np.allclose(y, expected_y)
+
+    expected_d = {
+        1: 1,
+        2: 2,
+        3: 3.07906977,
+        4: 2.46511628,
+        5: 3.3255814
+    }
+
+    d = nx.trophic_levels(S2)
+
+    for nid, level in d.items():
+        expected_level = expected_d[nid]
+        assert almost_equal(expected_level, level)
+
+
+def test_trophic_levels_simple():
+    matrix_a = np.array([[0, 0], [1, 0]])
+    G = nx.from_numpy_matrix(matrix_a, create_using=nx.DiGraph)
+    d = nx.trophic_levels(G)
+    assert almost_equal(d[0], 2)
+    assert almost_equal(d[1], 1)
+
+
+def test_trophic_levels_more_complex():
+    matrix = np.array([
+        [0, 1, 0, 0],
+        [0, 0, 1, 0],
+        [0, 0, 0, 1],
+        [0, 0, 0, 0]
+    ])
+    G = nx.from_numpy_matrix(matrix, create_using=nx.DiGraph)
+    d = nx.trophic_levels(G)
+    expected_result = [1, 2, 3, 4]
+    for ind in range(4):
+        assert almost_equal(d[ind], expected_result[ind])
+
+    matrix = np.array([
+        [0, 1, 1, 0],
+        [0, 0, 1, 1],
+        [0, 0, 0, 1],
+        [0, 0, 0, 0]
+    ])
+    G = nx.from_numpy_matrix(matrix, create_using=nx.DiGraph)
+    d = nx.trophic_levels(G)
+
+    expected_result = [1, 2, 2.5, 3.25]
+    print("Calculated result: ", d)
+    print("Expected Result: ", expected_result)
+
+    for ind in range(4):
+        assert almost_equal(d[ind], expected_result[ind])
+
+
+def test_trophic_levels_even_more_complex():
+    # Another, bigger matrix
+    matrix = np.array([
+        [0, 0, 0, 0, 0],
+        [0, 1, 0, 1, 0],
+        [1, 0, 0, 0, 0],
+        [0, 1, 0, 0, 0],
+        [0, 0, 0, 1, 0]
+    ])
+
+    # Generated this linear system using pen and paper:
+    K = np.array([
+        [1, 0, -1, 0, 0],
+        [0, 0.5, 0, -0.5, 0],
+        [0, 0, 1, 0, 0],
+        [0, -0.5, 0, 1, -0.5],
+        [0, 0, 0, 0, 1],
+    ])
+    result_1 = np.ravel(np.matmul(np.linalg.inv(K), np.ones(5)))
+    G = nx.from_numpy_matrix(matrix, create_using=nx.DiGraph)
+    result_2 = nx.trophic_levels(G)
+
+    for ind in range(5):
+        assert almost_equal(result_1[ind], result_2[ind])
+
+
+def test_trophic_levels_singular_matrix():
+    """Should raise an error with graphs with only non-basal nodes
+    """
+    matrix = np.identity(4)
+    G = nx.from_numpy_matrix(matrix, create_using=nx.DiGraph)
+    with pytest.raises(nx.NetworkXError) as e:
+        nx.trophic_levels(G)
+    msg = "Trophic levels are only defined for graphs where every node " + \
+          "has a path from a basal node (basal nodes are nodes with no " + \
+          "incoming edges)."
+    assert msg in str(e.value)
+
+
+def test_trophic_levels_singular_with_basal():
+    """Should fail to compute if there are any parts of the graph which are not
+    reachable from any basal node (with in-degree zero).
+    """
+    G = nx.DiGraph()
+    # a has in-degree zero
+    G.add_edge('a', 'b')
+
+    # b is one level above a, c and d
+    G.add_edge('c', 'b')
+    G.add_edge('d', 'b')
+
+    # c and d form a loop, neither are reachable from a
+    G.add_edge('c', 'd')
+    G.add_edge('d', 'c')
+
+    with pytest.raises(nx.NetworkXError) as e:
+        nx.trophic_levels(G)
+    msg = "Trophic levels are only defined for graphs where every node " + \
+          "has a path from a basal node (basal nodes are nodes with no " + \
+          "incoming edges)."
+    assert msg in str(e.value)
+
+    # if self-loops are allowed, smaller example:
+    G = nx.DiGraph()
+    G.add_edge('a', 'b')  # a has in-degree zero
+    G.add_edge('c', 'b')  # b is one level above a and c
+    G.add_edge('c', 'c')  # c has a self-loop
+    with pytest.raises(nx.NetworkXError) as e:
+        nx.trophic_levels(G)
+    msg = "Trophic levels are only defined for graphs where every node " + \
+          "has a path from a basal node (basal nodes are nodes with no " + \
+          "incoming edges)."
+    assert msg in str(e.value)
+
+
+def test_trophic_differences():
+    matrix_a = np.array([[0, 1], [0, 0]])
+    G = nx.from_numpy_matrix(matrix_a, create_using=nx.DiGraph)
+    diffs = nx.trophic_differences(G)
+    assert almost_equal(diffs[(0, 1)], 1)
+
+    matrix_b = np.array([
+        [0, 1, 1, 0],
+        [0, 0, 1, 1],
+        [0, 0, 0, 1],
+        [0, 0, 0, 0]
+    ])
+    G = nx.from_numpy_matrix(matrix_b, create_using=nx.DiGraph)
+    diffs = nx.trophic_differences(G)
+
+    assert almost_equal(diffs[(0, 1)], 1)
+    assert almost_equal(diffs[(0, 2)], 1.5)
+    assert almost_equal(diffs[(1, 2)], 0.5)
+    assert almost_equal(diffs[(1, 3)], 1.25)
+    assert almost_equal(diffs[(2, 3)], 0.75)
+
+
+def test_trophic_incoherence_parameter_no_cannibalism():
+    matrix_a = np.array([[0, 1], [0, 0]])
+    G = nx.from_numpy_matrix(matrix_a, create_using=nx.DiGraph)
+    q = nx.trophic_incoherence_parameter(G, cannibalism=False)
+    assert almost_equal(q, 0)
+
+    matrix_b = np.array([
+        [0, 1, 1, 0],
+        [0, 0, 1, 1],
+        [0, 0, 0, 1],
+        [0, 0, 0, 0]
+    ])
+    G = nx.from_numpy_matrix(matrix_b, create_using=nx.DiGraph)
+    q = nx.trophic_incoherence_parameter(G, cannibalism=False)
+    assert almost_equal(q, np.std([1, 1.5, 0.5, 0.75, 1.25]))
+
+    matrix_c = np.array([
+        [0, 1, 1, 0],
+        [0, 1, 1, 1],
+        [0, 0, 0, 1],
+        [0, 0, 0, 1]
+    ])
+    G = nx.from_numpy_matrix(matrix_c, create_using=nx.DiGraph)
+    q = nx.trophic_incoherence_parameter(G, cannibalism=False)
+    # Ignore the -link
+    assert almost_equal(q, np.std([1, 1.5, 0.5, 0.75, 1.25]))
+
+    # no self-loops case
+    matrix_d = np.array([
+        [0, 1, 1, 0],
+        [0, 0, 1, 1],
+        [0, 0, 0, 1],
+        [0, 0, 0, 0]
+    ])
+    G = nx.from_numpy_matrix(matrix_d, create_using=nx.DiGraph)
+    q = nx.trophic_incoherence_parameter(G, cannibalism=False)
+    # Ignore the -link
+    assert almost_equal(q, np.std([1, 1.5, 0.5, 0.75, 1.25]))
+
+
+
+def test_trophic_incoherence_parameter_cannibalism():
+    matrix_a = np.array([[0, 1], [0, 0]])
+    G = nx.from_numpy_matrix(matrix_a, create_using=nx.DiGraph)
+    q = nx.trophic_incoherence_parameter(G, cannibalism=True)
+    assert almost_equal(q, 0)
+
+    matrix_b = np.array([
+        [0, 0, 0, 0, 0],
+        [0, 1, 0, 1, 0],
+        [1, 0, 0, 0, 0],
+        [0, 1, 0, 0, 0],
+        [0, 0, 0, 1, 0]
+    ])
+    G = nx.from_numpy_matrix(matrix_b, create_using=nx.DiGraph)
+    q = nx.trophic_incoherence_parameter(G, cannibalism=True)
+    assert almost_equal(q, 2)
+
+    matrix_c = np.array([
+        [0, 1, 1, 0],
+        [0, 0, 1, 1],
+        [0, 0, 0, 1],
+        [0, 0, 0, 0]
+    ])
+    G = nx.from_numpy_matrix(matrix_c, create_using=nx.DiGraph)
+    q = nx.trophic_incoherence_parameter(G, cannibalism=True)
+    # Ignore the -link
+    assert almost_equal(q, np.std([1, 1.5, 0.5, 0.75, 1.25]))