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/io/utils.py ADDED
@@ -0,0 +1,208 @@
1
+ import json
2
+ import tarfile
3
+ from pathlib import Path
4
+ from typing import Union
5
+
6
+ import numpy as np
7
+ import yaml
8
+
9
+ PathLike = Union[str, Path]
10
+
11
+
12
+ def path_to_str(path: PathLike) -> str:
13
+ """Convert a Path object to a string."""
14
+ return str(path)
15
+
16
+
17
+ class NumpyYAMLEncoder(yaml.SafeDumper):
18
+ """YAML encoder for numpy arrays and numpy scalar types."""
19
+
20
+ def represent_numpy(self, obj):
21
+ if isinstance(obj, np.ndarray):
22
+ return self.represent_list(obj.tolist())
23
+ elif isinstance(obj, (np.generic, np.number)):
24
+ if isinstance(obj, np.floating):
25
+ return self.represent_float(obj.item())
26
+ else:
27
+ return self.represent_int(obj.item())
28
+ return super().represent_data(obj)
29
+
30
+
31
+ # Add the custom representers for NumPy types
32
+ NumpyYAMLEncoder.add_multi_representer(np.ndarray, NumpyYAMLEncoder.represent_numpy)
33
+ NumpyYAMLEncoder.add_multi_representer(np.generic, NumpyYAMLEncoder.represent_numpy)
34
+
35
+
36
+ def save_yaml(data: dict, fn: PathLike) -> None:
37
+ """Save a dictionary as YAML to a file."""
38
+ fn = str(fn) if isinstance(fn, Path) else fn
39
+ with open(fn, "w") as f:
40
+ yaml.dump(data, f, Dumper=NumpyYAMLEncoder, default_flow_style=False)
41
+
42
+
43
+ def load_yaml(fn: PathLike) -> dict:
44
+ """Load .yaml file into a dictionary"""
45
+ fn = path_to_str(fn) if isinstance(fn, Path) else fn
46
+ with open(fn, "r") as f:
47
+ file = yaml.safe_load(f)
48
+ return file
49
+
50
+
51
+ class NumpyArrayEncoder(json.JSONEncoder):
52
+ """JSON encoder for numpy arrays, scalars, and floats with 3 decimals."""
53
+
54
+ def default(self, obj):
55
+ if isinstance(obj, np.ndarray):
56
+ # Convert to nested lists, rounding elementwise
57
+ return self._round_nested(obj.tolist())
58
+ elif isinstance(obj, (np.generic, np.number)):
59
+ return round(obj.item(), 3)
60
+ elif isinstance(obj, float):
61
+ return round(obj, 3)
62
+ elif hasattr(obj, '__dict__'):
63
+ return obj.__dict__
64
+ return super().default(obj)
65
+
66
+ def _round_nested(self, obj):
67
+ """Recursively round floats in nested lists/structs."""
68
+ if isinstance(obj, list):
69
+ return [self._round_nested(x) for x in obj]
70
+ elif isinstance(obj, float):
71
+ return round(obj, 3)
72
+ return obj
73
+
74
+
75
+ def save_json(data: dict, fn: PathLike) -> None:
76
+ """Save a dictionary as JSON with floats rounded to 3 decimals."""
77
+ fn = str(fn) if isinstance(fn, Path) else fn
78
+ with open(fn, "w") as f:
79
+ json.dump(data, f, cls=NumpyArrayEncoder)
80
+
81
+
82
+ def load_json(fn: PathLike) -> dict:
83
+ """Save a dictionary as JSON to a file."""
84
+ fn = path_to_str(fn) if isinstance(fn, Path) else fn
85
+ with open(fn, "r") as f:
86
+ file = json.load(f)
87
+ return file
88
+
89
+
90
+ def _to_json_compatible(data: object) -> object:
91
+ """Convert nested numpy objects into plain JSON-compatible values."""
92
+ if isinstance(data, dict):
93
+ return {str(key): _to_json_compatible(value) for key, value in data.items()}
94
+ if isinstance(data, (list, tuple)):
95
+ return [_to_json_compatible(value) for value in data]
96
+ if isinstance(data, np.ndarray):
97
+ return data.tolist()
98
+ if isinstance(data, (np.generic, np.number)):
99
+ return data.item()
100
+ return data
101
+
102
+
103
+ def save_pt(data: object, fn: PathLike) -> None:
104
+ """Save a JSON-serializable object to a `.pt` file."""
105
+ path = Path(fn)
106
+ if path.suffix != ".pt":
107
+ raise ValueError(f"Expected a '.pt' file, got {path}.")
108
+ with open(path, "w", encoding="utf-8") as f:
109
+ json.dump(_to_json_compatible(data), f)
110
+
111
+
112
+ def load_pt(fn: PathLike) -> object:
113
+ """Load an object from a `.pt` file.
114
+
115
+ The preferred format is plain JSON. Legacy Torch `.pt` archives are still
116
+ accepted when PyTorch is available.
117
+ """
118
+ path = Path(fn)
119
+ if path.suffix != ".pt":
120
+ raise ValueError(f"Expected a '.pt' file, got {path}.")
121
+ try:
122
+ with open(path, "r", encoding="utf-8") as f:
123
+ return json.load(f)
124
+ except (UnicodeDecodeError, json.JSONDecodeError):
125
+ try:
126
+ import torch
127
+ except ModuleNotFoundError as exc:
128
+ raise RuntimeError(
129
+ f"Legacy binary .pt file detected at {path}, but PyTorch is not "
130
+ "installed. Install PyTorch or regenerate the file with the "
131
+ "current BFF version."
132
+ ) from exc
133
+ return torch.load(path, map_location="cpu", weights_only=False)
134
+
135
+
136
+ def compress_results(source_dir: PathLike) -> None:
137
+ """Compress the results into a tarball."""
138
+
139
+ extensions = {'.tpr', '.xtc', '.yaml', '.top', '.gro'}
140
+ source_path = Path(source_dir).resolve()
141
+ tar_filename = source_path.with_suffix('.tar.gz')
142
+ with tarfile.open(tar_filename, "w:gz") as tar:
143
+ for file_path in source_path.rglob('*'):
144
+ if file_path.suffix in extensions:
145
+ arcname = file_path.relative_to(source_path.parent)
146
+ tar.add(file_path, arcname=str(arcname))
147
+
148
+
149
+ def extract_tarball(fn: PathLike) -> None:
150
+ """Extract a tarball file into a directory."""
151
+ fn = path_to_str(fn) if isinstance(fn, Path) else fn
152
+ with tarfile.open(fn, "r:gz") as tar:
153
+ tar.extractall(path=fn.parent)
154
+
155
+
156
+ def prepare_path(fn: PathLike) -> Path:
157
+ """Prepare a directory from a tarball or validate its existence."""
158
+ if fn.suffix == ".gz":
159
+ train_dir = fn.parent / fn.name.replace(".tar.gz", "")
160
+ if not train_dir.exists():
161
+ extract_tarball(fn)
162
+ return train_dir
163
+ elif fn.is_dir():
164
+ return fn
165
+ else:
166
+ raise ValueError(f"Unknown file type: {fn}")
167
+
168
+
169
+ def extract_train_dir(
170
+ train_dir: PathLike
171
+ ) -> tuple[dict | None, list[dict], dict | None]:
172
+
173
+ """Extract training directory contents.
174
+
175
+ Parameters
176
+ ----------
177
+ train_dir : Path
178
+ Path to the training directory.
179
+
180
+ Returns
181
+ -------
182
+ tuple
183
+ A tuple containing:
184
+ - specs (dict | None): Specifications loaded from specs.yaml.
185
+ - systems (list[dict]): Staged system records from samples.yaml.
186
+ - samples (dict | None): Sample records loaded from samples.yaml.
187
+ """
188
+ if not train_dir.is_dir():
189
+ raise ValueError(f"Provided train_dir '{train_dir}' is not a valid directory.")
190
+
191
+ fn_specs = train_dir / "specs.yaml"
192
+ specs = load_yaml(fn_specs) if fn_specs.exists() else None
193
+
194
+ fn_samples = train_dir / "samples.yaml"
195
+ campaign = load_yaml(fn_samples) if fn_samples.exists() else None
196
+ systems = None if campaign is None else campaign.get("systems")
197
+ samples = None if campaign is None else campaign.get("samples")
198
+
199
+ if not systems:
200
+ raise ValueError(f"No staged simulation systems found in {train_dir}.")
201
+
202
+ fn_topol = [train_dir / system["topology"] for system in systems]
203
+ fn_coord = [train_dir / system["coordinates"] for system in systems]
204
+
205
+ if not all(path.exists() for path in fn_topol + fn_coord):
206
+ raise ValueError(f"Missing staged topology or coordinate files in {train_dir}.")
207
+
208
+ return specs, list(systems), samples
bff/mcmc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from . import convergence, proposal, sampler
2
+
3
+ __all__ = ["convergence", "proposal", "sampler"]
@@ -0,0 +1,168 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ import torch
5
+
6
+
7
+ @dataclass
8
+ class ConvergenceInfo:
9
+ rhat: Optional[torch.Tensor] = None
10
+ tau: Optional[torch.Tensor] = None # shape (n_walkers, n_dim)
11
+ ess: Optional[torch.Tensor] = None # shape (n_dim,)
12
+
13
+ @property
14
+ def max_rhat(self) -> Optional[float]:
15
+ return torch.max(self.rhat).item() if self.rhat is not None else None
16
+
17
+ @property
18
+ def min_ess(self) -> Optional[float]:
19
+ return torch.min(self.ess).item() if self.ess is not None else None
20
+
21
+
22
+ def split_rhat(chain: torch.Tensor) -> torch.Tensor:
23
+ """
24
+ Compute split R-hat for each parameter.
25
+
26
+ Parameters
27
+ ----------
28
+ chain : torch.Tensor
29
+ Shape (n_samples, n_walkers, n_dim).
30
+
31
+ Returns
32
+ -------
33
+ torch.Tensor
34
+ Shape (n_dim,).
35
+ """
36
+ n_samples, n_walkers, n_dim = chain.shape
37
+ half = n_samples // 2
38
+ if half < 2:
39
+ raise ValueError("Need at least 4 samples per walker for split R-hat.")
40
+
41
+ chains = chain.permute(1, 0, 2) # (n_walkers, n_samples, n_dim)
42
+ chains = torch.cat([chains[:, :half], chains[:, -half:]], dim=0)
43
+
44
+ _, n, _ = chains.shape
45
+ chain_means = chains.mean(dim=1)
46
+ chain_vars = chains.var(dim=1, unbiased=True)
47
+
48
+ W = chain_vars.mean(dim=0)
49
+ B = n * chain_means.var(dim=0, unbiased=True)
50
+ var_hat = ((n - 1) / n) * W + B / n
51
+
52
+ return torch.sqrt(var_hat / W)
53
+
54
+
55
+ def rank_normalize(values: torch.Tensor) -> torch.Tensor:
56
+ """Rank-normalize pooled samples along the first dimension."""
57
+ flat = values.reshape(values.shape[0], -1)
58
+ normalized = torch.empty_like(flat)
59
+ n = flat.shape[0]
60
+ denom = float(n) + 0.25
61
+ sqrt_two = torch.sqrt(torch.tensor(2.0, device=values.device))
62
+
63
+ for i in range(flat.shape[1]):
64
+ order = torch.argsort(flat[:, i], stable=True)
65
+ ranks = torch.empty(n, device=values.device, dtype=values.dtype)
66
+ ranks[order] = torch.arange(
67
+ 1,
68
+ n + 1,
69
+ device=values.device,
70
+ dtype=values.dtype,
71
+ )
72
+ u = (ranks - 0.375) / denom
73
+ u = torch.clamp(u, 1e-12, 1 - 1e-12)
74
+ normalized[:, i] = sqrt_two * torch.erfinv(2 * u - 1)
75
+
76
+ return normalized.reshape(values.shape)
77
+
78
+
79
+ def rank_normalized_split_rhat(chain: torch.Tensor) -> torch.Tensor:
80
+ """Compute rank-normalized split R-hat with folded refinement."""
81
+ pooled = chain.reshape(-1, chain.shape[-1])
82
+ ranked = rank_normalize(pooled).reshape(chain.shape)
83
+ rhat_rank = split_rhat(ranked)
84
+
85
+ pooled_median = pooled.median(dim=0).values
86
+ folded = torch.abs(chain - pooled_median.view(1, 1, -1))
87
+ ranked_folded = rank_normalize(
88
+ folded.reshape(-1, folded.shape[-1])
89
+ ).reshape(chain.shape)
90
+ rhat_folded = split_rhat(ranked_folded)
91
+ return torch.maximum(rhat_rank, rhat_folded)
92
+
93
+
94
+ def autocorrelation(x: torch.Tensor) -> torch.Tensor:
95
+ """
96
+ Compute autocorrelation along axis 0 using FFT.
97
+
98
+ Parameters
99
+ ----------
100
+ x : torch.Tensor
101
+ Shape (n_samples, ...).
102
+
103
+ Returns
104
+ -------
105
+ torch.Tensor
106
+ Shape (n_samples, ...).
107
+ """
108
+ if x.ndim == 0:
109
+ raise ValueError("x must have at least one dimension")
110
+ if x.shape[0] < 2:
111
+ return torch.ones_like(x)
112
+
113
+ n = x.shape[0]
114
+ x = x - x.mean(dim=0, keepdim=True)
115
+
116
+ nfft = 1 << (2 * n - 1).bit_length()
117
+ fx = torch.fft.rfft(x, n=nfft, dim=0)
118
+ acov = torch.fft.irfft(fx * torch.conj(fx), n=nfft, dim=0)[:n]
119
+
120
+ norm = torch.arange(n, 0, -1, device=x.device, dtype=x.dtype)
121
+ norm = norm.view((n,) + (1,) * (x.ndim - 1))
122
+ acov = acov / norm
123
+
124
+ acf = torch.zeros_like(acov)
125
+ var0 = acov[0]
126
+ mask = var0 > 0
127
+ acf[:, mask] = acov[:, mask] / var0[mask]
128
+ return acf
129
+
130
+
131
+ def integrated_autocorr_time(
132
+ x: torch.Tensor,
133
+ max_lag: Optional[int] = None
134
+ ) -> torch.Tensor:
135
+ """
136
+ Estimate integrated autocorrelation time along axis 0.
137
+
138
+ Parameters
139
+ ----------
140
+ x : torch.Tensor
141
+ Shape (n_samples, ...).
142
+ max_lag : int, optional
143
+ Maximum lag included.
144
+
145
+ Returns
146
+ -------
147
+ torch.Tensor
148
+ Shape x.shape[1:].
149
+ """
150
+ rho = autocorrelation(x)
151
+ n = rho.shape[0]
152
+
153
+ if max_lag is None:
154
+ max_lag = n - 1
155
+ max_lag = min(max_lag, n - 1)
156
+
157
+ tau = torch.ones(rho.shape[1:], device=x.device, dtype=x.dtype)
158
+ active = torch.ones_like(tau, dtype=torch.bool)
159
+
160
+ for k in range(1, max_lag + 1):
161
+ positive = rho[k] > 0
162
+ mask = active & positive
163
+ tau[mask] += 2.0 * rho[k][mask]
164
+ active &= positive
165
+ if not active.any():
166
+ break
167
+
168
+ return torch.clamp(tau, min=1.0)
bff/mcmc/proposal.py ADDED
@@ -0,0 +1,254 @@
1
+ from typing import Optional
2
+
3
+ import torch
4
+
5
+
6
+ class Proposal:
7
+ """Base class for proposal mechanisms."""
8
+
9
+ def __init__(
10
+ self,
11
+ device: str | torch.device = "cpu",
12
+ dtype: torch.dtype = torch.float64,
13
+ ) -> None:
14
+ self.device = torch.device(device)
15
+ self.dtype = dtype
16
+
17
+ def initialize(self, n_dim: int) -> None:
18
+ raise NotImplementedError
19
+
20
+ def propose(self, x: torch.Tensor, rng: torch.Generator) -> torch.Tensor:
21
+ raise NotImplementedError
22
+
23
+ def adapt(self, step: int, x: torch.Tensor, accepted: torch.Tensor) -> None:
24
+ pass
25
+
26
+ def info(self) -> dict:
27
+ return {}
28
+
29
+ def state_dict(self) -> dict:
30
+ return {}
31
+
32
+ def load_state_dict(self, state: dict) -> None:
33
+ pass
34
+
35
+
36
+ class AdaptiveGaussianProposal(Proposal):
37
+ """
38
+ Multivariate Gaussian random-walk proposal with optional adaptive covariance.
39
+
40
+ The proposal has the form
41
+
42
+ ``x_new = x + z @ L.T``,
43
+
44
+ where ``z ~ N(0, I)`` and ``L`` is the Cholesky factor of the proposal
45
+ covariance matrix.
46
+
47
+ During warmup, the empirical covariance of visited states can be used to
48
+ adapt the proposal shape and scale.
49
+
50
+ Parameters
51
+ ----------
52
+ proposal_cov : torch.Tensor, optional
53
+ Initial proposal covariance matrix of shape ``(n_dim, n_dim)``.
54
+ If ``None``, the identity matrix is used.
55
+ adapt : bool, default=True
56
+ Whether to adapt the proposal during warmup.
57
+ adapt_start : int, default=100
58
+ First warmup step at which adaptation is allowed.
59
+ adapt_interval : int, default=100
60
+ Adaptation frequency in number of warmup steps.
61
+ target_acceptance : float, default=0.234
62
+ Target acceptance rate used for multiplicative scale adaptation.
63
+ device : str or torch.device, default="cpu"
64
+ Device used internally by the proposal.
65
+ dtype : torch.dtype, default=torch.float64
66
+ Tensor dtype used internally.
67
+
68
+ Notes
69
+ -----
70
+ The covariance adaptation uses the empirical covariance of all walker
71
+ positions seen during warmup, together with the standard scaling factor
72
+ ``2.38**2 / n_dim``.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ proposal_cov: Optional[torch.Tensor] = None,
78
+ adapt: bool = True,
79
+ adapt_start: int = 100,
80
+ adapt_interval: int = 10,
81
+ target_acceptance: float = 0.234,
82
+ device: str | torch.device = "cpu",
83
+ dtype: torch.dtype = torch.float32,
84
+ ) -> None:
85
+ super().__init__(device=device, dtype=dtype)
86
+
87
+ self.proposal_cov = proposal_cov
88
+ self.do_adapt = adapt
89
+ self.adapt_start = adapt_start
90
+ self.adapt_interval = adapt_interval
91
+ self.target_acceptance = target_acceptance
92
+
93
+ self.eps = 1e-6
94
+ self.n_dim: Optional[int] = None
95
+ self.scale = 1.0
96
+ self.L: Optional[torch.Tensor] = None
97
+ self.n = 0
98
+ self.sum_x: Optional[torch.Tensor] = None
99
+ self.sum_xx: Optional[torch.Tensor] = None
100
+
101
+ def _to_tensor(self, x) -> torch.Tensor:
102
+ """Convert input to the proposal device and dtype."""
103
+ if isinstance(x, torch.Tensor):
104
+ return x.to(device=self.device, dtype=self.dtype)
105
+ return torch.as_tensor(x, device=self.device, dtype=self.dtype)
106
+
107
+ def _update_cov(self, x: torch.Tensor) -> None:
108
+ """
109
+ Update running first and second moments from a batch of walker positions.
110
+
111
+ Parameters
112
+ ----------
113
+ x : torch.Tensor
114
+ Walker positions with shape ``(n_walkers, n_dim)``.
115
+ """
116
+ x = self._to_tensor(x)
117
+ self.n += x.shape[0]
118
+ self.sum_x += x.sum(dim=0)
119
+ self.sum_xx += x.T @ x
120
+
121
+ def _randn(self, shape, rng: torch.Generator) -> torch.Tensor:
122
+ return torch.randn(shape, generator=rng, device=self.device, dtype=self.dtype)
123
+
124
+ @property
125
+ def cov(self) -> torch.Tensor:
126
+ """
127
+ Current empirical covariance estimate.
128
+
129
+ Returns
130
+ -------
131
+ torch.Tensor
132
+ Empirical covariance matrix of shape ``(n_dim, n_dim)``.
133
+ Returns the identity matrix if fewer than two samples have been seen.
134
+ """
135
+ if self.n < 2:
136
+ return torch.eye(self.n_dim, device=self.device, dtype=self.dtype)
137
+ mean = self.sum_x / self.n
138
+ return (self.sum_xx - self.n * torch.outer(mean, mean)) / (self.n - 1)
139
+
140
+ def initialize(self, n_dim: int) -> None:
141
+ """
142
+ Initialize proposal state for a given dimensionality.
143
+
144
+ Parameters
145
+ ----------
146
+ n_dim : int
147
+ Dimensionality of the sampled parameter vector.
148
+ """
149
+ self.n_dim = n_dim
150
+
151
+ if self.proposal_cov is None:
152
+ cov = torch.eye(n_dim, device=self.device, dtype=self.dtype)
153
+ else:
154
+ cov = self._to_tensor(self.proposal_cov)
155
+
156
+ unit_matrix = torch.eye(n_dim, device=self.device, dtype=self.dtype)
157
+ cov = 0.5 * (cov + cov.T) + self.eps * unit_matrix
158
+
159
+ self.scale = 1.0
160
+ self.L = torch.linalg.cholesky(cov)
161
+
162
+ self.n = 0
163
+ self.sum_x = torch.zeros(n_dim, device=self.device, dtype=self.dtype)
164
+ self.sum_xx = torch.zeros((n_dim, n_dim), device=self.device, dtype=self.dtype)
165
+
166
+ def propose(self, x: torch.Tensor, rng: torch.Generator) -> torch.Tensor:
167
+ """
168
+ Generate Gaussian random-walk proposals.
169
+
170
+ Parameters
171
+ ----------
172
+ x : torch.Tensor
173
+ Current walker positions with shape ``(n_walkers, n_dim)``.
174
+ rng : torch.Generator
175
+ Random number generator.
176
+
177
+ Returns
178
+ -------
179
+ torch.Tensor
180
+ Proposed walker positions with shape ``(n_walkers, n_dim)``.
181
+
182
+ Raises
183
+ ------
184
+ ValueError
185
+ If the supplied positions have the wrong dimensionality.
186
+ """
187
+ x = self._to_tensor(x)
188
+ if x.ndim != 2 or x.shape[1] != self.n_dim:
189
+ raise ValueError(
190
+ f"Expected shape (n_walkers, {self.n_dim}), "
191
+ f"got {tuple(x.shape)}"
192
+ )
193
+ z = self._randn(x.shape, rng)
194
+ return x + z @ self.L.T
195
+
196
+ def adapt(self, step: int, x: torch.Tensor, accepted: torch.Tensor) -> None:
197
+ """
198
+ Adapt the proposal covariance and scale during warmup.
199
+
200
+ Parameters
201
+ ----------
202
+ step : int
203
+ Current warmup step number, starting from 1.
204
+ x : torch.Tensor
205
+ Current walker positions with shape ``(n_walkers, n_dim)``.
206
+ accepted : torch.Tensor
207
+ Boolean mask of shape ``(n_walkers,)`` indicating accepted walkers.
208
+ """
209
+ if not self.do_adapt:
210
+ return
211
+
212
+ self._update_cov(x)
213
+
214
+ if step < self.adapt_start or step % self.adapt_interval != 0:
215
+ return
216
+
217
+ accept_mean = accepted.to(self.dtype).mean().item()
218
+ gamma = 1.0 / step**0.5
219
+ self.scale *= torch.exp(
220
+ torch.tensor(
221
+ gamma * (accept_mean - self.target_acceptance),
222
+ device=self.device,
223
+ dtype=self.dtype,
224
+ )
225
+ ).item()
226
+
227
+ cov = (2.38**2 / self.n_dim) * self.cov
228
+ unit_matrix = torch.eye(self.n_dim, device=self.device, dtype=self.dtype)
229
+ cov = cov + self.eps * unit_matrix
230
+ self.L = torch.linalg.cholesky((self.scale**2) * cov)
231
+
232
+ def state_dict(self) -> dict:
233
+ return {
234
+ "n_dim": self.n_dim,
235
+ "scale": self.scale,
236
+ "L": None if self.L is None else self.L.detach().cpu(),
237
+ "n": self.n,
238
+ "sum_x": None if self.sum_x is None else self.sum_x.detach().cpu(),
239
+ "sum_xx": None if self.sum_xx is None else self.sum_xx.detach().cpu(),
240
+ }
241
+
242
+ def load_state_dict(self, state: dict) -> None:
243
+ self.n_dim = state["n_dim"]
244
+ self.scale = state["scale"]
245
+ self.L = None if state["L"] is None else state["L"].to(
246
+ self.device, self.dtype
247
+ )
248
+ self.n = state["n"]
249
+ self.sum_x = None if state["sum_x"] is None else state["sum_x"].to(
250
+ self.device, self.dtype
251
+ )
252
+ self.sum_xx = None if state["sum_xx"] is None else (
253
+ state["sum_xx"].to(self.device, self.dtype)
254
+ )