scatrans 0.9.8.dev1__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.dev1 → scatrans-0.9.8.dev2}/CHANGELOG.md +44 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/CITATION.cff +1 -1
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/PKG-INFO +15 -6
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/README.md +14 -5
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/pyproject.toml +1 -1
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/__init__.py +1 -1
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/_de.py +52 -12
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/_utils.py +0 -2
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/_velocity.py +16 -18
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/_version.py +2 -2
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/pl.py +8 -2
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/tl.py +116 -35
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_basic.py +2 -1
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_de_backends.py +17 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_de_edge_cases.py +12 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_pl_extended.py +44 -6
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_tl_coverage.py +48 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/.github/workflows/ci.yml +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/.github/workflows/publish.yml +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/.gitignore +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/.pre-commit-config.yaml +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/LICENSE +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/MANIFEST.in +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/conftest.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/conftest_fixtures.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/examples/memento_de_example.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/examples/real_data_template.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/examples/synthetic_active_transcription.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/scatrans.egg-info/SOURCES.txt +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/setup.cfg +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/_bias.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/_permutation.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/DATA_LICENSES.md +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/Hs_KEGG_2026.txt +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/Mm_KEGG_2026.txt +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/Mus_musculus.GRCm39.115_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/README.md +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/human_GRCh38_2024A_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/mouse_2020A_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/enrich.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/generate_gene_features.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/pp_bias.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/qc.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/__init__.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/conftest.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_enrich_api.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_enrich_go.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_pl_coverage.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_pp_bias_cli.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_public_api.py +0 -0
- {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/tests/test_regression_bugs.py +0 -0
|
@@ -5,6 +5,50 @@ 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
|
|
@@ -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
|
|
@@ -805,16 +805,25 @@ Primary result columns use **unspliced / nascent excess** terminology (not RNA v
|
|
|
805
805
|
|
|
806
806
|
### Built-in `significant` gene list
|
|
807
807
|
|
|
808
|
-
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:
|
|
809
811
|
|
|
810
|
-
- `logFC > logfc_cutoff` (default 0.
|
|
812
|
+
- `logFC > logfc_cutoff` (default **0.35**)
|
|
811
813
|
- `p_adj < pval_cutoff` (default 0.05)
|
|
812
|
-
- `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)
|
|
813
817
|
- `unspliced_excess_fdr < unspliced_excess_fdr_cutoff` (default 0.05)
|
|
814
818
|
|
|
815
|
-
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.
|
|
816
822
|
|
|
817
|
-
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()`.
|
|
818
827
|
|
|
819
828
|
After each run inspect the diagnostics:
|
|
820
829
|
|
|
@@ -754,16 +754,25 @@ Primary result columns use **unspliced / nascent excess** terminology (not RNA v
|
|
|
754
754
|
|
|
755
755
|
### Built-in `significant` gene list
|
|
756
756
|
|
|
757
|
-
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:
|
|
758
760
|
|
|
759
|
-
- `logFC > logfc_cutoff` (default 0.
|
|
761
|
+
- `logFC > logfc_cutoff` (default **0.35**)
|
|
760
762
|
- `p_adj < pval_cutoff` (default 0.05)
|
|
761
|
-
- `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)
|
|
762
766
|
- `unspliced_excess_fdr < unspliced_excess_fdr_cutoff` (default 0.05)
|
|
763
767
|
|
|
764
|
-
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.
|
|
765
771
|
|
|
766
|
-
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()`.
|
|
767
776
|
|
|
768
777
|
After each run inspect the diagnostics:
|
|
769
778
|
|
|
@@ -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
|
|
@@ -301,7 +301,12 @@ def _run_de_wrapper(
|
|
|
301
301
|
)
|
|
302
302
|
stat_res.summary()
|
|
303
303
|
|
|
304
|
-
|
|
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)
|
|
305
310
|
de_df = pd.DataFrame(index=ad_temp.var_names)
|
|
306
311
|
de_df["logFC"] = res2["log2FoldChange"].reindex(ad_temp.var_names)
|
|
307
312
|
de_df["p_val"] = res2.get("pvalue", pd.Series(np.nan, index=res2.index)).reindex(
|
|
@@ -314,6 +319,24 @@ def _run_de_wrapper(
|
|
|
314
319
|
de_df["logFC"] = de_df["logFC"].fillna(0.0)
|
|
315
320
|
de_df["p_val"] = de_df["p_val"].fillna(1.0)
|
|
316
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
|
+
)
|
|
317
340
|
return de_df
|
|
318
341
|
|
|
319
342
|
else:
|
|
@@ -429,13 +452,9 @@ def _run_mixedlm_de(
|
|
|
429
452
|
lrt_stat = -2.0 * (m_null.llf - m_full.llf)
|
|
430
453
|
lrt_p = float(chi2.sf(max(lrt_stat, 0.0), 1))
|
|
431
454
|
|
|
432
|
-
# Extract condition coef (target vs ref)
|
|
433
|
-
|
|
434
|
-
coef_name = None
|
|
435
|
-
for pname in m_full.params.index:
|
|
436
|
-
if "condition" in str(pname) and target_group in str(pname):
|
|
437
|
-
coef_name = pname
|
|
438
|
-
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
|
|
439
458
|
if coef_name is None:
|
|
440
459
|
return idx, 0.0, 1.0, 1.0, 0.0, True
|
|
441
460
|
logfc = float(m_full.params.get(coef_name, 0.0))
|
|
@@ -702,8 +721,9 @@ def _run_memento_de(
|
|
|
702
721
|
pval_raw = result["de_pval"]
|
|
703
722
|
else:
|
|
704
723
|
pval_raw = pd.Series(1.0, index=res_index)
|
|
705
|
-
|
|
706
|
-
|
|
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)
|
|
707
727
|
|
|
708
728
|
# Preserve Memento-native adjusted p if present (audit trail); package p_adj uses BH for consistency.
|
|
709
729
|
for native_col in ("de_padj", "de_padjs", "padj", "p_adj", "de_fdr"):
|
|
@@ -713,8 +733,28 @@ def _run_memento_de(
|
|
|
713
733
|
).reindex(res_index)
|
|
714
734
|
break
|
|
715
735
|
|
|
716
|
-
|
|
717
|
-
|
|
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
|
+
)
|
|
718
758
|
|
|
719
759
|
# Expose Memento's native columns for users who want mean + variability signals
|
|
720
760
|
for src, dst in [
|
|
@@ -537,8 +537,6 @@ def _get_group_mean(matrix: Any, mask: np.ndarray) -> np.ndarray:
|
|
|
537
537
|
if np.sum(mask) == 0:
|
|
538
538
|
raise ValueError("Cannot compute group mean for an empty group.")
|
|
539
539
|
sub = matrix[mask]
|
|
540
|
-
if sparse.issparse(sub):
|
|
541
|
-
return np.asarray(sub.mean(axis=0)).ravel()
|
|
542
540
|
return np.asarray(sub.mean(axis=0)).ravel()
|
|
543
541
|
|
|
544
542
|
|
|
@@ -76,25 +76,23 @@ def _estimate_eb_prior_from_reference(
|
|
|
76
76
|
else:
|
|
77
77
|
lo, hi = np.quantile(r_valid, [trim_fraction, 1.0 - trim_fraction])
|
|
78
78
|
trim_mask = (r_valid >= lo) & (r_valid <= hi)
|
|
79
|
-
|
|
80
|
-
if
|
|
81
|
-
|
|
82
|
-
# global-ratio estimator used for the "not enough genes" case. This makes
|
|
83
|
-
# fallback_triggered semantically consistent (we used the non-robust path).
|
|
84
|
-
fallback_triggered = True
|
|
85
|
-
global_ratio = (float(np.sum(U_r)) + eps) / (float(np.sum(S_r)) + eps)
|
|
86
|
-
prior_mean_log = float(np.log(global_ratio))
|
|
87
|
-
tau = max(0.25, _robust_mad_scale(r_valid) if r_valid.size else 0.5)
|
|
88
|
-
method_detail = "empirical_bayes_fallback_global_ratio"
|
|
89
|
-
n_genes_used = int(r_valid.size)
|
|
90
|
-
else:
|
|
79
|
+
n_trimmed = int(trim_mask.sum())
|
|
80
|
+
if n_trimmed >= min_genes_for_prior:
|
|
81
|
+
r_trim = r_valid[trim_mask]
|
|
91
82
|
method_detail = "empirical_bayes_trimmed_median_mad"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
83
|
+
else:
|
|
84
|
+
# Quantile trim would leave too few genes; use all valid ratios instead.
|
|
85
|
+
r_trim = r_valid
|
|
86
|
+
method_detail = "empirical_bayes_median_mad"
|
|
87
|
+
prior_mean_log = float(np.median(r_trim))
|
|
88
|
+
tau = _robust_mad_scale(r_trim)
|
|
89
|
+
n_genes_used = int(r_trim.size)
|
|
90
|
+
|
|
91
|
+
# prior_weight scales observation-precision pseudocount (not direct shrinkage like
|
|
92
|
+
# heuristic_shrink). Use a low floor so the default tunable range (0.5–5.0) is not
|
|
93
|
+
# flattened by the legacy count_pseudocount=1.0 ceiling.
|
|
94
|
+
_count_pseudo_floor = 0.05
|
|
95
|
+
count_pseudo = max(_count_pseudo_floor, prior_weight * 0.2) * float(count_pseudocount)
|
|
98
96
|
|
|
99
97
|
eb_prior = {
|
|
100
98
|
"prior_mean_log": prior_mean_log,
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.9.8.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 9, 8, '
|
|
21
|
+
__version__ = version = '0.9.8.dev2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 9, 8, 'dev2')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -1903,6 +1903,14 @@ def gseaplot(
|
|
|
1903
1903
|
fig, ax = _empty_placeholder_fig("No ranked genes")
|
|
1904
1904
|
return fig, ax
|
|
1905
1905
|
|
|
1906
|
+
# Prefer gseapy's post-sort ranking so RES/hits and the bottom bar share one axis.
|
|
1907
|
+
if gsea_result is not None:
|
|
1908
|
+
ranking_attr = getattr(gsea_result, "attrs", {}).get("ranking")
|
|
1909
|
+
if ranking_attr is not None:
|
|
1910
|
+
ranked = pd.Series(ranking_attr, dtype=float)
|
|
1911
|
+
ranked.index = ranked.index.astype(str)
|
|
1912
|
+
ranked = ranked.dropna()
|
|
1913
|
+
|
|
1906
1914
|
# Try to get precomputed data from gsea_result.attrs
|
|
1907
1915
|
RES = None
|
|
1908
1916
|
hits = None
|
|
@@ -2647,8 +2655,6 @@ def active_genes_heatmap(
|
|
|
2647
2655
|
**kwargs,
|
|
2648
2656
|
)
|
|
2649
2657
|
if save_path:
|
|
2650
|
-
import matplotlib.pyplot as plt
|
|
2651
|
-
|
|
2652
2658
|
plt.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
2653
2659
|
logger.info("Heatmap saved → %s", save_path)
|
|
2654
2660
|
if show:
|
|
@@ -56,11 +56,26 @@ try:
|
|
|
56
56
|
|
|
57
57
|
VERSION = _version.version
|
|
58
58
|
except (ImportError, AttributeError):
|
|
59
|
-
VERSION = "0.9.
|
|
59
|
+
VERSION = "0.9.8"
|
|
60
60
|
|
|
61
61
|
logger = logging.getLogger(__name__)
|
|
62
62
|
logger.addHandler(logging.NullHandler())
|
|
63
63
|
|
|
64
|
+
# Shared defaults for filter_active_genes(preset="heuristic") and the built-in
|
|
65
|
+
# active_score() significant list. Keep in sync — both entry points should agree
|
|
66
|
+
# under default parameters.
|
|
67
|
+
HEURISTIC_FILTER_DEFAULTS: dict[str, float | None] = {
|
|
68
|
+
"active_score_cutoff": 55.0,
|
|
69
|
+
"pval_cutoff": 0.05,
|
|
70
|
+
"unspliced_excess_residual_cutoff": 1.0,
|
|
71
|
+
"logfc_cutoff": 0.35,
|
|
72
|
+
"active_score_fdr_cutoff": 0.25,
|
|
73
|
+
"unspliced_excess_fdr_cutoff": 0.05,
|
|
74
|
+
"effective_gamma_min": 0.05,
|
|
75
|
+
"effective_gamma_max": 1.0,
|
|
76
|
+
"delta_variance_min": None,
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
|
|
65
80
|
def _materialize_if_view(adata: ad.AnnData) -> ad.AnnData:
|
|
66
81
|
"""Return a writable AnnData when ``adata`` is an obs/var view (needed before in-place ``.obs`` writes)."""
|
|
@@ -248,7 +263,7 @@ def active_score(
|
|
|
248
263
|
weight_unspliced: float = 1.0,
|
|
249
264
|
weight_pval: float = 1.0,
|
|
250
265
|
pval_cutoff: float = 0.05,
|
|
251
|
-
logfc_cutoff: float =
|
|
266
|
+
logfc_cutoff: float = HEURISTIC_FILTER_DEFAULTS["logfc_cutoff"], # type: ignore[arg-type]
|
|
252
267
|
unspliced_excess_fdr_cutoff: float = 0.05,
|
|
253
268
|
de_method: str = "t-test_overestim_var", # freely switchable basic option, e.g. "wilcoxon"
|
|
254
269
|
pseudobulk_de_backend: str = "pydeseq2", # "pydeseq2" or "scanpy" when use_pseudobulk=True
|
|
@@ -304,7 +319,9 @@ def active_score(
|
|
|
304
319
|
# Gamma estimation method for reference group unspliced/spliced ratio.
|
|
305
320
|
# "heuristic_shrink": classic global-ratio + prior_weight shrinkage (default, prior_weight=5.0)
|
|
306
321
|
# "robust_median": use median ratio from reference for better stability with small reference groups
|
|
307
|
-
# "empirical_bayes": robust log-ratio empirical Bayes shrinkage (recommended for small reference)
|
|
322
|
+
# "empirical_bayes": robust log-ratio empirical Bayes shrinkage (recommended for small reference).
|
|
323
|
+
# Note: prior_weight affects empirical_bayes via count_pseudocount (observation precision), so its
|
|
324
|
+
# impact is gentler than on heuristic_shrink/robust_median (direct additive shrinkage).
|
|
308
325
|
# "raw": minimal shrinkage (use observed ratios directly)
|
|
309
326
|
gamma_method: str = "heuristic_shrink",
|
|
310
327
|
ranking_mode: str = "composite",
|
|
@@ -365,8 +382,10 @@ def active_score(
|
|
|
365
382
|
|
|
366
383
|
Diagnostics (including global unspliced fraction and bias fit details) are stored
|
|
367
384
|
under adata.uns["scatrans"]["diagnostics"]. The full ranked table (all_results)
|
|
368
|
-
is the main output; the built-in significant list
|
|
369
|
-
|
|
385
|
+
is the main output; the built-in significant list uses the same default thresholds
|
|
386
|
+
as :func:`filter_active_genes` with ``preset="heuristic"`` (logFC > 0.35,
|
|
387
|
+
unspliced_excess_residual > 1.0, active_score >= 55, etc.) and may still be
|
|
388
|
+
small on low-signal data.
|
|
370
389
|
|
|
371
390
|
A separate function diagnose_design is available to summarize the experimental
|
|
372
391
|
design and surface relevant warnings before analysis.
|
|
@@ -679,7 +698,7 @@ def active_score(
|
|
|
679
698
|
sample_col,
|
|
680
699
|
groupby,
|
|
681
700
|
layers=["spliced", "unspliced"],
|
|
682
|
-
x_layer=pb_x_layer,
|
|
701
|
+
x_layer=pb_x_layer if pb_x_layer != "X" else None,
|
|
683
702
|
use_total_for_x=pb_use_total_for_x,
|
|
684
703
|
min_cells=min_cells,
|
|
685
704
|
min_counts=min_counts,
|
|
@@ -1004,6 +1023,24 @@ def active_score(
|
|
|
1004
1023
|
if _gk in gamma_info:
|
|
1005
1024
|
velocity_diag[_gk] = gamma_info[_gk]
|
|
1006
1025
|
|
|
1026
|
+
pydeseq2_diag = {}
|
|
1027
|
+
if is_pseudobulk and pseudobulk_de_backend == "pydeseq2" and hasattr(de_df, "attrs"):
|
|
1028
|
+
pydeseq2_diag = {
|
|
1029
|
+
"used": True,
|
|
1030
|
+
"n_genes_filtered_low_count": int(de_df.attrs.get("n_genes_filtered_low_count", 0)),
|
|
1031
|
+
"n_genes_nan_from_deseq2": int(de_df.attrs.get("n_genes_nan_from_deseq2", 0)),
|
|
1032
|
+
"neutral_fill": bool(de_df.attrs.get("pydeseq2_neutral_fill", True)),
|
|
1033
|
+
"note": (
|
|
1034
|
+
"Genes filtered by min_counts or marked NaN by DESeq2 independent filtering "
|
|
1035
|
+
"appear as logFC=0, p_adj=1 and are not 'tested and non-significant'."
|
|
1036
|
+
),
|
|
1037
|
+
}
|
|
1038
|
+
n_memento_not_returned = (
|
|
1039
|
+
int(de_df.attrs.get("n_genes_not_returned_by_memento", 0))
|
|
1040
|
+
if (use_memento_de and hasattr(de_df, "attrs"))
|
|
1041
|
+
else 0
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1007
1044
|
diagnostics: dict[str, Any] = {
|
|
1008
1045
|
"n_cells": int(adata.n_obs),
|
|
1009
1046
|
"n_genes_input": int(adata.n_vars),
|
|
@@ -1032,6 +1069,17 @@ def active_score(
|
|
|
1032
1069
|
else None
|
|
1033
1070
|
),
|
|
1034
1071
|
},
|
|
1072
|
+
"pydeseq2": pydeseq2_diag or {"used": False},
|
|
1073
|
+
"memento": {
|
|
1074
|
+
"used": bool(use_memento_de),
|
|
1075
|
+
"n_genes_not_returned": n_memento_not_returned,
|
|
1076
|
+
"note": (
|
|
1077
|
+
"Genes dropped by memento internal filters appear as logFC=0, p_adj=1 "
|
|
1078
|
+
"after reindexing and were not tested."
|
|
1079
|
+
if use_memento_de
|
|
1080
|
+
else None
|
|
1081
|
+
),
|
|
1082
|
+
},
|
|
1035
1083
|
}
|
|
1036
1084
|
if mode == "advanced" and moments_info:
|
|
1037
1085
|
diagnostics["velocity"]["moments"] = {
|
|
@@ -1283,14 +1331,25 @@ def _finalize_active_score_results(
|
|
|
1283
1331
|
ue_fdr_cutoff = extra_metadata.get("unspliced_excess_fdr_cutoff", 0.05)
|
|
1284
1332
|
|
|
1285
1333
|
if use_permutation and UNSPLICED_EXCESS_FDR_COL in adata.var.columns:
|
|
1334
|
+
sig_pval = extra_metadata.get("pval_cutoff", HEURISTIC_FILTER_DEFAULTS["pval_cutoff"])
|
|
1335
|
+
sig_logfc = extra_metadata.get("logfc_cutoff", HEURISTIC_FILTER_DEFAULTS["logfc_cutoff"])
|
|
1336
|
+
sig_resid = HEURISTIC_FILTER_DEFAULTS["unspliced_excess_residual_cutoff"]
|
|
1337
|
+
sig_active = HEURISTIC_FILTER_DEFAULTS["active_score_cutoff"]
|
|
1338
|
+
sig_active_fdr = HEURISTIC_FILTER_DEFAULTS["active_score_fdr_cutoff"]
|
|
1286
1339
|
mask = (
|
|
1287
|
-
(adata.var["p_adj"] <
|
|
1288
|
-
& (adata.var["logFC"] >
|
|
1289
|
-
& (adata.var[residual_col] >
|
|
1340
|
+
(adata.var["p_adj"] < sig_pval)
|
|
1341
|
+
& (adata.var["logFC"] > sig_logfc)
|
|
1342
|
+
& (adata.var[residual_col] > sig_resid)
|
|
1343
|
+
& (adata.var["active_score"] >= sig_active)
|
|
1290
1344
|
& (adata.var["valid_expr"])
|
|
1291
1345
|
)
|
|
1292
1346
|
if extra_metadata.get("use_fdr_for_significance", True):
|
|
1293
1347
|
mask = mask & (adata.var[UNSPLICED_EXCESS_FDR_COL] < ue_fdr_cutoff)
|
|
1348
|
+
if "active_score_fdr" in adata.var.columns and sig_active_fdr is not None:
|
|
1349
|
+
mask = mask & (
|
|
1350
|
+
adata.var["active_score_fdr"].notna()
|
|
1351
|
+
& (adata.var["active_score_fdr"] < sig_active_fdr)
|
|
1352
|
+
)
|
|
1294
1353
|
else:
|
|
1295
1354
|
logger.warning(
|
|
1296
1355
|
"Permutation space is very small; unspliced_excess_fdr was not applied "
|
|
@@ -1367,15 +1426,16 @@ def _finalize_active_score_results(
|
|
|
1367
1426
|
"use_fdr_for_significance": extra_metadata.get("use_fdr_for_significance"),
|
|
1368
1427
|
"perm_disabled_reason": extra_metadata.get("perm_disabled_reason"),
|
|
1369
1428
|
"significant_criteria": {
|
|
1370
|
-
"logFC": f"> {extra_metadata.get('logfc_cutoff')}",
|
|
1371
|
-
"p_adj": f"< {extra_metadata.get('pval_cutoff')}",
|
|
1372
|
-
"unspliced_excess_residual": ">
|
|
1429
|
+
"logFC": f"> {extra_metadata.get('logfc_cutoff', HEURISTIC_FILTER_DEFAULTS['logfc_cutoff'])}",
|
|
1430
|
+
"p_adj": f"< {extra_metadata.get('pval_cutoff', HEURISTIC_FILTER_DEFAULTS['pval_cutoff'])}",
|
|
1431
|
+
"unspliced_excess_residual": f"> {HEURISTIC_FILTER_DEFAULTS['unspliced_excess_residual_cutoff']}",
|
|
1432
|
+
"active_score": f">= {HEURISTIC_FILTER_DEFAULTS['active_score_cutoff']}",
|
|
1433
|
+
"active_score_fdr": f"< {HEURISTIC_FILTER_DEFAULTS['active_score_fdr_cutoff']}",
|
|
1373
1434
|
"unspliced_excess_fdr": (
|
|
1374
1435
|
f"< {ue_fdr_cutoff} (requires use_permutation=True)"
|
|
1375
1436
|
if use_permutation
|
|
1376
1437
|
else "not evaluated (use_permutation=False)"
|
|
1377
1438
|
),
|
|
1378
|
-
"active_score": "ranking only (not used for significance)",
|
|
1379
1439
|
},
|
|
1380
1440
|
"use_delta_variance_pval": extra_metadata.get("use_delta_variance_pval"),
|
|
1381
1441
|
"delta_var_pval_cutoff": extra_metadata.get("delta_var_pval_cutoff"),
|
|
@@ -1529,8 +1589,8 @@ def differential_expression(
|
|
|
1529
1589
|
Returns
|
|
1530
1590
|
-------
|
|
1531
1591
|
(adata_with_results, results_df)
|
|
1532
|
-
- results_df is
|
|
1533
|
-
at minimum: logFC, p_val, p_adj, and (when use_memento_de) the
|
|
1592
|
+
- results_df is sorted by p_adj (ascending; most significant first) and
|
|
1593
|
+
contains at minimum: logFC, p_val, p_adj, and (when use_memento_de) the
|
|
1534
1594
|
native memento_de_* / memento_dv_* columns.
|
|
1535
1595
|
- adata.var is updated with the same columns for convenience.
|
|
1536
1596
|
- Metadata is stored under adata.uns["scatrans"].
|
|
@@ -1731,6 +1791,22 @@ def differential_expression(
|
|
|
1731
1791
|
if (use_mixed_model and hasattr(de_df, "attrs"))
|
|
1732
1792
|
else 0.0
|
|
1733
1793
|
)
|
|
1794
|
+
pydeseq2_diag = {}
|
|
1795
|
+
if use_pseudobulk and pseudobulk_de_backend == "pydeseq2" and hasattr(de_df, "attrs"):
|
|
1796
|
+
pydeseq2_diag = {
|
|
1797
|
+
"n_genes_filtered_low_count": int(de_df.attrs.get("n_genes_filtered_low_count", 0)),
|
|
1798
|
+
"n_genes_nan_from_deseq2": int(de_df.attrs.get("n_genes_nan_from_deseq2", 0)),
|
|
1799
|
+
"neutral_fill": bool(de_df.attrs.get("pydeseq2_neutral_fill", True)),
|
|
1800
|
+
"note": (
|
|
1801
|
+
"Genes filtered by min_counts or marked NaN by DESeq2 independent filtering "
|
|
1802
|
+
"appear as logFC=0, p_adj=1 and are not 'tested and non-significant'."
|
|
1803
|
+
),
|
|
1804
|
+
}
|
|
1805
|
+
n_memento_not_returned = (
|
|
1806
|
+
int(de_df.attrs.get("n_genes_not_returned_by_memento", 0))
|
|
1807
|
+
if (use_memento_de and hasattr(de_df, "attrs"))
|
|
1808
|
+
else 0
|
|
1809
|
+
)
|
|
1734
1810
|
|
|
1735
1811
|
# Build clean results table (no velocity columns)
|
|
1736
1812
|
cols = ["logFC", "p_val", "p_adj"]
|
|
@@ -1807,6 +1883,8 @@ def differential_expression(
|
|
|
1807
1883
|
),
|
|
1808
1884
|
"memento_num_boot": memento_num_boot if use_memento_de else None,
|
|
1809
1885
|
"memento_n_cpus": memento_n_cpus if use_memento_de else None,
|
|
1886
|
+
"n_genes_not_returned_by_memento": n_memento_not_returned,
|
|
1887
|
+
"pydeseq2": pydeseq2_diag or None,
|
|
1810
1888
|
"n_jobs": n_jobs,
|
|
1811
1889
|
"gene_type_filter": gene_type_filter,
|
|
1812
1890
|
}
|
|
@@ -2018,16 +2096,25 @@ def restore_raw_counts(adata: Any, layer: str = "counts", inplace: bool = False)
|
|
|
2018
2096
|
"Use the object before gene subsetting, or call store_raw_counts() again on the current object."
|
|
2019
2097
|
)
|
|
2020
2098
|
|
|
2021
|
-
#
|
|
2022
|
-
if (
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2099
|
+
# Guard against same-dimension but permuted gene order (layers and adata.raw).
|
|
2100
|
+
if source == "adata.raw" and hasattr(adata.raw, "var_names"):
|
|
2101
|
+
if not np.array_equal(adata.raw.var_names, adata.var_names):
|
|
2102
|
+
raise ValueError(
|
|
2103
|
+
"adata.raw has the same number of genes as current adata, but gene names/order differ. "
|
|
2104
|
+
"Cannot restore into .X without explicit gene reindexing."
|
|
2105
|
+
)
|
|
2106
|
+
elif source.startswith("layers"):
|
|
2107
|
+
raw_gene_list = adata.uns.get("scatrans", {}).get("raw_gene_list")
|
|
2108
|
+
if (
|
|
2109
|
+
raw_gene_list is not None
|
|
2110
|
+
and len(raw_gene_list) == adata.n_vars
|
|
2111
|
+
and not np.array_equal(np.asarray(raw_gene_list), adata.var_names.to_numpy())
|
|
2112
|
+
):
|
|
2113
|
+
raise ValueError(
|
|
2114
|
+
f"Stored counts in {source} match n_vars but stored raw_gene_list order differs "
|
|
2115
|
+
"from current adata.var_names. Cannot restore into .X without explicit gene "
|
|
2116
|
+
"reindexing. Re-run store_raw_counts() on the current object."
|
|
2117
|
+
)
|
|
2031
2118
|
|
|
2032
2119
|
target = adata if inplace else adata.copy()
|
|
2033
2120
|
target.X = raw
|
|
@@ -2169,16 +2256,10 @@ def filter_active_genes(
|
|
|
2169
2256
|
p = preset.lower()
|
|
2170
2257
|
if p in ("heuristic", "single_cell", "default"):
|
|
2171
2258
|
preset_vals = {
|
|
2172
|
-
|
|
2173
|
-
"
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
"logfc_cutoff": 0.35,
|
|
2177
|
-
"active_score_fdr_cutoff": 0.25,
|
|
2178
|
-
"unspliced_excess_fdr_cutoff": 0.05,
|
|
2179
|
-
"effective_gamma_min": 0.05,
|
|
2180
|
-
"effective_gamma_max": 1.0,
|
|
2181
|
-
"delta_variance_min": None,
|
|
2259
|
+
**HEURISTIC_FILTER_DEFAULTS,
|
|
2260
|
+
"velocity_residual_cutoff": HEURISTIC_FILTER_DEFAULTS[
|
|
2261
|
+
"unspliced_excess_residual_cutoff"
|
|
2262
|
+
],
|
|
2182
2263
|
}
|
|
2183
2264
|
elif p in ("pseudobulk", "bulk"):
|
|
2184
2265
|
preset_vals = {
|
|
@@ -728,7 +728,8 @@ def test_significant_requires_permutation_fdr(adata_basic):
|
|
|
728
728
|
)
|
|
729
729
|
assert "unspliced_excess_fdr" in allr_perm.columns
|
|
730
730
|
if len(sig_perm) > 0:
|
|
731
|
-
assert (sig_perm["unspliced_excess_residual"] > 0).all()
|
|
731
|
+
assert (sig_perm["unspliced_excess_residual"] > 1.0).all()
|
|
732
|
+
assert (sig_perm["active_score"] >= 55.0).all()
|
|
732
733
|
assert (sig_perm["unspliced_excess_fdr"] < 0.05).all()
|
|
733
734
|
|
|
734
735
|
|
|
@@ -76,6 +76,23 @@ def test_differential_expression_mixed_model(adata_mixed_small):
|
|
|
76
76
|
assert "delta_variance" in res.columns
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
@pytest.mark.slow
|
|
80
|
+
def test_active_score_pb_x_layer_sentinel(adata_pb):
|
|
81
|
+
"""pb_x_layer='X' must mean adata.X, matching differential_expression()."""
|
|
82
|
+
_, _, _ = scat.active_score(
|
|
83
|
+
adata_pb,
|
|
84
|
+
groupby="condition",
|
|
85
|
+
target_group="Disease",
|
|
86
|
+
reference_group="Control",
|
|
87
|
+
use_pseudobulk=True,
|
|
88
|
+
sample_col="sample",
|
|
89
|
+
pb_use_total_for_x=False,
|
|
90
|
+
pb_x_layer="X",
|
|
91
|
+
use_permutation=False,
|
|
92
|
+
show_plot=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
79
96
|
@pytest.mark.skipif(
|
|
80
97
|
importlib.util.find_spec("pydeseq2") is None,
|
|
81
98
|
reason="pydeseq2 not installed",
|
|
@@ -5,6 +5,7 @@ import numpy as np
|
|
|
5
5
|
import pandas as pd
|
|
6
6
|
import pytest
|
|
7
7
|
import scanpy as sc
|
|
8
|
+
from statsmodels.stats.multitest import multipletests
|
|
8
9
|
|
|
9
10
|
from scatrans._de import (
|
|
10
11
|
_run_de_wrapper,
|
|
@@ -214,3 +215,14 @@ def test_run_permutation_test_use_fdr_based_on_n_success(monkeypatch):
|
|
|
214
215
|
)
|
|
215
216
|
assert use_fdr is False
|
|
216
217
|
assert reason == "small_permutation_space"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_memento_bh_excludes_nan_pvals_from_denominator():
|
|
221
|
+
"""Defensive: NaN p-values must not inflate BH denominator if they ever appear."""
|
|
222
|
+
pvals_raw = pd.Series([0.01, 0.04, np.nan, 0.2], index=["g0", "g1", "g2", "g3"])
|
|
223
|
+
valid = pvals_raw.notna()
|
|
224
|
+
p_adj_clean = pd.Series(1.0, index=pvals_raw.index)
|
|
225
|
+
if valid.sum() > 0:
|
|
226
|
+
p_adj_clean.loc[valid] = multipletests(pvals_raw[valid].values, method="fdr_bh")[1]
|
|
227
|
+
p_adj_polluted = multipletests(pvals_raw.fillna(1.0).values, method="fdr_bh")[1]
|
|
228
|
+
assert p_adj_clean.loc["g0"] < p_adj_polluted[0]
|
|
@@ -8,6 +8,7 @@ import matplotlib.pyplot as plt
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
import pandas as pd
|
|
10
10
|
import pytest
|
|
11
|
+
import scanpy as sc
|
|
11
12
|
|
|
12
13
|
import scatrans as scat
|
|
13
14
|
|
|
@@ -28,7 +29,14 @@ def test_volcano_plot_label_genes(results_df):
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def test_gseaplot_with_stored_curves():
|
|
31
|
-
|
|
32
|
+
# gseapy-sorted ranking (descending scores)
|
|
33
|
+
ranking = pd.Series(
|
|
34
|
+
{f"G{i}": float(20 - i) for i in range(20)},
|
|
35
|
+
index=[f"G{i}" for i in range(20)],
|
|
36
|
+
).sort_values(ascending=False)
|
|
37
|
+
# Caller passes a deliberately shuffled series (common when ranked from all_results)
|
|
38
|
+
shuffled = ranking.sample(frac=1, random_state=0)
|
|
39
|
+
res_curve = np.linspace(0.0, 1.0, len(ranking))
|
|
32
40
|
gsea_res = pd.DataFrame(
|
|
33
41
|
{
|
|
34
42
|
"Term": ["TERM1"],
|
|
@@ -37,15 +45,45 @@ def test_gseaplot_with_stored_curves():
|
|
|
37
45
|
"p.adjust": [0.05],
|
|
38
46
|
}
|
|
39
47
|
)
|
|
40
|
-
gsea_res.attrs["
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
gsea_res.attrs["gsea_details"] = {
|
|
49
|
+
"TERM1": {
|
|
50
|
+
"RES": res_curve.tolist(),
|
|
51
|
+
"hits": [2, 5, 9],
|
|
52
|
+
"nes": 1.5,
|
|
53
|
+
"pval": 0.01,
|
|
54
|
+
"fdr": 0.05,
|
|
55
|
+
}
|
|
44
56
|
}
|
|
45
|
-
|
|
57
|
+
gsea_res.attrs["ranking"] = ranking.to_dict()
|
|
58
|
+
fig, ax = scat.pl.gseaplot(shuffled, gsea_res, term="TERM1", show=False)
|
|
59
|
+
bar_heights = [p.get_height() for p in fig.axes[2].patches]
|
|
60
|
+
assert np.allclose(bar_heights, ranking.values)
|
|
61
|
+
assert len(fig.axes[0].lines[0].get_ydata()) == len(ranking)
|
|
46
62
|
plt.close(fig)
|
|
47
63
|
|
|
48
64
|
|
|
65
|
+
@pytest.mark.plot
|
|
66
|
+
def test_active_genes_heatmap_default_show():
|
|
67
|
+
import anndata as ad
|
|
68
|
+
|
|
69
|
+
X = np.random.poisson(3, size=(20, 5)).astype(float)
|
|
70
|
+
adata = ad.AnnData(
|
|
71
|
+
X,
|
|
72
|
+
obs=pd.DataFrame({"group": ["A"] * 10 + ["B"] * 10}),
|
|
73
|
+
var=pd.DataFrame(index=[f"g{i}" for i in range(5)]),
|
|
74
|
+
)
|
|
75
|
+
sc.pp.normalize_total(adata, target_sum=1e4)
|
|
76
|
+
sc.pp.log1p(adata)
|
|
77
|
+
result = scat.pl.active_genes_heatmap(
|
|
78
|
+
adata, genes=adata.var_names[:3].tolist(), groupby="group", show=True
|
|
79
|
+
)
|
|
80
|
+
assert result is not None
|
|
81
|
+
fig, _ax = result
|
|
82
|
+
assert fig is not None
|
|
83
|
+
if isinstance(fig, plt.Figure):
|
|
84
|
+
plt.close(fig)
|
|
85
|
+
|
|
86
|
+
|
|
49
87
|
def test_active_score_rankplot_empty():
|
|
50
88
|
empty = pd.DataFrame(columns=["active_score", "logFC", "p_adj"])
|
|
51
89
|
fig, ax = scat.pl.active_score_rankplot(empty, show=False)
|
|
@@ -90,6 +90,17 @@ def test_store_and_restore_raw_counts(adata_de_only):
|
|
|
90
90
|
assert "log1p" not in restored.uns
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def test_restore_raw_counts_rejects_permuted_gene_order(adata_de_only):
|
|
94
|
+
ad = adata_de_only.copy()
|
|
95
|
+
scat.store_raw_counts(ad, layer="counts", save_raw=False)
|
|
96
|
+
perm = list(range(ad.n_vars))
|
|
97
|
+
perm.reverse()
|
|
98
|
+
ad_perm = ad[:, perm].copy()
|
|
99
|
+
sc.pp.normalize_total(ad_perm, target_sum=1e4)
|
|
100
|
+
with pytest.raises(ValueError, match="raw_gene_list order differs"):
|
|
101
|
+
scat.restore_raw_counts(ad_perm, layer="counts", inplace=False)
|
|
102
|
+
|
|
103
|
+
|
|
93
104
|
def test_reconcile_log1p_marker_detects_stale_metadata(adata_de_only):
|
|
94
105
|
from scatrans._utils import _reconcile_log1p_marker, _x_looks_log_normalized
|
|
95
106
|
|
|
@@ -411,3 +422,40 @@ def test_prepare_log_normalized_expression_gap(adata_de_only):
|
|
|
411
422
|
# Should return roughly same scale (no additional log1p applied)
|
|
412
423
|
assert X_out.max() < 15
|
|
413
424
|
assert np.allclose(X_out.mean(), mat.mean(), atol=1.0)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_prior_weight_empirical_bayes_gradients_below_default():
|
|
428
|
+
"""prior_weight must affect empirical_bayes below the default (0.5–5.0 range)."""
|
|
429
|
+
from scatrans._velocity import _apply_empirical_bayes_gamma, _estimate_eb_prior_from_reference
|
|
430
|
+
|
|
431
|
+
rng = np.random.default_rng(0)
|
|
432
|
+
u_r = rng.exponential(2.0, 400)
|
|
433
|
+
s_r = rng.exponential(2.0, 400)
|
|
434
|
+
|
|
435
|
+
_, meta_lo = _estimate_eb_prior_from_reference(u_r, s_r, prior_weight=0.5)
|
|
436
|
+
_, meta_hi = _estimate_eb_prior_from_reference(u_r, s_r, prior_weight=5.0)
|
|
437
|
+
assert meta_lo["count_pseudocount"] == pytest.approx(0.1)
|
|
438
|
+
assert meta_hi["count_pseudocount"] == pytest.approx(1.0)
|
|
439
|
+
assert meta_lo["count_pseudocount"] != meta_hi["count_pseudocount"]
|
|
440
|
+
|
|
441
|
+
_, w_lo, _ = _apply_empirical_bayes_gamma(u_r, s_r, meta_lo["eb_prior"], n_ref=20.0)
|
|
442
|
+
_, w_hi, _ = _apply_empirical_bayes_gamma(u_r, s_r, meta_hi["eb_prior"], n_ref=20.0)
|
|
443
|
+
assert float(np.nanmean(w_lo)) != float(np.nanmean(w_hi))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@pytest.mark.slow
|
|
447
|
+
def test_significant_matches_filter_heuristic_preset(adata_basic):
|
|
448
|
+
"""Built-in significant list should match filter_active_genes(preset='heuristic')."""
|
|
449
|
+
_, sig, allr = scat.active_score(
|
|
450
|
+
adata_basic,
|
|
451
|
+
groupby="condition",
|
|
452
|
+
target_group="Disease",
|
|
453
|
+
reference_group="Control",
|
|
454
|
+
use_permutation=True,
|
|
455
|
+
n_perm=30,
|
|
456
|
+
random_seed=1,
|
|
457
|
+
n_jobs=1,
|
|
458
|
+
show_plot=False,
|
|
459
|
+
)
|
|
460
|
+
filt = scat.filter_active_genes(allr, preset="heuristic")
|
|
461
|
+
assert sig.index.tolist() == filt.index.tolist()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt
RENAMED
|
File without changes
|
|
File without changes
|
{scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scatrans-0.9.8.dev1 → scatrans-0.9.8.dev2}/src/scatrans/data/mouse_2020A_gene_features.parquet
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|