Source code for process_improve.monitoring.control_charts

"""Class for ControlChart: robust control charts with a balance between CUSUM and Shewhart properties."""

import logging
import warnings
from collections.abc import Mapping, Sequence
from typing import Any, ClassVar

import numpy as np
import pandas as pd

from .._warnings import user_stacklevel
from ..regression.methods import repeated_median_slope
from ..univariate.metrics import median_absolute_deviation

logger = logging.getLogger(__name__)


#: Consistency constant for the bounded biweight rho with cutoff k = 2.52,
#: c_k = 1 / E[rho_norm(Z)] for Z ~ N(0, 1), where rho_norm is the biweight
#: rho normalised to a maximum of 1. Computed via numerical integration
#: (scipy.integrate.quad of rho_norm(z) * phi(z); E = 0.3061160, so
#: c_k = 3.266736). This makes E[rho(Z)] = 1, the condition for the scale
#: estimates built from rho to be consistent for sigma on Gaussian data.
#:
#: Departure from the paper, kept deliberately: equation (13) of Gelper, Fried and
#: Croux (2010, p 289) pairs the cutoff k = 2 with c_k = 2.52 ("for the common choice
#: of k = 2 we have c_k = 2.52"; numerically 1 / E[rho_norm(Z)] = 2.5153 for k = 2).
#: This module uses 2.52 as the cutoff instead, with the matching c_k above. Both pairs
#: give consistent scale estimates on Gaussian data, but this biweight saturates later
#: (at ``|x| > 2.52`` rather than ``|x| > 2``), so it down-weights large forecast errors
#: less than the paper's. The constants are left unchanged because existing results
#: and callers depend on them.
BIWEIGHT_RHO_CONSISTENCY = 3.266736


