From 3bb40481d42e36115e6fa86a2f5ade156020f7d7 Mon Sep 17 00:00:00 2001 From: Aabir Abubaker Kar <16526730+bakerwho@users.noreply.github.com> Date: Mon, 30 Dec 2019 21:24:02 -0500 Subject: [PATCH] Adding triadic analysis functions (#3742) * empty functions for triadic analysis * add triad functionality * fixes to triads functions * pep8 fixes * add functions to __all__ * rename to fix mismatch * delete unused variable * delete unused variable * minor fixes * fix Travis failure and add explanation of triads with citation * fix with defaultdict * added is_triads, error handling for non-triads, and tests * remove unnecessary is_triad() checks and rearrange imports --- .gitignore | 2 +- networkx/algorithms/tests/test_triads.py | 108 +++++++++- networkx/algorithms/triads.py | 258 ++++++++++++++++++++++- 3 files changed, 363 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 8a63b3b1468..868f8c1880c 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,4 @@ networkx.egg-info/ .idea # VS Code settings -.vscode \ No newline at end of file +.vscode diff --git a/networkx/algorithms/tests/test_triads.py b/networkx/algorithms/tests/test_triads.py index 12616128e45..c9f43fd81ff 100644 --- a/networkx/algorithms/tests/test_triads.py +++ b/networkx/algorithms/tests/test_triads.py @@ -1,10 +1,12 @@ """Unit tests for the :mod:`networkx.algorithms.triads` module.""" import networkx as nx +from collections import defaultdict +from random import sample def test_triadic_census(): - """Tests the triadic census function.""" + """Tests the triadic_census function.""" G = nx.DiGraph() G.add_edges_from(['01', '02', '03', '04', '05', '12', '16', '51', '56', '65']) @@ -13,3 +15,107 @@ def test_triadic_census(): '111D': 1, '300': 0, '120D': 0, '021C': 2} actual = nx.triadic_census(G) assert expected == actual + + +def test_is_triad(): + """Tests the is_triad function""" + G = nx.karate_club_graph() + G = G.to_directed() + for i in range(100): + nodes = sample(G.nodes(), 3) + G2 = G.subgraph(nodes) + assert nx.is_triad(G2) + + +def test_all_triplets(): + """Tests the all_triplets function.""" + G = nx.DiGraph() + G.add_edges_from(['01', '02', '03', '04', '05', '12', '16', '51', '56', + '65']) + expected = [f"{i},{j},{k}" for i in range(7) for j in range(i + 1, 7) + for k in range(j + 1, 7)] + expected = [set(x.split(',')) for x in expected] + actual = list(set(x) for x in nx.all_triplets(G)) + assert all([any([s1 == s2 for s1 in expected]) for s2 in actual]) + + +def test_all_triads(): + """Tests the all_triplets function.""" + G = nx.DiGraph() + G.add_edges_from(['01', '02', '03', '04', '05', '12', '16', '51', '56', + '65']) + expected = [f"{i},{j},{k}" for i in range(7) for j in range(i + 1, 7) + for k in range(j + 1, 7)] + expected = [G.subgraph(x.split(',')) for x in expected] + actual = list(nx.all_triads(G)) + assert all(any([nx.is_isomorphic(G1, G2) for G1 in expected]) + for G2 in actual) + + +def test_triad_type(): + """Tests the triad_type function.""" + # 0 edges (1 type) + G = nx.DiGraph({0: [], 1: [], 2: []}) + assert nx.triad_type(G) == '003' + # 1 edge (1 type) + G = nx.DiGraph({0: [1], 1: [], 2: []}) + assert nx.triad_type(G) == '012' + # 2 edges (4 types) + G = nx.DiGraph([(0, 1), (0, 2)]) + assert nx.triad_type(G) == '021D' + G = nx.DiGraph({0: [1], 1: [0], 2: []}) + assert nx.triad_type(G) == '102' + G = nx.DiGraph([(0, 1), (2, 1)]) + assert nx.triad_type(G) == '021U' + G = nx.DiGraph([(0, 1), (1, 2)]) + assert nx.triad_type(G) == '021C' + # 3 edges (4 types) + G = nx.DiGraph([(0, 1), (1, 0), (2, 1)]) + assert nx.triad_type(G) == '111D' + G = nx.DiGraph([(0, 1), (1, 0), (1, 2)]) + assert nx.triad_type(G) == '111U' + G = nx.DiGraph([(0, 1), (1, 2), (0, 2)]) + assert nx.triad_type(G) == '030T' + G = nx.DiGraph([(0, 1), (1, 2), (2, 0)]) + assert nx.triad_type(G) == '030C' + # 4 edges (4 types) + G = nx.DiGraph([(0, 1), (1, 0), (2, 0), (0, 2)]) + assert nx.triad_type(G) == '201' + G = nx.DiGraph([(0, 1), (1, 0), (2, 0), (2, 1)]) + assert nx.triad_type(G) == '120D' + G = nx.DiGraph([(0, 1), (1, 0), (0, 2), (1, 2)]) + assert nx.triad_type(G) == '120U' + G = nx.DiGraph([(0, 1), (1, 0), (0, 2,), (2, 1)]) + assert nx.triad_type(G) == '120C' + # 5 edges (1 type) + G = nx.DiGraph([(0, 1), (1, 0), (2, 1), (1, 2), (0, 2)]) + assert nx.triad_type(G) == '210' + # 6 edges (1 type) + G = nx.DiGraph([(0, 1), (1, 0), (1, 2), (2, 1), (0, 2), (2, 0)]) + assert nx.triad_type(G) == '300' + + +def test_triads_by_type(): + """Tests the all_triplets function.""" + G = nx.DiGraph() + G.add_edges_from(['01', '02', '03', '04', '05', '12', '16', '51', '56', + '65']) + all_triads = nx.all_triads(G) + expected = defaultdict(list) + for triad in all_triads: + name = nx.triad_type(triad) + expected[name].append(triad) + actual = nx.triads_by_type(G) + assert set(actual.keys()) == set(expected.keys()) + for tri_type, actual_Gs in actual.items(): + expected_Gs = expected[tri_type] + for a in actual_Gs: + assert any(nx.is_isomorphic(a, e) for e in expected_Gs) + + +def test_random_triad(): + """Tests the random_triad function""" + G = nx.karate_club_graph() + G = G.to_directed() + for i in range(100): + assert nx.is_triad(nx.random_triad(G)) diff --git a/networkx/algorithms/triads.py b/networkx/algorithms/triads.py index 7581699ce34..01e08847ba9 100644 --- a/networkx/algorithms/triads.py +++ b/networkx/algorithms/triads.py @@ -4,9 +4,15 @@ # Copyright 2011 Diederik van Liere """Functions for analyzing triads of a graph.""" +from itertools import combinations, permutations +from collections import defaultdict +from random import sample + +import networkx as nx from networkx.utils import not_implemented_for -__all__ = ['triadic_census'] +__all__ = ['triadic_census', 'is_triad', 'all_triplets', 'all_triads', + 'triads_by_type', 'triad_type', 'random_triad'] #: The integer codes representing each type of triad. #: @@ -54,7 +60,7 @@ def triadic_census(G): Returns ------- census : dict - Dictionary with triad names as keys and number of occurrences as values. + Dictionary with triad type as keys and number of occurrences as values. Notes ----- @@ -96,10 +102,256 @@ def triadic_census(G): v not in G.succ[w]): code = _tricode(G, v, u, w) census[TRICODE_TO_NAME[code]] += 1 - # 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()) return census + + +def is_triad(G): + """Returns True if the graph G is a triad, else False. + + Parameters + ---------- + G : graph + A NetworkX Graph + + Returns + ------- + istriad : boolean + Whether G is a valid triad + """ + if isinstance(G, nx.Graph): + if G.order() == 3 and nx.is_directed(G): + if not any((n, n) in G.edges() for n in G.nodes()): + return True + return False + + +@not_implemented_for('undirected') +def all_triplets(G): + """Returns a generator of all possible sets of 3 nodes in a DiGraph. + + Parameters + ---------- + G : digraph + A NetworkX DiGraph + + Returns + ------- + triplets : generator of 3-tuples + Generator of tuples of 3 nodes + """ + triplets = combinations(G.nodes(), 3) + return triplets + + +@not_implemented_for('undirected') +def all_triads(G): + """A generator of all possible triads in G. + + Parameters + ---------- + G : digraph + A NetworkX DiGraph + + Returns + ------- + all_triads : generator of DiGraphs + Generator of triads (order-3 DiGraphs) + """ + triplets = combinations(G.nodes(), 3) + for triplet in triplets: + yield G.subgraph(triplet).copy() + + +@not_implemented_for('undirected') +def triads_by_type(G): + """Returns a list of all triads for each triad type in a directed graph. + + Parameters + ---------- + G : digraph + A NetworkX DiGraph + + Returns + ------- + tri_by_type : dict + Dictionary with triad types as keys and lists of triads as values. + """ + # num_triads = o * (o - 1) * (o - 2) // 6 + # if num_triads > TRIAD_LIMIT: print(WARNING) + all_tri = all_triads(G) + tri_by_type = defaultdict(list) + for triad in all_tri: + name = triad_type(triad) + tri_by_type[name].append(triad) + return tri_by_type + + +@not_implemented_for('undirected') +def triad_type(G): + """Returns the sociological triad type for a triad. + + Parameters + ---------- + G : digraph + A NetworkX DiGraph with 3 nodes + + Returns + ------- + triad_type : str + A string identifying the triad type + + Notes + ----- + There can be 6 unique edges in a triad (order-3 DiGraph) (so 2^^6=64 unique + triads given 3 nodes). These 64 triads each display exactly 1 of 16 + topologies of triads (topologies can be permuted). These topologies are + identified by the following notation: + + {m}{a}{n}{type} (for example: 111D, 210, 102) + + Here: + + {m} = number of mutual ties (takes 0, 1, 2, 3); a mutual tie is (0,1) + AND (1,0) + {a} = number of assymmetric ties (takes 0, 1, 2, 3); an assymmetric tie + is (0,1) BUT NOT (1,0) or vice versa + {n} = number of null ties (takes 0, 1, 2, 3); a null tie is NEITHER + (0,1) NOR (1,0) + {type} = a letter (takes U, D, C, T) corresponding to up, down, cyclical + and transitive. This is only used for topologies that can have + more than one form (eg: 021D and 021U). + + References + ---------- + .. [1] Snijders, T. (2012). "Transitivity and triads." University of + Oxford. + http://www.stats.ox.ac.uk/snijders/Trans_Triads_ha.pdf + """ + if not is_triad(G): + raise nx.NetworkXAlgorithmError("G is not a triad (order-3 DiGraph)") + num_edges = len(G.edges()) + if num_edges == 0: + return "003" + elif num_edges == 1: + return "012" + elif num_edges == 2: + e1, e2 = G.edges() + if set(e1) == set(e2): + return "102" + elif e1[0] == e2[0]: + return "021D" + elif e1[1] == e2[1]: + return "021U" + elif e1[1] == e2[0] or e2[1] == e1[0]: + return "021C" + elif num_edges == 3: + for (e1, e2, e3) in permutations(G.edges(), 3): + if set(e1) == set(e2): + if e3[0] in e1: + return "111U" + # e3[1] in e1: + return "111D" + elif set(e1).symmetric_difference(set(e2)) == set(e3): + if {e1[0], e2[0], e3[0]} == {e1[0], e2[0], + e3[0]} == set(G.nodes()): + return "030C" + # e3 == (e1[0], e2[1]) and e2 == (e1[1], e3[1]): + return "030T" + elif num_edges == 4: + for (e1, e2, e3, e4) in permutations(G.edges(), 4): + if set(e1) == set(e2): + # identify pair of symmetric edges (which necessarily exists) + if set(e3) == set(e4): + return "201" + if {e3[0]} == {e4[0]} == set(e3).intersection(set(e4)): + return "120D" + if {e3[1]} == {e4[1]} == set(e3).intersection(set(e4)): + return "120U" + if e3[1] == e4[0]: + return "120C" + elif num_edges == 5: + return "210" + elif num_edges == 6: + return "300" + + +@not_implemented_for('undirected') +def random_triad(G): + '''Returns a random triad from a directed graph. + + Parameters + ---------- + G : digraph + A NetworkX DiGraph + + Returns + ------- + G2 : subgraph + A randomly selected triad (order-3 NetworkX DiGraph) + ''' + nodes = sample(G.nodes(), 3) + G2 = G.subgraph(nodes) + return G2 + + +""" +@not_implemented_for('undirected') +def triadic_closures(G): + '''Returns a list of order-3 subgraphs of G that are triadic closures. + + Parameters + ---------- + G : digraph + A NetworkX DiGraph + + Returns + ------- + closures : list + List of triads of G that are triadic closures + ''' + pass + + +@not_implemented_for('undirected') +def focal_closures(G, attr_name): + '''Returns a list of order-3 subgraphs of G that are focally closed. + + Parameters + ---------- + G : digraph + A NetworkX DiGraph + attr_name : str + An attribute name + + + Returns + ------- + closures : list + List of triads of G that are focally closed on attr_name + ''' + pass + + +@not_implemented_for('undirected') +def balanced_triads(G, crit_func): + '''Returns a list of order-3 subgraphs of G that are stable. + + Parameters + ---------- + G : digraph + A NetworkX DiGraph + crit_func : function + A function that determines if a triad (order-3 digraph) is stable + + Returns + ------- + triads : list + List of triads in G that are stable + ''' + pass +"""