Repository: networkx/networkx

Issue:
parameter nodelist for triadic_census
This is the code example:
```python
import networkx as nx

G = nx.DiGraph([("b", "a"), ("b","c")])
print(nx.triadic_census(G))
print(nx.triadic_census(G,nodelist=["c"]))
```
This is the result:
```
{'003': 0, '012': 0, '102': 0, '021D': 1, '021U': 0, '021C': 0, '111D': 0, '111U': 0, '030T': 0, '030C': 0, '201': 0, '120D': 0, '120U': 0, '120C': 0, '210': 0, '300': 0}
{'003': 0, '012': 0, '102': 0, '021D': 0, '021U': 0, '021C': 0, '111D': 0, '111U': 0, '030T': 0, '030C': 0, '201': 0, '120D': 0, '120U': 0, '120C': 0, '210': 0, '300': 0}
```
Is it wrong with this parameter nodelist setting?


Discussion:
the example is correct. For each type of triad, it shows how many triads are of that type. With only 1 node in the nodelist there are no triads. So there are no triads of any of the types. 
@dschult 
```python
G = nx.DiGraph([("b", "a"), ("b","c")])
print(nx.triadic_census(G))
print(nx.triadic_census(G,nodelist=["b"]))
```
I got the same result as the parameter nodelist
```text
{'003': 0, '012': 0, '102': 0, '021D': 1, '021U': 0, '021C': 0, '111D': 0, '111U': 0, '030T': 0, '030C': 0, '201': 0, '120D': 0, '120U': 0, '120C': 0, '210': 0, '300': 0}
{'003': 0, '012': 0, '102': 0, '021D': 1, '021U': 0, '021C': 0, '111D': 0, '111U': 0, '030T': 0, '030C': 0, '201': 0, '120D': 0, '120U': 0, '120C': 0, '210': 0, '300': 0}
```
So, sir. how can I get the triads type and quantity of a node?
Just by calling `nx.triadic_census(G))` it will return the triad type and number of occurrences. If you want the type of triad and all the triad graphs please use `nx.triads_by_type(G)`.


@MridulS 
Mr., I just want to get the quantity of the triads and the triads group belonging to a single node.
use ```nx.triads_by_type(G)``` . Because it's a bigger graph and takes a long time
~A single node in the nodelist contains no triads -- there is only one node!!
Perhaps you want to include only the node and its neighbors:
```nx.triad_census(G, nodelist=['b']+list(G['b']))```~

Sorry -- I was wrong.... It seems that you already *have* the triad type and quantity for e.g. node "b".
The only triad is a-b-c and it has type '021D': 1
When you specify node 'c' it has no triads so all types have value 0.
> ~A single node in the nodelist contains no triads -- there is only one node!! Perhaps you want to include only the node and its neighbors: `nx.triad_census(G, nodelist=['b']+list(G['b']))`~
> 
> Sorry -- I was wrong.... It seems that you already _have_ the triad type and quantity for e.g. node "b". The only triad is a-b-c and it has type '021D': 1 When you specify node 'c' it has no triads so all types have value 0.

@dschult Sir, does this setting are correct? I think the node "c" has a triads type "021D", because "c" belongs to a<-b->c
I see what you mean, but clearly this attempt to implement triad_census for a nodelist does not count the same way you suggest.  The PR that implemented it is #4361. It is possible that there is a bug in that change.  I haven't looked carefully, but I think that the condition `m[u] < m[v]`, which is supposed to eliminate double counting of triads, does not work correctly when only a subset of the actual nodes are looped over. 

So I am thinking this may have a bug.  Can you tell me what you expect the census to be for each node in 'a<-b->c'?
and can you tell me what you expect the census to be for each node of the more complicated 'a->b->c->d'?
If we can work through those two cases I think we should be able to check for bugs and identify the source.
@dschult 
For each node in "a<-b->c", i expect to be able to get each node to belong to “021D”,  for each node, the "021D" type quantity value is 1

For each node in "a->b->c->d": 
- node "a" get {"021C": 1}
- node "b" get {"021C": 2}
- node "c" get {"021C": 2}
- node "d" get {"021C": 1}
For the `"a->b->c->d"` case we should have 3 triads for each node -- so if we sum the individual node counts, we should get 12 counts.  I only get 4 counts total:  2 for "021D" and 2 for "021C". And there are no triads counted for node "d".

I think we made a serious error when we added the `nodelist` parameter to this code. It would work fine if we didn't have all that code to avoid considering the same triad multiple times. But it is a fundamentally different way of counting. I think it should probably be a different function than `triad_census`.  Perhaps `triad_census_by_node`.

