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/priors.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Iterable, Optional, Sequence, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import torch
|
|
7
|
+
from torch.distributions import Distribution, Normal, Uniform
|
|
8
|
+
|
|
9
|
+
ArrayLike = Union[np.ndarray, torch.Tensor]
|
|
10
|
+
PathLike = Union[str, Path]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Prior:
|
|
15
|
+
kind: str
|
|
16
|
+
a: float
|
|
17
|
+
b: float
|
|
18
|
+
name: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
kind = self.kind.lower()
|
|
22
|
+
if kind not in {"normal", "uniform"}:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f'Unknown prior type "{self.kind}". Options are "normal" or "uniform".'
|
|
25
|
+
)
|
|
26
|
+
if kind == "normal" and self.b <= 0:
|
|
27
|
+
raise ValueError("Normal prior scale must be positive.")
|
|
28
|
+
if kind == "uniform" and self.a >= self.b:
|
|
29
|
+
raise ValueError("Uniform prior requires lower < upper.")
|
|
30
|
+
|
|
31
|
+
object.__setattr__(self, "kind", kind)
|
|
32
|
+
object.__setattr__(self, "a", float(self.a))
|
|
33
|
+
object.__setattr__(self, "b", float(self.b))
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def distribution(self) -> Distribution:
|
|
37
|
+
if self.kind == "normal":
|
|
38
|
+
return Normal(self.a, self.b, validate_args=False)
|
|
39
|
+
return Uniform(self.a, self.b, validate_args=False)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def mean(self) -> float:
|
|
43
|
+
return self.a if self.kind == "normal" else 0.5 * (self.a + self.b)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def scale(self) -> float:
|
|
47
|
+
return self.b if self.kind == "normal" else (self.b - self.a) / np.sqrt(12)
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, float | str]:
|
|
50
|
+
data = {"kind": self.kind, "a": self.a, "b": self.b}
|
|
51
|
+
if self.name is not None:
|
|
52
|
+
data["name"] = self.name
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class Priors:
|
|
58
|
+
items: list[Prior] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
def __len__(self) -> int:
|
|
61
|
+
return len(self.items)
|
|
62
|
+
|
|
63
|
+
def __iter__(self):
|
|
64
|
+
return iter(self.items)
|
|
65
|
+
|
|
66
|
+
def __getitem__(self, idx: int) -> Prior:
|
|
67
|
+
return self.items[idx]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def names(self) -> list[str]:
|
|
71
|
+
return [prior.name or f"theta_{i}" for i, prior in enumerate(self.items)]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def distributions(self) -> list[Distribution]:
|
|
75
|
+
return [prior.distribution for prior in self.items]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def means(self) -> np.ndarray:
|
|
79
|
+
return np.array([prior.mean for prior in self.items], dtype=float)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def scales(self) -> np.ndarray:
|
|
83
|
+
return np.array([prior.scale for prior in self.items], dtype=float)
|
|
84
|
+
|
|
85
|
+
def log_prob(self, theta: torch.Tensor) -> torch.Tensor:
|
|
86
|
+
if theta.dim() == 1:
|
|
87
|
+
theta = theta.unsqueeze(0)
|
|
88
|
+
log_probs = [
|
|
89
|
+
prior.distribution.log_prob(theta[:, i]).to(theta.device)
|
|
90
|
+
for i, prior in enumerate(self.items)
|
|
91
|
+
]
|
|
92
|
+
return torch.stack(log_probs, dim=1).sum(dim=1)
|
|
93
|
+
|
|
94
|
+
def write(self, fn_out: PathLike) -> None:
|
|
95
|
+
fn_out = Path(fn_out)
|
|
96
|
+
data = {"priors": [prior.to_dict() for prior in self.items]}
|
|
97
|
+
torch.save(data, fn_out)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def load(cls, fn_in: PathLike) -> "Priors":
|
|
101
|
+
raw = torch.load(Path(fn_in), weights_only=False)
|
|
102
|
+
entries = raw["priors"] if isinstance(raw, dict) and "priors" in raw else raw
|
|
103
|
+
return cls([cls._coerce_prior(entry) for entry in entries])
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_any(cls, priors: Union["Priors", PathLike, Iterable]) -> "Priors":
|
|
107
|
+
if isinstance(priors, cls):
|
|
108
|
+
return priors
|
|
109
|
+
if isinstance(priors, (str, Path)):
|
|
110
|
+
return cls.load(priors)
|
|
111
|
+
return cls([cls._coerce_prior(entry) for entry in priors])
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _coerce_prior(entry) -> Prior:
|
|
115
|
+
if isinstance(entry, Prior):
|
|
116
|
+
return entry
|
|
117
|
+
|
|
118
|
+
if isinstance(entry, Distribution):
|
|
119
|
+
name = type(entry).__name__.lower()
|
|
120
|
+
if name == "normal":
|
|
121
|
+
return Prior("normal", float(entry.mean), float(entry.scale))
|
|
122
|
+
if name == "uniform":
|
|
123
|
+
return Prior("uniform", float(entry.low), float(entry.high))
|
|
124
|
+
raise ValueError(f"Unsupported distribution type: {name}")
|
|
125
|
+
|
|
126
|
+
if isinstance(entry, dict):
|
|
127
|
+
return Prior(
|
|
128
|
+
kind=entry["kind"],
|
|
129
|
+
a=entry["a"],
|
|
130
|
+
b=entry["b"],
|
|
131
|
+
name=entry.get("name"),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
raise TypeError(f"Unsupported prior entry type: {type(entry)}")
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_bounds(
|
|
138
|
+
cls,
|
|
139
|
+
bounds: ArrayLike,
|
|
140
|
+
dist_type: str = "normal",
|
|
141
|
+
n_nuisance: int = 0,
|
|
142
|
+
names: Optional[Sequence[str]] = None,
|
|
143
|
+
nuisance_names: Optional[Sequence[str]] = None,
|
|
144
|
+
) -> "Priors":
|
|
145
|
+
bounds = np.asarray(bounds, dtype=float)
|
|
146
|
+
dist_type = dist_type.lower()
|
|
147
|
+
|
|
148
|
+
if names is None:
|
|
149
|
+
names = [None] * len(bounds)
|
|
150
|
+
elif len(names) != len(bounds):
|
|
151
|
+
raise ValueError("names must match the number of parameter priors.")
|
|
152
|
+
|
|
153
|
+
if dist_type == "normal":
|
|
154
|
+
centers = bounds.mean(axis=1)
|
|
155
|
+
widths = 0.2 * (bounds[:, 1] - bounds[:, 0])
|
|
156
|
+
items = [
|
|
157
|
+
Prior("normal", center, width, name=name)
|
|
158
|
+
for center, width, name in zip(centers, widths, names)
|
|
159
|
+
]
|
|
160
|
+
elif dist_type == "uniform":
|
|
161
|
+
items = [
|
|
162
|
+
Prior("uniform", lower, upper, name=name)
|
|
163
|
+
for (lower, upper), name in zip(bounds, names)
|
|
164
|
+
]
|
|
165
|
+
else:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f'Unknown prior type "{dist_type}". Options are "normal" or "uniform".'
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
nuisance_names = nuisance_names or [f"nuisance_{i}" for i in range(n_nuisance)]
|
|
171
|
+
items.extend(Prior("normal", -2.0, 2.0, name=name) for name in nuisance_names)
|
|
172
|
+
return cls(items)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def log_prior(
|
|
176
|
+
theta: torch.Tensor,
|
|
177
|
+
priors: Union[Priors, Sequence[Distribution]],
|
|
178
|
+
) -> torch.Tensor:
|
|
179
|
+
return Priors.from_any(priors).log_prob(theta)
|
bff/bayes/results.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Callable, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import torch
|
|
7
|
+
from scipy.stats import gaussian_kde
|
|
8
|
+
|
|
9
|
+
from ..domain.specs import Specs
|
|
10
|
+
from ..io.utils import save_yaml
|
|
11
|
+
from ..mcmc.convergence import integrated_autocorr_time
|
|
12
|
+
from .priors import Priors
|
|
13
|
+
|
|
14
|
+
PathLike = Union[str, Path]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class InferenceResults:
|
|
19
|
+
posterior: np.ndarray
|
|
20
|
+
priors: Optional[Priors] = None
|
|
21
|
+
sample_labels: Optional[List[str]] = None
|
|
22
|
+
sample_transform: Optional[Callable[[np.ndarray], np.ndarray]] = None
|
|
23
|
+
specs: Optional[Specs] = None
|
|
24
|
+
include_implicit_charge: bool = False
|
|
25
|
+
|
|
26
|
+
_prepared_samples: Optional[np.ndarray] = field(
|
|
27
|
+
default=None,
|
|
28
|
+
init=False,
|
|
29
|
+
repr=False,
|
|
30
|
+
)
|
|
31
|
+
_tau: Optional[np.ndarray] = field(default=None, init=False, repr=False)
|
|
32
|
+
_prepared_labels: Optional[list[str]] = field(default=None, init=False, repr=False)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def load(
|
|
36
|
+
cls,
|
|
37
|
+
posterior: PathLike | np.ndarray | torch.Tensor,
|
|
38
|
+
priors: Optional[Union[Priors, PathLike, list]] = None,
|
|
39
|
+
sample_labels: Optional[List[str]] = None,
|
|
40
|
+
sample_transform: Optional[Callable[[np.ndarray], np.ndarray]] = None,
|
|
41
|
+
specs: Optional[Specs | PathLike] = None,
|
|
42
|
+
include_implicit_charge: bool = False,
|
|
43
|
+
) -> "InferenceResults":
|
|
44
|
+
return cls(
|
|
45
|
+
posterior=posterior,
|
|
46
|
+
priors=priors,
|
|
47
|
+
sample_labels=sample_labels,
|
|
48
|
+
sample_transform=sample_transform,
|
|
49
|
+
specs=specs,
|
|
50
|
+
include_implicit_charge=include_implicit_charge,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
self.posterior = self._coerce_posterior(self.posterior)
|
|
55
|
+
if self.priors is not None:
|
|
56
|
+
self.priors = self._coerce_priors(self.priors)
|
|
57
|
+
if self.specs is not None and not isinstance(self.specs, Specs):
|
|
58
|
+
self.specs = Specs(self.specs)
|
|
59
|
+
if self.sample_labels is None and self.priors is not None:
|
|
60
|
+
self.sample_labels = self._default_sample_labels()
|
|
61
|
+
if self.sample_transform is None and self._nuisance_indices:
|
|
62
|
+
self.sample_transform = self._default_transform
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def _nuisance_indices(self) -> list[int]:
|
|
66
|
+
if self.priors is None:
|
|
67
|
+
return []
|
|
68
|
+
return [
|
|
69
|
+
i for i, name in enumerate(self.priors.names)
|
|
70
|
+
if name.startswith("log_sigma_")
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def _default_sample_labels(self) -> list[str]:
|
|
74
|
+
if self.priors is None:
|
|
75
|
+
return [f"theta_{i}" for i in range(self.n_dim)]
|
|
76
|
+
labels = []
|
|
77
|
+
for i, name in enumerate(self.priors.names):
|
|
78
|
+
if name.startswith("log_sigma_"):
|
|
79
|
+
qoi = name.removeprefix("log_sigma_")
|
|
80
|
+
labels.append(f"$\\sigma_{{\\mathrm{{{qoi}}}}}$")
|
|
81
|
+
else:
|
|
82
|
+
labels.append(name or f"theta_{i}")
|
|
83
|
+
return labels
|
|
84
|
+
|
|
85
|
+
def _default_transform(self, samples: np.ndarray) -> np.ndarray:
|
|
86
|
+
transformed = np.asarray(samples, dtype=float).copy()
|
|
87
|
+
if self._nuisance_indices:
|
|
88
|
+
transformed[:, self._nuisance_indices] = np.exp(
|
|
89
|
+
transformed[:, self._nuisance_indices]
|
|
90
|
+
)
|
|
91
|
+
return transformed
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _to_numpy(x: np.ndarray | torch.Tensor) -> np.ndarray:
|
|
95
|
+
if isinstance(x, torch.Tensor):
|
|
96
|
+
return x.detach().cpu().numpy()
|
|
97
|
+
return np.asarray(x, dtype=float)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _coerce_priors(priors: Priors | PathLike | list) -> Priors:
|
|
101
|
+
if isinstance(priors, Priors):
|
|
102
|
+
return priors
|
|
103
|
+
return Priors.from_any(priors)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _load_posterior_payload(fn_in: PathLike) -> dict[str, Any]:
|
|
107
|
+
fn_in = Path(fn_in)
|
|
108
|
+
if fn_in.suffix != ".pt":
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"Unsupported posterior file {fn_in!r}. "
|
|
111
|
+
"InferenceResults accepts only .pt files."
|
|
112
|
+
)
|
|
113
|
+
return torch.load(fn_in, weights_only=False)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def _coerce_posterior(
|
|
117
|
+
cls,
|
|
118
|
+
posterior: PathLike | np.ndarray | torch.Tensor,
|
|
119
|
+
) -> np.ndarray:
|
|
120
|
+
if isinstance(posterior, (np.ndarray, torch.Tensor)):
|
|
121
|
+
posterior_array = cls._to_numpy(posterior)
|
|
122
|
+
elif isinstance(posterior, (str, Path)):
|
|
123
|
+
payload = cls._load_posterior_payload(posterior)
|
|
124
|
+
if "posterior" not in payload:
|
|
125
|
+
raise KeyError(
|
|
126
|
+
f"Posterior file {posterior!r} does not contain posterior samples."
|
|
127
|
+
)
|
|
128
|
+
posterior_array = cls._to_numpy(payload["posterior"])
|
|
129
|
+
else:
|
|
130
|
+
raise TypeError(f"Unsupported posterior source: {type(posterior)}")
|
|
131
|
+
|
|
132
|
+
if posterior_array.ndim != 3:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"Expected raw posterior with shape "
|
|
135
|
+
f"(n_saved, n_walkers, n_dim), got {posterior_array.shape}."
|
|
136
|
+
)
|
|
137
|
+
return posterior_array
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def n_dim(self) -> int:
|
|
141
|
+
return self.posterior.shape[-1]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def autocorr_time(self) -> np.ndarray:
|
|
145
|
+
if self._tau is None:
|
|
146
|
+
tau = integrated_autocorr_time(
|
|
147
|
+
torch.as_tensor(self.posterior, dtype=torch.float32)
|
|
148
|
+
).mean(dim=0)
|
|
149
|
+
self._tau = np.atleast_1d(tau.detach().cpu().numpy().astype(float))
|
|
150
|
+
return self._tau
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def prepared_samples(self) -> np.ndarray:
|
|
154
|
+
if self._prepared_samples is None:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
"No prepared samples available. Call prepare_samples() first."
|
|
157
|
+
)
|
|
158
|
+
return self._prepared_samples
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def has_prepared_samples(self) -> bool:
|
|
162
|
+
return self._prepared_samples is not None
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def labels(self) -> List[str]:
|
|
166
|
+
if self._prepared_samples is not None and self._prepared_labels is not None:
|
|
167
|
+
return self._prepared_labels
|
|
168
|
+
if self.sample_labels is not None:
|
|
169
|
+
if len(self.sample_labels) != self.n_dim:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"sample_labels length does not match the posterior dimension."
|
|
172
|
+
)
|
|
173
|
+
return self.sample_labels
|
|
174
|
+
return [f"theta_{i}" for i in range(self.n_dim)]
|
|
175
|
+
|
|
176
|
+
def _labels_with_implicit_charge(self) -> list[str]:
|
|
177
|
+
if self.specs is None:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"Implicit-charge expansion requires Specs to be attached to "
|
|
180
|
+
"InferenceResults."
|
|
181
|
+
)
|
|
182
|
+
raw_labels = (
|
|
183
|
+
list(self.sample_labels)
|
|
184
|
+
if self.sample_labels is not None
|
|
185
|
+
else [f"theta_{i}" for i in range(self.n_dim)]
|
|
186
|
+
)
|
|
187
|
+
n_explicit = self.specs.explicit_bounds.n_params
|
|
188
|
+
if len(raw_labels) < n_explicit:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
"Posterior labels do not cover all explicit parameters required "
|
|
191
|
+
"by the specs."
|
|
192
|
+
)
|
|
193
|
+
insert_at = self.specs.implicit_param_index
|
|
194
|
+
implicit_param = self.specs.implicit_param
|
|
195
|
+
return (
|
|
196
|
+
raw_labels[:insert_at]
|
|
197
|
+
+ [implicit_param]
|
|
198
|
+
+ raw_labels[insert_at:]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def prepare_samples(
|
|
202
|
+
self,
|
|
203
|
+
discard: Optional[int] = None,
|
|
204
|
+
thin: Optional[int] = None,
|
|
205
|
+
sample_transform: Optional[Callable[[np.ndarray], np.ndarray]] = None,
|
|
206
|
+
strip_outliers: bool = True
|
|
207
|
+
) -> None:
|
|
208
|
+
tau = self.autocorr_time
|
|
209
|
+
discard = discard if discard is not None else int(2 * np.max(tau))
|
|
210
|
+
thin = thin if thin is not None else max(1, int(0.5 * np.min(tau)))
|
|
211
|
+
|
|
212
|
+
samples = self.posterior[discard::thin]
|
|
213
|
+
if samples.size == 0:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
"No posterior samples remain after applying discard/thin. "
|
|
216
|
+
"Try smaller values."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
prepared = samples.reshape(-1, samples.shape[-1]).copy()
|
|
220
|
+
transform_fn = sample_transform or self.sample_transform
|
|
221
|
+
if transform_fn is not None:
|
|
222
|
+
prepared = np.asarray(transform_fn(prepared), dtype=float)
|
|
223
|
+
if self.include_implicit_charge:
|
|
224
|
+
if self.specs is None:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
"include_implicit_charge=True requires Specs to be provided."
|
|
227
|
+
)
|
|
228
|
+
prepared = self.specs.with_implicit_charge(prepared)
|
|
229
|
+
self._prepared_labels = self._labels_with_implicit_charge()
|
|
230
|
+
else:
|
|
231
|
+
self._prepared_labels = (
|
|
232
|
+
list(self.sample_labels)
|
|
233
|
+
if self.sample_labels is not None
|
|
234
|
+
else [f"theta_{i}" for i in range(prepared.shape[1])]
|
|
235
|
+
)
|
|
236
|
+
self._prepared_samples = prepared
|
|
237
|
+
|
|
238
|
+
if strip_outliers:
|
|
239
|
+
# Remove samples outside the 99% central interval to avoid skewing plots.
|
|
240
|
+
q_low = 0.01 / 2
|
|
241
|
+
q_high = 1 - q_low
|
|
242
|
+
confint = np.quantile(prepared, [q_low, q_high], axis=0)
|
|
243
|
+
mask = np.all((prepared >= confint[0]) & (prepared <= confint[1]), axis=1)
|
|
244
|
+
self._prepared_samples = prepared[mask]
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def map_estimates(self) -> dict[str, float]:
|
|
248
|
+
modes: list[float] = []
|
|
249
|
+
for col in self.prepared_samples.T:
|
|
250
|
+
grid = np.linspace(col.min(), col.max(), 1000)
|
|
251
|
+
density = gaussian_kde(col)
|
|
252
|
+
modes.append(float(np.round(grid[np.argmax(density(grid))], 3)))
|
|
253
|
+
return dict(zip(self.labels, modes))
|
|
254
|
+
|
|
255
|
+
def quantiles(self, confidence: float = 0.95) -> np.ndarray:
|
|
256
|
+
q_low = (1 - confidence) / 2
|
|
257
|
+
q_high = 1 - q_low
|
|
258
|
+
return np.quantile(self.prepared_samples, [q_low, 0.5, q_high], axis=0)
|
|
259
|
+
|
|
260
|
+
def _export_parameter_indices_and_labels(self) -> tuple[list[int], list[str]]:
|
|
261
|
+
if self.specs is not None:
|
|
262
|
+
explicit_names = list(self.specs.parameter_names(explicit_only=True))
|
|
263
|
+
label_to_index = {label: i for i, label in enumerate(self.labels)}
|
|
264
|
+
missing = [name for name in explicit_names if name not in label_to_index]
|
|
265
|
+
if missing:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"Prepared samples do not contain all explicit parameter "
|
|
268
|
+
"labels required by the specs: "
|
|
269
|
+
+ ", ".join(repr(name) for name in missing)
|
|
270
|
+
)
|
|
271
|
+
return [label_to_index[name] for name in explicit_names], explicit_names
|
|
272
|
+
|
|
273
|
+
if self.priors is not None:
|
|
274
|
+
indices = [
|
|
275
|
+
i for i, name in enumerate(self.priors.names)
|
|
276
|
+
if not name.startswith("log_sigma_")
|
|
277
|
+
]
|
|
278
|
+
labels = [self.labels[i] for i in indices]
|
|
279
|
+
return indices, labels
|
|
280
|
+
|
|
281
|
+
return list(range(self.prepared_samples.shape[1])), list(self.labels)
|
|
282
|
+
|
|
283
|
+
def _parameter_draw_source(self) -> tuple[np.ndarray, list[str]]:
|
|
284
|
+
indices, labels = self._export_parameter_indices_and_labels()
|
|
285
|
+
return np.asarray(self.prepared_samples[:, indices], dtype=float), labels
|
|
286
|
+
|
|
287
|
+
def sample_parameters(
|
|
288
|
+
self,
|
|
289
|
+
n_samples: int = 10,
|
|
290
|
+
distribution: str = "normal",
|
|
291
|
+
confidence: float = 0.9,
|
|
292
|
+
fn_out: Optional[str] = None,
|
|
293
|
+
overwrite: bool = False,
|
|
294
|
+
) -> np.ndarray:
|
|
295
|
+
param_samples, export_labels = self._parameter_draw_source()
|
|
296
|
+
q_low = (1 - confidence) / 2
|
|
297
|
+
q_high = 1 - q_low
|
|
298
|
+
confint = np.quantile(param_samples, [q_low, q_high], axis=0)
|
|
299
|
+
|
|
300
|
+
if distribution == "normal":
|
|
301
|
+
mean = np.mean(param_samples, axis=0)
|
|
302
|
+
cov = np.cov(param_samples, rowvar=False)
|
|
303
|
+
if np.ndim(cov) == 0:
|
|
304
|
+
draws = np.random.normal(mean, np.sqrt(cov), size=n_samples)[:, None]
|
|
305
|
+
else:
|
|
306
|
+
draws = np.random.multivariate_normal(mean, cov, size=n_samples)
|
|
307
|
+
elif distribution == "uniform":
|
|
308
|
+
widths = np.diff(confint, axis=0).ravel()
|
|
309
|
+
draws = np.random.rand(n_samples, widths.size) * widths + confint[0]
|
|
310
|
+
else:
|
|
311
|
+
raise ValueError('distribution must be "uniform" or "normal".')
|
|
312
|
+
|
|
313
|
+
if fn_out:
|
|
314
|
+
fn_out = Path(fn_out).resolve()
|
|
315
|
+
if fn_out.exists() and not overwrite:
|
|
316
|
+
raise FileExistsError(f"File '{fn_out}' already exists.")
|
|
317
|
+
if fn_out.suffix != ".yaml":
|
|
318
|
+
raise ValueError("fn_out must end with .yaml")
|
|
319
|
+
save_yaml(
|
|
320
|
+
{
|
|
321
|
+
label: draws[:, i].tolist()
|
|
322
|
+
for i, label in enumerate(export_labels)
|
|
323
|
+
},
|
|
324
|
+
fn_out,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return np.asarray(draws, dtype=float)
|