Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6c253cb
feat(preprocessing): add optimal shifted CLR (PFlog1pPF) normalization
rwbaber Jun 14, 2026
eec2659
docs: update release notes fragment with PR number
rwbaber Jun 14, 2026
32fb68b
test: register normalize_clr in copy_sigs signature conventions
rwbaber Jun 14, 2026
6bd44b0
refactor(pp): unify normalize_clr math variables and clean up bibliog…
rwbaber Jun 14, 2026
dd9aca0
docs: polish normalize_clr docstring wording
rwbaber Jun 14, 2026
f652a7e
refactor(pp): finalize normalize_clr API per review
rwbaber Jun 15, 2026
6eee954
feat(pp): support dask arrays in normalize_clr
rwbaber Jun 15, 2026
b284927
test(pp): cover alpha="auto" zero-mean overdispersion error
rwbaber Jun 15, 2026
f344a8e
Merge branch 'main' into feature/normalize_clr
ilan-gold Jun 24, 2026
7cf8a5b
Merge branch 'main' into feature/normalize_clr
Intron7 Jun 30, 2026
4212ccf
Merge remote-tracking branch 'upstream/main' into feature/normalize_clr
rwbaber Jul 7, 2026
3549f19
Add sparse PFlog normalization and PCA support
rwbaber Jul 8, 2026
1438bf9
Merge branch 'main' into feature/normalize_clr
ilan-gold Jul 8, 2026
4b3321f
Merge branch 'main' into feature/normalize_clr
rwbaber Jul 8, 2026
2cac9ef
Merge remote-tracking branch 'origin/feature/normalize_clr' into feat…
rwbaber Jul 8, 2026
30a7f4f
Refine PFlog metadata and Dask target handling
rwbaber Jul 8, 2026
3fd7086
Fix stale AnnData concatenation tutorial link
rwbaber Jul 8, 2026
a1f1acb
Merge branch 'main' into feature/normalize_clr
rwbaber Jul 10, 2026
d3c6b73
Merge branch 'main' into feature/normalize_clr
rwbaber Jul 20, 2026
dff3612
Merge branch 'main' into feature/normalize_clr
rwbaber Jul 21, 2026
4538e36
Merge branch 'main' into feature/normalize_clr
rwbaber Jul 21, 2026
96c4a8b
Merge upstream/main into feature/normalize_clr
rwbaber Sep 1, 2026
5110b39
refactor(preprocessing): simplify normalize_clr to dense PFlog
rwbaber Sep 2, 2026
2abb963
fix(preprocessing): relax CLR doctest tolerance
rwbaber Sep 2, 2026
e7e0560
Merge branch 'main' into feature/normalize_clr
rwbaber Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/preprocessing.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ For visual quality control, see {func}`~scanpy.pl.highest_expr_genes` and
pp.log1p
pp.pca
pp.normalize_total
pp.normalize_clr
pp.regress_out
pp.scale
pp.sample
Expand Down
10 changes: 10 additions & 0 deletions docs/references.bib
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ @article{Blondel2008
pages = {P10008},
}

