From 842b68f98e6a3644d64770c254833abbf829395a Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 15 Oct 2024 11:11:32 +0200 Subject: [PATCH 01/51] Remove 3.9 support (#3283) --- .azure-pipelines.yml | 6 +++--- benchmarks/asv.conf.json | 2 +- docs/release-notes/3283.breaking.md | 1 + hatch.toml | 2 +- pyproject.toml | 7 +++---- src/scanpy/_settings.py | 12 ++++++------ src/scanpy/_utils/__init__.py | 20 ++++++++++---------- src/scanpy/external/pl.py | 4 ++-- src/scanpy/external/pp/_bbknn.py | 2 +- src/scanpy/external/pp/_magic.py | 3 ++- src/scanpy/external/tl/_pypairs.py | 3 +-- src/scanpy/get/_aggregated.py | 3 +-- src/scanpy/neighbors/__init__.py | 2 +- src/scanpy/neighbors/_types.py | 4 ++-- src/scanpy/plotting/_anndata.py | 9 +++++---- src/scanpy/plotting/_baseplot_class.py | 4 ++-- src/scanpy/plotting/_scrublet.py | 4 ++-- src/scanpy/plotting/_tools/paga.py | 6 +++--- src/scanpy/plotting/_tools/scatterplots.py | 6 +----- src/scanpy/plotting/_utils.py | 12 ++++++------ src/scanpy/preprocessing/_normalization.py | 2 +- src/scanpy/preprocessing/_scrublet/core.py | 11 ++--------- src/scanpy/tools/_rank_genes_groups.py | 2 +- src/testing/scanpy/_pytest/marks.py | 18 +++++++----------- tests/conftest.py | 4 ++-- tests/external/test_scanorama_integrate.py | 9 +-------- tests/test_highly_variable_genes.py | 3 ++- tests/test_normalization.py | 2 +- 28 files changed, 71 insertions(+), 92 deletions(-) create mode 100644 docs/release-notes/3283.breaking.md diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index e0e8faefd6..f0e181f1ba 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -15,8 +15,8 @@ jobs: vmImage: 'ubuntu-22.04' strategy: matrix: - Python3.9: - python.version: '3.9' + Python3.10: + python.version: '3.10' Python3.12: {} minimal_dependencies: TEST_EXTRA: 'test-min' @@ -24,7 +24,7 @@ jobs: DEPENDENCIES_VERSION: "pre-release" TEST_TYPE: "coverage" minimum_versions: - python.version: '3.9' + python.version: '3.10' DEPENDENCIES_VERSION: "minimum-version" TEST_TYPE: "coverage" diff --git a/benchmarks/asv.conf.json b/benchmarks/asv.conf.json index 26591b490d..98192b3725 100644 --- a/benchmarks/asv.conf.json +++ b/benchmarks/asv.conf.json @@ -54,7 +54,7 @@ // The Pythons you'd like to test against. If not provided, defaults // to the current version of Python used to run `asv`. - // "pythons": ["3.9", "3.12"], + // "pythons": ["3.10", "3.12"], // The list of conda channel names to be searched for benchmark // dependency packages in the specified order diff --git a/docs/release-notes/3283.breaking.md b/docs/release-notes/3283.breaking.md new file mode 100644 index 0000000000..6f391f325d --- /dev/null +++ b/docs/release-notes/3283.breaking.md @@ -0,0 +1 @@ +Remove Python 3.9 support {smaller}`P Angerer` diff --git a/hatch.toml b/hatch.toml index 77c1c92b1f..705b003bb6 100644 --- a/hatch.toml +++ b/hatch.toml @@ -22,7 +22,7 @@ overrides.matrix.deps.env-vars = [ { if = ["min"], key = "UV_RESOLUTION", value = "lowest-direct" }, ] overrides.matrix.deps.python = [ - { if = ["min"] , value = "3.9" }, + { if = ["min"] , value = "3.10" }, { if = ["stable", "full", "pre"], value = "3.12" }, ] overrides.matrix.deps.features = [ diff --git a/pyproject.toml b/pyproject.toml index b13631fe9b..1d78652993 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = ["hatchling", "hatch-vcs"] [project] name = "scanpy" description = "Single-Cell Analysis in Python." -requires-python = ">=3.9" +requires-python = ">=3.10" license = "BSD-3-clause" authors = [ {name = "Alex Wolf"}, @@ -39,7 +39,6 @@ classifiers = [ "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -53,9 +52,9 @@ dependencies = [ "pandas >=1.5", "scipy>=1.8", "seaborn>=0.13", - "h5py>=3.1", + "h5py>=3.6", "tqdm", - "scikit-learn>=0.24", + "scikit-learn>=1.1", "statsmodels>=0.13", "patsy", "networkx>=2.7", diff --git a/src/scanpy/_settings.py b/src/scanpy/_settings.py index 63f91d2279..fa44fc8492 100644 --- a/src/scanpy/_settings.py +++ b/src/scanpy/_settings.py @@ -15,14 +15,14 @@ if TYPE_CHECKING: from collections.abc import Generator, Iterable - from typing import Any, Literal, TextIO, Union + from typing import Any, Literal, TextIO # Collected from the print_* functions in matplotlib.backends - _Format = Union[ - Literal["png", "jpg", "tif", "tiff"], - Literal["pdf", "ps", "eps", "svg", "svgz", "pgf"], - Literal["raw", "rgba"], - ] + _Format = ( + Literal["png", "jpg", "tif", "tiff"] + | Literal["pdf", "ps", "eps", "svg", "svgz", "pgf"] + | Literal["raw", "rgba"] + ) _VERBOSITY_TO_LOGLEVEL = { "error": "ERROR", diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index b8513d87ba..883f8b97b9 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -19,7 +19,7 @@ from operator import mul, truediv from textwrap import dedent from types import MethodType, ModuleType -from typing import TYPE_CHECKING, Union, overload +from typing import TYPE_CHECKING, overload from weakref import WeakSet import h5py @@ -42,9 +42,9 @@ from anndata._core.sparse_dataset import SparseDataset if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Callable, Mapping from pathlib import Path - from typing import Any, Callable, Literal, TypeVar + from typing import Any, Literal, TypeVar from anndata import AnnData from numpy.typing import DTypeLike, NDArray @@ -54,7 +54,7 @@ # e.g. https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html # maybe in the future random.Generator -AnyRandom = Union[int, np.random.RandomState, None] +AnyRandom = int | np.random.RandomState | None class Empty(Enum): @@ -538,9 +538,9 @@ def update_params( if TYPE_CHECKING: - _SparseMatrix = Union[sparse.csr_matrix, sparse.csc_matrix] - _MemoryArray = Union[NDArray, _SparseMatrix] - _SupportedArray = Union[_MemoryArray, DaskArray] + _SparseMatrix = sparse.csr_matrix | sparse.csc_matrix + _MemoryArray = NDArray | _SparseMatrix + _SupportedArray = _MemoryArray | DaskArray @singledispatch @@ -750,8 +750,8 @@ def _( def sum_drop_keepdims(*args, **kwargs): kwargs.pop("computing_meta", None) # masked operations on sparse produce which numpy matrices gives the same API issues handled here - if isinstance(X._meta, (sparse.spmatrix, np.matrix)) or isinstance( - args[0], (sparse.spmatrix, np.matrix) + if isinstance(X._meta, sparse.spmatrix | np.matrix) or isinstance( + args[0], sparse.spmatrix | np.matrix ): kwargs.pop("keepdims", None) axis = kwargs["axis"] @@ -1110,7 +1110,7 @@ def _resolve_axis( def is_backed_type(X: object) -> bool: - return isinstance(X, (SparseDataset, h5py.File, h5py.Dataset)) + return isinstance(X, SparseDataset | h5py.File | h5py.Dataset) def raise_not_implemented_error_if_backed_type(X: object, method_name: str) -> None: diff --git a/src/scanpy/external/pl.py b/src/scanpy/external/pl.py index a3d1767cee..a6ad48f718 100644 --- a/src/scanpy/external/pl.py +++ b/src/scanpy/external/pl.py @@ -218,7 +218,7 @@ def sam( with contextlib.suppress(KeyError): c = np.array(list(adata.obs[c])) - if isinstance(c[0], (str, np.str_)) and isinstance(c, (np.ndarray, list)): + if isinstance(c[0], str | np.str_) and isinstance(c, np.ndarray | list): import samalg.utilities as ut i = ut.convert_annotations(c) @@ -238,7 +238,7 @@ def sam( cbar = plt.colorbar(cax, ax=axes, ticks=ui) cbar.ax.set_yticklabels(c[ai]) else: - if not isinstance(c, (np.ndarray, list)): + if not isinstance(c, np.ndarray | list): colorbar = False i = c diff --git a/src/scanpy/external/pp/_bbknn.py b/src/scanpy/external/pp/_bbknn.py index 4a7d5e7c9b..07d6e41f93 100644 --- a/src/scanpy/external/pp/_bbknn.py +++ b/src/scanpy/external/pp/_bbknn.py @@ -6,7 +6,7 @@ from ..._utils._doctests import doctest_needs if TYPE_CHECKING: - from typing import Callable + from collections.abc import Callable from anndata import AnnData from sklearn.metrics import DistanceMetric diff --git a/src/scanpy/external/pp/_magic.py b/src/scanpy/external/pp/_magic.py index 983db18fcf..fd4b19667d 100644 --- a/src/scanpy/external/pp/_magic.py +++ b/src/scanpy/external/pp/_magic.py @@ -4,6 +4,7 @@ from __future__ import annotations +from types import NoneType from typing import TYPE_CHECKING from packaging.version import Version @@ -155,7 +156,7 @@ def magic( ) start = logg.info("computing MAGIC") - all_or_pca = isinstance(name_list, (str, type(None))) + all_or_pca = isinstance(name_list, str | NoneType) if all_or_pca and name_list not in {"all_genes", "pca_only", None}: raise ValueError( "Invalid string value for `name_list`: " diff --git a/src/scanpy/external/tl/_pypairs.py b/src/scanpy/external/tl/_pypairs.py index 821a060a4d..255334fe7a 100644 --- a/src/scanpy/external/tl/_pypairs.py +++ b/src/scanpy/external/tl/_pypairs.py @@ -13,12 +13,11 @@ if TYPE_CHECKING: from collections.abc import Collection, Mapping - from typing import Union import pandas as pd from anndata import AnnData - Genes = Collection[Union[str, int, bool]] + Genes = Collection[str | int | bool] @doctest_needs("pypairs") diff --git a/src/scanpy/get/_aggregated.py b/src/scanpy/get/_aggregated.py index 5318244af2..e95fedf9dc 100644 --- a/src/scanpy/get/_aggregated.py +++ b/src/scanpy/get/_aggregated.py @@ -14,11 +14,10 @@ if TYPE_CHECKING: from collections.abc import Collection, Iterable - from typing import Union from numpy.typing import NDArray - Array = Union[np.ndarray, sparse.csc_matrix, sparse.csr_matrix] + Array = np.ndarray | sparse.csc_matrix | sparse.csr_matrix # Used with get_args AggType = Literal["count_nonzero", "mean", "sum", "var", "median"] diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index b98e7d4458..7b1c3f2506 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -312,7 +312,7 @@ def __init__( self.restrict_array = restrict_array # restrict the array to a subset def __getitem__(self, index): - if isinstance(index, (int, np.integer)): + if isinstance(index, int | np.integer): if self.restrict_array is None: glob_index = index else: diff --git a/src/scanpy/neighbors/_types.py b/src/scanpy/neighbors/_types.py index 35e4c154cb..d98ec76af3 100644 --- a/src/scanpy/neighbors/_types.py +++ b/src/scanpy/neighbors/_types.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Literal, Protocol, Union +from typing import TYPE_CHECKING, Literal, Protocol import numpy as np @@ -42,7 +42,7 @@ "sqeuclidean", "yule", ] -_Metric = Union[_MetricSparseCapable, _MetricScipySpatial] +_Metric = _MetricSparseCapable | _MetricScipySpatial class KnnTransformerLike(Protocol): diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index b72ccfc99d..aadd0ac6ac 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -5,6 +5,7 @@ from collections import OrderedDict from collections.abc import Collection, Mapping, Sequence from itertools import product +from types import NoneType from typing import TYPE_CHECKING, get_args import matplotlib as mpl @@ -40,7 +41,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from typing import Literal, Union + from typing import Literal from anndata import AnnData from cycler import Cycler @@ -60,7 +61,7 @@ # TODO: is that all? _Basis = Literal["pca", "tsne", "umap", "diffmap", "draw_graph_fr"] - _VarNames = Union[str, Sequence[str]] + _VarNames = str | Sequence[str] VALID_LEGENDLOCS = frozenset(get_args(_utils._LegendLoc)) @@ -811,7 +812,7 @@ def violin( density_norm = _deprecated_scale(density_norm, scale, default="width") del scale - if isinstance(ylabel, (str, type(None))): + if isinstance(ylabel, str | NoneType): ylabel = [ylabel] * (1 if groupby is None else len(keys)) if groupby is None: if len(ylabel) != 1: @@ -992,7 +993,7 @@ def clustermap( """ import seaborn as sns # Slow import, only import if called - if not isinstance(obs_keys, (str, type(None))): + if not isinstance(obs_keys, str | NoneType): raise ValueError("Currently, only a single key is supported.") sanitize_anndata(adata) use_raw = _check_use_raw(adata, use_raw) diff --git a/src/scanpy/plotting/_baseplot_class.py b/src/scanpy/plotting/_baseplot_class.py index 23e74505b1..fff1b40322 100644 --- a/src/scanpy/plotting/_baseplot_class.py +++ b/src/scanpy/plotting/_baseplot_class.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence - from typing import Literal, Self, Union + from typing import Literal, Self import pandas as pd from anndata import AnnData @@ -28,7 +28,7 @@ from .._utils import Empty from ._utils import ColorLike, _AxesSubplot - _VarNames = Union[str, Sequence[str]] + _VarNames = str | Sequence[str] class VBoundNorm(NamedTuple): diff --git a/src/scanpy/plotting/_scrublet.py b/src/scanpy/plotting/_scrublet.py index 66e33e2e82..4a1247574d 100644 --- a/src/scanpy/plotting/_scrublet.py +++ b/src/scanpy/plotting/_scrublet.py @@ -11,13 +11,13 @@ if TYPE_CHECKING: from collections.abc import Sequence - from typing import Literal, Union + from typing import Literal from anndata import AnnData from matplotlib.axes import Axes from matplotlib.figure import Figure - Scale = Union[Literal["linear", "log", "symlog", "logit"], str] + Scale = Literal["linear", "log", "symlog", "logit"] | str @old_positionals( diff --git a/src/scanpy/plotting/_tools/paga.py b/src/scanpy/plotting/_tools/paga.py index 159be79913..29408735b6 100644 --- a/src/scanpy/plotting/_tools/paga.py +++ b/src/scanpy/plotting/_tools/paga.py @@ -26,7 +26,7 @@ from .._utils import matrix if TYPE_CHECKING: - from typing import Any, Literal, Union + from typing import Any, Literal from anndata import AnnData from matplotlib.axes import Axes @@ -36,7 +36,7 @@ from ...tools._draw_graph import _Layout as _LayoutWithoutEqTree from .._utils import _FontSize, _FontWeight, _LegendLoc - _Layout = Union[_LayoutWithoutEqTree, Literal["eq_tree"]] + _Layout = _LayoutWithoutEqTree | Literal["eq_tree"] @old_positionals( @@ -725,7 +725,7 @@ def _paga_graph( nx_g_dashed = nx.Graph(adjacency_dashed) # convert pos to array and dict - if not isinstance(pos, (Path, str)): + if not isinstance(pos, Path | str): pos_array = pos else: pos = Path(pos) diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index 5c15fa8df4..7f69a76025 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -1,7 +1,6 @@ from __future__ import annotations import inspect -import sys from collections.abc import Mapping, Sequence # noqa: TCH003 from copy import copy from functools import partial @@ -217,7 +216,7 @@ def embedding( # set as ndarray if ( size is not None - and isinstance(size, (Sequence, pd.Series, np.ndarray)) + and isinstance(size, Sequence | pd.Series | np.ndarray) and len(size) == adata.shape[0] ): size = np.array(size, dtype=float) @@ -593,9 +592,6 @@ def my_vmax(colors): np.percentile(colors, p=80) def _wraps_plot_scatter(wrapper): """Update the wrapper function to use the correct signature.""" - if sys.version_info < (3, 10): - # Python 3.9 does not support `eval_str`, so we only support this in 3.10+ - return wrapper params = inspect.signature(embedding, eval_str=True).parameters.copy() wrapper_sig = inspect.signature(wrapper, eval_str=True) diff --git a/src/scanpy/plotting/_utils.py b/src/scanpy/plotting/_utils.py index 13832658f5..09a01a9bc5 100644 --- a/src/scanpy/plotting/_utils.py +++ b/src/scanpy/plotting/_utils.py @@ -1,8 +1,8 @@ from __future__ import annotations import warnings -from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union, overload +from collections.abc import Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Literal, TypedDict, overload import matplotlib as mpl import numpy as np @@ -37,7 +37,7 @@ DensityNorm = Literal["area", "count", "width"] # These are needed by _wraps_plot_scatter -VBound = Union[str, float, Callable[[Sequence[float]], float]] +VBound = str | float | Callable[[Sequence[float]], float] _FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"] _FontSize = Literal[ "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large" @@ -59,7 +59,7 @@ "upper center", "center", ] -ColorLike = Union[str, tuple[float, ...]] +ColorLike = str | tuple[float, ...] class _AxesSubplot(Axes, axes.SubplotBase): @@ -156,7 +156,7 @@ def timeseries_subplot( """ if color is not None: - use_color_map = isinstance(color[0], (float, np.floating)) + use_color_map = isinstance(color[0], float | np.floating) palette = default_palette(palette) x_range = np.arange(X.shape[0]) if time is None else time if X.ndim == 1: @@ -371,7 +371,7 @@ def default_palette( ) -> str | Cycler: if palette is None: return rcParams["axes.prop_cycle"] - elif not isinstance(palette, (str, Cycler)): + elif not isinstance(palette, str | Cycler): return cycler(color=palette) else: return palette diff --git a/src/scanpy/preprocessing/_normalization.py b/src/scanpy/preprocessing/_normalization.py index 686c69b224..c888ded9c6 100644 --- a/src/scanpy/preprocessing/_normalization.py +++ b/src/scanpy/preprocessing/_normalization.py @@ -28,7 +28,7 @@ def _normalize_data(X, counts, after=None, *, copy: bool = False): X = X.copy() if copy else X - if issubclass(X.dtype.type, (int, np.integer)): + if issubclass(X.dtype.type, int | np.integer): X = X.astype(np.float32) # TODO: Check if float64 should be used if after is None: if isinstance(counts, DaskArray): diff --git a/src/scanpy/preprocessing/_scrublet/core.py b/src/scanpy/preprocessing/_scrublet/core.py index 20d49eda04..4c992b2b64 100644 --- a/src/scanpy/preprocessing/_scrublet/core.py +++ b/src/scanpy/preprocessing/_scrublet/core.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from dataclasses import InitVar, dataclass, field from typing import TYPE_CHECKING, cast @@ -28,13 +27,7 @@ __all__ = ["Scrublet"] -if sys.version_info > (3, 10): - kw_only = lambda yes: {"kw_only": yes} # noqa: E731 -else: - kw_only = lambda _: {} # noqa: E731 - - -@dataclass(**kw_only(True)) # noqa: FBT003 +@dataclass(kw_only=True) class Scrublet: """\ Initialize Scrublet object with counts matrix and doublet prediction parameters @@ -73,7 +66,7 @@ class Scrublet: # init fields counts_obs: InitVar[sparse.csr_matrix | sparse.csc_matrix | NDArray[np.integer]] = ( - field(**kw_only(False)) # noqa: FBT003 + field(kw_only=False) ) total_counts_obs: InitVar[NDArray[np.integer] | None] = None sim_doublet_ratio: float = 2.0 diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 56b71d55eb..d864b01b88 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -620,7 +620,7 @@ def rank_genes_groups( # for clarity, rename variable if groups == "all": groups_order = "all" - elif isinstance(groups, (str, int)): + elif isinstance(groups, str | int): raise ValueError("Specify a sequence of groups") else: groups_order = list(groups) diff --git a/src/testing/scanpy/_pytest/marks.py b/src/testing/scanpy/_pytest/marks.py index c046dd7246..22b32269d2 100644 --- a/src/testing/scanpy/_pytest/marks.py +++ b/src/testing/scanpy/_pytest/marks.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from enum import Enum, auto from importlib.util import find_spec from typing import TYPE_CHECKING @@ -29,11 +28,6 @@ def _skip_if_skmisc_too_old() -> str | None: SKIP_EXTRA["skmisc"] = _skip_if_skmisc_too_old -def _next_val(name: str, start: int, count: int, last_values: list[str]) -> str: - """Distribution name for matching modules""" - return name.replace("_", "-") - - class QuietMarkDecorator(pytest.MarkDecorator): def __init__(self, mark: pytest.Mark) -> None: super().__init__(mark, _ispytest=True) @@ -47,11 +41,13 @@ class needs(QuietMarkDecorator, Enum): :func:`pytest.importorskip` skips tests after they started running. """ - # _generate_next_value_ needs to come before members, also it’s finnicky: - # https://github.com/python/mypy/issues/7591#issuecomment-652800625 - _generate_next_value_ = ( - staticmethod(_next_val) if sys.version_info >= (3, 10) else _next_val - ) + # _generate_next_value_ needs to come before members + @staticmethod + def _generate_next_value_( + name: str, start: int, count: int, last_values: list[str] + ) -> str: + """Distribution name for matching modules""" + return name.replace("_", "-") mod: str diff --git a/tests/conftest.py b/tests/conftest.py index 52bc61168a..4cbe5ff53e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import sys from pathlib import Path from textwrap import dedent -from typing import TYPE_CHECKING, TypedDict, Union, cast +from typing import TYPE_CHECKING, TypedDict, cast import pytest @@ -88,7 +88,7 @@ def fmt_descr(descr): return f"{descr} ({basename})" if basename else descr result = cast( - Union[CompareResult, None], + CompareResult | None, compare_images(str(expected), str(actual), tol=tol, in_decorator=True), ) if result is None: diff --git a/tests/external/test_scanorama_integrate.py b/tests/external/test_scanorama_integrate.py index 19a53a4d27..baa2007fc0 100644 --- a/tests/external/test_scanorama_integrate.py +++ b/tests/external/test_scanorama_integrate.py @@ -1,18 +1,11 @@ from __future__ import annotations -import sys - -import pytest - import scanpy as sc import scanpy.external as sce from testing.scanpy._helpers.data import pbmc68k_reduced from testing.scanpy._pytest.marks import needs -pytestmark = [ - needs.scanorama, - pytest.mark.skipif(sys.version_info < (3, 10), reason="annoy is unstable on 3.9"), -] +pytestmark = [needs.scanorama] def test_scanorama_integrate(): diff --git a/tests/test_highly_variable_genes.py b/tests/test_highly_variable_genes.py index cdd5238c70..0f08b853e0 100644 --- a/tests/test_highly_variable_genes.py +++ b/tests/test_highly_variable_genes.py @@ -20,7 +20,8 @@ from testing.scanpy._pytest.params import ARRAY_TYPES if TYPE_CHECKING: - from typing import Callable, Literal + from collections.abc import Callable + from typing import Literal FILE = Path(__file__).parent / Path("_scripts/seurat_hvg.csv") FILE_V3 = Path(__file__).parent / Path("_scripts/seurat_hvg_v3.csv.gz") diff --git a/tests/test_normalization.py b/tests/test_normalization.py index 2527c997db..3acefe1bb1 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -239,7 +239,7 @@ def test_normalize_pearson_residuals_pca( np.repeat(True, n_unmasked), np.repeat(False, n_genes - n_unmasked) # noqa: FBT003 ] n_var_copy = locals()[n_var_copy_name] - assert isinstance(n_var_copy, (int, np.integer)) + assert isinstance(n_var_copy, int | np.integer) if do_hvg: sc.experimental.pp.highly_variable_genes( From 7268e537468182858fd48cf6136a168804ee1763 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 15 Oct 2024 16:05:27 +0200 Subject: [PATCH 02/51] =?UTF-8?q?Fix=20#3206=E2=80=99s=20release=20note=20?= =?UTF-8?q?(#3287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release-notes/3206.bugfix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/3206.bugfix.md b/docs/release-notes/3206.bugfix.md index d29c34300a..9e47d00b09 100644 --- a/docs/release-notes/3206.bugfix.md +++ b/docs/release-notes/3206.bugfix.md @@ -1 +1 @@ -Fix :meth:`scanpy.pl.DotPlot.style`, :meth:`scanpy.pl.MatrixPlot.style`, and :meth:`scanpy.pl.StackedViolin.style` resetting all non-specified parameters {smaller}`P Angerer` +Fix {meth}`scanpy.pl.DotPlot.style`, {meth}`scanpy.pl.MatrixPlot.style`, and {meth}`scanpy.pl.StackedViolin.style` resetting all non-specified parameters {smaller}`P Angerer` From 3da6891e232570907db036392771262fefa13ef5 Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Thu, 17 Oct 2024 15:00:11 +0200 Subject: [PATCH 03/51] (fix): conditional imports to avoid `anndata.io` warning (#3289) --- src/scanpy/__init__.py | 39 ++++++++++++++------- src/scanpy/readwrite.py | 33 +++++++++++------ src/testing/scanpy/_pytest/fixtures/data.py | 6 +++- tests/test_package_structure.py | 8 +++++ 4 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/scanpy/__init__.py b/src/scanpy/__init__.py index b9be00f8f3..ae56a974f3 100644 --- a/src/scanpy/__init__.py +++ b/src/scanpy/__init__.py @@ -4,6 +4,8 @@ import sys +from packaging.version import Version + try: # See https://github.com/maresb/hatch-vcs-footgun-example from setuptools_scm import get_version @@ -29,18 +31,31 @@ set_figure_params = settings.set_figure_params -from anndata import ( - AnnData, - concat, - read_csv, - read_excel, - read_h5ad, - read_hdf, - read_loom, - read_mtx, - read_text, - read_umi_tools, -) +import anndata + +if Version(anndata.__version__) >= Version("0.11.0rc0"): + from anndata.io import ( + read_csv, + read_excel, + read_h5ad, + read_hdf, + read_loom, + read_mtx, + read_text, + read_umi_tools, + ) +else: + from anndata import ( + read_csv, + read_excel, + read_h5ad, + read_hdf, + read_loom, + read_mtx, + read_text, + read_umi_tools, + ) +from anndata import AnnData, concat from . import datasets, experimental, external, get, logging, metrics, queries from . import plotting as pl diff --git a/src/scanpy/readwrite.py b/src/scanpy/readwrite.py index b24380fe16..3fbf8ef61c 100644 --- a/src/scanpy/readwrite.py +++ b/src/scanpy/readwrite.py @@ -10,16 +10,29 @@ import h5py import numpy as np import pandas as pd -from anndata import ( - AnnData, - read_csv, - read_excel, - read_h5ad, - read_hdf, - read_loom, - read_mtx, - read_text, -) +from packaging.version import Version + +if Version(anndata.__version__) >= Version("0.11.0rc0"): + from anndata.io import ( + read_csv, + read_excel, + read_h5ad, + read_hdf, + read_loom, + read_mtx, + read_text, + ) +else: + from anndata import ( + read_csv, + read_excel, + read_h5ad, + read_hdf, + read_loom, + read_mtx, + read_text, + ) +from anndata import AnnData from matplotlib.image import imread from . import logging as logg diff --git a/src/testing/scanpy/_pytest/fixtures/data.py b/src/testing/scanpy/_pytest/fixtures/data.py index d2be706076..4e5762f6cb 100644 --- a/src/testing/scanpy/_pytest/fixtures/data.py +++ b/src/testing/scanpy/_pytest/fixtures/data.py @@ -16,7 +16,11 @@ from anndata._core.sparse_dataset import ( BaseCompressedSparseDataset as SparseDataset, ) - from anndata.experimental import sparse_dataset + + if Version(anndata_version) >= Version("0.11.0rc0"): + from anndata.io import sparse_dataset + else: + from anndata.experimental import sparse_dataset def make_sparse(x): return sparse_dataset(x) diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py index 19a6836e65..834c06d8b4 100644 --- a/tests/test_package_structure.py +++ b/tests/test_package_structure.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import os from collections import defaultdict from inspect import Parameter, signature @@ -69,6 +70,13 @@ def test_descend_classes_and_funcs(): assert {p.values[0] for p in api_functions} == funcs +@pytest.mark.filterwarnings("error::FutureWarning:.*Import anndata.*") +def test_import_future_anndata_import_warning(): + import scanpy + + importlib.reload(scanpy) + + @pytest.mark.parametrize(("f", "qualname"), api_functions) def test_function_headers(f, qualname): filename = getsourcefile(f) From bbcd4b173aabebb8b4793cf2cdd6ea8b31e31005 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 17 Oct 2024 18:47:55 +0200 Subject: [PATCH 04/51] Fix benchmark job: Use upstream asv (#3292) --- .github/workflows/benchmark.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index f2de5e34df..68e274ad54 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -48,8 +48,7 @@ jobs: key: benchmark-state-${{ hashFiles('benchmarks/**') }} - name: Install dependencies - # TODO: revert once this PR is merged: https://github.com/airspeed-velocity/asv/pull/1397 - run: pip install 'asv @ git+https://github.com/ivirshup/asv@fix-conda-usage' + run: pip install 'asv>=0.6.4' - name: Configure ASV working-directory: ${{ env.ASV_DIR }} From 3570cd1e4cd717cd7cd15929059c84cf7eb6d396 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 18 Oct 2024 12:34:12 +0200 Subject: [PATCH 05/51] Use upstream sklearn PCA if possible (#3267) --- docs/release-notes/3267.feature.md | 1 + hatch.toml | 2 +- pyproject.toml | 3 +- .../{_pca.py => _pca/__init__.py} | 316 +++++++++--------- src/scanpy/preprocessing/_pca/_compat.py | 79 +++++ src/scanpy/preprocessing/_simple.py | 27 -- tests/test_pca.py | 98 +++--- 7 files changed, 278 insertions(+), 248 deletions(-) create mode 100644 docs/release-notes/3267.feature.md rename src/scanpy/preprocessing/{_pca.py => _pca/__init__.py} (70%) create mode 100644 src/scanpy/preprocessing/_pca/_compat.py diff --git a/docs/release-notes/3267.feature.md b/docs/release-notes/3267.feature.md new file mode 100644 index 0000000000..ea4a5c6080 --- /dev/null +++ b/docs/release-notes/3267.feature.md @@ -0,0 +1 @@ +Use upstreamed {class}`~sklearn.decomposition.PCA` implementation for {class}`~scipy.sparse.sparray` and {class}`~scipy.sparse.spmatrix` (see {ref}`sklearn:changes_1_4`) {smaller}`P Angerer` diff --git a/hatch.toml b/hatch.toml index 705b003bb6..ab2bb7550e 100644 --- a/hatch.toml +++ b/hatch.toml @@ -15,7 +15,7 @@ scripts.clean = "git restore --source=HEAD --staged --worktree -- docs/release-n [envs.hatch-test] default-args = [] -features = ["test"] +features = ["test", "dask-ml"] extra-dependencies = ["ipykernel"] overrides.matrix.deps.env-vars = [ { if = ["pre"], key = "UV_PRERELEASE", value = "allow" }, diff --git a/pyproject.toml b/pyproject.toml index 1d78652993..dff45651fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,8 +126,7 @@ doc = [ "sphinxcontrib-bibtex", "setuptools", # TODO: remove necessity for being able to import doc-linked classes - "dask", - "scanpy[paga]", + "scanpy[paga,dask-ml]", "sam-algorithm", ] dev = [ diff --git a/src/scanpy/preprocessing/_pca.py b/src/scanpy/preprocessing/_pca/__init__.py similarity index 70% rename from src/scanpy/preprocessing/_pca.py rename to src/scanpy/preprocessing/_pca/__init__.py index 93432841f8..8bc39cbf57 100644 --- a/src/scanpy/preprocessing/_pca.py +++ b/src/scanpy/preprocessing/_pca/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, get_args, overload from warnings import warn import anndata as ad @@ -9,24 +9,47 @@ from anndata import AnnData from packaging.version import Version from scipy.sparse import issparse -from scipy.sparse.linalg import LinearOperator, svds -from sklearn.utils import check_array, check_random_state -from sklearn.utils.extmath import svd_flip - -from .. import logging as logg -from .._compat import DaskArray, pkg_version -from .._settings import settings -from .._utils import _doc_params, _empty, is_backed_type -from ..get import _check_mask, _get_obs_rep -from ._docs import doc_mask_var_hvg -from ._utils import _get_mean_var +from sklearn.utils import check_random_state + +from ... import logging as logg +from ..._compat import DaskArray, pkg_version +from ..._settings import settings +from ..._utils import _doc_params, _empty, is_backed_type +from ...get import _check_mask, _get_obs_rep +from .._docs import doc_mask_var_hvg +from ._compat import _pca_compat_sparse if TYPE_CHECKING: + from collections.abc import Container + from typing import LiteralString, TypeVar + + import dask_ml.decomposition as dmld + import sklearn.decomposition as skld from numpy.typing import DTypeLike, NDArray + from scipy import sparse from scipy.sparse import spmatrix - from sklearn.decomposition import PCA - from .._utils import AnyRandom, Empty + from ..._utils import AnyRandom, Empty + + CSMatrix = sparse.csr_matrix | sparse.csc_matrix + + MethodDaskML = type[dmld.PCA | dmld.IncrementalPCA | dmld.TruncatedSVD] + MethodSklearn = type[skld.PCA | skld.TruncatedSVD] + + T = TypeVar("T", bound=LiteralString) + M = TypeVar("M", bound=LiteralString) + + +SvdSolvPCADaskML = Literal["auto", "full", "tsqr", "randomized"] +SvdSolvTruncatedSVDDaskML = Literal["tsqr", "randomized"] +SvdSolvDaskML = SvdSolvPCADaskML | SvdSolvTruncatedSVDDaskML + +SvdSolvPCASklearn = Literal["auto", "full", "arpack", "randomized"] +SvdSolvTruncatedSVDSklearn = Literal["arpack", "randomized"] +SvdSolvPCASparseSklearn = Literal["arpack"] +SvdSolvSkearn = SvdSolvPCASklearn | SvdSolvTruncatedSVDSklearn | SvdSolvPCASparseSklearn + +SvdSolver = SvdSolvDaskML | SvdSolvSkearn @_doc_params( @@ -38,7 +61,7 @@ def pca( *, layer: str | None = None, zero_center: bool | None = True, - svd_solver: str | None = None, + svd_solver: SvdSolver | None = None, random_state: AnyRandom = 0, return_info: bool = False, mask_var: NDArray[np.bool_] | str | None | Empty = _empty, @@ -180,7 +203,7 @@ def pca( logg.info( "Note that scikit-learn's randomized PCA might not be exactly " "reproducible across different computational platforms. For exact " - "reproducibility, choose `svd_solver='arpack'.`" + "reproducibility, choose `svd_solver='arpack'`." ) data_is_AnnData = isinstance(data, AnnData) if data_is_AnnData: @@ -224,11 +247,9 @@ def pca( UserWarning, ) - is_dask = isinstance(X, DaskArray) - # check_random_state returns a numpy RandomState when passed an int but # dask needs an int for random state - if not is_dask: + if not isinstance(X, DaskArray): random_state = check_random_state(random_state) elif not isinstance(random_state, int): msg = f"random_state needs to be an int, not a {type(random_state).__name__} when passing a dask array" @@ -243,12 +264,12 @@ def pca( logg.debug("Ignoring zero_center, random_state, svd_solver") incremental_pca_kwargs = dict() - if is_dask: + if isinstance(X, DaskArray): from dask.array import zeros from dask_ml.decomposition import IncrementalPCA incremental_pca_kwargs["svd_solver"] = _handle_dask_ml_args( - svd_solver, "IncrementalPCA" + svd_solver, IncrementalPCA ) else: from numpy import zeros @@ -265,46 +286,54 @@ def pca( for chunk, start, end in adata_comp.chunked_X(chunk_size): chunk = chunk.toarray() if issparse(chunk) else chunk X_pca[start:end] = pca_.transform(chunk) - elif (not issparse(X) or svd_solver == "randomized") and zero_center: - if is_dask: - from dask_ml.decomposition import PCA - - svd_solver = _handle_dask_ml_args(svd_solver, "PCA") + elif zero_center: + if issparse(X) and ( + pkg_version("scikit-learn") < Version("1.4") or svd_solver == "lobpcg" + ): + if svd_solver not in {"lobpcg", "arpack"}: + if svd_solver is not None: + msg = ( + f"Ignoring {svd_solver=} and using 'arpack', " + "sparse PCA with sklearn < 1.4 only supports 'lobpcg' and 'arpack'." + ) + warnings.warn(msg) + svd_solver = "arpack" + elif svd_solver == "lobpcg": + msg = ( + f"{svd_solver=} for sparse relies on legacy code and will not be supported in the future. " + "Also the lobpcg solver has been observed to be inaccurate. Please use 'arpack' instead." + ) + warnings.warn(msg, FutureWarning) + X_pca, pca_ = _pca_compat_sparse( + X, n_comps, solver=svd_solver, random_state=random_state + ) else: - from sklearn.decomposition import PCA + if isinstance(X, DaskArray): + from dask_ml.decomposition import PCA - svd_solver = _handle_sklearn_args(svd_solver, "PCA") + svd_solver = _handle_dask_ml_args(svd_solver, PCA) + else: + from sklearn.decomposition import PCA - if issparse(X) and svd_solver == "randomized": - # This is for backwards compat. Better behaviour would be to either error or use arpack. - warnings.warn( - "svd_solver 'randomized' does not work with sparse input. Densifying the array. " - "This may take a very large amount of memory." - ) - X = X.toarray() - pca_ = PCA( - n_components=n_comps, svd_solver=svd_solver, random_state=random_state - ) - X_pca = pca_.fit_transform(X) - elif issparse(X) and zero_center: - svd_solver = _handle_sklearn_args(svd_solver, "PCA (with sparse input)") + svd_solver = _handle_sklearn_args(svd_solver, PCA, sparse=issparse(X)) - X_pca, pca_ = _pca_with_sparse( - X, n_comps, solver=svd_solver, random_state=random_state - ) - elif not zero_center: - if is_dask: + pca_ = PCA( + n_components=n_comps, svd_solver=svd_solver, random_state=random_state + ) + X_pca = pca_.fit_transform(X) + else: + if isinstance(X, DaskArray): from dask_ml.decomposition import TruncatedSVD - svd_solver = _handle_dask_ml_args(svd_solver, "TruncatedSVD") + svd_solver = _handle_dask_ml_args(svd_solver, TruncatedSVD) else: from sklearn.decomposition import TruncatedSVD - svd_solver = _handle_sklearn_args(svd_solver, "TruncatedSVD") + svd_solver = _handle_sklearn_args(svd_solver, TruncatedSVD) logg.debug( " without zero-centering: \n" - " the explained variance does not correspond to the exact statistical defintion\n" + " the explained variance does not correspond to the exact statistical definition\n" " the first component, e.g., might be heavily influenced by different means\n" " the following components often resemble the exact PCA very closely" ) @@ -312,9 +341,6 @@ def pca( n_components=n_comps, random_state=random_state, algorithm=svd_solver ) X_pca = pca_.fit_transform(X) - else: - msg = "This shouldn’t happen. Please open a bug report." - raise AssertionError(msg) if X_pca.dtype.descr != np.dtype(dtype).descr: X_pca = X_pca.astype(dtype) @@ -402,110 +428,84 @@ def _handle_mask_var( return mask_var, _check_mask(adata, mask_var, "var") -def _pca_with_sparse( - X: spmatrix, - n_pcs: int, +@overload +def _handle_dask_ml_args( + svd_solver: str | None, method: type[dmld.PCA | dmld.IncrementalPCA] +) -> SvdSolvPCADaskML: ... +@overload +def _handle_dask_ml_args( + svd_solver: str | None, method: type[dmld.TruncatedSVD] +) -> SvdSolvTruncatedSVDDaskML: ... +def _handle_dask_ml_args(svd_solver: str | None, method: MethodDaskML) -> str: + import dask_ml.decomposition as dmld + + args: tuple[SvdSolvDaskML, ...] + default: SvdSolvDaskML + match method: + case dmld.PCA | dmld.IncrementalPCA: + args = get_args(SvdSolvPCADaskML) + default = "auto" + case dmld.TruncatedSVD: + args = get_args(SvdSolvTruncatedSVDDaskML) + default = "tsqr" + case _: + msg = f"Unknown {method=} in _handle_dask_ml_args" + raise ValueError(msg) + return _handle_x_args(svd_solver, method, args, default) + + +@overload +def _handle_sklearn_args( + svd_solver: str | None, method: type[skld.TruncatedSVD], *, sparse: None = None +) -> SvdSolvTruncatedSVDSklearn: ... +@overload +def _handle_sklearn_args( + svd_solver: str | None, method: type[skld.PCA], *, sparse: Literal[False] +) -> SvdSolvPCASklearn: ... +@overload +def _handle_sklearn_args( + svd_solver: str | None, method: type[skld.PCA], *, sparse: Literal[True] +) -> SvdSolvPCASparseSklearn: ... +def _handle_sklearn_args( + svd_solver: str | None, method: MethodSklearn, *, sparse: bool | None = None +) -> str: + import sklearn.decomposition as skld + + args: tuple[SvdSolvSkearn, ...] + default: SvdSolvSkearn + suffix = "" + match (method, sparse): + case (skld.TruncatedSVD, None): + args = get_args(SvdSolvTruncatedSVDSklearn) + default = "randomized" + case (skld.PCA, False): + args = get_args(SvdSolvPCASklearn) + default = "arpack" + case (skld.PCA, True): + args = get_args(SvdSolvPCASparseSklearn) + default = "arpack" + suffix = " (with sparse input)" + case _: + msg = f"Unknown {method=} ({sparse=}) in _handle_sklearn_args" + raise ValueError(msg) + + return _handle_x_args(svd_solver, method, args, default, suffix=suffix) + + +def _handle_x_args( + svd_solver: str | None, + method: type, + args: Container[T], + default: T, *, - solver: str = "arpack", - mu: NDArray[np.floating] | None = None, - random_state: AnyRandom = None, -) -> tuple[NDArray[np.floating], PCA]: - random_state = check_random_state(random_state) - np.random.set_state(random_state.get_state()) - random_init = np.random.rand(np.min(X.shape)) - X = check_array(X, accept_sparse=["csr", "csc"]) - - if mu is None: - mu = np.asarray(X.mean(0)).flatten()[None, :] - mdot = mu.dot - mmat = mdot - mhdot = mu.T.dot - mhmat = mu.T.dot - Xdot = X.dot - Xmat = Xdot - XHdot = X.T.conj().dot - XHmat = XHdot - ones = np.ones(X.shape[0])[None, :].dot - - def matvec(x): - return Xdot(x) - mdot(x) - - def matmat(x): - return Xmat(x) - mmat(x) - - def rmatvec(x): - return XHdot(x) - mhdot(ones(x)) - - def rmatmat(x): - return XHmat(x) - mhmat(ones(x)) - - XL = LinearOperator( - matvec=matvec, - dtype=X.dtype, - matmat=matmat, - shape=X.shape, - rmatvec=rmatvec, - rmatmat=rmatmat, - ) - - u, s, v = svds(XL, solver=solver, k=n_pcs, v0=random_init) - # u_based_decision was changed in https://github.com/scikit-learn/scikit-learn/pull/27491 - u, v = svd_flip( - u, v, u_based_decision=pkg_version("scikit-learn") < Version("1.5.0rc1") - ) - idx = np.argsort(-s) - v = v[idx, :] - - X_pca = (u * s)[:, idx] - ev = s[idx] ** 2 / (X.shape[0] - 1) - - total_var = _get_mean_var(X)[1].sum() - ev_ratio = ev / total_var - - from sklearn.decomposition import PCA - - pca = PCA(n_components=n_pcs, svd_solver=solver, random_state=random_state) - pca.explained_variance_ = ev - pca.explained_variance_ratio_ = ev_ratio - pca.components_ = v - return X_pca, pca - - -def _handle_dask_ml_args(svd_solver: str, method: str) -> str: - method2args = { - "PCA": {"auto", "full", "tsqr", "randomized"}, - "IncrementalPCA": {"auto", "full", "tsqr", "randomized"}, - "TruncatedSVD": {"tsqr", "randomized"}, - } - method2default = { - "PCA": "auto", - "IncrementalPCA": "auto", - "TruncatedSVD": "tsqr", - } - - return _handle_x_args("dask_ml", svd_solver, method, method2args, method2default) - - -def _handle_sklearn_args(svd_solver: str | None, method: str) -> str: - method2args = { - "PCA": {"auto", "full", "arpack", "randomized"}, - "TruncatedSVD": {"arpack", "randomized"}, - "PCA (with sparse input)": {"lobpcg", "arpack"}, - } - method2default = { - "PCA": "arpack", - "TruncatedSVD": "randomized", - "PCA (with sparse input)": "arpack", - } - - return _handle_x_args("sklearn", svd_solver, method, method2args, method2default) - - -def _handle_x_args(lib, svd_solver: str | None, method, method2args, method2default): - if svd_solver not in method2args[method]: - if svd_solver is not None: - warnings.warn( - f"Ignoring {svd_solver} and using {method2default[method]}, {lib}.decomposition.{method} only supports {method2args[method]}" - ) - svd_solver = method2default[method] - return svd_solver + suffix: str = "", +) -> T: + if svd_solver in args: + return svd_solver + if svd_solver is not None: + msg = ( + f"Ignoring {svd_solver=} and using {default}, " + f"{method.__module__}.{method.__qualname__}{suffix} only supports {args}." + ) + warnings.warn(msg) + return default diff --git a/src/scanpy/preprocessing/_pca/_compat.py b/src/scanpy/preprocessing/_pca/_compat.py new file mode 100644 index 0000000000..23cb60a2e9 --- /dev/null +++ b/src/scanpy/preprocessing/_pca/_compat.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from packaging.version import Version +from scipy.sparse.linalg import LinearOperator, svds +from sklearn.utils import check_array, check_random_state +from sklearn.utils.extmath import svd_flip + +from ..._compat import pkg_version +from .._utils import _get_mean_var + +if TYPE_CHECKING: + from typing import Literal + + from numpy.typing import NDArray + from scipy import sparse + from sklearn.decomposition import PCA + + from .._utils import AnyRandom + + CSMatrix = sparse.csr_matrix | sparse.csc_matrix + + +def _pca_compat_sparse( + x: CSMatrix, + n_pcs: int, + *, + solver: Literal["arpack", "lobpcg"], + mu: NDArray[np.floating] | None = None, + random_state: AnyRandom = None, +) -> tuple[NDArray[np.floating], PCA]: + """Sparse PCA for scikit-learn <1.4""" + random_state = check_random_state(random_state) + np.random.set_state(random_state.get_state()) + random_init = np.random.rand(np.min(x.shape)) + x = check_array(x, accept_sparse=["csr", "csc"]) + + if mu is None: + mu = np.asarray(x.mean(0)).flatten()[None, :] + ones = np.ones(x.shape[0])[None, :].dot + + def mat_op(v: NDArray[np.floating]): + return (x @ v) - (mu @ v) + + def rmat_op(v: NDArray[np.floating]): + return (x.T.conj() @ v) - (mu.T @ ones(v)) + + linop = LinearOperator( + dtype=x.dtype, + shape=x.shape, + matvec=mat_op, + matmat=mat_op, + rmatvec=rmat_op, + rmatmat=rmat_op, + ) + + u, s, v = svds(linop, solver=solver, k=n_pcs, v0=random_init) + # u_based_decision was changed in https://github.com/scikit-learn/scikit-learn/pull/27491 + u, v = svd_flip( + u, v, u_based_decision=pkg_version("scikit-learn") < Version("1.5.0rc1") + ) + idx = np.argsort(-s) + v = v[idx, :] + + X_pca = (u * s)[:, idx] + ev = s[idx] ** 2 / (x.shape[0] - 1) + + total_var = _get_mean_var(x)[1].sum() + ev_ratio = ev / total_var + + from sklearn.decomposition import PCA + + pca = PCA(n_components=n_pcs, svd_solver=solver, random_state=random_state) + pca.explained_variance_ = ev + pca.explained_variance_ratio_ = ev_ratio + pca.components_ = v + return X_pca, pca diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 983a372003..90a8d0e21a 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -11,7 +11,6 @@ import numba import numpy as np -import scipy as sp from anndata import AnnData from pandas.api.types import CategoricalDtype from scipy.sparse import csr_matrix, issparse, isspmatrix_csr, spmatrix @@ -998,29 +997,3 @@ def _downsample_array( geneptr += 1 col[geneptr] += 1 return col - - -# -------------------------------------------------------------------------------- -# Helper Functions -# -------------------------------------------------------------------------------- - - -def _pca_fallback(data, n_comps=2): - # mean center the data - data -= data.mean(axis=0) - # calculate the covariance matrix - C = np.cov(data, rowvar=False) - # calculate eigenvectors & eigenvalues of the covariance matrix - # use 'eigh' rather than 'eig' since C is symmetric, - # the performance gain is substantial - # evals, evecs = np.linalg.eigh(C) - evals, evecs = sp.sparse.linalg.eigsh(C, k=n_comps) - # sort eigenvalues in decreasing order - idcs = np.argsort(evals)[::-1] - evecs = evecs[:, idcs] - evals = evals[idcs] - # select the first n eigenvectors (n is desired dimension - # of rescaled data array, or n_comps) - evecs = evecs[:, :n_comps] - # project data points on eigenvectors - return np.dot(evecs.T, data.T).T diff --git a/tests/test_pca.py b/tests/test_pca.py index 7e49c49cfb..f0bd88567c 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -1,5 +1,6 @@ from __future__ import annotations +import random import warnings from contextlib import nullcontext from functools import wraps @@ -9,10 +10,8 @@ import numpy as np import pytest from anndata import AnnData -from anndata.tests.helpers import ( - asarray, - assert_equal, -) +from anndata.tests import helpers +from anndata.tests.helpers import assert_equal from packaging.version import Version from scipy import sparse from scipy.sparse import issparse @@ -117,77 +116,58 @@ def pca_params( ): all_svd_solvers = {"auto", "full", "arpack", "randomized", "tsqr", "lobpcg"} - expected_warning = None + warn_pat_expected = None svd_solver = None if svd_solver_type is not None: - if array_type in DASK_CONVERTERS.values(): - svd_solver = ( - {"auto", "full", "tsqr", "randomized"} - if zero_center - else {"tsqr", "randomized"} - ) - elif array_type in {sparse.csr_matrix, sparse.csc_matrix}: - svd_solver = ( - {"lobpcg", "arpack"} if zero_center else {"arpack", "randomized"} - ) - elif array_type is asarray: - svd_solver = ( - {"auto", "full", "arpack", "randomized"} - if zero_center - else {"arpack", "randomized"} - ) - else: - pytest.fail(f"Unknown array type {array_type}") + match array_type, zero_center: + case (dc, True) if dc in DASK_CONVERTERS.values(): + svd_solver = {"auto", "full", "tsqr", "randomized"} + case (dc, False) if dc in DASK_CONVERTERS.values(): + svd_solver = {"tsqr", "randomized"} + case ((sparse.csr_matrix | sparse.csc_matrix), True): + svd_solver = {"arpack"} + case ((sparse.csr_matrix | sparse.csc_matrix), False): + svd_solver = {"arpack", "randomized"} + case (helpers.asarray, True): + svd_solver = {"auto", "full", "arpack", "randomized"} + case (helpers.asarray, False): + svd_solver = {"arpack", "randomized"} + case _: + pytest.fail(f"Unknown array type {array_type}") if svd_solver_type == "invalid": svd_solver = all_svd_solvers - svd_solver - expected_warning = "Ignoring" + warn_pat_expected = r"Ignoring" - svd_solver = np.random.choice(list(svd_solver)) + svd_solver = random.choice(list(svd_solver)) # explicit check for special case if ( - svd_solver == "randomized" + array_type in {sparse.csr_matrix, sparse.csc_matrix} and zero_center - and array_type in [sparse.csr_matrix, sparse.csc_matrix] + and svd_solver == "lobpcg" ): - expected_warning = "not work with sparse input" + warn_pat_expected = r"legacy code" - return (svd_solver, expected_warning) + return (svd_solver, warn_pat_expected) def test_pca_warnings(array_type, zero_center, pca_params): - svd_solver, expected_warning = pca_params + svd_solver, warn_pat_expected = pca_params A = array_type(A_list).astype("float32") adata = AnnData(A) - if expected_warning is not None: - with pytest.warns(UserWarning, match=expected_warning): - sc.pp.pca(adata, svd_solver=svd_solver, zero_center=zero_center) - return - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error") + if warn_pat_expected is not None: + with pytest.warns((UserWarning, FutureWarning), match=warn_pat_expected): warnings.filterwarnings( - "ignore", - "pkg_resources is deprecated as an API", - DeprecationWarning, + "ignore", r".*Using a dense eigensolver instead of LOBPCG", UserWarning ) sc.pp.pca(adata, svd_solver=svd_solver, zero_center=zero_center) - except UserWarning: - # TODO: Fix this case, maybe by increasing test data size. - # https://github.com/scverse/scanpy/issues/2744 - if svd_solver == "lobpcg": - pytest.xfail(reason="lobpcg doesn’t work with this small test data") - raise - + return -# This warning test is out of the fixture because it is a special case in the logic of the function -def test_pca_warnings_sparse(): - for array_type in (sparse.csr_matrix, sparse.csc_matrix): - A = array_type(A_list).astype("float32") - adata = AnnData(A) - with pytest.warns(UserWarning, match="not work with sparse input"): - sc.pp.pca(adata, svd_solver="randomized", zero_center=True) + warnings.simplefilter("error") + warnings.filterwarnings( + "ignore", "pkg_resources is deprecated as an API", DeprecationWarning + ) + sc.pp.pca(adata, svd_solver=svd_solver, zero_center=zero_center) def test_pca_transform(array_type): @@ -206,22 +186,20 @@ def test_pca_transform_randomized(array_type): warnings.filterwarnings("error") with ( - pytest.warns( - UserWarning, match="svd_solver 'randomized' does not work with sparse input" - ) + pytest.warns(UserWarning, match="Ignoring.*'randomized'") if sparse.issparse(adata.X) else nullcontext() ): sc.pp.pca( adata, - n_comps=5, + n_comps=4, zero_center=True, svd_solver="randomized", dtype="float64", random_state=14, ) - assert np.linalg.norm(A_pca_abs - np.abs(adata.obsm["X_pca"])) < 2e-05 + assert np.linalg.norm(A_pca_abs[:, :4] - np.abs(adata.obsm["X_pca"])) < 2e-05 def test_pca_transform_no_zero_center(array_type): From f28c8c662c928332b7bb19d1576d7b6d975e6f93 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 18 Oct 2024 13:18:03 +0200 Subject: [PATCH 06/51] Test all PCA param combinations (#3294) --- src/testing/scanpy/_pytest/params.py | 9 +- tests/test_pca.py | 162 +++++++++++++++++---------- 2 files changed, 106 insertions(+), 65 deletions(-) diff --git a/src/testing/scanpy/_pytest/params.py b/src/testing/scanpy/_pytest/params.py index af80d1709d..f405e33d5e 100644 --- a/src/testing/scanpy/_pytest/params.py +++ b/src/testing/scanpy/_pytest/params.py @@ -15,19 +15,22 @@ from .._pytest.marks import needs if TYPE_CHECKING: - from collections.abc import Iterable - from typing import Literal + from collections.abc import Callable, Iterable + from typing import Any, Literal from _pytest.mark.structures import ParameterSet def param_with( at: ParameterSet, + transform: Callable[..., Iterable[Any]] = lambda x: (x,), *, marks: Iterable[pytest.Mark | pytest.MarkDecorator] = (), id: str | None = None, ) -> ParameterSet: - return pytest.param(*at.values, marks=[*at.marks, *marks], id=id or at.id) + return pytest.param( + *transform(*at.values), marks=[*at.marks, *marks], id=id or at.id + ) MAP_ARRAY_TYPES: dict[ diff --git a/tests/test_pca.py b/tests/test_pca.py index f0bd88567c..fa294ef343 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -1,10 +1,9 @@ from __future__ import annotations -import random import warnings from contextlib import nullcontext from functools import wraps -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, cast, get_args import anndata as ad import numpy as np @@ -20,18 +19,22 @@ from testing.scanpy import _helpers from testing.scanpy._helpers.data import pbmc3k_normalized from testing.scanpy._pytest.marks import needs +from testing.scanpy._pytest.params import ARRAY_TYPES as ARRAY_TYPES_ALL from testing.scanpy._pytest.params import ( - ARRAY_TYPES, ARRAY_TYPES_SPARSE_DASK_UNSUPPORTED, param_with, ) if TYPE_CHECKING: - from collections.abc import Callable - from typing import Literal + from collections.abc import Callable, Generator + + from anndata.typing import ArrayDataStructureType from scanpy._compat import DaskArray + ArrayType = Callable[[np.ndarray], ArrayDataStructureType] + + A_list = np.array( [ [0, 0, 7, 0, 0], @@ -83,75 +86,110 @@ def wrapper(a: np.ndarray) -> DaskArray: } -@pytest.fixture( - params=[ - param_with(at, marks=[needs.dask_ml]) if "dask" in at.id else at - for at in ARRAY_TYPES_SPARSE_DASK_UNSUPPORTED - ] -) -def array_type(request: pytest.FixtureRequest): +def maybe_convert_array_to_dask(array_type): # If one uses dask for PCA it will always require dask-ml. # dask-ml can’t do 2D-chunked arrays, so rechunk them. - if as_dask_array := DASK_CONVERTERS.get(request.param): - return as_dask_array + if as_dask_array := DASK_CONVERTERS.get(array_type): + return (as_dask_array,) # When not using dask, just return the array type - assert "dask" not in request.param.__name__, "add more branches or refactor" - return request.param + assert "dask" not in array_type.__name__, "add more branches or refactor" + return (array_type,) -@pytest.fixture(params=[None, "valid", "invalid"]) -def svd_solver_type(request: pytest.FixtureRequest): - return request.param +ARRAY_TYPES = [ + param_with(at, maybe_convert_array_to_dask, marks=[needs.dask_ml]) + if "dask" in cast(str, at.id) + else at + for at in ARRAY_TYPES_SPARSE_DASK_UNSUPPORTED +] -@pytest.fixture(params=[True, False], ids=["zero_center", "no_zero_center"]) -def zero_center(request: pytest.FixtureRequest): +@pytest.fixture(params=ARRAY_TYPES) +def array_type(request: pytest.FixtureRequest) -> ArrayType: return request.param -@pytest.fixture -def pca_params( - array_type, svd_solver_type: Literal[None, "valid", "invalid"], zero_center -): - all_svd_solvers = {"auto", "full", "arpack", "randomized", "tsqr", "lobpcg"} - - warn_pat_expected = None - svd_solver = None - if svd_solver_type is not None: - match array_type, zero_center: - case (dc, True) if dc in DASK_CONVERTERS.values(): - svd_solver = {"auto", "full", "tsqr", "randomized"} - case (dc, False) if dc in DASK_CONVERTERS.values(): - svd_solver = {"tsqr", "randomized"} - case ((sparse.csr_matrix | sparse.csc_matrix), True): - svd_solver = {"arpack"} - case ((sparse.csr_matrix | sparse.csc_matrix), False): - svd_solver = {"arpack", "randomized"} - case (helpers.asarray, True): - svd_solver = {"auto", "full", "arpack", "randomized"} - case (helpers.asarray, False): - svd_solver = {"arpack", "randomized"} - case _: - pytest.fail(f"Unknown array type {array_type}") - if svd_solver_type == "invalid": - svd_solver = all_svd_solvers - svd_solver - warn_pat_expected = r"Ignoring" - - svd_solver = random.choice(list(svd_solver)) - # explicit check for special case - if ( - array_type in {sparse.csr_matrix, sparse.csc_matrix} - and zero_center - and svd_solver == "lobpcg" - ): - warn_pat_expected = r"legacy code" +SVDSolver = Literal["auto", "full", "arpack", "randomized", "tsqr", "lobpcg"] - return (svd_solver, warn_pat_expected) +def gen_pca_params( + *, + array_type: ArrayType, + svd_solver_type: Literal[None, "valid", "invalid"], + zero_center: bool, +) -> Generator[tuple[SVDSolver, str | None] | tuple[None, None], None, None]: + if svd_solver_type is None: + yield None, None + return -def test_pca_warnings(array_type, zero_center, pca_params): - svd_solver, warn_pat_expected = pca_params + all_svd_solvers = set(get_args(SVDSolver)) + svd_solvers: set[SVDSolver] + match array_type, zero_center: + case (dc, True) if dc in DASK_CONVERTERS.values(): + svd_solvers = {"auto", "full", "tsqr", "randomized"} + case (dc, False) if dc in DASK_CONVERTERS.values(): + svd_solvers = {"tsqr", "randomized"} + case ((sparse.csr_matrix | sparse.csc_matrix), True): + svd_solvers = {"arpack"} + case ((sparse.csr_matrix | sparse.csc_matrix), False): + svd_solvers = {"arpack", "randomized"} + case (helpers.asarray, True): + svd_solvers = {"auto", "full", "arpack", "randomized"} + case (helpers.asarray, False): + svd_solvers = {"arpack", "randomized"} + case _: + pytest.fail(f"Unknown array type {array_type}") + + if svd_solver_type == "invalid": + svd_solvers = all_svd_solvers - svd_solvers + warn_pat_expected = r"Ignoring" + elif svd_solver_type == "valid": + warn_pat_expected = None + else: + pytest.fail(f"Unknown svd_solver_type {svd_solver_type}") + + for svd_solver in svd_solvers: + # explicit check for special case + if ( + array_type in {sparse.csr_matrix, sparse.csc_matrix} + and zero_center + and svd_solver == "lobpcg" + ): + pat = r"legacy code" + else: + pat = warn_pat_expected + yield (svd_solver, pat) + + +@pytest.mark.parametrize( + ("array_type", "zero_center", "svd_solver", "warn_pat_expected"), + [ + pytest.param( + array_type.values[0], + zero_center, + svd_solver, + warn_pat_expected, + marks=array_type.marks, + id=f"{array_type.id}-{'zero_center' if zero_center else 'no_zero_center'}-{svd_solver}-{warn_pat_expected}", + ) + for array_type in ARRAY_TYPES + for zero_center in [True, False] + for svd_solver_type in [None, "valid", "invalid"] + for svd_solver, warn_pat_expected in gen_pca_params( + array_type=array_type.values[0], + zero_center=zero_center, + svd_solver_type=svd_solver_type, + ) + ], +) +def test_pca_warnings( + *, + array_type: ArrayType, + zero_center: bool, + svd_solver: SVDSolver, + warn_pat_expected: str | None, +): A = array_type(A_list).astype("float32") adata = AnnData(A) @@ -322,9 +360,9 @@ def test_pca_n_pcs(): ) -# We use all ARRAY_TYPES here since this error should be raised before +# We use all possible array types here since this error should be raised before # PCA can realize that it got a Dask array -@pytest.mark.parametrize("array_type", ARRAY_TYPES) +@pytest.mark.parametrize("array_type", ARRAY_TYPES_ALL) def test_mask_highly_var_error(array_type): """Check if use_highly_variable=True throws an error if the annotation is missing.""" adata = AnnData(array_type(A_list).astype("float32")) From 121f2dbdbf97f42506dcaecf3f698cca406ffe2a Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 18 Oct 2024 16:39:11 +0200 Subject: [PATCH 07/51] Add explicit support to PCA for `'covariance_eigh'` svd_solver (#3296) --- docs/release-notes/3296.feature.md | 1 + src/scanpy/preprocessing/_pca/__init__.py | 8 ++++---- tests/test_pca.py | 4 +++- 3 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 docs/release-notes/3296.feature.md diff --git a/docs/release-notes/3296.feature.md b/docs/release-notes/3296.feature.md new file mode 100644 index 0000000000..74b89945dd --- /dev/null +++ b/docs/release-notes/3296.feature.md @@ -0,0 +1 @@ +Add explicit support to {func}`scanpy.pp.pca` for `svd_solver='covariance_eigh'` {smaller}`P Angerer` diff --git a/src/scanpy/preprocessing/_pca/__init__.py b/src/scanpy/preprocessing/_pca/__init__.py index 8bc39cbf57..918073d8b7 100644 --- a/src/scanpy/preprocessing/_pca/__init__.py +++ b/src/scanpy/preprocessing/_pca/__init__.py @@ -44,7 +44,7 @@ SvdSolvTruncatedSVDDaskML = Literal["tsqr", "randomized"] SvdSolvDaskML = SvdSolvPCADaskML | SvdSolvTruncatedSVDDaskML -SvdSolvPCASklearn = Literal["auto", "full", "arpack", "randomized"] +SvdSolvPCASklearn = Literal["auto", "full", "arpack", "covariance_eigh", "randomized"] SvdSolvTruncatedSVDSklearn = Literal["arpack", "randomized"] SvdSolvPCASparseSklearn = Literal["arpack"] SvdSolvSkearn = SvdSolvPCASklearn | SvdSolvTruncatedSVDSklearn | SvdSolvPCASparseSklearn @@ -116,13 +116,13 @@ def pca( `'arpack'` for the ARPACK wrapper in SciPy (:func:`~scipy.sparse.linalg.svds`) Not available with *dask* arrays. + `'covariance_eigh'` + Classic eigendecomposition of the covariance matrix, suited for tall-and-skinny matrices. `'randomized'` for the randomized algorithm due to Halko (2009). For *dask* arrays, this will use :func:`~dask.array.linalg.svd_compressed`. `'auto'` chooses automatically depending on the size of the problem. - `'lobpcg'` - An alternative SciPy solver. Not available with dask arrays. `'tsqr'` Only available with *dask* arrays. "tsqr" algorithm from Benson et. al. (2013). @@ -133,7 +133,7 @@ def pca( Default value changed from `'auto'` to `'arpack'`. Efficient computation of the principal components of a sparse matrix - currently only works with the `'arpack`' or `'lobpcg'` solvers. + currently only works with the `'arpack`' or `'covariance_eigh`' solver. If X is a *dask* array, *dask-ml* classes :class:`~dask_ml.decomposition.PCA`, :class:`~dask_ml.decomposition.IncrementalPCA`, or diff --git a/tests/test_pca.py b/tests/test_pca.py index fa294ef343..5dca5f2b8a 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -16,6 +16,7 @@ from scipy.sparse import issparse import scanpy as sc +from scanpy.preprocessing._pca import SvdSolver as SvdSolverSupported from testing.scanpy import _helpers from testing.scanpy._helpers.data import pbmc3k_normalized from testing.scanpy._pytest.marks import needs @@ -110,7 +111,8 @@ def array_type(request: pytest.FixtureRequest) -> ArrayType: return request.param -SVDSolver = Literal["auto", "full", "arpack", "randomized", "tsqr", "lobpcg"] +SVDSolverDeprecated = Literal["lobpcg"] +SVDSolver = SvdSolverSupported | SVDSolverDeprecated def gen_pca_params( From bae1610ab2d54213eba5ff2879c6b6f4e2761342 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 18 Oct 2024 19:04:53 +0200 Subject: [PATCH 08/51] Implement sparse `covariance_eigh` PCA using Dask (#3263) Co-authored-by: Ilan Gold --- docs/release-notes/3263.feature.md | 1 + src/scanpy/preprocessing/_pca/__init__.py | 63 ++++-- src/scanpy/preprocessing/_pca/_dask_sparse.py | 206 ++++++++++++++++++ src/testing/scanpy/_helpers/__init__.py | 20 ++ tests/test_pca.py | 165 +++++++++++--- 5 files changed, 405 insertions(+), 50 deletions(-) create mode 100644 docs/release-notes/3263.feature.md create mode 100644 src/scanpy/preprocessing/_pca/_dask_sparse.py diff --git a/docs/release-notes/3263.feature.md b/docs/release-notes/3263.feature.md new file mode 100644 index 0000000000..8e924e1799 --- /dev/null +++ b/docs/release-notes/3263.feature.md @@ -0,0 +1 @@ +Support running {func}`scanpy.pp.pca` on sparse Dask arrays with the `'covariance_eigh'` solver {smaller}`P Angerer` diff --git a/src/scanpy/preprocessing/_pca/__init__.py b/src/scanpy/preprocessing/_pca/__init__.py index 918073d8b7..396781ce7a 100644 --- a/src/scanpy/preprocessing/_pca/__init__.py +++ b/src/scanpy/preprocessing/_pca/__init__.py @@ -44,12 +44,18 @@ SvdSolvTruncatedSVDDaskML = Literal["tsqr", "randomized"] SvdSolvDaskML = SvdSolvPCADaskML | SvdSolvTruncatedSVDDaskML -SvdSolvPCASklearn = Literal["auto", "full", "arpack", "covariance_eigh", "randomized"] +SvdSolvPCADenseSklearn = Literal[ + "auto", "full", "arpack", "covariance_eigh", "randomized" +] +SvdSolvPCASparseSklearn = Literal["arpack", "covariance_eigh"] SvdSolvTruncatedSVDSklearn = Literal["arpack", "randomized"] -SvdSolvPCASparseSklearn = Literal["arpack"] -SvdSolvSkearn = SvdSolvPCASklearn | SvdSolvTruncatedSVDSklearn | SvdSolvPCASparseSklearn +SvdSolvSkearn = ( + SvdSolvPCADenseSklearn | SvdSolvPCASparseSklearn | SvdSolvTruncatedSVDSklearn +) + +SvdSolvPCACustom = Literal["covariance_eigh"] -SvdSolver = SvdSolvDaskML | SvdSolvSkearn +SvdSolver = SvdSolvDaskML | SvdSolvSkearn | SvdSolvPCACustom @_doc_params( @@ -109,6 +115,7 @@ def pca( `None` See `chunked` and `zero_center` descriptions to determine which class will be used. Depending on the class and the type of X different values for default will be set. + For sparse *dask* arrays, will use `'covariance_eigh'`. If *scikit-learn* :class:`~sklearn.decomposition.PCA` is used, will give `'arpack'`, if *scikit-learn* :class:`~sklearn.decomposition.TruncatedSVD` is used, will give `'randomized'`, if *dask-ml* :class:`~dask_ml.decomposition.PCA` or :class:`~dask_ml.decomposition.IncrementalPCA` is used, will give `'auto'`, @@ -124,7 +131,7 @@ def pca( `'auto'` chooses automatically depending on the size of the problem. `'tsqr'` - Only available with *dask* arrays. "tsqr" + Only available with dense *dask* arrays. "tsqr" algorithm from Benson et. al. (2013). .. versionchanged:: 1.9.3 @@ -135,7 +142,8 @@ def pca( Efficient computation of the principal components of a sparse matrix currently only works with the `'arpack`' or `'covariance_eigh`' solver. - If X is a *dask* array, *dask-ml* classes :class:`~dask_ml.decomposition.PCA`, + If X is a sparse *dask* array, a custom `'covariance_eigh'` solver will be used. + If X is a dense *dask* array, *dask-ml* classes :class:`~dask_ml.decomposition.PCA`, :class:`~dask_ml.decomposition.IncrementalPCA`, or :class:`~dask_ml.decomposition.TruncatedSVD` will be used. Otherwise their *scikit-learn* counterparts :class:`~sklearn.decomposition.PCA`, @@ -308,21 +316,40 @@ def pca( X, n_comps, solver=svd_solver, random_state=random_state ) else: - if isinstance(X, DaskArray): - from dask_ml.decomposition import PCA - - svd_solver = _handle_dask_ml_args(svd_solver, PCA) - else: + if not isinstance(X, DaskArray): from sklearn.decomposition import PCA svd_solver = _handle_sklearn_args(svd_solver, PCA, sparse=issparse(X)) + pca_ = PCA( + n_components=n_comps, + svd_solver=svd_solver, + random_state=random_state, + ) + elif issparse(X._meta): + from ._dask_sparse import PCASparseDask - pca_ = PCA( - n_components=n_comps, svd_solver=svd_solver, random_state=random_state - ) + if random_state != 0: + msg = f"Ignoring {random_state=} when using a sparse dask array" + warnings.warn(msg) + if svd_solver not in {None, "covariance_eigh"}: + msg = f"Ignoring {svd_solver=} when using a sparse dask array" + warnings.warn(msg) + pca_ = PCASparseDask(n_components=n_comps) + else: + from dask_ml.decomposition import PCA + + svd_solver = _handle_dask_ml_args(svd_solver, PCA) + pca_ = PCA( + n_components=n_comps, + svd_solver=svd_solver, + random_state=random_state, + ) X_pca = pca_.fit_transform(X) else: if isinstance(X, DaskArray): + if issparse(X._meta): + msg = "Dask sparse arrays do not support zero-centering (yet)" + raise TypeError(msg) from dask_ml.decomposition import TruncatedSVD svd_solver = _handle_dask_ml_args(svd_solver, TruncatedSVD) @@ -436,7 +463,7 @@ def _handle_dask_ml_args( def _handle_dask_ml_args( svd_solver: str | None, method: type[dmld.TruncatedSVD] ) -> SvdSolvTruncatedSVDDaskML: ... -def _handle_dask_ml_args(svd_solver: str | None, method: MethodDaskML) -> str: +def _handle_dask_ml_args(svd_solver: str | None, method: MethodDaskML) -> SvdSolvDaskML: import dask_ml.decomposition as dmld args: tuple[SvdSolvDaskML, ...] @@ -461,14 +488,14 @@ def _handle_sklearn_args( @overload def _handle_sklearn_args( svd_solver: str | None, method: type[skld.PCA], *, sparse: Literal[False] -) -> SvdSolvPCASklearn: ... +) -> SvdSolvPCADenseSklearn: ... @overload def _handle_sklearn_args( svd_solver: str | None, method: type[skld.PCA], *, sparse: Literal[True] ) -> SvdSolvPCASparseSklearn: ... def _handle_sklearn_args( svd_solver: str | None, method: MethodSklearn, *, sparse: bool | None = None -) -> str: +) -> SvdSolvSkearn: import sklearn.decomposition as skld args: tuple[SvdSolvSkearn, ...] @@ -479,7 +506,7 @@ def _handle_sklearn_args( args = get_args(SvdSolvTruncatedSVDSklearn) default = "randomized" case (skld.PCA, False): - args = get_args(SvdSolvPCASklearn) + args = get_args(SvdSolvPCADenseSklearn) default = "arpack" case (skld.PCA, True): args = get_args(SvdSolvPCASparseSklearn) diff --git a/src/scanpy/preprocessing/_pca/_dask_sparse.py b/src/scanpy/preprocessing/_pca/_dask_sparse.py new file mode 100644 index 0000000000..6123dadec5 --- /dev/null +++ b/src/scanpy/preprocessing/_pca/_dask_sparse.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, cast, overload + +import numpy as np +import scipy.linalg +from numpy.typing import NDArray + +from scanpy._utils._doctests import doctest_needs + +from .._utils import _get_mean_var + +if TYPE_CHECKING: + from typing import Literal + + from numpy.typing import DTypeLike + from scipy import sparse + + from ..._compat import DaskArray + + CSMatrix = sparse.csr_matrix | sparse.csc_matrix + + +@dataclass +class PCASparseDask: + n_components: int | None = None + + @doctest_needs("dask") + def fit(self, x: DaskArray) -> PCASparseDaskFit: + """Fit the model on `x`. + + This method transforms `self` into a `PCASparseDaskFit` object and returns it. + + Examples + -------- + >>> import dask.array as da + >>> import scipy.sparse as sp + >>> x = ( + ... da.array(sp.random(100, 200, density=0.3, dtype="float32").toarray()) + ... .rechunk((10, -1)) + ... .map_blocks(sp.csr_matrix) + ... ) + >>> x + dask.array + >>> pca_fit = PCASparseDask().fit(x) + >>> assert isinstance(pca_fit, PCASparseDaskFit) + >>> pca_fit.transform(x) + dask.array + """ + self.__class__ = PCASparseDaskFit + self = cast(PCASparseDaskFit, self) + + self.n_components_ = ( + min(x.shape) if self.n_components is None else self.n_components + ) + self.n_samples_ = x.shape[0] + self.n_features_in_ = x.shape[1] if x.ndim > 1 else 1 + self.dtype_ = x.dtype + covariance, self.mean_ = _cov_sparse_dask(x) + self.explained_variance_, self.components_ = scipy.linalg.eigh( + covariance, lower=False + ) + + # Arrange eigenvectors and eigenvalues in descending order + self.explained_variance_ = self.explained_variance_[::-1] + self.components_ = np.flip(self.components_, axis=1) + self.components_ = self.components_.T[: self.n_components_, :] + + self.explained_variance_ratio_ = self.explained_variance_ / np.sum( + self.explained_variance_ + ) + if self.n_components_ < min(self.n_samples_, self.n_features_in_): + self.noise_variance_ = self.explained_variance_[self.n_components_ :].mean() + else: + self.noise_variance_ = np.array([0.0]) + self.explained_variance_ = self.explained_variance_[: self.n_components_] + + self.explained_variance_ratio_ = self.explained_variance_ratio_[ + : self.n_components_ + ] + return self + + def fit_transform(self, x: DaskArray, y: DaskArray | None = None) -> DaskArray: + if y is None: + y = x + return self.fit(x).transform(y) + + +@dataclass +class PCASparseDaskFit(PCASparseDask): + n_components_: int = field(init=False) + n_samples_: int = field(init=False) + n_features_in_: int = field(init=False) + dtype_: np.dtype = field(init=False) + mean_: NDArray[np.floating] = field(init=False) + components_: NDArray[np.floating] = field(init=False) + explained_variance_: NDArray[np.floating] = field(init=False) + explained_variance_ratio_: NDArray[np.floating] = field(init=False) + noise_variance_: NDArray[np.floating] = field(init=False) + + def transform(self, x: DaskArray) -> DaskArray: + if TYPE_CHECKING: + # The type checker does not understand imports from dask.array + import dask.array.core as da + else: + import dask.array as da + + def transform_block( + x_part: CSMatrix, + mean_: NDArray[np.floating], + components_: NDArray[np.floating], + ): + pre_mean = mean_ @ components_.T + mean_impact = np.ones((x_part.shape[0], 1)) @ pre_mean.reshape(1, -1) + return (x_part @ components_.T) - mean_impact + + return da.map_blocks( + transform_block, + x, + mean_=self.mean_, + components_=self.components_, + chunks=(x.chunks[0], self.n_components_), + meta=np.zeros([0], dtype=x.dtype), + dtype=x.dtype, + ) + + +@overload +def _cov_sparse_dask( + x: DaskArray, *, return_gram: Literal[False] = False, dtype: DTypeLike | None = None +) -> tuple[NDArray[np.floating], NDArray[np.floating]]: ... +@overload +def _cov_sparse_dask( + x: DaskArray, *, return_gram: Literal[True], dtype: DTypeLike | None = None +) -> tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]: ... +def _cov_sparse_dask( + x: DaskArray, *, return_gram: bool = False, dtype: DTypeLike | None = None +) -> ( + tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]] + | tuple[NDArray[np.floating], NDArray[np.floating]] +): + """\ + Computes the covariance matrix and row/col means of matrix `x`. + + Parameters + ---------- + + x + A sparse matrix + return_gram + If `True`, the gram matrix will be returned and a copy will be created + to store the results of the covariance, + while if `False`, the local gram matrix result will be overwritten. + (only used for unit testing at the moment) + dtype + The data type of the result (excluding the means) + + Returns + ------- + + :math:`\\cov(X, X)` + The covariance matrix of `x` in the form :math:`\\cov(X, X) = \\E(XX) - \\E(X)\\E(X)`. + :math:`\\gram(X, X)` + When return_gram is `True`, the gram matrix of `x` in the form :math:`\\frac{1}{n} X.T \\dot X`. + :math:`\\mean(X)` + The row means of `x`. + """ + if TYPE_CHECKING: + import dask.array.core as da + import dask.base as dask + else: + import dask + import dask.array as da + + if dtype is None: + dtype = np.float64 if np.issubdtype(x.dtype, np.integer) else x.dtype + else: + dtype = np.dtype(dtype) + + def gram_block(x_part: CSMatrix): + gram_matrix: CSMatrix = x_part.T @ x_part + return gram_matrix.toarray()[None, ...] # need new axis for summing + + gram_matrix_dask: DaskArray = da.map_blocks( + gram_block, + x, + new_axis=(1,), + chunks=((1,) * x.blocks.size, (x.shape[1],), (x.shape[1],)), + meta=np.array([], dtype=x.dtype), + dtype=x.dtype, + ).sum(axis=0) + mean_x_dask, _ = _get_mean_var(x) + gram_matrix, mean_x = cast( + tuple[NDArray, NDArray[np.float64]], + dask.compute(gram_matrix_dask, mean_x_dask), + ) + gram_matrix = gram_matrix.astype(dtype) + gram_matrix /= x.shape[0] + + cov_result = gram_matrix.copy() if return_gram else gram_matrix + cov_result -= mean_x[:, None] @ mean_x[None, :] + + if return_gram: + return cov_result, gram_matrix, mean_x + return cov_result, mean_x diff --git a/src/testing/scanpy/_helpers/__init__.py b/src/testing/scanpy/_helpers/__init__.py index 449eef9c57..0c59eb592f 100644 --- a/src/testing/scanpy/_helpers/__init__.py +++ b/src/testing/scanpy/_helpers/__init__.py @@ -5,6 +5,8 @@ from __future__ import annotations import warnings +from contextlib import AbstractContextManager +from dataclasses import dataclass from itertools import permutations from typing import TYPE_CHECKING @@ -14,6 +16,8 @@ import scanpy as sc if TYPE_CHECKING: + from collections.abc import MutableSequence + from scanpy._compat import DaskArray # TODO: Report more context on the fields being compared on error @@ -138,3 +142,19 @@ def as_sparse_dask_array(*args, **kwargs) -> DaskArray: from anndata.tests.helpers import as_sparse_dask_array return as_sparse_dask_array(*args, **kwargs) + + +@dataclass(init=False) +class MultiContext(AbstractContextManager): + contexts: MutableSequence[AbstractContextManager] + + def __init__(self, *contexts: AbstractContextManager): + self.contexts = list(contexts) + + def __enter__(self): + for ctx in self.contexts: + ctx.__enter__() + + def __exit__(self, exc_type, exc_value, traceback): + for ctx in reversed(self.contexts): + ctx.__exit__(exc_type, exc_value, traceback) diff --git a/tests/test_pca.py b/tests/test_pca.py index 5dca5f2b8a..1439ea788d 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -3,7 +3,7 @@ import warnings from contextlib import nullcontext from functools import wraps -from typing import TYPE_CHECKING, Literal, cast, get_args +from typing import TYPE_CHECKING, Literal, get_args import anndata as ad import numpy as np @@ -16,23 +16,20 @@ from scipy.sparse import issparse import scanpy as sc +from scanpy._compat import DaskArray, pkg_version from scanpy.preprocessing._pca import SvdSolver as SvdSolverSupported +from scanpy.preprocessing._pca._dask_sparse import _cov_sparse_dask from testing.scanpy import _helpers from testing.scanpy._helpers.data import pbmc3k_normalized from testing.scanpy._pytest.marks import needs from testing.scanpy._pytest.params import ARRAY_TYPES as ARRAY_TYPES_ALL -from testing.scanpy._pytest.params import ( - ARRAY_TYPES_SPARSE_DASK_UNSUPPORTED, - param_with, -) +from testing.scanpy._pytest.params import param_with if TYPE_CHECKING: from collections.abc import Callable, Generator from anndata.typing import ArrayDataStructureType - from scanpy._compat import DaskArray - ArrayType = Callable[[np.ndarray], ArrayDataStructureType] @@ -70,6 +67,18 @@ ) +if pkg_version("anndata") < Version("0.9"): + + def to_memory(self: AnnData, *, copy: bool = False) -> AnnData: + """Compatibility version of AnnData.to_memory() that works with old AnnData versions""" + adata = self + if adata.isbacked: + adata = adata.to_memory() + return adata.copy() if copy else adata +else: + to_memory = AnnData.to_memory + + def _chunked_1d( f: Callable[[np.ndarray], DaskArray], ) -> Callable[[np.ndarray], DaskArray]: @@ -99,10 +108,12 @@ def maybe_convert_array_to_dask(array_type): ARRAY_TYPES = [ - param_with(at, maybe_convert_array_to_dask, marks=[needs.dask_ml]) - if "dask" in cast(str, at.id) - else at - for at in ARRAY_TYPES_SPARSE_DASK_UNSUPPORTED + param_with( + at, + maybe_convert_array_to_dask, + marks=[needs.dask_ml] if at.id == "dask_array_dense" else [], + ) + for at in ARRAY_TYPES_ALL ] @@ -120,18 +131,24 @@ def gen_pca_params( array_type: ArrayType, svd_solver_type: Literal[None, "valid", "invalid"], zero_center: bool, -) -> Generator[tuple[SVDSolver, str | None] | tuple[None, None], None, None]: +) -> Generator[tuple[SVDSolver | None, str | None, str | None], None, None]: + if array_type is DASK_CONVERTERS[_helpers.as_sparse_dask_array] and not zero_center: + xfail_reason = "Sparse-in-dask with zero_center=False not implemented yet" + yield None, None, xfail_reason + return if svd_solver_type is None: - yield None, None + yield None, None, None return all_svd_solvers = set(get_args(SVDSolver)) svd_solvers: set[SVDSolver] match array_type, zero_center: - case (dc, True) if dc in DASK_CONVERTERS.values(): + case (dc, True) if dc is DASK_CONVERTERS[_helpers.as_dense_dask_array]: svd_solvers = {"auto", "full", "tsqr", "randomized"} - case (dc, False) if dc in DASK_CONVERTERS.values(): + case (dc, False) if dc is DASK_CONVERTERS[_helpers.as_dense_dask_array]: svd_solvers = {"tsqr", "randomized"} + case (dc, True) if dc is DASK_CONVERTERS[_helpers.as_sparse_dask_array]: + svd_solvers = {"covariance_eigh"} case ((sparse.csr_matrix | sparse.csc_matrix), True): svd_solvers = {"arpack"} case ((sparse.csr_matrix | sparse.csc_matrix), False): @@ -141,15 +158,15 @@ def gen_pca_params( case (helpers.asarray, False): svd_solvers = {"arpack", "randomized"} case _: - pytest.fail(f"Unknown array type {array_type}") + pytest.fail(f"Unknown {array_type=} ({zero_center=})") if svd_solver_type == "invalid": svd_solvers = all_svd_solvers - svd_solvers - warn_pat_expected = r"Ignoring" + warn_pat_expected = r"Ignoring svd_solver" elif svd_solver_type == "valid": warn_pat_expected = None else: - pytest.fail(f"Unknown svd_solver_type {svd_solver_type}") + pytest.fail(f"Unknown {svd_solver_type=}") for svd_solver in svd_solvers: # explicit check for special case @@ -161,7 +178,7 @@ def gen_pca_params( pat = r"legacy code" else: pat = warn_pat_expected - yield (svd_solver, pat) + yield (svd_solver, pat, None) @pytest.mark.parametrize( @@ -172,13 +189,20 @@ def gen_pca_params( zero_center, svd_solver, warn_pat_expected, - marks=array_type.marks, - id=f"{array_type.id}-{'zero_center' if zero_center else 'no_zero_center'}-{svd_solver}-{warn_pat_expected}", + marks=( + array_type.marks + if xfail_reason is None + else [pytest.mark.xfail(reason=xfail_reason)] + ), + id=( + f"{array_type.id}-{'zero_center' if zero_center else 'no_zero_center'}-" + f"{svd_solver or svd_solver_type}-{'xfail' if xfail_reason else warn_pat_expected}" + ), ) for array_type in ARRAY_TYPES for zero_center in [True, False] for svd_solver_type in [None, "valid", "invalid"] - for svd_solver, warn_pat_expected in gen_pca_params( + for svd_solver, warn_pat_expected, xfail_reason in gen_pca_params( array_type=array_type.values[0], zero_center=zero_center, svd_solver_type=svd_solver_type, @@ -217,6 +241,7 @@ def test_pca_transform(array_type): warnings.filterwarnings("error") sc.pp.pca(adata, n_comps=4, zero_center=True, dtype="float64") + adata = to_memory(adata) assert np.linalg.norm(A_pca_abs[:, :4] - np.abs(adata.obsm["X_pca"])) < 2e-05 @@ -225,11 +250,20 @@ def test_pca_transform_randomized(array_type): A_pca_abs = np.abs(A_pca) warnings.filterwarnings("error") - with ( - pytest.warns(UserWarning, match="Ignoring.*'randomized'") - if sparse.issparse(adata.X) - else nullcontext() - ): + if isinstance(adata.X, DaskArray) and issparse(adata.X._meta): + patterns = ( + r"Ignoring random_state=14 when using a sparse dask array", + r"Ignoring svd_solver='randomized' when using a sparse dask array", + ) + ctx = _helpers.MultiContext( + *(pytest.warns(UserWarning, match=pattern) for pattern in patterns) + ) + elif sparse.issparse(adata.X): + ctx = pytest.warns(UserWarning, match=r"Ignoring.*'randomized") + else: + ctx = nullcontext() + + with ctx: sc.pp.pca( adata, n_comps=4, @@ -242,9 +276,12 @@ def test_pca_transform_randomized(array_type): assert np.linalg.norm(A_pca_abs[:, :4] - np.abs(adata.obsm["X_pca"])) < 2e-05 -def test_pca_transform_no_zero_center(array_type): +def test_pca_transform_no_zero_center(request: pytest.FixtureRequest, array_type): adata = AnnData(array_type(A_list).astype("float32")) A_svd_abs = np.abs(A_svd) + if isinstance(adata.X, DaskArray) and issparse(adata.X._meta): + reason = "TruncatedSVD is not supported for sparse Dask yet" + request.applymarker(pytest.mark.xfail(reason=reason)) warnings.filterwarnings("error") sc.pp.pca(adata, n_comps=4, zero_center=False, dtype="float64", random_state=14) @@ -308,14 +345,23 @@ def test_pca_reproducible(array_type): pbmc = pbmc3k_normalized() pbmc.X = array_type(pbmc.X) - a = sc.pp.pca(pbmc, copy=True, dtype=np.float64, random_state=42) - b = sc.pp.pca(pbmc, copy=True, dtype=np.float64, random_state=42) - c = sc.pp.pca(pbmc, copy=True, dtype=np.float64, random_state=0) + with ( + pytest.warns(UserWarning, match=r"Ignoring random_state.*sparse dask array") + if isinstance(pbmc.X, DaskArray) and issparse(pbmc.X._meta) + else nullcontext() + ): + a = sc.pp.pca(pbmc, copy=True, dtype=np.float64, random_state=42) + b = sc.pp.pca(pbmc, copy=True, dtype=np.float64, random_state=42) + c = sc.pp.pca(pbmc, copy=True, dtype=np.float64, random_state=0) assert_equal(a, b) + # Test that changing random seed changes result # Does not show up reliably with 32 bit computation - assert not np.array_equal(a.obsm["X_pca"], c.obsm["X_pca"]) + # sparse-in-dask doesn’t use a random seed, so it also doesn’t work there. + if not (isinstance(pbmc.X, DaskArray) and issparse(pbmc.X._meta)): + a, c = map(to_memory, [a, c]) + assert not np.array_equal(a.obsm["X_pca"], c.obsm["X_pca"]) def test_pca_chunked(): @@ -404,6 +450,7 @@ def test_mask_var_argument_equivalence(float_dtype, array_type): adata_w_mask.var["mask"] = mask_var sc.pp.pca(adata_w_mask, mask_var="mask", dtype=float_dtype) + adata, adata_w_mask = map(to_memory, [adata, adata_w_mask]) assert np.allclose( adata.X.toarray() if issparse(adata.X) else adata.X, adata_w_mask.X.toarray() if issparse(adata_w_mask.X) else adata_w_mask.X, @@ -470,8 +517,11 @@ def test_mask_defaults(array_type, float_dtype): with_var = sc.pp.pca(adata, copy=True, dtype=float_dtype) assert without_var.uns["pca"]["params"]["mask_var"] is None assert with_var.uns["pca"]["params"]["mask_var"] == "highly_variable" + without_var, with_var = map(to_memory, [without_var, with_var]) assert not np.array_equal(without_var.obsm["X_pca"], with_var.obsm["X_pca"]) + with_no_mask = sc.pp.pca(adata, mask_var=None, copy=True, dtype=float_dtype) + with_no_mask = to_memory(with_no_mask) assert np.array_equal(without_var.obsm["X_pca"], with_no_mask.obsm["X_pca"]) @@ -499,3 +549,54 @@ def test_pca_layer(): ) np.testing.assert_equal(X_adata.obsm["X_pca"], layer_adata.obsm["X_pca"]) np.testing.assert_equal(X_adata.varm["PCs"], layer_adata.varm["PCs"]) + + +# Skipping these tests during min-deps testing shouldn't be an issue because the sparse-in-dask feature is not available on anndata<0.10 anyway +needs_anndata_dask = pytest.mark.skipif( + pkg_version("anndata") < Version("0.10"), + reason="Old AnnData doesn’t have dask test helpers", +) + + +@needs.dask +@needs_anndata_dask +@pytest.mark.parametrize( + "other_array_type", + [lambda x: x.toarray(), DASK_CONVERTERS[_helpers.as_sparse_dask_array]], + ids=["dense-mem", "sparse-dask"], +) +def test_covariance_eigh_impls(other_array_type): + warnings.filterwarnings("error") + + adata_sparse_mem = pbmc3k_normalized()[:200, :100].copy() + adata_other = adata_sparse_mem.copy() + adata_other.X = other_array_type(adata_other.X) + + sc.pp.pca(adata_sparse_mem, svd_solver="covariance_eigh") + sc.pp.pca(adata_other, svd_solver="covariance_eigh") + + to_memory(adata_other) + np.testing.assert_allclose( + np.abs(adata_sparse_mem.obsm["X_pca"]), np.abs(adata_other.obsm["X_pca"]) + ) + + +@needs.dask +@needs_anndata_dask +@pytest.mark.parametrize( + ("dtype", "dtype_arg", "rtol"), + [ + pytest.param(np.float32, None, 1e-5, id="float32"), + pytest.param(np.float32, np.float64, None, id="float32-float64"), + pytest.param(np.float64, None, None, id="float64"), + pytest.param(np.int64, None, None, id="int64"), + ], +) +def test_cov_sparse_dask(dtype, dtype_arg, rtol): + x_arr = A_list.astype(dtype) + x = DASK_CONVERTERS[_helpers.as_sparse_dask_array](x_arr) + cov, gram, mean = _cov_sparse_dask(x, return_gram=True, dtype=dtype_arg) + np.testing.assert_allclose(mean, np.mean(x_arr, axis=0)) + np.testing.assert_allclose(gram, (x_arr.T @ x_arr) / x.shape[0]) + tol_args = dict(rtol=rtol) if rtol is not None else {} + np.testing.assert_allclose(cov, np.cov(x_arr, rowvar=False, bias=True), **tol_args) From 5e8eca9ce7db07555e50f1de8015ad2fadfb9af4 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Mon, 21 Oct 2024 14:29:41 +0200 Subject: [PATCH 09/51] Fix HVG with 1-obs batches (#3286) --- docs/release-notes/3286.bugfix.md | 1 + src/scanpy/preprocessing/_utils.py | 3 ++- tests/test_highly_variable_genes.py | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 docs/release-notes/3286.bugfix.md diff --git a/docs/release-notes/3286.bugfix.md b/docs/release-notes/3286.bugfix.md new file mode 100644 index 0000000000..164758a2fa --- /dev/null +++ b/docs/release-notes/3286.bugfix.md @@ -0,0 +1 @@ +Fix {func}`scanpy.pp.highly_variable_genes` for batches of size 1 {smaller}`P Angerer` diff --git a/src/scanpy/preprocessing/_utils.py b/src/scanpy/preprocessing/_utils.py index 64adb036d9..f5ba280cfd 100644 --- a/src/scanpy/preprocessing/_utils.py +++ b/src/scanpy/preprocessing/_utils.py @@ -40,7 +40,8 @@ def _get_mean_var( mean_sq = axis_mean(elem_mul(X, X), axis=axis, dtype=np.float64) var = mean_sq - mean**2 # enforce R convention (unbiased estimator) for variance - var *= X.shape[axis] / (X.shape[axis] - 1) + if X.shape[axis] != 1: + var *= X.shape[axis] / (X.shape[axis] - 1) return mean, var diff --git a/tests/test_highly_variable_genes.py b/tests/test_highly_variable_genes.py index 0f08b853e0..7d9fdac9fa 100644 --- a/tests/test_highly_variable_genes.py +++ b/tests/test_highly_variable_genes.py @@ -557,6 +557,14 @@ def test_batches(): assert np.all(np.isin(colnames, hvg1.columns)) +def test_degenerate_batches(): + adata = AnnData( + X=np.random.randn(10, 100), + obs=dict(batch=pd.Categorical([*([1] * 4), *([2] * 5), 3])), + ) + sc.pp.highly_variable_genes(adata, batch_key="batch") + + @needs.skmisc def test_seurat_v3_mean_var_output_with_batchkey(): pbmc = pbmc3k() From f0b8d6bc491ac85d31e81e9c469bbf10aecbf55f Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Mon, 21 Oct 2024 16:26:01 +0200 Subject: [PATCH 10/51] Allow specifying a collection of colors to scatterplots (#3299) Co-authored-by: Ilan Gold Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/release-notes/3299.bugfix.md | 1 + src/scanpy/plotting/_anndata.py | 135 +++++++++++------- .../expected.png | Bin 0 -> 32286 bytes tests/test_plotting.py | 31 ++-- tests/test_plotting_utils.py | 33 ++++- 5 files changed, 125 insertions(+), 75 deletions(-) create mode 100644 docs/release-notes/3299.bugfix.md create mode 100644 tests/_images/scatter_HES_percent_mito_n_genes_bulk_labels/expected.png diff --git a/docs/release-notes/3299.bugfix.md b/docs/release-notes/3299.bugfix.md new file mode 100644 index 0000000000..1b0b512ad2 --- /dev/null +++ b/docs/release-notes/3299.bugfix.md @@ -0,0 +1 @@ +Fix {func}`scanpy.pl.scatter`’s `color` parameter to take collections as advertised {smaller}`P Angerer` diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index aadd0ac6ac..f3474fe27b 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -6,7 +6,7 @@ from collections.abc import Collection, Mapping, Sequence from itertools import product from types import NoneType -from typing import TYPE_CHECKING, get_args +from typing import TYPE_CHECKING, cast, get_args import matplotlib as mpl import numpy as np @@ -31,6 +31,7 @@ doc_vboundnorm, ) from ._utils import ( + ColorLike, _deprecated_scale, _dk, check_colornorm, @@ -47,12 +48,12 @@ from cycler import Cycler from matplotlib.axes import Axes from matplotlib.colors import Colormap, ListedColormap, Normalize + from numpy.typing import NDArray from seaborn import FacetGrid from seaborn.matrix import ClusterGrid from .._utils import Empty from ._utils import ( - ColorLike, DensityNorm, _FontSize, _FontWeight, @@ -90,7 +91,7 @@ def scatter( x: str | None = None, y: str | None = None, *, - color: str | Collection[str] | None = None, + color: str | ColorLike | Collection[str | ColorLike] | None = None, use_raw: bool | None = None, layers: str | Collection[str] | None = None, sort_order: bool = True, @@ -110,7 +111,7 @@ def scatter( left_margin: float | None = None, size: int | float | None = None, marker: str | Sequence[str] = ".", - title: str | None = None, + title: str | Collection[str] | None = None, show: bool | None = None, save: str | bool | None = None, ax: Axes | None = None, @@ -148,33 +149,25 @@ def scatter( ------- If `show==False` a :class:`~matplotlib.axes.Axes` or a list of it. """ + # color can be a obs column name or a matplotlib color specification (or a collection thereof) + if color is not None: + color = cast( + Collection[str | ColorLike], + [color] if isinstance(color, str) or is_color_like(color) else color, + ) args = locals() - if _check_use_raw(adata, use_raw): - var_index = adata.raw.var.index - else: - var_index = adata.var.index + if basis is not None: return _scatter_obs(**args) if x is None or y is None: raise ValueError("Either provide a `basis` or `x` and `y`.") - if ( - (x in adata.obs.columns or x in var_index) - and (y in adata.obs.columns or y in var_index) - and (color is None or color in adata.obs.columns or color in var_index) - ): + if _check_if_annotations(adata, "obs", x=x, y=y, colors=color, use_raw=use_raw): return _scatter_obs(**args) - if ( - (x in adata.var.columns or x in adata.obs.index) - and (y in adata.var.columns or y in adata.obs.index) - and (color is None or color in adata.var.columns or color in adata.obs.index) - ): - adata_T = adata.T - axs = _scatter_obs( - adata=adata_T, - **{name: val for name, val in args.items() if name != "adata"}, - ) + if _check_if_annotations(adata, "var", x=x, y=y, colors=color, use_raw=use_raw): + args_t = {**args, "adata": adata.T} + axs = _scatter_obs(**args_t) # store .uns annotations that were added to the new adata object - adata.uns = adata_T.uns + adata.uns = args_t["adata"].uns return axs raise ValueError( "`x`, `y`, and potential `color` inputs must all " @@ -182,35 +175,74 @@ def scatter( ) +def _check_if_annotations( + adata: AnnData, + axis_name: Literal["obs", "var"], + *, + x: str | None = None, + y: str | None = None, + colors: Collection[str | ColorLike] | None = None, + use_raw: bool | None = None, +) -> bool: + """Checks if `x`, `y`, and `colors` are annotations of `adata`. + In the case of `colors`, valid matplotlib colors are also accepted. + + If `axis_name` is `obs`, checks in `adata.obs.columns` and `adata.var_names`, + if `axis_name` is `var`, checks in `adata.var.columns` and `adata.obs_names`. + """ + annotations: pd.Index[str] = getattr(adata, axis_name).columns + other_ax_obj = ( + adata.raw if _check_use_raw(adata, use_raw) and axis_name == "obs" else adata + ) + names: pd.Index[str] = getattr( + other_ax_obj, "var" if axis_name == "obs" else "obs" + ).index + + def is_annotation(needle: pd.Index) -> NDArray[np.bool]: + return needle.isin({None}) | needle.isin(annotations) | needle.isin(names) + + if not is_annotation(pd.Index([x, y])).all(): + return False + + color_idx = pd.Index(colors if colors is not None else []) + # Colors are valid + color_valid: NDArray[np.bool] = np.fromiter( + map(is_color_like, color_idx), dtype=np.bool, count=len(color_idx) + ) + # Annotation names are valid too + color_valid[~color_valid] = is_annotation(color_idx[~color_valid]) + return bool(color_valid.all()) + + def _scatter_obs( *, adata: AnnData, - x=None, - y=None, - color=None, - use_raw=None, - layers=None, - sort_order=True, - alpha=None, - basis=None, - groups=None, - components=None, + x: str | None = None, + y: str | None = None, + color: Collection[str | ColorLike] | None = None, + use_raw: bool | None = None, + layers: str | Collection[str] | None = None, + sort_order: bool = True, + alpha: float | None = None, + basis: _Basis | None = None, + groups: str | Iterable[str] | None = None, + components: str | Collection[str] | None = None, projection: Literal["2d", "3d"] = "2d", legend_loc: _LegendLoc | None = "right margin", - legend_fontsize=None, - legend_fontweight=None, - legend_fontoutline=None, - color_map=None, - palette=None, - frameon=None, - right_margin=None, - left_margin=None, + legend_fontsize: int | float | _FontSize | None = None, + legend_fontweight: int | _FontWeight | None = None, + legend_fontoutline: float | None = None, + color_map: str | Colormap | None = None, + palette: Cycler | ListedColormap | ColorLike | Sequence[ColorLike] | None = None, + frameon: bool | None = None, + right_margin: float | None = None, + left_margin: float | None = None, size: int | float | None = None, - marker=".", - title=None, - show=None, - save=None, - ax=None, + marker: str | Sequence[str] = ".", + title: str | Collection[str] | None = None, + show: bool | None = None, + save: str | bool | None = None, + ax: Axes | None = None, ) -> Axes | list[Axes] | None: """See docstring of scatter.""" sanitize_anndata(adata) @@ -245,14 +277,7 @@ def _scatter_obs( if isinstance(components, str): components = components.split(",") components = np.array(components).astype(int) - 1 - # color can be a obs column name or a matplotlib color specification - keys = ( - ["grey"] - if color is None - else [color] - if isinstance(color, str) or is_color_like(color) - else color - ) + keys = ["grey"] if color is None else color if title is not None and isinstance(title, str): title = [title] highlights = adata.uns.get("highlights", []) diff --git a/tests/_images/scatter_HES_percent_mito_n_genes_bulk_labels/expected.png b/tests/_images/scatter_HES_percent_mito_n_genes_bulk_labels/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..2609a53567e96d603c53f09bde0b205ccbfb8633 GIT binary patch literal 32286 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 F{{rDuc})NS literal 0 HcmV?d00001 diff --git a/tests/test_plotting.py b/tests/test_plotting.py index b34db78780..efdfe26fcd 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1337,7 +1337,9 @@ def test_scatter_specify_layer_and_raw(): sc.pl.umap(pbmc, color="HES4", use_raw=True, layer="layer") -@pytest.mark.parametrize("color", ["n_genes", "bulk_labels"]) +@pytest.mark.parametrize( + "color", ["n_genes", "bulk_labels", ["n_genes", "bulk_labels"]] +) def test_scatter_no_basis_per_obs(image_comparer, color): """Test scatterplot of per-obs points with no basis""" @@ -1353,7 +1355,8 @@ def test_scatter_no_basis_per_obs(image_comparer, color): # palette only applies to categorical, i.e. color=='bulk_labels' palette="Set2", ) - save_and_compare_images(f"scatter_HES_percent_mito_{color}") + color_str = color if isinstance(color, str) else "_".join(color) + save_and_compare_images(f"scatter_HES_percent_mito_{color_str}") def test_scatter_no_basis_per_var(image_comparer): @@ -1373,29 +1376,19 @@ def pbmc_filtered() -> Callable[[], AnnData]: return pbmc.copy -def test_scatter_no_basis_raw(check_same_image, pbmc_filtered, tmpdir): - adata = pbmc_filtered() - +@pytest.mark.parametrize("use_raw", [True, None]) +def test_scatter_no_basis_raw(check_same_image, pbmc_filtered, tmp_path, use_raw): """Test scatterplots of raw layer with no basis.""" - path1 = tmpdir / "scatter_EGFL7_F12_FAM185A_rawNone.png" - path2 = tmpdir / "scatter_EGFL7_F12_FAM185A_rawTrue.png" - path3 = tmpdir / "scatter_EGFL7_F12_FAM185A_rawToAdata.png" + adata = pbmc_filtered() - sc.pl.scatter(adata, x="EGFL7", y="F12", color="FAM185A", use_raw=None) - plt.savefig(path1) - plt.close() + sc.pl.scatter(adata.raw.to_adata(), x="EGFL7", y="F12", color="FAM185A") + plt.savefig(path1 := tmp_path / "scatter-raw-to-adata.png") - # is equivalent to: - sc.pl.scatter(adata, x="EGFL7", y="F12", color="FAM185A", use_raw=True) - plt.savefig(path2) + sc.pl.scatter(adata, x="EGFL7", y="F12", color="FAM185A", use_raw=use_raw) + plt.savefig(path2 := tmp_path / f"scatter-{use_raw=}.png") plt.close() - # and also to: - sc.pl.scatter(adata.raw.to_adata(), x="EGFL7", y="F12", color="FAM185A") - plt.savefig(path3) - check_same_image(path1, path2, tol=15) - check_same_image(path1, path3, tol=15) @pytest.mark.parametrize( diff --git a/tests/test_plotting_utils.py b/tests/test_plotting_utils.py index 6b53cd5b50..2090ffde38 100644 --- a/tests/test_plotting_utils.py +++ b/tests/test_plotting_utils.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import cast +from string import ascii_lowercase, ascii_uppercase +from typing import TYPE_CHECKING, cast import numpy as np import pytest @@ -8,8 +9,13 @@ from matplotlib import colormaps from matplotlib.colors import ListedColormap +from scanpy.plotting._anndata import _check_if_annotations from scanpy.plotting._utils import _validate_palette +if TYPE_CHECKING: + from typing import Any, Literal + + viridis = cast(ListedColormap, colormaps["viridis"]) @@ -27,3 +33,28 @@ def test_validate_palette_no_mod(palette, typ): adata = AnnData(uns=dict(test_colors=palette)) _validate_palette(adata, "test") assert palette is adata.uns["test_colors"], "Palette should not be modified" + + +@pytest.mark.parametrize( + ("axis_name", "args", "expected"), + [ + pytest.param("obs", {}, True, id="valid-nothing"), + pytest.param("obs", dict(x="B", colors=["obs_a"]), True, id="valid-basic"), + pytest.param("var", dict(colors=["A", "C", "obs_a"]), False, id="invalid-axis"), + pytest.param("obs", dict(x="A"), True, id="valid-raw"), + pytest.param("obs", dict(x="A", use_raw=False), False, id="invalid-noraw"), + pytest.param("obs", dict(colors=[(0, 0, 0), "red"]), True, id="valid-color"), + ], +) +def test_check_all_in_axis( + *, axis_name: Literal["obs", "var"], args: dict[str, Any], expected: bool +): + raw = AnnData( + np.random.randn(10, 20), + dict(obs_a=range(10), obs_names=list(ascii_lowercase[:10])), + dict(var_a=range(20), var_names=list(ascii_uppercase[:20])), + ) + adata = raw[:, 1:].copy() + adata.raw = raw + + assert _check_if_annotations(adata, axis_name, **args) is expected From 6d234a7ec8c33348b7778e9ccb8fe7f226e4723d Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Tue, 22 Oct 2024 10:49:31 +0200 Subject: [PATCH 11/51] (fix): correct anndata release for `io` usage (#3298) --- src/scanpy/__init__.py | 2 +- src/scanpy/readwrite.py | 2 +- src/testing/scanpy/_pytest/fixtures/data.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/scanpy/__init__.py b/src/scanpy/__init__.py index ae56a974f3..bbcc86437b 100644 --- a/src/scanpy/__init__.py +++ b/src/scanpy/__init__.py @@ -33,7 +33,7 @@ import anndata -if Version(anndata.__version__) >= Version("0.11.0rc0"): +if Version(anndata.__version__) >= Version("0.11.0rc2"): from anndata.io import ( read_csv, read_excel, diff --git a/src/scanpy/readwrite.py b/src/scanpy/readwrite.py index 3fbf8ef61c..cb75eb10cc 100644 --- a/src/scanpy/readwrite.py +++ b/src/scanpy/readwrite.py @@ -12,7 +12,7 @@ import pandas as pd from packaging.version import Version -if Version(anndata.__version__) >= Version("0.11.0rc0"): +if Version(anndata.__version__) >= Version("0.11.0rc2"): from anndata.io import ( read_csv, read_excel, diff --git a/src/testing/scanpy/_pytest/fixtures/data.py b/src/testing/scanpy/_pytest/fixtures/data.py index 4e5762f6cb..4d44d8239b 100644 --- a/src/testing/scanpy/_pytest/fixtures/data.py +++ b/src/testing/scanpy/_pytest/fixtures/data.py @@ -17,7 +17,7 @@ BaseCompressedSparseDataset as SparseDataset, ) - if Version(anndata_version) >= Version("0.11.0rc0"): + if Version(anndata_version) >= Version("0.11.0rc2"): from anndata.io import sparse_dataset else: from anndata.experimental import sparse_dataset From b73fb59253bf7b1933e4073acca0837de2be09ca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:30:46 +0200 Subject: [PATCH 12/51] [pre-commit.ci] pre-commit autoupdate (#3274) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cd0dd7072f..75b40177d2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.8 + rev: v0.7.0 hooks: - id: ruff types_or: [python, pyi, jupyter] @@ -20,7 +20,7 @@ repos: - --sort-by-bibkey - --drop=abstract - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace exclude: tests/_data From 8e6416570d421a5da2862541690e17e0647dc683 Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Tue, 22 Oct 2024 14:24:32 +0200 Subject: [PATCH 13/51] (fix): clarify sparse pca usage (#3306) * (fix): clarify sparse pca usage * (chore): clarify release note * (fix): docstring grammar * do spaces correctly * Update src/scanpy/preprocessing/_pca/_dask_sparse.py --------- Co-authored-by: Philipp A. --- docs/release-notes/3267.feature.md | 2 +- src/scanpy/preprocessing/_pca/__init__.py | 1 + src/scanpy/preprocessing/_pca/_dask_sparse.py | 13 +++++++++++ tests/test_pca.py | 23 +++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/3267.feature.md b/docs/release-notes/3267.feature.md index ea4a5c6080..6ea7fb20a2 100644 --- a/docs/release-notes/3267.feature.md +++ b/docs/release-notes/3267.feature.md @@ -1 +1 @@ -Use upstreamed {class}`~sklearn.decomposition.PCA` implementation for {class}`~scipy.sparse.sparray` and {class}`~scipy.sparse.spmatrix` (see {ref}`sklearn:changes_1_4`) {smaller}`P Angerer` +Use upstreamed {class}`~sklearn.decomposition.PCA` implementation for {class}`~scipy.sparse.csr_array` and {class}`~scipy.sparse.csr_matrix` (see {ref}`sklearn:changes_1_4`) {smaller}`P Angerer` diff --git a/src/scanpy/preprocessing/_pca/__init__.py b/src/scanpy/preprocessing/_pca/__init__.py index 396781ce7a..354848ea7d 100644 --- a/src/scanpy/preprocessing/_pca/__init__.py +++ b/src/scanpy/preprocessing/_pca/__init__.py @@ -125,6 +125,7 @@ def pca( Not available with *dask* arrays. `'covariance_eigh'` Classic eigendecomposition of the covariance matrix, suited for tall-and-skinny matrices. + With dask, array must be CSR and chunked as (N, adata.shape[1]). `'randomized'` for the randomized algorithm due to Halko (2009). For *dask* arrays, this will use :func:`~dask.array.linalg.svd_compressed`. diff --git a/src/scanpy/preprocessing/_pca/_dask_sparse.py b/src/scanpy/preprocessing/_pca/_dask_sparse.py index 6123dadec5..c2bff7ccca 100644 --- a/src/scanpy/preprocessing/_pca/_dask_sparse.py +++ b/src/scanpy/preprocessing/_pca/_dask_sparse.py @@ -48,6 +48,19 @@ def fit(self, x: DaskArray) -> PCASparseDaskFit: >>> pca_fit.transform(x) dask.array """ + if x._meta.format != "csr": + msg = ( + "Only dask arrays with CSR-meta format are supported. " + f"Got {x._meta.format} as meta." + ) + raise ValueError(msg) + if x.chunksize[1] != x.shape[1]: + msg = ( + "Only dask arrays with chunking along the first axis are supported. " + f"Got chunksize {x.chunksize} with shape {x.shape}. " + "Rechunking should be simple and cost nothing from AnnData's on-disk format when the on-disk layout has this chunking." + ) + raise ValueError(msg) self.__class__ = PCASparseDaskFit self = cast(PCASparseDaskFit, self) diff --git a/tests/test_pca.py b/tests/test_pca.py index 1439ea788d..6fc8eafd43 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -581,6 +581,29 @@ def test_covariance_eigh_impls(other_array_type): ) +@needs.dask +@needs_anndata_dask +@pytest.mark.parametrize( + ("msg_re", "op"), + [ + ( + r"Only dask arrays with CSR-meta", + lambda a: a.map_blocks( + sparse.csc_matrix, meta=sparse.csc_matrix(np.array([])) + ), + ), + (r"Only dask arrays with chunking", lambda a: a.rechunk((a.shape[0], 100))), + ], + ids=["as-csc", "bad-chunking"], +) +def test_sparse_dask_input_errors(msg_re: str, op: Callable[[DaskArray], DaskArray]): + adata_sparse = pbmc3k_normalized() + adata_sparse.X = op(DASK_CONVERTERS[_helpers.as_sparse_dask_array](adata_sparse.X)) + + with pytest.raises(ValueError, match=msg_re): + sc.pp.pca(adata_sparse, svd_solver="covariance_eigh") + + @needs.dask @needs_anndata_dask @pytest.mark.parametrize( From 39c6532d276ca83cc0548546c3d73ebee6eec0c1 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 22 Oct 2024 15:53:21 +0200 Subject: [PATCH 14/51] Fix sc.pl.highest_expr_genes with a categorical column (#3302) --- docs/release-notes/3302.bugfix.md | 1 + src/scanpy/plotting/_qc.py | 2 +- tests/_images/highest_expr_genes/expected.png | Bin 0 -> 11545 bytes tests/test_plotting.py | 11 +++++++++++ 4 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 docs/release-notes/3302.bugfix.md create mode 100644 tests/_images/highest_expr_genes/expected.png diff --git a/docs/release-notes/3302.bugfix.md b/docs/release-notes/3302.bugfix.md new file mode 100644 index 0000000000..00d2468dad --- /dev/null +++ b/docs/release-notes/3302.bugfix.md @@ -0,0 +1 @@ +Fix {func}`scanpy.pl.highest_expr_genes` when used with a categorical gene symbol column {smaller}`P Angerer` diff --git a/src/scanpy/plotting/_qc.py b/src/scanpy/plotting/_qc.py index e37276f4b6..dc89e3c064 100644 --- a/src/scanpy/plotting/_qc.py +++ b/src/scanpy/plotting/_qc.py @@ -86,7 +86,7 @@ def highest_expr_genes( columns = ( adata.var_names[top_idx] if gene_symbols is None - else adata.var[gene_symbols][top_idx] + else adata.var[gene_symbols].iloc[top_idx].astype("string") ) counts_top_genes = pd.DataFrame( counts_top_genes, index=adata.obs_names, columns=columns diff --git a/tests/_images/highest_expr_genes/expected.png b/tests/_images/highest_expr_genes/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..1df4b95ff29b5ec5ee094de121b2b19493af1041 GIT binary patch literal 11545 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 Date: Wed, 23 Oct 2024 14:24:36 -0400 Subject: [PATCH 15/51] Fix some `Returns` docstrs re: `inplace` semantics (#3311) * fix comment typo * fix docstrs re: `Returns`/`inplace` semantics --- src/scanpy/preprocessing/_combat.py | 4 ++-- src/scanpy/preprocessing/_highly_variable_genes.py | 2 +- src/scanpy/tools/_marker_gene_overlap.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/scanpy/preprocessing/_combat.py b/src/scanpy/preprocessing/_combat.py index ef193d38b4..caeb9a0b45 100644 --- a/src/scanpy/preprocessing/_combat.py +++ b/src/scanpy/preprocessing/_combat.py @@ -171,7 +171,7 @@ def combat( Returns ------- - Returns :class:`numpy.ndarray` if `inplace=True`, else returns `None` and sets the following field in the `adata` object: + Returns :class:`numpy.ndarray` if `inplace=False`, else returns `None` and sets the following field in the `adata` object: `adata.X` : :class:`numpy.ndarray` (dtype `float`) Corrected data matrix. @@ -261,7 +261,7 @@ def combat( # we now apply the parametric adjustment to the standardized data from above # loop over all batches in the data for j, batch_idxs in enumerate(batch_info): - # we basically substract the additive batch effect, rescale by the ratio + # we basically subtract the additive batch effect, rescale by the ratio # of multiplicative batch effect to pooled variance and add the overall gene # wise mean dsq = np.sqrt(delta_star[j, :]) diff --git a/src/scanpy/preprocessing/_highly_variable_genes.py b/src/scanpy/preprocessing/_highly_variable_genes.py index af71728d2c..fa7971d21e 100644 --- a/src/scanpy/preprocessing/_highly_variable_genes.py +++ b/src/scanpy/preprocessing/_highly_variable_genes.py @@ -615,7 +615,7 @@ def highly_variable_genes( Returns ------- - Returns a :class:`pandas.DataFrame` with calculated metrics if `inplace=True`, else returns an `AnnData` object where it sets the following field: + Returns a :class:`pandas.DataFrame` with calculated metrics if `inplace=False`, else returns an `AnnData` object where it sets the following field: `adata.var['highly_variable']` : :class:`pandas.Series` (dtype `bool`) boolean indicator of highly-variable genes diff --git a/src/scanpy/tools/_marker_gene_overlap.py b/src/scanpy/tools/_marker_gene_overlap.py index a1d71cf993..83a19c86a4 100644 --- a/src/scanpy/tools/_marker_gene_overlap.py +++ b/src/scanpy/tools/_marker_gene_overlap.py @@ -135,7 +135,7 @@ def marker_gene_overlap( Returns ------- - Returns :class:`pandas.DataFrame` if `inplace=True`, else returns an `AnnData` object where it sets the following field: + Returns :class:`pandas.DataFrame` if `inplace=False`, else returns an `AnnData` object where it sets the following field: `adata.uns[key_added]` : :class:`pandas.DataFrame` (dtype `float`) Marker gene overlap scores. Default for `key_added` is `'marker_gene_overlap'`. From 3d220a93c83fdd60ee3220c94db3dd8d5533c60d Mon Sep 17 00:00:00 2001 From: Jesko Wagner <35219306+jeskowagner@users.noreply.github.com> Date: Thu, 24 Oct 2024 10:55:17 +0100 Subject: [PATCH 16/51] Catch PerfectSeparationWarning during regress_out (#3275) * catch PerfectSeparationWarning during regress_out * add 3260 release note * format * pre-commit Signed-off-by: zethson * rename release note --------- Signed-off-by: zethson Co-authored-by: zethson Co-authored-by: Intron7 --- docs/release-notes/3275.bugfix.md | 1 + src/scanpy/preprocessing/_simple.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 docs/release-notes/3275.bugfix.md diff --git a/docs/release-notes/3275.bugfix.md b/docs/release-notes/3275.bugfix.md new file mode 100644 index 0000000000..4465c7651d --- /dev/null +++ b/docs/release-notes/3275.bugfix.md @@ -0,0 +1 @@ +Catch `PerfectSeparationWarning` during {func}`~scanpy.pp.regress_out` {smaller}`J Wagner` diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 90a8d0e21a..4096155a55 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -740,7 +740,7 @@ def _regress_out_chunk(data): responses_chunk_list = [] import statsmodels.api as sm - from statsmodels.tools.sm_exceptions import PerfectSeparationError + import statsmodels.tools.sm_exceptions as sme for col_index in range(data_chunk.shape[1]): # if all values are identical, the statsmodel.api.GLM throws an error; @@ -754,11 +754,17 @@ def _regress_out_chunk(data): else: regres = regressors try: - result = sm.GLM( - data_chunk[:, col_index], regres, family=sm.families.Gaussian() - ).fit() - new_column = result.resid_response - except PerfectSeparationError: # this emulates R's behavior + err_classes = (sme.PerfectSeparationError,) + with warnings.catch_warnings(): + if hasattr(sme, "PerfectSeparationWarning"): + # See issue #3260 - for statsmodels>=0.14.0 + warnings.simplefilter("error", sme.PerfectSeparationWarning) + err_classes = (*err_classes, sme.PerfectSeparationWarning) + result = sm.GLM( + data_chunk[:, col_index], regres, family=sm.families.Gaussian() + ).fit() + new_column = result.resid_response + except err_classes: # this emulates R's behavior logg.warning("Encountered PerfectSeparationError, setting to 0 as in R.") new_column = np.zeros(data_chunk.shape[0]) From 2f0afac72be3644624cf996323197239580f14f9 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 25 Oct 2024 12:14:16 +0200 Subject: [PATCH 17/51] Refactor regress_out (#3316) --- src/scanpy/preprocessing/_simple.py | 68 +++++++++++++++++------------ 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 4096155a55..e266cfc2a6 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -7,6 +7,7 @@ import warnings from functools import singledispatch +from itertools import repeat from typing import TYPE_CHECKING import numba @@ -45,6 +46,7 @@ from numbers import Number from typing import Literal + import pandas as pd from numpy.typing import NDArray from .._compat import DaskArray @@ -659,6 +661,8 @@ def regress_out( `adata.X` | `adata.layers[layer]` : :class:`numpy.ndarray` | :class:`scipy.sparse._csr.csr_matrix` (dtype `float`) Corrected count data matrix. """ + from joblib import Parallel, delayed + start = logg.info(f"regressing out {keys}") adata = adata.copy() if copy else adata @@ -673,7 +677,7 @@ def regress_out( raise_not_implemented_error_if_backed_type(X, "regress_out") if issparse(X): - logg.info(" sparse input is densified and may " "lead to high memory use") + logg.info(" sparse input is densified and may lead to high memory use") X = X.toarray() n_jobs = sett.n_jobs if n_jobs is None else n_jobs @@ -704,25 +708,27 @@ def regress_out( # add column of ones at index 0 (first column) regressors.insert(0, "ones", 1.0) - len_chunk = np.ceil(min(1000, X.shape[1]) / n_jobs).astype(int) - n_chunks = np.ceil(X.shape[1] / len_chunk).astype(int) + len_chunk = int(np.ceil(min(1000, X.shape[1]) / n_jobs)) + n_chunks = int(np.ceil(X.shape[1] / len_chunk)) - tasks = [] # split the adata.X matrix by columns in chunks of size n_chunk # (the last chunk could be of smaller size than the others) chunk_list = np.array_split(X, n_chunks, axis=1) - if variable_is_categorical: - regressors_chunk = np.array_split(regressors, n_chunks, axis=1) - for idx, data_chunk in enumerate(chunk_list): - # each task is a tuple of a data_chunk eg. (adata.X[:,0:100]) and - # the regressors. This data will be passed to each of the jobs. - regres = regressors_chunk[idx] if variable_is_categorical else regressors - tasks.append(tuple((data_chunk, regres, variable_is_categorical))) - - from joblib import Parallel, delayed + regressors_chunk = ( + np.array_split(regressors, n_chunks, axis=1) + if variable_is_categorical + else repeat(regressors) + ) + # each task is passed a data chunk (e.g. `adata.X[:, 0:100]``) and the regressors. + # This data will be passed to each of the jobs. # TODO: figure out how to test that this doesn't oversubscribe resources - res = Parallel(n_jobs=n_jobs)(delayed(_regress_out_chunk)(task) for task in tasks) + res = Parallel(n_jobs=n_jobs)( + delayed(_regress_out_chunk)( + data_chunk, regres, variable_is_categorical=variable_is_categorical + ) + for data_chunk, regres in zip(chunk_list, regressors_chunk, strict=False) + ) # res is a list of vectors (each corresponding to a regressed gene column). # The transpose is needed to get the matrix in the shape needed @@ -731,17 +737,22 @@ def regress_out( return adata if copy else None -def _regress_out_chunk(data): - # data is a tuple containing the selected columns from adata.X - # and the regressors dataFrame - data_chunk = data[0] - regressors = data[1] - variable_is_categorical = data[2] - - responses_chunk_list = [] +def _regress_out_chunk( + data_chunk: NDArray[np.floating], + regressors: pd.DataFrame | NDArray[np.floating], + *, + variable_is_categorical: bool, +) -> NDArray[np.floating]: import statsmodels.api as sm import statsmodels.tools.sm_exceptions as sme + Psw = ( + sme.PerfectSeparationWarning + if hasattr(sme, "PerfectSeparationWarning") + else None + ) + + responses_chunk_list = [] for col_index in range(data_chunk.shape[1]): # if all values are identical, the statsmodel.api.GLM throws an error; # but then no regression is necessary anyways... @@ -753,19 +764,18 @@ def _regress_out_chunk(data): regres = np.c_[np.ones(regressors.shape[0]), regressors[:, col_index]] else: regres = regressors + try: - err_classes = (sme.PerfectSeparationError,) with warnings.catch_warnings(): - if hasattr(sme, "PerfectSeparationWarning"): - # See issue #3260 - for statsmodels>=0.14.0 - warnings.simplefilter("error", sme.PerfectSeparationWarning) - err_classes = (*err_classes, sme.PerfectSeparationWarning) + # See issue #3260 - for statsmodels>=0.14.0 + if Psw: + warnings.simplefilter("error", Psw) result = sm.GLM( data_chunk[:, col_index], regres, family=sm.families.Gaussian() ).fit() new_column = result.resid_response - except err_classes: # this emulates R's behavior - logg.warning("Encountered PerfectSeparationError, setting to 0 as in R.") + except (sme.PerfectSeparationError, *([Psw] if Psw else [])): + logg.warning("Encountered perfect separation, setting to 0 as in R.") new_column = np.zeros(data_chunk.shape[0]) responses_chunk_list.append(new_column) From 9a9f17e4d4afdd3c2e1395dfe9aec5cce5489248 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 25 Oct 2024 13:31:34 +0200 Subject: [PATCH 18/51] Enforce `np.bool_` usage via Ruff (#3321) --- pyproject.toml | 1 + src/scanpy/plotting/_anndata.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dff45651fb..dda000d790 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -258,6 +258,7 @@ required-imports = ["from __future__ import annotations"] "pandas.api.types.is_categorical_dtype".msg = "Use isinstance(s.dtype, CategoricalDtype) instead" "pandas.value_counts".msg = "Use pd.Series(a).value_counts() instead" "legacy_api_wrap.legacy_api".msg = "Use scanpy._compat.old_positionals instead" +"numpy.bool".msg = "Use `np.bool_` instead for numpy>=1.24<2 compatibility" [tool.ruff.lint.flake8-type-checking] exempt-modules = [] strict = true diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index f3474fe27b..c1918878c8 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -198,7 +198,7 @@ def _check_if_annotations( other_ax_obj, "var" if axis_name == "obs" else "obs" ).index - def is_annotation(needle: pd.Index) -> NDArray[np.bool]: + def is_annotation(needle: pd.Index) -> NDArray[np.bool_]: return needle.isin({None}) | needle.isin(annotations) | needle.isin(names) if not is_annotation(pd.Index([x, y])).all(): @@ -206,8 +206,8 @@ def is_annotation(needle: pd.Index) -> NDArray[np.bool]: color_idx = pd.Index(colors if colors is not None else []) # Colors are valid - color_valid: NDArray[np.bool] = np.fromiter( - map(is_color_like, color_idx), dtype=np.bool, count=len(color_idx) + color_valid: NDArray[np.bool_] = np.fromiter( + map(is_color_like, color_idx), dtype=np.bool_, count=len(color_idx) ) # Annotation names are valid too color_valid[~color_valid] = is_annotation(color_idx[~color_valid]) From 60d30a40de65b4e9dacb9578f074f9e8565621dc Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Mon, 28 Oct 2024 09:44:58 +0200 Subject: [PATCH 19/51] Update `test_rank_genes_groups.py` reference (#3285) --- src/scanpy/tools/_rank_genes_groups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index d864b01b88..5381ea6228 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -582,7 +582,7 @@ def rank_genes_groups( Notes ----- There are slight inconsistencies depending on whether sparse - or dense data are passed. See `here `__. + or dense data are passed. See `here `__. Examples -------- From c990544ee55fb1fb016a4eeb8b5a4c6837c69910 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 31 Oct 2024 14:24:33 +0100 Subject: [PATCH 20/51] Support `layer` in `sc.pl.highest_expr_genes` (#3324) --- docs/release-notes/3324.feature.md | 1 + src/scanpy/plotting/_qc.py | 11 +++++++---- tests/test_plotting.py | 10 ++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 docs/release-notes/3324.feature.md diff --git a/docs/release-notes/3324.feature.md b/docs/release-notes/3324.feature.md new file mode 100644 index 0000000000..03d14dceb6 --- /dev/null +++ b/docs/release-notes/3324.feature.md @@ -0,0 +1 @@ +Support `layer` parameter in {func}`scanpy.pl.highest_expr_genes` {smaller}`P Angerer` diff --git a/src/scanpy/plotting/_qc.py b/src/scanpy/plotting/_qc.py index dc89e3c064..cd3f764468 100644 --- a/src/scanpy/plotting/_qc.py +++ b/src/scanpy/plotting/_qc.py @@ -24,11 +24,12 @@ def highest_expr_genes( adata: AnnData, n_top: int = 30, *, + layer: str | None = None, + gene_symbols: str | None = None, + log: bool = False, show: bool | None = None, save: str | bool | None = None, ax: Axes | None = None, - gene_symbols: str | None = None, - log: bool = False, **kwds, ): """\ @@ -56,11 +57,13 @@ def highest_expr_genes( Annotated data matrix. n_top Number of top - {show_save_ax} + layer + Layer from which to pull data. gene_symbols Key for field in .var that stores gene symbols if you do not want to use .var_names. log Plot x-axis in log scale + {show_save_ax} **kwds Are passed to :func:`~seaborn.boxplot`. @@ -72,7 +75,7 @@ def highest_expr_genes( from scipy.sparse import issparse # compute the percentage of each gene per cell - norm_dict = normalize_total(adata, target_sum=100, inplace=False) + norm_dict = normalize_total(adata, target_sum=100, layer=layer, inplace=False) # identify the genes with the highest mean if issparse(norm_dict["X"]): diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 43bd2f810a..2f0f5f60cd 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -37,13 +37,19 @@ @pytest.mark.parametrize("col", [None, "symb"]) -def test_highest_expr_genes(image_comparer, col): +@pytest.mark.parametrize("layer", [None, "layer_name"]) +def test_highest_expr_genes(image_comparer, col, layer): save_and_compare_images = partial(image_comparer, ROOT, tol=5) adata = pbmc3k() + if layer is not None: + adata.layers[layer] = adata.X + del adata.X # check that only existing categories are shown adata.var["symb"] = adata.var_names.astype("category") - sc.pl.highest_expr_genes(adata, 20, gene_symbols=col, show=False) + + sc.pl.highest_expr_genes(adata, 20, gene_symbols=col, layer=layer, show=False) + save_and_compare_images("highest_expr_genes") From a22997e106d0e7ff944967613d71c2d41d0da89a Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 31 Oct 2024 14:48:48 +0100 Subject: [PATCH 21/51] =?UTF-8?q?Align=20`get.obs=5Fdf`=E2=80=99s=20docs?= =?UTF-8?q?=20with=20its=20code=20(#3328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scanpy/_utils/__init__.py | 4 ++-- src/scanpy/get/get.py | 20 ++++++++++++-------- src/scanpy/tools/_rank_genes_groups.py | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 883f8b97b9..5a8b0288b8 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -42,7 +42,7 @@ from anndata._core.sparse_dataset import SparseDataset if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Callable, Iterable, Mapping from pathlib import Path from typing import Any, Literal, TypeVar @@ -805,7 +805,7 @@ def _check_nonnegative_integers_dask(X: DaskArray) -> DaskArray: def select_groups( adata: AnnData, - groups_order_subset: list[str] | Literal["all"] = "all", + groups_order_subset: Iterable[str] | Literal["all"] = "all", key: str = "groups", ) -> tuple[list[str], NDArray[np.bool_]]: """Get subset of groups in adata.obs[key].""" diff --git a/src/scanpy/get/get.py b/src/scanpy/get/get.py index 0c1272ae62..f3172ed45e 100644 --- a/src/scanpy/get/get.py +++ b/src/scanpy/get/get.py @@ -11,7 +11,7 @@ from scipy.sparse import spmatrix if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Collection, Iterable from typing import Any, Literal from anndata._core.sparse_dataset import BaseCompressedSparseDataset @@ -116,7 +116,7 @@ def _check_indices( alt_index: pd.Index, *, dim: Literal["obs", "var"], - keys: list[str], + keys: Iterable[str], alias_index: pd.Index | None = None, use_raw: bool = False, ) -> tuple[list[str], list[str], list[str]]: @@ -159,7 +159,7 @@ def _check_indices( # use only unique keys, otherwise duplicated keys will # further duplicate when reordering the keys later in the function - for key in np.unique(keys): + for key in dict.fromkeys(keys): if key in dim_df.columns: col_keys.append(key) if key in alt_names.index: @@ -191,7 +191,7 @@ def _check_indices( def _get_array_values( X, dim_names: pd.Index, - keys: list[str], + keys: Iterable[str], *, axis: Literal[0, 1], backed: bool, @@ -221,7 +221,7 @@ def _get_array_values( def obs_df( adata: AnnData, - keys: Iterable[str] = (), + keys: Collection[str] = (), obsm_keys: Iterable[tuple[str, int]] = (), *, layer: str | None = None, @@ -238,7 +238,7 @@ def obs_df( keys Keys from either `.var_names`, `.var[gene_symbols]`, or `.obs.columns`. obsm_keys - Tuple of `(key from obsm, column index of obsm[key])`. + Tuples of `(key from obsm, column index of obsm[key])`. layer Layer of `adata` to use as expression values. gene_symbols @@ -278,6 +278,8 @@ def obs_df( >>> grouped = genedf.groupby("louvain", observed=True) >>> mean, var = grouped.mean(), grouped.var() """ + if isinstance(keys, str): + keys = [keys] if use_raw: assert ( layer is None @@ -336,7 +338,7 @@ def obs_df( def var_df( adata: AnnData, - keys: Iterable[str] = (), + keys: Collection[str] = (), varm_keys: Iterable[tuple[str, int]] = (), *, layer: str | None = None, @@ -351,7 +353,7 @@ def var_df( keys Keys from either `.obs_names`, or `.var.columns`. varm_keys - Tuple of `(key from varm, column index of varm[key])`. + Tuples of `(key from varm, column index of varm[key])`. layer Layer of `adata` to use as expression values. @@ -361,6 +363,8 @@ def var_df( and `varm_keys`. """ # Argument handling + if isinstance(keys, str): + keys = [keys] var_cols, obs_idx_keys, _ = _check_indices( adata.var, adata.obs_names, dim="var", keys=keys ) diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 5381ea6228..3a737bb487 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -98,7 +98,7 @@ class _RankGenes: def __init__( self, adata: AnnData, - groups: list[str] | Literal["all"], + groups: Iterable[str] | Literal["all"], groupby: str, *, mask_var: NDArray[np.bool_] | None = None, From 6440515ebce6e38b62bac5bce6d656f71fbeaa5b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 5 Nov 2024 08:42:01 +0100 Subject: [PATCH 22/51] [pre-commit.ci] pre-commit autoupdate (#3329) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75b40177d2..25b824a582 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.0 + rev: v0.7.2 hooks: - id: ruff types_or: [python, pyi, jupyter] From 0d04447448747337e2d3adb15ecdfdbfa1ad91c7 Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Tue, 5 Nov 2024 15:12:54 +0100 Subject: [PATCH 23/51] (fix): sort pca test args (#3333) Co-authored-by: Philipp A. --- src/scanpy/_utils/__init__.py | 26 +++++++++++++----- src/scanpy/get/_aggregated.py | 10 +++---- src/scanpy/neighbors/__init__.py | 10 ++++--- src/scanpy/neighbors/_types.py | 2 +- src/scanpy/plotting/_anndata.py | 17 +++++++----- src/scanpy/preprocessing/_pca/__init__.py | 32 +++++++++++++---------- src/scanpy/tools/_draw_graph.py | 9 +++---- src/scanpy/tools/_rank_genes_groups.py | 8 +++--- tests/test_aggregated.py | 6 ++--- tests/test_pca.py | 16 ++++++++---- 10 files changed, 81 insertions(+), 55 deletions(-) diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 5a8b0288b8..8e886d1ff1 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -15,11 +15,11 @@ from collections import namedtuple from contextlib import contextmanager, suppress from enum import Enum -from functools import partial, singledispatch, wraps -from operator import mul, truediv +from functools import partial, reduce, singledispatch, wraps +from operator import mul, or_, truediv from textwrap import dedent -from types import MethodType, ModuleType -from typing import TYPE_CHECKING, overload +from types import MethodType, ModuleType, UnionType +from typing import TYPE_CHECKING, Literal, Union, get_args, get_origin, overload from weakref import WeakSet import h5py @@ -42,9 +42,9 @@ from anndata._core.sparse_dataset import SparseDataset if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Mapping + from collections.abc import Callable, Iterable, KeysView, Mapping from pathlib import Path - from typing import Any, Literal, TypeVar + from typing import Any, TypeVar from anndata import AnnData from numpy.typing import DTypeLike, NDArray @@ -55,6 +55,7 @@ # e.g. https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html # maybe in the future random.Generator AnyRandom = int | np.random.RandomState | None +LegacyUnionType = type(Union[int, str]) # noqa: UP007 class Empty(Enum): @@ -532,6 +533,19 @@ def update_params( return updated_params +# `get_args` returns `tuple[Any]` so I don’t think it’s possible to get the correct type here +def get_literal_vals(typ: UnionType | Any) -> KeysView[Any]: + """Get all literal values from a Literal or Union of … of Literal type.""" + if isinstance(typ, UnionType | LegacyUnionType): + return reduce( + or_, (dict.fromkeys(get_literal_vals(t)) for t in get_args(typ)) + ).keys() + if get_origin(typ) is Literal: + return dict.fromkeys(get_args(typ)).keys() + msg = f"{typ} is not a valid Literal" + raise TypeError(msg) + + # -------------------------------------------------------------------------------- # Others # -------------------------------------------------------------------------------- diff --git a/src/scanpy/get/_aggregated.py b/src/scanpy/get/_aggregated.py index e95fedf9dc..2d2739491e 100644 --- a/src/scanpy/get/_aggregated.py +++ b/src/scanpy/get/_aggregated.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import singledispatch -from typing import TYPE_CHECKING, Literal, get_args +from typing import TYPE_CHECKING, Literal import numpy as np import pandas as pd @@ -9,7 +9,7 @@ from scipy import sparse from sklearn.utils.sparsefuncs import csc_median_axis_0 -from .._utils import _resolve_axis +from .._utils import _resolve_axis, get_literal_vals from .get import _check_mask if TYPE_CHECKING: @@ -19,7 +19,7 @@ Array = np.ndarray | sparse.csc_matrix | sparse.csr_matrix -# Used with get_args +# Used with get_literal_vals AggType = Literal["count_nonzero", "mean", "sum", "var", "median"] @@ -347,8 +347,8 @@ def aggregate_array( result = {} funcs = set([func] if isinstance(func, str) else func) - if unknown := funcs - set(get_args(AggType)): - raise ValueError(f"func {unknown} is not one of {get_args(AggType)}") + if unknown := funcs - get_literal_vals(AggType): + raise ValueError(f"func {unknown} is not one of {get_literal_vals(AggType)}") if "sum" in funcs: # sum is calculated separately from the rest agg = groupby.sum() diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index 7b1c3f2506..379f34227b 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from textwrap import indent from types import MappingProxyType -from typing import TYPE_CHECKING, NamedTuple, TypedDict, get_args +from typing import TYPE_CHECKING, NamedTuple, TypedDict from warnings import warn import numpy as np @@ -16,7 +16,7 @@ from .. import logging as logg from .._compat import old_positionals from .._settings import settings -from .._utils import NeighborsView, _doc_params +from .._utils import NeighborsView, _doc_params, get_literal_vals from . import _connectivity from ._common import ( _get_indices_distances_from_sparse_matrix, @@ -652,7 +652,9 @@ def _handle_transformer( raise ValueError(msg) method = "umap" transformer = "rapids" - elif method not in (methods := set(get_args(_Method))) and method is not None: + elif ( + method not in (methods := get_literal_vals(_Method)) and method is not None + ): msg = f"`method` needs to be one of {methods}." raise ValueError(msg) @@ -704,7 +706,7 @@ def _handle_transformer( elif isinstance(transformer, str): msg = ( f"Unknown transformer: {transformer}. " - f"Try passing a class or one of {set(get_args(_KnownTransformer))}" + f"Try passing a class or one of {get_literal_vals(_KnownTransformer)}" ) raise ValueError(msg) # else `transformer` is probably an instance diff --git a/src/scanpy/neighbors/_types.py b/src/scanpy/neighbors/_types.py index d98ec76af3..39f50284ec 100644 --- a/src/scanpy/neighbors/_types.py +++ b/src/scanpy/neighbors/_types.py @@ -11,7 +11,7 @@ from scipy.sparse import spmatrix -# These two are used with get_args elsewhere +# These two are used with get_literal_vals elsewhere _Method = Literal["umap", "gauss"] _KnownTransformer = Literal["pynndescent", "sklearn", "rapids"] diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index c1918878c8..0ae810b2c7 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -6,7 +6,7 @@ from collections.abc import Collection, Mapping, Sequence from itertools import product from types import NoneType -from typing import TYPE_CHECKING, cast, get_args +from typing import TYPE_CHECKING, cast import matplotlib as mpl import numpy as np @@ -22,7 +22,13 @@ from .. import logging as logg from .._compat import old_positionals from .._settings import settings -from .._utils import _check_use_raw, _doc_params, _empty, sanitize_anndata +from .._utils import ( + _check_use_raw, + _doc_params, + _empty, + get_literal_vals, + sanitize_anndata, +) from . import _utils from ._docs import ( doc_common_plot_args, @@ -65,9 +71,6 @@ _VarNames = str | Sequence[str] -VALID_LEGENDLOCS = frozenset(get_args(_utils._LegendLoc)) - - @old_positionals( "color", "use_raw", @@ -268,9 +271,9 @@ def _scatter_obs( if use_raw and layers not in [("X", "X", "X"), (None, None, None)]: ValueError("`use_raw` must be `False` if layers are used.") - if legend_loc not in VALID_LEGENDLOCS: + if legend_loc not in (valid_legend_locs := get_literal_vals(_utils._LegendLoc)): raise ValueError( - f"Invalid `legend_loc`, need to be one of: {VALID_LEGENDLOCS}." + f"Invalid `legend_loc`, need to be one of: {valid_legend_locs}." ) if components is None: components = "1,2" if "2d" in projection else "1,2,3" diff --git a/src/scanpy/preprocessing/_pca/__init__.py b/src/scanpy/preprocessing/_pca/__init__.py index 354848ea7d..dba47d821c 100644 --- a/src/scanpy/preprocessing/_pca/__init__.py +++ b/src/scanpy/preprocessing/_pca/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Literal, get_args, overload +from typing import TYPE_CHECKING, Literal, overload from warnings import warn import anndata as ad @@ -14,13 +14,14 @@ from ... import logging as logg from ..._compat import DaskArray, pkg_version from ..._settings import settings -from ..._utils import _doc_params, _empty, is_backed_type +from ..._utils import _doc_params, _empty, get_literal_vals, is_backed_type from ...get import _check_mask, _get_obs_rep from .._docs import doc_mask_var_hvg from ._compat import _pca_compat_sparse if TYPE_CHECKING: from collections.abc import Container + from collections.abc import Set as AbstractSet from typing import LiteralString, TypeVar import dask_ml.decomposition as dmld @@ -44,10 +45,11 @@ SvdSolvTruncatedSVDDaskML = Literal["tsqr", "randomized"] SvdSolvDaskML = SvdSolvPCADaskML | SvdSolvTruncatedSVDDaskML -SvdSolvPCADenseSklearn = Literal[ - "auto", "full", "arpack", "covariance_eigh", "randomized" -] -SvdSolvPCASparseSklearn = Literal["arpack", "covariance_eigh"] +if pkg_version("scikit-learn") >= Version("1.5") or TYPE_CHECKING: + SvdSolvPCASparseSklearn = Literal["arpack", "covariance_eigh"] +else: + SvdSolvPCASparseSklearn = Literal["arpack"] +SvdSolvPCADenseSklearn = Literal["auto", "full", "randomized"] | SvdSolvPCASparseSklearn SvdSolvTruncatedSVDSklearn = Literal["arpack", "randomized"] SvdSolvSkearn = ( SvdSolvPCADenseSklearn | SvdSolvPCASparseSklearn | SvdSolvTruncatedSVDSklearn @@ -299,7 +301,9 @@ def pca( if issparse(X) and ( pkg_version("scikit-learn") < Version("1.4") or svd_solver == "lobpcg" ): - if svd_solver not in {"lobpcg", "arpack"}: + if svd_solver not in ( + {"lobpcg"} | get_literal_vals(SvdSolvPCASparseSklearn) + ): if svd_solver is not None: msg = ( f"Ignoring {svd_solver=} and using 'arpack', " @@ -467,14 +471,14 @@ def _handle_dask_ml_args( def _handle_dask_ml_args(svd_solver: str | None, method: MethodDaskML) -> SvdSolvDaskML: import dask_ml.decomposition as dmld - args: tuple[SvdSolvDaskML, ...] + args: AbstractSet[SvdSolvDaskML] default: SvdSolvDaskML match method: case dmld.PCA | dmld.IncrementalPCA: - args = get_args(SvdSolvPCADaskML) + args = get_literal_vals(SvdSolvPCADaskML) default = "auto" case dmld.TruncatedSVD: - args = get_args(SvdSolvTruncatedSVDDaskML) + args = get_literal_vals(SvdSolvTruncatedSVDDaskML) default = "tsqr" case _: msg = f"Unknown {method=} in _handle_dask_ml_args" @@ -499,18 +503,18 @@ def _handle_sklearn_args( ) -> SvdSolvSkearn: import sklearn.decomposition as skld - args: tuple[SvdSolvSkearn, ...] + args: AbstractSet[SvdSolvSkearn] default: SvdSolvSkearn suffix = "" match (method, sparse): case (skld.TruncatedSVD, None): - args = get_args(SvdSolvTruncatedSVDSklearn) + args = get_literal_vals(SvdSolvTruncatedSVDSklearn) default = "randomized" case (skld.PCA, False): - args = get_args(SvdSolvPCADenseSklearn) + args = get_literal_vals(SvdSolvPCADenseSklearn) default = "arpack" case (skld.PCA, True): - args = get_args(SvdSolvPCASparseSklearn) + args = get_literal_vals(SvdSolvPCASparseSklearn) default = "arpack" suffix = " (with sparse input)" case _: diff --git a/src/scanpy/tools/_draw_graph.py b/src/scanpy/tools/_draw_graph.py index 4e8c91fb1f..3f0e65c061 100644 --- a/src/scanpy/tools/_draw_graph.py +++ b/src/scanpy/tools/_draw_graph.py @@ -2,14 +2,14 @@ import random from importlib.util import find_spec -from typing import TYPE_CHECKING, Literal, get_args +from typing import TYPE_CHECKING, Literal import numpy as np from .. import _utils from .. import logging as logg from .._compat import old_positionals -from .._utils import _choose_graph +from .._utils import _choose_graph, get_literal_vals from ._utils import get_init_pos_from_paga if TYPE_CHECKING: @@ -24,7 +24,6 @@ _Layout = Literal["fr", "drl", "kk", "grid_fr", "lgl", "rt", "rt_circular", "fa"] -_LAYOUTS = get_args(_Layout) @old_positionals( @@ -124,8 +123,8 @@ def draw_graph( `draw_graph` parameters. """ start = logg.info(f"drawing single-cell graph using layout {layout!r}") - if layout not in _LAYOUTS: - raise ValueError(f"Provide a valid layout, one of {_LAYOUTS}.") + if layout not in (layouts := get_literal_vals(_Layout)): + raise ValueError(f"Provide a valid layout, one of {layouts}.") adata = adata.copy() if copy else adata if adjacency is None: adjacency = _choose_graph(adata, obsp, neighbors_key) diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 3a737bb487..f8ab13e9fd 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -3,7 +3,7 @@ from __future__ import annotations from math import floor -from typing import TYPE_CHECKING, Literal, get_args +from typing import TYPE_CHECKING, Literal import numpy as np import pandas as pd @@ -14,6 +14,7 @@ from .._compat import old_positionals from .._utils import ( check_nonnegative_integers, + get_literal_vals, raise_not_implemented_error_if_backed_type, ) from ..get import _check_mask @@ -28,7 +29,7 @@ _CorrMethod = Literal["benjamini-hochberg", "bonferroni"] -# Used with get_args +# Used with get_literal_vals _Method = Literal["logreg", "t-test", "wilcoxon", "t-test_overestim_var"] @@ -607,8 +608,7 @@ def rank_genes_groups( rankby_abs = not kwds.pop("only_positive") # backwards compat start = logg.info("ranking genes") - avail_methods = set(get_args(_Method)) - if method not in avail_methods: + if method not in (avail_methods := get_literal_vals(_Method)): raise ValueError(f"Method must be one of {avail_methods}.") avail_corr = {"benjamini-hochberg", "bonferroni"} diff --git a/tests/test_aggregated.py b/tests/test_aggregated.py index ce680b8df5..5bd87e231d 100644 --- a/tests/test_aggregated.py +++ b/tests/test_aggregated.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import get_args - import anndata as ad import numpy as np import pandas as pd @@ -10,14 +8,14 @@ from scipy import sparse import scanpy as sc -from scanpy._utils import _resolve_axis +from scanpy._utils import _resolve_axis, get_literal_vals from scanpy.get._aggregated import AggType from testing.scanpy._helpers import assert_equal from testing.scanpy._helpers.data import pbmc3k_processed from testing.scanpy._pytest.params import ARRAY_TYPES_MEM -@pytest.fixture(params=get_args(AggType)) +@pytest.fixture(params=get_literal_vals(AggType)) def metric(request: pytest.FixtureRequest) -> AggType: return request.param diff --git a/tests/test_pca.py b/tests/test_pca.py index 6fc8eafd43..0130b6ac35 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -3,7 +3,7 @@ import warnings from contextlib import nullcontext from functools import wraps -from typing import TYPE_CHECKING, Literal, get_args +from typing import TYPE_CHECKING, Literal import anndata as ad import numpy as np @@ -17,6 +17,7 @@ import scanpy as sc from scanpy._compat import DaskArray, pkg_version +from scanpy._utils import get_literal_vals from scanpy.preprocessing._pca import SvdSolver as SvdSolverSupported from scanpy.preprocessing._pca._dask_sparse import _cov_sparse_dask from testing.scanpy import _helpers @@ -125,6 +126,10 @@ def array_type(request: pytest.FixtureRequest) -> ArrayType: SVDSolverDeprecated = Literal["lobpcg"] SVDSolver = SvdSolverSupported | SVDSolverDeprecated +SKLEARN_ADDITIONAL: frozenset[SvdSolverSupported] = frozenset( + {"covariance_eigh"} if pkg_version("scikit-learn") >= Version("1.5") else () +) + def gen_pca_params( *, @@ -140,7 +145,7 @@ def gen_pca_params( yield None, None, None return - all_svd_solvers = set(get_args(SVDSolver)) + all_svd_solvers = get_literal_vals(SVDSolver) svd_solvers: set[SVDSolver] match array_type, zero_center: case (dc, True) if dc is DASK_CONVERTERS[_helpers.as_dense_dask_array]: @@ -150,11 +155,11 @@ def gen_pca_params( case (dc, True) if dc is DASK_CONVERTERS[_helpers.as_sparse_dask_array]: svd_solvers = {"covariance_eigh"} case ((sparse.csr_matrix | sparse.csc_matrix), True): - svd_solvers = {"arpack"} + svd_solvers = {"arpack"} | SKLEARN_ADDITIONAL case ((sparse.csr_matrix | sparse.csc_matrix), False): svd_solvers = {"arpack", "randomized"} case (helpers.asarray, True): - svd_solvers = {"auto", "full", "arpack", "randomized"} + svd_solvers = {"auto", "full", "arpack", "randomized"} | SKLEARN_ADDITIONAL case (helpers.asarray, False): svd_solvers = {"arpack", "randomized"} case _: @@ -168,7 +173,8 @@ def gen_pca_params( else: pytest.fail(f"Unknown {svd_solver_type=}") - for svd_solver in svd_solvers: + # sorted to prevent https://github.com/pytest-dev/pytest-xdist/issues/432 + for svd_solver in sorted(svd_solvers): # explicit check for special case if ( array_type in {sparse.csr_matrix, sparse.csc_matrix} From 5c0e89e99dc2461c654c549435a73f547f3573ce Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 5 Nov 2024 17:34:34 +0100 Subject: [PATCH 24/51] Add PYI lints (#3339) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- benchmarks/benchmarks/_utils.py | 7 +++-- pyproject.toml | 3 ++ src/scanpy/_settings.py | 4 +-- src/scanpy/_utils/__init__.py | 31 +++++++++++++------ src/scanpy/cli.py | 4 +-- src/scanpy/get/_aggregated.py | 2 +- src/scanpy/plotting/_anndata.py | 8 ++--- src/scanpy/plotting/_stacked_violin.py | 8 ++--- src/scanpy/plotting/_tools/__init__.py | 4 +-- src/scanpy/plotting/_tools/paga.py | 4 +-- src/scanpy/plotting/_tools/scatterplots.py | 2 +- src/scanpy/preprocessing/_scale.py | 1 - .../preprocessing/_scrublet/sparse_utils.py | 2 +- src/scanpy/tools/_marker_gene_overlap.py | 4 +-- src/scanpy/tools/_rank_genes_groups.py | 2 +- src/scanpy/tools/_tsne.py | 6 ++-- 16 files changed, 53 insertions(+), 39 deletions(-) diff --git a/benchmarks/benchmarks/_utils.py b/benchmarks/benchmarks/_utils.py index 810ace74fd..93bb4623f9 100644 --- a/benchmarks/benchmarks/_utils.py +++ b/benchmarks/benchmarks/_utils.py @@ -14,7 +14,8 @@ import scanpy as sc if TYPE_CHECKING: - from collections.abc import Callable, Sequence, Set + from collections.abc import Callable, Sequence + from collections.abc import Set as AbstractSet from typing import Literal, Protocol, TypeVar from anndata import AnnData @@ -22,7 +23,7 @@ C = TypeVar("C", bound=Callable) class ParamSkipper(Protocol): - def __call__(self, **skipped: Set) -> Callable[[C], C]: ... + def __call__(self, **skipped: AbstractSet) -> Callable[[C], C]: ... Dataset = Literal["pbmc68k_reduced", "pbmc3k", "bmmc", "lung93k"] KeyX = Literal[None, "off-axis"] @@ -195,7 +196,7 @@ def param_skipper( b 5 """ - def skip(**skipped: Set) -> Callable[[C], C]: + def skip(**skipped: AbstractSet) -> Callable[[C], C]: skipped_combs = [ tuple(record.values()) for record in ( diff --git a/pyproject.toml b/pyproject.toml index dda000d790..e983d04a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -232,6 +232,7 @@ select = [ "TID251", # Banned imports "ICN", # Follow import conventions "PTH", # Pathlib instead of os.path + "PYI", # Typing "PLR0917", # Ban APIs with too many positional parameters "FBT", # No positional boolean parameters "PT", # Pytest style @@ -246,6 +247,8 @@ ignore = [ "E262", # allow I, O, l as variable names -> I is the identity matrix, i, j, k, l is reasonable indexing notation "E741", + # `Literal["..."] | str` is useful for autocompletion + "PYI051", ] [tool.ruff.lint.per-file-ignores] # Do not assign a lambda expression, use a def diff --git a/src/scanpy/_settings.py b/src/scanpy/_settings.py index fa44fc8492..54b51b6420 100644 --- a/src/scanpy/_settings.py +++ b/src/scanpy/_settings.py @@ -19,7 +19,7 @@ # Collected from the print_* functions in matplotlib.backends _Format = ( - Literal["png", "jpg", "tif", "tiff"] + Literal["png", "jpg", "tif", "tiff"] # noqa: PYI030 | Literal["pdf", "ps", "eps", "svg", "svgz", "pgf"] | Literal["raw", "rgba"] ) @@ -340,7 +340,7 @@ def max_memory(self) -> int | float: return self._max_memory @max_memory.setter - def max_memory(self, max_memory: int | float): + def max_memory(self, max_memory: float): _type_check(max_memory, "max_memory", (int, float)) self._max_memory = max_memory diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 8e886d1ff1..066e23f667 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -12,14 +12,21 @@ import re import sys import warnings -from collections import namedtuple from contextlib import contextmanager, suppress from enum import Enum from functools import partial, reduce, singledispatch, wraps from operator import mul, or_, truediv from textwrap import dedent from types import MethodType, ModuleType, UnionType -from typing import TYPE_CHECKING, Literal, Union, get_args, get_origin, overload +from typing import ( + TYPE_CHECKING, + Literal, + NamedTuple, + Union, + get_args, + get_origin, + overload, +) from weakref import WeakSet import h5py @@ -297,6 +304,11 @@ def get_igraph_from_adjacency(adjacency, directed=None): # -------------------------------------------------------------------------------- +class AssoResult(NamedTuple): + asso_names: list[str] + asso_matrix: NDArray[np.floating] + + def compute_association_matrix_of_groups( adata: AnnData, prediction: str, @@ -305,7 +317,7 @@ def compute_association_matrix_of_groups( normalization: Literal["prediction", "reference"] = "prediction", threshold: float = 0.01, max_n_names: int | None = 2, -): +) -> AssoResult: """Compute overlaps between groups. See ``identify_groups`` for identifying the groups. @@ -347,8 +359,8 @@ def compute_association_matrix_of_groups( f"Ignoring category {cat!r} " "as it’s in `settings.categories_to_ignore`." ) - asso_names = [] - asso_matrix = [] + asso_names: list[str] = [] + asso_matrix: list[list[float]] = [] for ipred_group, pred_group in enumerate(adata.obs[prediction].cat.categories): if "?" in pred_group: pred_group = str(ipred_group) @@ -381,13 +393,12 @@ def compute_association_matrix_of_groups( if asso_matrix[-1][i] > threshold ] asso_names += ["\n".join(name_list_pred[:max_n_names])] - Result = namedtuple( - "compute_association_matrix_of_groups", ["asso_names", "asso_matrix"] - ) - return Result(asso_names=asso_names, asso_matrix=np.array(asso_matrix)) + return AssoResult(asso_names=asso_names, asso_matrix=np.array(asso_matrix)) -def get_associated_colors_of_groups(reference_colors, asso_matrix): +def get_associated_colors_of_groups( + reference_colors: Mapping[int, str], asso_matrix: NDArray[np.floating] +) -> list[dict[str, float]]: return [ { reference_colors[i_ref]: asso_matrix[i_pred, i_ref] diff --git a/src/scanpy/cli.py b/src/scanpy/cli.py index 04b75c8b74..c934292dba 100644 --- a/src/scanpy/cli.py +++ b/src/scanpy/cli.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Generator, Mapping, Sequence + from collections.abc import Iterator, Mapping, Sequence from subprocess import CompletedProcess from typing import Any @@ -64,7 +64,7 @@ def __delitem__(self, k: str) -> None: # These methods retrieve the command list or help with doing it - def __iter__(self) -> Generator[str, None, None]: + def __iter__(self) -> Iterator[str]: yield from self.parser_map yield from self.commands diff --git a/src/scanpy/get/_aggregated.py b/src/scanpy/get/_aggregated.py index 2d2739491e..13ca54b5c4 100644 --- a/src/scanpy/get/_aggregated.py +++ b/src/scanpy/get/_aggregated.py @@ -160,7 +160,7 @@ def median(self) -> Array: return np.array(medians) -def _power(X: Array, power: float | int) -> Array: +def _power(X: Array, power: float) -> Array: """\ Generate elementwise power of a matrix. diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index 0ae810b2c7..a93d55699b 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -104,7 +104,7 @@ def scatter( components: str | Collection[str] | None = None, projection: Literal["2d", "3d"] = "2d", legend_loc: _LegendLoc | None = "right margin", - legend_fontsize: int | float | _FontSize | None = None, + legend_fontsize: float | _FontSize | None = None, legend_fontweight: int | _FontWeight | None = None, legend_fontoutline: float | None = None, color_map: str | Colormap | None = None, @@ -112,7 +112,7 @@ def scatter( frameon: bool | None = None, right_margin: float | None = None, left_margin: float | None = None, - size: int | float | None = None, + size: float | None = None, marker: str | Sequence[str] = ".", title: str | Collection[str] | None = None, show: bool | None = None, @@ -232,7 +232,7 @@ def _scatter_obs( components: str | Collection[str] | None = None, projection: Literal["2d", "3d"] = "2d", legend_loc: _LegendLoc | None = "right margin", - legend_fontsize: int | float | _FontSize | None = None, + legend_fontsize: float | _FontSize | None = None, legend_fontweight: int | _FontWeight | None = None, legend_fontoutline: float | None = None, color_map: str | Colormap | None = None, @@ -240,7 +240,7 @@ def _scatter_obs( frameon: bool | None = None, right_margin: float | None = None, left_margin: float | None = None, - size: int | float | None = None, + size: float | None = None, marker: str | Sequence[str] = ".", title: str | Collection[str] | None = None, show: bool | None = None, diff --git a/src/scanpy/plotting/_stacked_violin.py b/src/scanpy/plotting/_stacked_violin.py index 691dd863d0..e47680facc 100644 --- a/src/scanpy/plotting/_stacked_violin.py +++ b/src/scanpy/plotting/_stacked_violin.py @@ -273,7 +273,7 @@ def style( cmap: Colormap | str | None | Empty = _empty, stripplot: bool | Empty = _empty, jitter: float | bool | Empty = _empty, - jitter_size: int | float | Empty = _empty, + jitter_size: float | Empty = _empty, linewidth: float | None | Empty = _empty, row_palette: str | None | Empty = _empty, density_norm: DensityNorm | Empty = _empty, @@ -470,8 +470,8 @@ def _make_rows_of_violinplots( _matrix, colormap_array, _color_df, - x_spacer_size: float | int, - y_spacer_size: float | int, + x_spacer_size: float, + y_spacer_size: float, x_axis_order, ): import seaborn as sns # Slow import, only import if called @@ -699,7 +699,7 @@ def stacked_violin( cmap: Colormap | str | None = StackedViolin.DEFAULT_COLORMAP, stripplot: bool = StackedViolin.DEFAULT_STRIPPLOT, jitter: float | bool = StackedViolin.DEFAULT_JITTER, - size: int | float = StackedViolin.DEFAULT_JITTER_SIZE, + size: float = StackedViolin.DEFAULT_JITTER_SIZE, row_palette: str | None = StackedViolin.DEFAULT_ROW_PALETTE, density_norm: DensityNorm | Empty = _empty, yticklabels: bool = StackedViolin.DEFAULT_PLOT_YTICKLABELS, diff --git a/src/scanpy/plotting/_tools/__init__.py b/src/scanpy/plotting/_tools/__init__.py index 837d3791e8..a421f6b94a 100644 --- a/src/scanpy/plotting/_tools/__init__.py +++ b/src/scanpy/plotting/_tools/__init__.py @@ -1209,7 +1209,7 @@ def rank_genes_groups_violin( split: bool = True, density_norm: DensityNorm = "width", strip: bool = True, - jitter: int | float | bool = True, + jitter: float | bool = True, size: int = 1, ax: Axes | None = None, show: bool | None = None, @@ -1428,7 +1428,7 @@ def embedding_density( *, key: str | None = None, groupby: str | None = None, - group: str | Sequence[str] | None | None = "all", + group: str | Sequence[str] | None = "all", color_map: Colormap | str = "YlOrRd", bg_dotsize: int | None = 80, fg_dotsize: int | None = 180, diff --git a/src/scanpy/plotting/_tools/paga.py b/src/scanpy/plotting/_tools/paga.py index 29408735b6..7e62d46eac 100644 --- a/src/scanpy/plotting/_tools/paga.py +++ b/src/scanpy/plotting/_tools/paga.py @@ -73,7 +73,7 @@ def paga_compare( components=None, projection: Literal["2d", "3d"] = "2d", legend_loc: _LegendLoc | None = "on data", - legend_fontsize: int | float | _FontSize | None = None, + legend_fontsize: float | _FontSize | None = None, legend_fontweight: int | _FontWeight = "bold", legend_fontoutline=None, color_map=None, @@ -1053,7 +1053,7 @@ def paga_path( show_node_names: bool = True, show_yticks: bool = True, show_colorbar: bool = True, - legend_fontsize: int | float | _FontSize | None = None, + legend_fontsize: float | _FontSize | None = None, legend_fontweight: int | _FontWeight | None = None, normalize_to_zero_one: bool = False, as_heatmap: bool = True, diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index 7f69a76025..4ce39f7211 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -94,7 +94,7 @@ def embedding( na_in_legend: bool = True, size: float | Sequence[float] | None = None, frameon: bool | None = None, - legend_fontsize: int | float | _FontSize | None = None, + legend_fontsize: float | _FontSize | None = None, legend_fontweight: int | _FontWeight = "bold", legend_loc: _LegendLoc | None = "right margin", legend_fontoutline: int | None = None, diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index be452c356d..a7a16bbcc4 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -148,7 +148,6 @@ def scale_array( | tuple[ np.ndarray | DaskArray, NDArray[np.float64] | DaskArray, NDArray[np.float64] ] - | DaskArray ): if copy: X = X.copy() diff --git a/src/scanpy/preprocessing/_scrublet/sparse_utils.py b/src/scanpy/preprocessing/_scrublet/sparse_utils.py index b4ff1a36b0..cc0b1bc815 100644 --- a/src/scanpy/preprocessing/_scrublet/sparse_utils.py +++ b/src/scanpy/preprocessing/_scrublet/sparse_utils.py @@ -17,7 +17,7 @@ def sparse_multiply( E: sparse.csr_matrix | sparse.csc_matrix | NDArray[np.float64], - a: float | int | NDArray[np.float64], + a: float | NDArray[np.float64], ) -> sparse.csr_matrix | sparse.csc_matrix: """multiply each row of E by a scalar""" diff --git a/src/scanpy/tools/_marker_gene_overlap.py b/src/scanpy/tools/_marker_gene_overlap.py index 83a19c86a4..eb07b84885 100644 --- a/src/scanpy/tools/_marker_gene_overlap.py +++ b/src/scanpy/tools/_marker_gene_overlap.py @@ -4,7 +4,7 @@ from __future__ import annotations -from collections.abc import Set +from collections.abc import Set as AbstractSet from typing import TYPE_CHECKING import numpy as np @@ -187,7 +187,7 @@ def marker_gene_overlap( if normalize is not None and method != "overlap_count": raise ValueError("Can only normalize with method=`overlap_count`.") - if not all(isinstance(val, Set) for val in reference_markers.values()): + if not all(isinstance(val, AbstractSet) for val in reference_markers.values()): try: reference_markers = { key: set(val) for key, val in reference_markers.items() diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index f8ab13e9fd..9a2896196a 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -749,7 +749,7 @@ def filter_rank_genes_groups( use_raw: bool | None = None, key_added: str = "rank_genes_groups_filtered", min_in_group_fraction: float = 0.25, - min_fold_change: int | float = 1, + min_fold_change: float = 1, max_out_group_fraction: float = 0.5, compare_abs: bool = False, ) -> None: diff --git a/src/scanpy/tools/_tsne.py b/src/scanpy/tools/_tsne.py index 23d490218b..ac0e6a6317 100644 --- a/src/scanpy/tools/_tsne.py +++ b/src/scanpy/tools/_tsne.py @@ -34,10 +34,10 @@ def tsne( n_pcs: int | None = None, *, use_rep: str | None = None, - perplexity: float | int = 30, + perplexity: float = 30, metric: str = "euclidean", - early_exaggeration: float | int = 12, - learning_rate: float | int = 1000, + early_exaggeration: float = 12, + learning_rate: float = 1000, random_state: AnyRandom = 0, use_fast_tsne: bool = False, n_jobs: int | None = None, From 2f15f796013e6d4fa4152891bc8e642e618a01ff Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Thu, 7 Nov 2024 12:44:51 +0100 Subject: [PATCH 25/51] (feat): `calculate_qc_metrics` with `dask` (#3307) Co-authored-by: Philipp A. --- docs/release-notes/3307.feature.md | 1 + src/scanpy/_utils/__init__.py | 23 +++- src/scanpy/preprocessing/_qc.py | 87 +++++++------ src/testing/scanpy/_helpers/__init__.py | 24 +++- tests/test_qc_metrics.py | 159 +++++++++++++++++------- 5 files changed, 208 insertions(+), 86 deletions(-) create mode 100644 docs/release-notes/3307.feature.md diff --git a/docs/release-notes/3307.feature.md b/docs/release-notes/3307.feature.md new file mode 100644 index 0000000000..1505befb40 --- /dev/null +++ b/docs/release-notes/3307.feature.md @@ -0,0 +1 @@ +Add support {class}`dask.array.Array` to {func}`scanpy.pp.calculate_qc_metrics` {smaller}`I Gold` diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 066e23f667..d97b23f7ae 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -54,7 +54,7 @@ from typing import Any, TypeVar from anndata import AnnData - from numpy.typing import DTypeLike, NDArray + from numpy.typing import ArrayLike, DTypeLike, NDArray from ..neighbors import NeighborsParams, RPForestDict @@ -738,6 +738,27 @@ def _( ) +@singledispatch +def axis_nnz(X: ArrayLike, axis: Literal[0, 1]) -> np.ndarray: + return np.count_nonzero(X, axis=axis) + + +@axis_nnz.register(sparse.spmatrix) +def _(X: sparse.spmatrix, axis: Literal[0, 1]) -> np.ndarray: + return X.getnnz(axis=axis) + + +@axis_nnz.register(DaskArray) +def _(X: DaskArray, axis: Literal[0, 1]) -> DaskArray: + return X.map_blocks( + partial(axis_nnz, axis=axis), + dtype=np.int64, + meta=np.array([], dtype=np.int64), + drop_axis=0, + chunks=len(X.to_delayed()) * (X.chunksize[int(not axis)],), + ) + + @overload def axis_sum( X: sparse.spmatrix, diff --git a/src/scanpy/preprocessing/_qc.py b/src/scanpy/preprocessing/_qc.py index 508beb7861..72b0e9cd50 100644 --- a/src/scanpy/preprocessing/_qc.py +++ b/src/scanpy/preprocessing/_qc.py @@ -1,15 +1,19 @@ from __future__ import annotations +from functools import singledispatch from typing import TYPE_CHECKING from warnings import warn import numba import numpy as np import pandas as pd -from scipy.sparse import csr_matrix, issparse, isspmatrix_coo, isspmatrix_csr -from sklearn.utils.sparsefuncs import mean_variance_axis +from scipy.sparse import csr_matrix, issparse, isspmatrix_coo, isspmatrix_csr, spmatrix -from .._utils import _doc_params +from scanpy.preprocessing._distributed import materialize_as_ndarray +from scanpy.preprocessing._utils import _get_mean_var + +from .._compat import DaskArray +from .._utils import _doc_params, axis_nnz, axis_sum from ._docs import ( doc_adata_basic, doc_expr_reps, @@ -23,7 +27,6 @@ from collections.abc import Collection from anndata import AnnData - from scipy.sparse import spmatrix def _choose_mtx_rep(adata, *, use_raw: bool = False, layer: str | None = None): @@ -104,15 +107,14 @@ def describe_obs( if issparse(X): X.eliminate_zeros() obs_metrics = pd.DataFrame(index=adata.obs_names) - if issparse(X): - obs_metrics[f"n_{var_type}_by_{expr_type}"] = X.getnnz(axis=1) - else: - obs_metrics[f"n_{var_type}_by_{expr_type}"] = np.count_nonzero(X, axis=1) + obs_metrics[f"n_{var_type}_by_{expr_type}"] = materialize_as_ndarray( + axis_nnz(X, axis=1) + ) if log1p: obs_metrics[f"log1p_n_{var_type}_by_{expr_type}"] = np.log1p( obs_metrics[f"n_{var_type}_by_{expr_type}"] ) - obs_metrics[f"total_{expr_type}"] = np.ravel(X.sum(axis=1)) + obs_metrics[f"total_{expr_type}"] = np.ravel(axis_sum(X, axis=1)) if log1p: obs_metrics[f"log1p_total_{expr_type}"] = np.log1p( obs_metrics[f"total_{expr_type}"] @@ -126,7 +128,7 @@ def describe_obs( ) for qc_var in qc_vars: obs_metrics[f"total_{expr_type}_{qc_var}"] = np.ravel( - X[:, adata.var[qc_var].values].sum(axis=1) + axis_sum(X[:, adata.var[qc_var].values], axis=1) ) if log1p: obs_metrics[f"log1p_total_{expr_type}_{qc_var}"] = np.log1p( @@ -141,6 +143,7 @@ def describe_obs( adata.obs[obs_metrics.columns] = obs_metrics else: return obs_metrics + return None @_doc_params( @@ -191,13 +194,9 @@ def describe_var( if issparse(X): X.eliminate_zeros() var_metrics = pd.DataFrame(index=adata.var_names) - if issparse(X): - # Current memory bottleneck for csr matrices: - var_metrics["n_cells_by_{expr_type}"] = X.getnnz(axis=0) - var_metrics["mean_{expr_type}"] = mean_variance_axis(X, axis=0)[0] - else: - var_metrics["n_cells_by_{expr_type}"] = np.count_nonzero(X, axis=0) - var_metrics["mean_{expr_type}"] = X.mean(axis=0) + var_metrics["n_cells_by_{expr_type}"], var_metrics["mean_{expr_type}"] = ( + materialize_as_ndarray((axis_nnz(X, axis=0), _get_mean_var(X, axis=0)[0])) + ) if log1p: var_metrics["log1p_mean_{expr_type}"] = np.log1p( var_metrics["mean_{expr_type}"] @@ -205,7 +204,7 @@ def describe_var( var_metrics["pct_dropout_by_{expr_type}"] = ( 1 - var_metrics["n_cells_by_{expr_type}"] / X.shape[0] ) * 100 - var_metrics["total_{expr_type}"] = np.ravel(X.sum(axis=0)) + var_metrics["total_{expr_type}"] = np.ravel(axis_sum(X, axis=0)) if log1p: var_metrics["log1p_total_{expr_type}"] = np.log1p( var_metrics["total_{expr_type}"] @@ -217,8 +216,8 @@ def describe_var( var_metrics.columns = new_colnames if inplace: adata.var[var_metrics.columns] = var_metrics - else: - return var_metrics + return None + return var_metrics @_doc_params( @@ -387,9 +386,18 @@ def top_proportions_sparse_csr(data, indptr, n): return values -def top_segment_proportions( - mtx: np.ndarray | spmatrix, ns: Collection[int] -) -> np.ndarray: +def check_ns(func): + def check_ns_inner(mtx: np.ndarray | spmatrix | DaskArray, ns: Collection[int]): + if not (max(ns) <= mtx.shape[1] and min(ns) > 0): + raise IndexError("Positions outside range of features.") + return func(mtx, ns) + + return check_ns_inner + + +@singledispatch +@check_ns +def top_segment_proportions(mtx: np.ndarray, ns: Collection[int]) -> np.ndarray: """ Calculates total percentage of counts in top ns genes. @@ -402,20 +410,6 @@ def top_segment_proportions( 1-indexed, e.g. `ns=[50]` will calculate cumulative proportion up to the 50th most expressed gene. """ - # Pretty much just does dispatch - if not (max(ns) <= mtx.shape[1] and min(ns) > 0): - raise IndexError("Positions outside range of features.") - if issparse(mtx): - if not isspmatrix_csr(mtx): - mtx = csr_matrix(mtx) - return top_segment_proportions_sparse_csr(mtx.data, mtx.indptr, np.array(ns)) - else: - return top_segment_proportions_dense(mtx, ns) - - -def top_segment_proportions_dense( - mtx: np.ndarray | spmatrix, ns: Collection[int] -) -> np.ndarray: # Currently ns is considered to be 1 indexed ns = np.sort(ns) sums = mtx.sum(axis=1) @@ -432,6 +426,25 @@ def top_segment_proportions_dense( return values / sums[:, None] +@top_segment_proportions.register(DaskArray) +@check_ns +def _(mtx: DaskArray, ns: Collection[int]) -> DaskArray: + if not isinstance(mtx._meta, csr_matrix | np.ndarray): + msg = f"DaskArray must have csr matrix or ndarray meta, got {mtx._meta}." + raise ValueError(msg) + return mtx.map_blocks( + lambda x: top_segment_proportions(x, ns), meta=np.array([]) + ).compute() + + +@top_segment_proportions.register(spmatrix) +@check_ns +def _(mtx: spmatrix, ns: Collection[int]) -> DaskArray: + if not isspmatrix_csr(mtx): + mtx = csr_matrix(mtx) + return top_segment_proportions_sparse_csr(mtx.data, mtx.indptr, np.array(ns)) + + @numba.njit(cache=True, parallel=True) def top_segment_proportions_sparse_csr(data, indptr, ns): # work around https://github.com/numba/numba/issues/5056 diff --git a/src/testing/scanpy/_helpers/__init__.py b/src/testing/scanpy/_helpers/__init__.py index 0c59eb592f..3cff738132 100644 --- a/src/testing/scanpy/_helpers/__init__.py +++ b/src/testing/scanpy/_helpers/__init__.py @@ -5,8 +5,9 @@ from __future__ import annotations import warnings -from contextlib import AbstractContextManager +from contextlib import AbstractContextManager, contextmanager from dataclasses import dataclass +from importlib.util import find_spec from itertools import permutations from typing import TYPE_CHECKING @@ -158,3 +159,24 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): for ctx in reversed(self.contexts): ctx.__exit__(exc_type, exc_value, traceback) + + +@contextmanager +def maybe_dask_process_context(): + """ + Running numba with dask's threaded scheduler causes crashes, + so we need to switch to single-threaded (or processes, which is slower) + scheduler for tests that use numba. + """ + if not find_spec("dask"): + yield + return + + import dask.config + + prev_scheduler = dask.config.get("scheduler", "threads") + dask.config.set(scheduler="single-threaded") + try: + yield + finally: + dask.config.set(scheduler=prev_scheduler) diff --git a/tests/test_qc_metrics.py b/tests/test_qc_metrics.py index 83971fa2ce..7ca6534b7c 100644 --- a/tests/test_qc_metrics.py +++ b/tests/test_qc_metrics.py @@ -4,19 +4,25 @@ import pandas as pd import pytest from anndata import AnnData +from anndata.tests.helpers import assert_equal from scipy import sparse import scanpy as sc +from scanpy._compat import DaskArray +from scanpy._utils import axis_sum from scanpy.preprocessing._qc import ( describe_obs, describe_var, top_proportions, top_segment_proportions, ) +from testing.scanpy._helpers import as_sparse_dask_array, maybe_dask_process_context +from testing.scanpy._pytest.marks import needs +from testing.scanpy._pytest.params import ARRAY_TYPES @pytest.fixture -def anndata(): +def adata() -> AnnData: a = np.random.binomial(100, 0.005, (1000, 1000)) adata = AnnData( sparse.csr_matrix(a), @@ -26,6 +32,22 @@ def anndata(): return adata +def prepare_adata(adata: AnnData) -> AnnData: + if isinstance(adata.X, DaskArray): + adata.X = adata.X.rechunk((100, -1)) + adata.var["mito"] = np.concatenate( + (np.ones(100, dtype=bool), np.zeros(900, dtype=bool)) + ) + adata.var["negative"] = False + return adata + + +@pytest.fixture(params=ARRAY_TYPES) +def adata_prepared(request: pytest.FixtureRequest, adata: AnnData) -> AnnData: + adata.X = request.param(adata.X) + return prepare_adata(adata) + + @pytest.mark.parametrize( "a", [np.ones((100, 100)), sparse.csr_matrix(np.ones((100, 100)))], @@ -67,58 +89,101 @@ def test_top_segments(cls): # While many of these are trivial, # they’re also just making sure the metrics are there -def test_qc_metrics(): - adata = AnnData(X=sparse.csr_matrix(np.random.binomial(100, 0.005, (1000, 1000)))) - adata.var["mito"] = np.concatenate( - (np.ones(100, dtype=bool), np.zeros(900, dtype=bool)) +def test_qc_metrics(adata_prepared: AnnData): + with maybe_dask_process_context(): + sc.pp.calculate_qc_metrics( + adata_prepared, qc_vars=["mito", "negative"], inplace=True + ) + X = ( + adata_prepared.X.compute() + if isinstance(adata_prepared.X, DaskArray) + else adata_prepared.X ) - adata.var["negative"] = False - sc.pp.calculate_qc_metrics(adata, qc_vars=["mito", "negative"], inplace=True) - assert (adata.obs["n_genes_by_counts"] < adata.shape[1]).all() + max_X = X.max(axis=0) + if isinstance(max_X, sparse.spmatrix): + max_X = max_X.toarray() + elif isinstance(max_X, DaskArray): + max_X = max_X.compute() + assert (adata_prepared.obs["n_genes_by_counts"] < adata_prepared.shape[1]).all() + assert ( + adata_prepared.obs["n_genes_by_counts"] + >= adata_prepared.obs["log1p_n_genes_by_counts"] + ).all() + assert ( + adata_prepared.obs["total_counts"] + == np.ravel(axis_sum(adata_prepared.X, axis=1)) + ).all() assert ( - adata.obs["n_genes_by_counts"] >= adata.obs["log1p_n_genes_by_counts"] + adata_prepared.obs["total_counts"] >= adata_prepared.obs["log1p_total_counts"] ).all() - assert (adata.obs["total_counts"] == np.ravel(adata.X.sum(axis=1))).all() - assert (adata.obs["total_counts"] >= adata.obs["log1p_total_counts"]).all() assert ( - adata.obs["total_counts_mito"] >= adata.obs["log1p_total_counts_mito"] + adata_prepared.obs["total_counts_mito"] + >= adata_prepared.obs["log1p_total_counts_mito"] ).all() - assert (adata.obs["total_counts_negative"] == 0).all() + assert (adata_prepared.obs["total_counts_negative"] == 0).all() assert ( - adata.obs["pct_counts_in_top_50_genes"] - <= adata.obs["pct_counts_in_top_100_genes"] + adata_prepared.obs["pct_counts_in_top_50_genes"] + <= adata_prepared.obs["pct_counts_in_top_100_genes"] ).all() - for col in filter(lambda x: "negative" not in x, adata.obs.columns): - assert (adata.obs[col] >= 0).all() # Values should be positive or zero - assert (adata.obs[col] != 0).any().all() # Nothing should be all zeros + for col in filter(lambda x: "negative" not in x, adata_prepared.obs.columns): + assert (adata_prepared.obs[col] >= 0).all() # Values should be positive or zero + assert (adata_prepared.obs[col] != 0).any().all() # Nothing should be all zeros if col.startswith("pct_counts_in_top"): - assert (adata.obs[col] <= 100).all() - assert (adata.obs[col] >= 0).all() - for col in adata.var.columns: - assert (adata.var[col] >= 0).all() - assert (adata.var["mean_counts"] < np.ravel(adata.X.max(axis=0).todense())).all() - assert (adata.var["mean_counts"] >= adata.var["log1p_mean_counts"]).all() - assert (adata.var["total_counts"] >= adata.var["log1p_total_counts"]).all() - # Should return the same thing if run again - old_obs, old_var = adata.obs.copy(), adata.var.copy() - sc.pp.calculate_qc_metrics(adata, qc_vars=["mito", "negative"], inplace=True) - assert set(adata.obs.columns) == set(old_obs.columns) - assert set(adata.var.columns) == set(old_var.columns) - for col in adata.obs: - assert np.allclose(adata.obs[col], old_obs[col]) - for col in adata.var: - assert np.allclose(adata.var[col], old_var[col]) - # with log1p=False - adata = AnnData(X=sparse.csr_matrix(np.random.binomial(100, 0.005, (1000, 1000)))) - adata.var["mito"] = np.concatenate( - (np.ones(100, dtype=bool), np.zeros(900, dtype=bool)) - ) - adata.var["negative"] = False + assert (adata_prepared.obs[col] <= 100).all() + assert (adata_prepared.obs[col] >= 0).all() + for col in adata_prepared.var.columns: + assert (adata_prepared.var[col] >= 0).all() + assert (adata_prepared.var["mean_counts"] < np.ravel(max_X)).all() + assert ( + adata_prepared.var["mean_counts"] >= adata_prepared.var["log1p_mean_counts"] + ).all() + assert ( + adata_prepared.var["total_counts"] >= adata_prepared.var["log1p_total_counts"] + ).all() + + +def test_qc_metrics_idempotent(adata_prepared: AnnData): + with maybe_dask_process_context(): + sc.pp.calculate_qc_metrics( + adata_prepared, qc_vars=["mito", "negative"], inplace=True + ) + old_obs, old_var = adata_prepared.obs.copy(), adata_prepared.var.copy() + sc.pp.calculate_qc_metrics( + adata_prepared, qc_vars=["mito", "negative"], inplace=True + ) + assert set(adata_prepared.obs.columns) == set(old_obs.columns) + assert set(adata_prepared.var.columns) == set(old_var.columns) + for col in adata_prepared.obs: + assert np.allclose(adata_prepared.obs[col], old_obs[col]) + for col in adata_prepared.var: + assert np.allclose(adata_prepared.var[col], old_var[col]) + + +def test_qc_metrics_no_log1p(adata_prepared: AnnData): + with maybe_dask_process_context(): + sc.pp.calculate_qc_metrics( + adata_prepared, qc_vars=["mito", "negative"], log1p=False, inplace=True + ) + assert not np.any(adata_prepared.obs.columns.str.startswith("log1p_")) + assert not np.any(adata_prepared.var.columns.str.startswith("log1p_")) + + +@needs.dask +@pytest.mark.anndata_dask_support +@pytest.mark.parametrize("log1p", [True, False], ids=["log1p", "no_log1p"]) +def test_dask_against_in_memory(adata, log1p): + adata_as_dask = adata.copy() + adata_as_dask.X = as_sparse_dask_array(adata.X) + adata = prepare_adata(adata) + adata_as_dask = prepare_adata(adata_as_dask) + with maybe_dask_process_context(): + sc.pp.calculate_qc_metrics( + adata_as_dask, qc_vars=["mito", "negative"], log1p=log1p, inplace=True + ) sc.pp.calculate_qc_metrics( - adata, qc_vars=["mito", "negative"], log1p=False, inplace=True + adata, qc_vars=["mito", "negative"], log1p=log1p, inplace=True ) - assert not np.any(adata.obs.columns.str.startswith("log1p_")) - assert not np.any(adata.var.columns.str.startswith("log1p_")) + assert_equal(adata, adata_as_dask) def adata_mito(): @@ -166,8 +231,8 @@ def test_qc_metrics_percentage(): # In response to #421 sc.pp.calculate_qc_metrics(adata_dense, percent_top=[20, 30, 1001]) -def test_layer_raw(anndata): - adata = anndata.copy() +def test_layer_raw(adata: AnnData): + adata = adata.copy() adata.raw = adata.copy() adata.layers["counts"] = adata.X.copy() obs_orig, var_orig = sc.pp.calculate_qc_metrics(adata) @@ -180,8 +245,8 @@ def test_layer_raw(anndata): assert np.allclose(var_orig, var_raw) -def test_inner_methods(anndata): - adata = anndata.copy() +def test_inner_methods(adata: AnnData): + adata = adata.copy() full_inplace = adata.copy() partial_inplace = adata.copy() obs_orig, var_orig = sc.pp.calculate_qc_metrics(adata) From 9d3c340152543a6364d9c55bc11e610027ea319f Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 7 Nov 2024 15:39:45 +0100 Subject: [PATCH 26/51] Fix docs (#3343) --- src/scanpy/readwrite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scanpy/readwrite.py b/src/scanpy/readwrite.py index cb75eb10cc..3c958a1e50 100644 --- a/src/scanpy/readwrite.py +++ b/src/scanpy/readwrite.py @@ -131,7 +131,7 @@ def read( See the h5py :ref:`dataset_compression`. (Default: `settings.cache_compression`) kwargs - Parameters passed to :func:`~anndata.read_loom`. + Parameters passed to :func:`~anndata.io.read_loom`. Returns ------- From d0adc25fa2dea621df87ccdcf1fcf96e894f3901 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 8 Nov 2024 18:17:44 +0100 Subject: [PATCH 27/51] move all `njit` calls into a decorator (#3335) --- docs/release-notes/3335.feature.md | 1 + pyproject.toml | 2 + src/scanpy/_compat.py | 108 +++++++++++++++++- src/scanpy/_utils/compute/is_constant.py | 23 ++-- .../experimental/pp/_highly_variable_genes.py | 5 +- src/scanpy/metrics/_gearys_c.py | 13 +-- src/scanpy/metrics/_morans_i.py | 12 +- .../preprocessing/_highly_variable_genes.py | 5 +- src/scanpy/preprocessing/_qc.py | 4 +- src/scanpy/preprocessing/_scale.py | 6 +- src/scanpy/preprocessing/_simple.py | 3 +- src/scanpy/preprocessing/_utils.py | 5 +- 12 files changed, 150 insertions(+), 37 deletions(-) create mode 100644 docs/release-notes/3335.feature.md diff --git a/docs/release-notes/3335.feature.md b/docs/release-notes/3335.feature.md new file mode 100644 index 0000000000..77a1723a8e --- /dev/null +++ b/docs/release-notes/3335.feature.md @@ -0,0 +1 @@ +Run numba functions single-threaded when called from inside of a ThreadPool {smaller}`P Angerer` diff --git a/pyproject.toml b/pyproject.toml index e983d04a97..526eca781d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -262,6 +262,8 @@ required-imports = ["from __future__ import annotations"] "pandas.value_counts".msg = "Use pd.Series(a).value_counts() instead" "legacy_api_wrap.legacy_api".msg = "Use scanpy._compat.old_positionals instead" "numpy.bool".msg = "Use `np.bool_` instead for numpy>=1.24<2 compatibility" +"numba.jit".msg = "Use `scanpy._compat.njit` instead" +"numba.njit".msg = "Use `scanpy._compat.njit` instead" [tool.ruff.lint.flake8-type-checking] exempt-modules = [] strict = true diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index e247524c31..c5fa4dbe84 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -1,17 +1,23 @@ from __future__ import annotations +import os import sys +import warnings from dataclasses import dataclass, field -from functools import cache, partial +from functools import cache, partial, wraps from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, ParamSpec, TypeVar, cast, overload from packaging.version import Version if TYPE_CHECKING: + from collections.abc import Callable from importlib.metadata import PackageMetadata +P = ParamSpec("P") +R = TypeVar("R") + if TYPE_CHECKING: # type checkers are confused and can only see …core.Array @@ -90,3 +96,101 @@ def pkg_version(package: str) -> Version: # but this code makes it possible to run scanpy without it. def old_positionals(*old_positionals: str): return lambda func: func + + +@overload +def njit(fn: Callable[P, R], /) -> Callable[P, R]: ... +@overload +def njit() -> Callable[[Callable[P, R]], Callable[P, R]]: ... +def njit( + fn: Callable[P, R] | None = None, / +) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: + """\ + Jit-compile a function using numba. + + On call, this function dispatches to a parallel or sequential numba function, + depending on if it has been called from a thread pool. + + See + """ + + def decorator(f: Callable[P, R], /) -> Callable[P, R]: + import numba + + fns: dict[bool, Callable[P, R]] = { + parallel: numba.njit(f, cache=True, parallel=parallel) # noqa: TID251 + for parallel in (True, False) + } + + @wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + parallel = not _is_in_unsafe_thread_pool() + if not parallel: + msg = ( + "Detected unsupported threading environment. " + f"Trying to run {f.__name__} in serial mode. " + "In case of problems, install `tbb`." + ) + warnings.warn(msg, stacklevel=2) + return fns[parallel](*args, **kwargs) + + return wrapper + + return decorator if fn is None else decorator(fn) + + +LayerType = Literal["default", "safe", "threadsafe", "forksafe"] +Layer = Literal["tbb", "omp", "workqueue"] + + +LAYERS: dict[LayerType, set[Layer]] = { + "default": {"tbb", "omp", "workqueue"}, + "safe": {"tbb"}, + "threadsafe": {"tbb", "omp"}, + "forksafe": {"tbb", "workqueue", *(() if sys.platform == "linux" else {"omp"})}, +} + + +def _is_in_unsafe_thread_pool() -> bool: + import threading + + current_thread = threading.current_thread() + # ThreadPoolExecutor threads typically have names like 'ThreadPoolExecutor-0_1' + return ( + current_thread.name.startswith("ThreadPoolExecutor") + and _numba_threading_layer() not in LAYERS["threadsafe"] + ) + + +@cache +def _numba_threading_layer() -> Layer: + """\ + Get numba’s threading layer. + + This function implements the algorithm as described in + + """ + import importlib + + import numba + + if (available := LAYERS.get(numba.config.THREADING_LAYER)) is None: + # given by direct name + return numba.config.THREADING_LAYER + + # given by layer type (safe, …) + for layer in cast(list[Layer], numba.config.THREADING_LAYER_PRIORITY): + if layer not in available: + continue + if layer != "workqueue": + try: # `importlib.util.find_spec` doesn’t work here + importlib.import_module(f"numba.np.ufunc.{layer}pool") + except ImportError: + continue + # the layer has been found + return layer + msg = ( + f"No loadable threading layer: {numba.config.THREADING_LAYER=} " + f" ({available=}, {numba.config.THREADING_LAYER_PRIORITY=})" + ) + raise ValueError(msg) diff --git a/src/scanpy/_utils/compute/is_constant.py b/src/scanpy/_utils/compute/is_constant.py index 80f6581980..1bc147d68e 100644 --- a/src/scanpy/_utils/compute/is_constant.py +++ b/src/scanpy/_utils/compute/is_constant.py @@ -5,11 +5,11 @@ from numbers import Integral from typing import TYPE_CHECKING, TypeVar, overload +import numba import numpy as np -from numba import njit from scipy import sparse -from ..._compat import DaskArray +from ..._compat import DaskArray, njit if TYPE_CHECKING: from typing import Literal @@ -103,22 +103,21 @@ def _( else: return (a.data == 0).all() if axis == 1: - return _is_constant_csr_rows(a.data, a.indices, a.indptr, a.shape) + return _is_constant_csr_rows(a.data, a.indptr, a.shape) elif axis == 0: a = a.T.tocsr() - return _is_constant_csr_rows(a.data, a.indices, a.indptr, a.shape) + return _is_constant_csr_rows(a.data, a.indptr, a.shape) @njit def _is_constant_csr_rows( data: NDArray[np.number], - indices: NDArray[np.integer], indptr: NDArray[np.integer], shape: tuple[int, int], -): +) -> NDArray[np.bool_]: n = len(indptr) - 1 result = np.ones(n, dtype=np.bool_) - for i in range(n): + for i in numba.prange(n): start = indptr[i] stop = indptr[i + 1] val = data[start] if stop - start == shape[1] else 0 @@ -139,10 +138,10 @@ def _( else: return (a.data == 0).all() if axis == 0: - return _is_constant_csr_rows(a.data, a.indices, a.indptr, a.shape[::-1]) + return _is_constant_csr_rows(a.data, a.indptr, a.shape[::-1]) elif axis == 1: a = a.T.tocsc() - return _is_constant_csr_rows(a.data, a.indices, a.indptr, a.shape[::-1]) + return _is_constant_csr_rows(a.data, a.indptr, a.shape[::-1]) @is_constant.register(DaskArray) @@ -151,4 +150,8 @@ def _(a: DaskArray, axis: Literal[0, 1] | None = None) -> bool | NDArray[np.bool v = a[tuple(0 for _ in range(a.ndim))].compute() return (a == v).all() # TODO: use overlapping blocks and reduction instead of `drop_axis` - return a.map_blocks(partial(is_constant, axis=axis), drop_axis=axis) + return a.map_blocks( + partial(is_constant, axis=axis), + drop_axis=axis, + meta=np.array([], dtype=a.dtype), + ) diff --git a/src/scanpy/experimental/pp/_highly_variable_genes.py b/src/scanpy/experimental/pp/_highly_variable_genes.py index a8f8929e93..ab78f0a74a 100644 --- a/src/scanpy/experimental/pp/_highly_variable_genes.py +++ b/src/scanpy/experimental/pp/_highly_variable_genes.py @@ -12,6 +12,7 @@ from anndata import AnnData from scanpy import logging as logg +from scanpy._compat import njit from scanpy._settings import Verbosity, settings from scanpy._utils import _doc_params, check_nonnegative_integers, view_to_actual from scanpy.experimental._docs import ( @@ -32,7 +33,7 @@ from numpy.typing import NDArray -@nb.njit(parallel=True) +@njit def _calculate_res_sparse( indptr: NDArray[np.integer], index: NDArray[np.integer], @@ -92,7 +93,7 @@ def clac_clipped_res_sparse(gene: int, cell: int, value: np.float64) -> np.float return residuals -@nb.njit(parallel=True) +@njit def _calculate_res_dense( matrix, *, diff --git a/src/scanpy/metrics/_gearys_c.py b/src/scanpy/metrics/_gearys_c.py index a0ca9a0b61..358a201eed 100644 --- a/src/scanpy/metrics/_gearys_c.py +++ b/src/scanpy/metrics/_gearys_c.py @@ -9,7 +9,7 @@ import numpy as np from scipy import sparse -from .._compat import fullname +from .._compat import fullname, njit from ..get import _get_obs_rep from ._common import _check_vals, _resolve_vals @@ -136,7 +136,6 @@ def gearys_c( # tests to fail. -@numba.njit(cache=True, parallel=True) def _gearys_c_vec( data: np.ndarray, indices: np.ndarray, @@ -147,7 +146,7 @@ def _gearys_c_vec( return _gearys_c_vec_W(data, indices, indptr, x, W) -@numba.njit(cache=True, parallel=True) +@njit def _gearys_c_vec_W( data: np.ndarray, indices: np.ndarray, @@ -182,7 +181,7 @@ def _gearys_c_vec_W( # https://github.com/numba/numba/issues/6774#issuecomment-788789663 -@numba.njit(cache=True) +@numba.njit(cache=True, parallel=False) # noqa: TID251 def _gearys_c_inner_sparse_x_densevec( g_data: np.ndarray, g_indices: np.ndarray, @@ -203,7 +202,7 @@ def _gearys_c_inner_sparse_x_densevec( return numer / denom -@numba.njit(cache=True) +@numba.njit(cache=True, parallel=False) # noqa: TID251 def _gearys_c_inner_sparse_x_sparsevec( # noqa: PLR0917 g_data: np.ndarray, g_indices: np.ndarray, @@ -239,7 +238,7 @@ def _gearys_c_inner_sparse_x_sparsevec( # noqa: PLR0917 return numer / denom -@numba.njit(cache=True, parallel=True) +@njit def _gearys_c_mtx( g_data: np.ndarray, g_indices: np.ndarray, @@ -256,7 +255,7 @@ def _gearys_c_mtx( return out -@numba.njit(cache=True, parallel=True) +@njit def _gearys_c_mtx_csr( # noqa: PLR0917 g_data: np.ndarray, g_indices: np.ndarray, diff --git a/src/scanpy/metrics/_morans_i.py b/src/scanpy/metrics/_morans_i.py index 7c7609323e..5e4ab50788 100644 --- a/src/scanpy/metrics/_morans_i.py +++ b/src/scanpy/metrics/_morans_i.py @@ -9,7 +9,7 @@ import numpy as np from scipy import sparse -from .._compat import fullname +from .._compat import fullname, njit from ..get import _get_obs_rep from ._common import _check_vals, _resolve_vals @@ -126,7 +126,7 @@ def morans_i( # This is done in a very similar way to gearys_c. See notes there for details. -@numba.njit(cache=True, parallel=True) +@njit def _morans_i_vec( g_data: np.ndarray, g_indices: np.ndarray, @@ -137,7 +137,7 @@ def _morans_i_vec( return _morans_i_vec_W(g_data, g_indices, g_indptr, x, W) -@numba.njit(cache=True) +@numba.njit(cache=True, parallel=False) # noqa: TID251 def _morans_i_vec_W( g_data: np.ndarray, g_indices: np.ndarray, @@ -159,7 +159,7 @@ def _morans_i_vec_W( return len(x) / W * inum / z2ss -@numba.njit(cache=True) +@numba.njit(cache=True, parallel=False) # noqa: TID251 def _morans_i_vec_W_sparse( # noqa: PLR0917 g_data: np.ndarray, g_indices: np.ndarray, @@ -174,7 +174,7 @@ def _morans_i_vec_W_sparse( # noqa: PLR0917 return _morans_i_vec_W(g_data, g_indices, g_indptr, x, W) -@numba.njit(cache=True, parallel=True) +@njit def _morans_i_mtx( g_data: np.ndarray, g_indices: np.ndarray, @@ -191,7 +191,7 @@ def _morans_i_mtx( return out -@numba.njit(cache=True, parallel=True) +@njit def _morans_i_mtx_csr( # noqa: PLR0917 g_data: np.ndarray, g_indices: np.ndarray, diff --git a/src/scanpy/preprocessing/_highly_variable_genes.py b/src/scanpy/preprocessing/_highly_variable_genes.py index fa7971d21e..e34340b256 100644 --- a/src/scanpy/preprocessing/_highly_variable_genes.py +++ b/src/scanpy/preprocessing/_highly_variable_genes.py @@ -200,7 +200,8 @@ def _highly_variable_genes_seurat_v3( return df -@numba.njit(cache=True) +# parallel=False needed for accuracy +@numba.njit(cache=True, parallel=False) # noqa: TID251 def _sum_and_sum_squares_clipped( indices: NDArray[np.integer], data: NDArray[np.floating], @@ -211,7 +212,7 @@ def _sum_and_sum_squares_clipped( ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: squared_batch_counts_sum = np.zeros(n_cols, dtype=np.float64) batch_counts_sum = np.zeros(n_cols, dtype=np.float64) - for i in range(nnz): + for i in numba.prange(nnz): idx = indices[i] element = min(np.float64(data[i]), clip_val[idx]) squared_batch_counts_sum[idx] += element**2 diff --git a/src/scanpy/preprocessing/_qc.py b/src/scanpy/preprocessing/_qc.py index 72b0e9cd50..27836e1717 100644 --- a/src/scanpy/preprocessing/_qc.py +++ b/src/scanpy/preprocessing/_qc.py @@ -12,7 +12,7 @@ from scanpy.preprocessing._distributed import materialize_as_ndarray from scanpy.preprocessing._utils import _get_mean_var -from .._compat import DaskArray +from .._compat import DaskArray, njit from .._utils import _doc_params, axis_nnz, axis_sum from ._docs import ( doc_adata_basic, @@ -445,7 +445,7 @@ def _(mtx: spmatrix, ns: Collection[int]) -> DaskArray: return top_segment_proportions_sparse_csr(mtx.data, mtx.indptr, np.array(ns)) -@numba.njit(cache=True, parallel=True) +@njit def top_segment_proportions_sparse_csr(data, indptr, ns): # work around https://github.com/numba/numba/issues/5056 indptr = indptr.astype(np.int64) diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index a7a16bbcc4..760c66cc5a 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -11,7 +11,7 @@ from scipy.sparse import issparse, isspmatrix_csc, spmatrix from .. import logging as logg -from .._compat import DaskArray, old_positionals +from .._compat import DaskArray, njit, old_positionals from .._utils import ( _check_array_function_arguments, axis_mul_or_truediv, @@ -32,7 +32,7 @@ from numpy.typing import NDArray -@numba.njit(cache=True, parallel=True) +@njit def _scale_sparse_numba(indptr, indices, data, *, std, mask_obs, clip): for i in numba.prange(len(indptr) - 1): if mask_obs[i]: @@ -43,7 +43,7 @@ def _scale_sparse_numba(indptr, indices, data, *, std, mask_obs, clip): data[j] /= std[indices[j]] -@numba.njit(parallel=True, cache=True) +@njit def clip_array(X: np.ndarray, *, max_value: float = 10, zero_center: bool = True): a_min, a_max = -max_value, max_value if X.ndim > 1: diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index e266cfc2a6..1a781aae71 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -982,7 +982,8 @@ def _downsample_total_counts(X, total_counts, random_state, replace): return X -@numba.njit(cache=True) +# TODO: can/should this be parallelized? +@numba.njit(cache=True) # noqa: TID251 def _downsample_array( col: np.ndarray, target: int, diff --git a/src/scanpy/preprocessing/_utils.py b/src/scanpy/preprocessing/_utils.py index f5ba280cfd..300d6450e8 100644 --- a/src/scanpy/preprocessing/_utils.py +++ b/src/scanpy/preprocessing/_utils.py @@ -8,6 +8,7 @@ from scipy import sparse from sklearn.random_projection import sample_without_replacement +from .._compat import njit from .._utils import axis_sum, elem_mul if TYPE_CHECKING: @@ -83,7 +84,7 @@ def sparse_mean_variance_axis(mtx: sparse.spmatrix, axis: int): ) -@numba.njit(cache=True, parallel=True) +@njit def sparse_mean_var_minor_axis( data, indices, indptr, *, major_len, minor_len, n_threads ): @@ -116,7 +117,7 @@ def sparse_mean_var_minor_axis( return means, variances -@numba.njit(cache=True, parallel=True) +@njit def sparse_mean_var_major_axis(data, indptr, *, major_len, minor_len, n_threads): """ Computes mean and variance for a sparse array for the major axis. From ff44a900590721412c5270c0555dc4a1f3d9c7d0 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Mon, 11 Nov 2024 13:43:38 +0100 Subject: [PATCH 28/51] Update notebooks (#3349) --- notebooks | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks b/notebooks index 3385df77ce..9f6926f87f 160000 --- a/notebooks +++ b/notebooks @@ -1 +1 @@ -Subproject commit 3385df77ce0f63987104bc644562a811c5d1b441 +Subproject commit 9f6926f87f052603916ee8f222965f654896e0c7 From a227c123a88c8305a93289d5985dcaa9917a7652 Mon Sep 17 00:00:00 2001 From: kaushal Date: Mon, 11 Nov 2024 18:22:09 +0530 Subject: [PATCH 29/51] Speedup (~20x) of scanpy.pp.regress_out function using Linear Least Square method. (#3284) Co-authored-by: Intron7 Co-authored-by: Philipp A. Co-authored-by: Severin Dicks <37635888+Intron7@users.noreply.github.com> --- docs/release-notes/3284.performance.md | 1 + src/scanpy/preprocessing/_simple.py | 90 ++++++++++++++++++------- src/scanpy/preprocessing/_utils.py | 43 ++++++++++++ tests/_data/regress_test_small.npy | Bin 0 -> 320128 bytes tests/test_preprocessing.py | 27 +++++++- 5 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 docs/release-notes/3284.performance.md create mode 100644 tests/_data/regress_test_small.npy diff --git a/docs/release-notes/3284.performance.md b/docs/release-notes/3284.performance.md new file mode 100644 index 0000000000..31c95245ff --- /dev/null +++ b/docs/release-notes/3284.performance.md @@ -0,0 +1 @@ +* Speed up {func}`~scanpy.pp.regress_out` {smaller}`P Ashish, P Angerer & S Dicks` diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 1a781aae71..4d540ef931 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -8,7 +8,7 @@ import warnings from functools import singledispatch from itertools import repeat -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar import numba import numpy as np @@ -18,7 +18,7 @@ from sklearn.utils import check_array, sparsefuncs from .. import logging as logg -from .._compat import old_positionals +from .._compat import njit, old_positionals from .._settings import settings as sett from .._utils import ( _check_array_function_arguments, @@ -31,6 +31,7 @@ ) from ..get import _get_obs_rep, _set_obs_rep from ._distributed import materialize_as_ndarray +from ._utils import _to_dense # install dask if available try: @@ -624,6 +625,34 @@ def normalize_per_cell( return X if copy else None +DT = TypeVar("DT") + + +@njit +def get_resid( + data: np.ndarray, + regressor: np.ndarray, + coeff: np.ndarray, +) -> np.ndarray: + for i in numba.prange(data.shape[0]): + data[i] -= regressor[i] @ coeff + return data + + +def numpy_regress_out( + data: np.ndarray, + regressor: np.ndarray, +) -> np.ndarray: + """\ + Numba kernel for regress out unwanted sorces of variantion. + Finding coefficient using Linear regression (Linear Least Squares). + """ + inv_gram_matrix = np.linalg.inv(regressor.T @ regressor) + coeff = inv_gram_matrix @ (regressor.T @ data) + data = get_resid(data, regressor, coeff) + return data + + @old_positionals("layer", "n_jobs", "copy") def regress_out( adata: AnnData, @@ -678,7 +707,6 @@ def regress_out( if issparse(X): logg.info(" sparse input is densified and may lead to high memory use") - X = X.toarray() n_jobs = sett.n_jobs if n_jobs is None else n_jobs @@ -695,6 +723,8 @@ def regress_out( ) logg.debug("... regressing on per-gene means within categories") regressors = np.zeros(X.shape, dtype="float32") + X = _to_dense(X, order="F") if issparse(X) else X + # TODO figure out if we should use a numba kernel for this for category in adata.obs[keys[0]].cat.categories: mask = (category == adata.obs[keys[0]]).values for ix, x in enumerate(X.T): @@ -707,32 +737,44 @@ def regress_out( # add column of ones at index 0 (first column) regressors.insert(0, "ones", 1.0) + regressors = regressors.to_numpy() - len_chunk = int(np.ceil(min(1000, X.shape[1]) / n_jobs)) - n_chunks = int(np.ceil(X.shape[1] / len_chunk)) + # if the regressors are not categorical and the matrix is not singular + # use the shortcut numpy_regress_out + if not variable_is_categorical and np.linalg.det(regressors.T @ regressors) != 0: + X = _to_dense(X, order="C") if issparse(X) else X + res = numpy_regress_out(X, regressors) - # split the adata.X matrix by columns in chunks of size n_chunk - # (the last chunk could be of smaller size than the others) - chunk_list = np.array_split(X, n_chunks, axis=1) - regressors_chunk = ( - np.array_split(regressors, n_chunks, axis=1) - if variable_is_categorical - else repeat(regressors) - ) + # for a categorical variable or if the above checks failed, + # we fall back to the GLM implemetation of regression. + else: + # split the adata.X matrix by columns in chunks of size n_chunk + # (the last chunk could be of smaller size than the others) + len_chunk = int(np.ceil(min(1000, X.shape[1]) / n_jobs)) + n_chunks = int(np.ceil(X.shape[1] / len_chunk)) + X = _to_dense(X, order="F") if issparse(X) else X + chunk_list = np.array_split(X, n_chunks, axis=1) + regressors_chunk = ( + np.array_split(regressors, n_chunks, axis=1) + if variable_is_categorical + else repeat(regressors) + ) - # each task is passed a data chunk (e.g. `adata.X[:, 0:100]``) and the regressors. - # This data will be passed to each of the jobs. - # TODO: figure out how to test that this doesn't oversubscribe resources - res = Parallel(n_jobs=n_jobs)( - delayed(_regress_out_chunk)( - data_chunk, regres, variable_is_categorical=variable_is_categorical + # each task is passed a data chunk (e.g. `adata.X[:, 0:100]``) and the regressors. + # This data will be passed to each of the jobs. + # TODO: figure out how to test that this doesn't oversubscribe resources + res = Parallel(n_jobs=n_jobs)( + delayed(_regress_out_chunk)( + data_chunk, regres, variable_is_categorical=variable_is_categorical + ) + for data_chunk, regres in zip(chunk_list, regressors_chunk, strict=False) ) - for data_chunk, regres in zip(chunk_list, regressors_chunk, strict=False) - ) - # res is a list of vectors (each corresponding to a regressed gene column). - # The transpose is needed to get the matrix in the shape needed - _set_obs_rep(adata, np.vstack(res).T, layer=layer) + # res is a list of vectors (each corresponding to a regressed gene column). + # The transpose is needed to get the matrix in the shape needed + res = np.vstack(res).T + + _set_obs_rep(adata, res, layer=layer) logg.info(" finished", time=start) return adata if copy else None diff --git a/src/scanpy/preprocessing/_utils.py b/src/scanpy/preprocessing/_utils.py index 300d6450e8..9c02f7e636 100644 --- a/src/scanpy/preprocessing/_utils.py +++ b/src/scanpy/preprocessing/_utils.py @@ -160,3 +160,46 @@ def sample_comb( np.prod(dims), nsamp, random_state=random_state, method=method ) return np.vstack(np.unravel_index(idx, dims)).T + + +def _to_dense( + X: sparse.spmatrix, + order: Literal["C", "F"] = "C", +) -> NDArray: + """\ + Numba kernel for np.toarray() function + """ + out = np.zeros(X.shape, dtype=X.dtype, order=order) + if X.format == "csr": + _to_dense_csr_numba(X.indptr, X.indices, X.data, out, X.shape) + elif X.format == "csc": + _to_dense_csc_numba(X.indptr, X.indices, X.data, out, X.shape) + else: + out = X.toarray(order=order) + return out + + +@njit +def _to_dense_csc_numba( + indptr: NDArray, + indices: NDArray, + data: NDArray, + X: NDArray, + shape: tuple[int, int], +) -> None: + for c in numba.prange(X.shape[1]): + for i in range(indptr[c], indptr[c + 1]): + X[indices[i], c] = data[i] + + +@njit +def _to_dense_csr_numba( + indptr: NDArray, + indices: NDArray, + data: NDArray, + X: NDArray, + shape: tuple[int, int], +) -> None: + for r in numba.prange(shape[0]): + for i in range(indptr[r], indptr[r + 1]): + X[r, indices[i]] = data[i] diff --git a/tests/_data/regress_test_small.npy b/tests/_data/regress_test_small.npy new file mode 100644 index 0000000000000000000000000000000000000000..5a590fb35f33916c28e2122cfd1a6f11a4854e28 GIT binary patch literal 320128 zcmbSS`9IX(_eT;X36-)-vQHc_kBxokbPgq*qO2K z#=i4?eg1{-{NVAJ8S{GG*E#2&`+Pp{^9DYA{6JNkoa{Z>r;7%LHm|HN-r&4=^R?tf zAsh{hV{Ksgzt1(S-x*>b+ZgLv8DekwM1_TgII%zcLYxkq|G(GKSqw*Zcr?Pk>dN;p3{;mcjs4drA; z-D-Dg;HYYJfg|5PI88`oR(jb7GTFC=H1^xUCeKshi$OE=ozY6P=Iex(u_T(s;Xar> zP3DqTx`M>kdJ}7$+QIHn@5flK9!PLY@Z<@s1wF4wPkUAzaAu30m;H+aIl_~Nk^S39 zIX5r6-emzgNGGPQ}y*mQK(KLfM|&Yb1WsDsVFzN`F#XTTqn@)l=Ab#Ru1%mLyhaR zvP+#vaFi={#Jm-fow@xt4tIjgrQ<5)TJ1pX^62B$2iwS2ie-EAaT|<=&vsulssi8B z-B)Q_I>72eY3MDHZB%z{)p0kl1JZwdDCx=U2N}74>3e!Ouu@)Y@!oF(YegHy(99Na ze&T)c(XVdU!AR6f#^t^BN|mp z$R#&O_4Dl(=-^^adrDsk8_&k;V}o~53pa1c+ulK#Kdnag{8AlEXl|ZW)ZIdKS6=t_ zGj_sr=RZFDf>^$dN2CNSyWz2Xa;LPwGAh@Ry)jr>3q8U!%Xy;R@ba8O(TRUGK++1L zdQAq1aJ6)$>RJbA;yE}?(TU3m)roxuOPebMtlIe1oD_?z&y!##f;eOAFGR6YNsUGPyK5JDpU<({K>A-p!}%bO2GprzBGG`Zdm=QIuXoiFr)#`^}- zFpXtYAx%qo6htO#s8-Zfmf8WVdgU>+i#td>vO+)${|73cuID$q)`QjPvA)Z9nnCu3 z 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
tcs+@6O6m8MUF7`h}@D{x&5E=%3 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

k;rNY`bO`*})`1GmXr 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

Q2Jv(E#>e&ta^=7rX-o`kl|ac<$%#xVz=tVt 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 Date: Mon, 11 Nov 2024 14:33:50 +0100 Subject: [PATCH 30/51] Fix zappy compatibility for clip_array (#3317) --- src/scanpy/_utils/__init__.py | 4 +-- src/scanpy/preprocessing/_scale.py | 42 ++++++++++++++++-------------- tests/test_preprocessing.py | 4 ++- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index d97b23f7ae..150afe8311 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -607,13 +607,13 @@ def check_op(op): @singledispatch def axis_mul_or_truediv( - X: np.ndarray, + X: ArrayLike, scaling_array: np.ndarray, axis: Literal[0, 1], op: Callable[[Any, Any], Any], *, allow_divide_by_zero: bool = True, - out: np.ndarray | None = None, + out: ArrayLike | None = None, ) -> np.ndarray: check_op(op) scaling_array = broadcast_axis(scaling_array, axis) diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index 760c66cc5a..d7123d5f65 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -30,6 +30,9 @@ if TYPE_CHECKING: from numpy.typing import NDArray + from scipy import sparse as sp + + CSMatrix = sp.csr_matrix | sp.csc_matrix @njit @@ -44,7 +47,9 @@ def _scale_sparse_numba(indptr, indices, data, *, std, mask_obs, clip): @njit -def clip_array(X: np.ndarray, *, max_value: float = 10, zero_center: bool = True): +def clip_array( + X: NDArray[np.floating], *, max_value: float, zero_center: bool +) -> NDArray[np.floating]: a_min, a_max = -max_value, max_value if X.ndim > 1: for r, c in numba.pndindex(X.shape): @@ -61,6 +66,14 @@ def clip_array(X: np.ndarray, *, max_value: float = 10, zero_center: bool = True return X +def clip_set(x: CSMatrix, *, max_value: float, zero_center: bool = True) -> CSMatrix: + x = x.copy() + x[x > max_value] = max_value + if zero_center: + x[x < -max_value] = -max_value + return x + + @renamed_arg("X", "data", pos_0=True) @old_positionals("zero_center", "max_value", "copy", "layer", "obsm") @singledispatch @@ -187,7 +200,8 @@ def scale_array( if zero_center: if isinstance(X, DaskArray) and issparse(X._meta): warnings.warn( - "zero-center being used with `DaskArray` sparse chunks. This can be bad if you have large chunks or intend to eventually read the whole data into memory.", + "zero-center being used with `DaskArray` sparse chunks. " + "This can be bad if you have large chunks or intend to eventually read the whole data into memory.", UserWarning, ) X -= mean @@ -203,25 +217,13 @@ def scale_array( # do the clipping if max_value is not None: logg.debug(f"... clipping at max_value {max_value}") - if isinstance(X, DaskArray) and issparse(X._meta): - - def clip_set(x): - x = x.copy() - x[x > max_value] = max_value - if zero_center: - x[x < -max_value] = -max_value - return x - - X = da.map_blocks(clip_set, X) + if isinstance(X, DaskArray): + clip = clip_set if issparse(X._meta) else clip_array + X = X.map_blocks(clip, max_value=max_value, zero_center=zero_center) + elif issparse(X): + X.data = clip_array(X.data, max_value=max_value, zero_center=False) else: - if isinstance(X, DaskArray): - X = X.map_blocks( - clip_array, max_value=max_value, zero_center=zero_center - ) - elif issparse(X): - X.data = clip_array(X.data, max_value=max_value, zero_center=False) - else: - X = clip_array(X, max_value=max_value, zero_center=zero_center) + X = clip_array(X, max_value=max_value, zero_center=zero_center) if return_mean_std: return X, mean, std else: diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index ce8313145e..b8f5115b01 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -17,6 +17,7 @@ anndata_v0_8_constructor_compat, check_rep_mutation, check_rep_results, + maybe_dask_process_context, ) from testing.scanpy._helpers.data import pbmc3k, pbmc68k_reduced from testing.scanpy._pytest.params import ARRAY_TYPES @@ -172,7 +173,8 @@ def test_scale_matrix_types(array_type, zero_center, max_value): adata_casted = adata.copy() adata_casted.X = array_type(adata_casted.raw.X) sc.pp.scale(adata, zero_center=zero_center, max_value=max_value) - sc.pp.scale(adata_casted, zero_center=zero_center, max_value=max_value) + with maybe_dask_process_context(): + sc.pp.scale(adata_casted, zero_center=zero_center, max_value=max_value) X = adata_casted.X if "dask" in array_type.__name__: X = X.compute() From 74f0ef07bbefc41c983acb9072f4cf2ade9cda65 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:38:03 +0100 Subject: [PATCH 31/51] [pre-commit.ci] pre-commit autoupdate (#3354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.7.2 → v0.7.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.2...v0.7.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 25b824a582..22194ec871 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.2 + rev: v0.7.3 hooks: - id: ruff types_or: [python, pyi, jupyter] From 02cb8c0f04580d02ca0a691d5becc8f2fa6c2ee0 Mon Sep 17 00:00:00 2001 From: "Lumberbot (aka Jack)" <39504233+meeseeksmachine@users.noreply.github.com> Date: Tue, 12 Nov 2024 02:06:36 -0800 Subject: [PATCH 32/51] Backport PR #3357 on branch main ((chore): generate 1.10.4 release notes) (#3359) Co-authored-by: Philipp A --- ci/scripts/min-deps.py | 16 +++++++++++++--- docs/release-notes/1.10.4.md | 17 +++++++++++++++++ docs/release-notes/3206.bugfix.md | 1 - docs/release-notes/3243.bugfix.md | 1 - docs/release-notes/3244.bugfix.md | 1 - docs/release-notes/3264.bugfix.md | 1 - docs/release-notes/3275.bugfix.md | 1 - docs/release-notes/3283.breaking.md | 1 - docs/release-notes/3286.bugfix.md | 1 - docs/release-notes/3299.bugfix.md | 1 - docs/release-notes/3302.bugfix.md | 1 - pyproject.toml | 5 ++--- 12 files changed, 32 insertions(+), 15 deletions(-) create mode 100644 docs/release-notes/1.10.4.md delete mode 100644 docs/release-notes/3206.bugfix.md delete mode 100644 docs/release-notes/3243.bugfix.md delete mode 100644 docs/release-notes/3244.bugfix.md delete mode 100644 docs/release-notes/3264.bugfix.md delete mode 100644 docs/release-notes/3275.bugfix.md delete mode 100644 docs/release-notes/3283.breaking.md delete mode 100644 docs/release-notes/3286.bugfix.md delete mode 100644 docs/release-notes/3299.bugfix.md delete mode 100644 docs/release-notes/3302.bugfix.md diff --git a/ci/scripts/min-deps.py b/ci/scripts/min-deps.py index f1381580b4..4dad297e03 100755 --- a/ci/scripts/min-deps.py +++ b/ci/scripts/min-deps.py @@ -1,4 +1,11 @@ #!/usr/bin/env python3 +# /// script +# dependencies = [ +# "tomli; python_version < '3.11'", +# "packaging", +# ] +# /// + from __future__ import annotations import argparse @@ -33,12 +40,15 @@ def min_dep(req: Requirement) -> Requirement: if req.extras: req_name = f"{req_name}[{','.join(req.extras)}]" - if not req.specifier: + filter_specs = [ + spec for spec in req.specifier if spec.operator in {"==", "~=", ">=", ">"} + ] + if not filter_specs: return Requirement(req_name) min_version = Version("0.0.0.a1") - for spec in req.specifier: - if spec.operator in [">", ">=", "~="]: + for spec in filter_specs: + if spec.operator in {">", ">=", "~="}: min_version = max(min_version, Version(spec.version)) elif spec.operator == "==": min_version = Version(spec.version) diff --git a/docs/release-notes/1.10.4.md b/docs/release-notes/1.10.4.md new file mode 100644 index 0000000000..d4ba850a46 --- /dev/null +++ b/docs/release-notes/1.10.4.md @@ -0,0 +1,17 @@ +(v1.10.4)= +### 1.10.4 {small}`2024-11-12` + +### Breaking changes + +- Remove Python 3.9 support {smaller}`P Angerer` ({pr}`3283`) + +### Bug fixes + +- Fix {meth}`scanpy.pl.DotPlot.style`, {meth}`scanpy.pl.MatrixPlot.style`, and {meth}`scanpy.pl.StackedViolin.style` resetting all non-specified parameters {smaller}`P Angerer` ({pr}`3206`) +- Accept `'group'` instead of `'obs'` for `standard_scale` parameter in {func}`~scanpy.pl.stacked_violin` {smaller}`P Angerer` ({pr}`3243`) +- Use `density_norm` instead of of `scale` (cont. from {pr}`2844`) in {func}`~scanpy.pl.violin` and {func}`~scanpy.pl.stacked_violin` {smaller}`P Angerer` ({pr}`3244`) +- Switched all compatibility adapters for positional parameters to {exc}`FutureWarning` {smaller}`P Angerer` ({pr}`3264`) +- Catch `PerfectSeparationWarning` during {func}`~scanpy.pp.regress_out` {smaller}`J Wagner` ({pr}`3275`) +- Fix {func}`scanpy.pp.highly_variable_genes` for batches of size 1 {smaller}`P Angerer` ({pr}`3286`) +- Fix {func}`scanpy.pl.scatter`’s `color` parameter to take collections as advertised {smaller}`P Angerer` ({pr}`3299`) +- Fix {func}`scanpy.pl.highest_expr_genes` when used with a categorical gene symbol column {smaller}`P Angerer` ({pr}`3302`) diff --git a/docs/release-notes/3206.bugfix.md b/docs/release-notes/3206.bugfix.md deleted file mode 100644 index 9e47d00b09..0000000000 --- a/docs/release-notes/3206.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix {meth}`scanpy.pl.DotPlot.style`, {meth}`scanpy.pl.MatrixPlot.style`, and {meth}`scanpy.pl.StackedViolin.style` resetting all non-specified parameters {smaller}`P Angerer` diff --git a/docs/release-notes/3243.bugfix.md b/docs/release-notes/3243.bugfix.md deleted file mode 100644 index 5aa6063b1e..0000000000 --- a/docs/release-notes/3243.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Accept `'group'` instead of `'obs'` for `standard_scale` parameter in {func}`~scanpy.pl.stacked_violin` {smaller}`P Angerer` diff --git a/docs/release-notes/3244.bugfix.md b/docs/release-notes/3244.bugfix.md deleted file mode 100644 index e918765588..0000000000 --- a/docs/release-notes/3244.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Use `density_norm` instead of of `scale` (cont. from {pr}`2844`) in {func}`~scanpy.pl.violin` and {func}`~scanpy.pl.stacked_violin` {smaller}`P Angerer` diff --git a/docs/release-notes/3264.bugfix.md b/docs/release-notes/3264.bugfix.md deleted file mode 100644 index 0886ee6aa3..0000000000 --- a/docs/release-notes/3264.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Switched all compatibility adapters for positional parameters to {exc}`FutureWarning` {smaller}`P Angerer` diff --git a/docs/release-notes/3275.bugfix.md b/docs/release-notes/3275.bugfix.md deleted file mode 100644 index 4465c7651d..0000000000 --- a/docs/release-notes/3275.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Catch `PerfectSeparationWarning` during {func}`~scanpy.pp.regress_out` {smaller}`J Wagner` diff --git a/docs/release-notes/3283.breaking.md b/docs/release-notes/3283.breaking.md deleted file mode 100644 index 6f391f325d..0000000000 --- a/docs/release-notes/3283.breaking.md +++ /dev/null @@ -1 +0,0 @@ -Remove Python 3.9 support {smaller}`P Angerer` diff --git a/docs/release-notes/3286.bugfix.md b/docs/release-notes/3286.bugfix.md deleted file mode 100644 index 164758a2fa..0000000000 --- a/docs/release-notes/3286.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix {func}`scanpy.pp.highly_variable_genes` for batches of size 1 {smaller}`P Angerer` diff --git a/docs/release-notes/3299.bugfix.md b/docs/release-notes/3299.bugfix.md deleted file mode 100644 index 1b0b512ad2..0000000000 --- a/docs/release-notes/3299.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix {func}`scanpy.pl.scatter`’s `color` parameter to take collections as advertised {smaller}`P Angerer` diff --git a/docs/release-notes/3302.bugfix.md b/docs/release-notes/3302.bugfix.md deleted file mode 100644 index 00d2468dad..0000000000 --- a/docs/release-notes/3302.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix {func}`scanpy.pl.highest_expr_genes` when used with a categorical gene symbol column {smaller}`P Angerer` diff --git a/pyproject.toml b/pyproject.toml index 526eca781d..8d221396bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "tqdm", "scikit-learn>=1.1", "statsmodels>=0.13", - "patsy", + "patsy!=1.0.0", # https://github.com/pydata/patsy/issues/215 "networkx>=2.7", "natsort", "joblib", @@ -66,7 +66,6 @@ dependencies = [ "packaging>=21.3", "session-info", "legacy-api-wrap>=1.4", # for positional API deprecations - "get-annotations; python_version < '3.10'", ] dynamic = ["version"] @@ -124,7 +123,7 @@ doc = [ "ipython>=7.20", # for nbsphinx code highlighting "matplotlib!=3.6.1", "sphinxcontrib-bibtex", - "setuptools", + "setuptools", # undeclared dependency of sphinxcontrib-bibtex→pybtex # TODO: remove necessity for being able to import doc-linked classes "scanpy[paga,dask-ml]", "sam-algorithm", From 0146f1a56e78c9d665f822fc81a093ac2d1e578e Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 14 Nov 2024 10:41:54 +0100 Subject: [PATCH 33/51] Actually working min-deps job (#3337) --- .gitignore | 1 + ci/scripts/min-deps.py | 35 ++++++++++++++++++++++++------ ci/scripts/towncrier_automation.py | 4 ++++ hatch.toml | 9 +++++--- pyproject.toml | 2 +- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index beafaf6171..d21120ee95 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ # Python build files __pycache__/ /src/scanpy/_version.py +/ci/scanpy-min-deps.txt /dist/ /*-env/ /env-*/ diff --git a/ci/scripts/min-deps.py b/ci/scripts/min-deps.py index 4dad297e03..b996302c01 100755 --- a/ci/scripts/min-deps.py +++ b/ci/scripts/min-deps.py @@ -11,6 +11,7 @@ import argparse import sys from collections import deque +from contextlib import ExitStack from pathlib import Path from typing import TYPE_CHECKING @@ -23,7 +24,7 @@ from packaging.version import Version if TYPE_CHECKING: - from collections.abc import Generator, Iterable + from collections.abc import Generator, Iterable, Sequence def min_dep(req: Requirement) -> Requirement: @@ -75,12 +76,19 @@ def extract_min_deps( yield min_dep(req) -def main(): +class Args(argparse.Namespace): + path: Path + output: Path | None + extras: list[str] + + +def main(argv: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser( prog="min-deps", - description="""Parse a pyproject.toml file and output a list of minimum dependencies. - - Output is directly passable to `pip install`.""", + description=( + "Parse a pyproject.toml file and output a list of minimum dependencies. " + "Output is optimized for `[uv] pip install` (see `-o`/`--output` for details)." + ), usage="pip install `python min-deps.py pyproject.toml`", ) parser.add_argument( @@ -89,8 +97,18 @@ def main(): parser.add_argument( "--extras", type=str, nargs="*", default=(), help="extras to install" ) + parser.add_argument( + *("--output", "-o"), + type=Path, + default=None, + help=( + "output file (default: stdout). " + "Without this option, output is space-separated for direct passing to `pip install`. " + "With this option, output written to a file newline-separated file usable as `requirements.txt` or `constraints.txt`." + ), + ) - args = parser.parse_args() + args = parser.parse_args(argv, Args()) pyproject = tomllib.loads(args.path.read_text()) @@ -102,7 +120,10 @@ def main(): min_deps = extract_min_deps(deps, pyproject=pyproject) - print(" ".join(map(str, min_deps))) + sep = "\n" if args.output else " " + with ExitStack() as stack: + f = stack.enter_context(args.output.open("w")) if args.output else sys.stdout + print(sep.join(map(str, min_deps)), file=f) if __name__ == "__main__": diff --git a/ci/scripts/towncrier_automation.py b/ci/scripts/towncrier_automation.py index 57a093a305..fd492f494a 100755 --- a/ci/scripts/towncrier_automation.py +++ b/ci/scripts/towncrier_automation.py @@ -1,4 +1,8 @@ #!/usr/bin/env python3 +# /// script +# dependencies = [ "towncrier", "packaging" ] +# /// + from __future__ import annotations import argparse diff --git a/hatch.toml b/hatch.toml index ab2bb7550e..ad5db60976 100644 --- a/hatch.toml +++ b/hatch.toml @@ -19,14 +19,17 @@ features = ["test", "dask-ml"] extra-dependencies = ["ipykernel"] overrides.matrix.deps.env-vars = [ { if = ["pre"], key = "UV_PRERELEASE", value = "allow" }, - { if = ["min"], key = "UV_RESOLUTION", value = "lowest-direct" }, + { if = ["min"], key = "UV_CONSTRAINT", value = "ci/scanpy-min-deps.txt" }, +] +overrides.matrix.deps.pre-install-commands = [ + { if = ["min"], value = "uv run ci/scripts/min-deps.py pyproject.toml -o ci/scanpy-min-deps.txt" }, ] overrides.matrix.deps.python = [ - { if = ["min"] , value = "3.10" }, + { if = ["min"], value = "3.10" }, { if = ["stable", "full", "pre"], value = "3.12" }, ] overrides.matrix.deps.features = [ - { if = ["full"] , value = "test-full" }, + { if = ["full"], value = "test-full" }, ] [[envs.hatch-test.matrix]] diff --git a/pyproject.toml b/pyproject.toml index 8d221396bf..d7e790f097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "pandas >=1.5", "scipy>=1.8", "seaborn>=0.13", - "h5py>=3.6", + "h5py>=3.7", "tqdm", "scikit-learn>=1.1", "statsmodels>=0.13", From ac19bb3ed51cfa14e9f5ded8f6a5ad39e1939ad2 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 14 Nov 2024 14:18:12 +0100 Subject: [PATCH 34/51] Fix CI (#3364) --- ci/scripts/min-deps.py | 2 +- ci/scripts/towncrier_automation.py | 4 +--- pyproject.toml | 4 ++-- tests/test_utils.py | 13 +++++++++---- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/ci/scripts/min-deps.py b/ci/scripts/min-deps.py index b996302c01..18af6ce151 100755 --- a/ci/scripts/min-deps.py +++ b/ci/scripts/min-deps.py @@ -35,7 +35,7 @@ def min_dep(req: Requirement) -> Requirement: ------- >>> min_dep(Requirement("numpy>=1.0")) - "numpy==1.0" + """ req_name = req.name if req.extras: diff --git a/ci/scripts/towncrier_automation.py b/ci/scripts/towncrier_automation.py index fd492f494a..c532883036 100755 --- a/ci/scripts/towncrier_automation.py +++ b/ci/scripts/towncrier_automation.py @@ -66,9 +66,7 @@ def main(argv: Sequence[str] | None = None) -> None: text=True, check=True, ).stdout.strip() - pr_description = ( - "" if base_branch == "main" else "@meeseeksmachine backport to main" - ) + pr_description = "" if base_branch == "main" else "@meeseeksdev backport to main" branch_name = f"release_notes_{args.version}" # Create a new branch + commit diff --git a/pyproject.toml b/pyproject.toml index d7e790f097..cfb7ffd28a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,7 +166,7 @@ addopts = [ "-ptesting.scanpy._pytest", "--pyargs", ] -testpaths = ["./tests", "scanpy"] +testpaths = ["./tests", "./ci", "scanpy"] norecursedirs = ["tests/_images"] xfail_strict = true nunit_attach_on = "fail" @@ -211,7 +211,7 @@ exclude_also = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:", # https://github.com/numba/numba/issues/4268 - "@numba.njit.*", + '@(numba\.|nb\.)njit.*', ] [tool.ruff] diff --git a/tests/test_utils.py b/tests/test_utils.py index 3bec055995..f8a38a5f9d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,9 +6,10 @@ import numpy as np import pytest from anndata.tests.helpers import asarray +from packaging.version import Version from scipy.sparse import csr_matrix, issparse -from scanpy._compat import DaskArray +from scanpy._compat import DaskArray, pkg_version from scanpy._utils import ( axis_mul_or_truediv, axis_sum, @@ -225,11 +226,15 @@ def test_is_constant(array_type): ], ) @pytest.mark.parametrize("block_type", [np.array, csr_matrix]) -def test_is_constant_dask(axis, expected, block_type): +def test_is_constant_dask(request: pytest.FixtureRequest, axis, expected, block_type): import dask.array as da - if (axis is None) and block_type is csr_matrix: - pytest.skip("Dask has weak support for scipy sparse matrices") + if block_type is csr_matrix and ( + axis is None or pkg_version("dask") < Version("2023.2.0") + ): + reason = "Dask has weak support for scipy sparse matrices" + # This test is flaky for old dask versions, but when `axis=None` it reliably fails + request.applymarker(pytest.mark.xfail(reason=reason, strict=axis is None)) x_data = [ [0, 0, 1, 1], From b92267cb43d839c8075ba648dea9be84ef4eace7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 08:57:59 +0100 Subject: [PATCH 35/51] [pre-commit.ci] pre-commit autoupdate (#3373) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22194ec871..c8088c28f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.3 + rev: v0.7.4 hooks: - id: ruff types_or: [python, pyi, jupyter] From 0f32b080e29bd0e5188c350d535b3779aeacf42a Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 19 Nov 2024 09:30:06 +0100 Subject: [PATCH 36/51] Deprecate RandomState (using names only) (#3372) --- src/scanpy/_compat.py | 4 ++++ src/scanpy/_utils/__init__.py | 10 ++++++---- src/scanpy/datasets/_datasets.py | 4 ++-- src/scanpy/external/pp/_dca.py | 4 ++-- src/scanpy/external/pp/_magic.py | 4 ++-- src/scanpy/external/tl/_phate.py | 4 ++-- src/scanpy/neighbors/__init__.py | 12 ++++++------ src/scanpy/plotting/_tools/paga.py | 3 ++- src/scanpy/preprocessing/_pca/__init__.py | 5 +++-- src/scanpy/preprocessing/_pca/_compat.py | 4 ++-- src/scanpy/preprocessing/_recipes.py | 4 ++-- src/scanpy/preprocessing/_scrublet/__init__.py | 8 ++++---- src/scanpy/preprocessing/_scrublet/core.py | 10 +++++----- src/scanpy/preprocessing/_scrublet/pipeline.py | 6 +++--- src/scanpy/preprocessing/_scrublet/sparse_utils.py | 8 ++++---- src/scanpy/preprocessing/_simple.py | 9 ++++----- src/scanpy/preprocessing/_utils.py | 6 +++--- src/scanpy/tools/_diffmap.py | 4 ++-- src/scanpy/tools/_draw_graph.py | 4 ++-- src/scanpy/tools/_leiden.py | 4 +++- src/scanpy/tools/_louvain.py | 4 +++- src/scanpy/tools/_score_genes.py | 4 ++-- src/scanpy/tools/_tsne.py | 4 ++-- src/scanpy/tools/_umap.py | 4 ++-- 24 files changed, 72 insertions(+), 61 deletions(-) diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index c5fa4dbe84..dca6c84c4e 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -9,15 +9,19 @@ from pathlib import Path from typing import TYPE_CHECKING, Literal, ParamSpec, TypeVar, cast, overload +import numpy as np from packaging.version import Version if TYPE_CHECKING: from collections.abc import Callable from importlib.metadata import PackageMetadata + P = ParamSpec("P") R = TypeVar("R") +_LegacyRandom = int | np.random.RandomState | None + if TYPE_CHECKING: # type checkers are confused and can only see …core.Array diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 150afe8311..67e2ae03c8 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -12,6 +12,7 @@ import re import sys import warnings +from collections.abc import Sequence from contextlib import contextmanager, suppress from enum import Enum from functools import partial, reduce, singledispatch, wraps @@ -56,12 +57,13 @@ from anndata import AnnData from numpy.typing import ArrayLike, DTypeLike, NDArray + from .._compat import _LegacyRandom from ..neighbors import NeighborsParams, RPForestDict -# e.g. https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html -# maybe in the future random.Generator -AnyRandom = int | np.random.RandomState | None +SeedLike = int | np.integer | Sequence[int] | np.random.SeedSequence +RNGLike = np.random.Generator | np.random.BitGenerator + LegacyUnionType = type(Union[int, str]) # noqa: UP007 @@ -493,7 +495,7 @@ def moving_average(a: np.ndarray, n: int): return ret[n - 1 :] / n -def get_random_state(seed: AnyRandom) -> np.random.RandomState: +def _get_legacy_random(seed: _LegacyRandom) -> np.random.RandomState: if isinstance(seed, np.random.RandomState): return seed return np.random.RandomState(seed) diff --git a/src/scanpy/datasets/_datasets.py b/src/scanpy/datasets/_datasets.py index 41b23160d6..df510b3209 100644 --- a/src/scanpy/datasets/_datasets.py +++ b/src/scanpy/datasets/_datasets.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from typing import Literal - from .._utils import AnyRandom + from .._compat import _LegacyRandom VisiumSampleID = Literal[ "V1_Breast_Cancer_Block_A_Section_1", @@ -63,7 +63,7 @@ def blobs( n_centers: int = 5, cluster_std: float = 1.0, n_observations: int = 640, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, ) -> AnnData: """\ Gaussian Blobs. diff --git a/src/scanpy/external/pp/_dca.py b/src/scanpy/external/pp/_dca.py index 14842c8071..c47fff90f2 100644 --- a/src/scanpy/external/pp/_dca.py +++ b/src/scanpy/external/pp/_dca.py @@ -11,7 +11,7 @@ from anndata import AnnData - from ..._utils import AnyRandom + from ..._compat import _LegacyRandom _AEType = Literal["zinb-conddisp", "zinb", "nb-conddisp", "nb"] @@ -62,7 +62,7 @@ def dca( early_stop: int = 15, batch_size: int = 32, optimizer: str = "RMSprop", - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, threads: int | None = None, learning_rate: float | None = None, verbose: bool = False, diff --git a/src/scanpy/external/pp/_magic.py b/src/scanpy/external/pp/_magic.py index fd4b19667d..132d2a6448 100644 --- a/src/scanpy/external/pp/_magic.py +++ b/src/scanpy/external/pp/_magic.py @@ -19,7 +19,7 @@ from anndata import AnnData - from ..._utils import AnyRandom + from ..._compat import _LegacyRandom MIN_VERSION = "2.0" @@ -36,7 +36,7 @@ def magic( n_pca: int | None = 100, solver: Literal["exact", "approximate"] = "exact", knn_dist: str = "euclidean", - random_state: AnyRandom = None, + random_state: _LegacyRandom = None, n_jobs: int | None = None, verbose: bool = False, copy: bool | None = None, diff --git a/src/scanpy/external/tl/_phate.py b/src/scanpy/external/tl/_phate.py index 78b50327a9..ff50a1e6f7 100644 --- a/src/scanpy/external/tl/_phate.py +++ b/src/scanpy/external/tl/_phate.py @@ -16,7 +16,7 @@ from anndata import AnnData - from ..._utils import AnyRandom + from ..._compat import _LegacyRandom @old_positionals( @@ -49,7 +49,7 @@ def phate( mds_dist: str = "euclidean", mds: Literal["classic", "metric", "nonmetric"] = "metric", n_jobs: int | None = None, - random_state: AnyRandom = None, + random_state: _LegacyRandom = None, verbose: bool | int | None = None, copy: bool = False, **kwargs, diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index 379f34227b..ec5957b325 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -33,7 +33,7 @@ from igraph import Graph from scipy.sparse import csr_matrix - from .._utils import AnyRandom + from .._compat import _LegacyRandom from ._types import KnnTransformerLike, _Metric, _MetricFn @@ -54,13 +54,13 @@ class KwdsForTransformer(TypedDict): n_neighbors: int metric: _Metric | _MetricFn metric_params: Mapping[str, Any] - random_state: AnyRandom + random_state: _LegacyRandom class NeighborsParams(TypedDict): n_neighbors: int method: _Method - random_state: AnyRandom + random_state: _LegacyRandom metric: _Metric | _MetricFn metric_kwds: NotRequired[Mapping[str, Any]] use_rep: NotRequired[str] @@ -79,7 +79,7 @@ def neighbors( transformer: KnnTransformerLike | _KnownTransformer | None = None, metric: _Metric | _MetricFn = "euclidean", metric_kwds: Mapping[str, Any] = MappingProxyType({}), - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, key_added: str | None = None, copy: bool = False, ) -> AnnData | None: @@ -521,7 +521,7 @@ def compute_neighbors( transformer: KnnTransformerLike | _KnownTransformer | None = None, metric: _Metric | _MetricFn = "euclidean", metric_kwds: Mapping[str, Any] = MappingProxyType({}), - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, ) -> None: """\ Compute distances and connectivities of neighbors. @@ -757,7 +757,7 @@ def compute_eigen( n_comps: int = 15, sym: bool | None = None, sort: Literal["decrease", "increase"] = "decrease", - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, ): """\ Compute eigen decomposition of transition matrix. diff --git a/src/scanpy/plotting/_tools/paga.py b/src/scanpy/plotting/_tools/paga.py index 7e62d46eac..ff14a19989 100644 --- a/src/scanpy/plotting/_tools/paga.py +++ b/src/scanpy/plotting/_tools/paga.py @@ -33,6 +33,7 @@ from matplotlib.colors import Colormap from scipy.sparse import spmatrix + from ..._compat import _LegacyRandom from ...tools._draw_graph import _Layout as _LayoutWithoutEqTree from .._utils import _FontSize, _FontWeight, _LegendLoc @@ -210,7 +211,7 @@ def _compute_pos( adjacency_solid: spmatrix | np.ndarray, *, layout: _Layout | None = None, - random_state: _sc_utils.AnyRandom = 0, + random_state: _LegacyRandom = 0, init_pos: np.ndarray | None = None, adj_tree=None, root: int = 0, diff --git a/src/scanpy/preprocessing/_pca/__init__.py b/src/scanpy/preprocessing/_pca/__init__.py index dba47d821c..3fd288ad93 100644 --- a/src/scanpy/preprocessing/_pca/__init__.py +++ b/src/scanpy/preprocessing/_pca/__init__.py @@ -30,7 +30,8 @@ from scipy import sparse from scipy.sparse import spmatrix - from ..._utils import AnyRandom, Empty + from ..._compat import _LegacyRandom + from ..._utils import Empty CSMatrix = sparse.csr_matrix | sparse.csc_matrix @@ -70,7 +71,7 @@ def pca( layer: str | None = None, zero_center: bool | None = True, svd_solver: SvdSolver | None = None, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, return_info: bool = False, mask_var: NDArray[np.bool_] | str | None | Empty = _empty, use_highly_variable: bool | None = None, diff --git a/src/scanpy/preprocessing/_pca/_compat.py b/src/scanpy/preprocessing/_pca/_compat.py index 23cb60a2e9..28eef2ba1a 100644 --- a/src/scanpy/preprocessing/_pca/_compat.py +++ b/src/scanpy/preprocessing/_pca/_compat.py @@ -18,7 +18,7 @@ from scipy import sparse from sklearn.decomposition import PCA - from .._utils import AnyRandom + from ..._compat import _LegacyRandom CSMatrix = sparse.csr_matrix | sparse.csc_matrix @@ -29,7 +29,7 @@ def _pca_compat_sparse( *, solver: Literal["arpack", "lobpcg"], mu: NDArray[np.floating] | None = None, - random_state: AnyRandom = None, + random_state: _LegacyRandom = None, ) -> tuple[NDArray[np.floating], PCA]: """Sparse PCA for scikit-learn <1.4""" random_state = check_random_state(random_state) diff --git a/src/scanpy/preprocessing/_recipes.py b/src/scanpy/preprocessing/_recipes.py index 4579739939..4b97405df9 100644 --- a/src/scanpy/preprocessing/_recipes.py +++ b/src/scanpy/preprocessing/_recipes.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from anndata import AnnData - from .._utils import AnyRandom + from .._compat import _LegacyRandom @old_positionals( @@ -36,7 +36,7 @@ def recipe_weinreb17( cv_threshold: int = 2, n_pcs: int = 50, svd_solver="randomized", - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, copy: bool = False, ) -> AnnData | None: """\ diff --git a/src/scanpy/preprocessing/_scrublet/__init__.py b/src/scanpy/preprocessing/_scrublet/__init__.py index d57eb81750..68b7f59526 100644 --- a/src/scanpy/preprocessing/_scrublet/__init__.py +++ b/src/scanpy/preprocessing/_scrublet/__init__.py @@ -15,7 +15,7 @@ from .core import Scrublet if TYPE_CHECKING: - from ..._utils import AnyRandom + from ..._compat import _LegacyRandom from ...neighbors import _Metric, _MetricFn @@ -58,7 +58,7 @@ def scrublet( threshold: float | None = None, verbose: bool = True, copy: bool = False, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, ) -> AnnData | None: """\ Predict doublets using Scrublet :cite:p:`Wolock2019`. @@ -309,7 +309,7 @@ def _scrublet_call_doublets( knn_dist_metric: _Metric | _MetricFn = "euclidean", get_doublet_neighbor_parents: bool = False, threshold: float | None = None, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, verbose: bool = True, ) -> AnnData: """\ @@ -503,7 +503,7 @@ def scrublet_simulate_doublets( layer: str | None = None, sim_doublet_ratio: float = 2.0, synthetic_doublet_umi_subsampling: float = 1.0, - random_seed: AnyRandom = 0, + random_seed: _LegacyRandom = 0, ) -> AnnData: """\ Simulate doublets by adding the counts of random observed transcriptome pairs. diff --git a/src/scanpy/preprocessing/_scrublet/core.py b/src/scanpy/preprocessing/_scrublet/core.py index 4c992b2b64..1236f42a7a 100644 --- a/src/scanpy/preprocessing/_scrublet/core.py +++ b/src/scanpy/preprocessing/_scrublet/core.py @@ -9,7 +9,7 @@ from scipy import sparse from ... import logging as logg -from ..._utils import get_random_state +from ..._utils import _get_legacy_random from ...neighbors import ( Neighbors, _get_indices_distances_from_sparse_matrix, @@ -21,7 +21,7 @@ from numpy.random import RandomState from numpy.typing import NDArray - from ..._utils import AnyRandom + from ..._compat import _LegacyRandom from ...neighbors import _Metric, _MetricFn __all__ = ["Scrublet"] @@ -73,7 +73,7 @@ class Scrublet: n_neighbors: InitVar[int | None] = None expected_doublet_rate: float = 0.1 stdev_doublet_rate: float = 0.02 - random_state: InitVar[AnyRandom] = 0 + random_state: InitVar[_LegacyRandom] = 0 # private fields @@ -174,7 +174,7 @@ def __post_init__( counts_obs: sparse.csr_matrix | sparse.csc_matrix | NDArray[np.integer], total_counts_obs: NDArray[np.integer] | None, n_neighbors: int | None, - random_state: AnyRandom, + random_state: _LegacyRandom, ) -> None: self._counts_obs = sparse.csc_matrix(counts_obs) self._total_counts_obs = ( @@ -187,7 +187,7 @@ def __post_init__( if n_neighbors is None else n_neighbors ) - self._random_state = get_random_state(random_state) + self._random_state = _get_legacy_random(random_state) def simulate_doublets( self, diff --git a/src/scanpy/preprocessing/_scrublet/pipeline.py b/src/scanpy/preprocessing/_scrublet/pipeline.py index 5f6c62838c..586587e2cf 100644 --- a/src/scanpy/preprocessing/_scrublet/pipeline.py +++ b/src/scanpy/preprocessing/_scrublet/pipeline.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from typing import Literal - from ..._utils import AnyRandom + from ..._compat import _LegacyRandom from .core import Scrublet @@ -49,7 +49,7 @@ def truncated_svd( self: Scrublet, n_prin_comps: int = 30, *, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, algorithm: Literal["arpack", "randomized"] = "arpack", ) -> None: if self._counts_sim_norm is None: @@ -68,7 +68,7 @@ def pca( self: Scrublet, n_prin_comps: int = 50, *, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, svd_solver: Literal["auto", "full", "arpack", "randomized"] = "arpack", ) -> None: if self._counts_sim_norm is None: diff --git a/src/scanpy/preprocessing/_scrublet/sparse_utils.py b/src/scanpy/preprocessing/_scrublet/sparse_utils.py index cc0b1bc815..795559583c 100644 --- a/src/scanpy/preprocessing/_scrublet/sparse_utils.py +++ b/src/scanpy/preprocessing/_scrublet/sparse_utils.py @@ -7,12 +7,12 @@ from scanpy.preprocessing._utils import _get_mean_var -from ..._utils import get_random_state +from ..._utils import _get_legacy_random if TYPE_CHECKING: from numpy.typing import NDArray - from ..._utils import AnyRandom + from .._compat import _LegacyRandom def sparse_multiply( @@ -47,10 +47,10 @@ def subsample_counts( *, rate: float, original_totals, - random_seed: AnyRandom = 0, + random_seed: _LegacyRandom = 0, ) -> tuple[sparse.csr_matrix | sparse.csc_matrix, NDArray[np.int64]]: if rate < 1: - random_seed = get_random_state(random_seed) + random_seed = _get_legacy_random(random_seed) E.data = random_seed.binomial(np.round(E.data).astype(int), rate) current_totals = np.asarray(E.sum(1)).squeeze() unsampled_orig_totals = original_totals - current_totals diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 4d540ef931..01936414a5 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -50,8 +50,7 @@ import pandas as pd from numpy.typing import NDArray - from .._compat import DaskArray - from .._utils import AnyRandom + from .._compat import DaskArray, _LegacyRandom @old_positionals( @@ -831,7 +830,7 @@ def subsample( fraction: float | None = None, *, n_obs: int | None = None, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, copy: bool = False, ) -> AnnData | tuple[np.ndarray | spmatrix, NDArray[np.int64]] | None: """\ @@ -894,7 +893,7 @@ def downsample_counts( counts_per_cell: int | Collection[int] | None = None, total_counts: int | None = None, *, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, replace: bool = False, copy: bool = False, ) -> AnnData | None: @@ -1030,7 +1029,7 @@ def _downsample_array( col: np.ndarray, target: int, *, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, replace: bool = True, inplace: bool = False, ): diff --git a/src/scanpy/preprocessing/_utils.py b/src/scanpy/preprocessing/_utils.py index 9c02f7e636..b200e89ce8 100644 --- a/src/scanpy/preprocessing/_utils.py +++ b/src/scanpy/preprocessing/_utils.py @@ -16,8 +16,8 @@ from numpy.typing import DTypeLike, NDArray - from .._compat import DaskArray - from .._utils import AnyRandom, _SupportedArray + from .._compat import DaskArray, _LegacyRandom + from .._utils import _SupportedArray @singledispatch @@ -150,7 +150,7 @@ def sample_comb( dims: tuple[int, ...], nsamp: int, *, - random_state: AnyRandom = None, + random_state: _LegacyRandom = None, method: Literal[ "auto", "tracking_selection", "reservoir_sampling", "pool" ] = "auto", diff --git a/src/scanpy/tools/_diffmap.py b/src/scanpy/tools/_diffmap.py index dee643c39b..d2bdcc647b 100644 --- a/src/scanpy/tools/_diffmap.py +++ b/src/scanpy/tools/_diffmap.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from anndata import AnnData - from .._utils import AnyRandom + from .._compat import _LegacyRandom @old_positionals("neighbors_key", "random_state", "copy") @@ -17,7 +17,7 @@ def diffmap( n_comps: int = 15, *, neighbors_key: str | None = None, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, copy: bool = False, ) -> AnnData | None: """\ diff --git a/src/scanpy/tools/_draw_graph.py b/src/scanpy/tools/_draw_graph.py index 3f0e65c061..aedd41f3d3 100644 --- a/src/scanpy/tools/_draw_graph.py +++ b/src/scanpy/tools/_draw_graph.py @@ -18,7 +18,7 @@ from anndata import AnnData from scipy.sparse import spmatrix - from .._utils import AnyRandom + from .._compat import _LegacyRandom S = TypeVar("S", bound=LiteralString) @@ -43,7 +43,7 @@ def draw_graph( *, init_pos: str | bool | None = None, root: int | None = None, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, n_jobs: int | None = None, adjacency: spmatrix | None = None, key_added_ext: str | None = None, diff --git a/src/scanpy/tools/_leiden.py b/src/scanpy/tools/_leiden.py index 5a8ba00484..f73ec1fd7d 100644 --- a/src/scanpy/tools/_leiden.py +++ b/src/scanpy/tools/_leiden.py @@ -17,6 +17,8 @@ from anndata import AnnData from scipy import sparse + from .._compat import _LegacyRandom + try: from leidenalg.VertexPartition import MutableVertexPartition except ImportError: @@ -32,7 +34,7 @@ def leiden( resolution: float = 1, *, restrict_to: tuple[str, Sequence[str]] | None = None, - random_state: _utils.AnyRandom = 0, + random_state: _LegacyRandom = 0, key_added: str = "leiden", adjacency: sparse.spmatrix | None = None, directed: bool | None = None, diff --git a/src/scanpy/tools/_louvain.py b/src/scanpy/tools/_louvain.py index d3e616a850..470858ff38 100644 --- a/src/scanpy/tools/_louvain.py +++ b/src/scanpy/tools/_louvain.py @@ -22,6 +22,8 @@ from anndata import AnnData from scipy.sparse import spmatrix + from .._compat import _LegacyRandom + try: from louvain.VertexPartition import MutableVertexPartition except ImportError: @@ -50,7 +52,7 @@ def louvain( adata: AnnData, resolution: float | None = None, *, - random_state: _utils.AnyRandom = 0, + random_state: _LegacyRandom = 0, restrict_to: tuple[str, Sequence[str]] | None = None, key_added: str = "louvain", adjacency: spmatrix | None = None, diff --git a/src/scanpy/tools/_score_genes.py b/src/scanpy/tools/_score_genes.py index a3909b7a28..a40d9f3288 100644 --- a/src/scanpy/tools/_score_genes.py +++ b/src/scanpy/tools/_score_genes.py @@ -22,7 +22,7 @@ from numpy.typing import DTypeLike, NDArray from scipy.sparse import csc_matrix, csr_matrix - from .._utils import AnyRandom + from .._compat import _LegacyRandom try: _StrIdx = pd.Index[str] @@ -70,7 +70,7 @@ def score_genes( gene_pool: Sequence[str] | pd.Index[str] | None = None, n_bins: int = 25, score_name: str = "score", - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, copy: bool = False, use_raw: bool | None = None, layer: str | None = None, diff --git a/src/scanpy/tools/_tsne.py b/src/scanpy/tools/_tsne.py index ac0e6a6317..18e4a47f8e 100644 --- a/src/scanpy/tools/_tsne.py +++ b/src/scanpy/tools/_tsne.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from anndata import AnnData - from .._utils import AnyRandom + from .._compat import _LegacyRandom @old_positionals( @@ -38,7 +38,7 @@ def tsne( metric: str = "euclidean", early_exaggeration: float = 12, learning_rate: float = 1000, - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, use_fast_tsne: bool = False, n_jobs: int | None = None, key_added: str | None = None, diff --git a/src/scanpy/tools/_umap.py b/src/scanpy/tools/_umap.py index 4f225da2a1..902171d58c 100644 --- a/src/scanpy/tools/_umap.py +++ b/src/scanpy/tools/_umap.py @@ -17,7 +17,7 @@ from anndata import AnnData - from .._utils import AnyRandom + from .._compat import _LegacyRandom _InitPos = Literal["paga", "spectral", "random"] @@ -49,7 +49,7 @@ def umap( gamma: float = 1.0, negative_sample_rate: int = 5, init_pos: _InitPos | np.ndarray | None = "spectral", - random_state: AnyRandom = 0, + random_state: _LegacyRandom = 0, a: float | None = None, b: float | None = None, method: Literal["umap", "rapids"] = "umap", From 751eafac9259edfacf083b0ffff268ca93182cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damin=20K=C3=BChn?= Date: Tue, 19 Nov 2024 10:09:04 +0100 Subject: [PATCH 37/51] Updated Harmony Integrate Docs to better match interface to Harmonypy package (#3362) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Phil Schaf --- docs/release-notes/3362.doc.md | 1 + src/scanpy/external/pp/_harmony_integrate.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 docs/release-notes/3362.doc.md diff --git a/docs/release-notes/3362.doc.md b/docs/release-notes/3362.doc.md new file mode 100644 index 0000000000..1dae77b3e2 --- /dev/null +++ b/docs/release-notes/3362.doc.md @@ -0,0 +1 @@ +Improve {func}`~scanpy.external.pp.harmony_integrate` docs {smaller}`D Kühl` diff --git a/src/scanpy/external/pp/_harmony_integrate.py b/src/scanpy/external/pp/_harmony_integrate.py index 27c4d2ac8f..1104690d53 100644 --- a/src/scanpy/external/pp/_harmony_integrate.py +++ b/src/scanpy/external/pp/_harmony_integrate.py @@ -4,6 +4,7 @@ from __future__ import annotations +from collections.abc import Sequence # noqa: TCH003 from typing import TYPE_CHECKING import numpy as np @@ -19,7 +20,7 @@ @doctest_needs("harmonypy") def harmony_integrate( adata: AnnData, - key: str, + key: str | Sequence[str], *, basis: str = "X_pca", adjusted_basis: str = "X_pca_harmony", @@ -42,7 +43,9 @@ def harmony_integrate( The annotated data matrix. key The name of the column in ``adata.obs`` that differentiates - among experiments/batches. + among experiments/batches. To integrate over two or more covariates, + you can pass multiple column names as a list. See ``vars_use`` + parameter of the ``harmonypy`` package for more details. basis The name of the field in ``adata.obsm`` where the PCA table is stored. Defaults to ``'X_pca'``, which is the default for From 7131500627a3037cedd11d380ae772400c744f9b Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 22 Nov 2024 15:11:40 +0100 Subject: [PATCH 38/51] Use deprecation decorator (#3380) --- docs/release-notes/3380.bugfix.md | 1 + pyproject.toml | 1 + src/scanpy/_compat.py | 13 ++++++++++ src/scanpy/plotting/_preprocessing.py | 3 ++- .../_deprecated/highly_variable_genes.py | 24 +++++++++---------- src/scanpy/preprocessing/_simple.py | 21 ++++++++-------- 6 files changed, 40 insertions(+), 23 deletions(-) create mode 100644 docs/release-notes/3380.bugfix.md diff --git a/docs/release-notes/3380.bugfix.md b/docs/release-notes/3380.bugfix.md new file mode 100644 index 0000000000..633ce346af --- /dev/null +++ b/docs/release-notes/3380.bugfix.md @@ -0,0 +1 @@ +Raise {exc}`FutureWarning` when calling deprecated {mod}`scanpy.pp` functions {smaller}`P Angerer` diff --git a/pyproject.toml b/pyproject.toml index cfb7ffd28a..324c4c4262 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dependencies = [ "packaging>=21.3", "session-info", "legacy-api-wrap>=1.4", # for positional API deprecations + "typing-extensions; python_version < '3.13'", ] dynamic = ["version"] diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index dca6c84c4e..b97b1a8603 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -48,6 +48,10 @@ class ZappyArray: "fullname", "pkg_metadata", "pkg_version", + "old_positionals", + "deprecated", + "njit", + "_numba_threading_layer", ] @@ -102,6 +106,15 @@ def old_positionals(*old_positionals: str): return lambda func: func +if sys.version_info >= (3, 13): + from warnings import deprecated as _deprecated +else: + from typing_extensions import deprecated as _deprecated + + +deprecated = partial(_deprecated, category=FutureWarning) + + @overload def njit(fn: Callable[P, R], /) -> Callable[P, R]: ... @overload diff --git a/src/scanpy/plotting/_preprocessing.py b/src/scanpy/plotting/_preprocessing.py index e6c7808be1..b51688082e 100644 --- a/src/scanpy/plotting/_preprocessing.py +++ b/src/scanpy/plotting/_preprocessing.py @@ -6,7 +6,7 @@ from matplotlib import pyplot as plt from matplotlib import rcParams -from .._compat import old_positionals +from .._compat import deprecated, old_positionals from .._settings import settings from . import _utils @@ -103,6 +103,7 @@ def highly_variable_genes( # backwards compat +@deprecated("Use sc.pl.highly_variable_genes instead") @old_positionals("log", "show", "save") def filter_genes_dispersion( result: np.recarray, diff --git a/src/scanpy/preprocessing/_deprecated/highly_variable_genes.py b/src/scanpy/preprocessing/_deprecated/highly_variable_genes.py index f2c3ce971b..27e8f1f846 100644 --- a/src/scanpy/preprocessing/_deprecated/highly_variable_genes.py +++ b/src/scanpy/preprocessing/_deprecated/highly_variable_genes.py @@ -9,7 +9,7 @@ from scipy.sparse import issparse from ... import logging as logg -from ..._compat import old_positionals +from ..._compat import deprecated, old_positionals from .._distributed import materialize_as_ndarray from .._utils import _get_mean_var @@ -19,6 +19,7 @@ from scipy.sparse import spmatrix +@deprecated("Use sc.pp.highly_variable_genes instead") @old_positionals( "flavor", "min_disp", @@ -48,18 +49,17 @@ def filter_genes_dispersion( """\ Extract highly variable genes :cite:p:`Satija2015,Zheng2017`. - .. warning:: - .. deprecated:: 1.3.6 - Use :func:`~scanpy.pp.highly_variable_genes` - instead. The new function is equivalent to the present - function, except that + .. deprecated:: 1.3.6 - * the new function always expects logarithmized data - * `subset=False` in the new function, it suffices to - merely annotate the genes, tools like `pp.pca` will - detect the annotation - * you can now call: `sc.pl.highly_variable_genes(adata)` - * `copy` is replaced by `inplace` + Use :func:`~scanpy.pp.highly_variable_genes` instead. + The new function is equivalent to the present function, except that + + * the new function always expects logarithmized data + * `subset=False` in the new function, it suffices to + merely annotate the genes, tools like `pp.pca` will + detect the annotation + * you can now call: `sc.pl.highly_variable_genes(adata)` + * `copy` is replaced by `inplace` If trying out parameters, pass the data matrix instead of AnnData. diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 01936414a5..eaf9648690 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -18,7 +18,7 @@ from sklearn.utils import check_array, sparsefuncs from .. import logging as logg -from .._compat import njit, old_positionals +from .._compat import deprecated, njit, old_positionals from .._settings import settings as sett from .._utils import ( _check_array_function_arguments, @@ -474,6 +474,7 @@ def sqrt( return X.sqrt() +@deprecated("Use sc.pp.normalize_total instead") @old_positionals( "counts_per_cell_after", "counts_per_cell", @@ -497,16 +498,16 @@ def normalize_per_cell( """\ Normalize total counts per cell. - .. warning:: - .. deprecated:: 1.3.7 - Use :func:`~scanpy.pp.normalize_total` instead. - The new function is equivalent to the present - function, except that + .. deprecated:: 1.3.7 - * the new function doesn't filter cells based on `min_counts`, - use :func:`~scanpy.pp.filter_cells` if filtering is needed. - * some arguments were renamed - * `copy` is replaced by `inplace` + Use :func:`~scanpy.pp.normalize_total` instead. + The new function is equivalent to the present + function, except that + + * the new function doesn't filter cells based on `min_counts`, + use :func:`~scanpy.pp.filter_cells` if filtering is needed. + * some arguments were renamed + * `copy` is replaced by `inplace` Normalize each cell by total counts over all genes, so that every cell has the same total count after normalization. From 8b0c3f6094cbf6c4edfbc010fa76cf0ac3af1c0c Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Fri, 6 Dec 2024 15:37:16 +0100 Subject: [PATCH 39/51] (fix): bound sklearn because of dask-ml on the release candidate (#3393) * (fix): bound sklearn because of dask-ml on the release candidate * (chore): release note * (fix): `mod` in note * (fix): release notes number --- docs/release-notes/3393.bugfix.md | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/release-notes/3393.bugfix.md diff --git a/docs/release-notes/3393.bugfix.md b/docs/release-notes/3393.bugfix.md new file mode 100644 index 0000000000..22af00f124 --- /dev/null +++ b/docs/release-notes/3393.bugfix.md @@ -0,0 +1 @@ +Upper-bound {mod}`sklearn` `<1.6.0` due to {issue}`dask/dask-ml#1002` {smaller}`Ilan Gold` diff --git a/pyproject.toml b/pyproject.toml index 324c4c4262..f1495442fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ "seaborn>=0.13", "h5py>=3.7", "tqdm", - "scikit-learn>=1.1", + "scikit-learn>=1.1,<1.6.0", "statsmodels>=0.13", "patsy!=1.0.0", # https://github.com/pydata/patsy/issues/215 "networkx>=2.7", From ef9286652aa3d61e7941553937f6830fd819d589 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 09:11:15 +0100 Subject: [PATCH 40/51] [pre-commit.ci] pre-commit autoupdate (#3388) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c8088c28f3..6c91285096 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.8.2 hooks: - id: ruff types_or: [python, pyi, jupyter] From 3f329bb2565b166612ce8db155de46348682c3ac Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 10 Dec 2024 16:37:09 +0100 Subject: [PATCH 41/51] Remove calls to `.format` (#3325) --- src/scanpy/_settings.py | 4 +--- src/scanpy/external/exporting.py | 4 ++-- src/scanpy/external/tl/_phenograph.py | 4 ++-- src/scanpy/plotting/_tools/paga.py | 8 +++---- src/scanpy/preprocessing/_qc.py | 21 +++++++------------ src/scanpy/tools/_rank_genes_groups.py | 7 ++++--- .../notebooks/test_paga_paul15_subsampled.py | 2 +- 7 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/scanpy/_settings.py b/src/scanpy/_settings.py index 54b51b6420..5543689ef7 100644 --- a/src/scanpy/_settings.py +++ b/src/scanpy/_settings.py @@ -82,9 +82,7 @@ def _type_check(var: Any, varname: str, types: type | tuple[type, ...]): possible_types_str = types.__name__ else: type_names = [t.__name__ for t in types] - possible_types_str = "{} or {}".format( - ", ".join(type_names[:-1]), type_names[-1] - ) + possible_types_str = f"{', '.join(type_names[:-1])} or {type_names[-1]}" raise TypeError(f"{varname} must be of type {possible_types_str}") diff --git a/src/scanpy/external/exporting.py b/src/scanpy/external/exporting.py index c1d7fa93b4..9364b7d368 100644 --- a/src/scanpy/external/exporting.py +++ b/src/scanpy/external/exporting.py @@ -345,8 +345,8 @@ def _write_color_tracks(ctracks, fname): def _frac_to_hex(frac): - rgb = tuple(np.array(np.array(plt.cm.jet(frac)[:3]) * 255, dtype=int)) - return "#{:02x}{:02x}{:02x}".format(*rgb) + r, g, b = tuple(np.array(np.array(plt.cm.jet(frac)[:3]) * 255, dtype=int)) + return f"#{r:02x}{g:02x}{b:02x}" def _get_color_stats_genes(color_stats, E, gene_list): diff --git a/src/scanpy/external/tl/_phenograph.py b/src/scanpy/external/tl/_phenograph.py index 8cecfa7276..24e10bcb85 100644 --- a/src/scanpy/external/tl/_phenograph.py +++ b/src/scanpy/external/tl/_phenograph.py @@ -244,8 +244,8 @@ def phenograph( comm_key = ( f"pheno_{clustering_algo}" if clustering_algo in ["louvain", "leiden"] else "" ) - ig_key = "pheno_{}_ig".format("jaccard" if jaccard else "gaussian") - q_key = "pheno_{}_q".format("jaccard" if jaccard else "gaussian") + ig_key = f"pheno_{'jaccard' if jaccard else 'gaussian'}_ig" + q_key = f"pheno_{'jaccard' if jaccard else 'gaussian'}_q" communities, graph, Q = phenograph.cluster( data=data, diff --git a/src/scanpy/plotting/_tools/paga.py b/src/scanpy/plotting/_tools/paga.py index ff14a19989..e67e6e2ece 100644 --- a/src/scanpy/plotting/_tools/paga.py +++ b/src/scanpy/plotting/_tools/paga.py @@ -702,11 +702,11 @@ def _paga_graph( and isinstance(node_labels, str) and node_labels != adata.uns["paga"]["groups"] ): - raise ValueError( - "Provide a list of group labels for the PAGA groups {}, not {}.".format( - adata.uns["paga"]["groups"], node_labels - ) + msg = ( + "Provide a list of group labels for the PAGA groups " + f"{adata.uns['paga']['groups']}, not {node_labels}." ) + raise ValueError(msg) groups_key = adata.uns["paga"]["groups"] if node_labels is None: node_labels = adata.obs[groups_key].cat.categories diff --git a/src/scanpy/preprocessing/_qc.py b/src/scanpy/preprocessing/_qc.py index 27836e1717..87ad51d420 100644 --- a/src/scanpy/preprocessing/_qc.py +++ b/src/scanpy/preprocessing/_qc.py @@ -194,26 +194,21 @@ def describe_var( if issparse(X): X.eliminate_zeros() var_metrics = pd.DataFrame(index=adata.var_names) - var_metrics["n_cells_by_{expr_type}"], var_metrics["mean_{expr_type}"] = ( + var_metrics[f"n_cells_by_{expr_type}"], var_metrics[f"mean_{expr_type}"] = ( materialize_as_ndarray((axis_nnz(X, axis=0), _get_mean_var(X, axis=0)[0])) ) if log1p: - var_metrics["log1p_mean_{expr_type}"] = np.log1p( - var_metrics["mean_{expr_type}"] + var_metrics[f"log1p_mean_{expr_type}"] = np.log1p( + var_metrics[f"mean_{expr_type}"] ) - var_metrics["pct_dropout_by_{expr_type}"] = ( - 1 - var_metrics["n_cells_by_{expr_type}"] / X.shape[0] + var_metrics[f"pct_dropout_by_{expr_type}"] = ( + 1 - var_metrics[f"n_cells_by_{expr_type}"] / X.shape[0] ) * 100 - var_metrics["total_{expr_type}"] = np.ravel(axis_sum(X, axis=0)) + var_metrics[f"total_{expr_type}"] = np.ravel(axis_sum(X, axis=0)) if log1p: - var_metrics["log1p_total_{expr_type}"] = np.log1p( - var_metrics["total_{expr_type}"] + var_metrics[f"log1p_total_{expr_type}"] = np.log1p( + var_metrics[f"total_{expr_type}"] ) - # Relabel - new_colnames = [] - for col in var_metrics.columns: - new_colnames.append(col.format(**locals())) - var_metrics.columns = new_colnames if inplace: adata.var[var_metrics.columns] = var_metrics return None diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 9a2896196a..59526ee516 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -124,10 +124,11 @@ def __init__( ) if len(invalid_groups_selected) > 0: - raise ValueError( - "Could not calculate statistics for groups {} since they only " - "contain one sample.".format(", ".join(invalid_groups_selected)) + msg = ( + f"Could not calculate statistics for groups {', '.join(invalid_groups_selected)} " + "since they only contain one sample." ) + raise ValueError(msg) adata_comp = adata if layer is not None: diff --git a/tests/notebooks/test_paga_paul15_subsampled.py b/tests/notebooks/test_paga_paul15_subsampled.py index 9ce6ea8319..5d8c17d336 100644 --- a/tests/notebooks/test_paga_paul15_subsampled.py +++ b/tests/notebooks/test_paga_paul15_subsampled.py @@ -138,6 +138,6 @@ def test_paga_paul15_subsampled(image_comparer, plt): show=False, ) # add a test for this at some point - # data.to_csv('./write/paga_path_{}.csv'.format(descr)) + # data.to_csv(f"./write/paga_path_{descr}.csv") save_and_compare_images("paga_path") From 391d87ad50d015d1f937d7d993dad36f24b915a5 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 12 Dec 2024 15:23:36 +0100 Subject: [PATCH 42/51] Constrain all extras for min-deps job (#3367) --- ci/scripts/min-deps.py | 107 ++++++++++++++++++++++++++++------------- hatch.toml | 2 +- 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/ci/scripts/min-deps.py b/ci/scripts/min-deps.py index 18af6ce151..0d49d151ef 100755 --- a/ci/scripts/min-deps.py +++ b/ci/scripts/min-deps.py @@ -12,6 +12,7 @@ import sys from collections import deque from contextlib import ExitStack +from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING @@ -25,6 +26,8 @@ if TYPE_CHECKING: from collections.abc import Generator, Iterable, Sequence + from collections.abc import Set as AbstractSet + from typing import Any, Self def min_dep(req: Requirement) -> Requirement: @@ -77,48 +80,86 @@ def extract_min_deps( class Args(argparse.Namespace): - path: Path + """\ + Parse a pyproject.toml file and output a list of minimum dependencies. + Output is optimized for `[uv] pip install` (see `-o`/`--output` for details). + """ + + _path: Path output: Path | None - extras: list[str] + _extras: list[str] + _all_extras: bool + + @classmethod + def parse(cls, argv: Sequence[str] | None = None) -> Self: + return cls.parser().parse_args(argv, cls()) + + @classmethod + def parser(cls) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="min-deps", + description=cls.__doc__, + usage="pip install `python min-deps.py pyproject.toml`", + ) + parser.add_argument( + "_path", + metavar="pyproject.toml", + type=Path, + help="Path to pyproject.toml to parse minimum dependencies from", + ) + parser.add_argument( + "--extras", + dest="_extras", + metavar="EXTRA", + type=str, + nargs="*", + default=(), + help="extras to install", + ) + parser.add_argument( + "--all-extras", + dest="_all_extras", + action="store_true", + help="get all extras", + ) + parser.add_argument( + *("--output", "-o"), + metavar="FILE", + type=Path, + default=None, + help=( + "output file (default: stdout). " + "Without this option, output is space-separated for direct passing to `pip install`. " + "With this option, output written to a file newline-separated file usable as `requirements.txt` or `constraints.txt`." + ), + ) + return parser + + @cached_property + def pyproject(self) -> dict[str, Any]: + return tomllib.loads(self._path.read_text()) + + @cached_property + def extras(self) -> AbstractSet[str]: + if self._extras: + if self._all_extras: + sys.exit("Cannot specify both --extras and --all-extras") + return dict.fromkeys(self._extras).keys() + if not self._all_extras: + return set() + return self.pyproject["project"]["optional-dependencies"].keys() def main(argv: Sequence[str] | None = None) -> None: - parser = argparse.ArgumentParser( - prog="min-deps", - description=( - "Parse a pyproject.toml file and output a list of minimum dependencies. " - "Output is optimized for `[uv] pip install` (see `-o`/`--output` for details)." - ), - usage="pip install `python min-deps.py pyproject.toml`", - ) - parser.add_argument( - "path", type=Path, help="pyproject.toml to parse minimum dependencies from" - ) - parser.add_argument( - "--extras", type=str, nargs="*", default=(), help="extras to install" - ) - parser.add_argument( - *("--output", "-o"), - type=Path, - default=None, - help=( - "output file (default: stdout). " - "Without this option, output is space-separated for direct passing to `pip install`. " - "With this option, output written to a file newline-separated file usable as `requirements.txt` or `constraints.txt`." - ), - ) - - args = parser.parse_args(argv, Args()) - - pyproject = tomllib.loads(args.path.read_text()) + args = Args.parse(argv) - project_name = pyproject["project"]["name"] + project_name = args.pyproject["project"]["name"] deps = [ - *map(Requirement, pyproject["project"]["dependencies"]), + *map(Requirement, args.pyproject["project"]["dependencies"]), *(Requirement(f"{project_name}[{extra}]") for extra in args.extras), ] - min_deps = extract_min_deps(deps, pyproject=pyproject) + min_deps = extract_min_deps(deps, pyproject=args.pyproject) sep = "\n" if args.output else " " with ExitStack() as stack: diff --git a/hatch.toml b/hatch.toml index ad5db60976..3163d5d82d 100644 --- a/hatch.toml +++ b/hatch.toml @@ -22,7 +22,7 @@ overrides.matrix.deps.env-vars = [ { if = ["min"], key = "UV_CONSTRAINT", value = "ci/scanpy-min-deps.txt" }, ] overrides.matrix.deps.pre-install-commands = [ - { if = ["min"], value = "uv run ci/scripts/min-deps.py pyproject.toml -o ci/scanpy-min-deps.txt" }, + { if = ["min"], value = "uv run ci/scripts/min-deps.py pyproject.toml --all-extras -o ci/scanpy-min-deps.txt" }, ] overrides.matrix.deps.python = [ { if = ["min"], value = "3.10" }, From 71dff09df70a7e7d57e504dc0461f4c4bcf160e3 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Mon, 16 Dec 2024 12:39:52 +0100 Subject: [PATCH 43/51] =?UTF-8?q?Add=20a=20=E2=80=9Cimproved=20documentati?= =?UTF-8?q?on=E2=80=9D=20category=20to=20enhancement=20template=20(#3403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug-report.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/ISSUE_TEMPLATE/enhancement-request.yml | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 9b7cac7353..71637016a7 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,8 +1,8 @@ name: Bug report description: Scanpy doesn’t do what it should? Please help us fix it! #title: ... +type: Bug labels: -- Bug 🐛 - Triage 🩺 #assignees: [] body: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1505f196f5..a0c4b12e00 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - name: Scanpy Community Forum url: https://discourse.scverse.org/ diff --git a/.github/ISSUE_TEMPLATE/enhancement-request.yml b/.github/ISSUE_TEMPLATE/enhancement-request.yml index 209ee6805a..9e511c592c 100644 --- a/.github/ISSUE_TEMPLATE/enhancement-request.yml +++ b/.github/ISSUE_TEMPLATE/enhancement-request.yml @@ -1,8 +1,8 @@ name: Enhancement request description: Anything you’d like to see in scanpy? #title: ... +type: Enhancement labels: -- Enhancement ✨ - Triage 🩺 #assignees: [] body: @@ -14,6 +14,7 @@ body: - 'Additional function parameters / changed functionality / changed defaults?' - 'New analysis tool: A simple analysis tool you have been using and are missing in `sc.tools`?' - 'New plotting function: A kind of plot you would like to seein `sc.pl`?' + - 'Improved documentation or error message?' - 'Other?' validations: required: true From 7e3dd157d109164d5f7e1bc82926d57c70c34572 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 17 Dec 2024 11:07:59 +0100 Subject: [PATCH 44/51] Modify error message if certifi is not installed (#3402) --- src/scanpy/_compat.py | 13 +++++++++++++ src/scanpy/readwrite.py | 23 +++++++++++------------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index b97b1a8603..d2c69a9e37 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -106,6 +106,19 @@ def old_positionals(*old_positionals: str): return lambda func: func +if sys.version_info >= (3, 11): + + @wraps(BaseException.add_note) + def add_note(exc: BaseException, note: str) -> None: + exc.add_note(note) +else: + + def add_note(exc: BaseException, note: str) -> None: + if not hasattr(exc, "__notes__"): + exc.__notes__ = [] + exc.__notes__.append(note) + + if sys.version_info >= (3, 13): from warnings import deprecated as _deprecated else: diff --git a/src/scanpy/readwrite.py b/src/scanpy/readwrite.py index 3c958a1e50..07bd817ca5 100644 --- a/src/scanpy/readwrite.py +++ b/src/scanpy/readwrite.py @@ -36,7 +36,7 @@ from matplotlib.image import imread from . import logging as logg -from ._compat import old_positionals +from ._compat import add_note, old_positionals from ._settings import settings from ._utils import _empty @@ -993,15 +993,11 @@ def _get_filename_from_key(key, ext=None) -> Path: def _download(url: str, path: Path): - try: - import ipywidgets # noqa: F401 - from tqdm.auto import tqdm - except ImportError: - from tqdm import tqdm - from urllib.error import URLError from urllib.request import Request, urlopen + from tqdm.auto import tqdm + blocksize = 1024 * 8 blocknum = 0 @@ -1011,14 +1007,17 @@ def _download(url: str, path: Path): try: open_url = urlopen(req) except URLError: - logg.warning( - "Failed to open the url with default certificates, trying with certifi." - ) + msg = "Failed to open the url with default certificates." + try: + from certifi import where + except ImportError as e: + add_note(e, f"{msg} Please install `certifi` and try again.") + raise + else: + logg.warning(f"{msg} Trying to use certifi.") from ssl import create_default_context - from certifi import where - open_url = urlopen(req, context=create_default_context(cafile=where())) with open_url as resp: From 86d656daa5553aa39804e21f8daab735cddbf6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6k=C3=A7en=20Eraslan?= Date: Thu, 19 Dec 2024 08:48:10 -0800 Subject: [PATCH 45/51] Add replace option to subsample and rename function to sample (#943) --- docs/api/deprecated.md | 1 + docs/api/preprocessing.md | 2 +- docs/release-notes/943.feature.md | 1 + pyproject.toml | 4 +- src/scanpy/_compat.py | 41 ++++- src/scanpy/preprocessing/__init__.py | 4 +- .../preprocessing/_deprecated/sampling.py | 60 +++++++ src/scanpy/preprocessing/_simple.py | 165 +++++++++++++----- tests/test_package_structure.py | 1 + tests/test_preprocessing.py | 144 ++++++++++++--- tests/test_utils.py | 42 ++++- 11 files changed, 391 insertions(+), 74 deletions(-) create mode 100644 docs/release-notes/943.feature.md create mode 100644 src/scanpy/preprocessing/_deprecated/sampling.py diff --git a/docs/api/deprecated.md b/docs/api/deprecated.md index 4511f4b3a7..d09c1af405 100644 --- a/docs/api/deprecated.md +++ b/docs/api/deprecated.md @@ -11,4 +11,5 @@ pp.filter_genes_dispersion pp.normalize_per_cell + pp.subsample ``` diff --git a/docs/api/preprocessing.md b/docs/api/preprocessing.md index 4b17567a6b..36e732a6dc 100644 --- a/docs/api/preprocessing.md +++ b/docs/api/preprocessing.md @@ -31,7 +31,7 @@ For visual quality control, see {func}`~scanpy.pl.highest_expr_genes` and pp.normalize_total pp.regress_out pp.scale - pp.subsample + pp.sample pp.downsample_counts ``` diff --git a/docs/release-notes/943.feature.md b/docs/release-notes/943.feature.md new file mode 100644 index 0000000000..4f5474d762 --- /dev/null +++ b/docs/release-notes/943.feature.md @@ -0,0 +1 @@ +{func}`~scanpy.pp.sample` supports both upsampling and downsampling of observations and variables. {func}`~scanpy.pp.subsample` is now deprecated. {smaller}`G Eraslan` & {smaller}`P Angerer` diff --git a/pyproject.toml b/pyproject.toml index f1495442fe..b4b8abd1b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ classifiers = [ ] dependencies = [ "anndata>=0.8", - "numpy>=1.23", + "numpy>=1.24", "matplotlib>=3.6", "pandas >=1.5", "scipy>=1.8", @@ -60,7 +60,7 @@ dependencies = [ "networkx>=2.7", "natsort", "joblib", - "numba>=0.56", + "numba>=0.57", "umap-learn>=0.5,!=0.5.0", "pynndescent>=0.5", "packaging>=21.3", diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index d2c69a9e37..9ea7780b0d 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -4,7 +4,7 @@ import sys import warnings from dataclasses import dataclass, field -from functools import cache, partial, wraps +from functools import WRAPPER_ASSIGNMENTS, cache, partial, wraps from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING, Literal, ParamSpec, TypeVar, cast, overload @@ -224,3 +224,42 @@ def _numba_threading_layer() -> Layer: f" ({available=}, {numba.config.THREADING_LAYER_PRIORITY=})" ) raise ValueError(msg) + + +def _legacy_numpy_gen( + random_state: _LegacyRandom | None = None, +) -> np.random.Generator: + """Return a random generator that behaves like the legacy one.""" + + if random_state is not None: + if isinstance(random_state, np.random.RandomState): + np.random.set_state(random_state.get_state(legacy=False)) + return _FakeRandomGen(random_state) + np.random.seed(random_state) + return _FakeRandomGen(np.random.RandomState(np.random.get_bit_generator())) + + +class _FakeRandomGen(np.random.Generator): + _state: np.random.RandomState + + def __init__(self, random_state: np.random.RandomState) -> None: + self._state = random_state + + @classmethod + def _delegate(cls) -> None: + for name, meth in np.random.Generator.__dict__.items(): + if name.startswith("_") or not callable(meth): + continue + + def mk_wrapper(name: str): + # Old pytest versions try to run the doctests + @wraps(meth, assigned=set(WRAPPER_ASSIGNMENTS) - {"__doc__"}) + def wrapper(self: _FakeRandomGen, *args, **kwargs): + return getattr(self._state, name)(*args, **kwargs) + + return wrapper + + setattr(cls, name, mk_wrapper(name)) + + +_FakeRandomGen._delegate() diff --git a/src/scanpy/preprocessing/__init__.py b/src/scanpy/preprocessing/__init__.py index 8c396d8640..4307cbb6c9 100644 --- a/src/scanpy/preprocessing/__init__.py +++ b/src/scanpy/preprocessing/__init__.py @@ -3,6 +3,7 @@ from ..neighbors import neighbors from ._combat import combat from ._deprecated.highly_variable_genes import filter_genes_dispersion +from ._deprecated.sampling import subsample from ._highly_variable_genes import highly_variable_genes from ._normalization import normalize_total from ._pca import pca @@ -17,8 +18,8 @@ log1p, normalize_per_cell, regress_out, + sample, sqrt, - subsample, ) __all__ = [ @@ -40,6 +41,7 @@ "log1p", "normalize_per_cell", "regress_out", + "sample", "scale", "sqrt", "subsample", diff --git a/src/scanpy/preprocessing/_deprecated/sampling.py b/src/scanpy/preprocessing/_deprecated/sampling.py new file mode 100644 index 0000000000..02619a2364 --- /dev/null +++ b/src/scanpy/preprocessing/_deprecated/sampling.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..._compat import _legacy_numpy_gen, old_positionals +from .._simple import sample + +if TYPE_CHECKING: + import numpy as np + from anndata import AnnData + from numpy.typing import NDArray + from scipy.sparse import csc_matrix, csr_matrix + + from ..._compat import _LegacyRandom + + CSMatrix = csr_matrix | csc_matrix + + +@old_positionals("n_obs", "random_state", "copy") +def subsample( + data: AnnData | np.ndarray | CSMatrix, + fraction: float | None = None, + *, + n_obs: int | None = None, + random_state: _LegacyRandom = 0, + copy: bool = False, +) -> AnnData | tuple[np.ndarray | CSMatrix, NDArray[np.int64]] | None: + """\ + Subsample to a fraction of the number of observations. + + .. deprecated:: 1.11.0 + + Use :func:`~scanpy.pp.sample` instead. + + Parameters + ---------- + data + The (annotated) data matrix of shape `n_obs` × `n_vars`. + Rows correspond to cells and columns to genes. + fraction + Subsample to this `fraction` of the number of observations. + n_obs + Subsample to this number of observations. + random_state + Random seed to change subsampling. + copy + If an :class:`~anndata.AnnData` is passed, + determines whether a copy is returned. + + Returns + ------- + Returns `X[obs_indices], obs_indices` if data is array-like, otherwise + subsamples the passed :class:`~anndata.AnnData` (`copy == False`) or + returns a subsampled copy of it (`copy == True`). + """ + + rng = _legacy_numpy_gen(random_state) + return sample( + data=data, fraction=fraction, n=n_obs, rng=rng, copy=copy, replace=False, axis=0 + ) diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index eaf9648690..29c267c3f4 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -8,20 +8,21 @@ import warnings from functools import singledispatch from itertools import repeat -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, TypeVar, overload import numba import numpy as np from anndata import AnnData from pandas.api.types import CategoricalDtype -from scipy.sparse import csr_matrix, issparse, isspmatrix_csr, spmatrix +from scipy.sparse import csc_matrix, csr_matrix, issparse, isspmatrix_csr, spmatrix from sklearn.utils import check_array, sparsefuncs from .. import logging as logg -from .._compat import deprecated, njit, old_positionals +from .._compat import DaskArray, deprecated, njit, old_positionals from .._settings import settings as sett from .._utils import ( _check_array_function_arguments, + _resolve_axis, axis_sum, is_backed_type, raise_not_implemented_error_if_backed_type, @@ -33,15 +34,11 @@ from ._distributed import materialize_as_ndarray from ._utils import _to_dense -# install dask if available try: import dask.array as da except ImportError: da = None -# backwards compat -from ._deprecated.highly_variable_genes import filter_genes_dispersion # noqa: F401 - if TYPE_CHECKING: from collections.abc import Collection, Iterable, Sequence from numbers import Number @@ -50,7 +47,13 @@ import pandas as pd from numpy.typing import NDArray - from .._compat import DaskArray, _LegacyRandom + from .._compat import _LegacyRandom + from .._utils import RNGLike, SeedLike + + +CSMatrix = csr_matrix | csc_matrix + +A = TypeVar("A", bound=np.ndarray | CSMatrix | DaskArray) @old_positionals( @@ -825,17 +828,51 @@ def _regress_out_chunk( return np.vstack(responses_chunk_list) -@old_positionals("n_obs", "random_state", "copy") -def subsample( - data: AnnData | np.ndarray | spmatrix, +@overload +def sample( + data: AnnData, fraction: float | None = None, *, - n_obs: int | None = None, - random_state: _LegacyRandom = 0, + n: int | None = None, + rng: RNGLike | SeedLike | None = 0, + copy: Literal[False] = False, + replace: bool = False, + axis: Literal["obs", 0, "var", 1] = "obs", +) -> None: ... +@overload +def sample( + data: AnnData, + fraction: float | None = None, + *, + n: int | None = None, + rng: RNGLike | SeedLike | None = None, + copy: Literal[True], + replace: bool = False, + axis: Literal["obs", 0, "var", 1] = "obs", +) -> AnnData: ... +@overload +def sample( + data: A, + fraction: float | None = None, + *, + n: int | None = None, + rng: RNGLike | SeedLike | None = None, copy: bool = False, -) -> AnnData | tuple[np.ndarray | spmatrix, NDArray[np.int64]] | None: + replace: bool = False, + axis: Literal["obs", 0, "var", 1] = "obs", +) -> tuple[A, NDArray[np.int64]]: ... +def sample( + data: AnnData | np.ndarray | CSMatrix | DaskArray, + fraction: float | None = None, + *, + n: int | None = None, + rng: RNGLike | SeedLike | None = None, + copy: bool = False, + replace: bool = False, + axis: Literal["obs", 0, "var", 1] = "obs", +) -> AnnData | None | tuple[np.ndarray | CSMatrix | DaskArray, NDArray[np.int64]]: """\ - Subsample to a fraction of the number of observations. + Sample observations or variables with or without replacement. Parameters ---------- @@ -843,49 +880,81 @@ def subsample( The (annotated) data matrix of shape `n_obs` × `n_vars`. Rows correspond to cells and columns to genes. fraction - Subsample to this `fraction` of the number of observations. - n_obs - Subsample to this number of observations. + Sample to this `fraction` of the number of observations or variables. + This can be larger than 1.0, if `replace=True`. + See `axis` and `replace`. + n + Sample to this number of observations or variables. See `axis`. random_state Random seed to change subsampling. copy If an :class:`~anndata.AnnData` is passed, determines whether a copy is returned. + replace + If True, samples are drawn with replacement. + axis + Sample `obs`\\ ervations (axis 0) or `var`\\ iables (axis 1). Returns ------- - Returns `X[obs_indices], obs_indices` if data is array-like, otherwise - subsamples the passed :class:`~anndata.AnnData` (`copy == False`) or - returns a subsampled copy of it (`copy == True`). + If `isinstance(data, AnnData)` and `copy=False`, + this function returns `None`. Otherwise: + + `data[indices, :]` | `data[:, indices]` (depending on `axis`) + If `data` is array-like or `copy=True`, returns the subset. + `indices` : numpy.ndarray + If `data` is array-like, also returns the indices into the original. """ - np.random.seed(random_state) - old_n_obs = data.n_obs if isinstance(data, AnnData) else data.shape[0] - if n_obs is not None: - new_n_obs = n_obs - elif fraction is not None: - if fraction > 1 or fraction < 0: - raise ValueError(f"`fraction` needs to be within [0, 1], not {fraction}") - new_n_obs = int(fraction * old_n_obs) - logg.debug(f"... subsampled to {new_n_obs} data points") - else: - raise ValueError("Either pass `n_obs` or `fraction`.") - obs_indices = np.random.choice(old_n_obs, size=new_n_obs, replace=False) - if isinstance(data, AnnData): - if data.isbacked: - if copy: - return data[obs_indices].to_memory() - else: - raise NotImplementedError( - "Inplace subsampling is not implemented for backed objects." - ) + # parameter validation + if not copy and isinstance(data, AnnData) and data.isbacked: + msg = "Inplace sampling (`copy=False`) is not implemented for backed objects." + raise NotImplementedError(msg) + axis, axis_name = _resolve_axis(axis) + old_n = data.shape[axis] + match (fraction, n): + case (None, None): + msg = "Either `fraction` or `n` must be set." + raise TypeError(msg) + case (None, _): + pass + case (_, None): + if fraction < 0: + msg = f"`{fraction=}` needs to be nonnegative." + raise ValueError(msg) + if not replace and fraction > 1: + msg = f"If `replace=False`, `{fraction=}` needs to be within [0, 1]." + raise ValueError(msg) + n = int(fraction * old_n) + logg.debug(f"... sampled to {n} {axis_name}") + case _: + msg = "Providing both `fraction` and `n` is not allowed." + raise TypeError(msg) + del fraction + + # actually do subsampling + rng = np.random.default_rng(rng) + indices = rng.choice(old_n, size=n, replace=replace) + + # overload 1: inplace AnnData subset + if not copy and isinstance(data, AnnData): + if axis_name == "obs": + data._inplace_subset_obs(indices) else: - if copy: - return data[obs_indices].copy() - else: - data._inplace_subset_obs(obs_indices) - else: - X = data - return X[obs_indices], obs_indices + data._inplace_subset_var(indices) + return None + + subset = data[indices] if axis_name == "obs" else data[:, indices] + + # overload 2: copy AnnData subset + if copy and isinstance(data, AnnData): + assert isinstance(subset, AnnData) + return subset.to_memory() if data.isbacked else subset.copy() + + # overload 3: return array and indices + assert isinstance(subset, np.ndarray | CSMatrix | DaskArray), type(subset) + if copy: + subset = subset.copy() + return subset, indices @renamed_arg("target_counts", "counts_per_cell") diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py index 834c06d8b4..3541c561a5 100644 --- a/tests/test_package_structure.py +++ b/tests/test_package_structure.py @@ -138,6 +138,7 @@ class ExpectedSig(TypedDict): copy_sigs["sc.pp.filter_cells"] = None # unclear `inplace` situation copy_sigs["sc.pp.filter_genes"] = None # unclear `inplace` situation copy_sigs["sc.pp.subsample"] = None # returns indices along matrix +copy_sigs["sc.pp.sample"] = None # returns indices along matrix # partial exceptions: “data” instead of “adata” copy_sigs["sc.pp.log1p"]["first_name"] = "data" copy_sigs["sc.pp.normalize_per_cell"]["first_name"] = "data" diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index b8f5115b01..36283e7ed0 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -1,7 +1,10 @@ from __future__ import annotations +import warnings +from importlib.util import find_spec from itertools import product from pathlib import Path +from typing import TYPE_CHECKING import numpy as np import pandas as pd @@ -22,6 +25,13 @@ from testing.scanpy._helpers.data import pbmc3k, pbmc68k_reduced from testing.scanpy._pytest.params import ARRAY_TYPES +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, Literal + + CSMatrix = sp.csc_matrix | sp.csr_matrix + + HERE = Path(__file__).parent DATA_PATH = HERE / "_data" @@ -134,34 +144,128 @@ def test_normalize_per_cell(): assert adata.X.sum(axis=1).tolist() == adata_sparse.X.sum(axis=1).A1.tolist() -def test_subsample(): - adata = AnnData(np.ones((200, 10))) - sc.pp.subsample(adata, n_obs=40) - assert adata.n_obs == 40 - sc.pp.subsample(adata, fraction=0.1) - assert adata.n_obs == 4 +@pytest.mark.parametrize("array_type", ARRAY_TYPES) +@pytest.mark.parametrize("which", ["copy", "inplace", "array"]) +@pytest.mark.parametrize( + ("axis", "fraction", "n", "replace", "expected"), + [ + pytest.param(0, None, 40, False, 40, id="obs-40-no_replace"), + pytest.param(0, 0.1, None, False, 20, id="obs-0.1-no_replace"), + pytest.param(0, None, 201, True, 201, id="obs-201-replace"), + pytest.param(0, None, 1, True, 1, id="obs-1-replace"), + pytest.param(1, None, 10, False, 10, id="var-10-no_replace"), + pytest.param(1, None, 11, True, 11, id="var-11-replace"), + pytest.param(1, 2.0, None, True, 20, id="var-2.0-replace"), + ], +) +def test_sample( + *, + array_type: Callable[[np.ndarray], np.ndarray | CSMatrix], + which: Literal["copy", "inplace", "array"], + axis: Literal[0, 1], + fraction: float | None, + n: int | None, + replace: bool, + expected: int, +): + adata = AnnData(array_type(np.ones((200, 10)))) + + # ignoring this warning declaratively is a pain so do it here + if find_spec("dask"): + import dask.array as da + + warnings.filterwarnings("ignore", category=da.PerformanceWarning) + # can’t guarantee that duplicates are drawn when `replace=True`, + # so we just ignore the warning instead using `with pytest.warns(...)` + warnings.filterwarnings( + "ignore" if replace else "error", r".*names are not unique", UserWarning + ) + rv = sc.pp.sample( + adata.X if which == "array" else adata, + fraction, + n=n, + replace=replace, + axis=axis, + # `copy` only effects AnnData inputs + copy=dict(copy=True, inplace=False, array=False)[which], + ) + match which: + case "copy": + subset = rv + assert rv is not adata + assert adata.shape == (200, 10) + case "inplace": + subset = adata + assert rv is None + case "array": + subset, indices = rv + assert len(indices) == expected + assert adata.shape == (200, 10) + case _: + pytest.fail(f"Unknown `{which=}`") -def test_subsample_copy(): + assert subset.shape == ((expected, 10) if axis == 0 else (200, expected)) + + +@pytest.mark.parametrize( + ("args", "exc", "pattern"), + [ + pytest.param( + dict(), TypeError, r"Either `fraction` or `n` must be set", id="empty" + ), + pytest.param( + dict(n=10, fraction=0.2), + TypeError, + r"Providing both `fraction` and `n` is not allowed", + id="both", + ), + pytest.param( + dict(fraction=2), + ValueError, + r"If `replace=False`, `fraction=2` needs to be", + id="frac>1", + ), + pytest.param( + dict(fraction=-0.3), + ValueError, + r"`fraction=-0\.3` needs to be nonnegative", + id="frac<0", + ), + ], +) +def test_sample_error(args: dict[str, Any], exc: type[Exception], pattern: str): adata = AnnData(np.ones((200, 10))) - assert sc.pp.subsample(adata, n_obs=40, copy=True).shape == (40, 10) - assert sc.pp.subsample(adata, fraction=0.1, copy=True).shape == (20, 10) + with pytest.raises(exc, match=pattern): + sc.pp.sample(adata, **args) -def test_subsample_copy_backed(tmp_path): - A = np.random.rand(200, 10).astype(np.float32) - adata_m = AnnData(A.copy()) - adata_d = AnnData(A.copy()) - filename = tmp_path / "test.h5ad" - adata_d.filename = filename - # This should not throw an error - assert sc.pp.subsample(adata_d, n_obs=40, copy=True).shape == (40, 10) +def test_sample_backwards_compat(): + expected = np.array( + [26, 86, 2, 55, 75, 93, 16, 73, 54, 95, 53, 92, 78, 13, 7, 30, 22, 24, 33, 8] + ) + legacy_result, indices = sc.pp.subsample(np.arange(100), n_obs=20) + assert np.array_equal(indices, legacy_result), "arange choices should match indices" + assert np.array_equal(legacy_result, expected) + + +def test_sample_copy_backed(tmp_path): + adata_m = AnnData(np.random.rand(200, 10).astype(np.float32)) + adata_d = adata_m.copy() + adata_d.filename = tmp_path / "test.h5ad" + + assert sc.pp.sample(adata_d, n=40, copy=True).shape == (40, 10) np.testing.assert_array_equal( - sc.pp.subsample(adata_m, n_obs=40, copy=True).X, - sc.pp.subsample(adata_d, n_obs=40, copy=True).X, + sc.pp.sample(adata_m, n=40, copy=True, rng=0).X, + sc.pp.sample(adata_d, n=40, copy=True, rng=0).X, ) + + +def test_sample_copy_backed_error(tmp_path): + adata_d = AnnData(np.random.rand(200, 10).astype(np.float32)) + adata_d.filename = tmp_path / "test.h5ad" with pytest.raises(NotImplementedError): - sc.pp.subsample(adata_d, n_obs=40, copy=False) + sc.pp.sample(adata_d, n=40, copy=False) @pytest.mark.parametrize("array_type", ARRAY_TYPES) diff --git a/tests/test_utils.py b/tests/test_utils.py index f8a38a5f9d..81369a6938 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ from operator import mul, truediv from types import ModuleType +from typing import TYPE_CHECKING import numpy as np import pytest @@ -9,7 +10,7 @@ from packaging.version import Version from scipy.sparse import csr_matrix, issparse -from scanpy._compat import DaskArray, pkg_version +from scanpy._compat import DaskArray, _legacy_numpy_gen, pkg_version from scanpy._utils import ( axis_mul_or_truediv, axis_sum, @@ -26,6 +27,9 @@ ARRAY_TYPES_SPARSE_DASK_UNSUPPORTED, ) +if TYPE_CHECKING: + from typing import Any + def test_descend_classes_and_funcs(): # create module hierarchy @@ -247,3 +251,39 @@ def test_is_constant_dask(request: pytest.FixtureRequest, axis, expected, block_ x = da.from_array(np.array(x_data), chunks=2).map_blocks(block_type) result = is_constant(x, axis=axis).compute() np.testing.assert_array_equal(expected, result) + + +@pytest.mark.parametrize("seed", [0, 1, 1256712675]) +@pytest.mark.parametrize("pass_seed", [True, False], ids=["pass_seed", "set_seed"]) +@pytest.mark.parametrize("func", ["choice"]) +def test_legacy_numpy_gen(*, seed: int, pass_seed: bool, func: str): + np.random.seed(seed) + state_before = np.random.get_state(legacy=False) + + arrs: dict[bool, np.ndarray] = {} + states_after: dict[bool, dict[str, Any]] = {} + for direct in [True, False]: + if not pass_seed: + np.random.seed(seed) + arrs[direct] = _mk_random(func, direct=direct, seed=seed if pass_seed else None) + states_after[direct] = np.random.get_state(legacy=False) + + np.testing.assert_array_equal(arrs[True], arrs[False]) + np.testing.assert_equal( + *states_after.values(), err_msg="both should affect global state the same" + ) + # they should affect the global state + with pytest.raises(AssertionError): + np.testing.assert_equal(states_after[True], state_before) + + +def _mk_random(func: str, *, direct: bool, seed: int | None) -> np.ndarray: + if direct and seed is not None: + np.random.seed(seed) + gen = np.random if direct else _legacy_numpy_gen(seed) + match func: + case "choice": + arr = np.arange(1000) + return gen.choice(arr, size=(100, 100)) + case _: + pytest.fail(f"Unknown {func=}") From e3efba280eed0726eb3e397715069cbaec761ba4 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 19 Dec 2024 17:49:55 +0100 Subject: [PATCH 46/51] Switch to session-info2 (#3384) --- docs/conf.py | 1 + docs/release-notes/3384.feature.md | 1 + pyproject.toml | 2 +- src/scanpy/logging.py | 92 ++++++++---------------------- tests/test_logging.py | 4 +- 5 files changed, 31 insertions(+), 69 deletions(-) create mode 100644 docs/release-notes/3384.feature.md diff --git a/docs/conf.py b/docs/conf.py index 2c79aa8d82..155869b360 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -142,6 +142,7 @@ rapids_singlecell=("https://rapids-singlecell.readthedocs.io/en/latest/", None), scipy=("https://docs.scipy.org/doc/scipy/", None), seaborn=("https://seaborn.pydata.org/", None), + session_info2=("https://session-info2.readthedocs.io/en/stable/", None), sklearn=("https://scikit-learn.org/stable/", None), ) diff --git a/docs/release-notes/3384.feature.md b/docs/release-notes/3384.feature.md new file mode 100644 index 0000000000..755af9a8a3 --- /dev/null +++ b/docs/release-notes/3384.feature.md @@ -0,0 +1 @@ +Switch {func}`~scanpy.logging.print_header` and {func}`~scanpy.logging.print_versions` to {mod}`session_info2` {smaller}`P Angerer` diff --git a/pyproject.toml b/pyproject.toml index b4b8abd1b1..8e23afb14b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dependencies = [ "umap-learn>=0.5,!=0.5.0", "pynndescent>=0.5", "packaging>=21.3", - "session-info", + "session-info2", "legacy-api-wrap>=1.4", # for positional API deprecations "typing-extensions; python_version < '3.13'", ] diff --git a/src/scanpy/logging.py b/src/scanpy/logging.py index 168c3b5405..3aa0ca494c 100644 --- a/src/scanpy/logging.py +++ b/src/scanpy/logging.py @@ -4,17 +4,20 @@ import logging import sys -import warnings from datetime import datetime, timedelta, timezone from functools import partial, update_wrapper from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, overload import anndata.logging +from ._compat import deprecated + if TYPE_CHECKING: from typing import IO + from session_info2 import SessionInfo + from ._settings import ScanpyConfig @@ -127,33 +130,11 @@ def format(self, record: logging.LogRecord): get_memory_usage = anndata.logging.get_memory_usage -_DEPENDENCIES_NUMERICS = [ - "anndata", # anndata actually shouldn't, but as long as it's in development - "umap", - "numpy", - "scipy", - "pandas", - ("sklearn", "scikit-learn"), - "statsmodels", - "igraph", - "louvain", - "leidenalg", - "pynndescent", -] - - -def _versions_dependencies(dependencies): - # this is not the same as the requirements! - for mod in dependencies: - mod_name, dist_name = mod if isinstance(mod, tuple) else (mod, mod) - try: - imp = __import__(mod_name) - yield dist_name, imp.__version__ - except (ImportError, AttributeError): - pass - - -def print_header(*, file=None): +@overload +def print_header(*, file: None = None) -> SessionInfo: ... +@overload +def print_header(*, file: IO[str]) -> None: ... +def print_header(*, file: IO[str] | None = None): """\ Versions that might influence the numerical results. Matplotlib and Seaborn are excluded from this. @@ -163,50 +144,27 @@ def print_header(*, file=None): file Optional path for dependency output. """ + from session_info2 import session_info - modules = ["scanpy"] + _DEPENDENCIES_NUMERICS - print( - " ".join(f"{mod}=={ver}" for mod, ver in _versions_dependencies(modules)), - file=file or sys.stdout, - ) + sinfo = session_info(os=True, cpu=True, gpu=True, dependencies=True) + + if file is not None: + print(sinfo, file=file) + return + + return sinfo -def print_versions(*, file: IO[str] | None = None): +@deprecated("Use `print_header` instead") +def print_versions() -> SessionInfo: """\ - Print versions of imported packages, OS, and jupyter environment. + Alias for `print_header`. - For more options (including rich output) use `session_info.show` directly. + .. deprecated:: 1.11.0 - Parameters - ---------- - file - Optional path for output. + Use :func:`print_header` instead. """ - import session_info - - if file is not None: - from contextlib import redirect_stdout - - warnings.warn( - "Passing argument 'file' to print_versions is deprecated, and will be " - "removed in a future version.", - FutureWarning, - ) - with redirect_stdout(file): - print_versions() - else: - session_info.show( - dependencies=True, - html=False, - excludes=[ - "builtins", - "stdlib_list", - "importlib_metadata", - # Special module present if test coverage being calculated - # https://gitlab.com/joelostblom/session_info/-/issues/10 - "$coverage", - ], - ) + return print_header() def print_version_and_date(*, file=None): @@ -235,7 +193,7 @@ def _copy_docs_and_signature(fn): def error( msg: str, *, - time: datetime = None, + time: datetime | None = None, deep: str | None = None, extra: dict | None = None, ) -> datetime: diff --git a/tests/test_logging.py b/tests/test_logging.py index 3f8a3ee97d..81b4acbf38 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -142,6 +142,8 @@ def test_call_outputs(func): """ output_io = StringIO() with redirect_stdout(output_io): - func() + out = func() + if out is not None: + print(out) output = output_io.getvalue() assert output != "" From 1cd5a00d750d061b04b94a9a6bf780a161f1da9e Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 19 Dec 2024 19:30:49 +0100 Subject: [PATCH 47/51] Scipy 1.15 compat, some test refactors (#3409) --- src/scanpy/tools/_rank_genes_groups.py | 2 +- tests/test_backed.py | 4 +- tests/test_filter_rank_genes_groups.py | 211 +++++++++--------------- tests/test_preprocessing_distributed.py | 3 +- 4 files changed, 79 insertions(+), 141 deletions(-) diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 59526ee516..aa4428dad1 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -854,7 +854,7 @@ def filter_rank_genes_groups( if not use_logfolds or not use_fraction: sub_X = adata.raw[:, var_names].X if use_raw else adata[:, var_names].X - in_group = adata.obs[groupby] == cluster + in_group = (adata.obs[groupby] == cluster).to_numpy() X_in = sub_X[in_group] X_out = sub_X[~in_group] diff --git a/tests/test_backed.py b/tests/test_backed.py index 787edf9c21..bfa1d79592 100644 --- a/tests/test_backed.py +++ b/tests/test_backed.py @@ -91,8 +91,8 @@ def test_log1p_backed_errors(backed_adata): def test_scatter_backed(backed_adata): sc.pp.pca(backed_adata, chunked=True) - sc.pl.scatter(backed_adata, color="0", basis="pca") + sc.pl.scatter(backed_adata, color="0", basis="pca", show=False) def test_dotplot_backed(backed_adata): - sc.pl.dotplot(backed_adata, ["0", "1", "2", "3"], groupby="cat") + sc.pl.dotplot(backed_adata, ["0", "1", "2", "3"], groupby="cat", show=False) diff --git a/tests/test_filter_rank_genes_groups.py b/tests/test_filter_rank_genes_groups.py index 26851bb102..a64ac983f3 100644 --- a/tests/test_filter_rank_genes_groups.py +++ b/tests/test_filter_rank_genes_groups.py @@ -1,159 +1,96 @@ from __future__ import annotations import numpy as np +import pytest from scanpy.tools import filter_rank_genes_groups, rank_genes_groups from testing.scanpy._helpers.data import pbmc68k_reduced -names_no_reference = np.array( +NAMES_NO_REF = [ + ["CD3D", "ITM2A", "CD3D", "CCL5", "CD7", "nan", "CD79A", "nan", "NKG7", "LYZ"], + ["CD3E", "CD3D", "nan", "NKG7", "CD3D", "AIF1", "CD79B", "nan", "GNLY", "CST3"], + ["IL32", "RPL39", "nan", "CST7", "nan", "nan", "nan", "SNHG7", "CD7", "nan"], + ["nan", "SRSF7", "IL32", "GZMA", "nan", "LST1", "IGJ", "nan", "CTSW", "nan"], + ["nan", "nan", "CD2", "CTSW", "CD8B", "TYROBP", "ISG20", "SNHG8", "GZMB", "nan"], +] + +NAMES_REF = [ + ["CD3D", "ITM2A", "CD3D", "nan", "CD3D", "nan", "CD79A", "nan", "CD7"], + ["nan", "nan", "nan", "CD3D", "nan", "AIF1", "nan", "nan", "NKG7"], + ["nan", "nan", "nan", "NKG7", "nan", "FCGR3A", "ISG20", "SNHG7", "CTSW"], + ["nan", "CD3D", "nan", "CCL5", "CD7", "nan", "CD79B", "nan", "GNLY"], + ["CD3E", "IL32", "nan", "IL32", "CD27", "FCER1G", "nan", "nan", "nan"], +] + +NAMES_NO_REF_COMPARE_ABS = [ [ - ["CD3D", "ITM2A", "CD3D", "CCL5", "CD7", "nan", "CD79A", "nan", "NKG7", "LYZ"], - ["CD3E", "CD3D", "nan", "NKG7", "CD3D", "AIF1", "CD79B", "nan", "GNLY", "CST3"], - ["IL32", "RPL39", "nan", "CST7", "nan", "nan", "nan", "SNHG7", "CD7", "nan"], - ["nan", "SRSF7", "IL32", "GZMA", "nan", "LST1", "IGJ", "nan", "CTSW", "nan"], - [ - "nan", - "nan", - "CD2", - "CTSW", - "CD8B", - "TYROBP", - "ISG20", - "SNHG8", - "GZMB", - "nan", - ], - ] -) - -names_reference = np.array( + *("CD3D", "ITM2A", "HLA-DRB1", "CCL5", "HLA-DPA1"), + *("nan", "CD79A", "nan", "NKG7", "LYZ"), + ], [ - ["CD3D", "ITM2A", "CD3D", "nan", "CD3D", "nan", "CD79A", "nan", "CD7"], - ["nan", "nan", "nan", "CD3D", "nan", "AIF1", "nan", "nan", "NKG7"], - ["nan", "nan", "nan", "NKG7", "nan", "FCGR3A", "ISG20", "SNHG7", "CTSW"], - ["nan", "CD3D", "nan", "CCL5", "CD7", "nan", "CD79B", "nan", "GNLY"], - ["CD3E", "IL32", "nan", "IL32", "CD27", "FCER1G", "nan", "nan", "nan"], - ] -) - -names_compare_abs = np.array( + *("HLA-DPA1", "nan", "CD3D", "NKG7", "HLA-DRB1"), + *("AIF1", "CD79B", "nan", "GNLY", "CST3"), + ], [ - [ - "CD3D", - "ITM2A", - "HLA-DRB1", - "CCL5", - "HLA-DPA1", - "nan", - "CD79A", - "nan", - "NKG7", - "LYZ", - ], - [ - "HLA-DPA1", - "nan", - "CD3D", - "NKG7", - "HLA-DRB1", - "AIF1", - "CD79B", - "nan", - "GNLY", - "CST3", - ], - [ - "nan", - "PSAP", - "CD74", - "CST7", - "CD74", - "PSAP", - "FCER1G", - "SNHG7", - "CD7", - "HLA-DRA", - ], - [ - "IL32", - "nan", - "HLA-DRB5", - "GZMA", - "HLA-DRB5", - "LST1", - "nan", - "nan", - "CTSW", - "HLA-DRB1", - ], - [ - "nan", - "FCER1G", - "HLA-DPB1", - "CTSW", - "HLA-DPB1", - "TYROBP", - "TYROBP", - "S100A10", - "GZMB", - "HLA-DPA1", - ], - ] -) - - -def test_filter_rank_genes_groups(): - adata = pbmc68k_reduced() - - # fix filter defaults - args = { - "adata": adata, - "key_added": "rank_genes_groups_filtered", - "min_in_group_fraction": 0.25, - "min_fold_change": 1, - "max_out_group_fraction": 0.5, - } - - rank_genes_groups( - adata, "bulk_labels", reference="Dendritic", method="wilcoxon", n_genes=5 - ) - filter_rank_genes_groups(**args) - - assert np.array_equal( - names_reference, - np.array(adata.uns["rank_genes_groups_filtered"]["names"].tolist()), - ) + *("nan", "PSAP", "CD74", "CST7", "CD74"), + *("PSAP", "FCER1G", "SNHG7", "CD7", "HLA-DRA"), + ], + [ + *("IL32", "nan", "HLA-DRB5", "GZMA", "HLA-DRB5"), + *("LST1", "nan", "nan", "CTSW", "HLA-DRB1"), + ], + [ + *("nan", "FCER1G", "HLA-DPB1", "CTSW", "HLA-DPB1"), + *("TYROBP", "TYROBP", "S100A10", "GZMB", "HLA-DPA1"), + ], +] - rank_genes_groups(adata, "bulk_labels", method="wilcoxon", n_genes=5) - filter_rank_genes_groups(**args) - assert np.array_equal( - names_no_reference, - np.array(adata.uns["rank_genes_groups_filtered"]["names"].tolist()), - ) +EXPECTED = { + ("Dendritic", False): np.array(NAMES_REF), + ("rest", False): np.array(NAMES_NO_REF), + ("rest", True): np.array(NAMES_NO_REF_COMPARE_ABS), +} - rank_genes_groups(adata, "bulk_labels", method="wilcoxon", pts=True, n_genes=5) - filter_rank_genes_groups(**args) - assert np.array_equal( - names_no_reference, - np.array(adata.uns["rank_genes_groups_filtered"]["names"].tolist()), - ) +@pytest.mark.parametrize( + ("reference", "pts", "abs"), + [ + pytest.param("Dendritic", False, False, id="ref-no_pts-no_abs"), + pytest.param("rest", False, False, id="rest-no_pts-no_abs"), + pytest.param("rest", True, False, id="rest-pts-no_abs"), + pytest.param("rest", True, True, id="rest-pts-abs"), + ], +) +def test_filter_rank_genes_groups(reference, pts, abs): + adata = pbmc68k_reduced() - # test compare_abs rank_genes_groups( - adata, "bulk_labels", method="wilcoxon", pts=True, rankby_abs=True, n_genes=5 - ) - - filter_rank_genes_groups( adata, - compare_abs=True, - min_in_group_fraction=-1, - max_out_group_fraction=1, - min_fold_change=3.1, + "bulk_labels", + reference=reference, + pts=pts, + method="wilcoxon", + rankby_abs=abs, + n_genes=5, ) + if abs: + filter_rank_genes_groups( + adata, + compare_abs=True, + min_in_group_fraction=-1, + max_out_group_fraction=1, + min_fold_change=3.1, + ) + else: + filter_rank_genes_groups( + adata, + min_in_group_fraction=0.25, + min_fold_change=1, + max_out_group_fraction=0.5, + ) assert np.array_equal( - names_compare_abs, + EXPECTED[reference, abs], np.array(adata.uns["rank_genes_groups_filtered"]["names"].tolist()), ) diff --git a/tests/test_preprocessing_distributed.py b/tests/test_preprocessing_distributed.py index a1b99121ef..afb120b982 100644 --- a/tests/test_preprocessing_distributed.py +++ b/tests/test_preprocessing_distributed.py @@ -40,13 +40,13 @@ def adata() -> AnnData: return a -@filter_oldformatwarning @pytest.fixture( params=[ pytest.param("direct", marks=[needs.zappy]), pytest.param("dask", marks=[needs.dask, pytest.mark.anndata_dask_support]), ] ) +@filter_oldformatwarning def adata_dist(request: pytest.FixtureRequest) -> AnnData: # regular anndata except for X, which we replace on the next line a = read_zarr(input_file) @@ -75,6 +75,7 @@ def test_log1p(adata: AnnData, adata_dist: AnnData): npt.assert_allclose(result, adata.X) +@pytest.mark.filterwarnings("ignore:Use sc.pp.normalize_total instead:FutureWarning") def test_normalize_per_cell( request: pytest.FixtureRequest, adata: AnnData, adata_dist: AnnData ): From ac4c629ba1b50642618e4b632a21e5de903ce8ec Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 19 Dec 2024 19:59:59 +0100 Subject: [PATCH 48/51] Deprecate visium (#3407) --- docs/conf.py | 1 + docs/release-notes/1.5.0.md | 2 +- docs/release-notes/3407.misc.md | 7 + docs/tutorials/index.md | 22 +- docs/tutorials/spatial/basic-analysis.ipynb | 1 - docs/tutorials/spatial/index.md | 8 - .../spatial/integration-scanorama.ipynb | 1 - src/scanpy/datasets/_datasets.py | 6 +- src/scanpy/plotting/_tools/scatterplots.py | 7 +- src/scanpy/readwrite.py | 6 +- tests/test_datasets.py | 3 + tests/test_embedding_plots.py | 566 ------------------ tests/test_plotting.py | 5 +- tests/test_plotting_embedded/conftest.py | 66 ++ .../test_plotting_embedded/test_embeddings.py | 253 ++++++++ tests/test_plotting_embedded/test_spatial.py | 267 +++++++++ tests/test_read_10x.py | 1 + 17 files changed, 625 insertions(+), 597 deletions(-) create mode 100644 docs/release-notes/3407.misc.md delete mode 120000 docs/tutorials/spatial/basic-analysis.ipynb delete mode 100644 docs/tutorials/spatial/index.md delete mode 120000 docs/tutorials/spatial/integration-scanorama.ipynb delete mode 100644 tests/test_embedding_plots.py create mode 100644 tests/test_plotting_embedded/conftest.py create mode 100644 tests/test_plotting_embedded/test_embeddings.py create mode 100644 tests/test_plotting_embedded/test_spatial.py diff --git a/docs/conf.py b/docs/conf.py index 155869b360..e17aa9df0f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -143,6 +143,7 @@ scipy=("https://docs.scipy.org/doc/scipy/", None), seaborn=("https://seaborn.pydata.org/", None), session_info2=("https://session-info2.readthedocs.io/en/stable/", None), + squidpy=("https://squidpy.readthedocs.io/en/stable/", None), sklearn=("https://scikit-learn.org/stable/", None), ) diff --git a/docs/release-notes/1.5.0.md b/docs/release-notes/1.5.0.md index 922e758723..956ceb9493 100644 --- a/docs/release-notes/1.5.0.md +++ b/docs/release-notes/1.5.0.md @@ -5,7 +5,7 @@ The `1.5.0` release adds a lot of new functionality, much of which takes advanta #### Spatial data support -- Basic analysis {doc}`/tutorials/spatial/basic-analysis` and integration with single cell data {doc}`/tutorials/spatial/integration-scanorama` {smaller}`G Palla` +- Tutorials for basic analysis and integration with single cell data {smaller}`G Palla` - {func}`~scanpy.read_visium` read 10x Visium data {pr}`1034` {smaller}`G Palla, P Angerer, I Virshup` - {func}`~scanpy.datasets.visium_sge` load Visium data directly from 10x Genomics {pr}`1013` {smaller}`M Mirkazemi, G Palla, P Angerer` - {func}`~scanpy.pl.spatial` plot spatial data {pr}`1012` {smaller}`G Palla, P Angerer` diff --git a/docs/release-notes/3407.misc.md b/docs/release-notes/3407.misc.md new file mode 100644 index 0000000000..4670de6fd4 --- /dev/null +++ b/docs/release-notes/3407.misc.md @@ -0,0 +1,7 @@ +| Deprecate … | in favor of … | +| --- | --- | +| {func}`scanpy.read_visium` | {func}`squidpy.read.visium` | +| {func}`scanpy.datasets.visium_sge` | {func}`squidpy.datasets.visium` | +| {func}`scanpy.pl.spatial` | {func}`squidpy.pl.spatial_scatter` | + +{smaller}`P Angerer` diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index ee57056a6d..b20ee2b762 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -37,19 +37,6 @@ trajectories/index ## Spatial data -```{seealso} -For more up-to-date tutorials on working with spatial data, see: - -* [SquidPy tutorials](https://squidpy.readthedocs.io/en/stable/notebooks/tutorials/index.html) -* [SpatialData tutorials](https://spatialdata.scverse.org/en/latest/tutorials/notebooks/notebooks.html) -* [Scverse ecosystem spatial tutorials](https://scverse.org/learn/) -``` - -```{toctree} -:maxdepth: 2 - -spatial/index -``` ## Experimental @@ -64,3 +51,12 @@ experimental/index A number of older tutorials can be found at: * The [`scanpy_usage`](https://github.com/scverse/scanpy_usage) repository + +```{seealso} +Scanpy used to have tutorials for its (now deprecated) spatial data functionality.x +For up-to-date tutorials on working with spatial data, see: + +* SquidPy {doc}`squidpy:notebooks/tutorials/index` +* [SpatialData tutorials](https://spatialdata.scverse.org/en/latest/tutorials/notebooks/notebooks.html) +* [Scverse ecosystem spatial tutorials](https://scverse.org/learn/) +``` diff --git a/docs/tutorials/spatial/basic-analysis.ipynb b/docs/tutorials/spatial/basic-analysis.ipynb deleted file mode 120000 index 66d9e48121..0000000000 --- a/docs/tutorials/spatial/basic-analysis.ipynb +++ /dev/null @@ -1 +0,0 @@ -../../../notebooks/spatial/basic-analysis.ipynb \ No newline at end of file diff --git a/docs/tutorials/spatial/index.md b/docs/tutorials/spatial/index.md deleted file mode 100644 index 801b901e53..0000000000 --- a/docs/tutorials/spatial/index.md +++ /dev/null @@ -1,8 +0,0 @@ -## Spatial - -```{toctree} -:maxdepth: 1 - -basic-analysis -integration-scanorama -``` diff --git a/docs/tutorials/spatial/integration-scanorama.ipynb b/docs/tutorials/spatial/integration-scanorama.ipynb deleted file mode 120000 index 5143681577..0000000000 --- a/docs/tutorials/spatial/integration-scanorama.ipynb +++ /dev/null @@ -1 +0,0 @@ -../../../notebooks/spatial/integration-scanorama.ipynb \ No newline at end of file diff --git a/src/scanpy/datasets/_datasets.py b/src/scanpy/datasets/_datasets.py index df510b3209..8859de4d74 100644 --- a/src/scanpy/datasets/_datasets.py +++ b/src/scanpy/datasets/_datasets.py @@ -9,7 +9,7 @@ from anndata import AnnData from .. import _utils -from .._compat import old_positionals +from .._compat import deprecated, old_positionals from .._settings import settings from .._utils._doctests import doctest_internet, doctest_needs from ..readwrite import read, read_visium @@ -509,6 +509,7 @@ def _download_visium_dataset( return sample_dir +@deprecated("Use `squidpy.datasets.visium` instead.") @doctest_internet @check_datasetdir_exists def visium_sge( @@ -519,6 +520,9 @@ def visium_sge( """\ Processed Visium Spatial Gene Expression data from 10x Genomics’ database. + .. deprecated:: 1.11.0 + Use :func:`squidpy.datasets.visium` instead. + The database_ can be browsed online to find the ``sample_id`` you want. .. _database: https://support.10xgenomics.com/spatial-gene-expression/datasets diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index 4ce39f7211..e2564eb17f 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -28,6 +28,7 @@ from packaging.version import Version from ... import logging as logg +from ..._compat import deprecated from ..._settings import settings from ..._utils import ( Empty, # noqa: TCH001 @@ -919,6 +920,7 @@ def pca( return axs +@deprecated("Use `squidpy.pl.spatial_scatter` instead.") @_wraps_plot_scatter @_doc_params( adata_color_etc=doc_adata_color_etc, @@ -948,6 +950,9 @@ def spatial( """\ Scatter plot in spatial coordinates. + .. deprecated:: 1.11.0 + Use :func:`squidpy.pl.spatial_scatter` instead. + This function allows overlaying data on top of images. Use the parameter `img_key` to see the image in the background And the parameter `library_id` to select the image. @@ -994,8 +999,6 @@ def spatial( -------- :func:`scanpy.datasets.visium_sge` Example visium data. - :doc:`/tutorials/spatial/basic-analysis` - Tutorial on spatial analysis. """ # get default image params if available library_id, spatial_data = _check_spatial_data(adata.uns, library_id) diff --git a/src/scanpy/readwrite.py b/src/scanpy/readwrite.py index 07bd817ca5..3333fbc0a1 100644 --- a/src/scanpy/readwrite.py +++ b/src/scanpy/readwrite.py @@ -36,7 +36,7 @@ from matplotlib.image import imread from . import logging as logg -from ._compat import add_note, old_positionals +from ._compat import add_note, deprecated, old_positionals from ._settings import settings from ._utils import _empty @@ -366,6 +366,7 @@ def _read_v3_10x_h5(filename, *, start=None): raise Exception("File is missing one or more required datasets.") +@deprecated("Use `squidpy.read.visium` instead.") def read_visium( path: Path | str, genome: str | None = None, @@ -378,6 +379,9 @@ def read_visium( """\ Read 10x-Genomics-formatted visum dataset. + .. deprecated:: 1.11.0 + Use :func:`squidpy.read.visium` instead. + In addition to reading regular 10x output, this looks for the `spatial` folder and loads images, coordinates and scale factors. diff --git a/tests/test_datasets.py b/tests/test_datasets.py index 4bad3800d7..5e0fc1e125 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -111,6 +111,7 @@ def test_pbmc68k_reduced(): sc.datasets.pbmc68k_reduced() +@pytest.mark.filterwarnings("ignore:Use `squidpy.*` instead:FutureWarning") @pytest.mark.internet def test_visium_datasets(): """Tests that reading/ downloading works and is does not have global effects.""" @@ -121,6 +122,7 @@ def test_visium_datasets(): assert_adata_equal(hheart, hheart_again) +@pytest.mark.filterwarnings("ignore:Use `squidpy.*` instead:FutureWarning") @pytest.mark.internet def test_visium_datasets_dir_change(tmp_path: Path): """Test that changing the dataset dir doesn't break reading.""" @@ -132,6 +134,7 @@ def test_visium_datasets_dir_change(tmp_path: Path): assert_adata_equal(mbrain, mbrain_again) +@pytest.mark.filterwarnings("ignore:Use `squidpy.*` instead:FutureWarning") @pytest.mark.internet def test_visium_datasets_images(): """Test that image download works and is does not have global effects.""" diff --git a/tests/test_embedding_plots.py b/tests/test_embedding_plots.py deleted file mode 100644 index d48f44b2b6..0000000000 --- a/tests/test_embedding_plots.py +++ /dev/null @@ -1,566 +0,0 @@ -from __future__ import annotations - -from functools import partial -from pathlib import Path -from typing import TYPE_CHECKING - -import matplotlib as mpl -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import pytest -import seaborn as sns -from matplotlib.colors import Normalize -from matplotlib.testing.compare import compare_images - -import scanpy as sc -from testing.scanpy._helpers.data import pbmc3k_processed - -if TYPE_CHECKING: - from scanpy.plotting._utils import _LegendLoc - - -HERE: Path = Path(__file__).parent -ROOT = HERE / "_images" - -MISSING_VALUES_ROOT = ROOT / "embedding-missing-values" - - -def check_images(pth1, pth2, *, tol): - result = compare_images(pth1, pth2, tol=tol) - assert result is None, result - - -@pytest.fixture(scope="module") -def adata(): - """A bit cute.""" - from matplotlib.image import imread - from sklearn.cluster import DBSCAN - from sklearn.datasets import make_blobs - - empty_pixel = np.array([1.0, 1.0, 1.0, 0]).reshape(1, 1, -1) - image = imread(HERE.parent / "docs/_static/img/Scanpy_Logo_RGB.png") - x, y = np.where(np.logical_and.reduce(~np.equal(image, empty_pixel), axis=2)) - - # Just using to calculate the hex coords - hexes = plt.hexbin(x, y, gridsize=(44, 100)) - counts = hexes.get_array() - pixels = hexes.get_offsets()[counts != 0] - plt.close() - - labels = DBSCAN(eps=20, min_samples=2).fit(pixels).labels_ - order = np.argsort(labels) - adata = sc.AnnData( - make_blobs( - pd.Series(labels[order]).value_counts().values, - n_features=20, - shuffle=False, - random_state=42, - )[0], - obs={"label": pd.Categorical(labels[order].astype(str))}, - obsm={"spatial": pixels[order, ::-1]}, - uns={ - "spatial": { - "scanpy_img": { - "images": {"hires": image}, - "scalefactors": { - "tissue_hires_scalef": 1, - "spot_diameter_fullres": 10, - }, - } - } - }, - ) - sc.pp.pca(adata) - - # Adding some missing values - adata.obs["label_missing"] = adata.obs["label"].copy() - adata.obs["label_missing"][::2] = np.nan - - adata.obs["1_missing"] = adata.obs_vector("1") - adata.obs.loc[ - adata.obsm["spatial"][:, 0] < adata.obsm["spatial"][:, 0].mean(), "1_missing" - ] = np.nan - - return adata - - -@pytest.fixture -def fixture_request(request): - """Returns a Request object. - - Allows you to access names of parameterized tests from within a test. - """ - return request - - -@pytest.fixture( - params=[(0, 0, 0, 1), None], - ids=["na_color.black_tup", "na_color.default"], -) -def na_color(request): - return request.param - - -@pytest.fixture(params=[True, False], ids=["na_in_legend.True", "na_in_legend.False"]) -def na_in_legend(request): - return request.param - - -@pytest.fixture( - params=[partial(sc.pl.pca, show=False), partial(sc.pl.spatial, show=False)], - ids=["pca", "spatial"], -) -def plotfunc(request): - return request.param - - -@pytest.fixture( - params=["on data", "right margin", "lower center", None], - ids=["legend.on_data", "legend.on_right", "legend.on_bottom", "legend.off"], -) -def legend_loc(request) -> _LegendLoc | None: - return request.param - - -@pytest.fixture( - params=[lambda x: list(x.cat.categories[:3]), lambda x: []], - ids=["groups.3", "groups.all"], -) -def groupsfunc(request): - return request.param - - -@pytest.fixture( - params=[ - pytest.param( - {"vmin": None, "vmax": None, "vcenter": None, "norm": None}, - id="vbounds.default", - ), - pytest.param( - {"vmin": 0, "vmax": 5, "vcenter": None, "norm": None}, id="vbounds.numbers" - ), - pytest.param( - {"vmin": "p15", "vmax": "p90", "vcenter": None, "norm": None}, - id="vbounds.percentile", - ), - pytest.param( - {"vmin": 0, "vmax": "p99", "vcenter": 0.1, "norm": None}, - id="vbounds.vcenter", - ), - pytest.param( - {"vmin": None, "vmax": None, "vcenter": None, "norm": Normalize(0, 5)}, - id="vbounds.norm", - ), - ] -) -def vbounds(request): - return request.param - - -def test_missing_values_categorical( - *, - fixture_request: pytest.FixtureRequest, - image_comparer, - adata, - plotfunc, - na_color, - na_in_legend, - legend_loc, - groupsfunc, -): - save_and_compare_images = partial(image_comparer, MISSING_VALUES_ROOT, tol=15) - - base_name = fixture_request.node.name - - # Passing through a dict so it's easier to use default values - kwargs = {} - kwargs["legend_loc"] = legend_loc - kwargs["groups"] = groupsfunc(adata.obs["label"]) - if na_color is not None: - kwargs["na_color"] = na_color - kwargs["na_in_legend"] = na_in_legend - - plotfunc(adata, color=["label", "label_missing"], **kwargs) - - save_and_compare_images(base_name) - - -def test_missing_values_continuous( - *, - fixture_request: pytest.FixtureRequest, - image_comparer, - adata, - plotfunc, - na_color, - vbounds, -): - save_and_compare_images = partial(image_comparer, MISSING_VALUES_ROOT, tol=15) - - base_name = fixture_request.node.name - - # Passing through a dict so it's easier to use default values - kwargs = {} - kwargs.update(vbounds) - if na_color is not None: - kwargs["na_color"] = na_color - - plotfunc(adata, color=["1", "1_missing"], **kwargs) - - save_and_compare_images(base_name) - - -def test_enumerated_palettes(fixture_request, adata, tmpdir, plotfunc): - tmpdir = Path(tmpdir) - base_name = fixture_request.node.name - - categories = adata.obs["label"].cat.categories - colors_rgb = dict(zip(categories, sns.color_palette(n_colors=12))) - - dict_pth = tmpdir / f"rgbdict_{base_name}.png" - list_pth = tmpdir / f"rgblist_{base_name}.png" - - # making a copy so colors aren't saved - plotfunc(adata.copy(), color="label", palette=colors_rgb) - plt.savefig(dict_pth, dpi=40) - plt.close() - plotfunc(adata.copy(), color="label", palette=[colors_rgb[c] for c in categories]) - plt.savefig(list_pth, dpi=40) - plt.close() - - check_images(dict_pth, list_pth, tol=15) - - -def test_dimension_broadcasting(adata, tmpdir, check_same_image): - tmpdir = Path(tmpdir) - - with pytest.raises( - ValueError, - match=r"Could not broadcast together arguments with shapes: \[2, 3, 1\]", - ): - sc.pl.pca( - adata, color=["label", "1_missing"], dimensions=[(0, 1), (1, 2), (2, 3)] - ) - - dims_pth = tmpdir / "broadcast_dims.png" - color_pth = tmpdir / "broadcast_colors.png" - - sc.pl.pca(adata, color=["label", "label", "label"], dimensions=(2, 3), show=False) - plt.savefig(dims_pth, dpi=40) - plt.close() - sc.pl.pca(adata, color="label", dimensions=[(2, 3), (2, 3), (2, 3)], show=False) - plt.savefig(color_pth, dpi=40) - plt.close() - - check_same_image(dims_pth, color_pth, tol=5) - - -def test_marker_broadcasting(adata, tmpdir, check_same_image): - tmpdir = Path(tmpdir) - - with pytest.raises( - ValueError, - match=r"Could not broadcast together arguments with shapes: \[2, 1, 3\]", - ): - sc.pl.pca(adata, color=["label", "1_missing"], marker=[".", "^", "x"]) - - dims_pth = tmpdir / "broadcast_markers.png" - color_pth = tmpdir / "broadcast_colors_for_markers.png" - - sc.pl.pca(adata, color=["label", "label", "label"], marker="^", show=False) - plt.savefig(dims_pth, dpi=40) - plt.close() - sc.pl.pca(adata, color="label", marker=["^", "^", "^"], show=False) - plt.savefig(color_pth, dpi=40) - plt.close() - - check_same_image(dims_pth, color_pth, tol=5) - - -def test_dimensions_same_as_components(adata, tmpdir, check_same_image): - tmpdir = Path(tmpdir) - adata = adata.copy() - adata.obs["mean"] = np.ravel(adata.X.mean(axis=1)) - - comp_pth = tmpdir / "components_plot.png" - dims_pth = tmpdir / "dimension_plot.png" - - # TODO: Deprecate components kwarg - # with pytest.warns(FutureWarning, match=r"components .* deprecated"): - sc.pl.pca( - adata, - color=["mean", "label"], - components=["1,2", "2,3"], - show=False, - ) - plt.savefig(comp_pth, dpi=40) - plt.close() - - sc.pl.pca( - adata, - color=["mean", "mean", "label", "label"], - dimensions=[(0, 1), (1, 2), (0, 1), (1, 2)], - show=False, - ) - plt.savefig(dims_pth, dpi=40) - plt.close() - - check_same_image(dims_pth, comp_pth, tol=5) - - -def test_embedding_colorbar_location(image_comparer): - save_and_compare_images = partial(image_comparer, ROOT, tol=15) - - adata = pbmc3k_processed().raw.to_adata() - - sc.pl.pca(adata, color="LDHB", colorbar_loc=None) - - save_and_compare_images("no_colorbar") - - -# Spatial specific - - -def test_visium_circles(image_comparer): # standard visium data - save_and_compare_images = partial(image_comparer, ROOT, tol=15) - - adata = sc.read_visium(HERE / "_data" / "visium_data" / "1.0.0") - adata.obs = adata.obs.astype({"array_row": "str"}) - - sc.pl.spatial( - adata, - color="array_row", - groups=["24", "33"], - crop_coord=(100, 400, 400, 100), - alpha=0.5, - size=1.3, - show=False, - ) - - save_and_compare_images("spatial_visium") - - -def test_visium_default(image_comparer): # default values - from packaging.version import parse as parse_version - - if parse_version(mpl.__version__) < parse_version("3.7.0"): - pytest.xfail("Matplotlib 3.7.0+ required for this test") - - save_and_compare_images = partial(image_comparer, ROOT, tol=5) - - adata = sc.read_visium(HERE / "_data" / "visium_data" / "1.0.0") - adata.obs = adata.obs.astype({"array_row": "str"}) - - # Points default to transparent if an image is included - sc.pl.spatial(adata, show=False) - - save_and_compare_images("spatial_visium_default") - - -def test_visium_empty_img_key(image_comparer): # visium coordinates but image empty - save_and_compare_images = partial(image_comparer, ROOT, tol=15) - - adata = sc.read_visium(HERE / "_data" / "visium_data" / "1.0.0") - adata.obs = adata.obs.astype({"array_row": "str"}) - - sc.pl.spatial(adata, img_key=None, color="array_row", show=False) - - save_and_compare_images("spatial_visium_empty_image") - - sc.pl.embedding(adata, basis="spatial", color="array_row", show=False) - save_and_compare_images("spatial_visium_embedding") - - -def test_spatial_general(image_comparer): # general coordinates - save_and_compare_images = partial(image_comparer, ROOT, tol=15) - - adata = sc.read_visium(HERE / "_data" / "visium_data" / "1.0.0") - adata.obs = adata.obs.astype({"array_row": "str"}) - spatial_metadata = adata.uns.pop( - "spatial" - ) # spatial data don't have imgs, so remove entry from uns - # Required argument for now - spot_size = list(spatial_metadata.values())[0]["scalefactors"][ - "spot_diameter_fullres" - ] - - sc.pl.spatial(adata, show=False, spot_size=spot_size) - save_and_compare_images("spatial_general_nocol") - - # category - sc.pl.spatial(adata, show=False, spot_size=spot_size, color="array_row") - save_and_compare_images("spatial_general_cat") - - # continuous - sc.pl.spatial(adata, show=False, spot_size=spot_size, color="array_col") - save_and_compare_images("spatial_general_cont") - - -def test_spatial_external_img(image_comparer): # external image - save_and_compare_images = partial(image_comparer, ROOT, tol=15) - - adata = sc.read_visium(HERE / "_data" / "visium_data" / "1.0.0") - adata.obs = adata.obs.astype({"array_row": "str"}) - - img = adata.uns["spatial"]["custom"]["images"]["hires"] - scalef = adata.uns["spatial"]["custom"]["scalefactors"]["tissue_hires_scalef"] - sc.pl.spatial( - adata, - color="array_row", - scale_factor=scalef, - img=img, - basis="spatial", - show=False, - ) - save_and_compare_images("spatial_external_img") - - -@pytest.fixture(scope="module") -def equivalent_spatial_plotters(adata): - no_spatial = adata.copy() - del no_spatial.uns["spatial"] - - img_key = "hires" - library_id = list(adata.uns["spatial"])[0] - spatial_data = adata.uns["spatial"][library_id] - img = spatial_data["images"][img_key] - scale_factor = spatial_data["scalefactors"][f"tissue_{img_key}_scalef"] - spot_size = spatial_data["scalefactors"]["spot_diameter_fullres"] - - orig_plotter = partial(sc.pl.spatial, adata, color="1", show=False) - removed_plotter = partial( - sc.pl.spatial, - no_spatial, - color="1", - img=img, - scale_factor=scale_factor, - spot_size=spot_size, - show=False, - ) - - return (orig_plotter, removed_plotter) - - -@pytest.fixture(scope="module") -def equivalent_spatial_plotters_no_img(equivalent_spatial_plotters): - orig, removed = equivalent_spatial_plotters - return (partial(orig, img_key=None), partial(removed, img=None, scale_factor=None)) - - -@pytest.fixture( - params=[ - pytest.param({"crop_coord": (50, 200, 0, 500)}, id="crop"), - pytest.param({"size": 0.5}, id="size:.5"), - pytest.param({"size": 2}, id="size:2"), - pytest.param({"spot_size": 5}, id="spotsize"), - pytest.param({"bw": True}, id="bw"), - # Shape of the image for particular fixture, should not be hardcoded like this - pytest.param({"img": np.ones((774, 1755, 4)), "scale_factor": 1.0}, id="img"), - pytest.param( - {"na_color": (0, 0, 0, 0), "color": "1_missing"}, id="na_color.transparent" - ), - pytest.param( - {"na_color": "lightgray", "color": "1_missing"}, id="na_color.lightgray" - ), - ] -) -def spatial_kwargs(request): - return request.param - - -def test_manual_equivalency(equivalent_spatial_plotters, tmpdir, spatial_kwargs): - """ - Tests that manually passing values to sc.pl.spatial is similar to automatic extraction. - """ - orig, removed = equivalent_spatial_plotters - - TESTDIR = Path(tmpdir) - orig_pth = TESTDIR / "orig.png" - removed_pth = TESTDIR / "removed.png" - - orig(**spatial_kwargs) - plt.savefig(orig_pth, dpi=40) - plt.close() - removed(**spatial_kwargs) - plt.savefig(removed_pth, dpi=40) - plt.close() - - check_images(orig_pth, removed_pth, tol=1) - - -def test_manual_equivalency_no_img( - equivalent_spatial_plotters_no_img, tmpdir, spatial_kwargs -): - if "bw" in spatial_kwargs: - # Has no meaning when there is no image - pytest.skip() - orig, removed = equivalent_spatial_plotters_no_img - - TESTDIR = Path(tmpdir) - orig_pth = TESTDIR / "orig.png" - removed_pth = TESTDIR / "removed.png" - - orig(**spatial_kwargs) - plt.savefig(orig_pth, dpi=40) - plt.close() - removed(**spatial_kwargs) - plt.savefig(removed_pth, dpi=40) - plt.close() - - check_images(orig_pth, removed_pth, tol=1) - - -def test_white_background_vs_no_img(adata, tmpdir, spatial_kwargs): - if {"bw", "img", "img_key", "na_color"}.intersection(spatial_kwargs): - # These arguments don't make sense for this check - pytest.skip() - - white_background = np.ones_like( - adata.uns["spatial"]["scanpy_img"]["images"]["hires"] - ) - TESTDIR = Path(tmpdir) - white_pth = TESTDIR / "white_background.png" - noimg_pth = TESTDIR / "no_img.png" - - sc.pl.spatial( - adata, - color="2", - img=white_background, - scale_factor=1.0, - show=False, - **spatial_kwargs, - ) - plt.savefig(white_pth) - sc.pl.spatial(adata, color="2", img_key=None, show=False, **spatial_kwargs) - plt.savefig(noimg_pth) - - check_images(white_pth, noimg_pth, tol=1) - - -def test_spatial_na_color(adata, tmpdir): - """ - Check that na_color defaults to transparent when an image is present, light gray when not. - """ - white_background = np.ones_like( - adata.uns["spatial"]["scanpy_img"]["images"]["hires"] - ) - TESTDIR = Path(tmpdir) - lightgray_pth = TESTDIR / "lightgray.png" - transparent_pth = TESTDIR / "transparent.png" - noimg_pth = TESTDIR / "noimg.png" - whiteimg_pth = TESTDIR / "whiteimg.png" - - def plot(pth, **kwargs): - sc.pl.spatial(adata, color="1_missing", show=False, **kwargs) - plt.savefig(pth, dpi=40) - plt.close() - - plot(lightgray_pth, na_color="lightgray", img_key=None) - plot(transparent_pth, na_color=(0.0, 0.0, 0.0, 0.0), img_key=None) - plot(noimg_pth, img_key=None) - plot(whiteimg_pth, img=white_background, scale_factor=1.0) - - check_images(lightgray_pth, noimg_pth, tol=1) - check_images(transparent_pth, whiteimg_pth, tol=1) - with pytest.raises(AssertionError): - check_images(lightgray_pth, transparent_pth, tol=1) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 2f0f5f60cd..f135a68aa4 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1456,11 +1456,10 @@ def test_rankings(image_comparer): # TODO: Make more generic -def test_scatter_rep(tmpdir): +def test_scatter_rep(tmp_path): """ Test to make sure I can predict when scatter reps should be the same """ - TESTDIR = Path(tmpdir) rep_args = { "raw": {"use_raw": True}, "layer": {"layer": "layer", "use_raw": False}, @@ -1475,7 +1474,7 @@ def test_scatter_rep(tmpdir): columns=["rep", "gene", "result"], ) states["outpth"] = [ - TESTDIR / f"{state.gene}_{state.rep}_{state.result}.png" + tmp_path / f"{state.gene}_{state.rep}_{state.result}.png" for state in states.itertuples() ] pattern = np.array(list(chain.from_iterable(repeat(i, 5) for i in range(3)))) diff --git a/tests/test_plotting_embedded/conftest.py b/tests/test_plotting_embedded/conftest.py new file mode 100644 index 0000000000..d9e8ff8581 --- /dev/null +++ b/tests/test_plotting_embedded/conftest.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import pytest + +import scanpy as sc + +HERE: Path = Path(__file__).parent + + +@pytest.fixture(scope="module") +def adata(): + """A bit cute.""" + from matplotlib.image import imread + from sklearn.cluster import DBSCAN + from sklearn.datasets import make_blobs + + empty_pixel = np.array([1.0, 1.0, 1.0, 0]).reshape(1, 1, -1) + image = imread(HERE.parent.parent / "docs/_static/img/Scanpy_Logo_RGB.png") + x, y = np.where(np.logical_and.reduce(~np.equal(image, empty_pixel), axis=2)) + + # Just using to calculate the hex coords + hexes = plt.hexbin(x, y, gridsize=(44, 100)) + counts = hexes.get_array() + pixels = hexes.get_offsets()[counts != 0] + plt.close() + + labels = DBSCAN(eps=20, min_samples=2).fit(pixels).labels_ + order = np.argsort(labels) + adata = sc.AnnData( + make_blobs( + pd.Series(labels[order]).value_counts().values, + n_features=20, + shuffle=False, + random_state=42, + )[0], + obs={"label": pd.Categorical(labels[order].astype(str))}, + obsm={"spatial": pixels[order, ::-1]}, + uns={ + "spatial": { + "scanpy_img": { + "images": {"hires": image}, + "scalefactors": { + "tissue_hires_scalef": 1, + "spot_diameter_fullres": 10, + }, + } + } + }, + ) + sc.pp.pca(adata) + + # Adding some missing values + adata.obs["label_missing"] = adata.obs["label"].copy() + adata.obs.loc[::2, "label_missing"] = np.nan + + adata.obs["1_missing"] = adata.obs_vector("1") + adata.obs.loc[ + adata.obsm["spatial"][:, 0] < adata.obsm["spatial"][:, 0].mean(), "1_missing" + ] = np.nan + + return adata diff --git a/tests/test_plotting_embedded/test_embeddings.py b/tests/test_plotting_embedded/test_embeddings.py new file mode 100644 index 0000000000..c5dc8d3e53 --- /dev/null +++ b/tests/test_plotting_embedded/test_embeddings.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from functools import partial, wraps +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt +import numpy as np +import pytest +import seaborn as sns +from matplotlib.colors import Normalize +from matplotlib.testing.compare import compare_images + +import scanpy as sc +from testing.scanpy._helpers.data import pbmc3k_processed + +if TYPE_CHECKING: + from scanpy.plotting._utils import _LegendLoc + + +HERE: Path = Path(__file__).parent +ROOT = HERE.parent / "_images" + +MISSING_VALUES_ROOT = ROOT / "embedding-missing-values" + + +def check_images(pth1: Path, pth2: Path, *, tol: int) -> None: + result = compare_images(str(pth1), str(pth2), tol=tol) + assert result is None, result + + +@pytest.fixture( + params=[(0, 0, 0, 1), None], + ids=["na_color.black_tup", "na_color.default"], +) +def na_color(request): + return request.param + + +@pytest.fixture(params=[True, False], ids=["na_in_legend.True", "na_in_legend.False"]) +def na_in_legend(request): + return request.param + + +@pytest.fixture(params=[sc.pl.pca, sc.pl.spatial]) +def plotfunc(request): + if request.param is sc.pl.spatial: + + @wraps(request.param) + def f(adata, **kwargs): + with pytest.warns(FutureWarning, match=r"Use `squidpy.*` instead"): + return sc.pl.spatial(adata, **kwargs) + + else: + f = request.param + return partial(f, show=False) + + +@pytest.fixture( + params=["on data", "right margin", "lower center", None], + ids=["legend.on_data", "legend.on_right", "legend.on_bottom", "legend.off"], +) +def legend_loc(request) -> _LegendLoc | None: + return request.param + + +@pytest.fixture( + params=[lambda x: list(x.cat.categories[:3]), lambda x: []], + ids=["groups.3", "groups.all"], +) +def groupsfunc(request): + return request.param + + +@pytest.fixture( + params=[ + pytest.param( + {"vmin": None, "vmax": None, "vcenter": None, "norm": None}, + id="vbounds.default", + ), + pytest.param( + {"vmin": 0, "vmax": 5, "vcenter": None, "norm": None}, id="vbounds.numbers" + ), + pytest.param( + {"vmin": "p15", "vmax": "p90", "vcenter": None, "norm": None}, + id="vbounds.percentile", + ), + pytest.param( + {"vmin": 0, "vmax": "p99", "vcenter": 0.1, "norm": None}, + id="vbounds.vcenter", + ), + pytest.param( + {"vmin": None, "vmax": None, "vcenter": None, "norm": Normalize(0, 5)}, + id="vbounds.norm", + ), + ] +) +def vbounds(request): + return request.param + + +def test_missing_values_categorical( + *, + request: pytest.FixtureRequest, + image_comparer, + adata, + plotfunc, + na_color, + na_in_legend, + legend_loc, + groupsfunc, +): + save_and_compare_images = partial(image_comparer, MISSING_VALUES_ROOT, tol=15) + + base_name = request.node.name + + # Passing through a dict so it's easier to use default values + kwargs = {} + kwargs["legend_loc"] = legend_loc + kwargs["groups"] = groupsfunc(adata.obs["label"]) + if na_color is not None: + kwargs["na_color"] = na_color + kwargs["na_in_legend"] = na_in_legend + + plotfunc(adata, color=["label", "label_missing"], **kwargs) + + save_and_compare_images(base_name) + + +def test_missing_values_continuous( + *, + request: pytest.FixtureRequest, + image_comparer, + adata, + plotfunc, + na_color, + vbounds, +): + save_and_compare_images = partial(image_comparer, MISSING_VALUES_ROOT, tol=15) + + base_name = request.node.name + + # Passing through a dict so it's easier to use default values + kwargs = {} + kwargs.update(vbounds) + if na_color is not None: + kwargs["na_color"] = na_color + + plotfunc(adata, color=["1", "1_missing"], **kwargs) + + save_and_compare_images(base_name) + + +def test_enumerated_palettes(request, adata, tmp_path, plotfunc): + base_name = request.node.name + + categories = adata.obs["label"].cat.categories + colors_rgb = dict(zip(categories, sns.color_palette(n_colors=12))) + + dict_pth = tmp_path / f"rgbdict_{base_name}.png" + list_pth = tmp_path / f"rgblist_{base_name}.png" + + # making a copy so colors aren't saved + plotfunc(adata.copy(), color="label", palette=colors_rgb) + plt.savefig(dict_pth, dpi=40) + plt.close() + plotfunc(adata.copy(), color="label", palette=[colors_rgb[c] for c in categories]) + plt.savefig(list_pth, dpi=40) + plt.close() + + check_images(dict_pth, list_pth, tol=15) + + +def test_dimension_broadcasting(adata, tmp_path, check_same_image): + with pytest.raises( + ValueError, + match=r"Could not broadcast together arguments with shapes: \[2, 3, 1\]", + ): + sc.pl.pca( + adata, color=["label", "1_missing"], dimensions=[(0, 1), (1, 2), (2, 3)] + ) + + dims_pth = tmp_path / "broadcast_dims.png" + color_pth = tmp_path / "broadcast_colors.png" + + sc.pl.pca(adata, color=["label", "label", "label"], dimensions=(2, 3), show=False) + plt.savefig(dims_pth, dpi=40) + plt.close() + sc.pl.pca(adata, color="label", dimensions=[(2, 3), (2, 3), (2, 3)], show=False) + plt.savefig(color_pth, dpi=40) + plt.close() + + check_same_image(dims_pth, color_pth, tol=5) + + +def test_marker_broadcasting(adata, tmp_path, check_same_image): + with pytest.raises( + ValueError, + match=r"Could not broadcast together arguments with shapes: \[2, 1, 3\]", + ): + sc.pl.pca(adata, color=["label", "1_missing"], marker=[".", "^", "x"]) + + dims_pth = tmp_path / "broadcast_markers.png" + color_pth = tmp_path / "broadcast_colors_for_markers.png" + + sc.pl.pca(adata, color=["label", "label", "label"], marker="^", show=False) + plt.savefig(dims_pth, dpi=40) + plt.close() + sc.pl.pca(adata, color="label", marker=["^", "^", "^"], show=False) + plt.savefig(color_pth, dpi=40) + plt.close() + + check_same_image(dims_pth, color_pth, tol=5) + + +def test_dimensions_same_as_components(adata, tmp_path, check_same_image): + adata = adata.copy() + adata.obs["mean"] = np.ravel(adata.X.mean(axis=1)) + + comp_pth = tmp_path / "components_plot.png" + dims_pth = tmp_path / "dimension_plot.png" + + # TODO: Deprecate components kwarg + # with pytest.warns(FutureWarning, match=r"components .* deprecated"): + sc.pl.pca( + adata, + color=["mean", "label"], + components=["1,2", "2,3"], + show=False, + ) + plt.savefig(comp_pth, dpi=40) + plt.close() + + sc.pl.pca( + adata, + color=["mean", "mean", "label", "label"], + dimensions=[(0, 1), (1, 2), (0, 1), (1, 2)], + show=False, + ) + plt.savefig(dims_pth, dpi=40) + plt.close() + + check_same_image(dims_pth, comp_pth, tol=5) + + +def test_embedding_colorbar_location(image_comparer): + save_and_compare_images = partial(image_comparer, ROOT, tol=15) + + adata = pbmc3k_processed().raw.to_adata() + + sc.pl.pca(adata, color="LDHB", colorbar_loc=None) + + save_and_compare_images("no_colorbar") diff --git a/tests/test_plotting_embedded/test_spatial.py b/tests/test_plotting_embedded/test_spatial.py new file mode 100644 index 0000000000..873db68794 --- /dev/null +++ b/tests/test_plotting_embedded/test_spatial.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +from functools import partial +from pathlib import Path + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +import pytest +from matplotlib.testing.compare import compare_images + +import scanpy as sc + +HERE: Path = Path(__file__).parent +ROOT = HERE.parent / "_images" +DATA_DIR = HERE.parent / "_data" + + +pytestmark = [ + pytest.mark.filterwarnings("ignore:Use `squidpy.*` instead:FutureWarning") +] + + +def check_images(pth1: Path, pth2: Path, *, tol: int) -> None: + result = compare_images(str(pth1), str(pth2), tol=tol) + assert result is None, result + + +def test_visium_circles(image_comparer): # standard visium data + save_and_compare_images = partial(image_comparer, ROOT, tol=15) + + adata = sc.read_visium(DATA_DIR / "visium_data" / "1.0.0") + adata.obs = adata.obs.astype({"array_row": "str"}) + + sc.pl.spatial( + adata, + color="array_row", + groups=["24", "33"], + crop_coord=(100, 400, 400, 100), + alpha=0.5, + size=1.3, + show=False, + ) + + save_and_compare_images("spatial_visium") + + +def test_visium_default(image_comparer): # default values + from packaging.version import parse as parse_version + + if parse_version(mpl.__version__) < parse_version("3.7.0"): + pytest.xfail("Matplotlib 3.7.0+ required for this test") + + save_and_compare_images = partial(image_comparer, ROOT, tol=5) + + adata = sc.read_visium(DATA_DIR / "visium_data" / "1.0.0") + adata.obs = adata.obs.astype({"array_row": "str"}) + + # Points default to transparent if an image is included + sc.pl.spatial(adata, show=False) + + save_and_compare_images("spatial_visium_default") + + +def test_visium_empty_img_key(image_comparer): # visium coordinates but image empty + save_and_compare_images = partial(image_comparer, ROOT, tol=15) + + adata = sc.read_visium(DATA_DIR / "visium_data" / "1.0.0") + adata.obs = adata.obs.astype({"array_row": "str"}) + + sc.pl.spatial(adata, img_key=None, color="array_row", show=False) + + save_and_compare_images("spatial_visium_empty_image") + + sc.pl.embedding(adata, basis="spatial", color="array_row", show=False) + save_and_compare_images("spatial_visium_embedding") + + +def test_spatial_general(image_comparer): # general coordinates + save_and_compare_images = partial(image_comparer, ROOT, tol=15) + + adata = sc.read_visium(DATA_DIR / "visium_data" / "1.0.0") + adata.obs = adata.obs.astype({"array_row": "str"}) + spatial_metadata = adata.uns.pop( + "spatial" + ) # spatial data don't have imgs, so remove entry from uns + # Required argument for now + spot_size = list(spatial_metadata.values())[0]["scalefactors"][ + "spot_diameter_fullres" + ] + + sc.pl.spatial(adata, show=False, spot_size=spot_size) + save_and_compare_images("spatial_general_nocol") + + # category + sc.pl.spatial(adata, show=False, spot_size=spot_size, color="array_row") + save_and_compare_images("spatial_general_cat") + + # continuous + sc.pl.spatial(adata, show=False, spot_size=spot_size, color="array_col") + save_and_compare_images("spatial_general_cont") + + +def test_spatial_external_img(image_comparer): # external image + save_and_compare_images = partial(image_comparer, ROOT, tol=15) + + adata = sc.read_visium(DATA_DIR / "visium_data" / "1.0.0") + adata.obs = adata.obs.astype({"array_row": "str"}) + + img = adata.uns["spatial"]["custom"]["images"]["hires"] + scalef = adata.uns["spatial"]["custom"]["scalefactors"]["tissue_hires_scalef"] + sc.pl.spatial( + adata, + color="array_row", + scale_factor=scalef, + img=img, + basis="spatial", + show=False, + ) + save_and_compare_images("spatial_external_img") + + +@pytest.fixture(scope="module") +def equivalent_spatial_plotters(adata): + no_spatial = adata.copy() + del no_spatial.uns["spatial"] + + img_key = "hires" + library_id = list(adata.uns["spatial"])[0] + spatial_data = adata.uns["spatial"][library_id] + img = spatial_data["images"][img_key] + scale_factor = spatial_data["scalefactors"][f"tissue_{img_key}_scalef"] + spot_size = spatial_data["scalefactors"]["spot_diameter_fullres"] + + orig_plotter = partial(sc.pl.spatial, adata, color="1", show=False) + removed_plotter = partial( + sc.pl.spatial, + no_spatial, + color="1", + img=img, + scale_factor=scale_factor, + spot_size=spot_size, + show=False, + ) + + return (orig_plotter, removed_plotter) + + +@pytest.fixture(scope="module") +def equivalent_spatial_plotters_no_img(equivalent_spatial_plotters): + orig, removed = equivalent_spatial_plotters + return (partial(orig, img_key=None), partial(removed, img=None, scale_factor=None)) + + +@pytest.fixture( + params=[ + pytest.param({"crop_coord": (50, 200, 0, 500)}, id="crop"), + pytest.param({"size": 0.5}, id="size:.5"), + pytest.param({"size": 2}, id="size:2"), + pytest.param({"spot_size": 5}, id="spotsize"), + pytest.param({"bw": True}, id="bw"), + # Shape of the image for particular fixture, should not be hardcoded like this + pytest.param({"img": np.ones((774, 1755, 4)), "scale_factor": 1.0}, id="img"), + pytest.param( + {"na_color": (0, 0, 0, 0), "color": "1_missing"}, id="na_color.transparent" + ), + pytest.param( + {"na_color": "lightgray", "color": "1_missing"}, id="na_color.lightgray" + ), + ] +) +def spatial_kwargs(request): + return request.param + + +def test_manual_equivalency(equivalent_spatial_plotters, tmp_path, spatial_kwargs): + """ + Tests that manually passing values to sc.pl.spatial is similar to automatic extraction. + """ + orig, removed = equivalent_spatial_plotters + + orig_pth = tmp_path / "orig.png" + removed_pth = tmp_path / "removed.png" + + orig(**spatial_kwargs) + plt.savefig(orig_pth, dpi=40) + plt.close() + removed(**spatial_kwargs) + plt.savefig(removed_pth, dpi=40) + plt.close() + + check_images(orig_pth, removed_pth, tol=1) + + +def test_manual_equivalency_no_img( + equivalent_spatial_plotters_no_img, tmp_path, spatial_kwargs +): + if "bw" in spatial_kwargs: + # Has no meaning when there is no image + pytest.skip() + orig, removed = equivalent_spatial_plotters_no_img + + orig_pth = tmp_path / "orig.png" + removed_pth = tmp_path / "removed.png" + + orig(**spatial_kwargs) + plt.savefig(orig_pth, dpi=40) + plt.close() + removed(**spatial_kwargs) + plt.savefig(removed_pth, dpi=40) + plt.close() + + check_images(orig_pth, removed_pth, tol=1) + + +def test_white_background_vs_no_img(adata, tmp_path, spatial_kwargs): + if {"bw", "img", "img_key", "na_color"}.intersection(spatial_kwargs): + # These arguments don't make sense for this check + pytest.skip() + + white_background = np.ones_like( + adata.uns["spatial"]["scanpy_img"]["images"]["hires"] + ) + white_pth = tmp_path / "white_background.png" + noimg_pth = tmp_path / "no_img.png" + + sc.pl.spatial( + adata, + color="2", + img=white_background, + scale_factor=1.0, + show=False, + **spatial_kwargs, + ) + plt.savefig(white_pth) + sc.pl.spatial(adata, color="2", img_key=None, show=False, **spatial_kwargs) + plt.savefig(noimg_pth) + + check_images(white_pth, noimg_pth, tol=1) + + +def test_spatial_na_color(adata, tmp_path): + """ + Check that na_color defaults to transparent when an image is present, light gray when not. + """ + white_background = np.ones_like( + adata.uns["spatial"]["scanpy_img"]["images"]["hires"] + ) + lightgray_pth = tmp_path / "lightgray.png" + transparent_pth = tmp_path / "transparent.png" + noimg_pth = tmp_path / "noimg.png" + whiteimg_pth = tmp_path / "whiteimg.png" + + def plot(pth, **kwargs): + sc.pl.spatial(adata, color="1_missing", show=False, **kwargs) + plt.savefig(pth, dpi=40) + plt.close() + + plot(lightgray_pth, na_color="lightgray", img_key=None) + plot(transparent_pth, na_color=(0.0, 0.0, 0.0, 0.0), img_key=None) + plot(noimg_pth, img_key=None) + plot(whiteimg_pth, img=white_background, scale_factor=1.0) + + check_images(lightgray_pth, noimg_pth, tol=1) + check_images(transparent_pth, whiteimg_pth, tol=1) + with pytest.raises(AssertionError): + check_images(lightgray_pth, transparent_pth, tol=1) diff --git a/tests/test_read_10x.py b/tests/test_read_10x.py index 7b31f6bddf..301a156bec 100644 --- a/tests/test_read_10x.py +++ b/tests/test_read_10x.py @@ -143,6 +143,7 @@ def visium_pth(request, tmp_path) -> Path: pytest.fail("add branch for new visium version") +@pytest.mark.filterwarnings("ignore:Use `squidpy.*` instead:FutureWarning") def test_read_visium_counts(visium_pth): """Test checking that read_visium reads the right genome""" spec_genome_v3 = sc.read_visium(visium_pth, genome="GRCh38") From 397d7036ed4fa8358ac552f7d8dc7b3c5ea5e93b Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 20 Dec 2024 14:19:20 +0100 Subject: [PATCH 49/51] Add sample probabilities (#3410) --- docs/release-notes/3410.feature.md | 1 + src/scanpy/get/_aggregated.py | 3 +- src/scanpy/get/get.py | 49 +++++++++++++----- src/scanpy/plotting/_tools/scatterplots.py | 3 +- src/scanpy/preprocessing/_scale.py | 2 +- src/scanpy/preprocessing/_simple.py | 16 +++++- src/scanpy/tools/_rank_genes_groups.py | 3 +- tests/test_preprocessing.py | 59 +++++++++++++++++----- 8 files changed, 102 insertions(+), 34 deletions(-) create mode 100644 docs/release-notes/3410.feature.md diff --git a/docs/release-notes/3410.feature.md b/docs/release-notes/3410.feature.md new file mode 100644 index 0000000000..d95ad201ba --- /dev/null +++ b/docs/release-notes/3410.feature.md @@ -0,0 +1 @@ +Add sampling probabilities/mask parameter `p` to {func}`~scanpy.pp.sample` {smaller}`P Angerer` diff --git a/src/scanpy/get/_aggregated.py b/src/scanpy/get/_aggregated.py index 13ca54b5c4..53a18bb47c 100644 --- a/src/scanpy/get/_aggregated.py +++ b/src/scanpy/get/_aggregated.py @@ -263,8 +263,7 @@ def aggregate( if axis is None: axis = 1 if varm else 0 axis, axis_name = _resolve_axis(axis) - if mask is not None: - mask = _check_mask(adata, mask, axis_name) + mask = _check_mask(adata, mask, axis_name) data = adata.X if sum(p is not None for p in [varm, obsm, layer]) > 1: raise TypeError("Please only provide one (or none) of varm, obsm, or layer") diff --git a/src/scanpy/get/get.py b/src/scanpy/get/get.py index f3172ed45e..c36ddde8f8 100644 --- a/src/scanpy/get/get.py +++ b/src/scanpy/get/get.py @@ -2,11 +2,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar import numpy as np import pandas as pd from anndata import AnnData +from numpy.typing import NDArray from packaging.version import Version from scipy.sparse import spmatrix @@ -16,7 +17,11 @@ from anndata._core.sparse_dataset import BaseCompressedSparseDataset from anndata._core.views import ArrayView - from numpy.typing import NDArray + from scipy.sparse import csc_matrix, csr_matrix + + from .._compat import DaskArray + + CSMatrix = csr_matrix | csc_matrix # -------------------------------------------------------------------------------- # Plotting data helpers @@ -485,11 +490,16 @@ def _set_obs_rep( raise AssertionError(msg) +M = TypeVar("M", bound=NDArray[np.bool_] | NDArray[np.floating] | pd.Series | None) + + def _check_mask( - data: AnnData | np.ndarray, - mask: NDArray[np.bool_] | str, + data: AnnData | np.ndarray | CSMatrix | DaskArray, + mask: str | M, dim: Literal["obs", "var"], -) -> NDArray[np.bool_]: # Could also be a series, but should be one or the other + *, + allow_probabilities: bool = False, +) -> M: # Could also be a series, but should be one or the other """ Validate mask argument Params @@ -497,30 +507,45 @@ def _check_mask( data Annotated data matrix or numpy array. mask - The mask. Either an appropriatley sized boolean array, or name of a column which will be used to mask. + Mask (or probabilities if `allow_probabilities=True`). + Either an appropriatley sized array, or name of a column. dim The dimension being masked. + allow_probabilities + Whether to allow probabilities as `mask` """ + if mask is None: + return mask + desc = "mask/probabilities" if allow_probabilities else "mask" + if isinstance(mask, str): if not isinstance(data, AnnData): - msg = "Cannot refer to mask with string without providing anndata object as argument" + msg = f"Cannot refer to {desc} with string without providing anndata object as argument" raise ValueError(msg) annot: pd.DataFrame = getattr(data, dim) if mask not in annot.columns: msg = ( f"Did not find `adata.{dim}[{mask!r}]`. " - f"Either add the mask first to `adata.{dim}`" - "or consider using the mask argument with a boolean array." + f"Either add the {desc} first to `adata.{dim}`" + f"or consider using the {desc} argument with an array." ) raise ValueError(msg) mask_array = annot[mask].to_numpy() else: if len(mask) != data.shape[0 if dim == "obs" else 1]: - raise ValueError("The shape of the mask do not match the data.") + msg = f"The shape of the {desc} do not match the data." + raise ValueError(msg) mask_array = mask - if not pd.api.types.is_bool_dtype(mask_array.dtype): - raise ValueError("Mask array must be boolean.") + is_bool = pd.api.types.is_bool_dtype(mask_array.dtype) + if not allow_probabilities and not is_bool: + msg = "Mask array must be boolean." + raise ValueError(msg) + elif allow_probabilities and not ( + is_bool or pd.api.types.is_float_dtype(mask_array.dtype) + ): + msg = f"{desc} array must be boolean or floating point." + raise ValueError(msg) return mask_array diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index e2564eb17f..b54897678f 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -150,8 +150,7 @@ def embedding( # Checking the mask format and if used together with groups if groups is not None and mask_obs is not None: raise ValueError("Groups and mask arguments are incompatible.") - if mask_obs is not None: - mask_obs = _check_mask(adata, mask_obs, "obs") + mask_obs = _check_mask(adata, mask_obs, "obs") # Figure out if we're using raw if use_raw is None: diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index d7123d5f65..bac08f246b 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -164,8 +164,8 @@ def scale_array( ): if copy: X = X.copy() + mask_obs = _check_mask(X, mask_obs, "obs") if mask_obs is not None: - mask_obs = _check_mask(X, mask_obs, "obs") scale_rv = scale_array( X[mask_obs, :], zero_center=zero_center, diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 29c267c3f4..821615676a 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -30,7 +30,7 @@ sanitize_anndata, view_to_actual, ) -from ..get import _get_obs_rep, _set_obs_rep +from ..get import _check_mask, _get_obs_rep, _set_obs_rep from ._distributed import materialize_as_ndarray from ._utils import _to_dense @@ -838,6 +838,7 @@ def sample( copy: Literal[False] = False, replace: bool = False, axis: Literal["obs", 0, "var", 1] = "obs", + p: str | NDArray[np.bool_] | NDArray[np.floating] | None = None, ) -> None: ... @overload def sample( @@ -849,6 +850,7 @@ def sample( copy: Literal[True], replace: bool = False, axis: Literal["obs", 0, "var", 1] = "obs", + p: str | NDArray[np.bool_] | NDArray[np.floating] | None = None, ) -> AnnData: ... @overload def sample( @@ -860,6 +862,7 @@ def sample( copy: bool = False, replace: bool = False, axis: Literal["obs", 0, "var", 1] = "obs", + p: str | NDArray[np.bool_] | NDArray[np.floating] | None = None, ) -> tuple[A, NDArray[np.int64]]: ... def sample( data: AnnData | np.ndarray | CSMatrix | DaskArray, @@ -870,6 +873,7 @@ def sample( copy: bool = False, replace: bool = False, axis: Literal["obs", 0, "var", 1] = "obs", + p: str | NDArray[np.bool_] | NDArray[np.floating] | None = None, ) -> AnnData | None | tuple[np.ndarray | CSMatrix | DaskArray, NDArray[np.int64]]: """\ Sample observations or variables with or without replacement. @@ -881,6 +885,7 @@ def sample( Rows correspond to cells and columns to genes. fraction Sample to this `fraction` of the number of observations or variables. + (All of them, even if there are `0`s/`False`s in `p`.) This can be larger than 1.0, if `replace=True`. See `axis` and `replace`. n @@ -894,6 +899,10 @@ def sample( If True, samples are drawn with replacement. axis Sample `obs`\\ ervations (axis 0) or `var`\\ iables (axis 1). + p + Drawing probabilities (floats) or mask (bools). + Either an `axis`-sized array, or the name of a column. + If `p` is an array of probabilities, it must sum to 1. Returns ------- @@ -910,6 +919,9 @@ def sample( msg = "Inplace sampling (`copy=False`) is not implemented for backed objects." raise NotImplementedError(msg) axis, axis_name = _resolve_axis(axis) + p = _check_mask(data, p, dim=axis_name, allow_probabilities=True) + if p is not None and p.dtype == bool: + p = p.astype(np.float64) / p.sum() old_n = data.shape[axis] match (fraction, n): case (None, None): @@ -933,7 +945,7 @@ def sample( # actually do subsampling rng = np.random.default_rng(rng) - indices = rng.choice(old_n, size=n, replace=replace) + indices = rng.choice(old_n, size=n, replace=replace, p=p) # overload 1: inplace AnnData subset if not copy and isinstance(data, AnnData): diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index aa4428dad1..2c214fcfdd 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -594,8 +594,7 @@ def rank_genes_groups( >>> # to visualize the results >>> sc.pl.rank_genes_groups(adata) """ - if mask_var is not None: - mask_var = _check_mask(adata, mask_var, "var") + mask_var = _check_mask(adata, mask_var, "var") if use_raw is None: use_raw = adata.raw is not None diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 36283e7ed0..6282c5ccf4 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -29,6 +29,8 @@ from collections.abc import Callable from typing import Any, Literal + from numpy.typing import NDArray + CSMatrix = sp.csc_matrix | sp.csr_matrix @@ -144,31 +146,55 @@ def test_normalize_per_cell(): assert adata.X.sum(axis=1).tolist() == adata_sparse.X.sum(axis=1).A1.tolist() +def _random_probs(n: int, frac_zero: float) -> NDArray[np.float64]: + """ + Generate a random probability distribution of `n` values between 0 and 1. + """ + probs = np.random.randint(0, 10000, n).astype(np.float64) + probs[probs < np.quantile(probs, frac_zero)] = 0 + probs /= probs.sum() + np.testing.assert_almost_equal(probs.sum(), 1) + return probs + + @pytest.mark.parametrize("array_type", ARRAY_TYPES) @pytest.mark.parametrize("which", ["copy", "inplace", "array"]) @pytest.mark.parametrize( - ("axis", "fraction", "n", "replace", "expected"), + ("axis", "f_or_n", "replace"), + [ + pytest.param(0, 40, False, id="obs-40-no_replace"), + pytest.param(0, 0.1, False, id="obs-0.1-no_replace"), + pytest.param(0, 201, True, id="obs-201-replace"), + pytest.param(0, 1, True, id="obs-1-replace"), + pytest.param(1, 10, False, id="var-10-no_replace"), + pytest.param(1, 11, True, id="var-11-replace"), + pytest.param(1, 2.0, True, id="var-2.0-replace"), + ], +) +@pytest.mark.parametrize( + "ps", [ - pytest.param(0, None, 40, False, 40, id="obs-40-no_replace"), - pytest.param(0, 0.1, None, False, 20, id="obs-0.1-no_replace"), - pytest.param(0, None, 201, True, 201, id="obs-201-replace"), - pytest.param(0, None, 1, True, 1, id="obs-1-replace"), - pytest.param(1, None, 10, False, 10, id="var-10-no_replace"), - pytest.param(1, None, 11, True, 11, id="var-11-replace"), - pytest.param(1, 2.0, None, True, 20, id="var-2.0-replace"), + dict(obs=None, var=None), + dict(obs=np.tile([True, False], 100), var=np.tile([True, False], 5)), + dict(obs=_random_probs(200, 0.3), var=_random_probs(10, 0.7)), ], + ids=["all", "mask", "p"], ) def test_sample( *, + request: pytest.FixtureRequest, array_type: Callable[[np.ndarray], np.ndarray | CSMatrix], which: Literal["copy", "inplace", "array"], axis: Literal[0, 1], - fraction: float | None, - n: int | None, + f_or_n: float | int, # noqa: PYI041 replace: bool, - expected: int, + ps: dict[Literal["obs", "var"], NDArray[np.bool_] | None], ): adata = AnnData(array_type(np.ones((200, 10)))) + p = ps["obs" if axis == 0 else "var"] + expected = int(adata.shape[axis] * f_or_n) if isinstance(f_or_n, float) else f_or_n + if p is not None and not replace and expected > (n_possible := (p != 0).sum()): + request.applymarker(pytest.xfail(f"Can’t draw {expected} out of {n_possible}")) # ignoring this warning declaratively is a pain so do it here if find_spec("dask"): @@ -182,12 +208,13 @@ def test_sample( ) rv = sc.pp.sample( adata.X if which == "array" else adata, - fraction, - n=n, + f_or_n if isinstance(f_or_n, float) else None, + n=f_or_n if isinstance(f_or_n, int) else None, replace=replace, axis=axis, # `copy` only effects AnnData inputs copy=dict(copy=True, inplace=False, array=False)[which], + p=p, ) match which: @@ -232,6 +259,12 @@ def test_sample( r"`fraction=-0\.3` needs to be nonnegative", id="frac<0", ), + pytest.param( + dict(n=3, p=np.ones(200, dtype=np.int32)), + ValueError, + r"mask/probabilities array must be boolean or floating point", + id="type(p)", + ), ], ) def test_sample_error(args: dict[str, Any], exc: type[Exception], pattern: str): From 465806b30ed908d9708b8faec866a15e00b923c4 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 20 Dec 2024 14:45:40 +0100 Subject: [PATCH 50/51] (chore): generate 1.11.0 release notes (#3412) --- ci/scripts/towncrier_automation.py | 6 +++- docs/release-notes/1.11.0.md | 38 ++++++++++++++++++++++++++ docs/release-notes/2921.feature.md | 1 - docs/release-notes/3155.feature.md | 1 - docs/release-notes/3180.feature.md | 1 - docs/release-notes/3184.feature.md | 1 - docs/release-notes/3263.feature.md | 1 - docs/release-notes/3267.feature.md | 1 - docs/release-notes/3284.performance.md | 1 - docs/release-notes/3296.feature.md | 1 - docs/release-notes/3307.feature.md | 1 - docs/release-notes/3324.feature.md | 1 - docs/release-notes/3335.feature.md | 1 - docs/release-notes/3362.doc.md | 1 - docs/release-notes/3380.bugfix.md | 1 - docs/release-notes/3384.feature.md | 1 - docs/release-notes/3393.bugfix.md | 1 - docs/release-notes/3407.misc.md | 7 ----- docs/release-notes/3410.feature.md | 1 - docs/release-notes/943.feature.md | 1 - 20 files changed, 43 insertions(+), 25 deletions(-) create mode 100644 docs/release-notes/1.11.0.md delete mode 100644 docs/release-notes/2921.feature.md delete mode 100644 docs/release-notes/3155.feature.md delete mode 100644 docs/release-notes/3180.feature.md delete mode 100644 docs/release-notes/3184.feature.md delete mode 100644 docs/release-notes/3263.feature.md delete mode 100644 docs/release-notes/3267.feature.md delete mode 100644 docs/release-notes/3284.performance.md delete mode 100644 docs/release-notes/3296.feature.md delete mode 100644 docs/release-notes/3307.feature.md delete mode 100644 docs/release-notes/3324.feature.md delete mode 100644 docs/release-notes/3335.feature.md delete mode 100644 docs/release-notes/3362.doc.md delete mode 100644 docs/release-notes/3380.bugfix.md delete mode 100644 docs/release-notes/3384.feature.md delete mode 100644 docs/release-notes/3393.bugfix.md delete mode 100644 docs/release-notes/3407.misc.md delete mode 100644 docs/release-notes/3410.feature.md delete mode 100644 docs/release-notes/943.feature.md diff --git a/ci/scripts/towncrier_automation.py b/ci/scripts/towncrier_automation.py index c532883036..10a8b0c9dc 100755 --- a/ci/scripts/towncrier_automation.py +++ b/ci/scripts/towncrier_automation.py @@ -92,7 +92,11 @@ def main(argv: Sequence[str] | None = None) -> None: f"--base={base_branch}", f"--title={pr_title}", f"--body={pr_description}", - *(["--label=no milestone"] if base_branch == "main" else []), + *( + ["--label=no milestone", "--label=Development Process 🚀"] + if base_branch == "main" + else [] + ), *(["--dry-run"] if args.dry_run else []), ], check=True, diff --git a/docs/release-notes/1.11.0.md b/docs/release-notes/1.11.0.md new file mode 100644 index 0000000000..8c51fe8a37 --- /dev/null +++ b/docs/release-notes/1.11.0.md @@ -0,0 +1,38 @@ +(v1.11.0)= +### 1.11.0 {small}`2024-12-20` + +### Features + +- {func}`~scanpy.pp.sample` supports both upsampling and downsampling of observations and variables. {func}`~scanpy.pp.subsample` is now deprecated. {smaller}`G Eraslan & P Angerer` ({pr}`943`) +- Add `layer` argument to {func}`scanpy.tl.score_genes` and {func}`scanpy.tl.score_genes_cell_cycle` {smaller}`L Zappia` ({pr}`2921`) +- Prevent `raw` conflict with `layer` in {func}`~scanpy.tl.score_genes` {smaller}`S Dicks` ({pr}`3155`) +- Add support for `median` as an aggregation function to the `Aggregation` class in `scanpy.get._aggregated.py`. This allows for median-based aggregation of data (e.g., pseudobulk), complementing existing methods like mean- and sum-based aggregation {smaller}`M Dehkordi (Farhad)` ({pr}`3180`) +- Add `key_added` argument to {func}`~scanpy.pp.pca`, {func}`~scanpy.tl.tsne` and {func}`~scanpy.tl.umap` {smaller}`P Angerer` ({pr}`3184`) +- Support running {func}`scanpy.pp.pca` on sparse Dask arrays with the `'covariance_eigh'` solver {smaller}`P Angerer` ({pr}`3263`) +- Use upstreamed {class}`~sklearn.decomposition.PCA` implementation for {class}`~scipy.sparse.csr_array` and {class}`~scipy.sparse.csr_matrix` (see {ref}`sklearn:changes_1_4`) {smaller}`P Angerer` ({pr}`3267`) +- Add explicit support to {func}`scanpy.pp.pca` for `svd_solver='covariance_eigh'` {smaller}`P Angerer` ({pr}`3296`) +- Add support {class}`dask.array.Array` to {func}`scanpy.pp.calculate_qc_metrics` {smaller}`I Gold` ({pr}`3307`) +- Support `layer` parameter in {func}`scanpy.pl.highest_expr_genes` {smaller}`P Angerer` ({pr}`3324`) +- Run numba functions single-threaded when called from inside of a ThreadPool {smaller}`P Angerer` ({pr}`3335`) +- Switch {func}`~scanpy.logging.print_header` and {func}`~scanpy.logging.print_versions` to {mod}`session_info2` {smaller}`P Angerer` ({pr}`3384`) +- Add sampling probabilities/mask parameter `p` to {func}`~scanpy.pp.sample` {smaller}`P Angerer` ({pr}`3410`) + +### Performance + +- Speed up {func}`~scanpy.pp.regress_out` {smaller}`P Ashish, P Angerer & S Dicks` ({pr}`3284`) + +### Documentation + +- Improve {func}`~scanpy.external.pp.harmony_integrate` docs {smaller}`D Kühl` ({pr}`3362`) +- Raise {exc}`FutureWarning` when calling deprecated {mod}`scanpy.pp` functions {smaller}`P Angerer` ({pr}`3380`) +- | Deprecate … | in favor of … | + | --- | --- | + | {func}`scanpy.read_visium` | {func}`squidpy.read.visium` | + | {func}`scanpy.datasets.visium_sge` | {func}`squidpy.datasets.visium` | + | {func}`scanpy.pl.spatial` | {func}`squidpy.pl.spatial_scatter` | + + {smaller}`P Angerer` ({pr}`3407`) + +### Bug fixes + +- Upper-bound {mod}`sklearn` `<1.6.0` due to {issue}`dask/dask-ml#1002` {smaller}`Ilan Gold` ({pr}`3393`) diff --git a/docs/release-notes/2921.feature.md b/docs/release-notes/2921.feature.md deleted file mode 100644 index e3c964abb2..0000000000 --- a/docs/release-notes/2921.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add `layer` argument to {func}`scanpy.tl.score_genes` and {func}`scanpy.tl.score_genes_cell_cycle` {smaller}`L Zappia` diff --git a/docs/release-notes/3155.feature.md b/docs/release-notes/3155.feature.md deleted file mode 100644 index 770c504348..0000000000 --- a/docs/release-notes/3155.feature.md +++ /dev/null @@ -1 +0,0 @@ -Prevent `raw` conflict with `layer` in {func}`~scanpy.tl.score_genes` {smaller}`S Dicks` diff --git a/docs/release-notes/3180.feature.md b/docs/release-notes/3180.feature.md deleted file mode 100644 index ab73dfe18e..0000000000 --- a/docs/release-notes/3180.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add support for `median` as an aggregation function to the `Aggregation` class in `scanpy.get._aggregated.py`. This allows for median-based aggregation of data (e.g., pseudobulk), complementing existing methods like mean- and sum-based aggregation {smaller}`M Dehkordi (Farhad)` diff --git a/docs/release-notes/3184.feature.md b/docs/release-notes/3184.feature.md deleted file mode 100644 index 3cc976b141..0000000000 --- a/docs/release-notes/3184.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add `key_added` argument to {func}`~scanpy.pp.pca`, {func}`~scanpy.tl.tsne` and {func}`~scanpy.tl.umap` {smaller}`P Angerer` diff --git a/docs/release-notes/3263.feature.md b/docs/release-notes/3263.feature.md deleted file mode 100644 index 8e924e1799..0000000000 --- a/docs/release-notes/3263.feature.md +++ /dev/null @@ -1 +0,0 @@ -Support running {func}`scanpy.pp.pca` on sparse Dask arrays with the `'covariance_eigh'` solver {smaller}`P Angerer` diff --git a/docs/release-notes/3267.feature.md b/docs/release-notes/3267.feature.md deleted file mode 100644 index 6ea7fb20a2..0000000000 --- a/docs/release-notes/3267.feature.md +++ /dev/null @@ -1 +0,0 @@ -Use upstreamed {class}`~sklearn.decomposition.PCA` implementation for {class}`~scipy.sparse.csr_array` and {class}`~scipy.sparse.csr_matrix` (see {ref}`sklearn:changes_1_4`) {smaller}`P Angerer` diff --git a/docs/release-notes/3284.performance.md b/docs/release-notes/3284.performance.md deleted file mode 100644 index 31c95245ff..0000000000 --- a/docs/release-notes/3284.performance.md +++ /dev/null @@ -1 +0,0 @@ -* Speed up {func}`~scanpy.pp.regress_out` {smaller}`P Ashish, P Angerer & S Dicks` diff --git a/docs/release-notes/3296.feature.md b/docs/release-notes/3296.feature.md deleted file mode 100644 index 74b89945dd..0000000000 --- a/docs/release-notes/3296.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add explicit support to {func}`scanpy.pp.pca` for `svd_solver='covariance_eigh'` {smaller}`P Angerer` diff --git a/docs/release-notes/3307.feature.md b/docs/release-notes/3307.feature.md deleted file mode 100644 index 1505befb40..0000000000 --- a/docs/release-notes/3307.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add support {class}`dask.array.Array` to {func}`scanpy.pp.calculate_qc_metrics` {smaller}`I Gold` diff --git a/docs/release-notes/3324.feature.md b/docs/release-notes/3324.feature.md deleted file mode 100644 index 03d14dceb6..0000000000 --- a/docs/release-notes/3324.feature.md +++ /dev/null @@ -1 +0,0 @@ -Support `layer` parameter in {func}`scanpy.pl.highest_expr_genes` {smaller}`P Angerer` diff --git a/docs/release-notes/3335.feature.md b/docs/release-notes/3335.feature.md deleted file mode 100644 index 77a1723a8e..0000000000 --- a/docs/release-notes/3335.feature.md +++ /dev/null @@ -1 +0,0 @@ -Run numba functions single-threaded when called from inside of a ThreadPool {smaller}`P Angerer` diff --git a/docs/release-notes/3362.doc.md b/docs/release-notes/3362.doc.md deleted file mode 100644 index 1dae77b3e2..0000000000 --- a/docs/release-notes/3362.doc.md +++ /dev/null @@ -1 +0,0 @@ -Improve {func}`~scanpy.external.pp.harmony_integrate` docs {smaller}`D Kühl` diff --git a/docs/release-notes/3380.bugfix.md b/docs/release-notes/3380.bugfix.md deleted file mode 100644 index 633ce346af..0000000000 --- a/docs/release-notes/3380.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Raise {exc}`FutureWarning` when calling deprecated {mod}`scanpy.pp` functions {smaller}`P Angerer` diff --git a/docs/release-notes/3384.feature.md b/docs/release-notes/3384.feature.md deleted file mode 100644 index 755af9a8a3..0000000000 --- a/docs/release-notes/3384.feature.md +++ /dev/null @@ -1 +0,0 @@ -Switch {func}`~scanpy.logging.print_header` and {func}`~scanpy.logging.print_versions` to {mod}`session_info2` {smaller}`P Angerer` diff --git a/docs/release-notes/3393.bugfix.md b/docs/release-notes/3393.bugfix.md deleted file mode 100644 index 22af00f124..0000000000 --- a/docs/release-notes/3393.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Upper-bound {mod}`sklearn` `<1.6.0` due to {issue}`dask/dask-ml#1002` {smaller}`Ilan Gold` diff --git a/docs/release-notes/3407.misc.md b/docs/release-notes/3407.misc.md deleted file mode 100644 index 4670de6fd4..0000000000 --- a/docs/release-notes/3407.misc.md +++ /dev/null @@ -1,7 +0,0 @@ -| Deprecate … | in favor of … | -| --- | --- | -| {func}`scanpy.read_visium` | {func}`squidpy.read.visium` | -| {func}`scanpy.datasets.visium_sge` | {func}`squidpy.datasets.visium` | -| {func}`scanpy.pl.spatial` | {func}`squidpy.pl.spatial_scatter` | - -{smaller}`P Angerer` diff --git a/docs/release-notes/3410.feature.md b/docs/release-notes/3410.feature.md deleted file mode 100644 index d95ad201ba..0000000000 --- a/docs/release-notes/3410.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add sampling probabilities/mask parameter `p` to {func}`~scanpy.pp.sample` {smaller}`P Angerer` diff --git a/docs/release-notes/943.feature.md b/docs/release-notes/943.feature.md deleted file mode 100644 index 4f5474d762..0000000000 --- a/docs/release-notes/943.feature.md +++ /dev/null @@ -1 +0,0 @@ -{func}`~scanpy.pp.sample` supports both upsampling and downsampling of observations and variables. {func}`~scanpy.pp.subsample` is now deprecated. {smaller}`G Eraslan` & {smaller}`P Angerer` From 5654389f0abee562bcdfe8f4f2ce24a586007dd8 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Fri, 20 Dec 2024 14:51:48 +0100 Subject: [PATCH 51/51] =?UTF-8?q?Note=20that=20it=E2=80=99s=20an=20rc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release-notes/1.11.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/1.11.0.md b/docs/release-notes/1.11.0.md index 8c51fe8a37..c7258ea271 100644 --- a/docs/release-notes/1.11.0.md +++ b/docs/release-notes/1.11.0.md @@ -1,5 +1,5 @@ (v1.11.0)= -### 1.11.0 {small}`2024-12-20` +### 1.11.0rc1 {small}`2024-12-20` ### Features