pygeoinf 1.3.7__py3-none-any.whl → 1.3.9__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.
- pygeoinf/__init__.py +41 -0
- pygeoinf/gaussian_measure.py +42 -12
- pygeoinf/plot.py +185 -117
- pygeoinf/preconditioners.py +1 -1
- pygeoinf/subsets.py +845 -0
- pygeoinf/subspaces.py +173 -23
- pygeoinf/symmetric_space/sphere.py +1 -1
- pygeoinf/utils.py +15 -0
- {pygeoinf-1.3.7.dist-info → pygeoinf-1.3.9.dist-info}/METADATA +2 -1
- {pygeoinf-1.3.7.dist-info → pygeoinf-1.3.9.dist-info}/RECORD +12 -10
- {pygeoinf-1.3.7.dist-info → pygeoinf-1.3.9.dist-info}/WHEEL +0 -0
- {pygeoinf-1.3.7.dist-info → pygeoinf-1.3.9.dist-info}/licenses/LICENSE +0 -0
pygeoinf/subsets.py
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defines classes for representing subsets of a Hilbert space.
|
|
3
|
+
|
|
4
|
+
This module provides a hierarchy of classes for sets, ranging from abstract
|
|
5
|
+
definitions to concrete geometric shapes. It supports Constructive Solid
|
|
6
|
+
Geometry (CSG) operations, with specialized handling for convex intersections
|
|
7
|
+
via functional combination.
|
|
8
|
+
|
|
9
|
+
Hierarchy:
|
|
10
|
+
- Subset (Abstract Base)
|
|
11
|
+
- EmptySet / UniversalSet
|
|
12
|
+
- LevelSet (f(x) = c)
|
|
13
|
+
- EllipsoidSurface -> Sphere
|
|
14
|
+
- SublevelSet (f(x) <= c)
|
|
15
|
+
- ConvexSubset -> Ellipsoid -> Ball
|
|
16
|
+
- ConvexIntersection (Max-Functional Combination)
|
|
17
|
+
- Intersection (Generic)
|
|
18
|
+
- Union (Generic)
|
|
19
|
+
- Complement (S^c)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from typing import TYPE_CHECKING, Optional, List, Iterable
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .nonlinear_forms import NonLinearForm
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .hilbert_space import HilbertSpace, Vector
|
|
31
|
+
from .linear_operators import LinearOperator
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Subset(ABC):
|
|
35
|
+
"""
|
|
36
|
+
Abstract base class for a subset of a HilbertSpace.
|
|
37
|
+
|
|
38
|
+
This class defines the minimal interface required for a mathematical set:
|
|
39
|
+
knowing which space it lives in, determining if a vector belongs to it,
|
|
40
|
+
accessing its boundary, and performing logical set operations.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, domain: Optional[HilbertSpace] = None) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Initializes the subset.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
domain: The Hilbert space containing this subset. Can be None
|
|
49
|
+
for sets that are not strictly attached to a specific domain
|
|
50
|
+
(e.g., a generic EmptySet).
|
|
51
|
+
"""
|
|
52
|
+
self._domain = domain
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def domain(self) -> HilbertSpace:
|
|
56
|
+
"""
|
|
57
|
+
The underlying Hilbert space.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The HilbertSpace instance associated with this subset.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If the domain was not set during initialization.
|
|
64
|
+
"""
|
|
65
|
+
if self._domain is None:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"{self.__class__.__name__} does not have an associated domain."
|
|
68
|
+
)
|
|
69
|
+
return self._domain
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_empty(self) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Returns True if the set is known to be empty.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
bool: True if the set contains no elements, False otherwise.
|
|
78
|
+
Note that returning False does not guarantee the set is non-empty,
|
|
79
|
+
only that it is not trivially known to be empty.
|
|
80
|
+
"""
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Returns True if the vector x lies within the subset.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
x: A vector from the domain.
|
|
90
|
+
rtol: Relative tolerance for floating-point comparisons (e.g.,
|
|
91
|
+
checking equality f(x) = c or inequality f(x) <= c).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
bool: True if x ∈ S, False otherwise.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def boundary(self) -> Subset:
|
|
100
|
+
"""
|
|
101
|
+
Returns the boundary of the subset.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Subset: A new Subset instance representing ∂S.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
# --- CSG Operations ---
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def complement(self) -> Subset:
|
|
111
|
+
"""
|
|
112
|
+
Returns the complement of this set: S^c = {x | x not in S}.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Complement: A generic Complement wrapper around this set.
|
|
116
|
+
"""
|
|
117
|
+
return Complement(self)
|
|
118
|
+
|
|
119
|
+
def intersect(self, other: Subset) -> Subset:
|
|
120
|
+
"""
|
|
121
|
+
Returns the intersection of this set and another: S ∩ O.
|
|
122
|
+
|
|
123
|
+
If both sets are instances of ConvexSubset, this returns a
|
|
124
|
+
ConvexIntersection, which combines their functionals into a single
|
|
125
|
+
convex constraint F(x) = max(f1(x), f2(x)).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
other: Another Subset instance.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Subset: A new set representing elements present in both sets.
|
|
132
|
+
"""
|
|
133
|
+
# Collect all subsets if we are merging intersections
|
|
134
|
+
subsets_to_merge = []
|
|
135
|
+
|
|
136
|
+
if isinstance(self, Intersection): # Includes ConvexIntersection
|
|
137
|
+
subsets_to_merge.extend(self.subsets)
|
|
138
|
+
else:
|
|
139
|
+
subsets_to_merge.append(self)
|
|
140
|
+
|
|
141
|
+
if isinstance(other, Intersection):
|
|
142
|
+
subsets_to_merge.extend(other.subsets)
|
|
143
|
+
else:
|
|
144
|
+
subsets_to_merge.append(other)
|
|
145
|
+
|
|
146
|
+
# Check if all parts are ConvexSubsets (defined by f(x) <= c)
|
|
147
|
+
all_convex_functional = all(
|
|
148
|
+
isinstance(s, ConvexSubset) for s in subsets_to_merge
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if all_convex_functional:
|
|
152
|
+
# We can combine them into a single ConvexSubset via max function
|
|
153
|
+
return ConvexIntersection(subsets_to_merge) # type: ignore
|
|
154
|
+
|
|
155
|
+
# Fallback to generic set logic
|
|
156
|
+
return Intersection(subsets_to_merge)
|
|
157
|
+
|
|
158
|
+
def union(self, other: Subset) -> Union:
|
|
159
|
+
"""
|
|
160
|
+
Returns the union of this set and another: S ∪ O.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
other: Another Subset instance.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Union: A new set representing elements present in either set.
|
|
167
|
+
"""
|
|
168
|
+
subsets_to_merge = [self]
|
|
169
|
+
if isinstance(other, Union):
|
|
170
|
+
subsets_to_merge.extend(other.subsets)
|
|
171
|
+
else:
|
|
172
|
+
subsets_to_merge.append(other)
|
|
173
|
+
return Union(subsets_to_merge)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class EmptySet(Subset):
|
|
177
|
+
"""
|
|
178
|
+
Represents the empty set (∅).
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def is_empty(self) -> bool:
|
|
183
|
+
"""Returns True, as this is the empty set."""
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
187
|
+
"""Returns False for any vector."""
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def boundary(self) -> Subset:
|
|
192
|
+
"""The boundary of an empty set is the empty set itself."""
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def complement(self) -> Subset:
|
|
197
|
+
"""The complement of the empty set is the whole space (Universal Set)."""
|
|
198
|
+
return UniversalSet(self.domain)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class UniversalSet(Subset):
|
|
202
|
+
"""
|
|
203
|
+
Represents the entire domain (Ω).
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
207
|
+
"""Returns True for any vector in the domain."""
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def boundary(self) -> Subset:
|
|
212
|
+
"""The boundary of the entire topological space is empty."""
|
|
213
|
+
return EmptySet(self.domain)
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def complement(self) -> Subset:
|
|
217
|
+
"""The complement of the universe is the empty set."""
|
|
218
|
+
return EmptySet(self.domain)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Complement(Subset):
|
|
222
|
+
"""
|
|
223
|
+
Represents the complement of a set: S^c = {x | x ∉ S}.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(self, subset: Subset) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Args:
|
|
229
|
+
subset: The set to complement.
|
|
230
|
+
"""
|
|
231
|
+
super().__init__(subset.domain)
|
|
232
|
+
self._subset = subset
|
|
233
|
+
|
|
234
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
235
|
+
"""
|
|
236
|
+
Returns True if x is NOT in the underlying subset.
|
|
237
|
+
"""
|
|
238
|
+
return not self._subset.is_element(x, rtol=rtol)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def boundary(self) -> Subset:
|
|
242
|
+
"""
|
|
243
|
+
Returns the boundary of the complement.
|
|
244
|
+
|
|
245
|
+
Ideally, ∂(S^c) = ∂S.
|
|
246
|
+
"""
|
|
247
|
+
return self._subset.boundary
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def complement(self) -> Subset:
|
|
251
|
+
"""
|
|
252
|
+
Returns the complement of the complement, which is the original set.
|
|
253
|
+
(S^c)^c = S.
|
|
254
|
+
"""
|
|
255
|
+
return self._subset
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Intersection(Subset):
|
|
259
|
+
"""
|
|
260
|
+
Represents the generic intersection of multiple subsets: S = S_1 ∩ S_2 ...
|
|
261
|
+
|
|
262
|
+
Used when the subsets cannot be mathematically combined into a single functional
|
|
263
|
+
(e.g., non-convex sets).
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def __init__(self, subsets: Iterable[Subset]) -> None:
|
|
267
|
+
"""
|
|
268
|
+
Args:
|
|
269
|
+
subsets: An iterable of Subset objects to intersect.
|
|
270
|
+
All subsets must belong to the same domain.
|
|
271
|
+
"""
|
|
272
|
+
subsets_list = list(subsets)
|
|
273
|
+
if not subsets_list:
|
|
274
|
+
raise ValueError("Intersection requires at least one subset.")
|
|
275
|
+
domain = subsets_list[0].domain
|
|
276
|
+
|
|
277
|
+
# Validate domains match
|
|
278
|
+
for s in subsets_list:
|
|
279
|
+
if s.domain != domain:
|
|
280
|
+
raise ValueError("All subsets must belong to the same domain.")
|
|
281
|
+
|
|
282
|
+
super().__init__(domain)
|
|
283
|
+
self._subsets = subsets_list
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def subsets(self) -> List[Subset]:
|
|
287
|
+
"""Direct access to the component sets."""
|
|
288
|
+
return self._subsets
|
|
289
|
+
|
|
290
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
291
|
+
"""Returns True if x is in ALL component subsets."""
|
|
292
|
+
return all(s.is_element(x, rtol=rtol) for s in self._subsets)
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def boundary(self) -> Subset:
|
|
296
|
+
"""
|
|
297
|
+
Returns the boundary of the intersection.
|
|
298
|
+
|
|
299
|
+
The general topological boundary is complex: ∂(A ∩ B) ⊆ (∂A ∩ B) ∪ (A ∩ ∂B).
|
|
300
|
+
Currently raises NotImplementedError.
|
|
301
|
+
"""
|
|
302
|
+
raise NotImplementedError(
|
|
303
|
+
"General boundary of intersection not yet implemented."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def complement(self) -> Subset:
|
|
308
|
+
"""
|
|
309
|
+
Returns the complement of the intersection.
|
|
310
|
+
|
|
311
|
+
Applies De Morgan's Law: (A ∩ B)^c = A^c ∪ B^c.
|
|
312
|
+
Returns a Union of the complements.
|
|
313
|
+
"""
|
|
314
|
+
return Union(s.complement for s in self._subsets)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class Union(Subset):
|
|
318
|
+
"""
|
|
319
|
+
Represents the union of multiple subsets: S = S_1 ∪ S_2 ...
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def __init__(self, subsets: Iterable[Subset]) -> None:
|
|
323
|
+
"""
|
|
324
|
+
Args:
|
|
325
|
+
subsets: An iterable of Subset objects to unite.
|
|
326
|
+
All subsets must belong to the same domain.
|
|
327
|
+
"""
|
|
328
|
+
subsets_list = list(subsets)
|
|
329
|
+
if not subsets_list:
|
|
330
|
+
raise ValueError("Union requires at least one subset.")
|
|
331
|
+
domain = subsets_list[0].domain
|
|
332
|
+
for s in subsets_list:
|
|
333
|
+
if s.domain != domain:
|
|
334
|
+
raise ValueError("All subsets must belong to the same domain.")
|
|
335
|
+
super().__init__(domain)
|
|
336
|
+
self._subsets = subsets_list
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def subsets(self) -> List[Subset]:
|
|
340
|
+
"""Direct access to the component sets."""
|
|
341
|
+
return self._subsets
|
|
342
|
+
|
|
343
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
344
|
+
"""Returns True if x is in ANY of the component subsets."""
|
|
345
|
+
return any(s.is_element(x, rtol=rtol) for s in self._subsets)
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def boundary(self) -> Subset:
|
|
349
|
+
"""
|
|
350
|
+
Returns the boundary of the union.
|
|
351
|
+
Currently raises NotImplementedError.
|
|
352
|
+
"""
|
|
353
|
+
raise NotImplementedError("General boundary of union not yet implemented.")
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def complement(self) -> Subset:
|
|
357
|
+
"""
|
|
358
|
+
Returns the complement of the union.
|
|
359
|
+
|
|
360
|
+
Applies De Morgan's Law: (A ∪ B)^c = A^c ∩ B^c.
|
|
361
|
+
Returns an Intersection of the complements.
|
|
362
|
+
"""
|
|
363
|
+
return Intersection(s.complement for s in self._subsets)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class SublevelSet(Subset):
|
|
367
|
+
"""
|
|
368
|
+
Represents a sublevel set defined by a functional: S = {x | f(x) <= c}.
|
|
369
|
+
|
|
370
|
+
This class serves as a base for sets defined by inequalities. Unlike
|
|
371
|
+
ConvexSubset, it does not assume the defining functional is convex.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
def __init__(
|
|
375
|
+
self,
|
|
376
|
+
form: NonLinearForm,
|
|
377
|
+
level: float,
|
|
378
|
+
open_set: bool = False,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""
|
|
381
|
+
Args:
|
|
382
|
+
form: The defining functional f(x).
|
|
383
|
+
level: The scalar upper bound c.
|
|
384
|
+
open_set: If True, uses strict inequality (f(x) < c).
|
|
385
|
+
If False, uses non-strict inequality (f(x) <= c).
|
|
386
|
+
"""
|
|
387
|
+
super().__init__(form.domain)
|
|
388
|
+
self._form = form
|
|
389
|
+
self._level = level
|
|
390
|
+
self._open = open_set
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def form(self) -> NonLinearForm:
|
|
394
|
+
"""The defining functional f(x)."""
|
|
395
|
+
return self._form
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def level(self) -> float:
|
|
399
|
+
"""The scalar upper bound c."""
|
|
400
|
+
return self._level
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def is_open(self) -> bool:
|
|
404
|
+
"""True if the set is defined by strict inequality."""
|
|
405
|
+
return self._open
|
|
406
|
+
|
|
407
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
408
|
+
"""
|
|
409
|
+
Returns True if f(x) <= c (or < c).
|
|
410
|
+
Tolerance is scaled by max(1.0, |c|).
|
|
411
|
+
"""
|
|
412
|
+
val = self._form(x)
|
|
413
|
+
scale = max(1.0, abs(self._level))
|
|
414
|
+
margin = rtol * scale
|
|
415
|
+
|
|
416
|
+
if self._open:
|
|
417
|
+
return val < self._level + margin
|
|
418
|
+
else:
|
|
419
|
+
return val <= self._level + margin
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def boundary(self) -> Subset:
|
|
423
|
+
"""
|
|
424
|
+
Returns the boundary of the sublevel set.
|
|
425
|
+
The boundary is typically the LevelSet {x | f(x) = c}.
|
|
426
|
+
"""
|
|
427
|
+
return LevelSet(self._form, self._level)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class LevelSet(Subset):
|
|
431
|
+
"""
|
|
432
|
+
Represents a level set of a functional: S = {x | f(x) = c}.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
def __init__(
|
|
436
|
+
self,
|
|
437
|
+
form: NonLinearForm,
|
|
438
|
+
level: float,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""
|
|
441
|
+
Args:
|
|
442
|
+
form: The defining functional f(x).
|
|
443
|
+
level: The scalar value c.
|
|
444
|
+
"""
|
|
445
|
+
super().__init__(form.domain)
|
|
446
|
+
self._form = form
|
|
447
|
+
self._level = level
|
|
448
|
+
|
|
449
|
+
@property
|
|
450
|
+
def form(self) -> NonLinearForm:
|
|
451
|
+
"""The defining functional f(x)."""
|
|
452
|
+
return self._form
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def level(self) -> float:
|
|
456
|
+
"""The scalar value c."""
|
|
457
|
+
return self._level
|
|
458
|
+
|
|
459
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
460
|
+
"""
|
|
461
|
+
Returns True if f(x) is approximately equal to c.
|
|
462
|
+
Tolerance is scaled by max(1.0, |c|).
|
|
463
|
+
"""
|
|
464
|
+
val = self._form(x)
|
|
465
|
+
scale = max(1.0, abs(self._level))
|
|
466
|
+
return abs(val - self._level) <= rtol * scale
|
|
467
|
+
|
|
468
|
+
@property
|
|
469
|
+
def boundary(self) -> Subset:
|
|
470
|
+
"""
|
|
471
|
+
Returns the boundary of the level set.
|
|
472
|
+
Assuming regularity, a level set is a closed manifold without boundary.
|
|
473
|
+
"""
|
|
474
|
+
return EmptySet(self.domain)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class ConvexSubset(SublevelSet):
|
|
478
|
+
"""
|
|
479
|
+
Represents a convex set defined as a sublevel set: S = {x | f(x) <= c}.
|
|
480
|
+
|
|
481
|
+
This class assumes the defining form 'f' is convex. It includes tools
|
|
482
|
+
to verify this property locally.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
def __init__(
|
|
486
|
+
self,
|
|
487
|
+
form: NonLinearForm,
|
|
488
|
+
level: float,
|
|
489
|
+
open_set: bool = False,
|
|
490
|
+
) -> None:
|
|
491
|
+
"""
|
|
492
|
+
Args:
|
|
493
|
+
form: The defining functional f(x). Must be convex.
|
|
494
|
+
level: The scalar upper bound c.
|
|
495
|
+
open_set: If True, uses strict inequality (<).
|
|
496
|
+
"""
|
|
497
|
+
super().__init__(form, level, open_set=open_set)
|
|
498
|
+
|
|
499
|
+
def check(
|
|
500
|
+
self, n_samples: int = 10, /, *, rtol: float = 1e-5, atol: float = 1e-8
|
|
501
|
+
) -> None:
|
|
502
|
+
"""
|
|
503
|
+
Performs a randomized check of the convexity inequality:
|
|
504
|
+
f(tx + (1-t)y) <= t*f(x) + (1-t)*f(y)
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
n_samples: Number of random pairs to test.
|
|
508
|
+
rtol: Relative tolerance.
|
|
509
|
+
atol: Absolute tolerance.
|
|
510
|
+
|
|
511
|
+
Raises:
|
|
512
|
+
AssertionError: If the function is found to be non-convex.
|
|
513
|
+
"""
|
|
514
|
+
for _ in range(n_samples):
|
|
515
|
+
x = self.domain.random()
|
|
516
|
+
y = self.domain.random()
|
|
517
|
+
t = np.random.uniform(0, 1)
|
|
518
|
+
|
|
519
|
+
# Convex combination
|
|
520
|
+
tx = self.domain.multiply(t, x)
|
|
521
|
+
ty = self.domain.multiply(1.0 - t, y)
|
|
522
|
+
z = self.domain.add(tx, ty)
|
|
523
|
+
|
|
524
|
+
# Evaluate form
|
|
525
|
+
fz = self._form(z)
|
|
526
|
+
fx = self._form(x)
|
|
527
|
+
fy = self._form(y)
|
|
528
|
+
|
|
529
|
+
# f(tx + (1-t)y) <= t f(x) + (1-t) f(y)
|
|
530
|
+
rhs = t * fx + (1.0 - t) * fy
|
|
531
|
+
|
|
532
|
+
if fz > rhs + atol + rtol * abs(rhs):
|
|
533
|
+
raise AssertionError(
|
|
534
|
+
f"Convexity check failed. "
|
|
535
|
+
f"LHS={fz:.4e}, RHS={rhs:.4e}. "
|
|
536
|
+
"Functional does not appear convex."
|
|
537
|
+
)
|
|
538
|
+
print(f"[✓] Convexity check passed ({n_samples} samples).")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class ConvexIntersection(ConvexSubset):
|
|
542
|
+
"""
|
|
543
|
+
Represents the intersection of multiple convex sets as a single convex set.
|
|
544
|
+
|
|
545
|
+
This class combines the defining functionals of its components into a single
|
|
546
|
+
max-functional: F(x) = max_i (f_i(x) - c_i).
|
|
547
|
+
The intersection is then defined as {x | F(x) <= 0}.
|
|
548
|
+
|
|
549
|
+
This allows the intersection to be treated as a standard ConvexSubset for
|
|
550
|
+
optimization algorithms, providing gradients and Hessians of the active
|
|
551
|
+
constraint, while preserving access to the individual constraints.
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
def __init__(self, subsets: Iterable[ConvexSubset]) -> None:
|
|
555
|
+
"""
|
|
556
|
+
Args:
|
|
557
|
+
subsets: An iterable of ConvexSubset objects.
|
|
558
|
+
"""
|
|
559
|
+
self._subsets = list(subsets)
|
|
560
|
+
if not self._subsets:
|
|
561
|
+
raise ValueError("ConvexIntersection requires at least one subset.")
|
|
562
|
+
|
|
563
|
+
domain = self._subsets[0].domain
|
|
564
|
+
|
|
565
|
+
# 1. Define the combined max-mapping
|
|
566
|
+
# F(x) = max (f_i(x) - c_i)
|
|
567
|
+
def mapping(x: Vector) -> float:
|
|
568
|
+
values = [s.form(x) - s.level for s in self._subsets]
|
|
569
|
+
return float(np.max(values))
|
|
570
|
+
|
|
571
|
+
# 2. Define the gradient via the active constraint
|
|
572
|
+
# dF(x) = df_k(x) where k = argmax(...)
|
|
573
|
+
# Note: At points where multiple constraints are active, this returns
|
|
574
|
+
# a subgradient (from one of the active sets).
|
|
575
|
+
def gradient(x: Vector) -> Vector:
|
|
576
|
+
values = [s.form(x) - s.level for s in self._subsets]
|
|
577
|
+
idx_max = np.argmax(values)
|
|
578
|
+
active_subset = self._subsets[idx_max]
|
|
579
|
+
return active_subset.form.gradient(x)
|
|
580
|
+
|
|
581
|
+
# 3. Define the Hessian via the active constraint
|
|
582
|
+
def hessian(x: Vector) -> LinearOperator:
|
|
583
|
+
values = [s.form(x) - s.level for s in self._subsets]
|
|
584
|
+
idx_max = np.argmax(values)
|
|
585
|
+
active_subset = self._subsets[idx_max]
|
|
586
|
+
return active_subset.form.hessian(x)
|
|
587
|
+
|
|
588
|
+
combined_form = NonLinearForm(
|
|
589
|
+
domain, mapping, gradient=gradient, hessian=hessian
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
# Determine strictness: if any subset is open, the intersection boundary
|
|
593
|
+
# handling gets complex. We adopt a conservative approach: effectively closed
|
|
594
|
+
# for calculation (level=0), but flagging open if any component is open.
|
|
595
|
+
is_any_open = any(s.is_open for s in self._subsets)
|
|
596
|
+
|
|
597
|
+
super().__init__(combined_form, level=0.0, open_set=is_any_open)
|
|
598
|
+
|
|
599
|
+
@property
|
|
600
|
+
def subsets(self) -> List[ConvexSubset]:
|
|
601
|
+
"""Direct access to the individual convex constraints."""
|
|
602
|
+
return self._subsets
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# --- Geometric Implementations ---
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class _EllipsoidalGeometry:
|
|
609
|
+
"""
|
|
610
|
+
Mixin class that holds the common data and logic for ellipsoidal sets.
|
|
611
|
+
|
|
612
|
+
This class constructs the quadratic form f(x) = <A(x-c), x-c> used by
|
|
613
|
+
Ellipsoid and EllipsoidSurface.
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
def __init__(
|
|
617
|
+
self,
|
|
618
|
+
domain: HilbertSpace,
|
|
619
|
+
center: Vector,
|
|
620
|
+
radius: float,
|
|
621
|
+
operator: LinearOperator,
|
|
622
|
+
) -> None:
|
|
623
|
+
"""
|
|
624
|
+
Args:
|
|
625
|
+
domain: The Hilbert space.
|
|
626
|
+
center: The center vector c.
|
|
627
|
+
radius: The radius r.
|
|
628
|
+
operator: The self-adjoint, positive-definite operator A defining the metric.
|
|
629
|
+
"""
|
|
630
|
+
if not domain.is_element(center):
|
|
631
|
+
raise ValueError("Center vector must be in the domain.")
|
|
632
|
+
if radius < 0:
|
|
633
|
+
raise ValueError("Radius must be non-negative.")
|
|
634
|
+
|
|
635
|
+
self._center = center
|
|
636
|
+
self._radius = radius
|
|
637
|
+
self._operator = operator
|
|
638
|
+
|
|
639
|
+
# 1. f(x) = <A(x-c), x-c>
|
|
640
|
+
def mapping(x: Vector) -> float:
|
|
641
|
+
diff = domain.subtract(x, center)
|
|
642
|
+
return domain.inner_product(operator(diff), diff)
|
|
643
|
+
|
|
644
|
+
# 2. f'(x) = 2 A (x-c)
|
|
645
|
+
def gradient(x: Vector) -> Vector:
|
|
646
|
+
diff = domain.subtract(x, center)
|
|
647
|
+
return domain.multiply(2.0, operator(diff))
|
|
648
|
+
|
|
649
|
+
# 3. f''(x) = 2 A
|
|
650
|
+
def hessian(x: Vector) -> LinearOperator:
|
|
651
|
+
return 2.0 * operator
|
|
652
|
+
|
|
653
|
+
self._generated_form = NonLinearForm(
|
|
654
|
+
domain, mapping, gradient=gradient, hessian=hessian
|
|
655
|
+
)
|
|
656
|
+
self._generated_level = radius**2
|
|
657
|
+
|
|
658
|
+
@property
|
|
659
|
+
def center(self) -> Vector:
|
|
660
|
+
"""The center of the ellipsoid."""
|
|
661
|
+
return self._center
|
|
662
|
+
|
|
663
|
+
@property
|
|
664
|
+
def radius(self) -> float:
|
|
665
|
+
"""The 'radius' parameter of the ellipsoid."""
|
|
666
|
+
return self._radius
|
|
667
|
+
|
|
668
|
+
@property
|
|
669
|
+
def operator(self) -> LinearOperator:
|
|
670
|
+
"""The defining linear operator A."""
|
|
671
|
+
return self._operator
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
class Ellipsoid(ConvexSubset, _EllipsoidalGeometry):
|
|
675
|
+
"""
|
|
676
|
+
Represents a solid ellipsoid: E = {x | <A(x-c), x-c> <= r^2}.
|
|
677
|
+
"""
|
|
678
|
+
|
|
679
|
+
def __init__(
|
|
680
|
+
self,
|
|
681
|
+
domain: HilbertSpace,
|
|
682
|
+
center: Vector,
|
|
683
|
+
radius: float,
|
|
684
|
+
operator: LinearOperator,
|
|
685
|
+
open_set: bool = False,
|
|
686
|
+
) -> None:
|
|
687
|
+
"""
|
|
688
|
+
Args:
|
|
689
|
+
domain: The Hilbert space.
|
|
690
|
+
center: The center vector c.
|
|
691
|
+
radius: The radius r.
|
|
692
|
+
operator: The operator A.
|
|
693
|
+
open_set: If True, defines an open ellipsoid (< r^2).
|
|
694
|
+
"""
|
|
695
|
+
_EllipsoidalGeometry.__init__(self, domain, center, radius, operator)
|
|
696
|
+
ConvexSubset.__init__(
|
|
697
|
+
self, self._generated_form, self._generated_level, open_set=open_set
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
@property
|
|
701
|
+
def boundary(self) -> Subset:
|
|
702
|
+
"""Returns the boundary EllipsoidSurface."""
|
|
703
|
+
return EllipsoidSurface(self.domain, self.center, self.radius, self.operator)
|
|
704
|
+
|
|
705
|
+
@property
|
|
706
|
+
def normalized(self) -> NormalisedEllipsoid:
|
|
707
|
+
"""
|
|
708
|
+
Returns a normalized version of this ellipsoid with radius 1.
|
|
709
|
+
The operator is scaled by 1/r^2 to represent the same set.
|
|
710
|
+
"""
|
|
711
|
+
if self.radius == 0:
|
|
712
|
+
raise ValueError("Cannot normalize an ellipsoid with zero radius.")
|
|
713
|
+
scale = 1.0 / (self.radius**2)
|
|
714
|
+
scaled_operator = scale * self.operator
|
|
715
|
+
return NormalisedEllipsoid(
|
|
716
|
+
self.domain, self.center, scaled_operator, open_set=self.is_open
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
class NormalisedEllipsoid(Ellipsoid):
|
|
721
|
+
"""
|
|
722
|
+
Represents a normalised ellipsoid with radius 1: E = {x | <A(x-c), x-c> <= 1}.
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
def __init__(
|
|
726
|
+
self,
|
|
727
|
+
domain: HilbertSpace,
|
|
728
|
+
center: Vector,
|
|
729
|
+
operator: LinearOperator,
|
|
730
|
+
open_set: bool = False,
|
|
731
|
+
) -> None:
|
|
732
|
+
"""
|
|
733
|
+
Args:
|
|
734
|
+
domain: The Hilbert space.
|
|
735
|
+
center: The center vector c.
|
|
736
|
+
operator: The operator A.
|
|
737
|
+
open_set: If True, defines an open ellipsoid.
|
|
738
|
+
"""
|
|
739
|
+
super().__init__(domain, center, 1.0, operator, open_set=open_set)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
class EllipsoidSurface(LevelSet, _EllipsoidalGeometry):
|
|
743
|
+
"""
|
|
744
|
+
Represents the surface of an ellipsoid: S = {x | <A(x-c), x-c> = r^2}.
|
|
745
|
+
"""
|
|
746
|
+
|
|
747
|
+
def __init__(
|
|
748
|
+
self,
|
|
749
|
+
domain: HilbertSpace,
|
|
750
|
+
center: Vector,
|
|
751
|
+
radius: float,
|
|
752
|
+
operator: LinearOperator,
|
|
753
|
+
) -> None:
|
|
754
|
+
"""
|
|
755
|
+
Args:
|
|
756
|
+
domain: The Hilbert space.
|
|
757
|
+
center: The center vector c.
|
|
758
|
+
radius: The radius r.
|
|
759
|
+
operator: The operator A.
|
|
760
|
+
"""
|
|
761
|
+
_EllipsoidalGeometry.__init__(self, domain, center, radius, operator)
|
|
762
|
+
LevelSet.__init__(self, self._generated_form, self._generated_level)
|
|
763
|
+
|
|
764
|
+
@property
|
|
765
|
+
def boundary(self) -> Subset:
|
|
766
|
+
"""Returns EmptySet (manifold without boundary)."""
|
|
767
|
+
return EmptySet(self.domain)
|
|
768
|
+
|
|
769
|
+
@property
|
|
770
|
+
def normalized(self) -> EllipsoidSurface:
|
|
771
|
+
"""
|
|
772
|
+
Returns a normalized version of this surface with radius 1.
|
|
773
|
+
"""
|
|
774
|
+
if self.radius == 0:
|
|
775
|
+
raise ValueError("Cannot normalize a surface with zero radius.")
|
|
776
|
+
scale = 1.0 / (self.radius**2)
|
|
777
|
+
scaled_operator = scale * self.operator
|
|
778
|
+
return EllipsoidSurface(self.domain, self.center, 1.0, scaled_operator)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
class Ball(Ellipsoid):
|
|
782
|
+
"""
|
|
783
|
+
Represents a ball in a Hilbert space: B = {x | ||x - c||^2 <= r^2}.
|
|
784
|
+
This is an Ellipsoid where A is the Identity operator.
|
|
785
|
+
"""
|
|
786
|
+
|
|
787
|
+
def __init__(
|
|
788
|
+
self,
|
|
789
|
+
domain: HilbertSpace,
|
|
790
|
+
center: Vector,
|
|
791
|
+
radius: float,
|
|
792
|
+
open_set: bool = True,
|
|
793
|
+
) -> None:
|
|
794
|
+
"""
|
|
795
|
+
Args:
|
|
796
|
+
domain: The Hilbert space.
|
|
797
|
+
center: The center vector c.
|
|
798
|
+
radius: The radius r.
|
|
799
|
+
open_set: If True (default), defines an open ball (< r).
|
|
800
|
+
"""
|
|
801
|
+
identity = domain.identity_operator()
|
|
802
|
+
super().__init__(domain, center, radius, identity, open_set=open_set)
|
|
803
|
+
|
|
804
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
805
|
+
"""
|
|
806
|
+
Returns True if x lies within the ball.
|
|
807
|
+
Optimized to use geometric distance ||x-c|| directly.
|
|
808
|
+
"""
|
|
809
|
+
diff = self.domain.subtract(x, self.center)
|
|
810
|
+
dist = self.domain.norm(diff)
|
|
811
|
+
margin = rtol * max(1.0, self.radius)
|
|
812
|
+
if self.is_open:
|
|
813
|
+
return dist < self.radius + margin
|
|
814
|
+
else:
|
|
815
|
+
return dist <= self.radius + margin
|
|
816
|
+
|
|
817
|
+
@property
|
|
818
|
+
def boundary(self) -> Subset:
|
|
819
|
+
"""Returns the Sphere bounding this Ball."""
|
|
820
|
+
return Sphere(self.domain, self.center, self.radius)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class Sphere(EllipsoidSurface):
|
|
824
|
+
"""
|
|
825
|
+
Represents a sphere in a Hilbert space: S = {x | ||x - c||^2 = r^2}.
|
|
826
|
+
This is an EllipsoidSurface where A is the Identity operator.
|
|
827
|
+
"""
|
|
828
|
+
|
|
829
|
+
def __init__(self, domain: HilbertSpace, center: Vector, radius: float) -> None:
|
|
830
|
+
"""
|
|
831
|
+
Args:
|
|
832
|
+
domain: The Hilbert space.
|
|
833
|
+
center: The center vector c.
|
|
834
|
+
radius: The radius r.
|
|
835
|
+
"""
|
|
836
|
+
identity = domain.identity_operator()
|
|
837
|
+
super().__init__(domain, center, radius, identity)
|
|
838
|
+
|
|
839
|
+
def is_element(self, x: Vector, /, *, rtol: float = 1e-6) -> bool:
|
|
840
|
+
"""
|
|
841
|
+
Returns True if ||x - c|| is approximately equal to r.
|
|
842
|
+
"""
|
|
843
|
+
diff = self.domain.subtract(x, self.center)
|
|
844
|
+
dist = self.domain.norm(diff)
|
|
845
|
+
return abs(dist - self.radius) <= rtol * max(1.0, self.radius)
|