emutrust 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.
emutrust/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """emutrust: neural network emulators for slow scientific models, with
2
+ calibrated uncertainty you can trust in Bayesian inference."""
3
+
4
+ from .emulator import Emulator
5
+ from .likelihood import GaussianLikelihood
6
+ from .runner import TrainingSet, load_runs, run_model
7
+ from .space import ParameterSpace, latin_hypercube
8
+ from .validation import ValidationReport, validate
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "Emulator",
14
+ "GaussianLikelihood",
15
+ "ParameterSpace",
16
+ "TrainingSet",
17
+ "ValidationReport",
18
+ "latin_hypercube",
19
+ "load_runs",
20
+ "run_model",
21
+ "validate",
22
+ "__version__",
23
+ ]
emutrust/emulator.py ADDED
@@ -0,0 +1,311 @@
1
+ """Neural network emulators with ensemble uncertainty."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Mapping, Sequence
7
+ from pathlib import Path
8
+ from typing import Union
9
+
10
+ import numpy as np
11
+ import torch
12
+ from torch import nn
13
+
14
+ from .runner import TrainingSet
15
+
16
+ PathLike = Union[str, Path]
17
+
18
+
19
+ class Standardizer:
20
+ """Shift and scale each column to mean 0 and standard deviation 1.
21
+
22
+ Neural networks train best when every input and output has a similar
23
+ size; without this, a parameter like H0 (~70) would swamp one like
24
+ omch2 (~0.12).
25
+ """
26
+
27
+ def __init__(self, mean: np.ndarray, std: np.ndarray):
28
+ self.mean = np.asarray(mean, dtype=float)
29
+ self.std = np.asarray(std, dtype=float)
30
+
31
+ @classmethod
32
+ def fit(cls, data: np.ndarray) -> Standardizer:
33
+ return cls(data.mean(axis=0), data.std(axis=0))
34
+
35
+ def transform(self, data: np.ndarray) -> np.ndarray:
36
+ # A column that never changes has std 0; it maps to 0 instead of
37
+ # dividing by zero.
38
+ return (data - self.mean) / np.where(self.std > 0, self.std, 1.0)
39
+
40
+ def inverse(self, data: np.ndarray) -> np.ndarray:
41
+ # Multiplying by std 0 returns a constant column exactly, whatever
42
+ # the network outputs for it.
43
+ return data * self.std + self.mean
44
+
45
+
46
+ def _should_use_log(Y: np.ndarray) -> bool:
47
+ """Use log outputs when every output is positive and spans >2 decades."""
48
+ if np.any(Y <= 0):
49
+ return False
50
+ return bool(np.max(np.log10(Y.max(axis=0) / Y.min(axis=0))) > 2.0)
51
+
52
+
53
+ def _make_network(n_in: int, n_out: int, hidden: Sequence[int]) -> nn.Sequential:
54
+ layers: list[nn.Module] = []
55
+ width = n_in
56
+ for h in hidden:
57
+ layers += [nn.Linear(width, h), nn.SiLU()]
58
+ width = h
59
+ layers.append(nn.Linear(width, n_out))
60
+ return nn.Sequential(*layers)
61
+
62
+
63
+ class Emulator:
64
+ """A fast stand-in for a slow model, built from an ensemble of networks.
65
+
66
+ Several networks are trained on the same training set, each starting
67
+ from different random weights and seeing a different random split of
68
+ the data. Where the training data pin the answer down, they agree;
69
+ where data are thin or the function is hard to learn, they disagree.
70
+ The spread of their predictions is the emulator's estimate of its own
71
+ error.
72
+
73
+ Example
74
+ -------
75
+ >>> emu = Emulator.fit(training, n_members=5, seed=0)
76
+ >>> mean, std = emu.predict({"H0": 67.0, "omch2": 0.12, "ns": 0.96})
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ members: list[nn.Sequential],
82
+ x_scaler: Standardizer,
83
+ y_scaler: Standardizer,
84
+ log_outputs: bool,
85
+ names: tuple[str, ...],
86
+ config: dict,
87
+ std_scale: float = 1.0,
88
+ ):
89
+ self.members = members
90
+ self.x_scaler = x_scaler
91
+ self.y_scaler = y_scaler
92
+ self.log_outputs = log_outputs
93
+ self.names = tuple(names)
94
+ self.config = config
95
+ #: Multiplies every predicted std. 1.0 means the raw ensemble spread;
96
+ #: set it from a validation report if the spread is too small or large.
97
+ self.std_scale = float(std_scale)
98
+ for net in self.members:
99
+ net.eval()
100
+
101
+ @property
102
+ def bounds(self) -> tuple[np.ndarray, np.ndarray]:
103
+ """Lowest and highest value of each parameter in the training set.
104
+
105
+ The emulator has only seen the model inside this box; predictions
106
+ outside it are extrapolations and should not be trusted.
107
+ """
108
+ return np.asarray(self.config["train_low"]), np.asarray(self.config["train_high"])
109
+
110
+ @property
111
+ def n_members(self) -> int:
112
+ return len(self.members)
113
+
114
+ # ------------------------------------------------------------------ fit
115
+ @classmethod
116
+ def fit(
117
+ cls,
118
+ training: TrainingSet,
119
+ n_members: int = 5,
120
+ hidden: Sequence[int] = (128, 128, 128),
121
+ epochs: int = 2000,
122
+ batch_size: int = 64,
123
+ learning_rate: float = 1e-3,
124
+ val_fraction: float = 0.1,
125
+ patience: int = 200,
126
+ log_outputs: bool | None = None,
127
+ seed: int | None = None,
128
+ verbose: bool = False,
129
+ ) -> Emulator:
130
+ """Train an ensemble of ``n_members`` networks on a training set.
131
+
132
+ ``log_outputs`` controls whether the networks learn ``log10`` of the
133
+ outputs. ``None`` (the default) decides automatically: log is used
134
+ when every output is positive and spans more than two orders of
135
+ magnitude, as power spectra do.
136
+
137
+ Each member trains until its error on its own held-out
138
+ ``val_fraction`` of the data has not improved for ``patience``
139
+ epochs, and keeps the weights from its best epoch ("early stopping",
140
+ which prevents memorising the training points).
141
+ """
142
+ X = np.asarray(training.X, dtype=float)
143
+ Y = np.asarray(training.Y, dtype=float)
144
+ if n_members < 1:
145
+ raise ValueError("n_members must be at least 1")
146
+ if len(X) < 10:
147
+ raise ValueError(f"Need at least 10 training points, got {len(X)}")
148
+ if not 0 < val_fraction < 0.5:
149
+ raise ValueError("val_fraction must be between 0 and 0.5")
150
+
151
+ if log_outputs is None:
152
+ log_outputs = _should_use_log(Y)
153
+ elif log_outputs and np.any(Y <= 0):
154
+ raise ValueError("log_outputs=True needs strictly positive outputs")
155
+ Yt = np.log10(Y) if log_outputs else Y
156
+
157
+ x_scaler = Standardizer.fit(X)
158
+ y_scaler = Standardizer.fit(Yt)
159
+ Xs = torch.tensor(x_scaler.transform(X), dtype=torch.float32)
160
+ Ys = torch.tensor(y_scaler.transform(Yt), dtype=torch.float32)
161
+
162
+ rng = np.random.default_rng(seed)
163
+ members = []
164
+ for m in range(n_members):
165
+ member_seed = int(rng.integers(2**31))
166
+ net, best = _train_member(
167
+ Xs,
168
+ Ys,
169
+ hidden,
170
+ epochs,
171
+ batch_size,
172
+ learning_rate,
173
+ val_fraction,
174
+ patience,
175
+ member_seed,
176
+ )
177
+ if verbose:
178
+ print(f" member {m + 1}/{n_members}: best validation loss {best:.2e}")
179
+ members.append(net)
180
+
181
+ config = {
182
+ "hidden": list(hidden),
183
+ "n_inputs": X.shape[1],
184
+ "n_outputs": Y.shape[1],
185
+ "n_train": len(X),
186
+ "train_low": X.min(axis=0).tolist(),
187
+ "train_high": X.max(axis=0).tolist(),
188
+ }
189
+ return cls(members, x_scaler, y_scaler, bool(log_outputs), training.names, config)
190
+
191
+ # -------------------------------------------------------------- predict
192
+ def _as_array(self, x) -> np.ndarray:
193
+ if isinstance(x, Mapping):
194
+ missing = set(self.names) - set(x)
195
+ if missing:
196
+ raise ValueError(f"Missing parameters: {sorted(missing)}")
197
+ x = [x[n] for n in self.names]
198
+ x = np.atleast_2d(np.asarray(x, dtype=float))
199
+ if x.shape[1] != len(self.names):
200
+ raise ValueError(f"Expected {len(self.names)} parameters, got {x.shape[1]}")
201
+ return x
202
+
203
+ def predict_members(self, x) -> np.ndarray:
204
+ """Each member's prediction, shape ``(n_members, n_points, n_outputs)``."""
205
+ xs = torch.tensor(self.x_scaler.transform(self._as_array(x)), dtype=torch.float32)
206
+ with torch.no_grad():
207
+ out = np.stack([net(xs).numpy() for net in self.members]).astype(float)
208
+ out = self.y_scaler.inverse(out)
209
+ return 10.0**out if self.log_outputs else out
210
+
211
+ def predict(self, x, return_std: bool = True):
212
+ """Predict outputs at one point (a dict or a 1-D array) or many points.
213
+
214
+ Returns ``(mean, std)``: the ensemble average and the spread between
215
+ members (times :attr:`std_scale`), both in the model's original output
216
+ units. A single input point gives 1-D arrays; many points give 2-D
217
+ arrays.
218
+ """
219
+ single = isinstance(x, Mapping) or np.ndim(x) == 1
220
+ preds = self.predict_members(x)
221
+ mean = preds.mean(axis=0)
222
+ std = preds.std(axis=0, ddof=1) if self.n_members > 1 else np.zeros_like(mean)
223
+ std = std * self.std_scale
224
+ if single:
225
+ mean, std = mean[0], std[0]
226
+ return (mean, std) if return_std else mean
227
+
228
+ def __call__(self, x):
229
+ return self.predict(x, return_std=False)
230
+
231
+ # ----------------------------------------------------------- save/load
232
+ def save(self, path: PathLike) -> None:
233
+ """Save the emulator to a single file."""
234
+ torch.save(
235
+ {
236
+ "members": [net.state_dict() for net in self.members],
237
+ "x_scaler": (self.x_scaler.mean, self.x_scaler.std),
238
+ "y_scaler": (self.y_scaler.mean, self.y_scaler.std),
239
+ "log_outputs": self.log_outputs,
240
+ "names": self.names,
241
+ "config": self.config,
242
+ "std_scale": self.std_scale,
243
+ },
244
+ path,
245
+ )
246
+
247
+ @classmethod
248
+ def load(cls, path: PathLike) -> Emulator:
249
+ """Load an emulator saved with :meth:`save`."""
250
+ state = torch.load(path, weights_only=False)
251
+ cfg = state["config"]
252
+ members = []
253
+ for sd in state["members"]:
254
+ net = _make_network(cfg["n_inputs"], cfg["n_outputs"], cfg["hidden"])
255
+ net.load_state_dict(sd)
256
+ members.append(net)
257
+ return cls(
258
+ members,
259
+ Standardizer(*state["x_scaler"]),
260
+ Standardizer(*state["y_scaler"]),
261
+ state["log_outputs"],
262
+ tuple(state["names"]),
263
+ cfg,
264
+ state.get("std_scale", 1.0),
265
+ )
266
+
267
+
268
+ def _train_member(Xs, Ys, hidden, epochs, batch_size, lr, val_fraction, patience, seed):
269
+ torch.manual_seed(seed)
270
+ gen = torch.Generator().manual_seed(seed)
271
+ n = len(Xs)
272
+ perm = torch.randperm(n, generator=gen)
273
+ n_val = max(1, int(round(val_fraction * n)))
274
+ val_idx, train_idx = perm[:n_val], perm[n_val:]
275
+ Xtr, Ytr, Xval, Yval = Xs[train_idx], Ys[train_idx], Xs[val_idx], Ys[val_idx]
276
+
277
+ net = _make_network(Xs.shape[1], Ys.shape[1], hidden)
278
+ opt = torch.optim.Adam(net.parameters(), lr=lr)
279
+ sched = torch.optim.lr_scheduler.ReduceLROnPlateau(
280
+ opt, factor=0.5, patience=max(patience // 4, 1)
281
+ )
282
+ loss_fn = nn.MSELoss()
283
+
284
+ best_loss, best_state, since_best = float("inf"), None, 0
285
+ for _ in range(epochs):
286
+ net.train()
287
+ order = torch.randperm(len(Xtr), generator=gen)
288
+ for start in range(0, len(Xtr), batch_size):
289
+ idx = order[start : start + batch_size]
290
+ opt.zero_grad()
291
+ loss = loss_fn(net(Xtr[idx]), Ytr[idx])
292
+ loss.backward()
293
+ opt.step()
294
+
295
+ net.eval()
296
+ with torch.no_grad():
297
+ val_loss = loss_fn(net(Xval), Yval).item()
298
+ sched.step(val_loss)
299
+ if val_loss < best_loss:
300
+ best_loss, best_state, since_best = val_loss, copy.deepcopy(net.state_dict()), 0
301
+ else:
302
+ since_best += 1
303
+ if since_best >= patience:
304
+ break
305
+
306
+ net.load_state_dict(best_state)
307
+ net.eval()
308
+ return net, best_loss
309
+
310
+
311
+ __all__ = ["Emulator", "Standardizer"]
emutrust/likelihood.py ADDED
@@ -0,0 +1,183 @@
1
+ """Likelihoods that include the emulator's own uncertainty."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Union
6
+
7
+ import numpy as np
8
+
9
+ from .emulator import Emulator
10
+ from .validation import ValidationReport
11
+
12
+ _LOG_2PI = np.log(2 * np.pi)
13
+
14
+ EmulatorError = Union[str, ValidationReport, None]
15
+
16
+
17
+ class GaussianLikelihood:
18
+ """Gaussian likelihood of observed data, with an emulator as the model.
19
+
20
+ For parameters ``theta`` the emulator predicts the model output
21
+ ``mu(theta)``. The likelihood compares the data with ``mu`` using a
22
+ covariance that adds the data's noise and the emulator's own error::
23
+
24
+ C = C_noise + C_emulator
25
+
26
+ so an imperfect emulator widens the result instead of silently
27
+ shrinking the error bars. ``emulator_error`` chooses ``C_emulator``:
28
+
29
+ ``ValidationReport`` (recommended)
30
+ The error pattern measured on validation runs the emulator never
31
+ saw, including correlations between outputs. Emulator errors are
32
+ usually systematic (too high at one output means too high at the
33
+ next), and with many data points that correlated part dominates;
34
+ this option captures it.
35
+ ``"ensemble"``
36
+ The spread of the ensemble members at ``theta``, treating each
37
+ output's error as independent. Needs no extra model runs, but can
38
+ underestimate correlated errors when there are many outputs.
39
+ ``None``
40
+ Treat the emulator as exact. Only for comparison: this is the
41
+ overconfident likelihood that ``emutrust`` exists to avoid.
42
+
43
+ For validation-measured error, ``error_variance_kept`` sets how much of
44
+ the measured error variance is described by its main correlated
45
+ patterns (default 99.9%); the rest is kept as independent per-output
46
+ error. This makes each evaluation much faster with a negligible change
47
+ in the result. Use ``1.0`` to keep every pattern.
48
+
49
+ Parameters outside the range the emulator was trained on get a
50
+ log-likelihood of ``-inf``, so a sampler never relies on extrapolation.
51
+
52
+ Example
53
+ -------
54
+ >>> report = validate(emu, validation_runs)
55
+ >>> like = GaussianLikelihood(emu, data, noise_std, emulator_error=report)
56
+ >>> sampler = emcee.EnsembleSampler(32, like.ndim, like, vectorize=True)
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ emulator: Emulator,
62
+ data: np.ndarray,
63
+ noise_std: float | np.ndarray,
64
+ emulator_error: EmulatorError = "ensemble",
65
+ error_variance_kept: float = 0.999,
66
+ ):
67
+ self.emulator = emulator
68
+ self.data = np.asarray(data, dtype=float).ravel()
69
+ n_out = emulator.config["n_outputs"]
70
+ if self.data.size != n_out:
71
+ raise ValueError(f"data has {self.data.size} values but the emulator predicts {n_out}")
72
+ noise = np.broadcast_to(np.asarray(noise_std, dtype=float), self.data.shape)
73
+ if np.any(noise <= 0):
74
+ raise ValueError("noise_std must be positive")
75
+ self.noise_var = noise**2
76
+
77
+ if isinstance(emulator_error, ValidationReport):
78
+ if emulator_error.names != emulator.names:
79
+ raise ValueError("The validation report belongs to a different emulator")
80
+ self._kind, matrix = emulator_error.error_covariance()
81
+ self._set_error_modes(matrix, error_variance_kept)
82
+ self.emulator_error = "validation"
83
+ elif emulator_error in ("ensemble", None):
84
+ self.emulator_error = emulator_error
85
+ else:
86
+ raise ValueError("emulator_error must be a ValidationReport, 'ensemble' or None")
87
+ self.low, self.high = emulator.bounds
88
+
89
+ def _set_error_modes(self, matrix: np.ndarray, kept: float) -> None:
90
+ """Split the measured error covariance into its main patterns.
91
+
92
+ The emulator's errors are dominated by a few patterns across the
93
+ outputs. Keeping the largest eigenvectors that together explain a
94
+ fraction ``kept`` of the error variance, and treating the small
95
+ remainder as independent per-output error, gives almost exactly the
96
+ same likelihood far faster: with ``K`` patterns and ``n`` outputs,
97
+ each evaluation costs about ``n*K**2`` instead of ``n**3``. The
98
+ remainder is kept on the diagonal, so every output's total error
99
+ variance is preserved exactly.
100
+ """
101
+ if not 0 < kept <= 1:
102
+ raise ValueError("error_variance_kept must be in (0, 1]")
103
+ values, vectors = np.linalg.eigh(matrix)
104
+ values, vectors = values[::-1], vectors[:, ::-1]
105
+ values = np.clip(values, 0.0, None)
106
+ total = values.sum()
107
+ if total == 0:
108
+ k = 0
109
+ else:
110
+ k = int(np.searchsorted(np.cumsum(values) / total, kept - 1e-12) + 1)
111
+ k = min(k, int(np.sum(values > 1e-14 * values[0])))
112
+ self._modes = vectors[:, :k] # (n, K)
113
+ self._mode_var = values[:k] # (K,)
114
+ kept_diag = np.sum(self._modes**2 * self._mode_var, axis=1)
115
+ self._remainder_var = np.clip(np.diag(matrix) - kept_diag, 0.0, None) # (n,)
116
+
117
+ @property
118
+ def n_error_modes(self) -> int:
119
+ """Number of error patterns used (validation-measured error only)."""
120
+ return self._modes.shape[1] if self.emulator_error == "validation" else 0
121
+
122
+ @property
123
+ def ndim(self) -> int:
124
+ return len(self.emulator.names)
125
+
126
+ def in_bounds(self, theta: np.ndarray) -> np.ndarray:
127
+ theta = np.atleast_2d(theta)
128
+ return np.all((theta >= self.low) & (theta <= self.high), axis=1)
129
+
130
+ def log_likelihood(self, theta: np.ndarray) -> np.ndarray | float:
131
+ """Log-likelihood at one parameter vector, or at each row of many."""
132
+ theta = np.asarray(theta, dtype=float)
133
+ single = theta.ndim == 1
134
+ theta = np.atleast_2d(theta)
135
+ out = np.full(len(theta), -np.inf)
136
+ ok = self.in_bounds(theta) & np.all(np.isfinite(theta), axis=1)
137
+ if ok.any():
138
+ out[ok] = self._log_gaussian(theta[ok])
139
+ return float(out[0]) if single else out
140
+
141
+ def log_prob(self, theta: np.ndarray) -> np.ndarray | float:
142
+ """Log-posterior with a flat prior over the emulator's training range.
143
+
144
+ Accepts one parameter vector or a batch of them, so it can be used
145
+ with ``emcee.EnsembleSampler(..., vectorize=True)`` for speed.
146
+ """
147
+ return self.log_likelihood(theta)
148
+
149
+ __call__ = log_prob
150
+
151
+ def _log_gaussian(self, theta: np.ndarray) -> np.ndarray:
152
+ mean, std = self.emulator.predict(theta)
153
+ resid = self.data - mean
154
+
155
+ if self.emulator_error != "validation":
156
+ var = self.noise_var + (std**2 if self.emulator_error == "ensemble" else 0.0)
157
+ return -0.5 * np.sum(resid**2 / var + np.log(var) + _LOG_2PI, axis=1)
158
+
159
+ # C = D + A diag(lam) A^T, with D diagonal (noise plus the small
160
+ # remainder of the emulator error) and A the K main error patterns.
161
+ # For relative errors the patterns scale with the prediction.
162
+ n = resid.shape[1]
163
+ scale = mean if self._kind == "relative" else np.ones_like(mean)
164
+ d = self.noise_var + scale**2 * self._remainder_var # (P, n)
165
+ quad = np.sum(resid**2 / d, axis=1)
166
+ logdet = np.sum(np.log(d), axis=1)
167
+ if self._modes.shape[1] > 0:
168
+ # Woodbury identity and matrix determinant lemma: only a K x K
169
+ # matrix per point needs factorising.
170
+ a = scale[:, :, None] * self._modes # (P, n, K)
171
+ a_d_t = np.swapaxes(a / d[:, :, None], 1, 2) # (P, K, n)
172
+ small = a_d_t @ a # (P, K, K), batched matrix product (BLAS)
173
+ k = small.shape[1]
174
+ small[:, np.arange(k), np.arange(k)] += 1.0 / self._mode_var
175
+ chol = np.linalg.cholesky(small)
176
+ u = np.linalg.solve(chol, a_d_t @ resid[..., None])[..., 0]
177
+ quad -= np.sum(u**2, axis=1)
178
+ logdet += 2 * np.sum(np.log(np.diagonal(chol, axis1=1, axis2=2)), axis=1)
179
+ logdet += np.sum(np.log(self._mode_var))
180
+ return -0.5 * (quad + logdet + n * _LOG_2PI)
181
+
182
+
183
+ __all__ = ["GaussianLikelihood"]
emutrust/runner.py ADDED
@@ -0,0 +1,151 @@
1
+ """Run a slow model over many parameter points, with checkpointing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Union
11
+
12
+ import numpy as np
13
+
14
+ from .space import ParameterSpace, as_space
15
+
16
+ Model = Callable[[dict], Any]
17
+ PathLike = Union[str, "os.PathLike[str]"]
18
+
19
+
20
+ @dataclass
21
+ class TrainingSet:
22
+ """Inputs ``X`` (n, n_params) and model outputs ``Y`` (n, n_outputs)."""
23
+
24
+ X: np.ndarray
25
+ Y: np.ndarray
26
+ names: tuple[str, ...]
27
+ seconds_per_run: float
28
+
29
+ def __len__(self) -> int:
30
+ return len(self.X)
31
+
32
+
33
+ def run_model(
34
+ model: Model,
35
+ points: np.ndarray,
36
+ space: ParameterSpace | Mapping[str, tuple[float, float]],
37
+ checkpoint: PathLike | None = None,
38
+ progress: bool = True,
39
+ ) -> TrainingSet:
40
+ """Evaluate ``model`` at every row of ``points``.
41
+
42
+ ``model`` is any Python function that takes a ``{name: value}`` dict and
43
+ returns an array (or list, or number) of outputs. Every call must return
44
+ the same number of outputs.
45
+
46
+ If ``checkpoint`` is a file path, results are saved after every run, and
47
+ calling ``run_model`` again with the same points resumes where it stopped
48
+ instead of starting over. This matters when each run takes seconds or
49
+ minutes and the session can crash or time out.
50
+ """
51
+ space = as_space(space)
52
+ points = np.atleast_2d(np.asarray(points, dtype=float))
53
+ if points.shape[1] != space.dim:
54
+ raise ValueError(
55
+ f"points have {points.shape[1]} columns but the space has {space.dim} parameters"
56
+ )
57
+ if not np.all(space.contains(points)):
58
+ raise ValueError("Some points lie outside the parameter ranges.")
59
+
60
+ path = Path(checkpoint) if checkpoint is not None else None
61
+ outputs: list[np.ndarray] = []
62
+ elapsed = 0.0
63
+ if path is not None and path.exists():
64
+ outputs, elapsed = _load_checkpoint(path, points, space)
65
+ if progress and outputs:
66
+ print(f"Resuming from checkpoint: {len(outputs)}/{len(points)} runs already done.")
67
+
68
+ n_out = outputs[0].size if outputs else None
69
+ for i in range(len(outputs), len(points)):
70
+ t0 = time.perf_counter()
71
+ y = np.asarray(model(space.to_dict(points[i])), dtype=float).ravel()
72
+ elapsed += time.perf_counter() - t0
73
+
74
+ if n_out is None:
75
+ n_out = y.size
76
+ if y.size != n_out:
77
+ raise ValueError(
78
+ f"Run {i} returned {y.size} outputs, but earlier runs returned {n_out}."
79
+ )
80
+ if not np.all(np.isfinite(y)):
81
+ raise ValueError(
82
+ f"Run {i} returned non-finite values (NaN or inf) at {space.to_dict(points[i])}."
83
+ )
84
+ outputs.append(y)
85
+
86
+ if path is not None:
87
+ _save_checkpoint(path, points, outputs, elapsed, space)
88
+ if progress:
89
+ print(f" run {i + 1}/{len(points)} done ({elapsed / len(outputs):.2f} s per run)")
90
+
91
+ return TrainingSet(
92
+ X=points.copy(),
93
+ Y=np.vstack(outputs),
94
+ names=space.names,
95
+ seconds_per_run=elapsed / max(len(outputs), 1),
96
+ )
97
+
98
+
99
+ def _save_checkpoint(path: Path, points, outputs, elapsed, space) -> None:
100
+ # Write to a temporary file and then rename it, so a crash in the middle
101
+ # of saving can never leave a half-written, corrupted checkpoint behind.
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ tmp = path.with_name(path.name + ".tmp")
104
+ with open(tmp, "wb") as f:
105
+ np.savez(
106
+ f,
107
+ points=points,
108
+ outputs=np.vstack(outputs),
109
+ elapsed=elapsed,
110
+ names=np.array(space.names),
111
+ )
112
+ os.replace(tmp, path)
113
+
114
+
115
+ def _load_checkpoint(path: Path, points, space):
116
+ with np.load(path) as data:
117
+ saved_points = data["points"]
118
+ saved_outputs = data["outputs"]
119
+ elapsed = float(data["elapsed"])
120
+ saved_names = tuple(str(n) for n in data["names"])
121
+ if (
122
+ saved_names != space.names
123
+ or saved_points.shape != points.shape
124
+ or not np.allclose(saved_points, points)
125
+ ):
126
+ raise ValueError(
127
+ f"The checkpoint at {path} belongs to a different set of points or parameters. "
128
+ "Use a new checkpoint path, or delete the old file to start over."
129
+ )
130
+ return [row for row in saved_outputs], elapsed
131
+
132
+
133
+ def load_runs(path: PathLike) -> TrainingSet:
134
+ """Load completed runs from a ``run_model`` checkpoint file.
135
+
136
+ Useful for analysing saved runs without importing or re-running the
137
+ slow model. Only finished runs are returned, so a partly complete
138
+ checkpoint gives a shorter training set.
139
+ """
140
+ with np.load(path) as data:
141
+ outputs = data["outputs"]
142
+ n = len(outputs)
143
+ return TrainingSet(
144
+ X=data["points"][:n].copy(),
145
+ Y=outputs.copy(),
146
+ names=tuple(str(name) for name in data["names"]),
147
+ seconds_per_run=float(data["elapsed"]) / max(n, 1),
148
+ )
149
+
150
+
151
+ __all__ = ["TrainingSet", "load_runs", "run_model"]