Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
cf8bcfd
added backend mode to file reading (1st function)
Jul 18, 2025
febd1a9
bacekdn mode for the 2nd function
Jul 18, 2025
c28abc5
backend mode for the third function
Jul 18, 2025
0957c4c
backend mode for 4th function
Jul 18, 2025
ea601cb
make format, make typecheck
sjfleming Jul 28, 2025
a91efd9
change default to backed='r'
sjfleming Jul 28, 2025
c208231
fix for URL and GCS readers in backed mode
sjfleming Jul 28, 2025
905ea23
testing anndata is in memory for comparison
sjfleming Jul 30, 2025
ca09d14
allow backed mode CellariumAnnDataDataModule load to pass
sjfleming Jul 30, 2025
de24204
mypy
sjfleming Jul 30, 2025
d2ee9f8
typing for backed mode
sjfleming Jul 30, 2025
a634b92
parameterize backed mode in LazyAnnData and DistributedAnnDataCollection
sjfleming Jul 30, 2025
83c514f
use backed_mode_type
sjfleming Jul 30, 2025
1b81428
docstring update to reflect lack of "r+" mode
sjfleming Jul 30, 2025
983178a
test memory pressure in backed mode with massive faked h5ad
sjfleming Jul 31, 2025
39bc582
format
sjfleming Jul 31, 2025
b7c4014
Merge branch 'main' into sf-dataloader-backed
sjfleming Apr 4, 2026
a370420
remove tests with repeat indices
sjfleming Apr 7, 2026
4b6a9e5
revert that change
sjfleming Apr 7, 2026
3e5c22f
upgrade anndata which requires python 3.12
sjfleming Apr 7, 2026
60584d8
update jsonargparse and fix its usage
sjfleming Apr 7, 2026
4dc3bf5
allow newer lightning and fix weights_only=True default loader issue
sjfleming Apr 7, 2026
9d188f5
linting
sjfleming Apr 7, 2026
21f06ba
override torch 2.4 default weights_only=True with False
sjfleming Apr 8, 2026
8476e54
fix meta device use for compute_var_names_g
sjfleming Apr 8, 2026
c875513
set weights_only=False in test
sjfleming Apr 8, 2026
b661b5f
linting
sjfleming Apr 8, 2026
ab9af9d
better fix for CLI compute_var_names_g
sjfleming Apr 8, 2026
1b5f363
fix compute_var_names_g again for meta cpu conflicts
sjfleming Apr 8, 2026
5915737
future-proof anndata 0.13.x
sjfleming Jun 19, 2026
3a24697
future-proof anndata 0.13.x
sjfleming Jun 19, 2026
3ff5594
linting
sjfleming Jun 19, 2026
5eb734c
mypy fix
sjfleming Jun 19, 2026
62c86d0
Merge branch 'main' into sf-dataloader-backed
sjfleming Jul 1, 2026
7d10999
pin scverse-misc due to anndata
sjfleming Jul 1, 2026
901bfee
pin scverse-misc
sjfleming Jul 1, 2026
3a9a3cb
fix h5py pickling issue
sjfleming Jul 1, 2026
70dc615
fix scvi test backed mode issue
sjfleming Jul 2, 2026
33bd58f
backed default False
sjfleming Jul 2, 2026
1a1acc1
fix bug with gcs read and test it
sjfleming Jul 2, 2026
6583fef
try default google credentials
sjfleming Jul 2, 2026
848bf96
bypass downloads to preserve disk in non-backed mode
sjfleming Jul 3, 2026
8b312fb
fix test
sjfleming Jul 3, 2026
09ec3e4
cache streamed gcs files for future local reading
sjfleming Jul 3, 2026
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
18 changes: 9 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.12"]

steps:
- uses: actions/checkout@v2
Expand All @@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.12"]

steps:
- uses: actions/checkout@v2
Expand All @@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.12"]

steps:
- uses: actions/checkout@v2
Expand All @@ -77,8 +77,8 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
devices: ["1", "2"]
python-version: ["3.12"]
devices: ["1", "2", "3"]

