Implementing Nelson Rules in Python: Eight Vectorized pandas Masks

This is the rule-by-rule build of Lloyd Nelson's eight run tests as vectorized pandas boolean masks, each returning one True/False column indexed to the plotted series. It is the detailed implementation behind out-of-control rule detection with Nelson and Western Electric rules: where that reference explains the zone model and the false-alarm budget, this page writes the eight masks and proves each one on a fixture. Every mask runs against a frozen mean and sigma, uses no Python loop over the data, and fills warm-up rows with False.

Prerequisites

  • Python 3.9+ with numpy and pandas installed (pip install numpy pandas).
  • The plotted statistic as a pandas.Series in strict production sequence — subgroup means, individuals, or the value you actually chart, not raw multi-column readings.
  • A frozen mean mu and sigma sigma from a stable Phase I baseline of at least 20–25 subgroups. On an X-bar chart, sigma is the standard deviation of the subgroup mean, sourced from the same calculation that produced your limits (see the X-Bar R chart implementation) — do not recompute it from the series under test.
  • A monotonic, gap-checked index. Run rules are meaningless on shuffled or out-of-order data.
  • A decision on which rules to enable. All eight together compound the false-alarm rate; enable rule 3 only where drift is plausible, rule 4 only where alternation is plausible, and so on.

Step 1 — Standardize the series into zone membership

Convert the series to a z-score against the frozen limits, then derive the boolean zone-membership Series every later rule reuses. Doing this once keeps the rules cheap and consistent.

from __future__ import annotations

import numpy as np
import pandas as pd


def zone_frame(series: pd.Series, mu: float, sigma: float) -> pd.DataFrame:
    """Standardize a plotted series against frozen limits into zone membership.

    Returns a DataFrame with helper columns reused by every Nelson rule:
    z, above (side of center), and Zone A/B/C absolute-band flags.
    """
    if not isinstance(series, pd.Series):
        raise TypeError("series must be a pandas Series in production order.")
    if sigma <= 0 or not np.isfinite(sigma) or not np.isfinite(mu):
        raise ValueError("mu must be finite and sigma a positive finite Phase I estimate.")

    z = (series.astype(float) - mu) / sigma
    return pd.DataFrame(
        {
            "z": z,
            "above": z > 0,          # True above center, False below
            "zone_a": z.abs() >= 2,  # Zone A or beyond
            "zone_b": z.abs() >= 1,  # Zone B or beyond
            "zone_c": z.abs() < 1,   # within Zone C
        },
        index=series.index,
    )

Every zone flag is absolute (either side); the above column carries the sign so the "same side" rules can be enforced separately.

Step 2 — Rule 1: one point beyond three sigma

The simplest mask. A point is out when its standardized distance reaches three, on either side.

def nelson_rule_1(z: pd.Series) -> pd.Series:
    """Rule 1: a single point beyond 3 sigma from center."""
    return (z.abs() >= 3).rename("R1_BEYOND_3S")

This is identical to Western Electric rule 1; if you also run the Western Electric set, do not emit both codes for the same point.

Step 3 — Rule 2: nine points in a row on one side

A sustained one-sided run signals a mean shift. Count consecutive points above center over a window of nine, and separately below; the mask is true where either count fills the window. rolling(9).sum() on a boolean Series counts the True values in the window.

def nelson_rule_2(above: pd.Series, n: int = 9) -> pd.Series:
    """Rule 2: n consecutive points on the same side of center (default 9)."""
    run_above = above.rolling(n).sum() >= n
    run_below = (~above).rolling(n).sum() >= n
    return (run_above | run_below).rename("R2_RUN_9")

The mask marks the point that completes the run of nine, which is the operational convention — that is the sample at which the operator is told to react.

Step 4 — Rule 3: six points steadily increasing or decreasing

A monotonic run flags trend — tool wear or bath depletion. Take the first difference, then require five consecutive strictly-positive steps (six rising points) or five strictly-negative steps. Strictness matters: a tie must break the run, so use > and <, never >=.

def nelson_rule_3(series: pd.Series, n: int = 6) -> pd.Series:
    """Rule 3: n points steadily increasing or steadily decreasing (default 6)."""
    d = series.astype(float).diff()
    rising = (d > 0).rolling(n - 1).sum() >= (n - 1)   # n points => n-1 steps
    falling = (d < 0).rolling(n - 1).sum() >= (n - 1)
    return (rising | falling).rename("R3_TREND_6")

Note the window is n - 1: six points contain five consecutive steps between them.

Step 5 — Rule 4: fourteen points alternating up and down

Over-adjustment or two alternating fixtures produce a sawtooth. A point alternates when the sign of its step is opposite to the previous step, i.e. the product of consecutive signed differences is negative. Fourteen alternating points contain twelve such sign reversals.

def nelson_rule_4(series: pd.Series, n: int = 14) -> pd.Series:
    """Rule 4: n points alternating up and down (default 14)."""
    d = np.sign(series.astype(float).diff())
    reversal = (d * d.shift(1)) < 0             # direction flipped this step
    return (reversal.rolling(n - 2).sum() >= (n - 2)).rename("R4_ALT_14")

Fourteen points yield thirteen steps and twelve interior reversals, hence the n - 2 window.

Step 6 — Rules 5 and 6: k of n in a zone, same side

These are the zone-density rules. Rule 5: two of three consecutive points in Zone A or beyond, on the same side. Rule 6: four of five consecutive points in Zone B or beyond, same side. The "same side" constraint is why the earlier above flag is combined with the zone flag before the rolling count.

