From b3acde4555bfc3b87187aebf6c8dd1738db910e1 Mon Sep 17 00:00:00 2001 From: Tiziano Date: Fri, 24 Apr 2026 13:31:26 +0200 Subject: [PATCH 1/5] feat: add HGNN+ model --- examples/hgnnp.py | 156 ++++++++++++++++++++++ hyperbench/hlp/__init__.py | 3 + hyperbench/hlp/hgnn_hlp.py | 4 +- hyperbench/hlp/hgnnp_hlp.py | 142 ++++++++++++++++++++ hyperbench/models/__init__.py | 3 +- hyperbench/models/hgnn.py | 2 +- hyperbench/models/hgnnp.py | 98 ++++++++++++++ hyperbench/nn/__init__.py | 3 +- hyperbench/nn/conv.py | 79 +++++++++++ hyperbench/tests/types/hypergraph_test.py | 117 +++++++++++++++- hyperbench/types/hypergraph.py | 100 +++++++++++++- 11 files changed, 694 insertions(+), 13 deletions(-) create mode 100644 examples/hgnnp.py create mode 100644 hyperbench/hlp/hgnnp_hlp.py create mode 100644 hyperbench/models/hgnnp.py diff --git a/examples/hgnnp.py b/examples/hgnnp.py new file mode 100644 index 0000000..fbe2d2e --- /dev/null +++ b/examples/hgnnp.py @@ -0,0 +1,156 @@ +from torchmetrics import MetricCollection +from torchmetrics.classification import ( + BinaryAUROC, + BinaryAveragePrecision, + BinaryPrecision, + BinaryRecall, +) +from lightning.pytorch.callbacks import EarlyStopping + +from hyperbench.data import AlgebraDataset, DataLoader, SamplingStrategy +from hyperbench.hlp import HGNNPHlpModule +from hyperbench.nn import LaplacianPositionalEncodingEnricher +from hyperbench.train import MultiModelTrainer, RandomNegativeSampler +from hyperbench.types import HData, ModelConfig + + +if __name__ == "__main__": + verbose = False + num_workers = 8 + sampling_strategy = SamplingStrategy.HYPEREDGE + metrics = MetricCollection( + { + "auc": BinaryAUROC(), + "avg_precision": BinaryAveragePrecision(), + "precision": BinaryPrecision(), + "recall": BinaryRecall(), + } + ) + + print("Loading and preparing dataset...") + + dataset = AlgebraDataset(sampling_strategy=sampling_strategy, prepare=True) + if verbose: + print(f"Dataset:\n {dataset.hdata}\n") + + train_dataset, test_dataset = dataset.split(ratios=[0.8, 0.2], shuffle=True, seed=42) + train_dataset, val_dataset = train_dataset.split(ratios=[0.875, 0.125], shuffle=True, seed=42) + if verbose: + print(f"Train dataset (before train/val split):\n {train_dataset.hdata}\n") + print(f"Train dataset (after train/val split):\n {train_dataset.hdata}\n") + print(f"Val dataset:\n {val_dataset.hdata}\n") + print(f"Test dataset:\n {test_dataset.hdata}\n") + + train_hyperedge_index = train_dataset.hdata.hyperedge_index + + for name, ds in [("Train", train_dataset), ("Val", val_dataset), ("Test", test_dataset)]: + num_negative_samples = ( + ds.hdata.num_hyperedges + if name in ["Train", "Val"] + else int(ds.hdata.num_hyperedges * 0.6) + ) + negative_sampler = RandomNegativeSampler( + num_negative_samples=num_negative_samples, + num_nodes_per_sample=int(ds.stats()["avg_degree_hyperedge"]), + ) + neg_hdata = negative_sampler.sample(ds.hdata) + combined_hdata = HData.cat_same_node_space([ds.hdata, neg_hdata]) + shuffled_hdata = combined_hdata.shuffle(seed=42) + ds_with_negatives = ds.update_from_hdata(shuffled_hdata) + + if name == "Train": + train_dataset = ds_with_negatives + elif name == "Val": + val_dataset = ds_with_negatives + else: + test_dataset = ds_with_negatives + + if verbose: + print(f"{name} dataset after adding negative samples: {shuffled_hdata}\n") + + print("Enriching node features...") + + train_dataset.enrich_node_features( + enricher=LaplacianPositionalEncodingEnricher(num_features=32), + enrichment_mode="replace", + ) + val_dataset.hdata.x = train_dataset.hdata.x[: val_dataset.hdata.num_nodes] + test_dataset.hdata.x = train_dataset.hdata.x[:, : test_dataset.hdata.num_nodes] + + print("Creating dataloaders...") + + train_loader_full_hypergraph = DataLoader( + train_dataset, + sample_full_hypergraph=True, + shuffle=False, + num_workers=num_workers, + persistent_workers=True, + ) + val_loader_full_hypergraph = DataLoader( + val_dataset, + sample_full_hypergraph=True, + shuffle=False, + num_workers=num_workers, + persistent_workers=True, + ) + test_loader_full_hypergraph = DataLoader( + test_dataset, + sample_full_hypergraph=True, + shuffle=False, + num_workers=num_workers, + persistent_workers=True, + ) + + mean_hgnnp_module = HGNNPHlpModule( + encoder_config={ + "in_channels": 32, + "hidden_channels": 16, + "out_channels": 16, + "bias": True, + "use_batch_normalization": False, + "drop_rate": 0.5, + "fast": False, + }, + aggregation="mean", + lr=0.01, + weight_decay=5e-4, + metrics=metrics, + ) + + configs = [ + ModelConfig( + name="hgnnp", + version="mean", + model=mean_hgnnp_module, + train_dataloader=train_loader_full_hypergraph, + val_dataloader=val_loader_full_hypergraph, + test_dataloader=test_loader_full_hypergraph, + ), + ] + + early_stopping = EarlyStopping( + monitor="val_loss", + patience=30, + mode="min", + ) + + print("Starting training and evaluation...") + + with MultiModelTrainer( + model_configs=configs, + max_epochs=60, + accelerator="auto", + log_every_n_steps=1, + callbacks=[early_stopping], + enable_checkpointing=False, + auto_start_tensorboard=True, + auto_wait=True, + ) as trainer: + trainer.fit_all( + train_dataloader=train_loader_full_hypergraph, + val_dataloader=val_loader_full_hypergraph, + verbose=True, + ) + trainer.test_all(dataloader=test_loader_full_hypergraph, verbose=True) + + print("Complete!") diff --git a/hyperbench/hlp/__init__.py b/hyperbench/hlp/__init__.py index 31c8561..0ae682e 100644 --- a/hyperbench/hlp/__init__.py +++ b/hyperbench/hlp/__init__.py @@ -1,5 +1,6 @@ from .common_neighbors_hlp import CommonNeighborsHlpModule from .hgnn_hlp import HGNNHlpModule, HGNNEncoderConfig +from .hgnnp_hlp import HGNNPEncoderConfig, HGNNPHlpModule from .hlp import HlpModule from .hypergcn_hlp import HyperGCNHlpModule, HyperGCNEncoderConfig from .mlp_hlp import MLPHlpModule, MlpEncoderConfig @@ -9,6 +10,8 @@ "CommonNeighborsHlpModule", "HGNNEncoderConfig", "HGNNHlpModule", + "HGNNPEncoderConfig", + "HGNNPHlpModule", "HlpModule", "HyperGCNEncoderConfig", "HyperGCNHlpModule", diff --git a/hyperbench/hlp/hgnn_hlp.py b/hyperbench/hlp/hgnn_hlp.py index 1b4b38f..76934b1 100644 --- a/hyperbench/hlp/hgnn_hlp.py +++ b/hyperbench/hlp/hgnn_hlp.py @@ -21,7 +21,7 @@ class HGNNEncoderConfig(TypedDict): bias: Whether to include bias terms. Defaults to ``True``. use_batch_normalization: Whether to use batch normalization. Defaults to ``False``. drop_rate: Dropout rate. Defaults to ``0.5``. - fast: Whether to cache the HGNN Laplacian. Defaults to ``True``. + fast: Whether to cache the HGNN Laplacian. Defaults to ``False``. """ in_channels: int @@ -66,7 +66,7 @@ def __init__( bias=encoder_config.get("bias", True), use_batch_normalization=encoder_config.get("use_batch_normalization", False), drop_rate=encoder_config.get("drop_rate", 0.5), - fast=encoder_config.get("fast", True), + fast=encoder_config.get("fast", False), ) decoder = SLP(in_channels=encoder_config["out_channels"], out_channels=1) diff --git a/hyperbench/hlp/hgnnp_hlp.py b/hyperbench/hlp/hgnnp_hlp.py new file mode 100644 index 0000000..a3b66b8 --- /dev/null +++ b/hyperbench/hlp/hgnnp_hlp.py @@ -0,0 +1,142 @@ +from torch import Tensor, nn, optim +from typing import Literal, Optional, TypedDict + +from torchmetrics import MetricCollection +from typing_extensions import NotRequired + +from hyperbench.hlp.hlp import HlpModule +from hyperbench.models import HGNNP, SLP +from hyperbench.nn import HyperedgeAggregator +from hyperbench.types import HData +from hyperbench.utils import Stage + + +class HGNNPEncoderConfig(TypedDict): + """ + Configuration for the HGNN+ encoder in HGNNPHlpModule. + + Args: + in_channels: Number of input features per node. + hidden_channels: Number of hidden units in the intermediate HGNN+ layer. + out_channels: Number of output features (embedding size) per node. + bias: Whether to include bias terms. Defaults to ``True``. + use_batch_normalization: Whether to use batch normalization. Defaults to ``False``. + drop_rate: Dropout rate. Defaults to ``0.5``. + fast: Whether to cache the HGNN+ smoothing matrix. Defaults to ``False``. + """ + + in_channels: int + hidden_channels: int + out_channels: int + bias: NotRequired[bool] + use_batch_normalization: NotRequired[bool] + drop_rate: NotRequired[float] + fast: NotRequired[bool] + + +class HGNNPHlpModule(HlpModule): + """ + A LightningModule for HGNN+-based Hyperedge Link Prediction. + + Uses HGNN+ as an encoder to produce structure-aware node embeddings via + row-stochastic hypergraph convolution, aggregates them per hyperedge, + and scores each hyperedge with a linear decoder. + + Args: + encoder_config: Configuration for the HGNN+ encoder. + aggregation: Method to aggregate node embeddings per hyperedge. Defaults to ``"mean"``. + loss_fn: Loss function. Defaults to ``BCEWithLogitsLoss``. + lr: Learning rate for the optimizer. Defaults to ``0.01``. + weight_decay: L2 regularization. Defaults to ``5e-4``. + metrics: Optional metric collection for evaluation. + """ + + def __init__( + self, + encoder_config: HGNNPEncoderConfig, + aggregation: Literal["mean", "max", "min", "sum"] = "mean", + loss_fn: Optional[nn.Module] = None, + lr: float = 0.01, + weight_decay: float = 5e-4, + metrics: Optional[MetricCollection] = None, + ): + encoder = HGNNP( + in_channels=encoder_config["in_channels"], + hidden_channels=encoder_config["hidden_channels"], + num_classes=encoder_config["out_channels"], + bias=encoder_config.get("bias", True), + use_batch_normalization=encoder_config.get("use_batch_normalization", False), + drop_rate=encoder_config.get("drop_rate", 0.5), + fast=encoder_config.get("fast", False), + ) + decoder = SLP(in_channels=encoder_config["out_channels"], out_channels=1) + + super().__init__( + encoder=encoder, + decoder=decoder, + loss_fn=loss_fn if loss_fn is not None else nn.BCEWithLogitsLoss(), + metrics=metrics, + ) + + self.aggregation = aggregation + self.lr = lr + self.weight_decay = weight_decay + + def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: + """ + Run the full HGNN+-based hyperedge link prediction pipeline. + + The pipeline has three stages: + 1. Encode: HGNN+ applies two rounds of ``D_v^{-1} H D_e^{-1} H^T`` + smoothing to propagate information through the hypergraph topology with + two-stage mean aggregation. The output is a structure-aware node + embedding matrix of shape ``(num_nodes, out_channels)``. + 2. Aggregate: For each hyperedge being scored, pool the embeddings of its member + nodes using the configured strategy (mean/max/min/sum). This produces a hyperedge + embedding of shape ``(num_hyperedges, out_channels)``. + 3. Decode: A single linear layer projects each hyperedge embedding to a + scalar score. Shape: ``(num_hyperedges,)``. + + Args: + x: Node feature matrix of shape ``(num_nodes, in_channels)``. + Must contain **all** nodes referenced in ``hyperedge_index``. + hyperedge_index: Hyperedge connectivity of shape ``(2, num_incidences)``, + with row 0 containing global node IDs and row 1 hyperedge IDs. + + Returns: + Logit scores of shape ``(num_hyperedges,)``. + """ + if self.encoder is None: + raise ValueError("Encoder is not defined for this HLP module.") + + node_embeddings: Tensor = self.encoder(x, hyperedge_index) + hyperedge_embeddings = HyperedgeAggregator(hyperedge_index, node_embeddings).pool( + self.aggregation + ) + + scores: Tensor = self.decoder(hyperedge_embeddings).squeeze(-1) + return scores + + def training_step(self, batch: HData, batch_idx: int) -> Tensor: + return self.__eval_step(batch, Stage.TRAIN) + + def validation_step(self, batch: HData, batch_idx: int) -> Tensor: + return self.__eval_step(batch, Stage.VAL) + + def test_step(self, batch: HData, batch_idx: int) -> Tensor: + return self.__eval_step(batch, Stage.TEST) + + def predict_step(self, batch: HData, batch_idx: int) -> Tensor: + return self.forward(batch.x, batch.hyperedge_index) + + def configure_optimizers(self): + return optim.Adam(self.parameters(), lr=self.lr, weight_decay=self.weight_decay) + + def __eval_step(self, batch: HData, stage: Stage) -> Tensor: + scores = self.forward(batch.x, batch.hyperedge_index) + labels = batch.y + batch_size = batch.num_hyperedges + + loss = self._compute_loss(scores, labels, batch_size, stage) + self._compute_metrics(scores, labels, batch_size, stage) + return loss diff --git a/hyperbench/models/__init__.py b/hyperbench/models/__init__.py index fc70efb..a839efe 100644 --- a/hyperbench/models/__init__.py +++ b/hyperbench/models/__init__.py @@ -1,7 +1,8 @@ from .common_neighbors import CommonNeighbors from .hgnn import HGNN +from .hgnnp import HGNNP from .hypergcn import HyperGCN from .mlp import MLP, SLP from .node2vec import Node2Vec -__all__ = ["CommonNeighbors", "HGNN", "HyperGCN", "MLP", "Node2Vec", "SLP"] +__all__ = ["CommonNeighbors", "HGNN", "HGNNP", "HyperGCN", "MLP", "Node2Vec", "SLP"] diff --git a/hyperbench/models/hgnn.py b/hyperbench/models/hgnn.py index e432813..0ee3768 100644 --- a/hyperbench/models/hgnn.py +++ b/hyperbench/models/hgnn.py @@ -68,7 +68,7 @@ def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: The second layer is the output layer (no activation/dropout) and maps ``hidden_channels -> num_classes``. - When ``fast=True`` (default), the HGNN Laplacian ``D_n^{-1/2} H D_e^{-1} H^T D_n^{-1/2}`` + When ``fast=True``, the HGNN Laplacian ``D_n^{-1/2} H D_e^{-1} H^T D_n^{-1/2}`` is computed once from ``hyperedge_index`` and cached. The cache is invalidated only when ``num_nodes`` changes (e.g., due to negative sampling adding nodes across batches). This is safe because the HGNN Laplacian depends solely on the hypergraph topology, diff --git a/hyperbench/models/hgnnp.py b/hyperbench/models/hgnnp.py new file mode 100644 index 0000000..02f00ad --- /dev/null +++ b/hyperbench/models/hgnnp.py @@ -0,0 +1,98 @@ +from typing import Optional + +from torch import Tensor, nn + +from hyperbench.nn import HGNNPConv +from hyperbench.types import HyperedgeIndex + + +class HGNNP(nn.Module): + """ + HGNN+ performs hypergraph convolution with two-stage mean aggregation using the + incidence structure directly: nodes -> hyperedges -> nodes. + - Proposed in `HGNN+: General Hypergraph Neural Networks `_ paper (IEEE T-PAMI 2022). + - Reference implementation: `source `_. + + Args: + in_channels: The number of input channels. + hidden_channels: The number of hidden channels. + num_classes: The number of output channels. + bias: If set to ``False``, the layer will not learn the bias parameter. Defaults to ``True``. + use_batch_normalization: If set to ``True``, layers will use batch normalization. Defaults to ``False``. + drop_rate: Dropout ratio. Defaults to ``0.5``. + fast: If set to ``True``, the HGNN+ smoothing matrix will be computed once and cached. + Defaults to ``False``. Since the matrix depends only on hypergraph topology, + caching is safe whenever the incidence structure is unchanged. + """ + + def __init__( + self, + in_channels: int, + hidden_channels: int, + num_classes: int, + bias: bool = True, + use_batch_normalization: bool = False, + drop_rate: float = 0.5, + fast: bool = False, + ): + super().__init__() + self.fast = fast + self.cached_hgnnp_smoothing_matrix: Optional[Tensor] = None + + self.layers = nn.ModuleList( + [ + HGNNPConv( + in_channels=in_channels, + out_channels=hidden_channels, + bias=bias, + use_batch_normalization=use_batch_normalization, + drop_rate=drop_rate, + ), + HGNNPConv( + in_channels=hidden_channels, + out_channels=num_classes, + bias=bias, + use_batch_normalization=use_batch_normalization, + is_last=True, + ), + ] + ) + + def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: + r""" + Apply two stacked ``HGNNPConv`` layers to produce node embeddings. + + When ``fast=True``, the HGNN+ smoothing matrix ``D_v^{-1} H D_e^{-1} H^T`` + is computed once from ``hyperedge_index`` and cached. The cache is invalidated + only when ``num_nodes`` changes. + + Args: + x: Input node feature matrix. Size ``(num_nodes, in_channels)``. + hyperedge_index: Hyperedge incidence in COO format. Size ``(2, num_incidences)``, + where row 0 contains node IDs and row 1 contains hyperedge IDs. + + Returns: + The output node feature matrix. Size ``(num_nodes, num_classes)``. + """ + if not self.fast: + for layer in self.layers: + x = layer(x, hyperedge_index) + return x + + should_not_use_cached = ( + self.cached_hgnnp_smoothing_matrix is None + or self.cached_hgnnp_smoothing_matrix.size(0) != x.size(0) + ) + + if should_not_use_cached: + self.cached_hgnnp_smoothing_matrix = HyperedgeIndex( + hyperedge_index + ).get_sparse_hgnnp_smoothing_matrix(num_nodes=x.size(0)) + + for layer in self.layers: + x = layer( + x, + hyperedge_index, + hgnnp_smoothing_matrix=self.cached_hgnnp_smoothing_matrix, + ) + return x diff --git a/hyperbench/nn/__init__.py b/hyperbench/nn/__init__.py index 9c2959d..7190c0e 100644 --- a/hyperbench/nn/__init__.py +++ b/hyperbench/nn/__init__.py @@ -1,7 +1,7 @@ from hyperbench.utils import Aggregation from .aggregator import HyperedgeAggregator -from .conv import HGNNConv, HyperGCNConv +from .conv import HGNNConv, HGNNPConv, HyperGCNConv from .enricher import ( EnrichmentMode, NodeEnricher, @@ -18,6 +18,7 @@ "CommonNeighborsScorer", "EnrichmentMode", "HGNNConv", + "HGNNPConv", "HyperedgeAggregator", "HyperGCNConv", "NeighborScorer", diff --git a/hyperbench/nn/conv.py b/hyperbench/nn/conv.py index 0ef206b..26b8367 100644 --- a/hyperbench/nn/conv.py +++ b/hyperbench/nn/conv.py @@ -168,3 +168,82 @@ def forward( x = self.dropout(x) return x + + +class HGNNPConv(nn.Module): + """ + The HGNNPConv layer proposed in `HGNN+: General Hypergraph Neural Networks `_ paper (IEEE T-PAMI 2022). + Reference implementation: `source `_. + + Each layer performs: ``X' = sigma(M_HGNN+ X Theta)`` where + ``M_HGNN+ = D_v^{-1} H D_e^{-1} H^T`` is the HGNN+ smoothing matrix. + + Unlike ``HGNNConv``, which uses symmetric ``D_v^{-1/2}`` normalization for a + spectral Laplacian, ``HGNNPConv`` uses plain inverse degrees and performs + two-stage mean aggregation: nodes -> hyperedges -> nodes. + + Args: + in_channels: The number of input channels. + out_channels: The number of output channels. + bias: If set to ``False``, the layer will not learn the bias parameter. Defaults to ``True``. + use_batch_normalization: If set to ``True``, the layer will use batch normalization. Defaults to ``False``. + drop_rate: If set to a positive number, the layer will use dropout. Defaults to ``0.5``. + is_last: If set to ``True``, the layer will not apply the final activation and dropout functions. Defaults to ``False``. + """ + + def __init__( + self, + in_channels: int, + out_channels: int, + bias: bool = True, + use_batch_normalization: bool = False, + drop_rate: float = 0.5, + is_last: bool = False, + ): + super().__init__() + self.is_last = is_last + self.batch_norm_1d = nn.BatchNorm1d(out_channels) if use_batch_normalization else None + self.activation_fn = nn.ReLU(inplace=True) + self.dropout = nn.Dropout(drop_rate) + self.theta = nn.Linear(in_channels, out_channels, bias=bias) + + def forward( + self, + x: Tensor, + hyperedge_index: Tensor, + hgnnp_smoothing_matrix: Optional[Tensor] = None, + ) -> Tensor: + r""" + Apply one HGNN+ convolution layer using row-stochastic hypergraph smoothing. + + The full per-layer formula is: + ``X' = sigma( D_v^{-1} H D_e^{-1} H^T (X Theta) )`` + + Args: + x: Input node feature matrix. Size ``(num_nodes, in_channels)``. + hyperedge_index: Hyperedge incidence in COO format. Size ``(2, num_incidences)``, + where row 0 contains node IDs and row 1 contains hyperedge IDs. + hgnnp_smoothing_matrix: Optional precomputed HGNN+ smoothing matrix + ``D_v^{-1} H D_e^{-1} H^T``. Size ``(num_nodes, num_nodes)``. + If provided, skips recomputing the matrix from ``hyperedge_index``. + + Returns: + The output node feature matrix. Size ``(num_nodes, out_channels)``. + """ + x = self.theta(x) + + if hgnnp_smoothing_matrix is not None: + x = Hypergraph.smoothing_with_laplacian_matrix(x, hgnnp_smoothing_matrix) + else: + smoothing_matrix = HyperedgeIndex(hyperedge_index).get_sparse_hgnnp_smoothing_matrix( + num_nodes=x.size(0), + ) + x = Hypergraph.smoothing_with_laplacian_matrix(x, smoothing_matrix) + + if not self.is_last: + x = self.activation_fn(x) + if self.batch_norm_1d is not None: + x = self.batch_norm_1d(x) + x = self.dropout(x) + + return x diff --git a/hyperbench/tests/types/hypergraph_test.py b/hyperbench/tests/types/hypergraph_test.py index a2c6f59..447f38f 100644 --- a/hyperbench/tests/types/hypergraph_test.py +++ b/hyperbench/tests/types/hypergraph_test.py @@ -1022,7 +1022,7 @@ def test_hypergraph_smoothing_with_laplacian_applies_dropout_when_enabled(): assert torch.equal(smoothed_laplacian, torch.zeros_like(x)) -def test_hyperedge_index_sparse_normalized_node_degree_handles_isolated_nodes(): +def test_hyperedge_index_sparse_symnormalized_node_degree_handles_isolated_nodes(): # Node 2 is isolated by setting num_nodes=3 while incidences involve only nodes 0 and 1 num_nodes = 3 hyperedge_index = HyperedgeIndex(torch.tensor([[0, 0, 1], [0, 1, 1]])) @@ -1030,7 +1030,7 @@ def test_hyperedge_index_sparse_normalized_node_degree_handles_isolated_nodes(): num_nodes=num_nodes, num_hyperedges=2 ) - node_degree_matrix = hyperedge_index.get_sparse_normalized_node_degree_matrix( + node_degree_matrix = hyperedge_index.get_sparse_symnormalized_node_degree_matrix( incidence_matrix, num_nodes=num_nodes ) @@ -1040,6 +1040,25 @@ def test_hyperedge_index_sparse_normalized_node_degree_handles_isolated_nodes(): assert torch.allclose(node_degree_matrix.to_dense(), expected_node_degree_matrix, atol=1e-6) +def test_hyperedge_index_sparse_rownormalized_node_degree_handles_isolated_nodes(): + # Node 2 is isolated by setting num_nodes=3 while incidences involve only nodes 0 and 1 + num_nodes = 3 + hyperedge_index = HyperedgeIndex(torch.tensor([[0, 0, 1], [0, 1, 1]])) + incidence_matrix = hyperedge_index.get_sparse_incidence_matrix( + num_nodes=num_nodes, num_hyperedges=2 + ) + + node_degree_matrix = hyperedge_index.get_sparse_rownormalized_node_degree_matrix( + incidence_matrix, num_nodes=num_nodes + ) + + # Node 2 is isolated, so its degree is 0. + # Nodes 0 and 1 have degree 2 (as it is connected to 2 hyperedges) and 1 respectively, + # so their normalized degrees are 1/2 and 1 respectively. + expected_node_degree_matrix = torch.diag(torch.tensor([0.5, 1.0, 0.0])) + assert torch.allclose(node_degree_matrix.to_dense(), expected_node_degree_matrix, atol=1e-6) + + def test_hyperedge_index_sparse_normalized_hyperedge_degree_handles_empty_hyperedge_slot(): # Hyperedge 1 is isolated by setting num_hyperedges=2 while incidences involve only hyperedge 0 num_hyperedges = 2 @@ -1106,6 +1125,63 @@ def test_get_sparse_hgnn_laplacian_inferred_equals_explicit(): ) +def test_hyperedge_index_sparse_hgnnp_smoothing_matrix_matches_formula(): + hyperedge_index = HyperedgeIndex(torch.tensor([[0, 0, 1], [0, 1, 1]])) + + smoothing_matrix = hyperedge_index.get_sparse_hgnnp_smoothing_matrix( + num_nodes=3, + num_hyperedges=2, + ) + + incidence_matrix = torch.tensor( + [ + [1.0, 1.0], + [0.0, 1.0], + [0.0, 0.0], + ] + ) + node_degree_inv = torch.diag(torch.tensor([0.5, 1.0, 0.0])) + hyperedge_degree_inv = torch.diag(torch.tensor([1.0, 0.5])) + + expected_smoothing_matrix = torch.mm( + node_degree_inv, + torch.mm( + incidence_matrix, + torch.mm(hyperedge_degree_inv, incidence_matrix.t()), + ), + ) + + assert smoothing_matrix.is_sparse + assert torch.allclose(smoothing_matrix.to_dense(), expected_smoothing_matrix, atol=1e-6) + + +def test_get_sparse_hgnnp_smoothing_matrix_inferred_equals_explicit(): + hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) + + smoothing_inferred = hyperedge_index.get_sparse_hgnnp_smoothing_matrix() + smoothing_explicit = hyperedge_index.get_sparse_hgnnp_smoothing_matrix( + num_nodes=3, + num_hyperedges=2, + ) + + assert smoothing_inferred.is_sparse + assert smoothing_explicit.is_sparse + assert torch.allclose( + smoothing_inferred.to_dense(), + smoothing_explicit.to_dense(), + atol=1e-6, + ) + + +def test_get_sparse_hgnnp_smoothing_matrix_is_row_stochastic_for_non_isolated_nodes(): + hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 2, 0, 3], [0, 0, 0, 1, 1]])) + + smoothing_matrix = hyperedge_index.get_sparse_hgnnp_smoothing_matrix(num_nodes=4).to_dense() + row_sums = smoothing_matrix.sum(dim=1) + + assert torch.allclose(row_sums, torch.ones(4), atol=1e-6) + + def test_get_sparse_incidence_matrix_infers_shape_and_values(): hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) @@ -1156,11 +1232,11 @@ def test_get_sparse_incidence_matrix_sums_duplicate_incidences_when_coalesced(): assert torch.allclose(incidence_matrix.to_dense(), expected_incidence_matrix, atol=1e-6) -def test_get_sparse_normalized_node_degree_matrix_is_expected_diagonal(): +def test_get_sparse_symnormalized_node_degree_matrix_is_expected_diagonal(): hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) incidence_matrix = hyperedge_index.get_sparse_incidence_matrix() # shape (3,2) - node_degree_matrix = hyperedge_index.get_sparse_normalized_node_degree_matrix( + node_degree_matrix = hyperedge_index.get_sparse_symnormalized_node_degree_matrix( incidence_matrix, num_nodes=3, ) @@ -1174,11 +1250,40 @@ def test_get_sparse_normalized_node_degree_matrix_is_expected_diagonal(): assert torch.allclose(node_degree_matrix.to_dense(), torch.diag(expected_diagonal), atol=1e-6) -def test_get_sparse_normalized_node_degree_matrix_infers_num_nodes(): +def test_get_sparse_symnormalized_node_degree_matrix_infers_num_nodes(): hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) incidence_matrix = hyperedge_index.get_sparse_incidence_matrix() # shape (3,2) - node_degree_matrix = hyperedge_index.get_sparse_normalized_node_degree_matrix(incidence_matrix) + node_degree_matrix = hyperedge_index.get_sparse_symnormalized_node_degree_matrix( + incidence_matrix + ) + + assert node_degree_matrix.to_dense().shape == (3, 3) + + +def test_get_sparse_rownormalized_node_degree_matrix_is_expected_diagonal(): + hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) + incidence_matrix = hyperedge_index.get_sparse_incidence_matrix() # shape (3,2) + + node_degree_matrix = hyperedge_index.get_sparse_rownormalized_node_degree_matrix( + incidence_matrix, + num_nodes=3, + ) + + expected_diagonal = torch.tensor([0.5, 1.0, 1.0]) + + assert node_degree_matrix.is_sparse + assert node_degree_matrix.shape == (3, 3) + assert torch.allclose(node_degree_matrix.to_dense(), torch.diag(expected_diagonal), atol=1e-6) + + +def test_get_sparse_rownormalized_node_degree_matrix_infers_num_nodes(): + hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) + incidence_matrix = hyperedge_index.get_sparse_incidence_matrix() # shape (3,2) + + node_degree_matrix = hyperedge_index.get_sparse_rownormalized_node_degree_matrix( + incidence_matrix + ) assert node_degree_matrix.to_dense().shape == (3, 3) diff --git a/hyperbench/types/hypergraph.py b/hyperbench/types/hypergraph.py index 9206adc..83aec3f 100644 --- a/hyperbench/types/hypergraph.py +++ b/hyperbench/types/hypergraph.py @@ -487,7 +487,59 @@ def get_sparse_incidence_matrix( ) return incidence_matrix.coalesce() - def get_sparse_normalized_node_degree_matrix( + def get_sparse_rownormalized_node_degree_matrix( + self, + incidence_matrix: Tensor, + num_nodes: Optional[int] = None, + ) -> Tensor: + """ + Compute the sparse normalized node degree matrix D_n^-1. + The node degree ``d_n[i]`` is the number of hyperedges containing node ``i`` + (i.e., the row-sum of the incidence matrix H). + + Args: + incidence_matrix: The sparse incidence matrix H of shape ``(num_nodes, num_hyperedges)``. + num_nodes: Total number of nodes. If ``None``, inferred from hyperedge index. + + Returns: + The sparse diagonal matrix D_n^-1 of shape ``(num_nodes, num_nodes)``. + """ + device = self.__hyperedge_index.device + num_nodes = num_nodes if num_nodes is not None else self.num_nodes + + # Example: hyperedge_index = [[0, 1, 2, 0], + # [0, 0, 0, 1]] + # hyperedges 0 1 + # -> incidence_matrix H = [[1, 1], node 0 + # [1, 0], node 1 + # [1, 0]] node 2 + # nodes 0 1 2 + # -> row-sum gives node degrees: d_n = [2, 1, 1], shape (num_nodes,) + degrees = torch.sparse.sum(incidence_matrix, dim=1).to_dense() + + # Example: d_n = [2, 1, 1] + # -> degree_inv = [1/2, 1, 1] + degree_inv = degrees.pow(-1) + degree_inv[degree_inv == float("inf")] = 0 + + # Construct the sparse diagonal matrix D_n^{-1} + # Example: degree_inv = [1/2, 1, 1] as the diagonal values, + # diagonal_indices = [[0, 0], + # [1, 1], + # [2, 2]] + # nodes 0 1 2 + # -> D_n^{-1} = [[1/2, 0, 0], node 0 + # [0, 1, 0], node 1 + # [0, 0, 1]] node 2 + diagonal_indices = torch.arange(num_nodes, device=device).unsqueeze(0).repeat(2, 1) + degree_matrix = torch.sparse_coo_tensor( + indices=diagonal_indices, + values=degree_inv, + size=(num_nodes, num_nodes), + ) + return degree_matrix.coalesce() + + def get_sparse_symnormalized_node_degree_matrix( self, incidence_matrix: Tensor, num_nodes: Optional[int] = None, @@ -615,7 +667,7 @@ def get_sparse_hgnn_laplacian( num_hyperedges = num_hyperedges if num_hyperedges is not None else self.num_hyperedges incidence_matrix = self.get_sparse_incidence_matrix(num_nodes, num_hyperedges) - node_degree_matrix = self.get_sparse_normalized_node_degree_matrix( + node_degree_matrix = self.get_sparse_symnormalized_node_degree_matrix( incidence_matrix, num_nodes, ) @@ -635,6 +687,50 @@ def get_sparse_hgnn_laplacian( ) return normalized_laplacian_matrix.coalesce() + def get_sparse_hgnnp_smoothing_matrix( + self, + num_nodes: Optional[int] = None, + num_hyperedges: Optional[int] = None, + ) -> Tensor: + """ + Compute the sparse HGNN+ smoothing matrix for hypergraph mean aggregation. + + Implements: ``M_HGNN+ = D_v^{-1} H D_e^{-1} H^T`` + + This matrix is row-stochastic for non-isolated nodes and corresponds to + the two-stage mean aggregation used by HGNN+: + 1. ``D_e^{-1} H^T X``: mean over nodes in each hyperedge. + 2. ``D_v^{-1} H (...)``: mean over hyperedges incident to each node. + + Args: + num_nodes: Total number of nodes. If ``None``, inferred from hyperedge index. + num_hyperedges: Total number of hyperedges. If ``None``, inferred from hyperedge index. + + Returns: + The sparse HGNN+ smoothing matrix of shape ``(num_nodes, num_nodes)``. + """ + num_nodes = num_nodes if num_nodes is not None else self.num_nodes + num_hyperedges = num_hyperedges if num_hyperedges is not None else self.num_hyperedges + + incidence_matrix = self.get_sparse_incidence_matrix(num_nodes, num_hyperedges) + node_degree_matrix = self.get_sparse_rownormalized_node_degree_matrix( + incidence_matrix, + num_nodes, + ) + hyperedge_degree_matrix = self.get_sparse_normalized_hyperedge_degree_matrix( + incidence_matrix, + num_hyperedges, + ) + + smoothing_matrix = torch.sparse.mm( + node_degree_matrix, + torch.sparse.mm( + incidence_matrix, + torch.sparse.mm(hyperedge_degree_matrix, incidence_matrix.t()), + ), + ) + return smoothing_matrix.coalesce() + def reduce(self, strategy: Literal["clique_expansion"], **kwargs) -> Tensor: """ Reduce the hypergraph to a graph represented by edge index using the specified strategy. From 8706ed4ec22a9e5dccc3ff4a2a92dc5b19337bd8 Mon Sep 17 00:00:00 2001 From: Tiziano Date: Fri, 24 Apr 2026 13:42:06 +0200 Subject: [PATCH 2/5] refactor: moved shared logic out of normalized degree method in Hypergraph --- hyperbench/tests/types/hypergraph_test.py | 47 +++++++++++ hyperbench/types/hypergraph.py | 96 +++++++++++------------ 2 files changed, 91 insertions(+), 52 deletions(-) diff --git a/hyperbench/tests/types/hypergraph_test.py b/hyperbench/tests/types/hypergraph_test.py index 447f38f..7fb95e1 100644 --- a/hyperbench/tests/types/hypergraph_test.py +++ b/hyperbench/tests/types/hypergraph_test.py @@ -1261,6 +1261,53 @@ def test_get_sparse_symnormalized_node_degree_matrix_infers_num_nodes(): assert node_degree_matrix.to_dense().shape == (3, 3) +@pytest.mark.parametrize( + "power, expected_diagonal", + [ + pytest.param(1.0, torch.tensor([2.0, 1.0, 1.0]), id="power_1"), + pytest.param(2.0, torch.tensor([4.0, 1.0, 1.0]), id="power_2"), + pytest.param(-2.0, torch.tensor([0.25, 1.0, 1.0]), id="power_minus_2"), + ], +) +def test_get_sparse_normalized_node_degree_matrix_applies_requested_power_to_node_degrees( + power, expected_diagonal +): + hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) + incidence_matrix = hyperedge_index.get_sparse_incidence_matrix() # shape (3,2) + + node_degree_matrix = hyperedge_index.get_sparse_normalized_node_degree_matrix( + incidence_matrix, + power=power, + ) + + assert node_degree_matrix.is_sparse + assert node_degree_matrix.shape == (3, 3) + assert torch.allclose(node_degree_matrix.to_dense(), torch.diag(expected_diagonal), atol=1e-6) + + +def test_get_sparse_normalized_node_degree_matrix_zeroes_isolated_nodes_for_negative_powers(): + hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0], [0, 0, 1]])) + incidence_matrix = hyperedge_index.get_sparse_incidence_matrix(num_nodes=3) # shape (3,2) + + node_degree_matrix = hyperedge_index.get_sparse_normalized_node_degree_matrix( + incidence_matrix, + power=-1.5, + num_nodes=3, + ) + + expected_diagonal = torch.tensor( + [ + 1 / (2**1.5), + 1.0, + 0.0, + ] + ) + + assert node_degree_matrix.is_sparse + assert node_degree_matrix.shape == (3, 3) + assert torch.allclose(node_degree_matrix.to_dense(), torch.diag(expected_diagonal), atol=1e-6) + + def test_get_sparse_rownormalized_node_degree_matrix_is_expected_diagonal(): hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) incidence_matrix = hyperedge_index.get_sparse_incidence_matrix() # shape (3,2) diff --git a/hyperbench/types/hypergraph.py b/hyperbench/types/hypergraph.py index 83aec3f..b5b9060 100644 --- a/hyperbench/types/hypergraph.py +++ b/hyperbench/types/hypergraph.py @@ -487,6 +487,38 @@ def get_sparse_incidence_matrix( ) return incidence_matrix.coalesce() + def get_sparse_normalized_node_degree_matrix( + self, + incidence_matrix: Tensor, + power: float, + num_nodes: Optional[int] = None, + ) -> Tensor: + """ + Compute a sparse diagonal node degree matrix from row-sums of the incidence matrix. + + Args: + incidence_matrix: The sparse incidence matrix H of shape ``(num_nodes, num_hyperedges)``. + power: Exponent applied to node degrees before placing them on the diagonal. + num_nodes: Total number of nodes. If ``None``, inferred from hyperedge index. + + Returns: + The sparse diagonal matrix of shape ``(num_nodes, num_nodes)``. + """ + device = self.__hyperedge_index.device + num_nodes = num_nodes if num_nodes is not None else self.num_nodes + + degrees = torch.sparse.sum(incidence_matrix, dim=1).to_dense() + normalized_degrees = degrees.pow(power) + normalized_degrees[normalized_degrees == float("inf")] = 0 + + diagonal_indices = torch.arange(num_nodes, device=device).unsqueeze(0).repeat(2, 1) + degree_matrix = torch.sparse_coo_tensor( + indices=diagonal_indices, + values=normalized_degrees, + size=(num_nodes, num_nodes), + ) + return degree_matrix.coalesce() + def get_sparse_rownormalized_node_degree_matrix( self, incidence_matrix: Tensor, @@ -504,9 +536,6 @@ def get_sparse_rownormalized_node_degree_matrix( Returns: The sparse diagonal matrix D_n^-1 of shape ``(num_nodes, num_nodes)``. """ - device = self.__hyperedge_index.device - num_nodes = num_nodes if num_nodes is not None else self.num_nodes - # Example: hyperedge_index = [[0, 1, 2, 0], # [0, 0, 0, 1]] # hyperedges 0 1 @@ -514,30 +543,13 @@ def get_sparse_rownormalized_node_degree_matrix( # [1, 0], node 1 # [1, 0]] node 2 # nodes 0 1 2 - # -> row-sum gives node degrees: d_n = [2, 1, 1], shape (num_nodes,) - degrees = torch.sparse.sum(incidence_matrix, dim=1).to_dense() - - # Example: d_n = [2, 1, 1] - # -> degree_inv = [1/2, 1, 1] - degree_inv = degrees.pow(-1) - degree_inv[degree_inv == float("inf")] = 0 - - # Construct the sparse diagonal matrix D_n^{-1} - # Example: degree_inv = [1/2, 1, 1] as the diagonal values, - # diagonal_indices = [[0, 0], - # [1, 1], - # [2, 2]] - # nodes 0 1 2 - # -> D_n^{-1} = [[1/2, 0, 0], node 0 - # [0, 1, 0], node 1 - # [0, 0, 1]] node 2 - diagonal_indices = torch.arange(num_nodes, device=device).unsqueeze(0).repeat(2, 1) - degree_matrix = torch.sparse_coo_tensor( - indices=diagonal_indices, - values=degree_inv, - size=(num_nodes, num_nodes), + # -> row-sum gives node degrees: d_n = [2, 1, 1] + # -> D_n^{-1} has diagonal [1/2, 1, 1] + return self.get_sparse_normalized_node_degree_matrix( + incidence_matrix=incidence_matrix, + power=-1, + num_nodes=num_nodes, ) - return degree_matrix.coalesce() def get_sparse_symnormalized_node_degree_matrix( self, @@ -556,9 +568,6 @@ def get_sparse_symnormalized_node_degree_matrix( Returns: The sparse diagonal matrix D_n^-1/2 of shape ``(num_nodes, num_nodes)``. """ - device = self.__hyperedge_index.device - num_nodes = num_nodes if num_nodes is not None else self.num_nodes - # Example: hyperedge_index = [[0, 1, 2, 0], # [0, 0, 0, 1]] # hyperedges 0 1 @@ -566,30 +575,13 @@ def get_sparse_symnormalized_node_degree_matrix( # [1, 0], node 1 # [1, 0]] node 2 # nodes 0 1 2 - # -> row-sum gives node degrees: d_n = [2, 1, 1], shape (num_nodes,) - degrees = torch.sparse.sum(incidence_matrix, dim=1).to_dense() - - # Example: d_n = [2, 1, 1] - # -> degree_inv_sqrt = [1/sqrt(2), 1, 1] - degree_inv_sqrt = degrees.pow(-0.5) - degree_inv_sqrt[degree_inv_sqrt == float("inf")] = 0 - - # Construct the sparse diagonal matrix D_n^{-1/2} - # Example: degree_inv_sqrt = [1/sqrt(2), 1, 1] as the diagonal values, - # diagonal_indices = [[0, 0], - # [1, 1], - # [2, 2]] - # nodes 0 1 2 - # -> D_n^{-1/2} = [[1/sqrt(2), 0, 0], node 0 - # [0, 1, 0], node 1 - # [0, 0, 1]] node 2 - diagonal_indices = torch.arange(num_nodes, device=device).unsqueeze(0).repeat(2, 1) - degree_matrix = torch.sparse_coo_tensor( - indices=diagonal_indices, - values=degree_inv_sqrt, - size=(num_nodes, num_nodes), + # -> row-sum gives node degrees: d_n = [2, 1, 1] + # -> D_n^{-1/2} has diagonal [1/sqrt(2), 1, 1] + return self.get_sparse_normalized_node_degree_matrix( + incidence_matrix=incidence_matrix, + power=-0.5, + num_nodes=num_nodes, ) - return degree_matrix.coalesce() def get_sparse_normalized_hyperedge_degree_matrix( self, From ddd64f4b94876ac12a3c94387f1de1281ff0bdac Mon Sep 17 00:00:00 2001 From: Tiziano Date: Mon, 27 Apr 2026 09:58:24 +0200 Subject: [PATCH 3/5] fix: remove fast mode from HGNN and HGNN+ --- examples/hgnn.py | 1 - examples/hgnnp.py | 1 - hyperbench/hlp/hgnn_hlp.py | 3 -- hyperbench/hlp/hgnnp_hlp.py | 8 ++--- hyperbench/models/hgnn.py | 32 +---------------- hyperbench/models/hgnnp.py | 35 +----------------- hyperbench/nn/conv.py | 44 ++++++----------------- hyperbench/tests/types/hypergraph_test.py | 38 ++++++++++---------- hyperbench/types/hypergraph.py | 28 +++++++-------- 9 files changed, 46 insertions(+), 144 deletions(-) diff --git a/examples/hgnn.py b/examples/hgnn.py index b1644aa..b34e13c 100644 --- a/examples/hgnn.py +++ b/examples/hgnn.py @@ -113,7 +113,6 @@ "bias": True, "use_batch_normalization": False, "drop_rate": 0.5, - "fast": False, }, aggregation="mean", lr=0.01, diff --git a/examples/hgnnp.py b/examples/hgnnp.py index fbe2d2e..daf5a33 100644 --- a/examples/hgnnp.py +++ b/examples/hgnnp.py @@ -109,7 +109,6 @@ "bias": True, "use_batch_normalization": False, "drop_rate": 0.5, - "fast": False, }, aggregation="mean", lr=0.01, diff --git a/hyperbench/hlp/hgnn_hlp.py b/hyperbench/hlp/hgnn_hlp.py index 76934b1..7c308fa 100644 --- a/hyperbench/hlp/hgnn_hlp.py +++ b/hyperbench/hlp/hgnn_hlp.py @@ -21,7 +21,6 @@ class HGNNEncoderConfig(TypedDict): bias: Whether to include bias terms. Defaults to ``True``. use_batch_normalization: Whether to use batch normalization. Defaults to ``False``. drop_rate: Dropout rate. Defaults to ``0.5``. - fast: Whether to cache the HGNN Laplacian. Defaults to ``False``. """ in_channels: int @@ -30,7 +29,6 @@ class HGNNEncoderConfig(TypedDict): bias: NotRequired[bool] use_batch_normalization: NotRequired[bool] drop_rate: NotRequired[float] - fast: NotRequired[bool] class HGNNHlpModule(HlpModule): @@ -66,7 +64,6 @@ def __init__( bias=encoder_config.get("bias", True), use_batch_normalization=encoder_config.get("use_batch_normalization", False), drop_rate=encoder_config.get("drop_rate", 0.5), - fast=encoder_config.get("fast", False), ) decoder = SLP(in_channels=encoder_config["out_channels"], out_channels=1) diff --git a/hyperbench/hlp/hgnnp_hlp.py b/hyperbench/hlp/hgnnp_hlp.py index a3b66b8..5c5a4dd 100644 --- a/hyperbench/hlp/hgnnp_hlp.py +++ b/hyperbench/hlp/hgnnp_hlp.py @@ -1,15 +1,14 @@ from torch import Tensor, nn, optim from typing import Literal, Optional, TypedDict - from torchmetrics import MetricCollection from typing_extensions import NotRequired - -from hyperbench.hlp.hlp import HlpModule from hyperbench.models import HGNNP, SLP from hyperbench.nn import HyperedgeAggregator from hyperbench.types import HData from hyperbench.utils import Stage +from hyperbench.hlp.hlp import HlpModule + class HGNNPEncoderConfig(TypedDict): """ @@ -22,7 +21,6 @@ class HGNNPEncoderConfig(TypedDict): bias: Whether to include bias terms. Defaults to ``True``. use_batch_normalization: Whether to use batch normalization. Defaults to ``False``. drop_rate: Dropout rate. Defaults to ``0.5``. - fast: Whether to cache the HGNN+ smoothing matrix. Defaults to ``False``. """ in_channels: int @@ -31,7 +29,6 @@ class HGNNPEncoderConfig(TypedDict): bias: NotRequired[bool] use_batch_normalization: NotRequired[bool] drop_rate: NotRequired[float] - fast: NotRequired[bool] class HGNNPHlpModule(HlpModule): @@ -67,7 +64,6 @@ def __init__( bias=encoder_config.get("bias", True), use_batch_normalization=encoder_config.get("use_batch_normalization", False), drop_rate=encoder_config.get("drop_rate", 0.5), - fast=encoder_config.get("fast", False), ) decoder = SLP(in_channels=encoder_config["out_channels"], out_channels=1) diff --git a/hyperbench/models/hgnn.py b/hyperbench/models/hgnn.py index 0ee3768..27b7ec7 100644 --- a/hyperbench/models/hgnn.py +++ b/hyperbench/models/hgnn.py @@ -1,7 +1,5 @@ -from typing import Optional from torch import Tensor, nn from hyperbench.nn import HGNNConv -from hyperbench.types import HyperedgeIndex class HGNN(nn.Module): @@ -21,10 +19,6 @@ class HGNN(nn.Module): bias: If set to ``False``, the layer will not learn the bias parameter. Defaults to ``True``. use_batch_normalization: If set to ``True``, layers will use batch normalization. Defaults to ``False``. drop_rate: Dropout ratio. Defaults to ``0.5``. - fast: If set to ``True``, the HGNN Laplacian will be computed once and cached. - Defaults to ``False`` as the original paper does not mention caching. - Setting it to ``True`` can speed up training when the hypergraph structure is static. - For example, negatives added only before training (not per batch/epoch) will not change the hypergraph topology, so caching is safe. """ def __init__( @@ -35,11 +29,8 @@ def __init__( bias: bool = True, use_batch_normalization: bool = False, drop_rate: float = 0.5, - fast: bool = False, ): super().__init__() - self.fast = fast - self.cached_hgnn_laplacian_matrix: Optional[Tensor] = None self.layers = nn.ModuleList( [ @@ -68,12 +59,6 @@ def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: The second layer is the output layer (no activation/dropout) and maps ``hidden_channels -> num_classes``. - When ``fast=True``, the HGNN Laplacian ``D_n^{-1/2} H D_e^{-1} H^T D_n^{-1/2}`` - is computed once from ``hyperedge_index`` and cached. The cache is invalidated only when - ``num_nodes`` changes (e.g., due to negative sampling adding nodes across batches). - This is safe because the HGNN Laplacian depends solely on the hypergraph topology, - unlike HyperGCN's Laplacian which depends on node features via random projection. - Args: x: Input node feature matrix. Size ``(num_nodes, in_channels)``. hyperedge_index: Hyperedge incidence in COO format. Size ``(2, num_incidences)``, @@ -82,21 +67,6 @@ def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: Returns: The output node feature matrix. Size ``(num_nodes, num_classes)``. """ - if not self.fast: - for layer in self.layers: - x = layer(x, hyperedge_index) - return x - - should_not_use_cached = ( - self.cached_hgnn_laplacian_matrix is None - or self.cached_hgnn_laplacian_matrix.size(0) != x.size(0) - ) - - if should_not_use_cached: - self.cached_hgnn_laplacian_matrix = HyperedgeIndex( - hyperedge_index - ).get_sparse_hgnn_laplacian(num_nodes=x.size(0)) - for layer in self.layers: - x = layer(x, hyperedge_index, hgnn_laplacian_matrix=self.cached_hgnn_laplacian_matrix) + x = layer(x, hyperedge_index) return x diff --git a/hyperbench/models/hgnnp.py b/hyperbench/models/hgnnp.py index 02f00ad..b3f8f7e 100644 --- a/hyperbench/models/hgnnp.py +++ b/hyperbench/models/hgnnp.py @@ -1,9 +1,5 @@ -from typing import Optional - from torch import Tensor, nn - from hyperbench.nn import HGNNPConv -from hyperbench.types import HyperedgeIndex class HGNNP(nn.Module): @@ -20,9 +16,6 @@ class HGNNP(nn.Module): bias: If set to ``False``, the layer will not learn the bias parameter. Defaults to ``True``. use_batch_normalization: If set to ``True``, layers will use batch normalization. Defaults to ``False``. drop_rate: Dropout ratio. Defaults to ``0.5``. - fast: If set to ``True``, the HGNN+ smoothing matrix will be computed once and cached. - Defaults to ``False``. Since the matrix depends only on hypergraph topology, - caching is safe whenever the incidence structure is unchanged. """ def __init__( @@ -33,11 +26,8 @@ def __init__( bias: bool = True, use_batch_normalization: bool = False, drop_rate: float = 0.5, - fast: bool = False, ): super().__init__() - self.fast = fast - self.cached_hgnnp_smoothing_matrix: Optional[Tensor] = None self.layers = nn.ModuleList( [ @@ -62,10 +52,6 @@ def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: r""" Apply two stacked ``HGNNPConv`` layers to produce node embeddings. - When ``fast=True``, the HGNN+ smoothing matrix ``D_v^{-1} H D_e^{-1} H^T`` - is computed once from ``hyperedge_index`` and cached. The cache is invalidated - only when ``num_nodes`` changes. - Args: x: Input node feature matrix. Size ``(num_nodes, in_channels)``. hyperedge_index: Hyperedge incidence in COO format. Size ``(2, num_incidences)``, @@ -74,25 +60,6 @@ def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: Returns: The output node feature matrix. Size ``(num_nodes, num_classes)``. """ - if not self.fast: - for layer in self.layers: - x = layer(x, hyperedge_index) - return x - - should_not_use_cached = ( - self.cached_hgnnp_smoothing_matrix is None - or self.cached_hgnnp_smoothing_matrix.size(0) != x.size(0) - ) - - if should_not_use_cached: - self.cached_hgnnp_smoothing_matrix = HyperedgeIndex( - hyperedge_index - ).get_sparse_hgnnp_smoothing_matrix(num_nodes=x.size(0)) - for layer in self.layers: - x = layer( - x, - hyperedge_index, - hgnnp_smoothing_matrix=self.cached_hgnnp_smoothing_matrix, - ) + x = layer(x, hyperedge_index) return x diff --git a/hyperbench/nn/conv.py b/hyperbench/nn/conv.py index 26b8367..f4e1fec 100644 --- a/hyperbench/nn/conv.py +++ b/hyperbench/nn/conv.py @@ -123,12 +123,7 @@ def __init__( self.dropout = nn.Dropout(drop_rate) self.theta = nn.Linear(in_channels, out_channels, bias=bias) - def forward( - self, - x: Tensor, - hyperedge_index: Tensor, - hgnn_laplacian_matrix: Optional[Tensor] = None, - ) -> Tensor: + def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: r""" Apply one HGNN convolution layer: project features, smooth via hypergraph Laplacian, then apply activation, batch norm, and dropout (unless this is the last layer). @@ -144,22 +139,16 @@ def forward( x: Input node feature matrix. Size ``(num_nodes, in_channels)``. hyperedge_index: Hyperedge incidence in COO format. Size ``(2, num_incidences)``, where row 0 contains node IDs and row 1 contains hyperedge IDs. - hgnn_laplacian_matrix: Optional precomputed HGNN Laplacian - ``D_n^{-1/2} H D_e^{-1} H^T D_n^{-1/2}``. Size ``(num_nodes, num_nodes)``. - If provided, skips recomputing the Laplacian from ``hyperedge_index``. Returns: The output node feature matrix. Size ``(num_nodes, out_channels)``. """ x = self.theta(x) - if hgnn_laplacian_matrix is not None: - x = Hypergraph.smoothing_with_laplacian_matrix(x, hgnn_laplacian_matrix) - else: - laplacian = HyperedgeIndex(hyperedge_index).get_sparse_hgnn_laplacian( - num_nodes=x.size(0), - ) - x = Hypergraph.smoothing_with_laplacian_matrix(x, laplacian) + smoothing_matrix = HyperedgeIndex(hyperedge_index).get_sparse_hgnn_smoothing_matrix( + num_nodes=x.size(0), + ) + x = Hypergraph.smoothing_with_matrix(x, smoothing_matrix) if not self.is_last: x = self.activation_fn(x) @@ -207,13 +196,8 @@ def __init__( self.dropout = nn.Dropout(drop_rate) self.theta = nn.Linear(in_channels, out_channels, bias=bias) - def forward( - self, - x: Tensor, - hyperedge_index: Tensor, - hgnnp_smoothing_matrix: Optional[Tensor] = None, - ) -> Tensor: - r""" + def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: + """ Apply one HGNN+ convolution layer using row-stochastic hypergraph smoothing. The full per-layer formula is: @@ -223,22 +207,16 @@ def forward( x: Input node feature matrix. Size ``(num_nodes, in_channels)``. hyperedge_index: Hyperedge incidence in COO format. Size ``(2, num_incidences)``, where row 0 contains node IDs and row 1 contains hyperedge IDs. - hgnnp_smoothing_matrix: Optional precomputed HGNN+ smoothing matrix - ``D_v^{-1} H D_e^{-1} H^T``. Size ``(num_nodes, num_nodes)``. - If provided, skips recomputing the matrix from ``hyperedge_index``. Returns: The output node feature matrix. Size ``(num_nodes, out_channels)``. """ x = self.theta(x) - if hgnnp_smoothing_matrix is not None: - x = Hypergraph.smoothing_with_laplacian_matrix(x, hgnnp_smoothing_matrix) - else: - smoothing_matrix = HyperedgeIndex(hyperedge_index).get_sparse_hgnnp_smoothing_matrix( - num_nodes=x.size(0), - ) - x = Hypergraph.smoothing_with_laplacian_matrix(x, smoothing_matrix) + smoothing_matrix = HyperedgeIndex(hyperedge_index).get_sparse_hgnnp_smoothing_matrix( + num_nodes=x.size(0), + ) + x = Hypergraph.smoothing_with_matrix(x, smoothing_matrix) if not self.is_last: x = self.activation_fn(x) diff --git a/hyperbench/tests/types/hypergraph_test.py b/hyperbench/tests/types/hypergraph_test.py index 7fb95e1..d92db30 100644 --- a/hyperbench/tests/types/hypergraph_test.py +++ b/hyperbench/tests/types/hypergraph_test.py @@ -980,46 +980,44 @@ def test_remove_hyperedges_with_fewer_than_k_nodes_returns_self(): pytest.param(0.0, id="with_dropout"), ], ) -def test_hypergraph_smoothing_with_laplacian_does_not_apply_dropout_when_not_provided(dropout): +def test_hypergraph_smoothing_with_matrix_does_not_apply_dropout_when_not_provided(dropout): x = torch.tensor([[1.0], [2.0]]) - laplacian = torch.tensor([[1.0, 0.0], [0.0, 1.0]]).to_sparse() + matrix = torch.tensor([[1.0, 0.0], [0.0, 1.0]]).to_sparse() with patch( "hyperbench.types.hypergraph.sparse_dropout", - return_value=torch.zeros_like(laplacian), + return_value=torch.zeros_like(matrix), ) as mock_sparse_dropout: if dropout is not None: - smoothed_laplacian = Hypergraph.smoothing_with_laplacian_matrix( - x, laplacian, drop_rate=dropout - ) + smoothed_matrix = Hypergraph.smoothing_with_matrix(x, matrix, drop_rate=dropout) else: - smoothed_laplacian = Hypergraph.smoothing_with_laplacian_matrix(x, laplacian) + smoothed_matrix = Hypergraph.smoothing_with_matrix(x, matrix) mock_sparse_dropout.assert_not_called() - # It is equal to x as the laplacian is identity and no dropout is applied - assert smoothed_laplacian.shape == x.shape - assert torch.equal(smoothed_laplacian, x) + # It is equal to x as the matrix is identity and no dropout is applied + assert smoothed_matrix.shape == x.shape + assert torch.equal(smoothed_matrix, x) -def test_hypergraph_smoothing_with_laplacian_applies_dropout_when_enabled(): +def test_hypergraph_smoothing_with_matrix_applies_dropout_when_enabled(): x = torch.tensor([[1.0], [2.0]]) - laplacian = torch.tensor([[1.0, 0.0], [0.0, 1.0]]).to_sparse() + matrix = torch.tensor([[1.0, 0.0], [0.0, 1.0]]).to_sparse() with patch( "hyperbench.types.hypergraph.sparse_dropout", - return_value=torch.zeros_like(laplacian), + return_value=torch.zeros_like(matrix), ) as mock_sparse_dropout: - smoothed_laplacian = Hypergraph.smoothing_with_laplacian_matrix(x, laplacian, drop_rate=0.7) + smoothed_matrix = Hypergraph.smoothing_with_matrix(x, matrix, drop_rate=0.7) mock_sparse_dropout.assert_called_once() called_matrix, called_drop_rate = mock_sparse_dropout.call_args.args - assert called_matrix is laplacian + assert called_matrix is matrix assert called_drop_rate == 0.7 - assert smoothed_laplacian.shape == x.shape - assert torch.equal(smoothed_laplacian, torch.zeros_like(x)) + assert smoothed_matrix.shape == x.shape + assert torch.equal(smoothed_matrix, torch.zeros_like(x)) def test_hyperedge_index_sparse_symnormalized_node_degree_handles_isolated_nodes(): @@ -1080,7 +1078,7 @@ def test_hyperedge_index_sparse_normalized_hyperedge_degree_handles_empty_hypere def test_hyperedge_index_sparse_hgnn_laplacian_matches_formula(): hyperedge_index = HyperedgeIndex(torch.tensor([[0, 0, 1], [0, 1, 1]])) - laplacian = hyperedge_index.get_sparse_hgnn_laplacian(num_nodes=3, num_hyperedges=2) + laplacian = hyperedge_index.get_sparse_hgnn_smoothing_matrix(num_nodes=3, num_hyperedges=2) incidence_matrix = torch.tensor( [ @@ -1110,8 +1108,8 @@ def test_hyperedge_index_sparse_hgnn_laplacian_matches_formula(): def test_get_sparse_hgnn_laplacian_inferred_equals_explicit(): hyperedge_index = HyperedgeIndex(torch.tensor([[0, 1, 0, 2], [0, 0, 1, 1]])) - laplacian_inferred = hyperedge_index.get_sparse_hgnn_laplacian() - laplacian_explicit = hyperedge_index.get_sparse_hgnn_laplacian( + laplacian_inferred = hyperedge_index.get_sparse_hgnn_smoothing_matrix() + laplacian_explicit = hyperedge_index.get_sparse_hgnn_smoothing_matrix( num_nodes=3, num_hyperedges=2, ) diff --git a/hyperbench/types/hypergraph.py b/hyperbench/types/hypergraph.py index b5b9060..f8398d1 100644 --- a/hyperbench/types/hypergraph.py +++ b/hyperbench/types/hypergraph.py @@ -345,34 +345,32 @@ def from_hyperedge_index(cls, hyperedge_index: Tensor) -> "Hypergraph": return cls(hyperedges=hyperedges) @staticmethod - def smoothing_with_laplacian_matrix( + def smoothing_with_matrix( x: Tensor, - laplacian_matrix: Tensor, + matrix: Tensor, drop_rate: float = 0.0, ) -> Tensor: """ - Return the feature matrix smoothed with a Laplacian matrix. - - Computes ``L @ X`` where ``L`` is the Laplacian matrix (e.g., the HGNN - hypergraph Laplacian ``D_n^{-1/2} H D_e^{-1} H^T D_n^{-1/2}``). + Return the feature matrix smoothed with a smoothing matrix. + Computes ``M @ X`` where ``M`` is the smoothing matrix and ``X`` is the node feature matrix. Args: - x: Node feature matrix. Size ``(|V|, C)``. - laplacian_matrix: The Laplacian matrix. Size ``(|V|, |V|)``. - drop_rate: Randomly dropout the connections in the Laplacian with probability ``drop_rate``. Defaults to ``0.0``. + x: Node feature matrix. Size ``(num_nodes, C)``. + matrix: The smoothing matrix. Size ``(num_nodes, num_nodes)``. + drop_rate: Randomly dropout the connections in the smoothing matrix with probability ``drop_rate``. Defaults to ``0.0``. Returns: - The smoothed feature matrix. Size ``(|V|, C)``. + The smoothed feature matrix. Size ``(num_nodes, C)``. """ if drop_rate > 0.0: - laplacian_matrix = sparse_dropout(laplacian_matrix, drop_rate) - return laplacian_matrix.matmul(x) + matrix = sparse_dropout(matrix, drop_rate) + return matrix.matmul(x) class HyperedgeIndex: """ A wrapper for hyperedge index representation. - Hyperedge index is a tensor of shape (2, |E|) that encodes the relationships between nodes and hyperedges. + Hyperedge index is a tensor of shape ``(2, num_incidences)`` that encodes the relationships between nodes and hyperedges. Each column in the tensor represents an incidence between a node and a hyperedge, with the first row containing node indices and the second row containing corresponding hyperedge indices. @@ -388,7 +386,7 @@ class HyperedgeIndex: The number of hyperedges is 2 (hyperedges 0 and 1). Args: - hyperedge_index: A tensor of shape ``(2, |E|)`` representing hyperedges, where each column is (node, hyperedge). + hyperedge_index: A tensor of shape ``(2, num_incidences)`` representing hyperedges, where each column is (node, hyperedge). """ def __init__(self, hyperedge_index: Tensor): @@ -633,7 +631,7 @@ def get_sparse_normalized_hyperedge_degree_matrix( ) return degree_matrix.coalesce() - def get_sparse_hgnn_laplacian( + def get_sparse_hgnn_smoothing_matrix( self, num_nodes: Optional[int] = None, num_hyperedges: Optional[int] = None, From 5759f3eb3b159d9dd556e20933fab2bcd7de8732 Mon Sep 17 00:00:00 2001 From: Tiziano Date: Mon, 27 Apr 2026 10:09:47 +0200 Subject: [PATCH 4/5] docs: add comments in HGNN forward for HLP --- hyperbench/hlp/hgnnp_hlp.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hyperbench/hlp/hgnnp_hlp.py b/hyperbench/hlp/hgnnp_hlp.py index 5c5a4dd..7ddfc36 100644 --- a/hyperbench/hlp/hgnnp_hlp.py +++ b/hyperbench/hlp/hgnnp_hlp.py @@ -105,11 +105,19 @@ def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: if self.encoder is None: raise ValueError("Encoder is not defined for this HLP module.") + # Encode: produce node embeddings using HGNN+, no graph reduction is applied + # Example: x: (num_nodes, in_channels) + # -> node_embeddings: (num_nodes, out_channels), out_channels) node_embeddings: Tensor = self.encoder(x, hyperedge_index) + + # Aggregate: pool node embeddings per hyperedge + # shape: (num_hyperedges, out_channels) hyperedge_embeddings = HyperedgeAggregator(hyperedge_index, node_embeddings).pool( self.aggregation ) + # Decode: linear projection to scalar score per hyperedge + # shape: (num_hyperedges, 1) -> squeeze -> (num_hyperedges,) scores: Tensor = self.decoder(hyperedge_embeddings).squeeze(-1) return scores From 5d2fc9bd757c4d9ff960887faf45377c0d8f7dc9 Mon Sep 17 00:00:00 2001 From: Tiziano Date: Mon, 27 Apr 2026 10:11:51 +0200 Subject: [PATCH 5/5] docs: fix docstrings --- hyperbench/models/hgnn.py | 2 +- hyperbench/models/hgnnp.py | 2 +- hyperbench/models/hypergcn.py | 2 +- hyperbench/nn/conv.py | 4 ++-- hyperbench/tests/types/graph_test.py | 2 +- hyperbench/types/graph.py | 8 ++++---- hyperbench/types/hypergraph.py | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/hyperbench/models/hgnn.py b/hyperbench/models/hgnn.py index 27b7ec7..7bdd055 100644 --- a/hyperbench/models/hgnn.py +++ b/hyperbench/models/hgnn.py @@ -52,7 +52,7 @@ def __init__( ) def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: - r""" + """ Apply two stacked ``HGNNConv`` layers to produce node embeddings. The first layer applies ReLU + dropout and maps ``in_channels -> hidden_channels``. diff --git a/hyperbench/models/hgnnp.py b/hyperbench/models/hgnnp.py index b3f8f7e..4e72e3c 100644 --- a/hyperbench/models/hgnnp.py +++ b/hyperbench/models/hgnnp.py @@ -49,7 +49,7 @@ def __init__( ) def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: - r""" + """ Apply two stacked ``HGNNPConv`` layers to produce node embeddings. Args: diff --git a/hyperbench/models/hypergcn.py b/hyperbench/models/hypergcn.py index 8d3b72b..a086edb 100644 --- a/hyperbench/models/hypergcn.py +++ b/hyperbench/models/hypergcn.py @@ -61,7 +61,7 @@ def __init__( ) def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: - r""" + """ The forward function. Args: diff --git a/hyperbench/nn/conv.py b/hyperbench/nn/conv.py index f4e1fec..e18a61a 100644 --- a/hyperbench/nn/conv.py +++ b/hyperbench/nn/conv.py @@ -45,7 +45,7 @@ def forward( hyperedge_index: Tensor, gcn_laplacian_matrix: Optional[Tensor] = None, ) -> Tensor: - r""" + """ The forward function. Args: @@ -124,7 +124,7 @@ def __init__( self.theta = nn.Linear(in_channels, out_channels, bias=bias) def forward(self, x: Tensor, hyperedge_index: Tensor) -> Tensor: - r""" + """ Apply one HGNN convolution layer: project features, smooth via hypergraph Laplacian, then apply activation, batch norm, and dropout (unless this is the last layer). diff --git a/hyperbench/tests/types/graph_test.py b/hyperbench/tests/types/graph_test.py index ef78905..574d809 100644 --- a/hyperbench/tests/types/graph_test.py +++ b/hyperbench/tests/types/graph_test.py @@ -310,7 +310,7 @@ def test_cyclic_graph(): ], ) def test_smoothing_with_laplacian_output_shape_matches_x_shape(num_nodes, num_features): - """Output shape should match input node feature matrix X shape (|V|, C).""" + """Output shape should match input node feature matrix X shape (num_nodes, C).""" x = torch.randn(num_nodes, num_features) edge_index = torch.tensor([[i, (i + 1) % num_nodes] for i in range(num_nodes)]).T diff --git a/hyperbench/types/graph.py b/hyperbench/types/graph.py index 755bdc8..2f4c667 100644 --- a/hyperbench/types/graph.py +++ b/hyperbench/types/graph.py @@ -111,16 +111,16 @@ def smoothing_with_laplacian_matrix( laplacian_matrix: Tensor, drop_rate: float = 0.0, ) -> Tensor: - r""" + """ Return the feature matrix smoothed with a Laplacian matrix. Args: - x: Node feature matrix. Size ``(|V|, C)``. - laplacian_matrix: The Laplacian matrix. Size ``(|V|, |V|)``. + x: Node feature matrix. Size ``(num_nodes, C)``. + laplacian_matrix: The Laplacian matrix. Size ``(num_nodes, num_nodes)``. drop_rate: Randomly dropout the connections in the Laplacian with probability ``drop_rate``. Defaults to ``0.0``. Returns: - The smoothed feature matrix. Size ``(|V|, C)``. + The smoothed feature matrix. Size ``(num_nodes, C)``. """ if drop_rate > 0.0: laplacian_matrix = utils.sparse_dropout(laplacian_matrix, drop_rate) diff --git a/hyperbench/types/hypergraph.py b/hyperbench/types/hypergraph.py index f8398d1..43197c6 100644 --- a/hyperbench/types/hypergraph.py +++ b/hyperbench/types/hypergraph.py @@ -785,7 +785,7 @@ def reduce_to_edge_index_on_random_direction( Reference implementation: `source `_. Args: - x: Node feature matrix. Size ``(|V|, C)``. + x: Node feature matrix. Size ``(num_nodes, C)``. with_mediators: Whether to use mediator to transform the hyperedges to edges in the graph. Defaults to ``False``. remove_selfloops: Whether to remove self-loops. Defaults to ``True``. return_weights: Whether to return the DHG-style reduced-edge weights alongside the edge index. Defaults to ``False``.