Run Leiden community detection. Returns {community_id: [node_ids]}. Community IDs are stable across runs: 0 = largest community after splitting. Oversized communities (> 25% of graph nodes, min 10) are split by running a second Leiden pass on the subgraph. Accepts directed or undir
(
G: nx.Graph,
resolution: float = 1.0,
exclude_hubs_percentile: float | None = None,
)
| 132 | |
| 133 | |
| 134 | def cluster( |
| 135 | G: nx.Graph, |
| 136 | resolution: float = 1.0, |
| 137 | exclude_hubs_percentile: float | None = None, |
| 138 | ) -> dict[int, list[str]]: |
| 139 | """Run Leiden community detection. Returns {community_id: [node_ids]}. |
| 140 | |
| 141 | Community IDs are stable across runs: 0 = largest community after splitting. |
| 142 | Oversized communities (> 25% of graph nodes, min 10) are split by running |
| 143 | a second Leiden pass on the subgraph. |
| 144 | |
| 145 | Accepts directed or undirected graphs. DiGraphs are converted to undirected |
| 146 | internally since Louvain/Leiden require undirected input. |
| 147 | |
| 148 | resolution: passed to Leiden/Louvain. >1.0 = more smaller communities, |
| 149 | <1.0 = fewer larger communities. Default 1.0. |
| 150 | exclude_hubs_percentile: if set (0-100), nodes whose degree exceeds this |
| 151 | percentile are excluded from partitioning and reattached to their |
| 152 | majority-vote neighbour community afterwards. Useful for staging/utility |
| 153 | super-hubs that inflate god-node rankings (#919). |
| 154 | """ |
| 155 | if G.number_of_nodes() == 0: |
| 156 | return {} |
| 157 | if G.is_directed(): |
| 158 | G = G.to_undirected() |
| 159 | if G.number_of_edges() == 0: |
| 160 | return {i: [n] for i, n in enumerate(sorted(G.nodes))} |
| 161 | |
| 162 | # Compute hub exclusion set before removing anything so degree is based on full graph |
| 163 | hub_nodes: set[str] = set() |
| 164 | if exclude_hubs_percentile is not None: |
| 165 | degrees = sorted(d for _, d in G.degree()) |
| 166 | if degrees: |
| 167 | idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1) |
| 168 | threshold = degrees[idx] |
| 169 | hub_nodes = {n for n, d in G.degree() if d > threshold} |
| 170 | |
| 171 | # Leiden warns and drops isolates - handle them separately |
| 172 | # Also exclude hub nodes from partitioning so they don't pull unrelated |
| 173 | # subsystems into the same community |
| 174 | excluded = hub_nodes |
| 175 | isolates = [n for n in G.nodes() if G.degree(n) == 0 and n not in excluded] |
| 176 | connected_nodes = [n for n in G.nodes() if G.degree(n) > 0 and n not in excluded] |
| 177 | connected = G.subgraph(connected_nodes) |
| 178 | |
| 179 | raw: dict[int, list[str]] = {} |
| 180 | if connected.number_of_nodes() > 0: |
| 181 | partition = _partition(connected, resolution=resolution) |
| 182 | for node, cid in partition.items(): |
| 183 | raw.setdefault(cid, []).append(node) |
| 184 | |
| 185 | # Each isolate becomes its own single-node community |
| 186 | next_cid = max(raw.keys(), default=-1) + 1 |
| 187 | for node in isolates: |
| 188 | raw[next_cid] = [node] |
| 189 | next_cid += 1 |
| 190 | |
| 191 | # Reattach excluded hubs by majority-vote neighbour community |