LGPA 1.0.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.
@@ -0,0 +1,26 @@
1
+ import networkx as nx
2
+ from ._lgpa_core import LGPA_Core
3
+
4
+ class LGPA:
5
+ """
6
+ Log-Gravity Propagation Algorithm (LGPA)
7
+ """
8
+ def __init__(self, graph):
9
+ self.G = graph.to_undirected()
10
+
11
+ self.nodes = list(self.G.nodes())
12
+ self.node_to_idx = {node: i for i, node in enumerate(self.nodes)}
13
+ self.idx_to_node = {i: node for i, node in enumerate(self.nodes)}
14
+
15
+ n = len(self.nodes)
16
+ edges = [(self.node_to_idx[u], self.node_to_idx[v]) for u, v in self.G.edges()]
17
+
18
+ self.core = LGPA_Core(n, edges)
19
+
20
+ def fit_predict(self, max_iter=50):
21
+ if not self.nodes:
22
+ return {}
23
+
24
+ raw_labels = self.core.fit_predict(max_iter)
25
+
26
+ return {self.idx_to_node[idx]: label for idx, label in raw_labels.items()}
@@ -0,0 +1,194 @@
1
+ #include <pybind11/pybind11.h>
2
+ #include <pybind11/stl.h>
3
+ #include <vector>
4
+ #include <cmath>
5
+ #include <algorithm>
6
+ #include <numeric>
7
+ #include <unordered_map>
8
+
9
+ namespace py = pybind11;
10
+
11
+ class LGPA_Core {
12
+ private:
13
+ int n;
14
+ int m;
15
+ std::vector<std::vector<int>> adj;
16
+ std::vector<int> degree;
17
+ std::vector<std::unordered_map<int, double>> sj;
18
+ std::vector<double> strength;
19
+ double core_threshold;
20
+
21
+ public:
22
+ LGPA_Core(int num_nodes, const std::vector<std::pair<int, int>>& edges) {
23
+ n = num_nodes;
24
+ m = edges.size();
25
+ adj.resize(n);
26
+ degree.resize(n, 0);
27
+ sj.resize(n);
28
+ strength.resize(n, 0.0);
29
+
30
+ for (const auto& edge : edges) {
31
+ int u = edge.first;
32
+ int v = edge.second;
33
+ adj[u].push_back(v);
34
+ adj[v].push_back(u);
35
+ degree[u]++;
36
+ degree[v]++;
37
+ }
38
+
39
+ for (int i = 0; i < n; ++i) {
40
+ std::sort(adj[i].begin(), adj[i].end());
41
+ }
42
+
43
+ std::vector<double> sj_values;
44
+ sj_values.reserve(m);
45
+
46
+ // STEP 1: Laplacian-Smoothed Jaccard
47
+ for (const auto& edge : edges) {
48
+ int u = edge.first;
49
+ int v = edge.second;
50
+
51
+ int common = 0;
52
+ auto it_u = adj[u].begin();
53
+ auto it_v = adj[v].begin();
54
+ while (it_u != adj[u].end() && it_v != adj[v].end()) {
55
+ if (*it_u < *it_v) ++it_u;
56
+ else if (*it_v < *it_u) ++it_v;
57
+ else {
58
+ common++;
59
+ ++it_u; ++it_v;
60
+ }
61
+ }
62
+
63
+ int union_size = degree[u] + degree[v] - common;
64
+ double sim = (common + 1.0) / (union_size + 1.0);
65
+ sj[u][v] = sim;
66
+ sj[v][u] = sim;
67
+ sj_values.push_back(sim);
68
+ }
69
+
70
+ // STEP 2: Intrinsic Structural Strength
71
+ for (int u = 0; u < n; ++u) {
72
+ double s = 0.0;
73
+ for (int v : adj[u]) {
74
+ s += sj[u][v];
75
+ }
76
+ strength[u] = s;
77
+ }
78
+
79
+ // STEP 3: Statistical Regime Thresholds
80
+ double mean_sj = 0.0, std_sj = 0.0, sci = 0.0;
81
+ if (!sj_values.empty()) {
82
+ double sum = std::accumulate(sj_values.begin(), sj_values.end(), 0.0);
83
+ mean_sj = sum / sj_values.size();
84
+ double sq_sum = std::inner_product(sj_values.begin(), sj_values.end(), sj_values.begin(), 0.0);
85
+ // std::max guards against tiny negative variance from round-off.
86
+ double var = std::max(0.0, sq_sum / sj_values.size() - mean_sj * mean_sj);
87
+ std_sj = std::sqrt(var);
88
+ sci = std_sj / mean_sj;
89
+ }
90
+
91
+ double w_struct = 1.0 - std::exp(-sci);
92
+ core_threshold = mean_sj + (w_struct * std_sj);
93
+ }
94
+
95
+ std::unordered_map<int, int> fit_predict(int max_iter) {
96
+ if (n == 0) return {};
97
+
98
+ // PHASE 1: Coring
99
+ std::vector<int> merged_to(n);
100
+ std::iota(merged_to.begin(), merged_to.end(), 0);
101
+
102
+ std::vector<int> nodes_desc(n);
103
+ std::iota(nodes_desc.begin(), nodes_desc.end(), 0);
104
+ std::sort(nodes_desc.begin(), nodes_desc.end(), [&](int a, int b) {
105
+ if (strength[a] != strength[b]) return strength[a] > strength[b];
106
+ return a > b;
107
+ });
108
+
109
+ for (int u : nodes_desc) {
110
+ if (adj[u].empty()) continue;
111
+
112
+ int best_nbr = adj[u][0];
113
+ double max_sj = sj[u][best_nbr];
114
+ for (int v : adj[u]) {
115
+ if (sj[u][v] > max_sj) {
116
+ max_sj = sj[u][v];
117
+ best_nbr = v;
118
+ }
119
+ }
120
+
121
+ if (max_sj > core_threshold) {
122
+ if (strength[best_nbr] > strength[u] || (strength[best_nbr] == strength[u] && best_nbr > u)) {
123
+ merged_to[u] = best_nbr;
124
+ }
125
+ }
126
+ }
127
+
128
+ // Path Compression
129
+ for (int u = 0; u < n; ++u) {
130
+ int curr = u;
131
+ while (curr != merged_to[curr]) {
132
+ curr = merged_to[curr];
133
+ }
134
+ merged_to[u] = curr;
135
+ }
136
+
137
+ // PHASE 2: Log-Gravity Propagation
138
+ std::vector<int> labels = merged_to;
139
+ std::vector<int> nodes_asc = nodes_desc;
140
+ std::reverse(nodes_asc.begin(), nodes_asc.end());
141
+
142
+ const double EULER_E = std::exp(1.0);
143
+
144
+ for (int iter = 0; iter < max_iter; ++iter) {
145
+ bool changed = false;
146
+ for (int u : nodes_asc) {
147
+ if (adj[u].empty()) continue;
148
+
149
+ std::unordered_map<int, double> scores;
150
+ for (int v : adj[u]) {
151
+ double weight = sj[u][v] * std::log(strength[v] + EULER_E);
152
+ scores[labels[v]] += weight;
153
+ }
154
+
155
+ if (scores.empty()) continue;
156
+
157
+ int best_label = -1;
158
+ double max_score = -1.0;
159
+ for (const auto& pair : scores) {
160
+ if (pair.second > max_score) {
161
+ max_score = pair.second;
162
+ best_label = pair.first;
163
+ }
164
+ }
165
+
166
+ if (labels[u] != best_label) {
167
+ labels[u] = best_label;
168
+ changed = true;
169
+ }
170
+ }
171
+ if (!changed) break;
172
+ }
173
+
174
+ std::unordered_map<int, int> final_labels;
175
+ std::unordered_map<int, int> label_mapping;
176
+ int current_id = 0;
177
+
178
+ for (int u = 0; u < n; ++u) {
179
+ int l = labels[u];
180
+ if (label_mapping.find(l) == label_mapping.end()) {
181
+ label_mapping[l] = current_id++;
182
+ }
183
+ final_labels[u] = label_mapping[l];
184
+ }
185
+
186
+ return final_labels;
187
+ }
188
+ };
189
+
190
+ PYBIND11_MODULE(_lgpa_core, m) {
191
+ py::class_<LGPA_Core>(m, "LGPA_Core")
192
+ .def(py::init<int, const std::vector<std::pair<int, int>>&>())
193
+ .def("fit_predict", &LGPA_Core::fit_predict, py::arg("max_iter") = 50, py::call_guard<py::gil_scoped_release>());
194
+ }
@@ -0,0 +1,274 @@
1
+ Metadata-Version: 2.4
2
+ Name: LGPA
3
+ Version: 1.0.0
4
+ Summary: Log-Gravity Propagation Algorithm (LGPA) for community detection in complex networks
5
+ Author: Anasse Tahboun
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tahbounanas/LGPA
8
+ Project-URL: Repository, https://github.com/tahbounanas/LGPA
9
+ Keywords: community-detection,graph,networks,label-propagation,LGPA,Complex-Networks,Graph-Mining,Social-Network-Analysis,Log-Gravity-Propagation-Algorithm
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: networkx>=2.5
14
+ Dynamic: license-file
15
+
16
+ # LGPA — Log-Gravity Propagation Algorithm
17
+
18
+ A deterministic, tuning-free community detection algorithm for complex networks, implemented in C++ (via [pybind11](https://github.com/pybind/pybind11)) with a simple Python/[NetworkX](https://networkx.org/) interface.
19
+
20
+ LGPA resolves two well-known weaknesses of standard Label Propagation — run-to-run instability and the formation of oversized *"monster"* communities — using a Laplacian-smoothed Jaccard similarity, a statistical Coring phase, and a log-gravity propagation rule that logarithmically dampens the influence of high-degree hubs. Its threshold and update rule are derived entirely from the graph's own structure, so there is nothing to tune, and its native C++ core scales to networks of tens of thousands of nodes in seconds.
21
+
22
+ This repository is the reference implementation for the paper *"LGPA: Log-Gravity Propagation Algorithm for Community Detection in Complex Networks"* (ASONAM 2026, Research Track). See [Citation](#citation).
23
+
24
+ ---
25
+
26
+ ## Requirements
27
+
28
+ Installing LGPA compiles a small C++ extension, so you need a C++ compiler in addition to Python. Everything else is installed automatically.
29
+
30
+ | Requirement | Notes |
31
+ |---|---|
32
+ | **Python** | 3.8 or newer |
33
+ | **A C++ compiler** | Windows: [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) (select the *"Desktop development with C++"* workload). macOS: `xcode-select --install`. Linux: `sudo apt install build-essential` (or your distro's equivalent). |
34
+ | **pybind11** | Installed automatically during the build. |
35
+ | **networkx** | Installed automatically as a dependency. |
36
+
37
+ ---
38
+
39
+ ## Installation
40
+
41
+ ### Option 1 — Install directly from GitHub (recommended)
42
+
43
+ ```bash
44
+ pip install git+https://github.com/tahbounanas/LGPA.git
45
+ ```
46
+
47
+ ### Option 2 — Clone, then install
48
+
49
+ ```bash
50
+ git clone https://github.com/tahbounanas/LGPA.git
51
+ cd LGPA
52
+ pip install .
53
+ ```
54
+
55
+ To develop (edit the C++/Python and rebuild in place), use `pip install -e .` instead.
56
+
57
+ `pip` handles `pybind11`, `networkx`, and compiling the extension. After it finishes, LGPA is importable from any folder in that Python environment.
58
+
59
+ ---
60
+
61
+ ## Usage
62
+
63
+ ```python
64
+ import networkx as nx
65
+ from LGPA import LGPA
66
+
67
+ # A simple graph with two communities of 10 nodes each,
68
+ # joined by a single bridge edge.
69
+ G = nx.Graph()
70
+ for start in (0, 10):
71
+ block = range(start, start + 10)
72
+ for i in block:
73
+ for j in block:
74
+ if i < j:
75
+ G.add_edge(i, j) # dense links inside each community
76
+ G.add_edge(9, 10) # one bridge between the two communities
77
+
78
+ # Run LGPA
79
+ lgpa = LGPA(G)
80
+ partition = lgpa.fit_predict(max_iter=50) # or simply partition = LGPA(G).fit_predict()
81
+
82
+
83
+ # partition: {node -> community_id}
84
+ print("Communities found:", len(set(partition.values())))
85
+ print(partition)
86
+ ```
87
+
88
+ Expected output:
89
+
90
+ ```
91
+ Communities found: 2
92
+ {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0,
93
+ 10: 1, 11: 1, 12: 1, 13: 1, 14: 1, 15: 1, 16: 1, 17: 1, 18: 1, 19: 1}
94
+ ```
95
+
96
+ | Input graph | LGPA result |
97
+ |:---:|:---:|
98
+ | ![Input graph](lgpa_before.jpg) | ![LGPA communities](lgpa_after.jpg) |
99
+
100
+ LGPA correctly separates the two communities (nodes 0–9 and 10–19) despite the bridge edge linking them.
101
+
102
+ `fit_predict` returns a dictionary mapping each node to its community id, so it works with any node labels (integers, strings, etc.), not just consecutive integers. LGPA is fully deterministic: the same graph always yields the same partition, with no random seed.
103
+
104
+ **Parameters**
105
+
106
+ - `max_iter` (int, default `50`): a safeguard cap on the number of propagation sweeps. It is not a tuned parameter — the loop stops on its own once labels stabilize, which in practice happens well before this cap.
107
+
108
+ ---
109
+
110
+ ## Example on a real dataset (Thiers)
111
+
112
+ The [`Datasets/`](Datasets/) folder contains the **Thiers** high-school contact network (327 nodes, 9 ground-truth classes): `Thiers.gml` is the graph and `Thiers_GR.txt` holds the ground-truth class label of each node (in GML node order).
113
+
114
+ > This example also uses `scikit-learn` and `scipy` for the metrics (`pip install scikit-learn scipy`); they are not required by LGPA itself.
115
+
116
+ ```python
117
+ import json
118
+ import time
119
+ import networkx as nx
120
+ from sklearn.metrics import (
121
+ normalized_mutual_info_score as nmi_score,
122
+ adjusted_rand_score as ari_score,
123
+ f1_score,
124
+ )
125
+ from scipy.optimize import linear_sum_assignment
126
+ from sklearn.metrics import confusion_matrix
127
+ import numpy as np
128
+
129
+ from LGPA import LGPA
130
+
131
+ # Load the graph and its ground-truth labels
132
+ G = nx.read_gml("Datasets/Thiers.gml", label="id").to_undirected()
133
+ G.remove_edges_from(nx.selfloop_edges(G))
134
+ gt_labels = json.load(open("Datasets/Thiers_GR.txt"))
135
+
136
+ nodes = list(G.nodes())
137
+ gt = {nodes[i]: gt_labels[i] for i in range(len(nodes))} # GR is in GML node order
138
+
139
+ # Run LGPA (timed)
140
+ start = time.perf_counter()
141
+ partition = LGPA(G).fit_predict()
142
+ runtime = time.perf_counter() - start
143
+
144
+ # Encode labels as integers
145
+ classes = sorted(set(gt.values()))
146
+ cmap = {c: i for i, c in enumerate(classes)}
147
+ y_true = np.array([cmap[gt[n]] for n in nodes])
148
+ y_pred = np.array([partition[n] for n in nodes])
149
+
150
+ # NMI and ARI
151
+ nmi = nmi_score(y_true, y_pred)
152
+ ari = ari_score(y_true, y_pred)
153
+
154
+ # Macro-F1 (align predicted communities to ground-truth classes via Hungarian matching)
155
+ labels_p = sorted(set(y_pred))
156
+ C = confusion_matrix(y_true, [labels_p.index(x) for x in y_pred])
157
+ n = max(C.shape)
158
+ Cp = np.zeros((n, n)); Cp[:C.shape[0], :C.shape[1]] = C
159
+ r, c = linear_sum_assignment(-Cp)
160
+ mapping = {labels_p[cc]: rr for rr, cc in zip(r, c) if cc < len(labels_p)}
161
+ y_pred_aligned = np.array([mapping.get(x, -1) for x in y_pred])
162
+ f1 = f1_score(y_true, y_pred_aligned, average="macro")
163
+
164
+ print(f"Communities found: {len(set(y_pred))}")
165
+ print(f"NMI: {nmi:.3f}")
166
+ print(f"ARI: {ari:.3f}")
167
+ print(f"F1 : {f1:.3f}")
168
+ print(f"Runtime: {runtime:.3f} s")
169
+ ```
170
+
171
+ Output:
172
+
173
+ ```
174
+ Communities found: 9
175
+ NMI: 0.970
176
+ ARI: 0.964
177
+ F1 : 0.979
178
+ Runtime: 0.116 s
179
+ ```
180
+
181
+ LGPA recovers all 9 classes with near-perfect agreement to the ground truth (NMI 0.970, ARI 0.964). In the two coloured figures below, each detected community has been matched to its best-corresponding ground-truth class (via Hungarian assignment) and drawn in that class's colour, so the ground-truth and LGPA plots line up directly. The metrics, not the colours, are what quantify the agreement.
182
+
183
+ | Input network | Ground truth | LGPA communities |
184
+ |:---:|:---:|:---:|
185
+ | ![Thiers input](thiers_before.jpg) | ![Thiers ground truth](thiers_groundtruth.jpg) | ![Thiers LGPA](thiers_after.jpg) |
186
+
187
+
188
+ ### Reproducing the figures
189
+
190
+ The three plots above are produced with the snippet below (requires `matplotlib`, `pip install matplotlib`). A single shared layout is used so the input, ground-truth, and LGPA figures line up node-for-node.
191
+
192
+ ```python
193
+ import json
194
+ import networkx as nx
195
+ import matplotlib.pyplot as plt
196
+ import matplotlib.patches as mpatches
197
+ from LGPA import LGPA
198
+
199
+ # Load graph + ground truth
200
+ G = nx.read_gml("Datasets/Thiers.gml", label="id").to_undirected()
201
+ G.remove_edges_from(nx.selfloop_edges(G))
202
+ gt_labels = json.load(open("Datasets/Thiers_GR.txt"))
203
+ nodes = list(G.nodes())
204
+ gt = {nodes[i]: gt_labels[i] for i in range(len(nodes))}
205
+
206
+ # Run LGPA
207
+ partition = LGPA(G).fit_predict()
208
+
209
+ # One shared layout so all three plots are comparable
210
+ pos = nx.spring_layout(G, seed=42, k=0.3, iterations=60)
211
+ cmap = plt.colormaps["tab10"]
212
+
213
+ def draw(color_by, title, filename, legend=None):
214
+ plt.figure(figsize=(8, 8))
215
+ nx.draw_networkx_edges(G, pos, alpha=0.15, width=0.5)
216
+ nx.draw_networkx_nodes(G, pos, node_color=color_by, edgecolors="#333333",
217
+ linewidths=0.4, node_size=90)
218
+ if legend:
219
+ plt.legend(handles=legend, loc="upper left", fontsize=8)
220
+ plt.title(title, fontsize=14)
221
+ plt.axis("off"); plt.tight_layout()
222
+ plt.savefig(filename, dpi=150, bbox_inches="tight"); plt.close()
223
+
224
+ # 1) Input graph (grey, unlabelled)
225
+ draw("#cccccc", "Thiers network - input", "thiers_before.jpg")
226
+
227
+ # 2) Ground truth (coloured by true class, with legend)
228
+ classes = sorted(set(gt.values()))
229
+ gt_colors = [cmap(classes.index(gt[n])) for n in G.nodes()]
230
+ handles = [mpatches.Patch(color=cmap(i), label=c) for i, c in enumerate(classes)]
231
+ draw(gt_colors, f"Thiers - ground truth ({len(classes)} classes)",
232
+ "thiers_groundtruth.jpg", legend=handles)
233
+
234
+ # 3) LGPA result — colours matched to ground-truth classes via Hungarian assignment
235
+ import numpy as np
236
+ from scipy.optimize import linear_sum_assignment
237
+ from sklearn.metrics import confusion_matrix
238
+
239
+ y_true = np.array([classes.index(gt[n]) for n in nodes])
240
+ y_pred = np.array([partition[n] for n in nodes])
241
+ labels_p = sorted(set(y_pred))
242
+ C = confusion_matrix(y_true, [labels_p.index(x) for x in y_pred])
243
+ m = max(C.shape); Cp = np.zeros((m, m)); Cp[:C.shape[0], :C.shape[1]] = C
244
+ r, c = linear_sum_assignment(-Cp)
245
+ comm_to_class = {labels_p[cc]: rr for rr, cc in zip(r, c) if cc < len(labels_p)}
246
+
247
+ lgpa_colors = [cmap(comm_to_class.get(partition[n], len(classes))) for n in G.nodes()]
248
+ draw(lgpa_colors, f"Thiers - LGPA ({len(set(y_pred))} communities)",
249
+ "thiers_after.jpg", legend=handles)
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Method at a glance
255
+
256
+ 1. **Preprocessing** — a Laplacian-smoothed Jaccard similarity is computed for every edge, per-node structural strength is aggregated, and an adaptive threshold is derived from the graph's Structural Complexity Index.
257
+ 2. **Phase 1 (Coring)** — nodes are merged with their most similar neighbour, in a deterministic strength-based order, to form stable proto-communities.
258
+ 3. **Phase 2 (Log-Gravity Propagation)** — remaining labels are updated with a log-gravity score in which each neighbour's influence grows only logarithmically with its strength, suppressing hub dominance and preventing the avalanche effect.
259
+
260
+ ---
261
+
262
+ ## Citation
263
+
264
+ If you use LGPA in your research, please cite:
265
+
266
+ ```bibtex
267
+
268
+ ```
269
+
270
+ ---
271
+
272
+ ## License
273
+
274
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ setup.py
6
+ LGPA/__init__.py
7
+ LGPA/lgpa_core.cpp
8
+ LGPA.egg-info/PKG-INFO
9
+ LGPA.egg-info/SOURCES.txt
10
+ LGPA.egg-info/dependency_links.txt
11
+ LGPA.egg-info/requires.txt
12
+ LGPA.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ networkx>=2.5
@@ -0,0 +1 @@
1
+ LGPA
lgpa-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anasse Tahboun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
lgpa-1.0.0/MANIFEST.in ADDED
@@ -0,0 +1,3 @@
1
+ include LGPA/lgpa_core.cpp
2
+ include LICENSE
3
+ include README.md
lgpa-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,274 @@
1
+ Metadata-Version: 2.4
2
+ Name: LGPA
3
+ Version: 1.0.0
4
+ Summary: Log-Gravity Propagation Algorithm (LGPA) for community detection in complex networks
5
+ Author: Anasse Tahboun
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tahbounanas/LGPA
8
+ Project-URL: Repository, https://github.com/tahbounanas/LGPA
9
+ Keywords: community-detection,graph,networks,label-propagation,LGPA,Complex-Networks,Graph-Mining,Social-Network-Analysis,Log-Gravity-Propagation-Algorithm
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: networkx>=2.5
14
+ Dynamic: license-file
15
+
16
+ # LGPA — Log-Gravity Propagation Algorithm
17
+
18
+ A deterministic, tuning-free community detection algorithm for complex networks, implemented in C++ (via [pybind11](https://github.com/pybind/pybind11)) with a simple Python/[NetworkX](https://networkx.org/) interface.
19
+
20
+ LGPA resolves two well-known weaknesses of standard Label Propagation — run-to-run instability and the formation of oversized *"monster"* communities — using a Laplacian-smoothed Jaccard similarity, a statistical Coring phase, and a log-gravity propagation rule that logarithmically dampens the influence of high-degree hubs. Its threshold and update rule are derived entirely from the graph's own structure, so there is nothing to tune, and its native C++ core scales to networks of tens of thousands of nodes in seconds.
21
+
22
+ This repository is the reference implementation for the paper *"LGPA: Log-Gravity Propagation Algorithm for Community Detection in Complex Networks"* (ASONAM 2026, Research Track). See [Citation](#citation).
23
+
24
+ ---
25
+
26
+ ## Requirements
27
+
28
+ Installing LGPA compiles a small C++ extension, so you need a C++ compiler in addition to Python. Everything else is installed automatically.
29
+
30
+ | Requirement | Notes |
31
+ |---|---|
32
+ | **Python** | 3.8 or newer |
33
+ | **A C++ compiler** | Windows: [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) (select the *"Desktop development with C++"* workload). macOS: `xcode-select --install`. Linux: `sudo apt install build-essential` (or your distro's equivalent). |
34
+ | **pybind11** | Installed automatically during the build. |
35
+ | **networkx** | Installed automatically as a dependency. |
36
+
37
+ ---
38
+
39
+ ## Installation
40
+
41
+ ### Option 1 — Install directly from GitHub (recommended)
42
+
43
+ ```bash
44
+ pip install git+https://github.com/tahbounanas/LGPA.git
45
+ ```
46
+
47
+ ### Option 2 — Clone, then install
48
+
49
+ ```bash
50
+ git clone https://github.com/tahbounanas/LGPA.git
51
+ cd LGPA
52
+ pip install .
53
+ ```
54
+
55
+ To develop (edit the C++/Python and rebuild in place), use `pip install -e .` instead.
56
+
57
+ `pip` handles `pybind11`, `networkx`, and compiling the extension. After it finishes, LGPA is importable from any folder in that Python environment.
58
+
59
+ ---
60
+
61
+ ## Usage
62
+
63
+ ```python
64
+ import networkx as nx
65
+ from LGPA import LGPA
66
+
67
+ # A simple graph with two communities of 10 nodes each,
68
+ # joined by a single bridge edge.
69
+ G = nx.Graph()
70
+ for start in (0, 10):
71
+ block = range(start, start + 10)
72
+ for i in block:
73
+ for j in block:
74
+ if i < j:
75
+ G.add_edge(i, j) # dense links inside each community
76
+ G.add_edge(9, 10) # one bridge between the two communities
77
+
78
+ # Run LGPA
79
+ lgpa = LGPA(G)
80
+ partition = lgpa.fit_predict(max_iter=50) # or simply partition = LGPA(G).fit_predict()
81
+
82
+
83
+ # partition: {node -> community_id}
84
+ print("Communities found:", len(set(partition.values())))
85
+ print(partition)
86
+ ```
87
+
88
+ Expected output:
89
+
90
+ ```
91
+ Communities found: 2
92
+ {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0,
93
+ 10: 1, 11: 1, 12: 1, 13: 1, 14: 1, 15: 1, 16: 1, 17: 1, 18: 1, 19: 1}
94
+ ```
95
+
96
+ | Input graph | LGPA result |
97
+ |:---:|:---:|
98
+ | ![Input graph](lgpa_before.jpg) | ![LGPA communities](lgpa_after.jpg) |
99
+
100
+ LGPA correctly separates the two communities (nodes 0–9 and 10–19) despite the bridge edge linking them.
101
+
102
+ `fit_predict` returns a dictionary mapping each node to its community id, so it works with any node labels (integers, strings, etc.), not just consecutive integers. LGPA is fully deterministic: the same graph always yields the same partition, with no random seed.
103
+
104
+ **Parameters**
105
+
106
+ - `max_iter` (int, default `50`): a safeguard cap on the number of propagation sweeps. It is not a tuned parameter — the loop stops on its own once labels stabilize, which in practice happens well before this cap.
107
+
108
+ ---
109
+
110
+ ## Example on a real dataset (Thiers)
111
+
112
+ The [`Datasets/`](Datasets/) folder contains the **Thiers** high-school contact network (327 nodes, 9 ground-truth classes): `Thiers.gml` is the graph and `Thiers_GR.txt` holds the ground-truth class label of each node (in GML node order).
113
+
114
+ > This example also uses `scikit-learn` and `scipy` for the metrics (`pip install scikit-learn scipy`); they are not required by LGPA itself.
115
+
116
+ ```python
117
+ import json
118
+ import time
119
+ import networkx as nx
120
+ from sklearn.metrics import (
121
+ normalized_mutual_info_score as nmi_score,
122
+ adjusted_rand_score as ari_score,
123
+ f1_score,
124
+ )
125
+ from scipy.optimize import linear_sum_assignment
126
+ from sklearn.metrics import confusion_matrix
127
+ import numpy as np
128
+
129
+ from LGPA import LGPA
130
+
131
+ # Load the graph and its ground-truth labels
132
+ G = nx.read_gml("Datasets/Thiers.gml", label="id").to_undirected()
133
+ G.remove_edges_from(nx.selfloop_edges(G))
134
+ gt_labels = json.load(open("Datasets/Thiers_GR.txt"))
135
+
136
+ nodes = list(G.nodes())
137
+ gt = {nodes[i]: gt_labels[i] for i in range(len(nodes))} # GR is in GML node order
138
+
139
+ # Run LGPA (timed)
140
+ start = time.perf_counter()
141
+ partition = LGPA(G).fit_predict()
142
+ runtime = time.perf_counter() - start
143
+
144
+ # Encode labels as integers
145
+ classes = sorted(set(gt.values()))
146
+ cmap = {c: i for i, c in enumerate(classes)}
147
+ y_true = np.array([cmap[gt[n]] for n in nodes])
148
+ y_pred = np.array([partition[n] for n in nodes])
149
+
150
+ # NMI and ARI
151
+ nmi = nmi_score(y_true, y_pred)
152
+ ari = ari_score(y_true, y_pred)
153
+
154
+ # Macro-F1 (align predicted communities to ground-truth classes via Hungarian matching)
155
+ labels_p = sorted(set(y_pred))
156
+ C = confusion_matrix(y_true, [labels_p.index(x) for x in y_pred])
157
+ n = max(C.shape)
158
+ Cp = np.zeros((n, n)); Cp[:C.shape[0], :C.shape[1]] = C
159
+ r, c = linear_sum_assignment(-Cp)
160
+ mapping = {labels_p[cc]: rr for rr, cc in zip(r, c) if cc < len(labels_p)}
161
+ y_pred_aligned = np.array([mapping.get(x, -1) for x in y_pred])
162
+ f1 = f1_score(y_true, y_pred_aligned, average="macro")
163
+
164
+ print(f"Communities found: {len(set(y_pred))}")
165
+ print(f"NMI: {nmi:.3f}")
166
+ print(f"ARI: {ari:.3f}")
167
+ print(f"F1 : {f1:.3f}")
168
+ print(f"Runtime: {runtime:.3f} s")
169
+ ```
170
+
171
+ Output:
172
+
173
+ ```
174
+ Communities found: 9
175
+ NMI: 0.970
176
+ ARI: 0.964
177
+ F1 : 0.979
178
+ Runtime: 0.116 s
179
+ ```
180
+
181
+ LGPA recovers all 9 classes with near-perfect agreement to the ground truth (NMI 0.970, ARI 0.964). In the two coloured figures below, each detected community has been matched to its best-corresponding ground-truth class (via Hungarian assignment) and drawn in that class's colour, so the ground-truth and LGPA plots line up directly. The metrics, not the colours, are what quantify the agreement.
182
+
183
+ | Input network | Ground truth | LGPA communities |
184
+ |:---:|:---:|:---:|
185
+ | ![Thiers input](thiers_before.jpg) | ![Thiers ground truth](thiers_groundtruth.jpg) | ![Thiers LGPA](thiers_after.jpg) |
186
+
187
+
188
+ ### Reproducing the figures
189
+
190
+ The three plots above are produced with the snippet below (requires `matplotlib`, `pip install matplotlib`). A single shared layout is used so the input, ground-truth, and LGPA figures line up node-for-node.
191
+
192
+ ```python
193
+ import json
194
+ import networkx as nx
195
+ import matplotlib.pyplot as plt
196
+ import matplotlib.patches as mpatches
197
+ from LGPA import LGPA
198
+
199
+ # Load graph + ground truth
200
+ G = nx.read_gml("Datasets/Thiers.gml", label="id").to_undirected()
201
+ G.remove_edges_from(nx.selfloop_edges(G))
202
+ gt_labels = json.load(open("Datasets/Thiers_GR.txt"))
203
+ nodes = list(G.nodes())
204
+ gt = {nodes[i]: gt_labels[i] for i in range(len(nodes))}
205
+
206
+ # Run LGPA
207
+ partition = LGPA(G).fit_predict()
208
+
209
+ # One shared layout so all three plots are comparable
210
+ pos = nx.spring_layout(G, seed=42, k=0.3, iterations=60)
211
+ cmap = plt.colormaps["tab10"]
212
+
213
+ def draw(color_by, title, filename, legend=None):
214
+ plt.figure(figsize=(8, 8))
215
+ nx.draw_networkx_edges(G, pos, alpha=0.15, width=0.5)
216
+ nx.draw_networkx_nodes(G, pos, node_color=color_by, edgecolors="#333333",
217
+ linewidths=0.4, node_size=90)
218
+ if legend:
219
+ plt.legend(handles=legend, loc="upper left", fontsize=8)
220
+ plt.title(title, fontsize=14)
221
+ plt.axis("off"); plt.tight_layout()
222
+ plt.savefig(filename, dpi=150, bbox_inches="tight"); plt.close()
223
+
224
+ # 1) Input graph (grey, unlabelled)
225
+ draw("#cccccc", "Thiers network - input", "thiers_before.jpg")
226
+
227
+ # 2) Ground truth (coloured by true class, with legend)
228
+ classes = sorted(set(gt.values()))
229
+ gt_colors = [cmap(classes.index(gt[n])) for n in G.nodes()]
230
+ handles = [mpatches.Patch(color=cmap(i), label=c) for i, c in enumerate(classes)]
231
+ draw(gt_colors, f"Thiers - ground truth ({len(classes)} classes)",
232
+ "thiers_groundtruth.jpg", legend=handles)
233
+
234
+ # 3) LGPA result — colours matched to ground-truth classes via Hungarian assignment
235
+ import numpy as np
236
+ from scipy.optimize import linear_sum_assignment
237
+ from sklearn.metrics import confusion_matrix
238
+
239
+ y_true = np.array([classes.index(gt[n]) for n in nodes])
240
+ y_pred = np.array([partition[n] for n in nodes])
241
+ labels_p = sorted(set(y_pred))
242
+ C = confusion_matrix(y_true, [labels_p.index(x) for x in y_pred])
243
+ m = max(C.shape); Cp = np.zeros((m, m)); Cp[:C.shape[0], :C.shape[1]] = C
244
+ r, c = linear_sum_assignment(-Cp)
245
+ comm_to_class = {labels_p[cc]: rr for rr, cc in zip(r, c) if cc < len(labels_p)}
246
+
247
+ lgpa_colors = [cmap(comm_to_class.get(partition[n], len(classes))) for n in G.nodes()]
248
+ draw(lgpa_colors, f"Thiers - LGPA ({len(set(y_pred))} communities)",
249
+ "thiers_after.jpg", legend=handles)
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Method at a glance
255
+
256
+ 1. **Preprocessing** — a Laplacian-smoothed Jaccard similarity is computed for every edge, per-node structural strength is aggregated, and an adaptive threshold is derived from the graph's Structural Complexity Index.
257
+ 2. **Phase 1 (Coring)** — nodes are merged with their most similar neighbour, in a deterministic strength-based order, to form stable proto-communities.
258
+ 3. **Phase 2 (Log-Gravity Propagation)** — remaining labels are updated with a log-gravity score in which each neighbour's influence grows only logarithmically with its strength, suppressing hub dominance and preventing the avalanche effect.
259
+
260
+ ---
261
+
262
+ ## Citation
263
+
264
+ If you use LGPA in your research, please cite:
265
+
266
+ ```bibtex
267
+
268
+ ```
269
+
270
+ ---
271
+
272
+ ## License
273
+
274
+ Released under the [MIT License](LICENSE).
lgpa-1.0.0/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # LGPA — Log-Gravity Propagation Algorithm
2
+
3
+ A deterministic, tuning-free community detection algorithm for complex networks, implemented in C++ (via [pybind11](https://github.com/pybind/pybind11)) with a simple Python/[NetworkX](https://networkx.org/) interface.
4
+
5
+ LGPA resolves two well-known weaknesses of standard Label Propagation — run-to-run instability and the formation of oversized *"monster"* communities — using a Laplacian-smoothed Jaccard similarity, a statistical Coring phase, and a log-gravity propagation rule that logarithmically dampens the influence of high-degree hubs. Its threshold and update rule are derived entirely from the graph's own structure, so there is nothing to tune, and its native C++ core scales to networks of tens of thousands of nodes in seconds.
6
+
7
+ This repository is the reference implementation for the paper *"LGPA: Log-Gravity Propagation Algorithm for Community Detection in Complex Networks"* (ASONAM 2026, Research Track). See [Citation](#citation).
8
+
9
+ ---
10
+
11
+ ## Requirements
12
+
13
+ Installing LGPA compiles a small C++ extension, so you need a C++ compiler in addition to Python. Everything else is installed automatically.
14
+
15
+ | Requirement | Notes |
16
+ |---|---|
17
+ | **Python** | 3.8 or newer |
18
+ | **A C++ compiler** | Windows: [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) (select the *"Desktop development with C++"* workload). macOS: `xcode-select --install`. Linux: `sudo apt install build-essential` (or your distro's equivalent). |
19
+ | **pybind11** | Installed automatically during the build. |
20
+ | **networkx** | Installed automatically as a dependency. |
21
+
22
+ ---
23
+
24
+ ## Installation
25
+
26
+ ### Option 1 — Install directly from GitHub (recommended)
27
+
28
+ ```bash
29
+ pip install git+https://github.com/tahbounanas/LGPA.git
30
+ ```
31
+
32
+ ### Option 2 — Clone, then install
33
+
34
+ ```bash
35
+ git clone https://github.com/tahbounanas/LGPA.git
36
+ cd LGPA
37
+ pip install .
38
+ ```
39
+
40
+ To develop (edit the C++/Python and rebuild in place), use `pip install -e .` instead.
41
+
42
+ `pip` handles `pybind11`, `networkx`, and compiling the extension. After it finishes, LGPA is importable from any folder in that Python environment.
43
+
44
+ ---
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ import networkx as nx
50
+ from LGPA import LGPA
51
+
52
+ # A simple graph with two communities of 10 nodes each,
53
+ # joined by a single bridge edge.
54
+ G = nx.Graph()
55
+ for start in (0, 10):
56
+ block = range(start, start + 10)
57
+ for i in block:
58
+ for j in block:
59
+ if i < j:
60
+ G.add_edge(i, j) # dense links inside each community
61
+ G.add_edge(9, 10) # one bridge between the two communities
62
+
63
+ # Run LGPA
64
+ lgpa = LGPA(G)
65
+ partition = lgpa.fit_predict(max_iter=50) # or simply partition = LGPA(G).fit_predict()
66
+
67
+
68
+ # partition: {node -> community_id}
69
+ print("Communities found:", len(set(partition.values())))
70
+ print(partition)
71
+ ```
72
+
73
+ Expected output:
74
+
75
+ ```
76
+ Communities found: 2
77
+ {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0,
78
+ 10: 1, 11: 1, 12: 1, 13: 1, 14: 1, 15: 1, 16: 1, 17: 1, 18: 1, 19: 1}
79
+ ```
80
+
81
+ | Input graph | LGPA result |
82
+ |:---:|:---:|
83
+ | ![Input graph](lgpa_before.jpg) | ![LGPA communities](lgpa_after.jpg) |
84
+
85
+ LGPA correctly separates the two communities (nodes 0–9 and 10–19) despite the bridge edge linking them.
86
+
87
+ `fit_predict` returns a dictionary mapping each node to its community id, so it works with any node labels (integers, strings, etc.), not just consecutive integers. LGPA is fully deterministic: the same graph always yields the same partition, with no random seed.
88
+
89
+ **Parameters**
90
+
91
+ - `max_iter` (int, default `50`): a safeguard cap on the number of propagation sweeps. It is not a tuned parameter — the loop stops on its own once labels stabilize, which in practice happens well before this cap.
92
+
93
+ ---
94
+
95
+ ## Example on a real dataset (Thiers)
96
+
97
+ The [`Datasets/`](Datasets/) folder contains the **Thiers** high-school contact network (327 nodes, 9 ground-truth classes): `Thiers.gml` is the graph and `Thiers_GR.txt` holds the ground-truth class label of each node (in GML node order).
98
+
99
+ > This example also uses `scikit-learn` and `scipy` for the metrics (`pip install scikit-learn scipy`); they are not required by LGPA itself.
100
+
101
+ ```python
102
+ import json
103
+ import time
104
+ import networkx as nx
105
+ from sklearn.metrics import (
106
+ normalized_mutual_info_score as nmi_score,
107
+ adjusted_rand_score as ari_score,
108
+ f1_score,
109
+ )
110
+ from scipy.optimize import linear_sum_assignment
111
+ from sklearn.metrics import confusion_matrix
112
+ import numpy as np
113
+
114
+ from LGPA import LGPA
115
+
116
+ # Load the graph and its ground-truth labels
117
+ G = nx.read_gml("Datasets/Thiers.gml", label="id").to_undirected()
118
+ G.remove_edges_from(nx.selfloop_edges(G))
119
+ gt_labels = json.load(open("Datasets/Thiers_GR.txt"))
120
+
121
+ nodes = list(G.nodes())
122
+ gt = {nodes[i]: gt_labels[i] for i in range(len(nodes))} # GR is in GML node order
123
+
124
+ # Run LGPA (timed)
125
+ start = time.perf_counter()
126
+ partition = LGPA(G).fit_predict()
127
+ runtime = time.perf_counter() - start
128
+
129
+ # Encode labels as integers
130
+ classes = sorted(set(gt.values()))
131
+ cmap = {c: i for i, c in enumerate(classes)}
132
+ y_true = np.array([cmap[gt[n]] for n in nodes])
133
+ y_pred = np.array([partition[n] for n in nodes])
134
+
135
+ # NMI and ARI
136
+ nmi = nmi_score(y_true, y_pred)
137
+ ari = ari_score(y_true, y_pred)
138
+
139
+ # Macro-F1 (align predicted communities to ground-truth classes via Hungarian matching)
140
+ labels_p = sorted(set(y_pred))
141
+ C = confusion_matrix(y_true, [labels_p.index(x) for x in y_pred])
142
+ n = max(C.shape)
143
+ Cp = np.zeros((n, n)); Cp[:C.shape[0], :C.shape[1]] = C
144
+ r, c = linear_sum_assignment(-Cp)
145
+ mapping = {labels_p[cc]: rr for rr, cc in zip(r, c) if cc < len(labels_p)}
146
+ y_pred_aligned = np.array([mapping.get(x, -1) for x in y_pred])
147
+ f1 = f1_score(y_true, y_pred_aligned, average="macro")
148
+
149
+ print(f"Communities found: {len(set(y_pred))}")
150
+ print(f"NMI: {nmi:.3f}")
151
+ print(f"ARI: {ari:.3f}")
152
+ print(f"F1 : {f1:.3f}")
153
+ print(f"Runtime: {runtime:.3f} s")
154
+ ```
155
+
156
+ Output:
157
+
158
+ ```
159
+ Communities found: 9
160
+ NMI: 0.970
161
+ ARI: 0.964
162
+ F1 : 0.979
163
+ Runtime: 0.116 s
164
+ ```
165
+
166
+ LGPA recovers all 9 classes with near-perfect agreement to the ground truth (NMI 0.970, ARI 0.964). In the two coloured figures below, each detected community has been matched to its best-corresponding ground-truth class (via Hungarian assignment) and drawn in that class's colour, so the ground-truth and LGPA plots line up directly. The metrics, not the colours, are what quantify the agreement.
167
+
168
+ | Input network | Ground truth | LGPA communities |
169
+ |:---:|:---:|:---:|
170
+ | ![Thiers input](thiers_before.jpg) | ![Thiers ground truth](thiers_groundtruth.jpg) | ![Thiers LGPA](thiers_after.jpg) |
171
+
172
+
173
+ ### Reproducing the figures
174
+
175
+ The three plots above are produced with the snippet below (requires `matplotlib`, `pip install matplotlib`). A single shared layout is used so the input, ground-truth, and LGPA figures line up node-for-node.
176
+
177
+ ```python
178
+ import json
179
+ import networkx as nx
180
+ import matplotlib.pyplot as plt
181
+ import matplotlib.patches as mpatches
182
+ from LGPA import LGPA
183
+
184
+ # Load graph + ground truth
185
+ G = nx.read_gml("Datasets/Thiers.gml", label="id").to_undirected()
186
+ G.remove_edges_from(nx.selfloop_edges(G))
187
+ gt_labels = json.load(open("Datasets/Thiers_GR.txt"))
188
+ nodes = list(G.nodes())
189
+ gt = {nodes[i]: gt_labels[i] for i in range(len(nodes))}
190
+
191
+ # Run LGPA
192
+ partition = LGPA(G).fit_predict()
193
+
194
+ # One shared layout so all three plots are comparable
195
+ pos = nx.spring_layout(G, seed=42, k=0.3, iterations=60)
196
+ cmap = plt.colormaps["tab10"]
197
+
198
+ def draw(color_by, title, filename, legend=None):
199
+ plt.figure(figsize=(8, 8))
200
+ nx.draw_networkx_edges(G, pos, alpha=0.15, width=0.5)
201
+ nx.draw_networkx_nodes(G, pos, node_color=color_by, edgecolors="#333333",
202
+ linewidths=0.4, node_size=90)
203
+ if legend:
204
+ plt.legend(handles=legend, loc="upper left", fontsize=8)
205
+ plt.title(title, fontsize=14)
206
+ plt.axis("off"); plt.tight_layout()
207
+ plt.savefig(filename, dpi=150, bbox_inches="tight"); plt.close()
208
+
209
+ # 1) Input graph (grey, unlabelled)
210
+ draw("#cccccc", "Thiers network - input", "thiers_before.jpg")
211
+
212
+ # 2) Ground truth (coloured by true class, with legend)
213
+ classes = sorted(set(gt.values()))
214
+ gt_colors = [cmap(classes.index(gt[n])) for n in G.nodes()]
215
+ handles = [mpatches.Patch(color=cmap(i), label=c) for i, c in enumerate(classes)]
216
+ draw(gt_colors, f"Thiers - ground truth ({len(classes)} classes)",
217
+ "thiers_groundtruth.jpg", legend=handles)
218
+
219
+ # 3) LGPA result — colours matched to ground-truth classes via Hungarian assignment
220
+ import numpy as np
221
+ from scipy.optimize import linear_sum_assignment
222
+ from sklearn.metrics import confusion_matrix
223
+
224
+ y_true = np.array([classes.index(gt[n]) for n in nodes])
225
+ y_pred = np.array([partition[n] for n in nodes])
226
+ labels_p = sorted(set(y_pred))
227
+ C = confusion_matrix(y_true, [labels_p.index(x) for x in y_pred])
228
+ m = max(C.shape); Cp = np.zeros((m, m)); Cp[:C.shape[0], :C.shape[1]] = C
229
+ r, c = linear_sum_assignment(-Cp)
230
+ comm_to_class = {labels_p[cc]: rr for rr, cc in zip(r, c) if cc < len(labels_p)}
231
+
232
+ lgpa_colors = [cmap(comm_to_class.get(partition[n], len(classes))) for n in G.nodes()]
233
+ draw(lgpa_colors, f"Thiers - LGPA ({len(set(y_pred))} communities)",
234
+ "thiers_after.jpg", legend=handles)
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Method at a glance
240
+
241
+ 1. **Preprocessing** — a Laplacian-smoothed Jaccard similarity is computed for every edge, per-node structural strength is aggregated, and an adaptive threshold is derived from the graph's Structural Complexity Index.
242
+ 2. **Phase 1 (Coring)** — nodes are merged with their most similar neighbour, in a deterministic strength-based order, to form stable proto-communities.
243
+ 3. **Phase 2 (Log-Gravity Propagation)** — remaining labels are updated with a log-gravity score in which each neighbour's influence grows only logarithmically with its strength, suppressing hub dominance and preventing the avalanche effect.
244
+
245
+ ---
246
+
247
+ ## Citation
248
+
249
+ If you use LGPA in your research, please cite:
250
+
251
+ ```bibtex
252
+
253
+ ```
254
+
255
+ ---
256
+
257
+ ## License
258
+
259
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "pybind11>=2.10"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "LGPA"
7
+ version = "1.0.0"
8
+ description = "Log-Gravity Propagation Algorithm (LGPA) for community detection in complex networks"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Anasse Tahboun" }]
13
+ keywords = ["community-detection", "graph", "networks", "label-propagation", "LGPA","Complex-Networks","Graph-Mining","Social-Network-Analysis","Log-Gravity-Propagation-Algorithm"]
14
+ dependencies = ["networkx>=2.5"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/tahbounanas/LGPA"
18
+ Repository = "https://github.com/tahbounanas/LGPA"
19
+
20
+ [tool.setuptools]
21
+ packages = ["LGPA"]
22
+
23
+
24
+
25
+
lgpa-1.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
lgpa-1.0.0/setup.py ADDED
@@ -0,0 +1,19 @@
1
+ import sys
2
+ from setuptools import setup
3
+ from pybind11.setup_helpers import Pybind11Extension, build_ext
4
+
5
+ # MSVC uses /O2, GCC/Clang use -O3
6
+ opt_flags = ["/O2"] if sys.platform == "win32" else ["-O3"]
7
+
8
+ ext_modules = [
9
+ Pybind11Extension(
10
+ "LGPA._lgpa_core",
11
+ ["LGPA/lgpa_core.cpp"],
12
+ extra_compile_args=opt_flags,
13
+ ),
14
+ ]
15
+
16
+ setup(
17
+ ext_modules=ext_modules,
18
+ cmdclass={"build_ext": build_ext},
19
+ )