EWMA and CUSUM Charts for Small-Shift Detection

The exponentially weighted moving average (EWMA) and cumulative sum (CUSUM) charts are the memory-based complement to Shewhart control charts: instead of judging each point in isolation, they accumulate evidence across consecutive observations so a small, sustained shift — a drifting fixture, a slowly fouling nozzle, a supplier lot that is quietly 0.8σ off nominal — surfaces long before any single point breaches a 3σ limit. Within the broader SPC Fundamentals & Control Chart Taxonomy, they occupy the niche the classic charts leave open: fast, reliable detection of mean shifts smaller than about 1.5σ.

A Shewhart X-Bar R chart or Individual Moving Range (I-MR) chart reacts almost instantly to a large excursion but is nearly blind to a persistent half-sigma drift, because each point is compared only against fixed limits with no recollection of the run that preceded it. EWMA and CUSUM trade that memorylessness for accumulated sensitivity. Both assume a known in-control mean $\mu_0$ and standard deviation $\sigma$ estimated from a stable Phase I baseline, exactly the same discipline the Shewhart charts require, and both are meant to run in Phase II against those frozen parameters — never recalibrated on the very drift they are supposed to catch.

What Breaks Without a Small-Shift Chart

The failure mode is silent and expensive: a process center creeps by half a sigma and stays there for hundreds of parts while the Shewhart chart reads "in control" the entire time. The arithmetic is unforgiving. For a single 3σ rule the in-control average run length (ARL) is about 370 samples, but at a 1σ mean shift the out-of-control ARL is still roughly 44 samples — you ship dozens of subgroups' worth of off-target product before the chart signals. At a 0.5σ shift the Shewhart chart barely reacts at all, with an out-of-control ARL near 155. Bolting on Western Electric run rules helps a little but at the cost of a much shorter in-control ARL and a flood of false alarms.

The second trap is over-reacting to that slowness by tightening the Shewhart limits or stacking on more run rules, which inflates the false-alarm rate until operators mute the chart entirely. EWMA and CUSUM solve the real problem instead: they are tuned so that their in-control ARL matches or exceeds the Shewhart chart's while their out-of-control ARL at a 1σ shift drops to roughly 10 samples — a four-fold improvement in time-to-detect with no extra false alarms. The third trap is autocorrelation. Like every mean-shift chart, EWMA and CUSUM assume independent observations around $\mu_0$; on serially correlated data the limits are wrong, and the fix is to model or difference the series first, not to switch charts blindly.

Why EWMA and CUSUM signal a small sustained shift before a Shewhart chart Three stacked charts sharing one observation-index axis, with a vertical divider marking where a small one-sigma shift in the process mean begins. The top panel is a Shewhart individuals chart with limits at plus and minus three sigma; after the shift the points drift upward but stay inside the limits for many samples, and the first point beyond the upper limit does not occur until observation twenty-three. The middle panel is an EWMA chart whose smoothed statistic and narrower control limit, which widens from the center over the first few samples toward a steady value, cross at observation sixteen. The bottom panel is a tabular CUSUM whose upper cumulative sum climbs steadily after the shift and crosses the decision interval H at observation fifteen, while the lower cumulative sum stays at zero. The diagram shows both memory-based charts detecting the same shift roughly seven to eight samples earlier than the Shewhart chart. IN CONTROL · mean = mu-zero SMALL +1 sigma SHIFT BEGINS Shewhart individuals chart (3 sigma) UCL = mu-zero + 3 sigma CL = mu-zero LCL = mu-zero - 3 sigma first signal: obs 23 EWMA chart (lambda = 0.2) UCL (time-varying) CL = mu-zero LCL (time-varying) signal: obs 16 Tabular CUSUM (k = 0.5 sigma, H = 5 sigma) decision interval H C-plus / C-minus = 0 signal: obs 15 C-minus stays flat at zero (no downward shift) observation index (Phase II, evaluated against frozen mu-zero and sigma)
A small +1σ shift begins after observation 12. The Shewhart individuals chart does not signal until observation 23, while the EWMA statistic crosses its (early-widening) limit at observation 16 and the tabular CUSUM crosses the decision interval H at observation 15.

Statistical Specification

Both charts start from the same Phase I inputs — a frozen in-control mean $\mu_0$ and standard deviation $\sigma$ — and differ only in how they accumulate deviations from $\mu_0$.

EWMA statistic and control limits

The EWMA plots a geometrically weighted average of all observations to date, giving the most recent point weight $\lambda$ and discounting older points by $(1-\lambda)$ each step:

