Repository navigation
Read nullable-string-array columns from h5ad files - #73
albertlieyingadrian wants to merge 2 commits into
Conversation
anndata >= 0.11 with pandas >= 3 writes string columns (including the obs and var index) as a nullable-string-array group holding 'values' and 'mask' instead of a single dataset. Reading them with [:] raised TypeError, so get_gene_names() and barcode loading failed on any h5ad written with current pandas/anndata. Add read_h5_column() to handle both layouts and use it for the gene-name and barcode reads.
There was a problem hiding this comment.
Code Review
This pull request introduces a helper function read_h5_column to support reading 1-D string columns stored as nullable-string-array groups in h5ad files (written by newer versions of anndata and pandas), replacing direct slicing of H5 datasets. Feedback on the changes highlights potential crashes where read_h5_column might raise a ValueError (e.g., for unsupported group encodings or categorical columns) that is not caught by existing except KeyError blocks, preventing proper fallback behavior. Additionally, it is recommended to use read_h5_column in H5MetadataCache.__init__ to prevent type errors when loading non-categorical string columns.
| try: | ||
| raw = self.h5_file["var/gene_name"][:] | ||
| raw = read_h5_column(self.h5_file["var/gene_name"]) | ||
| except KeyError: | ||
| raw = self.h5_file["var/gene_name_index"][:] | ||
| raw = read_h5_column(self.h5_file["var/gene_name_index"]) |
There was a problem hiding this comment.
If var/gene_name is a group with an unsupported encoding (e.g., if it is a categorical group or some other unsupported group), read_h5_column will raise a ValueError. Since the except block only catches KeyError, this ValueError will propagate and crash the program instead of falling back to var/gene_name_index.
To ensure robust fallback, catch both KeyError and ValueError.
| try: | |
| raw = self.h5_file["var/gene_name"][:] | |
| raw = read_h5_column(self.h5_file["var/gene_name"]) | |
| except KeyError: | |
| raw = self.h5_file["var/gene_name_index"][:] | |
| raw = read_h5_column(self.h5_file["var/gene_name_index"]) | |
| try: | |
| raw = read_h5_column(self.h5_file["var/gene_name"]) | |
| except (KeyError, ValueError): | |
| raw = read_h5_column(self.h5_file["var/gene_name_index"]) |
There was a problem hiding this comment.
Kept except KeyError here on purpose. Widening it to ValueError would also catch encodings we can't read and silently switch to a different column (gene_name_index may hold different IDs). A categorical var/gene_name is already handled by the codes/categories branch just above, and an unknown encoding now fails loudly with the column name. Fixed in 1bab51f.
| try: | ||
| fallback = self.h5_file["var/_index"][:] | ||
| fallback = read_h5_column(self.h5_file["var/_index"]) | ||
| except KeyError: | ||
| fallback = self.h5_file["var/feature"][:] | ||
| fallback = read_h5_column(self.h5_file["var/feature"]) |
There was a problem hiding this comment.
Similarly, if var/_index is a group with an unsupported encoding, read_h5_column will raise a ValueError. This will propagate and crash the program instead of falling back to var/feature because the except block only catches KeyError.
Catching both KeyError and ValueError ensures a robust fallback.
| try: | |
| fallback = self.h5_file["var/_index"][:] | |
| fallback = read_h5_column(self.h5_file["var/_index"]) | |
| except KeyError: | |
| fallback = self.h5_file["var/feature"][:] | |
| fallback = read_h5_column(self.h5_file["var/feature"]) | |
| try: | |
| fallback = read_h5_column(self.h5_file["var/_index"]) | |
| except (KeyError, ValueError): | |
| fallback = read_h5_column(self.h5_file["var/feature"]) |
There was a problem hiding this comment.
Addressed in 1bab51f differently: read_h5_column now expands a categorical group to categories[codes], so a categorical var/_index is read directly instead of falling through to var/feature. Other unknown encodings still raise rather than silently switching columns. Covered by test_read_h5_column_categorical.
| try: | ||
| # Try to load from obs/_index (AnnData's default storage for obs index) | ||
| barcodes = self.h5_file["obs/_index"][:] | ||
| barcodes = read_h5_column(self.h5_file["obs/_index"]) |
There was a problem hiding this comment.
If obs/_index is a categorical column, it is stored as a group in the H5 file. In this case, read_h5_column will raise a ValueError because its encoding is not nullable-string-array. Since the subsequent except KeyError: block (on line 1522) only catches KeyError, this ValueError will propagate and crash the program instead of falling back to the categorical loading logic.
To fix this, please update the except block on line 1522 to catch both KeyError and ValueError:
except (KeyError, ValueError):
# If obs/_index doesn't exist or is categorical, try obs/_index/categories and codesThere was a problem hiding this comment.
Good catch, a categorical obs/_index did not reach the fallback (on main it raised TypeError from group[:]). Fixed in 1bab51f by decoding categorical groups inside read_h5_column, instead of widening the except. A wider except ValueError would also swallow UnicodeDecodeError from the decode loop and replace real barcodes. Test: test_categorical_obs_index_loads_barcodes fails before the change and passes after.
| return self._cache[h5_path] | ||
|
|
||
|
|
||
| def read_h5_column(node) -> np.ndarray: |
There was a problem hiding this comment.
In H5MetadataCache.__init__ (around line 55), if batch_col is a non-categorical string column, under pandas >= 3 and anndata >= 0.11 it will be written as a nullable-string-array group. The else block currently does raw = batch_ds[:], which will raise a TypeError because batch_ds is a group.
To prevent this crash, we should also use read_h5_column there:
else:
self.batch_is_categorical = False
raw = read_h5_column(batch_ds)There was a problem hiding this comment.
Agreed, fixed in 1bab51f: the non-categorical batch branch now uses read_h5_column(batch_ds). test_metadata_cache_reads_string_batch_column writes a string batch column with convert_strings_to_categoricals=False (a nullable-string-array group). It raised TypeError before and now gives categories ['b1', 'b2'], codes [0, 1, 0, 1]. Full suite: 43 passed. The 3 test_mapping_strategy failures also fail on main.
…tch columns A categorical obs/_index or var/_index group is now expanded to categories[codes] instead of raising, and H5MetadataCache reads a non-categorical batch column through read_h5_column, so a nullable-string-array batch column no longer fails on batch_ds[:].
What's wrong
With pandas >= 3, the default string dtype is nullable, and anndata >= 0.11 writes such columns, including the obs and var index, as a
nullable-string-arraygroup withvaluesandmaskdatasets:PerturbationDatasetreads these withself.h5_file[...][:]. On a group that raises:so
get_gene_names()and barcode loading (barcode=True) fail on any h5ad written with current pandas/anndata.pyproject.tomldoesn't cap either package anduv.lockis gitignored, so a freshuv sync --all-extras --dev(as CI does) gets pandas 3.1.1 and anndata 0.13.4. Onmain,tests/test_dataloader.py::test_barcode_functionalityfails this way. Categorical columns are unaffected because anndata still writes theircategoriesas a dataset.Fix
A small
read_h5_column()helper inutils/data_utils.py:node[:]for a plain dataset (the old layout, unchanged);valuesfor anullable-string-arraygroup;ValueErrorthat names the encoding for any other group, so it doesn't fall silently into theKeyErrorfallbacks.get_gene_names()(var/gene_name,var/gene_name_index,var/_index,var/feature) and_load_cell_barcodes()(obs/_index) now read through it.Tests
New
tests/test_h5_columns.py:nullable-string-arraygroup;PerturbationDataset.get_gene_names()and_load_cell_barcodes(). It is skipped on anndata < 0.11.Results:
test_barcode_functionalityfails onmainand passes here. The new tests and all oftest_dataloader.pypass (17 passed). Onmain, the same script raises theTypeErrorabove fromget_gene_names().ruff checkandruff format --checkare clean on the touched lines.Three
test_mapping_strategy.pytests (test_backwards_compatibility_uncached,test_cache_parameter_effectiveness,test_register_split_indices_groups_by_batch_and_cell_type) fail onmainfor an unrelated reason: the control pool is keyed by cell-type name, while the test expects codes. This PR leaves them as they are.@abhinadduri, could you take a look when you have a moment?