Skip to content

perf(preprocessing): whiten via the covariance matrix in Whitener::pca - #453

Open
mysma-9403 wants to merge 3 commits into
rust-ml:masterfrom
mysma-9403:perf/whitening-pca
Open

perf(preprocessing): whiten via the covariance matrix in Whitener::pca#453
mysma-9403 wants to merge 3 commits into
rust-ml:masterfrom
mysma-9403:perf/whitening-pca

Conversation

@mysma-9403

@mysma-9403 mysma-9403 commented Aug 6, 2026

Copy link
Copy Markdown

WhiteningMethod::Pca takes the SVD of the whole centered (nsamples, nfeatures) data matrix. The Zca and Cholesky arms immediately next to it instead form the (nfeatures, nfeatures) covariance and decompose that.

For PCA whitening the two are equivalent: the right singular vectors of the centered data are the eigenvectors of its covariance, and the singular values relate to the eigenvalues by lambda = s^2 / (nsamples - 1). Forming the covariance first makes the decomposition's cost independent of the sample count, leaving a single GEMM as the only work proportional to nsamples.

This only applies when nsamples > nfeatures. Otherwise the covariance is rank deficient and the SVD of the data matrix yields a differently shaped factor, so that case keeps the original formulation.

Benchmark

Both groups below are criterion, live in the crate's whitening_bench, and are part of this PR. Numbers come from criterion's own change detection.

One of the two groups is added by this PR, so it does not exist on master. To keep the harness identical on both sides of the comparison, the baseline is taken by checking out this branch and reverting only the implementation file:

git checkout perf/whitening-pca

# baseline: this branch's benches against master's implementation
git checkout master -- algorithms/linfa-preprocessing/src/whitening.rs
cargo bench -p linfa-preprocessing --bench whitening_bench -- --save-baseline before

# compare
git checkout perf/whitening-pca -- algorithms/linfa-preprocessing/src/whitening.rs
cargo bench -p linfa-preprocessing --bench whitening_bench -- --baseline before

fit scaling with the sample count

whitening_fit, added here. nfeatures = 64, fit timed on its own, since that is the call this change touches and the sample count is the axis it is about.

nsamples pca master pca this branch change cholesky zca
2500 53.27 ms 2.06 ms −96.1% 1.22 ms 2.28 ms
5000 97.53 ms 2.97 ms −96.9% 2.10 ms 3.45 ms
10000 257.03 ms 4.60 ms −98.2% 4.01 ms 4.92 ms
20000 545.34 ms 8.06 ms −98.5% 6.96 ms 8.06 ms
40000 2.554 s 16.91 ms −99.3% 16.02 ms 17.03 ms

Medians over 20 samples; every pca row is p = 0.00, with the 97% interval on the change spanning under half a percentage point.

On master, pca grows super-linearly with the sample count while cholesky and zca grow linearly. After the change all three are linear and pca sits alongside them, which is the point of the change: what remains proportional to nsamples is the GEMM the other two methods already did.

The cholesky and zca columns are unchanged code, included as a reference line. Their apparent run-to-run deltas (up to a third at 64x10000) are measurement noise at this sample count, and are a fair indication of the noise floor for the small measurements here — the pca effect is two orders of magnitude clear of it.

Pre-existing group, varying the feature count

whitening, already in the crate. nsamples = 10000, timing fit followed by transform. transform is untouched by this change and is linear in the sample count either way, so it dilutes the effect; included because it is the crate's existing measurement of this code.

nfeatures master this branch change (p = 0.00)
10 4.22 ms 0.61 ms −85.5%
20 16.73 ms 1.57 ms −90.6%
30 41.14 ms 3.20 ms −92.2%
40 84.50 ms 5.62 ms −93.3%
50 123.18 ms 7.50 ms −93.9%
60 176.54 ms 10.97 ms −93.8%
70 218.71 ms 15.88 ms −92.7%
80 321.39 ms 19.56 ms −93.9%
90 371.75 ms 20.68 ms −94.4%

Medians over 200 samples, 97% confidence intervals within ±3.5% of each point estimate.

