From eb4393e7291a2fe563579631ef34b14bd3f490cc Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 12 Oct 2026 03:12:35 -0500 Subject: [PATCH] Build a weight=None from_networkx from the adjacency alone A structural conversion (weight=None) of a simple graph stores 1 for every edge, yet from_networkx built it like a weighted one: a (u, v, 1) tuple per edge through G.edges, three tuples transposed out of those, an array of ones inferred from a Python sequence, and a uniformity test over it before the iso build. Read only the coordinates instead: for each node in nodelist order, its neighbors straight from the adjacency, which for an undirected graph already holds both directions and each self-loop once, so nothing is mirrored. Rows come from np.repeat over the neighbor counts and columns from one np.fromiter; Matrix.from_coo with the scalar 1 stores the result iso on SuiteSparse and broadcasts it elsewhere. The dtype is what the general path infers (INT64, or dtype= when given). An edgeless graph still takes the general path, which gives it scipy's float64; so do multigraphs, whose parallel edges sum, and a dtype= that is not bool, int, float or complex. gnm_random_graph(100000, 500000) plus one self-loop, best of 5 in one warm process, 18 SuiteSparse threads, load average about 1.2: undirected 159.5 ms -> 75.0 ms (2.1x; 199.4 ms before the self-loop fix) directed 131.8 ms -> 49.3 ms (2.7x) A nodelist covering half the nodes goes through a subgraph view and gains nothing (178 against 180 ms undirected), but loses nothing either. The new test compares weight=None against an attribute no edge carries, which takes the general path and fills every entry with the default 1, over non-integer keys, a self-loop, reciprocal edges, an isolated node, nodelist reorders and subsets, an edgeless graph and three dtypes. Over 124 conversions (every case of a parity script also with weight=None) the result, dtype and iso flag match the parent's exactly. --- graphblas/io/_networkx.py | 11 +++++++++++ graphblas/tests/test_io.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index 5ead709ed..f3cc6f4b7 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -68,6 +68,17 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): G = G.subgraph(nodelist) index = dict(zip(nodelist, range(nlen), strict=True)) + if weight is None and not G.is_multigraph() and (dtype is None or dtype.kind in "biufc"): + # Every entry is 1, so read only the coordinates: row by row in nodelist + # order, straight from the adjacency, which for an undirected graph already + # holds both directions and each self-loop once. No tuple and no value per + # edge makes this two to three times faster than the general path below. + nbrs = [G._adj[u] for u in nodelist] + rows = np.repeat(np.arange(nlen, dtype=np.uint64), [len(nbr) for nbr in nbrs]) + if rows.size: # with no edge, the general path gives the dtype (scipy's float64) + cols = np.fromiter((index[v] for nbr in nbrs for v in nbr), np.uint64, rows.size) + dtype = np.int64 if dtype is None else dtype # as the general path infers the 1s + return Matrix.from_coo(rows, cols, 1, dtype=dtype, nrows=nlen, ncols=nlen, name=name) coefficients = zip( *((index[u], index[v], wt) for u, v, wt in G.edges(data=weight, default=1)), strict=True, diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 065a5deca..7ef360521 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -262,6 +262,36 @@ def test_from_networkx_uniform_weights_are_iso(graph_cls, selfloop, weight): assert M.ss.is_iso +@pytest.mark.skipif("not nx") +@pytest.mark.parametrize("graph_cls", [nx.Graph, nx.DiGraph] if nx else []) +@pytest.mark.parametrize("dtype", [None, bool, "float32"]) +def test_from_networkx_structure_matches_the_general_path(graph_cls, dtype): + # weight=None reads only the coordinates. An attribute no edge carries takes + # the general path and gives every entry the default 1, so the two agree. + G = graph_cls() + G.add_edges_from([("a", "b"), ("b", ("t", 1)), (("t", 1), ("t", 1)), ("b", "a")]) + G.add_node(frozenset({1})) + edgeless = graph_cls() + edgeless.add_nodes_from(range(3)) + cases = [ + (G, None), + (G, list(G)[::-1]), + (G, [("t", 1), "a"]), + (G, [frozenset({1})]), + (edgeless, None), + ] + for H, nodelist in cases: + M = gb.io.from_networkx(H, nodelist=nodelist, weight=None, dtype=dtype) + expected = gb.io.from_networkx(H, nodelist=nodelist, weight="absent", dtype=dtype) + assert M.isequal(expected, check_dtype=True) + if suitesparse and M.nvals > 0: + assert M.ss.is_iso + with pytest.raises(nx.NetworkXError, match="no nodes"): + gb.io.from_networkx(graph_cls(), weight=None) + with pytest.raises(nx.NetworkXError, match="not in G"): + gb.io.from_networkx(G, nodelist=["a", "missing"], weight=None) + + @pytest.mark.skipif("not nx") @pytest.mark.parametrize("wt", [np.inf, -np.inf, 1e308]) def test_from_networkx_undirected_selfloop_keeps_its_weight(wt):