Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ va.X # VCSCArray
va.raw_X # VCSCArray (separate from anndata's .raw)

# Persist to HDF5 (.h5ad) or Zarr with default Blosc2+LZ4 compression
# (stored as IVCSC/IVCSR, see below)
va.write_h5ad("compressed.h5ad") # Read back with VCSCAnnData.read_h5ad
va2 = vsparse.VCSCAnnData.read_h5ad("compressed.h5ad")

Expand All @@ -121,13 +122,13 @@ plain = va.to_anndata()

### Byte-Packed On-Disk Format (IVCSC / IVCSR)

For smaller files, pass `format="ivcsc"` (or `"ivcsr"`) on write. This byte-packs the minor-axis indices with delta + varint encoding (inspired by [IVSparse's IVCSC](https://github.com/Seth-Wolfgang/IVSparse)).
`write_h5ad` / `write_zarr` always store `X`/`raw_X` as IVCSC (from VCSC) or IVCSR (from VCSR). This byte-packs the minor-axis indices with delta + varint encoding (inspired by [IVSparse's IVCSC](https://github.com/Seth-Wolfgang/IVSparse)).

It is purely an archival storage format: `read_h5ad` / `read_zarr` decompress the indices on load and return a standard `VCSCArray`/`VCSRArray`.

```python
# Write delta+varint byte-packed indices
va.write_h5ad("archived.h5ad", format="ivcsc")
# Writes delta+varint byte-packed indices
va.write_h5ad("archived.h5ad")

# Reads back directly as an ordinary VCSCAnnData
va_loaded = vsparse.VCSCAnnData.read_h5ad("archived.h5ad")
Expand Down
5 changes: 2 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,7 @@ allowlist of types, so a plain `AnnData` cannot hold a `VCSCArray` in `X`.
zarr). Pass `dataset_kwargs={}` to write uncompressed, or your own
`dataset_kwargs` to use a different codec.

`X`/`raw_X` are stored in the VCSC/VCSR layout by default (`format="vcsc"`).
Passing `format="ivcsc"` instead stores them as **IVCSC/IVCSR** -- the same
`X`/`raw_X` are always stored as **IVCSC/IVCSR** -- the VCSC/VCSR
layout, but with the minor-axis `indices` array delta+varint byte-packed
(inspired by [IVSparse's IVCSC](https://github.com/Seth-Wolfgang/IVSparse)).
This is a *file-storage-only* format: it trades extra CPU on write/read for a
Expand All @@ -99,7 +98,7 @@ smaller file, and there is no in-memory IVCSC array type to compute with --
decompressed from IVCSC/IVCSR immediately on load.

```python
va.write_h5ad("archived.h5ad", format="ivcsc")
va.write_h5ad("archived.h5ad")
va4 = vsparse.VCSCAnnData.read_h5ad("archived.h5ad") # va4.X is a plain VCSCArray
```
`VCSCAnnData` works around this by overriding the `X` property; as a consequence,
Expand Down
30 changes: 10 additions & 20 deletions src/vsparse/_anndata_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
_DF_KEYS = ("obs", "var")
_MAPPING_KEYS = ("obsm", "varm", "obsp", "varp", "layers", "uns")
_FIELD_KEYS = (*_DF_KEYS, *_MAPPING_KEYS)
_STORE_FORMATS = ("vcsc", "ivcsc")

# Names statistics are recorded under in .obs/.varm/.uns -- see .normalized().
_VSPARSE_UNS_KEY = "vsparse"
Expand Down Expand Up @@ -419,21 +418,17 @@ def _write_group(
self,
g: Any,
*,
format: str = "vcsc",
convert_strings_to_categoricals: bool = True,
dataset_kwargs: Mapping[str, Any] = MappingProxyType({}),
) -> None:
if format not in _STORE_FORMATS:
raise ValueError(f"format must be one of {_STORE_FORMATS}, got {format!r}")
if convert_strings_to_categoricals:
# Writing field-by-field skips what anndata's own writers do here,
# leaving low-cardinality columns as one string per row.
self.strings_to_categoricals()
write_array = ad.io.write_elem if format == "vcsc" else _io.write_ivcs_elem
if self._vcs_X is not None:
write_array(g, "X", self._vcs_X, dataset_kwargs=dataset_kwargs)
_io.write_ivcs_elem(g, "X", self._vcs_X, dataset_kwargs=dataset_kwargs)
if self._vcs_raw_X is not None:
write_array(g, "raw_X", self._vcs_raw_X, dataset_kwargs=dataset_kwargs)
_io.write_ivcs_elem(g, "raw_X", self._vcs_raw_X, dataset_kwargs=dataset_kwargs)
for key in _DF_KEYS:
ad.io.write_elem(g, key, getattr(self, key), dataset_kwargs=dataset_kwargs)
for key in _MAPPING_KEYS:
Expand All @@ -455,22 +450,20 @@ def write_h5ad( # ty: ignore[invalid-method-override]
self,
filename: str | PathLike[str],
*,
format: str = "vcsc",
convert_strings_to_categoricals: bool = True,
dataset_kwargs: Mapping[str, Any] | None = None,
**_kwargs: Any,
) -> None:
"""Write to ``.h5ad``. Read back with :meth:`read_h5ad`.

``X``/``raw_X`` are always stored as IVCSC (from VCSC) or IVCSR (from
VCSR): the minor-axis indices are byte-packed (delta + varint
encoding), at the cost of extra work on write/read. They come back from
:meth:`read_h5ad` as ordinary VCSCArray/VCSRArray objects -- IVCSC/IVCSR
is purely an on-disk storage format.

Parameters
----------
format
``"vcsc"`` (default) stores ``X``/``raw_X`` with plain int arrays
for the minor-axis indices. ``"ivcsc"`` (IVCSC/IVCSR) instead
byte-packs them (delta + varint encoding) for a smaller file, at
the cost of extra work on write/read. Either way, ``X``/``raw_X``
come back from :meth:`read_h5ad` as ordinary VCSCArray/VCSRArray
objects -- ``"ivcsc"`` is purely an on-disk storage format.
convert_strings_to_categoricals
Convert ``obs``/``var`` string columns to categorical in place
before writing, as ``anndata``'s own writers do. Only columns
Expand All @@ -491,7 +484,6 @@ def write_h5ad( # ty: ignore[invalid-method-override]
):
self._write_group(
f,
format=format,
convert_strings_to_categoricals=convert_strings_to_categoricals,
dataset_kwargs=dataset_kwargs,
)
Expand All @@ -508,15 +500,14 @@ def write_zarr(
self,
store: Any,
*,
format: str = "vcsc",
convert_strings_to_categoricals: bool = True,
dataset_kwargs: Mapping[str, Any] | None = None,
**_kwargs: Any,
) -> None:
"""Write to a zarr store. Read back with :meth:`read_zarr`.

See :meth:`write_h5ad` for ``format``/``convert_strings_to_categoricals``/
``dataset_kwargs`` (including the numeric-only compression behavior);
See :meth:`write_h5ad` for the IVCSC/IVCSR storage format and
``convert_strings_to_categoricals``/``dataset_kwargs`` (including the numeric-only compression behavior);
the default compression here is Blosc+LZ4 via ``numcodecs``.
"""
import zarr
Expand All @@ -527,7 +518,6 @@ def write_zarr(
f = zarr.open_group(store, mode="w")
self._write_group(
f,
format=format,
convert_strings_to_categoricals=convert_strings_to_categoricals,
dataset_kwargs=dataset_kwargs,
)
Expand Down
4 changes: 2 additions & 2 deletions src/vsparse/_rapid_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ def load_and_normalize(
----------
path
Path to an ``.h5ad`` file whose ``X`` (or ``layers[x_key]``) was
written with ``format="ivcsc"``/``"ivcsr"`` (see
stored as IVCSC/IVCSR (see
:meth:`~vsparse.VCSCAnnData.write_h5ad`).
min_cell_counts
Cells with total raw counts <= this are dropped.
Expand Down Expand Up @@ -594,7 +594,7 @@ def load_packed(path: str | PathLike[str], *, x_key: str = "X") -> VCSCAnnData:
----------
path
Path to an ``.h5ad`` file whose ``X`` (or a top-level group named
``x_key``) was written with ``format="ivcsc"``/``"ivcsr"`` (see
``x_key``) was stored as IVCSC/IVCSR (see
:meth:`~vsparse.VCSCAnnData.write_h5ad`).
x_key
Top-level h5ad group holding the IVCSR/IVCSC array (``"X"`` by default).
Expand Down
10 changes: 2 additions & 8 deletions tests/test_anndata_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def test_zarr_roundtrip(base_adata, dense, tmp_path):
def test_h5ad_ivcsc_roundtrip(base_adata, dense, tmp_path):
va = VCSCAnnData.from_anndata(base_adata)
path = tmp_path / "test_ivcsc.h5ad"
va.write_h5ad(path, format="ivcsc")
va.write_h5ad(path)

import h5py

Expand All @@ -130,19 +130,13 @@ def test_h5ad_ivcsc_roundtrip(base_adata, dense, tmp_path):
def test_zarr_ivcsc_roundtrip(base_adata, dense, tmp_path):
va = VCSCAnnData.from_anndata(base_adata, format="csr")
path = tmp_path / "test_ivcsr.zarr"
va.write_zarr(path, format="ivcsc")
va.write_zarr(path)

read_back = VCSCAnnData.read_zarr(path)
assert isinstance(read_back.X, VCSRArray)
np.testing.assert_allclose(read_back.X.to_scipy().toarray(), dense)


def test_write_h5ad_rejects_bad_format(base_adata, tmp_path):
va = VCSCAnnData.from_anndata(base_adata)
with pytest.raises(ValueError):
va.write_h5ad(tmp_path / "bad.h5ad", format="bogus")


def test_write_h5ad_default_compression(base_adata, tmp_path):
va = VCSCAnnData.from_anndata(base_adata)
path = tmp_path / "compressed.h5ad"
Expand Down
9 changes: 4 additions & 5 deletions tests/test_index_dtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,12 @@ def test_derived_arrays_stay_narrow(vcls, dense, op):
assert op(v, dense).indices.dtype == np.int32


@pytest.mark.parametrize("fmt", ["vcsc", "ivcsc"])
def test_roundtrip_preserves_values_and_stores_narrow_indices(tmp_path, dense, fmt):
def test_roundtrip_preserves_values_and_stores_narrow_indices(tmp_path, dense):
import anndata as ad

va = VCSCAnnData.from_anndata(ad.AnnData(X=sp.csr_array(dense)), format="csr")
path = tmp_path / f"data.{fmt}.h5ad"
va.write_h5ad(path, format=fmt)
path = tmp_path / "data.h5ad"
va.write_h5ad(path)

back = VCSCAnnData.read_h5ad(path)
assert isinstance(back.X, VCSRArray)
Expand All @@ -121,7 +120,7 @@ def test_packed_write_narrows_a_wide_in_memory_array(tmp_path, dense):
va.X.indices = va.X.indices.astype(np.int64)

path = tmp_path / "wide.h5ad"
va.write_h5ad(path, format="ivcsc")
va.write_h5ad(path)

back = VCSCAnnData.read_h5ad(path)
assert isinstance(back.X, VCSRArray)
Expand Down
8 changes: 3 additions & 5 deletions tests/test_metadata_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import anndata as ad
import numpy as np
import pandas as pd
import pytest
import scipy.sparse as sp

from vsparse import VCSCAnnData, VCSRArray
Expand Down Expand Up @@ -31,13 +30,12 @@ def _adata(n_cells: int = 400, n_genes: int = 20) -> ad.AnnData:
return ad.AnnData(X=X, obs=obs, var=var)


@pytest.mark.parametrize("fmt", ["vcsc", "ivcsc"])
def test_string_columns_are_written_as_categoricals(tmp_path, fmt):
def test_string_columns_are_written_as_categoricals(tmp_path):
va = VCSCAnnData.from_anndata(_adata(), format="csr")
assert not isinstance(va.obs["cell_type"].dtype, pd.CategoricalDtype)

path = tmp_path / f"data.{fmt}.h5ad"
va.write_h5ad(path, format=fmt)
path = tmp_path / "data.h5ad"
va.write_h5ad(path)
back = VCSCAnnData.read_h5ad(path)

assert isinstance(back.obs["cell_type"].dtype, pd.CategoricalDtype)
Expand Down
12 changes: 6 additions & 6 deletions tests/test_rapid_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def _write_ivcsr(tmp_path, dense: np.ndarray, name="data.ivcsr.h5ad"):
adata = ad.AnnData(X=sp.csr_array(dense))
vad = VCSCAnnData.from_anndata(adata, format="csr")
path = tmp_path / name
vad.write_h5ad(path, format="ivcsc")
vad.write_h5ad(path)
return path


Expand Down Expand Up @@ -126,7 +126,7 @@ def test_min_cells_uses_obs_filtered_cohort(tmp_path):
)
adata = ad.AnnData(X=sp.csr_array(dense), obs=obs)
path = tmp_path / "min_cells_obs_filter.ivcsr.h5ad"
VCSCAnnData.from_anndata(adata, format="csr").write_h5ad(path, format="ivcsc")
VCSCAnnData.from_anndata(adata, format="csr").write_h5ad(path)

obs_mask = np.asarray(obs["condition"] == "control")
result = load_and_normalize(
Expand Down Expand Up @@ -180,7 +180,7 @@ def test_obs_filter_matches_subset_reference(tmp_path):
)
adata = ad.AnnData(X=sp.csr_array(dense), obs=obs)
path = tmp_path / "obs_filter.ivcsr.h5ad"
VCSCAnnData.from_anndata(adata, format="csr").write_h5ad(path, format="ivcsc")
VCSCAnnData.from_anndata(adata, format="csr").write_h5ad(path)

obs_mask = np.asarray(obs["condition"] == "control")
result = load_and_normalize(
Expand Down Expand Up @@ -217,7 +217,7 @@ def test_obs_filter_applied_before_cell_filtering_and_slices_metadata(tmp_path,
obsp = {"distances": rng.random((n_cells, n_cells))}
adata = ad.AnnData(X=sp.csr_array(dense), obs=obs, var=var, obsm=obsm, obsp=obsp)
path = tmp_path / "obs_filter_metadata.ivcsr.h5ad"
VCSCAnnData.from_anndata(adata, format="csr").write_h5ad(path, format="ivcsc")
VCSCAnnData.from_anndata(adata, format="csr").write_h5ad(path)

obs_mask = np.asarray(obs["condition"] == "control")
selected_rows = np.nonzero(obs_mask)[0]
Expand Down Expand Up @@ -317,7 +317,7 @@ def test_metadata_sliced_correctly(tmp_path, rng):

path = tmp_path / "metadata_test.ivcsr.h5ad"
vad = VCSCAnnData.from_anndata(adata, format="csr")
vad.write_h5ad(path, format="ivcsc")
vad.write_h5ad(path)

min_cell_counts = 10.0
gene_threshold = 0.05
Expand Down Expand Up @@ -423,7 +423,7 @@ def test_load_packed_metadata_preserved(tmp_path, rng):
adata.var["gene"] = [f"g{i}" for i in range(6)]
vad = VCSCAnnData.from_anndata(adata, format="csr")
path = tmp_path / "meta.ivcsr.h5ad"
vad.write_h5ad(path, format="ivcsc")
vad.write_h5ad(path)

result = load_packed(path)
pd.testing.assert_index_equal(result.obs.index, adata.obs.index)
Expand Down
Loading