Skip to content

Commit ae83afe

Browse files
committed
Merge branch 'main' into add_evaluation_options
2 parents 6117e63 + f78b205 commit ae83afe

File tree

4 files changed

+300
-3
lines changed

4 files changed

+300
-3
lines changed

graphconstructor/graph.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import numpy as np
44
import pandas as pd
55
import scipy.sparse as sp
6+
from scipy.sparse.csgraph import connected_components
67

78

89
SymOp = Literal["max", "min", "average"]
@@ -240,13 +241,47 @@ def sorted_by(self, col: str) -> "Graph":
240241
meta2 = self.meta.iloc[order].reset_index(drop=True)
241242
return Graph(adj=A2, directed=self.directed, weighted=self.weighted, meta=meta2)
242243

243-
def degree(self) -> np.ndarray:
244-
"""Return (out-)degree for directed, degree for undirected. For weighted graphs sum of weights."""
245-
if self.weighted:
244+
def degree(self, ignore_weights: bool = False) -> np.ndarray:
245+
"""Return (out-)degree for directed, degree for undirected. For weighted graphs sum of weights.
246+
247+
Parameters
248+
----------
249+
ignore_weights
250+
If True, count number of edges only (treat as unweighted).
251+
Default is False.
252+
"""
253+
if self.weighted and not ignore_weights:
246254
deg = np.asarray(self.adj.sum(axis=1)).ravel()
247255
else:
248256
# count nonzeros per row
249257
deg = np.diff(self.adj.indptr).astype(float)
250258
if not self.directed:
251259
return deg
252260
return deg # could also return (out_degree, in_degree) if desired
261+
262+
def is_connected(self) -> bool:
263+
"""Return True if the graph is connected (undirected) or strongly connected (directed)."""
264+
n_components = connected_components(
265+
self.adj,
266+
directed=self.directed,
267+
connection="strong" if self.directed else "weak",
268+
return_labels=False
269+
)
270+
return n_components == 1
271+
272+
def connected_components(self, return_labels: bool = False) -> bool:
273+
"""Return the number of connected components or labels per node.
274+
For directed graphs, strongly connected components are returned.
275+
276+
Parameters
277+
----------
278+
return_labels
279+
If True, return an array of component labels per node instead of the number of components.
280+
Default is False.
281+
"""
282+
return connected_components(
283+
self.adj,
284+
directed=self.directed,
285+
connection="strong" if self.directed else "weak",
286+
return_labels=return_labels
287+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from .graph_statistics import plot_degree_distribution, plot_degree_distributions_grid
12
from .network_plots_nx import plot_graph_by_class
23

34

45
__all__ = [
6+
"plot_degree_distribution",
7+
"plot_degree_distributions_grid",
58
"plot_graph_by_class",
69
]
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
from typing import Iterable, Optional, Tuple
2+
import matplotlib.pyplot as plt
3+
import numpy as np
4+
5+
6+
def plot_degree_distribution(
7+
graph,
8+
*,
9+
x_scale: str = "log",
10+
y_scale: str = "log",
11+
ax: Optional[plt.Axes] = None,
12+
normalize: bool = True,
13+
include_zero_degree: bool = False,
14+
label: Optional[str] = None,
15+
marker: str = "o",
16+
markersize: float = 5.0,
17+
) -> Tuple[plt.Figure, plt.Axes]:
18+
"""
19+
Plot the degree distribution p(k) vs k for a single graph.
20+
21+
Parameters
22+
----------
23+
graph
24+
Object exposing `.degree()` -> 1-D numpy array of node degrees.
25+
x_scale, y_scale
26+
Matplotlib scales ("linear", "log", "symlog", etc.). Defaults "log" / "log".
27+
ax
28+
Optional axes to draw on. If None, a new figure and axes are created.
29+
normalize
30+
If True, plot probability mass p(k) = count(k) / N. If False, plot raw counts.
31+
include_zero_degree
32+
If True, include k=0 in the plot (useful if not using log scale).
33+
Note: when using log scale, k=0 cannot be shown and will be dropped.
34+
label
35+
Optional label for legend.
36+
marker
37+
Matplotlib marker style for the scatter points.
38+
markersize
39+
Size of scatter markers.
40+
41+
Returns
42+
-------
43+
(fig, ax)
44+
The Matplotlib figure and axes used for plotting.
45+
46+
Notes
47+
-----
48+
- When `x_scale` or `y_scale` is "log", any k=0 or p(k)=0 entries are removed
49+
to avoid invalid values on a log axis.
50+
"""
51+
degrees = graph.degree()
52+
if degrees.ndim != 1:
53+
raise ValueError("graph.degree() must return a 1-D array of degrees.")
54+
if degrees.size == 0:
55+
# Create empty plot but still return fig/ax for consistency
56+
if ax is None:
57+
fig, ax = plt.subplots()
58+
else:
59+
fig = ax.figure
60+
ax.set_xlabel("Degree k")
61+
ax.set_ylabel("p(k)" if normalize else "count(k)")
62+
ax.set_xscale(x_scale)
63+
ax.set_yscale(y_scale)
64+
ax.set_title(getattr(graph, "name", "Degree distribution (empty graph)"))
65+
return fig, ax
66+
67+
if np.any(degrees < 0):
68+
raise ValueError("Degrees must be nonnegative.")
69+
70+
# Compute counts per unique degree efficiently
71+
# Using np.bincount for speed (requires nonnegative ints)
72+
if not np.issubdtype(degrees.dtype, np.integer):
73+
# allow float degrees that are whole numbers
74+
if np.all(np.mod(degrees, 1) == 0):
75+
degrees = degrees.astype(int)
76+
else:
77+
raise ValueError("Degrees must be integers for degree distributions.")
78+
79+
counts = np.bincount(degrees) # index = k, value = count(k)
80+
ks = np.nonzero(counts)[0] # degrees that actually appear
81+
freqs = counts[ks].astype(float)
82+
83+
if normalize:
84+
total = freqs.sum()
85+
if total == 0:
86+
raise ValueError("No nodes with valid degrees found.")
87+
pk = freqs / total
88+
else:
89+
pk = freqs
90+
91+
# Handle zero-degree inclusion/exclusion
92+
if not include_zero_degree or x_scale == "log":
93+
mask = ks > 0
94+
ks, pk = ks[mask], pk[mask]
95+
96+
# On log y, remove zero probabilities (shouldn't occur if computed as above)
97+
if y_scale == "log":
98+
nz = pk > 0
99+
ks, pk = ks[nz], pk[nz]
100+
101+
# Prepare axes
102+
if ax is None:
103+
fig, ax = plt.subplots()
104+
else:
105+
fig = ax.figure
106+
107+
ax.scatter(ks, pk, marker=marker, s=markersize**2, label=label)
108+
ax.set_xlabel("Degree k")
109+
ax.set_ylabel("p(k)" if normalize else "count(k)")
110+
ax.set_xscale(x_scale)
111+
ax.set_yscale(y_scale)
112+
113+
title_default = getattr(graph, "name", None)
114+
if title_default:
115+
ax.set_title(f"Degree distribution: {title_default}")
116+
else:
117+
ax.set_title("Degree distribution")
118+
119+
if label is not None:
120+
ax.legend()
121+
122+
ax.grid(True, which="both", linestyle=":", linewidth=0.5, alpha=0.6)
123+
124+
return fig, ax
125+
126+
127+
def plot_degree_distributions_grid(
128+
graphs: Iterable,
129+
*,
130+
ncols: int = 3,
131+
x_scale: str = "log",
132+
y_scale: str = "log",
133+
normalize: bool = True,
134+
include_zero_degree: bool = False,
135+
figsize: Optional[Tuple[float, float]] = None,
136+
tight_layout: bool = True,
137+
sharex: bool = False,
138+
sharey: bool = False,
139+
) -> Tuple[plt.Figure, np.ndarray]:
140+
"""
141+
Plot a grid of degree distribution plots for multiple graphs.
142+
143+
Parameters
144+
----------
145+
graphs
146+
Iterable of Graph-like objects exposing `.degree() -> np.ndarray`.
147+
ncols
148+
Number of columns in the grid.
149+
x_scale, y_scale
150+
Axis scales for all subplots (defaults to "log").
151+
normalize
152+
If True, plot probability mass p(k). If False, raw counts.
153+
include_zero_degree
154+
Whether to include k=0 (will be dropped automatically on log x).
155+
figsize
156+
Optional figure size. If None, inferred from grid size.
157+
tight_layout
158+
Whether to call `fig.tight_layout()` at the end.
159+
sharex, sharey
160+
Whether to share x/y axes across subplots.
161+
162+
Returns
163+
-------
164+
(fig, axes)
165+
Figure and 2D ndarray of Axes (some entries may be unused if the grid
166+
is larger than the number of graphs).
167+
"""
168+
graphs = list(graphs)
169+
n = len(graphs)
170+
if n == 0:
171+
raise ValueError("`graphs` must contain at least one graph.")
172+
173+
nrows = np.ceil(n / ncols)
174+
if figsize is None:
175+
# heuristic: wider for more columns, taller for more rows
176+
figsize = (4 * ncols, 3.2 * nrows)
177+
178+
fig, axes = plt.subplots(nrows=nrows, ncols=ncols, figsize=figsize, sharex=sharex, sharey=sharey)
179+
180+
# Ensure axes is 2D array for consistent indexing
181+
if isinstance(axes, plt.Axes):
182+
axes = np.array([[axes]])
183+
elif axes.ndim == 1:
184+
axes = axes.reshape(1, -1)
185+
186+
# Plot each graph
187+
for i, graph in enumerate(graphs):
188+
r, c = divmod(i, ncols)
189+
ax = axes[r, c]
190+
label = getattr(graph, "label", None) # optional custom label field
191+
plot_degree_distribution(
192+
graph,
193+
x_scale=x_scale,
194+
y_scale=y_scale,
195+
ax=ax,
196+
normalize=normalize,
197+
include_zero_degree=include_zero_degree,
198+
label=label,
199+
)
200+
# Prefer a clean, concise title per subplot
201+
title = getattr(graph, "name", None) or f"Graph {i+1}"
202+
ax.set_title(title)
203+
204+
# Hide any unused axes
205+
for j in range(n, nrows * ncols):
206+
r, c = divmod(j, ncols)
207+
axes[r, c].set_visible(False)
208+
209+
if tight_layout:
210+
fig.tight_layout()
211+
212+
return fig, axes

tests/test_graph.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,53 @@ def test_sorted_by_permuted_order():
122122
# adjacency must be permuted consistently
123123
assert G2.adj.shape == (3, 3)
124124

125+
# ----------------- utilities -----------------
126+
def test_graph_is_connected_method():
127+
# Connected undirected graph
128+
A1 = _csr([1, 1, 1, 1], [0, 0, 1, 2], [1, 2, 2, 0], 3)
129+
G1 = Graph.from_csr(A1, directed=False, weighted=True)
130+
assert G1.is_connected()
131+
132+
# Disconnected undirected graph
133+
A2 = _csr([1, 1], [0, 0], [1, 2], 4)
134+
G2 = Graph.from_csr(A2, directed=False, weighted=True)
135+
assert not G2.is_connected()
136+
137+
# Strongly connected directed graph
138+
A3 = _csr([1, 1, 1, 1], [0, 1, 2, 2], [1, 2, 0, 1], 3)
139+
G3 = Graph.from_csr(A3, directed=True, weighted=True)
140+
assert G3.is_connected()
141+
142+
# Not strongly connected directed graph
143+
A4 = _csr([1, 1], [0, 1], [1, 2], 3)
144+
G4 = Graph.from_csr(A4, directed=True, weighted=True)
145+
assert not G4.is_connected()
146+
147+
148+
def test_graph_connected_components_method():
149+
# Undirected graph with 2 components
150+
A = _csr([1, 1, 1, 1], [0, 0, 3, 3], [1, 2, 3, 4], 5)
151+
G = Graph.from_csr(A, directed=False, weighted=True)
152+
n_components, labels = G.connected_components(return_labels=True)
153+
assert n_components == 2
154+
assert set(labels) == {0, 1}
155+
assert np.allclose(labels, np.array([0, 0, 0, 1, 1]))
156+
157+
158+
def test_graph_degree_method_weighted_and_unweighted():
159+
A = _csr([2.0, 3.0, 4.0, 5.0], [0, 1, 3, 3], [1, 2, 2, 0], 4)
160+
G_weighted = Graph.from_csr(A, directed=False, weighted=True)
161+
deg_weighted = G_weighted.degree()
162+
assert np.allclose(deg_weighted, np.array([7.0, 5.0, 7.0, 9.0]))
163+
164+
# ignore weights
165+
deg_weighted = G_weighted.degree(ignore_weights=True)
166+
assert np.allclose(deg_weighted, np.array([2, 2, 2, 2]))
167+
168+
G_unweighted = Graph.from_csr(A, directed=False, weighted=False)
169+
deg_unweighted = G_unweighted.degree()
170+
assert np.allclose(deg_unweighted, np.array([2, 2, 2, 2]))
171+
125172

126173
# ----------------- exporters -----------------
127174
@pytest.mark.skipif(not HAS_NX, reason="networkx not installed")

0 commit comments

Comments
 (0)