diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index 8cf84e576..c4a31e6db 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -27,6 +27,121 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): if dtype is not None: dtype = lookup_dtype(dtype).np_type + + # 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 + # (instead of via a scipy sparse round-trip) skips the extra coo -> 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 + # 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. 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 + 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 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, + # 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. + return Matrix(lookup_dtype(values.dtype), nrows=nlen, ncols=nlen, name=name) + + rows = np.array(rows, dtype=np.uint64) + cols = np.array(cols, dtype=np.uint64) + return Matrix.from_coo(rows, cols, values, nrows=nlen, ncols=nlen, dup_op=dup_op, name=name) + + +def _from_networkx_via_scipy(G, nodelist, dtype, weight, name): + """Fallback path: convert through a scipy sparse array. + + ``dtype`` is already normalized to a numpy type (or None) by the caller. + """ + import networkx as nx + A = nx.to_scipy_sparse_array(G, nodelist=nodelist, dtype=dtype, weight=weight) return from_scipy_sparse(A, name=name) diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index a8d80e9ca..d2e57cebe 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -160,6 +160,78 @@ def test_matrix_to_from_networkx(): assert M.shape == (1, 1) +@pytest.mark.skipif("not nx") +def test_from_networkx_undirected(): + # Undirected graphs go through from_networkx's direct COO path (no scipy), + # which symmetrizes off-diagonal entries and keeps self-loops single-counted. + G = nx.Graph() + G.add_weighted_edges_from([(0, 1, 2.0), (0, 0, 7.0), (1, 2, 3.0)]) + M = gb.io.from_networkx(G) + expected = gb.Matrix.from_coo([0, 0, 1, 1, 2], [0, 1, 0, 2, 1], [7.0, 2.0, 2.0, 3.0, 3.0]) + assert M.isequal(expected, check_dtype=True) + + # weight=None ignores edge weights (all entries 1) and yields an int Matrix + M_none = gb.io.from_networkx(G, weight=None) + expected_none = gb.Matrix.from_coo([0, 0, 1, 1, 2], [0, 1, 0, 2, 1], 1) + assert M_none.isequal(expected_none, check_dtype=True) + + +@pytest.mark.skipif("not nx or not ss") +@pytest.mark.parametrize( + "graph_cls", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph] if nx else [] +) +def test_from_networkx_rejects_array_weights(graph_cls): + # Sequence weights of uniform length infer a 2-D numeric array, which passes + # a dtype-kind test but which from_coo would happily read as a UDT. scipy has + # always rejected these, so the direct path must defer rather than quietly + # widen what from_networkx accepts. + G = graph_cls() + G.add_edge(0, 1, weight=[1, 2]) + G.add_edge(1, 0, weight=[3, 4]) + with pytest.raises(ValueError, match="must be 1-D"): + gb.io.from_networkx(G) + + +@pytest.mark.skipif("not nx or not ss") +@pytest.mark.parametrize( + "graph_cls", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph] if nx else [] +) +def test_from_networkx_matches_scipy(graph_cls): + # 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 -> 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 + + 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 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):