From 55ff031381e68b27ebc6f9b5bd70df5e4713352a Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 11:04:41 +0000 Subject: [PATCH 01/20] Added code for closeness_centrality --- .../algorithms/centrality/closeness.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 nx_parallel/algorithms/centrality/closeness.py diff --git a/nx_parallel/algorithms/centrality/closeness.py b/nx_parallel/algorithms/centrality/closeness.py new file mode 100644 index 00000000..3e667223 --- /dev/null +++ b/nx_parallel/algorithms/centrality/closeness.py @@ -0,0 +1,71 @@ +from joblib import Parallel, delayed +import nx_parallel as nxp +import networkx as nx + +__all__ = ["closeness_centrality"] + + +@nxp._configure_if_nx_active() +def closeness_centrality( + G, distance=None, wf_improved=True, get_chunks="chunks" +): + """ + The parallel computation is implemented by dividing the nodes into chunks + and computing closeness centrality for each chunk concurrently. + + networkx.closeness_centrality : https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.centrality.closeness_centrality.html + + Parameters + ---------- + distance : string or function, optional + The edge attribute to use as distance when computing shortest paths, + or a user-defined distance function. + wf_improved : bool, optional + If True, use the improved formula for closeness centrality. + get_chunks : str, function (default = "chunks") + A function that takes in a list of all the nodes as input and returns an + iterable `node_chunks`. The default chunking is done by slicing the + `nodes` into `n_jobs` number of chunks. + """ + if hasattr(G, "graph_object"): + G = G.graph_object + + if len(G) == 0: # Handle empty graph + return {} + + nodes = list(G.nodes) + n_jobs = nxp.get_n_jobs() + + if get_chunks == "chunks": + node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) + else: + node_chunks = get_chunks(nodes) + + if not node_chunks: # Handle empty chunks + return {} + + cc_subs = Parallel()( + delayed(_closeness_centrality_node_subset)( + G, chunk, distance, wf_improved + ) + for chunk in node_chunks + ) + + closeness_centrality_dict = cc_subs[0] + for cc in cc_subs[1:]: + closeness_centrality_dict.update(cc) + + return closeness_centrality_dict + + +def _closeness_centrality_node_subset(G, nodes, distance=None, wf_improved=True): + """ + Compute closeness centrality for a subset of nodes. + """ + closeness = {} + for node in nodes: + # Use NetworkX's built-in function for closeness centrality + closeness[node] = nx.closeness_centrality( + G, u=node, distance=distance, wf_improved=wf_improved + ) + return closeness \ No newline at end of file From 3bae54a092b6bc4ce6a8191ff5fb8495609e193d Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 11:05:15 +0000 Subject: [PATCH 02/20] Added code for degree_centrality --- nx_parallel/algorithms/centrality/degree.py | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 nx_parallel/algorithms/centrality/degree.py diff --git a/nx_parallel/algorithms/centrality/degree.py b/nx_parallel/algorithms/centrality/degree.py new file mode 100644 index 00000000..d20ac9b1 --- /dev/null +++ b/nx_parallel/algorithms/centrality/degree.py @@ -0,0 +1,64 @@ +from joblib import Parallel, delayed +import nx_parallel as nxp +import networkx as nx + +__all__ = ["degree_centrality"] + + +@nxp._configure_if_nx_active() +def degree_centrality(G, get_chunks="chunks"): + """ + Parallel computation of degree centrality. Divides nodes into chunks + and computes degree centrality for each chunk concurrently. + + networkx.degree_centrality : https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.centrality.degree_centrality.html + + Parameters + ---------- + get_chunks : str, function (default = "chunks") + A function that takes in a list of all the nodes as input and returns an + iterable `node_chunks`. The default chunking is done by slicing the + `nodes` into `n_jobs` number of chunks. + """ + if hasattr(G, "graph_object"): + G = G.graph_object + + if len(G) == 0: # Handle empty graph + return {} + + nodes = list(G.nodes) + n_jobs = nxp.get_n_jobs() + + # Create node subsets + if get_chunks == "chunks": + node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) + else: + node_chunks = get_chunks(nodes) + + if not node_chunks: # Handle empty chunks + return {} + + # Compute degree centrality for each chunk in parallel + dc_subs = Parallel()( + delayed(_degree_centrality_node_subset)(G, chunk) for chunk in node_chunks + ) + + # Combine partial results + degree_centrality_dict = dc_subs[0] + for dc in dc_subs[1:]: + degree_centrality_dict.update(dc) + + return degree_centrality_dict + + +def _degree_centrality_node_subset(G, nodes): + part_dc = {} + n = len(G) + if n == 1: # Handle single-node graph + for node in nodes: + part_dc[node] = 1.0 + return part_dc + + for node in nodes: + part_dc[node] = G.degree[node] / (n - 1) + return part_dc \ No newline at end of file From 1f812a9aa5e93d575275e3ee512b718d95a0d22b Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 11:05:58 +0000 Subject: [PATCH 03/20] Added tests for Closeness_centrality.py --- .../tests/test_closeness_centrality.py | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py diff --git a/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py b/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py new file mode 100644 index 00000000..7a38359e --- /dev/null +++ b/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py @@ -0,0 +1,120 @@ +import networkx as nx +import nx_parallel as nxp +import math +import pytest + +def test_betweenness_centrality_get_chunks(): + def get_chunk(nodes): + num_chunks = nxp.get_n_jobs() + nodes_ebc = {i: 0 for i in nodes} + for i in ebc: + nodes_ebc[i[0]] += ebc[i] + nodes_ebc[i[1]] += ebc[i] + + sorted_nodes = sorted(nodes_ebc.items(), key=lambda x: x[1], reverse=True) + + chunks = [[] for _ in range(num_chunks)] + chunk_sums = [0] * num_chunks + + for node, value in sorted_nodes: + min_chunk_index = chunk_sums.index(min(chunk_sums)) + chunks[min_chunk_index].append(node) + chunk_sums[min_chunk_index] += value + + return chunks + + G = nx.fast_gnp_random_graph(100, 0.1, directed=False) + H = nxp.ParallelGraph(G) + ebc = nx.edge_betweenness_centrality(G) + par_bc_chunk = nxp.betweenness_centrality(H, get_chunks=get_chunk) # smoke test + par_bc = nxp.betweenness_centrality(H) + + for i in range(len(G.nodes)): + assert math.isclose(par_bc[i], par_bc_chunk[i], abs_tol=1e-16) + + +def test_betweenness_centrality_directed_graph(): + """Test betweenness centrality on a directed graph.""" + G = nx.fast_gnp_random_graph(100, 0.1, directed=True) + H = nxp.ParallelGraph(G) + + par_bc = nxp.betweenness_centrality(H) + expected_bc = nx.betweenness_centrality(G) + + for node in G.nodes: + assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) + + +def test_betweenness_centrality_weighted_graph(): + """Test betweenness centrality on a weighted graph.""" + G = nx.fast_gnp_random_graph(100, 0.1, directed=False) + for u, v in G.edges: + G[u][v]['weight'] = 1.0 # Assign uniform weights + + H = nxp.ParallelGraph(G) + par_bc = nxp.betweenness_centrality(H, weight='weight') + expected_bc = nx.betweenness_centrality(G, weight='weight') + + for node in G.nodes: + assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) + + +def test_betweenness_centrality_small_graph(): + """Test betweenness centrality on a small graph.""" + G = nx.path_graph(5) # A simple path graph + H = nxp.ParallelGraph(G) + + par_bc = nxp.betweenness_centrality(H) + expected_bc = nx.betweenness_centrality(G) + + for node in G.nodes: + assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) + + +def test_betweenness_centrality_empty_graph(): + """Test betweenness centrality on an empty graph.""" + G = nx.Graph() # An empty graph + H = nxp.ParallelGraph(G) + + # Check if the underlying graph is empty before calling the function + if len(H.graph_object) == 0: # Use the underlying graph's length + assert nxp.betweenness_centrality(H) == {}, "Expected an empty dictionary for an empty graph" + else: + pytest.fail("Graph is not empty, but it should be.") + + +def test_betweenness_centrality_single_node(): + """Test betweenness centrality on a graph with a single node.""" + G = nx.Graph() + G.add_node(1) + H = nxp.ParallelGraph(G) + + par_bc = nxp.betweenness_centrality(H) + expected_bc = nx.betweenness_centrality(G) + + assert par_bc == expected_bc # Both should return {1: 0.0} + + +def test_betweenness_centrality_large_graph(): + """Test betweenness centrality on a large graph.""" + G = nx.fast_gnp_random_graph(1000, 0.01, directed=False) + H = nxp.ParallelGraph(G) + + par_bc = nxp.betweenness_centrality(H) + expected_bc = nx.betweenness_centrality(G) + + for node in G.nodes: + assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-6) # Larger tolerance for large graphs + + +def test_betweenness_centrality_multigraph(): + """Test betweenness centrality on a multigraph.""" + G = nx.MultiGraph() + G.add_edges_from([(1, 2), (1, 2), (2, 3), (3, 4)]) + H = nxp.ParallelGraph(G) + + par_bc = nxp.betweenness_centrality(H) + expected_bc = nx.betweenness_centrality(G) + + for node in G.nodes: + assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) \ No newline at end of file From 8a7137ac5fe153f64101e4e977dadc2861535761 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 11:07:43 +0000 Subject: [PATCH 04/20] Updated betweenness.py to support empty chunks and empty results --- .../algorithms/centrality/betweenness.py | 328 +++++++++--------- 1 file changed, 168 insertions(+), 160 deletions(-) diff --git a/nx_parallel/algorithms/centrality/betweenness.py b/nx_parallel/algorithms/centrality/betweenness.py index 3f396b1f..e0d97898 100644 --- a/nx_parallel/algorithms/centrality/betweenness.py +++ b/nx_parallel/algorithms/centrality/betweenness.py @@ -1,160 +1,168 @@ -from joblib import Parallel, delayed -from networkx.algorithms.centrality.betweenness import ( - _accumulate_basic, - _accumulate_endpoints, - _rescale, - _single_source_dijkstra_path_basic, - _single_source_shortest_path_basic, - _rescale_e, - _add_edge_keys, - _accumulate_edges, -) -from networkx.utils import py_random_state -import nx_parallel as nxp - -__all__ = ["betweenness_centrality", "edge_betweenness_centrality"] - - -@nxp._configure_if_nx_active() -@py_random_state(5) -def betweenness_centrality( - G, - k=None, - normalized=True, - weight=None, - endpoints=False, - seed=None, - get_chunks="chunks", -): - """The parallel computation is implemented by dividing the nodes into chunks and - computing betweenness centrality for each chunk concurrently. - - networkx.betweenness_centrality : https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.centrality.betweenness_centrality.html - - Parameters - ---------- - get_chunks : str, function (default = "chunks") - A function that takes in a list of all the nodes as input and returns an - iterable `node_chunks`. The default chunking is done by slicing the - `nodes` into `n_jobs` number of chunks. - """ - if hasattr(G, "graph_object"): - G = G.graph_object - - if k is None: - nodes = G.nodes - else: - nodes = seed.sample(list(G.nodes), k) - - n_jobs = nxp.get_n_jobs() - - if get_chunks == "chunks": - node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) - else: - node_chunks = get_chunks(nodes) - - bt_cs = Parallel()( - delayed(_betweenness_centrality_node_subset)(G, chunk, weight, endpoints) - for chunk in node_chunks - ) - - # Reducing partial solution - bt_c = bt_cs[0] - for bt in bt_cs[1:]: - for n in bt: - bt_c[n] += bt[n] - - betweenness = _rescale( - bt_c, - len(G), - normalized=normalized, - directed=G.is_directed(), - k=k, - endpoints=endpoints, - ) - return betweenness - - -def _betweenness_centrality_node_subset(G, nodes, weight=None, endpoints=False): - betweenness = dict.fromkeys(G, 0.0) - for s in nodes: - # single source shortest paths - if weight is None: # use BFS - S, P, sigma, _ = _single_source_shortest_path_basic(G, s) - else: # use Dijkstra's algorithm - S, P, sigma, _ = _single_source_dijkstra_path_basic(G, s, weight) - # accumulation - if endpoints: - betweenness, delta = _accumulate_endpoints(betweenness, S, P, sigma, s) - else: - betweenness, delta = _accumulate_basic(betweenness, S, P, sigma, s) - return betweenness - - -@nxp._configure_if_nx_active() -@py_random_state(4) -def edge_betweenness_centrality( - G, k=None, normalized=True, weight=None, seed=None, get_chunks="chunks" -): - """The parallel computation is implemented by dividing the nodes into chunks and - computing edge betweenness centrality for each chunk concurrently. - - networkx.edge_betweenness_centrality : https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.centrality.edge_betweenness_centrality.html - - Parameters - ---------- - get_chunks : str, function (default = "chunks") - A function that takes in a list of all the nodes as input and returns an - iterable `node_chunks`. The default chunking is done by slicing the - `nodes` into `n_jobs` number of chunks. - """ - if hasattr(G, "graph_object"): - G = G.graph_object - - if k is None: - nodes = G.nodes - else: - nodes = seed.sample(list(G.nodes), k) - - n_jobs = nxp.get_n_jobs() - - if get_chunks == "chunks": - node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) - else: - node_chunks = get_chunks(nodes) - - bt_cs = Parallel()( - delayed(_edge_betweenness_centrality_node_subset)(G, chunk, weight) - for chunk in node_chunks - ) - - # Reducing partial solution - bt_c = bt_cs[0] - for bt in bt_cs[1:]: - for e in bt: - bt_c[e] += bt[e] - - for n in G: # remove nodes to only return edges - del bt_c[n] - - betweenness = _rescale_e(bt_c, len(G), normalized=normalized, k=k) - - if G.is_multigraph(): - betweenness = _add_edge_keys(G, betweenness, weight=weight) - - return betweenness - - -def _edge_betweenness_centrality_node_subset(G, nodes, weight=None): - betweenness = dict.fromkeys(G, 0.0) # b[v]=0 for v in G - # b[e]=0 for e in G.edges() - betweenness.update(dict.fromkeys(G.edges(), 0.0)) - for s in nodes: - # single source shortest paths - if weight is None: # use BFS - S, P, sigma, _ = _single_source_shortest_path_basic(G, s) - else: # use Dijkstra's algorithm - S, P, sigma, _ = _single_source_dijkstra_path_basic(G, s, weight) - # accumulation - betweenness = _accumulate_edges(betweenness, S, P, sigma, s) - return betweenness +from joblib import Parallel, delayed +from networkx.algorithms.centrality.betweenness import ( + _accumulate_basic, + _accumulate_endpoints, + _rescale, + _single_source_dijkstra_path_basic, + _single_source_shortest_path_basic, + _rescale_e, + _add_edge_keys, + _accumulate_edges, +) +from networkx.utils import py_random_state +import nx_parallel as nxp + +__all__ = ["betweenness_centrality", "edge_betweenness_centrality"] + + +@nxp._configure_if_nx_active() +@py_random_state(5) +def betweenness_centrality( + G, + k=None, + normalized=True, + weight=None, + endpoints=False, + seed=None, + get_chunks="chunks", +): + """The parallel computation is implemented by dividing the nodes into chunks and + computing betweenness centrality for each chunk concurrently. + + networkx.betweenness_centrality : https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.centrality.betweenness_centrality.html + + Parameters + ---------- + get_chunks : str, function (default = "chunks") + A function that takes in a list of all the nodes as input and returns an + iterable `node_chunks`. The default chunking is done by slicing the + `nodes` into `n_jobs` number of chunks. + """ + if hasattr(G, "graph_object"): + G = G.graph_object + + if k is None: + nodes = G.nodes + else: + nodes = seed.sample(list(G.nodes), k) + + n_jobs = nxp.get_n_jobs() + + if get_chunks == "chunks": + node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) + else: + node_chunks = get_chunks(nodes) + + # Handle empty chunks + if not node_chunks: + return {} + + bt_cs = Parallel()( + delayed(_betweenness_centrality_node_subset)(G, chunk, weight, endpoints) + for chunk in node_chunks + ) + + # Handle empty results + if not bt_cs: + return {} + + # Reducing partial solution + bt_c = bt_cs[0] + for bt in bt_cs[1:]: + for n in bt: + bt_c[n] += bt[n] + + betweenness = _rescale( + bt_c, + len(G), + normalized=normalized, + directed=G.is_directed(), + k=k, + endpoints=endpoints, + ) + return betweenness + + +def _betweenness_centrality_node_subset(G, nodes, weight=None, endpoints=False): + betweenness = dict.fromkeys(G, 0.0) + for s in nodes: + # single source shortest paths + if weight is None: # use BFS + S, P, sigma, _ = _single_source_shortest_path_basic(G, s) + else: # use Dijkstra's algorithm + S, P, sigma, _ = _single_source_dijkstra_path_basic(G, s, weight) + # accumulation + if endpoints: + betweenness, delta = _accumulate_endpoints(betweenness, S, P, sigma, s) + else: + betweenness, delta = _accumulate_basic(betweenness, S, P, sigma, s) + return betweenness + + +@nxp._configure_if_nx_active() +@py_random_state(4) +def edge_betweenness_centrality( + G, k=None, normalized=True, weight=None, seed=None, get_chunks="chunks" +): + """The parallel computation is implemented by dividing the nodes into chunks and + computing edge betweenness centrality for each chunk concurrently. + + networkx.edge_betweenness_centrality : https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.centrality.edge_betweenness_centrality.html + + Parameters + ---------- + get_chunks : str, function (default = "chunks") + A function that takes in a list of all the nodes as input and returns an + iterable `node_chunks`. The default chunking is done by slicing the + `nodes` into `n_jobs` number of chunks. + """ + if hasattr(G, "graph_object"): + G = G.graph_object + + if k is None: + nodes = G.nodes + else: + nodes = seed.sample(list(G.nodes), k) + + n_jobs = nxp.get_n_jobs() + + if get_chunks == "chunks": + node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) + else: + node_chunks = get_chunks(nodes) + + bt_cs = Parallel()( + delayed(_edge_betweenness_centrality_node_subset)(G, chunk, weight) + for chunk in node_chunks + ) + + # Reducing partial solution + bt_c = bt_cs[0] + for bt in bt_cs[1:]: + for e in bt: + bt_c[e] += bt[e] + + for n in G: # remove nodes to only return edges + del bt_c[n] + + betweenness = _rescale_e(bt_c, len(G), normalized=normalized, k=k) + + if G.is_multigraph(): + betweenness = _add_edge_keys(G, betweenness, weight=weight) + + return betweenness + + +def _edge_betweenness_centrality_node_subset(G, nodes, weight=None): + betweenness = dict.fromkeys(G, 0.0) # b[v]=0 for v in G + # b[e]=0 for e in G.edges() + betweenness.update(dict.fromkeys(G.edges(), 0.0)) + for s in nodes: + # single source shortest paths + if weight is None: # use BFS + S, P, sigma, _ = _single_source_shortest_path_basic(G, s) + else: # use Dijkstra's algorithm + S, P, sigma, _ = _single_source_dijkstra_path_basic(G, s, weight) + # accumulation + betweenness = _accumulate_edges(betweenness, S, P, sigma, s) + return betweenness From 12bc19d9e54cf717a73f5bd056917d96f22fcb27 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 11:08:16 +0000 Subject: [PATCH 05/20] Added tests for degree_centrality.py --- .../tests/test_degree_centrality.py | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 nx_parallel/algorithms/centrality/tests/test_degree_centrality.py diff --git a/nx_parallel/algorithms/centrality/tests/test_degree_centrality.py b/nx_parallel/algorithms/centrality/tests/test_degree_centrality.py new file mode 100644 index 00000000..931666fe --- /dev/null +++ b/nx_parallel/algorithms/centrality/tests/test_degree_centrality.py @@ -0,0 +1,135 @@ +import networkx as nx +import nx_parallel as nxp +import math + + +def test_degree_centrality_default_chunks(): + """Test degree centrality with default chunking.""" + G = nx.erdos_renyi_graph(100, 0.1, seed=42) # Random graph with 100 nodes + H = nxp.ParallelGraph(G) + + # Compute degree centrality using the parallel implementation + par_dc = nxp.degree_centrality(H) + + # Compute degree centrality using NetworkX's built-in function + expected_dc = nx.degree_centrality(G) + + # Compare the results + for node in G.nodes: + assert math.isclose(par_dc[node], expected_dc[node], abs_tol=1e-16) + + +def test_degree_centrality_custom_chunks(): + """Test degree centrality with custom chunking.""" + def get_chunk(nodes): + num_chunks = nxp.get_n_jobs() + chunks = [[] for _ in range(num_chunks)] + for i, node in enumerate(nodes): + chunks[i % num_chunks].append(node) + return chunks + + G = nx.erdos_renyi_graph(100, 0.1, seed=42) + H = nxp.ParallelGraph(G) + + # Compute degree centrality using custom chunking + par_dc_chunk = nxp.degree_centrality(H, get_chunks=get_chunk) + + # Compute degree centrality using NetworkX's built-in function + expected_dc = nx.degree_centrality(G) + + # Compare the results + for node in G.nodes: + assert math.isclose(par_dc_chunk[node], expected_dc[node], abs_tol=1e-16) + + +def test_degree_centrality_empty_graph(): + """Test degree centrality on an empty graph.""" + G = nx.Graph() # Empty graph + H = nxp.ParallelGraph(G) + + # Compute degree centrality + par_dc = nxp.degree_centrality(H) + expected_dc = nx.degree_centrality(G) + + assert par_dc == expected_dc # Both should return an empty dictionary + + +def test_degree_centrality_single_node(): + """Test degree centrality on a graph with a single node.""" + G = nx.Graph() + G.add_node(1) + H = nxp.ParallelGraph(G) + + # Compute degree centrality + par_dc = nxp.degree_centrality(H) + expected_dc = nx.degree_centrality(G) + + assert par_dc == expected_dc # Both should return {1: 0.0} + + +def test_degree_centrality_disconnected_graph(): + """Test degree centrality on a disconnected graph.""" + G = nx.Graph() + G.add_nodes_from([1, 2, 3]) # Add three disconnected nodes + H = nxp.ParallelGraph(G) + + # Compute degree centrality + par_dc = nxp.degree_centrality(H) + expected_dc = nx.degree_centrality(G) + + assert par_dc == expected_dc # Both should return {1: 0.0, 2: 0.0, 3: 0.0} + + +def test_degree_centrality_self_loops(): + """Test degree centrality on a graph with self-loops.""" + G = nx.Graph() + G.add_edges_from([(1, 1), (2, 2), (2, 3)]) # Add self-loops and one normal edge + H = nxp.ParallelGraph(G) + + # Compute degree centrality + par_dc = nxp.degree_centrality(H) + expected_dc = nx.degree_centrality(G) + + for node in G.nodes: + assert math.isclose(par_dc[node], expected_dc[node], abs_tol=1e-16) + + +def test_degree_centrality_directed_graph(): + """Test degree centrality on a directed graph.""" + G = nx.DiGraph() + G.add_edges_from([(1, 2), (2, 3), (3, 1)]) # Create a directed cycle + H = nxp.ParallelGraph(G) + + # Compute degree centrality + par_dc = nxp.degree_centrality(H) + expected_dc = nx.degree_centrality(G) + + for node in G.nodes: + assert math.isclose(par_dc[node], expected_dc[node], abs_tol=1e-16) + + +def test_degree_centrality_multigraph(): + """Test degree centrality on a multigraph.""" + G = nx.MultiGraph() + G.add_edges_from([(1, 2), (1, 2), (2, 3)]) # Add multiple edges between nodes + H = nxp.ParallelGraph(G) + + # Compute degree centrality + par_dc = nxp.degree_centrality(H) + expected_dc = nx.degree_centrality(G) + + for node in G.nodes: + assert math.isclose(par_dc[node], expected_dc[node], abs_tol=1e-16) + + +def test_degree_centrality_large_graph(): + """Test degree centrality on a large graph.""" + G = nx.fast_gnp_random_graph(1000, 0.01, seed=42) + H = nxp.ParallelGraph(G) + + # Compute degree centrality + par_dc = nxp.degree_centrality(H) + expected_dc = nx.degree_centrality(G) + + for node in G.nodes: + assert math.isclose(par_dc[node], expected_dc[node], abs_tol=1e-6) # Larger tolerance for large graphs \ No newline at end of file From 18d030cf314d60b8ec0b579d079fda2a8609933d Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 13:22:26 +0000 Subject: [PATCH 06/20] Refactor imports to unify all centrality functions under the module namespace. --- nx_parallel/interface.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nx_parallel/interface.py b/nx_parallel/interface.py index 38af8c73..d45e9363 100644 --- a/nx_parallel/interface.py +++ b/nx_parallel/interface.py @@ -18,6 +18,8 @@ # Centrality "betweenness_centrality", "edge_betweenness_centrality", + "closeness_centrality", + "degree_centrality", # Efficiency "local_efficiency", # Shortest Paths : generic From b575480c7333655787a945ec2b5269ed3f8a6337 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 13:24:46 +0000 Subject: [PATCH 07/20] Added all centrality functions to the all list for proper export --- nx_parallel/algorithms/centrality/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nx_parallel/algorithms/centrality/__init__.py b/nx_parallel/algorithms/centrality/__init__.py index cf7adb68..56f07a83 100644 --- a/nx_parallel/algorithms/centrality/__init__.py +++ b/nx_parallel/algorithms/centrality/__init__.py @@ -1 +1,10 @@ -from .betweenness import * +from .degree import degree_centrality +from .closeness import closeness_centrality +from .betweenness import betweenness_centrality, edge_betweenness_centrality + +__all__ = [ + "degree_centrality", + "closeness_centrality", + "betweenness_centrality", + "edge_betweenness_centrality", +] From 1ec1bb483ffc0dbac3e39f768358f8f418d31510 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 19:39:11 +0000 Subject: [PATCH 08/20] Fixed closeness script to get rid of errors --- .../algorithms/centrality/closeness.py | 76 +++++++++++++++---- .../tests/test_closeness_centrality.py | 63 ++++++++++++++- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/nx_parallel/algorithms/centrality/closeness.py b/nx_parallel/algorithms/centrality/closeness.py index 3e667223..81eee8e1 100644 --- a/nx_parallel/algorithms/centrality/closeness.py +++ b/nx_parallel/algorithms/centrality/closeness.py @@ -7,16 +7,18 @@ @nxp._configure_if_nx_active() def closeness_centrality( - G, distance=None, wf_improved=True, get_chunks="chunks" + G, u=None, distance=None, wf_improved=True, get_chunks="chunks" ): """ The parallel computation is implemented by dividing the nodes into chunks and computing closeness centrality for each chunk concurrently. - networkx.closeness_centrality : https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.centrality.closeness_centrality.html - Parameters ---------- + G : graph + A NetworkX graph + u : node, optional + Return only the value for node u distance : string or function, optional The edge attribute to use as distance when computing shortest paths, or a user-defined distance function. @@ -33,13 +35,32 @@ def closeness_centrality( if len(G) == 0: # Handle empty graph return {} - nodes = list(G.nodes) + # Handle single node case directly + if u is not None: + nodes = [u] + else: + nodes = list(G.nodes) + + # For single node case, don't use parallelization + if u is not None: + result = _closeness_centrality_node_subset(G, nodes, distance, wf_improved) + return result[u] + n_jobs = nxp.get_n_jobs() + # Validate get_chunks - the chunk parameter is only used for parallel execution + if not (callable(get_chunks) or get_chunks == "chunks"): + # Fallback to default chunking if get_chunks is invalid + get_chunks = "chunks" + if get_chunks == "chunks": node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) else: - node_chunks = get_chunks(nodes) + try: + node_chunks = get_chunks(nodes) + except: + # Fallback if get_chunks fails + node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) if not node_chunks: # Handle empty chunks return {} @@ -51,8 +72,8 @@ def closeness_centrality( for chunk in node_chunks ) - closeness_centrality_dict = cc_subs[0] - for cc in cc_subs[1:]: + closeness_centrality_dict = {} + for cc in cc_subs: closeness_centrality_dict.update(cc) return closeness_centrality_dict @@ -61,11 +82,38 @@ def closeness_centrality( def _closeness_centrality_node_subset(G, nodes, distance=None, wf_improved=True): """ Compute closeness centrality for a subset of nodes. + + Implemented to match NetworkX's implementation exactly. """ - closeness = {} - for node in nodes: - # Use NetworkX's built-in function for closeness centrality - closeness[node] = nx.closeness_centrality( - G, u=node, distance=distance, wf_improved=wf_improved - ) - return closeness \ No newline at end of file + # Create a copy of the graph to avoid modifying the original + # Handle directed graphs by reversing (matches NetworkX implementation) + if G.is_directed(): + G = G.reverse() # create a reversed graph view + + closeness_dict = {} + + for n in nodes: + # Using the exact NetworkX path calculation logic + if distance is not None: + # Use Dijkstra for weighted graphs + sp = nx.single_source_dijkstra_path_length(G, n, weight=distance) + else: + # Use BFS for unweighted graphs + sp = nx.single_source_shortest_path_length(G, n) + + # Sum of shortest paths exactly as NetworkX does it + totsp = sum(sp.values()) + len_G = len(G) + _closeness_centrality = 0.0 + + # Use the exact NetworkX formula and conditions + if totsp > 0.0 and len_G > 1: + _closeness_centrality = (len(sp) - 1.0) / totsp + # Use the exact normalization formula from NetworkX + if wf_improved: + s = (len(sp) - 1.0) / (len_G - 1) + _closeness_centrality *= s + + closeness_dict[n] = _closeness_centrality + + return closeness_dict \ No newline at end of file diff --git a/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py b/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py index 7a38359e..8dce0e19 100644 --- a/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py +++ b/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py @@ -117,4 +117,65 @@ def test_betweenness_centrality_multigraph(): expected_bc = nx.betweenness_centrality(G) for node in G.nodes: - assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) \ No newline at end of file + assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) + + +def test_closeness_centrality_default_chunks(): + """Test closeness centrality with default chunking.""" + G = nx.path_graph(5) # A simple path graph + H = nxp.ParallelGraph(G) + + par_cc = nxp.closeness_centrality(H, get_chunks="chunks") + expected_cc = nx.closeness_centrality(G) + + for node in G.nodes: + assert pytest.approx(par_cc[node], rel=1e-6) == expected_cc[node] + + +def test_closeness_centrality_custom_chunks(): + """Test closeness centrality with a custom chunking function.""" + def custom_chunking(nodes): + # Example custom chunking: split nodes into two equal parts + mid = len(nodes) // 2 + return [nodes[:mid], nodes[mid:]] + + G = nx.path_graph(5) # A simple path graph + H = nxp.ParallelGraph(G) + + par_cc = nxp.closeness_centrality(H, get_chunks=custom_chunking) + expected_cc = nx.closeness_centrality(G) + + for node in G.nodes: + assert pytest.approx(par_cc[node], rel=1e-6) == expected_cc[node] + + +def test_closeness_centrality_empty_graph(): + """Test closeness centrality on an empty graph.""" + G = nx.Graph() # An empty graph + H = nxp.ParallelGraph(G) + + assert nxp.closeness_centrality(H, get_chunks="chunks") == {}, "Expected an empty dictionary for an empty graph" + + +def test_closeness_centrality_single_node(): + """Test closeness centrality on a graph with a single node.""" + G = nx.Graph() + G.add_node(1) + H = nxp.ParallelGraph(G) + + par_cc = nxp.closeness_centrality(H, get_chunks="chunks") + expected_cc = nx.closeness_centrality(G) + + assert par_cc == expected_cc # Both should return {1: 0.0} + + +def test_closeness_centrality_large_graph(): + """Test closeness centrality on a large graph.""" + G = nx.fast_gnp_random_graph(1000, 0.01, directed=False) + H = nxp.ParallelGraph(G) + + par_cc = nxp.closeness_centrality(H, get_chunks="chunks") + expected_cc = nx.closeness_centrality(G) + + for node in G.nodes: + assert pytest.approx(par_cc[node], rel=1e-6) == expected_cc[node] \ No newline at end of file From b92c3a08bfbd137afa13e7a3f8abc382d6979ad9 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 19:40:25 +0000 Subject: [PATCH 09/20] Created heatmap for closeness_centrality --- .../heatmap_closeness_centrality_timing.png | Bin 0 -> 45331 bytes timing/timing_all_functions.py | 58 ++++++---- timing/timing_individual_function.py | 99 +++++------------- 3 files changed, 64 insertions(+), 93 deletions(-) create mode 100644 timing/heatmap_closeness_centrality_timing.png diff --git a/timing/heatmap_closeness_centrality_timing.png b/timing/heatmap_closeness_centrality_timing.png new file mode 100644 index 0000000000000000000000000000000000000000..032a4e63ecaea04db0e91a31b6e7fda73c233a8c GIT binary patch literal 45331 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�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

