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.
- lgpa-1.0.0/LGPA/__init__.py +26 -0
- lgpa-1.0.0/LGPA/lgpa_core.cpp +194 -0
- lgpa-1.0.0/LGPA.egg-info/PKG-INFO +274 -0
- lgpa-1.0.0/LGPA.egg-info/SOURCES.txt +12 -0
- lgpa-1.0.0/LGPA.egg-info/dependency_links.txt +1 -0
- lgpa-1.0.0/LGPA.egg-info/requires.txt +1 -0
- lgpa-1.0.0/LGPA.egg-info/top_level.txt +1 -0
- lgpa-1.0.0/LICENSE +21 -0
- lgpa-1.0.0/MANIFEST.in +3 -0
- lgpa-1.0.0/PKG-INFO +274 -0
- lgpa-1.0.0/README.md +259 -0
- lgpa-1.0.0/pyproject.toml +25 -0
- lgpa-1.0.0/setup.cfg +4 -0
- lgpa-1.0.0/setup.py +19 -0
|
@@ -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
|
+
|  |  |
|
|
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
|
+
|  |  |  |
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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
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
|
+
|  |  |
|
|
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
|
+
|  |  |  |
|
|
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
|
+
|  |  |
|
|
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
|
+
|  |  |  |
|
|
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
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
|
+
)
|