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,118 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""cBioPortal molecular-subtype fetcher.
|
|
4
|
+
|
|
5
|
+
Pulls canonical PAM50 intrinsic-subtype calls for a TCGA study from the public
|
|
6
|
+
cBioPortal REST API (https://www.cbioportal.org/api). Subtype labels are keyed
|
|
7
|
+
by patient barcode (``TCGA-XX-XXXX``), which is identical to the GDC
|
|
8
|
+
``cases.submitter_id`` — so the labels map straight onto the STAR-Counts files
|
|
9
|
+
fetched by :mod:`bindsight.io.gdc`.
|
|
10
|
+
|
|
11
|
+
This is what lets the rediscovery validation run *subtype-stratified* cohorts
|
|
12
|
+
(e.g. HER2-enriched breast tumors vs. adjacent normal), which is the scientific
|
|
13
|
+
prerequisite for recovering a subtype-specific antigen like ERBB2 — bulk
|
|
14
|
+
tumor-vs-normal over the whole BRCA cohort averages the HER2 signal away.
|
|
15
|
+
|
|
16
|
+
Source: cBioPortal (https://www.cbioportal.org/), Cerami et al. 2012
|
|
17
|
+
(doi:10.1158/2159-8290.CD-12-0095), Gao et al. 2013 (doi:10.1126/scisignal.2004088).
|
|
18
|
+
PAM50 subtypes: Parker et al. 2009 (doi:10.1200/JCO.2008.18.1370).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import collections
|
|
24
|
+
import datetime as _dt
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import requests
|
|
32
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
33
|
+
|
|
34
|
+
LOG = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
CBIOPORTAL_API = "https://www.cbioportal.org/api"
|
|
37
|
+
DEFAULT_STUDY = "brca_tcga_pan_can_atlas_2018"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=2, min=2, max=16))
|
|
41
|
+
def _get_subtype_clinical_data(study_id: str) -> list[dict[str, Any]]:
|
|
42
|
+
"""GET the per-patient SUBTYPE clinical attribute for a study (retried)."""
|
|
43
|
+
resp = requests.get(
|
|
44
|
+
f"{CBIOPORTAL_API}/studies/{study_id}/clinical-data",
|
|
45
|
+
params={
|
|
46
|
+
"clinicalDataType": "PATIENT",
|
|
47
|
+
"attributeId": "SUBTYPE",
|
|
48
|
+
"projection": "SUMMARY",
|
|
49
|
+
},
|
|
50
|
+
headers={"Accept": "application/json"},
|
|
51
|
+
timeout=60,
|
|
52
|
+
)
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
data: list[dict[str, Any]] = resp.json()
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fetch_pam50_subtypes(
|
|
59
|
+
study_id: str = DEFAULT_STUDY,
|
|
60
|
+
*,
|
|
61
|
+
cache_dir: Path | None = None,
|
|
62
|
+
) -> dict[str, str]:
|
|
63
|
+
"""Return ``{patient_barcode: subtype_label}`` for a cBioPortal study.
|
|
64
|
+
|
|
65
|
+
For ``brca_tcga_pan_can_atlas_2018`` the labels are the PAM50 calls
|
|
66
|
+
``BRCA_Basal``, ``BRCA_Her2``, ``BRCA_LumA``, ``BRCA_LumB``, ``BRCA_Normal``.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
study_id: cBioPortal study id.
|
|
70
|
+
cache_dir: if given, write the raw labels + provenance JSON here and
|
|
71
|
+
reuse them on subsequent calls (offline after the first run).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Mapping from TCGA patient barcode to subtype label.
|
|
75
|
+
"""
|
|
76
|
+
cache_path = None
|
|
77
|
+
if cache_dir is not None:
|
|
78
|
+
cache_dir = Path(cache_dir)
|
|
79
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
cache_path = cache_dir / f"{study_id}_SUBTYPE.json"
|
|
81
|
+
if cache_path.exists():
|
|
82
|
+
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
83
|
+
LOG.info("cBioPortal: using cached subtypes (%d patients)", len(cached["labels"]))
|
|
84
|
+
return dict(cached["labels"])
|
|
85
|
+
|
|
86
|
+
LOG.info("cBioPortal: fetching SUBTYPE for %s", study_id)
|
|
87
|
+
rows = _get_subtype_clinical_data(study_id)
|
|
88
|
+
labels = {r["patientId"]: r["value"] for r in rows if r.get("value")}
|
|
89
|
+
if not labels:
|
|
90
|
+
raise RuntimeError(f"cBioPortal returned no SUBTYPE labels for {study_id}")
|
|
91
|
+
|
|
92
|
+
counts = collections.Counter(labels.values())
|
|
93
|
+
LOG.info("cBioPortal: %d patients with SUBTYPE (%s)", len(labels), dict(counts))
|
|
94
|
+
|
|
95
|
+
if cache_path is not None:
|
|
96
|
+
payload = {
|
|
97
|
+
"schema": "bindsight-cbioportal-subtypes/1",
|
|
98
|
+
"source": "cBioPortal (https://www.cbioportal.org/)",
|
|
99
|
+
"study_id": study_id,
|
|
100
|
+
"attribute": "SUBTYPE",
|
|
101
|
+
"retrieved_utc": _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds"),
|
|
102
|
+
"n_patients": len(labels),
|
|
103
|
+
"counts": dict(sorted(counts.items())),
|
|
104
|
+
"labels": labels,
|
|
105
|
+
}
|
|
106
|
+
text = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
107
|
+
cache_path.write_text(text, encoding="utf-8", newline="\n")
|
|
108
|
+
payload_sha = hashlib.sha256(text.encode()).hexdigest()
|
|
109
|
+
(cache_path.parent / f"{study_id}_SUBTYPE.sha256").write_text(
|
|
110
|
+
payload_sha + "\n", encoding="utf-8", newline="\n"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return labels
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def patients_with_subtype(labels: dict[str, str], subtype: str) -> list[str]:
|
|
117
|
+
"""Return the sorted patient barcodes whose label equals ``subtype``."""
|
|
118
|
+
return sorted(bc for bc, st in labels.items() if st == subtype)
|
bindsight/io/gdc.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""NIH/GDC RNA-seq cohort fetcher.
|
|
4
|
+
|
|
5
|
+
Downloads **real** TCGA gene-expression (STAR - Counts) for a project such as
|
|
6
|
+
``TCGA-BRCA``, assembles a gene × sample integer-count matrix plus a sample
|
|
7
|
+
design table, and writes full provenance (GDC file UUIDs, case barcodes,
|
|
8
|
+
sample types, workflow, retrieval timestamp, SHA-256 of the outputs).
|
|
9
|
+
|
|
10
|
+
This is what makes ``bindsight demo`` / a ``bindsight discover`` config run on
|
|
11
|
+
authentic patient data rather than illustrative numbers. The GDC open-access
|
|
12
|
+
gene-level expression API needs no token. Outputs are written where the run
|
|
13
|
+
config's ``inputs.counts`` / ``inputs.design`` point (typically a cache dir),
|
|
14
|
+
so the first run downloads and subsequent runs are offline.
|
|
15
|
+
|
|
16
|
+
Source: GDC Data Portal, https://portal.gdc.cancer.gov/ (NIH/NCI, open access).
|
|
17
|
+
TCGA data usage: https://www.cancer.gov/ccg/research/genome-sequencing/tcga/using-tcga-data/citing
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as _dt
|
|
23
|
+
import gzip
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import pandas as pd
|
|
32
|
+
import requests
|
|
33
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
34
|
+
|
|
35
|
+
LOG = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
GDC_FILES_ENDPOINT = "https://api.gdc.cancer.gov/files"
|
|
38
|
+
GDC_DATA_ENDPOINT = "https://api.gdc.cancer.gov/data/{file_id}"
|
|
39
|
+
|
|
40
|
+
# STAR-Counts column layout (GENCODE v36); column 3 (0-based) is the raw count.
|
|
41
|
+
_UNSTRANDED_COL = 3
|
|
42
|
+
_SKIP_ROW_PREFIXES = ("N_unmapped", "N_multimapping", "N_noFeature", "N_ambiguous")
|
|
43
|
+
|
|
44
|
+
_SAMPLE_TYPES = {"tumor": "Primary Tumor", "normal": "Solid Tissue Normal"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CohortSample:
|
|
49
|
+
"""One downloaded sample's provenance."""
|
|
50
|
+
|
|
51
|
+
sample: str # short bindsight sample id, e.g. "T01"
|
|
52
|
+
condition: str # "tumor" | "normal"
|
|
53
|
+
sample_type: str # GDC sample_type, e.g. "Primary Tumor"
|
|
54
|
+
case_barcode: str # TCGA case submitter id
|
|
55
|
+
sample_barcode: str # TCGA sample submitter id
|
|
56
|
+
gdc_file_id: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=2, min=2, max=16))
|
|
60
|
+
def _post_files_query(
|
|
61
|
+
filters: dict[str, Any], fields: list[str], size: int
|
|
62
|
+
) -> list[dict[str, Any]]:
|
|
63
|
+
"""Query the GDC files endpoint; return the hits list (retried on failure)."""
|
|
64
|
+
resp = requests.post(
|
|
65
|
+
GDC_FILES_ENDPOINT,
|
|
66
|
+
json={
|
|
67
|
+
"filters": filters,
|
|
68
|
+
"fields": ",".join(fields),
|
|
69
|
+
"format": "JSON",
|
|
70
|
+
"size": str(size),
|
|
71
|
+
"sort": "file_id:asc", # deterministic selection
|
|
72
|
+
},
|
|
73
|
+
timeout=60,
|
|
74
|
+
)
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
hits: list[dict[str, Any]] = resp.json()["data"]["hits"]
|
|
77
|
+
return hits
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_FILE_FIELDS = [
|
|
81
|
+
"file_id",
|
|
82
|
+
"cases.submitter_id",
|
|
83
|
+
"cases.samples.submitter_id",
|
|
84
|
+
"cases.samples.sample_type",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _list_cohort_files(
|
|
89
|
+
project: str, condition: str, n: int, *, cases: list[str] | None = None
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
"""List STAR-Counts files for a project + tumor/normal class.
|
|
92
|
+
|
|
93
|
+
With ``cases=None`` this returns the first ``n`` files (deterministic by
|
|
94
|
+
``file_id``). With ``cases`` (a list of TCGA case submitter ids, e.g. the
|
|
95
|
+
HER2-enriched patients from cBioPortal) it restricts to those cases and
|
|
96
|
+
de-duplicates to at most one file per case before capping at ``n`` — so a
|
|
97
|
+
subtype-stratified cohort fetches exactly the patients you asked for.
|
|
98
|
+
|
|
99
|
+
An empty list raises. It means the restriction was applied and matched
|
|
100
|
+
nobody, which is not the same request as ``None`` and must not be answered
|
|
101
|
+
with an unrestricted cohort.
|
|
102
|
+
"""
|
|
103
|
+
if cases is not None and not cases:
|
|
104
|
+
# `None` means "no restriction"; `[]` means "restrict to these, and
|
|
105
|
+
# there are none". Every guard below was `if cases:`, which merged them,
|
|
106
|
+
# so a subtype that matched nobody dropped the `cases.submitter_id`
|
|
107
|
+
# clause and fetched the first `n` files of any subtype instead. The
|
|
108
|
+
# cohort then carried the label of a stratified one while being an
|
|
109
|
+
# unstratified sample, with nothing raised, nothing warned, and
|
|
110
|
+
# `selection: "first_n"` recorded in its own provenance.
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"an empty case list was supplied for {project} {condition}: the "
|
|
113
|
+
"restriction matched no patients. Passing None asks for an "
|
|
114
|
+
"unrestricted cohort; passing [] asks for a cohort of nobody, which "
|
|
115
|
+
"cannot be silently widened to everybody."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
content = [
|
|
119
|
+
{"op": "in", "content": {"field": "cases.project.project_id", "value": [project]}},
|
|
120
|
+
{
|
|
121
|
+
"op": "in",
|
|
122
|
+
"content": {"field": "data_type", "value": ["Gene Expression Quantification"]},
|
|
123
|
+
},
|
|
124
|
+
{"op": "in", "content": {"field": "analysis.workflow_type", "value": ["STAR - Counts"]}},
|
|
125
|
+
{
|
|
126
|
+
"op": "in",
|
|
127
|
+
"content": {"field": "cases.samples.sample_type", "value": [_SAMPLE_TYPES[condition]]},
|
|
128
|
+
},
|
|
129
|
+
]
|
|
130
|
+
if cases:
|
|
131
|
+
content.append({"op": "in", "content": {"field": "cases.submitter_id", "value": cases}})
|
|
132
|
+
|
|
133
|
+
# When selecting by case, ask for all matching files (a case can have >1
|
|
134
|
+
# sample) then dedupe to one-per-case; otherwise just ask for n.
|
|
135
|
+
size = max(n, len(cases) * 2) if cases else n
|
|
136
|
+
hits = _post_files_query({"op": "and", "content": content}, _FILE_FIELDS, size=size)
|
|
137
|
+
|
|
138
|
+
if cases:
|
|
139
|
+
seen: set[str] = set()
|
|
140
|
+
deduped: list[dict[str, Any]] = []
|
|
141
|
+
for hit in hits:
|
|
142
|
+
barcode = (hit.get("cases", [{}])[0]).get("submitter_id", "")
|
|
143
|
+
if barcode and barcode not in seen:
|
|
144
|
+
seen.add(barcode)
|
|
145
|
+
deduped.append(hit)
|
|
146
|
+
hits = deduped
|
|
147
|
+
|
|
148
|
+
if len(hits) < n:
|
|
149
|
+
LOG.warning(
|
|
150
|
+
"%s %s: requested %d files but only %d available", project, condition, n, len(hits)
|
|
151
|
+
)
|
|
152
|
+
return hits[:n]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def matched_pair_cases(project: str, *, limit: int = 500) -> list[str]:
|
|
156
|
+
"""Case barcodes with BOTH a primary tumour and a solid-tissue normal.
|
|
157
|
+
|
|
158
|
+
A paired differential-expression design removes between-patient variance,
|
|
159
|
+
which is the largest free power gain available to this study: in most TCGA
|
|
160
|
+
projects nearly every normal has a tumour from the same patient, so pairing
|
|
161
|
+
costs no data at all. It only works if both arms are drawn from the same
|
|
162
|
+
patients, which is what this selects.
|
|
163
|
+
|
|
164
|
+
The normal arm is the scarce one — a project with 113 normals and a thousand
|
|
165
|
+
tumours is limited by the normals — so normals are listed first and tumours
|
|
166
|
+
are then restricted to those patients.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
project: GDC project id, for example ``"TCGA-BRCA"``.
|
|
170
|
+
limit: upper bound on files requested per arm.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Sorted case submitter ids present in both arms. Sorted so a cohort is
|
|
174
|
+
reproducible rather than dependent on GDC's response ordering.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def _barcodes(condition: str, cases: list[str] | None = None) -> set[str]:
|
|
178
|
+
hits = _list_cohort_files(project, condition, limit, cases=cases)
|
|
179
|
+
return {
|
|
180
|
+
barcode
|
|
181
|
+
for hit in hits
|
|
182
|
+
if (barcode := (hit.get("cases", [{}])[0]).get("submitter_id", ""))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
normals = _barcodes("normal")
|
|
186
|
+
if not normals:
|
|
187
|
+
LOG.warning("%s: no solid-tissue-normal files; no paired contrast is possible", project)
|
|
188
|
+
return []
|
|
189
|
+
tumours = _barcodes("tumor", cases=sorted(normals))
|
|
190
|
+
matched = sorted(normals & tumours)
|
|
191
|
+
LOG.info(
|
|
192
|
+
"%s: %d normal case(s), %d of which also have a primary tumour",
|
|
193
|
+
project,
|
|
194
|
+
len(normals),
|
|
195
|
+
len(matched),
|
|
196
|
+
)
|
|
197
|
+
return matched
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=2, min=2, max=16))
|
|
201
|
+
def _download_counts(file_id: str, gene_types: tuple[str, ...]) -> dict[str, int]:
|
|
202
|
+
"""Download one STAR-Counts file; return {ensembl_gene_id: unstranded_count}.
|
|
203
|
+
|
|
204
|
+
Strips the Ensembl version suffix, skips the STAR summary rows and the
|
|
205
|
+
pseudoautosomal ``_PAR_Y`` duplicates, and optionally restricts to the
|
|
206
|
+
given gene biotypes.
|
|
207
|
+
"""
|
|
208
|
+
resp = requests.get(GDC_DATA_ENDPOINT.format(file_id=file_id), timeout=120)
|
|
209
|
+
resp.raise_for_status()
|
|
210
|
+
counts: dict[str, int] = {}
|
|
211
|
+
for line in resp.text.splitlines():
|
|
212
|
+
if not line or line.startswith("#") or line.startswith("gene_id\t"):
|
|
213
|
+
continue
|
|
214
|
+
if line.startswith(_SKIP_ROW_PREFIXES):
|
|
215
|
+
continue
|
|
216
|
+
parts = line.split("\t")
|
|
217
|
+
if len(parts) <= _UNSTRANDED_COL:
|
|
218
|
+
continue
|
|
219
|
+
gene_id = parts[0]
|
|
220
|
+
if "_PAR_Y" in gene_id:
|
|
221
|
+
continue
|
|
222
|
+
gene_type = parts[2]
|
|
223
|
+
if gene_types and gene_type not in gene_types:
|
|
224
|
+
continue
|
|
225
|
+
base = gene_id.split(".")[0]
|
|
226
|
+
try:
|
|
227
|
+
counts[base] = int(parts[_UNSTRANDED_COL])
|
|
228
|
+
except ValueError:
|
|
229
|
+
continue
|
|
230
|
+
return counts
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _sha256(path: Path) -> str:
|
|
234
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def fetch_cohort(
|
|
238
|
+
*,
|
|
239
|
+
project: str,
|
|
240
|
+
n_tumor: int,
|
|
241
|
+
n_normal: int,
|
|
242
|
+
counts_out: Path,
|
|
243
|
+
design_out: Path,
|
|
244
|
+
gene_types: tuple[str, ...] = ("protein_coding",),
|
|
245
|
+
tumor_cases: list[str] | None = None,
|
|
246
|
+
normal_cases: list[str] | None = None,
|
|
247
|
+
) -> dict[str, object]:
|
|
248
|
+
"""Download a real TCGA cohort and write counts + design + provenance.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
project: GDC project id, e.g. ``"TCGA-BRCA"``.
|
|
252
|
+
n_tumor: number of Primary Tumor samples to download.
|
|
253
|
+
n_normal: number of Solid Tissue Normal samples to download.
|
|
254
|
+
counts_out: where to write the gzipped gene × sample counts TSV.
|
|
255
|
+
design_out: where to write the sample design TSV.
|
|
256
|
+
gene_types: biotypes to keep (``()`` keeps all). Defaults to
|
|
257
|
+
protein-coding, which covers the surface-antigen targets and keeps
|
|
258
|
+
DESeq2 tractable.
|
|
259
|
+
tumor_cases: optional list of TCGA case submitter ids to restrict the
|
|
260
|
+
Primary Tumor samples to (e.g. a PAM50 subtype cohort). When given,
|
|
261
|
+
``n_tumor`` caps how many of those cases are used.
|
|
262
|
+
normal_cases: optional list of case submitter ids for the Solid Tissue
|
|
263
|
+
Normal samples (usually left ``None`` to take adjacent normals).
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The provenance dict (also written next to ``counts_out`` as
|
|
267
|
+
``provenance.json``).
|
|
268
|
+
"""
|
|
269
|
+
counts_out = Path(counts_out)
|
|
270
|
+
design_out = Path(design_out)
|
|
271
|
+
counts_out.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
design_out.parent.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
|
|
274
|
+
LOG.info("GDC: listing %s files (%d tumor + %d normal)…", project, n_tumor, n_normal)
|
|
275
|
+
tumor_hits = _list_cohort_files(project, "tumor", n_tumor, cases=tumor_cases)
|
|
276
|
+
normal_hits = _list_cohort_files(project, "normal", n_normal, cases=normal_cases)
|
|
277
|
+
|
|
278
|
+
samples: list[CohortSample] = []
|
|
279
|
+
per_sample_counts: dict[str, dict[str, int]] = {}
|
|
280
|
+
for condition, hits in (("tumor", tumor_hits), ("normal", normal_hits)):
|
|
281
|
+
prefix = "T" if condition == "tumor" else "N"
|
|
282
|
+
for i, hit in enumerate(hits, start=1):
|
|
283
|
+
sample_id = f"{prefix}{i:02d}"
|
|
284
|
+
case = hit.get("cases", [{}])[0]
|
|
285
|
+
sample0 = (case.get("samples") or [{}])[0]
|
|
286
|
+
LOG.info("GDC: downloading %s (%s) %s…", sample_id, condition, hit["file_id"][:8])
|
|
287
|
+
per_sample_counts[sample_id] = _download_counts(hit["file_id"], gene_types)
|
|
288
|
+
samples.append(
|
|
289
|
+
CohortSample(
|
|
290
|
+
sample=sample_id,
|
|
291
|
+
condition=condition,
|
|
292
|
+
sample_type=sample0.get("sample_type", _SAMPLE_TYPES[condition]),
|
|
293
|
+
case_barcode=case.get("submitter_id", ""),
|
|
294
|
+
sample_barcode=sample0.get("submitter_id", ""),
|
|
295
|
+
gdc_file_id=hit["file_id"],
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if not per_sample_counts:
|
|
300
|
+
raise RuntimeError(f"GDC returned no files for {project}")
|
|
301
|
+
|
|
302
|
+
# Assemble the gene × sample matrix on the intersection of genes (all STAR
|
|
303
|
+
# files share the GENCODE v36 gene set, so this is the full set in practice).
|
|
304
|
+
counts_df = pd.DataFrame(per_sample_counts).dropna().astype(int)
|
|
305
|
+
counts_df.index.name = "gene_id"
|
|
306
|
+
counts_df = counts_df.sort_index()
|
|
307
|
+
|
|
308
|
+
with gzip.open(counts_out, "wt", newline="", encoding="utf-8") as fh:
|
|
309
|
+
counts_df.to_csv(fh, sep="\t")
|
|
310
|
+
|
|
311
|
+
design_df = pd.DataFrame([asdict(s) for s in samples]).set_index("sample")
|
|
312
|
+
design_df.to_csv(design_out, sep="\t", lineterminator="\n")
|
|
313
|
+
|
|
314
|
+
provenance: dict[str, object] = {
|
|
315
|
+
"schema": "bindsight-gdc-cohort/1",
|
|
316
|
+
"source": "NIH/GDC (https://portal.gdc.cancer.gov/)",
|
|
317
|
+
"project": project,
|
|
318
|
+
"workflow": "STAR - Counts (GENCODE v36)",
|
|
319
|
+
"data_column": "unstranded",
|
|
320
|
+
"gene_types": list(gene_types),
|
|
321
|
+
"retrieved_utc": _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds"),
|
|
322
|
+
"selection": "by_case" if (tumor_cases or normal_cases) else "first_n",
|
|
323
|
+
"requested_tumor_cases": sorted(tumor_cases) if tumor_cases else None,
|
|
324
|
+
"requested_normal_cases": sorted(normal_cases) if normal_cases else None,
|
|
325
|
+
"n_tumor": sum(1 for s in samples if s.condition == "tumor"),
|
|
326
|
+
"n_normal": sum(1 for s in samples if s.condition == "normal"),
|
|
327
|
+
"n_genes": int(counts_df.shape[0]),
|
|
328
|
+
"samples": [asdict(s) for s in samples],
|
|
329
|
+
"outputs": {
|
|
330
|
+
counts_out.name: {"sha256": _sha256(counts_out), "bytes": counts_out.stat().st_size},
|
|
331
|
+
design_out.name: {"sha256": _sha256(design_out), "bytes": design_out.stat().st_size},
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
prov_path = counts_out.parent / "provenance.json"
|
|
335
|
+
prov_path.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
336
|
+
LOG.info(
|
|
337
|
+
"GDC: wrote %s (%d genes × %d samples) + %s",
|
|
338
|
+
counts_out,
|
|
339
|
+
counts_df.shape[0],
|
|
340
|
+
counts_df.shape[1],
|
|
341
|
+
design_out.name,
|
|
342
|
+
)
|
|
343
|
+
return provenance
|
bindsight/io/paths.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Cache and run directory helpers.
|
|
4
|
+
|
|
5
|
+
Cache layout (resolved via :mod:`platformdirs`):
|
|
6
|
+
|
|
7
|
+
<user_cache>/bindsight/
|
|
8
|
+
surface_bind/<commit_sha>/...
|
|
9
|
+
alphafolddb/<uniprot_id>.cif.gz
|
|
10
|
+
opentargets/<query_sha>.json
|
|
11
|
+
|
|
12
|
+
Run layout (per-run, user-chosen via ``--out``):
|
|
13
|
+
|
|
14
|
+
<run_dir>/
|
|
15
|
+
config.yaml
|
|
16
|
+
run_manifest.jsonld
|
|
17
|
+
deg/results.parquet
|
|
18
|
+
targets/candidates.parquet
|
|
19
|
+
epitopes/epitopes.parquet
|
|
20
|
+
structures/<uniprot_id>.cif
|
|
21
|
+
design/<uniprot_id>/...
|
|
22
|
+
validate/<uniprot_id>/...
|
|
23
|
+
rank/ranking.parquet
|
|
24
|
+
report.html
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from platformdirs import user_cache_path
|
|
34
|
+
|
|
35
|
+
#: Overrides the cache root. The only cross-platform seam for redirecting the
|
|
36
|
+
#: cache: ``XDG_CACHE_HOME`` is ignored by platformdirs on macOS, and a
|
|
37
|
+
#: ``monkeypatch`` cannot reach a subprocess — which is what the Snakemake
|
|
38
|
+
#: front-end and its test are. Same convention as ``BINDSIGHT_SURFACE_BIND_DATA``.
|
|
39
|
+
ENV_CACHE_DIR = "BINDSIGHT_CACHE_DIR"
|
|
40
|
+
|
|
41
|
+
#: Subdirectory of a run holding structures copied in from the cache.
|
|
42
|
+
STRUCTURES_SUBDIR = "structures"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cache_root() -> Path:
|
|
46
|
+
"""Return the bindsight cache root, honouring ``BINDSIGHT_CACHE_DIR``."""
|
|
47
|
+
override = os.environ.get(ENV_CACHE_DIR)
|
|
48
|
+
if override:
|
|
49
|
+
base = Path(override).expanduser()
|
|
50
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
return base
|
|
52
|
+
return user_cache_path("bindsight", appauthor=False, ensure_exists=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cache_dir(subdir: str | None = None) -> Path:
|
|
56
|
+
"""Return ``<cache_root>/[subdir]``, creating it if missing."""
|
|
57
|
+
base = cache_root()
|
|
58
|
+
if subdir is None:
|
|
59
|
+
return base
|
|
60
|
+
p = base / subdir
|
|
61
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
return p
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_dir(out: Path | str) -> Path:
|
|
66
|
+
"""Initialize a run directory, creating the standard subdirectories."""
|
|
67
|
+
root = Path(out)
|
|
68
|
+
for sub in ("deg", "targets", "epitopes", STRUCTURES_SUBDIR, "design", "validate", "rank"):
|
|
69
|
+
(root / sub).mkdir(parents=True, exist_ok=True)
|
|
70
|
+
return root
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def adopt_structure(run_root: Path | str, source: Path | str) -> str:
|
|
74
|
+
"""Copy a structure into the run's ``structures/`` directory.
|
|
75
|
+
|
|
76
|
+
``structures/`` has always been created by :func:`run_dir` and never written
|
|
77
|
+
to, while the structure itself stayed in the machine-local cache. Runs were
|
|
78
|
+
therefore not portable: a candidate table, and any RO-Crate built from it,
|
|
79
|
+
pointed at absolute paths that exist on exactly one machine.
|
|
80
|
+
|
|
81
|
+
Copying is deliberate — the cache is shared across runs and may be pruned,
|
|
82
|
+
so a hard link or symlink would let an unrelated cleanup silently gut a
|
|
83
|
+
finished, exported run.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
run_root: The run directory.
|
|
87
|
+
source: Path to the cached structure file.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The path relative to ``run_root``, in POSIX form, suitable for storing
|
|
91
|
+
in a Parquet column and reading back on another machine.
|
|
92
|
+
"""
|
|
93
|
+
root = Path(run_root)
|
|
94
|
+
src = Path(source)
|
|
95
|
+
dest_dir = root / STRUCTURES_SUBDIR
|
|
96
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
dest = dest_dir / src.name
|
|
98
|
+
if not dest.exists() or dest.stat().st_size != src.stat().st_size:
|
|
99
|
+
shutil.copy2(src, dest)
|
|
100
|
+
return dest.relative_to(root).as_posix()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_run_path(run_root: Path | str, stored: str | Path | None) -> Path | None:
|
|
104
|
+
"""Resolve a path stored in a run artifact back to a real file.
|
|
105
|
+
|
|
106
|
+
Accepts both forms deliberately. New runs store run-relative paths so they
|
|
107
|
+
stay portable; runs produced before that change stored absolute cache
|
|
108
|
+
paths, and must keep working.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
run_root: The run directory the artifact belongs to.
|
|
112
|
+
stored: The stored path, possibly empty or ``None``.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
An existing absolute path, or ``None`` if the reference is empty or
|
|
116
|
+
cannot be resolved.
|
|
117
|
+
"""
|
|
118
|
+
if not stored:
|
|
119
|
+
return None
|
|
120
|
+
p = Path(stored)
|
|
121
|
+
if p.is_absolute():
|
|
122
|
+
return p if p.exists() else None
|
|
123
|
+
candidate = Path(run_root) / p
|
|
124
|
+
return candidate if candidate.exists() else None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""End-to-end pipeline orchestrators.
|
|
4
|
+
|
|
5
|
+
Each module here exposes a ``run(config: RunConfig, ...) -> Manifest``
|
|
6
|
+
function that drives a chunk of the pipeline. The Click CLI calls these
|
|
7
|
+
directly (and the Snakefile calls them via ``scripts/``), so behavior stays
|
|
8
|
+
identical regardless of how a user invokes a stage.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""User-facing scientific caveats for the discovery half.
|
|
4
|
+
|
|
5
|
+
Single source of truth so the pipeline logs, the run manifest, and the HTML
|
|
6
|
+
report all state the *same* limitations in the same words. Kept dependency-free
|
|
7
|
+
(stdlib only) so the report renderer can import it without pulling in the heavy
|
|
8
|
+
discovery module — the same reason the disposition list is duplicated in
|
|
9
|
+
``bindsight.report.html``.
|
|
10
|
+
|
|
11
|
+
These are inherent limits of expression-based discovery, not bugs: bindsight
|
|
12
|
+
surfaces them rather than hiding them, consistent with its failure-honest design.
|
|
13
|
+
|
|
14
|
+
They are also worded for *any* two-condition contrast, because they are stamped
|
|
15
|
+
into every run manifest and every report. One of them used to be written purely
|
|
16
|
+
in tumour vocabulary, so a drug-versus-vehicle experiment came back carrying a
|
|
17
|
+
note about tumour purity — a limitation that is real and general, described in a
|
|
18
|
+
domain the run was not in.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
# Each entry is (title, body). Rendered as a "Limitations" section in the HTML
|
|
24
|
+
# report, logged at WARNING when discovery runs, and summarised into the run
|
|
25
|
+
# manifest's discover-stage notes.
|
|
26
|
+
DISCOVERY_LIMITATIONS: tuple[tuple[str, str], ...] = (
|
|
27
|
+
(
|
|
28
|
+
"mRNA abundance is not cell-surface protein abundance",
|
|
29
|
+
"Discovery ranks candidates from RNA-seq transcript abundance and a curated "
|
|
30
|
+
"surfaceome (SURFY). SURFY establishes that a protein *can* reach the cell "
|
|
31
|
+
"surface; it does not measure how much protein is actually there. Transcript "
|
|
32
|
+
"level is an imperfect proxy for surface-protein abundance — translation rate, "
|
|
33
|
+
"membrane trafficking and ectodomain shedding all intervene. Treat surfaced "
|
|
34
|
+
"candidates as hypotheses to confirm at the protein level (e.g. flow cytometry, "
|
|
35
|
+
"immunohistochemistry, or the Human Protein Atlas) before committing design effort.",
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"Bulk expression cannot attribute a transcript to a cell type",
|
|
39
|
+
"A bulk contrast measures the average over whatever cells each sample "
|
|
40
|
+
"contained. It cannot tell whether a transcript rose because the cells of "
|
|
41
|
+
"interest express more of it, or because the two arms hold different "
|
|
42
|
+
"mixtures of cells. Apparent over-expression can therefore reflect "
|
|
43
|
+
"composition rather than biology: in a tumour-versus-normal contrast that "
|
|
44
|
+
"is tumour purity and immune or stromal infiltration; in a treated-versus-"
|
|
45
|
+
"untreated contrast it is a shift in which cells survived or proliferated; "
|
|
46
|
+
"between two tissues it is simply that they are made of different cells. "
|
|
47
|
+
"Single-cell data or deconvolution is what establishes cell-intrinsic "
|
|
48
|
+
"expression, and neither is implemented (planned for v1.0).",
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def caveat_titles() -> tuple[str, ...]:
|
|
54
|
+
"""Return just the short titles, for logging."""
|
|
55
|
+
return tuple(title for title, _ in DISCOVERY_LIMITATIONS)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def caveat_summary() -> str:
|
|
59
|
+
"""One-line marker for the manifest discover-stage notes."""
|
|
60
|
+
return "caveats: " + "; ".join(caveat_titles()) + " (see report Limitations)"
|