import math
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
import networkx as nx
import numpy as np
import torch
from torch_geometric.data import Data, InMemoryDataset
from torch_geometric.utils import to_undirected
@dataclass(frozen=True)
class CSBMParameters:
num_graphs: int
num_nodes_per_class: int
dbar: float
lam: float
gamma: float
mu: Optional[float]
only_sbm: bool
structured_ratio: float
seed: int
def _sbm_edge_probabilities(
num_nodes_per_class: int,
dbar: float,
lam: float,
) -> Tuple[float, float]:
r"""Return in/out edge probabilities for the SBM.
:obj:`"dbar"` and :obj:`"lam"` must be chosen such that
:math:`\bar{d} \geq \lambda \sqrt{\bar{d}}`.
Args:
num_nodes_per_class (int): Number of nodes in each block.
dbar (float): Average degree.
lam (float): signal strength for the SBM structure
Returns:
Tuple[float, float]: (pin, pout) edge probabilities within and between blocks.
"""
n = 2 * num_nodes_per_class
sqrt_dbar = math.sqrt(dbar)
pin = (dbar + lam * sqrt_dbar) / n
pout = (dbar - lam * sqrt_dbar) / n
for name, value in {"pin": pin, "pout": pout}.items():
if not 0.0 <= value <= 1.0:
raise ValueError(
f"{name}={value:.4f} is outside [0, 1]; "
"please adjust dbar and lam to produce valid probabilities."
)
return pin, pout
def _generate_sbm_graph(
num_nodes_per_class: int,
pin: float,
pout: float,
rng: np.random.Generator,
) -> Tuple[nx.Graph, np.ndarray]:
"""Sample a (possibly disconnected) SBM graph and its block assignments.
Args:
num_nodes_per_class (int): Number of nodes in each block.
pin (float): Edge probability within blocks.
pout (float): Edge probability between blocks.
rng (np.random.Generator): Random number generator.
Returns:
Tuple[nx.Graph, np.ndarray]: The generated SBM graph and block assignments.
"""
n = 2 * num_nodes_per_class
blocks = np.concatenate(
[
np.zeros(num_nodes_per_class, dtype=np.int64),
np.ones(num_nodes_per_class, dtype=np.int64),
]
)
rng.shuffle(blocks)
adjacency = np.zeros((n, n), dtype=bool)
for i in range(n - 1):
same_block = blocks[i] == blocks[i + 1 :]
probs = np.where(same_block, pin, pout)
mask = rng.random(n - i - 1) < probs
adjacency[i, i + 1 :][mask] = True
adjacency |= adjacency.T # Make symmetric
graph = nx.from_numpy_array(adjacency.astype(int))
return graph, blocks
def _generate_connected_sbm(
num_nodes_per_class: int,
pin: float,
pout: float,
rng: np.random.Generator,
max_attempts: int = 128,
) -> Tuple[nx.Graph, np.ndarray]:
"""Sample a connected SBM graph, retrying when necessary (ie, rejection sampling).
Args:
num_nodes_per_class (int): Number of nodes in each block.
pin (float): Edge probability within blocks.
pout (float): Edge probability between blocks.
rng (np.random.Generator): Random number generator.
max_attempts (int): Maximum number of sampling attempts.
Returns:
Tuple[nx.Graph, np.ndarray]: A connected SBM graph and its block assignments.
"""
for _ in range(1, max_attempts + 1):
graph, blocks = _generate_sbm_graph(num_nodes_per_class, pin, pout, rng)
if nx.is_connected(graph):
return graph, blocks
raise RuntimeError(
"Failed to sample a connected SBM graph after "
f"{max_attempts} attempts. Please verify the parameters."
)
def _generate_random_graph_with_degree_sequence(
degrees: Sequence[int],
rng: np.random.Generator,
max_attempts: int = 128,
) -> nx.Graph:
"""Generate a connected random graph matching a target degree sequence.
Args:
degrees (Sequence[int]): Target degree sequence.
rng (np.random.Generator): Generator for the random seed.
max_attempts (int): Maximum number of attempts to sample a connected graph.
Returns:
nx.Graph: A connected random graph with the specified degree sequence.
"""
for _ in range(max_attempts):
seed = int(rng.integers(0, 2**32 - 1))
candidate = nx.random_degree_sequence_graph(degrees, tries=100, seed=seed)
candidate = nx.Graph(candidate) # enforce simple graph
candidate.remove_edges_from(nx.selfloop_edges(candidate))
if nx.is_connected(candidate):
return candidate
raise RuntimeError(
"Unable to sample a connected random graph with the provided degree sequence."
)
def _degree_vector(graph: nx.Graph) -> np.ndarray:
"""Return the node degrees ordered by node index."""
return np.array([graph.degree(i) for i in range(graph.number_of_nodes())])
def _gaussian_features(
n: int,
feature_dim: int,
rng: np.random.Generator,
) -> np.ndarray:
"""Noise features with unit variance per dimension."""
if feature_dim <= 0:
raise ValueError("feature_dim must be positive.")
return rng.normal(size=(n, feature_dim)) / math.sqrt(feature_dim)
def _gmm_features(
blocks: np.ndarray,
mu: float,
feature_dim: int,
rng: np.random.Generator,
) -> Tuple[np.ndarray, np.ndarray]:
"""Generate Gaussian mixture features aligned with the community structure.
Args:
blocks (np.ndarray): Block assignments for each node.
mu (float): Signal strength.
feature_dim (int): Dimensionality of the features.
rng (np.random.Generator): Random number generator.
Returns:
Tuple[np.ndarray, np.ndarray]: Node features and the cluster centroid.
"""
n = blocks.size
centroid = rng.normal(size=feature_dim) / math.sqrt(feature_dim)
noise = _gaussian_features(n, feature_dim, rng)
signal_scale = math.sqrt(mu / n)
signed_blocks = (blocks * 2 - 1).reshape(-1, 1) # map {0,1} -> {-1,1}
features = signal_scale * signed_blocks * centroid + noise
return features, signal_scale * centroid
def _build_data_object(
graph: nx.Graph,
features: np.ndarray,
label: int,
node_gt: Optional[np.ndarray] = None,
centroid: Optional[np.ndarray] = None,
) -> Data:
"""Create a PyG Data object from a NetworkX graph and feature matrix.
Args:
graph (nx.Graph): Input graph.
features (np.ndarray): Node feature matrix.
label (int): Graph label.
node_gt (Optional[np.ndarray]): Optional ground truth node labels.
centroid (Optional[np.ndarray]): Optional centroid of the feature clusters.
Returns:
~~torch_geometric.data.Data: The constructed PyG Data object.
"""
edges = list(graph.edges())
edge_index = torch.tensor(edges, dtype=torch.long).t().contiguous()
edge_index = to_undirected(edge_index, num_nodes=graph.number_of_nodes())
x = torch.from_numpy(features.astype(np.float32))
y = torch.tensor(label, dtype=torch.long)
data = Data(x=x, edge_index=edge_index, y=y)
if node_gt is not None:
data.node_gt = torch.from_numpy(node_gt.astype(np.int64))
if centroid is not None:
data.centroid = torch.from_numpy(centroid.astype(np.float32))
return data
[docs]
class CSBMDataset(InMemoryDataset):
"""Contextual SBM dataset for graph classification tasks in PyG format.
Args:
root (str): Root directory for the dataset.
num_graphs (int): Number of graphs to generate.
num_nodes_per_class (int): Number of nodes in each community.
dbar (float): Average degree of the graphs.
lam (float): Signal strength for the SBM structure.
gamma (float): Ratio of number of nodes to feature dimension.
mu (Optional[float]): Signal strength for node features (ignored if only_sbm is :obj:`True`).
only_sbm (bool): If True, generate only SBM structure without node features.
structured_ratio (float): Proportion of graphs with community structure.
seed (int): Random seed for reproducibility.
transform (callable, optional): A function/transform that takes in an
:obj:`~torch_geometric.data.Data` object and returns a transformed
version. The data object will be transformed before every access.
pre_transform (callable, optional): A function/transform that takes in
an :obj:`~torch_geometric.data.Data` object and returns a transformed
version. The data object will be transformed before being saved to disk.
pre_filter (callable, optional): A function that takes in an
:obj:`~torch_geometric.data.Data` object and returns a boolean
value, indicating whether the data object should be included in the
final dataset.
"""
def __init__(
self,
root: str,
num_graphs: int = 1024,
num_nodes_per_class: int = 50,
dbar: float = 10.0,
lam: float = 1.0,
gamma: float = 4.0,
mu: Optional[float] = 4.0,
only_sbm: bool = False,
structured_ratio: float = 0.5,
seed: int = 80,
transform=None,
pre_transform=None,
pre_filter=None,
force_reload: bool = False,
):
self.params = CSBMParameters(
num_graphs=num_graphs,
num_nodes_per_class=num_nodes_per_class,
dbar=dbar,
lam=lam,
gamma=gamma,
mu=mu,
only_sbm=only_sbm,
structured_ratio=structured_ratio,
seed=seed,
)
if not 0.0 <= structured_ratio <= 1.0:
raise ValueError("structured_ratio must be in the interval [0, 1].")
if not only_sbm and mu is None:
raise ValueError("mu must be provided when only_sbm is False.")
if force_reload:
self._purge_processed_files(root)
super().__init__(root, transform, pre_transform, pre_filter)
self.data, self.slices = torch.load(self.processed_paths[0])
@property
def raw_file_names(self) -> Sequence[str]:
"""Return raw filenames expected by :class:`~torch_geometric.data.Dataset`."""
return []
@property
def processed_file_names(self) -> Sequence[str]:
"""Return the processed filename derived from dataset generation parameters."""
p = self.params
suffix = (
f"graphs{p.num_graphs}_ncls{p.num_nodes_per_class}_d{p.dbar:.2f}"
f"_lam{p.lam:.3f}_gamma{p.gamma:.3f}_mu{p.mu if p.mu is not None else 'None'}"
f"_onlysbm{int(p.only_sbm)}_ratio{p.structured_ratio:.2f}_seed{p.seed}"
)
safe_suffix = suffix.replace(".", "p")
return [f"csbm_{safe_suffix}.pt"]
def _purge_processed_files(self, root: str) -> None:
from pathlib import Path
processed_dir = Path(root) / "processed"
for filename in self.processed_file_names:
path = processed_dir / filename
if path.exists():
path.unlink()
[docs]
def download(self):
"""No-op: this dataset is generated procedurally and needs no download."""
# Dataset is generated locally, no download required.
return
[docs]
def process(self):
"""Generate and persist the synthetic CSBM graphs to disk."""
p = self.params
rng = np.random.default_rng(p.seed)
pin, pout = _sbm_edge_probabilities(p.num_nodes_per_class, p.dbar, p.lam)
total_nodes = 2 * p.num_nodes_per_class
feature_dim = 1 if p.only_sbm else max(1, int(round(total_nodes / p.gamma)))
num_structured = int(round(p.num_graphs * p.structured_ratio))
num_structured = min(p.num_graphs, max(0, num_structured))
num_unstructured = p.num_graphs - num_structured
data_list = []
# Unstructured graphs: same degree profile but without block signal.
for _ in range(num_unstructured):
sbm_graph, _ = _generate_connected_sbm(
p.num_nodes_per_class, pin, pout, rng
) # to work exactly with a degree distribution drawn from an SBM
degrees = _degree_vector(sbm_graph).tolist()
random_graph = _generate_random_graph_with_degree_sequence(degrees, rng)
if p.only_sbm:
features = np.asarray(_degree_vector(random_graph), dtype=np.float32)
features = features.reshape(-1, 1)
else:
features = _gaussian_features(total_nodes, feature_dim, rng)
data = _build_data_object(random_graph, features, label=0)
data_list.append(data)
# Structured graphs: SBM structure with informative node features.
for _ in range(num_structured):
graph, blocks = _generate_connected_sbm(
p.num_nodes_per_class, pin, pout, rng
)
if p.only_sbm:
features = _degree_vector(graph).astype(np.float32).reshape(-1, 1)
centroid = None
else:
features, centroid = _gmm_features(
blocks, float(p.mu), feature_dim, rng
)
data = _build_data_object(
graph,
features,
label=1,
node_gt=blocks,
centroid=centroid,
)
data_list.append(data)
if self.pre_filter is not None:
data_list = [data for data in data_list if self.pre_filter(data)]
if self.pre_transform is not None:
data_list = [self.pre_transform(data) for data in data_list]
data, slices = self.collate(data_list)
torch.save((data, slices), self.processed_paths[0])