[docs] def rho(x: float, k: float = 2.52) -> float: """ Bi-weight rho function. The multiplier is the consistency constant c_k (chosen so that ``E[rho(Z)] = 1`` for standard-normal Z), NOT the cutoff k. The paper treats the two as separate constants; an earlier version of this code conflated them and used k = 2.52 as the multiplier, which made every scale estimate derived from rho a factor ``sqrt(2.52 * 0.30612) = 0.878`` too small, i.e. +/-3S control limits that were really +/-2.63 sigma (a ~3x inflation of the false-alarm rate). Parameters ---------- x : float Value at which to evaluate the bi-weight rho function. k : float, optional Bi-weight cutoff. The default of 2.52 is kept for compatibility. In the paper (equation 13, p 289) 2.52 is instead the consistency constant c_k for the cutoff k = 2; see the note on ``BIWEIGHT_RHO_CONSISTENCY``. Returns ------- float ``c_k * (1 - (1 - (x / k)**2)**3)`` for ``|x| <= k``; for ``|x| > k`` the function saturates at the consistency constant ``c_k``. References ---------- https://onlinelibrary.wiley.com/doi/abs/10.1002/for.1125 """ c_k = BIWEIGHT_RHO_CONSISTENCY if np.abs(x) > k: # pragma: no mutate - at |x| = k the polynomial below is c_k too, so >= is the same return c_k return c_k * (1 - np.power(1 - np.power(x / k, 2), 3))
def _rho_array(x: np.ndarray | pd.Series, k: float = 2.52) -> np.ndarray: """ Evaluate the bi-weight :func:`rho` element-wise over an array. Replaces ``np.vectorize(rho)``, which calls the scalar function once per element. The polynomial is evaluated on ``x`` clipped to ``[-k, k]``: it equals ``c_k`` at ``|x| = k``, so the clipped entries take the saturated value, and a huge ``|x|`` cannot overflow in ``np.power`` and raise a ``RuntimeWarning``. NaN propagates, exactly as in :func:`rho`. Parameters ---------- x : np.ndarray or pd.Series Values at which to evaluate the bi-weight rho function. k : float, optional Bi-weight cutoff, as in :func:`rho`. Returns ------- np.ndarray ``rho(x)`` for every element of ``x``, as float64. """ x = np.asarray(x, dtype=float) return BIWEIGHT_RHO_CONSISTENCY * (1 - np.power(1 - np.power(np.clip(x, -k, k) / k, 2), 3)) def _finite(values: pd.Series | np.ndarray) -> bool: """Return ``True`` if at least one entry of ``values`` is finite.""" return bool(np.isfinite(np.asarray(values, dtype=float)).any()) def _training_error_radicand(future_errors: pd.Series) -> float: """ Return tau squared for the training-sample errors, or NaN when it is undefined. Equation 16 of the Holt-Winters paper. This is the shared computation behind the lambda grid search, which treats an undefined cell as a non-contender, and :func:`_tau_from_training_errors`, which rejects it. Returning NaN rather than routing an all-NaN slice through ``np.nanmedian`` / ``np.nanmean`` keeps a ``RuntimeWarning`` from escaping ``calculate_limits``. (#557) Parameters ---------- future_errors : pd.Series One-step-ahead errors over the training samples (those after the warm-up). Returns ------- float The radicand, or ``np.nan`` when the errors hold nothing finite or carry no usable spread. """ if not _finite(future_errors): return np.nan # s_T = 1.48 Med|r_t|, as printed below equation (16) of the paper: 1.48 is the Gaussian # consistency factor of the median absolute error, 1 / Phi^-1(3/4) = 1.4826, rounded. The # robust Shewhart chart below uses the unrounded value. s_t_median_error = 1.48 * future_errors.abs().median() if not np.isfinite(s_t_median_error) or s_t_median_error <= 0: return np.nan return float(np.power(s_t_median_error, 2) * np.nanmean(_rho_array(future_errors / s_t_median_error))) def _tau_from_training_errors(future_errors: pd.Series, sigma: float | None = None) -> float: """ Return the robust scale estimate (tau) for the training-sample errors. Equation 16 of the Holt-Winters paper, with each way the estimate can come out undefined reported rather than silently reduced to zero. The previous ``np.sqrt(max(0.0, resids))`` was written to stop a negative radicand reaching ``sqrt``, but ``max(0.0, nan)`` returns ``0.0``: ``nan > 0.0`` is ``False``, so ``max`` keeps its first argument. A NaN therefore became a zero scale, which the caller carried outward as control limits of zero width. (#557) Parameters ---------- future_errors : pd.Series One-step-ahead errors over the training samples (those after the warm-up). sigma : float | None, optional A known, positive standard deviation. It is returned when the errors are finite but carry no usable spread, for example when more than half of them are exactly zero on a constant series. It does not replace a usable estimate from the data. Returns ------- float The scale estimate, strictly positive. Raises ------ ValueError If no training error is finite, or if the resulting radicand is non-finite or not positive (the errors carry no usable spread) and `sigma` is not given. """ if not _finite(future_errors): # Every training error is NaN when the training period holds no finite observation # to forecast against, or when the warm-up statistics themselves are not finite. raise ValueError( f"The control-chart scale estimate is undefined: none of the {future_errors.size} " "training-sample errors is finite, so the series has no finite observation in its " "training period to forecast against. Supply a series with more finite values." ) resids = _training_error_radicand(future_errors) if not np.isfinite(resids) or resids <= 0: if sigma is not None: return float(sigma) raise ValueError( f"The control-chart scale estimate is undefined (tau^2 = {resids}), so the control " "limits would have zero width. Supply more representative data, or pass an " "explicit positive 'sigma'." ) return float(np.sqrt(resids)) def _pop_deprecated_s(sigma: float | None, kwargs: dict[str, Any]) -> float | None: """Return `sigma`, or the value of the deprecated keyword ``s`` popped from ``kwargs``.""" if "s" not in kwargs: return sigma if sigma is not None: raise ValueError("calculate_limits() got both 'sigma' and its deprecated alias 's'; pass only 'sigma'.") warnings.warn( "process_improve.monitoring.control_charts.ControlChart.calculate_limits(s=...) is deprecated " "since 2.2.0 and will be removed in 3.0; use ControlChart.calculate_limits(sigma=...) instead.", category=DeprecationWarning, stacklevel=user_stacklevel(), ) return kwargs.pop("s")
[docs] def psi(x: float, k: float = 2.0) -> float: """ Pre-clean based on the Huber psi function. Can be interpreted as replacing unexpected high or low values by a more likely value. Parameters ---------- x : float Value to clean. k : float, optional Huber tuning constant: the threshold beyond which `x` is clipped to ``k * sign(x)``. The default of 2.0 follows p 288 of the referenced paper. Returns ------- float `x` itself when ``|x| < k``; otherwise ``k * sign(x)``. References ---------- https://onlinelibrary.wiley.com/doi/abs/10.1002/for.1125 """ if abs(x) < k: # pragma: no mutate - at |x| = k, k * sign(x) is x itself, so <= is the same return x return k * np.sign(x)
def _holt_winters_recursion( y: np.ndarray, warm_up: Mapping[str, Any], ld_1: float, ld_2: float, ld_s: float ) -> dict[str, np.ndarray]: """ Run the robust Holt-Winters recursion over ``y``, one time step per row. Row ``i`` needs row ``i - 1``, so the recursion stays a loop, but over plain numpy arrays that are read and filled in place, instead of several ``DataFrame`` lookups per row and a ``df.loc`` write of every row. The arithmetic is kept expression for expression, in the same order, on the same ``np.float64`` scalars and through the same scalar :func:`rho` and :func:`psi`, so the results are bit-for-bit those of the per-row pandas implementation it replaces (pinned by ``tests/test_control_chart_hw_golden.py``). Parameters ---------- y : np.ndarray The observations, as stored in ``ControlChart.df["y"]``. warm_up : Mapping[str, Any] ``ControlChart.warm_up``. Row 0 is set from ``y_zero_robust`` (median of the warm-up window) and the initial level ``alpha_0``, trend ``beta_0`` and strictly positive scale ``sigma_0`` (p 290 of the paper). ld_1 : float Smoothing parameter for the level, lambda_1. ld_2 : float Smoothing parameter for the trend, lambda_2. ld_s : float Smoothing parameter for the scale, lambda_s. Returns ------- dict[str, np.ndarray] Float64 arrays of length ``len(y)``, keyed by their ``ControlChart.df`` column: ``psi_input``, ``rho_input``, ``y_star``, ``alpha_hat``, ``beta_hat``, ``sigma_hat`` and ``error``. ``error[0]`` is NaN: row 0 has no one-step-ahead prediction. """ y_zero, alpha_0, beta_0, sigma_0 = (warm_up[key] for key in ("y_zero_robust", "alpha_0", "beta_0", "sigma_0")) n = len(y) psi_input, rho_input, y_star, alpha_hat, beta_hat, sigma_hat, error = (np.full(n, np.nan) for _ in range(7)) rho_input[0] = (y_zero - alpha_0 - beta_0) / sigma_0 psi_input[0] = rho_input[0] y_star[0] = y_zero alpha_hat[0] = alpha_0 beta_hat[0] = beta_0 sigma_hat[0] = sigma_0 for i in range(1, n): # Cover the warm-up period, and the rest of the data set. We need that for the residual # calculation later anyway. The previous state is read back from the float64 arrays, # which yields the same np.float64 scalars the per-row DataFrame lookups returned. alpha_prev, beta_prev, sigma_prev = alpha_hat[i - 1], beta_hat[i - 1], sigma_hat[i - 1] # Error = observed - predicted. Predicted = one-step-ahead prediction error_i = y[i] - (alpha_prev + beta_prev) if np.isnan(error_i): # If there is an error, replace it with the median of the last 10 error estimates # or as many points as available (row 0 has none), filtered to the finite ones so an # all-NaN slice never reaches a median (which returns NaN but emits "Mean of empty # slice"). (#557) # # Known limitation, kept for now: the imputed error is a median of ABSOLUTE errors, # so it is never negative. Each missing row is treated as an observation above its # prediction, which nudges the level up, and over a long gap the trend term # compounds that into a drift (with lambdas 0.4 / 0.7, a 40-row gap in N(50, 2) noise # took the level from about 49 to 550). Carrying the forecast forward for every # missing row, as below, would remove the bias; later gaps keep the imputation so # that their results do not change. recent_errors = np.abs(error[max(i - 10, 1) : i]) recent_errors = recent_errors[np.isfinite(recent_errors)] if not recent_errors.size: # No finite error to impute from: a gap that starts at index 1, the first row # with a one-step-ahead error. Imputing NaN used to spread through every later # row and make the whole fit fail. Carry the forecast forward instead (the level # follows the trend; trend and scale are held) and leave this row's error and # cleaned value NaN, so nothing invented reaches the target or the scale. alpha_hat[i], beta_hat[i], sigma_hat[i] = alpha_prev + beta_prev, beta_prev, sigma_prev continue error_i = float(np.median(recent_errors)) rho_i = error_i / sigma_prev prior_variance = np.power(sigma_prev, 2) sigma_i = np.sqrt(rho(rho_i) * ld_s * prior_variance + (1.0 - ld_s) * prior_variance) psi_i = error_i / sigma_i y_star_i = psi(psi_i) * sigma_i + alpha_prev + beta_prev alpha_i = ld_1 * y_star_i + (1 - ld_1) * (alpha_prev + beta_prev) beta_i = ld_2 * (alpha_i - alpha_prev) + (1 - ld_2) * beta_prev psi_input[i], rho_input[i], y_star[i] = psi_i, rho_i, y_star_i alpha_hat[i], beta_hat[i], sigma_hat[i], error[i] = alpha_i, beta_i, sigma_i, error_i return { "psi_input": psi_input, "rho_input": rho_input, "y_star": y_star, "alpha_hat": alpha_hat, "beta_hat": beta_hat, "sigma_hat": sigma_hat, "error": error, } def _one_sided_cusum( deviations: np.ndarray, reference: float, interval: float ) -> tuple[np.ndarray, list[tuple[int, int, float]]]: """ Run a one-sided tabular CUSUM, ``C_t = max(0, C_(t-1) + d_t - reference)``, from ``C_0 = 0``. The sum restarts from zero after each alarm (``C_t > interval``), and a missing deviation holds it. Returns the sum at every sample, and for each alarm a tuple of the alarm's position, the position where its run of non-zero sums started, and the estimated shift, ``reference + C_t / N`` for a run of ``N`` observations. """ sums = np.empty_like(deviations) alarms = [] total, n_run = 0.0, 0 start = 0 # pragma: no mutate - an alarm needs a run, whose first observation sets it before it is read for t, deviation in enumerate(deviations): if not np.isnan(deviation): total = max(0.0, total + deviation - reference) start = t if n_run == 0 else start n_run = n_run + 1 if total > 0.0 else 0 sums[t] = total if total > interval: alarms.append((t, start, reference + total / n_run)) total, n_run = 0.0, 0 return sums, alarms
[docs] def cusum_about_target(values: Sequence[float] | pd.Series, target: float) -> pd.DataFrame: """Return the cumulative sum of the deviations from a target, as Box, Hunter and Hunter plot it. Unlike the tabular CUSUM of ``ControlChart(variant="cusum")``, it has no reference value and no decision interval: plotted against the sample number, its slope is the mean's distance from the target, and a change of slope marks a change of mean (Box, Hunter and Hunter 1978, section 17.1). Parameters ---------- values : Sequence[float] or pd.Series The observations, in the order they were made. A missing one (NaN) holds the sum. target : float The target the deviations are taken from. Returns ------- pd.DataFrame One row per observation, with the index of ``values`` when it is a Series: ``y``, the ``deviation`` from the target, and the ``cusum`` of the deviations so far. Examples -------- >>> cusum_about_target([61.0, 59.5, 62.0], target=60.0)["cusum"].tolist() [1.0, 0.5, 2.5] """ y = pd.Series(values, dtype=float) deviation = y - float(target) return pd.DataFrame({"y": y, "deviation": deviation, "cusum": np.nancumsum(deviation.to_numpy())})
[docs] class ControlChart: """Create control chart instance objects.""" #: Tuning parameters, declared but not assigned here: each exists on an instance only once #: a caller pins it in ``calculate_limits`` or a fit sets it, which ``hasattr`` relies on. #: A Holt-Winters weight left as None is searched for. ld_1: float | None ld_2: float | None k: float h: float #: A given standard deviation must be positive and less than this. _LARGEST_SIGMA: ClassVar[float] = 1e300
[docs] def __init__( self, style: str = "robust", variant: str = "HW", # pragma: no mutate - stored lowercased, so "hw" is the same default ) -> None: """ Create/initialize a control chart. Args: style (str, optional): Which style control chart to calculate. Defaults to "robust". Other choice is 'regular' (i.e. not-robust) calculations. User should then ensure that no outliers are present in the data. variant (str, optional): Four variants are accepted: ``'hw'`` (the default), ``'xbar.no.subgroup'``, ``'ewma'`` and ``'cusum'``. Any other value raises ``ValueError`` at construction time. The variant string is compared case-insensitively (it is normalised via ``.strip().lower()`` on assignment), so ``'HW'``, ``'hw'``, and ``'Hw'`` are all equivalent. The default is a Holt-Winters (`'hw'`) chart, with automatic determination of control chart parameters. This chart is a blend of infinite history (CUSUM) charts, and an instantaneous (no history taken into account) Shewhart chart. The exact blend is specified by parameters `ld_1` (lambda 1) and `ld_2` (lambda 2). The other accepted variants are: 'xbar.no.subgroup' [Shewhart chart, with no subgroups]. In other words, each observation is independently plotted on the control chart. 'ewma' [exponentially weighted moving average chart]. The charted statistic is ``z_t = ld_1 * y_t + (1 - ld_1) * z_{t-1}``, started at the target, and it is compared with its exact time-varying limits, ``target +/- 3 s sqrt(ld_1 / (2 - ld_1) * (1 - (1 - ld_1) ** (2 t)))``. Pass the weight ``ld_1`` (lambda, default 0.2) to ``calculate_limits``. Averaging over recent samples makes it detect a small sustained shift that a Shewhart chart misses; ``ld_1 = 1`` gives the Shewhart individuals chart. 'cusum' [tabular cumulative sum chart]. Two one-sided sums, ``C+_t = max(0, C+_(t-1) + (y_t - target) - k s)`` and ``C-_t = max(0, C-_(t-1) - (y_t - target) - k s)``, start at zero, and an alarm is raised when either exceeds the decision interval ``h s``; that sum then restarts from zero. Pass ``k`` (the reference value: half the shift to detect, in units of ``s``, default 0.5) and ``h`` (default 5) to ``calculate_limits``. Each alarm also estimates when the shift started and the new mean (``cusum_alarms``). """ self.style = style.strip() self.variant = variant.strip().lower() # An unknown variant previously slipped through every fit branch and # surfaced much later as a misleading "input is likely constant or too # short" error from calculate_limits. Reject it up front instead. if self.variant not in self._TUNING_KWARGS: raise ValueError( f"Control chart variant {variant!r} is not implemented; " f"supported variants are {sorted(self._TUNING_KWARGS)}." ) self._reset_fit_state()
def _reset_fit_state(self) -> None: """ Forget everything an earlier fit set; only the constructor arguments survive. ``calculate_limits`` starts here, so a chart reused for a new series gives exactly what a fresh chart would. It used to keep the previous series' ``target`` and ``s`` and treat them as given, keep its fitted lambdas instead of searching again, and fail outright on a series of a different length. """ constructor_arguments = {"style": self.style, "variant": self.variant} self.__dict__.clear() self.__dict__.update(constructor_arguments) # Will be calculated by the self.calculate_limits() function self.target: float | None = None self._given_s: float | None = None # pragma: no mutate - calculate_limits sets it before it is read self.s: float | None = None # index of elements which are found to be outside +/- 3S self.idx_outside_3S: list[int] = [] self.warm_up: dict[str, float | np.ndarray | pd.Series] = {} self.warm_up_M: int = 0 # pragma: no mutate - calculate_limits sets it before it is read columns = [ "y", "psi_input", "rho_input", "y_star", "alpha_hat", "beta_hat", "sigma_hat", "error", ] self.df = pd.DataFrame(columns=columns, dtype=np.float64)
[docs] def calculate_limits( self, y: np.ndarray | pd.Series, target: float | None = None, sigma: float | None = None, **kwargs, ) -> None: """ Find for a given vector `y`, the control chart target and limits. Works for the Holt-Winters ('hw'), 'xbar.no.subgroup', 'ewma' and 'cusum' variants. For 'ewma', ``idx_outside_3S`` lists the samples where the EWMA statistic is outside its limits, and ``df`` gains the columns ``ewma``, ``ewma_ucl`` and ``ewma_lcl``. For the Holt-Winters variant, when there are fewer than min(20, max(10, np.ceil(0.10 * N))) measurements (where N is the length of the input vector), there is no Holt-Winters fit: a target and standard deviation that are not given are the median and the standard deviation of the data. In every case, if `target` and `sigma` (a known standard deviation) are numeric, those values are used, and the fitted standard deviation ``s`` is `sigma`; if not, they are estimated. When the data carry no usable spread (a constant series, for example), a given `sigma` is also the scale of the Holt-Winters limits, and the smoothing weights that were not pinned are 0.5 and 0.8; without `sigma` such a series raises ``ValueError``. The keyword ``s`` is a deprecated alias for `sigma`. Missing values (NaN) in `y` are bridged by imputing a one-step-ahead error equal to the median absolute error of the previous 10 rows. That error is never negative, so a gap pulls the Holt-Winters level upward, increasingly so for long gaps; fill or drop long runs of missing values before fitting. A gap that starts at index 1, the first row with a one-step-ahead error, has no earlier error to impute from: those rows carry the forecast forward instead (the level follows the trend, the trend and scale are held), and their error and cleaned value stay NaN. Every call starts afresh: nothing fitted by an earlier call on the same chart (target, ``s``, lambdas, warm-up statistics) is carried into this one. """ sigma = _pop_deprecated_s(sigma, kwargs) self._reset_fit_state() self._given_s = sigma # pragma: no mutate start - a debug message only logger.debug( "ControlChart.calculate_limits: variant=%s, style=%s, given target=%s, sigma=%s", self.variant, self.style, target, sigma, ) # pragma: no mutate end if sigma is not None: self.s = float(sigma) if not 0.0 < sigma < self._LARGEST_SIGMA: raise ValueError( "The given standard deviation must be positive and not excessively large " f"(0 < sigma < 1e300); got {sigma}." ) if target is not None: self.target = float(target) self.df["y"] = y.ravel() if isinstance(y, np.ndarray) else pd.Series(y).values.ravel() self.N = self.df.shape[0] # Between M = 10 and 20 samples required to warm-up (calculate summary statistics). # The paper fixes its startup period at m = 10 (p 291); here it grows with N, to 10% # of the series, capped at 20. Kept as is: existing results depend on it. self.warm_up["M"] = self.warm_up_M = int(min(20, max(10, np.ceil(0.10 * self.N)))) if (self.warm_up_M > self.N) and self.variant.strip().lower() == "hw": self._holt_winters_small_sample_fit() return # Check if there are enough training samples: if 2 * self.warm_up_M > self.N: self.train_samples: list[int] = list(range(self.N)) else: self.train_samples = list(range(self.warm_up_M, self.N)) self._apply_tuning_kwargs(kwargs) fits = { "hw": self._holt_winters_fit, "xbar.no.subgroup": self._xbar_no_subgroup_fit, "ewma": self._ewma_fit, "cusum": self._cusum_fit, } fits[self.variant]() # After whichever fit is completed, check which are outside +/- 3S. # Explicit validation (not assert) so the guard survives `python -O` # and surfaces as a documented ValueError at the tool boundary (SEC-17). if self.target is None or self.s is None: raise ValueError( "Control chart limits could not be estimated; the input is likely " "constant or too short to fit the chosen variant." ) self.idx_outside_3S = self._samples_outside_limits(self.target, self.s)
def _holt_winters_small_sample_fit(self) -> None: """ Fit a series shorter than the warm-up: no smoothing, the target and scale from the data. A given target and ``s`` are kept; the data median is recorded in ``_target_calculated_best`` either way. Raises ------ ValueError If ``s`` was not given and the data's standard deviation is not finite and positive. """ # TO CHECK: Completely handle the case with very few samples. Is everything filled in? # Also check case when some of these samples are NAN, you might have even fewer still. self._target_calculated_best = self.df["y"].median() if self.target is None: self.target = self._target_calculated_best if self.s is None: self.s = self.df["y"].std() if not np.isfinite(self.s) or self.s <= 0: raise ValueError( f"The control-chart scale estimate is undefined (standard deviation = {self.s} " f"over {self.N} samples), so the control limits would have zero width. Supply " "more representative data, or pass an explicit positive 'sigma'." ) self._tau = self.s self.df["y_star"] = self.df["y"].values self.df["alpha_hat"] = self.target self.df["beta_hat"] = 0.0 self.df["sigma_hat"] = np.nan self.df["error"] = np.nan def _samples_outside_limits(self, target: float, s: float) -> list[int]: """Return the positions of the samples outside the chart's limits (the CUSUM chart's alarms).""" if self.variant == "cusum": return sorted(set(self.cusum_alarms.index.tolist())) if self.variant == "ewma": # The statistic is compared with its own limits; a missing observation is never flagged. half_width = self.df["ewma_ucl"] - target outside = ((self.df["ewma"] - target).abs() > half_width) & self.df["y"].notna() else: outside = (self.df["y"] - target).abs() > 3.0 * s return np.nonzero(outside.to_numpy())[0].tolist() #: The variants, and the tuning parameters a caller may pin for each via #: ``calculate_limits(**kwargs)``: the Holt-Winters smoothing weights, the EWMA weight, and #: the CUSUM reference value and decision interval. Everything else is internal state. _TUNING_KWARGS: ClassVar[dict[str, frozenset[str]]] = { "hw": frozenset({"ld_1", "ld_2"}), "xbar.no.subgroup": frozenset({"ld_1", "ld_2"}), # accepted and unused, as before "ewma": frozenset({"ld_1"}), "cusum": frozenset({"k", "h"}), } def _apply_tuning_kwargs(self, kwargs: dict[str, object]) -> None: """Set caller-pinned tuning parameters, rejecting anything off the allowlist. A blanket ``setattr(self, key, val)`` over ``**kwargs`` would let a caller silently overwrite internal state (``self.s``, ``self.target``, ``self.train_samples``, even a bound method) and would swallow typos. We therefore accept only the documented parameters of the chart's variant and raise a clear ``ValueError`` otherwise. """ accepted = self._TUNING_KWARGS[self.variant] unknown = set(kwargs) - accepted if unknown: raise ValueError( f"calculate_limits() got unexpected keyword argument(s) {sorted(unknown)}; " f"the {self.variant!r} chart accepts {sorted(accepted)}." ) for key, val in kwargs.items(): setattr(self, key, val) #: Default EWMA weight, inside the 0.05 to 0.25 range usually recommended for small shifts. EWMA_DEFAULT_WEIGHT: ClassVar[float] = 0.2 def _ewma_fit(self) -> None: """ Fit an EWMA chart: the statistic and its exact time-varying 3-sigma limits. A given ``target`` and ``s`` are used as they are; whichever is missing is estimated from the data as for the Shewhart chart (median and 1.4826 times the MAD for the robust style, mean and standard deviation for the regular style). After ``t`` observations the statistic has variance ``lambda / (2 - lambda) * (1 - (1 - lambda) ** (2 t))`` times ``s**2``, which grows to its steady-state value, so the first limits are narrower and a shift present from the start is not missed. A missing observation carries the statistic forward and does not count towards ``t``. """ given_weight = getattr(self, "ld_1", None) weight = self.EWMA_DEFAULT_WEIGHT if given_weight is None else float(given_weight) if not 0.0 < weight <= 1.0: raise ValueError(f"The EWMA weight ld_1 must satisfy 0 < ld_1 <= 1; got {weight}.") self.ld_1 = weight self._estimate_missing_target_and_s() if self.target is None or self.s is None: return # an unknown style estimates nothing; calculate_limits raises for it y = self.df["y"].to_numpy(dtype=float) statistic = np.empty_like(y) previous = self.target for t, value in enumerate(y): previous = previous if np.isnan(value) else weight * value + (1.0 - weight) * previous statistic[t] = previous n_observed = np.cumsum(~np.isnan(y)) half_width = 3.0 * self.s * np.sqrt(weight / (2.0 - weight) * (1.0 - (1.0 - weight) ** (2 * n_observed))) self.df["ewma"] = statistic self.df["ewma_ucl"] = self.target + half_width self.df["ewma_lcl"] = self.target - half_width #: Default CUSUM reference value and decision interval, in units of ``s``: ``k = 0.5`` tunes #: the chart to a one-sigma shift, and ``h = 5`` gives an in-control average run length of 465. CUSUM_DEFAULT_K: ClassVar[float] = 0.5 CUSUM_DEFAULT_H: ClassVar[float] = 5.0 def _cusum_fit(self) -> None: """ Fit a tabular CUSUM chart: the upper and lower cumulative sums and the alarms they raise. The reference value is ``K = k s`` and the decision interval ``H = h s``. When a sum exceeds ``H`` it raises an alarm and restarts from zero, as it would once the cause has been found, so each alarm is a separate detection. A missing observation holds both sums. For each alarm, ``cusum_alarms`` gives its direction, where the shift is estimated to have started (the first sample of the run of non-zero sums that raised it), and the estimated new mean, ``target + K + C+/N`` upward or ``target - K - C-/N`` downward, where ``N`` is the number of observations in that run. """ given_k, given_h = getattr(self, "k", None), getattr(self, "h", None) k = self.CUSUM_DEFAULT_K if given_k is None else float(given_k) h = self.CUSUM_DEFAULT_H if given_h is None else float(given_h) if not (0.0 <= k < np.inf and 0.0 < h < np.inf): raise ValueError(f"The CUSUM parameters must satisfy k >= 0 and h > 0, both finite; got k={k}, h={h}.") self.k, self.h = k, h self._estimate_missing_target_and_s() if self.target is None or self.s is None: return # an unknown style estimates nothing; calculate_limits raises for it deviation = self.df["y"].to_numpy(dtype=float) - self.target reference, interval = k * self.s, h * self.s self.df["cusum_upper"], upward = _one_sided_cusum(deviation, reference, interval) self.df["cusum_lower"], downward = _one_sided_cusum(-deviation, reference, interval) alarms = [(t, "up", start, self.target + shift) for t, start, shift in upward] alarms += [(t, "down", start, self.target - shift) for t, start, shift in downward] columns = ["sample", "direction", "shift_start", "new_mean"] self.cusum_alarms = pd.DataFrame(sorted(alarms), columns=columns).set_index("sample") def _estimate_missing_target_and_s(self) -> None: """Estimate whichever of ``target`` and ``s`` was not given, as the Shewhart chart does. That is the median and 1.4826 times the MAD for the robust style, and the mean and standard deviation for the regular style; a given value is kept as it is. """ if self.target is None or self.s is None: self._xbar_no_subgroup_fit() def _holt_winters_fit(self) -> None: """Fit the Holt-Winters chart, searching for whichever smoothing weight was not pinned.""" if not hasattr(self, "ld_1"): self.ld_1 = None if not hasattr(self, "ld_2"): self.ld_2 = None self._holt_winters_parameter_fit() def _xbar_no_subgroup_fit(self) -> None: """ Fit the control chart from the data samples, assuming each sample is its own subgroup. The `style` attribute ('regular' | 'robust') switches how the average and standard deviation are calculated. A ``target`` or ``s`` given to ``calculate_limits`` is kept, and only a missing one is estimated. The robust ``s`` is the MAD about the data's own median, so a given target away from the centre of the data does not inflate it. Control chart limits assume the data are normally distributed and independent. In particular, this last assumption can have consequences if not actually met. Limits may be too wide, or too narrow. """ y = self.df["y"] if self.style == "regular": centre, spread = y.mean(), y.std() elif self.style == "robust": centre = y.median() spread = (y - centre).abs().median() * 1.4826 else: return # an unknown style estimates nothing; calculate_limits raises for it self.target = centre if self.target is None else self.target self.s = spread if self.s is None else self.s def _holt_winters_parameter_fit(self) -> None: """ Recommended in the paper: not to fit the lambda_s value, but to use a grid search for the lambda_1 and lambda_2 values. This is done in a 5x5 grid in the code below; a value the caller pinned is kept, and only the other one is searched. """ self.ld_s = ld_s = 0.2 for _name, _val in (("Lambda_1", self.ld_1), ("Lambda_2", self.ld_2)): if _val is None: continue if _val < 0.0: raise ValueError(f"{_name} must be greater than or equal to zero.") if _val > 1.0: raise ValueError(f"{_name} must be less than or equal to 1.0.") # ``is not None``: an explicit ld_1=0.0 (or ld_2=0.0) is a legitimate # user choice, but 0.0 is falsy and a plain truthiness test silently # discarded it and ran the grid search instead. if self.ld_1 is not None and self.ld_2 is not None: # User has provided their own lambda_1 and lambda_2 values. self._holt_winters_warmup_fit(ld_1=self.ld_1, ld_2=self.ld_2, ld_s=self.ld_s) else: # Find the values of ld_1 and ld_2 that best fit the data. A value the caller pinned # is a grid of one: it used to be searched over, and replaced, like the other. grid = np.linspace(0.1, 0.9, num=5) ld_1_index = grid if self.ld_1 is None else np.array([self.ld_1]) ld_2_index = grid if self.ld_2 is None else np.array([self.ld_2]) residuals, _ = np.meshgrid(ld_1_index, ld_2_index, indexing="ij") # The warm-up statistics do not depend on the lambdas: estimate them once, not per cell. self._holt_winters_warm_up_statistics() for i, ld_1 in enumerate(ld_1_index): for j, ld_2 in enumerate(ld_2_index): self._holt_winters_smooth(ld_1=ld_1, ld_2=ld_2, ld_s=ld_s) # Apply equation 16 from the paper to the residuals in the 'training' period, # that is the samples after the warm-up period. NaN-aware # statistics are required: row 0 never receives an "error" # value, and for small samples (2 * warm_up_M > N) the # training window includes row 0. With plain # np.median/np.average every grid cell became NaN and the # search silently "chose" (0.1, 0.1) via argmin-of-NaN. future_errors = self.df["error"].iloc[np.asarray(self.train_samples)] # An unusable cell records NaN and cannot win the search; the # `np.all(np.isnan(residuals))` check below still catches the case # where every cell is unusable. (#557) residuals[i, j] = _training_error_radicand(future_errors) best_ld_1, best_ld_2 = self._best_grid_lambdas(residuals, ld_1_index, ld_2_index) # Store the parameters that were calculated, even if a `target` or `s` were provided. self.ld_1 = best_ld_1 self.ld_2 = best_ld_2 self._residuals_HW = residuals self._holt_winters_smooth(ld_1=best_ld_1, ld_2=best_ld_2, ld_s=ld_s) # Common code for both branches of if-else above future_errors = self.df["error"].iloc[np.asarray(self.train_samples)] self._tau = _tau_from_training_errors(future_errors, sigma=self._given_s) if self.target is None: # Estimate the target as the median of the y-star (cleaned) y-values self.target = self.df["y_star"].median() else: self._target_calculated_best = self.df["y_star"].median() if self.s is None: self.s = self._tau # or an alternative: self.df["sigma_hat"] is approximately OK # The "delta" emphasizes that it is the deviation from the target. self._delta_UCL_3sigma = +3.0 * self._tau self._delta_LCL_3sigma = -3.0 * self._tau def _best_grid_lambdas( self, residuals: np.ndarray, ld_1_index: np.ndarray, ld_2_index: np.ndarray ) -> tuple[float, float]: """Return the lambda pair of the smallest usable grid cell (rows follow ``ld_1``).""" if not np.all(np.isnan(residuals)): row, column = np.unravel_index(np.nanargmin(residuals), residuals.shape) return ld_1_index[row], ld_2_index[column] if self._given_s is not None: # No cell is usable (a constant series has no error spread for any lambda), so the # data cannot choose the lambdas. The caller knows the scale, so fit with lambda_1 = # 0.5 and lambda_2 = 0.8, or the value pinned; `sigma` then becomes tau. return (0.5 if self.ld_1 is None else self.ld_1), (0.8 if self.ld_2 is None else self.ld_2) raise ValueError( "The Holt-Winters lambda grid search produced no usable residuals; " "the input is likely constant, too short, or entirely missing. Supply more " "representative data, or pass an explicit positive 'sigma'." ) def _holt_winters_warmup_fit(self, ld_1: float, ld_2: float, ld_s: float) -> None: """ See paper: https://onlinelibrary.wiley.com/doi/abs/10.1002/for.1125. Calculates the Holt-Winters fitting and control chart parameters, for given values of the smoothing parameters lambda_1 (how much local history for the level is used, with values approaching 1.0 implying that less history is used), and lambda_2 (history for the trend that is used, with lambda_2 approaching 1.0 implying that historical data is less interesting), and lambda_s, a similar parameter for the moving variance of the sequence. lambda_1 = ld_1: value must be between 0 <= ld_1 <= 1.0 lambda_2 = ld_2: value must be between 0 <= ld_2 <= 1.0 lambda_s = ld_s: value must be between 0 <= ld_s <= 1.0; the chart uses 0.2, based on values used in the paper, recommended on page 291. The ideal lambda values (ld_1, ld_2, ld_s) can be found from a grid search. """ self._holt_winters_warm_up_statistics() self._holt_winters_smooth(ld_1=ld_1, ld_2=ld_2, ld_s=ld_s) def _holt_winters_warm_up_statistics(self) -> None: """ Estimate the warm-up level, trend and scale (p 290 of the paper) into ``self.warm_up``. They depend only on the warm-up window and on a given ``target`` / ``s``, not on the smoothing lambdas, so the lambda grid search estimates them once instead of per cell. Raises ------ ValueError If the warm-up window has zero (or non-finite) variance. """ y_warm_up = self.df["y"].iloc[0 : self.warm_up_M] self.warm_up["y_zero_robust"] = y_warm_up.median() if isinstance(self.target, float): self.warm_up["alpha_0"] = self.target self.warm_up["beta_0"] = 0.0 else: # p 290 of the paper, https://onlinelibrary.wiley.com/doi/abs/10.1002/for.1125 self.warm_up["beta_0"] = repeated_median_slope(np.arange(self.warm_up_M), y_warm_up.to_numpy()) self.warm_up["alpha_0"] = np.nanmedian(y_warm_up - self.warm_up["beta_0"] * np.arange(self.warm_up_M)) if isinstance(self.s, float): self.warm_up["sigma_0"] = self.s else: # p 290 of the paper, https://onlinelibrary.wiley.com/doi/abs/10.1002/for.1125 # The residual is y_t - alpha_0 - beta_0 * t (alpha_0 above is the # median of exactly that de-trended series). Subtracting beta_0 as # a constant, as an earlier version did, leaves the whole warm-up # trend inside the residuals and inflates sigma_0 whenever the # window drifts - precisely the situation this chart is for. warm_up_residuals = y_warm_up - self.warm_up["alpha_0"] - self.warm_up["beta_0"] * np.arange(self.warm_up_M) # Some other method that does not rely on SciPy for 1 function. self.warm_up["sigma_0"] = median_absolute_deviation(np.asarray(warm_up_residuals)) self.warm_up["residuals"] = warm_up_residuals # A constant (zero-variance) warm-up window gives sigma_0 = MAD = 0, which # would make rho/psi infinite and silently poison every downstream control # limit with 0/NaN. Fail loudly instead of returning meaningless limits. _residuals_array = warm_up_residuals.dropna().to_numpy() _is_constant = _residuals_array.shape[0] == 0 or (_residuals_array[0] == _residuals_array).all() if not _is_constant and self.warm_up["sigma_0"] == 0: # Corner case: if there are multiple unique values in the warm-up residuals, but the MAD is zero, # then sigma_0 is set to regular standard deviation instead, which is non-zero. # This can happen when the warm-up residuals are symmetrically distributed around the median, # leading to a MAD of zero, but still have variability that can be captured by the standard deviation. self.warm_up["sigma_0"] = warm_up_residuals.std() # A constant (zero-variance) warm-up window gives sigma_0 = MAD = 0, which # would make rho/psi infinite and silently poison every downstream control # limit with 0/NaN. Fail loudly instead of returning meaningless limits. if not np.isfinite(self.warm_up["sigma_0"]) or self.warm_up["sigma_0"] <= 0: raise ValueError( "The Holt-Winters warm-up window has zero (or non-finite) variance " "(sigma_0 = 0), so the control-chart limits would be undefined. " "Supply more representative warm-up data, or pass a positive 'sigma'." ) def _holt_winters_smooth(self, ld_1: float, ld_2: float, ld_s: float) -> None: """Run the Holt-Winters recursion from ``self.warm_up`` and write its columns into ``self.df``.""" df = self.df columns = _holt_winters_recursion(df["y"].to_numpy(), self.warm_up, ld_1=ld_1, ld_2=ld_2, ld_s=ld_s) # One write per column per fit; the "y" column, the column order and the index are untouched. for name, values in columns.items(): df[name] = values