diff --git a/.gitignore b/.gitignore index f487cae..9348c02 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.claude/* +/build/* +.venv/* /venv/ /.idea/ /.vscode/ diff --git a/hypergraphx/__init__.py b/hypergraphx/__init__.py index a1a5ba0..bd36a4a 100644 --- a/hypergraphx/__init__.py +++ b/hypergraphx/__init__.py @@ -1,7 +1,19 @@ -from hypergraphx.core.directed_hypergraph import DirectedHypergraph -from hypergraphx.core.hypergraph import Hypergraph -from hypergraphx.core.multiplex_hypergraph import MultiplexHypergraph -from hypergraphx.core.temporal_hypergraph import TemporalHypergraph +# Core interfaces +from hypergraphx.core.IHypergraph import IHypergraph +from hypergraphx.core.IUndirectedHypergraph import IUndirectedHypergraph + +# Core classes +from hypergraphx.core.Hypergraph import Hypergraph +from hypergraphx.core.DirectedHypergraph import DirectedHypergraph +from hypergraphx.core.MultiplexHypergraph import MultiplexHypergraph +from hypergraphx.core.TemporalHypergraph import TemporalHypergraph + +# Visualization interface +from hypergraphx.viz.IHypergraphVisualizer import IHypergraphVisualizer + +# Visualization class +from hypergraphx.viz.HypergraphVisualizer import HypergraphVisualizer + from . import readwrite import sys diff --git a/hypergraphx/core/directed_hypergraph.py b/hypergraphx/core/DirectedHypergraph.py similarity index 67% rename from hypergraphx/core/directed_hypergraph.py rename to hypergraphx/core/DirectedHypergraph.py index 0f47c44..985c2da 100644 --- a/hypergraphx/core/directed_hypergraph.py +++ b/hypergraphx/core/DirectedHypergraph.py @@ -1,8 +1,5 @@ -import copy -from typing import Tuple, List - -from sklearn.preprocessing import LabelEncoder - +from typing import Tuple, List, Any, Optional, Dict +from hypergraphx.core.IHypergraph import IHypergraph def _get_edge_size(edge): """ @@ -20,7 +17,7 @@ def _get_edge_size(edge): return len(edge[0]) + len(edge[1]) -class DirectedHypergraph: +class DirectedHypergraph(IHypergraph): """ A Directed Hypergraph is a generalization of a graph in which hyperedges have a direction. Each hyperedge connects a set of source nodes to a set of target nodes. @@ -28,12 +25,12 @@ class DirectedHypergraph: def __init__( self, - edge_list=None, - weighted=False, - weights=None, - hypergraph_metadata=None, - node_metadata=None, - edge_metadata=None, + edge_list: Optional[List]=None, + weighted: bool = False, + weights:Optional[List[int]]=None, + hypergraph_metadata: Optional[Dict] = None, + node_metadata: Optional[Dict] = None, + edge_metadata: Optional[List[Dict]] = None ): """ Initialize a Directed Hypergraph. @@ -60,23 +57,22 @@ def __init__( If `edge_list` and `weights` have mismatched lengths when `weighted` is True. If `edge_list` contains improperly formatted edges. """ - # Initialize hypergraph metadata - self._hypergraph_metadata = hypergraph_metadata or {} - self._hypergraph_metadata.update( - {"weighted": weighted, "type": "DirectedHypergraph"} + # Call parent constructor + super().__init__( + edge_list=None, # We'll handle edge_list separately + weighted=weighted, + weights=weights, + hypergraph_metadata=hypergraph_metadata, + node_metadata=node_metadata, + edge_metadata=edge_metadata ) + + # Update hypergraph metadata with type + self._hypergraph_metadata.update({"type": "DirectedHypergraph"}) - # Initialize core attributes - self._weighted = weighted + # Initialize DirectedHypergraph-specific attributes self._adj_source = {} self._adj_target = {} - self._edge_list = {} - self._node_metadata = {} - self._edge_metadata = {} - self._incidences_metadata = {} - self._reverse_edge_list = {} - self._weights = {} - self._next_edge_id = 0 # Add node metadata if provided if node_metadata: @@ -89,7 +85,10 @@ def __init__( raise ValueError("Edge list and weights must have the same length.") self.add_edges(edge_list, weights=weights, metadata=edge_metadata) - # Nodes + # ============================================================================= + # Node Management Implementation + # ============================================================================= + def add_node(self, node, metadata=None): """ Add a node to the hypergraph. If the node is already in the hypergraph, nothing happens. @@ -98,35 +97,20 @@ def add_node(self, node, metadata=None): ---------- node : object The node to add. + metadata : dict, optional + Metadata for the node. Returns ------- None """ - if metadata is None: - self._node_metadata[node] = {} + # Call parent method for metadata handling + super().add_node(node, metadata) + + # DirectedHypergraph-specific initialization if node not in self._adj_source: self._adj_source[node] = [] self._adj_target[node] = [] - self._node_metadata[node] = {} - if self._node_metadata[node] == {}: - self._node_metadata[node] = metadata - - def add_nodes(self, node_list: list): - """ - Add a list of nodes to the hypergraph. - - Parameters - ---------- - node_list : list - The list of nodes to add. - - Returns - ------- - None - """ - for node in node_list: - self.add_node(node) def remove_node(self, node, keep_edges=False): """Remove a node from the hypergraph, with an option to keep or remove edges incident to it.""" @@ -144,6 +128,10 @@ def remove_node(self, node, keep_edges=False): del self._adj_source[node] del self._adj_target[node] + + # Remove from parent's node metadata + if node in self._node_metadata: + del self._node_metadata[node] def remove_nodes(self, node_list, keep_edges=False): """ @@ -153,10 +141,9 @@ def remove_nodes(self, node_list, keep_edges=False): ---------- node_list : list The list of nodes to remove. - keep_edges : bool, optional - If True, the edges incident to the nodes are kept, but the nodes are removed from the edges. If False, the edges incident to the nodes are removed. Default is False. - + If True, the edges incident to the nodes are kept, but the nodes are removed from the edges. + If False, the edges incident to the nodes are removed. Default is False. Returns ------- @@ -189,9 +176,8 @@ def check_node(self, node): ------- bool True if the node is in the hypergraph, False otherwise. - """ - return node in self._adj_source or self._adj_target + return node in self._adj_source def get_neighbors(self, node, order: int = None, size: int = None): """ @@ -226,7 +212,8 @@ def get_neighbors(self, node, order: int = None, size: int = None): neigh = set() edges = self.get_incident_edges(node) for edge in edges: - neigh.update(edge) + neigh.update(edge[0]) # Add source nodes + neigh.update(edge[1]) # Add target nodes if node in neigh: neigh.remove(node) return neigh @@ -236,7 +223,8 @@ def get_neighbors(self, node, order: int = None, size: int = None): neigh = set() edges = self.get_incident_edges(node, order=order) for edge in edges: - neigh.update(edge) + neigh.update(edge[0]) # Add source nodes + neigh.update(edge[1]) # Add target nodes if node in neigh: neigh.remove(node) return neigh @@ -279,7 +267,6 @@ def get_sources(self): ------- list List of sources of the hyperedges in the hypergraph. - """ return [edge[0] for edge in self._edge_list.keys()] @@ -290,11 +277,13 @@ def get_targets(self): ------- list List of targets of the hyperedges in the hypergraph. - """ return [edge[1] for edge in self._edge_list.keys()] - # Edges + # ============================================================================= + # Edge Management Implementation + # ============================================================================= + def add_edge(self, edge: Tuple[Tuple, Tuple], weight=None, metadata=None): """Add a directed hyperedge to the hypergraph. If the hyperedge already exists, its weight is updated. @@ -358,9 +347,10 @@ def add_edge(self, edge: Tuple[Tuple, Tuple], weight=None, metadata=None): else: self.set_edge_metadata(edge, {}) - def add_edges( - self, edge_list: List[Tuple[Tuple, Tuple]], weights=None, metadata=None - ): + def add_edges(self, + edge_list: List[Tuple[Tuple, Tuple]], + weights=None, + metadata=None): """Add a list of directed hyperedges to the hypergraph. If a hyperedge is already in the hypergraph, its weight is updated. Parameters @@ -552,7 +542,7 @@ def remove_edge(self, edge: Tuple[Tuple, Tuple]): del self._reverse_edge_list[e_idx] del self._weights[e_idx] - del self._edge_metadata[e_idx] + del self._edge_metadata[edge] del self._edge_list[edge] else: @@ -578,43 +568,9 @@ def remove_edges(self, edge_list): for edge in edge_list: self.remove_edge(edge) - def set_edge_list(self, edge_list): - self._edge_list = edge_list - - def get_edge_list(self): - return self._edge_list - - """def add_empty_edge(self, name, metadata): - pass - Don't know if needed - """ - - def check_edge(self, edge: Tuple[Tuple, Tuple]): - """Checks if the specified edge is in the hypergraph. - - Parameters - ---------- - edge : tuple - The edge to check. - - Returns - ------- - bool - True if the edge is in the hypergraph, False otherwise. - - """ - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - return edge in self._edge_list - - # Weight - def get_weight(self, edge: Tuple[Tuple, Tuple]): - """Returns the weight of the specified directed edge.""" - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - if edge in self._edge_list: - idx = self._edge_list[edge] - return self._weights[idx] - else: - raise ValueError(f"Edge {edge} not in hypergraph.") + # ============================================================================= + # Weight Management Implementation + # ============================================================================= def get_weights(self, order=None, size=None, up_to=False, asdict=False): """Returns the list of weights of the edges in the hypergraph. If order is specified, it returns the list of weights of the edges of the specified order. @@ -625,10 +581,8 @@ def get_weights(self, order=None, size=None, up_to=False, asdict=False): ---------- order : int, optional Order of the edges to get the weights of. - size : int, optional Size of the edges to get the weights of. - up_to : bool, optional If True, it returns the list of weights of the edges of order smaller or equal to the specified order. Default is False. @@ -641,7 +595,6 @@ def get_weights(self, order=None, size=None, up_to=False, asdict=False): ------ ValueError If both order and size are specified. - """ w = None if order is not None and size is not None: @@ -665,23 +618,9 @@ def get_weights(self, order=None, size=None, up_to=False, asdict=False): else: return list(w.values()) - def set_weight(self, edge: Tuple[Tuple, Tuple], weight: float): - """Sets the weight of the specified directed edge.""" - if not self._weighted and weight != 1: - raise ValueError( - "If the hypergraph is not weighted, weight can be 1 or None." - ) - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - if edge in self._edge_list: - idx = self._edge_list[edge] - self._weights[idx] = weight - else: - raise ValueError(f"Edge {edge} not in hypergraph.") - - # Info - def num_nodes(self): - """Returns the number of nodes in the hypergraph.""" - return len(self.get_nodes()) + # ============================================================================= + # Structural Information Implementation + # ============================================================================= def num_edges(self): """Returns the number of directed edges in the hypergraph.""" @@ -694,67 +633,9 @@ def get_sizes(self): ------- list List of sizes of the hyperedges in the hypergraph. - """ return [len(edge[0]) + len(edge[1]) for edge in self._edge_list.keys()] - def max_size(self): - """ - Returns the maximum size of the hypergraph. - - Returns - ------- - int - Maximum size of the hypergraph. - """ - return max(self.get_sizes()) - - def max_order(self): - """ - Returns the maximum order of the hypergraph. - - Returns - ------- - int - Maximum order of the hypergraph. - """ - return self.max_size() - 1 - - def distribution_sizes(self): - """ - Returns the distribution of sizes of the hyperedges in the hypergraph. - - Returns - ------- - collections.Counter - Distribution of sizes of the hyperedges in the hypergraph. - """ - from collections import Counter - - return dict(Counter(self.get_sizes())) - - def get_orders(self): - """Returns the list of orders of the hyperedges in the hypergraph. - - Returns - ------- - list - List of orders of the hyperedges in the hypergraph. - - """ - return [len(edge[0]) + len(edge[1]) - 1 for edge in self._edge_list.keys()] - - def is_weighted(self): - """ - Check if the hypergraph is weighted. - - Returns - ------- - bool - True if the hypergraph is weighted, False otherwise. - """ - return self._weighted - def is_uniform(self): """ Check if the hypergraph is uniform, i.e. all hyperedges have the same size. @@ -767,16 +648,19 @@ def is_uniform(self): uniform = True sz = None for edge in self._edge_list: - edge = set(edge[0]).union(set(edge[1])) + edge_nodes = set(edge[0]).union(set(edge[1])) if sz is None: - sz = len(edge) + sz = len(edge_nodes) else: - if len(edge) != sz: + if len(edge_nodes) != sz: uniform = False break return uniform - # Adj + # ============================================================================= + # Utility and DirectedHypergraph-specific methods + # ============================================================================= + def get_adj_dict(self, source_target): if source_target == "source": return self._adj_source @@ -797,249 +681,84 @@ def set_adj_dict(self, adj_dict, source_target): "Invalid value for source_target. Must be 'source' or 'target'." ) - # Degree + # Degree methods def degree(self, node, order=None, size=None): from hypergraphx.measures.degree import degree - return degree(self, node, order=order, size=size) def degree_sequence(self, order=None, size=None): from hypergraphx.measures.degree import degree_sequence - return degree_sequence(self, order=order, size=size) def degree_distribution(self, order=None, size=None): from hypergraphx.measures.degree import degree_distribution - return degree_distribution(self, order=order, size=size) - # Connected Components - """def is_connected(self, size=None, order=None): - from hypergraphx.utils.cc import is_connected - - return is_connected(self, size=size, order=order)""" - # TODO - - """def connected_components(self, size=None, order=None): - from hypergraphx.utils.cc import connected_components - - return connected_components(self, size=size, order=order)""" - # TODO - - """def node_connected_component(self, node, size=None, order=None): - from hypergraphx.utils.cc import node_connected_component - - return node_connected_component(self, node, size=size, order=order)""" - # TODO - - """def num_connected_components(self, size=None, order=None): - from hypergraphx.utils.cc import num_connected_components - - return num_connected_components(self, size=size, order=order)""" - # TODO - - """def largest_component(self, size=None, order=None): - from hypergraphx.utils.cc import largest_component - - return largest_component(self, size=size, order=order)""" - # TODO - - '''def subhypergraph_largest_component(self, size=None, order=None): - """ - Returns a subhypergraph induced by the nodes in the largest component of the hypergraph. - - Parameters - ---------- - size: int, optional - The size of the hyperedges to consider - order: int, optional - The order of the hyperedges to consider - - Returns - ------- - Hypergraph - Subhypergraph induced by the nodes in the largest component of the hypergraph. - """ - nodes = self.largest_component(size=size, order=order) - return self.subhypergraph(nodes)''' - # TODO - - """def largest_component_size(self, size=None, order=None): - from hypergraphx.utils.cc import largest_component_size - - return largest_component_size(self, size=size, order=order)""" - # TODO - - # Matrix - """def binary_incidence_matrix(self, return_mapping: bool = False): - from hypergraphx.linalg import binary_incidence_matrix - - return binary_incidence_matrix(self, return_mapping)""" - # TODO - - """def incidence_matrix(self, return_mapping: bool = False): - from hypergraphx.linalg import incidence_matrix - - return incidence_matrix(self, return_mapping)""" - # TODO - - """def adjacency_matrix(self, return_mapping: bool = False): - from hypergraphx.linalg import adjacency_matrix - - return adjacency_matrix(self, return_mapping)""" - # TODO - - # Utility + # Utility methods def isolated_nodes(self, size=None, order=None): from hypergraphx.utils.cc import isolated_nodes - return isolated_nodes(self, size=size, order=order) def is_isolated(self, node, size=None, order=None): from hypergraphx.utils.cc import is_isolated - return is_isolated(self, node, size=size, order=order) def to_line_graph(self, distance="intersection", s: int = 1, weighted=False): from hypergraphx.representations.projections import directed_line_graph - return directed_line_graph(self, distance, s, weighted) - # Metadata - def set_hypergraph_metadata(self, metadata): - self._hypergraph_metadata = metadata - - def get_hypergraph_metadata(self): - return self._hypergraph_metadata - - def set_node_metadata(self, node, metadata): - if node not in self._adj_source: - raise ValueError("Node {} not in hypergraph.".format(node)) - self._node_metadata[node] = metadata - - def get_node_metadata(self, node): - if node not in self._adj_source: - raise ValueError("Node {} not in hypergraph.".format(node)) - return self._node_metadata[node] - - def get_all_nodes_metadata(self): - return list(self._node_metadata.values()) + def _canon_edge(self, edge: Tuple) -> Tuple: + """ + Gets the canonical form of an edge (sorts the inner tuples) + Works for hyperedges but WILL BREAK FOR METAEDGES + TODO: Add recursive canonicalization for future metagraph integration + """ + return (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) + + def _restructure_query_edge(self, k: Tuple[Tuple, Any]): + """ + An implementation-specific helper for modifying a query edge + prior to metadata retrieval. + """ + return k - def set_edge_metadata(self, edge, metadata): - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - if edge not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - idx = self._edge_list[edge] - self._edge_metadata[idx] = metadata + # ============================================================================= + # Metadata Management Implementation + # ============================================================================= - def get_edge_metadata(self, edge): - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) + def get_incidence_metadata(self, edge, node): + edge = self._canon_edge(edge) if edge not in self._edge_list: raise ValueError("Edge {} not in hypergraph.".format(edge)) - idx = self._edge_list[edge] - return self._edge_metadata[idx] - - def get_all_edges_metadata(self): - return self._edge_metadata + return self._incidences_metadata[(edge, node)] def set_incidence_metadata(self, edge, node, metadata): - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) + edge = self._canon_edge(edge) if edge not in self._edge_list: raise ValueError("Edge {} not in hypergraph.".format(edge)) self._incidences_metadata[(edge, node)] = metadata - def get_incidence_metadata(self, edge, node): - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - if edge not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - return self._incidences_metadata[(edge, node)] - def get_all_incidences_metadata(self): return {k: v for k, v in self._incidences_metadata.items()} - def set_attr_to_hypergraph_metadata(self, field, value): - self._hypergraph_metadata[field] = value - - def set_attr_to_node_metadata(self, node, field, value): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - self._node_metadata[node][field] = value - - def set_attr_to_edge_metadata(self, edge, field, value): - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - if edge not in self._edge_metadata: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - self._edge_metadata[self._edge_list[edge]][field] = value - - def remove_attr_from_node_metadata(self, node, field): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - del self._node_metadata[node][field] - - def remove_attr_from_edge_metadata(self, edge, field): - edge = (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - if edge not in self._edge_metadata: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - del self._edge_metadata[self._edge_list[edge]][field] + # ============================================================================= + # Utility Methods Implementation + # ============================================================================= - # Basic Functions def clear(self): self._edge_list.clear() self._adj_source.clear() self._adj_target.clear() self._incidences_metadata.clear() + self._node_metadata.clear() + self._edge_metadata.clear() + self._weights.clear() + self._reverse_edge_list.clear() - def copy(self): - """ - Returns a copy of the hypergraph. - - Returns - ------- - Hypergraph - A copy of the hypergraph. - """ - return copy.deepcopy(self) - - def __str__(self): - """ - Returns a string representation of the hypergraph. + # ============================================================================= + # Serialization Support Implementation + # ============================================================================= - Returns - ------- - str - A string representation of the hypergraph. - """ - title = "Hypergraph with {} nodes and {} edges.\n".format( - self.num_nodes(), self.num_edges() - ) - details = "Distribution of hyperedge sizes: {}".format( - self.distribution_sizes() - ) - return title + details - - def __len__(self): - """ - Returns the number of edges in the hypergraph. - - Returns - ------- - int - The number of edges in the hypergraph. - """ - return len(self._edge_list) - - def __iter__(self): - """ - Returns an iterator over the edges in the hypergraph. - - Returns - ------- - iterator - An iterator over the edges in the hypergraph. - """ - return iter(self._edge_list.items()) - - # Data Structure Extra def expose_data_structures(self): """ Expose the internal data structures of the directed hypergraph for serialization. @@ -1101,7 +820,7 @@ def expose_attributes_for_hashing(self): { "nodes": sorted_edge, "weight": self._weights.get(edge_id, 1), - "metadata": self._edge_metadata.get(edge_id, {}), + "metadata": self.get_edge_metadata(edge), } ) @@ -1115,17 +834,4 @@ def expose_attributes_for_hashing(self): "hypergraph_metadata": self._hypergraph_metadata, "edges": edges, "nodes": nodes, - } - - def get_mapping(self): - """ - Map the nodes of the hypergraph to integers in [0, n_nodes). - - Returns - ------- - LabelEncoder - The mapping. - """ - encoder = LabelEncoder() - encoder.fit(self.get_nodes()) - return encoder + } \ No newline at end of file diff --git a/hypergraphx/core/Hypergraph.py b/hypergraphx/core/Hypergraph.py new file mode 100644 index 0000000..2f425d3 --- /dev/null +++ b/hypergraphx/core/Hypergraph.py @@ -0,0 +1,561 @@ +from typing import Tuple, Any, List, Dict, Optional +from hypergraphx.core.IUndirectedHypergraph import IUndirectedHypergraph + +class Hypergraph(IUndirectedHypergraph): + """ + A Hypergraph is a generalization of a graph where an edge (hyperedge) can connect + any number of nodes. It is represented as a set of nodes and a set of hyperedges, + where each hyperedge is a subset of nodes. + + This implementation now inherits from IUndirectedHypergraph to leverage common functionality. + """ + + def __init__( + self, + edge_list: Optional[List]=None, + weighted: bool = False, + weights:Optional[List[int]]=None, + hypergraph_metadata: Optional[Dict] = None, + node_metadata: Optional[Dict] = None, + edge_metadata: Optional[List[Dict]] = None + ): + """ + Initialize a Hypergraph. + + Parameters + ---------- + edge_list : list of tuples, optional + A list of hyperedges, where each hyperedge is represented as a tuple of nodes. + weighted : bool, optional + Indicates whether the hypergraph is weighted. Default is False. + weights : list of floats, optional + A list of weights corresponding to each edge in `edge_list`. Required if `weighted` is True. + hypergraph_metadata : dict, optional + Metadata for the hypergraph. Default is an empty dictionary. + node_metadata : dict, optional + A dictionary of metadata for nodes, where keys are node identifiers and values are metadata dictionaries. + edge_metadata : list of dicts, optional + A list of metadata dictionaries corresponding to the edges in `edge_list`. + + Raises + ------ + ValueError + If `edge_list` and `weights` have mismatched lengths when `weighted` is True. + """ + # Call parent constructor (IUndirectedHypergraph) + super().__init__(edge_list, weighted, weights, hypergraph_metadata, node_metadata, edge_metadata) + + # Set hypergraph type in metadata + self._hypergraph_metadata.update({"type": "Hypergraph"}) + + # Initialize Hypergraph-specific attributes + self._empty_edges = {} + + # Add node metadata if provided (using parent's add_nodes method) + if node_metadata: + self.add_nodes( + list(node_metadata.keys()), + metadata=node_metadata + ) + + # Add edges if provided + if edge_list: + if weighted and weights is not None and len(edge_list) != len(weights): + raise ValueError("Edge list and weights must have the same length.") + self.add_edges( + edge_list, + weights=weights, + metadata=edge_metadata + ) + + # ============================================================================= + # Implementation of Abstract Methods from IUndirectedHypergraph + # ============================================================================= + + def _add_edge_implementation(self, edge, weight, metadata, **kwargs): + """ + Implementation of abstract method for adding a single edge. + + Parameters + ---------- + edge : tuple + The edge to add. + weight : float, optional + The weight of the edge. + metadata : dict, optional + The metadata of the edge. + **kwargs + Additional parameters (unused for Hypergraph). + """ + self.add_edge(edge, weight=weight, metadata=metadata) + + def _extract_nodes_from_edge(self, edge) -> List: + """ + Extract node list from an edge representation. + For Hypergraph, edges are simple tuples of nodes. + + Parameters + ---------- + edge : tuple + The edge representation (tuple of nodes). + + Returns + ------- + list + List of nodes in the edge. + """ + return list(edge) + + def _get_edge_size(self, edge_key) -> int: + """ + Get the size of an edge given its key representation. + For Hypergraph, edge keys are tuples of nodes. + + Parameters + ---------- + edge_key : tuple + The edge key representation (tuple of nodes). + + Returns + ------- + int + Size of the edge. + """ + return len(edge_key) + + # ============================================================================= + # Hypergraph-Specific Node Management Implementation + # ============================================================================= + + def remove_node(self, node, keep_edges=False): + """Remove a node from the hypergraph. + + Parameters + ---------- + node + The node to remove. + keep_edges : bool, optional + If True, the edges incident to the node are kept, but the node is removed from the edges. + If False, the edges incident to the node are removed. Default is False. + + Returns + ------- + None + + Raises + ------ + KeyError + If the node is not in the hypergraph. + """ + if node not in self._adj: + raise KeyError("Node {} not in hypergraph.".format(node)) + + if not keep_edges: + self.remove_edges( + [self._reverse_edge_list[edge_id] for edge_id in self._adj[node]] + ) + else: + to_remove = [] + for edge_id in self._adj[node]: + edge = self._reverse_edge_list[edge_id] + self.add_edge( + tuple(sorted([n for n in edge if n != node])), + weight=self.get_weight(edge), + metadata=self.get_edge_metadata(edge), + ) + to_remove.append(edge) + self.remove_edges(to_remove) + + del self._adj[node] + # Remove from parent's node metadata + if node in self._node_metadata: + del self._node_metadata[node] + + # ============================================================================= + # Hypergraph-Specific Edge Management Implementation + # ============================================================================= + + def add_edge(self, edge, weight=None, metadata=None): + """Add a hyperedge to the hypergraph. If the hyperedge is already in the hypergraph, its weight is updated. + + Parameters + ---------- + edge : tuple + The hyperedge to add. + weight : float, optional + The weight of the hyperedge. If the hypergraph is weighted, this must be provided. + metadata : dict, optional + The metadata of the hyperedge. + + Returns + ------- + None + + Raises + ------ + ValueError + If the hypergraph is weighted and no weight is provided or if the hypergraph is not weighted and a weight is provided. + """ + if not self._weighted and weight is not None and weight != 1: + raise ValueError( + "If the hypergraph is not weighted, weight can be 1 or None." + ) + + if weight is None: + weight = 1 + + edge = self._canon_edge(edge) + if metadata is None: + metadata = {} + + if edge not in self._edge_list: + self._edge_list[edge] = self._next_edge_id + self._reverse_edge_list[self._next_edge_id] = edge + self._weights[self._next_edge_id] = 1 if not self._weighted else weight + self._next_edge_id += 1 + elif edge in self._edge_list and self._weighted: + self._weights[self._edge_list[edge]] += weight + + # Set edge metadata using parent method + if edge in self._edge_list: + self.set_edge_metadata(edge, metadata) + + # Update adjacency list + for node in edge: + self.add_node(node) + if self._edge_list[edge] not in self._adj[node]: + self._adj[node].append(self._edge_list[edge]) + + def remove_edge(self, edge): + """Remove an edge from the hypergraph. + + Parameters + ---------- + edge : tuple + The edge to remove. + + Returns + ------- + None + + Raises + ------ + KeyError + If the edge is not in the hypergraph. + """ + edge = self._canon_edge(edge) + if edge not in self._edge_list: + raise KeyError("Edge {} not in hypergraph.".format(edge)) + + edge_id = self._edge_list[edge] + + del self._reverse_edge_list[edge_id] + if edge_id in self._weights: + del self._weights[edge_id] + if edge in self._edge_metadata: + del self._edge_metadata[edge] + + # Remove from adjacency lists + for node in edge: + if node in self._adj and edge_id in self._adj[node]: + self._adj[node].remove(edge_id) + + # Remove from the edge list + del self._edge_list[edge] + + def get_edges( + self, + order=None, + size=None, + up_to=False, + subhypergraph=False, + keep_isolated_nodes=False, + metadata=False, + ): + """Get edges from the hypergraph with various filtering options.""" + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + if not subhypergraph and keep_isolated_nodes: + raise ValueError("Cannot keep nodes if not returning subhypergraphs.") + + if order is None and size is None: + edges = list(self._edge_list.keys()) + else: + if size is not None: + order = size - 1 + if not up_to: + edges = [ + edge + for edge in list(self._edge_list.keys()) + if len(edge) - 1 == order + ] + else: + edges = [ + edge + for edge in list(self._edge_list.keys()) + if len(edge) - 1 <= order + ] + + edge_metadata = [self.get_edge_metadata(edge) for edge in edges] + edge_weights = [self.get_weight(edge) for edge in edges] if self._weighted else None + if subhypergraph and keep_isolated_nodes: + h = Hypergraph(weighted=self._weighted) + nodes = list(self.get_nodes()) + node_metadata = [self.get_node_metadata(node) for node in nodes] + h.add_nodes(nodes, metadata=node_metadata) + h.add_edges(edges, weights=edge_weights, metadata=edge_metadata) + return h + + elif subhypergraph: + h = Hypergraph(weighted=self._weighted) + h.add_edges(edges, weights=edge_weights, metadata=edge_metadata) + return h + + else: + return ( + edges + if not metadata + else {edge: self.get_edge_metadata(edge) for edge in edges} + ) + + def get_incident_edges(self, node, order: int = None, size: int = None): + """ + Get the incident hyperedges of a node in the hypergraph. + + Parameters + ---------- + node : object + The node of interest. + order : int + The order of the hyperedges to consider. + size : int + The size of the hyperedges to consider. + + Returns + ------- + list + The incident hyperedges of the node. + + Raises + ------ + ValueError + If the node is not in the hypergraph. + """ + if node not in self._adj: + raise ValueError("Node {} not in hypergraph.".format(node)) + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + if order is None and size is None: + return list( + [self._reverse_edge_list[edge_id] for edge_id in self._adj[node]] + ) + else: + if order is None: + order = size - 1 + return list( + [ + self._reverse_edge_list[edge_id] + for edge_id in self._adj[node] + if len(self._reverse_edge_list[edge_id]) - 1 == order + ] + ) + + # ============================================================================= + # Hypergraph-Specific Utility Methods + # ============================================================================= + + def _restructure_query_edge(self, k: Tuple[Tuple, Any]): + """ + An implementation-specific helper for modifying a query edge + prior to metadata retrieval. + """ + return tuple(sorted(k)) + + def add_empty_edge(self, name, metadata): + """Add an empty edge with metadata.""" + if name not in self._empty_edges: + self._empty_edges[name] = metadata + else: + raise ValueError("Edge {} already in hypergraph.".format(name)) + + # ============================================================================= + # Subgraph Methods + # ============================================================================= + + def subhypergraph(self, nodes: list): + """ + Return a subhypergraph induced by the nodes in the list. + + Parameters + ---------- + nodes : list + List of nodes to be included in the subhypergraph. + + Returns + ------- + Hypergraph + Subhypergraph induced by the nodes in the list. + """ + h = Hypergraph(weighted=self._weighted) + h.add_nodes(nodes) + for node in nodes: + h.set_node_metadata(node, self.get_node_metadata(node)) + for edge in self._edge_list: + if set(edge).issubset(set(nodes)): + if self._weighted: + h.add_edge( + edge, + weight=self.get_weight(edge), + metadata=self.get_edge_metadata(edge), + ) + else: + h.add_edge(edge, metadata=self.get_edge_metadata(edge)) + return h + + def subhypergraph_by_orders( + self, orders: list = None, sizes: list = None, keep_nodes=True + ): + """Return a subhypergraph induced by the edges of the specified orders.""" + if orders is None and sizes is None: + raise ValueError( + "At least one between orders and sizes should be specified" + ) + if orders is not None and sizes is not None: + raise ValueError("Order and size cannot be both specified.") + + h = Hypergraph(weighted=self.is_weighted()) + if keep_nodes: + h.add_nodes(node_list=list(self.get_nodes())) + for node in self.get_nodes(): + h.set_node_metadata(node, self.get_node_metadata(node)) + + if sizes is None: + sizes = [order + 1 for order in orders] + + for size in sizes: + edges = self.get_edges(size=size) + for edge in edges: + if h.is_weighted(): + h.add_edge( + edge, + self.get_weight(edge), + self.get_edge_metadata(edge) + ) + else: + h.add_edge( + edge, + metadata=self.get_edge_metadata(edge) + ) + + return h + + def subhypergraph_largest_component(self, size=None, order=None): + """ + Returns a subhypergraph induced by the nodes in the largest component of the hypergraph. + """ + nodes = self.largest_component(size=size, order=order) + return self.subhypergraph(nodes) + + # ============================================================================= + # Projections and Transformations + # ============================================================================= + + def to_line_graph(self, distance="intersection", s: int = 1, weighted=False): + from hypergraphx.representations.projections import line_graph + return line_graph(self, distance, s, weighted) + + # ============================================================================= + # Incidence Metadata (specific implementation for undirected hypergraphs) + # ============================================================================= + + def set_incidence_metadata(self, edge, node, metadata): + """Set incidence metadata for a specific edge-node pair.""" + edge = self._canon_edge(edge) + if edge not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + self._incidences_metadata[(edge, node)] = metadata + + def get_incidence_metadata(self, edge, node): + """Get incidence metadata for a specific edge-node pair.""" + edge = self._canon_edge(edge) + if edge not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + return self._incidences_metadata.get((edge, node), {}) + + def get_all_incidences_metadata(self): + """Get all incidence metadata.""" + return {k: v for k, v in self._incidences_metadata.items()} + + # ============================================================================= + # Utility Methods + # ============================================================================= + + def clear(self): + """Clear all data from the hypergraph.""" + super().clear() # Calls IUndirectedHypergraph.clear() which calls IHypergraph.clear() + self._empty_edges.clear() + + # ============================================================================= + # Serialization Support + # ============================================================================= + + def expose_data_structures(self): + """ + Expose the internal data structures of the hypergraph for serialization. + + Returns + ------- + dict + A dictionary containing all internal attributes of the hypergraph. + """ + base_data = super().expose_data_structures() + base_data.update({ + "type": "Hypergraph", + "empty_edges": self._empty_edges, + }) + return base_data + + def populate_from_dict(self, data): + """ + Populate the attributes of the hypergraph from a dictionary. + + Parameters + ---------- + data : dict + A dictionary containing the attributes to populate the hypergraph. + """ + super().populate_from_dict(data) + self._empty_edges = data.get("empty_edges", {}) + + def expose_attributes_for_hashing(self): + """ + Expose relevant attributes for hashing specific to Hypergraph. + + Returns + ------- + dict + A dictionary containing key attributes. + """ + edges = [] + for edge in sorted(self._edge_list.keys()): + sorted_edge = sorted(edge) + edge_id = self._edge_list[edge] + edges.append( + { + "nodes": sorted_edge, + "weight": self._weights.get(edge_id, 1), + "metadata": self.get_edge_metadata(edge) + } + ) + + nodes = [] + for node in sorted(self._adj.keys()): + nodes.append({"node": node, "metadata": self._node_metadata[node]}) + + return { + "type": "Hypergraph", + "weighted": self._weighted, + "hypergraph_metadata": self._hypergraph_metadata, + "edges": edges, + "nodes": nodes, + } \ No newline at end of file diff --git a/hypergraphx/core/IHypergraph.py b/hypergraphx/core/IHypergraph.py new file mode 100644 index 0000000..c702621 --- /dev/null +++ b/hypergraphx/core/IHypergraph.py @@ -0,0 +1,820 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Tuple, Optional, Any +import copy +from sklearn.preprocessing import LabelEncoder +from collections import Counter + + +class IHypergraph(ABC): + """ + Abstract base class defining the common interface for all hypergraph implementations. + + This class specifies the required properties and methods that must be implemented + by all hypergraph types: Hypergraph, TemporalHypergraph, MultiplexHypergraph, and DirectedHypergraph. + + Contains functionality common to both directed and undirected hypergraphs. + """ + + def __init__( + self, + edge_list: Optional[List]=None, + weighted: bool = False, + weights:Optional[List[int]]=None, + hypergraph_metadata: Optional[Dict] = None, + node_metadata: Optional[Dict] = None, + edge_metadata: Optional[List[Dict]] = None + ): + """ + Initialize a Hypergraph. + + Parameters + ---------- + edge_list : list, optional + A list of hyperedges. Format varies by implementation. + weighted : bool, optional + Indicates whether the hypergraph is weighted. Default is False. + weights : list of floats, optional + A list of weights corresponding to each edge in `edge_list`. Required if `weighted` is True. + hypergraph_metadata : dict, optional + Metadata for the hypergraph. Default is an empty dictionary. + node_metadata : dict, optional + A dictionary of metadata for nodes, where keys are node identifiers and values are metadata dictionaries. + edge_metadata : list of dicts, optional + A list of metadata dictionaries corresponding to the edges in `edge_list`. + + Raises + ------ + ValueError + If `edge_list` and `weights` have mismatched lengths when `weighted` is True. + """ + # Initialize hypergraph metadata + self._hypergraph_metadata = hypergraph_metadata or dict() + self._hypergraph_metadata.update({"weighted": weighted}) + + self._weighted:bool = weighted + self._weights:dict = dict() + self._node_metadata:Dict[Any, Dict] = node_metadata or dict() + self._edge_metadata:Dict[Tuple, Dict] = { + edge_list[i]: edge_metadata[i] + for i in range(len(edge_list)) + } if edge_metadata and edge_list else dict() + + # store _edge_list and _reverse_edge_list as dictionaries + # keys of _edge_list are edges + # values of _edge_list are edge id's, ie integers + self._edge_list:dict = dict() + self._reverse_edge_list:dict = dict() + self._next_edge_id:int = 0 + + self._incidences_metadata = {} + + # ============================================================================= + # Node Management (Shared Implementation) + # ============================================================================= + + def add_node(self, + node: Any, + metadata: Optional[Dict] = None) -> None: + """ + Add a node to the hypergraph. If the node is already in the hypergraph, nothing happens. + + Parameters + ---------- + node : object + The node to add. + metadata : dict, optional + Metadata for the node. + + Returns + ------- + None + """ + if metadata is None: + metadata = {} + # Implementation varies by subclass due to different adjacency structures + if node not in self._node_metadata: + self._node_metadata[node] = metadata + + def add_nodes(self, + node_list: List[Any], + metadata: Optional[List | Dict] = None) -> None: + """ + Add a list of nodes to the hypergraph. + + Parameters + ---------- + node_list : list + The list of nodes to add. + metadata : list or dict + The list of nodes' metadata to add. + + Returns + ------- + None + """ + if metadata is None: + metadata = {} + # if the metadata was provided in list form, convert it to a dict + if isinstance(metadata, List): + if len(node_list) == len(metadata): + metadata = { + node_list[i]: metadata[i] + for i in range(len(node_list)) + } + else: + raise ValueError(f"len({node_list}) != len({metadata})") + + for node in node_list: + self.add_node(node, metadata=metadata.get(node)) + + @abstractmethod + def get_nodes(self, metadata: bool = False): + """ + Get all nodes in the hypergraph. + + Parameters + ---------- + metadata : bool, optional + If True, return node metadata dictionary. If False, return list of nodes. + + Returns + ------- + list or dict + List of nodes or dictionary of node metadata. + """ + pass + + @abstractmethod + def remove_node(self, node: Any, keep_edges: bool = False) -> None: + """ + Remove a node from the hypergraph. + + Parameters + ---------- + node : object + The node to remove. + keep_edges : bool, optional + If True, edges incident to the node are kept but updated to exclude the node. + If False, edges incident to the node are removed entirely. Default is False. + + Raises + ------ + ValueError + If the node is not in the hypergraph. + """ + pass + + @abstractmethod + def remove_nodes(self, node_list: List[Any], keep_edges: bool = False) -> None: + """ + Remove a list of nodes from the hypergraph. + + Parameters + ---------- + node_list : list + The list of nodes to remove. + keep_edges : bool, optional + If True, edges incident to the nodes are kept but updated to exclude the nodes. + If False, edges incident to the nodes are removed entirely. Default is False. + + Returns + ------- + None + + Raises + ------ + KeyError + If any of the nodes is not in the hypergraph. + """ + pass + + @abstractmethod + def check_node(self, node: Any) -> bool: + """ + Check if a node exists in the hypergraph. + + Parameters + ---------- + node : object + The node to check. + + Returns + ------- + bool + True if the node exists, False otherwise. + """ + pass + + # ============================================================================= + # Edge Management (Abstract - varies by implementation) + # ============================================================================= + + @abstractmethod + def add_edge(self, edge, *args, **kwargs) -> None: + """ + Add an edge to the hypergraph. + + Note: Signature varies by implementation. + """ + pass + + def add_edges(self, + edge_list:List[Tuple[Tuple, Tuple]], + + weights:List[int]=None, + metadata:List[Dict]=None, + *args, + **kwargs) -> None: + """Add a list of hyperedges to the hypergraph. If a hyperedge is already in the hypergraph, its weight is updated. + + Parameters + ---------- + edge_list : list + The list of hyperedges to add. + edge_layer : list + The list of layers to which the hyperedges belong. + weights : list, optional + The list of weights of the hyperedges. If the hypergraph is weighted, this must be provided. + metadata : list, optional + The list of metadata of the hyperedges. + + Returns + ------- + None + + Raises + ------ + ValueError + If the hypergraph is weighted and no weights are provided or if the hypergraph is not weighted and weights are provided. + """ + if weights is not None and not self._weighted: + print( + "Warning: weights are provided but the hypergraph is not weighted. The hypergraph will be weighted." + ) + self._weighted = True + + if self._weighted and weights is not None: + if len(set(edge_list)) != len(list(edge_list)): + raise ValueError( + "If weights are provided, the edge list must not contain repeated edges." + ) + if len(list(edge_list)) != len(list(weights)): + raise ValueError("The number of edges and weights must be the same.") + + for i, edge in enumerate(edge_list): + self.add_edge( + edge=edge, + weight=( + weights[i] if self._weighted and weights is not None else None + ), + metadata=metadata[i] if metadata is not None else None, + *args, + **kwargs, + ) + + @abstractmethod + def remove_edge(self, edge, *args, **kwargs) -> None: + """ + Remove an edge from the hypergraph. + + Note: Signature varies by implementation. + """ + pass + + @abstractmethod + def remove_edges(self, edge_list) -> None: + """ + Remove multiple edges from the hypergraph. + + Parameters + ---------- + edge_list : list + The list of edges to remove. + + Returns + ------- + None + + Raises + ------ + KeyError + If any edge is not in the hypergraph. + """ + pass + + @abstractmethod + def get_edges(self, *args, **kwargs): + """ + Get edges from the hypergraph. + + Note: Parameters vary by implementation due to different filtering capabilities. + """ + pass + + @abstractmethod + def get_neighbors(self, node, order: int = None, size: int = None): + """ + Get the neighbors of a node in the hypergraph. + + Parameters + ---------- + node : object + The node of interest. + order : int + The order of the hyperedges to consider. + size : int + The size of the hyperedges to consider. + + Returns + ------- + set + The neighbors of the node. + + Raises + ------ + ValueError + If order and size are both specified or neither are specified. + """ + pass + + @abstractmethod + def get_incident_edges(self, node, order: int = None, size: int = None) -> List[Tuple]: + """ + Get the incident edges of a node. + + Parameters + ---------- + node : object + The node of interest. + order : int, optional + The order of the hyperedges to consider. If None, all hyperedges are considered. + size : int, optional + The size of the hyperedges to consider. If None, all hyperedges are considered. + + Returns + ------- + list + The list of incident edges. + """ + pass + + def check_edge(self, edge, *args, **kwargs) -> bool: + """ + Check if an edge exists in the hypergraph. + + Parameters + ---------- + edge : tuple + The edge to check. + + Returns + ------- + bool + True if the edge is in the hypergraph, False otherwise. + + """ + edge = self._canon_edge(edge) + k = self._restructure_query_edge(edge, *args, **kwargs) + return k in self._edge_list + + def get_edge_list(self) -> Dict[Tuple, int]: + """Get the edge list dictionary.""" + return self._edge_list + + def set_edge_list(self, edge_list: List[Tuple]): + """Set the edge list dictionary.""" + self._edge_list = { + e: i for i, e in enumerate(edge_list) + } + self._next_edge_id = len(edge_list) + + # ============================================================================= + # Weight Management + # ============================================================================= + + def get_weight(self, edge, *args, **kwargs): + """Returns the weight of the specified edge. + + Parameters + ---------- + edge : tuple + The edge to get the weight of. + + Returns + ------- + float + Weight of the specified edge. + """ + edge = self._canon_edge(edge) + k = self._restructure_query_edge(edge, *args, **kwargs) + if k not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(k)) + else: + return self._weights[self._edge_list[k]] + + def set_weight(self, edge, weight, *args, **kwargs) -> None: + """Sets the weight of the specified edge. + + Parameters + ---------- + edge : tuple + The edge to set the weight of. + + weight : float + The weight to set. + + Returns + ------- + None + + Raises + ------ + ValueError + If the edge is not in the hypergraph. + """ + if not self._weighted and weight != 1: + raise ValueError( + "If the hypergraph is not weighted, weight can be 1 or None." + ) + + edge = self._canon_edge(edge) + k = self._restructure_query_edge(edge, *args, **kwargs) + if k not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + else: + self._weights[self._edge_list[k]] = weight + + @abstractmethod + def get_weights(self, order=None, size=None, up_to=False, asdict=False): + """Returns the list of weights of the edges in the hypergraph. If order is specified, it returns the list of weights of the edges of the specified order. + If size is specified, it returns the list of weights of the edges of the specified size. If both order and size are specified, it raises a ValueError. + If up_to is True, it returns the list of weights of the edges of order smaller or equal to the specified order. + + Parameters + ---------- + order : int, optional + Order of the edges to get the weights of. + + size : int, optional + Size of the edges to get the weights of. + + up_to : bool, optional + If True, it returns the list of weights of the edges of order smaller or equal to the specified order. Default is False. + + Returns + ------- + list + List of weights of the edges in the hypergraph. + + Raises + ------ + ValueError + If both order and size are specified. + + """ + pass + + # ============================================================================= + # Structural Information (Shared Implementation) + # ============================================================================= + + def num_nodes(self) -> int: + """ + Returns the number of nodes in the hypergraph. + + Returns + ------- + int + Number of nodes in the hypergraph. + """ + return len(self.get_nodes()) + + @abstractmethod + def num_edges(self) -> int: + """Returns the number of edges in the hypergraph. + + Returns + ------- + int + Number of edges in the hypergraph. + """ + pass + + @abstractmethod + def get_sizes(self) -> List[int]: + """Returns the list of sizes of the hyperedges in the hypergraph. + + Returns + ------- + list + List of sizes of the hyperedges in the hypergraph. + + """ + pass + + def max_size(self) -> int: + """ + Returns the maximum size of the hypergraph. + + Returns + ------- + int + Maximum size of the hypergraph. + """ + sizes = self.get_sizes() + return max(sizes) if sizes else 0 + + def max_order(self) -> int: + """ + Returns the maximum order of the hypergraph. + + Returns + ------- + int + Maximum order of the hypergraph. + """ + return self.max_size() - 1 + + def get_orders(self) -> List[int]: + """ + Get the order of each edge in the hypergraph. + + Returns + ------- + list + A list of integers representing the order of each edge. + """ + return [size - 1 for size in self.get_sizes()] + + def distribution_sizes(self) -> Dict[int, int]: + """ + Returns the distribution of sizes of the hyperedges in the hypergraph. + + Returns + ------- + dict + Distribution of sizes of the hyperedges in the hypergraph. + """ + return dict(Counter(self.get_sizes())) + + @abstractmethod + def is_uniform(self) -> bool: + """ + Check if the hypergraph is uniform, i.e. all hyperedges have the same size. + + Returns + ------- + bool + True if the hypergraph is uniform, False otherwise. + """ + pass + + def is_weighted(self) -> bool: + """ + Check if the hypergraph is weighted. + + Returns + ------- + bool + True if the hypergraph is weighted, False otherwise. + """ + return self._weighted + + # ============================================================================= + # Metadata Management (Shared Implementation) + # ============================================================================= + + # Hypergraph metadata + def get_hypergraph_metadata(self): + """Get hypergraph metadata.""" + return self._hypergraph_metadata + + def set_hypergraph_metadata(self, metadata): + """Set hypergraph metadata.""" + self._hypergraph_metadata = metadata + + def set_attr_to_hypergraph_metadata(self, field, value): + """Set an attribute in hypergraph metadata.""" + self._hypergraph_metadata[field] = value + + def add_attr_to_node_metadata(self, field, value): + """Included for backwards compatibility""" + self.set_attr_to_hypergraph_metadata(field, value) + + # Node metadata + def get_node_metadata(self, node): + """Get metadata for a specific node.""" + if node not in self._node_metadata: + raise ValueError("Node {} not in hypergraph.".format(node)) + return self._node_metadata[node] + + def set_node_metadata(self, node, metadata): + """Set metadata for a specific node.""" + if node not in self._node_metadata: + raise ValueError("Node {} not in hypergraph.".format(node)) + self._node_metadata[node] = metadata + + def get_all_nodes_metadata(self): + """Get metadata for all nodes.""" + return self._node_metadata + + def set_attr_to_node_metadata(self, node, field, value): + """Set an attribute in node metadata.""" + if node not in self._node_metadata: + raise ValueError("Node {} not in hypergraph.".format(node)) + self._node_metadata[node][field] = value + + def add_attr_to_node_metadata(self, node, field, value): + """Included for backwards compatibility""" + self.set_attr_to_node_metadata(node, field, value) + + def remove_attr_from_node_metadata(self, node, field): + """Remove an attribute from node metadata.""" + if node not in self._node_metadata: + raise ValueError("Node {} not in hypergraph.".format(node)) + del self._node_metadata[node][field] + + # Edge metadata + def get_edge_metadata(self, edge, *args, **kwargs) -> dict: + """ + Get metadata for a specific edge. + """ + edge = self._canon_edge(edge) + k = self._restructure_query_edge(edge, *args, **kwargs) + if k not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + return dict(self._edge_metadata[k]) + + def set_edge_metadata(self, edge, metadata:Dict, *args, **kwargs): + edge = self._canon_edge(edge) + k = self._restructure_query_edge(edge, *args, **kwargs) + if k not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + self._edge_metadata[k] = metadata + + def get_all_edges_metadata(self) -> Dict[Tuple, Dict]: + """Get metadata for all edges.""" + return self._edge_metadata + + def set_attr_to_edge_metadata(self, edge, field, value, *args, **kwargs): + edge = self._canon_edge(edge) + k = self._restructure_query_edge(edge, *args, **kwargs) + if k not in self._edge_metadata: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + self._edge_metadata[k][field] = value + + def add_attr_to_edge_metadata(self, edge, field, value, *args, **kwargs): + """Included for backwards compatibility""" + self.set_attr_to_edge_metadata(edge, field, value, *args, **kwargs) + + def remove_attr_from_edge_metadata(self, edge, field, *args, **kwargs): + edge = self._canon_edge(edge) + k = self._restructure_query_edge(edge, *args, **kwargs) + if k not in self._edge_metadata: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + del self._edge_metadata[k][field] + + # Incidence metadata + @abstractmethod + def get_incidence_metadata(self, edge, node): + """Get incidence metadata for a specific edge-node pair.""" + pass + + @abstractmethod + def set_incidence_metadata(self, edge, node, metadata): + """Set incidence metadata for a specific edge-node pair.""" + pass + + @abstractmethod + def get_all_incidences_metadata(self): + """Get all incidence metadata.""" + pass + + @abstractmethod + def _restructure_query_edge(self, k: Tuple[Tuple, Any]): + """ + An implementation-specific helper for modifying a query edge + prior to metadata retrieval. + """ + pass + + # ============================================================================= + # Utility Methods (Shared Implementation) + # ============================================================================= + + @abstractmethod + def _canon_edge(self, edge: Tuple) -> Tuple: + """ + Gets the canonical form of an edge (sorts the inner tuples) + Works for hyperedges but WILL BREAK FOR METAEDGES + TODO: Add recursive canonicalization for future metagraph integration + """ + pass + + @abstractmethod + def clear(self): + """Clear all data from the hypergraph.""" + pass + + def copy(self): + """ + Returns a copy of the hypergraph. + + Returns + ------- + IHypergraph + A copy of the hypergraph. + """ + return copy.deepcopy(self) + + def __str__(self): + """ + Returns a string representation of the hypergraph. + + Returns + ------- + str + A string representation of the hypergraph. + """ + title = "Hypergraph with {} nodes and {} edges.\n".format( + self.num_nodes(), self.num_edges() + ) + details = "Distribution of hyperedge sizes: {}".format( + self.distribution_sizes() + ) + return title + details + + def __len__(self): + """ + Returns the number of edges in the hypergraph. + + Returns + ------- + int + The number of edges in the hypergraph. + """ + return len(self._edge_list) + + def __iter__(self): + """ + Returns an iterator over the edges in the hypergraph. + + Returns + ------- + iterator + An iterator over the edges in the hypergraph. + """ + return iter(self._edge_list.items()) + + # ============================================================================= + # Serialization Support (Abstract - implementation-specific) + # ============================================================================= + + @abstractmethod + def expose_data_structures(self) -> Dict: + """ + Expose the internal data structures of the hypergraph for serialization. + + Returns + ------- + dict + A dictionary containing all internal attributes of the hypergraph. + """ + pass + + @abstractmethod + def populate_from_dict(self, data: Dict) -> None: + """ + Populate the attributes of the hypergraph from a dictionary. + + Parameters + ---------- + data : dict + A dictionary containing the attributes to populate the hypergraph. + """ + pass + + @abstractmethod + def expose_attributes_for_hashing(self) -> dict: + """ + Expose relevant attributes for hashing. + + Returns + ------- + dict + A dictionary containing key attributes for hashing. + """ + pass + + def get_mapping(self): + """ + Map the nodes of the hypergraph to integers in [0, n_nodes). + + Returns + ------- + LabelEncoder + The mapping. + """ + encoder = LabelEncoder() + encoder.fit(self.get_nodes()) + return encoder \ No newline at end of file diff --git a/hypergraphx/core/IUndirectedHypergraph.py b/hypergraphx/core/IUndirectedHypergraph.py new file mode 100644 index 0000000..14cd71c --- /dev/null +++ b/hypergraphx/core/IUndirectedHypergraph.py @@ -0,0 +1,599 @@ +import warnings +from abc import abstractmethod +from typing import Tuple, Any, List, Dict, Optional, Set + +from hypergraphx.core.IHypergraph import IHypergraph + + +class IUndirectedHypergraph(IHypergraph): + """ + Abstract base class for undirected hypergraphs that provides common functionality + for adjacency list management, canonical edge handling, and shared operations. + + This class serves as an intermediate layer between IHypergraph and concrete + implementations like Hypergraph, MultiplexHypergraph, and TemporalHypergraph. + """ + + def __init__( + self, + edge_list: Optional[List] = None, + weighted: bool = False, + weights: Optional[List[int]] = None, + hypergraph_metadata: Optional[Dict] = None, + node_metadata: Optional[Dict] = None, + edge_metadata: Optional[List[Dict]] = None + ): + """ + Initialize the undirected hypergraph base class. + + Parameters + ---------- + edge_list : list of tuples, optional + A list of hyperedges, where each hyperedge is represented as a tuple of nodes. + weighted : bool, optional + Indicates whether the hypergraph is weighted. Default is False. + weights : list of floats, optional + A list of weights corresponding to each edge in `edge_list`. + hypergraph_metadata : dict, optional + Metadata for the hypergraph. Default is an empty dictionary. + node_metadata : dict, optional + A dictionary of metadata for nodes. + edge_metadata : list of dicts, optional + A list of metadata dictionaries corresponding to the edges. + """ + super().__init__( + edge_list=edge_list, + weighted=weighted, + weights=weights, + hypergraph_metadata=hypergraph_metadata, + node_metadata=node_metadata, + edge_metadata=edge_metadata + ) + + # Initialize adjacency list - common to all undirected hypergraph implementations + self._adj = {} + + # ============================================================================= + # Common Node Management Implementation + # ============================================================================= + + def add_node(self, node: Any, metadata: Optional[Dict] = None) -> None: + """ + Add a node to the hypergraph. If the node is already in the hypergraph, nothing happens. + + Parameters + ---------- + node : object + The node to add. + metadata : dict, optional + Metadata for the node. + """ + # Call parent implementation for metadata handling + super().add_node(node, metadata) + + # Add to adjacency list if not already present + if node not in self._adj: + self._adj[node] = [] + + def get_nodes(self, metadata: bool = False): + """ + Get all nodes in the hypergraph. + + Parameters + ---------- + metadata : bool, optional + If True, return node metadata dictionary. If False, return list of nodes. + + Returns + ------- + list or dict + List of nodes or dictionary of node metadata. + """ + if metadata: + return {node: self.get_node_metadata(node) for node in self._adj.keys()} + else: + return list(self._adj.keys()) + + def check_node(self, node: Any) -> bool: + """ + Check if a node exists in the hypergraph. + + Parameters + ---------- + node : object + The node to check. + + Returns + ------- + bool + True if the node exists, False otherwise. + """ + return node in self._adj + + def remove_nodes(self, node_list: List[Any], keep_edges: bool = False) -> None: + """ + Remove a list of nodes from the hypergraph. + + Parameters + ---------- + node_list : list + The list of nodes to remove. + keep_edges : bool, optional + If True, edges incident to the nodes are kept but updated to exclude the nodes. + If False, edges incident to the nodes are removed entirely. Default is False. + """ + for node in node_list: + self.remove_node(node, keep_edges=keep_edges) + + # ============================================================================= + # Common Edge Management Implementation + # ============================================================================= + + def add_edges(self, edge_list: List, weights: Optional[List] = None, metadata: Optional[List[Dict]] = None, **kwargs) -> None: + """ + Add multiple edges to the hypergraph. + + Parameters + ---------- + edge_list : list + The list of edges to add. + weights : list, optional + The list of weights for the edges. + metadata : list, optional + The list of metadata dictionaries for the edges. + **kwargs + Additional parameters specific to subclass implementations. + """ + if weights is not None and not self._weighted: + warnings.warn( + "Weights are provided but the hypergraph is not weighted. The weights will be ignored.", + UserWarning, + ) + self._weighted = True + + if self._weighted and weights is not None: + if len(set(edge_list)) != len(list(edge_list)): + raise ValueError( + "If weights are provided, the edge list must not contain repeated edges." + ) + if len(list(edge_list)) != len(list(weights)): + raise ValueError("The number of edges and weights must be the same.") + + for i, edge in enumerate(edge_list): + weight = weights[i] if self._weighted and weights is not None else None + edge_metadata = metadata[i] if metadata is not None else None + self._add_edge_implementation(edge, weight, edge_metadata, **kwargs) + + @abstractmethod + def _add_edge_implementation(self, edge, weight, metadata, **kwargs): + """ + Abstract method for adding a single edge. Must be implemented by subclasses. + + Parameters + ---------- + edge : tuple + The edge to add. + weight : float, optional + The weight of the edge. + metadata : dict, optional + The metadata of the edge. + **kwargs + Additional parameters specific to subclass implementations. + """ + pass + + def remove_edges(self, edge_list: List) -> None: + """ + Remove multiple edges from the hypergraph. + + Parameters + ---------- + edge_list : list + The list of edges to remove. + """ + for edge in edge_list: + self.remove_edge(edge) + + # ============================================================================= + # Common Neighbor and Incident Edge Methods + # ============================================================================= + + def get_neighbors(self, node: Any, order: int = None, size: int = None) -> Set: + """ + Get the neighbors of a node in the hypergraph. + + Parameters + ---------- + node : object + The node of interest. + order : int, optional + The order of the hyperedges to consider. + size : int, optional + The size of the hyperedges to consider. + + Returns + ------- + set + The neighbors of the node. + + Raises + ------ + ValueError + If the node is not in the hypergraph or if both order and size are specified. + """ + if node not in self._adj: + raise ValueError("Node {} not in hypergraph.".format(node)) + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + + neigh = set() + edges = self.get_incident_edges(node, order=order, size=size) + for edge in edges: + # Extract nodes from edge (handling different edge formats) + edge_nodes = self._extract_nodes_from_edge(edge) + neigh.update(edge_nodes) + + # Remove the node itself from its neighbors + neigh.discard(node) + return neigh + + @abstractmethod + def _extract_nodes_from_edge(self, edge) -> List: + """ + Extract node list from an edge representation. + Must be implemented by subclasses based on their edge format. + + Parameters + ---------- + edge : object + The edge representation. + + Returns + ------- + list + List of nodes in the edge. + """ + pass + + # ============================================================================= + # Common Structural Information Methods + # ============================================================================= + + def num_edges(self, order: int = None, size: int = None, up_to: bool = False) -> int: + """ + Get the number of edges in the hypergraph. + + Parameters + ---------- + order : int, optional + The order of edges to count. + size : int, optional + The size of edges to count. + up_to : bool, optional + If True, count edges up to the specified order/size. + + Returns + ------- + int + Number of edges matching the criteria. + """ + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + + if order is None and size is None: + return len(self._edge_list) + else: + if size is not None: + order = size - 1 + count = 0 + for edge_key in self._edge_list: + edge_size = self._get_edge_size(edge_key) + edge_order = edge_size - 1 + if not up_to: + if edge_order == order: + count += 1 + else: + if edge_order <= order: + count += 1 + return count + + @abstractmethod + def _get_edge_size(self, edge_key) -> int: + """ + Get the size of an edge given its key representation. + Must be implemented by subclasses based on their edge format. + + Parameters + ---------- + edge_key : object + The edge key representation. + + Returns + ------- + int + Size of the edge. + """ + pass + + def get_sizes(self) -> List[int]: + """ + Get the sizes of all edges in the hypergraph. + + Returns + ------- + list + List of edge sizes. + """ + return [self._get_edge_size(edge_key) for edge_key in self._edge_list.keys()] + + def is_uniform(self) -> bool: + """ + Check if the hypergraph is uniform (all edges have the same size). + + Returns + ------- + bool + True if the hypergraph is uniform, False otherwise. + """ + if not self._edge_list: + return True + + sizes = self.get_sizes() + return len(set(sizes)) <= 1 + + # ============================================================================= + # Common Weight Management Methods + # ============================================================================= + + def get_weights(self, order: int = None, size: int = None, up_to: bool = False, asdict: bool = False): + """ + Get weights of edges in the hypergraph. + + Parameters + ---------- + order : int, optional + The order of edges to get weights for. + size : int, optional + The size of edges to get weights for. + up_to : bool, optional + If True, get weights for edges up to the specified order/size. + asdict : bool, optional + If True, return as dictionary mapping edges to weights. + + Returns + ------- + list or dict + Weights of the edges. + """ + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + + if order is None and size is None: + w = { + edge: self._weights[self._edge_list[edge]] for edge in self._edge_list.keys() + } + else: + if size is not None: + order = size - 1 + w = {} + for edge_key in self._edge_list: + edge_size = self._get_edge_size(edge_key) + edge_order = edge_size - 1 + if not up_to: + if edge_order == order: + w[edge_key] = self._weights[self._edge_list[edge_key]] + else: + if edge_order <= order: + w[edge_key] = self._weights[self._edge_list[edge_key]] + + return w if asdict else list(w.values()) + + # ============================================================================= + # Common Utility Methods + # ============================================================================= + + def _canon_edge(self, edge: Tuple) -> Tuple: + """ + Get the canonical form of an edge by sorting its components. + This default implementation works for simple undirected edges. + Subclasses can override for more complex edge structures. + + Parameters + ---------- + edge : tuple + The edge to canonicalize. + + Returns + ------- + tuple + The canonical form of the edge. + """ + return tuple(sorted(edge)) + + def get_adj_dict(self) -> Dict: + """ + Get the adjacency dictionary. + + Returns + ------- + dict + The adjacency dictionary mapping nodes to lists of incident edge IDs. + """ + return self._adj + + def set_adj_dict(self, adj: Dict) -> None: + """ + Set the adjacency dictionary. + + Parameters + ---------- + adj : dict + The adjacency dictionary to set. + """ + self._adj = adj + + def clear(self) -> None: + """Clear all data from the hypergraph.""" + super().clear() + self._adj.clear() + + # ============================================================================= + # Common Analysis Methods (delegated to external modules) + # ============================================================================= + + def degree(self, node: Any, order: int = None, size: int = None): + """Get the degree of a node.""" + from hypergraphx.measures.degree import degree + return degree(self, node, order=order, size=size) + + def degree_sequence(self, order: int = None, size: int = None): + """Get the degree sequence of the hypergraph.""" + from hypergraphx.measures.degree import degree_sequence + return degree_sequence(self, order=order, size=size) + + def degree_distribution(self, order: int = None, size: int = None): + """Get the degree distribution of the hypergraph.""" + from hypergraphx.measures.degree import degree_distribution + return degree_distribution(self, order=order, size=size) + + def isolated_nodes(self, size: int = None, order: int = None): + """Get isolated nodes in the hypergraph.""" + from hypergraphx.utils.cc import isolated_nodes + return isolated_nodes(self, size=size, order=order) + + def is_isolated(self, node: Any, size: int = None, order: int = None): + """Check if a node is isolated.""" + from hypergraphx.utils.cc import is_isolated + return is_isolated(self, node, size=size, order=order) + + # Connected Components + def is_connected(self, size: int = None, order: int = None): + """Check if the hypergraph is connected.""" + from hypergraphx.utils.cc import is_connected + return is_connected(self, size=size, order=order) + + def connected_components(self, size: int = None, order: int = None): + """Get connected components of the hypergraph.""" + from hypergraphx.utils.cc import connected_components + return connected_components(self, size=size, order=order) + + def node_connected_component(self, node: Any, size: int = None, order: int = None): + """Get the connected component containing a specific node.""" + from hypergraphx.utils.cc import node_connected_component + return node_connected_component(self, node, size=size, order=order) + + def num_connected_components(self, size: int = None, order: int = None): + """Get the number of connected components.""" + from hypergraphx.utils.cc import num_connected_components + return num_connected_components(self, size=size, order=order) + + def largest_component(self, size: int = None, order: int = None): + """Get the largest connected component.""" + from hypergraphx.utils.cc import largest_component + return largest_component(self, size=size, order=order) + + def largest_component_size(self, size: int = None, order: int = None): + """Get the size of the largest connected component.""" + from hypergraphx.utils.cc import largest_component_size + return largest_component_size(self, size=size, order=order) + + # Matrix operations + def binary_incidence_matrix(self, return_mapping: bool = False): + """Get the binary incidence matrix.""" + from hypergraphx.linalg import binary_incidence_matrix + return binary_incidence_matrix(self, return_mapping) + + def incidence_matrix(self, return_mapping: bool = False): + """Get the incidence matrix.""" + from hypergraphx.linalg import incidence_matrix + return incidence_matrix(self, return_mapping) + + def adjacency_matrix(self, return_mapping: bool = False): + """Get the adjacency matrix.""" + from hypergraphx.linalg import adjacency_matrix + return adjacency_matrix(self, return_mapping) + + def dual_random_walk_adjacency(self, return_mapping: bool = False): + """Get the dual random walk adjacency matrix.""" + from hypergraphx.linalg import dual_random_walk_adjacency + return dual_random_walk_adjacency(self, return_mapping) + + def adjacency_factor(self, t: int = 0): + """Get the adjacency factor.""" + from hypergraphx.linalg import adjacency_factor + return adjacency_factor(self, t) + + # ============================================================================= + # Common Serialization Support + # ============================================================================= + + def expose_data_structures(self) -> Dict: + """ + Expose the internal data structures for serialization. + Base implementation that subclasses can extend. + + Returns + ------- + dict + A dictionary containing the internal data structures. + """ + base_data = super().expose_data_structures() + base_data["_adj"] = self._adj + return base_data + + def populate_from_dict(self, data: Dict) -> None: + """ + Populate the attributes from a dictionary. + Base implementation that subclasses can extend. + + Parameters + ---------- + data : dict + A dictionary containing the attributes to populate. + """ + super().populate_from_dict(data) + self._adj = data.get("_adj", {}) + + # ============================================================================= + # Abstract Methods That Must Be Implemented by Subclasses + # ============================================================================= + + @abstractmethod + def remove_node(self, node: Any, keep_edges: bool = False) -> None: + """ + Remove a node from the hypergraph. + Must be implemented by subclasses due to edge format differences. + """ + pass + + @abstractmethod + def add_edge(self, edge, weight=None, metadata=None, **kwargs) -> None: + """ + Add an edge to the hypergraph. + Must be implemented by subclasses due to edge format differences. + """ + pass + + @abstractmethod + def remove_edge(self, edge, **kwargs) -> None: + """ + Remove an edge from the hypergraph. + Must be implemented by subclasses due to edge format differences. + """ + pass + + @abstractmethod + def get_edges(self, metadata: bool = False, **kwargs): + """ + Get edges from the hypergraph. + Must be implemented by subclasses due to edge format differences. + """ + pass + + @abstractmethod + def get_incident_edges(self, node: Any, order: int = None, size: int = None): + """ + Get incident edges of a node. + Must be implemented by subclasses due to edge format differences. + """ + pass \ No newline at end of file diff --git a/hypergraphx/core/MultiplexHypergraph.py b/hypergraphx/core/MultiplexHypergraph.py new file mode 100644 index 0000000..faa03ac --- /dev/null +++ b/hypergraphx/core/MultiplexHypergraph.py @@ -0,0 +1,808 @@ +from typing import Tuple, Any, List, Dict, Optional + +from hypergraphx.core.IUndirectedHypergraph import IUndirectedHypergraph +from hypergraphx import Hypergraph + + +class MultiplexHypergraph(IUndirectedHypergraph): + """ + A Multiplex Hypergraph is a hypergraph where hyperedges are organized into multiple layers. + Each layer shares the same node-set and represents a specific context or relationship between nodes, and hyperedges can + have weights and metadata specific to their layer. + + This implementation inherits from IUndirectedHypergraph to leverage common functionality. + """ + + def __init__( + self, + edge_list: Optional[List]=None, + edge_layer: Optional[List]=None, + weighted: bool = False, + weights: Optional[List[int]]=None, + hypergraph_metadata: Optional[Dict] = None, + node_metadata: Optional[Dict] = None, + edge_metadata: Optional[List[Dict]] = None + ): + """ + Initialize a Multiplex Hypergraph with optional edges, layers, weights, and metadata. + + Parameters + ---------- + edge_list : list of tuples, optional + A list of edges where each edge is represented as a tuple of nodes. + If `edge_layer` is not provided, each tuple in `edge_list` should have + the format `(edge, layer)`, where `edge` is itself a tuple of nodes. + edge_layer : list of str, optional + A list of layer names corresponding to each edge in `edge_list`. + weighted : bool, optional + Indicates whether the hypergraph is weighted. Default is False. + weights : list of float, optional + A list of weights for each edge in `edge_list`. Must be provided if `weighted` is True. + hypergraph_metadata : dict, optional + Metadata for the hypergraph as a whole. Default is an empty dictionary. + node_metadata : dict, optional + A dictionary of metadata for nodes, where keys are node identifiers and values are metadata dictionaries. + edge_metadata : list of dict, optional + A list of metadata dictionaries for each edge in `edge_list`. + + Raises + ------ + ValueError + If `edge_list` and `edge_layer` have mismatched lengths. + If `edge_list` contains improperly formatted edges when `edge_layer` is None. + """ + # Update hypergraph metadata with multiplex-specific info + multiplex_metadata = hypergraph_metadata or {} + multiplex_metadata.update({"type": "MultiplexHypergraph"}) + + # Call parent constructor + super().__init__( + edge_list=None, # We'll handle edge_list ourselves + weighted=weighted, + weights=None, # We'll handle weights ourselves + hypergraph_metadata=multiplex_metadata, + node_metadata=node_metadata, + edge_metadata=None # We'll handle edge_metadata ourselves + ) + + # Initialize multiplex-specific attributes + self._existing_layers = set() + self._incidence_metadata = {} # Store incidence metadata + + # Handle edge and layer consistency + if edge_list is not None and edge_layer is None: + # Extract layers from edge_list if layer information is embedded + if all(isinstance(edge, tuple) and len(edge) == 2 for edge in edge_list): + edge_layer = [edge[1] for edge in edge_list] + edge_list = [edge[0] for edge in edge_list] + else: + raise ValueError( + "If edge_layer is not provided, edge_list must contain tuples of the form (edge, layer)." + ) + + if edge_list is not None: + if edge_layer is not None and len(edge_list) != len(edge_layer): + raise ValueError("Edge list and edge layer must have the same length.") + self.add_edges( + edge_list, + weights=weights, + metadata=edge_metadata, + edge_layer=edge_layer, + ) + + # ============================================================================= + # Implementation of Abstract Methods from IUndirectedHypergraph + # ============================================================================= + + def _add_edge_implementation(self, edge, weight, metadata, **kwargs): + """ + Implementation of abstract method for adding a single edge. + + Parameters + ---------- + edge : tuple + The edge to add. + weight : float, optional + The weight of the edge. + metadata : dict, optional + The metadata of the edge. + **kwargs + Must contain 'edge_layer' parameter for MultiplexHypergraph. + """ + edge_layer = kwargs.get('edge_layer') + if edge_layer is None: + raise ValueError("edge_layer must be provided for MultiplexHypergraph") + + # This method is called for each individual edge, so edge_layer should be a single layer + if isinstance(edge_layer, list): + raise ValueError("_add_edge_implementation expects a single layer, not a list") + + self.add_edge(edge, edge_layer, weight=weight, metadata=metadata) + + def _extract_nodes_from_edge(self, edge) -> List: + """ + Extract node list from an edge representation. + For MultiplexHypergraph, edges are tuples of (nodes, layer). + + Parameters + ---------- + edge : tuple + The edge representation ((nodes...), layer). + + Returns + ------- + list + List of nodes in the edge. + """ + if isinstance(edge, tuple) and len(edge) == 2: + nodes, layer = edge + return list(nodes) + else: + # Handle case where edge might be just nodes + return list(edge) + + def _get_edge_size(self, edge_key) -> int: + """ + Get the size of an edge given its key representation. + For MultiplexHypergraph, edge keys are ((nodes...), layer). + + Parameters + ---------- + edge_key : tuple + The edge key representation ((nodes...), layer). + + Returns + ------- + int + Size of the edge. + """ + if isinstance(edge_key, tuple) and len(edge_key) == 2: + nodes, layer = edge_key + return len(nodes) + else: + return len(edge_key) + + # ============================================================================= + # Incidence Metadata Methods Implementation + # ============================================================================= + + def get_all_incidences_metadata(self) -> Dict: + """ + Get all incidence metadata for the hypergraph. + + Returns + ------- + dict + Dictionary mapping (node, edge_key) tuples to their incidence metadata. + """ + return self._incidence_metadata.copy() + + def get_incidence_metadata(self, node: Any, edge: Tuple, layer: str = None) -> Dict: + """ + Get the incidence metadata for a specific node-edge pair. + + Parameters + ---------- + node : object + The node in the incidence. + edge : tuple + The edge nodes or full edge representation. + layer : str, optional + The layer of the edge. Required if edge format doesn't include layer. + + Returns + ------- + dict + The incidence metadata dictionary. + """ + # Determine the edge key format + if layer is None: + if isinstance(edge, tuple) and len(edge) == 2 and isinstance(edge[1], str): + nodes, layer = edge + edge_key = (self._canon_edge(nodes), layer) + else: + raise ValueError("Layer must be provided or edge must be in format ((nodes...), layer)") + else: + edge_key = (self._canon_edge(edge), layer) + + incidence_key = (node, edge_key) + return self._incidence_metadata.get(incidence_key, {}) + + def set_incidence_metadata(self, node: Any, edge: Tuple, metadata: Dict, layer: str = None) -> None: + """ + Set the incidence metadata for a specific node-edge pair. + + Parameters + ---------- + node : object + The node in the incidence. + edge : tuple + The edge nodes or full edge representation. + metadata : dict + The metadata dictionary to set. + layer : str, optional + The layer of the edge. Required if edge format doesn't include layer. + """ + # Determine the edge key format + if layer is None: + if isinstance(edge, tuple) and len(edge) == 2 and isinstance(edge[1], str): + nodes, layer = edge + edge_key = (self._canon_edge(nodes), layer) + else: + raise ValueError("Layer must be provided or edge must be in format ((nodes...), layer)") + else: + edge_key = (self._canon_edge(edge), layer) + + # Verify the edge exists in the hypergraph + if edge_key not in self._edge_list: + raise ValueError(f"Edge {edge_key} not in hypergraph.") + + # Verify the node is part of the edge + edge_nodes, _ = edge_key + if node not in edge_nodes: + raise ValueError(f"Node {node} is not part of edge {edge_key}.") + + incidence_key = (node, edge_key) + self._incidence_metadata[incidence_key] = metadata + + # ============================================================================= + # MultiplexHypergraph-Specific Node Management Implementation + # ============================================================================= + + def remove_node(self, node: Any, keep_edges: bool = False) -> None: + """ + Remove a node from the multiplex hypergraph. + + Parameters + ---------- + node : object + The node to remove. + keep_edges : bool, optional + If True, edges incident to the node are kept but updated to exclude the node. + If False, edges incident to the node are removed entirely. Default is False. + + Raises + ------ + ValueError + If the node is not in the hypergraph. + """ + if node not in self._adj: + raise ValueError(f"Node {node} not in hypergraph.") + + edges_to_process = list(self._adj[node]) + + # Clean up incidence metadata for this node + keys_to_remove = [key for key in self._incidence_metadata.keys() if key[0] == node] + for key in keys_to_remove: + del self._incidence_metadata[key] + + if keep_edges: + for edge_id in edges_to_process: + edge, layer = self._reverse_edge_list[edge_id] + updated_edge = tuple(n for n in edge if n != node) + + # Get current metadata and weight before removing + current_weight = self._weights.get(edge_id, 1) + current_metadata = self.get_edge_metadata(edge, layer) + + self.remove_edge((edge, layer)) + if updated_edge: + self.add_edge( + updated_edge, + layer, + weight=current_weight, + metadata=current_metadata, + ) + else: + for edge_id in edges_to_process: + edge, layer = self._reverse_edge_list[edge_id] + self.remove_edge((edge, layer)) + + del self._adj[node] + if node in self._node_metadata: + del self._node_metadata[node] + + # ============================================================================= + # MultiplexHypergraph-Specific Edge Management Implementation + # ============================================================================= + + def add_edge(self, edge, layer, weight=None, metadata=None) -> None: + """Add a hyperedge to the hypergraph. If the hyperedge is already in the hypergraph, its weight is updated. + + Parameters + ---------- + edge : tuple + The hyperedge to add. + layer : str + The layer to which the hyperedge belongs. + weight : float, optional + The weight of the hyperedge. If the hypergraph is weighted, this must be provided. + metadata : dict, optional + The metadata of the hyperedge. + + Returns + ------- + None + + Raises + ------ + ValueError + If the hypergraph is weighted and no weight is provided or if the hypergraph is not weighted and a weight is provided. + """ + if weight is None: + weight = 1 + + if not self._weighted and weight is not None and weight != 1: + raise ValueError( + "If the hypergraph is not weighted, weight can be 1 or None." + ) + + self._existing_layers.add(layer) + + edge = self._canon_edge(edge) + k = (edge, layer) + + if k not in self._edge_list: + e_id = self._next_edge_id + self._reverse_edge_list[e_id] = k + self._edge_list[k] = e_id + self._next_edge_id += 1 + self._weights[e_id] = weight + elif k in self._edge_list and self._weighted: + self._weights[self._edge_list[k]] += weight + + e_id = self._edge_list[k] + + if metadata is None: + metadata = {} + + self._edge_metadata[k] = metadata + + for node in edge: + self.add_node(node) + + for node in edge: + if e_id not in self._adj[node]: + self._adj[node].append(e_id) + + def add_edges(self, edge_list, weights=None, metadata=None, edge_layer=None, **kwargs) -> None: + """Add a list of hyperedges to the hypergraph. + + Parameters + ---------- + edge_list : list + The list of hyperedges to add. + weights : list, optional + The list of weights of the hyperedges. + metadata : list, optional + The list of metadata of the hyperedges. + edge_layer : list + The list of layers to which the hyperedges belong. + **kwargs + Additional parameters. + """ + if edge_layer is None: + raise ValueError("edge_layer must be provided for MultiplexHypergraph") + + # Validate lengths + if len(edge_list) != len(edge_layer): + raise ValueError("edge_list and edge_layer must have the same length") + + if weights is not None and len(edge_list) != len(weights): + raise ValueError("edge_list and weights must have the same length") + + if metadata is not None and len(edge_list) != len(metadata): + raise ValueError("edge_list and metadata must have the same length") + + # Process each edge individually with its corresponding layer + for i, edge in enumerate(edge_list): + weight = weights[i] if weights is not None else None + edge_metadata = metadata[i] if metadata is not None else None + layer = edge_layer[i] + + # Call _add_edge_implementation with individual layer + self._add_edge_implementation(edge, weight, edge_metadata, edge_layer=layer) + + def remove_edge(self, edge, layer=None) -> None: + """ + Remove an edge from the multiplex hypergraph. + + Parameters + ---------- + edge : tuple + The edge to remove. Can be either the edge nodes or ((nodes...), layer). + layer : str, optional + The layer of the edge. Required if edge is just the nodes. + + Raises + ------ + ValueError + If the edge is not in the hypergraph. + """ + # Handle both formats: edge as (nodes, layer) or separate edge and layer + if layer is None: + if isinstance(edge, tuple) and len(edge) == 2 and isinstance(edge[1], str): + nodes, layer = edge + edge_key = (self._canon_edge(nodes), layer) + else: + raise ValueError("Layer must be provided or edge must be in format ((nodes...), layer)") + else: + edge_key = (self._canon_edge(edge), layer) + + if edge_key not in self._edge_list: + raise ValueError(f"Edge {edge_key} not in hypergraph.") + + edge_id = self._edge_list[edge_key] + + # Clean up incidence metadata for this edge + keys_to_remove = [key for key in self._incidence_metadata.keys() if key[1] == edge_key] + for key in keys_to_remove: + del self._incidence_metadata[key] + + del self._reverse_edge_list[edge_id] + if edge_id in self._weights: + del self._weights[edge_id] + if edge_key in self._edge_metadata: + del self._edge_metadata[edge_key] + + nodes, layer = edge_key + for node in nodes: + if edge_id in self._adj[node]: + self._adj[node].remove(edge_id) + + del self._edge_list[edge_key] + + def get_edges(self, metadata: bool = False, layer: str = None): + """ + Get edges from the hypergraph. + + Parameters + ---------- + metadata : bool, optional + If True, return edge metadata dictionary. If False, return list of edges. + layer : str, optional + If provided, only return edges from the specified layer. + + Returns + ------- + list or dict + List of edges or dictionary of edge metadata. + """ + if layer is not None: + # Filter edges by layer + filtered_edges = {k: v for k, v in self._edge_list.items() if k[1] == layer} + if metadata: + return { + self._reverse_edge_list[v]: self._edge_metadata[k] + for k, v in filtered_edges.items() + } + else: + return list(filtered_edges.keys()) + else: + if metadata: + return { + self._reverse_edge_list[v]: self._edge_metadata[k] + for k, v in self._edge_list.items() + } + else: + return list(self._edge_list.keys()) + + def get_incident_edges(self, node: Any, order: int = None, size: int = None) -> List[Tuple]: + """ + Get the incident edges of a node. + + Parameters + ---------- + node : object + The node of interest. + order : int, optional + The order of the hyperedges to consider. If None, all hyperedges are considered. + size : int, optional + The size of the hyperedges to consider. If None, all hyperedges are considered. + + Returns + ------- + list + The list of incident edges. + """ + if order is not None and size is not None: + raise ValueError("Cannot specify both order and size") + if order is None and size is None: + target_size = None + elif order is not None: + target_size = order + 1 + else: + target_size = size + + if node not in self._adj: + raise ValueError("Node {} not in hypergraph.".format(node)) + + incident_edges = [] + for edge_id in self._adj[node]: + edge, layer = self._reverse_edge_list[edge_id] + if target_size is None or len(edge) == target_size: + incident_edges.append((edge, layer)) + + return incident_edges + + # ============================================================================= + # MultiplexHypergraph-Specific Weight Management + # ============================================================================= + + def get_weight(self, edge, layer=None): + """Returns the weight of the specified edge. + + Parameters + ---------- + edge : tuple + The edge to get the weight of. + layer : str, optional + The layer of the edge. Required if edge format doesn't include layer. + + Returns + ------- + float + Weight of the specified edge. + """ + if layer is None: + if isinstance(edge, tuple) and len(edge) == 2 and isinstance(edge[1], str): + nodes, layer = edge + k = (self._canon_edge(nodes), layer) + else: + raise ValueError("Layer must be provided or edge must be in format ((nodes...), layer)") + else: + k = (self._canon_edge(edge), layer) + + if k not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(k)) + else: + return self._weights[self._edge_list[k]] + + def set_weight(self, edge, layer, weight) -> None: + """Sets the weight of the specified edge. + + Parameters + ---------- + edge : tuple + The edge to set the weight of. + layer : str + The layer of the edge. + weight : float + The weight to set. + + Returns + ------- + None + + Raises + ------ + ValueError + If the edge is not in the hypergraph. + """ + if not self._weighted and weight != 1: + raise ValueError( + "If the hypergraph is not weighted, weight can be 1 or None." + ) + + k = (self._canon_edge(edge), layer) + + if k not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + else: + self._weights[self._edge_list[k]] = weight + + def get_weights(self, order=None, size=None, up_to=False, asdict=False, layer=None): + """Returns the list of weights of the edges in the hypergraph. + + Parameters + ---------- + order : int, optional + Order of the edges to get the weights of. + size : int, optional + Size of the edges to get the weights of. + up_to : bool, optional + If True, it returns the list of weights of the edges of order smaller or equal to the specified order. + asdict : bool, optional + If True, return as dictionary mapping edges to weights. + layer : str, optional + If provided, only return weights for edges in the specified layer. + + Returns + ------- + list or dict + List or dictionary of weights of the edges in the hypergraph. + + Raises + ------ + ValueError + If both order and size are specified. + """ + if order is not None and size is not None: + raise ValueError("Cannot specify both order and size") + + if order is None and size is None: + target_size = None + elif order is not None: + target_size = order + 1 + else: + target_size = size + + weights = [] + weight_dict = {} + + for edge_key, edge_id in self._edge_list.items(): + nodes, edge_layer = edge_key + + # Filter by layer if specified + if layer is not None and edge_layer != layer: + continue + + edge_size = len(nodes) + + # Filter by size/order + if target_size is not None: + if up_to and edge_size > target_size: + continue + elif not up_to and edge_size != target_size: + continue + + weight = self._weights.get(edge_id, 1) + weights.append(weight) + if asdict: + weight_dict[edge_key] = weight + + return weight_dict if asdict else weights + + # ============================================================================= + # MultiplexHypergraph-Specific Utility Methods + # ============================================================================= + + def _canon_edge(self, edge: Tuple) -> Tuple: + """ + Gets the canonical form of an edge by sorting its components. + For MultiplexHypergraph, handles both simple edges and complex edge structures. + """ + edge = tuple(edge) + + if len(edge) == 2: + if isinstance(edge[0], tuple) and isinstance(edge[1], tuple): + # Sort the inner tuples and return + return (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) + elif not isinstance(edge[0], tuple) and not isinstance(edge[1], tuple): + # Sort the edge itself if it contains IDs (non-tuple elements) + return tuple(sorted(edge)) + + return tuple(sorted(edge)) + + def _restructure_query_edge(self, k: Tuple[Tuple, Any], layer=None): + """ + An implementation-specific helper for modifying a query edge + prior to metadata retrieval. + """ + if layer is not None: + return (k, layer) + return k + + def get_existing_layers(self): + """Get the set of existing layers.""" + return self._existing_layers + + def set_existing_layers(self, existing_layers): + """Set the existing layers.""" + self._existing_layers = existing_layers + + # ============================================================================= + # MultiplexHypergraph-Specific Methods + # ============================================================================= + + def set_dataset_metadata(self, metadata): + """Set dataset-level metadata.""" + self._hypergraph_metadata["multiplex_metadata"] = metadata + + def get_dataset_metadata(self): + """Get dataset-level metadata.""" + return self._hypergraph_metadata.get("multiplex_metadata", {}) + + def set_layer_metadata(self, layer_name, metadata): + """Set metadata for a specific layer.""" + if layer_name not in self._hypergraph_metadata: + self._hypergraph_metadata[layer_name] = {} + self._hypergraph_metadata[layer_name] = metadata + + def get_layer_metadata(self, layer_name): + """Get metadata for a specific layer.""" + return self._hypergraph_metadata.get(layer_name, {}) + + def aggregated_hypergraph(self): + """Create an aggregated hypergraph combining all layers.""" + h = Hypergraph( + weighted=self._weighted, hypergraph_metadata=self._hypergraph_metadata + ) + for node in self.get_nodes(): + h.add_node(node, metadata=self._node_metadata[node]) + for edge in self.get_edges(): + _edge, layer = edge + h.add_edge( + _edge, + weight=self.get_weight(_edge, layer), + metadata=self.get_edge_metadata(_edge, layer), + ) + return h + + # ============================================================================= + # Utility Methods + # ============================================================================= + + def clear(self): + """Clear all data from the hypergraph.""" + super().clear() + self._existing_layers.clear() + self._incidence_metadata.clear() + + # ============================================================================= + # Serialization Support + # ============================================================================= + + def expose_data_structures(self) -> Dict: + """ + Expose the internal data structures of the multiplex hypergraph for serialization. + + Returns + ------- + dict + A dictionary containing all internal attributes of the multiplex hypergraph. + """ + base_data = super().expose_data_structures() + base_data.update({ + "type": "MultiplexHypergraph", + "existing_layers": self._existing_layers, + "incidence_metadata": self._incidence_metadata, + }) + return base_data + + def populate_from_dict(self, data: Dict) -> None: + """ + Populate the attributes of the multiplex hypergraph from a dictionary. + + Parameters + ---------- + data : dict + A dictionary containing the attributes to populate the hypergraph. + """ + super().populate_from_dict(data) + self._existing_layers = data.get("existing_layers", set()) + self._incidence_metadata = data.get("incidence_metadata", {}) + + def expose_attributes_for_hashing(self) -> dict: + """ + Expose relevant attributes for hashing specific to MultiplexHypergraph. + + Returns + ------- + dict + A dictionary containing key attributes. + """ + edges = [] + for edge in sorted(self._edge_list.keys()): + edge = (tuple(sorted(edge[0])), edge[1]) + edge_id = self._edge_list[edge] + edges.append( + { + "nodes": edge, + "weight": self._weights.get(edge_id, 1), + "metadata": self.get_edge_metadata(edge=edge[0], layer=edge[1]), + } + ) + + nodes = [] + for node in sorted(self._node_metadata.keys()): + nodes.append({"node": node, "metadata": self._node_metadata[node]}) + + return { + "type": "MultiplexHypergraph", + "weighted": self._weighted, + "hypergraph_metadata": self._hypergraph_metadata, + "edges": edges, + "nodes": nodes, + "incidence_metadata": self._incidence_metadata, + } \ No newline at end of file diff --git a/hypergraphx/core/TemporalHypergraph.py b/hypergraphx/core/TemporalHypergraph.py new file mode 100644 index 0000000..d70338a --- /dev/null +++ b/hypergraphx/core/TemporalHypergraph.py @@ -0,0 +1,659 @@ +import math +from typing import Tuple, Any, List, Dict, Optional + +from hypergraphx import Hypergraph +from hypergraphx.core.IUndirectedHypergraph import IUndirectedHypergraph + + +def _get_size(edge): + """Get the size of an edge.""" + if len(edge) == 2 and isinstance(edge[0], tuple) and isinstance(edge[1], tuple): + return len(edge[0]) + len(edge[1]) + else: + return len(edge) + + +def _get_order(edge): + """Get the order of an edge.""" + return _get_size(edge) - 1 + + +def _get_nodes(edge): + """Get all nodes from an edge.""" + if len(edge) == 2 and isinstance(edge[0], tuple) and isinstance(edge[1], tuple): + return list(edge[0]) + list(edge[1]) + else: + return list(edge) + + +class TemporalHypergraph(IUndirectedHypergraph): + """ + A Temporal Hypergraph is a hypergraph where each hyperedge is associated with a specific timestamp. + Temporal hypergraphs are useful for modeling systems where interactions between nodes change over time, such as social networks, + communication networks, and transportation systems. + """ + + def __init__( + self, + edge_list: Optional[List]=None, + time_list: Optional[List]=None, + weighted: bool = False, + weights: Optional[List[int]]=None, + hypergraph_metadata: Optional[Dict] = None, + node_metadata: Optional[Dict] = None, + edge_metadata: Optional[List[Dict]] = None + ): + """ + Initialize a Temporal Hypergraph with optional edges, times, weights, and metadata. + + Parameters + ---------- + edge_list : list of tuples, optional + A list of edges where each edge is represented as a tuple of nodes. + If `time_list` is not provided, each tuple in `edge_list` should + have the format `(time, edge)`, where `edge` is itself a tuple of nodes. + time_list : list of int, optional + A list of times corresponding to each edge in `edge_list`. + Must be provided if `edge_list` does not include time information. + weighted : bool, optional + Indicates whether the hypergraph is weighted. Default is False. + weights : list of float, optional + A list of weights for each edge in `edge_list`. Must be provided if `weighted` is True. + hypergraph_metadata : dict, optional + Metadata for the hypergraph as a whole. Default is an empty dictionary. + node_metadata : dict, optional + A dictionary of metadata for nodes, where keys are node identifiers and values are metadata dictionaries. + edge_metadata : list of dict, optional + A list of metadata dictionaries for each edge in `edge_list`. + + Raises + ------ + ValueError + If `edge_list` and `time_list` have mismatched lengths. + If `edge_list` contains improperly formatted edges when `time_list` is None. + If `time_list` is provided without `edge_list`. + """ + # Initialize base class with temporal-specific metadata + temporal_metadata = hypergraph_metadata or {} + temporal_metadata.update({"type": "TemporalHypergraph"}) + + super().__init__( + edge_list=None, # We'll handle edge addition separately + weighted=weighted, + weights=None, + hypergraph_metadata=temporal_metadata, + node_metadata=node_metadata, + edge_metadata=None # We'll handle this separately + ) + + # Handle edge and time list consistency + if edge_list is not None and time_list is None: + # Extract times from the edge list if time information is embedded + if not all( + isinstance(edge, tuple) and len(edge) == 2 for edge in edge_list + ): + raise ValueError( + "If time_list is not provided, edge_list must contain tuples of the form (time, edge)." + ) + time_list = [edge[0] for edge in edge_list] + edge_list = [edge[1] for edge in edge_list] + + if edge_list is None and time_list is not None: + raise ValueError("Edge list must be provided if time list is provided.") + + if edge_list is not None and time_list is not None: + if len(edge_list) != len(time_list): + raise ValueError("Edge list and time list must have the same length.") + self.add_edges( + edge_list, + time_list, + weights=weights, + metadata=edge_metadata + ) + + # ============================================================================= + # Implementation of abstract methods from IUndirectedHypergraph + # ============================================================================= + + def _add_edge_implementation(self, edge, weight, metadata, time=None, **kwargs): + """Implementation of abstract method for adding a single edge.""" + if time is None: + raise ValueError("Time must be provided for temporal hypergraph edges.") + + if not isinstance(time, int): + raise TypeError("Time must be an integer") + + if not self._weighted and weight is not None and weight != 1: + raise ValueError( + "If the hypergraph is not weighted, weight can be 1 or None." + ) + if weight is None: + weight = 1 + + if time < 0: + raise ValueError("Time must be a positive integer") + + _edge = self._canon_edge(edge) + temporal_edge = (time, _edge) + + if temporal_edge not in self._edge_list: + e_id = self._next_edge_id + self._reverse_edge_list[e_id] = temporal_edge + self._edge_list[temporal_edge] = e_id + self._next_edge_id += 1 + self._weights[e_id] = weight + elif temporal_edge in self._edge_list and self._weighted: + self._weights[self._edge_list[temporal_edge]] += weight + + e_id = self._edge_list[temporal_edge] + + if metadata is None: + metadata = {} + self._edge_metadata[temporal_edge] = metadata + + nodes = _get_nodes(_edge) + for node in nodes: + self.add_node(node) + + for node in nodes: + if e_id not in self._adj[node]: + self._adj[node].append(e_id) + + def _extract_nodes_from_edge(self, edge) -> List: + """Extract node list from a temporal edge representation.""" + if isinstance(edge, tuple) and len(edge) == 2: + # Temporal edge format: (time, edge_nodes) + time, edge_nodes = edge + return _get_nodes(edge_nodes) + else: + # Fallback for direct edge nodes + return _get_nodes(edge) + + def _get_edge_size(self, edge_key) -> int: + """Get the size of an edge given its temporal key representation.""" + if isinstance(edge_key, tuple) and len(edge_key) == 2: + # Temporal edge format: (time, edge_nodes) + time, edge_nodes = edge_key + return _get_size(edge_nodes) + else: + return _get_size(edge_key) + + def remove_node(self, node: Any, keep_edges: bool = False) -> None: + """Remove a node from the temporal hypergraph.""" + if node not in self._adj: + raise ValueError(f"Node {node} not in hypergraph.") + + edges_to_process = list(self._adj[node]) + + if keep_edges: + for edge_id in edges_to_process: + time, edge = self._reverse_edge_list[edge_id] + updated_edge = tuple(n for n in edge if n != node) + + self.remove_edge(edge, time) + if updated_edge: + self.add_edge( + updated_edge, + time, + weight=self._weights.get(edge_id, 1), + metadata=self.get_edge_metadata(edge=edge, time=time), + ) + else: + for edge_id in edges_to_process: + time, edge = self._reverse_edge_list[edge_id] + self.remove_edge(edge, time) + + del self._adj[node] + if node in self._node_metadata: + del self._node_metadata[node] + + def add_edge(self, edge, time: int, weight=None, metadata=None) -> None: + """ + Add an edge to the temporal hypergraph. If the edge already exists, the weight is updated. + + Parameters + ---------- + edge : tuple + The edge to add. + time: int + The time at which the edge occurs. + weight: float, optional + The weight of the edge. Default is None. + metadata: dict, optional + Metadata for the edge. Default is an empty dictionary. + + Raises + ------ + TypeError + If time is not an integer. + ValueError + If the hypergraph is not weighted and weight is not None or 1. + """ + self._add_edge_implementation(edge, weight, metadata, time=time) + + def remove_edge(self, edge, time: int) -> None: + """Remove an edge from the temporal hypergraph.""" + _edge = self._canon_edge(edge) + temporal_edge = (time, _edge) + + if temporal_edge not in self._edge_list: + raise ValueError(f"Edge {temporal_edge} not in hypergraph.") + + edge_id = self._edge_list[temporal_edge] + + # Remove edge from reverse lookup and metadata + del self._reverse_edge_list[edge_id] + if edge_id in self._weights: + del self._weights[edge_id] + if temporal_edge in self._edge_metadata.keys(): + del self._edge_metadata[temporal_edge] + + # Remove from adjacency lists + nodes = _get_nodes(_edge) + for node in nodes: + if edge_id in self._adj[node]: + self._adj[node].remove(edge_id) + + del self._edge_list[temporal_edge] + + def get_edges( + self, + time_window=None, + order=None, + size=None, + up_to=False, + metadata=False, + ): + """Get the edges in the temporal hypergraph.""" + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + + edges = [] + if time_window is None: + edges = list(self._edge_list.keys()) + elif isinstance(time_window, tuple) and len(time_window) == 2: + for _t, _edge in sorted(self._edge_list.keys()): + if time_window[0] <= _t < time_window[1]: + edges.append((_t, _edge)) + else: + raise ValueError("Time window must be a tuple of length 2 or None") + + if order is not None or size is not None: + if size is not None: + order = size - 1 + if not up_to: + edges = [edge for edge in edges if len(edge[1]) - 1 == order] + else: + edges = [edge for edge in edges if len(edge[1]) - 1 <= order] + + return ( + edges + if not metadata + else {edge: self.get_edge_metadata(edge=edge[1], time=edge[0]) for edge in edges} + ) + + def get_incident_edges(self, node, order: int = None, size: int = None) -> List[Tuple]: + """Get the incident hyperedges of a node in the hypergraph.""" + if node not in self._adj: + raise ValueError("Node {} not in hypergraph.".format(node)) + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + + if order is None and size is None: + return [self._reverse_edge_list[edge_id] for edge_id in self._adj[node]] + else: + if order is None: + order = size - 1 + return [ + self._reverse_edge_list[edge_id] + for edge_id in self._adj[node] + if len(self._reverse_edge_list[edge_id][1]) - 1 == order + ] + + # ============================================================================= + # Temporal-specific edge management methods + # ============================================================================= + + def add_edges(self, edge_list, time_list, weights=None, metadata=None) -> None: + """Add multiple edges to the temporal hypergraph.""" + if not isinstance(edge_list, list) or not isinstance(time_list, list): + raise TypeError("Edge list and time list must be lists") + + if len(edge_list) != len(time_list): + raise ValueError("Edge list and time list must have the same length") + + if weights is not None and not self._weighted: + print( + "Warning: weights are provided but the hypergraph is not weighted. The hypergraph will be weighted." + ) + self._weighted = True + + if self._weighted and weights is not None: + if len(set(edge_list)) != len(list(edge_list)): + raise ValueError( + "If weights are provided, the edge list must not contain repeated edges." + ) + if len(list(edge_list)) != len(list(weights)): + raise ValueError("The number of edges and weights must be the same.") + + for i, edge in enumerate(edge_list): + self.add_edge( + edge, + time_list[i], + weight=( + weights[i] if self._weighted and weights is not None else None + ), + metadata=metadata[i] if metadata is not None else None, + ) + + def remove_edges(self, edge_list) -> None: + """Remove a list of edges from the hypergraph.""" + for edge in edge_list: + if isinstance(edge, tuple) and len(edge) == 2: + time, edge_nodes = edge + self.remove_edge(edge_nodes, time) + else: + raise ValueError("Edge must be a tuple of (time, edge_nodes)") + + # ============================================================================= + # Override base class methods for temporal-specific behavior + # ============================================================================= + + def get_neighbors(self, node, order: int = None, size: int = None): + """Get the neighbors of a node in the hypergraph.""" + if node not in self._adj: + raise ValueError("Node {} not in hypergraph.".format(node)) + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + + if order is None and size is None: + neigh = set() + edges = self.get_incident_edges(node) + for edge in edges: + neigh.update(_get_nodes(edge[1])) + neigh.discard(node) + return neigh + else: + if order is None: + order = size - 1 + neigh = set() + edges = self.get_incident_edges(node, order=order) + for edge in edges: + neigh.update(_get_nodes(edge[1])) + neigh.discard(node) + return neigh + + def num_edges(self, order: int = None, size: int = None, up_to: bool = False) -> int: + """Get the number of edges in the hypergraph.""" + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + + if order is None and size is None: + return len(self._edge_list) + else: + if size is not None: + order = size - 1 + count = 0 + for edge_key in self._edge_list: + edge_size = self._get_edge_size(edge_key) + edge_order = edge_size - 1 + if not up_to: + if edge_order == order: + count += 1 + else: + if edge_order <= order: + count += 1 + return count + + def get_sizes(self) -> List[int]: + """Get the size of each edge in the hypergraph.""" + return [_get_size(edge[1]) for edge in self._edge_list.keys()] + + def is_uniform(self) -> bool: + """Check if the hypergraph is uniform.""" + if not self._edge_list: + return True + + sizes = self.get_sizes() + return len(set(sizes)) <= 1 + + def get_weights(self, order=None, size=None, up_to=False, asdict=False): + """Get weights of edges in the hypergraph.""" + w = None + if order is not None and size is not None: + raise ValueError("Order and size cannot be both specified.") + + if order is None and size is None: + w = { + edge: self._weights[self._edge_list[edge]] for edge in self.get_edges() + } + + if size is not None: + order = size - 1 + + if w is None: + w = { + edge: self._weights[self._edge_list[edge]] + for edge in self.get_edges(order=order, up_to=up_to) + } + + if asdict: + return w + else: + return list(w.values()) + + # ============================================================================= + # Weight Management (Use base class with temporal edge format) + # ============================================================================= + + def get_weight(self, edge, time: int): + """Get the weight of an edge at a specific time.""" + return super().get_weight(edge, time) + + def set_weight(self, edge, time: int, weight) -> None: + """Set the weight of an edge at a specific time.""" + super().set_weight(edge, weight, time) + + # ============================================================================= + # Temporal-specific methods + # ============================================================================= + + def get_times_for_edge(self, edge): + """Get the times at which a specific set of nodes forms a hyperedge.""" + edge = self._canon_edge(edge) + times = [] + for time, _edge in self._edge_list.keys(): + if _edge == edge: + times.append(time) + return times + + def min_time(self): + """Get the minimum time in the hypergraph.""" + if not self._edge_list: + return None + return min(edge[0] for edge in self._edge_list.keys()) + + def max_time(self): + """Get the maximum time in the hypergraph.""" + if not self._edge_list: + return None + return max(edge[0] for edge in self._edge_list.keys()) + + def aggregate(self, time_window: int): + """Aggregate edges within time windows.""" + if not isinstance(time_window, int) or time_window <= 0: + raise TypeError("Time window must be a positive integer") + + aggregated = {} + node_list = self.get_nodes() + + # Get all edges and determine the max time + sorted_edges = sorted(self.get_edges()) + if not sorted_edges: + return aggregated # Return empty if no edges exist + + max_time = max(edge[0] for edge in sorted_edges) # Maximum time of all edges + + # Initialize time window boundaries + t_start = 0 + t_end = time_window + edges_in_window = [] + num_windows_created = 0 + + edge_index = 0 # Pointer to the current edge in sorted_edges + + while t_start <= max_time: + # Collect edges for the current window + while ( + edge_index < len(sorted_edges) + and t_start <= sorted_edges[edge_index][0] < t_end + ): + edges_in_window.append(sorted_edges[edge_index]) + edge_index += 1 + + # Create the hypergraph for this time window + Hypergraph_t = Hypergraph(weighted=self._weighted) + + # Add edges to the hypergraph + for time, edge_nodes in edges_in_window: + Hypergraph_t.add_edge( + edge_nodes, + metadata=self.get_edge_metadata(edge=edge_nodes, time=time), + weight=self.get_weight(edge_nodes, time), + ) + + # Add all nodes to ensure node consistency + for node in node_list: + Hypergraph_t.add_node(node, metadata=self._node_metadata[node]) + + # Store the finalized hypergraph for this window + aggregated[num_windows_created] = Hypergraph_t + num_windows_created += 1 + + # Advance to the next time window + t_start = t_end + t_end += time_window + edges_in_window = [] # Reset for the next window + + return aggregated + + def subhypergraph( + self, time_window=None, add_all_nodes: bool = False + ) -> dict[int, Hypergraph]: + """Create a hypergraph for each time of the Temporal Hypergraph.""" + edges = self.get_edges() + res = dict() + if time_window is None: + time_window = (-math.inf, math.inf) + if not isinstance(time_window, tuple): + raise ValueError("Time window must be a tuple of length 2 or None") + + for edge in edges: + if time_window[0] <= edge[0] < time_window[1]: + if edge[0] not in res.keys(): + res[edge[0]] = Hypergraph(weighted=self.is_weighted()) + weight = self.get_weight(edge[1], edge[0]) + res[edge[0]].add_edge(edge[1], weight) + if add_all_nodes: + for node in self.get_nodes(): + for k, v in res.items(): + if not v.check_node(node): + v.add_node(node) + + return res + + # ============================================================================= + # Metadata Management (Use base class with temporal edge format) + # ============================================================================= + + def get_incidence_metadata(self, edge, node, time: int = None): + """Get incidence metadata for a specific edge-node pair.""" + edge = self._canon_edge(edge) + k = (time, edge) if time is not None else edge + if k not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + return self._incidences_metadata.get((k, node), {}) + + def set_incidence_metadata(self, edge, node, metadata, time: int = None): + """Set incidence metadata for a specific edge-node pair.""" + edge = self._canon_edge(edge) + k = (time, edge) if time is not None else edge + if k not in self._edge_list: + raise ValueError("Edge {} not in hypergraph.".format(edge)) + self._incidences_metadata[(k, node)] = metadata + + def get_all_incidences_metadata(self): + """Get all incidence metadata.""" + return {k: v for k, v in self._incidences_metadata.items()} + + def _restructure_query_edge(self, k: Tuple[Tuple, Any], time: int): + """Helper for modifying a query edge prior to metadata retrieval.""" + return (time, k) + + # ============================================================================= + # Utility Methods (Override temporal-specific canonical edge handling) + # ============================================================================= + + def _canon_edge(self, edge: Tuple) -> Tuple: + """Canonical form of an edge - sorts inner tuples.""" + edge = tuple(edge) + + if len(edge) == 2: + if isinstance(edge[0], tuple) and isinstance(edge[1], tuple): + # Sort the inner tuples and return + return (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) + elif not isinstance(edge[0], tuple) and not isinstance(edge[1], tuple): + # Sort the edge itself if it contains IDs (non-tuple elements) + return tuple(sorted(edge)) + + return tuple(sorted(edge)) + + # ============================================================================= + # Matrix operations (inherited from base class) + # ============================================================================= + + def temporal_adjacency_matrix(self, return_mapping: bool = False): + """Get the temporal adjacency matrix.""" + from hypergraphx.linalg import temporal_adjacency_matrix + return temporal_adjacency_matrix(self, return_mapping) + + def annealed_adjacency_matrix(self, return_mapping: bool = False): + """Get the annealed adjacency matrix.""" + from hypergraphx.linalg import annealed_adjacency_matrix + return annealed_adjacency_matrix(self, return_mapping) + + # ============================================================================= + # Serialization Support (Override base class methods) + # ============================================================================= + + def expose_data_structures(self) -> Dict: + """Expose the internal data structures for serialization.""" + base_data = super().expose_data_structures() + base_data["type"] = "TemporalHypergraph" + return base_data + + def expose_attributes_for_hashing(self) -> dict: + """Expose relevant attributes for hashing.""" + edges = [] + for edge in sorted(self._edge_list.keys()): + edge = (edge[0], tuple(sorted(edge[1]))) + edge_id = self._edge_list[edge] + edges.append( + { + "nodes": edge, + "weight": self._weights.get(edge_id, 1), + "metadata": self.get_edge_metadata(edge=edge[1], time=edge[0]), + } + ) + + nodes = [] + for node in sorted(self._node_metadata.keys()): + nodes.append({"node": node, "metadata": self._node_metadata[node]}) + + return { + "type": "TemporalHypergraph", + "weighted": self._weighted, + "hypergraph_metadata": self._hypergraph_metadata, + "edges": edges, + "nodes": nodes, + } \ No newline at end of file diff --git a/hypergraphx/core/__init__.py b/hypergraphx/core/__init__.py index 8b13789..e69de29 100644 --- a/hypergraphx/core/__init__.py +++ b/hypergraphx/core/__init__.py @@ -1 +0,0 @@ - diff --git a/hypergraphx/core/hypergraph.py b/hypergraphx/core/hypergraph.py deleted file mode 100644 index 126c64d..0000000 --- a/hypergraphx/core/hypergraph.py +++ /dev/null @@ -1,1211 +0,0 @@ -import copy -import warnings - -from sklearn.preprocessing import LabelEncoder - - -class Hypergraph: - """ - A Hypergraph is a generalization of a graph where an edge (hyperedge) can connect - any number of nodes. It is represented as a set of nodes and a set of hyperedges, - where each hyperedge is a subset of nodes. - """ - - def __init__( - self, - edge_list=None, - weighted=False, - weights=None, - hypergraph_metadata=None, - node_metadata=None, - edge_metadata=None, - ): - """ - Initialize a Hypergraph. - - Parameters - ---------- - edge_list : list of tuples, optional - A list of hyperedges, where each hyperedge is represented as a tuple of nodes. - weighted : bool, optional - Indicates whether the hypergraph is weighted. Default is False. - weights : list of floats, optional - A list of weights corresponding to each edge in `edge_list`. Required if `weighted` is True. - hypergraph_metadata : dict, optional - Metadata for the hypergraph. Default is an empty dictionary. - node_metadata : dict, optional - A dictionary of metadata for nodes, where keys are node identifiers and values are metadata dictionaries. - edge_metadata : list of dicts, optional - A list of metadata dictionaries corresponding to the edges in `edge_list`. - - Raises - ------ - ValueError - If `edge_list` and `weights` have mismatched lengths when `weighted` is True. - """ - # Initialize hypergraph metadata - self._hypergraph_metadata = hypergraph_metadata or {} - self._hypergraph_metadata.update({"weighted": weighted, "type": "Hypergraph"}) - - # Initialize core attributes - self._weighted = weighted - self._adj = {} - self._edge_list = {} - self._weights = {} - self._incidences_metadata = {} - self._node_metadata = {} - self._edge_metadata = {} - self._empty_edges = {} - self._reverse_edge_list = {} - self._next_edge_id = 0 - - # Add node metadata if provided - if node_metadata: - for node, metadata in node_metadata.items(): - self.add_node(node, metadata=metadata) - - # Add edges if provided - if edge_list: - if weighted and weights is not None and len(edge_list) != len(weights): - raise ValueError("Edge list and weights must have the same length.") - self.add_edges(edge_list, weights=weights, metadata=edge_metadata) - - # Nodes - def add_node(self, node, metadata=None): - """ - Add a node to the hypergraph. If the node is already in the hypergraph, nothing happens. - - Parameters - ---------- - node : object - The node to add. - - Returns - ------- - None - """ - if metadata is None: - metadata = {} - if node not in self._adj: - self._adj[node] = [] - self._node_metadata[node] = {} - if self._node_metadata[node] == {}: - self._node_metadata[node] = metadata - - def add_nodes(self, node_list: list, metadata=None): - """ - Add a list of nodes to the hypergraph. - - Parameters - ---------- - node_list : list - The list of nodes to add. - - Returns - ------- - None - """ - for node in node_list: - try: - self.add_node(node, metadata[node] if metadata is not None else None) - except KeyError: - raise ValueError( - "The metadata dictionary must contain an entry for each node in the node list." - ) - - def remove_node(self, node, keep_edges=False): - """Remove a node from the hypergraph. - - Parameters - ---------- - node - The node to remove. - keep_edges : bool, optional - If True, the edges incident to the node are kept, but the node is removed from the edges. If False, the edges incident to the node are removed. Default is False. - - Returns - ------- - None - - Raises - ------ - KeyError - If the node is not in the hypergraph. - """ - if node not in self._adj: - raise KeyError("Node {} not in hypergraph.".format(node)) - if not keep_edges: - self.remove_edges( - [self._reverse_edge_list[edge_id] for edge_id in self._adj[node]] - ) - else: - to_remove = [] - for edge_id in self._adj[node]: - edge = self._reverse_edge_list[edge_id] - self.add_edge( - tuple(sorted([n for n in edge if n != node])), - weight=self.get_weight(edge), - metadata=self.get_edge_metadata(edge), - ) - to_remove.append(edge) - self.remove_edges(to_remove) - del self._adj[node] - - def remove_nodes(self, node_list, keep_edges=False): - """ - Remove a list of nodes from the hypergraph. - - Parameters - ---------- - node_list : list - The list of nodes to remove. - - keep_edges : bool, optional - If True, the edges incident to the nodes are kept, but the nodes are removed from the edges. If False, the edges incident to the nodes are removed. Default is False. - - - Returns - ------- - None - - Raises - ------ - KeyError - If any of the nodes is not in the hypergraph. - """ - for node in node_list: - self.remove_node(node, keep_edges=keep_edges) - - def get_nodes(self, metadata=False): - """ - Returns the list of nodes in the hypergraph. If metadata is True, it returns a list of tuples (node, metadata). - - Parameters - ---------- - metadata : bool, optional - - Returns - ------- - list - List of nodes in the hypergraph. If metadata is True, it returns a list of tuples (node, metadata). - """ - if not metadata: - return list(self._adj.keys()) - else: - return {node: self.get_node_metadata(node) for node in self._adj.keys()} - - def check_node(self, node): - """Checks if the specified node is in the hypergraph. - - Parameters - ---------- - node : Object - The node to check. - - Returns - ------- - bool - True if the node is in the hypergraph, False otherwise. - - """ - return node in self._adj - - def get_neighbors(self, node, order: int = None, size: int = None): - """ - Get the neighbors of a node in the hypergraph. - - Parameters - ---------- - node : object - The node of interest. - order : int - The order of the hyperedges to consider. - size : int - The size of the hyperedges to consider. - - Returns - ------- - set - The neighbors of the node. - - Raises - ------ - ValueError - If order and size are both specified or neither are specified. - """ - if node not in self._adj: - raise ValueError("Node {} not in hypergraph.".format(node)) - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - if order is None and size is None: - neigh = set() - edges = self.get_incident_edges(node) - for edge in edges: - neigh.update(edge) - if node in neigh: - neigh.remove(node) - return neigh - else: - if order is None: - order = size - 1 - neigh = set() - edges = self.get_incident_edges(node, order=order) - for edge in edges: - neigh.update(edge) - if node in neigh: - neigh.remove(node) - return neigh - - def get_incident_edges(self, node, order: int = None, size: int = None): - """ - Get the incident hyperedges of a node in the hypergraph. - - Parameters - ---------- - node : object - The node of interest. - order : int - The order of the hyperedges to consider. - size : int - The size of the hyperedges to consider. - - Returns - ------- - list - The incident hyperedges of the node. - - Raises - ------ - ValueError - If the node is not in the hypergraph. - - """ - if node not in self._adj: - raise ValueError("Node {} not in hypergraph.".format(node)) - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - if order is None and size is None: - return list( - [self._reverse_edge_list[edge_id] for edge_id in self._adj[node]] - ) - else: - if order is None: - order = size - 1 - return list( - [ - self._reverse_edge_list[edge_id] - for edge_id in self._adj[node] - if len(self._reverse_edge_list[edge_id]) - 1 == order - ] - ) - - # Edges - def add_edge(self, edge, weight=None, metadata=None): - """Add a hyperedge to the hypergraph. If the hyperedge is already in the hypergraph, its weight is updated. - - Parameters - ---------- - edge : tuple - The hyperedge to add. - weight : float, optional - The weight of the hyperedge. If the hypergraph is weighted, this must be provided. - metadata : dict, optional - The metadata of the hyperedge. - - Returns - ------- - None - - Raises - ------ - ValueError - If the hypergraph is weighted and no weight is provided or if the hypergraph is not weighted and a weight is provided. - """ - if not self._weighted and weight is not None and weight != 1: - raise ValueError( - "If the hypergraph is not weighted, weight can be 1 or None." - ) - - if weight is None: - weight = 1 - - edge = tuple(sorted(edge)) - order = len(edge) - 1 - if metadata is None: - metadata = {} - - if edge not in self._edge_list: - self._edge_list[edge] = self._next_edge_id - self._reverse_edge_list[self._next_edge_id] = edge - self._weights[self._next_edge_id] = 1 if not self._weighted else weight - self._next_edge_id += 1 - elif edge in self._edge_list and self._weighted: - self._weights[self._edge_list[edge]] += weight - - if metadata is not None: - self._edge_metadata[self._edge_list[edge]] = metadata - else: - self._edge_metadata[self._edge_list[edge]] = {} - - for node in edge: - self.add_node(node) - self._adj[node].append(self._edge_list[edge]) - - def add_edges(self, edge_list, weights=None, metadata=None): - """Add a list of hyperedges to the hypergraph. If a hyperedge is already in the hypergraph, its weight is updated. - - Parameters - ---------- - edge_list : list - The list of hyperedges to add. - - weights : list, optional - The list of weights of the hyperedges. If the hypergraph is weighted, this must be provided. - - metadata : list, optional - The list of metadata of the hyperedges. - - Returns - ------- - None - - Raises - ------ - ValueError - If the hypergraph is weighted and no weights are provided or if the hypergraph is not weighted and weights are provided. - - """ - if weights is not None and not self._weighted: - warnings.warn( - "Weights are provided but the hypergraph is not weighted. The weights will be ignored.", - UserWarning, - ) - self._weighted = True - - if self._weighted and weights is not None: - if len(set(edge_list)) != len(list(edge_list)): - raise ValueError( - "If weights are provided, the edge list must not contain repeated edges." - ) - if len(list(edge_list)) != len(list(weights)): - raise ValueError("The number of edges and weights must be the same.") - - i = 0 - if edge_list is not None: - for edge in edge_list: - self.add_edge( - edge, - weight=( - weights[i] if self._weighted and weights is not None else None - ), - metadata=metadata[i] if metadata is not None else None, - ) - i += 1 - - def remove_edge(self, edge): - """Remove an edge from the hypergraph. - - Parameters - ---------- - edge : tuple - The edge to remove. - - Returns - ------- - None - - Raises - ------ - KeyError - If the edge is not in the hypergraph. - """ - edge = tuple(sorted(edge)) - if edge not in self._edge_list: - raise KeyError("Edge {} not in hypergraph.".format(edge)) - - for node in edge: - try: - self._adj[node].remove(self._edge_list[edge]) - except KeyError: - pass - - del self._reverse_edge_list[self._edge_list[edge]] - del self._edge_metadata[self._edge_list[edge]] - del self._weights[self._edge_list[edge]] - del self._edge_list[edge] - - def remove_edges(self, edge_list): - """ - Remove a list of edges from the hypergraph. - - Parameters - ---------- - edge_list : list - The list of edges to remove. - - Returns - ------- - None - - Raises - ------ - KeyError - """ - for edge in edge_list: - self.remove_edge(edge) - - def set_edge_list(self, edge_list): - self._edge_list = edge_list - - def get_edge_list(self): - return self._edge_list - - def add_empty_edge(self, name, metadata): - if name not in self._empty_edges: - self._empty_edges[name] = metadata - else: - raise ("Edge {} already in hypergraph.".format(name)) - - def check_edge(self, edge): - """Checks if the specified edge is in the hypergraph. - - Parameters - ---------- - edge : tuple - The edge to check. - - Returns - ------- - bool - True if the edge is in the hypergraph, False otherwise. - - """ - return tuple(sorted(edge)) in self._edge_list - - def get_edges( - self, - order=None, - size=None, - up_to=False, - subhypergraph=False, - keep_isolated_nodes=False, - metadata=False, - ): - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - if not subhypergraph and keep_isolated_nodes: - raise ValueError("Cannot keep nodes if not returning subhypergraphs.") - - if order is None and size is None: - edges = list(self._edge_list.keys()) - else: - if size is not None: - order = size - 1 - if not up_to: - edges = [ - edge - for edge in list(self._edge_list.keys()) - if len(edge) - 1 == order - ] - else: - edges = [ - edge - for edge in list(self._edge_list.keys()) - if len(edge) - 1 <= order - ] - - if subhypergraph and keep_isolated_nodes: - h = Hypergraph(weighted=self._weighted) - h.add_nodes(list(self.get_nodes())) - if self._weighted: - edge_weights = [self.get_weight(edge) for edge in edges] - h.add_edges(edges, edge_weights) - else: - h.add_edges(edges) - - for node in h.get_nodes(): - h.set_node_metadata(node, self.get_node_metadata(node)) - for edge in edges: - h.set_edge_metadata(edge, self.get_edge_metadata(edge)) - return h - elif subhypergraph: - h = Hypergraph(weighted=self._weighted) - if self._weighted: - edge_weights = [self.get_weight(edge) for edge in edges] - h.add_edges(edges, edge_weights) - else: - h.add_edges(edges) - - for edge in edges: - h.set_edge_metadata(edge, self.get_edge_metadata(edge)) - return h - else: - return ( - edges - if not metadata - else {edge: self.get_edge_metadata(edge) for edge in edges} - ) - - # Weight - def set_weight(self, edge, weight): - """Sets the weight of the specified edge. - - Parameters - ---------- - edge : tuple - The edge to set the weight of. - - weight : float - The weight to set. - - Returns - ------- - None - - Raises - ------ - ValueError - If the edge is not in the hypergraph. - """ - if not self._weighted and weight != 1: - raise ValueError( - "If the hypergraph is not weighted, weight can be 1 or None." - ) - - edge = tuple(sorted(edge)) - if edge not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - edge_id = self._edge_list[edge] - self._weights[edge_id] = weight - - def get_weight(self, edge): - """Returns the weight of the specified edge. - - Parameters - ---------- - edge : tuple - The edge to get the weight of. - - Returns - ------- - float - Weight of the specified edge. - """ - edge = tuple(sorted(edge)) - if edge in self._edge_list: - edge_id = self._edge_list[edge] - return self._weights[edge_id] - else: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - - def get_weights(self, order=None, size=None, up_to=False, asdict=False): - """Returns the list of weights of the edges in the hypergraph. If order is specified, it returns the list of weights of the edges of the specified order. - If size is specified, it returns the list of weights of the edges of the specified size. If both order and size are specified, it raises a ValueError. - If up_to is True, it returns the list of weights of the edges of order smaller or equal to the specified order. - - Parameters - ---------- - order : int, optional - Order of the edges to get the weights of. - - size : int, optional - Size of the edges to get the weights of. - - up_to : bool, optional - If True, it returns the list of weights of the edges of order smaller or equal to the specified order. Default is False. - - Returns - ------- - list - List of weights of the edges in the hypergraph. - - Raises - ------ - ValueError - If both order and size are specified. - - """ - w = None - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - if order is None and size is None: - w = { - edge: self._weights[self._edge_list[edge]] for edge in self.get_edges() - } - - if size is not None: - order = size - 1 - - if w is None: - w = { - edge: self._weights[self._edge_list[edge]] - for edge in self.get_edges(order=order, up_to=up_to) - } - - if asdict: - return w - else: - return list(w.values()) - - # Info - def max_order(self): - """ - Returns the maximum order of the hypergraph. - - Returns - ------- - int - Maximum order of the hypergraph. - """ - return self.max_size() - 1 - - def max_size(self): - """ - Returns the maximum size of the hypergraph. - - Returns - ------- - int - Maximum size of the hypergraph. - """ - return max(self.get_sizes()) - - def num_nodes(self): - """ - Returns the number of nodes in the hypergraph. - - Returns - ------- - int - Number of nodes in the hypergraph. - """ - return len(list(self.get_nodes())) - - def num_edges(self, order=None, size=None, up_to=False): - """Returns the number of edges in the hypergraph. If order is specified, it returns the number of edges of the specified order. - If size is specified, it returns the number of edges of the specified size. If both order and size are specified, it raises a ValueError. - If up_to is True, it returns the number of edges of order smaller or equal to the specified order. - - Parameters - ---------- - order : int, optional - Order of the edges to count. - size : int, optional - Size of the edges to count. - up_to : bool, optional - If True, it returns the number of edges of order smaller or equal to the specified order. Default is False. - - Returns - ------- - int - Number of edges in the hypergraph. - """ - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - - if order is None and size is None: - return len(self._edge_list) - else: - if size is not None: - order = size - 1 - if not up_to: - s = 0 - for edge in self._edge_list: - if len(edge) - 1 == order: - s += 1 - return s - else: - s = 0 - for edge in self._edge_list: - if len(edge) - 1 <= order: - s += 1 - return s - - def get_sizes(self): - """Returns the list of sizes of the hyperedges in the hypergraph. - - Returns - ------- - list - List of sizes of the hyperedges in the hypergraph. - - """ - return [len(edge) for edge in self._edge_list.keys()] - - def distribution_sizes(self): - """ - Returns the distribution of sizes of the hyperedges in the hypergraph. - - Returns - ------- - collections.Counter - Distribution of sizes of the hyperedges in the hypergraph. - """ - from collections import Counter - - return dict(Counter(self.get_sizes())) - - def get_orders(self): - """Returns the list of orders of the hyperedges in the hypergraph. - - Returns - ------- - list - List of orders of the hyperedges in the hypergraph. - - """ - return [len(edge) - 1 for edge in self._edge_list.keys()] - - def is_weighted(self): - """ - Check if the hypergraph is weighted. - - Returns - ------- - bool - True if the hypergraph is weighted, False otherwise. - """ - return self._weighted - - def is_uniform(self): - """ - Check if the hypergraph is uniform, i.e. all hyperedges have the same size. - - Returns - ------- - bool - True if the hypergraph is uniform, False otherwise. - """ - uniform = True - sz = None - for edge in self._edge_list: - if sz is None: - sz = len(edge) - else: - if len(edge) != sz: - uniform = False - break - return uniform - - # Adj And Subhypergraph - def get_adj_dict(self): - return self._adj - - def set_adj_dict(self, adj): - self._adj = adj - - def subhypergraph(self, nodes: list): - """ - Return a subhypergraph induced by the nodes in the list. - - Parameters - ---------- - nodes : list - List of nodes to be included in the subhypergraph. - - Returns - ------- - Hypergraph - Subhypergraph induced by the nodes in the list. - """ - h = Hypergraph(weighted=self._weighted) - h.add_nodes(nodes) - for node in nodes: - h.set_node_metadata(node, self.get_node_metadata(node)) - for edge in self._edge_list: - if set(edge).issubset(set(nodes)): - if self._weighted: - h.add_edge( - edge, - weight=self._edge_list[edge], - metadata=self.get_edge_metadata(edge), - ) - else: - h.add_edge(edge, metadata=self.get_edge_metadata(edge)) - return h - - def subhypergraph_by_orders( - self, orders: list = None, sizes: list = None, keep_nodes=True - ): - """Return a subhypergraph induced by the edges of the specified orders. - - Parameters - ---------- - orders : list, optional - List of orders of the edges to be included in the subhypergraph. If None, the sizes parameter should be specified. - sizes : list, optional - List of sizes of the edges to be included in the subhypergraph. If None, the orders parameter should be specified. - keep_nodes : bool, optional - If True, the nodes of the original hypergraph are kept in the subhypergraph. If False, only the edges are kept. Default is True. - - Returns - ------- - Hypergraph - Subhypergraph induced by the edges of the specified orders. - - Raises - ------ - ValueError - If both orders and sizes are None or if both orders and sizes are specified. - """ - if orders is None and sizes is None: - raise ValueError( - "At least one between orders and sizes should be specified" - ) - if orders is not None and sizes is not None: - raise ValueError("Order and size cannot be both specified.") - h = Hypergraph(weighted=self.is_weighted()) - if keep_nodes: - h.add_nodes(node_list=list(self.get_nodes())) - for node in self.get_nodes(): - h.set_node_metadata(node, self.get_node_metadata(node)) - - if sizes is None: - sizes = [] - for order in orders: - sizes.append(order + 1) - - for size in sizes: - edges = self.get_edges(size=size) - for edge in edges: - if h.is_weighted(): - h.add_edge( - edge, self.get_weight(edge), self.get_edge_metadata(edge) - ) - else: - h.add_edge(edge, metadata=self.get_edge_metadata(edge)) - - return h - - # Degree - def degree(self, node, order=None, size=None): - from hypergraphx.measures.degree import degree - - return degree(self, node, order=order, size=size) - - def degree_sequence(self, order=None, size=None): - from hypergraphx.measures.degree import degree_sequence - - return degree_sequence(self, order=order, size=size) - - def degree_distribution(self, order=None, size=None): - from hypergraphx.measures.degree import degree_distribution - - return degree_distribution(self, order=order, size=size) - - # Connected Components - def is_connected(self, size=None, order=None): - from hypergraphx.utils.cc import is_connected - - return is_connected(self, size=size, order=order) - - def connected_components(self, size=None, order=None): - from hypergraphx.utils.cc import connected_components - - return connected_components(self, size=size, order=order) - - def node_connected_component(self, node, size=None, order=None): - from hypergraphx.utils.cc import node_connected_component - - return node_connected_component(self, node, size=size, order=order) - - def num_connected_components(self, size=None, order=None): - from hypergraphx.utils.cc import num_connected_components - - return num_connected_components(self, size=size, order=order) - - def largest_component(self, size=None, order=None): - from hypergraphx.utils.cc import largest_component - - return largest_component(self, size=size, order=order) - - def subhypergraph_largest_component(self, size=None, order=None): - """ - Returns a subhypergraph induced by the nodes in the largest component of the hypergraph. - - Parameters - ---------- - size: int, optional - The size of the hyperedges to consider - order: int, optional - The order of the hyperedges to consider - - Returns - ------- - Hypergraph - Subhypergraph induced by the nodes in the largest component of the hypergraph. - """ - nodes = self.largest_component(size=size, order=order) - return self.subhypergraph(nodes) - - def largest_component_size(self, size=None, order=None): - from hypergraphx.utils.cc import largest_component_size - - return largest_component_size(self, size=size, order=order) - - # Matrix - def binary_incidence_matrix(self, return_mapping: bool = False): - from hypergraphx.linalg import binary_incidence_matrix - - return binary_incidence_matrix(self, return_mapping) - - def incidence_matrix(self, return_mapping: bool = False): - from hypergraphx.linalg import incidence_matrix - - return incidence_matrix(self, return_mapping) - - def adjacency_matrix(self, return_mapping: bool = False): - from hypergraphx.linalg import adjacency_matrix - - return adjacency_matrix(self, return_mapping) - - # Utils - def isolated_nodes(self, size=None, order=None): - from hypergraphx.utils.cc import isolated_nodes - - return isolated_nodes(self, size=size, order=order) - - def is_isolated(self, node, size=None, order=None): - from hypergraphx.utils.cc import is_isolated - - return is_isolated(self, node, size=size, order=order) - - def dual_random_walk_adjacency(self, return_mapping: bool = False): - from hypergraphx.linalg import dual_random_walk_adjacency - - return dual_random_walk_adjacency(self, return_mapping) - - def adjacency_factor(self, t: int = 0): - from hypergraphx.linalg import adjacency_factor - - return adjacency_factor(self, t) - - def to_line_graph(self, distance="intersection", s: int = 1, weighted=False): - from hypergraphx.representations.projections import line_graph - - return line_graph(self, distance, s, weighted) - - # Metadata - def set_hypergraph_metadata(self, metadata): - self._hypergraph_metadata = metadata - - def get_hypergraph_metadata(self): - return self._hypergraph_metadata - - def set_node_metadata(self, node, metadata): - if node not in self._adj: - raise ValueError("Node {} not in hypergraph.".format(node)) - self._node_metadata[node] = metadata - - def get_node_metadata(self, node): - if node not in self._adj: - raise ValueError("Node {} not in hypergraph.".format(node)) - return self._node_metadata[node] - - def get_all_nodes_metadata(self): - return self._node_metadata - - def set_edge_metadata(self, edge, metadata): - edge = tuple(sorted(edge)) - if edge not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - self._edge_metadata[self._edge_list[edge]] = metadata - - def get_edge_metadata(self, edge): - edge = tuple(sorted(edge)) - if edge not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - return self._edge_metadata[self._edge_list[edge]] - - def get_all_edges_metadata(self): - return self._edge_metadata - - def set_incidence_metadata(self, edge, node, metadata): - if tuple(sorted(edge)) not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - self._incidences_metadata[(edge, node)] = metadata - - def get_incidence_metadata(self, edge, node): - if tuple(sorted(edge)) not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - return self._incidences_metadata[(edge, node)] - - def get_all_incidences_metadata(self): - return {k: v for k, v in self._incidences_metadata.items()} - - def set_attr_to_hypergraph_metadata(self, field, value): - self._hypergraph_metadata[field] = value - - def set_attr_to_node_metadata(self, node, field, value): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - self._node_metadata[node][field] = value - - def set_attr_to_edge_metadata(self, edge, field, value): - edge = tuple(sorted(edge)) - if edge not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - self._edge_metadata[self._edge_list[edge]][field] = value - - def remove_attr_from_node_metadata(self, node, field): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - del self._node_metadata[node][field] - - def remove_attr_from_edge_metadata(self, edge, field): - edge = tuple(sorted(edge)) - if edge not in self._edge_metadata: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - del self._edge_metadata[self._edge_list[edge]][field] - - # Basic Functions - def clear(self): - self._edge_list.clear() - self._adj.clear() - self._weights.clear() - self._hypergraph_metadata.clear() - self._incidences_metadata.clear() - self._node_metadata.clear() - self._edge_metadata.clear() - self._empty_edges.clear() - self._reverse_edge_list.clear() - - def copy(self): - """ - Returns a copy of the hypergraph. - - Returns - ------- - Hypergraph - A copy of the hypergraph. - """ - return copy.deepcopy(self) - - def __str__(self): - """ - Returns a string representation of the hypergraph. - - Returns - ------- - str - A string representation of the hypergraph. - """ - title = "Hypergraph with {} nodes and {} edges.\n".format( - self.num_nodes(), self.num_edges() - ) - details = "Distribution of hyperedge sizes: {}".format( - self.distribution_sizes() - ) - return title + details - - def __len__(self): - """ - Returns the number of edges in the hypergraph. - - Returns - ------- - int - The number of edges in the hypergraph. - """ - return len(self._edge_list) - - def __iter__(self): - """ - Returns an iterator over the edges in the hypergraph. - - Returns - ------- - iterator - An iterator over the edges in the hypergraph. - """ - return iter(self._edge_list.items()) - - # Data Structure Extra - def expose_data_structures(self): - """ - Expose the internal data structures of the hypergraph for serialization. - - Returns - ------- - dict - A dictionary containing all internal attributes of the hypergraph. - """ - return { - "type": "Hypergraph", - "_weighted": self._weighted, - "_adj": self._adj, - "_edge_list": self._edge_list, - "_weights": self._weights, - "hypergraph_metadata": self._hypergraph_metadata, - "node_metadata": self._node_metadata, - "edge_metadata": self._edge_metadata, - "reverse_edge_list": self._reverse_edge_list, - "next_edge_id": self._next_edge_id, - } - - def populate_from_dict(self, data): - """ - Populate the attributes of the hypergraph from a dictionary. - - Parameters - ---------- - data : dict - A dictionary containing the attributes to populate the hypergraph. - """ - self._weighted = data.get("_weighted", False) - self._adj = data.get("_adj", {}) - self._edge_list = data.get("_edge_list", {}) - self._weights = data.get("_weights", {}) - self._hypergraph_metadata = data.get("hypergraph_metadata", {}) - self._incidences_metadata = data.get("incidences_metadata", {}) - self._node_metadata = data.get("node_metadata", {}) - self._edge_metadata = data.get("edge_metadata", {}) - self._empty_edges = data.get("empty_edges", {}) - self._reverse_edge_list = data.get("reverse_edge_list", {}) - self._next_edge_id = data.get("next_edge_id", 0) - - def expose_attributes_for_hashing(self): - """ - Expose relevant attributes for hashing specific to Hypergraph. - - Returns - ------- - dict - A dictionary containing key attributes. - """ - edges = [] - for edge in sorted(self._edge_list.keys()): - sorted_edge = sorted(edge) - edge_id = self._edge_list[edge] - edges.append( - { - "nodes": sorted_edge, - "weight": self._weights.get(edge_id, 1), - "metadata": self._edge_metadata.get(edge_id, {}), - } - ) - - nodes = [] - for node in sorted(self._adj.keys()): - nodes.append({"node": node, "metadata": self._node_metadata[node]}) - - return { - "type": "Hypergraph", - "weighted": self._weighted, - "hypergraph_metadata": self._hypergraph_metadata, - "edges": edges, - "nodes": nodes, - } - - def get_mapping(self): - """ - Map the nodes of the hypergraph to integers in [0, n_nodes). - - Returns - ------- - LabelEncoder - The mapping. - """ - encoder = LabelEncoder() - encoder.fit(self.get_nodes()) - return encoder diff --git a/hypergraphx/core/multiplex_hypergraph.py b/hypergraphx/core/multiplex_hypergraph.py deleted file mode 100644 index cc8e91f..0000000 --- a/hypergraphx/core/multiplex_hypergraph.py +++ /dev/null @@ -1,552 +0,0 @@ -from hypergraphx import Hypergraph - - -def _canon_edge(edge): - edge = tuple(edge) - - if len(edge) == 2: - if isinstance(edge[0], tuple) and isinstance(edge[1], tuple): - # Sort the inner tuples and return - return (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - elif not isinstance(edge[0], tuple) and not isinstance(edge[1], tuple): - # Sort the edge itself if it contains IDs (non-tuple elements) - return tuple(sorted(edge)) - - return tuple(sorted(edge)) - - -class MultiplexHypergraph: - """ - A Multiplex Hypergraph is a hypergraph where hyperedges are organized into multiple layers. - Each layer share the same node-set and represents a specific context or relationship between nodes, and hyperedges can - have weights and metadata specific to their layer. - """ - - def __init__( - self, - edge_list=None, - edge_layer=None, - weighted=False, - weights=None, - hypergraph_metadata=None, - node_metadata=None, - edge_metadata=None, - ): - """ - Initialize a Multiplex Hypergraph with optional edges, layers, weights, and metadata. - - Parameters - ---------- - edge_list : list of tuples, optional - A list of edges where each edge is represented as a tuple of nodes. - If `edge_layer` is not provided, each tuple in `edge_list` should have - the format `(edge, layer)`, where `edge` is itself a tuple of nodes. - edge_layer : list of str, optional - A list of layer names corresponding to each edge in `edge_list`. - weighted : bool, optional - Indicates whether the hypergraph is weighted. Default is False. - weights : list of float, optional - A list of weights for each edge in `edge_list`. Must be provided if `weighted` is True. - hypergraph_metadata : dict, optional - Metadata for the hypergraph as a whole. Default is an empty dictionary. - node_metadata : dict, optional - A dictionary of metadata for nodes, where keys are node identifiers and values are metadata dictionaries. - edge_metadata : list of dict, optional - A list of metadata dictionaries for each edge in `edge_list`. - - Raises - ------ - ValueError - If `edge_list` and `edge_layer` have mismatched lengths. - If `edge_list` contains improperly formatted edges when `edge_layer` is None. - """ - # Initialize hypergraph metadata - self._hypergraph_metadata = hypergraph_metadata or {} - self._hypergraph_metadata.update( - {"weighted": weighted, "type": "MultiplexHypergraph"} - ) - - # Initialize core attributes - self._node_metadata = {} - self._edge_metadata = {} - self._weighted = weighted - self._weights = {} - self._edge_list = {} - self._adj = {} - self._reverse_edge_list = {} - self._next_edge_id = 0 - self._existing_layers = set() - - # Add node metadata if provided - if node_metadata: - for node, metadata in node_metadata.items(): - self.add_node(node, metadata=metadata) - - # Handle edge and layer consistency - if edge_list is not None and edge_layer is None: - # Extract layers from edge_list if layer information is embedded - if all(isinstance(edge, tuple) and len(edge) == 2 for edge in edge_list): - edge_layer = [edge[1] for edge in edge_list] - edge_list = [edge[0] for edge in edge_list] - else: - raise ValueError( - "If edge_layer is not provided, edge_list must contain tuples of the form (edge, layer)." - ) - - if edge_list is not None: - if edge_layer is not None and len(edge_list) != len(edge_layer): - raise ValueError("Edge list and edge layer must have the same length.") - self.add_edges( - edge_list, - edge_layer=edge_layer, - weights=weights, - metadata=edge_metadata, - ) - - def get_adj_dict(self): - return self._adj - - def set_adj_dict(self, adj_dict): - self._adj = adj_dict - - def get_incident_edges(self, node): - if node not in self._adj: - raise ValueError("Node {} not in hypergraph.".format(node)) - return [self._reverse_edge_list[e_id] for e_id in self._adj[node]] - - def degree(self, node, order=None, size=None): - from hypergraphx.measures.degree import degree - - return degree(self, node, order=order, size=size) - - def degree_sequence(self, order=None, size=None): - from hypergraphx.measures.degree import degree_sequence - - return degree_sequence(self, order=order, size=size) - - def get_edge_metadata(self, edge, layer): - edge = tuple(sorted(edge)) - k = (edge, layer) - if k not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - return self._edge_metadata[self._edge_list[k]] - - def is_weighted(self): - return self._weighted - - def get_edge_list(self): - return self._edge_list - - def set_edge_list(self, edge_list): - self._edge_list = edge_list - - def get_existing_layers(self): - return self._existing_layers - - def set_existing_layers(self, existing_layers): - self._existing_layers = existing_layers - - def get_nodes(self, metadata=False): - if metadata: - return self._node_metadata - else: - return list(self._node_metadata.keys()) - - def add_node(self, node, metadata=None): - """ - Add a node to the hypergraph. If the node is already in the hypergraph, nothing happens. - - Parameters - ---------- - node : object - The node to add. - - Returns - ------- - None - """ - if metadata is None: - metadata = {} - if node not in self._adj: - self._adj[node] = [] - self._node_metadata[node] = {} - if self._node_metadata[node] == {}: - self._node_metadata[node] = metadata - - def add_nodes(self, node_list: list, node_metadata=None): - """ - Add a list of nodes to the hypergraph. - - Parameters - ---------- - node_list : list - The list of nodes to add. - - Returns - ------- - None - """ - for node in node_list: - try: - self.add_node( - node, node_metadata[node] if node_metadata is not None else None - ) - except KeyError: - raise ValueError( - "The metadata dictionary must contain an entry for each node in the node list." - ) - - def add_edges(self, edge_list, edge_layer, weights=None, metadata=None): - """Add a list of hyperedges to the hypergraph. If a hyperedge is already in the hypergraph, its weight is updated. - - Parameters - ---------- - edge_list : list - The list of hyperedges to add. - - edge_layer : list - The list of layers to which the hyperedges belong. - - weights : list, optional - The list of weights of the hyperedges. If the hypergraph is weighted, this must be provided. - - metadata : list, optional - The list of metadata of the hyperedges. - - Returns - ------- - None - - Raises - ------ - ValueError - If the hypergraph is weighted and no weights are provided or if the hypergraph is not weighted and weights are provided. - - """ - if weights is not None and not self._weighted: - print( - "Warning: weights are provided but the hypergraph is not weighted. The hypergraph will be weighted." - ) - self._weighted = True - - if self._weighted and weights is not None: - if len(set(edge_list)) != len(list(edge_list)): - raise ValueError( - "If weights are provided, the edge list must not contain repeated edges." - ) - if len(list(edge_list)) != len(list(weights)): - raise ValueError("The number of edges and weights must be the same.") - - i = 0 - if edge_list is not None: - for edge in edge_list: - self.add_edge( - edge, - edge_layer[i], - weight=( - weights[i] if self._weighted and weights is not None else None - ), - metadata=metadata[i] if metadata is not None else None, - ) - i += 1 - - def add_edge(self, edge, layer, weight=None, metadata=None): - """Add a hyperedge to the hypergraph. If the hyperedge is already in the hypergraph, its weight is updated. - - Parameters - ---------- - edge : tuple - The hyperedge to add. - layer : str - The layer to which the hyperedge belongs. - weight : float, optional - The weight of the hyperedge. If the hypergraph is weighted, this must be provided. - metadata : dict, optional - The metadata of the hyperedge. - - Returns - ------- - None - - Raises - ------ - ValueError - If the hypergraph is weighted and no weight is provided or if the hypergraph is not weighted and a weight is provided. - """ - if weight is None: - weight = 1 - - if not self._weighted and weight is not None and weight != 1: - raise ValueError( - "If the hypergraph is not weighted, weight can be 1 or None." - ) - - self._existing_layers.add(layer) - - edge = _canon_edge(edge) - k = (edge, layer) - order = len(edge) - 1 - - if k not in self._edge_list: - e_id = self._next_edge_id - self._reverse_edge_list[e_id] = k - self._edge_list[k] = e_id - self._next_edge_id += 1 - self._weights[e_id] = weight - elif k in self._edge_list and self._weighted: - self._weights[self._edge_list[k]] += weight - - e_id = self._edge_list[k] - - if metadata is None: - metadata = {} - - self._edge_metadata[e_id] = metadata - - for node in edge: - self.add_node(node) - - for node in edge: - self._adj[node].append(e_id) - - def remove_edge(self, edge): - """ - Remove an edge from the multiplex hypergraph. - - Parameters - ---------- - edge : tuple - The edge to remove. Should be of the form ((nodes...), layer). - - Raises - ------ - ValueError - If the edge is not in the hypergraph. - """ - edge = _canon_edge(edge) - if edge not in self._edge_list: - raise ValueError(f"Edge {edge} not in hypergraph.") - - edge_id = self._edge_list[edge] - - del self._reverse_edge_list[edge_id] - if edge_id in self._weights: - del self._weights[edge_id] - if edge_id in self._edge_metadata: - del self._edge_metadata[edge_id] - - nodes, layer = edge - for node in nodes: - if edge_id in self._adj[node]: - self._adj[node].remove(edge_id) - - del self._edge_list[edge] - - def remove_node(self, node, keep_edges=False): - """ - Remove a node from the multiplex hypergraph. - - Parameters - ---------- - node : object - The node to remove. - keep_edges : bool, optional - If True, edges incident to the node are kept but updated to exclude the node. - If False, edges incident to the node are removed entirely. Default is False. - - Raises - ------ - ValueError - If the node is not in the hypergraph. - """ - if node not in self._adj: - raise ValueError(f"Node {node} not in hypergraph.") - - edges_to_process = list(self._adj[node]) - - if keep_edges: - for edge_id in edges_to_process: - edge, layer = self._reverse_edge_list[edge_id] - updated_edge = tuple(n for n in edge if n != node) - - self.remove_edge((edge, layer)) - if updated_edge: - self.add_edge( - updated_edge, - layer, - weight=self._weights.get(edge_id, 1), - metadata=self._edge_metadata.get(edge_id, {}), - ) - else: - for edge_id in edges_to_process: - edge, layer = self._reverse_edge_list[edge_id] - self.remove_edge((edge, layer)) - - del self._adj[node] - if node in self._node_metadata: - del self._node_metadata[node] - - def get_edges(self, metadata=False): - if metadata: - return { - self._reverse_edge_list[k]: self._edge_metadata[k] - for k in self._edge_metadata.keys() - } - else: - return list(self._edge_list.keys()) - - def get_weight(self, edge, layer): - edge = _canon_edge(edge) - k = (edge, layer) - if k not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(k)) - else: - return self._weights[self._edge_list[k]] - - def set_weight(self, edge, layer, weight): - if not self._weighted and weight != 1: - raise ValueError( - "If the hypergraph is not weighted, weight can be 1 or None." - ) - - k = (_canon_edge(edge), layer) - if k not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - else: - self._weights[self._edge_list[k]] = weight - - def set_dataset_metadata(self, metadata): - self._hypergraph_metadata["multiplex_metadata"] = metadata - - def get_dataset_metadata(self): - return self._hypergraph_metadata["multiplex_metadata"] - - def set_layer_metadata(self, layer_name, metadata): - if layer_name not in self._hypergraph_metadata: - self._hypergraph_metadata[layer_name] = {} - self._hypergraph_metadata[layer_name] = metadata - - def get_layer_metadata(self, layer_name): - return self._hypergraph_metadata[layer_name] - - def get_hypergraph_metadata(self): - return self._hypergraph_metadata - - def set_hypergraph_metadata(self, metadata): - self._hypergraph_metadata = metadata - - def aggregated_hypergraph(self): - h = Hypergraph( - weighted=self._weighted, hypergraph_metadata=self._hypergraph_metadata - ) - for node in self.get_nodes(): - h.add_node(node, metadata=self._node_metadata[node]) - for edge in self.get_edges(): - _edge, layer = edge - h.add_edge( - _edge, - weight=self.get_weight(_edge, layer), - metadata=self.get_edge_metadata(_edge, layer), - ) - return h - - def set_attr_to_hypergraph_metadata(self, field, value): - self._hypergraph_metadata[field] = value - - def set_attr_to_node_metadata(self, node, field, value): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - self._node_metadata[node][field] = value - - def set_attr_to_edge_metadata(self, edge, layer, field, value): - edge = _canon_edge(edge) - if edge not in self._edge_metadata: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - self._edge_metadata[self._edge_list[(edge, layer)]][field] = value - - def remove_attr_from_node_metadata(self, node, field): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - del self._node_metadata[node][field] - - def remove_attr_from_edge_metadata(self, edge, layer, field): - edge = _canon_edge(edge) - if edge not in self._edge_metadata: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - del self._edge_metadata[self._edge_list[(edge, layer)]][field] - - def expose_data_structures(self): - """ - Expose the internal data structures of the multiplex hypergraph for serialization. - - Returns - ------- - dict - A dictionary containing all internal attributes of the multiplex hypergraph. - """ - return { - "type": "MultiplexHypergraph", - "hypergraph_metadata": self._hypergraph_metadata, - "node_metadata": self._node_metadata, - "edge_metadata": self._edge_metadata, - "_weighted": self._weighted, - "_weights": self._weights, - "_edge_list": self._edge_list, - "_adj": self._adj, - "reverse_edge_list": self._reverse_edge_list, - "next_edge_id": self._next_edge_id, - "existing_layers": self._existing_layers, - } - - def populate_from_dict(self, data): - """ - Populate the attributes of the multiplex hypergraph from a dictionary. - - Parameters - ---------- - data : dict - A dictionary containing the attributes to populate the hypergraph. - """ - self._hypergraph_metadata = data.get("hypergraph_metadata", {}) - self._node_metadata = data.get("node_metadata", {}) - self._edge_metadata = data.get("edge_metadata", {}) - self._weighted = data.get("_weighted", False) - self._weights = data.get("_weights", {}) - self._edge_list = data.get("_edge_list", {}) - self._adj = data.get("_adj", {}) - self._reverse_edge_list = data.get("reverse_edge_list", {}) - self._next_edge_id = data.get("next_edge_id", 0) - self._existing_layers = data.get("existing_layers", set()) - - def expose_attributes_for_hashing(self): - """ - Expose relevant attributes for hashing specific to MultiplexHypergraph. - - Returns - ------- - dict - A dictionary containing key attributes. - """ - edges = [] - for edge in sorted(self._edge_list.keys()): - edge = (tuple(sorted(edge[0])), edge[1]) - edge_id = self._edge_list[edge] - edges.append( - { - "nodes": edge, - "weight": self._weights.get(edge_id, 1), - "metadata": self._edge_metadata.get(edge_id, {}), - } - ) - - nodes = [] - for node in sorted(self._node_metadata.keys()): - nodes.append({"node": node, "metadata": self._node_metadata[node]}) - - return { - "type": "MultiplexHypergraph", - "weighted": self._weighted, - "hypergraph_metadata": self._hypergraph_metadata, - "edges": edges, - "nodes": nodes, - } diff --git a/hypergraphx/core/temporal_hypergraph.py b/hypergraphx/core/temporal_hypergraph.py deleted file mode 100644 index 7525581..0000000 --- a/hypergraphx/core/temporal_hypergraph.py +++ /dev/null @@ -1,1203 +0,0 @@ -import copy -import math - -from sklearn.preprocessing import LabelEncoder - -from hypergraphx import Hypergraph - - -def _canon_edge(edge): - edge = tuple(edge) - - if len(edge) == 2: - if isinstance(edge[0], tuple) and isinstance(edge[1], tuple): - # Sort the inner tuples and return - return (tuple(sorted(edge[0])), tuple(sorted(edge[1]))) - elif not isinstance(edge[0], tuple) and not isinstance(edge[1], tuple): - # Sort the edge itself if it contains IDs (non-tuple elements) - return tuple(sorted(edge)) - - return tuple(sorted(edge)) - - -def _get_size(edge): - if len(edge) == 2 and isinstance(edge[0], tuple) and isinstance(edge[1], tuple): - return len(edge[0]) + len(edge[1]) - else: - return len(edge) - - -def _get_order(edge): - return _get_size(edge) - 1 - - -def _get_nodes(edge): - if len(edge) == 2 and isinstance(edge[0], tuple) and isinstance(edge[1], tuple): - return list(edge[0]) + list(edge[1]) - else: - return list(edge) - - -class TemporalHypergraph: - """ - A Temporal Hypergraph is a hypergraph where each hyperedge is associated with a specific timestamp. - Temporal hypergraphs are useful for modeling systems where interactions between nodes change over time, such as social networks, - communication networks, and transportation systems. - """ - - def __init__( - self, - edge_list=None, - time_list=None, - weighted=False, - weights=None, - hypergraph_metadata=None, - node_metadata=None, - edge_metadata=None, - ): - """ - Initialize a Temporal Hypergraph with optional edges, times, weights, and metadata. - - Parameters - ---------- - edge_list : list of tuples, optional - A list of edges where each edge is represented as a tuple of nodes. - If `time_list` is not provided, each tuple in `edge_list` should - have the format `(time, edge)`, where `edge` is itself a tuple of nodes. - time_list : list of int, optional - A list of times corresponding to each edge in `edge_list`. - Must be provided if `edge_list` does not include time information. - weighted : bool, optional - Indicates whether the hypergraph is weighted. Default is False. - weights : list of float, optional - A list of weights for each edge in `edge_list`. Must be provided if `weighted` is True. - hypergraph_metadata : dict, optional - Metadata for the hypergraph as a whole. Default is an empty dictionary. - node_metadata : dict, optional - A dictionary of metadata for nodes, where keys are node identifiers and values are metadata dictionaries. - edge_metadata : list of dict, optional - A list of metadata dictionaries for each edge in `edge_list`. - - Raises - ------ - ValueError - If `edge_list` and `time_list` have mismatched lengths. - If `edge_list` contains improperly formatted edges when `time_list` is None. - If `time_list` is provided without `edge_list`. - """ - # Initialize hypergraph metadata - self._hypergraph_metadata = hypergraph_metadata or {} - self._hypergraph_metadata.update( - {"weighted": weighted, "type": "TemporalHypergraph"} - ) - - # Initialize core attributes - self._weighted = weighted - self._weights = {} - self._adj = {} - self._edge_list = {} - self._incidences_metadata = {} - self._node_metadata = {} - self._edge_metadata = {} - self._reverse_edge_list = {} - self._next_edge_id = 0 - - # Add node metadata if provided - if node_metadata: - for node, metadata in node_metadata.items(): - self.add_node(node, metadata=metadata) - - # Handle edge and time list consistency - if edge_list is not None and time_list is None: - # Extract times from the edge list if time information is embedded - if not all( - isinstance(edge, tuple) and len(edge) == 2 for edge in edge_list - ): - raise ValueError( - "If time_list is not provided, edge_list must contain tuples of the form (time, edge)." - ) - time_list = [edge[0] for edge in edge_list] - edge_list = [edge[1] for edge in edge_list] - - if edge_list is None and time_list is not None: - raise ValueError("Edge list must be provided if time list is provided.") - - if edge_list is not None and time_list is not None: - if len(edge_list) != len(time_list): - raise ValueError("Edge list and time list must have the same length.") - self.add_edges( - edge_list, time_list, weights=weights, metadata=edge_metadata - ) - - # Node - def add_node(self, node, metadata=None): - if metadata is None: - metadata = {} - if node not in self._node_metadata: - self._adj[node] = [] - self._node_metadata[node] = {} - if self._node_metadata[node] == {}: - self._node_metadata[node] = metadata - - def add_nodes(self, node_list: list, metadata=None): - for node in node_list: - try: - self.add_node(node, metadata[node] if metadata is not None else None) - except KeyError: - raise ValueError( - "The metadata dictionary must contain an entry for each node in the node list." - ) - - def remove_node(self, node, keep_edges=False): - """ - Remove a node from the temporal hypergraph. - - Parameters - ---------- - node : object - The node to remove. - keep_edges : bool, optional - If True, edges incident to the node are kept but updated to exclude the node. - If False, edges incident to the node are removed entirely. Default is False. - - Raises - ------ - ValueError - If the node is not in the hypergraph. - """ - if node not in self._adj: - raise ValueError(f"Node {node} not in hypergraph.") - - edges_to_process = list(self._adj[node]) - - if keep_edges: - for edge_id in edges_to_process: - time, edge = self._reverse_edge_list[edge_id] - updated_edge = tuple(n for n in edge if n != node) - - self.remove_edge((time, edge)) - if updated_edge: - self.add_edge( - updated_edge, - time, - weight=self._weights.get(edge_id, 1), - metadata=self._edge_metadata.get(edge_id, {}), - ) - else: - for edge_id in edges_to_process: - time, edge = self._reverse_edge_list[edge_id] - self.remove_edge((time, edge)) - - del self._adj[node] - if node in self._node_metadata: - del self._node_metadata[node] - - def remove_nodes(self, node_list, keep_edges=False): - """ - Remove a list of nodes from the hypergraph. - - Parameters - ---------- - node_list : list - The list of nodes to remove. - - keep_edges : bool, optional - If True, the edges incident to the nodes are kept, but the nodes are removed from the edges. If False, the edges incident to the nodes are removed. Default is False. - - Returns - ------- - None - - Raises - ------ - KeyError - If any of the nodes is not in the hypergraph. - """ - for node in node_list: - self.remove_node(node, keep_edges=keep_edges) - - def get_nodes(self, metadata=False): - if metadata: - return self._node_metadata - return list(self._node_metadata.keys()) - - def check_node(self, node): - """Checks if the specified node is in the hypergraph. - - Parameters - ---------- - node : Object - The node to check. - - Returns - ------- - bool - True if the node is in the hypergraph, False otherwise. - - """ - return node in self._adj - - def get_neighbors(self, node, order: int = None, size: int = None): - """ - Get the neighbors of a node in the hypergraph. - - Parameters - ---------- - node : object - The node of interest. - order : int - The order of the hyperedges to consider. - size : int - The size of the hyperedges to consider. - - Returns - ------- - set - The neighbors of the node. - - Raises - ------ - ValueError - If order and size are both specified or neither are specified. - """ - if node not in self._adj: - raise ValueError("Node {} not in hypergraph.".format(node)) - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - if order is None and size is None: - neigh = set() - edges = self.get_incident_edges(node) - for edge in edges: - neigh.update(edge[1]) - if node in neigh: - neigh.remove(node) - return neigh - else: - if order is None: - order = size - 1 - neigh = set() - edges = self.get_incident_edges(node, order=order) - for edge in edges: - neigh.update(edge[1]) - if node in neigh: - neigh.remove(node) - return neigh - - def get_incident_edges(self, node, order: int = None, size: int = None): - """ - Get the incident hyperedges of a node in the hypergraph. - - Parameters - ---------- - node : object - The node of interest. - order : int - The order of the hyperedges to consider. - size : int - The size of the hyperedges to consider. - - Returns - ------- - list - The incident hyperedges of the node. - - Raises - ------ - ValueError - If the node is not in the hypergraph. - - """ - if node not in self._adj: - raise ValueError("Node {} not in hypergraph.".format(node)) - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - if order is None and size is None: - return list( - [self._reverse_edge_list[edge_id] for edge_id in self._adj[node]] - ) - else: - if order is None: - order = size - 1 - return list( - [ - self._reverse_edge_list[edge_id] - for edge_id in self._adj[node] - if len(self._reverse_edge_list[edge_id][1]) - 1 == order - ] - ) - - # Edge - def add_edge(self, edge, time, weight=None, metadata=None): - """ - Add an edge to the temporal hypergraph. If the edge already exists, the weight is updated. - - Parameters - ---------- - edge : tuple - The edge to add. If the hypergraph is undirected, should be a tuple. - If the hypergraph is directed, should be a tuple of two tuples. - time: int - The time at which the edge occurs. - weight: float, optional - The weight of the edge. Default is None. - - metadata: dict, optional - Metadata for the edge. Default is an empty dictionary. - - Raises - ------ - TypeError - If time is not an integer. - ValueError - If the hypergraph is not weighted and weight is not None or 1. - """ - if not isinstance(time, int): - raise TypeError("Time must be an integer") - - if not self._weighted and weight is not None and weight != 1: - raise ValueError( - "If the hypergraph is not weighted, weight can be 1 or None." - ) - if weight is None: - weight = 1 - - t = time - - if t < 0: - raise ValueError("Time must be a positive integer") - - _edge = _canon_edge(edge) - edge = (t, _edge) - - if edge not in self._edge_list: - e_id = self._next_edge_id - self._reverse_edge_list[e_id] = edge - self._edge_list[edge] = e_id - self._next_edge_id += 1 - self._weights[e_id] = weight - elif edge in self._edge_list and self._weighted: - self._weights[self._edge_list[edge]] += weight - - e_id = self._edge_list[edge] - - if metadata is None: - metadata = {} - self._edge_metadata[e_id] = metadata - - nodes = _get_nodes(_edge) - for node in nodes: - self.add_node(node) - - for node in nodes: - self._adj[node].append(e_id) - - def add_edges(self, edge_list, time_list, weights=None, metadata=None): - """ - Add multiple edges to the temporal hypergraph. - - Parameters - ---------- - edge_list: list - A list of edges to add. - time_list: list - A list of times corresponding to each edge in `edge_list`. - weights: list, optional - A list of weights for each edge in `edge_list`. Must be provided if the hypergraph is weighted. - metadata: list, optional - A list of metadata dictionaries for each edge in `edge_list`. - - Raises - ------ - TypeError - If `edge_list` and `time_list` are not lists. - ValueError - If `edge_list` and `time_list` have mismatched lengths. - """ - if not isinstance(edge_list, list) or not isinstance(time_list, list): - raise TypeError("Edge list and time list must be lists") - - if len(edge_list) != len(time_list): - raise ValueError("Edge list and time list must have the same length") - - if weights is not None and not self._weighted: - print( - "Warning: weights are provided but the hypergraph is not weighted. The hypergraph will be weighted." - ) - self._weighted = True - - if self._weighted and weights is not None: - if len(set(edge_list)) != len(list(edge_list)): - raise ValueError( - "If weights are provided, the edge list must not contain repeated edges." - ) - if len(list(edge_list)) != len(list(weights)): - raise ValueError("The number of edges and weights must be the same.") - - i = 0 - if edge_list is not None: - for edge in edge_list: - self.add_edge( - edge, - time_list[i], - weight=( - weights[i] if self._weighted and weights is not None else None - ), - metadata=metadata[i] if metadata is not None else None, - ) - i += 1 - - def remove_edge(self, edge, time): - """ - Remove an edge from the temporal hypergraph. - - Parameters - ---------- - edge : tuple - The edge to remove. - time : int - The time at which the edge occurs. - - Raises - ------ - ValueError - If the edge is not in the hypergraph. - """ - edge = _canon_edge(edge) - edge = (time, edge) - if edge not in self._edge_list: - raise ValueError(f"Edge {edge} not in hypergraph.") - edge_id = self._edge_list[edge] - - # Remove edge from reverse lookup and metadata - del self._reverse_edge_list[edge_id] - if edge_id in self._weights: - del self._weights[edge_id] - if edge_id in self._edge_metadata: - del self._edge_metadata[edge_id] - - time, nodes = edge - for node in nodes: - if edge_id in self._adj[node]: - self._adj[node].remove(edge_id) - - del self._edge_list[edge] - - def remove_edges(self, edge_list): - """ - Remove a list of edges from the hypergraph. - - Parameters - ---------- - edge_list : list - The list of edges to remove. - - Returns - ------- - None - - Raises - ------ - KeyError - """ - for edge in edge_list: - self.remove_edge(edge) - - def get_edge_list(self): - return self._edge_list - - def set_edge_list(self, edge_list): - self._edge_list = edge_list - - def check_edge(self, edge, time): - """Checks if the specified edge is in the hypergraph. - - Parameters - ---------- - edge : tuple - The edge to check. - time : int - The time to check. - Returns - ------- - bool - True if the edge is in the hypergraph, False otherwise. - - """ - edge = _canon_edge(edge) - k = (time, edge) - return k in self._edge_list - - def get_edges( - self, - time_window=None, - order=None, - size=None, - up_to=False, - # subhypergraph = False, - # keep_isolated_nodes=False, - metadata=False, - ): - """ - Get the edges in the temporal hypergraph. If a time window is provided, only edges within the window are returned. - - Parameters - ---------- - time_window: tuple, optional - A tuple of two integers representing the start and end times of the window. - size: int, optional - The size of the hyperedges to consider - order: int, optional - The order of the hyperedges to consider - up_to: bool, optional - metadata: bool, optional - If True, return edge metadata. Default is False. - - Returns - ------- - list - A list of edges in the hypergraph. - """ - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - # if not subhypergraph and keep_isolated_nodes: - # raise ValueError("Cannot keep nodes if not returning subhypergraphs.") - - edges = [] - if time_window is None: - edges = list(self._edge_list.keys()) - elif isinstance(time_window, tuple) and len(time_window) == 2: - for _t, _edge in list(sorted(self._edge_list.keys())): - if time_window[0] <= _t < time_window[1]: - edges.append((_t, _edge)) - else: - raise ValueError("Time window must be a tuple of length 2 or None") - if order is not None or size is not None: - if size is not None: - order = size - 1 - if not up_to: - edges = [edge for edge in edges if len(edge[1]) - 1 == order] - else: - edges = [edge for edge in edges if len(edge[1]) - 1 <= order] - return ( - edges - if not metadata - else {edge: self.get_edge_metadata(edge[1], edge[0]) for edge in edges} - ) - - def aggregate(self, time_window): - if not isinstance(time_window, int) or time_window <= 0: - raise TypeError("Time window must be a positive integer") - - aggregated = {} - node_list = self.get_nodes() - - # Get all edges and determine the max time - sorted_edges = sorted(self.get_edges()) - if not sorted_edges: - return aggregated # Return empty if no edges exist - - max_time = max(edge[0] for edge in sorted_edges) # Maximum time of all edges - - # Initialize time window boundaries - t_start = 0 - t_end = time_window - edges_in_window = [] - num_windows_created = 0 - - edge_index = 0 # Pointer to the current edge in sorted_edges - - while t_start <= max_time: - # Collect edges for the current window - while ( - edge_index < len(sorted_edges) - and t_start <= sorted_edges[edge_index][0] < t_end - ): - edges_in_window.append(sorted_edges[edge_index]) - edge_index += 1 - - # Create the hypergraph for this time window - Hypergraph_t = Hypergraph(weighted=self._weighted) - - # Add edges to the hypergraph - for time, edge_nodes in edges_in_window: - Hypergraph_t.add_edge( - edge_nodes, - metadata=self.get_edge_metadata(edge_nodes, time), - weight=self.get_weight(edge_nodes, time), - ) - - # Add all nodes to ensure node consistency - for node in node_list: - Hypergraph_t.add_node(node, metadata=self._node_metadata[node]) - - # Store the finalized hypergraph for this window - aggregated[num_windows_created] = Hypergraph_t - num_windows_created += 1 - - # Advance to the next time window - t_start = t_end - t_end += time_window - edges_in_window = [] # Reset for the next window - - return aggregated - - def get_times_for_edge(self, edge): - """ - Get the times at which a specific set of nodes forms a hyperedge in the hypergraph. - - Parameters - ---------- - edge: tuple - The set of nodes forming the hyperedge. - - Returns - ------- - times: list - A list of times at which the hyperedge occurs. - """ - edge = _canon_edge(edge) - times = [] - for time, _edge in self._edge_list.keys(): - if _edge == edge: - times.append(time) - return times - - # Weight - def set_weight(self, edge, time, weight): - edge = _canon_edge(edge) - if not self._weighted and weight != 1: - raise ValueError( - "If the hypergraph is not weighted, weight can be 1 or None." - ) - if (time, edge) not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - e_id = self._edge_list[(time, edge)] - self._weights[e_id] = weight - - def get_weight(self, edge, time): - edge = _canon_edge(edge) - if (time, edge) not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - e_id = self._edge_list[(time, edge)] - return self._weights[e_id] - - def get_weights(self, order=None, size=None, up_to=False, asdict=False): - """Returns the list of weights of the edges in the hypergraph. If order is specified, it returns the list of weights of the edges of the specified order. - If size is specified, it returns the list of weights of the edges of the specified size. If both order and size are specified, it raises a ValueError. - If up_to is True, it returns the list of weights of the edges of order smaller or equal to the specified order. - - Parameters - ---------- - order : int, optional - Order of the edges to get the weights of. - - size : int, optional - Size of the edges to get the weights of. - - up_to : bool, optional - If True, it returns the list of weights of the edges of order smaller or equal to the specified order. Default is False. - - asdict : bool, optional - Returns - ------- - list - List of weights of the edges in the hypergraph. - - Raises - ------ - ValueError - If both order and size are specified. - - """ - w = None - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - if order is None and size is None: - w = { - edge: self._weights[self._edge_list[edge]] for edge in self.get_edges() - } - - if size is not None: - order = size - 1 - - if w is None: - w = { - edge: self._weights[self._edge_list[edge]] - for edge in self.get_edges(order=order, up_to=up_to) - } - - if asdict: - return w - else: - return list(w.values()) - - # Info - def max_order(self): - """ - Returns the maximum order of the hypergraph. - - Returns - ------- - int - Maximum order of the hypergraph. - """ - return self.max_size() - 1 - - def max_size(self): - """ - Returns the maximum size of the hypergraph. - - Returns - ------- - int - Maximum size of the hypergraph. - """ - return max(self.get_sizes()) - - def num_nodes(self): - """ - Returns the number of nodes in the hypergraph. - - Returns - ------- - int - Number of nodes in the hypergraph. - """ - return len(list(self.get_nodes())) - - def num_edges(self, order=None, size=None, up_to=False): - """Returns the number of edges in the hypergraph. If order is specified, it returns the number of edges of the specified order. - If size is specified, it returns the number of edges of the specified size. If both order and size are specified, it raises a ValueError. - If up_to is True, it returns the number of edges of order smaller or equal to the specified order. - - Parameters - ---------- - order : int, optional - Order of the edges to count. - size : int, optional - Size of the edges to count. - up_to : bool, optional - If True, it returns the number of edges of order smaller or equal to the specified order. Default is False. - - Returns - ------- - int - Number of edges in the hypergraph. - """ - if order is not None and size is not None: - raise ValueError("Order and size cannot be both specified.") - - if order is None and size is None: - return len(self._edge_list) - else: - if size is not None: - order = size - 1 - if not up_to: - s = 0 - for edge in self._edge_list: - if len(edge) - 1 == order: - s += 1 - return s - else: - s = 0 - for edge in self._edge_list: - if len(edge) - 1 <= order: - s += 1 - return s - - def distribution_sizes(self): - """ - Returns the distribution of sizes of the hyperedges in the hypergraph. - - Returns - ------- - collections.Counter - Distribution of sizes of the hyperedges in the hypergraph. - """ - from collections import Counter - - return dict(Counter(self.get_sizes())) - - def get_sizes(self): - """ - Get the size of each edge in the hypergraph. - - Returns - ------- - list - A list of integers representing the size of each edge. - """ - return [_get_size(edge[1]) for edge in self._edge_list.keys()] - - def get_orders(self): - """ - Get the order of each edge in the hypergraph. - - Returns - ------- - list - A list of integers representing the order of each edge. - """ - return [_get_order(edge[1]) for edge in self._edge_list.keys()] - - def is_weighted(self): - """ - Check if the hypergraph is weighted. - - Returns - ------- - bool - True if the hypergraph is weighted, False otherwise. - """ - return self._weighted - - def is_uniform(self): - """ - Check if the hypergraph is uniform, i.e. all hyperedges have the same size. - - Returns - ------- - bool - True if the hypergraph is uniform, False otherwise. - """ - uniform = True - sz = None - for edge in self._edge_list: - if sz is None: - sz = len(edge[1]) - else: - if len(edge[1]) != sz: - uniform = False - break - return uniform - - def min_time(self): - min = math.inf - for edge in self._edge_list: - if min > edge[0]: - min = edge[0] - return min - - def max_time(self): - max = -math.inf - for edge in self._edge_list: - if max < edge[0]: - max = edge[0] - return max - - # Adj - def get_adj_dict(self): - return self._adj - - def set_adj_dict(self, adj_dict): - self._adj = adj_dict - - # Degree - def degree(self, node, order=None, size=None): - from hypergraphx.measures.degree import degree - - return degree(self, node, order=order, size=size) - - def degree_sequence(self, order=None, size=None): - from hypergraphx.measures.degree import degree_sequence - - return degree_sequence(self, order=order, size=size) - - def degree_distribution(self, order=None, size=None): - from hypergraphx.measures.degree import degree_distribution - - return degree_distribution(self, order=order, size=size) - - # Utils - def isolated_nodes(self, size=None, order=None): - from hypergraphx.utils.cc import isolated_nodes - - return isolated_nodes(self, size=size, order=order) - - def is_isolated(self, node, size=None, order=None): - from hypergraphx.utils.cc import is_isolated - - return is_isolated(self, node, size=size, order=order) - - def temporal_adjacency_matrix(self, return_mapping: bool = False): - from hypergraphx.linalg import temporal_adjacency_matrix - - return temporal_adjacency_matrix(self, return_mapping) - - def annealed_adjacency_matrix(self, return_mapping: bool = False): - from hypergraphx.linalg import annealed_adjacency_matrix - - return annealed_adjacency_matrix(self, return_mapping) - - def adjacency_factor(self, t: int = 0): - from hypergraphx.linalg import adjacency_factor - - return adjacency_factor(self, t) - - def subhypergraph( - self, time_window=None, add_all_nodes: bool = False - ) -> dict[int, Hypergraph]: - """ - Create an hypergraph for each time of the Temporal Hypergraph. - Parameters - ---------- - time_window : tuple[int,int]|None, optional - Give the time window (a,b), only the times inside the interval [a,b) will be considered. - If not specified all the times will be considered. - add_all_nodes : bool, optional - If True, the hypergraphs will have all the nodes of the Temporal Hypergraph even if they are not present - in their corresponding time. - Returns - ------- - dict: dict[int, Hypergraph] - A dictionary where the keys are the time and the values are the hypergraphs - """ - edges = self.get_edges() - res = dict() - if time_window is None: - time_window = (-math.inf, math.inf) - if not isinstance(time_window, tuple): - raise ValueError("Time window must be a tuple of length 2 or None") - - for edge in edges: - if time_window[0] <= edge[0] < time_window[1]: - if edge[0] not in res.keys(): - res[edge[0]] = Hypergraph(weighted=self.is_weighted()) - weight = self.get_weight(edge[1], edge[0]) - res[edge[0]].add_edge(edge[1], weight) - if add_all_nodes: - for node in self.get_nodes(): - for k, v in res.items(): - if v.check_node(node): - v.add_node(node) - - return res - - # Metadata - def set_hypergraph_metadata(self, metadata): - self._hypergraph_metadata = metadata - - def get_hypergraph_metadata(self): - return self._hypergraph_metadata - - def set_node_metadata(self, node, metadata): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - self._node_metadata[node] = metadata - - def get_node_metadata(self, node): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - return self._node_metadata[node] - - def get_all_nodes_metadata(self): - return self._node_metadata - - def set_edge_metadata(self, edge, time, metadata): - edge = _canon_edge(edge) - k = (time, edge) - if k not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - e_id = self._edge_list[k] - self._edge_metadata[e_id] = metadata - - def get_edge_metadata(self, edge, time): - edge = _canon_edge(edge) - k = (time, edge) - if k not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - e_id = self._edge_list[k] - return self._edge_metadata[e_id] - - def get_all_edges_metadata(self): - return self._edge_metadata - - def set_incidence_metadata(self, edge, time, node, metadata): - edge = _canon_edge(edge) - k = (time, edge) - if k not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - self._incidences_metadata[(k, node)] = metadata - - def get_incidence_metadata(self, edge, time, node): - edge = _canon_edge(edge) - k = (time, edge) - if k not in self._edge_list: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - return self._incidences_metadata[(k, node)] - - def get_all_incidences_metadata(self): - return {k: v for k, v in self._incidences_metadata.items()} - - def set_attr_to_hypergraph_metadata(self, field, value): - self._hypergraph_metadata[field] = value - - def set_attr_to_node_metadata(self, node, field, value): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - self._node_metadata[node][field] = value - - def set_attr_to_edge_metadata(self, edge, time, field, value): - _edge = _canon_edge(edge) - if edge not in self._edge_metadata: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - self._edge_metadata[self._edge_list[(time, edge)]][field] = value - - def remove_attr_from_node_metadata(self, node, field): - if node not in self._node_metadata: - raise ValueError("Node {} not in hypergraph.".format(node)) - del self._node_metadata[node][field] - - def remove_attr_from_edge_metadata(self, edge, time, field): - _edge = _canon_edge(edge) - if edge not in self._edge_metadata: - raise ValueError("Edge {} not in hypergraph.".format(edge)) - del self._edge_metadata[self._edge_list[(time, edge)]][field] - - # Basic Functions - def clear(self): - self._edge_list.clear() - self._adj.clear() - self._weights.clear() - self._hypergraph_metadata.clear() - self._node_metadata.clear() - self._edge_metadata.clear() - self._reverse_edge_list.clear() - - def copy(self): - """ - Returns a copy of the hypergraph. - - Returns - ------- - Hypergraph - A copy of the hypergraph. - """ - return copy.deepcopy(self) - - def __str__(self): - """ - Returns a string representation of the hypergraph. - - Returns - ------- - str - A string representation of the hypergraph. - """ - title = "Hypergraph with {} nodes and {} edges.\n".format( - self.num_nodes(), self.num_edges() - ) - details = "Distribution of hyperedge sizes: {}".format( - self.distribution_sizes() - ) - return title + details - - def __len__(self): - """ - Returns the number of edges in the hypergraph. - - Returns - ------- - int - The number of edges in the hypergraph. - """ - return len(self._edge_list) - - def __iter__(self): - """ - Returns an iterator over the edges in the hypergraph. - - Returns - ------- - iterator - An iterator over the edges in the hypergraph. - """ - return iter(self._edge_list.items()) - - # Data Structure Extra - def expose_data_structures(self): - """ - Expose the internal data structures of the temporal hypergraph for serialization. - - Returns - ------- - dict - A dictionary containing all internal attributes of the temporal hypergraph. - """ - return { - "type": "TemporalHypergraph", - "hypergraph_metadata": self._hypergraph_metadata, - "_weighted": self._weighted, - "_weights": self._weights, - "_adj": self._adj, - "_edge_list": self._edge_list, - "node_metadata": self._node_metadata, - "edge_metadata": self._edge_metadata, - "reverse_edge_list": self._reverse_edge_list, - "next_edge_id": self._next_edge_id, - } - - def populate_from_dict(self, data): - """ - Populate the attributes of the temporal hypergraph from a dictionary. - - Parameters - ---------- - data : dict - A dictionary containing the attributes to populate the hypergraph. - """ - self._hypergraph_metadata = data.get("hypergraph_metadata", {}) - self._weighted = data.get("_weighted", False) - self._weights = data.get("_weights", {}) - self._adj = data.get("_adj", {}) - self._edge_list = data.get("_edge_list", {}) - self._node_metadata = data.get("node_metadata", {}) - self._edge_metadata = data.get("edge_metadata", {}) - self._reverse_edge_list = data.get("reverse_edge_list", {}) - self._next_edge_id = data.get("next_edge_id", 0) - - def expose_attributes_for_hashing(self): - """ - Expose relevant attributes for hashing specific to TemporalHypergraph. - - Returns - ------- - dict - A dictionary containing key attributes. - """ - edges = [] - for edge in sorted(self._edge_list.keys()): - edge = (edge[0], tuple(sorted(edge[1]))) - edge_id = self._edge_list[edge] - edges.append( - { - "nodes": edge, - "weight": self._weights.get(edge_id, 1), - "metadata": self._edge_metadata.get(edge_id, {}), - } - ) - - nodes = [] - for node in sorted(self._node_metadata.keys()): - nodes.append({"node": node, "metadata": self._node_metadata[node]}) - - return { - "type": "TemporalHypergraph", - "weighted": self._weighted, - "hypergraph_metadata": self._hypergraph_metadata, - "edges": edges, - "nodes": nodes, - } - - def get_mapping(self): - """ - Map the nodes of the hypergraph to integers in [0, n_nodes). - - Returns - ------- - LabelEncoder - The mapping. - """ - encoder = LabelEncoder() - encoder.fit(self.get_nodes()) - return encoder diff --git a/hypergraphx/generation/activity_driven.py b/hypergraphx/generation/activity_driven.py index c369dd1..8a802ff 100644 --- a/hypergraphx/generation/activity_driven.py +++ b/hypergraphx/generation/activity_driven.py @@ -1,6 +1,6 @@ import random -from hypergraphx.core.temporal_hypergraph import TemporalHypergraph +from hypergraphx.core.TemporalHypergraph import TemporalHypergraph from hypergraphx.dynamics.randwalk import * diff --git a/hypergraphx/viz/HypergraphVisualizer.py b/hypergraphx/viz/HypergraphVisualizer.py new file mode 100644 index 0000000..8e04bc5 --- /dev/null +++ b/hypergraphx/viz/HypergraphVisualizer.py @@ -0,0 +1,72 @@ +import random +import networkx as nx +import numpy as np + +from hypergraphx.core.Hypergraph import Hypergraph +from hypergraphx.viz.IHypergraphVisualizer import IHypergraphVisualizer +from hypergraphx.linalg import * +from hypergraphx.generation.random import * + +class HypergraphVisualizer(IHypergraphVisualizer): + def __init__(self, g: Hypergraph): + super().__init__(g=g) + self.directed = False + + def to_nx(self) -> nx.Graph: + return self.get_pairwise_subgraph() + + def get_hyperedge_labels(self, key:str="label") -> Dict[tuple, str]: + """ + Get hyperedge labels for visualization. + """ + return { + edge: metadata.get(key, '') + for edge, metadata in self.g.get_edges(metadata=True).items() + if key in metadata.keys() and len(edge) > 2 + } + + def get_hyperedge_styling_data( + self, + hye: Tuple[int], + pos: Dict[int, tuple], + number_of_refinements: int = 12 + ) -> Tuple[Tuple[float, float], Tuple[List[float], List[float]]]: + """ + Get the fill data for a hyperedge. + """ + # Center of mass of points. + points, x_c, y_c = self.get_hyperedge_center_of_mass(pos, hye) + + # Order points in a clockwise fashion. + points = sorted( + points, + key=lambda point: np.arctan2(point[1] - y_c, point[0] - x_c) + ) + + offset_multiplier = 2.5 if len(points) == 3 else 1.8 + points = [ + ( + x_c + offset_multiplier * (x - x_c), + y_c + offset_multiplier * (y - y_c) + ) for x, y in points + ] + + smoothed_obj_coords = self.Smooth_by_Chaikin(points, number_of_refinements) + + order = len(hye) - 1 + if order not in self.hyperedge_color_by_order.keys(): + std_color = "#" + "%06x" % random.randint(0, 0xFFFFFF) + self.hyperedge_color_by_order[order] = std_color + + if order not in self.hyperedge_facecolor_by_order.keys(): + std_face_color = "#" + "%06x" % random.randint(0, 0xFFFFFF) + self.hyperedge_facecolor_by_order[order] = std_face_color + + # Extract x and y coordinates from the smoothed object. + return ( + (x_c, y_c), + ( + [pt[0] for pt in smoothed_obj_coords], + [pt[1] for pt in smoothed_obj_coords] + ) + ) \ No newline at end of file diff --git a/hypergraphx/viz/IHypergraphVisualizer.py b/hypergraphx/viz/IHypergraphVisualizer.py new file mode 100644 index 0000000..7303b76 --- /dev/null +++ b/hypergraphx/viz/IHypergraphVisualizer.py @@ -0,0 +1,368 @@ +from abc import ABC, abstractmethod +from typing import Optional, Union + +import matplotlib.pyplot as plt +import networkx as nx +import numpy as np + +from hypergraphx.core.IHypergraph import IHypergraph +from hypergraphx.linalg import * +from hypergraphx.generation.random import * +from hypergraphx.representations.projections import clique_projection + +class IHypergraphVisualizer(ABC): + def __init__(self, g: IHypergraph): + self.g = g + self.directed = None + self.node_labels = self.get_node_labels() + self.pairwise_edge_labels = self.get_pairwise_edge_labels() + + # Hyperedge stuff + self.hyperedge_labels = self.get_hyperedge_labels() + self.hyperedge_color_by_order = dict() + self.hyperedge_facecolor_by_order = dict() + + + @classmethod + def get_hyperedge_center_of_mass(cls, + pos: Dict[int, tuple], + hye: Tuple[int] + ) -> Tuple[List[Tuple[int, int]], float, float]: + points = [ + ( + pos[node][0], + pos[node][1] + ) for node in hye + ] + x_c = np.mean([x for x, y in points]) + y_c = np.mean([y for x, y in points]) + return points, x_c, y_c + + + @classmethod + def Smooth_by_Chaikin(cls, + coords: List[Tuple[float, float]], + number_of_refinements: int + ) -> List[Tuple[float, float]]: + coords = np.array(coords, dtype=float) + + for _ in range(number_of_refinements): + new_coords = [] + + # Wrap around so last point connects to first + pairs = zip(coords, np.roll(coords, -1, axis=0)) + + for p1, p2 in pairs: + p1 = np.asarray(p1) + p2 = np.asarray(p2) + + # Q: 1/4 of the way toward p2 + Q = 0.75 * p1 + 0.25 * p2 + # R: 3/4 of the way toward p2 + R = 0.25 * p1 + 0.75 * p2 + + new_coords.append(Q) + new_coords.append(R) + + coords = np.array(new_coords) + + return [tuple(map(float, pt)) for pt in coords] + + + def get_pairwise_subgraph(self) -> nx.DiGraph | nx.Graph: + """ + Convert a directed/undirected Hypergraph to a NetworkX Graph. + """ + if self.directed: + G = nx.DiGraph() + else: + G = nx.Graph() + + for node in self.g.get_nodes(): + G.add_node( + node, + **self.g.get_node_metadata(node) + ) + + for edge in self.g.get_edges(order=1, metadata=True): + G.add_edge( + edge[0], + edge[1], + **self.g.get_edge_metadata(edge) + ) + + return G + + + def get_node_labels(self, key:str="label") -> Dict[int, str]: + """ + Get node labels for visualization. + """ + return { + node: metadata.get(key, '') + for node, metadata in self.g.get_nodes( + metadata=True + ).items() + if key in metadata.keys() + } + + + def get_pairwise_edge_labels(self, key:str="label") -> Dict[tuple, str]: + """ + Get edge labels for edges of order 1 (standard edge pairs) to use for visualization. + """ + return { + edge: metadata.get(key, '') + for edge, metadata in self.g.get_edges( + order=1, + metadata=True + ).items() + if key in metadata.keys() + } + + + @abstractmethod + def get_hyperedge_labels(self, key:str="label") -> Dict[tuple, str]: + """ + Get hyperedge labels for visualization. + """ + pass + + + @abstractmethod + def get_hyperedge_styling_data( + self, + hye, + pos: Dict[int, tuple], + number_of_refinements: int = 12 + ) -> Tuple[Tuple[float, float], Tuple[List[float], List[float]]]: + """ + Get the fill data for a hyperedge. + + Returns 2 tuples: + (x_c, y_c): center of mass of the hyperedge + (x_coords, y_coords): coords of the shape enclosing members of the hyperedge + """ + pass + + + @abstractmethod + def to_nx(self, *args, **kwargs) -> nx.DiGraph | nx.Graph: + pass + + + def draw_pairwise_G(self, + # main parameters + ax: Optional[plt.Axes] = None, + pos: Optional[dict] = None, + + # node styling + with_node_labels: bool = False, + node_size: Union[int, np.array] = 150, + node_color: Union[str, np.array] = "#E2E0DD", + node_facecolor: Union[str, np.array] = "black", + node_shape: str = "o", + + # edge styling + with_pairwise_edge_labels: bool = False, + pairwise_edge_color: str = "lightgrey", + pairwise_edge_width: float = 1.2, + + # other styling parameters + label_size: float = 10, + label_col: str = "black", + ): + + # Initialize a networkx graph with the nodes and only the pairwise interactions of the hypergraph. + pairwise_G = self.to_nx() + + if type(node_shape) == str: + node_shape = {n: node_shape for n in pairwise_G.nodes()} + for nid, n in enumerate(list(pairwise_G.nodes())): + nx.draw_networkx_nodes( + G=pairwise_G, + pos=pos, + nodelist=[n], + node_size=node_size, + node_shape=node_shape[n], + node_color=node_color, + alpha=0.8, + linewidths=2, + edgecolors=node_facecolor, + ax=ax, + ) + if self.node_labels: + nx.draw_networkx_labels( + G=pairwise_G, + pos=pos, + labels=self.node_labels if with_node_labels else None, + font_size=int(label_size), + font_color=label_col, + ax=ax, + ) + + # Plot the edges of the pairwise graph. + if self.pairwise_edge_labels: + nx.draw_networkx_edge_labels( + G=pairwise_G, + pos=pos, + edge_labels=self.pairwise_edge_labels if with_pairwise_edge_labels else None, + font_size=int(label_size), + font_color=label_col, + ax=ax, + ) + nx.draw_networkx_edges( + G=pairwise_G, + pos=pos, + width=pairwise_edge_width, + edge_color=pairwise_edge_color, + alpha=0.8, + ax=ax, + ) + + def draw_hyperedges(self, + # main parameters + ax: Optional[plt.Axes] = None, + pos: Optional[dict] = None, + + # hyperedge styling + # Set color hyperedges of size > 2 (order > 1). + with_hyperedge_labels: bool = False, + hyperedge_alpha: Union[float, np.array] = 0.8, + + # other styling parameters + label_size: float = 10, + label_col: str = "black", + ): + for hye in list(self.g.get_edges()): + order = len(hye) - 1 + if order > 1: + center_of_mass, outline_coords = self.get_hyperedge_styling_data( + hye, + pos, + ) + x_c, y_c = center_of_mass + x_coords, y_coords = outline_coords + + ax.fill( + x_coords, + y_coords, + alpha=hyperedge_alpha, + c=self.hyperedge_color_by_order[order], + edgecolor=self.hyperedge_facecolor_by_order[order], + ) + if with_hyperedge_labels: + ax.annotate( + self.hyperedge_labels.get(hye, ''), + (x_c, y_c), + fontsize=label_size, + color=label_col, + ) + + def draw(self, + # main parameters + figsize: tuple = (12, 7), + ax: Optional[plt.Axes] = None, + + # node position parameters + pos: Optional[dict] = None, + iterations: int = 100, + seed: int = 10, + scale: int = 1, + opt_dist: float = 0.5, + + # node styling + with_node_labels: bool = False, + node_size: Union[int, np.array] = 150, + node_color: Union[str, np.array] = "#E2E0DD", + node_facecolor: Union[str, np.array] = "black", + node_shape: str = "o", + + # edge styling + with_pairwise_edge_labels: bool = False, + pairwise_edge_color: str = "lightgrey", + pairwise_edge_width: float = 1.2, + + # hyperedge styling + # Set color hyperedges of size > 2 (order > 1). + with_hyperedge_labels: bool = False, + hyperedge_color_by_order: dict = {2: "#FFBC79", 3: "#79BCFF", 4: "#4C9F4C"}, + hyperedge_facecolor_by_order: dict = {2: "#FFBC79", 3: "#79BCFF", 4: "#4C9F4C"}, + hyperedge_alpha: Union[float, np.array] = 0.8, + + # other styling parameters + label_size: float = 10, + label_col: str = "black", + + # other parameters + plot_title: str = "Hypergraph" + ): + """Visualize a hypergraph.""" + # Initialize figure. + if ax is None: + plt.figure(figsize=figsize) + plt.subplot(1, 1, 1) + ax = plt.gca() + + # Extract node positions based on the hypergraph clique projection. + if pos is None: + pos = nx.spring_layout( + G=clique_projection( + self.g, + keep_isolated=True + ), # type: ignore + iterations=iterations, + seed=seed, + scale=scale, + k=opt_dist, + ) + + + # Plot the subgraph of nodes and their pairwise edges ONLY + self.draw_pairwise_G( + # main parameters + ax=ax, + pos=pos, + + # node styling + with_node_labels=with_node_labels, + node_size=node_size, + node_color=node_color, + node_facecolor=node_facecolor, + node_shape=node_shape, + + # edge styling + with_pairwise_edge_labels=with_pairwise_edge_labels, + pairwise_edge_color=pairwise_edge_color, + pairwise_edge_width=pairwise_edge_width, + + # other styling parameters + label_size=label_size, + label_col=label_col, + ) + + # Configure this object's attributes with given hyperedge styling info + self.hyperedge_color_by_order.update(hyperedge_color_by_order) + self.hyperedge_facecolor_by_order.update(hyperedge_facecolor_by_order) + + # Plot the hyperedges (size>2/order>1). + self.draw_hyperedges( + # main parameters + ax=ax, + pos=pos, + + # hyperedge styling + # Set color hyperedges of size > 2 (order > 1). + with_hyperedge_labels=with_hyperedge_labels, + hyperedge_alpha=hyperedge_alpha, + + # other styling parameters + label_size=label_size, + label_col=label_col + ) + + # Set the aspect ratio of the plot to be equal. + ax.axis("equal") + plt.axis("equal") + plt.title(plot_title) + plt.show() \ No newline at end of file diff --git a/hypergraphx/viz/__init__.py b/hypergraphx/viz/__init__.py index 116d157..adf4e40 100644 --- a/hypergraphx/viz/__init__.py +++ b/hypergraphx/viz/__init__.py @@ -1,4 +1,19 @@ +# Import interfaces/abstract classes +from hypergraphx.viz.IHypergraphVisualizer import IHypergraphVisualizer + +# Import classes +from hypergraphx.viz.HypergraphVisualizer import HypergraphVisualizer + +# Import functions from hypergraphx.viz.draw_communities import draw_communities from hypergraphx.viz.draw_hypergraph import draw_hypergraph from hypergraphx.viz.draw_projections import draw_bipartite, draw_clique from hypergraphx.viz.plot_motifs import plot_motifs +from hypergraphx.viz.hypergraph_to_dot import ( + hypergraph_to_dot, + _parse_edgelist, + _auxiliary_method, + _direct_method, + _cluster_method, + save_and_render +) \ No newline at end of file diff --git a/hypergraphx/viz/draw_hypergraph.py b/hypergraphx/viz/draw_hypergraph.py index ec6cc6d..4d00abd 100644 --- a/hypergraphx/viz/draw_hypergraph.py +++ b/hypergraphx/viz/draw_hypergraph.py @@ -1,196 +1,8 @@ -import random -from typing import Optional, Union +from hypergraphx.core.Hypergraph import Hypergraph +from hypergraphx.viz.HypergraphVisualizer import HypergraphVisualizer -import matplotlib.pyplot as plt -import networkx as nx -import numpy as np - -from hypergraphx import Hypergraph -from hypergraphx.representations.projections import clique_projection - - -def Sum_points(P1, P2): - x1, y1 = P1 - x2, y2 = P2 - return x1 + x2, y1 + y2 - - -def Multiply_point(multiplier, P): - x, y = P - return float(x) * float(multiplier), float(y) * float(multiplier) - - -def Check_if_object_is_polygon(Cartesian_coords_list): - if ( - Cartesian_coords_list[0] - == Cartesian_coords_list[len(Cartesian_coords_list) - 1] - ): - return True - else: - return False - - -class Object: - def __init__(self, Cartesian_coords_list): - self.Cartesian_coords_list = Cartesian_coords_list - - def Find_Q_point_position(self, P1, P2): - Summand1 = Multiply_point(float(3) / float(4), P1) - Summand2 = Multiply_point(float(1) / float(4), P2) - Q = Sum_points(Summand1, Summand2) - return Q - - def Find_R_point_position(self, P1, P2): - Summand1 = Multiply_point(float(1) / float(4), P1) - Summand2 = Multiply_point(float(3) / float(4), P2) - R = Sum_points(Summand1, Summand2) - return R - - def Smooth_by_Chaikin(self, number_of_refinements): - refinement = 1 - copy_first_coord = Check_if_object_is_polygon(self.Cartesian_coords_list) - obj = Object(self.Cartesian_coords_list) - while refinement <= number_of_refinements: - self.New_cartesian_coords_list = [] - - for num, tuple in enumerate(self.Cartesian_coords_list): - if num + 1 == len(self.Cartesian_coords_list): - pass - else: - P1, P2 = (tuple, self.Cartesian_coords_list[num + 1]) - Q = obj.Find_Q_point_position(P1, P2) - R = obj.Find_R_point_position(P1, P2) - self.New_cartesian_coords_list.append(Q) - self.New_cartesian_coords_list.append(R) - - if copy_first_coord: - self.New_cartesian_coords_list.append(self.New_cartesian_coords_list[0]) - - self.Cartesian_coords_list = self.New_cartesian_coords_list - refinement += 1 - return self.Cartesian_coords_list - - -def draw_hypergraph( - hypergraph: Hypergraph, - figsize: tuple = (12, 7), - ax: Optional[plt.Axes] = None, - pos: Optional[dict] = None, - edge_color: str = "lightgrey", - hyperedge_color_by_order: Optional[dict] = None, - hyperedge_facecolor_by_order: Optional[dict] = None, - edge_width: float = 1.2, - hyperedge_alpha: Union[float, np.array] = 0.8, - node_size: Union[int, np.array] = 150, - node_color: Union[str, np.array] = "#E2E0DD", - node_facecolor: Union[str, np.array] = "black", - node_shape: str = "o", - with_node_labels: bool = False, - label_size: float = 10, - label_col: str = "black", - seed: int = 10, - scale: int = 1, - iterations: int = 100, - opt_dist: float = 0.5, -): +def draw_hypergraph(hypergraph: Hypergraph, *args, **kwargs): """Visualize a hypergraph.""" - # Initialize figure. - if ax is None: - plt.figure(figsize=figsize) - plt.subplot(1, 1, 1) - ax = plt.gca() - - # Extract node positions based on the hypergraph clique projection. - if pos is None: - pos = nx.spring_layout( - clique_projection(hypergraph, keep_isolated=True), - iterations=iterations, - seed=seed, - scale=scale, - k=opt_dist, - ) - - # Set color hyperedges of size > 2 (order > 1). - if hyperedge_color_by_order is None: - hyperedge_color_by_order = {2: "#FFBC79", 3: "#79BCFF", 4: "#4C9F4C"} - if hyperedge_facecolor_by_order is None: - hyperedge_facecolor_by_order = {2: "#FFBC79", 3: "#79BCFF", 4: "#4C9F4C"} - - # Extract edges (hyperedges of size=2/order=1). - edges = hypergraph.get_edges(order=1) - - # Initialize empty graph with the nodes and the pairwise interactions of the hypergraph. - G = nx.Graph() - G.add_nodes_from(hypergraph.get_nodes()) - for e in edges: - G.add_edge(e[0], e[1]) - - # Plot the graph. - if type(node_shape) == str: - node_shape = {n: node_shape for n in G.nodes()} - for nid, n in enumerate(list(G.nodes())): - nx.draw_networkx_nodes( - G, - pos, - [n], - node_size=node_size, - node_shape=node_shape[n], - node_color=node_color, - edgecolors=node_facecolor, - ax=ax, - ) - if with_node_labels: - ax.annotate( - n, - (pos[n][0] - 0.1, pos[n][1] - 0.06), - fontsize=label_size, - color=label_col, - ) - - # Plot the hyperedges (size>2/order>1). - for hye in list(hypergraph.get_edges()): - if len(hye) > 2: - points = [] - for node in hye: - points.append((pos[node][0], pos[node][1])) - # Center of mass of points. - x_c = np.mean([x for x, y in points]) - y_c = np.mean([y for x, y in points]) - # Order points in a clockwise fashion. - points = sorted(points, key=lambda x: np.arctan2(x[1] - y_c, x[0] - x_c)) - - if len(points) == 3: - points = [ - (x_c + 2.5 * (x - x_c), y_c + 2.5 * (y - y_c)) for x, y in points - ] - else: - points = [ - (x_c + 1.8 * (x - x_c), y_c + 1.8 * (y - y_c)) for x, y in points - ] - Cartesian_coords_list = points + [points[0]] - - obj = Object(Cartesian_coords_list) - Smoothed_obj = obj.Smooth_by_Chaikin(number_of_refinements=12) - - # Visualisation. - x1 = [i for i, j in Smoothed_obj] - y1 = [j for i, j in Smoothed_obj] - - order = len(hye) - 1 - - if order not in hyperedge_color_by_order.keys(): - std_color = "#" + "%06x" % random.randint(0, 0xFFFFFF) - hyperedge_color_by_order[order] = std_color - - if order not in hyperedge_facecolor_by_order.keys(): - std_face_color = "#" + "%06x" % random.randint(0, 0xFFFFFF) - hyperedge_facecolor_by_order[order] = std_face_color - - color = hyperedge_color_by_order[order] - facecolor = hyperedge_facecolor_by_order[order] - ax.fill(x1, y1, alpha=hyperedge_alpha, c=color, edgecolor=facecolor) - - nx.draw_networkx_edges(G, pos, width=edge_width, edge_color=edge_color, ax=ax) - - ax.axis("equal") - plt.axis("equal") + # Initialize HypergraphVisualizer object + hypergraph_visualizer = HypergraphVisualizer(hypergraph) + hypergraph_visualizer.draw(*args, **kwargs) \ No newline at end of file diff --git a/hypergraphx/viz/hypergraph_to_dot.py b/hypergraphx/viz/hypergraph_to_dot.py new file mode 100644 index 0000000..810f32e --- /dev/null +++ b/hypergraphx/viz/hypergraph_to_dot.py @@ -0,0 +1,384 @@ +def hypergraph_to_dot(edgelist, method="auxiliary", graph_name="hypergraph"): + """ + Convert a hypergraph edgelist to DOT format. + + Args: + edgelist: List of tuples or single tuples + - Directed: ((sources,), (targets,)) + - Undirected: (node1, node2, node3, ...) + method: "auxiliary", "direct", or "cluster" + graph_name: Name for the DOT graph + + Returns: + String containing DOT format representation + """ + + # Separate directed and undirected hyperedges + directed_edges, undirected_edges = _parse_edgelist(edgelist) + + if method == "auxiliary": + return _auxiliary_method(directed_edges, undirected_edges, graph_name) + elif method == "direct": + return _direct_method(directed_edges, undirected_edges, graph_name) + elif method == "cluster": + return _cluster_method(directed_edges, undirected_edges, graph_name) + else: + raise ValueError("Method must be 'auxiliary', 'direct', or 'cluster'") + +def _parse_edgelist(edgelist): + """ + Parse edgelist to separate directed and undirected hyperedges. + + Returns: + tuple: (directed_edges, undirected_edges) + """ + directed_edges = [] + undirected_edges = [] + + for edge in edgelist: + if isinstance(edge, tuple) and len(edge) == 2 and isinstance(edge[0], tuple): + # Directed hyperedge: ((sources,), (targets,)) + directed_edges.append(edge) + else: + # Undirected hyperedge: (node1, node2, node3, ...) + if isinstance(edge, tuple) and len(edge) > 1: + undirected_edges.append(edge) + else: + raise ValueError(f"Invalid edge format: {edge}") + + return directed_edges, undirected_edges + +def _auxiliary_method(directed_edges, undirected_edges, graph_name): + """Convert using auxiliary hyperedge nodes with clusters for multi-node groups.""" + + # Collect all nodes + all_nodes = set() + for sources, targets in directed_edges: + all_nodes.update(sources) + all_nodes.update(targets) + for nodes in undirected_edges: + all_nodes.update(nodes) + + dot_lines = [f"digraph {graph_name} {{"] + dot_lines.append(" // Global node styling") + dot_lines.append(" node [shape=circle];") + dot_lines.append("") + + # Track which nodes are in clusters to avoid duplication + clustered_nodes = set() + cluster_counter = 0 + + # Create clusters for multi-node source/target groups in directed edges + if directed_edges: + dot_lines.append(" // Source and target clusters for directed hyperedges") + + for i, (sources, targets) in enumerate(directed_edges): + # Create source cluster if multiple sources + if len(sources) > 1: + cluster_name = f"cluster_src_{i+1}" + dot_lines.append(f" subgraph {cluster_name} {{") + dot_lines.append(f' label="Sources {i+1}";') + dot_lines.append(" style=filled;") + dot_lines.append(" fillcolor=lightcyan;") + dot_lines.append(" color=blue;") + for source in sorted(sources): + dot_lines.append(f" {source};") + clustered_nodes.add(source) + dot_lines.append(" }") + dot_lines.append("") + + # Create target cluster if multiple targets + if len(targets) > 1: + cluster_name = f"cluster_tgt_{i+1}" + dot_lines.append(f" subgraph {cluster_name} {{") + dot_lines.append(f' label="Targets {i+1}";') + dot_lines.append(" style=filled;") + dot_lines.append(" fillcolor=lightpink;") + dot_lines.append(" color=red;") + for target in sorted(targets): + dot_lines.append(f" {target};") + clustered_nodes.add(target) + dot_lines.append(" }") + dot_lines.append("") + + # Create clusters for undirected hyperedges + if undirected_edges: + dot_lines.append(" // Undirected hyperedge clusters") + for i, nodes in enumerate(undirected_edges): + cluster_name = f"cluster_undirected_{i+1}" + dot_lines.append(f" subgraph {cluster_name} {{") + dot_lines.append(f' label="Undirected HE {i+1}";') + dot_lines.append(" style=filled;") + dot_lines.append(" fillcolor=lightyellow;") + dot_lines.append(" color=orange;") + for node in sorted(nodes): + dot_lines.append(f" {node};") + clustered_nodes.add(node) + # Add internal connections within undirected hyperedge + nodes_list = list(nodes) + if len(nodes_list) > 1: + dot_lines.append("") + for j in range(len(nodes_list)): + for k in range(j + 1, len(nodes_list)): + dot_lines.append(f" {nodes_list[j]} -> {nodes_list[k]} [dir=none, color=orange];") + dot_lines.append(" }") + dot_lines.append("") + + # Add standalone nodes (not in any cluster) + standalone_nodes = all_nodes - clustered_nodes + if standalone_nodes: + dot_lines.append(" // Standalone nodes") + for node in sorted(standalone_nodes): + dot_lines.append(f" {node};") + dot_lines.append("") + + # Add directed hyperedge auxiliary nodes + if directed_edges: + dot_lines.append(" // Directed hyperedge auxiliary nodes") + for i, (sources, targets) in enumerate(directed_edges): + he_name = f"dhe{i+1}" + dot_lines.append(f' {he_name} [label="DHE{i+1}", shape=box, style=filled, fillcolor=lightblue];') + dot_lines.append("") + + dot_lines.append(" // Directed hyperedge connections") + for i, (sources, targets) in enumerate(directed_edges): + he_name = f"dhe{i+1}" + + # Sources to hyperedge + for source in sources: + dot_lines.append(f" {source} -> {he_name};") + + # Hyperedge to targets + for target in targets: + dot_lines.append(f" {he_name} -> {target};") + + dot_lines.append("") + + # Add undirected hyperedge auxiliary nodes + if undirected_edges: + dot_lines.append(" // Undirected hyperedge auxiliary nodes") + for i, nodes in enumerate(undirected_edges): + he_name = f"uhe{i+1}" + dot_lines.append(f' {he_name} [label="UHE{i+1}", shape=diamond, style=filled, fillcolor=lightgreen];') + dot_lines.append("") + + dot_lines.append(" // Undirected hyperedge connections") + for i, nodes in enumerate(undirected_edges): + he_name = f"uhe{i+1}" + + # Bidirectional connections between hyperedge and all nodes + for node in nodes: + dot_lines.append(f" {node} -> {he_name} [dir=none];") + + dot_lines.append("") + + dot_lines.append("}") + return "\n".join(dot_lines) + +def _direct_method(directed_edges, undirected_edges, graph_name): + """Convert using direct connections with clusters for multi-node groups.""" + + # Collect all nodes + all_nodes = set() + for sources, targets in directed_edges: + all_nodes.update(sources) + all_nodes.update(targets) + for nodes in undirected_edges: + all_nodes.update(nodes) + + # Track which nodes are in clusters to avoid duplication + clustered_nodes = set() + direct_edges_set = set() + + dot_lines = [f"digraph {graph_name} {{"] + dot_lines.append(" // Global node styling") + dot_lines.append(" node [shape=circle];") + dot_lines.append("") + + # Create clusters for multi-node source/target groups in directed edges + if directed_edges: + dot_lines.append(" // Source and target clusters for directed hyperedges") + + for i, (sources, targets) in enumerate(directed_edges): + # Create source cluster if multiple sources + if len(sources) > 1: + cluster_name = f"cluster_src_{i+1}" + dot_lines.append(f" subgraph {cluster_name} {{") + dot_lines.append(f' label="Sources {i+1}";') + dot_lines.append(" style=filled;") + dot_lines.append(" fillcolor=lightcyan;") + dot_lines.append(" color=blue;") + for source in sorted(sources): + dot_lines.append(f" {source};") + clustered_nodes.add(source) + + # Add internal connections within source cluster + sources_list = list(sources) + if len(sources_list) > 1: + dot_lines.append("") + for j in range(len(sources_list)): + for k in range(j + 1, len(sources_list)): + dot_lines.append(f" {sources_list[j]} -> {sources_list[k]} [dir=none, color=blue, style=dashed];") + + dot_lines.append(" }") + dot_lines.append("") + + # Create target cluster if multiple targets + if len(targets) > 1: + cluster_name = f"cluster_tgt_{i+1}" + dot_lines.append(f" subgraph {cluster_name} {{") + dot_lines.append(f' label="Targets {i+1}";') + dot_lines.append(" style=filled;") + dot_lines.append(" fillcolor=lightpink;") + dot_lines.append(" color=red;") + for target in sorted(targets): + dot_lines.append(f" {target};") + clustered_nodes.add(target) + + # Add internal connections within target cluster + targets_list = list(targets) + if len(targets_list) > 1: + dot_lines.append("") + for j in range(len(targets_list)): + for k in range(j + 1, len(targets_list)): + dot_lines.append(f" {targets_list[j]} -> {targets_list[k]} [dir=none, color=red, style=dashed];") + + dot_lines.append(" }") + dot_lines.append("") + + # Create direct edges from each source to each target + for source in sources: + for target in targets: + direct_edges_set.add((source, target)) + + # Create clusters for undirected hyperedges + if undirected_edges: + dot_lines.append(" // Undirected hyperedge clusters") + for i, nodes in enumerate(undirected_edges): + cluster_name = f"cluster_undirected_{i+1}" + dot_lines.append(f" subgraph {cluster_name} {{") + dot_lines.append(f' label="Undirected HE {i+1}";') + dot_lines.append(" style=filled;") + dot_lines.append(" fillcolor=lightyellow;") + dot_lines.append(" color=orange;") + + for node in sorted(nodes): + dot_lines.append(f" {node};") + clustered_nodes.add(node) + + # Add bidirectional edges between all pairs in the hyperedge + nodes_list = list(nodes) + if len(nodes_list) > 1: + dot_lines.append("") + for j in range(len(nodes_list)): + for k in range(j + 1, len(nodes_list)): + dot_lines.append(f" {nodes_list[j]} -> {nodes_list[k]} [dir=none, color=orange];") + + dot_lines.append(" }") + dot_lines.append("") + + # Add standalone nodes (not in any cluster) + standalone_nodes = all_nodes - clustered_nodes + if standalone_nodes: + dot_lines.append(" // Standalone nodes") + for node in sorted(standalone_nodes): + dot_lines.append(f" {node};") + dot_lines.append("") + + # Add directed edges between clusters and standalone nodes + if direct_edges_set: + dot_lines.append(" // Directed edges") + for source, target in sorted(direct_edges_set): + dot_lines.append(f" {source} -> {target};") + dot_lines.append("") + + dot_lines.append("}") + return "\n".join(dot_lines) + +def _cluster_method(directed_edges, undirected_edges, graph_name): + """Convert using subgraph clusters for undirected hyperedges.""" + + # Collect all nodes + all_nodes = set() + for sources, targets in directed_edges: + all_nodes.update(sources) + all_nodes.update(targets) + for nodes in undirected_edges: + all_nodes.update(nodes) + + # Find nodes that are only in clusters (not in directed edges) + nodes_in_directed = set() + for sources, targets in directed_edges: + nodes_in_directed.update(sources) + nodes_in_directed.update(targets) + + dot_lines = [f"digraph {graph_name} {{"] + dot_lines.append(" // Global node styling") + dot_lines.append(" node [shape=circle];") + dot_lines.append("") + + # Create clusters for undirected hyperedges + if undirected_edges: + for i, nodes in enumerate(undirected_edges): + cluster_name = f"cluster_{i}" + dot_lines.append(f" subgraph {cluster_name} {{") + dot_lines.append(f' label="Hyperedge {i+1}";') + dot_lines.append(" style=filled;") + dot_lines.append(" fillcolor=lightyellow;") + dot_lines.append(" color=orange;") + dot_lines.append("") + + # Add nodes to cluster + for node in sorted(nodes): + dot_lines.append(f" {node};") + + # Add edges within cluster (complete subgraph) + nodes_list = list(nodes) + if len(nodes_list) > 1: + dot_lines.append("") + dot_lines.append(" // Internal connections") + for j in range(len(nodes_list)): + for k in range(j + 1, len(nodes_list)): + dot_lines.append(f" {nodes_list[j]} -> {nodes_list[k]} [dir=none, color=orange];") + + dot_lines.append(" }") + dot_lines.append("") + + # Add standalone nodes (not in any cluster) + standalone_nodes = all_nodes - set().union(*undirected_edges) if undirected_edges else all_nodes + if standalone_nodes: + dot_lines.append(" // Standalone nodes") + for node in sorted(standalone_nodes): + dot_lines.append(f" {node};") + dot_lines.append("") + + # Add directed edges + if directed_edges: + dot_lines.append(" // Directed edges") + for sources, targets in directed_edges: + for source in sources: + for target in targets: + dot_lines.append(f" {source} -> {target};") + dot_lines.append("") + + dot_lines.append("}") + return "\n".join(dot_lines) + +def save_and_render(dot_content:str, filename:str, format:str = "png"): + """ + Save DOT content to file and optionally render with Graphviz. + + Args: + dot_content: DOT format string + filename: Output filename (without extension) + format: Output format (png, svg, pdf, etc.) + """ + + # Save DOT file + dot_filename = f"{filename}.dot" + with open(dot_filename, 'w') as f: + f.write(dot_content) + + print(f"DOT file saved as: {dot_filename}") + print(f"To render with Graphviz, run:") + print(f"dot -T{format} {dot_filename} -o {filename}.{format}") \ No newline at end of file diff --git a/tests/viz/test_hypergraph_to_dot.py b/tests/viz/test_hypergraph_to_dot.py new file mode 100644 index 0000000..e8e5830 --- /dev/null +++ b/tests/viz/test_hypergraph_to_dot.py @@ -0,0 +1,361 @@ +import pytest +from unittest.mock import Mock, patch, mock_open + +# Import the functions to test (assuming they're in a module called hypergraph_dot_converter) +from hypergraphx.viz.hypergraph_to_dot import ( + hypergraph_to_dot, + _parse_edgelist, + _auxiliary_method, + _direct_method, + _cluster_method, + save_and_render +) + + +class TestHypergraphDotConverter: + """Test suite for hypergraph to DOT converter functions.""" + + @pytest.fixture + def simple_directed_edgelist(self): + """Create a simple directed hypergraph edgelist for testing.""" + return [ + ((1,), (2,)), + ((1,), (3, 4, 5)), + ((8, 7), (1,)) + ] + + @pytest.fixture + def simple_undirected_edgelist(self): + """Create a simple undirected hypergraph edgelist for testing.""" + return [ + (1, 2, 3, 4), + (6, 7, 8), + (9, 10) + ] + + @pytest.fixture + def mixed_edgelist(self): + """Create a mixed hypergraph edgelist for testing.""" + return [ + # Directed hyperedges + ((1,), (2,)), + ((1,), (3, 4, 5)), + ((8, 7), (1,)), + # Undirected hyperedges + (1, 2, 3, 4), + (6, 7, 8), + (9, 10) + ] + + @pytest.fixture + def complex_directed_edgelist(self): + """Create a complex directed hypergraph with multi-node sources and targets.""" + return [ + ((1, 2, 3), (4, 5, 6, 7)), + ((8, 9), (10,)), + ((11,), (12, 13)) + ] + + @pytest.fixture + def empty_edgelist(self): + """Create an empty edgelist for testing.""" + return [] + + def test_parse_edgelist_directed_only(self, simple_directed_edgelist): + """Test parsing edgelist with only directed hyperedges.""" + directed_edges, undirected_edges = _parse_edgelist(simple_directed_edgelist) + + assert len(directed_edges) == 3 + assert len(undirected_edges) == 0 + assert directed_edges == simple_directed_edgelist + + def test_parse_edgelist_undirected_only(self, simple_undirected_edgelist): + """Test parsing edgelist with only undirected hyperedges.""" + directed_edges, undirected_edges = _parse_edgelist(simple_undirected_edgelist) + + assert len(directed_edges) == 0 + assert len(undirected_edges) == 3 + assert undirected_edges == simple_undirected_edgelist + + def test_parse_edgelist_mixed(self, mixed_edgelist): + """Test parsing edgelist with both directed and undirected hyperedges.""" + directed_edges, undirected_edges = _parse_edgelist(mixed_edgelist) + + assert len(directed_edges) == 3 + assert len(undirected_edges) == 3 + + # Check directed edges + expected_directed = [ + ((1,), (2,)), + ((1,), (3, 4, 5)), + ((8, 7), (1,)) + ] + assert directed_edges == expected_directed + + # Check undirected edges + expected_undirected = [ + (1, 2, 3, 4), + (6, 7, 8), + (9, 10) + ] + assert undirected_edges == expected_undirected + + def test_parse_edgelist_empty(self, empty_edgelist): + """Test parsing empty edgelist.""" + directed_edges, undirected_edges = _parse_edgelist(empty_edgelist) + + assert len(directed_edges) == 0 + assert len(undirected_edges) == 0 + + def test_parse_edgelist_invalid_format(self): + """Test parsing edgelist with invalid format raises error.""" + invalid_edgelist = [ + ((1,), (2,)), # Valid + "invalid_edge", # Invalid + ] + + with pytest.raises(ValueError, match="Invalid edge format"): + _parse_edgelist(invalid_edgelist) + + def test_hypergraph_to_dot_auxiliary_method(self, simple_directed_edgelist): + """Test hypergraph_to_dot with auxiliary method.""" + result = hypergraph_to_dot(simple_directed_edgelist, method="auxiliary") + + assert isinstance(result, str) + assert "digraph hypergraph {" in result + assert "DHE1" in result + assert "DHE2" in result + assert "DHE3" in result + assert "shape=box" in result + assert "fillcolor=lightblue" in result + + def test_hypergraph_to_dot_direct_method(self, simple_directed_edgelist): + """Test hypergraph_to_dot with direct method.""" + result = hypergraph_to_dot(simple_directed_edgelist, method="direct") + + assert isinstance(result, str) + assert "digraph hypergraph {" in result + assert "1 -> 2;" in result + assert "1 -> 3;" in result + assert "1 -> 4;" in result + assert "1 -> 5;" in result + + def test_hypergraph_to_dot_cluster_method(self, simple_undirected_edgelist): + """Test hypergraph_to_dot with cluster method.""" + result = hypergraph_to_dot(simple_undirected_edgelist, method="cluster") + + assert isinstance(result, str) + assert "digraph hypergraph {" in result + assert "subgraph cluster_" in result + assert "fillcolor=lightyellow" in result + assert "dir=none" in result + + def test_hypergraph_to_dot_invalid_method(self, simple_directed_edgelist): + """Test hypergraph_to_dot with invalid method raises error.""" + with pytest.raises(ValueError, match="Method must be"): + hypergraph_to_dot(simple_directed_edgelist, method="invalid") + + def test_hypergraph_to_dot_custom_graph_name(self, simple_directed_edgelist): + """Test hypergraph_to_dot with custom graph name.""" + result = hypergraph_to_dot(simple_directed_edgelist, graph_name="custom_graph") + + assert "digraph custom_graph {" in result + + def test_auxiliary_method_clusters_multi_node_sources(self, complex_directed_edgelist): + """Test auxiliary method creates clusters for multi-node sources.""" + directed_edges, undirected_edges = _parse_edgelist(complex_directed_edgelist) + result = _auxiliary_method(directed_edges, undirected_edges, "test_graph") + + assert "cluster_src_1" in result + assert "Sources 1" in result + assert "fillcolor=lightcyan" in result + assert "color=blue" in result + + def test_auxiliary_method_clusters_multi_node_targets(self, complex_directed_edgelist): + """Test auxiliary method creates clusters for multi-node targets.""" + directed_edges, undirected_edges = _parse_edgelist(complex_directed_edgelist) + result = _auxiliary_method(directed_edges, undirected_edges, "test_graph") + + assert "cluster_tgt_1" in result + assert "Targets 1" in result + assert "fillcolor=lightpink" in result + assert "color=red" in result + + def test_auxiliary_method_single_nodes_not_clustered(self): + """Test auxiliary method doesn't cluster single nodes.""" + directed_edges = [((1,), (2,))] # Single source, single target + undirected_edges = [] + result = _auxiliary_method(directed_edges, undirected_edges, "test_graph") + + assert "cluster_src_" not in result + assert "cluster_tgt_" not in result + assert "DHE1" in result + + def test_auxiliary_method_undirected_hyperedges(self, simple_undirected_edgelist): + """Test auxiliary method handles undirected hyperedges.""" + directed_edges = [] + undirected_edges = simple_undirected_edgelist + result = _auxiliary_method(directed_edges, undirected_edges, "test_graph") + + assert "cluster_undirected_1" in result + assert "UHE1" in result + assert "shape=diamond" in result + assert "fillcolor=lightgreen" in result + assert "dir=none" in result + + def test_direct_method_clusters_multi_node_groups(self, complex_directed_edgelist): + """Test direct method creates clusters for multi-node groups.""" + directed_edges, undirected_edges = _parse_edgelist(complex_directed_edgelist) + result = _direct_method(directed_edges, undirected_edges, "test_graph") + + assert "cluster_src_1" in result + assert "cluster_tgt_1" in result + assert "style=dashed" in result + + def test_direct_method_internal_cluster_connections(self, complex_directed_edgelist): + """Test direct method adds internal connections within clusters.""" + directed_edges, undirected_edges = _parse_edgelist(complex_directed_edgelist) + result = _direct_method(directed_edges, undirected_edges, "test_graph") + + # Should have dashed internal connections in source cluster + assert "dir=none" in result + assert "color=blue" in result + assert "style=dashed" in result + + def test_cluster_method_creates_subgraphs(self, simple_undirected_edgelist): + """Test cluster method creates proper subgraph clusters.""" + directed_edges = [] + undirected_edges = simple_undirected_edgelist + result = _cluster_method(directed_edges, undirected_edges, "test_graph") + + assert "subgraph cluster_0" in result + assert "subgraph cluster_1" in result + assert "subgraph cluster_2" in result + assert "label=\"Hyperedge 1\"" in result + + def test_cluster_method_internal_edges(self, simple_undirected_edgelist): + """Test cluster method adds internal edges within clusters.""" + directed_edges = [] + undirected_edges = simple_undirected_edgelist + result = _cluster_method(directed_edges, undirected_edges, "test_graph") + + assert "dir=none" in result + assert "color=orange" in result + + def test_cluster_method_mixed_edges(self, mixed_edgelist): + """Test cluster method handles both directed and undirected edges.""" + directed_edges, undirected_edges = _parse_edgelist(mixed_edgelist) + result = _cluster_method(directed_edges, undirected_edges, "test_graph") + + # Should have both clusters and directed edges + assert "subgraph cluster_" in result + assert "1 -> 2;" in result + + @patch("builtins.open", new_callable=mock_open) + @patch("builtins.print") + def test_save_and_render_creates_file(self, mock_print, mock_file): + """Test save_and_render creates DOT file.""" + dot_content = "digraph test { 1 -> 2; }" + save_and_render(dot_content, "test_graph", "png") + + mock_file.assert_called_once_with("test_graph.dot", 'w') + mock_file().write.assert_called_once_with(dot_content) + + @patch("builtins.open", new_callable=mock_open) + @patch("builtins.print") + def test_save_and_render_print_instructions(self, mock_print, mock_file): + """Test save_and_render prints rendering instructions.""" + dot_content = "digraph test { 1 -> 2; }" + save_and_render(dot_content, "test_graph", "svg") + + # Check that appropriate messages were printed + print_calls = [call[0][0] for call in mock_print.call_args_list] + assert any("DOT file saved as: test_graph.dot" in call for call in print_calls) + assert any("dot -Tsvg test_graph.dot -o test_graph.svg" in call for call in print_calls) + + def test_auxiliary_method_empty_edgelist(self): + """Test auxiliary method handles empty edgelist.""" + result = _auxiliary_method([], [], "empty_graph") + + assert "digraph empty_graph {" in result + assert "}" in result + # Should not contain any nodes or edges + assert "DHE" not in result + assert "UHE" not in result + + def test_direct_method_empty_edgelist(self): + """Test direct method handles empty edgelist.""" + result = _direct_method([], [], "empty_graph") + + assert "digraph empty_graph {" in result + assert "}" in result + + def test_cluster_method_empty_edgelist(self): + """Test cluster method handles empty edgelist.""" + result = _cluster_method([], [], "empty_graph") + + assert "digraph empty_graph {" in result + assert "}" in result + + @pytest.mark.parametrize("method", ["auxiliary", "direct", "cluster"]) + def test_all_methods_handle_empty_input(self, method, empty_edgelist): + """Test all methods handle empty input gracefully.""" + result = hypergraph_to_dot(empty_edgelist, method=method) + + assert isinstance(result, str) + assert "digraph hypergraph {" in result + assert "}" in result + + @pytest.mark.parametrize("method", ["auxiliary", "direct", "cluster"]) + def test_all_methods_return_valid_dot(self, method, mixed_edgelist): + """Test all methods return valid DOT format.""" + result = hypergraph_to_dot(mixed_edgelist, method=method) + + assert isinstance(result, str) + assert result.startswith("digraph") + assert result.endswith("}") + assert "{" in result + + # Should not have syntax errors (basic check) + assert result.count("{") == result.count("}") + + def test_node_deduplication_across_edges(self): + """Test that nodes appearing in multiple edges are handled correctly.""" + edgelist = [ + ((1, 2), (3, 4)), # 1,2 in source; 3,4 in target + ((3, 4), (5, 6)), # 3,4 now in source + (1, 5, 7) # 1,5 in undirected edge + ] + + result = hypergraph_to_dot(edgelist, method="auxiliary") + + # Each node should appear only once in node declarations + node_declarations = [line for line in result.split('\n') if line.strip().endswith(';') and '->' not in line and 'label=' not in line] + # Nodes 1,2,3,4,5,6,7 should all be present but not duplicated in clusters + assert isinstance(result, str) + + def test_large_hyperedge_handling(self): + """Test handling of large hyperedges.""" + large_hyperedge = tuple(range(1, 21)) # 20-node hyperedge + edgelist = [large_hyperedge] + + result = hypergraph_to_dot(edgelist, method="cluster") + + assert isinstance(result, str) + assert "subgraph cluster_0" in result + # Should handle large hyperedges without errors + for i in range(1, 21): + assert str(i) in result + + @pytest.mark.parametrize("format_type", ["png", "svg", "pdf", "dot"]) + def test_save_and_render_different_formats(self, format_type): + """Test save_and_render with different output formats.""" + with patch("builtins.open", mock_open()) as mock_file, \ + patch("builtins.print") as mock_print: + + dot_content = "digraph test { 1 -> 2; }" + save_and_render(dot_content, "test", format_type) + + # Check format in printed command + print_calls = [call[0][0] for call in mock_print.call_args_list] + assert any(f"dot -T{format_type}" in call for call in print_calls) \ No newline at end of file diff --git a/tests/viz/test_hypergraph_visualizer.py b/tests/viz/test_hypergraph_visualizer.py new file mode 100644 index 0000000..2ee9f1c --- /dev/null +++ b/tests/viz/test_hypergraph_visualizer.py @@ -0,0 +1,359 @@ +import pytest +import copy +import networkx as nx +from unittest.mock import Mock, patch + +# Import the classes to test +from hypergraphx.core.Hypergraph import Hypergraph +from hypergraphx.viz.HypergraphVisualizer import HypergraphVisualizer + + +class TestHypergraphVisualizer: + """Test suite for HypergraphVisualizer class.""" + + @pytest.fixture + def simple_hypergraph(self): + """Create a simple hypergraph for testing.""" + h = Hypergraph() + h.add_nodes([1, 2, 3, 4]) + h.add_edges([ + (1, 2), # Order 1 edge + (2, 3), # Order 1 edge + (1, 2, 3), # Order 2 edge (hyperedge) + (2, 3, 4), # Order 2 edge (hyperedge) + ]) + return h + + @pytest.fixture + def hypergraph_with_metadata(self): + """Create a hypergraph with metadata for testing.""" + h = Hypergraph() + h.add_nodes([1, 2, 3, 4]) + + # Add node metadata + h.set_node_metadata(1, {"text": "Node1"}) + h.set_node_metadata(2, {"text": "Node2"}) + h.set_node_metadata(3, {"text": "Node3"}) + h.set_node_metadata(4, {"text": "Node4"}) + + # Add edges with metadata + h.add_edge((1, 2), metadata={"label": "edge_type_1"}) + h.add_edge((2, 3), metadata={"label": "edge_type_2"}) + h.add_edge((1, 2, 3), metadata={"label": "hyperedge_type_1"}) + h.add_edge((2, 3, 4), metadata={"label": "hyperedge_type_2"}) + + return h + + @pytest.fixture + def empty_hypergraph(self): + """Create an empty hypergraph for testing.""" + return Hypergraph() + + @pytest.fixture + def visualizer(self, simple_hypergraph): + """Create a HypergraphVisualizer instance.""" + return HypergraphVisualizer(simple_hypergraph) + + @pytest.fixture + def visualizer_with_metadata(self, hypergraph_with_metadata): + """Create a HypergraphVisualizer instance with metadata.""" + return HypergraphVisualizer(hypergraph_with_metadata) + + def test_init(self, simple_hypergraph): + """Test HypergraphVisualizer initialization.""" + visualizer = HypergraphVisualizer(simple_hypergraph) + + assert visualizer.g == simple_hypergraph + assert visualizer.directed == False + assert hasattr(visualizer, 'node_labels') + assert hasattr(visualizer, 'pairwise_edge_labels') + assert hasattr(visualizer, 'hyperedge_labels') + + def test_to_nx_returns_undirected_graph(self, visualizer): + """Test that to_nx returns an undirected NetworkX graph.""" + nx_graph = visualizer.to_nx() + + assert isinstance(nx_graph, (nx.Graph, nx.DiGraph)) + # Since directed=False, it should return the pairwise subgraph + assert hasattr(nx_graph, 'nodes') + assert hasattr(nx_graph, 'edges') + + @patch.object(HypergraphVisualizer, 'get_pairwise_subgraph') + def test_to_nx_calls_get_pairwise_subgraph(self, mock_get_pairwise, visualizer): + """Test that to_nx calls get_pairwise_subgraph.""" + mock_graph = Mock() + mock_get_pairwise.return_value = mock_graph + + result = visualizer.to_nx() + + mock_get_pairwise.assert_called_once() + assert result == mock_graph + + def test_get_hyperedge_labels_with_metadata(self, visualizer_with_metadata): + """Test getting hyperedge labels from metadata.""" + labels = visualizer_with_metadata.get_hyperedge_labels("label") + + # Should only include hyperedges (order > 1, i.e., size > 2) + expected_hyperedges = {(1, 2, 3), (2, 3, 4)} + + for edge in labels.keys(): + assert len(edge) > 2 # Only hyperedges + assert edge in expected_hyperedges + + def test_get_hyperedge_labels_empty_for_no_metadata(self, visualizer): + """Test that get_hyperedge_labels returns empty dict when no metadata.""" + labels = visualizer.get_hyperedge_labels("label") + + # Should be empty since no metadata was added + assert isinstance(labels, dict) + assert len(labels) == 0 + + def test_get_hyperedge_labels_custom_key(self, hypergraph_with_metadata): + """Test getting hyperedge labels with custom metadata key.""" + # Add hyperedge with custom metadata key + h = hypergraph_with_metadata + h.add_edge((1, 3, 4), metadata={"custom_key": "custom_value"}) + + visualizer = HypergraphVisualizer(h) + labels = visualizer.get_hyperedge_labels("custom_key") + + # Should only contain the edge with the custom key + assert (1, 3, 4) in labels + assert labels[(1, 3, 4)] == "custom_value" + + def test_get_hyperedge_styling_data_structure(self, visualizer): + """Test the structure of get_hyperedge_styling_data return value.""" + # Create a simple position dict + pos = {1: (0, 0), 2: (1, 0), 3: (0.5, 1)} + hyperedge = (1, 2, 3) + + result = visualizer.get_hyperedge_styling_data( + hyperedge, pos + ) + + # Should return tuple of (x_coords, y_coords, color, facecolor) + assert isinstance(result, tuple) + assert len(result) == 2 + assert len(result[0]) == 2 + x_c, y_c = result[0] + assert isinstance(x_c, float) + assert isinstance(y_c, float) + assert len(result[1]) == 2 + x_coords, y_coords = result[1] + assert isinstance(x_coords, list) + assert isinstance(y_coords, list) + assert isinstance(visualizer.hyperedge_color_by_order[2], str) + assert isinstance(visualizer.hyperedge_facecolor_by_order[2], str) + + # Colors should be hex strings + assert visualizer.hyperedge_color_by_order[2].startswith('#') + assert visualizer.hyperedge_facecolor_by_order[2].startswith('#') + + def test_get_hyperedge_styling_data_updates_color_dicts(self, visualizer): + """Test that get_hyperedge_styling_data updates color dictionaries.""" + pos = {1: (0, 0), 2: (1, 0), 3: (0.5, 1)} + hyperedge = (1, 2, 3) # Order 2 hyperedge + + visualizer.get_hyperedge_styling_data( + hyperedge, pos + ) + + # Order of (1,2,3) is 2, so order 2 should be added to dicts + assert 2 in visualizer.hyperedge_color_by_order + assert 2 in visualizer.hyperedge_facecolor_by_order + + # Colors should be hex strings + assert visualizer.hyperedge_color_by_order[2].startswith('#') + assert visualizer.hyperedge_facecolor_by_order[2].startswith('#') + + def test_get_hyperedge_styling_data_different_sizes(self, visualizer): + """Test get_hyperedge_styling_data with different hyperedge sizes.""" + # Test with triangle (3 nodes) + pos_3 = {1: (0, 0), 2: (1, 0), 3: (0.5, 1)} + hyperedge_3 = (1, 2, 3) + + # Test with square (4 nodes) + pos_4 = {1: (0, 0), 2: (1, 0), 3: (1, 1), 4: (0, 1)} + hyperedge_4 = (1, 2, 3, 4) + + # Both should work without errors + visualizer1 = copy.deepcopy(visualizer) + result_3 = visualizer1.get_hyperedge_styling_data( + hyperedge_3, pos_3 + ) + + visualizer2 = copy.deepcopy(visualizer) + result_4 = visualizer2.get_hyperedge_styling_data( + hyperedge_4, pos_4 + ) + + assert len(result_3) == 2 + assert len(result_4) == 2 + + # Different orders should have different colors + assert 2 in visualizer1.hyperedge_color_by_order.keys() # Order 2 (size 3) + assert 3 in visualizer2.hyperedge_color_by_order.keys() # Order 3 (size 4) + + def test_get_hyperedge_styling_data_maintains_existing_colors(self, visualizer): + """Test that existing colors in the dictionaries are maintained.""" + pos = {1: (0, 0), 2: (1, 0), 3: (0.5, 1)} + hyperedge = (1, 2, 3) # Order 2 + + # Pre-populate the color dictionaries + existing_color = "#123456" + existing_facecolor = "#654321" + + visualizer.hyperedge_color_by_order.update({2: existing_color}) + visualizer.hyperedge_facecolor_by_order.update({2: existing_facecolor}) + + result = visualizer.get_hyperedge_styling_data( + hyperedge, pos + ) + + # Should use existing colors, not generate new ones + assert visualizer.hyperedge_color_by_order[2] == existing_color + assert visualizer.hyperedge_facecolor_by_order[2] == existing_facecolor + + @patch('hypergraphx.viz.HypergraphVisualizer.random.randint') + def test_get_hyperedge_styling_data_random_color_generation(self, mock_randint, visualizer): + """Test that random colors are generated correctly.""" + mock_randint.side_effect = [0x123456, 0x654321] # Mock random color values + + pos = {1: (0, 0), 2: (1, 0), 3: (0.5, 1)} + hyperedge = (1, 2, 3) + + result = visualizer.get_hyperedge_styling_data( + hyperedge, pos + ) + + # Should generate colors based on mocked random values + assert visualizer.hyperedge_color_by_order[2] == "#123456" + assert visualizer.hyperedge_facecolor_by_order[2] == "#654321" + + # Should call randint twice (once for color, once for facecolor) + assert mock_randint.call_count == 2 + + def test_inheritance_from_ihypergraph_visualizer(self, visualizer): + """Test that HypergraphVisualizer properly inherits from IHypergraphVisualizer.""" + from hypergraphx.viz.IHypergraphVisualizer import IHypergraphVisualizer + + assert isinstance(visualizer, IHypergraphVisualizer) + + # Should have inherited attributes + assert hasattr(visualizer, 'g') + assert hasattr(visualizer, 'directed') + assert hasattr(visualizer, 'node_labels') + assert hasattr(visualizer, 'pairwise_edge_labels') + assert hasattr(visualizer, 'hyperedge_labels') + + def test_directed_attribute_set_correctly(self, visualizer): + """Test that the directed attribute is set to False for undirected hypergraphs.""" + assert visualizer.directed == False + + def test_empty_hypergraph_handling(self, empty_hypergraph): + """Test that visualizer handles empty hypergraphs correctly.""" + visualizer = HypergraphVisualizer(empty_hypergraph) + + assert visualizer.g == empty_hypergraph + assert visualizer.directed == False + + # Should not raise errors when getting labels from empty hypergraph + labels = visualizer.get_hyperedge_labels("label") + assert isinstance(labels, dict) + assert len(labels) == 0 + + @patch('hypergraphx.viz.HypergraphVisualizer.IHypergraphVisualizer.Smooth_by_Chaikin') + @patch('hypergraphx.viz.IHypergraphVisualizer.IHypergraphVisualizer.get_hyperedge_center_of_mass') + def test_get_hyperedge_styling_data_uses_smoothing(self, mock_get_center, mock_Smooth_by_Chaikin, visualizer): + """Test that get_hyperedge_styling_data uses the Object smoothing functionality.""" + + # Mock get_hyperedge_center_of_mass method from the base class + mock_points = [(0, 0), (1, 0), (0.5, 1)] + mock_get_center.return_value = (mock_points, 0.5, 0.33) + + # Mock Smooth_by_Chaikin method from the base class + mock_Smooth_by_Chaikin.return_value = [(i, i) for i in range(49)] # Expected 49 points + + # Initialize color dictionaries to avoid KeyError + visualizer.hyperedge_color_by_order = {} + visualizer.hyperedge_facecolor_by_order = {} + + pos = {1: (0, 0), 2: (1, 0), 3: (0.5, 1)} + hyperedge = (1, 2, 3) + + result = visualizer.get_hyperedge_styling_data(hyperedge, pos, number_of_refinements=4) + + # Verify results + assert len(result) == 2 + assert len(result[0]) == 2 + x_c, y_c = result[0] + assert isinstance(x_c, float) + assert isinstance(y_c, float) + assert len(result[1]) == 2 + x_coords, y_coords = result[1] + assert isinstance(x_coords, list) + assert isinstance(y_coords, list) + assert len(x_coords) == 49 + assert len(y_coords) == 49 + + def test_get_hyperedge_styling_data_invalid_position(self, visualizer): + """Test handling of invalid positions in get_hyperedge_styling_data.""" + # Position dict missing some nodes + pos = {1: (0, 0), 2: (1, 0)} # Missing node 3 + hyperedge = (1, 2, 3) + + # Should raise KeyError when trying to access missing node position + with pytest.raises(KeyError): + visualizer.get_hyperedge_styling_data( + hyperedge, pos + ) + + def test_get_hyperedge_labels_filters_by_size(self, hypergraph_with_metadata): + """Test that get_hyperedge_labels only returns edges with size > 2.""" + visualizer = HypergraphVisualizer(hypergraph_with_metadata) + labels = visualizer.get_hyperedge_labels("label") + + # All returned edges should have size > 2 (be hyperedges) + for edge in labels.keys(): + assert len(edge) > 2 + + def test_get_hyperedge_labels_with_no_matching_key(self, hypergraph_with_metadata): + """Test get_hyperedge_labels when no edges have the requested metadata key.""" + visualizer = HypergraphVisualizer(hypergraph_with_metadata) + labels = visualizer.get_hyperedge_labels("nonexistent_key") + + # Should return empty dict since no edges have this key + assert isinstance(labels, dict) + assert len(labels) == 0 + + @pytest.mark.parametrize("hyperedge_size", [3, 4, 5, 6]) + def test_get_hyperedge_styling_data_various_sizes(self, hyperedge_size): + """Test get_hyperedge_styling_data with various hyperedge sizes.""" + # Create a hypergraph with an edge of the specified size + h = Hypergraph() + nodes = list(range(hyperedge_size)) + edge = tuple(nodes) + h.add_nodes(nodes) + h.add_edge(edge) + + visualizer = HypergraphVisualizer(h) + + # Create positions for all nodes + pos = {i: (i, 0) for i in nodes} + + # Should not raise errors for any reasonable size + result = visualizer.get_hyperedge_styling_data( + edge, pos + ) + + assert len(result) == 2 + assert len(result[0]) == 2 + x_c, y_c = result[0] + assert isinstance(x_c, float) + assert isinstance(y_c, float) + assert len(result[1]) == 2 + x_coords, y_coords = result[1] + assert isinstance(x_coords, list) + assert isinstance(y_coords, list) + assert len(x_coords) > 0 + assert len(y_coords) > 0 \ No newline at end of file diff --git a/tutorials/basics.ipynb b/tutorials/basics.ipynb index b0845f4..cb0c51c 100644 --- a/tutorials/basics.ipynb +++ b/tutorials/basics.ipynb @@ -1,418 +1,705 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import sys\n", - "sys.path.append(\"..\")\n", - "\n", - "from hypergraphx.generation.scale_free import scale_free_hypergraph\n", - "from hypergraphx.linalg import *\n", - "from hypergraphx.representations.projections import bipartite_projection, clique_projection\n", - "from hypergraphx.generation.random import *\n", - "from hypergraphx.readwrite.save import save_hypergraph\n", - "from hypergraphx.readwrite.load import load_hypergraph\n", - "from hypergraphx.viz.draw_hypergraph import draw_hypergraph" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Hypergraph with 8 nodes and 5 edges.\n", - "Distribution of hyperedge sizes: {2: 3, 4: 1, 3: 1}\n" - ] - } - ], - "source": [ - "H = Hypergraph([(1, 3), (1, 4), (1, 2), (5, 6, 7, 8), (1, 2, 3)])\n", - "print(H)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "H = Hypergraph([(1, 3), (1, 4), (1, 2), (5, 6, 7, 8), (1, 2, 3)], weighted=True, weights=[1, 2, 3, 4, 5])" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[(1, 3), (1, 4), (1, 2), (5, 6, 7, 8), (1, 2, 3)]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.get_edges()" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[1, 2, 3, 4, 5]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.get_weights()" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "((1, 3), 0)\n", - "((1, 4), 1)\n", - "((1, 2), 2)\n", - "((5, 6, 7, 8), 3)\n", - "((1, 2, 3), 4)\n" - ] - } - ], - "source": [ - "for edge in H:\n", - " print(edge)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "5" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.get_weight((1, 2, 3))" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "5" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.get_weight((2, 3, 1))" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.is_connected()" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "draw_hypergraph(H)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "H.add_edge((1,5), weight=10)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "draw_hypergraph(H)" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.is_connected()" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.is_uniform()" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], - "source": [ - "print(H.check_edge((5, 6, 7, 8)))\n", - "H.remove_edge((5, 6, 7, 8))" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[(1, 3), (1, 4), (1, 2), (1, 2, 3), (1, 5)]\n", - "[1, 2, 3, 5, 10]\n" - ] - } - ], - "source": [ - "print(H.get_edges())\n", - "print(H.get_weights())\n", - "H.remove_edges([(1,2,3), (1,3)])" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[(1, 4), (1, 2), (1, 5)]\n", - "True\n" - ] - } - ], - "source": [ - "print(H.get_edges())\n", - "print(H.is_uniform())" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[(1, 4), (1, 2), (1, 5)]" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.get_edges()" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [], - "source": [ - "H.add_attr_to_node_metadata(1, 'role', 'student')" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{1: {'role': 'student'}, 3: {}, 4: {}, 2: {}, 5: {}, 6: {}, 7: {}, 8: {}}" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "H.get_nodes(metadata=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": {}, - "outputs": [], - "source": [ - "H.remove_node(1)" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{3: {}, 4: {}, 2: {}, 5: {}, 6: {}, 7: {}, 8: {}}" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "TIZ-QQCanpAF" + }, + "source": [ + "# Install Dependencies" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "7BYry3nqvLek", + "outputId": "9eaa0454-3d7e-46fc-d791-21c020522824" + }, + "outputs": [], + "source": [ + "# !pip install hypergraphx" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "TlwuFnbTntXR" + }, + "source": [ + "# Import/Config" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "Y_eNEC33vD1c" + }, + "outputs": [], + "source": [ + "import sys\n", + "import json\n", + "\n", + "from hypergraphx.generation.scale_free import scale_free_hypergraph\n", + "from hypergraphx.linalg import *\n", + "from hypergraphx.representations.projections import bipartite_projection, clique_projection\n", + "from hypergraphx.generation.random import *\n", + "from hypergraphx.readwrite.save import save_hypergraph\n", + "from hypergraphx.readwrite.load import load_hypergraph\n", + "from hypergraphx.viz.draw_hypergraph import draw_hypergraph" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "JtIUm1bXojr-" + }, + "source": [ + "# Undirected Hypergraph" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "yCGAmGJ3pGpy" + }, + "source": [ + "## Undirected Hypergraph instantiation" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "HLHRDV93vD1f", + "outputId": "f0190c80-6b6b-4ca4-843a-bfe5b98d1695" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hypergraph with 8 nodes and 5 edges.\n", + "Distribution of hyperedge sizes: {2: 3, 4: 1, 3: 1}\n" + ] + } + ], + "source": [ + "H = Hypergraph(\n", + " [(1, 3), (1, 4), (1, 2), (5, 6, 7, 8), (1, 2, 3)],\n", + " weighted=True,\n", + " weights=[1, 2, 3, 4, 5]\n", + ")\n", + "print(H)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6145GU4npQw6" + }, + "source": [ + "## Accessing hypergraph metadata" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "mfZf6yIipmgM" + }, + "source": [ + "### Accessing edge data" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "id": "ny9Fy2XRtEnM" + }, + "outputs": [], + "source": [ + "def print_edge_metadata(g):\n", + " clean_dict = {\n", + " str(k): v\n", + " for k, v in g.get_edges(metadata=True).items()\n", + " }\n", + " print(json.dumps(clean_dict, indent=4))" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "FpKp3Sj7vD1f", + "outputId": "32848be9-c9cf-45ff-b69d-c281537f3b00" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"(1, 3)\": {},\n", + " \"(1, 4)\": {},\n", + " \"(1, 2)\": {},\n", + " \"(5, 6, 7, 8)\": {},\n", + " \"(1, 2, 3)\": {}\n", + "}\n" + ] + } + ], + "source": [ + "print_edge_metadata(H)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "EBfOkS01vD1f", + "outputId": "856f297d-0257-4b97-da70-e48e7edb9f53" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H edges:\n", + "\t(1, 3) has index 0\n", + "\t(1, 4) has index 1\n", + "\t(1, 2) has index 2\n", + "\t(5, 6, 7, 8) has index 3\n", + "\t(1, 2, 3) has index 4\n" + ] + } + ], + "source": [ + "print(\"H edges:\")\n", + "for edge, edge_idx in H:\n", + " print(f\"\\t{edge} has index {edge_idx}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "XbLbHgRWpueF" + }, + "source": [ + "### Accessing edge weights" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "FzqPcePKvD1g", + "outputId": "6d661ac2-24b6-4e1a-ef5d-1c7f00eee8a4" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H weights: [1, 2, 3, 4, 5]\n", + "Weight of (1, 2, 3): 5\n", + "Weight of (2, 3, 1): 5\n" + ] + } + ], + "source": [ + "print(f\"H weights: {H.get_weights()}\")\n", + "print(f\"Weight of (1, 2, 3): {H.get_weight((1, 2, 3))}\")\n", + "print(f\"Weight of (2, 3, 1): {H.get_weight((2, 3, 1))}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GMym8j1GqZBj" + }, + "source": [ + "### Accessing node data" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "9ihRTPRyqbAy", + "outputId": "8be94cb8-347d-412c-f03c-a5ce12a9ce42" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H nodes: {1: {}, 3: {}, 4: {}, 2: {}, 5: {}, 6: {}, 7: {}, 8: {}}\n" + ] + } + ], + "source": [ + "print(f\"H nodes: {H.get_nodes(metadata=True)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "-LApMSkxpx8N" + }, + "source": [ + "### Other graph metadata" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "Zy_iA1kcvD1g", + "outputId": "ceec1a0b-91b3-4354-c2cc-c9f7986bab3c" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H is connected: False\n", + "H is uniform: False\n" + ] + } + ], + "source": [ + "print(f\"H is connected: {H.is_connected()}\")\n", + "print(f\"H is uniform: {H.is_uniform()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "B4DGRDuNp3Cu" + }, + "source": [ + "## Visualize the undirected hypergraph" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 0 + }, + "id": "anOhBNPgvD1h", + "outputId": "969e5a06-abce-4315-d3c9-587274c2ff39" + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "draw_hypergraph(H)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "1kwAs_CJqsvk" + }, + "source": [ + "## Try adding/removing edges" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "EU4DoHWEq_6V" + }, + "source": [ + "### Add edge" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 0 + }, + "id": "XPGKNKmcvD1h", + "outputId": "af4b8c20-7b7e-4719-a956-5128cff9041d" + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "H.add_edge((1,5), weight=10)\n", + "\n", + "draw_hypergraph(H)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "7S-TCOTerWqg" + }, + "source": [ + "### Remove edges" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "M9rzPTnKvD1h", + "outputId": "f52c6b4c-f22c-4134-8740-94df150428c2" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H has edge (5, 6, 7, 8)?: True\n", + "H has edge (5, 6, 7, 8) after remove_edge?: False\n" + ] + } + ], + "source": [ + "print(f\"H has edge (5, 6, 7, 8)?: {H.check_edge((5, 6, 7, 8))}\")\n", + "\n", + "H.remove_edge((5, 6, 7, 8))\n", + "\n", + "print(f\"H has edge (5, 6, 7, 8) after remove_edge?: {H.check_edge((5, 6, 7, 8))}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "Wm6y9CWBvD1h", + "outputId": "7ba10bd8-49da-46ac-bf78-be68dd1a209d" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H edges:\n", + "\t(1, 3) has index 0\n", + "\t(1, 4) has index 1\n", + "\t(1, 2) has index 2\n", + "\t(1, 2, 3) has index 4\n", + "\t(1, 5) has index 5\n", + "H is uniform: False\n", + "H edges after removing (1,2,3), (1,3):\n", + "\t(1, 4) has index 1\n", + "\t(1, 2) has index 2\n", + "\t(1, 5) has index 5\n", + "H is uniform: True\n" + ] + } + ], + "source": [ + "print(\"H edges:\")\n", + "for edge, edge_idx in H:\n", + " print(f\"\\t{edge} has index {edge_idx}\")\n", + "print(f\"H is uniform: {H.is_uniform()}\")\n", + "\n", + "H.remove_edges([(1,2,3), (1,3)])\n", + "\n", + "print(\"H edges after removing (1,2,3), (1,3):\")\n", + "for edge, edge_idx in H:\n", + " print(f\"\\t{edge} has index {edge_idx}\")\n", + "print(f\"H is uniform: {H.is_uniform()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "kcKu8FyYvD1i", + "outputId": "597de49e-d1aa-4800-acbb-dac763410332" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"(1, 4)\": {},\n", + " \"(1, 2)\": {},\n", + " \"(1, 5)\": {}\n", + "}\n" + ] + } + ], + "source": [ + "print_edge_metadata(H)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 598 + }, + "id": "9NCqb30Xsiz4", + "outputId": "86350f15-128c-4d31-815f-d4d644ae5a68" + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA7YAAAJFCAYAAADpi2ubAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjUsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvWftoOwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAMDJJREFUeJzt3X1s3fdd6PHvOcdJnQTH25puoe2SMjYQ8epoLB3SGJQ1lYAo6sawk4ynpiDuNFgmerdLrAlQNgQktCsMeeNyJ6hT8VAnqS5bHv6hzob4A0bT3TkdEQK0NWnp3C0bs7s8dMk55+rzdW3iPNqOE/vrvF7IcmKf3y+/dAr229+nSrPZbCYAAAAoVHW2HwAAAACuhrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAWAG9xP/MRPpDe/+c2z/RgAMG3CFoAi9PX1pUqlkg4dOnTRz4szALhxCVsAAACKJmwB4Bo7ceLEVd/j9OnTqdFozMjzAMB8I2wBmHfuvvvutHr16ot+7gd/8AfTT/7kT+ZfP/vss3l688MPP5z+6I/+KK1cuTItWrQoX//lL3/5gmv/9V//NXV1daXXvOY1qbW1Na1ZsyZ99rOfveiU6b//+79Pv/Zrv5Ze+9rXpttvv33885/85CfTG97whvznvO1tb0v/8A//kKdRx9uYz3/+8/kejz/+ePqt3/qtdNttt6XFixenkZGR9K1vfSt9+MMfTnfeeWf6nu/5nrR06dL00z/902lwcHDCc4zdo7+/P33kIx9Jy5cvT0uWLEn33Xdfeu655y763+bIkSPpne98Z/6z4s/8wz/8wyn+lweA2dEyS38uAEzL8PBwOn78+AUfP3PmzPivf/EXfzH96q/+ao7Tc9fdPvXUU+nf/u3fciye67HHHksvvfRS+vVf//U8MvqJT3wi3XPPPemZZ55Jr3vd6/Jr/uVf/iX96I/+aA6+np6eHIm7du1K7373u9MTTzyRfuZnfmbCPSNqb7nllvQ7v/M74yO2f/qnf5o+8IEPpB/7sR9LDz74YA7ruP7Vr371hPgd87u/+7tp4cKFOWRffvnl/OuIz7/9279N3d3d6fu+7/vSiy++mP7sz/4sx3h87tZbb51wj9/7vd/Lgbt169b09a9/Pf3xH/9xuvfee9OXvvSlHNdj/uu//iv91E/9VHrPe96TNmzYkPbs2ZOviYCOcAaAOa0JAAV49NFHm/Fl63JvHR0d+bXf/va3m62trc2tW7dOuMcHP/jB5pIlS5rf+c538u+/+tWv5usWLVrUfP7558df94UvfCF//MEHHxz/2Nq1a5t33nln8/Tp0+MfazQazbe//e3NN73pTRc85zve8Y7m2bNnxz/+8ssvN2+++ebmXXfd1Txz5sz4x/v6+vLr77777vGPfe5zn8sfe8Mb3tA8efLkhL9D/Pn1en3Cx+LvcdNNNzU/9rGPXXCP2267rTkyMjL+8V27duWPf+ITnxj/WPzZ8bHHHntswvMuX768+bM/+7NX+F8GAGafqcgAFCWm8v7d3/3dBW+dnZ3jr2lvb0/vete70t/8zd/ED3Dzx+r1ep6WGyOkMdp6rvhYjMSOiSnCP/IjP5IOHDiQfx/Tfw8ePJhHMmNkN0aM4+2b3/xmntb87//+7+k///M/J9wzRoxrtdr472M353h9fLyl5b8nTP38z/98HrG9mPvvv3/CqGq46aabUrVaHf87xT1jSnJMsf7iF794wT1+6Zd+KbW1tY3/PqZSf+/3fu/4321M3OMXfuEXxn8fo8Px3+ErX/nKRZ8NAOYSU5EBKErEVqxtPV/E4blTlCPoImRjDeuP//iPpyeffDJP241pyud705vedMHHfuAHfiBPNQ7/8R//kQP5t3/7t/PbxcQ033PjOKYJn+vo0aP5/Rvf+MYJH4/IveOOOy56z/PvEWIDqZgq/alPfSp99atfzXE75uabb77i3y2mJcczxDToc8VU6Pjc+f9NDx8+fNFnA4C5RNgCMC/FSGqsj/3Lv/zLHLbxPjZQivWlUzW2G3GsdR3beOp85wfr+SOt03Gxe/z+7/9+jutf/uVfzmtwYyOrGMH9jd/4javaNfnc0eVzjY14A8BcJmwBmJci1H7u534u71K8Y8eOvOHS+dODx8RU4vPFJlNjI6mxi3FYsGDBtMI4xI7LY6O/sfPwmLNnz+bR03OnUl9ObOoU1//5n//5hI9/+9vfTsuWLbvi3y1CNZ5hsn8eAJTAGlsA5q2Ydhy7/b7vfe9L3/nOdyasIT1XRO+5a2T/+Z//OX3hC18Y3w04juyJ43hi9+Gvfe1rF1z/jW9844rPEtOnY6rwpz/96RyzY/7qr/4qP+NkRZifP4q6e/fuC9b4nr/j87lhHH8HOx0DMJ8YsQVg3nrLW96Sj/uJ8PuhH/qh9MM//MOXnEb8jne8I73//e/Px+rEkTgRob/5m785YdOqeE0cfxMjvzGKG2t2//Ef/zE9//zzF5wje77YjGnbtm1py5Yt+Sih2IgqRmpjRPn7v//7L1jfeinr169PH/vYx9IDDzyQ3v72t+cjiSKOx0aVzxdTleO54/XxvPF3i79v/B0AYL4QtgDMa7GJVATqxTaNOvc1sU41oi82gYoNqnp7e/PuwWNWrVqVdzb+6Ec/mmM0diOOkdyI5zirdjLiDNsYbf34xz+e1+uuXr06ffazn00f/OAHU2tr66Tu8ZGPfCSfi/vXf/3XeXOsiPX9+/fns3Uv9frYAOoP/uAP8sjt2rVr88ZTixcvntSfBwAlqMSZP7P9EABwrcQOwg8++GAeHV2xYsWEz8XHYufhhx56KIfmbIgNn2655Zb0nve8J09Tnimf//zn81rcGK2OI34AYD6zxhaAeSt+dhubLN19990XRO1sOH369AXrY2MNbJyTG2t4AYDpMRUZgHknpurGFN/Pfe5zeQ3qZz7zmTQX/NM//VMePe7u7s5reL/4xS/m8I51wPExAGB6hC0A807sUhxH/bzqVa/Ka0zvu+++NBfE8UGvf/3r05/8yZ/kUdrY2CnW927fvj1vLgUATI81tgAAABTNGlsAAACKJmwBAACY/2ts4yiCF154IbW1tU36AHkAAACYrlg1G2ew33rrrfm8+asO24ja2OwCAAAArqfnnnsu3X777VcftjFSO3bDpUuXzszTAQAAwCWMjIzkAdaxHr3qsB2bfhxRK2wBAAC4XiazHNbmUQAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEVrme0HAKAMjUYjHTlyJA0NDaWTJ0+mxYsXp+XLl6eOjo5UqVRm+/EAgBuYsAXgskZGRtK+ffvSnj170rFjxy74/IoVK1JXV1dav359Wrp06aw8IwBwY6s0m83mZL6paW9vT8PDw75pAbiBRNBu3749nT59Oo/YxltqNlN84chjtJVKqlar+a21tTX19PTkwAUAuFpT6VAjtgBcVF9fX+rt7R0N2no9Rc6uXr06rVnz1rRkyZJ04sSJdOjQ02lwcDA16pV0stFI27ZtS8ePH0+bN2+e7ccHAG4gwhaAC+zdu/eVqK2ner2e7l27Nm3c2J2nHZ9rQ3dXOnr0aNq1a096cmAgpVTL1y1btszILQBw3ZiKDMAF/z9/3bp1eYOoev1semDz/WnTpo2XvSa+lPT370qP9u1MtVpLHtE9cOBAamtru27PDQDML1PpUMf9AHDButq8pnZ8pHbDFa+JXZHjdWvvuSdfd+rUqXwfAIDrQdgCMC7W08bux/E+1tTG9OPJHuUzGrfd+bpmo5F2796dR3IBAK41YQvAuDinNo70ibCNjaLOX1N7JStXrkydnZ2p3mjk+8T9AACuNWELwLihoaHRXzSbeffj6bjrrjX5+gn3AwC4hoQtAONiw6gQWRobQE3H4sWL8/UhjgQCALjWhC0AE6I0VK4iSiOOx1blTjeOAQCmQtgCMG758uWjv6hU0qFDT0/rHk89dShfP+F+AADXUMu1vDkAZVm1alXeMOrZZ59Ng4ODeQOoqWwgdfTo0XT48OHUUmvJG0nF/YBLi43aYpO1WI8esx1i1kT8QKijo2PSO5IDIGwBOEe1Wk1dXV3pkUceSY16JfX3704f/vD/nNQ32HG0T7y+Ev/3yn18Yw4XNzIyks96juO14gdI54sfKMW/ofXr16elS5fOyjMClMRUZAAmiG+kW1tbU7VWS08ODKT+/l1XPI82Pv/44/1p4ODBfN2iRYvyfYALRdCuW7cu/wApZkecPXs2nT1zJp05cya/j9/Hx+Pz8bp4PQCXZ8QWgAlidKinpydt27YtpVRLj/btTM8993zasKErTy++2PTjGKmNqK3VannUd+vWramtrW1Wnh/msr6+vtTb25unIDfq9dRMzXxmdByvFZutxaZtsb49lgLErImTjUb+t3j8+PG0efPm2X58gDmr0rzSj+FfmS7T3t6ehoeHTYcBuIG/Ae/s7Mzn1MY6wFgPGBtFxZramH5cfSVqt2zZku6///7ZfnyYc/bu3Zs++tGPpkajnur1erp37dq0cWP3Rdexxw+Mdu3ak2dNjP7AqJYD10wI4EYyMoUOFbYAXFJMgdyxY0c6depUajYaqd5oxLzjfE5tXj1bqaRatZrX1Mb04xip9Y03XPx7qZhWHD8QqtfPpgc23582bdo4iXXru/KsiVqtJY/oHjhwwGwI4IYxMoUOtcYWgEuKSN2/f3/60Ic+lFbecUdqaWlJLQsWpAULFuT38fv4eHw+vuEWtXDpHxKdPn06z34YHandcMVrYvO1eN3ae+7J18UPmKy3Bbg4I7YATEp8uRg7liTWAcboURxLEkf62P0YLi2m88cOx7EhVIzWfvr//O8pH6P1P973/tFjtO64Iz3xxBP+zQE3hJEpdKjNowCYlPhGOs7WjDdg8uIHQnGkTwRubBQ1lagNsWlbrG9/5pkv5/vE/fw7BJjIVGQAgGsoZjlkzWbe/Xg6YtO2uH7C/QAYJ2wBAK6h2DAqRJbGFP7piJ3Ix9aOxVIAACYStgAA11BEaahcRZRGHI+tqp1uHAPMZ8IWAOAaik3WskolHTr09LTuEWdGx/UT7gfAOGELAHANxc7hsWFUtVpNg4ODeQOoqYhdkQ8fPpzPjI6NpOJ+AEwkbAEArqEI2jjuJ95XUiX19+/Ox2dNRrwuXh/XVV65j6N+AC4kbAEArrH169en1tbWVK3V0pMDA6m/f9cV4zY+//jj/Wng4MF83aJFi/J9ALiQsAUAuMaWLl2aenp68qhtrVZLj/btTA8//EieZnwx8fGHHvp46tv5WH59XLd169bU1tZ23Z8doAQts/0AAAA3ghhtPX78eOrt7c17JA8MHMyjt52dnfmc2tg9OXY/jo2iYk1tTD+u1Vpy1G7ZssVoLcBlVJqTWOQxMjKS2tvb0/DwcP6JIwAA07Nv3760Y8eOdOrUqdRsNFK90Yh5x/mc2rx6tlLJG0XFmtqYfhwjtaIWuBGNTKFDhS0AwHUW31vt378/7d69+6K7JMcuyt3d3TloTT8GblQjwhYAYO6Lb8OOHDmShoaG0okTJ9KSJUvyObVxpI/dj4Eb3cgUOtQaWwCAWRLx2tHRkd8AmD67IgMAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUrWW2HwBuFI1GIx05ciQNDQ2lkydPpsWLF6fly5enjo6OVKlUZvvxAACgWMIWrrGRkZG0b9++tGfPnnTs2LELPr9ixYrU1dWV1q9fn5YuXTorzwgAACWrNJvN5mS+MW9vb0/Dw8O+8YYpiKDdvn17On36dB6xjbfUbKb4R5fHaCuVVK1W81tra2vq6enJgQsAADe6kSl0qBFbuEb6+vpSb2/vaNDW6ylydvXq1WnNmremJUuWpBMnTqRDh55Og4ODqVGvpJONRtq2bVs6fvx42rx582w/PgAAFEPYwjWwd+/eV6K2nur1erp37dq0cWN3nnZ8rg3dXeno0aNp16496cmBgZRSLV+3bNkyI7cAADBJpiLDDIt/L+vWrcsbRNXrZ9MDm+9PmzZtvOw18c+wv39XerRvZ6rVWvKI7oEDB1JbW9t1e24AAJhLptKhjvuBa7CuNq+pHR+p3XDFa2JX5Hjd2nvuydedOnUq3wcAALgyYQszKNbTxu7H8T7W1Mb048ke5TMat935umajkXbv3p1HcgEAgMsTtjCD4pzaONInwjY2ijp/Te2VrFy5MnV2dqZ6o5HvE/cDAAAuT9jCDBoaGhr9RbOZdz+ejrvuWpOvn3A/AADgkoQtzKDYMCpElsYGUNOxePHifH2II4EAAIDLE7YwgyJKQ+UqojTieGxV7nTjGAAAbiTCFmbQ8uXLR39RqaRDh56e1j2eeupQvn7C/QAAgEsStjCDVq1alTeMqlaraXBwMG8ANRVHjx5Nhw8fTrVqNW8kFfcDAAAuT9jCDIqg7erqyu8rqZL6+yd/ZE+8Ll4f11Veuc9kjwoCAIAbmbCFGbZ+/frU2tqaqrVaenJgIPX377pi3MbnH3+8Pw0cPJivW7RoUb4PAABwZcIWZtjSpUtTT09PHrWt1Wrp0b6d6eGHH8nTjC8mPv7QQx9PfTsfy6+P67Zu3Zra2tqu+7MDAECJWmb7AWA+itHW48ePp97e3rxH8sDAwTx629nZmc+pjd2TY/fj2Cgq1tTG9ONarSVH7ZYtW4zWAgDAFFSak1gAODIyktrb29Pw8HAejQImZ9++fWnHjh3p1KlTqdlopHqjEfOO8zm1efVspZI3ioo1tTH9OEZqRS0AAKQpdaiwhWss/v3s378/7d69+6K7JMcuyt3d3TloTT8GAIBRwhbmoPinduTIkTQ0NJROnDiRlixZks+pjSN97H4MAADT71BrbOE6iXjt6OjIbwAAwMyxKzIAAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEVrme0HAACA0jUajXTkyJE0NDSUTp48mRYvXpyWL1+eOjo6UqVSme3Hg3lP2AIAwDSNjIykffv2pT179qRjx45d8PkVK1akrq6utH79+rR06dJZeUa4EVSazWZzMv9g29vb0/DwsH+QAACQUg7a7du3p9OnT+cR23hLzWaKb67zGG2lkqrVan5rbW1NPT09OXCByZlKhxqxBQCAKerr60u9vb2jQVuvp8jZ1atXpzVr3pqWLFmSTpw4kQ4dejoNDg6mRr2STjYaadu2ben48eNp8+bNs/34MO8IWwAAmIK9e/e+ErX1VK/X071r16aNG7vztONzbejuSkePHk27du1JTw4MpJRq+bply5YZuYUZZioyAABMUnxfvG7durxBVL1+Nj2w+f60adPGy14T32739+9Kj/btTLVaSx7RPXDgQGpra7tuzw0lmkqHOu4HAACmsK42r6kdH6ndcMVrYlfkeN3ae+7J1506dSrfB5g5whYAACYh1tPG7sfxPtbUxvTjyR7lMxq33fm6ZqORdu/enUdygZkhbAEAYBLinNo40ifCNjaKOn9N7ZWsXLkydXZ2pnqjke8T9wNmhrAFAIBJGBoaGv1Fs5l3P56Ou+5ak6+fcD/gqglbAACYhNgwKkSWxgZQ07F48eJ8fYgjgYCZIWwBAGCSURoqVxGlEcdjq3KnG8fAhYQtAABMwvLly0d/UamkQ4eentY9nnrqUL5+wv2AqyZsAQBgElatWpU3jKpWq2lwcDBvADUVR48eTYcPH061ajVvJBX3A2aGsAUAgEmIoO3q6srvK6mS+vsnf2RPvC5eH9dVXrnPZI8KAq5M2AIAwCStX78+tba2pmqtlp4cGEj9/buuGLfx+ccf708DBw/m6xYtWpTvA8wcYQsAAJO0dOnS1NPTk0dta7VaerRvZ3r44UfyNOOLiY8/9NDHU9/Ox/Lr47qtW7emtra26/7sMJ+1zPYDAABASWK09fjx46m3tzfvkTwwcDCP3nZ2duZzamP35Nj9ODaKijW1Mf24VmvJUbtlyxajtXANVJqTWBgwMjKS2tvb0/DwcP4pFQAA3Oj27duXduzYkU6dOpWajUaqNxox7zifU5tXz1YqeaOoWFMb049jpFbUwuRNpUOFLQAATFN8n7x///60e/fui+6SHLsod3d356A1/RimRtgCAMB1FN9SHzlyJA0NDaUTJ06kJUuW5HNq40gfux/D9EylQ62xBQCAqxTx2tHRkd+A68+uyAAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGELQAAAEUTtgAAABRN2AIAAFA0YQsAAEDRhC0AAABFE7YAAAAUTdgCAABQNGELAABA0YQtAAAARRO2AAAAFE3YAgAAUDRhCwAAQNGE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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "draw_hypergraph(H)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "I-nf7QCWq0FV" + }, + "source": [ + "## Try adding/removing nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "id": "HJ35vv8buZ_8" + }, + "outputs": [], + "source": [ + "def print_node_metadata(g):\n", + " print(json.dumps(H.get_nodes(metadata=True), indent=4))" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "jZxN78CIq23c", + "outputId": "bce55284-b750-45f7-d390-75f23c1031dd" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"1\": {},\n", + " \"3\": {},\n", + " \"4\": {},\n", + " \"2\": {},\n", + " \"5\": {},\n", + " \"6\": {},\n", + " \"7\": {},\n", + " \"8\": {}\n", + "}\n", + "H nodes: None\n", + "{\n", + " \"3\": {},\n", + " \"4\": {},\n", + " \"2\": {},\n", + " \"5\": {},\n", + " \"6\": {},\n", + " \"7\": {},\n", + " \"8\": {}\n", + "}\n", + "H nodes after removing node 1: None\n", + "{\n", + " \"3\": {},\n", + " \"4\": {},\n", + " \"2\": {},\n", + " \"5\": {},\n", + " \"6\": {},\n", + " \"7\": {},\n", + " \"8\": {},\n", + " \"1\": {}\n", + "}\n", + "H nodes after adding node 1 back in: None\n" + ] + } + ], + "source": [ + "print(f\"H nodes: {print_node_metadata(H)}\")\n", + "\n", + "H.remove_node(1)\n", + "\n", + "print(f\"H nodes after removing node 1: {print_node_metadata(H)}\")\n", + "\n", + "H.add_node(1)\n", + "\n", + "print(f\"H nodes after adding node 1 back in: {print_node_metadata(H)}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 598 + }, + "id": "yREGyg1zsnBP", + "outputId": "07593774-6ba9-4d6e-dffd-3e6d155878d2" + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA7YAAAJFCAYAAADpi2ubAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjUsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvWftoOwAAAAlwSFlzAAAPYQAAD2EBqD+naQAALv5JREFUeJzt3W1snNld8OH/zATq2NgGmoLZtgkUCiKmjqAJSKWwkKwERFaBYicr3jaLhBDQVKyEiFUBSouAhN0tFBkQQijOipf4BQkaJ1+I0yI+QIm3wgEsBIjibCkGDNRuHafqzsyjc7LJk7fd2E5i+9jXJY3sHc99506r2PPzuc85lWaz2QwAAAAoVHW9LwAAAAAehLAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAWALe47vuM74hu+4RvW+zIAYNWELQBFGBoaikqlEpOTk/f8ujgDgK1L2AIAAFA0YQsAj9ji4uIDn+PatWvRaDQeyvUAwGYjbAHYdB5//PHYs2fPPb/2dV/3dfFd3/Vd+fN/+7d/y7c3P/fcc/Hrv/7rsWvXrti+fXs+/u///u/vOvYf//Efo6+vL770S780WlpaYu/evfHhD3/4nrdM/8Vf/EX81E/9VHzZl31ZvOlNb7r59d/6rd+Kt7zlLfnP+eZv/ub4y7/8y3wbdXrc8NGPfjSf48yZM/HzP//z8cY3vjFaW1tjYWEh/vd//zd+9md/Nt72trfFF33RF0VHR0d8z/d8T0xNTd12HTfOMTw8HO973/uiq6sr2tra4l3vele89NJL9/zfZnp6Or7zO78z/1npz/y1X/u1Ff4vDwDrY9s6/bkAsCrz8/MxNzd31/Of//znb37+Iz/yI/HjP/7jOU5vnXd76dKl+Kd/+qcci7d64YUX4jOf+Uz89E//dB4Z/dCHPhT79++Pv/u7v4sv//Ivz6/5h3/4h/jWb/3WHHwDAwM5EkdGRuL7vu/74k/+5E/i+7//+287Z4raN7zhDfGLv/iLN0dsf+d3fife8573xLd927fFM888k8M6Hf8lX/Ilt8XvDb/0S78UX/iFX5hD9nOf+1z+PMXnn/7pn0Z/f3981Vd9Vfznf/5n/O7v/m6O8fS1xx577LZz/PIv/3IO3GPHjsV//dd/xW/8xm/EE088EX/7t3+b4/qG//u//4vv/u7vjne/+91x6NChGBsby8ekgE7hDAAbWhMACnDq1Klm+rH1Wo/u7u782k9/+tPNlpaW5rFjx247x3vf+95mW1tb87Of/Wz+70984hP5uO3btzc/+clP3nzdxz72sfz8M888c/O5AwcONN/2trc1r127dvO5RqPRfMc73tF861vfetd1vvOd72y+/PLLN5//3Oc+13z961/f3LdvX/Pzn//8zeeHhoby6x9//PGbz33kIx/Jz73lLW9pXr169ba/Q/rz6/X6bc+lv8frXve65gc+8IG7zvHGN76xubCwcPP5kZGR/PyHPvShm8+lPzs998ILL9x2vV1dXc0f+IEfuM//MwCw/tyKDEBR0q28f/7nf37Xo6en5+ZrOjs743u/93vjj//4j9MvcPNz9Xo935abRkjTaOut0nNpJPaGdIvwt3zLt8T58+fzf6fbfy9evJhHMtPIbhoxTo//+Z//ybc1//M//3P8+7//+23nTCPGtVrt5n+n1ZzT69Pz27b9/xumfuiHfiiP2N7LU089dduoavK6170uqtXqzb9TOme6JTndYv3xj3/8rnP86I/+aLS3t9/873Qr9Vd8xVfc/LvdkM7xwz/8wzf/O40Op/8d/vVf//We1wYAG4lbkQEoSoqtNLf1TikOb71FOQVdCtk0h/Xbv/3b48KFC/m23XSb8p3e+ta33vXc137t1+ZbjZN/+Zd/yYH8C7/wC/lxL+k231vjON0mfKuZmZn88Wu+5mtuez5F7ld+5Vfe85x3niNJC0ilW6V/+7d/Oz7xiU/kuL3h9a9//X3/bum25HQN6TboW6VbodPX7vzf9PLly/e8NgDYSIQtAJtSGklN82P/4A/+IIdt+pgWUErzS1fqxmrEaa7rjYWn7nRnsN450roa9zrHr/zKr+S4/rEf+7E8BzctZJVGcH/mZ37mgVZNvnV0+VY3RrwBYCMTtgBsSinUfvAHfzCvUnzy5Mm84NKdtwffkG4lvlNaZOrGSGpaxTj5gi/4glWFcZJWXL4x+ptWHr7h5ZdfzqOnt95K/VrSok7p+N///d+/7flPf/rTsWPHjvv+3VKopmtY7p8HACUwxxaATSvddpxW+/2Jn/iJ+OxnP3vbHNJbpei9dY7s3/zN38THPvaxm6sBpy170nY8afXh//iP/7jr+P/+7/++77Wk26fTrcK/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njwZS0tL0Ww0ot5opPuO8z61efZspZIXikpzatPtx2mkVtQCAECsqEOFLTxi6d/PuXPnYnR09J6rJKdVlPv7+3PQuv0YAACuE7awAaV/atPT0zE7OxuLi4vR1taW96lNW/pY/RgAAFbfoebYwhpJ8drd3Z0fAADAw2NVZAAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsAAACKJmwBAAAomrAFAACgaMIWAACAoglbAAAAiiZsAQAAKJqwBQAAoGjCFgAAgKIJWwAAAIombAEAACiasAUAAKBowhYAAICiCVsA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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "draw_hypergraph(H)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "OKPwTDS4spK5" + }, + "source": [ + "## Try modifying node metadata" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "mUTFcDZtvD1i", + "outputId": "2a1ae6ff-c162-4524-d232-c125b39f2267" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"3\": {},\n", + " \"4\": {},\n", + " \"2\": {},\n", + " \"5\": {},\n", + " \"6\": {},\n", + " \"7\": {},\n", + " \"8\": {},\n", + " \"1\": {\n", + " \"role\": \"student\"\n", + " }\n", + "}\n" + ] + } + ], + "source": [ + "H.add_attr_to_node_metadata(1, 'role', 'student')\n", + "print_node_metadata(H)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "RDB_a9HEx8w-" + }, + "outputs": [], + "source": [] } - ], - "source": [ - "H.get_nodes(metadata=True)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "hgx-installation", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.17" + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": ".venv (3.11.5)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + }, + "orig_nbformat": 4 }, - "orig_nbformat": 4 - }, - "nbformat": 4, - "nbformat_minor": 2 + "nbformat": 4, + "nbformat_minor": 0 }