Skip to content

Commit 70cd2d3

Browse files
committed
Wire plot=True on compute functions to render via nns.plotting
Previously the compute functions accepted R-compatible plot arguments and immediately `del`'d them, so `plot=True` produced nothing — a strict validator expecting a figure would fail. Now `plot=True` renders a Matplotlib figure as a side effect through the nns.plotting layer while the value-only return contract (which the parity suite depends on) is unchanged. Wired: - nns_reg: plot / plot_regions -> plot_nns_reg; residual_plot -> residual scatter - nns_m_reg: plot -> fitted-vs-actual; residual_plot -> residual scatter - nns_arma / nns_arma_optim -> plot_nns_arma / plot_nns_arma_optim - nns_cdf (univariate) -> plot_nns_cdf - nns_seas -> plot_nns_seas (compute split into _nns_seas_compute) Rendering only fires for results that carry the needed series, and default plot=False opens no figure (verified). Adds tests/plotting/test_compute_plot_flag.py asserting plot=True creates a figure and returns the same value as plot=False. Updates the plot-parity policy and README.
1 parent 4162952 commit 70cd2d3

9 files changed

Lines changed: 266 additions & 23 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Important boundaries:
142142
- Stochastic exact stream parity is not expected because Python paths use NumPy random generation.
143143
- Factor and class ordering should be passed explicitly when ordering matters.
144144
- Direct raw-factor `nns_m_reg(..., factor_2_dummy=True)` is intentionally guarded. Use `prepare_factor_predictors(...)` before `nns_m_reg(...)`.
145-
- 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.
145+
- Compute functions still return values, not figures; passing `plot=True` (where R has it) additionally renders a Matplotlib figure as a side effect via the `nns.plotting` layer, which is color/element-faithful to R but not pixel-diffed. The plot functions can also be called directly on a computed result.
146146

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

docs/plot_parity_policy.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@ colors and which element they sit on*, never rendered images.
5050
a plotting API, so Python parity calls pass the R `plot = FALSE` equivalent
5151
and assert only on returned values.
5252

53-
When a ported function has an R `plot` argument, the Python API either omits the
54-
argument entirely or treats plotting as out of scope; only the value-bearing
55-
return is asserted in parity tests.
53+
When a ported function has an R `plot` argument, the Python function keeps its
54+
value-only **return** contract (parity asserts only on the returned value). As a
55+
side effect, passing `plot=True` renders a Matplotlib figure through the
56+
`nns.plotting` layer — `nns_reg`, `nns_m_reg`, `nns_arma`, `nns_arma_optim`,
57+
`nns_cdf`, and `nns_seas` are wired this way (plus `residual_plot=True` for the
58+
regression functions). The figures are color/element-faithful but never
59+
pixel-compared, and computation with the default `plot=False` opens no figure.
5660

5761
## Inventory of committed graphics artifacts
5862

src/nns/arma.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def nns_arma_optim(
3232
plot: bool = False,
3333
) -> dict[str, Any]:
3434
"""Optimize seasonal factors for :func:`nns_arma` like R's ``NNS.ARMA.optim``."""
35-
del ncores, print_trace, plot
35+
del ncores, print_trace
3636

