diff --git a/src/squidpy/_utils.py b/src/squidpy/_utils.py index 650b9686f..ccddae6fe 100644 --- a/src/squidpy/_utils.py +++ b/src/squidpy/_utils.py @@ -249,6 +249,22 @@ def spawn_generators(seed: int | None, n: int) -> list[np.random.Generator]: return [np.random.default_rng(s) for s in np.random.SeedSequence(seed).spawn(n)] +@contextmanager +def numba_threads(n_jobs: int | None) -> Generator[None, None, None]: + """Temporarily cap numba's thread pool to ``n_jobs``, restoring the previous value on exit. + + ``n_jobs`` is clamped to ``[1, NUMBA_NUM_THREADS]``; ``None`` leaves the current setting untouched. + Use around a ``@njit(parallel=True)`` kernel so ``n_jobs`` controls the number of threads it uses. + """ + previous = numba.get_num_threads() + if n_jobs is not None: + numba.set_num_threads(max(1, min(n_jobs, numba.config.NUMBA_NUM_THREADS))) + try: + yield + finally: + numba.set_num_threads(previous) + + def thread_map( fn: Callable[..., Any], items: Sequence[Any], diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index 1bdc33fa9..250a0f548 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -5,7 +5,6 @@ from abc import ABC from collections import namedtuple from collections.abc import Iterable, Mapping, Sequence -from functools import partial from itertools import product from types import MappingProxyType from typing import TYPE_CHECKING, Any, Literal @@ -13,6 +12,9 @@ import numpy as np import pandas as pd from anndata import AnnData +from numba import njit, prange +from numba.typed import List +from numba_progress import ProgressBar from scanpy import logging as logg from scipy.sparse import csc_matrix from spatialdata import SpatialData @@ -20,14 +22,7 @@ from squidpy._constants._constants import ComplexPolicy, CorrAxis from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs -from squidpy._utils import ( - NDArrayA, - Signal, - SigQueue, - _get_n_cores, - parallelize, - spawn_generators, -) +from squidpy._utils import NDArrayA, _get_n_cores, deprecated_params, numba_threads, spawn_generators from squidpy._validators import assert_positive, check_tuple_needles from squidpy.gr._utils import ( _assert_categorical_obs, @@ -49,102 +44,6 @@ TempResult = namedtuple("TempResult", ["means", "pvalues"]) -_template = """ -from __future__ import annotations - -from numba import njit, prange -import numpy as np - -@njit(parallel={parallel}, cache=False, fastmath=False) -def _test_{n_cls}_{ret_means}_{parallel}( - interactions: NDArrayA[np.uint32], - interaction_clusters: NDArrayA[np.uint32], - data: NDArrayA[np.float64], - clustering: NDArrayA[np.uint32], - mean: NDArrayA[np.float64], - mask: NDArrayA[np.bool_], - res: NDArrayA[np.float64], - {args} -) -> None: - - {init} - {loop} - {finalize} - - for i in prange(len(interactions)): - rec, lig = interactions[i] - for j in prange(len(interaction_clusters)): - c1, c2 = interaction_clusters[j] - m1, m2 = mean[rec, c1], mean[lig, c2] - - if np.isnan(res[i, j]): - continue - - if m1 > 0 and m2 > 0: - {set_means} - if mask[rec, c1] and mask[lig, c2]: - # both rec, lig are sufficiently expressed in c1, c2 - res[i, j] += (groups[c1, rec] + groups[c2, lig]) > (m1 + m2) - else: - res[i, j] = np.nan - else: - # res_means is initialized with 0s - res[i, j] = np.nan -""" - - -def _create_template(n_cls: int, return_means: bool = False, parallel: bool = True) -> str: - if n_cls <= 0: - raise ValueError(f"Expected number of clusters to be positive, found `{n_cls}`.") - - rng = range(n_cls) - init = "".join( - f""" - g{i} = np.zeros((data.shape[1],), dtype=np.float64); s{i} = 0""" - for i in rng - ) - init += """ - error = False - """ - - loop_body = """ - if cl == 0: - g0 += data[row] - s0 += 1""" - loop_body = loop_body + "".join( - f""" - elif cl == {i}: - g{i} += data[row] - s{i} += 1""" - for i in range(1, n_cls) - ) - loop = f""" - for row in prange(data.shape[0]): - cl = clustering[row] - {loop_body} - else: - error = True - """ - finalize = ", ".join(f"g{i} / s{i}" for i in rng) - finalize = f"groups = np.stack(({finalize}))" - - if return_means: - args = "res_means: NDArrayA, # [np.float64]" - set_means = "res_means[i, j] = (m1 + m2) / 2.0" - else: - args = set_means = "" - - return _template.format( - n_cls=n_cls, - parallel=bool(parallel), - ret_means=int(return_means), - args=args, - init=init, - loop=loop, - finalize=finalize, - set_means=set_means, - ) - def _fdr_correct( pvals: pd.DataFrame, @@ -333,14 +232,12 @@ def test( alpha: float = 0.05, copy: bool = False, key_added: str | None = None, - numba_parallel: bool | None = None, - **kwargs: Any, + n_jobs: int | None = None, + show_progress_bar: bool = True, ) -> Mapping[str, pd.DataFrame] | None: """ Perform the permutation test as described in :cite:`cellphonedb`. - %(seed_versionchanged)s - Parameters ---------- %(cluster_key)s @@ -364,8 +261,6 @@ def test( key_added Key in :attr:`anndata.AnnData.uns` where the result is stored if ``copy = False``. If `None`, ``'{{cluster_key}}_ligrec'`` will be used. - %(numba_parallel)s - %(parallelize)s Returns ------- @@ -418,7 +313,7 @@ def test( # much faster than applymap (tested on 1M interactions) interactions_ = np.vectorize(lambda g: gene_mapper[g])(interactions.values) - n_jobs = _get_n_cores(kwargs.pop("n_jobs", None)) + n_jobs = _get_n_cores(n_jobs) start = logg.info( f"Running `{n_perms}` permutations on `{len(interactions)}` interactions " f"and `{len(clusters)}` cluster combinations using `{n_jobs}` core(s)" @@ -431,8 +326,7 @@ def test( n_perms=n_perms, seed=seed, n_jobs=n_jobs, - numba_parallel=numba_parallel, - **kwargs, + show_progress_bar=show_progress_bar, ) index = pd.MultiIndex.from_frame(interactions, names=[SOURCE, TARGET]) columns = pd.MultiIndex.from_tuples(clusters, names=["cluster_1", "cluster_2"]) @@ -463,6 +357,7 @@ def test( return res _save_data(self._adata, attr="uns", key=Key.uns.ligrec(cluster_key, key_added), data=res, time=start) + return None def _trim_data(self) -> None: """Subset genes :attr:`_data` to those present in interactions.""" @@ -639,6 +534,7 @@ def prepare( @d.dedent +@deprecated_params({"numba_parallel": "1.10.0", "backend": "1.10.0"}) def ligrec( adata: AnnData | SpatialData, cluster_key: str, @@ -651,9 +547,16 @@ def ligrec( copy: bool = False, key_added: str | None = None, gene_symbols: str | None = None, - *, + n_perms: int = 1000, + seed: int | None = None, + clusters: Cluster_t | None = None, + alpha: float = 0.05, + n_jobs: int | None = None, + show_progress_bar: bool = True, + interactions_params: Mapping[str, Any] = MappingProxyType({}), + transmitter_params: Mapping[str, Any] = MappingProxyType({"categories": "ligand"}), + receiver_params: Mapping[str, Any] = MappingProxyType({"categories": "receptor"}), table_key: str | None = None, - **kwargs: Any, ) -> Mapping[str, pd.DataFrame] | None: """ %(PT_test.full_desc)s @@ -675,19 +578,85 @@ def ligrec( with _genesymbols(adata, key=gene_symbols, use_raw=use_raw, make_unique=False): return ( # type: ignore[no-any-return] PermutationTest(adata, use_raw=use_raw) - .prepare(interactions, complex_policy=complex_policy, **kwargs) + .prepare( + interactions, + complex_policy=complex_policy, + interactions_params=interactions_params, + transmitter_params=transmitter_params, + receiver_params=receiver_params, + ) .test( cluster_key=cluster_key, + clusters=clusters, + n_perms=n_perms, threshold=threshold, + seed=seed, corr_method=corr_method, corr_axis=corr_axis, + alpha=alpha, copy=copy, key_added=key_added, - **kwargs, + n_jobs=n_jobs, + show_progress_bar=show_progress_bar, ) ) +@njit(parallel=True, cache=True) +def _score_permutations( + data: NDArrayA, + clustering: NDArrayA, + generators: Any, + inv_counts: NDArrayA, + mean_obs: NDArrayA, + interactions: NDArrayA, + interaction_clusters: NDArrayA, + valid: NDArrayA, + progress: Any, +) -> NDArrayA: + """Shuffle and score one permutation per RNG in ``generators``; return accumulated p-value counts.""" + n_perms = len(generators) + n_cells = data.shape[0] + n_genes = data.shape[1] + n_cls = mean_obs.shape[0] + n_inter = interactions.shape[0] + n_cpairs = interaction_clusters.shape[0] + + counts = np.zeros((n_inter, n_cpairs), dtype=np.int64) + for p in prange(n_perms): + perm = clustering.copy() + # explicit int64 index: under prange the loop var is uint64 and indexing the typed list + # would otherwise trigger a (harmless) uint64->int64 NumbaTypeSafetyWarning + generators[np.int64(p)].shuffle(perm) + + groups = np.zeros((n_cls, n_genes), dtype=np.float64) + for cell in range(n_cells): + cl = perm[cell] + for g in range(n_genes): + groups[cl, g] += data[cell, g] + for k in range(n_cls): + inv_c = inv_counts[k] + for g in range(n_genes): + groups[k, g] *= inv_c + + local_counts = np.zeros((n_inter, n_cpairs), dtype=np.int64) + for i in range(n_inter): + r = interactions[i, 0] + l = interactions[i, 1] + for j in range(n_cpairs): + if valid[i, j]: + a = interaction_clusters[j, 0] + b = interaction_clusters[j, 1] + shuf = groups[a, r] + groups[b, l] + obs = mean_obs[a, r] + mean_obs[b, l] + if shuf > obs: + local_counts[i, j] = 1 + counts += local_counts + progress.update(1) + + return counts + + @d.dedent def _analysis( data: pd.DataFrame, @@ -697,14 +666,11 @@ def _analysis( n_perms: int = 1000, seed: int | None = None, n_jobs: int = 1, - numba_parallel: bool | None = None, - **kwargs: Any, + show_progress_bar: bool = True, ) -> TempResult: """ Run the analysis as described in :cite:`cellphonedb`. - This function runs the mean, percent and shuffled analysis. - Parameters ---------- data @@ -718,11 +684,9 @@ def _analysis( %(n_perms)s %(seed)s n_jobs - Number of parallel jobs to launch. - numba_parallel - Whether to use :func:`numba.prange` or not. If `None`, it's determined automatically. - kwargs - Keyword arguments for :func:`squidpy._utils.parallelize`, such as ``n_jobs`` or ``backend``. + Number of numba threads to use. + show_progress_bar + Whether to show the progress bar. Returns ------- @@ -731,149 +695,56 @@ def _analysis( - `'means'` - array of shape `(n_interactions, n_interaction_clusters)` containing the means. - `'pvalues'` - array of shape `(n_interactions, n_interaction_clusters)` containing the p-values. """ - - def extractor(res: Sequence[TempResult]) -> TempResult: - assert len(res) == n_jobs, f"Expected to find `{n_jobs}` results, found `{len(res)}`." - - meanss: list[NDArrayA] = [r.means for r in res if r.means is not None] - assert len(meanss) == 1, f"Only `1` job should've calculated the means, but found `{len(meanss)}`." - means = meanss[0] - if TYPE_CHECKING: - assert isinstance(means, np.ndarray) - - pvalues = np.sum([r.pvalues for r in res if r.pvalues is not None], axis=0) / float(n_perms) - assert means.shape == pvalues.shape, f"Means and p-values differ in shape: `{means.shape}`, `{pvalues.shape}`." - - return TempResult(means=means, pvalues=pvalues) - clustering = np.array(data["clusters"].values, dtype=np.int32) - # densify the data earlier to avoid concatenating sparse arrays - # with multiple fill values: '[0.0, nan]' (which leads to PerformanceWarning) data = data.astype({c: np.float64 for c in data.columns if c != "clusters"}) groups = data.groupby("clusters", observed=True) - mean = groups.mean().values.T # (n_genes, n_clusters) + mean_obs = groups.mean().values # (n_clusters, n_genes) # see https://github.com/scverse/squidpy/pull/991#issuecomment-2888506296 # for why we need to cast to int64 here mask = groups.apply( lambda c: ((c > 0).astype(np.int64).sum() / len(c)) >= threshold - ).values.T # (n_genes, n_clusters) - - # (n_cells, n_genes) - data = np.array(data[data.columns.difference(["clusters"])].values, dtype=np.float64, order="C") - # all 3 should be C contiguous - generators = spawn_generators(seed, n_perms) - return parallelize( # type: ignore[no-any-return] - _analysis_helper, - np.arange(n_perms, dtype=np.int32).tolist(), - n_jobs=n_jobs, - unit="permutation", - extractor=extractor, - **kwargs, - )( - data, - mean, - mask, - interactions, - interaction_clusters=interaction_clusters, - clustering=clustering, - generators=generators, - numba_parallel=numba_parallel, - ) - - -def _analysis_helper( - perms: NDArrayA, - data: NDArrayA, - mean: NDArrayA, - mask: NDArrayA, - interactions: NDArrayA, - interaction_clusters: NDArrayA, - clustering: NDArrayA, - generators: Sequence[np.random.Generator], - numba_parallel: bool | None = None, - queue: SigQueue | None = None, -) -> TempResult: - """ - Run the results of mean, percent and shuffled analysis. - - Parameters - ---------- - perms - Permutation indices. Used to index ``generators`` and to decide which worker returns the means. - data - Array of shape `(n_cells, n_genes)`. - mean - Array of shape `(n_genes, n_clusters)` representing mean expression per cluster. - mask - Array of shape `(n_genes, n_clusters)` containing `True` if the a gene within a cluster is - expressed at least in ``threshold`` percentage of cells. - interactions - Array of shape `(n_interactions, 2)`. - interaction_clusters - Array of shape `(n_interaction_clusters, 2)`. - clustering - Array of shape `(n_cells,)` containing the original clustering. - generators - One independent :class:`numpy.random.Generator` per permutation, indexed by the values in ``perms``. - numba_parallel - Whether to use :func:`numba.prange` or not. If `None`, it's determined automatically. - queue - Signalling queue to update progress bar. - - Returns - ------- - Tuple of the following format: - - - `'means'` - array of shape `(n_interactions, n_interaction_clusters)` containing the true test - statistic. It is `None` if ``min(perms)!=0`` so that only 1 worker calculates it. - - `'pvalues'` - array of shape `(n_interactions, n_interaction_clusters)` containing `np.sum(T0 > T)` - where `T0` is the test statistic under null hypothesis and `T` is the true test statistic. - """ - # used as a read-only base; each permutation shuffles its own copy (see the loop below) - clustering_base = clustering.copy() - n_cls = mean.shape[1] - return_means = np.min(perms) == 0 - - # ideally, these would be both sparse array, but there is no numba impl. (sparse.COO is read-only and very limited) - # keep it f64, because we're setting NaN - res = np.zeros((len(interactions), len(interaction_clusters)), dtype=np.float64) - numba_parallel = ( - (np.prod(res.shape) >= 2**20 or clustering.shape[0] >= 2**15) if numba_parallel is None else numba_parallel # type: ignore[assignment] - ) - - fn_key = f"_test_{n_cls}_{int(return_means)}_{bool(numba_parallel)}" - if fn_key not in globals(): - exec( - compile(_create_template(n_cls, return_means=return_means, parallel=numba_parallel), "", "exec"), # type: ignore[arg-type] - globals(), + ).values # (n_clusters, n_genes) + + counts = groups.size().values.astype(np.float64) + inv_counts = 1.0 / np.maximum(counts, 1) + + data_arr = np.array(data[data.columns.difference(["clusters"])].values, dtype=np.float64, order="C") + + interactions = np.array(interactions, dtype=np.int32) + interaction_clusters = np.array(interaction_clusters, dtype=np.int32) + rec = interactions[:, 0] + lig = interactions[:, 1] + c1 = interaction_clusters[:, 0] + c2 = interaction_clusters[:, 1] + + obs_score = mean_obs[c1, :][:, rec].T + mean_obs[c2, :][:, lig].T + nonzero = (mean_obs[c1, :][:, rec].T > 0) & (mean_obs[c2, :][:, lig].T > 0) + valid = nonzero & mask[c1, :][:, rec].T & mask[c2, :][:, lig].T + res_means = np.where(nonzero, obs_score / 2.0, 0.0) + + # one independent RNG per permutation; a numba typed list so the kernel can index it in prange + generators = List(spawn_generators(seed, n_perms)) + + # the whole permutation loop runs in a single numba call; ``n_jobs`` sets its thread count and + # the kernel updates ``progress`` (a numba_progress proxy) once per permutation + with ( + numba_threads(n_jobs), + ProgressBar(total=n_perms, unit="permutation", disable=not show_progress_bar) as progress, + ): + pval_counts = _score_permutations( + data_arr, + clustering, + generators, + inv_counts, + mean_obs, + interactions, + interaction_clusters, + valid, + progress, ) - _test = globals()[fn_key] - if return_means: - res_means: NDArrayA | None = np.zeros((len(interactions), len(interaction_clusters)), dtype=np.float64) - test = partial(_test, res_means=res_means) - else: - res_means = None - test = _test - - for p in perms: - # shuffle from the same base with a per-permutation generator, so each permutation is - # independent of the others and of how the permutations are split across jobs - rng = generators[p] - clustering = clustering_base.copy() - rng.shuffle(clustering) - error = test(interactions, interaction_clusters, data, clustering, mean, mask, res=res) - if error: - raise ValueError("In the execution of the numba function, an unhandled case was encountered. ") - # This is mainly to avoid a numba warning - # Otherwise, the numba function wouldn't be - # executed in parallel - # See: https://github.com/scverse/squidpy/issues/994 - if queue is not None: - queue.put(Signal.UPDATE) - - if queue is not None: - queue.put(Signal.FINISH) - - return TempResult(means=res_means, pvalues=res) + pvalues = pval_counts.astype(np.float64) / n_perms + pvalues[~valid] = np.nan + + return TempResult(means=res_means, pvalues=pvalues) diff --git a/tests/_data/ligrec_pvalues_reference.h5ad b/tests/_data/ligrec_pvalues_reference.h5ad new file mode 100644 index 000000000..2299b5737 Binary files /dev/null and b/tests/_data/ligrec_pvalues_reference.h5ad differ diff --git a/tests/conftest.py b/tests/conftest.py index 93f32958f..691496ed3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -257,10 +257,17 @@ def complexes(adata: AnnData) -> Sequence[tuple[str, str]]: @pytest.fixture(scope="session") -def ligrec_no_numba() -> Mapping[str, pd.DataFrame]: - with open("tests/_data/ligrec_no_numba.pickle", "rb") as fin: - data = pickle.load(fin) - return {"means": data[0], "pvalues": data[1], "metadata": data[2]} +def ligrec_pvalues_reference() -> Mapping[str, pd.DataFrame]: + # means/pvalues are cluster-pair x gene-pair matrices with a MultiIndex on both + # axes, stored as an AnnData (X=pvalues, layers["means"]=means) with the index + # levels kept as obs/var columns. + adata = ad.read_h5ad("tests/_data/ligrec_pvalues_reference.h5ad") + index = pd.MultiIndex.from_frame(adata.obs[["source", "target"]]) + columns = pd.MultiIndex.from_frame(adata.var[["cluster_1", "cluster_2"]]) + return { + "means": pd.DataFrame(adata.layers["means"], index=index, columns=columns), + "pvalues": pd.DataFrame(adata.X, index=index, columns=columns), + } @pytest.fixture(scope="session") diff --git a/tests/graph/test_ligrec.py b/tests/graph/test_ligrec.py index a72748faa..7accd608c 100644 --- a/tests/graph/test_ligrec.py +++ b/tests/graph/test_ligrec.py @@ -3,7 +3,6 @@ import sys from collections.abc import Mapping, Sequence from itertools import product -from time import time from typing import TYPE_CHECKING import numpy as np @@ -306,7 +305,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ assert not np.allclose(r3["pvalues"], r2["pvalues"]) def test_n_jobs_invariance(self, adata: AnnData, interactions: Interactions_t): - """The number of workers must not change the result (one seed is spawned per permutation).""" + """The number of threads must not change the result (each permutation is seeded independently).""" kw = {"interactions": interactions, "n_perms": 25, "copy": True, "show_progress_bar": False, "seed": 42} res_serial = ligrec(adata, _CK, n_jobs=1, **kw) res_parallel = ligrec(adata, _CK, n_jobs=2, **kw) @@ -314,39 +313,6 @@ def test_n_jobs_invariance(self, adata: AnnData, interactions: Interactions_t): np.testing.assert_allclose(res_serial["means"], res_parallel["means"]) np.testing.assert_allclose(res_serial["pvalues"], res_parallel["pvalues"]) - def test_reproducibility_numba_parallel_off(self, adata: AnnData, interactions: Interactions_t): - t1 = time() - r1 = ligrec( - adata, - _CK, - interactions=interactions, - n_perms=25, - copy=True, - show_progress_bar=False, - seed=42, - numba_parallel=False, - ) - t1 = time() - t1 - - t2 = time() - r2 = ligrec( - adata, - _CK, - interactions=interactions, - n_perms=25, - copy=True, - show_progress_bar=False, - seed=42, - numba_parallel=True, - ) - t2 = time() - t2 - - assert r1 is not r2 - # for such a small data, overhead from parallelization is too high - assert t1 <= t2, (t1, t2) - np.testing.assert_allclose(r1["means"], r2["means"]) - np.testing.assert_allclose(r1["pvalues"], r2["pvalues"]) - def test_paul15_correct_means(self, paul15: AnnData, paul15_means: pd.DataFrame): res = ligrec( paul15, @@ -365,20 +331,22 @@ def test_paul15_correct_means(self, paul15: AnnData, paul15_means: pd.DataFrame) np.testing.assert_array_equal(res["means"].columns, paul15_means.columns) np.testing.assert_allclose(res["means"].values, paul15_means.values) - def test_reproducibility_numba_off( - self, adata: AnnData, interactions: Interactions_t, ligrec_no_numba: Mapping[str, pd.DataFrame] + def test_pvalues_reference( + self, adata: AnnData, interactions: Interactions_t, ligrec_pvalues_reference: Mapping[str, pd.DataFrame] ): r = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=True, show_progress_bar=False, seed=42, n_jobs=1 + adata, _CK, interactions=interactions, n_perms=25, copy=True, show_progress_bar=False, seed=42, n_jobs=1 ) - np.testing.assert_array_equal(r["means"].index, ligrec_no_numba["means"].index) - np.testing.assert_array_equal(r["means"].columns, ligrec_no_numba["means"].columns) - np.testing.assert_array_equal(r["pvalues"].index, ligrec_no_numba["pvalues"].index) - np.testing.assert_array_equal(r["pvalues"].columns, ligrec_no_numba["pvalues"].columns) + np.testing.assert_array_equal(r["means"].index, ligrec_pvalues_reference["means"].index) + np.testing.assert_array_equal(r["means"].columns, ligrec_pvalues_reference["means"].columns) + np.testing.assert_array_equal(r["pvalues"].index, ligrec_pvalues_reference["pvalues"].index) + np.testing.assert_array_equal(r["pvalues"].columns, ligrec_pvalues_reference["pvalues"].columns) - np.testing.assert_allclose(r["means"], ligrec_no_numba["means"]) - np.testing.assert_allclose(r["pvalues"], ligrec_no_numba["pvalues"]) - np.testing.assert_array_equal(np.where(np.isnan(r["pvalues"])), np.where(np.isnan(ligrec_no_numba["pvalues"]))) + np.testing.assert_allclose(r["means"], ligrec_pvalues_reference["means"]) + np.testing.assert_allclose(r["pvalues"], ligrec_pvalues_reference["pvalues"]) + np.testing.assert_array_equal( + np.where(np.isnan(r["pvalues"])), np.where(np.isnan(ligrec_pvalues_reference["pvalues"])) + ) def test_logging(self, adata: AnnData, interactions: Interactions_t, capsys): s.logfile = sys.stderr @@ -421,7 +389,6 @@ def test_non_uniqueness(self, adata: AnnData, interactions: Interactions_t): copy=True, show_progress_bar=False, seed=42, - numba_parallel=False, ) assert len(res["pvalues"]) == len(expected)