scatrans 0.9.6.dev0__tar.gz → 0.9.8.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. scatrans-0.9.8.dev1/.pre-commit-config.yaml +34 -0
  2. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/CHANGELOG.md +18 -0
  3. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/PKG-INFO +13 -1
  4. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/README.md +12 -0
  5. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/scatrans.egg-info/SOURCES.txt +3 -0
  6. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_de.py +117 -46
  7. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_permutation.py +51 -8
  8. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_utils.py +79 -1
  9. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_velocity.py +5 -1
  10. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_version.py +3 -3
  11. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/enrich.py +35 -19
  12. scatrans-0.9.8.dev1/src/scatrans/pl.py +2923 -0
  13. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/pp_bias.py +8 -2
  14. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/tl.py +154 -45
  15. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_basic.py +90 -0
  16. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_de_backends.py +7 -2
  17. scatrans-0.9.8.dev1/tests/test_de_edge_cases.py +216 -0
  18. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_enrich_api.py +88 -4
  19. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_pp_bias_cli.py +2 -0
  20. scatrans-0.9.8.dev1/tests/test_regression_bugs.py +180 -0
  21. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_tl_coverage.py +21 -0
  22. scatrans-0.9.6.dev0/src/scatrans/pl.py +0 -3054
  23. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/.github/workflows/ci.yml +0 -0
  24. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/.github/workflows/publish.yml +0 -0
  25. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/.gitignore +0 -0
  26. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/CITATION.cff +0 -0
  27. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/LICENSE +0 -0
  28. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/MANIFEST.in +0 -0
  29. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/conftest.py +0 -0
  30. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/conftest_fixtures.py +0 -0
  31. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/examples/memento_de_example.py +0 -0
  32. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/examples/real_data_template.py +0 -0
  33. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/examples/synthetic_active_transcription.py +0 -0
  34. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/pyproject.toml +0 -0
  35. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/setup.cfg +0 -0
  36. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/__init__.py +0 -0
  37. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_bias.py +0 -0
  38. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/DATA_LICENSES.md +0 -0
  39. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt +0 -0
  40. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Hs_KEGG_2026.txt +0 -0
  41. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt +0 -0
  42. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Mm_KEGG_2026.txt +0 -0
  43. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Mus_musculus.GRCm39.115_gene_features.parquet +0 -0
  44. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/README.md +0 -0
  45. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/human_GRCh38_2024A_gene_features.parquet +0 -0
  46. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/mouse_2020A_gene_features.parquet +0 -0
  47. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/generate_gene_features.py +0 -0
  48. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/src/scatrans/qc.py +0 -0
  49. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/__init__.py +0 -0
  50. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/conftest.py +0 -0
  51. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_enrich_go.py +0 -0
  52. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_pl_coverage.py +0 -0
  53. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_pl_extended.py +0 -0
  54. {scatrans-0.9.6.dev0 → scatrans-0.9.8.dev1}/tests/test_public_api.py +0 -0
