Skip to content

Commit b667ac2

Browse files
committed
Add optional matplotlib plotting API faithful to R NNS colors
Introduce `nns.plotting`, an optional (`ovvo-nns[plot]`) visual plotting subpackage that is color/element-faithful to R NNS `plot = TRUE` output but not pixel-diffed. - pyproject: add `[plot]` optional extra (matplotlib>=3.7); keep core NumPy/SciPy-only; matplotlib imported lazily per function with a clear ImportError when absent. Add mypy override for matplotlib.*. - palette.py: pin exact R grDevices hex, flagging the fidelity traps where R and matplotlib disagree (green -> #00FF00, grey -> #BEBEBE); rainbow() emulates R's HSV rainbow(n). - Plot functions for reg, part, arma, arma_optim, cdf/VaR, fsd/ssd/tsd, anova, causation, norm, seas, diff, and copula. Each takes a computed result (or the same inputs) plus ax=None, returns the Axes, and never calls plt.show(). - tests/plotting: Agg-backend tests asserting artist colors and which element they sit on (no pixel/PDF comparison); palette trap tests; lazy-import tests. - docs/plot_parity_policy.md + README: document the new opt-in API. - CI: install the plot extra and run the plotting color-fidelity tests.
1 parent 87976f9 commit b667ac2

23 files changed

Lines changed: 1722 additions & 48 deletions

.github/workflows/native-backend-ci.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ jobs:
2727
python -m pip install -U pip
2828
python -m pip install build scikit-build-core nanobind pytest ruff mypy numpy scipy
2929
python -m pip install hypothesis pytest-benchmark pytest-xdist
30+
python -m pip install "matplotlib>=3.7"
3031
31-
- name: Install package editable
32-
run: python -m pip install -e .
32+
- name: Install package editable (with optional plot extra)
33+
run: python -m pip install -e ".[plot]"
3334

3435
- name: Run native import smoke test
3536
run: python -c "import nns._nnscore as c; print(c.lpm(2.0, 0.0, [-2.0, -1.0, 0.5, 3.0]))"
@@ -40,6 +41,9 @@ jobs:
4041
- name: Run parity from committed R cache
4142
run: NNS_R_CACHE_ONLY=1 python -m pytest -q tests/parity
4243

44+
- name: Run plotting color-fidelity tests
45+
run: python -m pytest -q tests/plotting
46+
4347
- name: Run vignette examples
4448
run: |
4549
if [ -f tests/docs/test_vignette_examples.py ]; then

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ The public package is Python-native and does not call R at runtime. Some core ke
3939
pip install ovvo-nns
4040
```
4141

42+
For the optional matplotlib plotting API (`nns.plotting`), install the extra:
43+
44+
```bash
45+
pip install "ovvo-nns[plot]"
46+
```
47+
48+
The core stays NumPy/SciPy-only; matplotlib is required only for `nns.plotting`
49+
and is imported lazily. See [`docs/plot_parity_policy.md`](docs/plot_parity_policy.md).
50+
4251
Use the package as `nns`:
4352

4453
```python
@@ -138,7 +147,7 @@ Important boundaries:
138147
- Stochastic exact stream parity is not expected because Python paths use NumPy random generation.
139148
- Factor and class ordering should be passed explicitly when ordering matters.
140149
- Direct raw-factor `nns_m_reg(..., factor_2_dummy=True)` is intentionally guarded. Use `prepare_factor_predictors(...)` before `nns_m_reg(...)`.
141-
- Plotting arguments are generally ignored and data is returned instead.
150+
- Compute functions' `plot` arguments are ignored and data is returned instead; visual plotting is a separate opt-in API in `nns.plotting` (install `ovvo-nns[plot]`), color/element-faithful to R but not pixel-diffed.
142151

143152
See [behavior conventions](docs/conventions.md) for detailed compatibility notes.
144153

docs/plot_parity_policy.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,38 @@
22

33
## Summary
44

5-
Graphics-device artifacts are **intentionally not compared** in CI parity. The
6-
parity suite validates the **returned values** of NNS functions, never the
7-
generated plots, PDFs, or other graphics-device output.
5+
Graphics-device artifacts are **intentionally not pixel-compared** in CI
6+
parity. The parity suite validates the **returned values** of NNS functions,
7+
never byte-/pixel-identical plot, PDF, or other graphics-device output.
88

99
This is a deliberate, permanent policy decision — not an unresolved migration
1010
blocker. R plotting and Python plotting use different graphics stacks, and a
1111
faithful value-level port does not require byte-identical (or pixel-identical)
1212
plot artifacts.
1313

14+
## A visual plotting API now exists (`nns.plotting`)
15+
16+
The Python port now ships an **optional** plotting API in the `nns.plotting`
17+
subpackage (`pip install ovvo-nns[plot]`). It is **color/element-faithful to R
18+
but not pixel-diffed**: tests assert *artist colors and which element they sit
19+
on*, never rendered images.
20+
21+
- matplotlib is an optional extra (`[project.optional-dependencies].plot`). The
22+
NNS core stays NumPy/SciPy-only; matplotlib is imported lazily inside each
23+
plot function and a clear `ImportError("install ovvo-nns[plot]")` is raised if
24+
it is absent. matplotlib is **never** imported at package top level.
25+
- Each `plot_*` function takes an already-computed NNS result (or the same raw
26+
inputs) plus a keyword `ax=None`, returns the `Axes`/`Figure`, and **never**
27+
calls `plt.show()`. The compute functions' `plot=False` default behavior is
28+
untouched; plotting is a separate opt-in call.
29+
- Colors are pinned in `nns.plotting.palette` to the exact R `grDevices` hex
30+
used by `tools/NNS/R/*.R`. R and matplotlib agree on `steelblue`/`red` but
31+
**disagree** on `green` (R `#00FF00` vs mpl `#008000`) and `grey` (R `#BEBEBE`
32+
vs mpl `#808080`); the palette pins those so the port stays faithful.
33+
- Plotting tests live in `tests/plotting/`, run on the headless `Agg` backend,
34+
and assert `mcolors.to_hex(...)` of line/scatter/patch artists — **no**
35+
pixel/PDF comparison.
36+
1437
## What is compared
1538

1639
- Numeric return values (scalars, vectors, matrices, nested result dicts) from
@@ -46,9 +69,9 @@ any `*.pdf`, `plot3d`, or `rgl`; the CI workflow
4669
(`.github/workflows/native-backend-ci.yml`) runs only the invariant suite, the
4770
cache-only parity suite, `ruff`, `mypy`, and `python -m build`.
4871

49-
## When (and only when) image comparison would be in scope
72+
## Image comparison remains out of scope
5073

51-
Image or PDF comparison would only be considered if and when the Python package
52-
grows a real, first-class plotting API that needs validation. There is no such
53-
API today. Until one exists, no PDF/image comparison is attempted, and adding
54-
one is explicitly out of scope.
74+
Even though a first-class plotting API (`nns.plotting`) now exists, image or PDF
75+
comparison is still **out of scope**. The API is validated by asserting artist
76+
colors and the element each color sits on (faithful to R's `col=` usage), which
77+
is sufficient for a value-level port. No PDF/image diffing is attempted.

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ dependencies = [
3737
"scipy",
3838
]
3939

40+
[project.optional-dependencies]
41+
# Optional visual plotting API. The core stays NumPy/SciPy-only; install with
42+
# pip install ovvo-nns[plot]
43+
plot = ["matplotlib>=3.7"]
44+
4045
[dependency-groups]
4146
dev = [
4247
"hypothesis",
48+
"matplotlib>=3.7",
4349
"mypy",
4450
"pytest",
4551
"pytest-benchmark",
@@ -113,6 +119,11 @@ strict = true
113119
files = ["src/nns", "tests"]
114120
mypy_path = ["tests"]
115121

122+
# matplotlib is an optional extra; allow type-checking without it installed.
123+
[[tool.mypy.overrides]]
124+
module = ["matplotlib.*"]
125+
ignore_missing_imports = true
126+
116127
[tool.cibuildwheel]
117128
# Native (C++17 / nanobind) extension: build CPython wheels across 3.11-3.13.
118129
# scikit-build-core fetches cmake/ninja as build deps, so no system CMake needed.

src/nns/plotting/__init__.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Optional matplotlib plotting API for NNS, faithful to R NNS ``plot = TRUE``.
2+
3+
This subpackage is an **optional extra**. The NNS core stays NumPy/SciPy-only;
4+
matplotlib is required only here and is imported lazily inside each plot
5+
function (``pip install ovvo-nns[plot]``).
6+
7+
Design contract for every ``plot_*`` function:
8+
9+
* Accept an already-computed NNS result (or the same raw inputs) plus a keyword
10+
``ax=None`` and return the matplotlib ``Axes`` (or ``Figure`` for 3-D).
11+
* Never call ``plt.show()`` -- the caller controls display and saving.
12+
* Be **color/element-faithful** to R (see :mod:`nns.plotting.palette`), not
13+
pixel-diffed.
14+
15+
Colors are exposed via :mod:`nns.plotting.palette`; the only same-named colors
16+
that must *not* be trusted from matplotlib are ``green`` (-> ``#00FF00``) and
17+
``grey`` (-> ``#BEBEBE``).
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import TYPE_CHECKING, Any
23+
24+
from nns.plotting import palette as palette
25+
26+
if TYPE_CHECKING: # pragma: no cover - typing only
27+
from nns.plotting.anova import plot_nns_anova as plot_nns_anova
28+
from nns.plotting.arma import plot_nns_arma as plot_nns_arma
29+
from nns.plotting.arma import plot_nns_arma_optim as plot_nns_arma_optim
30+
from nns.plotting.causation import plot_nns_causation as plot_nns_causation
31+
from nns.plotting.copula import plot_nns_copula as plot_nns_copula
32+
from nns.plotting.differentiation import plot_nns_diff as plot_nns_diff
33+
from nns.plotting.dominance import plot_fsd as plot_fsd
34+
from nns.plotting.dominance import plot_ssd as plot_ssd
35+
from nns.plotting.dominance import plot_tsd as plot_tsd
36+
from nns.plotting.normalization import plot_nns_norm as plot_nns_norm
37+
from nns.plotting.partial_moments import plot_nns_cdf as plot_nns_cdf
38+
from nns.plotting.regression import plot_nns_part as plot_nns_part
39+
from nns.plotting.regression import plot_nns_reg as plot_nns_reg
40+
from nns.plotting.seasonality import plot_nns_seas as plot_nns_seas
41+
42+
_EXPORTS = {
43+
"plot_nns_anova": ("nns.plotting.anova", "plot_nns_anova"),
44+
"plot_nns_arma": ("nns.plotting.arma", "plot_nns_arma"),
45+
"plot_nns_arma_optim": ("nns.plotting.arma", "plot_nns_arma_optim"),
46+
"plot_nns_causation": ("nns.plotting.causation", "plot_nns_causation"),
47+
"plot_nns_copula": ("nns.plotting.copula", "plot_nns_copula"),
48+
"plot_nns_diff": ("nns.plotting.differentiation", "plot_nns_diff"),
49+
"plot_fsd": ("nns.plotting.dominance", "plot_fsd"),
50+
"plot_ssd": ("nns.plotting.dominance", "plot_ssd"),
51+
"plot_tsd": ("nns.plotting.dominance", "plot_tsd"),
52+
"plot_nns_norm": ("nns.plotting.normalization", "plot_nns_norm"),
53+
"plot_nns_cdf": ("nns.plotting.partial_moments", "plot_nns_cdf"),
54+
"plot_nns_part": ("nns.plotting.regression", "plot_nns_part"),
55+
"plot_nns_reg": ("nns.plotting.regression", "plot_nns_reg"),
56+
"plot_nns_seas": ("nns.plotting.seasonality", "plot_nns_seas"),
57+
}
58+
59+
__all__ = sorted((*_EXPORTS, "palette"))
60+
61+
62+
def __getattr__(name: str) -> Any:
63+
if name not in _EXPORTS:
64+
raise AttributeError(f"module 'nns.plotting' has no attribute {name!r}")
65+
from importlib import import_module
66+
67+
module_name, attr_name = _EXPORTS[name]
68+
value = getattr(import_module(module_name), attr_name)
69+
globals()[name] = value
70+
return value

src/nns/plotting/_mpl.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Lazy matplotlib loading for the optional plotting API.
2+
3+
matplotlib is an *optional* extra (``pip install ovvo-nns[plot]``). It is never
4+
imported at package import time; every plot function calls :func:`require_mpl`
5+
to import it on demand and raises a clear, actionable error if it is absent.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import TYPE_CHECKING, Any, cast
11+
12+
if TYPE_CHECKING: # pragma: no cover - typing only
13+
from matplotlib.axes import Axes
14+
15+
_INSTALL_HINT = (
16+
"matplotlib is required for nns.plotting. Install the optional extra with "
17+
"`pip install ovvo-nns[plot]`."
18+
)
19+
20+
21+
def require_mpl() -> Any:
22+
"""Import and return the ``matplotlib.pyplot`` module, or raise ImportError."""
23+
try:
24+
import matplotlib.pyplot as plt
25+
except ImportError as exc: # pragma: no cover - exercised via test monkeypatch
26+
raise ImportError(_INSTALL_HINT) from exc
27+
return plt
28+
29+
30+
def horizontal_boxplot(ax: Axes, data: Any, **kwargs: Any) -> Any:
31+
"""``ax.boxplot`` rendered horizontally, compatible across matplotlib versions.
32+
33+
``vert=`` was deprecated for ``orientation=`` in matplotlib 3.11; prefer the
34+
new keyword when present and fall back to the old one for >= 3.7.
35+
"""
36+
import matplotlib
37+
38+
version = tuple(int(p) for p in matplotlib.__version__.split(".")[:2])
39+
if version >= (3, 11):
40+
return ax.boxplot(data, orientation="horizontal", **kwargs)
41+
return ax.boxplot(data, vert=False, **kwargs)
42+
43+
44+
def resolve_ax(ax: Axes | None) -> Axes:
45+
"""Return ``ax`` if given, otherwise create a fresh Axes.
46+
47+
Plot functions never call ``plt.show()``; they return the Axes/Figure so the
48+
caller controls display and saving.
49+
"""
50+
if ax is not None:
51+
return ax
52+
plt = require_mpl()
53+
_, new_ax = plt.subplots()
54+
return cast("Axes", new_ax)
55+
56+
57+
__all__ = ["require_mpl", "resolve_ax"]

src/nns/plotting/anova.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Plot for ``nns_anova`` (R: ANOVA.R)."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Sequence
6+
from typing import TYPE_CHECKING, Any
7+
8+
import numpy as np
9+
10+
from nns.plotting import palette
11+
from nns.plotting._mpl import horizontal_boxplot, resolve_ax
12+
13+
if TYPE_CHECKING: # pragma: no cover - typing only
14+
from matplotlib.axes import Axes
15+
16+
17+
def plot_nns_anova(
18+
groups: Sequence[Any],
19+
*,
20+
medians: bool = False,
21+
ax: Axes | None = None,
22+
) -> Axes:
23+
"""Plot ANOVA group boxplots, faithful to R ``NNS.ANOVA(..., plot = TRUE)``.
24+
25+
* first box: ``steelblue``; remaining boxes: ``rainbow(n - 1)``
26+
* grand mean/median reference line (vertical): ``red``
27+
"""
28+
ax = resolve_ax(ax)
29+
arrays = [np.asarray(g, dtype=np.float64) for g in groups]
30+
n = len(arrays)
31+
32+
bp = horizontal_boxplot(ax, arrays, patch_artist=True)
33+
rest = palette.rainbow(n - 1)
34+
facecolors = ["steelblue", *[tuple(c) for c in rest]]
35+
for patch, color in zip(bp["boxes"], facecolors, strict=True):
36+
patch.set_facecolor(color)
37+
38+
centers = [float(np.median(a)) if medians else float(np.mean(a)) for a in arrays]
39+
grand = float(np.mean(centers))
40+
ax.axvline(grand, color="red", linewidth=4)
41+
ax.set_title("NNS ANOVA")
42+
ax.set_xlabel("Grand Median" if medians else "Grand Mean")
43+
return ax
44+
45+
46+
__all__ = ["plot_nns_anova"]

0 commit comments

Comments
 (0)