modularitypruning 1.3.5__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/PKG-INFO +2 -1
  2. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/modularitypruning.egg-info/PKG-INFO +2 -1
  3. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/modularitypruning.egg-info/SOURCES.txt +2 -0
  4. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/modularitypruning.egg-info/requires.txt +1 -0
  5. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/setup.py +2 -2
  6. modularitypruning-1.4.0/tests/test_documentation_examples.py +265 -0
  7. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/tests/test_multiplex_parameter_estimation.py +4 -4
  8. modularitypruning-1.4.0/tests/test_parallel_leiden_performance.py +146 -0
  9. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/leiden_utilities.py +36 -71
  10. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/parameter_estimation_utilities.py +22 -4
  11. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/plotting.py +4 -4
  12. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/LICENSE +0 -0
  13. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/README.md +0 -0
  14. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/modularitypruning.egg-info/dependency_links.txt +0 -0
  15. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/modularitypruning.egg-info/top_level.txt +0 -0
  16. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/setup.cfg +0 -0
  17. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/tests/test_champ_coefficients_2D.py +0 -0
  18. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/tests/test_champ_coefficients_3D.py +0 -0
  19. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/tests/test_champ_usage_2D.py +0 -0
  20. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/tests/test_champ_usage_3D.py +0 -0
  21. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/tests/test_deprecated_louvain_names.py +0 -0
  22. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/tests/test_monolayer_parameter_estimation.py +0 -0
  23. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/tests/test_temporal_multilevel_parameter_estimation.py +0 -0
  24. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/__init__.py +0 -0
  25. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/champ_utilities.py +0 -0
  26. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/louvain_utilities.py +0 -0
  27. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/parameter_estimation.py +0 -0
  28. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/partition_utilities.py +0 -0
  29. {modularitypruning-1.3.5 → modularitypruning-1.4.0}/utilities/progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: modularitypruning
3
- Version: 1.3.5
3
+ Version: 1.4.0
4
4
  Summary: Pruning tool to identify small subsets of network partitions that are significant from the perspective of stochastic block model inference.
5
5
  Home-page: https://github.com/ragibson/ModularityPruning
6
6
  Author: Ryan Gibson
@@ -26,6 +26,7 @@ Requires-Dist: igraph
26
26
  Requires-Dist: scikit-learn
27
27
  Requires-Dist: scipy>=1.7
28
28
  Requires-Dist: seaborn
29
+ Requires-Dist: tqdm
29
30
 
30
31
  # ModularityPruning
31
32
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: modularitypruning
3
- Version: 1.3.5
3
+ Version: 1.4.0
4
4
  Summary: Pruning tool to identify small subsets of network partitions that are significant from the perspective of stochastic block model inference.
5
5
  Home-page: https://github.com/ragibson/ModularityPruning
6
6
  Author: Ryan Gibson
@@ -26,6 +26,7 @@ Requires-Dist: igraph
26
26
  Requires-Dist: scikit-learn
27
27
  Requires-Dist: scipy>=1.7
28
28
  Requires-Dist: seaborn
29
+ Requires-Dist: tqdm
29
30
 
30
31
  # ModularityPruning
31
32
 
@@ -11,8 +11,10 @@ tests/test_champ_coefficients_3D.py
11
11
  tests/test_champ_usage_2D.py
12
12
  tests/test_champ_usage_3D.py
13
13
  tests/test_deprecated_louvain_names.py
14
+ tests/test_documentation_examples.py
14
15
  tests/test_monolayer_parameter_estimation.py
15
16
  tests/test_multiplex_parameter_estimation.py
17
+ tests/test_parallel_leiden_performance.py
16
18
  tests/test_temporal_multilevel_parameter_estimation.py
17
19
  utilities/__init__.py
18
20
  utilities/champ_utilities.py
@@ -6,3 +6,4 @@ igraph
6
6
  scikit-learn
7
7
  scipy>=1.7
8
8
  seaborn
9
+ tqdm
@@ -9,7 +9,7 @@ with open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
9
9
 
10
10
  setup(
11
11
  name='modularitypruning',
12
- version='1.3.5',
12
+ version='1.4.0',
13
13
  package_dir={'modularitypruning': 'utilities'},
14
14
  packages=['modularitypruning'],
15
15
  url='https://github.com/ragibson/ModularityPruning',
@@ -34,5 +34,5 @@ setup(
34
34
  ],
35
35
  python_requires='>=3.8, <4',
36
36
  install_requires=['leidenalg', 'matplotlib', "numpy", 'psutil', 'igraph',
37
- "scikit-learn", "scipy>=1.7", 'seaborn']
37
+ "scikit-learn", "scipy>=1.7", 'seaborn', 'tqdm']
38
38
  )
