Skip to content

PCA: cross-validate a block that already has missing cells - #552

Merged
kgdunn merged 7 commits into
mainfrom
claude/issues-154-156-process-3cu4iq
Sep 9, 2026
Merged

PCA: cross-validate a block that already has missing cells#552
kgdunn merged 7 commits into
mainfrom
claude/issues-154-156-process-3cu4iq

Conversation

@kgdunn

@kgdunn kgdunn commented Sep 9, 2026

Copy link
Copy Markdown
Owner

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.fit has always taken a block with missing cells through NIPALS. PCA.select_n_components could not cross-validate the same block:

cv_scheme="ekf"       LinAlgError: SVD did not converge
cv_scheme="row_wise"  RuntimeError: Cross-validation produced NaN PRESS for every component count

The element-wise k-fold routine built its folds from the raw array, so two things broke independently:

  1. the per-column centre and scale came from mean() and std() over columns holding NaN, giving NaN constants;
  2. only the held-out cells were filled before the SVD, so the caller's own missing cells reached 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:

  • folds are assigned over the observed cells only, so every fold holds the same number of scorable cells;
  • the column centre and scale are fitted on cells that are both in-fold and measured;
  • 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 and returns bit-identical numbers.

Measured

Block Before After
Pure noise, 46 x 8 [-50.2, -110.3, -183.9, -281.3] identical
Rank 2, 60 x 8, complete peaks at 2 components, Q² 84.2% identical
The same block, 12 cells removed LinAlgError peaks at 2 components, Q² 85.0%
FMC quality block, 19 missing LinAlgError runs; Q² 30.5, 25.0, −28.4, −79.5 at 5 folds

The 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_cells punches 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 no NaN reaches PRESS or Q². It fails with LinAlgError without this change.
  • test_pca_select_n_components_ekf_q2_is_negative_on_noise pins 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

  • 3024 fast tests pass, 3 skipped.
  • ruff check ., ruff format --check . and mypy src/process_improve clean.
  • Version 1.83.1, with CHANGELOG.md and CITATION.cff in step.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LXthGpHLQFfGubBiKFtGAE


Generated by Claude Code

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

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kgdunn
kgdunn merged commit 336f18b into main Sep 9, 2026
14 checks passed
@kgdunn
kgdunn deleted the claude/issues-154-156-process-3cu4iq branch September 9, 2026 20:46
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