Skip to content

Commit c506138

Browse files
Merge pull request #3 from lagillenwater/data-downloaders
Data downloaders
2 parents 83c9f50 + 4da34d3 commit c506138

7 files changed

Lines changed: 459 additions & 13 deletions

File tree

.gitignore

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,13 @@ __marimo__/
218218
.streamlit/secrets.toml
219219

220220
# fmharness project-specific
221-
data/tranches/ # cached tranche artifacts (large)
222-
reports/ # generated reports
223-
containers/*.sif # built Apptainer images
224-
.fmharness/ # local user secrets/state
221+
# all dataset artifacts (raw, processed, tranches) — never tracked
222+
data/
223+
# generated reports
224+
reports/
225+
# built Apptainer images
226+
containers/*.sif
227+
# local user secrets/state
228+
.fmharness/
225229

226230
docs/fm-pdo-evaluator-plan.md

docs/adapter_contract.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Adapter contract
22

3-
How a model joins the harness. Every model — linear baseline, Tahoe-x1, STATE, and any future addition — implements the same `ModelAdapter` Protocol so the rest of the pipeline (splits, probe, metrics, registry, leakage scan) is model-agnostic.
3+
How a model joins the harness. Every model — linear baseline, Tahoe-x1, STACK, and any future addition — implements the same `ModelAdapter` Protocol so the rest of the pipeline (splits, probe, metrics, registry, leakage scan) is model-agnostic.
44

5-
The contract has three required surfaces (`embed`, `metadata`, `version`) and one optional surface (`predict_native`). Day 7 implements the Protocol and the `linear_baseline` and `MockAdapter` reference implementations; later days add `tahoe_x1` (Day 8) and `state` (Day 11) against the same contract.
5+
The contract has three required surfaces (`embed`, `metadata`, `version`) and one optional surface (`predict_native`). Day 7 implements the Protocol and the `linear_baseline` and `MockAdapter` reference implementations; later days add `tahoe_x1` (Day 8) and `stack` (Day 11) against the same contract.
66

77
## 1. The interface
88

@@ -50,7 +50,7 @@ class ModelAdapter(Protocol):
5050

5151
## 2. The probe-based prediction pipeline (default path)
5252

53-
All four matrix rows (linear baseline, Tahoe-x1, STATE, plus the metadata-only control) share an identical probe so the comparison isolates "what the encoder captures":
53+
All four matrix rows (linear baseline, Tahoe-x1, STACK, plus the metadata-only control) share an identical probe so the comparison isolates "what the encoder captures":
5454

5555
```
5656
sample (RNA-seq) --[ encoder ]--> embedding --[ concat drug feat ]--> [ probe ] --> P(responder)
@@ -59,7 +59,7 @@ All four matrix rows (linear baseline, Tahoe-x1, STATE, plus the metadata-only c
5959
trained per split-fold
6060
```
6161

62-
- **Encoder** is model-specific. For the linear baseline the encoder is `StandardScaler` (a passthrough; "embedding" == scaled expression). For Tahoe-x1 / STATE it is the pretrained transformer encoder.
62+
- **Encoder** is model-specific. For the linear baseline the encoder is `StandardScaler` (a passthrough; "embedding" == scaled expression). For Tahoe-x1 / STACK it is the pretrained transformer encoder.
6363
- **Drug feature** is a one-hot over the drug crosswalk's canonical IDs at MVP; richer drug descriptors (Morgan fingerprint, ATC class) are a deferred extension.
6464
- **Probe** is a fixed architecture across all models: `StandardScaler → ElasticNetCV` (continuous response) or `LogisticRegressionCV` (binary responder). Declared once in `src/fmharness/probe/linear.py`. The harness — not the adapter — owns the probe.
6565

@@ -70,7 +70,7 @@ The adapter's only job is to produce a faithful embedding. Probe training and in
7070
Foundation models with a drug-aware head can return a prediction directly:
7171

7272
- Tahoe-x1: trained on perturbation-response prediction; may expose a `predict(baseline_state, drug) → post_state` or scalar head.
73-
- STATE: the ST (state transition) component is exactly this.
73+
- STACK: `predict_in_context()` consumes a prompt set of cells defining a biological condition and predicts the effect on a target population — no per-task fine-tuning. This is STACK's native zero-shot path.
7474

7575
When `predict_native` returns a value, the harness records it as a separate row in the registry tagged `prediction_mode="native"`. The probe-based row (`prediction_mode="probe"`) is always produced for fair comparison; the native row is supplementary.
7676

docs/environment.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ Foundation-model inference runs inside Apptainer images pinned by digest in
1515
|---|---|---|---|
1616
| `fmharness` | `containers/fmharness.def` | Day 1+2 (skeleton); rebuilt Day 8 | core Python deps |
1717
| `tahoe` | `containers/tahoe.def` | Day 8 | Tahoe-x1 + torch + CUDA |
18-
| `state` | `containers/state.def` (built only if needed) | Day 11 | STATE + torch + CUDA |
18+
| `stack` | `containers/stack.def` (built only if needed) | Day 11 | STACK + torch + CUDA |
1919

20-
STATE reuses the Tahoe container unless torch/CUDA conflicts force a split;
20+
STACK reuses the Tahoe container unless torch/CUDA conflicts force a split;
2121
the decision (and the reason) is recorded on Day 11 in this document.
2222

2323
Every `PredictionRecord` carries `EnvironmentSnapshot.container_digest`. A

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,16 @@ dependencies = [
1919
"joblib>=1.4",
2020
"typer>=0.12",
2121
"pydeseq2>=0.4",
22+
"openpyxl>=3.1",
2223
]
2324

2425
[project.optional-dependencies]
2526
ml = [
2627
"torch>=2.2",
2728
"transformers>=4.40",
2829
]
29-
state = [
30-
"arc-state>=0.1",
30+
stack = [
31+
"arc-stack @ git+https://github.com/ArcInstitute/stack.git",
3132
]
3233
dev = [
3334
"pytest>=8.0",
@@ -42,6 +43,9 @@ dev = [
4243
requires = ["hatchling"]
4344
build-backend = "hatchling.build"
4445

46+
[tool.hatch.metadata]
47+
allow-direct-references = true
48+
4549
[tool.hatch.build.targets.wheel]
4650
packages = ["src/fmharness"]
4751

scripts/download/_utils.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Shared helpers for download scripts under scripts/download/.
2+
3+
Common manifest shape both downloaders produce::
4+
5+
{
6+
"dataset": "<short id>",
7+
"release": {<dataset-specific release/version keys>},
8+
"updated_utc": "<ISO8601>",
9+
"files": {
10+
"<relative_path>": {
11+
"sha256": "...",
12+
"bytes": N,
13+
"source_uri": "https://... | synapse://syn_id"
14+
}
15+
}
16+
}
17+
18+
Per-file extras (e.g. ``rows`` / ``cols`` / ``columns`` for Synapse Tables) are
19+
allowed on top of this; downstream consumers ignore unknown keys.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import hashlib
25+
import json
26+
import sys
27+
from datetime import UTC, datetime
28+
from pathlib import Path
29+
30+
MANIFEST_NAME = "manifest.json"
31+
32+
33+
def sha256_file(path: Path, chunk: int = 1 << 20) -> str:
34+
h = hashlib.sha256()
35+
with path.open("rb") as fh:
36+
for block in iter(lambda: fh.read(chunk), b""):
37+
h.update(block)
38+
return h.hexdigest()
39+
40+
41+
def load_manifest(manifest_path: Path, default: dict) -> dict:
42+
if manifest_path.exists():
43+
return json.loads(manifest_path.read_text())
44+
return default
45+
46+
47+
def write_manifest(manifest_path: Path, manifest: dict) -> None:
48+
manifest["updated_utc"] = datetime.now(UTC).isoformat(timespec="seconds")
49+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
50+
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
51+
52+
53+
def skip_or_fail_on_hash(name: str, dest: Path, manifest_files: dict) -> bool:
54+
"""Return True if ``dest`` exists with sha matching the manifest record.
55+
56+
Returns False when ``dest`` is missing or has no recorded sha — caller
57+
should (re-)fetch. Exits non-zero on mismatch so a silently changed
58+
upstream file does not slip past unnoticed.
59+
"""
60+
if not dest.exists():
61+
return False
62+
recorded = manifest_files.get(name, {}).get("sha256")
63+
if not recorded:
64+
return False
65+
actual = sha256_file(dest)
66+
if recorded != actual:
67+
sys.exit(
68+
f"[fail] {name} sha256 mismatch -- expected {recorded}, got {actual}. "
69+
"Delete the file to re-download, or investigate upstream change."
70+
)
71+
return True
72+
73+
74+
def verify_manifest(output_dir: Path, manifest_path: Path) -> int:
75+
"""Re-check every recorded file under ``output_dir`` against its manifest sha.
76+
77+
Returns the number of errors (0 = clean). Prints one ``[fail]`` line per
78+
bad file. Returns 0 silently when the manifest is missing (no records to
79+
verify), so callers can iterate over multiple optional manifests.
80+
"""
81+
if not manifest_path.exists():
82+
return 0
83+
manifest = json.loads(manifest_path.read_text())
84+
bad: list[str] = []
85+
for name, rec in manifest.get("files", {}).items():
86+
path = output_dir / name
87+
if not path.exists():
88+
bad.append(f"{name}: missing")
89+
continue
90+
actual = sha256_file(path)
91+
if actual != rec["sha256"]:
92+
bad.append(f"{name}: sha256 mismatch (expected {rec['sha256']}, got {actual})")
93+
if bad:
94+
for b in bad:
95+
print(f"[fail] {b}")
96+
return len(bad)
97+
n = len(manifest.get("files", {}))
98+
print(f"[ok ] {manifest_path.parent.name}/{manifest_path.name}: {n} files verified")
99+
return 0
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env python3
2+
"""Download GDSC2 drug-response + CCLE/DepMap RNA-seq for the sarcoma subset.
3+
4+
Two open data sources, paired on COSMIC ID:
5+
6+
1. GDSC2 (Sanger Institute, release 8.5, dated 27Oct23). Fitted dose-response
7+
(AUC, IC50), compound metadata, cell-line metadata. Hosted on the public
8+
Sanger CDN; no auth required.
9+
10+
2. DepMap (Broad Institute, latest public release, ~quarterly). RNA-seq raw
11+
read counts (per protein-coding gene, RSEM-style; fed through DESeq2
12+
median-of-ratios in the loader to match Soragni's normalization scheme)
13+
plus model metadata mapping DepMap ACH-XXXXX IDs to COSMIC IDs. DepMap
14+
distributes through figshare collections that change per release (26Q1,
15+
25Q4, ...). To swap to a newer release:
16+
17+
Go to https://depmap.org/portal/download/all/, pick the latest release,
18+
right-click the file and copy the figshare ndownloader link, then update
19+
the corresponding entry in DEPMAP_FILES below.
20+
21+
Outputs land in data/raw/gdsc2_sarcoma/ with sha256 of each file recorded in
22+
manifest.json. The manifest schema is shared with download_soragni.py via
23+
scripts/download/_utils.py.
24+
25+
Usage:
26+
python scripts/download/download_gdsc2_sarcoma.py
27+
python scripts/download/download_gdsc2_sarcoma.py --verify-only
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import argparse
33+
import sys
34+
import urllib.request
35+
from pathlib import Path
36+
from urllib.error import HTTPError, URLError
37+
38+
from _utils import (
39+
MANIFEST_NAME,
40+
load_manifest,
41+
sha256_file,
42+
skip_or_fail_on_hash,
43+
verify_manifest,
44+
write_manifest,
45+
)
46+
47+
REPO_ROOT = Path(__file__).resolve().parents[2]
48+
OUTPUT_DIR = REPO_ROOT / "data" / "raw" / "gdsc2_sarcoma"
49+
MANIFEST_PATH = OUTPUT_DIR / MANIFEST_NAME
50+
51+
# GDSC2 release 8.5 (27Oct23) -- verified live on the Sanger CDN.
52+
# Bump these when Sanger publishes a new release.
53+
GDSC2_RELEASE = "8.5"
54+
GDSC2_BASE = f"https://cog.sanger.ac.uk/cancerrxgene/GDSC_release{GDSC2_RELEASE}"
55+
GDSC2_FILES: list[tuple[str, str]] = [
56+
(
57+
"GDSC2_fitted_dose_response_27Oct23.xlsx",
58+
f"{GDSC2_BASE}/GDSC2_fitted_dose_response_27Oct23.xlsx",
59+
),
60+
("screened_compounds_rel_8.5.csv", f"{GDSC2_BASE}/screened_compounds_rel_8.5.csv"),
61+
("Cell_Lines_Details.xlsx", f"{GDSC2_BASE}/Cell_Lines_Details.xlsx"),
62+
]
63+
64+
# DepMap files -- release 26Q1 (canonical-id snapshot public-26q1-5bbf).
65+
# URLs hit depmap.org which 302s to a signed Google Cloud Storage URL that's
66+
# regenerated per request, so the depmap.org URL itself is stable across runs.
67+
#
68+
# RawReadCount is RSEM expected counts per protein-coding gene (linear,
69+
# integer-ish); the loader runs DESeq2 median-of-ratios on this to match
70+
# Soragni's pre-computed normalized counts. The log2(TPM+1) variant at the
71+
# same canonical-id (.27) is available if a length-normalized view is ever
72+
# needed for a sensitivity row; not pulled by default.
73+
DEPMAP_RELEASE = "26Q1"
74+
DEPMAP_BASE = "https://depmap.org/portal/download/api/download"
75+
DEPMAP_FILES: list[tuple[str, str]] = [
76+
(
77+
"OmicsExpressionRawReadCountHumanProteinCodingGenes.csv",
78+
f"{DEPMAP_BASE}?file_name=downloads-by-canonical-id%2Fpublic-26q1-5bbf.27%2FOmicsExpressionRawReadCountHumanProteinCodingGenes.csv&dl_name=OmicsExpressionRawReadCountHumanProteinCodingGenes.csv&bucket=depmap-external-downloads",
79+
),
80+
(
81+
"Model.csv",
82+
f"{DEPMAP_BASE}?file_name=downloads-by-canonical-id%2Fpublic-26q1-5bbf.37%2FModel.csv&dl_name=Model.csv&bucket=depmap-external-downloads",
83+
),
84+
]
85+
86+
87+
def default_manifest() -> dict:
88+
return {
89+
"dataset": "gdsc2_sarcoma",
90+
"release": {"gdsc": GDSC2_RELEASE, "depmap": DEPMAP_RELEASE},
91+
"files": {},
92+
}
93+
94+
95+
def download(url: str, dest: Path) -> None:
96+
dest.parent.mkdir(parents=True, exist_ok=True)
97+
tmp = dest.with_suffix(dest.suffix + ".part")
98+
req = urllib.request.Request(url, headers={"User-Agent": "fm-pdo-evaluator/0.1"})
99+
with urllib.request.urlopen(req) as response, tmp.open("wb") as fh:
100+
while True:
101+
chunk = response.read(1 << 20)
102+
if not chunk:
103+
break
104+
fh.write(chunk)
105+
tmp.replace(dest)
106+
107+
108+
def fetch_with_sha(name: str, url: str, manifest: dict) -> None:
109+
dest = OUTPUT_DIR / name
110+
if skip_or_fail_on_hash(name, dest, manifest["files"]):
111+
print(f"[skip] {name} present with matching sha256")
112+
return
113+
print(f"[get ] {name} <- {url}")
114+
try:
115+
download(url, dest)
116+
except (HTTPError, URLError) as e:
117+
sys.exit(f"[fail] {name} download error: {e}")
118+
digest = sha256_file(dest)
119+
manifest["files"][name] = {
120+
"sha256": digest,
121+
"bytes": dest.stat().st_size,
122+
"source_uri": url,
123+
}
124+
print(f" sha256 {digest}")
125+
126+
127+
def fetch_gdsc2(manifest: dict) -> None:
128+
for name, url in GDSC2_FILES:
129+
fetch_with_sha(f"gdsc2/{name}", url, manifest)
130+
131+
132+
def fetch_depmap(manifest: dict) -> None:
133+
for name, url in DEPMAP_FILES:
134+
fetch_with_sha(f"depmap/{name}", url, manifest)
135+
136+
137+
def main() -> None:
138+
parser = argparse.ArgumentParser(
139+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
140+
)
141+
parser.add_argument(
142+
"--verify-only", action="store_true", help="re-verify existing files; no download"
143+
)
144+
args = parser.parse_args()
145+
146+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
147+
manifest = load_manifest(MANIFEST_PATH, default_manifest())
148+
149+
if args.verify_only:
150+
errors = verify_manifest(OUTPUT_DIR, MANIFEST_PATH)
151+
sys.exit(1 if errors else 0)
152+
153+
fetch_gdsc2(manifest)
154+
fetch_depmap(manifest)
155+
expected = {f"gdsc2/{n}" for n, _ in GDSC2_FILES} | {f"depmap/{n}" for n, _ in DEPMAP_FILES}
156+
manifest["files"] = {k: v for k, v in manifest["files"].items() if k in expected}
157+
write_manifest(MANIFEST_PATH, manifest)
158+
print(f"[done] manifest written to {MANIFEST_PATH.relative_to(REPO_ROOT)}")
159+
160+
161+
if __name__ == "__main__":
162+
main()

0 commit comments

Comments
 (0)