Both runs on the same machine, back to back: Intel Core i9-9980HK, 32 GB, macOS 15.7.7, rustc 1.93.1, criterion 0.5, default (linfa-linalg) backend.

Details

  • The epsilon floor is applied to the reconstructed singular values rather than directly to the eigenvalues, so max(s, 1e-8) keeps the meaning it had before.
  • Eigenvalues are clamped at zero before sqrt, because rounding can push a numerically-zero one slightly negative and produce a NaN.
  • Singular vectors are sign-arbitrary, so individual rows of the whitening matrix may come out negated relative to before. This affects neither the whitening property nor WᵀW.
  • Forming XᵀX squares the condition number, so on badly conditioned inputs this is in principle less accurate than an SVD of X. Zca and Cholesky already make that trade, so this makes Pca consistent with them rather than introducing a new compromise.

Shape when nsamples <= nfeatures

The fallback keeps master's behaviour, which differs by backend: linfa_linalg::svd is a compact SVD and returns the (nsamples, nfeatures) factor, ndarray_linalg::svd is a full one and returns (nfeatures, nfeatures). test_pca_matrix_more_features_than_samples (20 × 50) asserts both; test_pca_matrix_square_input (16 × 16) covers the square case, where they agree.

That backend split is not introduced here — it is master's existing behaviour, pinned by the test so it cannot drift unnoticed.

Checks

Run locally on the machine noted above, in addition to CI. The blas runs use netlib-static rather than CI's intel-mkl-static, since MKL is not available on this host:

  • cargo test --release --workspace — no failures
  • cargo test --release -p linfa-preprocessing --features blas,linfa/netlib-static --lib — 55 passed, 0 failed
  • cargo clippy --workspace --all-targets --features benchmarks -- -D warnings — clean
  • cargo clippy -p linfa-preprocessing --all-targets --features blas,linfa/netlib-static,linfa/benchmarks -- -D warnings — clean
  • cargo fmt --all -- --check — clean

Each of the three commits was also built and tested on its own, so the branch is bisectable.

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.97%. Comparing base (3b411a4) to head (8c7211b).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #453      +/-   ##
==========================================
+ Coverage   77.93%   77.97%   +0.03%     
==========================================
  Files         104      104              
  Lines        7547     7558      +11     
==========================================
+ Hits         5882     5893      +11     
  Misses       1665     1665              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@quietlychris

Copy link
Copy Markdown
Member

First, I generally appreciate your interest in contributing to linfa, but it's a little hard for me to be excited about this contribution. A few reasons why:

  • The link to the Claude session above doesn't work. Maybe only you have access to it? Without that and only a single commit, I'm having trouble following the logical progression of some of the changes made
  • The PR description itself is in first-person writing that I can safely attribute to an LLM, and I'm not stoked that I wasted time trying to parse LLM-isms on context that isn't really relevant to me as a reviewer.
  • This is a performance-based fix, but based on your contribution, there's nothing actually reproducible here in that regard. Per the description, the perf change was

measured with a throwaway harness that is not part of this PR

but even the exact operation measured is unclear (was it a cargo test variation? Something else?).

Performance is also generally tricky to measure, using tools like criterion for statistically-significant runs on a given operation, or iai for instruction-based measurements. Based on the content here, it seems likely that you've improved performance, but there's no path forward for me (or any other reviewer) to measure that change ourselves or generally verify the content of this PR as generated by your LLM. Perf fixes much be accompanied with a statically-valid, reproducible workflow.

Also, for what it's worth, please remember that there's a person, not an agent, on the other side of this screen. It takes time and energy to review PRs like this. One of the key ways we evaluate the quality contributions is by getting a feel for the level of good-faith effort someone has put into their PR. When the content is clearly and primarily LLM-generated, the default assumption must be that it wasn't thoughtfully produced, which makes (at least me) much less likely to accept them.

The existing `whitening` group varies the feature count at a fixed sample
count and times `fit` together with `transform`. `transform` is linear in
the sample count whichever way `fit` is implemented, so that group cannot
show how a whitening method scales with the number of samples.