$$z_t = \lambda x_t + (1-\lambda)\,z_{t-1}, \qquad z_0 = \mu_0$$

The smoothing constant $\lambda \in (0, 1]$ sets the memory: small $\lambda$ (long memory) tunes the chart for small shifts, and $\lambda = 1$ collapses the EWMA back to a Shewhart individuals chart. A value of $\lambda = 0.2$ with a limit width $L$ between about $2.7$ and $3.0$ is the workhorse default. The control limits are time-varying, because the variance of $z_t$ grows from zero toward a steady-state value:

$$\text{UCL}_t,\ \text{LCL}_t = \mu_0 \pm L\,\sigma\sqrt{\frac{\lambda}{2-\lambda}\left(1-(1-\lambda)^{2t}\right)}$$

As $t$ grows the bracketed term approaches $1$, so the limits widen from the centerline over the first several samples and asymptote to the steady-state band:

$$\text{UCL}_\infty,\ \text{LCL}_\infty = \mu_0 \pm L\,\sigma\sqrt{\frac{\lambda}{2-\lambda}}$$

Using the asymptotic limits from $t=1$ is a common shortcut, but it makes the chart slightly less sensitive in its first few samples; the exact time-varying form is preferred for an automated engine that must be correct from the first Phase II point.

CUSUM tabular (algorithmic) form

The tabular CUSUM keeps two one-sided cumulative sums that reset at zero whenever the accumulated evidence turns non-positive, so only a sustained deviation in one direction builds up:

$$C_t^+ = \max\!\left(0,\ x_t - (\mu_0 + k) + C_{t-1}^+\right)$$

$$C_t^- = \max\!\left(0,\ (\mu_0 - k) - x_t + C_{t-1}^-\right)$$

with $C_0^+ = C_0^- = 0$. The reference value (or slack) $k$ is set to half the shift you want to detect quickly: to catch a $\delta\sigma$ shift, use $k = \tfrac{\delta}{2}\sigma$, so the canonical target of a $1\sigma$ shift gives $k = 0.5\sigma$. The chart signals when either sum exceeds the decision interval $H$, most often set to $H = h\sigma$ with $h$ between $4$ and $5$:

$$\text{signal at } t \iff C_t^+ > H \ \text{ or }\ C_t^- > H$$

The pair $k = 0.5\sigma,\ H = 5\sigma$ (i.e. $h = 5$) gives an in-control ARL near 370 with excellent $1\sigma$ detection, which is why it is the standard starting point. An optional graphical V-mask form exists but the tabular version is what automated pipelines implement, because its state is two scalars that stream trivially.

ARL comparison

The average run length is the expected number of samples until a signal; higher is better in control, lower is better out of control.

Chart Best at detecting Key tuning parameters ARL note
Shewhart (3σ) Large shifts (≥ 2σ) and spikes none (fixed 3σ) In-control ARL ≈ 370; ARL at 1σ shift ≈ 44 — slow on small shifts
EWMA Small sustained shifts (0.5–1.5σ) $\lambda$ (≈ 0.2), $L$ (≈ 2.7–3.0) Tuned to match Shewhart's in-control ARL; ARL at 1σ shift ≈ 10
CUSUM Small sustained shifts (0.5–1.5σ) $k = \tfrac{\delta}{2}\sigma$, $H = 4$–$5\sigma$ With $k=0.5\sigma, H=5\sigma$: in-control ARL ≈ 370, ARL at 1σ shift ≈ 10

EWMA and CUSUM are near-equivalent in small-shift power; the choice between them is mostly about interpretation and integration, covered next.

When to Use EWMA vs CUSUM vs Shewhart

Run a Shewhart chart when the dominant risk is a sudden, large excursion — a broken tool, a mislabeled lot, a sensor fault — because nothing beats it for a single big spike and its point-by-point interpretation is universally understood on the shop floor. Keep it as the primary chart and layer a memory-based chart alongside it; the two are complementary, not competing. Many mature deployments plot the Shewhart individuals chart and an EWMA on the same axes.

Choose EWMA when you want a chart operators can read like a smoothed trend line and when you may later want the same statistic for forecasting or feedback control — the EWMA is also a one-step-ahead predictor, so it doubles as a soft sensor. It degrades gracefully: mis-tuning $\lambda$ a little only softens sensitivity rather than breaking the chart. Choose CUSUM when you want the crispest possible detection of a specific target shift $\delta$ and a signal that also estimates when the shift started (the number of steps since the sum last reset) and how large it is. CUSUM's state is two scalars, which makes it marginally cleaner to embed in a streaming limit engine or an incremental pipeline.

