bindsight 0.3.5__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.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
bindsight/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# `bindsight/` — the package
|
|
2
|
+
|
|
3
|
+
Over 75 modules. The pipeline runs in stages, and the directories follow them.
|
|
4
|
+
|
|
5
|
+
## The discovery half (CPU, no GPU)
|
|
6
|
+
|
|
7
|
+
| Module | What it does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| [`io/`](io/) | Readers and writers: Parquet, FASTA, PDB, mmCIF, manifests, and the NIH/GDC and cBioPortal clients that fetch a cohort |
|
|
10
|
+
| [`deg/`](deg/) | Differential expression. A pydeseq2 wrapper with a cache key covering the library version, because an upgraded DESeq2 moves log2 fold changes |
|
|
11
|
+
| [`targets/`](targets/) | Open Targets client, ENSG→UniProt resolution, and the GTEx normal-tissue safety gate |
|
|
12
|
+
| [`surfaceome/`](surfaceome/) | Which proteins reach the cell surface: the SURFY list plus a UniProt curated-membrane extension, vendored so a run never depends on a live fetch |
|
|
13
|
+
| [`structures/`](structures/) | AlphaFoldDB structures, pLDDT disorder gating, UniProt topology |
|
|
14
|
+
| [`epitopes/`](epitopes/) | SURFACE-Bind targetable-site lookup |
|
|
15
|
+
|
|
16
|
+
## The design half (GPU, via a backend)
|
|
17
|
+
|
|
18
|
+
| Module | What it does |
|
|
19
|
+
|---|---|
|
|
20
|
+
| [`design/`](design/) | The designer plugin interface, developability scoring, ESM-2 embeddings |
|
|
21
|
+
| [`runners/`](runners/) | Where GPU work goes: Kaggle, Modal, local Docker, Colab. See the runner status table in the [root README](../README.md) — they do not carry equal evidence |
|
|
22
|
+
| [`validate/`](validate/) | Boltz-2 by default; Chai-1r and AF2-initial-guess behind flags |
|
|
23
|
+
| [`rank/`](rank/) | Multi-objective composite scoring of validated binders |
|
|
24
|
+
|
|
25
|
+
## Across both
|
|
26
|
+
|
|
27
|
+
| Module | What it does |
|
|
28
|
+
|---|---|
|
|
29
|
+
| [`pipelines/`](pipelines/) | The discovery orchestrator, and the caveats it attaches to its own output |
|
|
30
|
+
| [`provenance/`](provenance/) | PROV-O JSON-LD emission — the audit trail from a binder back to the cohort |
|
|
31
|
+
| [`export/`](export/) | The RO-Crate exporter — that audit trail packaged as a citable deposit |
|
|
32
|
+
| [`benchmark/`](benchmark/) | The scoring harnesses: rediscovery study, designer benchmark, and the statistics behind every published interval |
|
|
33
|
+
| [`report/`](report/) | The HTML report and the web interface |
|
|
34
|
+
| [`config.py`](config.py) | Pydantic run-configuration models — the single definition of what a run config may contain |
|
|
35
|
+
| [`cli.py`](cli.py) | The Click entry point for every `bindsight` command |
|
|
36
|
+
| [`cost.py`](cost.py) | GPU cost estimation, so `--dry-run` can price a job before it runs |
|
|
37
|
+
| [`plugins.py`](plugins.py) | Entry-point loading for third-party designers and validators |
|
|
38
|
+
|
|
39
|
+
## A note on what lives where
|
|
40
|
+
|
|
41
|
+
Statistics live in [`benchmark/statistics.py`](benchmark/statistics.py), not
|
|
42
|
+
scattered through the reporting code, because every published interval has to
|
|
43
|
+
come from one implementation that is tested once. If you are checking how a
|
|
44
|
+
number was computed, start there.
|
bindsight/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""bindsight — a reproducible bridge from RNA-seq to de novo protein binder design.
|
|
4
|
+
|
|
5
|
+
See https://github.com/mikhaeelatefrizk/bindsight for documentation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("bindsight")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
__version__ = "0.0.0+unknown"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Rediscovery benchmarking against the held-out known-antigen set.
|
|
4
|
+
|
|
5
|
+
Scores one or more finished ``bindsight`` run directories by how well they
|
|
6
|
+
resurface the literature-validated known antigens in ``benchmarks/known.tsv``
|
|
7
|
+
(rank of each known antigen, recall@k, enrichment), and renders a side-by-side
|
|
8
|
+
HTML report. This is the implementation behind the ``bindsight benchmark``
|
|
9
|
+
command referenced in ``docs/use-cases.md``.
|
|
10
|
+
|
|
11
|
+
Every public name here except ``DEFAULT_KS`` is resolved lazily, and
|
|
12
|
+
``DEFAULT_KS`` is the reason the rest are. Importing them all eagerly pulled
|
|
13
|
+
:mod:`bindsight.benchmark.core`, and therefore pandas, into anything that
|
|
14
|
+
touched this package by any route -- including ``from
|
|
15
|
+
bindsight.benchmark.defaults import DEFAULT_KS``, which needs nothing but a
|
|
16
|
+
tuple. So that one name is bound here directly and costs nothing; everything
|
|
17
|
+
that needs pandas waits until it is asked for. :mod:`bindsight.cli` reads that tuple at import time, so a plain ``pip
|
|
18
|
+
install bindsight`` (pandas lives in the ``discover`` extra) shipped a command
|
|
19
|
+
that raised ``ModuleNotFoundError`` before ``--version`` could print.
|
|
20
|
+
|
|
21
|
+
The lazy hook must raise ``AttributeError`` (never ``ImportError``) for unknown
|
|
22
|
+
names, for the reason :mod:`bindsight.report` gives: ``from bindsight.benchmark
|
|
23
|
+
import core`` consults ``__getattr__`` before the submodule import machinery,
|
|
24
|
+
so swallowing the error here would break every such import in the codebase.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from bindsight.benchmark.defaults import DEFAULT_KS
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
34
|
+
from bindsight.benchmark.core import (
|
|
35
|
+
KnownAntigen,
|
|
36
|
+
RunScore,
|
|
37
|
+
load_known_antigens,
|
|
38
|
+
render_benchmark_html,
|
|
39
|
+
run_benchmark,
|
|
40
|
+
score_run,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Spelled out as a literal, because mypy reads ``__all__`` statically and a list
|
|
44
|
+
# built with a splat tells it nothing -- it then reports every re-export as not
|
|
45
|
+
# explicitly exported. ``_LAZY`` is derived from it rather than written twice.
|
|
46
|
+
__all__ = [
|
|
47
|
+
"DEFAULT_KS",
|
|
48
|
+
"KnownAntigen",
|
|
49
|
+
"RunScore",
|
|
50
|
+
"load_known_antigens",
|
|
51
|
+
"render_benchmark_html",
|
|
52
|
+
"run_benchmark",
|
|
53
|
+
"score_run",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: Everything in ``__all__`` except the one name that costs nothing to import.
|
|
57
|
+
_LAZY = frozenset(__all__) - {"DEFAULT_KS"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def __getattr__(name: str) -> Any:
|
|
61
|
+
"""Resolve the scoring API on first access (PEP 562)."""
|
|
62
|
+
if name in _LAZY:
|
|
63
|
+
import bindsight.benchmark.core as _core
|
|
64
|
+
|
|
65
|
+
return getattr(_core, name)
|
|
66
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __dir__() -> list[str]:
|
|
70
|
+
"""Keep ``dir()`` honest about the lazily-exported names."""
|
|
71
|
+
return sorted({*globals(), *__all__})
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Rediscovery-benchmark scoring + report rendering.
|
|
4
|
+
|
|
5
|
+
Given one or more finished run directories (each with
|
|
6
|
+
``targets/candidates.parquet`` from ``bindsight discover``) and a known-antigen
|
|
7
|
+
table (``benchmarks/known.tsv``), compute, per run:
|
|
8
|
+
|
|
9
|
+
- the rank of each known antigen in the candidate shortlist (by UniProt),
|
|
10
|
+
- whether it was found at all, and whether it landed in the top-k,
|
|
11
|
+
- recall@k aggregated across the run's *on-indication* known antigens.
|
|
12
|
+
|
|
13
|
+
Then render a self-contained HTML report comparing the runs side by side. The
|
|
14
|
+
math is intentionally simple and transparent so the benchmark is defensible:
|
|
15
|
+
recall@k is ``#{on-indication known antigens with rank ≤ k} /
|
|
16
|
+
#{on-indication known antigens}``.
|
|
17
|
+
|
|
18
|
+
Rediscovery is indication-scoped: a run of a colorectal cohort surfacing a
|
|
19
|
+
breast antigen has not rediscovered anything, so a known antigen is credited
|
|
20
|
+
only when its own ``tumor_type`` is the run's indication. Known antigens from
|
|
21
|
+
other indications that do appear in a shortlist are reported separately as
|
|
22
|
+
cross-indication cross-reactivity and never enter recall@k. A run whose
|
|
23
|
+
indication is not supplied is scored over the whole known set and labelled as
|
|
24
|
+
such, because the gate cannot be applied without it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import html
|
|
30
|
+
import logging
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
# Re-exported, not defined here. This module imports pandas at module scope and
|
|
37
|
+
# the CLI needs the value at import time, so defining it here made a bare
|
|
38
|
+
# install unable to run `bindsight --version`. Callers that already have
|
|
39
|
+
# pandas may keep importing it from either place.
|
|
40
|
+
from bindsight.benchmark.defaults import DEFAULT_KS
|
|
41
|
+
|
|
42
|
+
LOG = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
__all__ = ["DEFAULT_KS"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def portable_path(path: Path | str) -> str:
|
|
48
|
+
"""Render *path* for a published artifact.
|
|
49
|
+
|
|
50
|
+
Absolute paths from the machine that produced a run are not reproducible
|
|
51
|
+
and leak the operating environment, so record a repo-relative POSIX path
|
|
52
|
+
where we can and fall back to the bare filename where we cannot.
|
|
53
|
+
"""
|
|
54
|
+
resolved = Path(path).resolve()
|
|
55
|
+
here = Path(__file__).resolve()
|
|
56
|
+
for parent in [here.parent, *here.parents]:
|
|
57
|
+
if (parent / "pyproject.toml").exists():
|
|
58
|
+
try:
|
|
59
|
+
return resolved.relative_to(parent).as_posix()
|
|
60
|
+
except ValueError:
|
|
61
|
+
break
|
|
62
|
+
return resolved.name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class KnownAntigen:
|
|
67
|
+
"""One held-out known antigen we expect a rediscovery run to surface."""
|
|
68
|
+
|
|
69
|
+
symbol: str
|
|
70
|
+
uniprot: str
|
|
71
|
+
tumor_type: str = ""
|
|
72
|
+
disease: str = ""
|
|
73
|
+
expected_direction: str = "up"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class _Hit:
|
|
78
|
+
"""A known antigen's match in a run's candidate shortlist."""
|
|
79
|
+
|
|
80
|
+
rank: int
|
|
81
|
+
log2fc: float | None
|
|
82
|
+
padj: float | None
|
|
83
|
+
symbol: str | None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class RunScore:
|
|
88
|
+
"""Per-run rediscovery score over the known-antigen set.
|
|
89
|
+
|
|
90
|
+
``per_antigen`` holds one row per *on-indication* known antigen (the
|
|
91
|
+
recall@k denominator); ``cross_indication`` holds the known antigens from
|
|
92
|
+
other indications that happen to appear in the shortlist, which are
|
|
93
|
+
cross-reactivity observations rather than rediscoveries. ``recall_basis``
|
|
94
|
+
records which of the two regimes produced ``recall_at``:
|
|
95
|
+
``"on_indication"``, ``"indication_unknown"`` (no indication supplied for
|
|
96
|
+
the run), ``"no_known_antigen_for_indication"`` (nothing to score, so
|
|
97
|
+
``recall_at`` is left empty rather than reported as zero) or
|
|
98
|
+
``"candidates_unavailable"`` (the run's ``candidates.parquet`` was missing,
|
|
99
|
+
empty or unreadable -- the same rule applies, and used not to: every antigen
|
|
100
|
+
came back ``found=False`` and every cutoff reported 0%, which reads as a
|
|
101
|
+
ranking that missed everything rather than a run that was never read).
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
run_name: str
|
|
105
|
+
run_dir: str
|
|
106
|
+
tumor_type: str | None = None
|
|
107
|
+
recall_basis: str = "indication_unknown"
|
|
108
|
+
per_antigen: list[dict[str, object]] = field(default_factory=list) # on-indication antigens
|
|
109
|
+
cross_indication: list[dict[str, object]] = field(default_factory=list)
|
|
110
|
+
recall_at: dict[int, float] = field(default_factory=dict)
|
|
111
|
+
n_known: int = 0
|
|
112
|
+
n_on_indication: int = 0
|
|
113
|
+
n_found: int = 0
|
|
114
|
+
n_cross_indication: int = 0
|
|
115
|
+
n_candidates: int = 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Loading
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
def load_known_antigens(path: Path | str) -> list[KnownAntigen]:
|
|
122
|
+
"""Load ``known.tsv`` into a list of :class:`KnownAntigen`.
|
|
123
|
+
|
|
124
|
+
Requires at least ``symbol`` and ``uniprot`` columns; ``tumor_type``,
|
|
125
|
+
``disease`` and ``expected_direction`` are optional.
|
|
126
|
+
"""
|
|
127
|
+
df = pd.read_csv(path, sep="\t", dtype=str).fillna("")
|
|
128
|
+
missing = {"symbol", "uniprot"} - set(df.columns)
|
|
129
|
+
if missing:
|
|
130
|
+
raise ValueError(f"{path}: known-antigen table missing columns {sorted(missing)}")
|
|
131
|
+
return [
|
|
132
|
+
KnownAntigen(
|
|
133
|
+
symbol=row["symbol"],
|
|
134
|
+
uniprot=row["uniprot"],
|
|
135
|
+
tumor_type=row.get("tumor_type", ""),
|
|
136
|
+
disease=row.get("disease", ""),
|
|
137
|
+
expected_direction=row.get("expected_direction", "up"),
|
|
138
|
+
)
|
|
139
|
+
for _, row in df.iterrows()
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _load_candidates(run_dir: Path) -> pd.DataFrame | None:
|
|
144
|
+
path = run_dir / "targets" / "candidates.parquet"
|
|
145
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
146
|
+
LOG.warning("no candidates.parquet in %s", run_dir)
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
return pd.read_parquet(path)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
LOG.warning("failed to read %s: %s", path, e)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Scoring
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
def _matches_indication(antigen_tumor_type: str, run_tumor_type: str) -> bool:
|
|
159
|
+
"""True when a known antigen's own indication is the run's indication."""
|
|
160
|
+
return antigen_tumor_type.strip().casefold() == run_tumor_type.strip().casefold()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def score_run(
|
|
164
|
+
run_dir: Path | str,
|
|
165
|
+
known: list[KnownAntigen],
|
|
166
|
+
*,
|
|
167
|
+
ks: tuple[int, ...] = DEFAULT_KS,
|
|
168
|
+
run_name: str | None = None,
|
|
169
|
+
tumor_type: str | None = None,
|
|
170
|
+
) -> RunScore:
|
|
171
|
+
"""Score one run directory against the known-antigen set.
|
|
172
|
+
|
|
173
|
+
A known antigen is matched to a candidate row by UniProt accession
|
|
174
|
+
(``candidates.uniprot_id``). Its ``rank`` is taken from the candidate table
|
|
175
|
+
(the discover stage's 1-based rank). If the antigen never appears in the
|
|
176
|
+
candidates it is recorded as not-found (rank ``None``).
|
|
177
|
+
|
|
178
|
+
Protein identity alone is not rediscovery: an antigen is credited (and
|
|
179
|
+
enters recall@k) only when its ``tumor_type`` is also the run's indication.
|
|
180
|
+
Matches from other indications are collected in
|
|
181
|
+
:attr:`RunScore.cross_indication` instead.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
run_dir: a finished run directory containing ``targets/candidates.parquet``.
|
|
185
|
+
known: the known-antigen set to score against.
|
|
186
|
+
ks: top-k cutoffs for recall@k.
|
|
187
|
+
run_name: display name for the run (defaults to the directory name).
|
|
188
|
+
tumor_type: the run's indication, using the same vocabulary as the
|
|
189
|
+
known table's ``tumor_type`` column (e.g. ``"BRCA"``). When
|
|
190
|
+
``None`` the indication gate cannot be applied; every known antigen
|
|
191
|
+
is then scored and ``recall_basis`` says so.
|
|
192
|
+
"""
|
|
193
|
+
run_dir = Path(run_dir)
|
|
194
|
+
name = run_name or run_dir.name
|
|
195
|
+
cands = _load_candidates(run_dir)
|
|
196
|
+
|
|
197
|
+
# Build a uniprot -> _Hit lookup from the candidates.
|
|
198
|
+
rank_by_uniprot: dict[str, _Hit] = {}
|
|
199
|
+
n_candidates = 0
|
|
200
|
+
candidates_available = False
|
|
201
|
+
# A frame that loaded but carries no accession column is as unusable as one
|
|
202
|
+
# that never loaded: neither can say whether an antigen was ranked. Narrowed
|
|
203
|
+
# in the ``if`` rather than stored in a flag first, because a flag is opaque
|
|
204
|
+
# to the type checker and ``cands`` is Optional on the very next line.
|
|
205
|
+
if cands is not None and "uniprot_id" in cands.columns:
|
|
206
|
+
candidates_available = True
|
|
207
|
+
n_candidates = int(cands["uniprot_id"].notna().sum())
|
|
208
|
+
ranked = cands.dropna(subset=["uniprot_id"]).copy()
|
|
209
|
+
# Prefer an explicit 'rank' column; otherwise rank by row order.
|
|
210
|
+
if "rank" not in ranked.columns:
|
|
211
|
+
ranked = ranked.reset_index(drop=True)
|
|
212
|
+
ranked["rank"] = range(1, len(ranked) + 1)
|
|
213
|
+
for _, r in ranked.iterrows():
|
|
214
|
+
uid = str(r["uniprot_id"])
|
|
215
|
+
if uid not in rank_by_uniprot: # keep best (first) rank per uniprot
|
|
216
|
+
sym = r.get("symbol")
|
|
217
|
+
rank_by_uniprot[uid] = _Hit(
|
|
218
|
+
rank=int(r["rank"]),
|
|
219
|
+
log2fc=_f(r.get("log2fc")),
|
|
220
|
+
padj=_f(r.get("padj")),
|
|
221
|
+
symbol=str(sym) if pd.notna(sym) else None,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
on_indication: list[KnownAntigen] = []
|
|
225
|
+
off_indication: list[KnownAntigen] = []
|
|
226
|
+
for ka in known:
|
|
227
|
+
on = tumor_type is None or _matches_indication(ka.tumor_type, tumor_type)
|
|
228
|
+
(on_indication if on else off_indication).append(ka)
|
|
229
|
+
|
|
230
|
+
def _row(ka: KnownAntigen, hit: _Hit | None, on: bool | None) -> dict[str, object]:
|
|
231
|
+
return {
|
|
232
|
+
"symbol": ka.symbol,
|
|
233
|
+
"uniprot": ka.uniprot,
|
|
234
|
+
"tumor_type": ka.tumor_type,
|
|
235
|
+
"on_indication": on,
|
|
236
|
+
# ``None``, not ``False``, when there was no shortlist to look in.
|
|
237
|
+
# "We did not find it" and "we could not look" render identically
|
|
238
|
+
# otherwise, and only one of them is a result.
|
|
239
|
+
"found": (hit is not None) if candidates_available else None,
|
|
240
|
+
"rank": hit.rank if hit else None,
|
|
241
|
+
"log2fc": hit.log2fc if hit else None,
|
|
242
|
+
"padj": hit.padj if hit else None,
|
|
243
|
+
**{
|
|
244
|
+
f"in_top_{k}": (hit is not None and hit.rank <= k) if candidates_available else None
|
|
245
|
+
for k in ks
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
per_antigen = [
|
|
250
|
+
_row(ka, rank_by_uniprot.get(ka.uniprot), None if tumor_type is None else True)
|
|
251
|
+
for ka in on_indication
|
|
252
|
+
]
|
|
253
|
+
n_found = sum(1 for a in per_antigen if a["found"])
|
|
254
|
+
# Off-indication antigens are only worth reporting when they were surfaced;
|
|
255
|
+
# a miss in another cancer type is neither a rediscovery nor a failure.
|
|
256
|
+
cross_indication = [
|
|
257
|
+
_row(ka, hit, False)
|
|
258
|
+
for ka in off_indication
|
|
259
|
+
if (hit := rank_by_uniprot.get(ka.uniprot)) is not None
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
recall_at: dict[int, float] = {}
|
|
263
|
+
if on_indication and candidates_available:
|
|
264
|
+
for k in ks:
|
|
265
|
+
hits = sum(1 for a in per_antigen if a[f"in_top_{k}"])
|
|
266
|
+
recall_at[k] = hits / len(on_indication)
|
|
267
|
+
|
|
268
|
+
if not candidates_available:
|
|
269
|
+
basis = "candidates_unavailable"
|
|
270
|
+
elif tumor_type is None:
|
|
271
|
+
basis = "indication_unknown"
|
|
272
|
+
elif on_indication:
|
|
273
|
+
basis = "on_indication"
|
|
274
|
+
else:
|
|
275
|
+
basis = "no_known_antigen_for_indication"
|
|
276
|
+
|
|
277
|
+
return RunScore(
|
|
278
|
+
run_name=name,
|
|
279
|
+
run_dir=str(run_dir),
|
|
280
|
+
tumor_type=tumor_type,
|
|
281
|
+
recall_basis=basis,
|
|
282
|
+
per_antigen=per_antigen,
|
|
283
|
+
cross_indication=cross_indication,
|
|
284
|
+
recall_at=recall_at,
|
|
285
|
+
n_known=len(known),
|
|
286
|
+
n_on_indication=len(on_indication),
|
|
287
|
+
n_found=n_found,
|
|
288
|
+
n_cross_indication=len(cross_indication),
|
|
289
|
+
n_candidates=n_candidates,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _f(v: object) -> float | None:
|
|
294
|
+
if v is None or (isinstance(v, float) and pd.isna(v)):
|
|
295
|
+
return None
|
|
296
|
+
try:
|
|
297
|
+
return float(v) # type: ignore[arg-type]
|
|
298
|
+
except (TypeError, ValueError):
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Reporting
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
def render_benchmark_html(
|
|
306
|
+
scores: list[RunScore],
|
|
307
|
+
*,
|
|
308
|
+
ks: tuple[int, ...] = DEFAULT_KS,
|
|
309
|
+
known_source: str = "",
|
|
310
|
+
) -> str:
|
|
311
|
+
"""Render the benchmark scores as a self-contained HTML string."""
|
|
312
|
+
e = html.escape
|
|
313
|
+
|
|
314
|
+
def _cell(v: object) -> str:
|
|
315
|
+
if v is None:
|
|
316
|
+
return "<td class='miss'>—</td>"
|
|
317
|
+
if isinstance(v, bool):
|
|
318
|
+
return f"<td class='{'hit' if v else 'miss'}'>{'✓' if v else '·'}</td>"
|
|
319
|
+
if isinstance(v, float):
|
|
320
|
+
return f"<td>{v:.3g}</td>"
|
|
321
|
+
return f"<td>{e(str(v))}</td>"
|
|
322
|
+
|
|
323
|
+
# Summary table: one row per run, recall@k columns over the on-indication set.
|
|
324
|
+
summary_rows = ""
|
|
325
|
+
for s in scores:
|
|
326
|
+
cells = "".join(
|
|
327
|
+
f"<td>{s.recall_at[k]:.0%}</td>" if k in s.recall_at else "<td class='miss'>n/a</td>"
|
|
328
|
+
for k in ks
|
|
329
|
+
)
|
|
330
|
+
indication = e(s.tumor_type) if s.tumor_type else "<span class='miss'>unknown</span>"
|
|
331
|
+
# A run whose candidate table could not be read has no "found" count and
|
|
332
|
+
# no candidate count. The recall columns already render n/a for it; these
|
|
333
|
+
# two printed a hard 0, which reads as a measured miss.
|
|
334
|
+
unread = s.recall_basis == "candidates_unavailable"
|
|
335
|
+
found_cell = (
|
|
336
|
+
"<td class='miss'>n/a</td>" if unread else f"<td>{s.n_found}/{s.n_on_indication}</td>"
|
|
337
|
+
)
|
|
338
|
+
candidates_cell = "<td class='miss'>n/a</td>" if unread else f"<td>{s.n_candidates}</td>"
|
|
339
|
+
summary_rows += (
|
|
340
|
+
f"<tr><td class='name'>{e(s.run_name)}</td><td>{indication}</td>"
|
|
341
|
+
f"{found_cell}<td>{s.n_cross_indication}</td>"
|
|
342
|
+
f"{candidates_cell}{cells}</tr>"
|
|
343
|
+
)
|
|
344
|
+
recall_headers = "".join(f"<th>recall@{k}</th>" for k in ks)
|
|
345
|
+
|
|
346
|
+
# Per-run detail tables.
|
|
347
|
+
detail_blocks = ""
|
|
348
|
+
topk_headers = "".join(f"<th>top{k}</th>" for k in ks)
|
|
349
|
+
antigen_headers = (
|
|
350
|
+
"<tr><th>antigen</th><th>uniprot</th><th>tumor</th>"
|
|
351
|
+
f"<th>found</th><th>rank</th><th>log2fc</th>{topk_headers}</tr>"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _rank_key(a: dict[str, object]) -> tuple[bool, int]:
|
|
355
|
+
r = a["rank"]
|
|
356
|
+
return (r is None, r if isinstance(r, int) else 0)
|
|
357
|
+
|
|
358
|
+
def _antigen_table(antigens: list[dict[str, object]]) -> str:
|
|
359
|
+
rows = ""
|
|
360
|
+
for a in sorted(antigens, key=_rank_key):
|
|
361
|
+
topk = "".join(_cell(a[f"in_top_{k}"]) for k in ks)
|
|
362
|
+
rows += (
|
|
363
|
+
f"<tr><td class='name'>{e(str(a['symbol']))}</td>"
|
|
364
|
+
f"<td>{e(str(a['uniprot']))}</td><td>{e(str(a['tumor_type']))}</td>"
|
|
365
|
+
f"{_cell(a['found'])}{_cell(a['rank'])}{_cell(a['log2fc'])}{topk}</tr>"
|
|
366
|
+
)
|
|
367
|
+
return f"<table><thead>{antigen_headers}</thead><tbody>{rows}</tbody></table>"
|
|
368
|
+
|
|
369
|
+
for s in scores:
|
|
370
|
+
detail_blocks += (
|
|
371
|
+
f"<h3>{e(s.run_name)}</h3><div class='sub'>{e(portable_path(s.run_dir))}</div>"
|
|
372
|
+
)
|
|
373
|
+
if s.recall_basis == "on_indication":
|
|
374
|
+
detail_blocks += (
|
|
375
|
+
f"<div class='scope'>Scored against the {e(str(s.tumor_type))} known "
|
|
376
|
+
"antigen(s) — the only ones this run can rediscover.</div>"
|
|
377
|
+
)
|
|
378
|
+
elif s.recall_basis == "indication_unknown":
|
|
379
|
+
detail_blocks += (
|
|
380
|
+
"<div class='scope warn'>Indication not supplied for this run, so no "
|
|
381
|
+
"indication gate could be applied: the whole known set is scored and a "
|
|
382
|
+
"match may belong to another cancer type.</div>"
|
|
383
|
+
)
|
|
384
|
+
elif s.recall_basis == "candidates_unavailable":
|
|
385
|
+
detail_blocks += (
|
|
386
|
+
"<div class='scope warn'>This run's candidate table could not be read "
|
|
387
|
+
"(missing, empty or unreadable), so nothing could be looked up and "
|
|
388
|
+
"recall@k is not defined. This is not a rediscovery rate of zero.</div>"
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
detail_blocks += (
|
|
392
|
+
f"<div class='scope warn'>No known antigen is annotated for indication "
|
|
393
|
+
f"{e(str(s.tumor_type))}, so recall@k is not defined for this run.</div>"
|
|
394
|
+
)
|
|
395
|
+
detail_blocks += _antigen_table(s.per_antigen)
|
|
396
|
+
if s.cross_indication:
|
|
397
|
+
detail_blocks += (
|
|
398
|
+
"<h4>Cross-indication cross-reactivity — NOT rediscovery</h4>"
|
|
399
|
+
"<div class='scope warn'>Known antigens of <em>other</em> cancer types that "
|
|
400
|
+
"this run surfaced. They are excluded from recall@k above.</div>"
|
|
401
|
+
+ _antigen_table(s.cross_indication)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return _HTML_TEMPLATE.format(
|
|
405
|
+
recall_headers=recall_headers,
|
|
406
|
+
summary_rows=summary_rows,
|
|
407
|
+
detail_blocks=detail_blocks,
|
|
408
|
+
known_source=e(known_source),
|
|
409
|
+
n_runs=len(scores),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def run_benchmark(
|
|
414
|
+
run_dirs: list[Path | str],
|
|
415
|
+
known_antigens_path: Path | str,
|
|
416
|
+
*,
|
|
417
|
+
out_html: Path | str,
|
|
418
|
+
ks: tuple[int, ...] = DEFAULT_KS,
|
|
419
|
+
tumor_types: list[str | None] | None = None,
|
|
420
|
+
) -> tuple[Path, list[RunScore]]:
|
|
421
|
+
"""Score every run against the known set and write the HTML report.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
run_dirs: finished run directories to score.
|
|
425
|
+
known_antigens_path: the known-antigen table (``benchmarks/known.tsv``).
|
|
426
|
+
out_html: where to write the report.
|
|
427
|
+
ks: top-k cutoffs for recall@k.
|
|
428
|
+
tumor_types: each run's indication, positionally aligned with
|
|
429
|
+
``run_dirs``. Runs without one are scored over the whole known set
|
|
430
|
+
and reported as indication-unknown, since rediscovery cannot be
|
|
431
|
+
told apart from cross-indication cross-reactivity without it.
|
|
432
|
+
|
|
433
|
+
Returns ``(out_html_path, scores)``.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
ValueError: if ``tumor_types`` is given with a different length than
|
|
437
|
+
``run_dirs``.
|
|
438
|
+
"""
|
|
439
|
+
if tumor_types is not None and len(tumor_types) != len(run_dirs):
|
|
440
|
+
raise ValueError(
|
|
441
|
+
f"tumor_types has {len(tumor_types)} entries for {len(run_dirs)} run dir(s)"
|
|
442
|
+
)
|
|
443
|
+
known = load_known_antigens(known_antigens_path)
|
|
444
|
+
indications = tumor_types if tumor_types is not None else [None] * len(run_dirs)
|
|
445
|
+
scores = [
|
|
446
|
+
score_run(rd, known, ks=ks, tumor_type=tt)
|
|
447
|
+
for rd, tt in zip(run_dirs, indications, strict=True)
|
|
448
|
+
]
|
|
449
|
+
out = Path(out_html)
|
|
450
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
out.write_text(
|
|
452
|
+
render_benchmark_html(scores, ks=ks, known_source=str(known_antigens_path)),
|
|
453
|
+
encoding="utf-8",
|
|
454
|
+
newline="\n",
|
|
455
|
+
)
|
|
456
|
+
LOG.info("wrote %s (%d runs, %d known antigens)", out, len(scores), len(known))
|
|
457
|
+
return out, scores
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
_HTML_TEMPLATE = """\
|
|
461
|
+
<!doctype html>
|
|
462
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
463
|
+
<title>bindsight rediscovery benchmark</title>
|
|
464
|
+
<style>
|
|
465
|
+
body {{ font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
|
466
|
+
margin: 2rem auto; max-width: 980px; color: #1a1a1a; line-height: 1.45; }}
|
|
467
|
+
h1 {{ font-size: 1.5rem; }} h3 {{ margin-top: 1.6rem; }} h4 {{ margin: 1.1rem 0 .2rem; }}
|
|
468
|
+
.sub {{ color: #666; font-size: .8rem; margin-bottom: .3rem; font-family: monospace; }}
|
|
469
|
+
.scope {{ color: #555; font-size: .82rem; margin-bottom: .25rem; }}
|
|
470
|
+
.scope.warn {{ color: #8a4b00; }}
|
|
471
|
+
table {{ border-collapse: collapse; width: 100%; margin: .5rem 0 1.2rem; font-size: .9rem; }}
|
|
472
|
+
th, td {{ border: 1px solid #ddd; padding: .35rem .55rem; text-align: center; }}
|
|
473
|
+
th {{ background: #f4f4f6; }}
|
|
474
|
+
td.name {{ text-align: left; font-weight: 600; }}
|
|
475
|
+
td.hit {{ color: #137333; font-weight: 700; }}
|
|
476
|
+
td.miss {{ color: #b00020; }}
|
|
477
|
+
.foot {{ color: #777; font-size: .8rem; margin-top: 2rem; }}
|
|
478
|
+
</style></head><body>
|
|
479
|
+
<h1>bindsight — rediscovery benchmark</h1>
|
|
480
|
+
<p>How well each run resurfaces the held-out known antigens of <em>its own</em>
|
|
481
|
+
indication. Known set: <code>{known_source}</code> · {n_runs} run(s).
|
|
482
|
+
<strong>recall@k</strong> = fraction of the run's on-indication known antigens
|
|
483
|
+
ranked in the top-k. A known antigen of another cancer type is never counted as
|
|
484
|
+
a rediscovery; where a run surfaces one it is listed under
|
|
485
|
+
<em>cross-indication cross-reactivity</em>. Runs with no declared indication are
|
|
486
|
+
marked <em>unknown</em> and are scored over the whole known set, which cannot
|
|
487
|
+
distinguish rediscovery from cross-reactivity.</p>
|
|
488
|
+
<h2>Summary</h2>
|
|
489
|
+
<table><thead><tr><th>run</th><th>indication</th><th>on-indication found</th>
|
|
490
|
+
<th>cross-indication</th><th>candidates</th>{recall_headers}</tr></thead>
|
|
491
|
+
<tbody>{summary_rows}</tbody></table>
|
|
492
|
+
<h2>Per-antigen detail</h2>
|
|
493
|
+
{detail_blocks}
|
|
494
|
+
<p class="foot">Generated by <code>bindsight benchmark</code>. Known antigens and
|
|
495
|
+
literature-validated binders: see <code>benchmarks/PROVENANCE.md</code>.</p>
|
|
496
|
+
</body></html>
|
|
497
|
+
"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Benchmark constants that cost nothing to import.
|
|
4
|
+
|
|
5
|
+
``DEFAULT_KS`` lived in :mod:`bindsight.benchmark.core`, which imports pandas at
|
|
6
|
+
module scope. :mod:`bindsight.cli` needs the value at import time -- it is
|
|
7
|
+
interpolated into a ``--k`` help string, which Click evaluates when the command
|
|
8
|
+
is defined -- so every invocation of ``bindsight``, including ``--version`` and
|
|
9
|
+
``--help``, pulled pandas in. pandas is declared under the ``discover`` extra
|
|
10
|
+
and not in the base dependencies, so a plain ``pip install bindsight`` produced
|
|
11
|
+
a command that could not start.
|
|
12
|
+
|
|
13
|
+
CI never saw it: every job installs ``.[dev,discover,report]``.
|
|
14
|
+
|
|
15
|
+
This module is stdlib-only at module scope and must stay that way.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
#: Rank cutoffs reported by ``bindsight benchmark`` when none are given.
|
|
21
|
+
#:
|
|
22
|
+
#: Named once, here. The CLI shows them in its help text and falls back to them
|
|
23
|
+
#: when ``--k`` is absent, and the scoring functions take them as a default
|
|
24
|
+
#: argument -- three readers of one value, which is three chances for it to be
|
|
25
|
+
#: written out by hand and drift.
|
|
26
|
+
DEFAULT_KS: tuple[int, ...] = (5, 10, 20)
|
|
27
|
+
|
|
28
|
+
__all__ = ["DEFAULT_KS"]
|