Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/release-notes/4315.feat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `use_rep` parameter of {func}`scanpy.pp.neighbors`, {func}`scanpy.tl.tsne`, and {func}`scanpy.tl.dendrogram` now accepts {mod}`anndata.acc` accessors such as `A.X`, `A.layers["scaled"]`, or `A.obsm["pca"]`, and resolves strings into them if {attr}`scanpy.settings.preset` is {attr}`~scanpy.Preset.ScanpyV2Preview` {smaller}`P Angerer`
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ scrublet = [ "scikit-image>=0.25" ]
# highly_variable_genes method 'seurat_v3'
skmisc = [ "scikit-misc>=0.5.1" ]
illico = [ "illico>=0.6" ]
scanpy2 = [ "anndata>=0.13.2", "hv-anndata>=0.0.3a5", "igraph>=0.10.8", "scanpy[illico]", "scikit-misc>=0.5.1" ]
scanpy2 = [ "anndata>=0.13.3", "hv-anndata>=0.0.3a5", "igraph>=0.10.8", "scanpy[illico]", "scikit-misc>=0.5.1" ]

[dependency-groups]
dev = [
Expand Down
69 changes: 67 additions & 2 deletions src/scanpy/get/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

from __future__ import annotations

from collections.abc import Collection
import json
from collections.abc import Collection, Sequence
from importlib.util import find_spec
from typing import TYPE_CHECKING, TypedDict, overload

Expand All @@ -19,7 +20,7 @@
from collections.abc import Iterable
from typing import Any, Literal, Unpack

from anndata.acc import Idx2D
from anndata.acc import Idx2D, RefAcc

from .._compat import DaskArray

Expand Down Expand Up @@ -485,6 +486,8 @@ class _Rep(TypedDict, total=False):


type ArrAcc = GraphAcc | LayerAcc | MultiAcc
type RepAcc = LayerAcc | MultiAcc
"""Accessor usable as a representation (`use_rep`), i.e. an obs×n or n×var array."""


@overload
Expand Down Expand Up @@ -790,3 +793,65 @@ def _get_vec(
ref = A.resolve(ref, vec=True)
_ref_dim(ref, dim=dim)
return adata[ref]


def _resolve_rep(rep: RefAcc | str) -> RepAcc:
"""Resolve a `rep`resentation string into a `LayerAcc`/`MultiAcc` using `anndata.acc`."""
if isinstance(rep, str):
from anndata.acc import A

rep = A.resolve(rep, vec=False)
if isinstance(rep, LayerAcc | MultiAcc):
return rep
msg = (
"Representation must be a `LayerAcc` (e.g. `A.X`, `A.layers[...]`) or a "
f"`MultiAcc` (e.g. `A.obsm[...]`, `A.varm[...]`), was {rep!r}"
)
raise TypeError(msg)


def _rep_to_json(rep: RepAcc | str | None) -> str | list[str] | None:
"""Serialize a `rep`resentation for storage in `.uns`.

v1 strings (`'X'` or an `.obsm` key) are stored unchanged,
accessors (and hence v2 strings) as `anndata.acc` JSON inside a 1-element list,
e.g. `A.obsm['pca']` as `['["obsm", "pca"]']`.

TODO: Once AnnData can store a heterogeneous list, store that instead of a 1-element list.
See https://github.com/scverse/anndata/issues/1979
"""
from scanpy import settings

if rep is None or (
isinstance(rep, str) and settings.preset is not Preset.ScanpyV2Preview
):
return rep
from anndata.acc import A

return [json.dumps(A.to_json(_resolve_rep(rep)))]


def _rep_from_json(rep: str | Sequence[str | int | None] | None) -> RepAcc | str | None:
"""Parse a `rep`resentation stored by `_rep_to_json`."""
from scanpy import settings

if rep is None:
return rep
if not isinstance(rep, str):
from anndata.acc import A

if (
isinstance(rep, Sequence | np.ndarray)
and len(rep) == 1
and isinstance(rep[0], str)
):
# see `_rep_to_json`
rep: Sequence[str | int | None] = json.loads(rep[0])
return _resolve_rep(A.from_json(rep, vec=False))
if settings.preset is Preset.ScanpyV2Preview:
from anndata.acc import A

# a plain string was stored under the v1 preset,
# so interpret it as one instead of as an `anndata.acc` spec
return A.X if rep == "X" else A.obsm[rep]
return rep
12 changes: 7 additions & 5 deletions src/scanpy/neighbors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .._keys import _EmbeddingKeys, _existing_preset_keys
from .._utils import NeighborsView, _doc_params, get_literal_vals
from .._utils.random import _accepts_legacy_random_state, _LegacyRng
from ..get.get import _rep_to_json
from . import _connectivity
from ._common import (
_get_indices_distances_from_dense_matrix,
Expand All @@ -44,6 +45,7 @@
from numpy.typing import NDArray

from .._utils.random import RNGLike, SeedLike
from ..get.get import RepAcc
from ._types import (
KnnTransformerLike,
RPForestDict,
Expand All @@ -64,7 +66,7 @@ def neighbors( # noqa: PLR0913
n_pcs: int | None = None,
*,
distances: np.ndarray | SpBase | None = None,
use_rep: str | None = None,
use_rep: RepAcc | str | None = None,
knn: bool = True,
method: _Method = "umap",
transformer: KnnTransformerLike | _KnownTransformer | None = None,
Expand Down Expand Up @@ -249,7 +251,7 @@ def neighbors( # noqa: PLR0913
metric=metric,
**meta_random_state,
**({} if not metric_kwds else dict(metric_kwds=metric_kwds)),
**({} if use_rep is None else dict(use_rep=use_rep)),
**({} if use_rep is None else dict(use_rep=_rep_to_json(use_rep))),
**({} if n_pcs is None else dict(n_pcs=n_pcs)),
)

Expand Down Expand Up @@ -536,7 +538,7 @@ def compute_neighbors(
n_neighbors: int = 30,
n_pcs: int | None = None,
*,
use_rep: str | None = None,
use_rep: RepAcc | str | None = None,
knn: bool = True,
method: _Method | None = "umap",
transformer: KnnTransformerLike | _KnownTransformer | None = None,
Expand Down Expand Up @@ -564,7 +566,7 @@ def compute_neighbors(
if `method` is not `None`, `.connectivities`.

"""
from ..tools._utils import _choose_representation
from ..tools._utils import _choose_representation_compat

start_neighbors = logg.debug("computing neighbors")
if transformer is not None and not isinstance(transformer, str):
Expand All @@ -590,7 +592,7 @@ def compute_neighbors(
self._rp_forest = None
self.n_neighbors = n_neighbors
self.knn = knn
x = _choose_representation(self._adata, use_rep=use_rep, n_pcs=n_pcs)
x = _choose_representation_compat(self._adata, use_rep=use_rep, n_pcs=n_pcs)
self._distances = transformer.fit_transform(x)
knn_indices, knn_distances = _get_indices_distances_from_sparse_matrix(
self._distances, n_neighbors
Expand Down
18 changes: 12 additions & 6 deletions src/scanpy/neighbors/_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@
``.obsp[.uns[neighbors_key]['connectivities_key']]`` for connectivities.
"""

doc_use_rep = """\
use_rep
Use the indicated representation. `'X'` or any key for `.obsm` is valid.
doc_use_rep = r"""use_rep
Use the indicated representation:
a :class:`~anndata.acc.LayerAcc` (e.g. `A.X`, `A.layers[...]`) or
:class:`~anndata.acc.MultiAcc` (e.g. `A.obsm[...]`, `A.varm[...]`).
A :class:`str` is :meth:`~anndata.acc.AdAcc.resolve`\ d to one of those
if :attr:`scanpy.settings.preset` is :attr:`~scanpy.Preset.ScanpyV2Preview`,
otherwise interpreted as `'X'` or a key of `.obsm`.

If `None`, the representation is chosen automatically:
For `.n_vars` < :attr:`~scanpy.settings.N_PCS` (default: 50), `.X` is used, otherwise 'X_pca' is used.
If 'X_pca' is not present, it’s computed with default parameters or `n_pcs` if present.\
"""
For `.n_vars` < :attr:`~scanpy.settings.N_PCS` (default: 50), `.X` is used, otherwise the PCA
representation (`.obsm['X_pca']`, or `.obsm['pca']` if it was computed under
:attr:`~scanpy.Preset.ScanpyV2Preview`).
If it is not present, it’s computed with default parameters or `n_pcs` if present."""
Comment thread
flying-sheep marked this conversation as resolved.

doc_n_pcs = """\
n_pcs
Expand Down
2 changes: 1 addition & 1 deletion src/scanpy/neighbors/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,5 @@ class NeighborsParams(TypedDict):
metric: _Metric | _MetricFn | None
random_state: NotRequired[_LegacyRandom]
metric_kwds: NotRequired[Mapping[str, Any]]
use_rep: NotRequired[str]
use_rep: NotRequired[str | list[str]] # see `scanpy.get.get._rep_to_json`
n_pcs: NotRequired[int]
11 changes: 7 additions & 4 deletions src/scanpy/tools/_dendrogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,26 @@

from .. import logging as logg
from .._utils import _doc_params, raise_not_implemented_error_if_backed_type
from ..get.get import _rep_to_json
from ..neighbors._doc import doc_n_pcs, doc_use_rep
from ._utils import _choose_representation
from ._utils import _choose_representation_compat

if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Any

from anndata import AnnData

from ..get.get import RepAcc


@_doc_params(n_pcs=doc_n_pcs, use_rep=doc_use_rep)
def dendrogram( # noqa: PLR0913
adata: AnnData,
groupby: str | Sequence[str],
*,
n_pcs: int | None = None,
use_rep: str | None = None,
use_rep: RepAcc | str | None = None,
var_names: Sequence[str] | None = None,
use_raw: bool | None = None,
cor_method: str = "pearson",
Expand Down Expand Up @@ -125,7 +128,7 @@ def dendrogram( # noqa: PLR0913

if var_names is None:
rep_df = pd.DataFrame(
_choose_representation(adata, use_rep=use_rep, n_pcs=n_pcs)
_choose_representation_compat(adata, use_rep=use_rep, n_pcs=n_pcs)
)
categorical = adata.obs[groupby[0]]
if len(groupby) > 1:
Expand Down Expand Up @@ -167,7 +170,7 @@ def dendrogram( # noqa: PLR0913
dat = dict(
linkage=z_var,
groupby=groupby,
use_rep=use_rep,
use_rep=_rep_to_json(use_rep),
cor_method=cor_method,
linkage_method=linkage_method,
categories_ordered=dendro_info["ivl"],
Expand Down
34 changes: 22 additions & 12 deletions src/scanpy/tools/_ingest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import contextlib
from collections.abc import MutableMapping
from typing import TYPE_CHECKING

Expand All @@ -21,7 +22,9 @@
from .._utils._doctests import doctest_skipif
from .._utils.random import _legacy_random_state, _LegacyRng
from ..get import _check_mask
from ..get.get import MultiAcc, _rep_from_json
from ..neighbors import FlatTree
from ._utils import _choose_representation_compat

if TYPE_CHECKING:
from collections.abc import Generator, Iterable
Expand All @@ -31,6 +34,7 @@
from umap import UMAP

from .._keys import _EmbeddingKeys
from ..get.get import RepAcc
from ..neighbors import RPForestDict


Expand Down Expand Up @@ -225,7 +229,7 @@ class Ingest:
_rng: np.random.Generator | None
# neighbors
_rep: np.ndarray
_use_rep: str
_use_rep: RepAcc | str
_metric: str
_metric_kwds: dict[str, object]
_n_neighbors: int
Expand Down Expand Up @@ -323,8 +327,10 @@ def _init_neighbors(self, adata: AnnData, neighbors_key: str | None) -> None:
self._n_neighbors = neighbors["params"]["n_neighbors"]

if "use_rep" in neighbors["params"]:
self._use_rep = neighbors["params"]["use_rep"]
self._rep = adata.X if self._use_rep == "X" else adata.obsm[self._use_rep]
self._use_rep = _rep_from_json(neighbors["params"]["use_rep"])
self._rep = _choose_representation_compat(
adata, use_rep=self._use_rep, n_pcs=None
)
elif "n_pcs" in neighbors["params"]:
self._use_rep = "X_pca"
self._n_pcs = neighbors["params"]["n_pcs"]
Expand Down Expand Up @@ -422,10 +428,11 @@ def _same_rep(self):
adata = self._adata_new
if self._n_pcs is not None:
return self._pca(self._n_pcs)
if self._use_rep == "X":
return adata.X
if self._use_rep in adata.obsm:
return adata.obsm[self._use_rep]
# fall back to `.X` if the representation is missing in the new object
with contextlib.suppress(KeyError, ValueError):
return _choose_representation_compat(
adata, use_rep=self._use_rep, n_pcs=None
)
return adata.X

def fit(self, adata_new: AnnData) -> None:
Expand Down Expand Up @@ -558,11 +565,14 @@ def to_adata_joint(
self._obsm[key],
))

if self._use_rep not in ("X_pca", "X"):
adata.obsm[self._use_rep] = np.vstack((
self._adata_ref.obsm[self._use_rep],
self._obsm["rep"],
))
pca_keys = _existing_preset_keys(self._adata_ref, "pca")
skip = {"X", pca_keys.obsm if pca_keys else "X_pca"}
match self._use_rep:
case MultiAcc(dim="obs", k=key) | str(key) if key not in skip:
adata.obsm[key] = np.vstack((
self._adata_ref.obsm[key],
self._obsm["rep"],
))

if keys := _existing_preset_keys(self._adata_ref, "umap"):
adata.uns[keys.uns] = self._adata_ref.uns[keys.uns]
Expand Down
10 changes: 6 additions & 4 deletions src/scanpy/tools/_tsne.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
from .._settings import Default, settings
from .._utils import _doc_params, raise_not_implemented_error_if_backed_type
from .._utils.random import _accepts_legacy_random_state, _legacy_random_state
from ..get.get import _rep_to_json
from ..neighbors._doc import doc_n_pcs, doc_use_rep
from ._utils import _choose_representation
from ._utils import _choose_representation_compat

if TYPE_CHECKING:
from anndata import AnnData

from .._utils.random import RNGLike, SeedLike
from ..get.get import RepAcc


@_accepts_legacy_random_state(0)
Expand All @@ -25,7 +27,7 @@ def tsne( # noqa: PLR0913
n_pcs: int | None = None,
*,
n_components: int = 2,
use_rep: str | None = None,
use_rep: RepAcc | str | None = None,
perplexity: float = 30,
metric: str = "euclidean",
early_exaggeration: float = 12,
Expand Down Expand Up @@ -105,7 +107,7 @@ def tsne( # noqa: PLR0913
start = logg.info("computing tSNE")
keys = _embedding_keys("tsne", key_added)
adata = adata.copy() if copy else adata
x = _choose_representation(adata, use_rep=use_rep, n_pcs=n_pcs)
x = _choose_representation_compat(adata, use_rep=use_rep, n_pcs=n_pcs)
raise_not_implemented_error_if_backed_type(x, "tsne")
# params for sklearn
n_jobs = settings.n_jobs if n_jobs is None else n_jobs
Expand Down Expand Up @@ -156,7 +158,7 @@ def tsne( # noqa: PLR0913
learning_rate=learning_rate,
n_jobs=n_jobs,
metric=metric,
use_rep=use_rep,
use_rep=_rep_to_json(use_rep),
n_components=n_components,
)
adata.obsm[keys.obsm] = x_tsne # annotate samples with tSNE coordinates
Expand Down
7 changes: 4 additions & 3 deletions src/scanpy/tools/_umap.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
_legacy_random_state,
_LegacyRng,
)
from ._utils import _choose_representation, get_init_pos_from_paga
from ..get.get import _rep_from_json
from ._utils import _choose_representation_compat, get_init_pos_from_paga

if TYPE_CHECKING:
from typing import Literal
Expand Down Expand Up @@ -182,9 +183,9 @@ def umap( # noqa: PLR0913
init_coords = check_array(init_coords, dtype=np.float32, accept_sparse=False)

neigh_params = neighbors["params"]
x = _choose_representation(
x = _choose_representation_compat(
adata,
use_rep=neigh_params.get("use_rep", None),
use_rep=_rep_from_json(neigh_params.get("use_rep", None)),
n_pcs=neigh_params.get("n_pcs", None),
silent=True,
)
Expand Down
Loading
Loading