Skip to content

Types

hypertorch.types

GraphReductionStrategy = GraphReductionStrategyEnum | GraphReductionStrategyLiteral module-attribute

Type for supported graph reduction strategies, either as an enum or a string literal.

GraphReductionStrategyLiteral = Literal['clique_expansion'] module-attribute

Literal type for supported hypergraph-to-graph reduction strategies.

Neighborhood = set[int] module-attribute

Set of node IDs adjacent to a node or hyperedge.

Task = TaskEnum | TaskLiteral module-attribute

Type for supported hypergraph learning tasks, either as a TaskEnum or a string literal.

TaskLiteral = Literal['hyperlink-prediction', 'node-classification'] module-attribute

Literal type for supported hypergraph learning tasks.

CkptStrategy = str | Path module-attribute

Checkpoint selection strategy ("best" or "last") or checkpoint path.

TestResult = Mapping[str, float] module-attribute

Mapping from metric names to scalar test results.

EdgeIndex

A wrapper for edge index representation of a graph. Edge index is a tensor of shape (2, num_edges) where the first row contains source node indices and the second row contains destination node indices for each edge.

Examples:

>>> edge_index = [[0, 1, 2],
...               [1, 0, 3]]

This represents a graph with edges (0, 1), (1, 0), and (2, 3). The number of nodes in this graph is 4 (nodes 0, 1, 2, and 3) and the number of edges is 3.

Source code in hypertorch/types/graph.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
class EdgeIndex:
    """
    A wrapper for edge index representation of a graph.
    Edge index is a tensor of shape ``(2, num_edges)`` where the first row contains source
        node indices
    and the second row contains destination node indices for each edge.

    Examples:
        >>> edge_index = [[0, 1, 2],
        ...               [1, 0, 3]]

        This represents a graph with edges (0, 1), (1, 0), and (2, 3).
        The number of nodes in this graph is 4 (nodes 0, 1, 2, and 3) and the number of edges is 3.
    """

    def __init__(
        self,
        edge_index: Tensor,
        edge_weights: Tensor | None = None,
    ):
        """
        Initialize the edge index wrapper.

        Args:
            edge_index: Tensor of shape ``(2, num_edges)`` representing graph edges.
            edge_weights: Optional tensor of shape ``(num_edges,)`` containing edge weights.
        """
        self.__edge_index: Tensor = edge_index
        self.__validate_edge_weights(edge_weights)
        self.__edge_weights: Tensor | None = edge_weights

    @property
    def item(self) -> Tensor:
        """
        Return the edge index tensor.
        """
        return self.__edge_index

    @property
    def edge_weights(self) -> Tensor | None:
        """
        Return the edge weight tensor, if present.
        """
        return self.__edge_weights

    @property
    def max_node_id(self) -> int:
        """
        Return the maximum node ID in the edge index.
        """
        if self.__edge_index.size(1) < 1:
            return -1
        return int(self.__edge_index.max())

    @property
    def num_edges(self) -> int:
        """
        Return the number of edges in the graph.
        """
        if self.__edge_index.size(1) < 1:
            return 0
        # Number of edges is the number of columns in edge_index, which is dim=1,
        # as each column represents an edge (source, destination)
        return self.__edge_index.size(1)

    @property
    def num_nodes(self) -> int:
        """
        Return the number of nodes in the graph.
        """
        if self.__edge_index.size(1) < 1:
            return 0
        unique_nodes = torch.unique(self.__edge_index)
        return len(unique_nodes)

    def add_selfloops(
        self,
        num_nodes: int | None = None,
        with_duplicate_removal: bool = True,
    ) -> EdgeIndex:
        """
        Add self-loops to each node in the edge index.

        Examples:
            >>> edge_index = [[0, 1, 2],
            ...               [1, 0, 3]]
            >>> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3],
            ...                              [1, 0, 3, 0, 1, 2, 3]]

            When ``num_nodes`` is higher than the number of nodes in ``edge_index``,
            self-loops are added for all nodes from ``0`` to ``num_nodes - 1``,
            including nodes not present in the original edges:

            >>> edge_index = [[0, 1, 2],
            ...               [1, 0, 3]]
            >>> num_nodes = 6
            >>> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3, 4, 5],
            ...                              [1, 0, 3, 0, 1, 2, 3, 4, 5]]

        Args:
            num_nodes: Total number of nodes. When provided, self-loops are added for nodes ``0``
                to ``num_nodes - 1``. When ``None``, defaults to ``self.num_nodes``.
                This parameter is important when ``edge_index`` does not contain all nodes
                (e.g., some nodes are isolated and have no edges or have been removed),
                as it ensures that the resulting Laplacian matrix has the correct size and includes
                all nodes. For instance, for self-loops.
            with_duplicate_removal: Whether to remove duplicate edges after adding self-loops.
                Defaults to ``True``.

        Returns:
            edge_index: This `EdgeIndex` instance with self-loops added.

        Raises:
            ValueError: If the input edge index has no edges (i.e., ``shape (2, 0)``).
        """
        num_selfloop_nodes = self.num_nodes if num_nodes is None else num_nodes
        self.__validate_num_nodes(num_selfloop_nodes)

        device = self.__edge_index.device
        src, dest = self.__edge_index[0], self.__edge_index[1]

        # Add self-loops: A_hat = A + I (works as we assume node indices are in [0, num_nodes-1])
        # Example: edge_index = [[0, 1, 2],
        #                        [1, 0, 3]], num_nodes = None
        #          -> num_selfloop_nodes = 4 (self.num_nodes, as num_nodes is None)
        #          -> selfloop_indices = [0, 1, 2, 3]
        #          -> src = [0, 1, 2, 0, 1, 2, 3]
        #          -> dest = [1, 0, 3, 0, 1, 2, 3]
        #          -> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3],
        #                                          [1, 0, 3, 0, 1, 2, 3]]
        #
        # Example with num_nodes=6 (higher than 4 nodes in edge_index):
        #          -> num_selfloop_nodes = 6
        #          -> selfloop_indices = [0, 1, 2, 3, 4, 5]
        #          -> src = [0, 1, 2, 0, 1, 2, 3, 4, 5]
        #          -> dest = [1, 0, 3, 0, 1, 2, 3, 4, 5]
        #          -> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3, 4, 5],
        #                                          [1, 0, 3, 0, 1, 2, 3, 4, 5]]
        selfloop_indices = torch.arange(
            num_selfloop_nodes,
            dtype=self.__edge_index.dtype,
            device=device,
        )
        src = torch.cat([src, selfloop_indices])
        dest = torch.cat([dest, selfloop_indices])
        edge_index_with_selfloops = torch.stack([src, dest], dim=0)

        self.__edge_index = edge_index_with_selfloops

        if self.__edge_weights is not None:
            # Set weights of self-loops to 1
            selfloop_weights = torch.ones(
                num_selfloop_nodes,
                device=device,
                dtype=self.__edge_weights.dtype,
            )
            self.__edge_weights = torch.cat([self.__edge_weights, selfloop_weights])

        if with_duplicate_removal:
            self.remove_duplicate_edges()

        return self

    def get_sparse_adjacency_matrix(
        self,
        num_nodes: int | None = None,
        use_edge_weights: bool = False,
    ) -> Tensor:
        """
        Compute the sparse adjacency matrix from a graph edge index.
        To get the normalized adjacency matrix, add self-loops to the edge_index.

        Examples:
            >>> edge_index = [[0, 1, 2],
            ...               [1, 0, 3]]
            >>> num_nodes = 4
            >>> adj_values = [1, 1, 1]
            >>> adj_indices = [[0, 1, 2],
            ...                [1, 0, 3]]
            >>>                0  1  2  3
            ... adj_matrix = [[0, 1, 0, 0], 0
            ...               [1, 0, 0, 0], 1
            ...               [0, 0, 0, 1], 2
            ...               [0, 0, 1, 0]] 3

        Args:
            num_nodes: The number of nodes in the graph. Defaults to ``None``.
                If ``None``, it will be inferred from ``self.num_nodes``.
                Note that the node indices in ``edge_index`` are assumed to be in the
                range [0, num_nodes-1].
            use_edge_weights: Whether to use edge weights if they are present.
                If ``False``, all edges will have weight 1. Defaults to ``False``.

        Returns:
            adjacency: The sparse adjacency matrix of shape ``(num_nodes, num_nodes)``.
        """
        num_nodes = self.num_nodes if num_nodes is None else num_nodes
        self.__validate_num_nodes(num_nodes)

        device = self.__edge_index.device
        src, dest = self.__edge_index

        # Example: edge_index = [[0, 1, 2, 3],
        #                       [1, 0, 3, 2]]
        #          use_edge_weights = False
        #          -> adj_values = [1, 1, 1, 1]
        #          -> adj_indices = [[0, 1, 2, 3],
        #                            [1, 0, 3, 2]]
        #                   0  1  2  3
        #          -> A = [[0, 1, 0, 0], 1
        #                  [1, 0, 0, 0], 0
        #                  [0, 0, 0, 1], 3
        #                  [0, 0, 1, 0]] 2
        if use_edge_weights:
            adj_values = (
                self.edge_weights
                if self.edge_weights is not None
                else torch.ones(self.num_edges, dtype=torch.float, device=device)
            )
        else:
            adj_values = torch.ones(self.num_edges, dtype=torch.float, device=device)

        adj_indices = torch.stack([src, dest], dim=0)
        adj_matrix = torch.sparse_coo_tensor(
            indices=adj_indices,
            values=adj_values,
            size=(num_nodes, num_nodes),
            dtype=adj_values.dtype,
            device=device,
        )
        return adj_matrix.coalesce()

    def get_sparse_identity_matrix(self, num_nodes: int | None = None) -> Tensor:
        """
        Compute the sparse identity matrix I of shape (num_nodes, num_nodes).

        Examples:
            >>> num_nodes = 3
            >>> identity_indices = [[0, 1, 2],
            ...                     [0, 1, 2]]
            >>> values = [1, 1, 1]
            >>> I = [[1, 0, 0],
            ...      [0, 1, 0],
            ...      [0, 0, 1]]

        Args:
            num_nodes: The number of nodes in the graph. Defaults to ``None``.
                If ``None``, it will be inferred from ``self.num_nodes``.

        Returns:
            identity: The sparse identity matrix I of shape ``(num_nodes, num_nodes)``.
        """
        num_nodes = self.num_nodes if num_nodes is None else num_nodes
        self.__validate_num_nodes(num_nodes)

        device = self.__edge_index.device

        # Example: num_nodes = 3
        #          -> identity_indices = [[0, 1, 2],
        #                                 [0, 1, 2]]
        #             we use repeat(2, 1) as I is a matrix NxN, so we need indices
        #             for both rows and columns
        #          -> values = [1, 1, 1]
        #                   0  1  2
        #          -> I = [[1, 0, 0], 0
        #                  [0, 1, 0], 1
        #                  [0, 0, 1]] 2
        identity_indices = (
            torch.arange(num_nodes, dtype=self.__edge_index.dtype, device=device)
            .unsqueeze(0)
            .repeat(2, 1)
        )
        identity_matrix = torch.sparse_coo_tensor(
            indices=identity_indices,
            values=torch.ones(num_nodes, dtype=torch.float, device=device),
            size=(num_nodes, num_nodes),
            dtype=torch.float,
            device=device,
        )
        return identity_matrix.coalesce()

    def get_sparse_normalized_degree_matrix(
        self,
        num_nodes: int | None = None,
        use_edge_weights: bool = False,
    ) -> Tensor:
        """
        Compute the sparse normalized degree matrix D^-1/2 from a graph edge index.

        Args:
            num_nodes: The number of nodes in the graph.
                If ``None``, it will be inferred from ``self.num_nodes``.
                Note that the node indices in ``edge_index`` are assumed to be in
                the range [0, num_nodes-1]. Defaults to ``None``.
            use_edge_weights: If ``True``, use the edge weights from ``self.edge_weights``.
                If ``False``, all edges use weight 1. Defaults to ``False``.

        Returns:
            degree_matrix: The sparse normalized degree matrix D^-1/2 of
                shape ``(num_nodes, num_nodes)``.
        """
        num_nodes = self.num_nodes if num_nodes is None else num_nodes
        self.__validate_num_nodes(num_nodes)

        device = self.__edge_index.device

        adj_matrix = self.get_sparse_adjacency_matrix(
            num_nodes=num_nodes, use_edge_weights=use_edge_weights
        )
        adj_indices = adj_matrix.indices()
        adj_values = adj_matrix.values()

        # Compute degree for each node as the weighted row-sum of the adjacency matrix.
        degrees: Tensor = torch.zeros(num_nodes, dtype=adj_values.dtype, device=device)
        degrees.scatter_add_(dim=0, index=adj_indices[0], src=adj_values)

        # Compute D^-1/2 == D^-0.5
        degree_inv_sqrt: Tensor = degrees.pow(-0.5)
        # Handle isolated nodes where degree is 0, which lead to inf values in degree_inv_sqrt
        degree_inv_sqrt[degree_inv_sqrt == float("inf")] = 0

        # Convert degree vector to a diagonal sparse normalized matrix D
        # Example: degree_inv_sqrt = [1, 0.707, 1, 0]
        #          -> diagonal_indices = [[0, 1, 2, 3],
        #                                 [0, 1, 2, 3]]
        #                   0  1      2  3
        #          -> D = [[1, 0,     0, 0], 0
        #                  [0, 0.707, 0, 0], 1
        #                  [0, 0,     1, 0], 2
        #                  [0, 0,     0, 0]] 3
        diagonal_indices = (
            torch.arange(num_nodes, dtype=self.__edge_index.dtype, 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),
            dtype=degree_inv_sqrt.dtype,
            device=device,
        )
        return degree_matrix.coalesce()

    def get_sparse_normalized_laplacian(
        self,
        num_nodes: int | None = None,
    ) -> Tensor:
        """
        Compute the sparse symmetric normalized Laplacian matrix: `L = I - D^{-1/2} A D^{-1/2}`.

        Unlike ``get_sparse_normalized_gcn_laplacian``, this method does not add self-loops
        and computes the standard Laplacian (not the GCN propagation matrix).

        Args:
            num_nodes: The number of nodes in the graph. If ``None``,
                it will be inferred from ``self.num_nodes``. Defaults to ``None``.

        Returns:
            laplacian: The sparse symmetric normalized Laplacian
                matrix of shape ``(num_nodes, num_nodes)``.
        """
        num_nodes = self.num_nodes if num_nodes is None else num_nodes
        self.__validate_num_nodes(num_nodes)

        self.to_undirected(with_selfloops=False, num_nodes=num_nodes)

        degree_matrix = self.get_sparse_normalized_degree_matrix(num_nodes)
        adj_matrix = self.get_sparse_adjacency_matrix(num_nodes)

        # D^{-1/2} A D^{-1/2}
        normalized_adj_matrix = torch.sparse.mm(
            degree_matrix,
            torch.sparse.mm(adj_matrix, degree_matrix),
        )

        # L = I - D^{-1/2} A D^{-1/2}
        identity_matrix = self.get_sparse_identity_matrix(num_nodes)
        return (identity_matrix - normalized_adj_matrix).coalesce()

    def get_sparse_normalized_gcn_laplacian(
        self,
        num_nodes: int | None = None,
        use_edge_weights: bool = False,
    ) -> Tensor:
        """
        Compute the sparse Laplacian matrix from a graph edge index.

        The GCN Laplacian is defined as: `L_GCN = D_hat^-1/2 * A_hat * D_hat^-1/2`,
        where `A_hat = A + I` (adjacency with self-loops) and `D_hat` is the degree matrix
        of `A_hat`.

        Args:
            num_nodes: The number of nodes in the graph. If ``None``,
                it will be inferred from ``self.num_nodes``. Defaults to ``None``.
                Note that the node indices in ``edge_index`` are assumed to be
                in the range [0, num_nodes-1].
                This parameter is important when ``edge_index`` does not contain all nodes
                (e.g., some nodes are isolated and have no edges or have been removed),
                as it ensures that the resulting Laplacian matrix has the correct size and
                includes all nodes. For instance, for self-loops.
            use_edge_weights: If ``True``, use the edge weights from ``self.edge_weights``.
                If ``False``, all edges use weight 1. Defaults to ``False``.

        Returns:
            laplacian: The sparse symmetrically normalized Laplacian matrix of
                shape ``(num_nodes, num_nodes)``.
        """
        num_nodes = self.num_nodes if num_nodes is None else num_nodes
        self.__validate_num_nodes(num_nodes)

        self.to_undirected(with_selfloops=True, num_nodes=num_nodes)

        degree_matrix = self.get_sparse_normalized_degree_matrix(
            num_nodes=num_nodes, use_edge_weights=use_edge_weights
        )
        adj_matrix = self.get_sparse_adjacency_matrix(
            num_nodes=num_nodes, use_edge_weights=use_edge_weights
        )

        # Compute normalized Laplacian matrix: L = D^-1/2 * A * D^-1/2
        normalized_laplacian_matrix = torch.sparse.mm(
            degree_matrix,
            torch.sparse.mm(adj_matrix, degree_matrix),
        )
        return normalized_laplacian_matrix.coalesce()

    def remove_selfloops(self) -> EdgeIndex:
        """
        Remove self-loops from the edge index.
        """
        # Example: edge_index = [[0, 1, 2, 3],
        #                        [1, 1, 3, 2]], shape (2, |E| = 4)
        #          -> keep_mask = [True, False, True, True]
        #          -> edge_index = [[0, 2, 3],
        #                           [1, 3, 2]], shape (2, |E'| = 3)
        keep_mask = self.__edge_index[0] != self.__edge_index[1]
        edge_index_without_selfloops: Tensor = self.__edge_index[:, keep_mask]
        self.__edge_index = edge_index_without_selfloops
        if self.__edge_weights is not None:
            self.__edge_weights = self.__edge_weights[keep_mask]
        return self

    def remove_duplicate_edges(self, num_nodes: int | None = None) -> EdgeIndex:
        """
        Remove duplicate edges from the edge index.
        Keeps the tensor contiguous in memory.

        Args:
            num_nodes: The number of nodes in the graph. If ``None``, it will be
                inferred from ``self.num_nodes``. Defaults to ``None``.
                This parameter is important when ``edge_index`` does not contain all nodes
                (e.g., some nodes are isolated and have no edges or have been removed),
                as it ensures that the resulting Laplacian matrix has the correct size
                and includes all nodes. For instance, for self-loops.

        Returns:
            edge_index: This `EdgeIndex` instance with duplicate edges removed.
        """
        num_nodes = self.num_nodes if num_nodes is None else num_nodes
        self.__validate_num_nodes(num_nodes)

        # Example: edge_index = [[0, 1, 2, 2, 0, 3, 2],
        #                        [1, 0, 3, 2, 1, 2, 2]], shape (2, |E| = 7)
        #          -> after torch.unique(..., dim=1):
        #             edge_index = [[0, 1, 2, 2, 3],
        #                           [1, 0, 3, 2, 2]], shape (2, |E'| = 5)
        # Note: we call contiguous() to ensure that the resulting tensor is contiguous in memory,
        # which can improve performance for subsequent operations that require contiguous tensors.
        if self.__edge_weights is None:
            edge_index_without_duplicate_edges = cast(
                Tensor, torch.unique(self.__edge_index, dim=1)
            )
            self.__edge_index = edge_index_without_duplicate_edges.contiguous()
            return self

        # No edges to process, just ensure tensors are contiguous
        if self.num_edges < 1:
            self.__edge_index = self.__edge_index.contiguous()
            self.__edge_weights = self.__edge_weights.contiguous()
            return self

        # When edge weights are present, we need to use torch.sparse_coo_tensor
        # to remove duplicate edges while preserving the weights.
        # Example: edge_index = [[0, 0, 1],
        #                        [1, 1, 2]]
        #          edge_weights = [1.0, 2.0, 3.0]
        #          -> before coalesce, we have duplicate edges (0, 1) with weights 1.0 and 2.0
        #          -> after `.coalesce()`:
        #          -> edge_index = [[0, 1],
        #                           [1, 2]]
        #          -> edge_weights = [3.0, 3.0] (weights of duplicate edges are summed)
        coalesced = torch.sparse_coo_tensor(
            self.__edge_index,
            self.__edge_weights,
            size=(num_nodes, num_nodes),
            dtype=self.__edge_weights.dtype,
            device=self.__edge_weights.device,
        ).coalesce()
        self.__edge_index = coalesced.indices().contiguous()
        self.__edge_weights = coalesced.values().contiguous()
        return self

    def to_undirected(
        self,
        with_selfloops: bool = False,
        num_nodes: int | None = None,
    ) -> EdgeIndex:
        """
        Convert the edge index to an undirected edge index by adding reverse edges.

        Args:
            with_selfloops: Whether to add self-loops to each node. Defaults to ``False``.
            num_nodes: Total number of nodes. Propagated to ``add_selfloops`` when
                ``with_selfloops`` is ``True``. This parameter is useful when ``edge_index``
                does not contain all nodes (e.g., some nodes are isolated and have no edges
                or have been removed), as it ensures that the resulting Laplacian matrix has
                the correct size and includes all nodes. For instance, for self-loops.
                Defaults to ``None``.

        Returns:
            edge_index: This `EdgeIndex` instance converted to undirected.
        """
        num_nodes = self.num_nodes if num_nodes is None else num_nodes
        self.__validate_num_nodes(num_nodes)

        device = self.__edge_index.device
        orig_src, orig_dest = self.__edge_index[0], self.__edge_index[1]

        # Encode each directed edge (u, v) as a unique scalar key u * num_nodes + v.
        # Example: num_nodes = 4, orig_src  = [0, 1, 2], orig_dest = [1, 0, 3]
        #          -> edges are [(0,1), (1,0), (2,3)]
        #          -> encoded_edge_ids = [0*4+1, 1*4+0, 2*4+3] = [1, 4, 11]
        encoded_edge_ids = orig_src * num_nodes + orig_dest

        # Build the key for the reverse of each existing edge.
        # Example: reverse edges are [(1,0), (0,1), (3,2)]
        #          -> reversed_encoded_edge_ids = [1*4+0, 0*4+1, 3*4+2] = [4, 1, 14]
        reversed_encoded_edge_ids = orig_dest * num_nodes + orig_src

        # Example: encoded_edge_ids          = [1, 4, 11],
        #          reversed_encoded_edge_ids = [4, 1, 14]
        #          -> missing_reverse_mask = [False, False, True]
        #             because 4 and 1 are in both, it means edges (0,1) and (1,0)
        #             are already present,
        #             but 14 is only in reversed_encoded_edge_ids, which means
        #             edge (3,2) is missing
        #             and this is because the mask points to the missing reversee edges
        #             that are missing
        missing_mask = torch.logical_not(torch.isin(reversed_encoded_edge_ids, encoded_edge_ids))

        # Keep all original sources and append the destination of each edge
        # whose reverse is missing.
        # Example: orig_src = [0, 1, 2], orig_dest[missing_mask] = [3]
        #          -> src = [0, 1, 2, 3]
        src = torch.cat([orig_src, orig_dest[missing_mask]])

        # Keep all original destinations and append the source of each edge
        # whose reverse is missing.
        # Example: orig_dest = [1, 0, 3], orig_src[missing_mask] = [2]
        #          -> dest = [1, 0, 3, 2]
        #          -> final undirected edges: [(0,1), (1,0), (2,3), (3,2)]
        dest = torch.cat([orig_dest, orig_src[missing_mask]])

        # Example: edge_index = [[0, 1, 2],
        #                        [1, 0, 3]]
        #          -> after torch.stack([...], dim=0):
        #             undirected_edge_index = [[0, 1, 2, 1, 0, 3],
        #                                      [1, 0, 3, 0, 1, 2]]
        #          -> after torch.unique(..., dim=1):
        #             undirected_edge_index = [[0, 1, 2, 3],
        #                                      [1, 0, 3, 2]]
        self.__edge_index = torch.stack([src, dest], dim=0).to(device)

        # The new edges have the same weights as the original edges.
        # Example: edge_index = [[0, 1, 2],
        #                        [1, 0, 3]]
        #          edge_weights = [0.5, 1.0, 2.0]
        #          -> after adding reverse edges:
        #             edge_index = [[0, 1, 2, 1, 0, 3],
        #                           [1, 0, 3, 0, 1, 2]]
        #             edge_weights = [0.5, 1.0, 2.0, 0.5, 1.0, 2.0]
        self.__edge_weights = (
            torch.cat([self.__edge_weights, self.__edge_weights[missing_mask]])
            if self.__edge_weights is not None
            else None
        )

        if with_selfloops:
            # Don't remove duplicate edges when adding self-loops, as we need to remove them
            # even if with_selfloops is False, to ensure that the edge index is clean
            # and doesn't contain duplicate edges.
            # In this way, we don't do the duplicate edge removal twice, which would be
            # redundant and inefficient
            self.add_selfloops(num_nodes=num_nodes, with_duplicate_removal=False)

        self.remove_duplicate_edges(num_nodes=num_nodes)

        return self

    def __validate_edge_weights(self, edge_weights: Tensor | None) -> None:
        """
        Validate edge weight tensor shape.

        Args:
            edge_weights: Optional edge weight tensor to validate.

        Raises:
            ValueError: If edge weights are not one-dimensional or do not match edge count.
        """
        if edge_weights is None:
            return

        if edge_weights.dim() != 1:
            raise ValueError(
                f"'edge_weights' must be a 1D tensor. Got "
                f"{edge_weights.dim()}D tensor with shape {edge_weights.shape}."
            )

        if edge_weights.size(0) != self.__edge_index.size(1):
            raise ValueError(
                f"'edge_weights' must have the same number of entries as edges in "
                f"the 'edge_index'. Got {edge_weights.size(0)} edge weights but "
                f"{self.__edge_index.size(1)} edge columns."
            )

    def __validate_num_nodes(self, num_nodes: int) -> None:
        """
        Validate that an explicit node count can contain the edge index.

        Args:
            num_nodes: Explicit number of nodes.

        Raises:
            ValueError: If ``num_nodes`` is negative or smaller than the maximum node ID.
        """
        validate_is_non_negative("num_nodes", num_nodes)

        if self.num_edges < 1:
            return

        if self.max_node_id >= num_nodes:
            raise ValueError(
                "'num_nodes' is too small for the edge index. "
                f"Got num_nodes={num_nodes}, but max node id is {self.max_node_id}."
            )

item property

Return the edge index tensor.

edge_weights property

Return the edge weight tensor, if present.

max_node_id property

Return the maximum node ID in the edge index.

num_edges property

Return the number of edges in the graph.

num_nodes property

Return the number of nodes in the graph.

__init__(edge_index, edge_weights=None)

Initialize the edge index wrapper.

Parameters:

Name Type Description Default
edge_index Tensor

Tensor of shape (2, num_edges) representing graph edges.

required
edge_weights Tensor | None

Optional tensor of shape (num_edges,) containing edge weights.

None
Source code in hypertorch/types/graph.py
def __init__(
    self,
    edge_index: Tensor,
    edge_weights: Tensor | None = None,
):
    """
    Initialize the edge index wrapper.

    Args:
        edge_index: Tensor of shape ``(2, num_edges)`` representing graph edges.
        edge_weights: Optional tensor of shape ``(num_edges,)`` containing edge weights.
    """
    self.__edge_index: Tensor = edge_index
    self.__validate_edge_weights(edge_weights)
    self.__edge_weights: Tensor | None = edge_weights

add_selfloops(num_nodes=None, with_duplicate_removal=True)

Add self-loops to each node in the edge index.

Examples:

>>> edge_index = [[0, 1, 2],
...               [1, 0, 3]]
>>> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3],
...                              [1, 0, 3, 0, 1, 2, 3]]

When num_nodes is higher than the number of nodes in edge_index, self-loops are added for all nodes from 0 to num_nodes - 1, including nodes not present in the original edges:

>>> edge_index = [[0, 1, 2],
...               [1, 0, 3]]
>>> num_nodes = 6
>>> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3, 4, 5],
...                              [1, 0, 3, 0, 1, 2, 3, 4, 5]]

Parameters:

Name Type Description Default
num_nodes int | None

Total number of nodes. When provided, self-loops are added for nodes 0 to num_nodes - 1. When None, defaults to self.num_nodes. This parameter is important when edge_index does not contain all nodes (e.g., some nodes are isolated and have no edges or have been removed), as it ensures that the resulting Laplacian matrix has the correct size and includes all nodes. For instance, for self-loops.

None
with_duplicate_removal bool

Whether to remove duplicate edges after adding self-loops. Defaults to True.

True

Returns:

Name Type Description
edge_index EdgeIndex

This EdgeIndex instance with self-loops added.

Raises:

Type Description
ValueError

If the input edge index has no edges (i.e., shape (2, 0)).

Source code in hypertorch/types/graph.py
def add_selfloops(
    self,
    num_nodes: int | None = None,
    with_duplicate_removal: bool = True,
) -> EdgeIndex:
    """
    Add self-loops to each node in the edge index.

    Examples:
        >>> edge_index = [[0, 1, 2],
        ...               [1, 0, 3]]
        >>> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3],
        ...                              [1, 0, 3, 0, 1, 2, 3]]

        When ``num_nodes`` is higher than the number of nodes in ``edge_index``,
        self-loops are added for all nodes from ``0`` to ``num_nodes - 1``,
        including nodes not present in the original edges:

        >>> edge_index = [[0, 1, 2],
        ...               [1, 0, 3]]
        >>> num_nodes = 6
        >>> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3, 4, 5],
        ...                              [1, 0, 3, 0, 1, 2, 3, 4, 5]]

    Args:
        num_nodes: Total number of nodes. When provided, self-loops are added for nodes ``0``
            to ``num_nodes - 1``. When ``None``, defaults to ``self.num_nodes``.
            This parameter is important when ``edge_index`` does not contain all nodes
            (e.g., some nodes are isolated and have no edges or have been removed),
            as it ensures that the resulting Laplacian matrix has the correct size and includes
            all nodes. For instance, for self-loops.
        with_duplicate_removal: Whether to remove duplicate edges after adding self-loops.
            Defaults to ``True``.

    Returns:
        edge_index: This `EdgeIndex` instance with self-loops added.

    Raises:
        ValueError: If the input edge index has no edges (i.e., ``shape (2, 0)``).
    """
    num_selfloop_nodes = self.num_nodes if num_nodes is None else num_nodes
    self.__validate_num_nodes(num_selfloop_nodes)

    device = self.__edge_index.device
    src, dest = self.__edge_index[0], self.__edge_index[1]

    # Add self-loops: A_hat = A + I (works as we assume node indices are in [0, num_nodes-1])
    # Example: edge_index = [[0, 1, 2],
    #                        [1, 0, 3]], num_nodes = None
    #          -> num_selfloop_nodes = 4 (self.num_nodes, as num_nodes is None)
    #          -> selfloop_indices = [0, 1, 2, 3]
    #          -> src = [0, 1, 2, 0, 1, 2, 3]
    #          -> dest = [1, 0, 3, 0, 1, 2, 3]
    #          -> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3],
    #                                          [1, 0, 3, 0, 1, 2, 3]]
    #
    # Example with num_nodes=6 (higher than 4 nodes in edge_index):
    #          -> num_selfloop_nodes = 6
    #          -> selfloop_indices = [0, 1, 2, 3, 4, 5]
    #          -> src = [0, 1, 2, 0, 1, 2, 3, 4, 5]
    #          -> dest = [1, 0, 3, 0, 1, 2, 3, 4, 5]
    #          -> edge_index_with_selfloops = [[0, 1, 2, 0, 1, 2, 3, 4, 5],
    #                                          [1, 0, 3, 0, 1, 2, 3, 4, 5]]
    selfloop_indices = torch.arange(
        num_selfloop_nodes,
        dtype=self.__edge_index.dtype,
        device=device,
    )
    src = torch.cat([src, selfloop_indices])
    dest = torch.cat([dest, selfloop_indices])
    edge_index_with_selfloops = torch.stack([src, dest], dim=0)

    self.__edge_index = edge_index_with_selfloops

    if self.__edge_weights is not None:
        # Set weights of self-loops to 1
        selfloop_weights = torch.ones(
            num_selfloop_nodes,
            device=device,
            dtype=self.__edge_weights.dtype,
        )
        self.__edge_weights = torch.cat([self.__edge_weights, selfloop_weights])

    if with_duplicate_removal:
        self.remove_duplicate_edges()

    return self

get_sparse_adjacency_matrix(num_nodes=None, use_edge_weights=False)

Compute the sparse adjacency matrix from a graph edge index. To get the normalized adjacency matrix, add self-loops to the edge_index.

Examples:

>>> edge_index = [[0, 1, 2],
...               [1, 0, 3]]
>>> num_nodes = 4
>>> adj_values = [1, 1, 1]
>>> adj_indices = [[0, 1, 2],
...                [1, 0, 3]]
>>>                0  1  2  3
... adj_matrix = [[0, 1, 0, 0], 0
...               [1, 0, 0, 0], 1
...               [0, 0, 0, 1], 2
...               [0, 0, 1, 0]] 3

Parameters:

Name Type Description Default
num_nodes int | None

The number of nodes in the graph. Defaults to None. If None, it will be inferred from self.num_nodes. Note that the node indices in edge_index are assumed to be in the range [0, num_nodes-1].

None
use_edge_weights bool

Whether to use edge weights if they are present. If False, all edges will have weight 1. Defaults to False.

False

Returns:

Name Type Description
adjacency Tensor

The sparse adjacency matrix of shape (num_nodes, num_nodes).

Source code in hypertorch/types/graph.py
def get_sparse_adjacency_matrix(
    self,
    num_nodes: int | None = None,
    use_edge_weights: bool = False,
) -> Tensor:
    """
    Compute the sparse adjacency matrix from a graph edge index.
    To get the normalized adjacency matrix, add self-loops to the edge_index.

    Examples:
        >>> edge_index = [[0, 1, 2],
        ...               [1, 0, 3]]
        >>> num_nodes = 4
        >>> adj_values = [1, 1, 1]
        >>> adj_indices = [[0, 1, 2],
        ...                [1, 0, 3]]
        >>>                0  1  2  3
        ... adj_matrix = [[0, 1, 0, 0], 0
        ...               [1, 0, 0, 0], 1
        ...               [0, 0, 0, 1], 2
        ...               [0, 0, 1, 0]] 3

    Args:
        num_nodes: The number of nodes in the graph. Defaults to ``None``.
            If ``None``, it will be inferred from ``self.num_nodes``.
            Note that the node indices in ``edge_index`` are assumed to be in the
            range [0, num_nodes-1].
        use_edge_weights: Whether to use edge weights if they are present.
            If ``False``, all edges will have weight 1. Defaults to ``False``.

    Returns:
        adjacency: The sparse adjacency matrix of shape ``(num_nodes, num_nodes)``.
    """
    num_nodes = self.num_nodes if num_nodes is None else num_nodes
    self.__validate_num_nodes(num_nodes)

    device = self.__edge_index.device
    src, dest = self.__edge_index

    # Example: edge_index = [[0, 1, 2, 3],
    #                       [1, 0, 3, 2]]
    #          use_edge_weights = False
    #          -> adj_values = [1, 1, 1, 1]
    #          -> adj_indices = [[0, 1, 2, 3],
    #                            [1, 0, 3, 2]]
    #                   0  1  2  3
    #          -> A = [[0, 1, 0, 0], 1
    #                  [1, 0, 0, 0], 0
    #                  [0, 0, 0, 1], 3
    #                  [0, 0, 1, 0]] 2
    if use_edge_weights:
        adj_values = (
            self.edge_weights
            if self.edge_weights is not None
            else torch.ones(self.num_edges, dtype=torch.float, device=device)
        )
    else:
        adj_values = torch.ones(self.num_edges, dtype=torch.float, device=device)

    adj_indices = torch.stack([src, dest], dim=0)
    adj_matrix = torch.sparse_coo_tensor(
        indices=adj_indices,
        values=adj_values,
        size=(num_nodes, num_nodes),
        dtype=adj_values.dtype,
        device=device,
    )
    return adj_matrix.coalesce()

get_sparse_identity_matrix(num_nodes=None)

Compute the sparse identity matrix I of shape (num_nodes, num_nodes).

Examples:

>>> num_nodes = 3
>>> identity_indices = [[0, 1, 2],
...                     [0, 1, 2]]
>>> values = [1, 1, 1]
>>> I = [[1, 0, 0],
...      [0, 1, 0],
...      [0, 0, 1]]

Parameters:

Name Type Description Default
num_nodes int | None

The number of nodes in the graph. Defaults to None. If None, it will be inferred from self.num_nodes.

None

Returns:

Name Type Description
identity Tensor

The sparse identity matrix I of shape (num_nodes, num_nodes).

Source code in hypertorch/types/graph.py
def get_sparse_identity_matrix(self, num_nodes: int | None = None) -> Tensor:
    """
    Compute the sparse identity matrix I of shape (num_nodes, num_nodes).

    Examples:
        >>> num_nodes = 3
        >>> identity_indices = [[0, 1, 2],
        ...                     [0, 1, 2]]
        >>> values = [1, 1, 1]
        >>> I = [[1, 0, 0],
        ...      [0, 1, 0],
        ...      [0, 0, 1]]

    Args:
        num_nodes: The number of nodes in the graph. Defaults to ``None``.
            If ``None``, it will be inferred from ``self.num_nodes``.

    Returns:
        identity: The sparse identity matrix I of shape ``(num_nodes, num_nodes)``.
    """
    num_nodes = self.num_nodes if num_nodes is None else num_nodes
    self.__validate_num_nodes(num_nodes)

    device = self.__edge_index.device

    # Example: num_nodes = 3
    #          -> identity_indices = [[0, 1, 2],
    #                                 [0, 1, 2]]
    #             we use repeat(2, 1) as I is a matrix NxN, so we need indices
    #             for both rows and columns
    #          -> values = [1, 1, 1]
    #                   0  1  2
    #          -> I = [[1, 0, 0], 0
    #                  [0, 1, 0], 1
    #                  [0, 0, 1]] 2
    identity_indices = (
        torch.arange(num_nodes, dtype=self.__edge_index.dtype, device=device)
        .unsqueeze(0)
        .repeat(2, 1)
    )
    identity_matrix = torch.sparse_coo_tensor(
        indices=identity_indices,
        values=torch.ones(num_nodes, dtype=torch.float, device=device),
        size=(num_nodes, num_nodes),
        dtype=torch.float,
        device=device,
    )
    return identity_matrix.coalesce()

get_sparse_normalized_degree_matrix(num_nodes=None, use_edge_weights=False)

Compute the sparse normalized degree matrix D^-½ from a graph edge index.

Parameters:

Name Type Description Default
num_nodes int | None

The number of nodes in the graph. If None, it will be inferred from self.num_nodes. Note that the node indices in edge_index are assumed to be in the range [0, num_nodes-1]. Defaults to None.

None
use_edge_weights bool

If True, use the edge weights from self.edge_weights. If False, all edges use weight 1. Defaults to False.

False

Returns:

Name Type Description
degree_matrix Tensor

The sparse normalized degree matrix D^-½ of shape (num_nodes, num_nodes).

