Skip to content
Merged
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
6 changes: 5 additions & 1 deletion scripts/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ def main() -> None:
coupling,
num_nodes,
)
degree = versatility.get_multi_degree(supra, num_layers, num_nodes)
degree = versatility.get_multi_degree(
supra,
nodes=num_nodes,
layers=num_layers,
)

print(f"nodes={num_nodes}, layers={num_layers}")
print(f"aggregated degree={degree.tolist()}")
Expand Down
94 changes: 47 additions & 47 deletions src/MuxVizPy/global_descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
import scipy.sparse as sp


def compute_average_global_clustering_coefficient(adj: sp.csr_matrix, n:int, l: int, logger: logging.Logger | None = None) -> float:
def compute_average_global_clustering_coefficient(adj: sp.csr_matrix, *, nodes:int, layers: int, logger: logging.Logger | None = None) -> float:
"""
Compute the average global clustering coefficient for a multilayer network.
This implementation matches the R version from the reference, using the formula:
C = tr(A^2*A) / (max(A) * tr(A*F*A))

Where F is the matrix with 1s everywhere except the diagonal.
Args:
adj: scipy.sparse.csr_matrix - the adjacency matrix of the multilayer network (shape: (n*l, n*l))
n: int - number of nodes per layer
l: int - number of layers
adj: scipy.sparse.csr_matrix - the adjacency matrix of the multilayer network (shape: (nodes*layers, nodes*layers))
nodes: int - number of nodes per layer
layers: int - number of layers
logger: Optional[logging.Logger] - logger for debugging (default: None)

Returns:
Expand Down Expand Up @@ -61,8 +61,8 @@ def _sparse_pmin(A: sp.csr_matrix, B: sp.csr_matrix) -> sp.csr_matrix:

def compute_average_global_overlap(
adj: sp.csr_matrix,
n: int,
l: int,
*, nodes: int,
layers: int,
weighted: bool = False,
logger: logging.Logger | None = None,
) -> float:
Expand All @@ -79,9 +79,9 @@ def compute_average_global_overlap(
For undirected networks (symmetric supra-adjacency) the result is halved.

Args:
adj: scipy.sparse.csr_matrix - supra-adjacency matrix (n*l, n*l)
n: int - number of nodes per layer
l: int - number of layers
adj: scipy.sparse.csr_matrix - supra-adjacency matrix (nodes*layers, nodes*layers)
nodes: int - number of nodes per layer
layers: int - number of layers
weighted: bool - if True use actual edge weights; if False binarize first (default: False)
logger: Optional[logging.Logger] - logger for debugging (default: None)

Expand All @@ -92,40 +92,40 @@ def compute_average_global_overlap(
De Domenico et al. (2015) "Structural reducibility of multilayer networks",
Nature Communications, 6, 6864.
"""
if l < 2:
if layers < 2:
raise ValueError("At least two layers are required.")

layers = []
for alpha in range(l):
A = adj[alpha * n:(alpha + 1) * n, alpha * n:(alpha + 1) * n].astype(float)
layer_matrices = []
for alpha in range(layers):
A = adj[alpha * nodes:(alpha + 1) * nodes, alpha * nodes:(alpha + 1) * nodes].astype(float)
if not weighted:
A = (A > 0).astype(float)
layers.append(A)
layer_matrices.append(A)

# Running element-wise minimum: nonzero only where all layers share the edge.
overlap = layers[0]
for layer in layers[1:]:
overlap = layer_matrices[0]
for layer in layer_matrices[1:]:
overlap = _sparse_pmin(overlap, layer)
norm_total = sum(float(layer.sum()) for layer in layers)
norm_total = sum(float(layer.sum()) for layer in layer_matrices)

sum_overlap = float(overlap.sum())
avg = l * sum_overlap / norm_total if norm_total != 0 else 0.0
avg = layers * sum_overlap / norm_total if norm_total != 0 else 0.0

# R checks `sum(A - A^T) == 0` before halving. However sum(A) == sum(A^T)
# for any matrix (transposing preserves the total), so R's condition is
# always True and R always halves. We replicate that behaviour here.
avg /= 2

if logger and logger.isEnabledFor(logging.DEBUG):
logger.debug(f"sum(O): {sum_overlap}, NormTotal: {norm_total}, L: {l}")
logger.debug(f"sum(O): {sum_overlap}, NormTotal: {norm_total}, L: {layers}")

return avg