@@ -0,0 +1,34 @@
1
+ # Pre-commit hooks for scATrans
2
+ # Install: pip install pre-commit
3
+ # Setup: pre-commit install
4
+ # Run manually: pre-commit run --all-files
5
+
6
+ repos:
7
+ - repo: https://github.com/astral-sh/ruff-pre-commit
8
+ rev: v0.4.4
9
+ hooks:
10
+ - id: ruff
11
+ args: [--fix, --exit-non-zero-on-fix]
12
+ - id: ruff-format
13
+
14
+ - repo: https://github.com/pre-commit/mirrors-mypy
15
+ rev: v1.10.0
16
+ hooks:
17
+ - id: mypy
18
+ additional_dependencies: []
19
+ args: [--ignore-missing-imports, --no-error-summary]
20
+ # mypy on the src layout; relax for now to avoid blocking on third-party stubs
21
+ exclude: |
22
+ (?x)^(
23
+ tests/|
24
+ backup/|
25
+ src/scatrans/_version.py
26
+ )
27
+
28
+ # Optional pyright (static type checker, often stricter). Requires node + pyright.
29
+ # Uncomment if you have pyright in your env (npm i -g pyright or via pyright package).
30
+ # - repo: https://github.com/RobertCraigie/pyright-python
31
+ # rev: v1.1.367
32
+ # hooks:
33
+ # - id: pyright
34
+ # additional_dependencies: [".[dev]"]
@@ -5,6 +5,24 @@ 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.2 bugfix 2026-07-02]
9
+
10
+ ### Fixed
11
+ - **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.
12
+ - **DE schema validation**: `_run_de_wrapper` validates that all backends return `logFC`, `p_val`, `p_adj` with at least one finite value per column.
13
+ - **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.
14
+ - **DE warnings**: selective `_de_warning_context()` suppresses deprecation/future noise only (scanpy/PyDESeq2/MixedLM/BH), no longer blanket `ignore` on all warning categories.
15
+ - **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.
16
+ - **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).
17
+ - **Diagnostics / docs**: `failed_fit_rate` exposed in `active_score` mixed-model diagnostics and `differential_expression` metadata; README adds a short DE backend decision guide.
18
+ - **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).
19
+ - **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).
20
+ - **CI lint**: removed unused `import scanpy as sc` from `tl.py` and `_permutation.py` (leftover from permutation refactor); fixed `test_enrich_api.py` formatting.
21
+ - **CI tests**: `test_pp_bias_cli` GTF generator tests now `pytest.importorskip("gtfparse")` so base installs without `scatrans[gene_features]` skip instead of failing.
22
+
23
+ ### Added
24
+ - Regression test `test_filter_active_genes_permissive_keeps_padj_one`.
25
+
8
26
  ## [Unreleased / Review 2026-06-27]
9
27
  ### Added / Improved
10
28
  - Clarified and documented the `gamma_method="empirical_bayes"` implementation as **hierarchical (分层) gamma estimation** for the reference U/S ratio (README keeps the CN term; source now English-only).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scatrans
3
- Version: 0.9.6.dev0
3
+ Version: 0.9.8.dev1
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 |
@@ -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 |
@@ -1,4 +1,5 @@
1
1
  .gitignore
2
+ .pre-commit-config.yaml
2
3
  CHANGELOG.md
3
4
  CITATION.cff
4
5
  LICENSE
@@ -40,10 +41,12 @@ tests/__init__.py
40
41
  tests/conftest.py
41
42
  tests/test_basic.py
42
43
  tests/test_de_backends.py
44
+ tests/test_de_edge_cases.py
43
45
  tests/test_enrich_api.py
44
46
  tests/test_enrich_go.py
45
47
  tests/test_pl_coverage.py
46
48
  tests/test_pl_extended.py
47
49
  tests/test_pp_bias_cli.py
48
50
  tests/test_public_api.py
51
+ tests/test_regression_bugs.py
49
52
  tests/test_tl_coverage.py
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import logging
11
11
  import warnings
12
+ from contextlib import contextmanager
12
13
  from importlib.metadata import version
13
14
  from typing import Any
14
15
 
@@ -29,6 +30,44 @@ from ._utils import (
29
30
  )
30
31
 
31
32
  logger = logging.getLogger(__name__)
33
+ logger.addHandler(logging.NullHandler())
34
+
35
+ _DE_REQUIRED_COLS = frozenset({"logFC", "p_val", "p_adj"})
36
+
37
+ # Suppress noisy deprecation/future warnings from scanpy/anndata/PyDESeq2 during DE,
38
+ # but keep convergence/runtime/user warnings visible to callers.
39
+ _DE_SUPPRESS_WARNING_CATEGORIES = (
40
+ DeprecationWarning,
41
+ FutureWarning,
42
+ PendingDeprecationWarning,
43
+ )
44
+
45
+
46
+ @contextmanager
47
+ def _de_warning_context():
48
+ with warnings.catch_warnings():
49
+ for cat in _DE_SUPPRESS_WARNING_CATEGORIES:
50
+ warnings.simplefilter("ignore", category=cat)
51
+ yield
52
+
53
+
54
+ def _validate_de_result(de_df: pd.DataFrame, *, backend: str) -> pd.DataFrame:
55
+ """Assert all DE backends return the minimal schema expected downstream."""
56
+ missing = _DE_REQUIRED_COLS - set(de_df.columns)
57
+ if missing:
58
+ raise RuntimeError(
59
+ f"DE backend '{backend}' returned incomplete results; "
60
+ f"missing columns: {sorted(missing)}"
61
+ )
62
+ if len(de_df) == 0:
63
+ return de_df
64
+ for col in _DE_REQUIRED_COLS:
65
+ vals = pd.to_numeric(de_df[col], errors="coerce")
66
+ if not np.isfinite(vals.to_numpy()).any():
67
+ raise RuntimeError(
68
+ f"DE backend '{backend}' returned no finite values in column {col!r}."
69
+ )
70
+ return de_df
32
71
 
