Skip to content

fix: only warn from log1p when the target representation was already transformed - #4298

Draft
Mohit-Ak wants to merge 2 commits into
scverse:mainfrom
Mohit-Ak:fix/log1p-warn-per-representation
Draft

fix: only warn from log1p when the target representation was already transformed#4298
Mohit-Ak wants to merge 2 commits into
scverse:mainfrom
Mohit-Ak:fix/log1p-warn-per-representation

Conversation

@Mohit-Ak

Copy link
Copy Markdown

sc.pp.log1p records that it ran in adata.uns["log1p"], and the "already log-transformed" guard only checks whether that key exists. Since the key carries no information about which representation was transformed, the warning is decoupled from what the call actually touches:

sc.pp.log1p(adata)                      # transforms X
sc.pp.log1p(adata, layer="spliced")     # WARNING: adata.X seems to be already log-transformed.
sc.pp.log1p(adata, layer="unspliced")   # WARNING: adata.X seems to be already log-transformed.

X was transformed exactly once, so both warnings are false positives.

While reproducing this I found the same root cause also produces the opposite failure, which the issue doesn't mention: because the state is never per-representation, transforming the same layer twice — a real double-log — stays silent. So the guard is currently both too noisy and too quiet.

Behaviour of every combination I could construct, before and after:

calls should warn before after
Xlayer="spliced" no warns no
layer="spliced"X no warns no
layer="spliced"layer="unspliced" no warns no
Xobsm="rep" no warns no
XX yes warns warns
layer="spliced" twice yes silent warns
obsm="rep" twice yes silent warns

The fix

uns["log1p"] now also records which representations have been transformed, and the guard tests membership against the representation the current call targets rather than mere key presence:

adata.uns["log1p"] = {"base": base, "reps": ["X", "layers['spliced']"]}

base keeps its existing meaning and position, so the readers of that key are unaffected. The warning also now names the representation it's actually about (adata.layers['spliced'] seems to be already log-transformed.) instead of always saying adata.X.

On backwards compatibility: state written by earlier versions is a bare {"base": ...} with no "reps". That's read as ["X"] — before layer=/obsm= were tracked at all, X is the only thing the marker could have meant — so an .h5ad written by an older scanpy still warns on a repeat log1p(adata) and doesn't spuriously warn on a first-time layer transform. There's a test for this. I also checked an h5ad round-trip: reps comes back as an object ndarray rather than a list, which the membership test handles.

A test-helper change worth flagging

check_rep_results() asserts that processing X, a layer and an obsm leaves identical AnnData objects, and uns is part of that comparison. Recording which representation was processed makes those objects legitimately differ, so test_log1p_rep failed (18 parametrisations) until I gave the helper a way to exclude that path:

check_rep_results(sc.pp.log1p, x, base=base, ignore_uns=["log1p/reps"])

ignore_uns takes "key" or "key/subkey" paths and defaults to empty, so every other caller is unchanged. This is a narrow version of the # TODO: Allow specifying paths to ignore on comparison already noted at the top of that module. Note the assertion is still meaningful — only the deliberately representation-dependent sub-key is skipped, so base equality is still enforced across representations. If you'd rather this helper stayed untouched and the state lived somewhere check_rep_results doesn't compare, say so and I'll rework it.

Testing

Added test_log1p_warns_per_representation (7 parametrised call sequences, the table above) and test_log1p_legacy_uns_state_implies_x.

$ pytest tests/test_preprocessing.py -k log1p
27 passed, 771 deselected

To confirm the new tests actually pin the behaviour, I reverted only _simple.py and kept the tests:

$ git stash push -- src/scanpy/preprocessing/_simple.py
$ pytest tests/test_preprocessing.py -k "log1p_warns_per_representation or log1p_legacy"
5 failed, 3 passed

The 5 failures are exactly the false-positive rows plus the legacy-state case; the same-rep-twice rows pass before and after, since those were already correct.

Wider run, to check nothing else depends on the shape of that uns entry (grep says base is the only key read anywhere else):

$ pytest tests/test_preprocessing.py tests/test_highly_variable_genes.py \
    tests/test_rank_genes_groups.py tests/test_qc_metrics.py \
    tests/test_score_genes.py tests/test_logging.py tests/test_backed.py
696 passed, 500 skipped, 26 xfailed, 144 subtests passed

ruff check and ruff format --check clean on the four touched files at the pinned v0.16.1.

Fixes #4285

…formed

sc.pp.log1p recorded its state in adata.uns["log1p"] without noting which
representation it transformed, and the guard only tested for the key's
presence. Transforming X and then a layer therefore warned that
"adata.X seems to be already log-transformed" even though X was touched
once, while genuinely transforming the same layer twice never warned.

Record the transformed representations in uns["log1p"]["reps"] and check
membership against the representation the current call targets. State
written by older scanpy versions has no "reps" key and is read as ["X"],
which is what it could only have meant.

check_rep_results() gains an ignore_uns argument so representation-dependent
uns state can be excluded from its cross-representation equality check.
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@9df46b4). Learn more about missing BASE report.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4298   +/-   ##
=======================================
  Coverage        ?   79.30%           
=======================================
  Files           ?      128           
  Lines           ?    13488           
  Branches        ?        0           
=======================================
  Hits            ?    10697           
  Misses          ?     2791           
  Partials        ?        0           
Flag Coverage Δ
hatch-test.low-vers 77.54% <100.00%> (?)
hatch-test.pre 79.18% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/scanpy/preprocessing/_simple.py 91.22% <100.00%> (ø)

@Mohit-Ak Mohit-Ak changed the title Only warn from log1p when the target representation was already transformed fix: only warn from log1p when the target representation was already transformed Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

log1p warning when modifying several layers

1 participant