S#p=|3iK%J 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 Du3lf{ literal 0 HcmV?d00001 diff --git a/timing/timing_all_functions.py b/timing/timing_all_functions.py index fe1cba97..a04c1436 100644 --- a/timing/timing_all_functions.py +++ b/timing/timing_all_functions.py @@ -1,4 +1,5 @@ import time +import os import networkx as nx import pandas as pd @@ -9,8 +10,13 @@ # Code to create README heatmap for all functions in function_list heatmapDF = pd.DataFrame() -function_list = [nx.betweenness_centrality, nx.closeness_vitality, nx.local_efficiency] -number_of_nodes_list = [10, 20, 50, 300, 600] +function_list = [ + nx.betweenness_centrality, + nx.closeness_vitality, + nx.closeness_centrality, + nx.degree_centrality, +] +number_of_nodes_list = [10, 20, 50, 150, 250] for i in range(0, len(function_list)): currFun = function_list[i] @@ -23,46 +29,59 @@ # time both versions and update heatmapDF t1 = time.time() - c = currFun(H) + if currFun == nx_parallel.closeness_centrality: + # Explicitly pass get_chunks="chunks" for the parallel version + c = currFun(H, get_chunks="chunks") + else: + c = currFun(H) t2 = time.time() parallelTime = t2 - t1 + t1 = time.time() - c = currFun(G) + if currFun == nx_parallel.closeness_centrality: + # Explicitly pass get_chunks="chunks" for the parallel version + c = currFun(G, get_chunks="chunks") + else: + c = currFun(G) t2 = time.time() stdTime = t2 - t1 + timesFaster = stdTime / parallelTime heatmapDF.at[j, i] = timesFaster print("Finished " + str(currFun)) -# Code to create for row of heatmap specifically for tournaments +# Code to handle nx.tournament.is_reachable separately for j in range(0, len(number_of_nodes_list)): num = number_of_nodes_list[j] G = nx.tournament.random_tournament(num) - H = nx_parallel.ParallelDiGraph(G) + H = nx_parallel.ParallelGraph(G) t1 = time.time() - c = nx.tournament.is_reachable(H, 1, num) + c = nx.tournament.is_reachable(H, 0, num - 1) # Provide source (0) and target (num - 1) t2 = time.time() parallelTime = t2 - t1 t1 = time.time() - c = nx.tournament.is_reachable(G, 1, num) + c = nx.tournament.is_reachable(G, 0, num - 1) # Provide source (0) and target (num - 1) t2 = time.time() stdTime = t2 - t1 timesFaster = stdTime / parallelTime - heatmapDF.at[j, 3] = timesFaster + heatmapDF.at[j, len(function_list)] = timesFaster # Add this as a new row in the heatmap + print("Finished nx.tournament.is_reachable") # plotting the heatmap with numbers and a green color scheme plt.figure(figsize=(20, 4)) hm = sns.heatmap(data=heatmapDF.T, annot=True, cmap="Greens", cbar=True) -# Remove the tick labels on both axes -hm.set_yticklabels( - [ - "betweenness_centrality", - "closeness_vitality", - "local_efficiency", - "tournament is_reachable", - ] -) +# Dynamically set y-axis labels based on the number of rows in heatmapDF +labels = [ + "betweenness_centrality", + "closeness_vitality", + "closeness_centrality", + "degree_centrality", + "tournament is_reachable", +] + +# Ensure the number of labels matches the number of rows in heatmapDF +hm.set_yticklabels(labels[:len(heatmapDF.columns)]) # Adding x-axis labels hm.set_xticklabels(number_of_nodes_list) @@ -76,3 +95,6 @@ # displaying the plotted heatmap plt.tight_layout() + +os.makedirs("timing", exist_ok=True) +plt.savefig("timing/" + "heatmap_all_functions_timing.png") diff --git a/timing/timing_individual_function.py b/timing/timing_individual_function.py index 809315d0..40a798ad 100644 --- a/timing/timing_individual_function.py +++ b/timing/timing_individual_function.py @@ -1,110 +1,59 @@ import time - import networkx as nx +import nx_parallel as nxp import pandas as pd import seaborn as sns from matplotlib import pyplot as plt -import nx_parallel as nxp - # Code to create README heatmaps for individual function currFun heatmapDF = pd.DataFrame() -# for bipartite graphs -# n = [50, 100, 200, 400] -# m = [25, 50, 100, 200] -number_of_nodes_list = [200, 400, 800, 1600] -weighted = False -pList = [1, 0.8, 0.6, 0.4, 0.2] -currFun = nx.tournament.is_reachable -""" -for p in pList: - for num in range(len(number_of_nodes_list)): - # create original and parallel graphs - G = nx.fast_gnp_random_graph( - number_of_nodes_list[num], p, seed=42, directed=True - ) - - - # for bipartite.node_redundancy - G = nx.bipartite.random_graph(n[num], m[num], p, seed=42, directed=True) - for i in G.nodes: - l = list(G.neighbors(i)) - if len(l) == 0: - v = random.choice(list(G.nodes) - [i,]) - G.add_edge(i, v) - G.add_edge(i, random.choice([node for node in G.nodes if node != i])) - elif len(l) == 1: - G.add_edge(i, random.choice([node for node in G.nodes if node != i and node not in list(G.neighbors(i))])) +number_of_nodes_list = [10, 50, 100, 200, 400] +pList = [1, 0.8, 0.6, 0.4, 0.2] # List of edge probabilities +currFun = nxp.closeness_centrality - # for weighted graphs - if weighted: - random.seed(42) - for u, v in G.edges(): - G[u][v]["weight"] = random.random() +for p in pList: # Loop through edge probabilities + for num in number_of_nodes_list: # Loop through number of nodes + print(f"Processing graph with {num} nodes and edge probability {p}") + # Create original and parallel graphs + G = nx.fast_gnp_random_graph(num, p, seed=42, directed=True) H = nxp.ParallelGraph(G) - # time both versions and update heatmapDF + # Time the parallel version t1 = time.time() c1 = currFun(H) - if isinstance(c1, types.GeneratorType): - d = dict(c1) t2 = time.time() parallelTime = t2 - t1 + + # Time the standard version t1 = time.time() c2 = currFun(G) - if isinstance(c2, types.GeneratorType): - d = dict(c2) t2 = time.time() stdTime = t2 - t1 - timesFaster = stdTime / parallelTime - heatmapDF.at[number_of_nodes_list[num], p] = timesFaster - print("Finished " + str(currFun)) -""" -# Code to create for row of heatmap specifically for tournaments -for num in number_of_nodes_list: - print(num) - G = nx.tournament.random_tournament(num, seed=42) - H = nxp.ParallelGraph(G) - t1 = time.time() - c = currFun(H, 1, num) - t2 = time.time() - parallelTime = t2 - t1 - print(parallelTime) - t1 = time.time() - c = currFun(G, 1, num) - t2 = time.time() - stdTime = t2 - t1 - print(stdTime) - timesFaster = stdTime / parallelTime - heatmapDF.at[num, 3] = timesFaster - print("Finished " + str(currFun)) + # Calculate speedup + timesFaster = stdTime / parallelTime + heatmapDF.at[num, p] = timesFaster + print(f"Finished {currFun.__name__} for {num} nodes and p={p}") -# plotting the heatmap with numbers and a green color scheme +# Plotting the heatmap with numbers and a green color scheme plt.figure(figsize=(20, 4)) hm = sns.heatmap(data=heatmapDF.T, annot=True, cmap="Greens", cbar=True) -# Remove the tick labels on both axes -hm.set_yticklabels( - [ - 3, - ] -) - -# Adding x-axis labels +# Adding x-axis and y-axis labels hm.set_xticklabels(number_of_nodes_list) +hm.set_yticklabels(pList) -# Rotating the x-axis labels for better readability (optional) +# Rotating the x-axis labels for better readability plt.xticks(rotation=45) plt.yticks(rotation=20) plt.title( - "Small Scale Demo: Times Speedups of " + currFun.__name__ + " compared to NetworkX" + f"Speedups of {currFun.__name__} compared to NetworkX for Different Edge Probabilities" ) plt.xlabel("Number of Vertices") plt.ylabel("Edge Probability") -print(currFun.__name__) -# displaying the plotted heatmap +# Save and display the heatmap plt.tight_layout() -plt.savefig("timing/" + "heatmap_" + currFun.__name__ + "_timing.png") +plt.savefig(f"heatmap_{currFun.__name__}_timing.png") +plt.show() From 04057cb536acfd638ddc06702dcefa7d0c8eb323 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 19:43:01 +0000 Subject: [PATCH 10/20] Added heatmap for degree_centrality --- timing/heatmap_degree_centrality_timing.png | Bin 0 -> 51548 bytes timing/timing_individual_function.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 timing/heatmap_degree_centrality_timing.png diff --git a/timing/heatmap_degree_centrality_timing.png b/timing/heatmap_degree_centrality_timing.png new file mode 100644 index 0000000000000000000000000000000000000000..655108f2cdee18971dbdeada441a7736ff8ba3b5 GIT binary patch literal 51548 zcmd43by$>L*ET+O0=F%#Qi6mcA*~`Yq;!Kwx6(Z(N;gPL!wlUuprUlc(4hj-&4Bc8 zT|V*N&+{I?@A&@w<~Z&f8Q{A1wfEXHMyBuGwEoJOHgBxuP;iYU~H4ixH8 z(8=TQ&PLu22YlhT7gM)avN5)I(zi82$?Drbv$V0dG<$l@(a6@$%*OgI`<>hDw^^^5 z+S@;~+o1$pLV3%MoWvGv59 zYSU;k@6F1L=C;tJVOrl@dOqcOS@_@(vL?wv_fMd%QBw;#KhD-H4ymcBfq{hIHIXA^ zYpI1Y$;&Nu#8buzIQ4#heS+J(=O)}oezG}IekmyEeS+sU`T~=jOy5=UK$^IDT8(Ra zsRX=Ip|Na~3?sMIAlInfb982I^)|~uJ>F?49;P7TI_AUL-sV^{%wtNj9H1xW>m1eygXH(fe-QJItw)T)lmNygsn6Dg2g7g}s^d$eZnV zu!6Tt+G3+`>p!eol?rDuo9;-IPod5X4%w6%C@>FxRV+E*EEb z)vGOD2$!jpj#bUp++HXh>&ervT_@!r@H@_D^y#|E%E`%j_pWSis;R4gprWE;HNvUo z_QE2bmq~i}_HE;6d~OIbv4TUt)yPn&8N;3oh1KQdj>VD6$(b25;&8D=Ul&uxR10yg zkAZoV*naY=F_ZQLW4g&l*QZ2WVSbkY*b1tHArX#)eDVVL+Je}dF9@bQg^|W z&@~w8hV_-rkHIcNGte zOiY*r1S%zZy<;4%XU0eD{KGBDx-sc(4?-q;<4GqmcAJ&W%+5esF zbCLOmiHQjW$8?~`{`=A;*mbZcdAEN5ZfI;oGf0JXv_#zyi@uz2!<{}s<^XDeG#;zLn{FHPkO>rmZ-}jT zO3-Q$&{EpZEgBA1n>i@B&BJUbn;2kz2st`h5TFG;IE}(I&z%L{I1$V!=cM)U+$AT! zV`nZ;j|YmVLZ+FVoy887+RXiGjiC^9&V#s5m5JhP92wDpRqxr~+o2t*d#85-(j~-# zIs8Gy)e8HWBsQ%QB;~`tSP59^mKSM+Z(ySb3aUMOmUo<#%Cx-CZn7y=E{=bu&Yv=Z zzb{8e33l@2_&6O5E}U73ag=L-Opa36sOjQldxBn0P7YQrH=bR$>Spb2F53yo$)<2= zc6N62(W-Lm(JC_szbcnCEIO1?v&Iu|M1tfRigY|q+WCP( z=@2h6T5E3?C}f)Dwy99PNU82U;TsKMkT&Fu>M1ai#;skvr7B}JR8lM$5D?JY5KO1? z@%|wszHCOTwwG(k#_aSP9-BI?@j_ZP z2m6&s>VVvoVbmOPO3vxvBh(=*6s3@q)0bG(M@^nzX46c0@d8y|R@TtmES+1kU%{?d zZRy}w#tnNzI9uGGg4ym@%WOwtEnR!e2gvm^q4L6=TWeF?n0`)O1F$kHC*`hbg*3nTF{!oc18ljjf)d(Ymx*ERzd;b>m zra|molQyIz+1lAD1yJ#EJ^gx;+)H0&3YS~07|x=CByM=Do7b-0_r1a)$2D5-5g=ts zvi?q}>bNUx9WDq9bZ_@?JsRFj@g$8BbSaPuyLnwrP0gq=L=3ikA5@YsQ%fXw2I*c^ zJc7E<4_USk_K5v-$MtYljn#Z2=3vi>O}p&z-Zn85UTJc1*yAJ=rVxt}dq<8OQOQ(f zXpUR}6w(JX-vgy(q}pSMlflB$G7H{FgJsTC%hhF3&H6w}BbagO0^{^o?+dCHf|~#} z)~4Fkb~YANQl&0gjaF$O+0$Vsv@4!>)L9fBsdvbxCL4nE*3FxeLnT(-P~uc_w96j_ zdi-A2SIO1YSzKI1^3lZi?;mYy_EfnodV7{?4sv`h-NHgRtgf&3K$3z@ECAp_GK$kk zYW;)0zCKo`vV>i`>?6!9vqS(@G_OrOkIg7m?jmeoLY}@TWJOabTbwbF)+{2-3D{JT zgIy(#>fcEzJLr9Q_=fg&`Pz46YBDl@;(k~4(eVuS!vGY-U<@TyRaKDlM!8&CX{X@k zSY3PtRIDDH8V8^#H3+$02tlkuvN&358-I0btzD!v%b)7}`J{Y`3&#d;?nMIPJfMeERky$eADl-_U>%rcp!)y<;Y`mZbfRBL4>3c4#DT5_e zIAp;Q9GIn1*i+$Pk*!my3fn*2UW7a>)*_~3F0^N}2{0n8xsXwNyiTnbiITm2kryd7 z3oUIzeYJI9g;R)|&V}w83`X|D?av_-axHkbP32^95}j&y4frPlv5Gu)97)JIAF1VN zcR~yz#S8GDtjG3xCo&gkKiD&nvL-_s4Z8qW=|{=!wu>Bu4OsS;SPijGm3!`0z|Jr( z<`N5}5#lmwqbfEhNG(;Z1voAaXWlWWn}%Y(az3g)kXEhIaiOQu$%Z&Tpd{vV5uGQ5 z;55Lo*+`fQxo!Au4!=5f2Kkk(ot@^ zS$KC@G+VDmSXY378Oj|J=aB7E1)LU5TRz+gSv+{JKU(ENaqrnjSbU?XUxM&UjfFl1!f^o$5u&khfszuJ zCL3c8IbpUtJ<#Ve8}`yIRdrA0m5;DZaIk}#0LdUQ6%hAKq@Je&6!jv*#t`Sd=ZeD> z4${!sD1Lr*EW7E#Y)y!rNZr@384q3`$0}!r)CExE03Mjbe)NG_@%|hgE0Iw)It8#( zChQg;KffMiB9WNUEM^%s$xU$&kn#qYF$cAw8+rnO=~eZ;^0OYl0aj$fQ^07#w>Ot^ zmzSSW-+Oi+W28aZ;syW&DJt`BraZu zyVV$E2z9Pr(Q$v*rFLLoz6X0Cpnf{{H^T zQ(AuejFn}HfRvK@+S*#@x$M%P)N9W*i%j+J@$*waLMaJ@mIovLt0l@mQrERn(Q#v5 zLo{Rs`U>HV570qz(UaHguKK3V0~{8IStdc(MX+fhqk^KH{5bKUIHY!e__y?k+vXxU zZ0WnRz1bGb7K25ZKW1mmwX59+6dhM5(6ytZ!k;aSd1CxwRkgy0wz=hfkUKU2KzI&w zfm=N>6w^~yVma_sk`r>U@uNqN)Mp`FVZx{cojVkJ%bt%PUCJ*rm}pNJvmL(o zv$MM^7kf|NA(l+|_ouwV!n;s@p1?0_*LVu)(o&{B@;)yf!(+u7*c^V#q{w zwdeNPTJ;M1%1DMS3A)zWJEse1t1qfVfdUOHhHnyaf2 zWgp;Slln|XDjXvAJ&{hK*R3BCD?}MY0Gm;8aIjZtX{kbiA)1brwMDVooWKI{GLxR5 z*~IU(Xer4FJN5O&ge83lVAV~`!@b(qo3*cZ_xAh&g=&Q}q|3`$9oYDK^4xj8DgAKh z!yN-wVp9a0#qX^x4Wjg1UoJ1bMx0G!2*b)^G$#|VV z2~@uEZQ@+S>bnCkF>!GU2I&Yc*wL3bHeloXRF(obvtY(4Bzr-HC21aiv%ilg_G)== zWRq>mqVZ%gkORN(z#fO*NL=OzDFB{KtawBX$_Xm0WP#?=-u1;26*c<{5Z^@TeHgaN?%cV9;Bttw#NDkm zt){)*-JWU>HvmwroJ4cmEM2^&r{|yamD)le5IE2CwL$K%hmBcmGp76C!Gj13*uOeR z&w|-qd55&P7cL~j;ILx8msRN;&Rk~wFkOKID3qru!ONz*R*zJ){omtc@KCZYs}uRI z?AAVg6JIZ-L%{*GQ6!iHSRiF5IW`nQ)GNJaj<&W1SxS^}*^eKuA)Omi+`8&us~al*I&d9Y z3w@BkU7VfOY1FdR6++g5fziqy($0S>21{HQV|x#Foa#zhWhG82U2Yqaoda=J1>IOv zLRKT(=#rbIRVv4(S(H~X1xY6jqJzm%Bb2_pkEnp;c~*@A)8ETKyn8i@OoO34%7Hi3 z@f@=~naLkMd^j38>8uV!8*wTDBUMq;@(r3bpf@;NTwFAoU=z!`xVmbJC)N=sUmqta z;8z`fKaF62tRPS!-nv?~x7UFJu;@xbM~h%QU(vlDH*Ae2*aiaLTgta`3xiUR1itH9 zD{pr16YJr!d*Rv<5^D3-fXBVW_m$3r}UL=O^)4_Wxz$ z>!znuZYze=r>VBM?CsPFhq*c}N>3mQY@rXMn6crBf$+A`AXK`nr2&Y|LRwocuAZ(9 zSRg{Ko*t8Yyo@~y@x&gqRZa0il~s7W3e4*s6dwvM)5lfqd}M!i7;Wsi8`edRkT`j) zhfSF=%T!2BhD?Ywlur_(!`c%dl%_zj6AA7|w$U~24_~NJFYJ{QA0A`V zsZfUOG6>lUDoh$+{S08F21{)c2v?e6xBHZ0etdcBurw;9Q{$;)T&VH+^XKS0242Q3 zAA-Mc!S-jI* zlU|53^CEz500F?ReT!jKReWXCwU^TSI!CX@daZBw`OougfzdQPe9ynR+#^q5kiLxG zyl@gtTqXuD%AXJfc1|{qUj=%#Vks6w=nfm}J!1g~V;{afy}eRTOMpHIy_v3%tDLEr zlxq^(oi0B|P}B1;jDwa32Q@ds&@lGtHy@37Oh;|sU3Rl@z#&JQX{*~#jm*70b0xwa zgV)y68#B}!dZ(9=QmuC5#*K@YFGoB{P)LzHCvAI=0pKp}x7u3KBgfAo3=I+b9?iuc zClKEF1SypyVJ8njf3&bi1p?LK_P~i|LNbf^>Ix|miFPcc%?QVD#7VL-U!Z)US@P>^ zdowEi{8w8X4#9{3+*L&cozh7B6e+kEZ-7`C>yzQx!c z{BYas$yDMzE1KZG>MguA5qh-UZDEjmvL$LQI3Ic`ZKSUP9)Z)OEeoM@T3gw4@$Ygm zwo4L)15`_e&96PoVVm=bn}zN7g0xH$gzPhS@G zypKT1rf)0^A;8p(h6#yc`YWgGp(GpzJ_147EXo-lzq~zzgFFc7I2ihj=m~?bb#+n| zB44=j-zX19m*?l_Lk$b73#3hWf`1oki4N|FtPD})K8=N*0_mZU9RLwI*UVA#hamxe zjFDeT0RIh7ijc}sFk_y?L3(5s6cik&_jI;*aIN{}jh54X4%p&+mdRD|koFx2w-o4l z=YWz>fDMUoI?eC<4;?wK{`tC~ z{!@O>-7=`RsYt&7)j68q0V}+>-YwBKjEE7anU{z_Bp(F>ldce%oO%6I+(H;3BY;IL z_IuI{DE8^or!+f8&t4urR)jh5gi5*-eCiS)!*6wUv+Fb6s>xXav?4UQ))A~4prugd z3g(}SMVkfMV`hoGW9~{e)mv$h#nND(nE?e23@vB{>}qO#M-2oW=RdR<+xdii15nRz zKOJ3(H*}xqf>xx z9SVsK{*-TpE&KD1hN6$1zSsdZn`WolxyK`dNuFl1F;oJxzm*P*ra_d~PHLoX*T)C1 zi%dIjeEa%!8on(sHvt|ODI|?KsRd< z%AHq|V6$O?I>158r&6bQwYItpm{yx;ZAgk;4VnVS!6mqdAad5f)vRAEw8phRkxSY`sg zG=r{0Rp|$UChxl%kU#?IYJ^>Xo{D&hmwKhP`_*E9eyw?QH!xY|-+V41l@zwNp@~U4 zR00+02v+oMa;Lp*hvtJ)vu^Q74!vHdF^?;`x*&2aj^b~jBj}+@W2{t|5`)8G>K{Wj zMyOQgO4~^vxX{=#`cVGjqnMj}A6xUf(O?@BCs`t3B%OaUl5B2dej zK*88LImz1}eB}Zz3=(1jfuaw{WM5t*v-|g7oW6MTBWP(-!0>xJ#g_GbZflbRs-_b* zOLnibh{J3c?6=UA4_!aGF|pA7&LL|JYv%Cd%Q4*4&&8>Uui;eI*48VF-u3x~g}$4@ zT1BP`A-#37tX^+CZ{8S5s4LU2^#YVs?YX-mfv99??+KU(LcyEb+MiEtppFCD#6s=U zjQ533f1)uIA-D+{3Up!%=oVlhO)j-VFURe)s2$Fx)yv8D%ski1cJvnH*gj;Gi{;4@ zf#%Tkp!wCFZpvwD_d%L$=5C*6V|4 zgOtH8eHd>!P|yTvK=@$S4v1CtZ2w`nJ$hwlv8=NlAtzNSfdGYmWp}B7Y#)qUU+&32<9( zgrf(&lbYW?Z{WGoKjRnrs7x41^j(txLO!o$|3_F?bpkLs)k#T7Svk@Wx}d^7RcQxN z1))5Fh@)IO@lLU6C_x#TCED2FueZ-4Ng2ZZF+fFz5sa)XV<@3T2tZ^v%+&?IAI~AS z>8vSszHWcK*H}*W`qitfcsqN0<&~e`jIMgYeH1VKAw>Eq=sFIeB$Q*>9%Ro0AC(3rT+8EgOwYy}FE17-DR9|v z=>C(T>Y70_If!>Fg^qT!!D45?B*6?wMTA5fbzhSm7#f;G*wjal-T(m=rv{g^Pdv z^$~=#Szy04tDN%zg)(t)aKH}DfMC)rdzOgY7;-iiC`CkqN!dBI4uq=$SRy!6D}lrV zCNT-nPA1eFq_2%Fg$S4(E-!?}BN2)yBpFd)mAatT^dyO$!SAj%H(HwjS%KtGL^=R& zu?zOiBU`fkSwft(-b&d_$ZL?jez5pD9veuyfPWy6HKL$Qj1!xFJ zE^<)%K=KV){nd(tO3YYELZ*eJ0uX|xr%i#pH(&eJI+&-Auq?&4lTtAOLB(PTyG84T zNLc_nU_9Ie+HL4^gXsOq?~23DQ3dw72bOB2%B6di#&h`u7yPsXZkypW>LLNls|Z8{ zaxG~s8Y*%j(1HLW%fdq`LIV&E-70cdP#Y2WxUn>rFu%d10*D%fMTC8aGOr4cS!~=& z0XnJ;qRO!)e* z!y=IbAV)Y$e}MHAFo&uR4CFR=V#FQL<`C)8h}M**TzYGOt{|Y`i|# z*WLio{Kv0f##~l|J!ODMfQteSmT(6+Dw{A?jjm_*_FN#KKWzc75&6j2;^5Khtyhr_La(o6}OaVggB-+V3O%m?Us zc|mnDE~}`Z(ACuy2S&;t;)f#SC;l>`ns%Jiw2oB^$_qb^S$Vp(=Q2JIIl6 z`~d-V@#f=CzxQ{R{2>(TpE)|pEk`qa?ag&jq~f=4K`z8GY3TZ|H!g2(VkYhYZ~UdU_Rocv*VgW8Yis*q zRdB$X&EJMzTpwWXc<_|;^r#TP%#|)_4sy_+z37vHKT>U%c=YJ1MuFiS=y?3{^781l z8+Ts4dgTZ8Yp4NICNlDgrts!z*qIO+B+_xPp2?6;y&@wckspS!s5G&Ro)}tL7K-cV zM#MRwMj?r(wv3I9nF6_zZ#}}FY(LY5%#H%UQ0E;+;-K&>@s3|gjhZJ2naFx0%eOk! zZX=T*EcChW=5_B80k7bqx#$nj-CPCd!+SN)xn4Hl+UY*`-2g7-DyQLl1u#I=f@f-Z zb(Is6$y0YCK}{TxVCpDTLAC{tvKk?fhp#u@1m=N|FS0X#S5&cTGfDrdIQVMrfPaJz z_#&^@uU`j);mQDTqqwtknF_wFPvrV3qUbHvtlRSo3;rN8QCz=%T^uw{zXGFX3b^6Q zMvcc#NidxRX);JHr-W&l5_dS;pal3|(RKd(%+{6@x-N52Yd@F4K30i?+(-#9%$r6e zf9;zU>>e*5-Jo^UF#wJV;ljrkLObx0{d+@hgq zlNtJBZq5SPd|;X4f}Ho1Rk!MQ@N>$#BegPhv-bBG-UBv>w1(pTKJbc^5RTl?rC5KEVZ^O5t#5HK zZ~lM)vZz|`l8&gZzW?%YUFA}B%zQg7l2w-JKb)m|S#EF$^-1ZQA*Y7;qR^T6;*EYy z5TNXV6+GI<`>mUepI?*DcH%Kq((!DPcm=R>sX<2oBljL$3Y8gv#P0A{QmU>mwBgQVAH|YzWFU`TLULfa>Oei3F&k~KeYz7$SjUCHR3H`rfVR=i2;fEkaU&-6BZ$u}Bn1HM zH0W_|hJc=C1rtq?2Zz`9M!NrWY5t!rogwqx0x!}D-a(=>)Q3?iP0?#}-2z8ZlEwVx zdU8vDp{}V6?g?_?MNu!51)l#>bbSX^<@`Qjbe;4^xiL28me>wCaAUQT=V?>C{J^ZVPcXzJf&dU4k4Z}!rz<4R>m8lL=_$A4VtnpKd<6RNVVncgA+md z|8c8p7}9I6um8_vz9`ba@tX9S!GFB;SpDO5&FVJle?8a#|IK(-hmyE8`7v*;j~%&-Xslahsmx>ViNT?C9Fk?r#qC3DF{V9TV{0+_ww~)tdAaV9Q~|&%=edCz3_rc zis28Yo*St4nAa9$W@Alz6<4UzEcXYL@meZd7gs&!J(&*pQ%NrHGV8sqGXJgXfmW2U z_j6?H&t7xmnB6EIvbA?m&Qau3DHjzhH*%1kOG7!!WouN)HBTL2zsFo}(z+e!TGg%c zR_dZm-r|^+qgi9@WQFYv_GyyAMC6cK>maXcs}~1@I%fOIn8jX!|1AH<(CF&kn`|+M zzVr2!7}7Z~@Ay<{7fwIw(D2`~^CHy|hcz08mnA&=^XxCMLoPS$n`74kVt{YHm83KB;9qqj}zZZa}3oD#l4eG+8L?7j9suhh|s&E zhg+R~Z>l3=CpxJWuEcX-=~G6TXjNEZLA+u?*!ETHuHZw(iw^Nv_GO{hdUp0`ShSNq zsznwY0cJ-`m%-TKLzV3w&a!_0>spPSy-% z@{;NqQh`=xE|yn&g$6iu>TdD#Xl9#`^(-t`&K`8lX?D9+GD^C%5cS zrj0t0n-+Exf$0uM` zn;UaJOONGa3QLW)LiyTch#tEhw_CRN>gDym;JtcQ2Xmf`oC1!BU>iBTtt$=5G)F3? zM@QWFomO{4%`d;0YS>pfp;F~|=aO4_M!~PPBnrH42tFAfU?m(eEu4QRfABYvQN>hA zcX4!4wVkh=FLNMWRyj}VkWu3d^Vk<*>?j_alTe=6F_&mILWOEi)Wht{I>huTE--S3 z3+S;EJ!{^@#!?r%5PFZ!MV`HxD8;R(oa4?n8PlvLJIE7)c`3+WF;+2lrovHaEHEbf zQ0>?+i3(Jgb~*t*FaykS%JtXFm^$q`sgPQgN^oj4-LqSHFQ+S#TP-coJn$K9)y(63nCP?AkUzCGk%({mwoJ7sZ36j*1g>9hbK^wo0R=v`f{{W?KGQYj6-e1+fs2%hcArS zwEx6R{9H`>S?tR=BN)7SgQ0tGf*;inKJFqpS`l2=B`J+2u)a&F-3*^-H#p%)+k?51 zli@t@G-4I39F9aXU-hGwxlaeq%q~gquo`)tU8pM3GjhLhP&GOG zJ*k+wrf*wL#8k01Q2Cm#1I8!>ooqqYHSf`EzkRuz`iP_espAGqWZ?&%gnl#TyJtnU zGK2Q>oxz>kZu@eVu$qqpaso0* z$Wd8uj*qEY%2-xq4hj`IPp_6sc+ga)$II^AWZ~2_7ASPZ56K$c+FxoOFPMJO3*^k=s)vFHe z`$sj8f#N06{>W>fzFq?0cLDImK>rkj2L>?&jE;_?2Ssk&cnCmJ)hPHJ_z!=c@l2t< zqIN|#`XKyE?U!QA=7YW4&r96xwlkFPqow1zCZ)~0c603o?kUej+H_nItuq_WYQLae ze*qOA){rM@6&t-aczadksY&?29X`Yu$VJL;nYV z-cFFQY&25>)hG-3>iah*12`lfMw28K^(@1x{1kG!mRxVBt2xw4O|FRB_*E@tVx zujIIw0a2VGI+JF;)BMTa?>y>!>Dun01wqWGRpHwx1$ zVJOGO&gQd_RbRT%Uhht`*1T{F-K2Iemp!gBE7eXd!|?to>H#O2>!sE>HQ!P;qJhCv z?x{H!&EhA;)jIpK(M88Tt<9+_Gh9)oM&E7KJU;>9E%Pi(O!u=cC1_V$B`G-!z8LOw^etIidi-rV^v<3z7DbX zgUMg0beB0+wO>m0ttK)M`}2*Xty3Glgn-*f8D*Bpc5COTuY?ta$P2DV2x?txq3>ul zE<9WN82sv!OG~+6!jJ{|F*JF6ye5MdI&^4e3{@Pj?<#A)^)c~-PW$v_wvR_D$*{Jq zJ|T;_k>!~7L)_MJ>odY6wU3pR*OTYIQuz4zNvTw!mTwE35HM;_(1|3|Ro%a`hOQVJ z)8=>9NTmQSVC-&s`e1;Cl}d@lXKOJpF|1}EwkeD)H06MyT{OolW2+<0wrxIk!~f8- zw(YY#wU;-x?4N92SIJU%(6#p`@hT?w5<}gopp!MpjwZorWoNRZ=}!2m`X48#;Lx%$ z-$+rw_#kV1sp(g)Nc&PS{hq74FClt1 zge$x8SvG&Z*sTDo0<&GzUzaXq<0AuSCm(Xm_SI@JjL@zt7k>AR-tYTDPhD%tiA&LY zkS(;WRyAe)ZY-Go(sEPw3+->*PZzl0_SgLHAWaA|#|((M36%WV!D1ZZ0|2hF2}}lv zW}E-?+xd}pu>8Ye995%$<8%{2_H2W^gf~AzRr1siU1C>|ymdKC(OLb?ELXY6nJ>Q+ z^=`#DsXBPhIiS0e2BeW++L~NdP&O>uupGm&;V^0mn{Mb3NF>DOEC$zn*JDP+)gH+2 zdnL}R|N9i!;5u3yZs+wMEMTv_jZIY!FDe?v-4kNpOE@SaQIa6v_; z?K__Wry{iy!Ct!4KgA>J!|NZ_n~s!`RCsQ9QD8bAi)SUel`f(~VmbL5+deOzDk1%} z_R*}eE?Uqw`t;@I+=t)J%=W#-T{=gTsOfJZ8Nx`CkYqVf2)gbNIwYb+Bj>Q#S~G8C zU=m$2v3&zW+=&(;%;6V9Qd(%D!UyOI349>!O+>XS@$K0O4%8iTwr&}zc?cTYN4h@w zneW9HT=DjI%lic^whovoB?vShKUMM)iDPyQXnb=i;pM0Cioa~d76YtjS074m8MWo4 zO7KdCJiPJn()n}d4gs)Tj*Xn@g_0p(@*EX^M(@6 z_*CE09PAst2mTiOIjS^+SbwH{xk#SFl862@^#eix~1Tuz0~~78&4>`VcF2 zrK1&c1Osu^Pn}%J!s?b{A6mbit|vcZf9t0D)|sj9Wi+i;+OrxOXHmwr15cwXG?#PC z3`}lL56bfK^RYx?AW*wKU%H0fA6Mr5fZuzxO=v|X{ z?UmCDA-Tt$DK*v@~SchX~X zQs$TrSx9ndvhDWN;{$!0OwAFNnV!R_px(H_G}bm6*R^jOtG2C4=!E@<7W-~kdc5_epi&3A}tXKAlt4(2I3B5~n z2`vLf2Q}m78+$_)j#Fv$%ubi}zER{yY{#=NMh|XZ*fMNO)7rG28*7$Ov9nbJgldsF zKDU^f-+H`JK#50(1fNK|nbkGzKPI$Na!B$m)4jtUplWRcp^W(AKs6PIqm{j&1bi^v z19ig#4mcr95S##F1}}qb>^%kGi=8 z^$|3SIS{w{g072B%+JS;{`l9`QG#PKX<=54f5G7fa6CwWHl7~_CuhO8;>@+YtO{~5%rdt@-C0oe5MKy#kd(_~+dd}l$o;ynC&9Py&sWMRCC+q% zz`KSGf00mdHDFm2JEuY>CMOwST0kw*3_o%FcvD{=?`ID%y2GKHYw*6b1{4N+h&!-q zru|u}zf)(?x1(g32V-MnCl(j2iy0#BJAuXwOanMR44Hkw-T};_G90-$x|yk2j0Hne zD&j*QfB>18nqrcWsJ}=JEAz*=^UpDMy=jDHE{AfTvQ)hz2S>LMHHnZePdkElLxkvX zdRQB@sMp#2o+UVp08yU_p3XV&`5-bXm+QJIVo5>^Grlmn06f;ep8cBa2c>&&-E0w? z2M9fV;4H_&lq0xLv(hmeXli=&6?W~Qf!&(tslbQ#f)$hN+4uY5aApJoRvCmEA}B6Q zTM(_Xj9{)`C;e$=e`DhD9~!dY*Y5T@=^>P2w%lUmAJQ`1QG`quAuS1VvE^ZZ_Rt$l zF`V~x&f&waJ@nb|y}Fs}Pj~1=ivHh;$2T*l{DGR(`a7}5^#IDZxPhnpA42(`4;1h5 zAM5Z01$57UP?$#%63_JS6s9atib0)!=M|%Gdjp5*_xA52I0{^(A=lqILq>!%{Ld?W zXhew4|06>vi|~s7H6$?Mk&_@!ynnt@POVO%I4S;icwh1YJQgxY`A6MRum3TVaW;D& z{!CzYO&Q~#By=ZctKhHub!tVA8Jm`THZ|DuXn(nMS!#Hh!Y_M z$ufw19L;B!h8!w*^z?3HU*BeMy@Rc-t0gt6{qL;%{b*fGXzj ztA%`Fq*4tzjN6tVl1n4xDjBjkT%L@$P{8R`3Mytg=-HW2q5t@G`*Lk!e2;w3T$jmy zSq;a$Y*h!Rps0Th0z*KwjPd#3_PEU#LJ>zjn)HO5C-IMEZmI%=S zx8d+JawuXB`EUsMchQs2hk>GD1Nd`|VKCARyLc!&Y=q4>Ku07wSSxMz$#?IgbJdDf zIl{RI0V*1g>{F!Vt&Lp-(?$i%HIi~v;zZgP6atNChlHI`K}FM34BO8)0#8>wS9)EH*O+wS!PNRUy}S0)gN;Y$cy1|5)9EhSs9Ziro#``HS|aF~ zyla8g`&i+A*M*sdM^hpvp_jMv*IY;On*;o5R3@S1k*EXvnomKw^FBsoBaT!@K85y{ zHgXY@e_TYQC|}T%?mIVu<8f;R?U0a2XZ5*H-S5Wq4>g!_x*zs{qyoD~8GJaC)6>Y2 zSj3_Q8()59+U{C8#br;lsP1fLVhp3j0bzVrJLj#ZDW4RH@n%PBUeM9aQ~qVRq`oGco)S0d_z?p>Xfw@Zee;r!WP4YG9;6rw z*`XxmSgm4cDZeb0qoJaz~4_lXyP}pr?0SORc)=!CjSrx<1|i4 zU{igTY8dm_Kbl+d{#)Co;BNWcBJ0FgZ_ms&e5RAfY5+$&6uFnZN}~I?+|_6@j;S)b zdPJR6j&!inQ!i=fYu#7#m3|{E(UruAxaR&e|L_ZX2i6g7>A;*mpYa&`U%g@;8}(do zB_uM$I{5?pn;$<`&ogrSEir5%x0e_fxjAQR3VnP<#`7V}V`C=XOHC2HvO|ZzZ}sFW zYm1aIfuwPIv|`)syb#(hl7T^*r0~M21nx2P6s(d85NZtgJGSf*)_p52%18 z1U_Tt5jeVv1Z}Q*F);7X;&QQIoesLH%XVKo58rRRc>Pb80{1v8ln|yC0rkDU~zMLz!!b_1RN+m=2!HG>;sjfRXTwIrH4>7m#Hr$AIRw_>=$gYf~JB#*7U3Un?dCH5u4_A zOhaq8>>;9_^LhQlFW%w@biWl`>1dPQVf&}Fk?P??Uy--x15FE!GjC4n2f_3 zwj!%kxtDWuJKhWMpR?~xTRHL~mA2CTlVU`XXNv}IsJ&y5bj;%6-gTH@AA*wUSDo!U z*Ny;&yq4_3a$y>2(4gxB*BI9x^y0{8lYowaG@H_KWo>uC?~U|^i2WPvbI4Ic#0-s~ z3pgZT4iF?HrN6&_-Sl+)5e@BOBbyRm-dBRtx_KZK)0edmXhp97*j{n9n|y|^G|qz4 zFqey{ITB=aBlt1#L&=+c9;xiUA{~)Ko{!&Zr>>9ON@%onWf?0$apry3`V7Ftn^(oH z*HXjb?zC}8z#XG6%8)x0-<}9rel0?k;(ly3(=6^6R&$Yo#MvNdbZ?p(|2RJ7;)^1u zr46BPbrelu452D6jPkrAvhedyHBSo2Gk?3P|N+gO{t|S5j(}4yD)OaRIObMp~Ud{<{7VQMZ2*G z?X3}em#Hi8Z9_9duqW%p!A`z|!FuPfQ=Bm^(IdNSZ+7ynMJ&_;^%ge`YB~cCk$XcY zNP_BWRw_O23a(D%zzuY3nP77aNdcQ?CLAM1PM0BG#4A^>;5?A_5?BbtZwrePP7elR zDL_34!l4(kWm|v${v>jSG5ni&eUqhp(s?t3vGC+Ur^$@Tl^V4GQ-`Ugb?^C#SW=-hgAx(pwxNY`uv` z-Vq5C`K!b=LN#VXqxU{0-}bG&-&NF4-?A%--}JH{uX)N&{Q40Mq{ICXs*{2_AaSq- zZl0j&?kZ90!8&`$j_ie|x^H4m5F9&EF8q}^qkLBitxX|(_4>%8KDy&Cm>52LX2Dq; zm8|NtMVAa(Ex~2Xo_d@Ual2r1y8gx4SDh0m&ggVbQFr(d1|VFTu~~q!5Cf=cc16u) z3pnXzr0fF64B((*A{-#Z8O#qBO9MsXI==-UK0pTsUEIR2y1P7<=i-e@zgUBAEk2+v zl-*QV?mAnC*Fy7)+gtht=n7;D#`6JUWS+oUkDiG*pqoYAmvS~<35$!{YX5IVF zTioT|V@)HCmBHms2NG8NeFSrp4M1=y(I3c1~ zZokOvT0EaZekVVbvtqbiWN}!Oq2lDQ5pn5AatrM@VDab>LAZoT4Vs8l1EC522*2GEE{TehON7FcCtd33)?T`Y8E2csL}DU67_ z9n;E+JN(@{&nZtS;FTwT;Xy0_|K5%$50mgp(;R0a-qvkqArnvh#fxXv3if#$o@=j* z-;(7hcXJW^Jbg$o@E?kW8U90IQECf)sBVkKpt=38WJIfInLX%#Q;BBe8{|u2(M37; zhc48&tQ*`H|3U>V?uT^i(@tnOs8I}`&X5LCwzWNaP^@3=z7+;1igYUM(Qqmh`REp4 zz<@Q%fpdq51- z$bZq|s(uPU-RWVEWcQ{di-UEnT|mt7%kxdl&7;_gQbV=ddGnAx*}b zMQ#6>EX{#`Y-}7*T{yhkJ|SAKFXNzcuM5#UI8<~qQ&Pv(Zkk*dnPiO>6;Pd5{QpRM z3$Uou_WgSlJ75h|KtNX-0YQPKF#v~@77#G#knU7eR*-Ng0Rd?kO6f*LL^=ngrMqLK z|JThv%euekeUJZp{9cbISa$}7`F`iVulqXB^KFGjcYzN zX$d=&q^;Oh4PD00SMRxyRx9xS#wG2U45T|W<^)XK0gTJ}w>bkp#lhp2@O&Kcjzp!Qkh;9TJ zy2!}LHApioeWfx>0wXbsdIZ6}QZoOQ3Zk6tm)ZpmHh&H~<;Ila?YX8rlQfmCX1%mV zv3z&s!_%4lQn(E+C)TzrmzR4Yt{Up}Y*o*MiCsKlXZ^=qpq9p!+lgjc{&W>zzT)4j z3g<9sNrXmpTTj=^Y})kG%5(~=%5%yeehyATg}m$3AgH#zV(w!e`zk}uu73MeqMs^H z#=2tuL9N5tlrp7DA-V{U=YO46jXF^x>hZ*?9vQY-U)#4a@MX)jC65P%)Rr2huBDE# zW@b4ii$6OaU_PFaBrru6<;@zl6!+PTwo$JR6Xg?6Ob0R7Y%+J>Oz#Wss67kzCYnh( zGsQFdcFW0)T3YbyZG2TLS?x0KIaXBmHA7pEgJP}EM0iFvbP4N|H#8h3mUn4pY;3t@ zqIk+BqQFKTciQ+1ONKtXPl~owWc|z$QZhonF(bjW;V;<3*v|=YB}%Ui#q*}Ee;B46lb)l;T5R5x^+_Ra!J{VO8w`8G2m8Pc&}hXU zanWpi!QDV91h^_lq=kF{0tY}GMj!9n@!^pK*gqw~{|;=9VLB{)gjK->4JY!%#>PA7 zVnU}Q0;ME68NMj$+#m_31>Zp2e|PsNN*>7XbX6rrNJJ0hWe^vT!}H9XOU>zNqMTnm zkMBMV?kmI19LP?j10yK~T0tl!g2ETD<`}_gUuE(QV^liD zF+;$=A(MPthIR&m27YwkgHjvaJw5bTNJc6`pcwa7)NkG9b(PNV4!gKi|2>>x=yC?^ zD~yVYf*d{MUgYJp&gU|bN9`~rjz6Y>ISis>| zHnsStSE_^AY2CgtweW|m0TYNATC0$XKyf3Dw|jSg1&UWyI+9HXFyj>rhnJwU&UE?m zWi1EDM1me|?5()_xE;Uig;*@%^h};vU+{x-5Fn3^j#g33ul(}G09rQ-D=V@#{=1L= zX8-Luyv+(hqQs@8?2VI2=KY`NBLU(_DeP=-B)fY#+>d$flu1hTjd>;=zn5Q8N>O2B zQ_H*s>#90pJ64Oa0O59a^16fTMv!W+}v|+0D#m_Yp1Z?Ajqwbjk-%svl6)oEx}WC=!WS z08qJSKwtEWR4$3VZQLJEdLbA32_w{zi>3mlf9OLxk-{>m{VenK+r1E5@>sUl#P4!TY@oX}9?)v)C^%s6`m+&^Ex zG8kU^=L0e4>}VhM|EDo+;xGeOrqFR2Pv8_16jTDmOoHZ==+}^eU;3zXOnL##Vo0<5 z-*=|zsxq}LeGmWW@E>6AhGd>Gd<>-E^N8M9TD3el-T(Un{i`!wRaJH7A{Ga$Qz~3f z4I0&C1u=+#wSc2P1%d{hQ-T<-$kU9CjM8u3K_VRmrcW_Q-_DMN)#I1WG#zxMB`rU9 z|Br^Wwa=m_%Edukp%G+E^h)pFN1f$VUo{{`(NV+u5eTmu)LnF|t8IyVn1)4|=jfT; z8;Zh{@41F05Sv(_s75X7K*;5z9=r;a$Mn#xJ4i;8O;@uxT*IiXt)23yx70-g471V; zpma+!(>#ehSAK;q-dS>YrNanP)ygqxqkOr|Ot%``cI_xX2kPSl#2AqWh7Mk;EQ)jL zQZFOOBrqCLL;9(Q$1<#g6A;XsXo~fWTX#r@u<;o1ClsS^JVzl*mxU@uME?bR#Qd$! zeHUE5Uw*PxP9|dT`PpIk=)}<_LwXGu%kJ^9RR-0ktHO5#thc2uzwZcG#=&{IJ6oJ( zk+{Lm7j0HkaXSiPqMhTxIcFyY5_Ui$rT}ee65`zikSq|07De}jg@ti(N6)fHyIaYaj-pn*?fZRAINJYqA0qm$8`vGhVLq zS7)234U+jqa03K=Q}|1W z3w3)ayYuqJkwmFq%OBi(QLsHMO}az0CaA77&w>u)e~pLYi7+%xTp&D$Du8$pnDkgQ z3$tNE4!?NnA94FOb@0Z1|EBMBPiV-=;~|e($x3$U&>cAXv;De!f8F@Wf|*nLfGs0H z$pHskHm%iH6jxiLZyx;jKGX5nK0{w|sqrAV$5!27U7ao4)`Is`6*^yZSh#swK^lV$ zPhzXY?qchH%#*nrHd$ubciYe_Q}H9&yO$0NJfC1XpfMmwB2YGD#q4+QTL2SNz=O>? zGAn{DcK`MVMY;799LM1kwY&1WOI&n;Zqf&}y)N)MSc6;48uIm^tg8#GPS=Q#sn7|O!ohQF;NAfi zH#*Z^)vJA9!Tk3#F#exVtYxRnN2m-+N){~o5W%WJhXo5q8zjm9{Sy*SKL!Hn6qE)4 zl*3Z-nA}NLh(}aE2n1^bw5tv3HYIQz{dxg0?$>k!_N@VUBNi||Sk$PbJ9zLQy1jv9 zfEV8S`RLb&QygpgfXXbKXhRrx%J4SW2@#P{d1n8tnG27lIzM+5#v!V zi6U(qx;fh8Ui8bKett6SB*ipj>=$8ceH&_JJt*#fK47U~ro_wlrOK1NtK5$VR2ORcLq?|e%sF=!IhNUS zbj22ZA`V5gOXWC~UM~Aw>en+n7FC&J)J;k5%vq+j`aU#%ao@09wlL1_e6vaOM7i59{ygrmACA3%;%iV)-p%8ravO`9apg2u;2;m!+p>P@CLTQ z+b2ML2hs>?yuh9k1XNT2ULe98@Xb;H2?4N)+p|~ZU!_c)eJ3lXF zMO*S+mBf`cq3d-tgaUf#!}-s+C`&w!d_}xB`Y=t2OXGx0uZUE8`BSCNlsArn28rSQ z7uQBRpJbar6sOhKM}XUHVXuu-UKU)f4DPbj_EC@0=KLs;5Q#HH`{kz0>WqEiLzN`m!DVS_*;+fRpCZ>Fte$E4(%E^2F_>K|KZ!lV zu7x!t_fAya8j~UeZ-lUoy05IPT%iLd@>FFmb$eT=Thikw^##_c6gQQv6tVJ<oWh3znMmCCR&m=P|Ox2%`pwLMBWrjA#W>WrfT7G#@q=%OJ%fSOgrXNP@cV5 z>v}w?OKq}uRZHx}wbtdv@c9XW?|cU3MOSU6ZT;0{#xrX2(yRJdJ)h3@Q_MxmO)_%O-GQ-N@_fCHong+vT+DWd^1 zK_7NV1p46Uhz!cIAsmt6uz^M;0HP%x;ZzZU3dHSNy+VUHABNQlgO-Z2?B!-sY_pxG zs{-Gwf+L?QdD`-rWC7XplUK<|DU}ejcoExaZ4o{( zlAQiD(gFL9Z1VrW$zGHz2*D|;f`xo zb=v%;+t0v04{^W94#Addwiy37hpf>Q-N|61rpyAm@xf@?algLp)`j>QLWzE!{c@!< zU+2X9``1xMEiYs`mx;36roBqIYYxr4_!R~2d8 zMbGGxwW2!t7aP>5JFV}MYp41DKw&3!?gwf3QWb27?>&_-J%>_8rHY-mtu$`mzukF& z41+sBn2ed-x*ONg6)cJCeIuacQ`34UZz5yDo6uL0(wF8arE9ZzCrEx8Ty1Pvep>4e zylnZrP#TTh>VrSi-AZomagZ8oIOWuwM2|56gfBYD*Ra5zW(@KWDl{FJ#-f3$$3#b` zyZy(}qeokShYO5BTQp7sSQ$-UeYV$wvLBoaW*!)vXowI{g|ZOgZI;2)I2mEr+ms|9 zYYkL9-01DAwhRKpb`7x}*CmiFMqO;7^gN4BaQXvX4omCZyh}H8hMQORMTuBUzS>(X z#p)f9NSA!8?el`wV$+jJ2@^^Y%xwj0rR;hdkwhH1m&Q)kD3~KUz*APl)~)=CwL!xP z%>r|=is~~T9B0PLDwo6uOC3jZ$d<}OtWHx}R*uY*h(cV)mxd1yU2}Exd=};OB$9R9 z%3wL{=aBWT=_$Y2yg3}Rwc!%Ju{hF1CDf->XOz<#Z7N~f`)2E^#U6W}idz+96XV7U z9Ghjd6K`r6Rzs>{w|%v-)@9RWQ-LX-DU$6n_EkqP_6p~x&Y$+rd&Wbv)ko9>e&^t8 zYW%(TCQsL|IYkHCzU@t9&Tzx0oXQ*j@hyn5IX|y9;Y(S=vU$~IY@tI4?SVZM&;L^| zRxpGP!w#GT_8b^<@e~;hp+Yg*_&JXV+rYI6pBhX9SSRezO@L=W1ey1ZBVna z*$lXTPy*mNY;}Nx*G6a7*x1n zL@!u-i41?BBd<~r&9PJ_9ot;=nU6CHnMzs`E^@< z?h(b^@VH4%b&Az5l}2TlMy-e3`RY6e-AqqGV!DlEilPtA+CtC#gy5V_kmJ0Yc6V^P zlBA39Ogvect6WA&hkE4-b^F=bSBfUb+yeG|Z%g~30aBto565J3XO?bF0u}F52y?s~ zWMD3`vsK1BiyU^lI+g%ur^L{{1_gqV^0mz(L%i}R%o^DCU76C0JB8^wdh~d}3FB|e z(aD{fLicQlZ9V&l1=d5Eq>lwt*UShGCok^(c*#Mx{&yz*!b@9PM zn7+2s==@xqeq;ZiblgjBYR?51$GwFVmp*C7audIwwBcxyjn5=^dD^u1V=P}0zj5u< z3Ch*YM5C5T<+>=ikW-s|ug)t^C@F|8pAAeDeXMNHEb387+87$EZ!^^QX#5;E<_odh z?rM(hGQZ4E$G2~{+*}s#4sQ9mXr(JFQVW!&$5ePFIW;_XOxCkuQ_ufHsC+*J3w7?s zsmR?+Tr+D6PARGxw8OY3`giEG<#b)2;9ggp7IM2=Hl-dbUnbj5#ofhl*R6Uo3a^uX z!_jo@9pias_tqU_ufjtxo+A>_TNru(SW)N_vSQcS?!Fgg2tcr!_bjteG#2Nh4p7W@ zjLvx;swZ3ElCA08a+AUXC2|tT+AWbnRkRTn|1C+X(~r9@2+9JPN2%D}qQ<}q%kQ>T z46K?AU?T~wI0J_KHOxmM*iMDG1yJ)wisW6IWn$oA=rypCORSw2kC(#v8QVG(IgW3- z^DbL?Fs~JSI$aW&6|>h_a&q*5;?Up({lP4cPIAL^K8drFQN{;tE-q!ZMfBmPRacS> zw+iL(hM34pp+Z+{O!QU0-Hfff`M5WD^k!@`EsUaogsOzY(e)IIq8E-I^!BH{A!@`d zyI4B1X6ftg*E*67$-fI&HNmWJKvKKT`xwmA`}bGXuy$P{MqIT@*K-k(YAK7te-=BU zLEX~ZtF?OcE=NrOhnuTri6z1I6UoPItE#Er13KcIJ`q!^XP@ zb$+AxQX@XBcFxuZijM@%ZXk{Zq9c(i%Pg3$( z#@WvHrU8;A36N`Wz6eG4kbx)h-Nl5s&(N57zI`q2FdNW0;!!9tQytO-84Fz5=Wi5LZ9EDow$O3=T}C! z-SWBAVYx5O*vo6RuZ)ASZ0n^Kv#LbSSKFons*sjcIF5Y{pnUR6<5U?+K5nq>XAnRcyhQeE^|v!Iz6 zTQT+9xR{5rHuLpvDqTHeSDs6FP7XwDpMLa(=`#O4PY>C#+M}CC1C7HUIt}fecesBzDk6M?zS{9ZvH+P6&;Joc=peRw__|(w*^0I2J zX1qrcDf8R{x27;L%5~krCpWDp-Dnwq8~1tkIV4)oj15IA7P<}>l1}r*SkOx2jiS5#26{2-r=g+8DPPR>i~@nr}UI-LiPH^?l8=v$$>KWggkb z#c1b6OaAx${-mbDG4H&2b6nRL#GXqe>r4;e;`$iUTWA+^vA8Mf>7bsz8Y3%9D_pF% zN9MYdT50>CqsLW$gc=_yw08^k)!6P)fPW|q_*OW9@uwVYZ%}EVOd}*49VK6`$uNv~ zQB_wZmX*sEL{~RHUm%{h*0(g-lDm)E-zTHMxIfx1Q#@Cwgq=3ZG3GKU>b)R#b zHz12V0f&$nn_L)y2cT4PW(9DgE`6xhgc7hrq_ld_1%OO&VgavnKul~bJr+z6XcPsI z92a$HX92y8hEZkPPOF2?(8Sf*T~Q$hViB?#fE*Bw#*cvWpb20*j99eH!M66un_&+& zcd%N-(ylg@=XrQ_OOxj44GzPl^r|B-j|I^=6zKTyhHY3dY^|4co;8hj)h%KU@JLDb z>}hRaR52)jF47!x81t^$hGoLmJn4*Ene->#n?u@6e!}O7zJgnBM~Lq}UI?ZAIc#>z z{+XU{cw%__Gy9U)y&3LS&5mW3yCi2Et`7H?7V&PxRyy`Rg1F^o0H`MvP$$%Hc3 zUeKp!ssy4!)Q_899V>+yIr-~B6Ht1B>ag4%H zh^BF%kd=y$L6d4JO-Ih`XL;^(eTwam`{a0rtAcNS__|%nSY*A3pYWTEK0Gbl_~bRK z^{UD{2Gci*94c=5$%}evD{#MxywSHwXYV6xdRrDO^h9OPZ8obT?N|N&{PPa58I?nU z^Y!-az_$Ac^dzA&eGO)B%;y2GiPrV!b>OE969#UlmQqA z1*0{zae?3r-TXP|=g0R?gSc5rvh8X}9Bj-65F%00J?tYKLI;3G;L9;zxzdOiymI9Z zkoe{&qJ&-4!SBAPgQL{Lh0d~7E z>bE|~H}t|7H)txrh#Htcq#oq5F<*HEM%Be(17H^B%)W;I#2l&`8aNz$_V`Cb9kYe# zpit_dqGa#hz0j4^TP`jta?12GJof@3fAPQqX6E4u&Y|0V6Xu4HW@WdVry zfR0`6b(&2E%pz!~Tc-?9xi38c2MxFbz(ot}XtoiIb7F-u4MB^=eb0vuy`IN#F~DI8H1UVt<-5P= z4+>l}ccbC}MC)YDt8K9mSeb+hSI8-s@CZzoX&QO;;;;97yR8r*%6sf4qO3vG6ipv= zata?B+I@}wpF)?0;JkMKXtKKf09;z6|G=mOJRBc(axhu?erXSP!+L-YdkJFuCIn8D zLH-$Oh{}Na15v{fG31vAiXPD830276BkG7HI8UM(V$jQn>?b!CD$gL~qd=)aIMVSP z{%5!>MN7av0GF9uRKC=h0fABH7haeyu#?*He~J$WPj zmu3Lc3fYk(3Sjbq-|GS5roshzO@}H(G$rd_3dPsJ&)feIq984FdvY8j@eQxD-G= zjfB)$Dl~s4K<$O!Z7qPRv6Mo{gcN9>C56&NU1l-x?h$UYbxc4Mf znunt1Fh=f`jAI&9)e{hWKpHF>GlQa2uvej3Cula}zjUlu%+I&shW=1>$Qp~LoPzG9 zlTyBWJuY}4gT#)Cg$6;q@7Cd}0;wM6yS#?ol1&ztt5lmBrXUG-)PsnlPGLef1+fMX5V z>+C==F|l!|R8=9tPLBnPSuoTazx+hZ-ODg75oIl5EJu#rtQHF7BS<-5TxkmWAU1dn zjKFA;U9S}Y$q9gXM{sas*eB4OM|d;HX=ox<>M(Ps-(z=@{Q!gxLfZ`N7|3}8=8O%*_%xdJp14=TAcHYy}SmU@a`N?`ujp*zFm-Je(NjrcYf0V<^O)oZ_e-nt{VB8KrUQ^w=*qL zz-c8F=9W&)ko()5liP zGylyh0ze5UH(sj5zly?_d4cc$A4|fyRS;~|edp&m2O~-5Oe8J#y1=4>P45B&LSW=C zQhOlZ^Gl7p%%9`b{7U67Hnp9I)T+9x+ZG!xq#6mwK0P0(etDE!!btaFd_lE*LFW1K z_S#UFtD9lZJ1un#gTp$nNU_OqT7@U@na4i$I(p}N!qbm@Wg;66ULP&p4=yb)S%@r@ z&P*20Hi+2>{V{5#-Ojz0{zwvwmH*GQL3p$JmNzlo+B4vUf|ktr(E}dW^|JU>L#Em`hM&oOumV-aRlc1C+ON|=ba2rWV zOSgjA6Wz_QH4N()|NSa3Ourwq`&kKto?vt3^9=`x$n>-_@Q+bNprFSUigpti>hOo^ zCJ29EMIj_w+f%iGh*P7&-MMLqR zr5g|bRjURcol@#uPWX(;LB&$#SGoHl-W@7v5o$DRhjG$q0w=^*lhF_Y7!1D3EpaPTN0u)&d|M+2aLLWj;1T$E;J4@OYc z$i^lEA9%%w4`Yyj0FCb8=Rb4nEavpd1B-pU>}4ddb%lUDaEAS!0mYvK0|RJGczj}_ zF_h4D)MF*Nlg&KFo*0bX@U7;(iV>qr-NX zZyc*QQJ8Inz~LFNuCc&J52plW6%a$bo}Bpm{r|B?_q43y}c1w z4mG(pHx?Ukzn~B927J1oA4`Hs9ZnB6-*$T1NqN{ftK+iAr+2VIGYbi6f-R;A>1a?G z)EEpMr|%Xh+juW4;#Q)LJ&aw*7|PJf5X$f(unwsa{d|g+2A@We+p)`bsC6u`4W=*_ z=DtA1sbrY3alZ3Yh$K?YS(+oQk6hHuKF--V9RCw>WjWte8jIL}m1; zWJOVj#9kJ%`OAe7^=&%`rJyMsVs4s}f z(N25w$86W0PZ9Bb(FfCOvch{EECTttU2a6C(|DDd%ia{B&L9Z1OX0pQpG%k*@*Fle zWx=mNSj)ZqQ48};?!b!Z!8>1ux@gAQEC<=*fH-alu26nh{n4%lG8q~@3%wk=X89kv z+l4o^>wcC-{>MGq|`7NwugF9wq_Rv6CXGLPY z4MP@90sV3 zz4lD$OUDm6%PJCURd3x0sYj0O&$rz20FOyPjhMZIxB{841Hf)lm zU2&5lN$_j(z2wB5-6Exas_-^h$@d#)PYqfT^0IPTI;*rNZ#*oHbSNsT*Yr~6>*JD? zmZ_GX{ZVx&hq9-~GBPMa`G?CMf45h)4Dh~pE9>!P=w4fJQu9Ig_SLJ43GHz7pc@C5 z8}bp+xNTU#Q4&oX5gpjGkm)$j;TWfBm195gAm}ht+F6N}Ko5@+p0sY9{^rCo9)I0? zjYYFG(77h?PB?qou=a;nzB2-mv5^rGX@bSwY@S(Nbpa|E|EOHHbkSKIFKTUd)(LU;oILVO5_VfT}npvP5C}ZByv<5^w#V0|7LaXx^Bu@Ydgr% zElCIQsM}b^w%sz5x2|@O>A9*+(LrIPSXxm>6${SgYn+dwls2ReR69oZxHrNG_)F3&M+lc z8sdrs8(znsz5eGz*-CuZM|Qq+>JF;tCvwMMXQgD}+cOgM z*KKeqMN*~2J(w*P)~gz$mmV#dha{ly@y^R;uQkKKluVcDPraac6x+FV<6Hn=bZK(%j;SHSO<={FeO*6QzpS;?kFQA2467Nfom2RPIS> zmg(Z_;GdXe6b}4!zj6MHfs2Lt%b0+M9&i6Wf2Z`zM?6!(8_AVt43K%%HzHnJc;#fkCG!~fIF>k8BK#$_oel}5C zvADmN5wq$+-@WHFw_T9vEI1{67lEmNQ8fbaQN1fJs<0TL=3$iUS~8kE19EY)+xFJl z4-Yc1l6u}-mV-7RfGVVL=yE~J20V3-)JvTU+EUeGK}Y61MZKSao>L5N?rtF~cEZfa zc=}@EBeTvK$qDk~GgqY5+B0>sIg^NUGvDciv3v0*8Wt1b+x&8iCx zyjzktX%%ss|B}7{qfPQcT!ILfii;aXtdweK+TnNi9g3W)+rFw;gm8>7`4u^1m1{;e z2jupPxCyBzzARPGyC=7mm&g*tk|Fn^m{ovEK<2LXW_$(p2`9q(X1n!L?cty$mS_u( z;siEYHs!hyTEz)M_TJgE?nM%=2SnwqR1YtKBXd~w{24VhHQ-iTM?F&rRF0jR67*kRFnxSAZ=TQvsP@ZfcFgj0_$&-S9LHU$Mm zfnJSvO+JIu!FA_ZT62#_oDwAh+isF&xagZjWRocrefV3+C-GVtc)DbZ6!GA~E%KV` zvWFBUZMxZ{vp#p~E~qGD?%3xD5P}wwh8S7(l2d<6d4{YTuO@)+OuUWnd+uus?+%7w~_Vrst+YxHa;|cQ{aiokJ z1t&YBf8MS;8XA{NPD9wZDRP&S3-c|!$)NT57yIRKeo2troqTEU7@CRrINQ3@KcQKv zJRr46!susE`MShwYr@V3b`z`e(j1jB%wv!K8QW6_+?~?z_^QLmPDfzGSq0jRH}4$q zpzx!|LiaYCH$9$$4 zlY6DWz;s|9QIQspy`CJRXcd^qkWf`|mB@4QFlR{6xsarcEn?c&oQNa~Q$`l&9#`rf z3+cg5S*07&H_~}va~a5>V__ALqQ56-6&PYZuXN97gRSQhEX-g@8rDoa3R!s^O=Ros69iAIjN<*6RW{~JVjT%4 zjF^Zqh^J&*y?lD;1jaHyFz{XK)aBJP#47n2jc$z(Asj;;3=I8GeItZkKPp(clz2(G zxklAC*VZ8yD=F3XA&XHdS!vbTn_Bb}*STXmb?WKDeT8XhsJCMCbMAqzo}PL&;7U>D z4fQ`3=i}p}i*W&P&rU7{W>7CI6>r6>fn86>*f_;%bju|w1BT5rF*7q)HmwtA_BRT2 z`Vbd z1gL`WN(ziC5t)IZ(s~hxM3OU=YliT%nr#{$BI6mbpy>0_7%7SEc{03QUAZ|lGVMwh zdvNYTb!~lfuhj`7wd5!9wfCBb-Eq;UB`tG-oba0UNDo4z99ULXE5Y#1xKwp^R0 z+^hjrbeFV;F!={FYdiRZ?ImrbHVOzK*AEcbA9!RFZn)krAD!jIEB%C>oVD~s8S(`iV~q#(;r#5INGzRO3(3o7TBg^Yiln*H|xttd(cJD|FV3R zw>cxuc)XjI@J`nsiX(EnnWv?zLjtS8%+YWlNNbbBkohuDd*m#Y(<&EQVuR8ZB)ik3oelmwia*1CvYORcewkSk1@H`bHHX}L|&!%8H|IM7ht(RpaXsg5UE5+{BND|JpEj+|X~Qe<`AR&KJ;SFWy6-JayT2OnXVtOo_$g@=BS zUI{-hDzC_NY7BPgHt5EYpE;8Ntf_Bbzl!tl@Ia49K~mBSI!&lkiNeRoq6ZGi%OrC? zZoS_bsq@cL<{8BWemU*3y=qBMPnUd8*Ue|^{+n*0jE2Hje%=l9Qa8jMUyQiC7d?EI zy#`#V@ztw_KQuTrR)lyv&)M)#yn^GNjA62>Qg_$Q9Ll!VEDpQC2$$X()jguFctXsJ zW?Y6r&u1Ap`r&cp&IGH^R+h}JE{BP6-c?+bt8*mp=6*idR(B9^8mp^ZuW2VptvApr zRuf!QiPd61+!;F8CM~b8BY}~-kF)*z?uQaiSqCM|7R{2_~ zk+N5&>?`$@TC-y0cGjv!dSSkr!tocWdR3s5Hn!RGkz}MuhIMGLKmKru__vIFzt6Fx zDRopr))(j3SVXqyC2MrHtbT9p@a8;1u~;lj`G?lJh(?N4$U|<^js-c&>6T)|$=*fH4`Yt{P`&BjFhA0- z9l`(gMOsuzZ|6nKM@`NQA>--aXbdLn(+Xp4{eoP%qAtOft;nPgU^Ao#AT$mbHIdyD z2sNnH4Cc1mxS@#&JZex^%kTCxe(2qWfV;e+!KpDsivxjgr=tzhCZtE8A$XOLZ}G_3 zbGNzZu5sVL3B3qY=Iq1PV6g|{=@>Y5A(D%Bf}ESX-}$5I6)qBL(G%!f^9Ca-p>L8sF zXZyR81ONZ!*gssx)4v6qAH380D=PBIl|Mn(ig^wN_1{mrbK%$lSc>f)0#XglZ-mGY z8s|CPU9gy714wB+z#m}hI|>q_QKKLLS+$NsA~-!Cm}gcJANDZuzxgFtxYr^>?;RR| z3*qP%XwgFO2*peB$Kz+`?k@bIrN4@C4LsA zygUm25MrDNCe;&u`@jD%{q{m&!>R}dciu!J0Fi$L+E3|_MuUOu4e5BK#3_SD_d`&qoXsl z`X_V=4PjOyGM&cPLNy9U!A=m{%U&4pgd>Rynz2gI0Bo=@g_9qPe9SW%$FYkjZZ$tY zZ%16j+!+I*{O?yU`Xs-FVhl_zFy^(v0w!LLtiA&491L{P0P&r#+Qr3%oQle+GGAX0m1++d&I`LjTv-W*+aDU9mVuQO6`;n?CQQE)kA;!jbXepO zf?1X^ps-5q-JFGiRTsb$1SvV3=)svsm-~0~o$h-r5~cF{Na0sSgGu2cWkIRK04+#a zC}T^9*Fm`qpUGm!f~uhm%|;l;>7X&che`e4w_Q=yoB&uVuArRc_3PJ-zQ2!#;imZP z>}$*8EfP4?k_8HEXQrPcI6z>a9TF~}3uypHdgP#l8Ptkc1tTN;7O`wQ85*q%i;IfT zJcgt=h+ZtrbaX?JONZTU{`G0fZ=vrtQw1Rmnd%U*7NkjK=rh9*cm@c!Vw*uS(!I7- zaK%bpJxt4Cm>g9!Abh}$i3hhs+>XV-#KZ*6v36M<4uM8JG_Q9ebtTYFSS>v(;QqPd zK2LaE8)?jyP8-AU9GII`WcmB}vz;$i2HqOB0XlU?GWMO(&KI3)9g{oD0{LQRRvT&m zlU$Jp?bJC(myT7B-p2fBgO%m)TM@H=K#L77!|Jc#*dt#`)IU>W7LFcX3jc0zoATGs zV;_#$`9h_;u3~2`<}>#pj_i}l z|DWsJ`)2~~F~9O|kL&}*T&VcHaQ_;s}Y}Kl8BO ziT?KsdiDf0A2@#@>E5}37V&>C*#G^!eODWyXYv2#XyqyKNo)Z z?7T}V$OtwE(o;LI4|RFM)2MfiJ|9UM%st&OAJx4!jxC`Pb>Ox`H#n^8$hQM1=ZRQ9 zVO*2FcFE50y`7(Z=ZpWbgLkq|kMDDY_z)Ue1TjAZjz>#A5CPECS{P?5icAnWH@}b! zHHMXUmJPV4GiU|r^T~0Kx7?*?S=}%$O6rTrc3l$>I=Y}b`PxsT+K!_mHp!GR@Jtj# zY?^%Uo?fHIv{FeWc6t9E%p67_XDd!N{iTw)!i#$~g~{SX^@XH68`@nFeGanyQPaU@ z)FK4h?kkl!t&7ZFKl|UG^sT6QYZ@%YtV*CdN5`1)a6jQVhU~r8vOtnsZ2E%5eCz9c zrjq3v>*tlXpvm8%esS%6&m>=Ub5?rtLZJV7-Js%)<*%1ETfdd%n)vYWyE3b92K%g8cS* zU5bwOR!1yt&1$*s)7DZ2R%K=_tr^nv396MBzn_XdnoWjTI6H2I5tDf>ix}suuqgm^ zHWylvj1YQ8924L)K&*lGuyyxU`7YTp;(meFsrUpI`3{MLs?^OBcIBG~R@GM3)vRgz6h&r45O^@bzp2Jo(6bSVF=wow6-@kd zmNfb6(I;>T?j^bkMMo&Qsd~TktNv8ipD>+%uQ29MdQYX|O#O6Y!?VrVE((lkI_%dCtu0l-S4guY6Me!GO^c54$N>+VCJHE#30&^sM$e##S!xG(F;=p12JHd>drd%ghYf^iBL(?eA7=!ex9p>0M|M*Cy&r{yz+79R~;_H z@>f21qKQz!TH$b^RrLXN&?#Ly@)wu>iEm2a_h z)oQ25-&0wC9IxN%x$Z%c-rb(k|MB!H^hky=70y21(WSPPy)EAGPL+?3&;~1@-#A zEb*MFlx2B=rI{BU{HM<*k9}EGcXIBMIkkW>&pU8!CPBe1xG!Gi;RUo*?Jiu}XgG2(ez+5((TyR152w>$WeBuR?En9XJ6p%T@d zIyPjr_QS70vZ1;$fLtBD1U>pVMm66WH>vDxOQV!h!EEWGLDDnUKQTvM^^^K_WkJ{% z&BuUJ4H7L*D6Rhd*|V|F&x=)mTLho6+xEIP%BF);Miq>ZNPSLI%lfJSbDf!yI|HQb zcu3%*Y8XmYNjbT)>7SU>)X%w|xB>wNMfCyffSQ#AaNTc$Lp6BP&FyFtxO?C~_pgkB zhR_KvIRG=*XOw>>VG&@}2$8*~Fs>edS@%Hf^?2i;=I4XF+MQOj#C}U_I|YISZDG9r ztyBS)^@l49EFn(8a9ldB7>uoI!Y<&qMsrWWvw8`|HmhIiv^)7q2a=|G&MU3-TNb2; z%IIl+r)AXF)}P)mC~|dSlP)XCQv2Za=tvwHhQ`EJT!X-(CZ-`bof{ac+ZG()q*P|@ z6P|08YuCJTz0aWXN5$Ge=u7;7X0K)41rM6EbEyG`^>dQCPEWhoB)d*5zr2CJ#)dxe z?j0T9@wUkOSX9ltUDNU(8v>z$%J%&_J- z+ptP00HYGqn#7g84i4S64%cm)$@jqb+bo8L$>#d1cRvmb78KH)y+)O=@Hl=}EFUuy z?Gzv!^1rM5CpCV&snbK}5SR|1083~F3@t_}2$iwaCg&A zeY<+ywDZ~EJb%%0w$XBG1W`zlD=^}9+NqRF!-}!bC`;S!y4pw?H88p{ZH>h{&yu(w z4A!38XtSc-Iv{e(XcPMi{7i2DeNkvw^kKSy85Y-z5eYwoYt3#s`Y}f_c6@TxW zoF&cqMLgout&4rHl)ispTT5Nsw|{+9Xl2jGo_yipj){jxH+&bo4G**Q4+QClCrKr8 zFI8eEmZv8jb;rB7C||7=%qFsoj|Av%rrSi`naE*X3mxv-a~fmkqzbRanoo#XVs&^) z$AQ0Kv|~hi!9k{DHFT?J_@+$v*2`B1JoIuYIcOOf{4xGqTwH-b1|0kbf3jx4o{;TC zd<@QnY6ZP(lt^cTkKv|v6cjhV9I`^_A_N^2U>)Y08Hd~w6z4t7LVHeYK4*Ut859%` zW;{gpK>cvYB<`A0j(^6Ab&P%t?XxEH&Dds+%DnMKk<#}5qw;Gy>%8iA&PDO0n7du+S)NNh zrB|q5`LwQ#nJ=qtynseJUVn4Np>0-+$h`S}`>d{xW>Mwqy`sIO6%s~u$!T8Fui8qz zPdHjlD$(oFrD(=Wq)}74JKN}6HQu~=a~Qm?gNKeEuUkBfMn>7%<^p)hDsX*mjfID2 zCE+#LJCKPM2243lBqt&TRA}qxAWp~7u86qr&JsCv#_8Z3HvwYA){cQ4ooZmm3Zw%d z;HXiadH)SaVJ?p~-NZq%mZ1(3&Oj|HnAlR*@|asIpjt@g&F|wYBHGJf6%-nw7R8z8$MQBU%$;!cx9*q5xxJ>De1q=4b*zT@^ zeD(zT?l@&xwFDFDR|4q^6;c{Ap`j7+gCVlRNp^x;*^&jTBNhiS`2y{gnU`1dhu+$Z z4^AZ?O`gxQ_PeZ@OZw%!s=I#Zy>`mggZNB4K9?MQ-O?{dF>WE&Y+~?DLCfIlD$b7$ zKbg+Z13w3e8Ct$76lY!g_RcUK@aXsT{aZ=kgwn&=r|z$V{0?lfUSQS$PxXc}1%kMT<48=QiaGaR!Y+N&9 z`DJGA^HmdwWx`#~mIwKq1p1cLV031OT7fpr9`~XL+1%A>Z;J-}HVU!}J9Jc>ztEFs z+!0iD8O5EscYh4V^{XosyI?8 zg`v3;nsAx=PrihN;YqdXV_ZpUtB##6GmUIL=v_ItWd3}u(`KcuGSjG(-1;InF_^ER-8coL zai~>7%{WOuYU_q(qUJe)mB@vblC5}bEULIBcm3^@L|O!4PkN@YjeVNT|J#-$r=`1+;Lhn zT$Vm3w{!XvlWEi8@hB_tCnbx^+gpzV^%L}2oI8Y9+li_l24p4Wsv7&bJ#Fawz4ksO znK2#32>!O0CL?LnidbFpwn%WV*W^$JV_?whG%lG8MqgM7B}i#}@tp;QCQ~Lu>YZ#7 zPF)Xeae=YLH*V8UN0dlKDi;qe`-ia86eb;AA80ku3269^eAx6LqitL1zi zB@$D5JnD5oL~Lo7+vfgmzfK={lWH-U-k(5?Am|C@5W62l6gta3CS|Amty@{Q+j+UJ zG>VP*c60q<-PBJ21axFfH#revZrselci+-Hb38}<>>fdkp6RyARsJp2^?C_|hJs5I zwB2GAQ*W21dnX^YR8sdTNiT?%WIfKMGqm^a1*4X~EW_J?@L2By+zzt3-J98CW?8kG z=e?3OmnE)kh4{@nL}3K)mU`b!sOKfRp`RF3)D#vhZ>jLo#aG&4!XjG~;$AWC7#TiZw{kq($BG7=Mt z${2@f2rq>}LeZ-O)4{nM#vn09QY43&N+q&IjHtffUFOr?dwpxK@B7yJtTk&*tM`3- z-sk!M@Bek*_jO%&xzn_=X^)euU3aRz`_E0W`tfSe_=%x2Uw-s7X7k2XCYt2gDwWg7 zklng@bLY$$rc@26C^&R0s}n(3ocP3klZQT5jgD0QpV4l2Ys50ocV{hTVX`#v3zCwX z338}O(n5!<`Skc-l(rj2ZZ&0UNBYVSjP}#Il(5Va<>Zr|yOWa>K6bk(Ukd$2%qwt^ zmcbg5k5Eg`s_Zn@c7*0e{5N?UuVubEejAMA>Q$EY?Ch4m=IU1aKhX@(pIg7V_42E6 zXXk%6^ibuIJm|yB9}t7Md{>%aqU;N z@rVgLX%Ox@p!m)zqmv<~uIYAjMyCu-{pqgZ`t+UOKWN`hJ=6EVHx=(hXctFz?NmDH zlIKs`Uc{_R-}k}!(VFwWUp)rwQM(kSe0yoe#)Rxud%o{xV>xB`FR}LaMdw*S{-GujN<~OdIS6A}zbm$!$MQ3Gaos`X+(^rkL zu2I_`S@ggsApTfygVgT_pBu2yduzjhn4`mvJy;&R$Mww5&ZV>m@Ym0|W+H8?Jcar;=n-1N! z_OA7OxZ|_5(+)KY7EHXDIne0%5~t;1MhD1cVup@$C@kDEroMN1$+iYN^}xsXbbbgA zth9d6G2-~VzBpb<+4kR5I0yV>MZcdCsFc1zSsBcS>kZi*icDqgk@jtb8U3$}*%%Y+ zh*uj=2MpTQm{pWjxW`V1BYqY=f&CL7*Ey|0&NeY5ue_`E*Gv$-yLF7s0Of!4~3DVh7rnhCGtZfBR2q~HYkR$34$C*%vF%4*WI$w)OZg%t`SxX~qZma9mw#Q(1>j59|v zx|utd?uC;o1`y&*2@^QTZs|LY1NDkp*O6^MqeC>S5ljE(4v`GblMWG8=01NfXZn+4 zK9%}F8-`BBB{F{JUYIS)iwSn`+&=cZ#qSMSPR1t;mprx9RgQeNBIx~vDzS5uIW#g% znnK&=tgI~TEEkVgm%pv&MQD%TH7;)3fBU|XZH;WN zPRj^>du@uM*XB~o_C-H^%9C+^Q(IJa6=hnBpW3coW=-kyA1bD{O>M81mt(tr_`kHK z^vCX^Io9%3%@?ho^0sEpm91z+Y4y)5bnLPv{GY#l)3+GeqQ&;^86SD3ep7YcHpl+C zRi-7?>1pj|q~8p9)FncD+?X*dM~)ix*^Ep2oUQJ|43#5u)=XEbwb%G6kwee*S6*nH z8=xqTH@L0IT?o1GYwYdq)!Vjh6C%;Ab3@wP3p?fBCN6Qhp;n-cPcI7NE85fP`}4vT zrAv#xlcJ1m;m)Zj5hl775w1gq4DrKL2!`UpU^6AqPXD}8tfQZ+=zaa?r6_R+X}r!z z&s_fYbCpv?Eh@--R*O5zUu9NYi`TZ~SO0gvj976qAm(PkpD7OZ$TgVC@8i0n(j8RH1>ME?b`l zlK-Q>EBCy)Y9->#x2DC*LM#6M+fULm5~@5ONJ#kNz=@hjlbqdjD>@-6y$7hCUWRVn$y!k;vN>8eKnE^MQP{O97WS-OX7ZdjG z-48u6r21%i#?n{8b8a7V6$2t5cr6pkFk8M@gt3kdA1BM@{RQ`oj|M;bfrWTI!Qo?3 zbKDJY%wlw$DZ<^QPk#PbN>V2M=XbE(B?iP<4Ruu^2%w~WM-Np63=rSVJK>E>Y4iO} zw?sc;w=c~@F?U40DSR=pOh%<}4HxZXiDUET%`5oeKa_?IaG!Xd`5FGsDm4bv^m_Mj z2G9%jH0VF=TU23J{f^ifZBwTHV!dT(!O}{VxELuZQZk{gZkOd#@BWd zH^nG2H+Mw0jX6D@v^sT?xeabprfkPebC!ATrAsbM2@$P|IID`i^qRja4ISTJn=Lu+ z01FEo{X3HUn04(sDzCQhH1uen1wVB6_4TbxI*^?f6tAqmwKjeNY!etoV(F~4jA)Zop*n|>SgFVy*ztTM&BF>1c zPkP~y3^Ibs>{V*qQ@EB7_4N};>-<=r_G8ETBpE3ycs_D7|M=)VGuy#2n+2lrTJDk5 zHT)R|1UGGf!CHpayRB&ZA%30vk^dk(ALSNhpfufoQ3$aRSYHCPX^u&R)3wrhT zJmv!2leck~Kn_h0-Th#M_3~DF=2~sqIf5InADur_s8<-=R9051(mga z6K#j-R@k<(WOqv+7HcMlNtDk9;z%t%&UJ`8Og+b^I;fM9lB8p9Nw>q)OZnqCt3U&d zcmISvD}4hV{JIN~o=-#Pi z_6`otC&TLXFiJsFq``q;a@v>Q@z7OPt<3Z!#L$JCg;rf`^b?AG`uSl?HelQ32f%c( z;0EbmK;497H6|;|I@eIkY?DY>yN&xHkuN5>{gHVk=r?UN@YkC_J7Ew5Xb(W2SjjWw zIwPz;1heHHDEo_;@7~AjL?Ykx!OQZUVi7Eod?Z%>94;{*f7|Vg?TJF4uv=#=7X-Kx zrvbDDUM!@7YT$SCHt*4H#-ONHHLmosSGMc;Df^cv<0q1PA_9xvymRNys=^l?&|z*E zv(8wgZ1~jl;Be&0Ic9~_CKqcdI>+cNiW43cKR5j1i6o!VToe)oLr=t&Uu;SV-hO9S z|A*+y(L;)VB~dmJ#R-?Alm22>=--GhY12xTK%=HT+JghO)B;97D(74ZjvPzThYufyR?$rrV{(jyAHmx7 z6N~5Xz)Jyn^@NnknJ%oZo_0D)p)46XN>@doDQ4^z7S0@d6PULv`^3(8@#h#PxUy9w z?7BA%Y@47$Zx_hGaLemGk z=5X?-t(Ah9ty?u5%$`SsMysiXx)O-}^Ws&=v6wJvu3=XVY`bzY(uzrVLh>15jkYkvnALzrOVEr?uri%9Km= zewCSpUd}A?MeE+Ro$8$Sr3%^MEaGfPeQhBvbl;9ivEdDiU;goj9}&kbN>}-+53x)A z<@D)Y>Do$mM&5Fb1J<{n{+9y{q7m7&{ji6ONRiW<&26HB6sFhK-``(^qr}aG0HV9# znb^>3OO=|hl$Ms(JbQ!daGmyU#EI?07Jfm(lE)3z@G+ihv41cc5*RU$F-Ll{OnzPV zj$A-xU2HiK{hIhK;|4&Dw)1rq-vc@42(V90dq<4aKrxZ8ay@yHg{rWFJvSVt5+kjr z9+>|opyEzK%y++tFZ!;WjoqubA=k=xhWDwwa`!s>vG&(dI%V!7-cyn*flX3Jlo!$y zk*BOSfgm+bVln4OE!(pP3w%sT?SbGvQ;u{&+0A+TL_H{Ckq2xsHbZ2M#D!SQH9j^H>%T%ytT z(;Vz!W;PayzZ-o(m#mPlC1{EAb^DGTwM>OF!!Lzz8V&3TYj`=c9>ct}%(5h7WyL5T zpSW0Ll1;=dJT?S>t?g{%B~gN-NZe;Y6b}4HZUD|}OIq+^ED%M20}rhSPBm=Cf)CzT zzRDF}f0;GHCBpeK5?0Aj7LLJyFISUFVz(AYEoe5eQhZNj{>1Pk(arQ# zrIrz&Y5+1UlMRd!*bPdOleQ3ZrVlg_>F>ASwpJBR9Bi@eG7c5ju|1xjuN86TjNibE z=g-^8NMvr5c+Hc~B?2(%-4>#zE8_-9Z@(FQO=&{1aN4|vZ0ypP8lQ&9vJe?6hd5H)!xVQx42I#YZO4SQZCNPDYU%FbZA+Kp`~$ zG6BL)Tt8CT;!#WV6-ohC+KxAFkBz-McBl+;W+Pv*ezCn>o8?mLBWv>JM4f!lXBh;Z zbC3O$w$Yt(=EL$QNT~{E?(jYmc-pBuQ3;l%ET@1ub9i)mL-`ma>+6QsB{CmJGtq1D z=p?t4JnTYoJV<54#6^NMgu&?L%e9(XHDL}Oa#ubr=wF^l47Ap8E%p(QC=ae>GB2UGl(nb2aWLk=j9mGWlCc5SYw zdU|l3Zc+GsbQ!$-X22kiVNHT(v-buMuhwtZM(B!&(+fEZ*_FL}+O<&{tIx%rlA#E= z6gPXuHA=_$Dz>PS} zZmH`;uIZGHO!c7zU&{XekQF7AcZ7cV08blVtEPb@k?*`A!bN=J*1QAkn)dU%%2yst zku#C(k*U{dK?9Y*(65>Z!jWyEx$$QM?@mAJsEqa`>=S2FjG8FO#V`Ibg>Qaob26^j z>b4l}ja^|2vMFEZfBs9KcLDgMypBwL${h9MYD~O<1R#4?8W^)zbydk1<*bs#zv-W` zeiO?rLfaADHb>q-fQ1Z)>AU%~&p{`b0`U*v?5TnoP6YEztrBZ1oaJi*j{D|*#Iwbj zq%kUXY5K=)fyH|vwB5aG-CW=aeAWELb=4hx{Hs;Va)7@>E7cVtOeSzAjsN#VS2VSU$6 zKh2L1=6>i^v-C) zn9<^hRjt%dIFy*CbSdaIXBxa?iK=YrbL%?qtTHATFe-a|YRBI+8jU0sOyB4!c1}sg zVSh{o)M>zgvYByC=vB)zI3Q^ORydm|OaWH+v71y*00f>>KG@38=GMvQ((}2!$(!kx4#7xpQ?5k$8B=;w|{KCKJFeQ zZSnl}0YFLS`^ZXCNh(ep07+%C14K7WQELL1y@139c%)&#*{rL(SID2F=#oS}qw`Q* z-+Yf3_utZ3cUgxYBD6=cIWnhU(YEOHL8NX*?} zrB72udNXT-fFN36*x&?^1d4w9IjtN2c-k!ISmfBhi7mb8l!PAdu$KwEGwA#a#78Sl z7xz&?*caGTR?L-od3ixB85tGKha7r+yP&|0Qzd2y`(_{C<^t8nAe=W`^J?oa&Q0__ z*QTAPIvY%ZohwkXuVrDVLFf@5a{0UJZPe)sb8C2xG)+%rKgN)=*bhvhI!D z$4{>Apr^OM<4SZqleeUvwa)svdu*YM9ib>8?y#|s*tXA}Eno%bT)XCsXv&QxN8H@U zA2jdQ?f#*X=Jj-B=gl{i=mN=DVZ-^M8X(9ojG9d%7Vez^fVU{LCW8*z-l^S6tK7eJ zyVm=B^bbGmrx591Q<`%$HM-ZNyqblOKRmK$hE?1;e4yd!&PG3aBOvKmC{%u1ukPUW z4fU^P<1loz=;fBs;ghmBXfWXM*{@C4(Ut7L7UW-GmM}zQ!_%caoLDW)k ztfXKjIu}F=n@W!CXq`c1q+FSRlU8rLm+)=(!neH}e2L()0okq?KapEk{QR+ZMz5`2 zSm+oHUovFk2?_%0tgQNRj~ACr4i0`kuF3`WCj|*P;$jP{t|#pSc_d+yd^Gk^b@f*o zc9ZAYjuvACfN*HllB3^!ex>;l+zT7s)UAjhg-RgL^18Nf{FMoJ(tI5}Pb``sqZkn) z&|1=&@7&??;*OkJ zq8K5D$x$pBpa421N7Npg9!&fJflIrC#IJNqrVTYU4x4e9x|?%G(jWXs|qMPW~Zg`i4y$t)hQ z^z!6KDTK1hBcq}Uh~~Swa~@vq&G$xWMmLRf8Oe1t#{B@+Yr|ibcDTIV(wUQ>0BJwv zqt#17@8b;#^1v?D(?e`%$ka^Go2b$*8C;=YO4JruD7{ZMz2$R{lOHnE_bQe{;;$v? z*wm?0>niu{bOB8ai#$H}ZuYSyFVn=d2!~OJ#iVpH*A_F5N=(z-IEPSs%q>gY_%U`& z!owgmxruZGWsp_XTBBiZoG^vrg~KzII%Le!vhP3axfj5Y3%LW0h!fd-{^7aah~va; zn>FlA_>&=&tV}{gm{@5G_0Z0SB$0m+uQr^1{i1#Q|LT3=7^pNys5iGt`M+YnH=pyr gK#l*u?^HEv-^r=lTT*|a>1L=#fAM+hNS|;16Q}r#F8}}l literal 0 HcmV?d00001 diff --git a/timing/timing_individual_function.py b/timing/timing_individual_function.py index 40a798ad..3f57055e 100644 --- a/timing/timing_individual_function.py +++ b/timing/timing_individual_function.py @@ -9,7 +9,7 @@ heatmapDF = pd.DataFrame() number_of_nodes_list = [10, 50, 100, 200, 400] pList = [1, 0.8, 0.6, 0.4, 0.2] # List of edge probabilities -currFun = nxp.closeness_centrality +currFun = nxp.degree_centrality for p in pList: # Loop through edge probabilities for num in number_of_nodes_list: # Loop through number of nodes From 9646cbf37bda8f554d213aa0120c5b8585cc034a Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 21:40:19 +0000 Subject: [PATCH 11/20] Updated .pre-commit-config.yaml --- .pre-commit-config.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22681389..0f909d39 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,8 +10,7 @@ repos: rev: v0.6.7 hooks: - id: ruff - args: - - --fix + args: ['--fix', '--fix-only'] - id: ruff-format - repo: local hooks: From 72caca4e8e720e2d4286be742d069646189fa59b Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Mon, 24 Mar 2025 21:43:43 +0000 Subject: [PATCH 12/20] Fix linting issues with ruff --- _nx_parallel/__init__.py | 16 ++++++++++++- .../algorithms/centrality/betweenness.py | 10 ++++---- .../algorithms/centrality/closeness.py | 24 +++++++++---------- nx_parallel/algorithms/centrality/degree.py | 3 +-- .../tests/test_closeness_centrality.py | 22 +++++++++++------ .../tests/test_degree_centrality.py | 5 +++- timing/timing_all_functions.py | 14 +++++++---- 7 files changed, 61 insertions(+), 33 deletions(-) diff --git a/_nx_parallel/__init__.py b/_nx_parallel/__init__.py index 4c5e4352..8c0f9d80 100644 --- a/_nx_parallel/__init__.py +++ b/_nx_parallel/__init__.py @@ -90,6 +90,13 @@ def get_info(): 'get_chunks : str, function (default = "chunks")': "A function that takes in a list of all the nodes as input and returns an iterable `node_chunks`. The default chunking is done by slicing the `nodes` into `n_jobs` number of chunks." }, }, + "closeness_centrality": { + "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/closeness.py#L9", + "additional_docs": "The parallel computation is implemented by dividing the nodes into chunks and computing closeness centrality for each chunk concurrently.", + "additional_parameters": { + "G : graph": 'A NetworkX graph u : node, optional Return only the value for node u distance : string or function, optional The edge attribute to use as distance when computing shortest paths, or a user-defined distance function. wf_improved : bool, optional If True, use the improved formula for closeness centrality. get_chunks : str, function (default = "chunks") A function that takes in a list of all the nodes as input and returns an iterable `node_chunks`. The default chunking is done by slicing the `nodes` into `n_jobs` number of chunks.' + }, + }, "closeness_vitality": { "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/vitality.py#L10", "additional_docs": "The parallel computation is implemented only when the node is not specified. The closeness vitality for each node is computed concurrently.", @@ -97,8 +104,15 @@ def get_info(): 'get_chunks : str, function (default = "chunks")': "A function that takes in a list of all the nodes as input and returns an iterable `node_chunks`. The default chunking is done by slicing the `nodes` into `n_jobs` number of chunks." }, }, + "degree_centrality": { + "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/degree.py#L8", + "additional_docs": "Parallel computation of degree centrality. Divides nodes into chunks and computes degree centrality for each chunk concurrently.", + "additional_parameters": { + 'get_chunks : str, function (default = "chunks")': "A function that takes in a list of all the nodes as input and returns an iterable `node_chunks`. The default chunking is done by slicing the `nodes` into `n_jobs` number of chunks." + }, + }, "edge_betweenness_centrality": { - "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/betweenness.py#L96", + "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/betweenness.py#L104", "additional_docs": "The parallel computation is implemented by dividing the nodes into chunks and computing edge betweenness centrality for each chunk concurrently.", "additional_parameters": { 'get_chunks : str, function (default = "chunks")': "A function that takes in a list of all the nodes as input and returns an iterable `node_chunks`. The default chunking is done by slicing the `nodes` into `n_jobs` number of chunks." diff --git a/nx_parallel/algorithms/centrality/betweenness.py b/nx_parallel/algorithms/centrality/betweenness.py index e0d97898..65d58b46 100644 --- a/nx_parallel/algorithms/centrality/betweenness.py +++ b/nx_parallel/algorithms/centrality/betweenness.py @@ -52,20 +52,20 @@ def betweenness_centrality( node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) else: node_chunks = get_chunks(nodes) - + # Handle empty chunks - if not node_chunks: + if not node_chunks: return {} - + bt_cs = Parallel()( delayed(_betweenness_centrality_node_subset)(G, chunk, weight, endpoints) for chunk in node_chunks ) # Handle empty results - if not bt_cs: + if not bt_cs: return {} - + # Reducing partial solution bt_c = bt_cs[0] for bt in bt_cs[1:]: diff --git a/nx_parallel/algorithms/centrality/closeness.py b/nx_parallel/algorithms/centrality/closeness.py index 81eee8e1..7484fe74 100644 --- a/nx_parallel/algorithms/centrality/closeness.py +++ b/nx_parallel/algorithms/centrality/closeness.py @@ -45,7 +45,7 @@ def closeness_centrality( if u is not None: result = _closeness_centrality_node_subset(G, nodes, distance, wf_improved) return result[u] - + n_jobs = nxp.get_n_jobs() # Validate get_chunks - the chunk parameter is only used for parallel execution @@ -66,9 +66,7 @@ def closeness_centrality( return {} cc_subs = Parallel()( - delayed(_closeness_centrality_node_subset)( - G, chunk, distance, wf_improved - ) + delayed(_closeness_centrality_node_subset)(G, chunk, distance, wf_improved) for chunk in node_chunks ) @@ -82,30 +80,30 @@ def closeness_centrality( def _closeness_centrality_node_subset(G, nodes, distance=None, wf_improved=True): """ Compute closeness centrality for a subset of nodes. - + Implemented to match NetworkX's implementation exactly. """ # Create a copy of the graph to avoid modifying the original # Handle directed graphs by reversing (matches NetworkX implementation) if G.is_directed(): G = G.reverse() # create a reversed graph view - + closeness_dict = {} - + for n in nodes: # Using the exact NetworkX path calculation logic if distance is not None: # Use Dijkstra for weighted graphs sp = nx.single_source_dijkstra_path_length(G, n, weight=distance) else: - # Use BFS for unweighted graphs + # Use BFS for unweighted graphs sp = nx.single_source_shortest_path_length(G, n) - + # Sum of shortest paths exactly as NetworkX does it totsp = sum(sp.values()) len_G = len(G) _closeness_centrality = 0.0 - + # Use the exact NetworkX formula and conditions if totsp > 0.0 and len_G > 1: _closeness_centrality = (len(sp) - 1.0) / totsp @@ -113,7 +111,7 @@ def _closeness_centrality_node_subset(G, nodes, distance=None, wf_improved=True) if wf_improved: s = (len(sp) - 1.0) / (len_G - 1) _closeness_centrality *= s - + closeness_dict[n] = _closeness_centrality - - return closeness_dict \ No newline at end of file + + return closeness_dict diff --git a/nx_parallel/algorithms/centrality/degree.py b/nx_parallel/algorithms/centrality/degree.py index d20ac9b1..9c23ecea 100644 --- a/nx_parallel/algorithms/centrality/degree.py +++ b/nx_parallel/algorithms/centrality/degree.py @@ -1,6 +1,5 @@ from joblib import Parallel, delayed import nx_parallel as nxp -import networkx as nx __all__ = ["degree_centrality"] @@ -61,4 +60,4 @@ def _degree_centrality_node_subset(G, nodes): for node in nodes: part_dc[node] = G.degree[node] / (n - 1) - return part_dc \ No newline at end of file + return part_dc diff --git a/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py b/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py index 8dce0e19..b2972b33 100644 --- a/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py +++ b/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py @@ -3,6 +3,7 @@ import math import pytest + def test_betweenness_centrality_get_chunks(): def get_chunk(nodes): num_chunks = nxp.get_n_jobs() @@ -49,11 +50,11 @@ def test_betweenness_centrality_weighted_graph(): """Test betweenness centrality on a weighted graph.""" G = nx.fast_gnp_random_graph(100, 0.1, directed=False) for u, v in G.edges: - G[u][v]['weight'] = 1.0 # Assign uniform weights + G[u][v]["weight"] = 1.0 # Assign uniform weights H = nxp.ParallelGraph(G) - par_bc = nxp.betweenness_centrality(H, weight='weight') - expected_bc = nx.betweenness_centrality(G, weight='weight') + par_bc = nxp.betweenness_centrality(H, weight="weight") + expected_bc = nx.betweenness_centrality(G, weight="weight") for node in G.nodes: assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) @@ -78,7 +79,9 @@ def test_betweenness_centrality_empty_graph(): # Check if the underlying graph is empty before calling the function if len(H.graph_object) == 0: # Use the underlying graph's length - assert nxp.betweenness_centrality(H) == {}, "Expected an empty dictionary for an empty graph" + assert ( + nxp.betweenness_centrality(H) == {} + ), "Expected an empty dictionary for an empty graph" else: pytest.fail("Graph is not empty, but it should be.") @@ -104,7 +107,9 @@ def test_betweenness_centrality_large_graph(): expected_bc = nx.betweenness_centrality(G) for node in G.nodes: - assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-6) # Larger tolerance for large graphs + assert math.isclose( + par_bc[node], expected_bc[node], abs_tol=1e-6 + ) # Larger tolerance for large graphs def test_betweenness_centrality_multigraph(): @@ -134,6 +139,7 @@ def test_closeness_centrality_default_chunks(): def test_closeness_centrality_custom_chunks(): """Test closeness centrality with a custom chunking function.""" + def custom_chunking(nodes): # Example custom chunking: split nodes into two equal parts mid = len(nodes) // 2 @@ -154,7 +160,9 @@ def test_closeness_centrality_empty_graph(): G = nx.Graph() # An empty graph H = nxp.ParallelGraph(G) - assert nxp.closeness_centrality(H, get_chunks="chunks") == {}, "Expected an empty dictionary for an empty graph" + assert ( + nxp.closeness_centrality(H, get_chunks="chunks") == {} + ), "Expected an empty dictionary for an empty graph" def test_closeness_centrality_single_node(): @@ -178,4 +186,4 @@ def test_closeness_centrality_large_graph(): expected_cc = nx.closeness_centrality(G) for node in G.nodes: - assert pytest.approx(par_cc[node], rel=1e-6) == expected_cc[node] \ No newline at end of file + assert pytest.approx(par_cc[node], rel=1e-6) == expected_cc[node] diff --git a/nx_parallel/algorithms/centrality/tests/test_degree_centrality.py b/nx_parallel/algorithms/centrality/tests/test_degree_centrality.py index 931666fe..911847f2 100644 --- a/nx_parallel/algorithms/centrality/tests/test_degree_centrality.py +++ b/nx_parallel/algorithms/centrality/tests/test_degree_centrality.py @@ -21,6 +21,7 @@ def test_degree_centrality_default_chunks(): def test_degree_centrality_custom_chunks(): """Test degree centrality with custom chunking.""" + def get_chunk(nodes): num_chunks = nxp.get_n_jobs() chunks = [[] for _ in range(num_chunks)] @@ -132,4 +133,6 @@ def test_degree_centrality_large_graph(): expected_dc = nx.degree_centrality(G) for node in G.nodes: - assert math.isclose(par_dc[node], expected_dc[node], abs_tol=1e-6) # Larger tolerance for large graphs \ No newline at end of file + assert math.isclose( + par_dc[node], expected_dc[node], abs_tol=1e-6 + ) # Larger tolerance for large graphs diff --git a/timing/timing_all_functions.py b/timing/timing_all_functions.py index a04c1436..1d19efd9 100644 --- a/timing/timing_all_functions.py +++ b/timing/timing_all_functions.py @@ -56,15 +56,21 @@ G = nx.tournament.random_tournament(num) H = nx_parallel.ParallelGraph(G) t1 = time.time() - c = nx.tournament.is_reachable(H, 0, num - 1) # Provide source (0) and target (num - 1) + c = nx.tournament.is_reachable( + H, 0, num - 1 + ) # Provide source (0) and target (num - 1) t2 = time.time() parallelTime = t2 - t1 t1 = time.time() - c = nx.tournament.is_reachable(G, 0, num - 1) # Provide source (0) and target (num - 1) + c = nx.tournament.is_reachable( + G, 0, num - 1 + ) # Provide source (0) and target (num - 1) t2 = time.time() stdTime = t2 - t1 timesFaster = stdTime / parallelTime - heatmapDF.at[j, len(function_list)] = timesFaster # Add this as a new row in the heatmap + heatmapDF.at[j, len(function_list)] = ( + timesFaster # Add this as a new row in the heatmap + ) print("Finished nx.tournament.is_reachable") # plotting the heatmap with numbers and a green color scheme @@ -81,7 +87,7 @@ ] # Ensure the number of labels matches the number of rows in heatmapDF -hm.set_yticklabels(labels[:len(heatmapDF.columns)]) +hm.set_yticklabels(labels[: len(heatmapDF.columns)]) # Adding x-axis labels hm.set_xticklabels(number_of_nodes_list) From faed653d76dd0059486a410d2e5299b69a41590f Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Tue, 25 Mar 2025 20:19:59 +0000 Subject: [PATCH 13/20] Removed implementation of closeness_centrality from this PR --- .../algorithms/centrality/closeness.py | 117 ----------- .../tests/test_closeness_centrality.py | 189 ------------------ 2 files changed, 306 deletions(-) delete mode 100644 nx_parallel/algorithms/centrality/closeness.py delete mode 100644 nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py diff --git a/nx_parallel/algorithms/centrality/closeness.py b/nx_parallel/algorithms/centrality/closeness.py deleted file mode 100644 index 7484fe74..00000000 --- a/nx_parallel/algorithms/centrality/closeness.py +++ /dev/null @@ -1,117 +0,0 @@ -from joblib import Parallel, delayed -import nx_parallel as nxp -import networkx as nx - -__all__ = ["closeness_centrality"] - - -@nxp._configure_if_nx_active() -def closeness_centrality( - G, u=None, distance=None, wf_improved=True, get_chunks="chunks" -): - """ - The parallel computation is implemented by dividing the nodes into chunks - and computing closeness centrality for each chunk concurrently. - - Parameters - ---------- - G : graph - A NetworkX graph - u : node, optional - Return only the value for node u - distance : string or function, optional - The edge attribute to use as distance when computing shortest paths, - or a user-defined distance function. - wf_improved : bool, optional - If True, use the improved formula for closeness centrality. - get_chunks : str, function (default = "chunks") - A function that takes in a list of all the nodes as input and returns an - iterable `node_chunks`. The default chunking is done by slicing the - `nodes` into `n_jobs` number of chunks. - """ - if hasattr(G, "graph_object"): - G = G.graph_object - - if len(G) == 0: # Handle empty graph - return {} - - # Handle single node case directly - if u is not None: - nodes = [u] - else: - nodes = list(G.nodes) - - # For single node case, don't use parallelization - if u is not None: - result = _closeness_centrality_node_subset(G, nodes, distance, wf_improved) - return result[u] - - n_jobs = nxp.get_n_jobs() - - # Validate get_chunks - the chunk parameter is only used for parallel execution - if not (callable(get_chunks) or get_chunks == "chunks"): - # Fallback to default chunking if get_chunks is invalid - get_chunks = "chunks" - - if get_chunks == "chunks": - node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) - else: - try: - node_chunks = get_chunks(nodes) - except: - # Fallback if get_chunks fails - node_chunks = nxp.create_iterables(G, "node", n_jobs, nodes) - - if not node_chunks: # Handle empty chunks - return {} - - cc_subs = Parallel()( - delayed(_closeness_centrality_node_subset)(G, chunk, distance, wf_improved) - for chunk in node_chunks - ) - - closeness_centrality_dict = {} - for cc in cc_subs: - closeness_centrality_dict.update(cc) - - return closeness_centrality_dict - - -def _closeness_centrality_node_subset(G, nodes, distance=None, wf_improved=True): - """ - Compute closeness centrality for a subset of nodes. - - Implemented to match NetworkX's implementation exactly. - """ - # Create a copy of the graph to avoid modifying the original - # Handle directed graphs by reversing (matches NetworkX implementation) - if G.is_directed(): - G = G.reverse() # create a reversed graph view - - closeness_dict = {} - - for n in nodes: - # Using the exact NetworkX path calculation logic - if distance is not None: - # Use Dijkstra for weighted graphs - sp = nx.single_source_dijkstra_path_length(G, n, weight=distance) - else: - # Use BFS for unweighted graphs - sp = nx.single_source_shortest_path_length(G, n) - - # Sum of shortest paths exactly as NetworkX does it - totsp = sum(sp.values()) - len_G = len(G) - _closeness_centrality = 0.0 - - # Use the exact NetworkX formula and conditions - if totsp > 0.0 and len_G > 1: - _closeness_centrality = (len(sp) - 1.0) / totsp - # Use the exact normalization formula from NetworkX - if wf_improved: - s = (len(sp) - 1.0) / (len_G - 1) - _closeness_centrality *= s - - closeness_dict[n] = _closeness_centrality - - return closeness_dict diff --git a/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py b/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py deleted file mode 100644 index b2972b33..00000000 --- a/nx_parallel/algorithms/centrality/tests/test_closeness_centrality.py +++ /dev/null @@ -1,189 +0,0 @@ -import networkx as nx -import nx_parallel as nxp -import math -import pytest - - -def test_betweenness_centrality_get_chunks(): - def get_chunk(nodes): - num_chunks = nxp.get_n_jobs() - nodes_ebc = {i: 0 for i in nodes} - for i in ebc: - nodes_ebc[i[0]] += ebc[i] - nodes_ebc[i[1]] += ebc[i] - - sorted_nodes = sorted(nodes_ebc.items(), key=lambda x: x[1], reverse=True) - - chunks = [[] for _ in range(num_chunks)] - chunk_sums = [0] * num_chunks - - for node, value in sorted_nodes: - min_chunk_index = chunk_sums.index(min(chunk_sums)) - chunks[min_chunk_index].append(node) - chunk_sums[min_chunk_index] += value - - return chunks - - G = nx.fast_gnp_random_graph(100, 0.1, directed=False) - H = nxp.ParallelGraph(G) - ebc = nx.edge_betweenness_centrality(G) - par_bc_chunk = nxp.betweenness_centrality(H, get_chunks=get_chunk) # smoke test - par_bc = nxp.betweenness_centrality(H) - - for i in range(len(G.nodes)): - assert math.isclose(par_bc[i], par_bc_chunk[i], abs_tol=1e-16) - - -def test_betweenness_centrality_directed_graph(): - """Test betweenness centrality on a directed graph.""" - G = nx.fast_gnp_random_graph(100, 0.1, directed=True) - H = nxp.ParallelGraph(G) - - par_bc = nxp.betweenness_centrality(H) - expected_bc = nx.betweenness_centrality(G) - - for node in G.nodes: - assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) - - -def test_betweenness_centrality_weighted_graph(): - """Test betweenness centrality on a weighted graph.""" - G = nx.fast_gnp_random_graph(100, 0.1, directed=False) - for u, v in G.edges: - G[u][v]["weight"] = 1.0 # Assign uniform weights - - H = nxp.ParallelGraph(G) - par_bc = nxp.betweenness_centrality(H, weight="weight") - expected_bc = nx.betweenness_centrality(G, weight="weight") - - for node in G.nodes: - assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) - - -def test_betweenness_centrality_small_graph(): - """Test betweenness centrality on a small graph.""" - G = nx.path_graph(5) # A simple path graph - H = nxp.ParallelGraph(G) - - par_bc = nxp.betweenness_centrality(H) - expected_bc = nx.betweenness_centrality(G) - - for node in G.nodes: - assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) - - -def test_betweenness_centrality_empty_graph(): - """Test betweenness centrality on an empty graph.""" - G = nx.Graph() # An empty graph - H = nxp.ParallelGraph(G) - - # Check if the underlying graph is empty before calling the function - if len(H.graph_object) == 0: # Use the underlying graph's length - assert ( - nxp.betweenness_centrality(H) == {} - ), "Expected an empty dictionary for an empty graph" - else: - pytest.fail("Graph is not empty, but it should be.") - - -def test_betweenness_centrality_single_node(): - """Test betweenness centrality on a graph with a single node.""" - G = nx.Graph() - G.add_node(1) - H = nxp.ParallelGraph(G) - - par_bc = nxp.betweenness_centrality(H) - expected_bc = nx.betweenness_centrality(G) - - assert par_bc == expected_bc # Both should return {1: 0.0} - - -def test_betweenness_centrality_large_graph(): - """Test betweenness centrality on a large graph.""" - G = nx.fast_gnp_random_graph(1000, 0.01, directed=False) - H = nxp.ParallelGraph(G) - - par_bc = nxp.betweenness_centrality(H) - expected_bc = nx.betweenness_centrality(G) - - for node in G.nodes: - assert math.isclose( - par_bc[node], expected_bc[node], abs_tol=1e-6 - ) # Larger tolerance for large graphs - - -def test_betweenness_centrality_multigraph(): - """Test betweenness centrality on a multigraph.""" - G = nx.MultiGraph() - G.add_edges_from([(1, 2), (1, 2), (2, 3), (3, 4)]) - H = nxp.ParallelGraph(G) - - par_bc = nxp.betweenness_centrality(H) - expected_bc = nx.betweenness_centrality(G) - - for node in G.nodes: - assert math.isclose(par_bc[node], expected_bc[node], abs_tol=1e-16) - - -def test_closeness_centrality_default_chunks(): - """Test closeness centrality with default chunking.""" - G = nx.path_graph(5) # A simple path graph - H = nxp.ParallelGraph(G) - - par_cc = nxp.closeness_centrality(H, get_chunks="chunks") - expected_cc = nx.closeness_centrality(G) - - for node in G.nodes: - assert pytest.approx(par_cc[node], rel=1e-6) == expected_cc[node] - - -def test_closeness_centrality_custom_chunks(): - """Test closeness centrality with a custom chunking function.""" - - def custom_chunking(nodes): - # Example custom chunking: split nodes into two equal parts - mid = len(nodes) // 2 - return [nodes[:mid], nodes[mid:]] - - G = nx.path_graph(5) # A simple path graph - H = nxp.ParallelGraph(G) - - par_cc = nxp.closeness_centrality(H, get_chunks=custom_chunking) - expected_cc = nx.closeness_centrality(G) - - for node in G.nodes: - assert pytest.approx(par_cc[node], rel=1e-6) == expected_cc[node] - - -def test_closeness_centrality_empty_graph(): - """Test closeness centrality on an empty graph.""" - G = nx.Graph() # An empty graph - H = nxp.ParallelGraph(G) - - assert ( - nxp.closeness_centrality(H, get_chunks="chunks") == {} - ), "Expected an empty dictionary for an empty graph" - - -def test_closeness_centrality_single_node(): - """Test closeness centrality on a graph with a single node.""" - G = nx.Graph() - G.add_node(1) - H = nxp.ParallelGraph(G) - - par_cc = nxp.closeness_centrality(H, get_chunks="chunks") - expected_cc = nx.closeness_centrality(G) - - assert par_cc == expected_cc # Both should return {1: 0.0} - - -def test_closeness_centrality_large_graph(): - """Test closeness centrality on a large graph.""" - G = nx.fast_gnp_random_graph(1000, 0.01, directed=False) - H = nxp.ParallelGraph(G) - - par_cc = nxp.closeness_centrality(H, get_chunks="chunks") - expected_cc = nx.closeness_centrality(G) - - for node in G.nodes: - assert pytest.approx(par_cc[node], rel=1e-6) == expected_cc[node] From b61396e636b99c64fe00a45a9e4bd953c4e823e9 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Tue, 25 Mar 2025 20:21:10 +0000 Subject: [PATCH 14/20] Reverted betweenness.py to original state and removed fixed of handling empty Graphs --- nx_parallel/algorithms/centrality/betweenness.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/nx_parallel/algorithms/centrality/betweenness.py b/nx_parallel/algorithms/centrality/betweenness.py index 65d58b46..8b41a951 100644 --- a/nx_parallel/algorithms/centrality/betweenness.py +++ b/nx_parallel/algorithms/centrality/betweenness.py @@ -53,18 +53,12 @@ def betweenness_centrality( else: node_chunks = get_chunks(nodes) - # Handle empty chunks - if not node_chunks: - return {} bt_cs = Parallel()( delayed(_betweenness_centrality_node_subset)(G, chunk, weight, endpoints) for chunk in node_chunks ) - # Handle empty results - if not bt_cs: - return {} # Reducing partial solution bt_c = bt_cs[0] From 7b71fa7970fb2545dfcf902a8a2c30422e1f9ae7 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Tue, 25 Mar 2025 20:22:46 +0000 Subject: [PATCH 15/20] Changed interface and __init__.py to remove centrality features --- nx_parallel/algorithms/centrality/__init__.py | 2 -- nx_parallel/interface.py | 1 - 2 files changed, 3 deletions(-) diff --git a/nx_parallel/algorithms/centrality/__init__.py b/nx_parallel/algorithms/centrality/__init__.py index 56f07a83..0dade124 100644 --- a/nx_parallel/algorithms/centrality/__init__.py +++ b/nx_parallel/algorithms/centrality/__init__.py @@ -1,10 +1,8 @@ from .degree import degree_centrality -from .closeness import closeness_centrality from .betweenness import betweenness_centrality, edge_betweenness_centrality __all__ = [ "degree_centrality", - "closeness_centrality", "betweenness_centrality", "edge_betweenness_centrality", ] diff --git a/nx_parallel/interface.py b/nx_parallel/interface.py index d45e9363..c71f58ce 100644 --- a/nx_parallel/interface.py +++ b/nx_parallel/interface.py @@ -18,7 +18,6 @@ # Centrality "betweenness_centrality", "edge_betweenness_centrality", - "closeness_centrality", "degree_centrality", # Efficiency "local_efficiency", From 031213848ef98c1246a9b76a5a496558caa2e517 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Tue, 25 Mar 2025 20:24:04 +0000 Subject: [PATCH 16/20] Updated timing directory and removed closesness_centrality --- timing/heatmap_closeness_centrality_timing.png | Bin 45331 -> 0 bytes timing/timing_all_functions.py | 1 - timing/timing_comparison.md | 3 +++ 3 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 timing/heatmap_closeness_centrality_timing.png diff --git a/timing/heatmap_closeness_centrality_timing.png b/timing/heatmap_closeness_centrality_timing.png deleted file mode 100644 index 032a4e63ecaea04db0e91a31b6e7fda73c233a8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45331 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�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

