Skip to content

Commit 1e9f0fa

Browse files
committed
improved memory footprint of streamings
1 parent b6245b9 commit 1e9f0fa

2 files changed

Lines changed: 100 additions & 4 deletions

File tree

src/cell2sentence4longevity/preprocessing/obs_stream.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,39 @@ def _read_dataset_slice(
7171
return data
7272

7373

74+
git def _decode_string_array(values: np.ndarray) -> np.ndarray:
75+
if values.dtype.kind in {"S", "O"}:
76+
decoded = np.empty(len(values), dtype=object)
77+
for idx, value in enumerate(values):
78+
decoded[idx] = _decode_scalar(value)
79+
return decoded
80+
if values.dtype.kind == "U":
81+
return values.astype(object)
82+
return values
83+
84+
85+
def _read_nullable_group_slice(
86+
field_node: h5py.Group,
87+
field_name: str,
88+
start_idx: int,
89+
end_idx: int
90+
) -> np.ndarray:
91+
if "values" not in field_node or "mask" not in field_node:
92+
msg = f"Nullable obs field '{field_name}' is missing required datasets"
93+
raise ValueError(msg)
94+
values = field_node["values"][start_idx:end_idx]
95+
mask = field_node["mask"][start_idx:end_idx]
96+
values_array = _ensure_numpy_array(values)
97+
if values_array.dtype.kind in {"S", "O", "U"}:
98+
values_array = _decode_string_array(values_array)
99+
mask_array = np.asarray(mask, dtype=bool)
100+
if mask_array.size == 0 or not mask_array.any():
101+
return values_array
102+
result = values_array.astype(object, copy=True)
103+
result[mask_array] = None
104+
return result
105+
106+
74107
def _read_obs_field_slice(
75108
field_node: h5py.Dataset | h5py.Group,
76109
field_name: str,
@@ -87,6 +120,11 @@ def _read_obs_field_slice(
87120
categorical_cache[field_name] = _load_categorical_values(field_node)
88121
categories = categorical_cache[field_name]
89122
return _decode_categorical_codes(field_node, categories, start_idx, end_idx)
123+
if encoding_type in {"nullable-boolean", "nullable-integer", "nullable-string-array"}:
124+
if not isinstance(field_node, h5py.Group):
125+
msg = f"Unexpected nullable node type for field '{field_name}'"
126+
raise ValueError(msg)
127+
return _read_nullable_group_slice(field_node, field_name, start_idx, end_idx)
90128
if not isinstance(field_node, h5py.Dataset):
91129
msg = f"Unsupported obs field storage for '{field_name}'"
92130
raise ValueError(msg)
@@ -244,6 +282,8 @@ def preload_complex_obs_fields(
244282
is_dataset = isinstance(node, h5py.Dataset)
245283
if is_dataset or encoding_type == "categorical":
246284
continue
285+
if encoding_type in {"nullable-boolean", "nullable-integer", "nullable-string-array"}:
286+
continue
247287
try:
248288
values = read_elem(node)
249289
except Exception as exc:
@@ -270,7 +310,7 @@ def infer_obs_schema(obs_group: h5py.Group) -> dict[str, pl.datatypes.DataType]:
270310
if name == "index":
271311
continue
272312
encoding_type = node.attrs.get("encoding-type", "array")
273-
if encoding_type in {"categorical", "string-array"}:
313+
if encoding_type in {"categorical", "string-array", "nullable-string-array"}:
274314
schema[name] = pl.String
275315
continue
276316
if encoding_type == "nullable-boolean":
@@ -279,9 +319,6 @@ def infer_obs_schema(obs_group: h5py.Group) -> dict[str, pl.datatypes.DataType]:
279319
if encoding_type == "nullable-integer":
280320
schema[name] = pl.Int64
281321
continue
282-
if encoding_type == "nullable-string-array":
283-
schema[name] = pl.String
284-
continue
285322
if not isinstance(node, h5py.Dataset):
286323
continue
287324
kind = node.dtype.kind

tests/test_obs_stream_nullable.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import anndata as ad
6+
import h5py
7+
import numpy as np
8+
import pandas as pd
9+
import polars as pl
10+
import pytest
11+
12+
from cell2sentence4longevity.preprocessing.obs_stream import (
13+
build_obs_chunk_dataframe,
14+
infer_obs_schema,
15+
list_obs_columns_from_group,
16+
preload_complex_obs_fields,
17+
)
18+
19+
20+
def test_nullable_obs_fields_stream_without_preload(tmp_path: Path) -> None:
21+
"""Ensure nullable obs columns stream chunk-by-chunk without eager preloading."""
22+
original_setting = ad.settings.allow_write_nullable_strings
23+
ad.settings.allow_write_nullable_strings = True
24+
try:
25+
n_cells = 6
26+
obs = pd.DataFrame(
27+
{
28+
"nullable_int": pd.Series([1, None, 3, None, 5, 6], dtype="Int64"),
29+
"nullable_bool": pd.Series([True, None, False, True, None, False], dtype="boolean"),
30+
}
31+
)
32+
var = pd.DataFrame(index=[f"gene_{idx}" for idx in range(3)])
33+
adata = ad.AnnData(X=np.zeros((n_cells, var.shape[0])), obs=obs, var=var)
34+
h5ad_path = tmp_path / "nullable.h5ad"
35+
adata.write_h5ad(h5ad_path)
36+
finally:
37+
ad.settings.allow_write_nullable_strings = original_setting
38+
39+
with h5py.File(h5ad_path, "r") as handle:
40+
obs_group = handle["obs"]
41+
fields = list_obs_columns_from_group(obs_group)
42+
schema = infer_obs_schema(obs_group)
43+
string_fields = {col for col, dtype in schema.items() if dtype == pl.String}
44+
preloaded = preload_complex_obs_fields(obs_group, fields, total_rows=n_cells)
45+
assert preloaded == {}
46+
47+
chunk_df = build_obs_chunk_dataframe(
48+
obs_group=obs_group,
49+
fields=fields,
50+
start_idx=0,
51+
end_idx=n_cells,
52+
categorical_cache={},
53+
string_fields=string_fields,
54+
string_fill_value=None,
55+
preloaded_fields=preloaded,
56+
)
57+
assert chunk_df["nullable_int"].to_list() == [1, None, 3, None, 5, 6]
58+
assert chunk_df["nullable_bool"].to_list() == [True, None, False, True, None, False]
59+

0 commit comments

Comments
 (0)