File size: 12,657 Bytes
f4e8048 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | """Evaluate a single AF2-predicted PDB against a case's locked eval regions.
For a known-state case (KaiB, GA/GB):
- pLDDT overall and per-region (common_core, switch_region, switch_region_2A)
- Cα RMSD to state_A and state_B on common_core, switch_region, switch_region_2A
- TM-score to state_A and state_B on common_core
- Primary hit criterion (protocol §9.1, BINDING 3-condition): RMSD <= 3.0 Å on
common_core AND mean pLDDT >= 70 overall AND switch_region pLDDT >= 70.
NaN switch_region pLDDT → §9.1 NOT evaluable → hit_primary=False (issue #7).
For the discovery case (Mpt53):
- pLDDT overall
- RMSD and TM-score to state_A_reference (1LU4) on the full 134-aa common_core
CLI:
python src/eval/evaluate_prediction.py PRED.pdb --case KaiB [--json OUT.json]
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Iterable
import numpy as np
import yaml
from Bio.PDB import PDBParser, Superimposer
# ROOT holds data/, configs/ and results/ (all paths below are ROOT-relative).
# Default = repo layout (this file at <ROOT>/src/eval/); override with the
# SF_BENCH_ROOT env var to point at a relocated benchmark bundle root.
_ENV_ROOT = os.environ.get("SF_BENCH_ROOT")
ROOT = Path(_ENV_ROOT).resolve() if _ENV_ROOT else Path(__file__).resolve().parents[2]
MANIFEST_REGIONS = ROOT / "data" / "manifests" / "eval_regions.tsv"
STRUCT_ROOT = ROOT / "data" / "processed" / "structures"
CASES_YAML = ROOT / "configs" / "cases.yaml"
def parse_csv_list(s: str) -> list[int]:
if not s or s in ("NA", ""):
return []
return [int(x) for x in s.split(",") if x.strip()]
def load_construct_start(case: str) -> int:
"""Return construct_start for the case (used to offset predicted PDB numbering).
ColabFold emits predicted residues numbered 1..L; our eval_regions are in the
state PDB's numbering (e.g. KaiB 5..95, Mpt53 38..173 mapped from UniProt).
Predicted residue i maps to region residue (i + construct_start - 1).
"""
cases = yaml.safe_load(CASES_YAML.read_text())["cases"]
for c in cases:
if c["case_name"] == case:
return int(c["construct_start"])
raise KeyError(f"no cases.yaml entry for {case}")
def load_regions(case: str) -> dict:
"""Return common_core / switch_region_3A / switch_region_2A residue lists.
For GA/GB the stored row is `GA_GB_GA98_vs_GB98`.
For Mpt53 the row is `Mpt53` with switch cols NA.
"""
row_key = {
"KaiB": "KaiB",
"GA_GB": "GA_GB_GA98_vs_GB98",
"Mpt53": "Mpt53",
}.get(case)
if row_key is None:
raise ValueError(f"unknown case {case}")
with MANIFEST_REGIONS.open() as f:
reader = csv.DictReader(f, delimiter="\t")
for row in reader:
if row["case"] == row_key:
return {
"case": row_key,
"common_core": parse_csv_list(row.get("common_core_residues", "")),
"switch_3A": parse_csv_list(row.get("switch_region_3A_residues", "")),
"switch_2A": parse_csv_list(row.get("switch_region_2A_residues", "")),
}
raise KeyError(f"no eval_regions row for {row_key}")
def load_state_paths(case: str) -> dict[str, Path]:
"""Map state name -> PDB path for the case."""
if case == "KaiB":
return {
"state_A_2QKE": STRUCT_ROOT / "KaiB" / "state_A.pdb",
"state_B_5JYT": STRUCT_ROOT / "KaiB" / "state_B.pdb",
}
if case == "GA_GB":
# Two fold canonical references
return {
"GA98_2LHC": STRUCT_ROOT / "GA_GB" / "GA98_2LHC.pdb",
"GB98_2LHD": STRUCT_ROOT / "GA_GB" / "GB98_2LHD.pdb",
}
if case == "Mpt53":
return {
"state_A_1LU4": STRUCT_ROOT / "Mpt53" / "state_A.pdb",
}
raise ValueError(f"unknown case {case}")
def read_ca_per_resid(pdb: Path, chain: str | None = None,
resid_offset: int = 0) -> dict[int, np.ndarray]:
"""Return {resid: Cα coord} for the first model, first (or named) chain.
resid_offset is added to each residue id (used to map 1LU4 crystal 1001..
into UniProt 38..171 frame when needed).
"""
s = PDBParser(QUIET=True).get_structure("x", str(pdb))
m = next(iter(s))
for c in m:
if chain is not None and c.id != chain:
continue
out: dict[int, np.ndarray] = {}
for r in c:
if r.id[0] != " ":
continue
if "CA" not in r:
continue
out[r.id[1] + resid_offset] = r["CA"].coord
if out:
return out
raise RuntimeError(f"no Cα atoms found in {pdb} chain={chain}")
def read_ca_bfactors(pdb: Path, chain: str | None = None,
resid_offset: int = 0) -> dict[int, float]:
"""Return {resid: Cα B-factor} — for AF2 outputs, B-factor == pLDDT."""
s = PDBParser(QUIET=True).get_structure("x", str(pdb))
m = next(iter(s))
for c in m:
if chain is not None and c.id != chain:
continue
out: dict[int, float] = {}
for r in c:
if r.id[0] != " ":
continue
if "CA" not in r:
continue
out[r.id[1] + resid_offset] = float(r["CA"].bfactor)
if out:
return out
raise RuntimeError(f"no Cα B-factors in {pdb} chain={chain}")
def rmsd_on_residues(pred_ca: dict[int, np.ndarray],
ref_ca: dict[int, np.ndarray],
residues: Iterable[int]) -> tuple[float, int]:
"""Superpose pred onto ref using the given residue set; return RMSD + N."""
residues = [r for r in residues if r in pred_ca and r in ref_ca]
if len(residues) < 3:
return float("nan"), len(residues)
from Bio.PDB.Atom import Atom
pred_atoms = [Atom("CA", pred_ca[r], 1.0, 1.0, " ", "CA", 1, "C") for r in residues]
ref_atoms = [Atom("CA", ref_ca[r], 1.0, 1.0, " ", "CA", 1, "C") for r in residues]
sup = Superimposer()
sup.set_atoms(ref_atoms, pred_atoms)
return float(sup.rms), len(residues)
def tmalign_score(pdb_a: Path, pdb_b: Path) -> tuple[float, float, int]:
"""Run TMalign, return (TM-score normalized by chain_1, chain_2, aligned_len).
TMalign is OPTIONAL: it only fills the tmalign_* columns and is NOT part of
the hit_primary criterion. If TMalign is not on PATH we skip it cleanly and
return NaNs.
"""
import shutil as _sh
tm = _sh.which("TMalign")
if tm is None:
return float("nan"), float("nan"), 0
try:
proc = subprocess.run(
[tm, str(pdb_a), str(pdb_b)],
capture_output=True, text=True, timeout=120,
)
except Exception as e:
print(f"WARN: TMalign failed for {pdb_a} vs {pdb_b}: {e}", file=sys.stderr)
return float("nan"), float("nan"), 0
tm1 = tm2 = float("nan")
aln = 0
for line in proc.stdout.splitlines():
if line.startswith("TM-score=") and "normalized by length of Chain_1" in line:
tm1 = float(line.split()[1])
elif line.startswith("TM-score=") and "normalized by length of Chain_2" in line:
tm2 = float(line.split()[1])
elif line.startswith("Aligned length="):
# "Aligned length= 66, RMSD= 3.86, Seq_id=n_identical/n_aligned= 0.000"
parts = line.replace(",", " ").split()
for i, p in enumerate(parts):
if p == "length=":
aln = int(parts[i + 1])
return tm1, tm2, aln
def evaluate(pdb: Path, case: str) -> dict:
# §9.1 binding 3-condition hit criterion (CLAUDE.md §Evaluation Protocol):
# ALL of (1) Cα RMSD ≤ 3.0 Å on common_core, (2) mean pLDDT ≥ 70 overall,
# (3) mean pLDDT ≥ 70 in switch_region must hold. NaN switch_region pLDDT
# means switch_region is undefined (or no overlap) → §9.1 is NOT evaluable
# → hit_primary MUST be False (issue #7 fix).
regions = load_regions(case)
states = load_state_paths(case)
# Predicted structure Cα + pLDDT (B-factors). Our predicted PDBs have chain A
# and residues numbered 1..L. Shift to the state PDB / eval_region numbering.
construct_start = load_construct_start(case)
pred_offset = construct_start - 1
pred_ca = read_ca_per_resid(pdb, chain="A", resid_offset=pred_offset)
pred_plddt = read_ca_bfactors(pdb, chain="A", resid_offset=pred_offset)
mean_plddt_overall = float(np.mean(list(pred_plddt.values())))
mean_plddt_core = float(np.mean([pred_plddt[r] for r in regions["common_core"]
if r in pred_plddt]))
mean_plddt_switch3 = (float(np.mean([pred_plddt[r] for r in regions["switch_3A"]
if r in pred_plddt]))
if regions["switch_3A"] else float("nan"))
mean_plddt_switch2 = (float(np.mean([pred_plddt[r] for r in regions["switch_2A"]
if r in pred_plddt]))
if regions["switch_2A"] else float("nan"))
result = {
"pdb": str(pdb.relative_to(ROOT)) if pdb.is_relative_to(ROOT) else str(pdb),
"case": case,
"pred_len": len(pred_ca),
"mean_plddt_overall": mean_plddt_overall,
"mean_plddt_core": mean_plddt_core,
"mean_plddt_switch_3A": mean_plddt_switch3,
"mean_plddt_switch_2A": mean_plddt_switch2,
"states": {},
}
for state_name, state_pdb in states.items():
# Determine state chain + offset
if state_name.startswith("state_A_1LU4"):
ref_chain = "A"
ref_offset = -963 # crystal 1001..1134 -> UniProt 38..171
elif state_name.startswith("state_A_2QKE"):
ref_chain = "B"
ref_offset = 0
elif state_name.startswith("state_B_5JYT"):
ref_chain = "A"
ref_offset = 0
else:
# GA/GB canonical refs (GA98, GB98)
ref_chain = "A"
ref_offset = 0
ref_ca = read_ca_per_resid(state_pdb, chain=ref_chain, resid_offset=ref_offset)
rms_core, n_core = rmsd_on_residues(pred_ca, ref_ca, regions["common_core"])
rms_s3, n_s3 = (rmsd_on_residues(pred_ca, ref_ca, regions["switch_3A"])
if regions["switch_3A"] else (float("nan"), 0))
rms_s2, n_s2 = (rmsd_on_residues(pred_ca, ref_ca, regions["switch_2A"])
if regions["switch_2A"] else (float("nan"), 0))
tm1, tm2, aln = tmalign_score(pdb, state_pdb)
# §9.1 binding 3-condition: all three thresholds must hold. If
# switch_region pLDDT is NaN, §9.1 is NOT evaluable → hit_primary=False.
no_switch_region_defined = bool(np.isnan(mean_plddt_switch3))
hit_primary = (
not np.isnan(rms_core)
and rms_core <= 3.0
and mean_plddt_overall >= 70.0
and (not no_switch_region_defined)
and mean_plddt_switch3 >= 70.0
)
if no_switch_region_defined:
print(
f"WARN: switch_region pLDDT is NaN for {pdb} (case={case}, "
f"state={state_name}); §9.1 not evaluable → hit_primary=False",
file=sys.stderr,
)
result["states"][state_name] = {
"rmsd_common_core_A": rms_core,
"rmsd_switch_3A": rms_s3,
"rmsd_switch_2A": rms_s2,
"n_common_core_aligned": n_core,
"n_switch_3A_aligned": n_s3,
"n_switch_2A_aligned": n_s2,
"tmalign_tm1": tm1,
"tmalign_tm2": tm2,
"tmalign_aligned_len": aln,
"hit_primary": hit_primary,
"no_switch_region_defined": no_switch_region_defined,
}
return result
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("pdb", type=Path, help="Path to AF2-predicted PDB")
ap.add_argument("--case", required=True, choices=["KaiB", "GA_GB", "Mpt53"])
ap.add_argument("--json", type=Path, default=None,
help="Optional path to dump JSON result")
args = ap.parse_args(argv)
result = evaluate(args.pdb, args.case)
if args.json:
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(result, indent=2, default=float))
print(json.dumps(result, indent=2, default=float))
return 0
if __name__ == "__main__":
sys.exit(main())
|