Source code in hypertorch/types/graph.py
def get_sparse_normalized_degree_matrix(
    self,
    num_nodes: int | None = None,
    use_edge_weights: bool = False,
) -> Tensor:
    """
    Compute the sparse normalized degree matrix D^-1/2 from a graph edge index.

    Args:
        num_nodes: The number of nodes in the graph.
            If ``None``, it will be inferred from ``self.num_nodes``.
            Note that the node indices in ``edge_index`` are assumed to be in
            the range [0, num_nodes-1]. Defaults to ``None``.
        use_edge_weights: If ``True``, use the edge weights from ``self.edge_weights``.
            If ``False``, all edges use weight 1. Defaults to ``False``.

    Returns:
        degree_matrix: The sparse normalized degree matrix D^-1/2 of
            shape ``(num_nodes, num_nodes)``.
    """
    num_nodes = self.num_nodes if num_nodes is None else num_nodes
    self.__validate_num_nodes(num_nodes)

    device = self.__edge_index.device

    adj_matrix = self.get_sparse_adjacency_matrix(
        num_nodes=num_nodes, use_edge_weights=use_edge_weights
    )
    adj_indices = adj_matrix.indices()
    adj_values = adj_matrix.values()

    # Compute degree for each node as the weighted row-sum of the adjacency matrix.
    degrees: Tensor = torch.zeros(num_nodes, dtype=adj_values.dtype, device=device)
    degrees.scatter_add_(dim=0, index=adj_indices[0], src=adj_values)

    # Compute D^-1/2 == D^-0.5
    degree_inv_sqrt: Tensor = degrees.pow(-0.5)
    # Handle isolated nodes where degree is 0, which lead to inf values in degree_inv_sqrt
    degree_inv_sqrt[degree_inv_sqrt == float("inf")] = 0

    # Convert degree vector to a diagonal sparse normalized matrix D
    # Example: degree_inv_sqrt = [1, 0.707, 1, 0]
    #          -> diagonal_indices = [[0, 1, 2, 3],
    #                                 [0, 1, 2, 3]]
    #                   0  1      2  3
    #          -> D = [[1, 0,     0, 0], 0
    #                  [0, 0.707, 0, 0], 1
    #                  [0, 0,     1, 0], 2
    #                  [0, 0,     0, 0]] 3
    diagonal_indices = (
        torch.arange(num_nodes, dtype=self.__edge_index.dtype, 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),
        dtype=degree_inv_sqrt.dtype,
        device=device,
    )
    return degree_matrix.coalesce()

get_sparse_normalized_laplacian(num_nodes=None)

Compute the sparse symmetric normalized Laplacian matrix: L = I - D^{-1/2} A D^{-1/2}.

Unlike get_sparse_normalized_gcn_laplacian, this method does not add self-loops and computes the standard Laplacian (not the GCN propagation matrix).

Parameters:

Name Type Description Default
num_nodes int | None

The number of nodes in the graph. If None, it will be inferred from self.num_nodes. Defaults to None.

None

Returns:

Name Type Description
laplacian Tensor

The sparse symmetric normalized Laplacian matrix of shape (num_nodes, num_nodes).

Source code in hypertorch/types/graph.py
def get_sparse_normalized_laplacian(
    self,
    num_nodes: int | None = None,
) -> Tensor:
    """
    Compute the sparse symmetric normalized Laplacian matrix: `L = I - D^{-1/2} A D^{-1/2}`.

    Unlike ``get_sparse_normalized_gcn_laplacian``, this method does not add self-loops
    and computes the standard Laplacian (not the GCN propagation matrix).

    Args:
        num_nodes: The number of nodes in the graph. If ``None``,
            it will be inferred from ``self.num_nodes``. Defaults to ``None``.

    Returns:
        laplacian: The sparse symmetric normalized Laplacian
            matrix of shape ``(num_nodes, num_nodes)``.
    """
    num_nodes = self.num_nodes if num_nodes is None else num_nodes
    self.__validate_num_nodes(num_nodes)

    self.to_undirected(with_selfloops=False, num_nodes=num_nodes)

    degree_matrix = self.get_sparse_normalized_degree_matrix(num_nodes)
    adj_matrix = self.get_sparse_adjacency_matrix(num_nodes)

    # D^{-1/2} A D^{-1/2}
    normalized_adj_matrix = torch.sparse.mm(
        degree_matrix,
        torch.sparse.mm(adj_matrix, degree_matrix),
    )

    # L = I - D^{-1/2} A D^{-1/2}
    identity_matrix = self.get_sparse_identity_matrix(num_nodes)
    return (identity_matrix - normalized_adj_matrix).coalesce()

get_sparse_normalized_gcn_laplacian(num_nodes=None, use_edge_weights=False)

Compute the sparse Laplacian matrix from a graph edge index.

The GCN Laplacian is defined as: L_GCN = D_hat^-1/2 * A_hat * D_hat^-1/2, where A_hat = A + I (adjacency with self-loops) and D_hat is the degree matrix of A_hat.

Parameters:

Name Type Description Default
num_nodes int | None

The number of nodes in the graph. If None, it will be inferred from self.num_nodes. Defaults to None. Note that the node indices in edge_index are assumed to be in the range [0, num_nodes-1]. This parameter is important when edge_index does not contain all nodes (e.g., some nodes are isolated and have no edges or have been removed), as it ensures that the resulting Laplacian matrix has the correct size and includes all nodes. For instance, for self-loops.

None
use_edge_weights bool

If True, use the edge weights from self.edge_weights. If False, all edges use weight 1. Defaults to False.

False

Returns:

Name Type Description
laplacian Tensor

The sparse symmetrically normalized Laplacian matrix of shape (num_nodes, num_nodes).

Source code in hypertorch/types/graph.py
def get_sparse_normalized_gcn_laplacian(
    self,
    num_nodes: int | None = None,
    use_edge_weights: bool = False,
) -> Tensor:
    """
    Compute the sparse Laplacian matrix from a graph edge index.

    The GCN Laplacian is defined as: `L_GCN = D_hat^-1/2 * A_hat * D_hat^-1/2`,
    where `A_hat = A + I` (adjacency with self-loops) and `D_hat` is the degree matrix
    of `A_hat`.

    Args:
        num_nodes: The number of nodes in the graph. If ``None``,
            it will be inferred from ``self.num_nodes``. Defaults to ``None``.
            Note that the node indices in ``edge_index`` are assumed to be
            in the range [0, num_nodes-1].
            This parameter is important when ``edge_index`` does not contain all nodes
            (e.g., some nodes are isolated and have no edges or have been removed),
            as it ensures that the resulting Laplacian matrix has the correct size and
            includes all nodes. For instance, for self-loops.
        use_edge_weights: If ``True``, use the edge weights from ``self.edge_weights``.
            If ``False``, all edges use weight 1. Defaults to ``False``.

    Returns:
        laplacian: The sparse symmetrically normalized Laplacian matrix of
            shape ``(num_nodes, num_nodes)``.
    """
    num_nodes = self.num_nodes if num_nodes is None else num_nodes
    self.__validate_num_nodes(num_nodes)

    self.to_undirected(with_selfloops=True, num_nodes=num_nodes)

    degree_matrix = self.get_sparse_normalized_degree_matrix(
        num_nodes=num_nodes, use_edge_weights=use_edge_weights
    )
    adj_matrix = self.get_sparse_adjacency_matrix(
        num_nodes=num_nodes, use_edge_weights=use_edge_weights
    )

    # Compute normalized Laplacian matrix: L = D^-1/2 * A * D^-1/2
    normalized_laplacian_matrix = torch.sparse.mm(
        degree_matrix,
        torch.sparse.mm(adj_matrix, degree_matrix),
    )
    return normalized_laplacian_matrix.coalesce()

remove_selfloops()

Remove self-loops from the edge index.

Source code in hypertorch/types/graph.py
def remove_selfloops(self) -> EdgeIndex:
    """
    Remove self-loops from the edge index.
    """
    # Example: edge_index = [[0, 1, 2, 3],
    #                        [1, 1, 3, 2]], shape (2, |E| = 4)
    #          -> keep_mask = [True, False, True, True]
    #          -> edge_index = [[0, 2, 3],
    #                           [1, 3, 2]], shape (2, |E'| = 3)
    keep_mask = self.__edge_index[0] != self.__edge_index[1]
    edge_index_without_selfloops: Tensor = self.__edge_index[:, keep_mask]
    self.__edge_index = edge_index_without_selfloops
    if self.__edge_weights is not None:
        self.__edge_weights = self.__edge_weights[keep_mask]
    return self

remove_duplicate_edges(num_nodes=None)

Remove duplicate edges from the edge index. Keeps the tensor contiguous in memory.

Parameters:

Name Type Description Default
num_nodes int | None

The number of nodes in the graph. If None, it will be inferred from self.num_nodes. Defaults to None. This parameter is important when edge_index does not contain all nodes (e.g., some nodes are isolated and have no edges or have been removed), as it ensures that the resulting Laplacian matrix has the correct size and includes all nodes. For instance, for self-loops.

None

Returns:

Name Type Description
edge_index EdgeIndex

This EdgeIndex instance with duplicate edges removed.

Source code in hypertorch/types/graph.py
def remove_duplicate_edges(self, num_nodes: int | None = None) -> EdgeIndex:
    """
    Remove duplicate edges from the edge index.
    Keeps the tensor contiguous in memory.

    Args:
        num_nodes: The number of nodes in the graph. If ``None``, it will be
            inferred from ``self.num_nodes``. Defaults to ``None``.
            This parameter is important when ``edge_index`` does not contain all nodes
            (e.g., some nodes are isolated and have no edges or have been removed),
            as it ensures that the resulting Laplacian matrix has the correct size
            and includes all nodes. For instance, for self-loops.

    Returns:
        edge_index: This `EdgeIndex` instance with duplicate edges removed.
    """
    num_nodes = self.num_nodes if num_nodes is None else num_nodes
    self.__validate_num_nodes(num_nodes)

    # Example: edge_index = [[0, 1, 2, 2, 0, 3, 2],
    #                        [1, 0, 3, 2, 1, 2, 2]], shape (2, |E| = 7)
    #          -> after torch.unique(..., dim=1):
    #             edge_index = [[0, 1, 2, 2, 3],
    #                           [1, 0, 3, 2, 2]], shape (2, |E'| = 5)
    # Note: we call contiguous() to ensure that the resulting tensor is contiguous in memory,
    # which can improve performance for subsequent operations that require contiguous tensors.
    if self.__edge_weights is None:
        edge_index_without_duplicate_edges = cast(
            Tensor, torch.unique(self.__edge_index, dim=1)
        )
        self.__edge_index = edge_index_without_duplicate_edges.contiguous()
        return self

    # No edges to process, just ensure tensors are contiguous
    if self.num_edges < 1:
        self.__edge_index = self.__edge_index.contiguous()
        self.__edge_weights = self.__edge_weights.contiguous()
        return self

    # When edge weights are present, we need to use torch.sparse_coo_tensor
    # to remove duplicate edges while preserving the weights.
    # Example: edge_index = [[0, 0, 1],
    #                        [1, 1, 2]]
    #          edge_weights = [1.0, 2.0, 3.0]
    #          -> before coalesce, we have duplicate edges (0, 1) with weights 1.0 and 2.0
    #          -> after `.coalesce()`:
    #          -> edge_index = [[0, 1],
    #                           [1, 2]]
    #          -> edge_weights = [3.0, 3.0] (weights of duplicate edges are summed)
    coalesced = torch.sparse_coo_tensor(
        self.__edge_index,
        self.__edge_weights,
        size=(num_nodes, num_nodes),
        dtype=self.__edge_weights.dtype,
        device=self.__edge_weights.device,
    ).coalesce()
    self.__edge_index = coalesced.indices().contiguous()
    self.__edge_weights = coalesced.values().contiguous()
    return self

to_undirected(with_selfloops=False, num_nodes=None)

Convert the edge index to an undirected edge index by adding reverse edges.

Parameters:

Name Type Description Default
with_selfloops bool

Whether to add self-loops to each node. Defaults to False.

False
num_nodes int | None

Total number of nodes. Propagated to add_selfloops when with_selfloops is True. This parameter is useful when edge_index does not contain all nodes (e.g., some nodes are isolated and have no edges or have been removed), as it ensures that the resulting Laplacian matrix has the correct size and includes all nodes. For instance, for self-loops. Defaults to None.

None

Returns:

Name Type Description
edge_index EdgeIndex

This EdgeIndex instance converted to undirected.

Source code in hypertorch/types/graph.py
def to_undirected(
    self,
    with_selfloops: bool = False,
    num_nodes: int | None = None,
) -> EdgeIndex:
    """
    Convert the edge index to an undirected edge index by adding reverse edges.

    Args:
        with_selfloops: Whether to add self-loops to each node. Defaults to ``False``.
        num_nodes: Total number of nodes. Propagated to ``add_selfloops`` when
            ``with_selfloops`` is ``True``. This parameter is useful when ``edge_index``
            does not contain all nodes (e.g., some nodes are isolated and have no edges
            or have been removed), as it ensures that the resulting Laplacian matrix has
            the correct size and includes all nodes. For instance, for self-loops.
            Defaults to ``None``.

    Returns:
        edge_index: This `EdgeIndex` instance converted to undirected.
    """
    num_nodes = self.num_nodes if num_nodes is None else num_nodes
    self.__validate_num_nodes(num_nodes)

    device = self.__edge_index.device
    orig_src, orig_dest = self.__edge_index[0], self.__edge_index[1]

    # Encode each directed edge (u, v) as a unique scalar key u * num_nodes + v.
    # Example: num_nodes = 4, orig_src  = [0, 1, 2], orig_dest = [1, 0, 3]
    #          -> edges are [(0,1), (1,0), (2,3)]
    #          -> encoded_edge_ids = [0*4+1, 1*4+0, 2*4+3] = [1, 4, 11]
    encoded_edge_ids = orig_src * num_nodes + orig_dest

    # Build the key for the reverse of each existing edge.
    # Example: reverse edges are [(1,0), (0,1), (3,2)]
    #          -> reversed_encoded_edge_ids = [1*4+0, 0*4+1, 3*4+2] = [4, 1, 14]
    reversed_encoded_edge_ids = orig_dest * num_nodes + orig_src

    # Example: encoded_edge_ids          = [1, 4, 11],
    #          reversed_encoded_edge_ids = [4, 1, 14]
    #          -> missing_reverse_mask = [False, False, True]
    #             because 4 and 1 are in both, it means edges (0,1) and (1,0)
    #             are already present,
    #             but 14 is only in reversed_encoded_edge_ids, which means
    #             edge (3,2) is missing
    #             and this is because the mask points to the missing reversee edges
    #             that are missing
    missing_mask = torch.logical_not(torch.isin(reversed_encoded_edge_ids, encoded_edge_ids))

    # Keep all original sources and append the destination of each edge
    # whose reverse is missing.
    # Example: orig_src = [0, 1, 2], orig_dest[missing_mask] = [3]
    #          -> src = [0, 1, 2, 3]
    src = torch.cat([orig_src, orig_dest[missing_mask]])

    # Keep all original destinations and append the source of each edge
    # whose reverse is missing.
    # Example: orig_dest = [1, 0, 3], orig_src[missing_mask] = [2]
    #          -> dest = [1, 0, 3, 2]
    #          -> final undirected edges: [(0,1), (1,0), (2,3), (3,2)]
    dest = torch.cat([orig_dest, orig_src[missing_mask]])

    # Example: edge_index = [[0, 1, 2],
    #                        [1, 0, 3]]
    #          -> after torch.stack([...], dim=0):
    #             undirected_edge_index = [[0, 1, 2, 1, 0, 3],
    #                                      [1, 0, 3, 0, 1, 2]]
    #          -> after torch.unique(..., dim=1):
    #             undirected_edge_index = [[0, 1, 2, 3],
    #                                      [1, 0, 3, 2]]
    self.__edge_index = torch.stack([src, dest], dim=0).to(device)

    # The new edges have the same weights as the original edges.
    # Example: edge_index = [[0, 1, 2],
    #                        [1, 0, 3]]
    #          edge_weights = [0.5, 1.0, 2.0]
    #          -> after adding reverse edges:
    #             edge_index = [[0, 1, 2, 1, 0, 3],
    #                           [1, 0, 3, 0, 1, 2]]
    #             edge_weights = [0.5, 1.0, 2.0, 0.5, 1.0, 2.0]
    self.__edge_weights = (
        torch.cat([self.__edge_weights, self.__edge_weights[missing_mask]])
        if self.__edge_weights is not None
        else None
    )

    if with_selfloops:
        # Don't remove duplicate edges when adding self-loops, as we need to remove them
        # even if with_selfloops is False, to ensure that the edge index is clean
        # and doesn't contain duplicate edges.
        # In this way, we don't do the duplicate edge removal twice, which would be
        # redundant and inefficient
        self.add_selfloops(num_nodes=num_nodes, with_duplicate_removal=False)

    self.remove_duplicate_edges(num_nodes=num_nodes)

    return self

__validate_edge_weights(edge_weights)

Validate edge weight tensor shape.

Parameters:

Name Type Description Default
edge_weights Tensor | None

Optional edge weight tensor to validate.

required

Raises:

Type Description
ValueError

If edge weights are not one-dimensional or do not match edge count.

Source code in hypertorch/types/graph.py
def __validate_edge_weights(self, edge_weights: Tensor | None) -> None:
    """
    Validate edge weight tensor shape.

    Args:
        edge_weights: Optional edge weight tensor to validate.

    Raises:
        ValueError: If edge weights are not one-dimensional or do not match edge count.
    """
    if edge_weights is None:
        return

    if edge_weights.dim() != 1:
        raise ValueError(
            f"'edge_weights' must be a 1D tensor. Got "
            f"{edge_weights.dim()}D tensor with shape {edge_weights.shape}."
        )

    if edge_weights.size(0) != self.__edge_index.size(1):
        raise ValueError(
            f"'edge_weights' must have the same number of entries as edges in "
            f"the 'edge_index'. Got {edge_weights.size(0)} edge weights but "
            f"{self.__edge_index.size(1)} edge columns."
        )

__validate_num_nodes(num_nodes)

Validate that an explicit node count can contain the edge index.

Parameters:

Name Type Description Default
num_nodes int

Explicit number of nodes.

required

Raises:

Type Description
ValueError

If num_nodes is negative or smaller than the maximum node ID.

Source code in hypertorch/types/graph.py
def __validate_num_nodes(self, num_nodes: int) -> None:
    """
    Validate that an explicit node count can contain the edge index.

    Args:
        num_nodes: Explicit number of nodes.

    Raises:
        ValueError: If ``num_nodes`` is negative or smaller than the maximum node ID.
    """
    validate_is_non_negative("num_nodes", num_nodes)

    if self.num_edges < 1:
        return

    if self.max_node_id >= num_nodes:
        raise ValueError(
            "'num_nodes' is too small for the edge index. "
            f"Got num_nodes={num_nodes}, but max node id is {self.max_node_id}."
        )

Graph

A simple graph data structure using edge list representation.

Attributes:

Name Type Description
edges list[list[int]]

A list of edges, where each edge is represented as a list of two integers (source_node, destination_node).

Source code in hypertorch/types/graph.py
class Graph:
    """
    A simple graph data structure using edge list representation.

    Attributes:
        edges: A list of edges, where each edge is represented as a list of two integers
            (source_node, destination_node).
    """

    def __init__(self, edges: list[list[int]], edge_weights: list[float] | None = None):
        """
        Initialize the graph.

        Args:
            edges: Edge list where each edge is ``[source_node, destination_node]``.
            edge_weights: Optional edge weights matching ``edges``.

        Raises:
            ValueError: If edge weights are provided but their length does not
                match the number of edges.
        """
        self.edges: list[list[int]] = edges
        self.__validate_edge_weights(edge_weights)
        self.__edge_weights: list[float] | None = edge_weights

    @property
    def edge_weights(self) -> list[float] | None:
        """
        Return the edge weights, if present.
        """
        return self.__edge_weights

    @property
    def edge_weights_tensor(self) -> Tensor:
        """
        Return the edge weights as a tensor, if present.
        """
        if self.__edge_weights is not None:
            return torch.tensor(self.__edge_weights, dtype=torch.float)
        return torch.empty(0, dtype=torch.float)

    @property
    def num_nodes(self) -> int:
        """
        Return the number of nodes in the graph.
        """
        nodes = set()
        for edge in self.edges:
            nodes.update(edge)
        return len(nodes)

    @property
    def num_edges(self) -> int:
        """
        Return the number of edges in the graph.
        """
        return len(self.edges)

    def remove_selfloops(self) -> Graph:
        """
        Remove self-loops from the graph.

        Returns:
            edges: List of edges without self-loops.
        """
        if self.num_edges == 0:
            return self

        edges_tensor = torch.tensor(self.edges, dtype=torch.long)
        edge_weights_tensor = (
            torch.tensor(self.edge_weights, dtype=torch.float)
            if self.edge_weights is not None
            else None
        )

        # Example: edges = [[0, 1],
        #                   [1, 1],
        #                   [2, 3]] shape (|E|, 2)
        #          -> no_selfloop_mask = [True, False, True]
        #          -> edges without self-loops = [[0, 1],
        #                                         [2, 3]]
        no_selfloop_mask = edges_tensor[:, 0] != edges_tensor[:, 1]
        edges_without_selfloops: list[list[int]] = edges_tensor[no_selfloop_mask].tolist()
        self.edges = edges_without_selfloops

        # Example: edge_weights = [0.5, 1.0, 0.8], no_selfloop_mask = [True, False, True]
        #         -> edge_weights without self-loops = [0.5, 0.8]
        if edge_weights_tensor is not None:
            self.__edge_weights = edge_weights_tensor[no_selfloop_mask].tolist()

        return self

    def to_edge_index(self) -> Tensor:
        """
        Convert the graph to edge index representation.

        Returns:
            edge_index: Tensor of shape (2, |E|) representing edges.
        """
        if self.num_edges == 0:
            return torch.empty((2, 0), dtype=torch.long)

        # Example: edges = [[0, 1],
        #                   [1, 2],
        #                   [2, 3]] shape (|E|, 2)
        #          ->  edge_index = [[0, 1, 2],
        #                            [1, 2, 3]] shape (2, |E|)
        edge_index = torch.tensor(self.edges, dtype=torch.long).t()
        return edge_index

    def __validate_edge_weights(self, edge_weights: list[float] | None) -> None:
        """
        Validate graph edge weights.

        Args:
            edge_weights: Optional edge weights to validate.

        Raises:
            ValueError: If the number of weights does not match the number of edges.
        """
        if edge_weights is None:
            return

        if len(edge_weights) != self.num_edges:
            raise ValueError(
                "'edge_weights' must have the same number of entries as edges. "
                f"Got {len(edge_weights)} edge weights but {self.num_edges} edges."
            )

    @staticmethod
    def smoothing_with_laplacian_matrix(
        x: Tensor,
        laplacian_matrix: Tensor,
        drop_rate: float = 0.0,
    ) -> Tensor:
        """
        Return the feature matrix smoothed with a Laplacian matrix.

        Args:
            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:
            x: The smoothed feature matrix. Size ``(num_nodes, C)``.
        """
        if laplacian_matrix.dtype != x.dtype or laplacian_matrix.device != x.device:
            laplacian_matrix = laplacian_matrix.to(dtype=x.dtype, device=x.device)
        if drop_rate > 0.0:
            laplacian_matrix = sparse_dropout(laplacian_matrix, drop_rate)
        return laplacian_matrix.matmul(x)

edge_weights property

Return the edge weights, if present.

edge_weights_tensor property

Return the edge weights as a tensor, if present.

num_nodes property

Return the number of nodes in the graph.

num_edges property

Return the number of edges in the graph.

__init__(edges, edge_weights=None)

Initialize the graph.

Parameters:

Name Type Description Default
edges list[list[int]]

Edge list where each edge is [source_node, destination_node].

required
edge_weights list[float] | None

Optional edge weights matching edges.

None

Raises:

Type Description
ValueError

If edge weights are provided but their length does not match the number of edges.

Source code in hypertorch/types/graph.py
def __init__(self, edges: list[list[int]], edge_weights: list[float] | None = None):
    """
    Initialize the graph.

    Args:
        edges: Edge list where each edge is ``[source_node, destination_node]``.
        edge_weights: Optional edge weights matching ``edges``.

    Raises:
        ValueError: If edge weights are provided but their length does not
            match the number of edges.
    """
    self.edges: list[list[int]] = edges
    self.__validate_edge_weights(edge_weights)
    self.__edge_weights: list[float] | None = edge_weights

remove_selfloops()

Remove self-loops from the graph.

Returns:

Name Type Description
edges Graph

List of edges without self-loops.

Source code in hypertorch/types/graph.py
def remove_selfloops(self) -> Graph:
    """
    Remove self-loops from the graph.

    Returns:
        edges: List of edges without self-loops.
    """
    if self.num_edges == 0:
        return self

    edges_tensor = torch.tensor(self.edges, dtype=torch.long)
    edge_weights_tensor = (
        torch.tensor(self.edge_weights, dtype=torch.float)
        if self.edge_weights is not None
        else None
    )

    # Example: edges = [[0, 1],
    #                   [1, 1],
    #                   [2, 3]] shape (|E|, 2)
    #          -> no_selfloop_mask = [True, False, True]
    #          -> edges without self-loops = [[0, 1],
    #                                         [2, 3]]
    no_selfloop_mask = edges_tensor[:, 0] != edges_tensor[:, 1]
    edges_without_selfloops: list[list[int]] = edges_tensor[no_selfloop_mask].tolist()
    self.edges = edges_without_selfloops

    # Example: edge_weights = [0.5, 1.0, 0.8], no_selfloop_mask = [True, False, True]
    #         -> edge_weights without self-loops = [0.5, 0.8]
    if edge_weights_tensor is not None:
        self.__edge_weights = edge_weights_tensor[no_selfloop_mask].tolist()

    return self

to_edge_index()

Convert the graph to edge index representation.

Returns:

Name Type Description
edge_index Tensor

Tensor of shape (2, |E|) representing edges.

Source code in hypertorch/types/graph.py
def to_edge_index(self) -> Tensor:
    """
    Convert the graph to edge index representation.

    Returns:
        edge_index: Tensor of shape (2, |E|) representing edges.
    """
    if self.num_edges == 0:
        return torch.empty((2, 0), dtype=torch.long)

    # Example: edges = [[0, 1],
    #                   [1, 2],
    #                   [2, 3]] shape (|E|, 2)
    #          ->  edge_index = [[0, 1, 2],
    #                            [1, 2, 3]] shape (2, |E|)
    edge_index = torch.tensor(self.edges, dtype=torch.long).t()
    return edge_index

__validate_edge_weights(edge_weights)

Validate graph edge weights.

Parameters:

Name Type Description Default
edge_weights list[float] | None

Optional edge weights to validate.

required

Raises:

Type Description
ValueError

If the number of weights does not match the number of edges.

Source code in hypertorch/types/graph.py
def __validate_edge_weights(self, edge_weights: list[float] | None) -> None:
    """
    Validate graph edge weights.

    Args:
        edge_weights: Optional edge weights to validate.

    Raises:
        ValueError: If the number of weights does not match the number of edges.
    """
    if edge_weights is None:
        return

    if len(edge_weights) != self.num_edges:
        raise ValueError(
            "'edge_weights' must have the same number of entries as edges. "
            f"Got {len(edge_weights)} edge weights but {self.num_edges} edges."
        )

smoothing_with_laplacian_matrix(x, laplacian_matrix, drop_rate=0.0) staticmethod

Return the feature matrix smoothed with a Laplacian matrix.

Parameters:

Name Type Description Default
x Tensor

Node feature matrix. Size (num_nodes, C).

required
laplacian_matrix Tensor

The Laplacian matrix. Size (num_nodes, num_nodes).

required
drop_rate float

Randomly dropout the connections in the Laplacian with probability drop_rate. Defaults to 0.0.

0.0

Returns:

Name Type Description
x Tensor

The smoothed feature matrix. Size (num_nodes, C).

Source code in hypertorch/types/graph.py
@staticmethod
def smoothing_with_laplacian_matrix(
    x: Tensor,
    laplacian_matrix: Tensor,
    drop_rate: float = 0.0,
) -> Tensor:
    """
    Return the feature matrix smoothed with a Laplacian matrix.

    Args:
        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:
        x: The smoothed feature matrix. Size ``(num_nodes, C)``.
    """
    if laplacian_matrix.dtype != x.dtype or laplacian_matrix.device != x.device:
        laplacian_matrix = laplacian_matrix.to(dtype=x.dtype, device=x.device)
    if drop_rate > 0.0:
        laplacian_matrix = sparse_dropout(laplacian_matrix, drop_rate)
    return laplacian_matrix.matmul(x)

HIFHypergraph

A hypergraph data structure that supports directed/undirected hyperedges with incidence-based representation.

Attributes:

Name Type Description
network_type Literal['asc', 'directed', 'undirected'] | None

The type of hypergraph, which can be "asc" (or "directed") for directed hyperedges, or "undirected" for undirected hyperedges.

metadata dict[str, Any]

Optional dictionary of metadata about the hypergraph.

incidences list[dict[str, Any]]

A list of incidences, where each incidence is a dictionary with keys "node" and "edge" representing the relationship between a node and a hyperedge.

nodes list[dict[str, Any]]

A list of node dictionaries, where each dictionary contains information about a node (e.g., id, features).

hyperedges list[dict[str, Any]]

A list of edge dictionaries, where each dictionary contains information about a hyperedge (e.g., id, features).

