Skip to content

Make multi-response optimization work end to end: sweet-spot region, shared desirability, intervals at the optimum#474

Merged
kgdunn merged 6 commits into
mainfrom
claude/document-investigation-gabrh6
Jul 26, 2026
Merged

Make multi-response optimization work end to end: sweet-spot region, shared desirability, intervals at the optimum#474
kgdunn merged 6 commits into
mainfrom
claude/document-investigation-gabrh6

Conversation

@kgdunn

@kgdunn kgdunn commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Summary

The library implements Derringer-Suich desirability correctly, but the surrounding multi-response workflow did not hold together. This PR closes that.

  • The sweet-spot plot did not show the sweet spot. OverlayPlot turned each response's low/high into an AnnotationType.label, and no adapter has a branch for that annotation type, so the limits were dropped silently at render time. Nothing computed the region where all responses are simultaneously in specification. It now computes a feasibility mask and shades it, draws each bounded response's contours at exactly its two specification limits, and reports sweet_spot_fraction / sweet_spot_empty so a caller can tell when no compromise exists.
  • The contour adapter discarded its styling. _contour_trace read only the three grid keys and hard-coded the colorscale, ignoring colorscale, zmin, zmax, contours_coloring, ncontours, layer.opacity, and LayerSpec.color. An overlay therefore rendered as N filled surfaces stacked in one colorscale. Those keys are now honoured, defaulting to the previous values so no existing plot changes.
  • The desirability math existed twice and had drifted. Now shared from experiments/_desirability.py, holding the union of the two behaviours: asymmetric target weights from the plotting copy, strict argument checking from the optimizer copy.
  • desirability_weights was misnamed: it is the importance vector, and it silently overrode per-goal importance. Renamed to response_importance, with the old name kept as a deprecated alias.
  • Goals were matched to models by list position while goal["response"] was documented as the key and never read, so reordering goals silently optimised the wrong thing. Now matched by name when both sides supply one, falling back to position with a warning.
  • optimize_responses returned no uncertainty at the optimum. Passing fitted_results alongside fitted_models now attaches a confidence interval and a prediction interval per response. Behaviour is unchanged when omitted.
  • The search region was hard-coded to the factorial cube. That is right for a two-level design but understates a central composite design, whose axial runs sit at plus or minus alpha: the optimizer would not consider settings the experiment had actually covered, and inside_design_space could report a legitimately explored point as outside. New search_bounds sets the region, defaulting to the previous (-1, 1).

Also fixed: the desirability contour plot's single-response fallback set the bounds to negative and positive infinity, so the ramp computed inf/inf and the entire grid came out NaN.

Test plan

  • uv run pytest on the merged tree: 2163 passed, 2 skipped
  • uv run ruff check .
  • uv run mypy src/process_improve
  • Rendered a two-response overlay and cross-checked the shaded mask against an independently recomputed feasibility set: no disagreements over 2500 grid cells. The existing tests never asserted z_matrix values or rendered output, so this could not be left to the suite.
  • New tests assert where the optimum lands (a plane's optimum at its known corner; two conflicting responses settling strictly between their individual optima; raising one response's importance moving the compromise its way), the sweet-spot mask against a hand-computed region, empty-sweet-spot reporting, goal matching by name, the deprecated alias, interval ordering at the optimum, and the widened search region. The previous end-to-end tests asserted only that the composite exceeded zero, which a badly wrong optimum would also satisfy.
  • New adapter tests cover each newly honoured contour style key.
  • search_bounds checked against the bioreactor example used in the book: its optimum is interior to the cube, so that answer is unchanged.

Checklist

  • Version bumped in pyproject.toml (MINOR, 1.61.0 to 1.62.0: new features plus API additions)
  • Tests added or updated where relevant
  • ruff check . passes
  • CHANGELOG.md updated, CITATION.cff kept in sync

Note on the version number

This branch originally claimed 1.61.0. #476 merged to main first and took that number for the score_contributions fix, so both were about to ship under one version. Merging main in renumbered this work to 1.62.0, leaving main's 1.61.0 entry exactly as it stands and adding the matching compare links to the changelog footer.

Related

claude added 2 commits July 24, 2026 22:44
The Derringer-Suich desirability helpers existed twice, in
experiments/optimization.py and in the optimisation plots module, and the
two copies had drifted apart. The plotting copy supported an asymmetric
weight on a target goal; the optimizer copy did not. The optimizer copy
raised on an unrecognised goal and guarded a zero importance sum; the
plotting copy silently returned zero and divided by zero respectively.
A reader could not tell which behaviour they would get.

Move them to experiments/_desirability.py, holding the union: asymmetric
target weights, plus the strict argument checking. A typo in a goal name
now raises instead of masquerading as an infeasible response.

The module docstring separates the two quantities that were easy to
confuse: weight shapes an individual ramp, importance sets how much a
response counts when the composite is formed.

