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})