@article{Booeshaghi2022,
author = {Booeshaghi, A. Sina and Hallgrímsdóttir, Ingileif B. and Gálvez-Merchán, Ángel and Pachter, Lior},
title = {Normalization for sampled count data},
year = {2026},
url = {https://doi.org/10.1101/2022.05.06.490859},
doi = {10.1101/2022.05.06.490859},
publisher = {Cold Spring Harbor Laboratory},
journal = {bioRxiv},
}

@article{Burczynski2006,
author = {Burczynski, Michael E. and Peterson, Ron L. and Twine, Natalie C. and Zuberek, Krystyna A. and Brodeur, Brendan J. and Casciotti, Lori and Maganti, Vasu and Reddy, Padma S. and Strahs, Andrew and Immermann, Fred and Spinelli, Walter and Schwertschlag, Ulrich and Slager, Anna M. and Cotreau, Monette M. and Dorner, Andrew J.},
title = {Molecular Classification of Crohn’s Disease and Ulcerative Colitis Patients Using Transcriptional Profiles in Peripheral Blood Mononuclear Cells},
Expand Down
1 change: 1 addition & 0 deletions docs/release-notes/4160.feat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add {func}`scanpy.pp.normalize_clr` for PFlog shifted centered log-ratio normalization, a variance-stabilizing, depth-invariant and rank-preserving count transform {cite:p}`Booeshaghi2022`. By default, it estimates negative-binomial overdispersion and uses the Anscombe pseudocount. The complete centered result is stored in `X` or the selected layer. Because CLR centering generally maps zeros to non-zero values, the output is dense. {smaller}`R Baber`
3 changes: 2 additions & 1 deletion src/scanpy/preprocessing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from ._harmony import harmony_integrate
from ._hashsolo import hashsolo
from ._highly_variable_genes import highly_variable_genes
from ._normalization import normalize_total
from ._normalization import normalize_clr, normalize_total
from ._pca import pca
from ._qc import calculate_qc_metrics
from ._recipes import recipe_seurat, recipe_weinreb17, recipe_zheng17
Expand Down Expand Up @@ -37,6 +37,7 @@
"highly_variable_genes",
"log1p",
"neighbors",
"normalize_clr",
"normalize_total",
"pca",
"recipe_seurat",
Expand Down
185 changes: 185 additions & 0 deletions src/scanpy/preprocessing/_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import numpy as np
from fast_array_utils import stats
from fast_array_utils.numba import njit
from fast_array_utils.stats import mean_var

from .. import logging as logg
from .._compat import CSBase, CSCBase, CSRBase, DaskArray, warn
Expand Down Expand Up @@ -304,3 +305,187 @@ def normalize_total( # noqa: PLR0912
elif not inplace:
return dat
return None


def _estimate_overdispersion(x: np.ndarray | CSBase | DaskArray) -> float:
r"""Estimate the negative-binomial overdispersion :math:`α` from raw counts.

Fits :math:`\mathrm{Var}_g = μ_g + α \cdot μ_g^2` across genes, where
:math:`μ_g` and :math:`\mathrm{Var}_g` are the per-gene mean and (population)
variance over cells. The model is linear in :math:`α`, so the ordinary
least-squares solution is closed form

.. math::
α = \frac{\sum_g (\mathrm{Var}_g - μ_g) \, μ_g^2}{\sum_g μ_g^4},

which is exactly the minimizer a non-linear `curve_fit` would converge to,
but without the dependency. :func:`~fast_array_utils.stats.mean_var` is
dispatched for dense, sparse and dask input alike, so the only dask-specific
step is computing the two final scalar sums.
"""
mu, var = mean_var(x, axis=0, correction=0)
mu2 = mu**2
numerator = np.sum((var - mu) * mu2)
denominator = np.sum(mu2 * mu2)
if isinstance(x, DaskArray):
import dask

numerator, denominator = dask.compute(numerator, denominator)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to compute this here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the subsequent conditional (if denominator == 0.0) and type cast (alpha = float(numerator / denominator)) need concrete scalar values at that point, not lazy Dask scalar objects.
I ran a quick test, and it looks like Dask can actually evaluate scalar truthiness and float conversion implicitly without throwing an error, but it does so sequentially for each line. This triggers separate computations and duplicates the upstream work.
Calling dask.compute(numerator, denominator) explicitly together merges both reductions into a single optimized pass. And this doesn't materialize the count matrix, it only executes the reduction graph down to the two final scalar sums.

if denominator == 0.0:
msg = (
"Cannot estimate overdispersion: every gene has zero mean. "
"Pass a positive `alpha` explicitly."
)
raise ValueError(msg)
alpha = float(numerator / denominator)
if not alpha > 0:
msg = (
f"Estimated overdispersion is non-positive (alpha = {alpha}); "
"pass a positive `alpha` explicitly."
)
raise ValueError(msg)
return alpha


def _log1p_sparse_block(x: np.ndarray | CSBase) -> np.ndarray | CSBase:
"""Apply log1p to a dense or sparse block while preserving sparse zeros."""
if isinstance(x, CSBase):
x = x.copy()
x.data = np.log1p(x.data)
return x
return np.log1p(x)


def _normalize_clr_helper(
x: np.ndarray | CSBase | DaskArray,
*,
alpha: float | None,
) -> tuple[np.ndarray | DaskArray, np.ndarray | DaskArray]:
"""Compute the dense PFlog / shifted-CLR matrix and cell depths."""
# Keep the depths lazy for dask; `.ravel()` would otherwise materialize them.
cell_depths = stats.sum(x, axis=1)
if not isinstance(x, DaskArray):
cell_depths = np.asarray(cell_depths).ravel()

if alpha is None:
alpha = _estimate_overdispersion(x)
elif not alpha > 0:
msg = (
f"`alpha` must be positive to compute PFlog, got {alpha}. "
"The data may be underdispersed."
)
raise ValueError(msg)

x = x * (4.0 * float(alpha))

Comment thread
rwbaber marked this conversation as resolved.
if isinstance(x, DaskArray):
log_values = x.map_blocks(
_log1p_sparse_block, dtype=np.float64, meta=x._meta.astype(np.float64)
)
else:
log_values = _log1p_sparse_block(x)

row_center = stats.sum(log_values, axis=1) / x.shape[1]
if not isinstance(row_center, DaskArray):
row_center = np.asarray(row_center).ravel()
if isinstance(log_values, CSBase):
log_values = log_values.toarray()
return log_values - row_center[:, None], cell_depths


def normalize_clr(
adata: AnnData,
*,
alpha: float | None = None,
layer: str | None = None,
inplace: bool = True,
copy: bool = False,
) -> AnnData | dict[str, np.ndarray | DaskArray] | None:
r"""Normalize counts with the shifted centered log-ratio (PFlog) transform.

If `alpha` is not provided, it is estimated from the input matrix. PFlog is
then computed as

.. math::
T(x)_i = \log(1 + 4 α x_i)
- \frac{1}{D} \sum_{j=1}^D \log(1 + 4 α x_j),

which is equivalent to centering
:math:`\log(x_i + 1 / (4 α))` because the constant :math:`\log(4 α)`
cancels during CLR centering.

.. note::
CLR centering generally maps zeros to non-zero values, so the resulting
matrix is dense even when the input is sparse.

Parameters
----------
adata
The annotated data matrix of shape `n_obs` × `n_vars`.
Rows correspond to cells and columns to genes.
alpha
Negative-binomial overdispersion of the dataset (``var = μ + α·μ²``).
If `None`, it is estimated from the input matrix. A positive numeric
value uses that value directly. PFlog applies
``log1p(4 * alpha * x)`` before CLR centering.
layer
Layer to normalize instead of `X`.
inplace
Whether to update `adata` or return a dictionary with the normalized
matrix.
copy
Whether to modify a copied input object. Not compatible with
`inplace=False`.

Returns
-------
Returns a dictionary with the normalized matrix or updates `adata`, depending
on `inplace`.

Example
-------
>>> import numpy as np
>>> from anndata import AnnData
>>> import scanpy as sc
>>> adata = AnnData(np.array([[1, 2, 30], [4, 50, 6]], dtype="float32"))
>>> sc.pp.normalize_clr(adata, alpha=0.5)
>>> np.allclose(adata.X.sum(axis=1), 0, atol=1e-5)
True
"""
if copy:
if not inplace:
msg = "`copy=True` cannot be used with `inplace=False`."
raise ValueError(msg)
adata = adata.copy()

view_to_actual(adata)

x = _get_arr(adata, layer=layer)
if isinstance(x, CSCBase):
x = x.tocsr()
if not inplace:
x = x.copy()
if issubclass(x.dtype.type, int | np.integer):
x = x.astype(np.float64)

start = logg.info("normalizing counts per cell via PFlog")

x, cell_depths = _normalize_clr_helper(x, alpha=alpha)

if not isinstance(cell_depths, DaskArray) and not np.all(cell_depths > 0):
warn("Some cells have zero counts", UserWarning)

dat = dict(X=x)
if inplace:
_set_obs_rep(adata, x, layer=layer)

logg.info(
" finished ({time_passed})",
time=start,
)

if copy:
return adata
elif not inplace:
return dat
return None
Loading
Loading