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 +23 -0
- emutrust/emulator.py +311 -0
- emutrust/likelihood.py +183 -0
- emutrust/runner.py +151 -0
- emutrust/space.py +122 -0
- emutrust/toy.py +29 -0
- emutrust/validation.py +245 -0
- emutrust-0.1.0.dist-info/METADATA +301 -0
- emutrust-0.1.0.dist-info/RECORD +11 -0
- emutrust-0.1.0.dist-info/WHEEL +4 -0
- emutrust-0.1.0.dist-info/licenses/LICENSE +21 -0
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"]
|