simplex-tree-classifier 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.
- simplex_tree_classifier/__init__.py +28 -0
- simplex_tree_classifier/backend.py +181 -0
- simplex_tree_classifier/classifier.py +770 -0
- simplex_tree_classifier/convexity.py +237 -0
- simplex_tree_classifier/plane_equation.py +65 -0
- simplex_tree_classifier/simplex.py +156 -0
- simplex_tree_classifier/simplex_tree.py +319 -0
- simplex_tree_classifier/vertex_registry.py +70 -0
- simplex_tree_classifier/visualization.py +102 -0
- simplex_tree_classifier-0.1.0.dist-info/METADATA +157 -0
- simplex_tree_classifier-0.1.0.dist-info/RECORD +14 -0
- simplex_tree_classifier-0.1.0.dist-info/WHEEL +5 -0
- simplex_tree_classifier-0.1.0.dist-info/licenses/LICENSE +21 -0
- simplex_tree_classifier-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Hierarchical tree of N-dimensional simplices.
|
|
2
|
+
|
|
3
|
+
A ``SimplexTree`` is a ``Simplex`` that can subdivide itself. Two subdivision
|
|
4
|
+
schemes are supported through this class:
|
|
5
|
+
|
|
6
|
+
* barycentric - recursively split every simplex at its barycenter
|
|
7
|
+
(``_add_barycentric_centers_recursively``), producing a uniform tree;
|
|
8
|
+
* data-driven - split only chosen leaves at their barycenter
|
|
9
|
+
(``subdivide_leaf``), driven externally by classification error.
|
|
10
|
+
|
|
11
|
+
The tree also answers geometric queries used by the classifier:
|
|
12
|
+
``find_containing_simplex`` and ``find_adjacent_simplexes``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from typing import List, Optional, Iterator, Tuple
|
|
17
|
+
from collections import deque
|
|
18
|
+
|
|
19
|
+
from .simplex import Simplex
|
|
20
|
+
from .vertex_registry import VertexRegistry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_enclosing_simplex(dimension: int, scale: Optional[float] = None,
|
|
24
|
+
margin: float = 0.05) -> List[Tuple[float, ...]]:
|
|
25
|
+
"""Build vertices of a d-simplex that encloses the unit hypercube ``[0, 1]^d``.
|
|
26
|
+
|
|
27
|
+
The simplex has one vertex at the origin and one vertex ``scale * e_i`` along
|
|
28
|
+
each axis, i.e. the region ``{x_i >= 0, sum_i x_i <= scale}``. Choosing
|
|
29
|
+
``scale > d`` guarantees the whole closed unit cube (whose farthest corner has
|
|
30
|
+
coordinate sum ``d``) lies strictly inside.
|
|
31
|
+
|
|
32
|
+
For ``d == 2`` and ``margin == 0`` this reproduces the classic base triangle
|
|
33
|
+
``[(0, 0), (2, 0), (0, 2)]``.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
dimension: Number of features ``d``.
|
|
37
|
+
scale: Axis intercept of the enclosing simplex. Defaults to
|
|
38
|
+
``d * (1 + margin)``.
|
|
39
|
+
margin: Relative slack beyond the unit cube when ``scale`` is not given.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of ``d + 1`` coordinate tuples.
|
|
43
|
+
"""
|
|
44
|
+
if dimension < 1:
|
|
45
|
+
raise ValueError("dimension must be >= 1")
|
|
46
|
+
if scale is None:
|
|
47
|
+
scale = dimension * (1.0 + margin)
|
|
48
|
+
origin = tuple(0.0 for _ in range(dimension))
|
|
49
|
+
vertices = [origin]
|
|
50
|
+
for axis in range(dimension):
|
|
51
|
+
vertex = [0.0] * dimension
|
|
52
|
+
vertex[axis] = float(scale)
|
|
53
|
+
vertices.append(tuple(vertex))
|
|
54
|
+
return vertices
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SimplexTree(Simplex):
|
|
58
|
+
def __init__(self, vertices: List[Tuple[float, ...]], tolerance: float = 1e-10,
|
|
59
|
+
registry: Optional[VertexRegistry] = None, _is_child: bool = False):
|
|
60
|
+
if registry is None:
|
|
61
|
+
reg = VertexRegistry(tolerance)
|
|
62
|
+
self._is_root = True
|
|
63
|
+
self._split_counter = [0]
|
|
64
|
+
else:
|
|
65
|
+
reg = registry
|
|
66
|
+
self._is_root = not _is_child
|
|
67
|
+
self._split_counter = None
|
|
68
|
+
|
|
69
|
+
vertex_indices = reg.register_vertices(vertices)
|
|
70
|
+
super().__init__(vertex_indices, reg, tolerance)
|
|
71
|
+
|
|
72
|
+
self.children: List[Optional['SimplexTree']] = []
|
|
73
|
+
self.parent: Optional['SimplexTree'] = None
|
|
74
|
+
self.depth: int = 0
|
|
75
|
+
self._node_count = 1
|
|
76
|
+
self.splitting_point_index: Optional[int] = None
|
|
77
|
+
self.splitting_point_vertex_index: Optional[int] = None
|
|
78
|
+
|
|
79
|
+
def _get_root(self) -> 'SimplexTree':
|
|
80
|
+
node = self
|
|
81
|
+
while node.parent is not None:
|
|
82
|
+
node = node.parent
|
|
83
|
+
return node
|
|
84
|
+
|
|
85
|
+
def _get_next_split_index(self) -> int:
|
|
86
|
+
root = self._get_root()
|
|
87
|
+
idx = root._split_counter[0]
|
|
88
|
+
root._split_counter[0] += 1
|
|
89
|
+
return idx
|
|
90
|
+
|
|
91
|
+
def _add_child(self, child_vertices: List[Tuple[float, ...]]) -> 'SimplexTree':
|
|
92
|
+
child = SimplexTree(child_vertices, self.tolerance, self.registry, _is_child=True)
|
|
93
|
+
child.parent = self
|
|
94
|
+
child.depth = self.depth + 1
|
|
95
|
+
self.children.append(child)
|
|
96
|
+
return child
|
|
97
|
+
|
|
98
|
+
def _is_leaf(self) -> bool:
|
|
99
|
+
return len(self.children) == 0
|
|
100
|
+
|
|
101
|
+
def _get_children(self) -> List['SimplexTree']:
|
|
102
|
+
return self.children.copy()
|
|
103
|
+
|
|
104
|
+
def _remove_splitting_point(self, splitting_point_index: int) -> bool:
|
|
105
|
+
"""Remove a splitting point by clearing its children and resetting to a leaf."""
|
|
106
|
+
for node in self._traverse_breadth_first():
|
|
107
|
+
if node.splitting_point_index == splitting_point_index:
|
|
108
|
+
for child in node.children:
|
|
109
|
+
if len(child.children) > 0:
|
|
110
|
+
return False
|
|
111
|
+
node.children.clear()
|
|
112
|
+
node.splitting_point_index = None
|
|
113
|
+
node.splitting_point_vertex_index = None
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def remove_by_leaf_key(self, vertex_key: frozenset) -> bool:
|
|
118
|
+
"""Removes a leaf simplex by undoing its parent's split.
|
|
119
|
+
|
|
120
|
+
Clears the parent's splitting point and removes all sibling children,
|
|
121
|
+
restoring the parent to a leaf state.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
vertex_key: Frozenset of vertex indices identifying the leaf to remove
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
True if removal succeeded, False if leaf not found or has children
|
|
128
|
+
"""
|
|
129
|
+
for node in self._traverse_breadth_first():
|
|
130
|
+
if frozenset(node.vertex_indices) == vertex_key:
|
|
131
|
+
parent = node.parent
|
|
132
|
+
if parent is not None and parent.splitting_point_index is not None:
|
|
133
|
+
return self._remove_splitting_point(parent.splitting_point_index)
|
|
134
|
+
return False
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def _traverse_breadth_first(self) -> Iterator['SimplexTree']:
|
|
138
|
+
queue = deque([self])
|
|
139
|
+
while queue:
|
|
140
|
+
node = queue.popleft()
|
|
141
|
+
yield node
|
|
142
|
+
for child in node._get_children():
|
|
143
|
+
queue.append(child)
|
|
144
|
+
|
|
145
|
+
def get_leaves(self) -> List['SimplexTree']:
|
|
146
|
+
"""Returns all leaf nodes (simplices with no children) in the tree."""
|
|
147
|
+
leaves = []
|
|
148
|
+
for node in self._traverse_breadth_first():
|
|
149
|
+
if node._is_leaf():
|
|
150
|
+
leaves.append(node)
|
|
151
|
+
return leaves
|
|
152
|
+
|
|
153
|
+
def find_adjacent_simplexes(self, leaf: 'SimplexTree') -> List['SimplexTree']:
|
|
154
|
+
"""Finds all leaf simplexes adjacent to the given leaf.
|
|
155
|
+
|
|
156
|
+
Two leaf simplexes are adjacent if they share at least ``d`` vertices
|
|
157
|
+
(a ``(d-1)``-dimensional face), where ``d`` is the dimension of the space.
|
|
158
|
+
|
|
159
|
+
Walks up the tree level by level - at each ancestor, checks the sibling
|
|
160
|
+
subtrees for adjacent leaves (siblings, cousins, second cousins, etc.).
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
leaf: A leaf SimplexTree node
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of adjacent leaf SimplexTree nodes
|
|
167
|
+
"""
|
|
168
|
+
leaf_vertex_set = set(leaf.vertex_indices)
|
|
169
|
+
min_shared = leaf.dimension
|
|
170
|
+
adjacent = []
|
|
171
|
+
seen = set()
|
|
172
|
+
|
|
173
|
+
current = leaf
|
|
174
|
+
while current.parent is not None:
|
|
175
|
+
parent = current.parent
|
|
176
|
+
for sibling in parent.children:
|
|
177
|
+
if sibling is current:
|
|
178
|
+
continue
|
|
179
|
+
candidates = [sibling] if sibling._is_leaf() else sibling.get_leaves()
|
|
180
|
+
for candidate in candidates:
|
|
181
|
+
if id(candidate) not in seen:
|
|
182
|
+
if len(leaf_vertex_set.intersection(candidate.vertex_indices)) >= min_shared:
|
|
183
|
+
adjacent.append(candidate)
|
|
184
|
+
seen.add(id(candidate))
|
|
185
|
+
if len(adjacent) == leaf.dimension + 1:
|
|
186
|
+
return adjacent
|
|
187
|
+
current = parent
|
|
188
|
+
|
|
189
|
+
return adjacent
|
|
190
|
+
|
|
191
|
+
def find_containing_simplex(self, point: Tuple[float, ...]) -> Optional['SimplexTree']:
|
|
192
|
+
"""Finds the leaf simplex containing the given point.
|
|
193
|
+
|
|
194
|
+
Recursively searches the tree to find the smallest (deepest) simplex
|
|
195
|
+
that contains the point.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
point: Coordinates to locate
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The leaf SimplexTree containing the point, or None if outside tree
|
|
202
|
+
"""
|
|
203
|
+
if not self._point_inside_simplex(point):
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
if self._is_leaf():
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
for child in self._get_children():
|
|
210
|
+
result = child.find_containing_simplex(point)
|
|
211
|
+
if result is not None:
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
def __repr__(self):
|
|
217
|
+
vertices = self.get_vertices_as_tuples()
|
|
218
|
+
vertex_str = str(vertices)
|
|
219
|
+
return (f"{self.__class__.__name__}(vertices={vertex_str}, "
|
|
220
|
+
f"children={len(self.children)}, depth={self.depth})")
|
|
221
|
+
|
|
222
|
+
def _add_splitting_point(self, point: Tuple[float, ...]) -> List['SimplexTree']:
|
|
223
|
+
if not self._is_leaf():
|
|
224
|
+
for child in self.children:
|
|
225
|
+
if child._point_inside_simplex(point):
|
|
226
|
+
return child._add_splitting_point(point)
|
|
227
|
+
|
|
228
|
+
if not self._point_inside_simplex(point):
|
|
229
|
+
raise ValueError(f"Splitting point {point} is not inside this simplex")
|
|
230
|
+
|
|
231
|
+
self.splitting_point_index = self._get_next_split_index()
|
|
232
|
+
vertex_indices = self.registry.register_vertices([tuple(point)])
|
|
233
|
+
self.splitting_point_vertex_index = vertex_indices[0]
|
|
234
|
+
|
|
235
|
+
vertices = self.get_vertices_as_tuples()
|
|
236
|
+
n_vertices = len(vertices)
|
|
237
|
+
|
|
238
|
+
children = []
|
|
239
|
+
for i in range(n_vertices):
|
|
240
|
+
child_vertices = [v for j, v in enumerate(vertices) if j != i] + [tuple(point)]
|
|
241
|
+
child = self._add_child(child_vertices)
|
|
242
|
+
children.append(child)
|
|
243
|
+
return children
|
|
244
|
+
|
|
245
|
+
def _compute_barycentric_center(self) -> Tuple[float, ...]:
|
|
246
|
+
vertices = self.vertices
|
|
247
|
+
center = np.mean(vertices, axis=0)
|
|
248
|
+
return tuple(float(x) for x in center)
|
|
249
|
+
|
|
250
|
+
def subdivide_leaf(self, leaf: 'SimplexTree') -> List['SimplexTree']:
|
|
251
|
+
"""Split a single leaf at its barycenter (used by data-driven subdivision).
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
leaf: A leaf node of this tree.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
The list of freshly created child simplices.
|
|
258
|
+
"""
|
|
259
|
+
if not leaf._is_leaf():
|
|
260
|
+
raise ValueError("subdivide_leaf expects a leaf node")
|
|
261
|
+
barycenter = leaf._compute_barycentric_center()
|
|
262
|
+
return leaf._add_splitting_point(barycenter)
|
|
263
|
+
|
|
264
|
+
def _add_barycentric_centers_recursively(self, levels: int) -> None:
|
|
265
|
+
if levels <= 0:
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if self._is_leaf():
|
|
269
|
+
barycenter = self._compute_barycentric_center()
|
|
270
|
+
self._add_splitting_point(barycenter)
|
|
271
|
+
|
|
272
|
+
for child in self.children:
|
|
273
|
+
child._add_barycentric_centers_recursively(levels - 1)
|
|
274
|
+
|
|
275
|
+
def print_tree(self, show_only_splitting_points: bool = False) -> None:
|
|
276
|
+
"""Prints the tree structure to console.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
show_only_splitting_points: If True, shows only splitting point indices.
|
|
280
|
+
If False, shows all nodes with vertex indices.
|
|
281
|
+
"""
|
|
282
|
+
def _print(node, prefix: str = "", is_last: bool = True):
|
|
283
|
+
connector = "└── " if is_last else "├── "
|
|
284
|
+
if show_only_splitting_points:
|
|
285
|
+
if node.splitting_point_index is not None:
|
|
286
|
+
print(f"{prefix}{connector}[{node.splitting_point_index}] "
|
|
287
|
+
f"(vertex {node.splitting_point_vertex_index})")
|
|
288
|
+
new_prefix = prefix + (" " if is_last else "│ ")
|
|
289
|
+
child_count = len(node.children)
|
|
290
|
+
for idx, child in enumerate(node.children):
|
|
291
|
+
_print(child, new_prefix, idx == child_count - 1)
|
|
292
|
+
else:
|
|
293
|
+
if node.splitting_point_index is not None:
|
|
294
|
+
label = f"[{node.splitting_point_index}] vertices: {node.vertex_indices}"
|
|
295
|
+
else:
|
|
296
|
+
label = f"vertices: {node.vertex_indices}"
|
|
297
|
+
print(f"{prefix}{connector}{label}")
|
|
298
|
+
new_prefix = prefix + (" " if is_last else "│ ")
|
|
299
|
+
child_count = len(node.children)
|
|
300
|
+
for idx, child in enumerate(node.children):
|
|
301
|
+
_print(child, new_prefix, idx == child_count - 1)
|
|
302
|
+
|
|
303
|
+
_print(self)
|
|
304
|
+
|
|
305
|
+
def get_splitting_points(self) -> List[Tuple[int, Tuple[float, ...]]]:
|
|
306
|
+
"""Returns all splitting points currently in the tree.
|
|
307
|
+
|
|
308
|
+
Each splitting point is the center used to subdivide a simplex into
|
|
309
|
+
children.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
List of ``(split_index, coords)`` tuples for each splitting point
|
|
313
|
+
"""
|
|
314
|
+
splitting_points = []
|
|
315
|
+
for node in self._traverse_breadth_first():
|
|
316
|
+
if node.splitting_point_index is not None:
|
|
317
|
+
coords = tuple(self.registry._get_vertex(node.splitting_point_vertex_index))
|
|
318
|
+
splitting_points.append((node.splitting_point_index, coords))
|
|
319
|
+
return splitting_points
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Global registry that de-duplicates vertices shared across a simplex tree.
|
|
2
|
+
|
|
3
|
+
Every unique coordinate tuple is stored once and referenced everywhere by an
|
|
4
|
+
integer index. This keeps barycentric feature columns consistent: a given
|
|
5
|
+
column always maps to the same point in space.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import List, Tuple, Dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VertexRegistry:
|
|
13
|
+
def __init__(self, tolerance: float = 1e-10):
|
|
14
|
+
self.vertices: List[np.ndarray] = []
|
|
15
|
+
self.vertex_to_index: Dict[Tuple, int] = {}
|
|
16
|
+
self.tolerance = tolerance
|
|
17
|
+
|
|
18
|
+
def _register_vertex(self, vertex: Tuple[float, ...]) -> int:
|
|
19
|
+
vertex_tuple = tuple(float(x) for x in vertex)
|
|
20
|
+
if vertex_tuple in self.vertex_to_index:
|
|
21
|
+
return self.vertex_to_index[vertex_tuple]
|
|
22
|
+
idx = len(self.vertices)
|
|
23
|
+
self.vertices.append(np.array(vertex_tuple))
|
|
24
|
+
self.vertex_to_index[vertex_tuple] = idx
|
|
25
|
+
return idx
|
|
26
|
+
|
|
27
|
+
def register_vertices(self, vertices: List[Tuple[float, ...]]) -> List[int]:
|
|
28
|
+
"""Registers multiple vertices and returns their indices.
|
|
29
|
+
|
|
30
|
+
Existing vertices return their existing index (no duplicates created).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
vertices: List of coordinate tuples to register
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List of integer indices corresponding to each vertex
|
|
37
|
+
"""
|
|
38
|
+
return [self._register_vertex(v) for v in vertices]
|
|
39
|
+
|
|
40
|
+
def _get_vertex(self, idx: int) -> np.ndarray:
|
|
41
|
+
return self.vertices[idx]
|
|
42
|
+
|
|
43
|
+
def _get_vertices(self, indices: List[int]) -> List[np.ndarray]:
|
|
44
|
+
return [self.vertices[idx] for idx in indices]
|
|
45
|
+
|
|
46
|
+
def _get_vertex_as_tuple(self, idx: int) -> Tuple[float, ...]:
|
|
47
|
+
return tuple(float(x) for x in self.vertices[idx])
|
|
48
|
+
|
|
49
|
+
def get_vertices_as_tuples(self, indices: List[int]) -> List[Tuple[float, ...]]:
|
|
50
|
+
"""Returns coordinates as tuples for the given vertex indices.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
indices: List of vertex indices to look up
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of coordinate tuples
|
|
57
|
+
"""
|
|
58
|
+
return [self._get_vertex_as_tuple(idx) for idx in indices]
|
|
59
|
+
|
|
60
|
+
def as_matrix(self) -> np.ndarray:
|
|
61
|
+
"""Returns all registered vertices stacked into an (n_vertices, dim) array."""
|
|
62
|
+
if not self.vertices:
|
|
63
|
+
return np.empty((0, 0))
|
|
64
|
+
return np.vstack(self.vertices)
|
|
65
|
+
|
|
66
|
+
def __len__(self) -> int:
|
|
67
|
+
return len(self.vertices)
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return f"VertexRegistry(num_vertices={len(self.vertices)})"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Optional 2-D visualization helpers (requires the ``viz`` extra: matplotlib).
|
|
2
|
+
|
|
3
|
+
These only make sense for 2-dimensional data. Import lazily so the core package
|
|
4
|
+
works without matplotlib installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _require_matplotlib():
|
|
11
|
+
try:
|
|
12
|
+
import matplotlib.pyplot as plt # noqa: F401
|
|
13
|
+
import matplotlib.patches as patches # noqa: F401
|
|
14
|
+
except ImportError as exc: # pragma: no cover
|
|
15
|
+
raise ImportError(
|
|
16
|
+
"Visualization requires matplotlib. Install the extra with "
|
|
17
|
+
"`pip install simplex-tree-classifier[viz]`."
|
|
18
|
+
) from exc
|
|
19
|
+
return plt, patches
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _draw_2d_simplex(vertices, ax, color, alpha=0.3, linewidth=1, s=50, label=None):
|
|
23
|
+
import matplotlib.patches as patches
|
|
24
|
+
n = len(vertices)
|
|
25
|
+
if n == 0:
|
|
26
|
+
return
|
|
27
|
+
if n == 1:
|
|
28
|
+
x, y = vertices[0]
|
|
29
|
+
ax.scatter([x], [y], color=color, s=s * 2, label=label)
|
|
30
|
+
elif n == 2:
|
|
31
|
+
x, y = zip(*vertices)
|
|
32
|
+
ax.plot(x, y, color=color, linewidth=linewidth * 2, alpha=alpha, label=label)
|
|
33
|
+
ax.scatter(x, y, color=color, s=s, alpha=alpha)
|
|
34
|
+
elif n == 3:
|
|
35
|
+
triangle = patches.Polygon(vertices, facecolor=color, alpha=alpha,
|
|
36
|
+
edgecolor="black", linewidth=linewidth)
|
|
37
|
+
ax.add_patch(triangle)
|
|
38
|
+
x, y = zip(*vertices)
|
|
39
|
+
ax.scatter(x, y, color=color, s=s, alpha=alpha, label=label)
|
|
40
|
+
else:
|
|
41
|
+
x, y = zip(*vertices)
|
|
42
|
+
ax.scatter(x, y, color=color, s=s, alpha=alpha, label=label)
|
|
43
|
+
for i in range(n):
|
|
44
|
+
for j in range(i + 1, n):
|
|
45
|
+
ax.plot([vertices[i][0], vertices[j][0]],
|
|
46
|
+
[vertices[i][1], vertices[j][1]],
|
|
47
|
+
color=color, linewidth=linewidth, alpha=alpha)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _draw_children_recursive(node, ax, colors, depth=0):
|
|
51
|
+
for i, child in enumerate(node._get_children()):
|
|
52
|
+
color = colors[(depth + i) % len(colors)]
|
|
53
|
+
_draw_2d_simplex(child.get_vertices_as_tuples(), ax, color,
|
|
54
|
+
alpha=0.2, linewidth=1, s=30)
|
|
55
|
+
_draw_children_recursive(child, ax, colors, depth + 1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def visualize_simplex_tree(tree, data_points=None,
|
|
59
|
+
title: str = "Simplex Tree",
|
|
60
|
+
figsize: Tuple[int, int] = (10, 8)):
|
|
61
|
+
"""Plot a 2-D simplex tree and (optionally) overlaid data points."""
|
|
62
|
+
plt, _ = _require_matplotlib()
|
|
63
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
64
|
+
colors = ["red", "blue", "green", "orange", "purple", "brown",
|
|
65
|
+
"pink", "gray", "cyan", "magenta"]
|
|
66
|
+
|
|
67
|
+
_draw_2d_simplex(tree.get_vertices_as_tuples(), ax, "red", alpha=0.3,
|
|
68
|
+
linewidth=2, s=20, label="Root simplex")
|
|
69
|
+
_draw_children_recursive(tree, ax, colors, depth=0)
|
|
70
|
+
|
|
71
|
+
if data_points is not None and len(data_points) > 0:
|
|
72
|
+
xs = [p[0] for p in data_points]
|
|
73
|
+
ys = [p[1] for p in data_points]
|
|
74
|
+
ax.scatter(xs, ys, color="black", s=40, alpha=0.8,
|
|
75
|
+
edgecolors="white", linewidth=1, label="Data", zorder=10)
|
|
76
|
+
|
|
77
|
+
ax.set_xlabel("x1")
|
|
78
|
+
ax.set_ylabel("x2")
|
|
79
|
+
ax.set_title(title)
|
|
80
|
+
ax.set_aspect("equal")
|
|
81
|
+
ax.legend()
|
|
82
|
+
ax.grid(True, alpha=0.3)
|
|
83
|
+
plt.tight_layout()
|
|
84
|
+
return fig, ax
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plot_leaf_boundaries(classifier, ax=None,
|
|
88
|
+
color: str = "red", linewidth: float = 0.5):
|
|
89
|
+
"""Draw the edges of every leaf simplex of a fitted 2-D classifier."""
|
|
90
|
+
plt, _ = _require_matplotlib()
|
|
91
|
+
if classifier.dimension != 2:
|
|
92
|
+
raise ValueError("plot_leaf_boundaries only supports 2-D classifiers.")
|
|
93
|
+
if ax is None:
|
|
94
|
+
_, ax = plt.subplots(figsize=(8, 8))
|
|
95
|
+
for boundary in classifier.get_simplex_vertices():
|
|
96
|
+
if len(boundary) >= 3:
|
|
97
|
+
closed = list(boundary) + [boundary[0]]
|
|
98
|
+
xs = [p[0] for p in closed]
|
|
99
|
+
ys = [p[1] for p in closed]
|
|
100
|
+
ax.plot(xs, ys, color=color, linewidth=linewidth, alpha=0.6)
|
|
101
|
+
ax.set_aspect("equal")
|
|
102
|
+
return ax
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simplex-tree-classifier
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hierarchical simplex-tree classifier with barycentric embedding, GPU-accelerated transform, and non-convex boundary pruning.
|
|
5
|
+
Author: Yasmin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yasmin/SimplexTreeClassifier
|
|
8
|
+
Project-URL: Repository, https://github.com/yasmin/SimplexTreeClassifier
|
|
9
|
+
Keywords: machine-learning,classifier,simplex,barycentric,interpretability,surrogate-model
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.21
|
|
18
|
+
Requires-Dist: scipy>=1.7
|
|
19
|
+
Requires-Dist: scikit-learn>=1.0
|
|
20
|
+
Requires-Dist: torch>=1.12
|
|
21
|
+
Provides-Extra: viz
|
|
22
|
+
Requires-Dist: matplotlib>=3.4; extra == "viz"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# simplex-tree-classifier
|
|
28
|
+
|
|
29
|
+
A hierarchical **simplex-tree classifier**. It embeds points into the barycentric
|
|
30
|
+
coordinates of a subdivided simplex, then trains any scikit-learn estimator on
|
|
31
|
+
that sparse, geometry-aware feature space. The barycentric `transform` is
|
|
32
|
+
accelerated with **PyTorch** and runs on the GPU when one is available.
|
|
33
|
+
|
|
34
|
+
It works in two ways:
|
|
35
|
+
|
|
36
|
+
- **Dataset mode** - train directly on your data `(X, y)`.
|
|
37
|
+
- **Surrogate mode** - imitate an existing `model` (e.g. a neural network) to
|
|
38
|
+
produce an interpretable, piecewise-linear geometric surrogate.
|
|
39
|
+
|
|
40
|
+
Everything is **N-dimensional**.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install simplex-tree-classifier # core (numpy, scipy, scikit-learn, torch)
|
|
46
|
+
pip install simplex-tree-classifier[viz] # + matplotlib for 2-D plots
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
From source:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd simplex_tree_package
|
|
53
|
+
pip install -e .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## Quickstart
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
### Dataset mode
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import numpy as np
|
|
66
|
+
from simplex_tree_classifier import SimplexTreeClassifier
|
|
67
|
+
|
|
68
|
+
X = np.random.uniform(0, 1, size=(500, 4))
|
|
69
|
+
y = (X.sum(axis=1) > 2.0).astype(int)
|
|
70
|
+
|
|
71
|
+
clf = SimplexTreeClassifier(subdivision_levels=2) # default classifier: LinearSVC
|
|
72
|
+
clf.fit(X, y)
|
|
73
|
+
print(clf.predict(X[:5]))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
### Surrogate mode (imitate a model)
|
|
79
|
+
|
|
80
|
+
Pass any object with `.predict(X)` or any callable `X -> labels`, plus the input
|
|
81
|
+
dimensionality. Fill points are sampled in `[0, 1]^d` and labeled by the model.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
def black_box(X): # e.g. a wrapped PyTorch model
|
|
85
|
+
X = np.atleast_2d(X)
|
|
86
|
+
return (X[:, 0] + X[:, 1] > 1.0).astype(int)
|
|
87
|
+
|
|
88
|
+
surrogate = SimplexTreeClassifier(
|
|
89
|
+
model=black_box,
|
|
90
|
+
n_features=2,
|
|
91
|
+
subdivision_levels=4,
|
|
92
|
+
n_fill=5000,
|
|
93
|
+
)
|
|
94
|
+
surrogate.fit() # samples + labels internally
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
### Data-driven subdivision
|
|
100
|
+
|
|
101
|
+
Instead of uniform (barycentric) subdivision, split only where the classifier is
|
|
102
|
+
still wrong:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
clf = SimplexTreeClassifier(
|
|
106
|
+
subdivision_strategy="data_driven",
|
|
107
|
+
subdivision_levels=6, # here: maximum tree depth
|
|
108
|
+
max_leaves=500,
|
|
109
|
+
)
|
|
110
|
+
clf.fit(X, y)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
## API overview
|
|
116
|
+
|
|
117
|
+
Constructor: `SimplexTreeClassifier(classifier=None, model=None, n_features=None, subdivision_levels=1, subdivision_strategy="barycentric", n_fill=5000, max_leaves=None, normalize=None, margin=0.05, device=None, tolerance=1e-10, random_state=None, data_driven_max_iter=50)`
|
|
118
|
+
|
|
119
|
+
Core methods:
|
|
120
|
+
|
|
121
|
+
- `transform(X)` - sparse barycentric embedding (GPU-accelerated); rows sum to 1.
|
|
122
|
+
- `fit(X=None, y=None)` - trains the classifier. In surrogate mode `X`/`y` are
|
|
123
|
+
optional. Automatically records same-side simplices afterward (linear only).
|
|
124
|
+
- `predict(X)` - predicted labels.
|
|
125
|
+
|
|
126
|
+
Geometry queries:
|
|
127
|
+
|
|
128
|
+
- `find_containing_simplex(point)` - the leaf simplex a point falls in.
|
|
129
|
+
- `find_adjacent_simplexes(simplex)` - leaves sharing a `(d-1)`-face.
|
|
130
|
+
- `get_simplex_vertices()` - vertices of every leaf simplex (renamed from
|
|
131
|
+
`get_simplex_boundaries`).
|
|
132
|
+
- `is_in_simplex(point, simplex)` - boolean containment test.
|
|
133
|
+
|
|
134
|
+
Decision-boundary analysis (linear classifiers, multiclass-aware):
|
|
135
|
+
|
|
136
|
+
- `identify_crossing_simplices()` - leaves crossed by the decision boundary.
|
|
137
|
+
- `remove_nonconvex_leaves(criterion="distance"|"convexity_sign", removal_factor=0.15, epsilon=None, keep_frac=None, min_depth=0.0, max_remove_frac=0.25, max_iter=10, refit=True)` - iteratively prune leaves whose
|
|
138
|
+
boundary bends non-convexly.
|
|
139
|
+
- `find_same_side_simplices()` - subdivisions that don't touch the boundary (also
|
|
140
|
+
run automatically at the end of `fit`, stored in `same_side_keys_`).
|
|
141
|
+
- `compute_plane_equations()` - the boundary hyperplane within each crossing leaf.
|
|
142
|
+
|
|
143
|
+
Helpers: `SimplexTree`, `Simplex`, `VertexRegistry`, `make_enclosing_simplex(d)`,
|
|
144
|
+
`get_device()`.
|
|
145
|
+
|
|
146
|
+
## GPU acceleration
|
|
147
|
+
|
|
148
|
+
`transform` stacks each leaf's inverse edge matrix into tensors and embeds an
|
|
149
|
+
entire batch of points against all leaves at once. The device is auto-detected
|
|
150
|
+
(CUDA if present); override with `device="cpu"` / `device="cuda"`. Points outside
|
|
151
|
+
every leaf (or inside a degenerate one) fall back to an exact per-point search.
|
|
152
|
+
|
|
153
|
+
## TODO
|
|
154
|
+
|
|
155
|
+
- [ ] **Overleaf cross-check.** Reconcile this package's operations against the
|
|
156
|
+
list of supported operations in the paper (Overleaf) and add anything missing.
|
|
157
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
simplex_tree_classifier/__init__.py,sha256=I7M12scTJVYBzkZfoYw4Rv_Wh4aOJvUC6GsURd66s_I,888
|
|
2
|
+
simplex_tree_classifier/backend.py,sha256=zSS6rZu-3ys5VGraLWMR03za_udmgOBkX-DFM7Yyaiw,7137
|
|
3
|
+
simplex_tree_classifier/classifier.py,sha256=tY-aWwtO_EWkCF6d3Bo3fV5_xc6L5NoTiQdEdI2X6CY,33016
|
|
4
|
+
simplex_tree_classifier/convexity.py,sha256=skg3iYo14iMo5_-6USeVUcLIhU9_bNvPRoPEcAmcuNs,8508
|
|
5
|
+
simplex_tree_classifier/plane_equation.py,sha256=JNfwlLKmYB4tZHRhs4Ly5Hyf074FT2LkBafjHtc7i1I,2481
|
|
6
|
+
simplex_tree_classifier/simplex.py,sha256=SwiM7_ay3DA5kMPKtS7UsF5m1uIkX8ca8JFa2dDg7yg,6208
|
|
7
|
+
simplex_tree_classifier/simplex_tree.py,sha256=Qc8gZUYuYnEloEiRIBpb_FZb3PYSQKjkgpVHZYw6Kto,12762
|
|
8
|
+
simplex_tree_classifier/vertex_registry.py,sha256=UzkgeHaXIm15ZCmglWsKuMKU5lpwJxKEA60ZNwDs3iU,2548
|
|
9
|
+
simplex_tree_classifier/visualization.py,sha256=NvSETvHRQb3S8MWOLyPUKMvFeTebec2TU1MazWzlIUk,4064
|
|
10
|
+
simplex_tree_classifier-0.1.0.dist-info/licenses/LICENSE,sha256=00sYuqcAxSjdor89Kd8thtcvK-xhUXf-fsBR1oTeC14,1084
|
|
11
|
+
simplex_tree_classifier-0.1.0.dist-info/METADATA,sha256=Ky0YZjSiGiD5lGPCqTpO2RwbymHcYanjWlPNeg13mB0,5227
|
|
12
|
+
simplex_tree_classifier-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
simplex_tree_classifier-0.1.0.dist-info/top_level.txt,sha256=PO4kiKAZRtkgzHgHtzufjkEL55mkhqvsTtF2hK4Wtdc,24
|
|
14
|
+
simplex_tree_classifier-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yasmin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|