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.
Files changed (53) hide show
  1. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/CHANGELOG.md +24 -0
  2. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/PKG-INFO +3 -1
  3. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/README.md +2 -0
  4. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_de.py +70 -32
  5. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_utils.py +123 -9
  6. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_version.py +2 -2
  7. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/enrich.py +35 -14
  8. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/pl.py +7 -0
  9. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/pp_bias.py +15 -0
  10. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/tl.py +50 -7
  11. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_pl_extended.py +6 -0
  12. scatrans-0.9.8.dev5/tests/test_regression_bugs.py +525 -0
  13. scatrans-0.9.8.dev3/tests/test_regression_bugs.py +0 -180
  14. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/.github/workflows/ci.yml +0 -0
  15. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/.github/workflows/publish.yml +0 -0
  16. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/.gitignore +0 -0
  17. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/.pre-commit-config.yaml +0 -0
  18. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/CITATION.cff +0 -0
  19. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/LICENSE +0 -0
  20. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/MANIFEST.in +0 -0
  21. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/conftest.py +0 -0
  22. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/conftest_fixtures.py +0 -0
  23. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/examples/memento_de_example.py +0 -0
  24. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/examples/real_data_template.py +0 -0
  25. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/examples/synthetic_active_transcription.py +0 -0
  26. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/pyproject.toml +0 -0
  27. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/scatrans.egg-info/SOURCES.txt +0 -0
  28. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/setup.cfg +0 -0
  29. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/__init__.py +0 -0
  30. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_permutation.py +0 -0
  31. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/_velocity.py +0 -0
  32. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/DATA_LICENSES.md +0 -0
  33. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Hs_GO_Biological_Process_2026.txt +0 -0
  34. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Hs_KEGG_2026.txt +0 -0
  35. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Mm_GO_Biological_Process_2026.txt +0 -0
  36. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Mm_KEGG_2026.txt +0 -0
  37. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/Mus_musculus.GRCm39.115_gene_features.parquet +0 -0
  38. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/README.md +0 -0
  39. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/human_GRCh38_2024A_gene_features.parquet +0 -0
  40. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/data/mouse_2020A_gene_features.parquet +0 -0
  41. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/generate_gene_features.py +0 -0
  42. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/src/scatrans/qc.py +0 -0
  43. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/__init__.py +0 -0
  44. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/conftest.py +0 -0
  45. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_basic.py +0 -0
  46. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_de_backends.py +0 -0
  47. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_de_edge_cases.py +0 -0
  48. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_enrich_api.py +0 -0
  49. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_enrich_go.py +0 -0
  50. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_pl_coverage.py +0 -0
  51. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_pp_bias_cli.py +0 -0
  52. {scatrans-0.9.8.dev3 → scatrans-0.9.8.dev5}/tests/test_public_api.py +0 -0
  53. {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.dev3
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, logreg, etc.): native log2
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
- # Coerce a tolerant view for the "looks like counts" decision
212
- try:
213
- if sparse.issparse(ad_temp.X):
214
- X_check_arr = np.asarray(ad_temp.X.todense())
215
- else:
216
- X_check_arr = np.asarray(ad_temp.X)
217
- X_check_arr = np.clip(np.round(np.nan_to_num(X_check_arr)), 0, None)
218
- is_count_like = _is_integer_counts_like(X_check_arr)
219
- except Exception as _e:
220
- logger.debug("Count-like check fallback: %s", _e)
221
- is_count_like = _is_integer_counts_like(ad_temp.X)
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
- sc.tl.rank_genes_groups(
347
- ad_temp,
348
- groupby=use_groupby,
349
- groups=[target_group],
350
- reference=reference_group,
351
- method=de_method,
352
- key_added=rank_key,
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 _x_looks_log_normalized(X: Any, *, max_check: int = 100000) -> bool:
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
- return has_neg or mx <= 20.0
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.debug(
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
- bias_info["bias_corrected"] = True # median correction still applied
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.dev3'
22
- __version_tuple__ = version_tuple = (0, 9, 8, 'dev3')
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 != 0.05:
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
- _warn_user(
382
- "`pval_cutoff` is deprecated (it applies to *adjusted* p-values, not raw p-values). "
383
- "Please use `padj_cutoff` instead for clarity."
384
- )
385
- return float(pval_cutoff)
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 = 0.05,
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 = 0.05,
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 = 0.05,
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 = padj_cutoff if padj_cutoff is not None else pval_cutoff
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 = 0.05,
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 = padj_cutoff if padj_cutoff is not None else pval_cutoff
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