33
72
 
34
73
  def _pydeseq2_uses_design_factors() -> bool:
@@ -94,15 +133,18 @@ def _run_de_wrapper(
94
133
  if use_mixed_model:
95
134
  if sample_col is None:
96
135
  raise ValueError("sample_col must be provided when use_mixed_model=True")
97
- return _run_mixedlm_de(
98
- adata,
99
- groupby=groupby,
100
- target_group=target_group,
101
- reference_group=reference_group,
102
- sample_col=sample_col,
103
- n_jobs=n_jobs,
104
- labels=labels,
105
- mixed_model_pval=mixed_model_pval,
136
+ return _validate_de_result(
137
+ _run_mixedlm_de(
138
+ adata,
139
+ groupby=groupby,
140
+ target_group=target_group,
141
+ reference_group=reference_group,
142
+ sample_col=sample_col,
143
+ n_jobs=n_jobs,
144
+ labels=labels,
145
+ mixed_model_pval=mixed_model_pval,
146
+ ),
147
+ backend="mixedlm",
106
148
  )
107
149
 
108
150
  if use_memento_de:
@@ -111,16 +153,19 @@ def _run_de_wrapper(
111
153
  "use_memento_de=True is not supported with use_pseudobulk=True "
112
154
  "(Memento is a cell-level method-of-moments estimator; use PyDESeq2 for pseudobulk)."
113
155
  )
114
- return _run_memento_de(
115
- adata,
116
- groupby=groupby,
117
- target_group=target_group,
118
- reference_group=reference_group,
119
- labels=labels,
120
- capture_rate=memento_capture_rate,
121
- num_boot=memento_num_boot,
122
- n_cpus=memento_n_cpus,
123
- counts=counts,
156
+ return _validate_de_result(
157
+ _run_memento_de(
158
+ adata,
159
+ groupby=groupby,
160
+ target_group=target_group,
161
+ reference_group=reference_group,
162
+ labels=labels,
163
+ capture_rate=memento_capture_rate,
164
+ num_boot=memento_num_boot,
165
+ n_cpus=memento_n_cpus,
166
+ counts=counts,
167
+ ),
168
+ backend="memento",
124
169
  )
125
170
 
126
171
  ad_temp = adata.copy() if labels is not None else adata
@@ -220,8 +265,7 @@ def _run_de_wrapper(
220
265
  index=counts_use.index,
221
266
  )
222
267
 
223
- with warnings.catch_warnings():
224
- warnings.simplefilter("ignore")
268
+ with _de_warning_context():
225
269
  if _pydeseq2_uses_design_factors():
226
270
  dds = DeseqDataSet(
227
271
  counts=counts_use,
@@ -259,16 +303,23 @@ def _run_de_wrapper(
259
303
 
260
304
  res2 = stat_res.results_df.copy().reindex(ad_temp.var_names)
261
305
  de_df = pd.DataFrame(index=ad_temp.var_names)
262
- de_df["logFC"] = res2["log2FoldChange"].fillna(0.0)
263
- de_df["p_val"] = res2.get("pvalue", pd.Series(1.0, index=res2.index)).fillna(1.0)
264
- de_df["p_adj"] = res2.get("padj", pd.Series(1.0, index=res2.index)).fillna(1.0)
306
+ de_df["logFC"] = res2["log2FoldChange"].reindex(ad_temp.var_names)
307
+ de_df["p_val"] = res2.get("pvalue", pd.Series(np.nan, index=res2.index)).reindex(
308
+ ad_temp.var_names
309
+ )
310
+ de_df["p_adj"] = res2.get("padj", pd.Series(np.nan, index=res2.index)).reindex(
311
+ ad_temp.var_names
312
+ )
313
+ _validate_de_result(de_df, backend="pydeseq2")
314
+ de_df["logFC"] = de_df["logFC"].fillna(0.0)
315
+ de_df["p_val"] = de_df["p_val"].fillna(1.0)
316
+ de_df["p_adj"] = de_df["p_adj"].fillna(1.0)
265
317
  return de_df
266
318
 
267
319
  else:
268
320
  # Standard scanpy path (works for both regular and pseudobulk when not using pydeseq2)
269
321
  rank_key = "_scatrans_rank_genes_groups"
270
- with warnings.catch_warnings():
271
- warnings.simplefilter("ignore")
322
+ with _de_warning_context():
272
323
  sc.tl.rank_genes_groups(
273
324
  ad_temp,
274
325
  groupby=use_groupby,
@@ -281,13 +332,16 @@ def _run_de_wrapper(
281
332
  "names"
282
333
  )
283
334
  de_df = pd.DataFrame(index=ad_temp.var_names)
284
- raw_lfc = de_raw["logfoldchanges"].reindex(ad_temp.var_names).fillna(0.0)
285
335
  # scanpy rank_genes_groups always returns logfoldchanges on log2 scale:
286
336
  # log2( (expm1(mean_t) + eps) / (expm1(mean_r) + eps) ), independent of the
287
337
  # statistical method (wilcoxon / t-test / etc.). No secondary conversion needed.
288
- de_df["logFC"] = raw_lfc
289
- de_df["p_val"] = de_raw["pvals"].reindex(ad_temp.var_names).fillna(1.0)
290
- de_df["p_adj"] = de_raw["pvals_adj"].reindex(ad_temp.var_names).fillna(1.0)
338
+ de_df["logFC"] = de_raw["logfoldchanges"].reindex(ad_temp.var_names)
339
+ de_df["p_val"] = de_raw["pvals"].reindex(ad_temp.var_names)
340
+ de_df["p_adj"] = de_raw["pvals_adj"].reindex(ad_temp.var_names)
341
+ _validate_de_result(de_df, backend=f"scanpy:{de_method}")
342
+ de_df["logFC"] = de_df["logFC"].fillna(0.0)
343
+ de_df["p_val"] = de_df["p_val"].fillna(1.0)
344
+ de_df["p_adj"] = de_df["p_adj"].fillna(1.0)
291
345
  return de_df
292
346
 
293
347
 
@@ -354,13 +408,12 @@ def _run_mixedlm_de(
354
408
  # The explicit flag avoids counting truly neutral (logFC~0, p~1, dvar~0) biological genes as "failed".
355
409
  def _fit_gene_mixed(idx: int):
356
410
  y = expr_mat[:, idx].astype(float)
357
- # guard against all-zero / constant (mixedlm will be singular)
358
- if np.allclose(y, y[0]):
411
+ # guard against near-constant expression (mixedlm will be singular)
412
+ if float(np.nanvar(y)) < 1e-12:
359
413
  return idx, 0.0, 1.0, 1.0, 0.0, True
360
414
  df = pd.DataFrame({"y": y, "condition": condition, "sample": samples})
361
415
  try:
362
- with warnings.catch_warnings():
363
- warnings.simplefilter("ignore")
416
+ with _de_warning_context():
364
417
  # Full model
365
418
  md_full = smf.mixedlm("y ~ C(condition)", df, groups=df["sample"])
366
419
  m_full = md_full.fit(reml=False, maxiter=200, disp=False)
@@ -369,6 +422,9 @@ def _run_mixedlm_de(
369
422
  md_null = smf.mixedlm("y ~ 1", df, groups=df["sample"])
370
423
  m_null = md_null.fit(reml=False, maxiter=200, disp=False)
371
424
 
425
+ if not getattr(m_full, "converged", True) or not getattr(m_null, "converged", True):
426
+ return idx, 0.0, 1.0, 1.0, 0.0, True
427
+
372
428
  # LRT statistic and p (chi2 df=1 for the added fixed effect term(s))
373
429
  lrt_stat = -2.0 * (m_null.llf - m_full.llf)
374
430
  lrt_p = float(chi2.sf(max(lrt_stat, 0.0), 1))
@@ -381,11 +437,7 @@ def _run_mixedlm_de(
381
437
  coef_name = pname
382
438
  break
383
439
  if coef_name is None:
384
- # fallback: take the second coef if intercept + one more
385
- if len(m_full.params) >= 2:
386
- coef_name = m_full.params.index[1]
387
- else:
388
- coef_name = m_full.params.index[0]
440
+ return idx, 0.0, 1.0, 1.0, 0.0, True
389
441
  logfc = float(m_full.params.get(coef_name, 0.0))
390
442
  p_wald = float(m_full.pvalues.get(coef_name, 1.0))
391
443
 
@@ -441,8 +493,7 @@ def _run_mixedlm_de(
441
493
  logger.warning("mixed_model_pval must be 'wald' or 'lrt'; falling back to 'wald'.")
442
494
  main_pvals = p_walds
443
495
 
444
- with warnings.catch_warnings():
445
- warnings.simplefilter("ignore")
496
+ with _de_warning_context():
446
497
  p_adjs = multipletests(main_pvals, method="fdr_bh")[1]
447
498
 
448
499
  de_df = pd.DataFrame(index=var_names)
@@ -456,14 +507,19 @@ def _run_mixedlm_de(
456
507
  # Use explicit failure flag returned from workers (avoids counting true biological
457
508
  # neutral genes (logFC~0, p~1, dvar~0) as "failed fits").
458
509
  n_failed = int(np.sum(failed_flags))
510
+ n_total = len(de_df)
511
+ failed_rate = (n_failed / n_total) if n_total else 0.0
459
512
  de_df.attrs["n_genes_failed_fit"] = n_failed
513
+ de_df.attrs["failed_fit_rate"] = failed_rate
460
514
  if n_failed > 0:
461
515
  logger.warning(
462
- "MixedLM: %d/%d genes had degenerate fits (e.g. small n per group, collinearity) "
516
+ "MixedLM: %d/%d genes (%.1f%%) had degenerate or non-convergent fits "
517
+ "(near-constant expression, singular Hessian, missing condition coefficient, etc.) "
463
518
  "and received neutral values (logFC=0, p_val=1, delta_variance=0). "
464
519
  "See diagnostics['mixed_model']['n_genes_failed_fit'].",
465
520
  n_failed,
466
- len(de_df),
521
+ n_total,
522
+ 100.0 * failed_rate,
467
523
  )
468
524
  return de_df
469
525
 
@@ -535,6 +591,15 @@ def _run_memento_de(
535
591
  else:
536
592
  raise ValueError(f"counts='{counts}' layer not found in adata.layers")
537
593
  elif isinstance(counts, ad.AnnData):
594
+ if not np.array_equal(counts.obs_names, ad_temp.obs_names):
595
+ missing = ad_temp.obs_names.difference(counts.obs_names)
596
+ if len(missing):
597
+ raise ValueError(
598
+ f"counts AnnData is missing {len(missing)} cell(s) required by the "
599
+ f"comparison subset (first missing: {missing[0]!r}). "
600
+ "Pass counts aligned to adata.obs_names or use a matrix layer."
601
+ )
602
+ counts = counts[ad_temp.obs_names]
538
603
  raw_counts = counts.X
539
604
  if counts.var_names.tolist() != ad_temp.var_names.tolist():
540
605
  common = counts.var_names.intersection(ad_temp.var_names)
@@ -640,9 +705,15 @@ def _run_memento_de(
640
705
  pvals = pd.to_numeric(pval_raw, errors="coerce").reindex(res_index).fillna(1.0)
641
706
  de_df["p_val"] = pvals
642
707
 
643
- # BH adjustment (Memento may return raw p; we make p_adj consistent with other backends)
644
- with warnings.catch_warnings():
645
- warnings.simplefilter("ignore")
708
+ # Preserve Memento-native adjusted p if present (audit trail); package p_adj uses BH for consistency.
709
+ for native_col in ("de_padj", "de_padjs", "padj", "p_adj", "de_fdr"):
710
+ if isinstance(result, pd.DataFrame) and native_col in result.columns:
711
+ de_df["memento_p_adj_native"] = pd.to_numeric(
712
+ result[native_col], errors="coerce"
713
+ ).reindex(res_index)
714
+ break
715
+
716
+ with _de_warning_context():
646
717
  de_df["p_adj"] = multipletests(pvals.values, method="fdr_bh")[1]
647
718
 
648
719
  # Expose Memento's native columns for users who want mean + variability signals
@@ -12,7 +12,6 @@ import warnings
12
12
  from typing import Any
13
13
 
14
14
  import numpy as np
15
- import scanpy as sc
16
15
  from joblib import Parallel, delayed
17
16
  from statsmodels.stats.multitest import multipletests
18
17
 
@@ -27,6 +26,46 @@ from ._utils import (
27
26
  from ._velocity import _compute_velocity_delta
28
27
 
29
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
+ )
30
69
 
31
70
 
32
71
  def _single_permutation_task(
@@ -61,6 +100,7 @@ def _single_permutation_task(
61
100
  memento_capture_rate: float = 0.07,
62
101
  memento_num_boot: int = 5000,
63
102
  memento_n_cpus: int = -1,
103
+ velocity_source: str = "heuristic_global_ratio",
64
104
  ) -> tuple[np.ndarray, np.ndarray]:
65
105
  """One permutation replicate.
66
106
 
@@ -109,12 +149,13 @@ def _single_permutation_task(
109
149
 
110
150
  t_mask = shuffled_labels == target_group
111
151
  r_mask = shuffled_labels == reference_group
112
- delta_velocity, _, _gamma_ref, _ = _compute_velocity_delta(
113
- uns_layer,
114
- spl_layer,
115
- t_mask,
116
- r_mask,
117
- prior_weight,
152
+ delta_velocity, _, _gamma_ref, _ = _compute_perm_velocity_delta(
153
+ velocity_source=velocity_source,
154
+ uns_layer=uns_layer,
155
+ spl_layer=spl_layer,
156
+ t_mask=t_mask,
157
+ r_mask=r_mask,
158
+ prior_weight=prior_weight,
118
159
  gamma_method=gamma_method,
119
160
  eb_prior=eb_prior,
120
161
  )
@@ -178,6 +219,7 @@ def run_permutation_test(
178
219
  real_score: np.ndarray,
179
220
  real_residual: np.ndarray,
180
221
  eb_prior: dict[str, Any] | None = None,
222
+ velocity_source: str = "heuristic_global_ratio",
181
223
  bias_correction: str = "huber_length_intron",
182
224
  # Memento forwarding for advanced consistent permutation
183
225
  use_memento_de: bool = False,
@@ -225,6 +267,7 @@ def run_permutation_test(
225
267
  de_preprocess,
226
268
  strict_pydeseq2_counts,
227
269
  eb_prior,
270
+ velocity_source=velocity_source,
228
271
  bias_correction=bias_correction,
229
272
  use_memento_de=use_memento_de,
230
273
  memento_capture_rate=memento_capture_rate,
@@ -288,7 +331,7 @@ def run_permutation_test(
288
331
  # - For very small permutation spaces (n_perm < 100, common in pseudobulk with few samples),
289
332
  # we mark use_fdr=False and provide reason so callers can avoid using FDR for significance
290
333
  # (p-values become coarse; BH adjustment less reliable). This eliminates vestigial logic in tl.py.
291
- use_fdr = n_perm >= 100
334
+ use_fdr = n_success >= 100
292
335
  disabled_reason = None if use_fdr else "small_permutation_space"
293
336
 
294
337
  return (
@@ -22,6 +22,7 @@ from scipy import sparse
22
22
  from sklearn.linear_model import HuberRegressor
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
+ logger.addHandler(logging.NullHandler())
25
26
 
26
27
 
27
28
  def _normalize_group_label(val: Any) -> str | None:
@@ -187,7 +188,16 @@ def _is_integer_counts_like(X: Any, max_check: int = 100000, atol: float = 1e-6)
187
188
 
188
189
  if vals.size > max_check:
189
190
  rng = np.random.default_rng(0)
190
- vals = rng.choice(vals, size=max_check, replace=False)
191
+ # Stride + random subsample: stride covers the full matrix deterministically;
192
+ # random supplement catches sparse non-integer contamination missed by stride alone.
193
+ stride = max(1, vals.size // (max_check // 2))
194
+ stride_vals = vals[::stride]
195
+ n_random = max_check - stride_vals.size
196
+ if n_random > 0:
197
+ random_vals = rng.choice(vals, size=min(n_random, vals.size), replace=False)
198
+ vals = np.concatenate([stride_vals, random_vals])
199
+ else:
200
+ vals = stride_vals[:max_check]
191
201
 
192
202
  # Tolerant check: allows tiny floating point noise from summation / cast
193
203
  rounded = np.round(vals)
@@ -200,6 +210,19 @@ def _dense_expression_matrix(X: Any) -> np.ndarray:
200
210
  return np.asarray(X, dtype=float)
201
211
 
202
212
 
213
+ def _x_looks_zscore_scaled(finite: np.ndarray) -> bool:
214
+ """Return True when values look z-score scaled (e.g. after ``sc.pp.scale``)."""
215
+ if finite.size == 0 or not np.any(finite < 0):
216
+ return False
217
+ mx = float(np.nanmax(finite))
218
+ mn = float(np.nanmin(finite))
219
+ mean = float(np.nanmean(finite))
220
+ std = float(np.nanstd(finite))
221
+ if mx > 25.0:
222
+ return False
223
+ return abs(mean) < 1.0 and 0.25 < std < 5.0 and mn < -0.1
224
+
225
+
203
226
  def _x_looks_log_normalized(X: Any, *, max_check: int = 100000) -> bool:
204
227
  """Return True when *X* is unlikely to be raw integer counts needing normalize+log1p."""
205
228
  if _is_integer_counts_like(X, max_check=max_check):
@@ -208,6 +231,8 @@ def _x_looks_log_normalized(X: Any, *, max_check: int = 100000) -> bool:
208
231
  finite = arr[np.isfinite(arr)]
209
232
  if finite.size == 0:
210
233
  return False
234
+ if _x_looks_zscore_scaled(finite):
235
+ return False
211
236
  mx = float(np.nanmax(finite))
212
237
  has_neg = bool(np.any(finite < 0))
213
238
  return has_neg or mx <= 20.0
@@ -218,6 +243,36 @@ def _clear_log_preprocess_metadata(adata: ad.AnnData) -> None:
218
243
  adata.uns.pop("log1p", None)
219
244
 
220
245
 
246
+ def _restore_log_from_scaled_x(adata: ad.AnnData) -> bool:
247
+ """Reverse ``sc.pp.scale`` on ``.X`` when mean/std are stored in ``adata.var``."""
248
+ arr = _dense_expression_matrix(adata.X)
249
+ finite = arr[np.isfinite(arr)]
250
+ if finite.size == 0 or not _x_looks_zscore_scaled(finite):
251
+ return False
252
+
253
+ if "mean" not in adata.var.columns or "std" not in adata.var.columns:
254
+ return False
255
+
256
+ mean = pd.to_numeric(adata.var["mean"], errors="coerce").to_numpy(dtype=float)
257
+ std = pd.to_numeric(adata.var["std"], errors="coerce").to_numpy(dtype=float)
258
+ if not np.all(np.isfinite(mean)) or not np.all(np.isfinite(std)):
259
+ return False
260
+
261
+ if sparse.issparse(adata.X):
262
+ X = np.asarray(adata.X.toarray(), dtype=float)
263
+ else:
264
+ X = np.asarray(adata.X, dtype=float)
265
+
266
+ adata.X = X * std + mean
267
+ logger.warning(
268
+ ".X appeared z-score scaled (typical after sc.pp.scale). Restored log-normalized "
269
+ "expression from adata.var['mean']/'std' for DE. Keep scaled data in a separate "
270
+ "AnnData copy for PCA/clustering."
271
+ )
272
+ adata.uns.setdefault("log1p", {"base": None})
273
+ return True
274
+
275
+
221
276
  def _reconcile_log1p_marker(adata: ad.AnnData) -> bool:
222
277
  """Align ``uns['log1p']`` with the current ``.X`` scale.
223
278
 
@@ -228,8 +283,20 @@ def _reconcile_log1p_marker(adata: ad.AnnData) -> bool:
228
283
  normalize_total + log1p.
229
284
  """
230
285
  has_marker = "log1p" in adata.uns
286
+ arr = _dense_expression_matrix(adata.X)
287
+ finite = arr[np.isfinite(arr)]
288
+ x_is_scaled = _x_looks_zscore_scaled(finite) if finite.size else False
231
289
  x_is_log = _x_looks_log_normalized(adata.X)
232
290
 
291
+ if has_marker and x_is_scaled:
292
+ _clear_log_preprocess_metadata(adata)
293
+ logger.warning(
294
+ "Removed stale uns['log1p'] metadata: .X appears z-score scaled (e.g. after "
295
+ "sc.pp.scale) while the log1p marker was still set. Downstream DE preprocessing "
296
+ "will re-apply normalize_total + log1p when de_preprocess='auto'."
297
+ )
298
+ return False
299
+
233
300
  if has_marker and not x_is_log:
234
301
  _clear_log_preprocess_metadata(adata)
235
302
  logger.warning(
@@ -257,6 +324,17 @@ def _apply_de_preprocess(
257
324
  skip_auto: bool = False,
258
325
  ) -> None:
259
326
  """Apply normalize_total + log1p according to ``de_preprocess`` mode."""
327
+ if de_preprocess in ("auto", "normalize_log1p") and not skip_auto:
328
+ arr = _dense_expression_matrix(adata.X)
329
+ finite = arr[np.isfinite(arr)]
330
+ if finite.size and _x_looks_zscore_scaled(finite) and not _restore_log_from_scaled_x(adata):
331
+ raise ValueError(
332
+ ".X appears z-score scaled (e.g. after sc.pp.scale) but cannot be restored "
333
+ "for DE (missing adata.var['mean']/'std']). Use an AnnData copy from before "
334
+ "scaling, pass de_preprocess='none' with a suitable matrix, or store "
335
+ "log-normalized expression in adata.raw or a layer."
336
+ )
337
+
260
338
  if de_preprocess == "normalize_log1p":
261
339
  logger.info("DE preprocessing: applying normalize_total + log1p (explicit).")
262
340
  sc.pp.normalize_total(adata, target_sum=1e4)
@@ -23,6 +23,7 @@ from scipy import sparse
23
23
  from ._utils import _get_group_mean
24
24
 
25
25
  logger = logging.getLogger(__name__)
26
+ logger.addHandler(logging.NullHandler())
26
27
 
27
28
  _EPS = 1e-8
28
29
  _MIN_GENES_FOR_EB_PRIOR = 10
@@ -211,7 +212,10 @@ def _compute_velocity_delta(
211
212
  gamma_method: str = "heuristic_shrink",
212
213
  eb_prior: dict[str, Any] | None = None,
213
214
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]:
214
- """Classic (global ratio) velocity delta used by the heuristic track.
215
+ """Groupwise velocity delta (U_t - gamma_ref * S_t) on arbitrary expression layers.
216
+
217
+ Used by the heuristic track (normalized unspliced/spliced) and, after scVelo
218
+ moments smoothing, by the advanced track (Mu/Ms).
215
219
 
216
220
  gamma_method:
217
221
  - "heuristic_shrink": original global ratio + additive shrinkage using prior_weight