Add a `whitening_fit` group that holds the feature count fixed and sweeps
the sample count, timing `fit` on its own, for all three methods. A single
`fit` there runs into the seconds, so the group lowers the shared
200-sample default rather than take tens of minutes.
`WhiteningMethod::Pca` took the SVD of the whole centered
(nsamples x nfeatures) data matrix, while the `Zca` and `Cholesky` arms
next to it first form the (nfeatures x nfeatures) covariance and decompose
that. The two are equivalent for PCA whitening: the right singular vectors
of the centered data are the eigenvectors of its covariance, and the
singular values relate to the eigenvalues by `lambda = s^2 / (nsamples - 1)`.

Forming the covariance first makes the decomposition's cost independent of
the sample count, leaving a single GEMM as the only work proportional to
`nsamples` - the same work `Zca` and `Cholesky` already did.

From the `whitening_fit` group added in the previous commit, timing `fit`
alone at nfeatures = 64, medians over 20 samples, p = 0.00 throughout:

  nsamples     before      after    change
     2500    53.27 ms    2.06 ms    -96.1%
     5000    97.53 ms    2.97 ms    -96.9%
    10000   257.03 ms    4.60 ms    -98.2%
    20000   545.34 ms    8.06 ms    -98.5%
    40000     2.554 s   16.91 ms    -99.3%

At 40000 samples `Cholesky` and `Zca` take 16.02 ms and 17.03 ms, so `Pca`
now sits alongside the two methods that already formed the covariance
instead of growing super-linearly away from them.

The epsilon floor is applied to the reconstructed singular values rather
than to the eigenvalues, so it keeps the meaning it had before, and the
eigenvalues are clamped at zero first because rounding can push a
numerically-zero one slightly negative and turn `sqrt` into a NaN.

Forming the covariance squares the condition number, so this is in
principle less accurate on badly conditioned inputs than an SVD of the data
matrix. `Zca` and `Cholesky` already make that trade; this brings `Pca` in
line with them rather than introducing a new compromise.

With at most as many samples as features the covariance is rank deficient,
and the SVD of the data matrix yields a differently shaped factor. Routing
that case to the original formulation keeps the shape of the whitening
matrix unchanged.
When `nsamples <= nfeatures` the whitening matrix keeps the shape it gets
from the SVD of the data matrix, and that shape depends on the linalg
backend: `linfa_linalg::svd` is compact and returns `nsamples x nfeatures`,
`ndarray_linalg::svd` is full and returns `nfeatures x nfeatures`.

That divergence is not introduced by the previous commit; it is existing
behaviour that no test covered. Assert both shapes explicitly in
`test_pca_matrix_more_features_than_samples` so it cannot drift unnoticed,
and note in `test_pca_matrix_square_input` that the square case is the one
where the two backends agree.
@mysma-9403

Copy link
Copy Markdown
Author

All fair, and the reproducibility point especially. What changed:

Measurement. The crate already ships a criterion bench for this (whitening_bench), so that is what this uses now instead of the throwaway harness. Its existing group holds the sample count fixed and times fit and transform together, which is not the axis this change is about, so the PR adds a whitening_fit group that sweeps the sample count and times fit on its own. Both groups are in the diff, and the description has the exact commands, the machine, and criterion's change detection against a master baseline.

Timing fit at 64 features, pca goes from 53.3 ms to 2.06 ms at 2500 samples, and from 2.55 s to 16.9 ms at 40000. The ratio is not really the interesting number though: cholesky and zca take 16.0 ms and 17.0 ms at that size, so the point is that pca now sits with the two methods that already formed the covariance instead of growing away from them. The new group times those two as well, as an unchanged reference line - which also makes the noise floor visible rather than hiding it.

Description. Rewritten - third person, only what bears on the diff. The dead session link and the co-author trailer are gone, from the description and from the commits.

Commits. Three now, in the order that makes the progression followable: the bench, then the change, then the tests. Each one builds and passes tests on its own.

One thing worth raising here rather than leaving buried in the diff: test_pca_matrix_more_features_than_samples asserts a different shape depending on the blas feature, because linfa_linalg::svd is compact and ndarray_linalg::svd is full. That divergence already exists on master - this PR neither introduces nor changes it, it only pins it so it cannot drift unnoticed. If you would rather see that as a separate PR, say so and I will pull it out.

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