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