Skip to content

Port robustness fixes from playbase::nnmCorrect - #1

Open
ivokwee wants to merge 3 commits into
mainfrom
port-nnmcorrect-robustness-fixes
Open

Port robustness fixes from playbase::nnmCorrect#1
ivokwee wants to merge 3 commits into
mainfrom
port-nnmcorrect-robustness-fixes

Conversation

@ivokwee

@ivokwee ivokwee commented Sep 8, 2026

Copy link
Copy Markdown
Member

Summary

  • Guard knn to 1 when ncol(X) <= 3, matching nnmCorrect's handling of very small sample sizes where k-NN matching is unstable.
  • Add a fallback: if the k-NN pairing matrix B doesn't end up with nrow(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.
  • Add na.rm = TRUE to the sd/rowMeans calls used for top-variance gene selection and per-group centering, so missing values in X no longer silently produce NAs that propagate through the pipeline.
  • Replace limma::removeBatchEffect() with an exact closed-form correction for the use.cov = FALSE path (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. Because full.pairs x full.y is 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 defensive nnmCorrect() implementation in playbase (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:

before after speedup
total time 15.4s 1.7s 9x

Synthetic, scaling with number of condition groups g (n=400 fixed):

g before after speedup
2 2.4s 0.5s 5x
4 4.2s 0.5s 9x
8 8.3s 0.5s 16x
12 12.5s 0.6s 22x

Synthetic, scaling with sample count n (g=4 fixed):

n before after speedup
200 1.4s 0.25s 6x
400 4.3s 0.5s 9x
800 17.7s 1.4s 12x
1200 44.6s 2.9s 15x

The "before" cost is dominated by fitting limma::removeBatchEffect(..., batch = full.pairs, ...), where full.pairs has one factor level per sample -- so it scales up with both n and g, which is exactly the "gets too slow as samples and conditions grow" complaint that motivated playbase::nnmCorrect2. The closed-form replacement removes that scaling dependency almost entirely on the g axis and substantially on the n axis.

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 as n grows: 3.2s vs 9.3s at n=1200) and is exact, whereas nnmCorrect2's output only reaches ~0.85 median gene-wise correlation with the exact result.

Test plan

  • Rscript -e 'source("R/NPmatch.R")' parses without error
  • Ran the README example dataset (GSE10846) through NPmatch() with knn=1 and knn=3; output matches the pre-change implementation to floating-point precision
  • Synthetic benchmarks across n (200-1200) and g (2-12) confirming both correctness and the speedups above
  • Spot-check behavior on a matrix with ncol(X) <= 3 and one containing NAs

🤖 Generated with Claude Code

https://claude.ai/code/session_01KRrp7pzhFakWWzbD9j1i5R

ivokwee and others added 3 commits September 8, 2026 17:54
- 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
@ivokwee
ivokwee requested a review from zitoa September 8, 2026 16:26
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.

2 participants