Source code in hypertorch/types/hypergraph.py
class HIFHypergraph:
    """
    A hypergraph data structure that supports directed/undirected hyperedges with incidence-based
    representation.

    Attributes:
        network_type: The type of hypergraph, which can be "asc" (or "directed") for
            directed hyperedges, or "undirected" for undirected hyperedges.
        metadata: Optional dictionary of metadata about the hypergraph.
        incidences: A list of incidences, where each incidence is a dictionary with keys "node"
            and "edge" representing the relationship between a node and a hyperedge.
        nodes: A list of node dictionaries, where each dictionary contains information about
            a node (e.g., id, features).
        hyperedges: A list of edge dictionaries, where each dictionary contains information
            about a hyperedge (e.g., id, features).
    """

    def __init__(
        self,
        network_type: Literal["asc", "directed", "undirected"] | None = None,
        metadata: dict[str, Any] | None = None,
        incidences: list[dict[str, Any]] | None = None,
        nodes: list[dict[str, Any]] | None = None,
        hyperedges: list[dict[str, Any]] | None = None,
    ):
        """
        Initialize the HIF hypergraph.

        Args:
            network_type: The type of hypergraph, which can be "asc" (or "directed") for
                directed hyperedges, or "undirected" for undirected hyperedges.
            metadata: Optional dictionary of metadata about the hypergraph.
            incidences: A list of incidences, where each incidence is a dictionary with keys
                "node" and "edge" representing the relationship between a node and a hyperedge.
            nodes: A list of node dictionaries, where each dictionary contains information about
                a node (e.g., id, features).
            hyperedges: A list of edge dictionaries, where each dictionary contains information
                about a hyperedge (e.g., id, features).
        """
        self.network_type: Literal["asc", "directed", "undirected"] | None = network_type
        self.metadata: dict[str, Any] = metadata if metadata is not None else {}
        self.incidences: list[dict[str, Any]] = incidences if incidences is not None else []
        self.nodes: list[dict[str, Any]] = nodes if nodes is not None else []
        self.hyperedges: list[dict[str, Any]] = hyperedges if hyperedges is not None else []

    @classmethod
    def empty(cls) -> HIFHypergraph:
        """
        Create an empty undirected HIF hypergraph.

        Returns:
            hypergraph: Empty HIF hypergraph.
        """
        return cls(
            network_type="undirected",
            nodes=[],
            hyperedges=[],
            incidences=[],
            metadata=None,
        )

    @classmethod
    def from_hif(cls, data: dict[str, Any]) -> HIFHypergraph:
        """
        Create a Hypergraph from a HIF (Hypergraph Interchange Format).

        Args:
            data: Dictionary with keys: network-type, metadata, incidences, nodes, hyperedges

        Returns:
            hypergraph: Hypergraph instance
        """
        network_type = data.get("network-type") or data.get("network_type")
        metadata = data.get("metadata", {})
        incidences = data.get("incidences", [])
        nodes = data.get("nodes", [])
        hyperedges = data.get("edges", [])

        return cls(
            network_type=network_type,
            metadata=metadata,
            incidences=incidences,
            nodes=nodes,
            hyperedges=hyperedges,
        )

    @property
    def num_nodes(self) -> int:
        """
        Return the number of nodes in the hypergraph.
        """
        return len(self.nodes)

    @property
    def num_hyperedges(self) -> int:
        """
        Return the number of hyperedges in the hypergraph.
        """
        return len(self.hyperedges)

    def stats(self) -> dict[str, Any]:
        """
        Compute statistics for the HIFhypergraph.

        Fields:
            - ``num_nodes``: The number of nodes in the hypergraph.
            - ``num_hyperedges``: The number of hyperedges in the hypergraph.
            - ``avg_degree_node_raw``: The average degree of nodes, calculated as the mean
                number of hyperedges each node belongs to.
            - ``avg_degree_node``: The floored node average degree.
            - ``avg_degree_hyperedge_raw``: The average size of hyperedges, calculated as the
                mean number of nodes each hyperedge contains.
            - ``avg_degree_hyperedge``: The floored hyperedge average size.
            - ``node_degree_max``: The maximum degree of any node in the hypergraph.
            - ``hyperedge_degree_max``: The maximum size of any hyperedge in the hypergraph.
            - ``node_degree_median``: The median degree of nodes in the hypergraph.
            - ``hyperedge_degree_median``: The median size of hyperedges in the hypergraph.
            - ``distribution_node_degree``: A list where the value at index ``i`` represents
                the count of nodes with degree ``i``.
            - ``distribution_hyperedge_size``: A list where the value at index ``i`` represents
                the count of hyperedges with size ``i``.
            - ``distribution_node_degree_hist``: A dictionary where the keys are node degrees
                and the values are the count of nodes with that degree.
            - ``distribution_hyperedge_size_hist``: A dictionary where the keys are hyperedge
                sizes and the values are the count of hyperedges with that size.

        Returns:
            stats: A dictionary containing various statistics about the hypergraph.
        """
        node_degree: dict[Any, int] = {}
        hyperedge_size: dict[Any, int] = {}

        for incidence in self.incidences:
            node_id = incidence.get("node")
            edge_id = incidence.get("edge")
            node_degree[node_id] = node_degree.get(node_id, 0) + 1
            hyperedge_size[edge_id] = hyperedge_size.get(edge_id, 0) + 1

        num_nodes = len(self.nodes)
        num_hyperedges = len(self.hyperedges)
        total_incidences = len(self.incidences)

        distribution_node_degree: list[int] = sorted(node_degree.values())
        distribution_hyperedge_size: list[int] = sorted(hyperedge_size.values())

        avg_degree_node_raw = total_incidences / num_nodes if num_nodes else 0
        avg_degree_node = int(avg_degree_node_raw)
        avg_degree_hyperedge_raw = total_incidences / num_hyperedges if num_hyperedges else 0
        avg_degree_hyperedge = int(avg_degree_hyperedge_raw)

        node_degree_max = max(distribution_node_degree) if distribution_node_degree else 0
        hyperedge_degree_max = (
            max(distribution_hyperedge_size) if distribution_hyperedge_size else 0
        )

        n_n = len(distribution_node_degree)
        node_degree_median = (
            (
                distribution_node_degree[n_n // 2]
                if n_n % 2
                else (distribution_node_degree[n_n // 2 - 1] + distribution_node_degree[n_n // 2])
                / 2
            )
            if n_n
            else 0
        )

        n_e = len(distribution_hyperedge_size)
        hyperedge_degree_median = (
            (
                distribution_hyperedge_size[n_e // 2]
                if n_e % 2
                else (
                    distribution_hyperedge_size[n_e // 2 - 1]
                    + distribution_hyperedge_size[n_e // 2]
                )
                / 2
            )
            if n_e
            else 0
        )

        distribution_node_degree_hist: dict[int, int] = {}
        for d in distribution_node_degree:
            distribution_node_degree_hist[d] = distribution_node_degree_hist.get(d, 0) + 1

        distribution_hyperedge_size_hist: dict[int, int] = {}
        for s in distribution_hyperedge_size:
            distribution_hyperedge_size_hist[s] = distribution_hyperedge_size_hist.get(s, 0) + 1

        return {
            "num_nodes": num_nodes,
            "num_hyperedges": num_hyperedges,
            "avg_degree_node_raw": avg_degree_node_raw,
            "avg_degree_node": avg_degree_node,
            "avg_degree_hyperedge_raw": avg_degree_hyperedge_raw,
            "avg_degree_hyperedge": avg_degree_hyperedge,
            "node_degree_max": node_degree_max,
            "hyperedge_degree_max": hyperedge_degree_max,
            "node_degree_median": node_degree_median,
            "hyperedge_degree_median": hyperedge_degree_median,
            "distribution_node_degree": distribution_node_degree,
            "distribution_hyperedge_size": distribution_hyperedge_size,
            "distribution_node_degree_hist": distribution_node_degree_hist,
            "distribution_hyperedge_size_hist": distribution_hyperedge_size_hist,
        }

num_nodes property

Return the number of nodes in the hypergraph.

num_hyperedges property

Return the number of hyperedges in the hypergraph.

__init__(network_type=None, metadata=None, incidences=None, nodes=None, hyperedges=None)

Initialize the HIF hypergraph.

Parameters:

Name Type Description Default
network_type Literal['asc', 'directed', 'undirected'] | None

The type of hypergraph, which can be "asc" (or "directed") for directed hyperedges, or "undirected" for undirected hyperedges.

None
metadata dict[str, Any] | None

Optional dictionary of metadata about the hypergraph.

None
incidences list[dict[str, Any]] | None

A list of incidences, where each incidence is a dictionary with keys "node" and "edge" representing the relationship between a node and a hyperedge.

None
nodes list[dict[str, Any]] | None

A list of node dictionaries, where each dictionary contains information about a node (e.g., id, features).

None
hyperedges list[dict[str, Any]] | None

A list of edge dictionaries, where each dictionary contains information about a hyperedge (e.g., id, features).

None
Source code in hypertorch/types/hypergraph.py
def __init__(
    self,
    network_type: Literal["asc", "directed", "undirected"] | None = None,
    metadata: dict[str, Any] | None = None,
    incidences: list[dict[str, Any]] | None = None,
    nodes: list[dict[str, Any]] | None = None,
    hyperedges: list[dict[str, Any]] | None = None,
):
    """
    Initialize the HIF hypergraph.

    Args:
        network_type: The type of hypergraph, which can be "asc" (or "directed") for
            directed hyperedges, or "undirected" for undirected hyperedges.
        metadata: Optional dictionary of metadata about the hypergraph.
        incidences: A list of incidences, where each incidence is a dictionary with keys
            "node" and "edge" representing the relationship between a node and a hyperedge.
        nodes: A list of node dictionaries, where each dictionary contains information about
            a node (e.g., id, features).
        hyperedges: A list of edge dictionaries, where each dictionary contains information
            about a hyperedge (e.g., id, features).
    """
    self.network_type: Literal["asc", "directed", "undirected"] | None = network_type
    self.metadata: dict[str, Any] = metadata if metadata is not None else {}
    self.incidences: list[dict[str, Any]] = incidences if incidences is not None else []
    self.nodes: list[dict[str, Any]] = nodes if nodes is not None else []
    self.hyperedges: list[dict[str, Any]] = hyperedges if hyperedges is not None else []

empty() classmethod

Create an empty undirected HIF hypergraph.

Returns:

Name Type Description
hypergraph HIFHypergraph

Empty HIF hypergraph.

Source code in hypertorch/types/hypergraph.py
@classmethod
def empty(cls) -> HIFHypergraph:
    """
    Create an empty undirected HIF hypergraph.

    Returns:
        hypergraph: Empty HIF hypergraph.
    """
    return cls(
        network_type="undirected",
        nodes=[],
        hyperedges=[],
        incidences=[],
        metadata=None,
    )

from_hif(data) classmethod

Create a Hypergraph from a HIF (Hypergraph Interchange Format).

Parameters:

Name Type Description Default
data dict[str, Any]

Dictionary with keys: network-type, metadata, incidences, nodes, hyperedges

required

Returns:

Name Type Description
hypergraph HIFHypergraph

Hypergraph instance

Source code in hypertorch/types/hypergraph.py
@classmethod
def from_hif(cls, data: dict[str, Any]) -> HIFHypergraph:
    """
    Create a Hypergraph from a HIF (Hypergraph Interchange Format).

    Args:
        data: Dictionary with keys: network-type, metadata, incidences, nodes, hyperedges

    Returns:
        hypergraph: Hypergraph instance
    """
    network_type = data.get("network-type") or data.get("network_type")
    metadata = data.get("metadata", {})
    incidences = data.get("incidences", [])
    nodes = data.get("nodes", [])
    hyperedges = data.get("edges", [])

    return cls(
        network_type=network_type,
        metadata=metadata,
        incidences=incidences,
        nodes=nodes,
        hyperedges=hyperedges,
    )

stats()

Compute statistics for the HIFhypergraph.

Fields
  • num_nodes: The number of nodes in the hypergraph.
  • num_hyperedges: The number of hyperedges in the hypergraph.
  • avg_degree_node_raw: The average degree of nodes, calculated as the mean number of hyperedges each node belongs to.
  • avg_degree_node: The floored node average degree.
  • avg_degree_hyperedge_raw: The average size of hyperedges, calculated as the mean number of nodes each hyperedge contains.
  • avg_degree_hyperedge: The floored hyperedge average size.
  • node_degree_max: The maximum degree of any node in the hypergraph.
  • hyperedge_degree_max: The maximum size of any hyperedge in the hypergraph.
  • node_degree_median: The median degree of nodes in the hypergraph.
  • hyperedge_degree_median: The median size of hyperedges in the hypergraph.
  • distribution_node_degree: A list where the value at index i represents the count of nodes with degree i.
  • distribution_hyperedge_size: A list where the value at index i represents the count of hyperedges with size i.
  • distribution_node_degree_hist: A dictionary where the keys are node degrees and the values are the count of nodes with that degree.
  • distribution_hyperedge_size_hist: A dictionary where the keys are hyperedge sizes and the values are the count of hyperedges with that size.

Returns:

Name Type Description
stats dict[str, Any]

A dictionary containing various statistics about the hypergraph.

Source code in hypertorch/types/hypergraph.py
def stats(self) -> dict[str, Any]:
    """
    Compute statistics for the HIFhypergraph.

    Fields:
        - ``num_nodes``: The number of nodes in the hypergraph.
        - ``num_hyperedges``: The number of hyperedges in the hypergraph.
        - ``avg_degree_node_raw``: The average degree of nodes, calculated as the mean
            number of hyperedges each node belongs to.
        - ``avg_degree_node``: The floored node average degree.
        - ``avg_degree_hyperedge_raw``: The average size of hyperedges, calculated as the
            mean number of nodes each hyperedge contains.
        - ``avg_degree_hyperedge``: The floored hyperedge average size.
        - ``node_degree_max``: The maximum degree of any node in the hypergraph.
        - ``hyperedge_degree_max``: The maximum size of any hyperedge in the hypergraph.
        - ``node_degree_median``: The median degree of nodes in the hypergraph.
        - ``hyperedge_degree_median``: The median size of hyperedges in the hypergraph.
        - ``distribution_node_degree``: A list where the value at index ``i`` represents
            the count of nodes with degree ``i``.
        - ``distribution_hyperedge_size``: A list where the value at index ``i`` represents
            the count of hyperedges with size ``i``.
        - ``distribution_node_degree_hist``: A dictionary where the keys are node degrees
            and the values are the count of nodes with that degree.
        - ``distribution_hyperedge_size_hist``: A dictionary where the keys are hyperedge
            sizes and the values are the count of hyperedges with that size.

    Returns:
        stats: A dictionary containing various statistics about the hypergraph.
    """
    node_degree: dict[Any, int] = {}
    hyperedge_size: dict[Any, int] = {}

    for incidence in self.incidences:
        node_id = incidence.get("node")
        edge_id = incidence.get("edge")
        node_degree[node_id] = node_degree.get(node_id, 0) + 1
        hyperedge_size[edge_id] = hyperedge_size.get(edge_id, 0) + 1

    num_nodes = len(self.nodes)
    num_hyperedges = len(self.hyperedges)
    total_incidences = len(self.incidences)

    distribution_node_degree: list[int] = sorted(node_degree.values())
    distribution_hyperedge_size: list[int] = sorted(hyperedge_size.values())

    avg_degree_node_raw = total_incidences / num_nodes if num_nodes else 0
    avg_degree_node = int(avg_degree_node_raw)
    avg_degree_hyperedge_raw = total_incidences / num_hyperedges if num_hyperedges else 0
    avg_degree_hyperedge = int(avg_degree_hyperedge_raw)

    node_degree_max = max(distribution_node_degree) if distribution_node_degree else 0
    hyperedge_degree_max = (
        max(distribution_hyperedge_size) if distribution_hyperedge_size else 0
    )

    n_n = len(distribution_node_degree)
    node_degree_median = (
        (
            distribution_node_degree[n_n // 2]
            if n_n % 2
            else (distribution_node_degree[n_n // 2 - 1] + distribution_node_degree[n_n // 2])
            / 2
        )
        if n_n
        else 0
    )

    n_e = len(distribution_hyperedge_size)
    hyperedge_degree_median = (
        (
            distribution_hyperedge_size[n_e // 2]
            if n_e % 2
            else (
                distribution_hyperedge_size[n_e // 2 - 1]
                + distribution_hyperedge_size[n_e // 2]
            )
            / 2
        )
        if n_e
        else 0
    )

    distribution_node_degree_hist: dict[int, int] = {}
    for d in distribution_node_degree:
        distribution_node_degree_hist[d] = distribution_node_degree_hist.get(d, 0) + 1

    distribution_hyperedge_size_hist: dict[int, int] = {}
    for s in distribution_hyperedge_size:
        distribution_hyperedge_size_hist[s] = distribution_hyperedge_size_hist.get(s, 0) + 1

    return {
        "num_nodes": num_nodes,
        "num_hyperedges": num_hyperedges,
        "avg_degree_node_raw": avg_degree_node_raw,
        "avg_degree_node": avg_degree_node,
        "avg_degree_hyperedge_raw": avg_degree_hyperedge_raw,
        "avg_degree_hyperedge": avg_degree_hyperedge,
        "node_degree_max": node_degree_max,
        "hyperedge_degree_max": hyperedge_degree_max,
        "node_degree_median": node_degree_median,
        "hyperedge_degree_median": hyperedge_degree_median,
        "distribution_node_degree": distribution_node_degree,
        "distribution_hyperedge_size": distribution_hyperedge_size,
        "distribution_node_degree_hist": distribution_node_degree_hist,
        "distribution_hyperedge_size_hist": distribution_hyperedge_size_hist,
    }

HyperedgeIndex

A wrapper for hyperedge index representation. 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.

Examples:

>>> hyperedge_index = [[0, 1, 2, 0],
...                    [0, 0, 0, 1]]

This represents two hyperedges:

>>> - Hyperedge 0 connects nodes 0, 1, and 2.
... - Hyperedge 1 connects node 0.

The number of nodes in this hypergraph is 3 (nodes 0, 1, and 2). The number of hyperedges is 2 (hyperedges 0 and 1).

Source code in hypertorch/types/hypergraph.py
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
class HyperedgeIndex:
    """
    A wrapper for hyperedge index representation.
    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.

    Examples:
        >>> hyperedge_index = [[0, 1, 2, 0],
        ...                    [0, 0, 0, 1]]

        This represents two hyperedges:
        >>> - Hyperedge 0 connects nodes 0, 1, and 2.
        ... - Hyperedge 1 connects node 0.

        The number of nodes in this hypergraph is 3 (nodes 0, 1, and 2).
        The number of hyperedges is 2 (hyperedges 0 and 1).
    """

    def __init__(self, hyperedge_index: Tensor):
        """
        Initialize the hyperedge index wrapper.

        Args:
            hyperedge_index: Tensor of shape ``(2, num_incidences)`` representing hyperedges,
                where each column is ``(node, hyperedge)``.
        """
        self.__hyperedge_index: Tensor = hyperedge_index

    @property
    def all_node_ids(self) -> Tensor:
        """
        Return the tensor of all node IDs in the hyperedge index.
        """
        return self.__hyperedge_index[0]

    @property
    def all_hyperedge_ids(self) -> Tensor:
        """
        Return the tensor of all hyperedge IDs in the hyperedge index.
        """
        return self.__hyperedge_index[1]

    @property
    def item(self) -> Tensor:
        """
        Return the hyperedge index tensor.
        """
        return self.__hyperedge_index

    @property
    def node_ids(self) -> Tensor:
        """
        Return the sorted unique node IDs from the hyperedge index.
        """
        return self.__hyperedge_index[0].unique(sorted=True)

    @property
    def hyperedge_ids(self) -> Tensor:
        """
        Return the sorted unique hyperedge IDs from the hyperedge index.
        """
        return self.__hyperedge_index[1].unique(sorted=True)

    @property
    def num_hyperedges(self) -> int:
        """
        Return the number of hyperedges in the hypergraph.
        """
        if self.num_incidences < 1:
            return 0

        hyperedges = self.__hyperedge_index[1]
        return len(hyperedges.unique())

    @property
    def num_nodes(self) -> int:
        """
        Return the number of nodes in the hypergraph.
        """
        if self.num_incidences < 1:
            return 0

        nodes = self.__hyperedge_index[0]
        return len(nodes.unique())

    @property
    def num_incidences(self) -> int:
        """
        Return the number of incidences in the hypergraph, which is the number of columns in the
        hyperedge index.
        """
        return self.__hyperedge_index.size(1)

    def nodes_in(self, hyperedge_id: int) -> list[int]:
        """
        Return the list of node IDs that belong to the given hyperedge.

        Args:
            hyperedge_id: The ID of the hyperedge to query.

        Returns:
            node_ids: A list of node IDs that belong to the specified hyperedge.
        """
        validate_is_non_negative("hyperedge_id", hyperedge_id)
        return self.__hyperedge_index[0, self.__hyperedge_index[1] == hyperedge_id].tolist()

    def num_nodes_if_isolated_exist(self, num_nodes: int) -> int:
        """
        Return the number of nodes in the hypergraph, accounting for isolated nodes that may not
        appear in the hyperedge index.

        Args:
            num_nodes: The total number of nodes in the hypergraph, including isolated nodes.

        Returns:
            num_nodes: The number of nodes in the hypergraph, which is the maximum of the number of
                unique nodes in the hyperedge index and the provided ``num_nodes``.
        """
        return max(self.num_nodes, num_nodes)

    def get_clique_expansion_adjacency_list(self, num_nodes: int | None = None) -> list[set[int]]:
        """
        Build an adjacency list for the clique-expanded underlying graph.

        For each hyperedge, every pair of member nodes becomes an undirected graph edge.
        Self-loops are not included in the returned neighbor sets.

        Args:
            num_nodes: Total number of nodes to include in the adjacency list.
                If ``None``, inferred from the unique node IDs in ``hyperedge_index``.
                 Defaults to ``None``.

        Returns:
            adjacency: A list where ``adjacency[node_id]`` is the set of
                nodes adjacent to ``node_id``.
        """
        num_nodes = num_nodes if num_nodes is not None else self.num_nodes
        self.__validate_num_nodes(num_nodes)

        adjacency_list: list[set[int]] = [set() for _ in range(num_nodes)]

        for hyperedge_id in self.hyperedge_ids.tolist():
            node_ids: list[int] = (
                self.all_node_ids[self.all_hyperedge_ids == hyperedge_id].unique().tolist()
            )

            # Clique expansion: every pair of nodes in the same hyperedge
            # becomes an undirected graph edge
            # Example: hyperedge [0, 1, 2] adds (0, 1), (0, 2), and (1, 2):
            #          -> adjacency[0] = {1, 2}
            #          -> adjacency[1] = {0, 2}
            #          -> adjacency[2] = {0, 1}
            for first_raw_node_id, second_raw_node_id in combinations(node_ids, 2):
                first_node_id = int(first_raw_node_id)
                second_node_id = int(second_raw_node_id)
                adjacency_list[first_node_id].add(second_node_id)
                adjacency_list[second_node_id].add(first_node_id)

        return adjacency_list

    def get_sparse_incidence_matrix(
        self,
        num_nodes: int | None = None,
        num_hyperedges: int | None = None,
    ) -> Tensor:
        """
        Compute the sparse incidence matrix H of shape ``(num_nodes, num_hyperedges)``.
        Each entry ``H[v, e] = 1`` if node ``v`` belongs to hyperedge ``e``, and 0 otherwise.

        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.
                Defaults to ``None``.

        Returns:
            incidence_matrix: The sparse incidence matrix H of
                shape ``(num_nodes, num_hyperedges)``.

        Raises:
            ValueError: If the provided dimensions cannot contain the raw node or hyperedge IDs.
        """
        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
        self.__validate_num_nodes(num_nodes)
        self.__validate_num_hyperedges(num_hyperedges)

        device = self.__hyperedge_index.device
        incidence_values = torch.ones(self.num_incidences, dtype=torch.float, device=device)
        incidence_indices = torch.stack([self.all_node_ids, self.all_hyperedge_ids], dim=0)
        incidence_matrix = torch.sparse_coo_tensor(
            indices=incidence_indices,
            values=incidence_values,
            size=(num_nodes, num_hyperedges),
            dtype=incidence_values.dtype,
            device=device,
        )
        return incidence_matrix.coalesce()

    def get_sparse_normalized_node_degree_matrix(
        self,
        incidence_matrix: Tensor,
        power: float,
        num_nodes: int | None = 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.
                Defaults to ``None``.

        Returns:
            degree_matrix: The sparse diagonal matrix of shape ``(num_nodes, num_nodes)``.
        """
        num_nodes = num_nodes if num_nodes is not None else int(incidence_matrix.size(0))
        self.__validate_num_nodes(num_nodes)
        self.__validate_degree_matrix_dimension(
            name="num_nodes",
            value=num_nodes,
            expected=int(incidence_matrix.size(0)),
        )

        device = self.__hyperedge_index.device

        degrees = torch.sparse.sum(incidence_matrix, dim=1, dtype=torch.float).to_dense()
        normalized_degrees = degrees.pow(power)
        normalized_degrees[normalized_degrees == float("inf")] = 0

        diagonal_indices = (
            torch.arange(num_nodes, dtype=self.__hyperedge_index.dtype, device=device)
            .unsqueeze(0)
            .repeat(2, 1)
        )
        degree_matrix = torch.sparse_coo_tensor(
            indices=diagonal_indices,
            values=normalized_degrees,
            size=(num_nodes, num_nodes),
            dtype=normalized_degrees.dtype,
            device=device,
        )
        return degree_matrix.coalesce()

    def get_sparse_rownormalized_node_degree_matrix(
        self,
        incidence_matrix: Tensor,
        num_nodes: int | None = 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.
                Defaults to ``None``.

        Returns:
            degree_matrix: The sparse diagonal matrix `D_n^-1` of shape ``(num_nodes, 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]
        #          -> 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,
        )

    def get_sparse_symnormalized_node_degree_matrix(
        self,
        incidence_matrix: Tensor,
        num_nodes: int | None = None,
    ) -> Tensor:
        """
        Compute the sparse normalized node degree matrix `D_n^-1/2`.

        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.
                Defaults to ``None``.

        Returns:
            degree_matrix: The sparse diagonal matrix `D_n^-1/2`
                of shape ``(num_nodes, 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]
        #          -> 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,
        )

    def get_sparse_normalized_hyperedge_degree_matrix(
        self,
        incidence_matrix: Tensor,
        num_hyperedges: int | None = None,
    ) -> Tensor:
        """
        Compute the sparse normalized hyperedge degree matrix `D_e^-1`.

        The hyperedge degree ``d_e[j]`` is the number of nodes in hyperedge ``j``
        (i.e., the column-sum of the incidence matrix H).

        Args:
            incidence_matrix: The sparse incidence matrix H of
                shape ``(num_nodes, num_hyperedges)``.
            num_hyperedges: Total number of hyperedges. If ``None``, inferred from hyperedge index.
                Defaults to ``None``.

        Returns:
            degree_matrix: The sparse diagonal matrix `D_e^-1` of
                shape ``(num_hyperedges, num_hyperedges)``.
        """
        num_hyperedges = (
            num_hyperedges if num_hyperedges is not None else int(incidence_matrix.size(1))
        )
        self.__validate_num_hyperedges(num_hyperedges)
        self.__validate_degree_matrix_dimension(
            name="num_hyperedges",
            value=num_hyperedges,
            expected=int(incidence_matrix.size(1)),
        )

        device = self.__hyperedge_index.device

        # 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
        #          -> column-sum gives hyperedge degrees: d_e = [3, 1], shape (num_hyperedges,)
        degrees = torch.sparse.sum(incidence_matrix, dim=0, dtype=torch.float).to_dense()

        # Example: d_e = [3, 1]
        #          -> degree_inv = [1/3, 1]
        degree_inv = degrees.pow(-1)
        degree_inv[degree_inv == float("inf")] = 0

        # Construct the sparse diagonal matrix D_e^{-1}
        # Example: degree_inv = [1/3, 1] as the diagonal values,
        #          diagonal_indices = [[0, 0],
        #                              [1, 1]]
        #               hyperedges 0  1
        #          -> D_e^{-1} = [[1/3, 0], hyperedge 0
        #                         [0, 1]]   hyperedge 1
        diagonal_indices = (
            torch.arange(num_hyperedges, dtype=self.__hyperedge_index.dtype, device=device)
            .unsqueeze(0)
            .repeat(2, 1)
        )
        degree_matrix = torch.sparse_coo_tensor(
            indices=diagonal_indices,
            values=degree_inv,
            size=(num_hyperedges, num_hyperedges),
            dtype=degree_inv.dtype,
            device=device,
        )
        return degree_matrix.coalesce()

    def get_sparse_hgnn_smoothing_matrix(
        self,
        num_nodes: int | None = None,
        num_hyperedges: int | None = None,
    ) -> Tensor:
        """
        Compute the sparse HGNN Laplacian matrix for hypergraph spectral convolution.

        Implements: ``L_HGNN = D_n^{-1/2} H D_e^{-1} H^T D_n^{-1/2}``

        where:
            - H is the incidence matrix of shape ``(num_nodes, num_hyperedges)``
            - `D_n^-1/2` is the normalized node degree matrix
            - `D_e^-1` is the inverse hyperedge degree matrix (with W = I)

        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.
                Defaults to ``None``.

        Returns:
            laplacian: The sparse HGNN Laplacian 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
        self.__validate_num_nodes(num_nodes)
        self.__validate_num_hyperedges(num_hyperedges)

        incidence_matrix = self.get_sparse_incidence_matrix(num_nodes, num_hyperedges)
        node_degree_matrix = self.get_sparse_symnormalized_node_degree_matrix(
            incidence_matrix,
            num_nodes,
        )
        hyperedge_degree_matrix = self.get_sparse_normalized_hyperedge_degree_matrix(
            incidence_matrix, num_hyperedges
        )

        normalized_laplacian_matrix = torch.sparse.mm(
            node_degree_matrix,
            torch.sparse.mm(
                incidence_matrix,
                torch.sparse.mm(
                    hyperedge_degree_matrix,
                    torch.sparse.mm(incidence_matrix.t(), node_degree_matrix),
                ),
            ),
        )
        return normalized_laplacian_matrix.coalesce()

    def get_sparse_hgnnp_smoothing_matrix(
        self,
        num_nodes: int | None = None,
        num_hyperedges: int | None = 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. Defaults to ``None``.

        Returns:
            laplacian: 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
        self.__validate_num_nodes(num_nodes)
        self.__validate_num_hyperedges(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: GraphReductionStrategy, **kwargs: Any) -> Tensor:
        """
        Reduce the hypergraph to a graph represented by edge index using the specified strategy.

        Args:
            strategy: The reduction strategy to use. Defaults to ``clique_expansion``.
            **kwargs: Additional keyword arguments for specific strategies.

        Returns:
            edge_index: The edge index of the reduced graph. Size ``(2, num_edges)``.

        Raises:
            ValueError: If ``strategy`` is unsupported.
        """
        match strategy:
            case GraphReductionStrategyEnum.CLIQUE_EXPANSION:
                return self.reduce_to_edge_index_on_clique_expansion(**kwargs)
            case _:
                raise ValueError(
                    f"Unsupported reduction strategy: {strategy}. "
                    f"Supported strategies: {get_args(GraphReductionStrategyLiteral)}"
                )

    def reduce_to_edge_index_on_clique_expansion(
        self,
        num_nodes: int | None = None,
        num_hyperedges: int | None = None,
    ) -> Tensor:
        """
        Construct a graph from a hypergraph via clique expansion using ``H @ H^T``,
        where ``H`` is the incidence matrix of the hypergraph.
        In clique expansion, each hyperedge is replaced by a clique connecting all its member nodes.

        For each hyperedge, all pairs of member nodes become edges in the resulting graph.
        This is computed efficiently using the incidence matrix: ``A = H @ H^T``, where ``H`` is
        the sparse incidence matrix of shape ``[num_nodes, num_hyperedges]`` and ``A`` is
        the adjacency matrix of the clique-expanded graph.

        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.
                Defaults to ``None``.

        Returns:
            edge_index: The edge index of the clique-expanded graph. Size ``(2, |E'|)``.
        """
        self.__validate_num_nodes(num_nodes)
        self.__validate_num_hyperedges(num_hyperedges)

        incidence_matrix = self.get_sparse_incidence_matrix(
            num_nodes=num_nodes,
            num_hyperedges=num_hyperedges,
        )

        # A = H @ H^T gives adjacency with self-loops on diagonal
        # Example: For 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
        #          -> H^T = [[1, 1, 1], hyperedge 0
        #                    [1, 0, 0]] hyperedge 1
        #                       nodes 0  1  2
        #          -> A = H @ H^T = [[2, 1, 1], node 0
        #                            [1, 1, 1], node 1
        #                            [1, 1, 1]] node 2
        #                                         nodes 0  1  2
        #          -> A (after removing self-loops) = [[0, 1, 1], node 0
        #                                              [1, 0, 1], node 1
        #                                              [1, 1, 0]] node 2
        adj_matrix = torch.sparse.mm(incidence_matrix, incidence_matrix.t()).coalesce()

        # Extract edge_index, make undirected, and deduplicate
        return EdgeIndex(adj_matrix.indices()).to_undirected(num_nodes=num_nodes).item

    def reduce_to_edge_index_on_random_direction(
        self,
        x: Tensor,
        with_mediators: bool = False,
        remove_selfloops: bool = True,
        return_weights: bool = False,
        seed: int | None = None,
    ) -> tuple[Tensor, Tensor | None]:
        """
        References:
            - Construct a graph from a hypergraph with methods proposed in [HyperGCN: A New Method of Training Graph Convolutional Networks on Hypergraphs](https://arxiv.org/pdf/1809.02589.pdf) paper.
            - Reference implementation: [source](https://deephypergraph.readthedocs.io/en/latest/_modules/dhg/structure/graphs/graph.html#Graph.from_hypergraph_hypergcn).

        Args:
            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``.

        Returns:
            edge_index: The edge index of the reduced graph. Size ``(2, |num_edges|)``.
            edge_weights: The edge weights of the reduced graph. Size ``(|num_edges|,)`` when
                ``return_weights=True``, otherwise ``None``.

        Raises:
            ValueError: If any hyperedge contains fewer than 2 nodes.
        """  # noqa: E501
        device = x.device
        generator = create_seeded_torch_generator(device, seed)

        hypergraph = Hypergraph.from_hyperedge_index(self.__hyperedge_index)
        hypergraph_edges: list[list[int]] = hypergraph.hyperedges
        graph_edges: list[list[int]] = []
        graph_edge_weights: list[float] = []

        # Random direction (feature_dim, 1) for projecting nodes in each hyperedge
        # Geometrically, we are choosing a random line through the origin
        # in ℝᵈ, where ᵈ = feature_dim
        random_direction = torch.rand(
            size=(x.shape[1], 1),
            dtype=x.dtype,
            device=device,
            generator=generator,
        )

        for edge in hypergraph_edges:
            num_nodes_in_edge = len(edge)
            if num_nodes_in_edge < 2:
                raise ValueError("The number of vertices in an hyperedge must be >= 2.")

            # projections (num_nodes_in_edge,) contains a scalar value for
            # each node in the hyperedge,
            # indicating its projection on the random vector 'random_direction'.
            # Key idea: If two points are very far apart in ℝᵈ, there is a high probability
            # that a random projection will still separate them
            projections = torch.matmul(x[edge], random_direction).squeeze()

            # The indices of the nodes that the farthest apart in the
            # direction of 'random_direction'
            node_max_proj_idx = torch.argmax(projections)
            node_min_proj_idx = torch.argmin(projections)

            if not with_mediators:  # Just connect the two farthest nodes
                graph_edges.append([edge[node_min_proj_idx], edge[node_max_proj_idx]])
                graph_edge_weights.append(1.0 / num_nodes_in_edge)
                continue

            edge_weight = 1.0 / (2 * num_nodes_in_edge - 3)
            for node_idx in range(num_nodes_in_edge):
                if node_idx not in {node_max_proj_idx.item(), node_min_proj_idx.item()}:
                    graph_edges.append([edge[node_min_proj_idx], edge[node_idx]])
                    graph_edges.append([edge[node_max_proj_idx], edge[node_idx]])
                    graph_edge_weights.extend([edge_weight, edge_weight])

        graph = Graph(
            edges=graph_edges,
            edge_weights=graph_edge_weights if return_weights else None,
        )
        if remove_selfloops:
            graph.remove_selfloops()

        return (
            graph.to_edge_index().to(device),
            graph.edge_weights_tensor.to(device) if return_weights else None,
        )

    def remove_duplicate_edges(self) -> HyperedgeIndex:
        """
        Remove duplicate edges from the hyperedge index.

        Keeps the tensor contiguous in memory.
        """
        # Example: hyperedge_index = [[0, 1, 2, 2, 0, 3, 2],
        #                             [3, 4, 4, 3, 4, 3, 3]], shape (2, 7)
        #          -> after torch.unique(..., dim=1):
        #             hyperedge_index = [[0, 1, 2, 2, 0, 3],
        #                                [3, 4, 4, 3, 4, 3]], shape (2, |E'| = 6)
        # Note: we need to call contiguous() after torch.unique() to ensure
        # the resulting tensor is contiguous in memory, which is important for efficient indexing
        # and further operations (e.g., searchsorted)
        hyperedge_index_without_duplicates = cast(
            Tensor, torch.unique(self.__hyperedge_index, dim=1)
        )
        self.__hyperedge_index = hyperedge_index_without_duplicates.contiguous()
        return self

    def remove_hyperedges_with_fewer_than_k_nodes(self, k: int) -> HyperedgeIndex:
        """
        Remove hyperedges that contain fewer than k nodes.

        Examples:
            >>> hyperedge_index = [[0, 1, 2, 3, 5, 4],
            ...                    [0, 0, 1, 1, 2, 1]], shape (2, |E| = 6)

            >>> k = 3
            >>> unique_hyperedge_ids: [0, 1, 2]
            ... # inverse -> idx_to_hyperedge_id, counts -> num_nodes_per_hyperedge
            ... # (index into unique_hyperedge_ids per column)
            ... inverse           = [0, 0, 1, 1, 2, 1]
            ... counts            = [2, 3, 1]
            >>> # counts[inverse] is equivalent to:
            ... # for i, inv in enumerate(inverse): keep_mask[i] = counts[inv]
            >>> counts[inverse]   = [2, 2, 3, 3, 1, 3]
            >>> keep_mask         = [F, F, T, T, F, T]

            >>> # after filtering hyperedges with fewer than k=3 nodes:
            >>> hyperedge_index = [[2, 3, 4],
            ...                    [1, 1, 1]], shape (2, |E'| = 3)

        Args:
            k: The minimum number of nodes a hyperedge must contain to be kept.

        Returns:
            hyperedge_index: A new `HyperedgeIndex` instance with hyperedges
                containing fewer than k nodes.
        """
        validate_is_positive("k", k)

        _, idx_to_hyperedge_id, num_nodes_per_hyperedge = torch.unique(
            self.all_hyperedge_ids,
            return_inverse=True,
            return_counts=True,
        )
        keep_mask = num_nodes_per_hyperedge[idx_to_hyperedge_id] >= k
        self.__hyperedge_index = self.__hyperedge_index[:, keep_mask]
        return self

    def to_0based(
        self,
        node_ids_to_rebase: Tensor | None = None,
        hyperedge_ids_to_rebase: Tensor | None = None,
    ) -> HyperedgeIndex:
        """
        Convert hyperedge index to the 0-based format by rebasing node IDs to the range ``[0,
        num_nodes-1]`` and hyperedge IDs ``[0, num_hyperedges-1]``.

        Args:
            node_ids_to_rebase: Tensor of shape ``(num_nodes,)`` containing the original node IDs
                that need to be rebased to 0-based format.
                If ``None``, all node IDs in the hyperedge index will be rebased to 0-based format
                based on their unique sorted order. Defaults to ``None``.
            hyperedge_ids_to_rebase: Tensor of shape ``(num_hyperedges,)`` containing the original
                hyperedge IDs that need to be rebased to 0-based format.
                If ``None``, all hyperedge IDs in the hyperedge index will be rebased to
                0-based format based on their unique sorted order. Defaults to ``None``.

        Returns:
            hyperedge_index: A new `HyperedgeIndex` instance with the hyperedge index
                converted to 0-based format.
        """
        # Example: hyperedge_index after sorting: [[0, 0, 1, 2, 3, 4],
        #                                          [3, 4, 4, 3, 4, 3]]
        #          node_ids_to_rebase = [0, 1, 2, 3, 4]
        #          -> hyperedge_index after remapping: [[0, 0, 1, 2, 3, 4],
        #                                               [3, 4, 4, 3, 4, 3]]
        self.__hyperedge_index[0] = to_0based_ids(self.all_node_ids, node_ids_to_rebase)

        # Example: hyperedge_index after remapping nodes: [[0, 0, 1, 2, 3, 4],
        #                                                  [3, 4, 4, 3, 4, 3]]
        #          hyperedge_ids_to_rebase = [3, 4]
        #          -> hyperedge_index after remapping hyperedges: [[0, 0, 1, 2, 3, 4],
        #                                                          [0, 0, 1, 0, 1, 0]]
        self.__hyperedge_index[1] = to_0based_ids(self.all_hyperedge_ids, hyperedge_ids_to_rebase)

        return self

    def to_global(self, global_node_ids: Tensor | None = None) -> HyperedgeIndex:
        """
        Convert hyperedge index to the global format by rebasing node IDs to the original IDs.

        Args:
            global_node_ids: Tensor of shape ``(num_nodes,)`` containing the original node IDs
                that need to be rebased to global format.
                If ``None``, the hyperedge index will remain unchanged. Defaults to ``None``.

        Returns:
            hyperedge_index: A new `HyperedgeIndex` instance with the hyperedge index
                converted to global format.
        """
        if global_node_ids is None:
            return self

        hyperedge_ids = self.__hyperedge_index[1]
        local_node_ids = self.__hyperedge_index[0]
        global_node_ids = global_node_ids[local_node_ids]
        global_hyperedge_index = torch.stack((global_node_ids, hyperedge_ids), dim=0)
        self.__hyperedge_index = global_hyperedge_index.contiguous()
        return self

    def __validate_num_hyperedges(self, num_hyperedges: int | None) -> None:
        """
        Validate that an explicit hyperedge count can contain the index.

        Args:
            num_hyperedges: Optional explicit number of hyperedges.

        Raises:
            ValueError: If ``num_hyperedges`` is negative or smaller than the maximum ID.
        """
        if num_hyperedges is None:
            return
        validate_is_non_negative("num_hyperedges", num_hyperedges)

        if self.all_hyperedge_ids.numel() < 1:
            return

        max_hyperedge_id = int(self.all_hyperedge_ids.max().item())
        if max_hyperedge_id >= num_hyperedges:
            raise ValueError(
                f"'num_hyperedges' is too small for the hyperedge index. "
                f"Got num_hyperedges={num_hyperedges}, but max hyperedge id is {max_hyperedge_id}."
            )

    def __validate_num_nodes(self, num_nodes: int | None) -> None:
        """
        Validate that an explicit node count can contain the index.

        Args:
            num_nodes: Optional explicit number of nodes.

        Raises:
            ValueError: If ``num_nodes`` is negative or smaller than the maximum ID.
        """
        if num_nodes is None:
            return
        validate_is_non_negative("num_nodes", num_nodes)

        if self.all_node_ids.numel() < 1:
            return

        max_node_id = int(self.all_node_ids.max().item())
        if max_node_id >= num_nodes:
            raise ValueError(
                f"'num_nodes' is too small for the hyperedge index. "
                f"Got num_nodes={num_nodes}, but max node id is {max_node_id}."
            )

    def __validate_degree_matrix_dimension(self, name: str, value: int, expected: int) -> None:
        """
        Validate a requested degree-matrix dimension.

        Args:
            name: Name of the dimension being validated.
            value: Requested dimension value.
            expected: Required dimension value.

        Raises:
            ValueError: If the value is negative or does not match the expected dimension.
        """
        validate_is_non_negative(name, value)
        if value != expected:
            raise ValueError(
                f"'{name}' must match the incidence matrix dimension. "
                f"Got {name}={value}, but expected {expected}."
            )

all_node_ids property

Return the tensor of all node IDs in the hyperedge index.

all_hyperedge_ids property

Return the tensor of all hyperedge IDs in the hyperedge index.

item property

Return the hyperedge index tensor.

node_ids property

Return the sorted unique node IDs from the hyperedge index.

hyperedge_ids property

Return the sorted unique hyperedge IDs from the hyperedge index.

num_hyperedges property

Return the number of hyperedges in the hypergraph.

num_nodes property

Return the number of nodes in the hypergraph.

num_incidences property

Return the number of incidences in the hypergraph, which is the number of columns in the hyperedge index.

__init__(hyperedge_index)

Initialize the hyperedge index wrapper.

Parameters:

Name Type Description Default
hyperedge_index Tensor

Tensor of shape (2, num_incidences) representing hyperedges, where each column is (node, hyperedge).

required
Source code in hypertorch/types/hypergraph.py
def __init__(self, hyperedge_index: Tensor):
    """
    Initialize the hyperedge index wrapper.

    Args:
        hyperedge_index: Tensor of shape ``(2, num_incidences)`` representing hyperedges,
            where each column is ``(node, hyperedge)``.
    """
    self.__hyperedge_index: Tensor = hyperedge_index

nodes_in(hyperedge_id)

Return the list of node IDs that belong to the given hyperedge.

Parameters:

Name Type Description Default
hyperedge_id int

The ID of the hyperedge to query.

required

Returns:

Name Type Description
node_ids list[int]

A list of node IDs that belong to the specified hyperedge.

Source code in hypertorch/types/hypergraph.py
def nodes_in(self, hyperedge_id: int) -> list[int]:
    """
    Return the list of node IDs that belong to the given hyperedge.

    Args:
        hyperedge_id: The ID of the hyperedge to query.

    Returns:
        node_ids: A list of node IDs that belong to the specified hyperedge.
    """
    validate_is_non_negative("hyperedge_id", hyperedge_id)
    return self.__hyperedge_index[0, self.__hyperedge_index[1] == hyperedge_id].tolist()

num_nodes_if_isolated_exist(num_nodes)

Return the number of nodes in the hypergraph, accounting for isolated nodes that may not appear in the hyperedge index.

Parameters:

Name Type Description Default
num_nodes int

The total number of nodes in the hypergraph, including isolated nodes.

required

Returns:

Name Type Description
num_nodes int

The number of nodes in the hypergraph, which is the maximum of the number of unique nodes in the hyperedge index and the provided num_nodes.

Source code in hypertorch/types/hypergraph.py
def num_nodes_if_isolated_exist(self, num_nodes: int) -> int:
    """
    Return the number of nodes in the hypergraph, accounting for isolated nodes that may not
    appear in the hyperedge index.

    Args:
        num_nodes: The total number of nodes in the hypergraph, including isolated nodes.

    Returns:
        num_nodes: The number of nodes in the hypergraph, which is the maximum of the number of
            unique nodes in the hyperedge index and the provided ``num_nodes``.
    """
    return max(self.num_nodes, num_nodes)

get_clique_expansion_adjacency_list(num_nodes=None)

Build an adjacency list for the clique-expanded underlying graph.

For each hyperedge, every pair of member nodes becomes an undirected graph edge. Self-loops are not included in the returned neighbor sets.

Parameters:

Name Type Description Default
num_nodes int | None

Total number of nodes to include in the adjacency list. If None, inferred from the unique node IDs in hyperedge_index. Defaults to None.

None

Returns:

Name Type Description
adjacency list[set[int]]

A list where adjacency[node_id] is the set of nodes adjacent to node_id.

Source code in hypertorch/types/hypergraph.py
def get_clique_expansion_adjacency_list(self, num_nodes: int | None = None) -> list[set[int]]:
    """
    Build an adjacency list for the clique-expanded underlying graph.

    For each hyperedge, every pair of member nodes becomes an undirected graph edge.
    Self-loops are not included in the returned neighbor sets.

    Args:
        num_nodes: Total number of nodes to include in the adjacency list.
            If ``None``, inferred from the unique node IDs in ``hyperedge_index``.
             Defaults to ``None``.

    Returns:
        adjacency: A list where ``adjacency[node_id]`` is the set of
            nodes adjacent to ``node_id``.
    """
    num_nodes = num_nodes if num_nodes is not None else self.num_nodes
    self.__validate_num_nodes(num_nodes)

    adjacency_list: list[set[int]] = [set() for _ in range(num_nodes)]

    for hyperedge_id in self.hyperedge_ids.tolist():
        node_ids: list[int] = (
            self.all_node_ids[self.all_hyperedge_ids == hyperedge_id].unique().tolist()
        )

        # Clique expansion: every pair of nodes in the same hyperedge
        # becomes an undirected graph edge
        # Example: hyperedge [0, 1, 2] adds (0, 1), (0, 2), and (1, 2):
        #          -> adjacency[0] = {1, 2}
        #          -> adjacency[1] = {0, 2}
        #          -> adjacency[2] = {0, 1}
        for first_raw_node_id, second_raw_node_id in combinations(node_ids, 2):
            first_node_id = int(first_raw_node_id)
            second_node_id = int(second_raw_node_id)
            adjacency_list[first_node_id].add(second_node_id)
            adjacency_list[second_node_id].add(first_node_id)

    return adjacency_list

get_sparse_incidence_matrix(num_nodes=None, num_hyperedges=None)

Compute the sparse incidence matrix H of shape (num_nodes, num_hyperedges). Each entry H[v, e] = 1 if node v belongs to hyperedge e, and 0 otherwise.

Parameters:

Name Type Description Default
num_nodes int | None

Total number of nodes. If None, inferred from hyperedge index.

None
num_hyperedges int | None

Total number of hyperedges. If None, inferred from hyperedge index. Defaults to None.

None

Returns:

Name Type Description
incidence_matrix Tensor

The sparse incidence matrix H of shape (num_nodes, num_hyperedges).

Raises:

Type Description
ValueError

If the provided dimensions cannot contain the raw node or hyperedge IDs.

Source code in hypertorch/types/hypergraph.py
def get_sparse_incidence_matrix(
    self,
    num_nodes: int | None = None,
    num_hyperedges: int | None = None,
) -> Tensor:
    """
    Compute the sparse incidence matrix H of shape ``(num_nodes, num_hyperedges)``.
    Each entry ``H[v, e] = 1`` if node ``v`` belongs to hyperedge ``e``, and 0 otherwise.

    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.
            Defaults to ``None``.

    Returns:
        incidence_matrix: The sparse incidence matrix H of
            shape ``(num_nodes, num_hyperedges)``.

    Raises:
        ValueError: If the provided dimensions cannot contain the raw node or hyperedge IDs.
    """
    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
    self.__validate_num_nodes(num_nodes)
    self.__validate_num_hyperedges(num_hyperedges)

    device = self.__hyperedge_index.device
    incidence_values = torch.ones(self.num_incidences, dtype=torch.float, device=device)
    incidence_indices = torch.stack([self.all_node_ids, self.all_hyperedge_ids], dim=0)
    incidence_matrix = torch.sparse_coo_tensor(
        indices=incidence_indices,
        values=incidence_values,
        size=(num_nodes, num_hyperedges),
        dtype=incidence_values.dtype,
        device=device,
    )
    return incidence_matrix.coalesce()

get_sparse_normalized_node_degree_matrix(incidence_matrix, power, num_nodes=None)

Compute a sparse diagonal node degree matrix from row-sums of the incidence matrix.

Parameters:

Name Type Description Default
incidence_matrix Tensor

The sparse incidence matrix H of shape (num_nodes, num_hyperedges).

required
power float

Exponent applied to node degrees before placing them on the diagonal.

required
num_nodes int | None

Total number of nodes. If None, inferred from hyperedge index. Defaults to None.

None

Returns:

Name Type Description
degree_matrix Tensor

The sparse diagonal matrix of shape (num_nodes, num_nodes).

Source code in hypertorch/types/hypergraph.py
def get_sparse_normalized_node_degree_matrix(
    self,
    incidence_matrix: Tensor,
    power: float,
    num_nodes: int | None = 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.
            Defaults to ``None``.

    Returns:
        degree_matrix: The sparse diagonal matrix of shape ``(num_nodes, num_nodes)``.
    """
    num_nodes = num_nodes if num_nodes is not None else int(incidence_matrix.size(0))
    self.__validate_num_nodes(num_nodes)
    self.__validate_degree_matrix_dimension(
        name="num_nodes",
        value=num_nodes,
        expected=int(incidence_matrix.size(0)),
    )

    device = self.__hyperedge_index.device

    degrees = torch.sparse.sum(incidence_matrix, dim=1, dtype=torch.float).to_dense()
    normalized_degrees = degrees.pow(power)
    normalized_degrees[normalized_degrees == float("inf")] = 0

    diagonal_indices = (
        torch.arange(num_nodes, dtype=self.__hyperedge_index.dtype, device=device)
        .unsqueeze(0)
        .repeat(2, 1)
    )
    degree_matrix = torch.sparse_coo_tensor(
        indices=diagonal_indices,
        values=normalized_degrees,
        size=(num_nodes, num_nodes),
        dtype=normalized_degrees.dtype,
        device=device,
    )
    return degree_matrix.coalesce()

get_sparse_rownormalized_node_degree_matrix(incidence_matrix, num_nodes=None)

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).

Parameters:

Name Type Description Default
incidence_matrix Tensor

The sparse incidence matrix H of shape (num_nodes, num_hyperedges).

required
num_nodes int | None

Total number of nodes. If None, inferred from hyperedge index. Defaults to None.

None

Returns:

Name Type Description
degree_matrix Tensor

The sparse diagonal matrix D_n^-1 of shape (num_nodes, num_nodes).

Source code in hypertorch/types/hypergraph.py
def get_sparse_rownormalized_node_degree_matrix(
    self,
    incidence_matrix: Tensor,
    num_nodes: int | None = 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.
            Defaults to ``None``.

    Returns:
        degree_matrix: The sparse diagonal matrix `D_n^-1` of shape ``(num_nodes, 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]
    #          -> 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,
    )

get_sparse_symnormalized_node_degree_matrix(incidence_matrix, num_nodes=None)

Compute the sparse normalized node degree matrix D_n^-1/2.

The node degree d_n[i] is the number of hyperedges containing node i (i.e., the row-sum of the incidence matrix H).

Parameters:

Name Type Description Default
incidence_matrix Tensor

The sparse incidence matrix H of shape (num_nodes, num_hyperedges).

required
num_nodes int | None

Total number of nodes. If None, inferred from hyperedge index. Defaults to None.

None

Returns:

Name Type Description
degree_matrix Tensor

The sparse diagonal matrix D_n^-1/2 of shape (num_nodes, num_nodes).

Source code in hypertorch/types/hypergraph.py
def get_sparse_symnormalized_node_degree_matrix(
    self,
    incidence_matrix: Tensor,
    num_nodes: int | None = None,
) -> Tensor:
    """
    Compute the sparse normalized node degree matrix `D_n^-1/2`.

    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.
            Defaults to ``None``.

    Returns:
        degree_matrix: The sparse diagonal matrix `D_n^-1/2`
            of shape ``(num_nodes, 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]
    #          -> 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,
    )

get_sparse_normalized_hyperedge_degree_matrix(incidence_matrix, num_hyperedges=None)

Compute the sparse normalized hyperedge degree matrix D_e^-1.

The hyperedge degree d_e[j] is the number of nodes in hyperedge j (i.e., the column-sum of the incidence matrix H).

Parameters:

Name Type Description Default
incidence_matrix Tensor

The sparse incidence matrix H of shape (num_nodes, num_hyperedges).

required
num_hyperedges int | None

Total number of hyperedges. If None, inferred from hyperedge index. Defaults to None.

None

Returns:

Name Type Description
degree_matrix Tensor

The sparse diagonal matrix D_e^-1 of shape (num_hyperedges, num_hyperedges).

Source code in hypertorch/types/hypergraph.py
def get_sparse_normalized_hyperedge_degree_matrix(
    self,
    incidence_matrix: Tensor,
    num_hyperedges: int | None = None,
) -> Tensor:
    """
    Compute the sparse normalized hyperedge degree matrix `D_e^-1`.

    The hyperedge degree ``d_e[j]`` is the number of nodes in hyperedge ``j``
    (i.e., the column-sum of the incidence matrix H).

    Args:
        incidence_matrix: The sparse incidence matrix H of
            shape ``(num_nodes, num_hyperedges)``.
        num_hyperedges: Total number of hyperedges. If ``None``, inferred from hyperedge index.
            Defaults to ``None``.

    Returns:
        degree_matrix: The sparse diagonal matrix `D_e^-1` of
            shape ``(num_hyperedges, num_hyperedges)``.
    """
    num_hyperedges = (
        num_hyperedges if num_hyperedges is not None else int(incidence_matrix.size(1))
    )
    self.__validate_num_hyperedges(num_hyperedges)
    self.__validate_degree_matrix_dimension(
        name="num_hyperedges",
        value=num_hyperedges,
        expected=int(incidence_matrix.size(1)),
    )

    device = self.__hyperedge_index.device

    # 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
    #          -> column-sum gives hyperedge degrees: d_e = [3, 1], shape (num_hyperedges,)
    degrees = torch.sparse.sum(incidence_matrix, dim=0, dtype=torch.float).to_dense()

    # Example: d_e = [3, 1]
    #          -> degree_inv = [1/3, 1]
    degree_inv = degrees.pow(-1)
    degree_inv[degree_inv == float("inf")] = 0

    # Construct the sparse diagonal matrix D_e^{-1}
    # Example: degree_inv = [1/3, 1] as the diagonal values,
    #          diagonal_indices = [[0, 0],
    #                              [1, 1]]
    #               hyperedges 0  1
    #          -> D_e^{-1} = [[1/3, 0], hyperedge 0
    #                         [0, 1]]   hyperedge 1
    diagonal_indices = (
        torch.arange(num_hyperedges, dtype=self.__hyperedge_index.dtype, device=device)
        .unsqueeze(0)
        .repeat(2, 1)
    )
    degree_matrix = torch.sparse_coo_tensor(
        indices=diagonal_indices,
        values=degree_inv,
        size=(num_hyperedges, num_hyperedges),
        dtype=degree_inv.dtype,
        device=device,
    )
    return degree_matrix.coalesce()

get_sparse_hgnn_smoothing_matrix(num_nodes=None, num_hyperedges=None)

Compute the sparse HGNN Laplacian matrix for hypergraph spectral convolution.

Implements: L_HGNN = D_n^{-1/2} H D_e^{-1} H^T D_n^{-1/2}

where
  • H is the incidence matrix of shape (num_nodes, num_hyperedges)
  • D_n^-1/2 is the normalized node degree matrix
  • D_e^-1 is the inverse hyperedge degree matrix (with W = I)

Parameters:

Name Type Description Default
num_nodes int | None

Total number of nodes. If None, inferred from hyperedge index.

None
num_hyperedges int | None

Total number of hyperedges. If None, inferred from hyperedge index. Defaults to None.

None

Returns:

Name Type Description
laplacian Tensor

The sparse HGNN Laplacian matrix of shape (num_nodes, num_nodes).

Source code in hypertorch/types/hypergraph.py
def get_sparse_hgnn_smoothing_matrix(
    self,
    num_nodes: int | None = None,
    num_hyperedges: int | None = None,
) -> Tensor:
    """
    Compute the sparse HGNN Laplacian matrix for hypergraph spectral convolution.

    Implements: ``L_HGNN = D_n^{-1/2} H D_e^{-1} H^T D_n^{-1/2}``

    where:
        - H is the incidence matrix of shape ``(num_nodes, num_hyperedges)``
        - `D_n^-1/2` is the normalized node degree matrix
        - `D_e^-1` is the inverse hyperedge degree matrix (with W = I)

    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.
            Defaults to ``None``.

    Returns:
        laplacian: The sparse HGNN Laplacian 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
    self.__validate_num_nodes(num_nodes)
    self.__validate_num_hyperedges(num_hyperedges)

    incidence_matrix = self.get_sparse_incidence_matrix(num_nodes, num_hyperedges)
    node_degree_matrix = self.get_sparse_symnormalized_node_degree_matrix(
        incidence_matrix,
        num_nodes,
    )
    hyperedge_degree_matrix = self.get_sparse_normalized_hyperedge_degree_matrix(
        incidence_matrix, num_hyperedges
    )

    normalized_laplacian_matrix = torch.sparse.mm(
        node_degree_matrix,
        torch.sparse.mm(
            incidence_matrix,
            torch.sparse.mm(
                hyperedge_degree_matrix,
                torch.sparse.mm(incidence_matrix.t(), node_degree_matrix),
            ),
        ),
    )
    return normalized_laplacian_matrix.coalesce()

get_sparse_hgnnp_smoothing_matrix(num_nodes=None, num_hyperedges=None)

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.

Parameters:

Name Type Description Default
num_nodes int | None

Total number of nodes. If None, inferred from hyperedge index.

None
num_hyperedges int | None

Total number of hyperedges. If None, inferred from hyperedge index. Defaults to None.

None

Returns:

Name Type Description
laplacian Tensor

The sparse HGNN+ smoothing matrix of shape (num_nodes, num_nodes).

Source code in hypertorch/types/hypergraph.py
def get_sparse_hgnnp_smoothing_matrix(
    self,
    num_nodes: int | None = None,
    num_hyperedges: int | None = 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. Defaults to ``None``.

    Returns:
        laplacian: 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
    self.__validate_num_nodes(num_nodes)
    self.__validate_num_hyperedges(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()

reduce(strategy, **kwargs)

Reduce the hypergraph to a graph represented by edge index using the specified strategy.

Parameters:

Name Type Description Default
strategy GraphReductionStrategy

The reduction strategy to use. Defaults to clique_expansion.

required
**kwargs Any

Additional keyword arguments for specific strategies.

{}

Returns:

Name Type Description
edge_index Tensor

The edge index of the reduced graph. Size (2, num_edges).

Raises:

Type Description
ValueError

If strategy is unsupported.

Source code in hypertorch/types/hypergraph.py
def reduce(self, strategy: GraphReductionStrategy, **kwargs: Any) -> Tensor:
    """
    Reduce the hypergraph to a graph represented by edge index using the specified strategy.

    Args:
        strategy: The reduction strategy to use. Defaults to ``clique_expansion``.
        **kwargs: Additional keyword arguments for specific strategies.

    Returns:
        edge_index: The edge index of the reduced graph. Size ``(2, num_edges)``.

    Raises:
        ValueError: If ``strategy`` is unsupported.
    """
    match strategy:
        case GraphReductionStrategyEnum.CLIQUE_EXPANSION:
            return self.reduce_to_edge_index_on_clique_expansion(**kwargs)
        case _:
            raise ValueError(
                f"Unsupported reduction strategy: {strategy}. "
                f"Supported strategies: {get_args(GraphReductionStrategyLiteral)}"
            )

reduce_to_edge_index_on_clique_expansion(num_nodes=None, num_hyperedges=None)

Construct a graph from a hypergraph via clique expansion using H @ H^T, where H is the incidence matrix of the hypergraph. In clique expansion, each hyperedge is replaced by a clique connecting all its member nodes.

For each hyperedge, all pairs of member nodes become edges in the resulting graph. This is computed efficiently using the incidence matrix: A = H @ H^T, where H is the sparse incidence matrix of shape [num_nodes, num_hyperedges] and A is the adjacency matrix of the clique-expanded graph.

Parameters:

Name Type Description Default
num_nodes int | None

Total number of nodes. If None, inferred from hyperedge index.

None
num_hyperedges int | None

Total number of hyperedges. If None, inferred from hyperedge index. Defaults to None.

None

Returns:

Name Type Description
edge_index Tensor

The edge index of the clique-expanded graph. Size (2, |E'|).

Source code in hypertorch/types/hypergraph.py
def reduce_to_edge_index_on_clique_expansion(
    self,
    num_nodes: int | None = None,
    num_hyperedges: int | None = None,
) -> Tensor:
    """
    Construct a graph from a hypergraph via clique expansion using ``H @ H^T``,
    where ``H`` is the incidence matrix of the hypergraph.
    In clique expansion, each hyperedge is replaced by a clique connecting all its member nodes.

    For each hyperedge, all pairs of member nodes become edges in the resulting graph.
    This is computed efficiently using the incidence matrix: ``A = H @ H^T``, where ``H`` is
    the sparse incidence matrix of shape ``[num_nodes, num_hyperedges]`` and ``A`` is
    the adjacency matrix of the clique-expanded graph.

    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.
            Defaults to ``None``.

    Returns:
        edge_index: The edge index of the clique-expanded graph. Size ``(2, |E'|)``.
    """
    self.__validate_num_nodes(num_nodes)
    self.__validate_num_hyperedges(num_hyperedges)

    incidence_matrix = self.get_sparse_incidence_matrix(
        num_nodes=num_nodes,
        num_hyperedges=num_hyperedges,
    )

    # A = H @ H^T gives adjacency with self-loops on diagonal
    # Example: For 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
    #          -> H^T = [[1, 1, 1], hyperedge 0
    #                    [1, 0, 0]] hyperedge 1
    #                       nodes 0  1  2
    #          -> A = H @ H^T = [[2, 1, 1], node 0
    #                            [1, 1, 1], node 1
    #                            [1, 1, 1]] node 2
    #                                         nodes 0  1  2
    #          -> A (after removing self-loops) = [[0, 1, 1], node 0
    #                                              [1, 0, 1], node 1
    #                                              [1, 1, 0]] node 2
    adj_matrix = torch.sparse.mm(incidence_matrix, incidence_matrix.t()).coalesce()

    # Extract edge_index, make undirected, and deduplicate
    return EdgeIndex(adj_matrix.indices()).to_undirected(num_nodes=num_nodes).item

reduce_to_edge_index_on_random_direction(x, with_mediators=False, remove_selfloops=True, return_weights=False, seed=None)

References

Parameters:

Name Type Description Default
x Tensor

Node feature matrix. Size (num_nodes, C).

required
with_mediators bool

Whether to use mediator to transform the hyperedges to edges in the graph. Defaults to False.

False
remove_selfloops bool

Whether to remove self-loops. Defaults to True.

True
return_weights bool

Whether to return the DHG-style reduced-edge weights alongside the edge index. Defaults to False.

False

Returns:

Name Type Description
edge_index Tensor

The edge index of the reduced graph. Size (2, |num_edges|).

edge_weights Tensor | None

The edge weights of the reduced graph. Size (|num_edges|,) when return_weights=True, otherwise None.

Raises:

Type Description
ValueError

If any hyperedge contains fewer than 2 nodes.

Source code in hypertorch/types/hypergraph.py
def reduce_to_edge_index_on_random_direction(
    self,
    x: Tensor,
    with_mediators: bool = False,
    remove_selfloops: bool = True,
    return_weights: bool = False,
    seed: int | None = None,
) -> tuple[Tensor, Tensor | None]:
    """
    References:
        - Construct a graph from a hypergraph with methods proposed in [HyperGCN: A New Method of Training Graph Convolutional Networks on Hypergraphs](https://arxiv.org/pdf/1809.02589.pdf) paper.
        - Reference implementation: [source](https://deephypergraph.readthedocs.io/en/latest/_modules/dhg/structure/graphs/graph.html#Graph.from_hypergraph_hypergcn).

    Args:
        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``.

    Returns:
        edge_index: The edge index of the reduced graph. Size ``(2, |num_edges|)``.
        edge_weights: The edge weights of the reduced graph. Size ``(|num_edges|,)`` when
            ``return_weights=True``, otherwise ``None``.

    Raises:
        ValueError: If any hyperedge contains fewer than 2 nodes.
    """  # noqa: E501
    device = x.device
    generator = create_seeded_torch_generator(device, seed)

    hypergraph = Hypergraph.from_hyperedge_index(self.__hyperedge_index)
    hypergraph_edges: list[list[int]] = hypergraph.hyperedges
    graph_edges: list[list[int]] = []
    graph_edge_weights: list[float] = []

    # Random direction (feature_dim, 1) for projecting nodes in each hyperedge
    # Geometrically, we are choosing a random line through the origin
    # in ℝᵈ, where ᵈ = feature_dim
    random_direction = torch.rand(
        size=(x.shape[1], 1),
        dtype=x.dtype,
        device=device,
        generator=generator,
    )

    for edge in hypergraph_edges:
        num_nodes_in_edge = len(edge)
        if num_nodes_in_edge < 2:
            raise ValueError("The number of vertices in an hyperedge must be >= 2.")

        # projections (num_nodes_in_edge,) contains a scalar value for
        # each node in the hyperedge,
        # indicating its projection on the random vector 'random_direction'.
        # Key idea: If two points are very far apart in ℝᵈ, there is a high probability
        # that a random projection will still separate them
        projections = torch.matmul(x[edge], random_direction).squeeze()

        # The indices of the nodes that the farthest apart in the
        # direction of 'random_direction'
        node_max_proj_idx = torch.argmax(projections)
        node_min_proj_idx = torch.argmin(projections)

        if not with_mediators:  # Just connect the two farthest nodes
            graph_edges.append([edge[node_min_proj_idx], edge[node_max_proj_idx]])
            graph_edge_weights.append(1.0 / num_nodes_in_edge)
            continue

        edge_weight = 1.0 / (2 * num_nodes_in_edge - 3)
        for node_idx in range(num_nodes_in_edge):
            if node_idx not in {node_max_proj_idx.item(), node_min_proj_idx.item()}:
                graph_edges.append([edge[node_min_proj_idx], edge[node_idx]])
                graph_edges.append([edge[node_max_proj_idx], edge[node_idx]])
                graph_edge_weights.extend([edge_weight, edge_weight])

    graph = Graph(
        edges=graph_edges,
        edge_weights=graph_edge_weights if return_weights else None,
    )
    if remove_selfloops:
        graph.remove_selfloops()

    return (
        graph.to_edge_index().to(device),
        graph.edge_weights_tensor.to(device) if return_weights else None,
    )

remove_duplicate_edges()

Remove duplicate edges from the hyperedge index.

Keeps the tensor contiguous in memory.

Source code in hypertorch/types/hypergraph.py
def remove_duplicate_edges(self) -> HyperedgeIndex:
    """
    Remove duplicate edges from the hyperedge index.

    Keeps the tensor contiguous in memory.
    """
    # Example: hyperedge_index = [[0, 1, 2, 2, 0, 3, 2],
    #                             [3, 4, 4, 3, 4, 3, 3]], shape (2, 7)
    #          -> after torch.unique(..., dim=1):
    #             hyperedge_index = [[0, 1, 2, 2, 0, 3],
    #                                [3, 4, 4, 3, 4, 3]], shape (2, |E'| = 6)
    # Note: we need to call contiguous() after torch.unique() to ensure
    # the resulting tensor is contiguous in memory, which is important for efficient indexing
    # and further operations (e.g., searchsorted)
    hyperedge_index_without_duplicates = cast(
        Tensor, torch.unique(self.__hyperedge_index, dim=1)
    )
    self.__hyperedge_index = hyperedge_index_without_duplicates.contiguous()
    return self

remove_hyperedges_with_fewer_than_k_nodes(k)

Remove hyperedges that contain fewer than k nodes.

Examples:

>>> hyperedge_index = [[0, 1, 2, 3, 5, 4],
...                    [0, 0, 1, 1, 2, 1]], shape (2, |E| = 6)
>>> k = 3
>>> unique_hyperedge_ids: [0, 1, 2]
... # inverse -> idx_to_hyperedge_id, counts -> num_nodes_per_hyperedge
... # (index into unique_hyperedge_ids per column)
... inverse           = [0, 0, 1, 1, 2, 1]
... counts            = [2, 3, 1]
>>> # counts[inverse] is equivalent to:
... # for i, inv in enumerate(inverse): keep_mask[i] = counts[inv]
>>> counts[inverse]   = [2, 2, 3, 3, 1, 3]
>>> keep_mask         = [F, F, T, T, F, T]
>>> # after filtering hyperedges with fewer than k=3 nodes:
>>> hyperedge_index = [[2, 3, 4],
...                    [1, 1, 1]], shape (2, |E'| = 3)

Parameters:

Name Type Description Default
k int

The minimum number of nodes a hyperedge must contain to be kept.

required

Returns:

Name Type Description
hyperedge_index HyperedgeIndex

A new HyperedgeIndex instance with hyperedges containing fewer than k nodes.

Source code in hypertorch/types/hypergraph.py
def remove_hyperedges_with_fewer_than_k_nodes(self, k: int) -> HyperedgeIndex:
    """
    Remove hyperedges that contain fewer than k nodes.

    Examples:
        >>> hyperedge_index = [[0, 1, 2, 3, 5, 4],
        ...                    [0, 0, 1, 1, 2, 1]], shape (2, |E| = 6)

        >>> k = 3
        >>> unique_hyperedge_ids: [0, 1, 2]
        ... # inverse -> idx_to_hyperedge_id, counts -> num_nodes_per_hyperedge
        ... # (index into unique_hyperedge_ids per column)
        ... inverse           = [0, 0, 1, 1, 2, 1]
        ... counts            = [2, 3, 1]
        >>> # counts[inverse] is equivalent to:
        ... # for i, inv in enumerate(inverse): keep_mask[i] = counts[inv]
        >>> counts[inverse]   = [2, 2, 3, 3, 1, 3]
        >>> keep_mask         = [F, F, T, T, F, T]

        >>> # after filtering hyperedges with fewer than k=3 nodes:
        >>> hyperedge_index = [[2, 3, 4],
        ...                    [1, 1, 1]], shape (2, |E'| = 3)

    Args:
        k: The minimum number of nodes a hyperedge must contain to be kept.

    Returns:
        hyperedge_index: A new `HyperedgeIndex` instance with hyperedges
            containing fewer than k nodes.
    """
    validate_is_positive("k", k)

    _, idx_to_hyperedge_id, num_nodes_per_hyperedge = torch.unique(
        self.all_hyperedge_ids,
        return_inverse=True,
        return_counts=True,
    )
    keep_mask = num_nodes_per_hyperedge[idx_to_hyperedge_id] >= k
    self.__hyperedge_index = self.__hyperedge_index[:, keep_mask]
    return self

to_0based(node_ids_to_rebase=None, hyperedge_ids_to_rebase=None)

Convert hyperedge index to the 0-based format by rebasing node IDs to the range [0, num_nodes-1] and hyperedge IDs [0, num_hyperedges-1].

Parameters:

Name Type Description Default
node_ids_to_rebase Tensor | None

Tensor of shape (num_nodes,) containing the original node IDs that need to be rebased to 0-based format. If None, all node IDs in the hyperedge index will be rebased to 0-based format based on their unique sorted order. Defaults to None.

None
hyperedge_ids_to_rebase Tensor | None

Tensor of shape (num_hyperedges,) containing the original hyperedge IDs that need to be rebased to 0-based format. If None, all hyperedge IDs in the hyperedge index will be rebased to 0-based format based on their unique sorted order. Defaults to None.

None

Returns:

Name Type Description
hyperedge_index HyperedgeIndex

A new HyperedgeIndex instance with the hyperedge index converted to 0-based format.

Source code in hypertorch/types/hypergraph.py
def to_0based(
    self,
    node_ids_to_rebase: Tensor | None = None,
    hyperedge_ids_to_rebase: Tensor | None = None,
) -> HyperedgeIndex:
    """
    Convert hyperedge index to the 0-based format by rebasing node IDs to the range ``[0,
    num_nodes-1]`` and hyperedge IDs ``[0, num_hyperedges-1]``.

    Args:
        node_ids_to_rebase: Tensor of shape ``(num_nodes,)`` containing the original node IDs
            that need to be rebased to 0-based format.
            If ``None``, all node IDs in the hyperedge index will be rebased to 0-based format
            based on their unique sorted order. Defaults to ``None``.
        hyperedge_ids_to_rebase: Tensor of shape ``(num_hyperedges,)`` containing the original
            hyperedge IDs that need to be rebased to 0-based format.
            If ``None``, all hyperedge IDs in the hyperedge index will be rebased to
            0-based format based on their unique sorted order. Defaults to ``None``.

    Returns:
        hyperedge_index: A new `HyperedgeIndex` instance with the hyperedge index
            converted to 0-based format.
    """
    # Example: hyperedge_index after sorting: [[0, 0, 1, 2, 3, 4],
    #                                          [3, 4, 4, 3, 4, 3]]
    #          node_ids_to_rebase = [0, 1, 2, 3, 4]
    #          -> hyperedge_index after remapping: [[0, 0, 1, 2, 3, 4],
    #                                               [3, 4, 4, 3, 4, 3]]
    self.__hyperedge_index[0] = to_0based_ids(self.all_node_ids, node_ids_to_rebase)

    # Example: hyperedge_index after remapping nodes: [[0, 0, 1, 2, 3, 4],
    #                                                  [3, 4, 4, 3, 4, 3]]
    #          hyperedge_ids_to_rebase = [3, 4]
    #          -> hyperedge_index after remapping hyperedges: [[0, 0, 1, 2, 3, 4],
    #                                                          [0, 0, 1, 0, 1, 0]]
    self.__hyperedge_index[1] = to_0based_ids(self.all_hyperedge_ids, hyperedge_ids_to_rebase)

    return self

to_global(global_node_ids=None)

Convert hyperedge index to the global format by rebasing node IDs to the original IDs.

Parameters:

Name Type Description Default
global_node_ids Tensor | None

Tensor of shape (num_nodes,) containing the original node IDs that need to be rebased to global format. If None, the hyperedge index will remain unchanged. Defaults to None.

None

Returns:

Name Type Description
hyperedge_index HyperedgeIndex

A new HyperedgeIndex instance with the hyperedge index converted to global format.

Source code in hypertorch/types/hypergraph.py
def to_global(self, global_node_ids: Tensor | None = None) -> HyperedgeIndex:
    """
    Convert hyperedge index to the global format by rebasing node IDs to the original IDs.

    Args:
        global_node_ids: Tensor of shape ``(num_nodes,)`` containing the original node IDs
            that need to be rebased to global format.
            If ``None``, the hyperedge index will remain unchanged. Defaults to ``None``.

    Returns:
        hyperedge_index: A new `HyperedgeIndex` instance with the hyperedge index
            converted to global format.
    """
    if global_node_ids is None:
        return self

    hyperedge_ids = self.__hyperedge_index[1]
    local_node_ids = self.__hyperedge_index[0]
    global_node_ids = global_node_ids[local_node_ids]
    global_hyperedge_index = torch.stack((global_node_ids, hyperedge_ids), dim=0)
    self.__hyperedge_index = global_hyperedge_index.contiguous()
    return self

__validate_num_hyperedges(num_hyperedges)

Validate that an explicit hyperedge count can contain the index.

Parameters:

Name Type Description Default
num_hyperedges int | None

Optional explicit number of hyperedges.

required

Raises:

Type Description
ValueError

If num_hyperedges is negative or smaller than the maximum ID.

Source code in hypertorch/types/hypergraph.py
def __validate_num_hyperedges(self, num_hyperedges: int | None) -> None:
    """
    Validate that an explicit hyperedge count can contain the index.

    Args:
        num_hyperedges: Optional explicit number of hyperedges.

    Raises:
        ValueError: If ``num_hyperedges`` is negative or smaller than the maximum ID.
    """
    if num_hyperedges is None:
        return
    validate_is_non_negative("num_hyperedges", num_hyperedges)

    if self.all_hyperedge_ids.numel() < 1:
        return

    max_hyperedge_id = int(self.all_hyperedge_ids.max().item())
    if max_hyperedge_id >= num_hyperedges:
        raise ValueError(
            f"'num_hyperedges' is too small for the hyperedge index. "
            f"Got num_hyperedges={num_hyperedges}, but max hyperedge id is {max_hyperedge_id}."
        )

__validate_num_nodes(num_nodes)

Validate that an explicit node count can contain the index.

Parameters:

Name Type Description Default
num_nodes int | None

Optional explicit number of nodes.

required

Raises:

Type Description
ValueError

If num_nodes is negative or smaller than the maximum ID.

Source code in hypertorch/types/hypergraph.py
def __validate_num_nodes(self, num_nodes: int | None) -> None:
    """
    Validate that an explicit node count can contain the index.

    Args:
        num_nodes: Optional explicit number of nodes.

    Raises:
        ValueError: If ``num_nodes`` is negative or smaller than the maximum ID.
    """
    if num_nodes is None:
        return
    validate_is_non_negative("num_nodes", num_nodes)

    if self.all_node_ids.numel() < 1:
        return

    max_node_id = int(self.all_node_ids.max().item())
    if max_node_id >= num_nodes:
        raise ValueError(
            f"'num_nodes' is too small for the hyperedge index. "
            f"Got num_nodes={num_nodes}, but max node id is {max_node_id}."
        )

__validate_degree_matrix_dimension(name, value, expected)

Validate a requested degree-matrix dimension.

Parameters:

Name Type Description Default
name str

Name of the dimension being validated.

required
value int

Requested dimension value.

required
expected int

Required dimension value.

required

Raises:

Type Description
ValueError

If the value is negative or does not match the expected dimension.

Source code in hypertorch/types/hypergraph.py
def __validate_degree_matrix_dimension(self, name: str, value: int, expected: int) -> None:
    """
    Validate a requested degree-matrix dimension.

    Args:
        name: Name of the dimension being validated.
        value: Requested dimension value.
        expected: Required dimension value.

    Raises:
        ValueError: If the value is negative or does not match the expected dimension.
    """
    validate_is_non_negative(name, value)
    if value != expected:
        raise ValueError(
            f"'{name}' must match the incidence matrix dimension. "
            f"Got {name}={value}, but expected {expected}."
        )

Hypergraph

A simple hypergraph data structure using edge list representation.

Attributes:

Name Type Description
hyperedges list[list[int]]

A list of hyperedges, where each hyperedge is represented as a list of node IDs.

Source code in hypertorch/types/hypergraph.py
class Hypergraph:
    """
    A simple hypergraph data structure using edge list representation.

    Attributes:
        hyperedges: A list of hyperedges, where each hyperedge is represented as a list of node IDs.
    """

    def __init__(self, hyperedges: list[list[int]]):
        """
        Initialize the hypergraph.

        Args:
            hyperedges: List of hyperedges represented as lists of node IDs.
        """
        self.hyperedges: list[list[int]] = hyperedges

    @property
    def num_nodes(self) -> int:
        """
        Return the number of nodes in the hypergraph.
        """
        nodes = set()
        for edge in self.hyperedges:
            nodes.update(edge)
        return len(nodes)

    @property
    def num_hyperedges(self) -> int:
        """
        Return the number of hyperedges in the hypergraph.
        """
        return len(self.hyperedges)

    def neighbors_of(self, node: int) -> Neighborhood:
        """
        Return the set of nodes that share at least one hyperedge with node.

        A node u is a neighbor of v if there exists a hyperedge e such that
        both u and v are in e. The node itself is excluded from the result.

        Args:
            node: The node ID to find neighbors for.

        Returns:
            neighbors: A set of neighbor node IDs (excluding the node itself).
        """
        validate_is_non_negative("node", node)

        neighbors: Neighborhood = set()
        for hyperedge in self.hyperedges:
            if node in hyperedge:
                neighbors.update(hyperedge)

        neighbors.discard(node)
        return neighbors

    def neighbors_of_all(self) -> dict[int, Neighborhood]:
        """
        Build a mapping from every node to its neighbors.

        This precomputes ``neighbors_of`` for all nodes at once, which is
        more efficient when scoring many candidate hyperedges.

        Returns:
            neighbors: A dictionary mapping each node ID to its set of neighbors.
        """
        nodes: set[int] = set()
        for hyperedge in self.hyperedges:
            nodes.update(hyperedge)

        node_to_neighbors: dict[int, Neighborhood] = {}
        for node in nodes:
            node_to_neighbors[node] = self.neighbors_of(node)

        return node_to_neighbors

    def stats(self) -> dict[str, Any]:
        """
        Return basic statistics about the hypergraph.
        """
        node_degree: dict[int, int] = {}
        distribution_hyperedge_size: list[int] = []
        total_incidences = 0

        for hyperedge in self.hyperedges:
            size = len(hyperedge)
            distribution_hyperedge_size.append(size)
            total_incidences += size
            for node in hyperedge:
                node_degree[node] = node_degree.get(node, 0) + 1

        num_nodes = len(node_degree)
        num_hyperedges = len(self.hyperedges)
        distribution_node_degree: list[int] = sorted(node_degree.values())

        avg_degree_hyperedge = total_incidences / num_hyperedges if num_hyperedges else 0
        total_incidences_nodes = sum(distribution_node_degree)
        avg_degree_node = total_incidences_nodes / num_nodes if num_nodes else 0

        hyperedge_degree_max = (
            max(distribution_hyperedge_size) if distribution_hyperedge_size else 0
        )
        node_degree_max = max(distribution_node_degree) if distribution_node_degree else 0

        sorted_hyperedge_sizes = sorted(distribution_hyperedge_size)
        n_e = len(sorted_hyperedge_sizes)
        hyperedge_degree_median = (
            (
                sorted_hyperedge_sizes[n_e // 2]
                if n_e % 2
                else (sorted_hyperedge_sizes[n_e // 2 - 1] + sorted_hyperedge_sizes[n_e // 2]) / 2
            )
            if n_e
            else 0
        )

        n_n = len(distribution_node_degree)
        node_degree_median = (
            (
                distribution_node_degree[n_n // 2]
                if n_n % 2
                else (distribution_node_degree[n_n // 2 - 1] + distribution_node_degree[n_n // 2])
                / 2
            )
            if n_n
            else 0
        )

        distribution_hyperedge_size_hist: dict[int, int] = {}
        for s in distribution_hyperedge_size:
            distribution_hyperedge_size_hist[s] = distribution_hyperedge_size_hist.get(s, 0) + 1

        distribution_node_degree_hist: dict[int, int] = {}
        for d in distribution_node_degree:
            distribution_node_degree_hist[d] = distribution_node_degree_hist.get(d, 0) + 1

        return {
            "num_nodes": num_nodes,
            "num_hyperedges": num_hyperedges,
            "avg_degree_node": avg_degree_node,
            "avg_degree_hyperedge": avg_degree_hyperedge,
            "node_degree_max": node_degree_max,
            "hyperedge_degree_max": hyperedge_degree_max,
            "node_degree_median": node_degree_median,
            "hyperedge_degree_median": hyperedge_degree_median,
            "distribution_node_degree": distribution_node_degree,
            "distribution_hyperedge_size": distribution_hyperedge_size,
            "distribution_node_degree_hist": distribution_node_degree_hist,
            "distribution_hyperedge_size_hist": distribution_hyperedge_size_hist,
        }

    @classmethod
    def from_hyperedge_index(cls, hyperedge_index: Tensor) -> Hypergraph:
        """
        Create a Hypergraph from a hyperedge index representation.

        Args:
            hyperedge_index: Tensor of shape (2, |E|) representing hyperedges, where each
                column is (node, hyperedge).

        Returns:
            hypergraph: Hypergraph instance
        """
        if hyperedge_index.size(1) < 1:
            return cls(hyperedges=[])

        unique_hyperedge_ids = hyperedge_index[1].unique()
        hyperedges = [
            hyperedge_index[0, hyperedge_index[1] == hyperedge_id].tolist()
            for hyperedge_id in unique_hyperedge_ids
        ]

        return cls(hyperedges=hyperedges)

    @staticmethod
    def smoothing_with_matrix(
        x: Tensor,
        matrix: Tensor,
        drop_rate: float = 0.0,
    ) -> Tensor:
        """
        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 ``(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:
            x: The smoothed feature matrix. Size ``(num_nodes, C)``.
        """
        if drop_rate > 0.0:
            matrix = sparse_dropout(matrix, drop_rate)
        return matrix.matmul(x)

num_nodes property

Return the number of nodes in the hypergraph.

num_hyperedges property

Return the number of hyperedges in the hypergraph.

__init__(hyperedges)

Initialize the hypergraph.

Parameters:

Name Type Description Default
hyperedges list[list[int]]

List of hyperedges represented as lists of node IDs.

required
Source code in hypertorch/types/hypergraph.py
def __init__(self, hyperedges: list[list[int]]):
    """
    Initialize the hypergraph.

    Args:
        hyperedges: List of hyperedges represented as lists of node IDs.
    """
    self.hyperedges: list[list[int]] = hyperedges

neighbors_of(node)

Return the set of nodes that share at least one hyperedge with node.

A node u is a neighbor of v if there exists a hyperedge e such that both u and v are in e. The node itself is excluded from the result.

Parameters:

Name Type Description Default
node int

The node ID to find neighbors for.

required

Returns:

Name Type Description
neighbors Neighborhood

A set of neighbor node IDs (excluding the node itself).

Source code in hypertorch/types/hypergraph.py
def neighbors_of(self, node: int) -> Neighborhood:
    """
    Return the set of nodes that share at least one hyperedge with node.

    A node u is a neighbor of v if there exists a hyperedge e such that
    both u and v are in e. The node itself is excluded from the result.

    Args:
        node: The node ID to find neighbors for.

    Returns:
        neighbors: A set of neighbor node IDs (excluding the node itself).
    """
    validate_is_non_negative("node", node)

    neighbors: Neighborhood = set()
    for hyperedge in self.hyperedges:
        if node in hyperedge:
            neighbors.update(hyperedge)

    neighbors.discard(node)
    return neighbors

neighbors_of_all()

Build a mapping from every node to its neighbors.

This precomputes neighbors_of for all nodes at once, which is more efficient when scoring many candidate hyperedges.

Returns:

Name Type Description
neighbors dict[int, Neighborhood]

A dictionary mapping each node ID to its set of neighbors.

Source code in hypertorch/types/hypergraph.py
def neighbors_of_all(self) -> dict[int, Neighborhood]:
    """
    Build a mapping from every node to its neighbors.

    This precomputes ``neighbors_of`` for all nodes at once, which is
    more efficient when scoring many candidate hyperedges.

    Returns:
        neighbors: A dictionary mapping each node ID to its set of neighbors.
    """
    nodes: set[int] = set()
    for hyperedge in self.hyperedges:
        nodes.update(hyperedge)

    node_to_neighbors: dict[int, Neighborhood] = {}
    for node in nodes:
        node_to_neighbors[node] = self.neighbors_of(node)

    return node_to_neighbors

stats()

Return basic statistics about the hypergraph.

Source code in hypertorch/types/hypergraph.py
def stats(self) -> dict[str, Any]:
    """
    Return basic statistics about the hypergraph.
    """
    node_degree: dict[int, int] = {}
    distribution_hyperedge_size: list[int] = []
    total_incidences = 0

    for hyperedge in self.hyperedges:
        size = len(hyperedge)
        distribution_hyperedge_size.append(size)
        total_incidences += size
        for node in hyperedge:
            node_degree[node] = node_degree.get(node, 0) + 1

    num_nodes = len(node_degree)
    num_hyperedges = len(self.hyperedges)
    distribution_node_degree: list[int] = sorted(node_degree.values())

    avg_degree_hyperedge = total_incidences / num_hyperedges if num_hyperedges else 0
    total_incidences_nodes = sum(distribution_node_degree)
    avg_degree_node = total_incidences_nodes / num_nodes if num_nodes else 0

    hyperedge_degree_max = (
        max(distribution_hyperedge_size) if distribution_hyperedge_size else 0
    )
    node_degree_max = max(distribution_node_degree) if distribution_node_degree else 0

    sorted_hyperedge_sizes = sorted(distribution_hyperedge_size)
    n_e = len(sorted_hyperedge_sizes)
    hyperedge_degree_median = (
        (
            sorted_hyperedge_sizes[n_e // 2]
            if n_e % 2
            else (sorted_hyperedge_sizes[n_e // 2 - 1] + sorted_hyperedge_sizes[n_e // 2]) / 2
        )
        if n_e
        else 0
    )

    n_n = len(distribution_node_degree)
    node_degree_median = (
        (
            distribution_node_degree[n_n // 2]
            if n_n % 2
            else (distribution_node_degree[n_n // 2 - 1] + distribution_node_degree[n_n // 2])
            / 2
        )
        if n_n
        else 0
    )

    distribution_hyperedge_size_hist: dict[int, int] = {}
    for s in distribution_hyperedge_size:
        distribution_hyperedge_size_hist[s] = distribution_hyperedge_size_hist.get(s, 0) + 1

    distribution_node_degree_hist: dict[int, int] = {}
    for d in distribution_node_degree:
        distribution_node_degree_hist[d] = distribution_node_degree_hist.get(d, 0) + 1

    return {
        "num_nodes": num_nodes,
        "num_hyperedges": num_hyperedges,
        "avg_degree_node": avg_degree_node,
        "avg_degree_hyperedge": avg_degree_hyperedge,
        "node_degree_max": node_degree_max,
        "hyperedge_degree_max": hyperedge_degree_max,
        "node_degree_median": node_degree_median,
        "hyperedge_degree_median": hyperedge_degree_median,
        "distribution_node_degree": distribution_node_degree,
        "distribution_hyperedge_size": distribution_hyperedge_size,
        "distribution_node_degree_hist": distribution_node_degree_hist,
        "distribution_hyperedge_size_hist": distribution_hyperedge_size_hist,
    }

from_hyperedge_index(hyperedge_index) classmethod

Create a Hypergraph from a hyperedge index representation.

Parameters:

Name Type Description Default
hyperedge_index Tensor

Tensor of shape (2, |E|) representing hyperedges, where each column is (node, hyperedge).

required

Returns:

Name Type Description
hypergraph Hypergraph

Hypergraph instance

Source code in hypertorch/types/hypergraph.py
@classmethod
def from_hyperedge_index(cls, hyperedge_index: Tensor) -> Hypergraph:
    """
    Create a Hypergraph from a hyperedge index representation.

    Args:
        hyperedge_index: Tensor of shape (2, |E|) representing hyperedges, where each
            column is (node, hyperedge).

    Returns:
        hypergraph: Hypergraph instance
    """
    if hyperedge_index.size(1) < 1:
        return cls(hyperedges=[])

    unique_hyperedge_ids = hyperedge_index[1].unique()
    hyperedges = [
        hyperedge_index[0, hyperedge_index[1] == hyperedge_id].tolist()
        for hyperedge_id in unique_hyperedge_ids
    ]

    return cls(hyperedges=hyperedges)

smoothing_with_matrix(x, matrix, drop_rate=0.0) staticmethod

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.

Parameters:

Name Type Description Default
x Tensor

Node feature matrix. Size (num_nodes, C).

required
matrix Tensor

The smoothing matrix. Size (num_nodes, num_nodes).

required
drop_rate float

Randomly dropout the connections in the smoothing matrix with probability drop_rate. Defaults to 0.0.

0.0

Returns:

Name Type Description
x Tensor

The smoothed feature matrix. Size (num_nodes, C).

Source code in hypertorch/types/hypergraph.py
@staticmethod
def smoothing_with_matrix(
    x: Tensor,
    matrix: Tensor,
    drop_rate: float = 0.0,
) -> Tensor:
    """
    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 ``(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:
        x: The smoothed feature matrix. Size ``(num_nodes, C)``.
    """
    if drop_rate > 0.0:
        matrix = sparse_dropout(matrix, drop_rate)
    return matrix.matmul(x)

GraphReductionStrategyEnum

Bases: StrEnum

Enum for supported hypergraph-to-graph reduction strategies.

Source code in hypertorch/types/hypergraph.py
class GraphReductionStrategyEnum(StrEnum):
    """
    Enum for supported hypergraph-to-graph reduction strategies.
    """

    CLIQUE_EXPANSION = "clique_expansion"

HData

Class for representing hypergraph data in a format suitable for hypergraph learning tasks.

Examples:

>>> x = torch.randn(10, 16)  # 10 nodes with 16 features each
>>> hyperedge_index = torch.tensor([[0, 0, 1, 1, 1],  # node IDs
...                                 [0, 1, 2, 3, 4]]) # hyperedge IDs
>>> data = HData(x=x, hyperedge_index=hyperedge_index)

Attributes:

Name Type Description
x Tensor

Node feature matrix of shape [num_nodes, num_features].

hyperedge_index Tensor

Sparse node-hyperedge incidence matrix of shape [2, num_incidences], where hyperedge_index[0] contains node IDs and hyperedge_index[1] contains hyperedge IDs.

hyperedge_weights Tensor | None

Optional tensor of shape [num_hyperedges] containing weights for each hyperedge.

hyperedge_attr Tensor | None

Optional hyperedge attributes of shape [num_hyperedges, num_hyperedge_features]. Features associated with each hyperedge (e.g., timestamps, types).

num_nodes int

Number of nodes in the hypergraph. If None, inferred as x.size(0).

num_hyperedges int

Number of hyperedges in the hypergraph. If None, inferred as the number of unique hyperedge IDs in hyperedge_index[1].

global_node_ids Tensor

Optional node IDs of shape [num_nodes] matching the row order of x. These remain unchanged across operations (like split and sampling), so that nodes can be mapped back to their original identity in the source dataset. Use this to preserve access to the canonical node space when hyperedge_index is rebased locally. If None, defaults to torch.arange(num_nodes), assuming that these are the global node IDs in the same order as the rows of x.

target_node_mask Tensor

Optional boolean tensor of shape [num_nodes] identifying the supervised nodes for tasks learning on nodes. This is useful in transductive settings where models are trained on the entire hypergraph. If None, defaults to a tensor of ones of size num_nodes. It is always ignored for hyperedge-related tasks.

target_hyperedge_mask Tensor

Optional boolean tensor of shape [num_hyperedges] identifying the supervised hyperedges for tasks learning on hyperedges. This is useful in transductive settings where models are trained on the entire hypergraph. If None, defaults to a tensor of ones of size num_hyperedges. It is always ignored for node-related tasks.

y Tensor

Labels for nodes or hyperedges, it has shape [num_hyperedges] for tasks learning on hyperedges, and shape [num_nodes] for tasks learning on nodes. Used for supervised learning tasks. For unsupervised tasks, this can be ignored. Defaults to a tensor of ones.

task Task

Learning task used to determine whether operations work on nodes or hyperedges. If None, defaults to "hyperlink-prediction".

device device

Device shared by all tensors in the instance.

Source code in hypertorch/types/hdata.py
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
class HData:
    """
    Class for representing hypergraph data in a format suitable for hypergraph learning tasks.

    Examples:
        >>> x = torch.randn(10, 16)  # 10 nodes with 16 features each
        >>> hyperedge_index = torch.tensor([[0, 0, 1, 1, 1],  # node IDs
        ...                                 [0, 1, 2, 3, 4]]) # hyperedge IDs
        >>> data = HData(x=x, hyperedge_index=hyperedge_index)

    Attributes:
        x: Node feature matrix of shape ``[num_nodes, num_features]``.
        hyperedge_index: Sparse node-hyperedge incidence matrix of shape
            ``[2, num_incidences]``, where ``hyperedge_index[0]`` contains node IDs
            and ``hyperedge_index[1]`` contains hyperedge IDs.
        hyperedge_weights: Optional tensor of shape ``[num_hyperedges]`` containing weights
            for each hyperedge.
        hyperedge_attr: Optional hyperedge attributes of
            shape ``[num_hyperedges, num_hyperedge_features]``.
            Features associated with each hyperedge (e.g., timestamps, types).
        num_nodes: Number of nodes in the hypergraph. If ``None``, inferred as ``x.size(0)``.
        num_hyperedges: Number of hyperedges in the hypergraph.
            If ``None``, inferred as the number of unique hyperedge IDs
            in ``hyperedge_index[1]``.
        global_node_ids: Optional node IDs of shape ``[num_nodes]`` matching the
            row order of ``x``. These remain unchanged across operations (like split and
            sampling), so that nodes can be mapped back to their original identity in
            the source dataset. Use this to preserve access to the canonical node space
            when ``hyperedge_index`` is rebased locally.
            If ``None``, defaults to ``torch.arange(num_nodes)``, assuming that these are the
            global node IDs in the same order as the rows of ``x``.
        target_node_mask: Optional boolean tensor of shape ``[num_nodes]`` identifying the
            supervised nodes for tasks learning on nodes. This is useful in transductive
            settings where models are trained on the entire hypergraph.
            If ``None``, defaults to a tensor of ones of size ``num_nodes``. It is always
            ignored for hyperedge-related tasks.
        target_hyperedge_mask: Optional boolean tensor of shape ``[num_hyperedges]`` identifying
            the supervised hyperedges for tasks learning on hyperedges. This is useful in
            transductive settings where models are trained on the entire hypergraph.
            If ``None``, defaults to a tensor of ones of size ``num_hyperedges``. It is always
            ignored for node-related tasks.
        y: Labels for nodes or hyperedges, it has shape ``[num_hyperedges]`` for tasks learning
            on hyperedges, and shape ``[num_nodes]`` for tasks learning on nodes.
            Used for supervised learning tasks. For unsupervised tasks, this can be ignored.
            Defaults to a tensor of ones.
        task: Learning task used to determine whether operations work on nodes or hyperedges.
            If ``None``, defaults to ``"hyperlink-prediction"``.
        device: Device shared by all tensors in the instance.
    """

    def __init__(
        self,
        x: Tensor,
        hyperedge_index: Tensor,
        hyperedge_weights: Tensor | None = None,
        hyperedge_attr: Tensor | None = None,
        num_nodes: int | None = None,
        num_hyperedges: int | None = None,
        global_node_ids: Tensor | None = None,
        target_node_mask: Tensor | None = None,
        target_hyperedge_mask: Tensor | None = None,
        y: Tensor | None = None,
        task: Task = TaskEnum.HYPERLINK_PREDICTION,
    ):
        """
        Initialize hypergraph learning data.

        Args:
            x: Node feature matrix of shape ``[num_nodes, num_features]``.
            hyperedge_index: Sparse node-hyperedge incidence matrix of shape
                ``[2, num_incidences]``, where ``hyperedge_index[0]`` contains node IDs
                and ``hyperedge_index[1]`` contains hyperedge IDs.
            hyperedge_weights: Optional tensor of shape ``[num_hyperedges]`` containing weights
                for each hyperedge.
            hyperedge_attr: Optional hyperedge attributes of
                shape ``[num_hyperedges, num_hyperedge_features]``.
                Features associated with each hyperedge (e.g., timestamps, types).
            num_nodes: Number of nodes in the hypergraph. If ``None``, inferred as ``x.size(0)``.
            num_hyperedges: Number of hyperedges in the hypergraph.
                If ``None``, inferred as the number of unique hyperedge IDs
                in ``hyperedge_index[1]``.
            global_node_ids: Optional node IDs of shape ``[num_nodes]`` matching the
                row order of ``x``. These remain unchanged across operations (like split and
                sampling), so that nodes can be mapped back to their original identity in
                the source dataset. Use this to preserve access to the canonical node space
                when ``hyperedge_index`` is rebased locally.
                If ``None``, defaults to ``torch.arange(num_nodes)``, assuming that these are the
                global node IDs in the same order as the rows of ``x``.
            target_node_mask: Optional boolean tensor of shape ``[num_nodes]`` identifying the
                supervised nodes for tasks learning on nodes. This is useful in transductive
                settings where models are trained on the entire hypergraph.
                If ``None``, defaults to a tensor of ones of size ``num_nodes``. It is always
                ignored for hyperedge-related tasks.
            target_hyperedge_mask: Optional boolean tensor of shape ``[num_hyperedges]``
                identifying the supervised hyperedges for tasks learning on hyperedges. This is
                useful in transductive settings where models are trained on the entire hypergraph.
                If ``None``, defaults to a tensor of ones of size ``num_hyperedges``. It is always
                ignored for node-related tasks.
            y: Labels for nodes or hyperedges, it has shape ``[num_hyperedges]`` for tasks learning
                on hyperedges, and shape ``[num_nodes]`` for tasks learning on nodes.
                Used for supervised learning tasks. For unsupervised tasks, this can be ignored.
                Defaults to a tensor of ones.
            task: Learning task used to determine whether operations work on nodes or hyperedges.
                If ``None``, defaults to ``"hyperlink-prediction"``.
        """
        self.x: Tensor = x
        self.hyperedge_index: Tensor = hyperedge_index
        self.__validate_x_and_hyperedge_index_type_and_dim()

        self.hyperedge_weights: Tensor | None = hyperedge_weights
        self.hyperedge_attr: Tensor | None = hyperedge_attr

        hyperedge_index_wrapper = HyperedgeIndex(hyperedge_index)
        self.num_nodes: int = (
            num_nodes
            if num_nodes is not None
            # There should never be isolated nodes when HData is created by Dataset
            # as each isolated node gets its own self-loop hyperedge
            else hyperedge_index_wrapper.num_nodes_if_isolated_exist(num_nodes=x.size(0))
        )
        validate_is_non_negative("num_nodes", self.num_nodes)

        self.num_hyperedges: int = (
            num_hyperedges if num_hyperedges is not None else hyperedge_index_wrapper.num_hyperedges
        )
        validate_is_non_negative("num_hyperedges", self.num_hyperedges)

        self.global_node_ids: Tensor = (
            # torch.arange is to handle isolated nodes, as they are already considered
            # when computing self.num_nodes via num_nodes_if_isolated_exist
            global_node_ids
            if global_node_ids is not None
            else torch.arange(self.num_nodes, dtype=torch.long, device=self.x.device)
        )

        self.target_node_mask: Tensor = (
            target_node_mask
            if target_node_mask is not None
            else torch.ones(self.num_nodes, dtype=torch.bool, device=self.x.device)
        )
        self.target_hyperedge_mask: Tensor = (
            target_hyperedge_mask
            if target_hyperedge_mask is not None
            else torch.ones(self.num_hyperedges, dtype=torch.bool, device=self.x.device)
        )

        self.task: Task = task
        self.y: Tensor = self.__assign_y_for_task(y)

        self.__validate()

        self.device: torch.device = self.get_device_if_all_consistent()

    def __repr__(self) -> str:
        """
        Return a shape-oriented representation of the data object.

        Returns:
            representation: Human-readable summary of tensor shapes and counts.
        """
        hyperedge_weights_shape = (
            self.hyperedge_weights.shape if self.hyperedge_weights is not None else None
        )
        hyperedge_attr_shape = (
            self.hyperedge_attr.shape if self.hyperedge_attr is not None else None
        )
        target_node_mask_shape = (
            str(self.target_node_mask.shape)
            if self.is_node_related_task
            else f"(ignored for task={self.task!r})"
        )
        target_hyperedge_mask_shape = (
            str(self.target_hyperedge_mask.shape)
            if self.is_hyperedge_related_task
            else f"(ignored for task={self.task!r})"
        )

        return (
            f"{self.__class__.__name__}(\n"
            f"    num_nodes={self.num_nodes},\n"
            f"    num_hyperedges={self.num_hyperedges},\n"
            f"    x_shape={self.x.shape},\n"
            f"    global_node_ids_shape={self.global_node_ids.shape},\n"
            f"    target_node_mask_shape={target_node_mask_shape},\n"
            f"    target_hyperedge_mask_shape={target_hyperedge_mask_shape},\n"
            f"    hyperedge_index_shape={self.hyperedge_index.shape},\n"
            f"    hyperedge_weights_shape={hyperedge_weights_shape},\n"
            f"    hyperedge_attr_shape={hyperedge_attr_shape},\n"
            f"    y_shape={self.y.shape},\n"
            f"    task={self.task!r},\n"
            f"    device={self.device}\n"
            f")"
        )

    @classmethod
    def cat_same_node_space(
        cls,
        hdatas: Sequence[HData],
        x: Tensor | None = None,
        global_node_ids: Tensor | None = None,
    ) -> HData:
        """
        Concatenate `HData` instances that share the same node space, meaning nodes with
        the same ID in different instances are the same node.
        This is useful when combining positive and negative hyperedges that reference
        the same set of nodes.

        Notes:
            - ``x`` is derived from the instance with the largest number of nodes,
                if not provided explicitly.
                If there are conflicting features for the same node ID across instances,
                the features from the instance with the largest number of nodes will be used.
                If ``global_node_ids`` is provided explicitly, ``x`` must also be provided
                to ensure consistency.
            - ``hyperedge_index`` is the concatenation of all input hyperedge indices.
            - ``hyperedge_weights`` is the concatenation of all input hyperedge weights, if present.
                If some instances have hyperedge weights and others do not, the resulting
                ``hyperedge_weights`` will be set to ``None``.
            - ``hyperedge_attr`` is the concatenation of all input hyperedge attributes, if present.
                If some instances have hyperedge attributes and others do not, the resulting
                ``hyperedge_attr`` will be set to ``None``.
            - ``global_node_ids`` is derived from the instance with the largest number of nodes,
                if not provided explicitly.
                If ``x`` is provided explicitly, ``global_node_ids`` must be provided explicitly
                as well to ensure consistency.
            - ``target_node_mask`` is derived from the instance with the largest number of nodes.
            - ``target_hyperedge_mask`` is the concatenation of all input hyperedge target masks.
            - ``y`` is the concatenation of all input labels.

        Examples:
            >>> x = torch.randn(5, 8)
            >>> pos = HData(x=x, hyperedge_index=torch.tensor([[0, 1, 2, 3, 4], [0, 0, 1, 2, 2]]))
            >>> neg = HData(x=x, hyperedge_index=torch.tensor([[0, 2], [3, 3]]))
            >>> new = HData.cat_same_node_space([pos, neg])
            >>> new.num_nodes  # 5 — nodes [0, 1, 2, 3, 4]
            >>> new.num_hyperedges  # 4 — hyperedges [0, 1, 2, 3]

        Args:
            hdatas: One or more `HData` instances sharing the same node space.
            x: Optional node feature matrix to use for the resulting `HData`.
                If ``None``, the node features from the instance with the largest number of
                nodes will be used.
                If ``global_node_ids`` is provided explicitly, ``x`` must also be provided
                to ensure consistency. Defaults to ``None``.
            global_node_ids: Optional global node IDs for the resulting `HData`.
                If ``None``, the global node IDs from the instance with the largest number of
                nodes will be used. If ``x`` is provided explicitly, ``global_node_ids`` must
                also be provided to ensure consistency.
                If ``x`` is provided and there is no need for ``global_node_ids`` to preserve
                access to the canonical node space, it is recommended to use arbitrary global node
                IDs that are consistent with the feature rows of ``x``.
                For example, ``global_node_ids=torch.arange(x.size(0))``).
                Defaults to ``None``.

        Returns:
            hdata: A new `HData` with shared nodes and concatenated hyperedges.

        Raises:
            ValueError: If no HData instances are provided, if there are overlapping
                hyperedge IDs across instances,
                or if ``x`` and ``global_node_ids`` are not both provided when one of
                them is provided.
        """
        cls.__validate_can_perform_cat_same_node_space(hdatas, x, global_node_ids)

        hdata_with_largest_node_space = max(hdatas, key=lambda hdata: hdata.num_nodes)

        new_x = (x.clone() if x is not None else hdata_with_largest_node_space.x).clone()
        new_global_node_ids = (
            global_node_ids.clone()
            if global_node_ids is not None
            else hdata_with_largest_node_space.global_node_ids.clone()
        )
        new_target_node_mask = (
            hdata_with_largest_node_space.target_node_mask.clone()
            if hdata_with_largest_node_space.target_node_mask is not None
            else None
        )
        new_num_hyperedges = sum(hdata.num_hyperedges for hdata in hdatas)
        new_target_hyperedge_mask = (
            torch.cat([hdata.target_hyperedge_mask for hdata in hdatas], dim=0)
            if hdata_with_largest_node_space.is_hyperedge_related_task
            else None
        )
        new_y = (
            # For node-based tasks, we must preserve the labels for the entire node space
            hdata_with_largest_node_space.y.clone()
            if hdata_with_largest_node_space.is_node_related_task
            else torch.cat([hdata.y for hdata in hdatas], dim=0)
        )
        new_hyperedge_index = torch.cat([hdata.hyperedge_index for hdata in hdatas], dim=1)

        hyperedge_attrs = []
        hyperedge_weights = []
        have_all_hyperedge_attr = all(hdata.hyperedge_attr is not None for hdata in hdatas)
        have_all_hyperedge_weights = all(hdata.hyperedge_weights is not None for hdata in hdatas)
        for hdata in hdatas:
            if have_all_hyperedge_attr and hdata.hyperedge_attr is not None:
                hyperedge_attrs.append(hdata.hyperedge_attr)
            if have_all_hyperedge_weights and hdata.hyperedge_weights is not None:
                hyperedge_weights.append(hdata.hyperedge_weights)
        new_hyperedge_attr = torch.cat(hyperedge_attrs, dim=0) if len(hyperedge_attrs) > 0 else None
        new_hyperedge_weights = (
            torch.cat(hyperedge_weights, dim=0) if len(hyperedge_weights) > 0 else None
        )

        return cls(
            x=new_x,
            hyperedge_index=new_hyperedge_index,
            hyperedge_weights=new_hyperedge_weights,
            hyperedge_attr=new_hyperedge_attr,
            num_nodes=new_x.size(0),
            num_hyperedges=new_num_hyperedges,
            global_node_ids=new_global_node_ids,
            target_node_mask=new_target_node_mask,
            target_hyperedge_mask=new_target_hyperedge_mask,
            y=new_y,
            task=hdata_with_largest_node_space.task,
        )

    def add_negative_samples(
        self,
        negative_sampler: NegativeSampler,
        seed: int | None = None,
    ) -> HData:
        """
        Return a new `HData` with sampled negative hyperedges added.

        Args:
            negative_sampler: Sampler used to generate negative hyperedges from this instance.
            seed: Optional random seed used for both negative sampling and the final shuffle.
                Defaults to ``None``.

        Returns:
            hdata: A new `HData` containing the original hyperedges and sampled negatives.
        """
        neg_hdata = negative_sampler.sample(self, seed=seed)
        hdata_with_negatives = self.cat_same_node_space([self, neg_hdata])
        return hdata_with_negatives.shuffle(seed=seed)

    @classmethod
    def empty(cls, task: Task = TaskEnum.HYPERLINK_PREDICTION) -> HData:
        """
        Create an empty HData instance.

        Args:
            task: Learning task for the empty HData. Defaults to ``"hyperlink-prediction"``.

        Returns:
            task: Learning task for the empty HData.

        Returns:
            hdata: Empty HData.
        """
        return cls(
            x=empty_nodefeatures(),
            hyperedge_index=empty_hyperedgeindex(),
            hyperedge_weights=None,
            hyperedge_attr=None,
            num_nodes=0,
            num_hyperedges=0,
            global_node_ids=None,
            target_node_mask=None,
            target_hyperedge_mask=None,
            y=None,
            task=task,
        )

    @classmethod
    def from_hyperedge_index(
        cls,
        hyperedge_index: Tensor,
        task: Task = TaskEnum.HYPERLINK_PREDICTION,
    ) -> HData:
        """
        Build an `HData` from a given hyperedge index, with empty node features and
        hyperedge attributes.

        - Node features are initialized as an empty tensor of shape ``[0, 0]``.
        - Hyperedge attributes are set to ``None``.
        - Hyperedge weights are set to ``None``.
        - The number of nodes and hyperedges are inferred from the hyperedge index.

        Examples:
            >>> hyperedge_index = [[0, 0, 1, 2, 3, 4],
            ...                    [0, 0, 0, 1, 2, 2]]
            >>> num_nodes = 5
            >>> num_hyperedges = 3
            >>> x = []  # Empty node features with shape [0, 0]
            >>> hyperedge_attr = None
            >>> hyperedge_weights = None

        Args:
            hyperedge_index: Tensor of shape ``[2, num_incidences]`` representing
                the hypergraph connectivity.
            task: Learning task for the resulting `HData`. Defaults to ``"hyperlink-prediction"``.

        Returns:
            hdata: An `HData` instance with the given hyperedge index and default values
                for other attributes.
        """
        return cls(
            x=empty_nodefeatures(),
            hyperedge_index=hyperedge_index.clone(),
            hyperedge_weights=None,
            hyperedge_attr=None,
            global_node_ids=None,
            target_node_mask=None,
            target_hyperedge_mask=None,
            y=None,
            task=task,
        )

    @classmethod
    def split(
        cls,
        hdata: HData,
        split_hyperedge_ids: Tensor | None = None,
        node_space_setting: NodeSpaceSetting = "transductive",
        splitter: Splitter[HData, Any] | None = None,
    ) -> HData:
        """
        Build an `HData` for a single split from the given hyperedge IDs.

        Examples:
            Transductive split (default) preserving the full node space:
            >>> split_hdata = HData.split(
            ...    hdata,
            ...    torch.tensor([1]),
            ...    node_space_setting="transductive")
            >>> split_hdata.x.shape[0] == hdata.x.shape[0]
            >>> split_hdata.hyperedge_index
            ... # node IDs stay in the original row space, hyperedge IDs are rebased

            Inductive split:
            >>> split_hdata = HData.split(hdata, torch.tensor([1]), node_space_setting="inductive")
            >>> split_hdata.x.shape[0]  # only nodes incident to hyperedge 1
            ... 2

        Args:
            hdata: The original `HData` containing the full hypergraph.
            split_hyperedge_ids: Tensor of hyperedge IDs to include in this split.
                It is assumed that the provided hyperedge IDs are valid and exist
                in ``hdata.hyperedge_index[1]``.
                It is mandatory to provide this argument unless a custom ``splitter`` is provided
                that owns split materialization.
            node_space_setting: Whether to preserve the full node space in the splits.
                ``transductive`` (default) ensures all node features are present in the split,
                while ``inductive`` allows splits to have disjoint node spaces.
            splitter: Optional HData splitter. When provided, it owns split materialization.
                Defaults to ``None``.

        Returns:
            hdata: The splitted instance with remapped node and hyperedge IDs.

        Raises:
            ValueError: If ``node_space_setting`` is not ``"transductive"`` or ``"inductive"``.
        """
        if splitter is not None:
            return splitter.split(to_split=hdata)

        if split_hyperedge_ids is None:
            raise ValueError(
                "'split_hyperedge_ids' must be provided when 'splitter' is not provided."
            )

        from hypertorch.data.splitter import HyperedgeHDataSplitter

        return HyperedgeHDataSplitter(node_space_setting=node_space_setting).split(
            to_split=hdata,
            split_hyperedge_ids=split_hyperedge_ids,
        )

    @property
    def is_hyperedge_related_task(self) -> bool:
        """
        Check if the task uses hyperedge-level targets and operations.

        Returns:
            is_hyperedge_related: True if the task is hyperedge-related, False otherwise.
        """
        # For now, we only support hyperlink prediction as a hyperedge-related task
        return self.task == TaskEnum.HYPERLINK_PREDICTION

    @property
    def is_node_related_task(self) -> bool:
        """
        Check if the task uses node-level targets and operations.

        Returns:
            is_node_related: True if the task is node-related, False otherwise.
        """
        # For now, we only support node classification as a node-related task
        return self.task == TaskEnum.NODE_CLASSIFICATION

    @property
    def num_sampleable_nodes(self) -> int:
        """
        Return the number of nodes that are eligible for sampling
        based on this HData instance's task.
        """
        return self.sampleable_node_ids.size(0)

    @property
    def num_sampleable_hyperedges(self) -> int:
        """
        Return the number of hyperedges that are eligible for sampling
        based on this HData instance's task.
        """
        return self.sampleable_hyperedge_ids.size(0)

    @property
    def sampleable_node_ids(self) -> Tensor:
        """
        Return node IDs that are eligible for sampling based on this HData instance's task.
        """
        if self.is_node_related_task:
            # as_tuple=False returns a 2-D tensor where each row is the index for a nonzero value
            # so, we flatten it to get a 1-D tensor of the nonzero indices (eligible node IDs)
            # Example: target_node_mask = [False, True, False, True], then
            #          -> target_node_mask.nonzero(as_tuple=False) = [[1], [3]]
            #          -> flatten() = [1, 3], which are the eligible node IDs
            return self.target_node_mask.nonzero(as_tuple=False).flatten()
        return HyperedgeIndex(self.hyperedge_index).node_ids

    @property
    def sampleable_hyperedge_ids(self) -> Tensor:
        """
        Return hyperedge IDs eligible for sampling based on this HData instance's task.
        """
        if self.is_hyperedge_related_task:
            # Same logic as `sampleable_node_ids`, but for hyperedges
            return self.target_hyperedge_mask.nonzero(as_tuple=False).flatten()
        return HyperedgeIndex(self.hyperedge_index).hyperedge_ids

    def enrich_node_features(
        self,
        enricher: NodeEnricher,
        enrichment_mode: EnrichmentMode | None = "replace",
    ) -> HData:
        """
        Enrich node features using the provided node feature enricher.

        Args:
            enricher: An instance of NodeEnricher to generate structural node features
                from hypergraph topology.
            enrichment_mode: How to combine generated features with existing ``hdata.x``.
                ``concatenate`` appends new features as additional columns.
                ``replace`` substitutes ``hdata.x`` entirely.
                Defaults to ``replace`` if not provided.
        """
        self.__validate_enrichment_mode(enrichment_mode)
        enriched_features = enricher.enrich(self.hyperedge_index)

        match enrichment_mode:
            case "concatenate":
                x = torch.cat([self.x, enriched_features], dim=1)
            case _:
                x = enriched_features

        return self.__class__(
            x=x,
            hyperedge_index=self.hyperedge_index.clone(),
            hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
            hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=self.target_node_mask.clone(),
            target_hyperedge_mask=self.target_hyperedge_mask.clone(),
            y=self.y.clone(),
            task=self.task,
        )

    def enrich_node_features_from(
        self,
        hdata_with_features: HData,
        node_space_setting: NodeSpaceSetting = "transductive",
        fill_value: NodeSpaceFiller | None = None,
    ) -> HData:
        """
        Copy node features from another `HData` by aligning features by ``global_node_ids``.

        Examples:
            Transductive enrichment (default) expecting the same node space in both
            source and target:
            >>> target = target.enrich_node_features_from(source, node_space_setting="transductive")

            Inductive with a scalar fill value:
            >>> target = target.enrich_node_features_from(
            ...     source,
            ...     node_space_setting="inductive",
            ...     fill_value=0.0,
            ... )

            Inductive with a feature vector fill value:
            >>> target = target.enrich_node_features_from(
            ...     source,
            ...     node_space_setting="inductive",
            ...     fill_value=[0.0, 1.0, 0.0],
            ... )

        Args:
            hdata_with_features: Source `HData` providing node features.
            node_space_setting: The setting for the node space, determining how nodes are handled.
                If ``"transductive"``, every target node is expected to exist in the source.
                If ``"inductive"``, the target dataset may have a different node space, and missing
                nodes are filled using ``fill_value``.
            fill_value: Scalar or vector used to fill missing node features when
                ``node_space_setting`` is not transductive.  Defaults to ``None``.

        Returns:
            hdata: A new `HData` with node features copied from ``hdata_with_features``.

        Raises:
            ValueError: If either instance lacks ``global_node_ids``, if the source feature rows
                do not align with the source node IDs, if ``fill_value`` is used with
                ``node_space_setting="transductive"``, or if ``fill_value`` is missing or
                malformed when ``node_space_setting="inductive"``.
        """
        source_global_node_ids = hdata_with_features.global_node_ids
        source_x = hdata_with_features.x
        if source_x.size(0) != source_global_node_ids.size(0):
            raise ValueError(
                "Expected 'hdata_with_features.x' rows to align with "
                "hdata_with_features.global_node_ids."
            )
        self.__validate_node_space_setting(node_space_setting, fill_value)

        target_global_node_ids = self.global_node_ids.detach().cpu().tolist()

        # We need the index of the features for each node in the source, as we will use
        # the index to track back
        # to the node feautures after we match the global node id in the target to the one that
        # is in the source
        source_feature_idx_by_global_node_id = {
            int(global_node_id): feature_idx
            for feature_idx, global_node_id in enumerate(
                source_global_node_ids.detach().cpu().tolist()
            )
        }

        fill_features = self.__to_fill_features(
            fill_value=fill_value,
            num_features=int(source_x.size(1)),
            dtype=source_x.dtype,
            device=source_x.device,
        )

        enriched_rows = []
        missing_global_node_ids = []
        for global_node_id in target_global_node_ids:
            source_feature_idx = source_feature_idx_by_global_node_id.get(int(global_node_id))
            if source_feature_idx is None:
                # Example: global_node_id = 30 is not present in the source
                #          -> strict transductive mode records it as
                #             missing and then raises an error
                #          -> non-transductive mode fills the features with
                #             fill_value and continues enriching the other nodes
                if is_transductive_setting(node_space_setting):
                    missing_global_node_ids.append(
                        int(global_node_id)
                    )  # record missing node for error message
                else:
                    enriched_rows.append(
                        fill_features
                    )  # fill missing node features with fill_value and
                continue

            # Match the global node IDs in the target to the corresponding
            # feature indices in the source
            # Example: source_global_node_ids = [10, 20, 30], source_x has shape (3, num_features)
            #          target_global_node_ids = [10, 30]
            #          -> source_feature_idx_by_global_node_id = {10: 0, 20: 1, 30: 2}
            #          -> pick source_x rows 0 and 2 for the target
            enriched_rows.append(source_x[source_feature_idx])

        if len(missing_global_node_ids) > 0:
            raise ValueError(
                f"Missing node features for target global_node_ids: {missing_global_node_ids}."
            )

        enriched_x = torch.stack(enriched_rows, dim=0).to(device=self.device)

        return self.__class__(
            x=enriched_x,
            hyperedge_index=self.hyperedge_index.clone(),
            hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
            hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=self.target_node_mask.clone(),
            target_hyperedge_mask=self.target_hyperedge_mask.clone(),
            y=self.y.clone(),
            task=self.task,
        )

    def enrich_hyperedge_weights(
        self,
        enricher: HyperedgeEnricher,
        enrichment_mode: EnrichmentMode | None = "replace",
    ) -> HData:
        """
        Enrich hyperedge weights using the provided hyperedge weight enricher.

        Args:
            enricher: An instance of HyperedgeEnricher to generate hyperedge weights from
                hypergraph topology.
            enrichment_mode: How to combine generated weights with
                existing ``hdata.hyperedge_weights``.
                ``concatenate`` appends new weights to the existing 1D tensor.
                ``replace`` substitutes ``hdata.hyperedge_weights`` entirely.
                Defaults to ``replace`` if not provided.

        Returns:
            hdata: A new `HData` with enriched hyperedge weights.
        """
        self.__validate_enrichment_mode(enrichment_mode)
        enriched_weights = enricher.enrich(self.hyperedge_index)

        match enrichment_mode:
            case "concatenate":
                hyperedge_weights = (
                    torch.cat([self.hyperedge_weights, enriched_weights], dim=0)
                    if self.hyperedge_weights is not None
                    else enriched_weights
                )
            case _:
                hyperedge_weights = enriched_weights

        return self.__class__(
            x=self.x.clone(),
            hyperedge_index=self.hyperedge_index.clone(),
            hyperedge_weights=hyperedge_weights,
            hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=self.target_node_mask.clone(),
            target_hyperedge_mask=self.target_hyperedge_mask.clone(),
            y=self.y.clone(),
            task=self.task,
        )

    def enrich_hyperedge_attr(
        self,
        enricher: HyperedgeEnricher,
        enrichment_mode: EnrichmentMode | None = "replace",
    ) -> HData:
        """
        Enrich hyperedge features using the provided hyperedge feature enricher.

        Args:
            enricher: An instance of HyperedgeEnricher to generate structural hyperedge
                features from hypergraph topology.
            enrichment_mode: How to combine generated features with
                existing ``hdata.hyperedge_attr``.
                ``concatenate`` appends new features as additional columns.
                ``replace`` substitutes ``hdata.hyperedge_attr`` entirely.
                Defaults to ``replace`` if not provided.
        """
        self.__validate_enrichment_mode(enrichment_mode)
        enriched_features = enricher.enrich(self.hyperedge_index)

        match enrichment_mode:
            case "concatenate":
                hyperedge_attr = (
                    torch.cat([self.hyperedge_attr, enriched_features], dim=1)
                    if self.hyperedge_attr is not None
                    else enriched_features
                )
            case _:
                hyperedge_attr = enriched_features

        return self.__class__(
            x=self.x.clone(),
            hyperedge_index=self.hyperedge_index.clone(),
            hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
            hyperedge_attr=hyperedge_attr,
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=self.target_node_mask.clone(),
            target_hyperedge_mask=self.target_hyperedge_mask.clone(),
            y=self.y.clone(),
            task=self.task,
        )

    def get_device_if_all_consistent(self) -> torch.device:
        """
        Check that all tensors are on the same device and return that device.

        If there are no tensors or if they are on different devices, return CPU.

        Returns:
            device: The common device if all tensors are on the same device, otherwise CPU.

        Raises:
            ValueError: If tensors are on different devices.
        """
        devices = {
            self.x.device,
            self.hyperedge_index.device,
            self.global_node_ids.device,
            self.target_node_mask.device,
            self.target_hyperedge_mask.device,
            self.y.device,
        }

        if self.hyperedge_attr is not None:
            devices.add(self.hyperedge_attr.device)
        if self.hyperedge_weights is not None:
            devices.add(self.hyperedge_weights.device)

        if len(devices) > 1:
            raise ValueError(f"Inconsistent device placement: {devices}")

        return devices.pop() if len(devices) == 1 else torch.device("cpu")

    def remove_hyperedges_with_fewer_than_k_nodes(
        self,
        k: int,
        preserve_global_node_ids: bool = False,
    ) -> HData:
        """
        Remove hyperedges that have fewer than k incident nodes.

        Args:
            k: The minimum number of nodes a hyperedge must have to be retained.
            preserve_global_node_ids: Whether to preserve the global node IDs after
                removing hyperedges. Defaults to ``False``.
                If ``False``, the global node IDs will be reindexed to be contiguous after
                removing hyperedges.
                If ``True``, the global node IDs will be preserved, which may cause some models
                to raise as they may expect contiguous global node IDs.
        """
        validate_is_positive("k", k)

        hyperedge_index_wrapper = HyperedgeIndex(
            self.hyperedge_index.clone()
        ).remove_hyperedges_with_fewer_than_k_nodes(k)

        x = self.x[hyperedge_index_wrapper.node_ids]
        y = (
            self.y[hyperedge_index_wrapper.node_ids]
            if self.is_node_related_task
            else self.y[hyperedge_index_wrapper.hyperedge_ids]
        )
        target_node_mask = self.target_node_mask[hyperedge_index_wrapper.node_ids]
        target_hyperedge_mask = self.target_hyperedge_mask[hyperedge_index_wrapper.hyperedge_ids]

        global_node_ids = (
            self.global_node_ids[hyperedge_index_wrapper.node_ids]
            if preserve_global_node_ids
            else None
        )
        hyperedge_attr = (
            self.hyperedge_attr[hyperedge_index_wrapper.hyperedge_ids]
            if self.hyperedge_attr is not None
            else None
        )
        hyperedge_weights = (
            self.hyperedge_weights[hyperedge_index_wrapper.hyperedge_ids]
            if self.hyperedge_weights is not None
            else None
        )

        return self.__class__(
            x=x,
            hyperedge_index=hyperedge_index_wrapper.to_0based().item,
            hyperedge_weights=hyperedge_weights,
            hyperedge_attr=hyperedge_attr,
            num_nodes=hyperedge_index_wrapper.num_nodes,
            num_hyperedges=hyperedge_index_wrapper.num_hyperedges,
            global_node_ids=global_node_ids,
            target_node_mask=target_node_mask,
            target_hyperedge_mask=target_hyperedge_mask,
            y=y,
            task=self.task,
        )

    def shuffle(self, seed: int | None = None) -> HData:
        """
        Return a new `HData` instance with hyperedge IDs randomly reassigned.

        Each hyperedge keeps its original set of nodes, but is assigned a new ID
        via a random permutation.
        ``y`` and ``hyperedge_attr`` are reordered to match, so that ``y[new_id]``
        still corresponds to the correct hyperedge.
        Same for ``hyperedge_attr[new_id]`` if hyperedge attributes are present.

        Examples:
            >>> hyperedge_index = torch.tensor([[0, 1, 2, 3], [0, 0, 1, 1]])
            >>> y  = torch.tensor([1, 0])
            >>> hdata = HData(x=x, hyperedge_index=hyperedge_index, y=y)
            >>> shuffled_hdata = hdata.shuffle(seed=42)
            >>> shuffled_hdata.hyperedge_index  # hyperedges may be reassigned
            ... # e.g.,
            ...     [[0, 1, 2, 3],
            ...      [1, 1, 0, 0]]
            >>> shuffled_hdata.y  # labels are permuted to match new hyperedge IDs, e.g., [0, 1]

        Args:
            seed: Optional random seed for reproducibility. If ``None``, the shuffle
                will be non-deterministic. Defaults to ``None``.

        Returns:
            hdata: A new `HData` instance with hyperedge IDs, ``y``, and
                ``hyperedge_attr`` permuted.
        """
        generator = create_seeded_torch_generator(device=self.device, seed=seed)
        permutation = torch.randperm(
            self.num_hyperedges,
            generator=generator,
            dtype=torch.long,
            device=self.device,
        )

        # permutation[new_id] = old_id, so y[permutation] puts old labels into new slots
        # inverse_permutation[old_id] = new_id, used to remap hyperedge IDs in incidences
        # Example: permutation = [1, 2, 0] means new_id 0 gets old_id 1,
        #                   new_id 1 gets old_id 2, new_id 2 gets old_id 0
        #                   -> inverse_permutation = [2, 0, 1] means old_id 0 gets new_id 2,
        #                        old_id 1 gets new_id 0, old_id 2 gets new_id 1
        inverse_permutation = torch.empty_like(
            permutation,
            dtype=permutation.dtype,
            device=permutation.device,
        )
        inverse_permutation[permutation] = torch.arange(
            self.num_hyperedges,
            dtype=permutation.dtype,
            device=permutation.device,
        )

        new_hyperedge_index = self.hyperedge_index.clone()

        # Example: hyperedge_index = [[0, 1, 2, 3, 4],
        #                             [0, 0, 1, 1, 2]],
        #          inverse_permutation = [2, 0, 1] (new_id 0 -> old_id 2, new_id 1 ->
        #                                           old_id 0, new_id 2 -> old_id 1)
        #          -> new_hyperedge_index = [[0, 1, 2, 3, 4],
        #                                    [2, 2, 0, 0, 1]]
        old_hyperedge_ids = self.hyperedge_index[1]
        new_hyperedge_index[1] = inverse_permutation[old_hyperedge_ids]

        # Example: hyperedge_attr = [attr_0, attr_1, attr_2], permutation = [1, 2, 0]
        #          -> new_hyperedge_attr = [attr_1  (attr of old_id 1),
        #                                   attr_2 (attr of old_id 2),
        #                                   attr_0 (attr of old_id 0)]
        new_hyperedge_attr = (
            self.hyperedge_attr[permutation] if self.hyperedge_attr is not None else None
        )

        new_hyperedge_weights = (
            self.hyperedge_weights[permutation] if self.hyperedge_weights is not None else None
        )
        new_target_hyperedge_mask = self.target_hyperedge_mask[permutation]

        # Permutate only for tasks where y is related to hyperedges (e.g., hyperlink-prediction)
        # Example: y = [1, 1, 0], permutation = [1, 2, 0]
        #          -> new_y = [y[1], y[2], y[0]] = [1, 0, 1]
        new_y = self.y[permutation] if self.is_hyperedge_related_task else self.y.clone()

        return self.__class__(
            x=self.x.clone(),
            hyperedge_index=new_hyperedge_index,
            hyperedge_weights=new_hyperedge_weights,
            hyperedge_attr=new_hyperedge_attr,
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=self.target_node_mask.clone(),
            target_hyperedge_mask=new_target_hyperedge_mask,
            y=new_y,
            task=self.task,
        )

    def clone(self) -> HData:
        """
        Return a deep copy of this `HData`.

        Returns:
            hdata: A new `HData` that is a deep copy of this instance.
        """
        return self.__class__(
            x=self.x.clone(),
            hyperedge_index=self.hyperedge_index.clone(),
            hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
            hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=self.target_node_mask.clone(),
            target_hyperedge_mask=self.target_hyperedge_mask.clone(),
            y=self.y.clone(),
            task=self.task,
        )

    def to(self, device: torch.device | str, non_blocking: bool = False) -> HData:
        """
        Move all tensors to the specified device.

        Args:
            device: The target device (e.g., 'cpu', 'cuda:0').
            non_blocking: If ``True`` and the source and destination devices are both CUDA,
                the copy will be non-blocking. Defaults to ``False``.

        Returns:
            hdata: The `HData` instance with all tensors moved to the specified device.
        """
        self.x = self.x.to(device=device, non_blocking=non_blocking)
        self.hyperedge_index = self.hyperedge_index.to(device=device, non_blocking=non_blocking)
        self.global_node_ids = self.global_node_ids.to(device=device, non_blocking=non_blocking)
        self.target_node_mask = self.target_node_mask.to(device=device, non_blocking=non_blocking)
        self.target_hyperedge_mask = self.target_hyperedge_mask.to(
            device=device,
            non_blocking=non_blocking,
        )
        self.y = self.y.to(device=device, non_blocking=non_blocking)

        if self.hyperedge_attr is not None:
            self.hyperedge_attr = self.hyperedge_attr.to(device=device, non_blocking=non_blocking)

        if self.hyperedge_weights is not None:
            self.hyperedge_weights = self.hyperedge_weights.to(
                device=device,
                non_blocking=non_blocking,
            )

        self.device = device if isinstance(device, torch.device) else torch.device(device)
        return self

    def with_target_node_mask(self, target_node_mask: Tensor) -> HData:
        """
        Return a copy of this instance with a ``target_node_mask`` attribute set to the given mask.

        Args:
            target_node_mask: A boolean tensor indicating which nodes are considered target nodes.

        Returns:
            hdata: A new `HData` instance with the same attributes except for ``target_node_mask``,
                which is set to the provided mask.
        """
        return self.__class__(
            x=self.x.clone(),
            hyperedge_index=self.hyperedge_index.clone(),
            hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
            hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=target_node_mask.clone(),
            target_hyperedge_mask=self.target_hyperedge_mask.clone(),
            y=self.y.clone(),
            task=self.task,
        )

    def with_target_hyperedge_mask(self, target_hyperedge_mask: Tensor) -> HData:
        """
        Return a copy of this instance with ``target_hyperedge_mask`` set to the given mask.

        Args:
            target_hyperedge_mask: Boolean tensor indicating target hyperedges.

        Returns:
            hdata: A new `HData` instance with the same attributes except for
                ``target_hyperedge_mask``.
        """
        return self.__class__(
            x=self.x.clone(),
            hyperedge_index=self.hyperedge_index.clone(),
            hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
            hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=self.target_node_mask.clone(),
            target_hyperedge_mask=target_hyperedge_mask.clone(),
            y=self.y.clone(),
            task=self.task,
        )

    def with_y_to(self, value: float, size: int | None = None) -> HData:
        """
        Return a copy of this instance with a y attribute set to the given value.

        Args:
            value: The value to set for all entries in the y attribute.
            size: The size of the y tensor. If ``None``, the size will be inferred
                from the number of hyperedges in the instance.

        Returns:
            hdata: A new `HData` instance with the same attributes except for y,
                which is set to a tensor of the given value.
        """
        y_size = size if size is not None else self.num_hyperedges
        return self.__class__(
            x=self.x.clone(),
            hyperedge_index=self.hyperedge_index.clone(),
            hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
            hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
            num_nodes=self.num_nodes,
            num_hyperedges=self.num_hyperedges,
            global_node_ids=self.global_node_ids.clone(),
            target_node_mask=self.target_node_mask.clone(),
            target_hyperedge_mask=self.target_hyperedge_mask.clone(),
            y=torch.full((y_size,), value, dtype=torch.float, device=self.device),
            task=self.task,
        )

    def with_y_ones(self, size: int | None = None) -> HData:
        """
        Return a copy of this instance with a y attribute of all ones.

        Args:
            size: The size of the y tensor. If ``None``, the size will be inferred
                from the number of hyperedges in the instance.

        Returns:
            hdata: A new `HData` instance with the same attributes except for y, which is
                set to a tensor of ones.
        """
        return self.with_y_to(1.0, size=size)

    def with_y_zeros(self, size: int | None = None) -> HData:
        """
        Return a copy of this instance with a y attribute of all zeros.

        Args:
            size: The size of the y tensor. If ``None``, the size will be inferred
                from the number of hyperedges in the instance.

        Returns:
            hdata: A new `HData` instance with the same attributes except for y, which is
                set to a tensor of zeros.
        """
        return self.with_y_to(0.0, size=size)

    def stats(self) -> dict[str, Any]:
        """
        Compute statistics for the hypergraph data.

        Fields:
            - ``shape_x``: The shape of the node feature matrix ``x``.
            - ``shape_hyperedge_weights``: The shape of the hyperedge weights tensor, or
                ``None`` if hyperedge weights are not present.
            - ``shape_hyperedge_attr``: The shape of the hyperedge attribute matrix, or ``None``
                if hyperedge attributes are not present.
            - ``num_nodes``: The number of nodes in the hypergraph.
            - ``num_hyperedges``: The number of hyperedges in the hypergraph.
            - ``avg_degree_node_raw``: The average degree of nodes, calculated as the mean
                number of hyperedges each node belongs to.
            - ``avg_degree_node``: The floored node average degree.
            - ``avg_degree_hyperedge_raw``: The average size of hyperedges, calculated as
                the mean number of nodes each hyperedge contains.
            - ``avg_degree_hyperedge``: The floored hyperedge average size.
            - ``node_degree_max``: The maximum degree of any node in the hypergraph.
            - ``hyperedge_degree_max``: The maximum size of any hyperedge in the hypergraph.
            - ``node_degree_median``: The median degree of nodes in the hypergraph.
            - ``hyperedge_degree_median``: The median size of hyperedges in the hypergraph.
            - ``distribution_node_degree``: A list where the value at index ``i`` represents
                the count of nodes with degree ``i``.
            - ``distribution_hyperedge_size``: A list where the value at index ``i`` represents
                the count of hyperedges with size ``i``.
            - ``distribution_node_degree_hist``: A dictionary where the keys are node degrees and
                the values are the count of nodes with that degree.
            - ``distribution_hyperedge_size_hist``: A dictionary where the keys are hyperedge
                sizes and the values are the count of hyperedges with that size.

        Returns:
            stats: A dictionary containing various statistics about the hypergraph.
        """
        node_ids = self.hyperedge_index[0]
        hyperedge_ids = self.hyperedge_index[1]

        # Degree of each node = number of hyperedges it belongs to
        # Size of each hyperedge = number of nodes it contains
        if node_ids.numel() > 0:
            distribution_node_degree = torch.bincount(node_ids, minlength=self.num_nodes).float()
            distribution_hyperedge_size = torch.bincount(
                hyperedge_ids, minlength=self.num_hyperedges
            ).float()
        else:
            distribution_node_degree = torch.zeros(
                self.num_nodes, dtype=torch.float, device=self.device
            )
            distribution_hyperedge_size = torch.zeros(
                self.num_hyperedges, dtype=torch.float, device=self.device
            )

        if distribution_node_degree.numel() > 0:
            avg_degree_node_raw = distribution_node_degree.mean(dtype=torch.float).item()
            avg_degree_node = int(avg_degree_node_raw)
            avg_degree_hyperedge_raw = distribution_hyperedge_size.mean(dtype=torch.float).item()
            avg_degree_hyperedge = int(avg_degree_hyperedge_raw)
            node_degree_max = int(distribution_node_degree.max().item())
            hyperedge_degree_max = int(distribution_hyperedge_size.max().item())
            node_degree_median = int(distribution_node_degree.median().item())
            hyperedge_degree_median = int(distribution_hyperedge_size.median().item())
        else:
            avg_degree_node_raw = 0
            avg_degree_node = 0
            avg_degree_hyperedge_raw = 0
            avg_degree_hyperedge = 0
            node_degree_max = 0
            hyperedge_degree_max = 0
            node_degree_median = 0
            hyperedge_degree_median = 0

        # Histograms: index i holds count of nodes/hyperedges with degree/size i
        distribution_node_degree_hist = torch.bincount(distribution_node_degree.long())
        distribution_hyperedge_size_hist = torch.bincount(distribution_hyperedge_size.long())

        distribution_node_degree_hist = {
            i: int(count.item())
            for i, count in enumerate(distribution_node_degree_hist)
            if count.item() > 0
        }
        distribution_hyperedge_size_hist = {
            i: int(count.item())
            for i, count in enumerate(distribution_hyperedge_size_hist)
            if count.item() > 0
        }

        return {
            "shape_x": self.x.shape,
            "shape_hyperedge_weights": self.hyperedge_weights.shape
            if self.hyperedge_weights is not None
            else None,
            "shape_hyperedge_attr": self.hyperedge_attr.shape
            if self.hyperedge_attr is not None
            else None,
            "num_nodes": self.num_nodes,
            "num_hyperedges": self.num_hyperedges,
            "avg_degree_node_raw": avg_degree_node_raw,
            "avg_degree_node": avg_degree_node,
            "avg_degree_hyperedge_raw": avg_degree_hyperedge_raw,
            "avg_degree_hyperedge": avg_degree_hyperedge,
            "node_degree_max": node_degree_max,
            "hyperedge_degree_max": hyperedge_degree_max,
            "node_degree_median": node_degree_median,
            "hyperedge_degree_median": hyperedge_degree_median,
            "distribution_node_degree": distribution_node_degree.int().tolist(),
            "distribution_hyperedge_size": distribution_hyperedge_size.int().tolist(),
            "distribution_node_degree_hist": distribution_node_degree_hist,
            "distribution_hyperedge_size_hist": distribution_hyperedge_size_hist,
        }

    @classmethod
    def __validate_can_perform_cat_same_node_space(
        cls,
        hdatas: Sequence[HData],
        x: Tensor | None,
        global_node_ids: Tensor | None,
    ) -> None:
        """
        Validate inputs for concatenating HData objects in the same node space.

        Args:
            hdatas: HData objects to concatenate.
            x: Optional shared node feature matrix.
            global_node_ids: Optional shared global node IDs.

        Raises:
            ValueError: If required paired arguments are missing or hyperedge IDs overlap.
        """
        validate_is_non_empty("hdatas", hdatas)

        if x is not None and global_node_ids is None:
            raise ValueError(
                "If 'x' is provided, 'global_node_ids' must also be provided to ensure consistency."
            )
        if x is None and global_node_ids is not None:
            raise ValueError(
                "If 'global_node_ids' is provided, 'x' must also be provided to ensure consistency."
            )

        joint_hyperedge_ids = torch.cat([hdata.hyperedge_index[1].unique() for hdata in hdatas])
        unique_joint_hyperedge_ids = joint_hyperedge_ids.unique()
        if unique_joint_hyperedge_ids.size(0) != joint_hyperedge_ids.size(0):
            raise ValueError(
                "Overlapping hyperedge IDs found across instances. Ensure each "
                "instance uses distinct hyperedge IDs."
            )

        tasks = {hdata.task for hdata in hdatas}
        if len(tasks) > 1:
            raise ValueError(f"All HData instances must have the same task, got {tasks}.")

    def __to_fill_features(
        self,
        fill_value: NodeSpaceFiller | None,
        num_features: int,
        dtype: torch.dtype,
        device: torch.device,
    ) -> Tensor:
        """
        Convert a fill value into a feature vector.

        Args:
            fill_value: Scalar or vector fill value.
            num_features: Required number of feature values.
            dtype: Desired tensor dtype.
            device: Desired tensor device.

        Returns:
            fill_features: Tensor of shape ``(num_features,)`` or an empty tensor.

        Raises:
            ValueError: If the fill value cannot be broadcast to the requested feature count.
        """
        if fill_value is None:
            return torch.empty((0,), dtype=dtype, device=device)

        if isinstance(fill_value, Tensor):
            fill_features = fill_value.to(dtype=dtype, device=device)
        elif isinstance(fill_value, (int, float)):
            fill_features = torch.full(
                (num_features,), float(fill_value), dtype=dtype, device=device
            )
        else:
            fill_features = torch.tensor(fill_value, dtype=dtype, device=device)

        # This can happen when fill_value is:
        # - A scalar tensor, e.g., tensor(0.0), which should be broadcasted to all features
        # - A list with a single value, e.g., [0.0], which should
        #   also be broadcasted to all features
        if fill_features.numel() == 1:
            fill_features = fill_features.repeat(num_features)

        if fill_features.dim() != 1 or fill_features.numel() != num_features:
            raise ValueError(
                f"Expected 'fill_value' to define exactly {num_features} features, got shape "
                f"{tuple(fill_features.shape)}."
            )
        return fill_features

    def __validate(self) -> None:
        """
        Validate all HData tensor fields.

        Raises:
            ValueError: If any field has an invalid shape, dtype, or count.
        """
        self.__validate_x()
        self.__validate_hyperedge_index()
        self.__validate_hyperedge_attr()
        self.__validate_hyperedge_weights()
        self.__validate_global_node_ids()
        self.__validate_target_node_mask()
        self.__validate_target_hyperedge_mask()
        self.__validate_labels()
        self.__validate_task()

    def __validate_enrichment_mode(self, enrichment_mode: EnrichmentMode | None) -> None:
        """
        Validate a feature enrichment mode.

        Args:
            enrichment_mode: Optional enrichment mode to validate.

        Raises:
            ValueError: If the mode is unsupported.
        """
        if enrichment_mode is None or enrichment_mode in ("replace", "concatenate"):
            return

        raise ValueError(
            f"'enrichment_mode' must be one of 'replace', 'concatenate', "
            f"or None, got {enrichment_mode!r}."
        )

    def __validate_hyperedge_attr(self) -> None:
        """
        Validate optional hyperedge attributes.

        Raises:
            ValueError: If hyperedge attributes have an invalid dtype or shape.
        """
        if self.hyperedge_attr is None:
            return

        validate_floating_tensor_dtype("hyperedge_attr", self.hyperedge_attr)
        if self.hyperedge_attr.dim() != 2:
            raise ValueError(
                f"'hyperedge_attr' must be a 2D tensor, got shape "
                f"{tuple(self.hyperedge_attr.shape)}."
            )
        if self.hyperedge_attr.size(0) != self.num_hyperedges:
            raise ValueError(
                "'hyperedge_attr' must have one row per hyperedge. "
                f"Got size={self.hyperedge_attr.size(0)} but "
                f"num_hyperedges={self.num_hyperedges}."
            )

    def __validate_hyperedge_index(self) -> None:
        """
        Validate hyperedge index IDs against configured node and hyperedge counts.

        Raises:
            ValueError: If IDs are negative or counts are too small.
        """
        if self.hyperedge_index.numel() > 0 and bool((self.hyperedge_index < 0).any()):
            raise ValueError("'hyperedge_index' cannot contain negative node or hyperedge IDs.")

        unique_node_count = self.hyperedge_index[0].unique().size(0)
        if unique_node_count > self.num_nodes:
            raise ValueError(
                f"'num_nodes' is too small for 'hyperedge_index'. "
                f"Got num_nodes={self.num_nodes}, but 'hyperedge_index' contains "
                f"{unique_node_count} unique node IDs."
            )

        unique_hyperedge_count = self.hyperedge_index[1].unique().size(0)
        if unique_hyperedge_count > self.num_hyperedges:
            raise ValueError(
                f"'num_hyperedges' is too small for 'hyperedge_index'. "
                f"Got num_hyperedges={self.num_hyperedges}, but 'hyperedge_index' contains "
                f"{unique_hyperedge_count} unique hyperedge IDs."
            )

    def __validate_hyperedge_weights(self) -> None:
        """
        Validate optional hyperedge weights.

        Raises:
            ValueError: If hyperedge weights have an invalid dtype or shape.
        """
        if self.hyperedge_weights is None:
            return

        validate_floating_tensor_dtype("hyperedge_weights", self.hyperedge_weights)

        if self.hyperedge_weights.dim() != 1:
            raise ValueError(
                f"'hyperedge_weights' must be a 1D tensor, "
                f"got shape {tuple(self.hyperedge_weights.shape)}."
            )
        if self.hyperedge_weights.size(0) != self.num_hyperedges:
            raise ValueError(
                f"'hyperedge_weights' must have one entry per hyperedge. "
                f"Got size={self.hyperedge_weights.size(0)} but "
                f"num_hyperedges={self.num_hyperedges}."
            )

    def __validate_global_node_ids(self) -> None:
        """
        Validate global node IDs.

        Raises:
            ValueError: If global node IDs have an invalid dtype, shape, or length.
        """
        validate_long_tensor_dtype("global_node_ids", self.global_node_ids)
        if self.global_node_ids.dim() != 1:
            raise ValueError(
                f"'global_node_ids' must be a 1D tensor, got "
                f"shape {tuple(self.global_node_ids.shape)}."
            )
        if self.global_node_ids.size(0) != self.num_nodes:
            raise ValueError(
                f"'global_node_ids' must have one entry per node. "
                f"Got size={self.global_node_ids.size(0)} but num_nodes={self.num_nodes}."
            )

    def __validate_labels(self) -> None:
        """
        Validate labels.

        Raises:
            ValueError: If labels have an invalid dtype, shape, or length.
        """
        if self.y.dim() != 1:
            raise ValueError(f"'y' must be a 1D tensor, got shape {tuple(self.y.shape)}.")

        if self.is_node_related_task:
            validate_long_tensor_dtype("y", self.y)
            if self.y.size(0) != self.num_nodes:
                raise ValueError(
                    f"For task={self.task!r}, 'y' must have one entry per node. "
                    f"Got {self.y.size(0)} entries but num_nodes={self.num_nodes}."
                )
            return

        if self.is_hyperedge_related_task:
            validate_floating_tensor_dtype("y", self.y)
            if self.y.size(0) != self.num_hyperedges:
                raise ValueError(
                    f"For task={self.task!r}, 'y' must have one entry per hyperedge. "
                    f"Got {self.y.size(0)} entries but num_hyperedges={self.num_hyperedges}."
                )

    def __validate_target_node_mask(self) -> None:
        """
        Validate the optional supervised-node mask.

        Raises:
            ValueError: If the mask is incompatible with the configured task or node count.
        """
        if not self.is_node_related_task:
            return

        if self.target_node_mask.dtype != torch.bool:
            raise ValueError(
                f"'target_node_mask' must have dtype torch.bool, got {self.target_node_mask.dtype}."
            )
        if self.target_node_mask.dim() != 1:
            raise ValueError(
                f"'target_node_mask' must be a 1D tensor, "
                f"got shape {tuple(self.target_node_mask.shape)}."
            )
        if self.target_node_mask.size(0) != self.num_nodes:
            raise ValueError(
                f"'target_node_mask' must have one entry per node. "
                f"Got size={self.target_node_mask.size(0)} but num_nodes={self.num_nodes}."
            )

    def __validate_target_hyperedge_mask(self) -> None:
        """
        Validate the optional supervised-hyperedge mask.

        Raises:
            ValueError: If the mask is incompatible with the configured task or hyperedge count.
        """
        if not self.is_hyperedge_related_task:
            return

        if self.target_hyperedge_mask.dtype != torch.bool:
            raise ValueError(
                "'target_hyperedge_mask' must have dtype torch.bool, "
                f"got {self.target_hyperedge_mask.dtype}."
            )
        if self.target_hyperedge_mask.dim() != 1:
            raise ValueError(
                f"'target_hyperedge_mask' must be a 1D tensor, "
                f"got shape {tuple(self.target_hyperedge_mask.shape)}."
            )
        if self.target_hyperedge_mask.size(0) != self.num_hyperedges:
            raise ValueError(
                f"'target_hyperedge_mask' must have one entry per hyperedge. "
                f"Got size={self.target_hyperedge_mask.size(0)} but "
                f"num_hyperedges={self.num_hyperedges}."
            )

    def __validate_task(self) -> None:
        """
        Validate the learning task.

        Raises:
            ValueError: If the task is unsupported.
        """
        valid_tasks = get_args(TaskLiteral)
        if self.task not in valid_tasks:
            raise ValueError(f"'task' must be one of {valid_tasks}, got {self.task!r}.")

    def __validate_x(self) -> None:
        """
        Validate node feature row count.

        Raises:
            ValueError: If node features do not match the configured node count.
        """
        if self.x.size(0) not in (0, self.num_nodes):
            raise ValueError(
                f"'x' must have one feature row per node, or be 'torch.empty((0, 0))' "
                f"if there are no nodes. "
                f"Got x.shape={tuple(self.x.shape)} but num_nodes={self.num_nodes}."
            )

    def __validate_node_space_setting(
        self,
        node_space_setting: NodeSpaceSetting,
        fill_value: NodeSpaceFiller | None,
    ) -> None:
        """
        Validate node-space enrichment settings.

        Args:
            node_space_setting: Node-space setting to validate.
            fill_value: Optional fill value for missing nodes.

        Raises:
            ValueError: If the setting and fill value are incompatible.
        """
        validate_node_space_setting(node_space_setting)

        if is_transductive_setting(node_space_setting) and fill_value is not None:
            raise ValueError(
                "'fill_value' cannot be provided when node_space_setting='transductive'."
            )
        if is_inductive_setting(node_space_setting) and fill_value is None:
            raise ValueError("'fill_value' must be provided when node_space_setting='inductive'.")

    def __validate_x_and_hyperedge_index_type_and_dim(self) -> None:
        """
        Validate core tensor dtypes and dimensions.

        Raises:
            ValueError: If ``x`` or ``hyperedge_index`` has an invalid dtype or shape.
        """
        validate_floating_tensor_dtype("x", self.x)
        if self.x.dim() != 2:
            raise ValueError(f"'x' must be a 2D tensor, got shape {tuple(self.x.shape)}.")

        validate_long_tensor_dtype("hyperedge_index", self.hyperedge_index)
        if self.hyperedge_index.dim() != 2 or self.hyperedge_index.size(0) != 2:
            raise ValueError(
                f"'hyperedge_index' must have shape (2, num_incidences), got "
                f"{tuple(self.hyperedge_index.shape)}."
            )

    def __assign_y_for_task(self, y: Tensor | None = None) -> Tensor:
        """
        Return labels as non-None tensor on the correct device.

        Args:
            y: Optional labels tensor. If ``None``, defaults to
                a tensor of ones for hyperlink prediction tasks
                or a tensor of zeros for node classification tasks.

        Returns:
            y: Labels tensor on the correct device.
        """
        if y is not None:
            return y
        if self.is_node_related_task:
            return torch.ones((self.num_nodes,), dtype=torch.long, device=self.x.device)
        return torch.ones((self.num_hyperedges,), dtype=torch.float, device=self.x.device)

Check if the task uses hyperedge-level targets and operations.

Returns:

Name Type Description
is_hyperedge_related bool

True if the task is hyperedge-related, False otherwise.

Check if the task uses node-level targets and operations.

Returns:

Name Type Description
is_node_related bool

True if the task is node-related, False otherwise.

num_sampleable_nodes property

Return the number of nodes that are eligible for sampling based on this HData instance's task.

num_sampleable_hyperedges property

Return the number of hyperedges that are eligible for sampling based on this HData instance's task.

sampleable_node_ids property

Return node IDs that are eligible for sampling based on this HData instance's task.

sampleable_hyperedge_ids property

Return hyperedge IDs eligible for sampling based on this HData instance's task.

__init__(x, hyperedge_index, hyperedge_weights=None, hyperedge_attr=None, num_nodes=None, num_hyperedges=None, global_node_ids=None, target_node_mask=None, target_hyperedge_mask=None, y=None, task=TaskEnum.HYPERLINK_PREDICTION)

Initialize hypergraph learning data.

Parameters:

Name Type Description Default
x Tensor

Node feature matrix of shape [num_nodes, num_features].

required
hyperedge_index Tensor

Sparse node-hyperedge incidence matrix of shape [2, num_incidences], where hyperedge_index[0] contains node IDs and hyperedge_index[1] contains hyperedge IDs.

required
hyperedge_weights Tensor | None

Optional tensor of shape [num_hyperedges] containing weights for each hyperedge.

None
hyperedge_attr Tensor | None

Optional hyperedge attributes of shape [num_hyperedges, num_hyperedge_features]. Features associated with each hyperedge (e.g., timestamps, types).

None
num_nodes int | None

Number of nodes in the hypergraph. If None, inferred as x.size(0).

None
num_hyperedges int | None

Number of hyperedges in the hypergraph. If None, inferred as the number of unique hyperedge IDs in hyperedge_index[1].

None
global_node_ids Tensor | None

Optional node IDs of shape [num_nodes] matching the row order of x. These remain unchanged across operations (like split and sampling), so that nodes can be mapped back to their original identity in the source dataset. Use this to preserve access to the canonical node space when hyperedge_index is rebased locally. If None, defaults to torch.arange(num_nodes), assuming that these are the global node IDs in the same order as the rows of x.

None
target_node_mask Tensor | None

Optional boolean tensor of shape [num_nodes] identifying the supervised nodes for tasks learning on nodes. This is useful in transductive settings where models are trained on the entire hypergraph. If None, defaults to a tensor of ones of size num_nodes. It is always ignored for hyperedge-related tasks.

None
target_hyperedge_mask Tensor | None

Optional boolean tensor of shape [num_hyperedges] identifying the supervised hyperedges for tasks learning on hyperedges. This is useful in transductive settings where models are trained on the entire hypergraph. If None, defaults to a tensor of ones of size num_hyperedges. It is always ignored for node-related tasks.

None
y Tensor | None

Labels for nodes or hyperedges, it has shape [num_hyperedges] for tasks learning on hyperedges, and shape [num_nodes] for tasks learning on nodes. Used for supervised learning tasks. For unsupervised tasks, this can be ignored. Defaults to a tensor of ones.

None
task Task

Learning task used to determine whether operations work on nodes or hyperedges. If None, defaults to "hyperlink-prediction".

HYPERLINK_PREDICTION
Source code in hypertorch/types/hdata.py
def __init__(
    self,
    x: Tensor,
    hyperedge_index: Tensor,
    hyperedge_weights: Tensor | None = None,
    hyperedge_attr: Tensor | None = None,
    num_nodes: int | None = None,
    num_hyperedges: int | None = None,
    global_node_ids: Tensor | None = None,
    target_node_mask: Tensor | None = None,
    target_hyperedge_mask: Tensor | None = None,
    y: Tensor | None = None,
    task: Task = TaskEnum.HYPERLINK_PREDICTION,
):
    """
    Initialize hypergraph learning data.

    Args:
        x: Node feature matrix of shape ``[num_nodes, num_features]``.
        hyperedge_index: Sparse node-hyperedge incidence matrix of shape
            ``[2, num_incidences]``, where ``hyperedge_index[0]`` contains node IDs
            and ``hyperedge_index[1]`` contains hyperedge IDs.
        hyperedge_weights: Optional tensor of shape ``[num_hyperedges]`` containing weights
            for each hyperedge.
        hyperedge_attr: Optional hyperedge attributes of
            shape ``[num_hyperedges, num_hyperedge_features]``.
            Features associated with each hyperedge (e.g., timestamps, types).
        num_nodes: Number of nodes in the hypergraph. If ``None``, inferred as ``x.size(0)``.
        num_hyperedges: Number of hyperedges in the hypergraph.
            If ``None``, inferred as the number of unique hyperedge IDs
            in ``hyperedge_index[1]``.
        global_node_ids: Optional node IDs of shape ``[num_nodes]`` matching the
            row order of ``x``. These remain unchanged across operations (like split and
            sampling), so that nodes can be mapped back to their original identity in
            the source dataset. Use this to preserve access to the canonical node space
            when ``hyperedge_index`` is rebased locally.
            If ``None``, defaults to ``torch.arange(num_nodes)``, assuming that these are the
            global node IDs in the same order as the rows of ``x``.
        target_node_mask: Optional boolean tensor of shape ``[num_nodes]`` identifying the
            supervised nodes for tasks learning on nodes. This is useful in transductive
            settings where models are trained on the entire hypergraph.
            If ``None``, defaults to a tensor of ones of size ``num_nodes``. It is always
            ignored for hyperedge-related tasks.
        target_hyperedge_mask: Optional boolean tensor of shape ``[num_hyperedges]``
            identifying the supervised hyperedges for tasks learning on hyperedges. This is
            useful in transductive settings where models are trained on the entire hypergraph.
            If ``None``, defaults to a tensor of ones of size ``num_hyperedges``. It is always
            ignored for node-related tasks.
        y: Labels for nodes or hyperedges, it has shape ``[num_hyperedges]`` for tasks learning
            on hyperedges, and shape ``[num_nodes]`` for tasks learning on nodes.
            Used for supervised learning tasks. For unsupervised tasks, this can be ignored.
            Defaults to a tensor of ones.
        task: Learning task used to determine whether operations work on nodes or hyperedges.
            If ``None``, defaults to ``"hyperlink-prediction"``.
    """
    self.x: Tensor = x
    self.hyperedge_index: Tensor = hyperedge_index
    self.__validate_x_and_hyperedge_index_type_and_dim()

    self.hyperedge_weights: Tensor | None = hyperedge_weights
    self.hyperedge_attr: Tensor | None = hyperedge_attr

    hyperedge_index_wrapper = HyperedgeIndex(hyperedge_index)
    self.num_nodes: int = (
        num_nodes
        if num_nodes is not None
        # There should never be isolated nodes when HData is created by Dataset
        # as each isolated node gets its own self-loop hyperedge
        else hyperedge_index_wrapper.num_nodes_if_isolated_exist(num_nodes=x.size(0))
    )
    validate_is_non_negative("num_nodes", self.num_nodes)

    self.num_hyperedges: int = (
        num_hyperedges if num_hyperedges is not None else hyperedge_index_wrapper.num_hyperedges
    )
    validate_is_non_negative("num_hyperedges", self.num_hyperedges)

    self.global_node_ids: Tensor = (
        # torch.arange is to handle isolated nodes, as they are already considered
        # when computing self.num_nodes via num_nodes_if_isolated_exist
        global_node_ids
        if global_node_ids is not None
        else torch.arange(self.num_nodes, dtype=torch.long, device=self.x.device)
    )

    self.target_node_mask: Tensor = (
        target_node_mask
        if target_node_mask is not None
        else torch.ones(self.num_nodes, dtype=torch.bool, device=self.x.device)
    )
    self.target_hyperedge_mask: Tensor = (
        target_hyperedge_mask
        if target_hyperedge_mask is not None
        else torch.ones(self.num_hyperedges, dtype=torch.bool, device=self.x.device)
    )

    self.task: Task = task
    self.y: Tensor = self.__assign_y_for_task(y)

    self.__validate()

    self.device: torch.device = self.get_device_if_all_consistent()

__repr__()

Return a shape-oriented representation of the data object.

Returns:

Name Type Description
representation str

Human-readable summary of tensor shapes and counts.

Source code in hypertorch/types/hdata.py
def __repr__(self) -> str:
    """
    Return a shape-oriented representation of the data object.

    Returns:
        representation: Human-readable summary of tensor shapes and counts.
    """
    hyperedge_weights_shape = (
        self.hyperedge_weights.shape if self.hyperedge_weights is not None else None
    )
    hyperedge_attr_shape = (
        self.hyperedge_attr.shape if self.hyperedge_attr is not None else None
    )
    target_node_mask_shape = (
        str(self.target_node_mask.shape)
        if self.is_node_related_task
        else f"(ignored for task={self.task!r})"
    )
    target_hyperedge_mask_shape = (
        str(self.target_hyperedge_mask.shape)
        if self.is_hyperedge_related_task
        else f"(ignored for task={self.task!r})"
    )

    return (
        f"{self.__class__.__name__}(\n"
        f"    num_nodes={self.num_nodes},\n"
        f"    num_hyperedges={self.num_hyperedges},\n"
        f"    x_shape={self.x.shape},\n"
        f"    global_node_ids_shape={self.global_node_ids.shape},\n"
        f"    target_node_mask_shape={target_node_mask_shape},\n"
        f"    target_hyperedge_mask_shape={target_hyperedge_mask_shape},\n"
        f"    hyperedge_index_shape={self.hyperedge_index.shape},\n"
        f"    hyperedge_weights_shape={hyperedge_weights_shape},\n"
        f"    hyperedge_attr_shape={hyperedge_attr_shape},\n"
        f"    y_shape={self.y.shape},\n"
        f"    task={self.task!r},\n"
        f"    device={self.device}\n"
        f")"
    )

cat_same_node_space(hdatas, x=None, global_node_ids=None) classmethod

Concatenate HData instances that share the same node space, meaning nodes with the same ID in different instances are the same node. This is useful when combining positive and negative hyperedges that reference the same set of nodes.

Notes
  • x is derived from the instance with the largest number of nodes, if not provided explicitly. If there are conflicting features for the same node ID across instances, the features from the instance with the largest number of nodes will be used. If global_node_ids is provided explicitly, x must also be provided to ensure consistency.
  • hyperedge_index is the concatenation of all input hyperedge indices.
  • hyperedge_weights is the concatenation of all input hyperedge weights, if present. If some instances have hyperedge weights and others do not, the resulting hyperedge_weights will be set to None.
  • hyperedge_attr is the concatenation of all input hyperedge attributes, if present. If some instances have hyperedge attributes and others do not, the resulting hyperedge_attr will be set to None.
  • global_node_ids is derived from the instance with the largest number of nodes, if not provided explicitly. If x is provided explicitly, global_node_ids must be provided explicitly as well to ensure consistency.
  • target_node_mask is derived from the instance with the largest number of nodes.
  • target_hyperedge_mask is the concatenation of all input hyperedge target masks.
  • y is the concatenation of all input labels.

Examples:

>>> x = torch.randn(5, 8)
>>> pos = HData(x=x, hyperedge_index=torch.tensor([[0, 1, 2, 3, 4], [0, 0, 1, 2, 2]]))
>>> neg = HData(x=x, hyperedge_index=torch.tensor([[0, 2], [3, 3]]))
>>> new = HData.cat_same_node_space([pos, neg])
>>> new.num_nodes  # 5 — nodes [0, 1, 2, 3, 4]
>>> new.num_hyperedges  # 4 — hyperedges [0, 1, 2, 3]

Parameters:

Name Type Description Default
hdatas Sequence[HData]

One or more HData instances sharing the same node space.

required
x Tensor | None

Optional node feature matrix to use for the resulting HData. If None, the node features from the instance with the largest number of nodes will be used. If global_node_ids is provided explicitly, x must also be provided to ensure consistency. Defaults to None.

None
global_node_ids Tensor | None

Optional global node IDs for the resulting HData. If None, the global node IDs from the instance with the largest number of nodes will be used. If x is provided explicitly, global_node_ids must also be provided to ensure consistency. If x is provided and there is no need for global_node_ids to preserve access to the canonical node space, it is recommended to use arbitrary global node IDs that are consistent with the feature rows of x. For example, global_node_ids=torch.arange(x.size(0))). Defaults to None.

None

Returns:

Name Type Description
hdata HData

A new HData with shared nodes and concatenated hyperedges.

Raises:

Type Description
ValueError

If no HData instances are provided, if there are overlapping hyperedge IDs across instances, or if x and global_node_ids are not both provided when one of them is provided.

Source code in hypertorch/types/hdata.py
@classmethod
def cat_same_node_space(
    cls,
    hdatas: Sequence[HData],
    x: Tensor | None = None,
    global_node_ids: Tensor | None = None,
) -> HData:
    """
    Concatenate `HData` instances that share the same node space, meaning nodes with
    the same ID in different instances are the same node.
    This is useful when combining positive and negative hyperedges that reference
    the same set of nodes.

    Notes:
        - ``x`` is derived from the instance with the largest number of nodes,
            if not provided explicitly.
            If there are conflicting features for the same node ID across instances,
            the features from the instance with the largest number of nodes will be used.
            If ``global_node_ids`` is provided explicitly, ``x`` must also be provided
            to ensure consistency.
        - ``hyperedge_index`` is the concatenation of all input hyperedge indices.
        - ``hyperedge_weights`` is the concatenation of all input hyperedge weights, if present.
            If some instances have hyperedge weights and others do not, the resulting
            ``hyperedge_weights`` will be set to ``None``.
        - ``hyperedge_attr`` is the concatenation of all input hyperedge attributes, if present.
            If some instances have hyperedge attributes and others do not, the resulting
            ``hyperedge_attr`` will be set to ``None``.
        - ``global_node_ids`` is derived from the instance with the largest number of nodes,
            if not provided explicitly.
            If ``x`` is provided explicitly, ``global_node_ids`` must be provided explicitly
            as well to ensure consistency.
        - ``target_node_mask`` is derived from the instance with the largest number of nodes.
        - ``target_hyperedge_mask`` is the concatenation of all input hyperedge target masks.
        - ``y`` is the concatenation of all input labels.

    Examples:
        >>> x = torch.randn(5, 8)
        >>> pos = HData(x=x, hyperedge_index=torch.tensor([[0, 1, 2, 3, 4], [0, 0, 1, 2, 2]]))
        >>> neg = HData(x=x, hyperedge_index=torch.tensor([[0, 2], [3, 3]]))
        >>> new = HData.cat_same_node_space([pos, neg])
        >>> new.num_nodes  # 5 — nodes [0, 1, 2, 3, 4]
        >>> new.num_hyperedges  # 4 — hyperedges [0, 1, 2, 3]

    Args:
        hdatas: One or more `HData` instances sharing the same node space.
        x: Optional node feature matrix to use for the resulting `HData`.
            If ``None``, the node features from the instance with the largest number of
            nodes will be used.
            If ``global_node_ids`` is provided explicitly, ``x`` must also be provided
            to ensure consistency. Defaults to ``None``.
        global_node_ids: Optional global node IDs for the resulting `HData`.
            If ``None``, the global node IDs from the instance with the largest number of
            nodes will be used. If ``x`` is provided explicitly, ``global_node_ids`` must
            also be provided to ensure consistency.
            If ``x`` is provided and there is no need for ``global_node_ids`` to preserve
            access to the canonical node space, it is recommended to use arbitrary global node
            IDs that are consistent with the feature rows of ``x``.
            For example, ``global_node_ids=torch.arange(x.size(0))``).
            Defaults to ``None``.

    Returns:
        hdata: A new `HData` with shared nodes and concatenated hyperedges.

    Raises:
        ValueError: If no HData instances are provided, if there are overlapping
            hyperedge IDs across instances,
            or if ``x`` and ``global_node_ids`` are not both provided when one of
            them is provided.
    """
    cls.__validate_can_perform_cat_same_node_space(hdatas, x, global_node_ids)

    hdata_with_largest_node_space = max(hdatas, key=lambda hdata: hdata.num_nodes)

    new_x = (x.clone() if x is not None else hdata_with_largest_node_space.x).clone()
    new_global_node_ids = (
        global_node_ids.clone()
        if global_node_ids is not None
        else hdata_with_largest_node_space.global_node_ids.clone()
    )
    new_target_node_mask = (
        hdata_with_largest_node_space.target_node_mask.clone()
        if hdata_with_largest_node_space.target_node_mask is not None
        else None
    )
    new_num_hyperedges = sum(hdata.num_hyperedges for hdata in hdatas)
    new_target_hyperedge_mask = (
        torch.cat([hdata.target_hyperedge_mask for hdata in hdatas], dim=0)
        if hdata_with_largest_node_space.is_hyperedge_related_task
        else None
    )
    new_y = (
        # For node-based tasks, we must preserve the labels for the entire node space
        hdata_with_largest_node_space.y.clone()
        if hdata_with_largest_node_space.is_node_related_task
        else torch.cat([hdata.y for hdata in hdatas], dim=0)
    )
    new_hyperedge_index = torch.cat([hdata.hyperedge_index for hdata in hdatas], dim=1)

    hyperedge_attrs = []
    hyperedge_weights = []
    have_all_hyperedge_attr = all(hdata.hyperedge_attr is not None for hdata in hdatas)
    have_all_hyperedge_weights = all(hdata.hyperedge_weights is not None for hdata in hdatas)
    for hdata in hdatas:
        if have_all_hyperedge_attr and hdata.hyperedge_attr is not None:
            hyperedge_attrs.append(hdata.hyperedge_attr)
        if have_all_hyperedge_weights and hdata.hyperedge_weights is not None:
            hyperedge_weights.append(hdata.hyperedge_weights)
    new_hyperedge_attr = torch.cat(hyperedge_attrs, dim=0) if len(hyperedge_attrs) > 0 else None
    new_hyperedge_weights = (
        torch.cat(hyperedge_weights, dim=0) if len(hyperedge_weights) > 0 else None
    )

    return cls(
        x=new_x,
        hyperedge_index=new_hyperedge_index,
        hyperedge_weights=new_hyperedge_weights,
        hyperedge_attr=new_hyperedge_attr,
        num_nodes=new_x.size(0),
        num_hyperedges=new_num_hyperedges,
        global_node_ids=new_global_node_ids,
        target_node_mask=new_target_node_mask,
        target_hyperedge_mask=new_target_hyperedge_mask,
        y=new_y,
        task=hdata_with_largest_node_space.task,
    )

add_negative_samples(negative_sampler, seed=None)

Return a new HData with sampled negative hyperedges added.

Parameters:

Name Type Description Default
negative_sampler NegativeSampler

Sampler used to generate negative hyperedges from this instance.

required
seed int | None

Optional random seed used for both negative sampling and the final shuffle. Defaults to None.

None

Returns:

Name Type Description
hdata HData

A new HData containing the original hyperedges and sampled negatives.

Source code in hypertorch/types/hdata.py
def add_negative_samples(
    self,
    negative_sampler: NegativeSampler,
    seed: int | None = None,
) -> HData:
    """
    Return a new `HData` with sampled negative hyperedges added.

    Args:
        negative_sampler: Sampler used to generate negative hyperedges from this instance.
        seed: Optional random seed used for both negative sampling and the final shuffle.
            Defaults to ``None``.

    Returns:
        hdata: A new `HData` containing the original hyperedges and sampled negatives.
    """
    neg_hdata = negative_sampler.sample(self, seed=seed)
    hdata_with_negatives = self.cat_same_node_space([self, neg_hdata])
    return hdata_with_negatives.shuffle(seed=seed)

empty(task=TaskEnum.HYPERLINK_PREDICTION) classmethod

Create an empty HData instance.

Parameters:

Name Type Description Default
task Task

Learning task for the empty HData. Defaults to "hyperlink-prediction".

HYPERLINK_PREDICTION

Returns:

Name Type Description
task HData

Learning task for the empty HData.

Returns:

Name Type Description
hdata HData

Empty HData.

Source code in hypertorch/types/hdata.py
@classmethod
def empty(cls, task: Task = TaskEnum.HYPERLINK_PREDICTION) -> HData:
    """
    Create an empty HData instance.

    Args:
        task: Learning task for the empty HData. Defaults to ``"hyperlink-prediction"``.

    Returns:
        task: Learning task for the empty HData.

    Returns:
        hdata: Empty HData.
    """
    return cls(
        x=empty_nodefeatures(),
        hyperedge_index=empty_hyperedgeindex(),
        hyperedge_weights=None,
        hyperedge_attr=None,
        num_nodes=0,
        num_hyperedges=0,
        global_node_ids=None,
        target_node_mask=None,
        target_hyperedge_mask=None,
        y=None,
        task=task,
    )

from_hyperedge_index(hyperedge_index, task=TaskEnum.HYPERLINK_PREDICTION) classmethod

Build an HData from a given hyperedge index, with empty node features and hyperedge attributes.

  • Node features are initialized as an empty tensor of shape [0, 0].
  • Hyperedge attributes are set to None.
  • Hyperedge weights are set to None.
  • The number of nodes and hyperedges are inferred from the hyperedge index.

Examples:

>>> hyperedge_index = [[0, 0, 1, 2, 3, 4],
...                    [0, 0, 0, 1, 2, 2]]
>>> num_nodes = 5
>>> num_hyperedges = 3
>>> x = []  # Empty node features with shape [0, 0]
>>> hyperedge_attr = None
>>> hyperedge_weights = None

Parameters:

Name Type Description Default
hyperedge_index Tensor

Tensor of shape [2, num_incidences] representing the hypergraph connectivity.

required
task Task

Learning task for the resulting HData. Defaults to "hyperlink-prediction".

HYPERLINK_PREDICTION

Returns:

Name Type Description
hdata HData

An HData instance with the given hyperedge index and default values for other attributes.

Source code in hypertorch/types/hdata.py
@classmethod
def from_hyperedge_index(
    cls,
    hyperedge_index: Tensor,
    task: Task = TaskEnum.HYPERLINK_PREDICTION,
) -> HData:
    """
    Build an `HData` from a given hyperedge index, with empty node features and
    hyperedge attributes.

    - Node features are initialized as an empty tensor of shape ``[0, 0]``.
    - Hyperedge attributes are set to ``None``.
    - Hyperedge weights are set to ``None``.
    - The number of nodes and hyperedges are inferred from the hyperedge index.

    Examples:
        >>> hyperedge_index = [[0, 0, 1, 2, 3, 4],
        ...                    [0, 0, 0, 1, 2, 2]]
        >>> num_nodes = 5
        >>> num_hyperedges = 3
        >>> x = []  # Empty node features with shape [0, 0]
        >>> hyperedge_attr = None
        >>> hyperedge_weights = None

    Args:
        hyperedge_index: Tensor of shape ``[2, num_incidences]`` representing
            the hypergraph connectivity.
        task: Learning task for the resulting `HData`. Defaults to ``"hyperlink-prediction"``.

    Returns:
        hdata: An `HData` instance with the given hyperedge index and default values
            for other attributes.
    """
    return cls(
        x=empty_nodefeatures(),
        hyperedge_index=hyperedge_index.clone(),
        hyperedge_weights=None,
        hyperedge_attr=None,
        global_node_ids=None,
        target_node_mask=None,
        target_hyperedge_mask=None,
        y=None,
        task=task,
    )

split(hdata, split_hyperedge_ids=None, node_space_setting='transductive', splitter=None) classmethod

Build an HData for a single split from the given hyperedge IDs.

Examples:

Transductive split (default) preserving the full node space:

>>> split_hdata = HData.split(
...    hdata,
...    torch.tensor([1]),
...    node_space_setting="transductive")
>>> split_hdata.x.shape[0] == hdata.x.shape[0]
>>> split_hdata.hyperedge_index
... # node IDs stay in the original row space, hyperedge IDs are rebased

Inductive split:

>>> split_hdata = HData.split(hdata, torch.tensor([1]), node_space_setting="inductive")
>>> split_hdata.x.shape[0]  # only nodes incident to hyperedge 1
... 2

Parameters:

Name Type Description Default
hdata HData

The original HData containing the full hypergraph.

required
split_hyperedge_ids Tensor | None

Tensor of hyperedge IDs to include in this split. It is assumed that the provided hyperedge IDs are valid and exist in hdata.hyperedge_index[1]. It is mandatory to provide this argument unless a custom splitter is provided that owns split materialization.

None
node_space_setting NodeSpaceSetting

Whether to preserve the full node space in the splits. transductive (default) ensures all node features are present in the split, while inductive allows splits to have disjoint node spaces.

'transductive'
splitter Splitter[HData, Any] | None

Optional HData splitter. When provided, it owns split materialization. Defaults to None.

None

Returns:

Name Type Description
hdata HData

The splitted instance with remapped node and hyperedge IDs.

Raises:

Type Description
ValueError

If node_space_setting is not "transductive" or "inductive".

Source code in hypertorch/types/hdata.py
@classmethod
def split(
    cls,
    hdata: HData,
    split_hyperedge_ids: Tensor | None = None,
    node_space_setting: NodeSpaceSetting = "transductive",
    splitter: Splitter[HData, Any] | None = None,
) -> HData:
    """
    Build an `HData` for a single split from the given hyperedge IDs.

    Examples:
        Transductive split (default) preserving the full node space:
        >>> split_hdata = HData.split(
        ...    hdata,
        ...    torch.tensor([1]),
        ...    node_space_setting="transductive")
        >>> split_hdata.x.shape[0] == hdata.x.shape[0]
        >>> split_hdata.hyperedge_index
        ... # node IDs stay in the original row space, hyperedge IDs are rebased

        Inductive split:
        >>> split_hdata = HData.split(hdata, torch.tensor([1]), node_space_setting="inductive")
        >>> split_hdata.x.shape[0]  # only nodes incident to hyperedge 1
        ... 2

    Args:
        hdata: The original `HData` containing the full hypergraph.
        split_hyperedge_ids: Tensor of hyperedge IDs to include in this split.
            It is assumed that the provided hyperedge IDs are valid and exist
            in ``hdata.hyperedge_index[1]``.
            It is mandatory to provide this argument unless a custom ``splitter`` is provided
            that owns split materialization.
        node_space_setting: Whether to preserve the full node space in the splits.
            ``transductive`` (default) ensures all node features are present in the split,
            while ``inductive`` allows splits to have disjoint node spaces.
        splitter: Optional HData splitter. When provided, it owns split materialization.
            Defaults to ``None``.

    Returns:
        hdata: The splitted instance with remapped node and hyperedge IDs.

    Raises:
        ValueError: If ``node_space_setting`` is not ``"transductive"`` or ``"inductive"``.
    """
    if splitter is not None:
        return splitter.split(to_split=hdata)

    if split_hyperedge_ids is None:
        raise ValueError(
            "'split_hyperedge_ids' must be provided when 'splitter' is not provided."
        )

    from hypertorch.data.splitter import HyperedgeHDataSplitter

    return HyperedgeHDataSplitter(node_space_setting=node_space_setting).split(
        to_split=hdata,
        split_hyperedge_ids=split_hyperedge_ids,
    )

enrich_node_features(enricher, enrichment_mode='replace')

Enrich node features using the provided node feature enricher.

Parameters:

Name Type Description Default
enricher NodeEnricher

An instance of NodeEnricher to generate structural node features from hypergraph topology.

required
enrichment_mode EnrichmentMode | None

How to combine generated features with existing hdata.x. concatenate appends new features as additional columns. replace substitutes hdata.x entirely. Defaults to replace if not provided.

'replace'
Source code in hypertorch/types/hdata.py
def enrich_node_features(
    self,
    enricher: NodeEnricher,
    enrichment_mode: EnrichmentMode | None = "replace",
) -> HData:
    """
    Enrich node features using the provided node feature enricher.

    Args:
        enricher: An instance of NodeEnricher to generate structural node features
            from hypergraph topology.
        enrichment_mode: How to combine generated features with existing ``hdata.x``.
            ``concatenate`` appends new features as additional columns.
            ``replace`` substitutes ``hdata.x`` entirely.
            Defaults to ``replace`` if not provided.
    """
    self.__validate_enrichment_mode(enrichment_mode)
    enriched_features = enricher.enrich(self.hyperedge_index)

    match enrichment_mode:
        case "concatenate":
            x = torch.cat([self.x, enriched_features], dim=1)
        case _:
            x = enriched_features

    return self.__class__(
        x=x,
        hyperedge_index=self.hyperedge_index.clone(),
        hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
        hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=self.target_node_mask.clone(),
        target_hyperedge_mask=self.target_hyperedge_mask.clone(),
        y=self.y.clone(),
        task=self.task,
    )

enrich_node_features_from(hdata_with_features, node_space_setting='transductive', fill_value=None)

Copy node features from another HData by aligning features by global_node_ids.

Examples:

Transductive enrichment (default) expecting the same node space in both source and target:

>>> target = target.enrich_node_features_from(source, node_space_setting="transductive")

Inductive with a scalar fill value:

>>> target = target.enrich_node_features_from(
...     source,
...     node_space_setting="inductive",
...     fill_value=0.0,
... )

Inductive with a feature vector fill value:

>>> target = target.enrich_node_features_from(
...     source,
...     node_space_setting="inductive",
...     fill_value=[0.0, 1.0, 0.0],
... )

Parameters:

Name Type Description Default
hdata_with_features HData

Source HData providing node features.

required
node_space_setting NodeSpaceSetting

The setting for the node space, determining how nodes are handled. If "transductive", every target node is expected to exist in the source. If "inductive", the target dataset may have a different node space, and missing nodes are filled using fill_value.

'transductive'
fill_value NodeSpaceFiller | None

Scalar or vector used to fill missing node features when node_space_setting is not transductive. Defaults to None.

None

Returns:

Name Type Description
hdata HData

A new HData with node features copied from hdata_with_features.

Raises:

Type Description
ValueError

If either instance lacks global_node_ids, if the source feature rows do not align with the source node IDs, if fill_value is used with node_space_setting="transductive", or if fill_value is missing or malformed when node_space_setting="inductive".

Source code in hypertorch/types/hdata.py
def enrich_node_features_from(
    self,
    hdata_with_features: HData,
    node_space_setting: NodeSpaceSetting = "transductive",
    fill_value: NodeSpaceFiller | None = None,
) -> HData:
    """
    Copy node features from another `HData` by aligning features by ``global_node_ids``.

    Examples:
        Transductive enrichment (default) expecting the same node space in both
        source and target:
        >>> target = target.enrich_node_features_from(source, node_space_setting="transductive")

        Inductive with a scalar fill value:
        >>> target = target.enrich_node_features_from(
        ...     source,
        ...     node_space_setting="inductive",
        ...     fill_value=0.0,
        ... )

        Inductive with a feature vector fill value:
        >>> target = target.enrich_node_features_from(
        ...     source,
        ...     node_space_setting="inductive",
        ...     fill_value=[0.0, 1.0, 0.0],
        ... )

    Args:
        hdata_with_features: Source `HData` providing node features.
        node_space_setting: The setting for the node space, determining how nodes are handled.
            If ``"transductive"``, every target node is expected to exist in the source.
            If ``"inductive"``, the target dataset may have a different node space, and missing
            nodes are filled using ``fill_value``.
        fill_value: Scalar or vector used to fill missing node features when
            ``node_space_setting`` is not transductive.  Defaults to ``None``.

    Returns:
        hdata: A new `HData` with node features copied from ``hdata_with_features``.

    Raises:
        ValueError: If either instance lacks ``global_node_ids``, if the source feature rows
            do not align with the source node IDs, if ``fill_value`` is used with
            ``node_space_setting="transductive"``, or if ``fill_value`` is missing or
            malformed when ``node_space_setting="inductive"``.
    """
    source_global_node_ids = hdata_with_features.global_node_ids
    source_x = hdata_with_features.x
    if source_x.size(0) != source_global_node_ids.size(0):
        raise ValueError(
            "Expected 'hdata_with_features.x' rows to align with "
            "hdata_with_features.global_node_ids."
        )
    self.__validate_node_space_setting(node_space_setting, fill_value)

    target_global_node_ids = self.global_node_ids.detach().cpu().tolist()

    # We need the index of the features for each node in the source, as we will use
    # the index to track back
    # to the node feautures after we match the global node id in the target to the one that
    # is in the source
    source_feature_idx_by_global_node_id = {
        int(global_node_id): feature_idx
        for feature_idx, global_node_id in enumerate(
            source_global_node_ids.detach().cpu().tolist()
        )
    }

    fill_features = self.__to_fill_features(
        fill_value=fill_value,
        num_features=int(source_x.size(1)),
        dtype=source_x.dtype,
        device=source_x.device,
    )

    enriched_rows = []
    missing_global_node_ids = []
    for global_node_id in target_global_node_ids:
        source_feature_idx = source_feature_idx_by_global_node_id.get(int(global_node_id))
        if source_feature_idx is None:
            # Example: global_node_id = 30 is not present in the source
            #          -> strict transductive mode records it as
            #             missing and then raises an error
            #          -> non-transductive mode fills the features with
            #             fill_value and continues enriching the other nodes
            if is_transductive_setting(node_space_setting):
                missing_global_node_ids.append(
                    int(global_node_id)
                )  # record missing node for error message
            else:
                enriched_rows.append(
                    fill_features
                )  # fill missing node features with fill_value and
            continue

        # Match the global node IDs in the target to the corresponding
        # feature indices in the source
        # Example: source_global_node_ids = [10, 20, 30], source_x has shape (3, num_features)
        #          target_global_node_ids = [10, 30]
        #          -> source_feature_idx_by_global_node_id = {10: 0, 20: 1, 30: 2}
        #          -> pick source_x rows 0 and 2 for the target
        enriched_rows.append(source_x[source_feature_idx])

    if len(missing_global_node_ids) > 0:
        raise ValueError(
            f"Missing node features for target global_node_ids: {missing_global_node_ids}."
        )

    enriched_x = torch.stack(enriched_rows, dim=0).to(device=self.device)

    return self.__class__(
        x=enriched_x,
        hyperedge_index=self.hyperedge_index.clone(),
        hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
        hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=self.target_node_mask.clone(),
        target_hyperedge_mask=self.target_hyperedge_mask.clone(),
        y=self.y.clone(),
        task=self.task,
    )

enrich_hyperedge_weights(enricher, enrichment_mode='replace')

Enrich hyperedge weights using the provided hyperedge weight enricher.

Parameters:

Name Type Description Default
enricher HyperedgeEnricher

An instance of HyperedgeEnricher to generate hyperedge weights from hypergraph topology.

required
enrichment_mode EnrichmentMode | None

How to combine generated weights with existing hdata.hyperedge_weights. concatenate appends new weights to the existing 1D tensor. replace substitutes hdata.hyperedge_weights entirely. Defaults to replace if not provided.

'replace'

Returns:

Name Type Description
hdata HData

A new HData with enriched hyperedge weights.

Source code in hypertorch/types/hdata.py
def enrich_hyperedge_weights(
    self,
    enricher: HyperedgeEnricher,
    enrichment_mode: EnrichmentMode | None = "replace",
) -> HData:
    """
    Enrich hyperedge weights using the provided hyperedge weight enricher.

    Args:
        enricher: An instance of HyperedgeEnricher to generate hyperedge weights from
            hypergraph topology.
        enrichment_mode: How to combine generated weights with
            existing ``hdata.hyperedge_weights``.
            ``concatenate`` appends new weights to the existing 1D tensor.
            ``replace`` substitutes ``hdata.hyperedge_weights`` entirely.
            Defaults to ``replace`` if not provided.

    Returns:
        hdata: A new `HData` with enriched hyperedge weights.
    """
    self.__validate_enrichment_mode(enrichment_mode)
    enriched_weights = enricher.enrich(self.hyperedge_index)

    match enrichment_mode:
        case "concatenate":
            hyperedge_weights = (
                torch.cat([self.hyperedge_weights, enriched_weights], dim=0)
                if self.hyperedge_weights is not None
                else enriched_weights
            )
        case _:
            hyperedge_weights = enriched_weights

    return self.__class__(
        x=self.x.clone(),
        hyperedge_index=self.hyperedge_index.clone(),
        hyperedge_weights=hyperedge_weights,
        hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=self.target_node_mask.clone(),
        target_hyperedge_mask=self.target_hyperedge_mask.clone(),
        y=self.y.clone(),
        task=self.task,
    )

enrich_hyperedge_attr(enricher, enrichment_mode='replace')

Enrich hyperedge features using the provided hyperedge feature enricher.

Parameters:

Name Type Description Default
enricher HyperedgeEnricher

An instance of HyperedgeEnricher to generate structural hyperedge features from hypergraph topology.

required
enrichment_mode EnrichmentMode | None

How to combine generated features with existing hdata.hyperedge_attr. concatenate appends new features as additional columns. replace substitutes hdata.hyperedge_attr entirely. Defaults to replace if not provided.

'replace'
Source code in hypertorch/types/hdata.py
def enrich_hyperedge_attr(
    self,
    enricher: HyperedgeEnricher,
    enrichment_mode: EnrichmentMode | None = "replace",
) -> HData:
    """
    Enrich hyperedge features using the provided hyperedge feature enricher.

    Args:
        enricher: An instance of HyperedgeEnricher to generate structural hyperedge
            features from hypergraph topology.
        enrichment_mode: How to combine generated features with
            existing ``hdata.hyperedge_attr``.
            ``concatenate`` appends new features as additional columns.
            ``replace`` substitutes ``hdata.hyperedge_attr`` entirely.
            Defaults to ``replace`` if not provided.
    """
    self.__validate_enrichment_mode(enrichment_mode)
    enriched_features = enricher.enrich(self.hyperedge_index)

    match enrichment_mode:
        case "concatenate":
            hyperedge_attr = (
                torch.cat([self.hyperedge_attr, enriched_features], dim=1)
                if self.hyperedge_attr is not None
                else enriched_features
            )
        case _:
            hyperedge_attr = enriched_features

    return self.__class__(
        x=self.x.clone(),
        hyperedge_index=self.hyperedge_index.clone(),
        hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
        hyperedge_attr=hyperedge_attr,
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=self.target_node_mask.clone(),
        target_hyperedge_mask=self.target_hyperedge_mask.clone(),
        y=self.y.clone(),
        task=self.task,
    )

get_device_if_all_consistent()

Check that all tensors are on the same device and return that device.

If there are no tensors or if they are on different devices, return CPU.

Returns:

Name Type Description
device device

The common device if all tensors are on the same device, otherwise CPU.

Raises:

Type Description
ValueError

If tensors are on different devices.

Source code in hypertorch/types/hdata.py
def get_device_if_all_consistent(self) -> torch.device:
    """
    Check that all tensors are on the same device and return that device.

    If there are no tensors or if they are on different devices, return CPU.

    Returns:
        device: The common device if all tensors are on the same device, otherwise CPU.

    Raises:
        ValueError: If tensors are on different devices.
    """
    devices = {
        self.x.device,
        self.hyperedge_index.device,
        self.global_node_ids.device,
        self.target_node_mask.device,
        self.target_hyperedge_mask.device,
        self.y.device,
    }

    if self.hyperedge_attr is not None:
        devices.add(self.hyperedge_attr.device)
    if self.hyperedge_weights is not None:
        devices.add(self.hyperedge_weights.device)

    if len(devices) > 1:
        raise ValueError(f"Inconsistent device placement: {devices}")

    return devices.pop() if len(devices) == 1 else torch.device("cpu")

remove_hyperedges_with_fewer_than_k_nodes(k, preserve_global_node_ids=False)

Remove hyperedges that have fewer than k incident nodes.

Parameters:

Name Type Description Default
k int

The minimum number of nodes a hyperedge must have to be retained.

required
preserve_global_node_ids bool

Whether to preserve the global node IDs after removing hyperedges. Defaults to False. If False, the global node IDs will be reindexed to be contiguous after removing hyperedges. If True, the global node IDs will be preserved, which may cause some models to raise as they may expect contiguous global node IDs.

False
Source code in hypertorch/types/hdata.py
def remove_hyperedges_with_fewer_than_k_nodes(
    self,
    k: int,
    preserve_global_node_ids: bool = False,
) -> HData:
    """
    Remove hyperedges that have fewer than k incident nodes.

    Args:
        k: The minimum number of nodes a hyperedge must have to be retained.
        preserve_global_node_ids: Whether to preserve the global node IDs after
            removing hyperedges. Defaults to ``False``.
            If ``False``, the global node IDs will be reindexed to be contiguous after
            removing hyperedges.
            If ``True``, the global node IDs will be preserved, which may cause some models
            to raise as they may expect contiguous global node IDs.
    """
    validate_is_positive("k", k)

    hyperedge_index_wrapper = HyperedgeIndex(
        self.hyperedge_index.clone()
    ).remove_hyperedges_with_fewer_than_k_nodes(k)

    x = self.x[hyperedge_index_wrapper.node_ids]
    y = (
        self.y[hyperedge_index_wrapper.node_ids]
        if self.is_node_related_task
        else self.y[hyperedge_index_wrapper.hyperedge_ids]
    )
    target_node_mask = self.target_node_mask[hyperedge_index_wrapper.node_ids]
    target_hyperedge_mask = self.target_hyperedge_mask[hyperedge_index_wrapper.hyperedge_ids]

    global_node_ids = (
        self.global_node_ids[hyperedge_index_wrapper.node_ids]
        if preserve_global_node_ids
        else None
    )
    hyperedge_attr = (
        self.hyperedge_attr[hyperedge_index_wrapper.hyperedge_ids]
        if self.hyperedge_attr is not None
        else None
    )
    hyperedge_weights = (
        self.hyperedge_weights[hyperedge_index_wrapper.hyperedge_ids]
        if self.hyperedge_weights is not None
        else None
    )

    return self.__class__(
        x=x,
        hyperedge_index=hyperedge_index_wrapper.to_0based().item,
        hyperedge_weights=hyperedge_weights,
        hyperedge_attr=hyperedge_attr,
        num_nodes=hyperedge_index_wrapper.num_nodes,
        num_hyperedges=hyperedge_index_wrapper.num_hyperedges,
        global_node_ids=global_node_ids,
        target_node_mask=target_node_mask,
        target_hyperedge_mask=target_hyperedge_mask,
        y=y,
        task=self.task,
    )

shuffle(seed=None)

Return a new HData instance with hyperedge IDs randomly reassigned.

Each hyperedge keeps its original set of nodes, but is assigned a new ID via a random permutation. y and hyperedge_attr are reordered to match, so that y[new_id] still corresponds to the correct hyperedge. Same for hyperedge_attr[new_id] if hyperedge attributes are present.

Examples:

>>> hyperedge_index = torch.tensor([[0, 1, 2, 3], [0, 0, 1, 1]])
>>> y  = torch.tensor([1, 0])
>>> hdata = HData(x=x, hyperedge_index=hyperedge_index, y=y)
>>> shuffled_hdata = hdata.shuffle(seed=42)
>>> shuffled_hdata.hyperedge_index  # hyperedges may be reassigned
... # e.g.,
...     [[0, 1, 2, 3],
...      [1, 1, 0, 0]]
>>> shuffled_hdata.y  # labels are permuted to match new hyperedge IDs, e.g., [0, 1]

Parameters:

Name Type Description Default
seed int | None

Optional random seed for reproducibility. If None, the shuffle will be non-deterministic. Defaults to None.

None

Returns:

Name Type Description
hdata HData

A new HData instance with hyperedge IDs, y, and hyperedge_attr permuted.

Source code in hypertorch/types/hdata.py
def shuffle(self, seed: int | None = None) -> HData:
    """
    Return a new `HData` instance with hyperedge IDs randomly reassigned.

    Each hyperedge keeps its original set of nodes, but is assigned a new ID
    via a random permutation.
    ``y`` and ``hyperedge_attr`` are reordered to match, so that ``y[new_id]``
    still corresponds to the correct hyperedge.
    Same for ``hyperedge_attr[new_id]`` if hyperedge attributes are present.

    Examples:
        >>> hyperedge_index = torch.tensor([[0, 1, 2, 3], [0, 0, 1, 1]])
        >>> y  = torch.tensor([1, 0])
        >>> hdata = HData(x=x, hyperedge_index=hyperedge_index, y=y)
        >>> shuffled_hdata = hdata.shuffle(seed=42)
        >>> shuffled_hdata.hyperedge_index  # hyperedges may be reassigned
        ... # e.g.,
        ...     [[0, 1, 2, 3],
        ...      [1, 1, 0, 0]]
        >>> shuffled_hdata.y  # labels are permuted to match new hyperedge IDs, e.g., [0, 1]

    Args:
        seed: Optional random seed for reproducibility. If ``None``, the shuffle
            will be non-deterministic. Defaults to ``None``.

    Returns:
        hdata: A new `HData` instance with hyperedge IDs, ``y``, and
            ``hyperedge_attr`` permuted.
    """
    generator = create_seeded_torch_generator(device=self.device, seed=seed)
    permutation = torch.randperm(
        self.num_hyperedges,
        generator=generator,
        dtype=torch.long,
        device=self.device,
    )

    # permutation[new_id] = old_id, so y[permutation] puts old labels into new slots
    # inverse_permutation[old_id] = new_id, used to remap hyperedge IDs in incidences
    # Example: permutation = [1, 2, 0] means new_id 0 gets old_id 1,
    #                   new_id 1 gets old_id 2, new_id 2 gets old_id 0
    #                   -> inverse_permutation = [2, 0, 1] means old_id 0 gets new_id 2,
    #                        old_id 1 gets new_id 0, old_id 2 gets new_id 1
    inverse_permutation = torch.empty_like(
        permutation,
        dtype=permutation.dtype,
        device=permutation.device,
    )
    inverse_permutation[permutation] = torch.arange(
        self.num_hyperedges,
        dtype=permutation.dtype,
        device=permutation.device,
    )

    new_hyperedge_index = self.hyperedge_index.clone()

    # Example: hyperedge_index = [[0, 1, 2, 3, 4],
    #                             [0, 0, 1, 1, 2]],
    #          inverse_permutation = [2, 0, 1] (new_id 0 -> old_id 2, new_id 1 ->
    #                                           old_id 0, new_id 2 -> old_id 1)
    #          -> new_hyperedge_index = [[0, 1, 2, 3, 4],
    #                                    [2, 2, 0, 0, 1]]
    old_hyperedge_ids = self.hyperedge_index[1]
    new_hyperedge_index[1] = inverse_permutation[old_hyperedge_ids]

    # Example: hyperedge_attr = [attr_0, attr_1, attr_2], permutation = [1, 2, 0]
    #          -> new_hyperedge_attr = [attr_1  (attr of old_id 1),
    #                                   attr_2 (attr of old_id 2),
    #                                   attr_0 (attr of old_id 0)]
    new_hyperedge_attr = (
        self.hyperedge_attr[permutation] if self.hyperedge_attr is not None else None
    )

    new_hyperedge_weights = (
        self.hyperedge_weights[permutation] if self.hyperedge_weights is not None else None
    )
    new_target_hyperedge_mask = self.target_hyperedge_mask[permutation]

    # Permutate only for tasks where y is related to hyperedges (e.g., hyperlink-prediction)
    # Example: y = [1, 1, 0], permutation = [1, 2, 0]
    #          -> new_y = [y[1], y[2], y[0]] = [1, 0, 1]
    new_y = self.y[permutation] if self.is_hyperedge_related_task else self.y.clone()

    return self.__class__(
        x=self.x.clone(),
        hyperedge_index=new_hyperedge_index,
        hyperedge_weights=new_hyperedge_weights,
        hyperedge_attr=new_hyperedge_attr,
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=self.target_node_mask.clone(),
        target_hyperedge_mask=new_target_hyperedge_mask,
        y=new_y,
        task=self.task,
    )

clone()

Return a deep copy of this HData.

Returns:

Name Type Description
hdata HData

A new HData that is a deep copy of this instance.

Source code in hypertorch/types/hdata.py
def clone(self) -> HData:
    """
    Return a deep copy of this `HData`.

    Returns:
        hdata: A new `HData` that is a deep copy of this instance.
    """
    return self.__class__(
        x=self.x.clone(),
        hyperedge_index=self.hyperedge_index.clone(),
        hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
        hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=self.target_node_mask.clone(),
        target_hyperedge_mask=self.target_hyperedge_mask.clone(),
        y=self.y.clone(),
        task=self.task,
    )

to(device, non_blocking=False)

Move all tensors to the specified device.

Parameters:

Name Type Description Default
device device | str

The target device (e.g., 'cpu', 'cuda:0').

required
non_blocking bool

If True and the source and destination devices are both CUDA, the copy will be non-blocking. Defaults to False.

False

Returns:

Name Type Description
hdata HData

The HData instance with all tensors moved to the specified device.

Source code in hypertorch/types/hdata.py
def to(self, device: torch.device | str, non_blocking: bool = False) -> HData:
    """
    Move all tensors to the specified device.

    Args:
        device: The target device (e.g., 'cpu', 'cuda:0').
        non_blocking: If ``True`` and the source and destination devices are both CUDA,
            the copy will be non-blocking. Defaults to ``False``.

    Returns:
        hdata: The `HData` instance with all tensors moved to the specified device.
    """
    self.x = self.x.to(device=device, non_blocking=non_blocking)
    self.hyperedge_index = self.hyperedge_index.to(device=device, non_blocking=non_blocking)
    self.global_node_ids = self.global_node_ids.to(device=device, non_blocking=non_blocking)
    self.target_node_mask = self.target_node_mask.to(device=device, non_blocking=non_blocking)
    self.target_hyperedge_mask = self.target_hyperedge_mask.to(
        device=device,
        non_blocking=non_blocking,
    )
    self.y = self.y.to(device=device, non_blocking=non_blocking)

    if self.hyperedge_attr is not None:
        self.hyperedge_attr = self.hyperedge_attr.to(device=device, non_blocking=non_blocking)

    if self.hyperedge_weights is not None:
        self.hyperedge_weights = self.hyperedge_weights.to(
            device=device,
            non_blocking=non_blocking,
        )

    self.device = device if isinstance(device, torch.device) else torch.device(device)
    return self

with_target_node_mask(target_node_mask)

Return a copy of this instance with a target_node_mask attribute set to the given mask.

Parameters:

Name Type Description Default
target_node_mask Tensor

A boolean tensor indicating which nodes are considered target nodes.

required

Returns:

Name Type Description
hdata HData

A new HData instance with the same attributes except for target_node_mask, which is set to the provided mask.

Source code in hypertorch/types/hdata.py
def with_target_node_mask(self, target_node_mask: Tensor) -> HData:
    """
    Return a copy of this instance with a ``target_node_mask`` attribute set to the given mask.

    Args:
        target_node_mask: A boolean tensor indicating which nodes are considered target nodes.

    Returns:
        hdata: A new `HData` instance with the same attributes except for ``target_node_mask``,
            which is set to the provided mask.
    """
    return self.__class__(
        x=self.x.clone(),
        hyperedge_index=self.hyperedge_index.clone(),
        hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
        hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=target_node_mask.clone(),
        target_hyperedge_mask=self.target_hyperedge_mask.clone(),
        y=self.y.clone(),
        task=self.task,
    )

with_target_hyperedge_mask(target_hyperedge_mask)

Return a copy of this instance with target_hyperedge_mask set to the given mask.

Parameters:

Name Type Description Default
target_hyperedge_mask Tensor

Boolean tensor indicating target hyperedges.

required

Returns:

Name Type Description
hdata HData

A new HData instance with the same attributes except for target_hyperedge_mask.

Source code in hypertorch/types/hdata.py
def with_target_hyperedge_mask(self, target_hyperedge_mask: Tensor) -> HData:
    """
    Return a copy of this instance with ``target_hyperedge_mask`` set to the given mask.

    Args:
        target_hyperedge_mask: Boolean tensor indicating target hyperedges.

    Returns:
        hdata: A new `HData` instance with the same attributes except for
            ``target_hyperedge_mask``.
    """
    return self.__class__(
        x=self.x.clone(),
        hyperedge_index=self.hyperedge_index.clone(),
        hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
        hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=self.target_node_mask.clone(),
        target_hyperedge_mask=target_hyperedge_mask.clone(),
        y=self.y.clone(),
        task=self.task,
    )

with_y_to(value, size=None)

Return a copy of this instance with a y attribute set to the given value.

Parameters:

Name Type Description Default
value float

The value to set for all entries in the y attribute.

required
size int | None

The size of the y tensor. If None, the size will be inferred from the number of hyperedges in the instance.

None

Returns:

Name Type Description
hdata HData

A new HData instance with the same attributes except for y, which is set to a tensor of the given value.

Source code in hypertorch/types/hdata.py
def with_y_to(self, value: float, size: int | None = None) -> HData:
    """
    Return a copy of this instance with a y attribute set to the given value.

    Args:
        value: The value to set for all entries in the y attribute.
        size: The size of the y tensor. If ``None``, the size will be inferred
            from the number of hyperedges in the instance.

    Returns:
        hdata: A new `HData` instance with the same attributes except for y,
            which is set to a tensor of the given value.
    """
    y_size = size if size is not None else self.num_hyperedges
    return self.__class__(
        x=self.x.clone(),
        hyperedge_index=self.hyperedge_index.clone(),
        hyperedge_weights=clone_optional_tensor(self.hyperedge_weights),
        hyperedge_attr=clone_optional_tensor(self.hyperedge_attr),
        num_nodes=self.num_nodes,
        num_hyperedges=self.num_hyperedges,
        global_node_ids=self.global_node_ids.clone(),
        target_node_mask=self.target_node_mask.clone(),
        target_hyperedge_mask=self.target_hyperedge_mask.clone(),
        y=torch.full((y_size,), value, dtype=torch.float, device=self.device),
        task=self.task,
    )

with_y_ones(size=None)

Return a copy of this instance with a y attribute of all ones.

Parameters:

Name Type Description Default
size int | None

The size of the y tensor. If None, the size will be inferred from the number of hyperedges in the instance.

None

Returns:

Name Type Description
hdata HData

A new HData instance with the same attributes except for y, which is set to a tensor of ones.

Source code in hypertorch/types/hdata.py
def with_y_ones(self, size: int | None = None) -> HData:
    """
    Return a copy of this instance with a y attribute of all ones.

    Args:
        size: The size of the y tensor. If ``None``, the size will be inferred
            from the number of hyperedges in the instance.

    Returns:
        hdata: A new `HData` instance with the same attributes except for y, which is
            set to a tensor of ones.
    """
    return self.with_y_to(1.0, size=size)

with_y_zeros(size=None)

Return a copy of this instance with a y attribute of all zeros.

Parameters:

Name Type Description Default
size int | None

The size of the y tensor. If None, the size will be inferred from the number of hyperedges in the instance.

None

Returns:

Name Type Description
hdata HData

A new HData instance with the same attributes except for y, which is set to a tensor of zeros.

Source code in hypertorch/types/hdata.py
def with_y_zeros(self, size: int | None = None) -> HData:
    """
    Return a copy of this instance with a y attribute of all zeros.

    Args:
        size: The size of the y tensor. If ``None``, the size will be inferred
            from the number of hyperedges in the instance.

    Returns:
        hdata: A new `HData` instance with the same attributes except for y, which is
            set to a tensor of zeros.
    """
    return self.with_y_to(0.0, size=size)

stats()

Compute statistics for the hypergraph data.

Fields
  • shape_x: The shape of the node feature matrix x.
  • shape_hyperedge_weights: The shape of the hyperedge weights tensor, or None if hyperedge weights are not present.
  • shape_hyperedge_attr: The shape of the hyperedge attribute matrix, or None if hyperedge attributes are not present.
  • num_nodes: The number of nodes in the hypergraph.
  • num_hyperedges: The number of hyperedges in the hypergraph.
  • avg_degree_node_raw: The average degree of nodes, calculated as the mean number of hyperedges each node belongs to.
  • avg_degree_node: The floored node average degree.
  • avg_degree_hyperedge_raw: The average size of hyperedges, calculated as the mean number of nodes each hyperedge contains.
  • avg_degree_hyperedge: The floored hyperedge average size.
  • node_degree_max: The maximum degree of any node in the hypergraph.
  • hyperedge_degree_max: The maximum size of any hyperedge in the hypergraph.
  • node_degree_median: The median degree of nodes in the hypergraph.
  • hyperedge_degree_median: The median size of hyperedges in the hypergraph.
  • distribution_node_degree: A list where the value at index i represents the count of nodes with degree i.
  • distribution_hyperedge_size: A list where the value at index i represents the count of hyperedges with size i.
  • distribution_node_degree_hist: A dictionary where the keys are node degrees and the values are the count of nodes with that degree.
  • distribution_hyperedge_size_hist: A dictionary where the keys are hyperedge sizes and the values are the count of hyperedges with that size.

Returns:

Name Type Description
stats dict[str, Any]

A dictionary containing various statistics about the hypergraph.

Source code in hypertorch/types/hdata.py
def stats(self) -> dict[str, Any]:
    """
    Compute statistics for the hypergraph data.

    Fields:
        - ``shape_x``: The shape of the node feature matrix ``x``.
        - ``shape_hyperedge_weights``: The shape of the hyperedge weights tensor, or
            ``None`` if hyperedge weights are not present.
        - ``shape_hyperedge_attr``: The shape of the hyperedge attribute matrix, or ``None``
            if hyperedge attributes are not present.
        - ``num_nodes``: The number of nodes in the hypergraph.
        - ``num_hyperedges``: The number of hyperedges in the hypergraph.
        - ``avg_degree_node_raw``: The average degree of nodes, calculated as the mean
            number of hyperedges each node belongs to.
        - ``avg_degree_node``: The floored node average degree.
        - ``avg_degree_hyperedge_raw``: The average size of hyperedges, calculated as
            the mean number of nodes each hyperedge contains.
        - ``avg_degree_hyperedge``: The floored hyperedge average size.
        - ``node_degree_max``: The maximum degree of any node in the hypergraph.
        - ``hyperedge_degree_max``: The maximum size of any hyperedge in the hypergraph.
        - ``node_degree_median``: The median degree of nodes in the hypergraph.
        - ``hyperedge_degree_median``: The median size of hyperedges in the hypergraph.
        - ``distribution_node_degree``: A list where the value at index ``i`` represents
            the count of nodes with degree ``i``.
        - ``distribution_hyperedge_size``: A list where the value at index ``i`` represents
            the count of hyperedges with size ``i``.
        - ``distribution_node_degree_hist``: A dictionary where the keys are node degrees and
            the values are the count of nodes with that degree.
        - ``distribution_hyperedge_size_hist``: A dictionary where the keys are hyperedge
            sizes and the values are the count of hyperedges with that size.

    Returns:
        stats: A dictionary containing various statistics about the hypergraph.
    """
    node_ids = self.hyperedge_index[0]
    hyperedge_ids = self.hyperedge_index[1]

    # Degree of each node = number of hyperedges it belongs to
    # Size of each hyperedge = number of nodes it contains
    if node_ids.numel() > 0:
        distribution_node_degree = torch.bincount(node_ids, minlength=self.num_nodes).float()
        distribution_hyperedge_size = torch.bincount(
            hyperedge_ids, minlength=self.num_hyperedges
        ).float()
    else:
        distribution_node_degree = torch.zeros(
            self.num_nodes, dtype=torch.float, device=self.device
        )
        distribution_hyperedge_size = torch.zeros(
            self.num_hyperedges, dtype=torch.float, device=self.device
        )

    if distribution_node_degree.numel() > 0:
        avg_degree_node_raw = distribution_node_degree.mean(dtype=torch.float).item()
        avg_degree_node = int(avg_degree_node_raw)
        avg_degree_hyperedge_raw = distribution_hyperedge_size.mean(dtype=torch.float).item()
        avg_degree_hyperedge = int(avg_degree_hyperedge_raw)
        node_degree_max = int(distribution_node_degree.max().item())
        hyperedge_degree_max = int(distribution_hyperedge_size.max().item())
        node_degree_median = int(distribution_node_degree.median().item())
        hyperedge_degree_median = int(distribution_hyperedge_size.median().item())
    else:
        avg_degree_node_raw = 0
        avg_degree_node = 0
        avg_degree_hyperedge_raw = 0
        avg_degree_hyperedge = 0
        node_degree_max = 0
        hyperedge_degree_max = 0
        node_degree_median = 0
        hyperedge_degree_median = 0

    # Histograms: index i holds count of nodes/hyperedges with degree/size i
    distribution_node_degree_hist = torch.bincount(distribution_node_degree.long())
    distribution_hyperedge_size_hist = torch.bincount(distribution_hyperedge_size.long())

    distribution_node_degree_hist = {
        i: int(count.item())
        for i, count in enumerate(distribution_node_degree_hist)
        if count.item() > 0
    }
    distribution_hyperedge_size_hist = {
        i: int(count.item())
        for i, count in enumerate(distribution_hyperedge_size_hist)
        if count.item() > 0
    }

    return {
        "shape_x": self.x.shape,
        "shape_hyperedge_weights": self.hyperedge_weights.shape
        if self.hyperedge_weights is not None
        else None,
        "shape_hyperedge_attr": self.hyperedge_attr.shape
        if self.hyperedge_attr is not None
        else None,
        "num_nodes": self.num_nodes,
        "num_hyperedges": self.num_hyperedges,
        "avg_degree_node_raw": avg_degree_node_raw,
        "avg_degree_node": avg_degree_node,
        "avg_degree_hyperedge_raw": avg_degree_hyperedge_raw,
        "avg_degree_hyperedge": avg_degree_hyperedge,
        "node_degree_max": node_degree_max,
        "hyperedge_degree_max": hyperedge_degree_max,
        "node_degree_median": node_degree_median,
        "hyperedge_degree_median": hyperedge_degree_median,
        "distribution_node_degree": distribution_node_degree.int().tolist(),
        "distribution_hyperedge_size": distribution_hyperedge_size.int().tolist(),
        "distribution_node_degree_hist": distribution_node_degree_hist,
        "distribution_hyperedge_size_hist": distribution_hyperedge_size_hist,
    }

__validate_can_perform_cat_same_node_space(hdatas, x, global_node_ids) classmethod

Validate inputs for concatenating HData objects in the same node space.

Parameters:

Name Type Description Default
hdatas Sequence[HData]

HData objects to concatenate.

required
x Tensor | None

Optional shared node feature matrix.

required
global_node_ids Tensor | None

Optional shared global node IDs.

required

Raises:

Type Description
ValueError

If required paired arguments are missing or hyperedge IDs overlap.

Source code in hypertorch/types/hdata.py
@classmethod
def __validate_can_perform_cat_same_node_space(
    cls,
    hdatas: Sequence[HData],
    x: Tensor | None,
    global_node_ids: Tensor | None,
) -> None:
    """
    Validate inputs for concatenating HData objects in the same node space.

    Args:
        hdatas: HData objects to concatenate.
        x: Optional shared node feature matrix.
        global_node_ids: Optional shared global node IDs.

    Raises:
        ValueError: If required paired arguments are missing or hyperedge IDs overlap.
    """
    validate_is_non_empty("hdatas", hdatas)

    if x is not None and global_node_ids is None:
        raise ValueError(
            "If 'x' is provided, 'global_node_ids' must also be provided to ensure consistency."
        )
    if x is None and global_node_ids is not None:
        raise ValueError(
            "If 'global_node_ids' is provided, 'x' must also be provided to ensure consistency."
        )

    joint_hyperedge_ids = torch.cat([hdata.hyperedge_index[1].unique() for hdata in hdatas])
    unique_joint_hyperedge_ids = joint_hyperedge_ids.unique()
    if unique_joint_hyperedge_ids.size(0) != joint_hyperedge_ids.size(0):
        raise ValueError(
            "Overlapping hyperedge IDs found across instances. Ensure each "
            "instance uses distinct hyperedge IDs."
        )

    tasks = {hdata.task for hdata in hdatas}
    if len(tasks) > 1:
        raise ValueError(f"All HData instances must have the same task, got {tasks}.")

__to_fill_features(fill_value, num_features, dtype, device)

Convert a fill value into a feature vector.

Parameters:

Name Type Description Default
fill_value NodeSpaceFiller | None

Scalar or vector fill value.

required
num_features int

Required number of feature values.

required
dtype dtype

Desired tensor dtype.

required
device device

Desired tensor device.

required

Returns:

Name Type Description
fill_features Tensor

Tensor of shape (num_features,) or an empty tensor.

Raises:

Type Description
ValueError

If the fill value cannot be broadcast to the requested feature count.

Source code in hypertorch/types/hdata.py
def __to_fill_features(
    self,
    fill_value: NodeSpaceFiller | None,
    num_features: int,
    dtype: torch.dtype,
    device: torch.device,
) -> Tensor:
    """
    Convert a fill value into a feature vector.

    Args:
        fill_value: Scalar or vector fill value.
        num_features: Required number of feature values.
        dtype: Desired tensor dtype.
        device: Desired tensor device.

    Returns:
        fill_features: Tensor of shape ``(num_features,)`` or an empty tensor.

    Raises:
        ValueError: If the fill value cannot be broadcast to the requested feature count.
    """
    if fill_value is None:
        return torch.empty((0,), dtype=dtype, device=device)

    if isinstance(fill_value, Tensor):
        fill_features = fill_value.to(dtype=dtype, device=device)
    elif isinstance(fill_value, (int, float)):
        fill_features = torch.full(
            (num_features,), float(fill_value), dtype=dtype, device=device
        )
    else:
        fill_features = torch.tensor(fill_value, dtype=dtype, device=device)

    # This can happen when fill_value is:
    # - A scalar tensor, e.g., tensor(0.0), which should be broadcasted to all features
    # - A list with a single value, e.g., [0.0], which should
    #   also be broadcasted to all features
    if fill_features.numel() == 1:
        fill_features = fill_features.repeat(num_features)

    if fill_features.dim() != 1 or fill_features.numel() != num_features:
        raise ValueError(
            f"Expected 'fill_value' to define exactly {num_features} features, got shape "
            f"{tuple(fill_features.shape)}."
        )
    return fill_features

__validate()

Validate all HData tensor fields.

Raises:

Type Description
ValueError

If any field has an invalid shape, dtype, or count.

Source code in hypertorch/types/hdata.py
def __validate(self) -> None:
    """
    Validate all HData tensor fields.

    Raises:
        ValueError: If any field has an invalid shape, dtype, or count.
    """
    self.__validate_x()
    self.__validate_hyperedge_index()
    self.__validate_hyperedge_attr()
    self.__validate_hyperedge_weights()
    self.__validate_global_node_ids()
    self.__validate_target_node_mask()
    self.__validate_target_hyperedge_mask()
    self.__validate_labels()
    self.__validate_task()

__validate_enrichment_mode(enrichment_mode)

Validate a feature enrichment mode.

Parameters:

Name Type Description Default
enrichment_mode EnrichmentMode | None

Optional enrichment mode to validate.

required

Raises:

Type Description
ValueError

If the mode is unsupported.

Source code in hypertorch/types/hdata.py
def __validate_enrichment_mode(self, enrichment_mode: EnrichmentMode | None) -> None:
    """
    Validate a feature enrichment mode.

    Args:
        enrichment_mode: Optional enrichment mode to validate.

    Raises:
        ValueError: If the mode is unsupported.
    """
    if enrichment_mode is None or enrichment_mode in ("replace", "concatenate"):
        return

    raise ValueError(
        f"'enrichment_mode' must be one of 'replace', 'concatenate', "
        f"or None, got {enrichment_mode!r}."
    )

__validate_hyperedge_attr()

Validate optional hyperedge attributes.

Raises:

Type Description
ValueError

If hyperedge attributes have an invalid dtype or shape.

Source code in hypertorch/types/hdata.py
def __validate_hyperedge_attr(self) -> None:
    """
    Validate optional hyperedge attributes.

    Raises:
        ValueError: If hyperedge attributes have an invalid dtype or shape.
    """
    if self.hyperedge_attr is None:
        return

    validate_floating_tensor_dtype("hyperedge_attr", self.hyperedge_attr)
    if self.hyperedge_attr.dim() != 2:
        raise ValueError(
            f"'hyperedge_attr' must be a 2D tensor, got shape "
            f"{tuple(self.hyperedge_attr.shape)}."
        )
    if self.hyperedge_attr.size(0) != self.num_hyperedges:
        raise ValueError(
            "'hyperedge_attr' must have one row per hyperedge. "
            f"Got size={self.hyperedge_attr.size(0)} but "
            f"num_hyperedges={self.num_hyperedges}."
        )

__validate_hyperedge_index()

Validate hyperedge index IDs against configured node and hyperedge counts.

Raises:

Type Description
ValueError

If IDs are negative or counts are too small.

Source code in hypertorch/types/hdata.py
def __validate_hyperedge_index(self) -> None:
    """
    Validate hyperedge index IDs against configured node and hyperedge counts.

    Raises:
        ValueError: If IDs are negative or counts are too small.
    """
    if self.hyperedge_index.numel() > 0 and bool((self.hyperedge_index < 0).any()):
        raise ValueError("'hyperedge_index' cannot contain negative node or hyperedge IDs.")

    unique_node_count = self.hyperedge_index[0].unique().size(0)
    if unique_node_count > self.num_nodes:
        raise ValueError(
            f"'num_nodes' is too small for 'hyperedge_index'. "
            f"Got num_nodes={self.num_nodes}, but 'hyperedge_index' contains "
            f"{unique_node_count} unique node IDs."
        )

    unique_hyperedge_count = self.hyperedge_index[1].unique().size(0)
    if unique_hyperedge_count > self.num_hyperedges:
        raise ValueError(
            f"'num_hyperedges' is too small for 'hyperedge_index'. "
            f"Got num_hyperedges={self.num_hyperedges}, but 'hyperedge_index' contains "
            f"{unique_hyperedge_count} unique hyperedge IDs."
        )

__validate_hyperedge_weights()

Validate optional hyperedge weights.

Raises:

Type Description
ValueError

If hyperedge weights have an invalid dtype or shape.

Source code in hypertorch/types/hdata.py
def __validate_hyperedge_weights(self) -> None:
    """
    Validate optional hyperedge weights.

    Raises:
        ValueError: If hyperedge weights have an invalid dtype or shape.
    """
    if self.hyperedge_weights is None:
        return

    validate_floating_tensor_dtype("hyperedge_weights", self.hyperedge_weights)

    if self.hyperedge_weights.dim() != 1:
        raise ValueError(
            f"'hyperedge_weights' must be a 1D tensor, "
            f"got shape {tuple(self.hyperedge_weights.shape)}."
        )
    if self.hyperedge_weights.size(0) != self.num_hyperedges:
        raise ValueError(
            f"'hyperedge_weights' must have one entry per hyperedge. "
            f"Got size={self.hyperedge_weights.size(0)} but "
            f"num_hyperedges={self.num_hyperedges}."
        )

__validate_global_node_ids()

Validate global node IDs.

Raises:

Type Description
ValueError

If global node IDs have an invalid dtype, shape, or length.

Source code in hypertorch/types/hdata.py
def __validate_global_node_ids(self) -> None:
    """
    Validate global node IDs.

    Raises:
        ValueError: If global node IDs have an invalid dtype, shape, or length.
    """
    validate_long_tensor_dtype("global_node_ids", self.global_node_ids)
    if self.global_node_ids.dim() != 1:
        raise ValueError(
            f"'global_node_ids' must be a 1D tensor, got "
            f"shape {tuple(self.global_node_ids.shape)}."
        )
    if self.global_node_ids.size(0) != self.num_nodes:
        raise ValueError(
            f"'global_node_ids' must have one entry per node. "
            f"Got size={self.global_node_ids.size(0)} but num_nodes={self.num_nodes}."
        )

__validate_labels()

Validate labels.

Raises:

Type Description
ValueError

If labels have an invalid dtype, shape, or length.

Source code in hypertorch/types/hdata.py
def __validate_labels(self) -> None:
    """
    Validate labels.

    Raises:
        ValueError: If labels have an invalid dtype, shape, or length.
    """
    if self.y.dim() != 1:
        raise ValueError(f"'y' must be a 1D tensor, got shape {tuple(self.y.shape)}.")

    if self.is_node_related_task:
        validate_long_tensor_dtype("y", self.y)
        if self.y.size(0) != self.num_nodes:
            raise ValueError(
                f"For task={self.task!r}, 'y' must have one entry per node. "
                f"Got {self.y.size(0)} entries but num_nodes={self.num_nodes}."
            )
        return

    if self.is_hyperedge_related_task:
        validate_floating_tensor_dtype("y", self.y)
        if self.y.size(0) != self.num_hyperedges:
            raise ValueError(
                f"For task={self.task!r}, 'y' must have one entry per hyperedge. "
                f"Got {self.y.size(0)} entries but num_hyperedges={self.num_hyperedges}."
            )

__validate_target_node_mask()

Validate the optional supervised-node mask.

Raises:

Type Description
ValueError

If the mask is incompatible with the configured task or node count.

Source code in hypertorch/types/hdata.py
def __validate_target_node_mask(self) -> None:
    """
    Validate the optional supervised-node mask.

    Raises:
        ValueError: If the mask is incompatible with the configured task or node count.
    """
    if not self.is_node_related_task:
        return

    if self.target_node_mask.dtype != torch.bool:
        raise ValueError(
            f"'target_node_mask' must have dtype torch.bool, got {self.target_node_mask.dtype}."
        )
    if self.target_node_mask.dim() != 1:
        raise ValueError(
            f"'target_node_mask' must be a 1D tensor, "
            f"got shape {tuple(self.target_node_mask.shape)}."
        )
    if self.target_node_mask.size(0) != self.num_nodes:
        raise ValueError(
            f"'target_node_mask' must have one entry per node. "
            f"Got size={self.target_node_mask.size(0)} but num_nodes={self.num_nodes}."
        )

__validate_target_hyperedge_mask()

Validate the optional supervised-hyperedge mask.

Raises:

Type Description
ValueError

If the mask is incompatible with the configured task or hyperedge count.

Source code in hypertorch/types/hdata.py
def __validate_target_hyperedge_mask(self) -> None:
    """
    Validate the optional supervised-hyperedge mask.

    Raises:
        ValueError: If the mask is incompatible with the configured task or hyperedge count.
    """
    if not self.is_hyperedge_related_task:
        return

    if self.target_hyperedge_mask.dtype != torch.bool:
        raise ValueError(
            "'target_hyperedge_mask' must have dtype torch.bool, "
            f"got {self.target_hyperedge_mask.dtype}."
        )
    if self.target_hyperedge_mask.dim() != 1:
        raise ValueError(
            f"'target_hyperedge_mask' must be a 1D tensor, "
            f"got shape {tuple(self.target_hyperedge_mask.shape)}."
        )
    if self.target_hyperedge_mask.size(0) != self.num_hyperedges:
        raise ValueError(
            f"'target_hyperedge_mask' must have one entry per hyperedge. "
            f"Got size={self.target_hyperedge_mask.size(0)} but "
            f"num_hyperedges={self.num_hyperedges}."
        )

__validate_task()

Validate the learning task.

Raises:

Type Description
ValueError

If the task is unsupported.

Source code in hypertorch/types/hdata.py
def __validate_task(self) -> None:
    """
    Validate the learning task.

    Raises:
        ValueError: If the task is unsupported.
    """
    valid_tasks = get_args(TaskLiteral)
    if self.task not in valid_tasks:
        raise ValueError(f"'task' must be one of {valid_tasks}, got {self.task!r}.")

__validate_x()

Validate node feature row count.

Raises:

Type Description
ValueError

If node features do not match the configured node count.

Source code in hypertorch/types/hdata.py
def __validate_x(self) -> None:
    """
    Validate node feature row count.

    Raises:
        ValueError: If node features do not match the configured node count.
    """
    if self.x.size(0) not in (0, self.num_nodes):
        raise ValueError(
            f"'x' must have one feature row per node, or be 'torch.empty((0, 0))' "
            f"if there are no nodes. "
            f"Got x.shape={tuple(self.x.shape)} but num_nodes={self.num_nodes}."
        )

__validate_node_space_setting(node_space_setting, fill_value)

Validate node-space enrichment settings.

Parameters:

Name Type Description Default
node_space_setting NodeSpaceSetting

Node-space setting to validate.

required
fill_value NodeSpaceFiller | None

Optional fill value for missing nodes.

required

Raises:

Type Description
ValueError

If the setting and fill value are incompatible.

Source code in hypertorch/types/hdata.py
def __validate_node_space_setting(
    self,
    node_space_setting: NodeSpaceSetting,
    fill_value: NodeSpaceFiller | None,
) -> None:
    """
    Validate node-space enrichment settings.

    Args:
        node_space_setting: Node-space setting to validate.
        fill_value: Optional fill value for missing nodes.

    Raises:
        ValueError: If the setting and fill value are incompatible.
    """
    validate_node_space_setting(node_space_setting)

    if is_transductive_setting(node_space_setting) and fill_value is not None:
        raise ValueError(
            "'fill_value' cannot be provided when node_space_setting='transductive'."
        )
    if is_inductive_setting(node_space_setting) and fill_value is None:
        raise ValueError("'fill_value' must be provided when node_space_setting='inductive'.")

__validate_x_and_hyperedge_index_type_and_dim()

Validate core tensor dtypes and dimensions.

Raises:

Type Description
ValueError

If x or hyperedge_index has an invalid dtype or shape.

Source code in hypertorch/types/hdata.py
def __validate_x_and_hyperedge_index_type_and_dim(self) -> None:
    """
    Validate core tensor dtypes and dimensions.

    Raises:
        ValueError: If ``x`` or ``hyperedge_index`` has an invalid dtype or shape.
    """
    validate_floating_tensor_dtype("x", self.x)
    if self.x.dim() != 2:
        raise ValueError(f"'x' must be a 2D tensor, got shape {tuple(self.x.shape)}.")

    validate_long_tensor_dtype("hyperedge_index", self.hyperedge_index)
    if self.hyperedge_index.dim() != 2 or self.hyperedge_index.size(0) != 2:
        raise ValueError(
            f"'hyperedge_index' must have shape (2, num_incidences), got "
            f"{tuple(self.hyperedge_index.shape)}."
        )

__assign_y_for_task(y=None)

Return labels as non-None tensor on the correct device.

Parameters:

Name Type Description Default
y Tensor | None

Optional labels tensor. If None, defaults to a tensor of ones for hyperlink prediction tasks or a tensor of zeros for node classification tasks.

None

Returns:

Name Type Description
y Tensor

Labels tensor on the correct device.

Source code in hypertorch/types/hdata.py
def __assign_y_for_task(self, y: Tensor | None = None) -> Tensor:
    """
    Return labels as non-None tensor on the correct device.

    Args:
        y: Optional labels tensor. If ``None``, defaults to
            a tensor of ones for hyperlink prediction tasks
            or a tensor of zeros for node classification tasks.

    Returns:
        y: Labels tensor on the correct device.
    """
    if y is not None:
        return y
    if self.is_node_related_task:
        return torch.ones((self.num_nodes,), dtype=torch.long, device=self.x.device)
    return torch.ones((self.num_hyperedges,), dtype=torch.float, device=self.x.device)

TaskEnum

Bases: StrEnum

Enum for supported hypergraph learning tasks.

Source code in hypertorch/types/hdata.py
class TaskEnum(StrEnum):
    """
    Enum for supported hypergraph learning tasks.
    """

    HYPERLINK_PREDICTION = "hyperlink-prediction"
    NODE_CLASSIFICATION = "node-classification"

ModelConfig

A class representing the configuration of a model for training.

Attributes:

Name Type Description
name str

The name of the model.

version str

The version of the model.

model LightningModule

a LightningModule instance.

is_trainable bool

Whether the model is trainable.

trainer Trainer | None

A Trainer instance used for fit_all and, by default, test_all.

test_trainer Trainer | None

Optional Trainer instance used by test_all. When omitted, test_all falls back to trainer.

train_dataloader DataLoader | None

Optional per-model train dataloader. When set, fit_all uses this instead of the shared train_dataloader argument.

val_dataloader DataLoader | None

Optional per-model validation dataloader. When set, fit_all uses this instead of the shared val_dataloader argument.

test_dataloader DataLoader | None

Optional per-model test dataloader. When set, test_all uses this instead of the shared dataloader argument.

Source code in hypertorch/types/model.py
class ModelConfig:
    """
    A class representing the configuration of a model for training.

    Attributes:
        name: The name of the model.
        version: The version of the model.
        model: a LightningModule instance.
        is_trainable: Whether the model is trainable.
        trainer: A Trainer instance used for `fit_all` and, by default, `test_all`.
        test_trainer: Optional Trainer instance used by ``test_all``. When omitted,
            ``test_all`` falls back to ``trainer``.
        train_dataloader: Optional per-model train dataloader. When set, ``fit_all``
            uses this instead of the shared ``train_dataloader`` argument.
        val_dataloader: Optional per-model validation dataloader. When set, ``fit_all``
            uses this instead of the shared ``val_dataloader`` argument.
        test_dataloader: Optional per-model test dataloader. When set, ``test_all``
            uses this instead of the shared ``dataloader`` argument.
    """

    def __init__(
        self,
        name: str,
        model: L.LightningModule,
        version: str = "default",
        is_trainable: bool = True,
        trainer: L.Trainer | None = None,
        test_trainer: L.Trainer | None = None,
        train_dataloader: DataLoader | None = None,
        val_dataloader: DataLoader | None = None,
        test_dataloader: DataLoader | None = None,
    ) -> None:
        """
        Initialize the model configuration.

        Args:
            name: The name of the model.
            version: The version of the model.
            model: a LightningModule instance.
            is_trainable: Whether the model is trainable.
            trainer: A Trainer instance used for `fit_all` and, by default, `test_all`.
            test_trainer: Optional Trainer instance used by ``test_all``. When omitted,
                ``test_all`` falls back to ``trainer``.
            train_dataloader: Optional per-model train dataloader. When set, ``fit_all``
                uses this instead of the shared ``train_dataloader`` argument.
            val_dataloader: Optional per-model validation dataloader. When set, ``fit_all``
                uses this instead of the shared ``val_dataloader`` argument.
            test_dataloader: Optional per-model test dataloader. When set, ``test_all``
                uses this instead of the shared ``dataloader`` argument.
        """
        self.name: str = name
        self.version: str = version
        self.model: L.LightningModule = model
        self.is_trainable: bool = is_trainable
        self.trainer: L.Trainer | None = trainer
        self.test_trainer: L.Trainer | None = test_trainer
        self.train_dataloader: DataLoader | None = train_dataloader
        self.val_dataloader: DataLoader | None = val_dataloader
        self.test_dataloader: DataLoader | None = test_dataloader

    def full_model_name(self) -> str:
        """
        Return the combined model name and version.

        Returns:
            full_model_name: Name formatted as ``"{name}:{version}"``.
        """
        return f"{self.name}:{self.version}"

__init__(name, model, version='default', is_trainable=True, trainer=None, test_trainer=None, train_dataloader=None, val_dataloader=None, test_dataloader=None)

Initialize the model configuration.

Parameters:

Name Type Description Default
name str

The name of the model.

required
version str

The version of the model.

'default'
model LightningModule

a LightningModule instance.

required
is_trainable bool

Whether the model is trainable.

True
trainer Trainer | None

A Trainer instance used for fit_all and, by default, test_all.

None
test_trainer Trainer | None

Optional Trainer instance used by test_all. When omitted, test_all falls back to trainer.

None
train_dataloader DataLoader | None

Optional per-model train dataloader. When set, fit_all uses this instead of the shared train_dataloader argument.

None
val_dataloader DataLoader | None

Optional per-model validation dataloader. When set, fit_all uses this instead of the shared val_dataloader argument.

None
test_dataloader DataLoader | None

Optional per-model test dataloader. When set, test_all uses this instead of the shared dataloader argument.

None
Source code in hypertorch/types/model.py
def __init__(
    self,
    name: str,
    model: L.LightningModule,
    version: str = "default",
    is_trainable: bool = True,
    trainer: L.Trainer | None = None,
    test_trainer: L.Trainer | None = None,
    train_dataloader: DataLoader | None = None,
    val_dataloader: DataLoader | None = None,
    test_dataloader: DataLoader | None = None,
) -> None:
    """
    Initialize the model configuration.

    Args:
        name: The name of the model.
        version: The version of the model.
        model: a LightningModule instance.
        is_trainable: Whether the model is trainable.
        trainer: A Trainer instance used for `fit_all` and, by default, `test_all`.
        test_trainer: Optional Trainer instance used by ``test_all``. When omitted,
            ``test_all`` falls back to ``trainer``.
        train_dataloader: Optional per-model train dataloader. When set, ``fit_all``
            uses this instead of the shared ``train_dataloader`` argument.
        val_dataloader: Optional per-model validation dataloader. When set, ``fit_all``
            uses this instead of the shared ``val_dataloader`` argument.
        test_dataloader: Optional per-model test dataloader. When set, ``test_all``
            uses this instead of the shared ``dataloader`` argument.
    """
    self.name: str = name
    self.version: str = version
    self.model: L.LightningModule = model
    self.is_trainable: bool = is_trainable
    self.trainer: L.Trainer | None = trainer
    self.test_trainer: L.Trainer | None = test_trainer
    self.train_dataloader: DataLoader | None = train_dataloader
    self.val_dataloader: DataLoader | None = val_dataloader
    self.test_dataloader: DataLoader | None = test_dataloader

full_model_name()

Return the combined model name and version.

Returns:

Name Type Description
full_model_name str

Name formatted as "{name}:{version}".

Source code in hypertorch/types/model.py
def full_model_name(self) -> str:
    """
    Return the combined model name and version.

    Returns:
        full_model_name: Name formatted as ``"{name}:{version}"``.
    """
    return f"{self.name}:{self.version}"