Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
77 changes: 68 additions & 9 deletions src/process_improve/multivariate/plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}<br>" + label + ": %{customdata:.4g}<extra></extra>",
}


def score_plot( # noqa: C901, PLR0913
model: BaseEstimator,
pc_horiz: int = 1,
Expand All @@ -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.

Expand All @@ -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::

Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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"]:
Expand Down
38 changes: 38 additions & 0 deletions tests/test_multivariate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down