From 552c5cbab19d72dae73b29fc3d7cc06d6dc2d334 Mon Sep 17 00:00:00 2001 From: Aljes Date: Fri, 16 Oct 2026 11:13:21 +0100 Subject: [PATCH 1/2] cellbender: keep uns as CellBender's loader gives it Checked against real CellBender 0.3.2 output (two samples, full and filtered) and load_anndata_from_input_and_output. Everything matched but uns: - one-element settings (target_false_positive_rate, estimator, ...) were unwrapped to scalars; the loader keeps them as stored; - a filtered file's barcode_indices_for_latents, which places no rows there, was dropped; the loader keeps it in uns. Co-Authored-By: Claude Opus 5.5 --- src/adata/conversion/cellbender.py | 9 ++++++--- tests/test_cellbender.py | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/adata/conversion/cellbender.py b/src/adata/conversion/cellbender.py index 06f905e..7d98681 100644 --- a/src/adata/conversion/cellbender.py +++ b/src/adata/conversion/cellbender.py @@ -202,6 +202,10 @@ def place_global(key: str, path: str, item: h5py.Dataset) -> None: rows = index elif n_latent in (0, n_barcodes): rows = None + if index_path is not None: + # A filtered 0.3+ file still lists every analysed droplet, which + # places nothing here; CellBender's loader keeps it in uns. + other[_INDEX] = index_path else: raise ValueError( f"Cannot tell which barcode each of CellBender's {n_latent:,} latent " @@ -641,10 +645,9 @@ def _write_uns(root: Any, handle: h5py.File, latents: _Latents) -> None: meta = write_mapping(root["uns"], "cellbender") write_scalar(meta, "version", latents.version) for key, path in latents.other.items(): + # A setting stays the one-element array CellBender stores it as, + # as its own loader gives it. value = np.asarray(handle[path][...]) - if value.ndim == 1 and value.size == 1: - # CellBender wraps each scalar setting in a one-element array. - value = value.reshape(()) if value.ndim == 0: item = value.item() write_scalar(meta, key, item.decode("utf-8", "replace") if isinstance(item, bytes) else item) diff --git a/tests/test_cellbender.py b/tests/test_cellbender.py index 71e9df4..79f4fc9 100644 --- a/tests/test_cellbender.py +++ b/tests/test_cellbender.py @@ -128,11 +128,15 @@ def test_each_latent_lands_on_its_own_barcode(tmp_path, version, filtered, suffi np.testing.assert_allclose(got.var["ambient_expression"], truth.ambient) uns = got.uns["cellbender"] assert uns["version"] == version - assert float(uns["target_false_positive_rate"]) == 0.01 + # As CellBender stores it: a scalar in 0.2, a one-element array since. + assert np.shape(uns["target_false_positive_rate"]) == (() if version == "0.2" else (1,)) + assert np.ravel(uns["target_false_positive_rate"]).tolist() == [0.01] if version == "0.3": assert "features_analyzed_inds" in uns, "metadata stays in uns" assert "features_analyzed_inds" not in got.var assert got.var["cellbender_analyzed"].all(), "as CellBender's anndata_from_h5 adds it" + # Unused for rows in a filtered file, so kept in uns as CellBender's loader does. + assert ("barcode_indices_for_latents" in uns) == filtered @pytest.mark.parametrize("filtered", [False, True], ids=["full", "filtered"]) From d82441acbb4779c36b84ead400a7954c8d7d93fc Mon Sep 17 00:00:00 2001 From: Aljes Date: Fri, 16 Oct 2026 11:57:59 +0100 Subject: [PATCH 2/2] test: the uns regression in its own test, saying what went wrong From review on #52. Co-Authored-By: Claude Opus 5.5 --- tests/test_cellbender.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_cellbender.py b/tests/test_cellbender.py index 79f4fc9..1566f5a 100644 --- a/tests/test_cellbender.py +++ b/tests/test_cellbender.py @@ -128,15 +128,11 @@ def test_each_latent_lands_on_its_own_barcode(tmp_path, version, filtered, suffi np.testing.assert_allclose(got.var["ambient_expression"], truth.ambient) uns = got.uns["cellbender"] assert uns["version"] == version - # As CellBender stores it: a scalar in 0.2, a one-element array since. - assert np.shape(uns["target_false_positive_rate"]) == (() if version == "0.2" else (1,)) assert np.ravel(uns["target_false_positive_rate"]).tolist() == [0.01] if version == "0.3": assert "features_analyzed_inds" in uns, "metadata stays in uns" assert "features_analyzed_inds" not in got.var assert got.var["cellbender_analyzed"].all(), "as CellBender's anndata_from_h5 adds it" - # Unused for rows in a filtered file, so kept in uns as CellBender's loader does. - assert ("barcode_indices_for_latents" in uns) == filtered @pytest.mark.parametrize("filtered", [False, True], ids=["full", "filtered"]) @@ -168,6 +164,24 @@ def test_latents_land_right_when_read_in_many_blocks(tmp_path, filtered, monkeyp assert np.array_equal(got.layers["cellranger"].toarray(), _raw_counts()[rows]) +@pytest.mark.parametrize("filtered", [False, True], ids=["full", "filtered"]) +@pytest.mark.parametrize("version", ["0.3", "0.2"]) +def test_uns_holds_what_cellbender_stored_as_its_loader_gives_it(tmp_path, version, filtered): + """Checked against real CellBender 0.3.2 output, uns differed from + CellBender's own loader twice: one-element settings were unwrapped to + scalars, so `uns[...][0]` broke, and a filtered file's + barcode_indices_for_latents, which places no rows there, was dropped.""" + truth = _cellbender(tmp_path, version=version, filtered=filtered) + out = tmp_path / "out.h5ad" + _ok(truth.path, "-o", out) + uns = _read(out).uns["cellbender"] + # A scalar in 0.2, a one-element array since. + assert np.shape(uns["target_false_positive_rate"]) == (() if version == "0.2" else (1,)) + # 0.2 drops the index from filtered files; 0.3+ keeps every analysed droplet's. + kept = version != "0.2" and filtered + assert ("barcode_indices_for_latents" in uns) == kept + + def test_cellbender_0_2_on_cell_ranger_2_input(tmp_path): truth = _cellbender(tmp_path, version="0.2", cellranger_v2=True) out = tmp_path / "out.h5ad"