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.
@@ -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()