bfflearn 0.1.0.dev1__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.
- bff/__init__.py +30 -0
- bff/bayes/__init__.py +11 -0
- bff/bayes/gaussian_process.py +285 -0
- bff/bayes/kernels.py +71 -0
- bff/bayes/learning.py +484 -0
- bff/bayes/likelihoods.py +142 -0
- bff/bayes/posterior.py +62 -0
- bff/bayes/priors.py +179 -0
- bff/bayes/results.py +327 -0
- bff/bayes/utils.py +333 -0
- bff/cli.py +254 -0
- bff/data.py +93 -0
- bff/domain/__init__.py +12 -0
- bff/domain/bias.py +149 -0
- bff/domain/specs.py +347 -0
- bff/domain/trainset.py +201 -0
- bff/io/__init__.py +12 -0
- bff/io/colvars.py +186 -0
- bff/io/commands.py +20 -0
- bff/io/cp2k.py +330 -0
- bff/io/logs.py +213 -0
- bff/io/mdp.py +98 -0
- bff/io/plumed.py +184 -0
- bff/io/progress.py +74 -0
- bff/io/schedulers.py +118 -0
- bff/io/utils.py +208 -0
- bff/mcmc/__init__.py +3 -0
- bff/mcmc/convergence.py +168 -0
- bff/mcmc/proposal.py +254 -0
- bff/mcmc/sampler.py +481 -0
- bff/plotting.py +362 -0
- bff/qoi/__init__.py +9 -0
- bff/qoi/analysis.py +209 -0
- bff/qoi/data.py +169 -0
- bff/qoi/hbonds.py +255 -0
- bff/qoi/rdf.py +108 -0
- bff/qoi/routines.py +272 -0
- bff/tools.py +168 -0
- bff/topology.py +581 -0
- bff/workflows/__init__.py +0 -0
- bff/workflows/configs.py +1147 -0
- bff/workflows/learn.py +77 -0
- bff/workflows/md.py +326 -0
- bff/workflows/prepare.py +788 -0
- bff/workflows/qoi.py +260 -0
- bff/workflows/runsims.py +620 -0
- bff/workflows/simulate.py +28 -0
- bff/workflows/train.py +69 -0
- bff/workflows/validate.py +29 -0
- bfflearn-0.1.0.dev1.dist-info/METADATA +913 -0
- bfflearn-0.1.0.dev1.dist-info/RECORD +55 -0
- bfflearn-0.1.0.dev1.dist-info/WHEEL +5 -0
- bfflearn-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- bfflearn-0.1.0.dev1.dist-info/licenses/LICENSE +674 -0
- bfflearn-0.1.0.dev1.dist-info/top_level.txt +1 -0
bff/bayes/learning.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from functools import partial
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable, Mapping, Optional, Sequence, Union
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from ..io.logs import Logger, print_progress_mcmc
|
|
10
|
+
from ..mcmc.proposal import AdaptiveGaussianProposal
|
|
11
|
+
from ..mcmc.sampler import Sampler
|
|
12
|
+
from ..qoi.data import QoIDataset
|
|
13
|
+
from ..tools import rdf_sigmoid_mean
|
|
14
|
+
from .gaussian_process import LGPCommittee, LocalGaussianProcess
|
|
15
|
+
from .likelihoods import gaussian_log_likelihood, loo_log_likelihood
|
|
16
|
+
from .posterior import log_posterior
|
|
17
|
+
from .priors import Prior, Priors
|
|
18
|
+
from .results import InferenceResults
|
|
19
|
+
from .utils import (
|
|
20
|
+
check_device,
|
|
21
|
+
check_tensor,
|
|
22
|
+
find_map,
|
|
23
|
+
initialize_walkers,
|
|
24
|
+
laplace_approximation,
|
|
25
|
+
train_test_split,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
PathLike = Union[str, Path]
|
|
29
|
+
ArrayLike = Union[np.ndarray, torch.Tensor]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class InferenceProblem:
|
|
34
|
+
"""Complete Bayesian learning problem for force-field parameter inference."""
|
|
35
|
+
|
|
36
|
+
models: dict[str, LGPCommittee]
|
|
37
|
+
constraint: Optional[Callable] = None
|
|
38
|
+
observations: dict[str, np.ndarray | torch.Tensor] = field(
|
|
39
|
+
init=False,
|
|
40
|
+
repr=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if not self.models:
|
|
45
|
+
raise ValueError("InferenceProblem requires at least one surrogate model.")
|
|
46
|
+
|
|
47
|
+
empty_models = [qoi for qoi, model in self.models.items() if not model.lgps]
|
|
48
|
+
if empty_models:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Surrogate models without committee members: "
|
|
51
|
+
+ ", ".join(sorted(empty_models))
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
object.__setattr__(
|
|
55
|
+
self,
|
|
56
|
+
"observations",
|
|
57
|
+
{
|
|
58
|
+
qoi: np.asarray(model.reference_values, dtype=float)
|
|
59
|
+
for qoi, model in self.models.items()
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
inconsistent_inputs = []
|
|
64
|
+
for qoi, model in self.models.items():
|
|
65
|
+
input_shapes = {
|
|
66
|
+
tuple(int(dim) for dim in lgp.X_train.shape)
|
|
67
|
+
for lgp in model.lgps
|
|
68
|
+
}
|
|
69
|
+
if len(input_shapes) != 1:
|
|
70
|
+
inconsistent_inputs.append(qoi)
|
|
71
|
+
if inconsistent_inputs:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"Surrogate committees with inconsistent training input shapes: "
|
|
74
|
+
+ ", ".join(sorted(inconsistent_inputs))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
model_input_shapes = {
|
|
78
|
+
qoi: tuple(int(dim) for dim in model.lgps[0].X_train.shape)
|
|
79
|
+
for qoi, model in self.models.items()
|
|
80
|
+
}
|
|
81
|
+
if len(set(model_input_shapes.values())) != 1:
|
|
82
|
+
shape_summary = ", ".join(
|
|
83
|
+
f"{qoi}={shape}"
|
|
84
|
+
for qoi, shape in sorted(model_input_shapes.items())
|
|
85
|
+
)
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"All surrogate models must expect the same training input shape. "
|
|
88
|
+
f"Found: {shape_summary}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
n_params = {model.n_params for model in self.models.values()}
|
|
92
|
+
if len(n_params) != 1:
|
|
93
|
+
raise ValueError("All surrogate models must have the same input dimension.")
|
|
94
|
+
|
|
95
|
+
if self.constraint is not None and self.constraint.n_params != self.n_params:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"The selected surrogate models and the charge constraint disagree "
|
|
98
|
+
f"on the number of explicit parameters: models expect "
|
|
99
|
+
f"{self.n_params}, constraint defines {self.constraint.n_params}."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
invalid = [
|
|
103
|
+
qoi
|
|
104
|
+
for qoi, model in self.models.items()
|
|
105
|
+
if model.reference_values.size != model.y_size
|
|
106
|
+
]
|
|
107
|
+
if invalid:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"Models with incompatible reference output size: "
|
|
110
|
+
+ ", ".join(sorted(invalid))
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def qoi_names(self) -> list[str]:
|
|
115
|
+
return list(self.models)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def n_params(self) -> int:
|
|
119
|
+
return next(iter(self.models.values())).n_params
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def parameter_bounds(self) -> np.ndarray:
|
|
123
|
+
if self.constraint is None:
|
|
124
|
+
return np.tile([-1e5, 1e5], (self.n_params, 1))
|
|
125
|
+
return np.asarray(self.constraint.explicit_bounds, dtype=float)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def parameter_names(self) -> list[str] | None:
|
|
129
|
+
if self.constraint is None:
|
|
130
|
+
return None
|
|
131
|
+
if not hasattr(self.constraint, "bounds") or not hasattr(
|
|
132
|
+
self.constraint, "implicit_param"
|
|
133
|
+
):
|
|
134
|
+
return None
|
|
135
|
+
bounds = self.constraint.bounds.without(self.constraint.implicit_param)
|
|
136
|
+
return bounds.names.tolist()
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def nuisance_names(self) -> list[str]:
|
|
140
|
+
return [
|
|
141
|
+
f"log_sigma_{qoi}"
|
|
142
|
+
for qoi, model in self.models.items()
|
|
143
|
+
if model.nuisance is None
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def n_free_nuisance(self) -> int:
|
|
148
|
+
return len(self.nuisance_names)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_models(
|
|
152
|
+
cls,
|
|
153
|
+
models: Mapping[str, LGPCommittee],
|
|
154
|
+
*,
|
|
155
|
+
constraint: Optional[Callable] = None,
|
|
156
|
+
) -> "InferenceProblem":
|
|
157
|
+
return cls(models=dict(models), constraint=constraint)
|
|
158
|
+
|
|
159
|
+
def build_priors(self, dist_type: str = "normal") -> Priors:
|
|
160
|
+
return Priors.from_bounds(
|
|
161
|
+
bounds=self.parameter_bounds,
|
|
162
|
+
dist_type=dist_type,
|
|
163
|
+
n_nuisance=self.n_free_nuisance,
|
|
164
|
+
names=self.parameter_names,
|
|
165
|
+
nuisance_names=self.nuisance_names,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def to_torch(
|
|
169
|
+
self,
|
|
170
|
+
device: str,
|
|
171
|
+
dtype: torch.dtype = torch.float32,
|
|
172
|
+
) -> "InferenceProblem":
|
|
173
|
+
problem = InferenceProblem(
|
|
174
|
+
models=self.models,
|
|
175
|
+
constraint=self.constraint,
|
|
176
|
+
)
|
|
177
|
+
object.__setattr__(
|
|
178
|
+
problem,
|
|
179
|
+
"observations",
|
|
180
|
+
{
|
|
181
|
+
qoi: torch.as_tensor(values, device=device, dtype=dtype)
|
|
182
|
+
for qoi, values in self.observations.items()
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
return problem
|
|
186
|
+
|
|
187
|
+
def infer(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
priors_disttype: str = "normal",
|
|
191
|
+
total_steps: int = 1500,
|
|
192
|
+
warmup: int = 500,
|
|
193
|
+
thin: int = 1,
|
|
194
|
+
progress_stride: int = 100,
|
|
195
|
+
n_walkers: Optional[int] = None,
|
|
196
|
+
fn_posterior: PathLike = "./posterior.pt",
|
|
197
|
+
fn_checkpoint: Optional[PathLike] = "./mcmc-checkpoint.pt",
|
|
198
|
+
fn_priors: Optional[PathLike] = "./priors.pt",
|
|
199
|
+
restart: bool = True,
|
|
200
|
+
device: str = "cuda",
|
|
201
|
+
logger: Optional[Logger] = None,
|
|
202
|
+
rhat_tol: float = 1.01,
|
|
203
|
+
ess_min: int = 100,
|
|
204
|
+
include_implicit_charge: bool = False,
|
|
205
|
+
) -> InferenceResults:
|
|
206
|
+
"""Run posterior sampling for this inference problem."""
|
|
207
|
+
logger = logger or Logger("BFFLearn")
|
|
208
|
+
logger.info("=== Posterior Inference ===", level=0)
|
|
209
|
+
logger.info("", level=0)
|
|
210
|
+
|
|
211
|
+
priors = self.build_priors(dist_type=priors_disttype)
|
|
212
|
+
n_walkers = 5 * len(priors) if n_walkers is None else n_walkers
|
|
213
|
+
initial_positions = initialize_walkers(
|
|
214
|
+
priors.distributions,
|
|
215
|
+
n_walkers,
|
|
216
|
+
self.constraint,
|
|
217
|
+
)
|
|
218
|
+
proposal_cov = check_tensor(
|
|
219
|
+
torch.diag(torch.tensor(priors.scales, dtype=torch.float32) ** 2),
|
|
220
|
+
device=device,
|
|
221
|
+
)
|
|
222
|
+
proposal = AdaptiveGaussianProposal(proposal_cov, device=device)
|
|
223
|
+
log_likelihood = partial(
|
|
224
|
+
gaussian_log_likelihood,
|
|
225
|
+
problem=self.to_torch(device),
|
|
226
|
+
)
|
|
227
|
+
log_probability = partial(
|
|
228
|
+
log_posterior,
|
|
229
|
+
priors=priors,
|
|
230
|
+
log_likelihood_fn=log_likelihood,
|
|
231
|
+
device=device,
|
|
232
|
+
)
|
|
233
|
+
sampler = Sampler(
|
|
234
|
+
log_prob=log_probability,
|
|
235
|
+
proposal=proposal,
|
|
236
|
+
device=device,
|
|
237
|
+
dtype=torch.float32,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
fn_posterior = Path(fn_posterior).resolve()
|
|
241
|
+
fn_checkpoint = (
|
|
242
|
+
None if fn_checkpoint is None else Path(fn_checkpoint).resolve()
|
|
243
|
+
)
|
|
244
|
+
fn_priors = None if fn_priors is None else Path(fn_priors).resolve()
|
|
245
|
+
|
|
246
|
+
if fn_checkpoint is None:
|
|
247
|
+
fn_checkpoint = _default_checkpoint_path(fn_posterior)
|
|
248
|
+
|
|
249
|
+
if fn_priors is not None:
|
|
250
|
+
priors.write(fn_priors)
|
|
251
|
+
|
|
252
|
+
print_progress_mcmc(
|
|
253
|
+
sampler,
|
|
254
|
+
initial_positions,
|
|
255
|
+
total_steps=total_steps,
|
|
256
|
+
warmup=warmup,
|
|
257
|
+
thin=thin,
|
|
258
|
+
progress_stride=progress_stride,
|
|
259
|
+
logger=logger,
|
|
260
|
+
restart=restart,
|
|
261
|
+
fn_checkpoint=fn_checkpoint,
|
|
262
|
+
rhat_tol=rhat_tol,
|
|
263
|
+
ess_min=ess_min,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
sampler.write_posterior(fn_posterior)
|
|
267
|
+
specs = getattr(self.constraint, "specs", None)
|
|
268
|
+
return InferenceResults.load(
|
|
269
|
+
posterior=fn_posterior,
|
|
270
|
+
priors=priors,
|
|
271
|
+
specs=specs,
|
|
272
|
+
include_implicit_charge=include_implicit_charge,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _resolve_mean(
|
|
277
|
+
dataset: QoIDataset,
|
|
278
|
+
mean: ArrayLike | float | str,
|
|
279
|
+
) -> ArrayLike | float:
|
|
280
|
+
"""Resolve a configured surrogate mean specification."""
|
|
281
|
+
if dataset.name == "rdf" and mean == "sigmoid":
|
|
282
|
+
n_bins = dataset.settings.get("n_bins")
|
|
283
|
+
r_range = dataset.settings.get("r_range")
|
|
284
|
+
if n_bins is None or r_range is None:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
"RDF sigmoid mean requires shared RDF settings in the dataset. "
|
|
287
|
+
"Analyze with one consistent RDF routine definition per QoI."
|
|
288
|
+
)
|
|
289
|
+
return rdf_sigmoid_mean(n_bins, r_range, dataset.outputs_ref)
|
|
290
|
+
return mean
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _default_checkpoint_path(fn_posterior: Path) -> Path:
|
|
294
|
+
suffix = "".join(fn_posterior.suffixes) or ".pt"
|
|
295
|
+
stem = fn_posterior.name[: -len(suffix)] if suffix else fn_posterior.name
|
|
296
|
+
return fn_posterior.with_name(f"{stem}.ckpt{suffix}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def fit_lgp_committee(
|
|
300
|
+
X: torch.Tensor,
|
|
301
|
+
y: torch.Tensor,
|
|
302
|
+
y_mean: torch.Tensor,
|
|
303
|
+
test_fraction: float,
|
|
304
|
+
n_hyper: int,
|
|
305
|
+
committee: int,
|
|
306
|
+
n_observations: int,
|
|
307
|
+
reference_values: np.ndarray,
|
|
308
|
+
nuisance: float | None,
|
|
309
|
+
fn_out: PathLike | None,
|
|
310
|
+
device: str,
|
|
311
|
+
logger: Optional[Logger] = None,
|
|
312
|
+
opt_kwargs: Optional[dict[str, Union[int, float, str]]] = None,
|
|
313
|
+
) -> LGPCommittee:
|
|
314
|
+
"""Fit a committee of local Gaussian-process surrogates."""
|
|
315
|
+
check_device(device)
|
|
316
|
+
logger = logger or Logger("LGP")
|
|
317
|
+
opt_kwargs = dict(opt_kwargs or {})
|
|
318
|
+
|
|
319
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_fraction)
|
|
320
|
+
n_hyper = min(n_hyper, len(X_train))
|
|
321
|
+
|
|
322
|
+
X_hyper = check_tensor(X_train[:n_hyper], device="cpu")
|
|
323
|
+
y_hyper = check_tensor(y_train[:n_hyper], device="cpu")
|
|
324
|
+
y_mean = check_tensor(y_mean, device="cpu")
|
|
325
|
+
|
|
326
|
+
n_params = X.shape[1]
|
|
327
|
+
priors = Priors(
|
|
328
|
+
[Prior("normal", -2.0, 2.0, name=f"length_{i}") for i in range(n_params)]
|
|
329
|
+
+ [
|
|
330
|
+
Prior("normal", -2.0, 2.0, name="width"),
|
|
331
|
+
Prior("normal", -2.0, 3.0, name="noise"),
|
|
332
|
+
]
|
|
333
|
+
)
|
|
334
|
+
p0 = torch.tensor(priors.means, dtype=torch.float32)
|
|
335
|
+
|
|
336
|
+
log_likelihood = partial(loo_log_likelihood, X=X_hyper, y=y_hyper - y_mean)
|
|
337
|
+
log_probability = partial(
|
|
338
|
+
log_posterior,
|
|
339
|
+
priors=priors,
|
|
340
|
+
log_likelihood_fn=log_likelihood,
|
|
341
|
+
device="cpu",
|
|
342
|
+
numpy_output=False,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
map_theta = find_map(log_probability, p0, logger=logger, **opt_kwargs)
|
|
346
|
+
|
|
347
|
+
if committee > 1:
|
|
348
|
+
cov = laplace_approximation(log_probability, map_theta, device="cpu")
|
|
349
|
+
hyper_dist = torch.distributions.MultivariateNormal(map_theta, cov)
|
|
350
|
+
hyper_samples = hyper_dist.sample((committee,))
|
|
351
|
+
else:
|
|
352
|
+
hyper_samples = map_theta.unsqueeze(0)
|
|
353
|
+
|
|
354
|
+
hyper_samples = hyper_samples.exp()
|
|
355
|
+
lengths = hyper_samples[:, :-2]
|
|
356
|
+
widths = hyper_samples[:, -2]
|
|
357
|
+
sigmas = hyper_samples[:, -1]
|
|
358
|
+
|
|
359
|
+
logger.info(f"Committee: 0/{committee}", level=2, overwrite=True)
|
|
360
|
+
lgps = []
|
|
361
|
+
for i, (length, width, sigma) in enumerate(
|
|
362
|
+
zip(lengths, widths, sigmas),
|
|
363
|
+
start=1,
|
|
364
|
+
):
|
|
365
|
+
lgps.append(
|
|
366
|
+
LocalGaussianProcess(
|
|
367
|
+
X_train,
|
|
368
|
+
y_train,
|
|
369
|
+
y_mean,
|
|
370
|
+
length,
|
|
371
|
+
width,
|
|
372
|
+
sigma,
|
|
373
|
+
device,
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
logger.info(f"Committee: {i}/{committee}", level=2, overwrite=True)
|
|
377
|
+
|
|
378
|
+
lgp_committee = LGPCommittee(
|
|
379
|
+
lgps,
|
|
380
|
+
n_observations,
|
|
381
|
+
reference_values,
|
|
382
|
+
nuisance,
|
|
383
|
+
)
|
|
384
|
+
lgp_committee.validate(X_test, y_test)
|
|
385
|
+
logger.info(
|
|
386
|
+
f"Committee: {committee} (100%) | MAPE = {lgp_committee.error:.2f}%",
|
|
387
|
+
level=2,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if fn_out is not None:
|
|
391
|
+
lgp_committee.write(fn_out)
|
|
392
|
+
|
|
393
|
+
return lgp_committee
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _effective_observations(
|
|
397
|
+
dataset: QoIDataset,
|
|
398
|
+
observation_scale: float = 1.0,
|
|
399
|
+
) -> int:
|
|
400
|
+
"""Return the effective observation count used in the likelihood term."""
|
|
401
|
+
return max(1, int(round(dataset.n_observations * float(observation_scale))))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def train_surrogates(
|
|
405
|
+
datasets: Sequence[QoIDataset],
|
|
406
|
+
*,
|
|
407
|
+
y_means: Optional[Mapping[str, ArrayLike | float | str]] = None,
|
|
408
|
+
observation_scales: Optional[Mapping[str, float]] = None,
|
|
409
|
+
model_paths: Optional[Mapping[str, PathLike | None]] = None,
|
|
410
|
+
reuse_models: bool = True,
|
|
411
|
+
n_hyper_max: int = 200,
|
|
412
|
+
committee_size: int = 1,
|
|
413
|
+
test_fraction: float = 0.2,
|
|
414
|
+
device: str = "cuda",
|
|
415
|
+
logger: Optional[Logger] = None,
|
|
416
|
+
**opt_kwargs,
|
|
417
|
+
) -> dict[str, LGPCommittee]:
|
|
418
|
+
"""Train or load QoI surrogate models."""
|
|
419
|
+
logger = logger or Logger("BFFLearn")
|
|
420
|
+
y_means = dict(y_means or {})
|
|
421
|
+
observation_scales = dict(observation_scales or {})
|
|
422
|
+
model_paths = dict(model_paths or {})
|
|
423
|
+
|
|
424
|
+
logger.info("=== Optimizing LGP surrogates ===", level=0)
|
|
425
|
+
logger.info("", level=0)
|
|
426
|
+
|
|
427
|
+
models: dict[str, LGPCommittee] = {}
|
|
428
|
+
for dataset in datasets:
|
|
429
|
+
if not isinstance(dataset, QoIDataset):
|
|
430
|
+
raise TypeError(
|
|
431
|
+
f"Invalid dataset type: {type(dataset)}. Expected QoIDataset."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
qoi = dataset.name
|
|
435
|
+
n_observations = _effective_observations(
|
|
436
|
+
dataset,
|
|
437
|
+
observation_scales.get(qoi, 1.0),
|
|
438
|
+
)
|
|
439
|
+
logger.info(f"QoI: {qoi}", level=1)
|
|
440
|
+
|
|
441
|
+
fn_model_raw = model_paths.get(qoi)
|
|
442
|
+
fn_model = None if fn_model_raw is None else Path(fn_model_raw).resolve()
|
|
443
|
+
if fn_model is not None:
|
|
444
|
+
fn_model.parent.mkdir(parents=True, exist_ok=True)
|
|
445
|
+
|
|
446
|
+
if reuse_models and fn_model is not None and fn_model.exists():
|
|
447
|
+
models[qoi] = LGPCommittee.load(fn_model)
|
|
448
|
+
models[qoi].n_observations = n_observations
|
|
449
|
+
models[qoi].reference_values = np.asarray(
|
|
450
|
+
dataset.outputs_ref,
|
|
451
|
+
dtype=float,
|
|
452
|
+
).reshape(-1)
|
|
453
|
+
if models[qoi].reference_values.size != models[qoi].y_size:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
f"Cached surrogate for {qoi!r} is incompatible with the "
|
|
456
|
+
"current reference observation size."
|
|
457
|
+
)
|
|
458
|
+
logger.info(
|
|
459
|
+
f"Using cached model. | obs = {n_observations} "
|
|
460
|
+
f"| MAPE = {models[qoi].error:.2f}",
|
|
461
|
+
level=2,
|
|
462
|
+
)
|
|
463
|
+
logger.info("", level=0)
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
models[qoi] = fit_lgp_committee(
|
|
467
|
+
X=dataset.inputs,
|
|
468
|
+
y=dataset.outputs,
|
|
469
|
+
y_mean=_resolve_mean(dataset, y_means.get(qoi, 0)),
|
|
470
|
+
test_fraction=test_fraction,
|
|
471
|
+
n_hyper=n_hyper_max,
|
|
472
|
+
committee=committee_size,
|
|
473
|
+
n_observations=n_observations,
|
|
474
|
+
reference_values=dataset.outputs_ref,
|
|
475
|
+
nuisance=dataset.nuisance,
|
|
476
|
+
fn_out=fn_model,
|
|
477
|
+
device=device,
|
|
478
|
+
logger=logger,
|
|
479
|
+
opt_kwargs=opt_kwargs,
|
|
480
|
+
)
|
|
481
|
+
logger.info(f"Effective observations: {n_observations}", level=2)
|
|
482
|
+
logger.info("", level=0)
|
|
483
|
+
|
|
484
|
+
return models
|
bff/bayes/likelihoods.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
from .kernels import gaussian_kernel
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .learning import InferenceProblem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def loo_log_likelihood(
|
|
14
|
+
theta: torch.Tensor, X: torch.Tensor, y: torch.Tensor
|
|
15
|
+
) -> torch.Tensor:
|
|
16
|
+
"""Leave One Out Log Likelihood for Gaussian Process Regression.
|
|
17
|
+
From Sundarajan & Keerthi (2001):
|
|
18
|
+
"Predictive Approaches for Choosing Hyperparameters in Gaussian Processes",
|
|
19
|
+
Equation 8.
|
|
20
|
+
doi: 10.1162/08997660151134343
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
theta : torch.Tensor
|
|
25
|
+
Hyperparameters of the Gaussian Process model.
|
|
26
|
+
Shape: (n_samples, n_hyperparams).
|
|
27
|
+
X : torch.Tensor
|
|
28
|
+
Input data points.
|
|
29
|
+
Shape: (n_samples, n_features).
|
|
30
|
+
y : torch.Tensor
|
|
31
|
+
Outputs.
|
|
32
|
+
Shape: (n_samples, n_outputs).
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
torch.Tensor
|
|
37
|
+
Leave One Out Log Likelihood for each hyperparameter set.
|
|
38
|
+
Shape: (n_samples,).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Ensure that all the inputs are on the same device
|
|
42
|
+
device = theta.device
|
|
43
|
+
theta = theta.exp()
|
|
44
|
+
length = theta[:, :-2][:, None].to(device)
|
|
45
|
+
width = theta[:, -2][:, None, None].to(device)
|
|
46
|
+
noise = theta[:, -1][:, None, None].to(device)
|
|
47
|
+
|
|
48
|
+
# Check the dimensions of X and y
|
|
49
|
+
n_batch = len(theta)
|
|
50
|
+
n_samples = len(X)
|
|
51
|
+
n_y = y.shape[1]
|
|
52
|
+
X = X.unsqueeze(0).repeat(n_batch, 1, 1).to(device)
|
|
53
|
+
|
|
54
|
+
identity = torch.eye(n_samples, device=device).expand(n_batch, n_samples, n_samples)
|
|
55
|
+
|
|
56
|
+
# Compute the kernel matrix and its inverse
|
|
57
|
+
Kdd = gaussian_kernel(X, X, length, width) + identity * noise
|
|
58
|
+
Kdd_inv = torch.linalg.inv(Kdd)
|
|
59
|
+
Kdd_inv_diagonal = torch.diagonal(Kdd_inv, 0, dim1=1, dim2=2)
|
|
60
|
+
log_Kdd_inv_ii = torch.log(Kdd_inv_diagonal)
|
|
61
|
+
|
|
62
|
+
# Compute the terms for the log likelihood
|
|
63
|
+
norm = torch.sqrt(Kdd_inv_diagonal + 1e-9).unsqueeze(1)
|
|
64
|
+
term_1 = (Kdd_inv @ y).transpose(1, 2) / norm
|
|
65
|
+
term_1 = 1 / (2 * n_samples) * torch.sum(term_1**2, dim=(1, 2))
|
|
66
|
+
|
|
67
|
+
term_2 = n_y / (2 * n_samples) * torch.sum(log_Kdd_inv_ii, dim=1)
|
|
68
|
+
|
|
69
|
+
pi_tensor = torch.tensor(torch.pi, device=device)
|
|
70
|
+
term_3 = (n_y / 2) * torch.log(2 * pi_tensor)
|
|
71
|
+
|
|
72
|
+
return - (term_1 - term_2 + term_3)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def gaussian_log_likelihood(
|
|
76
|
+
theta: torch.Tensor,
|
|
77
|
+
problem: InferenceProblem,
|
|
78
|
+
) -> torch.Tensor:
|
|
79
|
+
|
|
80
|
+
"""Compute the gaussian log likelihood.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
theta : torch.Tensor
|
|
85
|
+
Parameters of the surrogate model, shape (n_samples, n_params + n_sigma).
|
|
86
|
+
problem : InferenceProblem
|
|
87
|
+
Complete inference problem including surrogates, observations,
|
|
88
|
+
and the optional parameter constraint.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
torch.Tensor
|
|
93
|
+
Log likelihood for each sample in `theta`, shape (n_samples,).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# Assign device
|
|
97
|
+
device = theta.device
|
|
98
|
+
|
|
99
|
+
# Split the theta into parameters and nuisances
|
|
100
|
+
n_params = problem.n_params
|
|
101
|
+
params = theta[:, :n_params]
|
|
102
|
+
nuisance_free = theta[:, n_params:]
|
|
103
|
+
|
|
104
|
+
sigma_columns = []
|
|
105
|
+
j = 0
|
|
106
|
+
for model in problem.models.values():
|
|
107
|
+
if model.nuisance is None:
|
|
108
|
+
sigma_columns.append(nuisance_free[:, j].exp())
|
|
109
|
+
j += 1
|
|
110
|
+
else:
|
|
111
|
+
sigma_columns.append(
|
|
112
|
+
torch.full(
|
|
113
|
+
(len(theta),),
|
|
114
|
+
float(model.nuisance),
|
|
115
|
+
device=device,
|
|
116
|
+
dtype=theta.dtype,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
sigmas = torch.stack(sigma_columns, dim=1)
|
|
120
|
+
|
|
121
|
+
# Check if the parameters are within the valid bounds
|
|
122
|
+
if problem.constraint is not None:
|
|
123
|
+
mask = problem.constraint(params)
|
|
124
|
+
else:
|
|
125
|
+
mask = torch.ones(len(theta), dtype=bool, device=device)
|
|
126
|
+
params = params[mask]
|
|
127
|
+
sigmas = sigmas[mask]
|
|
128
|
+
|
|
129
|
+
# Compute the log-likelihod
|
|
130
|
+
log_likelihood = torch.full((len(theta), ), -torch.inf, device=device)
|
|
131
|
+
if mask.any():
|
|
132
|
+
log_like_valid = torch.zeros(mask.sum(), device=device)
|
|
133
|
+
for (qoi, model), sigma in zip(problem.models.items(), sigmas.T):
|
|
134
|
+
y_trial = model.predict(params)
|
|
135
|
+
N = model.n_observations
|
|
136
|
+
diff = problem.observations[qoi] - y_trial
|
|
137
|
+
ssq = torch.sum(diff**2, dim=1)
|
|
138
|
+
log_like_valid += -0.5 * ssq / sigma**2 - N * torch.log(sigma)
|
|
139
|
+
|
|
140
|
+
log_likelihood[mask] = log_like_valid
|
|
141
|
+
|
|
142
|
+
return log_likelihood
|
bff/bayes/posterior.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Callable, List
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import torch
|
|
5
|
+
|
|
6
|
+
from .priors import Priors, log_prior
|
|
7
|
+
from .utils import check_tensor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def log_posterior(
|
|
11
|
+
theta: torch.Tensor,
|
|
12
|
+
priors: Priors | List[torch.distributions.Distribution],
|
|
13
|
+
log_likelihood_fn: Callable[[torch.Tensor], torch.Tensor],
|
|
14
|
+
device: str,
|
|
15
|
+
numpy_output: bool = True
|
|
16
|
+
) -> np.ndarray:
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
Computes the log-posterior = log-prior + log-likelihood.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
theta : torch.Tensor
|
|
24
|
+
Tensor of parameters for which to compute the log-posterior.
|
|
25
|
+
priors : List[torch.distributions.Distribution]
|
|
26
|
+
List of prior distributions for the parameters.
|
|
27
|
+
log_likelihood_fn : Callable[[torch.Tensor], torch.Tensor]
|
|
28
|
+
Function that computes the log-likelihood given the parameters.
|
|
29
|
+
It must have signature `log_likelihood_fn(theta: torch.Tensor) -> torch.Tensor`.
|
|
30
|
+
device : str
|
|
31
|
+
Device on which the computations should be performed (e.g., 'cuda:0' or 'cpu').
|
|
32
|
+
numpy_output : bool, optional
|
|
33
|
+
If True, returns the log-posterior as a NumPy array.
|
|
34
|
+
If False, returns it as a PyTorch tensor. Defaults to True.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
np.ndarray
|
|
39
|
+
Log-posterior values for the given parameters.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
theta = check_tensor(theta, device)
|
|
43
|
+
if theta.dim() == 1:
|
|
44
|
+
theta = theta.unsqueeze(0)
|
|
45
|
+
|
|
46
|
+
if theta.isnan().any():
|
|
47
|
+
log_prob = torch.full((theta.shape[0],), -1e10, device=device)
|
|
48
|
+
if theta._grad_fn:
|
|
49
|
+
log_prob = log_prob.requires_grad_()
|
|
50
|
+
|
|
51
|
+
else:
|
|
52
|
+
log_likelihood = log_likelihood_fn(theta)
|
|
53
|
+
log_likelihood = torch.where(
|
|
54
|
+
torch.isnan(log_likelihood), -torch.inf, log_likelihood)
|
|
55
|
+
|
|
56
|
+
log_prob = log_prior(theta, priors) + log_likelihood
|
|
57
|
+
|
|
58
|
+
if log_prob.ndim == 1:
|
|
59
|
+
log_prob = log_prob.squeeze(0)
|
|
60
|
+
if numpy_output:
|
|
61
|
+
return log_prob.detach().cpu().numpy()
|
|
62
|
+
return log_prob
|