treegraphduals 0.1.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.
- treegraphduals/__init__.py +30 -0
- treegraphduals/agents/__init__.py +4 -0
- treegraphduals/core/__init__.py +28 -0
- treegraphduals/core/base_graph.py +303 -0
- treegraphduals/core/binary_tree.py +286 -0
- treegraphduals/core/dag.py +84 -0
- treegraphduals/core/erdos_renyi.py +36 -0
- treegraphduals/core/forest.py +45 -0
- treegraphduals/core/galton_watson.py +53 -0
- treegraphduals/core/graph.py +28 -0
- treegraphduals/core/multigraph.py +23 -0
- treegraphduals/core/polytree.py +58 -0
- treegraphduals/core/real_tree.py +39 -0
- treegraphduals/core/tree.py +1293 -0
- treegraphduals/py.typed +0 -0
- treegraphduals/timeseries/__init__.py +36 -0
- treegraphduals/timeseries/timeseries.py +991 -0
- treegraphduals/visualizations/__init__.py +44 -0
- treegraphduals/visualizations/plot_combined.py +217 -0
- treegraphduals/visualizations/plot_timeseries.py +205 -0
- treegraphduals/visualizations/plot_trees.py +751 -0
- treegraphduals-0.1.0.dist-info/METADATA +179 -0
- treegraphduals-0.1.0.dist-info/RECORD +25 -0
- treegraphduals-0.1.0.dist-info/WHEEL +4 -0
- treegraphduals-0.1.0.dist-info/licenses/LICENSE +9 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Trees, their duals, graphs, and time series.
|
|
2
|
+
|
|
3
|
+
An extension of *The Horizontal Tunnelability Graph is Dual to Level Set Trees*
|
|
4
|
+
(Khan, University of Nevada, Reno, 2023).
|
|
5
|
+
|
|
6
|
+
The library is organized into subpackages, imported explicitly so that a bare
|
|
7
|
+
``import treegraphduals`` stays free of plotting dependencies:
|
|
8
|
+
|
|
9
|
+
- :mod:`treegraphduals.core` -- tree and graph data structures
|
|
10
|
+
- :mod:`treegraphduals.timeseries` -- time series analysis and tree conversions
|
|
11
|
+
- :mod:`treegraphduals.visualizations` -- plotting (requires matplotlib)
|
|
12
|
+
|
|
13
|
+
Examples
|
|
14
|
+
--------
|
|
15
|
+
>>> from treegraphduals.core import Tree
|
|
16
|
+
>>> tree = Tree(n_nodes=3, root=0)
|
|
17
|
+
>>> tree.add_edge(0, 1)
|
|
18
|
+
>>> tree.add_edge(0, 2)
|
|
19
|
+
>>> tree.n_edges
|
|
20
|
+
2
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("treegraphduals")
|
|
27
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
28
|
+
__version__ = "0.0.0+unknown"
|
|
29
|
+
|
|
30
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Core package initialization."""
|
|
2
|
+
|
|
3
|
+
from .base_graph import BaseGraph, GraphRepresentation
|
|
4
|
+
from .binary_tree import BinaryTree
|
|
5
|
+
from .dag import DAG
|
|
6
|
+
from .erdos_renyi import ErdosRenyi
|
|
7
|
+
from .forest import Forest
|
|
8
|
+
from .galton_watson import GaltonWatsonTree
|
|
9
|
+
from .graph import Graph
|
|
10
|
+
from .multigraph import Multigraph
|
|
11
|
+
from .polytree import Polytree
|
|
12
|
+
from .real_tree import RealTree
|
|
13
|
+
from .tree import Tree
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DAG",
|
|
17
|
+
"BaseGraph",
|
|
18
|
+
"BinaryTree",
|
|
19
|
+
"ErdosRenyi",
|
|
20
|
+
"Forest",
|
|
21
|
+
"GaltonWatsonTree",
|
|
22
|
+
"Graph",
|
|
23
|
+
"GraphRepresentation",
|
|
24
|
+
"Multigraph",
|
|
25
|
+
"Polytree",
|
|
26
|
+
"RealTree",
|
|
27
|
+
"Tree",
|
|
28
|
+
]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic representation and base graph classes with multi-library compatibility.
|
|
3
|
+
|
|
4
|
+
Supports NetworkX, igraph, numpy arrays, scipy sparse matrices, and other representations.
|
|
5
|
+
BaseGraph(ABC)
|
|
6
|
+
├── DAG
|
|
7
|
+
│ └── Polytree
|
|
8
|
+
├── Tree
|
|
9
|
+
│ └── BinaryTree
|
|
10
|
+
│ ├── GaltonWatsonTree
|
|
11
|
+
│ └── Forest
|
|
12
|
+
├── GeneralGraph
|
|
13
|
+
│ ├── Multigraph
|
|
14
|
+
│ └── ErdosRenyi
|
|
15
|
+
└── RealTree (separate - different math)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GraphRepresentation:
|
|
25
|
+
"""
|
|
26
|
+
Lightweight internal representation of a graph.
|
|
27
|
+
|
|
28
|
+
Stores graph data in an efficient format and provides conversions
|
|
29
|
+
to networkx, igraph, and scipy graph libraries and various graph representations.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, n_nodes: int):
|
|
33
|
+
self.n_nodes = n_nodes
|
|
34
|
+
self.edges: list[tuple[int, int]] = []
|
|
35
|
+
self.edge_attrs: dict[tuple[int, int], dict[str, Any]] = {}
|
|
36
|
+
self.node_attrs: dict[int, dict[str, Any]] = {i: {} for i in range(n_nodes)}
|
|
37
|
+
|
|
38
|
+
# Cache for converted representations
|
|
39
|
+
self._networkx_cache = None
|
|
40
|
+
self._igraph_cache = None
|
|
41
|
+
self._adjacency_cache = None
|
|
42
|
+
self._dirty = False # Track if graph has been modified
|
|
43
|
+
|
|
44
|
+
def add_edge(self, u: int, v: int, **attrs):
|
|
45
|
+
"""Add an edge with optional attributes."""
|
|
46
|
+
edge = (u, v)
|
|
47
|
+
if edge not in self.edges:
|
|
48
|
+
self.edges.append(edge)
|
|
49
|
+
self.edge_attrs[edge] = attrs
|
|
50
|
+
self._invalidate_caches()
|
|
51
|
+
|
|
52
|
+
def add_node_attr(self, node: int, key: str, value: Any):
|
|
53
|
+
"""Add attribute to a node."""
|
|
54
|
+
self.node_attrs[node][key] = value
|
|
55
|
+
self._invalidate_caches()
|
|
56
|
+
|
|
57
|
+
def _invalidate_caches(self):
|
|
58
|
+
"""Invalidate cached representations when graph is modified."""
|
|
59
|
+
self._networkx_cache = None
|
|
60
|
+
self._igraph_cache = None
|
|
61
|
+
self._adjacency_cache = None
|
|
62
|
+
self._dirty = True
|
|
63
|
+
|
|
64
|
+
def to_networkx(self, directed: bool = True):
|
|
65
|
+
"""Convert to NetworkX graph."""
|
|
66
|
+
if self._networkx_cache is not None and not self._dirty:
|
|
67
|
+
return self._networkx_cache
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
import networkx as nx
|
|
71
|
+
except ImportError:
|
|
72
|
+
raise ImportError(
|
|
73
|
+
"NetworkX is required for this conversion. Install with: pip install networkx or per your package manager's syntax"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
G = nx.DiGraph() if directed else nx.Graph()
|
|
77
|
+
G.add_nodes_from(range(self.n_nodes))
|
|
78
|
+
|
|
79
|
+
# Add node attributes
|
|
80
|
+
for node, attrs in self.node_attrs.items():
|
|
81
|
+
for key, value in attrs.items():
|
|
82
|
+
G.nodes[node][key] = value
|
|
83
|
+
|
|
84
|
+
# Add edges with attributes
|
|
85
|
+
for u, v in self.edges:
|
|
86
|
+
attrs = self.edge_attrs.get((u, v), {})
|
|
87
|
+
G.add_edge(u, v, **attrs)
|
|
88
|
+
|
|
89
|
+
self._networkx_cache = G
|
|
90
|
+
self._dirty = False
|
|
91
|
+
return G
|
|
92
|
+
|
|
93
|
+
def to_igraph(self, directed: bool = True):
|
|
94
|
+
"""Convert to igraph graph."""
|
|
95
|
+
if self._igraph_cache is not None and not self._dirty:
|
|
96
|
+
return self._igraph_cache
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import igraph as ig
|
|
100
|
+
except ImportError:
|
|
101
|
+
raise ImportError(
|
|
102
|
+
"igraph is required for this conversion. Install with: pip install igraph or per your package manager's syntax"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
g = ig.Graph(n=self.n_nodes, directed=directed)
|
|
106
|
+
|
|
107
|
+
# Add edges
|
|
108
|
+
if self.edges:
|
|
109
|
+
g.add_edges(self.edges)
|
|
110
|
+
|
|
111
|
+
# Add edge attributes
|
|
112
|
+
for edge_idx, (u, v) in enumerate(self.edges):
|
|
113
|
+
attrs = self.edge_attrs.get((u, v), {})
|
|
114
|
+
for key, value in attrs.items():
|
|
115
|
+
if key not in g.es.attributes():
|
|
116
|
+
g.es[key] = [None] * g.ecount()
|
|
117
|
+
g.es[edge_idx][key] = value
|
|
118
|
+
|
|
119
|
+
# Add node attributes
|
|
120
|
+
for node, attrs in self.node_attrs.items():
|
|
121
|
+
for key, value in attrs.items():
|
|
122
|
+
if key not in g.vs.attributes():
|
|
123
|
+
g.vs[key] = [None] * g.vcount()
|
|
124
|
+
g.vs[node][key] = value
|
|
125
|
+
|
|
126
|
+
self._igraph_cache = g
|
|
127
|
+
self._dirty = False
|
|
128
|
+
return g
|
|
129
|
+
|
|
130
|
+
def to_adjacency_matrix(
|
|
131
|
+
self, weighted: bool = False, weight_attr: str = "length"
|
|
132
|
+
) -> np.ndarray:
|
|
133
|
+
"""
|
|
134
|
+
Convert to adjacency matrix (numpy array).
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
weighted : bool
|
|
139
|
+
If True, use edge weights. If False, binary adjacency.
|
|
140
|
+
weight_attr : str
|
|
141
|
+
Edge attribute to use for weights.
|
|
142
|
+
"""
|
|
143
|
+
if not weighted and self._adjacency_cache is not None:
|
|
144
|
+
return self._adjacency_cache
|
|
145
|
+
|
|
146
|
+
adj = np.zeros((self.n_nodes, self.n_nodes))
|
|
147
|
+
|
|
148
|
+
for u, v in self.edges:
|
|
149
|
+
if weighted and weight_attr in self.edge_attrs.get((u, v), {}):
|
|
150
|
+
adj[u, v] = self.edge_attrs[(u, v)][weight_attr]
|
|
151
|
+
else:
|
|
152
|
+
adj[u, v] = 1
|
|
153
|
+
|
|
154
|
+
if not weighted:
|
|
155
|
+
self._adjacency_cache = adj
|
|
156
|
+
|
|
157
|
+
return adj
|
|
158
|
+
|
|
159
|
+
def to_sparse_matrix(self, weighted: bool = False, weight_attr: str = "length"):
|
|
160
|
+
"""Convert to scipy sparse matrix (CSR format)."""
|
|
161
|
+
try:
|
|
162
|
+
from scipy.sparse import csr_matrix
|
|
163
|
+
except ImportError:
|
|
164
|
+
raise ImportError(
|
|
165
|
+
"scipy is required for sparse matrices. Install with: pip install scipy or per your package manager's syntax"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
adj = self.to_adjacency_matrix(weighted=weighted, weight_attr=weight_attr)
|
|
169
|
+
return csr_matrix(adj)
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_networkx(cls, G):
|
|
173
|
+
"""Create from NetworkX graph."""
|
|
174
|
+
n_nodes = G.number_of_nodes()
|
|
175
|
+
graph_rep = cls(n_nodes)
|
|
176
|
+
|
|
177
|
+
# Add edges with attributes
|
|
178
|
+
for u, v, attrs in G.edges(data=True):
|
|
179
|
+
graph_rep.add_edge(u, v, **attrs)
|
|
180
|
+
|
|
181
|
+
# Add node attributes
|
|
182
|
+
for node, attrs in G.nodes(data=True):
|
|
183
|
+
for key, value in attrs.items():
|
|
184
|
+
graph_rep.add_node_attr(node, key, value)
|
|
185
|
+
|
|
186
|
+
return graph_rep
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def from_igraph(cls, g):
|
|
190
|
+
"""Create from igraph graph."""
|
|
191
|
+
n_nodes = g.vcount()
|
|
192
|
+
graph_rep = cls(n_nodes)
|
|
193
|
+
|
|
194
|
+
# Add edges with attributes
|
|
195
|
+
for edge in g.es:
|
|
196
|
+
u, v = edge.tuple
|
|
197
|
+
attrs = {key: edge[key] for key in edge.attributes()}
|
|
198
|
+
graph_rep.add_edge(u, v, **attrs)
|
|
199
|
+
|
|
200
|
+
# Add node attributes
|
|
201
|
+
for node_idx in range(n_nodes):
|
|
202
|
+
vertex = g.vs[node_idx]
|
|
203
|
+
for key in vertex.attributes():
|
|
204
|
+
graph_rep.add_node_attr(node_idx, key, vertex[key])
|
|
205
|
+
|
|
206
|
+
return graph_rep
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def from_adjacency_matrix(cls, adj: np.ndarray, weighted: bool = False):
|
|
210
|
+
"""Create from adjacency matrix."""
|
|
211
|
+
n_nodes = adj.shape[0]
|
|
212
|
+
graph_rep = cls(n_nodes)
|
|
213
|
+
|
|
214
|
+
rows, cols = np.nonzero(adj)
|
|
215
|
+
for u, v in zip(rows, cols):
|
|
216
|
+
if weighted:
|
|
217
|
+
graph_rep.add_edge(int(u), int(v), weight=float(adj[u, v]))
|
|
218
|
+
else:
|
|
219
|
+
graph_rep.add_edge(int(u), int(v))
|
|
220
|
+
|
|
221
|
+
return graph_rep
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class BaseGraph(ABC):
|
|
225
|
+
"""
|
|
226
|
+
Abstract base class for all graph structures.
|
|
227
|
+
|
|
228
|
+
This provides a unified interface while maintaining compatibility
|
|
229
|
+
with multiple graph libraries.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(self, n_nodes: int = 0):
|
|
233
|
+
self._graph = GraphRepresentation(n_nodes)
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def n_nodes(self) -> int:
|
|
237
|
+
"""Number of nodes in the graph."""
|
|
238
|
+
return self._graph.n_nodes
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def n_edges(self) -> int:
|
|
242
|
+
"""Number of edges in the graph."""
|
|
243
|
+
return len(self._graph.edges)
|
|
244
|
+
|
|
245
|
+
def add_edge(self, u: int, v: int, **attrs):
|
|
246
|
+
"""Add an edge with optional attributes."""
|
|
247
|
+
self._graph.add_edge(u, v, **attrs)
|
|
248
|
+
|
|
249
|
+
def add_node_attr(self, node: int, key: str, value: Any):
|
|
250
|
+
"""Add attribute to a node."""
|
|
251
|
+
self._graph.add_node_attr(node, key, value)
|
|
252
|
+
|
|
253
|
+
# Conversion methods
|
|
254
|
+
def to_networkx(self, directed: bool = True):
|
|
255
|
+
"""Export to NetworkX graph."""
|
|
256
|
+
return self._graph.to_networkx(directed=directed)
|
|
257
|
+
|
|
258
|
+
def to_igraph(self, directed: bool = True):
|
|
259
|
+
"""Export to igraph graph."""
|
|
260
|
+
return self._graph.to_igraph(directed=directed)
|
|
261
|
+
|
|
262
|
+
def to_adjacency_matrix(
|
|
263
|
+
self, weighted: bool = False, weight_attr: str = "length"
|
|
264
|
+
) -> np.ndarray:
|
|
265
|
+
"""Export to numpy adjacency matrix."""
|
|
266
|
+
return self._graph.to_adjacency_matrix(
|
|
267
|
+
weighted=weighted, weight_attr=weight_attr
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def to_sparse_matrix(self, weighted: bool = False, weight_attr: str = "length"):
|
|
271
|
+
"""Export to scipy sparse matrix."""
|
|
272
|
+
return self._graph.to_sparse_matrix(weighted=weighted, weight_attr=weight_attr)
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
def from_networkx(cls, G):
|
|
276
|
+
"""Create from NetworkX graph."""
|
|
277
|
+
instance = cls(G.number_of_nodes())
|
|
278
|
+
instance._graph = GraphRepresentation.from_networkx(G)
|
|
279
|
+
return instance
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def from_igraph(cls, g):
|
|
283
|
+
"""Create from igraph graph."""
|
|
284
|
+
instance = cls(g.vcount())
|
|
285
|
+
instance._graph = GraphRepresentation.from_igraph(g)
|
|
286
|
+
return instance
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def from_adjacency_matrix(cls, adj: np.ndarray, weighted: bool = False):
|
|
290
|
+
"""Create from adjacency matrix."""
|
|
291
|
+
instance = cls(adj.shape[0])
|
|
292
|
+
instance._graph = GraphRepresentation.from_adjacency_matrix(adj, weighted)
|
|
293
|
+
return instance
|
|
294
|
+
|
|
295
|
+
@abstractmethod
|
|
296
|
+
def validate(self) -> bool:
|
|
297
|
+
"""Validate that the graph satisfies structural constraints."""
|
|
298
|
+
|
|
299
|
+
def __repr__(self):
|
|
300
|
+
"""Return a summary of the graph's node and edge counts."""
|
|
301
|
+
return (
|
|
302
|
+
f"{self.__class__.__name__}(n_nodes={self.n_nodes}, n_edges={self.n_edges})"
|
|
303
|
+
)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Binary tree data structure with multi-library compatibility.
|
|
3
|
+
|
|
4
|
+
Extends Tree with binary-specific constraints and operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .tree import Tree
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BinaryTree(Tree):
|
|
13
|
+
"""
|
|
14
|
+
Binary tree where each node has at most 2 children.
|
|
15
|
+
|
|
16
|
+
Maintains explicit left/right child tracking for binary-specific operations.
|
|
17
|
+
Also stores whether the tree is 'planted' (root has degree 1).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, n_nodes: int = 0, root: int | None = None, planted: bool = True):
|
|
21
|
+
super().__init__(n_nodes, root)
|
|
22
|
+
|
|
23
|
+
# Binary tree specific structures
|
|
24
|
+
self.left_child: np.ndarray = np.full(n_nodes, -1, dtype=np.int32)
|
|
25
|
+
self.right_child: np.ndarray = np.full(n_nodes, -1, dtype=np.int32)
|
|
26
|
+
self.planted = planted # Whether root has degree 1
|
|
27
|
+
|
|
28
|
+
def add_edge(
|
|
29
|
+
self,
|
|
30
|
+
parent: int,
|
|
31
|
+
child: int,
|
|
32
|
+
side: str | None = None,
|
|
33
|
+
length: float = 1.0,
|
|
34
|
+
**attrs,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Add an edge from parent to child.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
parent : int
|
|
42
|
+
Parent node index
|
|
43
|
+
child : int
|
|
44
|
+
Child node index
|
|
45
|
+
side : str, optional
|
|
46
|
+
'left' or 'right'. If None, will be determined automatically.
|
|
47
|
+
length : float
|
|
48
|
+
Edge length
|
|
49
|
+
**attrs
|
|
50
|
+
Additional edge attributes
|
|
51
|
+
"""
|
|
52
|
+
# Validate binary constraint
|
|
53
|
+
if len(self.children[parent]) >= 2:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Node {parent} already has 2 children. Binary tree constraint violated."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Determine side if not specified
|
|
59
|
+
if side is None:
|
|
60
|
+
if self.left_child[parent] == -1:
|
|
61
|
+
side = "left"
|
|
62
|
+
elif self.right_child[parent] == -1:
|
|
63
|
+
side = "right"
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError(f"Node {parent} already has 2 children")
|
|
66
|
+
|
|
67
|
+
# Update left/right tracking
|
|
68
|
+
if side == "left":
|
|
69
|
+
if self.left_child[parent] != -1:
|
|
70
|
+
raise ValueError(f"Node {parent} already has a left child")
|
|
71
|
+
self.left_child[parent] = child
|
|
72
|
+
elif side == "right":
|
|
73
|
+
if self.right_child[parent] != -1:
|
|
74
|
+
raise ValueError(f"Node {parent} already has a right child")
|
|
75
|
+
self.right_child[parent] = child
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f"side must be 'left' or 'right', got {side}")
|
|
78
|
+
|
|
79
|
+
# Call parent add_edge
|
|
80
|
+
super().add_edge(parent, child, length=length, **attrs)
|
|
81
|
+
|
|
82
|
+
# Store side as edge attribute
|
|
83
|
+
self._graph.edge_attrs[(parent, child)]["side"] = side
|
|
84
|
+
|
|
85
|
+
def get_left_child(self, node: int) -> int:
|
|
86
|
+
"""Get left child of node. Returns -1 if no left child."""
|
|
87
|
+
return int(self.left_child[node])
|
|
88
|
+
|
|
89
|
+
def get_right_child(self, node: int) -> int:
|
|
90
|
+
"""Get right child of node. Returns -1 if no right child."""
|
|
91
|
+
return int(self.right_child[node])
|
|
92
|
+
|
|
93
|
+
def is_left_child(self, node: int) -> bool:
|
|
94
|
+
"""Check if node is a left child of its parent."""
|
|
95
|
+
if self.is_root(node):
|
|
96
|
+
return False
|
|
97
|
+
parent = self.parent[node]
|
|
98
|
+
return self.left_child[parent] == node
|
|
99
|
+
|
|
100
|
+
def is_right_child(self, node: int) -> bool:
|
|
101
|
+
"""Check if node is a right child of its parent."""
|
|
102
|
+
if self.is_root(node):
|
|
103
|
+
return False
|
|
104
|
+
parent = self.parent[node]
|
|
105
|
+
return self.right_child[parent] == node
|
|
106
|
+
|
|
107
|
+
def get_sibling(self, node: int) -> int:
|
|
108
|
+
"""Get sibling of node. Returns -1 if no sibling or node is root."""
|
|
109
|
+
if self.is_root(node):
|
|
110
|
+
return -1
|
|
111
|
+
|
|
112
|
+
parent = self.parent[node]
|
|
113
|
+
if self.is_left_child(node):
|
|
114
|
+
return self.right_child[parent]
|
|
115
|
+
else:
|
|
116
|
+
return self.left_child[parent]
|
|
117
|
+
|
|
118
|
+
def depth_first_search_lr(self, start: int | None = None) -> list[int]:
|
|
119
|
+
"""
|
|
120
|
+
Depth-first search with explicit left-right ordering.
|
|
121
|
+
|
|
122
|
+
Returns nodes in order: leftmost leaf → root → rightmost leaf
|
|
123
|
+
(standard DFS for binary trees).
|
|
124
|
+
"""
|
|
125
|
+
if start is None:
|
|
126
|
+
start = self.root
|
|
127
|
+
|
|
128
|
+
order = []
|
|
129
|
+
|
|
130
|
+
def dfs(node):
|
|
131
|
+
if node == -1:
|
|
132
|
+
return
|
|
133
|
+
dfs(self.left_child[node])
|
|
134
|
+
order.append(node)
|
|
135
|
+
dfs(self.right_child[node])
|
|
136
|
+
|
|
137
|
+
dfs(start)
|
|
138
|
+
return order
|
|
139
|
+
|
|
140
|
+
def get_leaves_lr(self) -> list[int]:
|
|
141
|
+
"""Get leaves in left-to-right order."""
|
|
142
|
+
return [node for node in self.depth_first_search_lr() if self.is_leaf(node)]
|
|
143
|
+
|
|
144
|
+
def validate(self) -> bool:
|
|
145
|
+
"""
|
|
146
|
+
Validate binary tree structure.
|
|
147
|
+
|
|
148
|
+
Checks Tree constraints plus:
|
|
149
|
+
- Each node has at most 2 children
|
|
150
|
+
- Left/right child arrays are consistent with children lists
|
|
151
|
+
"""
|
|
152
|
+
# Check parent tree validation
|
|
153
|
+
if not super().validate():
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
# Check binary constraint
|
|
157
|
+
for node in range(self.n_nodes):
|
|
158
|
+
if len(self.children[node]) > 2:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
# Check consistency of left/right with children list
|
|
162
|
+
left = self.left_child[node]
|
|
163
|
+
right = self.right_child[node]
|
|
164
|
+
children_set = set(self.children[node])
|
|
165
|
+
|
|
166
|
+
expected_children = set()
|
|
167
|
+
if left != -1:
|
|
168
|
+
expected_children.add(left)
|
|
169
|
+
if right != -1:
|
|
170
|
+
expected_children.add(right)
|
|
171
|
+
|
|
172
|
+
if children_set != expected_children:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def from_parent_array(
|
|
179
|
+
cls,
|
|
180
|
+
parent_array: np.ndarray,
|
|
181
|
+
edge_lengths: np.ndarray | None = None,
|
|
182
|
+
left_right_order: list[tuple[int, str]] | None = None,
|
|
183
|
+
root: int | None = None,
|
|
184
|
+
planted: bool = True,
|
|
185
|
+
) -> "BinaryTree":
|
|
186
|
+
"""
|
|
187
|
+
Create binary tree from parent array.
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
parent_array : np.ndarray
|
|
192
|
+
Parent array where parent_array[i] is parent of node i
|
|
193
|
+
edge_lengths : np.ndarray, optional
|
|
194
|
+
Edge lengths
|
|
195
|
+
left_right_order : List[Tuple[int, str]], optional
|
|
196
|
+
List of (node, side) tuples specifying whether each child is 'left' or 'right'.
|
|
197
|
+
If None, will assign left/right based on index order.
|
|
198
|
+
root : int, optional
|
|
199
|
+
Root node index
|
|
200
|
+
planted : bool
|
|
201
|
+
Whether the tree is planted (root has degree 1)
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
BinaryTree instance
|
|
206
|
+
"""
|
|
207
|
+
n_nodes = len(parent_array)
|
|
208
|
+
|
|
209
|
+
# Find root if not specified
|
|
210
|
+
if root is None:
|
|
211
|
+
root_candidates = np.where(
|
|
212
|
+
(parent_array == -1) | (parent_array == np.arange(n_nodes))
|
|
213
|
+
)[0]
|
|
214
|
+
if len(root_candidates) == 0:
|
|
215
|
+
raise ValueError("No root found in parent array")
|
|
216
|
+
root = int(root_candidates[0])
|
|
217
|
+
|
|
218
|
+
tree = cls(n_nodes=n_nodes, root=root, planted=planted)
|
|
219
|
+
|
|
220
|
+
# Build mapping of which children are left/right
|
|
221
|
+
if left_right_order is None:
|
|
222
|
+
# Default: first child is left, second is right (by index)
|
|
223
|
+
parent_children = {i: [] for i in range(n_nodes)}
|
|
224
|
+
for child in range(n_nodes):
|
|
225
|
+
if child != root and parent_array[child] != child:
|
|
226
|
+
parent = int(parent_array[child])
|
|
227
|
+
parent_children[parent].append(child)
|
|
228
|
+
|
|
229
|
+
# Sort children by index for consistent left/right assignment
|
|
230
|
+
for children in parent_children.values():
|
|
231
|
+
children.sort()
|
|
232
|
+
|
|
233
|
+
# Add edges
|
|
234
|
+
for child in range(n_nodes):
|
|
235
|
+
if child != root and parent_array[child] != child:
|
|
236
|
+
parent = int(parent_array[child])
|
|
237
|
+
length = 1.0 if edge_lengths is None else float(edge_lengths[child])
|
|
238
|
+
|
|
239
|
+
# Determine side
|
|
240
|
+
if left_right_order is not None:
|
|
241
|
+
# Use provided left/right info
|
|
242
|
+
side = next((s for n, s in left_right_order if n == child), None)
|
|
243
|
+
if side is None:
|
|
244
|
+
# Guess based on existing children
|
|
245
|
+
side = "left" if tree.left_child[parent] == -1 else "right"
|
|
246
|
+
else:
|
|
247
|
+
# Use index-based ordering
|
|
248
|
+
children_of_parent = parent_children[parent]
|
|
249
|
+
idx = children_of_parent.index(child)
|
|
250
|
+
side = "left" if idx == 0 else "right"
|
|
251
|
+
|
|
252
|
+
tree.add_edge(parent, child, side=side, length=length)
|
|
253
|
+
|
|
254
|
+
return tree
|
|
255
|
+
|
|
256
|
+
def to_parent_array_with_sides(
|
|
257
|
+
self,
|
|
258
|
+
) -> tuple[np.ndarray, np.ndarray, list[tuple[int, str]]]:
|
|
259
|
+
"""
|
|
260
|
+
Export as parent array with left/right information.
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
parent_array : np.ndarray
|
|
265
|
+
edge_lengths : np.ndarray
|
|
266
|
+
left_right_info : List[Tuple[int, str]]
|
|
267
|
+
List of (node, side) tuples
|
|
268
|
+
"""
|
|
269
|
+
parent_arr, lengths = super().to_parent_array()
|
|
270
|
+
|
|
271
|
+
left_right_info = []
|
|
272
|
+
for node in range(self.n_nodes):
|
|
273
|
+
if not self.is_root(node):
|
|
274
|
+
side = "left" if self.is_left_child(node) else "right"
|
|
275
|
+
left_right_info.append((node, side))
|
|
276
|
+
|
|
277
|
+
return parent_arr, lengths, left_right_info
|
|
278
|
+
|
|
279
|
+
def __repr__(self):
|
|
280
|
+
"""Return a summary of the tree's size, leaf count, depth, and plantedness."""
|
|
281
|
+
leaves = len(self.get_leaves())
|
|
282
|
+
max_depth = np.max(self.get_depth()) if self.n_nodes > 0 else 0
|
|
283
|
+
return (
|
|
284
|
+
f"BinaryTree(n_nodes={self.n_nodes}, n_leaves={leaves}, "
|
|
285
|
+
f"max_depth={max_depth}, planted={self.planted})"
|
|
286
|
+
)
|