From 9652b43bb2aaf245d6aab634c3ccc3f5d00fbeb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 05:58:24 +0000 Subject: [PATCH] score_plot: let the marker area carry a value A score plot says how extreme an observation is along the components and nothing about how far it sits off them, so the reader needs a second plot for the second question. `sizes=` puts that second quantity, typically the SPE, on the marker area, and one plot answers both. Area, not diameter, is proportional to the value, and one scale is shared by the plain and the highlighted traces, so a highlighted point keeps its own area instead of being enlarged: two meanings on one channel cannot both be read. `settings["size_max"]` sets the largest marker and `size_name` names the value in the hover text, so a reader can recover it exactly. A negative value, a series that does not cover every observation, or an all-zero series raises rather than drawing a plot that cannot be read. The default path is untouched: without `sizes` the markers keep their fixed size and carry no `sizemode`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LXthGpHLQFfGubBiKFtGAE --- CHANGELOG.md | 17 ++++- CITATION.cff | 4 +- pyproject.toml | 2 +- src/process_improve/multivariate/plots.py | 77 ++++++++++++++++++++--- tests/test_multivariate.py | 38 +++++++++++ 5 files changed, 125 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491867c5..de8d61fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ those changes. ## [Unreleased] +## [1.82.0] - 2026-09-07 + +### Added + +- `score_plot(sizes=...)`: a value per observation, whose **area** the marker + then carries, so a score plot can answer two questions at once (which batches + are extreme along the components, and how far each sits off them). Area, not + diameter, is proportional to the value, and one scale is shared by the plain + and the highlighted traces, so a highlighted point keeps its own area rather + than being enlarged. `settings["size_max"]` sets the diameter of the largest + marker and `size_name` names the value in the hover text. A negative value, a + series that does not cover every observation, or an all-zero series raises + rather than drawing a plot that cannot be read. + ## [1.81.0] - 2026-09-05 Mid-batch prediction and on-line monitoring for batch PLS models, so a @@ -4256,7 +4270,8 @@ this entry records them together. - Reworked the README with a sharper value proposition and a "Why not scikit-learn?" comparison table. -[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.81.0...HEAD +[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.82.0...HEAD +[1.82.0]: https://github.com/kgdunn/process-improve/compare/v1.81.0...v1.82.0 [1.81.0]: https://github.com/kgdunn/process-improve/compare/v1.80.0...v1.81.0 [1.80.0]: https://github.com/kgdunn/process-improve/compare/v1.79.1...v1.80.0 [1.79.1]: https://github.com/kgdunn/process-improve/compare/v1.79.0...v1.79.1 diff --git a/CITATION.cff b/CITATION.cff index 03c466ca..ae1f3e1c 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,8 +12,8 @@ authors: repository-code: "https://github.com/kgdunn/process-improve" url: "https://kgdunn.github.io/process-improve/" license: MIT -version: 1.81.0 -date-released: "2026-09-05" +version: 1.82.0 +date-released: '2026-09-07' keywords: - chemometrics - multivariate analysis diff --git a/pyproject.toml b/pyproject.toml index 9b70a606..f713c6bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "process-improve" -version = "1.81.0" +version = "1.82.0" description = 'Designed Experiments; Latent Variables (PCA, PLS, multivariate methods with missing data); Process Monitoring; Batch data analysis.' readme = "README.md" license = "MIT" diff --git a/src/process_improve/multivariate/plots.py b/src/process_improve/multivariate/plots.py index 17b0f609..861618ee 100644 --- a/src/process_improve/multivariate/plots.py +++ b/src/process_improve/multivariate/plots.py @@ -81,6 +81,48 @@ def plot_pre_checks(model: BaseEstimator, pc_horiz: int, pc_vert: int, pc_depth: return True +def _area_scale(sizes: pd.Series | None, index: pd.Index, size_max: float) -> dict: + """Return the Plotly keys that make a marker's area, not its diameter, proportional to ``sizes``. + + One ``sizeref`` is computed for the whole series and shared by every trace, so that a + highlighted point and a plain one of the same value are drawn the same size. Values that + cannot be an area, or that do not cover the observations being plotted, raise instead. + """ + if sizes is None: + return {} + values = pd.Series(sizes).reindex(index).astype(float) + if values.isna().any(): + msg = f"`sizes` has no value for these observations: {list(values.index[values.isna()])[:5]}" + raise ValueError(msg) + if (values < 0).any(): + msg = "`sizes` cannot be negative: the marker area is proportional to it." + raise ValueError(msg) + largest = float(values.max()) + if largest <= 0: + msg = "`sizes` must have at least one positive value to set the marker scale." + raise ValueError(msg) + return {"sizemode": "area", "sizeref": 2.0 * largest / size_max**2, "sizemin": 2} + + +def _sized_marker(styling: dict, index: list, sizes: pd.Series | None, marker_area: dict) -> dict: + """Return the marker specification for one trace, with its area carrying ``sizes`` when given.""" + if sizes is None: + return styling + return {**styling, "size": pd.Series(sizes).reindex(index).astype(float).to_numpy(), **marker_area} + + +def _size_hover(index: list, sizes: pd.Series | None, size_name: str) -> dict: + """Return hover text reporting the value the marker area stands for, so it can be read exactly.""" + if sizes is None: + return {} + values = pd.Series(sizes).reindex(index).astype(float) + label = size_name or "size" + return { + "customdata": values.to_numpy(), + "hovertemplate": "%{text}
" + label + ": %{customdata:.4g}", + } + + def score_plot( # noqa: C901, PLR0913 model: BaseEstimator, pc_horiz: int = 1, @@ -89,6 +131,9 @@ def score_plot( # noqa: C901, PLR0913 items_to_highlight: dict[str, list] | None = None, settings: dict | None = None, fig: go.Figure | None = None, + *, + sizes: pd.Series | None = None, + size_name: str = "", ) -> go.Figure: """Generate a 2D or 3D score plot for the given latent variable model. @@ -114,6 +159,17 @@ def score_plot( # noqa: C901, PLR0913 will highlight the items in ``items_in_red`` with the given colour and shape. + sizes : pd.Series, optional + One non-negative value per observation, indexed as the scores are. The marker + **area** is made proportional to it, so that a marker of twice the area stands for + twice the value, and the largest value is drawn ``settings["size_max"]`` pixels + across. The plain and the highlighted traces share one scale, and a highlighted + point keeps its own area rather than being enlarged, because two meanings on one + channel cannot both be read. Give the reader that scale as well: an area cannot be + read off a plot on its own. + size_name : str, optional + What ``sizes`` measures, for example ``"SPE"``; it names the value in the hover text. + settings : dict Default settings:: @@ -128,6 +184,8 @@ def score_plot( # noqa: C901, PLR0913 # (pc_depth > 0). "show_labels": False, # bool: add a label for each observation "show_legend": True, # bool: show clickable legend + "size_max": 26, # float: diameter in pixels of the + # largest marker, when `sizes` is given "html_image_height": 500, # int: image height in pixels "html_aspect_ratio_w_over_h": 16/9, # float: width as ratio of height "template": "pi_journal", # str: registered Plotly theme name @@ -167,11 +225,13 @@ def check_ellipse_conf_level(cls, val: float) -> float: ) show_labels: bool = False show_legend: bool = True + size_max: float = 26.0 html_image_height: float = 500.0 html_aspect_ratio_w_over_h: float = 16 / 9.0 template: str = DEFAULT_THEME setdict = Settings(**settings).model_dump() if settings else Settings().model_dump() + marker_area = _area_scale(sizes, data_to_plot.index, setdict["size_max"]) if fig is None: fig = go.Figure() @@ -198,11 +258,10 @@ def check_ellipse_conf_level(cls, val: float) -> float: z=data_to_plot.loc[default_index, pc_depth], name=name, mode="markers+text" if setdict["show_labels"] else "markers", - marker=dict( - symbol="circle", - ), + marker=_sized_marker({"symbol": "circle"}, default_index, sizes, marker_area), text=list(default_index), textposition="top center", + **_size_hover(default_index, sizes, size_name), ) ) # Items to highlight, if any @@ -215,9 +274,10 @@ def check_ellipse_conf_level(cls, val: float) -> float: z=data_to_plot.loc[index, pc_depth], name=name, mode="markers+text" if setdict["show_labels"] else "markers", - marker=styling, + marker=_sized_marker(styling, index, sizes, marker_area), text=list(index), textposition="top center", + **_size_hover(index, sizes, size_name), ) ) else: @@ -228,12 +288,10 @@ def check_ellipse_conf_level(cls, val: float) -> float: y=data_to_plot.loc[default_index, pc_vert], name=name, mode="markers+text" if setdict["show_labels"] else "markers", - marker=dict( - symbol="circle", - size=7, - ), + marker=_sized_marker({"symbol": "circle", "size": 7}, default_index, sizes, marker_area), text=default_index, textposition="top center", + **_size_hover(default_index, sizes, size_name), ) ) # Items to highlight, if any @@ -245,9 +303,10 @@ def check_ellipse_conf_level(cls, val: float) -> float: y=data_to_plot.loc[index, pc_vert], name=name, mode="markers+text" if setdict["show_labels"] else "markers", - marker=styling, + marker=_sized_marker(styling, index, sizes, marker_area), text=list(index), textposition="top center", + **_size_hover(index, sizes, size_name), ) ) if setdict["show_ellipse"]: diff --git a/tests/test_multivariate.py b/tests/test_multivariate.py index c0b4cea3..b033103c 100644 --- a/tests/test_multivariate.py +++ b/tests/test_multivariate.py @@ -4552,6 +4552,44 @@ def test_score_plot_basic(fixture_pca_for_plots: PCA) -> None: assert len(fig.data) >= 1 # at least the scores trace +def test_score_plot_sizes_area_encoding(fixture_pca_for_plots: PCA) -> None: + """`sizes` puts the value on the marker area, on one scale shared by every trace.""" + model = fixture_pca_for_plots + spe = model.spe_.iloc[:, -1] + plain = model.score_plot() + assert plain.data[0].marker.sizemode is None # the default path is untouched + assert plain.data[0].marker.size == 7 + + fig = model.score_plot(sizes=spe, size_name="SPE") + marker = fig.data[0].marker + assert marker.sizemode == "area" + assert marker.size == pytest.approx(spe.to_numpy()) + # sizeref is what turns a value into an area: the largest value fills `size_max` pixels. + assert marker.sizeref == pytest.approx(2.0 * spe.max() / 26.0**2) + assert "SPE" in fig.data[0].hovertemplate + + highlighted = model.score_plot( + items_to_highlight={'{"color": "red"}': list(spe.index[:2])}, sizes=spe, size_name="SPE" + ) + refs = {trace.marker.sizeref for trace in highlighted.data if trace.marker.sizeref is not None} + assert len(refs) == 1, "the highlighted trace must be read on the same scale as the rest" + + +def test_score_plot_sizes_settings_and_guards(fixture_pca_for_plots: PCA) -> None: + """`size_max` sets the largest marker, and a value that cannot be an area is refused.""" + model = fixture_pca_for_plots + spe = model.spe_.iloc[:, -1] + fig = model.score_plot(sizes=spe, settings={"size_max": 40}) + assert fig.data[0].marker.sizeref == pytest.approx(2.0 * spe.max() / 40.0**2) + + with pytest.raises(ValueError, match="cannot be negative"): + model.score_plot(sizes=-spe) + with pytest.raises(ValueError, match="no value for these observations"): + model.score_plot(sizes=spe.iloc[:3]) + with pytest.raises(ValueError, match="at least one positive value"): + model.score_plot(sizes=spe * 0.0) + + def test_score_plot_with_ellipse(fixture_pca_for_plots: PCA) -> None: """score_plot with ellipse should have an extra trace for the ellipse.""" fig = fixture_pca_for_plots.score_plot(settings={"show_ellipse": True})