Port robustness fixes from playbase::nnmCorrect - #1
Open
ivokwee wants to merge 3 commits into
Open
Conversation
- Guard knn to 1 when ncol(X) <= 3 to avoid unstable k-NN matching on very small sample sizes. - Fall back to 1-nearest-neighbour matching, and finally bail out returning the uncorrected matrix, if the k-NN pairing matrix does not match ncol(X). - Add na.rm = TRUE to the sd/rowMeans calls used for top-variance gene selection and group centering, so missing values no longer silently propagate to NA. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRrp7pzhFakWWzbD9j1i5R
The batch=full.pairs factor in removeBatchEffect() has one level per original sample, so its cost dominates and grows with both the number of samples and the number of condition groups (the exact complaint that motivated playbase::nnmCorrect2). Because full.pairs x full.y is always a complete, balanced two-way layout by construction, the OLS batch coefficient removeBatchEffect would fit collapses to a closed form: each sample's own mean deviation from the grand mean, computed with a few vectorised matrix subtractions instead of an n-level factor regression. Verified on GSE10846 (n=350) and synthetic data (n up to 1200, up to 12 condition groups): output is numerically identical to the previous implementation (diffs ~1e-12, floating-point noise), while running 5-20x faster, with the speedup growing with both sample count and number of groups. Also faster and numerically exact (vs. ~0.85 median gene-wise correlation) compared to playbase::nnmCorrect2's SVD-based approximation on the same benchmarks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRrp7pzhFakWWzbD9j1i5R
zitoa
approved these changes
Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
knnto 1 whenncol(X) <= 3, matchingnnmCorrect's handling of very small sample sizes where k-NN matching is unstable.Bdoesn't end up withnrow(B) == ncol(X), retry with 1-nearest-neighbour matching, and if that still fails, bail out returning the uncorrected matrix with a warning instead of erroring downstream.na.rm = TRUEto thesd/rowMeanscalls used for top-variance gene selection and per-group centering, so missing values inXno longer silently produceNAs that propagate through the pipeline.limma::removeBatchEffect()with an exact closed-form correction for theuse.cov = FALSEpath (the default).full.pairs(batch) has one level per original sample, so its regression cost dominates and grows with both sample count and number of condition groups. Becausefull.pairs x full.yis always a complete, balanced two-way layout by construction, the OLS batch coefficient collapses to a closed form (each sample's own mean deviation from the grand mean), computed with a few vectorised matrix subtractions instead of an n-level factor regression.These bring
NPmatch()in line with the more defensivennmCorrect()implementation inplaybase(R/pgx-correct.R), which this package was derived from, and then goes further on performance.Speed (output numerically identical, diffs ~1e-12/1e-13 -- floating-point noise, not an approximation)
Real data, GSE10846 (n=350 samples), knn=1:
Synthetic, scaling with number of condition groups
g(n=400 fixed):Synthetic, scaling with sample count
n(g=4 fixed):The "before" cost is dominated by fitting
limma::removeBatchEffect(..., batch = full.pairs, ...), wherefull.pairshas one factor level per sample -- so it scales up with bothnandg, which is exactly the "gets too slow as samples and conditions grow" complaint that motivatedplaybase::nnmCorrect2. The closed-form replacement removes that scaling dependency almost entirely on thegaxis and substantially on thenaxis.Also benchmarked against
playbase::nnmCorrect2(an alternative, faster-but-approximate approach using per-group KNN + SVD-compressed pairing covariates): this closed-form fix is faster in every benchmark tested (especially asngrows: 3.2s vs 9.3s at n=1200) and is exact, whereasnnmCorrect2's output only reaches ~0.85 median gene-wise correlation with the exact result.Test plan
Rscript -e 'source("R/NPmatch.R")'parses without errorNPmatch()withknn=1andknn=3; output matches the pre-change implementation to floating-point precisionn(200-1200) andg(2-12) confirming both correctness and the speedups abovencol(X) <= 3and one containingNAs🤖 Generated with Claude Code
https://claude.ai/code/session_01KRrp7pzhFakWWzbD9j1i5R