Skip to content

Commit bf28d5c

Browse files
paulsaxeclaude
andcommitted
Add DIRECT descriptor-diversity down-selection for energy-stratified sampling
Add a "selection method" for the energy-stratified pool with three options: - "energy bins + diversity" (new default): flat-in-energy bins, and within each bin keep a geometrically diverse, de-duplicated subset by clustering the collective variables (separation, approach direction, relative orientation, closest contact) -- the DIRECT method (Qi et al., npj Comput. Mater. 2024). - "descriptor diversity": one global DIRECT clustering over those variables plus the interaction energy (scaled by a new "energy weight"); maximal geometric diversity, energy flatness controlled by the weight. - "energy bins": the prior flat-energy random pick. Featurization reuses the vendored dimer_analysis metrics; clustering is StandardScaler -> PCA (variance-weighted) -> BIRCH (adaptive threshold) -> nearest-centroid per cluster. Adds scikit-learn as a dependency. Validated on a real MOPAC PM6-ORG water pool (4877 candidates): the default method keeps the flat energy (flatness CV 0.19) and the deepest well coverage (28% below -10 kJ/mol) while improving geometric diversity (mean nearest- neighbour 0.61 -> 0.72) over plain energy bins. Global DIRECT with the energy weight trades energy flatness for more diversity as documented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a76aa4f commit bf28d5c

7 files changed

Lines changed: 368 additions & 26 deletions

File tree

devtools/conda-envs/test_env.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies:
1414
# Dependencies
1515
- mendeleev
1616
- plotly
17+
- scikit-learn
1718

1819
# Testing
1920
- black

dimer_builder_step/dimer_builder.py

