Skip to content

Compute score contributions from the data, not from the score#476

Merged
kgdunn merged 5 commits into
mainfrom
claude/modernize-book-figures-ajbqk8
Jul 25, 2026
Merged

Compute score contributions from the data, not from the score#476
kgdunn merged 5 commits into
mainfrom
claude/modernize-book-figures-ajbqk8

Conversation

@kgdunn

@kgdunn kgdunn commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • score_contributions() on PCA, PLS, MBPCA and MBPLS computed (t_end - t_start) @ P, an expression that never receives the observation's data. With one component it reduces exactly to -t_1 * p_1, so it returned the loading vector rescaled by a constant and gave every observation the same ranking of variables. It is now the term-by-term breakdown of the sum that forms the score, c_ik = x_ik * R_ka, which sums back to t_ia.
  • Added group_contributions() for the cluster and two-period forms from the same paper, which is what the old t_start / t_end pair was reaching for, and scaling= for the paper's two presentation scalings.
  • Breaking: score_contributions() now takes X and returns one row per observation, matching t2_contributions() and spe_contributions() beside it. The old signature cannot be supported alongside the new one, since a score vector does not carry the information the calculation needs, so old-form calls raise a TypeError naming the replacement.

The defect

A contribution is meant to answer "which variables put this observation where it is?". The old expression could not answer it, because it is a function of the scores and loadings only. Measured on real data:

process-improve (before) correct
Distinct variable rankings across the 50 food-texture observations 1 34
Top-blamed variable differs from the correct answer 36 of 50 (food texture)
Top-blamed variable differs from the correct answer 47 of 54 (LDPE)

One ranking for fifty observations is the loading ranking, reproduced fifty times.

That is precisely the failure the diagnostic exists to prevent. Miller, Swanson and Heckler, who introduced contribution plots, give a batch whose loadings point at the silver-concentration variables while its contributions point at the salt and silver pressure variables; the pressures were the real fault, confirmed by univariate charts and traced to a replaced valve. Their Figure 2 shows the two bar charts side by side and they share no visual resemblance. A loading describes the whole data set; a contribution describes one observation, and a variable with a large loading contributes nothing when that observation sits at its mean.

Worth noting: docs/user_guide/pca.rst already stated the correct formula, x_{i,k} p_{k,a}, in prose immediately above code calling the old back-projection. The documentation and the implementation had drifted apart.

What the calculation should be

The defining property is that the per-variable terms add up to the score being decomposed:

c_ik = x_ik * R_ka          sum_k c_ik = t_ia

with R the score-generating matrix: loadings_ for PCA, direct_weights_ for PLS (so that T = XR). That generalisation from PCA loadings to any latent-variable model's weights follows Westerhuis, Gurden and Smilde (2000). For the multiblock models the super score at component a is formed from the block data deflated through the earlier components, so the decomposition starts from that same deflated data; summing over every block and variable returns the super score.

t2_contributions() and spe_contributions() were already correct and are unchanged. t2_contributions multiplies by X_values and telescopes to T², which is exactly the pattern score_contributions was missing one function away.

Migration

Before After
model.score_contributions(model.scores_.iloc[i]) model.score_contributions(X).iloc[i]
model.score_contributions(t_a, t_b) model.group_contributions(X, group=[a], reference=[b])
model.score_contributions(t, weighted=True) model.t2_contributions(X) (already correct)

Test plan

  • New tests assert the defining property, that contributions sum to the score, for every component of PCA, PLS, MBPCA and MBPLS, and on the real LDPE blocks. The previous tests asserted only that the output equalled dt @ P.T, which re-derived the implementation and so held whatever it computed; that is why this survived.
  • A test asserts the ranking varies across observations, which is the specific defect above.
  • A test asserts a variable sitting at its mean contributes zero regardless of its loading.
  • Tests cover the migration errors, both scalings, and the selector forms of group_contributions (labels, positions, boolean mask).
  • uv run pytest tests/test_multivariate.py tests/test_multiblock_reference.py tests/test_multivariate_tsr.py tests/test_multivariate_robustness.py -> 274 passed, 1 skipped.
  • uv run ruff check . and uv run mypy src/process_improve both pass.
  • Both case-study notebooks execute and print the identity holding: the flagged food-texture sample sums to -1.1107 against a score of -1.1107, the flagged tablet to -53.4725 against -53.4725.
  • uv run sphinx-build docs docs/_build/html -b html -W reaches build finished with one warning, an autodoc import failure on process_improve.batch.plotting caused by seaborn not being installed in this sandbox. It is unrelated to this change (it also breaks tests/batch/test_plotting.py here) and does not occur in CI, which installs --all-extras.

