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/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
bff/mcmc/convergence.py
ADDED
|
@@ -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
|
+
)
|