def _k_of_n_same_side(zone: pd.Series, above: pd.Series, k: int, n: int) -> pd.Series:
    """At least k of n consecutive points in ``zone`` on the same side of center."""
    hi = (zone & above).rolling(n).sum() >= k
    lo = (zone & ~above).rolling(n).sum() >= k
    return hi | lo


def nelson_rule_5(zone_a: pd.Series, above: pd.Series) -> pd.Series:
    """Rule 5: 2 of 3 consecutive points in Zone A or beyond, same side."""
    return _k_of_n_same_side(zone_a, above, k=2, n=3).rename("R5_2OF3_ZONE_A")


def nelson_rule_6(zone_b: pd.Series, above: pd.Series) -> pd.Series:
    """Rule 6: 4 of 5 consecutive points in Zone B or beyond, same side."""
    return _k_of_n_same_side(zone_b, above, k=4, n=5).rename("R6_4OF5_ZONE_B")

Splitting the count by side prevents a false trip when, say, one point is deep in the upper Zone A and another in the lower Zone A — those are not the same-side pattern the rule targets.

Step 7 — Rules 7 and 8: hugging and mixture

Rule 7 catches stratification: fifteen points in a row all inside Zone C, unusually close to the centerline, which often means the limits are too wide or two streams are averaging out. Rule 8 catches a mixture: eight points in a row with none in Zone C, all pushed away from center on either side.

def nelson_rule_7(zone_c: pd.Series, n: int = 15) -> pd.Series:
    """Rule 7: n consecutive points within Zone C (default 15) -- hugging."""
    return (zone_c.rolling(n).sum() >= n).rename("R7_15_IN_C")


def nelson_rule_8(zone_c: pd.Series, n: int = 8) -> pd.Series:
    """Rule 8: n consecutive points with none in Zone C (default 8) -- mixture."""
    return ((~zone_c).rolling(n).sum() >= n).rename("R8_8_OUTSIDE_C")

Assemble the eight into one boolean frame:

def all_nelson_rules(series: pd.Series, mu: float, sigma: float) -> pd.DataFrame:
    """Evaluate all eight Nelson rules against frozen limits.

    Returns a boolean DataFrame, one column per rule, warm-up rows False.
    """
    zf = zone_frame(series, mu, sigma)
    cols = [
        nelson_rule_1(zf["z"]),
        nelson_rule_2(zf["above"]),
        nelson_rule_3(series),
        nelson_rule_4(series),
        nelson_rule_5(zf["zone_a"], zf["above"]),
        nelson_rule_6(zf["zone_b"], zf["above"]),
        nelson_rule_7(zf["zone_c"]),
        nelson_rule_8(zf["zone_c"]),
    ]
    return pd.concat(cols, axis=1).fillna(False).astype(bool)

Verification

Build a fixture where each rule can be tripped in isolation and assert the firing row and the absence of cross-fires. Use mu = 0, sigma = 1 so the series is its own z-score.

import numpy as np
import pandas as pd

# Rule 2: nine consecutive points just above center (all +0.5 sigma).
s2 = pd.Series([0.5] * 9)
r2 = all_nelson_rules(s2, mu=0.0, sigma=1.0)
assert r2["R2_RUN_9"].iloc[-1]           # completes on the 9th point
assert not r2["R2_RUN_9"].iloc[7]        # eight points is not enough
assert not r2["R1_BEYOND_3S"].any()      # nothing beyond 3 sigma

# Rule 1: a lone spike beyond 3 sigma.
s1 = pd.Series([0.1, -0.2, 3.4, 0.0])
r1 = all_nelson_rules(s1, mu=0.0, sigma=1.0)
assert r1["R1_BEYOND_3S"].tolist() == [False, False, True, False]

# Rule 3: six strictly increasing points -> trend fires on the 6th.
s3 = pd.Series([0.0, 0.2, 0.4, 0.6, 0.8, 1.0])
r3 = all_nelson_rules(s3, mu=0.0, sigma=1.0)
assert r3["R3_TREND_6"].iloc[-1]
assert not r3["R3_TREND_6"].iloc[-2]

# Boundary: exactly eight one-sided points must NOT trip rule 2.
assert not all_nelson_rules(pd.Series([0.5] * 8), 0.0, 1.0)["R2_RUN_9"].any()

print("all Nelson rule fixtures verified")

The key assertions are the boundary cases: rule 2 must stay silent at eight and fire at nine, rule 3 must stay silent at five rising points and fire at six. If a boundary is off by one, the rolling window length is wrong.

Root-cause table

Symptom Cause Fix
Rule 2 fires one point early or late Rolling window set to n steps instead of n points, or vice versa Count-based rules window on points (n); difference-based rules window on steps (n - 1)
Rule 3 fires on flat, noisy data Ties counted as increases via >= Use strict > and <; a tie must break a monotonic run
Rules 5 or 6 trip on opposite-side points Zone density counted without the same-side constraint Combine the zone flag with the above flag before the rolling sum, split by side
A known shift never fires any rule Sigma recomputed from the series under test Pass a frozen Phase I sigma; never estimate it from the data being tested
Every leading row is False and you expected fires Warm-up region where the rolling window is not yet full Correct behavior; supply enough history or accept the warm-up gap, do not backfill
Rule 4 never fires on a clear sawtooth Sign product uses unshifted differences or counts wrong length Compare each signed step to the previous (d * d.shift(1) < 0) over an n - 2 window

For the zone model, the combined false-alarm budget, and the alarm-code mapping that consumes these masks, see out-of-control rule detection with Nelson and Western Electric rules.