Pre-existing failures unrelated to this change, confirmed identical on a stashed tree: 130 failures across the experiments / design-generation suites.

Checklist

  • Version bumped in pyproject.toml (MINOR: 1.60.0 -> 1.61.0; the method changes shape and the old convention now raises). CITATION.cff version and date-released synced.
  • Tests added or updated where relevant
  • ruff check . passes
  • CHANGELOG.md updated

References

Miller, P., Swanson, R.E. and Heckler, C.E. (1994). "Contribution plots: a missing link in multivariate quality control." Applied Mathematics and Computer Science, 8(4), 775-792.

Westerhuis, J.A., Gurden, S.P. and Smilde, A.K. (2000). "Generalized contribution plots in multivariate statistical process monitoring." Chemometrics and Intelligent Laboratory Systems, 51(1), 95-114.


Generated by Claude Code

claude added 2 commits July 25, 2026 18:37
score_contributions() back-projected a score-space difference through the
loadings: contributions = (t_end - t_start) @ P. That expression never
receives the observation's data. With one component it reduces exactly to
-t_1 * p_1, so it returns the loading vector rescaled by a constant, and
every observation in a data set produces the same ranking of variables.
On the food-texture data all 50 observations gave one ranking; the correct
calculation gives 34. The variable blamed for the movement differed for 36
of those 50 observations, and for 47 of the 54 LDPE observations.

That is precisely the failure the diagnostic exists to prevent. Miller,
Swanson and Heckler (1994), who introduced contribution plots, show a batch
whose loadings point at the silver concentration variables while its
contributions point at the salt and silver pressure variables; the pressures
were the real fault. A loading describes the whole data set, a contribution
describes one observation, and a variable with a large loading contributes
nothing when that observation sits at its mean.

A contribution is the term-by-term breakdown of the sum that forms the
score, so the terms must add up to the score being decomposed:

    c_ik = x_ik * R_ka        sum_k c_ik = t_ia

with R the score-generating matrix (loadings for PCA, direct_weights_ for
PLS, following the generalisation to any latent-variable model in
Westerhuis, Gurden and Smilde, 2000). For the multiblock models the super
score at component a is formed from the block data deflated through the
earlier components, so the decomposition starts from that same deflated
data; summing over every block and variable returns the super score.

score_contributions() now takes X and returns one row per observation,
matching t2_contributions() and spe_contributions() next to it. Those two
were already correct: t2_contributions multiplies by X_values and telescopes
to T-squared, which is the pattern this function was missing. The group and
two-period forms from the same paper, where x_ik is replaced by a mean or by
a difference of means, are exposed as group_contributions(). The paper's two
presentation scalings are available via scaling=.

The old signature cannot be supported alongside the new one, since a score
vector does not carry the information needed. Calls in the old form raise a
TypeError naming the replacement rather than returning a different number
silently.

The previous tests asserted only that the output equalled dt @ P.T, which
re-derived the implementation and so held whatever it computed. They now
assert the defining property, that the contributions sum to the score, and
that the ranking varies across observations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D58KmvE1L75DDE7pqfJPeL
The user guide already carried the correct formula, x_ik * p_ka, next to
code that called the old back-projection: the prose and the implementation
had drifted apart. The guide now states the property that makes the formula
the right one, namely that the terms sum to the score, says plainly that a
contribution is not a loading and why that distinction is the reason the
diagnostic exists, and covers the group and two-period forms.

Both case-study notebooks print the sum of the contributions alongside the
score, so a reader can see the identity hold rather than take it on trust:
-1.1107 for the flagged food-texture sample, -53.4725 for the flagged
tablet.

Minor rather than patch: score_contributions() changes shape and its old
calling convention now raises. CITATION.cff tracks the version and the
release date, per the repository convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D58KmvE1L75DDE7pqfJPeL
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Codecov flagged 45 uncovered lines on the patch. Writing tests for them
turned up a real defect in group_contributions() rather than only a
coverage gap.

