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.
@@ -0,0 +1,770 @@
1
+ """The public :class:`SimplexTreeClassifier`.
2
+
3
+ Two ways to construct it:
4
+
5
+ * **Dataset mode** - pass a scikit-learn style ``classifier`` and call
6
+ ``fit(X, y)``. The classifier learns from the barycentric embedding of your
7
+ data.
8
+ * **Surrogate mode** - pass a ``model`` (any object with ``.predict`` or any
9
+ callable ``X -> labels``) plus ``n_features``. ``fit()`` samples points in the
10
+ unit hypercube ``[0, 1]^d``, labels them with the model, and learns to imitate
11
+ it - a geometric surrogate.
12
+
13
+ Subdivision of the simplex tree can be ``"barycentric"`` (uniform) or
14
+ ``"data_driven"`` (split only where the classifier still makes mistakes).
15
+
16
+ The barycentric ``transform`` is accelerated with PyTorch and runs on the GPU
17
+ when one is available.
18
+ """
19
+
20
+ from typing import Dict, List, Optional, Set, Tuple
21
+
22
+ import numpy as np
23
+ from scipy.sparse import csr_matrix
24
+ from sklearn.svm import LinearSVC
25
+
26
+ from .simplex_tree import SimplexTree, make_enclosing_simplex
27
+ from .backend import TransformBackend
28
+ from .plane_equation import PlaneEquation
29
+ from .convexity import (
30
+ check_convexity,
31
+ meeting_to_average_distance,
32
+ shared_face_length,
33
+ )
34
+
35
+
36
+ class SimplexTreeClassifier:
37
+ def __init__(self,
38
+ classifier=None,
39
+ model=None,
40
+ n_features: Optional[int] = None,
41
+ subdivision_levels: int = 1,
42
+ subdivision_strategy: str = "barycentric",
43
+ n_fill: Optional[int] = None,
44
+ max_leaves: Optional[int] = None,
45
+ normalize: Optional[bool] = None,
46
+ margin: float = 0.05,
47
+ device=None,
48
+ tolerance: float = 1e-10,
49
+ random_state: Optional[int] = None,
50
+ data_driven_max_iter: int = 50):
51
+ """Create a simplex-tree classifier.
52
+
53
+ Args:
54
+ classifier: Any sklearn-compatible estimator with ``.fit()`` /
55
+ ``.predict()``. Defaults to ``LinearSVC(C=1.0)``. This is the
56
+ estimator that learns from the barycentric features (used in both
57
+ dataset and surrogate modes).
58
+ model: If given, the classifier runs in *surrogate* mode and learns to
59
+ imitate this model. May be an object exposing ``.predict(X)`` or a
60
+ plain callable mapping ``X -> labels``. Requires ``n_features``.
61
+ n_features: Input dimensionality ``d`` (required in surrogate mode;
62
+ optional otherwise - inferred from ``X`` at ``fit`` time).
63
+ subdivision_levels: For ``"barycentric"`` strategy, the number of
64
+ uniform subdivision levels. For ``"data_driven"`` strategy, the
65
+ maximum tree depth.
66
+ subdivision_strategy: ``"barycentric"`` (uniform) or ``"data_driven"``
67
+ (split leaves that still contain misclassified points).
68
+ n_fill: Surrogate mode only - number of points sampled in
69
+ ``[0, 1]^d`` to query the model with (default ``5000``).
70
+ max_leaves: Optional cap on the number of leaves for data-driven
71
+ subdivision.
72
+ normalize: Whether to min-max normalize inputs to ``[0, 1]`` at fit
73
+ time (reused at predict time). ``None`` auto-selects: ``True`` in
74
+ dataset mode, ``False`` in surrogate mode (samples are already in
75
+ ``[0, 1]``).
76
+ margin: Relative slack of the enclosing simplex beyond ``[0, 1]^d``.
77
+ device: Torch device for the accelerated transform (auto-detected).
78
+ tolerance: Geometric tolerance for point-in-simplex tests.
79
+ random_state: Seed for surrogate fill-point sampling.
80
+ data_driven_max_iter: Safety cap on data-driven refinement passes.
81
+ """
82
+ if subdivision_strategy not in ("barycentric", "data_driven"):
83
+ raise ValueError(
84
+ "subdivision_strategy must be 'barycentric' or 'data_driven'"
85
+ )
86
+ if model is not None and n_features is None:
87
+ raise ValueError("Surrogate mode (model=...) requires n_features.")
88
+
89
+ self.classifier = classifier if classifier is not None else LinearSVC(C=1.0)
90
+ self.model = model
91
+ self.n_features = n_features
92
+ self.subdivision_levels = subdivision_levels
93
+ self.subdivision_strategy = subdivision_strategy
94
+ self.n_fill = n_fill if n_fill is not None else 5000
95
+ self.max_leaves = max_leaves
96
+ self.normalize = normalize
97
+ self.margin = margin
98
+ self.device = device
99
+ self.tolerance = tolerance
100
+ self.random_state = random_state
101
+ self.data_driven_max_iter = data_driven_max_iter
102
+
103
+ self.tree: Optional[SimplexTree] = None
104
+ self.dimension: Optional[int] = None
105
+ self.leaf_simplexes: List = []
106
+ self.all_nodes_lookup: Dict[frozenset, object] = {}
107
+ self.backend = TransformBackend(device=device, tolerance=tolerance)
108
+ self._synced = False
109
+
110
+ # Stored training state (for refit during pruning).
111
+ self._X_train_norm: Optional[np.ndarray] = None
112
+ self._y_train = None
113
+ self._min = None
114
+ self._max = None
115
+
116
+ # Result of the automatic same-side pass at the end of fit().
117
+ self.same_side_keys_: Set[frozenset] = set()
118
+
119
+ if self.n_features is not None:
120
+ self._ensure_tree(self.n_features)
121
+
122
+ # ------------------------------------------------------------------
123
+ # Tree construction / bookkeeping
124
+ # ------------------------------------------------------------------
125
+ def _mode(self) -> str:
126
+ return "surrogate" if self.model is not None else "dataset"
127
+
128
+ def _ensure_tree(self, dimension: int) -> None:
129
+ """Build the base simplex tree for the given dimension if needed."""
130
+ if self.tree is not None and self.dimension == dimension:
131
+ return
132
+ vertices = make_enclosing_simplex(dimension, margin=self.margin)
133
+ self.tree = SimplexTree(vertices, tolerance=self.tolerance)
134
+ self.dimension = dimension
135
+ if self.subdivision_strategy == "barycentric" and self.subdivision_levels > 0:
136
+ self.tree._add_barycentric_centers_recursively(self.subdivision_levels)
137
+ self._mark_dirty()
138
+ self._sync()
139
+
140
+ def _mark_dirty(self) -> None:
141
+ self._synced = False
142
+
143
+ def _build_node_lookup(self) -> None:
144
+ self.all_nodes_lookup = {}
145
+ self.leaf_simplexes = []
146
+ for node in self.tree._traverse_breadth_first():
147
+ key = frozenset(node.vertex_indices)
148
+ self.all_nodes_lookup[key] = node
149
+ if node._is_leaf():
150
+ self.leaf_simplexes.append(node)
151
+
152
+ def _sync(self) -> None:
153
+ """Rebuild the leaf lookup and the GPU backend after a tree change."""
154
+ if self.tree is None:
155
+ return
156
+ self._build_node_lookup()
157
+ self.backend.build(self.leaf_simplexes)
158
+ self._synced = True
159
+
160
+ def _ensure_synced(self) -> None:
161
+ if not self._synced:
162
+ self._sync()
163
+
164
+ # ------------------------------------------------------------------
165
+ # Normalization
166
+ # ------------------------------------------------------------------
167
+ def _normalize_enabled(self) -> bool:
168
+ if self.normalize is None:
169
+ return self.model is None
170
+ return self.normalize
171
+
172
+ def _fit_scaler(self, X: np.ndarray) -> None:
173
+ if not self._normalize_enabled():
174
+ self._min = None
175
+ self._max = None
176
+ return
177
+ self._min = np.min(X, axis=0)
178
+ self._max = np.max(X, axis=0)
179
+
180
+ def _apply_scaler(self, X: np.ndarray) -> np.ndarray:
181
+ X = np.asarray(X, dtype=float)
182
+ if X.ndim == 1:
183
+ X = X.reshape(1, -1)
184
+ if self._min is None:
185
+ return X
186
+ return (X - self._min) / (self._max - self._min + 1e-10)
187
+
188
+ # ------------------------------------------------------------------
189
+ # Transform
190
+ # ------------------------------------------------------------------
191
+ def transform(self, data_points) -> csr_matrix:
192
+ """Embed points into sparse barycentric coordinates.
193
+
194
+ For each point, finds its containing leaf simplex and writes that
195
+ simplex's barycentric coordinates into the columns for its vertices.
196
+ Points are expected to live in the tree's coordinate space (i.e. already
197
+ normalized to ``[0, 1]^d`` when normalization is enabled); ``fit`` and
198
+ ``predict`` handle normalization for you.
199
+
200
+ Args:
201
+ data_points: Array of shape ``(n_samples, d)``.
202
+
203
+ Returns:
204
+ Sparse ``csr_matrix`` of shape ``(n_samples, n_vertices)`` whose rows
205
+ each hold ``d + 1`` non-zero barycentric weights summing to 1.
206
+ """
207
+ X = np.asarray(data_points, dtype=float)
208
+ if X.ndim == 1:
209
+ X = X.reshape(1, -1)
210
+
211
+ if self.tree is None:
212
+ self._ensure_tree(X.shape[1])
213
+ self._ensure_synced()
214
+
215
+ m = X.shape[0]
216
+ n_cols = len(self.tree.registry)
217
+ d1 = self.dimension + 1
218
+
219
+ leaf_index, found, alphas = self.backend.embed(X)
220
+
221
+ row_arrays: List[np.ndarray] = []
222
+ col_arrays: List[np.ndarray] = []
223
+ val_arrays: List[np.ndarray] = []
224
+
225
+ found_idx = np.nonzero(found)[0]
226
+ if found_idx.size:
227
+ li = leaf_index[found_idx]
228
+ gidx = self.backend.leaf_vertex_indices[li] # (F, d+1)
229
+ vals = alphas[found_idx] # (F, d+1)
230
+ row_arrays.append(np.repeat(found_idx, d1))
231
+ col_arrays.append(gidx.reshape(-1))
232
+ val_arrays.append(vals.reshape(-1))
233
+
234
+ # Fallback: exact per-point search for anything the GPU pass missed
235
+ # (points outside every leaf, or inside a degenerate leaf).
236
+ for i in np.nonzero(~found)[0]:
237
+ point = tuple(X[i])
238
+ leaf = self.tree.find_containing_simplex(point)
239
+ if leaf is None:
240
+ continue
241
+ emb = leaf._embed_point(point)
242
+ if emb is None:
243
+ continue
244
+ gidx = np.asarray(leaf.vertex_indices, dtype=np.int64)
245
+ val = np.asarray(emb, dtype=float)
246
+ row_arrays.append(np.full(gidx.shape[0], i, dtype=np.int64))
247
+ col_arrays.append(gidx)
248
+ val_arrays.append(val)
249
+
250
+ if row_arrays:
251
+ rows = np.concatenate(row_arrays)
252
+ cols = np.concatenate(col_arrays)
253
+ values = np.concatenate(val_arrays)
254
+ else:
255
+ rows = np.empty(0, dtype=np.int64)
256
+ cols = np.empty(0, dtype=np.int64)
257
+ values = np.empty(0, dtype=float)
258
+
259
+ return csr_matrix((values, (rows, cols)), shape=(m, n_cols))
260
+
261
+ # ------------------------------------------------------------------
262
+ # Fit / predict
263
+ # ------------------------------------------------------------------
264
+ def _model_predict(self, X: np.ndarray) -> np.ndarray:
265
+ if hasattr(self.model, "predict"):
266
+ return np.asarray(self.model.predict(X))
267
+ if callable(self.model):
268
+ return np.asarray(self.model(X))
269
+ raise TypeError(
270
+ "model must expose a .predict(X) method or be callable X -> labels"
271
+ )
272
+
273
+ def _surrogate_training_data(self, X, y):
274
+ d = self.n_features
275
+ rng = np.random.default_rng(self.random_state)
276
+ X_fill = rng.uniform(0.0, 1.0, size=(self.n_fill, d))
277
+ if X is not None:
278
+ X_extra = np.asarray(X, dtype=float)
279
+ if X_extra.ndim == 1:
280
+ X_extra = X_extra.reshape(1, -1)
281
+ X_fill = np.vstack([X_fill, X_extra])
282
+ y_fill = self._model_predict(X_fill)
283
+ return X_fill, y_fill
284
+
285
+ def _fit_estimator(self, X_transformed, y) -> None:
286
+ if y is not None:
287
+ self.classifier.fit(X_transformed, y)
288
+ else:
289
+ self.classifier.fit(X_transformed)
290
+
291
+ def fit(self, X=None, y=None):
292
+ """Train the classifier.
293
+
294
+ Dataset mode: pass ``X`` (and ``y``). Surrogate mode: ``X``/``y`` are
295
+ optional - fill points are sampled and labeled by the model; any ``X``
296
+ given is added to the fill set.
297
+
298
+ Returns:
299
+ ``self``.
300
+ """
301
+ if self._mode() == "surrogate":
302
+ X_fit, y_fit = self._surrogate_training_data(X, y)
303
+ else:
304
+ if X is None:
305
+ raise ValueError("Dataset mode requires X (and usually y).")
306
+ X_fit = np.asarray(X, dtype=float)
307
+ if X_fit.ndim == 1:
308
+ X_fit = X_fit.reshape(1, -1)
309
+ y_fit = None if y is None else np.asarray(y)
310
+
311
+ d = X_fit.shape[1]
312
+ self._fit_scaler(X_fit)
313
+ X_norm = self._apply_scaler(X_fit)
314
+
315
+ self._X_train_norm = X_norm
316
+ self._y_train = y_fit
317
+
318
+ if self.subdivision_strategy == "data_driven":
319
+ self._fit_data_driven(X_norm, y_fit)
320
+ else:
321
+ self._ensure_tree(d)
322
+ X_transformed = self.transform(X_norm)
323
+ self._fit_estimator(X_transformed, y_fit)
324
+
325
+ self._finalize_fit()
326
+ return self
327
+
328
+ def _fit_data_driven(self, X_norm: np.ndarray, y) -> None:
329
+ d = X_norm.shape[1]
330
+ # Start from the root simplex (no uniform subdivision) and grow where
331
+ # the classifier is wrong.
332
+ vertices = make_enclosing_simplex(d, margin=self.margin)
333
+ self.tree = SimplexTree(vertices, tolerance=self.tolerance)
334
+ self.dimension = d
335
+ self._mark_dirty()
336
+ self._sync()
337
+
338
+ max_depth = self.subdivision_levels
339
+
340
+ X_transformed = self.transform(X_norm)
341
+ self._fit_estimator(X_transformed, y)
342
+
343
+ if y is None:
344
+ return # error-driven refinement needs labels
345
+
346
+ for _ in range(self.data_driven_max_iter):
347
+ preds = self.classifier.predict(self.transform(X_norm))
348
+ wrong = np.nonzero(np.asarray(preds) != np.asarray(y))[0]
349
+ if wrong.size == 0:
350
+ break
351
+ if self.max_leaves is not None and len(self.tree.get_leaves()) >= self.max_leaves:
352
+ break
353
+
354
+ leaves_to_split = {}
355
+ for i in wrong:
356
+ leaf = self.tree.find_containing_simplex(tuple(X_norm[i]))
357
+ if leaf is None or leaf.depth >= max_depth:
358
+ continue
359
+ leaves_to_split[id(leaf)] = leaf
360
+
361
+ if not leaves_to_split:
362
+ break
363
+
364
+ split_any = False
365
+ for leaf in leaves_to_split.values():
366
+ if self.max_leaves is not None and len(self.tree.get_leaves()) >= self.max_leaves:
367
+ break
368
+ try:
369
+ self.tree.subdivide_leaf(leaf)
370
+ split_any = True
371
+ except ValueError:
372
+ continue
373
+
374
+ if not split_any:
375
+ break
376
+
377
+ self._mark_dirty()
378
+ self._sync()
379
+ X_transformed = self.transform(X_norm)
380
+ self._fit_estimator(X_transformed, y)
381
+
382
+ def predict(self, X) -> np.ndarray:
383
+ """Predict class labels for input points.
384
+
385
+ Args:
386
+ X: Data points of shape ``(n_samples, d)``.
387
+
388
+ Returns:
389
+ Array of predicted labels.
390
+ """
391
+ if self.tree is None:
392
+ raise ValueError("Classifier not fitted yet. Call fit() first.")
393
+ X_norm = self._apply_scaler(np.asarray(X, dtype=float))
394
+ X_transformed = self.transform(X_norm)
395
+ return self.classifier.predict(X_transformed)
396
+
397
+ def _finalize_fit(self) -> None:
398
+ """Automatic post-fit step: record same-side simplices (linear only)."""
399
+ self._ensure_synced()
400
+ if self.is_linear_classifier:
401
+ try:
402
+ self.same_side_keys_ = self.find_same_side_simplices()
403
+ except Exception:
404
+ self.same_side_keys_ = set()
405
+ else:
406
+ self.same_side_keys_ = set()
407
+
408
+ # ------------------------------------------------------------------
409
+ # Geometry queries
410
+ # ------------------------------------------------------------------
411
+ def find_containing_simplex(self, point):
412
+ """Return the leaf simplex containing ``point`` (or ``None``).
413
+
414
+ ``point`` is interpreted in the tree's coordinate space. Underpins
415
+ ``find_adjacent_simplexes``.
416
+ """
417
+ if self.tree is None:
418
+ raise ValueError("Classifier not fitted yet. Call fit() first.")
419
+ self._ensure_synced()
420
+ return self.tree.find_containing_simplex(tuple(point))
421
+
422
+ def find_adjacent_simplexes(self, simplex) -> list:
423
+ """Return all leaf simplexes adjacent to ``simplex`` (shared ``(d-1)``-face)."""
424
+ if self.tree is None:
425
+ raise ValueError("Classifier not fitted yet. Call fit() first.")
426
+ self._ensure_synced()
427
+ return self.tree.find_adjacent_simplexes(simplex)
428
+
429
+ def get_simplex_vertices(self) -> List[List[Tuple[float, ...]]]:
430
+ """Return vertex coordinates of every leaf simplex.
431
+
432
+ (Formerly ``get_simplex_boundaries``.)
433
+ """
434
+ self._ensure_synced()
435
+ return [leaf.get_vertices_as_tuples() for leaf in self.leaf_simplexes]
436
+
437
+ def is_in_simplex(self, point, simplex) -> bool:
438
+ """Return ``True`` if ``point`` lies inside ``simplex``.
439
+
440
+ Args:
441
+ point: Coordinates in the tree's space.
442
+ simplex: A ``Simplex`` / ``SimplexTree`` node (e.g. from
443
+ ``find_containing_simplex`` or ``leaf_simplexes``).
444
+ """
445
+ return bool(simplex._point_inside_simplex(tuple(point)))
446
+
447
+ # ------------------------------------------------------------------
448
+ # Linear-classifier helpers
449
+ # ------------------------------------------------------------------
450
+ @property
451
+ def is_linear_classifier(self) -> bool:
452
+ """Whether the internal classifier exposes linear weights."""
453
+ return (hasattr(self.classifier, "coef_") and
454
+ hasattr(self.classifier, "intercept_"))
455
+
456
+ def get_weights_and_intercept(self):
457
+ """Return the fitted linear classifier's weight vector and intercept.
458
+
459
+ Raises ``AttributeError`` for non-linear classifiers.
460
+ """
461
+ return self._get_weights_and_intercept()
462
+
463
+ def _get_weights_and_intercept(self):
464
+ if not hasattr(self.classifier, "coef_"):
465
+ raise ValueError("Classifier not fitted yet. Call fit() first.")
466
+ if not self.is_linear_classifier:
467
+ raise AttributeError(
468
+ f"{type(self.classifier).__name__} has no coef_ attribute. "
469
+ "This requires a linear classifier (e.g. LinearSVC, "
470
+ "LogisticRegression, Perceptron)."
471
+ )
472
+ weights = self.classifier.coef_[0]
473
+ if hasattr(weights, "toarray"):
474
+ weights = weights.toarray().flatten()
475
+ elif hasattr(weights, "A"):
476
+ weights = np.asarray(weights).flatten()
477
+ intercept = self.classifier.intercept_[0]
478
+ return weights, intercept
479
+
480
+ def _hyperplanes(self):
481
+ """Yield ``(weights, intercept)`` for every linear hyperplane.
482
+
483
+ Binary -> one row; multiclass one-vs-rest -> one row per class.
484
+ """
485
+ coef = self.classifier.coef_
486
+ intercept = self.classifier.intercept_
487
+ if hasattr(coef, "toarray"):
488
+ coef = coef.toarray()
489
+ coef = np.asarray(coef)
490
+ intercept = np.atleast_1d(np.asarray(intercept))
491
+ for h in range(coef.shape[0]):
492
+ yield np.asarray(coef[h]).flatten(), float(intercept[h])
493
+
494
+ def _predict_at_vertices(self, simplex_node) -> np.ndarray:
495
+ n_vertices = len(self.tree.registry)
496
+ predictions = []
497
+ for vid in simplex_node.vertex_indices:
498
+ one_hot = csr_matrix(([1.0], ([0], [vid])), shape=(1, n_vertices))
499
+ predictions.append(self.classifier.predict(one_hot)[0])
500
+ return np.array(predictions)
501
+
502
+ @staticmethod
503
+ def _simplex_crosses_boundary(simplex_node, weights, intercept) -> bool:
504
+ decision_values = [weights[idx] + intercept for idx in simplex_node.vertex_indices]
505
+ has_positive = any(val > 0 for val in decision_values)
506
+ has_negative = any(val < 0 for val in decision_values)
507
+ return has_positive and has_negative
508
+
509
+ @staticmethod
510
+ def _get_simplex_class(simplex_node, weights, intercept) -> bool:
511
+ vals = [weights[i] + intercept for i in simplex_node.vertex_indices]
512
+ return any(v > 0 for v in vals) or all(v == 0 for v in vals)
513
+
514
+ def _are_siblings_same_side(self, parent_node, weights, intercept) -> bool:
515
+ child0 = parent_node.children[0]
516
+ if self._simplex_crosses_boundary(child0, weights, intercept):
517
+ return False
518
+ first_child_class = self._get_simplex_class(child0, weights, intercept)
519
+ for child in parent_node.children[1:]:
520
+ if self._simplex_crosses_boundary(child, weights, intercept):
521
+ return False
522
+ if self._get_simplex_class(child, weights, intercept) != first_child_class:
523
+ return False
524
+ return True
525
+
526
+ # ------------------------------------------------------------------
527
+ # Decision-boundary analysis
528
+ # ------------------------------------------------------------------
529
+ def identify_crossing_simplices(self) -> List[Dict]:
530
+ """Find leaf simplices that the decision boundary crosses.
531
+
532
+ Uses a weight-based test for linear classifiers and a prediction-based
533
+ test for non-linear ones.
534
+
535
+ Returns:
536
+ List of dicts with keys ``'simplex'`` and ``'vertices'`` (plus
537
+ ``'decision_values'`` when the classifier is linear).
538
+ """
539
+ self._ensure_synced()
540
+ use_linear = self.is_linear_classifier
541
+ if use_linear:
542
+ weights, intercept = self._get_weights_and_intercept()
543
+
544
+ crossing_simplices = []
545
+ for leaf in self.leaf_simplexes:
546
+ if use_linear:
547
+ crosses = self._simplex_crosses_boundary(leaf, weights, intercept)
548
+ else:
549
+ preds = self._predict_at_vertices(leaf)
550
+ crosses = not np.all(preds == preds[0])
551
+
552
+ if crosses:
553
+ info = {"simplex": leaf, "vertices": leaf.get_vertices_as_tuples()}
554
+ if use_linear:
555
+ info["decision_values"] = np.array(
556
+ [weights[idx] for idx in leaf.vertex_indices]
557
+ )
558
+ crossing_simplices.append(info)
559
+
560
+ return crossing_simplices
561
+
562
+ def find_same_side_simplices(self) -> Set[frozenset]:
563
+ """Find leaf simplices whose siblings all lie on the same boundary side.
564
+
565
+ These subdivisions do not contribute to the decision boundary and could
566
+ be merged back into their parent. Called automatically at the end of
567
+ ``fit`` for linear classifiers (stored in ``same_side_keys_``); also
568
+ available directly.
569
+ """
570
+ weights, intercept = self._get_weights_and_intercept()
571
+ same_side_keys: Set[frozenset] = set()
572
+ leaf_parents = set()
573
+ for leaf in self.leaf_simplexes:
574
+ if leaf.parent:
575
+ leaf_parents.add(leaf.parent)
576
+
577
+ for parent in leaf_parents:
578
+ if not all(child._is_leaf() for child in parent.children):
579
+ continue
580
+ if self._are_siblings_same_side(parent, weights, intercept):
581
+ for child in parent.children:
582
+ same_side_keys.add(frozenset(child.vertex_indices))
583
+ return same_side_keys
584
+
585
+ def _sampling_epsilon(self, epsilon=None) -> float:
586
+ """Boundary test-point placement fraction, defaulting to ``1 / (d + 1)``."""
587
+ if epsilon is not None:
588
+ return epsilon
589
+ return 1.0 / (self.tree.dimension + 1)
590
+
591
+ def find_nonconvex_leaves(self, criterion: str = "distance",
592
+ removal_factor: float = 0.15,
593
+ epsilon: Optional[float] = None,
594
+ keep_frac: Optional[float] = None,
595
+ min_depth: float = 0.0) -> Dict[frozenset, float]:
596
+ """Flag leaf simplices whose decision boundary bends non-convexly.
597
+
598
+ Multiclass-aware: every one-vs-rest hyperplane is checked and a leaf is
599
+ flagged if any hyperplane bends the wrong way at it.
600
+
601
+ Args:
602
+ criterion: ``"distance"`` compares the meeting->average distance to
603
+ ``removal_factor * shared_face_length``. ``"convexity_sign"`` uses
604
+ the geometric side test in ``check_convexity``.
605
+ removal_factor: (distance criterion) fraction of the shared-face
606
+ length above which a bend counts as non-convex.
607
+ epsilon: Boundary test-point placement fraction (default
608
+ ``1 / (d + 1)``).
609
+ keep_frac: Keep only the deepest ``keep_frac`` fraction of flagged
610
+ leaves (data-dependent quantile gate).
611
+ min_depth: Absolute floor on the normalized bend depth.
612
+
613
+ Returns:
614
+ Dict mapping each flagged leaf's vertex key (frozenset) to its bend
615
+ depth (normalized by the shared-face length).
616
+ """
617
+ if criterion not in ("distance", "convexity_sign"):
618
+ raise ValueError("criterion must be 'distance' or 'convexity_sign'")
619
+ self._ensure_synced()
620
+ eps = self._sampling_epsilon(epsilon)
621
+
622
+ crossing = self.identify_crossing_simplices()
623
+ crossing_ids = {id(info["simplex"]) for info in crossing}
624
+
625
+ depths: Dict[frozenset, float] = {}
626
+ for weights, intercept in self._hyperplanes():
627
+ for info in crossing:
628
+ leaf = info["simplex"]
629
+ for nb in self.find_adjacent_simplexes(leaf):
630
+ if id(nb) not in crossing_ids:
631
+ continue
632
+ face_len = shared_face_length(leaf, nb)
633
+ if not face_len:
634
+ continue
635
+
636
+ if criterion == "convexity_sign":
637
+ is_convex, avg_pt, meeting, _, _ = check_convexity(
638
+ leaf, nb, weights, intercept,
639
+ global_tree=self.tree, epsilon=eps)
640
+ if is_convex or avg_pt is None or meeting is None:
641
+ continue
642
+ dist = float(np.linalg.norm(np.asarray(avg_pt) - np.asarray(meeting)))
643
+ else:
644
+ dist, _, meeting, _, _ = meeting_to_average_distance(
645
+ leaf, nb, weights, intercept, eps)
646
+ if dist is None or meeting is None:
647
+ continue
648
+ if dist <= removal_factor * face_len:
649
+ continue
650
+
651
+ depth = dist / face_len
652
+ for s in (leaf, nb):
653
+ key = frozenset(s.vertex_indices)
654
+ if depth > depths.get(key, 0.0):
655
+ depths[key] = depth
656
+
657
+ return self._gate_depths(depths, keep_frac=keep_frac, min_depth=min_depth)
658
+
659
+ @staticmethod
660
+ def _gate_depths(depths: Dict[frozenset, float], keep_frac: Optional[float],
661
+ min_depth: float) -> Dict[frozenset, float]:
662
+ if not depths:
663
+ return dict(depths)
664
+ thr = float(min_depth)
665
+ if keep_frac is not None and 0.0 < keep_frac < 1.0:
666
+ thr = max(thr, float(np.quantile(list(depths.values()), 1.0 - keep_frac)))
667
+ if thr > 0.0:
668
+ return {k: v for k, v in depths.items() if v >= thr}
669
+ return dict(depths)
670
+
671
+ def _remove_nonconvex_once(self, criterion, removal_factor, epsilon,
672
+ keep_frac, min_depth, remove_budget) -> int:
673
+ depths = self.find_nonconvex_leaves(
674
+ criterion=criterion, removal_factor=removal_factor, epsilon=epsilon,
675
+ keep_frac=keep_frac, min_depth=min_depth)
676
+ if not depths:
677
+ return 0
678
+ start = len(self.tree.get_leaves())
679
+ for key, _ in sorted(depths.items(), key=lambda kv: -kv[1]):
680
+ if remove_budget is not None and start - len(self.tree.get_leaves()) >= remove_budget:
681
+ break
682
+ self.tree.remove_by_leaf_key(key)
683
+ return start - len(self.tree.get_leaves())
684
+
685
+ def remove_nonconvex_leaves(self, criterion: str = "distance",
686
+ removal_factor: float = 0.15,
687
+ epsilon: Optional[float] = None,
688
+ keep_frac: Optional[float] = None,
689
+ min_depth: float = 0.0,
690
+ max_remove_frac: float = 0.25,
691
+ max_iter: int = 10,
692
+ refit: bool = True) -> int:
693
+ """Iteratively prune non-convex leaves, deepest bend first.
694
+
695
+ Removes at most ``max_remove_frac`` of the initial leaf count in total,
696
+ over up to ``max_iter`` passes, refitting the classifier between passes
697
+ (using the stored training data). Requires a linear classifier.
698
+
699
+ Args:
700
+ criterion: See ``find_nonconvex_leaves``.
701
+ removal_factor: (distance criterion) shared-face-length fraction.
702
+ epsilon: Boundary test-point placement fraction.
703
+ keep_frac: Deepest-fraction gate on flagged leaves.
704
+ min_depth: Absolute floor on normalized bend depth.
705
+ max_remove_frac: Total removal budget as a fraction of initial leaves.
706
+ max_iter: Maximum number of removal passes.
707
+ refit: Whether to refit the classifier after each pass.
708
+
709
+ Returns:
710
+ Total number of leaves removed.
711
+ """
712
+ if not self.is_linear_classifier:
713
+ raise AttributeError(
714
+ f"{type(self.classifier).__name__} is not a linear classifier; "
715
+ "non-convex removal requires linear weights."
716
+ )
717
+ self._ensure_synced()
718
+ initial = len(self.tree.get_leaves())
719
+ budget = int(initial * max_remove_frac)
720
+ if budget < 1:
721
+ return 0
722
+
723
+ total = 0
724
+ for _ in range(max_iter):
725
+ remaining = budget - total
726
+ if remaining <= 0:
727
+ break
728
+ removed = self._remove_nonconvex_once(
729
+ criterion, removal_factor, epsilon, keep_frac, min_depth, remaining)
730
+ total += removed
731
+ if removed == 0:
732
+ break
733
+ self._mark_dirty()
734
+ self._sync()
735
+ if refit and self._X_train_norm is not None and self._y_train is not None:
736
+ X_transformed = self.transform(self._X_train_norm)
737
+ self._fit_estimator(X_transformed, self._y_train)
738
+
739
+ self._finalize_fit()
740
+ return total
741
+
742
+ # ------------------------------------------------------------------
743
+ # Misc
744
+ # ------------------------------------------------------------------
745
+ def compute_plane_equations(self) -> List[Dict]:
746
+ """Compute the boundary hyperplane within each crossing simplex (linear).
747
+
748
+ Returns:
749
+ List of dicts with keys ``'simplex'``, ``'vertices'``,
750
+ ``'coefficients'`` and ``'cartesian_form'``.
751
+ """
752
+ weights, _ = self._get_weights_and_intercept()
753
+ plane_equations = []
754
+ for info in self.identify_crossing_simplices():
755
+ simplex = info["simplex"]
756
+ plane_eq = PlaneEquation(simplex)
757
+ coefficients = plane_eq.compute_plane_from_weights(weights)
758
+ plane_equations.append({
759
+ "simplex": simplex,
760
+ "vertices": info["vertices"],
761
+ "coefficients": coefficients,
762
+ "cartesian_form": plane_eq.get_cartesian_form(),
763
+ })
764
+ return plane_equations
765
+
766
+ def __repr__(self):
767
+ n_leaves = len(self.leaf_simplexes) if self.tree is not None else 0
768
+ return (f"SimplexTreeClassifier(mode={self._mode()}, "
769
+ f"dimension={self.dimension}, strategy={self.subdivision_strategy}, "
770
+ f"leaves={n_leaves}, device={self.backend.device})")