scatrans 0.9.8.dev1__tar.gz → 0.9.8.dev3__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 (53) hide show
  1. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/CHANGELOG.md +44 -0
  2. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/CITATION.cff +1 -1
  3. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/PKG-INFO +15 -6
  4. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/README.md +14 -5
  5. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/pyproject.toml +1 -1
  6. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/scatrans.egg-info/SOURCES.txt +0 -2
  7. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/__init__.py +1 -1
  8. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/_de.py +52 -12
  9. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/_utils.py +0 -2
  10. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/_velocity.py +16 -18
  11. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/_version.py +2 -2
  12. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/pl.py +8 -2
  13. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/tl.py +116 -35
  14. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_basic.py +2 -1
  15. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_de_backends.py +19 -0
  16. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_de_edge_cases.py +12 -0
  17. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_pl_extended.py +44 -6
  18. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_tl_coverage.py +48 -0
  19. scatrans-0.9.8.dev1/src/scatrans/_bias.py +0 -24
  20. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/.github/workflows/ci.yml +0 -0
  21. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/.github/workflows/publish.yml +0 -0
  22. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/.gitignore +0 -0
  23. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/.pre-commit-config.yaml +0 -0
  24. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/LICENSE +0 -0
  25. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/MANIFEST.in +0 -0
  26. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/conftest.py +0 -0
  27. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/conftest_fixtures.py +0 -0
  28. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/examples/memento_de_example.py +0 -0
  29. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/examples/real_data_template.py +0 -0
  30. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/examples/synthetic_active_transcription.py +0 -0
  31. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/setup.cfg +0 -0
  32. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/_permutation.py +0 -0
  33. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/DATA_LICENSES.md +0 -0
  34. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt +0 -0
  35. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/Hs_KEGG_2026.txt +0 -0
  36. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt +0 -0
  37. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/Mm_KEGG_2026.txt +0 -0
  38. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/Mus_musculus.GRCm39.115_gene_features.parquet +0 -0
  39. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/README.md +0 -0
  40. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/human_GRCh38_2024A_gene_features.parquet +0 -0
  41. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/data/mouse_2020A_gene_features.parquet +0 -0
  42. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/enrich.py +0 -0
  43. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/generate_gene_features.py +0 -0
  44. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/pp_bias.py +0 -0
  45. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/src/scatrans/qc.py +0 -0
  46. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/__init__.py +0 -0
  47. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/conftest.py +0 -0
  48. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_enrich_api.py +0 -0
  49. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_enrich_go.py +0 -0
  50. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_pl_coverage.py +0 -0
  51. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_pp_bias_cli.py +0 -0
  52. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/tests/test_public_api.py +0 -0
  53. {scatrans-0.9.8.dev1 → scatrans-0.9.8.dev3}/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
@@ -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.dev1
3
+ Version: 0.9.8.dev3
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 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:
809
811
 
810
- - `logFC > logfc_cutoff` (default 0.5)
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 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.
816
822
 
817
- 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()`.
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 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:
758
760
 
759
- - `logFC > logfc_cutoff` (default 0.5)
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 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.
765
771
 
766
- 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()`.
767
776
 
768
777
  After each run inspect the diagnostics:
769
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
 
@@ -4,7 +4,6 @@ CHANGELOG.md
4
4
  CITATION.cff
5
5
  LICENSE
6
6
  MANIFEST.in
7
- PKG-INFO
8
7
  README.md
9
8
  conftest.py
10
9
  conftest_fixtures.py
@@ -16,7 +15,6 @@ examples/memento_de_example.py
16
15
  examples/real_data_template.py
17
16
  examples/synthetic_active_transcription.py
18
17
  src/scatrans/__init__.py
19
- src/scatrans/_bias.py
20
18
  src/scatrans/_de.py
21
19
  src/scatrans/_permutation.py
22
20
  src/scatrans/_utils.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
@@ -301,7 +301,12 @@ def _run_de_wrapper(
301
301
  )
302
302
  stat_res.summary()
303
303
 
