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.
Files changed (55) hide show
  1. bff/__init__.py +30 -0
  2. bff/bayes/__init__.py +11 -0
  3. bff/bayes/gaussian_process.py +285 -0
  4. bff/bayes/kernels.py +71 -0
  5. bff/bayes/learning.py +484 -0
  6. bff/bayes/likelihoods.py +142 -0
  7. bff/bayes/posterior.py +62 -0
  8. bff/bayes/priors.py +179 -0
  9. bff/bayes/results.py +327 -0
  10. bff/bayes/utils.py +333 -0
  11. bff/cli.py +254 -0
  12. bff/data.py +93 -0
  13. bff/domain/__init__.py +12 -0
  14. bff/domain/bias.py +149 -0
  15. bff/domain/specs.py +347 -0
  16. bff/domain/trainset.py +201 -0
  17. bff/io/__init__.py +12 -0
  18. bff/io/colvars.py +186 -0
  19. bff/io/commands.py +20 -0
  20. bff/io/cp2k.py +330 -0
  21. bff/io/logs.py +213 -0
  22. bff/io/mdp.py +98 -0
  23. bff/io/plumed.py +184 -0
  24. bff/io/progress.py +74 -0
  25. bff/io/schedulers.py +118 -0
  26. bff/io/utils.py +208 -0
  27. bff/mcmc/__init__.py +3 -0
  28. bff/mcmc/convergence.py +168 -0
  29. bff/mcmc/proposal.py +254 -0
  30. bff/mcmc/sampler.py +481 -0
  31. bff/plotting.py +362 -0
  32. bff/qoi/__init__.py +9 -0
  33. bff/qoi/analysis.py +209 -0
  34. bff/qoi/data.py +169 -0
  35. bff/qoi/hbonds.py +255 -0
  36. bff/qoi/rdf.py +108 -0
  37. bff/qoi/routines.py +272 -0
  38. bff/tools.py +168 -0
  39. bff/topology.py +581 -0
  40. bff/workflows/__init__.py +0 -0
  41. bff/workflows/configs.py +1147 -0
  42. bff/workflows/learn.py +77 -0
  43. bff/workflows/md.py +326 -0
  44. bff/workflows/prepare.py +788 -0
  45. bff/workflows/qoi.py +260 -0
  46. bff/workflows/runsims.py +620 -0
  47. bff/workflows/simulate.py +28 -0
  48. bff/workflows/train.py +69 -0
  49. bff/workflows/validate.py +29 -0
  50. bfflearn-0.1.0.dev1.dist-info/METADATA +913 -0
  51. bfflearn-0.1.0.dev1.dist-info/RECORD +55 -0
  52. bfflearn-0.1.0.dev1.dist-info/WHEEL +5 -0
  53. bfflearn-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  54. bfflearn-0.1.0.dev1.dist-info/licenses/LICENSE +674 -0
  55. 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
@@ -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