From d1726e92c6219132a47bb9e3f01e307ba442f774 Mon Sep 17 00:00:00 2001 From: Stefano Grava Date: Wed, 5 Aug 2026 08:41:14 +0200 Subject: [PATCH 1/6] Add to_matrix method to DataGraph Returns the graph as a real symmetric matrix, the inverse of from_matrix: node weights go on the diagonal (since self-loops are not supported) and edge weights fill the off-diagonal entries, defaulting to 1.0 for edges without an explicit weight. Closes #429 --- qoolqit/graphs/data_graph.py | 27 +++++++++++++++ tests/test_graphs/test_data_graph.py | 49 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/qoolqit/graphs/data_graph.py b/qoolqit/graphs/data_graph.py index cd5e122e9..2d98d335e 100644 --- a/qoolqit/graphs/data_graph.py +++ b/qoolqit/graphs/data_graph.py @@ -294,6 +294,33 @@ def from_matrix(cls, data: np.ndarray) -> DataGraph: graph.edge_weights = edge_weights return graph + def to_matrix(self) -> np.ndarray: + """Return the graph as a real symmetric square matrix. + + The inverse of `from_matrix`. Node weights are stored in the diagonal, + since self-loops are not supported. For each edge (i, j), the entries + M[i, j] and M[j, i] are set to its weight, or to 1.0 if the edge has + no weight set. Missing node weights are set to 0.0 in the diagonal. + + Nodes are ordered by sorting `self.nodes`. + """ + nodes = sorted(self.nodes) + n_nodes = len(nodes) + index = {node: i for i, node in enumerate(nodes)} + + matrix = np.zeros((n_nodes, n_nodes)) + + for node, weight in self.node_weights.items(): + if weight is not None: + matrix[index[node], index[node]] = weight + + for (i, j), weight in self.edge_weights.items(): + value = weight if weight is not None else 1.0 + matrix[index[i], index[j]] = value + matrix[index[j], index[i]] = value + + return matrix + @classmethod def from_pyg( cls, diff --git a/tests/test_graphs/test_data_graph.py b/tests/test_graphs/test_data_graph.py index 86fa9536a..8796270fd 100644 --- a/tests/test_graphs/test_data_graph.py +++ b/tests/test_graphs/test_data_graph.py @@ -119,6 +119,55 @@ def test_datagraph_from_matrix(n_nodes: int) -> None: np.testing.assert_allclose(edge_weights, data_edge_weights) +@pytest.mark.parametrize("n_nodes", [5, 10, 50]) +def test_datagraph_to_matrix_unweighted(n_nodes: int) -> None: + graph = DataGraph.random_er(n_nodes, p=0.5) + assert not graph.has_node_weights + assert not graph.has_edge_weights + + matrix = graph.to_matrix() + + np.testing.assert_equal(matrix, matrix.T) + np.testing.assert_equal(np.diag(matrix), np.zeros(n_nodes)) + + for i, j in graph.sorted_edges: + assert matrix[i, j] == 1.0 + assert matrix[j, i] == 1.0 + + non_edges = graph.all_node_pairs - graph.sorted_edges + for i, j in non_edges: + assert matrix[i, j] == 0.0 + assert matrix[j, i] == 0.0 + + +@pytest.mark.parametrize("n_nodes", [5, 10, 50]) +def test_datagraph_to_matrix_weighted(n_nodes: int) -> None: + graph = DataGraph.random_er(n_nodes, p=0.5) + graph.node_weights = {i: np.random.rand() for i in graph.nodes} + graph.edge_weights = {e: np.random.rand() for e in graph.sorted_edges} + + matrix = graph.to_matrix() + + np.testing.assert_equal(matrix, matrix.T) + np.testing.assert_allclose(np.diag(matrix), list(graph.node_weights.values())) + + for (i, j), weight in graph.edge_weights.items(): + assert matrix[i, j] == weight + assert matrix[j, i] == weight + + +@pytest.mark.parametrize("n_nodes", [5, 10, 50]) +def test_datagraph_to_matrix_roundtrip(n_nodes: int) -> None: + graph = DataGraph.random_er(n_nodes, p=0.5) + graph.node_weights = {i: np.random.rand() for i in graph.nodes} + graph.edge_weights = {e: np.random.rand() for e in graph.sorted_edges} + + matrix = graph.to_matrix() + rebuilt = DataGraph.from_matrix(matrix) + + np.testing.assert_allclose(rebuilt.to_matrix(), matrix) + + def test_triangular() -> None: graph = DataGraph.triangular(2, 2, spacing=2.71) From b1eff73348407e77df52a54776670a7c041ca2d3 Mon Sep 17 00:00:00 2001 From: Stefano Grava Date: Wed, 5 Aug 2026 09:10:52 +0200 Subject: [PATCH 2/6] Fix zero-weight truthiness and non-integer node index bugs in to_matrix weight or default treated an explicit 0.0 weight as unset, and indexing the matrix directly by node label assumed labels were exactly 0..N-1, breaking on string labels or non-contiguous integers. --- qoolqit/graphs/data_graph.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/qoolqit/graphs/data_graph.py b/qoolqit/graphs/data_graph.py index 2d98d335e..e33f6ce44 100644 --- a/qoolqit/graphs/data_graph.py +++ b/qoolqit/graphs/data_graph.py @@ -295,29 +295,26 @@ def from_matrix(cls, data: np.ndarray) -> DataGraph: return graph def to_matrix(self) -> np.ndarray: - """Return the graph as a real symmetric square matrix. - - The inverse of `from_matrix`. Node weights are stored in the diagonal, - since self-loops are not supported. For each edge (i, j), the entries - M[i, j] and M[j, i] are set to its weight, or to 1.0 if the edge has - no weight set. Missing node weights are set to 0.0 in the diagonal. - - Nodes are ordered by sorting `self.nodes`. + """Return the connectivity matrix of this graph. + + The inverse of `from_matrix`. + Nodes are mapped to indices 0,..N-1 according to `self.nodes` order. + - For each edge (i, j), the entries (i,j) and (j,i) are set to its weight, + or to 1.0 if the edge has no weight set. + - Node weights are stored in the diagonal since self-loops are not supported. + Missing node weights are set to 0.0 in the diagonal. """ - nodes = sorted(self.nodes) - n_nodes = len(nodes) - index = {node: i for i, node in enumerate(nodes)} - - matrix = np.zeros((n_nodes, n_nodes)) + n_nodes = len(self.nodes) + index = {node: i for i, node in enumerate(self.nodes)} + matrix = np.zeros((n_nodes, n_nodes), dtype=np.float64) for node, weight in self.node_weights.items(): - if weight is not None: - matrix[index[node], index[node]] = weight + i = index[node] + matrix[i, i] = weight if weight is not None else 0.0 - for (i, j), weight in self.edge_weights.items(): - value = weight if weight is not None else 1.0 - matrix[index[i], index[j]] = value - matrix[index[j], index[i]] = value + for (u, v), weight in self.edge_weights.items(): + i, j = index[u], index[v] + matrix[i, j] = matrix[j, i] = weight if weight is not None else 1.0 return matrix From f30d6d4d8a8f662680dc1a4ef827ba4b9bf91f26 Mon Sep 17 00:00:00 2001 From: Stefano Grava Date: Wed, 5 Aug 2026 10:00:04 +0200 Subject: [PATCH 3/6] Fix element-wise zeroing in to_matrix roundtrip test matrix[idx] with a (3, 2) index array fancy-indexes whole rows, not individual (row, col) entries, collapsing the n_nodes=3 case into an all-zero matrix. Index rows/cols separately instead. --- tests/test_graphs/test_data_graph.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/test_graphs/test_data_graph.py b/tests/test_graphs/test_data_graph.py index 8796270fd..53284cb4a 100644 --- a/tests/test_graphs/test_data_graph.py +++ b/tests/test_graphs/test_data_graph.py @@ -69,7 +69,7 @@ def test_datagraph_random_er(n_nodes: int) -> None: @pytest.mark.parametrize("n_nodes", [5, 10, 50]) def test_datagraph_from_matrix(n_nodes: int) -> None: - + np.random.seed(0) data = np.random.rand(n_nodes, n_nodes) with pytest.raises(ValueError): @@ -121,7 +121,7 @@ def test_datagraph_from_matrix(n_nodes: int) -> None: @pytest.mark.parametrize("n_nodes", [5, 10, 50]) def test_datagraph_to_matrix_unweighted(n_nodes: int) -> None: - graph = DataGraph.random_er(n_nodes, p=0.5) + graph = DataGraph.random_er(n_nodes, p=0.5, seed=0) assert not graph.has_node_weights assert not graph.has_edge_weights @@ -142,7 +142,7 @@ def test_datagraph_to_matrix_unweighted(n_nodes: int) -> None: @pytest.mark.parametrize("n_nodes", [5, 10, 50]) def test_datagraph_to_matrix_weighted(n_nodes: int) -> None: - graph = DataGraph.random_er(n_nodes, p=0.5) + graph = DataGraph.random_er(n_nodes, p=0.5, seed=0) graph.node_weights = {i: np.random.rand() for i in graph.nodes} graph.edge_weights = {e: np.random.rand() for e in graph.sorted_edges} @@ -156,16 +156,18 @@ def test_datagraph_to_matrix_weighted(n_nodes: int) -> None: assert matrix[j, i] == weight -@pytest.mark.parametrize("n_nodes", [5, 10, 50]) +@pytest.mark.parametrize("n_nodes", [3, 7, 21]) def test_datagraph_to_matrix_roundtrip(n_nodes: int) -> None: - graph = DataGraph.random_er(n_nodes, p=0.5) - graph.node_weights = {i: np.random.rand() for i in graph.nodes} - graph.edge_weights = {e: np.random.rand() for e in graph.sorted_edges} - - matrix = graph.to_matrix() - rebuilt = DataGraph.from_matrix(matrix) - - np.testing.assert_allclose(rebuilt.to_matrix(), matrix) + rng = np.random.default_rng(12345) + matrix = rng.normal(0, 1, size=(n_nodes, n_nodes)) + # zero out some elements for testing + rows, cols = rng.integers(n_nodes, size=3), rng.integers(n_nodes, size=3) + matrix[rows, cols] = 0.0 + matrix[cols, rows] = 0.0 + matrix += matrix.T + + graph = DataGraph.from_matrix(matrix) + np.testing.assert_allclose(graph.to_matrix(), matrix, atol=1e-8) def test_triangular() -> None: From 4dfc88de8d17c41a0c364df978babc40c1e04999 Mon Sep 17 00:00:00 2001 From: Stefano Grava Date: Wed, 5 Aug 2026 11:02:14 +0200 Subject: [PATCH 4/6] add none edge test --- tests/test_graphs/test_data_graph.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_graphs/test_data_graph.py b/tests/test_graphs/test_data_graph.py index 53284cb4a..1da3b404c 100644 --- a/tests/test_graphs/test_data_graph.py +++ b/tests/test_graphs/test_data_graph.py @@ -170,6 +170,28 @@ def test_datagraph_to_matrix_roundtrip(n_nodes: int) -> None: np.testing.assert_allclose(graph.to_matrix(), matrix, atol=1e-8) +def test_datagraph_to_matrix_custom_node_labels_and_none_weights() -> None: + # Ensure `to_matrix()` respects `self.nodes` ordering and handles None weights. + # Also ensures it works with non-0..N-1 node labels. + graph = DataGraph.from_nodes(["b", "a", "c"]) # preserve explicit order + graph.add_edges_from([("b", "a"), ("a", "c")]) + + # Mix of real weights and None + graph.node_weights = {"b": 2.0, "a": None, "c": -3.0} + # TOFIX: order is not guaranteed because DataGraph maintains arbitrarily sort edges + graph.edge_weights = {("a", "b"): None, ("a", "c"): 0.25} + + matrix = graph.to_matrix() + expected = np.array( + [ + [2.0, 1.0, 0.0], + [1.0, 0.0, 0.25], + [0.0, 0.25, -3.0], + ], + ) + np.testing.assert_equal(matrix, expected) + + def test_triangular() -> None: graph = DataGraph.triangular(2, 2, spacing=2.71) From b9249158f38b7de954a07f15a4a6d8fe0edcca7d Mon Sep 17 00:00:00 2001 From: Stefano Grava Date: Wed, 5 Aug 2026 13:55:39 +0200 Subject: [PATCH 5/6] small fixes --- qoolqit/graphs/data_graph.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/qoolqit/graphs/data_graph.py b/qoolqit/graphs/data_graph.py index e33f6ce44..ffa0167fb 100644 --- a/qoolqit/graphs/data_graph.py +++ b/qoolqit/graphs/data_graph.py @@ -10,6 +10,7 @@ import networkx as nx import numpy as np +import numpy.typing as npt from .base_graph import BaseGraph from .utils import random_coords @@ -254,7 +255,7 @@ def random_ud( return graph @classmethod - def from_matrix(cls, data: np.ndarray) -> DataGraph: + def from_matrix(cls, data: npt.NDArray[np.float64]) -> DataGraph: """Constructs a graph from a symmetric square matrix. The diagonal values are set as the node weights. For each entry (i, j) @@ -294,23 +295,27 @@ def from_matrix(cls, data: np.ndarray) -> DataGraph: graph.edge_weights = edge_weights return graph - def to_matrix(self) -> np.ndarray: + def to_matrix(self) -> npt.NDArray[np.float64]: """Return the connectivity matrix of this graph. The inverse of `from_matrix`. - Nodes are mapped to indices 0,..N-1 according to `self.nodes` order. + Nodes are mapped to indices 0, ..., N-1 according to `self.nodes` insertion order. - For each edge (i, j), the entries (i,j) and (j,i) are set to its weight, or to 1.0 if the edge has no weight set. - Node weights are stored in the diagonal since self-loops are not supported. - Missing node weights are set to 0.0 in the diagonal. + Nodes with no weight set (None) are left at 0.0 in the diagonal. + + Returns: + Symmetric N x N matrix of dtype float64, where N is the number of nodes. """ n_nodes = len(self.nodes) index = {node: i for i, node in enumerate(self.nodes)} matrix = np.zeros((n_nodes, n_nodes), dtype=np.float64) for node, weight in self.node_weights.items(): - i = index[node] - matrix[i, i] = weight if weight is not None else 0.0 + if weight is not None: + i = index[node] + matrix[i, i] = weight for (u, v), weight in self.edge_weights.items(): i, j = index[u], index[v] From b061ea0781ed6f9b9b204c094f4d2f12df79da11 Mon Sep 17 00:00:00 2001 From: Stefano Grava Date: Wed, 5 Aug 2026 17:08:26 +0200 Subject: [PATCH 6/6] adjacency --- qoolqit/graphs/data_graph.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qoolqit/graphs/data_graph.py b/qoolqit/graphs/data_graph.py index ffa0167fb..e5492e08a 100644 --- a/qoolqit/graphs/data_graph.py +++ b/qoolqit/graphs/data_graph.py @@ -296,14 +296,14 @@ def from_matrix(cls, data: npt.NDArray[np.float64]) -> DataGraph: return graph def to_matrix(self) -> npt.NDArray[np.float64]: - """Return the connectivity matrix of this graph. + """Return the adjacency matrix of this graph. The inverse of `from_matrix`. Nodes are mapped to indices 0, ..., N-1 according to `self.nodes` insertion order. - - For each edge (i, j), the entries (i,j) and (j,i) are set to its weight, - or to 1.0 if the edge has no weight set. - Node weights are stored in the diagonal since self-loops are not supported. Nodes with no weight set (None) are left at 0.0 in the diagonal. + - For each edge (i, j), the entries (i,j) and (j,i) are set to its weight, + or to 1.0 if the edge has no weight set. Returns: Symmetric N x N matrix of dtype float64, where N is the number of nodes.