I'm going to mark this as a defect.  Let's see if there is a good algorithm for getting the census by node...
@dschult 
Sir, If there is no parameter "nodelist", is this `triad_census` to run normal?
:-)
The `triad_census` code without a `nodelist` parameter has been around for a long time and seems to be solidly correct. (though I thought the nodelist part of it was correct, so maybe there is something subtle that we've missed). The `nodelist` feature is relatively new and I can see where the bug is coming from there, and that bug has no impact on the correctness of the calculation without `nodelist`.

So, you are safe to use the function without the `nodelist` parameter.  

Resolving patch:
diff --git a/networkx/algorithms/triads.py b/networkx/algorithms/triads.py
--- a/networkx/algorithms/triads.py
+++ b/networkx/algorithms/triads.py
@@ -154,6 +154,12 @@ def triadic_census(G, nodelist=None):
     This algorithm has complexity $O(m)$ where $m$ is the number of edges in
     the graph.
 
+    Raises
+    ------
+    ValueError
+        If `nodelist` contains duplicate nodes or nodes not in `G`.
+        If you want to ignore this you can preprocess with `set(nodelist) & G.nodes`
+
     See also
     --------
     triad_graph
@@ -166,49 +172,83 @@ def triadic_census(G, nodelist=None):
         http://vlado.fmf.uni-lj.si/pub/networks/doc/triads/triads.pdf
 
     """
+    nodeset = set(G.nbunch_iter(nodelist))
+    if nodelist is not None and len(nodelist) != len(nodeset):
+        raise ValueError("nodelist includes duplicate nodes or nodes not in G")
+
+    N = len(G)
+    Nnot = N - len(nodeset)  # can signal special counting for subset of nodes
+
+    # create an ordering of nodes with nodeset nodes first
+    m = {n: i for i, n in enumerate(nodeset)}
+    if Nnot:
+        # add non-nodeset nodes later in the ordering
+        not_nodeset = G.nodes - nodeset
+        m.update((n, i + N) for i, n in enumerate(not_nodeset))
+
+    # build all_neighbor dicts for easy counting
+    # After Python 3.8 can leave off these keys(). Speedup also using G._pred
+    # nbrs = {n: G._pred[n].keys() | G._succ[n].keys() for n in G}
+    nbrs = {n: G.pred[n].keys() | G.succ[n].keys() for n in G}
+    dbl_nbrs = {n: G.pred[n].keys() & G.succ[n].keys() for n in G}
+
+    if Nnot:
+        sgl_nbrs = {n: G.pred[n].keys() ^ G.succ[n].keys() for n in not_nodeset}
+        # find number of edges not incident to nodes in nodeset
+        sgl = sum(1 for n in not_nodeset for nbr in sgl_nbrs[n] if nbr not in nodeset)
+        sgl_edges_outside = sgl // 2
+        dbl = sum(1 for n in not_nodeset for nbr in dbl_nbrs[n] if nbr not in nodeset)
+        dbl_edges_outside = dbl // 2
+
     # Initialize the count for each triad to be zero.
     census = {name: 0 for name in TRIAD_NAMES}
-    n = len(G)
-    # m = dict(zip(G, range(n)))
-    m = {v: i for i, v in enumerate(G)}
-    if nodelist is None:
-        nodelist = list(G.nodes())
-    for v in nodelist:
-        vnbrs = set(G.pred[v]) | set(G.succ[v])
+    # Main loop over nodes
+    for v in nodeset:
+        vnbrs = nbrs[v]
+        dbl_vnbrs = dbl_nbrs[v]
+        if Nnot:
+            # set up counts of edges attached to v.
+            sgl_unbrs_bdy = sgl_unbrs_out = dbl_unbrs_bdy = dbl_unbrs_out = 0
         for u in vnbrs:
             if m[u] <= m[v]:
                 continue
-            neighbors = (vnbrs | set(G.succ[u]) | set(G.pred[u])) - {u, v}
-            # Calculate dyadic triads instead of counting them.
-            if v in G[u] and u in G[v]:
-                census["102"] += n - len(neighbors) - 2
-            else:
-                census["012"] += n - len(neighbors) - 2
+            unbrs = nbrs[u]
+            neighbors = (vnbrs | unbrs) - {u, v}
             # Count connected triads.
             for w in neighbors:
-                if m[u] < m[w] or (
-                    m[v] < m[w] < m[u] and v not in G.pred[w] and v not in G.succ[w]
-                ):
+                if m[u] < m[w] or (m[v] < m[w] < m[u] and v not in nbrs[w]):
                     code = _tricode(G, v, u, w)
                     census[TRICODE_TO_NAME[code]] += 1
 
-    if len(nodelist) != len(G):
-        census["003"] = 0
-        for v in nodelist:
-            vnbrs = set(G.pred[v]) | set(G.succ[v])
-            not_vnbrs = G.nodes() - vnbrs - set(v)
-            triad_003_count = 0
-            for u in not_vnbrs:
-                unbrs = set(set(G.succ[u]) | set(G.pred[u])) - vnbrs
-                triad_003_count += len(not_vnbrs - unbrs) - 1
-            triad_003_count //= 2
-            census["003"] += triad_003_count
-    else:
-        # null triads = total number of possible triads - all found triads
-        #
-        # Use integer division here, since we know this formula guarantees an
-        # integral value.
-        census["003"] = ((n * (n - 1) * (n - 2)) // 6) - sum(census.values())
+            # Use a formula for dyadic triads with edge incident to v
+            if u in dbl_vnbrs:
+                census["102"] += N - len(neighbors) - 2
+            else:
+                census["012"] += N - len(neighbors) - 2
+
+            # Count edges attached to v. Subtract later to get triads with v isolated
+            # _out are (u,unbr) for unbrs outside boundary of nodeset
+            # _bdy are (u,unbr) for unbrs on boundary of nodeset (get double counted)
+            if Nnot and u not in nodeset:
+                sgl_unbrs = sgl_nbrs[u]
+                sgl_unbrs_bdy += len(sgl_unbrs & vnbrs - nodeset)
+                sgl_unbrs_out += len(sgl_unbrs - vnbrs - nodeset)
+                dbl_unbrs = dbl_nbrs[u]
+                dbl_unbrs_bdy += len(dbl_unbrs & vnbrs - nodeset)
+                dbl_unbrs_out += len(dbl_unbrs - vnbrs - nodeset)
+        # if nodeset == G.nodes, skip this b/c we will find the edge later.
+        if Nnot:
+            # Count edges outside nodeset not connected with v (v isolated triads)
+            census["012"] += sgl_edges_outside - (sgl_unbrs_out + sgl_unbrs_bdy // 2)
+            census["102"] += dbl_edges_outside - (dbl_unbrs_out + dbl_unbrs_bdy // 2)
+
+    # calculate null triads: "003"
+    # null triads = total number of possible triads - all found triads
+    total_triangles = (N * (N - 1) * (N - 2)) // 6
+    triangles_without_nodeset = (Nnot * (Nnot - 1) * (Nnot - 2)) // 6
+    total_census = total_triangles - triangles_without_nodeset
+    census["003"] = total_census - sum(census.values())
+
     return census
 
 


Test patch:
diff --git a/networkx/algorithms/tests/test_triads.py b/networkx/algorithms/tests/test_triads.py
--- a/networkx/algorithms/tests/test_triads.py
+++ b/networkx/algorithms/tests/test_triads.py
@@ -1,8 +1,10 @@
-"""Unit tests for the :mod:`networkx.algorithms.triads` module."""
+"""Tests for the :mod:`networkx.algorithms.triads` module."""
 
-import networkx as nx
+import pytest
 from collections import defaultdict
+import itertools
 from random import sample
+import networkx as nx
 
 
 def test_triadic_census():
@@ -139,6 +141,72 @@ def test_random_triad():
         assert nx.is_triad(nx.random_triad(G))
 
 
+def test_triadic_census_short_path_nodelist():
+    G = nx.path_graph("abc", create_using=nx.DiGraph)
+    expected = {"021C": 1}
+    for nl in ["a", "b", "c", "ab", "ac", "bc", "abc"]:
+        triad_census = nx.triadic_census(G, nodelist=nl)
+        assert expected == {typ: cnt for typ, cnt in triad_census.items() if cnt > 0}
+
+
+def test_triadic_census_correct_nodelist_values():
+    G = nx.path_graph(5, create_using=nx.DiGraph)
+    msg = r"nodelist includes duplicate nodes or nodes not in G"
+    with pytest.raises(ValueError, match=msg):
+        nx.triadic_census(G, [1, 2, 2, 3])
+    with pytest.raises(ValueError, match=msg):
+        nx.triadic_census(G, [1, 2, "a", 3])
+
+
+def test_triadic_census_tiny_graphs():
+    tc = nx.triadic_census(nx.empty_graph(0, create_using=nx.DiGraph))
+    assert {} == {typ: cnt for typ, cnt in tc.items() if cnt > 0}
+    tc = nx.triadic_census(nx.empty_graph(1, create_using=nx.DiGraph))
+    assert {} == {typ: cnt for typ, cnt in tc.items() if cnt > 0}
+    tc = nx.triadic_census(nx.empty_graph(2, create_using=nx.DiGraph))
+    assert {} == {typ: cnt for typ, cnt in tc.items() if cnt > 0}
+    tc = nx.triadic_census(nx.DiGraph([(1, 2)]))
+    assert {} == {typ: cnt for typ, cnt in tc.items() if cnt > 0}
+
+
+def test_triadic_census_selfloops():
+    GG = nx.path_graph("abc", create_using=nx.DiGraph)
+    expected = {"021C": 1}
+    for n in GG:
+        G = GG.copy()
+        G.add_edge(n, n)
+        tc = nx.triadic_census(G)
+        assert expected == {typ: cnt for typ, cnt in tc.items() if cnt > 0}
+
+    GG = nx.path_graph("abcde", create_using=nx.DiGraph)
+    tbt = nx.triads_by_type(GG)
+    for n in GG:
+        GG.add_edge(n, n)
+    tc = nx.triadic_census(GG)
+    assert tc == {tt: len(tbt[tt]) for tt in tc}
+
+
+def test_triadic_census_four_path():
+    G = nx.path_graph("abcd", create_using=nx.DiGraph)
+    expected = {"012": 2, "021C": 2}
+    triad_census = nx.triadic_census(G)
+    assert expected == {typ: cnt for typ, cnt in triad_census.items() if cnt > 0}
+
+
+def test_triadic_census_four_path_nodelist():
+    G = nx.path_graph("abcd", create_using=nx.DiGraph)
+    expected_end = {"012": 2, "021C": 1}
+    expected_mid = {"012": 1, "021C": 2}
+    a_triad_census = nx.triadic_census(G, nodelist=["a"])
+    assert expected_end == {typ: cnt for typ, cnt in a_triad_census.items() if cnt > 0}
+    b_triad_census = nx.triadic_census(G, nodelist=["b"])
+    assert expected_mid == {typ: cnt for typ, cnt in b_triad_census.items() if cnt > 0}
+    c_triad_census = nx.triadic_census(G, nodelist=["c"])
+    assert expected_mid == {typ: cnt for typ, cnt in c_triad_census.items() if cnt > 0}
+    d_triad_census = nx.triadic_census(G, nodelist=["d"])
+    assert expected_end == {typ: cnt for typ, cnt in d_triad_census.items() if cnt > 0}
+
+
 def test_triadic_census_nodelist():
     """Tests the triadic_census function."""
     G = nx.DiGraph()
@@ -166,6 +234,37 @@ def test_triadic_census_nodelist():
         node_triad_census = nx.triadic_census(G, nodelist=[node])
         for triad_key in expected:
             actual[triad_key] += node_triad_census[triad_key]
-    # Divide the total count of 003 triads by 3, since we are counting them thrice
-    actual["003"] //= 3
+    # Divide all counts by 3
+    for k, v in actual.items():
+        actual[k] //= 3
     assert expected == actual
+
+
+@pytest.mark.parametrize("N", [5, 10])
+def test_triandic_census_on_random_graph(N):
+    G = nx.binomial_graph(N, 0.3, directed=True, seed=42)
+    tc1 = nx.triadic_census(G)
+    tbt = nx.triads_by_type(G)
+    tc2 = {tt: len(tbt[tt]) for tt in tc1}
+    assert tc1 == tc2
+
+    for n in G:
+        tc1 = nx.triadic_census(G, nodelist={n})
+        tc2 = {tt: sum(1 for t in tbt.get(tt, []) if n in t) for tt in tc1}
+        assert tc1 == tc2
+
+    for ns in itertools.combinations(G, 2):
+        ns = set(ns)
+        tc1 = nx.triadic_census(G, nodelist=ns)
+        tc2 = {
+            tt: sum(1 for t in tbt.get(tt, []) if any(n in ns for n in t)) for tt in tc1
+        }
+        assert tc1 == tc2
+
+    for ns in itertools.combinations(G, 3):
+        ns = set(ns)
+        tc1 = nx.triadic_census(G, nodelist=ns)
+        tc2 = {
+            tt: sum(1 for t in tbt.get(tt, []) if any(n in ns for n in t)) for tt in tc1
+        }
+        assert tc1 == tc2