Skip to content

Commit b25f06b

Browse files
committed
updated streaming
1 parent 3356e36 commit b25f06b

2 files changed

Lines changed: 249 additions & 63 deletions

File tree

src/cell2sentence4longevity/explore.py

Lines changed: 163 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,88 @@
1818
infer_obs_schema,
1919
list_obs_columns_from_file,
2020
list_obs_columns_from_group,
21+
preload_complex_obs_fields,
2122
)
2223

2324
app = typer.Typer(help="Extract metadata fields from h5ad AnnData files")
2425

2526

27+
MAX_LOG_VALUE_LENGTH = 200
28+
MAX_SAMPLE_ROWS = 5
29+
MAX_NESTED_ITEMS = 8
30+
31+
32+
def _coerce_all_null_object_columns(
33+
df: pl.DataFrame,
34+
obs_schema: dict[str, pl.DataType]
35+
) -> pl.DataFrame:
36+
if not df.columns:
37+
return df
38+
replacements: list[pl.Expr] = []
39+
for column_name, dtype in df.schema.items():
40+
if dtype != pl.Object:
41+
continue
42+
if column_name not in obs_schema:
43+
continue
44+
series = df[column_name]
45+
if series.is_null().all():
46+
target_dtype = obs_schema[column_name]
47+
replacements.append(pl.lit(None, dtype=target_dtype).alias(column_name))
48+
if not replacements:
49+
return df
50+
return df.with_columns(replacements)
51+
52+
53+
def _truncate_for_log(value: str) -> str:
54+
if len(value) <= MAX_LOG_VALUE_LENGTH:
55+
return value
56+
return f"{value[:MAX_LOG_VALUE_LENGTH]}...[truncated]"
57+
58+
59+
def _safe_serialize_for_log(value: Any, *, max_items: int = MAX_NESTED_ITEMS) -> Any:
60+
if value is None:
61+
return None
62+
if isinstance(value, (bool, int, float)):
63+
return value
64+
if isinstance(value, str):
65+
return _truncate_for_log(value)
66+
if isinstance(value, (bytes, bytearray)):
67+
decoded = value.decode("utf-8", errors="replace")
68+
return _truncate_for_log(decoded)
69+
if isinstance(value, dict):
70+
limited_items = list(value.items())[:max_items]
71+
return {
72+
str(k): _safe_serialize_for_log(v, max_items=max_items)
73+
for k, v in limited_items
74+
}
75+
if isinstance(value, (list, tuple, set)):
76+
limited_values = list(value)[:max_items]
77+
return [_safe_serialize_for_log(v, max_items=max_items) for v in limited_values]
78+
if hasattr(value, "tolist"):
79+
try:
80+
data_list = value.tolist()
81+
return _safe_serialize_for_log(data_list, max_items=max_items)
82+
except Exception:
83+
pass
84+
if hasattr(value, "item"):
85+
try:
86+
return value.item()
87+
except Exception:
88+
pass
89+
representation = repr(value)
90+
return _truncate_for_log(representation)
91+
92+
93+
def _collect_sample_rows(df: pl.DataFrame, max_rows: int = MAX_SAMPLE_ROWS) -> list[dict[str, Any]]:
94+
if df.height == 0:
95+
return []
96+
sample_rows: list[dict[str, Any]] = []
97+
for row in df.head(max_rows).iter_rows(named=True):
98+
serialized_row = {column: _safe_serialize_for_log(value) for column, value in row.items()}
99+
sample_rows.append(serialized_row)
100+
return sample_rows
101+
102+
26103
def extract_fields_from_h5ad(
27104
h5ad_path: Path,
28105
fields: Optional[list[str]],
@@ -63,6 +140,15 @@ def extract_fields_from_h5ad(
63140
obs_schema = infer_obs_schema(obs_group)
64141
string_fields = {col for col, dtype in obs_schema.items() if dtype == pl.String}
65142
requested_fields = fields if fields is not None else obs_columns
143+
preloaded_fields = preload_complex_obs_fields(
144+
obs_group=obs_group,
145+
fields=[field for field in requested_fields if field in obs_columns]
146+
)
147+
if preloaded_fields:
148+
action.log(
149+
message_type="preloaded_complex_obs_fields",
150+
fields=list(preloaded_fields.keys())
151+
)
66152

67153
action.log(
68154
message_type="h5ad_loaded",
@@ -109,55 +195,85 @@ def extract_fields_from_h5ad(
109195
start_idx = chunk_idx * chunk_size
110196
end_idx = min(start_idx + chunk_size, n_cells)
111197

112-
chunk_df = build_obs_chunk_dataframe(
113-
obs_group=obs_group,
114-
fields=valid_fields,
115-
start_idx=start_idx,
116-
end_idx=end_idx,
117-
categorical_cache=categorical_cache,
118-
string_fields=string_fields,
119-
string_fill_value=None
120-
)
198+
chunk_df: pl.DataFrame | None = None
199+
chunk_columns: list[str] = []
121200

122-
cast_exprs = [
123-
pl.col(col).cast(dtype, strict=False)
124-
for col, dtype in obs_schema.items()
125-
if col in chunk_df.columns
126-
]
127-
if cast_exprs:
128-
chunk_df = chunk_df.with_columns(cast_exprs)
129-
fill_exprs = [
130-
pl.col(col).fill_null("nan")
131-
for col, dtype in obs_schema.items()
132-
if dtype == pl.String and col in chunk_df.columns
133-
]
134-
if fill_exprs:
135-
chunk_df = chunk_df.with_columns(fill_exprs)
136-
137-
if extract_age:
138-
chunk_df = extract_age_columns(
139-
chunk_df,
140-
development_stage_col=age_source_col
201+
try:
202+
chunk_df = build_obs_chunk_dataframe(
203+
obs_group=obs_group,
204+
fields=valid_fields,
205+
start_idx=start_idx,
206+
end_idx=end_idx,
207+
categorical_cache=categorical_cache,
208+
string_fields=string_fields,
209+
string_fill_value=None,
210+
preloaded_fields=preloaded_fields
141211
)
142-
143-
# Write chunk to temp file
144-
chunk_file = temp_dir / f"chunk_{chunk_idx:04d}.parquet"
145-
chunk_df.write_parquet(
146-
chunk_file,
147-
compression=compression,
148-
compression_level=compression_level,
149-
use_pyarrow=use_pyarrow
150-
)
151-
152-
# Clean up chunk data immediately
153-
del chunk_df
154-
155-
chunk_action.log(
156-
message_type="chunk_processed",
157-
rows=end_idx - start_idx,
158-
start_idx=start_idx,
159-
end_idx=end_idx
160-
)
212+
chunk_df = _coerce_all_null_object_columns(chunk_df, obs_schema)
213+
214+
chunk_columns = list(chunk_df.columns)
215+
216+
cast_exprs = [
217+
pl.col(col).cast(dtype, strict=False)
218+
for col, dtype in obs_schema.items()
219+
if col in chunk_columns
220+
]
221+
if cast_exprs:
222+
chunk_df = chunk_df.with_columns(cast_exprs)
223+
fill_exprs = [
224+
pl.col(col).fill_null("nan")
225+
for col, dtype in obs_schema.items()
226+
if dtype == pl.String and col in chunk_columns
227+
]
228+
if fill_exprs:
229+
chunk_df = chunk_df.with_columns(fill_exprs)
230+
231+
if extract_age:
232+
chunk_df = extract_age_columns(
233+
chunk_df,
234+
development_stage_col=age_source_col
235+
)
236+
237+
# Write chunk to temp file
238+
chunk_file = temp_dir / f"chunk_{chunk_idx:04d}.parquet"
239+
chunk_df.write_parquet(
240+
chunk_file,
241+
compression=compression,
242+
compression_level=compression_level,
243+
use_pyarrow=use_pyarrow
244+
)
245+
246+
chunk_action.log(
247+
message_type="chunk_processed",
248+
rows=end_idx - start_idx,
249+
start_idx=start_idx,
250+
end_idx=end_idx
251+
)
252+
253+
chunk_df = None
254+
except Exception as exc:
255+
sample_rows: list[dict[str, Any]] = []
256+
if chunk_df is not None:
257+
try:
258+
sample_rows = _collect_sample_rows(chunk_df)
259+
except Exception as sample_exc:
260+
sample_rows = [
261+
{
262+
"_error": f"Failed to serialize sample rows: {sample_exc}"
263+
}
264+
]
265+
chunk_action.log(
266+
message_type="chunk_failure_sample",
267+
error=str(exc),
268+
start_idx=start_idx,
269+
end_idx=end_idx,
270+
columns=chunk_columns,
271+
sample_rows=sample_rows
272+
)
273+
raise
274+
finally:
275+
if chunk_df is not None:
276+
del chunk_df
161277

162278
# Read all chunks lazily without materializing full dataset
163279
action.log(message_type="building_lazy_dataframe", n_chunks=n_chunks)

src/cell2sentence4longevity/preprocessing/obs_stream.py

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
import math
66
from pathlib import Path
7-
from typing import Any, Dict, List, Set
7+
from typing import Any, Dict, List, Sequence, Set
88

99
import h5py
1010
import numpy as np
1111
import polars as pl
1212

13+
from anndata._io.specs import read_elem
14+
1315

1416
def _decode_scalar(value: Any) -> Any:
1517
if isinstance(value, (bytes, np.bytes_)):
@@ -106,6 +108,28 @@ def _fill_string_array(arr: np.ndarray, fill_value: str) -> np.ndarray:
106108
return arr
107109

108110

111+
def _normalize_preloaded_column(values: Any) -> np.ndarray | list[Any]:
112+
if isinstance(values, np.ndarray):
113+
return values
114+
if hasattr(values, "to_numpy"):
115+
return values.to_numpy()
116+
if hasattr(values, "to_list"):
117+
return values.to_list()
118+
if hasattr(values, "tolist"):
119+
return values.tolist()
120+
return np.asarray(values, dtype=object)
121+
122+
123+
def _slice_preloaded_column(
124+
data: np.ndarray | Sequence[Any],
125+
start_idx: int,
126+
end_idx: int
127+
) -> Any:
128+
if isinstance(data, np.ndarray):
129+
return data[start_idx:end_idx]
130+
return data[start_idx:end_idx]
131+
132+
109133
def read_obs_chunk_dict(
110134
obs_group: h5py.Group,
111135
fields: list[str],
@@ -115,27 +139,40 @@ def read_obs_chunk_dict(
115139
as_lists: bool = False,
116140
string_fields: Set[str] | None = None,
117141
string_fill_value: str | None = None,
142+
preloaded_fields: Dict[str, np.ndarray | Sequence[Any]] | None = None,
118143
) -> dict[str, Any]:
119144
if categorical_cache is None:
120145
categorical_cache = {}
146+
if preloaded_fields is None:
147+
preloaded_fields = {}
121148
chunk_data: dict[str, Any] = {}
122149
for field in fields:
123-
node = obs_group.get(field)
124-
if node is None:
125-
msg = f"Field '{field}' not found in obs group"
126-
raise KeyError(msg)
127-
values = _read_obs_field_slice(
128-
node,
129-
field,
130-
start_idx,
131-
end_idx,
132-
categorical_cache
133-
)
150+
if field in preloaded_fields:
151+
values = _slice_preloaded_column(
152+
preloaded_fields[field],
153+
start_idx,
154+
end_idx
155+
)
156+
else:
157+
node = obs_group.get(field)
158+
if node is None:
159+
msg = f"Field '{field}' not found in obs group"
160+
raise KeyError(msg)
161+
values = _read_obs_field_slice(
162+
node,
163+
field,
164+
start_idx,
165+
end_idx,
166+
categorical_cache
167+
)
134168
is_string_field = string_fields is not None and field in string_fields
135169
if not as_lists or is_string_field:
136170
values = _ensure_numpy_array(values)
137-
if is_string_field and string_fill_value is not None:
138-
values = _fill_string_array(values, string_fill_value)
171+
if is_string_field:
172+
if string_fill_value is not None:
173+
values = _fill_string_array(values, string_fill_value)
174+
values_list = values.tolist()
175+
values = pl.Series(field, values_list, dtype=pl.String)
139176
if as_lists:
140177
if isinstance(values, np.ndarray):
141178
values = values.tolist()
@@ -152,7 +189,8 @@ def build_obs_chunk_dataframe(
152189
end_idx: int,
153190
categorical_cache: Dict[str, np.ndarray] | None = None,
154191
string_fields: Set[str] | None = None,
155-
string_fill_value: str | None = None
192+
string_fill_value: str | None = None,
193+
preloaded_fields: Dict[str, np.ndarray | Sequence[Any]] | None = None,
156194
) -> pl.DataFrame:
157195
chunk_dict = read_obs_chunk_dict(
158196
obs_group=obs_group,
@@ -162,11 +200,34 @@ def build_obs_chunk_dataframe(
162200
categorical_cache=categorical_cache,
163201
as_lists=False,
164202
string_fields=string_fields,
165-
string_fill_value=string_fill_value
203+
string_fill_value=string_fill_value,
204+
preloaded_fields=preloaded_fields
166205
)
167206
return pl.DataFrame(chunk_dict)
168207

169208

209+
def preload_complex_obs_fields(
210+
obs_group: h5py.Group,
211+
fields: list[str]
212+
) -> dict[str, np.ndarray | list[Any]]:
213+
preloaded: dict[str, np.ndarray | list[Any]] = {}
214+
for field in fields:
215+
node = obs_group.get(field)
216+
if node is None:
217+
continue
218+
encoding_type = node.attrs.get("encoding-type", "array")
219+
is_dataset = isinstance(node, h5py.Dataset)
220+
if is_dataset or encoding_type == "categorical":
221+
continue
222+
try:
223+
values = read_elem(node)
224+
except Exception as exc:
225+
msg = f"Failed to preload obs field '{field}': {exc}"
226+
raise RuntimeError(msg) from exc
227+
preloaded[field] = _normalize_preloaded_column(values)
228+
return preloaded
229+
230+
170231
def list_obs_columns_from_group(obs_group: h5py.Group) -> list[str]:
171232
return [name for name in obs_group.keys() if name != "index"]
172233

@@ -187,6 +248,15 @@ def infer_obs_schema(obs_group: h5py.Group) -> dict[str, pl.datatypes.DataType]:
187248
if encoding_type in {"categorical", "string-array"}:
188249
schema[name] = pl.String
189250
continue
251+
if encoding_type == "nullable-boolean":
252+
schema[name] = pl.Boolean
253+
continue
254+
if encoding_type == "nullable-integer":
255+
schema[name] = pl.Int64
256+
continue
257+
if encoding_type == "nullable-string-array":
258+
schema[name] = pl.String
259+
continue
190260
if not isinstance(node, h5py.Dataset):
191261
continue
192262
kind = node.dtype.kind

0 commit comments

Comments
 (0)