Lines changed: 211 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,28 @@ def description_text(self, P=None, short=False):
250250
f" For each, evaluate the interaction energy ΔE(R) along the "
251251
f"approach using the '{P['contact method']}' method out to "
252252
f"{P['maximum separation']}, then pool the candidate points across "
253-
f"all orientations and keep a set that is flat in interaction "
254-
f"energy -- sorting them into {P['number of energy bins']} ΔE bins "
255-
f"over the range set by '{P['energy levels']}' and capping each bin "
256-
f"equally (about {P['target configurations']} configurations total)."
253+
f"all orientations and keep about {P['target configurations']} of "
254+
f"them"
257255
)
256+
method = P.get("selection method", "energy bins")
257+
if method == "descriptor diversity":
258+
text += (
259+
" by clustering in a space of geometric collective variables "
260+
"plus the interaction energy and keeping one per cluster (a "
261+
"DIRECT-style, de-duplicated, diverse set)."
262+
)
263+
elif method == "energy bins + diversity":
264+
text += (
265+
f" by sorting them into {P['number of energy bins']} ΔE bins "
266+
"(flat in interaction energy) and keeping a geometrically "
267+
"diverse subset of each bin (DIRECT clustering)."
268+
)
269+
else:
270+
text += (
271+
f" by sorting them into {P['number of energy bins']} ΔE bins "
272+
f"over the range set by '{P['energy levels']}' and capping each "
273+
f"bin equally (flat in interaction energy)."
274+
)
258275
else:
259276
text += (
260277
f" For each, locate the contact distance using the "
@@ -1030,6 +1047,177 @@ def _global_stratify(self, dE_values, P, rng):
10301047
selected.extend(int(i) for i in pick)
10311048
return sorted(selected)
10321049

1050+
def _candidate_dimers(
1051+
self, candidates, orient_data, symbols_A, symbols_B, a_idx, b_idx
1052+
):
1053+
"""Build ``dimer_analysis.Dimer`` objects for a list of candidates."""
1054+
from dimer_builder_step import dimer_analysis
1055+
1056+
out = []
1057+
for o, d, _e in candidates:
1058+
coords = orient_data[o]["rebuild"](d)
1059+
out.append(
1060+
dimer_analysis.Dimer(
1061+
symbols_A=symbols_A,
1062+
xyz_A=coords[a_idx],
1063+
symbols_B=symbols_B,
1064+
xyz_B=coords[b_idx],
1065+
)
1066+
)
1067+
return out
1068+
1069+
def _cluster_pick(self, dimers, dE, target, energy_weight):
1070+
"""DIRECT featurize → cluster → nearest-centroid pick; local indices.
1071+
1072+
Featurizes each dimer by the collective variables ``dimer_analysis``
1073+
computes (separation, approach unit vector, relative orientation,
1074+
closest contact); if ``dE`` is given, the interaction energy is appended
1075+
as an extra feature scaled by ``energy_weight`` (so energy can be made to
1076+
count for more than one geometric dimension). Follows DIRECT (Qi et al.,
1077+
npj Comput. Mater. 2024): standardize → PCA (variance-weighted scores) →
1078+
BIRCH into ``target`` clusters → keep the member nearest each centroid.
1079+
Returns the kept indices into ``dimers``.
1080+
"""
1081+
import warnings
1082+
1083+
from dimer_builder_step import dimer_analysis
1084+
from sklearn.preprocessing import StandardScaler
1085+
from sklearn.decomposition import PCA
1086+
from sklearn.cluster import Birch
1087+
from sklearn.exceptions import ConvergenceWarning
1088+
1089+
n = len(dimers)
1090+
if n <= target:
1091+
return list(range(n))
1092+
1093+
m = dimer_analysis.compute_metrics(dimers)
1094+
cols = [
1095+
m.R,
1096+
m.approach_vec[:, 0],
1097+
m.approach_vec[:, 1],
1098+
m.approach_vec[:, 2],
1099+
m.orient_angle,
1100+
m.min_contact,
1101+
]
1102+
if dE is not None:
1103+
cols.append(np.asarray(dE, dtype=float))
1104+
feats = np.column_stack(cols)
1105+
# Fill non-finite entries (e.g. orientation angle for a monatomic
1106+
# fragment) with the column mean so every candidate is clusterable.
1107+
for j in range(feats.shape[1]):
1108+
col = feats[:, j]
1109+
bad = ~np.isfinite(col)
1110+
if bad.any():
1111+
col[bad] = np.nanmean(col[~bad]) if (~bad).any() else 0.0
1112+
1113+
X = StandardScaler().fit_transform(feats)
1114+
if dE is not None:
1115+
X[:, -1] *= float(energy_weight) # up-weight the energy axis
1116+
1117+
Z = PCA(n_components=min(X.shape)).fit_transform(X)
1118+
# Normalize the (variance-weighted) PCA scores to an overall unit scale so
1119+
# the BIRCH threshold behaves consistently, then shrink the threshold
1120+
# until BIRCH resolves at least 'target' subclusters (it warns and
1121+
# returns fewer otherwise -- the classic BIRCH pitfall).
1122+
scale = float(Z.std())
1123+
if scale > 0.0:
1124+
Z = Z / scale
1125+
threshold = 0.5
1126+
with warnings.catch_warnings():
1127+
warnings.simplefilter("ignore", ConvergenceWarning)
1128+
for _ in range(10):
1129+
model = Birch(threshold=threshold, n_clusters=target).fit(Z)
1130+
if len(model.subcluster_centers_) >= target:
1131+
break
1132+
threshold *= 0.5
1133+
labels = model.labels_
1134+
1135+
keep = []
1136+
for lab in np.unique(labels):
1137+
members = np.where(labels == lab)[0]
1138+
centroid = Z[members].mean(axis=0)
1139+
nearest = members[np.argmin(((Z[members] - centroid) ** 2).sum(axis=1))]
1140+
keep.append(int(nearest))
1141+
return sorted(keep)
1142+
1143+
def _direct_select(
1144+
self, candidates, orient_data, symbols_A, symbols_B, a_idx, b_idx, P
1145+
):
1146+
"""Method A: one global DIRECT clustering over the CVs **plus ΔE**.
1147+
1148+
ΔE is included as a feature scaled by the 'energy weight' parameter, so a
1149+
single clustering covers geometry and energy together; near-duplicate
1150+
geometries collapse (de-duplication) and the kept set spreads over both
1151+
axes. Returns the kept indices into ``candidates``.
1152+
"""
1153+
target = max(int(P["target configurations"]), 1)
1154+
if len(candidates) <= target:
1155+
return list(range(len(candidates)))
1156+
dimers = self._candidate_dimers(
1157+
candidates, orient_data, symbols_A, symbols_B, a_idx, b_idx
1158+
)
1159+
dE = [c[2] for c in candidates]
1160+
weight = float(P.get("energy weight", 1.0))
1161+
return self._cluster_pick(dimers, dE, target, weight)
1162+
1163+
def _select_within_bins(
1164+
self, candidates, orient_data, symbols_A, symbols_B, a_idx, b_idx, P
1165+
):
1166+
"""Method B: flat-in-energy bins, DIRECT-diversify the geometry per bin.
1167+
1168+
Bins the candidates by ΔE (guaranteeing flat energy, like 'energy bins'),
1169+
then within each over-full bin keeps a geometrically diverse subset via
1170+
DIRECT on the geometric CVs alone (ΔE excluded -- energy is already fixed
1171+
by the bin). Returns the kept indices into ``candidates``.
1172+
"""
1173+
dE = np.array([c[2] for c in candidates], dtype=float)
1174+
if dE.size == 0:
1175+
return []
1176+
n_bins = max(int(P["number of energy bins"]), 1)
1177+
per_bin = max(int(P["target configurations"]) // n_bins, 1)
1178+
lo = float(dE.min())
1179+
cap = self._repulsive_cap(P)
1180+
hi = float(cap) if (cap is not None and cap > lo) else float(dE.max())
1181+
if hi <= lo:
1182+
hi = lo + 1.0
1183+
edges = np.linspace(lo, hi, n_bins + 1)
1184+
which = np.clip(np.digitize(dE, edges) - 1, 0, n_bins - 1)
1185+
1186+
keep = []
1187+
for b in range(n_bins):
1188+
members = np.where(which == b)[0]
1189+
if len(members) <= per_bin:
1190+
keep.extend(int(i) for i in members)
1191+
continue
1192+
subset = [candidates[i] for i in members]
1193+
dimers = self._candidate_dimers(
1194+
subset, orient_data, symbols_A, symbols_B, a_idx, b_idx
1195+
)
1196+
local = self._cluster_pick(dimers, None, per_bin, 1.0)
1197+
keep.extend(int(members[j]) for j in local)
1198+
return sorted(keep)
1199+
1200+
def _select_pool(
1201+
self, candidates, orient_data, symbols_A, symbols_B, a_idx, b_idx, P, rng
1202+
):
1203+
"""Down-select the pooled candidates by the chosen 'selection method'.
1204+
1205+
Returns the kept subset of ``candidates`` (both methods target about
1206+
'target configurations').
1207+
"""
1208+
method = P.get("selection method", "energy bins")
1209+
if method == "descriptor diversity":
1210+
keep = self._direct_select(
1211+
candidates, orient_data, symbols_A, symbols_B, a_idx, b_idx, P
1212+
)
1213+
elif method == "energy bins + diversity":
1214+
keep = self._select_within_bins(
1215+
candidates, orient_data, symbols_A, symbols_B, a_idx, b_idx, P
1216+
)
1217+
else:
1218+
keep = self._global_stratify([c[2] for c in candidates], P, rng)
1219+
return [candidates[i] for i in keep]
1220+
10331221
@staticmethod
10341222
def _make_interpolator(ds, dE):
10351223
"""A function mapping a distance (Å) to its interpolated ΔE (kJ/mol)."""
@@ -1342,10 +1530,13 @@ def assemble(d, xyzA=xyzA, xyzB=xyzB, axis=axis):
13421530
if engine is not None:
13431531
engine.close()
13441532

1345-
# Phase 2: globally stratify the pooled candidates by energy.
1533+
# Phase 2: globally down-select the pooled candidates.
1534+
a_idx = np.arange(nA)
1535+
b_idx = np.arange(nA, nA + B0.n_atoms)
13461536
if global_strat:
1347-
keep = self._global_stratify([c[2] for c in candidates], P, rng)
1348-
candidates = [candidates[i] for i in keep]
1537+
candidates = self._select_pool(
1538+
candidates, orient_data, symbols_A, symbols_B, a_idx, b_idx, P, rng
1539+
)
13491540
candidates.sort(key=lambda c: (c[0], c[1]))
13501541

13511542
# Phase 3: build the selected candidates.
@@ -1362,8 +1553,8 @@ def assemble(d, xyzA=xyzA, xyzB=xyzB, axis=axis):
13621553
movable_ids=movable_ids,
13631554
symbols_A=symbols_A,
13641555
symbols_B=symbols_B,
1365-
a_idx=np.arange(nA),
1366-
b_idx=np.arange(nA, nA + B0.n_atoms),
1556+
a_idx=a_idx,
1557+
b_idx=b_idx,
13671558
)
13681559

13691560
stats = self._stats(name, P["number of orientations"], separations)
@@ -1499,10 +1690,18 @@ def assemble(
14991690
if engine is not None:
15001691
engine.close()
15011692

1502-
# Phase 2: globally stratify by energy (energy-stratified spacing).
1693+
# Phase 2: globally down-select the pooled candidates.
15031694
if global_strat:
1504-
keep = self._global_stratify([c[2] for c in candidates], P, rng)
1505-
candidates = [candidates[i] for i in keep]
1695+
candidates = self._select_pool(
1696+
candidates,
1697+
orient_data,
1698+
symbols_fixed,
1699+
symbols_movable,
1700+
fixed_idx,
1701+
movable_idx,
1702+
P,
1703+
rng,
1704+
)
15061705
candidates.sort(key=lambda c: (c[0], c[1]))
15071706

15081707
# Phase 3: build the selected candidates.

dimer_builder_step/dimer_builder_parameters.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,45 @@ class DimerBuilderParameters(seamm.Parameters):
299299
"orientation weighting is 'none'."
300300
),
301301
},
302+
"selection method": {
303+
"default": "energy bins + diversity",
304+
"kind": "enum",
305+
"default_units": "",
306+
"enumeration": (
307+
"energy bins + diversity",
308+
"descriptor diversity",
309+
"energy bins",
310+
),
311+
"format_string": "",
312+
"description": "Down-select by:",
313+
"help_text": (
314+
"How to down-select the pooled candidate configurations for "
315+
"'energy-stratified' spacing (all target about 'target "
316+
"configurations'). 'energy bins + diversity' bins by interaction "
317+
"energy (flat in energy) and, within each bin, keeps a "
318+
"geometrically diverse, de-duplicated subset (DIRECT clustering of "
319+
"the geometric collective variables). 'descriptor diversity' is a "
320+
"single global DIRECT clustering over those variables PLUS the "
321+
"interaction energy (scaled by 'energy weight') -- maximally "
322+
"diverse, but flatness depends on the weight. 'energy bins' caps "
323+
"each energy bin equally with a random pick (flat in energy, no "
324+
"geometric de-duplication)."
325+
),
326+
},
327+
"energy weight": {
328+
"default": 8.0,
329+
"kind": "float",
330+
"default_units": "",
331+
"enumeration": tuple(),
332+
"format_string": ".1f",
333+
"description": "Energy weight:",
334+
"help_text": (
335+
"For the 'descriptor diversity' selection: how strongly the "
336+
"interaction energy counts relative to each geometric collective "
337+
"variable in the clustering (1 = equal; larger keeps the sample "
338+
"flatter in energy at the cost of some geometric diversity)."
339+
),
340+
},
302341
"number of energy bins": {
303342
"default": 12,
304343
"kind": "integer",

dimer_builder_step/tk_dimer_builder.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def create_dialog(self):
9292
"input mode",
9393
"spacing",
9494
"contact method",
95+
"selection method",
9596
"orientation weighting",
9697
"monomer A configurations",
9798
"monomer B configurations",
@@ -181,8 +182,13 @@ def add(key):
181182
add("number of separations") # ΔE(R) profile resolution
182183
add("energy levels")
183184
add("sampling temperature")
184-
add("number of energy bins")
185+
add("selection method")
185186
add("target configurations")
187+
selection = self["selection method"].get()
188+
if selection in ("energy bins", "energy bins + diversity"):
189+
add("number of energy bins")
190+
if selection == "descriptor diversity":
191+
add("energy weight")
186192
if mode == "two monomer sets":
187193
add("orientation weighting")
188194
if self["orientation weighting"].get() != "none":

docs/user_guide/index.rst

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,29 +91,43 @@ long-range tail -- so the training set should cover that whole energy range
9191
evenly, instead of piling most configurations in the shallow, nearly
9292
non-interacting region that uniform sampling produces.
9393

94-
It works by pooling candidate configurations from *all* orientations, sorting
95-
them into interaction-energy bins, and keeping the same number from each bin:
94+
It works by pooling candidate configurations from *all* orientations and then
95+
down-selecting about **Target configurations** of them. **Down-select by**
96+
chooses how:
97+
98+
* ``energy bins + diversity`` (default) -- sort the candidates into interaction-
99+
energy bins (flat in energy) and, within each bin, keep a *geometrically
100+
diverse, de-duplicated* subset by clustering the collective variables
101+
(separation, approach direction, relative orientation, closest contact). This
102+
gives a set that is flat in energy, reaches deep into the attractive well, and
103+
is not dominated by near-identical geometries.
104+
* ``descriptor diversity`` -- a single global clustering (the DIRECT method) over
105+
those collective variables **plus** the interaction energy, keeping one per
106+
cluster. **Energy weight** sets how strongly ΔE counts relative to each
107+
geometric variable (larger = flatter in energy, less geometric spread). This
108+
maximizes geometric diversity but the energy flatness depends on the weight.
109+
* ``energy bins`` -- energy bins with a plain random pick per bin (flat in
110+
energy, no geometric de-duplication).
111+
112+
Other controls:
96113

97114
* **Number of energy bins** -- how many ΔE bins to spread the kept
98-
configurations across.
99-
* **Target configurations** -- the approximate total to keep (the per-bin cap
100-
is this divided by the number of bins). Deeply bound geometries are rare, so
101-
those bins -- and hence the total -- may come out smaller; raise **Number of
102-
orientations** to find more deep configurations and fill them.
115+
configurations across (the two binned methods).
116+
* **Target configurations** -- the approximate total to keep. Deeply bound
117+
geometries are rare, so the total may come out smaller; raise **Number of
118+
orientations** to find more deep configurations.
103119
* **ΔE levels** -- sets the energy window. The most repulsive value (default
104120
``+5*kBT``) caps the wall, so no configuration is pushed to an absurd
105121
repulsive energy; the symbols ``De`` (well depth) and ``kBT`` (thermal energy
106122
at the **Sampling temperature**) may be used.
107123
* **Weight orientations by well depth** -- an optional pre-filter. The default
108-
``none`` keeps every orientation and lets the energy stratification do the
109-
balancing; ``reject shallow orientations`` / ``downweight by depth`` bias
110-
toward the more strongly bound orientations first (using **Minimum well
111-
depth**).
124+
``none`` keeps every orientation; ``reject shallow orientations`` /
125+
``downweight by depth`` bias toward the more strongly bound orientations first
126+
(using **Minimum well depth**).
112127

113128
Because the interaction energy varies almost entirely at short range, a
114129
flat-in-energy set naturally has most of its configurations at short
115-
separations; that is expected. (Note that flat-*in-energy* does not by itself
116-
make the *orientations* diverse -- that is a separate, planned selection step.)
130+
separations; that is expected.
117131

118132
What is stored
119133
==============

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ molsystem
66
mendeleev
77
numpy
88
plotly
9+
scikit-learn

0 commit comments

Comments
 (0)