Skip to content

Align each batch's controls onto the pooled controls with TVN - #100

Open
timtreis wants to merge 5 commits into
feat/known-relationshipsfrom
feat/tvn-coral
Open

timtreis wants to merge 5 commits into
feat/known-relationshipsfrom
feat/tvn-coral

Conversation

@timtreis

@timtreis timtreis commented Sep 20, 2026

Copy link
Copy Markdown
Member

Stacked on #99, which it is measured with.

pp.tvn is typical variation normalization as EFAAR defines it: centre and scale on the controls, rotate onto the principal components of the controls alone, centre and scale on them again within each batch, then CORAL — whiten each batch by the covariance of its own controls and recolour it with the covariance of all of them. pp.sphere already whitens by a control covariance, but its per-group fit cannot express a target taken from every batch at once.

The rotation is fitted on the controls, so it keeps at most one component per control and the result can be narrower than the input. That is why this writes obsm and never X.

Checked against EFAAR_benchmarking at 2935f21 by transcribing the reference into the test, which pins the order of operations and the two different degrees of freedom it uses. The matrix roots go through eigh instead of a general fractional matrix power, because the regularized covariances are symmetric, so the decomposition is exact and real rather than complex with a discarded imaginary part.

What it does, measured. It trades replicate consistency for relationship recall. Across the six feature sets of ds.jump_lite — five learned embeddings and one CellProfiler-equivalent, four plates run at four laboratories — recall of compounds sharing an annotated target rose sharply on every one, and replicate retrieval fell on all but one. It needs controls per batch, not in total, since each batch is whitened by its own controls' covariance: on BBBC021, whose batches are 55 plates with six control wells each, it lowered mechanism retrieval at every width tried, while 64 control wells per batch was enough. The docstring says this, and says the choice between those two readouts is the question to answer first.

An earlier revision of this PR said it lost outright. That was measured only on CellProfiler features, and where annotations were missing only on replicate retrieval, so it was too strong.

Verified: 805 tests pass, mypy, ruff and the formatters clean.

pp.tvn is typical variation normalization as EFAAR defines it: centre and
scale on the controls, rotate onto the principal components of the controls
alone, centre and scale on them again within each batch, then CORAL each
batch by whitening with the covariance of its own controls and recolouring
with the covariance of all of them.

sphere already whitens by the control covariance, but its per-group fit
cannot express the part that matters here, a target taken from every batch at
once. The rotation is fitted on the controls and so keeps one component per
control at most, which is why this writes obsm and never X.

Matched against EFAAR_benchmarking at 2935f21 in the tests, including the
population standard deviation of the centre-scale against the sample
covariance of CORAL. The matrix roots go through eigh rather than a general
fractional matrix power: the regularized covariances are symmetric, so the
decomposition is exact and real.

On control-normalized CellProfiler features it did not beat plain principal
components on either screen this package ships. The docstring says so, and
says that the published gains are on learned embeddings.
The first measurement used BBBC021 and the eleven-source Target-2 plates,
both CellProfiler features, and read as TVN losing outright. Measured again
on JUMP-Lite, four plates from four laboratories with 64 control wells each,
it roughly doubles recall of compounds sharing an annotated target on all six
feature sets, the CellProfiler-equivalent one included, while replicate
retrieval falls. The trade is between those two, not between feature types,
and what it needs is controls per batch.
…ffsets

A dimension with no spread among a batch's controls was silently clamped to a
scale of 1. pp.normalize warns about the same condition and flags var; here
var cannot be flagged, because the columns being scaled are an embedding it
does not describe, so the warning names the batch instead. The per-batch pass
is where this bites, being fitted on that batch's controls alone.

Both passes over the batches now take their rows from group_offsets, which
_core/_reduce.py documents as the rule and nineteen call sites follow, rather
than scanning codes == group once per batch. The astype(float64) went:
representation already returns float64, so it only ever copied the matrix.

use_rep defaults to X_pca, as pp.harmony's does, since this is a batch
correction on an embedding; None still reads X.
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.

1 participant