Whichever you pick, keep the parameter freeze disciplined. These charts are Phase II monitors against a frozen baseline; if you must move the baseline after a verified, intentional process change, do it deliberately through a rolling-window limit recalibration step, not by recomputing $\mu_0$ on live data. And route their signals into the same downstream layer as any other chart: evaluate and de-duplicate them alongside classic run rules in out-of-control rule detection so a single drift does not raise three separate alerts.

Production-Ready Python Implementation

The engine below computes both charts against a frozen baseline. It exposes an EWMA function with exact time-varying limits and a tabular CUSUM with one-sided sums and per-point signal flags, both vectorized where possible and defensive about inputs. It is written for headless execution in an automated SPC pipeline; the baseline $\mu_0$ and $\sigma$ are supplied explicitly so the Phase I / Phase II separation is never accidental.

"""Memory-based control charts (EWMA and tabular CUSUM) for small-shift detection.

Both charts monitor Phase II data against a frozen in-control baseline
(mu0, sigma) estimated in Phase I. They detect small sustained mean shifts
that a Shewhart 3-sigma chart is slow to catch.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

import numpy as np
import pandas as pd


def _validate(values: pd.Series, mu0: float, sigma: float) -> np.ndarray:
    """Coerce input to a clean float array and validate the baseline."""
    if not np.isfinite(mu0):
        raise ValueError("mu0 (in-control mean) must be finite.")
    if not np.isfinite(sigma) or sigma <= 0:
        raise ValueError("sigma (in-control std dev) must be finite and positive.")
    arr = np.asarray(values, dtype=np.float64)
    if arr.size == 0:
        raise ValueError("Input series cannot be empty.")
    if np.isnan(arr).any():
        raise ValueError(
            "Missing values present; resolve nulls upstream before charting."
        )
    return arr


def ewma_chart(
    values: pd.Series,
    mu0: float,
    sigma: float,
    lam: float = 0.2,
    L: float = 3.0,
    asymptotic: bool = False,
) -> pd.DataFrame:
    """Compute the EWMA statistic and time-varying control limits.

    Parameters
    ----------
    values : observations to monitor (Phase II).
    mu0, sigma : frozen in-control mean and standard deviation (Phase I).
    lam : smoothing constant lambda in (0, 1]; ~0.2 targets small shifts.
    L : limit width in sigma units, typically 2.7 to 3.0.
    asymptotic : if True use the steady-state limits from t=1 (a shortcut);
        otherwise use the exact time-varying limits.

    Returns
    -------
    DataFrame with columns z, ucl, lcl, signal (bool).
    """
    if not 0.0 < lam <= 1.0:
        raise ValueError("lambda must be in the interval (0, 1].")
    if L <= 0:
        raise ValueError("L must be positive.")
    x = _validate(values, mu0, sigma)

    # Recursive EWMA: z_t = lam*x_t + (1-lam)*z_{t-1}, z_0 = mu0.
    z = np.empty_like(x)
    prev = mu0
    for t in range(x.size):
        prev = lam * x[t] + (1.0 - lam) * prev
        z[t] = prev

    t_idx = np.arange(1, x.size + 1)
    steady = np.sqrt(lam / (2.0 - lam))
    if asymptotic:
        width = np.full(x.size, steady)
    else:
        width = steady * np.sqrt(1.0 - (1.0 - lam) ** (2 * t_idx))
    half = L * sigma * width

    df = pd.DataFrame(
        {"z": z, "ucl": mu0 + half, "lcl": mu0 - half},
        index=getattr(values, "index", pd.RangeIndex(x.size)),
    )
    df["signal"] = (df["z"] > df["ucl"]) | (df["z"] < df["lcl"])
    return df


@dataclass
class CusumResult:
    """Container for tabular CUSUM output."""
    table: pd.DataFrame          # per-point c_plus, c_minus, signal
    k: float                     # reference value in original units
    h: float                     # decision interval in original units
    first_signal: Optional[int]  # positional index of first signal, or None


def cusum_chart(
    values: pd.Series,
    mu0: float,
    sigma: float,
    delta: float = 1.0,
    h: float = 5.0,
) -> CusumResult:
    """Tabular two-sided CUSUM for a target shift of `delta` sigma.

    The reference value is k = (delta / 2) * sigma (half the target shift);
    the decision interval is H = h * sigma. A signal fires when either the
    upper sum C+ or the lower sum C- exceeds H.

    Returns
    -------
    CusumResult with the per-point table and the k, H used.
    """
    if delta <= 0:
        raise ValueError("delta (target shift in sigma) must be positive.")
    if h <= 0:
        raise ValueError("h (decision interval in sigma) must be positive.")
    x = _validate(values, mu0, sigma)

    k = (delta / 2.0) * sigma
    H = h * sigma
    c_plus = np.zeros(x.size)
    c_minus = np.zeros(x.size)
    cp = cm = 0.0
    for t in range(x.size):
        cp = max(0.0, x[t] - (mu0 + k) + cp)
        cm = max(0.0, (mu0 - k) - x[t] + cm)
        c_plus[t] = cp
        c_minus[t] = cm

    signal = (c_plus > H) | (c_minus > H)
    table = pd.DataFrame(
        {"c_plus": c_plus, "c_minus": c_minus, "signal": signal},
        index=getattr(values, "index", pd.RangeIndex(x.size)),
    )
    first = int(np.argmax(signal)) if signal.any() else None
    return CusumResult(table=table, k=k, h=H, first_signal=first)


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    mu0, sigma = 100.0, 2.0
    # 15 in-control points, then a sustained +1 sigma shift.
    stream = np.concatenate([
        rng.normal(mu0, sigma, 15),
        rng.normal(mu0 + 1.0 * sigma, sigma, 25),
    ])
    s = pd.Series(stream)

    ew = ewma_chart(s, mu0, sigma, lam=0.2, L=3.0)
    cs = cusum_chart(s, mu0, sigma, delta=1.0, h=5.0)

    print("EWMA first signal at index:",
          int(ew["signal"].values.argmax()) if ew["signal"].any() else None)
    print("CUSUM first signal at index:", cs.first_signal,
          f"(k={cs.k:.2f}, H={cs.h:.2f})")

The two functions never touch the baseline: mu0 and sigma are arguments, so nothing in Phase II can silently redefine "in control." That is the single most important property for an auditable chart.

Validation and Testing

Validate the same three assumptions every mean-shift chart requires, plus the tuning. First, independence: EWMA and CUSUM limits assume observations are independent around $\mu_0$, so check the lag-1 autocorrelation of the baseline and, if it exceeds roughly 0.25, model or difference the series before charting — an autocorrelated stream will trip these charts on common-cause noise exactly as it does an I-MR chart. Second, the frozen baseline: confirm $\mu_0$ and $\sigma$ come from at least 20–30 stable Phase I observations with no known assignable causes, and serialize them so Phase II is reproducible. Third, the tuning target: state the shift size you care about and derive the parameters from it — $k = \tfrac{\delta}{2}\sigma$ for CUSUM, and a small $\lambda$ with $L \approx 2.7$–$3.0$ for EWMA — rather than copying defaults blindly.

Regression-test the engine against fixtures whose answers you can reason about. A constant in-control series should never signal; a large step should signal on both charts; and the CUSUM's reset behavior should hold on a single outlier that does not persist.

import numpy as np
import pandas as pd


def test_incontrol_series_does_not_signal():
    s = pd.Series(np.full(50, 100.0))          # dead-on target
    assert not ewma_chart(s, 100.0, 2.0)["signal"].any()
    assert cusum_chart(s, 100.0, 2.0).table["signal"].sum() == 0


def test_sustained_shift_signals_on_both():
    s = pd.Series(np.concatenate([np.full(15, 100.0), np.full(15, 104.0)]))
    assert ewma_chart(s, 100.0, 2.0)["signal"].any()
    assert cusum_chart(s, 100.0, 2.0, delta=1.0, h=5.0).first_signal is not None


def test_single_spike_does_not_accumulate_in_cusum():
    x = np.full(30, 100.0)
    x[10] = 106.0                               # one transient point
    res = cusum_chart(pd.Series(x), 100.0, 2.0, delta=1.0, h=5.0)
    # a lone spike is absorbed by the max(0, .) reset and never reaches H
    assert res.first_signal is None

The last test encodes the defining behavior of CUSUM: the $\max(0,\cdot)$ reset discards transient noise, so a single outlier that a Shewhart chart might flag does not accumulate. If that test fails, the sign of a term in the recursion is wrong.

Failure Modes and Edge Cases

Symptom Likely cause Fix
Chart signals constantly on stable data Autocorrelation shrinks effective variance; limits too tight Check lag-1 ACF; model/difference the series before charting
EWMA never signals a known small shift $\lambda$ too large (chart behaves like Shewhart) Lower $\lambda$ toward 0.1–0.2 for small-shift sensitivity
CUSUM slow or misses the target shift $k$ not matched to the shift; $H$ too large Set $k = \tfrac{\delta}{2}\sigma$; use $H = 4$–$5\sigma$
First few EWMA points look over-sensitive Asymptotic limits used from $t=1$ instead of time-varying Use the exact $\left(1-(1-\lambda)^{2t}\right)$ limit form
Both charts drift and stop signaling Baseline recomputed on live (shifted) data Freeze $\mu_0,\sigma$ in Phase I; recalibrate only after a verified change
A lone spike raises a CUSUM alarm Sign error in the $\max(0,\cdot)$ recursion Verify the reset; a transient must not accumulate to $H$

Compliance Notes

The methods and typical parameter choices trace to the NIST/SEMATECH e-Handbook of Statistical Methods, sections 6.3.2.3 (EWMA) and 6.3.2.4 (CUSUM), which give the time-varying EWMA limit formula and the tabular CUSUM recursion, and to Montgomery, Introduction to Statistical Quality Control, the standard reference for the $\lambda \approx 0.2$, $L \approx 3$ and $k=0.5\sigma$, $H=5\sigma$ ARL-optimal defaults. The AIAG SPC Reference Manual (2nd ed.) documents EWMA and CUSUM as accepted alternatives to Shewhart charts for detecting small shifts and reiterates the Phase I baseline requirement. Under ISO 9001:2015 clause 9.1.1, the frozen $\mu_0$ and $\sigma$, the chosen tuning parameters, and the engine version must be retained as documented evidence of monitoring — serialize them alongside the code and version-control both.

Frequently Asked Questions

When should I use EWMA or CUSUM instead of a Shewhart chart?

Use a memory-based chart when the risk you care about is a small, sustained shift — roughly 0.5σ to 1.5σ — that persists across many samples, such as slow tool wear, gradual fouling, or a supplier lot centered slightly off nominal. A Shewhart chart judges each point in isolation and is very slow to catch these: its out-of-control run length at a 1σ shift is around 44 samples, versus about 10 for a well-tuned EWMA or CUSUM. Keep the Shewhart chart for large, sudden excursions and run the memory-based chart alongside it rather than replacing it.

How do I choose lambda and L for an EWMA chart?

Pick λ for the shift size you want to detect and L for the false-alarm rate. Smaller λ gives the chart a longer memory and more power against small shifts; λ around 0.2 with L between 2.7 and 3.0 is the standard starting point and gives an in-control run length comparable to a Shewhart chart. For very small shifts you can drop λ toward 0.05–0.10, but you must lower L slightly (nearer 2.7) to keep the in-control run length from ballooning. Setting λ to 1 turns the EWMA back into a Shewhart individuals chart.

What are k and H in a CUSUM, and how do I set them?

The reference value k is the slack subtracted at each step, and it is set to half the shift you want to detect quickly: for a target of δ sigma, use k equal to δ over two times sigma, so a 1σ target gives k = 0.5σ. The decision interval H is the threshold the cumulative sum must exceed to signal, usually 4σ to 5σ. The pairing k = 0.5σ with H = 5σ gives an in-control run length near 370 and detects a 1σ shift in about 10 samples, which is why it is the canonical default.

Why are the EWMA control limits curved at the start of the chart?

Because the variance of the EWMA statistic grows from zero as observations accumulate. At the first sample the statistic barely differs from the centerline, so the limits start close to it and widen over the next several samples toward a steady-state band. The exact width uses the factor one minus (one minus lambda) raised to the power two times t. Some implementations skip this and use the flat steady-state limits from the first point, which makes the earliest points slightly less sensitive; an automated engine should use the exact time-varying form.

Do EWMA and CUSUM work on autocorrelated data?

Not directly. Both assume observations are independent around the in-control mean, and serial correlation makes the limits wrong — usually too tight, so the chart signals on common-cause noise. This is the same failure that pinches an I-MR chart. The fix is to model the autocorrelation (for example with an ARIMA or a residuals approach) and chart the residuals, or to difference or down-sample the series until independence holds, rather than switching charts and hoping the problem disappears.

For chart selection criteria across every variable and attribute chart, see SPC Fundamentals & Control Chart Taxonomy.