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,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})")
|