Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions graphblas/io/_networkx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <U dtype rather than
# object), and sequence-valued attributes of uniform length, which infer
# a 2-D numeric array that from_coo would otherwise accept as a UDT.
try:
return _from_networkx_via_scipy(G, nodelist, dtype, weight, name)
except (nx.NetworkXError, TypeError) as err:
# Since scipy 1.15 an unsupported dtype is rejected while the coo array
# is built, and that ValueError reaches us unchanged. Older scipy builds
# the array and fails in the coo -> 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)

Expand Down
72 changes: 72 additions & 0 deletions graphblas/tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down