env:
TEST_DEVICES: ${{ matrix.devices }}
Expand All @@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.12"]

steps:
- uses: actions/checkout@v2
Expand All @@ -122,7 +122,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.12"]
devices: ["1", "2", "3"]

env:
Expand All @@ -146,7 +146,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.12"]
devices: ["1", "2", "3"]

env:
Expand All @@ -170,7 +170,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.12"]

steps:
- uses: actions/checkout@v2
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/testpypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
43 changes: 40 additions & 3 deletions cellarium/ml/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import torch
import yaml
from jsonargparse import Namespace, class_from_function
from jsonargparse._loaders_dumpers import DefaultLoader
from jsonargparse._loaders_dumpers import get_yaml_default_loader
from jsonargparse._util import import_object
from lightning.pytorch.cli import ArgsType, LightningArgumentParser, LightningCLI
from torch.utils._pytree import tree_map
Expand Down Expand Up @@ -184,7 +184,12 @@ class CheckpointLoader(FileLoader):
convert_fn: Callable[[Any], Any] | str | None = None

def __new__(cls, file_path, attr=None, key=None, convert_fn=None):
return super().__new__(cls, file_path, CellariumModule.load_from_checkpoint, attr, key, convert_fn)
return super().__new__(cls, file_path, _load_cellarium_module, attr, key, convert_fn)


def _load_cellarium_module(path: str) -> CellariumModule:
"""Wraps load_from_checkpoint, injecting weights_only=False, due to pytorch change in 2.4.0."""
return CellariumModule.load_from_checkpoint(path, weights_only=False)


