Recover raw counts from log1p-normalized single-cell expression matrices
Why
Many single-cell tools and public datasets distribute only the log1p-normalized expression matrix, discarding the raw counts. Some downstream methods (e.g., RNA velocity, differential expression via negative-binomial models, scVI) require integer counts.
stancounts reverses the normalization — exactly, in one line — without needing to know the original target_sum.
How it works
Standard scRNA-seq normalization:
y = log1p(count / library_size × target_sum)
The key insight: in every cell, the minimum nonzero value after expm1 corresponds to a gene with count = 1 (near-universal in sparse scRNA-seq data). This gives us the per-cell scale factor, and division + rounding recovers exact integer counts.
scale_factor = min_nonzero(expm1(y)) # per cell
counts = round(expm1(y) / scale_factor)
The algorithm is target_sum agnostic — it works with any normalization target (1e4, 1e6, median, etc.) and any per-cell size factor scheme (scanpy, scran, Seurat).
Installation
Python package
pip install git+https://github.com/chansigit/stancounts.git
Or from source:
git clone https://github.com/chansigit/stancounts.git
cd stancounts
pip install -e .
Claude Code plugin
If you use Claude Code, stancounts is also available as a plugin with a built-in skill that guides Claude through the detection and recovery workflow:
/plugins add chansigit/stancounts
Once installed, simply ask Claude to "recover counts" or "reverse log1p" and the skill activates automatically.
Quick start
import stancounts
import scanpy as sc
adata = sc.read_h5ad("my_dataset.h5ad")
# One-liner: recover counts into a new layer
stancounts.reverse_log1p_anndata(adata)
# → adata.layers["counts_recovered"]
# → adata.obs["stancounts_scale_factor"]
# → adata.obs["stancounts_library_size"]
Auto-detect normalization
det = stancounts.detect_normalization(adata.X)
print(det)
# {'is_log1p': True, 'base': 'e', 'scores': {'e': 1.0, '2': 0.32, '10': 0.31},
# 'is_integer': False, 'max_value': 7.26}
if det["is_log1p"]:
stancounts.reverse_log1p_anndata(adata, base=det["base"])
Low-level API
result = stancounts.reverse_log1p(
adata.X, # sparse or dense matrix
base="e", # "e", "2", or "10"
robust=True, # handle rare min_count > 1 cells
)
counts = result["counts"] # recovered count matrix
scale_factors = result["scale_factors"] # per-cell scale factors
library_sizes = result["library_sizes"] # recovered library sizes
corrections = result["corrections"] # robust correction flags
Validation
Tested on 256,000+ real cells with ground-truth counts:
| Dataset | Cells | Genes | Recovery |
|---|---|---|---|
| PBMC3k | 2,638 | 13,714 | 100% |
| Purified PBMC | 105,753 | 19,512 | 100% |
| CITE-seq PBMC | 147,582 | 20,729 | 100% |
All three datasets: every element, every cell, exactly recovered.
Robustness
| Scenario | Result |
|---|---|
target_sum = 100, 1e3, 1e4, 1e5, 1e6 |
100% |
target_sum = median(library_size) |
100% |
| scran-style arbitrary size factors | 100% |
| log2(1+x) and log10(1+x) | 100% |
| Cells with min_count > 1 (robust mode) | 100% |
| Deeply sequenced cells (high counts) | 100% |
API reference
stancounts.reverse_log1p(X, *, base="e", robust=True)
Reverse log1p normalization to recover raw counts.
Parameters:
X— expression matrix (cells × genes), sparse or densebase— log base:"e"(default),"2", or"10"robust— detect and correct cells whose minimum nonzero count > 1
Returns dict with keys: counts, scale_factors, library_sizes, corrections
stancounts.reverse_log1p_anndata(adata, *, source="X", target_layer="counts_recovered", base="e", robust=True)
Convenience wrapper for AnnData objects. Stores recovered counts in adata.layers[target_layer] and scale factors in adata.obs.
stancounts.detect_normalization(X, *, n_sample=200, seed=0)
Auto-detect whether data is log1p-normalized and determine the log base.
Returns dict with keys: is_log1p, base, scores, is_integer, max_value
stancounts.is_log1p_normalized(X, **kwargs)
Convenience wrapper: returns True if data appears log1p-normalized.
When it won't work
- Batch-corrected data (e.g., Harmony, scVI latent space) — the linear count→normalized relationship is broken
- scTransform (Pearson residuals) — not a log1p(scaled_counts) transform
- Aggregated/pseudobulk data — may still work if the aggregation preserves the structure
- Fractional counts (e.g., from imputation) — no integer counts to recover
Dependencies
numpy >= 1.22scipy >= 1.7anndata >= 0.8(optional, for AnnData integration)
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file stancounts-0.3.0.tar.gz.
File metadata
- Download URL: stancounts-0.3.0.tar.gz
- Upload date:
- Size: 26.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ec5837544add81337524dc27aefb4d4609642c552ccef84a69ba3bfc558d4e2d
|
|
| MD5 |
a0560ee29ff3fb37fe69a15b1a8b3569
|
|
| BLAKE2b-256 |
66cf899fda8eb6ebfca4339b3bddc2cbd71a41e51999a27f9fe2e38e69054cde
|
File details
Details for the file stancounts-0.3.0-py3-none-any.whl.
File metadata
- Download URL: stancounts-0.3.0-py3-none-any.whl
- Upload date:
- Size: 11.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d060b49f78b9163677fa5b11a1dcffbd2428f7268899e171ca365b80d0667e3a
|
|
| MD5 |
73ba3fc7b122e69779948cdb95e4e222
|
|
| BLAKE2b-256 |
89f7ce42e5550d274deebb45b6d66ea1ae336e3da0166ee6d67e9f81ca50f484
|