Skip to content

Commit 13f12a0

Browse files
authored
Merge pull request #51 from cellgeni/feat/convert-cellbender
convert: CellBender remove-background output, with its latents
2 parents 1d4287c + 3db9c67 commit 13f12a0

19 files changed

Lines changed: 1437 additions & 76 deletions

‎AGENTS.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ Each layer calls only the layers below it:
9393

9494
Content sniffing runs before extensions, because extensions lie (`.h5`).
9595
A format with no extension, such as an MTX folder (`10x-mtx`), is chosen as
96-
an output only with `--to`.
96+
an output only with `--to`. Order in `FORMATS` matters: the first sniffer
97+
that matches wins, so a more specific format (`cellbender`, which is also a
98+
10x matrix) goes before the general one, and an output extension picks the
99+
first format that can be written.
97100
- **`formats/`**: the per-file-type halves of `export` and `import`: npy, mtx,
98101
csv, json and images. `mtx.py` is a streaming Matrix Market codec (two
99102
passes in, block writes out) shared by `import`/`export sparse` and the

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A command-line tool for exploring huge AnnData stores (`.h5ad` and `.zarr`) with
88
- Auto-detects `.h5ad` files vs `.zarr` directories
99
- Chunked processing for dense and sparse matrices (CSR/CSC)
1010
- Reads every AnnData on-disk layout, from 0.7.x through the current spec, and always writes the current one
11-
- Converts between HDF5 and Zarr (v2 and v3) in either direction with `adata convert`, and to and from 10x Genomics HDF5 matrices and 10x / STARsolo MTX folders
11+
- Converts between HDF5 and Zarr (v2 and v3) in either direction with `adata convert`, and to and from 10x Genomics HDF5 matrices and 10x / STARsolo MTX folders, and from CellBender output
1212
- Rich terminal output with progress indicators, kept on stderr so results pipe cleanly
1313

1414
**Documentation: [cellgeni.github.io/adata-cli](https://cellgeni.github.io/adata-cli/)**
@@ -56,7 +56,7 @@ Run help at any level (e.g. `adata --help`, `adata export --help`).
5656
- `subset` – stream and write a filtered copy, selected by obs/var name lists (`--obs`/`--var`) or by expression (`--obs-query`/`--var-query`).
5757
- `split` – write one store per distinct value of an annotation column, with a CSV manifest.
5858
- `concat` – concatenate stores along the obs axis, with `--join inner|outer` and merge strategies for var and uns.
59-
- `convert` – change a store's format: h5ad ↔ Zarr v2/v3, 10x Genomics HDF5 (Cell Ranger's `*_feature_bc_matrix.h5`) and 10x / STARsolo MTX folders in and out, streaming, with the output written beside its destination and moved into place only once complete.
59+
- `convert` – change a store's format: h5ad ↔ Zarr v2/v3, 10x Genomics HDF5 (Cell Ranger's `*_feature_bc_matrix.h5`) and 10x / STARsolo MTX folders in and out, CellBender output in, streaming, with the output written beside its destination and moved into place only once complete.
6060
- `recast` – change a matrix's dtype, layout (CSR/CSC/dense) or density, streaming; refuses a lossy cast or a large size increase unless forced. Called `convert` until 0.7.0.
6161
- `export` – extract data from a store; subcommands: `dataframe` (any dataframe group to CSV), `array` (dense to `.npy`), `sparse` (CSR/CSC to `.mtx`), `dict` (JSON), `image` (PNG). Results go to stdout when no `--output` is given.
6262
- `import` – write new data into a store at any path; subcommands: `dataframe` (CSV), `array` (`.npy`), `sparse` (`.mtx`), `dict` (JSON), `image` (PNG/JPEG/TIFF).

‎docs/COMMANDS.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ adata convert counts.h5ad -o counts_mtx --to 10x-mtx
122122
| `h5ad` | `.h5ad` | yes | yes |
123123
| `zarr` | `.zarr` (v2 or v3) | yes | yes |
124124
| `10x-h5` | `.h5` | Cell Ranger 2 and 3+, Space Ranger, Xenium | Cell Ranger 3+ layout |
125+
| `cellbender` | `.h5` | CellBender `remove-background` 0.2 and later | no |
125126
| `10x-mtx` | a folder (`--to 10x-mtx`) | Cell Ranger 2 and 3+, STARsolo (velocyto too) | Cell Ranger 3+ layout, gzipped |
126127

127128
Formats other than h5ad and Zarr convert to and from AnnData only: to turn a
@@ -196,6 +197,42 @@ is refused, and `--force` writes the names as ids and `Gene Expression` as
196197
the type. A matrix of floats is written as it is, with a warning. A file
197198
imported by `convert` exports with its run metadata and tag fields intact.
198199

