Skip to content

Commit 4c355be

Browse files
committed
feat: add C-terminal carboxyl oxygen fix and test suite
add a new function to fix under-constrained O/OXT at protein C-termini, add comprehensive test for the fix, update gitignore and fix inference script executable bit
1 parent c3bfc36 commit 4c355be

4 files changed

Lines changed: 224 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,4 @@ wandb/
137137
output/
138138
release_data/
139139
debug/
140+
.trae/

inference_demo.sh

100755100644
File mode changed.

runner/inference.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222
from os.path import exists as opexists, join as opjoin
2323
from typing import Any, Mapping
2424

25+
import numpy as np
2526
import torch
2627
import torch.distributed as dist
28+
from biotite.structure import AtomArray
29+
2730

2831
from configs.configs_base import configs as configs_base
2932
from configs.configs_data import data_configs
@@ -255,6 +258,110 @@ def update_model_configs(self, new_configs: Any) -> None:
255258
self.model.configs = new_configs
256259

257260

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+
258365
def progress_callback(block_num: int, block_size: int, total_size: int) -> None:
259366
"""Callback for tracking download progress."""
260367
downloaded = block_num * block_size
@@ -484,6 +591,16 @@ def infer_predict(runner: InferenceRunner, configs: Any) -> None:
484591
new_configs = update_inference_configs(configs, data["N_token"].item())
485592
runner.update_model_configs(new_configs)
486593
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+
487604
runner.dumper.dump(
488605
dataset_name="",
489606
pdb_id=sample_name,

tests/test_inference.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright 2024 ByteDance and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
17+
import numpy as np
18+
import torch
19+
from biotite.structure import AtomArray
20+
21+
from runner.inference import fix_cterminal_carboxyl_oxygens
22+
23+
24+
class TestFixCterminalCarboxylOxygens(unittest.TestCase):
25+
def test_fix_cterminal_carboxyl_oxygens(self):
26+
atom_array = AtomArray(13)
27+
atom_array.chain_id = np.array(["A"] * 9 + ["B"] * 4)
28+
atom_array.res_id = np.array([1] * 4 + [2] * 5 + [7] * 4)
29+
atom_array.atom_name = np.array(
30+
[
31+
"CA",
32+
"C",
33+
"O",
34+
"OXT",
35+
"N",
36+
"CA",
37+
"C",
38+
"O",
39+
"OXT",
40+
"CA",
41+
"C",
42+
"O",
43+
"OXT",
44+
]
45+
)
46+
47+
coordinates = torch.arange(2 * 13 * 3, dtype=torch.float64).reshape(
48+
2, 13, 3
49+
)
50+
51+
# Chain A uses the x-axis through C; each sample keeps a different oxygen.
52+
coordinates[0, 5:9] = torch.tensor(
53+
[
54+
[1.0, 0.0, 0.0],
55+
[0.0, 0.0, 0.0],
56+
[0.5, 1.0, 0.0],
57+
[10.0, 0.0, 0.0],
58+
]
59+
)
60+
coordinates[1, 5:9] = torch.tensor(
61+
[
62+
[1.0, 0.0, 0.0],
63+
[0.0, 0.0, 0.0],
64+
[10.0, 0.0, 0.0],
65+
[0.25, 0.0, 2.0],
66+
]
67+
)
68+
69+
# Chain B uses the y-axis through C and has a translated origin.
70+
coordinates[0, 9:13] = torch.tensor(
71+
[
72+
[1.0, 2.0, 1.0],
73+
[1.0, 1.0, 1.0],
74+
[2.0, 1.25, 1.0],
75+
[8.0, 8.0, 8.0],
76+
]
77+
)
78+
coordinates[1, 9:13] = torch.tensor(
79+
[
80+
[-1.0, 3.0, 3.0],
81+
[-1.0, 2.0, 3.0],
82+
[9.0, 9.0, 9.0],
83+
[-1.0, 2.5, 4.5],
84+
]
85+
)
86+
87+
original = coordinates.clone()
88+
result = fix_cterminal_carboxyl_oxygens(coordinates, atom_array)
89+
90+
expected = original.clone()
91+
expected[0, 7] = torch.tensor([0.5, 1.0, 0.0])
92+
expected[0, 8] = torch.tensor([0.5, -1.0, 0.0])
93+
expected[1, 7] = torch.tensor([0.25, 0.0, 2.0])
94+
expected[1, 8] = torch.tensor([0.25, 0.0, -2.0])
95+
expected[0, 11] = torch.tensor([2.0, 1.25, 1.0])
96+
expected[0, 12] = torch.tensor([0.0, 1.25, 1.0])
97+
expected[1, 11] = torch.tensor([-1.0, 2.5, 4.5])
98+
expected[1, 12] = torch.tensor([-1.0, 2.5, 1.5])
99+
100+
torch.testing.assert_close(result, expected)
101+
torch.testing.assert_close(coordinates, original)
102+
self.assertEqual(result.dtype, coordinates.dtype)
103+
104+
105+
if __name__ == "__main__":
106+
unittest.main()

0 commit comments

Comments
 (0)