def file_loader_constructor(loader: yaml.SafeLoader, node: yaml.nodes.MappingNode) -> FileLoader:
Expand All @@ -202,7 +207,7 @@ def checkpoint_loader_constructor(loader: yaml.SafeLoader, node: yaml.nodes.Mapp
return CheckpointLoader(**loader.construct_mapping(node)) # type: ignore[arg-type]


loader = DefaultLoader
loader = get_yaml_default_loader()
loader.add_constructor("!FileLoader", file_loader_constructor)
loader.add_constructor("!FileMultiLoader", file_multi_loader_constructor)
loader.add_constructor("!CheckpointLoader", checkpoint_loader_constructor)
Expand Down Expand Up @@ -681,6 +686,38 @@ def _add_instantiators(self) -> None:
# https://github.com/Lightning-AI/pytorch-lightning/pull/18105
pass

def _prepare_subcommand_parser(self, klass, subcommand, **kwargs):
"""Override the default checkpoint loading with weights_only=True in torch 2.4.0+"""
parser = super()._prepare_subcommand_parser(klass, subcommand, **kwargs)
parser.set_defaults({"weights_only": False})
return parser

def _parse_ckpt_path(self) -> None:
from pathlib import Path

if not self.config.get("subcommand"):
return
ckpt_path = self.config[self.config.subcommand].get("ckpt_path")
if ckpt_path and Path(ckpt_path).is_file():
ckpt = torch.load(ckpt_path, weights_only=False, map_location="cpu")
hparams = ckpt.get("hyper_parameters", {})
hparams.pop("_instantiator", None)
if not hparams:
return
if "_class_path" in hparams:
hparams = {
"class_path": hparams.pop("_class_path"),
"dict_kwargs": hparams,
}
hparams = {self.config.subcommand: {"model": hparams}}
try:
self.config = self.parser.parse_object(hparams, self.config)
except SystemExit:
import sys

sys.stderr.write("Parsing of ckpt_path hyperparameters failed!\n")
raise

def before_instantiate_classes(self):
# issue a UserWarning if the subcommand is predict and return_predictions is not set to False
if self.subcommand == "predict":
Expand Down
4 changes: 1 addition & 3 deletions cellarium/ml/core/datamodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,7 @@ def __init__(
pin_memory: bool = False,
) -> None:
super().__init__()
self.save_hyperparameters(logger=False)
# Don't save dadc to the checkpoint
self.hparams["dadc"] = None
self.save_hyperparameters(logger=False, ignore=["dadc"])

self.dadc = dadc
# IterableDistributedAnnDataCollectionDataset args
Expand Down
20 changes: 20 additions & 0 deletions cellarium/ml/core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ def __init__(
# Thus, we need to use manual optimization for the No Optimizer case.
self.automatic_optimization = False

@classmethod
def load_from_checkpoint(
cls,
checkpoint_path,
map_location=None,
hparams_file=None,
strict=None,
weights_only=False,
**kwargs,
):
"""Replace the torch 2.4.0+ default weights_only=True with False"""
return super().load_from_checkpoint(
checkpoint_path,
map_location=map_location,
hparams_file=hparams_file,
strict=strict,
weights_only=weights_only,
**kwargs,
)

def configure_model(self) -> None:
"""
.. note::
Expand Down
67 changes: 56 additions & 11 deletions cellarium/ml/data/distributed_anndata.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
import pandas as pd
from anndata import AnnData, concat
from anndata._core.index import _normalize_indices
from anndata.compat import Index, Index1D

try:
from anndata.compat import Index, Index1D
except ImportError:
from anndata.typing import Index, Index1D

dtype_deprecated = True
from anndata.experimental.multi_files._anncollection import (
AnnCollection,
AnnCollectionView,
Expand All @@ -18,7 +24,7 @@
from boltons.cacheutils import LRU
from braceexpand import braceexpand

from cellarium.ml.data.fileio import read_h5ad_file
from cellarium.ml.data.fileio import GCS_CACHE_DIR, backed_mode_default, backed_mode_type, read_h5ad_file
from cellarium.ml.data.schema import AnnDataSchema


Expand All @@ -28,6 +34,8 @@ class getattr_mode:

_GETATTR_MODE = getattr_mode()

allowed_backed_modes = [None, True, False, "r"]


@contextmanager
def lazy_getattr():
Expand Down Expand Up @@ -155,6 +163,19 @@ class DistributedAnnDataCollection(AnnCollection):
obs_columns_to_validate:
Subset of columns to validate in the :attr:`obs` attribute.
If ``None``, all columns are validated.
backed:
Optional backing mode for the h5ad files. ``'r'`` will leave count matrices
on disk until specific cell indices are queried, enabling the use of very large
h5ad files, while ``None`` will load entire count matrices from individual h5ad files
into cached memory as needed: a strategy that necessitates smaller chunked h5ad files.
See :func:`anndata.read_h5ad` for details on backing modes.
cache_dir:
Directory for caching GCS-downloaded h5ad files on local disk. On first access each
shard is downloaded and written here; subsequent accesses (including after a checkpoint
restart on the same machine) read from disk instead of re-downloading. If a write fails
(e.g. disk full) the shard is streamed from GCS instead. Set to ``None`` to always
stream GCS files directly into memory with no local disk usage. Only affects
``gs://`` paths; local and HTTP paths are unaffected.
"""

def __init__(
Expand All @@ -171,6 +192,8 @@ def __init__(
convert: ConvertType | None = None,
indices_strict: bool = True,
obs_columns_to_validate: Sequence[str] | None = None,
backed: backed_mode_type = backed_mode_default,
cache_dir: str | None = str(GCS_CACHE_DIR),
):
self.filenames = list(braceexpand(filenames) if isinstance(filenames, str) else filenames)
if (shard_size is None) and (last_shard_size is not None):
Expand All @@ -192,8 +215,14 @@ def __init__(
self.cache = LRU(max_cache_size)
self.max_cache_size = max_cache_size
self.cache_size_strictly_enforced = cache_size_strictly_enforced
if backed not in allowed_backed_modes:
raise ValueError(f"Invalid backed mode: {backed}. Choose from {allowed_backed_modes}")
self.backed = backed
self.cache_dir = cache_dir
# schema
adata0 = self.cache[self.filenames[0]] = read_h5ad_file(self.filenames[0])
adata0 = self.cache[self.filenames[0]] = read_h5ad_file(
self.filenames[0], backed=backed, cache_dir=cache_dir
)
if len(adata0) != limits[0]:
raise ValueError(
f"The number of cells in the first anndata file ({len(adata0)}) "
Expand All @@ -203,7 +232,7 @@ def __init__(
self.schema = AnnDataSchema(adata0, obs_columns_to_validate)
# lazy anndatas
lazy_adatas = [
LazyAnnData(filename, (start, end), self.schema, self.cache)
LazyAnnData(filename, (start, end), self.schema, self.cache, backed=backed, cache_dir=cache_dir)
for start, end, filename in zip([0] + limits, limits, self.filenames)
]
# use filenames as default keys
Expand Down Expand Up @@ -298,10 +327,14 @@ def __getstate__(self):
def __setstate__(self, state):
self.__dict__.update(state)
self.cache = LRU(self.max_cache_size)
adata0 = self.cache[self.filenames[0]] = read_h5ad_file(self.filenames[0])
adata0 = self.cache[self.filenames[0]] = read_h5ad_file(
self.filenames[0], backed=self.backed, cache_dir=self.cache_dir
)
self.schema = AnnDataSchema(adata0, self.obs_columns_to_validate)
self.adatas = [
LazyAnnData(filename, (start, end), self.schema, self.cache)
LazyAnnData(
filename, (start, end), self.schema, self.cache, backed=self.backed, cache_dir=self.cache_dir
)
for start, end, filename in zip([0] + self.limits, self.limits, self.filenames)
]
self.obs_names = pd.Index([f"cell_{i}" for i in range(self.limits[-1])])
Expand All @@ -323,6 +356,11 @@ class LazyAnnData:
Schema used as a reference for lazy attributes.
cache:
Shared LRU cache storing buffered anndatas.
backed:
Optional backing mode for the anndata. ``'r'`` will leave count matrix
on disk, while ``None`` will load count matrix in memory (when the anndata is
cached by calling the `.adata` property).
See :func:`anndata.read_h5ad` for details on backing modes.
"""

_lazy_attrs = ["obs", "obsm", "layers", "var", "varm", "varp", "var_names"]
Expand All @@ -343,10 +381,16 @@ def __init__(
limits: tuple[int, int],
schema: AnnDataSchema,
cache: LRU | None = None,
backed: backed_mode_type = backed_mode_default,
cache_dir: str | None = str(GCS_CACHE_DIR),
):
self.filename = filename
self.limits = limits
self.schema = schema
if backed not in allowed_backed_modes:
raise ValueError(f"Invalid backed mode: {backed}. Choose from {allowed_backed_modes}")
self.backed = backed
self.cache_dir = cache_dir
if cache is None:
cache = LRU()
self.cache = cache
Expand Down Expand Up @@ -382,16 +426,16 @@ def cached(self) -> bool:

@property
def adata(self) -> AnnData:
"""Return backed anndata from the filename"""
"""Return anndata from the filename"""
try:
adata = self.cache[self.filename]
except KeyError:
# fetch anndata
adata = read_h5ad_file(self.filename)
adata = read_h5ad_file(self.filename, backed=self.backed, cache_dir=self.cache_dir)
# validate anndata
if self.n_obs != adata.n_obs:
raise ValueError(
"Expected `n_obs` for LazyAnnData object and backed anndata to match "
"Expected `n_obs` for LazyAnnData object and loaded anndata to match "
f"but found {self.n_obs} and {adata.n_obs}, respectively."
)
self.schema.validate_anndata(adata)
Expand Down Expand Up @@ -426,8 +470,9 @@ def __repr__(self) -> str:
buffered = "Cached "
else:
buffered = ""
backed_at = f" backed at {str(self.filename)!r}"
descr = f"{buffered}LazyAnnData object with n_obs × n_vars = {self.n_obs} × {self.n_vars}{backed_at}"
located_at = f" referencing {str(self.filename)!r}"
backed = " in backed mode" if (self.backed in [True, "r"]) else " in memory mode"
descr = f"{buffered}LazyAnnData object with n_obs × n_vars = {self.n_obs} × {self.n_vars}{located_at}{backed}"
if self.cached:
for attr in self._all_attrs:
keys = getattr(self, attr).keys()
Expand Down
Loading
Loading