Make multi-response optimization work end to end: sweet-spot region, shared desirability, intervals at the optimum#474
Merged
Conversation
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 Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
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
Merged
4 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The library implements Derringer-Suich desirability correctly, but the surrounding multi-response workflow did not hold together. This PR closes that.
OverlayPlotturned each response'slow/highinto anAnnotationType.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 reportssweet_spot_fraction/sweet_spot_emptyso a caller can tell when no compromise exists._contour_traceread only the three grid keys and hard-coded the colorscale, ignoringcolorscale,zmin,zmax,contours_coloring,ncontours,layer.opacity, andLayerSpec.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.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_weightswas misnamed: it is the importance vector, and it silently overrode per-goalimportance. Renamed toresponse_importance, with the old name kept as a deprecated alias.goal["response"]was documented as the key and never read, so reorderinggoalssilently optimised the wrong thing. Now matched by name when both sides supply one, falling back to position with a warning.optimize_responsesreturned no uncertainty at the optimum. Passingfitted_resultsalongsidefitted_modelsnow attaches a confidence interval and a prediction interval per response. Behaviour is unchanged when omitted.inside_design_spacecould report a legitimately explored point as outside. Newsearch_boundssets 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/infand the entire grid came out NaN.Test plan
uv run pyteston the merged tree: 2163 passed, 2 skippeduv run ruff check .uv run mypy src/process_improvez_matrixvalues or rendered output, so this could not be left to the suite.search_boundschecked against the bioreactor example used in the book: its optimum is interior to the cube, so that answer is unchanged.Checklist
pyproject.toml(MINOR, 1.61.0 to 1.62.0: new features plus API additions)ruff check .passesCHANGELOG.mdupdated,CITATION.cffkept in syncNote on the version number
This branch originally claimed 1.61.0. #476 merged to main first and took that number for the
score_contributionsfix, 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
mergeable_state: clean.