304
- 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)
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
- # The param name is typically "C(condition)[T.<target>]" or similar
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
- pvals = pd.to_numeric(pval_raw, errors="coerce").reindex(res_index).fillna(1.0)
706
- de_df["p_val"] = pvals
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
- with _de_warning_context():
717
- de_df["p_adj"] = multipletests(pvals.values, method="fdr_bh")[1]
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
- r_trim = r_valid[trim_mask] if trim_mask.sum() >= min_genes_for_prior else r_valid
80
- if r_trim.size < min_genes_for_prior:
81
- # After trimming for robustness we have too few; fall back to the same
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
- prior_mean_log = float(np.median(r_trim))
93
- tau = _robust_mad_scale(r_trim)
94
- n_genes_used = int(r_trim.size)
95
-
96
- # prior_weight scales count pseudocount for observation precision (backward-compat knob)
97
- count_pseudo = max(count_pseudocount, prior_weight * 0.2)
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.dev1'
22
- __version_tuple__ = version_tuple = (0, 9, 8, 'dev1')
21
+ __version__ = version = '0.9.8.dev3'
22
+ __version_tuple__ = version_tuple = (0, 9, 8, 'dev3')
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.2"
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 = 0.5,
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 is produced by a strict
369
- conjunction of thresholds and is often small or empty.
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"] < extra_metadata.get("pval_cutoff", 0.05))
1288
- & (adata.var["logFC"] > extra_metadata.get("logfc_cutoff", 0.5))
1289
- & (adata.var[residual_col] > 0)
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": "> 0",
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 a ranked DataFrame (by |logFC| or p_adj) containing
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
- # Additional guard when restoring from adata.raw (same dimension but possibly different order/names)
2022
- if (
2023
- source == "adata.raw"
2024
- and hasattr(adata.raw, "var_names")
2025
- and not np.array_equal(adata.raw.var_names, adata.var_names)
2026
- ):
2027
- raise ValueError(
2028
- "adata.raw has the same number of genes as current adata, but gene names/order differ. "
2029
- "Cannot restore into .X without explicit gene reindexing."
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
- "active_score_cutoff": 55.0,
2173
- "pval_cutoff": 0.05,
2174
- "velocity_residual_cutoff": 1.0,
2175
- "unspliced_excess_residual_cutoff": 1.0,
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,25 @@ 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
+ pseudobulk_de_backend="scanpy",
90
+ de_method="wilcoxon",
91
+ pb_use_total_for_x=False,
92
+ pb_x_layer="X",
93
+ use_permutation=False,
94
+ show_plot=False,
95
+ )
96
+
97
+
79
98
  @pytest.mark.skipif(
80
99
  importlib.util.find_spec("pydeseq2") is None,
81
100
  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
- ranked = pd.Series(np.linspace(1, 0, 20), index=[f"G{i}" for i in range(20)])
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["gsea_info"] = {
41
- "rank_metric": ranked,
42
- "enrichment_score": np.linspace(0, 0.5, 20),
43
- "hit_indices": [2, 5, 9],
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
- fig, ax = scat.pl.gseaplot(ranked, gsea_res, term="TERM1", show=False)
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()
@@ -1,24 +0,0 @@
1
- """
2
- Bias correction (Huber regression on gene length + intron number).
3
-
4
- The actual implementation lives in _utils._fit_huber_bias_correction so it can be
5
- used from both the main analysis path and from permutation tasks without duplication.
6
-
7
- Enhanced return: (residual, bias_info_dict) with fit diagnostics for transparency.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from typing import Any
13
-
14
- import numpy as np
15
-
16
- from ._utils import _fit_huber_bias_correction as _raw_fit
17
-
18
-
19
- def fit_huber_bias_correction(*args, **kwargs) -> tuple[np.ndarray, dict[str, Any]]:
20
- """Public/internal wrapper that returns (residual, bias_info)."""
21
- return _raw_fit(*args, **kwargs)
22
-
23
-
24
- __all__ = ["fit_huber_bias_correction"]
File without changes
File without changes
File without changes
File without changes
File without changes