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/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)