MatplotLibAPI 3.2.21__py3-none-any.whl → 4.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.
- MatplotLibAPI/__init__.py +4 -86
- MatplotLibAPI/accessor.py +519 -196
- MatplotLibAPI/area.py +177 -0
- MatplotLibAPI/bar.py +185 -0
- MatplotLibAPI/base_plot.py +88 -0
- MatplotLibAPI/box_violin.py +180 -0
- MatplotLibAPI/bubble.py +568 -0
- MatplotLibAPI/{Composite.py → composite.py} +127 -106
- MatplotLibAPI/heatmap.py +223 -0
- MatplotLibAPI/histogram.py +170 -0
- MatplotLibAPI/mcp/__init__.py +17 -0
- MatplotLibAPI/mcp/metadata.py +90 -0
- MatplotLibAPI/mcp/renderers.py +45 -0
- MatplotLibAPI/mcp_server.py +626 -0
- MatplotLibAPI/network/__init__.py +28 -0
- MatplotLibAPI/network/constants.py +22 -0
- MatplotLibAPI/network/core.py +1360 -0
- MatplotLibAPI/network/plot.py +597 -0
- MatplotLibAPI/network/scaling.py +56 -0
- MatplotLibAPI/pie.py +154 -0
- MatplotLibAPI/pivot.py +274 -0
- MatplotLibAPI/sankey.py +99 -0
- MatplotLibAPI/{StyleTemplate.py → style_template.py} +27 -22
- MatplotLibAPI/sunburst.py +139 -0
- MatplotLibAPI/{Table.py → table.py} +112 -87
- MatplotLibAPI/{Timeserie.py → timeserie.py} +98 -42
- MatplotLibAPI/{Treemap.py → treemap.py} +43 -55
- MatplotLibAPI/typing.py +12 -0
- MatplotLibAPI/{_visualization_utils.py → utils.py} +7 -13
- MatplotLibAPI/waffle.py +173 -0
- MatplotLibAPI/word_cloud.py +489 -0
- {matplotlibapi-3.2.21.dist-info → matplotlibapi-4.0.0.dist-info}/METADATA +98 -9
- matplotlibapi-4.0.0.dist-info/RECORD +36 -0
- {matplotlibapi-3.2.21.dist-info → matplotlibapi-4.0.0.dist-info}/WHEEL +1 -1
- matplotlibapi-4.0.0.dist-info/entry_points.txt +2 -0
- MatplotLibAPI/Area.py +0 -80
- MatplotLibAPI/Bar.py +0 -83
- MatplotLibAPI/BoxViolin.py +0 -75
- MatplotLibAPI/Bubble.py +0 -458
- MatplotLibAPI/Heatmap.py +0 -121
- MatplotLibAPI/Histogram.py +0 -73
- MatplotLibAPI/Network.py +0 -989
- MatplotLibAPI/Pie.py +0 -70
- MatplotLibAPI/Pivot.py +0 -134
- MatplotLibAPI/Sankey.py +0 -46
- MatplotLibAPI/Sunburst.py +0 -89
- MatplotLibAPI/Waffle.py +0 -86
- MatplotLibAPI/Wordcloud.py +0 -373
- MatplotLibAPI/_typing.py +0 -17
- matplotlibapi-3.2.21.dist-info/RECORD +0 -26
- {matplotlibapi-3.2.21.dist-info → matplotlibapi-4.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
"""Network chart plotting helpers."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union, cast
|
|
5
|
+
from pandas.api.extensions import register_dataframe_accessor
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
import networkx as nx
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import seaborn as sns
|
|
11
|
+
from matplotlib.axes import Axes
|
|
12
|
+
from matplotlib.figure import Figure
|
|
13
|
+
|
|
14
|
+
from ..base_plot import BasePlot
|
|
15
|
+
from .constants import _DEFAULT, _WEIGHT_PERCENTILES
|
|
16
|
+
from .scaling import _scale_weights
|
|
17
|
+
from ..style_template import (
|
|
18
|
+
NETWORK_STYLE_TEMPLATE,
|
|
19
|
+
FIG_SIZE,
|
|
20
|
+
TITLE_SCALE_FACTOR,
|
|
21
|
+
StyleTemplate,
|
|
22
|
+
string_formatter,
|
|
23
|
+
validate_dataframe,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"NETWORK_STYLE_TEMPLATE",
|
|
28
|
+
"NetworkGraph",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NodeView(nx.classes.reportviews.NodeView):
|
|
33
|
+
"""Extended node view with convenience helpers."""
|
|
34
|
+
|
|
35
|
+
def __getitem__(self, n: Any) -> Dict[str, Any]:
|
|
36
|
+
return super().__getitem__(n)
|
|
37
|
+
|
|
38
|
+
def sort(self, attribute: str = "weight", reverse: bool = True) -> List[Any]:
|
|
39
|
+
"""Return nodes sorted by the specified attribute.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
attribute : str, optional
|
|
44
|
+
Node attribute used for sorting. The default is "weight".
|
|
45
|
+
reverse : bool, optional
|
|
46
|
+
Sort order. The default is `True`.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
list[Any]
|
|
51
|
+
Sorted nodes.
|
|
52
|
+
"""
|
|
53
|
+
sorted_nodes = sorted(
|
|
54
|
+
self, key=lambda node: self[node].get(attribute, 1), reverse=reverse
|
|
55
|
+
)
|
|
56
|
+
return sorted_nodes
|
|
57
|
+
|
|
58
|
+
def filter(self, attribute: str, value: str) -> List[Any]:
|
|
59
|
+
"""Return nodes where ``attribute`` equals ``value``.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
attribute : str
|
|
64
|
+
Node attribute to compare.
|
|
65
|
+
value : str
|
|
66
|
+
Desired attribute value.
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
list
|
|
71
|
+
Nodes matching the condition.
|
|
72
|
+
"""
|
|
73
|
+
filtered_nodes = [node for node in self if self[node].get(attribute) == value]
|
|
74
|
+
return filtered_nodes
|
|
75
|
+
|
|
76
|
+
def to_dataframe(
|
|
77
|
+
self, node_col: str = "node", weight_col: str = "weight"
|
|
78
|
+
) -> pd.DataFrame:
|
|
79
|
+
"""Convert the node view to a DataFrame.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
node_col : str, optional
|
|
84
|
+
Column name for node identifiers. The default is "node".
|
|
85
|
+
weight_col : str, optional
|
|
86
|
+
Column name for node weights. The default is "weight".
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
pd.DataFrame
|
|
91
|
+
DataFrame with columns for nodes and their weights.
|
|
92
|
+
"""
|
|
93
|
+
data = [
|
|
94
|
+
{node_col: node, weight_col: data.get(weight_col, 1)}
|
|
95
|
+
for node, data in self(data=True)
|
|
96
|
+
]
|
|
97
|
+
return pd.DataFrame(data)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AdjacencyView(nx.classes.coreviews.AdjacencyView):
|
|
101
|
+
"""Adjacency view with sorting and filtering helpers."""
|
|
102
|
+
|
|
103
|
+
def sort(self, attribute: str = "weight", reverse: bool = True) -> List[Any]:
|
|
104
|
+
"""Return adjacent nodes sorted by the given attribute.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
attribute : str, optional
|
|
109
|
+
Attribute used for sorting. The default is "weight".
|
|
110
|
+
reverse : bool, optional
|
|
111
|
+
Sort order. The default is `True`.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
list[Any]
|
|
116
|
+
Sorted adjacent nodes.
|
|
117
|
+
"""
|
|
118
|
+
sorted_nodes = sorted(
|
|
119
|
+
self, key=lambda node: self[node].get(attribute, 1), reverse=reverse
|
|
120
|
+
)
|
|
121
|
+
return sorted_nodes
|
|
122
|
+
|
|
123
|
+
def filter(self, attribute: str, value: str) -> List[Any]:
|
|
124
|
+
"""Return adjacent nodes where ``attribute`` equals ``value``.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
attribute : str
|
|
129
|
+
Node attribute to compare.
|
|
130
|
+
value : str
|
|
131
|
+
Desired attribute value.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
list
|
|
136
|
+
Adjacent nodes matching the value.
|
|
137
|
+
"""
|
|
138
|
+
filtered_nodes = [node for node in self if self[node].get(attribute) == value]
|
|
139
|
+
return filtered_nodes
|
|
140
|
+
|
|
141
|
+
def to_dataframe(
|
|
142
|
+
self, node_col: str = "node", weight_col: str = "weight"
|
|
143
|
+
) -> pd.DataFrame:
|
|
144
|
+
"""Convert the adjacency view to a DataFrame.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
node_col : str, optional
|
|
149
|
+
Column name for node identifiers. The default is "node".
|
|
150
|
+
weight_col : str, optional
|
|
151
|
+
Column name for node weights. The default is "weight".
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
pd.DataFrame
|
|
156
|
+
DataFrame with columns for adjacent nodes and their weights.
|
|
157
|
+
"""
|
|
158
|
+
data = {node: self[node].get(weight_col, 1) for node in self}
|
|
159
|
+
return pd.DataFrame(data)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class EdgeView(nx.classes.reportviews.EdgeView):
|
|
163
|
+
"""Edge view with sorting and filtering helpers."""
|
|
164
|
+
|
|
165
|
+
def __getitem__(self, e: Tuple) -> Dict[str, Any]:
|
|
166
|
+
return super().__getitem__(e)
|
|
167
|
+
|
|
168
|
+
def sort(
|
|
169
|
+
self, attribute: str = "weight", reverse: bool = True
|
|
170
|
+
) -> Dict[Tuple[Any, Any], Dict[str, Any]]:
|
|
171
|
+
"""Return edges sorted by the given attribute.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
attribute : str, optional
|
|
176
|
+
Edge attribute used for sorting. The default is "weight".
|
|
177
|
+
reverse : bool, optional
|
|
178
|
+
Sort order. The default is `True`.
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
dict[tuple[Any, Any], dict[str, Any]]
|
|
183
|
+
Mapping of edge tuples to their attributes.
|
|
184
|
+
"""
|
|
185
|
+
sorted_edges = sorted(
|
|
186
|
+
self(data=True), key=lambda t: t[2].get(attribute, 1), reverse=reverse
|
|
187
|
+
)
|
|
188
|
+
return {(u, v): data for u, v, data in sorted_edges}
|
|
189
|
+
|
|
190
|
+
def filter(self, attribute: str, value: str) -> List[Tuple[Any, Any]]:
|
|
191
|
+
"""Return edges where ``attribute`` equals ``value``.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
attribute : str
|
|
196
|
+
Edge attribute to compare.
|
|
197
|
+
value : str
|
|
198
|
+
Desired attribute value.
|
|
199
|
+
|
|
200
|
+
Returns
|
|
201
|
+
-------
|
|
202
|
+
list[tuple[Any, Any]]
|
|
203
|
+
Edges matching the condition.
|
|
204
|
+
"""
|
|
205
|
+
return [
|
|
206
|
+
(edge[0], edge[1]) for edge in self if self[edge].get(attribute) == value
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
def to_dataframe(
|
|
210
|
+
self,
|
|
211
|
+
source_col: str = "source",
|
|
212
|
+
target_col: str = "target",
|
|
213
|
+
weight_col: str = "weight",
|
|
214
|
+
) -> pd.DataFrame:
|
|
215
|
+
"""Convert the edge view to a DataFrame.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
source_col : str, optional
|
|
220
|
+
Column name for source nodes. The default is "source".
|
|
221
|
+
target_col : str, optional
|
|
222
|
+
Column name for target nodes. The default is "target".
|
|
223
|
+
weight_col : str, optional
|
|
224
|
+
Column name for edge weights. The default is "weight".
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
pd.DataFrame
|
|
229
|
+
DataFrame with columns for source, target and weight.
|
|
230
|
+
"""
|
|
231
|
+
data = [
|
|
232
|
+
{source_col: u, target_col: v, weight_col: data.get(weight_col, 1)}
|
|
233
|
+
for u, v, data in self(data=True)
|
|
234
|
+
]
|
|
235
|
+
return pd.DataFrame(data)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@register_dataframe_accessor("network")
|
|
239
|
+
class NetworkGraph(BasePlot):
|
|
240
|
+
"""Custom graph class based on NetworkX's ``Graph``.
|
|
241
|
+
|
|
242
|
+
Methods
|
|
243
|
+
-------
|
|
244
|
+
compute_positions
|
|
245
|
+
Return node positions computed with a spring layout.
|
|
246
|
+
layout
|
|
247
|
+
Return scaled node sizes, edge widths, and grouped font sizes.
|
|
248
|
+
aplot
|
|
249
|
+
Plot the graph on a provided axis.
|
|
250
|
+
fplot
|
|
251
|
+
Plot the graph and return a new figure.
|
|
252
|
+
aplot_connected_components
|
|
253
|
+
Plot each connected component on a shared axis.
|
|
254
|
+
fplot_connected_components
|
|
255
|
+
Plot each connected component on a new figure.
|
|
256
|
+
get_component_subgraph
|
|
257
|
+
Return the subgraph containing the specified node.
|
|
258
|
+
k_core
|
|
259
|
+
Return the k-core of the graph.
|
|
260
|
+
get_core_subgraph
|
|
261
|
+
Return the 2-core of the graph.
|
|
262
|
+
top_k_edges
|
|
263
|
+
Return the top edges for each node based on an attribute.
|
|
264
|
+
calculate_node_weights_from_edges
|
|
265
|
+
Populate node weights by summing top edge weights.
|
|
266
|
+
trim_edges
|
|
267
|
+
Create a subgraph that retains the top edges per node.
|
|
268
|
+
set_node_attributes
|
|
269
|
+
Set multiple node attributes from a mapping.
|
|
270
|
+
from_pandas_edgelist
|
|
271
|
+
Build a graph from a pandas edge list.
|
|
272
|
+
build_from_dataframes
|
|
273
|
+
Construct a graph from node and edge DataFrames with validation.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
_nx_graph: nx.Graph
|
|
277
|
+
|
|
278
|
+
def __init__(
|
|
279
|
+
self,
|
|
280
|
+
pd_df: Optional[pd.DataFrame] = None,
|
|
281
|
+
nx_graph: Optional[nx.Graph] = None,
|
|
282
|
+
source: str = "source",
|
|
283
|
+
target: str = "target",
|
|
284
|
+
weight: str = "weight",
|
|
285
|
+
):
|
|
286
|
+
"""Initialize with an existing NetworkX graph.
|
|
287
|
+
|
|
288
|
+
Parameters
|
|
289
|
+
----------
|
|
290
|
+
nx_graph : nx.Graph
|
|
291
|
+
Graph to wrap.
|
|
292
|
+
"""
|
|
293
|
+
self._weight_column = weight
|
|
294
|
+
if isinstance(pd_df, nx.Graph) and nx_graph is None:
|
|
295
|
+
nx_graph = pd_df
|
|
296
|
+
pd_df = None
|
|
297
|
+
|
|
298
|
+
if nx_graph is not None:
|
|
299
|
+
self._nx_graph = nx_graph
|
|
300
|
+
elif pd_df is not None:
|
|
301
|
+
self._nx_graph = NetworkGraph.from_pandas_edgelist(
|
|
302
|
+
pd_df, source=source, target=target, edge_weight_col=weight
|
|
303
|
+
)._nx_graph
|
|
304
|
+
else:
|
|
305
|
+
self._nx_graph = nx.Graph()
|
|
306
|
+
|
|
307
|
+
self._scale = 1.0
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def scale(self) -> float:
|
|
311
|
+
"""Return scaling factor for plotting sizes."""
|
|
312
|
+
return self._scale
|
|
313
|
+
|
|
314
|
+
@scale.setter
|
|
315
|
+
def scale(self, value: float):
|
|
316
|
+
"""Set scaling factor for plotting sizes.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
value : float
|
|
321
|
+
Scaling factor.
|
|
322
|
+
"""
|
|
323
|
+
self._scale = value
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def node_view(self) -> NodeView:
|
|
327
|
+
"""Return a ``NodeView`` over the graph."""
|
|
328
|
+
return NodeView(self._nx_graph)
|
|
329
|
+
|
|
330
|
+
@node_view.setter
|
|
331
|
+
def node_view(self, node: Any, attributes: Dict[str, Any]):
|
|
332
|
+
"""Set attributes for a specific node.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
node : Any
|
|
337
|
+
Node identifier.
|
|
338
|
+
attributes : dict
|
|
339
|
+
Attributes to set for the node.
|
|
340
|
+
"""
|
|
341
|
+
self._nx_graph.nodes[node].update(attributes)
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def edge_view(self) -> EdgeView:
|
|
345
|
+
"""Return an ``EdgeView`` over the graph."""
|
|
346
|
+
return EdgeView(self._nx_graph)
|
|
347
|
+
|
|
348
|
+
@edge_view.setter
|
|
349
|
+
def edge_view(self, edge: Tuple[Any, Any], attributes: Dict[str, Any]):
|
|
350
|
+
"""Set attributes for a specific edge.
|
|
351
|
+
|
|
352
|
+
Parameters
|
|
353
|
+
----------
|
|
354
|
+
edge : tuple[Any, Any]
|
|
355
|
+
Edge defined by source and target node identifiers.
|
|
356
|
+
attributes : dict
|
|
357
|
+
Attributes to set for the edge.
|
|
358
|
+
"""
|
|
359
|
+
u, v = edge
|
|
360
|
+
self._nx_graph.edges[u, v].update(attributes)
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def adjacency_view(self) -> AdjacencyView:
|
|
364
|
+
"""Return an ``AdjacencyView`` of the graph."""
|
|
365
|
+
return AdjacencyView(self._nx_graph.adj)
|
|
366
|
+
|
|
367
|
+
@adjacency_view.setter
|
|
368
|
+
def adjacency_view(self, node: Any, attributes: Dict[str, Any]):
|
|
369
|
+
"""Set attributes for a specific node.
|
|
370
|
+
|
|
371
|
+
Parameters
|
|
372
|
+
----------
|
|
373
|
+
node : Any
|
|
374
|
+
Node identifier.
|
|
375
|
+
attributes : dict
|
|
376
|
+
Attributes to set for the node.
|
|
377
|
+
"""
|
|
378
|
+
self._nx_graph.adj[node].update(attributes)
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def connected_components(self) -> List[set]:
|
|
382
|
+
"""Return the connected components of the graph."""
|
|
383
|
+
return list(nx.connected_components(self._nx_graph))
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def number_of_nodes(self) -> int:
|
|
387
|
+
"""Return the number of nodes in the graph."""
|
|
388
|
+
return self._nx_graph.number_of_nodes()
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def number_of_edges(self) -> int:
|
|
392
|
+
"""Return the number of edges in the graph."""
|
|
393
|
+
return self._nx_graph.number_of_edges()
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def density(self) -> float:
|
|
397
|
+
"""Return the density of the graph."""
|
|
398
|
+
return nx.density(self._nx_graph)
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def is_connected(self) -> bool:
|
|
402
|
+
"""Return whether the graph is connected."""
|
|
403
|
+
return nx.is_connected(self._nx_graph)
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def average_clustering(self) -> float:
|
|
407
|
+
"""Return the average clustering coefficient of the graph."""
|
|
408
|
+
return nx.average_clustering(self._nx_graph)
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def diameter(self) -> int:
|
|
412
|
+
"""Return the diameter of the graph."""
|
|
413
|
+
return nx.diameter(self._nx_graph)
|
|
414
|
+
|
|
415
|
+
@property
|
|
416
|
+
def radius(self) -> int:
|
|
417
|
+
"""Return the radius of the graph."""
|
|
418
|
+
return nx.radius(self._nx_graph)
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def center(self) -> List[Any]:
|
|
422
|
+
"""Return the center nodes of the graph."""
|
|
423
|
+
return nx.center(self._nx_graph)
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def periphery(self) -> List[Any]:
|
|
427
|
+
"""Return the periphery nodes of the graph."""
|
|
428
|
+
return nx.periphery(self._nx_graph)
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def average_shortest_path_length(self) -> float:
|
|
432
|
+
"""Return the average shortest path length of the graph."""
|
|
433
|
+
return nx.average_shortest_path_length(self._nx_graph)
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def transitivity(self) -> float:
|
|
437
|
+
"""Return the transitivity of the graph."""
|
|
438
|
+
return nx.transitivity(self._nx_graph)
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def clustering_coefficients(self) -> Dict[Any, float]:
|
|
442
|
+
"""Return the clustering coefficients of the graph."""
|
|
443
|
+
return nx.clustering(self._nx_graph) # pyright: ignore[reportReturnType]
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def degree_assortativity_coefficient(self) -> float:
|
|
447
|
+
"""Return the degree assortativity coefficient of the graph."""
|
|
448
|
+
return nx.degree_assortativity_coefficient(self._nx_graph)
|
|
449
|
+
|
|
450
|
+
def add_node(self, node: Any, **attributes: Any):
|
|
451
|
+
"""Add a node with optional attributes.
|
|
452
|
+
|
|
453
|
+
Parameters
|
|
454
|
+
----------
|
|
455
|
+
node : Any
|
|
456
|
+
Node identifier.
|
|
457
|
+
**attributes : dict
|
|
458
|
+
Arbitrary node attributes as keyword arguments.
|
|
459
|
+
"""
|
|
460
|
+
self._nx_graph.add_node(node, **attributes)
|
|
461
|
+
|
|
462
|
+
def add_nodes_from(self, nodes: Iterable, **attributes: Any):
|
|
463
|
+
"""Add multiple nodes with optional attributes.
|
|
464
|
+
|
|
465
|
+
Parameters
|
|
466
|
+
----------
|
|
467
|
+
nodes : Iterable
|
|
468
|
+
Node identifiers to add.
|
|
469
|
+
**attributes : dict
|
|
470
|
+
Arbitrary node attributes as keyword arguments.
|
|
471
|
+
"""
|
|
472
|
+
self._nx_graph.add_nodes_from(nodes, **attributes)
|
|
473
|
+
|
|
474
|
+
def add_edge(self, source: Any, target: Any, **attributes: Any):
|
|
475
|
+
"""Add an edge with optional attributes.
|
|
476
|
+
|
|
477
|
+
Parameters
|
|
478
|
+
----------
|
|
479
|
+
source : Any
|
|
480
|
+
Source node identifier.
|
|
481
|
+
target : Any
|
|
482
|
+
Target node identifier.
|
|
483
|
+
**attributes : dict
|
|
484
|
+
Arbitrary edge attributes as keyword arguments.
|
|
485
|
+
"""
|
|
486
|
+
self._nx_graph.add_edge(source, target, **attributes)
|
|
487
|
+
|
|
488
|
+
def add_edges_from(self, edges: Iterable[Tuple[Any, Any]], **attributes: Any):
|
|
489
|
+
"""Add multiple edges with optional attributes.
|
|
490
|
+
|
|
491
|
+
Parameters
|
|
492
|
+
----------
|
|
493
|
+
edges : Iterable[tuple[Any, Any]]
|
|
494
|
+
Edge tuples defined by source and target node identifiers.
|
|
495
|
+
**attributes : dict
|
|
496
|
+
Arbitrary edge attributes as keyword arguments.
|
|
497
|
+
"""
|
|
498
|
+
self._nx_graph.add_edges_from(edges, **attributes)
|
|
499
|
+
|
|
500
|
+
def subgraph_edges(self, edges: Iterable) -> "NetworkGraph":
|
|
501
|
+
"""Return a subgraph containing only the specified edges.
|
|
502
|
+
|
|
503
|
+
Parameters
|
|
504
|
+
----------
|
|
505
|
+
edges : Iterable
|
|
506
|
+
Edges to include.
|
|
507
|
+
|
|
508
|
+
Returns
|
|
509
|
+
-------
|
|
510
|
+
NetworkGraph
|
|
511
|
+
Subgraph with only ``edges``.
|
|
512
|
+
"""
|
|
513
|
+
return NetworkGraph(nx.edge_subgraph(self._nx_graph, edges).copy())
|
|
514
|
+
|
|
515
|
+
def layout(
|
|
516
|
+
self,
|
|
517
|
+
max_node_size: int = _DEFAULT["MAX_NODE_SIZE"],
|
|
518
|
+
min_node_size: int = _DEFAULT["MIN_NODE_SIZE"],
|
|
519
|
+
max_edge_width: int = _DEFAULT["MAX_EDGE_WIDTH"],
|
|
520
|
+
max_font_size: int = _DEFAULT["MAX_FONT_SIZE"],
|
|
521
|
+
min_font_size: int = _DEFAULT["MIN_FONT_SIZE"],
|
|
522
|
+
edge_weight_col: str = "weight",
|
|
523
|
+
) -> Tuple[List[float], List[float], Dict[int, List[str]]]:
|
|
524
|
+
"""Calculate node, edge and font sizes based on weights.
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
max_node_size : int, optional
|
|
529
|
+
Upper bound for node size. The default is `_DEFAULT["MAX_NODE_SIZE"]`.
|
|
530
|
+
min_node_size : int, optional
|
|
531
|
+
Lower bound for node size. The default is `_DEFAULT["MIN_NODE_SIZE"]`.
|
|
532
|
+
max_edge_width : int, optional
|
|
533
|
+
Upper bound for edge width. The default is `_DEFAULT["MAX_EDGE_WIDTH"]`.
|
|
534
|
+
max_font_size : int, optional
|
|
535
|
+
Upper bound for font size. The default is `_DEFAULT["MAX_FONT_SIZE"]`.
|
|
536
|
+
min_font_size : int, optional
|
|
537
|
+
Lower bound for font size. The default is `_DEFAULT["MIN_FONT_SIZE"]`.
|
|
538
|
+
edge_weight_col : str, optional
|
|
539
|
+
Node attribute used for weighting. The default is "weight".
|
|
540
|
+
|
|
541
|
+
Returns
|
|
542
|
+
-------
|
|
543
|
+
tuple[list[float], list[float], dict[int, list[str]]]
|
|
544
|
+
Node sizes, edge widths and nodes grouped by font size.
|
|
545
|
+
"""
|
|
546
|
+
# Normalize and scale nodes' weights within the desired range of edge widths
|
|
547
|
+
node_weights = [
|
|
548
|
+
data.get(edge_weight_col, 1) for node, data in self.node_view(data=True)
|
|
549
|
+
]
|
|
550
|
+
node_deciles = (
|
|
551
|
+
np.percentile(np.array(node_weights), _WEIGHT_PERCENTILES)
|
|
552
|
+
if node_weights
|
|
553
|
+
else None
|
|
554
|
+
)
|
|
555
|
+
node_size = _scale_weights(
|
|
556
|
+
weights=node_weights,
|
|
557
|
+
scale_max=max_node_size,
|
|
558
|
+
scale_min=min_node_size,
|
|
559
|
+
deciles=node_deciles,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
# Normalize and scale edges' weights within the desired range of edge widths
|
|
563
|
+
edge_weights = [
|
|
564
|
+
data.get(edge_weight_col, 1) for _, _, data in self.edge_view(data=True)
|
|
565
|
+
]
|
|
566
|
+
edges_width = _scale_weights(weights=edge_weights, scale_max=max_edge_width)
|
|
567
|
+
|
|
568
|
+
# Scale the normalized node weights within the desired range of font sizes
|
|
569
|
+
node_size_dict = dict(
|
|
570
|
+
zip(
|
|
571
|
+
self.node_view,
|
|
572
|
+
_scale_weights(
|
|
573
|
+
weights=node_weights,
|
|
574
|
+
scale_max=max_font_size,
|
|
575
|
+
scale_min=min_font_size,
|
|
576
|
+
deciles=node_deciles,
|
|
577
|
+
),
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
fonts_size = defaultdict(list)
|
|
581
|
+
for node, width in node_size_dict.items():
|
|
582
|
+
fonts_size[int(width)].append(node)
|
|
583
|
+
fonts_size = dict(fonts_size)
|
|
584
|
+
|
|
585
|
+
return node_size, edges_width, fonts_size
|
|
586
|
+
|
|
587
|
+
def subgraph(
|
|
588
|
+
self,
|
|
589
|
+
node_list: Optional[List[str]] = None,
|
|
590
|
+
max_edges: int = _DEFAULT["MAX_EDGES"],
|
|
591
|
+
min_degree: int = 2,
|
|
592
|
+
top_k_edges_per_node: int = 5,
|
|
593
|
+
) -> "NetworkGraph":
|
|
594
|
+
"""Return a trimmed subgraph limited by nodes and edges.
|
|
595
|
+
|
|
596
|
+
Parameters
|
|
597
|
+
----------
|
|
598
|
+
node_list : list[str], optional
|
|
599
|
+
Nodes to include.
|
|
600
|
+
max_edges : int, optional
|
|
601
|
+
Maximum edges to retain. The default is `_DEFAULT["MAX_EDGES"]`.
|
|
602
|
+
min_degree : int, optional
|
|
603
|
+
Minimum degree for nodes in the core subgraph. The default is 2.
|
|
604
|
+
top_k_edges_per_node : int, optional
|
|
605
|
+
Number of top edges to keep per node. The default is 5.
|
|
606
|
+
|
|
607
|
+
Returns
|
|
608
|
+
-------
|
|
609
|
+
NetworkGraph
|
|
610
|
+
Trimmed subgraph.
|
|
611
|
+
"""
|
|
612
|
+
if node_list is None:
|
|
613
|
+
node_list = self.node_view.sort("weight")[: _DEFAULT["MAX_NODES"]]
|
|
614
|
+
core_subgraph_nodes = list(self.k_core(k=min_degree).node_view)
|
|
615
|
+
node_list = [node for node in node_list if node in core_subgraph_nodes]
|
|
616
|
+
_subgraph = nx.subgraph(self._nx_graph, node_list)
|
|
617
|
+
subgraph = NetworkGraph(_subgraph)
|
|
618
|
+
if top_k_edges_per_node > 0:
|
|
619
|
+
edges = subgraph.top_k_edges(
|
|
620
|
+
attribute="weight", k=top_k_edges_per_node
|
|
621
|
+
).keys()
|
|
622
|
+
subgraph = subgraph.subgraph_edges(list(edges)[:max_edges])
|
|
623
|
+
return subgraph
|
|
624
|
+
|
|
625
|
+
def compute_positions(
|
|
626
|
+
self,
|
|
627
|
+
k: Optional[float] = None,
|
|
628
|
+
seed: Optional[int] = _DEFAULT["SPRING_LAYOUT_SEED"],
|
|
629
|
+
) -> Dict[Any, np.ndarray]:
|
|
630
|
+
"""Return spring layout positions for the graph.
|
|
631
|
+
|
|
632
|
+
Parameters
|
|
633
|
+
----------
|
|
634
|
+
k : float, optional
|
|
635
|
+
Optimal distance between nodes. The default is ``_DEFAULT["SPRING_LAYOUT_K"]``.
|
|
636
|
+
seed : int, optional
|
|
637
|
+
Seed for reproducible layouts. The default is ``_DEFAULT["SPRING_LAYOUT_SEED"]``.
|
|
638
|
+
|
|
639
|
+
Returns
|
|
640
|
+
-------
|
|
641
|
+
dict[Any, np.ndarray]
|
|
642
|
+
Mapping of nodes to their layout coordinates.
|
|
643
|
+
"""
|
|
644
|
+
layout_k = _DEFAULT["SPRING_LAYOUT_K"] if k is None else k
|
|
645
|
+
return nx.spring_layout(self._nx_graph, k=layout_k, seed=seed)
|
|
646
|
+
|
|
647
|
+
def subgraph_component(self, node: Any) -> "NetworkGraph":
|
|
648
|
+
"""Return the connected component containing ``node``.
|
|
649
|
+
|
|
650
|
+
Parameters
|
|
651
|
+
----------
|
|
652
|
+
node : Any
|
|
653
|
+
Node identifier to anchor the component selection.
|
|
654
|
+
|
|
655
|
+
Returns
|
|
656
|
+
-------
|
|
657
|
+
NetworkGraph
|
|
658
|
+
Subgraph made of the nodes in the same connected component as
|
|
659
|
+
``node``.
|
|
660
|
+
|
|
661
|
+
Raises
|
|
662
|
+
------
|
|
663
|
+
ValueError
|
|
664
|
+
If ``node`` is not present in the graph.
|
|
665
|
+
"""
|
|
666
|
+
if node not in self._nx_graph:
|
|
667
|
+
raise ValueError(f"Node {node!r} is not present in the graph.")
|
|
668
|
+
|
|
669
|
+
component_nodes = next(
|
|
670
|
+
(
|
|
671
|
+
component
|
|
672
|
+
for component in nx.connected_components(self._nx_graph)
|
|
673
|
+
if node in component
|
|
674
|
+
),
|
|
675
|
+
None,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
if component_nodes is None:
|
|
679
|
+
return NetworkGraph()
|
|
680
|
+
|
|
681
|
+
return NetworkGraph(nx.subgraph(self._nx_graph, component_nodes).copy())
|
|
682
|
+
|
|
683
|
+
def aplot(
|
|
684
|
+
self,
|
|
685
|
+
title: Optional[str] = None,
|
|
686
|
+
style: StyleTemplate = NETWORK_STYLE_TEMPLATE,
|
|
687
|
+
edge_weight_col: str = "weight",
|
|
688
|
+
layout_seed: Optional[int] = _DEFAULT["SPRING_LAYOUT_SEED"],
|
|
689
|
+
ax: Optional[Axes] = None,
|
|
690
|
+
**kwargs: Any,
|
|
691
|
+
) -> Axes:
|
|
692
|
+
"""Plot the graph using node and edge weights.
|
|
693
|
+
|
|
694
|
+
Parameters
|
|
695
|
+
----------
|
|
696
|
+
title : str, optional
|
|
697
|
+
Plot title.
|
|
698
|
+
style : StyleTemplate, optional
|
|
699
|
+
Style configuration. The default is `NETWORK_STYLE_TEMPLATE`.
|
|
700
|
+
edge_weight_col : str, optional
|
|
701
|
+
Edge attribute used for weighting. The default is "weight".
|
|
702
|
+
layout_seed : int, optional
|
|
703
|
+
Seed for the spring layout used to place nodes. The default is ``_DEFAULT["SPRING_LAYOUT_SEED"]``.
|
|
704
|
+
ax : Axes, optional
|
|
705
|
+
Axes to draw on.
|
|
706
|
+
|
|
707
|
+
Returns
|
|
708
|
+
-------
|
|
709
|
+
Axes
|
|
710
|
+
Matplotlib axes with the plotted network.
|
|
711
|
+
"""
|
|
712
|
+
sns.set_palette(style.palette)
|
|
713
|
+
if ax is None:
|
|
714
|
+
ax = cast(Axes, plt.gca())
|
|
715
|
+
|
|
716
|
+
isolated_nodes = list(nx.isolates(self._nx_graph))
|
|
717
|
+
graph_nx = self._nx_graph
|
|
718
|
+
if isolated_nodes:
|
|
719
|
+
graph_nx = graph_nx.copy()
|
|
720
|
+
graph_nx.remove_nodes_from(isolated_nodes)
|
|
721
|
+
|
|
722
|
+
graph = self if graph_nx is self._nx_graph else NetworkGraph(nx_graph=graph_nx)
|
|
723
|
+
|
|
724
|
+
if graph._nx_graph.number_of_nodes() == 0:
|
|
725
|
+
ax.set_axis_off()
|
|
726
|
+
if title:
|
|
727
|
+
ax.set_title(
|
|
728
|
+
title,
|
|
729
|
+
color=style.font_color,
|
|
730
|
+
fontsize=style.font_size * TITLE_SCALE_FACTOR,
|
|
731
|
+
)
|
|
732
|
+
return ax
|
|
733
|
+
|
|
734
|
+
mapped_min_font_size = style.font_mapping.get(0)
|
|
735
|
+
mapped_max_font_size = style.font_mapping.get(4)
|
|
736
|
+
|
|
737
|
+
node_sizes, edge_widths, font_sizes = graph.layout(
|
|
738
|
+
min_node_size=_DEFAULT["MIN_NODE_SIZE"],
|
|
739
|
+
max_node_size=_DEFAULT["MAX_NODE_SIZE"],
|
|
740
|
+
max_edge_width=_DEFAULT["MAX_EDGE_WIDTH"],
|
|
741
|
+
min_font_size=(
|
|
742
|
+
mapped_min_font_size
|
|
743
|
+
if mapped_min_font_size is not None
|
|
744
|
+
else _DEFAULT["MIN_FONT_SIZE"]
|
|
745
|
+
),
|
|
746
|
+
max_font_size=(
|
|
747
|
+
mapped_max_font_size
|
|
748
|
+
if mapped_max_font_size is not None
|
|
749
|
+
else _DEFAULT["MAX_FONT_SIZE"]
|
|
750
|
+
),
|
|
751
|
+
edge_weight_col=edge_weight_col,
|
|
752
|
+
)
|
|
753
|
+
pos = graph.compute_positions(seed=layout_seed)
|
|
754
|
+
# nodes
|
|
755
|
+
node_sizes_int = [int(size) for size in node_sizes]
|
|
756
|
+
nx.draw_networkx_nodes(
|
|
757
|
+
graph._nx_graph,
|
|
758
|
+
pos,
|
|
759
|
+
ax=ax,
|
|
760
|
+
node_size=cast(Any, node_sizes_int),
|
|
761
|
+
node_color=cast(Any, node_sizes),
|
|
762
|
+
cmap=plt.get_cmap(style.palette),
|
|
763
|
+
)
|
|
764
|
+
# edges
|
|
765
|
+
nx.draw_networkx_edges(
|
|
766
|
+
graph._nx_graph,
|
|
767
|
+
pos,
|
|
768
|
+
ax=ax,
|
|
769
|
+
edge_color=style.font_color,
|
|
770
|
+
edge_cmap=plt.get_cmap(style.palette),
|
|
771
|
+
width=cast(Any, edge_widths),
|
|
772
|
+
)
|
|
773
|
+
# labels
|
|
774
|
+
for font_size, nodes in font_sizes.items():
|
|
775
|
+
nx.draw_networkx_labels(
|
|
776
|
+
graph._nx_graph,
|
|
777
|
+
pos,
|
|
778
|
+
ax=ax,
|
|
779
|
+
font_size=font_size,
|
|
780
|
+
font_color=style.font_color,
|
|
781
|
+
labels={n: string_formatter(n) for n in nodes},
|
|
782
|
+
)
|
|
783
|
+
ax.set_facecolor(style.background_color)
|
|
784
|
+
if title:
|
|
785
|
+
ax.set_title(
|
|
786
|
+
title,
|
|
787
|
+
color=style.font_color,
|
|
788
|
+
fontsize=style.font_size * TITLE_SCALE_FACTOR,
|
|
789
|
+
)
|
|
790
|
+
ax.set_axis_off()
|
|
791
|
+
|
|
792
|
+
return ax
|
|
793
|
+
|
|
794
|
+
def fplot(
|
|
795
|
+
self,
|
|
796
|
+
title: Optional[str] = None,
|
|
797
|
+
style: StyleTemplate = NETWORK_STYLE_TEMPLATE,
|
|
798
|
+
layout_seed: Optional[int] = _DEFAULT["SPRING_LAYOUT_SEED"],
|
|
799
|
+
figsize: Tuple[float, float] = FIG_SIZE,
|
|
800
|
+
) -> Figure:
|
|
801
|
+
"""Plot the graph using node and edge weights.
|
|
802
|
+
|
|
803
|
+
Parameters
|
|
804
|
+
----------
|
|
805
|
+
title : str, optional
|
|
806
|
+
Plot title.
|
|
807
|
+
style : StyleTemplate, optional
|
|
808
|
+
Style configuration. The default is `NETWORK_STYLE_TEMPLATE`.
|
|
809
|
+
edge_weight_col : str, optional
|
|
810
|
+
Edge attribute used for weighting. The default is "weight".
|
|
811
|
+
layout_seed : int, optional
|
|
812
|
+
Seed for the spring layout used to place nodes. The default is ``_DEFAULT["SPRING_LAYOUT_SEED"]``.
|
|
813
|
+
|
|
814
|
+
Returns
|
|
815
|
+
-------
|
|
816
|
+
Figure
|
|
817
|
+
Matplotlib figure with the plotted network.
|
|
818
|
+
"""
|
|
819
|
+
fig = Figure(
|
|
820
|
+
figsize=figsize,
|
|
821
|
+
facecolor=style.background_color,
|
|
822
|
+
edgecolor=style.background_color,
|
|
823
|
+
)
|
|
824
|
+
ax = Axes(fig=fig, facecolor=style.background_color)
|
|
825
|
+
self.aplot(
|
|
826
|
+
title=title,
|
|
827
|
+
style=style,
|
|
828
|
+
edge_weight_col="",
|
|
829
|
+
layout_seed=layout_seed,
|
|
830
|
+
ax=ax,
|
|
831
|
+
)
|
|
832
|
+
return fig
|
|
833
|
+
|
|
834
|
+
def aplot_connected_components(
|
|
835
|
+
self,
|
|
836
|
+
title: Optional[str] = None,
|
|
837
|
+
style: StyleTemplate = NETWORK_STYLE_TEMPLATE,
|
|
838
|
+
edge_weight_col: str = "weight",
|
|
839
|
+
layout_seed: Optional[int] = _DEFAULT["SPRING_LAYOUT_SEED"],
|
|
840
|
+
axes: Optional[np.ndarray] = None,
|
|
841
|
+
) -> Union[Axes, np.ndarray]:
|
|
842
|
+
"""Plot all connected components of the graph.
|
|
843
|
+
|
|
844
|
+
Parameters
|
|
845
|
+
----------
|
|
846
|
+
title : str, optional
|
|
847
|
+
Base title for component subplots. When provided, each axis title is
|
|
848
|
+
suffixed with the component index.
|
|
849
|
+
style : StyleTemplate, optional
|
|
850
|
+
Style configuration. The default is `NETWORK_STYLE_TEMPLATE`.
|
|
851
|
+
edge_weight_col : str, optional
|
|
852
|
+
Edge attribute used for weighting. The default is "weight".
|
|
853
|
+
layout_seed : int, optional
|
|
854
|
+
Seed for the spring layout used to place nodes. The default is ``_DEFAULT["SPRING_LAYOUT_SEED"]``.
|
|
855
|
+
axes : np.ndarray, optional
|
|
856
|
+
Existing axes to draw each component on. If None, a grid is created
|
|
857
|
+
based on the number of components.
|
|
858
|
+
|
|
859
|
+
Returns
|
|
860
|
+
-------
|
|
861
|
+
Union[Axes, np.ndarray]
|
|
862
|
+
Matplotlib axes with the plotted network. When ``axes`` is provided
|
|
863
|
+
or created, the flattened array of axes is returned; otherwise, a
|
|
864
|
+
single Axes is returned.
|
|
865
|
+
"""
|
|
866
|
+
sns.set_palette(style.palette)
|
|
867
|
+
|
|
868
|
+
graph = self
|
|
869
|
+
isolated_nodes = list(nx.isolates(self._nx_graph))
|
|
870
|
+
if isolated_nodes:
|
|
871
|
+
graph = NetworkGraph(nx_graph=self._nx_graph.copy())
|
|
872
|
+
graph._nx_graph.remove_nodes_from(isolated_nodes)
|
|
873
|
+
|
|
874
|
+
connected_components = list(nx.connected_components(graph._nx_graph))
|
|
875
|
+
|
|
876
|
+
local_axes = axes
|
|
877
|
+
created_axes = False
|
|
878
|
+
|
|
879
|
+
if not connected_components:
|
|
880
|
+
if local_axes is None:
|
|
881
|
+
local_axes = np.array([cast(Axes, plt.gca())])
|
|
882
|
+
for axis in local_axes.flatten():
|
|
883
|
+
axis.set_facecolor(style.background_color)
|
|
884
|
+
axis.set_axis_off()
|
|
885
|
+
return local_axes
|
|
886
|
+
|
|
887
|
+
if local_axes is None:
|
|
888
|
+
_, local_axes = _compute_network_grid(connected_components, style)
|
|
889
|
+
created_axes = True
|
|
890
|
+
|
|
891
|
+
for i, component in enumerate(connected_components):
|
|
892
|
+
if i >= len(local_axes):
|
|
893
|
+
break
|
|
894
|
+
component_graph = NetworkGraph(
|
|
895
|
+
nx.subgraph(graph._nx_graph, component).copy()
|
|
896
|
+
)
|
|
897
|
+
component_graph.aplot(
|
|
898
|
+
title=f"{title}::{i}" if title else str(i),
|
|
899
|
+
style=style,
|
|
900
|
+
edge_weight_col=edge_weight_col,
|
|
901
|
+
layout_seed=layout_seed,
|
|
902
|
+
ax=cast(Axes, local_axes[i]),
|
|
903
|
+
)
|
|
904
|
+
cast(Axes, local_axes[i]).set_axis_on()
|
|
905
|
+
|
|
906
|
+
for axis in local_axes[len(connected_components) :]:
|
|
907
|
+
axis.set_axis_off()
|
|
908
|
+
|
|
909
|
+
return local_axes if created_axes or len(local_axes) > 1 else local_axes[0]
|
|
910
|
+
|
|
911
|
+
def fplot_connected_components(
|
|
912
|
+
self,
|
|
913
|
+
title: Optional[str] = None,
|
|
914
|
+
style: StyleTemplate = NETWORK_STYLE_TEMPLATE,
|
|
915
|
+
edge_weight_col: str = "weight",
|
|
916
|
+
layout_seed: Optional[int] = _DEFAULT["SPRING_LAYOUT_SEED"],
|
|
917
|
+
) -> Figure:
|
|
918
|
+
"""Plot all connected components of the graph.
|
|
919
|
+
|
|
920
|
+
Parameters
|
|
921
|
+
----------
|
|
922
|
+
title : str, optional
|
|
923
|
+
Plot title to apply to the first component axis.
|
|
924
|
+
style : StyleTemplate, optional
|
|
925
|
+
Style configuration. The default is `NETWORK_STYLE_TEMPLATE`.
|
|
926
|
+
edge_weight_col : str, optional
|
|
927
|
+
Edge attribute used for weighting. The default is "weight".
|
|
928
|
+
layout_seed : int, optional
|
|
929
|
+
Seed for the spring layout used to place nodes. The default is ``_DEFAULT["SPRING_LAYOUT_SEED"]``.
|
|
930
|
+
|
|
931
|
+
Returns
|
|
932
|
+
-------
|
|
933
|
+
Figure
|
|
934
|
+
Matplotlib figure with the plotted network.
|
|
935
|
+
"""
|
|
936
|
+
fig, ax = plt.subplots(figsize=FIG_SIZE)
|
|
937
|
+
fig = cast(Figure, fig)
|
|
938
|
+
fig.set_facecolor(style.background_color)
|
|
939
|
+
self.aplot_connected_components(
|
|
940
|
+
title=title,
|
|
941
|
+
style=style,
|
|
942
|
+
edge_weight_col=edge_weight_col,
|
|
943
|
+
layout_seed=layout_seed,
|
|
944
|
+
axes=np.array([ax]),
|
|
945
|
+
)
|
|
946
|
+
return fig
|
|
947
|
+
|
|
948
|
+
def k_core(self, k: int = 2) -> "NetworkGraph":
|
|
949
|
+
"""Return the k-core of the graph.
|
|
950
|
+
|
|
951
|
+
The k-core is a subgraph containing only nodes with degree >= k.
|
|
952
|
+
|
|
953
|
+
Parameters
|
|
954
|
+
----------
|
|
955
|
+
k : int, optional
|
|
956
|
+
The minimum degree for nodes in the core. The default is 2.
|
|
957
|
+
|
|
958
|
+
Returns
|
|
959
|
+
-------
|
|
960
|
+
NetworkGraph
|
|
961
|
+
The k-core subgraph.
|
|
962
|
+
"""
|
|
963
|
+
core_graph = nx.k_core(self._nx_graph, k=k)
|
|
964
|
+
return NetworkGraph(core_graph)
|
|
965
|
+
|
|
966
|
+
def subgraph_core(self) -> "NetworkGraph":
|
|
967
|
+
"""Return the 2-core of the graph.
|
|
968
|
+
|
|
969
|
+
Returns
|
|
970
|
+
-------
|
|
971
|
+
NetworkGraph
|
|
972
|
+
The k-core subgraph with minimum degree 2.
|
|
973
|
+
"""
|
|
974
|
+
return self.k_core(k=2)
|
|
975
|
+
|
|
976
|
+
def top_k_edges(
|
|
977
|
+
self, attribute: str, reverse: bool = True, k: int = 5
|
|
978
|
+
) -> Dict[Any, List[Tuple[Any, Dict]]]:
|
|
979
|
+
"""Return the top ``k`` edges based on a given attribute.
|
|
980
|
+
|
|
981
|
+
Parameters
|
|
982
|
+
----------
|
|
983
|
+
attribute : str
|
|
984
|
+
Attribute name used for sorting.
|
|
985
|
+
reverse : bool, optional
|
|
986
|
+
Whether to sort in descending order. The default is `True`.
|
|
987
|
+
k : int, optional
|
|
988
|
+
Number of top edges to return. The default is 5.
|
|
989
|
+
|
|
990
|
+
Returns
|
|
991
|
+
-------
|
|
992
|
+
dict[Any, list[tuple[Any, dict]]]
|
|
993
|
+
Mapping of edge tuples to attribute values.
|
|
994
|
+
"""
|
|
995
|
+
top_list = {}
|
|
996
|
+
for node in self.node_view:
|
|
997
|
+
edges = self.edge_view(node, data=True)
|
|
998
|
+
edges_sorted = sorted(
|
|
999
|
+
edges, key=lambda x: x[2].get(attribute, 0), reverse=reverse
|
|
1000
|
+
)
|
|
1001
|
+
top_k_edges = edges_sorted[:k]
|
|
1002
|
+
for u, v, data in top_k_edges:
|
|
1003
|
+
edge_key = (u, v)
|
|
1004
|
+
top_list[edge_key] = data[attribute]
|
|
1005
|
+
return top_list
|
|
1006
|
+
|
|
1007
|
+
def calculate_nodes(
|
|
1008
|
+
self,
|
|
1009
|
+
edge_weight_col: str = "weight",
|
|
1010
|
+
k: int = 10,
|
|
1011
|
+
):
|
|
1012
|
+
"""Calculate node weights by summing weights of top k edges.
|
|
1013
|
+
|
|
1014
|
+
Parameters
|
|
1015
|
+
----------
|
|
1016
|
+
edge_weight_col : str, optional
|
|
1017
|
+
Edge attribute to use for weighting. The default is "weight".
|
|
1018
|
+
k : int, optional
|
|
1019
|
+
Number of top edges to consider for each node. The default is 10.
|
|
1020
|
+
"""
|
|
1021
|
+
top_edges = self.top_k_edges(attribute=edge_weight_col, k=k)
|
|
1022
|
+
attributes: dict[Any, dict[str, Any]] = {}
|
|
1023
|
+
for (u, v), weight_value in top_edges.items():
|
|
1024
|
+
for node in [u, v]:
|
|
1025
|
+
if node not in attributes:
|
|
1026
|
+
attributes[node] = {edge_weight_col: 0, "edges": 0}
|
|
1027
|
+
attributes[node][edge_weight_col] += weight_value
|
|
1028
|
+
attributes[node]["edges"] += 1
|
|
1029
|
+
|
|
1030
|
+
self.set_node_attributes(attributes=attributes)
|
|
1031
|
+
|
|
1032
|
+
def trim_edges(
|
|
1033
|
+
self, edge_weight_col: str = "weight", k: int = 10
|
|
1034
|
+
) -> "NetworkGraph":
|
|
1035
|
+
"""Trim the graph to keep only the top k edges per node.
|
|
1036
|
+
|
|
1037
|
+
Parameters
|
|
1038
|
+
----------
|
|
1039
|
+
edge_weight_col : str, optional
|
|
1040
|
+
Edge attribute to use for sorting. The default is "weight".
|
|
1041
|
+
top_k_per_node : int, optional
|
|
1042
|
+
Number of top edges to keep per node. The default is 5.
|
|
1043
|
+
|
|
1044
|
+
Returns
|
|
1045
|
+
-------
|
|
1046
|
+
NetworkGraph
|
|
1047
|
+
A new graph containing only the top edges.
|
|
1048
|
+
"""
|
|
1049
|
+
edges_to_keep = self.top_k_edges(attribute=edge_weight_col, k=k)
|
|
1050
|
+
network_X = self.subgraph_edges(edges=edges_to_keep)
|
|
1051
|
+
network_X.sanitize_network()
|
|
1052
|
+
return network_X
|
|
1053
|
+
|
|
1054
|
+
def set_node_attributes(self, attributes: Dict[Any, Dict[str, Any]]):
|
|
1055
|
+
"""Set multiple node attributes from a dictionary.
|
|
1056
|
+
|
|
1057
|
+
Parameters
|
|
1058
|
+
----------
|
|
1059
|
+
attributes : Dict[Any, Dict[str, Any]]
|
|
1060
|
+
Mapping of node identifiers to their attribute dictionaries.
|
|
1061
|
+
"""
|
|
1062
|
+
for node, attrs in attributes.items():
|
|
1063
|
+
nx.set_node_attributes(self._nx_graph, {node: attrs})
|
|
1064
|
+
|
|
1065
|
+
@staticmethod
|
|
1066
|
+
def from_pandas_edgelist(
|
|
1067
|
+
edges_df: pd.DataFrame,
|
|
1068
|
+
source: str = "source",
|
|
1069
|
+
target: str = "target",
|
|
1070
|
+
edge_weight_col: str = "weight",
|
|
1071
|
+
k: int = 10,
|
|
1072
|
+
) -> "NetworkGraph":
|
|
1073
|
+
"""Initialize a NetworkGraph from a simple DataFrame.
|
|
1074
|
+
|
|
1075
|
+
Parameters
|
|
1076
|
+
----------
|
|
1077
|
+
edges_df : pd.DataFrame
|
|
1078
|
+
DataFrame containing edge data.
|
|
1079
|
+
source : str, optional
|
|
1080
|
+
Column name for source nodes. The default is "source".
|
|
1081
|
+
target : str, optional
|
|
1082
|
+
Column name for target nodes. The default is "target".
|
|
1083
|
+
edge_weight_col : str, optional
|
|
1084
|
+
Column name for edge weights. The default is "weight".
|
|
1085
|
+
|
|
1086
|
+
Returns
|
|
1087
|
+
-------
|
|
1088
|
+
NetworkGraph
|
|
1089
|
+
Initialized network graph.
|
|
1090
|
+
"""
|
|
1091
|
+
validate_dataframe(edges_df, cols=[source, target, edge_weight_col])
|
|
1092
|
+
network_Z = NetworkGraph()
|
|
1093
|
+
for src, dst, weight in edges_df[[source, target, edge_weight_col]].itertuples(
|
|
1094
|
+
index=False, name=None
|
|
1095
|
+
):
|
|
1096
|
+
network_Z.add_edge(src, dst, **{edge_weight_col: weight})
|
|
1097
|
+
|
|
1098
|
+
network_Z.calculate_nodes(edge_weight_col=edge_weight_col, k=k)
|
|
1099
|
+
network_Z.sanitize_network()
|
|
1100
|
+
return network_Z
|
|
1101
|
+
|
|
1102
|
+
def sanitize_network(
|
|
1103
|
+
self,
|
|
1104
|
+
):
|
|
1105
|
+
"""Remove inconsistent nodes and edges to ensure graph consistency."""
|
|
1106
|
+
if len(self.node_view) == 0:
|
|
1107
|
+
return
|
|
1108
|
+
|
|
1109
|
+
nodes = set(self.node_view)
|
|
1110
|
+
edges = list(self.edge_view())
|
|
1111
|
+
nodes_in_edges = {endpoint for edge in edges for endpoint in edge}
|
|
1112
|
+
|
|
1113
|
+
for node in list(nodes):
|
|
1114
|
+
if node not in nodes_in_edges:
|
|
1115
|
+
self._nx_graph.remove_node(node)
|
|
1116
|
+
|
|
1117
|
+
for u, v in list(self.edge_view()):
|
|
1118
|
+
if u not in self._nx_graph or v not in self._nx_graph:
|
|
1119
|
+
self._nx_graph.remove_edge(u, v)
|
|
1120
|
+
|
|
1121
|
+
@staticmethod
|
|
1122
|
+
def sanitize_node_df(
|
|
1123
|
+
node_df: pd.DataFrame,
|
|
1124
|
+
edge_df: pd.DataFrame,
|
|
1125
|
+
node_col: str = "node",
|
|
1126
|
+
node_weight_col: str = "weight",
|
|
1127
|
+
edge_source_col: str = "source",
|
|
1128
|
+
edge_target_col: str = "target",
|
|
1129
|
+
edge_weight_col: str = "weight",
|
|
1130
|
+
) -> pd.DataFrame:
|
|
1131
|
+
"""Private helper returning ``node_df`` rows present in the edge list.
|
|
1132
|
+
|
|
1133
|
+
This method supports internal builders and is not part of the public API.
|
|
1134
|
+
|
|
1135
|
+
Parameters
|
|
1136
|
+
----------
|
|
1137
|
+
node_df : pd.DataFrame
|
|
1138
|
+
DataFrame containing ``node`` and ``weight`` columns.
|
|
1139
|
+
edge_df : pd.DataFrame
|
|
1140
|
+
Edge DataFrame containing source and target columns.
|
|
1141
|
+
node_col : str, optional
|
|
1142
|
+
Column name for node identifiers. The default is "node".
|
|
1143
|
+
node_weight_col : str, optional
|
|
1144
|
+
Column name for node weights. The default is "weight".
|
|
1145
|
+
edge_source_col : str, optional
|
|
1146
|
+
Column name for source edges. The default is "source".
|
|
1147
|
+
edge_target_col : str, optional
|
|
1148
|
+
Column name for target edges. The default is "target".
|
|
1149
|
+
edge_weight_col : str, optional
|
|
1150
|
+
Column name for edge weights. The default is "weight". Included for
|
|
1151
|
+
signature parity with other sanitization helpers.
|
|
1152
|
+
|
|
1153
|
+
Returns
|
|
1154
|
+
-------
|
|
1155
|
+
pd.DataFrame
|
|
1156
|
+
Filtered ``node_df`` with only nodes that appear as sources or targets.
|
|
1157
|
+
"""
|
|
1158
|
+
validate_dataframe(node_df, cols=[node_col, node_weight_col])
|
|
1159
|
+
filtered_node_df = node_df.copy()
|
|
1160
|
+
nodes_in_edges = list(
|
|
1161
|
+
set(edge_df[edge_source_col]).union(edge_df[edge_target_col])
|
|
1162
|
+
)
|
|
1163
|
+
return filtered_node_df.loc[filtered_node_df[node_col].isin(nodes_in_edges)]
|
|
1164
|
+
|
|
1165
|
+
@staticmethod
|
|
1166
|
+
def sanitize_edge_df(
|
|
1167
|
+
node_df: pd.DataFrame,
|
|
1168
|
+
edge_df: pd.DataFrame,
|
|
1169
|
+
node_col: str = "node",
|
|
1170
|
+
node_weight_col: str = "weight",
|
|
1171
|
+
edge_source_col: str = "source",
|
|
1172
|
+
edge_target_col: str = "target",
|
|
1173
|
+
edge_weight_col: str = "weight",
|
|
1174
|
+
) -> pd.DataFrame:
|
|
1175
|
+
"""Private helper returning a sanitized copy of the edge DataFrame.
|
|
1176
|
+
|
|
1177
|
+
Intended for internal validation when building graphs from dataframes.
|
|
1178
|
+
|
|
1179
|
+
Parameters
|
|
1180
|
+
----------
|
|
1181
|
+
node_df : pd.DataFrame
|
|
1182
|
+
DataFrame containing node identifiers and weights.
|
|
1183
|
+
edge_df : pd.DataFrame
|
|
1184
|
+
Edge DataFrame containing source and target columns.
|
|
1185
|
+
node_col : str, optional
|
|
1186
|
+
Column name for node identifiers. The default is "node".
|
|
1187
|
+
node_weight_col : str, optional
|
|
1188
|
+
Column name for node weights. The default is "weight".
|
|
1189
|
+
edge_source_col : str, optional
|
|
1190
|
+
Column name for source nodes. The default is "source".
|
|
1191
|
+
edge_target_col : str, optional
|
|
1192
|
+
Column name for target nodes. The default is "target".
|
|
1193
|
+
edge_weight_col : str, optional
|
|
1194
|
+
Column name for edge weights. The default is "weight".
|
|
1195
|
+
|
|
1196
|
+
Returns
|
|
1197
|
+
-------
|
|
1198
|
+
pd.DataFrame
|
|
1199
|
+
Sanitized edge DataFrame containing only edges whose nodes appear
|
|
1200
|
+
in ``node_df``.
|
|
1201
|
+
"""
|
|
1202
|
+
validate_dataframe(
|
|
1203
|
+
edge_df, cols=[edge_source_col, edge_target_col, edge_weight_col]
|
|
1204
|
+
)
|
|
1205
|
+
validate_dataframe(node_df, cols=[node_col, node_weight_col])
|
|
1206
|
+
allowed_nodes = node_df[node_col].tolist()
|
|
1207
|
+
edge_df = edge_df.loc[
|
|
1208
|
+
edge_df[edge_source_col].isin(allowed_nodes)
|
|
1209
|
+
& edge_df[edge_target_col].isin(allowed_nodes)
|
|
1210
|
+
]
|
|
1211
|
+
return edge_df
|
|
1212
|
+
|
|
1213
|
+
@staticmethod
|
|
1214
|
+
def sanitize_dfs(
|
|
1215
|
+
node_df: pd.DataFrame,
|
|
1216
|
+
edge_df: pd.DataFrame,
|
|
1217
|
+
node_col: str = "node",
|
|
1218
|
+
node_weight_col: str = "weight",
|
|
1219
|
+
edge_source_col: str = "source",
|
|
1220
|
+
edge_target_col: str = "target",
|
|
1221
|
+
edge_weight_col: str = "weight",
|
|
1222
|
+
) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
|
1223
|
+
"""Return sanitized node and edge DataFrames.
|
|
1224
|
+
|
|
1225
|
+
Parameters
|
|
1226
|
+
----------
|
|
1227
|
+
node_df : pd.DataFrame
|
|
1228
|
+
DataFrame containing node identifiers and weights.
|
|
1229
|
+
edge_df : pd.DataFrame
|
|
1230
|
+
Edge DataFrame containing source, target, and weights.
|
|
1231
|
+
node_col : str, optional
|
|
1232
|
+
Column name for node identifiers. The default is "node".
|
|
1233
|
+
node_weight_col : str, optional
|
|
1234
|
+
Column name for node weights. The default is "weight".
|
|
1235
|
+
edge_source_col : str, optional
|
|
1236
|
+
Column name for source nodes. The default is "source".
|
|
1237
|
+
edge_target_col : str, optional
|
|
1238
|
+
Column name for target nodes. The default is "target".
|
|
1239
|
+
edge_weight_col : str, optional
|
|
1240
|
+
Column name for edge weights. The default is "weight".
|
|
1241
|
+
|
|
1242
|
+
Returns
|
|
1243
|
+
-------
|
|
1244
|
+
tuple[pd.DataFrame, pd.DataFrame]
|
|
1245
|
+
A tuple containing the sanitized node and edge DataFrames.
|
|
1246
|
+
"""
|
|
1247
|
+
node_df = NetworkGraph.sanitize_node_df(
|
|
1248
|
+
node_df,
|
|
1249
|
+
edge_df=edge_df,
|
|
1250
|
+
node_col=node_col,
|
|
1251
|
+
node_weight_col=node_weight_col,
|
|
1252
|
+
edge_source_col=edge_source_col,
|
|
1253
|
+
edge_target_col=edge_target_col,
|
|
1254
|
+
edge_weight_col=edge_weight_col,
|
|
1255
|
+
)
|
|
1256
|
+
edge_df = NetworkGraph.sanitize_edge_df(
|
|
1257
|
+
node_df,
|
|
1258
|
+
edge_df=edge_df,
|
|
1259
|
+
node_col=node_col,
|
|
1260
|
+
node_weight_col=node_weight_col,
|
|
1261
|
+
edge_source_col=edge_source_col,
|
|
1262
|
+
edge_target_col=edge_target_col,
|
|
1263
|
+
edge_weight_col=edge_weight_col,
|
|
1264
|
+
)
|
|
1265
|
+
return node_df, edge_df
|
|
1266
|
+
|
|
1267
|
+
@staticmethod
|
|
1268
|
+
def from_pandas(
|
|
1269
|
+
node_df: pd.DataFrame,
|
|
1270
|
+
edge_df: pd.DataFrame,
|
|
1271
|
+
node_col: str = "node",
|
|
1272
|
+
node_weight_col: str = "weight",
|
|
1273
|
+
edge_source_col: str = "source",
|
|
1274
|
+
edge_target_col: str = "target",
|
|
1275
|
+
edge_weight_col: str = "weight",
|
|
1276
|
+
) -> "NetworkGraph":
|
|
1277
|
+
"""Build a NetworkGraph from node and edge DataFrames.
|
|
1278
|
+
|
|
1279
|
+
Parameters
|
|
1280
|
+
----------
|
|
1281
|
+
node_df : pd.DataFrame
|
|
1282
|
+
DataFrame containing node identifiers and weights.
|
|
1283
|
+
edge_df : pd.DataFrame
|
|
1284
|
+
DataFrame containing the edge list.
|
|
1285
|
+
node_col : str, optional
|
|
1286
|
+
Column name for node identifiers. The default is "node".
|
|
1287
|
+
node_weight_col : str, optional
|
|
1288
|
+
Column name for node weights. The default is "weight".
|
|
1289
|
+
edge_source_col : str, optional
|
|
1290
|
+
Column name for source nodes. The default is "source".
|
|
1291
|
+
edge_target_col : str, optional
|
|
1292
|
+
Column name for target nodes. The default is "target".
|
|
1293
|
+
edge_weight_col : str, optional
|
|
1294
|
+
Column name for edge weights. The default is "weight".
|
|
1295
|
+
|
|
1296
|
+
Returns
|
|
1297
|
+
-------
|
|
1298
|
+
NetworkGraph
|
|
1299
|
+
Prepared ``NetworkGraph`` instance with node weights set and edges
|
|
1300
|
+
filtered to nodes present in ``node_df``.
|
|
1301
|
+
"""
|
|
1302
|
+
if node_df is not None:
|
|
1303
|
+
node_df, edge_df = NetworkGraph.sanitize_dfs(
|
|
1304
|
+
node_df,
|
|
1305
|
+
edge_df,
|
|
1306
|
+
node_col=node_col,
|
|
1307
|
+
node_weight_col=node_weight_col,
|
|
1308
|
+
edge_source_col=edge_source_col,
|
|
1309
|
+
edge_target_col=edge_target_col,
|
|
1310
|
+
edge_weight_col=edge_weight_col,
|
|
1311
|
+
)
|
|
1312
|
+
graph = NetworkGraph.from_pandas_edgelist(
|
|
1313
|
+
edge_df,
|
|
1314
|
+
source=edge_source_col,
|
|
1315
|
+
target=edge_target_col,
|
|
1316
|
+
edge_weight_col=edge_weight_col,
|
|
1317
|
+
)
|
|
1318
|
+
if node_df is None or node_df.empty:
|
|
1319
|
+
graph.calculate_nodes(edge_weight_col=edge_weight_col)
|
|
1320
|
+
else:
|
|
1321
|
+
node_weights = {
|
|
1322
|
+
node: {node_weight_col: weight_value}
|
|
1323
|
+
for node, weight_value in node_df.set_index(node_col)[
|
|
1324
|
+
node_weight_col
|
|
1325
|
+
].items()
|
|
1326
|
+
if node in graph._nx_graph.nodes
|
|
1327
|
+
}
|
|
1328
|
+
graph.set_node_attributes(node_weights)
|
|
1329
|
+
|
|
1330
|
+
return graph
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _compute_network_grid(
|
|
1334
|
+
connected_components: List[set], style: StyleTemplate
|
|
1335
|
+
) -> Tuple[Figure, np.ndarray]:
|
|
1336
|
+
"""Compute the grid layout for network component subplots.
|
|
1337
|
+
|
|
1338
|
+
Parameters
|
|
1339
|
+
----------
|
|
1340
|
+
connected_components : list[set]
|
|
1341
|
+
A list of sets, where each set contains the nodes of a connected component.
|
|
1342
|
+
style : StyleTemplate
|
|
1343
|
+
The style template used for plotting.
|
|
1344
|
+
|
|
1345
|
+
Returns
|
|
1346
|
+
-------
|
|
1347
|
+
Tuple[Figure, np.ndarray]
|
|
1348
|
+
A tuple containing the Matplotlib figure and the grid of axes.
|
|
1349
|
+
"""
|
|
1350
|
+
n_components = len(connected_components)
|
|
1351
|
+
n_cols = int(np.ceil(np.sqrt(n_components)))
|
|
1352
|
+
n_rows = int(np.ceil(n_components / n_cols))
|
|
1353
|
+
fig, axes_grid = plt.subplots(n_rows, n_cols, figsize=FIG_SIZE)
|
|
1354
|
+
fig = cast(Figure, fig)
|
|
1355
|
+
fig.set_facecolor(style.background_color)
|
|
1356
|
+
if not isinstance(axes_grid, np.ndarray):
|
|
1357
|
+
axes = np.array([axes_grid])
|
|
1358
|
+
else:
|
|
1359
|
+
axes = axes_grid.flatten()
|
|
1360
|
+
return fig, axes
|