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/mcmc/sampler.py ADDED
@@ -0,0 +1,481 @@
1
+ import time
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Any, Callable, Optional, Tuple
5
+
6
+ import torch
7
+
8
+ from .convergence import (
9
+ ConvergenceInfo,
10
+ integrated_autocorr_time,
11
+ rank_normalize,
12
+ rank_normalized_split_rhat,
13
+ )
14
+ from .proposal import Proposal
15
+
16
+
17
+ @dataclass
18
+ class Checkpoint:
19
+ step: int
20
+ total_steps: int
21
+ phase: str
22
+ warmup: int
23
+ thin: int
24
+ progress_stride: int
25
+ p: Optional[torch.Tensor] = None
26
+ logp: Optional[torch.Tensor] = None
27
+ accepted: Optional[torch.Tensor] = None
28
+ posterior: Optional[torch.Tensor] = None
29
+ acceptance_rate: Optional[float] = None
30
+ scale: Optional[float] = None
31
+ convergence: Optional[ConvergenceInfo] = None
32
+ it_per_sec: Optional[float] = None
33
+ rng_state: Optional[torch.Tensor] = None
34
+ proposal_state: Optional[dict[str, Any]] = None
35
+ converged: bool = False
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return {
39
+ "p": self.p,
40
+ "logp": self.logp,
41
+ "accepted": self.accepted,
42
+ "posterior": self.posterior,
43
+ "step": self.step,
44
+ "total_steps": self.total_steps,
45
+ "warmup": self.warmup,
46
+ "thin": self.thin,
47
+ "progress_stride": self.progress_stride,
48
+ "rng_state": self.rng_state,
49
+ "proposal_state": self.proposal_state,
50
+ "converged": self.converged,
51
+ "phase": self.phase,
52
+ "acceptance_rate": self.acceptance_rate,
53
+ "scale": self.scale,
54
+ "it_per_sec": self.it_per_sec,
55
+ "convergence": self._serialize_convergence(self.convergence),
56
+ }
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: dict[str, Any]) -> "Checkpoint":
60
+ posterior = data.get("posterior", data.get("chain"))
61
+ if posterior is None:
62
+ raise KeyError("Checkpoint is missing 'posterior'.")
63
+ convergence = cls._deserialize_convergence(data.get("convergence"))
64
+ return cls(
65
+ p=data["p"],
66
+ logp=data["logp"],
67
+ accepted=data["accepted"],
68
+ posterior=posterior,
69
+ step=int(data["step"]),
70
+ total_steps=int(data["total_steps"]),
71
+ phase=data.get("phase", "sampling"),
72
+ acceptance_rate=data.get("acceptance_rate"),
73
+ scale=data.get("scale"),
74
+ convergence=convergence,
75
+ it_per_sec=data.get("it_per_sec"),
76
+ warmup=int(data["warmup"]),
77
+ thin=int(data["thin"]),
78
+ progress_stride=int(data["progress_stride"]),
79
+ rng_state=data["rng_state"],
80
+ proposal_state=data["proposal_state"],
81
+ converged=bool(data.get("converged", False)),
82
+ )
83
+
84
+ @classmethod
85
+ def load(cls, fn: Path | str) -> "Checkpoint":
86
+ return cls.from_dict(torch.load(fn, weights_only=False))
87
+
88
+ def write(self, fn: Path | str) -> None:
89
+ torch.save(self.to_dict(), fn)
90
+
91
+ def restore(
92
+ self,
93
+ sampler: "Sampler",
94
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, int]:
95
+ if self.p is None or self.logp is None or self.accepted is None:
96
+ raise ValueError("Checkpoint is missing sampler state.")
97
+ if self.posterior is None:
98
+ raise ValueError("Checkpoint is missing posterior samples.")
99
+ if self.rng_state is None or self.proposal_state is None:
100
+ raise ValueError("Checkpoint is missing restart metadata.")
101
+
102
+ p = sampler._to_tensor(self.p)
103
+ logp = sampler._to_tensor(self.logp)
104
+ accepted = sampler._to_tensor(self.accepted).to(torch.int64)
105
+
106
+ posterior = sampler._to_tensor(self.posterior)
107
+ if posterior.numel():
108
+ sampler._chain = [
109
+ posterior[i].detach().clone() for i in range(posterior.shape[0])
110
+ ]
111
+ else:
112
+ sampler._chain = []
113
+
114
+ sampler.rng.set_state(self.rng_state)
115
+ sampler.proposal.load_state_dict(self.proposal_state)
116
+ sampler.converged = self.converged
117
+
118
+ return p, logp, accepted, self.step
119
+
120
+ @property
121
+ def tau(self) -> torch.Tensor | None:
122
+ if self.convergence is None or self.convergence.tau is None:
123
+ return None
124
+ tau = self.convergence.tau
125
+ return tau.mean(dim=0) if tau.ndim > 1 else tau
126
+
127
+ @staticmethod
128
+ def _serialize_convergence(
129
+ convergence: Optional[ConvergenceInfo],
130
+ ) -> dict[str, torch.Tensor | None] | None:
131
+ if convergence is None:
132
+ return None
133
+ return {
134
+ "rhat": convergence.rhat.cpu() if convergence.rhat is not None else None,
135
+ "tau": convergence.tau.cpu() if convergence.tau is not None else None,
136
+ "ess": convergence.ess.cpu() if convergence.ess is not None else None,
137
+ }
138
+
139
+ @staticmethod
140
+ def _deserialize_convergence(
141
+ data: dict[str, torch.Tensor | None] | None,
142
+ ) -> Optional[ConvergenceInfo]:
143
+ if data is None:
144
+ return None
145
+ return ConvergenceInfo(
146
+ rhat=data.get("rhat"),
147
+ tau=data.get("tau"),
148
+ ess=data.get("ess"),
149
+ )
150
+
151
+
152
+ class Sampler:
153
+ """
154
+ Parallel Metropolis-Hastings sampler with walker-vectorized target evaluation.
155
+
156
+ The sampler assumes that the target log-probability function accepts a batch
157
+ of walker positions of shape ``(n_walkers, n_dim)`` and returns one log
158
+ probability per walker.
159
+
160
+ Parameters
161
+ ----------
162
+ log_prob : callable
163
+ Target log-probability function with signature
164
+ ``log_prob(x, *args) -> np.ndarray``, where ``x`` has shape
165
+ ``(n_walkers, n_dim)`` and the return value has shape ``(n_walkers,)``.
166
+ proposal : Proposal
167
+ Proposal mechanism used to generate trial moves.
168
+ args : tuple, optional
169
+ Additional arguments passed to ``log_prob``.
170
+ rng : np.random.Generator, optional
171
+ Random number generator. If ``None``, a new default generator is created.
172
+ """
173
+
174
+ def __init__(
175
+ self,
176
+ log_prob: Callable,
177
+ proposal: Proposal,
178
+ args: Tuple = (),
179
+ rng: Optional[torch.Generator] = None,
180
+ device: Optional[torch.device] = 'cuda',
181
+ dtype: Optional[torch.dtype] = torch.float32,
182
+ ) -> None:
183
+ self.log_prob = log_prob
184
+ self.proposal = proposal
185
+ self.args = args
186
+ self.device = device
187
+ self.dtype = dtype
188
+
189
+ if rng is None:
190
+ rng = torch.Generator(device=self.device)
191
+ rng.manual_seed(torch.seed())
192
+ self.rng = rng
193
+
194
+ self._chain: list[torch.Tensor] = []
195
+ self.converged = False
196
+ self._warmup = 0
197
+ self._thin = 1
198
+ self._progress_stride = 1
199
+ self._total_steps = 0
200
+
201
+ @property
202
+ def chain(self) -> torch.Tensor:
203
+ """Saved chain of walker positions.
204
+
205
+ Returns
206
+ -------
207
+ torch.Tensor
208
+ Tensor of shape ``(n_saved, n_walkers, n_dim)``.
209
+ Returns an empty tensor if no samples have been saved yet.
210
+ """
211
+ if not self._chain:
212
+ return torch.empty((0, 0, 0), dtype=self.dtype, device=self.device)
213
+ return torch.stack(self._chain, dim=0)
214
+
215
+ def _to_tensor(self, x) -> torch.Tensor:
216
+ if isinstance(x, torch.Tensor):
217
+ return x.to(device=self.device, dtype=self.dtype)
218
+ return torch.as_tensor(x, device=self.device, dtype=self.dtype)
219
+
220
+ def _rand(self, shape) -> torch.Tensor:
221
+ return torch.rand(
222
+ shape, generator=self.rng, device=self.device, dtype=self.dtype)
223
+
224
+ def _validate_inputs(
225
+ self,
226
+ p0: torch.Tensor,
227
+ total_steps: int,
228
+ warmup: int,
229
+ thin: int,
230
+ progress_stride: int,
231
+ ) -> torch.Tensor:
232
+ """Validate run inputs."""
233
+ p = self._to_tensor(p0)
234
+
235
+ if p.ndim != 2:
236
+ raise ValueError("p0 must have shape (n_walkers, n_dim)")
237
+ if total_steps < 1:
238
+ raise ValueError("total_steps must be positive")
239
+ if warmup < 0:
240
+ raise ValueError("warmup must be non-negative")
241
+ if warmup >= total_steps:
242
+ raise ValueError("warmup must be smaller than total_steps")
243
+ if thin < 1:
244
+ raise ValueError("thin must be >= 1")
245
+ if progress_stride < 1:
246
+ raise ValueError("progress_stride must be >= 1")
247
+ return p
248
+
249
+ def _log_prob(self, x: torch.Tensor) -> torch.Tensor:
250
+ """
251
+ Evaluate the target log-probability and sanitize non-finite values.
252
+
253
+ Parameters
254
+ ----------
255
+ x : torch.Tensor
256
+ Walker positions with shape ``(n_walkers, n_dim)``.
257
+
258
+ Returns
259
+ -------
260
+ torch.Tensor
261
+ Log probabilities with shape ``(n_walkers,)``. Non-finite values are
262
+ replaced by ``-inf``.
263
+ """
264
+ y = self._to_tensor(self.log_prob(x, *self.args))
265
+ if y.ndim != 1:
266
+ raise ValueError(
267
+ f"log_prob must return shape (n_walkers,), got {tuple(y.shape)}")
268
+ if y.shape[0] != x.shape[0]:
269
+ raise ValueError(
270
+ "log_prob returned wrong length: "
271
+ f"expected {x.shape[0]}, got {y.shape[0]}")
272
+ return torch.where(torch.isfinite(y), y, torch.full_like(y, -torch.inf))
273
+
274
+ def _diagnose_convergence(self, chain: torch.Tensor) -> ConvergenceInfo:
275
+ """
276
+ Compute convergence diagnostics from a chain tensor.
277
+ """
278
+ pooled = chain.reshape(-1, chain.shape[-1])
279
+ ranked_chain = rank_normalize(pooled).reshape(chain.shape)
280
+ rh = rank_normalized_split_rhat(chain)
281
+ tau = integrated_autocorr_time(ranked_chain)
282
+ n_total = chain.shape[0] * chain.shape[1]
283
+ ess = n_total / tau.mean(dim=0)
284
+
285
+ return ConvergenceInfo(
286
+ rhat=rh,
287
+ tau=tau,
288
+ ess=ess,
289
+ )
290
+
291
+ def _validate_checkpoint(self, checkpoint: Checkpoint) -> None:
292
+ if checkpoint.warmup != self._warmup:
293
+ raise ValueError(
294
+ f"Checkpoint warmup={checkpoint.warmup} "
295
+ f"does not match requested warmup={self._warmup}."
296
+ )
297
+ if checkpoint.thin != self._thin:
298
+ raise ValueError(
299
+ f"Checkpoint thin={checkpoint.thin} "
300
+ f"does not match requested thin={self._thin}."
301
+ )
302
+
303
+ def _should_stop(
304
+ self,
305
+ conv: Optional[ConvergenceInfo],
306
+ *,
307
+ rhat_tol: float,
308
+ ess_min: int,
309
+ ) -> bool:
310
+ if conv is None:
311
+ return False
312
+
313
+ rhat_ok = conv.max_rhat is not None and conv.max_rhat < rhat_tol
314
+ ess_ok = conv.min_ess is not None and conv.min_ess > ess_min
315
+ return rhat_ok and ess_ok
316
+
317
+ def write_posterior(self, fn: Path | str) -> None:
318
+ torch.save({"posterior": self.chain.detach().cpu()}, fn)
319
+
320
+ def run(
321
+ self,
322
+ p0: torch.Tensor,
323
+ total_steps: int = 1500,
324
+ warmup: int = 500,
325
+ thin: int = 1,
326
+ progress_stride: int = 100,
327
+ fn_checkpoint: Optional[str | Path] = None,
328
+ restart: bool = False,
329
+ rhat_tol: float = 1.01,
330
+ ess_min: int = 100,
331
+ ):
332
+ """
333
+ Run the sampler.
334
+
335
+ Parameters
336
+ ----------
337
+ p0 : torch.Tensor
338
+ Initial walker positions with shape ``(n_walkers, n_dim)``.
339
+ total_steps : int, default=1500
340
+ Total number of MCMC steps including warmup.
341
+ warmup : int, default=500
342
+ Number of warmup steps during which the proposal may adapt.
343
+ thin : int, default=1
344
+ Save every ``thin``-th production step.
345
+ progress_stride : int, default=100
346
+ Frequency of yielding progress information.
347
+ fn_chain: str or Path, optional
348
+ If provided, the chain will be saved to this file in
349
+ PyTorch format after each progress report.
350
+ restart: bool, default=False
351
+ If True and fn_chain exists, the sampler will attempt
352
+ to load the chain from the file
353
+ and resume sampling from the last saved position.
354
+ Otherwise, sampling starts from p0.
355
+ rhat_tol : float, default=1.01
356
+ Threshold for maximum R-hat to consider the chain converged.
357
+ ess_min : int, default=100
358
+ Minimum effective sample size to consider the chain converged.
359
+ Yields
360
+ ------
361
+ Checkpoint
362
+ Progress snapshot for monitoring and restart.
363
+ """
364
+
365
+ fn_checkpoint = Path(fn_checkpoint) if fn_checkpoint is not None else None
366
+ self.converged = False
367
+ self._warmup = warmup
368
+ self._thin = thin
369
+ self._progress_stride = progress_stride
370
+ self._total_steps = total_steps
371
+
372
+ if restart:
373
+ if fn_checkpoint is None or not fn_checkpoint.exists():
374
+ raise ValueError("restart=True requires an existing checkpoint file.")
375
+ p_probe = self._validate_inputs(
376
+ p0, total_steps, warmup, thin, progress_stride
377
+ )
378
+ _, n_dim = p_probe.shape
379
+ self.proposal.initialize(n_dim)
380
+ checkpoint = Checkpoint.load(fn_checkpoint)
381
+ self._validate_checkpoint(checkpoint)
382
+ p, logp, accepted, start_step = checkpoint.restore(self)
383
+ else:
384
+ p = self._validate_inputs(
385
+ p0, total_steps, warmup, thin, progress_stride
386
+ )
387
+ n_walkers, n_dim = p.shape
388
+ self.proposal.initialize(n_dim)
389
+ logp = self._log_prob(p)
390
+ accepted = torch.zeros(n_walkers, dtype=torch.int64, device=self.device)
391
+ self._chain = []
392
+ start_step = 0
393
+
394
+ n_walkers, n_dim = p.shape
395
+ last_report_time = time.time()
396
+
397
+ for t in range(start_step, self._total_steps):
398
+
399
+ # propose and evaluate
400
+ proposals = self._to_tensor(self.proposal.propose(p, self.rng))
401
+ logp_proposed = self._log_prob(proposals)
402
+
403
+ logu = torch.log(self._rand(n_walkers))
404
+ accept_mask = logu < (logp_proposed - logp)
405
+
406
+ p[accept_mask] = proposals[accept_mask]
407
+ logp[accept_mask] = logp_proposed[accept_mask]
408
+
409
+ # warmup / sampling
410
+ if t < warmup:
411
+ self.proposal.adapt(t + 1, p, accept_mask)
412
+ else:
413
+ accepted += accept_mask
414
+ if (t - warmup) % thin == 0:
415
+ self._chain.append(p.detach().clone())
416
+
417
+ report_progress = (
418
+ ((t + 1) % self._progress_stride == 0)
419
+ or (t == self._total_steps - 1)
420
+ )
421
+ if not report_progress:
422
+ continue
423
+
424
+ # number of iterations per second
425
+ now = time.time()
426
+ elapsed = max(now - last_report_time, 1e-12)
427
+ steps_since_start = t + 1 - start_step if t + 1 > start_step else 1
428
+ step_count = min(self._progress_stride, steps_since_start)
429
+ it_per_sec = step_count / elapsed
430
+ last_report_time = now
431
+
432
+ phase = "warmup" if t < warmup else "sampling"
433
+ chain = self.chain
434
+ convergence = None
435
+ if t >= warmup and chain.shape[0] >= 20:
436
+ convergence = self._diagnose_convergence(chain)
437
+
438
+ # acceptance rate during sampling phase
439
+ acceptance_rate = None
440
+ if t >= warmup:
441
+ n_sampling_steps_done = t + 1 - warmup
442
+ total_accepted = accepted.sum().item()
443
+ acceptance_rate = total_accepted / (n_sampling_steps_done * n_walkers)
444
+
445
+ should_stop = self._should_stop(
446
+ convergence,
447
+ rhat_tol=rhat_tol,
448
+ ess_min=ess_min,
449
+ )
450
+ if should_stop:
451
+ self.converged = True
452
+
453
+ # report progress
454
+ checkpoint = Checkpoint(
455
+ step=t + 1,
456
+ total_steps=self._total_steps,
457
+ phase=phase,
458
+ warmup=self._warmup,
459
+ thin=self._thin,
460
+ progress_stride=self._progress_stride,
461
+ p=p.detach().cpu(),
462
+ logp=logp.detach().cpu(),
463
+ accepted=accepted.detach().cpu(),
464
+ posterior=self.chain.detach().cpu(),
465
+ acceptance_rate=acceptance_rate,
466
+ scale=self.proposal.info().get("scale"),
467
+ convergence=convergence,
468
+ it_per_sec=it_per_sec,
469
+ rng_state=self.rng.get_state(),
470
+ proposal_state=self.proposal.state_dict(),
471
+ converged=self.converged,
472
+ )
473
+
474
+ if fn_checkpoint is not None:
475
+ checkpoint.write(fn_checkpoint)
476
+
477
+ yield checkpoint
478
+
479
+ # Early stopping based on convergence diagnostics
480
+ if should_stop:
481
+ break