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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,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(...)`.
- 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.
- 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.

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

Expand Down
10 changes: 7 additions & 3 deletions docs/plot_parity_policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,13 @@ colors and which element they sit on*, never rendered images.
a plotting API, so Python parity calls pass the R `plot = FALSE` equivalent
and assert only on returned values.

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

## Inventory of committed graphics artifacts

Expand Down
31 changes: 23 additions & 8 deletions src/nns/arma.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def nns_arma_optim(
plot: bool = False,
) -> dict[str, Any]:
"""Optimize seasonal factors for :func:`nns_arma` like R's ``NNS.ARMA.optim``."""
del ncores, print_trace, plot
del ncores, print_trace

values = _as_variable(variable)
original_values = values.copy()
Expand Down Expand Up @@ -326,7 +326,7 @@ def nns_arma_optim(
lower_pi = np.maximum(0.0, lower_pi)
upper_pi = np.maximum(0.0, upper_pi)

return {
result = {
"periods": nns_periods,
"weights": nns_weights,
"obj.fn": nns_score,
Expand All @@ -339,6 +339,11 @@ def nns_arma_optim(
"lower.pred.int": lower_pi,
"upper.pred.int": upper_pi,
}
if plot:
from nns.plotting.arma import plot_nns_arma_optim

plot_nns_arma_optim(result, original_values)
return result


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

horizon = int(h)
if horizon < 1:
raise ValueError("h must be a positive integer.")
values = _as_variable(variable)

def _finish(
forecast: NDArray[np.float64] | dict[str, NDArray[np.float64]],
) -> NDArray[np.float64] | dict[str, NDArray[np.float64]]:
if plot:
from nns.plotting.arma import plot_nns_arma

ts = int(training_set) if training_set is not None else int(values.size)
plot_nns_arma(forecast, values, training_set=ts)
return forecast
if _is_numeric_seasonal(seasonal_factor) and dynamic:
raise ValueError(
'Hmmm...Seems you have "seasonal.factor" specified and "dynamic = TRUE". '
Expand All @@ -389,12 +404,12 @@ def nns_arma(

estimates = np.zeros(horizon, dtype=np.float64)
if not _is_numeric_seasonal(seasonal_factor) and np.ptp(values) == 0.0:
return _with_prediction_intervals(
return _finish(_with_prediction_intervals(
estimates,
lin_residual=0.0,
pred_int=pred_int,
random_seed=random_seed,
)
))
lags, lag_weights = _resolve_lags_and_weights(
values,
seasonal_factor=seasonal_factor,
Expand All @@ -416,7 +431,7 @@ def nns_arma(
method=method_l,
shrink=shrink,
)
return estimates
return _finish(estimates)

current = values
lin_regression_estimates = np.array([], dtype=np.float64)
Expand Down Expand Up @@ -490,12 +505,12 @@ def nns_arma(
if not np.isfinite(lin_resid):
lin_resid = 0.0

return _with_prediction_intervals(
return _finish(_with_prediction_intervals(
estimates,
lin_residual=lin_resid,
pred_int=pred_int,
random_seed=random_seed,
)
))


def _valid_arma_optim_seasonals(
Expand Down
19 changes: 17 additions & 2 deletions src/nns/cdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def nns_cdf(
names: Sequence[str] | None = None,
) -> dict[str, object]:
"""Partial-moment CDF wrapper matching R's non-plotting NNS.CDF paths."""
del plot
type_value = type.lower()
if type_value not in {"cdf", "survival", "hazard", "cumulative hazard"}:
raise ValueError("invalid type")
Expand All @@ -29,12 +28,28 @@ def nns_cdf(
if values.ndim == 0:
values = values.reshape(1)
if values.ndim == 1 or (values.ndim == 2 and values.shape[1] == 1):
return _univariate_cdf(values.reshape(-1), float(degree), target, type_value)
result = _univariate_cdf(values.reshape(-1), float(degree), target, type_value)
if plot:
_render_cdf(result, target)
return result
if values.ndim == 2:
# Multivariate CDF has no faithful single-Axes plot; plot is a no-op here.
return _multivariate_cdf(values, float(degree), target, type_value, names)
raise ValueError("variable must be a vector or 2D matrix.")


def _render_cdf(result: dict[str, object], target: float | NDArray[np.float64] | None) -> None:
"""Render the univariate NNS.CDF figure as a side effect of ``plot=True``."""
from nns.plotting.partial_moments import plot_nns_cdf

plot_target: float | None = None
if target is not None:
coords = np.asarray(target, dtype=np.float64).reshape(-1)
if coords.size == 1:
plot_target = float(coords[0])
plot_nns_cdf(result, target=plot_target)


def _univariate_cdf(
values: NDArray[np.float64],
degree: float,
Expand Down
41 changes: 38 additions & 3 deletions src/nns/multivariate_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ def nns_m_reg(
confidence_interval: float | None = None,
class_levels: list[object] | None = None,
) -> MRegResult:
"""Multivariate numeric regression matching R's non-plotting NNS.M.reg path."""
del plot, residual_plot, location, dist, return_values, plot_regions, ncores
"""Multivariate numeric regression matching R's NNS.M.reg path."""
del location, dist, return_values, plot_regions, ncores
type_value = _normalize_type(type)
x_values, y_values = _validate_inputs(
x,
Expand Down Expand Up @@ -107,14 +107,49 @@ def nns_m_reg(
confidence_interval=confidence_interval,
)
r2 = _class_accuracy(y_values, fitted_y) if type_value == "class" else _r2(y_values, fitted_y)
return {
result: MRegResult = {
"R2": r2,
"rhs.partitions": _rhs_partitions_dict(reg_points_matrix),
"RPM": _rpm_dict(rpm),
"Point.est": _point_output(point_predictions),
"pred.int": pred_int,
"Fitted.xy": fitted,
}
if plot or residual_plot:
_render_m_reg(fitted)
return result


def _render_m_reg(fitted: dict[str, NDArray[np.float64] | NDArray[np.str_]]) -> None:
"""Render R's NNS.M.reg residual plot (Multivariate_Regression.R:367-377).

The multivariate plot output is the residual plot: actual ``y`` over the
observation index as ``steelblue`` open circles, fitted ``y.hat`` as a
``red`` line, and a pink (alpha 0.375) confidence band when present.
"""
from nns.plotting import palette
from nns.plotting._mpl import resolve_ax

y = np.asarray(fitted["y"], dtype=np.float64)
y_hat = np.asarray(fitted["y.hat"], dtype=np.float64)
if y.size == 0 or y.size != y_hat.size:
return
ax = resolve_ax(None)
index = np.arange(1, y.size + 1)
ax.scatter(index, y, facecolors="none", edgecolors="steelblue", marker="o")
ax.plot(index, y_hat, color="red", linewidth=2)
if "conf.int.pos" in fitted and "conf.int.neg" in fitted:
pos = np.asarray(fitted["conf.int.pos"], dtype=np.float64)
neg = np.asarray(fitted["conf.int.neg"], dtype=np.float64)
mask = np.isfinite(pos) & np.isfinite(neg)
if mask.any():
ax.fill_between(
index[mask], neg[mask], pos[mask],
color=palette.PINK, alpha=palette.CI_ALPHA_REG, linewidth=0.0,
)
ax.set_xlabel("Index")
ax.set_ylabel("y (blue) y.hat (red)")
ax.set_title("NNS.M.reg Residual Plot")


def _validate_inputs(
Expand Down
59 changes: 55 additions & 4 deletions src/nns/regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ def nns_reg(
factor_levels: Sequence[object] | Sequence[Sequence[object] | None] | None = None,
) -> dict[str, Any]:
"""Univariate numeric port of R's NNS.reg."""
del return_values, plot, plot_regions, residual_plot, ncores
del return_values, ncores

if dim_red_method is not None:
return _nns_reg_dimred(
result = _nns_reg_dimred(
x,
y,
factor_2_dummy=factor_2_dummy,
Expand All @@ -80,6 +80,11 @@ def nns_reg(
class_levels=class_levels,
factor_levels=factor_levels,
)
_maybe_render_reg(
result, plot=plot, plot_regions=plot_regions,
residual_plot=residual_plot, point_est=point_est,
)
return result

type_value = _normalize_type(type)
if type_value == "class":
Expand All @@ -101,7 +106,7 @@ def nns_reg(
dispatch_n_best = n_best
if type_value == "class" and dispatch_n_best is None:
dispatch_n_best = 1
return nns_m_reg(
result = nns_m_reg(
np.asarray(x_for_dispatch, dtype=np.float64),
y_matrix_values,
factor_2_dummy=False,
Expand All @@ -117,6 +122,11 @@ def nns_reg(
confidence_interval=confidence_interval,
class_levels=class_levels,
)
_maybe_render_reg(
result, plot=plot, plot_regions=plot_regions,
residual_plot=residual_plot, point_est=point_est,
)
return result

del tau, threshold, n_best, dist
x_values, y_values = _validate_univariate_inputs(
Expand All @@ -137,7 +147,7 @@ def nns_reg(
)
noise = _validate_noise_reduction(noise_reduction)
point_values = _as_point_est(point_for_dispatch)
return _nns_reg_univariate_core(
result = _nns_reg_univariate_core(
x_values,
y_values,
order=order,
Expand All @@ -150,6 +160,47 @@ def nns_reg(
equation=None,
x_star=None,
)
_maybe_render_reg(
result, plot=plot, plot_regions=plot_regions,
residual_plot=residual_plot, point_est=point_est,
)
return result


def _maybe_render_reg(
result: dict[str, Any],
*,
plot: bool,
plot_regions: bool,
residual_plot: bool,
point_est: NDArray[np.float64] | float | None,
) -> None:
"""Render the NNS.reg figure(s) as a side effect when a plot flag is set.

Plotting is decoupled from computation: this only fires for the standard
univariate result (one that carries ``Fitted.xy``) so the value-only return
contract is unchanged. ``plot``/``plot_regions`` draw the regression figure;
``residual_plot`` draws a residual scatter.
"""
if not (plot or plot_regions or residual_plot):
return
fitted = result.get("Fitted.xy") if isinstance(result, dict) else None
if not isinstance(fitted, dict) or "x" not in fitted:
return
if plot or plot_regions:
from nns.plotting.regression import plot_nns_reg

plot_nns_reg(result, point_est=point_est)
if residual_plot:
from nns.plotting._mpl import resolve_ax

xs = np.asarray(fitted["x"], dtype=np.float64)
residuals = np.asarray(fitted.get("residuals", []), dtype=np.float64)
if residuals.size and residuals.size == xs.size:
ax = resolve_ax(None)
ax.scatter(xs, residuals, color="steelblue")
ax.axhline(0.0, color="red")
ax.set_title("NNS Residual Plot")


def prepare_factor_predictors(
Expand Down
17 changes: 15 additions & 2 deletions src/nns/seasonality.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,21 @@ def nns_seas(
mod_only: bool = True,
plot: bool = False,
) -> SeasonalityResult:
"""Seasonality test matching R's NNS.seas non-plotting path."""
del plot
"""Seasonality test matching R's NNS.seas; ``plot=True`` renders the figure."""
result = _nns_seas_compute(variable, modulo=modulo, mod_only=mod_only)
if plot:
from nns.plotting.seasonality import plot_nns_seas

plot_nns_seas(result)
return result


def _nns_seas_compute(
variable: NDArray[np.float64],
*,
modulo: int | list[int] | NDArray[np.int64] | None = None,
mod_only: bool = True,
) -> SeasonalityResult:
values = _validate_variable(variable)
modulo_values = None if modulo is None else _as_modulo(modulo)
cache_key = _cache_key(values, modulo_values, mod_only)
Expand Down
4 changes: 4 additions & 0 deletions tests/plotting/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
import matplotlib

matplotlib.use("Agg")

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