200+
### CellBender
201+
202+
The output of CellBender's `remove-background` (`output.h5` and
203+
`output_filtered.h5`, 0.2 and later) is recognised by its latent variables,
204+
whatever its name. Its matrix is a 10x one and is read as above; the latents
205+
are kept, under the names CellBender's own loaders give them, so code written
206+
against those keeps working:
207+
208+
| CellBender | AnnData |
209+
|---|---|
210+
| denoised counts | `X` |
211+
| per-droplet latents (`cell_probability`, `cell_size`, `background_fraction`, `droplet_efficiency`; `latent_*` in 0.2) | obs columns, NaN for droplets CellBender did not analyse |
212+
| which droplets were analysed | `obs["cellbender_analyzed"]` |
213+
| `gene_expression_encoding` (`latent_gene_encoding` in 0.2) | obsm, same name |
214+
| `ambient_expression` | `var["ambient_expression"]` |
215+
| which features were analysed (`features_analyzed_inds`, 0.3+) | `var["cellbender_analyzed"]` |
216+
| everything else (fitted constants, learning curve, settings) | `uns["cellbender"]` |
217+
218+
```bash
219+
adata convert output.h5 -o cellbender.h5ad
220+
adata convert output.h5 -o cells.h5ad --min-cell-probability 0.5
221+
adata convert output_filtered.h5 -o cb.h5ad --raw raw_feature_bc_matrix.h5
222+
```
223+
224+
| Flag | Meaning |
225+
|---|---|
226+
| `--min-cell-probability` | Keep droplets whose `cell_probability` is at least this; CellBender's own cutoff is 0.5 |
227+
| `--raw` | Add the raw Cell Ranger matrix CellBender ran on as `layers["cellranger"]`, and the denoised counts as `layers["cellbender"]`, as `load_anndata_from_input_and_output` does |
228+
229+
The full `output.h5` keeps every droplet, as `read_10x_h5` would. `--raw`
230+
matches barcodes, so it works for `output_filtered.h5` too, whose cells
231+
CellBender orders by count; the raw file must list the same features in the
232+
same order, and every output barcode, or it is refused. The 10x flags above
233+
apply as well. `--from 10x-h5` reads a CellBender file as a plain 10x matrix,
234+
without the latents.
235+
199236
### 10x Genomics and STARsolo MTX folders
200237

