scatrans 0.9.8.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.
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/CHANGELOG.md +8 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/PKG-INFO +13 -1
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/README.md +12 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/scatrans.egg-info/SOURCES.txt +2 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_de.py +117 -46
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_permutation.py +51 -7
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_utils.py +79 -1
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_velocity.py +5 -1
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_version.py +2 -2
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/enrich.py +35 -19
- scatrans-0.9.8.dev1/src/scatrans/pl.py +2923 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/pp_bias.py +8 -2
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/tl.py +142 -34
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_basic.py +71 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_de_backends.py +7 -2
- scatrans-0.9.8.dev1/tests/test_de_edge_cases.py +216 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_enrich_api.py +82 -0
- scatrans-0.9.8.dev1/tests/test_regression_bugs.py +180 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_tl_coverage.py +21 -0
- scatrans-0.9.8.dev0/src/scatrans/pl.py +0 -3054
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/.github/workflows/ci.yml +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/.github/workflows/publish.yml +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/.gitignore +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/.pre-commit-config.yaml +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/CITATION.cff +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/LICENSE +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/MANIFEST.in +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/conftest.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/conftest_fixtures.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/examples/memento_de_example.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/examples/real_data_template.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/examples/synthetic_active_transcription.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/pyproject.toml +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/setup.cfg +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/__init__.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/_bias.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/DATA_LICENSES.md +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Hs_KEGG_2026.txt +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Mm_KEGG_2026.txt +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/Mus_musculus.GRCm39.115_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/README.md +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/human_GRCh38_2024A_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/data/mouse_2020A_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/generate_gene_features.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/src/scatrans/qc.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/__init__.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/conftest.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_enrich_go.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_pl_coverage.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_pl_extended.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_pp_bias_cli.py +0 -0
- {scatrans-0.9.8.dev0 → scatrans-0.9.8.dev1}/tests/test_public_api.py +0 -0
|
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
## [0.9.2 bugfix 2026-07-02]
|
|
9
9
|
|
|
10
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).
|
|
11
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).
|
|
12
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.
|
|
13
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scatrans
|
|
3
|
-
Version: 0.9.8.
|
|
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 |
|
|
@@ -41,10 +41,12 @@ tests/__init__.py
|
|
|
41
41
|
tests/conftest.py
|
|
42
42
|
tests/test_basic.py
|
|
43
43
|
tests/test_de_backends.py
|
|
44
|
+
tests/test_de_edge_cases.py
|
|
44
45
|
tests/test_enrich_api.py
|
|
45
46
|
tests/test_enrich_go.py
|
|
46
47
|
tests/test_pl_coverage.py
|
|
47
48
|
tests/test_pl_extended.py
|
|
48
49
|
tests/test_pp_bias_cli.py
|
|
49
50
|
tests/test_public_api.py
|
|
51
|
+
tests/test_regression_bugs.py
|
|
50
52
|
tests/test_tl_coverage.py
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
11
|
import warnings
|
|
12
|
+
from contextlib import contextmanager
|
|
12
13
|
from importlib.metadata import version
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
@@ -29,6 +30,44 @@ from ._utils import (
|
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
logger = logging.getLogger(__name__)
|
|
33
|
+
logger.addHandler(logging.NullHandler())
|
|
34
|
+
|
|
35
|
+
_DE_REQUIRED_COLS = frozenset({"logFC", "p_val", "p_adj"})
|
|
36
|
+
|
|
37
|
+
# Suppress noisy deprecation/future warnings from scanpy/anndata/PyDESeq2 during DE,
|
|
38
|
+
# but keep convergence/runtime/user warnings visible to callers.
|
|
39
|
+
_DE_SUPPRESS_WARNING_CATEGORIES = (
|
|
40
|
+
DeprecationWarning,
|
|
41
|
+
FutureWarning,
|
|
42
|
+
PendingDeprecationWarning,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@contextmanager
|
|
47
|
+
def _de_warning_context():
|
|
48
|
+
with warnings.catch_warnings():
|
|
49
|
+
for cat in _DE_SUPPRESS_WARNING_CATEGORIES:
|
|
50
|
+
warnings.simplefilter("ignore", category=cat)
|
|
51
|
+
yield
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_de_result(de_df: pd.DataFrame, *, backend: str) -> pd.DataFrame:
|
|
55
|
+
"""Assert all DE backends return the minimal schema expected downstream."""
|
|
56
|
+
missing = _DE_REQUIRED_COLS - set(de_df.columns)
|
|
57
|
+
if missing:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
f"DE backend '{backend}' returned incomplete results; "
|
|
60
|
+
f"missing columns: {sorted(missing)}"
|
|
61
|
+
)
|
|
62
|
+
if len(de_df) == 0:
|
|
63
|
+
return de_df
|
|
64
|
+
for col in _DE_REQUIRED_COLS:
|
|
65
|
+
vals = pd.to_numeric(de_df[col], errors="coerce")
|
|
66
|
+
if not np.isfinite(vals.to_numpy()).any():
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
f"DE backend '{backend}' returned no finite values in column {col!r}."
|
|
69
|
+
)
|
|
70
|
+
return de_df
|
|
32
71
|
|
|
33
72
|
|
|
34
73
|
def _pydeseq2_uses_design_factors() -> bool:
|
|
@@ -94,15 +133,18 @@ def _run_de_wrapper(
|
|
|
94
133
|
if use_mixed_model:
|
|
95
134
|
if sample_col is None:
|
|
96
135
|
raise ValueError("sample_col must be provided when use_mixed_model=True")
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
return _validate_de_result(
|
|
137
|
+
_run_mixedlm_de(
|
|
138
|
+
adata,
|
|
139
|
+
groupby=groupby,
|
|
140
|
+
target_group=target_group,
|
|
141
|
+
reference_group=reference_group,
|
|
142
|
+
sample_col=sample_col,
|
|
143
|
+
n_jobs=n_jobs,
|
|
144
|
+
labels=labels,
|
|
145
|
+
mixed_model_pval=mixed_model_pval,
|
|
146
|
+
),
|
|
147
|
+
backend="mixedlm",
|
|
106
148
|
)
|
|
107
149
|
|
|
108
150
|
if use_memento_de:
|
|
@@ -111,16 +153,19 @@ def _run_de_wrapper(
|
|
|
111
153
|
"use_memento_de=True is not supported with use_pseudobulk=True "
|
|
112
154
|
"(Memento is a cell-level method-of-moments estimator; use PyDESeq2 for pseudobulk)."
|
|
113
155
|
)
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
return _validate_de_result(
|
|
157
|
+
_run_memento_de(
|
|
158
|
+
adata,
|
|
159
|
+
groupby=groupby,
|
|
160
|
+
target_group=target_group,
|
|
161
|
+
reference_group=reference_group,
|
|
162
|
+
labels=labels,
|
|
163
|
+
capture_rate=memento_capture_rate,
|
|
164
|
+
num_boot=memento_num_boot,
|
|
165
|
+
n_cpus=memento_n_cpus,
|
|
166
|
+
counts=counts,
|
|
167
|
+
),
|
|
168
|
+
backend="memento",
|
|
124
169
|
)
|
|
125
170
|
|
|
126
171
|
ad_temp = adata.copy() if labels is not None else adata
|
|
@@ -220,8 +265,7 @@ def _run_de_wrapper(
|
|
|
220
265
|
index=counts_use.index,
|
|
221
266
|
)
|
|
222
267
|
|
|
223
|
-
with
|
|
224
|
-
warnings.simplefilter("ignore")
|
|
268
|
+
with _de_warning_context():
|
|
225
269
|
if _pydeseq2_uses_design_factors():
|
|
226
270
|
dds = DeseqDataSet(
|
|
227
271
|
counts=counts_use,
|
|
@@ -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"].
|
|
263
|
-
de_df["p_val"] = res2.get("pvalue", pd.Series(
|
|
264
|
-
|
|
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
|
|
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"] =
|
|
289
|
-
de_df["p_val"] = de_raw["pvals"].reindex(ad_temp.var_names)
|
|
290
|
-
de_df["p_adj"] = de_raw["pvals_adj"].reindex(ad_temp.var_names)
|
|
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
|
|
358
|
-
if np.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
@@ -26,6 +26,46 @@ from ._utils import (
|
|
|
26
26
|
from ._velocity import _compute_velocity_delta
|
|
27
27
|
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
29
|
+
logger.addHandler(logging.NullHandler())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _compute_perm_velocity_delta(
|
|
33
|
+
*,
|
|
34
|
+
velocity_source: str,
|
|
35
|
+
uns_layer: Any,
|
|
36
|
+
spl_layer: Any,
|
|
37
|
+
t_mask: np.ndarray,
|
|
38
|
+
r_mask: np.ndarray,
|
|
39
|
+
prior_weight: float,
|
|
40
|
+
gamma_method: str,
|
|
41
|
+
eb_prior: dict[str, Any] | None,
|
|
42
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]:
|
|
43
|
+
"""Velocity delta for one permutation replicate.
|
|
44
|
+
|
|
45
|
+
For ``scvelo_moments*`` sources the layers are pre-smoothed Mu/Ms from the main
|
|
46
|
+
analysis (label permutation keeps smoothed values fixed). For heuristic sources
|
|
47
|
+
they are library-size-normalized unspliced/spliced layers.
|
|
48
|
+
"""
|
|
49
|
+
if velocity_source.startswith("scvelo_moments"):
|
|
50
|
+
# Same groupwise ratio estimator as _compute_moments_velocity_delta (on Mu/Ms).
|
|
51
|
+
return _compute_velocity_delta(
|
|
52
|
+
uns_layer,
|
|
53
|
+
spl_layer,
|
|
54
|
+
t_mask,
|
|
55
|
+
r_mask,
|
|
56
|
+
prior_weight,
|
|
57
|
+
gamma_method=gamma_method,
|
|
58
|
+
eb_prior=eb_prior,
|
|
59
|
+
)
|
|
60
|
+
return _compute_velocity_delta(
|
|
61
|
+
uns_layer,
|
|
62
|
+
spl_layer,
|
|
63
|
+
t_mask,
|
|
64
|
+
r_mask,
|
|
65
|
+
prior_weight,
|
|
66
|
+
gamma_method=gamma_method,
|
|
67
|
+
eb_prior=eb_prior,
|
|
68
|
+
)
|
|
29
69
|
|
|
30
70
|
|
|
31
71
|
def _single_permutation_task(
|
|
@@ -60,6 +100,7 @@ def _single_permutation_task(
|
|
|
60
100
|
memento_capture_rate: float = 0.07,
|
|
61
101
|
memento_num_boot: int = 5000,
|
|
62
102
|
memento_n_cpus: int = -1,
|
|
103
|
+
velocity_source: str = "heuristic_global_ratio",
|
|
63
104
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
64
105
|
"""One permutation replicate.
|
|
65
106
|
|
|
@@ -108,12 +149,13 @@ def _single_permutation_task(
|
|
|
108
149
|
|
|
109
150
|
t_mask = shuffled_labels == target_group
|
|
110
151
|
r_mask = shuffled_labels == reference_group
|
|
111
|
-
delta_velocity, _, _gamma_ref, _ =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
152
|
+
delta_velocity, _, _gamma_ref, _ = _compute_perm_velocity_delta(
|
|
153
|
+
velocity_source=velocity_source,
|
|
154
|
+
uns_layer=uns_layer,
|
|
155
|
+
spl_layer=spl_layer,
|
|
156
|
+
t_mask=t_mask,
|
|
157
|
+
r_mask=r_mask,
|
|
158
|
+
prior_weight=prior_weight,
|
|
117
159
|
gamma_method=gamma_method,
|
|
118
160
|
eb_prior=eb_prior,
|
|
119
161
|
)
|
|
@@ -177,6 +219,7 @@ def run_permutation_test(
|
|
|
177
219
|
real_score: np.ndarray,
|
|
178
220
|
real_residual: np.ndarray,
|
|
179
221
|
eb_prior: dict[str, Any] | None = None,
|
|
222
|
+
velocity_source: str = "heuristic_global_ratio",
|
|
180
223
|
bias_correction: str = "huber_length_intron",
|
|
181
224
|
# Memento forwarding for advanced consistent permutation
|
|
182
225
|
use_memento_de: bool = False,
|
|
@@ -224,6 +267,7 @@ def run_permutation_test(
|
|
|
224
267
|
de_preprocess,
|
|
225
268
|
strict_pydeseq2_counts,
|
|
226
269
|
eb_prior,
|
|
270
|
+
velocity_source=velocity_source,
|
|
227
271
|
bias_correction=bias_correction,
|
|
228
272
|
use_memento_de=use_memento_de,
|
|
229
273
|
memento_capture_rate=memento_capture_rate,
|
|
@@ -287,7 +331,7 @@ def run_permutation_test(
|
|
|
287
331
|
# - For very small permutation spaces (n_perm < 100, common in pseudobulk with few samples),
|
|
288
332
|
# we mark use_fdr=False and provide reason so callers can avoid using FDR for significance
|
|
289
333
|
# (p-values become coarse; BH adjustment less reliable). This eliminates vestigial logic in tl.py.
|
|
290
|
-
use_fdr =
|
|
334
|
+
use_fdr = n_success >= 100
|
|
291
335
|
disabled_reason = None if use_fdr else "small_permutation_space"
|
|
292
336
|
|
|
293
337
|
return (
|
|
@@ -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
|
-
|
|
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
|
-
"""
|
|
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
|
|
@@ -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.dev1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 9, 8, 'dev1')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|