anne-voigt commited on
Commit
abc16da
·
1 Parent(s): 6cda24b

fix(rna_sc): harden load/visualize UMAP against missing embedding/leiden (#1)

Browse files

- fix(rna_sc): harden load/visualize UMAP against missing embedding/leiden (9d23b390a1d1f41be152d4f7e8d5db68f3cfd0ac)

Files changed (4) hide show
  1. TODO.md +14 -4
  2. memory.md +36 -0
  3. src/tools/rna_sc.py +135 -17
  4. tests/test_rna_sc_load.py +113 -0
TODO.md CHANGED
@@ -332,11 +332,21 @@ correct (single-dataset, Path B, ULM, 98.3% coverage); these are the gaps.
332
  `dataset_score_signature` scores it end-to-end (66.6% coverage on a synthetic
333
  cohort); `load_signature_net` + `resolve_to_local_path` now fetch a private
334
  signature URL with auth (env token OR cached `huggingface-cli login`).
 
 
 
 
 
 
 
 
 
 
 
335
  - **Still open:** FACTORY rebuild of the Space on the 0.1.8 re-pin + e2e verify
336
- on `hf-dev`; confirm prod RAM holds the subset live; harden
337
- `decoupler_load_and_visualize_data` for subsets lacking a precomputed
338
- UMAP/leiden (spun off → `task_8b2a1bdc`); pseudobulk-aggregation tool for the
339
- sample-level DE contrast; derive Werba signatures when needed. ADR-0006 items 7/8/9.
340
  Companion to the biodata-registry Loveless ingestion (provenance/scope/gate plan
341
  there). Design goal: keep user-facing runtime bulk-like.
342
  - Two roles. The **Steele-subset h5ad** is an analyzable sc dataset — loads once
 
332
  `dataset_score_signature` scores it end-to-end (66.6% coverage on a synthetic
333
  cohort); `load_signature_net` + `resolve_to_local_path` now fetch a private
334
  signature URL with auth (env token OR cached `huggingface-cli login`).
335
+ - **Done (2026-07-01) — `decoupler_load_and_visualize_data` hardened for subsets
336
+ lacking a precomputed UMAP/leiden** (was spun off → `task_8b2a1bdc`; branch
337
+ `claude/friendly-mendel-02443d`, off current `main`, pushed to `origin`, NOT
338
+ deployed). `_ensure_umap` reuses an existing embedding (pbmc3k unchanged) or
339
+ computes a bounded `normalize+log1p→pca→neighbors→leiden(igraph)→umap` pipeline
340
+ on the loaded copy, and NEVER hard-fails — on any failure the plot is skipped
341
+ and the loaded AnnData + metadata are still returned with a note. Grouping
342
+ detection covers R `make.names` atlas cols (`Clusters`, …), not just `leiden`;
343
+ leiden uses `flavor="igraph"` (no `leidenalg` dep on the Space). Sits on top of
344
+ the `_load_adata` seam, so the Loveless subsets now load AND visualize
345
+ end-to-end. Tests: `tests/test_rna_sc_load.py` (4 green).
346
  - **Still open:** FACTORY rebuild of the Space on the 0.1.8 re-pin + e2e verify
347
+ on `hf-dev`; confirm prod RAM holds the subset live; pseudobulk-aggregation tool
348
+ for the sample-level DE contrast; derive Werba signatures when needed. ADR-0006
349
+ items 7/8/9.
 
350
  Companion to the biodata-registry Loveless ingestion (provenance/scope/gate plan
351
  there). Design goal: keep user-facing runtime bulk-like.
352
  - Two roles. The **Steele-subset h5ad** is an analyzable sc dataset — loads once
memory.md CHANGED
@@ -8,6 +8,42 @@ Last updated: 2026-07-01
8
 
9
  ---
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ## 2026-07-01 (later) — Role-2 signature PUBLISHED + merged main + resolver auth (branch `feat/loveless-sc-serving`)
12
 
13
  Three things this session:
 
8
 
9
  ---
10
 
11
+ ## 2026-07-01 (later 2) — Harden sc load/visualize against missing UMAP/leiden (branch `claude/friendly-mendel-02443d`, off current `main`, pushed to `origin`, NOT deployed)
12
+
13
+ `decoupler_load_and_visualize_data` (rna_sc.py) called `sc.pl.umap(color="leiden")`
14
+ unconditionally after load and crashed on any h5ad lacking `obsm['X_umap']` /
15
+ `obs['leiden']` — exactly the Loveless raw-count subsets (`gse155698_steele`,
16
+ `gse205013_werba`; biodata-registry 0.1.8): extracted as RAW COUNTS with the
17
+ atlas's corrected/scaled/integrated layers dropped → no embedding, no leiden. The
18
+ load tool is the Path-P entry point, so it would crash on first real use. (Was the
19
+ `task_8b2a1bdc` spin-off + the "Still open" harden bullet under the Loveless item.)
20
+
21
+ - New `_ensure_umap(adata)` → `(color_key, note)`, mutates in place (caller holds
22
+ a `read_h5ad_cached` copy). Reuses a precomputed embedding+grouping unchanged
23
+ (pbmc3k demo path preserved); else runs a bounded
24
+ `normalize_total(1e4)+log1p` (only if the matrix looks like raw counts) →
25
+ `pca → neighbors → leiden → umap`. **Never raises** — on failure the UMAP plot
26
+ is skipped and the loaded AnnData + metadata are still returned, with the reason
27
+ in `message`. leiden uses `flavor="igraph"` (no `leidenalg` dep on the Space).
28
+ - New `_looks_like_raw_counts` — bounded, sparse-aware (`scipy.sparse.issparse`; a
29
+ plain numpy `.data` is a memoryview, not counts — the bug that failed the first
30
+ cut).
31
+ - Grouping detection `_UMAP_GROUPING_CANDIDATES` covers R `make.names` atlas cols
32
+ (`Clusters`, …), not just `leiden`.
33
+ - Sits ON TOP of the `_load_adata` seam already on `main`, so the hardening + the
34
+ hosted-URL loader ship together: the Loveless subsets now load AND visualize
35
+ end-to-end. Tests: `tests/test_rna_sc_load.py` (4 green; full rna_sc + loading-plan
36
+ suite 26 green).
37
+ - **Reconciliation note:** this branch was originally cut from an older `main`
38
+ (the executor-seam commit `b187b88`, which is NOT in `main`) and lacked the
39
+ Loveless loader. Reconciled by resetting the branch onto current `origin/main`
40
+ (`6cda24b`, loveless) and re-applying ONLY the UMAP hardening — the executor seam
41
+ was dropped (it lives on `feat/executor-seam`, unaffected). Old pre-reset commits
42
+ recoverable via reflog (`d46b0e9`).
43
+ - **NOT merged to `main`/deployed.** Folds into the pending 0.1.8 factory-rebuild.
44
+
45
+ ---
46
+
47
  ## 2026-07-01 (later) — Role-2 signature PUBLISHED + merged main + resolver auth (branch `feat/loveless-sc-serving`)
48
 
49
  Three things this session:
src/tools/rna_sc.py CHANGED
@@ -21,6 +21,7 @@ from typing import Annotated, Literal
21
 
22
  import decoupler as dc
23
  import matplotlib
 
24
  import pandas as pd
25
  # Analysis-specific imports
26
  import scanpy as sc
@@ -115,6 +116,105 @@ def _load_adata(adata_path: str):
115
  Path(local_path).unlink(missing_ok=True)
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  @rna_sc_mcp.tool
119
  def decoupler_load_and_visualize_data(
120
  # Primary data inputs
@@ -139,34 +239,52 @@ def decoupler_load_and_visualize_data(
139
  # Use demo dataset as in tutorial
140
  adata = dc.ds.pbmc3k()
141
 
142
- # Create UMAP visualization as in tutorial
143
- fig, ax = plt.subplots(figsize=(5, 4))
144
- sc.pl.umap(adata=adata, color="leiden", ax=ax)
145
-
146
- # Save figure
147
- umap_file = OUTPUT_DIR / f"{out_prefix}_leiden_umap.png"
148
- fig.savefig(umap_file, dpi=300, bbox_inches="tight")
149
- plt.close(fig)
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  # Save processed data
152
  data_file = OUTPUT_DIR / f"{out_prefix}_processed_data.h5ad"
153
  adata.write_h5ad(data_file)
 
 
 
154
 
155
  # Save metadata
156
  metadata_file = OUTPUT_DIR / f"{out_prefix}_metadata.csv"
157
  adata.obs.to_csv(metadata_file)
 
 
 
 
 
 
 
 
 
158
 
159
  return {
160
- "message": f"Loaded data with {adata.n_obs} cells and {adata.n_vars} genes",
161
  "reference": "https://github.com/scverse/decoupler-tutorials/blob/main/scell/rna_sc.ipynb",
162
- "artifacts": [
163
- {
164
- "description": "UMAP visualization with leiden clusters",
165
- "path": str(umap_file.resolve()),
166
- },
167
- {"description": "Processed AnnData file", "path": str(data_file.resolve())},
168
- {"description": "Cell metadata", "path": str(metadata_file.resolve())},
169
- ],
170
  }
171
 
172
 
 
21
 
22
  import decoupler as dc
23
  import matplotlib
24
+ import numpy as np
25
  import pandas as pd
26
  # Analysis-specific imports
27
  import scanpy as sc
 
116
  Path(local_path).unlink(missing_ok=True)
117
 
118
 
119
+ # Groupings to color the initial UMAP by, in priority order. `leiden` is the
120
+ # tutorial default (decoupler's pbmc3k demo carries it); the rest cover atlas
121
+ # exports that use R `make.names` obs naming (e.g. `Clusters` holds cell-type
122
+ # labels) — so we never assume `leiden` exists as the grouping.
123
+ _UMAP_GROUPING_CANDIDATES = [
124
+ "leiden",
125
+ "louvain",
126
+ "Clusters",
127
+ "clusters",
128
+ "cluster",
129
+ "seurat_clusters",
130
+ "cell_type",
131
+ "celltype",
132
+ "CellType",
133
+ "cell_types",
134
+ ]
135
+
136
+
137
+ def _looks_like_raw_counts(adata) -> bool:
138
+ """Heuristic: raw UMI counts are non-negative and integer-valued.
139
+
140
+ Samples a bounded slice of ``adata.X`` so it stays cheap on a cpu-basic
141
+ Space regardless of dataset size.
142
+ """
143
+ import scipy.sparse as sp
144
+
145
+ X = adata.X
146
+ data = X.data if sp.issparse(X) else np.asarray(X).ravel()
147
+ if data.size == 0:
148
+ return False
149
+ sample = np.asarray(data[:100_000], dtype=float)
150
+ if (sample < 0).any():
151
+ return False
152
+ return bool(np.allclose(sample, np.round(sample)))
153
+
154
+
155
+ def _ensure_umap(adata) -> tuple[str | None, str | None]:
156
+ """Ensure ``adata`` can produce an initial UMAP, degrading gracefully.
157
+
158
+ Returns ``(color_key, note)`` and mutates ``adata`` in place (the caller
159
+ holds a fresh ``read_h5ad_cached`` copy, so the source h5ad is untouched).
160
+
161
+ - If a UMAP embedding and a known grouping are already present (e.g.
162
+ decoupler's pbmc3k demo), returns immediately — existing behavior is
163
+ preserved and nothing is recomputed.
164
+ - Otherwise runs a minimal, bounded pipeline (normalize+log1p if the matrix
165
+ looks like raw counts, then pca → neighbors → leiden → umap) so datasets
166
+ exported without embeddings (e.g. the Loveless raw-count subsets) are
167
+ still visualizable.
168
+ - Never raises: on any failure it returns ``(color_key_or_None, note)`` so
169
+ the caller can skip the plot but still return the loaded data + metadata.
170
+ """
171
+ grouping = _find_column(adata.obs, _UMAP_GROUPING_CANDIDATES)
172
+ has_umap = "X_umap" in adata.obsm
173
+
174
+ if has_umap and grouping is not None:
175
+ return grouping, None
176
+
177
+ notes: list[str] = []
178
+ try:
179
+ if not has_umap and _looks_like_raw_counts(adata):
180
+ sc.pp.normalize_total(adata, target_sum=1e4)
181
+ sc.pp.log1p(adata)
182
+ notes.append("normalized_total(1e4)+log1p (input looked like raw counts)")
183
+
184
+ if not has_umap:
185
+ n_comps = min(50, adata.n_vars - 1, adata.n_obs - 1)
186
+ if n_comps < 2:
187
+ return grouping, (
188
+ "too few cells/genes to compute a UMAP; returning loaded "
189
+ "data without a plot"
190
+ )
191
+ sc.pp.pca(adata, n_comps=n_comps)
192
+ sc.pp.neighbors(adata, n_neighbors=min(15, adata.n_obs - 1))
193
+
194
+ if grouping is None:
195
+ try:
196
+ # flavor="igraph" uses igraph's built-in Leiden (no leidenalg
197
+ # dependency), which is what is available on the Space.
198
+ sc.tl.leiden(
199
+ adata, flavor="igraph", n_iterations=2, directed=False
200
+ )
201
+ grouping = "leiden"
202
+ notes.append("computed leiden clustering")
203
+ except Exception as e: # clustering is best-effort; UMAP still useful
204
+ notes.append(f"leiden clustering skipped ({e})")
205
+
206
+ if "X_umap" not in adata.obsm:
207
+ sc.tl.umap(adata)
208
+ notes.append("computed UMAP embedding")
209
+
210
+ return grouping, ("; ".join(notes) if notes else None)
211
+ except Exception as e:
212
+ return grouping, (
213
+ f"could not compute a UMAP embedding ({e}); returning loaded data "
214
+ "without a plot"
215
+ )
216
+
217
+
218
  @rna_sc_mcp.tool
219
  def decoupler_load_and_visualize_data(
220
  # Primary data inputs
 
239
  # Use demo dataset as in tutorial
240
  adata = dc.ds.pbmc3k()
241
 
242
+ # Ensure the data is visualizable. Precomputed embeddings (e.g. decoupler's
243
+ # pbmc3k demo) are used as-is; datasets exported without a UMAP/leiden (e.g.
244
+ # the Loveless raw-count subsets) get a bounded pipeline computed here. This
245
+ # never hard-fails — if no embedding can be produced, the plot is skipped and
246
+ # the loaded data + metadata are still returned.
247
+ color_key, viz_note = _ensure_umap(adata)
248
+
249
+ artifacts = []
250
+ umap_file = None
251
+ if "X_umap" in adata.obsm:
252
+ fig, ax = plt.subplots(figsize=(5, 4))
253
+ sc.pl.umap(adata=adata, color=color_key, ax=ax, show=False)
254
+ umap_file = OUTPUT_DIR / f"{out_prefix}_leiden_umap.png"
255
+ fig.savefig(umap_file, dpi=300, bbox_inches="tight")
256
+ plt.close(fig)
257
+ desc = (
258
+ f"UMAP visualization colored by {color_key}"
259
+ if color_key
260
+ else "UMAP visualization"
261
+ )
262
+ artifacts.append({"description": desc, "path": str(umap_file.resolve())})
263
 
264
  # Save processed data
265
  data_file = OUTPUT_DIR / f"{out_prefix}_processed_data.h5ad"
266
  adata.write_h5ad(data_file)
267
+ artifacts.append(
268
+ {"description": "Processed AnnData file", "path": str(data_file.resolve())}
269
+ )
270
 
271
  # Save metadata
272
  metadata_file = OUTPUT_DIR / f"{out_prefix}_metadata.csv"
273
  adata.obs.to_csv(metadata_file)
274
+ artifacts.append(
275
+ {"description": "Cell metadata", "path": str(metadata_file.resolve())}
276
+ )
277
+
278
+ message = f"Loaded data with {adata.n_obs} cells and {adata.n_vars} genes"
279
+ if umap_file is None:
280
+ message += " (no UMAP plot produced)"
281
+ if viz_note:
282
+ message += f". Visualization note: {viz_note}"
283
 
284
  return {
285
+ "message": message,
286
  "reference": "https://github.com/scverse/decoupler-tutorials/blob/main/scell/rna_sc.ipynb",
287
+ "artifacts": artifacts,
 
 
 
 
 
 
 
288
  }
289
 
290
 
tests/test_rna_sc_load.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for src/tools/rna_sc.py::decoupler_load_and_visualize_data.
3
+
4
+ Regression guard for the Loveless single-cell subsets (biodata-registry 0.1.8,
5
+ ADR-0006 Role 1 serving): those h5ads are raw counts extracted from the Loveless
6
+ integrated atlas with the corrected/scaled/integrated layers dropped, so they
7
+ carry no `X_umap` embedding and no `leiden` clustering. The tool used to call
8
+ `sc.pl.umap(color="leiden")` unconditionally and crash on first real use. These
9
+ tests assert it now degrades gracefully on such data while preserving the
10
+ original behavior when an embedding + grouping are already present.
11
+
12
+ `_load_adata` (the shared local/HF-URL resolver, covered by test_rna_sc_loader)
13
+ handles a local path here; these tests focus only on the post-load UMAP/leiden
14
+ assumptions. Uses tiny synthetic AnnData (no network, no downloads).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import anndata as ad
23
+ import numpy as np
24
+ import pytest
25
+
26
+ sys.path.insert(0, str(Path(__file__).parent.parent))
27
+
28
+ import src.tools.rna_sc as rna_sc # noqa: E402
29
+ from src.tools.rna_sc import decoupler_load_and_visualize_data # noqa: E402
30
+
31
+
32
+ def _write_raw_counts_adata(path: Path, n_obs: int = 60, n_vars: int = 80):
33
+ """Synthetic raw-count AnnData with no UMAP and no leiden — mimics a
34
+ Loveless subset. `Name` uses the atlas's R make.names obs naming."""
35
+ rng = np.random.default_rng(0)
36
+ X = rng.poisson(2.0, size=(n_obs, n_vars)).astype("float32")
37
+ adata = ad.AnnData(X)
38
+ adata.obs_names = [f"cell{i}" for i in range(n_obs)]
39
+ adata.var_names = [f"gene{i}" for i in range(n_vars)]
40
+ adata.obs["Name"] = "sampleA"
41
+ adata.write_h5ad(path)
42
+ return adata
43
+
44
+
45
+ @pytest.fixture(autouse=True)
46
+ def _redirect_output(tmp_path, monkeypatch):
47
+ """Keep all artifacts out of the repo's tmp/outputs."""
48
+ monkeypatch.setattr(rna_sc, "OUTPUT_DIR", tmp_path)
49
+
50
+
51
+ def test_raw_counts_without_umap_or_leiden_returns(tmp_path):
52
+ """The core regression: a raw-count h5ad with no X_umap and no leiden must
53
+ load successfully rather than crashing on sc.pl.umap(color='leiden')."""
54
+ h5ad = tmp_path / "loveless_like.h5ad"
55
+ _write_raw_counts_adata(h5ad)
56
+
57
+ out = decoupler_load_and_visualize_data(adata_path=str(h5ad))
58
+
59
+ assert isinstance(out, dict)
60
+ assert "60 cells and 80 genes" in out["message"]
61
+ # A processed AnnData artifact is always returned, whether or not a plot was.
62
+ descs = [a["description"] for a in out["artifacts"]]
63
+ assert any("Processed AnnData" in d for d in descs)
64
+ assert any("Cell metadata" in d for d in descs)
65
+ # Every returned artifact path exists on disk.
66
+ for art in out["artifacts"]:
67
+ assert Path(art["path"]).exists()
68
+
69
+
70
+ def test_bounded_pipeline_computes_umap(tmp_path):
71
+ """On raw counts the tool should compute a minimal pipeline and emit a UMAP
72
+ plot (leiden via igraph flavor, no leidenalg dependency)."""
73
+ h5ad = tmp_path / "loveless_like.h5ad"
74
+ _write_raw_counts_adata(h5ad)
75
+
76
+ out = decoupler_load_and_visualize_data(adata_path=str(h5ad))
77
+
78
+ descs = [a["description"] for a in out["artifacts"]]
79
+ assert any("UMAP" in d for d in descs)
80
+ assert "note" in out["message"].lower()
81
+
82
+ # The processed h5ad carries the computed embedding.
83
+ import scanpy as sc
84
+
85
+ proc = next(a["path"] for a in out["artifacts"] if "Processed" in a["description"])
86
+ reloaded = sc.read_h5ad(proc)
87
+ assert "X_umap" in reloaded.obsm
88
+
89
+
90
+ def test_precomputed_umap_and_leiden_preserved(tmp_path):
91
+ """When X_umap + leiden already exist (pbmc3k-like), behavior is preserved:
92
+ a UMAP plot is emitted and no recompute note appears."""
93
+ rng = np.random.default_rng(1)
94
+ n_obs, n_vars = 40, 30
95
+ adata = ad.AnnData(rng.normal(size=(n_obs, n_vars)).astype("float32"))
96
+ adata.obs_names = [f"cell{i}" for i in range(n_obs)]
97
+ adata.var_names = [f"gene{i}" for i in range(n_vars)]
98
+ adata.obs["leiden"] = np.array(["0", "1"] * (n_obs // 2))
99
+ adata.obs["leiden"] = adata.obs["leiden"].astype("category")
100
+ adata.obsm["X_umap"] = rng.normal(size=(n_obs, 2))
101
+ h5ad = tmp_path / "pbmc_like.h5ad"
102
+ adata.write_h5ad(h5ad)
103
+
104
+ out = decoupler_load_and_visualize_data(adata_path=str(h5ad))
105
+
106
+ descs = [a["description"] for a in out["artifacts"]]
107
+ assert any("UMAP" in d and "leiden" in d for d in descs)
108
+ assert "note" not in out["message"].lower()
109
+
110
+
111
+ def test_missing_file_raises(tmp_path):
112
+ with pytest.raises(FileNotFoundError):
113
+ decoupler_load_and_visualize_data(adata_path=str(tmp_path / "nope.h5ad"))