77import numba
88import numpy as np
99import pandas as pd
10+ from anndata import AnnData
1011from fast_array_utils .numba import njit
1112from fast_array_utils .stats import mean_var
1213from scipy import sparse
1314
1415from .. import _utils
1516from .. 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
1819from .._settings .presets import DETest
1920from .._utils import (
2021 _numba_thread_limit ,
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
5180def 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
0 commit comments