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,1443 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Discovery-half orchestrator.
|
|
4
|
+
|
|
5
|
+
Joins the upstream genomics evidence into a ranked target shortlist:
|
|
6
|
+
|
|
7
|
+
DEGs ──► Open Targets enrichment ──► Surfaceome filter
|
|
8
|
+
│
|
|
9
|
+
▼
|
|
10
|
+
Top-N targets
|
|
11
|
+
│
|
|
12
|
+
▼
|
|
13
|
+
AlphaFoldDB structures
|
|
14
|
+
│
|
|
15
|
+
▼
|
|
16
|
+
SURFACE-Bind site lookup
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
targets/candidates.parquet
|
|
20
|
+
epitopes/epitopes.parquet
|
|
21
|
+
|
|
22
|
+
All steps are CPU-only and run on the user's laptop. Each stage appends a
|
|
23
|
+
:class:`bindsight.provenance.StageRecord` to a single per-run manifest.
|
|
24
|
+
|
|
25
|
+
Failures are recorded, not swallowed: if Open Targets has no record for a
|
|
26
|
+
gene, we tag the row ``no_open_targets`` and keep going. The downstream
|
|
27
|
+
``rank`` stage uses these tags to build a failure taxonomy for the report.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
import pandas as pd
|
|
39
|
+
|
|
40
|
+
from bindsight import __version__
|
|
41
|
+
from bindsight.config import RunConfig, TargetDiscoveryParams
|
|
42
|
+
from bindsight.deg.pydeseq2_runner import PyDESeq2Runner
|
|
43
|
+
from bindsight.epitopes.surface_bind import SURFACE_BIND_DATA_ENV, SurfaceBindClient
|
|
44
|
+
from bindsight.io.paths import adopt_structure, resolve_run_path, run_dir
|
|
45
|
+
from bindsight.pipelines.caveats import DISCOVERY_LIMITATIONS, caveat_summary
|
|
46
|
+
from bindsight.provenance import (
|
|
47
|
+
InputRef,
|
|
48
|
+
Manifest,
|
|
49
|
+
OutputRef,
|
|
50
|
+
StageRecord,
|
|
51
|
+
ToolRef,
|
|
52
|
+
new_manifest,
|
|
53
|
+
sha256_file,
|
|
54
|
+
)
|
|
55
|
+
from bindsight.structures.alphafolddb import AlphaFoldDBClient
|
|
56
|
+
from bindsight.structures.plddt import mean_plddt, region_plddt
|
|
57
|
+
from bindsight.structures.topology import Topology, UniProtTopologyClient
|
|
58
|
+
from bindsight.surfaceome import (
|
|
59
|
+
is_surface_protein,
|
|
60
|
+
load_surfaceome,
|
|
61
|
+
load_surfaceome_gene_map,
|
|
62
|
+
load_surfy,
|
|
63
|
+
surfaceome_source,
|
|
64
|
+
)
|
|
65
|
+
from bindsight.targets.gtex import GTExTissueExpression
|
|
66
|
+
from bindsight.targets.open_targets import OpenTargetsClient
|
|
67
|
+
|
|
68
|
+
LOG = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
# Cap on how many top candidates (by |log2fc|) get an AlphaFoldDB structure
|
|
71
|
+
# fetch. Only the top-N proceed to design, so fetching for every surface DE gene
|
|
72
|
+
# on a real cohort (hundreds) is wasted work; this keeps discovery fast.
|
|
73
|
+
_STRUCTURE_FETCH_CAP = 25
|
|
74
|
+
|
|
75
|
+
# The enrichment cap lives in TargetDiscoveryParams.enrich_top_k so it is
|
|
76
|
+
# recorded in the run manifest and can be reported as an explicit gate. The
|
|
77
|
+
# module-level constant that used to shadow it is gone: it was retained "for
|
|
78
|
+
# older callers" that do not exist, and it silently duplicated the field's
|
|
79
|
+
# default, so the two could disagree with nothing to notice.
|
|
80
|
+
|
|
81
|
+
#: Sentinel for a gene with no UniProt mapping (see _do_discover).
|
|
82
|
+
_NO_UNIPROT: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Public entry point
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
def run(
|
|
89
|
+
config: RunConfig,
|
|
90
|
+
*,
|
|
91
|
+
out_dir: Path | None = None,
|
|
92
|
+
open_targets_client: OpenTargetsClient | None = None,
|
|
93
|
+
alphafolddb_client: AlphaFoldDBClient | None = None,
|
|
94
|
+
surface_bind_client: SurfaceBindClient | None = None,
|
|
95
|
+
topology_client: UniProtTopologyClient | None = None,
|
|
96
|
+
gtex_client: GTExTissueExpression | None = None,
|
|
97
|
+
surfy: frozenset[str] | None = None,
|
|
98
|
+
) -> Manifest:
|
|
99
|
+
"""Run the discovery half end-to-end and write artifacts to ``out_dir``.
|
|
100
|
+
|
|
101
|
+
The optional client/data arguments exist so tests can inject mocks. In
|
|
102
|
+
normal use, they default to live clients hitting the public APIs.
|
|
103
|
+
"""
|
|
104
|
+
root = run_dir(out_dir or config.out_dir)
|
|
105
|
+
LOG.info("bindsight discover: out=%s name=%s", root, config.name)
|
|
106
|
+
|
|
107
|
+
# Stage 0: ensure real reference data is present (auto-download TCGA cohort
|
|
108
|
+
# from GDC if configured + missing; populate the full SURFY surfaceome cache
|
|
109
|
+
# for production runs). No-ops when data is already present or injected.
|
|
110
|
+
_ensure_reference_data(config)
|
|
111
|
+
surface_bind_client = _resolve_surface_bind_client(surface_bind_client)
|
|
112
|
+
|
|
113
|
+
# Persist the effective configuration inside the run. Nothing used to write
|
|
114
|
+
# it, so a finished run — or an exported crate — could not say what produced
|
|
115
|
+
# it, and any CLI override (--backend, --cheap …) was lost entirely.
|
|
116
|
+
config_path = _write_run_config(config, root)
|
|
117
|
+
|
|
118
|
+
manifest = new_manifest(name=config.name, config_path=str(config_path))
|
|
119
|
+
|
|
120
|
+
# ---- 1. DEG ----
|
|
121
|
+
deg_table_path = root / "deg" / "results.parquet"
|
|
122
|
+
deg_stage = _stage_deg(config, deg_table_path)
|
|
123
|
+
manifest.append(deg_stage)
|
|
124
|
+
if deg_stage.status != "completed":
|
|
125
|
+
manifest.write(root / "run_manifest.jsonld")
|
|
126
|
+
return manifest
|
|
127
|
+
|
|
128
|
+
# ---- 2. Discover ----
|
|
129
|
+
candidates_path = root / "targets" / "candidates.parquet"
|
|
130
|
+
epitopes_path = root / "epitopes" / "epitopes.parquet"
|
|
131
|
+
discover_stage = _stage_discover(
|
|
132
|
+
config,
|
|
133
|
+
deg_table_path=deg_table_path,
|
|
134
|
+
candidates_path=candidates_path,
|
|
135
|
+
epitopes_path=epitopes_path,
|
|
136
|
+
open_targets_client=open_targets_client,
|
|
137
|
+
alphafolddb_client=alphafolddb_client,
|
|
138
|
+
surface_bind_client=surface_bind_client,
|
|
139
|
+
topology_client=topology_client,
|
|
140
|
+
gtex_client=gtex_client,
|
|
141
|
+
surfy=surfy,
|
|
142
|
+
)
|
|
143
|
+
manifest.append(discover_stage)
|
|
144
|
+
|
|
145
|
+
manifest.write(root / "run_manifest.jsonld")
|
|
146
|
+
LOG.info("bindsight discover complete; manifest=%s", root / "run_manifest.jsonld")
|
|
147
|
+
return manifest
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Stage 0: ensure real reference data (GDC cohort + SURFY surfaceome)
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
def _ensure_reference_data(config: RunConfig) -> None:
|
|
154
|
+
"""Auto-download the real input cohort from NIH/GDC when configured + missing.
|
|
155
|
+
|
|
156
|
+
Only fires when ``inputs.download`` is set and the counts/design files don't
|
|
157
|
+
exist yet. (The SURFY surfaceome cache is populated later, inside the
|
|
158
|
+
discover stage, so a missing-inputs run fails fast on DEG without any
|
|
159
|
+
network calls.)
|
|
160
|
+
"""
|
|
161
|
+
counts_p = Path(config.inputs.counts)
|
|
162
|
+
design_p = Path(config.inputs.design)
|
|
163
|
+
dl = config.inputs.download
|
|
164
|
+
if dl is not None and (not counts_p.exists() or not design_p.exists()):
|
|
165
|
+
from bindsight.io.gdc import fetch_cohort
|
|
166
|
+
|
|
167
|
+
LOG.info("inputs missing; auto-downloading %s cohort from GDC", dl.project)
|
|
168
|
+
fetch_cohort(
|
|
169
|
+
project=dl.project,
|
|
170
|
+
n_tumor=dl.n_tumor,
|
|
171
|
+
n_normal=dl.n_normal,
|
|
172
|
+
counts_out=counts_p,
|
|
173
|
+
design_out=design_p,
|
|
174
|
+
gene_types=tuple(dl.gene_types),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _write_run_config(config: RunConfig, root: Path) -> Path:
|
|
179
|
+
"""Write the effective run configuration to ``<run>/config.yaml``.
|
|
180
|
+
|
|
181
|
+
``io/paths.py`` has always documented this file as part of the run layout,
|
|
182
|
+
and nothing ever produced it. Writing the *effective* config — after CLI
|
|
183
|
+
overrides are applied — is what makes a run self-describing and an exported
|
|
184
|
+
crate reproducible.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
config: The resolved run configuration.
|
|
188
|
+
root: The run directory.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Path to the written config file.
|
|
192
|
+
"""
|
|
193
|
+
import yaml
|
|
194
|
+
|
|
195
|
+
out = root / "config.yaml"
|
|
196
|
+
payload = config.model_dump(mode="json", by_alias=True)
|
|
197
|
+
out.write_text(
|
|
198
|
+
"# Effective bindsight run configuration, written by the pipeline.\n"
|
|
199
|
+
"# Includes any command-line overrides applied to the source config.\n"
|
|
200
|
+
+ yaml.safe_dump(payload, sort_keys=False),
|
|
201
|
+
encoding="utf-8",
|
|
202
|
+
newline="\n",
|
|
203
|
+
)
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_surfy(p: object, surfy: frozenset[str] | None) -> frozenset[str]:
|
|
208
|
+
"""Return the SURFY surface set.
|
|
209
|
+
|
|
210
|
+
No network access. The full canonical list is vendored with the package, so
|
|
211
|
+
discovery resolves the surfaceome from disk every time.
|
|
212
|
+
|
|
213
|
+
This used to try ``populate_surfy_cache()`` on a cache miss. That download
|
|
214
|
+
can no longer succeed — upstream serves an HTML page on one host and a
|
|
215
|
+
Git-LFS pointer on the other — so every fresh install either hard-failed or
|
|
216
|
+
silently degraded to a ten-protein list and surfaced almost nothing. Keeping
|
|
217
|
+
the attempt would only add five retries of latency before the same outcome.
|
|
218
|
+
|
|
219
|
+
An explicitly injected set (tests) still wins.
|
|
220
|
+
"""
|
|
221
|
+
from bindsight.config import TargetDiscoveryParams
|
|
222
|
+
|
|
223
|
+
assert isinstance(p, TargetDiscoveryParams)
|
|
224
|
+
if surfy is not None:
|
|
225
|
+
return surfy
|
|
226
|
+
if p.use_extended_surfaceome:
|
|
227
|
+
# SURFY plus UniProt's curated cell-membrane annotations. SURFY alone
|
|
228
|
+
# cannot see CA9 or STEAP1, and CA9 carries the largest effect measured
|
|
229
|
+
# anywhere in the rediscovery panel.
|
|
230
|
+
resolved = load_surfaceome(extended=True)
|
|
231
|
+
LOG.info("surfaceome: %d accessions (SURFY + UniProt cell membrane)", len(resolved))
|
|
232
|
+
return resolved
|
|
233
|
+
return load_surfy(allow_offline_fallback=p.surfy_allow_offline_fallback)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Stage: DEG
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
def _deg_cache_key(inputs: list[InputRef], params: dict[str, Any]) -> str:
|
|
240
|
+
"""Identify a differential-expression computation by its inputs and parameters.
|
|
241
|
+
|
|
242
|
+
Covers the *content* of the counts and design tables, not their paths, so a
|
|
243
|
+
moved or re-downloaded but identical cohort still hits. Any parameter change
|
|
244
|
+
misses, including the worker count, because a run recorded under one
|
|
245
|
+
configuration should not be reported under another.
|
|
246
|
+
"""
|
|
247
|
+
import hashlib
|
|
248
|
+
|
|
249
|
+
# The tool that produced the table is part of the work, not context around
|
|
250
|
+
# it. Without it an upgraded pydeseq2 hits the old cache and the manifest
|
|
251
|
+
# records the reused bytes under the NEW version -- a completed stage
|
|
252
|
+
# attributing one tool's output to another. The design cache already folds
|
|
253
|
+
# its code identity in for exactly this reason; this one did not.
|
|
254
|
+
from bindsight.validate.protocol import UNRECORDED_VERSION, installed_version
|
|
255
|
+
|
|
256
|
+
tool_identity = f"pydeseq2:{installed_version('pydeseq2') or UNRECORDED_VERSION}"
|
|
257
|
+
material = "|".join(
|
|
258
|
+
[
|
|
259
|
+
*(f"{i.role}:{i.sha256}" for i in sorted(inputs, key=lambda x: x.role)),
|
|
260
|
+
json.dumps(params, sort_keys=True, default=str),
|
|
261
|
+
tool_identity,
|
|
262
|
+
]
|
|
263
|
+
)
|
|
264
|
+
return hashlib.sha256(material.encode()).hexdigest()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _stage_deg(config: RunConfig, out_path: Path) -> StageRecord:
|
|
268
|
+
counts_p = Path(config.inputs.counts)
|
|
269
|
+
design_p = Path(config.inputs.design)
|
|
270
|
+
|
|
271
|
+
inputs: list[InputRef] = []
|
|
272
|
+
for role, p in (("counts", counts_p), ("design", design_p)):
|
|
273
|
+
if p.exists():
|
|
274
|
+
inputs.append(
|
|
275
|
+
InputRef(
|
|
276
|
+
role=role,
|
|
277
|
+
path=str(p),
|
|
278
|
+
sha256=sha256_file(p),
|
|
279
|
+
bytes=p.stat().st_size,
|
|
280
|
+
media_type="text/tab-separated-values",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
from pydeseq2 import __version__ as pydeseq2_version
|
|
286
|
+
except ImportError:
|
|
287
|
+
pydeseq2_version = "uninstalled"
|
|
288
|
+
|
|
289
|
+
stage = StageRecord(
|
|
290
|
+
name="deg",
|
|
291
|
+
tool=ToolRef(
|
|
292
|
+
name="pydeseq2",
|
|
293
|
+
version=pydeseq2_version,
|
|
294
|
+
license="MIT",
|
|
295
|
+
repo_url="https://github.com/scverse/PyDESeq2",
|
|
296
|
+
citation="10.1093/bioinformatics/btad547",
|
|
297
|
+
),
|
|
298
|
+
inputs=inputs,
|
|
299
|
+
params=config.params.deg.model_dump(),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if not counts_p.exists() or not design_p.exists():
|
|
303
|
+
stage.mark_failed(
|
|
304
|
+
f"missing input(s): counts_exists={counts_p.exists()} design_exists={design_p.exists()}"
|
|
305
|
+
)
|
|
306
|
+
return stage
|
|
307
|
+
|
|
308
|
+
# Differential expression is by far the most expensive stage — minutes to hours
|
|
309
|
+
# on a real cohort — and it depends only on the counts, the design and the DEG
|
|
310
|
+
# parameters. Re-running it because something downstream changed wastes that
|
|
311
|
+
# time for an identical answer. The key covers the content of both inputs and
|
|
312
|
+
# every parameter, so a cache hit is only ever the same computation.
|
|
313
|
+
cache_key = _deg_cache_key(inputs, config.params.deg.model_dump())
|
|
314
|
+
key_path = out_path.with_suffix(".cache_key")
|
|
315
|
+
if (
|
|
316
|
+
out_path.exists()
|
|
317
|
+
and out_path.stat().st_size > 0
|
|
318
|
+
and key_path.exists()
|
|
319
|
+
and key_path.read_text(encoding="utf-8").strip() == cache_key
|
|
320
|
+
):
|
|
321
|
+
LOG.info("DEG cache hit (%s); reusing %s", cache_key[:8], out_path)
|
|
322
|
+
stage.cache_key = cache_key
|
|
323
|
+
stage.cache_status = "hit"
|
|
324
|
+
stage.notes = "reused an existing DEG table with identical inputs and parameters"
|
|
325
|
+
stage.mark_completed(
|
|
326
|
+
outputs=[
|
|
327
|
+
OutputRef(
|
|
328
|
+
role="deg_table",
|
|
329
|
+
path=str(out_path),
|
|
330
|
+
sha256=sha256_file(out_path),
|
|
331
|
+
bytes=out_path.stat().st_size,
|
|
332
|
+
media_type="application/x-parquet",
|
|
333
|
+
)
|
|
334
|
+
]
|
|
335
|
+
)
|
|
336
|
+
return stage
|
|
337
|
+
|
|
338
|
+
stage.cache_key = cache_key
|
|
339
|
+
stage.cache_status = "miss"
|
|
340
|
+
try:
|
|
341
|
+
runner = PyDESeq2Runner(config.params.deg)
|
|
342
|
+
metrics = runner.run(counts_p, design_p, out_path)
|
|
343
|
+
key_path.write_text(cache_key, encoding="utf-8", newline="\n")
|
|
344
|
+
stage.notes = (
|
|
345
|
+
f"n_samples={metrics['n_samples']}, "
|
|
346
|
+
f"n_genes_tested={metrics['n_genes_tested']}, "
|
|
347
|
+
f"n_significant={metrics['n_significant']}"
|
|
348
|
+
)
|
|
349
|
+
stage.mark_completed(
|
|
350
|
+
outputs=[
|
|
351
|
+
OutputRef(
|
|
352
|
+
role="deg_table",
|
|
353
|
+
path=str(out_path),
|
|
354
|
+
sha256=sha256_file(out_path),
|
|
355
|
+
bytes=out_path.stat().st_size,
|
|
356
|
+
media_type="application/x-parquet",
|
|
357
|
+
)
|
|
358
|
+
]
|
|
359
|
+
)
|
|
360
|
+
except Exception as e:
|
|
361
|
+
LOG.exception("DEG stage failed")
|
|
362
|
+
stage.mark_failed(repr(e))
|
|
363
|
+
return stage
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# Stage: discover (joins DEGs → Open Targets → SURFY → SURFACE-Bind → AFDB)
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
def _stage_discover(
|
|
370
|
+
config: RunConfig,
|
|
371
|
+
*,
|
|
372
|
+
deg_table_path: Path,
|
|
373
|
+
candidates_path: Path,
|
|
374
|
+
epitopes_path: Path,
|
|
375
|
+
open_targets_client: OpenTargetsClient | None,
|
|
376
|
+
alphafolddb_client: AlphaFoldDBClient | None,
|
|
377
|
+
surface_bind_client: SurfaceBindClient | None,
|
|
378
|
+
topology_client: UniProtTopologyClient | None,
|
|
379
|
+
gtex_client: GTExTissueExpression | None,
|
|
380
|
+
surfy: frozenset[str] | None,
|
|
381
|
+
) -> StageRecord:
|
|
382
|
+
stage = StageRecord(
|
|
383
|
+
name="discover",
|
|
384
|
+
tool=ToolRef(
|
|
385
|
+
name=f"bindsight/{__version__}",
|
|
386
|
+
version=__version__,
|
|
387
|
+
license="AGPL-3.0-or-later",
|
|
388
|
+
repo_url="https://github.com/mikhaeelatefrizk/bindsight",
|
|
389
|
+
),
|
|
390
|
+
inputs=[
|
|
391
|
+
InputRef(
|
|
392
|
+
role="deg_table",
|
|
393
|
+
path=str(deg_table_path),
|
|
394
|
+
sha256=sha256_file(deg_table_path),
|
|
395
|
+
bytes=deg_table_path.stat().st_size,
|
|
396
|
+
media_type="application/x-parquet",
|
|
397
|
+
)
|
|
398
|
+
],
|
|
399
|
+
params=config.params.target_discovery.model_dump()
|
|
400
|
+
| {
|
|
401
|
+
# Which surfaceome list this run actually resolved. The choice
|
|
402
|
+
# between a user-refreshed cache and the vendored list is made
|
|
403
|
+
# inside load_surfy and was recorded nowhere, so two runs could use
|
|
404
|
+
# different lists -- different eligible denominators, different
|
|
405
|
+
# counterfactual ranks -- with nothing in either manifest saying so.
|
|
406
|
+
"surfaceome_source": surfaceome_source(),
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
for _title, _body in DISCOVERY_LIMITATIONS:
|
|
411
|
+
LOG.warning("discovery limitation — %s: %s", _title, _body)
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
candidates_df, epitopes_df, taxonomy_df = _do_discover(
|
|
415
|
+
config=config,
|
|
416
|
+
deg_table_path=deg_table_path,
|
|
417
|
+
open_targets_client=open_targets_client,
|
|
418
|
+
alphafolddb_client=alphafolddb_client,
|
|
419
|
+
surface_bind_client=surface_bind_client,
|
|
420
|
+
topology_client=topology_client,
|
|
421
|
+
gtex_client=gtex_client,
|
|
422
|
+
surfy=surfy,
|
|
423
|
+
)
|
|
424
|
+
candidates_path.parent.mkdir(parents=True, exist_ok=True)
|
|
425
|
+
epitopes_path.parent.mkdir(parents=True, exist_ok=True)
|
|
426
|
+
taxonomy_path = candidates_path.parent.parent / "taxonomy" / "failure_taxonomy.parquet"
|
|
427
|
+
taxonomy_path.parent.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
candidates_df.to_parquet(candidates_path, index=False)
|
|
429
|
+
epitopes_df.to_parquet(epitopes_path, index=False)
|
|
430
|
+
taxonomy_df.to_parquet(taxonomy_path, index=False)
|
|
431
|
+
|
|
432
|
+
disp_counts = taxonomy_df["disposition"].value_counts().to_dict()
|
|
433
|
+
stage.notes = (
|
|
434
|
+
f"n_candidates={len(candidates_df)}, "
|
|
435
|
+
f"n_with_structure={int(candidates_df['has_alphafold_structure'].sum())}, "
|
|
436
|
+
f"n_top={int((candidates_df['rank_in_top_n']).sum())}; "
|
|
437
|
+
f"taxonomy({len(taxonomy_df)} genes)="
|
|
438
|
+
+ ",".join(f"{k}:{v}" for k, v in sorted(disp_counts.items()))
|
|
439
|
+
+ "; "
|
|
440
|
+
+ caveat_summary()
|
|
441
|
+
)
|
|
442
|
+
stage.mark_completed(
|
|
443
|
+
outputs=[
|
|
444
|
+
OutputRef(
|
|
445
|
+
role="candidates",
|
|
446
|
+
path=str(candidates_path),
|
|
447
|
+
sha256=sha256_file(candidates_path),
|
|
448
|
+
bytes=candidates_path.stat().st_size,
|
|
449
|
+
media_type="application/x-parquet",
|
|
450
|
+
),
|
|
451
|
+
OutputRef(
|
|
452
|
+
role="epitopes",
|
|
453
|
+
path=str(epitopes_path),
|
|
454
|
+
sha256=sha256_file(epitopes_path),
|
|
455
|
+
bytes=epitopes_path.stat().st_size,
|
|
456
|
+
media_type="application/x-parquet",
|
|
457
|
+
),
|
|
458
|
+
OutputRef(
|
|
459
|
+
role="failure_taxonomy",
|
|
460
|
+
path=str(taxonomy_path),
|
|
461
|
+
sha256=sha256_file(taxonomy_path),
|
|
462
|
+
bytes=taxonomy_path.stat().st_size,
|
|
463
|
+
media_type="application/x-parquet",
|
|
464
|
+
),
|
|
465
|
+
]
|
|
466
|
+
)
|
|
467
|
+
except Exception as e:
|
|
468
|
+
LOG.exception("discover stage failed")
|
|
469
|
+
stage.mark_failed(repr(e))
|
|
470
|
+
return stage
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _do_discover(
|
|
474
|
+
*,
|
|
475
|
+
config: RunConfig,
|
|
476
|
+
deg_table_path: Path,
|
|
477
|
+
open_targets_client: OpenTargetsClient | None,
|
|
478
|
+
alphafolddb_client: AlphaFoldDBClient | None,
|
|
479
|
+
surface_bind_client: SurfaceBindClient | None,
|
|
480
|
+
topology_client: UniProtTopologyClient | None,
|
|
481
|
+
gtex_client: GTExTissueExpression | None,
|
|
482
|
+
surfy: frozenset[str] | None,
|
|
483
|
+
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
484
|
+
"""Pure-data discovery logic; returns (candidates_df, epitopes_df, taxonomy_df)."""
|
|
485
|
+
p = config.params.target_discovery
|
|
486
|
+
# The run root, derived the same way the taxonomy path is: the DEG table
|
|
487
|
+
# lives at <run>/deg/results.parquet. Structures are adopted into <run>/
|
|
488
|
+
# structures/ so the run stays portable.
|
|
489
|
+
run_root = deg_table_path.parent.parent
|
|
490
|
+
|
|
491
|
+
# 1. Load DEGs and keep significant ones. Antibody targets need tumor
|
|
492
|
+
# over-expression, so carry the most up-regulated significant genes into
|
|
493
|
+
# enrichment (bounded — a real cohort has thousands of significant DEGs).
|
|
494
|
+
deg = pd.read_parquet(deg_table_path)
|
|
495
|
+
sig = deg[deg["significant"]].copy()
|
|
496
|
+
n_sig = len(sig)
|
|
497
|
+
|
|
498
|
+
# The surfaceome must be resolved before the enrichment cut, because the cut
|
|
499
|
+
# is now spent on surface proteins rather than on the whole genome.
|
|
500
|
+
surfy_set = _resolve_surfy(p, surfy)
|
|
501
|
+
|
|
502
|
+
# 1a. Surfaceome pre-filter. Enrichment slots are a scarce resource, and
|
|
503
|
+
# spending them on genes that could never be antibody targets discards most
|
|
504
|
+
# of the surfaceome before it is ever examined. Filtering first is free: the
|
|
505
|
+
# number of Open Targets calls is unchanged, only which genes get them.
|
|
506
|
+
surfaceome_gene_ids: frozenset[str] = frozenset()
|
|
507
|
+
n_before_prefilter = len(sig)
|
|
508
|
+
if p.surfaceome_prefilter and p.require_surfy:
|
|
509
|
+
surfaceome_gene_ids = frozenset(
|
|
510
|
+
gene
|
|
511
|
+
for gene, accession in load_surfaceome_gene_map(
|
|
512
|
+
extended=p.use_extended_surfaceome
|
|
513
|
+
).items()
|
|
514
|
+
if accession in surfy_set
|
|
515
|
+
)
|
|
516
|
+
if surfaceome_gene_ids:
|
|
517
|
+
sig = sig[sig["gene_id"].astype(str).isin(surfaceome_gene_ids)].copy()
|
|
518
|
+
LOG.info(
|
|
519
|
+
"surfaceome pre-filter: %d significant gene(s) → %d on the surfaceome",
|
|
520
|
+
n_before_prefilter,
|
|
521
|
+
len(sig),
|
|
522
|
+
)
|
|
523
|
+
else:
|
|
524
|
+
# Without the map the pre-filter cannot run, and silently skipping it
|
|
525
|
+
# would change the science without saying so.
|
|
526
|
+
LOG.warning(
|
|
527
|
+
"surfaceome gene map is unavailable; falling back to filtering AFTER "
|
|
528
|
+
"enrichment, which spends the cut on the whole genome"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Carry the most *confidently* over-expressed genes into enrichment, ranked
|
|
532
|
+
# by the combined DE score π = log2fc × −log10(padj) rather than raw
|
|
533
|
+
# fold-change — so a highly-significant, abundant antigen with a moderate
|
|
534
|
+
# ratio (e.g. PSMA) is not crowded out by noisy high-fold-change genes.
|
|
535
|
+
sig["pi_score"] = _pi_score(sig)
|
|
536
|
+
sig = sig.sort_values("pi_score", ascending=False).head(p.enrich_top_k)
|
|
537
|
+
enriched_gene_ids = {str(g) for g in sig["gene_id"]}
|
|
538
|
+
LOG.info(
|
|
539
|
+
"DEGs: %d total, %d significant; enriching top %d by combined score (π)",
|
|
540
|
+
len(deg),
|
|
541
|
+
n_sig,
|
|
542
|
+
len(sig),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# Default clients
|
|
546
|
+
ot = open_targets_client or OpenTargetsClient()
|
|
547
|
+
afdb = alphafolddb_client or AlphaFoldDBClient()
|
|
548
|
+
|
|
549
|
+
# 2. For each significant gene, enrich via Open Targets (or fall back to
|
|
550
|
+
# the bundled offline map for well-known genes — used by the demo and
|
|
551
|
+
# when Open Targets is unreachable).
|
|
552
|
+
from bindsight.targets import ensembl_uniprot
|
|
553
|
+
|
|
554
|
+
enriched_rows: list[dict[str, object]] = []
|
|
555
|
+
for _, row in sig.iterrows():
|
|
556
|
+
gene_id = str(row["gene_id"])
|
|
557
|
+
ev = None
|
|
558
|
+
ot_status = "skipped"
|
|
559
|
+
if p.use_open_targets:
|
|
560
|
+
try:
|
|
561
|
+
ev = ot.get_target(gene_id)
|
|
562
|
+
ot_status = "ok" if ev is not None else "no_record"
|
|
563
|
+
except Exception as e:
|
|
564
|
+
LOG.warning("Open Targets failed for %s: %s", gene_id, e)
|
|
565
|
+
ot_status = f"error:{type(e).__name__}"
|
|
566
|
+
|
|
567
|
+
uniprot_ids = ev.uniprot_ids if ev else []
|
|
568
|
+
modalities = ev.tractability_modalities if ev else []
|
|
569
|
+
symbol = ev.symbol if ev else None
|
|
570
|
+
|
|
571
|
+
# Offline / fallback path: consult the bundled ENSG → UniProt map for
|
|
572
|
+
# well-known genes so the demo (and other offline runs) still produce
|
|
573
|
+
# candidates.
|
|
574
|
+
if not uniprot_ids:
|
|
575
|
+
fb_symbol, fb_uniprot = ensembl_uniprot.lookup(gene_id)
|
|
576
|
+
if fb_uniprot:
|
|
577
|
+
uniprot_ids = [fb_uniprot]
|
|
578
|
+
if symbol is None:
|
|
579
|
+
symbol = fb_symbol
|
|
580
|
+
if ot_status == "skipped":
|
|
581
|
+
ot_status = "bundled_fallback"
|
|
582
|
+
|
|
583
|
+
# [None] is the deliberate sentinel for 'gene mapped to no UniProt
|
|
584
|
+
# accession'; the row is still emitted so the failure taxonomy can
|
|
585
|
+
# record it as no_uniprot rather than dropping it silently.
|
|
586
|
+
candidates_for_gene: list[str | None] = list(uniprot_ids) or [_NO_UNIPROT]
|
|
587
|
+
for uniprot_id in candidates_for_gene:
|
|
588
|
+
enriched_rows.append(
|
|
589
|
+
{
|
|
590
|
+
"gene_id": gene_id,
|
|
591
|
+
"symbol": symbol,
|
|
592
|
+
"uniprot_id": uniprot_id,
|
|
593
|
+
"log2fc": float(row["log2fc"]),
|
|
594
|
+
"padj": float(row["padj"]) if pd.notna(row["padj"]) else None,
|
|
595
|
+
"tractable_modalities": ";".join(modalities),
|
|
596
|
+
"open_targets_status": ot_status,
|
|
597
|
+
"n_safety_events": ev.safety_event_count if ev else 0,
|
|
598
|
+
# Whether that count is a measurement or a default. Without
|
|
599
|
+
# this, a gene whose Open Targets lookup errored carries 0
|
|
600
|
+
# and sails through a filter meant to exclude genes with
|
|
601
|
+
# known safety events. Silence is not zero.
|
|
602
|
+
"safety_events_measured": ev is not None,
|
|
603
|
+
}
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
candidates = pd.DataFrame(enriched_rows)
|
|
607
|
+
enriched_all = candidates.copy()
|
|
608
|
+
if candidates.empty:
|
|
609
|
+
LOG.warning("no candidates after Open Targets enrichment")
|
|
610
|
+
taxonomy = _build_taxonomy(
|
|
611
|
+
deg,
|
|
612
|
+
enriched_gene_ids,
|
|
613
|
+
enriched_all,
|
|
614
|
+
_empty_candidates_frame(),
|
|
615
|
+
_empty_epitopes_frame(),
|
|
616
|
+
surfy_set,
|
|
617
|
+
p,
|
|
618
|
+
surface_bind_active=surface_bind_client is not None,
|
|
619
|
+
structure_queried=frozenset(),
|
|
620
|
+
surfaceome_gene_ids=surfaceome_gene_ids,
|
|
621
|
+
)
|
|
622
|
+
return _empty_candidates_frame(), _empty_epitopes_frame(), taxonomy
|
|
623
|
+
|
|
624
|
+
# 3. Surfaceome filter.
|
|
625
|
+
if p.require_surfy:
|
|
626
|
+
before = len(candidates)
|
|
627
|
+
candidates["is_surface"] = candidates["uniprot_id"].apply(
|
|
628
|
+
lambda u: bool(u) and is_surface_protein(u, surfy=surfy_set)
|
|
629
|
+
)
|
|
630
|
+
candidates = candidates[candidates["is_surface"]].copy()
|
|
631
|
+
LOG.info("surfaceome filter: %d → %d", before, len(candidates))
|
|
632
|
+
else:
|
|
633
|
+
candidates["is_surface"] = True
|
|
634
|
+
|
|
635
|
+
# 4. Tractability filter (optional).
|
|
636
|
+
if p.require_tractable_modality and not candidates.empty:
|
|
637
|
+
before = len(candidates)
|
|
638
|
+
wanted = set(p.require_tractable_modality)
|
|
639
|
+
|
|
640
|
+
def _has_wanted_modality(s: str) -> bool:
|
|
641
|
+
modalities = {m.strip() for m in s.split(";") if m.strip()}
|
|
642
|
+
return bool(wanted & modalities)
|
|
643
|
+
|
|
644
|
+
candidates = candidates[
|
|
645
|
+
candidates["tractable_modalities"].fillna("").apply(_has_wanted_modality)
|
|
646
|
+
].copy()
|
|
647
|
+
LOG.info("tractability filter (%s): %d → %d", sorted(wanted), before, len(candidates))
|
|
648
|
+
|
|
649
|
+
# 5. Safety filter (optional).
|
|
650
|
+
#
|
|
651
|
+
# An unreachable Open Targets is not a clean bill of health. The count
|
|
652
|
+
# defaults to zero when the lookup returns nothing, so before this a rate
|
|
653
|
+
# limit or a network blip promoted every affected gene to "no known safety
|
|
654
|
+
# events" — the same fail-open the GTEx gate beside it was fixed not to do.
|
|
655
|
+
# When the gate is switched off entirely the count claims nothing either
|
|
656
|
+
# way, so nothing is withheld on its account.
|
|
657
|
+
if not candidates.empty:
|
|
658
|
+
before = len(candidates)
|
|
659
|
+
within = candidates["n_safety_events"] <= p.max_safety_events
|
|
660
|
+
gate_claims = p.use_open_targets and p.open_targets_require_measured
|
|
661
|
+
if gate_claims and "safety_events_measured" in candidates.columns:
|
|
662
|
+
measured = candidates["safety_events_measured"].fillna(False).astype(bool)
|
|
663
|
+
withheld = int((~measured).sum())
|
|
664
|
+
if withheld:
|
|
665
|
+
LOG.warning(
|
|
666
|
+
"safety gate: %d candidate(s) withheld because Open Targets "
|
|
667
|
+
"returned no record, so their safety-event count is unknown "
|
|
668
|
+
"rather than zero",
|
|
669
|
+
withheld,
|
|
670
|
+
)
|
|
671
|
+
candidates = candidates[within & measured].copy()
|
|
672
|
+
else:
|
|
673
|
+
candidates = candidates[within].copy()
|
|
674
|
+
LOG.info(
|
|
675
|
+
"safety filter (≤%d events): %d → %d", p.max_safety_events, before, len(candidates)
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# 6. Pull AlphaFoldDB structures + tag. Only the strongest candidates carry
|
|
679
|
+
# forward to design, so we fetch structures for the top ones by |log2fc|
|
|
680
|
+
# (capped) rather than every surface DE gene — on a real cohort that can
|
|
681
|
+
# be hundreds, and the rest are never used downstream.
|
|
682
|
+
#
|
|
683
|
+
# ``structure_queried`` records the accessions whose AlphaFoldDB lookup
|
|
684
|
+
# actually completed. Everything else — below the cap, or a lookup that
|
|
685
|
+
# errored — was never assessed, which the taxonomy must not report as
|
|
686
|
+
# "no model exists".
|
|
687
|
+
structure_queried: set[str] = set()
|
|
688
|
+
if not candidates.empty:
|
|
689
|
+
candidates["pi_score"] = _pi_score(candidates)
|
|
690
|
+
candidates = candidates.sort_values(by="pi_score", ascending=False).reset_index(drop=True)
|
|
691
|
+
n_fetch = max(p.top_n, _STRUCTURE_FETCH_CAP)
|
|
692
|
+
fetch_uniprots = sorted(
|
|
693
|
+
{u for u in candidates.head(n_fetch)["uniprot_id"].dropna().unique() if u}
|
|
694
|
+
)
|
|
695
|
+
# Structures are fetched into the shared cache, then adopted into the
|
|
696
|
+
# run's own structures/ directory and referenced run-relatively. Storing
|
|
697
|
+
# the absolute cache path made every run — and every RO-Crate built from
|
|
698
|
+
# one — valid on exactly one machine.
|
|
699
|
+
struct_paths: dict[str, str] = {}
|
|
700
|
+
for uid in fetch_uniprots:
|
|
701
|
+
try:
|
|
702
|
+
cached = afdb.fetch(uid)
|
|
703
|
+
except Exception as e:
|
|
704
|
+
# A failed lookup is not evidence that no model exists; leaving the
|
|
705
|
+
# accession out of ``structure_queried`` keeps the taxonomy honest.
|
|
706
|
+
LOG.warning("AlphaFoldDB fetch failed for %s: %s", uid, e)
|
|
707
|
+
continue
|
|
708
|
+
structure_queried.add(uid)
|
|
709
|
+
if cached is None:
|
|
710
|
+
continue
|
|
711
|
+
try:
|
|
712
|
+
struct_paths[uid] = adopt_structure(run_root, cached)
|
|
713
|
+
except OSError as e:
|
|
714
|
+
# A copy failure must not lose the structure; fall back to the
|
|
715
|
+
# cache path, which older runs used exclusively anyway.
|
|
716
|
+
LOG.warning("could not adopt structure for %s into the run (%s)", uid, e)
|
|
717
|
+
struct_paths[uid] = str(cached)
|
|
718
|
+
candidates["alphafold_structure_path"] = candidates["uniprot_id"].map(
|
|
719
|
+
lambda u: struct_paths.get(u, "") if u else ""
|
|
720
|
+
)
|
|
721
|
+
candidates["has_alphafold_structure"] = candidates["alphafold_structure_path"] != ""
|
|
722
|
+
else:
|
|
723
|
+
candidates["alphafold_structure_path"] = ""
|
|
724
|
+
candidates["has_alphafold_structure"] = False
|
|
725
|
+
|
|
726
|
+
# 6b. Disorder signal: per-model mean pLDDT (AlphaFold confidence, 0-100),
|
|
727
|
+
# read straight from the cached mmCIF B-factor column. Always surfaced; only
|
|
728
|
+
# gates carry-forward when ``min_mean_plddt`` is set. A mostly-disordered /
|
|
729
|
+
# low-confidence model cannot be reliably designed against.
|
|
730
|
+
candidates["mean_plddt"] = candidates["alphafold_structure_path"].map(
|
|
731
|
+
lambda pth: mean_plddt(resolved) if (resolved := resolve_run_path(run_root, pth)) else None
|
|
732
|
+
)
|
|
733
|
+
if p.min_mean_plddt > 0:
|
|
734
|
+
low_conf = (
|
|
735
|
+
candidates["has_alphafold_structure"]
|
|
736
|
+
& candidates["mean_plddt"].notna()
|
|
737
|
+
& (candidates["mean_plddt"] < p.min_mean_plddt)
|
|
738
|
+
)
|
|
739
|
+
else:
|
|
740
|
+
low_conf = pd.Series(False, index=candidates.index)
|
|
741
|
+
candidates["low_confidence_structure"] = low_conf
|
|
742
|
+
# A model whose pLDDT could not be read is not a model that passed. The gate
|
|
743
|
+
# above tests `mean_plddt.notna()`, so an unreadable confidence fell through
|
|
744
|
+
# to the accepting side and an unmeasured structure was carried forward as
|
|
745
|
+
# though it had cleared the bar. Recorded rather than gated, mirroring
|
|
746
|
+
# `normal_tissue_unassessed`: what a reader needs is the difference between
|
|
747
|
+
# a model that passed and one never measured, and dropping the second for an
|
|
748
|
+
# infrastructure reason would be its own error.
|
|
749
|
+
candidates["structure_confidence_unassessed"] = (
|
|
750
|
+
candidates["has_alphafold_structure"]
|
|
751
|
+
& candidates["mean_plddt"].isna()
|
|
752
|
+
& (p.min_mean_plddt > 0)
|
|
753
|
+
)
|
|
754
|
+
if bool(candidates["structure_confidence_unassessed"].any()):
|
|
755
|
+
LOG.info(
|
|
756
|
+
"pLDDT gate: %d candidate(s) carry a structure whose confidence could not "
|
|
757
|
+
"be read; recorded as structure_confidence_unassessed, not as passing",
|
|
758
|
+
int(candidates["structure_confidence_unassessed"].sum()),
|
|
759
|
+
)
|
|
760
|
+
if bool(low_conf.any()):
|
|
761
|
+
# Distinct from no_alphafold_model: the model exists but is too disordered.
|
|
762
|
+
candidates.loc[low_conf, "has_alphafold_structure"] = False
|
|
763
|
+
LOG.info(
|
|
764
|
+
"pLDDT gate: %d candidate(s) below mean pLDDT %.0f -> low_confidence_structure",
|
|
765
|
+
int(low_conf.sum()),
|
|
766
|
+
p.min_mean_plddt,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# 6c. Membrane topology (extracellular-domain awareness) — opt-in (UniProt).
|
|
770
|
+
# A binder only reaches the extracellular part of a surface protein, so when
|
|
771
|
+
# enabled we annotate each candidate's extracellular ranges (and target the
|
|
772
|
+
# ECD for whole-surface design). Off by default — requires UniProt network.
|
|
773
|
+
topo_map: dict[str, Topology] = {}
|
|
774
|
+
if p.use_uniprot_topology and not candidates.empty:
|
|
775
|
+
topo_client = topology_client or UniProtTopologyClient()
|
|
776
|
+
topo_fetch = sorted(
|
|
777
|
+
{
|
|
778
|
+
u
|
|
779
|
+
for u in candidates.head(max(p.top_n, _STRUCTURE_FETCH_CAP))["uniprot_id"]
|
|
780
|
+
.dropna()
|
|
781
|
+
.unique()
|
|
782
|
+
if u
|
|
783
|
+
}
|
|
784
|
+
)
|
|
785
|
+
for uid in topo_fetch:
|
|
786
|
+
try:
|
|
787
|
+
t = topo_client.fetch(uid)
|
|
788
|
+
except Exception as e: # network/parse — fall back to whole-surface
|
|
789
|
+
LOG.warning("UniProt topology fetch failed for %s: %s", uid, e)
|
|
790
|
+
t = None
|
|
791
|
+
if t is not None:
|
|
792
|
+
topo_map[uid] = t
|
|
793
|
+
candidates["has_extracellular_domain"] = candidates["uniprot_id"].map(
|
|
794
|
+
lambda u: topo_map[u].has_extracellular if u in topo_map else None
|
|
795
|
+
)
|
|
796
|
+
candidates["extracellular_ranges"] = candidates["uniprot_id"].map(
|
|
797
|
+
lambda u: [list(r) for r in topo_map[u].extracellular_ranges] if u in topo_map else None
|
|
798
|
+
)
|
|
799
|
+
if p.require_extracellular_domain:
|
|
800
|
+
no_ecd = candidates["uniprot_id"].map(
|
|
801
|
+
lambda u: u in topo_map and not topo_map[u].has_extracellular
|
|
802
|
+
)
|
|
803
|
+
candidates["no_extracellular_domain"] = no_ecd
|
|
804
|
+
if bool(no_ecd.any()):
|
|
805
|
+
# Not antibody-accessible — drop from design carry-forward.
|
|
806
|
+
candidates.loc[no_ecd, "has_alphafold_structure"] = False
|
|
807
|
+
LOG.info(
|
|
808
|
+
"topology gate: %d candidate(s) with no extracellular domain", int(no_ecd.sum())
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
# 6d. Normal-tissue safety (GTEx) — opt-in. A binder against a target that is
|
|
812
|
+
# also highly expressed in vital normal tissue risks on-target/off-tumor
|
|
813
|
+
# toxicity. Flag candidates over the vital-tissue TPM threshold and drop them
|
|
814
|
+
# from design carry-forward. Off by default — requires the GTEx download.
|
|
815
|
+
if p.use_gtex_safety and not candidates.empty:
|
|
816
|
+
gtex = gtex_client or GTExTissueExpression()
|
|
817
|
+
# assess() separates a *measured* pass from an absent measurement. Using
|
|
818
|
+
# max_expression() directly conflated the two: a gene GTEx has no entry
|
|
819
|
+
# for returned None, which compared False against the ceiling and so was
|
|
820
|
+
# published as having cleared a safety gate that never ran on it.
|
|
821
|
+
verdicts = {
|
|
822
|
+
str(g): gtex.assess(str(g), p.vital_tissues, p.vital_tissue_max_tpm)
|
|
823
|
+
for g in candidates["gene_id"]
|
|
824
|
+
if g is not None
|
|
825
|
+
}
|
|
826
|
+
candidates["max_vital_tissue_tpm"] = candidates["gene_id"].map(
|
|
827
|
+
lambda g: verdicts[str(g)].max_tpm if g is not None and str(g) in verdicts else None
|
|
828
|
+
)
|
|
829
|
+
candidates["gtex_safety_status"] = candidates["gene_id"].map(
|
|
830
|
+
lambda g: (
|
|
831
|
+
verdicts[str(g)].status if g is not None and str(g) in verdicts else "unassessed"
|
|
832
|
+
)
|
|
833
|
+
)
|
|
834
|
+
tissue_unsafe = candidates["gtex_safety_status"] == "unsafe"
|
|
835
|
+
tissue_unassessed = candidates["gtex_safety_status"] == "unassessed"
|
|
836
|
+
candidates["high_normal_tissue_expression"] = tissue_unsafe
|
|
837
|
+
candidates["normal_tissue_unassessed"] = tissue_unassessed & p.gtex_require_measured
|
|
838
|
+
if bool(tissue_unsafe.any()):
|
|
839
|
+
candidates.loc[tissue_unsafe, "has_alphafold_structure"] = False
|
|
840
|
+
LOG.info(
|
|
841
|
+
"GTEx tissue-safety: %d candidate(s) over %.1f TPM in a vital tissue "
|
|
842
|
+
"-> high_normal_tissue_expression",
|
|
843
|
+
int(tissue_unsafe.sum()),
|
|
844
|
+
p.vital_tissue_max_tpm,
|
|
845
|
+
)
|
|
846
|
+
if bool(tissue_unassessed.any()):
|
|
847
|
+
if p.gtex_require_measured:
|
|
848
|
+
candidates.loc[tissue_unassessed, "has_alphafold_structure"] = False
|
|
849
|
+
LOG.info(
|
|
850
|
+
"GTEx tissue-safety: %d candidate(s) have no GTEx entry -> %s",
|
|
851
|
+
int(tissue_unassessed.sum()),
|
|
852
|
+
"normal_tissue_unassessed (withheld)"
|
|
853
|
+
if p.gtex_require_measured
|
|
854
|
+
else "unassessed (accepted; gtex_require_measured is False)",
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
# 7. Rank by the combined DE score π = log2fc × −log10(padj) (Xiao et al.
|
|
858
|
+
# 2014). Higher π = more confidently over-expressed. π is the *only* rank
|
|
859
|
+
# key, so a published rank is a deterministic function of the statistics:
|
|
860
|
+
# structure availability is network-dependent (fetched only for a capped
|
|
861
|
+
# head of the list, and a failed fetch is indistinguishable from a missing
|
|
862
|
+
# model), so ranking on it let a transient AlphaFoldDB outage reorder the
|
|
863
|
+
# table. Gene/accession break π ties so equal scores order identically
|
|
864
|
+
# across runs.
|
|
865
|
+
if "pi_score" not in candidates.columns:
|
|
866
|
+
candidates["pi_score"] = _pi_score(candidates)
|
|
867
|
+
candidates = candidates.sort_values(
|
|
868
|
+
by=["pi_score", "gene_id", "uniprot_id"],
|
|
869
|
+
ascending=[False, True, True],
|
|
870
|
+
kind="stable",
|
|
871
|
+
).reset_index(drop=True)
|
|
872
|
+
candidates["rank"] = range(1, len(candidates) + 1)
|
|
873
|
+
# Design eligibility is a separate criterion, carried by the
|
|
874
|
+
# has_alphafold_structure column rather than by the rank: only
|
|
875
|
+
# structure-bearing candidates can proceed to design, so the design
|
|
876
|
+
# shortlist takes those first, in rank order.
|
|
877
|
+
design_shortlist = candidates.sort_values(
|
|
878
|
+
by=["has_alphafold_structure", "rank"],
|
|
879
|
+
ascending=[False, True],
|
|
880
|
+
kind="stable",
|
|
881
|
+
).index[: p.top_n]
|
|
882
|
+
candidates["rank_in_top_n"] = candidates.index.isin(design_shortlist)
|
|
883
|
+
|
|
884
|
+
# 8. Build the epitopes table from SURFACE-Bind targetable sites when the
|
|
885
|
+
# data is vendored; otherwise design against the whole surface, recorded
|
|
886
|
+
# honestly in ``epitope_status``.
|
|
887
|
+
epitopes = _build_epitopes(
|
|
888
|
+
candidates[candidates["rank_in_top_n"]],
|
|
889
|
+
surface_bind_client,
|
|
890
|
+
p,
|
|
891
|
+
topology_map=topo_map,
|
|
892
|
+
run_root=run_root,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
# 9. Negative-result taxonomy: one disposition per DEG gene, explaining why it
|
|
896
|
+
# is / isn't a surfaced candidate. Every gene is accounted for (the counts sum
|
|
897
|
+
# to the DEG total) — the failure modes are a first-class, auditable output.
|
|
898
|
+
taxonomy = _build_taxonomy(
|
|
899
|
+
deg,
|
|
900
|
+
enriched_gene_ids,
|
|
901
|
+
enriched_all,
|
|
902
|
+
candidates,
|
|
903
|
+
epitopes,
|
|
904
|
+
surfy_set,
|
|
905
|
+
p,
|
|
906
|
+
surface_bind_active=surface_bind_client is not None,
|
|
907
|
+
structure_queried=frozenset(structure_queried),
|
|
908
|
+
surfaceome_gene_ids=surfaceome_gene_ids,
|
|
909
|
+
)
|
|
910
|
+
return candidates, epitopes, taxonomy
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
# Negative-result taxonomy: the ordered dispositions a DEG gene can land in, from
|
|
914
|
+
# "never in contention" to "surfaced". The funnel is exhaustive, so the per-
|
|
915
|
+
# disposition counts always sum to the total DEG gene count.
|
|
916
|
+
#
|
|
917
|
+
# ``uniprot_lookup_failed`` and ``structure_not_queried`` are *not* findings: they
|
|
918
|
+
# mark genes whose lookup errored or was never attempted. Only a lookup that ran
|
|
919
|
+
# and came back empty earns ``no_uniprot`` / ``no_alphafold_model``, so a network
|
|
920
|
+
# outage can never be published as a negative result.
|
|
921
|
+
TAXONOMY_DISPOSITIONS: tuple[str, ...] = (
|
|
922
|
+
"not_significant",
|
|
923
|
+
"significance_unassessed",
|
|
924
|
+
"down_regulated",
|
|
925
|
+
"below_enrichment_cutoff",
|
|
926
|
+
"uniprot_lookup_failed",
|
|
927
|
+
"no_uniprot",
|
|
928
|
+
"not_surfaceome",
|
|
929
|
+
"fails_tractability",
|
|
930
|
+
"fails_safety",
|
|
931
|
+
"safety_unassessed",
|
|
932
|
+
"high_normal_tissue_expression",
|
|
933
|
+
"normal_tissue_unassessed",
|
|
934
|
+
"no_extracellular_domain",
|
|
935
|
+
"structure_not_queried",
|
|
936
|
+
"no_alphafold_model",
|
|
937
|
+
"low_confidence_structure",
|
|
938
|
+
"structure_confidence_unassessed",
|
|
939
|
+
"not_top_n",
|
|
940
|
+
"no_surface_bind_site",
|
|
941
|
+
"surface_bind_lookup_failed",
|
|
942
|
+
"surfaced",
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _padj_is_absent(row: object) -> bool:
|
|
947
|
+
"""Whether this gene has no adjusted p-value, so nothing tested it.
|
|
948
|
+
|
|
949
|
+
pydeseq2 returns ``padj = NaN`` for genes its independent filtering removed
|
|
950
|
+
before testing. NaN is not equal to itself, which is the check; a gene with
|
|
951
|
+
no ``padj`` column at all is also untested rather than assumed null.
|
|
952
|
+
"""
|
|
953
|
+
padj = getattr(row, "padj", None)
|
|
954
|
+
if padj is None:
|
|
955
|
+
return True
|
|
956
|
+
try:
|
|
957
|
+
return bool(padj != padj) # NaN
|
|
958
|
+
except TypeError: # pragma: no cover - a non-numeric padj is not a p-value
|
|
959
|
+
return True
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _build_taxonomy(
|
|
963
|
+
deg: pd.DataFrame,
|
|
964
|
+
enriched_gene_ids: set[str],
|
|
965
|
+
enriched_all: pd.DataFrame,
|
|
966
|
+
candidates: pd.DataFrame,
|
|
967
|
+
epitopes: pd.DataFrame,
|
|
968
|
+
surfy_set: frozenset[str],
|
|
969
|
+
p: TargetDiscoveryParams,
|
|
970
|
+
*,
|
|
971
|
+
surface_bind_active: bool,
|
|
972
|
+
structure_queried: frozenset[str],
|
|
973
|
+
surfaceome_gene_ids: frozenset[str] = frozenset(),
|
|
974
|
+
) -> pd.DataFrame:
|
|
975
|
+
"""One disposition per DEG gene explaining why it is / isn't a surfaced candidate.
|
|
976
|
+
|
|
977
|
+
Re-derives each gene's fate from the same signals the pipeline used — without
|
|
978
|
+
perturbing the candidate/epitope outputs — so the failure modes (the "negative
|
|
979
|
+
results") become an auditable, first-class artifact. The funnel is exhaustive:
|
|
980
|
+
the per-disposition counts always sum to the total DEG gene count.
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
deg: The full DEG table; every gene in it gets exactly one row out.
|
|
984
|
+
enriched_gene_ids: Genes carried into Open Targets enrichment.
|
|
985
|
+
enriched_all: Enrichment rows before any filter, one per gene × UniProt.
|
|
986
|
+
candidates: The surviving candidates, ranked.
|
|
987
|
+
epitopes: The epitope table for the design shortlist.
|
|
988
|
+
surfy_set: The resolved SURFY surfaceome.
|
|
989
|
+
p: Target-discovery parameters.
|
|
990
|
+
surface_bind_active: Whether SURFACE-Bind data was actually vendored.
|
|
991
|
+
structure_queried: UniProt accessions whose AlphaFoldDB lookup actually
|
|
992
|
+
ran to completion. Accessions outside this set were never assessed,
|
|
993
|
+
so their genes are reported as ``structure_not_queried`` rather than
|
|
994
|
+
as an absent model.
|
|
995
|
+
surfaceome_gene_ids: Genes on the surfaceome, when the pre-filter ran.
|
|
996
|
+
A significant, up-regulated gene outside this set was dropped before
|
|
997
|
+
the enrichment cut could apply, so it is reported as
|
|
998
|
+
``not_surfaceome`` rather than blamed on a gate that never saw it.
|
|
999
|
+
Empty when the pre-filter is off, which restores the old behaviour.
|
|
1000
|
+
|
|
1001
|
+
Returns:
|
|
1002
|
+
One row per DEG gene with its disposition and the Open Targets status
|
|
1003
|
+
that produced it.
|
|
1004
|
+
"""
|
|
1005
|
+
wanted = {m for m in (p.require_tractable_modality or [])}
|
|
1006
|
+
|
|
1007
|
+
# Per-gene "furthest stage reached", collapsed over a gene's UniProt rows.
|
|
1008
|
+
reach: dict[str, dict[str, bool]] = {}
|
|
1009
|
+
sym_map: dict[str, object] = {}
|
|
1010
|
+
ot_status_map: dict[str, str] = {}
|
|
1011
|
+
for r in enriched_all.itertuples(index=False):
|
|
1012
|
+
gid = str(r.gene_id)
|
|
1013
|
+
sym_map.setdefault(gid, getattr(r, "symbol", None))
|
|
1014
|
+
ot_status = str(getattr(r, "open_targets_status", "") or "")
|
|
1015
|
+
# An errored lookup dominates: it is the reason the gene has no mapping.
|
|
1016
|
+
if gid not in ot_status_map or ot_status.startswith("error:"):
|
|
1017
|
+
ot_status_map[gid] = ot_status
|
|
1018
|
+
uniprot = getattr(r, "uniprot_id", None)
|
|
1019
|
+
has_u = bool(uniprot) and pd.notna(uniprot)
|
|
1020
|
+
surf = has_u and (not p.require_surfy or is_surface_protein(str(uniprot), surfy=surfy_set))
|
|
1021
|
+
mods = {
|
|
1022
|
+
m.strip()
|
|
1023
|
+
for m in str(getattr(r, "tractable_modalities", "") or "").split(";")
|
|
1024
|
+
if m.strip()
|
|
1025
|
+
}
|
|
1026
|
+
tract = surf and (not wanted or bool(wanted & mods))
|
|
1027
|
+
# A gene whose safety count was never measured has not passed the gate;
|
|
1028
|
+
# it was never put to it. Old taxonomies carry no such column, and a
|
|
1029
|
+
# disabled gate claims nothing, so both default to measured.
|
|
1030
|
+
safety_measured = (not (p.use_open_targets and p.open_targets_require_measured)) or bool(
|
|
1031
|
+
getattr(r, "safety_events_measured", True)
|
|
1032
|
+
)
|
|
1033
|
+
safe = (
|
|
1034
|
+
tract
|
|
1035
|
+
and safety_measured
|
|
1036
|
+
and (int(getattr(r, "n_safety_events", 0) or 0) <= p.max_safety_events)
|
|
1037
|
+
)
|
|
1038
|
+
d = reach.setdefault(
|
|
1039
|
+
gid,
|
|
1040
|
+
{"u": False, "surf": False, "tract": False, "safe": False, "safety_measured": False},
|
|
1041
|
+
)
|
|
1042
|
+
d["u"] |= has_u
|
|
1043
|
+
d["surf"] |= surf
|
|
1044
|
+
d["tract"] |= tract
|
|
1045
|
+
d["safety_measured"] |= tract and safety_measured
|
|
1046
|
+
d["safe"] |= safe
|
|
1047
|
+
|
|
1048
|
+
cand_gids: set[str] = set()
|
|
1049
|
+
struct_gids: set[str] = set()
|
|
1050
|
+
queried_gids: set[str] = set()
|
|
1051
|
+
topn_gids: set[str] = set()
|
|
1052
|
+
low_conf_gids: set[str] = set()
|
|
1053
|
+
unassessed_conf_gids: set[str] = set()
|
|
1054
|
+
no_ecd_gids: set[str] = set()
|
|
1055
|
+
tissue_unsafe_gids: set[str] = set()
|
|
1056
|
+
tissue_unassessed_gids: set[str] = set()
|
|
1057
|
+
if not candidates.empty:
|
|
1058
|
+
cand_gids = {str(g) for g in candidates["gene_id"]}
|
|
1059
|
+
struct_gids = {
|
|
1060
|
+
str(g) for g in candidates.loc[candidates["has_alphafold_structure"], "gene_id"]
|
|
1061
|
+
}
|
|
1062
|
+
was_queried = candidates["uniprot_id"].map(
|
|
1063
|
+
lambda u: isinstance(u, str) and u in structure_queried
|
|
1064
|
+
)
|
|
1065
|
+
queried_gids = {str(g) for g in candidates.loc[was_queried, "gene_id"]}
|
|
1066
|
+
topn_gids = {str(g) for g in candidates.loc[candidates["rank_in_top_n"], "gene_id"]}
|
|
1067
|
+
if "low_confidence_structure" in candidates.columns:
|
|
1068
|
+
low_conf_gids = {
|
|
1069
|
+
str(g) for g in candidates.loc[candidates["low_confidence_structure"], "gene_id"]
|
|
1070
|
+
}
|
|
1071
|
+
if "structure_confidence_unassessed" in candidates.columns:
|
|
1072
|
+
unassessed_conf_gids = {
|
|
1073
|
+
str(g)
|
|
1074
|
+
for g in candidates.loc[candidates["structure_confidence_unassessed"], "gene_id"]
|
|
1075
|
+
}
|
|
1076
|
+
if "no_extracellular_domain" in candidates.columns:
|
|
1077
|
+
no_ecd_gids = {
|
|
1078
|
+
str(g) for g in candidates.loc[candidates["no_extracellular_domain"], "gene_id"]
|
|
1079
|
+
}
|
|
1080
|
+
if "high_normal_tissue_expression" in candidates.columns:
|
|
1081
|
+
tissue_unsafe_gids = {
|
|
1082
|
+
str(g)
|
|
1083
|
+
for g in candidates.loc[candidates["high_normal_tissue_expression"], "gene_id"]
|
|
1084
|
+
}
|
|
1085
|
+
if "normal_tissue_unassessed" in candidates.columns:
|
|
1086
|
+
tissue_unassessed_gids = {
|
|
1087
|
+
str(g) for g in candidates.loc[candidates["normal_tissue_unassessed"], "gene_id"]
|
|
1088
|
+
}
|
|
1089
|
+
site_gids: set[str] = set()
|
|
1090
|
+
# Genes whose site lookup errored. They are not genes without a site: the
|
|
1091
|
+
# lookup said nothing about them at all, and the taxonomy derived
|
|
1092
|
+
# "no_surface_bind_site" purely from the absence of a row, so an outage
|
|
1093
|
+
# became a biological finding about every protein it touched.
|
|
1094
|
+
lookup_failed_gids: set[str] = set()
|
|
1095
|
+
if surface_bind_active and not epitopes.empty and "epitope_status" in epitopes.columns:
|
|
1096
|
+
site_gids = {
|
|
1097
|
+
str(g)
|
|
1098
|
+
for g in epitopes.loc[epitopes["epitope_status"] == "surface_bind_site", "gene_id"]
|
|
1099
|
+
}
|
|
1100
|
+
lookup_failed_gids = {
|
|
1101
|
+
str(g)
|
|
1102
|
+
for g in epitopes.loc[
|
|
1103
|
+
epitopes["epitope_status"] == "surface_bind_lookup_failed", "gene_id"
|
|
1104
|
+
]
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
rows: list[dict[str, object]] = []
|
|
1108
|
+
for r in deg.itertuples(index=False):
|
|
1109
|
+
gid = str(r.gene_id)
|
|
1110
|
+
significant = bool(getattr(r, "significant", False))
|
|
1111
|
+
log2fc = float(r.log2fc) if pd.notna(getattr(r, "log2fc", None)) else None
|
|
1112
|
+
padj = float(r.padj) if pd.notna(getattr(r, "padj", None)) else None
|
|
1113
|
+
# Terminal outcome wins: a gene's *actual* fate (the pipeline ranks rather
|
|
1114
|
+
# than hard-filters on fold-change/structure, so a down-regulated or
|
|
1115
|
+
# structure-less gene can still appear in candidates). Only genes that
|
|
1116
|
+
# never reached candidates get the upstream "why dropped" reasons.
|
|
1117
|
+
if gid in struct_gids and gid in topn_gids:
|
|
1118
|
+
if gid in lookup_failed_gids:
|
|
1119
|
+
disp = "surface_bind_lookup_failed"
|
|
1120
|
+
elif surface_bind_active and gid not in site_gids:
|
|
1121
|
+
disp = "no_surface_bind_site"
|
|
1122
|
+
else:
|
|
1123
|
+
disp = "surfaced"
|
|
1124
|
+
elif gid in cand_gids:
|
|
1125
|
+
# passed the filters but can't proceed to design: over-expressed in vital
|
|
1126
|
+
# tissue, not antibody-accessible (no ECD), low-confidence model, no
|
|
1127
|
+
# structure, or out of top-N.
|
|
1128
|
+
if gid in tissue_unsafe_gids:
|
|
1129
|
+
disp = "high_normal_tissue_expression"
|
|
1130
|
+
elif gid in tissue_unassessed_gids:
|
|
1131
|
+
disp = "normal_tissue_unassessed"
|
|
1132
|
+
elif gid in no_ecd_gids:
|
|
1133
|
+
disp = "no_extracellular_domain"
|
|
1134
|
+
elif gid in unassessed_conf_gids:
|
|
1135
|
+
disp = "structure_confidence_unassessed"
|
|
1136
|
+
elif gid in low_conf_gids:
|
|
1137
|
+
disp = "low_confidence_structure"
|
|
1138
|
+
elif gid not in struct_gids:
|
|
1139
|
+
# Past the fetch cap, or the lookup errored: nothing was measured.
|
|
1140
|
+
disp = "no_alphafold_model" if gid in queried_gids else "structure_not_queried"
|
|
1141
|
+
else:
|
|
1142
|
+
disp = "not_top_n"
|
|
1143
|
+
elif gid in enriched_gene_ids:
|
|
1144
|
+
d = reach.get(
|
|
1145
|
+
gid,
|
|
1146
|
+
{
|
|
1147
|
+
"u": False,
|
|
1148
|
+
"surf": False,
|
|
1149
|
+
"tract": False,
|
|
1150
|
+
"safe": False,
|
|
1151
|
+
"safety_measured": False,
|
|
1152
|
+
},
|
|
1153
|
+
)
|
|
1154
|
+
if not d["u"]:
|
|
1155
|
+
disp = (
|
|
1156
|
+
"uniprot_lookup_failed"
|
|
1157
|
+
if ot_status_map.get(gid, "").startswith("error:")
|
|
1158
|
+
else "no_uniprot"
|
|
1159
|
+
)
|
|
1160
|
+
elif p.require_surfy and not d["surf"]:
|
|
1161
|
+
disp = "not_surfaceome"
|
|
1162
|
+
elif wanted and not d["tract"]:
|
|
1163
|
+
disp = "fails_tractability"
|
|
1164
|
+
elif not d["safety_measured"]:
|
|
1165
|
+
# Withheld for want of an answer, not for failing one. Reporting
|
|
1166
|
+
# this as fails_safety would publish a network outage as a
|
|
1167
|
+
# negative result about the gene.
|
|
1168
|
+
disp = "safety_unassessed"
|
|
1169
|
+
else:
|
|
1170
|
+
disp = "fails_safety"
|
|
1171
|
+
elif _padj_is_absent(r):
|
|
1172
|
+
# pydeseq2's independent filtering drops low-power genes before
|
|
1173
|
+
# testing them and returns padj = NaN. `_postprocess` fills that
|
|
1174
|
+
# with 1.0 to compute `significant`, which makes an untested gene
|
|
1175
|
+
# indistinguishable from a tested one that came back null -- and
|
|
1176
|
+
# `not_significant` is a gate, so it would enter every recall
|
|
1177
|
+
# denominator as a measured miss, under a reason naming a
|
|
1178
|
+
# comparison that never happened.
|
|
1179
|
+
#
|
|
1180
|
+
# `outcomes.eligible_ranking` already fixed this exact merge for the
|
|
1181
|
+
# counterfactual ordering, quoting this module's rule back at it.
|
|
1182
|
+
# The `significant` column it is computed from was left alone.
|
|
1183
|
+
disp = "significance_unassessed"
|
|
1184
|
+
elif not significant:
|
|
1185
|
+
disp = "not_significant"
|
|
1186
|
+
elif (log2fc or 0.0) <= 0.0:
|
|
1187
|
+
disp = "down_regulated"
|
|
1188
|
+
elif surfaceome_gene_ids and gid not in surfaceome_gene_ids:
|
|
1189
|
+
# Dropped by the surfaceome pre-filter, before the enrichment cut
|
|
1190
|
+
# could apply. Recording it as below_enrichment_cutoff would blame a
|
|
1191
|
+
# gate that never saw this gene.
|
|
1192
|
+
disp = "not_surfaceome"
|
|
1193
|
+
else:
|
|
1194
|
+
disp = "below_enrichment_cutoff"
|
|
1195
|
+
rows.append(
|
|
1196
|
+
{
|
|
1197
|
+
"gene_id": gid,
|
|
1198
|
+
"symbol": sym_map.get(gid),
|
|
1199
|
+
"log2fc": log2fc,
|
|
1200
|
+
"padj": padj,
|
|
1201
|
+
"disposition": disp,
|
|
1202
|
+
# Kept alongside the disposition so a reader can tell a real
|
|
1203
|
+
# negative from a lookup that never ran; it is dropped from the
|
|
1204
|
+
# candidates table for genes that never became candidates.
|
|
1205
|
+
"open_targets_status": ot_status_map.get(gid),
|
|
1206
|
+
}
|
|
1207
|
+
)
|
|
1208
|
+
return pd.DataFrame(
|
|
1209
|
+
rows,
|
|
1210
|
+
columns=["gene_id", "symbol", "log2fc", "padj", "disposition", "open_targets_status"],
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _pi_score(df: pd.DataFrame) -> pd.Series:
|
|
1215
|
+
"""Combined DE ranking score π = log2fc × −log10(padj) (Xiao et al. 2014).
|
|
1216
|
+
|
|
1217
|
+
Rewards genes that are both strongly and *confidently* up-regulated. Genes
|
|
1218
|
+
with a large fold-change but weak significance, or down-regulated genes
|
|
1219
|
+
(negative log2fc → negative score), are naturally de-prioritised. A missing
|
|
1220
|
+
padj maps to π = 0.
|
|
1221
|
+
"""
|
|
1222
|
+
padj = df["padj"].astype(float).fillna(1.0).clip(lower=1e-300)
|
|
1223
|
+
return df["log2fc"].astype(float) * -np.log10(padj)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _empty_candidates_frame() -> pd.DataFrame:
|
|
1227
|
+
return pd.DataFrame(
|
|
1228
|
+
columns=[
|
|
1229
|
+
"gene_id",
|
|
1230
|
+
"symbol",
|
|
1231
|
+
"uniprot_id",
|
|
1232
|
+
"log2fc",
|
|
1233
|
+
"padj",
|
|
1234
|
+
"tractable_modalities",
|
|
1235
|
+
"open_targets_status",
|
|
1236
|
+
"n_safety_events",
|
|
1237
|
+
"is_surface",
|
|
1238
|
+
"alphafold_structure_path",
|
|
1239
|
+
"has_alphafold_structure",
|
|
1240
|
+
"mean_plddt",
|
|
1241
|
+
"low_confidence_structure",
|
|
1242
|
+
"gtex_safety_status",
|
|
1243
|
+
"normal_tissue_unassessed",
|
|
1244
|
+
"rank",
|
|
1245
|
+
"rank_in_top_n",
|
|
1246
|
+
]
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _resolve_surface_bind_client(injected: SurfaceBindClient | None) -> SurfaceBindClient | None:
|
|
1251
|
+
"""Return the injected client, or auto-construct one from vendored data.
|
|
1252
|
+
|
|
1253
|
+
Auto-construction happens *only* if SURFACE-Bind data is actually vendored
|
|
1254
|
+
(the ``BINDSIGHT_SURFACE_BIND_DATA`` env var is set, or
|
|
1255
|
+
``data/surface_bind/sites/`` exists). A bare or absent data tree yields None
|
|
1256
|
+
— discovery then designs against the whole surface and says so.
|
|
1257
|
+
"""
|
|
1258
|
+
if injected is not None:
|
|
1259
|
+
return injected
|
|
1260
|
+
import os
|
|
1261
|
+
|
|
1262
|
+
env = os.environ.get(SURFACE_BIND_DATA_ENV)
|
|
1263
|
+
local = Path("data/surface_bind")
|
|
1264
|
+
root: str | Path | None = env or (local if (local / "sites").is_dir() else None)
|
|
1265
|
+
if root is None:
|
|
1266
|
+
return None
|
|
1267
|
+
try:
|
|
1268
|
+
return SurfaceBindClient(data_root=root)
|
|
1269
|
+
except (RuntimeError, FileNotFoundError) as e:
|
|
1270
|
+
LOG.warning("SURFACE-Bind data not usable (%s); designing against the whole surface", e)
|
|
1271
|
+
return None
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _build_epitopes(
|
|
1275
|
+
top: pd.DataFrame,
|
|
1276
|
+
client: SurfaceBindClient | None,
|
|
1277
|
+
p: TargetDiscoveryParams,
|
|
1278
|
+
*,
|
|
1279
|
+
topology_map: dict[str, Topology] | None = None,
|
|
1280
|
+
run_root: Path | None = None,
|
|
1281
|
+
) -> pd.DataFrame:
|
|
1282
|
+
"""Build the epitopes table for the top-N candidates.
|
|
1283
|
+
|
|
1284
|
+
With a SURFACE-Bind client whose data is vendored, each candidate gets one
|
|
1285
|
+
row per qualifying targetable site (real residues → focused RFdiffusion
|
|
1286
|
+
design). The ``epitope_status`` is honest about what happened:
|
|
1287
|
+
|
|
1288
|
+
- ``surface_bind_site`` — a real vendored site (focused design);
|
|
1289
|
+
- ``no_surface_bind_site`` — data present, none for this protein;
|
|
1290
|
+
- ``surface_bind_lookup_failed`` — the client raised; nothing was learned
|
|
1291
|
+
about this protein, and that is not the same as learning it has no site;
|
|
1292
|
+
- ``surface_bind_not_configured`` — no SURFACE-Bind data vendored.
|
|
1293
|
+
|
|
1294
|
+
``min_surface_bind_score`` filters scored sites only. A site the vendored
|
|
1295
|
+
data left unscored cannot be compared against the threshold, so it is kept
|
|
1296
|
+
and logged rather than silently counted as having passed one; its row
|
|
1297
|
+
carries ``score: None``.
|
|
1298
|
+
|
|
1299
|
+
``require_surface_bind_site`` only bites when data is actually vendored: with
|
|
1300
|
+
a client, ``True`` carries *only* candidates that have ≥1 qualifying site,
|
|
1301
|
+
while ``False`` carries every top-N candidate (whole-surface where no site
|
|
1302
|
+
exists). Without vendored data there is nothing to require, so every
|
|
1303
|
+
candidate falls back to whole-surface design.
|
|
1304
|
+
|
|
1305
|
+
Two columns describe the region a designer must restrict itself to:
|
|
1306
|
+
|
|
1307
|
+
- ``design_ranges`` — list of ``[start, end]`` residue ranges, 1-based
|
|
1308
|
+
and inclusive, in UniProt numbering: the extracellular parts of the
|
|
1309
|
+
receptor. ``None`` when no topology was resolved, in which case the
|
|
1310
|
+
designer has no evidence to narrow the target and must say so rather than
|
|
1311
|
+
assume the whole chain is reachable;
|
|
1312
|
+
- ``design_range_source`` — ``uniprot_topology`` when the ranges came from a
|
|
1313
|
+
resolved UniProt topology, ``not_available`` when none was.
|
|
1314
|
+
|
|
1315
|
+
A binder only ever reaches the extracellular part of a surface protein, so a
|
|
1316
|
+
consumer that ignores ``design_ranges`` designs against the transmembrane
|
|
1317
|
+
helix and cytoplasmic tail too.
|
|
1318
|
+
"""
|
|
1319
|
+
tmap = topology_map or {}
|
|
1320
|
+
|
|
1321
|
+
def _epitope_plddt(stored: Any, residues: list[int]) -> float | None:
|
|
1322
|
+
"""Mean pLDDT over a region, resolving the run-relative path first.
|
|
1323
|
+
|
|
1324
|
+
``adopt_structure`` stores structure paths relative to the run root, so
|
|
1325
|
+
handing the raw value to ``region_plddt`` silently yields ``None`` for
|
|
1326
|
+
every row. The sibling ``mean_plddt`` column resolves it; this must too.
|
|
1327
|
+
"""
|
|
1328
|
+
resolved = resolve_run_path(run_root, stored) if run_root is not None else stored
|
|
1329
|
+
return region_plddt(resolved, residues) if resolved else None
|
|
1330
|
+
|
|
1331
|
+
rows: list[dict[str, Any]] = []
|
|
1332
|
+
for _, row in top.iterrows():
|
|
1333
|
+
uni = row["uniprot_id"]
|
|
1334
|
+
topo = tmap.get(uni) if isinstance(uni, str) else None
|
|
1335
|
+
design_ranges = (
|
|
1336
|
+
[[int(start), int(end)] for start, end in topo.extracellular_ranges] if topo else None
|
|
1337
|
+
)
|
|
1338
|
+
base = {
|
|
1339
|
+
"gene_id": row["gene_id"],
|
|
1340
|
+
"symbol": row["symbol"],
|
|
1341
|
+
"uniprot_id": uni,
|
|
1342
|
+
"structure_path": row["alphafold_structure_path"],
|
|
1343
|
+
"design_ranges": design_ranges,
|
|
1344
|
+
"design_range_source": "uniprot_topology" if topo else "not_available",
|
|
1345
|
+
}
|
|
1346
|
+
sites: list[Any] = []
|
|
1347
|
+
lookup_failed = False
|
|
1348
|
+
if client is not None and isinstance(uni, str) and uni:
|
|
1349
|
+
try:
|
|
1350
|
+
found = client.sites(uni)
|
|
1351
|
+
# `score` is optional in the SURFACE-Bind contract -- the loader
|
|
1352
|
+
# reads it with `.get` and sorts unscored sites last -- so a site
|
|
1353
|
+
# can arrive with nothing to compare against the threshold.
|
|
1354
|
+
# Dropping it would let the run report `no_surface_bind_site`,
|
|
1355
|
+
# which is documented as "data present, none for this protein":
|
|
1356
|
+
# a claim about the biology that would not be true. So it is
|
|
1357
|
+
# kept, and the run says so rather than letting the reader
|
|
1358
|
+
# assume every surviving site cleared the bar. Its row carries
|
|
1359
|
+
# `score: None`, which is the same fact on the reader's side.
|
|
1360
|
+
unscored = [s for s in found if s.score is None]
|
|
1361
|
+
if unscored:
|
|
1362
|
+
LOG.warning(
|
|
1363
|
+
"%s: %d SURFACE-Bind site(s) carry no score; "
|
|
1364
|
+
"min_surface_bind_score=%.2f could not be applied to them "
|
|
1365
|
+
"and they are retained unfiltered",
|
|
1366
|
+
uni,
|
|
1367
|
+
len(unscored),
|
|
1368
|
+
p.min_surface_bind_score,
|
|
1369
|
+
)
|
|
1370
|
+
sites = [s for s in found if s.score is None or s.score >= p.min_surface_bind_score]
|
|
1371
|
+
except Exception as e: # malformed vendored data must not abort discovery
|
|
1372
|
+
LOG.warning("SURFACE-Bind lookup failed for %s: %s", uni, e)
|
|
1373
|
+
lookup_failed = True
|
|
1374
|
+
if sites:
|
|
1375
|
+
for s in sites:
|
|
1376
|
+
rows.append(
|
|
1377
|
+
{
|
|
1378
|
+
**base,
|
|
1379
|
+
"site_id": s.site_id,
|
|
1380
|
+
"chain": s.chain,
|
|
1381
|
+
"residues": list(s.residues),
|
|
1382
|
+
"score": s.score,
|
|
1383
|
+
"seed_pdb_path": s.seed_pdb_path,
|
|
1384
|
+
"epitope_status": "surface_bind_site",
|
|
1385
|
+
"mean_epitope_plddt": _epitope_plddt(
|
|
1386
|
+
row["alphafold_structure_path"], list(s.residues)
|
|
1387
|
+
),
|
|
1388
|
+
"fraction_extracellular": (
|
|
1389
|
+
topo.fraction_extracellular(list(s.residues)) if topo else None
|
|
1390
|
+
),
|
|
1391
|
+
}
|
|
1392
|
+
)
|
|
1393
|
+
elif lookup_failed or client is None or not p.require_surface_bind_site:
|
|
1394
|
+
# whole-surface fallback (honest status); omitted only when data is
|
|
1395
|
+
# vendored AND a site is required but none exists for this protein.
|
|
1396
|
+
# A lookup that errored is not a biological negative.
|
|
1397
|
+
# ``no_surface_bind_site`` is documented as "data present, none for
|
|
1398
|
+
# this protein" — a finding about the protein. An exception in the
|
|
1399
|
+
# client said nothing about the protein at all, and reporting the
|
|
1400
|
+
# two identically turned an outage into evidence. The project's own
|
|
1401
|
+
# outcomes module states the rule: "a lookup that errored or never
|
|
1402
|
+
# ran is not a scientific negative."
|
|
1403
|
+
if lookup_failed:
|
|
1404
|
+
status = "surface_bind_lookup_failed"
|
|
1405
|
+
elif client is not None:
|
|
1406
|
+
status = "no_surface_bind_site"
|
|
1407
|
+
else:
|
|
1408
|
+
status = "surface_bind_not_configured"
|
|
1409
|
+
rows.append(
|
|
1410
|
+
{
|
|
1411
|
+
**base,
|
|
1412
|
+
"site_id": None,
|
|
1413
|
+
"chain": "A",
|
|
1414
|
+
"residues": [],
|
|
1415
|
+
"score": None,
|
|
1416
|
+
"seed_pdb_path": None,
|
|
1417
|
+
"epitope_status": status,
|
|
1418
|
+
"mean_epitope_plddt": _epitope_plddt(row["alphafold_structure_path"], []),
|
|
1419
|
+
"fraction_extracellular": None,
|
|
1420
|
+
}
|
|
1421
|
+
)
|
|
1422
|
+
return pd.DataFrame(rows) if rows else _empty_epitopes_frame()
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def _empty_epitopes_frame() -> pd.DataFrame:
|
|
1426
|
+
return pd.DataFrame(
|
|
1427
|
+
columns=[
|
|
1428
|
+
"gene_id",
|
|
1429
|
+
"symbol",
|
|
1430
|
+
"uniprot_id",
|
|
1431
|
+
"structure_path",
|
|
1432
|
+
"design_ranges",
|
|
1433
|
+
"design_range_source",
|
|
1434
|
+
"site_id",
|
|
1435
|
+
"chain",
|
|
1436
|
+
"residues",
|
|
1437
|
+
"score",
|
|
1438
|
+
"seed_pdb_path",
|
|
1439
|
+
"epitope_status",
|
|
1440
|
+
"mean_epitope_plddt",
|
|
1441
|
+
"fraction_extracellular",
|
|
1442
|
+
]
|
|
1443
|
+
)
|