S#p=|3iK%J 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 Du3lf{ diff --git a/timing/timing_all_functions.py b/timing/timing_all_functions.py index 1d19efd9..c72deb66 100644 --- a/timing/timing_all_functions.py +++ b/timing/timing_all_functions.py @@ -81,7 +81,6 @@ labels = [ "betweenness_centrality", "closeness_vitality", - "closeness_centrality", "degree_centrality", "tournament is_reachable", ] diff --git a/timing/timing_comparison.md b/timing/timing_comparison.md index ea331708..985dfd9b 100644 --- a/timing/timing_comparison.md +++ b/timing/timing_comparison.md @@ -22,6 +22,9 @@ betweenness_centrality closeness_vitality ![alt text](heatmap_closeness_vitality_timing.png) +degree_centrality +![alt text](heatmap_degree_centrality_timing.png) + local_efficiency ![alt text](heatmap_local_efficiency_timing.png) From 4f83046a630e45ecb5efdf3ad49cd579e2b5eff2 Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Tue, 25 Mar 2025 21:31:56 +0000 Subject: [PATCH 17/20] Added Benchmark --- benchmarks/asv.conf.json | 2 +- benchmarks/benchmarks/bench_centrality.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/benchmarks/asv.conf.json b/benchmarks/asv.conf.json index a12982fb..b5b49eef 100644 --- a/benchmarks/asv.conf.json +++ b/benchmarks/asv.conf.json @@ -17,5 +17,5 @@ "results_dir": "results", "html_dir": "html", "build_cache_size": 8, - "default_benchmark_timeout": 1200, + "default_benchmark_timeout": 1200 } diff --git a/benchmarks/benchmarks/bench_centrality.py b/benchmarks/benchmarks/bench_centrality.py index 74cba68e..02b9b53f 100644 --- a/benchmarks/benchmarks/bench_centrality.py +++ b/benchmarks/benchmarks/bench_centrality.py @@ -19,3 +19,11 @@ def time_betweenness_centrality(self, backend, num_nodes, edge_prob): def time_edge_betweenness_centrality(self, backend, num_nodes, edge_prob): G = get_cached_gnp_random_graph(num_nodes, edge_prob, is_weighted=True) _ = nx.edge_betweenness_centrality(G, backend=backend) + +class Degree(Benchmark): + params = [(backends), (num_nodes), (edge_prob)] + param_names = ["backend", "num_nodes", "edge_prob"] + + def time_degree_centrality(self, backend, num_nodes, edge_prob): + G = get_cached_gnp_random_graph(num_nodes, edge_prob) + _ = nx.degree_centrality(G, backend=backend) \ No newline at end of file From 8661a1968f0b83be87c24def7fd5a0fcaad1628c Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Tue, 25 Mar 2025 22:34:22 +0000 Subject: [PATCH 18/20] Reverted .yaml file --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0f909d39..d14eea98 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: rev: v0.6.7 hooks: - id: ruff - args: ['--fix', '--fix-only'] + args: ['--fix'] - id: ruff-format - repo: local hooks: From 13a6c12eb453793d8cbfe2031740b43b0a14b38c Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Tue, 25 Mar 2025 22:34:42 +0000 Subject: [PATCH 19/20] Updated README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9d54ad19..561935ee 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ nx-parallel is a NetworkX backend that uses joblib for parallelization. This pro - [approximate_all_pairs_node_connectivity](https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/approximation/connectivity.py#L13) - [betweenness_centrality](https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/betweenness.py#L20) - [closeness_vitality](https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/vitality.py#L10) +- [degree_centrality](https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/degree.py#L9) - [edge_betweenness_centrality](https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/betweenness.py#L96) - [is_reachable](https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/tournament.py#L13) - [johnson](https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/shortest_paths/weighted.py#L256) From 4a47ec7492b475cb49f908c28f748527282fcada Mon Sep 17 00:00:00 2001 From: SKADE2303 Date: Tue, 25 Mar 2025 22:42:51 +0000 Subject: [PATCH 20/20] Fixed pre-commit issues --- _nx_parallel/__init__.py | 9 +-------- benchmarks/benchmarks/bench_centrality.py | 3 ++- nx_parallel/algorithms/centrality/betweenness.py | 2 -- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/_nx_parallel/__init__.py b/_nx_parallel/__init__.py index 8c0f9d80..c46b33c4 100644 --- a/_nx_parallel/__init__.py +++ b/_nx_parallel/__init__.py @@ -90,13 +90,6 @@ def get_info(): 'get_chunks : str, function (default = "chunks")': "A function that takes in a list of all the nodes as input and returns an iterable `node_chunks`. The default chunking is done by slicing the `nodes` into `n_jobs` number of chunks." }, }, - "closeness_centrality": { - "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/closeness.py#L9", - "additional_docs": "The parallel computation is implemented by dividing the nodes into chunks and computing closeness centrality for each chunk concurrently.", - "additional_parameters": { - "G : graph": 'A NetworkX graph u : node, optional Return only the value for node u distance : string or function, optional The edge attribute to use as distance when computing shortest paths, or a user-defined distance function. wf_improved : bool, optional If True, use the improved formula for closeness centrality. get_chunks : str, function (default = "chunks") A function that takes in a list of all the nodes as input and returns an iterable `node_chunks`. The default chunking is done by slicing the `nodes` into `n_jobs` number of chunks.' - }, - }, "closeness_vitality": { "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/vitality.py#L10", "additional_docs": "The parallel computation is implemented only when the node is not specified. The closeness vitality for each node is computed concurrently.", @@ -112,7 +105,7 @@ def get_info(): }, }, "edge_betweenness_centrality": { - "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/betweenness.py#L104", + "url": "https://github.com/networkx/nx-parallel/blob/main/nx_parallel/algorithms/centrality/betweenness.py#L96", "additional_docs": "The parallel computation is implemented by dividing the nodes into chunks and computing edge betweenness centrality for each chunk concurrently.", "additional_parameters": { 'get_chunks : str, function (default = "chunks")': "A function that takes in a list of all the nodes as input and returns an iterable `node_chunks`. The default chunking is done by slicing the `nodes` into `n_jobs` number of chunks." diff --git a/benchmarks/benchmarks/bench_centrality.py b/benchmarks/benchmarks/bench_centrality.py index 02b9b53f..26b322a6 100644 --- a/benchmarks/benchmarks/bench_centrality.py +++ b/benchmarks/benchmarks/bench_centrality.py @@ -20,10 +20,11 @@ def time_edge_betweenness_centrality(self, backend, num_nodes, edge_prob): G = get_cached_gnp_random_graph(num_nodes, edge_prob, is_weighted=True) _ = nx.edge_betweenness_centrality(G, backend=backend) + class Degree(Benchmark): params = [(backends), (num_nodes), (edge_prob)] param_names = ["backend", "num_nodes", "edge_prob"] def time_degree_centrality(self, backend, num_nodes, edge_prob): G = get_cached_gnp_random_graph(num_nodes, edge_prob) - _ = nx.degree_centrality(G, backend=backend) \ No newline at end of file + _ = nx.degree_centrality(G, backend=backend) diff --git a/nx_parallel/algorithms/centrality/betweenness.py b/nx_parallel/algorithms/centrality/betweenness.py index 8b41a951..25dc5056 100644 --- a/nx_parallel/algorithms/centrality/betweenness.py +++ b/nx_parallel/algorithms/centrality/betweenness.py @@ -53,13 +53,11 @@ def betweenness_centrality( else: node_chunks = get_chunks(nodes) - bt_cs = Parallel()( delayed(_betweenness_centrality_node_subset)(G, chunk, weight, endpoints) for chunk in node_chunks ) - # Reducing partial solution bt_c = bt_cs[0] for bt in bt_cs[1:]: