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
3 changes: 3 additions & 0 deletions .github/workflows/native-backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Run parity from committed R cache
run: NNS_R_CACHE_ONLY=1 python -m pytest -q tests/parity

- name: Run plotting color-fidelity tests
run: python -m pytest -q tests/plotting

- name: Run vignette examples
run: |
if [ -f tests/docs/test_vignette_examples.py ]; then
Expand Down
22 changes: 20 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,26 @@ jobs:
name: sdist
path: dist/*.tar.gz

publish_testpypi:
check_metadata:
needs: [build_wheels, build_sdist]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Validate metadata and README rendering (twine check)
run: |
# Pin modern tooling so PEP 639 License-Expression / Metadata 2.4 is
# recognized regardless of any preinstalled system 'packaging'.
python -m pip install -U pip "twine>=6.1" "packaging>=24.2"
python -m twine check --strict dist/*

publish_testpypi:
needs: [build_wheels, build_sdist, check_metadata]
if: github.event_name == 'workflow_dispatch' && inputs.publish == 'testpypi'
runs-on: ubuntu-latest
environment: testpypi
Expand All @@ -97,7 +115,7 @@ jobs:
repository-url: https://test.pypi.org/legacy/

publish_pypi:
needs: [build_wheels, build_sdist]
needs: [build_wheels, build_sdist, check_metadata]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'pypi')
runs-on: ubuntu-latest
environment: pypi
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ The public package is Python-native and does not call R at runtime. Some core ke
pip install ovvo-nns
```

This includes the matplotlib plotting API (`nns.plotting`); matplotlib is a
regular dependency and is imported lazily, so `import nns` stays light. See
[`docs/plot_parity_policy.md`](docs/plot_parity_policy.md).

Use the package as `nns`:

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

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

Expand Down
37 changes: 29 additions & 8 deletions docs/plot_parity_policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,36 @@

## Summary

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

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

## A visual plotting API now exists (`nns.plotting`)

The Python port now ships a plotting API in the `nns.plotting` subpackage. It is
**color/element-faithful to R but not pixel-diffed**: tests assert *artist
colors and which element they sit on*, never rendered images.

- matplotlib is a regular dependency of the package (no optional extra). It is
still imported lazily inside each plot function — never at package top level —
so `import nns` stays light and does not pull matplotlib in.
- Each `plot_*` function takes an already-computed NNS result (or the same raw
inputs) plus a keyword `ax=None`, returns the `Axes`/`Figure`, and **never**
calls `plt.show()`. The compute functions' `plot=False` default behavior is
untouched; plotting is a separate opt-in call.
- Colors are pinned in `nns.plotting.palette` to the exact R `grDevices` hex
used by `tools/NNS/R/*.R`. R and matplotlib agree on `steelblue`/`red` but
**disagree** on `green` (R `#00FF00` vs mpl `#008000`) and `grey` (R `#BEBEBE`
vs mpl `#808080`); the palette pins those so the port stays faithful.
- Plotting tests live in `tests/plotting/`, run on the headless `Agg` backend,
and assert `mcolors.to_hex(...)` of line/scatter/patch artists — **no**
pixel/PDF comparison.

## What is compared

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

## When (and only when) image comparison would be in scope
## Image comparison remains out of scope

Image or PDF comparison would only be considered if and when the Python package
grows a real, first-class plotting API that needs validation. There is no such
API today. Until one exists, no PDF/image comparison is attempted, and adding
one is explicitly out of scope.
Even though a first-class plotting API (`nns.plotting`) now exists, image or PDF
comparison is still **out of scope**. The API is validated by asserting artist
colors and the element each color sits on (faithful to R's `col=` usage), which
is sufficient for a value-level port. No PDF/image diffing is attempted.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ keywords = [
]
urls = { "Homepage" = "https://github.com/OVVO-Financial/NNS-python", "Repository" = "https://github.com/OVVO-Financial/NNS-python", "Issues" = "https://github.com/OVVO-Financial/NNS-python/issues", "Source" = "https://github.com/OVVO-Financial/NNS-python", "Project" = "https://github.com/OVVO-Financial/NNS-python" }
dependencies = [
"matplotlib>=3.7",
"numpy",
"scipy",
]
Expand Down
69 changes: 69 additions & 0 deletions src/nns/plotting/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""matplotlib plotting API for NNS, faithful to R NNS ``plot = TRUE``.

matplotlib ships as a regular dependency of the package, but it is imported
lazily inside each plot function so ``import nns`` stays light.

Design contract for every ``plot_*`` function:

* Accept an already-computed NNS result (or the same raw inputs) plus a keyword
``ax=None`` and return the matplotlib ``Axes`` (or ``Figure`` for 3-D).
* Never call ``plt.show()`` -- the caller controls display and saving.
* Be **color/element-faithful** to R (see :mod:`nns.plotting.palette`), not
pixel-diffed.

Colors are pinned in :mod:`nns.plotting.palette`; the only same-named colors
that must *not* be trusted from matplotlib are ``green`` (-> ``#00FF00``) and
``grey`` (-> ``#BEBEBE``).
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from nns.plotting import palette as palette

if TYPE_CHECKING: # pragma: no cover - typing only
from nns.plotting.anova import plot_nns_anova as plot_nns_anova
from nns.plotting.arma import plot_nns_arma as plot_nns_arma
from nns.plotting.arma import plot_nns_arma_optim as plot_nns_arma_optim
from nns.plotting.causation import plot_nns_causation as plot_nns_causation
from nns.plotting.copula import plot_nns_copula as plot_nns_copula
from nns.plotting.differentiation import plot_nns_diff as plot_nns_diff
from nns.plotting.dominance import plot_fsd as plot_fsd
from nns.plotting.dominance import plot_ssd as plot_ssd
from nns.plotting.dominance import plot_tsd as plot_tsd
from nns.plotting.normalization import plot_nns_norm as plot_nns_norm
from nns.plotting.partial_moments import plot_nns_cdf as plot_nns_cdf
from nns.plotting.regression import plot_nns_part as plot_nns_part
from nns.plotting.regression import plot_nns_reg as plot_nns_reg
from nns.plotting.seasonality import plot_nns_seas as plot_nns_seas

_EXPORTS = {
"plot_nns_anova": ("nns.plotting.anova", "plot_nns_anova"),
"plot_nns_arma": ("nns.plotting.arma", "plot_nns_arma"),
"plot_nns_arma_optim": ("nns.plotting.arma", "plot_nns_arma_optim"),
"plot_nns_causation": ("nns.plotting.causation", "plot_nns_causation"),
"plot_nns_copula": ("nns.plotting.copula", "plot_nns_copula"),
"plot_nns_diff": ("nns.plotting.differentiation", "plot_nns_diff"),
"plot_fsd": ("nns.plotting.dominance", "plot_fsd"),
"plot_ssd": ("nns.plotting.dominance", "plot_ssd"),
"plot_tsd": ("nns.plotting.dominance", "plot_tsd"),
"plot_nns_norm": ("nns.plotting.normalization", "plot_nns_norm"),
"plot_nns_cdf": ("nns.plotting.partial_moments", "plot_nns_cdf"),
"plot_nns_part": ("nns.plotting.regression", "plot_nns_part"),
"plot_nns_reg": ("nns.plotting.regression", "plot_nns_reg"),
"plot_nns_seas": ("nns.plotting.seasonality", "plot_nns_seas"),
}

__all__ = sorted((*_EXPORTS, "palette"))


def __getattr__(name: str) -> Any:
if name not in _EXPORTS:
raise AttributeError(f"module 'nns.plotting' has no attribute {name!r}")
from importlib import import_module

module_name, attr_name = _EXPORTS[name]
value = getattr(import_module(module_name), attr_name)
globals()[name] = value
return value
57 changes: 57 additions & 0 deletions src/nns/plotting/_mpl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Lazy matplotlib loading for the plotting API.

matplotlib is a regular dependency of this package, but it is still imported
lazily (never at package import time) so ``import nns`` stays light. Every plot
function calls :func:`require_mpl` to import it on demand.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, cast

if TYPE_CHECKING: # pragma: no cover - typing only
from matplotlib.axes import Axes

_INSTALL_HINT = (
"matplotlib is required for nns.plotting but could not be imported; "
"reinstall ovvo-nns to restore it (`pip install --force-reinstall ovvo-nns`)."
)


def require_mpl() -> Any:
"""Import and return the ``matplotlib.pyplot`` module, or raise ImportError."""
try:
import matplotlib.pyplot as plt
except ImportError as exc: # pragma: no cover - exercised via test monkeypatch
raise ImportError(_INSTALL_HINT) from exc
return plt


def horizontal_boxplot(ax: Axes, data: Any, **kwargs: Any) -> Any:
"""``ax.boxplot`` rendered horizontally, compatible across matplotlib versions.

``vert=`` was deprecated for ``orientation=`` in matplotlib 3.11; prefer the
new keyword when present and fall back to the old one for >= 3.7.
"""
import matplotlib

version = tuple(int(p) for p in matplotlib.__version__.split(".")[:2])
if version >= (3, 11):
return ax.boxplot(data, orientation="horizontal", **kwargs)
return ax.boxplot(data, vert=False, **kwargs)


def resolve_ax(ax: Axes | None) -> Axes:
"""Return ``ax`` if given, otherwise create a fresh Axes.

Plot functions never call ``plt.show()``; they return the Axes/Figure so the
caller controls display and saving.
"""
if ax is not None:
return ax
plt = require_mpl()
_, new_ax = plt.subplots()
return cast("Axes", new_ax)


__all__ = ["require_mpl", "resolve_ax"]
46 changes: 46 additions & 0 deletions src/nns/plotting/anova.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Plot for ``nns_anova`` (R: ANOVA.R)."""

from __future__ import annotations

from collections.abc import Sequence
from typing import TYPE_CHECKING, Any

import numpy as np

from nns.plotting import palette
from nns.plotting._mpl import horizontal_boxplot, resolve_ax

if TYPE_CHECKING: # pragma: no cover - typing only
from matplotlib.axes import Axes


def plot_nns_anova(
groups: Sequence[Any],
*,
medians: bool = False,
ax: Axes | None = None,
) -> Axes:
"""Plot ANOVA group boxplots, faithful to R ``NNS.ANOVA(..., plot = TRUE)``.

* first box: ``steelblue``; remaining boxes: ``rainbow(n - 1)``
* grand mean/median reference line (vertical): ``red``
"""
ax = resolve_ax(ax)
arrays = [np.asarray(g, dtype=np.float64) for g in groups]
n = len(arrays)

bp = horizontal_boxplot(ax, arrays, patch_artist=True)
rest = palette.rainbow(n - 1)
facecolors = ["steelblue", *[tuple(c) for c in rest]]
for patch, color in zip(bp["boxes"], facecolors, strict=True):
patch.set_facecolor(color)

centers = [float(np.median(a)) if medians else float(np.mean(a)) for a in arrays]
grand = float(np.mean(centers))
ax.axvline(grand, color="red", linewidth=4)
ax.set_title("NNS ANOVA")
ax.set_xlabel("Grand Median" if medians else "Grand Mean")
return ax


__all__ = ["plot_nns_anova"]
Loading
Loading