core-score 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of core-score might be problematic. Click here for more details.
- core_score/__init__.py +8 -0
- core_score/__main__.py +3 -0
- core_score/analysis/__init__.py +1 -0
- core_score/analysis/core.py +61 -0
- core_score/analysis/ost.py +79 -0
- core_score/analysis/pairs.py +119 -0
- core_score/analysis/posebusters.py +65 -0
- core_score/analysis/reference.py +59 -0
- core_score/cli.py +90 -0
- core_score/inputs/__init__.py +1 -0
- core_score/inputs/inputs.py +113 -0
- core_score/inputs/make_csv.py +209 -0
- core_score/inputs/prepare.py +108 -0
- core_score/inputs/sites.py +115 -0
- core_score/inputs/structures.py +357 -0
- core_score/outputs/__init__.py +1 -0
- core_score/outputs/files.py +63 -0
- core_score/outputs/plot.py +77 -0
- core_score/outputs/report.py +104 -0
- core_score/pipeline.py +102 -0
- core_score-0.4.0.dist-info/METADATA +382 -0
- core_score-0.4.0.dist-info/RECORD +26 -0
- core_score-0.4.0.dist-info/WHEEL +5 -0
- core_score-0.4.0.dist-info/entry_points.txt +3 -0
- core_score-0.4.0.dist-info/licenses/LICENSE +28 -0
- core_score-0.4.0.dist-info/top_level.txt +1 -0
core_score/__init__.py
ADDED
core_score/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Check, compare and score the prepared poses."""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Build each site ensemble's RMSD matrix and calculate CORE at each threshold."""
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import numpy as np
|
|
5
|
+
from core_score.outputs.files import diagnostic
|
|
6
|
+
|
|
7
|
+
NEIGHBOURS = 3 # each pose is scored against its three closest other poses
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def score_groups(groups, rows, thresholds=(2.0,)):
|
|
11
|
+
"""One score per site ensemble and threshold T. An ensemble with a missing or failed pair is not scored."""
|
|
12
|
+
scores = []
|
|
13
|
+
for group in groups:
|
|
14
|
+
ids = [pose["pose_id"] for pose in group["poses"]]
|
|
15
|
+
values, error = dict.fromkeys(thresholds), group.get("error")
|
|
16
|
+
if not error:
|
|
17
|
+
try:
|
|
18
|
+
key = (group["ensemble"], group["site"])
|
|
19
|
+
matrix = build_matrix(ids, [r for r in rows if (r["ensemble"], r["site"]) == key])
|
|
20
|
+
values = {threshold: core_score(matrix, threshold) for threshold in thresholds}
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
error = diagnostic("scoring", exc)
|
|
23
|
+
scores += [
|
|
24
|
+
dict(ensemble=group["ensemble"], site=group["site"], name=group["name"], pose_count=len(ids), threshold_angstrom=threshold,
|
|
25
|
+
core_score=value, error=error["error"] if error else None)
|
|
26
|
+
for threshold, value in values.items()
|
|
27
|
+
]
|
|
28
|
+
return scores
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_matrix(pose_ids, rows):
|
|
32
|
+
"""Pairwise ligand-RMSD matrix. Raises unless every pair has exactly one usable RMSD;
|
|
33
|
+
a missing or failed comparison is never filled in."""
|
|
34
|
+
index = {pose_id: i for i, pose_id in enumerate(pose_ids)}
|
|
35
|
+
matrix = np.full((len(pose_ids), len(pose_ids)), np.nan)
|
|
36
|
+
np.fill_diagonal(matrix, 0.0)
|
|
37
|
+
for row in rows:
|
|
38
|
+
if row["n_assigned"] == 1 and not row["error"]:
|
|
39
|
+
i, j = index[row["pose_i"]], index[row["pose_j"]]
|
|
40
|
+
matrix[i, j] = matrix[j, i] = row["rmsd"]
|
|
41
|
+
missing = [(a, b) for (i, a), (j, b) in itertools.combinations(enumerate(pose_ids), 2)
|
|
42
|
+
if not np.isfinite(matrix[i, j])]
|
|
43
|
+
if missing:
|
|
44
|
+
raise ValueError(f"No usable ligand RMSD for pairs {missing}; CORE needs every pair")
|
|
45
|
+
return matrix
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def core_score(matrix, threshold=2.0, neighbours=NEIGHBOURS):
|
|
49
|
+
"""CORE Score of an ensemble from its complete pairwise ligand-RMSD matrix.
|
|
50
|
+
|
|
51
|
+
Each pose's RMSDs to its `neighbours` closest other poses are capped at `threshold`
|
|
52
|
+
and divided by it, and their mean is that pose's penalty: zero when those poses
|
|
53
|
+
reproduce it exactly, one when none of them lies within the threshold. CORE is one
|
|
54
|
+
minus the mean penalty over all poses, so it needs at least `neighbours` + 1 poses.
|
|
55
|
+
"""
|
|
56
|
+
distances = np.array(matrix, dtype=float)
|
|
57
|
+
if len(distances) <= neighbours:
|
|
58
|
+
raise ValueError(f"CORE needs at least {neighbours + 1} poses; this ensemble has {len(distances)}")
|
|
59
|
+
np.fill_diagonal(distances, np.inf)
|
|
60
|
+
closest = np.sort(distances, axis=1)[:, :neighbours]
|
|
61
|
+
return float(1.0 - np.mean(np.minimum(closest, threshold) / threshold))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Run OpenStructure's command-line actions. `ost` is found on PATH, and its chemical
|
|
2
|
+
library through OST's own discovery or OST_COMPOUNDS_CHEMLIB."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OSTError(RuntimeError):
|
|
13
|
+
def __init__(self, message, evidence):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.evidence = evidence
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def execute(arguments, timeout=None):
|
|
19
|
+
"""Run `ost <arguments> -o <file>` and return its JSON output with the execution record."""
|
|
20
|
+
# One numerical-library thread per process, so --workers alone sets the parallelism.
|
|
21
|
+
env = dict(os.environ, OMP_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1", MKL_NUM_THREADS="1")
|
|
22
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
23
|
+
output = Path(directory) / "ost.json"
|
|
24
|
+
command = ["ost", *map(str, arguments), "-o", str(output)]
|
|
25
|
+
process = subprocess.run(command, env=env, capture_output=True, text=True, timeout=timeout)
|
|
26
|
+
execution = dict(command=command, returncode=process.returncode,
|
|
27
|
+
stdout=process.stdout, stderr=process.stderr)
|
|
28
|
+
if not output.exists():
|
|
29
|
+
raise OSTError(process.stderr.strip() or f"OST exited with code {process.returncode}", execution)
|
|
30
|
+
return json.loads(output.read_text()), execution
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def compare_ligands(model, reference, timeout=None, radius=4.0, lddt_pli=False):
|
|
34
|
+
"""`ost compare-ligand-structures` result for one model/reference pair of prepared structures.
|
|
35
|
+
lDDT-PLI is only computed when asked for, since CORE uses RMSD alone."""
|
|
36
|
+
arguments = ["compare-ligand-structures", "-m", model["path"]]
|
|
37
|
+
if model["ligand_sdfs"]:
|
|
38
|
+
arguments += ["-ml", *model["ligand_sdfs"]]
|
|
39
|
+
arguments += ["-r", reference["path"]]
|
|
40
|
+
if reference["ligand_sdfs"]:
|
|
41
|
+
arguments += ["-rl", *reference["ligand_sdfs"]]
|
|
42
|
+
arguments += ["--fault-tolerant", "--rmsd", "--full-bs-search", "--full-results"]
|
|
43
|
+
if lddt_pli:
|
|
44
|
+
arguments += ["--lddt-pli", "--lddt-pli-add-mdl-contacts"]
|
|
45
|
+
if radius != 4.0:
|
|
46
|
+
arguments += ["--radius", radius]
|
|
47
|
+
result, execution = execute(arguments, timeout)
|
|
48
|
+
if execution["returncode"] or result.get("status") != "SUCCESS":
|
|
49
|
+
result["status"] = "FAILURE"
|
|
50
|
+
result.setdefault("exception", execution["stderr"][-2000:] or "OST did not report success")
|
|
51
|
+
result["_execution"] = execution
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reads_ligand(prepared, timeout=None):
|
|
56
|
+
"""Whether OST reads each ligand copy from the structure itself, connected the same way
|
|
57
|
+
as in the package's own SDFs (bonds from the coordinates). A ligand OST cannot find,
|
|
58
|
+
cannot read, or connects differently returns False."""
|
|
59
|
+
sdfs = [copy["ost_sdf"] for copy in prepared["copy_records"]]
|
|
60
|
+
try:
|
|
61
|
+
result = compare_ligands(prepared, dict(prepared, ligand_sdfs=sdfs), timeout)
|
|
62
|
+
except OSTError:
|
|
63
|
+
return False
|
|
64
|
+
assigned = [] if result["status"] == "FAILURE" else result.get("rmsd", {}).get("assigned_scores", [])
|
|
65
|
+
return len(assigned) == len(sdfs)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def superpose(model_path, reference_path, timeout=None):
|
|
69
|
+
"""OST rigid superposition of matched protein C-alpha atoms, model onto reference."""
|
|
70
|
+
result, execution = execute(
|
|
71
|
+
["compare-structures", "-m", model_path, "-r", reference_path, "--rigid-scores"], timeout
|
|
72
|
+
)
|
|
73
|
+
if execution["returncode"] or result.get("status") != "SUCCESS" or result.get("transform") is None:
|
|
74
|
+
raise OSTError(
|
|
75
|
+
f"OST rigid superposition failed (status {result.get('status')}); chains may not map",
|
|
76
|
+
dict(execution, ost_results=result),
|
|
77
|
+
)
|
|
78
|
+
return dict(transform=np.asarray(result["transform"], dtype=float),
|
|
79
|
+
rmsd=result.get("rmsd"), chain_mapping=result.get("chain_mapping") or {})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Compare every pair of poses within each site ensemble with OST and save the measurements."""
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import math
|
|
5
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
6
|
+
from functools import partial
|
|
7
|
+
from core_score.analysis import ost
|
|
8
|
+
from core_score.outputs.files import diagnostic, write_csv, write_jsonl
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compare_groups(groups, output, *, workers=1, timeout=None, radius_fallback=True):
|
|
12
|
+
results = run_pair_tasks(pair_tasks(groups), workers, timeout=timeout, radius_fallback=radius_fallback)
|
|
13
|
+
results.sort(key=lambda result: tuple(result[0][k] for k in ("ensemble", "site", "pose_i", "pose_j")))
|
|
14
|
+
rows = [row for row, _ in results]
|
|
15
|
+
write_csv(output / "pairs.csv", rows)
|
|
16
|
+
write_jsonl(output / "ligand_details.jsonl", [detail for _, detail in results])
|
|
17
|
+
return rows
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pair_tasks(groups):
|
|
21
|
+
"""Every pair of poses within each site ensemble; earlier poses are the OST model."""
|
|
22
|
+
return [
|
|
23
|
+
(group, pose_i, pose_j)
|
|
24
|
+
for group in groups
|
|
25
|
+
if not group.get("error")
|
|
26
|
+
for pose_i, pose_j in itertools.combinations(group["poses"], 2)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_pair_tasks(tasks, workers=1, **options):
|
|
31
|
+
"""(row, detail) for each task, in task order, using up to `workers` processes."""
|
|
32
|
+
job = partial(pair_job, **options)
|
|
33
|
+
if workers == 1:
|
|
34
|
+
return [job(task) for task in tasks]
|
|
35
|
+
with ProcessPoolExecutor(max_workers=workers) as pool:
|
|
36
|
+
return list(pool.map(job, tasks))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pair_job(task, *, timeout=None, radius_fallback=True, lddt_pli=False):
|
|
40
|
+
group, pose_i, pose_j = task
|
|
41
|
+
ids = dict(ensemble=group["ensemble"], site=group["site"],
|
|
42
|
+
pose_i=pose_i["pose_id"], pose_j=pose_j["pose_id"])
|
|
43
|
+
try:
|
|
44
|
+
row, detail = compare_pair(pose_i, pose_j, timeout=timeout, radius_fallback=radius_fallback, lddt_pli=lddt_pli)
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
detail = diagnostic("comparison", exc, inputs=[pose_i["path"], pose_j["path"]])
|
|
47
|
+
row = dict(n_assigned=0, error=detail["error"])
|
|
48
|
+
return dict(ids, **row), dict(ids, **detail)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compare_pair(pose_i, pose_j, *, timeout=None, radius_fallback=True, lddt_pli=False):
|
|
52
|
+
"""OST scores for one pair, retried at 6 and then 8 A after a binding-site failure at 4 A.
|
|
53
|
+
|
|
54
|
+
The RMSD metrics come from the radius that scored. lDDT-PLI always stays from 4 A.
|
|
55
|
+
"""
|
|
56
|
+
model, reference = pose_i["prepared"], pose_j["prepared"]
|
|
57
|
+
# Recovery needs one ligand copy per side. Ensembles are split by site before comparison.
|
|
58
|
+
retry = radius_fallback and model["copies"] == reference["copies"] == 1
|
|
59
|
+
results, attempts = [], []
|
|
60
|
+
for radius in (4.0, 6.0, 8.0) if retry else (4.0,):
|
|
61
|
+
result = ost.compare_ligands(model, reference, timeout, radius, lddt_pli)
|
|
62
|
+
failed = result["status"] == "FAILURE"
|
|
63
|
+
assigned = [] if failed else result.get("rmsd", {}).get("assigned_scores", [])
|
|
64
|
+
score = assigned[0].get("score") if len(assigned) == 1 else None
|
|
65
|
+
reason = result["exception"] if failed else result.get("rmsd", {})
|
|
66
|
+
results.append(result)
|
|
67
|
+
attempts.append(dict(radius=radius, score=score, reason=reason))
|
|
68
|
+
if usable(score) or not binding_site_failure(reason):
|
|
69
|
+
break
|
|
70
|
+
recovered = len(attempts) > 1 and usable(attempts[-1]["score"])
|
|
71
|
+
result = dict(results[0])
|
|
72
|
+
if recovered:
|
|
73
|
+
result["rmsd"] = results[-1]["rmsd"]
|
|
74
|
+
row = summarize(result)
|
|
75
|
+
if row["n_assigned"] == 0 and not row["error"]:
|
|
76
|
+
row["error"] = "OST assigned no ligand pair; see the saved OST results"
|
|
77
|
+
scored = attempts[-1] if recovered else attempts[0]
|
|
78
|
+
row.update(
|
|
79
|
+
standard_rmsd=attempts[0]["score"] if usable(attempts[0]["score"]) else None,
|
|
80
|
+
binding_site_radius_angstrom=scored["radius"] if usable(scored["score"]) else None,
|
|
81
|
+
radius_fallback_used=recovered,
|
|
82
|
+
)
|
|
83
|
+
detail = dict(error=row["error"], source_pose_i=pose_i["path"], source_pose_j=pose_j["path"],
|
|
84
|
+
radius_attempts=attempts, ost_results=results)
|
|
85
|
+
return row, detail
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def summarize(result):
|
|
89
|
+
"""OST's scores for the pair, blank unless exactly one ligand pair was assigned."""
|
|
90
|
+
failed = result["status"] == "FAILURE"
|
|
91
|
+
assigned = [] if failed else [
|
|
92
|
+
s for s in result.get("rmsd", {}).get("assigned_scores", []) if s.get("score") is not None
|
|
93
|
+
]
|
|
94
|
+
contacts = [] if failed else [
|
|
95
|
+
s["score"] for s in result.get("lddt_pli", {}).get("assigned_scores", []) if s.get("score") is not None
|
|
96
|
+
]
|
|
97
|
+
single = assigned[0] if len(assigned) == 1 else {}
|
|
98
|
+
return dict(
|
|
99
|
+
n_assigned=len(assigned),
|
|
100
|
+
rmsd=single.get("score"),
|
|
101
|
+
bb_rmsd=single.get("bb_rmsd"),
|
|
102
|
+
lddt_lp=single.get("lddt_lp"),
|
|
103
|
+
lddt_pli=contacts[0] if len(contacts) == 1 else None,
|
|
104
|
+
error=str(result.get("exception") or result.get("traceback") or "OST failed") if failed else "",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def usable(score):
|
|
109
|
+
"""A finite, non-negative RMSD."""
|
|
110
|
+
return score is not None and math.isfinite(float(score)) and float(score) >= 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def binding_site_failure(result):
|
|
114
|
+
"""Whether OST's reasons name a binding-site failure (not a graph or general failure)."""
|
|
115
|
+
if isinstance(result, dict):
|
|
116
|
+
return any(binding_site_failure(v) for v in result.values())
|
|
117
|
+
if isinstance(result, (list, tuple)):
|
|
118
|
+
return any(binding_site_failure(v) for v in result)
|
|
119
|
+
return result in ("target_binding_site", "model_binding_site", "binding_site")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""PoseBusters dock checks for every pose. The results are saved for use downstream, such as
|
|
2
|
+
choosing the top-ranked valid pose. CORE uses every pose whatever its result. Unknown never
|
|
3
|
+
counts as a pass."""
|
|
4
|
+
|
|
5
|
+
from core_score.outputs.files import clean, diagnostic, write_json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_posebusters(groups, output):
|
|
9
|
+
from posebusters import PoseBusters
|
|
10
|
+
|
|
11
|
+
buster = PoseBusters(config="dock", max_workers=0)
|
|
12
|
+
checks = dock_checks(buster)
|
|
13
|
+
records = [check_pose(buster, checks, pose) for group in groups for pose in group["poses"]]
|
|
14
|
+
write_json(output / "posebusters.json", records)
|
|
15
|
+
return records
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def dock_checks(buster):
|
|
19
|
+
"""Names of the binary checks in PoseBusters' dock configuration."""
|
|
20
|
+
return [
|
|
21
|
+
normalized(module.get("rename_outputs", {}).get(name, name))
|
|
22
|
+
for module in buster.config["modules"]
|
|
23
|
+
for name in module.get("chosen_binary_test_output", [])
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalized(name):
|
|
28
|
+
return name.lower().replace(" ", "_")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def aggregate(values):
|
|
32
|
+
"""False if any check failed, None if any is unknown (or there are none), otherwise True."""
|
|
33
|
+
values = list(values)
|
|
34
|
+
if any(v is False for v in values):
|
|
35
|
+
return False
|
|
36
|
+
if not values or any(v is None for v in values):
|
|
37
|
+
return None
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_pose(buster, checks, pose):
|
|
42
|
+
"""A pose is valid only if every ligand copy passes every dock check. Each check's result
|
|
43
|
+
for the pose combines its copies the same way."""
|
|
44
|
+
record = dict(ensemble=pose["ensemble"], pose_id=pose["pose_id"], pb_valid=None,
|
|
45
|
+
checks=dict.fromkeys(checks))
|
|
46
|
+
if pose.get("error"):
|
|
47
|
+
return dict(record, error="Pose was not prepared; see details/prepared_manifest.json")
|
|
48
|
+
prepared = pose["prepared"]
|
|
49
|
+
copies = [check_copy(buster, checks, copy, prepared["receptor"]) for copy in prepared["copy_records"]]
|
|
50
|
+
combined = {name: aggregate(copy.get("checks", {}).get(name) for copy in copies) for name in checks}
|
|
51
|
+
return dict(record, pb_valid=aggregate(copy["pb_valid"] for copy in copies), checks=combined, copies=copies)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_copy(buster, checks, copy, receptor):
|
|
55
|
+
record = dict(copy_id=copy["copy_id"], preparation=copy["provenance"], pb_valid=None)
|
|
56
|
+
if copy["pb_error"]:
|
|
57
|
+
return dict(record, error=copy["pb_error"])
|
|
58
|
+
try:
|
|
59
|
+
report = buster.bust(copy["pb_sdf"], mol_cond=receptor, full_report=True)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
return dict(record, **diagnostic("posebusters", exc))
|
|
62
|
+
tests = {normalized(k): clean(v) for k, v in report.iloc[0].items()}
|
|
63
|
+
results = {name: tests.get(name) if isinstance(tests.get(name), bool) else None for name in checks}
|
|
64
|
+
return dict(record, pb_valid=aggregate(results.values()), checks=results, tests=tests)
|
|
65
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Optional comparison of every prediction against its ensemble's ground-truth structure.
|
|
2
|
+
|
|
3
|
+
A pose is correct at threshold T when its ligand RMSD to the reference is below T and its
|
|
4
|
+
lDDT-PLI is above 0.8, the definition used in the paper."""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
from core_score.outputs.files import fingerprint, write_json
|
|
8
|
+
from core_score.analysis.pairs import run_pair_tasks
|
|
9
|
+
from core_score.inputs.prepare import prepare
|
|
10
|
+
|
|
11
|
+
LDDT_PLI_CUTOFF = 0.8
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def evaluate_groups(groups, output, thresholds=(2.0,), *, workers=1, timeout=None, radius_fallback=True):
|
|
15
|
+
"""Ligand RMSD and lDDT-PLI of each prepared prediction to its reference, labelled correct
|
|
16
|
+
or not at each threshold. Ensembles without a reference are skipped. Reference results
|
|
17
|
+
never enter CORE."""
|
|
18
|
+
groups = [group for group in groups if "reference" in group]
|
|
19
|
+
tasks, records = [], []
|
|
20
|
+
for group in groups:
|
|
21
|
+
destination = output / "references" / fingerprint(group["ensemble"])[:20] / "reference.cif"
|
|
22
|
+
reference = prepare(group["reference"], destination, timeout=timeout)
|
|
23
|
+
for pose in group["poses"]:
|
|
24
|
+
error = reference.get("error") or pose.get("error")
|
|
25
|
+
if error:
|
|
26
|
+
records.append(dict(ensemble=group["ensemble"], pose_id=pose["pose_id"], rmsd=None, error=error))
|
|
27
|
+
else:
|
|
28
|
+
tasks.append((dict(group, site="reference"), pose, reference))
|
|
29
|
+
for row, detail in run_pair_tasks(tasks, workers, timeout=timeout, radius_fallback=radius_fallback, lddt_pli=True):
|
|
30
|
+
records.append(dict(row, pose_id=row["pose_i"], detail=detail))
|
|
31
|
+
write_json(output / "reference_details.json", records)
|
|
32
|
+
keys = ((group["ensemble"], pose["pose_id"]) for group in groups for pose in group["poses"])
|
|
33
|
+
order = {key: i for i, key in enumerate(keys)}
|
|
34
|
+
labels = label_reference_results(records, thresholds)
|
|
35
|
+
return sorted(labels, key=lambda label: order[label["ensemble"], label["pose_id"]])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def correct_column(threshold):
|
|
39
|
+
"""The column saying whether a pose is correct at `threshold`."""
|
|
40
|
+
return f"correct_{threshold:g}A"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def label_reference_results(records, thresholds):
|
|
44
|
+
"""One row per pose. A pose is correct at T when RMSD < T and lDDT-PLI > 0.8. A failed or
|
|
45
|
+
ambiguous comparison, or one missing either score, is unknown (blank scores and labels),
|
|
46
|
+
not incorrect."""
|
|
47
|
+
labels = []
|
|
48
|
+
for record in records:
|
|
49
|
+
rmsd, lddt_pli = record.get("rmsd"), record.get("lddt_pli")
|
|
50
|
+
known = not record.get("error") and all(v is not None and math.isfinite(v) for v in (rmsd, lddt_pli))
|
|
51
|
+
label = dict(ensemble=record["ensemble"], pose_id=record["pose_id"],
|
|
52
|
+
rmsd=rmsd if known else None, lddt_pli=lddt_pli if known else None)
|
|
53
|
+
for threshold in thresholds:
|
|
54
|
+
label[correct_column(threshold)] = bool(rmsd < threshold and lddt_pli > LDDT_PLI_CUTOFF) if known else None
|
|
55
|
+
label["radius_fallback_used"] = record.get("radius_fallback_used", False)
|
|
56
|
+
error = record.get("error")
|
|
57
|
+
label["error"] = (error["error"] if isinstance(error, dict) else error) or None # message only; details in JSON
|
|
58
|
+
labels.append(label)
|
|
59
|
+
return labels
|
core_score/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""One command: load inputs and run the analysis."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import math
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from core_score.pipeline import run
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def positive(value):
|
|
11
|
+
number = float(value)
|
|
12
|
+
if not math.isfinite(number) or number <= 0:
|
|
13
|
+
raise argparse.ArgumentTypeError("must be finite and positive")
|
|
14
|
+
return number
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fraction(value):
|
|
18
|
+
number = float(value)
|
|
19
|
+
if not 0 <= number <= 1:
|
|
20
|
+
raise argparse.ArgumentTypeError("must be between 0 and 1")
|
|
21
|
+
return number
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def worker_count(value):
|
|
25
|
+
number = int(value)
|
|
26
|
+
if number < 1:
|
|
27
|
+
raise argparse.ArgumentTypeError("must be at least 1")
|
|
28
|
+
return number
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parser():
|
|
32
|
+
p = argparse.ArgumentParser(
|
|
33
|
+
prog="core-score",
|
|
34
|
+
description="Prepare structures, run PoseBusters, compare poses and calculate CORE.",
|
|
35
|
+
epilog="Example: core-score inputs.csv --out results/ --workers 4. Make inputs.csv with core-score-csv.",
|
|
36
|
+
)
|
|
37
|
+
p.add_argument("inputs", metavar="INPUT", nargs="+", type=Path,
|
|
38
|
+
help="CSV of ensembles, or structure files forming one ensemble")
|
|
39
|
+
p.add_argument("--out", type=Path, required=True, help="Results directory")
|
|
40
|
+
p.add_argument("--smiles", help="Ligand SMILES for structure files; a CSV sets its own")
|
|
41
|
+
p.add_argument("--ligand-chain", dest="ligand_chains", action="append",
|
|
42
|
+
help="Ligand chain for structure files; repeat for identical copies")
|
|
43
|
+
p.add_argument("--workers", type=worker_count, default=1,
|
|
44
|
+
help="Simultaneous OST comparisons (default: 1)")
|
|
45
|
+
p.add_argument("--timeout", type=positive, default=None,
|
|
46
|
+
help="Seconds per OST call (default: no timeout)")
|
|
47
|
+
p.add_argument("--no-radius-fallback", action="store_true",
|
|
48
|
+
help="Disable eligible binding-site retries at 6 and 8 angstroms")
|
|
49
|
+
p.add_argument("--skip-posebusters", action="store_true", help="Do not run PoseBusters checks")
|
|
50
|
+
p.add_argument("--reference", type=Path,
|
|
51
|
+
help="Ground-truth structure for structure files; a CSV sets its own")
|
|
52
|
+
p.add_argument("--rmsd-thresholds", type=positive, nargs="+", default=[2.0],
|
|
53
|
+
help="Thresholds T in angstroms: CORE is calculated at each, and a pose is "
|
|
54
|
+
"correct when its RMSD to the reference is below T and its lDDT-PLI is "
|
|
55
|
+
"above 0.8 (default: 2)")
|
|
56
|
+
p.add_argument("--plot", action="store_true",
|
|
57
|
+
help="Also save core_scores.png: a dot per complex (needs matplotlib)")
|
|
58
|
+
p.add_argument("--core-cutoffs", type=fraction, nargs="+", default=[0.5, 0.6, 0.7, 0.8, 0.9],
|
|
59
|
+
help="CORE cutoffs: each complex is reported as above (CORE >= cutoff) or below "
|
|
60
|
+
"each one (default: 0.5 to 0.9)")
|
|
61
|
+
return p
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv=None):
|
|
65
|
+
words = sys.argv[1:] if argv is None else argv
|
|
66
|
+
args = parser().parse_args(words or ["--help"])
|
|
67
|
+
try:
|
|
68
|
+
summary = run(
|
|
69
|
+
args.inputs,
|
|
70
|
+
args.out,
|
|
71
|
+
smiles=args.smiles,
|
|
72
|
+
ligand_chains=args.ligand_chains,
|
|
73
|
+
reference=args.reference,
|
|
74
|
+
workers=args.workers,
|
|
75
|
+
timeout=args.timeout,
|
|
76
|
+
radius_fallback=not args.no_radius_fallback,
|
|
77
|
+
skip_posebusters=args.skip_posebusters,
|
|
78
|
+
thresholds=args.rmsd_thresholds,
|
|
79
|
+
cutoffs=args.core_cutoffs,
|
|
80
|
+
plot=args.plot,
|
|
81
|
+
)
|
|
82
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
83
|
+
print(f"core-score: {exc}", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
print(
|
|
86
|
+
f"Completed {summary['completed_comparisons']}/{summary['comparison_count']} "
|
|
87
|
+
f"pair comparisons; calculated {summary['score_count']} CORE scores."
|
|
88
|
+
)
|
|
89
|
+
print(f"Results saved to {args.out}")
|
|
90
|
+
return int(any(summary["status"].values()))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Read the inputs and prepare every pose."""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Load prediction ensembles from a CSV file, or from structure files given directly.
|
|
2
|
+
|
|
3
|
+
The CSV needs three columns. `poses` is one structure file, or a wildcard matching many:
|
|
4
|
+
|
|
5
|
+
ensemble,smiles,poses
|
|
6
|
+
7ly6,CC(=O)Nc1ccc(O)cc1,predictions/7ly6/*.cif
|
|
7
|
+
|
|
8
|
+
Optional columns are confidence (one pose per row), ligand_chain (space separated when the
|
|
9
|
+
ligand has several copies), reference (the ground-truth structure) and ligand_sdf (the
|
|
10
|
+
ligand in the structure's frame, for docking outputs). Paths are relative to the CSV.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import glob
|
|
15
|
+
import os
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from core_score.analysis.core import NEIGHBOURS
|
|
19
|
+
from core_score.inputs.structures import template_mol
|
|
20
|
+
|
|
21
|
+
REQUIRED = ("ensemble", "smiles", "poses")
|
|
22
|
+
OPTIONAL = ("confidence", "ligand_chain", "reference", "ligand_sdf")
|
|
23
|
+
MIN_POSES = NEIGHBOURS + 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_inputs(inputs, *, smiles=None, ligand_chains=None, reference=None):
|
|
27
|
+
"""Ensemble records from one CSV, or from structure files forming one ensemble.
|
|
28
|
+
|
|
29
|
+
Poses are sorted by path, never kept in listing order. That order fixes the
|
|
30
|
+
site-assignment anchor and which pose of each pair OST treats as the model, so
|
|
31
|
+
reordering the input never changes a score. Pose IDs are paths relative to the CSV.
|
|
32
|
+
"""
|
|
33
|
+
inputs = [Path(path) for path in inputs]
|
|
34
|
+
if len(inputs) == 1 and inputs[0].suffix.lower() == ".csv":
|
|
35
|
+
base, rows = inputs[0].resolve().parent, read_csv(inputs[0])
|
|
36
|
+
else:
|
|
37
|
+
base = Path.cwd()
|
|
38
|
+
rows = [dict(ensemble="ensemble", smiles=smiles or "", poses=str(path),
|
|
39
|
+
ligand_chain=" ".join(ligand_chains or []), reference=str(reference or ""))
|
|
40
|
+
for path in inputs]
|
|
41
|
+
ensembles = defaultdict(list)
|
|
42
|
+
for row in rows:
|
|
43
|
+
ensembles[row["ensemble"]].append({k: v.strip() for k, v in row.items() if v and v.strip()})
|
|
44
|
+
groups = [make_group(name, members, base) for name, members in ensembles.items()]
|
|
45
|
+
small = [f"{group['ensemble']} ({len(group['poses'])})" for group in groups if len(group["poses"]) < MIN_POSES]
|
|
46
|
+
if small:
|
|
47
|
+
raise ValueError(f"CORE needs at least {MIN_POSES} poses per ensemble, since each pose is scored "
|
|
48
|
+
f"against its {NEIGHBOURS} closest others. Too few: {', '.join(small)}")
|
|
49
|
+
return groups
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_csv(path):
|
|
53
|
+
with Path(path).open(newline="") as handle:
|
|
54
|
+
reader = csv.DictReader(handle)
|
|
55
|
+
columns = reader.fieldnames or []
|
|
56
|
+
missing = [c for c in REQUIRED if c not in columns]
|
|
57
|
+
unknown = [c for c in columns if c not in REQUIRED + OPTIONAL]
|
|
58
|
+
if missing or unknown:
|
|
59
|
+
raise ValueError(f"{path}: missing columns {missing}, unknown columns {unknown}. "
|
|
60
|
+
f"The CSV needs {', '.join(REQUIRED)} and may add {', '.join(OPTIONAL)}.")
|
|
61
|
+
rows = list(reader)
|
|
62
|
+
blank = [i + 2 for i, row in enumerate(rows) if not all((row[c] or "").strip() for c in REQUIRED)]
|
|
63
|
+
if blank:
|
|
64
|
+
raise ValueError(f"{path}: lines {blank} leave ensemble, smiles or poses empty")
|
|
65
|
+
return rows
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_group(name, rows, base):
|
|
69
|
+
"""One ensemble from its CSV rows. Every row must give the same SMILES and reference."""
|
|
70
|
+
for column in ("smiles", "reference"):
|
|
71
|
+
if len({row.get(column) for row in rows}) > 1:
|
|
72
|
+
raise ValueError(f"{name}: every row of an ensemble needs the same {column}")
|
|
73
|
+
try:
|
|
74
|
+
template_mol(rows[0].get("smiles", ""))
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise ValueError(f"{name}: {exc}. Give the ligand SMILES in the CSV, or --smiles with structure files.") from exc
|
|
77
|
+
poses = [pose for row in rows for pose in make_poses(name, row, base)]
|
|
78
|
+
paths = [pose["path"] for pose in poses]
|
|
79
|
+
if len(set(paths)) != len(paths):
|
|
80
|
+
raise ValueError(f"{name}: a structure is listed more than once")
|
|
81
|
+
if len({"confidence" in pose for pose in poses}) != 1:
|
|
82
|
+
raise ValueError(f"{name}: give a confidence for every pose or for none")
|
|
83
|
+
group = dict(ensemble=name, poses=sorted(poses, key=lambda pose: pose["path"]))
|
|
84
|
+
if rows[0].get("reference"):
|
|
85
|
+
path = resolve(base, rows[0]["reference"])
|
|
86
|
+
group["reference"] = dict(ensemble=name, pose_id="reference", path=path, smiles=rows[0]["smiles"])
|
|
87
|
+
return group
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def make_poses(name, row, base):
|
|
91
|
+
"""The poses one CSV row names: one file, or every file its wildcard matches."""
|
|
92
|
+
paths = sorted(glob.glob(str(base / row["poses"]), recursive=True))
|
|
93
|
+
if not paths:
|
|
94
|
+
raise FileNotFoundError(f"{name}: no structure file matches {row['poses']}")
|
|
95
|
+
single = {k: row[k] for k in ("confidence", "ligand_sdf") if k in row}
|
|
96
|
+
if single and len(paths) > 1:
|
|
97
|
+
raise ValueError(f"{name}: {row['poses']} matches {len(paths)} files, so give "
|
|
98
|
+
f"{' and '.join(single)} on a row of its own for each pose")
|
|
99
|
+
poses = []
|
|
100
|
+
for path in paths:
|
|
101
|
+
path = str(Path(path).resolve())
|
|
102
|
+
pose = dict(ensemble=name, pose_id=Path(os.path.relpath(path, base)).as_posix(), path=path,
|
|
103
|
+
smiles=row["smiles"], ligand_chains=row.get("ligand_chain", "").split())
|
|
104
|
+
if "confidence" in row:
|
|
105
|
+
pose["confidence"] = float(row["confidence"])
|
|
106
|
+
if "ligand_sdf" in row:
|
|
107
|
+
pose["ligand_sdfs"] = [resolve(base, row["ligand_sdf"])]
|
|
108
|
+
poses.append(pose)
|
|
109
|
+
return poses
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve(base, path):
|
|
113
|
+
return str((base / path).resolve(strict=True))
|