From 0863fc4921bc8ebb35e97f5f6a5c8689f65be9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Mon, 25 Nov 2024 12:05:54 -0800 Subject: [PATCH 01/36] Add initial redesign of liftings maps --- .../test_SimplicialCliqueLifting.py | 19 +- topobenchmarkx/complex.py | 85 +++++ topobenchmarkx/transforms/converters.py | 313 ++++++++++++++++++ .../transforms/feature_liftings/base.py | 13 + .../transforms/feature_liftings/identity.py | 37 +-- .../feature_liftings/projection_sum.py | 67 +--- topobenchmarkx/transforms/liftings/base.py | 95 +++++- .../liftings/graph2simplicial/clique.py | 30 +- 8 files changed, 550 insertions(+), 109 deletions(-) create mode 100644 topobenchmarkx/complex.py create mode 100644 topobenchmarkx/transforms/converters.py create mode 100644 topobenchmarkx/transforms/feature_liftings/base.py diff --git a/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py b/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py index 41b8ac45..fc2f89f8 100644 --- a/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py +++ b/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py @@ -3,21 +3,24 @@ import torch from topobenchmarkx.transforms.liftings.graph2simplicial import ( - SimplicialCliqueLifting, + SimplicialCliqueLifting ) - +from topobenchmarkx.transforms.converters import Data2NxGraph, Complex2Dict +from topobenchmarkx.transforms.liftings.base import LiftingTransform class TestSimplicialCliqueLifting: """Test the SimplicialCliqueLifting class.""" def setup_method(self): # Initialise the SimplicialCliqueLifting class - self.lifting_signed = SimplicialCliqueLifting( - complex_dim=3, signed=True - ) - self.lifting_unsigned = SimplicialCliqueLifting( - complex_dim=3, signed=False - ) + data2graph = Data2NxGraph() + simplicial2dict_signed = Complex2Dict(signed=True) + simplicial2dict_unsigned = Complex2Dict(signed=False) + + lifting_map = SimplicialCliqueLifting(complex_dim=3) + + self.lifting_signed = LiftingTransform(data2graph, simplicial2dict_signed, lifting_map) + self.lifting_unsigned = LiftingTransform(data2graph, simplicial2dict_unsigned, lifting_map) def test_lift_topology(self, simple_graph_1): """Test the lift_topology method.""" diff --git a/topobenchmarkx/complex.py b/topobenchmarkx/complex.py new file mode 100644 index 00000000..8a2949f2 --- /dev/null +++ b/topobenchmarkx/complex.py @@ -0,0 +1,85 @@ +import torch + + +class PlainComplex: + def __init__( + self, + incidence, + down_laplacian, + up_laplacian, + adjacency, + coadjacency, + hodge_laplacian, + features=None, + ): + # TODO: allow None with nice error message if callable? + + # TODO: make this private? do not allow for changes in these values? + self.incidence = incidence + self.down_laplacian = down_laplacian + self.up_laplacian = up_laplacian + self.adjacency = adjacency + self.coadjacency = coadjacency + self.hodge_laplacian = hodge_laplacian + + if features is None: + features = [None for _ in range(len(self.incidence))] + else: + for rank, dim in enumerate(self.shape): + # TODO: make error message more informative + if ( + features[rank] is not None + and features[rank].shape[0] != dim + ): + raise ValueError("Features have wrong shape.") + + self.features = features + + @property + def shape(self): + """Shape of the complex. + + Returns + ------- + list[int] + """ + return [incidence.shape[-1] for incidence in self.incidence] + + @property + def max_rank(self): + """Maximum rank of the complex. + + Returns + ------- + int + """ + return len(self.incidence) + + def update_features(self, rank, values): + """Update features. + + Parameters + ---------- + rank : int + Rank of simplices the features belong to. + values : array-like + New features for the rank-simplices. + """ + self.features[rank] = values + + def reset_features(self): + """Reset features.""" + self.features = [None for _ in self.features] + + def propagate_values(self, rank, values): + """Propagate features from a rank to an upper one. + + Parameters + ---------- + rank : int + Rank of the simplices the values belong to. + values : array-like + Features for the rank-simplices. + """ + # TODO: can be made much better + return torch.matmul(torch.abs(self.incidence[rank + 1].t()), values) diff --git a/topobenchmarkx/transforms/converters.py b/topobenchmarkx/transforms/converters.py new file mode 100644 index 00000000..96920b74 --- /dev/null +++ b/topobenchmarkx/transforms/converters.py @@ -0,0 +1,313 @@ +import abc + +import networkx as nx +import numpy as np +import torch +import torch_geometric +from topomodelx.utils.sparse import from_sparse +from torch_geometric.utils.undirected import is_undirected, to_undirected + +from topobenchmarkx.complex import PlainComplex +from topobenchmarkx.data.utils.utils import ( + generate_zero_sparse_connectivity, + select_neighborhoods_of_interest, +) + + +class Converter(abc.ABC): + """Convert between data structures representing the same domain.""" + + def __call__(self, domain): + """Convert domain's data structure.""" + return self.convert(domain) + + @abc.abstractmethod + def convert(self, domain): + """Convert domain's data structure.""" + + +class IdentityConverter(Converter): + """Identity conversion. + + Retrieves same data structure for domain. + """ + + def convert(self, domain): + """Convert domain.""" + return domain + + +class Data2NxGraph(Converter): + """Data to nx.Graph conversion. + + Parameters + ---------- + preserve_edge_attr : bool + Whether to preserve edge attributes. + """ + + def __init__(self, preserve_edge_attr=False): + self.preserve_edge_attr = preserve_edge_attr + + def _data_has_edge_attr(self, data: torch_geometric.data.Data) -> bool: + r"""Check if the input data object has edge attributes. + + Parameters + ---------- + data : torch_geometric.data.Data + The input data. + + Returns + ------- + bool + Whether the data object has edge attributes. + """ + return hasattr(data, "edge_attr") and data.edge_attr is not None + + def convert(self, domain: torch_geometric.data.Data) -> nx.Graph: + r"""Generate a NetworkX graph from the input data object. + + Parameters + ---------- + domain : torch_geometric.data.Data + The input data. + + Returns + ------- + nx.Graph + The generated NetworkX graph. + """ + # Check if data object have edge_attr, return list of tuples as [(node_id, {'features':data}, 'dim':1)] or ?? + nodes = [ + (n, dict(features=domain.x[n], dim=0)) + for n in range(domain.x.shape[0]) + ] + + if self.preserve_edge_attr and self._data_has_edge_attr(domain): + # In case edge features are given, assign features to every edge + edge_index, edge_attr = ( + domain.edge_index, + ( + domain.edge_attr + if is_undirected(domain.edge_index, domain.edge_attr) + else to_undirected(domain.edge_index, domain.edge_attr) + ), + ) + edges = [ + (i.item(), j.item(), dict(features=edge_attr[edge_idx], dim=1)) + for edge_idx, (i, j) in enumerate( + zip(edge_index[0], edge_index[1], strict=False) + ) + ] + + else: + # If edge_attr is not present, return list list of edges + edges = [ + (i.item(), j.item(), {}) + for i, j in zip( + domain.edge_index[0], domain.edge_index[1], strict=False + ) + ] + graph = nx.Graph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edges) + return graph + + +class Complex2PlainComplex(Converter): + """toponetx.Complex to PlainComplex conversion. + + NB: order of features plays a crucial role, as ``PlainComplex`` + simply stores them as lists (i.e. the reference to the indices + of the simplex are lost). + + Parameters + ---------- + max_rank : int + Maximum rank of the complex. + neighborhoods : list, optional + List of neighborhoods of interest. + signed : bool, optional + If True, returns signed connectivity matrices. + transfer_features : bool, optional + Whether to transfer features. + """ + + def __init__( + self, + max_rank=None, + neighborhoods=None, + signed=False, + transfer_features=True, + ): + super().__init__() + self.max_rank = max_rank + self.neighborhoods = neighborhoods + self.signed = signed + self.transfer_features = transfer_features + + def convert(self, domain): + """Convert toponetx.Complex to PlainComplex. + + Parameters + ---------- + domain : toponetx.Complex + + Returns + ------- + PlainComplex + """ + # NB: just a slightly rewriting of get_complex_connectivity + + max_rank = self.max_rank or domain.dim + signed = self.signed + neighborhoods = self.neighborhoods + + connectivity_infos = [ + "incidence", + "down_laplacian", + "up_laplacian", + "adjacency", + "coadjacency", + "hodge_laplacian", + ] + + practical_shape = list( + np.pad(list(domain.shape), (0, max_rank + 1 - len(domain.shape))) + ) + data = { + connectivity_info: [] for connectivity_info in connectivity_infos + } + for rank_idx in range(max_rank + 1): + for connectivity_info in connectivity_infos: + try: + data[connectivity_info].append( + from_sparse( + getattr(domain, f"{connectivity_info}_matrix")( + rank=rank_idx, signed=signed + ) + ) + ) + except ValueError: + if connectivity_info == "incidence": + data[connectivity_info].append( + generate_zero_sparse_connectivity( + m=practical_shape[rank_idx - 1], + n=practical_shape[rank_idx], + ) + ) + else: + data[connectivity_info].append( + generate_zero_sparse_connectivity( + m=practical_shape[rank_idx], + n=practical_shape[rank_idx], + ) + ) + + # TODO: handle this + if neighborhoods is not None: + data = select_neighborhoods_of_interest(data, neighborhoods) + + # TODO: simplex specific? + # TODO: how to do this for other? + if self.transfer_features and hasattr( + domain, "get_simplex_attributes" + ): + # TODO: confirm features are in the right order; update this + data["features"] = [] + for rank in range(max_rank + 1): + rank_features_dict = domain.get_simplex_attributes( + "features", rank + ) + if rank_features_dict: + rank_features = torch.stack( + list(rank_features_dict.values()) + ) + else: + rank_features = None + data["features"].append(rank_features) + + return PlainComplex(**data) + + +class PlainComplex2Dict(Converter): + """PlainComplex to dict conversion.""" + + def convert(self, domain): + """Convert PlainComplex to dict. + + Parameters + ---------- + domain : toponetx.Complex + + Returns + ------- + dict + """ + data = {} + connectivity_infos = [ + "incidence", + "down_laplacian", + "up_laplacian", + "adjacency", + "coadjacency", + "hodge_laplacian", + ] + for connectivity_info in connectivity_infos: + info = getattr(domain, connectivity_info) + for rank, rank_info in enumerate(info): + data[f"{connectivity_info}_{rank}"] = rank_info + + # TODO: handle neighborhoods + data["shape"] = domain.shape + + for index, values in enumerate(domain.features): + if values is not None: + data[f"x_{index}"] = values + + return data + + +class ConverterComposition(Converter): + def __init__(self, converters): + super().__init__() + self.converters = converters + + def convert(self, domain): + """Convert domain""" + for converter in self.converters: + domain = converter(domain) + + return domain + + +class Complex2Dict(ConverterComposition): + """Complex to dict conversion. + + Parameters + ---------- + max_rank : int + Maximum rank of the complex. + neighborhoods : list, optional + List of neighborhoods of interest. + signed : bool, optional + If True, returns signed connectivity matrices. + transfer_features : bool, optional + Whether to transfer features. + """ + + def __init__( + self, + max_rank=None, + neighborhoods=None, + signed=False, + transfer_features=True, + ): + complex2plain = Complex2PlainComplex( + max_rank=max_rank, + neighborhoods=neighborhoods, + signed=signed, + transfer_features=transfer_features, + ) + plain2dict = PlainComplex2Dict() + super().__init__(converters=(complex2plain, plain2dict)) diff --git a/topobenchmarkx/transforms/feature_liftings/base.py b/topobenchmarkx/transforms/feature_liftings/base.py new file mode 100644 index 00000000..c5969398 --- /dev/null +++ b/topobenchmarkx/transforms/feature_liftings/base.py @@ -0,0 +1,13 @@ +import abc + + +class FeatureLiftingMap(abc.ABC): + """Feature lifting map.""" + + def __call__(self, domain): + """Lift features of a domain.""" + return self.lift_features(domain) + + @abc.abstractmethod + def lift_features(self, domain): + """Lift features of a domain.""" diff --git a/topobenchmarkx/transforms/feature_liftings/identity.py b/topobenchmarkx/transforms/feature_liftings/identity.py index 93806f1d..9abf4e5d 100644 --- a/topobenchmarkx/transforms/feature_liftings/identity.py +++ b/topobenchmarkx/transforms/feature_liftings/identity.py @@ -1,36 +1,13 @@ """Identity transform that does nothing to the input data.""" -import torch_geometric +from .base import FeatureLiftingMap -class Identity(torch_geometric.transforms.BaseTransform): - r"""An identity transform that does nothing to the input data. +class Identity(FeatureLiftingMap): + """Identity feature lifting map.""" - Parameters - ---------- - **kwargs : optional - Parameters for the base transform. - """ + # TODO: rename to IdentityFeatureLifting - def __init__(self, **kwargs): - super().__init__() - self.type = "domain2domain" - self.parameters = kwargs - - def __repr__(self) -> str: - return f"{self.__class__.__name__}(type={self.type!r}, parameters={self.parameters!r})" - - def forward(self, data: torch_geometric.data.Data): - r"""Apply the transform to the input data. - - Parameters - ---------- - data : torch_geometric.data.Data - The input data. - - Returns - ------- - torch_geometric.data.Data - The same data. - """ - return data + def lift_features(self, domain): + """Lift features of a domain using identity map.""" + return domain diff --git a/topobenchmarkx/transforms/feature_liftings/projection_sum.py b/topobenchmarkx/transforms/feature_liftings/projection_sum.py index 3cce03eb..4d0c04b5 100644 --- a/topobenchmarkx/transforms/feature_liftings/projection_sum.py +++ b/topobenchmarkx/transforms/feature_liftings/projection_sum.py @@ -1,69 +1,30 @@ """ProjectionSum class.""" -import torch -import torch_geometric +from .base import FeatureLiftingMap -class ProjectionSum(torch_geometric.transforms.BaseTransform): - r"""Lift r-cell features to r+1-cells by projection. +class ProjectionSum(FeatureLiftingMap): + r"""Lift r-cell features to r+1-cells by projection.""" - Parameters - ---------- - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, **kwargs): - super().__init__() - - def __repr__(self) -> str: - return f"{self.__class__.__name__}()" - - def lift_features( - self, data: torch_geometric.data.Data | dict - ) -> torch_geometric.data.Data | dict: + def lift_features(self, domain): r"""Project r-cell features of a graph to r+1-cell structures. Parameters ---------- - data : torch_geometric.data.Data | dict + data : PlainComplex The input data to be lifted. Returns ------- - torch_geometric.data.Data | dict - The data with the lifted features. + PlainComplex + Domain with the lifted features. """ - keys = sorted( - [ - key.split("_")[1] - for key in data - if ("incidence" in key and "-" not in key) - ] - ) - for elem in keys: - if f"x_{elem}" not in data: - idx_to_project = 0 if elem == "hyperedges" else int(elem) - 1 - data["x_" + elem] = torch.matmul( - abs(data["incidence_" + elem].t()), - data[f"x_{idx_to_project}"], - ) - return data + for rank in range(domain.max_rank - 1): + if domain.features[rank + 1] is not None: + continue - def forward( - self, data: torch_geometric.data.Data | dict - ) -> torch_geometric.data.Data | dict: - r"""Apply the lifting to the input data. - - Parameters - ---------- - data : torch_geometric.data.Data | dict - The input data to be lifted. + domain.features[rank + 1] = domain.propagate_values( + rank, domain.features[rank] + ) - Returns - ------- - torch_geometric.data.Data | dict - The lifted data. - """ - data = self.lift_features(data) - return data + return domain diff --git a/topobenchmarkx/transforms/liftings/base.py b/topobenchmarkx/transforms/liftings/base.py index c08a54e5..fa00e40e 100644 --- a/topobenchmarkx/transforms/liftings/base.py +++ b/topobenchmarkx/transforms/liftings/base.py @@ -1,10 +1,99 @@ """Abstract class for topological liftings.""" -from abc import abstractmethod +import abc import torch_geometric +from topobenchmarkx.transforms.converters import IdentityConverter from topobenchmarkx.transforms.feature_liftings import FEATURE_LIFTINGS +from topobenchmarkx.transforms.feature_liftings.identity import ( + Identity, +) + + +class LiftingTransform(torch_geometric.transforms.BaseTransform): + """Lifting transform. + + Parameters + ---------- + data2domain : Converter + Conversion between ``torch_geometric.Data`` into + domain for consumption by lifting. + domain2dict : Converter + Conversion between output domain of feature lifting + and ``torch_geometric.Data``. + lifting : LiftingMap + Lifting map. + domain2domain : Converter + Conversion between output domain of lifting + and input domain for feature lifting. + feature_lifting : FeatureLiftingMap + Feature lifting map. + """ + + # NB: emulates previous AbstractLifting + def __init__( + self, + data2domain, + domain2dict, + lifting, + domain2domain=None, + feature_lifting=None, + ): + if feature_lifting is None: + feature_lifting = Identity() + + if domain2domain is None: + domain2domain = IdentityConverter() + + self.data2domain = data2domain + self.domain2domain = domain2domain + self.domain2dict = domain2dict + self.lifting = lifting + self.feature_lifting = feature_lifting + + def forward( + self, data: torch_geometric.data.Data + ) -> torch_geometric.data.Data: + r"""Apply the full lifting (topology + features) to the input data. + + Parameters + ---------- + data : torch_geometric.data.Data + The input data to be lifted. + + Returns + ------- + torch_geometric.data.Data + The lifted data. + """ + initial_data = data.to_dict() + + domain = self.data2domain(data) + lifted_topology = self.lifting(domain) + lifted_topology = self.domain2domain(lifted_topology) + lifted_topology = self.feature_lifting(lifted_topology) + lifted_topology_dict = self.domain2dict(lifted_topology) + + # TODO: make this line more clear + return torch_geometric.data.Data( + **initial_data, **lifted_topology_dict + ) + + +class LiftingMap(abc.ABC): + """Lifting map. + + Lifts a domain into another. + """ + + def __call__(self, domain): + """Lift domain.""" + return self.lift(domain) + + @abc.abstractmethod + def lift(self, domain): + """Lift domain.""" class AbstractLifting(torch_geometric.transforms.BaseTransform): @@ -18,12 +107,14 @@ class AbstractLifting(torch_geometric.transforms.BaseTransform): Additional arguments for the class. """ + # TODO: delete + def __init__(self, feature_lifting=None, **kwargs): super().__init__() self.feature_lifting = FEATURE_LIFTINGS[feature_lifting]() self.neighborhoods = kwargs.get("neighborhoods") - @abstractmethod + @abc.abstractmethod def lift_topology(self, data: torch_geometric.data.Data) -> dict: r"""Lift the topology of a graph to higher-order topological domains. diff --git a/topobenchmarkx/transforms/liftings/graph2simplicial/clique.py b/topobenchmarkx/transforms/liftings/graph2simplicial/clique.py index af7d5cdf..990d2e6e 100755 --- a/topobenchmarkx/transforms/liftings/graph2simplicial/clique.py +++ b/topobenchmarkx/transforms/liftings/graph2simplicial/clique.py @@ -1,32 +1,30 @@ """This module implements the CliqueLifting class, which lifts graphs to simplicial complexes.""" from itertools import combinations -from typing import Any import networkx as nx -import torch_geometric from toponetx.classes import SimplicialComplex -from topobenchmarkx.transforms.liftings.graph2simplicial import ( - Graph2SimplicialLifting, -) +from topobenchmarkx.transforms.liftings.base import LiftingMap -class SimplicialCliqueLifting(Graph2SimplicialLifting): +class SimplicialCliqueLifting(LiftingMap): r"""Lift graphs to simplicial complex domain. The algorithm creates simplices by identifying the cliques and considering them as simplices of the same dimension. Parameters ---------- - **kwargs : optional - Additional arguments for the class. + complex_dim : int + Maximum rank of the complex. """ - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self, complex_dim=2): + super().__init__() + # TODO: better naming + self.complex_dim = complex_dim - def lift_topology(self, data: torch_geometric.data.Data) -> dict: + def lift(self, domain): r"""Lift the topology of a graph to a simplicial complex. Parameters @@ -39,12 +37,11 @@ def lift_topology(self, data: torch_geometric.data.Data) -> dict: dict The lifted topology. """ - graph = self._generate_graph_from_data(data) + graph = domain + simplicial_complex = SimplicialComplex(graph) cliques = nx.find_cliques(graph) - simplices: list[set[tuple[Any, ...]]] = [ - set() for _ in range(2, self.complex_dim + 1) - ] + simplices = [set() for _ in range(2, self.complex_dim + 1)] for clique in cliques: for i in range(2, self.complex_dim + 1): for c in combinations(clique, i + 1): @@ -53,4 +50,5 @@ def lift_topology(self, data: torch_geometric.data.Data) -> dict: for set_k_simplices in simplices: simplicial_complex.add_simplices_from(list(set_k_simplices)) - return self._get_lifted_topology(simplicial_complex, graph) + # TODO: need to check for edge preservation + return simplicial_complex From e5ee0f338734f6e29b4059dff433a423853a4b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 5 Dec 2024 18:56:36 -0800 Subject: [PATCH 02/36] Rename Complex and move propagate_values to projection sum feature lifting --- topobenchmarkx/complex.py | 18 +----------------- .../feature_liftings/projection_sum.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/topobenchmarkx/complex.py b/topobenchmarkx/complex.py index 8a2949f2..531592dc 100644 --- a/topobenchmarkx/complex.py +++ b/topobenchmarkx/complex.py @@ -1,7 +1,4 @@ -import torch - - -class PlainComplex: +class Complex: def __init__( self, incidence, @@ -70,16 +67,3 @@ def update_features(self, rank, values): def reset_features(self): """Reset features.""" self.features = [None for _ in self.features] - - def propagate_values(self, rank, values): - """Propagate features from a rank to an upper one. - - Parameters - ---------- - rank : int - Rank of the simplices the values belong to. - values : array-like - Features for the rank-simplices. - """ - # TODO: can be made much better - return torch.matmul(torch.abs(self.incidence[rank + 1].t()), values) diff --git a/topobenchmarkx/transforms/feature_liftings/projection_sum.py b/topobenchmarkx/transforms/feature_liftings/projection_sum.py index 4d0c04b5..a02a1db5 100644 --- a/topobenchmarkx/transforms/feature_liftings/projection_sum.py +++ b/topobenchmarkx/transforms/feature_liftings/projection_sum.py @@ -1,5 +1,7 @@ """ProjectionSum class.""" +import torch + from .base import FeatureLiftingMap @@ -23,8 +25,12 @@ def lift_features(self, domain): if domain.features[rank + 1] is not None: continue - domain.features[rank + 1] = domain.propagate_values( - rank, domain.features[rank] + domain.update_features( + rank + 1, + torch.matmul( + torch.abs(domain.incidence[rank + 1].t()), + domain.features[rank], + ), ) return domain From 55b41207c19791a3216ce183ca2783983b3f6594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 5 Dec 2024 18:57:15 -0800 Subject: [PATCH 03/36] Rename adapters --- topobenchmarkx/transforms/converters.py | 76 +++++++++++----------- topobenchmarkx/transforms/liftings/base.py | 4 +- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/topobenchmarkx/transforms/converters.py b/topobenchmarkx/transforms/converters.py index 96920b74..bc31ef1f 100644 --- a/topobenchmarkx/transforms/converters.py +++ b/topobenchmarkx/transforms/converters.py @@ -7,38 +7,38 @@ from topomodelx.utils.sparse import from_sparse from torch_geometric.utils.undirected import is_undirected, to_undirected -from topobenchmarkx.complex import PlainComplex -from topobenchmarkx.data.utils.utils import ( +from topobenchmarkx.complex import Complex +from topobenchmarkx.data.utils import ( generate_zero_sparse_connectivity, select_neighborhoods_of_interest, ) -class Converter(abc.ABC): - """Convert between data structures representing the same domain.""" +class Adapter(abc.ABC): + """Adapt between data structures representing the same domain.""" def __call__(self, domain): - """Convert domain's data structure.""" - return self.convert(domain) + """Adapt domain's data structure.""" + return self.adapt(domain) @abc.abstractmethod - def convert(self, domain): - """Convert domain's data structure.""" + def adapt(self, domain): + """Adapt domain's data structure.""" -class IdentityConverter(Converter): - """Identity conversion. +class IdentityAdapter(Adapter): + """Identity adaptation. Retrieves same data structure for domain. """ - def convert(self, domain): - """Convert domain.""" + def adapt(self, domain): + """Adapt domain.""" return domain -class Data2NxGraph(Converter): - """Data to nx.Graph conversion. +class Data2NxGraph(Adapter): + """Data to nx.Graph adaptation. Parameters ---------- @@ -64,7 +64,7 @@ def _data_has_edge_attr(self, data: torch_geometric.data.Data) -> bool: """ return hasattr(data, "edge_attr") and data.edge_attr is not None - def convert(self, domain: torch_geometric.data.Data) -> nx.Graph: + def adapt(self, domain: torch_geometric.data.Data) -> nx.Graph: r"""Generate a NetworkX graph from the input data object. Parameters @@ -114,10 +114,10 @@ def convert(self, domain: torch_geometric.data.Data) -> nx.Graph: return graph -class Complex2PlainComplex(Converter): - """toponetx.Complex to PlainComplex conversion. +class TnxComplex2Complex(Adapter): + """toponetx.Complex to Complex adaptation. - NB: order of features plays a crucial role, as ``PlainComplex`` + NB: order of features plays a crucial role, as ``Complex`` simply stores them as lists (i.e. the reference to the indices of the simplex are lost). @@ -146,8 +146,8 @@ def __init__( self.signed = signed self.transfer_features = transfer_features - def convert(self, domain): - """Convert toponetx.Complex to PlainComplex. + def adapt(self, domain): + """Adapt toponetx.Complex to Complex. Parameters ---------- @@ -155,7 +155,7 @@ def convert(self, domain): Returns ------- - PlainComplex + Complex """ # NB: just a slightly rewriting of get_complex_connectivity @@ -227,14 +227,14 @@ def convert(self, domain): rank_features = None data["features"].append(rank_features) - return PlainComplex(**data) + return Complex(**data) -class PlainComplex2Dict(Converter): - """PlainComplex to dict conversion.""" +class Complex2Dict(Adapter): + """Complex to dict adaptation.""" - def convert(self, domain): - """Convert PlainComplex to dict. + def adapt(self, domain): + """Adapt Complex to dict. Parameters ---------- @@ -268,21 +268,21 @@ def convert(self, domain): return data -class ConverterComposition(Converter): - def __init__(self, converters): +class AdapterComposition(Adapter): + def __init__(self, adapters): super().__init__() - self.converters = converters + self.adapters = adapters - def convert(self, domain): - """Convert domain""" - for converter in self.converters: - domain = converter(domain) + def adapt(self, domain): + """Adapt domain""" + for adapter in self.adapters: + domain = adapter(domain) return domain -class Complex2Dict(ConverterComposition): - """Complex to dict conversion. +class TnxComplex2Dict(AdapterComposition): + """toponetx.Complex to dict adaptation. Parameters ---------- @@ -303,11 +303,11 @@ def __init__( signed=False, transfer_features=True, ): - complex2plain = Complex2PlainComplex( + complex2plain = TnxComplex2Complex( max_rank=max_rank, neighborhoods=neighborhoods, signed=signed, transfer_features=transfer_features, ) - plain2dict = PlainComplex2Dict() - super().__init__(converters=(complex2plain, plain2dict)) + plain2dict = Complex2Dict() + super().__init__(adapters=(complex2plain, plain2dict)) diff --git a/topobenchmarkx/transforms/liftings/base.py b/topobenchmarkx/transforms/liftings/base.py index fa00e40e..8dddde67 100644 --- a/topobenchmarkx/transforms/liftings/base.py +++ b/topobenchmarkx/transforms/liftings/base.py @@ -4,7 +4,7 @@ import torch_geometric -from topobenchmarkx.transforms.converters import IdentityConverter +from topobenchmarkx.transforms.converters import IdentityAdapter from topobenchmarkx.transforms.feature_liftings import FEATURE_LIFTINGS from topobenchmarkx.transforms.feature_liftings.identity import ( Identity, @@ -44,7 +44,7 @@ def __init__( feature_lifting = Identity() if domain2domain is None: - domain2domain = IdentityConverter() + domain2domain = IdentityAdapter() self.data2domain = data2domain self.domain2domain = domain2domain From 8c146f27a6d61ea139e549493a09429a8076ef4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 5 Dec 2024 18:58:34 -0800 Subject: [PATCH 04/36] Move Complex and adapters to data utils --- .../{transforms/converters.py => data/utils/adapters.py} | 0 topobenchmarkx/{complex.py => data/utils/domain.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename topobenchmarkx/{transforms/converters.py => data/utils/adapters.py} (100%) rename topobenchmarkx/{complex.py => data/utils/domain.py} (100%) diff --git a/topobenchmarkx/transforms/converters.py b/topobenchmarkx/data/utils/adapters.py similarity index 100% rename from topobenchmarkx/transforms/converters.py rename to topobenchmarkx/data/utils/adapters.py diff --git a/topobenchmarkx/complex.py b/topobenchmarkx/data/utils/domain.py similarity index 100% rename from topobenchmarkx/complex.py rename to topobenchmarkx/data/utils/domain.py From 59051c6c1f10e488e068727d32f500b4e97765d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 24 Dec 2024 16:05:42 -0800 Subject: [PATCH 05/36] Update imports --- topobenchmarkx/data/utils/__init__.py | 2 ++ topobenchmarkx/data/utils/adapters.py | 4 ++-- topobenchmarkx/transforms/liftings/base.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/topobenchmarkx/data/utils/__init__.py b/topobenchmarkx/data/utils/__init__.py index 74f57c96..01e77220 100644 --- a/topobenchmarkx/data/utils/__init__.py +++ b/topobenchmarkx/data/utils/__init__.py @@ -1,5 +1,7 @@ """Init file for data/utils module.""" +from .adapters import * +from .domain import Complex from .utils import ( ensure_serializable, # noqa: F401 generate_zero_sparse_connectivity, # noqa: F401 diff --git a/topobenchmarkx/data/utils/adapters.py b/topobenchmarkx/data/utils/adapters.py index bc31ef1f..6aa33fac 100644 --- a/topobenchmarkx/data/utils/adapters.py +++ b/topobenchmarkx/data/utils/adapters.py @@ -7,8 +7,8 @@ from topomodelx.utils.sparse import from_sparse from torch_geometric.utils.undirected import is_undirected, to_undirected -from topobenchmarkx.complex import Complex -from topobenchmarkx.data.utils import ( +from topobenchmarkx.data.utils.domain import Complex +from topobenchmarkx.data.utils.utils import ( generate_zero_sparse_connectivity, select_neighborhoods_of_interest, ) diff --git a/topobenchmarkx/transforms/liftings/base.py b/topobenchmarkx/transforms/liftings/base.py index 8dddde67..0a436585 100644 --- a/topobenchmarkx/transforms/liftings/base.py +++ b/topobenchmarkx/transforms/liftings/base.py @@ -4,7 +4,7 @@ import torch_geometric -from topobenchmarkx.transforms.converters import IdentityAdapter +from topobenchmarkx.data.utils import IdentityAdapter from topobenchmarkx.transforms.feature_liftings import FEATURE_LIFTINGS from topobenchmarkx.transforms.feature_liftings.identity import ( Identity, From e4165d81f83612dcee230f382ac8c9b80a806564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 14 Jan 2025 16:05:29 -0800 Subject: [PATCH 06/36] Update SimplicialKHopLifting to work with new design --- .../liftings/graph2simplicial/khop.py | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/topobenchmark/transforms/liftings/graph2simplicial/khop.py b/topobenchmark/transforms/liftings/graph2simplicial/khop.py index 50239f18..dc9e13e2 100755 --- a/topobenchmark/transforms/liftings/graph2simplicial/khop.py +++ b/topobenchmark/transforms/liftings/graph2simplicial/khop.py @@ -4,15 +4,14 @@ from itertools import combinations from typing import Any +import torch import torch_geometric from toponetx.classes import SimplicialComplex -from topobenchmark.transforms.liftings.graph2simplicial.base import ( - Graph2SimplicialLifting, -) +from topobenchmark.transforms.liftings.base import LiftingMap -class SimplicialKHopLifting(Graph2SimplicialLifting): +class SimplicialKHopLifting(LiftingMap): r"""Lift graphs to simplicial complex domain. The function lifts a graph to a simplicial complex by considering k-hop @@ -23,38 +22,43 @@ class SimplicialKHopLifting(Graph2SimplicialLifting): Parameters ---------- + complex_dim : int + Dimension of the desired complex. max_k_simplices : int, optional The maximum number of k-simplices to consider. Default is 5000. - **kwargs : optional - Additional arguments for the class. """ - def __init__(self, max_k_simplices=5000, **kwargs): - super().__init__(**kwargs) + def __init__(self, complex_dim=3, max_k_simplices=5000): + super().__init__() + self.complex_dim = complex_dim self.max_k_simplices = max_k_simplices def __repr__(self) -> str: return f"{self.__class__.__name__}(max_k_simplices={self.max_k_simplices!r})" - def lift_topology(self, data: torch_geometric.data.Data) -> dict: + def lift(self, domain): r"""Lift the topology to simplicial complex domain. Parameters ---------- - data : torch_geometric.data.Data - The input data to be lifted. + domain : nx.Graph + Graph to be lifted. Returns ------- - dict - The lifted topology. + toponetx.Complex + Lifted simplicial complex. """ - graph = self._generate_graph_from_data(data) + graph = domain + simplicial_complex = SimplicialComplex(graph) - edge_index = torch_geometric.utils.to_undirected(data.edge_index) + edge_index = torch_geometric.utils.to_undirected( + torch.tensor(list(zip(*graph.edges, strict=False))) + ) simplices: list[set[tuple[Any, ...]]] = [ set() for _ in range(2, self.complex_dim + 1) ] + for n in range(graph.number_of_nodes()): # Find 1-hop node n neighbors neighbors, _, _, _ = torch_geometric.utils.k_hop_subgraph( @@ -67,10 +71,12 @@ def lift_topology(self, data: torch_geometric.data.Data) -> dict: for i in range(1, self.complex_dim): for c in combinations(neighbors, i + 1): simplices[i - 1].add(tuple(c)) + for set_k_simplices in simplices: list_k_simplices = list(set_k_simplices) if len(set_k_simplices) > self.max_k_simplices: random.shuffle(list_k_simplices) list_k_simplices = list_k_simplices[: self.max_k_simplices] simplicial_complex.add_simplices_from(list_k_simplices) - return self._get_lifted_topology(simplicial_complex, graph) + + return simplicial_complex From 2777f9b607354f540d555af65088376bab7e1bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 14 Jan 2025 16:18:11 -0800 Subject: [PATCH 07/36] Add IdentityAdapter as default for all the adaptations in the LiftingTransform pipeline --- topobenchmark/transforms/liftings/base.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/topobenchmark/transforms/liftings/base.py b/topobenchmark/transforms/liftings/base.py index 6f5f35a7..d5c78dc9 100644 --- a/topobenchmark/transforms/liftings/base.py +++ b/topobenchmark/transforms/liftings/base.py @@ -32,15 +32,21 @@ class LiftingTransform(torch_geometric.transforms.BaseTransform): # NB: emulates previous AbstractLifting def __init__( self, - data2domain, - domain2dict, lifting, + data2domain=None, + domain2dict=None, domain2domain=None, feature_lifting=None, ): if feature_lifting is None: feature_lifting = Identity() + if data2domain is None: + data2domain = IdentityAdapter() + + if domain2dict is None: + domain2dict = IdentityAdapter() + if domain2domain is None: domain2domain = IdentityAdapter() From e5d48d3918354a0174c6748dc4d9697c579e0564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 14 Jan 2025 16:19:46 -0800 Subject: [PATCH 08/36] Improve TnxComplex2Complex api and signatures; improve variable naming --- topobenchmark/data/utils/adapters.py | 32 +++++++++++++++------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/topobenchmark/data/utils/adapters.py b/topobenchmark/data/utils/adapters.py index 9aa8355a..9db40c08 100644 --- a/topobenchmark/data/utils/adapters.py +++ b/topobenchmark/data/utils/adapters.py @@ -123,8 +123,9 @@ class TnxComplex2Complex(Adapter): Parameters ---------- - max_rank : int - Maximum rank of the complex. + complex_dim : int + Dimension of the desired subcomplex. + If ``None``, adapts the (full) complex. neighborhoods : list, optional List of neighborhoods of interest. signed : bool, optional @@ -135,13 +136,13 @@ class TnxComplex2Complex(Adapter): def __init__( self, - max_rank=None, + complex_dim=None, neighborhoods=None, signed=False, transfer_features=True, ): super().__init__() - self.max_rank = max_rank + self.complex_dim = complex_dim self.neighborhoods = neighborhoods self.signed = signed self.transfer_features = transfer_features @@ -159,7 +160,7 @@ def adapt(self, domain): """ # NB: just a slightly rewriting of get_complex_connectivity - max_rank = self.max_rank or domain.dim + dim = self.complex_dim or domain.dim signed = self.signed neighborhoods = self.neighborhoods @@ -173,12 +174,12 @@ def adapt(self, domain): ] practical_shape = list( - np.pad(list(domain.shape), (0, max_rank + 1 - len(domain.shape))) + np.pad(list(domain.shape), (0, dim + 1 - len(domain.shape))) ) data = { connectivity_info: [] for connectivity_info in connectivity_infos } - for rank_idx in range(max_rank + 1): + for rank_idx in range(dim + 1): for connectivity_info in connectivity_infos: try: data[connectivity_info].append( @@ -215,7 +216,7 @@ def adapt(self, domain): ): # TODO: confirm features are in the right order; update this data["features"] = [] - for rank in range(max_rank + 1): + for rank in range(dim + 1): rank_features_dict = domain.get_simplex_attributes( "features", rank ) @@ -286,8 +287,9 @@ class TnxComplex2Dict(AdapterComposition): Parameters ---------- - max_rank : int - Maximum rank of the complex. + complex_dim : int + Dimension of the desired subcomplex. + If ``None``, adapts the (full) complex. neighborhoods : list, optional List of neighborhoods of interest. signed : bool, optional @@ -298,16 +300,16 @@ class TnxComplex2Dict(AdapterComposition): def __init__( self, - max_rank=None, + complex_dim=None, neighborhoods=None, signed=False, transfer_features=True, ): - complex2plain = TnxComplex2Complex( - max_rank=max_rank, + tnxcomplex2complex = TnxComplex2Complex( + complex_dim=complex_dim, neighborhoods=neighborhoods, signed=signed, transfer_features=transfer_features, ) - plain2dict = Complex2Dict() - super().__init__(adapters=(complex2plain, plain2dict)) + complex2dict = Complex2Dict() + super().__init__(adapters=(tnxcomplex2complex, complex2dict)) From 601a0e1ad6e7288ce2020a8369fd832fb8e300c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 14 Jan 2025 17:38:13 -0800 Subject: [PATCH 09/36] Update graph2hypergraph liftings to work with new design --- .../liftings/graph2hypergraph/khop.py | 20 ++++++++----------- .../liftings/graph2hypergraph/knn.py | 20 +++++++------------ 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/topobenchmark/transforms/liftings/graph2hypergraph/khop.py b/topobenchmark/transforms/liftings/graph2hypergraph/khop.py index 298fa135..f8997e31 100755 --- a/topobenchmark/transforms/liftings/graph2hypergraph/khop.py +++ b/topobenchmark/transforms/liftings/graph2hypergraph/khop.py @@ -3,12 +3,10 @@ import torch import torch_geometric -from topobenchmark.transforms.liftings.graph2hypergraph import ( - Graph2HypergraphLifting, -) +from topobenchmark.transforms.liftings.base import LiftingMap -class HypergraphKHopLifting(Graph2HypergraphLifting): +class HypergraphKHopLifting(LiftingMap): r"""Lift graph to hypergraphs by considering k-hop neighborhoods. The class transforms graphs to hypergraph domain by considering k-hop neighborhoods of @@ -19,18 +17,16 @@ class HypergraphKHopLifting(Graph2HypergraphLifting): ---------- k_value : int, optional The number of hops to consider. Default is 1. - **kwargs : optional - Additional arguments for the class. """ - def __init__(self, k_value=1, **kwargs): - super().__init__(**kwargs) - self.k = k_value + def __init__(self, k_value=1): + super().__init__() + self.n_hops = k_value def __repr__(self) -> str: - return f"{self.__class__.__name__}(k={self.k!r})" + return f"{self.__class__.__name__}(k={self.n_hops!r})" - def lift_topology(self, data: torch_geometric.data.Data) -> dict: + def lift(self, data: torch_geometric.data.Data) -> dict: r"""Lift a graphs to hypergraphs by considering k-hop neighborhoods. Parameters @@ -70,7 +66,7 @@ def lift_topology(self, data: torch_geometric.data.Data) -> dict: for n in range(num_nodes): neighbors, _, _, _ = torch_geometric.utils.k_hop_subgraph( - n, self.k, edge_index + n, self.n_hops, edge_index ) incidence_1[n, neighbors] = 1 diff --git a/topobenchmark/transforms/liftings/graph2hypergraph/knn.py b/topobenchmark/transforms/liftings/graph2hypergraph/knn.py index 03d0a13a..5b0de672 100755 --- a/topobenchmark/transforms/liftings/graph2hypergraph/knn.py +++ b/topobenchmark/transforms/liftings/graph2hypergraph/knn.py @@ -3,12 +3,10 @@ import torch import torch_geometric -from topobenchmark.transforms.liftings.graph2hypergraph import ( - Graph2HypergraphLifting, -) +from topobenchmark.transforms.liftings.base import LiftingMap -class HypergraphKNNLifting(Graph2HypergraphLifting): +class HypergraphKNNLifting(LiftingMap): r"""Lift graphs to hypergraph domain by considering k-nearest neighbors. Parameters @@ -17,8 +15,6 @@ class HypergraphKNNLifting(Graph2HypergraphLifting): The number of nearest neighbors to consider. Must be positive. Default is 1. loop : bool, optional If True the hyperedges will contain the node they were created from. - **kwargs : optional - Additional arguments for the class. Raises ------ @@ -28,8 +24,8 @@ class HypergraphKNNLifting(Graph2HypergraphLifting): If k_value is not an integer or if loop is not a boolean. """ - def __init__(self, k_value=1, loop=True, **kwargs): - super().__init__(**kwargs) + def __init__(self, k_value=1, loop=True): + super().__init__() # Validate k_value if not isinstance(k_value, int): @@ -41,11 +37,9 @@ def __init__(self, k_value=1, loop=True, **kwargs): if not isinstance(loop, bool): raise TypeError("loop must be a boolean") - self.k = k_value - self.loop = loop - self.transform = torch_geometric.transforms.KNNGraph(self.k, self.loop) + self.transform = torch_geometric.transforms.KNNGraph(k_value, loop) - def lift_topology(self, data: torch_geometric.data.Data) -> dict: + def lift(self, data: torch_geometric.data.Data) -> dict: r"""Lift a graph to hypergraph by considering k-nearest neighbors. Parameters @@ -64,7 +58,7 @@ def lift_topology(self, data: torch_geometric.data.Data) -> dict: incidence_1 = torch.zeros(num_nodes, num_nodes) data_lifted = self.transform(data) # check for loops, since KNNGraph is inconsistent with nodes with equal features - if self.loop: + if self.transform.loop: for i in range(num_nodes): if not torch.any( torch.all( From 1f9b12d76095fc428617ab405852ece84aa7efe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 14 Jan 2025 17:38:39 -0800 Subject: [PATCH 10/36] Update graph2cell liftings to work with new design --- .../transforms/liftings/graph2cell/cycle.py | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/topobenchmark/transforms/liftings/graph2cell/cycle.py b/topobenchmark/transforms/liftings/graph2cell/cycle.py index 31e94d8b..63160701 100755 --- a/topobenchmark/transforms/liftings/graph2cell/cycle.py +++ b/topobenchmark/transforms/liftings/graph2cell/cycle.py @@ -1,15 +1,12 @@ """This module implements the cycle lifting for graphs to cell complexes.""" import networkx as nx -import torch_geometric from toponetx.classes import CellComplex -from topobenchmark.transforms.liftings.graph2cell.base import ( - Graph2CellLifting, -) +from topobenchmark.transforms.liftings.base import LiftingMap -class CellCycleLifting(Graph2CellLifting): +class CellCycleLifting(LiftingMap): r"""Lift graphs to cell complexes. The algorithm creates 2-cells by identifying the cycles and considering them as 2-cells. @@ -18,39 +15,40 @@ class CellCycleLifting(Graph2CellLifting): ---------- max_cell_length : int, optional The maximum length of the cycles to be lifted. Default is None. - **kwargs : optional - Additional arguments for the class. """ - def __init__(self, max_cell_length=None, **kwargs): - super().__init__(**kwargs) - self.complex_dim = 2 + def __init__(self, max_cell_length=None): + super().__init__() + self._complex_dim = 2 self.max_cell_length = max_cell_length - def lift_topology(self, data: torch_geometric.data.Data) -> dict: + def lift(self, domain): r"""Find the cycles of a graph and lifts them to 2-cells. Parameters ---------- - data : torch_geometric.data.Data - The input data to be lifted. + domain : nx.Graph + Graph to be lifted. Returns ------- - dict - The lifted topology. + CellComplex + The cell complex. """ - G = self._generate_graph_from_data(data) - cycles = nx.cycle_basis(G) - cell_complex = CellComplex(G) + graph = domain + + cycles = nx.cycle_basis(graph) + cell_complex = CellComplex(graph) # Eliminate self-loop cycles cycles = [cycle for cycle in cycles if len(cycle) != 1] - # Eliminate cycles that are greater than the max_cell_lenght + + # Eliminate cycles that are greater than the max_cell_length if self.max_cell_length is not None: cycles = [ cycle for cycle in cycles if len(cycle) <= self.max_cell_length ] if len(cycles) != 0: - cell_complex.add_cells_from(cycles, rank=self.complex_dim) - return self._get_lifted_topology(cell_complex, G) + cell_complex.add_cells_from(cycles, rank=self._complex_dim) + + return cell_complex From f5604753268b667f4d6fe22d60442e461feb9d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 14 Jan 2025 17:40:06 -0800 Subject: [PATCH 11/36] Improve SimplicialCliqueLifting docstrings --- .../liftings/graph2simplicial/clique.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/topobenchmark/transforms/liftings/graph2simplicial/clique.py b/topobenchmark/transforms/liftings/graph2simplicial/clique.py index 2bb8c405..37a5cc15 100755 --- a/topobenchmark/transforms/liftings/graph2simplicial/clique.py +++ b/topobenchmark/transforms/liftings/graph2simplicial/clique.py @@ -11,17 +11,17 @@ class SimplicialCliqueLifting(LiftingMap): r"""Lift graphs to simplicial complex domain. - The algorithm creates simplices by identifying the cliques and considering them as simplices of the same dimension. + The algorithm creates simplices by identifying the cliques + and considering them as simplices of the same dimension. Parameters ---------- complex_dim : int - Maximum rank of the complex. + Dimension of the subcomplex. """ def __init__(self, complex_dim=2): super().__init__() - # TODO: better naming self.complex_dim = complex_dim def lift(self, domain): @@ -29,13 +29,13 @@ def lift(self, domain): Parameters ---------- - data : torch_geometric.data.Data - The input data to be lifted. + domain : nx.Graph + Graph to be lifted. Returns ------- - dict - The lifted topology. + toponetx.Complex + Lifted simplicial complex. """ graph = domain @@ -50,5 +50,4 @@ def lift(self, domain): for set_k_simplices in simplices: simplicial_complex.add_simplices_from(list(set_k_simplices)) - # TODO: need to check for edge preservation return simplicial_complex From cfd7f458587e908b5bfccb97179e2f18aa9991b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 14 Jan 2025 17:41:35 -0800 Subject: [PATCH 12/36] Fix lifting tests (NB: same behavior, only adapted setup - with few exceptions) --- test/conftest.py | 48 +++--- .../liftings/cell/test_CellCyclesLifting.py | 10 +- .../hypergraph/test_HypergraphKHopLifting.py | 41 ++++-- ...test_HypergraphKNearestNeighborsLifting.py | 138 ++++++++++-------- .../test_SimplicialCliqueLifting.py | 38 ++++- .../test_SimplicialNeighborhoodLifting.py | 35 ++++- test/transforms/liftings/test_GraphLifting.py | 89 +++++------ 7 files changed, 243 insertions(+), 156 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index c84a1b72..753d63b2 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,25 +1,25 @@ """Configuration file for pytest.""" + import networkx as nx import pytest import torch import torch_geometric -from topobenchmark.transforms.liftings.graph2simplicial import ( - SimplicialCliqueLifting -) -from topobenchmark.transforms.liftings.graph2cell import ( - CellCycleLifting + +from topobenchmark.transforms.liftings.graph2cell.cycle import CellCycleLifting +from topobenchmark.transforms.liftings.graph2simplicial.clique import ( + SimplicialCliqueLifting, ) @pytest.fixture def mocker_fixture(mocker): """Return pytest mocker, used when one want to use mocker in setup_method. - + Parameters ---------- mocker : pytest_mock.plugin.MockerFixture A pytest mocker. - + Returns ------- pytest_mock.plugin.MockerFixture @@ -31,7 +31,7 @@ def mocker_fixture(mocker): @pytest.fixture def simple_graph_0(): """Create a manual graph for testing purposes. - + Returns ------- torch_geometric.data.Data @@ -74,10 +74,11 @@ def simple_graph_0(): ) return data + @pytest.fixture def simple_graph_1(): """Create a manual graph for testing purposes. - + Returns ------- torch_geometric.data.Data @@ -133,37 +134,35 @@ def simple_graph_1(): return data - @pytest.fixture def sg1_clique_lifted(simple_graph_1): """Return a simple graph with a clique lifting. - + Parameters ---------- simple_graph_1 : torch_geometric.data.Data A simple graph data object. - + Returns ------- torch_geometric.data.Data A simple graph data object with a clique lifting. """ - lifting_signed = SimplicialCliqueLifting( - complex_dim=3, signed=True - ) + lifting_signed = SimplicialCliqueLifting(complex_dim=3, signed=True) data = lifting_signed(simple_graph_1) data.batch_0 = "null" return data + @pytest.fixture def sg1_cell_lifted(simple_graph_1): """Return a simple graph with a cell lifting. - + Parameters ---------- simple_graph_1 : torch_geometric.data.Data A simple graph data object. - + Returns ------- torch_geometric.data.Data @@ -178,7 +177,7 @@ def sg1_cell_lifted(simple_graph_1): @pytest.fixture def simple_graph_2(): """Create a manual graph for testing purposes. - + Returns ------- torch_geometric.data.Data @@ -244,7 +243,7 @@ def simple_graph_2(): @pytest.fixture def random_graph_input(): """Create a random graph for testing purposes. - + Returns ------- torch.Tensor @@ -261,13 +260,12 @@ def random_graph_input(): num_nodes = 8 d_feat = 12 x = torch.randn(num_nodes, 12) - edges_1 = torch.randint(0, num_nodes, (2, num_nodes*2)) - edges_2 = torch.randint(0, num_nodes, (2, num_nodes*2)) - + edges_1 = torch.randint(0, num_nodes, (2, num_nodes * 2)) + edges_2 = torch.randint(0, num_nodes, (2, num_nodes * 2)) + d_feat_1, d_feat_2 = 5, 17 - x_1 = torch.randn(num_nodes*2, d_feat_1) - x_2 = torch.randn(num_nodes*2, d_feat_2) + x_1 = torch.randn(num_nodes * 2, d_feat_1) + x_2 = torch.randn(num_nodes * 2, d_feat_2) return x, x_1, x_2, edges_1, edges_2 - diff --git a/test/transforms/liftings/cell/test_CellCyclesLifting.py b/test/transforms/liftings/cell/test_CellCyclesLifting.py index 54fd276f..c574992e 100644 --- a/test/transforms/liftings/cell/test_CellCyclesLifting.py +++ b/test/transforms/liftings/cell/test_CellCyclesLifting.py @@ -2,7 +2,9 @@ import torch -from topobenchmark.transforms.liftings.graph2cell import CellCycleLifting +from topobenchmark.data.utils import Data2NxGraph, TnxComplex2Dict +from topobenchmark.transforms.liftings.base import LiftingTransform +from topobenchmark.transforms.liftings.graph2cell.cycle import CellCycleLifting class TestCellCycleLifting: @@ -10,7 +12,11 @@ class TestCellCycleLifting: def setup_method(self): # Initialise the CellCycleLifting class - self.lifting = CellCycleLifting() + self.lifting = LiftingTransform( + CellCycleLifting(), + data2domain=Data2NxGraph(), + domain2dict=TnxComplex2Dict(), + ) def test_lift_topology(self, simple_graph_1): # Test the lift_topology method diff --git a/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py b/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py index 13285fc1..3fcc7ebb 100644 --- a/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py +++ b/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py @@ -2,7 +2,8 @@ import torch -from topobenchmark.transforms.liftings.graph2hypergraph import ( +from topobenchmark.transforms.liftings.base import LiftingTransform +from topobenchmark.transforms.liftings.graph2hypergraph.khop import ( HypergraphKHopLifting, ) @@ -11,15 +12,23 @@ class TestHypergraphKHopLifting: """Test the HypergraphKHopLifting class.""" def setup_method(self): - """ Setup the test.""" + """Setup the test.""" # Initialise the HypergraphKHopLifting class - self.lifting_k1 = HypergraphKHopLifting(k_value=1) - self.lifting_k2 = HypergraphKHopLifting(k_value=2) - self.lifting_edge_attr = HypergraphKHopLifting(k_value=1, preserve_edge_attr=True) + self.lifting_k1 = LiftingTransform(HypergraphKHopLifting(k_value=1)) + self.lifting_k2 = LiftingTransform(HypergraphKHopLifting(k_value=2)) + + # TODO: delete? + # NB: `preserve_edge_attr` is never used? therefore they're equivalent + # self.lifting_edge_attr = HypergraphKHopLifting( + # k_value=1, preserve_edge_attr=True + # ) + self.lifting_edge_attr = LiftingTransform( + HypergraphKHopLifting(k_value=1) + ) def test_lift_topology(self, simple_graph_2): - """ Test the lift_topology method. - + """Test the lift_topology method. + Parameters ---------- simple_graph_2 : Data @@ -78,10 +87,18 @@ def test_lift_topology(self, simple_graph_2): assert ( expected_n_hyperedges == lifted_data_k2.num_hyperedges ), "Something is wrong with the number of hyperedges (k=2)." - + self.data_edge_attr = simple_graph_2 - edge_attributes = torch.rand((self.data_edge_attr.edge_index.shape[1], 2)) + edge_attributes = torch.rand( + (self.data_edge_attr.edge_index.shape[1], 2) + ) self.data_edge_attr.edge_attr = edge_attributes - lifted_data_edge_attr = self.lifting_edge_attr.forward(self.data_edge_attr.clone()) - assert lifted_data_edge_attr.edge_attr is not None, "Edge attributes are not preserved." - assert torch.all(edge_attributes == lifted_data_edge_attr.edge_attr), "Edge attributes are not preserved correctly." + lifted_data_edge_attr = self.lifting_edge_attr.forward( + self.data_edge_attr.clone() + ) + assert ( + lifted_data_edge_attr.edge_attr is not None + ), "Edge attributes are not preserved." + assert torch.all( + edge_attributes == lifted_data_edge_attr.edge_attr + ), "Edge attributes are not preserved correctly." diff --git a/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py b/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py index 7e9d1216..069d7a3c 100644 --- a/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py +++ b/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py @@ -3,7 +3,8 @@ import pytest import torch from torch_geometric.data import Data -from topobenchmark.transforms.liftings.graph2hypergraph import ( + +from topobenchmark.transforms.liftings.graph2hypergraph.knn import ( HypergraphKNNLifting, ) @@ -13,7 +14,7 @@ class TestHypergraphKNNLifting: def setup_method(self): """Set up test fixtures before each test method. - + Creates instances of HypergraphKNNLifting with different k values and loop settings. """ @@ -23,88 +24,94 @@ def setup_method(self): def test_initialization(self): """Test initialization with different parameters.""" + # TODO: overkill, delete? + # Test default parameters lifting_default = HypergraphKNNLifting() - assert lifting_default.k == 1 - assert lifting_default.loop is True + assert lifting_default.transform.k == 1 + assert lifting_default.transform.loop is True # Test custom parameters lifting_custom = HypergraphKNNLifting(k_value=5, loop=False) - assert lifting_custom.k == 5 - assert lifting_custom.loop is False + assert lifting_custom.transform.k == 5 + assert lifting_custom.transform.loop is False def test_lift_topology_k2(self, simple_graph_2): """Test the lift_topology method with k=2. - + Parameters ---------- simple_graph_2 : torch_geometric.data.Data A simple graph fixture with 9 nodes arranged in a line pattern. """ - lifted_data_k2 = self.lifting_k2.lift_topology(simple_graph_2.clone()) + lifted_data_k2 = self.lifting_k2.lift(simple_graph_2.clone()) expected_n_hyperedges = 9 - expected_incidence_1 = torch.tensor([ - [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0], - ]) + expected_incidence_1 = torch.tensor( + [ + [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0], + ] + ) assert torch.equal( lifted_data_k2["incidence_hyperedges"].to_dense(), - expected_incidence_1 + expected_incidence_1, ), "Incorrect incidence_hyperedges for k=2" - + assert lifted_data_k2["num_hyperedges"] == expected_n_hyperedges assert torch.equal(lifted_data_k2["x_0"], simple_graph_2.x) def test_lift_topology_k3(self, simple_graph_2): """Test the lift_topology method with k=3. - + Parameters ---------- simple_graph_2 : torch_geometric.data.Data A simple graph fixture with 9 nodes arranged in a line pattern. """ - lifted_data_k3 = self.lifting_k3.lift_topology(simple_graph_2.clone()) + lifted_data_k3 = self.lifting_k3.lift(simple_graph_2.clone()) expected_n_hyperedges = 9 - expected_incidence_1 = torch.tensor([ - [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], - ]) + expected_incidence_1 = torch.tensor( + [ + [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + ] + ) assert torch.equal( lifted_data_k3["incidence_hyperedges"].to_dense(), - expected_incidence_1 + expected_incidence_1, ), "Incorrect incidence_hyperedges for k=3" - + assert lifted_data_k3["num_hyperedges"] == expected_n_hyperedges assert torch.equal(lifted_data_k3["x_0"], simple_graph_2.x) def test_lift_topology_no_loop(self, simple_graph_2): """Test the lift_topology method with loop=False. - + Parameters ---------- simple_graph_2 : torch_geometric.data.Data A simple graph fixture with 9 nodes arranged in a line pattern. """ - lifted_data = self.lifting_no_loop.lift_topology(simple_graph_2.clone()) - + lifted_data = self.lifting_no_loop.lift(simple_graph_2.clone()) + # Verify no self-loops in the incidence matrix incidence_matrix = lifted_data["incidence_hyperedges"].to_dense() diagonal = torch.diag(incidence_matrix) @@ -115,11 +122,11 @@ def test_lift_topology_with_equal_features(self): # Create a graph where some nodes have identical features data = Data( x=torch.tensor([[1.0], [1.0], [2.0], [2.0]]), - edge_index=torch.tensor([[0, 1, 2, 3], [1, 0, 3, 2]]) + edge_index=torch.tensor([[0, 1, 2, 3], [1, 0, 3, 2]]), ) - - lifted_data = self.lifting_k2.lift_topology(data) - + + lifted_data = self.lifting_k2.lift(data) + # Verify the shape of the output assert lifted_data["incidence_hyperedges"].size() == (4, 4) assert lifted_data["num_hyperedges"] == 4 @@ -128,7 +135,7 @@ def test_lift_topology_with_equal_features(self): @pytest.mark.parametrize("k_value", [1, 2, 3, 4]) def test_different_k_values(self, k_value, simple_graph_2): """Test lift_topology with different k values. - + Parameters ---------- k_value : int @@ -137,29 +144,30 @@ def test_different_k_values(self, k_value, simple_graph_2): A simple graph fixture with 9 nodes arranged in a line pattern. """ lifting = HypergraphKNNLifting(k_value=k_value, loop=True) - lifted_data = lifting.lift_topology(simple_graph_2.clone()) - + lifted_data = lifting.lift(simple_graph_2.clone()) + # Verify basic properties assert lifted_data["num_hyperedges"] == simple_graph_2.x.size(0) incidence_matrix = lifted_data["incidence_hyperedges"].to_dense() - + # Check that each node is connected to at most k nodes - assert torch.all(incidence_matrix.sum(dim=1) <= k_value), \ - f"Some nodes are connected to more than {k_value} neighbors" + assert torch.all( + incidence_matrix.sum(dim=1) <= k_value + ), f"Some nodes are connected to more than {k_value} neighbors" def test_invalid_inputs(self): """Test handling of invalid inputs and edge cases.""" # Test with no x attribute (this should raise AttributeError) data_no_x = Data(edge_index=torch.tensor([[0, 1], [1, 0]])) with pytest.raises(AttributeError): - self.lifting_k2.lift_topology(data_no_x) + self.lifting_k2.lift(data_no_x) # Test single node case (edge case that should work) single_node_data = Data( x=torch.tensor([[1.0]], dtype=torch.float), - edge_index=torch.tensor([[0], [0]]) + edge_index=torch.tensor([[0], [0]]), ) - lifted_single = self.lifting_k2.lift_topology(single_node_data) + lifted_single = self.lifting_k2.lift(single_node_data) assert lifted_single["num_hyperedges"] == 1 assert lifted_single["incidence_hyperedges"].size() == (1, 1) assert torch.equal(lifted_single["x_0"], single_node_data.x) @@ -167,32 +175,30 @@ def test_invalid_inputs(self): # Test with identical nodes (edge case that should work) identical_nodes_data = Data( x=torch.tensor([[1.0], [1.0]], dtype=torch.float), - edge_index=torch.tensor([[0, 1], [1, 0]]) + edge_index=torch.tensor([[0, 1], [1, 0]]), ) - lifted_identical = self.lifting_k2.lift_topology(identical_nodes_data) + lifted_identical = self.lifting_k2.lift(identical_nodes_data) assert lifted_identical["num_hyperedges"] == 2 assert lifted_identical["incidence_hyperedges"].size() == (2, 2) assert torch.equal(lifted_identical["x_0"], identical_nodes_data.x) # Test with missing edge_index (this should work as KNNGraph will create edges) - data_no_edges = Data( - x=torch.tensor([[1.0], [2.0]], dtype=torch.float) - ) - lifted_no_edges = self.lifting_k2.lift_topology(data_no_edges) + data_no_edges = Data(x=torch.tensor([[1.0], [2.0]], dtype=torch.float)) + lifted_no_edges = self.lifting_k2.lift(data_no_edges) assert lifted_no_edges["num_hyperedges"] == 2 assert lifted_no_edges["incidence_hyperedges"].size() == (2, 2) assert torch.equal(lifted_no_edges["x_0"], data_no_edges.x) # Test with no data (should raise AttributeError) with pytest.raises(AttributeError): - self.lifting_k2.lift_topology(None) + self.lifting_k2.lift(None) # Test with empty tensor for x (should work but result in empty outputs) empty_data = Data( x=torch.tensor([], dtype=torch.float).reshape(0, 1), - edge_index=torch.tensor([], dtype=torch.long).reshape(2, 0) + edge_index=torch.tensor([], dtype=torch.long).reshape(2, 0), ) - lifted_empty = self.lifting_k2.lift_topology(empty_data) + lifted_empty = self.lifting_k2.lift(empty_data) assert lifted_empty["num_hyperedges"] == 0 assert lifted_empty["incidence_hyperedges"].size(0) == 0 @@ -203,13 +209,17 @@ def test_invalid_initialization(self): HypergraphKNNLifting(k_value=1.5) # Test with zero k_value - with pytest.raises(ValueError, match="k_value must be greater than or equal to 1"): + with pytest.raises( + ValueError, match="k_value must be greater than or equal to 1" + ): HypergraphKNNLifting(k_value=0) # Test with negative k_value - with pytest.raises(ValueError, match="k_value must be greater than or equal to 1"): + with pytest.raises( + ValueError, match="k_value must be greater than or equal to 1" + ): HypergraphKNNLifting(k_value=-1) # Test with non-boolean loop with pytest.raises(TypeError, match="loop must be a boolean"): - HypergraphKNNLifting(k_value=1, loop="True") \ No newline at end of file + HypergraphKNNLifting(k_value=1, loop="True") diff --git a/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py b/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py index 7d85b19e..fa36e072 100644 --- a/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py +++ b/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py @@ -2,11 +2,19 @@ import torch -from topobenchmark.transforms.liftings.graph2simplicial import ( - SimplicialCliqueLifting +from topobenchmark.data.utils import ( + Complex2Dict, + Data2NxGraph, + TnxComplex2Complex, +) +from topobenchmark.transforms.feature_liftings.projection_sum import ( + ProjectionSum, ) -from topobenchmark.transforms.converters import Data2NxGraph, Complex2Dict from topobenchmark.transforms.liftings.base import LiftingTransform +from topobenchmark.transforms.liftings.graph2simplicial.clique import ( + SimplicialCliqueLifting, +) + class TestSimplicialCliqueLifting: """Test the SimplicialCliqueLifting class.""" @@ -14,13 +22,25 @@ class TestSimplicialCliqueLifting: def setup_method(self): # Initialise the SimplicialCliqueLifting class data2graph = Data2NxGraph() - simplicial2dict_signed = Complex2Dict(signed=True) - simplicial2dict_unsigned = Complex2Dict(signed=False) lifting_map = SimplicialCliqueLifting(complex_dim=3) + feature_lifting = ProjectionSum() + domain2dict = Complex2Dict() - self.lifting_signed = LiftingTransform(data2graph, simplicial2dict_signed, lifting_map) - self.lifting_unsigned = LiftingTransform(data2graph, simplicial2dict_unsigned, lifting_map) + self.lifting_signed = LiftingTransform( + lifting=lifting_map, + feature_lifting=feature_lifting, + data2domain=data2graph, + domain2domain=TnxComplex2Complex(signed=True), + domain2dict=domain2dict, + ) + self.lifting_unsigned = LiftingTransform( + lifting=lifting_map, + feature_lifting=feature_lifting, + data2domain=data2graph, + domain2domain=TnxComplex2Complex(signed=False), + domain2dict=domain2dict, + ) def test_lift_topology(self, simple_graph_1): """Test the lift_topology method.""" @@ -207,6 +227,8 @@ def test_lift_topology(self, simple_graph_1): def test_lifted_features_signed(self, simple_graph_1): """Test the lift_features method in signed incidence cases.""" + # TODO: can be removed/moved; part of projection sum + self.data = simple_graph_1 # Test the lift_features method for signed case lifted_data = self.lifting_signed.forward(self.data) @@ -249,6 +271,8 @@ def test_lifted_features_signed(self, simple_graph_1): def test_lifted_features_unsigned(self, simple_graph_1): """Test the lift_features method in unsigned incidence cases.""" + # TODO: redundant. can be moved/removed + self.data = simple_graph_1 # Test the lift_features method for unsigned case lifted_data = self.lifting_unsigned.forward(self.data) diff --git a/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py b/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py index 5a03f67e..e21b8f99 100644 --- a/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py +++ b/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py @@ -2,19 +2,46 @@ import torch -from topobenchmark.transforms.liftings.graph2simplicial import ( +from topobenchmark.data.utils import ( + Complex2Dict, + Data2NxGraph, + TnxComplex2Complex, +) +from topobenchmark.transforms.feature_liftings.projection_sum import ( + ProjectionSum, +) +from topobenchmark.transforms.liftings.base import LiftingTransform +from topobenchmark.transforms.liftings.graph2simplicial.khop import ( SimplicialKHopLifting, ) +# TODO: rename for consistency? + class TestSimplicialKHopLifting: """Test the SimplicialKHopLifting class.""" def setup_method(self): # Initialise the SimplicialKHopLifting class - self.lifting_signed = SimplicialKHopLifting(complex_dim=3, signed=True) - self.lifting_unsigned = SimplicialKHopLifting( - complex_dim=3, signed=False + data2graph = Data2NxGraph() + feature_lifting = ProjectionSum() + domain2dict = Complex2Dict() + + lifting_map = SimplicialKHopLifting(complex_dim=3) + + self.lifting_signed = LiftingTransform( + lifting=lifting_map, + feature_lifting=feature_lifting, + data2domain=data2graph, + domain2domain=TnxComplex2Complex(signed=True), + domain2dict=domain2dict, + ) + self.lifting_unsigned = LiftingTransform( + lifting=lifting_map, + feature_lifting=feature_lifting, + data2domain=data2graph, + domain2domain=TnxComplex2Complex(signed=False), + domain2dict=domain2dict, ) def test_lift_topology(self, simple_graph_1): diff --git a/test/transforms/liftings/test_GraphLifting.py b/test/transforms/liftings/test_GraphLifting.py index c7acf454..546956c9 100644 --- a/test/transforms/liftings/test_GraphLifting.py +++ b/test/transforms/liftings/test_GraphLifting.py @@ -1,21 +1,42 @@ """Test the GraphLifting class.""" -import pytest + import torch +import torch_geometric from torch_geometric.data import Data -from topobenchmark.transforms.liftings import GraphLifting +from topobenchmark.transforms.feature_liftings.projection_sum import ( + ProjectionSum, +) +from topobenchmark.transforms.liftings.base import LiftingMap, LiftingTransform + + +def _data_has_edge_attr(data: torch_geometric.data.Data) -> bool: + r"""Check if the input data object has edge attributes. + + Parameters + ---------- + data : torch_geometric.data.Data + The input data. + + Returns + ------- + bool + Whether the data object has edge attributes. + """ + return hasattr(data, "edge_attr") and data.edge_attr is not None -class ConcreteGraphLifting(GraphLifting): + +class ConcreteGraphLifting(LiftingMap): """Concrete implementation of GraphLifting for testing.""" - - def lift_topology(self, data): + + def lift(self, data): """Implement the abstract lift_topology method. - + Parameters ---------- data : torch_geometric.data.Data The input data to be lifted. - + Returns ------- dict @@ -26,86 +47,70 @@ def lift_topology(self, data): class TestGraphLifting: """Test the GraphLifting class.""" - + def setup_method(self): """Set up test fixtures before each test method. - + Creates an instance of ConcreteGraphLifting with default parameters. """ - self.lifting = ConcreteGraphLifting( - feature_lifting="ProjectionSum", - preserve_edge_attr=False + self.lifting = LiftingTransform( + ConcreteGraphLifting(), feature_lifting=ProjectionSum() ) def test_data_has_edge_attr(self): """Test _data_has_edge_attr method with different data configurations.""" - + # Test case 1: Data with edge attributes data_with_edge_attr = Data( x=torch.tensor([[1.0], [2.0]]), edge_index=torch.tensor([[0, 1], [1, 0]]), - edge_attr=torch.tensor([[1.0], [1.0]]) + edge_attr=torch.tensor([[1.0], [1.0]]), ) - assert self.lifting._data_has_edge_attr(data_with_edge_attr) is True + assert _data_has_edge_attr(data_with_edge_attr) is True # Test case 2: Data without edge attributes data_without_edge_attr = Data( x=torch.tensor([[1.0], [2.0]]), - edge_index=torch.tensor([[0, 1], [1, 0]]) + edge_index=torch.tensor([[0, 1], [1, 0]]), ) - assert self.lifting._data_has_edge_attr(data_without_edge_attr) is False + assert _data_has_edge_attr(data_without_edge_attr) is False # Test case 3: Data with edge_attr set to None data_with_none_edge_attr = Data( x=torch.tensor([[1.0], [2.0]]), edge_index=torch.tensor([[0, 1], [1, 0]]), - edge_attr=None + edge_attr=None, ) - assert self.lifting._data_has_edge_attr(data_with_none_edge_attr) is False + assert _data_has_edge_attr(data_with_none_edge_attr) is False def test_data_has_edge_attr_empty_data(self): """Test _data_has_edge_attr method with empty data object.""" empty_data = Data() - assert self.lifting._data_has_edge_attr(empty_data) is False + assert _data_has_edge_attr(empty_data) is False def test_data_has_edge_attr_different_edge_formats(self): """Test _data_has_edge_attr method with different edge attribute formats.""" - + # Test with float edge attributes data_float_attr = Data( x=torch.tensor([[1.0], [2.0]]), edge_index=torch.tensor([[0, 1], [1, 0]]), - edge_attr=torch.tensor([[0.5], [0.5]]) + edge_attr=torch.tensor([[0.5], [0.5]]), ) - assert self.lifting._data_has_edge_attr(data_float_attr) is True + assert _data_has_edge_attr(data_float_attr) is True # Test with integer edge attributes data_int_attr = Data( x=torch.tensor([[1.0], [2.0]]), edge_index=torch.tensor([[0, 1], [1, 0]]), - edge_attr=torch.tensor([[1], [1]], dtype=torch.long) + edge_attr=torch.tensor([[1], [1]], dtype=torch.long), ) - assert self.lifting._data_has_edge_attr(data_int_attr) is True + assert _data_has_edge_attr(data_int_attr) is True # Test with multi-dimensional edge attributes data_multidim_attr = Data( x=torch.tensor([[1.0], [2.0]]), edge_index=torch.tensor([[0, 1], [1, 0]]), - edge_attr=torch.tensor([[1.0, 2.0], [2.0, 1.0]]) - ) - assert self.lifting._data_has_edge_attr(data_multidim_attr) is True - - @pytest.mark.parametrize("preserve_edge_attr", [True, False]) - def test_init_preserve_edge_attr(self, preserve_edge_attr): - """Test initialization with different preserve_edge_attr values. - - Parameters - ---------- - preserve_edge_attr : bool - Boolean value to test initialization with True and False values. - """ - lifting = ConcreteGraphLifting( - feature_lifting="ProjectionSum", - preserve_edge_attr=preserve_edge_attr + edge_attr=torch.tensor([[1.0, 2.0], [2.0, 1.0]]), ) - assert lifting.preserve_edge_attr == preserve_edge_attr \ No newline at end of file + assert _data_has_edge_attr(data_multidim_attr) is True From 5ac166f34f47a176931ff20d6c55830086bd77e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 14 Jan 2025 17:47:16 -0800 Subject: [PATCH 13/36] Remove dead code --- .../liftings/test_AbstractLifting.py | 53 ------ topobenchmark/transforms/liftings/__init__.py | 20 -- topobenchmark/transforms/liftings/base.py | 58 ------ .../transforms/liftings/graph2cell/base.py | 57 ------ .../liftings/graph2hypergraph/base.py | 17 -- .../liftings/graph2simplicial/base.py | 69 ------- topobenchmark/transforms/liftings/liftings.py | 172 ------------------ 7 files changed, 446 deletions(-) delete mode 100644 test/transforms/liftings/test_AbstractLifting.py delete mode 100755 topobenchmark/transforms/liftings/graph2cell/base.py delete mode 100755 topobenchmark/transforms/liftings/graph2hypergraph/base.py delete mode 100755 topobenchmark/transforms/liftings/graph2simplicial/base.py delete mode 100644 topobenchmark/transforms/liftings/liftings.py diff --git a/test/transforms/liftings/test_AbstractLifting.py b/test/transforms/liftings/test_AbstractLifting.py deleted file mode 100644 index 49167cb1..00000000 --- a/test/transforms/liftings/test_AbstractLifting.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Test AbstractLifting module.""" - -import pytest -import torch -from torch_geometric.data import Data -from topobenchmark.transforms.liftings import AbstractLifting - -class TestAbstractLifting: - """Test the AbstractLifting class.""" - - def setup_method(self): - """Set up test fixtures for each test method. - - Creates a concrete subclass of AbstractLifting for testing purposes. - """ - class ConcreteLifting(AbstractLifting): - """Concrete implementation of AbstractLifting for testing.""" - - def lift_topology(self, data): - """Implementation of abstract method that calls parent's method. - - Parameters - ---------- - data : torch_geometric.data.Data - The input data to be lifted. - - Returns - ------- - dict - Empty dictionary as this is just for testing. - - Raises - ------ - NotImplementedError - Always raises this error as it calls the parent's abstract method. - """ - return super().lift_topology(data) - - self.lifting = ConcreteLifting(feature_lifting=None) - - def test_lift_topology_raises_not_implemented(self): - """Test that the abstract lift_topology method raises NotImplementedError. - - Verifies that calling lift_topology on an abstract class implementation - raises NotImplementedError as expected. - """ - dummy_data = Data( - x=torch.tensor([[1.0], [2.0]]), - edge_index=torch.tensor([[0, 1], [1, 0]]) - ) - - with pytest.raises(NotImplementedError): - self.lifting.lift_topology(dummy_data) \ No newline at end of file diff --git a/topobenchmark/transforms/liftings/__init__.py b/topobenchmark/transforms/liftings/__init__.py index 4692ceaf..0776fee4 100755 --- a/topobenchmark/transforms/liftings/__init__.py +++ b/topobenchmark/transforms/liftings/__init__.py @@ -1,21 +1 @@ """This module implements the liftings for the topological transforms.""" - -from .base import AbstractLifting -from .liftings import ( - CellComplexLifting, - CombinatorialLifting, - GraphLifting, - HypergraphLifting, - PointCloudLifting, - SimplicialLifting, -) - -__all__ = [ - "AbstractLifting", - "CellComplexLifting", - "CombinatorialLifting", - "GraphLifting", - "HypergraphLifting", - "PointCloudLifting", - "SimplicialLifting", -] diff --git a/topobenchmark/transforms/liftings/base.py b/topobenchmark/transforms/liftings/base.py index d5c78dc9..ce3d1ab4 100644 --- a/topobenchmark/transforms/liftings/base.py +++ b/topobenchmark/transforms/liftings/base.py @@ -5,7 +5,6 @@ import torch_geometric from topobenchmark.data.utils import IdentityAdapter -from topobenchmark.transforms.feature_liftings import FEATURE_LIFTINGS from topobenchmark.transforms.feature_liftings.identity import Identity @@ -29,7 +28,6 @@ class LiftingTransform(torch_geometric.transforms.BaseTransform): Feature lifting map. """ - # NB: emulates previous AbstractLifting def __init__( self, lifting, @@ -79,7 +77,6 @@ def forward( lifted_topology = self.feature_lifting(lifted_topology) lifted_topology_dict = self.domain2dict(lifted_topology) - # TODO: make this line more clear return torch_geometric.data.Data( **initial_data, **lifted_topology_dict ) @@ -98,58 +95,3 @@ def __call__(self, domain): @abc.abstractmethod def lift(self, domain): """Lift domain.""" - - -class AbstractLifting(torch_geometric.transforms.BaseTransform): - r"""Abstract class for topological liftings. - - Parameters - ---------- - feature_lifting : str, optional - The feature lifting method to be used. Default is 'ProjectionSum'. - **kwargs : optional - Additional arguments for the class. - """ - - # TODO: delete - - def __init__(self, feature_lifting=None, **kwargs): - super().__init__() - self.feature_lifting = FEATURE_LIFTINGS[feature_lifting]() - self.neighborhoods = kwargs.get("neighborhoods") - - @abc.abstractmethod - def lift_topology(self, data: torch_geometric.data.Data) -> dict: - r"""Lift the topology of a graph to higher-order topological domains. - - Parameters - ---------- - data : torch_geometric.data.Data - The input data to be lifted. - - Returns - ------- - dict - The lifted topology. - """ - raise NotImplementedError - - def forward( - self, data: torch_geometric.data.Data - ) -> torch_geometric.data.Data: - r"""Apply the full lifting (topology + features) to the input data. - - Parameters - ---------- - data : torch_geometric.data.Data - The input data to be lifted. - - Returns - ------- - torch_geometric.data.Data - The lifted data. - """ - initial_data = data.to_dict() - lifted_topology = self.lift_topology(data) - lifted_topology = self.feature_lifting(lifted_topology) - return torch_geometric.data.Data(**initial_data, **lifted_topology) diff --git a/topobenchmark/transforms/liftings/graph2cell/base.py b/topobenchmark/transforms/liftings/graph2cell/base.py deleted file mode 100755 index aeff3646..00000000 --- a/topobenchmark/transforms/liftings/graph2cell/base.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Abstract class for lifting graphs to cell complexes.""" - -import networkx as nx -import torch -from toponetx.classes import CellComplex - -from topobenchmark.data.utils.utils import get_complex_connectivity -from topobenchmark.transforms.liftings import GraphLifting - - -class Graph2CellLifting(GraphLifting): - r"""Abstract class for lifting graphs to cell complexes. - - Parameters - ---------- - complex_dim : int, optional - The dimension of the cell complex to be generated. Default is 2. - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, complex_dim=2, **kwargs): - super().__init__(**kwargs) - self.complex_dim = complex_dim - self.type = "graph2cell" - - def _get_lifted_topology( - self, cell_complex: CellComplex, graph: nx.Graph - ) -> dict: - r"""Return the lifted topology. - - Parameters - ---------- - cell_complex : CellComplex - The cell complex. - graph : nx.Graph - The input graph. - - Returns - ------- - dict - The lifted topology. - """ - lifted_topology = get_complex_connectivity( - cell_complex, self.complex_dim, neighborhoods=self.neighborhoods - ) - lifted_topology["x_0"] = torch.stack( - list(cell_complex.get_cell_attributes("features", 0).values()) - ) - # If new edges have been added during the lifting process, we discard the edge attributes - if self.contains_edge_attr and cell_complex.shape[1] == ( - graph.number_of_edges() - ): - lifted_topology["x_1"] = torch.stack( - list(cell_complex.get_cell_attributes("features", 1).values()) - ) - return lifted_topology diff --git a/topobenchmark/transforms/liftings/graph2hypergraph/base.py b/topobenchmark/transforms/liftings/graph2hypergraph/base.py deleted file mode 100755 index e060e30e..00000000 --- a/topobenchmark/transforms/liftings/graph2hypergraph/base.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Abstract class for lifting graphs to hypergraphs.""" - -from topobenchmark.transforms.liftings import GraphLifting - - -class Graph2HypergraphLifting(GraphLifting): - r"""Abstract class for lifting graphs to hypergraphs. - - Parameters - ---------- - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.type = "graph2hypergraph" diff --git a/topobenchmark/transforms/liftings/graph2simplicial/base.py b/topobenchmark/transforms/liftings/graph2simplicial/base.py deleted file mode 100755 index e52449dc..00000000 --- a/topobenchmark/transforms/liftings/graph2simplicial/base.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Abstract class for lifting graphs to simplicial complexes.""" - -import networkx as nx -import torch -from toponetx.classes import SimplicialComplex - -from topobenchmark.data.utils.utils import get_complex_connectivity -from topobenchmark.transforms.liftings import GraphLifting - - -class Graph2SimplicialLifting(GraphLifting): - r"""Abstract class for lifting graphs to simplicial complexes. - - Parameters - ---------- - complex_dim : int, optional - The maximum dimension of the simplicial complex to be generated. Default is 2. - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, complex_dim=2, **kwargs): - super().__init__(**kwargs) - self.complex_dim = complex_dim - self.type = "graph2simplicial" - self.signed = kwargs.get("signed", False) - - def _get_lifted_topology( - self, simplicial_complex: SimplicialComplex, graph: nx.Graph - ) -> dict: - r"""Return the lifted topology. - - Parameters - ---------- - simplicial_complex : SimplicialComplex - The simplicial complex. - graph : nx.Graph - The input graph. - - Returns - ------- - dict - The lifted topology. - """ - lifted_topology = get_complex_connectivity( - simplicial_complex, - self.complex_dim, - neighborhoods=self.neighborhoods, - signed=self.signed, - ) - lifted_topology["x_0"] = torch.stack( - list( - simplicial_complex.get_simplex_attributes( - "features", 0 - ).values() - ) - ) - # If new edges have been added during the lifting process, we discard the edge attributes - if self.contains_edge_attr and simplicial_complex.shape[1] == ( - graph.number_of_edges() - ): - lifted_topology["x_1"] = torch.stack( - list( - simplicial_complex.get_simplex_attributes( - "features", 1 - ).values() - ) - ) - return lifted_topology diff --git a/topobenchmark/transforms/liftings/liftings.py b/topobenchmark/transforms/liftings/liftings.py deleted file mode 100644 index 9453eaa3..00000000 --- a/topobenchmark/transforms/liftings/liftings.py +++ /dev/null @@ -1,172 +0,0 @@ -"""This module implements the abstract classes for lifting graphs.""" - -import networkx as nx -import torch_geometric -from torch_geometric.utils.undirected import is_undirected, to_undirected - -from topobenchmark.transforms.liftings import AbstractLifting - - -class GraphLifting(AbstractLifting): - r"""Abstract class for lifting graph topologies to other domains. - - Parameters - ---------- - feature_lifting : str, optional - The feature lifting method to be used. Default is 'ProjectionSum'. - preserve_edge_attr : bool, optional - Whether to preserve edge attributes. Default is False. - **kwargs : optional - Additional arguments for the class. - """ - - def __init__( - self, - feature_lifting="ProjectionSum", - preserve_edge_attr=False, - **kwargs, - ): - super().__init__(feature_lifting=feature_lifting, **kwargs) - self.preserve_edge_attr = preserve_edge_attr - - def _data_has_edge_attr(self, data: torch_geometric.data.Data) -> bool: - r"""Check if the input data object has edge attributes. - - Parameters - ---------- - data : torch_geometric.data.Data - The input data. - - Returns - ------- - bool - Whether the data object has edge attributes. - """ - return hasattr(data, "edge_attr") and data.edge_attr is not None - - def _generate_graph_from_data( - self, data: torch_geometric.data.Data - ) -> nx.Graph: - r"""Generate a NetworkX graph from the input data object. - - Parameters - ---------- - data : torch_geometric.data.Data - The input data. - - Returns - ------- - nx.Graph - The generated NetworkX graph. - """ - # Check if data object have edge_attr, return list of tuples as [(node_id, {'features':data}, 'dim':1)] or ?? - nodes = [ - (n, dict(features=data.x[n], dim=0)) - for n in range(data.x.shape[0]) - ] - - if self.preserve_edge_attr and self._data_has_edge_attr(data): - # In case edge features are given, assign features to every edge - edge_index, edge_attr = ( - data.edge_index, - ( - data.edge_attr - if is_undirected(data.edge_index, data.edge_attr) - else to_undirected(data.edge_index, data.edge_attr) - ), - ) - edges = [ - (i.item(), j.item(), dict(features=edge_attr[edge_idx], dim=1)) - for edge_idx, (i, j) in enumerate( - zip(edge_index[0], edge_index[1], strict=False) - ) - ] - self.contains_edge_attr = True - else: - # If edge_attr is not present, return list list of edges - edges = [ - (i.item(), j.item(), {}) - for i, j in zip( - data.edge_index[0], data.edge_index[1], strict=False - ) - ] - self.contains_edge_attr = False - graph = nx.Graph() - graph.add_nodes_from(nodes) - graph.add_edges_from(edges) - return graph - - -class PointCloudLifting(AbstractLifting): - r"""Abstract class for lifting point clouds to other topological domains. - - Parameters - ---------- - feature_lifting : str, optional - The feature lifting method to be used. Default is 'ProjectionSum'. - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, feature_lifting="ProjectionSum", **kwargs): - super().__init__(feature_lifting=feature_lifting, **kwargs) - - -class CellComplexLifting(AbstractLifting): - r"""Abstract class for lifting cell complexes to other domains. - - Parameters - ---------- - feature_lifting : str, optional - The feature lifting method to be used. Default is 'ProjectionSum'. - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, feature_lifting="ProjectionSum", **kwargs): - super().__init__(feature_lifting=feature_lifting, **kwargs) - - -class SimplicialLifting(AbstractLifting): - r"""Abstract class for lifting simplicial complexes to other domains. - - Parameters - ---------- - feature_lifting : str, optional - The feature lifting method to be used. Default is 'ProjectionSum'. - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, feature_lifting="ProjectionSum", **kwargs): - super().__init__(feature_lifting=feature_lifting, **kwargs) - - -class HypergraphLifting(AbstractLifting): - r"""Abstract class for lifting hypergraphs to other domains. - - Parameters - ---------- - feature_lifting : str, optional - The feature lifting method to be used. Default is 'ProjectionSum'. - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, feature_lifting="ProjectionSum", **kwargs): - super().__init__(feature_lifting=feature_lifting, **kwargs) - - -class CombinatorialLifting(AbstractLifting): - r"""Abstract class for lifting combinatorial complexes to other domains. - - Parameters - ---------- - feature_lifting : str, optional - The feature lifting method to be used. Default is 'ProjectionSum'. - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, feature_lifting="ProjectionSum", **kwargs): - super().__init__(feature_lifting=feature_lifting, **kwargs) From 305f4861670240717d677f87454c1344250e4286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Wed, 15 Jan 2025 19:10:25 -0800 Subject: [PATCH 14/36] Update TRANSFORMS automatically dict creation/imports --- topobenchmark/transforms/__init__.py | 29 +---- topobenchmark/transforms/_utils.py | 53 +++++++++ .../transforms/data_manipulations/__init__.py | 83 +------------- .../transforms/feature_liftings/__init__.py | 104 +----------------- .../transforms/feature_liftings/identity.py | 2 +- .../feature_liftings/projection_sum.py | 6 +- topobenchmark/transforms/liftings/__init__.py | 14 +++ .../liftings/graph2cell/__init__.py | 99 ++--------------- .../liftings/graph2hypergraph/__init__.py | 99 ++--------------- .../liftings/graph2simplicial/__init__.py | 99 ++--------------- 10 files changed, 104 insertions(+), 484 deletions(-) create mode 100644 topobenchmark/transforms/_utils.py diff --git a/topobenchmark/transforms/__init__.py b/topobenchmark/transforms/__init__.py index 3f568814..62f8d85e 100755 --- a/topobenchmark/transforms/__init__.py +++ b/topobenchmark/transforms/__init__.py @@ -1,32 +1,11 @@ """This module contains the transforms for the topobenchmark package.""" -from typing import Any +from .data_manipulations import DATA_MANIPULATIONS +from .feature_liftings import FEATURE_LIFTINGS +from .liftings import LIFTINGS -from topobenchmark.transforms.data_manipulations import DATA_MANIPULATIONS -from topobenchmark.transforms.feature_liftings import FEATURE_LIFTINGS -from topobenchmark.transforms.liftings.graph2cell import GRAPH2CELL_LIFTINGS -from topobenchmark.transforms.liftings.graph2hypergraph import ( - GRAPH2HYPERGRAPH_LIFTINGS, -) -from topobenchmark.transforms.liftings.graph2simplicial import ( - GRAPH2SIMPLICIAL_LIFTINGS, -) - -LIFTINGS = { - **GRAPH2CELL_LIFTINGS, - **GRAPH2HYPERGRAPH_LIFTINGS, - **GRAPH2SIMPLICIAL_LIFTINGS, -} - -TRANSFORMS: dict[Any, Any] = { +TRANSFORMS = { **LIFTINGS, **FEATURE_LIFTINGS, **DATA_MANIPULATIONS, } - -__all__ = [ - "DATA_MANIPULATIONS", - "FEATURE_LIFTINGS", - "LIFTINGS", - "TRANSFORMS", -] diff --git a/topobenchmark/transforms/_utils.py b/topobenchmark/transforms/_utils.py new file mode 100644 index 00000000..f14d156e --- /dev/null +++ b/topobenchmark/transforms/_utils.py @@ -0,0 +1,53 @@ +import inspect +from importlib import util +from pathlib import Path + + +def discover_objs(package_path, condition=None): + """Dynamically discover all manipulation classes in the package. + + Parameters + ---------- + package_path : str + Path to the package's __init__.py file. + condition : callable + `(name, obj) -> bool` + + Returns + ------- + dict[str, type] + Dictionary mapping class names to their corresponding class objects. + """ + if condition is None: + condition = lambda name, obj: True + + objs = {} + + # Get the directory containing the manipulation modules + package_dir = Path(package_path).parent + + # Iterate through all .py files in the directory + for file_path in package_dir.glob("*.py"): + if file_path.stem == "__init__": + continue + + # Import the module + module_name = f"{Path(package_path).stem}.{file_path.stem}" + spec = util.spec_from_file_location(module_name, file_path) + if spec and spec.loader: + module = util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Find all manipulation classes in the module + for name, obj in inspect.getmembers(module): + if ( + not inspect.isclass(obj) + or name.startswith("_") + or obj.__module__ != module.__name__ + ): + continue + + if condition(name, obj): + objs[name] = obj + + return objs diff --git a/topobenchmark/transforms/data_manipulations/__init__.py b/topobenchmark/transforms/data_manipulations/__init__.py index a17e506d..314d5fa6 100644 --- a/topobenchmark/transforms/data_manipulations/__init__.py +++ b/topobenchmark/transforms/data_manipulations/__init__.py @@ -1,86 +1,7 @@ """Data manipulations module with automated exports.""" -import inspect -from importlib import util -from pathlib import Path -from typing import Any +from topobenchmark.transforms._utils import discover_objs +DATA_MANIPULATIONS = discover_objs(__file__) -class ModuleExportsManager: - """Manages automatic discovery and registration of data manipulation classes.""" - - @staticmethod - def is_manipulation_class(obj: Any) -> bool: - """Check if an object is a valid manipulation class. - - Parameters - ---------- - obj : Any - The object to check if it's a valid manipulation class. - - Returns - ------- - bool - True if the object is a valid manipulation class (non-private class - defined in __main__), False otherwise. - """ - return ( - inspect.isclass(obj) - and obj.__module__ == "__main__" - and not obj.__name__.startswith("_") - ) - - @classmethod - def discover_manipulations(cls, package_path: str) -> dict[str, type]: - """Dynamically discover all manipulation classes in the package. - - Parameters - ---------- - package_path : str - Path to the package's __init__.py file. - - Returns - ------- - dict[str, type] - Dictionary mapping class names to their corresponding class objects. - """ - manipulations = {} - - # Get the directory containing the manipulation modules - package_dir = Path(package_path).parent - - # Iterate through all .py files in the directory - for file_path in package_dir.glob("*.py"): - if file_path.stem == "__init__": - continue - - # Import the module - module_name = f"{Path(package_path).stem}.{file_path.stem}" - spec = util.spec_from_file_location(module_name, file_path) - if spec and spec.loader: - module = util.module_from_spec(spec) - spec.loader.exec_module(module) - - # Find all manipulation classes in the module - for name, obj in inspect.getmembers(module): - if ( - inspect.isclass(obj) - and obj.__module__ == module.__name__ - and not name.startswith("_") - ): - manipulations[name] = obj # noqa: PERF403 - - return manipulations - - -# Create the exports manager -manager = ModuleExportsManager() - -# Automatically discover and populate DATA_MANIPULATIONS -DATA_MANIPULATIONS = manager.discover_manipulations(__file__) - -# Automatically generate __all__ -__all__ = [*DATA_MANIPULATIONS.keys(), "DATA_MANIPULATIONS"] - -# For backwards compatibility, also create individual imports locals().update(DATA_MANIPULATIONS) diff --git a/topobenchmark/transforms/feature_liftings/__init__.py b/topobenchmark/transforms/feature_liftings/__init__.py index ec4f763c..6e047683 100644 --- a/topobenchmark/transforms/feature_liftings/__init__.py +++ b/topobenchmark/transforms/feature_liftings/__init__.py @@ -1,104 +1,12 @@ """Feature lifting transforms with automated exports.""" -import inspect -from importlib import util -from pathlib import Path -from typing import Any +from topobenchmark.transforms._utils import discover_objs -from .identity import Identity # Import Identity for special case +from .base import FeatureLiftingMap - -class ModuleExportsManager: - """Manages automatic discovery and registration of feature lifting classes.""" - - @staticmethod - def is_lifting_class(obj: Any) -> bool: - """Check if an object is a valid lifting class. - - Parameters - ---------- - obj : Any - The object to check if it's a valid lifting class. - - Returns - ------- - bool - True if the object is a valid lifting class (non-private class - defined in __main__), False otherwise. - """ - return ( - inspect.isclass(obj) - and obj.__module__ == "__main__" - and not obj.__name__.startswith("_") - ) - - @classmethod - def discover_liftings( - cls, package_path: str, special_cases: dict[Any, type] | None = None - ) -> dict[str, type]: - """Dynamically discover all lifting classes in the package. - - Parameters - ---------- - package_path : str - Path to the package's __init__.py file. - special_cases : Optional[dict[Any, type]] - Dictionary of special case mappings (e.g., {None: Identity}), - by default None. - - Returns - ------- - dict[str, type] - Dictionary mapping class names to their corresponding class objects, - including any special cases if provided. - """ - liftings = {} - - # Get the directory containing the lifting modules - package_dir = Path(package_path).parent - - # Iterate through all .py files in the directory - for file_path in package_dir.glob("*.py"): - if file_path.stem == "__init__": - continue - - # Import the module - module_name = f"{Path(package_path).stem}.{file_path.stem}" - spec = util.spec_from_file_location(module_name, file_path) - if spec and spec.loader: - module = util.module_from_spec(spec) - spec.loader.exec_module(module) - - # Find all lifting classes in the module - for name, obj in inspect.getmembers(module): - if ( - inspect.isclass(obj) - and obj.__module__ == module.__name__ - and not name.startswith("_") - ): - liftings[name] = obj # noqa: PERF403 - - # Add special cases if provided - if special_cases: - liftings.update(special_cases) - - return liftings - - -# Create the exports manager -manager = ModuleExportsManager() - -# Automatically discover and populate FEATURE_LIFTINGS with special case for None -FEATURE_LIFTINGS = manager.discover_liftings( - __file__, special_cases={None: Identity} +FEATURE_LIFTINGS = discover_objs( + __file__, + condition=lambda name, obj: issubclass(obj, FeatureLiftingMap), ) -# Automatically generate __all__ (excluding None key) -__all__ = [name for name in FEATURE_LIFTINGS if isinstance(name, str)] + [ - "FEATURE_LIFTINGS" -] - -# For backwards compatibility, create individual imports (excluding None key) -locals().update( - {k: v for k, v in FEATURE_LIFTINGS.items() if isinstance(k, str)} -) +locals().update(FEATURE_LIFTINGS) diff --git a/topobenchmark/transforms/feature_liftings/identity.py b/topobenchmark/transforms/feature_liftings/identity.py index 9abf4e5d..e640bd06 100644 --- a/topobenchmark/transforms/feature_liftings/identity.py +++ b/topobenchmark/transforms/feature_liftings/identity.py @@ -1,6 +1,6 @@ """Identity transform that does nothing to the input data.""" -from .base import FeatureLiftingMap +from topobenchmark.transforms.feature_liftings.base import FeatureLiftingMap class Identity(FeatureLiftingMap): diff --git a/topobenchmark/transforms/feature_liftings/projection_sum.py b/topobenchmark/transforms/feature_liftings/projection_sum.py index a02a1db5..a756fd0e 100644 --- a/topobenchmark/transforms/feature_liftings/projection_sum.py +++ b/topobenchmark/transforms/feature_liftings/projection_sum.py @@ -2,7 +2,7 @@ import torch -from .base import FeatureLiftingMap +from topobenchmark.transforms.feature_liftings.base import FeatureLiftingMap class ProjectionSum(FeatureLiftingMap): @@ -13,12 +13,12 @@ def lift_features(self, domain): Parameters ---------- - data : PlainComplex + data : Complex The input data to be lifted. Returns ------- - PlainComplex + Complex Domain with the lifted features. """ for rank in range(domain.max_rank - 1): diff --git a/topobenchmark/transforms/liftings/__init__.py b/topobenchmark/transforms/liftings/__init__.py index 0776fee4..513f5035 100755 --- a/topobenchmark/transforms/liftings/__init__.py +++ b/topobenchmark/transforms/liftings/__init__.py @@ -1 +1,15 @@ """This module implements the liftings for the topological transforms.""" + +from .base import LiftingTransform # noqa: F401 +from .graph2cell import GRAPH2CELL_LIFTINGS +from .graph2hypergraph import GRAPH2HYPERGRAPH_LIFTINGS +from .graph2simplicial import GRAPH2SIMPLICIAL_LIFTINGS + +LIFTINGS = { + **GRAPH2CELL_LIFTINGS, + **GRAPH2HYPERGRAPH_LIFTINGS, + **GRAPH2SIMPLICIAL_LIFTINGS, +} + + +locals().update(LIFTINGS) diff --git a/topobenchmark/transforms/liftings/graph2cell/__init__.py b/topobenchmark/transforms/liftings/graph2cell/__init__.py index d0faae96..480ada64 100755 --- a/topobenchmark/transforms/liftings/graph2cell/__init__.py +++ b/topobenchmark/transforms/liftings/graph2cell/__init__.py @@ -1,96 +1,11 @@ """Graph2Cell liftings with automated exports.""" -import inspect -from importlib import util -from pathlib import Path -from typing import Any +from topobenchmark.transforms._utils import discover_objs +from topobenchmark.transforms.liftings.base import LiftingMap -from .base import Graph2CellLifting +GRAPH2CELL_LIFTINGS = discover_objs( + __file__, + condition=lambda name, obj: issubclass(obj, LiftingMap), +) - -class ModuleExportsManager: - """Manages automatic discovery and registration of Graph2Cell lifting classes.""" - - @staticmethod - def is_lifting_class(obj: Any) -> bool: - """Check if an object is a valid Graph2Cell lifting class. - - Parameters - ---------- - obj : Any - The object to check if it's a valid lifting class. - - Returns - ------- - bool - True if the object is a valid Graph2Cell lifting class (non-private class - inheriting from Graph2CellLifting), False otherwise. - """ - return ( - inspect.isclass(obj) - and obj.__module__ == "__main__" - and not obj.__name__.startswith("_") - and issubclass(obj, Graph2CellLifting) - and obj != Graph2CellLifting - ) - - @classmethod - def discover_liftings(cls, package_path: str) -> dict[str, type]: - """Dynamically discover all Graph2Cell lifting classes in the package. - - Parameters - ---------- - package_path : str - Path to the package's __init__.py file. - - Returns - ------- - dict[str, type] - Dictionary mapping class names to their corresponding class objects. - """ - liftings = {} - - # Get the directory containing the lifting modules - package_dir = Path(package_path).parent - - # Iterate through all .py files in the directory - for file_path in package_dir.glob("*.py"): - if file_path.stem == "__init__": - continue - - # Import the module - module_name = f"{Path(package_path).stem}.{file_path.stem}" - spec = util.spec_from_file_location(module_name, file_path) - if spec and spec.loader: - module = util.module_from_spec(spec) - spec.loader.exec_module(module) - - # Find all lifting classes in the module - for name, obj in inspect.getmembers(module): - if ( - inspect.isclass(obj) - and obj.__module__ == module.__name__ - and not name.startswith("_") - and issubclass(obj, Graph2CellLifting) - and obj != Graph2CellLifting - ): - liftings[name] = obj # noqa: PERF403 - - return liftings - - -# Create the exports manager -manager = ModuleExportsManager() - -# Automatically discover and populate GRAPH2CELL_LIFTINGS -GRAPH2CELL_LIFTINGS = manager.discover_liftings(__file__) - -# Automatically generate __all__ -__all__ = [ - *GRAPH2CELL_LIFTINGS.keys(), - "Graph2CellLifting", - "GRAPH2CELL_LIFTINGS", -] - -# For backwards compatibility, create individual imports -locals().update(**GRAPH2CELL_LIFTINGS) +locals().update(GRAPH2CELL_LIFTINGS) diff --git a/topobenchmark/transforms/liftings/graph2hypergraph/__init__.py b/topobenchmark/transforms/liftings/graph2hypergraph/__init__.py index acb89e0c..e7a5a815 100755 --- a/topobenchmark/transforms/liftings/graph2hypergraph/__init__.py +++ b/topobenchmark/transforms/liftings/graph2hypergraph/__init__.py @@ -1,96 +1,11 @@ """Graph2HypergraphLifting module with automated exports.""" -import inspect -from importlib import util -from pathlib import Path -from typing import Any +from topobenchmark.transforms._utils import discover_objs +from topobenchmark.transforms.liftings.base import LiftingMap -from .base import Graph2HypergraphLifting +GRAPH2HYPERGRAPH_LIFTINGS = discover_objs( + __file__, + condition=lambda name, obj: issubclass(obj, LiftingMap), +) - -class ModuleExportsManager: - """Manages automatic discovery and registration of Graph2Hypergraph lifting classes.""" - - @staticmethod - def is_lifting_class(obj: Any) -> bool: - """Check if an object is a valid Graph2Hypergraph lifting class. - - Parameters - ---------- - obj : Any - The object to check if it's a valid lifting class. - - Returns - ------- - bool - True if the object is a valid Graph2Hypergraph lifting class (non-private class - inheriting from Graph2HypergraphLifting), False otherwise. - """ - return ( - inspect.isclass(obj) - and obj.__module__ == "__main__" - and not obj.__name__.startswith("_") - and issubclass(obj, Graph2HypergraphLifting) - and obj != Graph2HypergraphLifting - ) - - @classmethod - def discover_liftings(cls, package_path: str) -> dict[str, type]: - """Dynamically discover all Graph2Hypergraph lifting classes in the package. - - Parameters - ---------- - package_path : str - Path to the package's __init__.py file. - - Returns - ------- - dict[str, type] - Dictionary mapping class names to their corresponding class objects. - """ - liftings = {} - - # Get the directory containing the lifting modules - package_dir = Path(package_path).parent - - # Iterate through all .py files in the directory - for file_path in package_dir.glob("*.py"): - if file_path.stem == "__init__": - continue - - # Import the module - module_name = f"{Path(package_path).stem}.{file_path.stem}" - spec = util.spec_from_file_location(module_name, file_path) - if spec and spec.loader: - module = util.module_from_spec(spec) - spec.loader.exec_module(module) - - # Find all lifting classes in the module - for name, obj in inspect.getmembers(module): - if ( - inspect.isclass(obj) - and obj.__module__ == module.__name__ - and not name.startswith("_") - and issubclass(obj, Graph2HypergraphLifting) - and obj != Graph2HypergraphLifting - ): - liftings[name] = obj # noqa: PERF403 - - return liftings - - -# Create the exports manager -manager = ModuleExportsManager() - -# Automatically discover and populate GRAPH2HYPERGRAPH_LIFTINGS -GRAPH2HYPERGRAPH_LIFTINGS = manager.discover_liftings(__file__) - -# Automatically generate __all__ -__all__ = [ - *GRAPH2HYPERGRAPH_LIFTINGS.keys(), - "Graph2HypergraphLifting", - "GRAPH2HYPERGRAPH_LIFTINGS", -] - -# For backwards compatibility, create individual imports -locals().update(**GRAPH2HYPERGRAPH_LIFTINGS) +locals().update(GRAPH2HYPERGRAPH_LIFTINGS) diff --git a/topobenchmark/transforms/liftings/graph2simplicial/__init__.py b/topobenchmark/transforms/liftings/graph2simplicial/__init__.py index 238691cd..9e77797b 100755 --- a/topobenchmark/transforms/liftings/graph2simplicial/__init__.py +++ b/topobenchmark/transforms/liftings/graph2simplicial/__init__.py @@ -1,96 +1,11 @@ """Graph2SimplicialLifting module with automated exports.""" -import inspect -from importlib import util -from pathlib import Path -from typing import Any +from topobenchmark.transforms._utils import discover_objs +from topobenchmark.transforms.liftings.base import LiftingMap -from .base import Graph2SimplicialLifting +GRAPH2SIMPLICIAL_LIFTINGS = discover_objs( + __file__, + condition=lambda name, obj: issubclass(obj, LiftingMap), +) - -class ModuleExportsManager: - """Manages automatic discovery and registration of Graph2Simplicial lifting classes.""" - - @staticmethod - def is_lifting_class(obj: Any) -> bool: - """Check if an object is a valid Graph2Simplicial lifting class. - - Parameters - ---------- - obj : Any - The object to check if it's a valid lifting class. - - Returns - ------- - bool - True if the object is a valid Graph2Simplicial lifting class (non-private class - inheriting from Graph2SimplicialLifting), False otherwise. - """ - return ( - inspect.isclass(obj) - and obj.__module__ == "__main__" - and not obj.__name__.startswith("_") - and issubclass(obj, Graph2SimplicialLifting) - and obj != Graph2SimplicialLifting - ) - - @classmethod - def discover_liftings(cls, package_path: str) -> dict[str, type]: - """Dynamically discover all Graph2Simplicial lifting classes in the package. - - Parameters - ---------- - package_path : str - Path to the package's __init__.py file. - - Returns - ------- - dict[str, type] - Dictionary mapping class names to their corresponding class objects. - """ - liftings = {} - - # Get the directory containing the lifting modules - package_dir = Path(package_path).parent - - # Iterate through all .py files in the directory - for file_path in package_dir.glob("*.py"): - if file_path.stem == "__init__": - continue - - # Import the module - module_name = f"{Path(package_path).stem}.{file_path.stem}" - spec = util.spec_from_file_location(module_name, file_path) - if spec and spec.loader: - module = util.module_from_spec(spec) - spec.loader.exec_module(module) - - # Find all lifting classes in the module - for name, obj in inspect.getmembers(module): - if ( - inspect.isclass(obj) - and obj.__module__ == module.__name__ - and not name.startswith("_") - and issubclass(obj, Graph2SimplicialLifting) - and obj != Graph2SimplicialLifting - ): - liftings[name] = obj # noqa: PERF403 - - return liftings - - -# Create the exports manager -manager = ModuleExportsManager() - -# Automatically discover and populate GRAPH2SIMPLICIAL_LIFTINGS -GRAPH2SIMPLICIAL_LIFTINGS = manager.discover_liftings(__file__) - -# Automatically generate __all__ -__all__ = [ - *GRAPH2SIMPLICIAL_LIFTINGS.keys(), - "Graph2SimplicialLifting", - "GRAPH2SIMPLICIAL_LIFTINGS", -] - -# For backwards compatibility, create individual imports -locals().update(**GRAPH2SIMPLICIAL_LIFTINGS) +locals().update(GRAPH2SIMPLICIAL_LIFTINGS) From f77ad640d9687bb253d75becae6ea35550ea6a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Wed, 15 Jan 2025 19:11:52 -0800 Subject: [PATCH 15/36] Fix handling of empty matrices due to inexisting dimension --- topobenchmark/data/utils/adapters.py | 37 +++++++++++-------- topobenchmark/data/utils/domain.py | 3 ++ .../liftings/graph2simplicial/clique.py | 3 ++ 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/topobenchmark/data/utils/adapters.py b/topobenchmark/data/utils/adapters.py index 9db40c08..342a2622 100644 --- a/topobenchmark/data/utils/adapters.py +++ b/topobenchmark/data/utils/adapters.py @@ -124,8 +124,9 @@ class TnxComplex2Complex(Adapter): Parameters ---------- complex_dim : int - Dimension of the desired subcomplex. + Dimension of the (sub)complex. If ``None``, adapts the (full) complex. + If greater than dimension of complex, pads with empty matrices. neighborhoods : list, optional List of neighborhoods of interest. signed : bool, optional @@ -136,13 +137,11 @@ class TnxComplex2Complex(Adapter): def __init__( self, - complex_dim=None, neighborhoods=None, signed=False, transfer_features=True, ): super().__init__() - self.complex_dim = complex_dim self.neighborhoods = neighborhoods self.signed = signed self.transfer_features = transfer_features @@ -160,7 +159,13 @@ def adapt(self, domain): """ # NB: just a slightly rewriting of get_complex_connectivity - dim = self.complex_dim or domain.dim + practical_dim = ( + domain.practical_dim + if hasattr(domain, "practical_dim") + else domain.dim + ) + dim = domain.dim + signed = self.signed neighborhoods = self.neighborhoods @@ -174,18 +179,20 @@ def adapt(self, domain): ] practical_shape = list( - np.pad(list(domain.shape), (0, dim + 1 - len(domain.shape))) + np.pad( + list(domain.shape), (0, practical_dim + 1 - len(domain.shape)) + ) ) data = { connectivity_info: [] for connectivity_info in connectivity_infos } - for rank_idx in range(dim + 1): + for rank in range(practical_dim + 1): for connectivity_info in connectivity_infos: try: data[connectivity_info].append( from_sparse( getattr(domain, f"{connectivity_info}_matrix")( - rank=rank_idx, signed=signed + rank=rank, signed=signed ) ) ) @@ -193,15 +200,15 @@ def adapt(self, domain): if connectivity_info == "incidence": data[connectivity_info].append( generate_zero_sparse_connectivity( - m=practical_shape[rank_idx - 1], - n=practical_shape[rank_idx], + m=practical_shape[rank - 1], + n=practical_shape[rank], ) ) else: data[connectivity_info].append( generate_zero_sparse_connectivity( - m=practical_shape[rank_idx], - n=practical_shape[rank_idx], + m=practical_shape[rank], + n=practical_shape[rank], ) ) @@ -228,6 +235,9 @@ def adapt(self, domain): rank_features = None data["features"].append(rank_features) + for _ in range(dim + 1, practical_dim + 1): + data["features"].append(None) + return Complex(**data) @@ -287,9 +297,6 @@ class TnxComplex2Dict(AdapterComposition): Parameters ---------- - complex_dim : int - Dimension of the desired subcomplex. - If ``None``, adapts the (full) complex. neighborhoods : list, optional List of neighborhoods of interest. signed : bool, optional @@ -300,13 +307,11 @@ class TnxComplex2Dict(AdapterComposition): def __init__( self, - complex_dim=None, neighborhoods=None, signed=False, transfer_features=True, ): tnxcomplex2complex = TnxComplex2Complex( - complex_dim=complex_dim, neighborhoods=neighborhoods, signed=signed, transfer_features=transfer_features, diff --git a/topobenchmark/data/utils/domain.py b/topobenchmark/data/utils/domain.py index 531592dc..8bf4f1d7 100644 --- a/topobenchmark/data/utils/domain.py +++ b/topobenchmark/data/utils/domain.py @@ -46,6 +46,9 @@ def shape(self): def max_rank(self): """Maximum rank of the complex. + NB: may differ from mathematical definition due to empty + matrices. + Returns ------- int diff --git a/topobenchmark/transforms/liftings/graph2simplicial/clique.py b/topobenchmark/transforms/liftings/graph2simplicial/clique.py index 37a5cc15..04baa1ef 100755 --- a/topobenchmark/transforms/liftings/graph2simplicial/clique.py +++ b/topobenchmark/transforms/liftings/graph2simplicial/clique.py @@ -50,4 +50,7 @@ def lift(self, domain): for set_k_simplices in simplices: simplicial_complex.add_simplices_from(list(set_k_simplices)) + # because Complex pads unexisting dimensions with empty matrices + simplicial_complex.practical_dim = self.complex_dim + return simplicial_complex From 0668f97821c607f4ccff4acdcec9f1c4c0e928ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Wed, 15 Jan 2025 19:12:30 -0800 Subject: [PATCH 16/36] Update feature liftings due to new design --- .../feature_liftings/test_Concatenation.py | 24 ++-- .../feature_liftings/test_ProjectionSum.py | 59 +++++----- .../feature_liftings/test_SetLifting.py | 18 ++- .../feature_liftings/concatenation.py | 86 +++++---------- .../transforms/feature_liftings/set.py | 103 +++++++----------- 5 files changed, 125 insertions(+), 165 deletions(-) diff --git a/test/transforms/feature_liftings/test_Concatenation.py b/test/transforms/feature_liftings/test_Concatenation.py index a8f83d78..aff3a2c1 100644 --- a/test/transforms/feature_liftings/test_Concatenation.py +++ b/test/transforms/feature_liftings/test_Concatenation.py @@ -2,24 +2,34 @@ import torch -from topobenchmark.transforms.liftings.graph2simplicial import ( +from topobenchmark.data.utils import ( + Complex2Dict, + Data2NxGraph, + TnxComplex2Complex, +) +from topobenchmark.transforms.liftings import ( + LiftingTransform, SimplicialCliqueLifting, ) -class TestConcatention: +class TestConcatenation: """Test the Concatention feature lifting class.""" def setup_method(self): """Set up the test.""" # Initialize a lifting class - self.lifting = SimplicialCliqueLifting( - feature_lifting="Concatenation", complex_dim=3 + self.lifting = LiftingTransform( + SimplicialCliqueLifting(complex_dim=3), + feature_lifting="Concatenation", + data2domain=Data2NxGraph(), + domain2domain=TnxComplex2Complex(signed=False), + domain2dict=Complex2Dict(), ) def test_lift_features(self, simple_graph_0, simple_graph_1): """Test the lift_features method. - + Parameters ---------- simple_graph_0 : torch_geometric.data.Data @@ -27,12 +37,12 @@ def test_lift_features(self, simple_graph_0, simple_graph_1): simple_graph_1 : torch_geometric.data.Data A simple graph data object. """ - + data = simple_graph_0 # Test the lift_features method lifted_data = self.lifting.forward(data.clone()) assert lifted_data.x_2.shape == torch.Size([0, 6]) - + data = simple_graph_1 # Test the lift_features method lifted_data = self.lifting.forward(data.clone()) diff --git a/test/transforms/feature_liftings/test_ProjectionSum.py b/test/transforms/feature_liftings/test_ProjectionSum.py index 935a5148..b14ea5e8 100644 --- a/test/transforms/feature_liftings/test_ProjectionSum.py +++ b/test/transforms/feature_liftings/test_ProjectionSum.py @@ -2,7 +2,13 @@ import torch -from topobenchmark.transforms.liftings.graph2simplicial import ( +from topobenchmark.data.utils import ( + Complex2Dict, + Data2NxGraph, + TnxComplex2Complex, +) +from topobenchmark.transforms.liftings import ( + LiftingTransform, SimplicialCliqueLifting, ) @@ -13,13 +19,17 @@ class TestProjectionSum: def setup_method(self): """Set up the test.""" # Initialize a lifting class - self.lifting = SimplicialCliqueLifting( - feature_lifting="ProjectionSum", complex_dim=3 + self.lifting = LiftingTransform( + lifting=SimplicialCliqueLifting(complex_dim=3), + feature_lifting="ProjectionSum", + data2domain=Data2NxGraph(), + domain2domain=TnxComplex2Complex(), + domain2dict=Complex2Dict(), ) def test_lift_features(self, simple_graph_1): """Test the lift_features method. - + Parameters ---------- simple_graph_1 : torch_geometric.data.Data @@ -31,38 +41,27 @@ def test_lift_features(self, simple_graph_1): expected_x1 = torch.tensor( [ - [ 6.], - [ 11.], - [ 101.], - [5001.], - [ 15.], - [ 105.], - [ 60.], - [ 110.], - [ 510.], - [5010.], - [1050.], - [1500.], - [5500.] + [6.0], + [11.0], + [101.0], + [5001.0], + [15.0], + [105.0], + [60.0], + [110.0], + [510.0], + [5010.0], + [1050.0], + [1500.0], + [5500.0], ] ) expected_x2 = torch.tensor( - [ - [ 32.], - [ 212.], - [ 222.], - [10022.], - [ 230.], - [11020.] - ] + [[32.0], [212.0], [222.0], [10022.0], [230.0], [11020.0]] ) - expected_x3 = torch.tensor( - [ - [696.] - ] - ) + expected_x3 = torch.tensor([[696.0]]) assert ( expected_x1 == lifted_data.x_1 diff --git a/test/transforms/feature_liftings/test_SetLifting.py b/test/transforms/feature_liftings/test_SetLifting.py index 9b71816f..bf0c621f 100644 --- a/test/transforms/feature_liftings/test_SetLifting.py +++ b/test/transforms/feature_liftings/test_SetLifting.py @@ -2,7 +2,13 @@ import torch -from topobenchmark.transforms.liftings.graph2simplicial import ( +from topobenchmark.data.utils import ( + Complex2Dict, + Data2NxGraph, + TnxComplex2Complex, +) +from topobenchmark.transforms.liftings import ( + LiftingTransform, SimplicialCliqueLifting, ) @@ -13,13 +19,17 @@ class TestSetLifting: def setup_method(self): """Set up the test.""" # Initialize a lifting class - self.lifting = SimplicialCliqueLifting( - feature_lifting="Set", complex_dim=3 + self.lifting = LiftingTransform( + SimplicialCliqueLifting(complex_dim=3), + feature_lifting="Set", + data2domain=Data2NxGraph(), + domain2domain=TnxComplex2Complex(signed=False), + domain2dict=Complex2Dict(), ) def test_lift_features(self, simple_graph_1): """Test the lift_features method. - + Parameters ---------- simple_graph_1 : torch_geometric.data.Data diff --git a/topobenchmark/transforms/feature_liftings/concatenation.py b/topobenchmark/transforms/feature_liftings/concatenation.py index 5a69f46d..b26509d9 100644 --- a/topobenchmark/transforms/feature_liftings/concatenation.py +++ b/topobenchmark/transforms/feature_liftings/concatenation.py @@ -1,83 +1,53 @@ """Concatenation feature lifting.""" import torch -import torch_geometric +from topobenchmark.transforms.feature_liftings.base import FeatureLiftingMap -class Concatenation(torch_geometric.transforms.BaseTransform): - r"""Lift r-cell features to r+1-cells by concatenation. - Parameters - ---------- - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, **kwargs): - super().__init__() +class Concatenation(FeatureLiftingMap): + """Lift r-cell features to r+1-cells by concatenation.""" def __repr__(self) -> str: return f"{self.__class__.__name__}()" - def lift_features( - self, data: torch_geometric.data.Data | dict - ) -> torch_geometric.data.Data | dict: + def lift_features(self, domain): r"""Concatenate r-cell features to obtain r+1-cell features. Parameters ---------- - data : torch_geometric.data.Data | dict + data : Complex The input data to be lifted. Returns ------- - torch_geometric.data.Data | dict - The lifted data. + Complex + Domain with the lifted features. """ - keys = sorted( - [ - key.split("_")[1] - for key in data - if "incidence" in key and "-" not in key - ] - ) - for elem in keys: - if f"x_{elem}" not in data: - idx_to_project = 0 if elem == "hyperedges" else int(elem) - 1 - incidence = data["incidence_" + elem] - _, n = incidence.shape + for rank in range(domain.max_rank - 1): + if domain.features[rank + 1] is not None: + continue - if n != 0: - idxs_list = [] - for n_feature in range(n): - idxs_for_feature = incidence.indices()[ - 0, incidence.indices()[1, :] == n_feature - ] - idxs_list.append(torch.sort(idxs_for_feature)[0]) + # TODO: different if hyperedges? + idx_to_project = rank - idxs = torch.stack(idxs_list, dim=0) - values = data[f"x_{idx_to_project}"][idxs].view(n, -1) - else: - m = data[f"x_{int(elem)-1}"].shape[1] * (int(elem) + 1) - values = torch.zeros([0, m]) + incidence = domain.incidence[rank + 1] + _, n = incidence.shape - data["x_" + elem] = values - return data + if n != 0: + idxs_list = [] + for n_feature in range(n): + idxs_for_feature = incidence.indices()[ + 0, incidence.indices()[1, :] == n_feature + ] + idxs_list.append(torch.sort(idxs_for_feature)[0]) - def forward( - self, data: torch_geometric.data.Data | dict - ) -> torch_geometric.data.Data | dict: - r"""Apply the lifting to the input data. + idxs = torch.stack(idxs_list, dim=0) + values = domain.features[idx_to_project][idxs].view(n, -1) + else: + m = domain.features[rank].shape[1] * (rank + 2) + values = torch.zeros([0, m]) - Parameters - ---------- - data : torch_geometric.data.Data | dict - The input data to be lifted. + domain.update_features(rank + 1, values) - Returns - ------- - torch_geometric.data.Data | dict - The lifted data. - """ - data = self.lift_features(data) - return data + return domain diff --git a/topobenchmark/transforms/feature_liftings/set.py b/topobenchmark/transforms/feature_liftings/set.py index 28ccd0cc..1886e25b 100644 --- a/topobenchmark/transforms/feature_liftings/set.py +++ b/topobenchmark/transforms/feature_liftings/set.py @@ -1,89 +1,60 @@ """Set lifting for r-cell features to r+1-cell features.""" import torch -import torch_geometric +from topobenchmark.transforms.feature_liftings.base import FeatureLiftingMap -class Set(torch_geometric.transforms.BaseTransform): - r"""Lift r-cell features to r+1-cells by set operations. - Parameters - ---------- - **kwargs : optional - Additional arguments for the class. - """ - - def __init__(self, **kwargs): - super().__init__() +class Set(FeatureLiftingMap): + """Lift r-cell features to r+1-cells by set operations.""" def __repr__(self) -> str: return f"{self.__class__.__name__}()" - def lift_features( - self, data: torch_geometric.data.Data | dict - ) -> torch_geometric.data.Data | dict: + def lift_features(self, domain): r"""Concatenate r-cell features to r+1-cell structures. Parameters ---------- - data : torch_geometric.data.Data | dict + data : Complex The input data to be lifted. Returns ------- - torch_geometric.data.Data | dict - The lifted data. + Complex + Domain with the lifted features. """ - keys = sorted( - [key.split("_")[1] for key in data if "incidence" in key] - ) - for elem in keys: - if f"x_{elem}" not in data: - # idx_to_project = 0 if elem == "hyperedges" else int(elem) - 1 - incidence = data["incidence_" + elem] - _, n = incidence.shape - - if n != 0: - idxs_list = [] - for n_feature in range(n): - idxs_for_feature = incidence.indices()[ - 0, incidence.indices()[1, :] == n_feature - ] - idxs_list.append(torch.sort(idxs_for_feature)[0]) - - idxs = torch.stack(idxs_list, dim=0) - if elem == "1": - values = idxs - else: - values = torch.sort( - torch.unique( - data["x_" + str(int(elem) - 1)][idxs].view( - idxs.shape[0], -1 - ), - dim=1, + for rank in range(domain.max_rank - 1): + if domain.features[rank + 1] is not None: + continue + + incidence = domain.incidence[rank + 1] + _, n = incidence.shape + + if n != 0: + idxs_list = [] + for n_feature in range(n): + idxs_for_feature = incidence.indices()[ + 0, incidence.indices()[1, :] == n_feature + ] + idxs_list.append(torch.sort(idxs_for_feature)[0]) + + idxs = torch.stack(idxs_list, dim=0) + if rank == 0: + values = idxs + else: + values = torch.sort( + torch.unique( + domain.features[rank][idxs].view( + idxs.shape[0], -1 ), dim=1, - )[0] - else: - values = torch.tensor([]) - - data["x_" + elem] = values - return data + ), + dim=1, + )[0] + else: + values = torch.tensor([]) - def forward( - self, data: torch_geometric.data.Data | dict - ) -> torch_geometric.data.Data | dict: - r"""Apply the lifting to the input data. + domain.update_features(rank + 1, values) - Parameters - ---------- - data : torch_geometric.data.Data | dict - The input data to be lifted. - - Returns - ------- - torch_geometric.data.Data | dict - The lifted data. - """ - data = self.lift_features(data) - return data + return domain From 190e2b89b7de4e47504211b5a6867a09025571e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Wed, 15 Jan 2025 19:13:00 -0800 Subject: [PATCH 17/36] Add str-based instantiation to LiftingMap for backwards compatibility --- topobenchmark/transforms/liftings/base.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/topobenchmark/transforms/liftings/base.py b/topobenchmark/transforms/liftings/base.py index ce3d1ab4..d9fb0593 100644 --- a/topobenchmark/transforms/liftings/base.py +++ b/topobenchmark/transforms/liftings/base.py @@ -48,6 +48,16 @@ def __init__( if domain2domain is None: domain2domain = IdentityAdapter() + if isinstance(lifting, str): + from topobenchmark.transforms import TRANSFORMS + + lifting = TRANSFORMS[lifting]() + + if isinstance(feature_lifting, str): + from topobenchmark.transforms import TRANSFORMS + + feature_lifting = TRANSFORMS[feature_lifting]() + self.data2domain = data2domain self.domain2domain = domain2domain self.domain2dict = domain2dict From f774c97b192ee6e60ebe11938069b33789a57545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Wed, 15 Jan 2025 19:13:46 -0800 Subject: [PATCH 18/36] Fix Data2NxGraph adapter --- topobenchmark/data/utils/adapters.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/topobenchmark/data/utils/adapters.py b/topobenchmark/data/utils/adapters.py index 342a2622..1dc4328f 100644 --- a/topobenchmark/data/utils/adapters.py +++ b/topobenchmark/data/utils/adapters.py @@ -85,14 +85,14 @@ def adapt(self, domain: torch_geometric.data.Data) -> nx.Graph: if self.preserve_edge_attr and self._data_has_edge_attr(domain): # In case edge features are given, assign features to every edge - edge_index, edge_attr = ( - domain.edge_index, - ( - domain.edge_attr - if is_undirected(domain.edge_index, domain.edge_attr) - else to_undirected(domain.edge_index, domain.edge_attr) - ), - ) + # TODO: confirm this is the desired behavior + if is_undirected(domain.edge_index, domain.edge_attr): + edge_index, edge_attr = (domain.edge_index, domain.edge_attr) + else: + edge_index, edge_attr = to_undirected( + domain.edge_index, domain.edge_attr + ) + edges = [ (i.item(), j.item(), dict(features=edge_attr[edge_idx], dim=1)) for edge_idx, (i, j) in enumerate( From bd243871552d45f0449800bd55891e563017fe10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Wed, 15 Jan 2025 19:14:19 -0800 Subject: [PATCH 19/36] Fix failing data manipulation test (only setup) --- .../test_SimplicialCurvature.py | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/test/transforms/data_manipulations/test_SimplicialCurvature.py b/test/transforms/data_manipulations/test_SimplicialCurvature.py index e4cb517b..e8199beb 100644 --- a/test/transforms/data_manipulations/test_SimplicialCurvature.py +++ b/test/transforms/data_manipulations/test_SimplicialCurvature.py @@ -2,8 +2,19 @@ import torch from torch_geometric.data import Data -from topobenchmark.transforms.data_manipulations import CalculateSimplicialCurvature -from topobenchmark.transforms.liftings.graph2simplicial import SimplicialCliqueLifting + +from topobenchmark.data.utils import ( + Complex2Dict, + Data2NxGraph, + TnxComplex2Complex, +) +from topobenchmark.transforms.data_manipulations import ( + CalculateSimplicialCurvature, +) +from topobenchmark.transforms.liftings import ( + LiftingTransform, + SimplicialCliqueLifting, +) class TestSimplicialCurvature: @@ -11,29 +22,31 @@ class TestSimplicialCurvature: def test_simplicial_curvature(self, simple_graph_1): """Test simplicial curvature calculation. - + Parameters ---------- simple_graph_1 : torch_geometric.data.Data A simple graph fixture. """ simplicial_curvature = CalculateSimplicialCurvature() - lifting_unsigned = SimplicialCliqueLifting( - complex_dim=3, signed=False + + lifting_unsigned = LiftingTransform( + lifting=SimplicialCliqueLifting(complex_dim=3), + data2domain=Data2NxGraph(), + domain2domain=TnxComplex2Complex(signed=False), + domain2dict=Complex2Dict(), ) + data = lifting_unsigned(simple_graph_1) - data['0_cell_degrees'] = torch.unsqueeze( - torch.sum(data['incidence_1'], dim=1).to_dense(), - dim=1 + data["0_cell_degrees"] = torch.unsqueeze( + torch.sum(data["incidence_1"], dim=1).to_dense(), dim=1 ) - data['1_cell_degrees'] = torch.unsqueeze( - torch.sum(data['incidence_2'], dim=1).to_dense(), - dim=1 + data["1_cell_degrees"] = torch.unsqueeze( + torch.sum(data["incidence_2"], dim=1).to_dense(), dim=1 ) - data['2_cell_degrees'] = torch.unsqueeze( - torch.sum(data['incidence_3'], dim=1).to_dense(), - dim=1 + data["2_cell_degrees"] = torch.unsqueeze( + torch.sum(data["incidence_3"], dim=1).to_dense(), dim=1 ) - + res = simplicial_curvature(data) - assert isinstance(res, Data) \ No newline at end of file + assert isinstance(res, Data) From a7a87553df3b7c1e82b2f94c12c582cc440d16e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 16 Jan 2025 11:21:59 -0800 Subject: [PATCH 20/36] Fix TnxComplex2Complex adapter to handle CellComplex features --- topobenchmark/data/utils/adapters.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/topobenchmark/data/utils/adapters.py b/topobenchmark/data/utils/adapters.py index 1dc4328f..80295e69 100644 --- a/topobenchmark/data/utils/adapters.py +++ b/topobenchmark/data/utils/adapters.py @@ -5,6 +5,7 @@ import torch import torch_geometric from topomodelx.utils.sparse import from_sparse +from toponetx.classes import CellComplex, SimplicialComplex from torch_geometric.utils.undirected import is_undirected, to_undirected from topobenchmark.data.utils.domain import Complex @@ -123,10 +124,6 @@ class TnxComplex2Complex(Adapter): Parameters ---------- - complex_dim : int - Dimension of the (sub)complex. - If ``None``, adapts the (full) complex. - If greater than dimension of complex, pads with empty matrices. neighborhoods : list, optional List of neighborhoods of interest. signed : bool, optional @@ -216,17 +213,18 @@ def adapt(self, domain): if neighborhoods is not None: data = select_neighborhoods_of_interest(data, neighborhoods) - # TODO: simplex specific? - # TODO: how to do this for other? - if self.transfer_features and hasattr( - domain, "get_simplex_attributes" - ): + if self.transfer_features: + if isinstance(domain, SimplicialComplex): + get_features = domain.get_simplex_attributes + elif isinstance(domain, CellComplex): + get_features = domain.get_cell_attributes + else: + raise ValueError("Can't transfer features.") + # TODO: confirm features are in the right order; update this data["features"] = [] for rank in range(dim + 1): - rank_features_dict = domain.get_simplex_attributes( - "features", rank - ) + rank_features_dict = get_features("features", rank) if rank_features_dict: rank_features = torch.stack( list(rank_features_dict.values()) From 2ccbea30d59686f69199eb911eb373811f28c5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 16 Jan 2025 11:23:10 -0800 Subject: [PATCH 21/36] Add syntax sugar to instantiate graph2complex/simplicial lifting transforms --- topobenchmark/transforms/liftings/__init__.py | 6 +- topobenchmark/transforms/liftings/base.py | 55 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/topobenchmark/transforms/liftings/__init__.py b/topobenchmark/transforms/liftings/__init__.py index 513f5035..2c759ac3 100755 --- a/topobenchmark/transforms/liftings/__init__.py +++ b/topobenchmark/transforms/liftings/__init__.py @@ -1,6 +1,10 @@ """This module implements the liftings for the topological transforms.""" -from .base import LiftingTransform # noqa: F401 +from .base import ( # noqa: F401 + Graph2ComplexLiftingTransform, + Graph2SimplicialLiftingTransform, + LiftingTransform, +) from .graph2cell import GRAPH2CELL_LIFTINGS from .graph2hypergraph import GRAPH2HYPERGRAPH_LIFTINGS from .graph2simplicial import GRAPH2SIMPLICIAL_LIFTINGS diff --git a/topobenchmark/transforms/liftings/base.py b/topobenchmark/transforms/liftings/base.py index d9fb0593..3637f564 100644 --- a/topobenchmark/transforms/liftings/base.py +++ b/topobenchmark/transforms/liftings/base.py @@ -4,7 +4,12 @@ import torch_geometric -from topobenchmark.data.utils import IdentityAdapter +from topobenchmark.data.utils import ( + Complex2Dict, + Data2NxGraph, + IdentityAdapter, + TnxComplex2Complex, +) from topobenchmark.transforms.feature_liftings.identity import Identity @@ -13,14 +18,14 @@ class LiftingTransform(torch_geometric.transforms.BaseTransform): Parameters ---------- + lifting : LiftingMap + Lifting map. data2domain : Converter Conversion between ``torch_geometric.Data`` into domain for consumption by lifting. domain2dict : Converter Conversion between output domain of feature lifting and ``torch_geometric.Data``. - lifting : LiftingMap - Lifting map. domain2domain : Converter Conversion between output domain of lifting and input domain for feature lifting. @@ -92,6 +97,50 @@ def forward( ) +class Graph2ComplexLiftingTransform(LiftingTransform): + """Graph to complex lifting transform. + + Parameters + ---------- + lifting : LiftingMap + Lifting map. + feature_lifting : FeatureLiftingMap + Feature lifting map. + preserve_edge_attr : bool + Whether to preserve edge attributes. + neighborhoods : list, optional + List of neighborhoods of interest. + signed : bool, optional + If True, returns signed connectivity matrices. + transfer_features : bool, optional + Whether to transfer features. + """ + + def __init__( + self, + lifting, + feature_lifting="ProjectionSum", + preserve_edge_attr=False, + neighborhoods=None, + signed=False, + transfer_features=True, + ): + super().__init__( + lifting, + feature_lifting=feature_lifting, + data2domain=Data2NxGraph(preserve_edge_attr), + domain2domain=TnxComplex2Complex( + neighborhoods=neighborhoods, + signed=signed, + transfer_features=transfer_features, + ), + domain2dict=Complex2Dict(), + ) + + +Graph2SimplicialLiftingTransform = Graph2ComplexLiftingTransform + + class LiftingMap(abc.ABC): """Lifting map. From 7682feffad4b73fcc370266ce59cd7c6abc87d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 16 Jan 2025 11:24:25 -0800 Subject: [PATCH 22/36] Make imports shorter and use newly added syntax sugar --- test/conftest.py | 4 ++-- .../test_SimplicialCurvature.py | 14 +++-------- .../feature_liftings/test_Concatenation.py | 15 ++++-------- .../feature_liftings/test_ProjectionSum.py | 12 ++-------- .../feature_liftings/test_SetLifting.py | 14 +++-------- .../liftings/cell/test_CellCyclesLifting.py | 13 ++++------- .../hypergraph/test_HypergraphKHopLifting.py | 4 ++-- ...test_HypergraphKNearestNeighborsLifting.py | 4 +--- .../test_SimplicialCliqueLifting.py | 23 +++++-------------- .../test_SimplicialNeighborhoodLifting.py | 23 +++++-------------- topobenchmark/data/utils/__init__.py | 2 +- 11 files changed, 35 insertions(+), 93 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 753d63b2..9a70c6a1 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -5,8 +5,8 @@ import torch import torch_geometric -from topobenchmark.transforms.liftings.graph2cell.cycle import CellCycleLifting -from topobenchmark.transforms.liftings.graph2simplicial.clique import ( +from topobenchmark.transforms.liftings import ( + CellCycleLifting, SimplicialCliqueLifting, ) diff --git a/test/transforms/data_manipulations/test_SimplicialCurvature.py b/test/transforms/data_manipulations/test_SimplicialCurvature.py index e8199beb..e90d4e68 100644 --- a/test/transforms/data_manipulations/test_SimplicialCurvature.py +++ b/test/transforms/data_manipulations/test_SimplicialCurvature.py @@ -3,16 +3,11 @@ import torch from torch_geometric.data import Data -from topobenchmark.data.utils import ( - Complex2Dict, - Data2NxGraph, - TnxComplex2Complex, -) from topobenchmark.transforms.data_manipulations import ( CalculateSimplicialCurvature, ) from topobenchmark.transforms.liftings import ( - LiftingTransform, + Graph2SimplicialLiftingTransform, SimplicialCliqueLifting, ) @@ -30,11 +25,8 @@ def test_simplicial_curvature(self, simple_graph_1): """ simplicial_curvature = CalculateSimplicialCurvature() - lifting_unsigned = LiftingTransform( - lifting=SimplicialCliqueLifting(complex_dim=3), - data2domain=Data2NxGraph(), - domain2domain=TnxComplex2Complex(signed=False), - domain2dict=Complex2Dict(), + lifting_unsigned = Graph2SimplicialLiftingTransform( + lifting=SimplicialCliqueLifting(complex_dim=3) ) data = lifting_unsigned(simple_graph_1) diff --git a/test/transforms/feature_liftings/test_Concatenation.py b/test/transforms/feature_liftings/test_Concatenation.py index aff3a2c1..9474e8da 100644 --- a/test/transforms/feature_liftings/test_Concatenation.py +++ b/test/transforms/feature_liftings/test_Concatenation.py @@ -2,13 +2,8 @@ import torch -from topobenchmark.data.utils import ( - Complex2Dict, - Data2NxGraph, - TnxComplex2Complex, -) from topobenchmark.transforms.liftings import ( - LiftingTransform, + Graph2SimplicialLiftingTransform, SimplicialCliqueLifting, ) @@ -19,12 +14,10 @@ class TestConcatenation: def setup_method(self): """Set up the test.""" # Initialize a lifting class - self.lifting = LiftingTransform( - SimplicialCliqueLifting(complex_dim=3), + + self.lifting = Graph2SimplicialLiftingTransform( + lifting=SimplicialCliqueLifting(complex_dim=3), feature_lifting="Concatenation", - data2domain=Data2NxGraph(), - domain2domain=TnxComplex2Complex(signed=False), - domain2dict=Complex2Dict(), ) def test_lift_features(self, simple_graph_0, simple_graph_1): diff --git a/test/transforms/feature_liftings/test_ProjectionSum.py b/test/transforms/feature_liftings/test_ProjectionSum.py index b14ea5e8..a6ad8cdf 100644 --- a/test/transforms/feature_liftings/test_ProjectionSum.py +++ b/test/transforms/feature_liftings/test_ProjectionSum.py @@ -2,13 +2,8 @@ import torch -from topobenchmark.data.utils import ( - Complex2Dict, - Data2NxGraph, - TnxComplex2Complex, -) from topobenchmark.transforms.liftings import ( - LiftingTransform, + Graph2SimplicialLiftingTransform, SimplicialCliqueLifting, ) @@ -19,12 +14,9 @@ class TestProjectionSum: def setup_method(self): """Set up the test.""" # Initialize a lifting class - self.lifting = LiftingTransform( + self.lifting = Graph2SimplicialLiftingTransform( lifting=SimplicialCliqueLifting(complex_dim=3), feature_lifting="ProjectionSum", - data2domain=Data2NxGraph(), - domain2domain=TnxComplex2Complex(), - domain2dict=Complex2Dict(), ) def test_lift_features(self, simple_graph_1): diff --git a/test/transforms/feature_liftings/test_SetLifting.py b/test/transforms/feature_liftings/test_SetLifting.py index bf0c621f..584f9724 100644 --- a/test/transforms/feature_liftings/test_SetLifting.py +++ b/test/transforms/feature_liftings/test_SetLifting.py @@ -2,13 +2,8 @@ import torch -from topobenchmark.data.utils import ( - Complex2Dict, - Data2NxGraph, - TnxComplex2Complex, -) from topobenchmark.transforms.liftings import ( - LiftingTransform, + Graph2SimplicialLiftingTransform, SimplicialCliqueLifting, ) @@ -19,12 +14,9 @@ class TestSetLifting: def setup_method(self): """Set up the test.""" # Initialize a lifting class - self.lifting = LiftingTransform( - SimplicialCliqueLifting(complex_dim=3), + self.lifting = Graph2SimplicialLiftingTransform( + lifting=SimplicialCliqueLifting(complex_dim=3), feature_lifting="Set", - data2domain=Data2NxGraph(), - domain2domain=TnxComplex2Complex(signed=False), - domain2dict=Complex2Dict(), ) def test_lift_features(self, simple_graph_1): diff --git a/test/transforms/liftings/cell/test_CellCyclesLifting.py b/test/transforms/liftings/cell/test_CellCyclesLifting.py index c574992e..7235b20f 100644 --- a/test/transforms/liftings/cell/test_CellCyclesLifting.py +++ b/test/transforms/liftings/cell/test_CellCyclesLifting.py @@ -2,9 +2,10 @@ import torch -from topobenchmark.data.utils import Data2NxGraph, TnxComplex2Dict -from topobenchmark.transforms.liftings.base import LiftingTransform -from topobenchmark.transforms.liftings.graph2cell.cycle import CellCycleLifting +from topobenchmark.transforms.liftings import ( + CellCycleLifting, + Graph2ComplexLiftingTransform, +) class TestCellCycleLifting: @@ -12,11 +13,7 @@ class TestCellCycleLifting: def setup_method(self): # Initialise the CellCycleLifting class - self.lifting = LiftingTransform( - CellCycleLifting(), - data2domain=Data2NxGraph(), - domain2dict=TnxComplex2Dict(), - ) + self.lifting = Graph2ComplexLiftingTransform(CellCycleLifting()) def test_lift_topology(self, simple_graph_1): # Test the lift_topology method diff --git a/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py b/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py index 3fcc7ebb..8fd1b75b 100644 --- a/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py +++ b/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py @@ -2,9 +2,9 @@ import torch -from topobenchmark.transforms.liftings.base import LiftingTransform -from topobenchmark.transforms.liftings.graph2hypergraph.khop import ( +from topobenchmark.transforms.liftings import ( HypergraphKHopLifting, + LiftingTransform, ) diff --git a/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py b/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py index 069d7a3c..23dc5d35 100644 --- a/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py +++ b/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py @@ -4,9 +4,7 @@ import torch from torch_geometric.data import Data -from topobenchmark.transforms.liftings.graph2hypergraph.knn import ( - HypergraphKNNLifting, -) +from topobenchmark.transforms.liftings import HypergraphKNNLifting class TestHypergraphKNNLifting: diff --git a/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py b/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py index fa36e072..a2c32ebf 100644 --- a/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py +++ b/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py @@ -2,16 +2,11 @@ import torch -from topobenchmark.data.utils import ( - Complex2Dict, - Data2NxGraph, - TnxComplex2Complex, -) from topobenchmark.transforms.feature_liftings.projection_sum import ( ProjectionSum, ) -from topobenchmark.transforms.liftings.base import LiftingTransform -from topobenchmark.transforms.liftings.graph2simplicial.clique import ( +from topobenchmark.transforms.liftings import ( + Graph2SimplicialLiftingTransform, SimplicialCliqueLifting, ) @@ -21,25 +16,19 @@ class TestSimplicialCliqueLifting: def setup_method(self): # Initialise the SimplicialCliqueLifting class - data2graph = Data2NxGraph() lifting_map = SimplicialCliqueLifting(complex_dim=3) feature_lifting = ProjectionSum() - domain2dict = Complex2Dict() - self.lifting_signed = LiftingTransform( + self.lifting_signed = Graph2SimplicialLiftingTransform( lifting=lifting_map, feature_lifting=feature_lifting, - data2domain=data2graph, - domain2domain=TnxComplex2Complex(signed=True), - domain2dict=domain2dict, + signed=True, ) - self.lifting_unsigned = LiftingTransform( + self.lifting_unsigned = Graph2SimplicialLiftingTransform( lifting=lifting_map, feature_lifting=feature_lifting, - data2domain=data2graph, - domain2domain=TnxComplex2Complex(signed=False), - domain2dict=domain2dict, + signed=False, ) def test_lift_topology(self, simple_graph_1): diff --git a/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py b/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py index e21b8f99..6a81d9f2 100644 --- a/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py +++ b/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py @@ -2,16 +2,11 @@ import torch -from topobenchmark.data.utils import ( - Complex2Dict, - Data2NxGraph, - TnxComplex2Complex, -) from topobenchmark.transforms.feature_liftings.projection_sum import ( ProjectionSum, ) -from topobenchmark.transforms.liftings.base import LiftingTransform -from topobenchmark.transforms.liftings.graph2simplicial.khop import ( +from topobenchmark.transforms.liftings import ( + Graph2SimplicialLiftingTransform, SimplicialKHopLifting, ) @@ -23,25 +18,19 @@ class TestSimplicialKHopLifting: def setup_method(self): # Initialise the SimplicialKHopLifting class - data2graph = Data2NxGraph() feature_lifting = ProjectionSum() - domain2dict = Complex2Dict() lifting_map = SimplicialKHopLifting(complex_dim=3) - self.lifting_signed = LiftingTransform( + self.lifting_signed = Graph2SimplicialLiftingTransform( lifting=lifting_map, feature_lifting=feature_lifting, - data2domain=data2graph, - domain2domain=TnxComplex2Complex(signed=True), - domain2dict=domain2dict, + signed=True, ) - self.lifting_unsigned = LiftingTransform( + self.lifting_unsigned = Graph2SimplicialLiftingTransform( lifting=lifting_map, feature_lifting=feature_lifting, - data2domain=data2graph, - domain2domain=TnxComplex2Complex(signed=False), - domain2dict=domain2dict, + signed=False, ) def test_lift_topology(self, simple_graph_1): diff --git a/topobenchmark/data/utils/__init__.py b/topobenchmark/data/utils/__init__.py index d7010c2b..34fc79f3 100644 --- a/topobenchmark/data/utils/__init__.py +++ b/topobenchmark/data/utils/__init__.py @@ -1,7 +1,7 @@ """Init file for data/utils module.""" from .adapters import * -from .domain import Complex +from .domain import Complex # noqa: F401 from .utils import ( ensure_serializable, # noqa: F401 generate_zero_sparse_connectivity, # noqa: F401 From f3e3f88b5b503ef84570bc3b7fcb384a405ace31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 16 Jan 2025 15:31:25 -0800 Subject: [PATCH 23/36] Update domain to accomodate hypergraph data --- .../liftings/cell/test_CellCyclesLifting.py | 4 +- .../hypergraph/test_HypergraphKHopLifting.py | 12 +- topobenchmark/data/utils/__init__.py | 2 +- topobenchmark/data/utils/adapters.py | 39 ++++-- topobenchmark/data/utils/domain.py | 113 +++++++++++------- .../feature_liftings/concatenation.py | 18 +-- .../feature_liftings/projection_sum.py | 16 +-- .../transforms/feature_liftings/set.py | 16 +-- topobenchmark/transforms/liftings/__init__.py | 2 + topobenchmark/transforms/liftings/base.py | 29 +++-- .../liftings/graph2hypergraph/khop.py | 13 +- 11 files changed, 166 insertions(+), 98 deletions(-) diff --git a/test/transforms/liftings/cell/test_CellCyclesLifting.py b/test/transforms/liftings/cell/test_CellCyclesLifting.py index 7235b20f..706e1f9d 100644 --- a/test/transforms/liftings/cell/test_CellCyclesLifting.py +++ b/test/transforms/liftings/cell/test_CellCyclesLifting.py @@ -4,7 +4,7 @@ from topobenchmark.transforms.liftings import ( CellCycleLifting, - Graph2ComplexLiftingTransform, + Graph2CellLiftingTransform, ) @@ -13,7 +13,7 @@ class TestCellCycleLifting: def setup_method(self): # Initialise the CellCycleLifting class - self.lifting = Graph2ComplexLiftingTransform(CellCycleLifting()) + self.lifting = Graph2CellLiftingTransform(CellCycleLifting()) def test_lift_topology(self, simple_graph_1): # Test the lift_topology method diff --git a/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py b/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py index 8fd1b75b..68326f11 100644 --- a/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py +++ b/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py @@ -3,8 +3,8 @@ import torch from topobenchmark.transforms.liftings import ( + Graph2HypergraphLiftingTransform, HypergraphKHopLifting, - LiftingTransform, ) @@ -14,15 +14,19 @@ class TestHypergraphKHopLifting: def setup_method(self): """Setup the test.""" # Initialise the HypergraphKHopLifting class - self.lifting_k1 = LiftingTransform(HypergraphKHopLifting(k_value=1)) - self.lifting_k2 = LiftingTransform(HypergraphKHopLifting(k_value=2)) + self.lifting_k1 = Graph2HypergraphLiftingTransform( + HypergraphKHopLifting(k_value=1) + ) + self.lifting_k2 = Graph2HypergraphLiftingTransform( + HypergraphKHopLifting(k_value=2) + ) # TODO: delete? # NB: `preserve_edge_attr` is never used? therefore they're equivalent # self.lifting_edge_attr = HypergraphKHopLifting( # k_value=1, preserve_edge_attr=True # ) - self.lifting_edge_attr = LiftingTransform( + self.lifting_edge_attr = Graph2HypergraphLiftingTransform( HypergraphKHopLifting(k_value=1) ) diff --git a/topobenchmark/data/utils/__init__.py b/topobenchmark/data/utils/__init__.py index 34fc79f3..de796c1d 100644 --- a/topobenchmark/data/utils/__init__.py +++ b/topobenchmark/data/utils/__init__.py @@ -1,7 +1,7 @@ """Init file for data/utils module.""" from .adapters import * -from .domain import Complex # noqa: F401 +from .domain import ComplexData, HypergraphData # noqa: F401 from .utils import ( ensure_serializable, # noqa: F401 generate_zero_sparse_connectivity, # noqa: F401 diff --git a/topobenchmark/data/utils/adapters.py b/topobenchmark/data/utils/adapters.py index 80295e69..b049d49c 100644 --- a/topobenchmark/data/utils/adapters.py +++ b/topobenchmark/data/utils/adapters.py @@ -8,7 +8,7 @@ from toponetx.classes import CellComplex, SimplicialComplex from torch_geometric.utils.undirected import is_undirected, to_undirected -from topobenchmark.data.utils.domain import Complex +from topobenchmark.data.utils.domain import ComplexData from topobenchmark.data.utils.utils import ( generate_zero_sparse_connectivity, select_neighborhoods_of_interest, @@ -115,7 +115,7 @@ def adapt(self, domain: torch_geometric.data.Data) -> nx.Graph: return graph -class TnxComplex2Complex(Adapter): +class TnxComplex2ComplexData(Adapter): """toponetx.Complex to Complex adaptation. NB: order of features plays a crucial role, as ``Complex`` @@ -236,18 +236,18 @@ def adapt(self, domain): for _ in range(dim + 1, practical_dim + 1): data["features"].append(None) - return Complex(**data) + return ComplexData(**data) -class Complex2Dict(Adapter): - """Complex to dict adaptation.""" +class ComplexData2Dict(Adapter): + """ComplexData to dict adaptation.""" def adapt(self, domain): """Adapt Complex to dict. Parameters ---------- - domain : toponetx.Complex + domain : ComplexData Returns ------- @@ -277,6 +277,29 @@ def adapt(self, domain): return data +class HypergraphData2Dict(Adapter): + """HypergraphData to dict adaptation.""" + + def adapt(self, domain): + """Adapt HypergraphData to dict. + + Parameters + ---------- + domain : HypergraphData + + Returns + ------- + dict + """ + hyperedges_key = domain.keys()[-1] + return { + "incidence_hyperedges": domain.incidence[hyperedges_key], + "num_hyperedges": domain.num_hyperedges, + "x_0": domain.features[0], + "x_hyperedges": domain.features[hyperedges_key], + } + + class AdapterComposition(Adapter): def __init__(self, adapters): super().__init__() @@ -309,10 +332,10 @@ def __init__( signed=False, transfer_features=True, ): - tnxcomplex2complex = TnxComplex2Complex( + tnxcomplex2complex = TnxComplex2ComplexData( neighborhoods=neighborhoods, signed=signed, transfer_features=transfer_features, ) - complex2dict = Complex2Dict() + complex2dict = ComplexData2Dict() super().__init__(adapters=(tnxcomplex2complex, complex2dict)) diff --git a/topobenchmark/data/utils/domain.py b/topobenchmark/data/utils/domain.py index 8bf4f1d7..57790162 100644 --- a/topobenchmark/data/utils/domain.py +++ b/topobenchmark/data/utils/domain.py @@ -1,4 +1,44 @@ -class Complex: +import abc + + +class Data(abc.ABC): + def __init__(self, incidence, features): + self.incidence = incidence + self.features = features + + @abc.abstractmethod + def keys(self): + pass + + def update_features(self, rank, values): + """Update features. + + Parameters + ---------- + rank : int + Rank of simplices the features belong to. + values : array-like + New features for the rank-simplices. + """ + self.features[rank] = values + + @property + def shape(self): + """Shape of the complex. + + Returns + ------- + list[int] + """ + return [ + None + if self.incidence[key] is None + else self.incidence[key].shape[-1] + for key in self.keys() + ] + + +class ComplexData(Data): def __init__( self, incidence, @@ -9,10 +49,6 @@ def __init__( hodge_laplacian, features=None, ): - # TODO: allow None with nice error message if callable? - - # TODO: make this private? do not allow for changes in these values? - self.incidence = incidence self.down_laplacian = down_laplacian self.up_laplacian = up_laplacian self.adjacency = adjacency @@ -20,53 +56,42 @@ def __init__( self.hodge_laplacian = hodge_laplacian if features is None: - features = [None for _ in range(len(self.incidence))] + features = [None for _ in range(len(incidence))] else: - for rank, dim in enumerate(self.shape): + for rank, incidence_ in enumerate(incidence): # TODO: make error message more informative if ( features[rank] is not None - and features[rank].shape[0] != dim + and features[rank].shape[0] != incidence_.shape[-1] ): raise ValueError("Features have wrong shape.") - self.features = features + super().__init__(incidence, features) - @property - def shape(self): - """Shape of the complex. + def keys(self): + return list(range(len(self.incidence))) - Returns - ------- - list[int] - """ - return [incidence.shape[-1] for incidence in self.incidence] - - @property - def max_rank(self): - """Maximum rank of the complex. - - NB: may differ from mathematical definition due to empty - matrices. - - Returns - ------- - int - """ - return len(self.incidence) - def update_features(self, rank, values): - """Update features. - - Parameters - ---------- - rank : int - Rank of simplices the features belong to. - values : array-like - New features for the rank-simplices. - """ - self.features[rank] = values +class HypergraphData(Data): + def __init__( + self, + incidence_hyperedges, + num_hyperedges, + incidence_0=None, + x_0=None, + x_hyperedges=None, + ): + self._hyperedges_key = 1 + incidence = { + 0: incidence_0, + self._hyperedges_key: incidence_hyperedges, + } + features = { + 0: x_0, + self._hyperedges_key: x_hyperedges, + } + super().__init__(incidence, features) + self.num_hyperedges = num_hyperedges - def reset_features(self): - """Reset features.""" - self.features = [None for _ in self.features] + def keys(self): + return [0, self._hyperedges_key] diff --git a/topobenchmark/transforms/feature_liftings/concatenation.py b/topobenchmark/transforms/feature_liftings/concatenation.py index b26509d9..44e3b192 100644 --- a/topobenchmark/transforms/feature_liftings/concatenation.py +++ b/topobenchmark/transforms/feature_liftings/concatenation.py @@ -24,14 +24,13 @@ def lift_features(self, domain): Complex Domain with the lifted features. """ - for rank in range(domain.max_rank - 1): - if domain.features[rank + 1] is not None: + for key, next_key in zip( + domain.keys(), domain.keys()[1:], strict=False + ): + if domain.features[next_key] is not None: continue - # TODO: different if hyperedges? - idx_to_project = rank - - incidence = domain.incidence[rank + 1] + incidence = domain.incidence[next_key] _, n = incidence.shape if n != 0: @@ -43,11 +42,12 @@ def lift_features(self, domain): idxs_list.append(torch.sort(idxs_for_feature)[0]) idxs = torch.stack(idxs_list, dim=0) - values = domain.features[idx_to_project][idxs].view(n, -1) + values = domain.features[key][idxs].view(n, -1) else: - m = domain.features[rank].shape[1] * (rank + 2) + # NB: only works if key represents rank + m = domain.features[key].shape[1] * (next_key + 1) values = torch.zeros([0, m]) - domain.update_features(rank + 1, values) + domain.update_features(next_key, values) return domain diff --git a/topobenchmark/transforms/feature_liftings/projection_sum.py b/topobenchmark/transforms/feature_liftings/projection_sum.py index a756fd0e..757234a7 100644 --- a/topobenchmark/transforms/feature_liftings/projection_sum.py +++ b/topobenchmark/transforms/feature_liftings/projection_sum.py @@ -13,23 +13,25 @@ def lift_features(self, domain): Parameters ---------- - data : Complex + data : Data The input data to be lifted. Returns ------- - Complex + Data Domain with the lifted features. """ - for rank in range(domain.max_rank - 1): - if domain.features[rank + 1] is not None: + for key, next_key in zip( + domain.keys(), domain.keys()[1:], strict=False + ): + if domain.features[next_key] is not None: continue domain.update_features( - rank + 1, + next_key, torch.matmul( - torch.abs(domain.incidence[rank + 1].t()), - domain.features[rank], + torch.abs(domain.incidence[next_key].t()), + domain.features[key], ), ) diff --git a/topobenchmark/transforms/feature_liftings/set.py b/topobenchmark/transforms/feature_liftings/set.py index 1886e25b..54ac1b9d 100644 --- a/topobenchmark/transforms/feature_liftings/set.py +++ b/topobenchmark/transforms/feature_liftings/set.py @@ -24,11 +24,13 @@ def lift_features(self, domain): Complex Domain with the lifted features. """ - for rank in range(domain.max_rank - 1): - if domain.features[rank + 1] is not None: + for key, next_key in zip( + domain.keys(), domain.keys()[1:], strict=False + ): + if domain.features[next_key] is not None: continue - incidence = domain.incidence[rank + 1] + incidence = domain.incidence[next_key] _, n = incidence.shape if n != 0: @@ -40,14 +42,12 @@ def lift_features(self, domain): idxs_list.append(torch.sort(idxs_for_feature)[0]) idxs = torch.stack(idxs_list, dim=0) - if rank == 0: + if key == 0: values = idxs else: values = torch.sort( torch.unique( - domain.features[rank][idxs].view( - idxs.shape[0], -1 - ), + domain.features[key][idxs].view(idxs.shape[0], -1), dim=1, ), dim=1, @@ -55,6 +55,6 @@ def lift_features(self, domain): else: values = torch.tensor([]) - domain.update_features(rank + 1, values) + domain.update_features(next_key, values) return domain diff --git a/topobenchmark/transforms/liftings/__init__.py b/topobenchmark/transforms/liftings/__init__.py index 2c759ac3..322c43a1 100755 --- a/topobenchmark/transforms/liftings/__init__.py +++ b/topobenchmark/transforms/liftings/__init__.py @@ -1,7 +1,9 @@ """This module implements the liftings for the topological transforms.""" from .base import ( # noqa: F401 + Graph2CellLiftingTransform, Graph2ComplexLiftingTransform, + Graph2HypergraphLiftingTransform, Graph2SimplicialLiftingTransform, LiftingTransform, ) diff --git a/topobenchmark/transforms/liftings/base.py b/topobenchmark/transforms/liftings/base.py index 3637f564..13f1f443 100644 --- a/topobenchmark/transforms/liftings/base.py +++ b/topobenchmark/transforms/liftings/base.py @@ -5,12 +5,12 @@ import torch_geometric from topobenchmark.data.utils import ( - Complex2Dict, + ComplexData2Dict, Data2NxGraph, + HypergraphData2Dict, IdentityAdapter, - TnxComplex2Complex, + TnxComplex2ComplexData, ) -from topobenchmark.transforms.feature_liftings.identity import Identity class LiftingTransform(torch_geometric.transforms.BaseTransform): @@ -39,11 +39,8 @@ def __init__( data2domain=None, domain2dict=None, domain2domain=None, - feature_lifting=None, + feature_lifting="ProjectionSum", ): - if feature_lifting is None: - feature_lifting = Identity() - if data2domain is None: data2domain = IdentityAdapter() @@ -129,16 +126,30 @@ def __init__( lifting, feature_lifting=feature_lifting, data2domain=Data2NxGraph(preserve_edge_attr), - domain2domain=TnxComplex2Complex( + domain2domain=TnxComplex2ComplexData( neighborhoods=neighborhoods, signed=signed, transfer_features=transfer_features, ), - domain2dict=Complex2Dict(), + domain2dict=ComplexData2Dict(), ) Graph2SimplicialLiftingTransform = Graph2ComplexLiftingTransform +Graph2CellLiftingTransform = Graph2ComplexLiftingTransform + + +class Graph2HypergraphLiftingTransform(LiftingTransform): + def __init__( + self, + lifting, + feature_lifting="ProjectionSum", + ): + super().__init__( + lifting, + feature_lifting=feature_lifting, + domain2dict=HypergraphData2Dict(), + ) class LiftingMap(abc.ABC): diff --git a/topobenchmark/transforms/liftings/graph2hypergraph/khop.py b/topobenchmark/transforms/liftings/graph2hypergraph/khop.py index f8997e31..7c56006c 100755 --- a/topobenchmark/transforms/liftings/graph2hypergraph/khop.py +++ b/topobenchmark/transforms/liftings/graph2hypergraph/khop.py @@ -3,6 +3,7 @@ import torch import torch_geometric +from topobenchmark.data.utils import HypergraphData from topobenchmark.transforms.liftings.base import LiftingMap @@ -36,7 +37,7 @@ def lift(self, data: torch_geometric.data.Data) -> dict: Returns ------- - dict + HypergraphData The lifted topology. """ # Check if data has instance x: @@ -72,8 +73,8 @@ def lift(self, data: torch_geometric.data.Data) -> dict: num_hyperedges = incidence_1.shape[1] incidence_1 = torch.Tensor(incidence_1).to_sparse_coo() - return { - "incidence_hyperedges": incidence_1, - "num_hyperedges": num_hyperedges, - "x_0": data.x, - } + return HypergraphData( + incidence_hyperedges=incidence_1, + num_hyperedges=num_hyperedges, + x_0=data.x, + ) From f4010fad4856a3423d59354ea4a2adf31a455d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 16 Jan 2025 15:31:50 -0800 Subject: [PATCH 24/36] Update data_transform to handle new liftings design --- topobenchmark/transforms/data_transform.py | 69 +++++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/topobenchmark/transforms/data_transform.py b/topobenchmark/transforms/data_transform.py index da9e883b..8106f829 100755 --- a/topobenchmark/transforms/data_transform.py +++ b/topobenchmark/transforms/data_transform.py @@ -1,8 +1,54 @@ """DataTransform class.""" +import inspect + import torch_geometric -from topobenchmark.transforms import TRANSFORMS +from topobenchmark.transforms import LIFTINGS, TRANSFORMS +from topobenchmark.transforms.liftings import ( + GRAPH2CELL_LIFTINGS, + GRAPH2HYPERGRAPH_LIFTINGS, + GRAPH2SIMPLICIAL_LIFTINGS, + Graph2CellLiftingTransform, + Graph2HypergraphLiftingTransform, + Graph2SimplicialLiftingTransform, + LiftingTransform, +) + +_map_lifting_types = { + "graph2cell": (GRAPH2CELL_LIFTINGS, Graph2CellLiftingTransform), + "graph2hypergraph": ( + GRAPH2HYPERGRAPH_LIFTINGS, + Graph2HypergraphLiftingTransform, + ), + "graph2simplicial": ( + GRAPH2SIMPLICIAL_LIFTINGS, + Graph2SimplicialLiftingTransform, + ), +} + + +def _map_lifting_name(lifting_name): + for liftings_dict, Transform in _map_lifting_types.values(): + if lifting_name in liftings_dict: + return Transform + + return LiftingTransform + + +def _route_lifting_kwargs(kwargs, LiftingMap): + lifting_map_sign = inspect.signature(LiftingMap) + + lifting_map_kwargs = {} + transform_kwargs = {} + + for key, value in kwargs.items(): + if key in lifting_map_sign.parameters: + lifting_map_kwargs[key] = value + else: + transform_kwargs[key] = value + + return lifting_map_kwargs, transform_kwargs class DataTransform(torch_geometric.transforms.BaseTransform): @@ -19,14 +65,21 @@ class DataTransform(torch_geometric.transforms.BaseTransform): def __init__(self, transform_name, **kwargs): super().__init__() - kwargs["transform_name"] = transform_name - self.parameters = kwargs + if transform_name not in LIFTINGS: + kwargs["transform_name"] = transform_name + transform = TRANSFORMS[transform_name](**kwargs) + else: + LiftingMap_ = TRANSFORMS[transform_name] + Transform = _map_lifting_name(transform_name) + lifting_map_kwargs, transform_kwargs = _route_lifting_kwargs( + kwargs, LiftingMap_ + ) + + lifting_map = LiftingMap_(**lifting_map_kwargs) + transform = Transform(lifting_map, **transform_kwargs) - self.transform = ( - TRANSFORMS[transform_name](**kwargs) - if transform_name is not None - else None - ) + self.parameters = kwargs + self.transform = transform def forward( self, data: torch_geometric.data.Data From 27962fc1e7c75c6a4b1cc99a951451c09b4c3d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 16 Jan 2025 15:49:23 -0800 Subject: [PATCH 25/36] Fix failing tests --- test/conftest.py | 8 ++- test/nn/backbones/simplicial/test_sccnn.py | 59 +++++++++++------- test/nn/wrappers/cell/test_cell_wrappers.py | 46 ++++++-------- .../wrappers/simplicial/test_SCCNNWrapper.py | 62 ++++++++++--------- topobenchmark/transforms/data_transform.py | 7 ++- 5 files changed, 97 insertions(+), 85 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 9a70c6a1..d8cf94d0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -7,6 +7,8 @@ from topobenchmark.transforms.liftings import ( CellCycleLifting, + Graph2CellLiftingTransform, + Graph2SimplicialLiftingTransform, SimplicialCliqueLifting, ) @@ -148,7 +150,9 @@ def sg1_clique_lifted(simple_graph_1): torch_geometric.data.Data A simple graph data object with a clique lifting. """ - lifting_signed = SimplicialCliqueLifting(complex_dim=3, signed=True) + lifting_signed = Graph2SimplicialLiftingTransform( + SimplicialCliqueLifting(complex_dim=3), signed=True + ) data = lifting_signed(simple_graph_1) data.batch_0 = "null" return data @@ -168,7 +172,7 @@ def sg1_cell_lifted(simple_graph_1): torch_geometric.data.Data A simple graph data object with a cell lifting. """ - lifting = CellCycleLifting() + lifting = Graph2CellLiftingTransform(CellCycleLifting()) data = lifting(simple_graph_1) data.batch_0 = "null" return data diff --git a/test/nn/backbones/simplicial/test_sccnn.py b/test/nn/backbones/simplicial/test_sccnn.py index 19e2b774..09e86342 100644 --- a/test/nn/backbones/simplicial/test_sccnn.py +++ b/test/nn/backbones/simplicial/test_sccnn.py @@ -1,38 +1,53 @@ """Unit tests for SCCNN""" -import torch -from torch_geometric.utils import get_laplacian -from ...._utils.nn_module_auto_test import NNModuleAutoTest from topobenchmark.nn.backbones.simplicial import SCCNNCustom -from topobenchmark.transforms.liftings.graph2simplicial import ( +from topobenchmark.transforms.liftings import ( + Graph2SimplicialLiftingTransform, SimplicialCliqueLifting, ) +from ...._utils.nn_module_auto_test import NNModuleAutoTest + def test_SCCNNCustom(simple_graph_1): - lifting_signed = SimplicialCliqueLifting( - complex_dim=3, signed=True - ) + lifting_signed = Graph2SimplicialLiftingTransform( + SimplicialCliqueLifting(complex_dim=3), signed=True + ) data = lifting_signed(simple_graph_1) out_dim = 4 conv_order = 1 sc_order = 3 laplacian_all = ( - data.hodge_laplacian_0, - data.down_laplacian_1, - data.up_laplacian_1, - data.down_laplacian_2, - data.up_laplacian_2, - ) + data.hodge_laplacian_0, + data.down_laplacian_1, + data.up_laplacian_1, + data.down_laplacian_2, + data.up_laplacian_2, + ) incidence_all = (data.incidence_1, data.incidence_2) - expected_shapes = [(data.x.shape[0], out_dim), (data.x_1.shape[0], out_dim), (data.x_2.shape[0], out_dim)] + expected_shapes = [ + (data.x.shape[0], out_dim), + (data.x_1.shape[0], out_dim), + (data.x_2.shape[0], out_dim), + ] - auto_test = NNModuleAutoTest([ - { - "module" : SCCNNCustom, - "init": ((data.x.shape[1], data.x_1.shape[1], data.x_2.shape[1]), (out_dim, out_dim, out_dim), conv_order, sc_order), - "forward": ((data.x, data.x_1, data.x_2), laplacian_all, incidence_all), - "assert_shape": expected_shapes - }, - ]) + auto_test = NNModuleAutoTest( + [ + { + "module": SCCNNCustom, + "init": ( + (data.x.shape[1], data.x_1.shape[1], data.x_2.shape[1]), + (out_dim, out_dim, out_dim), + conv_order, + sc_order, + ), + "forward": ( + (data.x, data.x_1, data.x_2), + laplacian_all, + incidence_all, + ), + "assert_shape": expected_shapes, + }, + ] + ) auto_test.run() diff --git a/test/nn/wrappers/cell/test_cell_wrappers.py b/test/nn/wrappers/cell/test_cell_wrappers.py index 45b69888..fb551a67 100644 --- a/test/nn/wrappers/cell/test_cell_wrappers.py +++ b/test/nn/wrappers/cell/test_cell_wrappers.py @@ -1,23 +1,14 @@ """Unit tests for cell model wrappers""" -import torch -from torch_geometric.utils import get_laplacian -from ...._utils.nn_module_auto_test import NNModuleAutoTest -from ...._utils.flow_mocker import FlowMocker -from unittest.mock import MagicMock +from topomodelx.nn.cell.ccxn import CCXN +from topomodelx.nn.cell.cwn import CWN +from topobenchmark.nn.backbones.cell.cccn import CCCN from topobenchmark.nn.wrappers import ( - AbstractWrapper, CCCNWrapper, - CANWrapper, CCXNWrapper, - CWNWrapper + CWNWrapper, ) -from topomodelx.nn.cell.can import CAN -from topomodelx.nn.cell.ccxn import CCXN -from topomodelx.nn.cell.cwn import CWN -from topobenchmark.nn.backbones.cell.cccn import CCCN -from unittest.mock import MagicMock class TestCellWrappers: @@ -27,11 +18,9 @@ def test_CCCNWrapper(self, sg1_clique_lifted): num_cell_dimensions = 2 wrapper = CCCNWrapper( - CCCN( - data.x_1.shape[1] - ), - out_channels=out_channels, - num_cell_dimensions=num_cell_dimensions + CCCN(data.x_1.shape[1]), + out_channels=out_channels, + num_cell_dimensions=num_cell_dimensions, ) out = wrapper(data) @@ -44,11 +33,9 @@ def test_CCXNWrapper(self, sg1_cell_lifted): num_cell_dimensions = 2 wrapper = CCXNWrapper( - CCXN( - data.x_0.shape[1], data.x_1.shape[1], out_channels - ), - out_channels=out_channels, - num_cell_dimensions=num_cell_dimensions + CCXN(data.x_0.shape[1], data.x_1.shape[1], out_channels), + out_channels=out_channels, + num_cell_dimensions=num_cell_dimensions, ) out = wrapper(data) @@ -63,13 +50,16 @@ def test_CWNWrapper(self, sg1_cell_lifted): wrapper = CWNWrapper( CWN( - data.x_0.shape[1], data.x_1.shape[1], data.x_2.shape[1], hid_channels, 2 - ), - out_channels=out_channels, - num_cell_dimensions=num_cell_dimensions + data.x_0.shape[1], + data.x_1.shape[1], + data.x_2.shape[1], + hid_channels, + 2, + ), + out_channels=out_channels, + num_cell_dimensions=num_cell_dimensions, ) out = wrapper(data) for key in ["labels", "batch_0", "x_0", "x_1", "x_2"]: assert key in out - diff --git a/test/nn/wrappers/simplicial/test_SCCNNWrapper.py b/test/nn/wrappers/simplicial/test_SCCNNWrapper.py index f3614a7b..bc3e1807 100644 --- a/test/nn/wrappers/simplicial/test_SCCNNWrapper.py +++ b/test/nn/wrappers/simplicial/test_SCCNNWrapper.py @@ -1,26 +1,24 @@ """Unit tests for simplicial model wrappers""" -import torch -from torch_geometric.utils import get_laplacian -from ...._utils.nn_module_auto_test import NNModuleAutoTest -from ...._utils.flow_mocker import FlowMocker -from topobenchmark.nn.backbones.simplicial import SCCNNCustom from topomodelx.nn.simplicial.san import SAN -from topomodelx.nn.simplicial.scn2 import SCN2 from topomodelx.nn.simplicial.sccn import SCCN +from topomodelx.nn.simplicial.scn2 import SCN2 + +from topobenchmark.nn.backbones.simplicial import SCCNNCustom from topobenchmark.nn.wrappers import ( - SCCNWrapper, - SCCNNWrapper, SANWrapper, - SCNWrapper + SCCNNWrapper, + SCCNWrapper, + SCNWrapper, ) + class TestSimplicialWrappers: """Test simplicial model wrappers.""" def test_SCCNNWrapper(self, sg1_clique_lifted): """Test SCCNNWrapper. - + Parameters ---------- sg1_clique_lifted : torch_geometric.data.Data @@ -30,12 +28,17 @@ def test_SCCNNWrapper(self, sg1_clique_lifted): out_dim = 4 conv_order = 1 sc_order = 3 - init_args = (data.x_0.shape[1], data.x_1.shape[1], data.x_2.shape[1]), (out_dim, out_dim, out_dim), conv_order, sc_order + init_args = ( + (data.x_0.shape[1], data.x_1.shape[1], data.x_2.shape[1]), + (out_dim, out_dim, out_dim), + conv_order, + sc_order, + ) wrapper = SCCNNWrapper( - SCCNNCustom(*init_args), - out_channels=out_dim, - num_cell_dimensions=3 + SCCNNCustom(*init_args), + out_channels=out_dim, + num_cell_dimensions=3, ) out = wrapper(data) # Assert keys in output @@ -44,20 +47,20 @@ def test_SCCNNWrapper(self, sg1_clique_lifted): def test_SANWarpper(self, sg1_clique_lifted): """Test SANWarpper. - + Parameters ---------- sg1_clique_lifted : torch_geometric.data.Data - A fixture of simple graph 1 lifted with SimlicialCliqueLifting + A fixture of simple graph 1 lifted with SimlicialCliqueLifting """ data = sg1_clique_lifted out_dim = data.x_0.shape[1] hidden_channels = data.x_0.shape[1] wrapper = SANWrapper( - SAN(data.x_0.shape[1], hidden_channels), - out_channels=out_dim, - num_cell_dimensions=3 + SAN(data.x_0.shape[1], hidden_channels), + out_channels=out_dim, + num_cell_dimensions=3, ) out = wrapper(data) # Assert keys in output @@ -66,19 +69,19 @@ def test_SANWarpper(self, sg1_clique_lifted): def test_SCNWrapper(self, sg1_clique_lifted): """Test SCNWrapper. - + Parameters ---------- sg1_clique_lifted : torch_geometric.data.Data - A fixture of simple graph 1 lifted with SimlicialCliqueLifting + A fixture of simple graph 1 lifted with SimlicialCliqueLifting """ data = sg1_clique_lifted out_dim = data.x_0.shape[1] wrapper = SCNWrapper( - SCN2(data.x_0.shape[1], data.x_1.shape[1], data.x_2.shape[1]), - out_channels=out_dim, - num_cell_dimensions=3 + SCN2(data.x_0.shape[1], data.x_1.shape[1], data.x_2.shape[1]), + out_channels=out_dim, + num_cell_dimensions=3, ) out = wrapper(data) # Assert keys in output @@ -87,23 +90,22 @@ def test_SCNWrapper(self, sg1_clique_lifted): def test_SCCNWrapper(self, sg1_clique_lifted): """Test SCCNWrapper. - + Parameters ---------- sg1_clique_lifted : torch_geometric.data.Data - A fixture of simple graph 1 lifted with SimlicialCliqueLifting + A fixture of simple graph 1 lifted with SimlicialCliqueLifting """ data = sg1_clique_lifted out_dim = data.x_0.shape[1] max_rank = 2 wrapper = SCCNWrapper( - SCCN(data.x_0.shape[1], max_rank), - out_channels=out_dim, - num_cell_dimensions=3 + SCCN(data.x_0.shape[1], max_rank), + out_channels=out_dim, + num_cell_dimensions=3, ) out = wrapper(data) # Assert keys in output for key in ["labels", "batch_0", "x_0", "x_1", "x_2"]: assert key in out - diff --git a/topobenchmark/transforms/data_transform.py b/topobenchmark/transforms/data_transform.py index 8106f829..af48dc88 100755 --- a/topobenchmark/transforms/data_transform.py +++ b/topobenchmark/transforms/data_transform.py @@ -36,8 +36,9 @@ def _map_lifting_name(lifting_name): return LiftingTransform -def _route_lifting_kwargs(kwargs, LiftingMap): +def _route_lifting_kwargs(kwargs, LiftingMap, Transform): lifting_map_sign = inspect.signature(LiftingMap) + transform_sign = inspect.signature(Transform) lifting_map_kwargs = {} transform_kwargs = {} @@ -45,7 +46,7 @@ def _route_lifting_kwargs(kwargs, LiftingMap): for key, value in kwargs.items(): if key in lifting_map_sign.parameters: lifting_map_kwargs[key] = value - else: + elif key in transform_sign.parameters: transform_kwargs[key] = value return lifting_map_kwargs, transform_kwargs @@ -72,7 +73,7 @@ def __init__(self, transform_name, **kwargs): LiftingMap_ = TRANSFORMS[transform_name] Transform = _map_lifting_name(transform_name) lifting_map_kwargs, transform_kwargs = _route_lifting_kwargs( - kwargs, LiftingMap_ + kwargs, LiftingMap_, Transform ) lifting_map = LiftingMap_(**lifting_map_kwargs) From 1f5ad563eba1c84100e8c7561244625cb79b9fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 16 Jan 2025 16:26:15 -0800 Subject: [PATCH 26/36] Fix tutorial_lifting --- topobenchmark/transforms/__init__.py | 24 +++- topobenchmark/transforms/data_transform.py | 31 ++--- topobenchmark/transforms/liftings/__init__.py | 1 + .../liftings/graph2simplicial/clique.py | 2 +- tutorials/tutorial_lifting.ipynb | 124 ++++++++++-------- 5 files changed, 110 insertions(+), 72 deletions(-) diff --git a/topobenchmark/transforms/__init__.py b/topobenchmark/transforms/__init__.py index 62f8d85e..20840dfe 100755 --- a/topobenchmark/transforms/__init__.py +++ b/topobenchmark/transforms/__init__.py @@ -2,10 +2,32 @@ from .data_manipulations import DATA_MANIPULATIONS from .feature_liftings import FEATURE_LIFTINGS -from .liftings import LIFTINGS +from .liftings import ( + GRAPH2CELL_LIFTINGS, + GRAPH2HYPERGRAPH_LIFTINGS, + GRAPH2SIMPLICIAL_LIFTINGS, + LIFTINGS, +) TRANSFORMS = { **LIFTINGS, **FEATURE_LIFTINGS, **DATA_MANIPULATIONS, } + + +_map_lifting_type_to_dict = { + "graph2cell": GRAPH2CELL_LIFTINGS, + "graph2hypergraph": GRAPH2HYPERGRAPH_LIFTINGS, + "graph2simplicial": GRAPH2SIMPLICIAL_LIFTINGS, +} + + +def add_lifting_map(LiftingMap, lifting_type, name=None): + if name is None: + name = LiftingMap.__name__ + + liftings_dict = _map_lifting_type_to_dict[lifting_type] + + for dict_ in (liftings_dict, LIFTINGS, TRANSFORMS): + dict_[name] = LiftingMap diff --git a/topobenchmark/transforms/data_transform.py b/topobenchmark/transforms/data_transform.py index af48dc88..c1cda424 100755 --- a/topobenchmark/transforms/data_transform.py +++ b/topobenchmark/transforms/data_transform.py @@ -4,34 +4,29 @@ import torch_geometric -from topobenchmark.transforms import LIFTINGS, TRANSFORMS +from topobenchmark.transforms import ( + LIFTINGS, + TRANSFORMS, + _map_lifting_type_to_dict, +) from topobenchmark.transforms.liftings import ( - GRAPH2CELL_LIFTINGS, - GRAPH2HYPERGRAPH_LIFTINGS, - GRAPH2SIMPLICIAL_LIFTINGS, Graph2CellLiftingTransform, Graph2HypergraphLiftingTransform, Graph2SimplicialLiftingTransform, LiftingTransform, ) -_map_lifting_types = { - "graph2cell": (GRAPH2CELL_LIFTINGS, Graph2CellLiftingTransform), - "graph2hypergraph": ( - GRAPH2HYPERGRAPH_LIFTINGS, - Graph2HypergraphLiftingTransform, - ), - "graph2simplicial": ( - GRAPH2SIMPLICIAL_LIFTINGS, - Graph2SimplicialLiftingTransform, - ), +_map_lifting_type_to_transform = { + "graph2cell": Graph2CellLiftingTransform, + "graph2hypergraph": Graph2HypergraphLiftingTransform, + "graph2simplicial": Graph2SimplicialLiftingTransform, } -def _map_lifting_name(lifting_name): - for liftings_dict, Transform in _map_lifting_types.values(): +def _map_lifting_to_transform(lifting_name): + for key, liftings_dict in _map_lifting_type_to_dict.items(): if lifting_name in liftings_dict: - return Transform + return _map_lifting_type_to_transform[key] return LiftingTransform @@ -71,7 +66,7 @@ def __init__(self, transform_name, **kwargs): transform = TRANSFORMS[transform_name](**kwargs) else: LiftingMap_ = TRANSFORMS[transform_name] - Transform = _map_lifting_name(transform_name) + Transform = _map_lifting_to_transform(transform_name) lifting_map_kwargs, transform_kwargs = _route_lifting_kwargs( kwargs, LiftingMap_, Transform ) diff --git a/topobenchmark/transforms/liftings/__init__.py b/topobenchmark/transforms/liftings/__init__.py index 322c43a1..10e1e3c1 100755 --- a/topobenchmark/transforms/liftings/__init__.py +++ b/topobenchmark/transforms/liftings/__init__.py @@ -5,6 +5,7 @@ Graph2ComplexLiftingTransform, Graph2HypergraphLiftingTransform, Graph2SimplicialLiftingTransform, + LiftingMap, LiftingTransform, ) from .graph2cell import GRAPH2CELL_LIFTINGS diff --git a/topobenchmark/transforms/liftings/graph2simplicial/clique.py b/topobenchmark/transforms/liftings/graph2simplicial/clique.py index 04baa1ef..41047a62 100755 --- a/topobenchmark/transforms/liftings/graph2simplicial/clique.py +++ b/topobenchmark/transforms/liftings/graph2simplicial/clique.py @@ -50,7 +50,7 @@ def lift(self, domain): for set_k_simplices in simplices: simplicial_complex.add_simplices_from(list(set_k_simplices)) - # because Complex pads unexisting dimensions with empty matrices + # because ComplexData pads unexisting dimensions with empty matrices simplicial_complex.practical_dim = self.complex_dim return simplicial_complex diff --git a/tutorials/tutorial_lifting.ipynb b/tutorials/tutorial_lifting.ipynb index d1a77003..af533a1b 100644 --- a/tutorials/tutorial_lifting.ipynb +++ b/tutorials/tutorial_lifting.ipynb @@ -56,8 +56,6 @@ "\n", "import lightning as pl\n", "import networkx as nx\n", - "import hydra\n", - "import torch_geometric\n", "from omegaconf import OmegaConf\n", "from topomodelx.nn.simplicial.scn2 import SCN2\n", "from toponetx.classes import SimplicialComplex\n", @@ -72,8 +70,8 @@ "from topobenchmark.nn.readouts import PropagateSignalDown\n", "from topobenchmark.nn.wrappers.simplicial import SCNWrapper\n", "from topobenchmark.optimizer import TBOptimizer\n", - "from topobenchmark.transforms.liftings.graph2simplicial import (\n", - " Graph2SimplicialLifting,\n", + "from topobenchmark.transforms.liftings import (\n", + " LiftingMap,\n", ")" ] }, @@ -101,14 +99,17 @@ " \"data_domain\": \"graph\",\n", " \"data_type\": \"TUDataset\",\n", " \"data_name\": \"MUTAG\",\n", - " \"data_dir\": \"./data/MUTAG/\"}\n", + " \"data_dir\": \"./data/MUTAG/\",\n", + "}\n", "\n", "\n", - "transform_config = { \"clique_lifting\":\n", - " {\"_target_\": \"__main__.SimplicialCliquesLEQLifting\",\n", - " \"transform_name\": \"SimplicialCliquesLEQLifting\",\n", - " \"transform_type\": \"lifting\",\n", - " \"complex_dim\": 3,}\n", + "transform_config = {\n", + " \"clique_lifting\": {\n", + " \"_target_\": \"topobenchmark.transforms.data_transform.DataTransform\",\n", + " \"transform_name\": \"SimplicialCliquesLEQLifting\",\n", + " \"transform_type\": \"lifting\",\n", + " \"complex_dim\": 3,\n", + " }\n", "}\n", "\n", "split_config = {\n", @@ -138,21 +139,19 @@ "}\n", "\n", "loss_config = {\n", - " \"dataset_loss\": \n", - " {\n", - " \"task\": \"classification\", \n", - " \"loss_type\": \"cross_entropy\"\n", - " }\n", + " \"dataset_loss\": {\"task\": \"classification\", \"loss_type\": \"cross_entropy\"}\n", "}\n", "\n", - "evaluator_config = {\"task\": \"classification\",\n", - " \"num_classes\": out_channels,\n", - " \"metrics\": [\"accuracy\", \"precision\", \"recall\"]}\n", + "evaluator_config = {\n", + " \"task\": \"classification\",\n", + " \"num_classes\": out_channels,\n", + " \"metrics\": [\"accuracy\", \"precision\", \"recall\"],\n", + "}\n", "\n", - "optimizer_config = {\"optimizer_id\": \"Adam\",\n", - " \"parameters\":\n", - " {\"lr\": 0.001,\"weight_decay\": 0.0005}\n", - " }\n", + "optimizer_config = {\n", + " \"optimizer_id\": \"Adam\",\n", + " \"parameters\": {\"lr\": 0.001, \"weight_decay\": 0.0005},\n", + "}\n", "\n", "\n", "loader_config = OmegaConf.create(loader_config)\n", @@ -174,6 +173,7 @@ "def wrapper(**factory_kwargs):\n", " def factory(backbone):\n", " return SCNWrapper(backbone, **factory_kwargs)\n", + "\n", " return factory" ] }, @@ -197,16 +197,15 @@ "metadata": {}, "outputs": [], "source": [ - "class SimplicialCliquesLEQLifting(Graph2SimplicialLifting):\n", + "class SimplicialCliquesLEQLifting(LiftingMap):\n", " r\"\"\"Lifts graphs to simplicial complex domain by identifying the cliques as k-simplices. Only the cliques with size smaller or equal to the max complex dimension are considered.\n", - " \n", - " Args:\n", - " kwargs (optional): Additional arguments for the class.\n", " \"\"\"\n", - " def __init__(self, **kwargs):\n", - " super().__init__(**kwargs)\n", + " def __init__(self, complex_dim=2):\n", + " super().__init__()\n", + " self.complex_dim = complex_dim\n", + "\n", "\n", - " def lift_topology(self, data: torch_geometric.data.Data) -> dict:\n", + " def lift(self, domain) -> dict:\n", " r\"\"\"Lifts the topology of a graph to a simplicial complex by identifying the cliques as k-simplices. Only the cliques with size smaller or equal to the max complex dimension are considered.\n", "\n", " Args:\n", @@ -214,11 +213,14 @@ " Returns:\n", " dict: The lifted topology.\n", " \"\"\"\n", - " graph = self._generate_graph_from_data(data)\n", + " graph = domain\n", + "\n", " simplicial_complex = SimplicialComplex(graph)\n", " cliques = nx.find_cliques(graph)\n", - " \n", - " simplices: list[set[tuple[Any, ...]]] = [set() for _ in range(2, self.complex_dim + 1)]\n", + "\n", + " simplices: list[set[tuple[Any, ...]]] = [\n", + " set() for _ in range(2, self.complex_dim + 1)\n", + " ]\n", " for clique in cliques:\n", " if len(clique) <= self.complex_dim + 1:\n", " for i in range(2, self.complex_dim + 1):\n", @@ -227,8 +229,11 @@ "\n", " for set_k_simplices in simplices:\n", " simplicial_complex.add_simplices_from(list(set_k_simplices))\n", + " \n", + " # because ComplexData pads unexisting dimensions with empty matrices\n", + " simplicial_complex.practical_dim = self.complex_dim\n", "\n", - " return self._get_lifted_topology(simplicial_complex, graph)\n" + " return simplicial_complex" ] }, { @@ -251,9 +256,9 @@ "metadata": {}, "outputs": [], "source": [ - "from topobenchmark.transforms import TRANSFORMS\n", + "from topobenchmark.transforms import add_lifting_map\n", "\n", - "TRANSFORMS[\"SimplicialCliquesLEQLifting\"] = SimplicialCliquesLEQLifting" + "add_lifting_map(SimplicialCliquesLEQLifting, \"graph2simplicial\")" ] }, { @@ -275,8 +280,12 @@ "dataset, dataset_dir = graph_loader.load()\n", "\n", "preprocessor = PreProcessor(dataset, dataset_dir, transform_config)\n", - "dataset_train, dataset_val, dataset_test = preprocessor.load_dataset_splits(split_config)\n", - "datamodule = TBDataloader(dataset_train, dataset_val, dataset_test, batch_size=32)" + "dataset_train, dataset_val, dataset_test = preprocessor.load_dataset_splits(\n", + " split_config\n", + ")\n", + "datamodule = TBDataloader(\n", + " dataset_train, dataset_val, dataset_test, batch_size=32\n", + ")" ] }, { @@ -299,12 +308,19 @@ "metadata": {}, "outputs": [], "source": [ - "backbone = SCN2(in_channels_0=dim_hidden,in_channels_1=dim_hidden,in_channels_2=dim_hidden)\n", + "backbone = SCN2(\n", + " in_channels_0=dim_hidden,\n", + " in_channels_1=dim_hidden,\n", + " in_channels_2=dim_hidden,\n", + ")\n", "backbone_wrapper = wrapper(**wrapper_config)\n", "\n", "readout = PropagateSignalDown(**readout_config)\n", "loss = TBLoss(**loss_config)\n", - "feature_encoder = AllCellFeatureEncoder(in_channels=[in_channels, in_channels, in_channels], out_channels=dim_hidden)\n", + "feature_encoder = AllCellFeatureEncoder(\n", + " in_channels=[in_channels, in_channels, in_channels],\n", + " out_channels=dim_hidden,\n", + ")\n", "\n", "evaluator = TBEvaluator(**evaluator_config)\n", "optimizer = TBOptimizer(**optimizer_config)" @@ -316,14 +332,16 @@ "metadata": {}, "outputs": [], "source": [ - "model = TBModel(backbone=backbone,\n", - " backbone_wrapper=backbone_wrapper,\n", - " readout=readout,\n", - " loss=loss,\n", - " feature_encoder=feature_encoder,\n", - " evaluator=evaluator,\n", - " optimizer=optimizer,\n", - " compile=False,)" + "model = TBModel(\n", + " backbone=backbone,\n", + " backbone_wrapper=backbone_wrapper,\n", + " readout=readout,\n", + " loss=loss,\n", + " feature_encoder=feature_encoder,\n", + " evaluator=evaluator,\n", + " optimizer=optimizer,\n", + " compile=False,\n", + ")" ] }, { @@ -386,7 +404,9 @@ ], "source": [ "# Increase the number of epochs to get better results\n", - "trainer = pl.Trainer(max_epochs=50, accelerator=\"cpu\", enable_progress_bar=False)\n", + "trainer = pl.Trainer(\n", + " max_epochs=50, accelerator=\"cpu\", enable_progress_bar=False\n", + ")\n", "\n", "trainer.fit(model, datamodule)\n", "train_metrics = trainer.callback_metrics" @@ -415,9 +435,9 @@ } ], "source": [ - "print(' Training metrics\\n', '-'*26)\n", + "print(\" Training metrics\\n\", \"-\" * 26)\n", "for key in train_metrics:\n", - " print('{:<21s} {:>5.4f}'.format(key+':', train_metrics[key].item()))" + " print(\"{:<21s} {:>5.4f}\".format(key + \":\", train_metrics[key].item()))" ] }, { @@ -505,9 +525,9 @@ } ], "source": [ - "print(' Testing metrics\\n', '-'*25)\n", + "print(\" Testing metrics\\n\", \"-\" * 25)\n", "for key in test_metrics:\n", - " print('{:<20s} {:>5.4f}'.format(key+':', test_metrics[key].item()))" + " print(\"{:<20s} {:>5.4f}\".format(key + \":\", test_metrics[key].item()))" ] }, { From 81df9ac6e05a75d633079dbc03a5d3a094e6534e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 16 Jan 2025 19:48:35 -0800 Subject: [PATCH 27/36] Remove use of lambda func --- topobenchmark/transforms/_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/topobenchmark/transforms/_utils.py b/topobenchmark/transforms/_utils.py index f14d156e..c2e0750c 100644 --- a/topobenchmark/transforms/_utils.py +++ b/topobenchmark/transforms/_utils.py @@ -19,7 +19,9 @@ def discover_objs(package_path, condition=None): Dictionary mapping class names to their corresponding class objects. """ if condition is None: - condition = lambda name, obj: True + + def condition(name, obj): + return True objs = {} From 4f10f7b6125895d11e9dc3f28aa369b789bae139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Tue, 21 Jan 2025 18:05:39 -0800 Subject: [PATCH 28/36] Bump codecov to v5 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20ebf7d8..e6dbd429 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: pytest --cov --cov-report=xml:coverage.xml test/ - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4.0.1 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} file: coverage.xml From 203676eed0259b1fdca191b610cadefad35d2eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 23 Jan 2025 10:34:21 -0800 Subject: [PATCH 29/36] Improve naming and docstrings; remove leftovers; remove redundant/unncessary test --- .../hypergraph/test_HypergraphKHopLifting.py | 9 +--- ...test_HypergraphKNearestNeighborsLifting.py | 14 ------ .../test_SimplicialCliqueLifting.py | 4 -- ...fting.py => test_SimplicialKHopLifting.py} | 2 - topobenchmark/data/utils/adapters.py | 26 +++++----- topobenchmark/data/utils/domain.py | 49 ++++++++++++++++--- .../feature_liftings/concatenation.py | 8 +-- .../transforms/feature_liftings/identity.py | 4 +- .../feature_liftings/projection_sum.py | 2 +- .../transforms/feature_liftings/set.py | 6 +-- topobenchmark/transforms/liftings/base.py | 8 +-- .../liftings/graph2hypergraph/khop.py | 2 - 12 files changed, 67 insertions(+), 67 deletions(-) rename test/transforms/liftings/simplicial/{test_SimplicialNeighborhoodLifting.py => test_SimplicialKHopLifting.py} (99%) diff --git a/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py b/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py index 68326f11..1106a2d7 100644 --- a/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py +++ b/test/transforms/liftings/hypergraph/test_HypergraphKHopLifting.py @@ -21,14 +21,7 @@ def setup_method(self): HypergraphKHopLifting(k_value=2) ) - # TODO: delete? - # NB: `preserve_edge_attr` is never used? therefore they're equivalent - # self.lifting_edge_attr = HypergraphKHopLifting( - # k_value=1, preserve_edge_attr=True - # ) - self.lifting_edge_attr = Graph2HypergraphLiftingTransform( - HypergraphKHopLifting(k_value=1) - ) + self.lifting_edge_attr = self.lifting_k1 def test_lift_topology(self, simple_graph_2): """Test the lift_topology method. diff --git a/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py b/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py index 23dc5d35..95c0ae93 100644 --- a/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py +++ b/test/transforms/liftings/hypergraph/test_HypergraphKNearestNeighborsLifting.py @@ -20,20 +20,6 @@ def setup_method(self): self.lifting_k3 = HypergraphKNNLifting(k_value=3, loop=True) self.lifting_no_loop = HypergraphKNNLifting(k_value=2, loop=False) - def test_initialization(self): - """Test initialization with different parameters.""" - # TODO: overkill, delete? - - # Test default parameters - lifting_default = HypergraphKNNLifting() - assert lifting_default.transform.k == 1 - assert lifting_default.transform.loop is True - - # Test custom parameters - lifting_custom = HypergraphKNNLifting(k_value=5, loop=False) - assert lifting_custom.transform.k == 5 - assert lifting_custom.transform.loop is False - def test_lift_topology_k2(self, simple_graph_2): """Test the lift_topology method with k=2. diff --git a/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py b/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py index a2c32ebf..1e84263b 100644 --- a/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py +++ b/test/transforms/liftings/simplicial/test_SimplicialCliqueLifting.py @@ -216,8 +216,6 @@ def test_lift_topology(self, simple_graph_1): def test_lifted_features_signed(self, simple_graph_1): """Test the lift_features method in signed incidence cases.""" - # TODO: can be removed/moved; part of projection sum - self.data = simple_graph_1 # Test the lift_features method for signed case lifted_data = self.lifting_signed.forward(self.data) @@ -260,8 +258,6 @@ def test_lifted_features_signed(self, simple_graph_1): def test_lifted_features_unsigned(self, simple_graph_1): """Test the lift_features method in unsigned incidence cases.""" - # TODO: redundant. can be moved/removed - self.data = simple_graph_1 # Test the lift_features method for unsigned case lifted_data = self.lifting_unsigned.forward(self.data) diff --git a/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py b/test/transforms/liftings/simplicial/test_SimplicialKHopLifting.py similarity index 99% rename from test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py rename to test/transforms/liftings/simplicial/test_SimplicialKHopLifting.py index 6a81d9f2..c46890ec 100644 --- a/test/transforms/liftings/simplicial/test_SimplicialNeighborhoodLifting.py +++ b/test/transforms/liftings/simplicial/test_SimplicialKHopLifting.py @@ -10,8 +10,6 @@ SimplicialKHopLifting, ) -# TODO: rename for consistency? - class TestSimplicialKHopLifting: """Test the SimplicialKHopLifting class.""" diff --git a/topobenchmark/data/utils/adapters.py b/topobenchmark/data/utils/adapters.py index b049d49c..f4e520b3 100644 --- a/topobenchmark/data/utils/adapters.py +++ b/topobenchmark/data/utils/adapters.py @@ -38,7 +38,7 @@ def adapt(self, domain): return domain -class Data2NxGraph(Adapter): +class Data2Graph(Adapter): """Data to nx.Graph adaptation. Parameters @@ -115,10 +115,10 @@ def adapt(self, domain: torch_geometric.data.Data) -> nx.Graph: return graph -class TnxComplex2ComplexData(Adapter): - """toponetx.Complex to Complex adaptation. +class Complex2ComplexData(Adapter): + """toponetx.Complex to ComplexData adaptation. - NB: order of features plays a crucial role, as ``Complex`` + NB: order of features plays a crucial role, as ``ComplexData`` simply stores them as lists (i.e. the reference to the indices of the simplex are lost). @@ -144,7 +144,7 @@ def __init__( self.transfer_features = transfer_features def adapt(self, domain): - """Adapt toponetx.Complex to Complex. + """Adapt toponetx.Complex to ComplexData. Parameters ---------- @@ -152,10 +152,8 @@ def adapt(self, domain): Returns ------- - Complex + ComplexData """ - # NB: just a slightly rewriting of get_complex_connectivity - practical_dim = ( domain.practical_dim if hasattr(domain, "practical_dim") @@ -221,7 +219,6 @@ def adapt(self, domain): else: raise ValueError("Can't transfer features.") - # TODO: confirm features are in the right order; update this data["features"] = [] for rank in range(dim + 1): rank_features_dict = get_features("features", rank) @@ -243,7 +240,7 @@ class ComplexData2Dict(Adapter): """ComplexData to dict adaptation.""" def adapt(self, domain): - """Adapt Complex to dict. + """Adapt ComplexData to dict. Parameters ---------- @@ -267,7 +264,6 @@ def adapt(self, domain): for rank, rank_info in enumerate(info): data[f"{connectivity_info}_{rank}"] = rank_info - # TODO: handle neighborhoods data["shape"] = domain.shape for index, values in enumerate(domain.features): @@ -291,7 +287,7 @@ def adapt(self, domain): ------- dict """ - hyperedges_key = domain.keys()[-1] + hyperedges_key = domain.rank_keys()[-1] return { "incidence_hyperedges": domain.incidence[hyperedges_key], "num_hyperedges": domain.num_hyperedges, @@ -301,6 +297,8 @@ def adapt(self, domain): class AdapterComposition(Adapter): + """Composed adapter.""" + def __init__(self, adapters): super().__init__() self.adapters = adapters @@ -313,7 +311,7 @@ def adapt(self, domain): return domain -class TnxComplex2Dict(AdapterComposition): +class Complex2Dict(AdapterComposition): """toponetx.Complex to dict adaptation. Parameters @@ -332,7 +330,7 @@ def __init__( signed=False, transfer_features=True, ): - tnxcomplex2complex = TnxComplex2ComplexData( + tnxcomplex2complex = Complex2ComplexData( neighborhoods=neighborhoods, signed=signed, transfer_features=transfer_features, diff --git a/topobenchmark/data/utils/domain.py b/topobenchmark/data/utils/domain.py index 57790162..1f14467d 100644 --- a/topobenchmark/data/utils/domain.py +++ b/topobenchmark/data/utils/domain.py @@ -2,13 +2,23 @@ class Data(abc.ABC): + """Topological data. + + Parameters + ---------- + incidence : collection[array-like] + Incidence matrices. + features : collection[array-like] + Features. + """ + def __init__(self, incidence, features): self.incidence = incidence self.features = features @abc.abstractmethod - def keys(self): - pass + def rank_keys(self): + """Keys to access different rank information.""" def update_features(self, rank, values): """Update features. @@ -34,11 +44,13 @@ def shape(self): None if self.incidence[key] is None else self.incidence[key].shape[-1] - for key in self.keys() + for key in self.rank_keys() ] class ComplexData(Data): + """Complex.""" + def __init__( self, incidence, @@ -59,7 +71,6 @@ def __init__( features = [None for _ in range(len(incidence))] else: for rank, incidence_ in enumerate(incidence): - # TODO: make error message more informative if ( features[rank] is not None and features[rank].shape[0] != incidence_.shape[-1] @@ -68,15 +79,22 @@ def __init__( super().__init__(incidence, features) - def keys(self): + def rank_keys(self): + """Keys to access different rank information. + + Returns + ------- + list[int] + """ return list(range(len(self.incidence))) class HypergraphData(Data): + """Hypergraph.""" + def __init__( self, incidence_hyperedges, - num_hyperedges, incidence_0=None, x_0=None, x_hyperedges=None, @@ -91,7 +109,22 @@ def __init__( self._hyperedges_key: x_hyperedges, } super().__init__(incidence, features) - self.num_hyperedges = num_hyperedges - def keys(self): + @property + def num_hyperedges(self): + """Number of hyperedges. + + Returns + ------- + int + """ + return self.incidence[self._hyperedges_key].shape[1] + + def rank_keys(self): + """Keys to access different rank information. + + Returns + ------- + list[int] + """ return [0, self._hyperedges_key] diff --git a/topobenchmark/transforms/feature_liftings/concatenation.py b/topobenchmark/transforms/feature_liftings/concatenation.py index 44e3b192..d147c392 100644 --- a/topobenchmark/transforms/feature_liftings/concatenation.py +++ b/topobenchmark/transforms/feature_liftings/concatenation.py @@ -16,16 +16,16 @@ def lift_features(self, domain): Parameters ---------- - data : Complex + data : Data The input data to be lifted. Returns ------- - Complex + Data Domain with the lifted features. """ for key, next_key in zip( - domain.keys(), domain.keys()[1:], strict=False + domain.rank_keys(), domain.rank_keys()[1:], strict=False ): if domain.features[next_key] is not None: continue @@ -44,7 +44,7 @@ def lift_features(self, domain): idxs = torch.stack(idxs_list, dim=0) values = domain.features[key][idxs].view(n, -1) else: - # NB: only works if key represents rank + # NB: only works if key is an int representing rank m = domain.features[key].shape[1] * (next_key + 1) values = torch.zeros([0, m]) diff --git a/topobenchmark/transforms/feature_liftings/identity.py b/topobenchmark/transforms/feature_liftings/identity.py index e640bd06..4128af1a 100644 --- a/topobenchmark/transforms/feature_liftings/identity.py +++ b/topobenchmark/transforms/feature_liftings/identity.py @@ -3,11 +3,9 @@ from topobenchmark.transforms.feature_liftings.base import FeatureLiftingMap -class Identity(FeatureLiftingMap): +class IdentityFeatureLifting(FeatureLiftingMap): """Identity feature lifting map.""" - # TODO: rename to IdentityFeatureLifting - def lift_features(self, domain): """Lift features of a domain using identity map.""" return domain diff --git a/topobenchmark/transforms/feature_liftings/projection_sum.py b/topobenchmark/transforms/feature_liftings/projection_sum.py index 757234a7..cc1b77e7 100644 --- a/topobenchmark/transforms/feature_liftings/projection_sum.py +++ b/topobenchmark/transforms/feature_liftings/projection_sum.py @@ -22,7 +22,7 @@ def lift_features(self, domain): Domain with the lifted features. """ for key, next_key in zip( - domain.keys(), domain.keys()[1:], strict=False + domain.rank_keys(), domain.rank_keys()[1:], strict=False ): if domain.features[next_key] is not None: continue diff --git a/topobenchmark/transforms/feature_liftings/set.py b/topobenchmark/transforms/feature_liftings/set.py index 54ac1b9d..db5d2c93 100644 --- a/topobenchmark/transforms/feature_liftings/set.py +++ b/topobenchmark/transforms/feature_liftings/set.py @@ -16,16 +16,16 @@ def lift_features(self, domain): Parameters ---------- - data : Complex + data : Data The input data to be lifted. Returns ------- - Complex + Data Domain with the lifted features. """ for key, next_key in zip( - domain.keys(), domain.keys()[1:], strict=False + domain.rank_keys(), domain.rank_keys()[1:], strict=False ): if domain.features[next_key] is not None: continue diff --git a/topobenchmark/transforms/liftings/base.py b/topobenchmark/transforms/liftings/base.py index 13f1f443..5709f0a1 100644 --- a/topobenchmark/transforms/liftings/base.py +++ b/topobenchmark/transforms/liftings/base.py @@ -5,11 +5,11 @@ import torch_geometric from topobenchmark.data.utils import ( + Complex2ComplexData, ComplexData2Dict, - Data2NxGraph, + Data2Graph, HypergraphData2Dict, IdentityAdapter, - TnxComplex2ComplexData, ) @@ -125,8 +125,8 @@ def __init__( super().__init__( lifting, feature_lifting=feature_lifting, - data2domain=Data2NxGraph(preserve_edge_attr), - domain2domain=TnxComplex2ComplexData( + data2domain=Data2Graph(preserve_edge_attr), + domain2domain=Complex2ComplexData( neighborhoods=neighborhoods, signed=signed, transfer_features=transfer_features, diff --git a/topobenchmark/transforms/liftings/graph2hypergraph/khop.py b/topobenchmark/transforms/liftings/graph2hypergraph/khop.py index 7c56006c..26b297ff 100755 --- a/topobenchmark/transforms/liftings/graph2hypergraph/khop.py +++ b/topobenchmark/transforms/liftings/graph2hypergraph/khop.py @@ -71,10 +71,8 @@ def lift(self, data: torch_geometric.data.Data) -> dict: ) incidence_1[n, neighbors] = 1 - num_hyperedges = incidence_1.shape[1] incidence_1 = torch.Tensor(incidence_1).to_sparse_coo() return HypergraphData( incidence_hyperedges=incidence_1, - num_hyperedges=num_hyperedges, x_0=data.x, ) From fd9e73177f7ff5b3b5b6f843176c62d3ecb5c9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 23 Jan 2025 12:26:50 -0800 Subject: [PATCH 30/36] Allow for data2domain to be passed to Graph2ComplexLiftingTransform --- topobenchmark/transforms/liftings/base.py | 16 ++++++++++++---- .../liftings/graph2simplicial/clique.py | 2 +- .../transforms/liftings/graph2simplicial/khop.py | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/topobenchmark/transforms/liftings/base.py b/topobenchmark/transforms/liftings/base.py index 5709f0a1..d892db71 100644 --- a/topobenchmark/transforms/liftings/base.py +++ b/topobenchmark/transforms/liftings/base.py @@ -41,13 +41,13 @@ def __init__( domain2domain=None, feature_lifting="ProjectionSum", ): - if data2domain is None: + if data2domain is None or data2domain == "Identity": data2domain = IdentityAdapter() - if domain2dict is None: + if domain2dict is None or data2domain == "Identity": domain2dict = IdentityAdapter() - if domain2domain is None: + if domain2domain is None or data2domain == "Identity": domain2domain = IdentityAdapter() if isinstance(lifting, str): @@ -105,12 +105,16 @@ class Graph2ComplexLiftingTransform(LiftingTransform): Feature lifting map. preserve_edge_attr : bool Whether to preserve edge attributes. + Ignored if ``data2domain`` is not None. neighborhoods : list, optional List of neighborhoods of interest. signed : bool, optional If True, returns signed connectivity matrices. transfer_features : bool, optional Whether to transfer features. + data2domain : Converter + Conversion between ``torch_geometric.Data`` into + domain for consumption by lifting. """ def __init__( @@ -121,11 +125,15 @@ def __init__( neighborhoods=None, signed=False, transfer_features=True, + data2domain=None, ): + if data2domain is None: + data2domain = Data2Graph(preserve_edge_attr) + super().__init__( lifting, feature_lifting=feature_lifting, - data2domain=Data2Graph(preserve_edge_attr), + data2domain=data2domain, domain2domain=Complex2ComplexData( neighborhoods=neighborhoods, signed=signed, diff --git a/topobenchmark/transforms/liftings/graph2simplicial/clique.py b/topobenchmark/transforms/liftings/graph2simplicial/clique.py index 41047a62..448ae33a 100755 --- a/topobenchmark/transforms/liftings/graph2simplicial/clique.py +++ b/topobenchmark/transforms/liftings/graph2simplicial/clique.py @@ -34,7 +34,7 @@ def lift(self, domain): Returns ------- - toponetx.Complex + toponetx.SimplicialComplex Lifted simplicial complex. """ graph = domain diff --git a/topobenchmark/transforms/liftings/graph2simplicial/khop.py b/topobenchmark/transforms/liftings/graph2simplicial/khop.py index dc9e13e2..b4fb8f6e 100755 --- a/topobenchmark/transforms/liftings/graph2simplicial/khop.py +++ b/topobenchmark/transforms/liftings/graph2simplicial/khop.py @@ -46,7 +46,7 @@ def lift(self, domain): Returns ------- - toponetx.Complex + toponetx.SimplicialComplex Lifted simplicial complex. """ graph = domain From c83a584dd04d4c51e6a78087bf2397da025e7156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Fri, 24 Jan 2025 12:14:18 -0800 Subject: [PATCH 31/36] Simplify Data2GraphAdapter --- topobenchmark/data/utils/adapters.py | 81 +++++++------------ topobenchmark/transforms/liftings/base.py | 19 ++++- .../liftings/graph2simplicial/khop.py | 3 + 3 files changed, 47 insertions(+), 56 deletions(-) diff --git a/topobenchmark/data/utils/adapters.py b/topobenchmark/data/utils/adapters.py index f4e520b3..99b375dc 100644 --- a/topobenchmark/data/utils/adapters.py +++ b/topobenchmark/data/utils/adapters.py @@ -6,7 +6,7 @@ import torch_geometric from topomodelx.utils.sparse import from_sparse from toponetx.classes import CellComplex, SimplicialComplex -from torch_geometric.utils.undirected import is_undirected, to_undirected +from torch_geometric.utils.convert import to_networkx from topobenchmark.data.utils.domain import ComplexData from topobenchmark.data.utils.utils import ( @@ -45,25 +45,21 @@ class Data2Graph(Adapter): ---------- preserve_edge_attr : bool Whether to preserve edge attributes. + to_undirected (bool or str, optional): If set to :obj:`True`, will + return a :class:`networkx.Graph` instead of a + :class:`networkx.DiGraph`. + By default, will include all edges and make them undirected. + If set to :obj:`"upper"`, the undirected graph will only correspond + to the upper triangle of the input adjacency matrix. + If set to :obj:`"lower"`, the undirected graph will only correspond + to the lower triangle of the input adjacency matrix. + Only applicable in case the :obj:`data` object holds a homogeneous + graph. (default: :obj:`False`) """ - def __init__(self, preserve_edge_attr=False): + def __init__(self, preserve_edge_attr=False, to_undirected=True): self.preserve_edge_attr = preserve_edge_attr - - def _data_has_edge_attr(self, data: torch_geometric.data.Data) -> bool: - r"""Check if the input data object has edge attributes. - - Parameters - ---------- - data : torch_geometric.data.Data - The input data. - - Returns - ------- - bool - Whether the data object has edge attributes. - """ - return hasattr(data, "edge_attr") and data.edge_attr is not None + self.to_undirected = to_undirected def adapt(self, domain: torch_geometric.data.Data) -> nx.Graph: r"""Generate a NetworkX graph from the input data object. @@ -78,41 +74,17 @@ def adapt(self, domain: torch_geometric.data.Data) -> nx.Graph: nx.Graph The generated NetworkX graph. """ - # Check if data object have edge_attr, return list of tuples as [(node_id, {'features':data}, 'dim':1)] or ?? - nodes = [ - (n, dict(features=domain.x[n], dim=0)) - for n in range(domain.x.shape[0]) - ] - - if self.preserve_edge_attr and self._data_has_edge_attr(domain): - # In case edge features are given, assign features to every edge - # TODO: confirm this is the desired behavior - if is_undirected(domain.edge_index, domain.edge_attr): - edge_index, edge_attr = (domain.edge_index, domain.edge_attr) - else: - edge_index, edge_attr = to_undirected( - domain.edge_index, domain.edge_attr - ) - - edges = [ - (i.item(), j.item(), dict(features=edge_attr[edge_idx], dim=1)) - for edge_idx, (i, j) in enumerate( - zip(edge_index[0], edge_index[1], strict=False) - ) - ] - - else: - # If edge_attr is not present, return list list of edges - edges = [ - (i.item(), j.item(), {}) - for i, j in zip( - domain.edge_index[0], domain.edge_index[1], strict=False - ) - ] - graph = nx.Graph() - graph.add_nodes_from(nodes) - graph.add_edges_from(edges) - return graph + edge_attrs = ( + "edge_attr" + if self.preserve_edge_attr and hasattr(domain, "edge_attr") + else None + ) + return to_networkx( + domain, + to_undirected=self.to_undirected, + node_attrs="x", + edge_attrs=edge_attrs, + ) class Complex2ComplexData(Adapter): @@ -142,6 +114,7 @@ def __init__( self.neighborhoods = neighborhoods self.signed = signed self.transfer_features = transfer_features + self._features_key = "x" def adapt(self, domain): """Adapt toponetx.Complex to ComplexData. @@ -221,9 +194,9 @@ def adapt(self, domain): data["features"] = [] for rank in range(dim + 1): - rank_features_dict = get_features("features", rank) + rank_features_dict = get_features(self._features_key, rank) if rank_features_dict: - rank_features = torch.stack( + rank_features = torch.tensor( list(rank_features_dict.values()) ) else: diff --git a/topobenchmark/transforms/liftings/base.py b/topobenchmark/transforms/liftings/base.py index d892db71..c78ab49c 100644 --- a/topobenchmark/transforms/liftings/base.py +++ b/topobenchmark/transforms/liftings/base.py @@ -39,7 +39,7 @@ def __init__( data2domain=None, domain2dict=None, domain2domain=None, - feature_lifting="ProjectionSum", + feature_lifting=None, ): if data2domain is None or data2domain == "Identity": data2domain = IdentityAdapter() @@ -55,6 +55,9 @@ def __init__( lifting = TRANSFORMS[lifting]() + if feature_lifting is None: + feature_lifting = "IdentityFeatureLifting" + if isinstance(feature_lifting, str): from topobenchmark.transforms import TRANSFORMS @@ -106,6 +109,17 @@ class Graph2ComplexLiftingTransform(LiftingTransform): preserve_edge_attr : bool Whether to preserve edge attributes. Ignored if ``data2domain`` is not None. + to_undirected (bool or str, optional): If set to :obj:`True`, will + return a :class:`networkx.Graph` instead of a + :class:`networkx.DiGraph`. + By default, will include all edges and make them undirected. + If set to :obj:`"upper"`, the undirected graph will only correspond + to the upper triangle of the input adjacency matrix. + If set to :obj:`"lower"`, the undirected graph will only correspond + to the lower triangle of the input adjacency matrix. + Only applicable in case the :obj:`data` object holds a homogeneous + graph. (default: :obj:`False`) + Ignored if ``data2domain`` is not None. neighborhoods : list, optional List of neighborhoods of interest. signed : bool, optional @@ -122,13 +136,14 @@ def __init__( lifting, feature_lifting="ProjectionSum", preserve_edge_attr=False, + to_undirected=True, neighborhoods=None, signed=False, transfer_features=True, data2domain=None, ): if data2domain is None: - data2domain = Data2Graph(preserve_edge_attr) + data2domain = Data2Graph(preserve_edge_attr, to_undirected) super().__init__( lifting, diff --git a/topobenchmark/transforms/liftings/graph2simplicial/khop.py b/topobenchmark/transforms/liftings/graph2simplicial/khop.py index b4fb8f6e..c70cf64d 100755 --- a/topobenchmark/transforms/liftings/graph2simplicial/khop.py +++ b/topobenchmark/transforms/liftings/graph2simplicial/khop.py @@ -79,4 +79,7 @@ def lift(self, domain): list_k_simplices = list_k_simplices[: self.max_k_simplices] simplicial_complex.add_simplices_from(list_k_simplices) + # because ComplexData pads unexisting dimensions with empty matrices + simplicial_complex.practical_dim = self.complex_dim + return simplicial_complex From 7aefd87bf4a94ac05b4e92f6e1fff59864b00322 Mon Sep 17 00:00:00 2001 From: Jonas-Verhellen Date: Tue, 28 May 2024 15:14:00 +0200 Subject: [PATCH 32/36] Initial commit of neighborhood cell code --- .../graph2cell/neighborhood_lifting.yaml | 5 + .../graph2cell/neighborhood_lifting.py | 62 +++ .../liftings/graph2cell/test_neighborhood.py | 41 ++ .../graph2cell/neighborhood_lifting.ipynb | 360 ++++++++++++++++++ 4 files changed, 468 insertions(+) create mode 100644 configs/transforms/liftings/graph2cell/neighborhood_lifting.yaml create mode 100755 modules/transforms/liftings/graph2cell/neighborhood_lifting.py create mode 100644 test/transforms/liftings/graph2cell/test_neighborhood.py create mode 100644 tutorials/graph2cell/neighborhood_lifting.ipynb diff --git a/configs/transforms/liftings/graph2cell/neighborhood_lifting.yaml b/configs/transforms/liftings/graph2cell/neighborhood_lifting.yaml new file mode 100644 index 00000000..4eb6790d --- /dev/null +++ b/configs/transforms/liftings/graph2cell/neighborhood_lifting.yaml @@ -0,0 +1,5 @@ +transform_type: 'lifting' +transform_name: "CellCycleLifting" +max_cell_length: null +preserve_edge_attr: False +feature_lifting: ProjectionSum diff --git a/modules/transforms/liftings/graph2cell/neighborhood_lifting.py b/modules/transforms/liftings/graph2cell/neighborhood_lifting.py new file mode 100755 index 00000000..1d58024a --- /dev/null +++ b/modules/transforms/liftings/graph2cell/neighborhood_lifting.py @@ -0,0 +1,62 @@ +import networkx as nx +import torch_geometric +from toponetx.classes import CellComplex + +from modules.transforms.liftings.graph2cell.base import Graph2CellLifting + + +class NeighborhoodLifting(Graph2CellLifting): + r"""Lifts graphs to cell complexes by identifying the cycles as 2-cells. + + Parameters + ---------- + max_cell_length : int, optional + The maximum length of the cycles to be lifted. Default is None. + **kwargs : optional + Additional arguments for the class. + """ + + def __init__(self, max_cell_length=None, **kwargs): + super().__init__(**kwargs) + self.complex_dim = 2 + self.max_cell_length = max_cell_length + + def lift_topology(self, data: torch_geometric.data.Data) -> dict: + r"""Finds the cycles of a graph and lifts them to 2-cells. + + Parameters + ---------- + data : torch_geometric.data.Data + The input data to be lifted. + + Returns + ------- + dict + The lifted topology. + """ + + G = self._generate_graph_from_data(data) + + cell_complex = CellComplex(G) + + vertices = list(G.nodes()) + for v in vertices: + cell_complex.add_node(v, rank=0) + + edges = list(G.edges()) + for edge in edges: + cell_complex.add_cell(edge, rank=1) + + for v in vertices: + neighbors = list(G.neighbors(v)) + if len(neighbors) > 1: + two_cell = [v] + neighbors + if ( + self.max_cell_length is not None + and len(two_cell) > self.max_cell_length + ): + pass + else: + cell_complex.add_cell(two_cell, rank=2) + + return self._get_lifted_topology(cell_complex, G) diff --git a/test/transforms/liftings/graph2cell/test_neighborhood.py b/test/transforms/liftings/graph2cell/test_neighborhood.py new file mode 100644 index 00000000..16f07315 --- /dev/null +++ b/test/transforms/liftings/graph2cell/test_neighborhood.py @@ -0,0 +1,41 @@ +"""Test the message passing module.""" + +import torch + +from modules.data.utils.utils import load_manual_graph +from modules.transforms.liftings.graph2cell.neighborhood_lifting import ( + NeighborhoodLifting, +) + + +class TestCellCyclesLifting: + """Test the NeighborhoodLifting class.""" + + def setup_method(self): + # Load the graph + self.data = load_manual_graph() + + # Initialise the NeighborhoodLifting class + self.lifting = NeighborhoodLifting() + + def test_lift_topology(self): + # Test the lift_topology method + lifted_data = self.lifting.forward(self.data.clone()) + + U, S, V = torch.svd(lifted_data.incidence_1.to_dense()) + expected_incidence_1_singular_values = torch.tensor( + [3.4431, 2.4495, 2.4495, 2.3984, 2.2361, 2.2050, 1.9275, 1.6779] + ) + + assert ( + expected_incidence_1_singular_values == S + ).all(), "Something is wrong with incidence_1." + + U, S, V = torch.svd(lifted_data.incidence_2.to_dense()) + expected_incidence_2_singular_values = torch.tensor( + [3.8155, 3.0758, 2.5256, 2.3475, 1.8136, 1.5562, 1.3854, 1.2090] + ) + + assert ( + expected_incidence_2_singular_values == S + ).all(), "Something is wrong with incidence_2." diff --git a/tutorials/graph2cell/neighborhood_lifting.ipynb b/tutorials/graph2cell/neighborhood_lifting.ipynb new file mode 100644 index 00000000..f9f4ab07 --- /dev/null +++ b/tutorials/graph2cell/neighborhood_lifting.ipynb @@ -0,0 +1,360 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Graph-to-Cell Neighborhood Lifting Tutorial" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "***\n", + "This notebook shows how to import a dataset, with the desired lifting, and how to run a neural network using the loaded data.\n", + "\n", + "The notebook is divided into sections:\n", + "\n", + "- [Loading the dataset](#loading-the-dataset) loads the config files for the data and the desired tranformation, createsa a dataset object and visualizes it.\n", + "- [Loading and applying the lifting](#loading-and-applying-the-lifting) defines a simple neural network to test that the lifting creates the expected incidence matrices.\n", + "- [Create and run a simplicial nn model](#create-and-run-a-simplicial-nn-model) simply runs a forward pass of the model to check that everything is working as expected.\n", + "\n", + "***\n", + "***\n", + "\n", + "Note that for simplicity the notebook is setup to use a simple graph. However, there is a set of available datasets that you can play with.\n", + "\n", + "To switch to one of the available datasets, simply change the *dataset_name* variable in [Dataset config](#dataset-config) to one of the following names:\n", + "\n", + "* cocitation_cora\n", + "* cocitation_citeseer\n", + "* cocitation_pubmed\n", + "* MUTAG\n", + "* NCI1\n", + "* NCI109\n", + "* PROTEINS_TU\n", + "* AQSOL\n", + "* ZINC\n", + "***" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Imports and utilities" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], + "source": [ + "# With this cell any imported module is reloaded before each cell execution\n", + "%load_ext autoreload\n", + "%autoreload 2\n", + "from modules.data.load.loaders import GraphLoader\n", + "from modules.data.preprocess.preprocessor import PreProcessor\n", + "from modules.utils.utils import (\n", + " describe_data,\n", + " load_dataset_config,\n", + " load_model_config,\n", + " load_transform_config,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Loading the dataset" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here we just need to spicify the name of the available dataset that we want to load. First, the dataset config is read from the corresponding yaml file (located at `/configs/datasets/` directory), and then the data is loaded via the implemented `Loaders`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Dataset configuration for manual_dataset:\n", + "\n", + "{'data_domain': 'graph',\n", + " 'data_type': 'toy_dataset',\n", + " 'data_name': 'manual',\n", + " 'data_dir': 'datasets/graph/toy_dataset',\n", + " 'num_features': 1,\n", + " 'num_classes': 2,\n", + " 'task': 'classification',\n", + " 'loss_type': 'cross_entropy',\n", + " 'monitor_metric': 'accuracy',\n", + " 'task_level': 'node'}\n" + ] + } + ], + "source": [ + "dataset_name = \"manual_dataset\"\n", + "dataset_config = load_dataset_config(dataset_name)\n", + "loader = GraphLoader(dataset_config)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can then access to the data through the `load()`method:" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Dataset only contains 1 sample:\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " - Graph with 8 vertices and 13 edges.\n", + " - Features dimensions: [1, 0]\n", + " - There are 0 isolated nodes.\n", + "\n" + ] + } + ], + "source": [ + "dataset = loader.load()\n", + "describe_data(dataset)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Loading and Applying the Lifting" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this section we will instantiate the lifting we want to apply to the data. For this example the cycle lifting was chosen. The algorithm finds a cycle base for the graph and creates a cell for each cycle in said base. This is a connectivity based deterministic lifting that preserves the initial connectivity of the graph. [[1]](https://arxiv.org/abs/2309.01632) combine two heuristics to design an algorithm that selects cycle basis in $O(m \\log m)$ time, where $m$ is the number of edges of the graph.\n", + "\n", + "***\n", + "[[1]](https://arxiv.org/abs/2309.01632) Hoppe, J., & Schaub, M. T. (2024). Representing Edge Flows on Graphs via Sparse Cell\n", + "Complexes. In Learning on Graphs Conference (pp. 1-1). PMLR.\n", + "***\n", + "For cell complexes creating a lifting involves creating a `CellComplex` object from topomodelx and adding cells to it using the method `add_cells_from`. The `CellComplex` class then takes care of creating all the needed matrices.\n", + "\n", + "Similarly to before, we can specify the transformation we want to apply through its type and id --the correxponding config files located at `/configs/transforms`. \n", + "\n", + "Note that the *tranform_config* dictionary generated below can contain a sequence of tranforms if it is needed.\n", + "\n", + "This can also be used to explore liftings from one topological domain to another, for example using two liftings it is possible to achieve a sequence such as: graph -> cell complex -> hypergraph. " + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Transform configuration for graph2cell/neighborhood_lifting:\n", + "\n", + "{'transform_type': 'lifting',\n", + " 'transform_name': 'CellCycleLifting',\n", + " 'max_cell_length': None,\n", + " 'preserve_edge_attr': False,\n", + " 'feature_lifting': 'ProjectionSum'}\n" + ] + } + ], + "source": [ + "# Define transformation type and id\n", + "transform_type = \"liftings\"\n", + "# If the transform is a topological lifting, it should include both the type of the lifting and the identifier\n", + "transform_id = \"graph2cell/neighborhood_lifting\"\n", + "\n", + "# Read yaml file\n", + "transform_config = {\n", + " \"lifting\": load_transform_config(transform_type, transform_id)\n", + " # other transforms (e.g. data manipulations, feature liftings) can be added here\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We than apply the transform via our `PreProcesor`:" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Transform parameters are the same, using existing data_dir: /home/jonasver/Documents/Code/challenge-icml-2024/datasets/graph/toy_dataset/manual/lifting/1820307683\n", + "\n", + "Dataset only contains 1 sample:\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAgcAAAIbCAYAAAB/tT3bAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy80BEi2AAAACXBIWXMAAA9hAAAPYQGoP6dpAACaLklEQVR4nOzdd3gc53nv/e/MVvTG3jtBgARBsBewAqAk02okJUuWI8eWpRPFKU4c6TjHJ7Fsp1h2YsfOcSL5JE5e6yQWSdmyTTVWAAt2gp2U2HsDiI4Fts7z/rEESFDo2MUsgPujC5e0i9mZGwsI88NTNaWUQgghhBDiLt3sAoQQQggRXSQcCCGEEKIFCQdCCCGEaEHCgRBCCCFakHAghBBCiBYkHAghhBCiBQkHQgghhGhBwoEQQgghWpBwICLi1VdfJSUlpd1jZs+ezUsvvdTiuW3btjFx4kQ0TePVV18NSy2HDh1C07ROH//SSy+RkpKCpmlMnDiRl156iQsXLoSllvbk5+fz+uuvR+z8rb3fZtTR1jXD9f3urK7+XETag++BGe+JEE0kHAjTfOMb32D9+vXNj6urq1m/fj0bN25EKcU3vvGNXq3nwoULTJw4kQsXLrBx40aqqqp44403qKysZNOmTb1aSyQ8+H6LjjX9TKakpDBx4kS5WYsBw2p2AWLgWrduXYvH27ZtIzU1lZycHACSk5PZtm0bL730EufPn494PevXr2fChAls3bq1+bm8vDzy8vIifu3e0Nr73VvvbV/1la98hfz8fH72s59x8ODB5lakjRs3ml2aEBEl4UAIYNOmTRw6dEhulKLZhQsXqK6u5sUXXwRCQfGNN94gPz+f6upqkpOTzS1QiAiSbgVhmvv7VF999VXWr1/PhQsX0DSNl156ifXr15Ofn9/8nKZpVFdXN7++aWzAxIkTefPNN5ufr66uJj8/H03TmD17Ntu2beuwlrfffpucnBwmTJjQ4bHr16/nzTff5M0332TixInN59+0aROzZ89uHqvwYFfESy+9xKuvvtpcd0pKyqeOqaioaPF1tVf7+vXrW4whaOpDv398RNP7Ci3f747e267Ucb/XX3+9eczIg+/9q6++ysSJE0lJSenU2If2jm/rewDh+7mYMGECb7zxRovn5syZA8DBgwfbfW1770Nb9XWkaRxPV36uheg2JUQEvPLKKyo5ObndY/Ly8tQrr7zS/Hjjxo1qwoQJLY5p7TmllFq3bp3Ky8tTVVVV6vz58yo5OVmVlpY2nzcnJ0edP39eVVVVqXXr1qmOftRzcnLUiy++2Kmvren8EyZMUBs3bmx+/o033miuYevWrQpoftz0uuTkZLV161ZVVVWlXnzxRQWo8+fPN38eaP78unXrWv3a739v7n+PX3nlFTVhwgT1ve99r/m5+2vszPvdnTqavPjiiyonJ0eVlpaqqqoqtXXr1uZrN32/7v+e5OXltbjm/bV15vjWvgfh/rl4UNP3taqqqkfvQ1v13f8e3P9469atasKECc3X3bp1a4ufLSHCTcKBiIhIhoPz589/6hf0G2+8oV555ZXmzzXdcJVSqrS0tMObwIQJE1rU0lHdycnJ7d4gms55/436wa/3wes23byaNN2I2lJVVdXia83JyVHf+973mm+iTZ9vqrMr4aArdbRWy/2a3v8H36+moPRgbZ09/sHvQSR+Lh7U2vfwfu29D+3V19q573/8YBAUItKkW0H0OYcOHQJg/Pjxzc3zr776KocOHeLQoUMkJyd3qnvgfhMmTGg+b5P169c3N7nn5+e3+FxeXl6rfc5vvvkm69evZ/bs2Z2a/piXl9fiuKZma4DU1NR2X5ucnExOTk5z8/KFCxd48cUXmx9v2LCBCRMmdKtvvCt1QGhwY1vv+8GDB1utY86cOS0Gf3b1+Ae/B5H4ubjf+vXrycnJ4Xvf+16bx7T3PrRXX0fy8vJITU1t/lnsD7NnRHSTcCD6pJycHKqqqlp8tHaj6az8/Hy2bdvWot+9aUrlK6+88qnjW/vlP3v2bDZu3MhLL71EaWlp86yLrujqjTwvL4+tW7eybdu25ptlU9DZunXrp2YoRKqO9tz/nobz+Na+B+H+uWjSNJPl/mDQNL6k6aMza0N0t77k5GTOnz/PG2+8QXJyMuvXr+/1tSjEwCLhQPQ5OTk5HDp0qNWbyIQJE6iuru7yokWvvPIKEyZM6PY89gsXLjTfkLsy9XHbtm3MnTu3W9cEePrpp9m2bRtbt25tbt1Yt24d27ZtY9u2bZ9q8YiUnJycNt/3ptaRB79fBw8ebPVr7+rx99cQ7p8LuDd488EWg9LSUlSoa7Y5RLb3PrRXX2e9+OKLbNy4kTfeeIO333672+cRoiMSDkRUmzBhQvONYtu2bVy4cIEJEybw4osvNs9ugNBMgddff52cnBxycnJYv3598y/pr3zlK5261saNG9mwYQPr169v/iV+6NChTt1Qmprem0afN02NfNCbb77ZfO6m+pumynVH081o06ZNzaEkPz+ft99+m+rq6naDSmvvbXc9+D1pqunVV18lJyeHvLw8Vq1a1fy5pr/EW2vZ6OrxbdUAPf+5WL9+PXPnzuWpp56iurq6+aM770N79XWk6bim62/durVHXSRCdETCgYiY6urqFs2urU2Z60jTL/Xx48e3+MvtjTfeICcnh9mzZ5OSksIbb7zRfCPcvn07qampzVPgXnrppU79Is3JyeHixYukpqbyla98hZSUlOZpgB21KCQnJ/PKK680T1NrakF4sHk+Ly+Pv/u7v2P8+PFcuHCB0tLSHjfhN90wm77GvLw8Dh061GELRlvvbXc1fQ/y8/ObvydPP/00QPP7MXv2bMaPH09qaiqlpaVtnqurx99fQ7h+Li5cuNB8Y28aI9D00d40wvbeh/bqa0/T4lxN4xWqq6v52c9+1uHrhOguTSmlzC5CiIEgPz+/wwFtQggRDaTlQAghhBAtSDgQQgghRAsSDoQQQgjRgow5EEIIIUQL0nIghBBCiBYkHAghhBCiBQkHQgghhGhBwoEQQgghWpBwIIQQQogWJBwIIYQQogUJB0IIIYRoQcKBEEIIIVqQcCCEEEKIFiQcCCGEEKIFCQdCCCGEaEHCgRBCCCFakHAghBBCiBYkHAghhBCiBQkHQgghhGhBwoEQQgghWpBwIIQQQogWJBwIIYQQogUJB0IIIYRoQcKBEEIIIVqQcCCEEEKIFiQcCCGEEKIFCQdCCCGEaEHCgRBCCCFakHAghBBCiBYkHAghhBCiBQkHQgghhGhBwoEQQgghWpBwIIQQQogWJBwIIYQQogWr2QUIIYTomqBSVHiClDUGKG8M4g4YBJXComnEWXUGx1gYEmMlzWnBomlmlyv6IAkHQgjRR9T6gpys9HKs0oPbrzCUQtc0DKWaj2l6rGsacTaNrFQnmakOEu0WEysXfY2m1H0/VUIIIaKON2iw62YDxyq9BJUCBVZdQwe0VloGlFIYQMBQoIFF08hKdbB4eCwOi/Qmi45JOBBCiCh2pc7Plmv11PiC6GhYtdYDQVuUUgQUGCiS7RbyR8UzJsEWwYpFfyDhQAghotSxCg/br7sxlMKmaeg9GD9gKIX/bnfDqpFxZKU5w1ip6G+kfUkIIaLQsQoP26+5MQyFvYfBAEJjEeyahmEotl9zc6zCE6ZKRX8k4UAIIaLMlTp/c4uBXde61I3QHk3TsOuhAYvbr7u5UucPy3lF/yPhQAghoog3aLDlWn3Yg0GT+wPC1mv1eINGWM8v+gcJB0IIEUV23WygxhfEpoU/GDTRNA2bplHtC7LrZkNEriH6NgkHQggRJWp9QY5VetHp+RiDjuiaho7GsUovtb5gRK8l+h4JB0IIESVO3l3HwNpLixpatdBqiycrvb1zQdFnSDgQQogoEFSKY5UeUF1bx6AnNE0DBccqPaHFlYS4S8KBEEJEgQpPELdfYdV7dy8Eq67h9of2ahCiiYQDIYSIAmWNgdCeCB0cd650DyeKPuLA5g3Nz/33a19r8bgrdEKrKJY3Brr1etE/STgQQogoUN4YRO9ghkLljavEJiQxYnIGxb/8t+bnR06dTsWNK926rnb3mmWN0nIg7pFwIIQQUcAdMFrsrtiaypvXGDElgxPFW5iYs7D5+elL80kZPqrb1zaUoiEg6x2IeyQcCCFEFOjMgMBJs0OB4HjhB0xfthoAhaKispKk0VOorKqkobEBQxmcKPqIc6V7On39gAxIFPeRcCCEEFHA0skZCo31tdw4e6o5KHi9Xm6f/4SUYWMwDBu1tY1cu3iZbf/fT6mtuoOiczd9ay/NkBB9g4QDIYSIAnFWvVMLH1XduEbq8NHNjxsbG7FaLFh0hdVqIzExhYulu5myqAC320NZ2R1qamvw+rxtBgVd04i1yu1A3CM/DUIIEQUGx1gwlEJ10LzvjE9o/m/DMDhZvIWsZatx2K34fB5un/+YKXOX4nQ4iY2Jx+GIx+uFqqo6ysvvUFtXiz/gh7tBQd295pAYSyS/PNHHWM0uQAghBAyJsaJrGgbQ3m06dcRoMpfmc2DzBnS7k2HjJ2O32wkaBo1eN3euX2LG8s8AoOk6TmcMTqeTYCCIz+ejsdFHQ0MNFgvExDixO5xouoXBMXI7EPdoqqOYKoQQIuKCSvF/P66i3mfgsHSuUbeisgKbphEXFwfAtl/8KwlDx+J0xnCq+EOcCUnMfuQphk/KuPcipQgEAvj8Pvw+H7YYGwF3LaOu7OXxRx9lyJAhkfjyRB8j4UAIIaLEnlsN7L7dgL0TOzIGggEq79whMT4em80GgM/no87tJS4hhZ3/8UNGTJlBRu7qNs9hKIMgBue3HGXzT36Cpt1gyZJJrFv3BI888ggJCQltvlb0bxIOhBAiStT6gvz7J9UoBbYOllGuq6/D19hIUmISTTlCKUVNbR1XTp6g5L/+DynDRrPqS39Gyn0DGO9naApNwcSyZHy1Hg4cOMC+fbs4c6YIp7OShx6azZNPPsHKlSux2+3h/nJFFJNwIIQQUWTHtXoOV3iwaW1v26xQ3LlzB6fVSmxsbIvPNTY20uA1SEhMabf1QaEwNEWq28mwmvgWn6uoqGDfvr3s2+fi6tV9pKQ08NhjS3nyySeZP38+ui5j2fs7CQdCCBFFvEGDt87UUO0Lttm94PN5qa6qIikhAau15UBCwzCorqnHHpOIw+Fo9RpNwcAesDC+PAmLavtmf/36dfbs2cP+/TuoqDjBiBEG69Y9xJNPPsm0adN6bQdJ0bskHAghRJS5UufnnYu1GIbCrn86INTU1hD0+UhKTGz19W63G2/AQkJiItDytU3BQENjzJ1E4ny2TtWklOLcuXPs2bOHAwe20dBwhvT0RNatW8MTTzzB6NGtd12IvknCgRBCRKFjFR62X3NjqJYBQSlFeXkZcU4nTqez1dcGAgFq6hqIjU/Gar13878/GAyrjiOlofXXdyQQCHDixAn27t3DkSPbCQQuM2/eaNate4zPfvazpKamduu8InpIOBBCiCh1rMLD9uuhgNA0BqHR00h9TS3JSYnt9v3X1tZh6E7i4kLjCcIVDB7k8Xg4dOgQe/fu5tSpnVitt1m5MpO1a5+goKDgU2MiRN8g4UAIIaLYlTo/W6/VU+0LoqNRX1OFphQJ8fHtvs7r9VLX4Cc+MQXNoqEIjTEYXh3f6a6ErqqtrWX//v3s3eviwoVdxMfX8B//8QNyc3NlEGMfI+FACCGinDdosOtmA0fvNFLf0IjdZsWu62goWhsOqABDQYPPj26xY7PYSG5wMLg2tt3Bh+FUVlbG3r17WbkylbVr52O3RyaQiMiQcCCEEH3EP//fn3Pw48vM+dyX8ep2DE1DUwp134DFpse6UvhqKjn83j7WLXuWGM2cdQqmT1eMGQMWi8xq6EtkMW0hhOgDlFK8+9Z/Mm/wYB6tP0eN7qDaEkOVxYlHsxDUdCzKwKmCpAQ9JAcbabh5iV/+10846kxnwYIFJlWu8anZjsePg8sFY8ZAdTU895wZhYl2SDgQQog+4OTJk9w+d44ljz+ODqQYXlIML+P9bb8mZcRwcmcOZee2900MBw+orYWvfx0++gguX4b33gs9v3o1JCfDjBnwzW+aWqKQcCCEEH3Cxo0bGZOSwvTp07v0uoL8VWx7/V0uXbrEuHHjIlNcVxQXw9ix91oOXn459Pwf/RGsWWNubZ0UVIoKT5CyxgDljUHcAYOgUlg0jTirzuAYC0NirKQ5LVj66CJREg6EECLKBQIB3vvVr3hqwQJ0S3sbOn9a9syZTB22gW3btvHCCy9EqMIuSEqC7GzIzQ09vnw5FBauXAkFhqKiNlsOgsEgli5+/eFU6wtystLLsUoPbr/CUCq0zfZ9Q/eaHuuaRpxNIyvVSWaqg0S7eXV3h8wtEUKIKFdUVESwqoolS5Z0+bW6xcLDeUs4deA31NfXR6C6LsrNhcpK2Lw59HHlSuj5l18OfW7cuNDzrcjJWcZrr73GsWPH6M2x9N6gwY5r9fz7J9Xsvt1Avc/AooFD17DrGk6L3vxh1zUcuoZFg3qfwe7bDfz7J9XsuFaPN2j0Ws09JeFACCGi3MYNG5g+dixjxozp1uuXLVvGINs1iouLw1xZN33zm6EuhDVrQoHA5Qp9QGhMQhvS01/iP//zEnl5L7FkSR4//OEPuXTpUkRLvVLn5xdnajhc4UEpsGsaDouOpZ1ttTVNw3L3OLumoRQcrvDw1pkartS1M0gkishURiGEiGK1tbUszMriT9av55FHHun2ed5482e8eySJ7/zdP/dq03xmZqgxoMM1kDZvDnU5HD9+bxzCfZQKHRIMBjl58iR79+7h8OHt+P0XmTNnJGvXPsqjjz7KoEGDwlZ7aytUdpehFP673Q2rRsaRlRaeFSojRcKBEEJEsf/6r//iR//zf/LvP/oRySkp3T7PhQsX+MNvvsFTL79JdnZ2+ArswLhxMH06n57O2EVuN+zY0fI5r9fL4cOH2bt3NydO7MBiucXy5ek8+eTjPPTQQ8R3sIpke9ra26InlFL4jLsBYVR0BwQJB0IIEcWefPxxRvl8vPIXf9Hjc/3Vt77LRfL5s6//zzBU1jkOB6xaFWo56O79VSk4fRrOnm37mPr6+rtLN5dw7pyL2NgqHn54LmvXPsny5cux2Tq/QmNHu2L2RHNA0DXWjk9kTEJ0rhwp4UAIIaLUlStXeGjhQr718sssWrSox+fbtWsX//sn23nlb/6boUOHhaHCzklLg9mzQ0GhqwwDrl6FY8c6/5o7d+6wd+9e9u0r5vr1/aSleXj88eU8+eSTzJkzp919HrxBg1+cqaHGF8TezriCnlBK4VOKZLuF56Yk4bBE3/A/CQdCCBGlfvjDH/Lbn/6U//uTn+Dozp31AX6/n//xJ39Jysyv8fnPfz4MFXZNcnIoIHT2fhsMhhZQ9PdgDN/Vq1fZs2cP+/btoLr6BKNH66xd+xBPPvkk6enpnzp+x7V6Dld4ejzGoCNNYxBmpTlZOar73R+RIuFACCGikFKKpYsWsWrChLCuT7Bx40b+dfMd/uYffxGWwNFXGIbBmTNn2LNnD6Wl22lsPEtmZgrr1z/KY489xsiRI6n1Bfn3T6pRCmx65Bcv8hsKTYMvpSdH3ToIEg6EECIKlZaW8vuPPcYPvvENMjIywnbeiooKvvynf8vSp/6BFStWhO28fUkgEOD48ePs2bObI0e2YRhXWLhwPAX/489oGDYFR5jHGbSlqXth0dBYFg6Ljfj1ukJWSBRCiCi0ceNGJgwa1GrTd0+kpaWxfM5Ydm7fzPLly3vlJhhtrFYrs2bNYtasWTQ2fonS0lL27d/NZX8Cce4GGg0/MTExOBwOtFY3xQ4PTdPAUByr9DBvaExULbUcfaMghBBigPP5fHz0m9+wbNGidgfPdVdBfh6esr2cPn067Ofua2JiYliyZAl/+OqfkTp8OBbdRiBgobq6nrKyO9TU1uD1eVFEppHdqmu4/aG9GqKJtBwIIUSU2bZtG1pdHYsXL47I+TMyMpg+WmPH9m1hb5noqzy2AGhgt9lx2BwYwSA+nw+v10djYx26rnA6Hdz8+Cj+RjeNdbXMXfMUAP/92teYNHth8+Ou0IGAUpQ3BhgSEz23ZGk5EEKIKLNxwwayJ01ixIgREbvGIwXLOXd0M1VVVRG7Rl/isQXR7v4DoT0pnDExJCYmkhCfjM0ax81LV/EaGo604ez8rzcJBAMAjJw6nYobV7p1Xe3udMmyxuhqOZBwIIQQUaSyspJ9O3eSG4Z1DdqzaNEiRsSVUVi4M6LX6SuCutFG14GGxWolJjaWYH0NE6bP5/z+3YzKmMOdO1VUVFYwcV4uaSO6t+8FhKY1NgSia1MmCQdCCBFFfvOb35CsacyfPz+i14mJieGhpdnsLf4dgUAgotfqCwyt4zEFE3IWYbXZOLt3O9mrHiU2NomA30JdXSNjZ84FoLG+luJf/hvFv/y3Ll0/EGUTByUcCCFEFHln40YWzpxJYmJixK+1atUqbO5jHDx4MOLXina66txMAU99LbfOnWL8rIX4fT4MpVN28SxpI0MtB+dL99BQ2/WuGmsUzVQACQdCCBE1zp07x8Vjx1gSoYGIDxoxYgS5M4dSuP39XrleNLMYeqemLVbdukbysNHU19fj9yusVju6DhY9tIjR9GWru9zFoGsasdbouh1HVzVCCDGAbdq0iRHx8b26a2J+3koqLhZy5crlXrtmNHL6Lai7/7THHhNHMBgkGNRwxsRyevdWMpeu6vZ1lVIopRgSE10rJEo4EEKIKGAYBr/ZtIll8+d3aQfBnpqVnc2UoR62bdvea9eMRk6/tcOWg2AwiDU+hUnzV3G66D3O7NnG4HGTevT9MgjNWBgcRdMYQdY5EEKIqLB3714abt1i8Ysv9up1dYuFR/KW8A9vv0v9U08RHx99mwD1BoffgjWo4bcYWFoZfxAIBKivd6NpNvJfeBVN01HKoKGhFqu1+7fSgKGIt+ukOaXlQAghxAM2btzIlOHDmTx5cq9fe+myZQyyXsXlcvX6taOFhkZygxPgU10Lfr+f+no3um4nxhmDpoVunYZhoGF0u+VAKQUaZKU6o2rpZJBwIIQQpmtsbGT75s0si/DaBm1JTEwkb2E6JYW/wzCia759b0pucKArjfsbDnxeL+76Biy6A6fT2WK/acMw0HSF5b4lrs+V7rn7sZsTRR+1e72AAoumkZkafbtjSreCEEKY7MMPP8Tm8URsueTOyMtbxbtFP+P48ePMnDnTtDrMZAtaSG5wUBnnQaHwenw0NnqxWZ3YHXZ4YEyCETSw2fQWz0+avZBJsxd2eC1DKQwUs1KdUbddM0jLgRBCmG7jhg3MmTqVwYMHm1bDxIkTmTMlnu3btphWQzQYXBuLPWDBFwzQ2OjDbotpNRgAGCqAzdb1v7GVUviVItluYfHw6NqquYmEAyGEMNHt27c5UlJCromtBk1Wr1rGtY8/4Pbt22aXYhrDF2Tvz7fi8waITYjFZrfRWjBQSmEYgS4PRlRK4TMUuqaRPyoehyU6b8PRWZUQQgwQv/71r0m12Zg3b57ZpTB/wQLGp9SyY8cOs0sxRWNjA//wDz9ky9tnKHcF0XULmlVBK2sfGEawy4MR7w8Gq0bGMSah96asdpWEAyGEMNE7GzaQO3s2sbHmNy/bbDYeWjGX0pJf4/V6zS6nV1VXV/M3f/N9zpzRefzxL5LmGYvvWBwordWAYBgGmmZgsXRuvIChFD6l0HWNVaPiyEpzRuCrCB8JB0IIYZJTp05x88wZFps0S6E1K1euJD54lj179phdSq+5ffs23/nO9ygrS2XduucZNWoUAIHLTjx7EzEaLGg2BZZ7IcEwDKy2jpdcVkrhN+6NMVg7PjHqgwFIOBBCCNNs3LiRMUlJzJgxw+xSmg0aNIjlc8ZRuO13oXn4/dzFixd57bXX8Xgm8tRTzzNoUMtBocYdG56iJPwXnGgKNJtCsxqgGW0ORlRKEVQKb9DApxSaBrPSnDw3JSmquxLuJ+FACCFMEAgEeO9Xv2LZwoWdbpruLQX5q2gs28vZs2fNLiWiTpw4wd/8zT8RE5PDU0892/ZOmAEd/4l4Gnck4/8kFsOjoVvB4ojBZyg8QaP5w2eExhUEFcTbdRYNjeVL6cmsjOLBh62RdQ6EEMIELpcLf0WFqWsbtCUjI4MZozW2bd3ClClTzC4nIvbs2cMbb7zNyJHLeOSRNZ0aWKgaLfjPxHK5uBrXoX/gJz//Kva0YTQEDAJKYb27u+KQGAuDY6ykOS1Rt/JhZ0k4EEIIE2zcsIGMMWMYN26c2aV8iqZpPJS3lL/9+Waqqz9PcnKK2SWF1Ycffshbb20hPf1hVq3K63LLTdmtMupuHGPltDG9uklWb+o7bRxCCNFP1NXVUfThhyyNooGID1qyeDEjYsvYubPQ7FLCxjAM3n77bd56ayc5OU+Qn1/QrS6dsrIyMjIS+m0wAAkHQgjR695//33igkEWLux4mV2zxMTGsnrpTPYW/5ZAIGB2OT0WCAT42c/+L7/97XFycz/HkiW5aN1s8q+ouEp29sQwVxhdJBwIIUQv27RhA/MzM0lNTTW7lHatWrUKm/s4paWlZpfSIx6Phx/+8EeUlNzioYeeY9asWd0+VyAQoKrqk6iaYRIJEg6EEKIXXbt2jRN797IkCgciPmjkyJEsnjGYHds+MLuUbqurq+Pv//77nDpl8NhjzzN1anqPzldRUYFSNyUcCCGECJ933nmHoTExzJkzx+xSOqUgfyUVF3dw5coVs0vpsvLycr797b/n+vVEnnzyecaMGRuWc1qtZUybNi0MFUYvCQdCCNFLlFL8asMGcufOxeFwmF1Op+TMmsXUoV62b99mdildcuXKFV577Xu43WNZv/73GDp0aFjOW1ZWxqRJzqhY7jqSJBwIIUQvOXLkCFWXL/eJLoUmusXCQ6sWcXzfu7jdbrPL6ZSPP/6Y7373R1itWaxfH96pmHfuXCcnZ0zYzhetJBwIIUQv2bRpE+PS0vpck/Ty5ctJs17D5XKZXUqHDhzYz/e+9wapqQtZt+5p4uLiwnZuwwhSUXGq3483AAkHQgjRK/x+P+//+tcsX7QIXe9bv3oTExPJWzAF187fYRiG2eW0afv2bfz4x79k/PgCHnvsSez28HbdVFVVEwxeZ/r06WE9bzTqWz+hQgjRR23fvh2ttpZFUbzwUXvy8/NQVQc5fvy42aV8ilKKd955h5//fAszZjzK6tUPRWS/irKyMiyW29JyIIQQIjw2bthA1oQJzdsB9zUTJ04kZ1Ic27dtMbuUFoLBID//+c/59a9LWbRoPcuWLYtYy0x5eRljxuhtb9DUj0g4EEKICKuurmbvjh0s7UMDEVvzcP5yrn/yAbdv3za7FAB8Ph8//vFP2LnzCnl5zzFnztxur3rYGeXlt5g1q2+Gu66ScCCEEBH2m9/8hgSlWLBggdml9Mj8BQsYm1TLzp07zS6F+vp6vve973PkSCNr1jxPRkZGRK+nlKKi4hQzZ/b/LgWQcCCEEBH3zsaNLM7O7vPN0TabjYdXzuWg69d4vV7T6qioqOA73/l7Ll2K5cknv8j48eMjfs2amhp8vmsDYjAiSDgQQoiIunDhAuePHGFxHx2I+KAVK1YQHzzL3r17Tbn+9evXee2171FbO5L1659n+PDhvXLd8vKBMxgRJBwIIUREbdq0ieFxcT3a7CeaDB48mOVzxlC0fTNKqV699tmzZ/n2t3+ApmXw1FNf6NWNq8rKyhk6NMDgwYN77ZpmknAghBARYhgGv9m0iWXz5mGz2cwuJ2zy81bhvrWbc+fO9to1Dx8+zN/93f8hMXE+69Y9Q1xcfK9dG+DOndvMmjWsV69pJgkHQggRIfv376f+xg0WL1lidilhlZmZSeZI2La1d6Y1FhUV8cMf/iejRq3iiSfWmbIvxZ07p8nKiuygx2gi4UAIISJk48aNTB42jMmTJ5tdSlhpmsYj+Us5e3gz1dVVEbuOUorf/va3/Oxnv2PatM/yyCOPYLVaI3a9trjd9TQ2Xhww4w1AwoEQQkSEx+Nh2+bNLFu0KKJz782yZMkShseVUVhYFJHzB4NB3nrrLTZs2MP8+etYuXIluh7+VQ874/btMnT99oCZqQASDoQQIiK2bNmCtaGhzy6X3JGY2FhW52axp+i3BAKBsJ7b7/fz05/+Cx99dJYVK55l/vwFpgas8vIykpPdfXZ1y+6QcCCEEBGwccMGZk+ZwtChQ80uJWJWrVqFzX2M0tLSsJ2zoaGB73//Hzh4sIZHHvm9qGjKLysrY9asQf2yBagtvd95I4QQ/VxZWRmlxcV844tfNLuUiLE4nUyYPp0vPreSC1WfkJY2v93jlYK6OvD72z6murqK11//EbduJfL4419g5MiRYa66eyorzzNzZqbZZfQqCQdCCBFm7777LikWC/PmzTO7lLCzOJ2kZWZiv7va49e6sH6DYcDNm3DkSOi/73fr1i2+970f0dAwmnXrnmbQoEFhrLr7Ghsbqas7x4wZ+WaX0qskHAghRJi9s2EDS3JyiIuLM7uUsBucnY2lm1MJdR1GjIBAAI4du/f8hQsX+MEP/g8WSwZPPbWehIToWWa6vLx8wA1GBBlzIIQQYfXxxx9z/ZNPWNLHd2BsjT0pCWtMDFoPtkTWNBg1KhQUAI4dO8bf/M2PiY3NYd26Z6MqGECoiygurrpX9m+IJtJyIIQQYfTOO+8wKimJrKwss0sJO1tcHEqpHg/Ms1ggJga2bNnFz362iZEjl/PII5+JylUky8rKmD49GYvFnGmUZpGWAyGECJNgMMhvN21i2fz5/fNmEsbR+i5XMf/yL79i0qSH+OxnPxuVwQCgsvIis2ZNNbuMXictB0IIESYlJSX47txhST9bLjkS3nvvILNnP8miKF4kyufzUVV1mqys/tdF1BEJB0IIEUbf++EPmbVwIUop/HV1NN65gwoGzS4r6syZ8zBpadPMLqNdd+4MzMGIIOFACCHCQinFokWL0DUN/e5oO03XCfp8lB86RKCx0eQKI+j4cXC5YMwYqK6G557r8CXp6dMoL498aT1RVlaOw3GHKVOmmF1Kr5MxB0IIEQaapmGz2bBYrWi63jyiX7daSZnaj/usa2vh61+Hl1+GGTNCjwE2bw59fPe75tbXA2VlZWRkJETteIhIknAghBARpOk69uRktP44QBGguBjGjg21HEAoJGzeDElJsGYNpKbCW2+ZW2M3VVRcYdasSWaXYQrpVhBCiAjTNA3dbifYH7sWkpIgOxtyc0OPL18OhYImly7BF75gRmU9EggEqKw8xfTp/W9KamdIOBBCiF4QnePxwyA3F4qKQq0FEAoLY8eG/tvlgqysUHdDH1NRUQGURcXGT2aQcCCEEKJnvvnNTz93/DjU1IQGJx4/3ucCQnl5OVbrLTIyMswuxRQy5kAIIUTnKNW54y5fhhdfhF/8AlavDs1g6GNu377NpEmxxMTEmF2KKaTlQAghuqobU/f6A8Pv79yCRWPHwp497R7idoepqAipqLhBQcFYs8swjbQcCCFEV7Q2da+2Fl555d40vn7KU1WFMgxUZ1sQWmEYcP06NDSEsbAwM4wgFRWnBuTiR02k5UAIIbri/ql7Y8aEQsLx43D0KKxfHzomN7f1fvg+TgUCVJ05Q2p6Osowmp83lAIFaFqLlgWlFEpx9zkNXQefD377296v/UFDhkBCQuvbRdTWulm2LJXcphkYA5CmehIBhRBioHG5QmHg5ZdDjy9fDv27aYT+5s0tp/LddWvv3n6zSqItLo6YIUNQFgsff3KamjoHw0eMJy4ursVxXp+X0+evMWj4FGJjE7l1C06ehLo6kwoHhg+HZ56BlBTzaugLpOVACCG6orWpe01/Yb71Fjz6qHm19RK/282tgwf53us/5krZeNY8+hw3hlYClZ869u2Nv+OKZxpr13+u9wt9gMMBX/oS2O1mVxL9JBwIIURXtdVlUFw8IAYnXrt2jb9//f9Q453F2vXPkJyc3Oaxs2bO4PhvSrlzZxWDBg3uvSJbMXUqOJ2mltBnyIBEIYQIh9paaOcm2V96cM+cOcNr3/4xDcYi1q1/vt1gADBp0iRGJHk5evRo7xTYjkGDQDbI7BwJB0IIEQ6JifD6661+SgWDBH2+Xi4o/A4dOsR3/u7fsCY+xNq1zxAbG9vha3RdZ/bMaVw6vQ+v19sLVbZXS+eXahjoJBwIIUQEKaVoKCsLzeHrwwoLC/n+DzcyeOTjPPbYk9gdjk6/dsaMGSTo5Zw6dSqCFYpwkjEHQggRQZ6KCqrPnDG1BltcHM7Bg7HY7a3P3WuFMgx8NTV4Kip491e/4v9tKGVSxjMsX768cwsh3Sc2NpasKcM5eGwfM2fORNfl79JoJ+FACCG64OzZs/z09df5/bVrGTFyZNsHKoW/vp6gyU3pCWPGkDRxYmjMQxfb1LVRo6i+c4cP//SnZM99nrnz5nW7juzsmew/tY2rV68ydmyUrTw4QFe8bI+EAyGE6II333yTy6WlpDzzDJ47d8wup13WmBiSJk4E7i5E1MW/+AHik1N47Sd/z9HCnq3+OGLECCYMt3H0yKHoCgdNK15+9FFozYr33gs9/9ZbobUrLl8ekGFB2naEEKKTvF4vW3/3O5YtWtTlpnUzOAcN6vEsCavVwoy5Y8JSz+zsLMqvllJTUxOW84XF/SteQmhxq6b/zs0NzUBpWtNiAJFwIIQQnbRlyxYsbjeLFy82u5ROsdjtYRmeb7PrWKw9D0NTp05lcKybY8eO9fhcYZOUBNnZoSDQ1FJw/Pi9FS+TkuDIETMrNIWEAyGE6KRNGzeSM3kyw4YNM7uUXheOhhKr1crsGZM4d2ovgYC/5ycMh9xcqKwMtQ5s3gxXroSej6bWDRPImAMhhOiEO3fucKCwkFd/7/fMLqVPy8rKoujAO5w+fYbMzEyzywlpbcXLpj0zampCLQsDjLQcCCFEJ7z77rukWCzMnz/f7FL6tKSkJKZPHMTxowdNWTWyUy0gubmhgYouV6gloZWNtPo7aTkQQohO+NXGjSzKziY+Pt7sUnomCqbtZc/MonRTCbdu3WT48BG9dt36+i50jzTtujlAt22WlgMhhOjA6dOnuXLyJEv6yEDENjVN23v5ZZgxI/T4/s+98kqvlDF27FjGpCqO9vJAv9OnQ0soi47J2ySEEB3YtGkTIxMTmTlzptml9Exr0/bu/1x1da+UoWkas2dN5/r5/bjd7l65JkBVFWzZEvrvYLDlRyBg4PcH+s0GWT0l4UAIIdoRDAb57aZNLJs/H6u1j/fEtjZtD0Kj9Hu5Xz0zI4NkezUnThzv1eu6XPDTn4ay0OHDoVmKR47Ae+9dZ+fO4j6xfkVv6OM/6UIIEVm7d+/GU17eZ9Y2aFduLhQV3VvUJykp1J0wY0avl+JwOJiVMQbXif3MnTsXXbf02rVv3gx93O+tt37Ls8/eoKBgZa/VEc0kHAghRDs2bdpE+ogRTLy7DHGf9+C0vePH7300LQDUS2EhOzubXUd+x/nz55k8eUqvXLM1Pp+PqqrTZGX1gwAYJtKtIIQQbXC73ex87z2W9dFWg071n8+YEepSqK5uOUDxAZHYcXrQoEGkj0ng2NFD4T95F9y5U46u32aGCS0o0UrCgRBCtOGDDz7A7vWyaNEis0vplkBDQ+fn7j33HOzZ02qrgbvWhxGMzEC9WdlZ1Nw8yh0TN7EqKyvD4bjD5MmTTash2kg4EEKINmzcsIF506YxaNAgs0vpliMlJXgaPQSD3f+zXynF2aNVYayqpUmTJjEiycvRo0cjdo2OlJWVk5mZiM1mM62GaCPhQAghWnHr1i2O7d7dZ9c22Lt3L9/+zs/466/+PxrdgW6dwzAUZ45UcsR1O8zV3aPrOjlZ6Vw6sxev1xux67SnouIy2dn9ZExJmMiARCGEaMWvfvUrBjsczJ0zx+xSumzLli382/9XxNjJTzF1/Bw2/uQTUoc6iY23QSd7GYIBRcWtRnyeYGSLBWbMmMHOvW9z6tQpZs2aFfHr3S8QCFBZ+TEzZvTxNSzCTMKBEEI8QCnFO2+/Te7s2cTExppdTpds2LCBjb/5hIyZz7FkyZLm5ytve6i87TGxsrbFxcWRNWUYpcf2M3PmTPReXMawouIOcIvp0z/fa9fsC6RbQQghHnDixAnKzp/vU2sbBINB3nzzZ7z97gXmLvpii2DQF8zKzsaoO8vVq1d79brl5XewWsvIyMjo1etGOwkHQgjxgI0bNzI2NZXp0bKlcAe8Xi8/+tGP2VLcwIr8F3q9aT4cRowYwYRhNo4ePdyr1719+zaTJjmJiYnp1etGOwkHQghxH7/fz/u//jXLFixAt/Teqn3dVV9fz9/93Q/YdzSGh9e8wNSpU80uqdtysmdQdvkgNTU1vXbNiorrzJ49vteu11dIOBBCiPsUFRURrKrqE10Kd+7c4bVvv87pK6N57MmvMGbMGLNL6pGpU6cyJLae48eP9cr1DCNIRcXHsvhRKyQcCCHEfTZt3MiMceOi/kZ77do1vvXaP3CrZgZr13+JIUOGmF1Sj9lsNmZnTebMyX0EAv6IX6+ysopg8DrTp0+P+LX6GgkHQghxV21tLa6PPiI3yldEPP3JJ3zr2z+hUVvMuvW/R1JSktklhU1WVhaOwDVOnz4T8WuVl5djsdyWcNAKCQdCCHHX7373O+INg4ULF5pdSpsOHjzId7/3c+xJD/Hk2meJ7WNTLTuSlJTEjIlpnDh6sHN7Q/RAWVkZY8daSExMjOh1+iIJB0IIcdc7GzeyYMYMkpOTzS6lVTt27OAHP3qHwaOe5NFHn8DeT5f7zZ6ZRUPlKW7duhXR61RU3GTWrFERvUZfJeFACCGAy5cv88mBA1G7XPKvf/1r/vXfipmU+XkeeuhhLH1gJkV3jR03jjGpBkePRG5ao2EY3LnzMVlZ0qXQGgkHQggBvPPOOwyLiyMnJ8fsUlowgkF+/vOf8/82fsysBc+zbNkytM7utNhHaZrG7OxMrp/fT0NDQ0SuUVtbg893RWYqtEHCgRBiwFNK8euNG1k6dy4Oh8Pscpr5/X5+/JP/w3vbqshd+WXmzO57+zx0V0ZmBsn2ak6cOBGR84cGI5bJYMQ2SDgQQgx4paWl1Fy9yqIomqXQ0NDA3//9D9hVamX1Iy8MuOV9nQ4nszLG8PHxfRhG+Dd/un27jKFD/QwePDjs5+4PJBwIIQa8TZs2MXHwYKZNm2Z2KQBUVVXx7e+8zskLw3j0ia8wbvzAXMFv5syZ6I0XOX/+QtjPfefObXJyRob9vP2FhAMhxIDm8/n48N13WbZoUVT05d+8cYO//tb3uXYnnSfXfZlhw4aZXZJpBg8ezNQx8Rw7eiis51VKUVFxhqysgdUa0xUSDoQQA9rWrVvR6+tZHAVdCufOneOvXvsRdcEFrHvqi6SkpJhdkulysrOovnmEioqKsJ2zocFNY+NFGYzYDgkHQogBbeOGDWRPmsTwESNMrePIkSN8529/hiW+gHVrnyUuLs7UeqLFpEmTGJHk5ejRo2E75+3bt9F1WRmxPRIOhBADVkVFBfsLC1lq8toGLpeL1//xbVKGP8bjj6/DHkUzJsym6zo5WelcPL0Xn88blnOWl5eTktLAyJEy5qAtEg6EEAPWb3/7W5I1jfnz55tWw+9+9zv++Y0tjJ3yOR55ZE2/Xtyou7JmzCBeK+PUqY/Dcr6ysjKys9OiYoxJtJJwIIQYsN7ZsIFF2dkkJCT0+rUNw+AXv/gF//nLY8yY/TwrV66Um1Ub4uLiyJoyjBNH94dlv4WKirNkZ0uXQnskHAghBqSzZ89y8fhxU5ZLDgQC/MtP/4V3P7zNoqW/b2rLRV+RnZ2NUXeWq1ev9Og8jY2N1Nefk8GIHZBwIIQYkDZt2sSI+Hiys7N79bqNDQ18/wc/ZOdeg/yHXpCbVCeNHDGC8UOtHOnhfgvl5WUyGLETJBwIIQYcwzD4zaZNLF+wAKvV2mvXra6u5jt/832OfJzCZx9/kYkTJ/batfuDnOzplF85SG1tbbfPUVZWRnx8LeMH6MJSnSXhQAgx4OzZs4fG27dZ3ItdCrdv3+Zb3/4Bl25P5vF1LzDC5KmTfVF6ejqDY9wcO9b9aY1lZWVMn56Crsvtrz3y7gghBpxNmzYxdcQIJk2a1CvXu3jxIn/1rX+k2jObdeu/yKC0tF65bn9js9mYNX0CZ07uIxAIdOsclZUXmTVrapgr638kHAghBpSGhga2b97Msl5aEfHEiRN8+2/+FeVYwdp1z5kyM6I/mTlzJo7ANc6cOdPl1/p8Xqqqzsg4j06QcCCEGFA+/PBDbB5Pr+zAuHv3bv7+9V+QMHgNTzz5NE6nM+LX7O+Sk5PJnJDK8aOlXX5tefkddP22hINOkHAghBhQNm3cyNz09Ihv1fvhhx/yTz99nxETn2LNZx7t1YGP/d2s7Jk0VJzg5s2bXXpdeXkZDscdJk+eHKHK+g8JB0KIAeP27dscKSkhN4IDEZVS/PKXv+TffnGAjOznyM8vQJPBb2E1btw4RqcaHDt6pEuvKysrJzMzEZvNFpnC+hH5iRVCDBi/+tWvSLXZmDd3bkTOHwwGeeONN9n428vMX/L7LFpk7p4N/ZWmacyemcHVc/toaGjo9OsqKi4za1bvDELt6yQcCCEGBKUU72zYwNLZs4mJjQ37+b1eL//wj//EthIPKwteYObMmWG/hrgnc3omybZqTpw40anjA4EAlZUfy+JHnSThQAgxIJw6dYpbZ89GZCBibW0tf/s3r3PwRDyPfPYrTJkyJezXEC05HU5mZYzm4+P7MIxgh8dXVNwBbslgxE6ScCCEGBA2bdrEmOTksN8cysvLee3br3Pm2lgef/IFRo8eHdbzi7bNzJ6J3niRCxcudnhsWVk5VmsZ06ZN64XK+j4JB0KIfi8QCLD5nXdYvmBBWLdEvnLlCn/92j9SXp/N2qe+FPEZEKKlIYOHMGV0PMeOHurw2LKyMiZPjiEmJqYXKuv7ZG6NEKLfKy4uJlBZyeIlS8J2zlOnTvGDH/4neswy1q5bS2wExjGIjuVkz+DEbw9RWbmK1LRUtIQgelIAPTGI5jTQdIUyNMZb4xmZ9FnKGgOkOS1YZHvsdkk4EEL0exs3bCBjzBjGjh0blvPt37+fH//0HZKGPMxnPvOoTI0z0eTJk5kw/gT1Q24ycoWG5lSgKVBa6N93TRk0CodzLG+dqSHOppGV6iQz1UGiPXwtSf2JppRSHR8mhBB9U21tLQtnzuRP1q3jkUce6fH5tm/fzs9+vp1RE58gP79ANvAxkWHVcE9KoG6YnaCmYbPaUEENFMC9lgHDMGhsrCE1LQ6r1UbAUKCBRdPISnWweHgsDot8H+8nLQdCiH7t/fffJy4YZOHChT0+1zubNvH2r0+QnvUcS5YsQZOmadP4Uu3UZiZhxFiwGApPXQPYdKyttOIYRhBNM7BZreiahsWioZQioOBwhYeLdX7yR8UzJkFagJpIOBBC9GubNmxg/vTppKSkdPscRjDIv//8P/hwRxlzFn6R2bNnh7FC0VWNo2Kom5YUahwIKHTAZtHxB3xYbVbubzWAUMuBxQK6dq91QNM0bBoYCqp9Qd65WMuqkXFkpcn+FyCzFYQQ/djVq1c5uW9fj5ZL9vl8/NOP/5mPdtawLO8FCQYmawoG6m4waIoBNpsNjADB4KfXPDAMA5ut9bEFuqZh1zQMQ7H9mptjFZ7IFd+HSMuBEKLfeueddxgaE9PtG3p9fT3/8A//xMnzaaxe81zYBjSK7vGl2puDgXZfMACwWCxYLBoBfwCL5f5bm8Iw/Nja2fhK0zTsOvgMxfbrbpLtlgHfxSAtB0KIfkkpxa83bmTp3Lk4HI4uv76yspLvfOf7fHxpJJ99/MsSDExmWDVqM0NdCQ8GgyY2q5Vg0IdSRvNzSimUCrY6FuF+oYCgYSjF1mv1eINGu8f3dxIOhBD90uHDh6m6fJkl3Vjb4Pr16/zVt77PjaoM1q7/MsOGDYtAhaIr3JMSMGIsLboSHmS1WdE1hd/vb37OCBrNgxE7EhqHoFHtC7LrZuc3dOqPJBwIIfqlTZs2MX7QINLT07v0urNnz/Kt1/6JBmMRa9c/T3JycmQKFJ0WdOo0jooF9eBQw5Y0NGw2C8GAn7vzGQkaQXQddL1z6xnomoaOxrFKL7W+jvds6K8kHAgh+h2fz8cH777LsoULu7QOweHDh/nO3/5fLAmrWbv2GeLi4iJYpegsz8hYlA4EO16Wx2a1gQoSCASApsGIXbvVWTUIKsXJSm93yu0XJBwIIfqd7du3o9XWsrgLsxSKiop4/R83kDbycR5/fC32boxTEOGnNEKtBrTfatBE13VsVp2AP9R6YBiBLq9gqWmhhZSOVXoIDtB1AmW2ghCi39m0cSMzJ05k5MiRnTr+N7/5Df9vQymTMp5h+fLlsrhRFAnEWzHsOlonWg2aWG02/I0+gsFgaDCitetrF1h1DbdfUeEJMiRm4N0qB95XLITos4Iq9Mu6rDFAeWMQd8AgqBQWTSPOqjM4xoLT38C+oiL+/KmnOjyfYRi89dZb/PbDq8ya9zzz5s3rha+i5+KTbCSkOOhshjEMRfUdLx53ILKFRUAgwfbgNgmtunhkLx53LY11teQ8tA5dV/z677/G6OmzWPns812+rg4ElKK8MSDhQAgholGtL8jJSi/HKj24/QpDKXQtNO2sSdPjYMDP4z/eQGqswq01EqdavyH6/X7+5V/eoGivh9wVXyYzM7O3vpxui0u0sWr9WNKGdX0HSKUUV8/WUvTuFQL+vtNUHkiwotF+l0LVzas4E5JIHjqS//qr/0HOQ+uw22wMmZBOTdk1LN3Y/0LTNDQNyhqDRP9PRvhJOBBCRC1v0GDXzQaOVXpDfb8q1Nxr07S7Tf8tbxlKQXVjI7FpQzlrt3FeKSb6qsjylGHj3rz1xoYGfvjDn3D4k3gKHvkKEyZM6OWvrHtWPzuBhGR7t16raRqjJiWyZM1oCn99JcyVRY7hsIRaDto5purWNcbNnM+eX/2csVlz8fv9GIbB5HnLuPbxPjo3WqGVaytFQ2Bgrncg4UAIEZWu1PnZcq2eGl8QndASt5re/i/5oBHE7/XgtFqwKgtBNM46Urlpi2duw02GBt1UV1fzvdd/xIUbo1nz2HOMGDGil76inhk0IoaktJ4NktR1jbFTk7DZdfy+6L7pKaVobGzE44tBGQ78gQBKGShDhRY2QqEMA4XB0KnTafTUc6LoA3Kf+wP8wcbQz4rFR/r8XABOFH0EQGNdDSnDRzNpduc24grIgEQhhIgOxyo8bL/uxlAKm6ahd7Jz3ePxYNF1bDYbGmAldCOp1+wUxY1h0u3T/Odr3+dOXSZPrvs8qampkf1Cwig5TBsC6RaN+GQ7VWXm7CFgGAYNDQ243e5WPupp9NRS31hFg6capfmYl/b7jB45D7/fjaZp6LqGbtHRdQ1Nt6DrNjRNw9fg5s7ls8xctgpN0/B6vZRfPMukNeuovHGVc6V7ePzPvgXAv//FlzsdDqwDdHCqhAMhRFQ5VuFh+7VQMLDrWqdnDigUnsZGHDZbi9eEQoKBT8GxxLHELniBdSnpxMfHR+griAwtjBPP9Q5aYLojGAzidrtpaGigvr6++WZfX++msaEet6eGBk8NDZ5qNN0P1gCaxY9uM0hKiyMlLZ4hkxJJTkkmOXkiKSkpJCcn4x45iUq7lThHUrs/C3cunyNl+KjmY3w+H1arFYtu4fyhPTjv+37HxCdyrnRPhwFB1zRirQNzxr+EAyFE1LhS529uMehKMIDQAEMjGMQe++nBegG/n0Z3I5aYBDJ//zPYD1VBpS+cpfdbfr+/jb/y3bjddTR6qnF7qvH46sDiR7MG0Sx+bA5FUlo8KUPiGTkkmeTkZJKShpOcnNziIyEhod2Fqq4E7FT6dJpWPGyLMy6h+b+DwSAni7cwZ/VjAFTcuEJs4r0tu2MSkvDU17Z7vtCeDIohMZ1bWbG/kXAghIgK3qDBlmv13QoGAI2NjVgtFqwPrKHv8/lwuz3oFic2zQK6Rl1GEil77qB3Ye58f+Pzeqm/70Zf766nwX23ub+hlgZPDe7GKvyBejRLAM0aAGsAR4xOyqB4UkYlMn5Q0t2b/DiSkpJa3PTj4uLCsl5Ekh5EQ6Fof1hh6ojRZOTmcfC9Teh2B0MnTG13w63Gupp2r2sQGsQ5eABOYwQJB0KIKLHrZgM1vuB9MxE6T6HwejzEOVv2y3s8Hhob/VisMc03ChVQBGMtuCcnkPBJ+3899gnHj4PLBWPGQHU1PPdchy/Z8Kt/4+Txj9Hu+0s/JsFGclo8qRMSGZGaRErKYJKTJ9/9iz+p+d8xMTG9ukhUghbEoSk8SkfvoPWg4Ct/hlKKmpoaHA4H2t04kTZiDI31dc3HNQ1KbE/AUMTbddKc0nIghBCmqPUFOVbpRafzgw/v5/V40RTY7fem+TU2NuLxBLDaYlo8rxGa8tg4KpbYS/VYPNE9ah9AGQZebyvdILW18PWvw0cfweXL8N57oec3b4af/CT0fCvWfCGb1f7MFn/p3/8eRRNdg7FWD6f9sShFhws/NXUvxcTEND83MWchH/3sH5sfV9682u54A6VCOzxlpTqxyIBEIYQwx8m76xjYu/mLuNHTiM1qae67drvdeH0Kuz229XX1gwqsGp4RscRdqO9J6T1iGEaLZn13vfveSP6Gehoaa2horKLBW8Njn1vGqrV/3PIExcUwduy9loOXXw49v2YN/OIXbV53wYIFeL19Z1OhMRYfZ/0xBOn4puXz+bDabFgt945MHTGaGcsf4kTRRzTW1bDsma+0e46AAoumkZk6cPfXkHAghDBVUCmOVXpC2/F2YxR9aG0DH/GxMSilcLvd+PwaDkfsp8YfNNEI9Sk3jool9mJ9h0vzdlUgELg3aO/uyP16txt3Q0NoEJ+3BndDFR5fLZol0Dxy32JXJKfFkToogeFTE0lJTSE5OZ3k5GSys7M/faGkJMjOhtzQXH4uXw6FhX4mRjcYa/VyMeBEKdVm64ERNPD5fCQkJHzqc9OXre7UtQylMFDMSnWSaB+YXQog4UAIYbIKTxC3X2Ht5vQ6j8eDroHVaqO+vp5A0ILTGYPF0v4vdi2oMBw6gXgrtrrO7Tng83pxNzS0HLzndlNfX09DYx0NjaGR+z5/PZrFD3f78+0xGimDEkgZkcC4QUkkJSWRkjK6uS8/JSWFpKQk4uPj2+zPT0pK+vSTublQVBTqRggd1C/DAUC6rYGyoA230rG20b3g84VaQ5yO7q0JoZTCrxTJdguLh3d9ier+RMKBEMJUZY2B5sWO2nOudA+e+loa62qYuya0qdJ/v/Y1hk6dzuyCx6l3uzEMK05nTLtT45qp0HbAgQQbwfL60F/2d//Kb2hooN7tpqG+HndjLY3eGuobqggYDfdG7lsCxMZZSBoUT9r4JEakJZGcPIikpImfmq4X0UF83/xmZM4bZWwazLS72etNIICG9YEWBKUUXp8Pp8PZrfdaKYXPUOi6Rv6oeByWgbm+QRMJB0IIU5U3BtE7mKFQeeMqsQlJpA4fxc9feaE5HAyfPI2KG1cJBIIoHDhjnOj3rRbUNFddqdBmTcowWjynO63sP3+Ao794G3Rf88j9hCQnyYPiSZ2ayJjUZJKTh5OcPK3FyP3k5OR2p8qFm+rKMr4uF1y5EmpRWLMmckX1skGWADPsDRzzxX4qIAQCAYLBIImJiV0+b3Mw0DRWjYxjTEIr41QGGAkHQghTuQPG3d0V2wkHN68xafZCin/5b0zMCY0yVyjGzFpIoHQvhrJjtVrweX2h9feVQjVvtKRCH1poTIOua+i6jq7paHYL03MnU5DxdPONPykpqfVBjCYLBoOdPzg3F/bsCc+5osxYa6jr4LgvlgA0dzH4fL7Q0tltjDNpi3G3K0HXQ8EgK0zLVPd1Eg6EEKYKduIv4kmzF6JQHN3xHsuf/yqVVZUEAz4Cfi+js+agW4Oc3rsdj7uOsgunmbYkjwk5C9B1/e56/PrdLXhbBhC/0hg2fASzx3X9r83e1tDQgGEYrX4dnaWUwufzEQh0boxFtBpr9RKnBTnqi8OtLGCEBiLGxcbS2R0YlVIEFBiExhjkj4qXFoP7SDgQQpiqrXnkCkUgEMDn84VWOayu5MbZU4xKz8RmMdBsds5eOsvkhauou32VmNgY5jz0GJ76On74hYf4xq93dXhthepwYZ1ooZTi1q1bDB8+vGtdDK2coz8YZAmw1FnLJ/4YzjXqWJ0x6I4YgkqhQ6sBSqlQe1LACLUkWTSNWalOFg+PHfBjDB4k4UAIYao4q3534SNFIBhsDgMBvw+Uga6DzWbFX3uHtBGjSE5ORNM06uvrsehWLEBtxR3OH9pL5tICnPEJxCQkcePsKUZMzmj32jrgCPc8xgiqq6vD4/GQkJAQ2nmys5tSKYXX66Wurq5Pdyk8yKYpptvcbPz+64xblMeQpQ/h9isCd8ciGPeFKF3T7k6D1Ii362SlOslMdQzo6YrtkXAghDDN7du3uXziLN7UCdS661BGEF0Dm81CXIwNq9WK1WpF0zT8qWnNTeqGYXC8aBvZKz6DYRiMmDaTaQuXNZ+3sa6mw2CgFCg0kvS+1cTu9/uprKw0u4yoceHCBY4f3MsfvvBFFk9LocITpLwxQFljkIaAQUAprHd3VxwSY2FwjJU0p2XArnzYWRIOhBC9pra2lj179uByudi/p5jKG6cZPWUSc7/xX8TEOLBbtOYw8KDUEaPIzF3FgffeQXfEMHT8FGw2G0opGr1evF4vTqeT3/7o2zz6tb/usJbQEEhFot5//pIeiIqKikhKSmLJkiXomsaQGCtDYqxkml1YHyfhQAgRMR6PhwMHDuByudi7u5hr54+SEhsgffwgPrc8nYyMF5iSnsHGgE69YcfWwY36oa/8aWhjndpaLJq9uSXBbrPh83o5t6+IiTkLyFxa0GFtQTScmkGCJuGgr/J6vRQXF7Nu3brOrW0hOk3CgRAibAKBAEePHsXlcrFnVwnnTu0j3u5hwohEHsqeQuaznydjWgaDBw9u8bqshjJ2u0d1amOdQCCAEVDExt9bY8DpcHB8dyExMbFMmZ/LjbOncMYlkDqi9Z33mrqix1pDqyuKvmn//v1UVFTwuc99zuxS+h0JB0KIbjMMg9OnT1NSUsKuEhcnj+zCSR2jBjuZP3MyX37kcTKmZTBy1Mjm7XNbk+m4w76GEQTQsdH+Lolerxddt7ZYHrmm7Cab//Gv4O60RU99Ha9tPdbmOYKAjmKMpZWdDkWfsXPnTubMmcPIkSPNLqXfkXAghOiSy5cvU1JSgstVzNGDLvDeYViyhXnZk1j7B6vImJbBuPHjsOidHwWeaPGR5SzncONQDGjzr3nDMPD5AjgdLde9Tx0+ilc2FOJuaCAhMQmLte1rNw1EHGf1EKNH/3bNonU3btzg6NGj/N3f/Z3ZpfRLEg6EEO0qKytj165dlJSUULqviIaqKwxKgDnTx/Fnz80hMyOTSZMnYbfZe3SdxbFXuehLojroxE6wjY11fKA07PZPX8tus9GoaXi9HmKtca1eQykIAHFakHRbQ4/qFeYqLi7GarWyenXndlsUXSPhQAjRQm1tLXv37sXlcrFvTxEV10+TFmeQlT6CFz6bTmbGo0xNn0psTHh3rXPoBvnxl3indio+ZflUQFCA1+vDZrO3Ob/fYbfT6PPhjPn05kuhYKCho5hpd2OTsQZ9VjAYpLCwkMcff7zVoCh6TsKBEAOcx+Ph4MGDdwcRFnPt/BGSY0IzCp5eOpXMjBdIn5ZOclJyxGsZY69lVfwltteP+1RACPj9BAMGzri2bwYOhwOP14vP58PpvLdGflMw0FDMsDcwyNK31jYQLR0+fJgbN27wzDPPmF1KvyXhQIgBpmlGQUlJCbtLXC1mFKzOnkrmM8+SkZHBkMFDTKkvy1kO0BwQbIQWRvL5fGi6BWs7G+s0TWv0ejw4HA40TWvuStDvBoOmjXtE31VUVMSUKVOYOnWq2aX0WxIOhOjnlFLNMwpKXMWcOrIbu6pl1GAH87On8KWHHyMjI4NRo0a1O6OgN2U5y0nWvWytH0d10ImmDHw+Pw57x10ZDocDb10dPr8fi82OQiNOCzLT7pYWg36gsrKSffv28eqrr5pdSr+mqe7u4CGEiFpXrly5O6PAxdGDxShPOUOSdOZnT2J65jQyMjIYP358l2YUmMFrWNjVMIrSumR8AbDbHVhRaKhWY0xoc2aNRr8fpWk47XbGWr2k2xpkjEE/8Zvf/Ib//M//ZN++fcTHx5tdTr8lLQdC9APl5eXs2rULl8tF6b5iGqouMygBZk8fx58+m0NmZiaTJ0/u8YyC3ubQg6yIv8QH//BD4jM+T3Luw3g0K4amoymFum9gYtNjXSmcQT9Hf/vfPL5sLhkTx5r4FYhwUkpRWFhIQUGBBIMIk3AgRB/UNKOgpKSEfXuKuHPtE1LjDLKmDufLa9LJzFxDenp62GcUmOH06dOcPniKP87xM6XuDDW6g2pLDFUWJx7NQlDTsSgDpwqSEvSQHGwkMejhYNHv2Nlwi4yvftXsL0GEyccff8yFCxd47bXXzC6l35NwIEQf4PV6OXDgACUlJc0zCpKcfqaOS2P9kqlkZn6ZadOm9cqMgt5WWFhEonM06enpAKQYXlIML+P97bxI01idl8fPfvc7Kp55hrS0tN4pVkRUUVERw4YNY86cOWaX0u9JOBAiCgUCAY4dO9a8LPHZk3tJsHsYPzyBguypZDz9udCMgiFDomYQYSTUu+sp3FnKyiXPdfm1S5Ys4Zfvvsv27dt56qmnIlCd6E1ut5uSkhK+/OUvt7nOhQgfCQdCRIH7ZxTsKnFx8vAu7KqWEYPszJ81hd9f/SiZmZmMHDkSXRs4u8/t3r2b+morS5Ys6fJrHQ4HqxYv5sOPPuKxxx7D4XB0/CIRtfbs2UNdXR3r1683u5QBQcKBECa5evVq84yCIweKUZ4yhiTpzJs5icdfWk5mZibjxo3DahmY/5sqFNu3FzN96lwSExO7dY68vDx+t3Mnu3fvZsWKFWGuUPQWpRQ7d+4kNzeXQYMGmV3OgDAwf+sIYYI7d+40zyg4uLcId9VlBidATuZY/vSZWWRkZjB58mQcdvkLF+D8+fN8cuoWf/D7L3T7HIMGDWLBjBlsef99li9fLs3RfdSlS5c4deoUP/nJT8wuZcCQcCBEhNTV1bWYUVB+9WNS4wxmTBnOlz+TTkbmZ0hPTycutvVNgga6wsJCYm0jyMzM7NF5CvLzKfnRjzh58iTTp08PU3WiNxUXFxMXF8fy5cvNLmXAkHAgRBidPn2ad999lz27irly9jDJMX6mjE1l3aJ0MjK/xLRpGaQkJ5tdZtRraGykcOdBchc91eO/9qdOncqUoUP56MMPJRz0QT6fj6KiItavX4/FEt2LdvUnEg6ECKP//rcfcPH4NvJnpZPx1MCYURAJe/fuoaoclubmhuV8q/Py+PEvf8mtW7cYNmxYWM4peseBAwcoKyvj6aefNruUAUXCgRBtOX4cXC4YMwaqq+G5jqfTffWFdaTGrBlQMwoiYfv2IqZNmk1ySkpYzrdgwQL+65132Lp1K1/4whfCck7RO4qKipg1axZjx8pKl71JfoMJ0ZraWvj61+Hll2HGjNBjCIWFzZvhpz+999x9BqUNkmDQQ5cuX+LE8essXx6+2QVWq5WCZcso2bKFhoaGsJ1XRNbt27cpLS2VrZlNIL/FhGhNcTGMHRsKAxAKCZcvQ1ERrFkTakXo5vQ60b6iwiIc2mCyZswI63lXrFiBXltLcXFxWM8rIqe4uBhd13nkkUfMLmXAkXAgBrS6ujoOHDjw6U8kJUF2NuTmhkLC5cuhoFBbG2o5+PGPe73WgcDr9bJjxz5yF61ED/Pgs6SkJHJzctj6/vsEg8GwnluEXzAYpLCwkM9+9rOygJUJJByIAcXn87F7925ef/11Hn/0MyybN5W33vjOpw/MzYXKylAQ2LwZrlwJPT9uXKjlYNw4eOut3ix9QNh/YD93bgdZunRpRM5fUFBA9aVLHDlyJCLnF+Fz7Ngxrl69yrPPPmt2KQOSDEgU/VowGOT48eOUlJRQ4iri7Il9xNkaGTssjrxZ6WSsf5qZ81a1/uJvfrPl4+Tke90MycmhQYoirLZvL2TK+FkR2yhp7NixTB8zhi0ffsjs2bMjcg0RHkVFRUyYMKHH61yI7pFwIPoVpRRnz54NhYESFycPlWAzahieZmd+9mSez19DRkYmo0ePujdw0N7JEfEzZtwbkHjkyKfDg+iRa9euceTQZb74uciunf9QQQGv/8d/cPnyZRkBH6Wqq6vZs2cPX/va18wuZcCScCD6vGvXrjUvS3zkQDEB9y2GJOnMnTmBR7+ylIyMTCZMGN/2HgWqC/3PL78c+veaNW2cK9C14kWzwqJCrKQxa9asiF4nZ9YsRrz9Nlu3bOGFr3wlotcS3bNr1y48Hg9PPPGE2aUMWBIORJ9TUVHBrl27KCkp4eDeIuoqLpIWp5g9fSx/9HQWmRnPhPYo6OwgJn8tGH7QbT0rTBngrezZOQYov9/P9u17WTTvkYivgqdbLBSsWMEvtm/nqaef7vamTiIymjZZys/Pl++NiSQciKhXX19/b4+C3cXcvnLy7h4Fw/jiw+lkZDxEeno68XHx3byCgtrTkDw9dIPv6joFTa+pOy8tB9104OABym76WPalZb1yveXLl7PxvffYsWMHjz/+eK9cU3TOmTNnOHfuHH/5l39pdikDmoQDEXV8Ph8HDx5k165d7C4p4srZwyQ6fUwencKTi9LJeOH3ycjIICU5PKvnAVB/EQKNEDcGbInQ2fX8lYJAPTRcC32Ibtm5s4gJo7IYOnRor1wvNjaWFfPns/PDD1mzZg1Wq/wqjBZFRUWkpaWxYMECs0sZ0OT/CGG6YDDIiRMnWswoiLU2MHZYHCtnTSVz3VNkZGQydGiE9yjw3Ap9iF516/YtDu4/z+fX/mmvXjc/P58PvvUt9u/fz6JFi3r12qJ1DQ0NuFwuvvCFL8j22iaTcCB6nVKKc+fOUVJSgstVzIm7MwpG3J1R8IVVj5CZmcno0aNlKeIBoLCwEC2Y3OtTC4cPH86cKVP46P33WbhwodyMosC+ffuoqanhqaeeMruUAU/CgcmCSlHhCVLWGKC8MYg7YBBUCoumEWfVGRxjYUiMlTSnBUsf/uV1/fr1u9MLSzi8v+jejIKsCXz2hVwyMzOZMGFC2zMKRL8UCAbYvn03C+bkYbP1cEBoNxQUFPDdf/1Xzp49y5QpU3r9+qKlnTt3smDBgl7rXhJtk9/EJqn1BTlZ6eVYpQe3X2Eoha5pGEo1H9P0WNc04mwaWalOMlMdJNqjf0/zysrK5hkF+/cUUn/nEmnxBrMyx/BHT2eRkfE5pkyeIsuiDnCHDh3ixtVGvvL55aZcf/r06YxNSmLrli0SDkx29epVTpw4wT/+4z+aXYpAwkGv8wYNdt1s4Fill6BSoMCqa9g07W6zZsvWAaXAAOp9BrtvN7CvrJGsVAeLh8fisERPk7vb7W6eUbB3V1HzjILpk4fx+w+nM23aaqalTyM+vrszCkR/tGNHEWOGZTBixAhTrq9pGg/l5fHGb35DxTPPRGxlRtGxoqIiYmJiWLlypdmlCCQc9KordX62XKunxhdER8OuaWh6+10FmqZhASwWDaUUAQWHKzxcrPOTPyqeMQm93xQLoRkFpaWlzTMKLp85RKLTx6TRyTyxMJ2ML3+RjIwMUlNSTalPRL/y8nL27f2Epx/9I1PrWLx4Mf/961+zfft26es2id/vp6ioiCeeeMKU7iXxaRIOesmxCg/br7sxlMKmaejdGD+gaRo2DQwF1b4g71ysZdXIOLLSnBGouKVgMMjJkyfvzigo5vTxPcRZGhk7LJYVs6aS8eR6MjMzGTp0aGRnFIh+o6i4CM2fxLx580ytw+FwkLdkCe9/8AGPPfaYdHWZoLS0lFu3bvHMM8+YXYq4S8JBLzhW4WH7tVAwsOtaj0dF65qGHfAZiu3X3ABhDwhKKc6fP4/L5aKkxMWJQyVYg9UMT7Uxf9ZkPr/iETIzMxgzZozMKBBdFjSCbNu2i7k5S7Hb7WaXQ15eHr/dsYNdu3ZJs7YJioqKmgcli+gg4SDCrtT5m1sMwhEMmmiahl2/GxCuu0m2W3rcxXDjxg1cLhe7du3i0L5CAu5bDE7UmJs1gTVfXkJGRgYTJ06UGQWix44ePcq1y/X83rreWRGxI2lpaSycMYOtH3zAihUrZFpjLyovL+fAgQP81V/9ldmliPvIb/kI8gYNtlyrD3swaNIcEJRi67V6npuS1KVBilVVVS1mFNSVXyQ13iAnYwxffWoGGRlPM2XKFJyOyHdbiIFl585Chg+awpgxY8wupVlBQQGuH/6QEydOMGPGDLPLGTBcd7dBX9PWZmbCFBIOImjXzQZqfMH7ZiKEn6Zp2AiNQdh1s4GVo9qeDeB2u9m3b1+LGQUpsUEyJw3l+dXpTJtWQMY0mVEgIquyqpLdu07x5MN/YHYpLUyZMoWpQ4ey5aOPJBz0EsMw2LlzJ4888ggxMTFmlyPuI+EgQmp9QY5VetHp3uDDrtA1DV3BsUovc4bENK+D4Pf7KS0tpaSkhN27irl8upQEh49Jo5N4fP40pv3+82RkTCMtVaZvid5TXFxMoDGO+fPnm13Kp6zOz+ef/vu/uXnzJsOHDze7nH7vxIkTXLlyhe9///tmlyIeIOEgQk7eXcfA3kt9l1Yt1L2w/ePL3Nz1IbtKXJw+vodYvYHRQ2NYPmsqGY+vJzMzg2HDhsmMAmGK0EBEF3NmLsbpjL7uqvnz5zN40ya2bdvGF77wBbPL6feKiooYNWoUWVlZZpciHiDhIAKCSnGs0gOKDtcx6BlFIBjE5/Ph8/lQFgul1ZWc3vQ95maN49k/epiMjGmMGTMGix79qyqK/u/EiRNcvlDLU3+83OxSWmW1WslftoyNW7awdu1aYmNjzS6p36qtrWX37t384R/+oQwAjUISDiKgwhPE7VdYOwgG50r34KmvpbGuhrlrQouv/PdrX2PS7IXNjx8UNO6FAb/Pi1IGugZ2mwWLrhMzZAQ//tmbDHf4wv51CdFThTuLGJw0IaqnrK1cuZJ3PvyQoqIiHn74YbPL6bd2796N2+3mySefNLsU0QqZoB4BZY2B0J4I7RxTeeMqsQlJjJicQfEv/635+ZFTp1Nx40rzY0MZeLweautqqagop7KinIb6GjTDR1yMjeTEOFJTkkhISCDGYUfTLVSqxAh+dUJ0T3VNNbt2HWfZ0uheRyAxMZFlc+aw7f33CQaDZpfTLyml2LlzJytXriQlJcXsckQrJBxEQHljEL2DGQqVN68xYkoGJ4q3MDFnYfPz05cWkDhkBHX1dVRU3qHiThnuumpUwEOMw0JSQiwpyYkkJiYQExODzWZrvo6mgaYpygLSFCqiT0lJCY11ThYtXNjxwSYrKCig+vJlDh8+bHYp/dL58+c5c+YMzz77rNmliDZIOIgAd8BosbtiaybNDv2CPF74AdOXrW5+3uvzMmzyNIK+Bi7uL8R9+wrndm3jTMlWYmNjsdvt6Hrb3zZDaTQYsja5iC6GMti2zUXOjEXE9IF+/DFjxjBj7Fi2fPih2aX0S0VFRSQlJbF48WKzSxFtkHAQAcEOgkGTxvpabpw91RwUAC6dOMKQ0aNxWHX2/fq/GJeRRdaK1bz7o7/p9PUDMhNBRJlPPv6E82crWb58udmldNpDBQWcO3SIy5cvm11Kv+LxeCguLuapp55q9w8dYS75zkSApZMjb6tuXCN1+Ojmx4YyMIwANpuNmPgE/vBf/it03M1rTJzV+c1prHQunAjRW3bu3Elq/FgmT55sdimdNmvWLEbExrLlo4/MLqVf2bdvHxUVFTz99NNmlyLaIeEgAuKseqcWPnLGJ7R4fHT7e0xdtByr9d4kkgPvvUPRf/+cZ/6qc4uE6JoiVvd3rWAhIqiuvg6X6yi5i5ebXUqX6LpOwYoV7Nu5k5qaGrPL6TeKioqYN28eI0aMMLsU0Q4JBxEwOMaCoRSqg+6F1BGjyVyaz4HNGzhR9BGpYyditehYLPfWJJj7mbXMXbOWj372Tx1eVylQSmOItaHHX4MQ4bKrZBf11TaWLFlidildtmzZMmK8Xnbu3Gl2Kf3C9evXOXr0qGzN3AdIOIiAITFWdE3D6MSxD734deaueYrpy1YTnzoEu+1eq0FjfR0Ak3Lmc7xoC+cO7Wv3XAYamqYYLOFARAmFYtv2ImZmzO+Te3bExsayYv58dnzwAYFAwOxy+rzi4mKsViurV6/u+GBhKgkHEZDmtBBn0wgYne/7DxpBDCPQ3KUQ6k64t/5BbGISsQntr18QUDpxmp80S2P3ChcizM6ePcuZT8pZtmy52aV0W35+Pg03brBvX/vhXLQvGAxSWFjIE088gc0mM6qinYSDCLBoGlmpTtDosGuhic/nQ9doDgfTlxUwamom5w7t48Of/Yi5n1nLiMnT2nx902WyYsqwaDIgUUSHwp2FJDhHM21a2z+70W7YsGHMmTqVj95/v9P/P4tPO3ToEDdu3JAuhT5Clk+OkMxUB/vKGgkosHVi8oLP58NmtTRP7YmJT2D60nwg1K3QkQA6Fs0g03GnR3ULES7uBjc7Cw+yfPHn+/za+asLCvjOv/wLZ8+eZcqUKWaX0ycVFxeTnp4u718fIS0HEZJot5CV6sBAdbggEij8Pi82W/eymqFCix9lOctJtMieCiI67Nmzh7oqS58ciPig6dOnMy45WaY1dlNFRQV79+6VVoM+RMJBBC0eHkuy3YK/g5kLgWAQpYwWUxg7SynwKwvJFg+LY6/2pFwhwkah2LatiMzJc0lKSjK7nLB4KC+PQ8XF3LkjrXNdVVJSQiAQ4LOf/azZpYhOknAQQQ6LTv6oeHRNw2e0HRB8Ph8WnS6HA6XApyzomiI//hIOvTPzI4SIvAsXLnDq5E2WL19hdilhs2jRIhINg+3bt5tdSlTQdZ3k5GRGjhzJ6NGj2/145JFH2Lx5c5+csTJQyZiDCBuTYGPVyDi2X3PjMxR2nU/1v/q8XmxWa5f6Ze8PBqviLzHGXhvu0oXotqKiIuJsw8jMzDS7lLBxOBzk5+by3ocf8vjjj+NwOMwuyTS6rjN69Ojm96Cj313Tp0/vjbJEGEnLQS/ISnOyalQcuq7hUy3HICgUfr+vS+MNjAeCQZazPBJlC9EtjZ5Gduw4wJKFK/rd2vmrVq0iWFFBSUmJ2aWYKikpCYfDgdbB7rOi7+pf/+dGsaw0J2vHJzaPQfDf7Wbw+/1oqE51KYTGF+jNYwzWJp6WYCCizt69e6kuN1i6dKnZpYRdWloaC2fMYOsHHwzoaY1xcXFmlyAiTMJBLxqTYOO5KUnMSnOiaeBTCl9QYbXZ0HVLq69RCoJKw2tY8CkLGopZMbd5LvmEdCWIqLRjRzHpE+eQkpJidikRUVBQQNnZs5w4ccLsUkxjsVikxaCfkzEHvcxh0Vk5Kp45Q2I4WenloxMXcSYk4MeKphSGuvc/nK4plAotiRyv+8iKKSPTcUemK4qodfnKFY4dvcoLn3/W7FIiZvLkyUwdNoyPPvyQGTNmmF2OEBEh4cAkiXYLWQmKP/qzz/BX/+srTJy9krJALA2GjQAaVkK7Kw6xNjDY2kCapVFWPhRRr6ioEKc+mJlZWWaXEjmaxqOPPsq/btpEeXk5Q4cObfdww5BZRKLvkXBgon379pFgdTN34mCGO+/Qf8Z1i4HI6/OyY8c+Fs1/FN3SejdZX2ZPTiZp/HjsSUmMWr6cx//szzr1OqUUDQ0N3LlzB4/HE+EqTXT8OLhcMGYMVFfDc8+ZXZHoARlzYCKXy8Xk0SkMGzbM7FKE6LH9+w9QftPfLwci2uLjGTxzJvakpC73tWuaRmxsLKNHj+6/Gw7V1sLXvw4vvwwzZoQeA7z1VigwfPe75tYnukzCgYn27NrJvFnT0JCBPaLv276jkEljsxk8eLDZpYRd3PDhQMfz+dvSNOWvv6wW+SnFxTB2bCgIQCgkNAWE3Fy4cgUuXzavPtFlEg5MUl5eTvnVj8nIzDC7FCF67Nr1axw5dInlS5ebXUpE2JOS0Hq4ZoOmacTExISpoiiTlATZ2aEgMHZsKAgkJoa6Ft56K9TVMHas2VWKLpBwYJJdu3aRFmeQkSHhQPR9RUVFWIxUcmbPNruUiOhpMGg+T3+d/pebC5WVsHlz6OPKlXufe+65UCuCtBz0KTIg0STFxcXMnDaS5KRks0sRokf8fj/bt+9l8fyHsfTDgYiik775zZaPN28OtSjk5sK4cfDee6HuBtEnSMuBCZRSHNizk+wZ6WaXIkSPlZaWcvuGl2XLlpldiogmS5dCTU0oJFy6JMGgj5GWAxNcunQJX911MjOfNLsUIXpsx84ixo+c3uF8/35nAE/d69TS0YmJsGZN6L+b/i36DGk5MEFxcTGDEzWmTp1qdilC9Mit27c5sO8My5b2n62ZO6WtqXvf/W7oL+Wf/tTc+iLM4/EM6L0lBgIJByZwuYqZlzWBGGc/HbksBoyiokIIJDNnzhyzS+ldrU3d27w51Le+Zk3or+amz/VDNTU1QCdbEESfJOGglwWDQY4dLGbG9GlmlyJEjwSCAbZv282COUv77+I+bWlt6l519b0WBAh1O/RTXq+X69ev4/PJPi/9lYSDXnb8+HHsRg2ZmdPNLkWIHjl8+DA3rjYMzIGIrU3de+650HMuFxw7ZnaFEed2u7l06RLnzp3j/PnzvPjii3xXVkLsN2RAYi9zuVyMGGRnwoTxZpciRI/s3FnE6KHTGDVqlNmlmOPBqXsAf/zHoS6F48fhM5/p/ZpMEAwG+eSTT3C5XPz85z83uxwRJhIOelmJq5gFs6ZitchbL/qu8jvl7NvzMevXfNXsUqJHbe291QAH2IqARUVFDBo0iPnz55tdiggTuUP1osbGRs6e2MOXH37C7FKE6BGXy4XhTWTuvHlml9IrjEAApVT7KxwmJrY7l18pRTAYjEB15mpoaKCkpITnn3++/64AOQDJmINedODAARIdXlkyWfRpQSPI1q0u5sxagsPhMLucXuGprAzLeRoaGsJynmiyZ88eampqWL9+vdmliDCScNCLiouLmTgqiZEjR5pdihDdduzYMa5eqmf58uVml9Jr3DduELw7t7+r0/eaXuPz+ZqnAPYnRUVFLFy4kCFDhphdiggj6VboRXt3FfHZuemyRbPo03buLGR42mTGDqA+dcPno+zQIeJGjMCZmorewdTNutpa3F4vg4cORSlFfX09NTU1GIbRSxX3jsuXL3PixAl+9KMfmV2KCDMJB72ksrKSm5eOk/H882aXIkS3VVVXsXvXSR4reMnsUnqd4fNRd+kSdZcudXhsRUUFf/K//hfr//iPycvLi3xxJikuLiY2NpYVKwbYCpkDgHQr9JJdu3aREhskM1PGG4i+q7i4GH9DLAsWLDC7lKiWlpbGwqwstn7wQb9dRdDv91NYWMgTTzyB1Sp/Z/Y3Eg56icvlYsaUYaSmpJpdihDdYiiDbdtc5GQtJiZGlv7uSEF+PnfOneN4P10psbS0lLKyMp555hmzSxERIOGglxzYU0hOliyZLPqukydPcul8zYAaiNgTkydPZurw4Xz04YdmlxIRhYWFzJgxg/HjZUG3/kjCQS+4fPky7spLZE7PNLsUIT5Ns4AjDZxD2/24fNtL/iO/R+b8+diTk0HmtHdodV4ep/bu5caNG2aXElZlZWUcPHhQWg36Meko6gUlJSUMSoD09HSzSxGipfgJkJgOuqXDQ9c833LBIyMYpPrsWRpu3oxUdX3e/PnzGbJpE1u3buX5fjQY2XV3x8nPDJAlogciaTnoBS5XMXOzxhMbE2t2KULc4xwKyZmdCgat0XSd1PR07ElJYS6s/7BYLOQvW8aurVtxu91mlxMWwWCQwsJC1qxZI2NP+jEJBxFmGAZH9heRJVs0i2gTOwpU9+fda5qGMgxihw4NY1H9z4oVK9BraykqKjK7lLA4ceIEV65ckS6Ffk7CQYSdOnUKS6CSzEwZbyCijC0BtJ79CtB0HVustIi1JzExkWVz5rDt/ff7xd4KRUVFjB49mhkzZphdioggCQcRVlxczPBUGxMnTjS7FCEeEKYBhbr8GunI6tWrqblyhdLSUrNL6ZHa2lr27NnDs88+K5ss9XPyf3WE7SpxMX/WZGzW9pdbFUL0X6NHjyZr3Di2fvSR2aX0SElJCQ0NDTz55JNmlyIiTMJBBPl8Pk4f2810WRVRiAHvoYICzh8+zMWLF80upVuUUhQWFrJy5UqSZBBqvyfhIIIOHjxIrLVBxhuIvuP4cfjpT2HzZnjrLbOr6Veys7MZGRfHlj7aenDu3DnOnj3Ls88+a3YpohdIOIggl8vF+OHxjBo1yuxShOhYbS18/evw8sswY0boMYSCgssVCg2i23Rdp2DFCg4UFVFdXW12OV1WVFREcnIyixYtMrsU0QskHETQ7pIi5s1KR+/hiHAhekVxMYwdGwoCEAoJly/DlSuQmwtjxoQei25btnQpMV4vO3bsMLuULmlsbKS4uJinnnoKXQagDgjyXY6Q2tparp47zHRZMln0FUlJkJ0dCgJjx4aCwNixcOQIPP10KCSMHWt2lX1aTGwsK+bPZ+eHH+L3+80up9P2799PVVUVTz/9tNmliF4i4SBCdu/eTUpsgIwMGYwo+ojcXKisDHUjbN4cCgO1taHA8NWvwi9+ERqTIHqkoKCAxps32bt3r9mldFphYSHz589n+PDhZpcieonsrRAhLpeLjAlDGDxosNmlCNF53/xmy8dvvQWf+UyoxeCXv4T33guNRxDdNnToUOZNm8ZHH3zAkiVLon69gGvXrnH06FG+//3vm12K6EXSchAh+3bvZHa2bLQk+rhHHw0FApcr1Grw3HOfPkap3q+rjyvIz+fGqVOcPn3a7FI6VFxcjN1uJz8/3+xSRC+SloMIuHHjBjW3z5ORkWd2KUK0LegBa1z7Wy8nJoYGJrZBGQZBrzcCxfVvmZmZTEhNZctHH0X1bq2BQIDCwkKeeOIJbDZZyG0gkZaDCHC5XKTFG2RMk82WRBRr7PlWy5qu46moCEMxA8/qvDyOuFyUl5ebXUqbDh06xM2bN2WTpQFIwkEEuFwuZk8fS3x8vNmlCNE29xXw3L0xKQOUgWEECPh9GIaB6uADoOH2bRrKykz8IvquRYsWkaxpbN++3exS2lRUVER6ejqTJ082uxTRy6RbIcyUUhzaV8ifPjPL7FKE6IABFfvBkQaOQaDbOHjgIP7GGGbOzG7/lX4/nspKfDU1vVNqP2S321m1eDHvffQRjz/+OE6n0+ySWqioqGD//v385V/+pdmlCBNIOAiz06dPozxlZMr6BqJPUOC9A947XL12lb/9+mt86dlXqI6RbZh7Q15eHr/dsYOSkhLy8qJrjJLL5SIYDPLZz37W7FKECaRbIcxcLhfDki3SDCf6nMKdhdi1QcycOdPsUgaM1NRUFmVlsfX991FRNOvDMAx27tzJww8/TFxcnNnlCBNIOAizElcx87InYbfZzS5FiE7z+X1s37GXRfOXY7FYzC5nQCkoKODO+fMcO3bM7FKanTp1ikuXLslAxAFMwkEY+f1+Th4uYcZ0WRVR9C0HDhyg/KafpUuXml3KgDNp0iTShw/now8/NLuUZsXFxYwcOZKcnByzSxEmkXAQRocPHyZWd8sWzaLP2bG9kIljZjJkyBCzSxmQVufn8/G+fdy4ccPsUqivr6ekpISnn3466ldvFJEj4SCMXC4Xo4fGMGbMGLNLEaLTbty8waHSiyxftsLsUgasefPmMdThYOvWrWaXwu7du3G73axbt87sUoSJJByE0S5XIQty0rHo0mcr+o6ioiJ0I0WakE1ksVjIX7aMXVu34na7TatDKUVhYSFLly4lNTXVtDqE+SQchEldXR2XTpeSmSnjDUTf4Q/42b5tNwvnLsNqlZnNZlqxYgXWujoKCwtNq+HixYt8/PHHPPvss6bVIKKDhIMw2bt3L8kxfhlvIPqUQ4cOcfO6l2XLlpldyoCXkJBA7uzZbP/gA4LBoCk1FBUVherIzTXl+iJ6SDgIE5fLxdRxaTKgS/QpO3YUMm5EJsOHDze7FAGsXr2a2itXKC0t7fVre71eXC4X69evl+msQsJBuOzdXcic7HQ0ZHSv6Btul91m/94zLFsqAxGjxejRo8kaP54tJkxr3L9/P+Xl5Tz99NO9fm0RfSQchMHt27epvH5auhREn1JUVASBJObOnWt2KeI+DxUUcOHIES5evNir1y0qKiInJ4fRo0f36nVFdJJwEAYlJSV3t2iWwYiibwgEA2zfvov5s5dis9nMLkfcZ+bMmYyMi2PLRx/12jVv3rzJ4cOHZSCiaCbhIAxcLhfZ00aRmJhodilCdMrRI0e5frmB5cuXm12KeICu66xeuZIDRUVUV1f3yjVdLhcWi4WHHnqoV64nop+Egx5SSnFwz05mZUmrgeg7dhYWMmpIOqNGjTK7FNGKpbm5xHi97NixI+LXCgaD7Ny5k8ceewy7XfaEESESDnro/PnzBNw3yZD1DUQfUVFRwZ5dp1gqAxGjVkxsLCsXLGDnhx/i9/sjeq0jR45w/fp12WRJtCDhoIdcLhdDknSmTplqdilCdEpRcTFBbwLz5883uxTRjvz8fDw3b7J3796IXqe4uJhJkyYxbdq0iF5H9C0SDnqouLiIeTMn4nA4zC5FiA4FjSDbtrmYk71Efmaj3NChQ5k7bRpb3n8fpVRErlFVVcWePXuk1UB8ioSDHggEApw8VMKM6ZK4Rd9w/Phxrl6sY7msiNgnrC4o4PrHH3P69OmInL+kpAS/389jjz0WkfOLvkvCQQ8cPXoUB7WyvoHoM3buLGJI6iTGjR9vdimiEzIyMpiQmhqRaY1KKXbu3ElBQQEJCQlhP7/o2yQc9IDL5WLUYCfj5Ret6AOqqqvZtesEy3JlIGJf8lB+PkdcLsrLy8N63k8++YQLFy5Il4JolYSDHthVUsT8WVNki2bRJ5SUuPC5Y1i4YIHZpYguWLhwIcmaxrZt28J63qKiIgYPHsy8efPCel7RP0g46Cav18uMaRMpePzLEDv6gY9RYI03u0QhmhnKYNu2YnJmLCImNtbsckQX2O128pYsofijj/B4PGE5Z0NDAy6Xi8997nNomuwHIz5NNnDvJofDwV995wftH+SthDv7QAV6pygh2nDq1CkunKvmz/9wudmliG7Iy8vjN9u3U1JSQl5eXo/Pt3v3bmpra1m3bl0YqhP9kbQcRJI9GVJmmF2FEBQWFpKWMI5JkyaZXYrohpSUFBZlZbE1TNMai4qKWLJkiWwxL9ok4SCSNB1ihiNvszBTbV0truJjLFsiAxH7stWrV3Pn/HmOHTvWo/NcvnyZkydPykBE0S65a0WaZgGLLDYjzFNSUkJDrZ1FixebXYrogYkTJzJtxAg++vDDHp2nuLiY2NhYVqyQsCjaJuFAiH5Modi+3cXMjPnExcWZXY7oodX5+Xy8bx/Xr1/v1uv9fj+FhYWsXbsWq1WGnIm2STgQoh87ffo0Z0+Xs3y5/JXYH8ydO5ehdjtbt27t1usPHDhAWVkZn/vc58JcmehvJBwI0Y8V7iwk0Tma9PR0s0sRYWCxWChYsYLd27ZRX1/f5dcXFRWRlZXFuHHjwl+c6FckHLTl+HH46U9h82Z46y2zqxGiy+rd9RQWHmKpDETsV5YvX461ro6ioqIuva6srIyDBw/KQETRKRIOWlNbC1//Orz8MsyYEXoMoaCwenXLY996C1wuCRAi6uzevRt3jZUlS5aYXYoIo4SEBJbOmcO2998nGAxiKKgxLFwJ2Dnhi6XUG8cBbzyl3jhO+GK5ErBTY1godpWgaRqf+cxnzP4SRB8gI1JaU1wMY8eGbvpjxoRCAsCaNfCLX9w7zuUK/Ts3F2pqQuFhzZrer1eIB4QGIhYzfeo8EhMTzS5HhNnq1atx/Z9/pfByFcHhk/AqDYWGhsJAQwMUoKNQONFQeOesYe2odHy6DafZX4CIetJy0JqkJMjODt30x46Fy5dbP+748dDnm15z5EhvVShEu86fP8/HJ2+xbNlys0sRYeZD5/akOaz4h/9HVeo4PEpHB6worBrYNYVNU9i10GMrCiMQwBafRNyMxfz7J9XsuFaPN2iY/aWIKCYtB63JzYWiolBLAIRu/E0h4EE1Nb1XlxCdVFhYSLx9hGwn3s/ctsSxP3YEbt2GLRCgob4OhyURvZ1piZoGfq8HIxjEGR9HQMHhCg8X6/zkj4pnTIKtF78C0VdIOGjLN7/Z8TEzZtxrVaipCbU2CGGyhsZGCnceZMmip2RTnX7kvD2FUucwDDSsykCz6Hg1Da/X2+6aBYZh4PP7iYuLQ9M0bBoYCqp9Qd65WMuqkXFkpUlHg2hJuhW6wuWCK1futSjk5oYGKzY9L+MNRBTYu3cPVeWwNDfX7FJEmJy3p3DQORxD07Bi0BT5HA4Hfp8Pw2i7i8Dn86GUwum8FwB0TcOuaRiGYvs1N8cqwrPbo+g/NBWOXTxE25QBNz4EFTS7EjFA/K9v/jW+6tH8yZ/8qdmliDC4bYmjKG5MKBioe8EAQClFTW0t9pgYYmJiPv1ipaitrcNi0UlKSm7l0wqfodB1jbXjE6WLQTSTloNIUgZ4yiUYiF5z6fIlTh6/LgMR+wkfOvtjR9zrSnjg85qm4bDb8Xm9re7WGAgGCQQDxMTEtnp+TdOw6xqGUmyVQYriPhIOOikYDBII+FHKCN30O/oACHqgumc7qAnRFUWFRTj0IWTNkK3C+4PjziG4dVuLroQHORwOVDCI3+f71Od8Xi+6rmOzt90iEBqHoFHtC7LrZkOYKhd9nQxI7KSNG37JpGE6c2bP7vhgZYC/JtRqgPTaiN7h9XrZsWMfSxY+gW6xmF2O6CG3ZuO8PQVNqTaDARC6+dtseLxe7HZ7aHoCoS4Dr89HTEzM3ZUP2jmHpqErOFbpZc6QGBLt8vMz0Ek46ITq6mpe//af809//XtQG292OUK0at/+fdy5HWTpHyw1uxQRBhftyc3jDDridDioc7sJBAJYbaFWAp/PhzKM1scitMKqgU8pTlZ6WTis9W4IMXBIt0In7Nq1i9TYIBnTMswuRYg27dhRxJTxs0hLSzO7FNFDBqEZCkAHf/OHWK1WLLqOx+ttfs7n82Gz27HonWsF0DQNFByr9BCUceoDnoSDTnC5XGROGiq/dEXUunbtGkcOXWb5MtlkqT+o0Z14dCuWLtyknQ4HAZ+PYDBIMBjE7/d3utWgiVXXcPsVFR4ZRD3QSTjohP27dzI7e5rZZQjRpsKiQmzaILJlIa5+ocrivLtHQvvh4PzhfZx0beXg++9gs9nQgLe//Wfs++0vQzMZHI4uXVcnNFahvDHQ/eJFvyDhoANXr16lruIimRmyDK2ITn6/n+3b97Jw7lIsMhCxX6i2ODsciFh58xoxCYkMnzQN18b/aJ7WOHj8FO5cu4zT6exwIOKDNE1D0zTKGqXlYKCTcNCBkpISBidA+rR0s0sRolUHDh6g7KaP5cuXm12KCBOPbkV1sPR11a3rjJg0jVMl25iYPQ8ITWucNC+XxMHDu9yl0MRQioaArHcw0Ek46EBxcRGzp48jLjbO7FKEaNWOHUVMGJXFkCFDzC5FhEmwE3/xT5w1H4DjRR+RvngljY2N1NXXowzFpLmLsVqs/PdrX6OxvrbL1w/IgMQBT6YytsMwDI4cKObPvzDX7FKEaNXNWzcpPXCez6/9U7NLEWFk6WCsgVIKv99PXXUl18+eIm1iBo0+Hw6HE/edm0xe9Rkqb1zlRPFHnCvdDYDHXcfqr/w5Sz/35Q6vb5UNuwY8CQft+OSTT9C8d8jIkCmMIjoVFRWhGynM7sziXKLPcBoBtAf+ejcMA7/fH/oIBDCU4s7Na6QMH0VKWhpWqxUNDZvVhoZG5c1rfPM3e4mJTwTgwOYNzF3zVIfX1jWNWKs0Kg90Eg7aUVxczLAUK5MnTTa7FCE+JRAMsH3bbhbMycdmkw1z+pPkoAelafj9AQJ3w0AgEERpYLPbiU1IwOFwYA36segWbNbQ9/9E0UdMX7YagEmzFzaf78DmDUxf/lCH11VKoZRiSIwMbB3oJBy0o8RVzIJZk+UXr4hKhw4d4sa1Rl58bpnZpYgw8Xq9nDp1isNXbqKv/yoBnxelDOwOBwnx8djtdnTt3l/1qSNGk7k0nwObNxCTkMSIyZ9u5ay8cZXG+rrmFoT2GIRmLAyOkVvDQCc/AW3w+Xx8cmw3z/zRw2aXIkSrduwoYsywDIaPGGF2KaIHqqurOXLkCIePHOHAiRNUeL0MmTCRgrV+7EnJxNgs7U5JfOjFr7d7/v2b32bS7MWdqiVgKOLtOmlOaTkY6CQctKG0tJRYi5vp02V9AxF9ysrL2Lf3E55+9I/MLkV0w9WrVzl06BCHjh7l6LlzuIGMOXN49i/+goKCAiZMmMCeWw3svt0Q2rutB+MDTxZvZd6apzs8TikFGmSlOrHIgMQBT8JBG1wuF2OHxTF69GizSxHiU4qLi9H8ScybN8/sUkQnBAIBPvnkEw4fPsyBI0e4WFaGERvLguXL+cZXv8rKlStJSUlp8ZrMVAf7yhoJKLD14F7tjE8gJjGp4xoVWDSNzNSuraoo+icJB23YXVJEfs60Fv17QkSDoBFk27ZdzM1ZGtqiV0Sl+vp6jh07xuHDh9l//Dhlbjfxw4ax8uGH+dPVq1m4cGG745kS7RayUh0crvBgqNAsgu74w3/d1OExhlIYKGalOmW7ZgFIOGhVbW0tV84eIvPpZ80uRYhPOXr0KNcu1/P8+uVmlyIecPv2bQ4dOsThI0c4dPo0NYEAE7Ky+Owf/AEFBQVMmzYttPthJy0eHsvFOj/VviB26NJrO0sphV8pku0WFg+XrZpFiISDVuzZs4eU2IDspyCi0s6dhYwYNFW6vKKAEQxy7vz55u6CM9eu4bXZmJObyx89/zx5eXkMHTq02+d3WHTyR8XzzsVafIbCroc3ICil8BkKXdfIHxWPwyItpSJEwkEriouLmTZhMIMHDza7FCFaqKisYPeuUzz58B+YXcqA5fF4OH78eKi74NgxbtTUYE9JYVlBAV9avZrc3Nxu72vQmjEJNlaNjGP7NXdYA0JzMNA0Vo2MY0yCTNkW90g4aMW+3YV8fpVstCSiT3FxMYHGOObPn292KQNKRUXFvemGJ09S7fczYvJkVn3hCxQUFJCdnY2uR+6v7qw0JwDbr7vxKYWN7o9BgNAYA78KtRisGhnXfH4hmkg4eMDNmzepuX2OzMyVZpciRAuhgYglzJm5GKdTfplHklKKy5cucfjIEQ4ePsyJS5do1HWmz5vHl775TfLz8xkzZkyv1pSV5iTZbmHrtXqqfUF0BVata60ISikCCgxCYwzyR8VLi4FolYSDB7hcLlLjDaZNm2Z2KUK0cOLECa5crOVzf7LC7FLCRrNaiRs+HGdaGrq1Z7+OVCCAp6oK982bGD5fl1/v9/tDqxMePsz+I0e4XFGBFh/P4lWr+Ou/+AuWL19OYmLHqwxG0pgEG89NSWLXzQaOVXrxKQWGwqpr6LQeFJRSGIQWOEILTVeclepk8fBYGWMg2iTh4AEul4uczDEkxCeYXYoQLRTuLGJw8kTGjx9vdilhoVmtDJk1C2tcaDv0nvajK6WwJycTN2IE5YcOEfR6O3xNbW0tR48e5dDhwxw4fpw7Hg+po0ez8okn+J8FBcydOzfqlk93WHRWjopnzpAYTlZ6OVbpwe1XBJRC00JdBk10TUMphaZpxNt1slKdZKY6ZLqi6JCEg/sopTi0r5A//txMs0sRooXqmmp27TrOZ1a+YHYpYRM3fDjWuLiwjb5vOo/Fbid+1Chqzp9v9bjr169z+PBhDh05wpGzZ6kzDKbMmsX6r32NgoICJk2aFJEpg+GWaLewcFgs84bGUOEJUt4YoKwxSEPAIKAU1ru7Kw6JsTA4xkqa0yIrH4pOk3Bwn7Nnz2I03iYzU6YwiuhSUlJCY52TRQsXdnxwH+F8YEXAcNF0HWdaWnM4CAaDnD59miOHD3Pg6FHO3bxJ0Olk7tKl/PmLL7Jq1SoGDRoUkVp6g0XTGBJjZUiMFfnNJcJFwsF9XC4XQ5MsTJkyxexShGhmKINtW4vJmbGImNj+s0iNbrNF7C90zWJh7969zdMNb9XXEzNoECsfeog/KChg8eLFOByyTLAQbZFwcB9XcRHzZ03CbpMlaUX0+OTjTzh/roo/e3m52aX0GTW1tfzvf/5nxmZk8NBXvkJBQQHTp0/vE90FQkQDCQd3+f1+Th3Zxdo/WGV2KUK0sHPnTlLjxzJ58mSzS+kzYuLj2X7gACNkO2shukXCwV1Hjx7FSZ2MNxBRpa6+DpfrKPlLnje7lN5x/Di4XDBmDFRXw3PPdes0sbGxxPajLhgheptMcr2ruLiYUUOcjB071uxShGi2q2QX9dU2Fi9ebHYpkVdbC1//Orz8MsyYEXoMsHkzrF7d8tjNm0Mf3/1u79cpxAAg4eCuXSVFLMhJx6LL/F8RHRSKbduLmJkxn/j4eLPLibziYhg7NtRyAKGQALBmDSQn3ztu82ZISgo9n5oKb73V66UK0d9JOADcbjeXPjlIZmaG2aUI0ezs2bOc+aScZcuWm11K70hKguxsyM0NhYTLl1s/bs2a0DEAly7BTFmXRIhwkzEHwN69e0l0+sjMnG52KUI027ljJwnO0f12Ke9gMNjyidxcKCoKtQxAKCy0183nckFWVqgLQggRVhIOCK1vMHVsKkOHDjG7FCEAcDe4KSwqZcWS5/rV9Ltbt25x6NAhDh05wh//4AdkPLgQ0je/2bkTHT8ONTWhAYvHj0tAECLMJBwAe3cXsm5xOhr955ew6Nv27NlDXZWFJUuWmF1KjxjBIGfPnePw4cMcOHyYszdu4LPbmZOby9BRozp3EpcLrlwJtSisWRPqbnjxxdCMhp/8pPOBQgjRaQM+HJSVlXHn6sdMz+w/a9aLvk2h2LatiOlT5pm+C2B3NDY2cuL4cQ4fOcK+o0e5WVuLMy2Npfn5vLB6NUuWLCEmJqbzJ8zNhT177j0eO7blYyFE2A34cLBr1y5S4wymZfTPfl3R91y4cIFTJ2/yB1/8ktmldFpFRQVHjhyh9NAhSj/+mCqfj5FTp5L3/PMUFBQwc+ZMdF3GPwvRVwz4cFBcXEz2tFEkJSaZXYro54JKoyIYQ1kglvJALG7DRhAdCwZxup/B1gaGWBsoLC4hzjYsqhfkUkpx6dIlDh8+zMHDhzl5+TIei4UZ8+fz5f/9v8nPz2f06NFmlymE6KYBHQ6UUpTuLeQP1kqrgYic2qCdk95BHGscglvZMJSGrikMdW+MS9NjHUXd3G8wd9h5Gi124lTAxMpb8vv9nDx5MrSZ0ZEjXKmsRE9IYEleHn/9yissX768T3aDCCE+bUCHg4sXL+Kru05GxlqzSxH9kNewsKthFMc8gwmqUJO6VTOwaQaaBg+Of1WAx+/HkjCI4KxRvIdioq+KLE8ZNoxerx+gpqaGo0ePcujwYfYfP06l10vq6NHkrVvHX+bnM3fuXKzWAf1rRIh+aUD/X11cXMyQJJ2pU6eaXYroZ674EtlSP56aoANdU9i1IB3NSNQ08Hs8aMqCzWEniMZZRyo3bfHMbbjJ0KC7V2q/du0aRw4fpvTIEY6cPYsbmJqTw+f+/M8pKChg4sSJ/Wp6pRDi0wZ0OHC5ipk7cwJOh9PsUkQ/cswzmO314zCUhk0LonfyPhoMBvn/27v32Crv+47j7+d5ztU3nGNssDEOVxswJSUkLISwXJiBJumadl2yrGmrdr1J7TRFy7pVatVpqrRpUjdplab9sUu3TlrTrlJUpZpolynEJFlCAsxAUpxwMT7HNrax8eX4XJ/ntz+OOXASY2xj42PO5yUdCT8+POd3LDj+6Pf7/r6/bNalLBzGAnwYjDGMWwEOlTexI9nH+vTwvI/XdV1Onz6dWy44doyzFy+SDYW476GH+JOvfY29e/dSU1Mz768rIsWrZMOB67qcfLud3/7yg4s9FLmNXBsMZjJbcK1UKgXY+P3+/LVcSPDIWjZvheoB5iUgTExM0NHRwdGjR3mzo4OL8TjldXU8cuAA32hr4/777ycYDN7064jI0lSy4aCjowO/N0Lr1uKtCJel5UK6as7BwBhDKpUh4P/wLJYF+EwuILwdWkmFm57TEkN/fz/Hjh3j6PHjvP3uu4xks6zdupVHv/pV9u3bR2trq5YLRAQo4XDQ3t5OY22QtWvXLvZQ5DaQ8hx+Ob52TsEAIJ3J4HmGYDAw5ffzAQGbI2X17B87e8MiRc/zOHv2LEePHuWt48f5dXc3KZ+Pux94gK9/73u0tbWxcuXK2Q1UREpCyYaDVw8f4r7tLTqiWebFqxONjLhB/HMIBgDpVAqf45+2UdCVJYZxO0BHqI4dyb4PPSeVSnHy5EmOHTvGG8ePExsZIVBdzZ62Nj733e+yZ88eysvLZz9AESkpJRkOEokE7518gy89+qnFHorcBkbdAB3JWmzLzLj48Fqu65LJuDNqKWwBljGcCdzBptQg5SbL8PAwx48f5+ixYxw5dYrhdJqV69ez9zOfYd++fWzfvh3HUQgWkZkryXDw5ptvUhVMFXUHOlk6TqWW4xqbgOXe+MlTSKXTgE3gmkLE6TgY0sbiF+/3cOJHf8+Js2eZsCy27tzJ57/1Ldra2lizZs2cxiIiAiUaDtrb29nQWE19ff1iD0WWONdYdCRyR33PZTnBGEM6lSbgn35ngDGQzWbIZHIPKxhmpKGZUGsr3372WR5++GGWLVMLcBGZHyUZDl4//DKf2KkjmuXmXXLDxI0fnzV9ceD7R98gOT5KYmyUex/LdeT8j7/4Jmvu2kHzA/spK/twOPCMITsZBtKZDJ4x2I5DIBQiEAxSXrGaP/r+31EXLsn/xiKygErumLShoSF6z59gS+uWxR6K3Ab6s2X5MxGuZ6gnSlllFQ0bNvPK8z/MX1/VsoX+7i58jh9nshDRdV2SySRjY2NcHhlhLB4nawzh8nIiNTUsX76cqsoqgn4/xsBAonjOXhCR20fJhYPDhw9TU+Gp3kDmxUC2DNsy0y4pDPXFaNi4mZPt/8367b+Rv75598NU1dbjOA6JRIKR0VFGRkeJJ5PgOFRUVbG8tpZIpIby8gp8Pj9XDmSwLAvLsuhPzK3OQURkOiUXDtrb2/lIcz13VN+x2EOR20Dc8xecrjiVDXfnAsGJl3/Jlj2PkE6nSSQSTExM0Nh6NxPJJD/9qz9jsPsc8Ut9HH3hR1RX30E4FMaeZqutZwwT2cU5kElEbm8lt1h55PWX+YPHNy32MOQ24U6Rr40xeJ6H67qTD4/46AjRzneIrN3CyNgEluXQe/YsWx/cTzAYZGJ4kOe/+4esatnK09/92xm/ftZcfzlDRGSuSiocdHV1MTHcxdbWjy/2UOQ2kHWzpCbG8axlJFIJPM8jm3XJuh7G5I5gtiwbx/ExOjhIpH41VdURfD4ftmXTV15BRXkFAA8+/WW2Prh/1mPwqd2xiCyAkgoH7e3t1FVZtGzSEc0yc1k3y8WLF4nFYvnHma5e3j9/kZWPPUvTw7+Hm8rgOD4cX4hwyIfP58uHAACTqMe2bQL+XHvkk4cOFoSB6OkTACTGRgC49/Enbzgu27Io85XcyqCI3AIlFg5eYcdH1lAWLlvsoUgRcj33QyHgbFcv73dd5HLcJZ60CVYsp3HNZjY038PvHmhh2eYdvOevIFhROe2hRZGG1bT+ZhtHXvwJ4cplNGws3C1z4CvP5f/8/Wf2s/WhA4Qrqq57P2NyxznXhdX5UETmX8mEA8/zOP7mIf70i/cv9lBkkbmey8DAILFYlGg0SiwW4/yFPjrP9eZCQMrGF47QuHYTG5sf45NtzbS0tNDS0kIkEim4V38iy5nOETzgRr+mrw0A1zp56CDR0yfy3w9VVDLcEyXcfP3tth65HQu16nEgIgugZD5ZTp06hc8dZssW9Tco4IQgvAoCy8C6iSlqA3hJSPRBanDehnczPOMxMDBAT6yHaCwXAs5d6KXzXC/DY1niKRsnVE3jmk1saN7PJx5pobk5FwSWL18+o9eoCTmU+y3G0x6OM7f1/0j9akLXzBIkx8domCYYAGQ9Q0XApiakmQMRmX8lEw7a29upj/jZsGHDYg+lePiroPZ+sObrn4GBirUwdgZG3pmne96YZzwuDV4iGovS09NDLBrjXHcPnWd7GRrLMJ6ysP3LaLizhQ3Ne3lsT24WoLm5mbq6ummXA27EsSy2RUK8dnECY8yc7tXQvIWThw7mZxC+8Nf/OO3zjTFgwbZICEcFiSKyAEonHLzyMvdtb8bnlMxbvrHqVrCcuR0KMKXJ+1Suh4luyIzN031zDIZLly4Ri8WIRmP09MQ4PzkTMDiSYjxlga+SVXduYv3Gh9i/qyW/HLBixYqbCgHTaY0EeaM/QdaAf44vcaU4cSY7FrImF0paI9OfxyAiMlcl8ZsylUrReeJ/+fxvaQtjnuVAoGYeg8E1jAehFXMOBwbD0NDQ1cLAaIyuaC+nz/YweDkXAoxTQcOdLazb8AB7n7oaAurr6xcsBFxPVcBhWyTIsUtJPJPbRbBQPGPwMGyPhKgKaElBRBZGSYSDI0eOUBFI0LpVLZPzbP/CBIP8/QM3fIrBcPnyCLHJeoBoNMqFaC+dZ3vpH04wnrJwrTIamlpYt/F+Hvr01cLAhoYGbLt4tvHtri/j3FiGy2mXACxIQDHGkDGG6oDD7nrtuBGRhVMS4eDw4cOsa6hi1apViz2UkmQwjIyMFBQGdnX3cvpsjIGhXAjImBArmzaybsNOdj9xtSZg9erVRRUCrifo2LQ1VvCzc6OkPUPAnt+AYIwh7Rls26KtsYKgU/w/ExFZukoiHLz+6iEObG/JN6SRheUZQ/eFLl5+4V+5EO3h9Nke+gbjjKcs0ibIisYNrNtwN7s+/nR+JmD16tU4ztKeJm+q9LN3VTkvRePzGhDywcCy2LuqnKZK/zyMVkTk+m77cDAyMkL0zHFaf/+ZxR7K0nDiBLS3Q1MTXL4Mz8z+5+a6Lofe7OTHhwZZs/4u7vnYk/kQ0NTUhM93+/6z21YTAuClWJy0Mfi5uRoEb3IpwbZzweDK/UVEFtKS/5R2jeFS0qU/kWUg4RLPerjG4FgW5T6b3s7TrN64js2bty72UIvf6Cg89xwcPAhdXfCLX+Suv/gi/OAHuetXTHVtks/n55nPf4XPfeFrt2jgxWVbTYjqgMOvouNcTrvYBnzW7GYRjDFkDXjkagzaGis0YyAit4xlzNI81m007XJqKEXHUJJ4xuCZ3LSrd83bsS2LVDqNcVPUhB22hftpDQ5S5aQXceRFwglBfVvhtRdfhJ//HD772dzMwZ13Xv3eU0/B888XPn+qa5KXcj1e7Z2gYyiFO3kSk8+2sJk6KBhj8Mg1OMK60kMhyO76MtUYiMgtteRmDq73geu3rMkP3MIP3bH4KKFQgHEvwGvxRt6YaGBbaIDdZd0EbW9x3sQimkgk6OmJMTg8zn1PfCAcLFsGH/0o7NmT+7qrqzAgyKwEHZtHGiu4py5cEGSzxmBZfCjIXmmiVBGw2RYJ0RoJaruiiCyKJRUOLoxl+GV0nJG0i41FwLKw7OtP1bqei+e5+B2LgO1iDGSxOZZYwbn0MtoqztMUGL2F7+DWSaaSxGKxfMfA7liM98710hUbYjxlURFp5OAT3yn8S3v2wKFDuRkEyIUFhYObVhVw2LWyjJ0rwlxKugwksvQnXCayHllj8E2erlgXdqgN+6gJOep8KCKLasmEg45LSV6KxfGMwW9ZMyrySqfT2Bb4/bm1WssCPx4ecNkN8bPRFvZWnGdbaGCBR79wUqkUPb09+Y6B3dEY75/v5Vz3JeIpi3ja5o66Jlav3UTLzn3snywMvG4b6W9/+9a+gRLiWBZ1YR91YR/quCEixWxJhIOOS0leiuaCQcC2ZlzYlU6l8PudDz3ftiCAS9o4vDS+BqDoA0I6k87NAkx2DIz29PD++R7OdQ8ynrQYT1ksq2mkad1mmu/ey96nr4aAsrKbbJjT3g4XLuRmFB5//PrXRETktlD0BYkXxjL87Nwonje7YACGwcEBysN+wuHw1M8wkDYOtmX4narTRbHEkMlk6O3rzbcOjkZjnDnfy5kL/fkQUBlpYPXazTS3bMo3C2pubqa8vHyxhy8iIreBog4HKdfjR50jjKTdXH3BLNZhM9kMI8OXWFZVnl9WmMqVgFDtJHmm+uQtK1LMZDP09fUVhoCuXs509TOWgPGURXn1yilDQGVl5S0Zo4iIlKaiDgf/Ex3n2KXkjGsMrhWfiJNKjHFH9bIbhgrPQMY4bA9f5JGKrpsZ8odk3ew1IaCHWCyaCwHn+xlJeMRTNmVVK1i1poXmls35ZkHNzc1UVVXN61hERERmomhrDkbTLh1DKWxmHwwgV4wY8PtmNNtgW2Bj6EjWck+4d059ELJulv7+fqLRGLFYlJ6eHs529fLe+T5GJjziSZtgxXIa12xmY8tOnnz0agiorq6e9euJiIgslKINB6cm+xgE5hAMDIZsJk24fObn3fvwSBuHU6nl7Crrue7zXM+lv7//amFgLMq5C328d76Py3GXeNLGXxahce0mNjY/xqfarp4kGIlEZv1eREREbrWiDAeuMXQMJcEwbR+D68lkMliYWfXwtyzAQEeijp3hXjBZBgcH80cJx2Ixzk3OBAyPZ4mnbJxQNavWbGJj8wGe2JubBWhpaWH58uWzHrOIiEixKMqag/5Eln/vHMGZbCE7nffffp3k+CiJsRHuffxJAP7tO9+gqfUuHnzys9woWhjA8zw81yXrGVxsRn/8LO++9RrDY7kQYAeqWLVmExuaC2sCamtr5/VYXhERkWJQlDMH/YlsvtnRdIZ6uimrXEakvpF/+eaX8uGgbm0zowN9BcHAAMbzcF33modH1nXxTG7XAlgEyquw1j3Go5sP5INAXV2dQoCIiJSMogwHAwkXewZbF4d6o2zYsYtXfvxPrL97FwCe8Vh/7wNcPP1/JJNJPC8XALLZwhDgOD4cX4BgwIfPl3s4tk3ag/s/+RQPr1LPABERKU1FGQ7iWW/yUJrpw8GGHblAcOLl/2L/l/8YIH94zYpNdzEWT+E4Pt76+fNEGlbjOA7bHv4Yjm1f996e8ZjIlt6BTCIiIlcU5Tmw7izKIBLjo/S8904+KDi2Q3ygl3Wbt7G8to4X/vKb7Pn057hn3yd47ac/xLEdbhQ6ssVXhiEiInLLFGU4mM2JdMM9USL1qwv/vu3gOD56O98lXJFrJNTT+Q5f/4f/nNE9faovEBGRElaU4aDcZ8+48VGoorCV8MlDB9n64H4AYp0nGertZrgnCsALf/PnN7yfPXl8roiISKkqypqD2rCDZwzGcMOixEjDalp/s40jL/6EcOUyGjZuyX8vMT6Wu9acuxbrPElP5zv5rz/IGIMxhrqwM39vRkREZIkpynBQF/ZhWxYeMJNf0we+8tyU1yP1jQVLDuHKZQz1dl83HHjkwkhtuCh/LCIiIrdEUc6f14Qcyv0WWe/mCgPX79jFUG93/uvh3ijrJwsXp5L1DOV+i5qQZg5ERKR0FWWHRIDX+yZ47eLErI9q/qCThw6SGBshMT5GpL4xX4/wQcYY0sZw/4oydq0sm/PriYiILHVFGw5G0y7//OvLGAP+OZyvMFsZz2BZ8MVN1VQFNHMgIiKlqyiXFQCqAg7bIkE8zGRDpIXjGYOHYVskqGAgIiIlr2jDAcDu+jKqAw6ZyV0EC8EYQ8YYqgMOu+u1nCAiIlLU4SDo2LQ1VmBbFmlv/gOCMYa0Z7Ati7bGCoJOUf84REREbomi/23YVOln76ryeQ8I1waDvavKaar0z8t9RURElrolsaF/W00IgJdicdLG4IcZd1Ccije5lGDbuWBw5f4iIiJSxLsVpnJhLMOvouNcTrvYWPisG3dQvJYxhqwBj1yNQVtjhWYMREREPmBJhQOAlOvxau8EHUOp3OmNBny2hc3UQcEYg0euwRFW7lCnbZEgu+vLVGMgIiIyhSUXDq4YTbucGkrRMZQknsnVIliWVbDt0bas/PVyv8W2SIhWbVcUERGZ1pINB1e4xnAp6TKQyNKfcJnIemSNwTd5umJd2KE27KMm5MzqKGgREZFSteTDgYiIiMwvLbqLiIhIAYUDERERKaBwICIiIgUUDkRERKSAwoGIiIgUUDgQERGRAgoHIiIiUkDhQERERAooHIiIiEgBhQMREREpoHAgIiIiBRQOREREpIDCgYiIiBRQOBAREZECCgciIiJSQOFARERECigciIiISAGFAxERESmgcCAiIiIFFA5ERESkgMKBiIiIFFA4EBERkQIKByIiIlJA4UBEREQKKByIiIhIAYUDERERKaBwICIiIgUUDkRERKSAwoGIiIgUUDgQERGRAgoHIiIiUkDhQERERAooHIiIiEgBhQMREREpoHAgIiIiBf4fE0PONvqcxZAAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " - The complex has 8 0-cells.\n", + " - The 0-cells have features dimension 1\n", + " - The complex has 13 1-cells.\n", + " - The 1-cells have features dimension 1\n", + " - The complex has 6 2-cells.\n", + " - The 2-cells have features dimension 1\n", + "\n" + ] + } + ], + "source": [ + "lifted_dataset = PreProcessor(dataset, transform_config, loader.data_dir)\n", + "describe_data(lifted_dataset)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create and Run a Cell NN Model" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this section a simple model is created to test that the used lifting works as intended. In this case the model uses the `x_0`, `x_1`, `x_2` which are the features of the nodes, edges and cells respectively. It also uses the `adjacency_1`, `incidence_1` and `incidence_2` matrices so the lifting should make sure to add them to the data." + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model configuration for cell CWN:\n", + "\n", + "{'in_channels_0': None,\n", + " 'in_channels_1': None,\n", + " 'in_channels_2': None,\n", + " 'hidden_channels': 32,\n", + " 'out_channels': None,\n", + " 'n_layers': 2}\n" + ] + } + ], + "source": [ + "from modules.models.cell.cwn import CWNModel\n", + "\n", + "model_type = \"cell\"\n", + "model_id = \"cwn\"\n", + "model_config = load_model_config(model_type, model_id)\n", + "\n", + "model = CWNModel(model_config, dataset_config)" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [], + "source": [ + "y_hat = model(lifted_dataset.get(0))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If everything is correct the cell above should execute without errors. " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From 7f6fd09a328624a31605c8ee82a19086a546acac Mon Sep 17 00:00:00 2001 From: Jonas-Verhellen Date: Fri, 31 May 2024 14:54:09 +0200 Subject: [PATCH 33/36] Fixed numerical issues in tests. --- .../liftings/graph2cell/neighborhood_lifting.py | 3 +-- .../liftings/graph2cell/test_neighborhood.py | 13 ++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/modules/transforms/liftings/graph2cell/neighborhood_lifting.py b/modules/transforms/liftings/graph2cell/neighborhood_lifting.py index 1d58024a..df432602 100755 --- a/modules/transforms/liftings/graph2cell/neighborhood_lifting.py +++ b/modules/transforms/liftings/graph2cell/neighborhood_lifting.py @@ -1,4 +1,3 @@ -import networkx as nx import torch_geometric from toponetx.classes import CellComplex @@ -50,7 +49,7 @@ def lift_topology(self, data: torch_geometric.data.Data) -> dict: for v in vertices: neighbors = list(G.neighbors(v)) if len(neighbors) > 1: - two_cell = [v] + neighbors + two_cell = [v, *neighbors] if ( self.max_cell_length is not None and len(two_cell) > self.max_cell_length diff --git a/test/transforms/liftings/graph2cell/test_neighborhood.py b/test/transforms/liftings/graph2cell/test_neighborhood.py index 16f07315..29cdee0a 100644 --- a/test/transforms/liftings/graph2cell/test_neighborhood.py +++ b/test/transforms/liftings/graph2cell/test_neighborhood.py @@ -26,16 +26,15 @@ def test_lift_topology(self): expected_incidence_1_singular_values = torch.tensor( [3.4431, 2.4495, 2.4495, 2.3984, 2.2361, 2.2050, 1.9275, 1.6779] ) - - assert ( - expected_incidence_1_singular_values == S - ).all(), "Something is wrong with incidence_1." + assert torch.allclose( + expected_incidence_1_singular_values, S, atol=1e-4 + ), "Something is wrong with incidence_1." U, S, V = torch.svd(lifted_data.incidence_2.to_dense()) expected_incidence_2_singular_values = torch.tensor( [3.8155, 3.0758, 2.5256, 2.3475, 1.8136, 1.5562, 1.3854, 1.2090] ) - assert ( - expected_incidence_2_singular_values == S - ).all(), "Something is wrong with incidence_2." + assert torch.allclose( + expected_incidence_2_singular_values, S, atol=1e-4 + ), "Something is wrong with incidence_2." From f74c7389bd9401b170740acfe1987a9fd1c9f9a6 Mon Sep 17 00:00:00 2001 From: Jonas-Verhellen Date: Fri, 31 May 2024 14:56:14 +0200 Subject: [PATCH 34/36] Cleaned up tutorial notebook. --- tutorials/graph2cell/neighborhood_lifting.ipynb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tutorials/graph2cell/neighborhood_lifting.ipynb b/tutorials/graph2cell/neighborhood_lifting.ipynb index f9f4ab07..fd8fdbb7 100644 --- a/tutorials/graph2cell/neighborhood_lifting.ipynb +++ b/tutorials/graph2cell/neighborhood_lifting.ipynb @@ -176,12 +176,6 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "In this section we will instantiate the lifting we want to apply to the data. For this example the cycle lifting was chosen. The algorithm finds a cycle base for the graph and creates a cell for each cycle in said base. This is a connectivity based deterministic lifting that preserves the initial connectivity of the graph. [[1]](https://arxiv.org/abs/2309.01632) combine two heuristics to design an algorithm that selects cycle basis in $O(m \\log m)$ time, where $m$ is the number of edges of the graph.\n", - "\n", - "***\n", - "[[1]](https://arxiv.org/abs/2309.01632) Hoppe, J., & Schaub, M. T. (2024). Representing Edge Flows on Graphs via Sparse Cell\n", - "Complexes. In Learning on Graphs Conference (pp. 1-1). PMLR.\n", - "***\n", "For cell complexes creating a lifting involves creating a `CellComplex` object from topomodelx and adding cells to it using the method `add_cells_from`. The `CellComplex` class then takes care of creating all the needed matrices.\n", "\n", "Similarly to before, we can specify the transformation we want to apply through its type and id --the correxponding config files located at `/configs/transforms`. \n", From c0326506221efdff33f8b9fc8229b6d6e9b987a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Fri, 17 Jan 2025 12:59:15 -0800 Subject: [PATCH 35/36] Move icml2024-pr5 files --- .../test_neighborhood.py => cell/test_NeighborhoodLifting.py} | 0 .../transforms/liftings/graph2cell/neighborhood_lifting.py | 0 ...hborhood_lifting.ipynb => tutorial_neighborhood_lifting.ipynb} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename test/transforms/liftings/{graph2cell/test_neighborhood.py => cell/test_NeighborhoodLifting.py} (100%) rename {modules => topobenchmark}/transforms/liftings/graph2cell/neighborhood_lifting.py (100%) rename tutorials/{graph2cell/neighborhood_lifting.ipynb => tutorial_neighborhood_lifting.ipynb} (100%) diff --git a/test/transforms/liftings/graph2cell/test_neighborhood.py b/test/transforms/liftings/cell/test_NeighborhoodLifting.py similarity index 100% rename from test/transforms/liftings/graph2cell/test_neighborhood.py rename to test/transforms/liftings/cell/test_NeighborhoodLifting.py diff --git a/modules/transforms/liftings/graph2cell/neighborhood_lifting.py b/topobenchmark/transforms/liftings/graph2cell/neighborhood_lifting.py similarity index 100% rename from modules/transforms/liftings/graph2cell/neighborhood_lifting.py rename to topobenchmark/transforms/liftings/graph2cell/neighborhood_lifting.py diff --git a/tutorials/graph2cell/neighborhood_lifting.ipynb b/tutorials/tutorial_neighborhood_lifting.ipynb similarity index 100% rename from tutorials/graph2cell/neighborhood_lifting.ipynb rename to tutorials/tutorial_neighborhood_lifting.ipynb From d4a62697df4a66313bb1816fd67d4179c5c23b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20F=2E=20Pereira?= Date: Thu, 23 Jan 2025 11:18:43 -0800 Subject: [PATCH 36/36] Update NeighborhoodLifting to work with new design --- .../liftings/cell/test_NeighborhoodLifting.py | 9 +- .../graph2cell/neighborhood_lifting.py | 53 +-- tutorials/tutorial_neighborhood_lifting.ipynb | 354 ------------------ 3 files changed, 36 insertions(+), 380 deletions(-) delete mode 100644 tutorials/tutorial_neighborhood_lifting.ipynb diff --git a/test/transforms/liftings/cell/test_NeighborhoodLifting.py b/test/transforms/liftings/cell/test_NeighborhoodLifting.py index 29cdee0a..cd115000 100644 --- a/test/transforms/liftings/cell/test_NeighborhoodLifting.py +++ b/test/transforms/liftings/cell/test_NeighborhoodLifting.py @@ -2,13 +2,14 @@ import torch -from modules.data.utils.utils import load_manual_graph -from modules.transforms.liftings.graph2cell.neighborhood_lifting import ( +from topobenchmark.data.utils.utils import load_manual_graph +from topobenchmark.transforms.liftings import ( + Graph2CellLiftingTransform, NeighborhoodLifting, ) -class TestCellCyclesLifting: +class TestNeighborhoodLifting: """Test the NeighborhoodLifting class.""" def setup_method(self): @@ -16,7 +17,7 @@ def setup_method(self): self.data = load_manual_graph() # Initialise the NeighborhoodLifting class - self.lifting = NeighborhoodLifting() + self.lifting = Graph2CellLiftingTransform(NeighborhoodLifting()) def test_lift_topology(self): # Test the lift_topology method diff --git a/topobenchmark/transforms/liftings/graph2cell/neighborhood_lifting.py b/topobenchmark/transforms/liftings/graph2cell/neighborhood_lifting.py index df432602..644f0ae8 100755 --- a/topobenchmark/transforms/liftings/graph2cell/neighborhood_lifting.py +++ b/topobenchmark/transforms/liftings/graph2cell/neighborhood_lifting.py @@ -1,53 +1,62 @@ -import torch_geometric +"""This module implements the neighborhood lifting for graphs to cell complexes. + +Definition: +* 0-cells: Vertices of the graph. +* 1-cells: Edges of the graph. +* Higher-dimensional cells: Defined based on the neighborhoods of vertices. +A 2-cell is added for each vertex and its immediate neighbors. + +Characteristics: +Star-like Structure: Star-like structures centered around a vertex and include all its adjacent vertices. +Flexibility: This approach can generate higher-dimensional cells even in graphs that do not have cycles. +Local Connectivity: The focus is on local connectivity rather than global cycles. +""" + from toponetx.classes import CellComplex -from modules.transforms.liftings.graph2cell.base import Graph2CellLifting +from topobenchmark.transforms.liftings.base import LiftingMap -class NeighborhoodLifting(Graph2CellLifting): - r"""Lifts graphs to cell complexes by identifying the cycles as 2-cells. +class NeighborhoodLifting(LiftingMap): + """Lifts graphs to cell complexes by identifying the cycles as 2-cells. Parameters ---------- max_cell_length : int, optional The maximum length of the cycles to be lifted. Default is None. - **kwargs : optional - Additional arguments for the class. """ - def __init__(self, max_cell_length=None, **kwargs): - super().__init__(**kwargs) - self.complex_dim = 2 + def __init__(self, max_cell_length=None): + super().__init__() self.max_cell_length = max_cell_length - def lift_topology(self, data: torch_geometric.data.Data) -> dict: - r"""Finds the cycles of a graph and lifts them to 2-cells. + def lift(self, domain): + """Finds the cycles of a graph and lifts them to 2-cells. Parameters ---------- - data : torch_geometric.data.Data - The input data to be lifted. + domain : nx.Graph + Graph to be lifted. Returns ------- - dict - The lifted topology. + CellComplex + Lifted cell complex. """ + graph = domain - G = self._generate_graph_from_data(data) - - cell_complex = CellComplex(G) + cell_complex = CellComplex(graph) - vertices = list(G.nodes()) + vertices = list(graph.nodes()) for v in vertices: cell_complex.add_node(v, rank=0) - edges = list(G.edges()) + edges = list(graph.edges()) for edge in edges: cell_complex.add_cell(edge, rank=1) for v in vertices: - neighbors = list(G.neighbors(v)) + neighbors = list(graph.neighbors(v)) if len(neighbors) > 1: two_cell = [v, *neighbors] if ( @@ -58,4 +67,4 @@ def lift_topology(self, data: torch_geometric.data.Data) -> dict: else: cell_complex.add_cell(two_cell, rank=2) - return self._get_lifted_topology(cell_complex, G) + return cell_complex diff --git a/tutorials/tutorial_neighborhood_lifting.ipynb b/tutorials/tutorial_neighborhood_lifting.ipynb deleted file mode 100644 index fd8fdbb7..00000000 --- a/tutorials/tutorial_neighborhood_lifting.ipynb +++ /dev/null @@ -1,354 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Graph-to-Cell Neighborhood Lifting Tutorial" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "***\n", - "This notebook shows how to import a dataset, with the desired lifting, and how to run a neural network using the loaded data.\n", - "\n", - "The notebook is divided into sections:\n", - "\n", - "- [Loading the dataset](#loading-the-dataset) loads the config files for the data and the desired tranformation, createsa a dataset object and visualizes it.\n", - "- [Loading and applying the lifting](#loading-and-applying-the-lifting) defines a simple neural network to test that the lifting creates the expected incidence matrices.\n", - "- [Create and run a simplicial nn model](#create-and-run-a-simplicial-nn-model) simply runs a forward pass of the model to check that everything is working as expected.\n", - "\n", - "***\n", - "***\n", - "\n", - "Note that for simplicity the notebook is setup to use a simple graph. However, there is a set of available datasets that you can play with.\n", - "\n", - "To switch to one of the available datasets, simply change the *dataset_name* variable in [Dataset config](#dataset-config) to one of the following names:\n", - "\n", - "* cocitation_cora\n", - "* cocitation_citeseer\n", - "* cocitation_pubmed\n", - "* MUTAG\n", - "* NCI1\n", - "* NCI109\n", - "* PROTEINS_TU\n", - "* AQSOL\n", - "* ZINC\n", - "***" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Imports and utilities" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The autoreload extension is already loaded. To reload it, use:\n", - " %reload_ext autoreload\n" - ] - } - ], - "source": [ - "# With this cell any imported module is reloaded before each cell execution\n", - "%load_ext autoreload\n", - "%autoreload 2\n", - "from modules.data.load.loaders import GraphLoader\n", - "from modules.data.preprocess.preprocessor import PreProcessor\n", - "from modules.utils.utils import (\n", - " describe_data,\n", - " load_dataset_config,\n", - " load_model_config,\n", - " load_transform_config,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Loading the dataset" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Here we just need to spicify the name of the available dataset that we want to load. First, the dataset config is read from the corresponding yaml file (located at `/configs/datasets/` directory), and then the data is loaded via the implemented `Loaders`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Dataset configuration for manual_dataset:\n", - "\n", - "{'data_domain': 'graph',\n", - " 'data_type': 'toy_dataset',\n", - " 'data_name': 'manual',\n", - " 'data_dir': 'datasets/graph/toy_dataset',\n", - " 'num_features': 1,\n", - " 'num_classes': 2,\n", - " 'task': 'classification',\n", - " 'loss_type': 'cross_entropy',\n", - " 'monitor_metric': 'accuracy',\n", - " 'task_level': 'node'}\n" - ] - } - ], - "source": [ - "dataset_name = \"manual_dataset\"\n", - "dataset_config = load_dataset_config(dataset_name)\n", - "loader = GraphLoader(dataset_config)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can then access to the data through the `load()`method:" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Dataset only contains 1 sample:\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " - Graph with 8 vertices and 13 edges.\n", - " - Features dimensions: [1, 0]\n", - " - There are 0 isolated nodes.\n", - "\n" - ] - } - ], - "source": [ - "dataset = loader.load()\n", - "describe_data(dataset)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Loading and Applying the Lifting" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For cell complexes creating a lifting involves creating a `CellComplex` object from topomodelx and adding cells to it using the method `add_cells_from`. The `CellComplex` class then takes care of creating all the needed matrices.\n", - "\n", - "Similarly to before, we can specify the transformation we want to apply through its type and id --the correxponding config files located at `/configs/transforms`. \n", - "\n", - "Note that the *tranform_config* dictionary generated below can contain a sequence of tranforms if it is needed.\n", - "\n", - "This can also be used to explore liftings from one topological domain to another, for example using two liftings it is possible to achieve a sequence such as: graph -> cell complex -> hypergraph. " - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Transform configuration for graph2cell/neighborhood_lifting:\n", - "\n", - "{'transform_type': 'lifting',\n", - " 'transform_name': 'CellCycleLifting',\n", - " 'max_cell_length': None,\n", - " 'preserve_edge_attr': False,\n", - " 'feature_lifting': 'ProjectionSum'}\n" - ] - } - ], - "source": [ - "# Define transformation type and id\n", - "transform_type = \"liftings\"\n", - "# If the transform is a topological lifting, it should include both the type of the lifting and the identifier\n", - "transform_id = \"graph2cell/neighborhood_lifting\"\n", - "\n", - "# Read yaml file\n", - "transform_config = {\n", - " \"lifting\": load_transform_config(transform_type, transform_id)\n", - " # other transforms (e.g. data manipulations, feature liftings) can be added here\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We than apply the transform via our `PreProcesor`:" - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Transform parameters are the same, using existing data_dir: /home/jonasver/Documents/Code/challenge-icml-2024/datasets/graph/toy_dataset/manual/lifting/1820307683\n", - "\n", - "Dataset only contains 1 sample:\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " - The complex has 8 0-cells.\n", - " - The 0-cells have features dimension 1\n", - " - The complex has 13 1-cells.\n", - " - The 1-cells have features dimension 1\n", - " - The complex has 6 2-cells.\n", - " - The 2-cells have features dimension 1\n", - "\n" - ] - } - ], - "source": [ - "lifted_dataset = PreProcessor(dataset, transform_config, loader.data_dir)\n", - "describe_data(lifted_dataset)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Create and Run a Cell NN Model" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In this section a simple model is created to test that the used lifting works as intended. In this case the model uses the `x_0`, `x_1`, `x_2` which are the features of the nodes, edges and cells respectively. It also uses the `adjacency_1`, `incidence_1` and `incidence_2` matrices so the lifting should make sure to add them to the data." - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Model configuration for cell CWN:\n", - "\n", - "{'in_channels_0': None,\n", - " 'in_channels_1': None,\n", - " 'in_channels_2': None,\n", - " 'hidden_channels': 32,\n", - " 'out_channels': None,\n", - " 'n_layers': 2}\n" - ] - } - ], - "source": [ - "from modules.models.cell.cwn import CWNModel\n", - "\n", - "model_type = \"cell\"\n", - "model_id = \"cwn\"\n", - "model_config = load_model_config(model_type, model_id)\n", - "\n", - "model = CWNModel(model_config, dataset_config)" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": {}, - "outputs": [], - "source": [ - "y_hat = model(lifted_dataset.get(0))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If everything is correct the cell above should execute without errors. " - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "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.3" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -}