simplex-tree-classifier 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0/src/simplex_tree_classifier.egg-info → simplex_tree_classifier-0.2.0}/PKG-INFO +1 -1
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/pyproject.toml +1 -1
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/__init__.py +1 -1
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/classifier.py +138 -6
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0/src/simplex_tree_classifier.egg-info}/PKG-INFO +1 -1
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier.egg-info/SOURCES.txt +1 -2
- simplex_tree_classifier-0.1.0/tests/test_api.py +0 -249
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/LICENSE +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/README.md +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/setup.cfg +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/backend.py +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/convexity.py +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/plane_equation.py +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/simplex.py +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/simplex_tree.py +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/vertex_registry.py +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier/visualization.py +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier.egg-info/dependency_links.txt +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier.egg-info/requires.txt +0 -0
- {simplex_tree_classifier-0.1.0 → simplex_tree_classifier-0.2.0}/src/simplex_tree_classifier.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simplex-tree-classifier"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Hierarchical simplex-tree classifier with barycentric embedding, GPU-accelerated transform, and non-convex boundary pruning."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -34,6 +34,12 @@ from .convexity import (
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class SimplexTreeClassifier:
|
|
37
|
+
# Class-level defaults so instances unpickled from an older version (whose
|
|
38
|
+
# __dict__ predates these attributes) resolve them to None instead of
|
|
39
|
+
# raising AttributeError on access.
|
|
40
|
+
_X_fit: Optional[np.ndarray] = None
|
|
41
|
+
_y_fit = None
|
|
42
|
+
|
|
37
43
|
def __init__(self,
|
|
38
44
|
classifier=None,
|
|
39
45
|
model=None,
|
|
@@ -113,6 +119,12 @@ class SimplexTreeClassifier:
|
|
|
113
119
|
self._min = None
|
|
114
120
|
self._max = None
|
|
115
121
|
|
|
122
|
+
# The raw (pre-normalization) data actually used to fit. In surrogate
|
|
123
|
+
# mode this is the sampled fill set (plus any points passed to fit),
|
|
124
|
+
# each labeled by the imitated model. Exposed via ``training_data_``.
|
|
125
|
+
self._X_fit: Optional[np.ndarray] = None
|
|
126
|
+
self._y_fit = None
|
|
127
|
+
|
|
116
128
|
# Result of the automatic same-side pass at the end of fit().
|
|
117
129
|
self.same_side_keys_: Set[frozenset] = set()
|
|
118
130
|
|
|
@@ -185,10 +197,30 @@ class SimplexTreeClassifier:
|
|
|
185
197
|
return X
|
|
186
198
|
return (X - self._min) / (self._max - self._min + 1e-10)
|
|
187
199
|
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _ensure_in_unit_cube(X: np.ndarray, tol: float = 1e-6) -> None:
|
|
202
|
+
"""Validate that inputs are normalized to ``[0, 1]`` per feature.
|
|
203
|
+
|
|
204
|
+
Raises ``ValueError`` when normalization is disabled but the data lies
|
|
205
|
+
outside the unit cube (so the caller either scales the data or passes
|
|
206
|
+
``normalize=True``).
|
|
207
|
+
"""
|
|
208
|
+
if X.size == 0:
|
|
209
|
+
return
|
|
210
|
+
lo = float(np.min(X))
|
|
211
|
+
hi = float(np.max(X))
|
|
212
|
+
if lo < -tol or hi > 1.0 + tol:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"Input features must be normalized to [0, 1] when normalize=False "
|
|
215
|
+
f"(got value range [{lo:.4g}, {hi:.4g}]). Either min-max scale your "
|
|
216
|
+
f"data to [0, 1] first, or construct with normalize=True to let the "
|
|
217
|
+
f"classifier scale it for you."
|
|
218
|
+
)
|
|
219
|
+
|
|
188
220
|
# ------------------------------------------------------------------
|
|
189
221
|
# Transform
|
|
190
222
|
# ------------------------------------------------------------------
|
|
191
|
-
def transform(self, data_points) -> csr_matrix:
|
|
223
|
+
def transform(self, data_points) -> csr_matrix: # TODO: use pytorch insead of matrix_csr
|
|
192
224
|
"""Embed points into sparse barycentric coordinates.
|
|
193
225
|
|
|
194
226
|
For each point, finds its containing leaf simplex and writes that
|
|
@@ -295,6 +327,11 @@ class SimplexTreeClassifier:
|
|
|
295
327
|
optional - fill points are sampled and labeled by the model; any ``X``
|
|
296
328
|
given is added to the fill set.
|
|
297
329
|
|
|
330
|
+
For linear classifiers, redundant same-side subdivisions are merged back
|
|
331
|
+
automatically at the end (see ``remove_same_side_leaves``): this shrinks
|
|
332
|
+
the tree without moving the decision boundary. Non-convex pruning stays a
|
|
333
|
+
separate, explicit ``remove_nonconvex_leaves`` call.
|
|
334
|
+
|
|
298
335
|
Returns:
|
|
299
336
|
``self``.
|
|
300
337
|
"""
|
|
@@ -308,6 +345,15 @@ class SimplexTreeClassifier:
|
|
|
308
345
|
X_fit = X_fit.reshape(1, -1)
|
|
309
346
|
y_fit = None if y is None else np.asarray(y)
|
|
310
347
|
|
|
348
|
+
# When normalization is off, the data must already live in [0, 1]^d,
|
|
349
|
+
# otherwise points fall outside the enclosing simplex and embed to
|
|
350
|
+
# nothing. Fail loudly instead of silently producing empty rows.
|
|
351
|
+
if not self._normalize_enabled():
|
|
352
|
+
self._ensure_in_unit_cube(X_fit)
|
|
353
|
+
|
|
354
|
+
self._X_fit = X_fit
|
|
355
|
+
self._y_fit = y_fit
|
|
356
|
+
|
|
311
357
|
d = X_fit.shape[1]
|
|
312
358
|
self._fit_scaler(X_fit)
|
|
313
359
|
X_norm = self._apply_scaler(X_fit)
|
|
@@ -322,9 +368,37 @@ class SimplexTreeClassifier:
|
|
|
322
368
|
X_transformed = self.transform(X_norm)
|
|
323
369
|
self._fit_estimator(X_transformed, y_fit)
|
|
324
370
|
|
|
325
|
-
|
|
371
|
+
# Merge back redundant same-side subdivisions (linear only): splits the
|
|
372
|
+
# boundary never crosses and whose leaves are all one class carry no
|
|
373
|
+
# boundary information, so collapsing them removes leaves without moving
|
|
374
|
+
# the decision surface. remove_same_side_leaves() refits and records the
|
|
375
|
+
# final (empty on convergence) same_side_keys_ itself. For non-linear
|
|
376
|
+
# classifiers there is nothing to merge, so we just record the state.
|
|
377
|
+
if self.is_linear_classifier:
|
|
378
|
+
self.remove_same_side_leaves()
|
|
379
|
+
else:
|
|
380
|
+
self._finalize_fit()
|
|
326
381
|
return self
|
|
327
382
|
|
|
383
|
+
@property
|
|
384
|
+
def training_data_(self):
|
|
385
|
+
"""The ``(X, y)`` actually used to fit the classifier.
|
|
386
|
+
|
|
387
|
+
In surrogate mode this is the sampled fill set in ``[0, 1]^d`` (plus any
|
|
388
|
+
points passed to ``fit``), each labeled by the imitated model - i.e. the
|
|
389
|
+
surrogate training set the package built for you. In dataset mode it is
|
|
390
|
+
the raw ``X`` (and ``y``) you passed. Raises ``AttributeError`` if the
|
|
391
|
+
classifier has not been fitted yet.
|
|
392
|
+
"""
|
|
393
|
+
if self._X_fit is None:
|
|
394
|
+
raise AttributeError(
|
|
395
|
+
"training_data_ is unavailable: the classifier was not fitted, or "
|
|
396
|
+
"it was built by an older version of the package (e.g. a stale "
|
|
397
|
+
"Jupyter kernel or an old cached pickle). Restart the kernel so the "
|
|
398
|
+
"updated package is re-imported, delete the cached model, then refit."
|
|
399
|
+
)
|
|
400
|
+
return self._X_fit, self._y_fit
|
|
401
|
+
|
|
328
402
|
def _fit_data_driven(self, X_norm: np.ndarray, y) -> None:
|
|
329
403
|
d = X_norm.shape[1]
|
|
330
404
|
# Start from the root simplex (no uniform subdivision) and grow where
|
|
@@ -562,10 +636,10 @@ class SimplexTreeClassifier:
|
|
|
562
636
|
def find_same_side_simplices(self) -> Set[frozenset]:
|
|
563
637
|
"""Find leaf simplices whose siblings all lie on the same boundary side.
|
|
564
638
|
|
|
565
|
-
These subdivisions do not contribute to the decision boundary and
|
|
566
|
-
|
|
567
|
-
``fit`` for linear classifiers (
|
|
568
|
-
available directly.
|
|
639
|
+
These subdivisions do not contribute to the decision boundary and can be
|
|
640
|
+
merged back into their parent. They are detected *and* merged away
|
|
641
|
+
automatically at the end of ``fit`` for linear classifiers (see
|
|
642
|
+
``remove_same_side_leaves``); this method is also available directly.
|
|
569
643
|
"""
|
|
570
644
|
weights, intercept = self._get_weights_and_intercept()
|
|
571
645
|
same_side_keys: Set[frozenset] = set()
|
|
@@ -582,6 +656,64 @@ class SimplexTreeClassifier:
|
|
|
582
656
|
same_side_keys.add(frozenset(child.vertex_indices))
|
|
583
657
|
return same_side_keys
|
|
584
658
|
|
|
659
|
+
def remove_same_side_leaves(self, max_iter: int = 50, refit: bool = True) -> int:
|
|
660
|
+
"""Merge back redundant same-side subdivisions (linear classifiers only).
|
|
661
|
+
|
|
662
|
+
A parent split is *same-side* when the decision boundary crosses none of
|
|
663
|
+
its (leaf) children and they all fall on the same side of it. Such a
|
|
664
|
+
split adds no information about the boundary, so collapsing it removes
|
|
665
|
+
leaves without moving the decision surface. Detection is repeated,
|
|
666
|
+
refitting the classifier between passes, until no same-side splits
|
|
667
|
+
remain (a new pass can expose parents that only became "all-leaf" after
|
|
668
|
+
an earlier collapse). Called automatically at the end of ``fit``.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
max_iter: Safety cap on the number of detect/collapse passes.
|
|
672
|
+
refit: Whether to refit the classifier on the stored training data
|
|
673
|
+
after each collapse pass.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Total number of leaves removed (0 for non-linear classifiers).
|
|
677
|
+
"""
|
|
678
|
+
if not self.is_linear_classifier:
|
|
679
|
+
self.same_side_keys_ = set()
|
|
680
|
+
return 0
|
|
681
|
+
self._ensure_synced()
|
|
682
|
+
total = 0
|
|
683
|
+
try:
|
|
684
|
+
keys = self.find_same_side_simplices()
|
|
685
|
+
except Exception:
|
|
686
|
+
self.same_side_keys_ = set()
|
|
687
|
+
return 0
|
|
688
|
+
for _ in range(max_iter):
|
|
689
|
+
if not keys:
|
|
690
|
+
break
|
|
691
|
+
start = len(self.tree.get_leaves())
|
|
692
|
+
for key in keys:
|
|
693
|
+
# Collapsing one child un-splits the whole parent; the remaining
|
|
694
|
+
# sibling keys for that parent then no-op harmlessly.
|
|
695
|
+
self.tree.remove_by_leaf_key(key)
|
|
696
|
+
removed = start - len(self.tree.get_leaves())
|
|
697
|
+
if removed == 0:
|
|
698
|
+
break
|
|
699
|
+
total += removed
|
|
700
|
+
self._mark_dirty()
|
|
701
|
+
self._sync()
|
|
702
|
+
if refit and self._X_train_norm is not None and self._y_train is not None:
|
|
703
|
+
X_transformed = self.transform(self._X_train_norm)
|
|
704
|
+
self._fit_estimator(X_transformed, self._y_train)
|
|
705
|
+
# Re-detect on the new (pruned + refit) tree; this doubles as both the
|
|
706
|
+
# next pass's work-list and the final same-side record, so we never
|
|
707
|
+
# recompute find_same_side_simplices() redundantly afterwards.
|
|
708
|
+
try:
|
|
709
|
+
keys = self.find_same_side_simplices()
|
|
710
|
+
except Exception:
|
|
711
|
+
keys = set()
|
|
712
|
+
break
|
|
713
|
+
# keys already reflects the current tree/weights (empty on convergence).
|
|
714
|
+
self.same_side_keys_ = keys
|
|
715
|
+
return total
|
|
716
|
+
|
|
585
717
|
def _sampling_epsilon(self, epsilon=None) -> float:
|
|
586
718
|
"""Boundary test-point placement fraction, defaulting to ``1 / (d + 1)``."""
|
|
587
719
|
if epsilon is not None:
|
|
@@ -14,5 +14,4 @@ src/simplex_tree_classifier.egg-info/PKG-INFO
|
|
|
14
14
|
src/simplex_tree_classifier.egg-info/SOURCES.txt
|
|
15
15
|
src/simplex_tree_classifier.egg-info/dependency_links.txt
|
|
16
16
|
src/simplex_tree_classifier.egg-info/requires.txt
|
|
17
|
-
src/simplex_tree_classifier.egg-info/top_level.txt
|
|
18
|
-
tests/test_api.py
|
|
17
|
+
src/simplex_tree_classifier.egg-info/top_level.txt
|
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
"""Smoke tests for the public SimplexTreeClassifier API.
|
|
2
|
-
|
|
3
|
-
Run with: pytest simplex_tree_package/tests
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import numpy as np
|
|
7
|
-
import pytest
|
|
8
|
-
from scipy.sparse import issparse
|
|
9
|
-
from sklearn.svm import LinearSVC, SVC
|
|
10
|
-
|
|
11
|
-
from simplex_tree_classifier import (
|
|
12
|
-
SimplexTreeClassifier,
|
|
13
|
-
SimplexTree,
|
|
14
|
-
Simplex,
|
|
15
|
-
make_enclosing_simplex,
|
|
16
|
-
get_device,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _toy_2d(n=200, seed=0):
|
|
21
|
-
rng = np.random.default_rng(seed)
|
|
22
|
-
X = rng.uniform(0, 1, size=(n, 2))
|
|
23
|
-
y = (X[:, 0] + X[:, 1] > 1.0).astype(int)
|
|
24
|
-
return X, y
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _toy_nd(n=300, d=5, seed=1):
|
|
28
|
-
rng = np.random.default_rng(seed)
|
|
29
|
-
X = rng.uniform(0, 1, size=(n, d))
|
|
30
|
-
y = (X.sum(axis=1) > d / 2.0).astype(int)
|
|
31
|
-
return X, y
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
# Geometry primitives
|
|
36
|
-
# ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
def test_make_enclosing_simplex_2d_matches_classic_triangle():
|
|
39
|
-
verts = make_enclosing_simplex(2, margin=0.0)
|
|
40
|
-
assert verts == [(0.0, 0.0), (2.0, 0.0), (0.0, 2.0)]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_make_enclosing_simplex_contains_unit_cube_corner():
|
|
44
|
-
d = 4
|
|
45
|
-
verts = make_enclosing_simplex(d) # default margin
|
|
46
|
-
tree = SimplexTree(verts)
|
|
47
|
-
# Farthest cube corner (1,1,1,1) must be strictly inside.
|
|
48
|
-
assert tree._point_inside_simplex(tuple([1.0] * d))
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_get_device_returns_torch_device():
|
|
52
|
-
dev = get_device()
|
|
53
|
-
assert dev.type in ("cpu", "cuda")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# ---------------------------------------------------------------------------
|
|
57
|
-
# Constructor modes
|
|
58
|
-
# ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
def test_constructor_dataset_mode_defaults_to_linear_svc():
|
|
61
|
-
clf = SimplexTreeClassifier(subdivision_levels=2)
|
|
62
|
-
assert isinstance(clf.classifier, LinearSVC)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_constructor_surrogate_requires_n_features():
|
|
66
|
-
with pytest.raises(ValueError):
|
|
67
|
-
SimplexTreeClassifier(model=lambda X: np.zeros(len(X)))
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
# ---------------------------------------------------------------------------
|
|
71
|
-
# transform / fit / predict
|
|
72
|
-
# ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
def test_transform_rows_sum_to_one_2d():
|
|
75
|
-
X, _ = _toy_2d()
|
|
76
|
-
clf = SimplexTreeClassifier(subdivision_levels=2)
|
|
77
|
-
T = clf.transform(X)
|
|
78
|
-
assert issparse(T)
|
|
79
|
-
assert T.shape[0] == len(X)
|
|
80
|
-
row_sums = np.asarray(T.sum(axis=1)).ravel()
|
|
81
|
-
assert np.allclose(row_sums, 1.0, atol=1e-6)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_fit_predict_2d_reasonable_accuracy():
|
|
85
|
-
X, y = _toy_2d()
|
|
86
|
-
clf = SimplexTreeClassifier(subdivision_levels=3)
|
|
87
|
-
clf.fit(X, y)
|
|
88
|
-
preds = clf.predict(X)
|
|
89
|
-
assert preds.shape == y.shape
|
|
90
|
-
assert (preds == y).mean() > 0.85
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def test_fit_predict_nd():
|
|
94
|
-
X, y = _toy_nd(d=5)
|
|
95
|
-
clf = SimplexTreeClassifier(subdivision_levels=2)
|
|
96
|
-
clf.fit(X, y)
|
|
97
|
-
preds = clf.predict(X)
|
|
98
|
-
assert preds.shape == y.shape
|
|
99
|
-
assert (preds == y).mean() > 0.7
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def test_transform_matches_cpu_reference():
|
|
103
|
-
"""GPU/backend embedding must agree with the exact per-simplex embedding."""
|
|
104
|
-
X, _ = _toy_2d(n=50)
|
|
105
|
-
clf = SimplexTreeClassifier(subdivision_levels=2)
|
|
106
|
-
clf._ensure_tree(2)
|
|
107
|
-
T = clf.transform(X).toarray()
|
|
108
|
-
for i, point in enumerate(X):
|
|
109
|
-
leaf = clf.tree.find_containing_simplex(tuple(point))
|
|
110
|
-
emb = leaf._embed_point(tuple(point))
|
|
111
|
-
expected = np.zeros(len(clf.tree.registry))
|
|
112
|
-
for k, vid in enumerate(leaf.vertex_indices):
|
|
113
|
-
expected[vid] = emb[k]
|
|
114
|
-
assert np.allclose(T[i], expected, atol=1e-6)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
# ---------------------------------------------------------------------------
|
|
118
|
-
# Data-driven subdivision
|
|
119
|
-
# ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
def test_data_driven_subdivision_grows_tree():
|
|
122
|
-
X, y = _toy_2d(n=300)
|
|
123
|
-
clf = SimplexTreeClassifier(subdivision_levels=6,
|
|
124
|
-
subdivision_strategy="data_driven",
|
|
125
|
-
max_leaves=200)
|
|
126
|
-
clf.fit(X, y)
|
|
127
|
-
assert len(clf.leaf_simplexes) > 1
|
|
128
|
-
assert (clf.predict(X) == y).mean() > 0.85
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
# ---------------------------------------------------------------------------
|
|
132
|
-
# Surrogate mode
|
|
133
|
-
# ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
def test_surrogate_mode_imitates_callable_model():
|
|
136
|
-
def model(X):
|
|
137
|
-
X = np.atleast_2d(X)
|
|
138
|
-
return (X[:, 0] + X[:, 1] > 1.0).astype(int)
|
|
139
|
-
|
|
140
|
-
clf = SimplexTreeClassifier(model=model, n_features=2,
|
|
141
|
-
subdivision_levels=3, n_fill=1500,
|
|
142
|
-
random_state=0)
|
|
143
|
-
clf.fit()
|
|
144
|
-
grid = np.random.default_rng(2).uniform(0, 1, size=(200, 2))
|
|
145
|
-
agreement = (clf.predict(grid) == model(grid)).mean()
|
|
146
|
-
assert agreement > 0.85
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def test_surrogate_mode_accepts_predict_object():
|
|
150
|
-
class Model:
|
|
151
|
-
def predict(self, X):
|
|
152
|
-
X = np.atleast_2d(X)
|
|
153
|
-
return (X[:, 0] > 0.5).astype(int)
|
|
154
|
-
|
|
155
|
-
clf = SimplexTreeClassifier(model=Model(), n_features=2,
|
|
156
|
-
subdivision_levels=3, random_state=0)
|
|
157
|
-
clf.fit()
|
|
158
|
-
assert clf.predict(np.array([[0.9, 0.1], [0.1, 0.9]])).shape == (2,)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# ---------------------------------------------------------------------------
|
|
162
|
-
# Geometry queries / renamed & new methods
|
|
163
|
-
# ---------------------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
def test_get_simplex_vertices_returns_leaf_vertices():
|
|
166
|
-
clf = SimplexTreeClassifier(subdivision_levels=2)
|
|
167
|
-
clf._ensure_tree(2)
|
|
168
|
-
verts = clf.get_simplex_vertices()
|
|
169
|
-
assert len(verts) == len(clf.leaf_simplexes)
|
|
170
|
-
assert all(len(v) == 3 for v in verts) # 2-D leaves are triangles
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def test_find_containing_simplex_and_is_in_simplex():
|
|
174
|
-
clf = SimplexTreeClassifier(subdivision_levels=2)
|
|
175
|
-
clf._ensure_tree(2)
|
|
176
|
-
point = (0.3, 0.3)
|
|
177
|
-
leaf = clf.find_containing_simplex(point)
|
|
178
|
-
assert leaf is not None
|
|
179
|
-
assert clf.is_in_simplex(point, leaf) is True
|
|
180
|
-
# A point far outside the leaf should not be contained.
|
|
181
|
-
far = tuple(np.array(leaf.get_vertices_as_tuples()).mean(axis=0) + 100.0)
|
|
182
|
-
assert clf.is_in_simplex(far, leaf) is False
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def test_find_adjacent_simplexes_share_a_face():
|
|
186
|
-
clf = SimplexTreeClassifier(subdivision_levels=2)
|
|
187
|
-
clf._ensure_tree(2)
|
|
188
|
-
leaf = clf.leaf_simplexes[0]
|
|
189
|
-
neighbors = clf.find_adjacent_simplexes(leaf)
|
|
190
|
-
for nb in neighbors:
|
|
191
|
-
shared = set(leaf.vertex_indices).intersection(nb.vertex_indices)
|
|
192
|
-
assert len(shared) >= leaf.dimension
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# ---------------------------------------------------------------------------
|
|
196
|
-
# Decision-boundary analysis
|
|
197
|
-
# ---------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
def test_identify_crossing_simplices_linear():
|
|
200
|
-
X, y = _toy_2d()
|
|
201
|
-
clf = SimplexTreeClassifier(subdivision_levels=3)
|
|
202
|
-
clf.fit(X, y)
|
|
203
|
-
crossing = clf.identify_crossing_simplices()
|
|
204
|
-
assert isinstance(crossing, list)
|
|
205
|
-
assert len(crossing) > 0
|
|
206
|
-
assert "simplex" in crossing[0] and "vertices" in crossing[0]
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def test_identify_crossing_simplices_nonlinear_fallback():
|
|
210
|
-
X, y = _toy_2d()
|
|
211
|
-
clf = SimplexTreeClassifier(classifier=SVC(kernel="rbf", C=10),
|
|
212
|
-
subdivision_levels=3)
|
|
213
|
-
clf.fit(X, y)
|
|
214
|
-
crossing = clf.identify_crossing_simplices()
|
|
215
|
-
assert isinstance(crossing, list)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def test_same_side_computed_automatically_after_fit():
|
|
219
|
-
X, y = _toy_2d()
|
|
220
|
-
clf = SimplexTreeClassifier(subdivision_levels=3)
|
|
221
|
-
clf.fit(X, y)
|
|
222
|
-
# Attribute exists and is a set (guarded to linear classifiers).
|
|
223
|
-
assert isinstance(clf.same_side_keys_, set)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
# ---------------------------------------------------------------------------
|
|
227
|
-
# Non-convex pruning
|
|
228
|
-
# ---------------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
def test_remove_nonconvex_leaves_reduces_or_keeps_leaf_count():
|
|
231
|
-
X, y = _toy_2d(n=400)
|
|
232
|
-
clf = SimplexTreeClassifier(subdivision_levels=4)
|
|
233
|
-
clf.fit(X, y)
|
|
234
|
-
before = len(clf.tree.get_leaves())
|
|
235
|
-
removed = clf.remove_nonconvex_leaves(criterion="distance",
|
|
236
|
-
removal_factor=0.1,
|
|
237
|
-
max_remove_frac=0.25)
|
|
238
|
-
after = len(clf.tree.get_leaves())
|
|
239
|
-
assert removed >= 0
|
|
240
|
-
assert after == before - removed
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def test_remove_nonconvex_requires_linear_classifier():
|
|
244
|
-
X, y = _toy_2d()
|
|
245
|
-
clf = SimplexTreeClassifier(classifier=SVC(kernel="rbf"),
|
|
246
|
-
subdivision_levels=2)
|
|
247
|
-
clf.fit(X, y)
|
|
248
|
-
with pytest.raises(AttributeError):
|
|
249
|
-
clf.remove_nonconvex_leaves()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|