From 2403db6adb8d184389805733f6f773320f512d5f Mon Sep 17 00:00:00 2001 From: Intron7 Date: Wed, 5 Aug 2026 18:27:06 +0200 Subject: [PATCH] add scanpy-2 and scverse-misc Signed-off-by: Intron7 --- pyproject.toml | 3 +- src/rapids_singlecell/__init__.py | 3 + src/rapids_singlecell/_settings.py | 173 ++++++++++ src/rapids_singlecell/get/_aggregated.py | 9 + .../preprocessing/_hvg/__init__.py | 5 +- src/rapids_singlecell/preprocessing/_pca.py | 74 +++-- src/rapids_singlecell/preprocessing/_scale.py | 7 +- src/rapids_singlecell/tools/_diffmap.py | 14 +- src/rapids_singlecell/tools/_draw_graph.py | 16 +- src/rapids_singlecell/tools/_ingest.py | 7 +- .../tools/_rank_genes_groups/__init__.py | 37 ++- .../tools/_rank_genes_groups/_core.py | 39 ++- .../tools/_rank_genes_groups/_wilcoxon.py | 18 +- src/rapids_singlecell/tools/_score_genes.py | 4 +- src/rapids_singlecell/tools/_tsne.py | 4 +- src/rapids_singlecell/tools/_umap.py | 4 +- src/rapids_singlecell/tools/_utils.py | 18 +- tests/dask/test_dask_rank_ttest.py | 40 ++- tests/test_settings.py | 313 ++++++++++++++++++ 19 files changed, 706 insertions(+), 82 deletions(-) create mode 100644 src/rapids_singlecell/_settings.py create mode 100644 tests/test_settings.py diff --git a/pyproject.toml b/pyproject.toml index 4c2c26689..7c37f57bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,9 @@ readme = { file = "README.md", content-type = "text/markdown" } dynamic = [ "version" ] dependencies = [ - "anndata>=0.10.0", + "anndata>=0.12.14", "scanpy>=1.10.0", + "scverse-misc[settings]>=0.1.3", "numpy>=1.17.0", "scipy>=1.4", "pandas", diff --git a/src/rapids_singlecell/__init__.py b/src/rapids_singlecell/__init__.py index 9217ac066..a3f3f4c90 100644 --- a/src/rapids_singlecell/__init__.py +++ b/src/rapids_singlecell/__init__.py @@ -2,6 +2,9 @@ import cuml.internals.logger as logger +# Import settings before the public modules which consume them. +from ._settings import Preset, settings # isort: skip + from . import dcg, get, gr, pp, ptg, tl from ._version import __version__ diff --git a/src/rapids_singlecell/_settings.py b/src/rapids_singlecell/_settings.py new file mode 100644 index 000000000..f6b2a758e --- /dev/null +++ b/src/rapids_singlecell/_settings.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import enum +from contextlib import contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, NamedTuple, cast + +from scverse_misc import Settings as BaseSettings + +if TYPE_CHECKING: + from collections.abc import Generator + + +type HVGFlavor = Literal[ + "seurat", + "cell_ranger", + "seurat_v3", + "seurat_v3_paper", + "pearson_residuals", + "poisson_gene_selection", +] +type DETest = Literal[ + "logreg", "t-test", "t-test_overestim_var", "wilcoxon", "wilcoxon_binned" +] + + +class HVGPreset(NamedTuple): + flavor: HVGFlavor + return_df: bool + + +class BasicEmbeddingPreset(NamedTuple): + key_added: str | None + + +class RankGenesGroupsPreset(NamedTuple): + method: DETest + mask_var: str | None + mean_in_log_space: bool + + +class ScalePreset(NamedTuple): + zero_center: bool | None + + +class ScoreGenesPreset(NamedTuple): + ctrl_as_ref: bool + + +class Preset(enum.StrEnum): + """Presets for :attr:`rapids_singlecell.settings.preset`. + + See properties below for details. + """ + + ScanpyV1 = "scanpy-v1" + """Scanpy 1.*’s default settings.""" + + ScanpyV2Preview = "scanpy-v2-preview" + """Scanpy 2.*’s feature default settings. (Preview: subject to change!)""" + + @property + def highly_variable_genes(self) -> HVGPreset: + return { + Preset.ScanpyV1: HVGPreset(flavor="seurat", return_df=False), + Preset.ScanpyV2Preview: HVGPreset(flavor="seurat_v3_paper", return_df=True), + }[self] + + @property + def pca(self) -> BasicEmbeddingPreset: + return self._embedding("pca") + + @property + def umap(self) -> BasicEmbeddingPreset: + return self._embedding("umap") + + @property + def tsne(self) -> BasicEmbeddingPreset: + return self._embedding("tsne") + + @property + def diffmap(self) -> BasicEmbeddingPreset: + return self._embedding("diffmap") + + @property + def draw_graph(self) -> BasicEmbeddingPreset: + return BasicEmbeddingPreset( + key_added=None if self is Preset.ScanpyV1 else "graph_{layout}" + ) + + @property + def rank_genes_groups(self) -> RankGenesGroupsPreset: + return { + Preset.ScanpyV1: RankGenesGroupsPreset( + method="t-test", mask_var=None, mean_in_log_space=True + ), + Preset.ScanpyV2Preview: RankGenesGroupsPreset( + method="wilcoxon", mask_var=None, mean_in_log_space=False + ), + }[self] + + @property + def scale(self) -> ScalePreset: + return ScalePreset(zero_center=True if self is Preset.ScanpyV1 else None) + + @property + def score_genes(self) -> ScoreGenesPreset: + return ScoreGenesPreset(ctrl_as_ref=self is Preset.ScanpyV1) + + def _embedding(self, name: str) -> BasicEmbeddingPreset: + return BasicEmbeddingPreset(key_added=None if self is Preset.ScanpyV1 else name) + + @contextmanager + def override(self, preset: Preset) -> Generator[Preset, None, None]: + """Temporarily override :attr:`rapids_singlecell.settings.preset`.""" + with settings.override(preset=preset): + yield self + + +class Settings(BaseSettings): + """Validated global settings for rapids-singlecell.""" + + preset: Preset = Preset.ScanpyV1 + """Preset to use.""" + + N_PCS: int = 50 + """Default number of principal components to use.""" + + +settings = Settings() + + +@dataclass(frozen=True) +class Default: + """Marker for a function default resolved from :data:`settings`.""" + + preset: tuple[str, str] | None = None + repr: str | None = None + + def __post_init__(self) -> None: + if self.preset is not None and self.repr is not None: + raise TypeError("Cannot provide both preset and repr.") + + def resolve(self) -> object: + if self.preset is None: + raise TypeError("A default without a preset cannot be resolved.") + return self._get_value(settings.preset) + + def _get_value(self, preset: Preset) -> object: + if self.preset is None: + raise TypeError("A default without a preset has no preset value.") + section, field = self.preset + return getattr(getattr(preset, section), field) + + def __repr__(self) -> str: + if self.preset is None: + return self.repr or "default" + value = self.resolve() + suffix = ( + " – changes in 2.0" + if settings.preset is Preset.ScanpyV1 + and value != self._get_value(Preset.ScanpyV2Preview) + else "" + ) + return f"{value!r} (settings.preset={str(settings.preset)!r}{suffix})" + + +def resolve_default[T](value: T | Default) -> T: + """Resolve a :class:`Default` marker against the active preset.""" + return cast("T", value.resolve()) if isinstance(value, Default) else value + + +__all__ = ["Preset", "settings"] diff --git a/src/rapids_singlecell/get/_aggregated.py b/src/rapids_singlecell/get/_aggregated.py index cea9cd3c3..89fd9aa91 100644 --- a/src/rapids_singlecell/get/_aggregated.py +++ b/src/rapids_singlecell/get/_aggregated.py @@ -14,6 +14,7 @@ from rapids_singlecell._compat import DaskArray, _meta_dense from rapids_singlecell._cuda import _aggr_cuda +from rapids_singlecell._settings import Preset, settings from rapids_singlecell.get import _check_mask from rapids_singlecell.preprocessing._utils import _check_gpu_X @@ -574,6 +575,14 @@ def aggregate( Note that this filters out any combination of groups that wasn't present in the original data. """ + if settings.preset is Preset.ScanpyV2Preview and any( + value is not None for value in (axis, layer, obsm, varm) + ): + msg = "`acc` will replace `layer`, `obsm`, and `varm` arguments in Scanpy 2." + if axis is not None: + msg += " `axis` is no longer necessary because it is inferred from `by`." + warnings.warn(msg, FutureWarning, stacklevel=2) + if axis is None: axis = 1 if varm else 0 axis, axis_name = _resolve_axis(axis) diff --git a/src/rapids_singlecell/preprocessing/_hvg/__init__.py b/src/rapids_singlecell/preprocessing/_hvg/__init__.py index 553b1d78d..24c0b326d 100644 --- a/src/rapids_singlecell/preprocessing/_hvg/__init__.py +++ b/src/rapids_singlecell/preprocessing/_hvg/__init__.py @@ -4,6 +4,7 @@ import numpy as np +from rapids_singlecell._settings import Default, resolve_default from rapids_singlecell.preprocessing._utils import _sanitize_column from ._cutoffs import _Cutoffs @@ -37,7 +38,7 @@ def highly_variable_genes( min_disp: float = 0.5, max_disp: float = np.inf, n_top_genes: int = None, - flavor: flavors = "seurat", + flavor: flavors | Default = Default(("highly_variable_genes", "flavor")), n_bins: int = 20, span: float = 0.3, check_values: bool = True, @@ -145,6 +146,8 @@ def highly_variable_genes( `highly_variable_intersection` : bool If batch_key is given, this denotes the genes that are highly variable in all batches """ + flavor = resolve_default(flavor) + if batch_key is not None: _sanitize_column(adata, batch_key) diff --git a/src/rapids_singlecell/preprocessing/_pca.py b/src/rapids_singlecell/preprocessing/_pca.py index 3758b8356..e312506c7 100644 --- a/src/rapids_singlecell/preprocessing/_pca.py +++ b/src/rapids_singlecell/preprocessing/_pca.py @@ -10,8 +10,10 @@ from cupyx.scipy.sparse import issparse as issparse_cupy from cupyx.scipy.sparse import isspmatrix_csr from scipy.sparse import issparse +from scverse_misc import Deprecation, deprecated_arg from rapids_singlecell._compat import DaskArray +from rapids_singlecell._settings import Default, Preset, resolve_default, settings from rapids_singlecell.get import X_to_GPU, _check_mask, _get_obs_rep from ._utils import _check_gpu_X @@ -21,47 +23,65 @@ from rapids_singlecell._utils import ArrayTypesDask -_empty = object() +_empty = Default(repr="adata.var.get('highly_variable')") def _resolve_mask_var( adata: AnnData, - mask_var: NDArray[np.bool] | str | None, + mask_var: NDArray[np.bool] | str | Default | None, *, use_highly_variable: bool | None, + warn: bool = True, ) -> tuple[str | None, np.ndarray | None]: """Resolve mask_var and use_highly_variable into a mask parameter and array.""" - import warnings - if use_highly_variable is not None: - warnings.warn( - "Argument `use_highly_variable` is deprecated, use `mask_var` instead. " - 'Use mask_var="highly_variable" instead of use_highly_variable=True, ' - "and mask_var=None instead of use_highly_variable=False.", - FutureWarning, - stacklevel=3, - ) - if mask_var is not _empty: + if warn: + import warnings + + warnings.warn( + "Argument `use_highly_variable` is deprecated, use `mask_var` instead.", + FutureWarning, + stacklevel=2, + ) + if not isinstance(mask_var, Default): raise ValueError( "Cannot specify both `mask_var` and `use_highly_variable`." ) - if use_highly_variable or ( - use_highly_variable is None - and mask_var is _empty - and "highly_variable" in adata.var.columns - ): + if use_highly_variable: mask_var = "highly_variable" + elif isinstance(mask_var, Default): + if "highly_variable" not in adata.var.columns: + return None, None + mask_var_param = ( + "var.highly_variable" + if settings.preset is Preset.ScanpyV2Preview + else "highly_variable" + ) + return mask_var_param, _check_mask(adata, "highly_variable", "var") - if mask_var is _empty or mask_var is None: + if mask_var is None: return None, None # `_check_mask` validates boolean dtype + shape (and resolves a column name), # matching scanpy. Keep the param as the column name for strings, else None. mask_var_param = mask_var if isinstance(mask_var, str) else None - return mask_var_param, _check_mask(adata, mask_var, "var") + mask_var_lookup = ( + mask_var.removeprefix("var.") + if isinstance(mask_var, str) and mask_var.startswith("var.") + else mask_var + ) + return mask_var_param, _check_mask(adata, mask_var_lookup, "var") +@deprecated_arg( + "use_highly_variable", + Deprecation( + "0.16.2", + "Use `mask_var='highly_variable'` instead of `use_highly_variable=True` " + "and `mask_var=None` instead of `use_highly_variable=False`.", + ), +) def pca( data: AnnData | ArrayTypesDask, n_comps: int | None = None, @@ -70,12 +90,12 @@ def pca( zero_center: bool = True, svd_solver: str | None = None, random_state: int | None = 0, - mask_var: NDArray[np.bool] | str | None = _empty, + mask_var: NDArray[np.bool] | str | Default | None = _empty, use_highly_variable: bool | None = None, dtype: str = "float32", chunked: bool = False, chunk_size: int = None, - key_added: str | None = None, + key_added: str | Default | None = Default(("pca", "key_added")), return_info: bool = False, copy: bool = False, **kwargs, @@ -224,7 +244,9 @@ def pca( "`use_highly_variable` can only be used with an AnnData object." ) X = data - mask_var = None if mask_var is _empty else _check_mask(X, mask_var, "var") + mask_var = ( + None if isinstance(mask_var, Default) else _check_mask(X, mask_var, "var") + ) X = X[:, mask_var] if mask_var is not None else X pca_func, X_pca, _ = _pca_compute( X, @@ -247,6 +269,7 @@ def pca( return X_pca adata = data + key_added = resolve_default(key_added) if use_highly_variable is True and "highly_variable" not in adata.var.keys(): raise ValueError( "Did not find adata.var['highly_variable']. " @@ -259,7 +282,7 @@ def pca( X = _get_obs_rep(adata, layer=layer) mask_var_param, mask_var = _resolve_mask_var( - adata, mask_var, use_highly_variable=use_highly_variable + adata, mask_var, use_highly_variable=use_highly_variable, warn=False ) del use_highly_variable X = X[:, mask_var] if mask_var is not None else X @@ -283,7 +306,6 @@ def pca( adata.uns[key_uns] = { "params": { "zero_center": zero_center, - "use_highly_variable": mask_var_param == "highly_variable", "mask_var": mask_var_param, **({"layer": layer} if layer is not None else {}), }, @@ -314,10 +336,10 @@ def _pca_compute( ): if n_comps is None: min_dim = min(X.shape[0], X.shape[1]) - if 50 >= min_dim: + if settings.N_PCS >= min_dim: n_comps = min_dim - 1 else: - n_comps = 50 + n_comps = settings.N_PCS # Auto-select sparse solver based on matrix dimensions # Lanczos is faster for large feature counts (>8000) diff --git a/src/rapids_singlecell/preprocessing/_scale.py b/src/rapids_singlecell/preprocessing/_scale.py index 35773e66d..64d159ffb 100644 --- a/src/rapids_singlecell/preprocessing/_scale.py +++ b/src/rapids_singlecell/preprocessing/_scale.py @@ -15,6 +15,7 @@ _meta_dense, _meta_sparse, ) +from rapids_singlecell._settings import Default, resolve_default from rapids_singlecell.get import _check_mask, _get_obs_rep, _set_obs_rep from rapids_singlecell.preprocessing._utils import ( _check_gpu_X, @@ -29,7 +30,7 @@ def scale( data: AnnData | ArrayTypesDask, *, - zero_center: bool = True, + zero_center: bool | Default = Default(("scale", "zero_center")), max_value: float | None = None, copy: bool = False, layer: str | None = None, @@ -80,6 +81,10 @@ def scale( depending on `inplace`. If a matrix is passed, the scaled matrix is returned. """ + zero_center = resolve_default(zero_center) + if zero_center is None: + raise TypeError("scale() missing 1 required keyword argument: 'zero_center'") + if not isinstance(data, AnnData): if layer is not None or obsm is not None: raise ValueError( diff --git a/src/rapids_singlecell/tools/_diffmap.py b/src/rapids_singlecell/tools/_diffmap.py index 0888c84df..53055cdee 100644 --- a/src/rapids_singlecell/tools/_diffmap.py +++ b/src/rapids_singlecell/tools/_diffmap.py @@ -7,6 +7,8 @@ from cupyx.scipy.sparse import linalg from scipy.sparse import issparse +from rapids_singlecell._settings import Default, resolve_default + if TYPE_CHECKING: from anndata import AnnData @@ -142,6 +144,7 @@ def diffmap( n_comps: int = 15, *, neighbors_key: str | None = None, + key_added: str | Default | None = Default(("diffmap", "key_added")), sort: Literal["decrease", "increase"] = "decrease", density_normalize: bool = True, ) -> None: @@ -168,6 +171,8 @@ def diffmap( Leave as is for the same behavior as :func:`scanpy.tl.diffmap`. density_normalize Leave as is for the same behavior as :func:`scanpy.tl.diffmap`. + key_added + Control where the embedding and eigenvalues are stored. Returns ------- @@ -186,5 +191,10 @@ def diffmap( ) evals, evecs = _compute_eigen(transitions_sym, n_comps=n_comps, sort=sort) - adata.uns["diffmap_evals"] = evals.get() - adata.obsm["X_diffmap"] = evecs.get() + key_added = resolve_default(key_added) + if key_added is None: + adata.uns["diffmap_evals"] = evals.get() + adata.obsm["X_diffmap"] = evecs.get() + else: + adata.uns[key_added] = {"evals": evals.get()} + adata.obsm[key_added] = evecs.get() diff --git a/src/rapids_singlecell/tools/_draw_graph.py b/src/rapids_singlecell/tools/_draw_graph.py index faacbbaf7..2f4ce01fc 100644 --- a/src/rapids_singlecell/tools/_draw_graph.py +++ b/src/rapids_singlecell/tools/_draw_graph.py @@ -8,6 +8,7 @@ from scanpy.tools._utils import get_init_pos_from_paga from rapids_singlecell._compat import _random_state_kwargs +from rapids_singlecell._settings import Default, resolve_default from ._clustering import _create_graph from ._utils import _validate_init_pos @@ -22,6 +23,7 @@ def draw_graph( init_pos: str | bool | None = None, max_iter: int = 500, random_state: int | None = 0, + key_added: str | Default | None = Default(("draw_graph", "key_added")), ) -> None: """ Force-directed graph drawing :cite:p:`Fruchterman1991,Jacomy2014`. @@ -49,6 +51,8 @@ def draw_graph( Random state to use when initializing layout and generating samples. Defaults to 0. If `None` is passed, a hash of process id, time, and hostname is used by `cugraph`. + key_added + Template controlling where coordinates and parameters are stored. Returns ------- @@ -110,7 +114,11 @@ def draw_graph( positions = positions.sort_values("vertex").reset_index(drop=True) positions = cp.vstack((positions["x"].to_cupy(), positions["y"].to_cupy())).T layout = "fa" - adata.uns["draw_graph"] = {} - adata.uns["draw_graph"]["params"] = {"layout": layout, "random_state": random_state} - key_added = f"X_draw_graph_{layout}" - adata.obsm[key_added] = positions.get() # Format output + key_added = resolve_default(key_added) + key_uns, key_obsm = ( + ("draw_graph", f"X_draw_graph_{layout}") + if key_added is None + else [key_added.format(layout=layout)] * 2 + ) + adata.uns[key_uns] = {"params": {"layout": layout, "random_state": random_state}} + adata.obsm[key_obsm] = positions.get() # Format output diff --git a/src/rapids_singlecell/tools/_ingest.py b/src/rapids_singlecell/tools/_ingest.py index 33824acb0..53f1371f0 100644 --- a/src/rapids_singlecell/tools/_ingest.py +++ b/src/rapids_singlecell/tools/_ingest.py @@ -330,9 +330,10 @@ def _resolve_pca_feature_mask( mask = "highly_variable" if isinstance(mask, str): - if mask not in adata_ref.var: - raise ValueError(f"Did not find `adata_ref.var[{mask!r}]`.") - mask = adata_ref.var[mask].to_numpy() + mask_key = mask.removeprefix("var.") if mask.startswith("var.") else mask + if mask_key not in adata_ref.var: + raise ValueError(f"Did not find `adata_ref.var[{mask_key!r}]`.") + mask = adata_ref.var[mask_key].to_numpy() elif mask is None: components_host = ( cp.asnumpy(components) if isinstance(components, cp.ndarray) else components diff --git a/src/rapids_singlecell/tools/_rank_genes_groups/__init__.py b/src/rapids_singlecell/tools/_rank_genes_groups/__init__.py index 78037f627..775a8c816 100644 --- a/src/rapids_singlecell/tools/_rank_genes_groups/__init__.py +++ b/src/rapids_singlecell/tools/_rank_genes_groups/__init__.py @@ -1,11 +1,12 @@ from __future__ import annotations -import sys -from functools import partial from typing import TYPE_CHECKING, Literal import numpy as np import pandas as pd +from scverse_misc import Deprecation, deprecated + +from rapids_singlecell._settings import Default, resolve_default, settings from ._core import _RankGenes @@ -60,12 +61,15 @@ def rank_genes_groups( rankby_abs: bool = False, pts: bool = False, key_added: str | None = None, - method: _Method | None = None, + method: _Method | Default | None = Default(("rank_genes_groups", "method")), corr_method: _CorrMethod = "benjamini-hochberg", tie_correct: bool = False, use_continuity: bool = False, return_u_values: bool = False, layer: str | None = None, + mean_in_log_space: bool | Default = Default( + ("rank_genes_groups", "mean_in_log_space") + ), chunk_size: int | None = None, multi_gpu: bool | list[int] | str | None = None, n_bins: int | None = None, @@ -157,6 +161,11 @@ def rank_genes_groups( approximation using the selected tie and continuity settings. layer Key from `adata.layers` whose value will be used to perform tests on. + mean_in_log_space + Whether to calculate statistics from mean-log values (`True`) or from + the mean after applying the inverse log1p transform (`False`). The + latter is more accurate in the presence of outliers. The Scanpy 1 + preset defaults to `True`; the Scanpy 2 preview defaults to `False`. chunk_size Number of genes to process at once for `'wilcoxon'` and `'wilcoxon_binned'`. Default is 512 for `'wilcoxon'`. For @@ -219,6 +228,9 @@ def rank_genes_groups( `adata.uns['rank_genes_groups' | key_added]['pts_rest']` Fraction of cells expressing genes in rest. Only if `pts=True` and `reference='rest'`. """ + method = resolve_default(method) + mean_in_log_space = resolve_default(mean_in_log_space) + if corr_method not in {"benjamini-hochberg", "bonferroni"}: msg = "corr_method must be either 'benjamini-hochberg' or 'bonferroni'." raise ValueError(msg) @@ -231,7 +243,7 @@ def rank_genes_groups( raise TypeError(msg) if method is None: - method = "t-test" + method = settings.preset.rank_genes_groups.method if method not in { "logreg", @@ -303,6 +315,7 @@ def rank_genes_groups( corr_method=corr_method, n_genes_user=n_genes_user, rankby_abs=rankby_abs, + mean_in_log_space=mean_in_log_space, tie_correct=tie_correct, use_continuity=use_continuity, return_u_values=return_u_values, @@ -320,6 +333,7 @@ def rank_genes_groups( "use_raw": use_raw, "layer": layer, "corr_method": corr_method, + "mean_in_log_space": mean_in_log_space, } if method == "wilcoxon": params["tie_correct"] = tie_correct @@ -348,20 +362,7 @@ def rank_genes_groups( return None -if TYPE_CHECKING: - from warnings import deprecated -else: - 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) - - -@deprecated( - "rank_genes_groups_logreg is deprecated. " - "Use rank_genes_groups(method='logreg') instead." -) +@deprecated(Deprecation("0.14.1", "Use `rank_genes_groups(method='logreg')` instead.")) def rank_genes_groups_logreg( adata: AnnData, groupby: str, diff --git a/src/rapids_singlecell/tools/_rank_genes_groups/_core.py b/src/rapids_singlecell/tools/_rank_genes_groups/_core.py index d03dee7f5..581713927 100644 --- a/src/rapids_singlecell/tools/_rank_genes_groups/_core.py +++ b/src/rapids_singlecell/tools/_rank_genes_groups/_core.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Literal, assert_never import cupy as cp +import cupyx.scipy.sparse as cpsp import numpy as np import pandas as pd import scipy.sparse as sp @@ -23,6 +24,21 @@ _RANK_SORT_MIN_ELEMENTS = 1_000_000 _RANK_SORT_MAX_WORKERS = 64 + +def _apply_expm1_preserving_sparsity(X, scale: float): + """Apply the inverse log1p transform without densifying sparse arrays.""" + if isinstance(X, DaskArray): + dtype = np.result_type(X.dtype, np.float32) + return X.map_blocks(_apply_expm1_preserving_sparsity, scale, dtype=dtype) + if sp.issparse(X) or cpsp.issparse(X): + result = X.copy() + xp = np if sp.issparse(result) else cp + result.data = xp.expm1(result.data * scale) + return result + xp = np if isinstance(X, np.ndarray) else cp + return xp.expm1(X * scale) + + if TYPE_CHECKING: from collections.abc import Iterable @@ -125,6 +141,7 @@ def __init__( self._sparse_negative_fallback = False self._score_dtype = np.dtype(np.float32) self._multi_gpu: bool | list[int] | str | None = None + self.mean_in_log_space = True def _accumulate_planes( self, @@ -173,7 +190,14 @@ def _stream_planes(self) -> tuple[cp.ndarray, cp.ndarray, cp.ndarray | None]: def _basic_stats(self) -> None: """Compute means, vars, and pts (host input streams, device uses Aggregate).""" - sums_all, sq_sums_all, nnz_all = self._accumulate_planes() + original_X = self.X + if not self.mean_in_log_space: + scale = np.log(self._log1p_base) if self._log1p_base is not None else 1.0 + self.X = _apply_expm1_preserving_sparsity(original_X, scale) + try: + sums_all, sq_sums_all, nnz_all = self._accumulate_planes() + finally: + self.X = original_X # Map category order → selected groups order. cat_names = list(self.labels.cat.categories) @@ -263,6 +287,7 @@ def compute_statistics( corr_method: _CorrMethod = "benjamini-hochberg", n_genes_user: int | None = None, rankby_abs: bool = False, + mean_in_log_space: bool = True, tie_correct: bool = False, use_continuity: bool = False, chunk_size: int | None = None, @@ -273,6 +298,7 @@ def compute_statistics( **kwds, ) -> None: """Compute statistics for all groups.""" + self.mean_in_log_space = mean_in_log_space # Devices for the host-streaming t-test / binned shards. self._multi_gpu = multi_gpu # Exact OVR inserts implicit zeros between negative and positive stored @@ -315,6 +341,9 @@ def compute_statistics( multi_gpu=multi_gpu, return_u_values=return_u_values, ) + if wilcoxon_result is not None and not mean_in_log_space: + self._basic_stats() + wilcoxon_result = (*wilcoxon_result[:3], None) test_results = [] elif method == "wilcoxon_binned": test_results = self.wilcoxon_binned( @@ -324,6 +353,8 @@ def compute_statistics( chunk_size=chunk_size, bin_range=bin_range, ) + if not mean_in_log_space: + self._basic_stats() elif method == "logreg": test_results = self.logreg(**kwds) else: @@ -433,8 +464,10 @@ def _logfoldchanges_into( mean_rest = self.means_rest[group_indices] else: mean_rest = self.means[self.ireference][None, :] - foldchanges = (self.expm1_func(mean_group) + EPS) / ( - self.expm1_func(mean_rest) + EPS + foldchanges = ( + (self.expm1_func(mean_group) + EPS) / (self.expm1_func(mean_rest) + EPS) + if self.mean_in_log_space + else (mean_group + EPS) / (mean_rest + EPS) ) logfoldchanges = np.log2(foldchanges) arrays["logfoldchanges"] = np.take_along_axis( diff --git a/src/rapids_singlecell/tools/_rank_genes_groups/_wilcoxon.py b/src/rapids_singlecell/tools/_rank_genes_groups/_wilcoxon.py index 41f91b31e..9ada9b8a8 100644 --- a/src/rapids_singlecell/tools/_rank_genes_groups/_wilcoxon.py +++ b/src/rapids_singlecell/tools/_rank_genes_groups/_wilcoxon.py @@ -108,13 +108,17 @@ def _logfoldchanges_from_means( mean_group: cp.ndarray, mean_reference: cp.ndarray, ) -> cp.ndarray: - scale = ( - cp.float64(np.log(rg._log1p_base)) - if rg._log1p_base is not None - else cp.float64(1.0) - ) - group_expr = cp.expm1(mean_group * scale) - reference_expr = cp.expm1(mean_reference * scale) + if rg.mean_in_log_space: + scale = ( + cp.float64(np.log(rg._log1p_base)) + if rg._log1p_base is not None + else cp.float64(1.0) + ) + group_expr = cp.expm1(mean_group * scale) + reference_expr = cp.expm1(mean_reference * scale) + else: + group_expr = mean_group + reference_expr = mean_reference return cp.log2((group_expr + EPS) / (reference_expr + EPS)) diff --git a/src/rapids_singlecell/tools/_score_genes.py b/src/rapids_singlecell/tools/_score_genes.py index d905a7c6d..295847176 100644 --- a/src/rapids_singlecell/tools/_score_genes.py +++ b/src/rapids_singlecell/tools/_score_genes.py @@ -9,6 +9,7 @@ import pandas as pd from rapids_singlecell._compat import DaskArray +from rapids_singlecell._settings import Default, resolve_default from rapids_singlecell.get import X_to_GPU, _get_obs_rep from rapids_singlecell.preprocessing._utils import _check_gpu_X, _check_use_raw @@ -24,7 +25,7 @@ def score_genes( adata: AnnData, gene_list: Sequence[str] | pd.Index, *, - ctrl_as_ref: bool = True, + ctrl_as_ref: bool | Default = Default(("score_genes", "ctrl_as_ref")), ctrl_size: int = 50, gene_pool: Sequence[str] | pd.Index | None = None, n_bins: int = 25, @@ -75,6 +76,7 @@ def score_genes( `adata.obs[score_name]` : :class:`numpy.ndarray` (dtype `float`) Scores of each cell. """ + ctrl_as_ref = resolve_default(ctrl_as_ref) adata = adata.copy() if copy else adata use_raw = _check_use_raw(adata, layer, use_raw=use_raw) X = _get_obs_rep(adata, layer=layer, use_raw=use_raw) diff --git a/src/rapids_singlecell/tools/_tsne.py b/src/rapids_singlecell/tools/_tsne.py index 9aedb1b56..820df485f 100644 --- a/src/rapids_singlecell/tools/_tsne.py +++ b/src/rapids_singlecell/tools/_tsne.py @@ -5,6 +5,7 @@ import cuml.internals.logger as logger from cuml.manifold import TSNE +from rapids_singlecell._settings import Default, resolve_default from rapids_singlecell._utils import _get_logger_level from ._utils import _choose_representation @@ -23,7 +24,7 @@ def tsne( learning_rate: int = 200, method: str = "barnes_hut", metric: str = "euclidean", - key_added: str | None = None, + key_added: str | Default | None = Default(("tsne", "key_added")), copy: bool = False, ) -> AnnData | None: """ @@ -86,6 +87,7 @@ def tsne( """ adata = adata.copy() if copy else adata + key_added = resolve_default(key_added) X = _choose_representation(adata, use_rep=use_rep, n_pcs=n_pcs) logger_level = _get_logger_level(logger) diff --git a/src/rapids_singlecell/tools/_umap.py b/src/rapids_singlecell/tools/_umap.py index 0d6ad8963..6af45fa27 100644 --- a/src/rapids_singlecell/tools/_umap.py +++ b/src/rapids_singlecell/tools/_umap.py @@ -14,6 +14,7 @@ from sklearn.utils import check_random_state from rapids_singlecell._compat import _random_state_kwargs +from rapids_singlecell._settings import Default, resolve_default from rapids_singlecell._utils import _get_logger_level from ._utils import _choose_representation, _validate_init_pos @@ -37,7 +38,7 @@ def umap( random_state: int = 0, a: float | None = None, b: float | None = None, - key_added: str | None = None, + key_added: str | Default | None = Default(("umap", "key_added")), neighbors_key: str | None = None, copy: bool = False, ) -> AnnData | None: @@ -127,6 +128,7 @@ def umap( """ adata = adata.copy() if copy else adata + key_added = resolve_default(key_added) if neighbors_key is None: neighbors_key = "neighbors" diff --git a/src/rapids_singlecell/tools/_utils.py b/src/rapids_singlecell/tools/_utils.py index a9f130993..7ef10ef2f 100644 --- a/src/rapids_singlecell/tools/_utils.py +++ b/src/rapids_singlecell/tools/_utils.py @@ -5,6 +5,7 @@ from cupyx.scipy.sparse import issparse, isspmatrix_csc, isspmatrix_csr from rapids_singlecell._compat import DaskArray +from rapids_singlecell._settings import settings from . import pca @@ -27,18 +28,21 @@ def _choose_representation(adata, use_rep=None, n_pcs=None): if use_rep is None and n_pcs == 0: # backwards compat for specifying `.X` use_rep = "X" if use_rep is None: - if adata.n_vars > 50 or adata.X is None: - if "X_pca" in adata.obsm.keys(): - if n_pcs is not None and n_pcs > adata.obsm["X_pca"].shape[1]: + if adata.n_vars > settings.N_PCS or adata.X is None: + pca_key = next((key for key in ("X_pca", "pca") if key in adata.obsm), None) + if pca_key is not None: + if n_pcs is not None and n_pcs > adata.obsm[pca_key].shape[1]: raise ValueError( - "`X_pca` does not have enough PCs. Rerun `rsc.pp.pca` with adjusted `n_comps`." + f"`{pca_key}` does not have enough PCs. Rerun `rsc.pp.pca` " + "with adjusted `n_comps`." ) - X = adata.obsm["X_pca"][:, :n_pcs] + X = adata.obsm[pca_key][:, :n_pcs] else: - n_pcs_pca = n_pcs if n_pcs is not None else 50 + n_pcs_pca = n_pcs if n_pcs is not None else settings.N_PCS pca(adata, n_comps=n_pcs_pca) - X = adata.obsm["X_pca"][:, :n_pcs] + pca_key = "X_pca" if settings.preset.pca.key_added is None else "pca" + X = adata.obsm[pca_key][:, :n_pcs] else: X = adata.X else: diff --git a/tests/dask/test_dask_rank_ttest.py b/tests/dask/test_dask_rank_ttest.py index 027a659e9..538af9961 100644 --- a/tests/dask/test_dask_rank_ttest.py +++ b/tests/dask/test_dask_rank_ttest.py @@ -52,7 +52,10 @@ def _compare_top_genes(result1, result2, top_n=10, min_overlap=9): @pytest.mark.parametrize("data_kind", ["sparse", "dense"]) @pytest.mark.parametrize("dtype", [cp.float32, cp.float64]) @pytest.mark.parametrize("method", ["t-test", "t-test_overestim_var"]) -def test_rank_genes_groups_ttest_dask(client, data_kind, dtype, method): +@pytest.mark.parametrize("mean_in_log_space", [True, False]) +def test_rank_genes_groups_ttest_dask( + client, data_kind, dtype, method, mean_in_log_space +): """Test t-test methods with dask arrays.""" if data_kind == "dense": adata = pbmc68k_reduced() @@ -72,8 +75,20 @@ def test_rank_genes_groups_ttest_dask(client, data_kind, dtype, method): rsc.get.anndata_to_GPU(adata) groupby = "louvain" - rsc.tl.rank_genes_groups(adata, groupby=groupby, method=method, use_raw=False) - rsc.tl.rank_genes_groups(dask_data, groupby=groupby, method=method, use_raw=False) + rsc.tl.rank_genes_groups( + adata, + groupby=groupby, + method=method, + use_raw=False, + mean_in_log_space=mean_in_log_space, + ) + rsc.tl.rank_genes_groups( + dask_data, + groupby=groupby, + method=method, + use_raw=False, + mean_in_log_space=mean_in_log_space, + ) # Compare top genes overlap assert _compare_top_genes( @@ -130,7 +145,8 @@ def test_rank_genes_groups_wilcoxon_dask_errors(client, data_kind): @pytest.mark.parametrize("data_kind", ["sparse", "dense"]) @pytest.mark.parametrize("method", ["t-test", "t-test_overestim_var"]) -def test_rank_genes_groups_ttest_cpu_dask(client, data_kind, method): +@pytest.mark.parametrize("mean_in_log_space", [True, False]) +def test_rank_genes_groups_ttest_cpu_dask(client, data_kind, method, mean_in_log_space): """Test t-test methods with CPU dask arrays (auto-converted to GPU). Compares CPU dask arrays against GPU cupy arrays to ensure conversion works. @@ -160,10 +176,22 @@ def test_rank_genes_groups_ttest_cpu_dask(client, data_kind, method): groupby = "louvain" # Run rsc on GPU cupy array for reference - rsc.tl.rank_genes_groups(adata, groupby=groupby, method=method, use_raw=False) + rsc.tl.rank_genes_groups( + adata, + groupby=groupby, + method=method, + use_raw=False, + mean_in_log_space=mean_in_log_space, + ) # Run rsc on CPU dask array (gets converted to GPU internally) - rsc.tl.rank_genes_groups(cpu_dask, groupby=groupby, method=method, use_raw=False) + rsc.tl.rank_genes_groups( + cpu_dask, + groupby=groupby, + method=method, + use_raw=False, + mean_in_log_space=mean_in_log_space, + ) # Compare top genes overlap assert _compare_top_genes( diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 000000000..0f20a89c2 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import importlib +import inspect + +import cupy as cp +import numpy as np +import pandas as pd +import pytest +from anndata import AnnData +from cupyx.scipy import sparse as cp_sparse +from scanpy.datasets import pbmc68k_reduced +from scipy import sparse + +import rapids_singlecell as rsc + + +@pytest.mark.parametrize( + ("preset", "expected"), + [ + ( + rsc.Preset.ScanpyV1, + { + "hvg_flavor": "seurat", + "hvg_return_df": False, + "pca_key": None, + "umap_key": None, + "tsne_key": None, + "diffmap_key": None, + "draw_graph_key": None, + "rank_method": "t-test", + "mean_in_log_space": True, + "rank_mask_var": None, + "zero_center": True, + "ctrl_as_ref": True, + }, + ), + ( + rsc.Preset.ScanpyV2Preview, + { + "hvg_flavor": "seurat_v3_paper", + "hvg_return_df": True, + "pca_key": "pca", + "umap_key": "umap", + "tsne_key": "tsne", + "diffmap_key": "diffmap", + "draw_graph_key": "graph_{layout}", + "rank_method": "wilcoxon", + "mean_in_log_space": False, + "rank_mask_var": None, + "zero_center": None, + "ctrl_as_ref": False, + }, + ), + ], +) +def test_scanpy_preset_values(preset, expected): + assert preset.highly_variable_genes.flavor == expected["hvg_flavor"] + assert preset.highly_variable_genes.return_df is expected["hvg_return_df"] + assert preset.pca.key_added == expected["pca_key"] + assert preset.umap.key_added == expected["umap_key"] + assert preset.tsne.key_added == expected["tsne_key"] + assert preset.diffmap.key_added == expected["diffmap_key"] + assert preset.draw_graph.key_added == expected["draw_graph_key"] + assert preset.rank_genes_groups.method == expected["rank_method"] + assert preset.rank_genes_groups.mean_in_log_space is expected["mean_in_log_space"] + assert preset.rank_genes_groups.mask_var is expected["rank_mask_var"] + assert preset.scale.zero_center is expected["zero_center"] + assert preset.score_genes.ctrl_as_ref is expected["ctrl_as_ref"] + + +def test_settings_validation_override_and_reset(): + original = rsc.settings.preset + try: + rsc.settings.preset = "scanpy-v2-preview" + assert rsc.settings.preset is rsc.Preset.ScanpyV2Preview + + with rsc.settings.override(preset="scanpy-v1"): + assert rsc.settings.preset is rsc.Preset.ScanpyV1 + assert rsc.settings.preset is rsc.Preset.ScanpyV2Preview + + rsc.settings.preset = rsc.Preset.ScanpyV1 + with rsc.settings.preset.override(rsc.Preset.ScanpyV2Preview) as previous: + assert previous is rsc.Preset.ScanpyV1 + assert rsc.settings.preset is rsc.Preset.ScanpyV2Preview + assert rsc.settings.preset is rsc.Preset.ScanpyV1 + + with pytest.raises(ValueError): + rsc.settings.preset = "invalid-preset" + + rsc.settings.preset = rsc.Preset.ScanpyV2Preview + with rsc.settings.reset("preset") as reset: + assert reset == {"preset"} + assert rsc.settings.preset is rsc.Preset.ScanpyV1 + assert rsc.settings.preset is rsc.Preset.ScanpyV2Preview + + rsc.settings.reset("preset") + assert rsc.settings.preset is rsc.Preset.ScanpyV1 + finally: + rsc.settings.preset = original + + +@pytest.mark.parametrize( + ("preset", "key_obsm", "key_varm", "key_uns"), + [ + (rsc.Preset.ScanpyV1, "X_pca", "PCs", "pca"), + (rsc.Preset.ScanpyV2Preview, "pca", "pca", "pca"), + ], +) +def test_pca_preset_storage_keys(preset, key_obsm, key_varm, key_uns): + adata = AnnData( + np.array( + [ + [1, 0, 2, 1], + [2, 1, 0, 1], + [0, 2, 1, 3], + [3, 1, 2, 0], + [1, 3, 0, 2], + ], + dtype=np.float32, + ) + ) + + with rsc.settings.override(preset=preset): + rsc.pp.pca(adata, n_comps=2) + + assert key_obsm in adata.obsm + assert key_varm in adata.varm + assert key_uns in adata.uns + + +@pytest.mark.parametrize( + ("preset", "expected_mask_var"), + [ + (rsc.Preset.ScanpyV1, "highly_variable"), + (rsc.Preset.ScanpyV2Preview, "var.highly_variable"), + ], +) +def test_pca_preset_default_mask_metadata(preset, expected_mask_var): + adata = AnnData(np.arange(30, dtype=np.float32).reshape(6, 5)) + adata.var["highly_variable"] = [True, True, True, False, False] + + with rsc.settings.override(preset=preset): + rsc.pp.pca(adata, n_comps=2) + + assert adata.uns["pca"]["params"]["mask_var"] == expected_mask_var + + +def test_n_pcs_setting_controls_pca_and_representation_selection(): + adata = AnnData(np.arange(80, dtype=np.float32).reshape(10, 8)) + + with rsc.settings.override(N_PCS=3): + rsc.pp.pca(adata) + representation = rsc.pp.neighbors(adata, n_neighbors=3, copy=True) + + assert adata.obsm["X_pca"].shape == (10, 3) + assert representation.uns["neighbors"]["params"].get("n_pcs") is None + + +def test_scanpy_v2_automatic_representation_uses_preview_pca_key(): + adata = AnnData(np.arange(600, dtype=np.float32).reshape(60, 10)) + + with rsc.settings.override(preset=rsc.Preset.ScanpyV2Preview, N_PCS=3): + rsc.pp.pca(adata) + rsc.pp.neighbors(adata) + + assert "pca" in adata.obsm + assert "neighbors" in adata.uns + + +def test_scanpy_v2_aggregate_legacy_selection_warning(): + adata = AnnData( + np.arange(24, dtype=np.float32).reshape(6, 4), + obs=pd.DataFrame( + {"group": pd.Categorical(["a", "a", "a", "b", "b", "b"])}, + index=[f"cell_{i}" for i in range(6)], + ), + ) + adata.layers["counts"] = adata.X.copy() + + with rsc.settings.override(preset=rsc.Preset.ScanpyV2Preview): + with pytest.warns(FutureWarning, match="`acc` will replace"): + rsc.get.aggregate(adata, by="group", func="sum", layer="counts") + + +@pytest.mark.parametrize( + ("embedding", "key"), + [ + (rsc.tl.umap, "umap"), + (rsc.tl.tsne, "tsne"), + (rsc.tl.diffmap, "diffmap"), + ], +) +def test_scanpy_v2_embedding_storage_keys(embedding, key): + adata = pbmc68k_reduced()[:100, :100].copy() + + with rsc.settings.override(preset=rsc.Preset.ScanpyV2Preview): + embedding(adata) + + assert key in adata.obsm + assert key in adata.uns + + +def test_scanpy_v2_draw_graph_storage_keys(): + adata = pbmc68k_reduced()[:100, :100].copy() + + with rsc.settings.override(preset=rsc.Preset.ScanpyV2Preview): + rsc.tl.draw_graph(adata) + + assert "graph_fa" in adata.obsm + assert "graph_fa" in adata.uns + + +def test_scanpy_v2_hvg_default_reaches_implementation(monkeypatch): + hvg_module = importlib.import_module("rapids_singlecell.preprocessing._hvg") + called = {} + + def record_call(**kwargs): + called.update(kwargs) + + monkeypatch.setattr(hvg_module, "_highly_variable_genes_seurat_v3", record_call) + with rsc.settings.override(preset=rsc.Preset.ScanpyV2Preview): + rsc.pp.highly_variable_genes( + AnnData(np.ones((3, 2), dtype=np.float32)), n_top_genes=1 + ) + + assert called["flavor"] == "seurat_v3_paper" + + +def test_scanpy_v2_rank_genes_groups_defaults(): + group = pd.Categorical(["a"] * 30 + ["b"] * 30) + counts = np.vstack( + [ + np.tile([4, 0, 2, 1], (30, 1)), + np.tile([0, 4, 1, 2], (30, 1)), + ] + ).astype(np.float32) + obs = pd.DataFrame({"group": group}, index=[f"cell_{i}" for i in range(len(group))]) + adata = AnnData(np.log1p(counts), obs=obs) + + with rsc.settings.override(preset=rsc.Preset.ScanpyV2Preview): + rsc.tl.rank_genes_groups(adata, "group", method=None) + + params = adata.uns["rank_genes_groups"]["params"] + assert params["method"] == "wilcoxon" + assert params["mean_in_log_space"] is False + + +@pytest.mark.parametrize( + ("preset", "expected_logfc"), + [ + (rsc.Preset.ScanpyV1, np.log2((np.sqrt(5) - 1) / 8)), + (rsc.Preset.ScanpyV2Preview, -2.0), + ], +) +@pytest.mark.parametrize("method", ["t-test", "wilcoxon"]) +@pytest.mark.parametrize("array_type", ["numpy", "scipy_csr", "cupy", "cupy_csr"]) +def test_rank_genes_groups_mean_in_log_space_default( + preset, expected_logfc, method, array_type +): + n_genes = 5 + n_group = 30 + group_a = np.zeros((n_group, n_genes)) + group_a[n_group // 2 :] = np.log(5) + group_b = np.full((n_group, n_genes), np.log(9)) + X = np.concatenate([group_a, group_b]) + X = { + "numpy": lambda: X, + "scipy_csr": lambda: sparse.csr_matrix(X), + "cupy": lambda: cp.asarray(X), + "cupy_csr": lambda: cp_sparse.csr_matrix(cp.asarray(X)), + }[array_type]() + group = pd.Categorical(["a"] * n_group + ["b"] * n_group) + obs = pd.DataFrame( + {"group": group}, index=[f"cell_{i}" for i in range(2 * n_group)] + ) + adata = AnnData(X=X, obs=obs) + + with rsc.settings.override(preset=preset): + rsc.tl.rank_genes_groups( + adata, + groupby="group", + groups=["a"], + reference="b", + method=method, + ) + + logfoldchanges = adata.uns["rank_genes_groups"]["logfoldchanges"]["a"] + np.testing.assert_allclose(logfoldchanges, expected_logfc) + + +def test_scanpy_v2_scale_requires_zero_center(): + adata = AnnData(np.ones((3, 2), dtype=np.float32)) + + with ( + rsc.settings.override(preset=rsc.Preset.ScanpyV2Preview), + pytest.raises(TypeError, match="zero_center"), + ): + rsc.pp.scale(adata) + + +def test_scverse_misc_deprecation_metadata(): + assert rsc.pp.pca.__scverse_misc_deprecated_arg__[0].arg == "use_highly_variable" + assert "rank_genes_groups_logreg" in rsc.tl.rank_genes_groups_logreg.__deprecated__ + + +def test_changing_default_repr_mentions_scanpy_2(): + default = inspect.signature(rsc.pp.pca).parameters["key_added"].default + + with rsc.settings.override(preset=rsc.Preset.ScanpyV1): + assert "changes in 2.0" in repr(default) + with rsc.settings.override(preset=rsc.Preset.ScanpyV2Preview): + assert "changes in 2.0" not in repr(default)