PCA: cross-validate a block that already has missing cells - #552
Merged
Conversation
A score plot says how extreme an observation is along the components and nothing about how far it sits off them, so the reader needs a second plot for the second question. `sizes=` puts that second quantity, typically the SPE, on the marker area, and one plot answers both. Area, not diameter, is proportional to the value, and one scale is shared by the plain and the highlighted traces, so a highlighted point keeps its own area instead of being enlarged: two meanings on one channel cannot both be read. `settings["size_max"]` sets the largest marker and `size_name` names the value in the hover text, so a reader can recover it exactly. A negative value, a series that does not cover every observation, or an all-zero series raises rather than drawing a plot that cannot be read. The default path is untouched: without `sizes` the markers keep their fixed size and carry no `sizemode`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXthGpHLQFfGubBiKFtGAE
…-process-3cu4iq # Conflicts: # CHANGELOG.md # CITATION.cff # pyproject.toml
…-process-3cu4iq # Conflicts: # CHANGELOG.md # CITATION.cff # pyproject.toml
The super score was formed by scoring each block on its own and adding the
per-block scores up. Written out over the variables instead of over the blocks,
t_i = (x_i' g) / (w_s' w_s), g = [ w_s,b w_b / sqrt(K_b) ]_b
the same quantity is a single linear combination of every variable in every
block. On a complete row the two are the same number. They part company as soon
as a row has missing cells: the per-block sum lets a block observed in one
variable out of twenty hand a score built on that one variable into the sum,
weighted as though it were as well determined as a block observed in full.
So estimate it as one masked regression of the whole row onto g, rescaled by
g'g so a complete row reproduces the old value exactly. No complete-data model
moves. With a quarter of the cells knocked out of a 14-variable and a
3-variable block, the super scores correlate 0.93 with the complete-data ones
where the per-block sum reached 0.31. The super weight is now a masked
regression too, so a block a row has nothing in does not vote on it.
transform, predict and score_contributions use the same estimate, so a
projected score still matches a fitted one.
That makes a row missing one whole block scorable, which is the multiblock case
of a batch that skipped an analysis, so the fit no longer refuses it. The block
that was not observed reports NaN in block_scores_ and block_spe_: it has no
score of its own there, and a zero would place the row at that block's average.
A row observed in no block at all still carries no information, and is refused
with a message naming the rows.
Tests: the pooled score is pinned against the by-hand formula on a partly
observed row, and against the per-block sum it must differ from; the heavy
missingness case is pinned at a threshold the old code fails by a wide margin;
and the two all-missing cases are pinned either way round.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXthGpHLQFfGubBiKFtGAE
…-process-3cu4iq # Conflicts: # CHANGELOG.md # CITATION.cff # pyproject.toml
PCA.fit has always taken a block with missing cells through NIPALS, but select_n_components could not cross-validate one. The element-wise k-fold scheme built its folds from the raw array, so two things broke: the per-column centre and scale came from mean() and std() over columns holding NaN, and only the held-out cells were filled before the SVD. The caller's own missing cells therefore reached numpy.linalg.svd, which raised "SVD did not converge". The legacy row_wise path failed differently on the same input, with "NaN PRESS for every component count". A cell the caller never measured has no true value to predict. It is now imputed by EM alongside the held-out cells and never scored: folds are assigned over the observed cells only, so each fold holds the same number of scorable cells; the column constants are fitted on cells that are both in-fold and measured; and PRESS, the null reference and the per-column split are accumulated over held-out measured cells alone. A block with no missing cells takes an unchanged path. Pinned by the noise test, whose Q2 curve is bit-identical before and after. Two tests. The first punches 12 cells out of a rank-2 block and checks that cross-validation still finds the rank, that the Q2 curves peak at the same component count, and that no NaN reaches PRESS or Q2; it fails with LinAlgError without this change. The second pins the property that separates the two schemes: on pure noise the ekf Q2 stays negative at every component count, while the row-wise Q2 climbs monotonically past 0.5, because that scheme scores a held-out row through its own values. 3024 fast tests pass. ruff check, ruff format --check and mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXthGpHLQFfGubBiKFtGAE
…-process-3cu4iq # Conflicts: # CHANGELOG.md # CITATION.cff # pyproject.toml
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
Found while cross-validating the quality block of the FMC batch dryer case study (kgdunn/pid-book#273), which has 19 missing cells in 46 x 8.
The defect
PCA.fithas always taken a block with missing cells through NIPALS.PCA.select_n_componentscould not cross-validate the same block:The element-wise k-fold routine built its folds from the raw array, so two things broke independently:
mean()andstd()over columns holdingNaN, givingNaNconstants;numpy.linalg.svd.The fix
A cell the caller never measured has no true value to predict, so it is never held out and never scored. It still has to be filled for the SVD, so EM imputes it alongside the held-out cells:
A block with no missing cells takes an unchanged path and returns bit-identical numbers.
Measured
[-50.2, -110.3, -183.9, -281.3]LinAlgErrorLinAlgErrorThe third row is the point: the missing cells cost a little accuracy rather than changing the answer.
Tests
Two, both in
tests/test_multivariate.py:test_pca_select_n_components_ekf_handles_missing_cellspunches 12 cells out of a rank-2 block and checks that the rank is still found, that the complete and holed Q² curves peak at the same component count and agree to 0.1 there, and that noNaNreaches PRESS or Q². It fails withLinAlgErrorwithout this change.test_pca_select_n_components_ekf_q2_is_negative_on_noisepins the property that separates the two schemes. On pure noise the ekf Q² stays negative at every component count, while the row-wise Q² climbs monotonically past 0.5, because that scheme scores a held-out row through its own values and so measures the row's SPE rather than a prediction.That second test also documents, in a form CI will keep honest, why
cv_scheme="row_wise"warns: on data with no structure at all it reports 87% of the variation "predicted" at seven components.Verification
ruff check .,ruff format --check .andmypy src/process_improveclean.CHANGELOG.mdandCITATION.cffin step.🤖 Generated with Claude Code
https://claude.ai/code/session_01LXthGpHLQFfGubBiKFtGAE
Generated by Claude Code