openmm-opes 0.1.0__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.
- openmm_opes/__init__.py +19 -0
- openmm_opes/io.py +102 -0
- openmm_opes/kde.py +450 -0
- openmm_opes/opes.py +465 -0
- openmm_opes-0.1.0.dist-info/METADATA +64 -0
- openmm_opes-0.1.0.dist-info/RECORD +9 -0
- openmm_opes-0.1.0.dist-info/WHEEL +5 -0
- openmm_opes-0.1.0.dist-info/licenses/LICENSE +21 -0
- openmm_opes-0.1.0.dist-info/top_level.txt +1 -0
openmm_opes/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""OpenMM implementation of On-the-fly Probability Enhanced Sampling (OPES)."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
__all__ = ["OPES", "__version__"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name):
|
|
9
|
+
"""Import OPES lazily (PEP 562).
|
|
10
|
+
|
|
11
|
+
An eager ``from .opes import OPES`` would also run on
|
|
12
|
+
``import openmm_opes.kde``, dragging openmm in and breaking the guarantee
|
|
13
|
+
that kde and io stay OpenMM-free. Task 1's test enforces that.
|
|
14
|
+
"""
|
|
15
|
+
if name == "OPES":
|
|
16
|
+
from .opes import OPES
|
|
17
|
+
|
|
18
|
+
return OPES
|
|
19
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
openmm_opes/io.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Multi-walker bias sharing through a shared directory.
|
|
2
|
+
|
|
3
|
+
Pure NumPy. This module must not import openmm, and deliberately knows
|
|
4
|
+
nothing about kernels: it moves flat dicts of arrays so it stays testable
|
|
5
|
+
without a simulation.
|
|
6
|
+
|
|
7
|
+
The format is ``np.savez``, not pickle. A walker reads files written by other
|
|
8
|
+
processes out of a shared directory, and ``pickle.load`` on data from another
|
|
9
|
+
process would be a remote-code-execution primitive. OpenMM's own Metadynamics
|
|
10
|
+
writes .npy for the same reason.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import warnings
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
FILENAME_PATTERN = re.compile(r"kde_(\d+)_(\d+)\.npz")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _LoadedBias:
|
|
25
|
+
"""A peer's most recently seen state. Mirrors Metadynamics' own namedtuple."""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("index", "state", "walkerId")
|
|
28
|
+
|
|
29
|
+
def __init__(self, walkerId: int, index: int, state: dict):
|
|
30
|
+
self.walkerId = walkerId
|
|
31
|
+
self.index = index
|
|
32
|
+
self.state = state
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BiasSharer:
|
|
36
|
+
"""Reads and writes walker bias states in a shared directory.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
biasDir
|
|
41
|
+
Directory shared by every walker.
|
|
42
|
+
walkerId
|
|
43
|
+
This walker's identifier. Drawn at random when omitted, from a fresh
|
|
44
|
+
generator so the global NumPy random state is left undisturbed.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, biasDir: str, walkerId: int | None = None):
|
|
48
|
+
self.biasDir = biasDir
|
|
49
|
+
self.walkerId = (
|
|
50
|
+
int(np.random.default_rng().integers(0x7FFFFFFF))
|
|
51
|
+
if walkerId is None
|
|
52
|
+
else walkerId
|
|
53
|
+
)
|
|
54
|
+
self._saveIndex = 0
|
|
55
|
+
self._loaded: dict[int, _LoadedBias] = {}
|
|
56
|
+
|
|
57
|
+
def _path(self, prefix: str, index: int) -> str:
|
|
58
|
+
return os.path.join(self.biasDir, f"{prefix}_{self.walkerId}_{index}.npz")
|
|
59
|
+
|
|
60
|
+
def save(self, state: dict) -> None:
|
|
61
|
+
"""Write this walker's state atomically and drop the previous index."""
|
|
62
|
+
oldName = self._path("kde", self._saveIndex)
|
|
63
|
+
self._saveIndex += 1
|
|
64
|
+
tempName = self._path("temp", self._saveIndex)
|
|
65
|
+
fileName = self._path("kde", self._saveIndex)
|
|
66
|
+
np.savez(tempName, **state)
|
|
67
|
+
os.rename(tempName, fileName)
|
|
68
|
+
if os.path.exists(oldName):
|
|
69
|
+
os.remove(oldName)
|
|
70
|
+
|
|
71
|
+
def load(self) -> dict[int, dict]:
|
|
72
|
+
"""Return peer states that are new or have advanced since the last call."""
|
|
73
|
+
updated: dict[int, dict] = {}
|
|
74
|
+
for filename in os.listdir(self.biasDir):
|
|
75
|
+
match = FILENAME_PATTERN.match(filename)
|
|
76
|
+
if match is None:
|
|
77
|
+
continue
|
|
78
|
+
walkerId, index = int(match.group(1)), int(match.group(2))
|
|
79
|
+
if walkerId == self.walkerId:
|
|
80
|
+
continue
|
|
81
|
+
known = self._loaded.get(walkerId)
|
|
82
|
+
if known is not None and index <= known.index:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
with np.load(
|
|
86
|
+
os.path.join(self.biasDir, filename), allow_pickle=False
|
|
87
|
+
) as data:
|
|
88
|
+
state = {key: data[key] for key in data.files}
|
|
89
|
+
except (OSError, ValueError):
|
|
90
|
+
warnings.warn(
|
|
91
|
+
f"The file {filename} seems to have been deleted. Using the "
|
|
92
|
+
"latest loaded data from the same walker.",
|
|
93
|
+
stacklevel=2,
|
|
94
|
+
)
|
|
95
|
+
continue
|
|
96
|
+
self._loaded[walkerId] = _LoadedBias(walkerId, index, state)
|
|
97
|
+
updated[walkerId] = state
|
|
98
|
+
return updated
|
|
99
|
+
|
|
100
|
+
def getLoadedStates(self) -> dict[int, dict]:
|
|
101
|
+
"""Every peer state seen so far, whether or not it changed recently."""
|
|
102
|
+
return {walkerId: bias.state for walkerId, bias in self._loaded.items()}
|
openmm_opes/kde.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Online kernel density estimation for OPES.
|
|
2
|
+
|
|
3
|
+
Pure NumPy/SciPy. This module must not import openmm.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
from collections import namedtuple
|
|
10
|
+
from copy import copy
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CVSpace:
|
|
16
|
+
"""The domain of the collective variables: grid, periodicity, boundaries.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
variables
|
|
21
|
+
Sequence of collective variable descriptions. Each must expose
|
|
22
|
+
``minValue``, ``maxValue``, ``gridWidth`` and ``periodic``; an
|
|
23
|
+
``openmm.app.BiasVariable`` qualifies.
|
|
24
|
+
bounded
|
|
25
|
+
Whether non-periodic variables have reflective boundaries. When true the
|
|
26
|
+
internal grid is tripled with mirrored copies on each side, and
|
|
27
|
+
:meth:`foldedGrid` folds them back.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
CV = namedtuple("CV", ["minValue", "maxValue", "gridWidth", "periodic"])
|
|
31
|
+
|
|
32
|
+
def __init__(self, variables, bounded: bool = False):
|
|
33
|
+
self.variables = [
|
|
34
|
+
self.CV(cv.minValue, cv.maxValue, cv.gridWidth, cv.periodic)
|
|
35
|
+
for cv in variables
|
|
36
|
+
]
|
|
37
|
+
self.bounded = bounded
|
|
38
|
+
self._periodic = any(cv.periodic for cv in self.variables)
|
|
39
|
+
self._grid = []
|
|
40
|
+
for cv in self.variables:
|
|
41
|
+
a, b, n = cv.minValue, cv.maxValue, cv.gridWidth
|
|
42
|
+
points = np.linspace(a, b, n)
|
|
43
|
+
if bounded and not cv.periodic:
|
|
44
|
+
left = np.linspace(2 * a - b, a, n)
|
|
45
|
+
right = np.linspace(b, 2 * b - a, n)
|
|
46
|
+
left[-1] = right[0] = np.inf
|
|
47
|
+
points = np.concatenate((points, np.flip(left), np.flip(right)))
|
|
48
|
+
self._grid.append(points)
|
|
49
|
+
self._widths = np.array([cv.gridWidth for cv in self.variables])
|
|
50
|
+
self._lbounds = np.array([cv.minValue for cv in self.variables])
|
|
51
|
+
self._lengths = np.array([cv.maxValue for cv in self.variables]) - self._lbounds
|
|
52
|
+
if self._periodic:
|
|
53
|
+
self._pdims = tuple(i for i, cv in enumerate(self.variables) if cv.periodic)
|
|
54
|
+
self._plbounds = self._lbounds[list(self._pdims)]
|
|
55
|
+
self._plengths = self._lengths[list(self._pdims)]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def gridShape(self) -> tuple[int, ...]:
|
|
59
|
+
"""Shape of the CV-space grid, slowest axis first."""
|
|
60
|
+
return tuple(int(width) for width in reversed(self._widths))
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def numDimensions(self) -> int:
|
|
64
|
+
"""Number of collective variables."""
|
|
65
|
+
return len(self.variables)
|
|
66
|
+
|
|
67
|
+
def displacement(self, position, endpoint):
|
|
68
|
+
"""Displacement from ``position`` to ``endpoint``, minimum-image if periodic."""
|
|
69
|
+
disp = endpoint - position
|
|
70
|
+
if self._periodic:
|
|
71
|
+
disp[..., self._pdims] -= self._plengths * np.rint(
|
|
72
|
+
disp[..., self._pdims] / self._plengths
|
|
73
|
+
)
|
|
74
|
+
return disp
|
|
75
|
+
|
|
76
|
+
def endpoint(self, position, displacement):
|
|
77
|
+
"""Endpoint reached from ``position``, wrapped into the periodic domain."""
|
|
78
|
+
end = position + displacement
|
|
79
|
+
if self._periodic:
|
|
80
|
+
end[..., self._pdims] = (
|
|
81
|
+
self._plbounds
|
|
82
|
+
+ (end[..., self._pdims] - self._plbounds) % self._plengths
|
|
83
|
+
)
|
|
84
|
+
return end
|
|
85
|
+
|
|
86
|
+
def gridDistances(self, position):
|
|
87
|
+
"""Per-axis distances from ``position`` to every grid node."""
|
|
88
|
+
distances = [
|
|
89
|
+
centers - x for centers, x in zip(self._grid, position, strict=True)
|
|
90
|
+
]
|
|
91
|
+
if self._periodic:
|
|
92
|
+
for dim, length in zip(self._pdims, self._plengths, strict=True):
|
|
93
|
+
distances[dim] -= length * np.rint(distances[dim] / length)
|
|
94
|
+
# a periodic grid's last node is the same point as its first
|
|
95
|
+
distances[dim][-1] = distances[dim][0]
|
|
96
|
+
return distances
|
|
97
|
+
|
|
98
|
+
def closestNode(self, position) -> tuple[int, ...]:
|
|
99
|
+
"""Index of the grid node nearest to ``position``, in gridShape order."""
|
|
100
|
+
indices = np.rint(
|
|
101
|
+
(self._widths - 1) * (position - self._lbounds) / self._lengths
|
|
102
|
+
).astype(int)
|
|
103
|
+
if self._periodic:
|
|
104
|
+
indices[list(self._pdims)] %= self._widths[list(self._pdims)]
|
|
105
|
+
indices = np.clip(indices, 0, self._widths - 1)
|
|
106
|
+
return tuple(reversed(indices))
|
|
107
|
+
|
|
108
|
+
def foldedGrid(self, values):
|
|
109
|
+
"""Fold a tripled bounded grid back onto the physical domain, in log space."""
|
|
110
|
+
if self.bounded:
|
|
111
|
+
for i, cv in enumerate(reversed(self.variables)):
|
|
112
|
+
if not cv.periodic:
|
|
113
|
+
values, left, right = np.array_split(values, 3, axis=i)
|
|
114
|
+
values = np.logaddexp(left, np.logaddexp(values, right))
|
|
115
|
+
return values
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class KernelShape:
|
|
119
|
+
"""A kernel profile: its per-dimension log normalization and its exponent."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, name: str, logNorm: float, exponents):
|
|
122
|
+
self.name = name
|
|
123
|
+
self.logNorm = logNorm
|
|
124
|
+
self._exponents = exponents
|
|
125
|
+
|
|
126
|
+
def exponents(self, x):
|
|
127
|
+
"""Log of the kernel profile at scaled distances ``x``."""
|
|
128
|
+
return self._exponents(x)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _gaussianExponents(x):
|
|
132
|
+
return -0.5 * x**2
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _compactExponents(x):
|
|
136
|
+
values = 9 - x**2
|
|
137
|
+
mask = values > 0
|
|
138
|
+
result = np.empty_like(values)
|
|
139
|
+
result[mask] = 4 * np.log(values[mask])
|
|
140
|
+
result[~mask] = -np.inf
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
#: Unbounded Gaussian profile; the default and the one both papers use.
|
|
145
|
+
GAUSSIAN = KernelShape("gaussian", np.log(2 * np.pi) / 2, _gaussianExponents)
|
|
146
|
+
|
|
147
|
+
#: Compact quartic profile with support of +/- 3 bandwidths. Its normalization
|
|
148
|
+
#: constant is exactly the integral of (9 - x**2)**4 over [-3, 3].
|
|
149
|
+
COMPACT = KernelShape("compact", np.log(559872 / 35), _compactExponents)
|
|
150
|
+
|
|
151
|
+
KERNEL_SHAPES = {shape.name: shape for shape in (GAUSSIAN, COMPACT)}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Kernel:
|
|
155
|
+
"""A multivariate kernel with diagonal covariance."""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self, cvSpace, position, bandwidth, logWeight, numSamples=1, shape=GAUSSIAN
|
|
159
|
+
):
|
|
160
|
+
self.cvSpace = cvSpace
|
|
161
|
+
self.position = np.array(position, dtype=float)
|
|
162
|
+
self.bandwidth = np.array(bandwidth, dtype=float)
|
|
163
|
+
self.logWeight = logWeight
|
|
164
|
+
self.numSamples = numSamples
|
|
165
|
+
self.shape = shape
|
|
166
|
+
self.logHeight = self._computeLogHeight()
|
|
167
|
+
|
|
168
|
+
def __copy__(self):
|
|
169
|
+
return Kernel(
|
|
170
|
+
self.cvSpace,
|
|
171
|
+
self.position,
|
|
172
|
+
self.bandwidth,
|
|
173
|
+
self.logWeight,
|
|
174
|
+
self.numSamples,
|
|
175
|
+
self.shape,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _computeLogHeight(self):
|
|
179
|
+
if np.any(self.bandwidth == 0):
|
|
180
|
+
return -np.inf
|
|
181
|
+
d = self.cvSpace.numDimensions
|
|
182
|
+
return self.logWeight - d * self.shape.logNorm - np.sum(np.log(self.bandwidth))
|
|
183
|
+
|
|
184
|
+
def _scaledDistances(self, points, bandwidths):
|
|
185
|
+
return self.cvSpace.displacement(self.position, points) / bandwidths
|
|
186
|
+
|
|
187
|
+
def findNearest(self, centers, bandwidths, ignore=()):
|
|
188
|
+
"""Index of and squared Mahalanobis distance to the nearest center."""
|
|
189
|
+
if centers.size == 0:
|
|
190
|
+
return -1, np.inf
|
|
191
|
+
sqDistances = np.sum(self._scaledDistances(centers, bandwidths) ** 2, axis=-1)
|
|
192
|
+
if len(ignore):
|
|
193
|
+
sqDistances[list(ignore)] = np.inf
|
|
194
|
+
index = int(np.argmin(sqDistances))
|
|
195
|
+
return index, sqDistances[index]
|
|
196
|
+
|
|
197
|
+
def merge(self, other) -> None:
|
|
198
|
+
"""Absorb ``other``, preserving total weight, mean and second moment."""
|
|
199
|
+
logSumWeights = np.logaddexp(self.logWeight, other.logWeight)
|
|
200
|
+
w1 = np.exp(self.logWeight - logSumWeights)
|
|
201
|
+
w2 = np.exp(other.logWeight - logSumWeights)
|
|
202
|
+
disp = self.cvSpace.displacement(self.position, other.position)
|
|
203
|
+
self.position = self.cvSpace.endpoint(self.position, w2 * disp)
|
|
204
|
+
self.bandwidth = np.sqrt(
|
|
205
|
+
w1 * self.bandwidth**2 + w2 * other.bandwidth**2 + w1 * w2 * disp**2
|
|
206
|
+
)
|
|
207
|
+
self.logWeight = logSumWeights
|
|
208
|
+
self.numSamples += other.numSamples
|
|
209
|
+
self.logHeight = self._computeLogHeight()
|
|
210
|
+
|
|
211
|
+
def evaluate(self, points):
|
|
212
|
+
"""Log of the kernel at the given point or points."""
|
|
213
|
+
return self.logHeight + np.sum(
|
|
214
|
+
self.shape.exponents(self._scaledDistances(points, self.bandwidth)), axis=-1
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def evaluateOnGrid(self):
|
|
218
|
+
"""Log of the kernel on the CV-space grid, in gridShape order."""
|
|
219
|
+
distances = self.cvSpace.gridDistances(self.position)
|
|
220
|
+
exponents = [
|
|
221
|
+
self.shape.exponents(dist / sigma)
|
|
222
|
+
for dist, sigma in zip(distances, self.bandwidth, strict=True)
|
|
223
|
+
]
|
|
224
|
+
return self.cvSpace.foldedGrid(
|
|
225
|
+
self.logHeight + functools.reduce(np.add.outer, reversed(exponents))
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class OnlineKDE:
|
|
230
|
+
"""Online kernel density estimate with on-the-fly kernel compression.
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
cvSpace
|
|
235
|
+
The :class:`CVSpace` the estimate lives on.
|
|
236
|
+
compressionThreshold
|
|
237
|
+
Merge a new kernel into any existing kernel closer than this, measured
|
|
238
|
+
as a Mahalanobis distance. Zero disables merging.
|
|
239
|
+
useExistingBandwidths
|
|
240
|
+
Whether the nearest-neighbour search scales distances by the existing
|
|
241
|
+
kernels' bandwidths rather than the incoming kernel's.
|
|
242
|
+
kernelShape
|
|
243
|
+
Either ``"gaussian"`` or ``"compact"``.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
cvSpace,
|
|
249
|
+
compressionThreshold: float = 1.0,
|
|
250
|
+
useExistingBandwidths: bool = True,
|
|
251
|
+
kernelShape: str = "gaussian",
|
|
252
|
+
):
|
|
253
|
+
if kernelShape not in KERNEL_SHAPES:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"Unknown kernelShape {kernelShape!r}; "
|
|
256
|
+
f"expected one of {sorted(KERNEL_SHAPES)}"
|
|
257
|
+
)
|
|
258
|
+
self._cvSpace = cvSpace
|
|
259
|
+
self._compressionThreshold = compressionThreshold
|
|
260
|
+
self._useExistingBandwidths = useExistingBandwidths
|
|
261
|
+
self._shape = KERNEL_SHAPES[kernelShape]
|
|
262
|
+
self._kernels: list[Kernel] = []
|
|
263
|
+
self._logSumW = -np.inf
|
|
264
|
+
self._logSumWSq = -np.inf
|
|
265
|
+
self._logPK = np.empty(0)
|
|
266
|
+
self._logPG = np.full(cvSpace.gridShape, -np.inf)
|
|
267
|
+
self._d = cvSpace.numDimensions
|
|
268
|
+
|
|
269
|
+
def __bool__(self) -> bool:
|
|
270
|
+
return bool(self._kernels)
|
|
271
|
+
|
|
272
|
+
def __copy__(self):
|
|
273
|
+
new = self.__class__(
|
|
274
|
+
self._cvSpace,
|
|
275
|
+
self._compressionThreshold,
|
|
276
|
+
self._useExistingBandwidths,
|
|
277
|
+
self._shape.name,
|
|
278
|
+
)
|
|
279
|
+
new._kernels = list(map(copy, self._kernels))
|
|
280
|
+
new._logSumW = self._logSumW
|
|
281
|
+
new._logSumWSq = self._logSumWSq
|
|
282
|
+
new._logPK = self._logPK.copy()
|
|
283
|
+
new._logPG = self._logPG.copy()
|
|
284
|
+
return new
|
|
285
|
+
|
|
286
|
+
def __iadd__(self, other):
|
|
287
|
+
for kernel in other._kernels:
|
|
288
|
+
self._addKernel(
|
|
289
|
+
kernel.position,
|
|
290
|
+
kernel.bandwidth,
|
|
291
|
+
kernel.logWeight,
|
|
292
|
+
kernel.numSamples,
|
|
293
|
+
adjustBandwidth=False,
|
|
294
|
+
)
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _logsubexp(x, y):
|
|
299
|
+
"""log(exp(x) - exp(y)), elementwise and numerically stable."""
|
|
300
|
+
result = np.full_like(x, -np.inf)
|
|
301
|
+
valid = y < x
|
|
302
|
+
inner = -np.exp(y[valid] - x[valid])
|
|
303
|
+
representable = inner > -1.0
|
|
304
|
+
inner[representable] = np.log1p(inner[representable])
|
|
305
|
+
inner[~representable] = -np.inf
|
|
306
|
+
result[valid] = inner + x[valid]
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
def _removeKernels(self, centers, toRemove):
|
|
310
|
+
"""Remove kernels by index, subtracting their contributions in log space.
|
|
311
|
+
|
|
312
|
+
Callers MUST add any replacement kernel's contribution to _logPK and
|
|
313
|
+
_logPG *before* calling this. _logsubexp loses precision as its two
|
|
314
|
+
arguments converge, and the replacement is what keeps the removed
|
|
315
|
+
kernel from dominating the density at its own center. See spec 12.4;
|
|
316
|
+
tests/test_kde.py pins the resulting margin.
|
|
317
|
+
"""
|
|
318
|
+
toRemove = sorted(toRemove, reverse=True)
|
|
319
|
+
removed = []
|
|
320
|
+
for index in toRemove:
|
|
321
|
+
kernel = self._kernels.pop(index)
|
|
322
|
+
self._logPK = self._logsubexp(self._logPK, kernel.evaluate(centers))
|
|
323
|
+
self._logPG = self._logsubexp(self._logPG, kernel.evaluateOnGrid())
|
|
324
|
+
removed.append(kernel)
|
|
325
|
+
self._logPK = np.delete(self._logPK, toRemove)
|
|
326
|
+
return removed
|
|
327
|
+
|
|
328
|
+
def _pushKernel(self, newKernel):
|
|
329
|
+
centers = np.stack([k.position for k in self._kernels])
|
|
330
|
+
bandwidths = (
|
|
331
|
+
np.stack([k.bandwidth for k in self._kernels])
|
|
332
|
+
if self._useExistingBandwidths
|
|
333
|
+
else newKernel.bandwidth
|
|
334
|
+
)
|
|
335
|
+
threshold = self._compressionThreshold
|
|
336
|
+
index, minSqDist = newKernel.findNearest(centers, bandwidths)
|
|
337
|
+
toRemove = []
|
|
338
|
+
# threshold > 0 guard: without it, coincident kernels satisfy 0 <= 0
|
|
339
|
+
# and merge even when compression is meant to be disabled.
|
|
340
|
+
while threshold > 0 and index >= 0 and minSqDist <= threshold**2:
|
|
341
|
+
toRemove.append(index)
|
|
342
|
+
newKernel.merge(self._kernels[index])
|
|
343
|
+
index, minSqDist = newKernel.findNearest(centers, bandwidths, toRemove)
|
|
344
|
+
self._logPK = np.logaddexp(self._logPK, newKernel.evaluate(centers))
|
|
345
|
+
self._logPG = np.logaddexp(self._logPG, newKernel.evaluateOnGrid())
|
|
346
|
+
if toRemove:
|
|
347
|
+
self._removeKernels(centers, toRemove)
|
|
348
|
+
self._kernels.append(newKernel)
|
|
349
|
+
self._logPK = np.append(
|
|
350
|
+
self._logPK,
|
|
351
|
+
np.logaddexp.reduce(
|
|
352
|
+
[k.evaluate(newKernel.position) for k in self._kernels]
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def _addKernel(
|
|
357
|
+
self, position, bandwidth, logWeight, numSamples=1, adjustBandwidth=True
|
|
358
|
+
):
|
|
359
|
+
self._logSumW = np.logaddexp(self._logSumW, logWeight)
|
|
360
|
+
self._logSumWSq = np.logaddexp(self._logSumWSq, 2 * logWeight)
|
|
361
|
+
if adjustBandwidth:
|
|
362
|
+
neff = np.exp(2 * self._logSumW - self._logSumWSq)
|
|
363
|
+
silverman = (neff * (self._d + 2) / 4) ** (-1 / (self._d + 4))
|
|
364
|
+
bandwidth = bandwidth * silverman
|
|
365
|
+
newKernel = Kernel(
|
|
366
|
+
self._cvSpace, position, bandwidth, logWeight, numSamples, self._shape
|
|
367
|
+
)
|
|
368
|
+
if self._kernels:
|
|
369
|
+
self._pushKernel(newKernel)
|
|
370
|
+
else:
|
|
371
|
+
self._kernels = [newKernel]
|
|
372
|
+
self._logPG = newKernel.evaluateOnGrid()
|
|
373
|
+
self._logPK = np.array([newKernel.logHeight])
|
|
374
|
+
|
|
375
|
+
def update(self, position, logWeight, variance) -> None:
|
|
376
|
+
"""Deposit a kernel of the given log weight and per-CV variance."""
|
|
377
|
+
self._addKernel(position, np.sqrt(variance), logWeight)
|
|
378
|
+
|
|
379
|
+
def getNumKernels(self) -> int:
|
|
380
|
+
"""Number of compressed kernels currently stored."""
|
|
381
|
+
return len(self._kernels)
|
|
382
|
+
|
|
383
|
+
def getLogPDF(self):
|
|
384
|
+
"""Log of the normalized probability density on the grid."""
|
|
385
|
+
return self._logPG - self._logSumW
|
|
386
|
+
|
|
387
|
+
def getLogMeanDensity(self) -> float:
|
|
388
|
+
"""Log of Z_n, the mean density over the compressed kernel centers."""
|
|
389
|
+
return (
|
|
390
|
+
np.logaddexp.reduce(self._logPK)
|
|
391
|
+
- np.log(len(self._kernels))
|
|
392
|
+
- self._logSumW
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def evaluate(self, point) -> float:
|
|
396
|
+
"""Log of the normalized density at a single point."""
|
|
397
|
+
return (
|
|
398
|
+
np.logaddexp.reduce([k.evaluate(point) for k in self._kernels])
|
|
399
|
+
- self._logSumW
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def getState(self) -> dict:
|
|
403
|
+
"""Flat, npz-writable snapshot of this estimate.
|
|
404
|
+
|
|
405
|
+
The CVSpace is deliberately excluded: all walkers share identical CV
|
|
406
|
+
definitions, so a reader constructs its own and calls setState.
|
|
407
|
+
"""
|
|
408
|
+
d = self._cvSpace.numDimensions
|
|
409
|
+
if self._kernels:
|
|
410
|
+
positions = np.stack([k.position for k in self._kernels])
|
|
411
|
+
bandwidths = np.stack([k.bandwidth for k in self._kernels])
|
|
412
|
+
logWeights = np.array([k.logWeight for k in self._kernels])
|
|
413
|
+
numSamples = np.array([k.numSamples for k in self._kernels])
|
|
414
|
+
else:
|
|
415
|
+
positions = np.empty((0, d))
|
|
416
|
+
bandwidths = np.empty((0, d))
|
|
417
|
+
logWeights = np.empty(0)
|
|
418
|
+
numSamples = np.empty(0, dtype=int)
|
|
419
|
+
return {
|
|
420
|
+
"positions": positions,
|
|
421
|
+
"bandwidths": bandwidths,
|
|
422
|
+
"logWeights": logWeights,
|
|
423
|
+
"numSamples": numSamples,
|
|
424
|
+
"logSumW": float(self._logSumW),
|
|
425
|
+
"logSumWSq": float(self._logSumWSq),
|
|
426
|
+
"logPK": self._logPK.copy(),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
def setState(self, state) -> None:
|
|
430
|
+
"""Restore from a :meth:`getState` snapshot, discarding current contents."""
|
|
431
|
+
self._kernels = [
|
|
432
|
+
Kernel(self._cvSpace, position, bandwidth, logWeight, int(n), self._shape)
|
|
433
|
+
for position, bandwidth, logWeight, n in zip(
|
|
434
|
+
state["positions"],
|
|
435
|
+
state["bandwidths"],
|
|
436
|
+
state["logWeights"],
|
|
437
|
+
state["numSamples"],
|
|
438
|
+
strict=True,
|
|
439
|
+
)
|
|
440
|
+
]
|
|
441
|
+
self._logSumW = float(state["logSumW"])
|
|
442
|
+
self._logSumWSq = float(state["logSumWSq"])
|
|
443
|
+
self._logPK = np.asarray(state["logPK"]).copy()
|
|
444
|
+
# Seeded with -inf so an empty kernel list restores cleanly; the source
|
|
445
|
+
# used a bare reduce here and raised TypeError on an empty sequence.
|
|
446
|
+
self._logPG = functools.reduce(
|
|
447
|
+
np.logaddexp,
|
|
448
|
+
(k.evaluateOnGrid() for k in self._kernels),
|
|
449
|
+
np.full(self._cvSpace.gridShape, -np.inf),
|
|
450
|
+
)
|
openmm_opes/opes.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""The OPES bias, integrated with OpenMM as a CustomCVForce.
|
|
2
|
+
|
|
3
|
+
Closely mirrors ``openmm.app.Metadynamics``: same constructor shape, same
|
|
4
|
+
unit-coercion idiom, same force-group selection, same ``step`` contract.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from copy import copy
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import openmm as mm
|
|
14
|
+
from openmm import unit
|
|
15
|
+
|
|
16
|
+
from .io import BiasSharer
|
|
17
|
+
from .kde import CVSpace, OnlineKDE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RunningAverage:
|
|
21
|
+
"""Accumulates a running mean of per-CV squared deviations."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, numDimensions: int = 1):
|
|
24
|
+
self._num = 0
|
|
25
|
+
self._total = np.zeros(numDimensions)
|
|
26
|
+
|
|
27
|
+
def __iadd__(self, other):
|
|
28
|
+
self._total = self._total + other._total
|
|
29
|
+
self._num += other._num
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def copy(self):
|
|
33
|
+
"""An independent copy. The array is copied, not aliased."""
|
|
34
|
+
new = self.__class__(self._total.shape[0])
|
|
35
|
+
new._num = self._num
|
|
36
|
+
# The source aliased this array, so merging peers into a copy silently
|
|
37
|
+
# corrupted the original. See spec 7.4.1.
|
|
38
|
+
new._total = self._total.copy()
|
|
39
|
+
return new
|
|
40
|
+
|
|
41
|
+
def update(self, sample) -> None:
|
|
42
|
+
"""Fold one sample into the average."""
|
|
43
|
+
self._num += 1
|
|
44
|
+
self._total = self._total + sample
|
|
45
|
+
|
|
46
|
+
def get(self):
|
|
47
|
+
"""The running average."""
|
|
48
|
+
return self._total / max(1, self._num)
|
|
49
|
+
|
|
50
|
+
def getState(self) -> dict:
|
|
51
|
+
"""Flat, npz-writable snapshot."""
|
|
52
|
+
return {"num": float(self._num), "total": self._total.copy()}
|
|
53
|
+
|
|
54
|
+
def setState(self, state) -> None:
|
|
55
|
+
"""Restore from a :meth:`getState` snapshot."""
|
|
56
|
+
self._num = int(state["num"])
|
|
57
|
+
self._total = np.asarray(state["total"]).copy()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OPES:
|
|
61
|
+
"""On-the-fly Probability Enhanced Sampling.
|
|
62
|
+
|
|
63
|
+
Implements OPES and its exploratory variant, following Invernizzi and
|
|
64
|
+
Parrinello, https://doi.org/10.1021/acs.jpclett.0c00497 and
|
|
65
|
+
https://doi.org/10.1021/acs.jctc.2c00152.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
system: System
|
|
70
|
+
The System to simulate. A CustomCVForce implementing the bias is
|
|
71
|
+
created and added to it.
|
|
72
|
+
variables: list of BiasVariable
|
|
73
|
+
The collective variables whose sampling should be enhanced.
|
|
74
|
+
temperature: temperature
|
|
75
|
+
The temperature at which the simulation is run.
|
|
76
|
+
barrier: energy
|
|
77
|
+
The free energy barrier the simulation should overcome.
|
|
78
|
+
frequency: int
|
|
79
|
+
Interval in time steps at which to deposit a kernel.
|
|
80
|
+
varianceFrequency: int or None
|
|
81
|
+
Interval in time steps at which to update the CV variance. When None,
|
|
82
|
+
the bandwidth is fixed and taken from each variable's ``biasWidth``,
|
|
83
|
+
which is interpreted as the standard deviation of the UNBIASED
|
|
84
|
+
distribution, exactly as both papers define sigma^(0).
|
|
85
|
+
biasFactor: float, optional
|
|
86
|
+
Defaults to ``barrier / kT``.
|
|
87
|
+
exploreMode: bool
|
|
88
|
+
Whether to apply the OPES-explore variant.
|
|
89
|
+
bounded: bool
|
|
90
|
+
Whether non-periodic CVs have reflective boundaries.
|
|
91
|
+
saveFrequency: int, optional
|
|
92
|
+
Interval in time steps at which to share the bias on disk. Must be a
|
|
93
|
+
multiple of ``frequency``.
|
|
94
|
+
biasDir: str, optional
|
|
95
|
+
Directory shared with other walkers.
|
|
96
|
+
warmupSteps: int, optional
|
|
97
|
+
When given, run this many steps without depositing kernels while
|
|
98
|
+
measuring the CV variance, then freeze it. See the spec, section 7.7.
|
|
99
|
+
compressionThreshold: float
|
|
100
|
+
Mahalanobis distance below which kernels are merged.
|
|
101
|
+
useExistingBandwidths: bool
|
|
102
|
+
Whether the merge search scales by existing kernels' bandwidths.
|
|
103
|
+
kernelShape: str
|
|
104
|
+
``"gaussian"`` or ``"compact"``.
|
|
105
|
+
statsWindowSize: int
|
|
106
|
+
Window, in deposition strides, of the running CV-mean estimate.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
system,
|
|
112
|
+
variables,
|
|
113
|
+
temperature,
|
|
114
|
+
barrier,
|
|
115
|
+
frequency,
|
|
116
|
+
varianceFrequency,
|
|
117
|
+
biasFactor=None,
|
|
118
|
+
exploreMode: bool = False,
|
|
119
|
+
bounded: bool = False,
|
|
120
|
+
saveFrequency=None,
|
|
121
|
+
biasDir=None,
|
|
122
|
+
warmupSteps=None,
|
|
123
|
+
compressionThreshold: float = 1.0,
|
|
124
|
+
useExistingBandwidths: bool = True,
|
|
125
|
+
kernelShape: str = "gaussian",
|
|
126
|
+
statsWindowSize: int = 10,
|
|
127
|
+
):
|
|
128
|
+
if not unit.is_quantity(temperature):
|
|
129
|
+
temperature = temperature * unit.kelvin
|
|
130
|
+
if not unit.is_quantity(barrier):
|
|
131
|
+
barrier = barrier * unit.kilojoules_per_mole
|
|
132
|
+
|
|
133
|
+
self.variables = variables
|
|
134
|
+
self.temperature = temperature
|
|
135
|
+
self.barrier = barrier
|
|
136
|
+
self.frequency = frequency
|
|
137
|
+
self.varianceFrequency = varianceFrequency
|
|
138
|
+
self.exploreMode = exploreMode
|
|
139
|
+
self.bounded = bounded
|
|
140
|
+
self.saveFrequency = saveFrequency
|
|
141
|
+
self.biasDir = biasDir
|
|
142
|
+
self.warmupSteps = warmupSteps
|
|
143
|
+
self.statsWindowSize = statsWindowSize
|
|
144
|
+
|
|
145
|
+
d = len(variables)
|
|
146
|
+
kbt = unit.MOLAR_GAS_CONSTANT_R * temperature
|
|
147
|
+
userSuppliedBiasFactor = biasFactor is not None
|
|
148
|
+
if not userSuppliedBiasFactor:
|
|
149
|
+
biasFactor = barrier / kbt
|
|
150
|
+
self.biasFactor = biasFactor
|
|
151
|
+
numPeriodics = sum(v.periodic for v in variables)
|
|
152
|
+
freeGroups = set(range(32)) - {f.getForceGroup() for f in system.getForces()}
|
|
153
|
+
self._validate(d, biasFactor, numPeriodics, freeGroups, userSuppliedBiasFactor)
|
|
154
|
+
|
|
155
|
+
prefactor = (1 - 1 / biasFactor) * kbt
|
|
156
|
+
if exploreMode:
|
|
157
|
+
prefactor *= biasFactor
|
|
158
|
+
self._kbt = kbt.in_units_of(unit.kilojoules_per_mole)
|
|
159
|
+
self._biasFactor = biasFactor
|
|
160
|
+
self._prefactor = prefactor
|
|
161
|
+
self._logEpsilon = -barrier / prefactor
|
|
162
|
+
|
|
163
|
+
self._cvSpace = CVSpace(variables, bounded)
|
|
164
|
+
self._cases = ("total",) + ("self",) * bool(saveFrequency)
|
|
165
|
+
kdeOptions = {
|
|
166
|
+
"compressionThreshold": compressionThreshold,
|
|
167
|
+
"useExistingBandwidths": useExistingBandwidths,
|
|
168
|
+
"kernelShape": kernelShape,
|
|
169
|
+
}
|
|
170
|
+
self._kde = {}
|
|
171
|
+
for case in self._cases:
|
|
172
|
+
self._kde[case] = OnlineKDE(self._cvSpace, **kdeOptions)
|
|
173
|
+
self._kde[f"{case}.rw"] = OnlineKDE(self._cvSpace, **kdeOptions)
|
|
174
|
+
|
|
175
|
+
self._adaptiveVariance = varianceFrequency is not None
|
|
176
|
+
self._interval = varianceFrequency or frequency
|
|
177
|
+
self._variance = {case: RunningAverage(d) for case in self._cases}
|
|
178
|
+
self._warmupComplete = warmupSteps is None
|
|
179
|
+
if self._adaptiveVariance:
|
|
180
|
+
self._tau = statsWindowSize * frequency // varianceFrequency
|
|
181
|
+
self._counter = 0
|
|
182
|
+
self._sampleMean = np.zeros(d)
|
|
183
|
+
else:
|
|
184
|
+
# Spec 7.5: biasWidth is the unbiased sigma^(0), while _variance
|
|
185
|
+
# holds a sampled-distribution variance, which is gamma times wider.
|
|
186
|
+
self._setFixedVariance(
|
|
187
|
+
biasFactor * np.array([v.biasWidth**2 for v in variables])
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
self._sharer = BiasSharer(biasDir) if biasDir is not None else None
|
|
191
|
+
|
|
192
|
+
gridWidths = [v.gridWidth for v in variables]
|
|
193
|
+
self._widths = [] if d == 1 else gridWidths
|
|
194
|
+
self._limits = [limit for v in variables for limit in (v.minValue, v.maxValue)]
|
|
195
|
+
periodic = numPeriodics == d
|
|
196
|
+
initial = np.full(int(np.prod(gridWidths)), -barrier / unit.kilojoules_per_mole)
|
|
197
|
+
|
|
198
|
+
energyFunction = "table(" + ",".join(f"cv{i}" for i in range(d)) + ")"
|
|
199
|
+
self._force = mm.CustomCVForce(energyFunction)
|
|
200
|
+
for i, variable in enumerate(variables):
|
|
201
|
+
self._force.addCollectiveVariable(f"cv{i}", variable.force)
|
|
202
|
+
# Explicit branch rather than a dynamic getattr, so ty can check it.
|
|
203
|
+
if d == 1:
|
|
204
|
+
table = mm.Continuous1DFunction(initial, *self._limits, periodic)
|
|
205
|
+
elif d == 2:
|
|
206
|
+
table = mm.Continuous2DFunction(
|
|
207
|
+
*self._widths, initial, *self._limits, periodic
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
table = mm.Continuous3DFunction(
|
|
211
|
+
*self._widths, initial, *self._limits, periodic
|
|
212
|
+
)
|
|
213
|
+
self._force.addTabulatedFunction("table", table)
|
|
214
|
+
self._force.setForceGroup(max(freeGroups))
|
|
215
|
+
system.addForce(self._force)
|
|
216
|
+
|
|
217
|
+
def _validate(
|
|
218
|
+
self, d, biasFactor, numPeriodics, freeGroups, userSuppliedBiasFactor
|
|
219
|
+
):
|
|
220
|
+
if self.varianceFrequency is not None and self.varianceFrequency <= 0:
|
|
221
|
+
raise ValueError("varianceFrequency must be positive or None")
|
|
222
|
+
if self.varianceFrequency and self.frequency % self.varianceFrequency != 0:
|
|
223
|
+
raise ValueError("varianceFrequency must be a divisor of frequency")
|
|
224
|
+
if (self.saveFrequency is None) != (self.biasDir is None):
|
|
225
|
+
raise ValueError("Must specify both saveFrequency and biasDir")
|
|
226
|
+
if self.saveFrequency is not None:
|
|
227
|
+
if self.saveFrequency <= 0:
|
|
228
|
+
raise ValueError("saveFrequency must be positive")
|
|
229
|
+
if self.saveFrequency % self.frequency != 0:
|
|
230
|
+
raise ValueError("saveFrequency must be a multiple of frequency")
|
|
231
|
+
if self.warmupSteps is not None:
|
|
232
|
+
if self.varianceFrequency is None:
|
|
233
|
+
raise ValueError("warmupSteps requires varianceFrequency")
|
|
234
|
+
if self.warmupSteps <= 0:
|
|
235
|
+
raise ValueError("warmupSteps must be positive")
|
|
236
|
+
if self.warmupSteps % self.varianceFrequency != 0:
|
|
237
|
+
raise ValueError("warmupSteps must be a multiple of varianceFrequency")
|
|
238
|
+
if self.warmupSteps // self.varianceFrequency < 2:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"warmupSteps must span at least two varianceFrequency intervals"
|
|
241
|
+
)
|
|
242
|
+
if biasFactor <= 1.0:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"biasFactor must be greater than 1"
|
|
245
|
+
if userSuppliedBiasFactor
|
|
246
|
+
else "barrier must be greater than 1 kT"
|
|
247
|
+
)
|
|
248
|
+
if numPeriodics not in [0, d]:
|
|
249
|
+
raise ValueError("OPES cannot handle mixed periodic/non-periodic variables")
|
|
250
|
+
if not 1 <= d <= 3:
|
|
251
|
+
raise ValueError("OPES requires 1, 2, or 3 collective variables")
|
|
252
|
+
if not freeGroups:
|
|
253
|
+
raise RuntimeError("OPES requires a free force group, but all are in use.")
|
|
254
|
+
|
|
255
|
+
def _setFixedVariance(self, value) -> None:
|
|
256
|
+
"""Replace every accumulator with a frozen value (spec 7.5 and 7.7)."""
|
|
257
|
+
for case in self._cases:
|
|
258
|
+
average = RunningAverage(len(self.variables))
|
|
259
|
+
average.update(np.asarray(value, dtype=float))
|
|
260
|
+
self._variance[case] = average
|
|
261
|
+
self._adaptiveVariance = False
|
|
262
|
+
|
|
263
|
+
def getVariance(self):
|
|
264
|
+
"""Variance currently used to size newly deposited kernels."""
|
|
265
|
+
return self._variance["total"].get()
|
|
266
|
+
|
|
267
|
+
def getNumKernels(self) -> int:
|
|
268
|
+
"""Number of kernels in the estimate that defines the bias."""
|
|
269
|
+
return self._kde["total" if self.exploreMode else "total.rw"].getNumKernels()
|
|
270
|
+
|
|
271
|
+
def getBias(self):
|
|
272
|
+
"""The OPES bias potential on the grid."""
|
|
273
|
+
kde = self._kde["total" if self.exploreMode else "total.rw"]
|
|
274
|
+
return self._prefactor * np.logaddexp(
|
|
275
|
+
kde.getLogPDF() - kde.getLogMeanDensity(), self._logEpsilon
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def getFreeEnergy(self):
|
|
279
|
+
"""Free energy as a function of the collective variables.
|
|
280
|
+
|
|
281
|
+
Returned as an N-dimensional array in kJ/mole. The i'th position along
|
|
282
|
+
an axis corresponds to ``minValue + i*(maxValue-minValue)/gridWidth``.
|
|
283
|
+
Always the importance-sampling estimate, which converges better than
|
|
284
|
+
the direct one in explore mode.
|
|
285
|
+
"""
|
|
286
|
+
return -self._kbt * self._kde["total.rw"].getLogPDF()
|
|
287
|
+
|
|
288
|
+
def getAverageDensity(self):
|
|
289
|
+
"""Z_n, the mean density over the explored CV space."""
|
|
290
|
+
kde = self._kde["total" if self.exploreMode else "total.rw"]
|
|
291
|
+
return np.exp(kde.getLogMeanDensity())
|
|
292
|
+
|
|
293
|
+
def getCollectiveVariables(self, simulation):
|
|
294
|
+
"""Current values of all collective variables in a Simulation."""
|
|
295
|
+
return self._force.getCollectiveVariableValues(simulation.context)
|
|
296
|
+
|
|
297
|
+
def updateContext(self, context) -> None:
|
|
298
|
+
"""Push the current bias into a Context."""
|
|
299
|
+
bias = self.getBias().value_in_unit(unit.kilojoules_per_mole)
|
|
300
|
+
self._force.getTabulatedFunction(0).setFunctionParameters(
|
|
301
|
+
*self._widths, bias.ravel(), *self._limits
|
|
302
|
+
)
|
|
303
|
+
self._force.updateParametersInContext(context)
|
|
304
|
+
|
|
305
|
+
def addKernel(self, values, biasEnergy, variance=None) -> None:
|
|
306
|
+
"""Deposit a kernel into the probability estimates.
|
|
307
|
+
|
|
308
|
+
This does not refresh any Context; call :meth:`updateContext`
|
|
309
|
+
afterwards if a simulation is running.
|
|
310
|
+
"""
|
|
311
|
+
if not unit.is_quantity(biasEnergy):
|
|
312
|
+
biasEnergy = biasEnergy * unit.kilojoules_per_mole
|
|
313
|
+
if variance is None:
|
|
314
|
+
variance = self._variance["total"].get()
|
|
315
|
+
if np.any(np.asarray(variance) <= 0):
|
|
316
|
+
# Spec 7.4.4: a zero bandwidth poisons the estimate with NaN.
|
|
317
|
+
warnings.warn(
|
|
318
|
+
"Skipping kernel deposition: the CV variance estimate is not "
|
|
319
|
+
"yet positive. Use a varianceFrequency smaller than frequency, "
|
|
320
|
+
"or set warmupSteps.",
|
|
321
|
+
stacklevel=2,
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
for case in self._cases:
|
|
325
|
+
self._kde[case].update(values, 0.0, variance)
|
|
326
|
+
self._kde[f"{case}.rw"].update(
|
|
327
|
+
values, biasEnergy / self._kbt, variance / self._biasFactor
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def _updateSampleStats(self, values) -> None:
|
|
331
|
+
self._counter += 1
|
|
332
|
+
delta = self._cvSpace.displacement(self._sampleMean, values)
|
|
333
|
+
x = 1 / min(self._tau, self._counter)
|
|
334
|
+
self._sampleMean = self._cvSpace.endpoint(self._sampleMean, x * delta)
|
|
335
|
+
sqdev = delta * self._cvSpace.displacement(self._sampleMean, values)
|
|
336
|
+
for case in self._cases:
|
|
337
|
+
self._variance[case].update(sqdev)
|
|
338
|
+
|
|
339
|
+
def _syncWithDisk(self) -> None:
|
|
340
|
+
# Only ever called when saveFrequency is set, which _validate ties to
|
|
341
|
+
# biasDir being set, which is what constructs self._sharer.
|
|
342
|
+
assert self._sharer is not None
|
|
343
|
+
self._sharer.save(self._getSharedState())
|
|
344
|
+
if not self._sharer.load():
|
|
345
|
+
return
|
|
346
|
+
self._kde["total"] = copy(self._kde["self"])
|
|
347
|
+
self._kde["total.rw"] = copy(self._kde["self.rw"])
|
|
348
|
+
self._variance["total"] = self._variance["self"].copy()
|
|
349
|
+
for state in self._sharer.getLoadedStates().values():
|
|
350
|
+
self._kde["total"] += self._kdeFromState(state, "kde")
|
|
351
|
+
self._kde["total.rw"] += self._kdeFromState(state, "kdeRW")
|
|
352
|
+
peer = RunningAverage(len(self.variables))
|
|
353
|
+
peer.setState({"num": state["var_num"], "total": state["var_total"]})
|
|
354
|
+
self._variance["total"] += peer
|
|
355
|
+
|
|
356
|
+
def _getSharedState(self) -> dict:
|
|
357
|
+
state = {}
|
|
358
|
+
for prefix, key in (("kde", "self"), ("kdeRW", "self.rw")):
|
|
359
|
+
for name, value in self._kde[key].getState().items():
|
|
360
|
+
state[f"{prefix}_{name}"] = value
|
|
361
|
+
variance = self._variance["self"].getState()
|
|
362
|
+
state["var_num"] = variance["num"]
|
|
363
|
+
state["var_total"] = variance["total"]
|
|
364
|
+
return state
|
|
365
|
+
|
|
366
|
+
def _kdeFromState(self, state, prefix):
|
|
367
|
+
kde = OnlineKDE(self._cvSpace)
|
|
368
|
+
strip = len(prefix) + 1
|
|
369
|
+
kde.setState(
|
|
370
|
+
{
|
|
371
|
+
key[strip:]: value
|
|
372
|
+
for key, value in state.items()
|
|
373
|
+
if key.startswith(f"{prefix}_")
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
return kde
|
|
377
|
+
|
|
378
|
+
def step(self, simulation, steps) -> None:
|
|
379
|
+
"""Advance the simulation by a number of time steps.
|
|
380
|
+
|
|
381
|
+
Parameters
|
|
382
|
+
----------
|
|
383
|
+
simulation: Simulation
|
|
384
|
+
The Simulation to advance.
|
|
385
|
+
steps: int
|
|
386
|
+
The number of time steps to integrate.
|
|
387
|
+
"""
|
|
388
|
+
stepsToGo = steps
|
|
389
|
+
while stepsToGo > 0:
|
|
390
|
+
nextSteps = min(
|
|
391
|
+
stepsToGo,
|
|
392
|
+
self._interval - simulation.currentStep % self._interval,
|
|
393
|
+
)
|
|
394
|
+
simulation.step(nextSteps)
|
|
395
|
+
if simulation.currentStep % self._interval == 0:
|
|
396
|
+
self._onInterval(simulation)
|
|
397
|
+
stepsToGo -= nextSteps
|
|
398
|
+
|
|
399
|
+
def _onInterval(self, simulation) -> None:
|
|
400
|
+
position = self.getCollectiveVariables(simulation)
|
|
401
|
+
if not self._warmupComplete:
|
|
402
|
+
self._updateSampleStats(position)
|
|
403
|
+
if simulation.currentStep >= self.warmupSteps:
|
|
404
|
+
self._finishWarmup()
|
|
405
|
+
return
|
|
406
|
+
if self._adaptiveVariance:
|
|
407
|
+
self._updateSampleStats(position)
|
|
408
|
+
if simulation.currentStep % self.frequency == 0:
|
|
409
|
+
groups = {self._force.getForceGroup()}
|
|
410
|
+
energy = simulation.context.getState(
|
|
411
|
+
getEnergy=True, groups=groups
|
|
412
|
+
).getPotentialEnergy()
|
|
413
|
+
self.addKernel(position, energy)
|
|
414
|
+
self.updateContext(simulation.context)
|
|
415
|
+
if (
|
|
416
|
+
self.saveFrequency is not None
|
|
417
|
+
and simulation.currentStep % self.saveFrequency == 0
|
|
418
|
+
):
|
|
419
|
+
self._syncWithDisk()
|
|
420
|
+
|
|
421
|
+
def _finishWarmup(self) -> None:
|
|
422
|
+
"""Freeze sigma^(0) measured from the unbiased warm-up segment.
|
|
423
|
+
|
|
424
|
+
Nothing was deposited during warm-up, so the trajectory was unbiased
|
|
425
|
+
and this measurement is the unbiased variance. _variance holds a
|
|
426
|
+
sampled-distribution variance by convention, which is gamma times
|
|
427
|
+
wider, so that is what gets stored. This is the same transformation
|
|
428
|
+
the fixed-bandwidth path applies to biasWidth. See spec 7.5 and 7.7.
|
|
429
|
+
"""
|
|
430
|
+
measured = self._variance["total"].get()
|
|
431
|
+
if np.any(measured <= 0):
|
|
432
|
+
raise ValueError(
|
|
433
|
+
"Warm-up produced a non-positive CV variance. Increase "
|
|
434
|
+
"warmupSteps or decrease varianceFrequency."
|
|
435
|
+
)
|
|
436
|
+
self._setFixedVariance(self._biasFactor * measured)
|
|
437
|
+
self._warmupComplete = True
|
|
438
|
+
|
|
439
|
+
def getState(self) -> dict:
|
|
440
|
+
"""Flat, npz-writable snapshot of this sampler's accumulated state."""
|
|
441
|
+
state = self._getSharedState() if "self" in self._cases else {}
|
|
442
|
+
for prefix, key in (("total", "total"), ("totalRW", "total.rw")):
|
|
443
|
+
for name, value in self._kde[key].getState().items():
|
|
444
|
+
state[f"{prefix}_{name}"] = value
|
|
445
|
+
variance = self._variance["total"].getState()
|
|
446
|
+
state["totalVar_num"] = variance["num"]
|
|
447
|
+
state["totalVar_total"] = variance["total"]
|
|
448
|
+
# Persisted explicitly rather than re-derived from the step counter:
|
|
449
|
+
# re-deriving would risk rescaling an already-frozen variance by gamma
|
|
450
|
+
# a second time on reload. See spec 7.7.
|
|
451
|
+
state["warmupComplete"] = float(self._warmupComplete)
|
|
452
|
+
return state
|
|
453
|
+
|
|
454
|
+
def setState(self, state) -> None:
|
|
455
|
+
"""Restore from a :meth:`getState` snapshot."""
|
|
456
|
+
for prefix, key in (("total", "total"), ("totalRW", "total.rw")):
|
|
457
|
+
self._kde[key] = self._kdeFromState(state, prefix)
|
|
458
|
+
average = RunningAverage(len(self.variables))
|
|
459
|
+
average.setState(
|
|
460
|
+
{"num": state["totalVar_num"], "total": state["totalVar_total"]}
|
|
461
|
+
)
|
|
462
|
+
self._variance["total"] = average
|
|
463
|
+
self._warmupComplete = bool(float(state["warmupComplete"]))
|
|
464
|
+
if self._warmupComplete and self.warmupSteps is not None:
|
|
465
|
+
self._adaptiveVariance = False
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openmm-opes
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenMM implementation of On-the-fly Probability Enhanced Sampling (OPES)
|
|
5
|
+
Author: Charlles Abreu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: numpy>=1.24
|
|
11
|
+
Requires-Dist: scipy>=1.10
|
|
12
|
+
Requires-Dist: openmm>=8.1
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
16
|
+
Requires-Dist: ty==0.0.72; extra == "dev"
|
|
17
|
+
Provides-Extra: docs
|
|
18
|
+
Requires-Dist: mkdocs>=1.6; extra == "docs"
|
|
19
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
20
|
+
Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# openmm-opes
|
|
24
|
+
|
|
25
|
+
[](https://github.com/craabreu/openmm-opes/actions/workflows/ci.yml)
|
|
26
|
+
|
|
27
|
+
An [OpenMM](https://openmm.org) implementation of On-the-fly Probability
|
|
28
|
+
Enhanced Sampling (OPES) and OPES-explore.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install openmm-opes
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from openmm_opes import OPES
|
|
40
|
+
|
|
41
|
+
sampler = OPES(system, variables, temperature, barrier, frequency=500,
|
|
42
|
+
varianceFrequency=50)
|
|
43
|
+
sampler.step(simulation, 1_000_000)
|
|
44
|
+
fes = sampler.getFreeEnergy()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Full documentation: <https://craabreu.github.io/openmm-opes>
|
|
48
|
+
|
|
49
|
+
## Origin
|
|
50
|
+
|
|
51
|
+
This library packages and hardens the prototype implementation developed in
|
|
52
|
+
[craabreu/opes-simulations](https://github.com/craabreu/opes-simulations),
|
|
53
|
+
porting its `OPES`/`OnlineKDE` classes into a tested, documented package.
|
|
54
|
+
|
|
55
|
+
## References
|
|
56
|
+
|
|
57
|
+
- Invernizzi & Parrinello, J. Phys. Chem. Lett. 2020,
|
|
58
|
+
[doi:10.1021/acs.jpclett.0c00497](https://doi.org/10.1021/acs.jpclett.0c00497)
|
|
59
|
+
- Invernizzi & Parrinello, J. Chem. Theory Comput. 2022,
|
|
60
|
+
[doi:10.1021/acs.jctc.2c00152](https://doi.org/10.1021/acs.jctc.2c00152)
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
openmm_opes/__init__.py,sha256=lod_MXaEyOiV-UQE9vDxeTJK5nJlbdTfmedodfmVLbQ,562
|
|
2
|
+
openmm_opes/io.py,sha256=-K1grAYSua2afV1eehrFwLvooDBX5eGTOREmqXpF3AQ,3664
|
|
3
|
+
openmm_opes/kde.py,sha256=85XUrSL5db9e2iRkO7u4P2YaLCdCQC0Gu7qf2wV7UrY,17265
|
|
4
|
+
openmm_opes/opes.py,sha256=-Rge8wDelDI2p8WXFuLwpcGW3CXWkNBVoyaUsDDOD4s,19391
|
|
5
|
+
openmm_opes-0.1.0.dist-info/licenses/LICENSE,sha256=TpNLslqYwa8hLxWToDJdeBd-Ld3Zz0qWo9pi3wCsgDo,1071
|
|
6
|
+
openmm_opes-0.1.0.dist-info/METADATA,sha256=soT_eT2qffOKq7wpcLQDJpnp3p9_JEJKvisW4CSkfQI,1862
|
|
7
|
+
openmm_opes-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
openmm_opes-0.1.0.dist-info/top_level.txt,sha256=Ql0Vg4GnHDMON9G0HFjkvl2B-EtUaX0WXujAMXX2hK0,12
|
|
9
|
+
openmm_opes-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Charlles Abreu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openmm_opes
|