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,28 @@
|
|
|
1
|
+
"""simplex_tree_classifier - hierarchical simplex-tree classifier.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
- ``SimplexTreeClassifier``: the main estimator (dataset or surrogate mode).
|
|
5
|
+
- ``SimplexTree`` / ``Simplex`` / ``VertexRegistry``: the geometric core.
|
|
6
|
+
- ``make_enclosing_simplex``: build an N-D simplex enclosing ``[0, 1]^d``.
|
|
7
|
+
- ``get_device``: resolve the torch device used for the transform.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .vertex_registry import VertexRegistry
|
|
11
|
+
from .simplex import Simplex
|
|
12
|
+
from .simplex_tree import SimplexTree, make_enclosing_simplex
|
|
13
|
+
from .plane_equation import PlaneEquation
|
|
14
|
+
from .classifier import SimplexTreeClassifier
|
|
15
|
+
from .backend import get_device
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"SimplexTreeClassifier",
|
|
21
|
+
"SimplexTree",
|
|
22
|
+
"Simplex",
|
|
23
|
+
"VertexRegistry",
|
|
24
|
+
"PlaneEquation",
|
|
25
|
+
"make_enclosing_simplex",
|
|
26
|
+
"get_device",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""PyTorch backend that accelerates the barycentric ``transform``.
|
|
2
|
+
|
|
3
|
+
Leaves produced by barycentric subdivision are full ``d``-simplices (``d + 1``
|
|
4
|
+
vertices), so each non-degenerate leaf has an invertible edge matrix ``A`` with a
|
|
5
|
+
precomputed inverse. This backend stacks those per-leaf inverses into tensors and
|
|
6
|
+
embeds an entire batch of points against every leaf at once, on the GPU when one
|
|
7
|
+
is available. For each point it selects the leaf whose barycentric coordinates
|
|
8
|
+
are all non-negative and most interior.
|
|
9
|
+
|
|
10
|
+
Points that are not resolved on the GPU (outside every leaf, or inside a
|
|
11
|
+
degenerate leaf excluded from the tensor stack) are reported back so the caller
|
|
12
|
+
can fall back to the exact per-point CPU search.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import torch
|
|
21
|
+
_TORCH_AVAILABLE = True
|
|
22
|
+
except ImportError: # pragma: no cover - torch is a hard dependency, guard anyway
|
|
23
|
+
torch = None
|
|
24
|
+
_TORCH_AVAILABLE = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_device(device=None):
|
|
28
|
+
"""Resolve a torch device, defaulting to CUDA when available, else CPU.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
device: An explicit ``torch.device``/str, or ``None`` to auto-detect.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A ``torch.device``.
|
|
35
|
+
"""
|
|
36
|
+
if not _TORCH_AVAILABLE:
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"PyTorch is required for simplex_tree_classifier. Install it with "
|
|
39
|
+
"`pip install torch`."
|
|
40
|
+
)
|
|
41
|
+
if device is not None:
|
|
42
|
+
return torch.device(device)
|
|
43
|
+
if torch.cuda.is_available():
|
|
44
|
+
return torch.device("cuda")
|
|
45
|
+
return torch.device("cpu")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TransformBackend:
|
|
49
|
+
"""Precomputes per-leaf tensors and batch-embeds points into barycentric coords."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, device=None, tolerance: float = 1e-10,
|
|
52
|
+
element_budget: int = 40_000_000):
|
|
53
|
+
self.device = get_device(device)
|
|
54
|
+
self.tolerance = tolerance
|
|
55
|
+
# Caps the number of (chunk x leaves x dim) elements held at once.
|
|
56
|
+
self.element_budget = int(element_budget)
|
|
57
|
+
self.dtype = torch.float64
|
|
58
|
+
self._built = False
|
|
59
|
+
|
|
60
|
+
self.leaves: List = []
|
|
61
|
+
self.n_leaves = 0
|
|
62
|
+
self.dimension = 0
|
|
63
|
+
# Global vertex indices per leaf, in the order alphas are produced.
|
|
64
|
+
self.leaf_vertex_indices: Optional[np.ndarray] = None
|
|
65
|
+
self._V0 = None # (L, d)
|
|
66
|
+
self._A_inv = None # (L, d, d)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_built(self) -> bool:
|
|
70
|
+
return self._built and self.n_leaves > 0
|
|
71
|
+
|
|
72
|
+
def build(self, leaves: List) -> None:
|
|
73
|
+
"""Stack tensors for all full, non-degenerate leaves.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
leaves: Iterable of leaf ``SimplexTree`` nodes.
|
|
77
|
+
"""
|
|
78
|
+
self._built = False
|
|
79
|
+
self.leaves = []
|
|
80
|
+
v0_list = []
|
|
81
|
+
a_inv_list = []
|
|
82
|
+
vidx_list = []
|
|
83
|
+
|
|
84
|
+
dimension = None
|
|
85
|
+
for leaf in leaves:
|
|
86
|
+
d = leaf.dimension
|
|
87
|
+
# Only stack full simplices with a usable inverse.
|
|
88
|
+
if leaf.n_vertices != d + 1:
|
|
89
|
+
continue
|
|
90
|
+
if getattr(leaf, "is_degenerate", False) or getattr(leaf, "A_inv", None) is None:
|
|
91
|
+
continue
|
|
92
|
+
if dimension is None:
|
|
93
|
+
dimension = d
|
|
94
|
+
elif d != dimension:
|
|
95
|
+
# Mixed dimensions should not happen within one tree; skip oddities.
|
|
96
|
+
continue
|
|
97
|
+
self.leaves.append(leaf)
|
|
98
|
+
v0_list.append(np.asarray(leaf.vertices[0], dtype=np.float64))
|
|
99
|
+
a_inv_list.append(np.asarray(leaf.A_inv, dtype=np.float64))
|
|
100
|
+
vidx_list.append(np.asarray(leaf.vertex_indices, dtype=np.int64))
|
|
101
|
+
|
|
102
|
+
self.n_leaves = len(self.leaves)
|
|
103
|
+
self.dimension = dimension or 0
|
|
104
|
+
|
|
105
|
+
if self.n_leaves == 0:
|
|
106
|
+
self.leaf_vertex_indices = None
|
|
107
|
+
self._V0 = None
|
|
108
|
+
self._A_inv = None
|
|
109
|
+
self._built = True
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self.leaf_vertex_indices = np.stack(vidx_list, axis=0) # (L, d+1)
|
|
113
|
+
self._V0 = torch.as_tensor(np.stack(v0_list, axis=0),
|
|
114
|
+
dtype=self.dtype, device=self.device) # (L, d)
|
|
115
|
+
self._A_inv = torch.as_tensor(np.stack(a_inv_list, axis=0),
|
|
116
|
+
dtype=self.dtype, device=self.device) # (L, d, d)
|
|
117
|
+
self._built = True
|
|
118
|
+
|
|
119
|
+
def _chunk_size(self) -> int:
|
|
120
|
+
per_point = max(self.n_leaves * max(self.dimension, 1), 1)
|
|
121
|
+
return max(1, self.element_budget // per_point)
|
|
122
|
+
|
|
123
|
+
def embed(self, points: np.ndarray):
|
|
124
|
+
"""Embed a batch of points against every stacked leaf.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
points: Array of shape ``(m, d)``.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Tuple ``(leaf_index, found, alphas)`` where:
|
|
131
|
+
* ``leaf_index`` (m,) int array indexes into ``self.leaves`` / rows of
|
|
132
|
+
``self.leaf_vertex_indices`` (``-1`` when unresolved),
|
|
133
|
+
* ``found`` (m,) bool array marks GPU-resolved points,
|
|
134
|
+
* ``alphas`` (m, d+1) float array holds the barycentric coordinates
|
|
135
|
+
for the chosen leaf (rows for unresolved points are meaningless).
|
|
136
|
+
"""
|
|
137
|
+
points = np.asarray(points, dtype=np.float64)
|
|
138
|
+
if points.ndim == 1:
|
|
139
|
+
points = points.reshape(1, -1)
|
|
140
|
+
m = points.shape[0]
|
|
141
|
+
|
|
142
|
+
leaf_index = np.full(m, -1, dtype=np.int64)
|
|
143
|
+
found = np.zeros(m, dtype=bool)
|
|
144
|
+
alphas_out = np.zeros((m, self.dimension + 1), dtype=np.float64)
|
|
145
|
+
|
|
146
|
+
if not self.is_built:
|
|
147
|
+
return leaf_index, found, alphas_out
|
|
148
|
+
|
|
149
|
+
tol = self.tolerance
|
|
150
|
+
chunk = self._chunk_size()
|
|
151
|
+
P_all = torch.as_tensor(points, dtype=self.dtype, device=self.device)
|
|
152
|
+
|
|
153
|
+
for start in range(0, m, chunk):
|
|
154
|
+
end = min(start + chunk, m)
|
|
155
|
+
P = P_all[start:end] # (c, d)
|
|
156
|
+
b = P[:, None, :] - self._V0[None, :, :] # (c, L, d)
|
|
157
|
+
alpha_r = torch.einsum("lij,clj->cli", self._A_inv, b) # (c, L, d)
|
|
158
|
+
alpha0 = 1.0 - alpha_r.sum(dim=-1) # (c, L)
|
|
159
|
+
alphas = torch.cat([alpha0[..., None], alpha_r], dim=-1) # (c, L, d+1)
|
|
160
|
+
|
|
161
|
+
inside = (alphas >= -tol).all(dim=-1) # (c, L)
|
|
162
|
+
min_alpha = alphas.amin(dim=-1) # (c, L)
|
|
163
|
+
neg_inf = torch.full_like(min_alpha, float("-inf"))
|
|
164
|
+
scored = torch.where(inside, min_alpha, neg_inf) # (c, L)
|
|
165
|
+
|
|
166
|
+
best_val, best_leaf = scored.max(dim=1) # (c,), (c,)
|
|
167
|
+
chunk_found = torch.isfinite(best_val) # (c,)
|
|
168
|
+
|
|
169
|
+
rows = torch.arange(end - start, device=self.device)
|
|
170
|
+
best_alphas = alphas[rows, best_leaf] # (c, d+1)
|
|
171
|
+
|
|
172
|
+
cf = chunk_found.cpu().numpy()
|
|
173
|
+
bl = best_leaf.cpu().numpy()
|
|
174
|
+
ba = best_alphas.cpu().numpy()
|
|
175
|
+
|
|
176
|
+
sl = slice(start, end)
|
|
177
|
+
found[sl] = cf
|
|
178
|
+
leaf_index[sl] = np.where(cf, bl, -1)
|
|
179
|
+
alphas_out[sl] = ba
|
|
180
|
+
|
|
181
|
+
return leaf_index, found, alphas_out
|