hiveplotlib 0.26.2__py3-none-any.whl → 0.27.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.
hiveplotlib/__init__.py CHANGED
@@ -12,5 +12,9 @@ from hiveplotlib.hiveplot import ( # noqa: F401
12
12
  HivePlot,
13
13
  hive_plot_n_axes,
14
14
  )
15
+ from hiveplotlib.hiveplot_matrix import ( # noqa: F401
16
+ HPM_SUPPORTED_VIZ_BACKENDS,
17
+ HivePlotMatrix,
18
+ )
15
19
  from hiveplotlib.node import Node, NodeCollection # noqa: F401
16
20
  from hiveplotlib.p2cp import P2CP, p2cp_n_axes # noqa: F401
hiveplotlib/axis.py CHANGED
@@ -6,6 +6,7 @@ Definition of ``Axis`` instance and helper static methods for generating and wor
6
6
 
7
7
  from typing import Hashable, Optional
8
8
 
9
+ import numpy as np
9
10
  import pandas as pd
10
11
 
11
12
  from hiveplotlib.utils import polar2cartesian
@@ -93,7 +94,9 @@ class Axis:
93
94
  self.sorting_variable = None
94
95
 
95
96
  # hold all the cartesian coordinates, polar rho, corresponding labels, and node metadata in a pandas dataframe
96
- self.node_placements = pd.DataFrame(columns=["x", "y", "unique_id", "rho"])
97
+ self.node_placements = pd.DataFrame(
98
+ columns=np.array(["x", "y", "unique_id", "rho"])
99
+ )
97
100
 
98
101
  # hold the current vmin and vmax used to place nodes on an axis in a ``HivePlot`` instance.
99
102
  self.vmin = None
hiveplotlib/converters.py CHANGED
@@ -4,22 +4,22 @@
4
4
  Converters from various data structures to ``hiveplotlib``-ready structures.
5
5
  """
6
6
 
7
- from typing import Tuple
8
-
9
7
  import pandas as pd
10
8
 
11
- from hiveplotlib import Edges, Node, NodeCollection
12
- from hiveplotlib.node import node_collection_from_node_list
9
+ from hiveplotlib import Edges, NodeCollection
13
10
 
14
- # say the graph type instead of calling `nx.Graph` to keep networkx dep out of this file.
15
- NetworkXGraph = "networkx.classes.graph.Graph instance"
11
+ try:
12
+ import networkx as nx
13
+ except ImportError as ie: # pragma: no cover
14
+ msg = "`networkx` dependencies not installed, but can be installed by running `pip install hiveplotlib[networkx]`"
15
+ raise ImportError(msg) from ie
16
16
 
17
17
 
18
18
  def networkx_to_nodes_edges(
19
- graph: NetworkXGraph,
19
+ graph: nx.Graph,
20
20
  unique_id_name: str = "unique_id",
21
21
  check_uniqueness: bool = True,
22
- ) -> Tuple[NodeCollection, Edges]: # type: ignore
22
+ ) -> tuple[NodeCollection, Edges]:
23
23
  """
24
24
  Take a ``networkx`` graph and return ``hiveplotlib``-friendly data structures.
25
25
 
