soar-lite 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.
- soar_lite/__init__.py +77 -0
- soar_lite/domain.py +368 -0
- soar_lite/engine/__init__.py +64 -0
- soar_lite/engine/contracts.py +761 -0
- soar_lite/engine/schedule.py +1296 -0
- soar_lite/global_model/__init__.py +39 -0
- soar_lite/global_model/blr.py +507 -0
- soar_lite/global_model/descent.py +98 -0
- soar_lite/global_model/projection.py +266 -0
- soar_lite/global_model/sample.py +56 -0
- soar_lite/local_solver.py +366 -0
- soar_lite/py.typed +0 -0
- soar_lite-0.1.0.dist-info/METADATA +269 -0
- soar_lite-0.1.0.dist-info/RECORD +16 -0
- soar_lite-0.1.0.dist-info/WHEEL +4 -0
- soar_lite-0.1.0.dist-info/licenses/LICENSE +21 -0
soar_lite/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""soar_lite: first-order n-path descent with stall restarts.
|
|
2
|
+
|
|
3
|
+
The public face is deliberate and small. The two plugin contracts
|
|
4
|
+
(`LocalSolver`, `GlobalModel`) and the data types they exchange are re-exported
|
|
5
|
+
from the engine so callers can write their own methods and restart-point models
|
|
6
|
+
against them; the concrete implementations (`DiagonalPreconditioner`,
|
|
7
|
+
`SampleGlobalModel`, `BLRGlobalModel`) are re-exported for convenience.
|
|
8
|
+
`n_path_schedule` is the whole algorithm entry point.
|
|
9
|
+
|
|
10
|
+
Whether a stalled path is re-drawn is the `restart_stalled` knob on
|
|
11
|
+
`n_path_schedule`, not a plugin: *when* a path has stopped is the local solver's
|
|
12
|
+
own stopping test, and *where* it restarts is the global model's, so the engine
|
|
13
|
+
needs no third plugin seam to join them.
|
|
14
|
+
|
|
15
|
+
Internal helpers (the box/argmin utilities, the run's private state holders) are
|
|
16
|
+
not re-exported here on purpose; they are `_`-private in their own modules. A
|
|
17
|
+
test or a contributor may still import them, but nothing outside the package is
|
|
18
|
+
meant to.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from soar_lite.domain import Bounds, Domain, DrawFn
|
|
22
|
+
from soar_lite.engine import (
|
|
23
|
+
EvalF,
|
|
24
|
+
EvalFG,
|
|
25
|
+
GlobalModel,
|
|
26
|
+
LocalSolver,
|
|
27
|
+
NPathResult,
|
|
28
|
+
NPathStatus,
|
|
29
|
+
PathPoint,
|
|
30
|
+
Point,
|
|
31
|
+
ServeTrials,
|
|
32
|
+
ValuePoint,
|
|
33
|
+
Work,
|
|
34
|
+
n_path_schedule,
|
|
35
|
+
pointwise,
|
|
36
|
+
)
|
|
37
|
+
from soar_lite.global_model import (
|
|
38
|
+
AcquisitionFn,
|
|
39
|
+
BLRGlobalModel,
|
|
40
|
+
IdentityProjection,
|
|
41
|
+
MinimizeFn,
|
|
42
|
+
Projection,
|
|
43
|
+
ProjectionFactory,
|
|
44
|
+
SampleGlobalModel,
|
|
45
|
+
SphereProjection,
|
|
46
|
+
descent_minimize,
|
|
47
|
+
)
|
|
48
|
+
from soar_lite.local_solver import DiagonalPreconditioner
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"AcquisitionFn",
|
|
52
|
+
"BLRGlobalModel",
|
|
53
|
+
"Bounds",
|
|
54
|
+
"DiagonalPreconditioner",
|
|
55
|
+
"Domain",
|
|
56
|
+
"DrawFn",
|
|
57
|
+
"EvalF",
|
|
58
|
+
"EvalFG",
|
|
59
|
+
"GlobalModel",
|
|
60
|
+
"IdentityProjection",
|
|
61
|
+
"LocalSolver",
|
|
62
|
+
"MinimizeFn",
|
|
63
|
+
"NPathResult",
|
|
64
|
+
"NPathStatus",
|
|
65
|
+
"PathPoint",
|
|
66
|
+
"Point",
|
|
67
|
+
"Projection",
|
|
68
|
+
"ProjectionFactory",
|
|
69
|
+
"SampleGlobalModel",
|
|
70
|
+
"ServeTrials",
|
|
71
|
+
"SphereProjection",
|
|
72
|
+
"ValuePoint",
|
|
73
|
+
"Work",
|
|
74
|
+
"descent_minimize",
|
|
75
|
+
"n_path_schedule",
|
|
76
|
+
"pointwise",
|
|
77
|
+
]
|
soar_lite/domain.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""The search domain: a declared box, its per-dimension scales and its draws.
|
|
2
|
+
|
|
3
|
+
This module owns the coordinate space a restart model reads and the distribution
|
|
4
|
+
it proposes restart points from - the box (any side possibly unbounded), each
|
|
5
|
+
dimension's prior width in coordinate units, and the per-dimension draw, either
|
|
6
|
+
the default for that dimension or one the caller injected. It is the object a
|
|
7
|
+
`Projection` is built on: the geometry adds a `features` map, this owns
|
|
8
|
+
everything about *where* the model may look and *how* a start is proposed, so a
|
|
9
|
+
model-free restart sampler needs nothing but this and a key.
|
|
10
|
+
|
|
11
|
+
The box's own spelling lives here too - `Bounds`, and the validation and
|
|
12
|
+
normalization behind it. This module imports nothing from the package, so it is
|
|
13
|
+
the leaf both `n_path_schedule` and the model modules read a box from; the
|
|
14
|
+
check that holds every point to it stays the engine's.
|
|
15
|
+
|
|
16
|
+
A side may be unbounded, and there are three cases, not two: a bounded dimension
|
|
17
|
+
draws uniformly, a free one (both sides infinite) draws ``N(0, scale_i^2)``, and
|
|
18
|
+
a half-bounded one (exactly one finite side) draws that same normal *truncated*
|
|
19
|
+
to its declared side - the default never steps over the side the caller declared,
|
|
20
|
+
because a start outside it is not a start in the declared domain at all.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
from collections.abc import Callable, Sequence
|
|
25
|
+
from typing import TypeAlias
|
|
26
|
+
|
|
27
|
+
import jax
|
|
28
|
+
import jax.numpy as jnp
|
|
29
|
+
from jax.scipy.special import ndtr, ndtri
|
|
30
|
+
|
|
31
|
+
Bounds: TypeAlias = Sequence[tuple[float | None, float | None]] | jnp.ndarray
|
|
32
|
+
"""The search box in scipy's spelling, which `Domain` normalizes.
|
|
33
|
+
|
|
34
|
+
One ``(lb, ub)`` pair per dimension, where either side of the pair - the lower
|
|
35
|
+
or the upper bound - may be ``None`` or ``+-inf``, meaning unbounded there. A
|
|
36
|
+
``(d, 2)`` array is the same data row-wise, for callers that already hold one.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
DrawFn: TypeAlias = Callable[[jnp.ndarray, int], jnp.ndarray]
|
|
40
|
+
"""One dimension's draw, injected per dimension.
|
|
41
|
+
|
|
42
|
+
``sampler(key, n)`` returns ``n`` coordinates for that one dimension, shape
|
|
43
|
+
``(n,)``. Injected per dimension so a restart model can sample where a bounded
|
|
44
|
+
box does not reach; ``None`` leaves the domain's own default in place - uniform
|
|
45
|
+
inside a bounded side, ``N(0, scale_i**2)`` where the domain is free, and that
|
|
46
|
+
normal restricted to the declared side where it is half-bounded.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Domain:
|
|
51
|
+
"""The coordinates a restart model may read, and how a start is drawn.
|
|
52
|
+
|
|
53
|
+
A domain carries the box, the per-dimension scales and the per-dimension
|
|
54
|
+
draws, and validates all three once at construction: a NaN side, a lower side
|
|
55
|
+
above its upper, a scale that is not a ``(d,)`` vector of finite positive
|
|
56
|
+
values, or a sampler count that is not ``d`` are refused here rather than
|
|
57
|
+
deep inside a clip or a draw. It is concrete - a caller sampling with no
|
|
58
|
+
surrogate needs nothing more - and `Projection` wraps one to add the feature
|
|
59
|
+
map a fitted model reads.
|
|
60
|
+
|
|
61
|
+
A side may be unbounded. The box then declares only the dimension and which
|
|
62
|
+
sides are unbounded; `scale` is what carries a length there, because no width
|
|
63
|
+
exists to read one off. Not even a bounded side's width is a natural unit:
|
|
64
|
+
`scale` is in coordinate units, so a caller changing a variable from meters
|
|
65
|
+
to millimeters changes `scale`, never the box.
|
|
66
|
+
|
|
67
|
+
`draw` is the one contract: ``(key, n) -> (n, d)`` starts drawn one dimension
|
|
68
|
+
at a time.
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
Sample starts with no engine and no surrogate, the case a domain alone
|
|
72
|
+
covers:
|
|
73
|
+
|
|
74
|
+
>>> import jax
|
|
75
|
+
>>> from soar_lite import Domain
|
|
76
|
+
|
|
77
|
+
>>> domain = Domain([(0.0, 1.0), (None, None)], scale=[1.0, 2.0])
|
|
78
|
+
>>> domain.draw(jax.random.PRNGKey(0), 3).shape
|
|
79
|
+
(3, 2)
|
|
80
|
+
|
|
81
|
+
The first column is uniform inside its bounded side, the second draws
|
|
82
|
+
``N(0, 2^2)`` because nothing there is declared.
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
d: int
|
|
87
|
+
"""The dimension: the number of pairs `bounds` declares.
|
|
88
|
+
|
|
89
|
+
Every shape in the engine is read off it - ``x0`` is ``(n_paths, d)``, a
|
|
90
|
+
proposal is ``(d,)`` - so a caller never states the dimension twice.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
lo: jnp.ndarray
|
|
94
|
+
"""The lower side per dimension, ``(d,)``; ``-inf`` where that side is free."""
|
|
95
|
+
|
|
96
|
+
hi: jnp.ndarray
|
|
97
|
+
"""The upper side per dimension, ``(d,)``; ``+inf`` where that side is free."""
|
|
98
|
+
|
|
99
|
+
bounds: Bounds
|
|
100
|
+
"""The box as the caller declared it, unbounded sides included."""
|
|
101
|
+
|
|
102
|
+
scale: jnp.ndarray
|
|
103
|
+
"""The prior width per dimension, ``(d,)``, in coordinate units.
|
|
104
|
+
|
|
105
|
+
It is the length the default draw is measured in on an unbounded side, and
|
|
106
|
+
what `SphereProjection` normalizes by; a dimension whose spread is far from
|
|
107
|
+
a unit scale needs its own entry here rather than a different box.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
center: jnp.ndarray
|
|
111
|
+
"""The box's midpoint per dimension, ``(d,)``; zero outside `bounded_dims`."""
|
|
112
|
+
|
|
113
|
+
width: jnp.ndarray
|
|
114
|
+
"""The box's width per dimension, ``(d,)``; zero outside `bounded_dims`."""
|
|
115
|
+
|
|
116
|
+
bounded_dims: jnp.ndarray
|
|
117
|
+
"""The dimensions with both sides finite, ``(d,)``.
|
|
118
|
+
|
|
119
|
+
`width` and `center` read off it, so a dimension outside it is zero in
|
|
120
|
+
both.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
free_dims: jnp.ndarray
|
|
124
|
+
"""The dimensions with neither side finite, ``(d,)``.
|
|
125
|
+
|
|
126
|
+
A dimension in neither mask is the half-bounded one, whose default draw
|
|
127
|
+
alone has a boundary to respect.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
bounds: Bounds,
|
|
133
|
+
*,
|
|
134
|
+
scale: jnp.ndarray | Sequence[float] | None = None,
|
|
135
|
+
samplers: Sequence[DrawFn | None] | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Validate and carry the declared box, the scales and the samplers.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
bounds: the box, scipy-style pairs or a ``(d, 2)`` array. A side may
|
|
141
|
+
be ``None`` or ``+-inf`` for an unbounded domain; a NaN side, or
|
|
142
|
+
a lower side above its upper bound, is refused. There is no box
|
|
143
|
+
for a domain to default to: the dimension and the unbounded
|
|
144
|
+
sides are what the box declares.
|
|
145
|
+
scale: per-dimension prior width in coordinate units, a ``(d,)``
|
|
146
|
+
vector or ``None`` for every dimension at one. A projection that
|
|
147
|
+
normalizes compares the entries to each other. The common
|
|
148
|
+
magnitude is not free either: `scale` is the length the default
|
|
149
|
+
draw is measured in, so a dimension whose coordinates span far
|
|
150
|
+
more or far less than a unit scale should carry its own entry.
|
|
151
|
+
samplers: one optional draw per dimension, ``d`` entries; a ``None``
|
|
152
|
+
entry keeps the default for that dimension - uniform inside a
|
|
153
|
+
bounded dimension, ``N(0, scale_i^2)`` inside a free one, and
|
|
154
|
+
that same normal restricted to the domain inside a half-bounded
|
|
155
|
+
one (exactly one finite side), where drawing the unrestricted
|
|
156
|
+
normal would step over the side the caller did declare.
|
|
157
|
+
Dimensions are drawn independently, so a prior that couples them
|
|
158
|
+
needs its own `GlobalModel`, not a domain.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: If the box is malformed, or `scale` is not a ``(d,)``
|
|
162
|
+
vector of finite positive values, or `samplers` is not ``d``
|
|
163
|
+
entries long.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
self.lo, self.hi = _check_bounds(bounds)
|
|
167
|
+
# The dimension is what `bounds` declares: read it off the argument, not
|
|
168
|
+
# off `lo`/`hi`, which `_check_bounds` just derived from it.
|
|
169
|
+
self.d = len(bounds)
|
|
170
|
+
self.bounds = bounds
|
|
171
|
+
self.scale = _check_scale(scale, self.d)
|
|
172
|
+
self._samplers = _check_samplers(samplers, self.d)
|
|
173
|
+
# The domain's shape per dimension. An unbounded side has no width, so
|
|
174
|
+
# `width` is zero there and the center falls back to the origin - the
|
|
175
|
+
# only center an unbounded dimension has, and `scale` the only length
|
|
176
|
+
# a caller can state about it.
|
|
177
|
+
self.bounded_dims = jnp.isfinite(self.lo) & jnp.isfinite(self.hi)
|
|
178
|
+
# Neither side finite. The half-bounded dimension - one side finite,
|
|
179
|
+
# one not - is the remaining case, and the only one whose default draw
|
|
180
|
+
# has a boundary to respect.
|
|
181
|
+
self.free_dims = ~jnp.isfinite(self.lo) & ~jnp.isfinite(self.hi)
|
|
182
|
+
self.width = jnp.where(self.bounded_dims, self.hi - self.lo, 0.0)
|
|
183
|
+
self.center = jnp.where(self.bounded_dims, self.lo + self.width / 2.0, 0.0)
|
|
184
|
+
|
|
185
|
+
def draw(self, key: jnp.ndarray, n_points: int) -> jnp.ndarray:
|
|
186
|
+
"""Return ``(n_points, d)`` restart starts, one dimension at a time.
|
|
187
|
+
|
|
188
|
+
A dimension with an injected `DrawFn` takes its column from that, else
|
|
189
|
+
from that dimension's default: uniform inside a bounded side,
|
|
190
|
+
``N(0, scale_i^2)`` inside a free one, and the same normal *truncated* to
|
|
191
|
+
the domain inside a half-bounded one - the default never steps over the
|
|
192
|
+
side the caller declared. An injected sampler is the caller's own
|
|
193
|
+
statement about the dimension's spread, so nothing checks the drawn
|
|
194
|
+
values against `scale`: an arbitrary distribution publishes no spread to
|
|
195
|
+
check them against.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ValueError: If `n_points` is below one, or a column does not come
|
|
199
|
+
back ``(n_points,)`` long.
|
|
200
|
+
|
|
201
|
+
"""
|
|
202
|
+
if n_points <= 0:
|
|
203
|
+
msg = f"a draw needs at least one point, got {n_points}"
|
|
204
|
+
raise ValueError(msg)
|
|
205
|
+
columns: list[jnp.ndarray] = []
|
|
206
|
+
for j, sub in enumerate(jax.random.split(key, self.d)):
|
|
207
|
+
sampler = self._samplers[j]
|
|
208
|
+
if sampler is not None:
|
|
209
|
+
column = sampler(sub, n_points)
|
|
210
|
+
elif bool(self.bounded_dims[j]):
|
|
211
|
+
column = jax.random.uniform(
|
|
212
|
+
sub,
|
|
213
|
+
(n_points,),
|
|
214
|
+
minval=self.lo[j],
|
|
215
|
+
maxval=self.hi[j],
|
|
216
|
+
)
|
|
217
|
+
elif bool(self.free_dims[j]):
|
|
218
|
+
column = self.scale[j] * jax.random.normal(sub, (n_points,))
|
|
219
|
+
else:
|
|
220
|
+
column = _truncated_normal(
|
|
221
|
+
sub,
|
|
222
|
+
n_points,
|
|
223
|
+
lower=float(self.lo[j]),
|
|
224
|
+
upper=float(self.hi[j]),
|
|
225
|
+
scale=float(self.scale[j]),
|
|
226
|
+
)
|
|
227
|
+
if jnp.shape(column) != (n_points,):
|
|
228
|
+
msg = (
|
|
229
|
+
f"each drawn column must be ({n_points},) long, got "
|
|
230
|
+
f"{jnp.shape(column)} for dimension {j}"
|
|
231
|
+
)
|
|
232
|
+
raise ValueError(msg)
|
|
233
|
+
columns.append(column)
|
|
234
|
+
return jnp.stack(columns, axis=-1)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _check_scale(scale: jnp.ndarray | Sequence[float] | None, d: int) -> jnp.ndarray:
|
|
238
|
+
"""Normalize the per-dimension prior width to ``(d,)``, refusing a bad one.
|
|
239
|
+
|
|
240
|
+
``None`` is every dimension at one, which puts the coordinate unit itself in
|
|
241
|
+
charge: the caller has either made each variable's spread O(1) or is about to
|
|
242
|
+
say what it is. Anything but a ``(d,)`` vector is refused rather than
|
|
243
|
+
broadcast: a scalar or a length-one vector would silently become a uniform
|
|
244
|
+
vector, which is a real scale but never what a caller reaching for those
|
|
245
|
+
shapes means.
|
|
246
|
+
"""
|
|
247
|
+
if scale is None:
|
|
248
|
+
return jnp.ones(d)
|
|
249
|
+
given = jnp.asarray(scale)
|
|
250
|
+
if given.ndim != 1 or given.shape[0] != d:
|
|
251
|
+
msg = f"scale must be a ({d},) vector or None, got shape {given.shape}"
|
|
252
|
+
raise ValueError(msg)
|
|
253
|
+
if not bool(jnp.all(jnp.isfinite(given))) or not bool(jnp.all(given > 0.0)):
|
|
254
|
+
msg = f"scale must hold finite positive values, got {scale!r}"
|
|
255
|
+
raise ValueError(msg)
|
|
256
|
+
return given
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _check_samplers(
|
|
260
|
+
samplers: Sequence[DrawFn | None] | None,
|
|
261
|
+
d: int,
|
|
262
|
+
) -> tuple[DrawFn | None, ...]:
|
|
263
|
+
"""Normalize the injected draws to one entry per dimension.
|
|
264
|
+
|
|
265
|
+
``None`` is every dimension on its default draw. The length is checked
|
|
266
|
+
because zipping a short sequence would leave the tail dimensions silently on
|
|
267
|
+
the default - a wiring error that would look like a working run.
|
|
268
|
+
"""
|
|
269
|
+
if samplers is None:
|
|
270
|
+
return (None,) * d
|
|
271
|
+
given = tuple(samplers)
|
|
272
|
+
if len(given) != d:
|
|
273
|
+
msg = f"samplers needs {d} entries, got {len(given)}"
|
|
274
|
+
raise ValueError(msg)
|
|
275
|
+
return given
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _check_bounds(bounds: Bounds) -> tuple[jnp.ndarray, jnp.ndarray]:
|
|
279
|
+
"""Reject a malformed box and normalize it to ``(lo, hi)``, in one pass.
|
|
280
|
+
|
|
281
|
+
Both spellings are read the same way: one `(lb, ub)` pair per dim, or a
|
|
282
|
+
`(d, 2)` array. A `None` side becomes ∓inf before anything is looked at, so
|
|
283
|
+
the check and the box it hands out cannot disagree about what a side means.
|
|
284
|
+
A NaN side is rejected - it would poison every clip silently instead of
|
|
285
|
+
failing; +-inf sides are legitimate (unbounded dim).
|
|
286
|
+
"""
|
|
287
|
+
pairs: list[tuple[float | None, float | None]]
|
|
288
|
+
if isinstance(bounds, jnp.ndarray):
|
|
289
|
+
if bounds.ndim != 2 or bounds.shape[1] != 2:
|
|
290
|
+
msg = f"bounds as an array needs shape (d, 2), got {bounds.shape}"
|
|
291
|
+
raise ValueError(msg)
|
|
292
|
+
pairs = list(zip(bounds[:, 0].tolist(), bounds[:, 1].tolist(), strict=True))
|
|
293
|
+
else:
|
|
294
|
+
pairs = []
|
|
295
|
+
for j, pair in enumerate(bounds):
|
|
296
|
+
if len(pair) != 2:
|
|
297
|
+
msg = f"bounds[{j}] must be a (lb, ub) pair, got {pair!r}"
|
|
298
|
+
raise ValueError(msg)
|
|
299
|
+
pairs.append((pair[0], pair[1]))
|
|
300
|
+
lo: list[float] = []
|
|
301
|
+
hi: list[float] = []
|
|
302
|
+
for j, (low, high) in enumerate(pairs):
|
|
303
|
+
lo_f = -math.inf if low is None else low
|
|
304
|
+
hi_f = math.inf if high is None else high
|
|
305
|
+
if math.isnan(lo_f) or math.isnan(hi_f):
|
|
306
|
+
msg = f"bounds[{j}] has a NaN side: ({low!r}, {high!r})"
|
|
307
|
+
raise ValueError(msg)
|
|
308
|
+
if lo_f > hi_f:
|
|
309
|
+
msg = f"bounds[{j}] has lo={lo_f} > hi={hi_f}"
|
|
310
|
+
raise ValueError(msg)
|
|
311
|
+
lo.append(lo_f)
|
|
312
|
+
hi.append(hi_f)
|
|
313
|
+
return jnp.asarray(lo), jnp.asarray(hi)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_TRUNCATED_TAIL = 9.0
|
|
317
|
+
"""Clip the standard-normal draw to this many scales either way.
|
|
318
|
+
|
|
319
|
+
Only the domain's own endpoints can push the CDF inverse past it: `ndtri` is
|
|
320
|
+
undefined at 0 and 1, and a truncation far into the tail rounds both bounds of
|
|
321
|
+
the uniform interval onto the same endpoint. The clip keeps such a draw finite
|
|
322
|
+
and costs nothing else - beyond nine scales the normal's mass is negligible, so
|
|
323
|
+
no reachable draw is touched, while a domain truncated that far out collapses
|
|
324
|
+
onto its own boundary, which is the right limit anyway.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _truncated_normal(
|
|
329
|
+
key: jnp.ndarray,
|
|
330
|
+
n_points: int,
|
|
331
|
+
*,
|
|
332
|
+
lower: float,
|
|
333
|
+
upper: float,
|
|
334
|
+
scale: float,
|
|
335
|
+
) -> jnp.ndarray:
|
|
336
|
+
"""Draw ``N(0, scale^2)`` restricted to ``[lower, upper]``.
|
|
337
|
+
|
|
338
|
+
Both bounds take an infinity, so this one routine serves either orientation
|
|
339
|
+
of a half-bounded domain. Inverse-CDF because the alternative - draw and
|
|
340
|
+
reject - costs a variable number of rounds that JAX cannot express without
|
|
341
|
+
a fixed cap, which would bias whatever the cap cuts off.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
key: the PRNG key to draw from.
|
|
345
|
+
n_points: how many values to draw.
|
|
346
|
+
lower: the domain's lower bound, possibly ``-inf``.
|
|
347
|
+
upper: the domain's upper bound, possibly ``+inf``.
|
|
348
|
+
scale: the prior's standard deviation, in coordinate units.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
``(n_points,)`` finite values, all inside ``[lower, upper]``.
|
|
352
|
+
|
|
353
|
+
"""
|
|
354
|
+
v = jax.random.uniform(key, (n_points,))
|
|
355
|
+
# Sample the *tail* probability, not the CDF value. A half-line is one tail,
|
|
356
|
+
# and a truncation far out makes that tail small enough that `1 - Phi(a)` and
|
|
357
|
+
# `Phi(a)` agree to the last float32 - the CDF value loses its precision
|
|
358
|
+
# exactly where the truncated mass still lives. Multiplying the small tail by
|
|
359
|
+
# `v` keeps the relative precision, so the draw tracks the true truncated
|
|
360
|
+
# normal instead of piling its far tail onto one clipped value.
|
|
361
|
+
if lower == -math.inf:
|
|
362
|
+
p = ndtr(jnp.asarray(upper) / scale)
|
|
363
|
+
z = ndtri(p * v)
|
|
364
|
+
else:
|
|
365
|
+
p = ndtr(-jnp.asarray(lower) / scale)
|
|
366
|
+
z = -ndtri(p * (1.0 - v))
|
|
367
|
+
z = jnp.clip(z, -_TRUNCATED_TAIL, _TRUNCATED_TAIL)
|
|
368
|
+
return jnp.clip(scale * z, lower, upper)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The engine package: the contracts a plugin is written against, and the machine.
|
|
2
|
+
|
|
3
|
+
This package is two modules behind one facade.
|
|
4
|
+
`soar_lite.engine.contracts` is what a plugin author reads: the two plugin
|
|
5
|
+
contracts (`LocalSolver`, `GlobalModel`), the data they exchange (`Point`,
|
|
6
|
+
`ValuePoint`, `PathPoint`, `Work`, `NPathResult`, `NPathStatus`), the
|
|
7
|
+
evaluation-seam aliases, and `pointwise`. `soar_lite.engine.schedule` is the
|
|
8
|
+
machine: the `n_path_schedule` loop and the four private holders a run's state
|
|
9
|
+
lives in.
|
|
10
|
+
This module re-exports the public half, so `from soar_lite.engine import
|
|
11
|
+
LocalSolver` names the contract without a caller knowing which file holds it.
|
|
12
|
+
|
|
13
|
+
The dependency direction is one-way - plugins import these types, never the
|
|
14
|
+
reverse:
|
|
15
|
+
|
|
16
|
+
engine/contracts.py <- LocalSolver, GlobalModel, Point, ValuePoint, PathPoint
|
|
17
|
+
engine/schedule.py <- n_path_schedule and the four holders
|
|
18
|
+
domain.py <- Domain, Bounds (the box; a leaf)
|
|
19
|
+
local_solver.py <- DiagonalPreconditioner (implements LocalSolver)
|
|
20
|
+
global_model/ <- SampleGlobalModel, BLRGlobalModel and its projections
|
|
21
|
+
|
|
22
|
+
The two concrete defaults are bound lazily inside `n_path_schedule`, which keeps
|
|
23
|
+
that graph acyclic. The engine is first-order (value and gradient),
|
|
24
|
+
coordinate-free, and owns the flock, the domain and its box test, the shared horizon
|
|
25
|
+
and the restart cadence; the local solver owns direction, step size, acceptance
|
|
26
|
+
and its step's enumeration, and the global model only where a restart lands.
|
|
27
|
+
|
|
28
|
+
Restarting is two plugin seams and one flag, not a third plugin: `_Paths` marks a
|
|
29
|
+
path stalled when its method gave up or its point was not usable,
|
|
30
|
+
`restart_stalled` says whether such a path is re-drawn, and `GlobalModel.propose`
|
|
31
|
+
says where.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from soar_lite.engine.contracts import (
|
|
35
|
+
EvalF,
|
|
36
|
+
EvalFG,
|
|
37
|
+
GlobalModel,
|
|
38
|
+
LocalSolver,
|
|
39
|
+
NPathResult,
|
|
40
|
+
NPathStatus,
|
|
41
|
+
PathPoint,
|
|
42
|
+
Point,
|
|
43
|
+
ServeTrials,
|
|
44
|
+
ValuePoint,
|
|
45
|
+
Work,
|
|
46
|
+
pointwise,
|
|
47
|
+
)
|
|
48
|
+
from soar_lite.engine.schedule import n_path_schedule
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"EvalF",
|
|
52
|
+
"EvalFG",
|
|
53
|
+
"GlobalModel",
|
|
54
|
+
"LocalSolver",
|
|
55
|
+
"NPathResult",
|
|
56
|
+
"NPathStatus",
|
|
57
|
+
"PathPoint",
|
|
58
|
+
"Point",
|
|
59
|
+
"ServeTrials",
|
|
60
|
+
"ValuePoint",
|
|
61
|
+
"Work",
|
|
62
|
+
"n_path_schedule",
|
|
63
|
+
"pointwise",
|
|
64
|
+
]
|