scatrans 0.9.8.dev3__tar.gz → 0.9.8.dev5__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.dev3 → scatrans-0.9.8.dev5}/CHANGELOG.md +24 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/PKG-INFO +3 -1
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/README.md +2 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_de.py +70 -32
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_utils.py +123 -9
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_version.py +2 -2
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/enrich.py +35 -14
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/pl.py +7 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/pp_bias.py +15 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/tl.py +50 -7
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_pl_extended.py +6 -0
- scatrans-0.9.8.dev5/tests/test_regression_bugs.py +525 -0
- scatrans-0.9.8.dev3/tests/test_regression_bugs.py +0 -180
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/.github/workflows/ci.yml +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/.github/workflows/publish.yml +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/.gitignore +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/.pre-commit-config.yaml +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/CITATION.cff +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/LICENSE +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/MANIFEST.in +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/conftest.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/conftest_fixtures.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/examples/memento_de_example.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/examples/real_data_template.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/examples/synthetic_active_transcription.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/pyproject.toml +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/scatrans.egg-info/SOURCES.txt +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/setup.cfg +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/__init__.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_permutation.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_velocity.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/DATA_LICENSES.md +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Hs_KEGG_2026.txt +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Mm_KEGG_2026.txt +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Mus_musculus.GRCm39.115_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/README.md +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/human_GRCh38_2024A_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/mouse_2020A_gene_features.parquet +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/generate_gene_features.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/qc.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/__init__.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/conftest.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_basic.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_de_backends.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_de_edge_cases.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_enrich_api.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_enrich_go.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_pl_coverage.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_pp_bias_cli.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_public_api.py +0 -0
- {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_tl_coverage.py +0 -0
|
@@ -5,6 +5,30 @@ 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 bugfix 2026-07-04]
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Pseudobulk `strict_pydeseq2_counts` check ran on already-rounded data**: `_pseudobulk_with_layers`
|
|
12
|
+
always rounds aggregated sums to integers, so the PyDESeq2 count-likeness check in
|
|
13
|
+
`_run_de_wrapper` (which inspected the rounded pseudobulk `.X`) could never detect that the
|
|
14
|
+
underlying per-cell source (e.g. a `pb_x_layer` pointing at log-normalized data) was not raw
|
|
15
|
+
counts. The check now runs on the pre-aggregation, pre-rounding source matrix inside
|
|
16
|
+
`_pseudobulk_with_layers` and the verdict is carried via `adata.uns["pb_x_is_count_like"]` to
|
|
17
|
+
`_run_de_wrapper`, so `strict_pydeseq2_counts=True` correctly rejects non-count pseudobulk input.
|
|
18
|
+
- **`de_preprocess="auto"` false "already log-normalized" detection regressed by real cell
|
|
19
|
+
heterogeneity**: the per-cell library-size CV heuristic added to detect raw counts when
|
|
20
|
+
`uns['log1p']` is missing (e.g. after `anndata.concat()`, which drops `.uns`) could misfire on
|
|
21
|
+
correctly log-normalized data from heterogeneous cell populations (different cell types/states
|
|
22
|
+
varying in expression breadth inflate per-cell CV even post-log1p), triggering a spurious
|
|
23
|
+
second `normalize_total` + `log1p` pass that systematically compresses effect sizes. Added a
|
|
24
|
+
cross-gene mean-variance dispersion check (`_x_gene_dispersion_looks_raw`), which is not
|
|
25
|
+
confounded by cell-level heterogeneity, and now require it to corroborate the library-size CV
|
|
26
|
+
signal before concluding data still needs normalization.
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
- README: noted that `anndata.concat()` drops `uns['log1p']` and recommends re-setting the marker
|
|
30
|
+
or passing `de_preprocess="none"` explicitly after combining pre-normalized samples.
|
|
31
|
+
|
|
8
32
|
## [0.9.8] - 2026-07-03
|
|
9
33
|
|
|
10
34
|
### Changed (user-visible default behavior)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scatrans
|
|
3
|
-
Version: 0.9.8.
|
|
3
|
+
Version: 0.9.8.dev5
|
|
4
4
|
Summary: Single-cell Active Transcription Analysis
|
|
5
5
|
Author: scATrans Developers
|
|
6
6
|
License: Apache-2.0
|
|
@@ -1232,6 +1232,8 @@ adata, de_res = scat.differential_expression(adata, use_memento_de=True, ...)
|
|
|
1232
1232
|
|
|
1233
1233
|
`ensure_raw_counts()` will also try to recover from `adata.raw`. The functions emit clear warnings when they detect this situation.
|
|
1234
1234
|
|
|
1235
|
+
**Note on `anndata.concat()` and `de_preprocess="auto"`:** `ad.concat()` drops `.uns` by default, including the `uns['log1p']` marker that scATrans uses to detect already-log-normalized data. This is common when combining multiple samples for a case-vs-control comparison — each sample may be correctly `normalize_total` + `log1p`'d before concatenation, but the marker is gone afterward. `de_preprocess="auto"` still guards against double-log1p in this case via heuristics on `.X`, but for certainty, either re-set the marker after concatenating (`combined.uns["log1p"] = {"base": None}`) or pass `de_preprocess="none"` explicitly when you know `.X` is already log-normalized.
|
|
1236
|
+
|
|
1235
1237
|
---
|
|
1236
1238
|
|
|
1237
1239
|
## Limitations
|
|
@@ -1181,6 +1181,8 @@ adata, de_res = scat.differential_expression(adata, use_memento_de=True, ...)
|
|
|
1181
1181
|
|
|
1182
1182
|
`ensure_raw_counts()` will also try to recover from `adata.raw`. The functions emit clear warnings when they detect this situation.
|
|
1183
1183
|
|
|
1184
|
+
**Note on `anndata.concat()` and `de_preprocess="auto"`:** `ad.concat()` drops `.uns` by default, including the `uns['log1p']` marker that scATrans uses to detect already-log-normalized data. This is common when combining multiple samples for a case-vs-control comparison — each sample may be correctly `normalize_total` + `log1p`'d before concatenation, but the marker is gone afterward. `de_preprocess="auto"` still guards against double-log1p in this case via heuristics on `.X`, but for certainty, either re-set the marker after concatenating (`combined.uns["log1p"] = {"base": None}`) or pass `de_preprocess="none"` explicitly when you know `.X` is already log-normalized.
|
|
1185
|
+
|
|
1184
1186
|
---
|
|
1185
1187
|
|
|
1186
1188
|
## Limitations
|
|
@@ -125,7 +125,7 @@ def _run_de_wrapper(
|
|
|
125
125
|
This is treated as a third parallel cell-level backend (alongside scanpy-style and mixed-model).
|
|
126
126
|
|
|
127
127
|
logFC is normalized toward log2 scale for cross-backend comparability of logfc_cutoff:
|
|
128
|
-
- PyDESeq2 + scanpy backends (wilcoxon, t-test,
|
|
128
|
+
- PyDESeq2 + scanpy backends (wilcoxon, t-test, t-test_overestim_var): native log2
|
|
129
129
|
- mixedlm + memento: converted from natural/log1p scale (/ log(2))
|
|
130
130
|
|
|
131
131
|
Internal function: type hints strengthened for mypy/pyright.
|
|
@@ -185,18 +185,6 @@ def _run_de_wrapper(
|
|
|
185
185
|
_ann_log.setLevel(_prev_ann)
|
|
186
186
|
|
|
187
187
|
if is_pseudobulk and pb_backend == "pydeseq2":
|
|
188
|
-
try:
|
|
189
|
-
from pydeseq2.dds import DeseqDataSet
|
|
190
|
-
from pydeseq2.ds import DeseqStats
|
|
191
|
-
except ImportError as e:
|
|
192
|
-
raise ImportError(
|
|
193
|
-
"PyDESeq2 backend requested but 'pydeseq2' is not installed.\n"
|
|
194
|
-
"Install with:\n"
|
|
195
|
-
' pip install "scatrans[pseudobulk]"\n'
|
|
196
|
-
"or\n"
|
|
197
|
-
" pip install pydeseq2"
|
|
198
|
-
) from e
|
|
199
|
-
|
|
200
188
|
n_t = (ad_temp.obs[use_groupby] == target_group).sum()
|
|
201
189
|
n_r = (ad_temp.obs[use_groupby] == reference_group).sum()
|
|
202
190
|
if n_t < 2 or n_r < 2:
|
|
@@ -208,17 +196,26 @@ def _run_de_wrapper(
|
|
|
208
196
|
# but are integer-valued. Use a tolerant check + pre-coercion so users don't have to set
|
|
209
197
|
# strict_pydeseq2_counts=False for normal aggregated data.
|
|
210
198
|
if is_pseudobulk:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
199
|
+
if "pb_x_is_count_like" in ad_temp.uns:
|
|
200
|
+
# Authoritative verdict computed by _pseudobulk_with_layers on the
|
|
201
|
+
# pre-aggregation, pre-rounding source matrix. Summing+rounding after
|
|
202
|
+
# aggregation always yields integer-valued output, so checking ad_temp.X
|
|
203
|
+
# here (post-rounding) would always pass and defeat strict_pydeseq2_counts.
|
|
204
|
+
is_count_like = bool(ad_temp.uns["pb_x_is_count_like"])
|
|
205
|
+
else:
|
|
206
|
+
# Fallback for pseudobulk AnnData not built via _pseudobulk_with_layers
|
|
207
|
+
# (e.g. user-supplied pseudobulk). No pre-aggregation matrix is available,
|
|
208
|
+
# so we check the (already rounded) X as a best effort.
|
|
209
|
+
try:
|
|
210
|
+
if sparse.issparse(ad_temp.X):
|
|
211
|
+
X_check_arr = np.asarray(ad_temp.X.todense())
|
|
212
|
+
else:
|
|
213
|
+
X_check_arr = np.asarray(ad_temp.X)
|
|
214
|
+
X_check_arr = np.clip(np.nan_to_num(X_check_arr), 0, None)
|
|
215
|
+
is_count_like = _is_integer_counts_like(X_check_arr)
|
|
216
|
+
except Exception as _e:
|
|
217
|
+
logger.debug("Count-like check fallback: %s", _e)
|
|
218
|
+
is_count_like = _is_integer_counts_like(ad_temp.X)
|
|
222
219
|
else:
|
|
223
220
|
is_count_like = _is_integer_counts_like(ad_temp.X)
|
|
224
221
|
|
|
@@ -259,6 +256,18 @@ def _run_de_wrapper(
|
|
|
259
256
|
f"No genes passed the DESeq2 count filter (sum(counts) >= {min_counts_per_gene})."
|
|
260
257
|
)
|
|
261
258
|
|
|
259
|
+
try:
|
|
260
|
+
from pydeseq2.dds import DeseqDataSet
|
|
261
|
+
from pydeseq2.ds import DeseqStats
|
|
262
|
+
except ImportError as e:
|
|
263
|
+
raise ImportError(
|
|
264
|
+
"PyDESeq2 backend requested but 'pydeseq2' is not installed.\n"
|
|
265
|
+
"Install with:\n"
|
|
266
|
+
' pip install "scatrans[pseudobulk]"\n'
|
|
267
|
+
"or\n"
|
|
268
|
+
" pip install pydeseq2"
|
|
269
|
+
) from e
|
|
270
|
+
|
|
262
271
|
condition = ad_temp.obs[use_groupby].astype(str).values
|
|
263
272
|
metadata = pd.DataFrame(
|
|
264
273
|
{use_groupby: pd.Categorical(condition, categories=[reference_group, target_group])},
|
|
@@ -341,16 +350,45 @@ def _run_de_wrapper(
|
|
|
341
350
|
|
|
342
351
|
else:
|
|
343
352
|
# Standard scanpy path (works for both regular and pseudobulk when not using pydeseq2)
|
|
353
|
+
if de_method == "logreg":
|
|
354
|
+
raise ValueError(
|
|
355
|
+
"de_method='logreg' is not supported: scanpy's logreg ranks genes by logistic "
|
|
356
|
+
"regression scores and does not produce logFC, p-values, or adjusted p-values. "
|
|
357
|
+
"Use 'wilcoxon', 't-test', or 't-test_overestim_var' instead."
|
|
358
|
+
)
|
|
359
|
+
labels = ad_temp.obs[use_groupby].astype(str)
|
|
360
|
+
n_target = int((labels == str(target_group)).sum())
|
|
361
|
+
n_reference = int((labels == str(reference_group)).sum())
|
|
362
|
+
if n_target < 2 or n_reference < 2:
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"scanpy DE (method='{de_method}') requires at least 2 cells per group. "
|
|
365
|
+
f"Found {n_target} in target '{target_group}' and {n_reference} in reference "
|
|
366
|
+
f"'{reference_group}'. With a single cell per group, no valid statistics can be "
|
|
367
|
+
f"computed. Consider pseudobulk aggregation (use_pseudobulk=True with sample_col) "
|
|
368
|
+
f"if you have multiple biological replicates per condition."
|
|
369
|
+
)
|
|
344
370
|
rank_key = "_scatrans_rank_genes_groups"
|
|
345
371
|
with _de_warning_context():
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
372
|
+
try:
|
|
373
|
+
sc.tl.rank_genes_groups(
|
|
374
|
+
ad_temp,
|
|
375
|
+
groupby=use_groupby,
|
|
376
|
+
groups=[target_group],
|
|
377
|
+
reference=reference_group,
|
|
378
|
+
method=de_method,
|
|
379
|
+
key_added=rank_key,
|
|
380
|
+
)
|
|
381
|
+
except Exception as exc:
|
|
382
|
+
msg = str(exc)
|
|
383
|
+
if "only contain one sample" in msg or "one sample" in msg.lower():
|
|
384
|
+
raise ValueError(
|
|
385
|
+
f"scanpy could not compute DE statistics: each group needs at least 2 "
|
|
386
|
+
f"cells (found {n_target} target, {n_reference} reference for "
|
|
387
|
+
f"'{use_groupby}'). For single-cell-per-group designs, use pseudobulk "
|
|
388
|
+
f"with biological replicates (use_pseudobulk=True, sample_col=...) or "
|
|
389
|
+
f"add more cells per group."
|
|
390
|
+
) from exc
|
|
391
|
+
raise
|
|
354
392
|
de_raw = sc.get.rank_genes_groups_df(ad_temp, group=target_group, key=rank_key).set_index(
|
|
355
393
|
"names"
|
|
356
394
|
)
|
|
@@ -143,6 +143,7 @@ __all__ = [
|
|
|
143
143
|
"_resolve_results_column",
|
|
144
144
|
"_write_unspliced_excess_columns",
|
|
145
145
|
"_is_integer_counts_like",
|
|
146
|
+
"_warn_if_negative_layer_values",
|
|
146
147
|
"_warn_if_not_integer_counts_matrix",
|
|
147
148
|
"_warn_if_low_counts_matrix",
|
|
148
149
|
"_safe_add_matrices",
|
|
@@ -161,6 +162,30 @@ __all__ = [
|
|
|
161
162
|
]
|
|
162
163
|
|
|
163
164
|
|
|
165
|
+
def _warn_if_negative_layer_values(layer: Any, layer_name: str, *, max_check: int = 100000) -> None:
|
|
166
|
+
"""Warn when a count layer contains negative values (physically invalid for RNA counts)."""
|
|
167
|
+
if sparse.issparse(layer):
|
|
168
|
+
vals = np.asarray(layer.data, dtype=float)
|
|
169
|
+
else:
|
|
170
|
+
vals = np.asarray(layer, dtype=float).ravel()
|
|
171
|
+
vals = vals[np.isfinite(vals)]
|
|
172
|
+
if vals.size == 0:
|
|
173
|
+
return
|
|
174
|
+
if vals.size > max_check:
|
|
175
|
+
rng = np.random.default_rng(0)
|
|
176
|
+
vals = rng.choice(vals, size=max_check, replace=False)
|
|
177
|
+
if np.any(vals < 0):
|
|
178
|
+
n_neg = int(np.sum(vals < 0))
|
|
179
|
+
logger.warning(
|
|
180
|
+
"Layer '%s' contains %d negative value(s) (min=%.4g). "
|
|
181
|
+
"RNA count layers should be non-negative; results may be unreliable. "
|
|
182
|
+
"Check for data corruption or incorrect layer assignment.",
|
|
183
|
+
layer_name,
|
|
184
|
+
n_neg,
|
|
185
|
+
float(np.min(vals)),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
164
189
|
def _is_integer_counts_like(X: Any, max_check: int = 100000, atol: float = 1e-6) -> bool:
|
|
165
190
|
"""Return True if the matrix contains non-negative values that are integer-valued
|
|
166
191
|
(within tolerance). This is tolerant of float64 summed counts that are exactly
|
|
@@ -223,7 +248,74 @@ def _x_looks_zscore_scaled(finite: np.ndarray) -> bool:
|
|
|
223
248
|
return abs(mean) < 1.0 and 0.25 < std < 5.0 and mn < -0.1
|
|
224
249
|
|
|
225
250
|
|
|
226
|
-
def
|
|
251
|
+
def _x_carries_library_size_signal(X: Any, *, min_cv: float = 0.12, max_cells: int = 5000) -> bool:
|
|
252
|
+
"""Return True when per-cell library sizes show meaningful sequencing-depth variation.
|
|
253
|
+
|
|
254
|
+
Raw counts (including kallisto/salmon decimal UMIs) retain depth signal in row sums;
|
|
255
|
+
normalize_total+log1p data does not. Used to avoid false 'already log-normalized'
|
|
256
|
+
detection on small-magnitude decimal count matrices.
|
|
257
|
+
"""
|
|
258
|
+
if sparse.issparse(X):
|
|
259
|
+
lib = np.asarray(X.sum(axis=1)).ravel(dtype=float)
|
|
260
|
+
else:
|
|
261
|
+
lib = np.asarray(X, dtype=float).sum(axis=1)
|
|
262
|
+
lib = lib[np.isfinite(lib)]
|
|
263
|
+
if lib.size < 2:
|
|
264
|
+
return False
|
|
265
|
+
if lib.size > max_cells:
|
|
266
|
+
rng = np.random.default_rng(0)
|
|
267
|
+
lib = rng.choice(lib, size=max_cells, replace=False)
|
|
268
|
+
mean = float(np.mean(lib))
|
|
269
|
+
if mean <= 0:
|
|
270
|
+
return False
|
|
271
|
+
cv = float(np.std(lib) / mean)
|
|
272
|
+
return cv >= min_cv
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _x_gene_dispersion_looks_raw(
|
|
276
|
+
X: Any, *, slope_threshold: float = 1.0, min_genes: int = 20, max_cells: int = 5000
|
|
277
|
+
) -> bool:
|
|
278
|
+
"""Return True when the cross-gene mean-variance relationship looks like raw counts.
|
|
279
|
+
|
|
280
|
+
Raw RNA-seq counts (Poisson/NB) have variance that grows roughly linearly-to-quadratically
|
|
281
|
+
with the mean across genes (log-log slope >= ~1). normalize_total+log1p compresses this
|
|
282
|
+
relationship to a much flatter slope. Unlike per-cell library-size CV
|
|
283
|
+
(``_x_carries_library_size_signal``), this is a cross-gene statistic and is not confounded
|
|
284
|
+
by real biological heterogeneity between cells/cell types, which can inflate per-cell
|
|
285
|
+
library-size CV even in properly log-normalized data (e.g. after ``anndata.concat`` drops
|
|
286
|
+
the ``uns['log1p']`` marker). Returns False (conservatively "not raw-looking") when there
|
|
287
|
+
are too few informative genes to estimate the relationship reliably.
|
|
288
|
+
"""
|
|
289
|
+
if sparse.issparse(X):
|
|
290
|
+
n_cells = X.shape[0]
|
|
291
|
+
if n_cells > max_cells:
|
|
292
|
+
rng = np.random.default_rng(0)
|
|
293
|
+
idx = rng.choice(n_cells, size=max_cells, replace=False)
|
|
294
|
+
X = X[idx]
|
|
295
|
+
mean = np.asarray(X.mean(axis=0)).ravel()
|
|
296
|
+
mean_sq = np.asarray(X.multiply(X).mean(axis=0)).ravel()
|
|
297
|
+
else:
|
|
298
|
+
arr = np.asarray(X, dtype=float)
|
|
299
|
+
if arr.shape[0] > max_cells:
|
|
300
|
+
rng = np.random.default_rng(0)
|
|
301
|
+
idx = rng.choice(arr.shape[0], size=max_cells, replace=False)
|
|
302
|
+
arr = arr[idx]
|
|
303
|
+
mean = arr.mean(axis=0)
|
|
304
|
+
mean_sq = (arr**2).mean(axis=0)
|
|
305
|
+
var = np.clip(mean_sq - mean**2, 0, None)
|
|
306
|
+
|
|
307
|
+
mask = np.isfinite(mean) & np.isfinite(var) & (mean > 0.05) & (var > 0)
|
|
308
|
+
if int(mask.sum()) < min_genes:
|
|
309
|
+
return False
|
|
310
|
+
log_mean = np.log(mean[mask])
|
|
311
|
+
log_var = np.log(var[mask])
|
|
312
|
+
slope = float(np.polyfit(log_mean, log_var, 1)[0])
|
|
313
|
+
return slope >= slope_threshold
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _x_looks_log_normalized(
|
|
317
|
+
X: Any, *, max_check: int = 100000, has_log1p_marker: bool = False
|
|
318
|
+
) -> bool:
|
|
227
319
|
"""Return True when *X* is unlikely to be raw integer counts needing normalize+log1p."""
|
|
228
320
|
if _is_integer_counts_like(X, max_check=max_check):
|
|
229
321
|
return False
|
|
@@ -235,7 +327,18 @@ def _x_looks_log_normalized(X: Any, *, max_check: int = 100000) -> bool:
|
|
|
235
327
|
return False
|
|
236
328
|
mx = float(np.nanmax(finite))
|
|
237
329
|
has_neg = bool(np.any(finite < 0))
|
|
238
|
-
|
|
330
|
+
if has_neg:
|
|
331
|
+
return True
|
|
332
|
+
if has_log1p_marker and mx <= 20.0:
|
|
333
|
+
return True
|
|
334
|
+
if mx > 20.0:
|
|
335
|
+
return False
|
|
336
|
+
# Per-cell library-size CV alone is unreliable: real biological heterogeneity (different
|
|
337
|
+
# cell types/states with different expression breadth) can produce high CV even in data
|
|
338
|
+
# that is already correctly log-normalized, most commonly after anndata.concat() drops the
|
|
339
|
+
# uns['log1p'] marker. Require corroboration from the cross-gene mean-variance dispersion
|
|
340
|
+
# check (robust to cell-level heterogeneity) before concluding "still needs normalization".
|
|
341
|
+
return not (_x_carries_library_size_signal(X) and _x_gene_dispersion_looks_raw(X))
|
|
239
342
|
|
|
240
343
|
|
|
241
344
|
def _clear_log_preprocess_metadata(adata: ad.AnnData) -> None:
|
|
@@ -286,7 +389,7 @@ def _reconcile_log1p_marker(adata: ad.AnnData) -> bool:
|
|
|
286
389
|
arr = _dense_expression_matrix(adata.X)
|
|
287
390
|
finite = arr[np.isfinite(arr)]
|
|
288
391
|
x_is_scaled = _x_looks_zscore_scaled(finite) if finite.size else False
|
|
289
|
-
x_is_log = _x_looks_log_normalized(adata.X)
|
|
392
|
+
x_is_log = _x_looks_log_normalized(adata.X, has_log1p_marker=has_marker)
|
|
290
393
|
|
|
291
394
|
if has_marker and x_is_scaled:
|
|
292
395
|
_clear_log_preprocess_metadata(adata)
|
|
@@ -308,9 +411,10 @@ def _reconcile_log1p_marker(adata: ad.AnnData) -> bool:
|
|
|
308
411
|
|
|
309
412
|
if x_is_log:
|
|
310
413
|
if not has_marker:
|
|
311
|
-
logger.
|
|
312
|
-
"DE preprocess: .X appears log-normalized without uns['log1p']; "
|
|
313
|
-
"skipping re-normalization."
|
|
414
|
+
logger.warning(
|
|
415
|
+
"DE preprocess: .X appears log-normalized without uns['log1p'] metadata; "
|
|
416
|
+
"skipping re-normalization. If this is raw decimal counts (e.g. kallisto/salmon "
|
|
417
|
+
"UMIs), pass de_preprocess='normalize_log1p' explicitly."
|
|
314
418
|
)
|
|
315
419
|
return True
|
|
316
420
|
|
|
@@ -595,6 +699,11 @@ def _pseudobulk_with_layers(
|
|
|
595
699
|
X_source = adata.X if x_layer is None else adata.layers[x_layer]
|
|
596
700
|
x_source_name = "adata.X" if x_layer is None else f"layer '{x_layer}'"
|
|
597
701
|
|
|
702
|
+
# Check count-likeness on the pre-aggregation, pre-rounding source matrix.
|
|
703
|
+
# Summing+rounding below always produces integer-valued output, so checking
|
|
704
|
+
# after aggregation would always pass and defeat strict_pydeseq2_counts.
|
|
705
|
+
x_source_is_count_like = _is_integer_counts_like(X_source)
|
|
706
|
+
|
|
598
707
|
valid_mask = adata.obs[sample_col].notna() & adata.obs[groupby].notna()
|
|
599
708
|
if not valid_mask.any():
|
|
600
709
|
raise ValueError(
|
|
@@ -679,6 +788,10 @@ def _pseudobulk_with_layers(
|
|
|
679
788
|
for layer in layers:
|
|
680
789
|
adata_pb.layers[layer] = sparse.vstack(layer_rows[layer]).tocsr()
|
|
681
790
|
adata_pb.obs_names_make_unique()
|
|
791
|
+
# Carry the pre-aggregation count-likeness verdict so downstream consumers
|
|
792
|
+
# (e.g. PyDESeq2 strict_pydeseq2_counts check) don't re-check the already-rounded X.
|
|
793
|
+
adata_pb.uns["pb_x_is_count_like"] = bool(x_source_is_count_like)
|
|
794
|
+
adata_pb.uns["pb_x_source_desc"] = x_source_name
|
|
682
795
|
return adata_pb
|
|
683
796
|
|
|
684
797
|
|
|
@@ -721,10 +834,10 @@ def _fit_huber_bias_correction(
|
|
|
721
834
|
the basic analysis clean for users who do not want the correction.
|
|
722
835
|
|
|
723
836
|
Returns (residual, bias_info_dict) where bias_info contains:
|
|
724
|
-
- "bias_corrected": bool
|
|
837
|
+
- "bias_corrected": bool — True only when Huber length+intron regression succeeded
|
|
725
838
|
- "method": the effective method used ("huber_length_intron" or "none")
|
|
726
839
|
- "n_genes_used_for_fit": int
|
|
727
|
-
- "fallback_to_median": bool
|
|
840
|
+
- "fallback_to_median": bool — median centering used instead of Huber regression
|
|
728
841
|
- "coef_gene_length", "coef_intron_number" (if regression succeeded)
|
|
729
842
|
- "intercept" (if available)
|
|
730
843
|
"""
|
|
@@ -790,7 +903,8 @@ def _fit_huber_bias_correction(
|
|
|
790
903
|
if not regression_succeeded and valid_expr.sum() > 0:
|
|
791
904
|
residual[valid_expr] = delta_velocity[valid_expr] - np.nanmedian(delta_velocity[valid_expr])
|
|
792
905
|
bias_info["fallback_to_median"] = True
|
|
793
|
-
|
|
906
|
+
# Median centering is a safe fallback but is not length/intron Huber correction.
|
|
907
|
+
bias_info["bias_corrected"] = False
|
|
794
908
|
|
|
795
909
|
residual[~valid_expr] = 0.0
|
|
796
910
|
return residual, bias_info
|
|
@@ -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.dev5'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 9, 8, 'dev5')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -366,23 +366,25 @@ def _warn_user(msg: str) -> None:
|
|
|
366
366
|
|
|
367
367
|
|
|
368
368
|
def _resolve_enrichment_padj_cutoff(
|
|
369
|
-
pval_cutoff: float,
|
|
369
|
+
pval_cutoff: float | None,
|
|
370
370
|
padj_cutoff: float | None,
|
|
371
371
|
) -> float:
|
|
372
372
|
"""Resolve the effective adjusted-p cutoff; warn on legacy ``pval_cutoff`` usage."""
|
|
373
373
|
if padj_cutoff is not None:
|
|
374
374
|
cutoff = float(padj_cutoff)
|
|
375
|
-
if pval_cutoff
|
|
375
|
+
if pval_cutoff is not None:
|
|
376
376
|
_warn_user(
|
|
377
377
|
"Both pval_cutoff and padj_cutoff were provided. "
|
|
378
378
|
"Using padj_cutoff; pval_cutoff is ignored."
|
|
379
379
|
)
|
|
380
380
|
return cutoff
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
381
|
+
if pval_cutoff is not None:
|
|
382
|
+
_warn_user(
|
|
383
|
+
"`pval_cutoff` is deprecated (it applies to *adjusted* p-values, not raw p-values). "
|
|
384
|
+
"Please use `padj_cutoff` instead for clarity."
|
|
385
|
+
)
|
|
386
|
+
return float(pval_cutoff)
|
|
387
|
+
return 0.05
|
|
386
388
|
|
|
387
389
|
|
|
388
390
|
def _apply_gene_case(genes: Iterable[Any], gene_case: str | None) -> list[str]:
|
|
@@ -659,7 +661,7 @@ def run_enrichment(
|
|
|
659
661
|
background: Iterable[Any] | None = None,
|
|
660
662
|
adata: Any
|
|
661
663
|
| None = None, # NEW: if provided and no explicit universe, we try to use the preserved raw_gene_list
|
|
662
|
-
pval_cutoff: float =
|
|
664
|
+
pval_cutoff: float | None = None,
|
|
663
665
|
padj_cutoff: float | None = None,
|
|
664
666
|
min_size: int = 5,
|
|
665
667
|
max_size: int = 500,
|
|
@@ -1071,7 +1073,7 @@ def run_kegg(
|
|
|
1071
1073
|
universe: Iterable[Any] | None = None,
|
|
1072
1074
|
background: Iterable[Any] | None = None,
|
|
1073
1075
|
adata: Any | None = None, # forwarded to run_enrichment for smart universe default
|
|
1074
|
-
pval_cutoff: float =
|
|
1076
|
+
pval_cutoff: float | None = None,
|
|
1075
1077
|
padj_cutoff: float | None = None,
|
|
1076
1078
|
min_size: int = 5,
|
|
1077
1079
|
max_size: int = 500,
|
|
@@ -1152,7 +1154,7 @@ def run_go(
|
|
|
1152
1154
|
universe: Iterable[Any] | None = None,
|
|
1153
1155
|
background: Iterable[Any] | None = None,
|
|
1154
1156
|
adata: Any | None = None,
|
|
1155
|
-
pval_cutoff: float =
|
|
1157
|
+
pval_cutoff: float | None = None,
|
|
1156
1158
|
padj_cutoff: float | None = None,
|
|
1157
1159
|
min_size: int = 5,
|
|
1158
1160
|
max_size: int = 500,
|
|
@@ -1279,7 +1281,7 @@ def run_go(
|
|
|
1279
1281
|
# "ALL" case: run three and concat
|
|
1280
1282
|
frames = []
|
|
1281
1283
|
per_ontology_attrs: dict[str, dict] = {}
|
|
1282
|
-
eff_cut =
|
|
1284
|
+
eff_cut = _resolve_enrichment_padj_cutoff(pval_cutoff, padj_cutoff)
|
|
1283
1285
|
|
|
1284
1286
|
for o in ont_list:
|
|
1285
1287
|
gs_name = ont_map[o]
|
|
@@ -1961,6 +1963,25 @@ def expand_enrichment_genes(res: pd.DataFrame) -> pd.DataFrame:
|
|
|
1961
1963
|
base_cols = ["Ontology"] + base_cols
|
|
1962
1964
|
return pd.DataFrame(columns=base_cols)
|
|
1963
1965
|
|
|
1966
|
+
base_cols = [
|
|
1967
|
+
"Term",
|
|
1968
|
+
"Description",
|
|
1969
|
+
"Gene",
|
|
1970
|
+
"Count",
|
|
1971
|
+
"GeneRatio",
|
|
1972
|
+
"GeneRatio_str",
|
|
1973
|
+
"BgRatio",
|
|
1974
|
+
"BgRatio_str",
|
|
1975
|
+
"FoldEnrichment",
|
|
1976
|
+
"RichFactor",
|
|
1977
|
+
"Overlap",
|
|
1978
|
+
"pvalue",
|
|
1979
|
+
"p.adjust",
|
|
1980
|
+
"TermSize",
|
|
1981
|
+
]
|
|
1982
|
+
if has_ontology:
|
|
1983
|
+
base_cols = ["Ontology"] + base_cols
|
|
1984
|
+
|
|
1964
1985
|
rows = []
|
|
1965
1986
|
for _, row in res.iterrows():
|
|
1966
1987
|
genes_str = str(row.get("Genes", "") or "")
|
|
@@ -1994,7 +2015,7 @@ def expand_enrichment_genes(res: pd.DataFrame) -> pd.DataFrame:
|
|
|
1994
2015
|
)
|
|
1995
2016
|
rows.append(rec)
|
|
1996
2017
|
|
|
1997
|
-
df = pd.DataFrame(rows)
|
|
2018
|
+
df = pd.DataFrame(rows, columns=base_cols) if not rows else pd.DataFrame(rows)
|
|
1998
2019
|
# Ensure Ontology first if present
|
|
1999
2020
|
if "Ontology" in df.columns:
|
|
2000
2021
|
cols = ["Ontology"] + [c for c in df.columns if c != "Ontology"]
|
|
@@ -2738,7 +2759,7 @@ def compare_enrichment(
|
|
|
2738
2759
|
gene_clusters: Mapping[str, Iterable[Any]],
|
|
2739
2760
|
*,
|
|
2740
2761
|
fun: Callable | None = None,
|
|
2741
|
-
pval_cutoff: float =
|
|
2762
|
+
pval_cutoff: float | None = None,
|
|
2742
2763
|
padj_cutoff: float | None = None,
|
|
2743
2764
|
min_size: int = 5,
|
|
2744
2765
|
max_size: int = 500,
|
|
@@ -2818,7 +2839,7 @@ def compare_enrichment(
|
|
|
2818
2839
|
"For best cross-cluster comparability, pass the same adata (after store_raw_counts) or explicit universe."
|
|
2819
2840
|
)
|
|
2820
2841
|
|
|
2821
|
-
eff_cut =
|
|
2842
|
+
eff_cut = _resolve_enrichment_padj_cutoff(pval_cutoff, padj_cutoff)
|
|
2822
2843
|
eff_return_all = return_all
|
|
2823
2844
|
if adjust_across_clusters:
|
|
2824
2845
|
if not return_all:
|
|
@@ -1885,6 +1885,13 @@ def gseaplot(
|
|
|
1885
1885
|
pheno_pos, pheno_neg : labels for phenotype (shown in plot).
|
|
1886
1886
|
"""
|
|
1887
1887
|
with _style_context_if(use_style):
|
|
1888
|
+
if isinstance(ranked_genes, pd.DataFrame):
|
|
1889
|
+
raise TypeError(
|
|
1890
|
+
"ranked_genes must be a one-dimensional pd.Series, dict, or gene list, "
|
|
1891
|
+
f"but received a DataFrame with shape {ranked_genes.shape}. "
|
|
1892
|
+
"Did you mean to pass the run_gsea() result as the second argument "
|
|
1893
|
+
"(gsea_result=...) instead of the first positional argument?"
|
|
1894
|
+
)
|
|
1888
1895
|
# Normalize ranked input to Series
|
|
1889
1896
|
if isinstance(ranked_genes, (list, tuple)):
|
|
1890
1897
|
# assume order high->low, make scores
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import logging
|
|
17
17
|
import sys
|
|
18
|
+
import warnings
|
|
18
19
|
from collections.abc import Iterator
|
|
19
20
|
from contextlib import contextmanager
|
|
20
21
|
from pathlib import Path
|
|
@@ -257,6 +258,7 @@ def generate_gene_features_from_gtf(
|
|
|
257
258
|
)
|
|
258
259
|
|
|
259
260
|
n_before_names = len(gene_df)
|
|
261
|
+
gene_df["gene_name"] = gene_df["gene_name"].replace("", np.nan)
|
|
260
262
|
gene_df = gene_df.dropna(subset=["gene_name"])
|
|
261
263
|
n_missing_names = n_before_names - len(gene_df)
|
|
262
264
|
if n_missing_names:
|
|
@@ -410,4 +412,17 @@ def add_gene_features(
|
|
|
410
412
|
valid_count = int(adata.var["gene_length"].notna().sum())
|
|
411
413
|
logger.info("Successfully mapped features for %d out of %d genes.", valid_count, adata.n_vars)
|
|
412
414
|
|
|
415
|
+
mapping_rate = valid_count / max(int(adata.n_vars), 1)
|
|
416
|
+
if mapping_rate < 0.2:
|
|
417
|
+
example_input = [str(g) for g in adata.var_names[:5]]
|
|
418
|
+
example_feat = [str(g) for g in gf.index[:5]]
|
|
419
|
+
msg = (
|
|
420
|
+
f"Low gene feature mapping rate ({mapping_rate:.1%}). "
|
|
421
|
+
f"Input examples: {example_input}; feature table examples: {example_feat}. "
|
|
422
|
+
"Check gene symbol casing (e.g. ACTB vs actb), organism, and whether "
|
|
423
|
+
"adata.var_names match the annotation used to build the feature table."
|
|
424
|
+
)
|
|
425
|
+
warnings.warn(msg, UserWarning, stacklevel=2)
|
|
426
|
+
logger.warning(msg)
|
|
427
|
+
|
|
413
428
|
return adata
|