chq1155 commited on
Commit
643b8e1
·
verified ·
1 Parent(s): eb47c2c

Remove old eval/universal_v2_evaluate.py (renamed to main_benchmark)

Browse files
Files changed (1) hide show
  1. eval/universal_v2_evaluate.py +0 -341
eval/universal_v2_evaluate.py DELETED
@@ -1,341 +0,0 @@
1
- #!/usr/bin/env python3
2
- """universal_v2 §9.1 evaluator.
3
-
4
- For each prediction PDB under <root>, computes:
5
- - mean_plddt_overall, mean_plddt_switch, mean_plddt_core
6
- - For each state (a, b): Cα RMSD common_core, TM-score, hit_primary (§9.1 binding 3-condition)
7
-
8
- Regions come from data/universal_v2/annotations/<case_id>/state_region_FINAL.tsv
9
- Reference PDBs come from data/universal_v2/structures/<case_id>/{state_a,state_b}.pdb
10
-
11
- ID-pattern cases (state_a == state_b): single hit column hit_primary_state_a.
12
- Per IDP_EVAL_OVERRIDE: treat the whole query region as the target region;
13
- RMSD ≤3Å AND mean_pLDDT ≥70 → hit_primary=True. switch_region pLDDT check is
14
- skipped because every residue is marked switch_region.
15
-
16
- Usage:
17
- python scripts/universal_v2_evaluate.py --case <case_id> --root <pred_dir>
18
- """
19
- from __future__ import annotations
20
- import argparse
21
- import csv
22
- import json
23
- import os
24
- import re
25
- import shutil
26
- import subprocess
27
- import sys
28
- from pathlib import Path
29
-
30
- import numpy as np
31
- import yaml
32
- from Bio.PDB import PDBParser, Superimposer
33
-
34
- # Benchmark data root: env override, else the bundled bench/universal_v2 that
35
- # ships alongside this script (eval/ and bench/ are siblings at the repo root).
36
- DATA_ROOT = Path(os.environ.get(
37
- "SF_UV2_DATA", Path(__file__).resolve().parents[1] / "bench" / "universal_v2"))
38
- # TMalign is optional: it only fills the TM-score columns and is NOT part of the
39
- # hit_primary criterion. Resolved from PATH; None -> those columns are NaN.
40
- TMALIGN = shutil.which("TMalign")
41
-
42
- PRED_NAME_RE = re.compile(
43
- r"^(?P<subset>.+?)_unrelaxed_rank_(?P<rank>\d+)_alphafold2_ptm_model_(?P<model>\d+)_seed_(?P<seed>\d+)\.pdb$"
44
- )
45
-
46
- _CASES_CACHE: dict[str, dict] | None = None
47
-
48
-
49
- def load_case(case_id: str) -> dict:
50
- global _CASES_CACHE
51
- if _CASES_CACHE is None:
52
- cases = yaml.safe_load((DATA_ROOT / "cases.yaml").read_text())["cases"]
53
- _CASES_CACHE = {c["case_id"]: c for c in cases}
54
- return _CASES_CACHE[case_id]
55
-
56
-
57
- def load_regions(case_id: str) -> dict:
58
- path = DATA_ROOT / "annotations" / case_id / "state_region_FINAL.tsv"
59
- common_core: list[int] = []
60
- switch_3a: list[int] = []
61
- state_a_resnum: dict[int, int] = {}
62
- with path.open() as f:
63
- for line in f:
64
- if line.startswith("#"):
65
- continue
66
- line = line.rstrip("\n")
67
- if not line:
68
- continue
69
- parts = line.split("\t")
70
- if parts[0] == "residue_index_query":
71
- continue
72
- qi = int(parts[0])
73
- try:
74
- resnum_a = int(parts[1])
75
- except ValueError:
76
- resnum_a = qi
77
- cc = int(parts[3])
78
- sr = int(parts[4])
79
- if cc:
80
- common_core.append(qi)
81
- if sr:
82
- switch_3a.append(qi)
83
- state_a_resnum[qi] = resnum_a
84
- return {"common_core": common_core, "switch_3A": switch_3a,
85
- "state_a_resnum": state_a_resnum}
86
-
87
-
88
- def is_id_case(case_id: str) -> bool:
89
- return case_id.startswith("SFB_ID_")
90
-
91
-
92
- def read_ca_per_resid(pdb: Path, chain: str | None = None) -> dict[int, np.ndarray]:
93
- s = PDBParser(QUIET=True).get_structure("x", str(pdb))
94
- m = next(iter(s))
95
- for c in m:
96
- if chain is not None and c.id != chain:
97
- continue
98
- out: dict[int, np.ndarray] = {}
99
- for r in c:
100
- if r.id[0] != " ":
101
- continue
102
- if "CA" not in r:
103
- continue
104
- out[r.id[1]] = r["CA"].coord
105
- if out:
106
- return out
107
- raise RuntimeError(f"no Cα in {pdb} chain={chain}")
108
-
109
-
110
- def read_ca_bfactors(pdb: Path, chain: str | None = None) -> dict[int, float]:
111
- s = PDBParser(QUIET=True).get_structure("x", str(pdb))
112
- m = next(iter(s))
113
- for c in m:
114
- if chain is not None and c.id != chain:
115
- continue
116
- out: dict[int, float] = {}
117
- for r in c:
118
- if r.id[0] != " ":
119
- continue
120
- if "CA" not in r:
121
- continue
122
- out[r.id[1]] = float(r["CA"].bfactor)
123
- if out:
124
- return out
125
- raise RuntimeError(f"no Cα B-factors in {pdb} chain={chain}")
126
-
127
-
128
- def rmsd_on_residues(pred_ca: dict[int, np.ndarray],
129
- ref_ca: dict[int, np.ndarray],
130
- pred_to_ref: dict[int, int],
131
- residues: list[int]) -> tuple[float, int]:
132
- pairs = [(qi, pred_to_ref[qi]) for qi in residues
133
- if qi in pred_ca and qi in pred_to_ref and pred_to_ref[qi] in ref_ca]
134
- if len(pairs) < 3:
135
- return float("nan"), len(pairs)
136
- from Bio.PDB.Atom import Atom
137
- pred_atoms = [Atom("CA", pred_ca[qi], 1.0, 1.0, " ", "CA", 1, "C") for qi, _ in pairs]
138
- ref_atoms = [Atom("CA", ref_ca[ri], 1.0, 1.0, " ", "CA", 1, "C") for _, ri in pairs]
139
- sup = Superimposer()
140
- sup.set_atoms(ref_atoms, pred_atoms)
141
- return float(sup.rms), len(pairs)
142
-
143
-
144
- def tmalign(pdb_a: Path, pdb_b: Path) -> tuple[float, float, int]:
145
- if not TMALIGN:
146
- return float("nan"), float("nan"), 0
147
- try:
148
- p = subprocess.run([TMALIGN, str(pdb_a), str(pdb_b)],
149
- capture_output=True, text=True, timeout=120)
150
- except Exception:
151
- return float("nan"), float("nan"), 0
152
- t1 = t2 = float("nan"); aln = 0
153
- for line in p.stdout.splitlines():
154
- if line.startswith("TM-score=") and "normalized by length of Chain_1" in line:
155
- try: t1 = float(line.split()[1])
156
- except: pass
157
- elif line.startswith("TM-score=") and "normalized by length of Chain_2" in line:
158
- try: t2 = float(line.split()[1])
159
- except: pass
160
- elif line.startswith("Aligned length="):
161
- parts = line.replace(",", " ").split()
162
- for i, q in enumerate(parts):
163
- if q == "length=":
164
- try: aln = int(parts[i + 1])
165
- except: pass
166
- return t1, t2, aln
167
-
168
-
169
- def get_state_chain(case: dict, which: str) -> str:
170
- """Get the chain id within state PDB. The structures/ files use the curated chain
171
- from `case_definitions.py` build_v2 — they are usually chain A in the file.
172
- Try chain A first; fall back to the chain id from cases.yaml."""
173
- return case[f"state_{which}_chain"]
174
-
175
-
176
- def state_chain_in_file(pdb: Path) -> str | None:
177
- """Return first chain id present in the PDB file (with Cα atoms)."""
178
- s = PDBParser(QUIET=True).get_structure("x", str(pdb))
179
- m = next(iter(s))
180
- for c in m:
181
- for r in c:
182
- if r.id[0] == " " and "CA" in r:
183
- return c.id
184
- return None
185
-
186
-
187
- def evaluate_pdb(pdb: Path, case_id: str) -> dict:
188
- case = load_case(case_id)
189
- regions = load_regions(case_id)
190
- id_case = is_id_case(case_id)
191
-
192
- pred_ca = read_ca_per_resid(pdb, chain="A")
193
- pred_plddt = read_ca_bfactors(pdb, chain="A")
194
- # Pred residues are 1..L → match regions' residue_index_query directly (it's 1..L too)
195
- mean_plddt_overall = float(np.mean(list(pred_plddt.values())))
196
- # core / switch pLDDT (None if region empty)
197
- core_p = [pred_plddt[r] for r in regions["common_core"] if r in pred_plddt]
198
- sw_p = [pred_plddt[r] for r in regions["switch_3A"] if r in pred_plddt]
199
- mean_plddt_core = float(np.mean(core_p)) if core_p else float("nan")
200
- mean_plddt_switch = float(np.mean(sw_p)) if sw_p else float("nan")
201
-
202
- result = {
203
- "pdb": str(pdb),
204
- "case_id": case_id,
205
- "id_case": id_case,
206
- "pred_len": len(pred_ca),
207
- "mean_plddt_overall": mean_plddt_overall,
208
- "mean_plddt_core": mean_plddt_core,
209
- "mean_plddt_switch_3A": mean_plddt_switch,
210
- "states": {},
211
- }
212
-
213
- # Build pred-resi → state_resi mapping (state_a_resnum comes from FINAL.tsv).
214
- # state_b residue mapping = same query residue index (residue_index_query),
215
- # because the state_b PDBs were curated to use the query residue numbering too
216
- # (see build_v2 case definitions). We discover the actual chain id from the
217
- # state PDB file directly.
218
- pred_to_state_a = dict(regions["state_a_resnum"])
219
-
220
- for which in ["a", "b"]:
221
- pdb_state = DATA_ROOT / "structures" / case_id / f"state_{which}.pdb"
222
- if not pdb_state.exists():
223
- continue
224
- ch = state_chain_in_file(pdb_state)
225
- ref_ca = read_ca_per_resid(pdb_state, chain=ch)
226
- # Determine pred→ref residue map
227
- if which == "a":
228
- pred_to_ref = pred_to_state_a
229
- else:
230
- # state_b: curator built FINAL.tsv via residue-number intersection
231
- # (default) or positional pairing (MPT53). Try numbering-intersection
232
- # first: pred qi → state_a_resnum, expect those resnums to be present
233
- # in state_b PDB. If <50% overlap, fall back to POSITIONAL pairing:
234
- # qi=1 → state_b's 1st CA, qi=2 → 2nd, etc.
235
- ref_keys = set(ref_ca.keys())
236
- num_overlap = sum(1 for rn in pred_to_state_a.values() if rn in ref_keys)
237
- if pred_to_state_a and num_overlap >= 0.5 * len(pred_to_state_a):
238
- pred_to_ref = pred_to_state_a
239
- else:
240
- ref_sorted = sorted(ref_ca.keys())
241
- pred_sorted = sorted(pred_ca.keys())
242
- pred_to_ref = {p: r for p, r in zip(pred_sorted, ref_sorted)}
243
-
244
- rms_core, n_core = rmsd_on_residues(pred_ca, ref_ca, pred_to_ref,
245
- regions["common_core"])
246
- rms_sw, n_sw = rmsd_on_residues(pred_ca, ref_ca, pred_to_ref,
247
- regions["switch_3A"])
248
- t1, t2, aln = tmalign(pdb, pdb_state)
249
-
250
- if id_case:
251
- # IDP override: use the WHOLE query region as target (switch_3A = all residues)
252
- rms_target = rms_sw if not np.isnan(rms_sw) else rms_core
253
- hit = (not np.isnan(rms_target) and rms_target <= 3.0
254
- and mean_plddt_overall >= 70.0)
255
- else:
256
- # Standard §9.1 binding 3-condition
257
- hit = (not np.isnan(rms_core) and rms_core <= 3.0
258
- and mean_plddt_overall >= 70.0
259
- and not np.isnan(mean_plddt_switch)
260
- and mean_plddt_switch >= 70.0)
261
-
262
- result["states"][f"state_{which}"] = {
263
- "rmsd_common_core_A": rms_core,
264
- "rmsd_switch_3A": rms_sw,
265
- "n_common_core_aligned": n_core,
266
- "n_switch_3A_aligned": n_sw,
267
- "tmalign_tm1": t1,
268
- "tmalign_tm2": t2,
269
- "tmalign_aligned_len": aln,
270
- "hit_primary": hit,
271
- }
272
-
273
- if id_case:
274
- # ID cases: state_a == state_b, only emit state_a
275
- break
276
-
277
- return result
278
-
279
-
280
- def main(argv: list[str] | None = None) -> int:
281
- ap = argparse.ArgumentParser()
282
- ap.add_argument("--case", required=True)
283
- ap.add_argument("--root", required=True, type=Path)
284
- ap.add_argument("--out", type=Path, default=None)
285
- args = ap.parse_args(argv)
286
-
287
- pdbs = sorted(args.root.rglob("*_unrelaxed_rank_*.pdb"))
288
- if not pdbs:
289
- print(f"ERROR: no PDBs under {args.root}", file=sys.stderr)
290
- return 2
291
- out_tsv = args.out or args.root / "evals.tsv"
292
- sample = evaluate_pdb(pdbs[0], args.case)
293
- state_keys = list(sample["states"].keys())
294
- cols = ["subset_id", "model", "seed", "rank",
295
- "mean_plddt_overall", "mean_plddt_core", "mean_plddt_switch_3A"]
296
- for sk in state_keys:
297
- for m in ("rmsd_common_core_A", "rmsd_switch_3A",
298
- "tmalign_tm1", "tmalign_tm2", "hit_primary"):
299
- cols.append(f"{sk}__{m}")
300
- cols.append("pdb")
301
-
302
- with out_tsv.open("w") as out:
303
- out.write("\t".join(cols) + "\n")
304
- for pdb in pdbs:
305
- m = PRED_NAME_RE.match(pdb.name)
306
- sid = m.group("subset") if m else pdb.stem
307
- rank = int(m.group("rank")) if m else -1
308
- model = int(m.group("model")) if m else -1
309
- seed = int(m.group("seed")) if m else -1
310
- try:
311
- r = evaluate_pdb(pdb, args.case)
312
- except Exception as e:
313
- print(f"WARN: eval failed {pdb}: {e}", file=sys.stderr)
314
- continue
315
- (pdb.with_name(pdb.stem + "_eval.json")).write_text(
316
- json.dumps(r, indent=2, default=float))
317
- row = [sid, model, seed, rank,
318
- f"{r['mean_plddt_overall']:.2f}",
319
- f"{r['mean_plddt_core']:.2f}" if r['mean_plddt_core'] == r['mean_plddt_core'] else "NA",
320
- f"{r['mean_plddt_switch_3A']:.2f}" if r['mean_plddt_switch_3A'] == r['mean_plddt_switch_3A'] else "NA"]
321
- for sk in state_keys:
322
- sv = r["states"].get(sk, {})
323
- for k in ("rmsd_common_core_A", "rmsd_switch_3A",
324
- "tmalign_tm1", "tmalign_tm2", "hit_primary"):
325
- v = sv.get(k)
326
- if v is None or (isinstance(v, float) and v != v):
327
- row.append("NA")
328
- elif isinstance(v, bool):
329
- row.append("1" if v else "0")
330
- elif isinstance(v, float):
331
- row.append(f"{v:.4f}")
332
- else:
333
- row.append(str(v))
334
- row.append(str(pdb))
335
- out.write("\t".join(str(x) for x in row) + "\n")
336
- print(f"wrote {len(pdbs)} preds -> {out_tsv}")
337
- return 0
338
-
339
-
340
- if __name__ == "__main__":
341
- sys.exit(main())