_select_rows fell back to positional selection when a label was missing,
which made the meaning depend on the data. On the LDPE blocks, indexed
1..54, group=[0..9] selected positions (0 is not a label) while
reference=[10..19] selected labels, so the two halves of one comparison
were resolved by different rules and the result was silently off by one
row. Selection is now by index label or boolean mask only; callers wanting
positions pass X.index[...], which the error message says. A rule that
quietly changes with the index is worse than one that refuses.

Coverage of the new code: _scale_block_contributions is now fully
exercised, including both of the paper's scalings and the joint-over-blocks
behaviour that distinguishes them from scaling each block separately.
_diagnostics reaches 97% under the contribution tests alone, with the
remaining misses in unrelated functions. The MBPCA and MBPLS guards were
each tested on one class only; they are now tested on both, along with
blocks supplied as plain arrays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D58KmvE1L75DDE7pqfJPeL

kgdunn commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Pushed af70dac for the Codecov patch coverage. Writing the missing tests turned up a real defect rather than only a coverage gap, and it carries a small semantic decision worth your eye.

group_contributions() selected rows inconsistently. _select_rows fell back to positional selection whenever a label was missing, which made the meaning depend on the data. On the LDPE blocks, indexed 1..54:

model.group_contributions(x_blocks, group=[0..9], reference=[10..19])
#                                          ^ positions        ^ labels

0 is not a label, so the group resolved positionally; every entry of 10..19 is a label, so the reference resolved by label. The two halves of one comparison were resolved by different rules and the answer was silently off by one row. My own test caught it: it expected the shift in average super score and got -0.2859 against -0.4018.

Selection is now by index label or boolean mask only. Callers wanting positions pass X.index[64:74], which the error message says explicitly. I preferred refusing over guessing here: a rule that quietly changes with the index is worse than one that fails loudly, particularly on a diagnostic people act on. Happy to reinstate a positional path behind an explicit argument if you would rather have the convenience.

Coverage of the new code after this commit:

  • _scale_block_contributions fully exercised, including both of the paper's scalings and the joint-over-blocks behaviour that distinguishes them from scaling each block on its own.
  • _diagnostics.py at 97% under the contribution tests alone; the remaining misses sit in unrelated functions.
  • The MBPCA and MBPLS guards were each tested on one class only. Both are now covered, along with blocks supplied as plain ndarrays.

278 passed, 1 skipped across the four multivariate suites; ruff check and mypy both clean.


Generated by Claude Code

tests/test_multivariate_contribution_plots.py encodes Miller, Swanson and
Heckler (1994), the paper the diagnostic comes from, one test per equation,
page or figure, each citing what it encodes:

  https://literature.learnche.org/item/78/contribution-plots-a-missing-link-in-multivariate-quality-control

Equations 1 and 2 (T-squared from the covariance form and from the scores,
and that they agree when no dimension is dropped); equation 3 and its
per-term form, for PCA, for PLS, and for the multiblock super score;
equation 4 and the squared, non-negative Q contributions; the group average
of page 14 and its limiting case where averaging over every batch gives
zero on centred data; the plus and minus one-tenth level-shift weights of
page 18 written out exactly as the paper prints them; the orthogonal
polynomial for a drift from the same page; and both scalings of pages 20
and 21, including the proportion property and the statement that scaling
leaves the pattern of bar heights unchanged.

The tests are written against the paper rather than against the code. The
old tests asserted the output equalled dt @ P.T, which re-derived the
implementation and so held whatever it computed; that is why the defect
survived. To check these are not vacuous in the same way, the old
back-projection was reintroduced temporarily: 9 of the 21 PCA and PLS tests
failed, including every load-bearing one.

Two things came out of writing them.

The paper's general form is an arbitrary linear combination of the rows,
not only a mean or a difference of means, and page 18 gives a case the
group/reference pair cannot express: a run of batches drifting rather than
stepping, weighted by the first order orthogonal polynomial. group_
contributions() now takes weights= for that, with group and reference as
sugar over it.

The remaining Codecov gaps were input guards on the multiblock
spe_contributions, the multiblock form of equation 4, so they are covered
here too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D58KmvE1L75DDE7pqfJPeL

kgdunn commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Added tests/test_multivariate_contribution_plots.py in 5af109e: 24 tests written against the paper rather than against the code, each citing the equation, page or figure it encodes.