3737
values = _as_variable(variable)
3838
original_values = values.copy()
@@ -326,7 +326,7 @@ def nns_arma_optim(
326326
lower_pi = np.maximum(0.0, lower_pi)
327327
upper_pi = np.maximum(0.0, upper_pi)
328328

329-
return {
329+
result = {
330330
"periods": nns_periods,
331331
"weights": nns_weights,
332332
"obj.fn": nns_score,
@@ -339,6 +339,11 @@ def nns_arma_optim(
339339
"lower.pred.int": lower_pi,
340340
"upper.pred.int": upper_pi,
341341
}
342+
if plot:
343+
from nns.plotting.arma import plot_nns_arma_optim
344+
345+
plot_nns_arma_optim(result, original_values)
346+
return result
342347

343348

344349
def nns_arma(
@@ -360,12 +365,22 @@ def nns_arma(
360365
random_seed: int | None = None,
361366
) -> NDArray[np.float64] | dict[str, NDArray[np.float64]]:
362367
"""Autoregressive NNS forecast matching R's installed NNS.ARMA behavior."""
363-
del plot, seasonal_plot
368+
del seasonal_plot
364369

365370
horizon = int(h)
366371
if horizon < 1:
367372
raise ValueError("h must be a positive integer.")
368373
values = _as_variable(variable)
374+
375+
def _finish(
376+
forecast: NDArray[np.float64] | dict[str, NDArray[np.float64]],
377+
) -> NDArray[np.float64] | dict[str, NDArray[np.float64]]:
378+
if plot:
379+
from nns.plotting.arma import plot_nns_arma
380+
381+
ts = int(training_set) if training_set is not None else int(values.size)
382+
plot_nns_arma(forecast, values, training_set=ts)
383+
return forecast
369384
if _is_numeric_seasonal(seasonal_factor) and dynamic:
370385
raise ValueError(
371386
'Hmmm...Seems you have "seasonal.factor" specified and "dynamic = TRUE". '
@@ -389,12 +404,12 @@ def nns_arma(
389404

390405
estimates = np.zeros(horizon, dtype=np.float64)
391406
if not _is_numeric_seasonal(seasonal_factor) and np.ptp(values) == 0.0:
392-
return _with_prediction_intervals(
407+
return _finish(_with_prediction_intervals(
393408
estimates,
394409
lin_residual=0.0,
395410
pred_int=pred_int,
396411
random_seed=random_seed,
397-
)
412+
))
398413
lags, lag_weights = _resolve_lags_and_weights(
399414
values,
400415
seasonal_factor=seasonal_factor,
@@ -416,7 +431,7 @@ def nns_arma(
416431
method=method_l,
417432
shrink=shrink,
418433
)
419-
return estimates
434+
return _finish(estimates)
420435

421436
current = values
422437
lin_regression_estimates = np.array([], dtype=np.float64)
@@ -490,12 +505,12 @@ def nns_arma(
490505
if not np.isfinite(lin_resid):
491506
lin_resid = 0.0
492507

493-
return _with_prediction_intervals(
508+
return _finish(_with_prediction_intervals(
494509
estimates,
495510
lin_residual=lin_resid,
496511
pred_int=pred_int,
497512
random_seed=random_seed,
498-
)
513+
))
499514

500515

501516
def _valid_arma_optim_seasonals(

src/nns/cdf.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ def nns_cdf(
2020
names: Sequence[str] | None = None,
2121
) -> dict[str, object]:
2222
"""Partial-moment CDF wrapper matching R's non-plotting NNS.CDF paths."""
23-
del plot
2423
type_value = type.lower()
2524
if type_value not in {"cdf", "survival", "hazard", "cumulative hazard"}:
2625
raise ValueError("invalid type")
@@ -29,12 +28,28 @@ def nns_cdf(
2928
if values.ndim == 0:
3029
values = values.reshape(1)
3130
if values.ndim == 1 or (values.ndim == 2 and values.shape[1] == 1):
32-
return _univariate_cdf(values.reshape(-1), float(degree), target, type_value)
31+
result = _univariate_cdf(values.reshape(-1), float(degree), target, type_value)
32+
if plot:
33+
_render_cdf(result, target)
34+
return result
3335
if values.ndim == 2:
36+
# Multivariate CDF has no faithful single-Axes plot; plot is a no-op here.
3437
return _multivariate_cdf(values, float(degree), target, type_value, names)
3538
raise ValueError("variable must be a vector or 2D matrix.")
3639

3740

41+
def _render_cdf(result: dict[str, object], target: float | NDArray[np.float64] | None) -> None:
42+
"""Render the univariate NNS.CDF figure as a side effect of ``plot=True``."""
43+
from nns.plotting.partial_moments import plot_nns_cdf
44+
45+
plot_target: float | None = None
46+
if target is not None:
47+
coords = np.asarray(target, dtype=np.float64).reshape(-1)
48+
if coords.size == 1:
49+
plot_target = float(coords[0])
50+
plot_nns_cdf(result, target=plot_target)
51+
52+
3853
def _univariate_cdf(
3954
values: NDArray[np.float64],
4055
degree: float,

src/nns/multivariate_regression.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ def nns_m_reg(
3939
confidence_interval: float | None = None,
4040
class_levels: list[object] | None = None,
4141
) -> MRegResult:
42-
"""Multivariate numeric regression matching R's non-plotting NNS.M.reg path."""
43-
del plot, residual_plot, location, dist, return_values, plot_regions, ncores
42+
"""Multivariate numeric regression matching R's NNS.M.reg path."""
43+
del location, dist, return_values, plot_regions, ncores
4444
type_value = _normalize_type(type)
4545
x_values, y_values = _validate_inputs(
4646
x,
@@ -107,14 +107,50 @@ def nns_m_reg(
107107
confidence_interval=confidence_interval,
108108
)
109109
r2 = _class_accuracy(y_values, fitted_y) if type_value == "class" else _r2(y_values, fitted_y)
110-
return {
110+
result: MRegResult = {
111111
"R2": r2,
112112
"rhs.partitions": _rhs_partitions_dict(reg_points_matrix),
113113
"RPM": _rpm_dict(rpm),
114114
"Point.est": _point_output(point_predictions),
115115
"pred.int": pred_int,
116116
"Fitted.xy": fitted,
117117
}
118+
if plot or residual_plot:
119+
_render_m_reg(fitted, plot=plot, residual_plot=residual_plot)
120+
return result
121+
122+
123+
def _render_m_reg(
124+
fitted: dict[str, NDArray[np.float64] | NDArray[np.str_]],
125+
*,
126+
plot: bool,
127+
residual_plot: bool,
128+
) -> None:
129+
"""Render multivariate fitted-vs-actual / residual diagnostics on ``plot=True``.
130+
131+
The synthetic predictors make a single x-axis ill-defined, so ``plot`` shows
132+
fitted vs actual (steelblue points, red 1:1 line) and ``residual_plot`` shows
133+
residuals about zero -- a figure is still produced, faithful to R's colors.
134+
"""
135+
from nns.plotting._mpl import resolve_ax
136+
137+
y = np.asarray(fitted["y"], dtype=np.float64)
138+
y_hat = np.asarray(fitted["y.hat"], dtype=np.float64)
139+
if plot and y.size and y.size == y_hat.size:
140+
ax = resolve_ax(None)
141+
ax.scatter(y, y_hat, color="steelblue")
142+
lo, hi = float(min(y.min(), y_hat.min())), float(max(y.max(), y_hat.max()))
143+
ax.plot([lo, hi], [lo, hi], color="red")
144+
ax.set_xlabel("y")
145+
ax.set_ylabel("y.hat")
146+
ax.set_title("NNS.M.reg Fitted vs Actual")
147+
if residual_plot:
148+
residuals = np.asarray(fitted.get("residuals", []), dtype=np.float64)
149+
if residuals.size:
150+
ax = resolve_ax(None)
151+
ax.scatter(np.arange(1, residuals.size + 1), residuals, color="steelblue")
152+
ax.axhline(0.0, color="red")
153+
ax.set_title("NNS.M.reg Residual Plot")
118154

119155

120156
def _validate_inputs(

src/nns/regression.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ def nns_reg(
5757
factor_levels: Sequence[object] | Sequence[Sequence[object] | None] | None = None,
5858
) -> dict[str, Any]:
5959
"""Univariate numeric port of R's NNS.reg."""
60-
del return_values, plot, plot_regions, residual_plot, ncores
60+
del return_values, ncores
6161

6262
if dim_red_method is not None:
63-
return _nns_reg_dimred(
63+
result = _nns_reg_dimred(
6464
x,
6565
y,
6666
factor_2_dummy=factor_2_dummy,
@@ -80,6 +80,11 @@ def nns_reg(
8080
class_levels=class_levels,
8181
factor_levels=factor_levels,
8282
)
83+
_maybe_render_reg(
84+
result, plot=plot, plot_regions=plot_regions,
85+
residual_plot=residual_plot, point_est=point_est,
86+
)
87+
return result
8388

8489
type_value = _normalize_type(type)
8590
if type_value == "class":
@@ -101,7 +106,7 @@ def nns_reg(
101106
dispatch_n_best = n_best
102107
if type_value == "class" and dispatch_n_best is None:
103108
dispatch_n_best = 1
104-
return nns_m_reg(
109+
result = nns_m_reg(
105110
np.asarray(x_for_dispatch, dtype=np.float64),
106111
y_matrix_values,
107112
factor_2_dummy=False,
@@ -117,6 +122,11 @@ def nns_reg(
117122
confidence_interval=confidence_interval,
118123
class_levels=class_levels,
119124
)
125+
_maybe_render_reg(
126+
result, plot=plot, plot_regions=plot_regions,
127+
residual_plot=residual_plot, point_est=point_est,
128+
)
129+
return result
120130

121131
del tau, threshold, n_best, dist
122132
x_values, y_values = _validate_univariate_inputs(
@@ -137,7 +147,7 @@ def nns_reg(
137147
)
138148
noise = _validate_noise_reduction(noise_reduction)
139149
point_values = _as_point_est(point_for_dispatch)
140-
return _nns_reg_univariate_core(
150+
result = _nns_reg_univariate_core(
141151
x_values,
142152
y_values,
143153
order=order,
@@ -150,6 +160,47 @@ def nns_reg(
150160
equation=None,
151161
x_star=None,
152162
)
163+
_maybe_render_reg(
164+
result, plot=plot, plot_regions=plot_regions,
165+
residual_plot=residual_plot, point_est=point_est,
166+
)
167+
return result
168+
169+
170+
def _maybe_render_reg(
171+
result: dict[str, Any],
172+
*,
173+
plot: bool,
174+
plot_regions: bool,
175+
residual_plot: bool,
176+
point_est: NDArray[np.float64] | float | None,
177+
) -> None:
178+
"""Render the NNS.reg figure(s) as a side effect when a plot flag is set.
179+
180+
Plotting is decoupled from computation: this only fires for the standard
181+
univariate result (one that carries ``Fitted.xy``) so the value-only return
182+
contract is unchanged. ``plot``/``plot_regions`` draw the regression figure;
183+
``residual_plot`` draws a residual scatter.
184+
"""
185+
if not (plot or plot_regions or residual_plot):
186+
return
187+
fitted = result.get("Fitted.xy") if isinstance(result, dict) else None
188+
if not isinstance(fitted, dict) or "x" not in fitted:
189+
return
190+
if plot or plot_regions:
191+
from nns.plotting.regression import plot_nns_reg
192+
193+
plot_nns_reg(result, point_est=point_est)
194+
if residual_plot:
195+
from nns.plotting._mpl import resolve_ax
196+
197+
xs = np.asarray(fitted["x"], dtype=np.float64)
198+
residuals = np.asarray(fitted.get("residuals", []), dtype=np.float64)
199+
if residuals.size and residuals.size == xs.size:
200+
ax = resolve_ax(None)
201+
ax.scatter(xs, residuals, color="steelblue")
202+
ax.axhline(0.0, color="red")
203+
ax.set_title("NNS Residual Plot")
153204

154205

155206
def prepare_factor_predictors(

src/nns/seasonality.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,21 @@ def nns_seas(
1919
mod_only: bool = True,
2020
plot: bool = False,
2121
) -> SeasonalityResult:
22-
"""Seasonality test matching R's NNS.seas non-plotting path."""
23-
del plot
22+
"""Seasonality test matching R's NNS.seas; ``plot=True`` renders the figure."""
23+
result = _nns_seas_compute(variable, modulo=modulo, mod_only=mod_only)
24+
if plot:
25+
from nns.plotting.seasonality import plot_nns_seas
26+
27+
plot_nns_seas(result)
28+
return result
29+
30+
31+
def _nns_seas_compute(
32+
variable: NDArray[np.float64],
33+
*,
34+
modulo: int | list[int] | NDArray[np.int64] | None = None,
35+
mod_only: bool = True,
36+
) -> SeasonalityResult:
2437
values = _validate_variable(variable)
2538
modulo_values = None if modulo is None else _as_modulo(modulo)
2639
cache_key = _cache_key(values, modulo_values, mod_only)

tests/plotting/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@
55
import matplotlib
66

77
matplotlib.use("Agg")
8+
9+
# Plotting tests open many short-lived figures (closed per-test); don't warn
10+
# about the open-figure count if this session shares a process with others.
11+
matplotlib.rcParams["figure.max_open_warning"] = 0

0 commit comments

Comments
 (0)