def compute_average_global_overlap_matrix(
adj: sp.csr_matrix,
n: int,
l: int,
*, nodes: int,
layers: int,
weighted: bool = False,
logger: logging.Logger | None = None,
) -> np.ndarray:
Expand All @@ -138,35 +138,35 @@ def compute_average_global_overlap_matrix(

This is a Dice-like similarity coefficient in [0, 1]. The diagonal is 1.
Args:
adj: scipy.sparse.csr_matrix - supra-adjacency matrix (n*l, n*l)
n: int - number of nodes per layer
l: int - number of layers
adj: scipy.sparse.csr_matrix - supra-adjacency matrix (nodes*layers, nodes*layers)
nodes: int - number of nodes per layer
layers: int - number of layers
weighted: bool - if True use actual edge weights; if False binarize first (default: False)
logger: Optional[logging.Logger] - logger for debugging (default: None)

Returns:
numpy.ndarray of shape (l, l) - symmetric overlap matrix with 1s on diagonal
numpy.ndarray of shape (layers, layers) - symmetric overlap matrix with 1s on diagonal

Reference:
De Domenico et al. (2015) "Structural reducibility of multilayer networks",
Nature Communications, 6, 6864.
"""
if l < 2:
if layers < 2:
raise ValueError("At least two layers are required.")

layers = []
layer_matrices = []
layer_sums = []
for alpha in range(l):
A = adj[alpha * n:(alpha + 1) * n, alpha * n:(alpha + 1) * n].astype(float)
for alpha in range(layers):
A = adj[alpha * nodes:(alpha + 1) * nodes, alpha * nodes:(alpha + 1) * nodes].astype(float)
if not weighted:
A = (A > 0).astype(float)
layers.append(A)
layer_matrices.append(A)
layer_sums.append(float(A.sum()))

M = np.eye(l, dtype=float)
for l1 in range(l - 1):
for l2 in range(l1 + 1, l):
O = _sparse_pmin(layers[l1], layers[l2])
M = np.eye(layers, dtype=float)
for l1 in range(layers - 1):
for l2 in range(l1 + 1, layers):
O = _sparse_pmin(layer_matrices[l1], layer_matrices[l2])
denom = layer_sums[l1] + layer_sums[l2]
val = 2.0 * float(O.sum()) / denom if denom != 0 else 0.0
M[l1, l2] = val
Expand All @@ -180,8 +180,8 @@ def compute_average_global_overlap_matrix(

def compute_average_global_node_overlap_matrix(
adj: sp.csr_matrix,
n: int,
l: int,
*, nodes: int,
layers: int,
logger: logging.Logger | None = None,
) -> np.ndarray:
"""
Expand All @@ -195,32 +195,32 @@ def compute_average_global_node_overlap_matrix(
The diagonal is 1 (all active nodes overlap with themselves).

Args:
adj: scipy.sparse.csr_matrix - supra-adjacency matrix (n*l, n*l)
n: int - number of nodes per layer
l: int - number of layers
adj: scipy.sparse.csr_matrix - supra-adjacency matrix (nodes*layers, nodes*layers)
nodes: int - number of nodes per layer
layers: int - number of layers
logger: Optional[logging.Logger] - logger for debugging (default: None)

Returns:
numpy.ndarray of shape (l, l) - symmetric overlap matrix with 1s on diagonal
numpy.ndarray of shape (layers, layers) - symmetric overlap matrix with 1s on diagonal

Reference:
De Domenico et al. (2015) "Structural reducibility of multilayer networks",
Nature Communications, 6, 6864.
"""
if l < 2:
if layers < 2:
raise ValueError("At least two layers are required.")

active = []
for alpha in range(l):
A = adj[alpha * n:(alpha + 1) * n, alpha * n:(alpha + 1) * n]
for alpha in range(layers):
A = adj[alpha * nodes:(alpha + 1) * nodes, alpha * nodes:(alpha + 1) * nodes]
out_deg = np.asarray(A.sum(axis=1)).ravel() # row sums
in_deg = np.asarray(A.sum(axis=0)).ravel() # col sums
active.append(set(np.where((out_deg > 0) | (in_deg > 0))[0]))

M = np.eye(l, dtype=float)
for l1 in range(l - 1):
for l2 in range(l1 + 1, l):
val = len(active[l1] & active[l2]) / n
M = np.eye(layers, dtype=float)
for l1 in range(layers - 1):
for l2 in range(l1 + 1, layers):
val = len(active[l1] & active[l2]) / nodes
M[l1, l2] = val
M[l2, l1] = val

Expand Down
20 changes: 10 additions & 10 deletions src/MuxVizPy/mesoscale.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ def inter_layer_assortativity(

def compute_local_clustering_coefficient(
adj: sp.sparse.spmatrix,
n: int,
l: int,
*, nodes: int,
layers: int,
logger: logging.Logger | None = None,
) -> np.ndarray:
"""
Expand All @@ -110,17 +110,17 @@ def compute_local_clustering_coefficient(
----------
adj : scipy.sparse matrix, shape (N·L, N·L)
Binary supra-adjacency matrix with supra-node indexing
``row = layer * n + node``.
n : int
``row = layer * nodes + node``.
nodes : int
Number of physical nodes.
l : int
layers : int
Number of layers.
logger : optional
Unused; kept for API consistency with other descriptor functions.

Returns
-------
numpy.ndarray, shape (n,)
numpy.ndarray, shape (nodes,)
Local clustering coefficient in ``[0, 1]`` for each physical node.
Nodes with no possible triangles (isolated or degree-1 nodes) are
assigned 0.
Expand All @@ -131,13 +131,13 @@ def compute_local_clustering_coefficient(
networks. *Physical Review X*, 3(4), 041022.
https://doi.org/10.1103/PhysRevX.3.041022
"""
NL = n * l
NL = nodes * layers
A = adj.astype(float)

# Fold matrix P of shape (NL x n): P[i*n+p, p] = 1 for all layers i.
# P.T @ M @ P sums all (l x l) blocks of M into a single (n x n) matrix.
# Fold matrix P of shape (NL x nodes): P[i*nodes+p, p] = 1 for all layers i.
# P.T @ M @ P sums all (layers x layers) blocks of M into a single (nodes x nodes) matrix.
idx = np.arange(NL)
P = csr_matrix((np.ones(NL), (idx, idx % n)), shape=(NL, n))
P = csr_matrix((np.ones(NL), (idx, idx % nodes)), shape=(NL, nodes))

A2 = A @ A
A3 = A2 @ A
Expand Down
7 changes: 4 additions & 3 deletions src/MuxVizPy/percolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@

def get_percolation(
g_list: list[gt.Graph],
layers: int,
*,
nodes: int,
layers: int,
order: np.ndarray,
) -> dict[str, np.ndarray | float]:
"""
Expand All @@ -21,10 +22,10 @@ def get_percolation(
----------
g_list : list of graph_tool.Graph
List of graphs, one per layer of the multilayer network.
layers : int
Number of layers in the network.
nodes : int
Number of physical nodes (used for normalization).
layers : int
Number of layers in the network.
order : np.ndarray
Array specifying the order in which nodes are removed during percolation.

Expand Down
22 changes: 11 additions & 11 deletions src/MuxVizPy/utils/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,10 +820,9 @@ def build_tensor_from_supra_adjacency_matrix(a, *, nodes, layers):

def build_transition_matrix_from_adjacency_matrix(
adj: sp.csr_matrix,
n: int,
l: int,
*, nodes: int,
layers: int,
kind: str,
*,
alpha: float | None = None,
logger: logging.Logger | None = None,
) -> sp.csr_matrix:
Expand All @@ -838,11 +837,11 @@ def build_transition_matrix_from_adjacency_matrix(
Notes:
- Teleportation term makes the matrix dense; returned as CSR for consistency.
"""
NL = n * l
NL = nodes * layers
if not sp.isspmatrix_csr(adj):
adj = adj.tocsr(copy=False)
if adj.shape != (NL, NL):
raise ValueError(f"Incompatible shape {adj.shape} for n={n}, l={l} (expected {(NL, NL)})")
raise ValueError(f"Incompatible shape {adj.shape} for n={nodes}, l={layers} (expected {(NL, NL)})")

kind = kind.lower().strip()

Expand Down Expand Up @@ -982,7 +981,7 @@ def get_aggregate_network(


def supra_adjacency_to_block_tensor(
supra: sp.spmatrix, layers: int, nodes: int
supra: sp.spmatrix, *, nodes: int, layers: int
) -> list[list[sp.spmatrix]]:
"""
Convert supra-adjacency matrix to block tensor format.
Expand All @@ -991,10 +990,10 @@ def supra_adjacency_to_block_tensor(
----------
supra : scipy.sparse matrix
Supra-adjacency matrix.
layers : int
Number of layers.
nodes : int
Number of nodes.
layers : int
Number of layers.

Returns
-------
Expand All @@ -1009,7 +1008,7 @@ def supra_adjacency_to_block_tensor(


def node_tensor_to_network_list(
tensor: list[sp.spmatrix], layers: int, nodes: int
tensor: list[sp.spmatrix], *, nodes: int, layers: int
) -> list[gt.Graph]:
"""
Convert node-layer adjacency matrices into graph_tool graphs.
Expand All @@ -1018,10 +1017,10 @@ def node_tensor_to_network_list(
----------
tensor : list of scipy.sparse matrices
List of per-layer adjacency matrices.
layers : int
Number of layers.
nodes : int
Number of nodes.
layers : int
Number of layers.

Returns
-------
Expand Down Expand Up @@ -1078,6 +1077,7 @@ def supra_idx(node, layer):
def create_supra_transition_matrix_virus(
supra: sp.spmatrix,
node_tensor: list[sp.spmatrix],
*,
nodes: int,
layers: int,
p_intra: float = 1,
Expand Down
Loading