@@ -31,11 +31,14 @@ def networkx_to_nodes_edges(
31
31
  :param check_uniqueness: whether or not to check that provided ``Node`` instances have unique IDs.
32
32
  :return: ``list`` of ``Node`` instances, ``(n, 2)`` ``np.ndarray`` of edges.
33
33
  """
34
- nodes = [Node(unique_id=i, data=data) for i, data in list(graph.nodes.data())]
35
- edge_keys = pd.DataFrame(list(graph.edges.keys()), columns=["from", "to"])
36
- edge_values = pd.DataFrame(list(graph.edges.values()))
37
- return node_collection_from_node_list(
38
- node_list=nodes,
39
- unique_id_name=unique_id_name,
34
+ node_data = []
35
+ for node, attributes in graph.nodes(data=True):
36
+ node_data.append({unique_id_name: node, **attributes})
37
+
38
+ node_df = pd.DataFrame(node_data)
39
+ edge_df = nx.to_pandas_edgelist(graph, source="from", target="to")
40
+ return NodeCollection(
41
+ data=node_df,
42
+ unique_id_column=unique_id_name,
40
43
  check_uniqueness=check_uniqueness,
41
- ), Edges(data=edge_keys.join(edge_values))
44
+ ), Edges(data=edge_df)
@@ -52,7 +52,7 @@ def international_trade_data(
52
52
  csv_years = [i.split("_")[2] for i in csv_files]
53
53
  csv_hs92 = [i.split("_")[-1] for i in csv_files]
54
54
  hiveplotlib_supported_values = pd.DataFrame(
55
- np.c_[csv_years, csv_hs92], columns=["Year", "Trade Code"]
55
+ np.c_[csv_years, csv_hs92], columns=np.array(["Year", "Trade Code"])
56
56
  )
57
57
 
58
58
  # check that our implied data and metadata files exist
@@ -13,6 +13,7 @@ from hiveplotlib import (
13
13
  NodeCollection,
14
14
  hive_plot_n_axes,
15
15
  )
16
+ from hiveplotlib.datasets.international_trade import international_trade_data
16
17
  from hiveplotlib.edges import Edges
17
18
  from hiveplotlib.hiveplot import HivePlot
18
19
  from hiveplotlib.node import dataframe_to_node_list
@@ -52,7 +53,7 @@ def example_base_hive_plot(
52
53
  rng.uniform(low=10, high=20, size=num_nodes),
53
54
  rng.uniform(low=20, high=30, size=num_nodes),
54
55
  ],
55
- columns=["low", "med", "high"],
56
+ columns=np.array(["low", "med", "high"]),
56
57
  )
57
58
 
58
59
  # make indices a column
@@ -106,7 +107,7 @@ def example_node_data(num_nodes: int = 100, seed: int = 0) -> pd.DataFrame:
106
107
  rng.uniform(low=10, high=19.99, size=num_nodes),
107
108
  rng.uniform(low=20, high=29.99, size=num_nodes),
108
109
  ],
109
- columns=["low", "med", "high"],
110
+ columns=np.array(["low", "med", "high"]),
110
111
  )
111
112
  # make indices a column
112
113
  return data.reset_index(names="unique_id")
@@ -162,7 +163,9 @@ def example_edge_data(
162
163
  node_ids = nodes.data[nodes.unique_id_column].to_numpy()
163
164
  rng = np.random.default_rng(seed)
164
165
  edge_array = rng.choice(node_ids, size=num_edges * 2).reshape(-1, 2)
165
- return pd.DataFrame(edge_array, columns=[from_column_name, to_column_name])
166
+ return pd.DataFrame(
167
+ edge_array, columns=np.array([from_column_name, to_column_name])
168
+ )
166
169
 
167
170
 
168
171
  def example_edges(
@@ -199,6 +202,85 @@ def example_edges(
199
202
  )
200
203
 
201
204
 
205
+ def example_trade_nodes_and_edges() -> Tuple[NodeCollection, Edges]:
206
+ """
207
+ Load the international trade nodes and edges.
208
+
209
+ Used in the
210
+ :doc:`Hive Plots with More Than 3 Groups </notebooks/hive_plots_more_than_three_groups>`
211
+ notebook.
212
+
213
+ Uses :py:func:`hiveplotlib.datasets.international_trade.international_trade_data` to load 2019 trade data for HS92
214
+ trade group 8112 ("beryllium, chromium, germanium, vanadium, gallium, hafnium, indium, niobium
215
+ (columbium), rhenium and thallium, articles thereof, and waste or scrap") from the
216
+ `Harvard Growth Lab <https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi%3A10.7910%2FDVN%2FT4CHWJ>`_.
217
+
218
+ **Node columns** (152 nodes):
219
+
220
+ - ``"country"``: ISO 3166-1 alpha-3 country code (unique ID).
221
+ - ``"continent"``: continent of the country (Africa, Asia, Europe, North America, Oceania, or South America).
222
+ - ``"export_value"``: total USD exported by that country within trade group 8112 in 2019.
223
+ Countries with no exports are assigned 0.
224
+
225
+ **Edge columns** (1158 edges):
226
+
227
+ Each edge represents non-zero trade from an origin country to a destination country.
228
+
229
+ - ``"origin_country"``: ISO 3166-1 alpha-3 code of the exporting country (from column).
230
+ - ``"destination_country"``: ISO 3166-1 alpha-3 code of the importing country (to column).
231
+ - ``"export_value"``: USD exported from the origin to the destination.
232
+ - ``"origin_continent"``: continent of the origin country.
233
+ - ``"destination_continent"``: continent of the destination country.
234
+
235
+ :return: tuple of ``(NodeCollection, Edges)`` representing the trade network.
236
+ """
237
+ data, _ = international_trade_data(year=2019, hs92_code=8112)
238
+
239
+ export_totals_per_country = (
240
+ data.loc[:, ["origin_country", "export_value"]]
241
+ .groupby("origin_country")
242
+ .sum()
243
+ .reset_index()
244
+ )
245
+
246
+ node_data = pd.concat(
247
+ [
248
+ data.loc[:, ["origin_country", "origin_continent"]].rename(
249
+ columns={
250
+ "origin_country": "country",
251
+ "origin_continent": "continent",
252
+ }
253
+ ),
254
+ data.loc[:, ["destination_country", "destination_continent"]].rename(
255
+ columns={
256
+ "destination_country": "country",
257
+ "destination_continent": "continent",
258
+ }
259
+ ),
260
+ ]
261
+ ).drop_duplicates()
262
+
263
+ node_data = node_data.merge(
264
+ export_totals_per_country,
265
+ how="left",
266
+ left_on="country",
267
+ right_on="origin_country",
268
+ ).drop(columns=["origin_country"])
269
+
270
+ # fill in countries with no exports as 0, not NaN
271
+ node_data.export_value = node_data.export_value.fillna(0)
272
+
273
+ nodes = NodeCollection(node_data, unique_id_column="country")
274
+
275
+ edges = Edges(
276
+ data,
277
+ from_column_name="origin_country",
278
+ to_column_name="destination_country",
279
+ )
280
+
281
+ return nodes, edges
282
+
283
+
202
284
  def example_nodes_and_edges(
203
285
  num_nodes: int = 100,
204
286
  num_edges: int = 200,
@@ -238,8 +320,8 @@ def example_hive_plot(
238
320
  num_nodes: int = 100,
239
321
  num_edges: int = 100,
240
322
  partition_data_column: Literal["low", "med", "high"] = "low",
241
- labels: Optional[List[Hashable]] = ("A", "B", "C"),
242
- cutoffs: Optional[Union[List[float], int]] = 3,
323
+ labels: Optional[List[Hashable]] = ["A", "B", "C"], # noqa: B006
324
+ cutoffs: Union[List[Union[int, float]], int] = 3,
243
325
  partition_variable_name: Optional[Hashable] = None,
244
326
  sorting_variables: Union[Hashable, Dict[Hashable, Hashable]] = "low",
245
327
  seed: int = 0,
@@ -310,7 +392,7 @@ def example_hive_plot(
310
392
  node_collection.data,
311
393
  left_on="to",
312
394
  right_on=node_unique_id_column,
313
- suffixes=[None, "_to"],
395
+ suffixes=(None, "_to"),
314
396
  )
315
397
  )
316
398
  for col in ["low", "med", "high"]:
@@ -329,3 +411,219 @@ def example_hive_plot(
329
411
  sorting_variables=sorting_variables,
330
412
  **hive_plot_kwargs,
331
413
  )
414
+
415
+
416
+ def example_hpm_nodes_and_edges(
417
+ num_groups: int = 3,
418
+ nodes_per_group: int = 10,
419
+ edge_tag_counts: Optional[Dict[str, int]] = None,
420
+ edge_structure: Optional[Dict[str, str]] = None,
421
+ seed: int = 42,
422
+ ) -> Tuple["NodeCollection", "Edges"]:
423
+ """
424
+ Generate a shared toy dataset that works well with ``HivePlotMatrix`` examples.
425
+
426
+ Returns a ``NodeCollection`` of ``num_groups * nodes_per_group`` nodes across
427
+ ``num_groups`` groups (A, B, C, ...) with structured numeric columns, and a
428
+ multi-tag ``Edges`` object.
429
+
430
+ **Node columns**:
431
+
432
+ - ``"unique_id"``: integers 0 to (num_groups * nodes_per_group - 1).
433
+ - ``"group"``: group label, assigned in consecutive blocks. With ``num_groups=3``,
434
+ nodes 0-9 are ``"A"``, 10-19 are ``"B"``, 20-29 are ``"C"``.
435
+ - ``"value1"``: *correlated with group* - each group draws from a distinct sub-interval
436
+ of [0, 10] in ascending order. Sorting by ``value1`` places groups at distinct,
437
+ separated positions along each axis if setting axes ``vmin`` and ``vmax`` values to the same fixed range.
438
+ - ``"value2"``: *uncorrelated noise* - all nodes draw from Uniform(0, 10). Sorting
439
+ by ``value2`` produces little visible group separation.
440
+ - ``"value3"``: *inversely correlated with group* - the mirror image of ``value1``.
441
+ The first group draws from the highest sub-interval, the last from the lowest.
442
+
443
+ **Default edge tags** (decreasing density):
444
+
445
+ - ``"official"``: 40 edges.
446
+ - ``"social"``: 30 edges.
447
+ - ``"informal"``: 20 edges.
448
+
449
+ :param num_groups: number of groups to generate (A, B, C, ...). Default 3.
450
+ :param nodes_per_group: number of nodes in each group. Default 10.
451
+ :param edge_tag_counts: dictionary mapping edge tag names to edge counts. Defaults to
452
+ ``{"official": 40, "social": 30, "informal": 20}``.
453
+ :param edge_structure: dictionary mapping edge tag names to structure types.
454
+ Supported types:
455
+
456
+ - ``"random"``: edges connect any two nodes uniformly at random (default).
457
+ - ``"intragroup"``: edges connect nodes within the same group only.
458
+ - ``"intergroup"``: edges connect nodes in different groups only.
459
+
460
+ Tags not listed in this dictionary default to ``"random"``.
461
+ :param seed: base random seed. Seeds ``seed``, ``seed+1``, ... are used for
462
+ successive edge tags respectively.
463
+ :return: tuple of ``(NodeCollection, Edges)``.
464
+ """
465
+ _valid_structures = {"random", "intragroup", "intergroup"}
466
+ if edge_tag_counts is None:
467
+ edge_tag_counts = {"official": 40, "social": 30, "informal": 20}
468
+ if edge_structure is None:
469
+ edge_structure = {}
470
+ for tag, structure in edge_structure.items():
471
+ if structure not in _valid_structures:
472
+ msg = (
473
+ f"edge_structure[{tag!r}] = {structure!r} is not valid. "
474
+ f"Must be one of {_valid_structures}."
475
+ )
476
+ raise ValueError(msg)
477
+ num_nodes = num_groups * nodes_per_group
478
+ rng = np.random.default_rng(seed)
479
+
480
+ # Deterministic block group assignment
481
+ group_labels = [chr(ord("A") + i) for i in range(num_groups)]
482
+ groups: list = []
483
+ for label in group_labels:
484
+ groups.extend([label] * nodes_per_group)
485
+
486
+ # Divide [0, 10] into num_groups equal sub-intervals
487
+ breakpoints = np.linspace(0, 10, num_groups + 1)
488
+
489
+ # value1: correlated - group i draws from [breakpoints[i], breakpoints[i+1]]
490
+ value1_parts = []
491
+ for i in range(num_groups):
492
+ lo, hi = breakpoints[i], breakpoints[i + 1]
493
+ value1_parts.append(rng.uniform(lo, hi, size=nodes_per_group))
494
+ value1 = np.concatenate(value1_parts)
495
+
496
+ value2 = rng.uniform(0, 10, size=num_nodes)
497
+
498
+ # value3: inversely correlated - group i draws from the reversed sub-interval
499
+ value3_parts = []
500
+ for i in range(num_groups):
501
+ j = num_groups - 1 - i
502
+ lo, hi = breakpoints[j], breakpoints[j + 1]
503
+ value3_parts.append(rng.uniform(lo, hi, size=nodes_per_group))
504
+ value3 = np.concatenate(value3_parts)
505
+
506
+ data = pd.DataFrame(
507
+ {
508
+ "unique_id": np.arange(num_nodes),
509
+ "group": groups,
510
+ "value1": value1,
511
+ "value2": value2,
512
+ "value3": value3,
513
+ }
514
+ )
515
+ nodes = NodeCollection(data=data, unique_id_column="unique_id")
516
+ node_ids = data["unique_id"].to_numpy()
517
+ groups_array = np.array(groups)
518
+
519
+ def _make_edges(n_edges: int, edge_seed: int, structure: str) -> pd.DataFrame:
520
+ edge_rng = np.random.default_rng(edge_seed)
521
+ if structure == "random":
522
+ return pd.DataFrame(
523
+ edge_rng.choice(node_ids, size=n_edges * 2).reshape(-1, 2),
524
+ columns=np.array(["from", "to"]),
525
+ )
526
+
527
+ from_nodes = edge_rng.choice(node_ids, size=n_edges)
528
+ to_nodes = np.empty(n_edges, dtype=node_ids.dtype)
529
+ for j in range(n_edges):
530
+ src_group = groups_array[from_nodes[j]]
531
+ if structure == "intragroup":
532
+ candidates = node_ids[groups_array == src_group]
533
+ else: # intergroup
534
+ candidates = node_ids[groups_array != src_group]
535
+ to_nodes[j] = edge_rng.choice(candidates)
536
+ return pd.DataFrame(
537
+ {"from": from_nodes, "to": to_nodes},
538
+ )
539
+
540
+ edges = Edges(
541
+ data={
542
+ tag: _make_edges(count, seed + i, edge_structure.get(tag, "random"))
543
+ for i, (tag, count) in enumerate(edge_tag_counts.items())
544
+ }
545
+ )
546
+ return nodes, edges
547
+
548
+
549
+ def example_minimal_hive_plot() -> HivePlot:
550
+ """
551
+ Generate a minimal ``HivePlot`` instance with edge color styling.
552
+
553
+ Uses :py:func:`example_hive_plot` with 6 nodes and 6 edges (seed 42) and adds
554
+ a uniform edge color of ``"#006BA4"`` to all axis pairs that have edges.
555
+
556
+ :return: ``HivePlot`` instance.
557
+ """
558
+ return example_hive_plot(
559
+ num_nodes=6,
560
+ num_edges=6,
561
+ seed=42,
562
+ all_edge_kwargs={"color": "#006BA4"},
563
+ )
564
+
565
+
566
+ def example_multi_tag_hive_plot() -> HivePlot:
567
+ """
568
+ Generate a ``HivePlot`` instance with two edge tags and ``repeat_axes=True``.
569
+
570
+ Creates 12 nodes (seed 10) with two independent sets of 15 edges (seeds 10 and 20)
571
+ stored under tags ``"tag_red"`` and ``"tag_blue"``. Edge colors are ``"#FF0000"``
572
+ and ``"#0000FF"`` respectively.
573
+
574
+ :return: ``HivePlot`` instance with two edge tags.
575
+ """
576
+ nodes = example_node_collection(num_nodes=12, seed=10)
577
+ edge_data_1 = example_edge_data(nodes=nodes, num_edges=15, seed=10)
578
+ edge_data_2 = example_edge_data(nodes=nodes, num_edges=15, seed=20)
579
+ multi_tag_edges = Edges(data={"tag_red": edge_data_1, "tag_blue": edge_data_2})
580
+ partition_variable = nodes.create_partition_variable(
581
+ data_column="low", labels=["A", "B", "C"]
582
+ )
583
+ hp = HivePlot(
584
+ nodes=nodes,
585
+ edges=multi_tag_edges,
586
+ partition_variable=partition_variable,
587
+ sorting_variables="low",
588
+ repeat_axes=True,
589
+ )
590
+ hp.edges.update_edge_viz_kwargs(tag="tag_red", color="#FF0000")
591
+ hp.edges.update_edge_viz_kwargs(tag="tag_blue", color="#0000FF")
592
+ return hp
593
+
594
+
595
+ def example_full_kwargs_hive_plot() -> HivePlot:
596
+ """
597
+ Generate a ``HivePlot`` instance with data-dependent edge kwargs, colormaps, and node styling.
598
+
599
+ Uses :py:func:`example_hive_plot` with 9 nodes, 10 edges (seed 99), and
600
+ ``repeat_axes=True``. Scaled edge data columns are created for alpha and linewidth,
601
+ and all edge visualization kwargs are set via
602
+ :py:meth:`~hiveplotlib.HivePlot.update_edge_plotting_keyword_arguments` using column
603
+ name references:
604
+
605
+ - **color**: the ``"low"`` edge data column mapped through a ``"cividis"`` colormap.
606
+ - **alpha**: the ``"alpha_scaled"`` column (``low / 10``, yielding values in ~0.3-1.0).
607
+ - **linewidth**: the ``"lw_scaled"`` column (``low / 3``, yielding values in ~1.0-3.3).
608
+
609
+ Node styling is applied via ``update_node_viz_kwargs`` with a ``"viridis"`` colormap mapped to the ``"low"``
610
+ data column.
611
+
612
+ :return: ``HivePlot`` instance with node and edge kwargs.
613
+ """
614
+ hp = example_hive_plot(num_nodes=9, num_edges=10, seed=99, repeat_axes=True)
615
+ # create scaled edge data columns for alpha (0-1 range) and linewidth
616
+ hp.edges.data["alpha_scaled"] = hp.edges.data["low"] / 10
617
+ hp.edges.data["lw_scaled"] = hp.edges.data["low"] / 3
618
+ # data-dependent edge kwargs: color, alpha, and linewidth all from edge data columns
619
+ hp.update_edge_plotting_keyword_arguments(
620
+ array="low",
621
+ cmap="cividis",
622
+ clim=(0, 10),
623
+ alpha="alpha_scaled",
624
+ linewidth="lw_scaled",
625
+ )
626
+ hp.update_node_viz_kwargs(
627
+ c="low", s=30, alpha=0.8, edgecolor="black", cmap="viridis"
628
+ )
629
+ return hp
@@ -34,7 +34,7 @@ def four_gaussian_blobs_3d(
34
34
  corners_to_keep = [0, 1, 2, 4]
35
35
 
36
36
  # name of the features we will create for each set of data generated below
37
- feature_names = ["X", "Y", "Z", "Label"]
37
+ feature_names = np.array(["X", "Y", "Z", "Label"])
38
38
 
39
39
  # set seed for consistent data
40
40
  rng = np.random.default_rng(random_seed)
@@ -63,7 +63,7 @@ def four_gaussian_blobs_3d(
63
63
  # make the labels ints
64
64
  df.Label = df.Label.astype(int)
65
65
  # replace the 4s with 3s so our labels are just range(4)
66
- df.Label.to_numpy()[df.Label.to_numpy() == 4] = 3
66
+ df.loc[df["Label"] == 4, "Label"] = 3
67
67
 
68
68
  return df
69
69
 
hiveplotlib/edges.py CHANGED
@@ -4,7 +4,7 @@
4
4
  """
5
5
 
6
6
  from copy import deepcopy
7
- from typing import Hashable, Literal, Optional, Union
7
+ from typing import Hashable, Literal, Optional, Union, cast
8
8
 
9
9
  import numpy as np
10
10
  import pandas as pd
@@ -99,7 +99,9 @@ class Edges:
99
99
  def __init__(
100
100
  self,
101
101
  data: Union[
102
- pd.DataFrame, np.ndarray, dict[Hashable, Union[np.ndarray, pd.DataFrame]]
102
+ pd.DataFrame,
103
+ np.ndarray,
104
+ dict[Hashable, Union[np.ndarray, pd.DataFrame]],
103
105
  ],
104
106
  from_column_name: Hashable = "from",
105
107
  to_column_name: Hashable = "to",
@@ -114,7 +116,7 @@ class Edges:
114
116
 
115
117
  self.edge_viz_kwargs = {} if edge_viz_kwargs is None else edge_viz_kwargs.copy()
116
118
 
117
- self._data = self._validate_edge_data(data)
119
+ self._data: dict[Hashable, pd.DataFrame] = self._validate_edge_data(data)
118
120
 
119
121
  # nested dictionary that stores boolean 1d arrays of which edges are relevant to which set of edges plotted
120
122
  # edges are plotted in chunks of "from axis", "to axis", and "tag", so this stores 1 boolean array nested in
@@ -124,7 +126,10 @@ class Edges:
124
126
  def _validate_edge_data(
125
127
  self,
126
128
  data: Union[
127
- pd.DataFrame, np.ndarray, dict[Hashable, Union[np.ndarray, pd.DataFrame]]
129
+ pd.DataFrame,
130
+ np.ndarray,
131
+ dict[Hashable, Union[np.ndarray, pd.DataFrame]],
132
+ dict[Hashable, pd.DataFrame],
128
133
  ],
129
134
  ) -> dict[Hashable, pd.DataFrame]:
130
135
  """
@@ -134,40 +139,52 @@ class Edges:
134
139
  of either.
135
140
  :return: dictionary of ``pandas.DataFrame`` objects with validated edge data.
136
141
  """
137
- if not isinstance(data, dict):
142
+ # Normalize input to a dictionary without mutating the input parameter to avoid type ambiguity.
143
+ if isinstance(data, dict):
144
+ data_dict = data
145
+ else:
138
146
  if isinstance(data, np.ndarray):
139
147
  assert data.shape[1] == 2, (
140
148
  "Any `numpy.ndarray` input in `data` must be shape (n, 2) "
141
149
  f"but found input with {data.shape[1]} columns."
142
150
  )
143
- data = pd.DataFrame(
144
- data, columns=[self.from_column_name, self.to_column_name]
151
+ df_single = pd.DataFrame(
152
+ data, columns=np.array([self.from_column_name, self.to_column_name])
145
153
  )
146
- data = {0: data} # default to tag 0 for single dataframe provided
154
+ else:
155
+ df_single = cast("pd.DataFrame", data)
156
+ # default to tag 0 for single dataframe provided
157
+ data_dict: dict[Hashable, pd.DataFrame] = {0: df_single}
147
158
  if 0 not in self.edge_viz_kwargs:
148
159
  self.edge_viz_kwargs[0] = {}
149
- for kw in data:
150
- assert isinstance(data[kw], (pd.DataFrame, np.ndarray)), (
160
+
161
+ # Build the validated output dictionary of DataFrames.
162
+ out: dict[Hashable, pd.DataFrame] = {}
163
+ for kw, val in data_dict.items():
164
+ assert isinstance(val, (pd.DataFrame, np.ndarray)), (
151
165
  f"Provided data for tag {kw} must be a `pandas.DataFrame` or `numpy.ndarray`."
152
166
  )
153
- if isinstance(data[kw], pd.DataFrame):
154
- assert self.from_column_name in data[kw].columns, (
167
+ if isinstance(val, pd.DataFrame):
168
+ df_kw = val
169
+ assert self.from_column_name in df_kw.columns, (
155
170
  f"Provided data for tag {kw} must have a column named '{self.from_column_name}'"
156
171
  )
157
- assert self.to_column_name in data[kw].columns, (
172
+ assert self.to_column_name in df_kw.columns, (
158
173
  f"Provided data for tag {kw} must have a column named '{self.to_column_name}'"
159
174
  )
160
- elif isinstance(data[kw], np.ndarray):
161
- assert data[kw].shape[1] == 2, (
175
+ out[kw] = df_kw
176
+ else:
177
+ assert val.shape[1] == 2, (
162
178
  "Any `numpy.ndarray` input in `data` must be shape (n, 2) "
163
- f"but found input with {data[kw].shape[1]} columns."
179
+ f"but found input with {val.shape[1]} columns."
164
180
  )
165
- data[kw] = pd.DataFrame(
166
- data[kw], columns=[self.from_column_name, self.to_column_name]
181
+ out[kw] = pd.DataFrame(
182
+ val,
183
+ columns=np.array([self.from_column_name, self.to_column_name]),
167
184
  )
168
185
  if kw not in self.edge_viz_kwargs:
169
186
  self.edge_viz_kwargs[kw] = {}
170
- return data
187
+ return out
171
188
 
172
189
  @property
173
190
  def data(self) -> Union[pd.DataFrame, dict[Hashable, pd.DataFrame]]:
@@ -182,9 +199,19 @@ class Edges:
182
199
  return next(iter(self._data.values()))
183
200
  return self._data
184
201
 
202
+ @property
203
+ def tags(self) -> list[Hashable]:
204
+ """
205
+ Return the list of all edge tags.
206
+
207
+ :return: list of tag keys for this ``Edges`` instance.
208
+ """
209
+ return list(self._data.keys())
210
+
185
211
  def add_edges(
186
212
  self,
187
- data: dict[Hashable, Union[np.ndarray, pd.DataFrame]],
213
+ data: dict[Hashable, Union[np.ndarray, pd.DataFrame]]
214
+ | dict[Hashable, pd.DataFrame],
188
215
  ) -> None:
189
216
  """
190
217
  Add edges to the ``Edges`` instance.
@@ -217,7 +244,7 @@ class Edges:
217
244
  :return: copy of the ``Edges`` instance.
218
245
  """
219
246
  return Edges(
220
- data=self._data.copy(),
247
+ data=deepcopy(self._data), # ty:ignore[invalid-argument-type]
221
248
  from_column_name=self.from_column_name,
222
249
  to_column_name=self.to_column_name,
223
250
  edge_viz_kwargs=deepcopy(self.edge_viz_kwargs),