From 535acfa349ec52be738083b7604369ea77ecc8e6 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 12:38:50 -0500 Subject: [PATCH 1/4] Add a direct (scipy-free) ingestion path to from_networkx from_networkx always routed nx -> scipy CSR -> Matrix, so every conversion paid for scipy.sparse (importing it alone costs over 100ms) plus an extra coo -> csr materialization. Simple graphs with numeric weights now build COO arrays straight from the nx adjacency and call Matrix.from_coo. The node selection preamble mirrors nx.to_scipy_sparse_array so ordering, subsetting, and error behavior match exactly; undirected graphs are symmetrized with the same diagonal correction nx uses (self-loop entries appear as wt + wt - wt under dup_op=plus, which is exact in IEEE arithmetic). Kept on the scipy fallback automatically: multigraphs (scipy's duplicate-coordinate summation matches exactly) and weights that do not form a 1-D numeric array. That second condition covers non-numeric attributes, including all-string weights, which infer a csr + # materialization and the scipy.sparse import, which alone costs over 100ms. + import numpy as np + + from ..binary import plus + from ..core.matrix import Matrix + + if len(G) == 0: + raise nx.NetworkXError("Graph has no nodes or edges") + + if nodelist is None: + nodelist = list(G) + nlen = len(G) + else: + nlen = len(nodelist) + if nlen == 0: + raise nx.NetworkXError("nodelist has no nodes") + nodeset = set(G.nbunch_iter(nodelist)) + if nlen != len(nodeset): + for n in nodelist: + if n not in G: + raise nx.NetworkXError(f"Node {n} in nodelist is not in G") + raise nx.NetworkXError("nodelist contains duplicates.") + if nlen < len(G): + G = G.subgraph(nodelist) + + index = dict(zip(nodelist, range(nlen), strict=True)) + coefficients = zip( + *((index[u], index[v], wt) for u, v, wt in G.edges(data=weight, default=1)), + strict=True, + ) + try: + row, col, data = coefficients + except ValueError: + # there is no edge in the (sub)graph + row, col, data = (), (), () + + if G.is_directed(): + rows, cols, vals = row, col, data + dup_op = None + else: + # Symmetrize: mirror off-diagonal entries. Self-loops would be double + # counted, so subtract the diagonal contribution once, matching + # nx.to_scipy_sparse_array. dup_op=plus then sums the diagonal triple + # (wt + wt - wt) back to wt; off-diagonal entries are unique so plus + # leaves them untouched. + d = data + data + r = row + col + c = col + row + selfloops = list(nx.selfloop_edges(G, data=weight, default=1)) + if selfloops: + diag_index, diag_data = zip(*((index[u], -wt) for u, v, wt in selfloops), strict=True) + d += diag_data + r += diag_index + c += diag_index + rows, cols, vals = r, c, d + dup_op = plus + + values = np.array(vals, dtype=dtype) + if values.ndim != 1 or values.dtype.kind not in "biufc": + # Defer to scipy so the error matches the previous behavior exactly. + # Two kinds of weight land here: non-numeric attributes (object arrays, + # but also e.g. all-string weights, which infer a default 1 + G.add_node(9) # isolated node + + A = nx.to_scipy_sparse_array(G, weight="weight") + reference = gb.io.from_scipy_sparse(A) + M = gb.io.from_networkx(G, weight="weight") + assert M.isequal(reference, check_dtype=True) + assert M.shape == reference.shape + + @pytest.mark.skipif("not ss") @pytest.mark.parametrize("engine", ["auto", "scipy", "fmm"]) def test_mmread_mmwrite(engine): From ffc629ec96bb1b1678b6de742c9645b6be44122f Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 12:22:44 -0500 Subject: [PATCH 2/4] Ingest multigraphs directly in from_networkx (drop the scipy detour) A networkx multigraph fell back to scipy in from_networkx because the direct path could not sum the weights of parallel edges. The directed branch now passes dup_op=plus when the graph is a multigraph, so parallel edges accumulate exactly as scipy's coo -> csr summation does. Simple graphs keep dup_op=None and are unchanged, and the undirected branch already summed because it uses plus for the self-loop diagonal correction. Numeric multigraphs no longer need scipy at all; non-numeric weights still defer to it. Parity with the retained scipy path holds for MultiGraph and MultiDiGraph with parallel edges, parallel self-loops, reciprocal parallel edges, parallel edges whose weights cancel to zero (both sides keep the explicit zero), absent weight attributes (nx defaults to 1), weight=None, bool and int weights, and nodelist permutations and subsets. New tests pin the multigraph diagonal sum and assert the numeric path never reaches the scipy fallback. --- graphblas/io/_networkx.py | 20 +++++++++----------- graphblas/tests/test_io.py | 25 ++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index 1a2b06566..9fd45a867 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -28,14 +28,6 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): if dtype is not None: dtype = lookup_dtype(dtype).np_type - # Multigraphs sum the weights of parallel edges. scipy does this by - # accumulating duplicate coordinates when a coo array is converted to csr; - # replicating it here would need the same self-loop bookkeeping as the - # undirected path plus a general dup_op. Defer to scipy so the summation - # matches exactly. - if G.is_multigraph(): - return _from_networkx_via_scipy(G, nodelist, dtype, weight, name) - # The node selection below mirrors nx.to_scipy_sparse_array so that empty # graphs, nodelist subsets, missing nodes, and duplicate nodes raise the # same errors and produce the same ordering. Building the coo arrays here @@ -78,13 +70,19 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): if G.is_directed(): rows, cols, vals = row, col, data - dup_op = None + # A multigraph can have parallel edges (duplicate ``(u, v)``); summing + # them with ``plus`` matches scipy's coo -> csr accumulation. A simple + # graph has no duplicate coordinates, so ``dup_op=None`` is exact and + # skips the accumulator. + dup_op = plus if G.is_multigraph() else None else: # Symmetrize: mirror off-diagonal entries. Self-loops would be double # counted, so subtract the diagonal contribution once, matching # nx.to_scipy_sparse_array. dup_op=plus then sums the diagonal triple - # (wt + wt - wt) back to wt; off-diagonal entries are unique so plus - # leaves them untouched. + # (wt + wt - wt) back to wt. For a multigraph, plus also sums parallel + # edges (duplicate coordinates) the same way scipy's coo -> csr does; + # for a simple graph off-diagonal entries are unique so plus is a no-op + # there. d = data + data r = row + col c = col + row diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 051404ebc..d2e57cebe 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -197,12 +197,14 @@ def test_from_networkx_rejects_array_weights(graph_cls): "graph_cls", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph] if nx else [] ) def test_from_networkx_matches_scipy(graph_cls): - # The direct path (simple graphs) and the scipy fallback (multigraphs) must - # both reproduce the scipy round-trip exactly, including parallel-edge sums. + # Both simple graphs and multigraphs now build the coo directly (no scipy + # round-trip); the result must reproduce nx.to_scipy_sparse_array exactly, + # including parallel-edge and parallel-self-loop weight sums. G = graph_cls() G.add_weighted_edges_from([(0, 1, 2.0), (1, 2, 3.0), (2, 0, 4.0), (0, 0, 5.0)]) if G.is_multigraph(): - G.add_edge(0, 1, weight=1.5) # parallel edge summed by scipy + G.add_edge(0, 1, weight=1.5) # parallel edge -> weights summed + G.add_edge(0, 0, weight=2.5) # parallel self-loop -> diagonal summed G.add_edge(3, 4) # missing weight attr -> default 1 G.add_node(9) # isolated node @@ -213,6 +215,23 @@ def test_from_networkx_matches_scipy(graph_cls): assert M.shape == reference.shape +@pytest.mark.skipif("not nx") +def test_from_networkx_multigraph_is_scipy_free(monkeypatch): + # A numeric-weight multigraph must ingest via the direct coo path, not the + # scipy fallback, so networkx ingest no longer requires scipy for this case. + import graphblas.io._networkx as _gnx + + def _boom(*args, **kwargs): # pragma: no cover (only runs if the direct path regresses) + raise AssertionError("scipy fallback should not be used for a numeric multigraph") + + monkeypatch.setattr(_gnx, "_from_networkx_via_scipy", _boom) + G = nx.MultiDiGraph() + G.add_weighted_edges_from([(0, 1, 2.0), (0, 1, 3.0), (2, 0, 1.0), (1, 1, 4.0), (1, 1, 0.5)]) + M = gb.io.from_networkx(G) + assert M[0, 1].new().value == 5.0 # parallel edges summed + assert M[1, 1].new().value == 4.5 # parallel self-loops summed + + @pytest.mark.skipif("not ss") @pytest.mark.parametrize("engine", ["auto", "scipy", "fmm"]) def test_mmread_mmwrite(engine): From 40e9b9cd68d2961d53060e8876c878e12ff903eb Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:28 -0700 Subject: [PATCH 3/4] Widen the inferred int32 to int64 in from_networkx --- graphblas/io/_networkx.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index 9fd45a867..fe97f4fa6 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -96,6 +96,12 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): dup_op = plus values = np.array(vals, dtype=dtype) + if dtype is None and values.dtype == np.int32: # pragma: no cover (win64 numpy < 2) + # numpy < 2 infers the platform C long for a sequence of Python ints, which + # is 32-bit on Windows. values_to_numpy_buffer widens the same way for + # non-numpy input, so this keeps from_networkx agreeing with + # Matrix.from_coo on INT64 for an unweighted graph on every platform. + values = values.astype(np.int64) if values.ndim != 1 or values.dtype.kind not in "biufc": # Defer to scipy so the error matches the previous behavior exactly. # Two kinds of weight land here: non-numeric attributes (object arrays, From 29c495747a044a750ae6a0a710bd27983f876169 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:28 -0700 Subject: [PATCH 4/4] Raise ValueError for unsupported edge-weight dtypes across scipy versions --- graphblas/io/_networkx.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index fe97f4fa6..c4a31e6db 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -108,7 +108,23 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): # but also e.g. all-string weights, which infer a csr conversion instead: scipy raises + # TypeError ("no supported conversion for types"), which networkx 3.4+ + # wraps in a NetworkXError that blames the sparse format while + # networkx <= 3.3 lets the TypeError propagate. + # Restate it so every supported stack reports the same error for the + # same graph. The graph and nodelist checks above are the ones + # nx.to_scipy_sparse_array makes, so a NetworkXError or TypeError from + # the fallback can only be that dtype complaint. + raise ValueError( + f"scipy.sparse does not support dtype {values.dtype}; " + "edge weights must be numeric scalars" + ) from err if values.size == 0: # An empty graph has no data to infer a dtype from; scipy defaults an # empty coo array to float64, so match that when dtype is unset.