feat: Add lazy table loading via anndata.experimental.read_lazy - #1055
feat: Add lazy table loading via anndata.experimental.read_lazy#1055Tomatokeftes wants to merge 5 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1055 +/- ##
=======================================
Coverage 92.44% 92.44%
=======================================
Files 51 51
Lines 7820 7837 +17
=======================================
+ Hits 7229 7245 +16
- Misses 591 592 +1
🚀 New features to boost your workflow:
|
Add a `lazy: bool = False` parameter to SpatialData.read(), read_zarr() and _read_table(), backing tables with anndata.experimental.read_lazy() instead of loading them into memory. Large tables -- Mass Spectrometry Imaging data can reach millions of pixels by hundreds of thousands of m/z bins -- otherwise have to be read eagerly even though every other element type is already lazy. Adds a _is_lazy_anndata() helper and skips the eager dtype/null checks in TableModel validation for lazy tables, since those checks would materialize the data and defeat the purpose. If lazy=True and read_lazy() fails the error propagates rather than silently falling back to an eager read, which could exhaust memory on large stores.
Tables read via anndata.experimental.read_lazy() expose obs as an xarray Dataset2D rather than a pandas DataFrame. pd.DataFrame(obs) accepts it but returns a malformed frame instead of raising, so the failure surfaces later as wrong values rather than an error. Materialize with obs.to_memory() on that branch in get_values() and _inplace_fix_subset_categorical_obs(), which is what bounding_box_query() and aggregate() go through.
Adds coverage that lazy=True builds a SpatialData object, that lazy=False is unchanged, and that the flag is threaded through read_zarr().
Upstream scverse#1131 rewrote the join helpers to call pandas methods on table.obs directly (reset_index, groupby). A table read with anndata.experimental.read_lazy exposes obs as an xarray Dataset2D, which implements neither, so join_spatialelement_table, bounding_box_query and get_values all raised AttributeError on lazy tables. Route those five call sites through _obs_as_dataframe(), which materializes obs only when it is not already a DataFrame. obs is the small axis, so X stays lazy. get_values additionally needs X computed before it reaches pd.DataFrame(), which cannot consume a dask array; that is a selection of the requested columns only. Adds a regression test covering all five join modes, bounding_box_query and get_values against a lazy table, asserting the results equal the eager read and that X is still a dask array afterwards. The test fails without this change.
A lazily-read sparse table backs X with a dask array whose blocks are scipy.sparse matrices. Dask builds a reduction's result metadata by calling the matching NumPy reduction on one block, and scipy.sparse rejects the keepdims and ndmin arguments NumPy forwards, so X.sum(), .mean(), .max() and .std() all raise while the graph is built, before compute() is reached. This is a dask/scipy.sparse interoperability limitation rather than something the lazy reader introduces, and it is not fixable here. Document it on read_zarr() and SpatialData.read() along with the workarounds that do work, slicing before reducing and map_blocks, so it does not arrive as a bug report.
bfd2b5f to
4b1da50
Compare
|
Rebased onto current While rebasing I found that this branch had quietly broken, and I think this is worth Fixed by routing those call sites through a small BenchmarkRe-measured on the rebased branch: 100,000 pixels x 100,000 m/z bins, 295,547,702
anndata 0.13.2, dask 2026.7.1, scipy 1.18.0, zarr 3.3.0, Python 3.13. Downstream consumerThis is on the critical path for Thyra, Known limitation, now documentedWhen the stored table.X.sum() # TypeError: _cs_matrix.sum() got an unexpected keyword argument 'keepdims'
table.X.mean() # IndexError: Index dimension must be 1 or 2
table.X.max() # TypeError: _cs_matrix.__init__() got an unexpected keyword argument 'ndmin'Dask derives the result metadata by calling the matching NumPy reduction on one block, StatusAll 10 checks are green on the rebased head: the full test matrix (ubuntu/macOS/Windows, Happy to split the query fix into its own PR if you would rather review the two |
Summary
This PR adds support for lazy loading of tables in SpatialData using anndata's experimental
read_lazy()function.Motivation
Currently, all elements in SpatialData (images, labels, points) are loaded lazily using Dask, except for tables which are always loaded into memory. For large datasets, particularly Mass Spectrometry Imaging (MSI) data where tables can contain millions of pixels with hundreds of thousands of m/z bins, this creates memory bottlenecks.
Changes
Core lazy loading support:
lazy: bool = Falseparameter toSpatialData.read()andread_zarr()lazy: bool = Falseparameter to_read_table()in io_table.pyanndata.experimental.read_lazy()whenlazy=True_is_lazy_anndata()helper function to detect lazy AnnData objectsread_lazyQuery API compatibility fixes:
_filter_table_by_element_names()to handle lazyDataset2Dobs_filter_table_by_elements()to handle lazyDataset2Dobsget_values()to handle lazyDataset2Dobs_inplace_fix_subset_categorical_obs()to handle lazy tablesThe query fixes ensure that
bounding_box_query,aggregate, and other APIs work correctly with lazy-loaded tables. The issue was thatpd.DataFrame(table.obs)doesn't correctly convert lazyDataset2Dobjects - it produces a malformed DataFrame. The fix usestable.obs.to_memory()for lazy tables instead.Usage
Benchmark Results
Test configuration: 100,000 pixels x 100,000 m/z bins, 3,000 peaks/pixel (~296M non-zeros)
Reproducible Example
Requirements
anndata >= 0.12for lazy loading supportReal-world use case
This feature was developed for Thyra, a Mass Spectrometry Imaging converter. MSI datasets can have:
With lazy loading, users can work with these datasets without loading the full table into memory.
Test plan
test_lazy_read_basic- Verify lazy=True creates a SpatialData object without errorstest_lazy_false_loads_normally- Verify lazy=False maintains current behaviortest_read_zarr_lazy_parameter- Verify lazy parameter is passed through correctly