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.
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/CHANGELOG.md +52 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/CITATION.cff +1 -1
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/PKG-INFO +27 -6
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/README.md +26 -5
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/pyproject.toml +1 -1
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/scatrans.egg-info/SOURCES.txt +2 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/__init__.py +1 -1
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_de.py +169 -58
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_permutation.py +51 -7
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_utils.py +79 -3
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_velocity.py +21 -19
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_version.py +2 -2
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/enrich.py +35 -19
- scatrans-0.9.8.dev2/src/scatrans/pl.py +2929 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/pp_bias.py +8 -2
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/tl.py +258 -69
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_basic.py +73 -1
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_de_backends.py +24 -2
- scatrans-0.9.8.dev2/tests/test_de_edge_cases.py +228 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_enrich_api.py +82 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_pl_extended.py +44 -6
- scatrans-0.9.8.dev2/tests/test_regression_bugs.py +180 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_tl_coverage.py +69 -0
- scatrans-0.9.8.dev0/src/scatrans/pl.py +0 -3054
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/.github/workflows/ci.yml +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/.github/workflows/publish.yml +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/.gitignore +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/.pre-commit-config.yaml +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/LICENSE +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/MANIFEST.in +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/conftest.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/conftest_fixtures.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/examples/memento_de_example.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/examples/real_data_template.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/examples/synthetic_active_transcription.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/setup.cfg +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/_bias.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/DATA_LICENSES.md +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Hs_KEGG_2026.txt +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Mm_KEGG_2026.txt +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/Mus_musculus.GRCm39.115_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/README.md +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/human_GRCh38_2024A_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/data/mouse_2020A_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/generate_gene_features.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/src/scatrans/qc.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/__init__.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/conftest.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_enrich_go.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_pl_coverage.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev2}/tests/test_pp_bias_cli.py +0 -0
- {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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scatrans
|
|
3
|
-
Version: 0.9.8.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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"].
|
|
263
|
-
de_df["p_val"] = res2.get("pvalue", pd.Series(
|
|
264
|
-
|
|
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
|
|
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"] =
|
|
289
|
-
de_df["p_val"] = de_raw["pvals"].reindex(ad_temp.var_names)
|
|
290
|
-
de_df["p_adj"] = de_raw["pvals_adj"].reindex(ad_temp.var_names)
|
|
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
|
|
358
|
-
if np.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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, _ =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 =
|
|
334
|
+
use_fdr = n_success >= 100
|
|
291
335
|
disabled_reason = None if use_fdr else "small_permutation_space"
|
|
292
336
|
|
|
293
337
|
return (
|