scatrans 0.9.8.dev0__tar.gz → 0.9.8.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/CHANGELOG.md +52 -0
  2. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/CITATION.cff +1 -1
  3. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/PKG-INFO +27 -6
  4. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/README.md +26 -5
  5. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/pyproject.toml +1 -1
  6. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/scatrans.egg-info/SOURCES.txt +2 -0
  7. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/__init__.py +1 -1
  8. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_de.py +169 -58
  9. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_permutation.py +51 -7
  10. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_utils.py +79 -3
  11. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_velocity.py +21 -19
  12. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_version.py +2 -2
  13. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/enrich.py +35 -19
  14. scatrans-0.9.8.dev2/src/scatrans/pl.py +2929 -0
  15. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/pp_bias.py +8 -2
  16. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/tl.py +258 -69
  17. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_basic.py +73 -1
  18. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_de_backends.py +24 -2
  19. scatrans-0.9.8.dev2/tests/test_de_edge_cases.py +228 -0
  20. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_enrich_api.py +82 -0
  21. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_pl_extended.py +44 -6
  22. scatrans-0.9.8.dev2/tests/test_regression_bugs.py +180 -0
  23. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_tl_coverage.py +69 -0
  24. scatrans-0.9.8.dev0/src/scatrans/pl.py +0 -3054
  25. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/.github/workflows/ci.yml +0 -0
  26. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/.github/workflows/publish.yml +0 -0
  27. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/.gitignore +0 -0
  28. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/.pre-commit-config.yaml +0 -0
  29. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/LICENSE +0 -0
  30. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/MANIFEST.in +0 -0
  31. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/conftest.py +0 -0
  32. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/conftest_fixtures.py +0 -0
  33. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/examples/memento_de_example.py +0 -0
  34. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/examples/real_data_template.py +0 -0
  35. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/examples/synthetic_active_transcription.py +0 -0
  36. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/setup.cfg +0 -0
  37. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_bias.py +0 -0
  38. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/DATA_LICENSES.md +0 -0
  39. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt +0 -0
  40. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Hs_KEGG_2026.txt +0 -0
  41. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt +0 -0
  42. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Mm_KEGG_2026.txt +0 -0
  43. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Mus_musculus.GRCm39.115_gene_features.parquet +0 -0
  44. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/README.md +0 -0
  45. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/human_GRCh38_2024A_gene_features.parquet +0 -0
  46. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/mouse_2020A_gene_features.parquet +0 -0
  47. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/generate_gene_features.py +0 -0
  48. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/qc.py +0 -0
  49. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/__init__.py +0 -0
  50. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/conftest.py +0 -0
  51. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_enrich_go.py +0 -0
  52. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_pl_coverage.py +0 -0
  53. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_pp_bias_cli.py +0 -0
  54. {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_public_api.py +0 -0
@@ -5,9 +5,61 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.8] - 2026-07-03
9
+
10
+ ### Changed (user-visible default behavior)
11
+ - **`active_score()` built-in `significant` list** now uses the same default thresholds as
12
+ `filter_active_genes(preset="heuristic")`: `logFC > 0.35`, `unspliced_excess_residual > 1.0`,
13
+ `active_score >= 55`, `active_score_fdr < 0.25`, plus `p_adj` / `unspliced_excess_fdr` cutoffs.
14
+ Re-running analyses with defaults may return a **different** significant gene set than 0.9.2
15
+ (typically more genes when signal is present; aligns with the documented post-hoc workflow).
16
+ - **`active_score(logfc_cutoff=...)` default** lowered from `0.5` to `0.35` to match the heuristic
17
+ preset (only affects the built-in significant mask and metadata; ranking in `all_results` unchanged).
18
+ - **`gamma_method="empirical_bayes"` + `prior_weight`**: fixed a numeric floor so `prior_weight` in
19
+ the usual tuning range (0.5–5.0, including the default 5.0) now changes `count_pseudocount` and
20
+ shrinkage weights instead of being pinned at 1.0. EB gamma values may differ from 0.9.2 at the
21
+ same `prior_weight`.
22
+
23
+ ### Fixed
24
+ - **`scat.pl.gseaplot`**: RES curve / hit ticks and the bottom ranked-metric bar now share the same
25
+ gene order by preferring `gsea_result.attrs["ranking"]` over caller-supplied row order.
26
+ - **`scat.pl` robustness**: placeholder figures for empty or incomplete volcano/comet inputs;
27
+ `bias_diagnostic_plot` validates external `axes`; `active_genes_heatmap` default `show=True` no
28
+ longer hits a Python `UnboundLocalError` on `plt`.
29
+ - **PyDESeq2 pseudobulk DE**: log warning + `de_df.attrs` diagnostics when genes are skipped by
30
+ `min_counts_per_gene` or marked NaN by DESeq2 independent filtering (neutral fill is not
31
+ "tested and non-significant").
32
+ - **Memento DE**: log warning + `n_genes_not_returned_by_memento` when memento drops genes
33
+ internally; reindexed neutral rows are documented in diagnostics.
34
+ - **`restore_raw_counts`**: reject `layers["counts"]` restore when `raw_gene_list` order differs
35
+ from current `var_names` (same guard as `adata.raw`).
36
+ - **`active_score` pseudobulk**: `pb_x_layer="X"` now means `adata.X`, matching
37
+ `differential_expression()`.
38
+ - **`_velocity._estimate_eb_prior_from_reference`**: removed unreachable trim fallback branch;
39
+ trim-skipped path labeled `empirical_bayes_median_mad`.
40
+ - **`_utils._get_group_mean`**: removed redundant sparse/dense branches.
41
+
42
+ ### Added
43
+ - Shared `HEURISTIC_FILTER_DEFAULTS` in `tl.py` (single source for heuristic preset + built-in
44
+ significant mask).
45
+ - Tests: gseaplot ranking alignment, `prior_weight` EB sensitivity, significant vs heuristic
46
+ filter parity, PyDESeq2/Memento diagnostic paths, `restore_raw_counts` order guard,
47
+ `active_genes_heatmap` default show, `pb_x_layer="X"` sentinel.
48
+
49
+ ### Documentation
50
+ - README built-in `significant` section updated to match `HEURISTIC_FILTER_DEFAULTS`.
51
+
8
52
  ## [0.9.2 bugfix 2026-07-02]
