Evaluate ligand poses and protein interfaces#
Compare predictions with PLINDER references using OpenStructure, and check ligand plausibility with PoseBusters.
Evaluate two Boltz predictions for the 8c3u protein–ligand complex from Runs N’ Poses and a GeoDock prediction for the 2e31 protein interface from PINDER’s evaluation tutorial.
Set up#
Install OpenStructure 2.12.0 or newer and the evaluation extra in your notebook environment:
conda install -c conda-forge -c bioconda 'openstructure>=2.12.0'
pip install 'plinder[eval]'
The ost command must be on your PATH. Set PLINDER_DATA_DIR to use a local release; otherwise PLINDER uses the configured release. The evaluation reference describes the options and output files.
import os
import tempfile
from pathlib import Path
from urllib.request import urlretrieve
import yaml
import pandas as pd
from plinder.core import PlinderRelease
from plinder.eval import evaluate_predictions
data_dir = os.environ.get("PLINDER_DATA_DIR")
release = PlinderRelease(data_dir=Path(data_dir)) if data_dir else PlinderRelease()
work_dir = Path(
os.environ.get("PLINDER_EVAL_WORK_DIR")
or tempfile.mkdtemp(prefix="plinder-evaluation-")
).resolve()
predictions = work_dir / "predictions"
work_dir
/home/runner/micromamba/envs/plinder/lib/python3.12/site-packages/vcs_versioning/_backends/_git.py:709: UserWarning: "/home/runner/work/plinder/plinder" is shallow and may cause errors
pre_parse(wd)
PosixPath('/tmp/plinder-evaluation-o9tu1crr')
Choose prediction files#
Search and evaluation share an input table with input_id, structure_path, and optional ligand_path. Evaluation adds a reference_id: a ligand-system ID, an interface ID, or a PDB ID. The evaluation reference describes the columns and folder alternative.
predictions/
├── <ligand-system ID>/
│ ├── boltz_seed_1372115236.cif
│ └── boltz_seed_555476360.cif
└── <interface ID>/
└── geodock_model_1.pdb
For your own models, use the same folder layout and skip this example-data setup. Interface evaluation accepts PDB or mmCIF files. For ligand evaluation, pair a receptor model.pdb or model.cif with model.sdf, or a model.ligands/ folder containing one SDF per ligand. Each prediction then carries its own ligand chemistry and coordinates.
Complete-mmCIF predictions are also supported, as shown with the two Boltz models below. Their input YAML provides the ligand SMILES.
system_id = "8c3u__1__1.A__1.C"
revision = "78bfe19e15b086e39de4e03f30142e0dcb38beb0"
example_url = f"https://raw.githubusercontent.com/plinder-org/runs-n-poses/{revision}/examples"
ligand_folder = predictions / system_id
ligand_folder.mkdir(parents=True, exist_ok=True)
for seed in (1372115236, 555476360):
model = ligand_folder / f"boltz_seed_{seed}.cif"
if not model.is_file():
urlretrieve(
f"{example_url}/outputs/boltz/{system_id}/{seed}/"
"boltz_results_input/predictions/input/input_model_0.cif",
model,
)
input_yaml = work_dir / "boltz_input.yaml"
if not input_yaml.is_file():
urlretrieve(f"{example_url}/inputs/boltz/{system_id}/input.yaml", input_yaml)
boltz_input = yaml.safe_load(input_yaml.read_text())
ligand_smiles = next(
item["ligand"]["smiles"] for item in boltz_input["sequences"] if "ligand" in item
)
interface_id = "2e31__1__1.A--1.B"
interface_folder = predictions / interface_id
interface_folder.mkdir(parents=True, exist_ok=True)
pinder_revision = "20487ac403516f453d8be1b0bdc07a9f7a77d9df"
pinder_id = "2e31__A1_Q80UW2--2e31__B1_P63208"
geodock_pdb = interface_folder / "geodock_model_1.pdb"
if not geodock_pdb.is_file():
urlretrieve(
f"https://raw.githubusercontent.com/pinder-org/pinder/{pinder_revision}/"
f"tests/test_data/method_eval/geodock/{pinder_id}/holo_decoys/model_1.pdb",
geodock_pdb,
)
input_table = pd.DataFrame([
{
"input_id": path.stem,
"structure_path": str(path.resolve()),
"reference_id": path.parent.name,
}
for path in sorted(predictions.glob("*/*")) if path.is_file()
])
input_table
| input_id | structure_path | reference_id | |
|---|---|---|---|
| 0 | geodock_model_1 | /tmp/plinder-evaluation-o9tu1crr/predictions/2... | 2e31__1__1.A--1.B |
| 1 | boltz_seed_1372115236 | /tmp/plinder-evaluation-o9tu1crr/predictions/8... | 8c3u__1__1.A__1.C |
| 2 | boltz_seed_555476360 | /tmp/plinder-evaluation-o9tu1crr/predictions/8... | 8c3u__1__1.A__1.C |
Evaluate the predictions#
One call evaluates both kinds of prediction. Choose mode="ligands" or mode="interfaces" for just one kind. num_workers limits the number of predictions processed at once.
By default, ligand results cover proper reference ligands. Set include_all_ligands=True to include reference ions and artifacts too.
The two Boltz predictions share the same ligand, so pass its SMILES as ligand_smiles={"LIG": ligand_smiles}. A SMILES must follow the CIF heavy-atom order. With complete-mmCIF inputs, ligand_ccd_codes supplies CCD templates and ligand_chains=["B"] selects a peptide ligand by its label asym ID. For receptor + SDF inputs, the SDF files supply the chemistry.
results = evaluate_predictions(
input_table,
output_dir=work_dir / "results",
release=release,
mode="both",
num_workers=2,
ligand_smiles={"LIG": ligand_smiles},
)
display(results["failures"])
assert results["failures"].empty, "Inspect the errors and tool logs before interpreting results."
/home/runner/micromamba/envs/plinder/lib/python3.12/site-packages/vcs_versioning/_backends/_git.py:709: UserWarning: "/home/runner/work/plinder/plinder" is shallow and may cause errors
pre_parse(wd)
/home/runner/micromamba/envs/plinder/lib/python3.12/site-packages/vcs_versioning/_backends/_git.py:709: UserWarning: "/home/runner/work/plinder/plinder" is shallow and may cause errors
pre_parse(wd)
| prediction | system_id | stage | error |
|---|
Read ligand results#
Each row represents one reference ligand and one prediction. rmsd is the binding-site-superposed, symmetry-corrected ligand RMSD; bb_rmsd describes the backbone fit, lddt_lp the local binding-site structure, and lddt_pli the protein–ligand contacts. Lower RMSD is better; lDDT values range from zero to one.
status="success" means the comparison completed. Both Boltz examples here have a large ligand RMSD. A missing or unassigned reference ligand has a row with unavailable metrics and an unassigned reason.
ligands = results["ligands"]
ligands[
[
"prediction", "ligand_id", "status",
"rmsd", "bb_rmsd", "lddt_lp", "lddt_pli",
"rmsd_unassigned", "lddt_pli_unassigned",
]
]
| prediction | ligand_id | status | rmsd | bb_rmsd | lddt_lp | lddt_pli | rmsd_unassigned | lddt_pli_unassigned | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | boltz_seed_1372115236 | 8c3u__1__1.C | success | 21.360781 | 2.621234 | 0.639289 | 0.000949 | NaN | NaN |
| 1 | boltz_seed_555476360 | 8c3u__1__1.C | success | 21.320765 | 2.660444 | 0.638495 | 0.000000 | NaN | NaN |
Attach PoseBusters checks#
PoseBusters checks all candidate predicted ligands. To attach those checks to a reference-ligand result, use the predicted ligand selected by the metric you are analysing. RMSD and lDDT-PLI can choose different assignments.
Below, the join follows rmsd_model_ligand. For lDDT-PLI, use lddt_pli_model_ligand instead. PoseBusters uses each prediction’s own receptor coordinates. Failed checks appear as False; PoseBusters fields for missing ligands are empty.
checks = results["posebusters"][
[
"prediction", "model_ligand", "sanitization",
"internal_steric_clash", "minimum_distance_to_protein",
]
]
ligands_with_checks = ligands.merge(
checks,
left_on=["prediction", "rmsd_model_ligand"],
right_on=["prediction", "model_ligand"],
how="left",
validate="many_to_one",
)
ligands_with_checks[
[
"prediction", "ligand_id", "rmsd_model_ligand", "rmsd",
"sanitization", "internal_steric_clash", "minimum_distance_to_protein",
]
]
| prediction | ligand_id | rmsd_model_ligand | rmsd | sanitization | internal_steric_clash | minimum_distance_to_protein | |
|---|---|---|---|---|---|---|---|
| 0 | boltz_seed_1372115236 | 8c3u__1__1.C | B | 21.360781 | True | True | False |
| 1 | boltz_seed_555476360 | 8c3u__1__1.C | B | 21.320765 | True | True | False |
Read interface results#
OST maps the GeoDock model’s chains R and L to reference chains 1.A and 1.B.
ilddt describes local agreement at the interface; QS-score describes assembly agreement. Use dockq_ave_full or dockq_wave_full when comparing predictions that may have missing chains: these include the penalty for unmapped reference interfaces.
results["interfaces"][
[
"prediction", "system_id", "status", "lddt", "ilddt",
"qs_global", "qs_best", "dockq_ave_full", "dockq_wave_full",
]
]
| prediction | system_id | status | lddt | ilddt | qs_global | qs_best | dockq_ave_full | dockq_wave_full | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | geodock_model_1 | 2e31__1__1.A--1.B | success | 0.216 | 0.029 | 0.187 | 0.187 | 0.066 | 0.066 |
Access reference-ligand assignments in results["ligands"] using rmsd_model_ligand and lddt_pli_model_ligand, with reasons for unmatched ligands in rmsd_unassigned and lddt_pli_unassigned.
Reload saved tables with pandas, for example pd.read_parquet(work_dir / "results" / "ligands.parquet").