Skip to content

Add PLS model inversion (null space) and O-PLS support#470

Merged
kgdunn merged 7 commits into
mainfrom
claude/pid-book-opls-code-xzm6ef
Jul 23, 2026
Merged

Add PLS model inversion (null space) and O-PLS support#470
kgdunn merged 7 commits into
mainfrom
claude/pid-book-opls-code-xzm6ef

Conversation

@kgdunn

@kgdunn kgdunn commented Jul 23, 2026

Copy link
Copy Markdown
Owner

What and why

Adds latent-variable model inversion and an O-PLS estimator to
process-improve, motivated by García-Carrión et al., "On the Equivalence
Between Null Space and Orthogonal Space in Latent Variable Regression Modeling"

(Journal of Chemometrics, 2025, e70057). The paper proves that, for a single
response, the null space from PLS model inversion and the orthogonal space
from O-PLS are the same linear space. This PR brings both capabilities into the
package; it also underpins a companion page in the pid-book.

PLS.invert() - model inversion + null space

Given a desired response on the original Y scale, PLS.invert():

  • returns the minimum-norm (direct-inversion) input vector the model predicts
    will achieve it;
  • returns an orthonormal basis for the (A - r)-dimensional null space (the
    family of input vectors that all yield the same prediction);
  • accepts null_space_coordinates to move along that space and meet secondary
    criteria (cost, safety, operability) without changing the prediction;
  • reports the solution's Hotelling's T2 to flag extrapolation.

Verified against the paper's Case Study 1 (Table 3).

OPLS - Orthogonal PLS estimator (single response)

Trygg-Wold O-PLS NIPALS: one Y-predictive component plus
n_orthogonal_components Y-orthogonal components. Methods: fit, transform
(predictive scores), correct (orthogonal-signal-corrected X), predict, and
invert. OPLS.invert() reaches a desired response through a single division
and returns the orthogonal-space basis.

Tests confirm the paper's results on real and synthetic data:

  • O-PLS(1; A-1) reproduces A-component PLS predictions and beta to ~1e-14;
  • orthogonal scores are Y-orthogonal (To' y = 0);
  • the O-PLS orthogonal space and the PLS null space span the same subspace
    (principal cosine = 1.0).

Worked example (cheddar cheese)

The cheddar-cheese dataset (already used elsewhere in the pid-book PLS section)
is vendored as an offline fixture. A test trains on cheeses 5-30 and designs a
chemistry toward each held-out cheese's taste; every design round-trips to its
target, and the more ambitious targets land at higher T2 (a teaching point for
the book).

Housekeeping

  • Version 1.58.0 -> 1.59.0 (new public API); CITATION.cff and CHANGELOG.md
    updated in step.
  • New tests: test_multivariate_inversion.py, test_multivariate_opls.py, plus
    OPLS added to the sklearn-compatibility checks. ruff and mypy clean; no
    regressions in the multivariate suite.
  • CI fix (not caused by this diff): CI's uv sync had auto-upgraded ruff
    past the lockfile to a version that enabled new rules (CPY001, RUF100/ISC
    changes), reddening lint on every PR. Upper-bounded ruff<0.16 in both dev
    groups (mirroring the existing mypy pin) and added an explicit CPY001
    ignore for the repo's house copyright style.

Follow-up (separate repos)

  • A pid-book page in the PLS section tying the cheese example to the
    null-space / orthogonal-space equivalence, with a figure in kgdunn/figures.

claude added 3 commits July 23, 2026 19:13
Given a desired response on the original Y scale, PLS.invert() runs the
model backwards to return the minimum-norm input vector that achieves it,
plus an orthonormal basis for the (A - r)-dimensional null space of input
vectors that all yield the same prediction. Callers can move along that
space (null_space_coordinates) to meet secondary criteria without changing
the predicted response, and the solution's Hotelling's T2 is reported to
flag extrapolation.

Reproduces the null-space results of Garcia-Carrion et al. (2025, J.
Chemometrics), whose single-response proof identifies this null space with
the orthogonal space of an O-PLS model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsTypY4FcRbrXRxorb9raz
A newer ruff release promoted CPY001 from preview to stable. With
select=["ALL"], it then fired on ~every file (292 hits) because the
repo's "# (c) Kevin Dunn" house notice does not match ruff's default
copyright regex, turning the lint gate red on all PRs regardless of their
diff. Copyright notices are managed in the repo's own style, not via ruff,
so ignore CPY001 to make the gate independent of the installed ruff version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsTypY4FcRbrXRxorb9raz
Adds tests/test_multivariate_inversion.py: reproduces the paper's Case
Study 1 (Table 3), checks the invert round-trip, null-space invariance,
input coercion, T2 reporting, the unique single-component case, the cheddar
cheese hold-out design scenario, and the multi-response null-space
dimension on the real LDPE data. Vendors cheddar-cheese.csv as an offline
fixture.

Also upper-bounds ruff (>=0.11.0,<0.16) in both dev dependency groups. CI's
'uv sync' has no --frozen flag and the old 'ruff>=0.11.0' let it resolve to
a newer ruff than the lock, which promoted CPY001 and changed RUF100/ISC
handling and turned the lint gate red on every PR. This mirrors the existing
mypy upper bound and keeps the gate independent of the latest ruff release.

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

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

claude added 3 commits July 23, 2026 19:29
…ale=False)

Adds tests for the DataFrame and dict forms of y_desired, the wrong
target-count and NaN-target error paths, and the unscaled (scale=False)
code path, bringing patch coverage of invert() to full.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsTypY4FcRbrXRxorb9raz
New OPLS class for single-response Orthogonal PLS: fit (Trygg-Wold NIPALS),
transform (predictive scores), correct (orthogonal-signal-corrected X),
predict, and invert. OPLS.invert() reaches a desired response through one
division and returns the orthogonal-space basis; for a single response this
is the same set of designs as PLS.invert()'s null space (Garcia-Carrion et
al., 2025). Tests confirm O-PLS(1; A-1) reproduces A-component PLS
predictions and beta to ~1e-14, the orthogonal scores are Y-orthogonal, and
the orthogonal space coincides with the PLS null space (cosine 1.0).

Exported from methods.py and the multivariate package; added to the sklearn
compatibility checks. Folded into the 1.59.0 changelog entry alongside
PLS.invert (same PR / release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsTypY4FcRbrXRxorb9raz
Adds a scale=False end-to-end test (predict/transform/invert) and a 2-D
ndarray Y case, taking _opls.py to full patch coverage. Also broadens the
.coveragerc TYPE_CHECKING exclude to match the 'if typing.TYPE_CHECKING:'
form the codebase actually uses, so type-only import blocks no longer count
as missed lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsTypY4FcRbrXRxorb9raz
Adds a two-response design test: seven solvent properties predicting logP
and Solubility, inverted toward a target (logP, Solubility). Confirms the
null-space dimension is A - rank(Y), the design round-trips to both targets,
and moving along the null space leaves both predictions fixed. Vendors
solvents.csv as an offline fixture. This is the boundary of the null-space /
orthogonal-space equivalence, which is proven for a single response only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsTypY4FcRbrXRxorb9raz
@kgdunn
kgdunn merged commit 2462a43 into main Jul 23, 2026
14 checks passed
@kgdunn
kgdunn deleted the claude/pid-book-opls-code-xzm6ef branch July 23, 2026 22:13
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