9
53
 
10
54
  ### Fixed
55
+ - **compare_enrichment / concat_compare_results metadata**: `attrs["clusters"]` now lists only clusters that contributed rows to the combined table (skipped, failed, and empty per-cluster runs remain in `per_cluster` diagnostics only). Fixes ghost cluster names in both the main compare API and the concat wrapper.
56
+ - **DE schema validation**: `_run_de_wrapper` validates that all backends return `logFC`, `p_val`, `p_adj` with at least one finite value per column.
57
+ - **MixedLM robustness**: near-constant genes use variance threshold; non-converged fits and missing condition coefficients are counted as failed fits instead of guessing the second coefficient; `failed_fit_rate` added to result attrs with percentage in warning log.
58
+ - **DE warnings**: selective `_de_warning_context()` suppresses deprecation/future noise only (scanpy/PyDESeq2/MixedLM/BH), no longer blanket `ignore` on all warning categories.
59
+ - **Memento audit column**: native adjusted p-values preserved as `memento_p_adj_native` when returned by memento-de; package `p_adj` still uses BH for cross-backend consistency.
60
+ - **DE edge-case tests**: new `tests/test_de_edge_cases.py` (schema validation, constant-gene MixedLM failure counting, scanpy direction agreement, all-zero gene finiteness).
61
+ - **Diagnostics / docs**: `failed_fit_rate` exposed in `active_score` mixed-model diagnostics and `differential_expression` metadata; README adds a short DE backend decision guide.
62
+ - **copy_input performance**: `active_score` and `differential_expression` now honor `copy_input=False` — combined obs filters (subset + target/reference) perform at most one `AnnData.copy()` when `copy_input=True`, and zero when `copy_input=False` and no obs filtering is required (previously an unconditional `[keep_mask].copy()` always ran).
11
63
  - **filter_active_genes permissive mode**: default/permissive thresholds for `pval_cutoff`, `active_score_fdr_cutoff`, and `unspliced_excess_fdr_cutoff` now use `float("inf")` instead of `1.0`, so genes with adjusted p-value or permutation FDR exactly equal to 1.0 are no longer silently dropped (strict `<` vs `1.0` bug).
12
64
  - **CI lint**: removed unused `import scanpy as sc` from `tl.py` and `_permutation.py` (leftover from permutation refactor); fixed `test_enrich_api.py` formatting.
13
65
  - **CI tests**: `test_pp_bias_cli` GTF generator tests now `pytest.importorskip("gtfparse")` so base installs without `scatrans[gene_features]` skip instead of failing.
@@ -8,7 +8,7 @@ authors:
8
8
  repository-code: "https://github.com/scATrans/scatrans"
9
9
  url: "https://github.com/scATrans/scatrans"
10
10
  license: Apache-2.0
11
- version: 0.9.2
11
+ version: 0.9.8
12
12
  keywords:
13
13
  - single-cell
14
14
  - RNA-seq
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scatrans
3
- Version: 0.9.8.dev0
3
+ Version: 0.9.8.dev2
4
4
  Summary: Single-cell Active Transcription Analysis
5
5
  Author: scATrans Developers
6
6
  License: Apache-2.0
