maltefranke commited on
Commit
7ada26b
·
verified ·
1 Parent(s): a52834f

Release GEOM scaffold substituent LoRA rank-16 checkpoint after 25 epochs (part 2)

Browse files
geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/tensor.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Geometric tensor metrics consumed directly from ``ChemBatch``.
2
+
3
+ These run in every representation (no RDKit, no validity conditioning). Target
4
+ stats (per-element-pair distance histogram, RoG histogram) are computed once at
5
+ preprocessing time on training-set coords in Angstroms and stored in
6
+ ``Distributions``. Generated coords are expected to arrive in Angstroms too —
7
+ ``sample()`` in the LightningModule already multiplies by ``coordinate_std``
8
+ before returning the final ``ChemBatch``.
9
+
10
+ Also hosts the shared bin edges + histogram accumulators because both this
11
+ module and ``preprocessing.py`` must agree on them exactly.
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+
16
+ import torch
17
+ from torchmetrics import Metric
18
+
19
+ from morph.dataset.core.atomic_data import ChemBatch
20
+
21
+ # Shared bin edges (Angstroms). Used both at preprocessing time to compute
22
+ # target histograms and at metric time to bin generated samples — they must
23
+ # match exactly. Defaults are generous enough for QM9 / GEOM / TMQM; values
24
+ # above the upper bound are counted in an overflow accumulator (see the binning
25
+ # helpers below) so silent clamping is visible at validation time.
26
+ DIST_RANGE = (0.0, 15.0)
27
+ DIST_N_BINS = 150
28
+ RG_RANGE = (0.0, 8.0)
29
+ RG_N_BINS = 80
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class BatchGeometry:
34
+ """Geometry shared by all tensor metrics for one generated batch."""
35
+
36
+ num_graphs: int
37
+ node_counts: torch.Tensor
38
+ pair_distances: torch.Tensor
39
+ pair_atom_lo: torch.Tensor
40
+ pair_atom_hi: torch.Tensor
41
+ pair_graph: torch.Tensor
42
+ radii_of_gyration: torch.Tensor
43
+
44
+
45
+ def compute_batch_geometry(batch: ChemBatch) -> BatchGeometry:
46
+ """Compute pair distances and radii of gyration once for all metrics."""
47
+ rows, cols = batch.edge_index
48
+ upper = rows < cols
49
+ rows, cols = rows[upper], cols[upper]
50
+ pair_distances = (batch.x[rows] - batch.x[cols]).norm(dim=-1)
51
+ atom_i, atom_j = batch.a[rows], batch.a[cols]
52
+
53
+ counts = batch.num_nodes_per_graph
54
+ centers = batch.x.new_zeros((batch.num_graphs, batch.x.shape[-1]))
55
+ centers.index_add_(0, batch.batch, batch.x)
56
+ centers = centers / counts.clamp(min=1).unsqueeze(1)
57
+ squared_radius = (batch.x - centers[batch.batch]).square().sum(dim=-1)
58
+ mean_squared_radius = batch.x.new_zeros(batch.num_graphs)
59
+ mean_squared_radius.index_add_(0, batch.batch, squared_radius)
60
+ mean_squared_radius = mean_squared_radius / counts.clamp(min=1)
61
+
62
+ return BatchGeometry(
63
+ num_graphs=batch.num_graphs,
64
+ node_counts=counts,
65
+ pair_distances=pair_distances,
66
+ pair_atom_lo=torch.minimum(atom_i, atom_j),
67
+ pair_atom_hi=torch.maximum(atom_i, atom_j),
68
+ pair_graph=batch.batch[rows],
69
+ radii_of_gyration=mean_squared_radius.sqrt(),
70
+ )
71
+
72
+
73
+ def dist_edges() -> torch.Tensor:
74
+ return torch.linspace(DIST_RANGE[0], DIST_RANGE[1], DIST_N_BINS + 1)
75
+
76
+
77
+ def rg_edges() -> torch.Tensor:
78
+ return torch.linspace(RG_RANGE[0], RG_RANGE[1], RG_N_BINS + 1)
79
+
80
+
81
+ def accumulate_pairwise_distance_hist(
82
+ hist: torch.Tensor,
83
+ coord: torch.Tensor,
84
+ atom_idx: torch.Tensor,
85
+ edges: torch.Tensor,
86
+ ) -> tuple[int, int]:
87
+ """Add upper-triangular pair distances to ``hist`` (A, A, B), in place.
88
+
89
+ The pair key is ``(min(a_i, a_j), max(a_i, a_j))`` so the histogram is
90
+ naturally invariant to atom ordering.
91
+
92
+ Returns ``(n_overflow, n_total)`` for the pairs in this molecule, so callers
93
+ can track the fraction of distances that fell beyond the histogram range
94
+ (silent clamping otherwise hides "the cloud is way too large").
95
+ """
96
+ n = coord.shape[0]
97
+ if n < 2:
98
+ return 0, 0
99
+ i_idx, j_idx = torch.triu_indices(n, n, offset=1, device=coord.device)
100
+ dists = (coord[i_idx] - coord[j_idx]).norm(dim=-1)
101
+ lo = torch.minimum(atom_idx[i_idx], atom_idx[j_idx])
102
+ hi = torch.maximum(atom_idx[i_idx], atom_idx[j_idx])
103
+ edges_d = edges.to(coord.device)
104
+ n_overflow = int((dists >= edges_d[-1]).sum().item())
105
+ bin_idx = torch.bucketize(dists, edges_d).sub_(1).clamp_(0, hist.shape[-1] - 1)
106
+ a = hist.shape[0]
107
+ flat = (lo.long() * a + hi.long()) * hist.shape[-1] + bin_idx
108
+ hist.view(-1).index_add_(
109
+ 0, flat, torch.ones_like(flat, dtype=hist.dtype, device=coord.device)
110
+ )
111
+ return n_overflow, int(dists.numel())
112
+
113
+
114
+ def accumulate_rog_hist(
115
+ hist: torch.Tensor, coord: torch.Tensor, edges: torch.Tensor
116
+ ) -> tuple[int, int]:
117
+ """Add one molecule's radius of gyration into ``hist`` (B,), in place.
118
+
119
+ Returns ``(n_overflow, n_total)`` (0 or 1 each) for the same reason as
120
+ ``accumulate_pairwise_distance_hist``.
121
+ """
122
+ if coord.shape[0] == 0:
123
+ return 0, 0
124
+ centered = coord - coord.mean(dim=0)
125
+ rog = (centered.pow(2).sum(dim=-1).mean()).sqrt()
126
+ edges_d = edges.to(coord.device)
127
+ overflow = int((rog >= edges_d[-1]).item())
128
+ bin_idx = (
129
+ torch.bucketize(rog.unsqueeze(0), edges_d).sub_(1).clamp_(0, hist.shape[0] - 1)
130
+ )
131
+ hist.index_add_(0, bin_idx, torch.ones(1, dtype=hist.dtype, device=coord.device))
132
+ return overflow, 1
133
+
134
+
135
+ class BatchMetric(Metric):
136
+ """Base class for metrics that consume a ``ChemBatch`` directly."""
137
+
138
+ def update(self, batch: ChemBatch) -> None:
139
+ """Accumulate tensor statistics from molecular batches."""
140
+ raise NotImplementedError
141
+
142
+ def compute(self) -> torch.Tensor:
143
+ """Reduce the accumulated tensor statistics to a metric value."""
144
+ raise NotImplementedError
145
+
146
+
147
+ class PairwiseDistanceL1(BatchMetric):
148
+ """Mean per-element-pair L1 distance between gen and target distance histograms.
149
+
150
+ For every ordered (lo, hi) atom-type pair the *target* has meaningful mass
151
+ for, we normalize gen and target slices independently and report
152
+ ``|gen - target|.sum()``. The mean over those pairs is a 0-2 metric where 0
153
+ means perfect distributional match.
154
+
155
+ Note: gen-empty pairs are *not* excluded. If the target has C-N distances
156
+ but the model never generates a C-N pair, the L1 against zeros is 1.0 and
157
+ that pair counts in the average — which is what we want.
158
+ """
159
+
160
+ def __init__(
161
+ self, target_hist: torch.Tensor, min_pair_count: float = 10.0, **kwargs
162
+ ):
163
+ super().__init__(**kwargs)
164
+ self.register_buffer("target_hist", target_hist.detach().to(torch.float32))
165
+ self.register_buffer("edges", dist_edges())
166
+ self.min_pair_count = min_pair_count
167
+ self.add_state(
168
+ "gen_hist",
169
+ default=torch.zeros_like(self.target_hist),
170
+ dist_reduce_fx="sum",
171
+ )
172
+
173
+ def update(self, geometry: BatchGeometry) -> None:
174
+ """Accumulate pair-distance histograms by unordered atom-type pair."""
175
+ distances = geometry.pair_distances
176
+ if distances.numel() == 0:
177
+ return
178
+ edges = self.edges.to(distances.device)
179
+ bins = (
180
+ torch.bucketize(distances, edges)
181
+ .sub_(1)
182
+ .clamp_(0, self.gen_hist.shape[-1] - 1)
183
+ )
184
+ num_atom_types = self.gen_hist.shape[0]
185
+ flat = (
186
+ geometry.pair_atom_lo.long() * num_atom_types + geometry.pair_atom_hi.long()
187
+ ) * self.gen_hist.shape[-1] + bins
188
+ self.gen_hist.view(-1).index_add_(
189
+ 0, flat, torch.ones_like(flat, dtype=self.gen_hist.dtype)
190
+ )
191
+
192
+ def compute(self) -> torch.Tensor:
193
+ """Average normalized histogram L1 errors over populated target pairs."""
194
+ gen = self.gen_hist # (A, A, B)
195
+ tgt = self.target_hist
196
+ pair_t = tgt.sum(dim=-1)
197
+ # Target-populated only: a pair the training set knows about counts in
198
+ # the metric even if the model generates zero of them (gen_n = 0 →
199
+ # L1 == tgt_n.sum() == 1, which is maximal pain).
200
+ populated = pair_t >= self.min_pair_count
201
+ if not populated.any():
202
+ return torch.tensor(0.0, device=gen.device)
203
+ gen_n = gen / gen.sum(dim=-1).unsqueeze(-1).clamp(min=1.0)
204
+ tgt_n = tgt / pair_t.unsqueeze(-1).clamp(min=1.0)
205
+ l1 = (gen_n - tgt_n).abs().sum(dim=-1) # (A, A)
206
+ return l1[populated].mean()
207
+
208
+
209
+ class MinDistanceViolation(BatchMetric):
210
+ """Fraction of generated molecules whose smallest pairwise distance is below
211
+ a physical threshold (default 0.5 Å). Catches collapse and overlap.
212
+ """
213
+
214
+ def __init__(self, threshold: float = 0.5, **kwargs):
215
+ super().__init__(**kwargs)
216
+ self.threshold = float(threshold)
217
+ self.add_state("violations", default=torch.tensor(0.0), dist_reduce_fx="sum")
218
+ self.add_state("total", default=torch.tensor(0.0), dist_reduce_fx="sum")
219
+
220
+ def update(self, geometry: BatchGeometry) -> None:
221
+ """Count graphs with an atom pair below the distance threshold."""
222
+ valid = geometry.node_counts >= 2
223
+ minima = geometry.pair_distances.new_full((geometry.num_graphs,), float("inf"))
224
+ minima.scatter_reduce_(
225
+ 0, geometry.pair_graph, geometry.pair_distances, reduce="amin"
226
+ )
227
+ self.violations += ((minima < self.threshold) & valid).sum()
228
+ self.total += valid.sum()
229
+
230
+ def compute(self) -> torch.Tensor:
231
+ """Return the violation fraction among graphs with at least two atoms."""
232
+ if self.total <= 0:
233
+ return torch.tensor(0.0, device=self.violations.device)
234
+ return self.violations / self.total
235
+
236
+
237
+ class RangeOverflow(BatchMetric):
238
+ """Fraction of generated values that landed outside the histogram range.
239
+
240
+ If this is non-trivial (say >1%), the fixed range in this module is too
241
+ small for the dataset and we're silently clamping into the last bin —
242
+ revisit ``DIST_RANGE`` / ``RG_RANGE`` or switch to target-derived edges.
243
+ """
244
+
245
+ def __init__(self, **kwargs):
246
+ super().__init__(**kwargs)
247
+ self.register_buffer("dist_edges_buf", dist_edges())
248
+ self.register_buffer("rog_edges_buf", rg_edges())
249
+ self.add_state("dist_over", default=torch.tensor(0.0), dist_reduce_fx="sum")
250
+ self.add_state("dist_total", default=torch.tensor(0.0), dist_reduce_fx="sum")
251
+ self.add_state("rog_over", default=torch.tensor(0.0), dist_reduce_fx="sum")
252
+ self.add_state("rog_total", default=torch.tensor(0.0), dist_reduce_fx="sum")
253
+
254
+ def update(self, geometry: BatchGeometry) -> None:
255
+ """Count distances and radii outside their histogram ranges."""
256
+ self.dist_over += (geometry.pair_distances >= self.dist_edges_buf[-1]).sum()
257
+ self.dist_total += geometry.pair_distances.numel()
258
+ valid = geometry.node_counts > 0
259
+ self.rog_over += (
260
+ (geometry.radii_of_gyration >= self.rog_edges_buf[-1]) & valid
261
+ ).sum()
262
+ self.rog_total += valid.sum()
263
+
264
+ def compute(self) -> torch.Tensor:
265
+ """Return the larger of the distance and radius overflow fractions."""
266
+ d = self.dist_over / self.dist_total.clamp(min=1.0)
267
+ r = self.rog_over / self.rog_total.clamp(min=1.0)
268
+ return torch.maximum(d, r)
269
+
270
+
271
+ class RoGL1(BatchMetric):
272
+ """L1 distance between gen and target normalized radius-of-gyration histograms."""
273
+
274
+ def __init__(self, target_hist: torch.Tensor, **kwargs):
275
+ super().__init__(**kwargs)
276
+ self.register_buffer("target_hist", target_hist.detach().to(torch.float32))
277
+ self.register_buffer("edges", rg_edges())
278
+ self.add_state(
279
+ "gen_hist",
280
+ default=torch.zeros_like(self.target_hist),
281
+ dist_reduce_fx="sum",
282
+ )
283
+
284
+ def update(self, geometry: BatchGeometry) -> None:
285
+ """Accumulate radii of gyration for nonempty graphs."""
286
+ valid = geometry.node_counts > 0
287
+ radii = geometry.radii_of_gyration[valid]
288
+ if radii.numel() == 0:
289
+ return
290
+ bins = (
291
+ torch.bucketize(radii, self.edges.to(radii.device))
292
+ .sub_(1)
293
+ .clamp_(0, self.gen_hist.shape[0] - 1)
294
+ )
295
+ self.gen_hist.index_add_(
296
+ 0, bins, torch.ones_like(bins, dtype=self.gen_hist.dtype)
297
+ )
298
+
299
+ def compute(self) -> torch.Tensor:
300
+ """Return the L1 distance between normalized radius histograms."""
301
+ gen = self.gen_hist
302
+ tgt = self.target_hist
303
+ if gen.sum() <= 0 or tgt.sum() <= 0:
304
+ return torch.tensor(0.0, device=gen.device)
305
+ gen_n = gen / gen.sum().clamp(min=1.0)
306
+ tgt_n = tgt / tgt.sum().clamp(min=1.0)
307
+ return (gen_n - tgt_n).abs().sum()
308
+
309
+
310
+ def build_batch_metrics(distributions) -> dict[str, BatchMetric]:
311
+ """Construct the geometric batch-side metric set from training target stats.
312
+
313
+ Atom-count / atom-type / edge / charge KLs live in the distribution
314
+ collection (see ``init_metrics``), not here — this set is purely geometric.
315
+
316
+ Returns an empty dict if Distributions lacks either geometric target stat
317
+ (e.g. an older cache built before these were added). Both are checked
318
+ independently in case one is added without the other later.
319
+ """
320
+ if (
321
+ distributions.pairwise_distance_histogram is None
322
+ or distributions.radius_of_gyration_histogram is None
323
+ ):
324
+ return {}
325
+ return {
326
+ "min_dist_violation": MinDistanceViolation(
327
+ threshold=0.5, sync_on_compute=False
328
+ ),
329
+ "range_overflow": RangeOverflow(sync_on_compute=False),
330
+ "pair_dist_l1": PairwiseDistanceL1(
331
+ distributions.pairwise_distance_histogram, sync_on_compute=False
332
+ ),
333
+ "rog_l1": RoGL1(
334
+ distributions.radius_of_gyration_histogram, sync_on_compute=False
335
+ ),
336
+ }
geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/rdkit_utils.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RDKit molecule construction, conversion, and checks.
2
+
3
+ Upstream attribution is recorded in docs/development.md.
4
+ """
5
+
6
+ import threading
7
+
8
+ import numpy as np
9
+ from rdkit import Chem, RDLogger
10
+ from rdkit.Chem import AllChem
11
+ from rdkit.Chem.Descriptors import NumRadicalElectrons
12
+ from scipy.spatial.transform import Rotation
13
+
14
+ # Silence RDKit's noisy validity/kekulize warnings. These fire once per
15
+ # generated molecule during validation and, on a shared Lustre stdout,
16
+ # can block every Python thread for minutes -- including the TorchElastic
17
+ # rendezvous keep-alive thread, which then times out and kills the job.
18
+ RDLogger.DisableLog("rdApp.*")
19
+
20
+ ArrT = np.ndarray
21
+
22
+
23
+ class PeriodicTable:
24
+ """Thread-safe singleton wrapper for RDKit periodic-table queries."""
25
+
26
+ _instance = None
27
+
28
+ def __new__(cls):
29
+ """Return the shared periodic-table wrapper instance."""
30
+ if cls._instance is None:
31
+ cls._instance = super().__new__(cls)
32
+
33
+ return cls._instance
34
+
35
+ def __init__(self):
36
+ self._table = Chem.GetPeriodicTable()
37
+
38
+ # Just to be certain that vocab objects are thread safe
39
+ self._pt_lock = threading.Lock()
40
+
41
+ def atomic_from_symbol(self, symbol: str) -> int:
42
+ """Look up an atomic number under the periodic-table lock."""
43
+ with self._pt_lock:
44
+ symbol = symbol.upper() if len(symbol) == 1 else symbol
45
+ atomic = self._table.GetAtomicNumber(symbol)
46
+
47
+ return atomic
48
+
49
+ def symbol_from_atomic(self, atomic_num: int) -> str:
50
+ """Look up an element symbol under the periodic-table lock."""
51
+ with self._pt_lock:
52
+ token = self._table.GetElementSymbol(atomic_num)
53
+
54
+ return token
55
+
56
+ def valence(self, atom: str | int) -> int:
57
+ """Look up the default valence for an element symbol or atomic number."""
58
+ with self._pt_lock:
59
+ valence = self._table.GetDefaultValence(atom)
60
+
61
+ return valence
62
+
63
+
64
+ PT = PeriodicTable()
65
+
66
+ IDX_BOND_MAP = {
67
+ 0: "NO_BOND",
68
+ 1: Chem.BondType.SINGLE,
69
+ 2: Chem.BondType.DOUBLE,
70
+ 3: Chem.BondType.TRIPLE,
71
+ 4: Chem.BondType.AROMATIC,
72
+ }
73
+ BOND_IDX_MAP = {bond: idx for idx, bond in IDX_BOND_MAP.items()}
74
+
75
+ # IDX_CHARGE_MAP = {0: 0, 1: 1, 2: 2, 3: 3, 4: -1, 5: -2, 6: -3}
76
+ # CHARGE_IDX_MAP = {charge: idx for idx, charge in IDX_CHARGE_MAP.items()}
77
+
78
+
79
+ # TODO merge these with check functions in other files
80
+
81
+
82
+ def tensors_to_rdkit_mol(
83
+ atom_types: list[str],
84
+ coords: np.ndarray,
85
+ charge_types: list[int],
86
+ edge_types: list[Chem.BondType],
87
+ edge_index: list[tuple[int, int]],
88
+ sanitize: bool = True,
89
+ implicit_hydrogens: bool = False,
90
+ ):
91
+ mol = Chem.EditableMol(Chem.Mol())
92
+
93
+ # Add atoms with formal charges
94
+ for idx, atomic in enumerate(atom_types):
95
+ atom = Chem.Atom(atomic)
96
+ charge = charge_types[idx]
97
+ atom.SetFormalCharge(charge)
98
+ atom.SetNoImplicit(not implicit_hydrogens)
99
+ mol.AddAtom(atom)
100
+
101
+ mol = mol.GetMol()
102
+
103
+ # Add 3D coords
104
+ conf = Chem.Conformer(len(atom_types))
105
+
106
+ conf.SetPositions(np.ascontiguousarray(coords, dtype=np.float64))
107
+
108
+ mol.AddConformer(conf, assignId=True)
109
+
110
+ mol = Chem.EditableMol(mol)
111
+ for edge, edge_type in zip(edge_index, edge_types, strict=True):
112
+ start, end = edge
113
+ mol.AddBond(start, end, edge_type)
114
+
115
+ try:
116
+ mol = mol.GetMol()
117
+ # DetermineBonds(mol)
118
+ for atom in mol.GetAtoms():
119
+ atom.UpdatePropertyCache(strict=False)
120
+ except Exception:
121
+ return None
122
+
123
+ if sanitize:
124
+ return sanitize_mol_correctly(mol, implicit_hydrogens=implicit_hydrogens)
125
+
126
+ return mol
127
+
128
+
129
+ def sanitize_mol_correctly(mol: Chem.Mol, *, implicit_hydrogens: bool | None = False):
130
+ """Sanitize in place; None preserves each atom's existing hydrogen policy."""
131
+ for a in mol.GetAtoms():
132
+ if implicit_hydrogens is not None:
133
+ a.SetNoImplicit(not implicit_hydrogens)
134
+ if a.HasProp("_MolFileHCount"):
135
+ a.ClearProp("_MolFileHCount")
136
+
137
+ flags = Chem.SanitizeFlags.SANITIZE_ALL
138
+ if all(a.GetNoImplicit() for a in mol.GetAtoms()):
139
+ flags &= ~Chem.SanitizeFlags.SANITIZE_ADJUSTHS
140
+
141
+ # Explicit-H graphs must not acquire extra H; implicit-H graphs need ADJUSTHS.
142
+ err = Chem.SanitizeMol(
143
+ mol,
144
+ sanitizeOps=flags,
145
+ catchErrors=True,
146
+ )
147
+ if err: # nonzero bitmask means some step failed
148
+ return None
149
+ return mol
150
+
151
+
152
+ def mol_is_valid(
153
+ mol: Chem.Mol, allow_radical: bool = False, allow_charged: bool = False
154
+ ) -> bool:
155
+ # Validity is a predicate: do not let sanitization mutate the molecule that
156
+ # subsequent metrics, serialization, or dataset processing will consume.
157
+ if mol is None or mol.GetNumAtoms() == 0:
158
+ return False
159
+ sanitized_mol = sanitize_mol_correctly(Chem.Mol(mol), implicit_hydrogens=None)
160
+ return not (
161
+ sanitized_mol is None
162
+ or len(Chem.GetMolFrags(sanitized_mol)) > 1
163
+ or (not allow_radical and NumRadicalElectrons(sanitized_mol) != 0)
164
+ or (not allow_charged and Chem.GetFormalCharge(sanitized_mol) != 0)
165
+ )
166
+
167
+
168
+ def calc_energy(mol: Chem.Mol, per_atom: bool = False, level: str = "MMFF94") -> float:
169
+ """Calculate the energy for an RDKit molecule using the MMFF forcefield
170
+
171
+ Evaluate the first conformer on a copy, leaving the original unchanged.
172
+
173
+ Args:
174
+ mol (Chem.Mol): RDKit molecule
175
+ per_atom (bool): Whether to normalise by the number of atoms.
176
+ level: Force-field variant; currently only "MMFF94" is supported.
177
+
178
+ Returns:
179
+ float: Energy of the molecule or None if the energy could not be calculated
180
+ """
181
+
182
+ mol_copy = Chem.Mol(mol)
183
+
184
+ try:
185
+ if level == "MMFF94":
186
+ mmff_props = AllChem.MMFFGetMoleculeProperties(
187
+ mol_copy, mmffVariant="MMFF94"
188
+ )
189
+ ff = AllChem.MMFFGetMoleculeForceField(mol_copy, mmff_props, confId=0)
190
+ energy = ff.CalcEnergy()
191
+ energy = energy / mol.GetNumAtoms() if per_atom else energy
192
+ except Exception:
193
+ energy = None
194
+
195
+ return energy
196
+
197
+
198
+ def optimise_mol(
199
+ mol: Chem.rdchem.Mol, level: str = "MMFF94", max_iters: int = 1000
200
+ ) -> Chem.rdchem.Mol:
201
+ """Optimise the conformation of an RDKit molecule
202
+
203
+ Optimize the first conformer on a copy, leaving the original unchanged.
204
+
205
+ Args:
206
+ mol (Chem.Mol): RDKit molecule
207
+ max_iters (int): Maximum iterations for conformer optimization.
208
+ level: Force-field variant; currently only "MMFF94" is supported.
209
+
210
+ Returns:
211
+ Chem.Mol: Optimized copy, or None if the force-field call raises an error.
212
+ """
213
+
214
+ mol_copy = Chem.Mol(mol)
215
+ try:
216
+ if level == "MMFF94":
217
+ AllChem.MMFFOptimizeMolecule(mol_copy, maxIters=max_iters)
218
+ except Exception:
219
+ return None
220
+
221
+ return mol_copy
222
+
223
+
224
+ def conf_distance(
225
+ mol1: Chem.rdchem.Mol, mol2: Chem.rdchem.Mol, fix_order: bool = True
226
+ ) -> float:
227
+ """Approximately align two molecules and then calculate RMSD between them
228
+
229
+ Alignment and distance use the default conformer of each molecule.
230
+
231
+ Args:
232
+ mol1 (Chem.Mol): First molecule to align
233
+ mol2 (Chem.Mol): Second molecule to align
234
+ fix_order (bool): Whether to fix the atom order of the molecules
235
+
236
+ Returns:
237
+ float: RMSD between molecules after approximate alignment
238
+ """
239
+
240
+ assert len(mol1.GetAtoms()) == len(mol2.GetAtoms())
241
+
242
+ if not fix_order:
243
+ raise NotImplementedError
244
+
245
+ coords1 = np.array(mol1.GetConformer().GetPositions())
246
+ coords2 = np.array(mol2.GetConformer().GetPositions())
247
+
248
+ # Firstly, centre both molecules
249
+ coords1 = coords1 - (coords1.sum(axis=0) / coords1.shape[0])
250
+ coords2 = coords2 - (coords2.sum(axis=0) / coords2.shape[0])
251
+
252
+ if coords1.shape[0] == 1:
253
+ # catch the case where the molecule is a single atom
254
+ return 0.0
255
+
256
+ else:
257
+ # Find the best rotation alignment between the centred mols
258
+ rotation, _ = Rotation.align_vectors(coords1, coords2)
259
+ aligned_coords2 = rotation.apply(coords2)
260
+
261
+ sqrd_dists = (coords1 - aligned_coords2) ** 2
262
+ rmsd = np.sqrt(sqrd_dists.sum(axis=1).mean())
263
+ return rmsd
264
+
265
+
266
+ def smiles_from_mol(
267
+ mol: Chem.rdchem.Mol, canonical: bool = True, explicit_hs: bool = False
268
+ ) -> str | None:
269
+ """Create a SMILES string from a molecule
270
+
271
+ Args:
272
+ mol (Chem.Mol): RDKit molecule object
273
+ canonical (bool): Whether to create a canonical SMILES, default True
274
+ explicit_hs (bool): Add explicit hydrogen atoms before creating the SMILES.
275
+ If False, remove hydrogen atoms. Explicit and implicit hydrogen
276
+ representations can produce different strings.
277
+
278
+ Returns:
279
+ str: SMILES string which could be None if the SMILES generation failed
280
+ """
281
+
282
+ if mol is None:
283
+ return None
284
+
285
+ try:
286
+ mol = Chem.AddHs(mol) if explicit_hs else Chem.RemoveHs(mol)
287
+ smiles = Chem.MolToSmiles(mol, canonical=canonical)
288
+ except Exception:
289
+ smiles = None
290
+
291
+ return smiles
geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/run_config.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime settings, Hydra/Lightning configuration, callbacks, and run IDs."""
2
+
3
+ import faulthandler
4
+ import os
5
+ import sys
6
+
7
+ import hydra
8
+ import pytorch_lightning as pl
9
+ import torch
10
+ import torch.distributed as dist
11
+ import wandb
12
+ from omegaconf import DictConfig
13
+ from pytorch_lightning import Callback
14
+ from pytorch_lightning.callbacks import (
15
+ EarlyStopping,
16
+ ModelCheckpoint,
17
+ )
18
+ from pytorch_lightning.loggers import WandbLogger
19
+ from rdkit import RDLogger
20
+
21
+
22
+ def configure_runtime(*, seed: int | None = None, diagnostics: bool = False) -> None:
23
+ """Set arithmetic, chemistry logging, and optional reproducibility diagnostics."""
24
+ torch.set_float32_matmul_precision("medium")
25
+ RDLogger.DisableLog("rdApp.*")
26
+ if seed is not None:
27
+ pl.seed_everything(seed)
28
+ if diagnostics and os.environ.get("MORPH_STALL_DUMP"):
29
+ faulthandler.dump_traceback_later(
30
+ int(os.environ["MORPH_STALL_DUMP"]), repeat=True, file=sys.stderr
31
+ )
32
+ faulthandler.enable(file=sys.stderr, all_threads=True)
33
+ if diagnostics and os.environ.get("MORPH_FORCE_MATH_SDP"):
34
+ torch.backends.cuda.enable_flash_sdp(False)
35
+ torch.backends.cuda.enable_mem_efficient_sdp(False)
36
+ torch.backends.cuda.enable_math_sdp(True)
37
+
38
+
39
+ class ManualAccumulationWandbLogger(WandbLogger):
40
+ """Report manual-optimization updates on W&B's standard step axis."""
41
+
42
+ def __init__(self, *args, accumulation_steps: int = 1, **kwargs):
43
+ if accumulation_steps <= 0:
44
+ raise ValueError("accumulation_steps must be positive")
45
+ self._accumulation_steps = accumulation_steps
46
+ super().__init__(*args, **kwargs)
47
+
48
+ def log_metrics(self, metrics, step=None):
49
+ """Log metrics against completed updates rather than microbatches."""
50
+ # Lightning's logger counter advances per batch when its Trainer-side
51
+ # accumulation is one. Manual optimization accumulates inside the
52
+ # module, so convert that counter back to completed model updates.
53
+ if step is not None:
54
+ step //= self._accumulation_steps
55
+ return super().log_metrics(metrics, step=step)
56
+
57
+
58
+ def validation_batches_per_rank(
59
+ num_samples: int, batch_size: int, world_size: int
60
+ ) -> int:
61
+ """Return the per-rank batch limit for an exact global sample count."""
62
+ if num_samples <= 0 or batch_size <= 0 or world_size <= 0:
63
+ raise ValueError(
64
+ "validation sample count, batch size and world size must be positive"
65
+ )
66
+ samples_per_distributed_batch = batch_size * world_size
67
+ if num_samples % samples_per_distributed_batch:
68
+ raise ValueError(
69
+ f"num_val_samples ({num_samples}) must be divisible by val batch size "
70
+ f"({batch_size}) * world size ({world_size})"
71
+ )
72
+ return num_samples // samples_per_distributed_batch
73
+
74
+
75
+ def configure_manual_gradient_accumulation(module, trainer_kwargs: dict) -> None:
76
+ """Move Lightning's accumulation setting onto a manual-optimization module.
77
+
78
+ Lightning rejects ``Trainer(accumulate_grad_batches != 1)`` for manual
79
+ optimization. The optimizer mixin implements the equivalent accumulation,
80
+ while the Trainer must therefore retain its neutral value of one.
81
+ """
82
+ steps = int(trainer_kwargs.get("accumulate_grad_batches", 1))
83
+ if steps <= 0:
84
+ raise ValueError("accumulate_grad_batches must be positive")
85
+ if module.automatic_optimization:
86
+ return
87
+ module.manual_gradient_accumulation_steps = steps
88
+ trainer_kwargs["accumulate_grad_batches"] = 1
89
+
90
+
91
+ def gather_rank_outputs(outputs: list) -> list | None:
92
+ """Gather picklable rank outputs on rank zero without replicating them."""
93
+ if not (dist.is_available() and dist.is_initialized()):
94
+ return outputs
95
+
96
+ is_global_zero = dist.get_rank() == 0
97
+ gathered = [None] * dist.get_world_size() if is_global_zero else None
98
+ dist.gather_object(outputs, gathered, dst=0)
99
+ if not is_global_zero:
100
+ return None
101
+ return [output for rank_outputs in gathered for output in rank_outputs]
102
+
103
+
104
+ def build_callbacks(cfg: DictConfig) -> list[Callback]:
105
+ callbacks: list[Callback] = []
106
+
107
+ if "early_stopping" in cfg.callbacks:
108
+ hydra.utils.log.info("Adding callback <EarlyStopping>")
109
+ es_cfg = cfg.callbacks.early_stopping
110
+ # stopping_threshold (optional): halt the moment the monitored metric
111
+ # crosses this value (>= for mode=max), independent of the plateau path.
112
+ # Used to stop a run once validity reaches a target (e.g. 0.8).
113
+ callbacks.append(
114
+ EarlyStopping(
115
+ monitor=cfg.callbacks.monitor_metric,
116
+ mode=cfg.callbacks.monitor_metric_mode,
117
+ patience=es_cfg.patience,
118
+ verbose=es_cfg.verbose,
119
+ stopping_threshold=es_cfg.get("stopping_threshold", None),
120
+ )
121
+ )
122
+
123
+ if "model_checkpoints" in cfg.callbacks:
124
+ hydra.utils.log.info("Adding callback <ModelCheckpoint>")
125
+ callbacks.append(
126
+ ModelCheckpoint(
127
+ monitor=cfg.callbacks.monitor_metric,
128
+ mode=cfg.callbacks.monitor_metric_mode,
129
+ every_n_epochs=cfg.callbacks.model_checkpoints.get("every_n_epochs", 1),
130
+ save_top_k=cfg.callbacks.model_checkpoints.save_top_k,
131
+ verbose=cfg.callbacks.model_checkpoints.verbose,
132
+ save_last=cfg.callbacks.model_checkpoints.save_last,
133
+ )
134
+ )
135
+
136
+ if "every_n_epochs_checkpoint" in cfg.callbacks:
137
+ cb_cfg = cfg.callbacks.every_n_epochs_checkpoint
138
+ hydra.utils.log.info(
139
+ "Adding callback <ModelCheckpoint> for every "
140
+ f"{cb_cfg.every_n_epochs} epochs"
141
+ )
142
+ # Keep best-by-validation separate from latest-for-resume. Lightning only saves
143
+ # train-epoch checkpoints when save_on_train_epoch_end=True if validation is not
144
+ # run every epoch.
145
+ callbacks.append(
146
+ ModelCheckpoint(
147
+ dirpath="every_n_epochs",
148
+ every_n_epochs=cb_cfg.every_n_epochs,
149
+ save_top_k=cb_cfg.save_top_k,
150
+ verbose=cb_cfg.verbose,
151
+ save_last=cb_cfg.save_last,
152
+ save_on_train_epoch_end=cb_cfg.get("save_on_train_epoch_end", None),
153
+ )
154
+ )
155
+
156
+ # Task-specific callbacks are built generically: any ``callbacks.<name>`` that
157
+ # carries a ``_target_`` is instantiated as-is. This module therefore imports
158
+ # none of them, which matters because the per-task eval callbacks ship with
159
+ # their own recipe (edit / multimode) and may not be installed at
160
+ # all -- a hard import here would make this core module depend on them.
161
+ # Defaults live on the callback classes, so the config need only override.
162
+ for entry in (cfg.callbacks or {}).values():
163
+ if not isinstance(entry, DictConfig) or "_target_" not in entry:
164
+ continue # the explicitly-handled callbacks above
165
+ hydra.utils.log.info(f"Adding callback <{entry._target_.rsplit('.', 1)[-1]}>")
166
+ callbacks.append(hydra.utils.instantiate(entry, _convert_="all"))
167
+
168
+ return callbacks
169
+
170
+
171
+ def bootstrap_run_id() -> None:
172
+ """Set the shared run IDs before Hydra composes ``hydra.run.dir``.
173
+
174
+ A single process generates a W&B-style ID. Multi-process ``torchrun`` must
175
+ receive the ID in its environment so every rank shares one output directory.
176
+ """
177
+ if os.environ.get("MORPH_RUN_ID"):
178
+ os.environ.setdefault("WANDB_RUN_ID", os.environ["MORPH_RUN_ID"])
179
+ return
180
+
181
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
182
+ if world_size > 1:
183
+ raise RuntimeError(
184
+ "Multi-process runs require MORPH_RUN_ID in the environment (use the same "
185
+ "value as WANDB_RUN_ID). Example:\n"
186
+ " export MORPH_RUN_ID=<shared-id>\n"
187
+ ' export WANDB_RUN_ID="$MORPH_RUN_ID"'
188
+ )
189
+
190
+ rid = wandb.util.generate_id()
191
+ os.environ["MORPH_RUN_ID"] = rid
192
+ os.environ["WANDB_RUN_ID"] = rid
geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/vocab_utils.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Token vocabulary helpers and loss-weight computation."""
2
+
3
+ from copy import deepcopy
4
+
5
+ import torch
6
+ from rdkit import Chem
7
+
8
+
9
+ def z_to_atom_types(z):
10
+ """Convert the atomic numbers to atom symbols with rdkit."""
11
+
12
+ table = Chem.GetPeriodicTable()
13
+ return [table.GetElementSymbol(z_i) for z_i in z]
14
+
15
+
16
+ def token_to_index(token_list, token: str):
17
+ return token_list.index(token)
18
+
19
+
20
+ def index_to_token(token_list, index: int):
21
+ return token_list[index]
22
+
23
+
24
+ def compute_token_weights(
25
+ token_list: list[str],
26
+ distribution: torch.Tensor,
27
+ special_token_names: list[str],
28
+ weight_alpha: float = 1.0,
29
+ type_loss_token_weights: str = "uniform",
30
+ ) -> torch.Tensor:
31
+ """
32
+ Compute token weights with special handling for special tokens.
33
+
34
+ This function can be used for both node tokens (atom types) and edge tokens.
35
+ It computes inverse frequency weights, normalizes regular tokens, and assigns
36
+ appropriate weights to special tokens.
37
+
38
+ Args:
39
+ token_list: List of all tokens (e.g., atom types or edge types)
40
+ distribution: Distribution of token types from training data.
41
+ MUST have the same length as token_list and be aligned by index.
42
+ special_token_names: List of special token names to handle specially
43
+ (e.g., ["<NO_BOND>"] for edges)
44
+ weight_alpha: Alpha parameter for weight scaling (default: 1.0)
45
+ type_loss_token_weights: "uniform" or "training" - if "uniform", returns
46
+ uniform weights (all 1.0)
47
+
48
+ Returns:
49
+ Weights tensor with same shape as distribution
50
+ """
51
+ # Validate inputs
52
+ if len(distribution) != len(token_list):
53
+ raise ValueError(
54
+ f"Distribution length ({len(distribution)}) must match "
55
+ f"token_list length ({len(token_list)})"
56
+ )
57
+
58
+ # Get indices for special tokens (with validation)
59
+ special_token_indices = set()
60
+ for token_name in special_token_names:
61
+ if token_name not in token_list:
62
+ print(f"Warning: Special token '{token_name}' not in token_list, skipping")
63
+ continue
64
+ idx = token_to_index(token_list, token_name)
65
+ special_token_indices.add(idx)
66
+
67
+ # --- Handle uniform weights case early ---
68
+ if type_loss_token_weights == "uniform":
69
+ return torch.ones_like(distribution)
70
+
71
+ # --- 1. Initial Weight Calculation (Inverse Frequency) ---
72
+ epsilon = 1e-8
73
+ weights = 1.0 / (distribution + epsilon)
74
+ weights = weights**weight_alpha # Apply alpha scaling
75
+
76
+ # --- 2. Isolate & Normalize REGULAR Tokens ---
77
+ all_indices = set(range(len(token_list)))
78
+ regular_token_indices = list(all_indices - special_token_indices)
79
+
80
+ if not regular_token_indices:
81
+ return torch.ones_like(weights)
82
+
83
+ # Get weights for only the regular tokens
84
+ regular_weights = weights[regular_token_indices]
85
+
86
+ # Normalize *only* the regular weights to have a mean of 1.0
87
+ mean_regular_weight = regular_weights.mean()
88
+ if mean_regular_weight > 0:
89
+ regular_weights = regular_weights / mean_regular_weight
90
+
91
+ # --- 3. Build Final Weights Tensor ---
92
+ # Start with the full weights (including special tokens computed from distribution)
93
+ final_weights = weights.clone()
94
+
95
+ # Assign normalized regular weights to their correct positions
96
+ final_weights[regular_token_indices] = regular_weights
97
+
98
+ # Handle special tokens: use their actual inverse frequency weight,
99
+ # normalized the same way as regular tokens
100
+ for token_name in special_token_names:
101
+ if token_name not in token_list:
102
+ continue
103
+ token_idx = token_to_index(token_list, token_name)
104
+ special_weight = weights[token_idx]
105
+ if mean_regular_weight > 0:
106
+ special_weight = special_weight / mean_regular_weight
107
+ final_weights[token_idx] = special_weight
108
+
109
+ # set the minimum weight to 1.0
110
+ # final_weights = final_weights / final_weights.min()
111
+
112
+ return final_weights
113
+
114
+
115
+ def init_uniform_prior(distributions):
116
+ """Return a new Distributions with uniform atom, edge, and charge type
117
+ distributions.
118
+
119
+ All other fields (n_atoms_distribution, coordinate_std) are copied from the
120
+ original. The original object is not modified.
121
+ """
122
+ prior = deepcopy(distributions)
123
+ for attr in (
124
+ "atom_type_distribution",
125
+ "edge_type_distribution",
126
+ "charge_type_distribution",
127
+ ):
128
+ dist = torch.ones_like(getattr(prior, attr))
129
+ setattr(prior, attr, dist / dist.sum())
130
+ return prior
131
+
132
+
133
+ _PRIOR_MODES = ("uniform", "marginal", "absorbing")
134
+
135
+
136
+ def build_token_prior(
137
+ distributions,
138
+ *,
139
+ edge_mode: str = "uniform",
140
+ atom_mode: str = "uniform",
141
+ charge_mode: str = "uniform",
142
+ ):
143
+ """Return a new Distributions with per-token prior modes.
144
+
145
+ Generalises :func:`init_uniform_prior` (``all modes == "uniform"`` reproduces
146
+ it exactly). The prior is the flow-matching *source*: it seeds
147
+ ``sample_prior_graph`` and the interpolator, so changing it changes the whole
148
+ transport path the denoiser is trained on.
149
+
150
+ Modes per token channel:
151
+ * ``uniform`` -- maximum entropy (the historical default; for edges this
152
+ is why the prior graph starts massively over-bonded).
153
+ * ``marginal`` -- the data marginal already on ``distributions`` (DiGress-
154
+ style; the source is close to the data, so far fewer edits).
155
+ * ``absorbing`` -- one-hot on token index 0 (``<NO_BOND>`` for edges): an
156
+ empty-graph / masked source with zero deletions to make.
157
+ """
158
+ prior = deepcopy(distributions)
159
+ for attr, mode in (
160
+ ("atom_type_distribution", atom_mode),
161
+ ("edge_type_distribution", edge_mode),
162
+ ("charge_type_distribution", charge_mode),
163
+ ):
164
+ cur = getattr(prior, attr)
165
+ if mode == "uniform":
166
+ dist = torch.ones_like(cur)
167
+ elif mode == "marginal":
168
+ dist = cur.clamp_min(0).clone() # keep the data marginal already present
169
+ elif mode == "absorbing":
170
+ dist = torch.zeros_like(cur)
171
+ dist[0] = 1.0
172
+ else:
173
+ raise ValueError(
174
+ f"prior mode for {attr} must be one of {_PRIOR_MODES}, got {mode!r}"
175
+ )
176
+ setattr(prior, attr, dist / dist.sum())
177
+ return prior
geom/scaffold_murcko_substituent_lora_r16/runtime/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
geom/scaffold_murcko_substituent_lora_r16/training_config.yaml ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ representation: molecule
2
+ seed: 42
3
+ heads:
4
+ heads:
5
+ _target_: morph.model.layers.heads.MultiHeadModule
6
+ output_init_std: ${model.head_initialization.output_std}
7
+ independent_adapters: ${oc.select:model.head_independent_adapters,false}
8
+ heads_configs:
9
+ graph_aggregation: mean
10
+ node_heads:
11
+ atom_type_head:
12
+ input_dim: ${model.head_node_dim}
13
+ output_dim: ${len:${data.vocab.atom_tokens}}
14
+ adapter_dim: ${model.head_node_adapter_dim}
15
+ group: state
16
+ charge_head:
17
+ input_dim: ${model.head_node_dim}
18
+ output_dim: ${len:${data.vocab.charge_tokens}}
19
+ adapter_dim: ${model.head_node_adapter_dim}
20
+ group: state
21
+ ins_rate_head:
22
+ input_dim: ${model.head_node_dim}
23
+ output_dim: 1
24
+ adapter_dim: ${model.head_node_adapter_dim}
25
+ group: edit
26
+ initial_rate: ${model.head_initialization.ins_rate}
27
+ do_del_head:
28
+ input_dim: ${model.head_node_dim}
29
+ output_dim: 1
30
+ adapter_dim: ${model.head_node_adapter_dim}
31
+ group: edit
32
+ initial_probability: ${model.head_initialization.do_del_probability}
33
+ do_sub_a_head:
34
+ input_dim: ${model.head_node_dim}
35
+ output_dim: 1
36
+ adapter_dim: ${model.head_node_adapter_dim}
37
+ group: edit
38
+ initial_probability: ${model.head_initialization.do_sub_a_probability}
39
+ edge_heads:
40
+ edge_type_head:
41
+ input_dim: ${model.head_edge_dim}
42
+ output_dim: ${len:${data.vocab.edge_tokens}}
43
+ adapter_dim: ${model.head_edge_adapter_dim}
44
+ group: state
45
+ do_sub_e_head:
46
+ input_dim: ${model.head_edge_dim}
47
+ output_dim: 1
48
+ adapter_dim: ${model.head_edge_adapter_dim}
49
+ group: state
50
+ initial_probability: ${model.head_initialization.do_sub_e_probability}
51
+ ins_edge_head:
52
+ _target_: morph.model.layers.heads.InsertionEdgeHead
53
+ _recursive_: false
54
+ hidden_dim: ${model.head_node_dim}
55
+ readout_dim: ${model.head_insertion_edge_dim}
56
+ output_init_std: ${model.head_initialization.output_std}
57
+ n_edge_types: ${len:${data.vocab.edge_tokens}}
58
+ n_atom_types: ${len:${data.vocab.atom_tokens}}
59
+ n_charge_types: ${len:${data.vocab.charge_tokens}}
60
+ rbf_embedding_args:
61
+ _target_: morph.model.layers.embedding.RBFEmbedding
62
+ num_rbf: 128
63
+ rbf_dmax: 3.5
64
+ out_dim: 64
65
+ dropout: 0.0
66
+ trainable: false
67
+ insertion_head:
68
+ mode: unimodal
69
+ dimension: 3
70
+ num_components: 1
71
+ model:
72
+ _target_: morph.model.layers.heads.UnimodalInsertionHead
73
+ hidden_dim: ${model.head_node_dim}
74
+ readout_dim: ${model.head_insertion_adapter_dim}
75
+ output_init_std: ${model.head_initialization.output_std}
76
+ independent_adapters: ${oc.select:model.head_independent_adapters,false}
77
+ N_a: ${len:${data.vocab.atom_tokens}}
78
+ N_c: ${len:${data.vocab.charge_tokens}}
79
+ model:
80
+ module:
81
+ optimizer_config:
82
+ implementation: torch
83
+ optimizer:
84
+ execution: ${oc.env:MORPH_MUON_EXECUTION,native}
85
+ lr: 0.001
86
+ momentum: 0.95
87
+ weight_decay: 0.0
88
+ nesterov: true
89
+ ns_steps: 5
90
+ eps: 1.0e-07
91
+ adjust_lr_fn: original
92
+ aux_optimizer:
93
+ lr: 0.0001
94
+ betas:
95
+ - 0.9
96
+ - 0.95
97
+ eps: 1.0e-10
98
+ weight_decay: 0.0
99
+ gradient_clip_val: 1.0
100
+ gradient_clip_algorithm: norm
101
+ scheduler:
102
+ _target_: morph.utils.lr_schedulers.MultiGroupCosineWarmupLR
103
+ warmup_steps: 2000
104
+ total_steps: 200000
105
+ min_lr_fraction: 0.05
106
+ last_epoch: -1
107
+ interval: step
108
+ monitor: null
109
+ loss_weights:
110
+ do_sub_a: 0.2
111
+ sub_a_class: 0.03
112
+ do_sub_e: 0.25
113
+ sub_e_class: 0.5
114
+ do_del: 0.4
115
+ ins_rate: 0.15
116
+ ins_state: 0.01
117
+ ins_e: 0.4
118
+ x: 2.0
119
+ c: 0.5
120
+ ins_e_ii: 0.03
121
+ _target_: morph.model.finetune.scaffold.LightningModuleRatesScaffoldFinetune
122
+ insertion_head_mode: ${insertion_head.mode}
123
+ ins_noise_scale: ${data.ins_noise_scale}
124
+ ema_decay: 0.999
125
+ ema_warmup_steps: 5000
126
+ use_ema_for_eval: true
127
+ time_dist: ${if:${eq:${model.sample_time_strategy}, log}, ${model.beta_sched},
128
+ ${model.linear_sched}}
129
+ integrator: ${model.integrator}
130
+ vocab: ${data.vocab}
131
+ use_learnable_loss_weights: false
132
+ use_time_weights: false
133
+ model:
134
+ _target_: morph.model.finetune.scaffold.ScaffoldConditionalDiTBackboneWithHeads
135
+ _recursive_: false
136
+ atom_type_embedding_args: ${embeddings.atom_type_embedding}
137
+ edge_type_embedding_args: ${embeddings.edge_type_embedding}
138
+ time_embedding_args: ${embeddings.time_embedding}
139
+ node_count_embedding_args: ${embeddings.node_count_embedding}
140
+ incident_bond_embedding: false
141
+ backbone_model_args: ${model.backbone}
142
+ heads_args: ${heads.heads}
143
+ insertion_head_args: ${insertion_head.model}
144
+ ins_edge_head_args: ${heads.ins_edge_head}
145
+ xattn_encoder_args: ${model.xattn_encoder}
146
+ use_distance_bias: true
147
+ pretrained_ckpt: /cluster/project/krause/frankem/morph-chem/outputs/hf_geom_qm9_20261009/stage/geom/explicit_h/epoch_100.ckpt
148
+ pretrained_prefer_ema: true
149
+ freeze_pretrained_backbone: true
150
+ freeze_output_heads: false
151
+ exclude_scaffold_from_sub_loss: true
152
+ exclude_scaffold_from_x_loss: false
153
+ freeze_keep_positions: false
154
+ freeze_keep_charges: true
155
+ rotate: true
156
+ n_augmentations: 1
157
+ integrator: ${integrator.integrator}
158
+ beta_sched:
159
+ _target_: torch.distributions.Beta
160
+ concentration1: 2.0
161
+ concentration0: 1.0
162
+ linear_sched:
163
+ _target_: torch.distributions.Uniform
164
+ low: 0.0
165
+ high: 1.0
166
+ sample_time_strategy: linear
167
+ head_node_dim: ${model.backbone.out_node_nf}
168
+ head_edge_dim: ${model.backbone.out_edge_nf}
169
+ head_node_adapter_dim: 256
170
+ head_edge_adapter_dim: 256
171
+ head_insertion_adapter_dim: 256
172
+ head_insertion_edge_dim: 256
173
+ head_independent_adapters: true
174
+ head_initialization:
175
+ output_std: 0.001
176
+ coordinate_output_std: 0.0
177
+ do_del_probability: 0.5
178
+ do_sub_a_probability: 0.5
179
+ do_sub_e_probability: 0.5
180
+ ins_rate: 0.6931471805599453
181
+ token_weighting:
182
+ type_loss_token_weights: uniform
183
+ weight_alpha: 0.4
184
+ lora:
185
+ enabled: true
186
+ rank: 16
187
+ alpha: 16.0
188
+ dropout: 0.0
189
+ targets:
190
+ - qkv
191
+ - proj
192
+ - mlp.gate_value
193
+ - mlp.out_proj
194
+ freeze_base: true
195
+ backbone:
196
+ _target_: morph.model.backbones.conditional_dit.ConditionalDiTBackbone
197
+ _recursive_: false
198
+ d_input: ${embeddings.atom_type_embedding.out_dim}
199
+ d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"}
200
+ d_model: 512
201
+ out_node_nf: 256
202
+ out_edge_nf: 256
203
+ num_layers: 8
204
+ nhead: 16
205
+ mlp_ratio: 4.0
206
+ ffn_hidden_dim: null
207
+ ffn_multiple_of: 64
208
+ qk_norm: true
209
+ coordinate_output_init_std: ${model.head_initialization.coordinate_output_std}
210
+ center_coordinate_output: true
211
+ in_edge_nf: ${embeddings.edge_type_embedding.out_dim}
212
+ edge_pair_dim: 256
213
+ pair_bias_hidden_dim: 32
214
+ num_atom_types: ${len:${data.vocab.atom_tokens}}
215
+ lora: ${model.lora}
216
+ rbf_embedding_args:
217
+ _target_: morph.model.layers.embedding.RBFEmbedding
218
+ num_rbf: 128
219
+ rbf_dmax: 3.5
220
+ out_dim: 64
221
+ dropout: 0.0
222
+ trainable: false
223
+ ctx_dim: 128
224
+ use_cross_attn: true
225
+ use_node_cond: true
226
+ use_edge_cond: true
227
+ xattn_encoder:
228
+ _target_: morph.model.finetune.base.LightweightContextEncoder
229
+ num_atom_types: ${len:${data.vocab.atom_tokens}}
230
+ num_charges: ${len:${data.vocab.charge_tokens}}
231
+ atom_embed_dim: 64
232
+ charge_embed_dim: 16
233
+ pos_embed_dim: 32
234
+ ctx_dim: ${model.backbone.ctx_dim}
235
+ num_attn_layers: 4
236
+ nhead: 8
237
+ mlp_ratio: 4.0
238
+ dropout: 0.0
239
+ use_pair_bias: true
240
+ num_edge_types: ${len:${data.vocab.edge_tokens}}
241
+ edge_embed_dim: 32
242
+ num_rbf: 32
243
+ rbf_dmax: 5.0
244
+ pair_bias_hidden_dim: 32
245
+ schedule:
246
+ _target_: morph.flow_matching.schedules.SmoothstepSchedule
247
+ shift: 0.8
248
+ trainer:
249
+ trainer:
250
+ deterministic: warn
251
+ devices: 2
252
+ accelerator: gpu
253
+ strategy: ddp_find_unused_parameters_false
254
+ precision: bf16-mixed
255
+ max_epochs: 50
256
+ accumulate_grad_batches: 1
257
+ num_sanity_val_steps: 0
258
+ gradient_clip_val: null
259
+ gradient_clip_algorithm: norm
260
+ check_val_every_n_epoch: 1
261
+ num_val_samples: 4096
262
+ limit_val_batches: null
263
+ limit_predict_batches: 4
264
+ enable_progress_bar: false
265
+ max_time:
266
+ hours: 0
267
+ seconds: 169927
268
+ do_train: true
269
+ do_validate: false
270
+ do_predict: false
271
+ checkpoint_path: null
272
+ final_checkpoint_path: final.ckpt
273
+ callbacks:
274
+ monitor_metric: ${if:${oc.eval:"'${representation}' in ('geometric_graph', 'molecule')"},val/validity,val/batch/pair_dist_l1}
275
+ monitor_metric_mode: ${if:${oc.eval:"'${representation}' in ('geometric_graph',
276
+ 'molecule')"},max,min}
277
+ model_checkpoints:
278
+ save_top_k: 1
279
+ save_last: false
280
+ verbose: false
281
+ every_n_epochs: 5
282
+ every_n_epochs_checkpoint:
283
+ every_n_epochs: 5
284
+ save_top_k: -1
285
+ save_last: link
286
+ verbose: false
287
+ save_on_train_epoch_end: true
288
+ metric_audit:
289
+ _target_: run_audit.ValidationAudit
290
+ expected_samples: 4096
291
+ expected_scaffolds: 512
292
+ logging:
293
+ enabled: true
294
+ project: morph-geom-scaffold-decoration
295
+ name: ${oc.env:MORPH_RUN_ID,base}
296
+ save_dir: .
297
+ offline: false
298
+ group: geom-e100-murcko-original-50ep-20261010
299
+ tags:
300
+ - substituent-insertion
301
+ - rank-16
302
+ - scaffold-decoration
303
+ - bemis-murcko
304
+ - geom-e100
305
+ - lora
306
+ - trainable-heads
307
+ - movable-scaffold
308
+ - jitter-0.1A
309
+ - scaffold-metrics
310
+ integrator:
311
+ integrator:
312
+ _target_: morph.flow_matching.integration.RateIntegrator
313
+ vocab: ${data.vocab}
314
+ distributions: None
315
+ insertion_head_mode: ${insertion_head.mode}
316
+ insertion_dimension: ${insertion_head.dimension}
317
+ insertion_num_components: ${insertion_head.num_components}
318
+ time_strategy: log
319
+ time_beta_alpha: 2.0
320
+ time_beta_beta: 1.0
321
+ num_integration_steps: 100
322
+ ins_noise_scale: ${data.ins_noise_scale}
323
+ renoise_insertions: true
324
+ exact_schedule_integration: true
325
+ del_schedule: ${data.interpolator.del_schedule}
326
+ ins_schedule: ${data.interpolator.ins_schedule}
327
+ sub_schedule: ${data.interpolator.sub_schedule}
328
+ sub_e_schedule: ${data.interpolator.sub_e_schedule}
329
+ data:
330
+ root_path: ${oc.env:PROJECT_ROOT}/data
331
+ preprocess_workers: 10
332
+ preprocess_chunk_size: 128
333
+ statistics_workers: ${data.preprocess_workers}
334
+ hydrogen_mode: explicit
335
+ cache_name: ${data.dataset_name}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''}
336
+ source_dir: ${oc.env:GEOM_SOURCE_DIR,null}
337
+ max_atoms: 72
338
+ allowed_atom_symbols:
339
+ - H
340
+ - C
341
+ - 'N'
342
+ - O
343
+ - S
344
+ - F
345
+ - Cl
346
+ - Br
347
+ - P
348
+ - I
349
+ n_atoms_strategy: uniform
350
+ uniform_n_atoms_padding: 5
351
+ optimal_transport: equivariant
352
+ ins_noise_scale: 0.65
353
+ move_noise_scale: 0.5
354
+ allow_charged: true
355
+ preprocessing:
356
+ _target_: morph.dataset.core.preprocessing.Preprocessing
357
+ _recursive_: false
358
+ root: /cluster/project/krause/frankem/morph/data/geom
359
+ num_workers: ${data.statistics_workers}
360
+ train_dataset: ${data.datamodule.datasets.train}
361
+ atom_tokens_path: ${data.preprocessing.root}/processed/atom_tokens.txt
362
+ edge_tokens_path: ${data.preprocessing.root}/processed/edge_tokens.txt
363
+ charge_tokens_path: ${data.preprocessing.root}/processed/charge_tokens.txt
364
+ distributions_path: ${data.preprocessing.root}/processed/distributions.pt
365
+ vocab: null
366
+ interpolator:
367
+ _target_: morph.flow_matching.interpolation.Interpolator
368
+ distributions: null
369
+ optimal_transport: ${data.optimal_transport}
370
+ ins_noise_scale: ${data.ins_noise_scale}
371
+ move_noise_scale: ${data.move_noise_scale}
372
+ atom_type_noise_prob: 0.0
373
+ pre_align_iters: 2
374
+ c_move: 1.0
375
+ c_sub: 0.0
376
+ c_ins: 6.125
377
+ c_del: 6.125
378
+ del_schedule: ${schedule}
379
+ ins_schedule: ${schedule}
380
+ sub_schedule:
381
+ _target_: morph.flow_matching.schedules.SmoothstepSchedule
382
+ shift: 1.5
383
+ sub_e_schedule:
384
+ _target_: morph.flow_matching.schedules.SmoothstepSchedule
385
+ shift: 1.5
386
+ jitter_scale: ${data.jitter_scale}
387
+ insertion_assignment: ${data.insertion_assignment}
388
+ datamodule:
389
+ _target_: morph.dataset.conditional.scaffold.datamodule.ScaffoldLightningDataModule
390
+ vocab: ${data.vocab}
391
+ distributions: null
392
+ representation: ${representation}
393
+ datasets:
394
+ train:
395
+ _target_: ${data.dataset_target}
396
+ root: ${data.preprocessing.root}
397
+ preprocess_workers: ${data.preprocess_workers}
398
+ preprocess_chunk_size: ${data.preprocess_chunk_size}
399
+ hydrogen_mode: ${data.hydrogen_mode}
400
+ source_dir: ${data.source_dir}
401
+ max_atoms: ${data.max_atoms}
402
+ allowed_atom_symbols: ${data.allowed_atom_symbols}
403
+ vocab: ${data.vocab}
404
+ distributions: null
405
+ val:
406
+ - _target_: ${data.dataset_target}
407
+ root: ${data.preprocessing.root}
408
+ preprocess_workers: ${data.preprocess_workers}
409
+ preprocess_chunk_size: ${data.preprocess_chunk_size}
410
+ hydrogen_mode: ${data.hydrogen_mode}
411
+ source_dir: ${data.source_dir}
412
+ max_atoms: ${data.max_atoms}
413
+ allowed_atom_symbols: ${data.allowed_atom_symbols}
414
+ vocab: ${data.vocab}
415
+ distributions: null
416
+ test:
417
+ - _target_: ${data.dataset_target}
418
+ root: ${data.preprocessing.root}
419
+ preprocess_workers: ${data.preprocess_workers}
420
+ preprocess_chunk_size: ${data.preprocess_chunk_size}
421
+ hydrogen_mode: ${data.hydrogen_mode}
422
+ source_dir: ${data.source_dir}
423
+ max_atoms: ${data.max_atoms}
424
+ allowed_atom_symbols: ${data.allowed_atom_symbols}
425
+ vocab: ${data.vocab}
426
+ distributions: null
427
+ interpolator: ${data.interpolator}
428
+ rotate: ${model.rotate}
429
+ n_augmentations: ${model.n_augmentations}
430
+ num_workers:
431
+ train: 20
432
+ val: 0
433
+ test: 0
434
+ batch_size:
435
+ train: 320
436
+ val: 32
437
+ test: 32
438
+ n_atoms_strategy: ${data.n_atoms_strategy}
439
+ uniform_n_atoms_padding: ${data.uniform_n_atoms_padding}
440
+ time_dist: ${model.module.time_dist}
441
+ scaffold_groups_dir: ${data.scaffold_groups_dir}
442
+ scaffold_kind: ${data.scaffold_kind}
443
+ enumerate_slicings: ${data.enumerate_slicings}
444
+ eval_panel: ${data.eval_panel}
445
+ train_prefetch_factor: 1
446
+ dataset_name: geom
447
+ dataset_target: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset
448
+ scaffold_groups_dir: ${data.preprocessing.root}/processed/scaffold_groups
449
+ scaffold_kind: murcko
450
+ jitter_scale: 0.1
451
+ insertion_assignment: substituent
452
+ enumerate_slicings: false
453
+ eval_panel:
454
+ directory: /cluster/project/krause/frankem/morph-chem/logs/training/geom-scaffold-substituent-lora-r16-20261012T095326Z/panels
455
+ n_scaffolds: 512
456
+ samples_per_scaffold: 8
457
+ seed: 42
458
+ embeddings:
459
+ atom_type_embedding:
460
+ _target_: morph.model.layers.embedding.Embedding
461
+ num_embeddings: ${len:${data.vocab.atom_tokens}}
462
+ embedding_dim: 64
463
+ out_dim: 128
464
+ edge_type_embedding:
465
+ _target_: morph.model.layers.embedding.Embedding
466
+ num_embeddings: ${len:${data.vocab.edge_tokens}}
467
+ embedding_dim: 64
468
+ out_dim: 128
469
+ time_embedding:
470
+ _target_: morph.model.layers.embedding.TimeEmbedding
471
+ embedding_dim: 128
472
+ out_dim: 128
473
+ node_count_embedding:
474
+ _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding
475
+ embedding_dim: 64
476
+ out_dim: 128
477
+ min_period: 128.0
478
+ max_period: 1024.0
479
+ max_count: 96.0
geom/scaffold_murcko_substituent_lora_r16/training_source.tar.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f2ee1bf919ad17d664f0614252f43bdcf39b2474e5a1069a37429a520adb8a2f
3
+ size 510894