|
22 | 22 | from os.path import exists as opexists, join as opjoin |
23 | 23 | from typing import Any, Mapping |
24 | 24 |
|
| 25 | +import numpy as np |
25 | 26 | import torch |
26 | 27 | import torch.distributed as dist |
| 28 | +from biotite.structure import AtomArray |
| 29 | + |
27 | 30 |
|
28 | 31 | from configs.configs_base import configs as configs_base |
29 | 32 | from configs.configs_data import data_configs |
@@ -255,6 +258,110 @@ def update_model_configs(self, new_configs: Any) -> None: |
255 | 258 | self.model.configs = new_configs |
256 | 259 |
|
257 | 260 |
|
| 261 | +def fix_cterminal_carboxyl_oxygens( |
| 262 | + pred_coordinate: torch.Tensor, |
| 263 | + atom_array: AtomArray, |
| 264 | +) -> torch.Tensor: |
| 265 | + """Rebuild the C-terminal carboxyl oxygens (O / OXT) of every polymer chain. |
| 266 | +
|
| 267 | + Motivation: due to the O<->OXT substructure permutation symmetry, the model |
| 268 | + may leave one of the two carboxylate oxygens under-constrained (it can drift |
| 269 | + far away). For each polymer chain's last residue we regularize the two |
| 270 | + oxygens by an idealized, symmetric carboxylate geometry. |
| 271 | +
|
| 272 | + Per C-terminal residue (identified by having both an ``O`` and an ``OXT`` |
| 273 | + atom), using: |
| 274 | + A = C atom, C_ca = CA atom. |
| 275 | + We pick B = the oxygen (``O`` or ``OXT``) nearest to A, keep it and name it |
| 276 | + ``O``. The other oxygen D (``OXT``) is placed as the reflection of B across |
| 277 | + the A->C_ca axis, so that: |
| 278 | + * angle(C_ca, A, B) == angle(C_ca, A, D) (equal angles at A) |
| 279 | + * |AB| == |AD| (equal bond lengths) |
| 280 | + * B, D and the A->C_ca axis are coplanar (plane ABC) |
| 281 | + Reflection of vector v about unit axis u: v' = 2*(v.u)*u - v. |
| 282 | +
|
| 283 | + The correction is fully vectorized over samples and over all terminal |
| 284 | + residues. Only the ``O`` and ``OXT`` coordinate slots are modified; all |
| 285 | + other atoms (and atom names) are left untouched. |
| 286 | +
|
| 287 | + Args: |
| 288 | + pred_coordinate: predicted coordinates, shape ``[N_sample, N_atom, 3]``. |
| 289 | + atom_array: the AtomArray whose atom order matches ``pred_coordinate``. |
| 290 | +
|
| 291 | + Returns: |
| 292 | + The corrected coordinate tensor (same shape/dtype/device as input). |
| 293 | + """ |
| 294 | + n_atom = pred_coordinate.shape[-2] |
| 295 | + if len(atom_array) != n_atom: |
| 296 | + return pred_coordinate |
| 297 | + |
| 298 | + chain_ids = np.asarray(atom_array.chain_id) |
| 299 | + res_ids = np.asarray(atom_array.res_id) |
| 300 | + atom_names = np.asarray(atom_array.atom_name) |
| 301 | + |
| 302 | + # Locate the last residue of each chain: a chain block ends where chain_id |
| 303 | + # changes (chains are built as contiguous blocks, residues in order). |
| 304 | + is_chain_end = np.empty(n_atom, dtype=bool) |
| 305 | + is_chain_end[-1] = True |
| 306 | + is_chain_end[:-1] = chain_ids[1:] != chain_ids[:-1] |
| 307 | + chain_end_idx = np.nonzero(is_chain_end)[0] |
| 308 | + |
| 309 | + # For each chain's C-terminal residue, gather the C / CA / O / OXT indices. |
| 310 | + idx_c, idx_ca, idx_o, idx_oxt = [], [], [], [] |
| 311 | + for end_i in chain_end_idx: |
| 312 | + term_mask = (chain_ids == chain_ids[end_i]) & (res_ids == res_ids[end_i]) |
| 313 | + names = atom_names[term_mask] |
| 314 | + # only protein C-termini carry an OXT; require the full quartet. |
| 315 | + if not ({"C", "CA", "O", "OXT"} <= set(names.tolist())): |
| 316 | + continue |
| 317 | + term_pos = np.nonzero(term_mask)[0] |
| 318 | + name_to_pos = {atom_names[p]: p for p in term_pos} |
| 319 | + idx_c.append(name_to_pos["C"]) |
| 320 | + idx_ca.append(name_to_pos["CA"]) |
| 321 | + idx_o.append(name_to_pos["O"]) |
| 322 | + idx_oxt.append(name_to_pos["OXT"]) |
| 323 | + |
| 324 | + if not idx_c: |
| 325 | + return pred_coordinate |
| 326 | + |
| 327 | + device = pred_coordinate.device |
| 328 | + t_c = torch.as_tensor(idx_c, dtype=torch.long, device=device) |
| 329 | + t_ca = torch.as_tensor(idx_ca, dtype=torch.long, device=device) |
| 330 | + t_o = torch.as_tensor(idx_o, dtype=torch.long, device=device) |
| 331 | + t_oxt = torch.as_tensor(idx_oxt, dtype=torch.long, device=device) |
| 332 | + |
| 333 | + coords = pred_coordinate.clone() |
| 334 | + # Compute in float32 for numerical stability regardless of input dtype. |
| 335 | + work = coords.to(torch.float32) |
| 336 | + |
| 337 | + A = work[:, t_c, :] # [S, T, 3] (C atom, plane point A) |
| 338 | + CA = work[:, t_ca, :] # [S, T, 3] (CA atom, plane point C) |
| 339 | + O = work[:, t_o, :] # [S, T, 3] |
| 340 | + OXT = work[:, t_oxt, :] # [S, T, 3] |
| 341 | + |
| 342 | + # B = nearest oxygen (O or OXT) to A; this stays and is named O. |
| 343 | + dist_o = torch.linalg.norm(O - A, dim=-1) # [S, T] |
| 344 | + dist_oxt = torch.linalg.norm(OXT - A, dim=-1) # [S, T] |
| 345 | + nearest_is_o = (dist_o <= dist_oxt).unsqueeze(-1) # [S, T, 1] |
| 346 | + B = torch.where(nearest_is_o, O, OXT) # [S, T, 3] |
| 347 | + |
| 348 | + # Reflect B across the A->CA axis to place D (OXT). |
| 349 | + axis = CA - A |
| 350 | + axis = axis / axis.norm(dim=-1, keepdim=True).clamp_min(1e-8) # unit u |
| 351 | + vB = B - A |
| 352 | + proj = (vB * axis).sum(dim=-1, keepdim=True) # vB . u |
| 353 | + D = A + 2.0 * proj * axis - vB # reflection: 2(v.u)u - v |
| 354 | + |
| 355 | + # Write back: O slot gets B (kept oxygen), OXT slot gets D (reconstructed). |
| 356 | + B = B.to(coords.dtype) |
| 357 | + D = D.to(coords.dtype) |
| 358 | + n_sample = coords.shape[0] |
| 359 | + sample_ax = torch.arange(n_sample, device=device).unsqueeze(-1) # [S, 1] |
| 360 | + coords[sample_ax, t_o.unsqueeze(0), :] = B |
| 361 | + coords[sample_ax, t_oxt.unsqueeze(0), :] = D |
| 362 | + return coords |
| 363 | + |
| 364 | + |
258 | 365 | def progress_callback(block_num: int, block_size: int, total_size: int) -> None: |
259 | 366 | """Callback for tracking download progress.""" |
260 | 367 | downloaded = block_num * block_size |
@@ -484,6 +591,16 @@ def infer_predict(runner: InferenceRunner, configs: Any) -> None: |
484 | 591 | new_configs = update_inference_configs(configs, data["N_token"].item()) |
485 | 592 | runner.update_model_configs(new_configs) |
486 | 593 | prediction = runner.predict(data) |
| 594 | + |
| 595 | + # Regularize the C-terminal carboxyl oxygens (O / OXT) of each |
| 596 | + # polymer chain: the O<->OXT substructure-permutation symmetry can |
| 597 | + # leave one oxygen under-constrained and drift far away. Rebuild |
| 598 | + # them with an idealized symmetric carboxylate geometry. The |
| 599 | + # atom_array order matches prediction["coordinate"] 1:1. |
| 600 | + prediction["coordinate"] = fix_cterminal_carboxyl_oxygens( |
| 601 | + prediction["coordinate"], atom_array |
| 602 | + ) |
| 603 | + |
487 | 604 | runner.dumper.dump( |
488 | 605 | dataset_name="", |
489 | 606 | pdb_id=sample_name, |
|
0 commit comments