@@ -76,6 +76,18 @@ It also supports conventional differential expression workflows (no velocity dat
76
76
 
77
77
  The simple wrappers (`active_score_simple`, `run_default_pipeline`) keep permutation off by default so new users explore ranked tables first; enable `use_permutation=True` explicitly when you need FDR on unspliced excess.
78
78
 
79
+ ### Choosing a DE backend (decision guide)
80
+
81
+ | Your design | Recommended backend | Caveats |
82
+ |-------------|---------------------|---------|
83
+ | Exploratory / default | scanpy `wilcoxon` or `t-test` on normalized data | Fast; standard pseudoreplication limits |
84
+ | ≥2 biological replicates per group, aggregated counts | `use_pseudobulk=True` + `pydeseq2` | Requires raw counts (`store_raw_counts`); DESeq2 assumptions |
85
+ | Few pseudobulk samples, no DESeq2 | `use_pseudobulk=True` + `pseudobulk_de_backend="scanpy"` | Non-parametric on aggregated profiles |
86
+ | Cell-level data + true sample replicates | `use_mixed_model=True` + `sample_col` | Lightweight LMM (log1p); check `diagnostics["mixed_model"]["failed_fit_rate"]` — not NB-GLMM/voom |
87
+ | Method-of-moments cell-level DE | `use_memento_de=True` | Raw integer counts required; compare `memento_p_adj_native` vs package `p_adj` if auditing |
88
+
89
+ Always run `recommend_workflow(...)` first; inspect `adata.uns["scatrans"]["diagnostics"]` (bias, gamma, permutation `disabled_reason`) before publication claims.
90
+
79
91
  ## Quick Reference + Reporting Checklist (one page)
80
92
 
81
93
  | Step | Function | Key outputs |
@@ -793,16 +805,25 @@ Primary result columns use **unspliced / nascent excess** terminology (not RNA v
793
805
 
794
806
  ### Built-in `significant` gene list
795
807
 
796
- When `use_permutation=True`, the internal mask requires **all** of:
808
+ When `use_permutation=True`, the built-in mask uses the same default thresholds as
809
+ `filter_active_genes(..., preset="heuristic")` (see `HEURISTIC_FILTER_DEFAULTS` in
810
+ `tl.py`). Under default parameters it requires **all** of:
797
811
 
798
- - `logFC > logfc_cutoff` (default 0.5)
812
+ - `logFC > logfc_cutoff` (default **0.35**)
799
813
  - `p_adj < pval_cutoff` (default 0.05)
800
- - `unspliced_excess_residual > 0`
814
+ - `unspliced_excess_residual > 1.0` (default residual magnitude cutoff)
815
+ - `active_score >= 55.0`
816
+ - `active_score_fdr < 0.25` (when permutation computed composite-score FDR)
801
817
  - `unspliced_excess_fdr < unspliced_excess_fdr_cutoff` (default 0.05)
802
818
 
803
- Without `use_permutation=True`, the built-in `significant` list is **empty** (FDR on unspliced excess cannot be computed). Use `all_results` + `filter_active_genes` for custom thresholds.
819
+ Without `use_permutation=True`, the built-in `significant` list is **empty** (FDR on
820
+ unspliced excess cannot be computed). Use `all_results` + `filter_active_genes` for
821
+ custom thresholds.
804
822
 
805
- On real data the built-in list often returns zero or few genes. Use the full table in `all_results`, sorted by `active_score` descending.
823
+ On low-signal data the built-in list may still be small. Use the full table in
824
+ `all_results`, sorted by `active_score` descending. If you need different cutoffs,
825
+ pass explicit arguments to `filter_active_genes` rather than assuming the built-in
826
+ list matches a custom `logfc_cutoff` override on `active_score()`.
806
827
 
807
828
  After each run inspect the diagnostics:
808
829
 
@@ -25,6 +25,18 @@ It also supports conventional differential expression workflows (no velocity dat
25
25
 
26
26
  The simple wrappers (`active_score_simple`, `run_default_pipeline`) keep permutation off by default so new users explore ranked tables first; enable `use_permutation=True` explicitly when you need FDR on unspliced excess.
27
27
 
28
+ ### Choosing a DE backend (decision guide)
29
+
30
+ | Your design | Recommended backend | Caveats |
31
+ |-------------|---------------------|---------|
32
+ | Exploratory / default | scanpy `wilcoxon` or `t-test` on normalized data | Fast; standard pseudoreplication limits |
33
+ | ≥2 biological replicates per group, aggregated counts | `use_pseudobulk=True` + `pydeseq2` | Requires raw counts (`store_raw_counts`); DESeq2 assumptions |
34
+ | Few pseudobulk samples, no DESeq2 | `use_pseudobulk=True` + `pseudobulk_de_backend="scanpy"` | Non-parametric on aggregated profiles |
35
+ | Cell-level data + true sample replicates | `use_mixed_model=True` + `sample_col` | Lightweight LMM (log1p); check `diagnostics["mixed_model"]["failed_fit_rate"]` — not NB-GLMM/voom |
36
+ | Method-of-moments cell-level DE | `use_memento_de=True` | Raw integer counts required; compare `memento_p_adj_native` vs package `p_adj` if auditing |
37
+
38
+ Always run `recommend_workflow(...)` first; inspect `adata.uns["scatrans"]["diagnostics"]` (bias, gamma, permutation `disabled_reason`) before publication claims.
39
+
28
40
  ## Quick Reference + Reporting Checklist (one page)
29
41
 
30
42
  | Step | Function | Key outputs |
@@ -742,16 +754,25 @@ Primary result columns use **unspliced / nascent excess** terminology (not RNA v
742
754
 
743
755
  ### Built-in `significant` gene list
744
756
 
745
- When `use_permutation=True`, the internal mask requires **all** of:
757
+ When `use_permutation=True`, the built-in mask uses the same default thresholds as
758
+ `filter_active_genes(..., preset="heuristic")` (see `HEURISTIC_FILTER_DEFAULTS` in
759
+ `tl.py`). Under default parameters it requires **all** of:
746
760
 
747
- - `logFC > logfc_cutoff` (default 0.5)
761
+ - `logFC > logfc_cutoff` (default **0.35**)
748
762
  - `p_adj < pval_cutoff` (default 0.05)
749
- - `unspliced_excess_residual > 0`
763
+ - `unspliced_excess_residual > 1.0` (default residual magnitude cutoff)
764
+ - `active_score >= 55.0`
765
+ - `active_score_fdr < 0.25` (when permutation computed composite-score FDR)
750
766
  - `unspliced_excess_fdr < unspliced_excess_fdr_cutoff` (default 0.05)
751
767
 
752
- Without `use_permutation=True`, the built-in `significant` list is **empty** (FDR on unspliced excess cannot be computed). Use `all_results` + `filter_active_genes` for custom thresholds.
768
+ Without `use_permutation=True`, the built-in `significant` list is **empty** (FDR on
769
+ unspliced excess cannot be computed). Use `all_results` + `filter_active_genes` for
770
+ custom thresholds.
753
771
 
754
- On real data the built-in list often returns zero or few genes. Use the full table in `all_results`, sorted by `active_score` descending.
772
+ On low-signal data the built-in list may still be small. Use the full table in
773
+ `all_results`, sorted by `active_score` descending. If you need different cutoffs,
774
+ pass explicit arguments to `filter_active_genes` rather than assuming the built-in
775
+ list matches a custom `logfc_cutoff` override on `active_score()`.
755
776
 
756
777
  After each run inspect the diagnostics:
757
778
 
@@ -71,7 +71,7 @@ scatrans = [
71
71
 
72
72
  [tool.setuptools_scm]
73
73
  write_to = "src/scatrans/_version.py"
74
- fallback_version = "0.9.2"
74
+ fallback_version = "0.9.8"
75
75
  version_scheme = "guess-next-dev"
76
76
  local_scheme = "no-local-version"
77
77
 
@@ -41,10 +41,12 @@ tests/__init__.py
41
41
  tests/conftest.py
42
42
  tests/test_basic.py
43
43
  tests/test_de_backends.py
44
+ tests/test_de_edge_cases.py
44
45
  tests/test_enrich_api.py
45
46
  tests/test_enrich_go.py
46
47
  tests/test_pl_coverage.py
47
48
  tests/test_pl_extended.py
48
49
  tests/test_pp_bias_cli.py
49
50
  tests/test_public_api.py
51
+ tests/test_regression_bugs.py
50
52
  tests/test_tl_coverage.py
@@ -87,7 +87,7 @@ __all__ = [
87
87
  try:
88
88
  from ._version import version as __version__
89
89
  except ImportError:
90
- __version__ = "0.9.2"
90
+ __version__ = "0.9.8"
91
91
 
92
92
  # Optional: prevent some internal modules from appearing too prominently
93
93
  # in casual inspection while still allowing advanced users to do
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import logging
11
11
  import warnings
12
+ from contextlib import contextmanager
12
13
  from importlib.metadata import version
13
14
  from typing import Any
14
15
 
@@ -29,6 +30,44 @@ from ._utils import (
29
30
  )
30
31
 
31
32
  logger = logging.getLogger(__name__)
33
+ logger.addHandler(logging.NullHandler())
34
+
35
+ _DE_REQUIRED_COLS = frozenset({"logFC", "p_val", "p_adj"})
36
+
37
+ # Suppress noisy deprecation/future warnings from scanpy/anndata/PyDESeq2 during DE,
38
+ # but keep convergence/runtime/user warnings visible to callers.
39
+ _DE_SUPPRESS_WARNING_CATEGORIES = (
40
+ DeprecationWarning,
41
+ FutureWarning,
42
+ PendingDeprecationWarning,
43
+ )
44
+
45
+
46
+ @contextmanager
47
+ def _de_warning_context():
48
+ with warnings.catch_warnings():
49
+ for cat in _DE_SUPPRESS_WARNING_CATEGORIES:
50
+ warnings.simplefilter("ignore", category=cat)
51
+ yield
52
+
53
+
54
+ def _validate_de_result(de_df: pd.DataFrame, *, backend: str) -> pd.DataFrame:
55
+ """Assert all DE backends return the minimal schema expected downstream."""
56
+ missing = _DE_REQUIRED_COLS - set(de_df.columns)
57
+ if missing:
58
+ raise RuntimeError(
59
+ f"DE backend '{backend}' returned incomplete results; "
60
+ f"missing columns: {sorted(missing)}"
61
+ )
62
+ if len(de_df) == 0:
63
+ return de_df
64
+ for col in _DE_REQUIRED_COLS:
65
+ vals = pd.to_numeric(de_df[col], errors="coerce")
66
+ if not np.isfinite(vals.to_numpy()).any():
67
+ raise RuntimeError(
68
+ f"DE backend '{backend}' returned no finite values in column {col!r}."
69
+ )
70
+ return de_df
32
71
 
33
72
 
34
73
  def _pydeseq2_uses_design_factors() -> bool:
@@ -94,15 +133,18 @@ def _run_de_wrapper(
94
133
  if use_mixed_model:
95
134
  if sample_col is None:
96
135
  raise ValueError("sample_col must be provided when use_mixed_model=True")
97
- return _run_mixedlm_de(
98
- adata,
99
- groupby=groupby,
100
- target_group=target_group,
101
- reference_group=reference_group,
102
- sample_col=sample_col,
103
- n_jobs=n_jobs,
104
- labels=labels,
105
- mixed_model_pval=mixed_model_pval,
136
+ return _validate_de_result(
137
+ _run_mixedlm_de(
138
+ adata,
139
+ groupby=groupby,
140
+ target_group=target_group,
141
+ reference_group=reference_group,
142
+ sample_col=sample_col,
143
+ n_jobs=n_jobs,
144
+ labels=labels,
145
+ mixed_model_pval=mixed_model_pval,
146
+ ),
147
+ backend="mixedlm",
106
148
  )
107
149
 
108
150
  if use_memento_de:
@@ -111,16 +153,19 @@ def _run_de_wrapper(
111
153
  "use_memento_de=True is not supported with use_pseudobulk=True "
112
154
  "(Memento is a cell-level method-of-moments estimator; use PyDESeq2 for pseudobulk)."
113
155
  )
114
- return _run_memento_de(
115
- adata,
116
- groupby=groupby,
117
- target_group=target_group,
118
- reference_group=reference_group,
119
- labels=labels,
120
- capture_rate=memento_capture_rate,
121
- num_boot=memento_num_boot,
122
- n_cpus=memento_n_cpus,
123
- counts=counts,
156
+ return _validate_de_result(
157
+ _run_memento_de(
158
+ adata,
159
+ groupby=groupby,
160
+ target_group=target_group,
161
+ reference_group=reference_group,
162
+ labels=labels,
163
+ capture_rate=memento_capture_rate,
164
+ num_boot=memento_num_boot,
165
+ n_cpus=memento_n_cpus,
166
+ counts=counts,
167
+ ),
168
+ backend="memento",
124
169
  )
125
170
 
126
171
  ad_temp = adata.copy() if labels is not None else adata
@@ -220,8 +265,7 @@ def _run_de_wrapper(
220
265
  index=counts_use.index,
221
266
  )
222
267
 
223
- with warnings.catch_warnings():
224
- warnings.simplefilter("ignore")
268
+ with _de_warning_context():
225
269
  if _pydeseq2_uses_design_factors():
226
270
  dds = DeseqDataSet(
227
271
  counts=counts_use,
@@ -257,18 +301,48 @@ def _run_de_wrapper(
257
301
  )
258
302
  stat_res.summary()
259
303
 
260
- res2 = stat_res.results_df.copy().reindex(ad_temp.var_names)
304
+ res_df = stat_res.results_df.copy()
305
+ n_genes_filtered_low_count = int((~gene_keep).sum())
306
+ n_genes_nan_from_deseq2 = (
307
+ int(res_df["padj"].isna().sum()) if "padj" in res_df.columns else 0
308
+ )
309
+ res2 = res_df.reindex(ad_temp.var_names)
261
310
  de_df = pd.DataFrame(index=ad_temp.var_names)
262
- de_df["logFC"] = res2["log2FoldChange"].fillna(0.0)
263
- de_df["p_val"] = res2.get("pvalue", pd.Series(1.0, index=res2.index)).fillna(1.0)
264
- de_df["p_adj"] = res2.get("padj", pd.Series(1.0, index=res2.index)).fillna(1.0)
311
+ de_df["logFC"] = res2["log2FoldChange"].reindex(ad_temp.var_names)
312
+ de_df["p_val"] = res2.get("pvalue", pd.Series(np.nan, index=res2.index)).reindex(
313
+ ad_temp.var_names
314
+ )
315
+ de_df["p_adj"] = res2.get("padj", pd.Series(np.nan, index=res2.index)).reindex(
316
+ ad_temp.var_names
317
+ )
318
+ _validate_de_result(de_df, backend="pydeseq2")
319
+ de_df["logFC"] = de_df["logFC"].fillna(0.0)
320
+ de_df["p_val"] = de_df["p_val"].fillna(1.0)
321
+ de_df["p_adj"] = de_df["p_adj"].fillna(1.0)
322
+ n_total = len(de_df)
323
+ de_df.attrs["n_genes_filtered_low_count"] = n_genes_filtered_low_count
324
+ de_df.attrs["n_genes_nan_from_deseq2"] = n_genes_nan_from_deseq2
325
+ de_df.attrs["pydeseq2_neutral_fill"] = True
326
+ if n_total > 0 and (n_genes_filtered_low_count > 0 or n_genes_nan_from_deseq2 > 0):
327
+ logger.warning(
328
+ "PyDESeq2: %d/%d genes (%.1f%%) skipped by min_counts_per_gene filter; "
329
+ "%d/%d genes (%.1f%%) had NaN padj from DESeq2 independent filtering/outliers. "
330
+ "These genes appear as neutral values (logFC=0, p_adj=1) and are NOT "
331
+ "'tested and non-significant'. "
332
+ "See de_df.attrs['n_genes_filtered_low_count'] and ['n_genes_nan_from_deseq2'].",
333
+ n_genes_filtered_low_count,
334
+ n_total,
335
+ 100.0 * n_genes_filtered_low_count / n_total,
336
+ n_genes_nan_from_deseq2,
337
+ n_total,
338
+ 100.0 * n_genes_nan_from_deseq2 / n_total,
339
+ )
265
340
  return de_df
266
341
 
267
342
  else:
268
343
  # Standard scanpy path (works for both regular and pseudobulk when not using pydeseq2)
269
344
  rank_key = "_scatrans_rank_genes_groups"
270
- with warnings.catch_warnings():
271
- warnings.simplefilter("ignore")
345
+ with _de_warning_context():
272
346
  sc.tl.rank_genes_groups(
273
347
  ad_temp,
274
348
  groupby=use_groupby,
@@ -281,13 +355,16 @@ def _run_de_wrapper(
281
355
  "names"
282
356
  )
283
357
  de_df = pd.DataFrame(index=ad_temp.var_names)
284
- raw_lfc = de_raw["logfoldchanges"].reindex(ad_temp.var_names).fillna(0.0)
285
358
  # scanpy rank_genes_groups always returns logfoldchanges on log2 scale:
286
359
  # log2( (expm1(mean_t) + eps) / (expm1(mean_r) + eps) ), independent of the
287
360
  # statistical method (wilcoxon / t-test / etc.). No secondary conversion needed.
288
- de_df["logFC"] = raw_lfc
289
- de_df["p_val"] = de_raw["pvals"].reindex(ad_temp.var_names).fillna(1.0)
290
- de_df["p_adj"] = de_raw["pvals_adj"].reindex(ad_temp.var_names).fillna(1.0)
361
+ de_df["logFC"] = de_raw["logfoldchanges"].reindex(ad_temp.var_names)
362
+ de_df["p_val"] = de_raw["pvals"].reindex(ad_temp.var_names)
363
+ de_df["p_adj"] = de_raw["pvals_adj"].reindex(ad_temp.var_names)
364
+ _validate_de_result(de_df, backend=f"scanpy:{de_method}")
365
+ de_df["logFC"] = de_df["logFC"].fillna(0.0)
366
+ de_df["p_val"] = de_df["p_val"].fillna(1.0)
367
+ de_df["p_adj"] = de_df["p_adj"].fillna(1.0)
291
368
  return de_df
292
369
 
293
370
 
@@ -354,13 +431,12 @@ def _run_mixedlm_de(
354
431
  # The explicit flag avoids counting truly neutral (logFC~0, p~1, dvar~0) biological genes as "failed".
355
432
  def _fit_gene_mixed(idx: int):
356
433
  y = expr_mat[:, idx].astype(float)
357
- # guard against all-zero / constant (mixedlm will be singular)
358
- if np.allclose(y, y[0]):
434
+ # guard against near-constant expression (mixedlm will be singular)
435
+ if float(np.nanvar(y)) < 1e-12:
359
436
  return idx, 0.0, 1.0, 1.0, 0.0, True
360
437
  df = pd.DataFrame({"y": y, "condition": condition, "sample": samples})
361
438
  try:
362
- with warnings.catch_warnings():
363
- warnings.simplefilter("ignore")
439
+ with _de_warning_context():
364
440
  # Full model
365
441
  md_full = smf.mixedlm("y ~ C(condition)", df, groups=df["sample"])
366
442
  m_full = md_full.fit(reml=False, maxiter=200, disp=False)
@@ -369,23 +445,18 @@ def _run_mixedlm_de(
369
445
  md_null = smf.mixedlm("y ~ 1", df, groups=df["sample"])
370
446
  m_null = md_null.fit(reml=False, maxiter=200, disp=False)
371
447
 
448
+ if not getattr(m_full, "converged", True) or not getattr(m_null, "converged", True):
449
+ return idx, 0.0, 1.0, 1.0, 0.0, True
450
+
372
451
  # LRT statistic and p (chi2 df=1 for the added fixed effect term(s))
373
452
  lrt_stat = -2.0 * (m_null.llf - m_full.llf)
374
453
  lrt_p = float(chi2.sf(max(lrt_stat, 0.0), 1))
375
454
 
376
- # Extract condition coef (target vs ref)
377
- # The param name is typically "C(condition)[T.<target>]" or similar
378
- coef_name = None
379
- for pname in m_full.params.index:
380
- if "condition" in str(pname) and target_group in str(pname):
381
- coef_name = pname
382
- break
455
+ # Extract condition coef (target vs ref) — exact statsmodels name only
456
+ expected_coef = f"C(condition)[T.{target_group}]"
457
+ coef_name = expected_coef if expected_coef in m_full.params.index else None
383
458
  if coef_name is None:
384
- # fallback: take the second coef if intercept + one more
385
- if len(m_full.params) >= 2:
386
- coef_name = m_full.params.index[1]
387
- else:
388
- coef_name = m_full.params.index[0]
459
+ return idx, 0.0, 1.0, 1.0, 0.0, True
389
460
  logfc = float(m_full.params.get(coef_name, 0.0))
390
461
  p_wald = float(m_full.pvalues.get(coef_name, 1.0))
391
462
 
@@ -441,8 +512,7 @@ def _run_mixedlm_de(
441
512
  logger.warning("mixed_model_pval must be 'wald' or 'lrt'; falling back to 'wald'.")
442
513
  main_pvals = p_walds
443
514
 
444
- with warnings.catch_warnings():
445
- warnings.simplefilter("ignore")
515
+ with _de_warning_context():
446
516
  p_adjs = multipletests(main_pvals, method="fdr_bh")[1]
447
517
 
448
518
  de_df = pd.DataFrame(index=var_names)
@@ -456,14 +526,19 @@ def _run_mixedlm_de(
456
526
  # Use explicit failure flag returned from workers (avoids counting true biological
457
527
  # neutral genes (logFC~0, p~1, dvar~0) as "failed fits").
458
528
  n_failed = int(np.sum(failed_flags))
529
+ n_total = len(de_df)
530
+ failed_rate = (n_failed / n_total) if n_total else 0.0
459
531
  de_df.attrs["n_genes_failed_fit"] = n_failed
532
+ de_df.attrs["failed_fit_rate"] = failed_rate
460
533
  if n_failed > 0:
461
534
  logger.warning(
462
- "MixedLM: %d/%d genes had degenerate fits (e.g. small n per group, collinearity) "
535
+ "MixedLM: %d/%d genes (%.1f%%) had degenerate or non-convergent fits "
536
+ "(near-constant expression, singular Hessian, missing condition coefficient, etc.) "
463
537
  "and received neutral values (logFC=0, p_val=1, delta_variance=0). "
464
538
  "See diagnostics['mixed_model']['n_genes_failed_fit'].",
465
539
  n_failed,
466
- len(de_df),
540
+ n_total,
541
+ 100.0 * failed_rate,
467
542
  )
468
543
  return de_df
469
544
 
@@ -535,6 +610,15 @@ def _run_memento_de(
535
610
  else:
536
611
  raise ValueError(f"counts='{counts}' layer not found in adata.layers")
537
612
  elif isinstance(counts, ad.AnnData):
613
+ if not np.array_equal(counts.obs_names, ad_temp.obs_names):
614
+ missing = ad_temp.obs_names.difference(counts.obs_names)
615
+ if len(missing):
616
+ raise ValueError(
617
+ f"counts AnnData is missing {len(missing)} cell(s) required by the "
618
+ f"comparison subset (first missing: {missing[0]!r}). "
619
+ "Pass counts aligned to adata.obs_names or use a matrix layer."
620
+ )
621
+ counts = counts[ad_temp.obs_names]
538
622
  raw_counts = counts.X
539
623
  if counts.var_names.tolist() != ad_temp.var_names.tolist():
540
624
  common = counts.var_names.intersection(ad_temp.var_names)
@@ -637,13 +721,40 @@ def _run_memento_de(
637
721
  pval_raw = result["de_pval"]
638
722
  else:
639
723
  pval_raw = pd.Series(1.0, index=res_index)
640
- pvals = pd.to_numeric(pval_raw, errors="coerce").reindex(res_index).fillna(1.0)
641
- de_df["p_val"] = pvals
642
-
643
- # BH adjustment (Memento may return raw p; we make p_adj consistent with other backends)
644
- with warnings.catch_warnings():
645
- warnings.simplefilter("ignore")
646
- de_df["p_adj"] = multipletests(pvals.values, method="fdr_bh")[1]
724
+ pvals_for_correction = pd.to_numeric(pval_raw, errors="coerce").reindex(res_index)
725
+ valid_pval_mask = pvals_for_correction.notna()
726
+ de_df["p_val"] = pvals_for_correction.fillna(1.0)
727
+
728
+ # Preserve Memento-native adjusted p if present (audit trail); package p_adj uses BH for consistency.
729
+ for native_col in ("de_padj", "de_padjs", "padj", "p_adj", "de_fdr"):
730
+ if isinstance(result, pd.DataFrame) and native_col in result.columns:
731
+ de_df["memento_p_adj_native"] = pd.to_numeric(
732
+ result[native_col], errors="coerce"
733
+ ).reindex(res_index)
734
+ break
735
+
736
+ de_df["p_adj"] = 1.0
737
+ if valid_pval_mask.sum() > 0:
738
+ with _de_warning_context():
739
+ de_df.loc[valid_pval_mask, "p_adj"] = multipletests(
740
+ pvals_for_correction[valid_pval_mask].values, method="fdr_bh"
741
+ )[1]
742
+
743
+ # Memento typically drops low-coverage genes entirely (not NaN rows). Genes absent
744
+ # from the memento result are reindexed below with neutral fill — same UX issue as
745
+ # PyDESeq2 filtered genes, but via row omission rather than NaN padj.
746
+ n_genes_not_returned = int(len(adata.var_names.difference(res_index)))
747
+ de_df.attrs["n_genes_not_returned_by_memento"] = n_genes_not_returned
748
+ de_df.attrs["n_genes_missing_pval"] = int((~valid_pval_mask).sum())
749
+ if n_genes_not_returned > 0:
750
+ logger.warning(
751
+ "Memento: %d/%d genes were not returned by binary_test_1d (e.g. internal "
752
+ "min_perc_group filtering) and will appear as neutral values (logFC=0, p_adj=1) "
753
+ "after reindexing. These were not tested. "
754
+ "See de_df.attrs['n_genes_not_returned_by_memento'].",
755
+ n_genes_not_returned,
756
+ len(adata.var_names),
757
+ )
647
758
 
648
759
  # Expose Memento's native columns for users who want mean + variability signals
649
760
  for src, dst in [
@@ -26,6 +26,46 @@ from ._utils import (
26
26
  from ._velocity import _compute_velocity_delta
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
+ logger.addHandler(logging.NullHandler())
30
+
31
+
32
+ def _compute_perm_velocity_delta(
33
+ *,
34
+ velocity_source: str,
35
+ uns_layer: Any,
36
+ spl_layer: Any,
37
+ t_mask: np.ndarray,
38
+ r_mask: np.ndarray,
39
+ prior_weight: float,
40
+ gamma_method: str,
41
+ eb_prior: dict[str, Any] | None,
42
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]:
43
+ """Velocity delta for one permutation replicate.
44
+
45
+ For ``scvelo_moments*`` sources the layers are pre-smoothed Mu/Ms from the main
46
+ analysis (label permutation keeps smoothed values fixed). For heuristic sources
47
+ they are library-size-normalized unspliced/spliced layers.
48
+ """
49
+ if velocity_source.startswith("scvelo_moments"):
50
+ # Same groupwise ratio estimator as _compute_moments_velocity_delta (on Mu/Ms).
51
+ return _compute_velocity_delta(
52
+ uns_layer,
53
+ spl_layer,
54
+ t_mask,
55
+ r_mask,
56
+ prior_weight,
57
+ gamma_method=gamma_method,
58
+ eb_prior=eb_prior,
59
+ )
60
+ return _compute_velocity_delta(
61
+ uns_layer,
62
+ spl_layer,
63
+ t_mask,
64
+ r_mask,
65
+ prior_weight,
66
+ gamma_method=gamma_method,
67
+ eb_prior=eb_prior,
68
+ )
29
69
 
30
70
 
31
71
  def _single_permutation_task(
@@ -60,6 +100,7 @@ def _single_permutation_task(
60
100
  memento_capture_rate: float = 0.07,
61
101
  memento_num_boot: int = 5000,
62
102
  memento_n_cpus: int = -1,
103
+ velocity_source: str = "heuristic_global_ratio",
63
104
  ) -> tuple[np.ndarray, np.ndarray]:
64
105
  """One permutation replicate.
65
106
 
@@ -108,12 +149,13 @@ def _single_permutation_task(
108
149
 
109
150
  t_mask = shuffled_labels == target_group
110
151
  r_mask = shuffled_labels == reference_group
111
- delta_velocity, _, _gamma_ref, _ = _compute_velocity_delta(
112
- uns_layer,
113
- spl_layer,
114
- t_mask,
115
- r_mask,
116
- prior_weight,
152
+ delta_velocity, _, _gamma_ref, _ = _compute_perm_velocity_delta(
153
+ velocity_source=velocity_source,
154
+ uns_layer=uns_layer,
155
+ spl_layer=spl_layer,
156
+ t_mask=t_mask,
157
+ r_mask=r_mask,
158
+ prior_weight=prior_weight,
117
159
  gamma_method=gamma_method,
118
160
  eb_prior=eb_prior,
119
161
  )
@@ -177,6 +219,7 @@ def run_permutation_test(
177
219
  real_score: np.ndarray,
178
220
  real_residual: np.ndarray,
179
221
  eb_prior: dict[str, Any] | None = None,
222
+ velocity_source: str = "heuristic_global_ratio",
180
223
  bias_correction: str = "huber_length_intron",
181
224
  # Memento forwarding for advanced consistent permutation
182
225
  use_memento_de: bool = False,
@@ -224,6 +267,7 @@ def run_permutation_test(
224
267
  de_preprocess,
225
268
  strict_pydeseq2_counts,
226
269
  eb_prior,
270
+ velocity_source=velocity_source,
227
271
  bias_correction=bias_correction,
228
272
  use_memento_de=use_memento_de,
229
273
  memento_capture_rate=memento_capture_rate,
@@ -287,7 +331,7 @@ def run_permutation_test(
287
331
  # - For very small permutation spaces (n_perm < 100, common in pseudobulk with few samples),
288
332
  # we mark use_fdr=False and provide reason so callers can avoid using FDR for significance
289
333
  # (p-values become coarse; BH adjustment less reliable). This eliminates vestigial logic in tl.py.
290
- use_fdr = n_perm >= 100
334
+ use_fdr = n_success >= 100
291
335
  disabled_reason = None if use_fdr else "small_permutation_space"
292
336
 
293
337
  return (