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
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Multi-objective ranking of validated binders.
|
|
4
|
+
|
|
5
|
+
Combines four orthogonal signals into a composite ``score``:
|
|
6
|
+
|
|
7
|
+
1. **Upstream evidence** — DE log2FC × specificity penalty (vital-tissue baseline).
|
|
8
|
+
2. **Structure quality** — iPTM, pTM, binder pLDDT, pAE_interaction, RMSD-to-designed.
|
|
9
|
+
3. **Affinity** — ``affinity_pred_value`` and ``affinity_probability_binary``.
|
|
10
|
+
4. **Sequence quality** — ProteinMPNN sequence recovery vs. backbone (when available).
|
|
11
|
+
|
|
12
|
+
``affinity_pred_value`` is a log(IC50)-like quantity where **lower is stronger**,
|
|
13
|
+
so it is inverted before normalisation. Only a validator that genuinely predicts
|
|
14
|
+
affinity may populate it; structure-confidence metrics belong to component 2. Boltz-2
|
|
15
|
+
affinity prediction is ligand-only, so for protein binders this component is usually
|
|
16
|
+
absent and the composite is renormalised over the components that are present.
|
|
17
|
+
|
|
18
|
+
Each signal is min-max normalised to [0, 1] across the run, then combined with
|
|
19
|
+
user-configurable weights from :class:`bindsight.config.RankWeights`. The output
|
|
20
|
+
Parquet has BOTH the composite ``score`` and every component, so users can
|
|
21
|
+
re-rank by any single metric in the report.
|
|
22
|
+
|
|
23
|
+
The rank module deliberately does NOT pick one Right Answer; it produces a
|
|
24
|
+
defensible default and surfaces the components so the user can override.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import pandas as pd
|
|
33
|
+
|
|
34
|
+
from bindsight.config import RankWeights
|
|
35
|
+
from bindsight.design.developability import developability
|
|
36
|
+
|
|
37
|
+
LOG = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _minmax(s: pd.Series, *, invert: bool = False) -> pd.Series:
|
|
41
|
+
"""Min-max normalise to [0, 1]. NaN stays NaN. Constant series → 0.5.
|
|
42
|
+
|
|
43
|
+
With ``invert=True``, lower values → 1.0 (used for "lower is better"
|
|
44
|
+
metrics like pAE_interaction or RMSD).
|
|
45
|
+
"""
|
|
46
|
+
if s.isna().all():
|
|
47
|
+
return s
|
|
48
|
+
lo, hi = s.min(skipna=True), s.max(skipna=True)
|
|
49
|
+
if pd.isna(lo) or pd.isna(hi) or lo == hi:
|
|
50
|
+
return s.where(s.isna(), 0.5)
|
|
51
|
+
norm = (s - lo) / (hi - lo)
|
|
52
|
+
return 1.0 - norm if invert else norm
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def rank_validated(
|
|
56
|
+
validated: pd.DataFrame,
|
|
57
|
+
candidates: pd.DataFrame | None = None,
|
|
58
|
+
*,
|
|
59
|
+
weights: RankWeights | None = None,
|
|
60
|
+
) -> pd.DataFrame:
|
|
61
|
+
"""Rank validated binders by a composite score; return a new DataFrame.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
validated: rows produced by ``bindsight validate``. Must include at
|
|
65
|
+
least ``binder_id``, ``target_uniprot``, and any of the metric
|
|
66
|
+
columns (``iptm``, ``pae_interaction``, ``affinity_pred_value``,
|
|
67
|
+
``affinity_probability_binary``, ``rmsd_to_designed``,
|
|
68
|
+
``sequence_recovery``).
|
|
69
|
+
candidates: optional rows from ``bindsight discover`` (provides
|
|
70
|
+
``log2fc``, ``symbol``, ``n_safety_events``). Joined on
|
|
71
|
+
``target_uniprot`` if provided.
|
|
72
|
+
weights: composite-score weights. Defaults to the RankWeights defaults.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
DataFrame sorted by descending ``score``, with ``rank`` column added.
|
|
76
|
+
Columns include the composite ``score``, every component score
|
|
77
|
+
(``score_evidence``, ``score_structure``, ``score_affinity``,
|
|
78
|
+
``score_sequence``), and the original metrics for transparency.
|
|
79
|
+
"""
|
|
80
|
+
weights = weights or RankWeights()
|
|
81
|
+
df = validated.copy()
|
|
82
|
+
|
|
83
|
+
# Join in upstream evidence if available.
|
|
84
|
+
if candidates is not None and not candidates.empty:
|
|
85
|
+
keep_cols = [
|
|
86
|
+
c
|
|
87
|
+
for c in ("symbol", "uniprot_id", "log2fc", "padj", "n_safety_events")
|
|
88
|
+
if c in candidates.columns
|
|
89
|
+
]
|
|
90
|
+
if "uniprot_id" in keep_cols:
|
|
91
|
+
cand = _one_row_per_accession(candidates[keep_cols])
|
|
92
|
+
df = df.merge(
|
|
93
|
+
cand,
|
|
94
|
+
left_on="target_uniprot",
|
|
95
|
+
right_on="uniprot_id",
|
|
96
|
+
how="left",
|
|
97
|
+
suffixes=("", "_cand"),
|
|
98
|
+
)
|
|
99
|
+
if "uniprot_id" in df.columns and "target_uniprot" in df.columns:
|
|
100
|
+
df = df.drop(columns=["uniprot_id"], errors="ignore")
|
|
101
|
+
|
|
102
|
+
# Component scores (each in [0, 1]; NaN if metric missing for the row).
|
|
103
|
+
df["score_evidence"] = _evidence_score(df)
|
|
104
|
+
df["score_structure"] = _structure_score(df)
|
|
105
|
+
df["score_affinity"] = _affinity_score(df)
|
|
106
|
+
df["score_sequence"] = (
|
|
107
|
+
_minmax(df["sequence_recovery"])
|
|
108
|
+
if "sequence_recovery" in df.columns
|
|
109
|
+
else pd.Series([float("nan")] * len(df))
|
|
110
|
+
)
|
|
111
|
+
# Developability (sequence biophysics) — only when the binder sequence is
|
|
112
|
+
# carried on the row. developability_score is already an absolute [0, 1].
|
|
113
|
+
if "sequence" in df.columns:
|
|
114
|
+
df["developability_score"] = df["sequence"].map(_developability_score)
|
|
115
|
+
df["score_developability"] = df["developability_score"]
|
|
116
|
+
else:
|
|
117
|
+
df["score_developability"] = pd.Series([float("nan")] * len(df))
|
|
118
|
+
|
|
119
|
+
# Composite. Weights apply to non-NaN components only; missing metrics
|
|
120
|
+
# don't penalise — they're just excluded from the weighted average.
|
|
121
|
+
component_cols = [
|
|
122
|
+
("score_evidence", weights.log2fc_specificity),
|
|
123
|
+
("score_structure", weights.iptm),
|
|
124
|
+
("score_affinity", weights.affinity),
|
|
125
|
+
("score_sequence", weights.sequence_recovery),
|
|
126
|
+
("score_developability", weights.developability),
|
|
127
|
+
]
|
|
128
|
+
composite = pd.Series([0.0] * len(df), index=df.index)
|
|
129
|
+
weight_sum = pd.Series([0.0] * len(df), index=df.index)
|
|
130
|
+
for col, w in component_cols:
|
|
131
|
+
if col in df.columns:
|
|
132
|
+
# Coerce first, then mask. The mask used to be taken from the raw
|
|
133
|
+
# column while the value was coerced with ``errors="coerce"``, so a
|
|
134
|
+
# non-numeric but non-null cell -- ``"N/A"``, an empty string, an
|
|
135
|
+
# object-dtype string surviving a parquet merge -- was ``notna()``
|
|
136
|
+
# and therefore counted its full weight in the denominator, while
|
|
137
|
+
# coercing to NaN and contributing 0.0 to the numerator. The binder
|
|
138
|
+
# was silently penalised by exactly that component's weight, which
|
|
139
|
+
# is the opposite of what the comment above promises. A value that
|
|
140
|
+
# is not a number is a missing measurement, and is excluded.
|
|
141
|
+
column = pd.to_numeric(df[col], errors="coerce").astype(float)
|
|
142
|
+
mask = column.notna()
|
|
143
|
+
composite = composite.add(column.fillna(0.0) * w, fill_value=0.0)
|
|
144
|
+
weight_sum = weight_sum + (mask.astype(float) * w)
|
|
145
|
+
df["score"] = composite / weight_sum.replace(0.0, pd.NA)
|
|
146
|
+
|
|
147
|
+
# Sort + add rank. ``kind="mergesort"`` because pandas' default is not
|
|
148
|
+
# stable: two binders with the same composite score were ordered by numpy's
|
|
149
|
+
# introsort internals, so the published rank among ties could differ between
|
|
150
|
+
# runs and between platforms for identical inputs. Stable means ties keep the
|
|
151
|
+
# order they arrived in, which is reproducible and explainable.
|
|
152
|
+
df = df.sort_values("score", ascending=False, na_position="last", kind="mergesort").reset_index(
|
|
153
|
+
drop=True
|
|
154
|
+
)
|
|
155
|
+
df["rank"] = range(1, len(df) + 1)
|
|
156
|
+
|
|
157
|
+
return df
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _developability_score(sequence: object) -> float:
|
|
161
|
+
"""Absolute developability score in [0, 1] for a binder sequence; NaN if unavailable."""
|
|
162
|
+
if not isinstance(sequence, str) or not sequence:
|
|
163
|
+
return float("nan")
|
|
164
|
+
d = developability(sequence)
|
|
165
|
+
return d.developability_score if d is not None else float("nan")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _fold_change_score(log2fc: pd.Series) -> pd.Series:
|
|
169
|
+
"""Scale log2 fold change to [0, 1] against an absolute floor of zero.
|
|
170
|
+
|
|
171
|
+
Min-max normalisation is scale-free: it maps the smallest value in the table
|
|
172
|
+
to 0 and the largest to 1 regardless of how far apart they actually are. For
|
|
173
|
+
a per-binder metric across forty rows that is harmless. For log2 fold change
|
|
174
|
+
it is not, because the value is per *target*, so a two-target run has two
|
|
175
|
+
distinct values and min-max maps them onto exactly {0.0, 1.0}.
|
|
176
|
+
|
|
177
|
+
That is what happened on the first real two-target run. CA9 was
|
|
178
|
+
over-expressed 764-fold and CD70 304-fold; both are enormous, and min-max
|
|
179
|
+
scored the second one zero. The full 0.25 evidence weight swung on a log2fc
|
|
180
|
+
difference of 1.33, which pushed CD70 binders with an ipTM of 0.95 and a
|
|
181
|
+
3.3 A interface below CA9 binders at 0.83 and 11.7 A. The ranking was being
|
|
182
|
+
driven by an artefact of normalisation rather than by evidence.
|
|
183
|
+
|
|
184
|
+
Anchoring the floor at zero fixes it without inventing a scale. A log2 fold
|
|
185
|
+
change of zero *is* an absolute zero point: no differential expression, no
|
|
186
|
+
evidence. The top of the range stays relative to the run, which is what
|
|
187
|
+
makes this a ranking rather than an absolute score, but nothing that is
|
|
188
|
+
genuinely over-expressed can be scored as though it were not.
|
|
189
|
+
|
|
190
|
+
Down-regulated targets score zero rather than negative: this component asks
|
|
191
|
+
how strong the tumour-selective evidence is, and a gene expressed less in
|
|
192
|
+
tumour has none.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
log2fc: per-row log2 fold change, possibly with missing values.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
A [0, 1] series, NaN preserved.
|
|
199
|
+
"""
|
|
200
|
+
values = pd.to_numeric(log2fc, errors="coerce")
|
|
201
|
+
if values.isna().all():
|
|
202
|
+
return values
|
|
203
|
+
top = values.max(skipna=True)
|
|
204
|
+
if pd.isna(top) or top <= 0:
|
|
205
|
+
# Nothing in the table is over-expressed, so nothing carries evidence.
|
|
206
|
+
return values.where(values.isna(), 0.0)
|
|
207
|
+
return (values.clip(lower=0.0) / top).where(values.notna())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _one_row_per_accession(cand: pd.DataFrame) -> pd.DataFrame:
|
|
211
|
+
"""Reduce the candidate evidence to one row per UniProt accession.
|
|
212
|
+
|
|
213
|
+
Several gene IDs can map to one accession -- read-through loci, paralogous
|
|
214
|
+
symbols sharing a product -- and each carries its own ``log2fc`` and
|
|
215
|
+
``padj``, which feed the evidence component of the composite score. The
|
|
216
|
+
previous ``drop_duplicates("uniprot_id")`` kept whichever row happened to
|
|
217
|
+
come first, so the evidence joined to a binder depended on the row order of
|
|
218
|
+
an upstream table: a different answer from the same data.
|
|
219
|
+
|
|
220
|
+
The strongest evidence for the protein wins instead: smallest ``padj``, then
|
|
221
|
+
largest ``|log2fc|``, then the symbol, so the result is the same whatever
|
|
222
|
+
order the rows arrive in. A collision is logged, because an accession with
|
|
223
|
+
two disagreeing rows is something the reader should be able to see.
|
|
224
|
+
|
|
225
|
+
No committed cohort has ever contained one (4,665 candidate rows across the
|
|
226
|
+
eleven real runs, zero duplicated accessions), which is why the arbitrary
|
|
227
|
+
tie-break went unnoticed; that is a reason to make it deterministic, not a
|
|
228
|
+
reason to leave it.
|
|
229
|
+
"""
|
|
230
|
+
accessions = cand["uniprot_id"]
|
|
231
|
+
duplicated = accessions.notna() & accessions.duplicated(keep=False)
|
|
232
|
+
if not duplicated.any():
|
|
233
|
+
return cand.drop_duplicates("uniprot_id")
|
|
234
|
+
|
|
235
|
+
for accession, group in cand[duplicated].groupby("uniprot_id", sort=True):
|
|
236
|
+
LOG.warning(
|
|
237
|
+
"%d candidate rows share accession %s (%s); keeping the most "
|
|
238
|
+
"significant one for ranking evidence",
|
|
239
|
+
len(group),
|
|
240
|
+
accession,
|
|
241
|
+
", ".join(sorted(str(v) for v in group.get("symbol", []))) or "no symbols",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
order = cand.copy()
|
|
245
|
+
order["_padj"] = order["padj"].astype(float) if "padj" in order.columns else 1.0
|
|
246
|
+
order["_absfc"] = -order["log2fc"].abs().astype(float) if "log2fc" in order.columns else 0.0
|
|
247
|
+
order["_symbol"] = order["symbol"].astype(str) if "symbol" in order.columns else ""
|
|
248
|
+
# NaN padj sorts last under na_position, so a row with no statistic never
|
|
249
|
+
# displaces one that has it.
|
|
250
|
+
order = order.sort_values(["_padj", "_absfc", "_symbol"], na_position="last", kind="mergesort")
|
|
251
|
+
return (
|
|
252
|
+
order.drop_duplicates("uniprot_id")
|
|
253
|
+
.drop(columns=["_padj", "_absfc", "_symbol"])
|
|
254
|
+
.loc[:, cand.columns]
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _evidence_score(df: pd.DataFrame) -> pd.Series:
|
|
259
|
+
"""log2FC on an absolute scale, cut by a vital-tissue specificity penalty.
|
|
260
|
+
|
|
261
|
+
``n_safety_events`` counts the normal tissues where the target is expressed
|
|
262
|
+
above the configured safety ceiling (see ``bindsight.targets.gtex``). Each
|
|
263
|
+
event divides the evidence score down, so a strongly over-expressed target
|
|
264
|
+
that is also present in vital tissue cannot outrank a comparably
|
|
265
|
+
over-expressed one that is tumour-restricted. A target with no recorded
|
|
266
|
+
events is unpenalised.
|
|
267
|
+
|
|
268
|
+
Returns NaN where ``log2fc`` is absent, matching the other components: a
|
|
269
|
+
missing metric is excluded from the composite rather than scored as zero.
|
|
270
|
+
"""
|
|
271
|
+
if "log2fc" not in df.columns:
|
|
272
|
+
return pd.Series([float("nan")] * len(df), index=df.index)
|
|
273
|
+
score = _fold_change_score(df["log2fc"])
|
|
274
|
+
if "n_safety_events" in df.columns:
|
|
275
|
+
events = pd.to_numeric(df["n_safety_events"], errors="coerce").fillna(0.0).clip(lower=0.0)
|
|
276
|
+
score = score * (1.0 / (1.0 + events))
|
|
277
|
+
return score
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _structure_score(df: pd.DataFrame) -> pd.Series:
|
|
281
|
+
"""Mean of the available structure-confidence signals, all oriented so higher is better.
|
|
282
|
+
|
|
283
|
+
Carries iPTM, pTM and binder pLDDT (confidence, higher better) alongside
|
|
284
|
+
inverted pAE-interaction and RMSD (error, lower better). pTM and pLDDT live
|
|
285
|
+
here rather than in the affinity component because they measure how confident
|
|
286
|
+
the predictor is in the fold, not how tightly the binder binds.
|
|
287
|
+
"""
|
|
288
|
+
parts: list[pd.Series] = []
|
|
289
|
+
if "iptm" in df.columns:
|
|
290
|
+
parts.append(_minmax(df["iptm"]))
|
|
291
|
+
if "ptm" in df.columns:
|
|
292
|
+
parts.append(_minmax(df["ptm"]))
|
|
293
|
+
if "plddt_binder" in df.columns:
|
|
294
|
+
parts.append(_minmax(df["plddt_binder"]))
|
|
295
|
+
if "pae_interaction" in df.columns:
|
|
296
|
+
parts.append(_minmax(df["pae_interaction"], invert=True))
|
|
297
|
+
if "rmsd_to_designed" in df.columns:
|
|
298
|
+
parts.append(_minmax(df["rmsd_to_designed"], invert=True))
|
|
299
|
+
if not parts:
|
|
300
|
+
return pd.Series([float("nan")] * len(df), index=df.index)
|
|
301
|
+
stacked = pd.concat(parts, axis=1)
|
|
302
|
+
return stacked.mean(axis=1, skipna=True)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _affinity_score(df: pd.DataFrame) -> pd.Series:
|
|
306
|
+
"""Combined predicted affinity + binary binder probability, higher = better.
|
|
307
|
+
|
|
308
|
+
``affinity_pred_value`` is a log(IC50)-like quantity: **lower is a stronger
|
|
309
|
+
binder** (-8.0 beats -6.0), so it is inverted on the way in. Getting this
|
|
310
|
+
backwards silently promotes the weakest designs, which is why it is asserted
|
|
311
|
+
numerically in ``tests/test_rank.py`` rather than only by ordering.
|
|
312
|
+
"""
|
|
313
|
+
parts: list[pd.Series] = []
|
|
314
|
+
if "affinity_pred_value" in df.columns:
|
|
315
|
+
parts.append(_minmax(df["affinity_pred_value"], invert=True))
|
|
316
|
+
if "affinity_probability_binary" in df.columns:
|
|
317
|
+
# Already in [0, 1]
|
|
318
|
+
parts.append(df["affinity_probability_binary"].astype(float))
|
|
319
|
+
if not parts:
|
|
320
|
+
return pd.Series([float("nan")] * len(df), index=df.index)
|
|
321
|
+
stacked = pd.concat(parts, axis=1)
|
|
322
|
+
return stacked.mean(axis=1, skipna=True)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def rank_run(
|
|
326
|
+
run_dir: Path | str,
|
|
327
|
+
*,
|
|
328
|
+
weights: RankWeights | None = None,
|
|
329
|
+
) -> Path:
|
|
330
|
+
"""Read a finished run, rank validated binders, write ``rank/ranking.parquet``.
|
|
331
|
+
|
|
332
|
+
Returns the output path. Raises FileNotFoundError if validation hasn't
|
|
333
|
+
been run.
|
|
334
|
+
"""
|
|
335
|
+
run = Path(run_dir)
|
|
336
|
+
validated_path = run / "validate" / "validated.parquet"
|
|
337
|
+
candidates_path = run / "targets" / "candidates.parquet"
|
|
338
|
+
if not validated_path.exists() or validated_path.stat().st_size == 0:
|
|
339
|
+
raise FileNotFoundError(
|
|
340
|
+
f"no validation output at {validated_path}; run `bindsight validate <run_dir>` first."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
validated = pd.read_parquet(validated_path)
|
|
344
|
+
candidates = (
|
|
345
|
+
pd.read_parquet(candidates_path)
|
|
346
|
+
if candidates_path.exists() and candidates_path.stat().st_size > 0
|
|
347
|
+
else None
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
ranked = rank_validated(validated, candidates, weights=weights)
|
|
351
|
+
out = run / "rank" / "ranking.parquet"
|
|
352
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
353
|
+
ranked.to_parquet(out, index=False)
|
|
354
|
+
LOG.info("wrote %s (%d rows)", out, len(ranked))
|
|
355
|
+
return out
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Reporting: paper-style HTML + a served interface, both backed by the same data.
|
|
4
|
+
|
|
5
|
+
``render_run`` is resolved lazily. Importing it eagerly pulled in
|
|
6
|
+
:mod:`bindsight.report.html`, and therefore pandas and jinja2, for anyone who
|
|
7
|
+
touched *anything* under this package — including :mod:`bindsight.report.theme`
|
|
8
|
+
and :mod:`bindsight.report.showcase`, both of which are deliberately
|
|
9
|
+
stdlib-only at module scope so they stay usable in minimal environments. The
|
|
10
|
+
documentation build is exactly such an environment.
|
|
11
|
+
|
|
12
|
+
The lazy hook must raise ``AttributeError`` (never ``ImportError``) for unknown
|
|
13
|
+
names: ``from bindsight.report import showcase`` consults ``__getattr__`` before
|
|
14
|
+
falling back to the submodule import machinery, so swallowing the error here
|
|
15
|
+
would break every ``from bindsight.report import <submodule>`` in the codebase.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
23
|
+
from bindsight.report.html import render_run
|
|
24
|
+
|
|
25
|
+
__all__ = ["render_run"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def __getattr__(name: str) -> Any:
|
|
29
|
+
"""Resolve ``render_run`` on first access (PEP 562)."""
|
|
30
|
+
if name == "render_run":
|
|
31
|
+
from bindsight.report.html import render_run as _render_run
|
|
32
|
+
|
|
33
|
+
return _render_run
|
|
34
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def __dir__() -> list[str]:
|
|
38
|
+
"""Keep ``dir()`` honest about the lazily-exported name."""
|
|
39
|
+
return sorted({*globals(), *__all__})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""How bindsight writes a number down.
|
|
4
|
+
|
|
5
|
+
Shared by both surfaces on purpose. These lived inside the web app, and the
|
|
6
|
+
standalone report kept its own ``f"{v:.3g}"`` -- so the same ``padj`` from the
|
|
7
|
+
same run printed as ``<1e-300`` in the browser and as ``0`` in the file a
|
|
8
|
+
collaborator opened from an email. A design system that shares a stylesheet and
|
|
9
|
+
not its rules only makes the disagreement harder to spot.
|
|
10
|
+
|
|
11
|
+
Three of these are claims rather than cosmetics:
|
|
12
|
+
|
|
13
|
+
* **Absence is an em dash, never a zero.** A crisp ``0`` in metric chrome reads
|
|
14
|
+
as a measurement, and a crashed run then looks like one that found nothing.
|
|
15
|
+
* **A p-value never prints as zero.** CA9's ``padj`` is literally ``0.0`` in the
|
|
16
|
+
artifact -- below floating-point resolution, not measured as zero.
|
|
17
|
+
* **A confidence level is read, never assumed.** An interval labelled with a
|
|
18
|
+
level nothing computed states two things from two different places.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
#: How a number with nothing behind it is written. Never ``0``: four zeros in
|
|
26
|
+
#: metric chrome read as a measurement, and a broken run then looks like a run
|
|
27
|
+
#: that found nothing.
|
|
28
|
+
ABSENT = "—"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Presentation helpers
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
def fmt(value: Any, digits: int = 2) -> str:
|
|
35
|
+
"""A number, or :data:`ABSENT` if there is not one."""
|
|
36
|
+
if value is None:
|
|
37
|
+
return ABSENT
|
|
38
|
+
try:
|
|
39
|
+
number = float(value)
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return ABSENT
|
|
42
|
+
if number != number: # NaN
|
|
43
|
+
return ABSENT
|
|
44
|
+
return f"{number:.{digits}f}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fmt_int(value: Any) -> str:
|
|
48
|
+
"""An integer for the screen, or the em dash when there is not one.
|
|
49
|
+
|
|
50
|
+
Absence renders as ``—``. It never renders as ``0``: a crisp zero in
|
|
51
|
+
metric chrome reads as a measurement, which is how a run that crashed
|
|
52
|
+
comes to look like a run that found nothing.
|
|
53
|
+
"""
|
|
54
|
+
if value is None:
|
|
55
|
+
return ABSENT
|
|
56
|
+
try:
|
|
57
|
+
return f"{int(value):,}"
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
return ABSENT
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fmt_p(value: Any) -> str:
|
|
63
|
+
"""A p-value that never prints as zero.
|
|
64
|
+
|
|
65
|
+
``%.2e`` rendered CA9's ``padj`` -- literally 0.0 in the artifact, below
|
|
66
|
+
floating-point resolution -- as ``0.00e+00``. A p of zero is not a p.
|
|
67
|
+
"""
|
|
68
|
+
if value is None:
|
|
69
|
+
return ABSENT
|
|
70
|
+
try:
|
|
71
|
+
number = float(value)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return ABSENT
|
|
74
|
+
if number != number:
|
|
75
|
+
return ABSENT
|
|
76
|
+
if number <= 0:
|
|
77
|
+
return "<1e-300"
|
|
78
|
+
# Scientific below 1e-3, not 1e-4: the study's own permutation p is
|
|
79
|
+
# 3.97e-04, and fixed notation rendered that as "0.0004" -- losing the two
|
|
80
|
+
# significant figures that distinguish it from the floor it sits on.
|
|
81
|
+
if number < 1e-3:
|
|
82
|
+
return f"{number:.2e}"
|
|
83
|
+
return f"{number:.4f}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def interval(low: Any, high: Any, *, digits: int = 3, confidence: Any = None) -> str:
|
|
87
|
+
"""``95% CI 0.188–0.513``, or :data:`ABSENT`.
|
|
88
|
+
|
|
89
|
+
The confidence level is read, never assumed. An interval labelled with a
|
|
90
|
+
level nothing computed states two things from two different places.
|
|
91
|
+
"""
|
|
92
|
+
if low is None or high is None:
|
|
93
|
+
return ABSENT
|
|
94
|
+
label = ""
|
|
95
|
+
if confidence is not None:
|
|
96
|
+
try:
|
|
97
|
+
label = f"{float(confidence) * 100:g}% "
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
label = ""
|
|
100
|
+
return f"{label}CI {fmt(low, digits)}–{fmt(high, digits)}"
|