Skip to content

Commit eea0015

Browse files
ilan-goldpre-commit-ci[bot]flying-sheepCopilotilaykav
authored
feat: add illicofor rank_genes_groups (#4038)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Philipp A. <flying-sheep@web.de> Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Ilay Kavitzky <ilay.kavitzky@gmail.com> Co-authored-by: Cuiwei Gao <48gaocuiwei@gmail.com> Co-authored-by: Jhonatan Felix <108437587+JhonatanFelix@users.noreply.github.com> Co-authored-by: Zach Boldyga <zboldyga@users.noreply.github.com>
1 parent a7868fe commit eea0015

8 files changed

Lines changed: 269 additions & 23 deletions

File tree

docs/release-notes/3653.feat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add {attr}`scanpy.settings.preset` setting with the new preset {attr}`~scanpy.Preset.ScanpyV2Preview` {smaller}`P Angerer`

docs/release-notes/3653.feature.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

docs/release-notes/4038.feat.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Introduce blazing-fast [`illico`][] as a new `wilcoxon` `method` in {func}`~scanpy.tl.rank_genes_groups`. Use it via either `method="wilcoxon_illico"` or via {attr}`~scanpy.Preset.ScanpyV2Preview` (preferred) {smaller}`I Gold`
2+
3+
[`illico`]: https://github.com/remydubois/illico

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ scanorama = [ "scanorama" ]
9898
scrublet = [ "scikit-image>=0.25" ]
9999
# highly_variable_genes method 'seurat_v3'
100100
skmisc = [ "scikit-misc>=0.5.1" ]
101-
scanpy2 = [ "igraph>=0.10.8", "scikit-misc>=0.5.1" ]
101+
illico = [ "illico>=0.6" ]
102+
scanpy2 = [ "igraph>=0.10.8", "scanpy[illico]", "scikit-misc>=0.5.1" ]
102103

103104
[dependency-groups]
104105
dev = [
@@ -108,6 +109,7 @@ dev = [
108109
test = [
109110
"scanpy[dask-ml]",
110111
"scanpy[dask]",
112+
"scanpy[illico]",
111113
"scanpy[leiden]",
112114
"scanpy[plotting]",
113115
"scanpy[scrublet]",

src/scanpy/_settings/presets.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
]
3434

3535

36-
type DETest = Literal["logreg", "t-test", "wilcoxon", "t-test_overestim_var"]
36+
type DETest = Literal[
37+
"logreg", "t-test", "wilcoxon", "wilcoxon_illico", "t-test_overestim_var"
38+
]
3739
type HVGFlavor = Literal["seurat", "cell_ranger", "seurat_v3", "seurat_v3_paper"]
3840
type LeidenFlavor = Literal["leidenalg", "igraph"]
3941

@@ -233,7 +235,11 @@ def draw_graph() -> Mapping[Preset, DrawGraphPreset]:
233235

234236
@preset_property
235237
def rank_genes_groups() -> Mapping[Preset, RankGenesGroupsPreset]:
236-
"""Correlation method for :func:`~scanpy.tl.rank_genes_groups`."""
238+
"""
239+
Correlation method for :func:`~scanpy.tl.rank_genes_groups`.
240+
241+
V2 `method` preset is for `illico` implementation of `wilcoxon` test.
242+
"""
237243
return {
238244
Preset.ScanpyV1: RankGenesGroupsPreset(
239245
method="t-test", mask_var=None, mean_in_log_space=True

src/scanpy/tools/_rank_genes_groups.py

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
import numba
88
import numpy as np
99
import pandas as pd
10+
from anndata import AnnData
1011
from fast_array_utils.numba import njit
1112
from fast_array_utils.stats import mean_var
1213
from scipy import sparse
1314

1415
from .. import _utils
1516
from .. import logging as logg
16-
from .._compat import CSBase
17-
from .._settings import Default
17+
from .._compat import CSBase, warn
18+
from .._settings import Default, Preset
1819
from .._settings.presets import DETest
1920
from .._utils import (
2021
_numba_thread_limit,
@@ -28,7 +29,6 @@
2829
from collections.abc import Generator, Iterable
2930
from typing import Literal
3031

31-
from anndata import AnnData
3232
from numpy.typing import NDArray
3333

3434

@@ -47,6 +47,35 @@ def _select_top_n(scores: NDArray, n_top: int):
4747
return global_indices
4848

4949

50+
def _illico_results_to_iter(
51+
illico_df: pd.DataFrame,
52+
groups_order: NDArray,
53+
ireference: int | None,
54+
*,
55+
copy_pvalues: bool,
56+
) -> Generator[tuple[int, NDArray[np.floating], NDArray[np.floating]]]:
57+
"""Yield per-group ``(index, z, p)`` tuples from illico's long-form output.
58+
59+
illico returns a DataFrame with a 2-level MultiIndex ``(pert, feature)``
60+
and columns including ``z_score`` and ``p_value``. We stream one group
61+
at a time via `pandas.Series.loc`, trusting illico_df groups are ordered
62+
by ``var_name``.
63+
"""
64+
ref_label = None if ireference is None else groups_order[ireference]
65+
z_series = illico_df["z_score"]
66+
p_series = illico_df["p_value"]
67+
illico_groups = set(illico_df.index.unique(level="pert"))
68+
return (
69+
(
70+
group_index,
71+
z_series.loc[group_name].to_numpy(),
72+
p_series.loc[group_name].to_numpy(copy=copy_pvalues),
73+
)
74+
for group_index, group_name in enumerate(groups_order)
75+
if group_name != ref_label and group_name in illico_groups
76+
)
77+
78+
5079
@njit
5180
def rankdata(data: NDArray[np.number]) -> NDArray[np.float64]:
5281
"""Parallelized version of scipy.stats.rankdata."""
@@ -141,6 +170,7 @@ def __init__(
141170
self.expm1_func = lambda x: np.expm1(x * np.log(base))
142171
else:
143172
self.expm1_func = np.expm1
173+
self.group_col = adata.obs[groupby].array
144174

145175
self.groups_order, self.groups_masks_obs = _utils.select_groups(
146176
adata, groups, groupby
@@ -424,6 +454,40 @@ def logreg(
424454
if len(self.groups_order) <= 2:
425455
break
426456

457+
def illico(
458+
self, *, tie_correct: bool, corr_method: _CorrMethod
459+
) -> Generator[tuple[int, NDArray[np.floating], NDArray[np.floating]], None, None]:
460+
from illico import asymptotic_wilcoxon
461+
462+
illico_df = asymptotic_wilcoxon(
463+
AnnData(
464+
X=self.X,
465+
var=pd.DataFrame(index=self.var_names),
466+
obs=pd.DataFrame(
467+
index=pd.RangeIndex(self.X.shape[0]).astype("str"),
468+
data={"group": self.group_col},
469+
),
470+
),
471+
reference=self.groups_order[self.ireference]
472+
if self.ireference is not None
473+
else None,
474+
group_keys="group",
475+
return_as_scanpy=False,
476+
is_log1p=True,
477+
tie_correct=tie_correct,
478+
use_continuity=False,
479+
alternative="two-sided",
480+
use_rust=False,
481+
groups=self.groups_order,
482+
)
483+
return _illico_results_to_iter(
484+
illico_df,
485+
self.groups_order,
486+
self.ireference,
487+
# p-values are altered by this correction method
488+
copy_pvalues=corr_method == "benjamini-hochberg",
489+
)
490+
427491
def compute_statistics( # noqa: PLR0912
428492
self,
429493
method: DETest,
@@ -441,8 +505,12 @@ def compute_statistics( # noqa: PLR0912
441505
if not mean_in_log_space:
442506
# If we are not exponentiating after the mean aggregation, we need to recalculate the stats.
443507
self._basic_stats(exponentiate_values=True)
444-
elif method == "wilcoxon":
445-
generate_test_results = self.wilcoxon(tie_correct=tie_correct)
508+
elif "wilcoxon" in method:
509+
generate_test_results = (
510+
self.illico(tie_correct=tie_correct, corr_method=corr_method)
511+
if "illico" in method
512+
else self.wilcoxon(tie_correct=tie_correct)
513+
)
446514
# If we're not exponentiating after the mean aggregation, then do it now.
447515
self._basic_stats(exponentiate_values=not mean_in_log_space)
448516
elif method == "logreg":
@@ -451,7 +519,6 @@ def compute_statistics( # noqa: PLR0912
451519
self.stats = None
452520

453521
n_genes = self.X.shape[1]
454-
455522
for group_index, scores, pvals in generate_test_results:
456523
group_name = str(self.groups_order[group_index])
457524

@@ -649,8 +716,18 @@ def rank_genes_groups( # noqa: PLR0912, PLR0913, PLR0915
649716
mask_var = settings.preset.rank_genes_groups.mask_var
650717
if isinstance(mean_in_log_space, Default):
651718
mean_in_log_space = settings.preset.rank_genes_groups.mean_in_log_space
719+
# If scanpy presets are used for v2, use illico - prevents the presets from showing the `wilcoxon_illico` method and allows us to silently replace `wilcoxon`'s implementation.
652720
if method is None or isinstance(method, Default):
653721
method = settings.preset.rank_genes_groups.method
722+
if settings.preset is Preset.ScanpyV2Preview:
723+
method = "wilcoxon_illico"
724+
# Otherwise, nudge people to use the presets.
725+
elif "illico" in method:
726+
msg = (
727+
"`wilcoxon_illico` flavor will be removed in scanpy 2.0 and be simply the new `wilcoxon` implementation."
728+
"To remove theis warning, you can locally do `with sc.settings.override(preset=sc.Preset.ScanpyV2Preview)`."
729+
)
730+
warn(msg, DeprecationWarning)
654731

655732
mask_var = _check_mask(adata, mask_var, "var")
656733

src/testing/scanpy/_pytest/marks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def _generate_next_value_(
4141
skimage = "scikit-image"
4242
skmisc = "scikit-misc"
4343
zarr = auto()
44+
illico = auto()
4445
# external
4546
bbknn = auto()
4647
harmony = "harmonyTS"

0 commit comments

Comments
 (0)