Also fixes the single-response fallback in the desirability contour plot,
which set low and high to negative and positive infinity. That made the
ramp compute inf/inf, so the whole grid came out NaN and the plot was
blank. Bounds are now derived from the range the model spans over the
plotted region.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVbQmQNRwrSerdh7WQX2Aw
The overlay plot's docstring promised "constraint regions shaded" and a
"feasible operating region that satisfies all specifications", but nothing
computed one. Each response's low/high bounds became an AnnotationType.label,
and no adapter has a branch for that annotation type, so the limits were
dropped silently at render time. What reached the reader was N filled
contour surfaces stacked in a single colorscale.

Now the plot computes the region where every bounded response is
simultaneously within specification, and shades it. Each bounded response
draws its contours at exactly its two specification limits rather than at
eight arbitrary levels, so the boundary that matters is the one visible.

The region can be empty, which is a real answer to a real question: these
specifications cannot be met together. metadata carries sweet_spot_fraction
and sweet_spot_empty so a caller can tell without inspecting pixels, and
distinguishes "empty" from "no bounds were given" (None).

The contour adapter now honours the style keys it was already being handed:
colorscale, zmin, zmax, showscale, ncontours, contours_coloring, explicit
contour start/end/size, layer opacity, and the layer colour for line-drawn
contours. Defaults match the previous hard-coded values, so no existing
plot changes. Without this the sweet-spot layer could not be drawn
distinctly from the response contours.

_get_overlay_responses now also accepts the output of optimize_responses
directly. It previously read only analysis_results["optimization"]["responses"],
a shape no library function produces, so every caller had to hand-assemble it.

Verified by rendering a two-response case and cross-checking the shaded
mask against an independently recomputed feasibility set: no disagreements
over 2500 grid cells.

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

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Four changes to the desirability path, all user-facing.

The optimizer reported a point and nothing about how well that point was
known. Coefficients alone cannot say: the residual variance and the design's
leverage live on the fitted model object, not in its coefficients. Passing
fitted_results alongside fitted_models now yields a confidence interval and a
prediction interval per response at the optimum. Omitting it changes nothing.
This matters in practice because a predicted optimum can sit inside its
required range while the interval around it does not.

Goals were consumed in list order while goal["response"] was documented as the
key that ties a goal to its model, and was never read. Passing the two lists in
different orders optimised the wrong thing and said nothing. They are now
matched by name when both sides supply one, falling back to position with a
warning when they do not.

desirability_weights is renamed to response_importance. The old name described
the wrong quantity: these are importances, setting how much each response
counts in the composite, not the per-goal weight that shapes an individual
ramp. The old name still works and warns.

The desirability result also carries a "responses" list pairing coefficients
with specification limits, so it can go straight to the overlay plot instead of
the caller assembling a dict by hand.

New tests assert where the optimum lands rather than only that a number came
back: a plane's optimum at its known corner, two conflicting responses settling
strictly between their individual optima, and raising one response's importance
moving the compromise its way. The existing end-to-end tests asserted only that
the composite exceeded zero, which a badly wrong optimum would also satisfy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVbQmQNRwrSerdh7WQX2Aw
claude added 3 commits July 25, 2026 05:41
The desirability search was hard-coded to the coded cube, and a stationary
point was judged inside or outside the design space by the same test. That
is right for a two-level design, whose runs sit on the cube. It is wrong for
a central composite design, whose axial runs sit at plus or minus alpha: the
optimizer would refuse to consider settings the experiment had actually
covered, and inside_design_space could report a legitimately explored point
as outside.

search_bounds now sets that region. It takes one (low, high) pair applied to
every factor, or a mapping for per-factor control, and defaults to (-1, 1),
so nothing changes for callers who do not pass it. The multi-start sampling
follows the bounds too, rather than always seeding inside the cube, so
widening the region actually widens where the search looks.

Malformed bounds are rejected rather than silently coerced: a reversed or
degenerate pair, a non-finite limit, or a mapping naming a factor the model
does not have. That last one matters because a mistyped factor name would
otherwise be ignored without complaint.

Checked against the bioreactor example used in the book: its optimum is
interior to the cube, so the answer there is unchanged, which is the
reassuring case. The tests cover the cases where it is not: a plane whose
optimum moves out to the widened bound, and a quadratic whose stationary
point at 1.2 is outside the cube but inside a rotatable two-factor composite
design's reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVbQmQNRwrSerdh7WQX2Aw
The region arrives over the MCP boundary as a JSON list rather than a tuple,
so it is converted before reaching the library. That conversion had no test,
including the per-factor mapping form and the error path for a reversed pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVbQmQNRwrSerdh7WQX2Aw
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
@kgdunn
kgdunn merged commit 0513197 into main Jul 26, 2026
14 checks passed
@kgdunn
kgdunn deleted the claude/document-investigation-gabrh6 branch July 26, 2026 06:52
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