Compute score contributions from the data, not from the score#476
Conversation
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 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
|
Pushed
model.group_contributions(x_blocks, group=[0..9], reference=[10..19])
# ^ positions ^ labels
Selection is now by index label or boolean mask only. Callers wanting positions pass Coverage of the new code after this commit:
278 passed, 1 skipped across the four multivariate suites; 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
|
Added Source recorded in the module docstring and linked from every test group:
These are not vacuous. The old tests asserted 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 The last Codecov gaps were the multiblock 303 passed, 1 skipped across the five multivariate suites; 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
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
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
Summary
score_contributions()onPCA,PLS,MBPCAandMBPLScomputed(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 tot_ia.group_contributions()for the cluster and two-period forms from the same paper, which is what the oldt_start/t_endpair was reaching for, andscaling=for the paper's two presentation scalings.score_contributions()now takesXand returns one row per observation, matchingt2_contributions()andspe_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 aTypeErrornaming 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)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.rstalready 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:
with
Rthe score-generating matrix:loadings_for PCA,direct_weights_for PLS (so thatT = 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 componentais 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()andspe_contributions()were already correct and are unchanged.t2_contributionsmultiplies byX_valuesand telescopes to T², which is exactly the patternscore_contributionswas missing one function away.Migration
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
dt @ P.T, which re-derived the implementation and so held whatever it computed; that is why this survived.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 .anduv run mypy src/process_improveboth pass.uv run sphinx-build docs docs/_build/html -b html -Wreachesbuild finishedwith one warning, an autodoc import failure onprocess_improve.batch.plottingcaused byseabornnot being installed in this sandbox. It is unrelated to this change (it also breakstests/batch/test_plotting.pyhere) 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
pyproject.toml(MINOR:1.60.0->1.61.0; the method changes shape and the old convention now raises).CITATION.cffversion anddate-releasedsynced.ruff check .passesCHANGELOG.mdupdatedReferences
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