Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 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.11"]

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.11"]

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.11"]

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

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

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

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.11"
- 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.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.. image:: https://cellarium.ai/wp-content/uploads/2024/07/cellarium-logo-medium.png
:alt: Cellarium Logo
:alt: Cellarium Logo!!
:width: 180
:align: center

Expand Down
42 changes: 32 additions & 10 deletions cellarium/ml/data/distributed_anndata.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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 backed_mode_default, backed_mode_type, read_h5ad_file
from cellarium.ml.data.schema import AnnDataSchema


Expand All @@ -28,6 +28,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 +157,12 @@ 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.
"""

def __init__(
Expand All @@ -171,6 +179,7 @@ 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,
):
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 +201,11 @@ 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
# 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)
if len(adata0) != limits[0]:
raise ValueError(
f"The number of cells in the first anndata file ({len(adata0)}) "
Expand All @@ -203,7 +215,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)
for start, end, filename in zip([0] + limits, limits, self.filenames)
]
# use filenames as default keys
Expand Down Expand Up @@ -298,10 +310,10 @@ 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)
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)
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 +335,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 +360,14 @@ def __init__(
limits: tuple[int, int],
schema: AnnDataSchema,
cache: LRU | None = None,
backed: backed_mode_type = backed_mode_default,
):
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
if cache is None:
cache = LRU()
self.cache = cache
Expand Down Expand Up @@ -382,16 +403,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)
# 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 +447,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
64 changes: 53 additions & 11 deletions cellarium/ml/data/fileio.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
# Copyright Contributors to the Cellarium project.
# SPDX-License-Identifier: BSD-3-Clause

import os
import re
import shutil
import tempfile
import urllib.request
from typing import Literal

from anndata import AnnData, read_h5ad
from google.cloud.storage import Client

url_schemes = ("http:", "https:", "ftp:")
backed_mode_type = Literal["r"] | bool | None
backed_mode_default: backed_mode_type = "r"


def read_h5ad_gcs(filename: str, storage_client: Client | None = None) -> AnnData:
def read_h5ad_gcs(
filename: str,
storage_client: Client | None = None,
backed: backed_mode_type = backed_mode_default,
) -> AnnData:
r"""
Read ``.h5ad``-formatted hdf5 file from the Google Cloud Storage.

Expand All @@ -22,6 +30,9 @@ def read_h5ad_gcs(filename: str, storage_client: Client | None = None) -> AnnDat

Args:
filename: Path to the data file in Cloud Storage.
backed: See :func:`anndata.read_h5ad` for details on backed mode.
['r', True] will load in backed mode instead of fully loading into memory.
[False, None] will use in-memory mode.
"""
if not filename.startswith("gs:"):
raise ValueError("The filename must start with 'gs:' protocol name.")
Expand All @@ -35,11 +46,20 @@ def read_h5ad_gcs(filename: str, storage_client: Client | None = None) -> AnnDat
bucket = storage_client.bucket(bucket_name)
blob = bucket.blob(blob_name)

with blob.open("rb") as f:
return read_h5ad(f)
# write to a named temporary file
with tempfile.NamedTemporaryFile(suffix=".h5ad", delete=False) as tmp_file:
temp_path = tmp_file.name
blob.download_to_file(tmp_file)
try:
return read_h5ad(temp_path, backed=backed)
finally:
try:
os.unlink(temp_path) # clean up the temp file
except OSError:
pass # if there's an error during cleanup, continue


def read_h5ad_url(filename: str) -> AnnData:
def read_h5ad_url(filename: str, backed: backed_mode_type = backed_mode_default) -> AnnData:
r"""
Read ``.h5ad``-formatted hdf5 file from the URL.

Expand All @@ -48,37 +68,59 @@ def read_h5ad_url(filename: str) -> AnnData:
>>> adata = read_h5ad_url(
... "https://storage.googleapis.com/dsp-cellarium-cas-public/test-data/test_0.h5ad"
... )
>>> adata = read_h5ad_url(
... "https://storage.googleapis.com/dsp-cellarium-cas-public/test-data/test_0.h5ad",
... backed='r'
... )

Args:
filename: URL of the data file.
backed: See :func:`anndata.read_h5ad` for details on backed mode.
['r', True] will load in backed mode instead of fully loading into memory.
[False, None] will use in-memory mode.
"""
if not any(filename.startswith(scheme) for scheme in url_schemes):
raise ValueError("The filename must start with 'http:', 'https:', or 'ftp:' protocol name.")
with urllib.request.urlopen(filename) as response:
with tempfile.TemporaryFile() as tmp_file:

# write to a named temporary file
with tempfile.NamedTemporaryFile(suffix=".h5ad", delete=False) as tmp_file:
temp_path = tmp_file.name
with urllib.request.urlopen(filename) as response:
shutil.copyfileobj(response, tmp_file)
return read_h5ad(tmp_file)
try:
return read_h5ad(temp_path, backed=backed)
finally:
try:
os.unlink(temp_path) # clean up the temp file
except OSError:
pass # if there's an error during cleanup, continue


def read_h5ad_local(filename: str) -> AnnData:
def read_h5ad_local(filename: str, backed: backed_mode_type = backed_mode_default) -> AnnData:
r"""
Read ``.h5ad``-formatted hdf5 file from the local disk.

Args:
filename: Path to the local data file.
backed: See :func:`anndata.read_h5ad` for details on backed mode.
['r', True] will load in backed mode instead of fully loading into memory.
[False, None] will use in-memory mode.
"""
if not filename.startswith("file:"):
raise ValueError("The filename must start with 'file:' protocol name.")
filename = re.sub(r"^file://?", "", filename)
return read_h5ad(filename)
return read_h5ad(filename, backed=backed)


def read_h5ad_file(filename: str, **kwargs) -> AnnData:
def read_h5ad_file(filename: str, backed: backed_mode_type = backed_mode_default, **kwargs) -> AnnData:
r"""
Read ``.h5ad``-formatted hdf5 file from a filename.

Args:
filename: Path to the data file.
backed: See :func:`anndata.read_h5ad` for details on backed mode.
['r', True] will load in backed mode instead of fully loading into memory.
[False, None] will use in-memory mode.
"""
if filename.startswith("gs:"):
return read_h5ad_gcs(filename, **kwargs)
Expand All @@ -89,4 +131,4 @@ def read_h5ad_file(filename: str, **kwargs) -> AnnData:
if any(filename.startswith(scheme) for scheme in url_schemes):
return read_h5ad_url(filename)

return read_h5ad(filename)
return read_h5ad(filename, backed=backed)
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ authors = [
]
description = "Machine learning library for single-cell data analysis"
readme = "README.rst"
requires-python = ">=3.10"
requires-python = ">=3.11"
license = {file = "LICENSE.md"}
classifiers = [
"Intended Audience :: Developers",
Expand All @@ -21,7 +21,7 @@ classifiers = [
]

dependencies = [
"anndata",
"anndata @ git+https://github.com/sjfleming/anndata.git@sf-backed-hdf5-fancy-indexing",
"boltons",
"braceexpand",
"crick>=0.0.4",
Expand Down
Loading