Skip to content

Commit 95211d9

Browse files
authored
Merge pull request #27 from hejung/distributed_housekeeping
Refactoring and cleanup, remove unnecessary code and reduce duplication
2 parents 8cabc33 + b4d3c77 commit 95211d9

63 files changed

Lines changed: 4560 additions & 9376 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12+
- `aimmd.ops`: The density collector is now attached to the `aimmd.ops.hooks.DensityCollectionHook` and uses the new density collector implementation from `aimmd.base.density_collection`
13+
- `aimmd.distributed`: Introduced `PathSamplingSimStateInfo` dataclass to make it easier to access any information about the current state of the path sampling simulation at any level when performing a MC step.
14+
- `aimmd.distributed`: Improve `aimmd.distributed.pathmovers.PathMover` class inheritance structure, shooting pathmovers now use uninitialized shooting point selectors as init argument and use the additional `sp_selector_kwargs` argument to specify any arguments to the shooting point selector.
15+
- `aimmd.distributed`: The density adaption is now a part of the `aimmd.distributed.SPSelector` classes, the additional density adaption scheme "lazzeri" has been added.
16+
- `aimmd.distributed.Brain`: use tqdm progress bars
17+
- `aimmd.distributed.pathmovers`: shooting pathmovers now use the `aimmd.distributed.MDEngineSpec` dataclass as input argument to specify all the MD engine/propagation options together instead of as previously specifying each argument separately
1218
- Complete rewrite of `aimmd.distributed.CommittorSimulation`, update and improve corresponding example notebook.
1319

20+
### Removed
21+
22+
- `aimmd.keras`: removed complete submodule (has not seen updates for a long time, the `aimmd.pytorch` submodule is better maintained and offers better functionality)
23+
- `aimmd.ops`: removed unused legacy code
24+
- `aimmd.base.rcmodel`: Removed old TrajectoryDensityCollector (attached to model) as it is no longer needed with the ops-based aimmd also now using the new `aimmd.base.density_collector.DensityCollector` class
25+
- `aimmd.distributed`: The density collection `BrainTask` has been removed as it is no longer needed due to the rework of density collection (see changed).
26+
1427
## [0.9.3] - 2025-08-03
1528

1629
### Added

aimmd/__init__.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,3 @@
3939
from . import symreg
4040
except(ImportError, ModuleNotFoundError):
4141
logger.warning('dCGPy not found. SymReg will not be available.')
42-
43-
try:
44-
from . import keras
45-
except (ImportError, ModuleNotFoundError):
46-
logger.warning("Tensorflow/Keras not available")

