correlation-network 1.0.0__py3-none-any.whl
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.
- correlation_network/__init__.py +56 -0
- correlation_network/_graph_utils.py +65 -0
- correlation_network/_types.py +34 -0
- correlation_network/_validation.py +118 -0
- correlation_network/association/__init__.py +29 -0
- correlation_network/association/_auto.py +104 -0
- correlation_network/association/_base.py +25 -0
- correlation_network/association/_categorical.py +174 -0
- correlation_network/association/_correlation.py +65 -0
- correlation_network/association/_dtypes.py +47 -0
- correlation_network/association/_universal.py +162 -0
- correlation_network/centrality.py +124 -0
- correlation_network/exceptions.py +61 -0
- correlation_network/network.py +407 -0
- correlation_network/py.typed +0 -0
- correlation_network/visualization.py +394 -0
- correlation_network-1.0.0.dist-info/METADATA +385 -0
- correlation_network-1.0.0.dist-info/RECORD +20 -0
- correlation_network-1.0.0.dist-info/WHEEL +4 -0
- correlation_network-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Network visualization utilities."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from itertools import compress
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import networkx as nx
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from matplotlib import pyplot as plt
|
|
13
|
+
from matplotlib.axes import Axes
|
|
14
|
+
from matplotlib.colors import Normalize, TwoSlopeNorm
|
|
15
|
+
from matplotlib.figure import Figure
|
|
16
|
+
from matplotlib.lines import Line2D
|
|
17
|
+
|
|
18
|
+
from correlation_network._graph_utils import abs_weight_graph, distance_graph
|
|
19
|
+
from correlation_network._types import CentralityMetric, LayoutAlgorithm
|
|
20
|
+
from correlation_network._validation import check_option
|
|
21
|
+
from correlation_network.centrality import CentralityAnalyzer
|
|
22
|
+
|
|
23
|
+
# Each layout is paired with the weight transform it needs. Kamada-Kawai reads
|
|
24
|
+
# weights as target *distances*, so association strength becomes
|
|
25
|
+
# ``1 - |weight|``; raw values would draw the strongest associations as the
|
|
26
|
+
# longest edges. Spring and spectral read weights as attraction and only need
|
|
27
|
+
# them non-negative. Circular and shell ignore weights.
|
|
28
|
+
_LAYOUTS: dict[
|
|
29
|
+
str, tuple[Callable[..., dict[Any, np.ndarray]], Callable[[nx.Graph], nx.Graph]]
|
|
30
|
+
] = {
|
|
31
|
+
"kamada_kawai": (nx.kamada_kawai_layout, distance_graph),
|
|
32
|
+
"spring": (nx.spring_layout, abs_weight_graph),
|
|
33
|
+
"circular": (nx.circular_layout, lambda graph: graph),
|
|
34
|
+
"shell": (nx.shell_layout, lambda graph: graph),
|
|
35
|
+
"spectral": (nx.spectral_layout, abs_weight_graph),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_CENTRALITY_METRICS: dict[str, Callable[[CentralityAnalyzer], pd.Series]] = {
|
|
39
|
+
"degree": CentralityAnalyzer.degree,
|
|
40
|
+
"strength": CentralityAnalyzer.strength,
|
|
41
|
+
"betweenness": CentralityAnalyzer.betweenness,
|
|
42
|
+
"eigenvector": CentralityAnalyzer.eigenvector,
|
|
43
|
+
"closeness": CentralityAnalyzer.closeness,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NetworkVisualizer:
|
|
48
|
+
"""Visualize an association network graph.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
graph : nx.Graph
|
|
53
|
+
A weighted undirected graph.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, graph: nx.Graph) -> None:
|
|
57
|
+
self.graph = graph
|
|
58
|
+
|
|
59
|
+
@functools.cached_property
|
|
60
|
+
def _centrality_analyzer(self) -> CentralityAnalyzer:
|
|
61
|
+
return CentralityAnalyzer(self.graph)
|
|
62
|
+
|
|
63
|
+
@functools.cached_property
|
|
64
|
+
def _community_colors(self) -> list[tuple[float, float, float, float]]:
|
|
65
|
+
communities = nx.community.greedy_modularity_communities(self.graph)
|
|
66
|
+
palette = plt.get_cmap("tab10")
|
|
67
|
+
node_to_color: dict[str, tuple[float, float, float, float]] = {}
|
|
68
|
+
for idx, community in enumerate(communities):
|
|
69
|
+
color = palette(idx % 10)
|
|
70
|
+
for node in community:
|
|
71
|
+
node_to_color[node] = color
|
|
72
|
+
return [node_to_color[n] for n in self.graph.nodes()]
|
|
73
|
+
|
|
74
|
+
def plot(
|
|
75
|
+
self,
|
|
76
|
+
layout: LayoutAlgorithm = "kamada_kawai",
|
|
77
|
+
centrality_metric: CentralityMetric | None = None,
|
|
78
|
+
figsize: tuple[float, float] = (10, 8),
|
|
79
|
+
dpi: int = 100,
|
|
80
|
+
node_color: str = "tab:blue",
|
|
81
|
+
community_colors: bool = False,
|
|
82
|
+
edge_cmap: str | None = "RdYlGn",
|
|
83
|
+
positive_edge_color: str = "darkgreen",
|
|
84
|
+
negative_edge_color: str = "tab:red",
|
|
85
|
+
font_size: int = 10,
|
|
86
|
+
edge_label_font_size: int = 8,
|
|
87
|
+
show_edge_labels: bool = True,
|
|
88
|
+
title: str | None = None,
|
|
89
|
+
) -> Figure:
|
|
90
|
+
"""Plot the network graph.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
layout : LayoutAlgorithm
|
|
95
|
+
Graph layout algorithm. Default ``"kamada_kawai"``.
|
|
96
|
+
centrality_metric : CentralityMetric | None
|
|
97
|
+
If provided, node sizes are scaled by this centrality measure.
|
|
98
|
+
figsize : tuple[float, float]
|
|
99
|
+
Figure size in inches.
|
|
100
|
+
dpi : int
|
|
101
|
+
Figure resolution.
|
|
102
|
+
node_color : str
|
|
103
|
+
Color for nodes (ignored when ``community_colors`` is ``True``).
|
|
104
|
+
community_colors : bool
|
|
105
|
+
If ``True``, detect communities via greedy modularity and color
|
|
106
|
+
each node by its community. Overrides ``node_color``.
|
|
107
|
+
edge_cmap : str | None
|
|
108
|
+
Matplotlib colormap for continuous edge coloring based on weight,
|
|
109
|
+
read as diverging around 0 (lower half = negative). When set, a
|
|
110
|
+
colorbar is shown. When ``None``, falls back to
|
|
111
|
+
the two-color mode using ``positive_edge_color`` / ``negative_edge_color``.
|
|
112
|
+
positive_edge_color : str
|
|
113
|
+
Color for positive edges (only used when ``edge_cmap`` is ``None``).
|
|
114
|
+
negative_edge_color : str
|
|
115
|
+
Color for negative edges (only used when ``edge_cmap`` is ``None``).
|
|
116
|
+
font_size : int
|
|
117
|
+
Font size for node labels.
|
|
118
|
+
edge_label_font_size : int
|
|
119
|
+
Font size for edge weight labels.
|
|
120
|
+
show_edge_labels : bool
|
|
121
|
+
Whether to display edge weight labels.
|
|
122
|
+
title : str | None
|
|
123
|
+
Optional title displayed above the plot.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
matplotlib.figure.Figure
|
|
128
|
+
The figure object (not shown automatically).
|
|
129
|
+
"""
|
|
130
|
+
node_sizes = self._compute_node_sizes(metric=centrality_metric)
|
|
131
|
+
pos = self._layout_positions(layout=layout)
|
|
132
|
+
|
|
133
|
+
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
|
|
134
|
+
|
|
135
|
+
resolved_node_color = self._community_colors if community_colors else node_color
|
|
136
|
+
|
|
137
|
+
nx.draw_networkx_nodes(
|
|
138
|
+
self.graph,
|
|
139
|
+
pos,
|
|
140
|
+
node_size=node_sizes,
|
|
141
|
+
node_color=resolved_node_color,
|
|
142
|
+
alpha=0.7,
|
|
143
|
+
edgecolors="black",
|
|
144
|
+
linewidths=0.5,
|
|
145
|
+
ax=ax,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
edge_data = list(self.graph.edges(data="weight", default=1.0))
|
|
149
|
+
edges = [(u, v) for u, v, _ in edge_data]
|
|
150
|
+
weights = np.array([w for _, _, w in edge_data])
|
|
151
|
+
abs_w = np.abs(weights)
|
|
152
|
+
widths = np.maximum(0.5, abs_w * 3)
|
|
153
|
+
|
|
154
|
+
# Alpha proportional to |weight|: stronger edges are more opaque
|
|
155
|
+
if len(abs_w) > 0 and abs_w.max() > 0:
|
|
156
|
+
alphas = 0.3 + 0.6 * (abs_w / abs_w.max())
|
|
157
|
+
else:
|
|
158
|
+
alphas = np.full_like(abs_w, 0.6)
|
|
159
|
+
|
|
160
|
+
if edge_cmap is not None and len(edges) > 0:
|
|
161
|
+
self._draw_edges_cmap(
|
|
162
|
+
pos=pos,
|
|
163
|
+
edges=edges,
|
|
164
|
+
weights=weights,
|
|
165
|
+
widths=widths,
|
|
166
|
+
alphas=alphas,
|
|
167
|
+
cmap_name=edge_cmap,
|
|
168
|
+
fig=fig,
|
|
169
|
+
ax=ax,
|
|
170
|
+
)
|
|
171
|
+
elif len(edges) > 0:
|
|
172
|
+
self._draw_edges_two_color(
|
|
173
|
+
pos=pos,
|
|
174
|
+
edges=edges,
|
|
175
|
+
weights=weights,
|
|
176
|
+
widths=widths,
|
|
177
|
+
alphas=alphas,
|
|
178
|
+
positive_color=positive_edge_color,
|
|
179
|
+
negative_color=negative_edge_color,
|
|
180
|
+
ax=ax,
|
|
181
|
+
)
|
|
182
|
+
self._add_edge_legend(
|
|
183
|
+
weights=weights,
|
|
184
|
+
positive_color=positive_edge_color,
|
|
185
|
+
negative_color=negative_edge_color,
|
|
186
|
+
font_size=font_size,
|
|
187
|
+
ax=ax,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
nx.draw_networkx_labels(self.graph, pos, font_size=font_size, ax=ax)
|
|
191
|
+
|
|
192
|
+
if show_edge_labels:
|
|
193
|
+
edge_labels = {
|
|
194
|
+
(u, v): round(d.get("weight", 0), 2) for u, v, d in self.graph.edges(data=True)
|
|
195
|
+
}
|
|
196
|
+
nx.draw_networkx_edge_labels(
|
|
197
|
+
self.graph,
|
|
198
|
+
pos,
|
|
199
|
+
edge_labels=edge_labels,
|
|
200
|
+
font_size=edge_label_font_size,
|
|
201
|
+
ax=ax,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if title is not None:
|
|
205
|
+
ax.set_title(title, fontsize=font_size + 2, fontweight="bold")
|
|
206
|
+
|
|
207
|
+
ax.set_axis_off()
|
|
208
|
+
fig.tight_layout()
|
|
209
|
+
return fig
|
|
210
|
+
|
|
211
|
+
def save(self, path: str | os.PathLike[str], *, dpi: int = 150, **plot_kwargs: Any) -> None:
|
|
212
|
+
"""Plot the graph and save it to a file.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
path : str | os.PathLike[str]
|
|
217
|
+
Output file path (e.g. ``"network.png"``). Format is inferred
|
|
218
|
+
from the extension.
|
|
219
|
+
dpi : int
|
|
220
|
+
Resolution for the saved image.
|
|
221
|
+
**plot_kwargs
|
|
222
|
+
Additional keyword arguments forwarded to :meth:`plot`.
|
|
223
|
+
"""
|
|
224
|
+
fig = self.plot(**plot_kwargs)
|
|
225
|
+
fig.savefig(path, dpi=dpi, bbox_inches="tight")
|
|
226
|
+
plt.close(fig)
|
|
227
|
+
|
|
228
|
+
def _layout_positions(self, layout: LayoutAlgorithm) -> dict[Any, np.ndarray]:
|
|
229
|
+
"""Compute node positions, adapting weights to what the layout expects.
|
|
230
|
+
|
|
231
|
+
Raises
|
|
232
|
+
------
|
|
233
|
+
ValueError
|
|
234
|
+
If ``layout`` is not a supported algorithm.
|
|
235
|
+
"""
|
|
236
|
+
check_option(name="layout", value=layout, options=_LAYOUTS)
|
|
237
|
+
layout_fn, prepare = _LAYOUTS[layout]
|
|
238
|
+
return layout_fn(prepare(self.graph))
|
|
239
|
+
|
|
240
|
+
def _draw_edges_cmap(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
pos: dict,
|
|
244
|
+
edges: list[tuple],
|
|
245
|
+
weights: np.ndarray,
|
|
246
|
+
widths: np.ndarray,
|
|
247
|
+
alphas: np.ndarray,
|
|
248
|
+
cmap_name: str,
|
|
249
|
+
fig: Figure,
|
|
250
|
+
ax: Axes,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Draw edges colored by a continuous colormap with a colorbar."""
|
|
253
|
+
cmap = plt.get_cmap(cmap_name)
|
|
254
|
+
norm = self._edge_norm(weights)
|
|
255
|
+
edge_colors = cmap(norm(weights))
|
|
256
|
+
|
|
257
|
+
nx.draw_networkx_edges(
|
|
258
|
+
self.graph,
|
|
259
|
+
pos,
|
|
260
|
+
edgelist=edges,
|
|
261
|
+
width=widths.tolist(),
|
|
262
|
+
edge_color=edge_colors,
|
|
263
|
+
style="dashed",
|
|
264
|
+
alpha=alphas.tolist(),
|
|
265
|
+
ax=ax,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
|
|
269
|
+
sm.set_array([])
|
|
270
|
+
fig.colorbar(sm, ax=ax, label="Association strength", shrink=0.8)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _edge_norm(weights: np.ndarray) -> Normalize:
|
|
274
|
+
"""Map edge weights to colormap positions with 0 at the centre.
|
|
275
|
+
|
|
276
|
+
The edge colormap is read as diverging: its lower half means negative
|
|
277
|
+
association. Stretching single-signed weights over the full range
|
|
278
|
+
would paint the weakest positive edge in the "negative" colour, so
|
|
279
|
+
such weights keep a symmetric range around 0 instead.
|
|
280
|
+
"""
|
|
281
|
+
w_min, w_max = float(weights.min()), float(weights.max())
|
|
282
|
+
if w_min < 0 < w_max:
|
|
283
|
+
return TwoSlopeNorm(vcenter=0, vmin=w_min, vmax=w_max)
|
|
284
|
+
limit = max(abs(w_min), abs(w_max)) or 1.0
|
|
285
|
+
return Normalize(vmin=-limit, vmax=limit)
|
|
286
|
+
|
|
287
|
+
def _draw_edges_two_color(
|
|
288
|
+
self,
|
|
289
|
+
*,
|
|
290
|
+
pos: dict,
|
|
291
|
+
edges: list[tuple],
|
|
292
|
+
weights: np.ndarray,
|
|
293
|
+
widths: np.ndarray,
|
|
294
|
+
alphas: np.ndarray,
|
|
295
|
+
positive_color: str,
|
|
296
|
+
negative_color: str,
|
|
297
|
+
ax: Axes,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Draw edges with two fixed colors and per-edge alpha."""
|
|
300
|
+
positive_mask = weights >= 0
|
|
301
|
+
negative_mask = ~positive_mask
|
|
302
|
+
|
|
303
|
+
positive_edges = list(compress(edges, positive_mask))
|
|
304
|
+
negative_edges = list(compress(edges, negative_mask))
|
|
305
|
+
|
|
306
|
+
if positive_edges:
|
|
307
|
+
nx.draw_networkx_edges(
|
|
308
|
+
self.graph,
|
|
309
|
+
pos,
|
|
310
|
+
edgelist=positive_edges,
|
|
311
|
+
width=widths[positive_mask].tolist(),
|
|
312
|
+
edge_color=positive_color,
|
|
313
|
+
style="dashed",
|
|
314
|
+
alpha=alphas[positive_mask].tolist(),
|
|
315
|
+
ax=ax,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if negative_edges:
|
|
319
|
+
nx.draw_networkx_edges(
|
|
320
|
+
self.graph,
|
|
321
|
+
pos,
|
|
322
|
+
edgelist=negative_edges,
|
|
323
|
+
width=widths[negative_mask].tolist(),
|
|
324
|
+
edge_color=negative_color,
|
|
325
|
+
style="dashed",
|
|
326
|
+
alpha=alphas[negative_mask].tolist(),
|
|
327
|
+
ax=ax,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _add_edge_legend(
|
|
332
|
+
*,
|
|
333
|
+
weights: np.ndarray,
|
|
334
|
+
positive_color: str,
|
|
335
|
+
negative_color: str,
|
|
336
|
+
font_size: int,
|
|
337
|
+
ax: Axes,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Add a positive/negative edge legend to the axes."""
|
|
340
|
+
has_positive = bool((weights >= 0).any())
|
|
341
|
+
has_negative = bool((weights < 0).any())
|
|
342
|
+
|
|
343
|
+
legend_handles = []
|
|
344
|
+
if has_positive:
|
|
345
|
+
legend_handles.append(
|
|
346
|
+
Line2D(
|
|
347
|
+
[0],
|
|
348
|
+
[0],
|
|
349
|
+
color=positive_color,
|
|
350
|
+
linestyle="dashed",
|
|
351
|
+
linewidth=2,
|
|
352
|
+
label="Positive",
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
if has_negative:
|
|
356
|
+
legend_handles.append(
|
|
357
|
+
Line2D(
|
|
358
|
+
[0],
|
|
359
|
+
[0],
|
|
360
|
+
color=negative_color,
|
|
361
|
+
linestyle="dashed",
|
|
362
|
+
linewidth=2,
|
|
363
|
+
label="Negative",
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
if legend_handles:
|
|
367
|
+
ax.legend(
|
|
368
|
+
handles=legend_handles,
|
|
369
|
+
loc="best",
|
|
370
|
+
fontsize=font_size,
|
|
371
|
+
framealpha=0.8,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def _compute_node_sizes(self, metric: CentralityMetric | None) -> list[float] | float:
|
|
375
|
+
"""Compute node sizes scaled by centrality.
|
|
376
|
+
|
|
377
|
+
Raises
|
|
378
|
+
------
|
|
379
|
+
ValueError
|
|
380
|
+
If ``metric`` is not a supported centrality measure.
|
|
381
|
+
"""
|
|
382
|
+
if metric is None:
|
|
383
|
+
return 300.0
|
|
384
|
+
|
|
385
|
+
check_option(name="centrality_metric", value=metric, options=_CENTRALITY_METRICS)
|
|
386
|
+
values = _CENTRALITY_METRICS[metric](self._centrality_analyzer)
|
|
387
|
+
|
|
388
|
+
# Scale to a reasonable range [100, 1500]
|
|
389
|
+
nodes = list(self.graph.nodes())
|
|
390
|
+
raw = values.reindex(nodes).fillna(0).to_numpy(dtype=float)
|
|
391
|
+
if raw.max() == raw.min():
|
|
392
|
+
return [300.0] * len(nodes)
|
|
393
|
+
normalized = (raw - raw.min()) / (raw.max() - raw.min())
|
|
394
|
+
return (100 + normalized * 1400).tolist()
|