@@ -0,0 +1,265 @@
1
+ """
2
+ This set of tests checks that the examples from the documentation still work correctly.
3
+
4
+ Sometimes this is simply checking that the code produces the intended output or runs without errors.
5
+ """
6
+ from modularitypruning import prune_to_stable_partitions, prune_to_multilayer_stable_partitions
7
+ from modularitypruning.champ_utilities import CHAMP_2D, CHAMP_3D
8
+ from modularitypruning.leiden_utilities import (repeated_parallel_leiden_from_gammas,
9
+ repeated_parallel_leiden_from_gammas_omegas)
10
+ from modularitypruning.parameter_estimation_utilities import domains_to_gamma_omega_estimates, ranges_to_gamma_estimates
11
+ from modularitypruning.partition_utilities import num_communities
12
+ from modularitypruning.plotting import (plot_2d_domains_with_estimates, plot_2d_domains, plot_2d_domains_with_ami,
13
+ plot_2d_domains_with_num_communities, plot_estimates, plot_multiplex_community)
14
+ from random import seed, random
15
+ import igraph as ig
16
+ import matplotlib.pyplot as plt
17
+ import numpy as np
18
+ import unittest
19
+
20
+
21
+ class TestDocumentationExamples(unittest.TestCase):
22
+ def test_basic_singlelayer_example(self):
23
+ """
24
+ Taken verbatim from basic_example.rst.
25
+
26
+ Like a lot of our other tests, this is stochastic but appears incredibly stable.
27
+ """
28
+ # get Karate Club graph in igraph
29
+ G = ig.Graph.Famous("Zachary")
30
+
31
+ # run leiden 1000 times on this graph from gamma=0 to gamma=2
32
+ partitions = repeated_parallel_leiden_from_gammas(G, np.linspace(0, 2, 1000))
33
+
34
+ # prune to the stable partitions from gamma=0 to gamma=2
35
+ stable_partitions = prune_to_stable_partitions(G, partitions, 0, 2)
36
+
37
+ intended_stable_partition = [(0, 0, 0, 0, 1, 1, 1, 0, 2, 2, 1, 0, 0, 0, 2, 2, 1,
38
+ 0, 2, 0, 2, 0, 2, 3, 3, 3, 2, 3, 3, 2, 2, 3, 2, 2)]
39
+ self.assertEqual(stable_partitions, intended_stable_partition)
40
+
41
+ @staticmethod
42
+ def generate_basic_multilayer_network():
43
+ """This is taken verbatim from basic_multilayer_example.rst."""
44
+ num_layers = 3
45
+ n_per_layer = 30
46
+ p_in = 0.5
47
+ p_out = 0.05
48
+ K = 3
49
+
50
+ # layer_vec holds the layer membership of each node
51
+ # e.g. layer_vec[5] = 2 means that node 5 resides in layer 2 (the third layer)
52
+ layer_vec = [i // n_per_layer for i in range(n_per_layer * num_layers)]
53
+ interlayer_edges = [(n_per_layer * layer + v, n_per_layer * layer + v + n_per_layer)
54
+ for layer in range(num_layers - 1)
55
+ for v in range(n_per_layer)]
56
+
57
+ # set up a community vector with
58
+ # three communities in layer 0 (each of size 10)
59
+ # three communities in layer 1 (each of size 10)
60
+ # one community in layer 2 (of size 30)
61
+ comm_per_layer = [[i // (n_per_layer // K) if layer < num_layers - 1 else 0
62
+ for i in range(n_per_layer)] for layer in range(num_layers)]
63
+ comm_vec = [item for sublist in comm_per_layer for item in sublist]
64
+
65
+ # randomly connect nodes inside each layer with undirected edges according to
66
+ # within-community probability p_in and between-community probability p_out
67
+ intralayer_edges = [(u, v) for v in range(len(comm_vec)) for u in range(v + 1, len(comm_vec))
68
+ if layer_vec[v] == layer_vec[u] and (
69
+ (comm_vec[v] == comm_vec[u] and random() < p_in) or
70
+ (comm_vec[v] != comm_vec[u] and random() < p_out)
71
+ )]
72
+
73
+ # create the networks in igraph. By Pamfil et al.'s convention, the interlayer edges
74
+ # of a temporal network are directed (representing the "one-way" nature of time)
75
+ G_intralayer = ig.Graph(intralayer_edges)
76
+ G_interlayer = ig.Graph(interlayer_edges, directed=True)
77
+
78
+ return G_intralayer, G_interlayer, layer_vec
79
+
80
+ def test_basic_multilayer_example(self):
81
+ """
82
+ This is taken verbatim from basic_multilayer_example.rst.
83
+
84
+ For simplicity and re-use, the network generation is encapsulated in generate_basic_multilayer_network().
85
+ """
86
+ n_per_layer = 30 # from network generation code
87
+ G_intralayer, G_interlayer, layer_vec = self.generate_basic_multilayer_network()
88
+
89
+ # run leidenalg on a uniform 32x32 grid (1024 samples) of gamma and omega in [0, 2]
90
+ gamma_range = (0, 2)
91
+ omega_range = (0, 2)
92
+ leiden_gammas = np.linspace(*gamma_range, 32)
93
+ leiden_omegas = np.linspace(*omega_range, 32)
94
+
95
+ parts = repeated_parallel_leiden_from_gammas_omegas(G_intralayer, G_interlayer, layer_vec,
96
+ gammas=leiden_gammas, omegas=leiden_omegas)
97
+
98
+ # prune to the stable partitions from (gamma=0, omega=0) to (gamma=2, omega=2)
99
+ stable_parts = prune_to_multilayer_stable_partitions(G_intralayer, G_interlayer, layer_vec,
100
+ "temporal", parts,
101
+ *gamma_range, *omega_range)
102
+
103
+ # check all 3-partition stable partitions closely match ground truth communities
104
+ for membership in stable_parts:
105
+ if num_communities(membership) != 3:
106
+ continue
107
+
108
+ most_common_label = []
109
+ for chunk_idx in range(6): # check most common label of each community (10 nodes each)
110
+ counts = {i: 0 for i in range(max(membership) + 1)}
111
+ for chunk_label in membership[10 * chunk_idx:10 * (chunk_idx + 1)]:
112
+ counts[chunk_label] += 1
113
+ most_common_label.append(max(counts.items(), key=lambda x: x[1])[0])
114
+
115
+ # check these communities look like the intended ground truth communities for the first layer
116
+ # [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
117
+ self.assertNotEqual(most_common_label[0], most_common_label[1])
118
+ self.assertNotEqual(most_common_label[1], most_common_label[2])
119
+
120
+ # at least one partition has the last layer mostly in one community and another splits it into multiple
121
+ unified_final_layer_count = 0
122
+ split_final_layer_count = 0
123
+ for membership in stable_parts:
124
+ count_final_layer = {i: 0 for i in range(max(membership) + 1)}
125
+ for label in membership[-n_per_layer:]:
126
+ count_final_layer[label] += 1
127
+ most_common_label_final_layer, most_common_label_count = max(count_final_layer.items(),
128
+ key=lambda x: x[1])
129
+ proportion_final_layer_having_same_label = most_common_label_count / n_per_layer
130
+
131
+ if proportion_final_layer_having_same_label > 0.9:
132
+ unified_final_layer_count += 1
133
+ elif proportion_final_layer_having_same_label < 0.5:
134
+ split_final_layer_count += 1
135
+
136
+ self.assertGreater(unified_final_layer_count, 0)
137
+ self.assertGreater(split_final_layer_count, 0)
138
+
139
+ def test_plot_estimates_example(self):
140
+ """
141
+ This is taken (almost) verbatim from plotting_examples.rst.
142
+
143
+ The first call to plt.rc() has usetex=False (instead of True) to avoid requiring a full LaTeX installation.
144
+ """
145
+ # get Karate Club graph in igraph
146
+ G = ig.Graph.Famous("Zachary")
147
+
148
+ # run leiden 100K times on this graph from gamma=0 to gamma=2 (takes ~2-3 seconds)
149
+ partitions = repeated_parallel_leiden_from_gammas(G, np.linspace(0, 2, 10 ** 5))
150
+
151
+ # run CHAMP to obtain the dominant partitions along with their regions of optimality
152
+ ranges = CHAMP_2D(G, partitions, gamma_0=0.0, gamma_f=2.0)
153
+
154
+ # append gamma estimate for each dominant partition onto the CHAMP domains
155
+ gamma_estimates = ranges_to_gamma_estimates(G, ranges)
156
+
157
+ # plot gamma estimates and domains of optimality
158
+ plt.rc('text', usetex=False)
159
+ plt.rc('font', family='serif')
160
+ plot_estimates(gamma_estimates)
161
+ plt.title(r"Karate Club CHAMP Domains of Optimality and $\gamma$ Estimates", fontsize=14)
162
+ plt.xlabel(r"$\gamma$", fontsize=14)
163
+ plt.ylabel("Number of communities", fontsize=14)
164
+
165
+ def test_plot_2d_domains_examples(self):
166
+ """
167
+ This is taken (almost) verbatim from plotting_examples.rst.
168
+
169
+ The first call to plt.rc() has usetex=False (instead of True) to avoid requiring a full LaTeX installation.
170
+
171
+ The documentation explicitly shows plot_2d_domains_with_estimates() and describes other, similar functions
172
+ * plot_2d_domains()
173
+ * plot_2d_domains_with_ami()
174
+ * plot_2d_domains_with_num_communities()
175
+ As such, we test them all here.
176
+ """
177
+ G_intralayer, G_interlayer, layer_vec = self.generate_basic_multilayer_network()
178
+ # run leiden on a uniform grid (10K samples) of gamma and omega (takes ~3 seconds)
179
+ gamma_range = (0.5, 1.5)
180
+ omega_range = (0, 2)
181
+ parts = repeated_parallel_leiden_from_gammas_omegas(G_intralayer, G_interlayer, layer_vec,
182
+ gammas=np.linspace(*gamma_range, 100),
183
+ omegas=np.linspace(*omega_range, 100))
184
+
185
+ # run CHAMP to obtain the dominant partitions along with their regions of optimality
186
+ domains = CHAMP_3D(G_intralayer, G_interlayer, layer_vec, parts,
187
+ gamma_0=gamma_range[0], gamma_f=gamma_range[1],
188
+ omega_0=omega_range[0], omega_f=omega_range[1])
189
+
190
+ # append resolution parameter estimates for each dominant partition onto the CHAMP domains
191
+ domains_with_estimates = domains_to_gamma_omega_estimates(G_intralayer, G_interlayer, layer_vec,
192
+ domains, model='temporal')
193
+
194
+ # plot resolution parameter estimates and domains of optimality
195
+ plt.rc('text', usetex=False)
196
+ plt.rc('font', family='serif')
197
+ plot_2d_domains_with_estimates(domains_with_estimates, xlim=omega_range, ylim=gamma_range)
198
+ plt.title(r"CHAMP Domains and ($\omega$, $\gamma$) Estimates", fontsize=16)
199
+ plt.xlabel(r"$\omega$", fontsize=20)
200
+ plt.ylabel(r"$\gamma$", fontsize=20)
201
+ plt.gca().tick_params(axis='both', labelsize=12)
202
+ plt.tight_layout()
203
+
204
+ # same plotting code, but with plot_2d_domains()
205
+ plt.rc('text', usetex=False)
206
+ plt.rc('font', family='serif')
207
+ plot_2d_domains(domains, xlim=omega_range, ylim=gamma_range)
208
+ plt.title(r"CHAMP Domains", fontsize=16)
209
+ plt.xlabel(r"$\omega$", fontsize=20)
210
+ plt.ylabel(r"$\gamma$", fontsize=20)
211
+ plt.gca().tick_params(axis='both', labelsize=12)
212
+ plt.tight_layout()
213
+
214
+ # same plotting code, but with plot_2d_domains_with_ami()
215
+ plt.rc('text', usetex=False)
216
+ plt.rc('font', family='serif')
217
+ ground_truth_partition = ([0] * 10 + [1] * 10 + [2] * 10) * 2 + [0] * 30
218
+ plot_2d_domains_with_ami(domains_with_estimates, ground_truth=ground_truth_partition,
219
+ xlim=omega_range, ylim=gamma_range)
220
+ plt.title(r"CHAMP Domains, Colored by AMI with Ground Truth", fontsize=16)
221
+ plt.xlabel(r"$\omega$", fontsize=20)
222
+ plt.ylabel(r"$\gamma$", fontsize=20)
223
+ plt.gca().tick_params(axis='both', labelsize=12)
224
+ plt.tight_layout()
225
+
226
+ # same plotting code, but with plot_2d_domains_with_num_communities()
227
+ plt.rc('text', usetex=False)
228
+ plt.rc('font', family='serif')
229
+ plot_2d_domains_with_num_communities(domains_with_estimates, xlim=omega_range, ylim=gamma_range)
230
+ plt.title(r"CHAMP Domains, Colored by Number of Communities", fontsize=16)
231
+ plt.xlabel(r"$\omega$", fontsize=20)
232
+ plt.ylabel(r"$\gamma$", fontsize=20)
233
+ plt.gca().tick_params(axis='both', labelsize=12)
234
+ plt.tight_layout()
235
+ plt.close() # closing all these figures instead of showing
236
+
237
+ def test_plot_multiplex_community(self):
238
+ """
239
+ This is taken (almost) verbatim from plotting_examples.rst.
240
+
241
+ The first call to plt.rc() has usetex=False (instead of True) to avoid requiring a full LaTeX installation.
242
+ """
243
+ num_layers = 3
244
+ layer_vec = [i // 71 for i in range(num_layers * 71)]
245
+ membership = [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1,
246
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
247
+ 2, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
248
+ 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2,
249
+ 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1,
250
+ 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 2, 2, 2,
251
+ 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
252
+
253
+ plt.rc('text', usetex=False)
254
+ plt.rc('font', family='serif')
255
+ ax = plot_multiplex_community(np.array(membership), np.array(layer_vec))
256
+ ax.set_xticks(np.linspace(0, num_layers, 2 * num_layers + 1))
257
+ ax.set_xticklabels(["", "Advice", "", "Coworker", "", "Friend", ""], fontsize=14)
258
+ plt.title(f"Multiplex Communities", fontsize=14)
259
+ plt.ylabel("Node ID", fontsize=14)
260
+ plt.close() # closing this these figures instead of showing
261
+
262
+
263
+ if __name__ == "__main__":
264
+ seed(0)
265
+ unittest.main()
@@ -49,10 +49,10 @@ class TestMultiplexParameterEstimation(unittest.TestCase):
49
49
  model='multiplex')
50
50
 
51
51
  # check we converged close to the ground truth "correct" values
52
- # the multiplex omega estimation seems less accurate than in other models, perhaps due to
53
- # the copying probability approximation
54
- self.assertLess(abs(true_gamma - gamma), 0.05)
55
- self.assertLess(abs(true_omega - omega), 0.15)
52
+ # the multiplex parameter estimation is much less robust and less accurate than in other models,
53
+ # perhaps due to the copying probability approximation
54
+ self.assertLess(abs(true_gamma - gamma), 0.1)
55
+ self.assertLess(abs(true_omega - omega), 0.2)
56
56
 
57
57
  def test_multiplex_SBM_correct_convergence_varying_copying_probabilty(self):
58
58
  for eta in [0.25, 0.5, 0.75, 0.9]:
@@ -0,0 +1,146 @@
1
+ from .shared_testing_functions import generate_connected_ER, generate_multilayer_intralayer_SBM
2
+ from modularitypruning.leiden_utilities import (repeated_leiden_from_gammas, repeated_parallel_leiden_from_gammas,
3
+ repeated_leiden_from_gammas_omegas,
4
+ repeated_parallel_leiden_from_gammas_omegas)
5
+ from multiprocessing import Pool, cpu_count
6
+ from random import seed
7
+ from time import time, sleep
8
+ import functools
9
+ import igraph as ig
10
+ import numpy as np
11
+ import psutil
12
+ import unittest
13
+ import warnings
14
+
15
+ # this set of tests ensures that we achieve >= 75% parallel performance compared to perfect scaling of
16
+ # single-threaded jobs to multiple cores (with no memory contention). This threshold will be decreased in
17
+ # determine_target_parallelization_speedup() if the background CPU utilization exceeds 20%.
18
+ PERFORMANCE_TARGET_RELATIVE_TO_PERFECT_SCALING = 0.75
19
+
20
+
21
+ def mock_calculation(_):
22
+ """A mock calculation that provides enough work to make serialization overhead negligible."""
23
+ return sum(range(10 ** 7))
24
+
25
+
26
+ @functools.lru_cache(maxsize=1)
27
+ def determine_target_parallelization_speedup(num_calculations=32):
28
+ """
29
+ Calculate the parallelization speedup on mock_calculation to benchmark our implementation against.
30
+
31
+ This performs
32
+ * ``num_calculations`` function calls in the single-threaded case, and
33
+ * ``num_calculations * cpu_count()`` calls in the multi-processed case
34
+
35
+ Due in part to frequency scaling and simple memory contention, leidenalg over multiple processes (completely
36
+ outside of Python or multiprocessing.Pool) seems to run at around (90% * core count) speedup on modern systems when
37
+ hyper-threading is disabled.
38
+ """
39
+ global PERFORMANCE_TARGET_RELATIVE_TO_PERFECT_SCALING
40
+
41
+ sleep(5) # sleep to increase stability of the CPU utilization check
42
+ cpu_utilization = psutil.cpu_percent()
43
+ if cpu_utilization > 20:
44
+ PERFORMANCE_TARGET_RELATIVE_TO_PERFECT_SCALING = 0.5
45
+ warnings.warn(f"System CPU utilization is non-negligible during parallel performance test! "
46
+ f"Dropping performance scaling target to 50%.")
47
+
48
+ start_time = time()
49
+ _ = [mock_calculation(i) for i in range(num_calculations)]
50
+ base_duration = time() - start_time
51
+
52
+ num_pool_calculations = num_calculations * cpu_count()
53
+ with Pool(processes=cpu_count()) as pool:
54
+ pool.map(mock_calculation, range(cpu_count())) # force pool initialization and basic burn-in
55
+
56
+ start_time = time()
57
+ pool.map(mock_calculation, range(num_pool_calculations))
58
+ pool_duration = time() - start_time
59
+
60
+ return num_pool_calculations / num_calculations * base_duration / pool_duration
61
+
62
+
63
+ class TestParallelLeidenPerformance(unittest.TestCase):
64
+ @staticmethod
65
+ def run_singlelayer_graph_parallelization(G, gammas):
66
+ target_speedup = determine_target_parallelization_speedup()
67
+
68
+ start_time = time()
69
+ _ = repeated_leiden_from_gammas(G, gammas)
70
+ duration = time() - start_time
71
+
72
+ pool_gammas = np.linspace(min(gammas), max(gammas), len(gammas) * cpu_count())
73
+ start_time = time()
74
+ _ = repeated_parallel_leiden_from_gammas(G, pool_gammas)
75
+ pool_duration = time() - start_time
76
+
77
+ speedup = len(pool_gammas) / len(gammas) * duration / pool_duration
78
+ return speedup / target_speedup
79
+
80
+ @staticmethod
81
+ def run_multilayer_graph_parallelization(G_intralayer, G_interlayer, layer_membership, gammas, omegas):
82
+ target_speedup = determine_target_parallelization_speedup()
83
+
84
+ start_time = time()
85
+ _ = repeated_leiden_from_gammas_omegas(G_intralayer, G_interlayer, layer_membership, gammas, omegas)
86
+ duration = time() - start_time
87
+
88
+ pool_gammas = np.linspace(min(gammas), max(gammas), int(len(gammas) * np.sqrt(cpu_count())))
89
+ pool_omegas = np.linspace(min(omegas), max(omegas), int(len(omegas) * np.sqrt(cpu_count())))
90
+ start_time = time()
91
+ _ = repeated_parallel_leiden_from_gammas_omegas(
92
+ G_intralayer, G_interlayer, layer_membership, pool_gammas, pool_omegas
93
+ )
94
+ pool_duration = time() - start_time
95
+
96
+ speedup = len(pool_gammas) * len(pool_omegas) / len(gammas) / len(omegas) * duration / pool_duration
97
+ return speedup / target_speedup
98
+
99
+ def test_tiny_singlelayer_graph_many_runs(self):
100
+ """Single-threaded equivalent is 25k runs on G(n=34, m=78)."""
101
+ G = ig.Graph.Famous("Zachary")
102
+ gammas = np.linspace(0.0, 4.0, 25000)
103
+ parallelization = self.run_singlelayer_graph_parallelization(G, gammas)
104
+ self.assertGreater(parallelization, PERFORMANCE_TARGET_RELATIVE_TO_PERFECT_SCALING)
105
+
106
+ def test_larger_singlelayer_graph_few_runs(self):
107
+ """Single-threaded equivalent is 50 runs on G(n=10000, m=40000)."""
108
+ G = generate_connected_ER(n=10000, m=40000, directed=False)
109
+ gammas = np.linspace(0.0, 2.0, 50)
110
+ parallelization = self.run_singlelayer_graph_parallelization(G, gammas)
111
+ self.assertGreater(parallelization, PERFORMANCE_TARGET_RELATIVE_TO_PERFECT_SCALING)
112
+
113
+ def test_tiny_multilayer_graph_many_runs(self):
114
+ """Single-threaded equivalent is 10k runs on G(n=50, m=150)."""
115
+ G_intralayer, layer_membership = generate_multilayer_intralayer_SBM(
116
+ copying_probability=0.9, p_in=0.8, p_out=0.2, first_layer_membership=[0] * 5 + [1] * 5, num_layers=5
117
+ )
118
+ interlayer_edges = [(10 * layer + v, 10 * layer + v + 10)
119
+ for layer in range(5 - 1) for v in range(10)]
120
+ G_interlayer = ig.Graph(interlayer_edges, directed=True)
121
+
122
+ gammas = np.linspace(0.0, 2.0, 100)
123
+ omegas = np.linspace(0.0, 2.0, 100)
124
+ parallelization = self.run_multilayer_graph_parallelization(G_intralayer, G_interlayer,
125
+ layer_membership, gammas, omegas)
126
+ self.assertGreater(parallelization, PERFORMANCE_TARGET_RELATIVE_TO_PERFECT_SCALING)
127
+
128
+ def test_larger_multilayer_graph_few_runs(self):
129
+ """Single-threaded equivalent is 49 runs on approximately G(n=2500, m=15000)."""
130
+ G_intralayer, layer_membership = generate_multilayer_intralayer_SBM(
131
+ copying_probability=0.9, p_in=0.15, p_out=0.05, first_layer_membership=[0] * 50 + [1] * 50, num_layers=25
132
+ )
133
+ interlayer_edges = [(100 * layer + v, 100 * layer + v + 100)
134
+ for layer in range(25 - 1) for v in range(100)]
135
+ G_interlayer = ig.Graph(interlayer_edges, directed=True)
136
+
137
+ gammas = np.linspace(0.0, 2.0, 7)
138
+ omegas = np.linspace(0.0, 2.0, 7)
139
+ parallelization = self.run_multilayer_graph_parallelization(G_intralayer, G_interlayer,
140
+ layer_membership, gammas, omegas)
141
+ self.assertGreater(parallelization, PERFORMANCE_TARGET_RELATIVE_TO_PERFECT_SCALING)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ seed(0)
146
+ unittest.main()
@@ -1,12 +1,11 @@
1
- from .progress import Progress
2
1
  import functools
3
2
  import igraph as ig
4
3
  import leidenalg
5
4
  from math import ceil
6
5
  from multiprocessing import Pool, cpu_count
6
+ from tqdm import tqdm
7
7
  import numpy as np
8
8
  import psutil
9
- import warnings
10
9
 
11
10
  LOW_MEMORY_THRESHOLD = 1e9 # 1 GB
12
11
 
@@ -50,6 +49,11 @@ def singlelayer_leiden(G, gamma, return_partition=False):
50
49
  return tuple(partition.membership)
51
50
 
52
51
 
52
+ def _wrapped_singlelayer_leiden(args):
53
+ """Wrapped singlelayer_leiden() for use in multiprocessing.Pool.imap_unordered."""
54
+ return singlelayer_leiden(*args)
55
+
56
+
53
57
  def leiden_part(G):
54
58
  return leidenalg.RBConfigurationVertexPartition(G)
55
59
 
@@ -68,8 +72,6 @@ def split_intralayer_leiden_graph(G_intralayer, layer_membership):
68
72
 
69
73
  This is needed since leidenalg lacks support for faster multilayer optimization.
70
74
 
71
- WARNING: Optimization can be EXTREMELY slow! Leidenalg does not properly implement multilayer optimization.
72
-
73
75
  :param G_intralayer: intralayer graph of interest
74
76
  :type G_intralayer: igraph.Graph
75
77
  :param layer_vec: list of each vertex's layer membership
@@ -77,9 +79,6 @@ def split_intralayer_leiden_graph(G_intralayer, layer_membership):
77
79
  :return: list of intralayer networks
78
80
  :rtype: list[igraph.Graph]
79
81
  """
80
- warnings.warn("You are using Leiden multilayer modularity optimization. THIS CAN BE EXTREMELY SLOW! "
81
- "leidenalg's implementation is inefficient, especially when there are many layers.")
82
-
83
82
  # internally use hashable objects for memoization
84
83
  return _split_leiden_graph_layers_cached(n=G_intralayer.vcount(), G_es=tuple(G_intralayer.es),
85
84
  is_directed=G_intralayer.is_directed(),
@@ -108,7 +107,8 @@ def _split_leiden_graph_layers_cached(n, G_es, is_directed, layer_membership):
108
107
  def multilayer_leiden(G_intralayer, G_interlayer, layer_vec, gamma, omega, optimiser=None, return_partition=False):
109
108
  r"""Run the Leiden modularity maximization algorithm at a single (:math:`\gamma, \omega`) value.
110
109
 
111
- WARNING: Optimization can be EXTREMELY slow! Leidenalg does not properly implement multilayer optimization.
110
+ WARNING: Optimization can be EXTREMELY slow for large numbers of layers! Leidenalg does not properly implement
111
+ multilayer optimization.
112
112
 
113
113
  :param G_intralayer: intralayer graph of interest
114
114
  :type G_intralayer: igraph.Graph
@@ -150,6 +150,11 @@ def multilayer_leiden(G_intralayer, G_interlayer, layer_vec, gamma, omega, optim
150
150
  return tuple(intralayer_parts[0].membership)
151
151
 
152
152
 
153
+ def _wrapped_multilayer_leiden(args):
154
+ """Wrapped multilayer_leiden() for use in multiprocessing.Pool.imap_unordered."""
155
+ return multilayer_leiden(*args)
156
+
157
+
153
158
  def multilayer_leiden_part(G_intralayer, G_interlayer, layer_membership):
154
159
  if 'weight' not in G_intralayer.es:
155
160
  G_intralayer.es['weight'] = [1.0] * G_intralayer.ecount()
@@ -178,51 +183,29 @@ def repeated_leiden_from_gammas(G, gammas):
178
183
  return {sorted_tuple(singlelayer_leiden(G, gamma)) for gamma in gammas}
179
184
 
180
185
 
181
- def repeated_parallel_leiden_from_gammas(G, gammas, show_progress=True, chunk_dispatch=True):
186
+ def repeated_parallel_leiden_from_gammas(G, gammas, show_progress=True):
182
187
  r"""Runs the Leiden modularity maximization algorithm at each provided :math:`\gamma` value, using all CPU cores.
183
188
 
184
189
  :param G: graph of interest
185
190
  :type G: igraph.Graph
186
191
  :param gammas: list of gammas (resolution parameters) to run Leiden at
187
192
  :type gammas: list[float]
188
- :param show_progress: if True, render a progress bar. This will only work if ``chunk_dispatch`` is also True
193
+ :param show_progress: if True, render a progress bar
189
194
  :type show_progress: bool
190
- :param chunk_dispatch: if True, dispatch parallel work in chunks. Setting this to False may increase performance,
191
- but can lead to out-of-memory issues
192
- :type chunk_dispatch: bool
193
195
  :return: a set of all unique partitions returned by the Leiden algorithm
194
196
  :rtype: set of tuple[int]
195
197
  """
196
-
197
- pool = Pool(processes=cpu_count())
198
198
  total = set()
199
-
200
- chunk_size = len(gammas) // 99
201
- if chunk_size > 0 and chunk_dispatch:
202
- chunk_params = ([(G, g) for g in gammas[i:i + chunk_size]] for i in range(0, len(gammas), chunk_size))
203
- else:
204
- chunk_params = [[(G, g) for g in gammas]]
205
- chunk_size = len(gammas)
206
-
207
- if show_progress:
208
- progress = Progress(ceil(len(gammas) / chunk_size))
209
-
210
- for chunk in chunk_params:
211
- for partition in pool.starmap(singlelayer_leiden, chunk):
212
- total.add(sorted_tuple(partition))
213
-
199
+ pool_chunk_size = max(1, len(gammas) // (cpu_count() * 100))
200
+ with Pool(processes=cpu_count()) as pool:
201
+ pool_iterator = pool.imap_unordered(_wrapped_singlelayer_leiden, [(G, g) for g in gammas],
202
+ chunksize=pool_chunk_size)
214
203
  if show_progress:
215
- progress.increment()
216
-
217
- if psutil.virtual_memory().available < LOW_MEMORY_THRESHOLD:
218
- # Reinitialize pool to get around an apparent memory leak in multiprocessing
219
- pool.close()
220
- pool = Pool(processes=cpu_count())
204
+ pool_iterator = tqdm(pool_iterator, total=len(gammas))
221
205
 
222
- if show_progress:
223
- progress.done()
206
+ for partition in pool_iterator:
207
+ total.add(sorted_tuple(partition))
224
208
 
225
- pool.close()
226
209
  return total
227
210
 
228
211
 
@@ -232,10 +215,13 @@ def repeated_leiden_from_gammas_omegas(G_intralayer, G_interlayer, layer_vec, ga
232
215
 
233
216
 
234
217
  def repeated_parallel_leiden_from_gammas_omegas(G_intralayer, G_interlayer, layer_vec, gammas, omegas,
235
- show_progress=True, chunk_dispatch=True):
218
+ show_progress=True):
236
219
  """
237
220
  Runs leidenalg at each gamma and omega in ``gammas`` and ``omegas``, using all CPU cores available.
238
221
 
222
+ WARNING: Optimization can be EXTREMELY slow for large numbers of layers! Leidenalg does not properly implement
223
+ multilayer optimization.
224
+
239
225
  :param G_intralayer: intralayer graph of interest
240
226
  :type G_intralayer: igraph.Graph
241
227
  :param G_interlayer: interlayer graph of interest
@@ -248,44 +234,23 @@ def repeated_parallel_leiden_from_gammas_omegas(G_intralayer, G_interlayer, laye
248
234
  :type omegas: list[float]
249
235
  :param show_progress: if True, render a progress bar
250
236
  :type show_progress: bool
251
- :param chunk_dispatch: if True, dispatch parallel work in chunks. Setting this to False may increase performance,
252
- but can lead to out-of-memory issues
253
- :type chunk_dispatch: bool
254
237
  :return: a set of all unique partitions encountered
255
238
  :rtype: set of tuple[int]
256
239
  """
257
240
  resolution_parameter_points = [(gamma, omega) for gamma in gammas for omega in omegas]
258
241
 
259
- pool = Pool(processes=cpu_count())
260
242
  total = set()
261
-
262
- chunk_size = len(resolution_parameter_points) // 99
263
- if chunk_size > 0 and chunk_dispatch:
264
- chunk_params = ([(G_intralayer, G_interlayer, layer_vec, gamma, omega)
265
- for gamma, omega in resolution_parameter_points[i:i + chunk_size]]
266
- for i in range(0, len(resolution_parameter_points), chunk_size))
267
- else:
268
- chunk_params = [[(G_intralayer, G_interlayer, layer_vec, gamma, omega)
269
- for gamma, omega in resolution_parameter_points]]
270
- chunk_size = len(gammas)
271
-
272
- if show_progress:
273
- progress = Progress(ceil(len(resolution_parameter_points) / chunk_size))
274
-
275
- for chunk in chunk_params:
276
- for partition in pool.starmap(multilayer_leiden, chunk):
277
- total.add(sorted_tuple(partition))
278
-
243
+ pool_chunk_size = max(1, len(resolution_parameter_points) // (cpu_count() * 100))
244
+ with Pool(processes=cpu_count()) as pool:
245
+ pool_iterator = pool.imap_unordered(
246
+ _wrapped_multilayer_leiden,
247
+ [(G_intralayer, G_interlayer, layer_vec, gamma, omega) for gamma, omega in resolution_parameter_points],
248
+ chunksize=pool_chunk_size
249
+ )
279
250
  if show_progress:
280
- progress.increment()
281
-
282
- if psutil.virtual_memory().available < LOW_MEMORY_THRESHOLD:
283
- # Reinitialize pool to get around an apparent memory leak in multiprocessing
284
- pool.close()
285
- pool = Pool(processes=cpu_count())
251
+ pool_iterator = tqdm(pool_iterator, total=len(resolution_parameter_points))
286
252
 
287
- if show_progress:
288
- progress.done()
253
+ for partition in pool_iterator:
254
+ total.add(sorted_tuple(partition))
289
255
 
290
- pool.close()
291
256
  return total
@@ -534,12 +534,25 @@ def prune_to_multilayer_stable_partitions(G_intralayer, G_interlayer, layer_vec,
534
534
  parameter estimates are within the provided ``gamma_start``, ``gamma_end``, ``omega_start``, and ``omega_end``
535
535
  bounds.
536
536
 
537
+ There are three network layer topology models available, all from Pamfil et al.
538
+
539
+ * **"temporal"**: Interlayer edges always connect copies of a node from one layer to the next, often representing
540
+ interactions that change over time.
541
+ * **"multilevel"**: Interlayer edges connect a hierarchy of monolayer networks from one layer to the next. This is
542
+ more general than temporal networks, as nodes can connect arbitrarily to nodes in the next layer. These often
543
+ represent inclusion relationships, such as cities to counties, counties to states, and states to countries.
544
+ * **"multiplex"**: Each layer represents a type of interaction, making the entire multilayer network akin to an
545
+ edge-colored multigraph (each type of edge has its own layer). This model is unique in that there is no natural
546
+ ordering of layers, and the resulting theory requires some analytical simplifications, making the resulting
547
+ parameter estimation the least robust of the three models.
548
+
537
549
  See https://doi.org/10.1038/s41598-022-20142-6 for more details.
538
550
 
539
- NOTE: This method truncates omega estimates to ``omega_end - 1e-3`` in order to properly identify stable partitions
540
- with infinite interlayer coupling estimates (e.g. when all membership labels persist across layers). If
541
- ``omega_end`` is set too low, such partitions may be incorrectly identified as stable. As such, you should be
542
- somewhat wary of the returned partitions with zero community structure differences across layers.
551
+ NOTE: This method will truncate omega estimates to ``omega_end - 1e-3`` (and raise a warning) if needed to properly
552
+ identify stable partitions with very large or infinite interlayer coupling estimates (e.g., when all membership
553
+ labels persist across layers). If ``omega_end`` is set too low, these partitions may be incorrectly identified as
554
+ stable. Conversely, some partitions with large omega estimates might be misclassified as not stable. Therefore, be
555
+ cautious of returned partitions with little or no community structure differences across layers.
543
556
 
544
557
  :param G_intralayer: intralayer graph of interest
545
558
  :type G_intralayer: igraph.Graph
@@ -599,6 +612,11 @@ def prune_to_multilayer_stable_partitions(G_intralayer, G_interlayer, layer_vec,
599
612
  omega_start, omega_end)
600
613
  domains_with_estimates = domains_to_gamma_omega_estimates(G_intralayer, G_interlayer, layer_vec, domains, model)
601
614
 
615
+ if any(o_est >= omega_end for _, _, g_est, o_est in domains_with_estimates if g_est is not None):
616
+ warnings.warn(f"We are truncating some omega estimates to your choice of omega_end={omega_end}. You should "
617
+ f"check that this accurately captures the high-omega behavior of the partition domains. "
618
+ f"Be cautious of partitions with little or no community structure differences across layers!")
619
+
602
620
  # Truncate infinite omega solutions to our maximum omega
603
621
  domains_with_estimates = [(polyverts, membership, g_est, min(o_est, omega_end - 1e-3))
604
622
  for polyverts, membership, g_est, o_est in domains_with_estimates
@@ -18,7 +18,7 @@ def plot_estimates(gamma_estimates):
18
18
  """Plot partition dominance ranges with gamma estimates.
19
19
 
20
20
  :param gamma_estimates: gamma estimates as returned from
21
- :meth:`~modularitypruning.plotting.ranges_to_gamma_estimates`
21
+ :meth:`~modularitypruning.parameter_estimation_utilities.ranges_to_gamma_estimates`
22
22
  """
23
23
 
24
24
  ax = plt.gca()
@@ -69,7 +69,7 @@ def plot_estimates(gamma_estimates):
69
69
  # length_includes_head=True, alpha=0.5, zorder=2, **{"overhang": 0.5})
70
70
 
71
71
 
72
- def plot_2d_domains(domains, xlim, ylim, flip_axes=False, use_current_axes=False):
72
+ def plot_2d_domains(domains, xlim, ylim, flip_axes=True, use_current_axes=False):
73
73
  """Plot partition dominance ranges in the (gamma, omega) plane, using the domains from CHAMP_3D.
74
74
 
75
75
  Limits output to xlim and ylim dimensions. Note that the plotting here has x=gamma and y=omega.
@@ -91,7 +91,7 @@ def plot_2d_domains(domains, xlim, ylim, flip_axes=False, use_current_axes=False
91
91
  patches.append(polygon)
92
92
 
93
93
  cnorm = matplotlib.colors.Normalize(vmin=0, vmax=len(domains))
94
- cmap = matplotlib.cm.get_cmap("Set1")
94
+ cmap = plt.get_cmap("Set1")
95
95
  available_colors = {cmap(cnorm(i)) for i in range(len(domains))}
96
96
 
97
97
  if len(available_colors) == len(domains):
@@ -207,7 +207,7 @@ def plot_2d_domains_with_num_communities(domains_with_estimates, xlim, ylim, fli
207
207
  plt.ylim(ylim)
208
208
 
209
209
 
210
- def plot_2d_domains_with_ami(domains_with_estimates, ground_truth, xlim, ylim, flip_axes=False):
210
+ def plot_2d_domains_with_ami(domains_with_estimates, ground_truth, xlim, ylim, flip_axes=True):
211
211
  """Plot partition dominance ranges in the (gamma, omega) plane, using the domains from CHAMP_3D and coloring by the
212
212
  AMI between the partitions and ground truth.
213
213