diff --git a/docs/release-notes/1.10.0.md b/docs/release-notes/1.10.0.md index 7d48d4fd9d..e30a95b8ea 100644 --- a/docs/release-notes/1.10.0.md +++ b/docs/release-notes/1.10.0.md @@ -13,6 +13,7 @@ * {func}`~scanpy.pp.scrublet` and {func}`~scanpy.pp.scrublet_simulate_doublets` were moved from {mod}`scanpy.external.pp` to {mod}`scanpy.pp`. The `scrublet` implementation is now maintained as part of scanpy {pr}`2703` {smaller}`P Angerer` * Enhanced dask support for some internal utilities, paving the way for more extensive dask support {pr}`2696` {smaller}`P Angerer` +* {func}`scanpy.tl.dendrogram` now accepts `axis` argument to enable dendrograms on `obs` or `var` {pr}`2771` {smaller}`D Burkhardt` * {func}`scanpy.pp.pca`, {func}`scanpy.pp.scale`, {func}`scanpy.pl.embedding`, and {func}`scanpy.experimental.pp.normalize_pearson_residuals_pca` now support a `mask` parameter {pr}`2272` {smaller}`C Bright, T Marcella, & P Angerer` * {func}`scanpy.tl.rank_genes_groups` no longer warns that it's default was changed from t-test_overestim_var to t-test {pr}`2798` {smaller}`L Heumos` @@ -33,6 +34,7 @@ * Fix {func}`~scanpy.pp.normalize_total` {pr}`2466` {smaller}`P Angerer` * Fix testing package build {pr}`2468` {smaller}`P Angerer` * Fix setting `sc.settings.verbosity` in some cases {pr}`2605` {smaller}`P Angerer` +* Fix `sc.tl.dendrogram` ignoring passed `var_names` {pr}`2771` {smaller}`D Burkhardt` * Fix all remaining pandas warnings {pr}`2789` {smaller}`P Angerer` * Fix some annoying plotting warnings around violin plots {pr}`2844` {smaller}`P Angerer` * Scanpy now has a test job which tests against the minumum versions of the dependencies. In the process of implementing this, many bugs associated with using older versions of `pandas`, `anndata`, `numpy`, and `matplotlib` were fixed. {pr}`2816` {smaller}`I Virshup` diff --git a/scanpy/plotting/_anndata.py b/scanpy/plotting/_anndata.py index 9c48a9a952..fbd93fca66 100755 --- a/scanpy/plotting/_anndata.py +++ b/scanpy/plotting/_anndata.py @@ -21,6 +21,8 @@ from .._compat import old_positionals from .._settings import settings from .._utils import _check_use_raw, _doc_params, sanitize_anndata +from ..tools._dendrogram import _get_dendrogram_key +from ..tools._utils import _resolve_axis from . import _utils from ._docs import ( doc_common_plot_args, @@ -1738,8 +1740,9 @@ def tracksplot( @_doc_params(show_save_ax=doc_show_save_ax) def dendrogram( adata: AnnData, - groupby: str, + groupby: str | Sequence[str] | None = None, *, + axis: Literal["obs", 0, "var", 1] = "obs", dendrogram_key: str | None = None, orientation: Literal["top", "bottom", "left", "right"] = "top", remove_labels: bool = False, @@ -1758,6 +1761,8 @@ def dendrogram( Annotated data matrix. groupby Categorical data column used to create the dendrogram + axis + Axis for which to plot the dendrogram. dendrogram_key Key under with the dendrogram information was stored. By default the dendrogram information is stored under @@ -1788,10 +1793,12 @@ def dendrogram( """ if ax is None: _, ax = plt.subplots() + axis, axis_name = _resolve_axis(axis) _plot_dendrogram( ax, adata, groupby, + axis_name=axis_name, dendrogram_key=dendrogram_key, remove_labels=remove_labels, orientation=orientation, @@ -1867,7 +1874,9 @@ def correlation_matrix( >>> sc.pl.correlation_matrix(adata, 'bulk_labels') """ - dendrogram_key = _get_dendrogram_key(adata, dendrogram, groupby) + dendrogram_key = _get_dendrogram_key( + dendrogram, groupby, axis_name="obs", adata=adata + ) index = adata.uns[dendrogram_key]["categories_idx_ordered"] corr_matrix = adata.uns[dendrogram_key]["correlation_matrix"] @@ -2244,8 +2253,8 @@ def _plot_gene_groups_brackets( def _reorder_categories_after_dendrogram( adata: AnnData, - groupby, - dendrogram, + groupby: str | Sequence[str], + dendrogram: bool | str | None, *, var_names=None, var_group_labels=None, @@ -2270,12 +2279,14 @@ def _reorder_categories_after_dendrogram( 'var_group_labels', and 'var_group_positions' """ - key = _get_dendrogram_key(adata, dendrogram, groupby) + dendrogram_key = _get_dendrogram_key( + dendrogram, groupby, axis_name="obs", adata=adata + ) if isinstance(groupby, str): groupby = [groupby] - dendro_info = adata.uns[key] + dendro_info = adata.uns[dendrogram_key] if groupby != dendro_info["groupby"]: raise ValueError( "Incompatible observations. The precomputed dendrogram contains " @@ -2355,50 +2366,25 @@ def _format_first_three_categories(categories): return ", ".join(categories) -def _get_dendrogram_key(adata, dendrogram_key, groupby): - # the `dendrogram_key` can be a bool an NoneType or the name of the - # dendrogram key. By default the name of the dendrogram key is 'dendrogram' - if not isinstance(dendrogram_key, str): - if isinstance(groupby, str): - dendrogram_key = f"dendrogram_{groupby}" - elif isinstance(groupby, list): - dendrogram_key = f'dendrogram_{"_".join(groupby)}' - - if dendrogram_key not in adata.uns: - from ..tools._dendrogram import dendrogram - - logg.warning( - f"dendrogram data not found (using key={dendrogram_key}). " - "Running `sc.tl.dendrogram` with default parameters. For fine " - "tuning it is recommended to run `sc.tl.dendrogram` independently." - ) - dendrogram(adata, groupby, key_added=dendrogram_key) - - if "dendrogram_info" not in adata.uns[dendrogram_key]: - raise ValueError( - f"The given dendrogram key ({dendrogram_key!r}) does not contain " - "valid dendrogram information." - ) - - return dendrogram_key - - def _plot_dendrogram( dendro_ax: Axes, adata: AnnData, - groupby: str, + groupby: str | Sequence[str] | None, *, + axis_name: Literal["obs", "var"] = "obs", dendrogram_key: str | None = None, orientation: Literal["top", "bottom", "left", "right"] = "right", remove_labels: bool = True, ticks: Collection[float] | None = None, -): +) -> None: """\ Plots a dendrogram on the given ax using the precomputed dendrogram information stored in `.uns[dendrogram_key]` """ - dendrogram_key = _get_dendrogram_key(adata, dendrogram_key, groupby) + dendrogram_key = _get_dendrogram_key( + dendrogram_key, groupby, axis_name=axis_name, adata=adata + ) def translate_pos(pos_list, new_ticks, old_ticks): """\ diff --git a/scanpy/plotting/_baseplot_class.py b/scanpy/plotting/_baseplot_class.py index 10329f16e4..c713753427 100644 --- a/scanpy/plotting/_baseplot_class.py +++ b/scanpy/plotting/_baseplot_class.py @@ -857,7 +857,7 @@ def savefig(self, filename: str, bbox_inches: str | None = "tight", **kwargs): self.make_figure() plt.savefig(filename, bbox_inches=bbox_inches, **kwargs) - def _reorder_categories_after_dendrogram(self, dendrogram) -> None: + def _reorder_categories_after_dendrogram(self, dendrogram_key: str | None) -> None: """\ Function used by plotting functions that need to reorder the the groupby observations based on the dendrogram results. @@ -883,9 +883,11 @@ def _format_first_three_categories(_categories): _categories = _categories[:3] + ["etc."] return ", ".join(_categories) - key = _get_dendrogram_key(self.adata, dendrogram, self.groupby) + dendrogram_key = _get_dendrogram_key( + dendrogram_key, self.groupby, axis_name="obs", adata=self.adata + ) - dendro_info = self.adata.uns[key] + dendro_info = self.adata.uns[dendrogram_key] if self.groupby != dendro_info["groupby"]: raise ValueError( "Incompatible observations. The precomputed dendrogram contains " diff --git a/scanpy/tests/test_dendrogram.py b/scanpy/tests/test_dendrogram.py new file mode 100644 index 0000000000..073ee4988b --- /dev/null +++ b/scanpy/tests/test_dendrogram.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pytest + +import scanpy as sc +from scanpy.testing._helpers.data import pbmc68k_reduced + +n_neighbors = 5 +key = "test" + + +@pytest.mark.parametrize( + ("groupby", "dendrogram_key"), + [ + pytest.param(None, "dendrogram_obs", id="obs"), + pytest.param("bulk_labels", "dendrogram_bulk_labels", id="bulk_labels"), + pytest.param( + ["bulk_labels", "phase"], + "dendrogram_bulk_labels_phase", + id="bulk_labels+phase", + ), + ], +) +@pytest.mark.parametrize( + "key_added", [None, "custom_key"], ids=["derived", "custom_key"] +) +def test_dendrogram_key_added(groupby, dendrogram_key, key_added): + adata = pbmc68k_reduced() + sc.tl.dendrogram(adata, groupby=groupby, key_added=key_added, use_rep="X_pca") + + if key_added is None: + key_added = dendrogram_key + assert key_added in adata.uns + + +def test_dendrogram_var_names(): + adata = pbmc68k_reduced() + sc.tl.dendrogram(adata, var_names=adata.var_names[:10], axis=1) + assert len(adata.uns["dendrogram_var"]["categories_ordered"]) == 10 diff --git a/scanpy/tests/test_dendrogram_key_added.py b/scanpy/tests/test_dendrogram_key_added.py deleted file mode 100644 index 586612962a..0000000000 --- a/scanpy/tests/test_dendrogram_key_added.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -import pytest - -import scanpy as sc -from scanpy.testing._helpers.data import pbmc68k_reduced - -n_neighbors = 5 -key = "test" - - -@pytest.mark.parametrize("groupby", ["bulk_labels", ["bulk_labels", "phase"]]) -@pytest.mark.parametrize("key_added", [None, "custom_key"]) -def test_dendrogram_key_added(groupby, key_added): - adata = pbmc68k_reduced() - sc.tl.dendrogram(adata, groupby=groupby, key_added=key_added, use_rep="X_pca") - if isinstance(groupby, list): - dendrogram_key = f'dendrogram_{"_".join(groupby)}' - else: - dendrogram_key = f"dendrogram_{groupby}" - - if key_added is None: - key_added = dendrogram_key - assert key_added in adata.uns diff --git a/scanpy/tools/_dendrogram.py b/scanpy/tools/_dendrogram.py index 68902a6632..8128af48df 100644 --- a/scanpy/tools/_dendrogram.py +++ b/scanpy/tools/_dendrogram.py @@ -4,7 +4,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Literal import pandas as pd from pandas.api.types import CategoricalDtype @@ -13,11 +14,9 @@ from .._compat import old_positionals from .._utils import _doc_params from ..neighbors._doc import doc_n_pcs, doc_use_rep -from ._utils import _choose_representation +from ._utils import _choose_representation, _resolve_axis if TYPE_CHECKING: - from collections.abc import Sequence - from anndata import AnnData @@ -35,8 +34,9 @@ @_doc_params(n_pcs=doc_n_pcs, use_rep=doc_use_rep) def dendrogram( adata: AnnData, - groupby: str | Sequence[str], + groupby: str | Sequence[str] | None = None, *, + axis: Literal["obs", 0, "var", 1] = "obs", n_pcs: int | None = None, use_rep: str | None = None, var_names: Sequence[str] | None = None, @@ -73,6 +73,10 @@ def dendrogram( ---------- adata Annotated data matrix + groupby + The obs column(s) to use to group observations. Default is None. + axis + Axis along which to calculate the dendrogram. {n_pcs} {use_rep} var_names @@ -116,9 +120,69 @@ def dendrogram( >>> markers = ['C1QA', 'PSAP', 'CD79A', 'CD79B', 'CST3', 'LYZ'] >>> sc.pl.dotplot(adata, markers, groupby='bulk_labels', dendrogram=True) """ - if isinstance(groupby, str): - # if not a list, turn into a list - groupby = [groupby] + axis, axis_name = _resolve_axis(axis) + + if groupby is not None: + if isinstance(groupby, str): + # if not a list, turn into a list + groupby = [groupby] + rep_df, categories = _dendrogram_grouped( + adata, + groupby, + n_pcs=n_pcs, + use_rep=use_rep, + var_names=var_names, + use_raw=use_raw, + ) + else: + rep_df = pd.DataFrame( + _choose_representation( + adata if var_names is None else adata[:, var_names], + use_rep=use_rep, + n_pcs=n_pcs, + ) + ) + categories = rep_df.axes[axis] + + import scipy.cluster.hierarchy as sch + from scipy.spatial import distance + + corr_matrix = (rep_df.T if axis == 0 else rep_df).corr(method=cor_method) + corr_condensed = distance.squareform(1 - corr_matrix) + z_var = sch.linkage( + corr_condensed, method=linkage_method, optimal_ordering=optimal_ordering + ) + dendro_info = sch.dendrogram(z_var, labels=list(categories), no_plot=True) + + dat = dict( + linkage=z_var, + groupby=groupby, + use_rep=use_rep, + cor_method=cor_method, + linkage_method=linkage_method, + categories_ordered=dendro_info["ivl"], + categories_idx_ordered=dendro_info["leaves"], + dendrogram_info=dendro_info, + correlation_matrix=corr_matrix.values, + ) + + if not inplace: + return dat + key_added = _get_dendrogram_key(key_added, groupby, axis_name=axis_name) + logg.info(f"Storing dendrogram info using `.uns[{key_added!r}]`") + adata.uns[key_added] = dat + return None + + +def _dendrogram_grouped( + adata: AnnData, + groupby: Sequence[str], + *, + n_pcs: int | None, + use_rep: str | None, + var_names: Sequence[str] | None, + use_raw: bool | None, +) -> tuple[pd.DataFrame, pd.Index[str]]: for group in groupby: if group not in adata.obs_keys(): raise ValueError( @@ -135,58 +199,62 @@ def dendrogram( rep_df = pd.DataFrame( _choose_representation(adata, use_rep=use_rep, n_pcs=n_pcs) ) - categorical = adata.obs[groupby[0]] - if len(groupby) > 1: - for group in groupby[1:]: - # create new category by merging the given groupby categories - categorical = ( - categorical.astype(str) + "_" + adata.obs[group].astype(str) - ).astype("category") + if len(groupby) == 1: + categorical = adata.obs[groupby[0]] + else: + categorical = adata.obs[groupby].apply("_".join, axis=1).astype("category") categorical.name = "_".join(groupby) rep_df.set_index(categorical, inplace=True) categories = rep_df.index.categories else: - gene_names = adata.raw.var_names if use_raw else adata.var_names - from ..plotting._anndata import _prepare_dataframe + from scanpy.plotting._anndata import _prepare_dataframe categories, rep_df = _prepare_dataframe( - adata, gene_names, groupby, use_raw=use_raw + adata, var_names, groupby, use_raw=use_raw ) # aggregate values within categories using 'mean' - mean_df = ( - rep_df.groupby(level=0, observed=True) - .mean() - .loc[categories] # Fixed ordering for pandas < 2 - ) + rep_df = rep_df.groupby(level=0, observed=True).mean().loc[categories] + return rep_df, categories - import scipy.cluster.hierarchy as sch - from scipy.spatial import distance - corr_matrix = mean_df.T.corr(method=cor_method) - corr_condensed = distance.squareform(1 - corr_matrix) - z_var = sch.linkage( - corr_condensed, method=linkage_method, optimal_ordering=optimal_ordering - ) - dendro_info = sch.dendrogram(z_var, labels=list(categories), no_plot=True) - dat = dict( - linkage=z_var, - groupby=groupby, - use_rep=use_rep, - cor_method=cor_method, - linkage_method=linkage_method, - categories_ordered=dendro_info["ivl"], - categories_idx_ordered=dendro_info["leaves"], - dendrogram_info=dendro_info, - correlation_matrix=corr_matrix.values, - ) +def _get_dendrogram_key( + dendrogram_key: bool | str | None, + groupby: str | Sequence[str] | None, + *, + axis_name: Literal["obs", "var"], + adata: AnnData | None = None, +) -> str: + # the `dendrogram_key` can be a bool an NoneType or the name of the + # dendrogram key. By default the name of the dendrogram key is 'dendrogram' + if not isinstance(dendrogram_key, str): + if groupby is None: + dendrogram_key = f"dendrogram_{axis_name}" + elif isinstance(groupby, str): + dendrogram_key = f"dendrogram_{groupby}" + elif isinstance(groupby, Sequence): + dendrogram_key = f'dendrogram_{"_".join(groupby)}' + else: + msg = "Either `groupby` or `dendrogram_key` must be specified." + raise TypeError(msg) - if inplace: - if key_added is None: - key_added = f'dendrogram_{"_".join(groupby)}' - logg.info(f"Storing dendrogram info using `.uns[{key_added!r}]`") - adata.uns[key_added] = dat - else: - return dat + if adata is None: + return dendrogram_key + + if dendrogram_key not in adata.uns: + logg.warning( + f"dendrogram data not found (using key={dendrogram_key}). " + "Running `sc.tl.dendrogram` with default parameters. For fine " + "tuning it is recommended to run `sc.tl.dendrogram` independently." + ) + dendrogram(adata, groupby, key_added=dendrogram_key) + + if "dendrogram_info" not in adata.uns[dendrogram_key]: + raise ValueError( + f"The given dendrogram key ({dendrogram_key!r}) does not contain " + "valid dendrogram information." + ) + + return dendrogram_key diff --git a/scanpy/tools/_utils.py b/scanpy/tools/_utils.py index 317948caf6..ffc2fca3cb 100644 --- a/scanpy/tools/_utils.py +++ b/scanpy/tools/_utils.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import numpy as np @@ -129,3 +129,13 @@ def get_init_pos_from_paga( else: raise ValueError("Plot PAGA first, so that adata.uns['paga']" "with key 'pos'.") return init_pos + + +def _resolve_axis( + axis: Literal["obs", 0, "var", 1], +) -> tuple[Literal[0], Literal["obs"]] | tuple[Literal[1], Literal["var"]]: + if axis in {0, "obs"}: + return (0, "obs") + if axis in {1, "var"}: + return (1, "var") + raise ValueError(f"`axis` must be either 0, 1, 'obs', or 'var', was {axis!r}")