Source recorded in the module docstring and linked from every test group:
https://literature.learnche.org/item/78/contribution-plots-a-missing-link-in-multivariate-quality-control

Paper What is pinned
Eq. 1 & 2, p.4 T² from x S⁻¹ x' equals T² from the scores when no dimension is dropped; t2_contributions sums to T²
Eq. 3, p.6-7 Contributions sum to the score, every component, for PCA, PLS and the multiblock super score; each term is literally x_ij · p_jd
p.7-8, Fig. 2 A large loading contributes nothing when the observation sits at its mean, so the loading ranking and the contribution ranking disagree
Eq. 4, p.10 & p.12 Q contributions are the squares of the k residual elements: non-negative, summing to Q. Also per block
p.14 The group average form; and its limiting case, where averaging over every batch gives zero because the data are centred
p.18, Fig. 9 The +0.1 / -0.1 level-shift weights, asserted against the formula exactly as the paper prints it, and against the group/reference spelling
p.18 A drift weighted by the first-order orthogonal polynomial
p.20, Method 1 Divide by the largest absolute contribution in the data set; exactly one variable-batch pair attains ±1
p.21, Method 2 Row absolute values sum to 1, and equal the proportion exactly when every contribution shares a sign
p.20 Both scalings "leave the pattern of bar heights unchanged": constant positive ratio within a row, ranking untouched

These are not vacuous. The old tests asserted contrib == dt @ P.T, which re-derives the implementation and so holds whatever it computes; that is why the defect survived. To check the new ones do not have the same property, I temporarily reintroduced the back-projection:

9 failed, 12 passed

including every load-bearing test. Restored, and the suite is green again.

Two things came out of writing them.

The paper's general form is wider than the API was. Page 14 says the data may be replaced by "a suitably chosen average, or other linear combination of the data", and page 18 gives a case group/reference cannot express: a run of batches drifting rather than stepping, weighted by the first-order orthogonal polynomial. group_contributions() now takes weights= for that, with group/reference as sugar over it. This is a small addition beyond the bug fix, made because the paper is the specification for this PR; say the word and I will pull it back out.

The last Codecov gaps were the multiblock spe_contributions input guards, which is the multiblock form of equation 4, so they are covered in the same file.

303 passed, 1 skipped across the five multivariate suites; ruff check and mypy clean.


Generated by Claude Code

The user guide, the test module and several docstrings carried a note
describing what score_contributions() did before this release. That history
belongs in the changelog, not in prose a reader meets while trying to use
the function: it puts a defunct calculation in front of someone who never
called it, and it dates the moment the version is no longer current.

The error message for a score-vector call now states the requirement and
the alternatives without narrating what changed. It is shorter and reads as
usage guidance rather than a migration note. The internal helper is named
for what it checks, _reject_score_vector_call, rather than for the API it
used to reject.

Behaviour is unchanged: the same calls raise, with the same three
replacements named, and the tests that match on the message text still
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D58KmvE1L75DDE7pqfJPeL
@kgdunn
kgdunn merged commit 14d658c into main Jul 25, 2026
14 checks passed
@kgdunn
kgdunn deleted the claude/modernize-book-figures-ajbqk8 branch July 25, 2026 21:21
kgdunn pushed a commit that referenced this pull request Jul 25, 2026
PR #476 landed the score_contributions fix on main and took 1.61.0, which this
branch had also claimed.  Renumbered to 1.62.0 and collapsed this branch's two
in-PR sections (1.60.1 and 1.61.0) into one, since the PR ships as a single
version bump and neither intermediate version was ever tagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeEyMBnkeSKZ8QbSyejvNu
kgdunn pushed a commit that referenced this pull request Jul 26, 2026
main merged #476, which took the 1.61.0 version number for the
score_contributions fix. This branch had also claimed 1.61.0, so two
unrelated sets of changes were about to ship under one version.

Renumbered to 1.62.0 (MINOR: new features and API additions) in
pyproject.toml and CITATION.cff, with the CHANGELOG section renamed and
placed above main's 1.61.0, whose entry is kept as it stands. The footer
compare links are extended so [Unreleased] runs from v1.62.0 and v1.62.0
runs from v1.61.0.

CITATION.cff date-released set to today rather than either side's value,
since the release date is now today.

Full suite passes on the merged tree: 2163 passed, 2 skipped, which
includes the tests main brought in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVbQmQNRwrSerdh7WQX2Aw
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