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,237 @@
|
|
|
1
|
+
"""Geometry helpers to test whether the decision boundary between two adjacent
|
|
2
|
+
simplices bends in a convex or non-convex way.
|
|
3
|
+
|
|
4
|
+
The idea: two adjacent boundary-crossing simplices share a face. The linear
|
|
5
|
+
boundary crosses that shared face at a "meeting point" and exits each simplex at
|
|
6
|
+
an "external crossing". Sampling test points a fraction ``epsilon`` of the way
|
|
7
|
+
toward each external crossing and checking which side of the hyperplane their
|
|
8
|
+
average lands on tells us whether the boundary is locally convex.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .plane_equation import PlaneEquation
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ensure_dense_weights(weights):
|
|
17
|
+
"""Convert sparse matrix to dense array if needed (OneClassSVM returns sparse)."""
|
|
18
|
+
if hasattr(weights, 'toarray'):
|
|
19
|
+
return weights.toarray().flatten()
|
|
20
|
+
elif hasattr(weights, 'A'):
|
|
21
|
+
return np.asarray(weights).flatten()
|
|
22
|
+
return weights
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_shared_vertices(simplex1_node, simplex2_node):
|
|
26
|
+
set1 = set(tuple(v) for v in simplex1_node.vertices)
|
|
27
|
+
set2 = set(tuple(v) for v in simplex2_node.vertices)
|
|
28
|
+
shared_tuples = set1.intersection(set2)
|
|
29
|
+
return [np.array(v) for v in shared_tuples]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def shared_face_length(simplex1_node, simplex2_node):
|
|
33
|
+
"""Longest edge of the shared face between two adjacent simplices."""
|
|
34
|
+
shared = get_shared_vertices(simplex1_node, simplex2_node)
|
|
35
|
+
if len(shared) < 2:
|
|
36
|
+
return None
|
|
37
|
+
return max(np.linalg.norm(shared[i] - shared[j])
|
|
38
|
+
for i in range(len(shared))
|
|
39
|
+
for j in range(i + 1, len(shared)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_crossing_on_edge(line_coeffs, point_a, point_b):
|
|
43
|
+
w = line_coeffs[:-1]
|
|
44
|
+
b = line_coeffs[-1]
|
|
45
|
+
|
|
46
|
+
val_a = np.dot(w, point_a) + b
|
|
47
|
+
val_b = np.dot(w, point_b) + b
|
|
48
|
+
|
|
49
|
+
if abs(val_a - val_b) < 1e-10:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
t = val_a / (val_a - val_b)
|
|
53
|
+
if t < 0 or t > 1:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
return point_a + t * (point_b - point_a)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_crossing_on_shared_face(shared_vertices, line_coeffs):
|
|
60
|
+
for i in range(len(shared_vertices)):
|
|
61
|
+
for j in range(i + 1, len(shared_vertices)):
|
|
62
|
+
point_a = np.array(shared_vertices[i])
|
|
63
|
+
point_b = np.array(shared_vertices[j])
|
|
64
|
+
crossing = find_crossing_on_edge(line_coeffs, point_a, point_b)
|
|
65
|
+
if crossing is not None:
|
|
66
|
+
return crossing
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_svm_meeting_point(simplex1_node, simplex2_node, weights, intercept):
|
|
71
|
+
shared_vertices = get_shared_vertices(simplex1_node, simplex2_node)
|
|
72
|
+
|
|
73
|
+
if len(shared_vertices) < 2:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
svm_plane1 = PlaneEquation(simplex1_node)
|
|
77
|
+
line_coeffs1 = svm_plane1.compute_plane_from_weights(weights, intercept)
|
|
78
|
+
|
|
79
|
+
meeting_point = find_crossing_on_shared_face(shared_vertices, line_coeffs1)
|
|
80
|
+
|
|
81
|
+
return meeting_point
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_external_crossing(simplex_node, line_coeffs, shared_vertices):
|
|
85
|
+
vertices = [np.array(v) for v in simplex_node.vertices]
|
|
86
|
+
shared_set = set(tuple(v) for v in shared_vertices)
|
|
87
|
+
|
|
88
|
+
for i in range(len(vertices)):
|
|
89
|
+
for j in range(i + 1, len(vertices)):
|
|
90
|
+
vi_shared = tuple(vertices[i]) in shared_set
|
|
91
|
+
vj_shared = tuple(vertices[j]) in shared_set
|
|
92
|
+
|
|
93
|
+
if vi_shared and vj_shared:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
crossing = find_crossing_on_edge(line_coeffs, vertices[i], vertices[j])
|
|
97
|
+
if crossing is not None:
|
|
98
|
+
return crossing
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_epsilon_points(simplex1_node, simplex2_node, weights, intercept, epsilon):
|
|
104
|
+
"""Find test points along the decision boundary for convexity checking.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
simplex1_node: First adjacent simplex
|
|
108
|
+
simplex2_node: Second adjacent simplex
|
|
109
|
+
weights: Hyperplane weights
|
|
110
|
+
intercept: Hyperplane intercept
|
|
111
|
+
epsilon: Fraction (0-1) of distance from meeting point to external crossing.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
``(meeting_point, point1, point2)``.
|
|
115
|
+
"""
|
|
116
|
+
shared_vertices = get_shared_vertices(simplex1_node, simplex2_node)
|
|
117
|
+
|
|
118
|
+
if len(shared_vertices) < 2:
|
|
119
|
+
return None, None, None
|
|
120
|
+
|
|
121
|
+
svm_plane1 = PlaneEquation(simplex1_node)
|
|
122
|
+
svm_plane2 = PlaneEquation(simplex2_node)
|
|
123
|
+
|
|
124
|
+
line_coeffs1 = svm_plane1.compute_plane_from_weights(weights, intercept)
|
|
125
|
+
line_coeffs2 = svm_plane2.compute_plane_from_weights(weights, intercept)
|
|
126
|
+
|
|
127
|
+
meeting_point = find_crossing_on_shared_face(shared_vertices, line_coeffs1)
|
|
128
|
+
|
|
129
|
+
if meeting_point is None:
|
|
130
|
+
return None, None, None
|
|
131
|
+
|
|
132
|
+
external1 = find_external_crossing(simplex1_node, line_coeffs1, shared_vertices)
|
|
133
|
+
external2 = find_external_crossing(simplex2_node, line_coeffs2, shared_vertices)
|
|
134
|
+
|
|
135
|
+
if external1 is None or external2 is None:
|
|
136
|
+
return meeting_point, None, None
|
|
137
|
+
|
|
138
|
+
dir1 = external1 - meeting_point
|
|
139
|
+
dir2 = external2 - meeting_point
|
|
140
|
+
|
|
141
|
+
norm1 = np.linalg.norm(dir1)
|
|
142
|
+
norm2 = np.linalg.norm(dir2)
|
|
143
|
+
|
|
144
|
+
if norm1 < 1e-10 or norm2 < 1e-10:
|
|
145
|
+
return meeting_point, None, None
|
|
146
|
+
|
|
147
|
+
point1 = meeting_point + epsilon * dir1
|
|
148
|
+
point2 = meeting_point + epsilon * dir2
|
|
149
|
+
|
|
150
|
+
return meeting_point, point1, point2
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def find_average_point(simplex1_node, simplex2_node, weights, intercept, epsilon):
|
|
154
|
+
meeting_point, point1, point2 = find_epsilon_points(
|
|
155
|
+
simplex1_node, simplex2_node, weights, intercept, epsilon
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if meeting_point is None or point1 is None or point2 is None:
|
|
159
|
+
return None, meeting_point, point1, point2
|
|
160
|
+
|
|
161
|
+
average_point = (meeting_point + point1 + point2) / 3
|
|
162
|
+
|
|
163
|
+
return average_point, meeting_point, point1, point2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def meeting_to_average_distance(simplex1_node, simplex2_node, weights, intercept, epsilon):
|
|
167
|
+
"""Distance from the shared-face meeting point to the average test point.
|
|
168
|
+
|
|
169
|
+
Returns ``(distance, average_point, meeting_point, point1, point2)`` where
|
|
170
|
+
``distance`` is ``None`` when the geometry could not be resolved.
|
|
171
|
+
"""
|
|
172
|
+
average_point, meeting, pt1, pt2 = find_average_point(
|
|
173
|
+
simplex1_node, simplex2_node, weights, intercept, epsilon
|
|
174
|
+
)
|
|
175
|
+
if average_point is None or meeting is None:
|
|
176
|
+
return None, average_point, meeting, pt1, pt2
|
|
177
|
+
distance = float(np.linalg.norm(np.asarray(average_point) - np.asarray(meeting)))
|
|
178
|
+
return distance, average_point, meeting, pt1, pt2
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_point_in_red_area(point, containing_simplex, weights, intercept):
|
|
182
|
+
barycentric = containing_simplex._embed_point(tuple(point))
|
|
183
|
+
if barycentric is None:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
weights = _ensure_dense_weights(weights)
|
|
187
|
+
vertex_decisions = [weights[idx] + intercept for idx in containing_simplex.vertex_indices]
|
|
188
|
+
|
|
189
|
+
decision_value = np.dot(vertex_decisions, barycentric)
|
|
190
|
+
|
|
191
|
+
return decision_value >= 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_convexity(simplex1_node, simplex2_node, weights, intercept,
|
|
195
|
+
global_tree=None, epsilon=0.3):
|
|
196
|
+
"""Check if the boundary between two adjacent simplices is convex.
|
|
197
|
+
|
|
198
|
+
Creates test points along the boundary and checks if the average point falls
|
|
199
|
+
on the expected side of the hyperplane.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
simplex1_node: First adjacent simplex
|
|
203
|
+
simplex2_node: Second adjacent simplex
|
|
204
|
+
weights: Hyperplane weights
|
|
205
|
+
intercept: Hyperplane intercept
|
|
206
|
+
global_tree: Optional tree to search for the containing simplex if the
|
|
207
|
+
average point falls outside both input simplices
|
|
208
|
+
epsilon: Fraction (0-1) of distance from meeting point to external crossing.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
``(is_convex, average_point, meeting, pt1, pt2)``.
|
|
212
|
+
"""
|
|
213
|
+
average_point, meeting, pt1, pt2 = find_average_point(
|
|
214
|
+
simplex1_node, simplex2_node, weights, intercept, epsilon
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if average_point is None:
|
|
218
|
+
return True, None, meeting, pt1, pt2
|
|
219
|
+
|
|
220
|
+
containing_simplex = None
|
|
221
|
+
|
|
222
|
+
if simplex1_node._point_inside_simplex(tuple(average_point)):
|
|
223
|
+
containing_simplex = simplex1_node
|
|
224
|
+
elif simplex2_node._point_inside_simplex(tuple(average_point)):
|
|
225
|
+
containing_simplex = simplex2_node
|
|
226
|
+
elif global_tree is not None:
|
|
227
|
+
containing_simplex = global_tree.find_containing_simplex(tuple(average_point))
|
|
228
|
+
|
|
229
|
+
if containing_simplex is None:
|
|
230
|
+
return True, average_point, meeting, pt1, pt2
|
|
231
|
+
|
|
232
|
+
in_red = is_point_in_red_area(average_point, containing_simplex, weights, intercept)
|
|
233
|
+
|
|
234
|
+
if in_red is None:
|
|
235
|
+
return True, average_point, meeting, pt1, pt2
|
|
236
|
+
|
|
237
|
+
return in_red, average_point, meeting, pt1, pt2
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Recover the linear decision boundary inside a single simplex.
|
|
2
|
+
|
|
3
|
+
Given a linear classifier's per-vertex weights, ``PlaneEquation`` turns them
|
|
4
|
+
into the hyperplane (in the original feature space) where the decision function
|
|
5
|
+
is zero within one simplex. Used by the convexity checks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .simplex import Simplex
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlaneEquation:
|
|
14
|
+
def __init__(self, simplex: Simplex):
|
|
15
|
+
self.simplex = simplex
|
|
16
|
+
self.plane_coefficients = None
|
|
17
|
+
self.normalized_coefficients = None
|
|
18
|
+
|
|
19
|
+
def compute_plane_from_weights(self, weight_vector: np.ndarray,
|
|
20
|
+
intercept: float = 0.0) -> np.ndarray:
|
|
21
|
+
# Convert sparse matrix to dense array if needed (OneClassSVM returns sparse).
|
|
22
|
+
if hasattr(weight_vector, 'toarray'):
|
|
23
|
+
weight_vector = weight_vector.toarray().flatten()
|
|
24
|
+
elif hasattr(weight_vector, 'A'):
|
|
25
|
+
weight_vector = np.asarray(weight_vector).flatten()
|
|
26
|
+
simplex_weights = np.asarray(weight_vector)[self.simplex.vertex_indices]
|
|
27
|
+
plane_eq = self.simplex.A_inv.T @ (simplex_weights[1:] - simplex_weights[0])
|
|
28
|
+
constant = (simplex_weights[0] + intercept) - plane_eq @ self.simplex.vertices[0]
|
|
29
|
+
self.plane_coefficients = np.append(plane_eq, constant)
|
|
30
|
+
return self.plane_coefficients
|
|
31
|
+
|
|
32
|
+
def get_cartesian_form(self) -> str:
|
|
33
|
+
if self.plane_coefficients is None:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"Plane equation not computed yet. Call compute_plane_from_weights first."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
coeffs = self.plane_coefficients
|
|
39
|
+
var_names = [f"x{i+1}" for i in range(len(coeffs) - 1)]
|
|
40
|
+
constant = coeffs[-1]
|
|
41
|
+
|
|
42
|
+
equation_parts = []
|
|
43
|
+
for coeff, var in zip(coeffs[:-1], var_names):
|
|
44
|
+
if abs(coeff) < 1e-10:
|
|
45
|
+
continue
|
|
46
|
+
if coeff == 1.0:
|
|
47
|
+
term = var
|
|
48
|
+
elif coeff == -1.0:
|
|
49
|
+
term = f"-{var}"
|
|
50
|
+
else:
|
|
51
|
+
term = f"{coeff:.4f}{var}"
|
|
52
|
+
|
|
53
|
+
if equation_parts and coeff > 0:
|
|
54
|
+
equation_parts.append("+")
|
|
55
|
+
equation_parts.append(term)
|
|
56
|
+
|
|
57
|
+
if abs(constant) > 1e-10:
|
|
58
|
+
if constant > 0 and equation_parts:
|
|
59
|
+
equation_parts.append("+")
|
|
60
|
+
equation_parts.append(f"{constant:.4f}")
|
|
61
|
+
|
|
62
|
+
if not equation_parts:
|
|
63
|
+
return "0 = 0"
|
|
64
|
+
|
|
65
|
+
return " ".join(equation_parts) + " = 0"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""N-dimensional simplex with barycentric-coordinate embedding.
|
|
2
|
+
|
|
3
|
+
A ``Simplex`` is defined by ``d + 1`` (or more) vertices in ``d``-dimensional
|
|
4
|
+
space. It knows how to embed an arbitrary point into barycentric coordinates
|
|
5
|
+
and to test whether a point lies inside it. The math here is dimension-agnostic
|
|
6
|
+
and unchanged from the original research implementation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from typing import List, Tuple, Optional, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .vertex_registry import VertexRegistry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Simplex:
|
|
17
|
+
def __init__(self, vertex_indices: List[int], registry: "VertexRegistry",
|
|
18
|
+
tolerance: float = 1e-10):
|
|
19
|
+
if len(vertex_indices) < 2:
|
|
20
|
+
raise ValueError("Simplex must have at least 2 vertices")
|
|
21
|
+
|
|
22
|
+
self.vertex_indices = vertex_indices
|
|
23
|
+
self.registry = registry
|
|
24
|
+
self.n_vertices = len(vertex_indices)
|
|
25
|
+
self.tolerance = tolerance
|
|
26
|
+
|
|
27
|
+
first_vertex = self.registry._get_vertex(vertex_indices[0])
|
|
28
|
+
self.dimension = len(first_vertex)
|
|
29
|
+
|
|
30
|
+
for idx in vertex_indices:
|
|
31
|
+
v = self.registry._get_vertex(idx)
|
|
32
|
+
if len(v) != self.dimension:
|
|
33
|
+
raise ValueError("All vertices must have the same dimension")
|
|
34
|
+
|
|
35
|
+
if self.n_vertices < self.dimension + 1:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"For {self.dimension}D space, need at least "
|
|
38
|
+
f"{self.dimension + 1} vertices, got {self.n_vertices}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self._build_transformation_matrix()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def vertices(self) -> List[np.ndarray]:
|
|
45
|
+
"""Returns list of vertex coordinates as numpy arrays."""
|
|
46
|
+
return self.registry._get_vertices(self.vertex_indices)
|
|
47
|
+
|
|
48
|
+
def _build_transformation_matrix(self):
|
|
49
|
+
vertices = self.vertices
|
|
50
|
+
v0 = vertices[0]
|
|
51
|
+
|
|
52
|
+
if self.n_vertices == self.dimension + 1:
|
|
53
|
+
self.A = np.column_stack([v - v0 for v in vertices[1:]])
|
|
54
|
+
self.det_A = np.linalg.det(self.A)
|
|
55
|
+
self.is_degenerate = abs(self.det_A) < self.tolerance
|
|
56
|
+
if not self.is_degenerate:
|
|
57
|
+
self.A_inv = np.linalg.inv(self.A)
|
|
58
|
+
else:
|
|
59
|
+
self.A_inv = None
|
|
60
|
+
else:
|
|
61
|
+
edge_vectors = np.array([v - v0 for v in vertices[1:]])
|
|
62
|
+
self.A = edge_vectors.T
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
self.A_pseudo_inv = np.linalg.pinv(self.A)
|
|
66
|
+
self.is_degenerate = False
|
|
67
|
+
self.det_A = 1.0
|
|
68
|
+
except np.linalg.LinAlgError:
|
|
69
|
+
self.is_degenerate = True
|
|
70
|
+
self.A_pseudo_inv = None
|
|
71
|
+
self.det_A = 0.0
|
|
72
|
+
|
|
73
|
+
def _embed_point(self, point: Tuple[float, ...]) -> Optional[Tuple[float, ...]]:
|
|
74
|
+
if self.is_degenerate:
|
|
75
|
+
return None
|
|
76
|
+
P = np.array(point)
|
|
77
|
+
if len(P) != self.dimension:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"Point dimension {len(P)} doesn't match simplex "
|
|
80
|
+
f"dimension {self.dimension}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
vertices = self.vertices
|
|
84
|
+
|
|
85
|
+
if self.n_vertices == self.dimension + 1:
|
|
86
|
+
v0 = vertices[0]
|
|
87
|
+
b = P - v0
|
|
88
|
+
alpha_rest = self.A_inv @ b
|
|
89
|
+
alpha_0 = 1 - np.sum(alpha_rest)
|
|
90
|
+
embeddings = tuple([float(alpha_0)] + [float(x) for x in alpha_rest])
|
|
91
|
+
else:
|
|
92
|
+
from itertools import combinations
|
|
93
|
+
required_vertices = self.dimension + 1
|
|
94
|
+
|
|
95
|
+
best_simplex = None
|
|
96
|
+
best_coords = None
|
|
97
|
+
best_indices = None
|
|
98
|
+
|
|
99
|
+
for local_indices in combinations(range(self.n_vertices), required_vertices):
|
|
100
|
+
sub_vertex_indices = [self.vertex_indices[i] for i in local_indices]
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
temp_simplex = Simplex(sub_vertex_indices, self.registry, self.tolerance)
|
|
104
|
+
|
|
105
|
+
if temp_simplex._point_inside_simplex(tuple(P)):
|
|
106
|
+
coords = temp_simplex._embed_point(tuple(P))
|
|
107
|
+
if coords is not None and all(c >= -self.tolerance for c in coords):
|
|
108
|
+
min_coord = min(coords)
|
|
109
|
+
if best_simplex is None or min_coord > best_simplex:
|
|
110
|
+
best_simplex = min_coord
|
|
111
|
+
best_coords = coords
|
|
112
|
+
best_indices = local_indices
|
|
113
|
+
except (ValueError, np.linalg.LinAlgError):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if best_coords is not None:
|
|
117
|
+
sparse_coords = [0.0] * self.n_vertices
|
|
118
|
+
for i, local_idx in enumerate(best_indices):
|
|
119
|
+
sparse_coords[local_idx] = best_coords[i]
|
|
120
|
+
embeddings = tuple(sparse_coords)
|
|
121
|
+
else:
|
|
122
|
+
vertex_matrix = np.array(vertices).T
|
|
123
|
+
constraint_matrix = np.vstack([vertex_matrix, np.ones(self.n_vertices)])
|
|
124
|
+
constraint_vector = np.append(P, 1.0)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
weights = np.linalg.lstsq(constraint_matrix, constraint_vector, rcond=None)[0]
|
|
128
|
+
embeddings = tuple(float(w) for w in weights)
|
|
129
|
+
except np.linalg.LinAlgError:
|
|
130
|
+
return None
|
|
131
|
+
return embeddings
|
|
132
|
+
|
|
133
|
+
def _point_inside_simplex(self, point) -> bool:
|
|
134
|
+
coords = self._embed_point(point)
|
|
135
|
+
if coords is None:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
if not all(0 <= alpha <= 1 for alpha in coords):
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
if abs(sum(coords) - 1.0) > self.tolerance:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
def contains_point(self, point) -> bool:
|
|
147
|
+
"""Public predicate: True if ``point`` lies inside this simplex."""
|
|
148
|
+
return self._point_inside_simplex(point)
|
|
149
|
+
|
|
150
|
+
def get_vertices_as_tuples(self) -> List[Tuple[float, ...]]:
|
|
151
|
+
"""Returns list of vertex coordinates as tuples for easy iteration/display."""
|
|
152
|
+
return self.registry.get_vertices_as_tuples(self.vertex_indices)
|
|
153
|
+
|
|
154
|
+
def __repr__(self):
|
|
155
|
+
return (f"Simplex(vertex_indices={self.vertex_indices}, "
|
|
156
|
+
f"dim={self.dimension}, degenerate={self.is_degenerate})")
|