From 89dcf21e858702d6aa35dedd44dfcb9a831ad76a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:22:46 +0200 Subject: [PATCH 01/66] [pre-commit.ci] pre-commit autoupdate (#3207) 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 7dab13996e..63c9eafbae 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.5.6 + rev: v0.5.7 hooks: - id: ruff types_or: [python, pyi, jupyter] From b6193502e11b84fc1b4a011ee9cf08a19da22ebf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 13:08:03 +0200 Subject: [PATCH 02/66] [pre-commit.ci] pre-commit autoupdate (#3210) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- .../scanpy/_pytest/fixtures/__init__.py | 2 +- src/testing/scanpy/_pytest/fixtures/data.py | 4 ++-- tests/conftest.py | 8 ++++---- tests/test_aggregated.py | 6 +++--- tests/test_binary.py | 2 +- tests/test_clustering.py | 2 +- tests/test_datasets.py | 18 +++++++++--------- tests/test_embedding_plots.py | 2 +- tests/test_get.py | 2 +- tests/test_highly_variable_genes.py | 2 +- tests/test_ingest.py | 2 +- tests/test_neighbors.py | 2 +- tests/test_neighbors_key_added.py | 2 +- tests/test_package_structure.py | 2 +- tests/test_paga.py | 2 +- tests/test_pca.py | 2 +- tests/test_plotting.py | 6 +++--- tests/test_preprocessing_distributed.py | 2 +- tests/test_qc_metrics.py | 2 +- tests/test_queries.py | 4 ++-- tests/test_scrublet.py | 2 +- 22 files changed, 39 insertions(+), 39 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63c9eafbae..26fb8aebea 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.5.7 + rev: v0.6.1 hooks: - id: ruff types_or: [python, pyi, jupyter] diff --git a/src/testing/scanpy/_pytest/fixtures/__init__.py b/src/testing/scanpy/_pytest/fixtures/__init__.py index db0d525589..7578473786 100644 --- a/src/testing/scanpy/_pytest/fixtures/__init__.py +++ b/src/testing/scanpy/_pytest/fixtures/__init__.py @@ -37,7 +37,7 @@ def float_dtype(request): return request.param -@pytest.fixture() +@pytest.fixture def _doctest_env(cache: pytest.Cache, tmp_path: Path) -> Generator[None, None, None]: from scanpy._compat import chdir diff --git a/src/testing/scanpy/_pytest/fixtures/data.py b/src/testing/scanpy/_pytest/fixtures/data.py index 75ecd2a81e..d2be706076 100644 --- a/src/testing/scanpy/_pytest/fixtures/data.py +++ b/src/testing/scanpy/_pytest/fixtures/data.py @@ -50,12 +50,12 @@ def pbmc3ks_parametrized_session(request) -> dict[bool, AnnData]: } -@pytest.fixture() +@pytest.fixture def pbmc3k_parametrized(pbmc3ks_parametrized_session) -> Callable[[], AnnData]: return pbmc3ks_parametrized_session[False].copy -@pytest.fixture() +@pytest.fixture def pbmc3k_parametrized_small(pbmc3ks_parametrized_session) -> Callable[[], AnnData]: return pbmc3ks_parametrized_session[True].copy diff --git a/tests/conftest.py b/tests/conftest.py index 82a8e4166e..52bc61168a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,7 +56,7 @@ def _caplog_adapter(caplog: pytest.LogCaptureFixture) -> Generator[None, None, N sc.settings._root_logger.removeHandler(caplog.handler) -@pytest.fixture() +@pytest.fixture def imported_modules(): return IMPORTED @@ -69,7 +69,7 @@ class CompareResult(TypedDict): tol: int -@pytest.fixture() +@pytest.fixture def check_same_image(add_nunit_attachment): from urllib.parse import quote @@ -117,7 +117,7 @@ def fmt_descr(descr): return check_same_image -@pytest.fixture() +@pytest.fixture def image_comparer(check_same_image): from matplotlib import pyplot as plt @@ -139,7 +139,7 @@ def save_and_compare(*path_parts: Path | os.PathLike, tol: int): return save_and_compare -@pytest.fixture() +@pytest.fixture def plt(): from matplotlib import pyplot as plt diff --git a/tests/test_aggregated.py b/tests/test_aggregated.py index f16bc80a48..ce680b8df5 100644 --- a/tests/test_aggregated.py +++ b/tests/test_aggregated.py @@ -22,13 +22,13 @@ def metric(request: pytest.FixtureRequest) -> AggType: return request.param -@pytest.fixture() +@pytest.fixture def df_base(): ax_base = ["A", "B"] return pd.DataFrame(index=ax_base) -@pytest.fixture() +@pytest.fixture def df_groupby(): ax_groupby = [ *["v0", "v1", "v2"], @@ -49,7 +49,7 @@ def df_groupby(): return df_groupby -@pytest.fixture() +@pytest.fixture def X(): data = [ *[[0, -2], [1, 13], [2, 1]], # v diff --git a/tests/test_binary.py b/tests/test_binary.py index 4e1ce7e790..2cf1aa1bee 100644 --- a/tests/test_binary.py +++ b/tests/test_binary.py @@ -19,7 +19,7 @@ HERE = Path(__file__).parent -@pytest.fixture() +@pytest.fixture def _set_path(monkeypatch: MonkeyPatch) -> None: monkeypatch.setenv("PATH", str(HERE / "_scripts"), prepend=os.pathsep) diff --git a/tests/test_clustering.py b/tests/test_clustering.py index e87b586325..260513ff45 100644 --- a/tests/test_clustering.py +++ b/tests/test_clustering.py @@ -8,7 +8,7 @@ from testing.scanpy._pytest.marks import needs -@pytest.fixture() +@pytest.fixture def adata_neighbors(): return pbmc68k_reduced() diff --git a/tests/test_datasets.py b/tests/test_datasets.py index e46e68e435..4bad3800d7 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -33,7 +33,7 @@ def _tmp_dataset_dir(tmp_path: Path) -> None: sc.settings.datasetdir = tmp_path / "scanpy_data" -@pytest.mark.internet() +@pytest.mark.internet def test_burczynski06(): with pytest.warns(UserWarning, match=r"Variable names are not unique"): adata = sc.datasets.burczynski06() @@ -41,7 +41,7 @@ def test_burczynski06(): assert not (adata.X == 0).any() -@pytest.mark.internet() +@pytest.mark.internet @needs.openpyxl def test_moignard15(): with warnings.catch_warnings(): @@ -56,19 +56,19 @@ def test_moignard15(): assert adata.shape == (3934, 42) -@pytest.mark.internet() +@pytest.mark.internet def test_paul15(): sc.datasets.paul15() -@pytest.mark.internet() +@pytest.mark.internet def test_pbmc3k(): adata = sc.datasets.pbmc3k() assert adata.shape == (2700, 32738) assert "CD8A" in adata.var_names -@pytest.mark.internet() +@pytest.mark.internet def test_pbmc3k_processed(): with warnings.catch_warnings(record=True) as records: adata = sc.datasets.pbmc3k_processed() @@ -78,7 +78,7 @@ def test_pbmc3k_processed(): assert len(records) == 0 -@pytest.mark.internet() +@pytest.mark.internet def test_ebi_expression_atlas(): adata = sc.datasets.ebi_expression_atlas("E-MTAB-4888") # The shape changes sometimes @@ -111,7 +111,7 @@ def test_pbmc68k_reduced(): sc.datasets.pbmc68k_reduced() -@pytest.mark.internet() +@pytest.mark.internet def test_visium_datasets(): """Tests that reading/ downloading works and is does not have global effects.""" with pytest.warns(UserWarning, match=r"Variable names are not unique"): @@ -121,7 +121,7 @@ def test_visium_datasets(): assert_adata_equal(hheart, hheart_again) -@pytest.mark.internet() +@pytest.mark.internet def test_visium_datasets_dir_change(tmp_path: Path): """Test that changing the dataset dir doesn't break reading.""" with pytest.warns(UserWarning, match=r"Variable names are not unique"): @@ -132,7 +132,7 @@ def test_visium_datasets_dir_change(tmp_path: Path): assert_adata_equal(mbrain, mbrain_again) -@pytest.mark.internet() +@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 index 3c1bab6d51..d48f44b2b6 100644 --- a/tests/test_embedding_plots.py +++ b/tests/test_embedding_plots.py @@ -85,7 +85,7 @@ def adata(): return adata -@pytest.fixture() +@pytest.fixture def fixture_request(request): """Returns a Request object. diff --git a/tests/test_get.py b/tests/test_get.py index 4eff3ee23f..673b26787d 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -33,7 +33,7 @@ def transpose_adata(adata: AnnData, *, expect_duplicates: bool = False) -> AnnDa ) -@pytest.fixture() +@pytest.fixture def adata(): """ adata.X is np.ones((2, 2)) diff --git a/tests/test_highly_variable_genes.py b/tests/test_highly_variable_genes.py index 8b87074fe1..f3b9298505 100644 --- a/tests/test_highly_variable_genes.py +++ b/tests/test_highly_variable_genes.py @@ -36,7 +36,7 @@ def adata_sess() -> AnnData: return adata -@pytest.fixture() +@pytest.fixture def adata(adata_sess: AnnData) -> AnnData: return adata_sess.copy() diff --git a/tests/test_ingest.py b/tests/test_ingest.py index 31f38a40a7..2c57342de1 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -26,7 +26,7 @@ T = np.array([[2.0, 3.5, 4.0, 1.0, 4.7], [3.2, 2.0, 5.0, 5.0, 8.0]], dtype=np.float32) -@pytest.fixture() +@pytest.fixture def adatas(): pbmc = pbmc68k_reduced() n_split = 500 diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index 1cf74a8d8f..806594ff8d 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -119,7 +119,7 @@ def get_neighbors() -> Neighbors: return Neighbors(anndata_v0_8_constructor_compat(np.array(X))) -@pytest.fixture() +@pytest.fixture def neigh() -> Neighbors: return get_neighbors() diff --git a/tests/test_neighbors_key_added.py b/tests/test_neighbors_key_added.py index 4bc4a068f9..1da87f8ba9 100644 --- a/tests/test_neighbors_key_added.py +++ b/tests/test_neighbors_key_added.py @@ -11,7 +11,7 @@ key = "test" -@pytest.fixture() +@pytest.fixture def adata(): return sc.AnnData(pbmc68k_reduced().X) diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py index 58df6515e9..19a6836e65 100644 --- a/tests/test_package_structure.py +++ b/tests/test_package_structure.py @@ -53,7 +53,7 @@ ] -@pytest.fixture() +@pytest.fixture def in_project_dir(): wd_orig = Path.cwd() os.chdir(proj_dir) diff --git a/tests/test_paga.py b/tests/test_paga.py index cbbb3095fc..d8de573fee 100644 --- a/tests/test_paga.py +++ b/tests/test_paga.py @@ -27,7 +27,7 @@ def pbmc_session(): return pbmc -@pytest.fixture() +@pytest.fixture def pbmc(pbmc_session): return pbmc_session.copy() diff --git a/tests/test_pca.py b/tests/test_pca.py index 54c21a1391..bebb752998 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -110,7 +110,7 @@ def zero_center(request: pytest.FixtureRequest): return request.param -@pytest.fixture() +@pytest.fixture def pca_params( array_type, svd_solver_type: Literal[None, "valid", "invalid"], zero_center ): diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 3b86a9e0d5..b2d14b52a7 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -849,7 +849,7 @@ def gene_symbols_adatas_session() -> tuple[AnnData, AnnData]: return a, b -@pytest.fixture() +@pytest.fixture def gene_symbols_adatas(gene_symbols_adatas_session) -> tuple[AnnData, AnnData]: a, b = gene_symbols_adatas_session return a.copy(), b.copy() @@ -993,7 +993,7 @@ def pbmc_scatterplots_session() -> AnnData: return pbmc -@pytest.fixture() +@pytest.fixture def pbmc_scatterplots(pbmc_scatterplots_session) -> AnnData: return pbmc_scatterplots_session.copy() @@ -1352,7 +1352,7 @@ def test_scatter_no_basis_per_var(image_comparer): save_and_compare_images("scatter_AAAGCCTGGCTAAC-1_vs_AAATTCGATGCACA-1") -@pytest.fixture() +@pytest.fixture def pbmc_filtered() -> Callable[[], AnnData]: pbmc = pbmc68k_reduced() sc.pp.filter_genes(pbmc, min_cells=10) diff --git a/tests/test_preprocessing_distributed.py b/tests/test_preprocessing_distributed.py index f0451fb07b..a1b99121ef 100644 --- a/tests/test_preprocessing_distributed.py +++ b/tests/test_preprocessing_distributed.py @@ -31,7 +31,7 @@ pytestmark = [needs.zarr] -@pytest.fixture() +@pytest.fixture @filter_oldformatwarning def adata() -> AnnData: a = read_zarr(input_file) diff --git a/tests/test_qc_metrics.py b/tests/test_qc_metrics.py index 16449a5eed..83971fa2ce 100644 --- a/tests/test_qc_metrics.py +++ b/tests/test_qc_metrics.py @@ -15,7 +15,7 @@ ) -@pytest.fixture() +@pytest.fixture def anndata(): a = np.random.binomial(100, 0.005, (1000, 1000)) adata = AnnData( diff --git a/tests/test_queries.py b/tests/test_queries.py index 769b7aa2d1..d25df9d331 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -8,7 +8,7 @@ from testing.scanpy._pytest.marks import needs -@pytest.mark.internet() +@pytest.mark.internet @needs.gprofiler def test_enrich(): pbmc = pbmc68k_reduced() @@ -33,7 +33,7 @@ def test_enrich(): assert "set2" in enrich_list["query"].unique() -@pytest.mark.internet() +@pytest.mark.internet @needs.pybiomart def test_mito_genes(): pbmc = pbmc68k_reduced() diff --git a/tests/test_scrublet.py b/tests/test_scrublet.py index d8df27d710..dc08d24837 100644 --- a/tests/test_scrublet.py +++ b/tests/test_scrublet.py @@ -176,7 +176,7 @@ def scrub_small_sess() -> AnnData: return adata -@pytest.fixture() +@pytest.fixture def scrub_small(scrub_small_sess: AnnData): return scrub_small_sess.copy() From 8159592a70688488815c4e3120d9920ab7e3e4b9 Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Fri, 30 Aug 2024 17:33:46 +0200 Subject: [PATCH 03/66] (fix): Upper bound `dask` (#3217) Co-authored-by: Philipp A. --- docs/release-notes/1.10.3.md | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/1.10.3.md b/docs/release-notes/1.10.3.md index b7bd113d3c..5fcdc4400a 100644 --- a/docs/release-notes/1.10.3.md +++ b/docs/release-notes/1.10.3.md @@ -13,5 +13,6 @@ * Fix `legend_loc` argument in {func}`scanpy.pl.embedding` not accepting matplotlib parameters {pr}`3163` {smaller}`P Angerer` * Fix dispersion cutoff in {func}`~scanpy.pp.highly_variable_genes` in presence of `NaN`s {pr}`3176` {smaller}`P Angerer` * Fix axis labeling for swapped axes in {func}`~scanpy.pl.rank_genes_groups_stacked_violin` {pr}`3196` {smaller}`Ilan Gold` +* Upper bound dask on account of {issue}`scverse/anndata#1579` {pr}`3217` {smaller}`Ilan Gold` #### Performance diff --git a/pyproject.toml b/pyproject.toml index e9311a47b8..cde2ce89fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,7 +148,7 @@ scanorama = ["scanorama"] # Scanorama dataset integration scrublet = ["scikit-image"] # Doublet detection with automatic thresholds # Acceleration rapids = ["cudf>=0.9", "cuml>=0.9", "cugraph>=0.9"] # GPU accelerated calculation of neighbors -dask = ["dask[array]>=2022.09.2"] # Use the Dask parallelization engine +dask = ["dask[array]>=2022.09.2,<2024.8.0"] # Use the Dask parallelization engine dask-ml = ["dask-ml", "scanpy[dask]"] # Dask-ML for sklearn-like API [tool.hatch.build.targets.wheel] From 3c1349529fa0c0385c0f6bbc313ceb9bcdc84b8d Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Mon, 2 Sep 2024 09:30:39 +0200 Subject: [PATCH 04/66] Update notebooks (#3216) --- docs/extensions/canonical_tutorial.py | 24 ++++++++++++++++++++++++ docs/how-to/knn-transformers.ipynb | 2 +- docs/how-to/plotting-with-marsilea.ipynb | 2 +- notebooks | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 docs/extensions/canonical_tutorial.py diff --git a/docs/extensions/canonical_tutorial.py b/docs/extensions/canonical_tutorial.py new file mode 100644 index 0000000000..b459fbb059 --- /dev/null +++ b/docs/extensions/canonical_tutorial.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sphinx.util.docutils import SphinxDirective + +if TYPE_CHECKING: + from typing import ClassVar + + from docutils import nodes + from sphinx.application import Sphinx + + +class CanonicalTutorial(SphinxDirective): + """In the scanpy-tutorials repo, this links to the canonical location (here!).""" + + required_arguments: ClassVar = 1 + + def run(self) -> list[nodes.Node]: + return [] + + +def setup(app: Sphinx) -> None: + app.add_directive("canonical-tutorial", CanonicalTutorial) diff --git a/docs/how-to/knn-transformers.ipynb b/docs/how-to/knn-transformers.ipynb index 74ae4f265b..f97950c67e 120000 --- a/docs/how-to/knn-transformers.ipynb +++ b/docs/how-to/knn-transformers.ipynb @@ -1 +1 @@ -../../notebooks/knn-transformers.ipynb \ No newline at end of file +../../notebooks/how-to/knn-transformers.ipynb \ No newline at end of file diff --git a/docs/how-to/plotting-with-marsilea.ipynb b/docs/how-to/plotting-with-marsilea.ipynb index 45b62b725a..1deff1bb72 120000 --- a/docs/how-to/plotting-with-marsilea.ipynb +++ b/docs/how-to/plotting-with-marsilea.ipynb @@ -1 +1 @@ -../../notebooks/plotting-with-marsilea.ipynb \ No newline at end of file +../../notebooks/how-to/plotting-with-marsilea.ipynb \ No newline at end of file diff --git a/notebooks b/notebooks index 02c4946e0b..3385df77ce 160000 --- a/notebooks +++ b/notebooks @@ -1 +1 @@ -Subproject commit 02c4946e0be47e033355ef84b2a4909b302d2513 +Subproject commit 3385df77ce0f63987104bc644562a811c5d1b441 From bec794c7e7e28393e7cb6ae6624ecdbd187868ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 20:33:14 +0200 Subject: [PATCH 05/66] [pre-commit.ci] pre-commit autoupdate (#3213) 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 26fb8aebea..5cf457c07d 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.1 + rev: v0.6.3 hooks: - id: ruff types_or: [python, pyi, jupyter] From d4e1fb4cb290d9835710fba2b5b9594d97176601 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Sep 2024 10:17:52 +0200 Subject: [PATCH 06/66] [pre-commit.ci] pre-commit autoupdate (#3225) 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 5cf457c07d..ef3d2e951e 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.3 + rev: v0.6.4 hooks: - id: ruff types_or: [python, pyi, jupyter] From 9d8b1648daca94a7d879fdc9a84600d5611817e2 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Fri, 13 Sep 2024 11:57:05 +0200 Subject: [PATCH 07/66] fa2 library changed to fa2_modified (#3220) Co-authored-by: Philipp A. --- docs/release-notes/1.10.3.md | 4 + src/scanpy/plotting/_tools/paga.py | 65 ++++--------- src/scanpy/plotting/_tools/scatterplots.py | 4 +- src/scanpy/plotting/_utils.py | 60 +++++++----- src/scanpy/tools/_draw_graph.py | 103 ++++++++++++--------- 5 files changed, 122 insertions(+), 114 deletions(-) diff --git a/docs/release-notes/1.10.3.md b/docs/release-notes/1.10.3.md index 5fcdc4400a..6ba996730c 100644 --- a/docs/release-notes/1.10.3.md +++ b/docs/release-notes/1.10.3.md @@ -14,5 +14,9 @@ * Fix dispersion cutoff in {func}`~scanpy.pp.highly_variable_genes` in presence of `NaN`s {pr}`3176` {smaller}`P Angerer` * Fix axis labeling for swapped axes in {func}`~scanpy.pl.rank_genes_groups_stacked_violin` {pr}`3196` {smaller}`Ilan Gold` * Upper bound dask on account of {issue}`scverse/anndata#1579` {pr}`3217` {smaller}`Ilan Gold` +* The [fa2-modified][] package replaces [forceatlas2][] for the latter’s lack of maintenance. {pr}`3220` {smaller}`A Alam` + +[fa2-modified]: https://github.com/AminAlam/fa2_modified +[forceatlas2]: https://github.com/bhargavchippada/forceatlas2 #### Performance diff --git a/src/scanpy/plotting/_tools/paga.py b/src/scanpy/plotting/_tools/paga.py index 49fe1c9d2c..6ea2560b10 100644 --- a/src/scanpy/plotting/_tools/paga.py +++ b/src/scanpy/plotting/_tools/paga.py @@ -16,6 +16,8 @@ from scipy.sparse import issparse from sklearn.utils import check_random_state +from scanpy.tools._draw_graph import coerce_fa2_layout, fa2_positions + from ... import _utils as _sc_utils from ... import logging as logg from ..._compat import old_positionals @@ -25,13 +27,17 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence - from typing import Any, Literal + from typing import Any, Literal, Union from anndata import AnnData from matplotlib.axes import Axes from matplotlib.colors import Colormap + from scipy.sparse import spmatrix + + from ...tools._draw_graph import _Layout as _LayoutWithoutEqTree + from .._utils import _FontSize, _FontWeight, _LegendLoc - from .._utils import _FontSize, _FontWeight, _IGraphLayout, _LegendLoc + _Layout = Union[_LayoutWithoutEqTree, Literal["eq_tree"]] @old_positionals( @@ -202,13 +208,13 @@ def paga_compare( def _compute_pos( - adjacency_solid, + adjacency_solid: spmatrix | np.ndarray, *, - layout=None, - random_state=0, - init_pos=None, + layout: _Layout | None = None, + random_state: _sc_utils.AnyRandom = 0, + init_pos: np.ndarray | None = None, adj_tree=None, - root=0, + root: int = 0, layout_kwds: Mapping[str, Any] = MappingProxyType({}), ): import random @@ -220,50 +226,15 @@ def _compute_pos( nx_g_solid = nx.Graph(adjacency_solid) if layout is None: layout = "fr" - if layout == "fa": - try: - from fa2 import ForceAtlas2 - except ImportError: - logg.warning( - "Package 'fa2' is not installed, falling back to layout 'fr'." - "To use the faster and better ForceAtlas2 layout, " - "install package 'fa2' (`pip install fa2`)." - ) - layout = "fr" + layout = coerce_fa2_layout(layout) if layout == "fa": # np.random.seed(random_state) if init_pos is None: init_coords = random_state.random_sample((adjacency_solid.shape[0], 2)) else: init_coords = init_pos.copy() - forceatlas2 = ForceAtlas2( - # Behavior alternatives - outboundAttractionDistribution=False, # Dissuade hubs - linLogMode=False, # NOT IMPLEMENTED - adjustSizes=False, # Prevent overlap (NOT IMPLEMENTED) - edgeWeightInfluence=1.0, - # Performance - jitterTolerance=1.0, # Tolerance - barnesHutOptimize=True, - barnesHutTheta=1.2, - multiThreaded=False, # NOT IMPLEMENTED - # Tuning - scalingRatio=2.0, - strongGravityMode=False, - gravity=1.0, - # Log - verbose=False, - ) - if "maxiter" in layout_kwds: - iterations = layout_kwds["maxiter"] - elif "iterations" in layout_kwds: - iterations = layout_kwds["iterations"] - else: - iterations = 500 - pos_list = forceatlas2.forceatlas2( - adjacency_solid, pos=init_coords, iterations=iterations - ) - pos = {n: [p[0], -p[1]] for n, p in enumerate(pos_list)} + pos_list = fa2_positions(adjacency_solid, init_coords, **layout_kwds) + pos = {n: (x, -y) for n, (x, y) in enumerate(pos_list)} elif layout == "eq_tree": nx_g_tree = nx.Graph(adj_tree) pos = _utils.hierarchy_pos(nx_g_tree, root) @@ -302,7 +273,7 @@ def _compute_pos( ).coords except AttributeError: # hack for empty graphs... pos_list = g.layout(layout, seed=init_coords, **layout_kwds).coords - pos = {n: [p[0], -p[1]] for n, p in enumerate(pos_list)} + pos = {n: (x, -y) for n, (x, y) in enumerate(pos_list)} if len(pos) == 1: pos[0] = (0.5, 0.5) pos_array = np.array([pos[n] for count, n in enumerate(nx_g_solid)]) @@ -333,7 +304,7 @@ def paga( *, threshold: float | None = None, color: str | Mapping[str | int, Mapping[Any, float]] | None = None, - layout: _IGraphLayout | None = None, + layout: _Layout | None = None, layout_kwds: Mapping[str, Any] = MappingProxyType({}), init_pos: np.ndarray | None = None, root: int | str | Sequence[int] | None = 0, diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index ca44014459..4f2b208ef1 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -38,6 +38,7 @@ sanitize_anndata, ) from ...get import _check_mask +from ...tools._draw_graph import _Layout # noqa: TCH001 from .. import _utils from .._docs import ( doc_adata_color_etc, @@ -51,7 +52,6 @@ VBound, # noqa: TCH001 _FontSize, # noqa: TCH001 _FontWeight, # noqa: TCH001 - _IGraphLayout, # noqa: TCH001 _LegendLoc, # noqa: TCH001 check_colornorm, check_projection, @@ -779,7 +779,7 @@ def diffmap(adata: AnnData, **kwargs) -> Figure | Axes | list[Axes] | None: show_save_ax=doc_show_save_ax, ) def draw_graph( - adata: AnnData, *, layout: _IGraphLayout | None = None, **kwargs + adata: AnnData, *, layout: _Layout | None = None, **kwargs ) -> Figure | Axes | list[Axes] | None: """\ Scatter plot in graph-drawing basis. diff --git a/src/scanpy/plotting/_utils.py b/src/scanpy/plotting/_utils.py index c26cc121b1..44e85c5c68 100644 --- a/src/scanpy/plotting/_utils.py +++ b/src/scanpy/plotting/_utils.py @@ -3,7 +3,7 @@ import collections.abc as cabc import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING, Callable, Literal, Union +from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union import matplotlib as mpl import numpy as np @@ -23,7 +23,7 @@ from . import palettes if TYPE_CHECKING: - from collections.abc import Collection + from collections.abc import Collection, Mapping from anndata import AnnData from matplotlib.colors import Colormap @@ -36,7 +36,6 @@ DensityNorm = Literal["area", "count", "width"] # These are needed by _wraps_plot_scatter -_IGraphLayout = Literal["fa", "fr", "rt", "rt_circular", "drl", "eq_tree"] VBound = Union[str, float, Callable[[Sequence[float]], float]] _FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"] _FontSize = Literal[ @@ -970,7 +969,14 @@ def scale_to_zero_one(x): return xscaled -def hierarchy_pos(G, root, levels=None, width=1.0, height=1.0): +class _Level(TypedDict): + total: int + current: int + + +def hierarchy_pos( + G, root: int, levels_: Mapping[int, int] | None = None, width=1.0, height=1.0 +) -> dict[int, tuple[float, float]]: """Tree layout for networkx graph. See https://stackoverflow.com/questions/29586520/can-one-get-hierarchical-graphs-from-networkx-with-python-3 @@ -989,37 +995,47 @@ def hierarchy_pos(G, root, levels=None, width=1.0, height=1.0): width: horizontal space allocated for drawing height: vertical space allocated for drawing """ - TOTAL = "total" - CURRENT = "current" - def make_levels(levels, node=root, currentLevel=0, parent=None): + def make_levels( + levels: dict[int, _Level], + node: int = root, + current_level: int = 0, + parent: int | None = None, + ) -> dict[int, _Level]: """Compute the number of nodes for each level""" - if currentLevel not in levels: - levels[currentLevel] = {TOTAL: 0, CURRENT: 0} - levels[currentLevel][TOTAL] += 1 - neighbors = list(G.neighbors(node)) + if current_level not in levels: + levels[current_level] = _Level(total=0, current=0) + levels[current_level]["total"] += 1 + neighbors: list[int] = list(G.neighbors(node)) if parent is not None: neighbors.remove(parent) for neighbor in neighbors: - levels = make_levels(levels, neighbor, currentLevel + 1, node) + levels = make_levels(levels, neighbor, current_level + 1, node) return levels - def make_pos(pos, node=root, currentLevel=0, parent=None, vert_loc=0): - dx = 1 / levels[currentLevel][TOTAL] + if levels_ is None: + levels = make_levels({}) + else: + levels = {k: _Level(total=0, current=0) for k, v in levels_.items()} + + def make_pos( + pos: dict[int, tuple[float, float]], + node: int = root, + current_level: int = 0, + parent: int | None = None, + vert_loc: float = 0.0, + ): + dx = 1 / levels[current_level]["total"] left = dx / 2 - pos[node] = ((left + dx * levels[currentLevel][CURRENT]) * width, vert_loc) - levels[currentLevel][CURRENT] += 1 - neighbors = list(G.neighbors(node)) + pos[node] = ((left + dx * levels[current_level]["current"]) * width, vert_loc) + levels[current_level]["current"] += 1 + neighbors: list[int] = list(G.neighbors(node)) if parent is not None: neighbors.remove(parent) for neighbor in neighbors: - pos = make_pos(pos, neighbor, currentLevel + 1, node, vert_loc - vert_gap) + pos = make_pos(pos, neighbor, current_level + 1, node, vert_loc - vert_gap) return pos - if levels is None: - levels = make_levels({}) - else: - levels = {k: {TOTAL: v, CURRENT: 0} for k, v in levels.items()} vert_gap = height / (max(levels.keys()) + 1) return make_pos({}) diff --git a/src/scanpy/tools/_draw_graph.py b/src/scanpy/tools/_draw_graph.py index 9d922fe49e..b36638b5b5 100644 --- a/src/scanpy/tools/_draw_graph.py +++ b/src/scanpy/tools/_draw_graph.py @@ -1,6 +1,7 @@ from __future__ import annotations import random +from importlib.util import find_spec from typing import TYPE_CHECKING, Literal, get_args import numpy as np @@ -12,11 +13,16 @@ from ._utils import get_init_pos_from_paga if TYPE_CHECKING: + from typing import LiteralString, TypeVar + from anndata import AnnData from scipy.sparse import spmatrix from .._utils import AnyRandom + S = TypeVar("S", bound=LiteralString) + + _Layout = Literal["fr", "drl", "kk", "grid_fr", "lgl", "rt", "rt_circular", "fa"] _LAYOUTS = get_args(_Layout) @@ -53,8 +59,8 @@ def draw_graph( An alternative to tSNE that often preserves the topology of the data better. This requires to run :func:`~scanpy.pp.neighbors`, first. - The default layout ('fa', `ForceAtlas2`, :cite:t:`Jacomy2014`) uses the package |fa2|_ - :cite:p:`Chippada2018`, which can be installed via `pip install fa2`. + The default layout ('fa', `ForceAtlas2`, :cite:t:`Jacomy2014`) uses the package |fa2-modified|_ + :cite:p:`Chippada2018`, which can be installed via `pip install fa2-modified`. `Force-directed graph drawing`_ describes a class of long-established algorithms for visualizing graphs. @@ -62,8 +68,8 @@ def draw_graph( Many other layouts as implemented in igraph :cite:p:`Csardi2006` are available. Similar approaches have been used by :cite:t:`Zunder2015` or :cite:t:`Weinreb2017`. - .. |fa2| replace:: `fa2` - .. _fa2: https://github.com/bhargavchippada/forceatlas2 + .. |fa2-modified| replace:: `fa2-modified` + .. _fa2-modified: https://github.com/AminAlam/fa2_modified .. _Force-directed graph drawing: https://en.wikipedia.org/wiki/Force-directed_graph_drawing Parameters @@ -137,47 +143,10 @@ def draw_graph( else: np.random.seed(random_state) init_coords = np.random.random((adjacency.shape[0], 2)) - # see whether fa2 is installed - if layout == "fa": - try: - from fa2 import ForceAtlas2 - except ImportError: - logg.warning( - "Package 'fa2' is not installed, falling back to layout 'fr'." - "To use the faster and better ForceAtlas2 layout, " - "install package 'fa2' (`pip install fa2`)." - ) - layout = "fr" + layout = coerce_fa2_layout(layout) # actual drawing if layout == "fa": - forceatlas2 = ForceAtlas2( - # Behavior alternatives - outboundAttractionDistribution=False, # Dissuade hubs - linLogMode=False, # NOT IMPLEMENTED - adjustSizes=False, # Prevent overlap (NOT IMPLEMENTED) - edgeWeightInfluence=1.0, - # Performance - jitterTolerance=1.0, # Tolerance - barnesHutOptimize=True, - barnesHutTheta=1.2, - multiThreaded=False, # NOT IMPLEMENTED - # Tuning - scalingRatio=2.0, - strongGravityMode=False, - gravity=1.0, - # Log - verbose=False, - ) - if "maxiter" in kwds: - iterations = kwds["maxiter"] - elif "iterations" in kwds: - iterations = kwds["iterations"] - else: - iterations = 500 - positions = forceatlas2.forceatlas2( - adjacency, pos=init_coords, iterations=iterations - ) - positions = np.array(positions) + positions = np.array(fa2_positions(adjacency, init_coords, **kwds)) else: # igraph doesn't use numpy seed random.seed(random_state) @@ -202,3 +171,51 @@ def draw_graph( deep=f"added\n {key_added!r}, graph_drawing coordinates (adata.obsm)", ) return adata if copy else None + + +def fa2_positions( + adjacency: spmatrix | np.ndarray, init_coords: np.ndarray, **kwds +) -> list[tuple[float, float]]: + from fa2_modified import ForceAtlas2 + + forceatlas2 = ForceAtlas2( + # Behavior alternatives + outboundAttractionDistribution=False, # Dissuade hubs + linLogMode=False, # NOT IMPLEMENTED + adjustSizes=False, # Prevent overlap (NOT IMPLEMENTED) + edgeWeightInfluence=1.0, + # Performance + jitterTolerance=1.0, # Tolerance + barnesHutOptimize=True, + barnesHutTheta=1.2, + multiThreaded=False, # NOT IMPLEMENTED + # Tuning + scalingRatio=2.0, + strongGravityMode=False, + gravity=1.0, + # Log + verbose=False, + ) + if "maxiter" in kwds: + iterations = kwds["maxiter"] + elif "iterations" in kwds: + iterations = kwds["iterations"] + else: + iterations = 500 + return forceatlas2.forceatlas2(adjacency, pos=init_coords, iterations=iterations) + + +def coerce_fa2_layout(layout: S) -> S | Literal["fa", "fr"]: + # see whether fa2 is installed + if layout != "fa": + return layout + + if find_spec("fa2_modified") is None: + logg.warning( + "Package 'fa2-modified' is not installed, falling back to layout 'fr'." + "To use the faster and better ForceAtlas2 layout, " + "install package 'fa2-modified' (`pip install fa2-modified`)." + ) + return "fr" + + return "fa" From c530274c8aae4e8b6d3a6c6d982399b4ae91bcd5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 17 Sep 2024 11:55:53 +0200 Subject: [PATCH 08/66] [pre-commit.ci] pre-commit autoupdate (#3232) 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 ef3d2e951e..6ce8309e63 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.4 + rev: v0.6.5 hooks: - id: ruff types_or: [python, pyi, jupyter] From 78b738bdf8c3521f2bb8ccd2e386ec9bf69481c2 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 17 Sep 2024 13:35:41 +0200 Subject: [PATCH 09/66] Switch to towncrier (#3231) Co-authored-by: Ilan Gold --- .gitignore | 1 - .readthedocs.yml | 9 +- ci/scripts/min-deps.py | 2 +- ci/scripts/towncrier_automation.py | 109 ++++++++++++++++++ docs/conf.py | 1 + docs/dev/code.md | 17 ++- docs/dev/documentation.md | 46 ++++---- docs/dev/getting-set-up.md | 67 ++++++----- docs/dev/release.md | 41 +++---- docs/dev/testing.md | 35 ++++-- docs/dev/versioning.md | 21 ++-- docs/installation.md | 151 ++++++++++++------------ docs/release-notes/1.10.0.md | 4 +- docs/release-notes/1.10.1.md | 2 +- docs/release-notes/1.10.2.md | 4 +- docs/release-notes/1.10.3.md | 22 ---- docs/release-notes/1.11.0.md | 14 --- docs/release-notes/1.8.0.md | 2 +- docs/release-notes/1.8.2.md | 2 +- docs/release-notes/2875.bugfix.md | 1 + docs/release-notes/2921.feature.md | 1 + docs/release-notes/3042.bugfix.md | 1 + docs/release-notes/3115.bugfix.md | 1 + docs/release-notes/3155.feature.md | 1 + docs/release-notes/3163.bugfix.md | 1 + docs/release-notes/3176.bugfix.md | 1 + docs/release-notes/3184.feature.md | 1 + docs/release-notes/3196.bugfix.md | 1 + docs/release-notes/3217.bugfix.md | 1 + docs/release-notes/3220.bugfix.md | 4 + docs/release-notes/index.md | 178 +---------------------------- hatch.toml | 33 ++++++ pyproject.toml | 20 +++- 33 files changed, 393 insertions(+), 402 deletions(-) create mode 100755 ci/scripts/towncrier_automation.py delete mode 100644 docs/release-notes/1.10.3.md delete mode 100644 docs/release-notes/1.11.0.md create mode 100644 docs/release-notes/2875.bugfix.md create mode 100644 docs/release-notes/2921.feature.md create mode 100644 docs/release-notes/3042.bugfix.md create mode 100644 docs/release-notes/3115.bugfix.md create mode 100644 docs/release-notes/3155.feature.md create mode 100644 docs/release-notes/3163.bugfix.md create mode 100644 docs/release-notes/3176.bugfix.md create mode 100644 docs/release-notes/3184.feature.md create mode 100644 docs/release-notes/3196.bugfix.md create mode 100644 docs/release-notes/3217.bugfix.md create mode 100644 docs/release-notes/3220.bugfix.md create mode 100644 hatch.toml diff --git a/.gitignore b/.gitignore index 7aca652727..beafaf6171 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ /tests/**/*failed-diff.png # Environment management -/hatch.toml /Pipfile /Pipfile.lock /requirements*.lock diff --git a/.readthedocs.yml b/.readthedocs.yml index 4a5d3e219f..4ffa520491 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,9 +2,16 @@ version: 2 submodules: include: all build: - os: ubuntu-20.04 + os: ubuntu-24.04 tools: python: '3.12' + jobs: + post_checkout: + # unshallow so version can be derived from tag + - git fetch --unshallow || true + pre_build: + # run towncrier to preview the next version’s release notes + - ( find docs/release-notes -regex '[^.]+[.][^.]+.md' | grep -q . ) && towncrier build --keep || true sphinx: fail_on_warning: true # do not change or you will be fired configuration: docs/conf.py diff --git a/ci/scripts/min-deps.py b/ci/scripts/min-deps.py index b3f393ea57..f1381580b4 100755 --- a/ci/scripts/min-deps.py +++ b/ci/scripts/min-deps.py @@ -1,4 +1,4 @@ -#!python3 +#!/usr/bin/env python3 from __future__ import annotations import argparse diff --git a/ci/scripts/towncrier_automation.py b/ci/scripts/towncrier_automation.py new file mode 100755 index 0000000000..7dcb3cb7bf --- /dev/null +++ b/ci/scripts/towncrier_automation.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +from typing import TYPE_CHECKING + +from packaging.version import Version + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class Args(argparse.Namespace): + version: str + dry_run: bool + + +def parse_args(argv: Sequence[str] | None = None) -> Args: + parser = argparse.ArgumentParser( + prog="towncrier-automation", + description=( + "This script runs towncrier for a given version, " + "creates a branch off of the current one, " + "and then creates a PR into the original branch with the changes. " + "The PR will be backported to main if the current branch is not main." + ), + ) + parser.add_argument( + "version", + type=str, + help=( + "The new version for the release must have at least three parts, like `major.minor.patch` and no `major.minor`. " + "It can have a suffix like `major.minor.patch.dev0` or `major.minor.0rc1`." + ), + ) + parser.add_argument( + "--dry-run", + help="Whether or not to dry-run the actual creation of the pull request", + action="store_true", + ) + args = parser.parse_args(argv, Args()) + # validate the version + if len(Version(args.version).release) != 3: + msg = f"Version argument {args.version} must contain major, minor, and patch version." + raise ValueError(msg) + return args + + +def main(argv: Sequence[str] | None = None) -> None: + args = parse_args(argv) + + # Run towncrier + subprocess.run( + ["towncrier", "build", f"--version={args.version}", "--yes"], check=True + ) + + # Check if we are on the main branch to know if we need to backport + base_branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + pr_description = ( + "" if base_branch == "main" else "@meeseeksmachine backport to main" + ) + branch_name = f"release_notes_{args.version}" + + # Create a new branch + commit + subprocess.run(["git", "switch", "-c", branch_name], check=True) + subprocess.run(["git", "add", "docs/release-notes"], check=True) + pr_title = f"(chore): generate {args.version} release notes" + subprocess.run(["git", "commit", "-m", pr_title], check=True) + + # push + if not args.dry_run: + subprocess.run( + ["git", "push", "--set-upstream=origin", branch_name], check=True + ) + else: + print("Dry run, not pushing") + + # Create a PR + subprocess.run( + [ + "gh", + "pr", + "create", + f"--base={base_branch}", + f"--title={pr_title}", + f"--body={pr_description}", + *(["--label=no milestone"] if base_branch == "main" else []), + *(["--dry-run"] if args.dry_run else []), + ], + check=True, + ) + + # Enable auto-merge + if not args.dry_run: + subprocess.run( + ["gh", "pr", "merge", branch_name, "--auto", "--squash"], check=True + ) + else: + print("Dry run, not merging") + + +if __name__ == "__main__": + main() diff --git a/docs/conf.py b/docs/conf.py index 9a5626d6b0..2c79aa8d82 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -79,6 +79,7 @@ "scanpydoc", # needs to be before sphinx.ext.linkcode "sphinx.ext.linkcode", "sphinx_design", + "sphinx_tabs.tabs", "sphinx_search.extension", "sphinxext.opengraph", *[p.stem for p in (HERE / "extensions").glob("*.py") if p.stem not in {"git_ref"}], diff --git a/docs/dev/code.md b/docs/dev/code.md index 06c267951b..1e9d295725 100644 --- a/docs/dev/code.md +++ b/docs/dev/code.md @@ -12,13 +12,12 @@ ## Code style -New code should follow -[Black](https://black.readthedocs.io/en/stable/the_black_code_style.html) -and -[flake8](https://flake8.pycqa.org). -We ignore a couple of flake8 checks which are documented in the .flake8 file in the root of this repository. -To learn how to ignore checks per line please read -[flake8 violations](https://flake8.pycqa.org/en/latest/user/violations.html). -Additionally, we use Scanpy’s -[EditorConfig](https://github.com/scverse/scanpy/blob/main/.editorconfig), +Code contributions will be formatted and style checked using [Ruff][]. +Ignored checks are configured in the `tool.ruff.lint` section of {file}`pyproject.toml`. +To learn how to ignore checks per line please read about [ignoring errors][]. +Additionally, we use Scanpy’s [EditorConfig][], so using an editor/IDE with support for both is helpful. + +[Ruff]: https://docs.astral.sh/ruff/ +[ignoring errors]: https://docs.astral.sh/ruff/tutorial/#ignoring-errors +[EditorConfig]: https://github.com/scverse/scanpy/blob/main/.editorconfig diff --git a/docs/dev/documentation.md b/docs/dev/documentation.md index 159b533ee3..d9c3f6e034 100644 --- a/docs/dev/documentation.md +++ b/docs/dev/documentation.md @@ -4,38 +4,37 @@ ## Building the docs -Dependencies for building the documentation for scanpy can be installed with `pip install -e "scanpy[doc]"` - -To build the docs, enter the `docs` directory and run `make html`. After this process completes you can take a look at the docs by opening `scanpy/docs/_build/html/index.html`. +To build the docs, run `hatch run docs:build`. +Afterwards, you can run `hatch run docs:open` to open {file}`docs/_build/html/index.html`. Your browser and Sphinx cache docs which have been built previously. Sometimes these caches are not invalidated when you've updated the docs. If docs are not updating the way you expect, first try "force reloading" your browser page – e.g. reload the page without using the cache. -Next, if problems persist, clear the sphinx cache and try building them again (`make clean` from `docs` directory). - -```{note} -If you've cloned the repository pre 1.8.0, you may need to be more thorough in cleaning. -If you run into warnings try removing all untracked files in the docs directory. -``` +Next, if problems persist, clear the sphinx cache (`hatch run docs:clean`) and try building them again. ## Adding to the docs -For any user-visible changes, please make sure a note has been added to the release notes for the relevant version so we can credit you! -These files are found in the `docs/release-notes/` directory. +For any user-visible changes, please make sure a note has been added to the release notes using [`hatch run towncrier:create`][towncrier create]. We recommend waiting on this until your PR is close to done since this can often causes merge conflicts. Once you've added a new function to the documentation, you'll need to make sure there is a link somewhere in the documentation site pointing to it. This should be added to `docs/api.md` under a relevant heading. -For tutorials and more in depth examples, consider adding a notebook to [scanpy-tutorials](https://github.com/scverse/scanpy-tutorials/). +For tutorials and more in depth examples, consider adding a notebook to the [scanpy-tutorials][] repository. -The tutorials are tied to this repository via a submodule. To update the submodule, run `git submodule update --remote` from the root of the repository. Subsequently, commit and push the changes in a PR. This should be done before each release to ensure the tutorials are up to date. +The tutorials are tied to this repository via a submodule. +To update the submodule, run `git submodule update --remote` from the root of the repository. +Subsequently, commit and push the changes in a PR. +This should be done before each release to ensure the tutorials are up to date. + +[towncrier create]: https://towncrier.readthedocs.io/en/stable/tutorial.html#creating-news-fragments +[scanpy-tutorials]: https://github.com/scverse/scanpy-tutorials/ ## docstrings format We use the numpydoc style for writing docstrings. -We'd primarily suggest looking at existing docstrings for examples, but the [napolean guide to numpy style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html#example-numpy) is also a great source. -If you're unfamiliar with the reStructuredText (`rst`) markup format, [Sphinx has a useful primer](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html). +We'd primarily suggest looking at existing docstrings for examples, but the [napolean guide to numpy style docstrings][] is also a great source. +If you're unfamiliar with the reStructuredText (rST) markup format, check out the [Sphinx rST primer][]. Some key points: @@ -46,11 +45,14 @@ Some key points: Look at [sc.tl.louvain](https://github.com/scverse/scanpy/blob/a811fee0ef44fcaecbde0cad6336336bce649484/scanpy/tools/_louvain.py#L22-L90) as an example for everything mentioned here. +[napolean guide to numpy style docstrings]: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html#example-numpy +[sphinx rst primer]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html + ### Plots in docstrings One of the most useful things you can include in a docstring is examples of how the function should be used. These are a great way to demonstrate intended usage and give users a template they can copy and modify. -We're able to include the plots produced by these snippets in the rendered docs using [matplotlib's plot directive](https://matplotlib.org/devel/plot_directive.html). +We're able to include the plots produced by these snippets in the rendered docs using [matplotlib's plot directive][]. For examples of this, see the `Examples` sections of {func}`~scanpy.pl.dotplot` or {func}`~scanpy.pp.calculate_qc_metrics`. Note that anything in these sections will need to be run when the docs are built, so please keep them computationally light. @@ -58,6 +60,8 @@ Note that anything in these sections will need to be run when the docs are built - If you need computed features (e.g. an embedding, differential expression results) load data that has this precomputed. - Try to re-use datasets, this reduces the amount of data that needs to be downloaded to the CI server. +[matplotlib's plot directive]: https://matplotlib.org/devel/plot_directive.html + ### `Params` section The `Params` abbreviation is a legit replacement for `Parameters`. @@ -65,7 +69,10 @@ The `Params` abbreviation is a legit replacement for `Parameters`. To document parameter types use type annotations on function parameters. These will automatically populate the docstrings on import, and when the documentation is built. -Use the python standard library types (defined in [collections.abc](https://docs.python.org/3/library/collections.abc.html) and [typing](https://docs.python.org/3/library/typing.html) modules) for containers, e.g. `Sequence`s (like `list`), `Iterable`s (like `set`), and `Mapping`s (like `dict`). +Use the python standard library types (defined in {mod}`collections.abc` and {mod}`typing` modules) for containers, e.g. +{class}`~collections.abc.Sequence`s (like `list`), +{class}`~collections.abc.Iterable`s (like `set`), and +{class}`~collections.abc.Mapping`s (like `dict`). Always specify what these contain, e.g. `{'a': (1, 2)}` → `Mapping[str, Tuple[int, int]]`. If you can’t use one of those, use a concrete class like `AnnData`. If your parameter only accepts an enumeration of strings, specify them like so: `Literal['elem-1', 'elem-2']`. @@ -80,8 +87,7 @@ There are three types of return sections – prose, tuple, and a mix of both. #### Examples -For simple cases, use prose as in -{func}`~scanpy.pp.normalize_total` +For simple cases, use prose as in {func}`~scanpy.pp.normalize_total`: ```rst Returns @@ -110,7 +116,7 @@ def myfunc(...) -> tuple[int, str]: ``` Many functions also just modify parts of the passed AnnData object, like e.g. {func}`~scanpy.tl.dpt`. -You can then combine prose and lists to best describe what happens. +You can then combine prose and lists to best describe what happens: ```rst Returns diff --git a/docs/dev/getting-set-up.md b/docs/dev/getting-set-up.md index 750af53e91..20c6cba63a 100644 --- a/docs/dev/getting-set-up.md +++ b/docs/dev/getting-set-up.md @@ -6,8 +6,11 @@ This section of the docs covers our practices for working with `git` on our code For a more complete git tutorials we recommend checking out: -- [Atlassian's git tutorial](https://www.atlassian.com/git/tutorials) -- Beginner friendly introductions to the git command line interface -- [Setting up git for GitHub](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/set-up-git) -- Configuring git to work with your GitHub user account +[Atlassian's git tutorial](https://www.atlassian.com/git/tutorials) +: Beginner friendly introductions to the git command line interface + +[Setting up git for GitHub](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/set-up-git) +: Configuring git to work with your GitHub user account (forking-and-cloning)= @@ -15,9 +18,9 @@ For a more complete git tutorials we recommend checking out: To get the code, and be able to push changes back to the main project, you'll need to (1) fork the repository on github and (2) clone the repository to your local machine. -This is very straight forward if you're using [GitHub's CLI](https://cli.github.com): +This is very straight forward if you're using [GitHub's CLI][]: -```shell +```console $ gh repo fork scverse/scanpy --clone --remote ``` @@ -25,37 +28,41 @@ This will fork the repo to your github account, create a clone of the repo on yo To do this manually, first make a fork of the repository by clicking the "fork" button on our main github package. Then, on your machine, run: -```shell -# Clone your fork of the repository (substitute in your username) -git clone https://github.com/{your-username}/scanpy.git -# Enter the cloned repository -cd scanpy -# Add our repository as a remote -git remote add upstream https://github.com/scverse/scanpy.git -# git branch --set-upstream-to "upstream/main" +```console +$ # Clone your fork of the repository (substitute in your username) +$ git clone https://github.com/{your-username}/scanpy.git +$ # Enter the cloned repository +$ cd scanpy +$ # Add our repository as a remote +$ git remote add upstream https://github.com/scverse/scanpy.git +$ # git branch --set-upstream-to "upstream/main" ``` +[GitHub's CLI]: https://cli.github.com + ### `pre-commit` -We use [precommit](https://pre-commit.com) to run some styling checks in an automated way. +We use [pre-commit][] to run some styling checks in an automated way. We also test against these checks, so make sure you follow them! You can install pre-commit with: -```shell -pip install pre-commit +```console +$ pip install pre-commit ``` You can then install it to run while developing here with: -```shell -pre-commit install +```console +$ pre-commit install ``` From the root of the repo. If you choose not to run the hooks on each commit, you can run them manually with `pre-commit run --files={your files}`. +[pre-commit]: https://pre-commit.com + (creating-a-branch)= ### Creating a branch for your feature @@ -64,10 +71,10 @@ All development should occur in branches dedicated to the particular work being Additionally, unless you are a maintainer, all changes should be directed at the `main` branch. You can create a branch with: -```shell -git checkout main # Starting from the main branch -git pull # Syncing with the repo -git checkout -b {your-branch-name} # Making and changing to the new branch +```console +$ git checkout main # Starting from the main branch +$ git pull # Syncing with the repo +$ git switch -c {your-branch-name} # Making and changing to the new branch ``` (open-a-pr)= @@ -76,11 +83,11 @@ git checkout -b {your-branch-name} # Making and changing to the new branch When you're ready to have your code reviewed, push your changes up to your fork: -```shell -# The first time you push the branch, you'll need to tell git where -git push --set-upstream origin {your-branch-name} -# After that, just use -git push +```console +$ # The first time you push the branch, you'll need to tell git where +$ git push --set-upstream origin {your-branch-name} +$ # After that, just use +$ git push ``` And open a pull request by going to the main repo and clicking *New pull request*. @@ -93,6 +100,10 @@ We'll try and get back to you soon! ## Development environments It's recommended to do development work in an isolated environment. -There are number of ways to do this, including conda environments, virtual environments, and virtual machines. +There are number of ways to do this, including virtual environments, conda environments, and virtual machines. + +We think the easiest is probably [Hatch environments][]. +Using one of the predefined environments in {file}`hatch.toml` is as simple as running `hatch test` or `hatch run docs:build` (they will be created on demand). +For an in-depth guide, refer to the {ref}`development install instructions ` of `scanpy`. -We think the easiest is probably conda environments. Simply create a new environment with a supported version of python and make a {ref}`development install ` of `scanpy`. +[hatch environments]: https://hatch.pypa.io/latest/tutorials/environment/basic-usage/ diff --git a/docs/dev/release.md b/docs/dev/release.md index e3ce2a31ab..f93b73eaa1 100644 --- a/docs/dev/release.md +++ b/docs/dev/release.md @@ -5,12 +5,9 @@ That page also explains concepts like *pre-releases* and applications thereof. ## Preparing the release -1. Make a new branch off of `main` to prepare the release notes, and create a PR from this branch back into `main`. - Add a milestone for the desired version to be released. -2. Update the date in the desired release’s notes and if applicable, delete empty headers in the notes. - Create a new blank note for the next desired release and update the `index.md` to include it. -3. Push the changes to the PR, and merge into `main`. - If it is a patch release, backport the updated notes (see [](#versioning-tooling)) into the major/minor version branch. +1. Switch to the `main` branch for a major/minor release and the respective release series branch for a *patch* release (e.g. `1.8.x` when releasing version 1.8.4). +2. Run `hatch towncrier:build` to generate a PR that creates a new release notes file. Wait for the PR to be auto-merged. +3. If it is a *patch* release, merge the backport PR (see {ref}`versioning-tooling`) into the `main` branch. ## Actually making the release @@ -28,8 +25,6 @@ That page also explains concepts like *pre-releases* and applications thereof. After *any* release has been made: -- Create a new release notes file for the next bugfix release. - This should be included in both dev and stable branches. - Create a milestone for the next release (in case you made a bugfix release) or releases (in case of a major/minor release). For bugfix releases, this should have `on-merge: backport to 0..x`, so the [meeseeksdev][] bot will create a backport PR. See {doc}`versioning` for more info. @@ -50,27 +45,20 @@ If you changed something about the build process (e.g. [Hatchling’s build conf or something about the package’s structure, you might want to manually check if the build and upload process behaves as expected: -```shell -# Clear out old distributions -rm -r dist - -# Build source distribution and wheel both -python -m build - -# Now check those build artifacts -twine check dist/* - -# List the wheel archive’s contents -bsdtar -tf dist/*.whl - +```console +$ # Clear out old distributions +$ rm -r dist +$ # Build source distribution and wheel both +$ python -m build +$ # Now check those build artifacts +$ twine check dist/* +$ # List the wheel archive’s contents +$ bsdtar -tf dist/*.whl ``` You can also upload the package to ([tutorial][testpypi tutorial]) - -[testpypi tutorial]: https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives - -``` -twine upload --repository testpypi dist/* +```console +$ twine upload --repository testpypi dist/* ``` The above approximates what the [publish workflow][] does automatically for us. @@ -78,4 +66,5 @@ If you want to replicate the process more exactly, make sure you are careful, and create a version tag before building (make sure you delete it after uploading to TestPyPI!). [hatch-build]: https://hatch.pypa.io/latest/config/build/ +[testpypi tutorial]: https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives [publish workflow]: https://github.com/scverse/scanpy/tree/main/.github/workflows/publish.yml diff --git a/docs/dev/testing.md b/docs/dev/testing.md index aeae4ee8da..81eae36c75 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -7,34 +7,43 @@ Implementations may change, but the only way we can know the code is working bef ## Running the tests -We use [pytest](https://docs.pytest.org/en/stable/) to test scanpy. -To run the tests first make sure you have the required dependencies (`pip install -e ".[test,dev]"`), then run `pytest` from the root of the repository. +We use [pytest][] to test scanpy. +To run the tests, simply run `hatch test`. It can take a while to run the whole test suite. There are a few ways to cut down on this while working on a PR: -1. Only run a subset of the tests. This can be done with the `-k` argument from pytest (e.g. `pytest -k test_plotting.py` or `pytest -k "test_umap*"` -2. Run the tests in parallel. If you install the pytest extension [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) you can run tests in parallel with the `--numprocesses` argument to pytest (e.g. `pytest -n 8`). +1. Only run a subset of the tests. + This can be done by specifying paths or test name patterns using the `-k` argument (e.g. `hatch test test_plotting.py` or `hatch test -k "test_umap*"`) +2. Run the tests in parallel using the `-n` argument (e.g. `hatch test -n 8`). + +[pytest]: https://docs.pytest.org/en/stable/ ### Miscellaneous tips -- A lot of warnings can be thrown while running the test suite. It's often easier to read the test results with them hidden via the `--disable-pytest-warnings` argument. +- A lot of warnings can be thrown while running the test suite. + It's often easier to read the test results with them hidden via the `--disable-pytest-warnings` argument. ## Writing tests -You can refer to the [existing test suite](https://github.com/scverse/scanpy/tree/main/scanpy/tests) for examples. -If you haven't written tests before, Software Carpentry has an [in-depth guide](https://katyhuff.github.io/2016-07-11-scipy/testing/01-basics.html) on the topic. +You can refer to the [existing test suite][] for examples. +If you haven't written tests before, Software Carpentry has an [in-depth testing guide][]. -We highly recommend using [Test Driven Development](https://en.wikipedia.org/wiki/Test-driven_development) when contributing code. +We highly recommend using [Test-Driven Development][] when contributing code. This not only ensures you have tests written, it often makes implementation easier since you start out with a specification for your function. Consider parameterizing your tests using the `pytest.mark.parameterize` and `pytest.fixture` decorators. -Documentation on these can be found [here](https://docs.pytest.org/en/stable/fixture.html), but we'd also recommend searching our test suite for existing usage. +You can read more about [fixtures][] in pytest’s documentation, but we’d also recommend searching our test suite for existing usage. + +[existing test suite]: https://github.com/scverse/scanpy/tree/main/scanpy/tests +[in-depth testing guide]: https://katyhuff.github.io/2016-07-11-scipy/testing/ +[test-driven development]: https://en.wikipedia.org/wiki/Test-driven_development +[fixtures]: https://docs.pytest.org/en/stable/fixture.html ### What to test If you're not sure what to tests about your function, some ideas include: -- Are there arguments which conflict with each other? Check that if they are both passed, the function throws an error (see `pytest.raises` [in the pytest docs](https://docs.pytest.org/en/stable/assert.html#assertions-about-expected-exceptions). +- Are there arguments which conflict with each other? Check that if they are both passed, the function throws an error (see [`pytest.raises`][] docs). - Are there input values which should cause your function to error? - Did you add a helpful error message that recommends better outputs? Check that that error message is actually thrown. - Can you place bounds on the values returned by your function? @@ -42,6 +51,8 @@ If you're not sure what to tests about your function, some ideas include: - Do you have arguments which should have orthogonal effects on the output? Check that they are independent. For example, if there is a flag for extended output, the base output should remain the same either way. - Are you optimizing a method? Check that it's results are the same as a gold standard implementation. +[`pytest.raises`]: https://docs.pytest.org/en/stable/assert.html#assertions-about-expected-exceptions + ### Performance It's more important that you're accurately testing the code works than it is that test suite runs quickly. @@ -51,9 +62,11 @@ You can check how long tests take to run by passing `--durations=0` argument to Hopefully your new tests won't show up on top! Some approaches to this include: -- Is there a common setup/ computation happening in each test? Consider caching these in a [scoped test fixture](https://docs.pytest.org/en/stable/fixture.html#sharing-test-data). +- Is there a common setup/ computation happening in each test? Consider caching these in a [scoped test fixture][]. - Is the behaviour you're testing for dependent on the size of the data? If not, consider reducing it. +[scoped test fixture]: https://docs.pytest.org/en/stable/fixture.html#sharing-test-data + ### Plotting tests While computational functions will return arrays and values, it can be harder to work with the output of plotting functions. diff --git a/docs/dev/versioning.md b/docs/dev/versioning.md index e87b3d7a9f..748b3d2e2c 100644 --- a/docs/dev/versioning.md +++ b/docs/dev/versioning.md @@ -18,9 +18,10 @@ At a `point` release, there should be no changes beyond bug fixes. Valid version numbers are described in [PEP 440](https://peps.python.org/pep-0440/). [Pre-releases](https://peps.python.org/pep-0440/#pre-releases) -: should have versions like `1.7.0rc1` or `1.7.0rc2`. +: should have versions like `1.7.0rc1` or `1.7.0rc2`. + [Development versions](https://peps.python.org/pep-0440/#developmental-releases) -: should look like `1.8.0.dev0`, with a commit hash optionally appended as a local version identifier (e.g. `1.8.0.dev2+g00ad77b`). +: should look like `1.8.0.dev0`, with a commit hash optionally appended as a local version identifier (e.g. `1.8.0.dev2+g00ad77b`). (versioning-tooling)= ## Tooling @@ -29,12 +30,18 @@ To be sure we can follow this scheme and maintain some agility in development, w When a minor release is made, a release branch should be cut and pushed to the main repo (e.g. `1.7.x` for the `1.7` release series). For PRs which fix an bug in the most recent minor release, the changes will need to added to both the development and release branches. -To accomplish this, PRs which fix bugs must be labelled as such. -After approval, a developer will notify the [meeseeks bot](https://meeseeksbox.github.io) to open a backport PR onto the release branch via a comment saying: +To accomplish this, PRs which fix bugs are assigned a patch version milestone such as `1.7.4`. +Once the PR is approved and merged, the bot will attempt to make a backport and open a PR. +This will sometimes require manual intervention due to merge conflicts or test failures. + +### Technical details + +The [meeseeks bot][] reacts to commands like this, +given as a comment on the PR, or a label or milestone description: > @Meeseeksdev backport \ -Where "\" is the most recent release branch. +In our case, these commands are part of the milestone description, +which causes the merge of a PR assigned to a milestone to trigger the bot. -The bot will attempt to make a backport and open a PR. -This will sometimes require manual intervention due to merge conflicts or test failures. +[meseeks bot]: https://meeseeksbox.github.io diff --git a/docs/installation.md b/docs/installation.md index 815b2a3a15..c28a09d668 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,116 +1,113 @@ # Installation -## Anaconda +To use `scanpy` from another project, install it using your favourite environment manager: -If you do not have a working installation of Python 3.6 (or later), consider -installing [Miniconda] (see [Installing Miniconda]). Then run: +::::{tabs} -```shell -conda install -c conda-forge scanpy python-igraph leidenalg -``` +:::{group-tab} Hatch (recommended) +Adding `scanpy[leiden]` to your dependencies is enough. +See below for how to use Scanpy’s {ref}`dev-install-instructions`. +::: -Pull Scanpy from [PyPI](https://pypi.org/project/scanpy) (consider using `pip3` to access Python 3): +:::{group-tab} Pip/PyPI +If you prefer to exclusively use PyPI run: -```shell -pip install scanpy +```console +$ pip install 'scanpy[leiden]' ``` +::: -## PyPI only +:::{group-tab} Conda +After installing installing e.g. [Miniconda][], run: -If you prefer to exclusively use PyPI run: +```console +$ conda install -c conda-forge scanpy python-igraph leidenalg +``` + +Pull Scanpy [from PyPI][] (consider using `pip3` to access Python 3): -```shell -pip install 'scanpy[leiden]' +```console +$ pip install scanpy ``` -The extra `[leiden]` installs two packages that are needed for popular -parts of scanpy but aren't requirements: [igraph] {cite:p}`Csardi2006` and [leiden] {cite:p}`Traag2019`. +[miniconda]: https://docs.anaconda.com/miniconda/miniconda-install/ +[from pypi]: https://pypi.org/project/scanpy +::: + +:::: + +If you use Hatch or pip, the extra `[leiden]` installs two packages that are needed for popular +parts of scanpy but aren't requirements: [igraph][] {cite:p}`Csardi2006` and [leiden][] {cite:p}`Traag2019`. +If you use conda, you should to add these dependencies to your environment individually. + +[igraph]: https://python.igraph.org/en/stable/ +[leiden]: https://leidenalg.readthedocs.io (dev-install-instructions)= ## Development Version -To work with the latest version [on GitHub]: clone the repository and `cd` into its root directory. +To work with the latest version [on GitHub][]: clone the repository and `cd` into its root directory. -```shell -gh repo clone scverse/scanpy -cd scanpy +```console +$ gh repo clone scverse/scanpy +$ cd scanpy ``` -If you are using `pip>=21.3`, an editable install can be made: +::::{tabs} -```shell -pip install -e '.[dev,test]' -``` +:::{group-tab} Hatch (recommended) +To use one of the predefined [Hatch environments][] in {file}`hatch.toml`, +run either `hatch test [args]` or `hatch run [env:]command [...args]`, e.g.: -If you want to let [conda] handle the installations of dependencies, do: - -```shell -pipx install beni -beni pyproject.toml > environment.yml -conda env create -f environment.yml -conda activate scanpy -pip install -e '.[dev,doc,test]' +```console +$ hatch test -p # run tests in parallel +$ hatch run docs:build # build docs +$ hatch run towncrier:create # create changelog entry ``` -For instructions on how to work with the code, see the {ref}`contribution guide `. +[hatch environments]: https://hatch.pypa.io/latest/tutorials/environment/basic-usage/ +::: -## Docker - -If you're using [Docker], you can use e.g. the image [gcfntnu/scanpy] from Docker Hub. +:::{group-tab} Pip/PyPI +If you are using `pip>=21.3`, an editable install can be made: -## Troubleshooting +```console +$ python -m venv .venv +$ source .venv/bin/activate +$ pip install -e '.[dev,test]' +``` +::: -If you get a `Permission denied` error, never use `sudo pip`. Instead, use virtual environments or: +:::{group-tab} Conda +If you want to let `conda` handle the installations of dependencies, do: -```shell -pip install --user scanpy +```console +$ pipx install beni +$ beni pyproject.toml > environment.yml +$ conda env create -f environment.yml +$ conda activate scanpy +$ pip install -e '.[dev,doc,test]' ``` -**On MacOS**, if **not** using `conda`, you might need to install the C core of igraph via homebrew first - -- `brew install igraph` +For instructions on how to work with the code, see the {ref}`contribution guide `. +::: -- If igraph still fails to install, see the question on [compiling igraph]. - Alternatively consider installing gcc via `brew install gcc --without-multilib` - and exporting the required variables: +:::: - ```shell - export CC="/usr/local/Cellar/gcc/X.x.x/bin/gcc-X" - export CXX="/usr/local/Cellar/gcc/X.x.x/bin/gcc-X" - ``` +[on github]: https://github.com/scverse/scanpy - where `X` and `x` refers to the version of `gcc`; - in my case, the path reads `/usr/local/Cellar/gcc/6.3.0_1/bin/gcc-6`. +## Docker -**On Windows**, there also often problems installing compiled packages such as `igraph`, -but you can find precompiled packages on Christoph Gohlke’s [unofficial binaries]. -Download those and install them using `pip install ./path/to/file.whl` +If you're using [Docker][], you can use e.g. the image [gcfntnu/scanpy][] from Docker Hub. -(conda)= +[docker]: https://en.wikipedia.org/wiki/Docker_(software) +[gcfntnu/scanpy]: https://hub.docker.com/r/gcfntnu/scanpy -## Installing Miniconda +## Troubleshooting -After downloading [Miniconda], in a unix shell (Linux, Mac), run +If you get a `Permission denied` error, never use `sudo pip`. Instead, use virtual environments or: -```shell -cd DOWNLOAD_DIR -chmod +x Miniconda3-latest-VERSION.sh -./Miniconda3-latest-VERSION.sh +```console +$ pip install --user scanpy ``` - -and accept all suggestions. -Either reopen a new terminal or `source ~/.bashrc` on Linux/ `source ~/.bash_profile` on Mac. -The whole process takes just a couple of minutes. - -[bioconda]: https://bioconda.github.io/ -[compiling igraph]: https://stackoverflow.com/q/29589696/247482 -[create symbolic links]: https://docs.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links -[docker]: https://en.wikipedia.org/wiki/Docker_(software) -[from pypi]: https://pypi.org/project/scanpy -[gcfntnu/scanpy]: https://hub.docker.com/r/gcfntnu/scanpy -[leiden]: https://leidenalg.readthedocs.io -[miniconda]: https://docs.conda.io/projects/miniconda/en/latest/ -[on github]: https://github.com/scverse/scanpy -[igraph]: https://python.igraph.org/en/stable/ -[unofficial binaries]: https://www.lfd.uci.edu/~gohlke/pythonlibs/ diff --git a/docs/release-notes/1.10.0.md b/docs/release-notes/1.10.0.md index 0448d9fc5f..2e23d24426 100644 --- a/docs/release-notes/1.10.0.md +++ b/docs/release-notes/1.10.0.md @@ -32,7 +32,7 @@ Some highlights: * {func}`scanpy.pp.scale` now clips `np.ndarray` also at `- max_value` for zero-centering {pr}`2913` {smaller}`S Dicks` * Support sparse chunks in dask {func}`~scanpy.pp.scale`, {func}`~scanpy.pp.normalize_total` and {func}`~scanpy.pp.highly_variable_genes` (`seurat` and `cell-ranger` tested) {pr}`2856` {smaller}`ilan-gold` -#### Docs +#### Documentation * Doc style overhaul {pr}`2220` {smaller}`A Gayoso` * Re-add search-as-you-type, this time via `readthedocs-sphinx-search` {pr}`2805` {smaller}`P Angerer` @@ -59,7 +59,7 @@ Some highlights: * Fix pytest deprecation warning {pr}`2879` {smaller}`P Angerer` -#### Development +#### Development Process * Scanpy is now tested against python 3.12 {pr}`2863` {smaller}`ivirshup` * Fix testing package build {pr}`2468` {smaller}`P Angerer` diff --git a/docs/release-notes/1.10.1.md b/docs/release-notes/1.10.1.md index 35f631bdc0..859789af5b 100644 --- a/docs/release-notes/1.10.1.md +++ b/docs/release-notes/1.10.1.md @@ -1,7 +1,7 @@ (v1.10.1)= ### 1.10.1 {small}`2024-04-09` -#### Docs +#### Documentation * Added {doc}`how-to example ` on plotting with [Marsilea](https://marsilea.readthedocs.io) {pr}`2974` {smaller}`Y Zheng` diff --git a/docs/release-notes/1.10.2.md b/docs/release-notes/1.10.2.md index 8e0342054b..947da0be29 100644 --- a/docs/release-notes/1.10.2.md +++ b/docs/release-notes/1.10.2.md @@ -1,11 +1,11 @@ (v1.10.2)= ### 1.10.2 {small}`2024-06-25` -#### Development features +#### Development Process * Add performance benchmarking {pr}`2977` {smaller}`R Shrestha`, {smaller}`P Angerer` -#### Docs +#### Documentation * Document several missing parameters in docstring {pr}`2888` {smaller}`S Cheney` * Fixed incorrect instructions in "testing" dev docs {pr}`2994` {smaller}`I Virshup` diff --git a/docs/release-notes/1.10.3.md b/docs/release-notes/1.10.3.md deleted file mode 100644 index 6ba996730c..0000000000 --- a/docs/release-notes/1.10.3.md +++ /dev/null @@ -1,22 +0,0 @@ -(v1.10.3)= -### 1.10.3 {small}`the future` - -#### Development features - -#### Docs - -#### Bug fixes - -* Prevent empty control gene set in {func}`~scanpy.tl.score_genes` {pr}`2875` {smaller}`M Müller` -* Fix `subset=True` of {func}`~scanpy.pp.highly_variable_genes` when `flavor` is `seurat` or `cell_ranger`, and `batch_key!=None` {pr}`3042` {smaller}`E Roellin` -* Add compatibility with {mod}`numpy` 2.0 {pr}`3065` and {pr}`3115` {smaller}`P Angerer` -* Fix `legend_loc` argument in {func}`scanpy.pl.embedding` not accepting matplotlib parameters {pr}`3163` {smaller}`P Angerer` -* Fix dispersion cutoff in {func}`~scanpy.pp.highly_variable_genes` in presence of `NaN`s {pr}`3176` {smaller}`P Angerer` -* Fix axis labeling for swapped axes in {func}`~scanpy.pl.rank_genes_groups_stacked_violin` {pr}`3196` {smaller}`Ilan Gold` -* Upper bound dask on account of {issue}`scverse/anndata#1579` {pr}`3217` {smaller}`Ilan Gold` -* The [fa2-modified][] package replaces [forceatlas2][] for the latter’s lack of maintenance. {pr}`3220` {smaller}`A Alam` - -[fa2-modified]: https://github.com/AminAlam/fa2_modified -[forceatlas2]: https://github.com/bhargavchippada/forceatlas2 - -#### Performance diff --git a/docs/release-notes/1.11.0.md b/docs/release-notes/1.11.0.md deleted file mode 100644 index c948f8068a..0000000000 --- a/docs/release-notes/1.11.0.md +++ /dev/null @@ -1,14 +0,0 @@ -(v1.11.0)= -### 1.11.0 {small}`the future` - -#### Features - -* Add `layer` argument to {func}`scanpy.tl.score_genes` and {func}`scanpy.tl.score_genes_cell_cycle` {pr}`2921` {smaller}`L Zappia` -* Prevent `raw` conflict with `layer` in {func}`~scanpy.tl.score_genes` {pr}`3155` {smaller}`S Dicks` -* Add `key_added` argument to {func}`~scanpy.pp.pca`, {func}`~scanpy.tl.tsne` and {func}`~scanpy.tl.umap` {pr}`3184` {smaller}`P Angerer` - -#### Docs - -#### Bug fixes - -#### Deprecations diff --git a/docs/release-notes/1.8.0.md b/docs/release-notes/1.8.0.md index f2e06e8371..8bf2c36895 100644 --- a/docs/release-notes/1.8.0.md +++ b/docs/release-notes/1.8.0.md @@ -47,7 +47,7 @@ - {func}`scanpy.pl.rank_genes_groups_violin` now works for `raw=False` {pr}`1669` {smaller}`M van den Beek` - {func}`scanpy.pl.dotplot` now uses `smallest_dot` argument correctly {pr}`1771` {smaller}`S Flemming` -#### Development processes +#### Development Process - Switched to [flit] for building and deploying the package, a simple tool with an easy to understand command line interface and metadata {pr}`1527` {smaller}`P Angerer` - Use [pre-commit](https://pre-commit.com) for style checks {pr}`1684` {pr}`1848` {smaller}`L Heumos` {smaller}`I Virshup` diff --git a/docs/release-notes/1.8.2.md b/docs/release-notes/1.8.2.md index 418cae9944..d26e2e4ac2 100644 --- a/docs/release-notes/1.8.2.md +++ b/docs/release-notes/1.8.2.md @@ -1,7 +1,7 @@ (v1.8.2)= ### 1.8.2 {small}`2021-11-3` -#### Docs +#### Documentation - Update conda installation instructions {pr}`1974` {smaller}`L Heumos` diff --git a/docs/release-notes/2875.bugfix.md b/docs/release-notes/2875.bugfix.md new file mode 100644 index 0000000000..0a4866f175 --- /dev/null +++ b/docs/release-notes/2875.bugfix.md @@ -0,0 +1 @@ +Prevent empty control gene set in {func}`~scanpy.tl.score_genes` {smaller}`M Müller` diff --git a/docs/release-notes/2921.feature.md b/docs/release-notes/2921.feature.md new file mode 100644 index 0000000000..e3c964abb2 --- /dev/null +++ b/docs/release-notes/2921.feature.md @@ -0,0 +1 @@ +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/3042.bugfix.md b/docs/release-notes/3042.bugfix.md new file mode 100644 index 0000000000..8317856177 --- /dev/null +++ b/docs/release-notes/3042.bugfix.md @@ -0,0 +1 @@ +Fix `subset=True` of {func}`~scanpy.pp.highly_variable_genes` when `flavor` is `seurat` or `cell_ranger`, and `batch_key!=None` {smaller}`E Roellin` diff --git a/docs/release-notes/3115.bugfix.md b/docs/release-notes/3115.bugfix.md new file mode 100644 index 0000000000..2e31f4f691 --- /dev/null +++ b/docs/release-notes/3115.bugfix.md @@ -0,0 +1 @@ +Add compatibility with {mod}`numpy` 2.0 {smaller}`P Angerer` {pr}`3065` and diff --git a/docs/release-notes/3155.feature.md b/docs/release-notes/3155.feature.md new file mode 100644 index 0000000000..770c504348 --- /dev/null +++ b/docs/release-notes/3155.feature.md @@ -0,0 +1 @@ +Prevent `raw` conflict with `layer` in {func}`~scanpy.tl.score_genes` {smaller}`S Dicks` diff --git a/docs/release-notes/3163.bugfix.md b/docs/release-notes/3163.bugfix.md new file mode 100644 index 0000000000..7e82799ee8 --- /dev/null +++ b/docs/release-notes/3163.bugfix.md @@ -0,0 +1 @@ +Fix `legend_loc` argument in {func}`scanpy.pl.embedding` not accepting matplotlib parameters {smaller}`P Angerer` diff --git a/docs/release-notes/3176.bugfix.md b/docs/release-notes/3176.bugfix.md new file mode 100644 index 0000000000..bd22b88f8e --- /dev/null +++ b/docs/release-notes/3176.bugfix.md @@ -0,0 +1 @@ +Fix dispersion cutoff in {func}`~scanpy.pp.highly_variable_genes` in presence of `NaN`s {smaller}`P Angerer` diff --git a/docs/release-notes/3184.feature.md b/docs/release-notes/3184.feature.md new file mode 100644 index 0000000000..3cc976b141 --- /dev/null +++ b/docs/release-notes/3184.feature.md @@ -0,0 +1 @@ +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/3196.bugfix.md b/docs/release-notes/3196.bugfix.md new file mode 100644 index 0000000000..8b701aa42f --- /dev/null +++ b/docs/release-notes/3196.bugfix.md @@ -0,0 +1 @@ +Fix axis labeling for swapped axes in {func}`~scanpy.pl.rank_genes_groups_stacked_violin` {smaller}`Ilan Gold` diff --git a/docs/release-notes/3217.bugfix.md b/docs/release-notes/3217.bugfix.md new file mode 100644 index 0000000000..9054c4c8f6 --- /dev/null +++ b/docs/release-notes/3217.bugfix.md @@ -0,0 +1 @@ +Upper bound dask on account of {issue}`scverse/anndata#1579` {smaller}`Ilan Gold` diff --git a/docs/release-notes/3220.bugfix.md b/docs/release-notes/3220.bugfix.md new file mode 100644 index 0000000000..81437fc4cf --- /dev/null +++ b/docs/release-notes/3220.bugfix.md @@ -0,0 +1,4 @@ +The [fa2-modified][] package replaces [forceatlas2][] for the latter’s lack of maintenance {smaller}`A Alam` + +[fa2-modified]: https://github.com/AminAlam/fa2_modified +[forceatlas2]: https://github.com/bhargavchippada/forceatlas2 diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 3dc461d0b4..ae08bf99cb 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -2,181 +2,5 @@ # Release notes -## Version 1.11 - -```{include} /release-notes/1.11.0.md -``` - -## Version 1.10 - -```{include} /release-notes/1.10.3.md -``` - -```{include} /release-notes/1.10.2.md -``` - -```{include} /release-notes/1.10.1.md -``` - -```{include} /release-notes/1.10.0.md -``` - -## Version 1.9 - -```{include} /release-notes/1.9.8.md -``` - -```{include} /release-notes/1.9.7.md -``` - -```{include} /release-notes/1.9.6.md -``` - -```{include} /release-notes/1.9.5.md -``` - -```{include} /release-notes/1.9.4.md -``` - -```{include} /release-notes/1.9.3.md -``` - -```{include} /release-notes/1.9.2.md -``` - -```{include} /release-notes/1.9.1.md -``` - -```{include} /release-notes/1.9.0.md -``` - -## Version 1.8 - -```{include} /release-notes/1.8.2.md -``` - -```{include} /release-notes/1.8.1.md -``` - -```{include} /release-notes/1.8.0.md -``` - -## Version 1.7 - -```{include} /release-notes/1.7.2.md -``` - -```{include} /release-notes/1.7.1.md -``` - -```{include} /release-notes/1.7.0.md -``` - -## Version 1.6 - -```{include} 1.6.0.md -``` - -## Version 1.5 - -```{include} 1.5.1.md -``` - -```{include} 1.5.0.md -``` - -## Version 1.4 - -```{include} 1.4.6.md -``` - -```{include} 1.4.5.md -``` - -```{include} 1.4.4.md -``` - -```{include} 1.4.3.md -``` - -```{include} 1.4.2.md -``` - -```{include} 1.4.1.md -``` - -## Version 1.3 - -```{include} 1.3.8.md -``` - -```{include} 1.3.7.md -``` - -```{include} 1.3.6.md -``` - -```{include} 1.3.5.md -``` - -```{include} 1.3.4.md -``` - -```{include} 1.3.3.md -``` - -```{include} 1.3.1.md -``` - -## Version 1.2 - -```{include} 1.2.1.md -``` - -```{include} 1.2.0.md -``` - -## Version 1.1 - -```{include} 1.1.0.md -``` - -## Version 1.0 - -```{include} 1.0.0.md -``` - -## Version 0.4 - -```{include} 0.4.4.md -``` - -```{include} 0.4.3.md -``` - -```{include} 0.4.2.md -``` - -```{include} 0.4.0.md -``` - -## Version 0.3 - -```{include} 0.3.2.md -``` - -```{include} 0.3.0.md -``` - -## Version 0.2 - -```{include} 0.2.9.md -``` - -```{include} 0.2.1.md -``` - -## Version 0.1 - -```{include} 0.1.0.md +```{release-notes} . ``` diff --git a/hatch.toml b/hatch.toml new file mode 100644 index 0000000000..77c1c92b1f --- /dev/null +++ b/hatch.toml @@ -0,0 +1,33 @@ +[envs.default] +installer = "uv" +features = ["dev"] + +[envs.docs] +features = ["doc"] +scripts.build = "sphinx-build -M html docs docs/_build -W --keep-going {args}" +scripts.open = "python3 -m webbrowser -t docs/_build/html/index.html" +scripts.clean = "git clean -fdX -- {args:docs}" + +[envs.towncrier] +scripts.create = "towncrier create {args}" +scripts.build = "python3 ci/scripts/towncrier_automation.py {args}" +scripts.clean = "git restore --source=HEAD --staged --worktree -- docs/release-notes" + +[envs.hatch-test] +default-args = [] +features = ["test"] +extra-dependencies = ["ipykernel"] +overrides.matrix.deps.env-vars = [ + { if = ["pre"], key = "UV_PRERELEASE", value = "allow" }, + { if = ["min"], key = "UV_RESOLUTION", value = "lowest-direct" }, +] +overrides.matrix.deps.python = [ + { if = ["min"] , value = "3.9" }, + { if = ["stable", "full", "pre"], value = "3.12" }, +] +overrides.matrix.deps.features = [ + { if = ["full"] , value = "test-full" }, +] + +[[envs.hatch-test.matrix]] +deps = ["stable", "full", "pre", "min"] diff --git a/pyproject.toml b/pyproject.toml index cde2ce89fb..d4e85bf676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,11 +112,12 @@ test-full = [ doc = [ "sphinx>=7", "sphinx-book-theme>=1.1.0", - "scanpydoc>=0.13.4", + "scanpydoc>=0.14.1", "sphinx-autodoc-typehints>=1.25.2", "myst-parser>=2", "myst-nb>=1", "sphinx-design", + "sphinx-tabs", "readthedocs-sphinx-search", "sphinxext-opengraph", # for nice cards when sharing on social "sphinx-copybutton", @@ -135,6 +136,7 @@ dev = [ "setuptools_scm", # static checking "pre-commit", + "towncrier", ] # Algorithms paga = ["igraph"] @@ -258,3 +260,19 @@ required-imports = ["from __future__ import annotations"] [tool.ruff.lint.flake8-type-checking] exempt-modules = [] strict = true + +[tool.towncrier] +package = "scanpy" +directory = "docs/release-notes" +filename = "docs/release-notes/{version}.md" +single_file = false +package_dir = "src" +issue_format = "{{pr}}`{issue}`" +title_format = "(v{version})=\n### {version} {{small}}`{project_date}`" +fragment.bugfix.name = "Bug fixes" +fragment.doc.name = "Documentation" +fragment.feature.name = "Features" +fragment.misc.name = "Miscellaneous improvements" +fragment.performance.name = "Performance" +fragment.breaking.name = "Breaking changes" +fragment.dev.name = "Development Process" From 8a44ef62f2848a4532f290cf5396503815c2b197 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 17 Sep 2024 14:37:55 +0200 Subject: [PATCH 10/66] Fix towncrier git CLI call (#3236) --- ci/scripts/towncrier_automation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/scripts/towncrier_automation.py b/ci/scripts/towncrier_automation.py index 7dcb3cb7bf..57a093a305 100755 --- a/ci/scripts/towncrier_automation.py +++ b/ci/scripts/towncrier_automation.py @@ -76,7 +76,7 @@ def main(argv: Sequence[str] | None = None) -> None: # push if not args.dry_run: subprocess.run( - ["git", "push", "--set-upstream=origin", branch_name], check=True + ["git", "push", "--set-upstream", "origin", branch_name], check=True ) else: print("Dry run, not pushing") From 7ab0bfeafa2f4890ae9667c0a44986c0bfdaa360 Mon Sep 17 00:00:00 2001 From: "Lumberbot (aka Jack)" <39504233+meeseeksmachine@users.noreply.github.com> Date: Tue, 17 Sep 2024 06:36:26 -0700 Subject: [PATCH 11/66] Backport PR #3235 on branch main ((chore): generate 1.10.3 release notes) (#3238) Co-authored-by: Philipp A --- docs/release-notes/1.10.3.md | 16 ++++++++++++++++ docs/release-notes/2875.bugfix.md | 1 - docs/release-notes/3042.bugfix.md | 1 - docs/release-notes/3115.bugfix.md | 1 - docs/release-notes/3163.bugfix.md | 1 - docs/release-notes/3176.bugfix.md | 1 - docs/release-notes/3196.bugfix.md | 1 - docs/release-notes/3217.bugfix.md | 1 - docs/release-notes/3220.bugfix.md | 4 ---- 9 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 docs/release-notes/1.10.3.md delete mode 100644 docs/release-notes/2875.bugfix.md delete mode 100644 docs/release-notes/3042.bugfix.md delete mode 100644 docs/release-notes/3115.bugfix.md delete mode 100644 docs/release-notes/3163.bugfix.md delete mode 100644 docs/release-notes/3176.bugfix.md delete mode 100644 docs/release-notes/3196.bugfix.md delete mode 100644 docs/release-notes/3217.bugfix.md delete mode 100644 docs/release-notes/3220.bugfix.md diff --git a/docs/release-notes/1.10.3.md b/docs/release-notes/1.10.3.md new file mode 100644 index 0000000000..f2f06ca94b --- /dev/null +++ b/docs/release-notes/1.10.3.md @@ -0,0 +1,16 @@ +(v1.10.3)= +### 1.10.3 {small}`2024-09-17` + +#### Bug fixes + +- Prevent empty control gene set in {func}`~scanpy.tl.score_genes` {smaller}`M Müller` ({pr}`2875`) +- Fix `subset=True` of {func}`~scanpy.pp.highly_variable_genes` when `flavor` is `seurat` or `cell_ranger`, and `batch_key!=None` {smaller}`E Roellin` ({pr}`3042`) +- Add compatibility with {mod}`numpy` 2.0 {smaller}`P Angerer` {pr}`3065` and ({pr}`3115`) +- Fix `legend_loc` argument in {func}`scanpy.pl.embedding` not accepting matplotlib parameters {smaller}`P Angerer` ({pr}`3163`) +- Fix dispersion cutoff in {func}`~scanpy.pp.highly_variable_genes` in presence of `NaN`s {smaller}`P Angerer` ({pr}`3176`) +- Fix axis labeling for swapped axes in {func}`~scanpy.pl.rank_genes_groups_stacked_violin` {smaller}`Ilan Gold` ({pr}`3196`) +- Upper bound dask on account of {issue}`scverse/anndata#1579` {smaller}`Ilan Gold` ({pr}`3217`) +- The [fa2-modified][] package replaces [forceatlas2][] for the latter’s lack of maintenance {smaller}`A Alam` ({pr}`3220`) + + [fa2-modified]: https://github.com/AminAlam/fa2_modified + [forceatlas2]: https://github.com/bhargavchippada/forceatlas2 diff --git a/docs/release-notes/2875.bugfix.md b/docs/release-notes/2875.bugfix.md deleted file mode 100644 index 0a4866f175..0000000000 --- a/docs/release-notes/2875.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Prevent empty control gene set in {func}`~scanpy.tl.score_genes` {smaller}`M Müller` diff --git a/docs/release-notes/3042.bugfix.md b/docs/release-notes/3042.bugfix.md deleted file mode 100644 index 8317856177..0000000000 --- a/docs/release-notes/3042.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix `subset=True` of {func}`~scanpy.pp.highly_variable_genes` when `flavor` is `seurat` or `cell_ranger`, and `batch_key!=None` {smaller}`E Roellin` diff --git a/docs/release-notes/3115.bugfix.md b/docs/release-notes/3115.bugfix.md deleted file mode 100644 index 2e31f4f691..0000000000 --- a/docs/release-notes/3115.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Add compatibility with {mod}`numpy` 2.0 {smaller}`P Angerer` {pr}`3065` and diff --git a/docs/release-notes/3163.bugfix.md b/docs/release-notes/3163.bugfix.md deleted file mode 100644 index 7e82799ee8..0000000000 --- a/docs/release-notes/3163.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix `legend_loc` argument in {func}`scanpy.pl.embedding` not accepting matplotlib parameters {smaller}`P Angerer` diff --git a/docs/release-notes/3176.bugfix.md b/docs/release-notes/3176.bugfix.md deleted file mode 100644 index bd22b88f8e..0000000000 --- a/docs/release-notes/3176.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix dispersion cutoff in {func}`~scanpy.pp.highly_variable_genes` in presence of `NaN`s {smaller}`P Angerer` diff --git a/docs/release-notes/3196.bugfix.md b/docs/release-notes/3196.bugfix.md deleted file mode 100644 index 8b701aa42f..0000000000 --- a/docs/release-notes/3196.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix axis labeling for swapped axes in {func}`~scanpy.pl.rank_genes_groups_stacked_violin` {smaller}`Ilan Gold` diff --git a/docs/release-notes/3217.bugfix.md b/docs/release-notes/3217.bugfix.md deleted file mode 100644 index 9054c4c8f6..0000000000 --- a/docs/release-notes/3217.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Upper bound dask on account of {issue}`scverse/anndata#1579` {smaller}`Ilan Gold` diff --git a/docs/release-notes/3220.bugfix.md b/docs/release-notes/3220.bugfix.md deleted file mode 100644 index 81437fc4cf..0000000000 --- a/docs/release-notes/3220.bugfix.md +++ /dev/null @@ -1,4 +0,0 @@ -The [fa2-modified][] package replaces [forceatlas2][] for the latter’s lack of maintenance {smaller}`A Alam` - -[fa2-modified]: https://github.com/AminAlam/fa2_modified -[forceatlas2]: https://github.com/bhargavchippada/forceatlas2 From 8b7673d11fc77b4ed5d92e252dc069e69bd4f018 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 17 Sep 2024 17:02:46 +0200 Subject: [PATCH 12/66] Fix release note building and check (#3239) --- .github/workflows/check-pr.yml | 8 ++++---- .readthedocs.yml | 1 + pyproject.toml | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml index 88d78c9b43..5eaa4dacb3 100644 --- a/.github/workflows/check-pr.yml +++ b/.github/workflows/check-pr.yml @@ -49,13 +49,13 @@ jobs: with: fetch-depth: 0 filter: blob:none - - name: Find out if relevant release notes are modified - uses: dorny/paths-filter@v2 + - name: Find out if a relevant release fragment is added + uses: dorny/paths-filter@v3 id: changes with: filters: | # this is intentionally a string - relnotes: 'docs/release-notes/${{ github.event.pull_request.milestone.title }}.md' - - name: Check if relevant release notes are modified + relnotes: 'docs/release-notes/${{ github.event.pull_request.number }}.*.md' + - name: Check if a relevant release fragment is added uses: flying-sheep/check@v1 with: success: ${{ steps.changes.outputs.relnotes }} diff --git a/.readthedocs.yml b/.readthedocs.yml index 4ffa520491..adcdfb80d7 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -21,4 +21,5 @@ python: path: . extra_requirements: - doc + - dev # for towncrier - leiden diff --git a/pyproject.toml b/pyproject.toml index d4e85bf676..ec5c6e0541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,7 @@ dask-ml = ["dask-ml", "scanpy[dask]"] # Dask-ML for sklearn-like API packages = ["src/testing", "src/scanpy"] [tool.hatch.version] source = "vcs" +raw-options.version_scheme = "release-branch-semver" [tool.hatch.build.hooks.vcs] version-file = "src/scanpy/_version.py" From 303404ac4fa39c62b93895471591841487e101ab Mon Sep 17 00:00:00 2001 From: farhadmd7 <56954508+farhadmd7@users.noreply.github.com> Date: Tue, 17 Sep 2024 18:21:51 +0200 Subject: [PATCH 13/66] impl median function for aggregation (#3180) Co-authored-by: Philipp A. Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/release-notes/3180.feature.md | 1 + src/scanpy/get/_aggregated.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 docs/release-notes/3180.feature.md diff --git a/docs/release-notes/3180.feature.md b/docs/release-notes/3180.feature.md new file mode 100644 index 0000000000..ab73dfe18e --- /dev/null +++ b/docs/release-notes/3180.feature.md @@ -0,0 +1 @@ +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/src/scanpy/get/_aggregated.py b/src/scanpy/get/_aggregated.py index eceaa40fe2..5318244af2 100644 --- a/src/scanpy/get/_aggregated.py +++ b/src/scanpy/get/_aggregated.py @@ -7,6 +7,7 @@ import pandas as pd from anndata import AnnData, utils from scipy import sparse +from sklearn.utils.sparsefuncs import csc_median_axis_0 from .._utils import _resolve_axis from .get import _check_mask @@ -20,7 +21,7 @@ Array = Union[np.ndarray, sparse.csc_matrix, sparse.csr_matrix] # Used with get_args -AggType = Literal["count_nonzero", "mean", "sum", "var"] +AggType = Literal["count_nonzero", "mean", "sum", "var", "median"] class Aggregate: @@ -138,6 +139,27 @@ def mean_var(self, dof: int = 1) -> tuple[np.ndarray, np.ndarray]: var_ *= (group_counts / (group_counts - dof))[:, np.newaxis] return mean_, var_ + def median(self) -> Array: + """\ + Compute the median per feature per group of observations. + + Returns + ------- + Array of median. + """ + + medians = [] + for group in np.unique(self.groupby.codes): + group_mask = self.groupby.codes == group + group_data = self.data[group_mask] + if sparse.issparse(group_data): + if group_data.format != "csc": + group_data = group_data.tocsc() + medians.append(csc_median_axis_0(group_data)) + else: + medians.append(np.median(group_data, axis=0)) + return np.array(medians) + def _power(X: Array, power: float | int) -> Array: """\ @@ -343,7 +365,9 @@ def aggregate_array( result["var"] = var_ if "mean" in funcs: result["mean"] = mean_ - + if "median" in funcs: + agg = groupby.median() + result["median"] = agg return result From bd758395a669c31a6c9eaa9239750fde368d3ca7 Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Thu, 19 Sep 2024 10:47:10 +0200 Subject: [PATCH 14/66] Upload scrublet scores on test failure (#3069) Co-authored-by: Phil Schaf --- .azure-pipelines.yml | 6 +++++ src/testing/scanpy/_pytest/__init__.py | 2 ++ tests/test_scrublet.py | 36 +++++++++++++++++++------- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 2086c20aa4..e0e8faefd6 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -103,6 +103,12 @@ jobs: testResultsFormat: NUnit testRunTitle: 'Publish test results for $(Agent.JobName)' + - task: PublishBuildArtifacts@1 + inputs: + pathToPublish: '.pytest_cache/d/debug' + artifactName: debug-data + condition: eq(variables['TEST_TYPE'], 'coverage') + - script: bash <(curl -s https://codecov.io/bash) displayName: 'Upload to codecov.io' condition: eq(variables['TEST_TYPE'], 'coverage') diff --git a/src/testing/scanpy/_pytest/__init__.py b/src/testing/scanpy/_pytest/__init__.py index d989b4080c..318baac1aa 100644 --- a/src/testing/scanpy/_pytest/__init__.py +++ b/src/testing/scanpy/_pytest/__init__.py @@ -34,6 +34,8 @@ def _global_test_context( sc.settings.logfile = sys.stderr sc.settings.verbosity = "hint" sc.settings.autoshow = True + # create directory for debug data + cache.mkdir("debug") # reuse data files between test runs (unless overwritten in the test) sc.settings.datasetdir = cache.mkdir("scanpy-data") # create new writedir for each test run diff --git a/tests/test_scrublet.py b/tests/test_scrublet.py index dc08d24837..246ffa4027 100644 --- a/tests/test_scrublet.py +++ b/tests/test_scrublet.py @@ -117,7 +117,7 @@ def _create_sim_from_parents(adata: AnnData, parents: np.ndarray) -> AnnData: ) -def test_scrublet_data(): +def test_scrublet_data(cache: pytest.Cache): """ Test that Scrublet processing is arranged correctly. @@ -156,14 +156,32 @@ def test_scrublet_data(): random_state=random_state, ) - # Require that the doublet scores are the same whether simulation is via - # the main function or manually provided - assert_allclose( - adata_scrublet_manual_sim.obs["doublet_score"], - adata_scrublet_auto_sim.obs["doublet_score"], - atol=1e-15, - rtol=1e-15, - ) + try: + # Require that the doublet scores are the same whether simulation is via + # the main function or manually provided + assert_allclose( + adata_scrublet_manual_sim.obs["doublet_score"], + adata_scrublet_auto_sim.obs["doublet_score"], + atol=1e-15, + rtol=1e-15, + ) + except AssertionError: + import zarr + + # try debugging https://github.com/scverse/scanpy/issues/3068 + cache_path = cache.mkdir("debug") + store_manual = zarr.ZipStore(cache_path / "scrublet-manual.zip", mode="w") + store_auto = zarr.ZipStore(cache_path / "scrublet-auto.zip", mode="w") + z_manual = zarr.zeros( + adata_scrublet_manual_sim.shape[0], chunks=10, store=store_manual + ) + z_auto = zarr.zeros( + adata_scrublet_auto_sim.shape[0], chunks=10, store=store_auto + ) + z_manual[...] = adata_scrublet_manual_sim.obs["doublet_score"].values + z_auto[...] = adata_scrublet_auto_sim.obs["doublet_score"].values + + raise @pytest.fixture(scope="module") From dbabafab8bd962f124f3c8a6ca461a399cd15e1f Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 20 Sep 2024 12:06:48 +0200 Subject: [PATCH 15/66] =?UTF-8?q?Fix=20stacked=5Fviolin=E2=80=99s=20`stand?= =?UTF-8?q?ard=5Fscale`=20parameter=20(#3243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release-notes/3243.bugfix.md | 1 + src/scanpy/plotting/_stacked_violin.py | 6 +++++- tests/_images/dotplot/expected.png | Bin 13522 -> 13581 bytes tests/_images/dotplot_dict/expected.png | Bin 14613 -> 14753 bytes tests/_images/matrixplot/expected.png | Bin 5623 -> 5577 bytes tests/_images/matrixplot2/expected.png | Bin 7482 -> 7465 bytes .../matrixplot_std_scale_group/expected.png | Bin 6888 -> 6878 bytes tests/_images/stacked_violin/expected.png | Bin 8000 -> 8043 bytes .../stacked_violin_no_cat_obs/expected.png | Bin 13374 -> 13343 bytes .../expected.png | Bin 8440 -> 9009 bytes .../expected.png | Bin 9478 -> 9526 bytes tests/test_plotting.py | 15 +++++++++------ 12 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 docs/release-notes/3243.bugfix.md diff --git a/docs/release-notes/3243.bugfix.md b/docs/release-notes/3243.bugfix.md new file mode 100644 index 0000000000..5aa6063b1e --- /dev/null +++ b/docs/release-notes/3243.bugfix.md @@ -0,0 +1 @@ +Accept `'group'` instead of `'obs'` for `standard_scale` parameter in {func}`~scanpy.pl.stacked_violin` {smaller}`P Angerer` diff --git a/src/scanpy/plotting/_stacked_violin.py b/src/scanpy/plotting/_stacked_violin.py index 862bf76098..f2b4a1696b 100644 --- a/src/scanpy/plotting/_stacked_violin.py +++ b/src/scanpy/plotting/_stacked_violin.py @@ -222,6 +222,10 @@ def __init__( ) if standard_scale == "obs": + standard_scale = "group" + msg = "`standard_scale='obs'` is deprecated, use `standard_scale='group'` instead" + warnings.warn(msg, FutureWarning) + if standard_scale == "group": self.obs_tidy = self.obs_tidy.sub(self.obs_tidy.min(1), axis=0) self.obs_tidy = self.obs_tidy.div(self.obs_tidy.max(1), axis=0).fillna(0) elif standard_scale == "var": @@ -680,7 +684,7 @@ def stacked_violin( gene_symbols: str | None = None, var_group_positions: Sequence[tuple[int, int]] | None = None, var_group_labels: Sequence[str] | None = None, - standard_scale: Literal["var", "obs"] | None = None, + standard_scale: Literal["var", "group"] | None = None, var_group_rotation: float | None = None, layer: str | None = None, stripplot: bool = StackedViolin.DEFAULT_STRIPPLOT, diff --git a/tests/_images/dotplot/expected.png b/tests/_images/dotplot/expected.png index ea54ae3447b9bc422597bef934aa4f1daf849bda..9c4b822369debbebeefbb566ea834406e5a24026 100644 GIT binary patch literal 13581 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 literal 13522 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 E0R%UY2mk;8 literal 14613 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 PQG}BclNT)$*7y5gpGZJ6 diff --git a/tests/_images/matrixplot/expected.png b/tests/_images/matrixplot/expected.png index 216626af46924a32906da451345f493007051cd0..cb2421ba028572646bac2d775ab1a4d5dead7451 100644 GIT binary patch literal 5577 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 literal 5623 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 diff --git a/tests/_images/matrixplot2/expected.png b/tests/_images/matrixplot2/expected.png index b4cdc8dc02a18ff39d5879512576bec60214dd9c..58990e2526363224f2691b3eeb3279cd2db50422 100644 GIT binary patch literal 7465 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 KWZ@IT!2bXZuc_n! diff --git a/tests/_images/matrixplot_std_scale_group/expected.png b/tests/_images/matrixplot_std_scale_group/expected.png index 6d1970b3bb972f9aec1d1e09e63ee2060f043000..05fe80da7daadf6a428588a38e2c2871a4d9ac4e 100644 GIT binary patch literal 6878 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 BLG=It diff --git a/tests/_images/stacked_violin/expected.png b/tests/_images/stacked_violin/expected.png index 8130c0e8d829530332121cd8143bae436f338785..1cadbe8ad7a22ec5155661c09406433f1dd29358 100644 GIT binary patch literal 8043 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 CiS?5J literal 8000 zcmd6shc{ep)b`pVD9Q+>}-bi#@N--*1^@*>I0*@nX`+PgFQbt_~Bx-bai!f5#`~r`@aLY z9h@zAdXs8@fQvkGRM2rjL&IYI_d=D_F z#5mcwQ`(dgjKopmQkNK#kDqkffl^{FbrV8#Q9 zFY5HT?VHY~4{IRqyGCt+4{0IpQ6@bxaR~_>*JnFk8)+WGA5Yn8YHIX6mfV_-z4vNn zw5!YsSQ5fF(!FDzD4J|6W|yO#P`K!;iYjKvhu@x6yrXH2|Cb4>A^-Ur`Y!=6!w`X^0A}tE&^gGBYM>gFlLo==C8Qr&j4& z)5S7P%?1+{6laBqDOT*9ihzLNmq8PC*$iImZ@UFHPEICP*3jW$mHCB*zW)AgH+^4U zF%c1wSzAF34)UH@28*em|A9yR@bWvFf~x940^)4Oa73QLw=;$gQg?rM)6qjmhz*0m zOifKk$HvAsM62eM_4H^pi@)vf@2~h^20jWTzT-XKZ#;0`UlhT|dSG3Xg19@c?d<7M z*V7wZZt_O2+bM1IJfE|eYjEQX4h}|peKN=ozuqpcuB}Z-N$D?AFJOD|qRWUkbIj6n zyQpBc*1<3X#SDAK$%)svq#`9Hb$KavvDgtpK;wBjCa=@tce}X=H>_86gtCWMpS&H_?99CL@bu$QUNo32Bxf4#ru*!&D0+{{Qx)ITyc! zwRLuOHbz39Kw=kh6sbxcyMk-lrYwLeoaOVRK z6_NsB`jXFMUoY{+dqHp}PB!6D%>VA~|J&v!*D+99;WU+VhD-d^4p{Ak^v;$2BuDt# zrS>4p1B$AEoihCpl>(XxXL_#JQA7Kkf6vFqjdO0{X{aI{DgseuEU}aIL1uP#E9C*U zFnSjF;Tsk*!}nhLmo5cEF>*O=w2-MG`!Am&Z1rl;{)?hdGVPScN1(Rx z4Hl{m!VGva-GgC8Yiy@rj4HlM{QC z3TbI69!L>P6`kT)99&;@%u}q_WhP(Y0XgcE6m3G+;FA+rw&&`msoJG*!?pyRnf5EvT)v%>Q#7C^!x*M3m0fqwxg$7i)U2vA7&IRLVtIjQ{)XXno;BU`8S0`tnnwO-SfC^o$%?~# zbQ&}> zZB9v8(3mL;70&3=6ig{J?KV^#IiDBV(~u0V`c3T?RIX?_L6eA)jhv>Y2j6sMb*;BE zo5I@47xsi3(%2%T7&DCy+({^HWJ(f2-PKMYDV`FA2e&A{99QpCAZj9Y6QT7%%{jgM zC1+p%k&xq?Bf)nZ<;yL2Mdym6j$a z4r2#N)_oEGgsGe=ND$f8zBN&>wY~j}3jbM=hAV`&Gi502CV@FVK0f)imvh;%?x6X8 zo$;0#w2>av*U*wio^HHn7VUT~jSGGEHyZ}?BW`1(K7^sHr)07GKa;w%Rmm!)- zn+%}4Ua3RorMlIHBS*XJ2{*Lf8x9O_b(R!%>bWsKFGqgMR1VbX$vwriZ@J<$%`b>g zPNq^@PGD8tXKIhs`FJ*krV!uR9*oDN=rUhzMKVXg+Sk`-yVT&OJx+>z7{|X7%s^wa z8sP8?qxIn8q$M?jb-EC#1-;7{u(py~Is~3o)^vV#Tk+a&wtMuK|rIx1a#&D%v+fG}LqupkG}c#ro48ow*T2f6e%nHGpKwJ5f_C-PdfZ|faFu9X1hOW5l z7=w%;U8Z|>XjlJp=?n9a>>A|jiIY;{?@nD^gs%fDj5|Ny_cFzPzQ3Cr@e_PE=kt^l zS+ZEk7+X1Ep(A>5yy$mhB43HZ&?p*dkOi#r$8seEr;EzaisBNBs>q=xIn)HII0V znxQ{d*-X#rR#1!hrH^=j%-1SQW66ww@zZLJ@ptN$R(3h1MGw@aLhX)uu z(nA89Wl9dQbF#7mrhmIX2F>`%H$_=xWqh|Yi^9?(reV^7(=8@sw~fH&BdiCM;t!7? zkTDyHvPTa|!OK@z*cmbGDKN6Gw9oS(#vg zR_X<$l$1UHwsrMf!05BQ$w>`G#aJ|4(l7DxwC8l72!Y!iUZ7LB<6b4;SRJg8Iwiw= zit(Ue0u-2GAJm|PujkaSmks?1U#qiNoQb&a!=oFjhUyl@ zF(jY+z-v0>hUO?%m9fl+l0QC=mE>*5S&8M(R~a(fo?LvoM@mXsx6jEbmnGxNx9PT7 zqwC|07CX_A9;>L`>?pbg({!bNRUyas4!ZnA^Iu1eOG=VAF=4v8x-#_4iZJpq5ui>^ zyZ1UU`dgzFZES^U_}Q5J4saSf7gy)T$q~q45*nI;-MK2*n>TqsfBu|#y!==A5C=1G z#Y{W6Y+|cio68o_oMuy6kCikKtXV8uHUmhH%YBdpt%=T)TlQBu4PD4B-F@@TK5_$k zFA_=d;=@?Btf_@XXosVIR3T<|QX8#jCvM}Iv34*5S*`y5y%H}KGciu)ly%?8h`e`r zPN)PU6-3Tf!!Yk%aF7_z7#=1v(g~6_v$&|Nu0HW;18@Yt#c=1I`vva85?R*z2Zt&V zm-WnvEn)XvDuXhQRsq&GDKdb$|B-Y}jhh4{vP;UqfL>o;|A(mC_6eC-6;=O+%fiEl zPe*E*Qfnw7Ez&#Qg72 zVY>jotn97Yc(A|xWprlzBv&F;JeE0Dr~p%8;c=jTQDTSMK?eP(b(>hU&zV?}f6i{o zoC;GZ|B`6>cX&a%4GK4Sk~9;!u$Qy^h+S?P>*NaHc=jG*>pYKBg-WK1rq~7xw-YBBi zE{;OZTbN;7hV(kbU*|v_OoN^r61OuUs=ez>V>GaT(1^4ki2GdoqXT6k;DyCToS zHte}dvjH>N6%rp2P(v*Q(%eI~!>@hBIXF3wJYVOKvR0OtJ8B%)e+6fpj4SaRUGJ17 zq~2VulC89oHR)})@~5akC@*AEQ&ah_3x+RGqPHznjM}YcpY+U-KQx)QY@J+Is$XC3x60f?))w0 zwH7DeJ2=S9#1!-krG%+NA#=9*m6d=mRIRMoy)X9jGBZEL$CH9>tX*mP=)eE| zW4fl5vuj~m>13kfS#)2p`?q>%${FHxwF^TaGKU|Vz|Lp2)q3|XaAc$7;KD>O72Gx` zs9hlVENERn>;HlI%h$MJV`AU|;G81%{s|e7pQt(iwkuih zr)>FSmdsEnIDDwt_r}uRJ|A2(7q67vSt1{`sRkT`y1VP08@E68lW}IpYkeT_pb7z7 zqT7hq@u*{~qGrx^(GwZ%>gMLO+8JIRx|$aw(q{A}7?1MY>Ce%?OWo)Y;A^-G^bZfm zcv7ZSnUtgPG;^!tG?!e*xQxroSPEITfWEzc$c*M_&Bb0S+3?!*nF zxCRvBCwG9y0UePh5}V}o-09li5%bdjVWFz4MS6JV{G_)1{w2Woi0sqGwlxHFb_8w!p?h6(u!4naeOB`1w};!xxhIU~nP)*Z+YhL}pEhSs zK=VN~jZn-&9IArahsKH}>OpJ|GTidMvEdp^!t33Bw)Oooa>t7jPVGvg=}#k>S9+8xwH691T2q2Zb*ZBxtker} zm)|6*@=|KUabE@ME=STJ^0v0NWfZ#ufm{O`|NWE@f{89)(dmQ3(G9={GxU}dx z@do5dDCo5u931~LpN}uD-euz*2!mqYD)<*j^okE%vogxtvBthVP^VSQw6wIQhRu!u z#6dy%ufX#d7ik(LQj?j`aQx?mnKMLOqG>8UHA+3mJV>jw(uchHK1%O^zX*>$R&!-}w5iBrRCT&LR#Tg0-?oLz}yNdD(mM94)^Wc->}j`7&9jb#P}Iu<8@ko zQJPA%6LfJUW4wpr0b;Xz`kStDsJj3ydW=HuB8h7n6SU1u;ZSyNjkb>uaNCR=B^HR2 zE?RF~{yZu3Iq*j8TpXm@H-xK-Uwwwd;fI22G(z_HM586z6&NL&1Cczxt-*!xVd?c`VM`7As5WKUUnUmeBk@5NYemajulJsm|D*PsE>1a9W|NbK{ z)-3sOvA?tuXInfq&zUjsWc1|ffu-x8i-Dk+N({C(#_>$(_()OVFH_CCQOK5n;H$Xr zd_LS2;w{is61{KMj%qbE9&ZuO^f}KTWyp6_{G3=*8Qv6X_C2V+X3+cm7KP&PDTXPl z$QG*+aC}T%QhBFH{mSiZFw8Nwg{Ol;7g%?ufBU~R#pt8b&XoOlv#>!3mNMmS4+JVzJ3-@yIjKKKF7f09AYgVT5cHDp^t&K-?zr~j31!9KyduRHO=+d29ki50pEMoT2Ium{ z1&LGgt?dq&Ux|^mAe1}i1BVQ%hsbbHkYg?-w(8P`i%W|OCde7N#fwHn zk|qLnE2}J*x%9Xx-<0fKng!Q=&UKVUo_%zh9m!FHDGw{}oG}KqG1Ae|O-@hoTby$# zv#7WHbrDUQ-E|kWBOGKAJ32Z_9$NQ9)SKz7&mJkdHc*)lColKpuh=q)i-akbJQsgD z8GT;2BupzP5HDk`xN5d1g>-Hd(Vq!pEo`H**>WpwtV)xKOouPBV`8A6({nM2m?*Yv z3GWK#=cn`zDimZDeUBNoSDuy;NEzX@Et?6dT~N`D|5p8#8E(Kc^uq3Pjj^w2YsK5m zb?(SpKlzPZe8P>N1JdmJ{7+kukEZk2CspQ5*pJsfNikA&GHBpnK3P4w zRoaE-PKiEOF%XtOrl1HXzLG~zc!~ulT#F3BoBX6!CaWe@g>}x2tw74B`|A9}u4zJ} zsN7BDn*7f5!}*VTEayfBN)T=_3ZQiYqlEk9D>#i?+D9vx(e2o!cgkt=E(-xEY zDG$-K$;rtmGZM@k1u$GFOU5_pJ2Ls(+x91!aR1>o&Ekp7pEObYsG>uQp%n5seJ?MO z)-MKrar4nKDbEhFPKJkuqAwu~FduP-k0qaFq2U%woPt{#~oT zj=~SnBq;C9Zf2m?Jsi1zYp)7I%lHGYS9z%wS2{+uI#arcgq->%E7EPEf& zo&8CSducQM>(B!lAGM=)`|~Hq`1p9aZi(%}?`Q|#o%gr%HNSsn&+LRpM&^Lnv8!KS zM0zwX6>4y}iqfeRxU~M~C3foiP)Siy8}8}UA_#&|IAyKOLI#$ZhF91OL`fJG&kXzZ zzSe#e9vKngSg-drG4UAK?=6O@0F_QZih-%n{QSJ+`|_5%voZ}$%@2a9SHm97{FzHD z9ibwwn{S0DPb44=XFtFfvqii#eujxB71hFV@XUPpN8ebEJeUyKT!v0F6crT#{y0f$ zK|KWgudXUKRhHIfWCVivL&PN{C53u@Bx9t52>RNnK@1(*KQMs(_%57`^^4i}@$q+` z&}IJv!tRaGl8m`I%R;RK1MdiZlpGT{o9XFOV~JG2N{5J7uU-Y=wpdK9yyeoV#LOA% z1>!Zh2l-^Qw6G8dIKSX>XmvOHjiJi#dHtwn+CxWg%>Det0jIQb^L*C*i$eKe1hi(l zgMkb1k3n4ASCL5U!P@NXK>xdw3^ZZd81y|5;<`Gj)FDC3k9Kx;EB(@mp)t&^V2bB= zn!|9kU0g;;ef={pFZlQG-%lGCl#;m3w#M_|$NikrA>$m}+$>Niloku#VzK4k=mo}? z$q)~F89wxfzzh+q!*&EaBeeDv^)B|b2`g+o)BWGP5G*oru~r$qs}fIWM_bA(n|~uS z5OSK)(w{#g9QA=rJqVXXfMY-FK=5O1%z#(%QjZba%IOy?~T-w+KjgH{8Sju5~}%4>~$C zYvwu6*=O%xO_-v*1oAtAcMu2!SxQpu3k32;2>ja!9v1w4v$Q7w9(bHSt2rs#nmD=Y zI~YUc^quUiY@Mvk4ai)K9URSVZ8%tXSlF4!%$%I;9C_crxBh=`V6k;DeNQeAwFo{0 z!A?@$5dwjwef{%BPGw;L0^x|15))Q&%Q(t(bt9T>c@eDl`d0o`{ew)MmeHoaH7goY zps%5!e*n3ikB}tNP$3Ok4@?5Y@3cVim^#oG0UBP7km$9uG<-@9QmuRW8cR;I;+Ws>mvRA{d=TB_uaUJgoTCI z-E#4SU|@n0aQYz8idrnV4Gj%YNz1i{1F_en3XRsw;@`i2|6*NHRn>oRMG~M&pCL&H zYlr7>v+9a^hu5u#7ShY6*RqrC<1P6R(xdx)j!Qs50Qjy6$JIH=LWSm-{2$cZM!nZKL|!LX8W)@L5}1|42%5IP1f$*YAZT@i=5V zZarWj@jMlV7XI6|_Qc}2Gq%~Mpg)RGLR}pnRq&D3a3C7yZ>H;?+=hn4=xCJA=X*<*)?JF@_6zw6RmRH7%J$P9 z#1)riYa?T0`$gkqrElLPY;0I;yPiIxgap*pu|MDVyd=YsxIv`iQ#j2rxc<0pDm`@4 zAt_)%aZ3`Rz&UP@=#ORaJf0_a6?AlDzHY}bOIzeYRa>-F0*#WQ;(xEpt!(-eKE9Km zm*?NpBa-EPZ?5CMNA1K*48hg)WYhJ!!X8dy-rNy<;VI7c=0)W@g}m>2WCz#2^>bQ& zvkzDI*U61zANDu9y~1RN*T=vAy`hrY825SRXj*YV^b>+KEsvI0cmBRUS}rXq`P;IS zwVCB{tiSOq43Z*C^VSbS>UsKxn3$Nq>Hu7h!QND1`_uJug+@cT9_wJ<=Lj=%^Yz4U zI^CORc9WlR*=+x`>zoV^kot^F70SF>==Abv$6cD45hFwU54_`~ux7|2kdxZVIfTVXusn)|G?AD+W1O~BhSm@g?ftvb?9vR1^(* zjC&L5`T3@+-eiQ_<0G-L50o(dfaR!T+&N~rKl-;Jvm3iR8<)wX1? z9Sdcdf-NT1ERjAKCg0w|~D#VK;s!kX=3Sp>);)GBq^?5B;`e)&dDu^znl9 z%|23X%#}&>v|BcooZ|^#*cs_b4 z{;nP?UY9Fu!0RHw=X8lK9N&l+Psb%C^=Cg{(*E&z@yhZ%A98B}g~-6j*aFV$se1=r zRR8VMSMEDSq`&9}-Tp9-PbWUFw|;BIar~%Okd`*&wfd&zKSfzqcO)&_c4e+T9lL@9 z$ji&CR%ax&l5AY7=JwYQuK9Y&QoYHBn2^iH2oxZbT8;0#jz<)zc3(U0k$*!8i(Ni~ zT`x~3O69X^Oi3g=9cTT7cURRuqo9bSjg6`O$xgS^ZR@O;8+EGl%Wqno_P%V7riVxu zZmfrpZgX|rAqzgA#eCmN{I<~M`W`0&CQ-VN*mVWHni2l3a*^zMHyr7|_da)splCd= z7ftydPkVnhHh$679gnA$Id$VRGBtJV$2UFQ&5r^1TZ9g4I`(NdbcwJvbl)EeT3^^Dk;!trlzM8Eb`Y*2Fy77JmSbxIdB}n23hw^sFHv5n_gTTn*F9rOe(@icR5h= z(RXxo^l-7>RI=kOOb{l1_V~a|sWLqi6W<>>{5YfoftM_PkCM_-o~0e#BD6LX-mc zTgA0lNQ>ptLscnrR7({R*a;HD<0td+xLlARpBOA#o`=R`VVtLS+P4oyo^LNmsjMsy zcK`O)b-;_P1*G#Oq`lm%I^M6mTvbVX>X(i`eO@%7i6maNT_!@I)yeJ}aEa)XH4zu@ za>kA(25aM{P^(rTdA z`^_LS-}hc7nyo$eL$1=#HqXc00^gm;UEjCkIcg|;#_!M>&oDQP?djGTk0XD$8{1R9 zw;nK0=qJB(FT#v9#;-ptc9K2woi~u;TDNb*Xmobr9U42NS1+=AR7A?@sS!bg#?i8) zIeGLZPaYc#vmNIucRR|0dECF385|uuyusTHN5r>2=J}CC6=xeonn<>^M97;NJxv=) z^G`uG59^92^@E|qmwElsF&ctv%rG(E6tW79=*zOj67UTjpnpDYlD>r6zD{rTb~m<< z=}`@q^L5~4tk`gYA-;7sKssR4bnyxvZZ~5n!zpBvnDj@}KJcSF?w41egMQI3=mRiF zJNPIFlWu1QZI;{H1?OAhr3^cmUp&BXZSB#kA~;2Da=c4006$@X zMtjYG{c-{4{5w7Uh(ein^asR_*b+BuIW814zBZ0VBTf`eLddZ^Vc+NQykR@h#LUd7 z@tufbBqm12&BkS$%~jVm{`$ID{DEr2cx8XJ=~HVpO;4q7&&KMf8Iy#fWxs+M4{ zhihu0pOux!0o}e#!wW|)5R>sNIa5u>pB_;6rlY@t5J2*%?qs*DfxUq@-CHx8@e3PFBnp z6x@5>;=|=R>)0D{E|8L2&f@PG*RF@#YY1wh;g} zPEJmG0FA;1tax6G!u$JKbgsfC zT+n2IDfvEb>2UZ27NT%-pS zxmzQ5S@EReH}L1NU?o`bA{Atb5xwvJJ+%KUqUpYMCWn8UZrSm8Ii6i-ZM#;A!Fwn# zu=EpCDLIAHqMBo*)z*dj%4N(9#fa$w`?nflJ%) zRRX`9 z#D7x^B!mCiz{zfNm?GeaOG+=MRN%Shm!({@N;lFJZz_i+Z0FJLd1}nFE|64n#jU_O zGQ-5Y#(yv&w#>Da_!CapmgrU|sGUU~KT&EgMKrRhxArJ@RV z*_uN*>gg`KfxGV~*Cb4&KBDbGcJ^4~QP81a8l>eMWe7)^D_7ld*iA?H_>W^M7TR#D z=R+bP@qyBX{rdHo?}9g^X|JxLI5|1N_P=GHF3Kd}lAWEMt>u;w4!zN%+MB3WgFw&b zdJVEC(8z`FTvwKt_u4cKc;B!~*2!`ck#jJn+i^#(oqVpI_G6+ffri$5ygfS$+yg*^ zv+1WVQ9ZHt&iwwRSRY*-UEV(LOE+v3F$c{bN*i0-;;K$^oPF|PvXa5k@qw*iAIHEvzCAs{Ak;^ik- z;4T$CEVqm%6q1VHt#OWzixZa6wES|loKf`#Ij3jR_}4YSAmVSq+56wUdN+^^C{5NA zoSC_6ZGK%eRFsQW$dEAp6>s=OJ-6dNUif)kDtBkfVa3Siv>pu1E9wY5y?tEB(6P!S zfff!L%AqEb(~8n(-<>d@<<=5#;ze$;$p%0tn(==C;h#o*%k5k0e!2Bu24VwmUh&eU zga~R4Ab(3s8h})Q`LNmh~T<6{CX z>6FxRr`M;0xyntz9SRBxpxai*0AiO@RmE9*dA@mt&M5+3yv3O=1h4b~0ObtlIrT@t z_P3KXZFsi1UPXz2RJm}sfD9&ZjPoTBe{!j@TNe?y-=%~|OG^(5Gzad0LHBRI>-hr! z`G#wqUU*UB;SreF4uGu>18zJNLh23g3+Qz?V^vOPXV$pDLnLUBY0d|8N?*VB1tMUU zR8^Ubq_72wJ;4 zJYQt*9UlI>Kd2r!a=DmMRyYGZ@plX==Z0Y-U0uwS-z^+%6YQCC+14Aglo zN4fSfy#OJ~IQXbhYq3`Ino^awpYM3+E4zL!6T3aVffg<*aQiF9O&Jy3FK#4^sreNH znWL!SMI~0^9e@iq)|41hobBwa4DV@6js5DF>c5*(w_ax4nsVsK(-SAF5c27~ca}Rc z+UZlZ2%V&3@nB4#jg*nX@hN#FN6F|-7zK5$N#Y@-3Dj)^{_b6E2~7F_?J3iu&{?6u zL!0f{8H^4Apn{IzM#sR{c;FjDE0B$vJfP6{p|NWA$VGpmEXLwXc2c9u(jf_7H~jt2 z5gp{H`J-P2*fn?`A{puTXT1Pp`r3fZ{fH6#Q}hV=yVVbzoj6N57A;z=cS@@h;zb{D zlPiPhF$YkeH(o_gE6U zHH)>|zmT$Yss}cCTZ|%6aJyy?kfEQl@=YL`%e)KP)6%NPbW@jJf*B)8De(TIC&iB6 zs7=Q+aMpIt+)6EN2KEr+lHvl$na{ZIFNw-DI*82b^yRo%y$^T2Ndt*L!f9yBJ_;+E z+jD0Cih89^^55cKUQ2IxH}-r-o`{K)Fw5TFw!knQ7?qK4RLY^*r;)_^&_bWaY0^hw zIiX5=)R^{~wON?p64VgShYIP<8Z>wmHA&~|9T#}kX5f|;2ows_5-!ErGY z_>`*{x;^LecAc*1l4(ko4a#h2to!(E;<$oZuDYmo{QuG9fC4fmzQtMl&-XIP;WB2Zx8{=-U?bPVYRqGc$eB65@qTRD^}6 z98T+)re|g%olZM+iVMjETShE^Al0@SU8aeG6eTWdXh^xZxR@f#?|d2!?cR}95$U8Q zKP;u&@aGZBbXza!GTa zn3RQ50Qf}PH8C0GsF2iIu1fN{9iFWu4gPwad>4BvI}simtX;|BlD4o4&fv&z@nQvu z{1{`ZgWpM}oTM52Ka8)BtEq`l*uQVwlJI)eF%3M1Ff;F71~*q^jvo}-sl1^{DE+oc zKg`aOj0m(JBqx_v*0d2U;tbEldJkOiT>&~VWbi-<33QCe3+-J8LJ=9JOb=$*@3Z>` zIEzs6&w&BgD%JA|iHX?bFq>EI_AaflM);LuLh{}QD9bC4DW8C@I;HQLg8|LXv?t*y z$rMgOkm#$L#{d znUgA33dWM-jE!B5v5V!UweMxt2iZ1ggkfd4iR7j3pX}l$)6xidwX4+pX)B{S>v_5- zhU&l+r~P|FDNQlRUYy||t%X_x8oeSN(M22bL_nM|Ld-yKiT~TWIs<{yxj9w;ZpA77 zX+^rgzIc*(C@0>`+sD$a5mo|Z>F5ZXpN$RV;t8&aW0K{e zAB5!J^aoA1ql9WR(q+FIr(Hhe}~?{1Q~+wi#QgV{2fVznaTZ$xLy%K-?Rps-~Eal{|erCKguu8Bcu1%1OOGlTidPaqLvsKN_s}c|IIYN4vW; zqofm`=v(|oyP(|O(qN;F`%8!QZgS2hqUmeAm~{Mz74EwrwMGZ1h_&0uG!tA%UyI3c zyEnNOK*cm;uZmk^KKX4dSrr+McLftEncmzp0h zDUJ-|&+Cl^qKd)NZM^~1Z8wX*8c{sHz}7TWLMd1iBm^KXTQTpSvZ6iHU*oaltToT*y)p-}Wh(vt_3o9530j2u-+U29|pOLf=mTG|E z>)4L?C<7eu$_;-fGL}|U{8R9`$4uw4H33AL@9tm1D;q?BTLnNS0{4AUXQx2tQDL&q z27ngiSntk8IL2!LPXxxs-Ked10F~6t?CjZ;%zNS|MtQ&C?M#JWDZMeC>6p~BfRqhM z&ZTMG9s9N#6O+p?`Ls9@_}MqHmkGmrcy`#m>sZ*^j{P$jCK+E+>dpex%)*6>X$Vka zv$8Va{rr1ssp$FtL%^i58=_9GaXtA1&Ho=zChh-x-%--h$+Da1y@E8^uE|?kG6EP? zq0^Bj!_pEM9{vshf}!zoiTDB%cVBG>wSe5|t-K0PQXlQkI6<$P=;EYG(<81n^MjhW z-=Vr_&|*r*xeN|J@wrSmJLNTdJ}O~NBBHY=aQdpJrUpWVAms=-DYgVfxdZNSl7}~x zGi@ia?keDC|(e}y1Bc!Hr&d3Hn6|S0Qgi32af}J*pBC6_SszIvT5i^@gl#^eWD9m;jUMgl-T#{r zv34SGZn_D$aQSDXDT#G~A5cVB#kk&j~s8_OW7@K@0p*uz(efi~xIDR$i3{ zoEhN8CEN3N(JL20YW3~DjaQbG&^?b|K0E;j1$b{5K40EzHP?XU0W=+{Q7K;d#eU7} zrtikOfrYC4WfNX4`4)&6Q-hYMi$*3RH%Nyb-()v4mN7D1S%`9T&=kqAdnVslWC zH!CV>C=wn_&psr}GGg-Othrz-r4{j`8E5HE^}fIo>7mvOy&>Z1J)A8hGn0f$9h(%p zApFCpbf8Kpf8pcC-Uik4i@cxbuySRfX^fX+%YFIgaDZ5~?-GkTj40v1(gf*{IlLfB zY5zB}3JRv%BdHMntfkeE%GGQmZN$yCh}>({t_RklCX((HHbXoP6Itmfh2UJ46~Ts| ztc4_Cc_#aTwX&HI$474+-6!PYm?nomBc=42TxjrQ&h;98dixPOqH%QEP=|I#c4cEc zi#V69Ga)&zx^Anu)3)r0J3m0{c*-ghhDVn|cu24maovb;l7O3j_=!;QTu|9My|~x2 z?X-^i`79(4{+@G+Ib;PcOV771^MQ5m1D=z`dF7%S)9-&1XJS$jJdkH{Dhp|j)WL~z zdU{Fg21!Rt!mn4AMox%9Y{qJf4NdQw+HN3Q$3X32D7TOt=AN0JNo6{$7Kp8D?+^q+ zw7iWo{4XR|XKVgh=TW17(LF}&xvJ$u%ydPI+j;v!#F;GLq^el>D`WweSc}?)GD@C`bSU9bgUoSzi|wL{eX&8sr^n=JPeA+*Lh9l;;98$ z7EcUMvUpjpQnTl0SJ;LUR5hlxjdkz(xjw6Dtht;MoEfJEyMy?ok>liwNj|G!vBXLD z$<6eX2_*%h+66{PBseEPOnhFVLDKN+@%OyE_oL-JJ=@*JO6?D?gX_=>~`Ur($g7l8gz`(XAKJweq9mkaaxef+cdUw#h&!xubtZDTB4wYoD|)eA>=nL{=U*WgeL4r{<#R5Xr(9KEWm>y!G3jyM`78 z_MDxI3&jxICap|tLb8O7H@Qn&CQ2K}M_LNs$($I*MJGTaH_zPTwo)?G#Z5i}`>bnR z!b=pBA<FS;V=0);&I$GO2 zX&qDUEXmXcCiI&(^m`wgEXZ3@{g~=W@@@1KhEgU)F$Y%2D^6u23dDM-9Qam%3qdk`(#7j2SS1AhHSdo3_;D}*jY} z6)j=YZ1$h?FItR6{5`&l;k%WWsV;ikeINKwJi6mPYo6$LJp8l9CYii`a*36k*R^h9 z*w^-V-=V6@c9}1!Qthx{f>b1LVVatnlr=RY<_`+n+cVN_S`q3OWr0}F5bn} zT``=4!4HFvJI3{!j4Hqz@9I1@=*O{IPfhfI5ADDcR^Q$zRbT0{W5M4mtu*6C$J37O zjfklF>+?rzbYOYOorNsPxRY!3cYt6^=!k$S>kMb()P9VA+0CDV61H=nw(?Xj@fVh` z5hTPa;E=L&pk`@^C;l6`8X9Rk=^T#4uT2@v{N#GxOZv>J-!%-dR0UGP&@Ye=@R?bz zx8LkHbja$C*Mq#=jr%xE%W=H|L_44YT}(=l_PwOZ(g2h&;BnG1074&Qx-a+NaF{iI zbv<8IlfY@WxiGt~I^!w`K5@FPd2t;b9T}OJOwG(-CHLN6PAjAW-RWXydJ-sTaJxLaZ#b&3jd3*)q0wQaU+g(F6{Qe2+>$}rhj&t}d! z4QObuDZ5v+RZF_tgP~q=K3Wl=_1@Q9{%$QwJt+`L`fEzcyR{7}AvdkQ__~ATBQrB{ zNIl2%;-AT?UlY{AGBSvOJw}NK^sej8mVH%GQL%dkph`-@z&qmM;Q<=Zt5p$ul?C_Hi9TEkE4cHpWpOP`**9Q zpP;}-fZH)NmiZ|a2#MLA=Lndz@>B#-Q@|7ku7B{XMPW@%Y=_59d}`_tsHd2lBk9XG z*VG}#P0Oi#-`RmFM^1!-N~hq|Oq4_=1swn~Kp@*eRl#_?yBoSwbTkI+k&xf&$^#d_ zkZ}#>NH~4#Mwsgd91jw4qs=zwgUH}lyswC=_;(bo)=gn~=ccwkP>P9}_jnkflF-*VtigZD>!k*idz^X{A$YqpAPYTdn~B1bnr5v64q-NLJh!Cke(1C z6Jo^!UKq^(oOh7P0Kjw7>_>1gtZKE+s6PxM_zLiHPCF04rnQ@is1zDrJa2ms?>h0u8iQ-4G2Az(qGkN1We&MtyGTA$nicO= zH9m6yL@Of9MiRrlgCt5E6Za+agC!17;bl|VgTOgR21S$bs@7_2k1|%b_W%`?Embzd zeZLf#81g(@SinJqfr0svm>8FxJr2qb@~SDHv|ph335_&W%pZ6hT@s;Uyv^k&LH+vm zE9h7CAsJt9#zRh^7V3;;{3%A(ja!}80bS`4sGg5lv?{8qiUyICs+R3Ex6Dmq=vI>p z8}FxUE-hZl7{NbIL%v(fpR5*my~GMTy}rI>7tajdP-+SIVkR)B>AE2^(5=xo`w zm9}KX*JOb!3`VJuaQJyINBBK4Y($=?9S|{Gs8bV=e z=8)MZM_WrWxcJ_EZ;sZJ0|FFMW}b@N38NdT$DhX!){LU+mM_gKv+ah92~UrJK>S7xsV0e543SZ+UJVM)KZ51WQ! zNM)W&S>wxs>0;g^kxjY>!!Xyksl$C;LDt?Jgwv*30NIr#*c@lf(QDn9|+_go3*+&aux3#}!@K@UE&-kw}5iDf{QXzEYp5SqTRErz$5Nqks?#Bd+`b8ytrh;N> z+(@hX2BjI9kP?HJH+ARX_nda|R7$ng!KG60j-74paQf=VZ`+$1Gu9q|+Csszwvr`( zVmaYY*Jc2?YUvZvzbIZOV)aE;5O8_)BLg!qyZ6{`!^tDA_2;7zex`U18JFpQS*{== zw!JHcRT!9R_XfXGTu#SkEc8rb>B!~x@87q8_(>`#?0~f3+rPFx4}{%%v7q8l=QM5E zK$NG^W;Go2Enp%evNZdGZAEI(kn`~PgcI?4a`(A)<@O^ZUpAEf9*s9D9)-eO{`ODX z5qt{fpouxUh{(>iq!yeH?_HFN=)iOp>WOuctPqM6kL%T`o4%42bhe5qHlt!7w@g62 zm>`U7Udd`kPoxS10_jg)uda?yiPA6(*vysISpRme)EkR(6J5DxCkiFHDk`!_J=^?B zF{6)vCvPXIqH+F<+Bdi2sILPF&Dtz|9eLESJCN}TSrsOu%9~4P)o^E6o%y&tQD!_M z&i}n7#JvZ|qZ2bCWK{m*0e$g{2Qi{a#@DkAd0&T&QlkECU8@xA$%9_M6%VJd}`BUS|fICPDx#z#Ms9bo9@MiGMlQG(XidLg^U+SD;t;o~u0jg5;cO|d*)gnBx6B=Q5lEb%wV zy_Ct02Q8Jic`5B`rDVf{tAV_h`vmZz7E|@G$HeM&7xpk>X=x8xL0(6?Yk#AN*rUm% z`r;Ec6Kr(L2J2+6{CKCB)Qya;GCP%l?5U|J8IsRIZk`OTuH8V8#RocZEHpPJk;Xhp zRz%6~V{y!?2jk|lXf9f(5ME_>D7YItcQC?ajLYE_Q-2*$`M&R<1`pM3qr)TFQl}ha zVuIcd5`3za8bQ*9fU&VzFL8pL8^5Oj>E=MpPPNYyV`5_M!_D)<3F&wdDncRL0;2A5 zSs%8*;UXEl)xzqp#QF!_KD02gh0bCEsqE;+-%7E}F`Bm8yNe;vY#+$pgCo`w?ci z*y@OC;rt@%afB2@qFrrs|UY@}2LksK8Rj7k`qT{v>^L!!rauFzZ zTQI19B~rXB8q!fe;1ZxSu-o$-VEI+z^2(~_j}rQ01oR(u<0PqAx~iuwh6eGti|fXBD2Yh8;lhTD z_SY9;#3;Bk#|q9R%V#Y>9u&4cdEkJH4D@O~eOQh99$)9u+%lhR{#_GJWKu)^jmOpEueBB0Ay26a+(uccfCzDkER_E zaFUN6XGRr{hmD@EeUgucAj0`J4o%EoGRtJH>tfIz+v6*U!1chd&zMj1TpX&4eNHW* z!$ha1Fc>*?_Bvj|#isVp6H~)L_zWH;C4az&VLXRQk}<6dnn6G5hv|t?;h`gtJ`#q9 zLibX{g-UW~+=d6{m-L|Lt<#B6g%d@Sg;1hh4@BzH+3h~ri>Jb<{{Lck4X6`47fQ+r zo_Q27L2)2^&I8VcLfPT5_Ev3JwEjl~3h_M24mp45m@cY0kPuWZzOE+VL(_KmpDtD) z%_;ce)7-djR4wTJcw68N2Mmr%f~oky^$Z4y4UZXIZQ;3r2z_e^A|fI^6A?Fq4_Pqp zsCV5wDT+1s;sI))eDP~!P~wV@l8x)EMh77;Cnv{_Q;9|x>opSyjP)G5xAm6uEU$TI z3k!=_iT#=}sO`7MVtkm>{hqTiGg8|y)@K|*=G*V2B?q$j=C9Rn~W<)bx$ zv$HeOZ2jz0v!9Uf_O^+%3>?lR|0G@|w#{tm&Qm7J^ zM?n}p%i#$`FB1R?1WDrb9|t63wAr5Q1^0}q6?iHg;Ilw9)#nt%f{^weAqVr-3N7-^ z&L2R2ivfUR{LAlx;9^L89__)A3i@%Ns;XM&{p3DZsW|{5Wc<4yk;Ia76O4JX?3MB- z$-+0#KiSC3qkx$2Le6ooovGURThyej;bg~U>!!2Ei}6i6?vxLfs?t~4?)&sc>f(T5 zJ)X;q0pRvyUl2r8BS{5G)RWW!zY%=eQ&29`1%msZ4i8|U8vzu$0y0_jvpzs*tktMI zyViACG^U>g2~x1y00LK!W{`SSE<>$_UNxyt1+ER16us#ikBu# zzt?Px)P3B*^(s1(4K6C8Ic_@<&;hjKhjF&R?O&+v@oZ8BHjkflEG*lvvl$$|SK}2pxy*A3g|3C`%oK#QBeX+vP^)ETS^7F(CSm ztVoFtI?I?aeogTFN#c=)jL5k1PfTT)sri@nn+GVzWIZ$fO9#5**M9-bqPc zkrx@7HZ3nU*^)-!FiW?1gCA7z9an(#E&}HL*uWIwE8L5dga$cDzz?&|@792&Z((V< zi9hcBu)J2J(sl3#BTOvTn1`OZr+bQj*nkQ{7^Jb$4QDm4`{VjU58sTXbH#kHoT~vp zf_U`y`F&+NmNB%{V5#2a&F|fn4N~uXh4+A|J%Z#v7XbNSC+LeadO(^z+sR89ZG%6# zcRGqj6f9x6$+j4f7BXWfMRoNdWUduokbDL)dZ|(yTmWrW+guGBu1ALBkX8e1Zcf&+ z>)tTY(}(H4=r!N(m%lO&P<1go^>RVXnvHt(iyO{9AX|yr$_zEBz0g>Vl`P7CEXnE3 zDuPV*%ToyK$C*}1DM}Cs6a=fRnQ=N?&3?N8fdyqn#n>q})hewa5b%Xyoa;~x1I{=! zGV*K7A#PT|JE{{f%rkOJDhzzEMcUfL;KwMWw(V#AIbQ=TEiAx`SlHNwS~noj3HKLB z7DO{Is0M4fz(_&b|5W3+BmWx2Hvv(=0{!Nm$*mWt8?2~cOSfo5@M9f_)Mt6IDiJ;Z F{{u}{c2xiX literal 13374 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 TH4c2%2TD>*PPANDKk)wmRp?DP diff --git a/tests/_images/stacked_violin_std_scale_group/expected.png b/tests/_images/stacked_violin_std_scale_group/expected.png index 963cab263ab1316ff8b0aa67ebea11da277fe98e..af4bf8948b7332bfb45e071465f9519213a6a10e 100644 GIT binary patch literal 9009 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 By37Co literal 8440 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 LFv()^_x}G6B7pK# literal 9478 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 diff --git a/tests/test_plotting.py b/tests/test_plotting.py index b2d14b52a7..60f1a774af 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -167,9 +167,9 @@ def test_clustermap(image_comparer, obs_keys, name): save_and_compare_images(name) -@pytest.mark.parametrize( - ("id", "fn"), - [ +params_dotplot_matrixplot_stacked_violin = [ + pytest.param(id, fn, id=id) + for id, fn in [ ( "dotplot", partial( @@ -317,10 +317,13 @@ def test_clustermap(image_comparer, obs_keys, name): figsize=(8, 2.5), ), ), - ], -) + ] +] + + +@pytest.mark.parametrize(("id", "fn"), params_dotplot_matrixplot_stacked_violin) def test_dotplot_matrixplot_stacked_violin(image_comparer, id, fn): - save_and_compare_images = partial(image_comparer, ROOT, tol=15) + save_and_compare_images = partial(image_comparer, ROOT, tol=5) adata = krumsiek11() adata.obs["numeric_column"] = adata.X[:, 0] From b0597a9f6f114a1aee6737e0acae6b1ca403e1b8 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 20 Sep 2024 12:34:57 +0200 Subject: [PATCH 16/66] =?UTF-8?q?Finish=20`scale`=E2=86=92`density=5Fnorm`?= =?UTF-8?q?=20deprecation=20(#3244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release-notes/3244.bugfix.md | 1 + src/scanpy/plotting/_anndata.py | 24 ++++++++++++++----- src/scanpy/plotting/_stacked_violin.py | 21 +++++++++------- src/scanpy/plotting/_tools/__init__.py | 9 ++++--- src/scanpy/plotting/_utils.py | 33 ++++++++++++++++++++++---- 5 files changed, 66 insertions(+), 22 deletions(-) create mode 100644 docs/release-notes/3244.bugfix.md diff --git a/docs/release-notes/3244.bugfix.md b/docs/release-notes/3244.bugfix.md new file mode 100644 index 0000000000..e918765588 --- /dev/null +++ b/docs/release-notes/3244.bugfix.md @@ -0,0 +1 @@ +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/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index b164f30c7c..ddd639f62d 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -21,7 +21,7 @@ from .. import logging as logg from .._compat import old_positionals from .._settings import settings -from .._utils import _check_use_raw, _doc_params, sanitize_anndata +from .._utils import _check_use_raw, _doc_params, _empty, sanitize_anndata from . import _utils from ._docs import ( doc_common_plot_args, @@ -30,6 +30,7 @@ doc_vboundnorm, ) from ._utils import ( + _deprecated_scale, check_colornorm, scatter_base, scatter_group, @@ -47,7 +48,14 @@ from seaborn import FacetGrid from seaborn.matrix import ClusterGrid - from ._utils import ColorLike, _FontSize, _FontWeight, _LegendLoc + from .._utils import Empty + from ._utils import ( + ColorLike, + DensityNorm, + _FontSize, + _FontWeight, + _LegendLoc, + ) # TODO: is that all? _Basis = Literal["pca", "tsne", "umap", "diffmap", "draw_graph_fr"] @@ -688,7 +696,7 @@ def violin( jitter: float | bool = True, size: int = 1, layer: str | None = None, - scale: Literal["area", "count", "width"] = "width", + density_norm: DensityNorm = "width", order: Sequence[str] | None = None, multi_panel: bool | None = None, xlabel: str = "", @@ -697,6 +705,8 @@ def violin( show: bool | None = None, save: bool | str | None = None, ax: Axes | None = None, + # deprecatd + scale: DensityNorm | Empty = _empty, **kwds, ) -> Axes | FacetGrid | None: """\ @@ -729,7 +739,7 @@ def violin( default adata.raw.X is plotted. If `use_raw=False` is set, then `adata.X` is plotted. If `layer` is set to a valid layer name, then the layer is plotted. `layer` takes precedence over `use_raw`. - scale + density_norm The method used to scale the width of each violin. If 'width' (the default), each violin will have the same width. If 'area', each violin will have the same area. @@ -808,6 +818,8 @@ def violin( if isinstance(keys, str): keys = [keys] keys = list(OrderedDict.fromkeys(keys)) # remove duplicates, preserving the order + density_norm = _deprecated_scale(density_norm, scale, default="width") + del scale if isinstance(ylabel, (str, type(None))): ylabel = [ylabel] * (1 if groupby is None else len(keys)) @@ -855,7 +867,7 @@ def violin( y=y, data=obs_tidy, kind="violin", - density_norm=scale, + density_norm=density_norm, col=x, col_order=keys, sharey=False, @@ -903,7 +915,7 @@ def violin( data=obs_tidy, order=order, orient="vertical", - density_norm=scale, + density_norm=density_norm, ax=ax, **kwds, ) diff --git a/src/scanpy/plotting/_stacked_violin.py b/src/scanpy/plotting/_stacked_violin.py index f2b4a1696b..3dcbbf067a 100644 --- a/src/scanpy/plotting/_stacked_violin.py +++ b/src/scanpy/plotting/_stacked_violin.py @@ -12,7 +12,7 @@ from .. import logging as logg from .._compat import old_positionals from .._settings import settings -from .._utils import _doc_params +from .._utils import _doc_params, _empty from ._baseplot_class import BasePlot, doc_common_groupby_plot_args from ._docs import doc_common_plot_args, doc_show_save_ax, doc_vboundnorm from ._utils import ( @@ -30,8 +30,9 @@ from matplotlib.axes import Axes from matplotlib.colors import Normalize + from .._utils import Empty from ._baseplot_class import _VarNames - from ._utils import _AxesSubplot + from ._utils import DensityNorm, _AxesSubplot @_doc_params(common_plot_args=doc_common_plot_args) @@ -118,7 +119,7 @@ class StackedViolin(BasePlot): DEFAULT_JITTER_SIZE = 1 DEFAULT_LINE_WIDTH = 0.2 DEFAULT_ROW_PALETTE = None - DEFAULT_DENSITY_NORM: Literal["area", "count", "width"] = "width" + DEFAULT_DENSITY_NORM: DensityNorm = "width" DEFAULT_PLOT_YTICKLABELS = False DEFAULT_YLIM = None DEFAULT_PLOT_X_PADDING = 0.5 # a unit is the distance between two x-axis ticks @@ -274,13 +275,13 @@ def style( jitter_size: int | None = DEFAULT_JITTER_SIZE, linewidth: float | None = DEFAULT_LINE_WIDTH, row_palette: str | None = DEFAULT_ROW_PALETTE, - density_norm: Literal["area", "count", "width"] = DEFAULT_DENSITY_NORM, + density_norm: DensityNorm = DEFAULT_DENSITY_NORM, yticklabels: bool | None = DEFAULT_PLOT_YTICKLABELS, ylim: tuple[float, float] | None = DEFAULT_YLIM, x_padding: float | None = DEFAULT_PLOT_X_PADDING, y_padding: float | None = DEFAULT_PLOT_Y_PADDING, # deprecated - scale: Literal["area", "count", "width"] | None = None, + scale: DensityNorm | Empty = _empty, ) -> Self: r"""\ Modifies plot visual parameters @@ -690,7 +691,7 @@ def stacked_violin( stripplot: bool = StackedViolin.DEFAULT_STRIPPLOT, jitter: float | bool = StackedViolin.DEFAULT_JITTER, size: int = StackedViolin.DEFAULT_JITTER_SIZE, - scale: Literal["area", "count", "width"] = StackedViolin.DEFAULT_DENSITY_NORM, + density_norm: DensityNorm = StackedViolin.DEFAULT_DENSITY_NORM, yticklabels: bool | None = StackedViolin.DEFAULT_PLOT_YTICKLABELS, order: Sequence[str] | None = None, swap_axes: bool = False, @@ -704,6 +705,8 @@ def stacked_violin( vmax: float | None = None, vcenter: float | None = None, norm: Normalize | None = None, + # deprecated + scale: DensityNorm | Empty = _empty, **kwds, ) -> StackedViolin | dict | None: """\ @@ -735,7 +738,7 @@ def stacked_violin( Order in which to show the categories. Note: if `dendrogram=True` the categories order will be given by the dendrogram and `order` will be ignored. - scale + density_norm The method used to scale the width of each violin. If 'width' (the default), each violin will have the same width. If 'area', each violin will have the same area. @@ -839,7 +842,9 @@ def stacked_violin( jitter=jitter, jitter_size=size, row_palette=row_palette, - density_norm=kwds.get("density_norm", scale), + density_norm=_deprecated_scale( + density_norm, scale, default=StackedViolin.DEFAULT_DENSITY_NORM + ), yticklabels=yticklabels, linewidth=kwds.get("linewidth", StackedViolin.DEFAULT_LINE_WIDTH), ).legend(title=colorbar_title) diff --git a/src/scanpy/plotting/_tools/__init__.py b/src/scanpy/plotting/_tools/__init__.py index dceb779fd8..47f392bd44 100644 --- a/src/scanpy/plotting/_tools/__init__.py +++ b/src/scanpy/plotting/_tools/__init__.py @@ -15,7 +15,7 @@ from ... import logging as logg from ..._compat import old_positionals from ..._settings import settings -from ..._utils import _doc_params, sanitize_anndata, subsample +from ..._utils import _doc_params, _empty, sanitize_anndata, subsample from ...get import rank_genes_groups_df from .._anndata import ranking from .._docs import ( @@ -47,6 +47,9 @@ from matplotlib.colors import Colormap, Normalize from matplotlib.figure import Figure + from ..._utils import Empty + from .._utils import DensityNorm + # ------------------------------------------------------------------------------ # PCA # ------------------------------------------------------------------------------ @@ -1213,7 +1216,7 @@ def rank_genes_groups_violin( use_raw: bool | None = None, key: str | None = None, split: bool = True, - density_norm: Literal["area", "count", "width"] = "width", + density_norm: DensityNorm = "width", strip: bool = True, jitter: int | float | bool = True, size: int = 1, @@ -1221,7 +1224,7 @@ def rank_genes_groups_violin( show: bool | None = None, save: bool | None = None, # deprecated - scale: Literal["area", "count", "width"] | None = None, + scale: DensityNorm | Empty = _empty, ): """\ Plot ranking of genes for all tested comparisons. diff --git a/src/scanpy/plotting/_utils.py b/src/scanpy/plotting/_utils.py index 44e85c5c68..b56649e568 100644 --- a/src/scanpy/plotting/_utils.py +++ b/src/scanpy/plotting/_utils.py @@ -3,7 +3,7 @@ import collections.abc as cabc import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union +from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union, overload import matplotlib as mpl import numpy as np @@ -19,7 +19,7 @@ from .. import logging as logg from .._compat import old_positionals from .._settings import settings -from .._utils import NeighborsView +from .._utils import NeighborsView, _empty from . import palettes if TYPE_CHECKING: @@ -32,6 +32,8 @@ from numpy.typing import ArrayLike from PIL.Image import Image + from .._utils import Empty + # TODO: more DensityNorm = Literal["area", "count", "width"] @@ -1309,10 +1311,31 @@ def check_colornorm(vmin=None, vmax=None, vcenter=None, norm=None): return norm +@overload +def _deprecated_scale( + density_norm: DensityNorm, + scale: DensityNorm | Empty, + *, + default: DensityNorm, +) -> DensityNorm: ... + + +@overload def _deprecated_scale( - density_norm: DensityNorm, scale: DensityNorm | None, *, default: DensityNorm -) -> DensityNorm: - if scale is None: + density_norm: DensityNorm | Empty, + scale: DensityNorm | Empty, + *, + default: DensityNorm | Empty = _empty, +) -> DensityNorm | Empty: ... + + +def _deprecated_scale( + density_norm: DensityNorm | Empty, + scale: DensityNorm | Empty, + *, + default: DensityNorm | Empty = _empty, +) -> DensityNorm | Empty: + if scale is _empty: return density_norm if density_norm != default: msg = "can’t specify both `scale` and `density_norm`" From 1650aed30fd0141a97c01a6a6b19c2735e058c77 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 20 Sep 2024 13:38:26 +0200 Subject: [PATCH 17/66] Rely on Ruff for TYPE_CHECKING block mgmt (#3248) --- src/scanpy/cli.py | 4 ++-- src/scanpy/external/tl/_wishbone.py | 6 +++--- src/scanpy/plotting/_anndata.py | 10 +++++----- src/scanpy/plotting/_baseplot_class.py | 6 +++--- src/scanpy/plotting/_tools/__init__.py | 11 +++-------- src/scanpy/plotting/_tools/paga.py | 17 +++++++---------- src/scanpy/plotting/_tools/scatterplots.py | 15 ++++++--------- src/scanpy/plotting/_utils.py | 17 ++++++++--------- src/scanpy/queries/_queries.py | 6 +++--- src/scanpy/tools/_marker_gene_overlap.py | 4 ++-- 10 files changed, 42 insertions(+), 54 deletions(-) diff --git a/src/scanpy/cli.py b/src/scanpy/cli.py index 4a41c3a0d4..04b75c8b74 100644 --- a/src/scanpy/cli.py +++ b/src/scanpy/cli.py @@ -1,9 +1,9 @@ from __future__ import annotations -import collections.abc as cabc import os import sys from argparse import ArgumentParser, Namespace, _SubParsersAction +from collections.abc import MutableMapping from functools import lru_cache, partial from pathlib import Path from shutil import which @@ -27,7 +27,7 @@ def __init__(self, *args, _command: str, _runargs: dict[str, Any], **kwargs): ) -class _CommandDelegator(cabc.MutableMapping): +class _CommandDelegator(MutableMapping): """\ Provide the ability to delegate, but don’t calculate the whole list until necessary diff --git a/src/scanpy/external/tl/_wishbone.py b/src/scanpy/external/tl/_wishbone.py index 7e78d2eec6..e857226feb 100644 --- a/src/scanpy/external/tl/_wishbone.py +++ b/src/scanpy/external/tl/_wishbone.py @@ -1,6 +1,6 @@ from __future__ import annotations -import collections.abc as cabc +from collections.abc import Collection from typing import TYPE_CHECKING import numpy as np @@ -11,7 +11,7 @@ from ..._utils._doctests import doctest_needs if TYPE_CHECKING: - from collections.abc import Collection, Iterable + from collections.abc import Iterable from anndata import AnnData @@ -115,7 +115,7 @@ def wishbone( f"Start cell {start_cell} not found in data. " "Please rerun with correct start cell." ) - if isinstance(num_waypoints, cabc.Collection): + if isinstance(num_waypoints, Collection): diff = np.setdiff1d(num_waypoints, adata.obs.index) if diff.size > 0: logging.warning( diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index ddd639f62d..e3cbf1ae88 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -2,8 +2,8 @@ from __future__ import annotations -import collections.abc as cabc from collections import OrderedDict +from collections.abc import Collection, Mapping, Sequence from itertools import product from typing import TYPE_CHECKING, get_args @@ -38,7 +38,7 @@ ) if TYPE_CHECKING: - from collections.abc import Collection, Iterable, Mapping, Sequence + from collections.abc import Iterable from typing import Literal, Union from anndata import AnnData @@ -220,7 +220,7 @@ def _scatter_obs( isinstance(layers, str) and layers in adata.layers.keys() ): layers = (layers, layers, layers) - elif isinstance(layers, cabc.Collection) and len(layers) == 3: + elif isinstance(layers, Collection) and len(layers) == 3: layers = tuple(layers) for layer in layers: if layer not in adata.layers.keys() and layer not in ["X", None]: @@ -299,7 +299,7 @@ def _scatter_obs( palette_was_none = False if palette is None: palette_was_none = True - if isinstance(palette, cabc.Sequence) and not isinstance(palette, str): + if isinstance(palette, Sequence) and not isinstance(palette, str): if not is_color_like(palette[0]): palettes = palette else: @@ -2665,7 +2665,7 @@ def _check_var_names_type(var_names, var_group_labels, var_group_positions): var_names, var_group_labels, var_group_positions """ - if isinstance(var_names, cabc.Mapping): + if isinstance(var_names, Mapping): if var_group_labels is not None or var_group_positions is not None: logg.warning( "`var_names` is a dictionary. This will reset the current " diff --git a/src/scanpy/plotting/_baseplot_class.py b/src/scanpy/plotting/_baseplot_class.py index b3b6803c8c..928fc0057e 100644 --- a/src/scanpy/plotting/_baseplot_class.py +++ b/src/scanpy/plotting/_baseplot_class.py @@ -2,7 +2,7 @@ from __future__ import annotations -import collections.abc as cabc +from collections.abc import Mapping from typing import TYPE_CHECKING, NamedTuple from warnings import warn @@ -17,7 +17,7 @@ from ._utils import check_colornorm, make_grid_spec if TYPE_CHECKING: - from collections.abc import Iterable, Mapping, Sequence + from collections.abc import Iterable, Sequence from typing import Literal, Self, Union import pandas as pd @@ -1097,7 +1097,7 @@ def _update_var_groups(self) -> None: updates var_names, var_group_labels, var_group_positions """ - if isinstance(self.var_names, cabc.Mapping): + if isinstance(self.var_names, Mapping): if self.has_var_groups: logg.warning( "`var_names` is a dictionary. This will reset the current " diff --git a/src/scanpy/plotting/_tools/__init__.py b/src/scanpy/plotting/_tools/__init__.py index 47f392bd44..eec202d0a5 100644 --- a/src/scanpy/plotting/_tools/__init__.py +++ b/src/scanpy/plotting/_tools/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations -import collections.abc as cabc -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from copy import copy from typing import TYPE_CHECKING @@ -38,7 +37,7 @@ from .scatterplots import _panel_grid, embedding, pca if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Iterable from typing import Literal from anndata import AnnData @@ -1611,11 +1610,7 @@ def embedding_density( # if group is set, then plot it using multiple panels # (even if only one group is set) - if ( - group is not None - and not isinstance(group, str) - and isinstance(group, cabc.Sequence) - ): + if group is not None and not isinstance(group, str) and isinstance(group, Sequence): if ax is not None: raise ValueError("Can only specify `ax` if no `group` sequence is given.") fig, gs = _panel_grid(hspace, wspace, ncols, len(group)) diff --git a/src/scanpy/plotting/_tools/paga.py b/src/scanpy/plotting/_tools/paga.py index 6ea2560b10..f0d45e9a80 100644 --- a/src/scanpy/plotting/_tools/paga.py +++ b/src/scanpy/plotting/_tools/paga.py @@ -1,7 +1,7 @@ from __future__ import annotations -import collections.abc as cabc import warnings +from collections.abc import Collection, Mapping, Sequence from pathlib import Path from types import MappingProxyType from typing import TYPE_CHECKING @@ -26,7 +26,6 @@ from .._utils import matrix if TYPE_CHECKING: - from collections.abc import Mapping, Sequence from typing import Any, Literal, Union from anndata import AnnData @@ -506,14 +505,12 @@ def paga( groups_key = adata.uns["paga"]["groups"] def is_flat(x): - has_one_per_category = isinstance(x, cabc.Collection) and len(x) == len( + has_one_per_category = isinstance(x, Collection) and len(x) == len( adata.obs[groups_key].cat.categories ) return has_one_per_category or x is None or isinstance(x, str) - if isinstance(colors, cabc.Mapping) and isinstance( - colors[next(iter(colors))], cabc.Mapping - ): + if isinstance(colors, Mapping) and isinstance(colors[next(iter(colors))], Mapping): # handle paga pie, remap string keys to integers names_to_ixs = { n: i for i, n in enumerate(adata.obs[groups_key].cat.categories) @@ -554,7 +551,7 @@ def is_flat(x): f"it needs to be one of {labels} not {root!r}." ) root = list(labels).index(root) - if isinstance(root, cabc.Sequence) and root[0] in labels: + if isinstance(root, Sequence) and root[0] in labels: root = [list(labels).index(r) for r in root] # define the adjacency matrices @@ -600,7 +597,7 @@ def is_flat(x): sct = _paga_graph( adata, axs[icolor], - colors=colors if isinstance(colors, cabc.Mapping) else c, + colors=colors if isinstance(colors, Mapping) else c, solid_edges=solid_edges, dashed_edges=dashed_edges, transitions=transitions, @@ -935,7 +932,7 @@ def _paga_graph( patheffects.withStroke(linewidth=fontoutline, foreground="w") ] # usual scatter plot - if not isinstance(colors[0], cabc.Mapping): + if not isinstance(colors[0], Mapping): n_groups = len(pos_array) sct = ax.scatter( pos_array[:, 0], @@ -959,7 +956,7 @@ def _paga_graph( # else pie chart plot else: for ix, (xx, yy) in enumerate(zip(pos_array[:, 0], pos_array[:, 1])): - if not isinstance(colors[ix], cabc.Mapping): + if not isinstance(colors[ix], Mapping): raise ValueError( f"{colors[ix]} is neither a dict of valid " "matplotlib colors nor a valid matplotlib color." diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index 4f2b208ef1..93aff99552 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -1,6 +1,5 @@ from __future__ import annotations -import collections.abc as cabc import inspect import sys from collections.abc import Mapping, Sequence # noqa: TCH003 @@ -202,13 +201,13 @@ def embedding( title = [title] if isinstance(title, str) else list(title) # turn vmax and vmin into a sequence - if isinstance(vmax, str) or not isinstance(vmax, cabc.Sequence): + if isinstance(vmax, str) or not isinstance(vmax, Sequence): vmax = [vmax] - if isinstance(vmin, str) or not isinstance(vmin, cabc.Sequence): + if isinstance(vmin, str) or not isinstance(vmin, Sequence): vmin = [vmin] - if isinstance(vcenter, str) or not isinstance(vcenter, cabc.Sequence): + if isinstance(vcenter, str) or not isinstance(vcenter, Sequence): vcenter = [vcenter] - if isinstance(norm, Normalize) or not isinstance(norm, cabc.Sequence): + if isinstance(norm, Normalize) or not isinstance(norm, Sequence): norm = [norm] # Size @@ -219,7 +218,7 @@ def embedding( # set as ndarray if ( size is not None - and isinstance(size, (cabc.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) @@ -245,9 +244,7 @@ def embedding( # Eg. ['Gene1', 'louvain', 'Gene2']. # component_list is a list of components [[0,1], [1,2]] if ( - not isinstance(color, str) - and isinstance(color, cabc.Sequence) - and len(color) > 1 + not isinstance(color, str) and isinstance(color, Sequence) and len(color) > 1 ) or len(dimensions) > 1: if ax is not None: raise ValueError( diff --git a/src/scanpy/plotting/_utils.py b/src/scanpy/plotting/_utils.py index b56649e568..a545f1e30c 100644 --- a/src/scanpy/plotting/_utils.py +++ b/src/scanpy/plotting/_utils.py @@ -1,8 +1,7 @@ from __future__ import annotations -import collections.abc as cabc import warnings -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union, overload import matplotlib as mpl @@ -23,7 +22,7 @@ from . import palettes if TYPE_CHECKING: - from collections.abc import Collection, Mapping + from collections.abc import Collection from anndata import AnnData from matplotlib.colors import Colormap @@ -445,12 +444,12 @@ def _set_colors_for_categorical_obs( # this creates a palette from a colormap. E.g. 'Accent, Dark2, tab20' cmap = plt.get_cmap(palette) colors_list = [to_hex(x) for x in cmap(np.linspace(0, 1, len(categories)))] - elif isinstance(palette, cabc.Mapping): + elif isinstance(palette, Mapping): colors_list = [to_hex(palette[k], keep_alpha=True) for k in categories] else: # check if palette is a list and convert it to a cycler, thus # it doesnt matter if the list is shorter than the categories length: - if isinstance(palette, cabc.Sequence): + if isinstance(palette, Sequence): if len(palette) < len(categories): logg.warning( "Length of palette colors is smaller than the number of " @@ -551,7 +550,7 @@ def add_colors_for_categorical_sample_annotation( def plot_edges(axs, adata, basis, edges_width, edges_color, *, neighbors_key=None): import networkx as nx - if not isinstance(axs, cabc.Sequence): + if not isinstance(axs, Sequence): axs = [axs] if neighbors_key is None: @@ -577,7 +576,7 @@ def plot_edges(axs, adata, basis, edges_width, edges_color, *, neighbors_key=Non def plot_arrows(axs, adata, basis, arrows_kwds=None): - if not isinstance(axs, cabc.Sequence): + if not isinstance(axs, Sequence): axs = [axs] v_prefix = next( (p for p in ["velocity", "Delta"] if f"{p}_{basis}" in adata.obsm), None @@ -724,7 +723,7 @@ def setup_axes( ax = plt.axes([left, bottom, width, height], projection="3d") axs.append(ax) else: - axs = ax if isinstance(ax, cabc.Sequence) else [ax] + axs = ax if isinstance(ax, Sequence) else [ax] return axs, panel_pos, draw_region_width, figure_width @@ -763,7 +762,7 @@ def scatter_base( Depending on whether supplying a single array or a list of arrays, return a single axis or a list of axes. """ - if isinstance(highlights, cabc.Mapping): + if isinstance(highlights, Mapping): highlights_indices = sorted(highlights) highlights_labels = [highlights[i] for i in highlights_indices] else: diff --git a/src/scanpy/queries/_queries.py b/src/scanpy/queries/_queries.py index 24a2482449..8da90151ce 100644 --- a/src/scanpy/queries/_queries.py +++ b/src/scanpy/queries/_queries.py @@ -1,6 +1,6 @@ from __future__ import annotations -import collections.abc as cabc +from collections.abc import Iterable from functools import singledispatch from types import MappingProxyType from typing import TYPE_CHECKING @@ -12,7 +12,7 @@ from ..get import rank_genes_groups_df if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Mapping from typing import Any import pandas as pd @@ -60,7 +60,7 @@ def simple_query( """ if isinstance(attrs, str): attrs = [attrs] - elif isinstance(attrs, cabc.Iterable): + elif isinstance(attrs, Iterable): attrs = list(attrs) else: raise TypeError(f"attrs must be of type list or str, was {type(attrs)}.") diff --git a/src/scanpy/tools/_marker_gene_overlap.py b/src/scanpy/tools/_marker_gene_overlap.py index 534f5c3b33..1c286e9333 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 -import collections.abc as cabc +from collections.abc import Set from typing import TYPE_CHECKING import numpy as np @@ -190,7 +190,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, cabc.Set) for val in reference_markers.values()): + if not all(isinstance(val, Set) for val in reference_markers.values()): try: reference_markers = { key: set(val) for key, val in reference_markers.items() From e27e257964c358acb3a9a83e4289cccfdfa425ae Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 20 Sep 2024 15:11:10 +0200 Subject: [PATCH 18/66] Clean up dendrogram typing (#3249) --- src/scanpy/plotting/_anndata.py | 97 ++++++++++++++------------ src/scanpy/plotting/_baseplot_class.py | 4 +- src/scanpy/plotting/_dotplot.py | 3 +- src/scanpy/plotting/_matrixplot.py | 4 +- src/scanpy/plotting/_stacked_violin.py | 3 +- src/scanpy/plotting/_utils.py | 5 ++ 6 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index e3cbf1ae88..5bd3de8188 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -31,6 +31,7 @@ ) from ._utils import ( _deprecated_scale, + _dk, check_colornorm, scatter_base, scatter_group, @@ -1189,7 +1190,7 @@ def heatmap( dendro_data = _reorder_categories_after_dendrogram( adata, groupby, - dendrogram, + dendrogram_key=_dk(dendrogram), var_names=var_names, var_group_labels=var_group_labels, var_group_positions=var_group_positions, @@ -1324,7 +1325,7 @@ def heatmap( if dendrogram: dendro_ax = fig.add_subplot(axs[1, 2], sharey=heatmap_ax) _plot_dendrogram( - dendro_ax, adata, groupby, ticks=ticks, dendrogram_key=dendrogram + dendro_ax, adata, groupby, dendrogram_key=_dk(dendrogram), ticks=ticks ) # plot group legends on top of heatmap_ax (if given) @@ -1427,7 +1428,7 @@ def heatmap( dendro_ax, adata, groupby, - dendrogram_key=dendrogram, + dendrogram_key=_dk(dendrogram), ticks=ticks, orientation="top", ) @@ -1579,7 +1580,7 @@ def tracksplot( dendro_data = _reorder_categories_after_dendrogram( adata, groupby, - dendrogram, + dendrogram_key=_dk(dendrogram), var_names=var_names, var_group_labels=var_group_labels, var_group_positions=var_group_positions, @@ -1711,7 +1712,7 @@ def tracksplot( dendro_ax, adata, groupby, - dendrogram_key=dendrogram, + dendrogram_key=_dk(dendrogram), orientation="top", ticks=ticks, ) @@ -1872,7 +1873,7 @@ def correlation_matrix( >>> sc.pl.correlation_matrix(adata, 'bulk_labels') """ - dendrogram_key = _get_dendrogram_key(adata, dendrogram, groupby) + dendrogram_key = _get_dendrogram_key(adata, _dk(dendrogram), groupby) index = adata.uns[dendrogram_key]["categories_idx_ordered"] corr_matrix = adata.uns[dendrogram_key]["correlation_matrix"] @@ -2247,13 +2248,13 @@ def _plot_gene_groups_brackets( def _reorder_categories_after_dendrogram( adata: AnnData, - groupby, - dendrogram, + groupby: str | Sequence[str], *, - var_names=None, - var_group_labels=None, - var_group_positions=None, - categories=None, + dendrogram_key: str | None, + var_names: Sequence[str], + var_group_labels: Sequence[str] | None, + var_group_positions: Sequence[tuple[int, int]] | None, + categories: Sequence[str], ): """\ Function used by plotting functions that need to reorder the the groupby @@ -2273,12 +2274,12 @@ def _reorder_categories_after_dendrogram( 'var_group_labels', and 'var_group_positions' """ - key = _get_dendrogram_key(adata, dendrogram, groupby) + dendrogram_key = _get_dendrogram_key(adata, dendrogram_key, groupby) 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 " @@ -2305,36 +2306,35 @@ def _reorder_categories_after_dendrogram( ) # reorder var_groups (if any) - if var_names is not None: - var_names_idx_ordered = list(range(len(var_names))) - - if var_group_positions: - if set(var_group_labels) == set(categories): - positions_ordered = [] - labels_ordered = [] - position_start = 0 - var_names_idx_ordered = [] - for cat_name in categories_ordered: - idx = var_group_labels.index(cat_name) - position = var_group_positions[idx] - _var_names = var_names[position[0] : position[1] + 1] - var_names_idx_ordered.extend(range(position[0], position[1] + 1)) - positions_ordered.append( - (position_start, position_start + len(_var_names) - 1) - ) - position_start += len(_var_names) - labels_ordered.append(var_group_labels[idx]) - var_group_labels = labels_ordered - var_group_positions = positions_ordered - else: - logg.warning( - "Groups are not reordered because the `groupby` categories " - "and the `var_group_labels` are different.\n" - f"categories: {_format_first_three_categories(categories)}\n" - f"var_group_labels: {_format_first_three_categories(var_group_labels)}" + if var_group_positions is None or var_group_labels is None: + assert var_group_positions is None + assert var_group_labels is None + var_names_idx_ordered = None + elif set(var_group_labels) == set(categories): + positions_ordered = [] + labels_ordered = [] + position_start = 0 + var_names_idx_ordered = [] + for cat_name in categories_ordered: + idx = var_group_labels.index(cat_name) + position = var_group_positions[idx] + _var_names = var_names[position[0] : position[1] + 1] + var_names_idx_ordered.extend(range(position[0], position[1] + 1)) + positions_ordered.append( + (position_start, position_start + len(_var_names) - 1) ) + position_start += len(_var_names) + labels_ordered.append(var_group_labels[idx]) + var_group_labels = labels_ordered + var_group_positions = positions_ordered else: - var_names_idx_ordered = None + logg.warning( + "Groups are not reordered because the `groupby` categories " + "and the `var_group_labels` are different.\n" + f"categories: {_format_first_three_categories(categories)}\n" + f"var_group_labels: {_format_first_three_categories(var_group_labels)}" + ) + var_names_idx_ordered = list(range(len(var_names))) if var_names_idx_ordered is not None: var_names_ordered = [var_names[x] for x in var_names_idx_ordered] @@ -2358,14 +2358,19 @@ def _format_first_three_categories(categories): return ", ".join(categories) -def _get_dendrogram_key(adata, dendrogram_key, groupby): +def _get_dendrogram_key( + adata: AnnData, dendrogram_key: str | None, groupby: str | Sequence[str] +) -> 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 dendrogram_key is None: if isinstance(groupby, str): dendrogram_key = f"dendrogram_{groupby}" - elif isinstance(groupby, list): + elif isinstance(groupby, Sequence): dendrogram_key = f'dendrogram_{"_".join(groupby)}' + else: + msg = f"groupby has wrong type: {type(groupby).__name__}." + raise AssertionError(msg) if dendrogram_key not in adata.uns: from ..tools._dendrogram import dendrogram @@ -2389,7 +2394,7 @@ def _get_dendrogram_key(adata, dendrogram_key, groupby): def _plot_dendrogram( dendro_ax: Axes, adata: AnnData, - groupby: str, + groupby: str | Sequence[str], *, dendrogram_key: str | None = None, orientation: Literal["top", "bottom", "left", "right"] = "right", diff --git a/src/scanpy/plotting/_baseplot_class.py b/src/scanpy/plotting/_baseplot_class.py index 928fc0057e..98c13bc79a 100644 --- a/src/scanpy/plotting/_baseplot_class.py +++ b/src/scanpy/plotting/_baseplot_class.py @@ -874,7 +874,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. @@ -900,7 +900,7 @@ def _format_first_three_categories(_categories): _categories = _categories[:3] + ["etc."] return ", ".join(_categories) - key = _get_dendrogram_key(self.adata, dendrogram, self.groupby) + key = _get_dendrogram_key(self.adata, dendrogram_key, self.groupby) dendro_info = self.adata.uns[key] if self.groupby != dendro_info["groupby"]: diff --git a/src/scanpy/plotting/_dotplot.py b/src/scanpy/plotting/_dotplot.py index 2048cd0e8e..48f7dbca44 100644 --- a/src/scanpy/plotting/_dotplot.py +++ b/src/scanpy/plotting/_dotplot.py @@ -12,6 +12,7 @@ from ._baseplot_class import BasePlot, doc_common_groupby_plot_args from ._docs import doc_common_plot_args, doc_show_save_ax, doc_vboundnorm from ._utils import ( + _dk, check_colornorm, fix_kwds, make_grid_spec, @@ -1043,7 +1044,7 @@ def dotplot( ) if dendrogram: - dp.add_dendrogram(dendrogram_key=dendrogram) + dp.add_dendrogram(dendrogram_key=_dk(dendrogram)) if swap_axes: dp.swap_axes() diff --git a/src/scanpy/plotting/_matrixplot.py b/src/scanpy/plotting/_matrixplot.py index f5fc18a72f..0567cf27c1 100644 --- a/src/scanpy/plotting/_matrixplot.py +++ b/src/scanpy/plotting/_matrixplot.py @@ -16,7 +16,7 @@ doc_show_save_ax, doc_vboundnorm, ) -from ._utils import check_colornorm, fix_kwds, savefig_or_show +from ._utils import _dk, check_colornorm, fix_kwds, savefig_or_show if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -454,7 +454,7 @@ def matrixplot( ) if dendrogram: - mp.add_dendrogram(dendrogram_key=dendrogram) + mp.add_dendrogram(dendrogram_key=_dk(dendrogram)) if swap_axes: mp.swap_axes() diff --git a/src/scanpy/plotting/_stacked_violin.py b/src/scanpy/plotting/_stacked_violin.py index 3dcbbf067a..0d18956fcd 100644 --- a/src/scanpy/plotting/_stacked_violin.py +++ b/src/scanpy/plotting/_stacked_violin.py @@ -17,6 +17,7 @@ from ._docs import doc_common_plot_args, doc_show_save_ax, doc_vboundnorm from ._utils import ( _deprecated_scale, + _dk, check_colornorm, make_grid_spec, savefig_or_show, @@ -833,7 +834,7 @@ def stacked_violin( ) if dendrogram: - vp.add_dendrogram(dendrogram_key=dendrogram) + vp.add_dendrogram(dendrogram_key=_dk(dendrogram)) if swap_axes: vp.swap_axes() vp = vp.style( diff --git a/src/scanpy/plotting/_utils.py b/src/scanpy/plotting/_utils.py index a545f1e30c..ea6aa0cb10 100644 --- a/src/scanpy/plotting/_utils.py +++ b/src/scanpy/plotting/_utils.py @@ -1342,3 +1342,8 @@ def _deprecated_scale( msg = "`scale` is deprecated, use `density_norm` instead" warnings.warn(msg, FutureWarning) return scale + + +def _dk(dendrogram: bool | str | None) -> str | None: + """Helper to convert the `dendrogram` parameter to a `dendrogram_key` parameter.""" + return None if isinstance(dendrogram, bool) else dendrogram From 874ce151d466de03d74da39be925be739483e678 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 20 Sep 2024 16:13:49 +0200 Subject: [PATCH 19/66] Deprecate defunct `order` parameter in `stacked_violin` (#3252) --- src/scanpy/plotting/_dotplot.py | 2 ++ src/scanpy/plotting/_matrixplot.py | 4 +++- src/scanpy/plotting/_stacked_violin.py | 15 ++++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/scanpy/plotting/_dotplot.py b/src/scanpy/plotting/_dotplot.py index 48f7dbca44..a895a269ce 100644 --- a/src/scanpy/plotting/_dotplot.py +++ b/src/scanpy/plotting/_dotplot.py @@ -881,6 +881,7 @@ def dotplot( use_raw: bool | None = None, log: bool = False, num_categories: int = 7, + categories_order: Sequence[str] | None = None, expression_cutoff: float = 0.0, mean_only_expressed: bool = False, cmap: str = "Reds", @@ -1024,6 +1025,7 @@ def dotplot( use_raw=use_raw, log=log, num_categories=num_categories, + categories_order=categories_order, expression_cutoff=expression_cutoff, mean_only_expressed=mean_only_expressed, standard_scale=standard_scale, diff --git a/src/scanpy/plotting/_matrixplot.py b/src/scanpy/plotting/_matrixplot.py index 0567cf27c1..059233ce37 100644 --- a/src/scanpy/plotting/_matrixplot.py +++ b/src/scanpy/plotting/_matrixplot.py @@ -134,7 +134,7 @@ def __init__( var_group_labels: Sequence[str] | None = None, var_group_rotation: float | None = None, layer: str | None = None, - standard_scale: Literal["var", "group"] = None, + standard_scale: Literal["var", "group"] | None = None, ax: _AxesSubplot | None = None, values_df: pd.DataFrame | None = None, vmin: float | None = None, @@ -343,6 +343,7 @@ def matrixplot( use_raw: bool | None = None, log: bool = False, num_categories: int = 7, + categories_order: Sequence[str] | None = None, figsize: tuple[float, float] | None = None, dendrogram: bool | str = False, title: str | None = None, @@ -436,6 +437,7 @@ def matrixplot( use_raw=use_raw, log=log, num_categories=num_categories, + categories_order=categories_order, standard_scale=standard_scale, title=title, figsize=figsize, diff --git a/src/scanpy/plotting/_stacked_violin.py b/src/scanpy/plotting/_stacked_violin.py index 0d18956fcd..a3d5e65834 100644 --- a/src/scanpy/plotting/_stacked_violin.py +++ b/src/scanpy/plotting/_stacked_violin.py @@ -694,7 +694,7 @@ def stacked_violin( size: int = StackedViolin.DEFAULT_JITTER_SIZE, density_norm: DensityNorm = StackedViolin.DEFAULT_DENSITY_NORM, yticklabels: bool | None = StackedViolin.DEFAULT_PLOT_YTICKLABELS, - order: Sequence[str] | None = None, + categories_order: Sequence[str] | None = None, swap_axes: bool = False, show: bool | None = None, save: bool | str | None = None, @@ -707,6 +707,7 @@ def stacked_violin( vcenter: float | None = None, norm: Normalize | None = None, # deprecated + order: Sequence[str] | None | Empty = _empty, scale: DensityNorm | Empty = _empty, **kwds, ) -> StackedViolin | dict | None: @@ -735,10 +736,6 @@ def stacked_violin( See :func:`~seaborn.stripplot`. size Size of the jitter points. - order - Order in which to show the categories. Note: if `dendrogram=True` - the categories order will be given by the dendrogram and `order` - will be ignored. density_norm The method used to scale the width of each violin. If 'width' (the default), each violin will have the same width. @@ -809,6 +806,13 @@ def stacked_violin( print(axes_dict) """ + if order is not _empty: + msg = ( + "`order` is deprecated (and never worked for `stacked_violin`), " + "use categories_order instead" + ) + warnings.warn(msg, FutureWarning) + # no reason to set `categories_order` here, as `order` never worked. vp = StackedViolin( adata, @@ -817,6 +821,7 @@ def stacked_violin( use_raw=use_raw, log=log, num_categories=num_categories, + categories_order=categories_order, standard_scale=standard_scale, title=title, figsize=figsize, From 7ae12167f582935a8c6f9c06fff9cda99a4eedc6 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 20 Sep 2024 17:05:13 +0200 Subject: [PATCH 20/66] Fix *Plot.style() methods (#3206) Co-authored-by: Ilan Gold --- docs/release-notes/3206.bugfix.md | 1 + src/scanpy/plotting/_baseplot_class.py | 12 +- src/scanpy/plotting/_dotplot.py | 196 +++++----- src/scanpy/plotting/_matrixplot.py | 28 +- src/scanpy/plotting/_stacked_violin.py | 91 +++-- src/scanpy/plotting/_tools/scatterplots.py | 9 +- tests/test_plotting.py | 413 +++++++++++---------- tests/test_score_genes.py | 5 +- 8 files changed, 369 insertions(+), 386 deletions(-) create mode 100644 docs/release-notes/3206.bugfix.md diff --git a/docs/release-notes/3206.bugfix.md b/docs/release-notes/3206.bugfix.md new file mode 100644 index 0000000000..d29c34300a --- /dev/null +++ b/docs/release-notes/3206.bugfix.md @@ -0,0 +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` diff --git a/src/scanpy/plotting/_baseplot_class.py b/src/scanpy/plotting/_baseplot_class.py index 98c13bc79a..6e5c8cd2c5 100644 --- a/src/scanpy/plotting/_baseplot_class.py +++ b/src/scanpy/plotting/_baseplot_class.py @@ -13,6 +13,7 @@ from .. import logging as logg from .._compat import old_positionals +from .._utils import _empty from ._anndata import _get_dendrogram_key, _plot_dendrogram, _prepare_dataframe from ._utils import check_colornorm, make_grid_spec @@ -23,8 +24,9 @@ import pandas as pd from anndata import AnnData from matplotlib.axes import Axes - from matplotlib.colors import Normalize + from matplotlib.colors import Colormap, Normalize + from .._utils import Empty from ._utils import ColorLike, _AxesSubplot _VarNames = Union[str, Sequence[str]] @@ -403,21 +405,23 @@ def add_totals( return self @old_positionals("cmap") - def style(self, *, cmap: str | None = DEFAULT_COLORMAP) -> Self: + def style(self, *, cmap: Colormap | str | None | Empty = _empty) -> Self: """\ Set visual style parameters Parameters ---------- cmap - colormap + Matplotlib color map, specified by name or directly. + If ``None``, use :obj:`matplotlib.rcParams`\\ ``["image.cmap"]`` Returns ------- Returns `self` for method chaining. """ - self.cmap = cmap + if cmap is not _empty: + self.cmap = cmap return self @old_positionals("show", "title", "width") diff --git a/src/scanpy/plotting/_dotplot.py b/src/scanpy/plotting/_dotplot.py index a895a269ce..e2ae434db6 100644 --- a/src/scanpy/plotting/_dotplot.py +++ b/src/scanpy/plotting/_dotplot.py @@ -8,7 +8,7 @@ from .. import logging as logg from .._compat import old_positionals from .._settings import settings -from .._utils import _doc_params +from .._utils import _doc_params, _empty from ._baseplot_class import BasePlot, doc_common_groupby_plot_args from ._docs import doc_common_plot_args, doc_show_save_ax, doc_vboundnorm from ._utils import ( @@ -26,13 +26,11 @@ import pandas as pd from anndata import AnnData from matplotlib.axes import Axes - from matplotlib.colors import Normalize + from matplotlib.colors import Colormap, Normalize + from .._utils import Empty from ._baseplot_class import _VarNames - from ._utils import ( - ColorLike, - _AxesSubplot, - ) + from ._utils import ColorLike, _AxesSubplot @_doc_params(common_plot_args=doc_common_plot_args) @@ -98,7 +96,7 @@ class DotPlot(BasePlot): DEFAULT_SAVE_PREFIX = "dotplot_" # default style parameters - DEFAULT_COLORMAP = "winter" + DEFAULT_COLORMAP = "Reds" DEFAULT_COLOR_ON = "dot" DEFAULT_DOT_MAX = None DEFAULT_DOT_MIN = None @@ -264,6 +262,7 @@ def __init__( ] for df in (dot_color_df, dot_size_df) ) + self.standard_scale = standard_scale # Set default style parameters self.cmap = self.DEFAULT_COLORMAP @@ -304,18 +303,18 @@ def __init__( def style( self, *, - cmap: str = DEFAULT_COLORMAP, - color_on: Literal["dot", "square"] | None = DEFAULT_COLOR_ON, - dot_max: float | None = DEFAULT_DOT_MAX, - dot_min: float | None = DEFAULT_DOT_MIN, - smallest_dot: float | None = DEFAULT_SMALLEST_DOT, - largest_dot: float | None = DEFAULT_LARGEST_DOT, - dot_edge_color: ColorLike | None = DEFAULT_DOT_EDGECOLOR, - dot_edge_lw: float | None = DEFAULT_DOT_EDGELW, - size_exponent: float | None = DEFAULT_SIZE_EXPONENT, - grid: float | None = False, - x_padding: float | None = DEFAULT_PLOT_X_PADDING, - y_padding: float | None = DEFAULT_PLOT_Y_PADDING, + cmap: Colormap | str | None | Empty = _empty, + color_on: Literal["dot", "square"] | Empty = _empty, + dot_max: float | None | Empty = _empty, + dot_min: float | None | Empty = _empty, + smallest_dot: float | Empty = _empty, + largest_dot: float | Empty = _empty, + dot_edge_color: ColorLike | None | Empty = _empty, + dot_edge_lw: float | None | Empty = _empty, + size_exponent: float | Empty = _empty, + grid: bool | Empty = _empty, + x_padding: float | Empty = _empty, + y_padding: float | Empty = _empty, ) -> Self: r"""\ Modifies plot visual parameters @@ -325,31 +324,30 @@ def style( cmap String denoting matplotlib color map. color_on - Options are 'dot' or 'square'. Be default the colomap is applied to - the color of the dot. Optionally, the colormap can be applied to an - square behind the dot, in which case the dot is transparent and only - the edge is shown. + By default the color map is applied to the color of the ``"dot"``. + Optionally, the colormap can be applied to a ``"square"`` behind the dot, + in which case the dot is transparent and only the edge is shown. dot_max - If none, the maximum dot size is set to the maximum fraction value found - (e.g. 0.6). If given, the value should be a number between 0 and 1. + If ``None``, the maximum dot size is set to the maximum fraction value found (e.g. 0.6). + If given, the value should be a number between 0 and 1. All fractions larger than dot_max are clipped to this value. dot_min - If none, the minimum dot size is set to 0. If given, - the value should be a number between 0 and 1. + If ``None``, the minimum dot size is set to 0. + If given, the value should be a number between 0 and 1. All fractions smaller than dot_min are clipped to this value. smallest_dot - If none, the smallest dot has size 0. All expression fractions with `dot_min` are plotted with this size. largest_dot - If none, the largest dot has size 200. All expression fractions with `dot_max` are plotted with this size. dot_edge_color - Dot edge color. When `color_on='dot'` the default is no edge. When - `color_on='square'`, edge color is white for darker colors and black - for lighter background square colors. + Dot edge color. + When `color_on='dot'`, ``None`` means no edge. + When `color_on='square'`, ``None`` means that + the edge color is white for darker colors and black for lighter background square colors. dot_edge_lw - Dot edge line width. When `color_on='dot'` the default is no edge. When - `color_on='square'`, line width = 1.5. + Dot edge line width. + When `color_on='dot'`, ``None`` means no edge. + When `color_on='square'`, ``None`` means a line width of 1.5. size_exponent Dot size is computed as: fraction ** size exponent and afterwards scaled to match the @@ -389,31 +387,29 @@ def style( ... .style(dot_edge_color='black', dot_edge_lw=1, grid=True) \ ... .show() """ + super().style(cmap=cmap) - # change only the values that had changed - if cmap != self.cmap: - self.cmap = cmap - if dot_max != self.dot_max: + if dot_max is not _empty: self.dot_max = dot_max - if dot_min != self.dot_min: + if dot_min is not _empty: self.dot_min = dot_min - if smallest_dot != self.smallest_dot: + if smallest_dot is not _empty: self.smallest_dot = smallest_dot - if largest_dot != self.largest_dot: + if largest_dot is not _empty: self.largest_dot = largest_dot - if color_on != self.color_on: + if color_on is not _empty: self.color_on = color_on - if size_exponent != self.size_exponent: + if size_exponent is not _empty: self.size_exponent = size_exponent - if dot_edge_color != self.dot_edge_color: + if dot_edge_color is not _empty: self.dot_edge_color = dot_edge_color - if dot_edge_lw != self.dot_edge_lw: + if dot_edge_lw is not _empty: self.dot_edge_lw = dot_edge_lw - if grid != self.grid: + if grid is not _empty: self.grid = grid - if x_padding != self.plot_x_padding: + if x_padding is not _empty: self.plot_x_padding = x_padding - if y_padding != self.plot_y_padding: + if y_padding is not _empty: self.plot_y_padding = y_padding return self @@ -575,7 +571,7 @@ def _plot_legend(self, legend_ax, return_ax_dict, normalize): self._plot_colorbar(color_legend_ax, normalize) return_ax_dict["color_legend_ax"] = color_legend_ax - def _mainplot(self, ax): + def _mainplot(self, ax: Axes): # work on a copy of the dataframes. This is to avoid changes # on the original data frames after repetitive calls to the # DotPlot object, for example once with swap_axes and other without @@ -600,9 +596,10 @@ def _mainplot(self, ax): _color_df, ax, cmap=self.cmap, + color_on=self.color_on, dot_max=self.dot_max, dot_min=self.dot_min, - color_on=self.color_on, + standard_scale=self.standard_scale, edge_color=self.dot_edge_color, edge_lw=self.dot_edge_lw, smallest_dot=self.smallest_dot, @@ -627,24 +624,23 @@ def _dotplot( dot_color: pd.DataFrame, dot_ax: Axes, *, - cmap: str = "Reds", - color_on: str | None = "dot", - y_label: str | None = None, - dot_max: float | None = None, - dot_min: float | None = None, - standard_scale: Literal["var", "group"] | None = None, - smallest_dot: float | None = 0.0, - largest_dot: float | None = 200, - size_exponent: float | None = 2, - edge_color: ColorLike | None = None, - edge_lw: float | None = None, - grid: bool | None = False, - x_padding: float | None = 0.8, - y_padding: float | None = 1.0, - vmin: float | None = None, - vmax: float | None = None, - vcenter: float | None = None, - norm: Normalize | None = None, + cmap: Colormap | str | None, + color_on: Literal["dot", "square"], + dot_max: float | None, + dot_min: float | None, + standard_scale: Literal["var", "group"] | None, + smallest_dot: float, + largest_dot: float, + size_exponent: float, + edge_color: ColorLike | None, + edge_lw: float | None, + grid: bool, + x_padding: float, + y_padding: float, + vmin: float | None, + vmax: float | None, + vcenter: float | None, + norm: Normalize | None, **kwds, ): """\ @@ -657,47 +653,25 @@ def _dotplot( Parameters ---------- - dot_size: Data frame containing the dot_size. - dot_color: Data frame containing the dot_color, should have the same, - shape, columns and indices as dot_size. - dot_ax: matplotlib axis + dot_size + Data frame containing the dot_size. + dot_color + Data frame containing the dot_color, should have the same, + shape, columns and indices as dot_size. + dot_ax + matplotlib axis cmap - String denoting matplotlib color map. color_on - Options are 'dot' or 'square'. Be default the colomap is applied to - the color of the dot. Optionally, the colormap can be applied to an - square behind the dot, in which case the dot is transparent and only - the edge is shown. - y_label: String. Label for y axis dot_max - If none, the maximum dot size is set to the maximum fraction value found - (e.g. 0.6). If given, the value should be a number between 0 and 1. - All fractions larger than dot_max are clipped to this value. dot_min - If none, the minimum dot size is set to 0. If given, - the value should be a number between 0 and 1. - All fractions smaller than dot_min are clipped to this value. standard_scale - Whether or not to standardize that dimension between 0 and 1, - meaning for each variable or group, - subtract the minimum and divide each by its maximum. smallest_dot - If none, the smallest dot has size 0. - All expression levels with `dot_min` are plotted with this size. edge_color - Dot edge color. When `color_on='dot'` the default is no edge. When - `color_on='square'`, edge color is white edge_lw - Dot edge line width. When `color_on='dot'` the default is no edge. When - `color_on='square'`, line width = 1.5 grid - Adds a grid to the plot - x_paddding - Space between the plot left/right borders and the dots center. A unit - is the distance between the x ticks. Only applied when color_on = dot - y_paddding - Space between the plot top/bottom borders and the dots center. A unit is - the distance between the y ticks. Only applied when color_on = dot + x_padding + y_padding + See `style` kwds Are passed to :func:`matplotlib.pyplot.scatter`. @@ -806,7 +780,6 @@ def _dotplot( linewidth=edge_lw, edgecolor=edge_color, ) - dot_ax.scatter(x, y, **kwds) y_ticks = np.arange(dot_color.shape[0]) + 0.5 @@ -825,7 +798,6 @@ def _dotplot( ) dot_ax.tick_params(axis="both", labelsize="small") dot_ax.grid(visible=False) - dot_ax.set_ylabel(y_label) # to be consistent with the heatmap plot, is better to # invert the order of the y-axis, such that the first group is on @@ -884,11 +856,7 @@ def dotplot( categories_order: Sequence[str] | None = None, expression_cutoff: float = 0.0, mean_only_expressed: bool = False, - cmap: str = "Reds", - dot_max: float | None = DotPlot.DEFAULT_DOT_MAX, - dot_min: float | None = DotPlot.DEFAULT_DOT_MIN, standard_scale: Literal["var", "group"] | None = None, - smallest_dot: float | None = DotPlot.DEFAULT_SMALLEST_DOT, title: str | None = None, colorbar_title: str | None = DotPlot.DEFAULT_COLOR_LEGEND_TITLE, size_title: str | None = DotPlot.DEFAULT_SIZE_LEGEND_TITLE, @@ -909,6 +877,11 @@ def dotplot( vmax: float | None = None, vcenter: float | None = None, norm: Normalize | None = None, + # Style parameters + cmap: Colormap | str | None = DotPlot.DEFAULT_COLORMAP, + dot_max: float | None = DotPlot.DEFAULT_DOT_MAX, + dot_min: float | None = DotPlot.DEFAULT_DOT_MIN, + smallest_dot: float = DotPlot.DEFAULT_SMALLEST_DOT, **kwds, ) -> DotPlot | dict | None: """\ @@ -946,15 +919,14 @@ def dotplot( If True, gene expression is averaged only over the cells expressing the given genes. dot_max - If none, the maximum dot size is set to the maximum fraction value found + If ``None``, the maximum dot size is set to the maximum fraction value found (e.g. 0.6). If given, the value should be a number between 0 and 1. All fractions larger than dot_max are clipped to this value. dot_min - If none, the minimum dot size is set to 0. If given, + If ``None``, the minimum dot size is set to 0. If given, the value should be a number between 0 and 1. All fractions smaller than dot_min are clipped to this value. smallest_dot - If none, the smallest dot has size 0. All expression levels with `dot_min` are plotted with this size. {show_save_ax} {vminmax} @@ -1014,9 +986,7 @@ def dotplot( # backwards compatibility: previous version of dotplot used `color_map` # instead of `cmap` - cmap = kwds.get("color_map", cmap) - if "color_map" in kwds: - del kwds["color_map"] + cmap = kwds.pop("color_map", cmap) dp = DotPlot( adata, @@ -1055,7 +1025,7 @@ def dotplot( dot_max=dot_max, dot_min=dot_min, smallest_dot=smallest_dot, - dot_edge_lw=kwds.pop("linewidth", DotPlot.DEFAULT_DOT_EDGELW), + dot_edge_lw=kwds.pop("linewidth", _empty), ).legend(colorbar_title=colorbar_title, size_title=size_title) if return_fig: diff --git a/src/scanpy/plotting/_matrixplot.py b/src/scanpy/plotting/_matrixplot.py index 059233ce37..9184f2455b 100644 --- a/src/scanpy/plotting/_matrixplot.py +++ b/src/scanpy/plotting/_matrixplot.py @@ -9,7 +9,7 @@ from .. import logging as logg from .._compat import old_positionals from .._settings import settings -from .._utils import _doc_params +from .._utils import _doc_params, _empty from ._baseplot_class import BasePlot, doc_common_groupby_plot_args from ._docs import ( doc_common_plot_args, @@ -25,8 +25,9 @@ import pandas as pd from anndata import AnnData from matplotlib.axes import Axes - from matplotlib.colors import Normalize + from matplotlib.colors import Colormap, Normalize + from .._utils import Empty from ._baseplot_class import _VarNames from ._utils import ColorLike, _AxesSubplot @@ -198,9 +199,9 @@ def __init__( def style( self, - cmap: str = DEFAULT_COLORMAP, - edge_color: ColorLike | None = DEFAULT_EDGE_COLOR, - edge_lw: float | None = DEFAULT_EDGE_LW, + cmap: Colormap | str | None | Empty = _empty, + edge_color: ColorLike | None | Empty = _empty, + edge_lw: float | None | Empty = _empty, ) -> Self: """\ Modifies plot visual parameters. @@ -208,11 +209,14 @@ def style( Parameters ---------- cmap - String denoting matplotlib color map. + Matplotlib color map, specified by name or directly. + If ``None``, use :obj:`matplotlib.rcParams`\\ ``["image.cmap"]`` edge_color - Edge color between the squares of matrix plot. Default is gray + Edge color between the squares of matrix plot. + If ``None``, use :obj:`matplotlib.rcParams`\\ ``["patch.edgecolor"]`` edge_lw Edge line width. + If ``None``, use :obj:`matplotlib.rcParams`\\ ``["lines.linewidth"]`` Returns ------- @@ -242,13 +246,11 @@ def style( ) """ + super().style(cmap=cmap) - # change only the values that had changed - if cmap != self.cmap: - self.cmap = cmap - if edge_color != self.edge_color: + if edge_color is not _empty: self.edge_color = edge_color - if edge_lw != self.edge_lw: + if edge_lw is not _empty: self.edge_lw = edge_lw return self @@ -347,7 +349,7 @@ def matrixplot( figsize: tuple[float, float] | None = None, dendrogram: bool | str = False, title: str | None = None, - cmap: str | None = MatrixPlot.DEFAULT_COLORMAP, + cmap: Colormap | str | None = MatrixPlot.DEFAULT_COLORMAP, colorbar_title: str | None = MatrixPlot.DEFAULT_COLOR_LEGEND_TITLE, gene_symbols: str | None = None, var_group_positions: Sequence[tuple[int, int]] | None = None, diff --git a/src/scanpy/plotting/_stacked_violin.py b/src/scanpy/plotting/_stacked_violin.py index a3d5e65834..691dd863d0 100644 --- a/src/scanpy/plotting/_stacked_violin.py +++ b/src/scanpy/plotting/_stacked_violin.py @@ -29,7 +29,7 @@ from anndata import AnnData from matplotlib.axes import Axes - from matplotlib.colors import Normalize + from matplotlib.colors import Colormap, Normalize from .._utils import Empty from ._baseplot_class import _VarNames @@ -270,17 +270,17 @@ def __init__( def style( self, *, - cmap: str | None = DEFAULT_COLORMAP, - stripplot: bool | None = DEFAULT_STRIPPLOT, - jitter: float | bool | None = DEFAULT_JITTER, - jitter_size: int | None = DEFAULT_JITTER_SIZE, - linewidth: float | None = DEFAULT_LINE_WIDTH, - row_palette: str | None = DEFAULT_ROW_PALETTE, - density_norm: DensityNorm = DEFAULT_DENSITY_NORM, - yticklabels: bool | None = DEFAULT_PLOT_YTICKLABELS, - ylim: tuple[float, float] | None = DEFAULT_YLIM, - x_padding: float | None = DEFAULT_PLOT_X_PADDING, - y_padding: float | None = DEFAULT_PLOT_Y_PADDING, + cmap: Colormap | str | None | Empty = _empty, + stripplot: bool | Empty = _empty, + jitter: float | bool | Empty = _empty, + jitter_size: int | float | Empty = _empty, + linewidth: float | None | Empty = _empty, + row_palette: str | None | Empty = _empty, + density_norm: DensityNorm | Empty = _empty, + yticklabels: bool | Empty = _empty, + ylim: tuple[float, float] | None | Empty = _empty, + x_padding: float | Empty = _empty, + y_padding: float | Empty = _empty, # deprecated scale: DensityNorm | Empty = _empty, ) -> Self: @@ -290,7 +290,8 @@ def style( Parameters ---------- cmap - String denoting matplotlib color map. + Matplotlib color map, specified by name or directly. + If ``None``, use :obj:`matplotlib.rcParams`\ ``["image.cmap"]`` stripplot Add a stripplot on top of the violin plot. See :func:`~seaborn.stripplot`. @@ -300,9 +301,11 @@ def style( jitter_size Size of the jitter points. linewidth - linewidth for the violin plots. + line width for the violin plots. + If None, use :obj:`matplotlib.rcParams`\ ``["lines.linewidth"]`` row_palette The row palette determines the colors to use for the stacked violins. + If ``None``, use :obj:`matplotlib.rcParams`\ ``["axes.prop_cycle"]`` The value should be a valid seaborn or matplotlib palette name (see :func:`~seaborn.color_palette`). Alternatively, a single color name or hex value can be passed, @@ -315,8 +318,9 @@ def style( yticklabels Set to true to view the y tick labels. ylim - minimum and maximum values for the y-axis. If set. All rows will have - the same y-axis range. Example: ylim=(0, 5) + minimum and maximum values for the y-axis. + If not ``None``, all rows will have the same y-axis range. + Example: ``ylim=(0, 5)`` x_padding Space between the plot left/right borders and the violins. A unit is the distance between the x ticks. @@ -339,20 +343,18 @@ def style( >>> sc.pl.StackedViolin(adata, markers, groupby='bulk_labels') \ ... .style(row_palette='Blues', linewidth=0).show() """ + super().style(cmap=cmap) - # modify only values that had changed - if cmap != self.cmap: - self.cmap = cmap - if row_palette != self.row_palette: + if row_palette is not _empty: self.row_palette = row_palette self.kwds["color"] = self.row_palette - if stripplot != self.stripplot: + if stripplot is not _empty: self.stripplot = stripplot - if jitter != self.jitter: + if jitter is not _empty: self.jitter = jitter - if jitter_size != self.jitter_size: + if jitter_size is not _empty: self.jitter_size = jitter_size - if yticklabels != self.plot_yticklabels: + if yticklabels is not _empty: self.plot_yticklabels = yticklabels if self.plot_yticklabels: # space needs to be added to avoid overlapping @@ -360,21 +362,15 @@ def style( self.wspace = 0.3 else: self.wspace = StackedViolin.DEFAULT_WSPACE - if ylim != self.ylim: + if ylim is not _empty: self.ylim = ylim - if x_padding != self.plot_x_padding: + if x_padding is not _empty: self.plot_x_padding = x_padding - if y_padding != self.plot_y_padding: + if y_padding is not _empty: self.plot_y_padding = y_padding - if linewidth != self.kwds["linewidth"] and linewidth != self.DEFAULT_LINE_WIDTH: + if linewidth is not _empty: self.kwds["linewidth"] = linewidth - density_norm = _deprecated_scale( - density_norm, scale, default=self.DEFAULT_DENSITY_NORM - ) - if ( - density_norm != self.kwds["density_norm"] - and density_norm != self.DEFAULT_DENSITY_NORM - ): + if (density_norm := _deprecated_scale(density_norm, scale)) is not _empty: self.kwds["density_norm"] = density_norm return self @@ -474,8 +470,8 @@ def _make_rows_of_violinplots( _matrix, colormap_array, _color_df, - x_spacer_size, - y_spacer_size, + x_spacer_size: float | int, + y_spacer_size: float | int, x_axis_order, ): import seaborn as sns # Slow import, only import if called @@ -689,23 +685,24 @@ def stacked_violin( standard_scale: Literal["var", "group"] | None = None, var_group_rotation: float | None = None, layer: str | None = None, - stripplot: bool = StackedViolin.DEFAULT_STRIPPLOT, - jitter: float | bool = StackedViolin.DEFAULT_JITTER, - size: int = StackedViolin.DEFAULT_JITTER_SIZE, - density_norm: DensityNorm = StackedViolin.DEFAULT_DENSITY_NORM, - yticklabels: bool | None = StackedViolin.DEFAULT_PLOT_YTICKLABELS, categories_order: Sequence[str] | None = None, swap_axes: bool = False, show: bool | None = None, save: bool | str | None = None, return_fig: bool | None = False, - row_palette: str | None = StackedViolin.DEFAULT_ROW_PALETTE, - cmap: str | None = StackedViolin.DEFAULT_COLORMAP, ax: _AxesSubplot | None = None, vmin: float | None = None, vmax: float | None = None, vcenter: float | None = None, norm: Normalize | None = None, + # Style options + 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, + row_palette: str | None = StackedViolin.DEFAULT_ROW_PALETTE, + density_norm: DensityNorm | Empty = _empty, + yticklabels: bool = StackedViolin.DEFAULT_PLOT_YTICKLABELS, # deprecated order: Sequence[str] | None | Empty = _empty, scale: DensityNorm | Empty = _empty, @@ -848,11 +845,9 @@ def stacked_violin( jitter=jitter, jitter_size=size, row_palette=row_palette, - density_norm=_deprecated_scale( - density_norm, scale, default=StackedViolin.DEFAULT_DENSITY_NORM - ), + density_norm=_deprecated_scale(density_norm, scale), yticklabels=yticklabels, - linewidth=kwds.get("linewidth", StackedViolin.DEFAULT_LINE_WIDTH), + linewidth=kwds.get("linewidth", _empty), ).legend(title=colorbar_title) if return_fig: return vp diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index 93aff99552..769514a69e 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -182,11 +182,10 @@ def embedding( # Prevents warnings during legend creation na_color = colors.to_hex(na_color, keep_alpha=True) - if "edgecolor" not in kwargs: - # by default turn off edge color. Otherwise, for - # very small sizes the edge will not reduce its size - # (https://github.com/scverse/scanpy/issues/293) - kwargs["edgecolor"] = "none" + # by default turn off edge color. Otherwise, for + # very small sizes the edge will not reduce its size + # (https://github.com/scverse/scanpy/issues/293) + kwargs.setdefault("edgecolor", "none") # Vectorized arguments diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 60f1a774af..92eb61c252 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -370,6 +370,17 @@ def test_dotplot_obj(image_comparer): save_and_compare_images("dotplot_std_scale_var") +def test_dotplot_style_no_reset(): + pbmc = pbmc68k_reduced() + plot = sc.pl.dotplot(pbmc, "CD79A", "bulk_labels", return_fig=True) + assert isinstance(plot, sc.pl.DotPlot) + assert plot.cmap == sc.pl.DotPlot.DEFAULT_COLORMAP + plot.style(cmap="winter") + assert plot.cmap == "winter" + plot.style(color_on="square") + assert plot.cmap == "winter", "style() should not reset unspecified parameters" + + def test_dotplot_add_totals(image_comparer): save_and_compare_images = partial(image_comparer, ROOT, tol=5) @@ -588,221 +599,220 @@ def test_correlation(image_comparer): save_and_compare_images("correlation") -@pytest.mark.parametrize( - ("name", "fn"), - [ - ( - "ranked_genes_sharey", - partial( - sc.pl.rank_genes_groups, n_genes=12, n_panels_per_row=3, show=False - ), - ), - ( - "ranked_genes", - partial( - sc.pl.rank_genes_groups, - n_genes=12, - n_panels_per_row=3, - sharey=False, - show=False, - ), - ), - ( - "ranked_genes_heatmap", - partial( - sc.pl.rank_genes_groups_heatmap, n_genes=4, cmap="YlGnBu", show=False - ), - ), - ( - "ranked_genes_heatmap_swap_axes", - partial( - sc.pl.rank_genes_groups_heatmap, - n_genes=20, - swap_axes=True, - use_raw=False, - show_gene_labels=False, - show=False, - vmin=-3, - vmax=3, - cmap="bwr", - ), +_RANK_GENES_GROUPS_PARAMS = [ + ( + "sharey", + partial(sc.pl.rank_genes_groups, n_genes=12, n_panels_per_row=3, show=False), + ), + ( + "basic", + partial( + sc.pl.rank_genes_groups, + n_genes=12, + n_panels_per_row=3, + sharey=False, + show=False, ), - ( - "ranked_genes_heatmap_swap_axes_vcenter", - partial( - sc.pl.rank_genes_groups_heatmap, - n_genes=20, - swap_axes=True, - use_raw=False, - show_gene_labels=False, - show=False, - vmin=-3, - vcenter=1, - vmax=3, - cmap="RdBu_r", - ), + ), + ( + "heatmap", + partial(sc.pl.rank_genes_groups_heatmap, n_genes=4, cmap="YlGnBu", show=False), + ), + ( + "heatmap_swap_axes", + partial( + sc.pl.rank_genes_groups_heatmap, + n_genes=20, + swap_axes=True, + use_raw=False, + show_gene_labels=False, + show=False, + vmin=-3, + vmax=3, + cmap="bwr", ), - ( - "ranked_genes_stacked_violin", - partial( - sc.pl.rank_genes_groups_stacked_violin, - n_genes=3, - show=False, - groups=["3", "0", "5"], - ), + ), + ( + "heatmap_swap_axes_vcenter", + partial( + sc.pl.rank_genes_groups_heatmap, + n_genes=20, + swap_axes=True, + use_raw=False, + show_gene_labels=False, + show=False, + vmin=-3, + vcenter=1, + vmax=3, + cmap="RdBu_r", ), - ( - "ranked_genes_dotplot", - partial(sc.pl.rank_genes_groups_dotplot, n_genes=4, show=False), + ), + ( + "stacked_violin", + partial( + sc.pl.rank_genes_groups_stacked_violin, + n_genes=3, + show=False, + groups=["3", "0", "5"], ), - ( - "ranked_genes_dotplot_gene_names", - partial( - sc.pl.rank_genes_groups_dotplot, - var_names={ - "T-cell": ["CD3D", "CD3E", "IL32"], - "B-cell": ["CD79A", "CD79B", "MS4A1"], - "myeloid": ["CST3", "LYZ"], - }, - values_to_plot="logfoldchanges", - cmap="bwr", - vmin=-3, - vmax=3, - show=False, - ), + ), + ( + "dotplot", + partial(sc.pl.rank_genes_groups_dotplot, n_genes=4, show=False), + ), + ( + "dotplot_gene_names", + partial( + sc.pl.rank_genes_groups_dotplot, + var_names={ + "T-cell": ["CD3D", "CD3E", "IL32"], + "B-cell": ["CD79A", "CD79B", "MS4A1"], + "myeloid": ["CST3", "LYZ"], + }, + values_to_plot="logfoldchanges", + cmap="bwr", + vmin=-3, + vmax=3, + show=False, ), - ( - "ranked_genes_dotplot_logfoldchange", - partial( - sc.pl.rank_genes_groups_dotplot, - n_genes=4, - values_to_plot="logfoldchanges", - vmin=-5, - vmax=5, - min_logfoldchange=3, - cmap="RdBu_r", - swap_axes=True, - title="log fold changes swap_axes", - show=False, - ), + ), + ( + "dotplot_logfoldchange", + partial( + sc.pl.rank_genes_groups_dotplot, + n_genes=4, + values_to_plot="logfoldchanges", + vmin=-5, + vmax=5, + min_logfoldchange=3, + cmap="RdBu_r", + swap_axes=True, + title="log fold changes swap_axes", + show=False, ), - ( - "ranked_genes_dotplot_logfoldchange_vcenter", - partial( - sc.pl.rank_genes_groups_dotplot, - n_genes=4, - values_to_plot="logfoldchanges", - vmin=-5, - vcenter=1, - vmax=5, - min_logfoldchange=3, - cmap="RdBu_r", - swap_axes=True, - title="log fold changes swap_axes", - show=False, - ), + ), + ( + "dotplot_logfoldchange_vcenter", + partial( + sc.pl.rank_genes_groups_dotplot, + n_genes=4, + values_to_plot="logfoldchanges", + vmin=-5, + vcenter=1, + vmax=5, + min_logfoldchange=3, + cmap="RdBu_r", + swap_axes=True, + title="log fold changes swap_axes", + show=False, ), - ( - "ranked_genes_matrixplot", - partial( - sc.pl.rank_genes_groups_matrixplot, - n_genes=5, - show=False, - title="matrixplot", - gene_symbols="symbol", - use_raw=False, - ), + ), + ( + "matrixplot", + partial( + sc.pl.rank_genes_groups_matrixplot, + n_genes=5, + show=False, + title="matrixplot", + gene_symbols="symbol", + use_raw=False, ), - ( - "ranked_genes_matrixplot_gene_names_symbol", - partial( - sc.pl.rank_genes_groups_matrixplot, - var_names={ - "T-cell": ["CD3D__", "CD3E__", "IL32__"], - "B-cell": ["CD79A__", "CD79B__", "MS4A1__"], - "myeloid": ["CST3__", "LYZ__"], - }, - values_to_plot="logfoldchanges", - cmap="bwr", - vmin=-3, - vmax=3, - gene_symbols="symbol", - use_raw=False, - show=False, - ), + ), + ( + "matrixplot_gene_names_symbol", + partial( + sc.pl.rank_genes_groups_matrixplot, + var_names={ + "T-cell": ["CD3D__", "CD3E__", "IL32__"], + "B-cell": ["CD79A__", "CD79B__", "MS4A1__"], + "myeloid": ["CST3__", "LYZ__"], + }, + values_to_plot="logfoldchanges", + cmap="bwr", + vmin=-3, + vmax=3, + gene_symbols="symbol", + use_raw=False, + show=False, ), - ( - "ranked_genes_matrixplot_n_genes_negative", - partial( - sc.pl.rank_genes_groups_matrixplot, - n_genes=-5, - show=False, - title="matrixplot n_genes=-5", - ), + ), + ( + "matrixplot_n_genes_negative", + partial( + sc.pl.rank_genes_groups_matrixplot, + n_genes=-5, + show=False, + title="matrixplot n_genes=-5", ), - ( - "ranked_genes_matrixplot_swap_axes", - partial( - sc.pl.rank_genes_groups_matrixplot, - n_genes=5, - show=False, - swap_axes=True, - values_to_plot="logfoldchanges", - vmin=-6, - vmax=6, - cmap="bwr", - title="log fold changes swap_axes", - ), + ), + ( + "matrixplot_swap_axes", + partial( + sc.pl.rank_genes_groups_matrixplot, + n_genes=5, + show=False, + swap_axes=True, + values_to_plot="logfoldchanges", + vmin=-6, + vmax=6, + cmap="bwr", + title="log fold changes swap_axes", ), - ( - "ranked_genes_matrixplot_swap_axes_vcenter", - partial( - sc.pl.rank_genes_groups_matrixplot, - n_genes=5, - show=False, - swap_axes=True, - values_to_plot="logfoldchanges", - vmin=-6, - vcenter=1, - vmax=6, - cmap="bwr", - title="log fold changes swap_axes", - ), + ), + ( + "matrixplot_swap_axes_vcenter", + partial( + sc.pl.rank_genes_groups_matrixplot, + n_genes=5, + show=False, + swap_axes=True, + values_to_plot="logfoldchanges", + vmin=-6, + vcenter=1, + vmax=6, + cmap="bwr", + title="log fold changes swap_axes", ), - ( - "ranked_genes_tracksplot", - partial( - sc.pl.rank_genes_groups_tracksplot, - n_genes=3, - show=False, - groups=["3", "2", "1"], - ), + ), + ( + "tracksplot", + partial( + sc.pl.rank_genes_groups_tracksplot, + n_genes=3, + show=False, + groups=["3", "2", "1"], ), - ( - "ranked_genes_violin", - partial( - sc.pl.rank_genes_groups_violin, - groups="0", - n_genes=5, - use_raw=True, - jitter=False, - strip=False, - show=False, - ), + ), + ( + "violin", + partial( + sc.pl.rank_genes_groups_violin, + groups="0", + n_genes=5, + use_raw=True, + jitter=False, + strip=False, + show=False, ), - ( - "ranked_genes_violin_not_raw", - partial( - sc.pl.rank_genes_groups_violin, - groups="0", - n_genes=5, - use_raw=False, - jitter=False, - strip=False, - show=False, - ), + ), + ( + "violin_not_raw", + partial( + sc.pl.rank_genes_groups_violin, + groups="0", + n_genes=5, + use_raw=False, + jitter=False, + strip=False, + show=False, ), - ], + ), +] + + +@pytest.mark.parametrize( + ("name", "fn"), + [pytest.param(name, fn, id=name) for name, fn in _RANK_GENES_GROUPS_PARAMS], ) def test_rank_genes_groups(image_comparer, name, fn): save_and_compare_images = partial(image_comparer, ROOT, tol=15) @@ -815,7 +825,8 @@ def test_rank_genes_groups(image_comparer, name, fn): with plt.rc_context({"axes.grid": True, "figure.figsize": (4, 4)}): fn(pbmc) - save_and_compare_images(name) + key = "ranked_genes" if name == "basic" else f"ranked_genes_{name}" + save_and_compare_images(key) plt.close() diff --git a/tests/test_score_genes.py b/tests/test_score_genes.py index c243b8e022..4ac1b62224 100644 --- a/tests/test_score_genes.py +++ b/tests/test_score_genes.py @@ -19,7 +19,8 @@ from numpy.typing import NDArray -HERE = Path(__file__).parent / "_data" +HERE = Path(__file__).parent +DATA_PATH = HERE / "_data" def _create_random_gene_names(n_genes, name_length) -> NDArray[np.str_]: @@ -72,7 +73,7 @@ def test_score_with_reference(): sc.pp.scale(adata) sc.tl.score_genes(adata, gene_list=adata.var_names[:100], score_name="Test") - with (HERE / "score_genes_reference_paul2015.pkl").open("rb") as file: + with (DATA_PATH / "score_genes_reference_paul2015.pkl").open("rb") as file: reference = pickle.load(file) # np.testing.assert_allclose(reference, adata.obs["Test"].to_numpy()) np.testing.assert_array_equal(reference, adata.obs["Test"].to_numpy()) From 8b2088de18452ff11e555bac0c147eaf15cf27f4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 07:43:58 +0000 Subject: [PATCH 21/66] [pre-commit.ci] pre-commit autoupdate (#3256) 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 6ce8309e63..a09eb15442 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.5 + rev: v0.6.7 hooks: - id: ruff types_or: [python, pyi, jupyter] From d9987426be03f9ef1bdab065f50959d046734ea4 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 24 Sep 2024 19:19:24 +0200 Subject: [PATCH 22/66] Add `SIM` checks (#3258) --- pyproject.toml | 1 + src/scanpy/_settings.py | 4 +- src/scanpy/_utils/__init__.py | 17 ++--- src/scanpy/_utils/compute/is_constant.py | 5 +- src/scanpy/datasets/_datasets.py | 2 +- src/scanpy/datasets/_ebi_expression_atlas.py | 5 +- src/scanpy/external/pl.py | 5 +- src/scanpy/get/get.py | 10 +-- src/scanpy/neighbors/__init__.py | 11 +-- src/scanpy/plotting/_anndata.py | 75 ++++++------------- src/scanpy/plotting/_baseplot_class.py | 43 +++++------ src/scanpy/plotting/_tools/__init__.py | 43 ++++------- src/scanpy/plotting/_tools/paga.py | 2 +- src/scanpy/plotting/_tools/scatterplots.py | 17 +---- src/scanpy/plotting/_utils.py | 4 +- src/scanpy/preprocessing/_combat.py | 5 +- .../preprocessing/_deprecated/__init__.py | 22 +++--- src/scanpy/preprocessing/_normalization.py | 12 +-- src/scanpy/preprocessing/_pca.py | 7 +- .../preprocessing/_scrublet/__init__.py | 2 +- src/scanpy/preprocessing/_simple.py | 15 +--- src/scanpy/readwrite.py | 13 ++-- src/scanpy/tools/_dpt.py | 2 +- src/scanpy/tools/_draw_graph.py | 2 +- src/scanpy/tools/_ingest.py | 12 +-- src/scanpy/tools/_louvain.py | 5 +- src/scanpy/tools/_marker_gene_overlap.py | 9 +-- src/scanpy/tools/_paga.py | 10 +-- src/scanpy/tools/_rank_genes_groups.py | 15 +--- src/scanpy/tools/_sim.py | 32 ++++---- src/scanpy/tools/_top_genes.py | 5 +- src/scanpy/tools/_umap.py | 2 +- src/scanpy/tools/_utils.py | 6 +- .../notebooks/test_paga_paul15_subsampled.py | 2 +- tests/test_highly_variable_genes.py | 4 +- tests/test_plotting.py | 12 +-- tests/test_rank_genes_groups_logreg.py | 2 +- tests/test_scaling.py | 2 +- 38 files changed, 160 insertions(+), 282 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec5c6e0541..355c4ff483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -237,6 +237,7 @@ select = [ "PLR0917", # Ban APIs with too many positional parameters "FBT", # No positional boolean parameters "PT", # Pytest style + "SIM", # Simplify control flow ] ignore = [ # line too long -> we accept long comment lines; black gets rid of long code lines diff --git a/src/scanpy/_settings.py b/src/scanpy/_settings.py index b090261d1f..63f91d2279 100644 --- a/src/scanpy/_settings.py +++ b/src/scanpy/_settings.py @@ -371,7 +371,7 @@ def logpath(self) -> Path | None: def logpath(self, logpath: Path | str | None): _type_check(logpath, "logfile", (str, Path)) # set via “file object” branch of logfile.setter - self.logfile = Path(logpath).open("a") + self.logfile = Path(logpath).open("a") # noqa: SIM115 self._logpath = Path(logpath) @property @@ -519,7 +519,7 @@ def __str__(self) -> str: return "\n".join( f"{k} = {v!r}" for k, v in inspect.getmembers(self) - if not k.startswith("_") and not k == "getdoc" + if not k.startswith("_") and k != "getdoc" ) diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 46d62bcde6..b8513d87ba 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -13,7 +13,7 @@ import sys import warnings from collections import namedtuple -from contextlib import contextmanager +from contextlib import contextmanager, suppress from enum import Enum from functools import partial, singledispatch, wraps from operator import mul, truediv @@ -281,10 +281,8 @@ def get_igraph_from_adjacency(adjacency, directed=None): g = ig.Graph(directed=directed) g.add_vertices(adjacency.shape[0]) # this adds adjacency.shape[0] vertices g.add_edges(list(zip(sources, targets))) - try: + with suppress(KeyError): g.es["weight"] = weights - except KeyError: - pass if g.vcount() != adjacency.shape[0]: logg.warning( f"The constructed graph has only {g.vcount()} nodes. " @@ -613,11 +611,10 @@ def _( out: sparse.csr_matrix | sparse.csc_matrix | None = None, ) -> sparse.csr_matrix | sparse.csc_matrix: check_op(op) - if out is not None: - if X.data is not out.data: - raise ValueError( - "`out` argument provided but not equal to X. This behavior is not supported for sparse matrix scaling." - ) + if out is not None and X.data is not out.data: + raise ValueError( + "`out` argument provided but not equal to X. This behavior is not supported for sparse matrix scaling." + ) if not allow_divide_by_zero and op is truediv: scaling_array = scaling_array.copy() + (scaling_array == 0) @@ -684,7 +681,7 @@ def _( column_scale = axis == 1 if isinstance(scaling_array, DaskArray): - if (row_scale and not X.chunksize[0] == scaling_array.chunksize[0]) or ( + if (row_scale and X.chunksize[0] != scaling_array.chunksize[0]) or ( column_scale and ( ( diff --git a/src/scanpy/_utils/compute/is_constant.py b/src/scanpy/_utils/compute/is_constant.py index 7dac03b40a..80f6581980 100644 --- a/src/scanpy/_utils/compute/is_constant.py +++ b/src/scanpy/_utils/compute/is_constant.py @@ -121,10 +121,7 @@ def _is_constant_csr_rows( for i in range(n): start = indptr[i] stop = indptr[i + 1] - if stop - start == shape[1]: - val = data[start] - else: - val = 0 + val = data[start] if stop - start == shape[1] else 0 for j in range(start, stop): if data[j] != val: result[i] = False diff --git a/src/scanpy/datasets/_datasets.py b/src/scanpy/datasets/_datasets.py index ccbc9a3bb3..41b23160d6 100644 --- a/src/scanpy/datasets/_datasets.py +++ b/src/scanpy/datasets/_datasets.py @@ -219,7 +219,7 @@ def moignard15() -> AnnData: } # annotate each observation/cell adata.obs["exp_groups"] = [ - next(gname for gname in groups.keys() if sname.startswith(gname)) + next(gname for gname in groups if sname.startswith(gname)) for sname in adata.obs_names ] # fix the order and colors of names in "groups" diff --git a/src/scanpy/datasets/_ebi_expression_atlas.py b/src/scanpy/datasets/_ebi_expression_atlas.py index 9f3bcb81ad..b7e1886e71 100644 --- a/src/scanpy/datasets/_ebi_expression_atlas.py +++ b/src/scanpy/datasets/_ebi_expression_atlas.py @@ -65,10 +65,7 @@ def read_mtx_from_stream(stream: BinaryIO) -> sparse.csr_matrix: n, m, _ = (int(x) for x in curline[:-1].split(b" ")) max_int32 = np.iinfo(np.int32).max - if n > max_int32 or m > max_int32: - coord_dtype = np.int64 - else: - coord_dtype = np.int32 + coord_dtype = np.int64 if n > max_int32 or m > max_int32 else np.int32 data = pd.read_csv( stream, diff --git a/src/scanpy/external/pl.py b/src/scanpy/external/pl.py index 662bc88eb3..a3d1767cee 100644 --- a/src/scanpy/external/pl.py +++ b/src/scanpy/external/pl.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from typing import TYPE_CHECKING import matplotlib.pyplot as plt @@ -214,10 +215,8 @@ def sam( return axes if isinstance(c, str): - try: + with contextlib.suppress(KeyError): c = np.array(list(adata.obs[c])) - except KeyError: - pass if isinstance(c[0], (str, np.str_)) and isinstance(c, (np.ndarray, list)): import samalg.utilities as ut diff --git a/src/scanpy/get/get.py b/src/scanpy/get/get.py index c5e95de1ab..0c1272ae62 100644 --- a/src/scanpy/get/get.py +++ b/src/scanpy/get/get.py @@ -121,10 +121,7 @@ def _check_indices( use_raw: bool = False, ) -> tuple[list[str], list[str], list[str]]: """Common logic for checking indices for obs_df and var_df.""" - if use_raw: - alt_repr = "adata.raw" - else: - alt_repr = "adata" + alt_repr = "adata.raw" if use_raw else "adata" alt_dim = ("obs", "var")[dim == "obs"] @@ -288,10 +285,7 @@ def obs_df( var = adata.raw.var else: var = adata.var - if gene_symbols is not None: - alias_index = pd.Index(var[gene_symbols]) - else: - alias_index = None + alias_index = pd.Index(var[gene_symbols]) if gene_symbols is not None else None obs_cols, var_idx_keys, var_symbols = _check_indices( adata.obs, diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index 0666a8b3ed..64b14cf112 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from collections.abc import Mapping from textwrap import indent from types import MappingProxyType @@ -469,10 +470,7 @@ def transitions(self) -> np.ndarray | csr_matrix: ----- This has not been tested, in contrast to `transitions_sym`. """ - if issparse(self.Z): - Zinv = self.Z.power(-1) - else: - Zinv = np.diag(1.0 / np.diag(self.Z)) + Zinv = self.Z.power(-1) if issparse(self.Z) else np.diag(1.0 / np.diag(self.Z)) return self.Z @ self.transitions_sym @ Zinv @property @@ -591,10 +589,9 @@ def compute_neighbors( if isinstance(index, NNDescent): # very cautious here - try: + # TODO catch the correct exception + with contextlib.suppress(Exception): self._rp_forest = _make_forest_dict(index) - except Exception: # TODO catch the correct exception - pass start_connect = logg.debug("computed neighbors", time=start_neighbors) if method == "umap": diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index 5bd3de8188..5dfea0ae29 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -157,15 +157,15 @@ def scatter( if x is None or y is None: raise ValueError("Either provide a `basis` or `x` and `y`.") if ( - (x in adata.obs.keys() or x in var_index) - and (y in adata.obs.keys() or y in var_index) - and (color is None or color in adata.obs.keys() or color in var_index) + (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) ): return _scatter_obs(**args) if ( - (x in adata.var.keys() or x in adata.obs.index) - and (y in adata.var.keys() or y in adata.obs.index) - and (color is None or color in adata.var.keys() or color in adata.obs.index) + (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( @@ -217,14 +217,12 @@ def _scatter_obs( use_raw = _check_use_raw(adata, use_raw) # Process layers - if layers in ["X", None] or ( - isinstance(layers, str) and layers in adata.layers.keys() - ): + if layers in ["X", None] or (isinstance(layers, str) and layers in adata.layers): layers = (layers, layers, layers) elif isinstance(layers, Collection) and len(layers) == 3: layers = tuple(layers) for layer in layers: - if layer not in adata.layers.keys() and layer not in ["X", None]: + if layer not in adata.layers and layer not in ["X", None]: raise ValueError( "`layers` should have elements that are " "either None or in adata.layers.keys()." @@ -256,7 +254,7 @@ def _scatter_obs( ) if title is not None and isinstance(title, str): title = [title] - highlights = adata.uns["highlights"] if "highlights" in adata.uns else [] + highlights = adata.uns.get("highlights", []) if basis is not None: try: # ignore the '0th' diffusion component @@ -292,19 +290,14 @@ def _scatter_obs( n = Y.shape[0] size = 120000 / n - if legend_loc.startswith("on data") and legend_fontsize is None: - legend_fontsize = rcParams["legend.fontsize"] - elif legend_fontsize is None: + if legend_fontsize is None: legend_fontsize = rcParams["legend.fontsize"] palette_was_none = False if palette is None: palette_was_none = True if isinstance(palette, Sequence) and not isinstance(palette, str): - if not is_color_like(palette[0]): - palettes = palette - else: - palettes = [palette] + palettes = palette if not is_color_like(palette[0]) else [palette] else: palettes = [palette for _ in range(len(keys))] palettes = [_utils.default_palette(palette) for palette in palettes] @@ -328,7 +321,7 @@ def _scatter_obs( else: component_name = None axis_labels = (x, y) if component_name is None else None - show_ticks = True if component_name is None else False + show_ticks = component_name is None # generate the colors color_ids: list[np.ndarray | ColorLike] = [] @@ -364,9 +357,8 @@ def _scatter_obs( categoricals.append(ikey) color_ids.append(c) - if right_margin is None and len(categoricals) > 0: - if legend_loc == "right margin": - right_margin = 0.5 + if right_margin is None and len(categoricals) > 0 and legend_loc == "right margin": + right_margin = 0.5 if title is None and keys[0] is not None: title = [ key.replace("_", " ") if not is_color_like(key) else "" for key in keys @@ -488,10 +480,7 @@ def add_centroid(centroids, name, Y, mask): all_pos = np.zeros((len(adata.obs[key].cat.categories), 2)) for iname, name in enumerate(adata.obs[key].cat.categories): - if name in centroids: - all_pos[iname] = centroids[name] - else: - all_pos[iname] = [np.nan, np.nan] + all_pos[iname] = centroids.get(name, [np.nan, np.nan]) if legend_loc == "on data export": filename = settings.writedir / "pos.csv" logg.warning(f"exporting label positions to {filename}") @@ -1245,10 +1234,7 @@ def heatmap( groupby_width = 0.2 if categorical else 0 if figsize is None: height = 6 - if show_gene_labels: - heatmap_width = len(var_names) * 0.3 - else: - heatmap_width = 8 + heatmap_width = len(var_names) * 0.3 if show_gene_labels else 8 width = heatmap_width + dendro_width + groupby_width else: width, height = figsize @@ -1352,10 +1338,7 @@ def heatmap( dendro_height = 0.8 if dendrogram else 0 groupby_height = 0.13 if categorical else 0 if figsize is None: - if show_gene_labels: - heatmap_height = len(var_names) * 0.18 - else: - heatmap_height = 4 + heatmap_height = len(var_names) * 0.18 if show_gene_labels else 4 width = 10 height = heatmap_height + dendro_height + groupby_height else: @@ -1440,10 +1423,7 @@ def heatmap( for idx, (label, pos) in enumerate( zip(var_group_labels, var_group_positions) ): - if var_groups_subset_of_groupby: - label_code = label2code[label] - else: - label_code = idx + label_code = label2code[label] if var_groups_subset_of_groupby else idx arr += [label_code] * (pos[1] + 1 - pos[0]) gene_groups_ax.imshow( np.array([arr]).T, aspect="auto", cmap=groupby_cmap, norm=norm @@ -1892,10 +1872,7 @@ def correlation_matrix( labels = adata.obs[groupby].cat.categories num_rows = corr_matrix.shape[0] colorbar_height = 0.2 - if dendrogram: - dendrogram_width = 1.8 - else: - dendrogram_width = 0 + dendrogram_width = 1.8 if dendrogram else 0 if figsize is None: corr_matrix_height = num_rows * 0.6 height = corr_matrix_height + colorbar_height @@ -2052,7 +2029,7 @@ def _prepare_dataframe( "groupby has to be a valid observation. " f"Given {group}, is not in observations: {adata.obs_keys()}" + msg ) - if group in adata.obs.keys() and group == adata.obs.index.name: + if group in adata.obs.columns and group == adata.obs.index.name: raise ValueError( f"Given group {group} is both and index and a column level, " "which is ambiguous." @@ -2171,10 +2148,7 @@ def _plot_gene_groups_brackets( if orientation == "top": # rotate labels if any of them is longer than 4 characters if rotation is None and group_labels: - if max([len(x) for x in group_labels]) > 4: - rotation = 90 - else: - rotation = 0 + rotation = 90 if max([len(x) for x in group_labels]) > 4 else 0 for idx in range(len(left)): verts.append((left[idx], 0)) # lower-left verts.append((left[idx], 0.6)) # upper-left @@ -2600,11 +2574,8 @@ def _plot_categories_as_colorblocks( ) if len(labels) > 1: groupby_ax.set_xticks(ticks) - if max([len(str(x)) for x in labels]) < 3: - # if the labels are small do not rotate them - rotation = 0 - else: - rotation = 90 + # if the labels are small do not rotate them + rotation = 0 if max(len(str(x)) for x in labels) < 3 else 90 groupby_ax.set_xticklabels(labels, rotation=rotation) # remove x ticks diff --git a/src/scanpy/plotting/_baseplot_class.py b/src/scanpy/plotting/_baseplot_class.py index 6e5c8cd2c5..e68cc07727 100644 --- a/src/scanpy/plotting/_baseplot_class.py +++ b/src/scanpy/plotting/_baseplot_class.py @@ -137,9 +137,7 @@ def __init__( self.width, self.height = figsize if figsize is not None else (None, None) self.has_var_groups = ( - True - if var_group_positions is not None and len(var_group_positions) > 0 - else False + var_group_positions is not None and len(var_group_positions) > 0 ) self._update_var_groups() @@ -160,18 +158,19 @@ def __init__( "Plot would be very large." ) - if categories_order is not None: - if set(self.obs_tidy.index.categories) != set(categories_order): - logg.error( - "Please check that the categories given by " - "the `order` parameter match the categories that " - "want to be reordered.\n\n" - "Mismatch: " - f"{set(self.obs_tidy.index.categories).difference(categories_order)}\n\n" - f"Given order categories: {categories_order}\n\n" - f"{groupby} categories: {list(self.obs_tidy.index.categories)}\n" - ) - return + if categories_order is not None and ( + set(self.obs_tidy.index.categories) != set(categories_order) + ): + logg.error( + "Please check that the categories given by " + "the `order` parameter match the categories that " + "want to be reordered.\n\n" + "Mismatch: " + f"{set(self.obs_tidy.index.categories).difference(categories_order)}\n\n" + f"Given order categories: {categories_order}\n\n" + f"{groupby} categories: {list(self.obs_tidy.index.categories)}\n" + ) + return self.adata = adata self.groupby = [groupby] if isinstance(groupby, str) else groupby @@ -388,8 +387,8 @@ def add_totals( self.group_extra_size = 0 return self - _sort = True if sort is not None else False - _ascending = True if sort == "ascending" else False + _sort = sort is not None + _ascending = sort == "ascending" counts_df = self.obs_tidy.index.value_counts(sort=_sort, ascending=_ascending) if _sort: @@ -489,10 +488,7 @@ def _plot_totals( if self.categories_order is not None: counts_df = counts_df.loc[self.categories_order] if params["color"] is None: - if f"{self.groupby}_colors" in self.adata.uns: - color = self.adata.uns[f"{self.groupby}_colors"] - else: - color = "salmon" + color = self.adata.uns.get(f"{self.groupby}_colors", "salmon") else: color = params["color"] @@ -1027,10 +1023,7 @@ def _plot_var_groups_brackets( if orientation == "top": # rotate labels if any of them is longer than 4 characters if rotation is None and group_labels: - if max([len(x) for x in group_labels]) > 4: - rotation = 90 - else: - rotation = 0 + rotation = 90 if max([len(x) for x in group_labels]) > 4 else 0 for idx, (left_coor, right_coor) in enumerate(zip(left, right)): verts.append((left_coor, 0)) # lower-left verts.append((left_coor, 0.6)) # upper-left diff --git a/src/scanpy/plotting/_tools/__init__.py b/src/scanpy/plotting/_tools/__init__.py index eec202d0a5..837d3791e8 100644 --- a/src/scanpy/plotting/_tools/__init__.py +++ b/src/scanpy/plotting/_tools/__init__.py @@ -93,9 +93,7 @@ def pca_overview(adata: AnnData, **params): -------- pp.pca """ - show = params["show"] if "show" in params else None - if "show" in params: - del params["show"] + show = params.pop("show", None) pca(adata, **params, show=False) pca_loadings(adata, show=False) pca_variance_ratio(adata, show=show) @@ -398,10 +396,7 @@ def rank_genes_groups( tl.rank_genes_groups """ - if "n_panels_per_row" in kwds: - n_panels_per_row = kwds["n_panels_per_row"] - else: - n_panels_per_row = ncols + n_panels_per_row = kwds.get("n_panels_per_row", ncols) if n_genes < 1: raise NotImplementedError( "Specifying a negative number for n_genes has not been implemented for " @@ -567,10 +562,7 @@ def _rank_genes_groups_plot( if len(genes_list) == 0: logg.warning(f"No genes found for group {group}") continue - if n_genes < 0: - genes_list = genes_list[n_genes:] - else: - genes_list = genes_list[:n_genes] + genes_list = genes_list[n_genes:] if n_genes < 0 else genes_list[:n_genes] var_names[group] = genes_list var_names_list.extend(genes_list) @@ -1566,10 +1558,7 @@ def embedding_density( # turn group into a list if needed if group == "all": - if groupby is None: - group = None - else: - group = list(adata.obs[groupby].cat.categories) + group = None if groupby is None else list(adata.obs[groupby].cat.categories) elif isinstance(group, str): group = [group] @@ -1633,10 +1622,7 @@ def embedding_density( adata.obs[density_col_name] = dens_values dot_sizes[group_mask] = np.ones(sum(group_mask)) * fg_dotsize - if title is None: - _title = group_name - else: - _title = title + _title = group_name if title is None else title ax = embedding( adata, @@ -1773,16 +1759,15 @@ def _get_values_to_plot( df["names"] = df[gene_symbols] # check that all genes are present in the df as sc.tl.rank_genes_groups # can be called with only top genes - if not check_done: - if df.shape[0] < adata.shape[1]: - message = ( - "Please run `sc.tl.rank_genes_groups` with " - "'n_genes=adata.shape[1]' to save all gene " - f"scores. Currently, only {df.shape[0]} " - "are found" - ) - logg.error(message) - raise ValueError(message) + if not check_done and df.shape[0] < adata.shape[1]: + message = ( + "Please run `sc.tl.rank_genes_groups` with " + "'n_genes=adata.shape[1]' to save all gene " + f"scores. Currently, only {df.shape[0]} " + "are found" + ) + logg.error(message) + raise ValueError(message) df["group"] = group df_list.append(df) diff --git a/src/scanpy/plotting/_tools/paga.py b/src/scanpy/plotting/_tools/paga.py index f0d45e9a80..159be79913 100644 --- a/src/scanpy/plotting/_tools/paga.py +++ b/src/scanpy/plotting/_tools/paga.py @@ -1131,7 +1131,7 @@ def paga_path( groups_key = adata.uns["paga"]["groups"] groups_names = adata.obs[groups_key].cat.categories - if "dpt_pseudotime" not in adata.obs.keys(): + if "dpt_pseudotime" not in adata.obs.columns: raise ValueError( "`pl.paga_path` requires computation of a pseudotime `tl.dpt` " "for ordering at single-cell resolution" diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index 769514a69e..5c15fa8df4 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -886,7 +886,7 @@ def pca( return embedding( adata, "pca", show=show, return_fig=return_fig, save=save, **kwargs ) - if "pca" not in adata.obsm.keys() and "X_pca" not in adata.obsm.keys(): + if "pca" not in adata.obsm and "X_pca" not in adata.obsm: raise KeyError( f"Could not find entry in `obsm` for 'pca'.\n" f"Available keys are: {list(adata.obsm.keys())}." @@ -1011,10 +1011,7 @@ def spatial( crop_coord = _check_crop_coord(crop_coord, scale_factor) na_color = _check_na_color(na_color, img=img) - if bw: - cmap_img = "gray" - else: - cmap_img = None + cmap_img = "gray" if bw else None circle_radius = size * scale_factor * spot_size * 0.5 axs = embedding( @@ -1342,10 +1339,7 @@ def _check_spatial_data( library_id = list(spatial_mapping.keys())[0] else: library_id = None - if library_id is not None: - spatial_data = spatial_mapping[library_id] - else: - spatial_data = None + spatial_data = spatial_mapping[library_id] if library_id is not None else None return library_id, spatial_data @@ -1387,10 +1381,7 @@ def _check_na_color( na_color: ColorLike | None, *, img: np.ndarray | None = None ) -> ColorLike: if na_color is None: - if img is not None: - na_color = (0.0, 0.0, 0.0, 0.0) - else: - na_color = "lightgray" + na_color = (0.0, 0.0, 0.0, 0.0) if img is not None else "lightgray" return na_color diff --git a/src/scanpy/plotting/_utils.py b/src/scanpy/plotting/_utils.py index ea6aa0cb10..13832658f5 100644 --- a/src/scanpy/plotting/_utils.py +++ b/src/scanpy/plotting/_utils.py @@ -1280,10 +1280,10 @@ def fix_kwds(kwds_dict, **kwargs): def _get_basis(adata: AnnData, basis: str): - if basis in adata.obsm.keys(): + if basis in adata.obsm: basis_key = basis - elif f"X_{basis}" in adata.obsm.keys(): + elif f"X_{basis}" in adata.obsm: basis_key = f"X_{basis}" return basis_key diff --git a/src/scanpy/preprocessing/_combat.py b/src/scanpy/preprocessing/_combat.py index b8487e4e7e..ef193d38b4 100644 --- a/src/scanpy/preprocessing/_combat.py +++ b/src/scanpy/preprocessing/_combat.py @@ -196,10 +196,7 @@ def combat( raise ValueError("Covariates must be unique") # only works on dense matrices so far - if issparse(adata.X): - X = adata.X.toarray().T - else: - X = adata.X.T + X = adata.X.toarray().T if issparse(adata.X) else adata.X.T data = pd.DataFrame(data=X, index=adata.var_names, columns=adata.obs_names) sanitize_anndata(adata) diff --git a/src/scanpy/preprocessing/_deprecated/__init__.py b/src/scanpy/preprocessing/_deprecated/__init__.py index bb944b874a..7cd7520171 100644 --- a/src/scanpy/preprocessing/_deprecated/__init__.py +++ b/src/scanpy/preprocessing/_deprecated/__init__.py @@ -7,7 +7,7 @@ @legacy_api("max_fraction", "mult_with_mean") def normalize_per_cell_weinreb16_deprecated( - X: np.ndarray, + x: np.ndarray, *, max_fraction: float = 1, mult_with_mean: bool = False, @@ -37,23 +37,23 @@ def normalize_per_cell_weinreb16_deprecated( if max_fraction < 0 or max_fraction > 1: raise ValueError("Choose max_fraction between 0 and 1.") - counts_per_cell = X.sum(1).A1 if issparse(X) else X.sum(1) - gene_subset = np.all(X <= counts_per_cell[:, None] * max_fraction, axis=0) - if issparse(X): + counts_per_cell = x.sum(1).A1 if issparse(x) else x.sum(1) + gene_subset = np.all(x <= counts_per_cell[:, None] * max_fraction, axis=0) + if issparse(x): gene_subset = gene_subset.A1 tc_include = ( - X[:, gene_subset].sum(1).A1 if issparse(X) else X[:, gene_subset].sum(1) + x[:, gene_subset].sum(1).A1 if issparse(x) else x[:, gene_subset].sum(1) ) - X_norm = ( - X.multiply(csr_matrix(1 / tc_include[:, None])) - if issparse(X) - else X / tc_include[:, None] + x_norm = ( + x.multiply(csr_matrix(1 / tc_include[:, None])) + if issparse(x) + else x / tc_include[:, None] ) if mult_with_mean: - X_norm *= np.mean(counts_per_cell) + x_norm *= np.mean(counts_per_cell) - return X_norm + return x_norm def zscore_deprecated(X: np.ndarray) -> np.ndarray: diff --git a/src/scanpy/preprocessing/_normalization.py b/src/scanpy/preprocessing/_normalization.py index c6fccfb70a..686c69b224 100644 --- a/src/scanpy/preprocessing/_normalization.py +++ b/src/scanpy/preprocessing/_normalization.py @@ -206,25 +206,25 @@ def normalize_total( view_to_actual(adata) - X = _get_obs_rep(adata, layer=layer) + x = _get_obs_rep(adata, layer=layer) gene_subset = None msg = "normalizing counts per cell" - counts_per_cell = axis_sum(X, axis=1) + counts_per_cell = axis_sum(x, axis=1) if exclude_highly_expressed: counts_per_cell = np.ravel(counts_per_cell) # at least one cell as more than max_fraction of counts per cell - gene_subset = axis_sum((X > counts_per_cell[:, None] * max_fraction), axis=0) + gene_subset = axis_sum((x > counts_per_cell[:, None] * max_fraction), axis=0) gene_subset = np.asarray(np.ravel(gene_subset) == 0) msg += ( ". The following highly-expressed genes are not considered during " f"normalization factor computation:\n{adata.var_names[~gene_subset].tolist()}" ) - counts_per_cell = axis_sum(X[:, gene_subset], axis=1) + counts_per_cell = axis_sum(x[:, gene_subset], axis=1) start = logg.info(msg) counts_per_cell = np.ravel(counts_per_cell) @@ -237,12 +237,12 @@ def normalize_total( if key_added is not None: adata.obs[key_added] = counts_per_cell _set_obs_rep( - adata, _normalize_data(X, counts_per_cell, target_sum), layer=layer + adata, _normalize_data(x, counts_per_cell, target_sum), layer=layer ) else: # not recarray because need to support sparse dat = dict( - X=_normalize_data(X, counts_per_cell, target_sum, copy=True), + X=_normalize_data(x, counts_per_cell, target_sum, copy=True), norm_factor=counts_per_cell, ) diff --git a/src/scanpy/preprocessing/_pca.py b/src/scanpy/preprocessing/_pca.py index 5b5706c123..93432841f8 100644 --- a/src/scanpy/preprocessing/_pca.py +++ b/src/scanpy/preprocessing/_pca.py @@ -202,10 +202,7 @@ def pca( if n_comps is None: min_dim = min(adata_comp.n_vars, adata_comp.n_obs) - if settings.N_PCS >= min_dim: - n_comps = min_dim - 1 - else: - n_comps = settings.N_PCS + n_comps = min_dim - 1 if min_dim <= settings.N_PCS else settings.N_PCS logg.info(f" with n_comps={n_comps}") @@ -395,7 +392,7 @@ def _handle_mask_var( if use_highly_variable or ( use_highly_variable is None and mask_var is _empty - and "highly_variable" in adata.var.keys() + and "highly_variable" in adata.var.columns ): mask_var = "highly_variable" diff --git a/src/scanpy/preprocessing/_scrublet/__init__.py b/src/scanpy/preprocessing/_scrublet/__init__.py index 976dafe89f..d57eb81750 100644 --- a/src/scanpy/preprocessing/_scrublet/__init__.py +++ b/src/scanpy/preprocessing/_scrublet/__init__.py @@ -245,7 +245,7 @@ def _run_scrublet(ad_obs: AnnData, ad_sim: AnnData | None = None): return {"obs": ad_obs.obs, "uns": ad_obs.uns["scrublet"]} if batch_key is not None: - if batch_key not in adata.obs.keys(): + if batch_key not in adata.obs.columns: msg = ( "`batch_key` must be a column of .obs in the input AnnData object," f"but {batch_key!r} is not in {adata.obs.keys()!r}." diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 6cf42cccb7..073af955e9 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -376,10 +376,7 @@ def log1p_array(X: np.ndarray, *, base: Number | None = None, copy: bool = False # Can force arrays to be np.ndarrays, but would be useful to not # X = check_array(X, dtype=(np.float64, np.float32), ensure_2d=False, copy=copy) if copy: - if not np.issubdtype(X.dtype, np.floating): - X = X.astype(float) - else: - X = X.copy() + X = X.astype(float) if not np.issubdtype(X.dtype, np.floating) else X.copy() elif not (np.issubdtype(X.dtype, np.floating) or np.issubdtype(X.dtype, complex)): X = X.astype(float) np.log1p(X, out=X) @@ -700,10 +697,7 @@ def regress_out( # regress on one or several ordinal variables else: # create data frame with selected keys (if given) - if keys: - regressors = adata.obs[keys] - else: - regressors = adata.obs.copy() + regressors = adata.obs[keys] if keys else adata.obs.copy() # add column of ones at index 0 (first column) regressors.insert(0, "ones", 1.0) @@ -720,10 +714,7 @@ def regress_out( 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. - if variable_is_categorical: - regres = regressors_chunk[idx] - else: - regres = regressors + regres = regressors_chunk[idx] if variable_is_categorical else regressors tasks.append(tuple((data_chunk, regres, variable_is_categorical))) from joblib import Parallel, delayed diff --git a/src/scanpy/readwrite.py b/src/scanpy/readwrite.py index 90179daf46..9e0a298e18 100644 --- a/src/scanpy/readwrite.py +++ b/src/scanpy/readwrite.py @@ -703,13 +703,12 @@ def read_params( params = OrderedDict([]) for line in filename.open(): - if "=" in line: - if not as_header or line.startswith("#"): - line = line[1:] if line.startswith("#") else line - key, val = line.split("=") - key = key.strip() - val = val.strip() - params[key] = convert_string(val) + if "=" in line and (not as_header or line.startswith("#")): + line = line[1:] if line.startswith("#") else line + key, val = line.split("=") + key = key.strip() + val = val.strip() + params[key] = convert_string(val) return params diff --git a/src/scanpy/tools/_dpt.py b/src/scanpy/tools/_dpt.py index 8c5f7d857b..c0fa59262f 100644 --- a/src/scanpy/tools/_dpt.py +++ b/src/scanpy/tools/_dpt.py @@ -137,7 +137,7 @@ def dpt( " adata.uns['iroot'] = root_cell_index\n" " adata.var['xroot'] = adata[root_cell_name, :].X" ) - if "X_diffmap" not in adata.obsm.keys(): + if "X_diffmap" not in adata.obsm: logg.warning( "Trying to run `tl.dpt` without prior call of `tl.diffmap`. " "Falling back to `tl.diffmap` with default parameters." diff --git a/src/scanpy/tools/_draw_graph.py b/src/scanpy/tools/_draw_graph.py index b36638b5b5..4e8c91fb1f 100644 --- a/src/scanpy/tools/_draw_graph.py +++ b/src/scanpy/tools/_draw_graph.py @@ -130,7 +130,7 @@ def draw_graph( if adjacency is None: adjacency = _choose_graph(adata, obsp, neighbors_key) # init coordinates - if init_pos in adata.obsm.keys(): + if init_pos in adata.obsm: init_coords = adata.obsm[init_pos] elif init_pos == "paga" or init_pos: init_coords = get_init_pos_from_paga( diff --git a/src/scanpy/tools/_ingest.py b/src/scanpy/tools/_ingest.py index 136f58af46..9ced418888 100644 --- a/src/scanpy/tools/_ingest.py +++ b/src/scanpy/tools/_ingest.py @@ -297,16 +297,12 @@ def _init_neighbors(self, adata, neighbors_key): self._use_rep = "X_pca" self._n_pcs = neighbors["params"]["n_pcs"] self._rep = adata.obsm["X_pca"][:, : self._n_pcs] - elif adata.n_vars > settings.N_PCS and "X_pca" in adata.obsm.keys(): + elif adata.n_vars > settings.N_PCS and "X_pca" in adata.obsm: self._use_rep = "X_pca" self._rep = adata.obsm["X_pca"][:, : settings.N_PCS] self._n_pcs = self._rep.shape[1] - if "metric_kwds" in neighbors["params"]: - self._metric_kwds = neighbors["params"]["metric_kwds"] - else: - self._metric_kwds = {} - + self._metric_kwds = neighbors["params"].get("metric_kwds", {}) self._metric = neighbors["params"]["metric"] self._neigh_random_state = neighbors["params"].get("random_state", 0) @@ -317,7 +313,7 @@ def _init_pca(self, adata): self._pca_use_hvg = adata.uns["pca"]["params"]["use_highly_variable"] mask = "highly_variable" - if self._pca_use_hvg and mask not in adata.var.keys(): + if self._pca_use_hvg and mask not in adata.var.columns: msg = f"Did not find `adata.var[{mask!r}']`." raise ValueError(msg) @@ -376,7 +372,7 @@ def _same_rep(self): return self._pca(self._n_pcs) if self._use_rep == "X": return adata.X - if self._use_rep in adata.obsm.keys(): + if self._use_rep in adata.obsm: return adata.obsm[self._use_rep] return adata.X diff --git a/src/scanpy/tools/_louvain.py b/src/scanpy/tools/_louvain.py index e6259f035d..d3e616a850 100644 --- a/src/scanpy/tools/_louvain.py +++ b/src/scanpy/tools/_louvain.py @@ -163,10 +163,7 @@ def louvain( if not directed: logg.debug(" using the undirected graph") g = _utils.get_igraph_from_adjacency(adjacency, directed=directed) - if use_weights: - weights = np.array(g.es["weight"]).astype(np.float64) - else: - weights = None + weights = np.array(g.es["weight"]).astype(np.float64) if use_weights else None if flavor == "vtraag": import louvain diff --git a/src/scanpy/tools/_marker_gene_overlap.py b/src/scanpy/tools/_marker_gene_overlap.py index 1c286e9333..a1d71cf993 100644 --- a/src/scanpy/tools/_marker_gene_overlap.py +++ b/src/scanpy/tools/_marker_gene_overlap.py @@ -30,10 +30,7 @@ def _calc_overlap_count(markers1: dict, markers2: dict): overlaps = np.zeros((len(markers1), len(markers2))) for j, marker_group in enumerate(markers1): - tmp = [ - len(markers2[i].intersection(markers1[marker_group])) - for i in markers2.keys() - ] + tmp = [len(markers2[i].intersection(markers1[marker_group])) for i in markers2] overlaps[j, :] = tmp return overlaps @@ -51,7 +48,7 @@ def _calc_overlap_coef(markers1: dict, markers2: dict): tmp = [ len(markers2[i].intersection(markers1[marker_group])) / max(min(len(markers2[i]), len(markers1[marker_group])), 1) - for i in markers2.keys() + for i in markers2 ] overlap_coef[j, :] = tmp @@ -70,7 +67,7 @@ def _calc_jaccard(markers1: dict, markers2: dict): tmp = [ len(markers2[i].intersection(markers1[marker_group])) / len(markers2[i].union(markers1[marker_group])) - for i in markers2.keys() + for i in markers2 ] jacc_results[j, :] = tmp diff --git a/src/scanpy/tools/_paga.py b/src/scanpy/tools/_paga.py index 98f0ac622b..98146b83e2 100644 --- a/src/scanpy/tools/_paga.py +++ b/src/scanpy/tools/_paga.py @@ -196,10 +196,7 @@ def _compute_connectivities_v1_2(self): inter_es = inter_es.tocoo() for i, j, v in zip(inter_es.row, inter_es.col, inter_es.data): expected_random_null = (es[i] * ns[j] + es[j] * ns[i]) / (n - 1) - if expected_random_null != 0: - scaled_value = v / expected_random_null - else: - scaled_value = 1 + scaled_value = v / expected_random_null if expected_random_null != 0 else 1 if scaled_value > 1: scaled_value = 1 connectivities[i, j] = scaled_value @@ -229,10 +226,7 @@ def _compute_connectivities_v1_0(self): for i, j, v in zip(inter_es.row, inter_es.col, inter_es.data): # have n_neighbors**2 inside sqrt for backwards compat geom_mean_approx_knn = np.sqrt(n_neighbors_sq * ns[i] * ns[j]) - if geom_mean_approx_knn != 0: - scaled_value = v / geom_mean_approx_knn - else: - scaled_value = 1 + scaled_value = v / geom_mean_approx_knn if geom_mean_approx_knn != 0 else 1 connectivities[i, j] = scaled_value # set attributes self.ns = ns diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 3327fe7501..56b71d55eb 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -287,10 +287,7 @@ def wilcoxon( # initialize space for z-scores scores = np.zeros(n_genes) # initialize space for tie correction coefficients - if tie_correct: - T = np.zeros(n_genes) - else: - T = 1 + T = np.zeros(n_genes) if tie_correct else 1 for group_index, mask_obs in enumerate(self.groups_masks_obs): if group_index == self.ireference: @@ -346,10 +343,7 @@ def wilcoxon( for group_index, mask_obs in enumerate(self.groups_masks_obs): n_active = np.count_nonzero(mask_obs) - if tie_correct: - T_i = T[group_index] - else: - T_i = 1 + T_i = T[group_index] if tie_correct else 1 std_dev = np.sqrt( T_i * n_active * (n_cells - n_active) * (n_cells + 1) / 12.0 @@ -733,10 +727,7 @@ def rank_genes_groups( def _calc_frac(X): - if issparse(X): - n_nonzero = X.getnnz(axis=0) - else: - n_nonzero = np.count_nonzero(X, axis=0) + n_nonzero = X.getnnz(axis=0) if issparse(X) else np.count_nonzero(X, axis=0) return n_nonzero / X.shape[0] diff --git a/src/scanpy/tools/_sim.py b/src/scanpy/tools/_sim.py index bf562b8408..7410442952 100644 --- a/src/scanpy/tools/_sim.py +++ b/src/scanpy/tools/_sim.py @@ -198,7 +198,7 @@ def sample_dynamic_data(**params): X[::step], dir=writedir, noiseObs=noiseObs, - append=(False if restart == 0 else True), + append=restart != 0, branching=branching, nrRealizations=nrRealizations, ) @@ -208,7 +208,7 @@ def sample_dynamic_data(**params): noiseDyn * np.random.randn(500, 3), dir=writedir, noiseObs=noiseObs, - append=(False if restart == 0 else True), + append=restart != 0, branching=branching, nrRealizations=nrRealizations, ) @@ -270,7 +270,7 @@ def sample_dynamic_data(**params): X[::step], dir=writedir, noiseObs=noiseObs, - append=(False if restart == 0 else True), + append=restart != 0, branching=branching, nrRealizations=nrRealizations, ) @@ -367,7 +367,7 @@ def write_data( # variable names if varNames: header += f'{"it":>2} ' - for v in varNames.keys(): + for v in varNames: header += f"{v:>7} " with (dir / f"sim_{id}.txt").open("ab" if append else "wb") as f: np.savetxt( @@ -430,7 +430,7 @@ def __init__( # checks if initType not in ["branch", "random"]: raise RuntimeError("initType must be either: branch, random") - if model not in self.availModels.keys(): + if model not in self.availModels: message = "model not among predefined models \n" # noqa: F841 # TODO FIX # read from file from .. import sim_models @@ -605,12 +605,12 @@ def set_coupl(self, Coupl=None): or via sampling. """ self.varNames = {str(i): i for i in range(self.dim)} - if self.model not in self.availModels.keys() and Coupl is None: + if self.model not in self.availModels and Coupl is None: self.read_model() elif "var" in self.model.name: # vector auto regressive process self.Coupl = Coupl - self.boolRules = {s: "" for s in self.varNames.keys()} + self.boolRules = {s: "" for s in self.varNames} names = list(self.varNames.keys()) for gp in range(self.dim): pas = [] @@ -819,7 +819,7 @@ def parents_from_boolRule(self, rule): pa_old = [] pa_delete = [] for pa in rule_pa: - if pa not in self.varNames.keys(): + if pa not in self.varNames: settings.m(0, "list of available variables:") settings.m(0, list(self.varNames.keys())) message = ( @@ -842,12 +842,11 @@ def parents_from_boolRule(self, rule): def build_boolCoeff(self): """Compute coefficients for tuple space.""" # coefficients for hill functions from boolean update rules - self.boolCoeff = {s: [] for s in self.varNames.keys()} + self.boolCoeff = {s: [] for s in self.varNames} # parents - self.pas = {s: [] for s in self.varNames.keys()} + self.pas = {s: [] for s in self.varNames} # - for key in self.boolRules.keys(): - rule = self.boolRules[key] + for key, rule in self.boolRules.items(): self.pas[key] = self.parents_from_boolRule(rule) pasIndices = [self.varNames[pa] for pa in self.pas[key]] # check whether there are coupling matrix entries for each parent @@ -1150,11 +1149,10 @@ def sim_givenAdj(self, Adj: np.ndarray, model="line"): # if there is more than a child with a single parent # order these children (there are two in three dim) # by distance to the source/parent - if nrchildren_par[1] > 1: - if Adj[children_sorted[0], parents[0]] == 0: - help = children_sorted[0] - children_sorted[0] = children_sorted[1] - children_sorted[1] = help + if nrchildren_par[1] > 1 and Adj[children_sorted[0], parents[0]] == 0: + help = children_sorted[0] + children_sorted[0] = children_sorted[1] + children_sorted[1] = help for gp in children_sorted: for g in range(dim): diff --git a/src/scanpy/tools/_top_genes.py b/src/scanpy/tools/_top_genes.py index 00dc764d82..d66e9232f0 100644 --- a/src/scanpy/tools/_top_genes.py +++ b/src/scanpy/tools/_top_genes.py @@ -181,10 +181,7 @@ def ROC_AUC_analysis( y_true = mask for i, j in enumerate(name_list): vec = adata[:, [j]].X - if issparse(vec): - y_score = vec.todense() - else: - y_score = vec + y_score = vec.todense() if issparse(vec) else vec ( fpr[name_list[i]], diff --git a/src/scanpy/tools/_umap.py b/src/scanpy/tools/_umap.py index c23b5551fa..4f225da2a1 100644 --- a/src/scanpy/tools/_umap.py +++ b/src/scanpy/tools/_umap.py @@ -187,7 +187,7 @@ def umap( if a is None or b is None: a, b = find_ab_params(spread, min_dist) adata.uns[key_uns] = dict(params=dict(a=a, b=b)) - if isinstance(init_pos, str) and init_pos in adata.obsm.keys(): + if isinstance(init_pos, str) and init_pos in adata.obsm: init_coords = adata.obsm[init_pos] elif isinstance(init_pos, str) and init_pos == "paga": init_coords = get_init_pos_from_paga( diff --git a/src/scanpy/tools/_utils.py b/src/scanpy/tools/_utils.py index b3ffa7d324..97e2de0df1 100644 --- a/src/scanpy/tools/_utils.py +++ b/src/scanpy/tools/_utils.py @@ -30,7 +30,7 @@ def _choose_representation( use_rep = "X" if use_rep is None: if adata.n_vars > settings.N_PCS: - if "X_pca" in adata.obsm.keys(): + if "X_pca" in adata.obsm: if n_pcs is not None and n_pcs > adata.obsm["X_pca"].shape[1]: raise ValueError( "`X_pca` does not have enough PCs. Rerun `sc.pp.pca` with adjusted `n_comps`." @@ -50,7 +50,7 @@ def _choose_representation( logg.info(" using data matrix X directly") X = adata.X else: - if use_rep in adata.obsm.keys() and n_pcs is not None: + if use_rep in adata.obsm and n_pcs is not None: if n_pcs > adata.obsm[use_rep].shape[1]: raise ValueError( f"{use_rep} does not have enough Dimensions. Provide a " @@ -58,7 +58,7 @@ def _choose_representation( "`n_pcs` or lower `n_pcs` " ) X = adata.obsm[use_rep][:, :n_pcs] - elif use_rep in adata.obsm.keys() and n_pcs is None: + elif use_rep in adata.obsm and n_pcs is None: X = adata.obsm[use_rep] elif use_rep == "X": X = adata.X diff --git a/tests/notebooks/test_paga_paul15_subsampled.py b/tests/notebooks/test_paga_paul15_subsampled.py index 6d4ee886ba..9ce6ea8319 100644 --- a/tests/notebooks/test_paga_paul15_subsampled.py +++ b/tests/notebooks/test_paga_paul15_subsampled.py @@ -129,7 +129,7 @@ def test_paga_paul15_subsampled(image_comparer, plt): left_margin=0.15, n_avg=50, annotations=["distance"], - show_yticks=True if ipath == 0 else False, + show_yticks=ipath == 0, show_colorbar=False, color_map="Greys", color_maps_annotations={"distance": "viridis"}, diff --git a/tests/test_highly_variable_genes.py b/tests/test_highly_variable_genes.py index f3b9298505..cdd5238c70 100644 --- a/tests/test_highly_variable_genes.py +++ b/tests/test_highly_variable_genes.py @@ -255,7 +255,7 @@ def test_pearson_residuals_general( "residual_variances", "highly_variable_rank", ]: - assert key in output_df.keys() + assert key in output_df.columns # check consistency with normalization method if subset: @@ -324,7 +324,7 @@ def test_pearson_residuals_batch(pbmc3k_parametrized_small, subset, n_top_genes) "highly_variable_nbatches", "highly_variable_intersection", ]: - assert key in output_df.keys() + assert key in output_df.columns # general checks on ranks, hvg flag and residual variance _check_pearson_hvg_columns(output_df, n_top_genes) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 92eb61c252..b34db78780 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1590,11 +1590,13 @@ def test_color_cycler(caplog): colors = sns.color_palette("deep") cyl = sns.rcmod.cycler("color", sns.color_palette("deep")) - with caplog.at_level(logging.WARNING): - with plt.rc_context({"axes.prop_cycle": cyl, "patch.facecolor": colors[0]}): - sc.pl.umap(pbmc, color="phase") - plt.show() - plt.close() + with ( + caplog.at_level(logging.WARNING), + plt.rc_context({"axes.prop_cycle": cyl, "patch.facecolor": colors[0]}), + ): + sc.pl.umap(pbmc, color="phase") + plt.show() + plt.close() assert caplog.text == "" diff --git a/tests/test_rank_genes_groups_logreg.py b/tests/test_rank_genes_groups_logreg.py index 3cc294487e..618de375f7 100644 --- a/tests/test_rank_genes_groups_logreg.py +++ b/tests/test_rank_genes_groups_logreg.py @@ -40,7 +40,7 @@ def test_rank_genes_groups_with_renamed_categories_use_rep(): assert adata.uns["rank_genes_groups"]["names"][0].tolist() == ("1", "3", "0") sc.tl.rank_genes_groups(adata, "blobs", method="logreg") - assert not adata.uns["rank_genes_groups"]["names"][0].tolist() == ("3", "1", "0") + assert adata.uns["rank_genes_groups"]["names"][0].tolist() != ("3", "1", "0") def test_rank_genes_groups_with_unsorted_groups(): diff --git a/tests/test_scaling.py b/tests/test_scaling.py index fad2443dc1..0ad62bbc7d 100644 --- a/tests/test_scaling.py +++ b/tests/test_scaling.py @@ -120,7 +120,7 @@ def test_mask_string(): adata.obs["some cells"] = np.array((0, 0, 1, 1, 1, 0, 0), dtype=bool) sc.pp.scale(adata, mask_obs="some cells") assert np.array_equal(adata.X, X_centered_for_mask) - assert "mean of some cells" in adata.var.keys() + assert "mean of some cells" in adata.var.columns @pytest.mark.parametrize("zero_center", [True, False]) From 48706caa5e3b0b5076b4156249acc3579f1e20bc Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 26 Sep 2024 16:13:50 +0200 Subject: [PATCH 23/66] Fix compat typing and old_positionals usage (#3264) --- docs/release-notes/3264.bugfix.md | 1 + pyproject.toml | 1 + src/scanpy/_compat.py | 32 ++++++++++++++----- src/scanpy/neighbors/__init__.py | 3 +- src/scanpy/plotting/_baseplot_class.py | 7 ++-- src/scanpy/plotting/_preprocessing.py | 3 +- .../preprocessing/_deprecated/__init__.py | 5 +-- .../_deprecated/highly_variable_genes.py | 4 +-- src/scanpy/preprocessing/_simple.py | 9 ++++-- src/scanpy/readwrite.py | 3 +- src/scanpy/tools/_ingest.py | 5 ++- 11 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 docs/release-notes/3264.bugfix.md diff --git a/docs/release-notes/3264.bugfix.md b/docs/release-notes/3264.bugfix.md new file mode 100644 index 0000000000..0886ee6aa3 --- /dev/null +++ b/docs/release-notes/3264.bugfix.md @@ -0,0 +1 @@ +Switched all compatibility adapters for positional parameters to {exc}`FutureWarning` {smaller}`P Angerer` diff --git a/pyproject.toml b/pyproject.toml index 355c4ff483..b13631fe9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,6 +259,7 @@ required-imports = ["from __future__ import annotations"] "pytest.importorskip".msg = "Use the “@needs” decorator/mark instead" "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" [tool.ruff.lint.flake8-type-checking] exempt-modules = [] strict = true diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index 4af24fd6a9..e247524c31 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -3,22 +3,30 @@ import sys from dataclasses import dataclass, field from functools import cache, partial +from importlib.util import find_spec from pathlib import Path +from typing import TYPE_CHECKING -from legacy_api_wrap import legacy_api from packaging.version import Version -try: +if TYPE_CHECKING: + from importlib.metadata import PackageMetadata + + +if TYPE_CHECKING: + # type checkers are confused and can only see …core.Array + from dask.array.core import Array as DaskArray +elif find_spec("dask"): from dask.array import Array as DaskArray -except ImportError: +else: class DaskArray: pass -try: +if find_spec("zappy") or TYPE_CHECKING: from zappy.base import ZappyArray -except ImportError: +else: class ZappyArray: pass @@ -60,17 +68,25 @@ def __exit__(self, *_excinfo) -> None: os.chdir(self._old_cwd.pop()) -def pkg_metadata(package): +def pkg_metadata(package: str) -> PackageMetadata: from importlib.metadata import metadata return metadata(package) @cache -def pkg_version(package): +def pkg_version(package: str) -> Version: from importlib.metadata import version return Version(version(package)) -old_positionals = partial(legacy_api, category=FutureWarning) +if find_spec("legacy_api_wrap") or TYPE_CHECKING: + from legacy_api_wrap import legacy_api # noqa: TID251 + + old_positionals = partial(legacy_api, category=FutureWarning) +else: + # legacy_api_wrap is currently a hard dependency, + # but this code makes it possible to run scanpy without it. + def old_positionals(*old_positionals: str): + return lambda func: func diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index 64b14cf112..b98e7d4458 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -9,7 +9,6 @@ import numpy as np import scipy -from legacy_api_wrap import legacy_api from scipy.sparse import issparse from sklearn.utils import check_random_state @@ -711,7 +710,7 @@ def _handle_transformer( # else `transformer` is probably an instance return conn_method, transformer, shortcut - @legacy_api("density_normalize") + @old_positionals("density_normalize") def compute_transitions(self, *, density_normalize: bool = True): """\ Compute transition matrix. diff --git a/src/scanpy/plotting/_baseplot_class.py b/src/scanpy/plotting/_baseplot_class.py index e68cc07727..23e74505b1 100644 --- a/src/scanpy/plotting/_baseplot_class.py +++ b/src/scanpy/plotting/_baseplot_class.py @@ -7,7 +7,6 @@ from warnings import warn import numpy as np -from legacy_api_wrap import legacy_api from matplotlib import gridspec from matplotlib import pyplot as plt @@ -206,7 +205,7 @@ def __init__( self.ax_dict = None self.ax = ax - @legacy_api("swap_axes") + @old_positionals("swap_axes") def swap_axes(self, *, swap_axes: bool | None = True) -> Self: """ Plots a transposed image. @@ -234,7 +233,7 @@ def swap_axes(self, *, swap_axes: bool | None = True) -> Self: self.are_axes_swapped = swap_axes return self - @legacy_api("show", "dendrogram_key", "size") + @old_positionals("show", "dendrogram_key", "size") def add_dendrogram( self, *, @@ -321,7 +320,7 @@ def add_dendrogram( } return self - @legacy_api("show", "sort", "size", "color") + @old_positionals("show", "sort", "size", "color") def add_totals( self, *, diff --git a/src/scanpy/plotting/_preprocessing.py b/src/scanpy/plotting/_preprocessing.py index f2fd1c6d66..e6c7808be1 100644 --- a/src/scanpy/plotting/_preprocessing.py +++ b/src/scanpy/plotting/_preprocessing.py @@ -3,7 +3,6 @@ import numpy as np import pandas as pd from anndata import AnnData -from legacy_api_wrap import legacy_api from matplotlib import pyplot as plt from matplotlib import rcParams @@ -104,7 +103,7 @@ def highly_variable_genes( # backwards compat -@legacy_api("log", "show", "save") +@old_positionals("log", "show", "save") def filter_genes_dispersion( result: np.recarray, *, diff --git a/src/scanpy/preprocessing/_deprecated/__init__.py b/src/scanpy/preprocessing/_deprecated/__init__.py index 7cd7520171..c23361631a 100644 --- a/src/scanpy/preprocessing/_deprecated/__init__.py +++ b/src/scanpy/preprocessing/_deprecated/__init__.py @@ -1,11 +1,12 @@ from __future__ import annotations import numpy as np -from legacy_api_wrap import legacy_api from scipy.sparse import csr_matrix, issparse +from ..._compat import old_positionals -@legacy_api("max_fraction", "mult_with_mean") + +@old_positionals("max_fraction", "mult_with_mean") def normalize_per_cell_weinreb16_deprecated( x: np.ndarray, *, diff --git a/src/scanpy/preprocessing/_deprecated/highly_variable_genes.py b/src/scanpy/preprocessing/_deprecated/highly_variable_genes.py index ab75346127..f2c3ce971b 100644 --- a/src/scanpy/preprocessing/_deprecated/highly_variable_genes.py +++ b/src/scanpy/preprocessing/_deprecated/highly_variable_genes.py @@ -6,10 +6,10 @@ import numpy as np import pandas as pd from anndata import AnnData -from legacy_api_wrap import legacy_api from scipy.sparse import issparse from ... import logging as logg +from ..._compat import old_positionals from .._distributed import materialize_as_ndarray from .._utils import _get_mean_var @@ -19,7 +19,7 @@ from scipy.sparse import spmatrix -@legacy_api( +@old_positionals( "flavor", "min_disp", "max_disp", diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 073af955e9..983a372003 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -13,7 +13,6 @@ import numpy as np import scipy as sp from anndata import AnnData -from legacy_api_wrap import legacy_api from pandas.api.types import CategoricalDtype from scipy.sparse import csr_matrix, issparse, isspmatrix_csr, spmatrix from sklearn.utils import check_array, sparsefuncs @@ -474,7 +473,7 @@ def sqrt( return X.sqrt() -@legacy_api( +@old_positionals( "counts_per_cell_after", "counts_per_cell", "key_n_counts", @@ -577,7 +576,11 @@ def normalize_per_cell( adata.obs[key_n_counts] = counts_per_cell adata._inplace_subset_obs(cell_subset) counts_per_cell = counts_per_cell[cell_subset] - normalize_per_cell(adata.X, counts_per_cell_after, counts_per_cell) + normalize_per_cell( + adata.X, + counts_per_cell_after=counts_per_cell_after, + counts_per_cell=counts_per_cell, + ) layers = adata.layers.keys() if layers == "all" else layers if use_rep == "after": diff --git a/src/scanpy/readwrite.py b/src/scanpy/readwrite.py index 9e0a298e18..b24380fe16 100644 --- a/src/scanpy/readwrite.py +++ b/src/scanpy/readwrite.py @@ -20,7 +20,6 @@ read_mtx, read_text, ) -from legacy_api_wrap import legacy_api from matplotlib.image import imread from . import logging as logg @@ -673,7 +672,7 @@ def write( # ------------------------------------------------------------------------------- -@legacy_api("as_header") +@old_positionals("as_header") def read_params( filename: Path | str, *, as_header: bool = False ) -> dict[str, int | float | bool | str | None]: diff --git a/src/scanpy/tools/_ingest.py b/src/scanpy/tools/_ingest.py index 9ced418888..3698067035 100644 --- a/src/scanpy/tools/_ingest.py +++ b/src/scanpy/tools/_ingest.py @@ -5,7 +5,6 @@ import numpy as np import pandas as pd -from legacy_api_wrap import legacy_api from packaging.version import Version from scipy.sparse import issparse from sklearn.utils import check_random_state @@ -154,7 +153,7 @@ def ingest( ing.map_labels(col, labeling_method[i]) logg.info(" finished", time=start) - return ing.to_adata(inplace) + return ing.to_adata(inplace=inplace) def _rp_forest_generate( @@ -464,7 +463,7 @@ def map_labels(self, labels, method): else: raise NotImplementedError("Ingest supports knn labeling for now.") - @legacy_api("inplace") + @old_positionals("inplace") def to_adata(self, *, inplace: bool = False) -> AnnData | None: """\ Returns `adata_new` with mapped embeddings and labels. From 2e208a34a0affe8e89a0e5c44984b318622914f4 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Mon, 30 Sep 2024 15:53:46 +0200 Subject: [PATCH 24/66] Split up PCA tests (#3268) --- tests/test_pca.py | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/tests/test_pca.py b/tests/test_pca.py index bebb752998..7e49c49cfb 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from contextlib import nullcontext from functools import wraps from typing import TYPE_CHECKING @@ -190,19 +191,27 @@ def test_pca_warnings_sparse(): def test_pca_transform(array_type): - A = array_type(A_list).astype("float32") + adata = AnnData(array_type(A_list).astype("float32")) A_pca_abs = np.abs(A_pca) - A_svd_abs = np.abs(A_svd) - - adata = AnnData(A) - with warnings.catch_warnings(record=True) as record: - sc.pp.pca(adata, n_comps=4, zero_center=True, dtype="float64") - assert len(record) == 0, record + warnings.filterwarnings("error") + sc.pp.pca(adata, n_comps=4, zero_center=True, dtype="float64") assert np.linalg.norm(A_pca_abs[:, :4] - np.abs(adata.obsm["X_pca"])) < 2e-05 - with warnings.catch_warnings(record=True) as record: + +def test_pca_transform_randomized(array_type): + adata = AnnData(array_type(A_list).astype("float32")) + A_pca_abs = np.abs(A_pca) + + warnings.filterwarnings("error") + with ( + pytest.warns( + UserWarning, match="svd_solver 'randomized' does not work with sparse input" + ) + if sparse.issparse(adata.X) + else nullcontext() + ): sc.pp.pca( adata, n_comps=5, @@ -211,21 +220,16 @@ def test_pca_transform(array_type): dtype="float64", random_state=14, ) - if sparse.issparse(A): - assert any( - isinstance(r.message, UserWarning) - and "svd_solver 'randomized' does not work with sparse input" - in str(r.message) - for r in record - ) - else: - assert len(record) == 0 assert np.linalg.norm(A_pca_abs - np.abs(adata.obsm["X_pca"])) < 2e-05 - with warnings.catch_warnings(record=True) as record: - sc.pp.pca(adata, n_comps=4, zero_center=False, dtype="float64", random_state=14) - assert len(record) == 0 + +def test_pca_transform_no_zero_center(array_type): + adata = AnnData(array_type(A_list).astype("float32")) + A_svd_abs = np.abs(A_svd) + + warnings.filterwarnings("error") + sc.pp.pca(adata, n_comps=4, zero_center=False, dtype="float64", random_state=14) assert np.linalg.norm(A_svd_abs[:, :4] - np.abs(adata.obsm["X_pca"])) < 2e-05 From be99b230fa84e077f5167979bc9f6dacc4ad0d41 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 13:23:25 +0200 Subject: [PATCH 25/66] [pre-commit.ci] pre-commit autoupdate (#3270) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- src/scanpy/plotting/_anndata.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a09eb15442..cd0dd7072f 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.7 + rev: v0.6.8 hooks: - id: ruff types_or: [python, pyi, jupyter] diff --git a/src/scanpy/plotting/_anndata.py b/src/scanpy/plotting/_anndata.py index 5dfea0ae29..b72ccfc99d 100755 --- a/src/scanpy/plotting/_anndata.py +++ b/src/scanpy/plotting/_anndata.py @@ -826,7 +826,7 @@ def violin( if groupby is not None: obs_df = get.obs_df(adata, keys=[groupby] + keys, layer=layer, use_raw=use_raw) - if kwds.get("palette", None) is None: + if kwds.get("palette") is None: if not isinstance(adata.obs[groupby].dtype, CategoricalDtype): raise ValueError( f"The column `adata.obs[{groupby!r}]` needs to be categorical, " From 842b68f98e6a3644d64770c254833abbf829395a Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 15 Oct 2024 11:11:32 +0200 Subject: [PATCH 26/66] 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 27/66] =?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 28/66] (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 29/66] 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 30/66] 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 31/66] 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 32/66] 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 33/66] 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 34/66] 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 35/66] 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 36/66] (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 37/66] [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 38/66] (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 39/66] 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 40/66] 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 41/66] 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 42/66] 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 43/66] 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 44/66] 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 45/66] 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 46/66] =?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 47/66] [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 48/66] (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 49/66] 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 50/66] (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 51/66] 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 52/66] 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 53/66] 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 54/66] 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 55/66] 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 56/66] [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 57/66] 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 58/66] 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 59/66] 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 60/66] [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 61/66] 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 62/66] 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 63/66] 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 64/66] (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 65/66] [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 66/66] 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")