aimmd/base/density_collection.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
# This file is part of aimmd
2+
#
3+
# aimmd is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# aimmd is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU General Public License
14+
# along with aimmd. If not, see <https://www.gnu.org/licenses/>.
15+
"""
16+
This file contains the helper classes to perform collection of (and correction for)
17+
the density of configurations on trajectories when projected to the selection coordinate.
18+
19+
In the standard case we will attempt to flatten the distribution of (shooting)
20+
configurations along the committor.
21+
"""
22+
import asyncio
23+
import inspect
24+
import logging
25+
import typing
26+
27+
import numpy as np
28+
29+
from ..tools import is_documented_by as _is_documented_by
30+
31+
if typing.TYPE_CHECKING: # pragma: no cover
32+
import numpy.typing as npt
33+
34+
35+
logger = logging.getLogger(__name__)
36+
37+
38+
class DensityCollector:
39+
"""
40+
Keep track of density of configurations on trajectories projected to probabilities.
41+
"""
42+
43+
def __init__(self, n_dim: int, bins: int = 10) -> None:
44+
"""
45+
Initialize a :class:`DensityCollector`.
46+
47+
Parameters
48+
----------
49+
n_dim : int
50+
The number of dimensions the probability space has in which we are
51+
histograming.
52+
bins : int, optional
53+
The number of bins to use for each probability direction, by default 10.
54+
"""
55+
self.n_dim = n_dim
56+
self.bins = bins
57+
self.density_histogram = np.zeros(tuple(bins for _ in range(n_dim)))
58+
self._trajectories = []
59+
self._weights = []
60+
self._n_samp = 0
61+
62+
# need to know the number of forbidden bins, i.e. where sum_prob > 1
63+
bounds = np.arange(0., 1., 1./bins)
64+
# this magic line creates all possible combinations of probs
65+
# as an array of shape (bins**n_probs, n_probs)
66+
combs = np.array(np.meshgrid(*tuple(bounds for _ in range(self.n_dim)))
67+
).T.reshape(-1, self.n_dim)
68+
sums = np.sum(combs, axis=1)
69+
n_forbidden_bins = len(np.where(sums > 1)[0])
70+
self._n_allowed_bins = bins**self.n_dim - n_forbidden_bins
71+
72+
def reset(self) -> None:
73+
"""
74+
Reset everything stored in self (density histogram, trajectories and weights).
75+
"""
76+
self.density_histogram = np.zeros(tuple(self.bins for _ in range(self.n_dim)))
77+
self._trajectories = []
78+
self._weights = []
79+
self._n_samp = 0
80+
81+
def _check_for_async(self, model) -> bool:
82+
"""
83+
Check if the given model is async.
84+
85+
Parameters
86+
----------
87+
model : RCModel
88+
The model to test
89+
90+
Returns
91+
-------
92+
bool
93+
Whether the models __call__ method is async.
94+
"""
95+
return (inspect.iscoroutinefunction(model.__call__)
96+
# for 'real' models we should not need the second check, but to be sure
97+
or inspect.iscoroutinefunction(model)
98+
)
99+
100+
def add_density_for_trajectories(self, model, trajectories, weights=None):
101+
"""
102+
Add the density of the given trajectories to density histogram.
103+
104+
Adds the weights for the added trajectories according to the given models
105+
predictions to the existing histogram in probability space.
106+
Additionally stores trajectories/descriptors for later reevaluation.
107+
108+
Parameters
109+
----------
110+
model : aimmd.base.RCModel
111+
The model to use for predicting commitment probabilities
112+
trajectories : Iterable[Trajectory]
113+
The trajectories to evaluate.
114+
weights : None | list[float | np.ndarray]
115+
The weights to use for the trajectories. If None every configuration
116+
on every trajectory will get a weight of one. If a single float per
117+
trajectory, each configuration on the corresponding trajectory will
118+
get a weight corresponding to the value. Can also be a list of np.arrays
119+
(each of the same length as the corresponding trajectory), i.e. supports
120+
different weights per configuration.
121+
"""
122+
if self._check_for_async(model=model):
123+
raise ValueError(
124+
"An async model was passed, but this method is not async."
125+
" See method ``add_density_for_trajectories_async``"
126+
)
127+
weights = self._sanitize_weights_for_trajectories(trajectories=trajectories,
128+
weights=weights)
129+
# now predict for the newly added
130+
preds = [model(tra) for tra in trajectories]
131+
for pred, tra, w in zip(preds, trajectories, weights):
132+
# add to histogram
133+
histo, _ = np.histogramdd(sample=pred,
134+
bins=self.bins,
135+
range=[(0., 1.)
136+
for _ in range(self.n_dim)],
137+
weights=w,
138+
)
139+
self.density_histogram += histo
140+
self._n_samp += pred.shape[0]
141+
# store trajectories and weights for reevaluation
142+
self._trajectories.append(tra)
143+
self._weights.append(w)
144+
145+
@_is_documented_by(add_density_for_trajectories)
146+
# pylint: disable-next=missing-function-docstring
147+
async def add_density_for_trajectories_async(self, model, trajectories,
148+
weights=None) -> None:
149+
if not self._check_for_async(model=model):
150+
raise ValueError(
151+
"A non-async model was passed, but this method is async."
152+
" See method ``add_density_for_trajectories``"
153+
)
154+
weights = self._sanitize_weights_for_trajectories(trajectories=trajectories,
155+
weights=weights)
156+
# now predict for the newly added
157+
preds = await asyncio.gather(*(model(tra) for tra in trajectories))
158+
for pred, tra, w in zip(preds, trajectories, weights):
159+
# add to histogram
160+
histo, _ = np.histogramdd(sample=pred,
161+
bins=self.bins,
162+
range=[(0., 1.)
163+
for _ in range(self.n_dim)],
164+
weights=w,
165+
)
166+
self.density_histogram += histo
167+
self._n_samp += pred.shape[0]
168+
# store trajectories and weights for reevaluation
169+
self._trajectories.append(tra)
170+
self._weights.append(w)
171+
172+
def _sanitize_weights_for_trajectories(self, trajectories: list, weights: list | None,
173+
) -> "list[npt.NDArray]":
174+
"""
175+
Ensure that weights has the correct/expected shape for each trajectory.
176+
177+
The correct/expected shape is a np.NDArray of shape=(len(traj),) for each
178+
trajectory.
179+
180+
Parameters
181+
----------
182+
trajectories : list
183+
A list of trajectories for which the weights should match.
184+
weights : list | None
185+
The corresponding list of weights to sanitize. Can be None in which
186+
case every configuration in every Trajectory will get an equal weight.
187+
Can also be one weight for a whole Trajectory, in which case each
188+
configuration in that Trajectory will get this value as weight.
189+
190+
Returns
191+
-------
192+
list[npt.NDArray]
193+
A list with sanitize weights of matching/expected shape.
194+
"""
195+
if weights is None:
196+
# give each point an equal weight of 1
197+
weights = [np.ones(shape=(len(t), )) for t in trajectories]
198+
else:
199+
for i, (w, traj) in enumerate(zip(weights, trajectories)):
200+
# if weights is one weight for the whole trajectory:
201+
# make it an array of the correct length
202+
if isinstance(w, (int, np.integer, float, np.floating)):
203+
weights[i] = np.full((len(traj), ), w)
204+
return weights
205+
206+
def reevaluate_density(self, model) -> None:
207+
"""
208+
Reevaluate the density for all stored trajectories.
209+
210+
Will replace the density histogram with a new density estimate for all
211+
trajectories using the given models prediction.
212+
213+
Parameters
214+
----------
215+
model : aimmd.base.RCModel
216+
The model to use fpr predicting commitment probabilities.
217+
"""
218+
if self._check_for_async(model=model):
219+
raise ValueError(
220+
"An async model was passed, but this method is not async."
221+
" See method ``reevaluate_density_async``"
222+
)
223+
# keep a ref to current trajs and weights, then reset self
224+
trajs, weights = self._trajectories, self._weights
225+
self.reset()
226+
# and finally (re)add all trajectories using the current model
227+
self.add_density_for_trajectories(model=model, trajectories=trajs,
228+
weights=weights)
229+
230+
@_is_documented_by(reevaluate_density)
231+
# pylint: disable-next=missing-function-docstring
232+
async def reevaluate_density_async(self, model):
233+
if not self._check_for_async(model=model):
234+
raise ValueError(
235+
"A non-async model was passed, but this method is async."
236+
" See method ``reevaluate_density``"
237+
)
238+
# keep a ref to current trajs and weights, then reset self
239+
trajs, weights = self._trajectories, self._weights
240+
self.reset()
241+
# and finally (re)add all trajectories using the current model
242+
await self.add_density_for_trajectories_async(model=model,
243+
trajectories=trajs,
244+
weights=weights,
245+
)
246+
247+
def get_counts(self, probabilities):
248+
"""
249+
Return the current counts/density values for a given probability vector.
250+
251+
Parameters
252+
----------
253+
probabilities : numpy.ndarray
254+
The commitment probabilities, with shape=(n_points, self.n_dim), for
255+
which density values will be returned.
256+
257+
Returns
258+
-------
259+
counts : numpy.ndarray, shape=(n_points,)
260+
Values of the density counter at the given points in probability-space.
261+
"""
262+
# we take the min to make sure we are always in the
263+
# histogram range, even if p = 1
264+
idxs = tuple(np.intp(
265+
np.minimum(np.floor(probabilities[:, i] * self.bins),
266+
self.bins - 1)
267+
)
268+
for i in range(self.n_dim)
269+
)
270+
return self.density_histogram[idxs]
271+
272+
def get_correction(self, probabilities):
273+
"""
274+
Return the 'flattening factor' for the observed density of points.
275+
276+
The factor is calculated as total_count / counts[probabilities], i.e.
277+
the factor is proportional to 1 / rho[probabilities].
278+
279+
Note that we use a variant of add-one-smoothing in case we encounter infinite
280+
values due to zero density in a bin.
281+
In case we encounter an infinity we replace the value in that bin by
282+
(norm + n_bins * weight_1_samp) / weight_1_samp,
283+
where norm is the sum of the density in all bins, n_bins is the total
284+
number of (allowed) bins in each probability direction combined, and
285+
weight_1_samp is the average weight per sample.
286+
287+
Parameters
288+
----------
289+
probabilities : np.ndarray
290+
The commitment probabilities, with shape=(n_points, self.n_dim),
291+
for which correction factor values are to be returned.
292+
"""
293+
dens = self.get_counts(probabilities)
294+
if not (norm := np.sum(self.density_histogram)):
295+
return np.ones_like(dens) # we dont have any density yet
296+
with np.errstate(divide="ignore"):
297+
# ignore errors through division by zero
298+
factor = norm / dens
299+
# Now replace all potential infs by a large (but finite) value
300+
if np.any(np.isinf(factor)):
301+
# weight_1_samp is the average weight per sample configuration
302+
weight_1_samp = norm / self._n_samp
303+
# the bit below is similar to laplace add-one smoothing, just
304+
# that we use weight_1_samp instead of adding a one, because we
305+
# do not know if the average weight per sample \approx 1
306+
# [in laplace add-one smoothing we would use:
307+
# (norm + self._n_allowed_bins) / (dens + 1)
308+
# here dens = 0 (since we got the inf)]
309+
# and self._n_allowed_bins becomes self._n_allowed_bins * weight_1_samp,
310+
# i.e., we add one average sample into each allowed bin
311+
# (as in laplace add-one smoothing)
312+
factor[factor == np.inf] = norm/weight_1_samp + self._n_allowed_bins
313+
314+
return factor

0 commit comments

Comments
 (0)