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,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