201238
A folder holding `matrix.mtx`, `barcodes.tsv` and `features.tsv` (or

‎docs/TESTING.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ separate jobs.
2828
| `test_recast.py` | `recast` against scipy: casts, transposes, density changes and every refusal |
2929
| `test_tenx.py` | 10x HDF5 import and export against Cell Ranger-shaped fixtures (v2, v3, multimodal, probe-based): mapping, filters, round trips, refusals |
3030
| `test_mtx.py` | 10x / STARsolo MTX folders against hand-written fixtures (Cell Ranger 2 and 3, STARsolo, velocyto, GEO prefixes): mapping, entry orders, refusals that leave nothing, round trips |
31+
| `test_cellbender.py` | CellBender 0.2 and 0.3+ output, full and filtered: each latent at its barcode, `--min-cell-probability`, `--raw` alignment, refusals |
3132
| `test_mtx_codec.py`, `test_csr_build.py` | The streaming Matrix Market reader and writer against scipy, and the bucket sort they and `recast` share |
3233
| `test_external_readers.py` | 10x conversions, HDF5 and MTX, checked by scanpy and PyTables in their own uv environments (`integration`) |
3334
| `test_performance.py` | Complexity guards -- what an operation costs, not how long it takes (see below) |
@@ -245,6 +246,7 @@ should scale with, scale only that, and hold everything else fixed.
245246
| `convert` 10x import (whole, `--split-feature-types`), export | n_barcodes | linear |
246247
| `convert` 10x MTX import (in order, shuffled, `--split-feature-types`) | n_barcodes | linear; **flat** write calls |
247248
| `convert` to 10x MTX | n_obs | **flat** read calls |
249+
| `convert` CellBender (plain, `--min-cell-probability`, `--raw` full and filtered) | n_barcodes | linear; **flat** write calls |
248250
| `recast --dtype`, `--layout csc`, `--layout dense` | nnz, n_obs | linear; the streaming transpose's **peak memory** is bounded |
249251

250252
The `slow` marker is on the three guards that build a 65,536-row store; they still gate

‎docs/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ in.h5ad -o out.zarr` converts a whole store, and `subset`, `split` and
7070
`convert` also reads and writes 10x Genomics HDF5 matrices (Cell Ranger's
7171
`*_feature_bc_matrix.h5`) and 10x / STARsolo Matrix Market folders,
7272
velocyto's included, matching what scanpy's `read_10x_h5` and `read_10x_mtx`
73-
read.
73+
read, and CellBender's `remove-background` output with its latent variables.
7474

7575
| Element | Read | Write |
7676
|---|---|---|

‎src/adata/cli.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,8 @@ def concat(
522522
"gene_ids_column": "--gene-ids-column",
523523
"gene_names_column": "--gene-names-column",
524524
"feature_types_column": "--feature-types-column",
525+
"raw": "--raw",
526+
"min_cell_probability": "--min-cell-probability",
525527
}
526528

527529
#: Flags that only make sense for format conversion; with recast's flags,
@@ -645,6 +647,21 @@ def convert(
645647
"--feature-types-column",
646648
help="10x output: var column holding feature types (default feature_types)",
647649
),
650+
raw: Optional[Path] = typer.Option(
651+
None,
652+
"--raw",
653+
help="CellBender input: the raw Cell Ranger .h5 it ran on, added as "
654+
"layers['cellranger']",
655+
exists=True,
656+
dir_okay=False,
657+
readable=True,
658+
),
659+
min_cell_probability: Optional[float] = typer.Option(
660+
None,
661+
"--min-cell-probability",
662+
help="CellBender input: keep droplets with cell_probability at least "
663+
"this (CellBender's own cutoff is 0.5)",
664+
),
648665
# Deprecated: recast's flags, so `convert` keeps working as it did until
649666
# 1.0.0. Hidden from --help; the old matrix paths arrive in ctx.args.
650667
inplace: bool = typer.Option(False, "--inplace", hidden=True),
@@ -662,7 +679,8 @@ def convert(
662679
Genomics matrix (Cell Ranger's *_feature_bc_matrix.h5); --to 10x-mtx
663680
writes a Matrix Market folder. Between h5ad and zarr every element is
664681
copied, streamed, keeping the source's chunking and compression where the
665-
target can express them. A 10x matrix is read as scanpy's read_10x_h5 or
682+
target can express them. CellBender output keeps its latent variables, as
683+
CellBender's own loader names them. A 10x matrix is read as scanpy's read_10x_h5 or
666684
read_10x_mtx (gex_only=False) reads it, keeping integer counts, and
667685
written as Cell Ranger writes it. MTX folders from Cell Ranger and
668686
STARsolo, velocyto's included, are read in two streaming passes.
@@ -684,6 +702,8 @@ def convert(
684702
adata convert Solo.out/Gene/filtered -o counts.h5ad
685703
adata convert Solo.out/Velocyto/raw -o velocyto.h5ad
686704
adata convert counts.h5ad -o counts_mtx --to 10x-mtx --layer counts
705+
adata convert cellbender_output.h5 -o cb.h5ad --raw raw_feature_bc_matrix.h5
706+
adata convert cellbender_output.h5 -o cells.h5ad --min-cell-probability 0.5
687707
"""
688708
entries = list(ctx.args)
689709
recast_flags = _given(ctx, _RECAST_FLAGS)
@@ -767,6 +787,8 @@ def convert(
767787
gene_ids_column=gene_ids_column,
768788
gene_names_column=gene_names_column,
769789
feature_types_column=feature_types_column,
790+
raw=raw,
791+
min_cell_probability=min_cell_probability,
770792
),
771793
)
772794
except Exception as e:

‎src/adata/conversion/__init__.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import h5py
1919

20-
from adata.conversion import mtx, tenx
20+
from adata.conversion import cellbender, mtx, tenx
2121
from adata.conversion.anndata_copy import AnnDataCopy
2222
from adata.conversion.base import ConversionPlan, Converter, Format, Options
2323
from adata.storage import has_valid_anndata_root_attrs, is_zarr_path
@@ -66,6 +66,15 @@ def _sniff_h5ad(path: Path) -> bool:
6666
anndata=True,
6767
sniff=is_zarr_path,
6868
),
69+
# Before 10x-h5: a CellBender file is a 10x matrix too, and the first
70+
# sniffer to match wins. `--from 10x-h5` still reads one as plain 10x.
71+
"cellbender": Format(
72+
name="cellbender",
73+
description="CellBender remove-background output (0.2 and later)",
74+
extensions=(".h5",),
75+
sniff=cellbender.sniff,
76+
importer=cellbender.CellbenderImport(),
77+
),
6978
"10x-h5": Format(
7079
name="10x-h5",
7180
description="10x Genomics feature-barcode matrix (Cell Ranger v2 or v3)",
@@ -136,10 +145,21 @@ def output_format(path: Path, requested: Optional[str] = None) -> str:
136145
path = Path(path)
137146
if requested is not None and requested not in FORMATS:
138147
raise ValueError(f"Unknown format {requested!r}; use one of: {_names()}.")
148+
if requested is not None and not (
149+
FORMATS[requested].anndata or FORMATS[requested].exporter is not None
150+
):
151+
raise ValueError(f"{requested} can be read but not written.")
139152

140153
suffix = path.suffix.lower()
154+
# Only formats that can be written: `.h5` names CellBender output too,
155+
# but an `.h5` output is a 10x matrix.
141156
by_extension = next(
142-
(fmt.name for fmt in FORMATS.values() if suffix in fmt.extensions), None
157+
(
158+
fmt.name
159+
for fmt in FORMATS.values()
160+
if suffix in fmt.extensions and (fmt.anndata or fmt.exporter is not None)
161+
),
162+
None,
143163
)
144164
if requested is None:
145165
if by_extension is None:

‎src/adata/conversion/base.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ class Options:
3535
gene_names_column: Optional[str] = None
3636
feature_types_column: Optional[str] = None
3737

38+
# Reading CellBender output.
39+
raw: Optional[Path] = None
40+
min_cell_probability: Optional[float] = None
41+
3842

3943
@dataclass
4044
class ConversionPlan:

0 commit comments

Comments
 (0)