diff --git a/CHANGELOG.md b/CHANGELOG.md index 522e4979..7ea6fe53 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.2] - 2026-09-07 ### Fixed @@ -4284,7 +4298,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.2...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.2...v1.82.0 [1.81.2]: https://github.com/kgdunn/process-improve/compare/v1.81.1...v1.81.2 [1.81.1]: https://github.com/kgdunn/process-improve/compare/v1.81.0...v1.81.1 [1.81.0]: https://github.com/kgdunn/process-improve/compare/v1.80.0...v1.81.0 diff --git a/CITATION.cff b/CITATION.cff index a6a8abf2..e43fef37 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,7 +12,7 @@ authors: repository-code: "https://github.com/kgdunn/process-improve" url: "https://kgdunn.github.io/process-improve/" license: MIT -version: 1.81.2 +version: 1.82.0 date-released: "2026-09-07" keywords: - chemometrics diff --git a/pyproject.toml b/pyproject.toml index fda2fbcc..f713c6bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "process-improve" -version = "1.81.2" +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 8d2de8cc..e2c9623c 100644 --- a/tests/test_multivariate.py +++ b/tests/test_multivariate.py @@ -4581,6 +4581,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})