How to Decide Between an X-Bar R and an X-Bar S Chart in Python
Picking between the X-bar R and X-bar S chart is not a style preference you tune later — it is a routing decision driven by one number, the subgroup size n, and getting it wrong corrupts every limit and capability index downstream. Feed a subgroup of fifteen readings to a range-based chart and the range throws away the thirteen values between the minimum and maximum; the resulting σ̂ is biased low, the control limits compress, and genuine drift hides until it breaches specification. This how-to belongs to the X-bar S chart for large subgroups workflow inside the wider SPC fundamentals and control chart taxonomy: it gives a reproducible way to route data at n = 9/10, justify the cutoff with estimator efficiency, and build a runtime guard so an ETL change can never silently misroute a subgroup.
The rule the rest of this page implements is short: route n = 2–9 to the X-bar R chart, route n ≥ 10 to X-bar S, and send n = 1 streams to an I-MR chart. The steps below turn that rule into code that refuses to proceed when the observed n and the chosen estimator disagree.
Prerequisites
Confirm these are in place before wiring the router into a pipeline:
- Python 3.10+ with
pandas >= 2.0andnumpy >= 1.24installed (pip install "pandas>=2.0" "numpy>=1.24");math.gammafrom the standard library covers the constants - A tidy long-format
pd.DataFramewith one row per measurement, a column identifying the rational subgroup (lot, cycle, or inspection run), and a numeric value column — not a wide array pre-averaged into means - Rational subgroups already established so within-subgroup spread is common cause only; a subgroup that straddles a tool change or lot boundary breaks the efficiency argument regardless of which chart you pick
- Timestamps aligned and subgroups formed on real process boundaries via the time-series alignment pipeline, not arbitrary clock slices
- Missing measurements resolved by the missing-value policy for quality data so a dropout does not silently turn an
n = 10subgroup inton = 9and cross the routing threshold - The subgroup size treated as a locked design value, validated at runtime — not an ingestion convenience that varies row to row
Why n = 9/10 Is the Cutoff
Both charts monitor the subgroup mean the same way; they differ only in how they estimate within-subgroup dispersion. The X-bar R chart estimates σ from the average range scaled by the $d_2$ constant, $\hat{\sigma} = \bar{R}/d_2$; the X-bar S chart uses the average sample standard deviation corrected by the $c_4$ bias factor, $\hat{\sigma} = \bar{S}/c_4$. The X-bar limits then follow from whichever dispersion statistic you charted:
$$\text{UCL}/\text{LCL}_{\bar{X}} = \bar{\bar{X}} \pm A_2 \bar{R} \quad\text{(R route)} \qquad \bar{\bar{X}} \pm A_3 \bar{S} \quad\text{(S route)}$$
The range only ever looks at two observations — the extremes — no matter how many readings the subgroup carries. The sample standard deviation uses every reading. For small subgroups that barely matters: at n = 2 the range is essentially the whole story, and $\bar{R}/d_2$ recovers σ at roughly 99% of the efficiency of the standard deviation. As n grows the discarded interior grows with it, and range efficiency decays past the point where it earns its computational cheapness — beyond n ≈ 9 it drops below about 85% and keeps falling. That decay, not a regulatory line in the sand, is why the industry cutoff sits at n = 9/10.
Subgroup size n |
Estimator of σ | Dispersion chart | Relative efficiency | Route to |
|---|---|---|---|---|
| 1 | Moving range, $\overline{MR}/d_2$ ($d_2 = 1.128$) | MR | — (no within-subgroup spread) | I-MR chart |
| 2–5 | Range, $\bar{R}/d_2$ | R | ~0.99 → 0.95 | X-Bar R |
| 6–9 | Range, $\bar{R}/d_2$ | R | ~0.93 → 0.85 | X-Bar R (watch efficiency) |
| ≥ 10 | Std dev, $\bar{S}/c_4$ | S | S is materially more efficient | X-Bar S chart |
The practical consequence is a compressed limit, not a crash. A legacy MES template that keeps charting n = 15 on the range route produces a σ̂ biased low, so UCL/LCL sit too close to the centerline. That does not cause false alarms by itself — a biased-low σ makes limits tighter, so the more common field symptom is capability inflation feeding process capability analysis: a $\sigma_{within}$ read too small pushes Cpk up with no real process change. Misrouting in the other direction — forcing small-n data through S-route constants — distorts limits by a wide margin because the c₄, A₃, and B₃/B₄ values diverge sharply from their range-route counterparts at low n.
Step-by-Step Implementation
Step 1 — Count the subgroup size from the data and lock it
Never trust the recipe for n. Count it, and refuse to proceed if it varies, because a mixed-n frame biases both dispersion estimators and breaks constant lookup. This one guard prevents the drift that shows up after an ETL or missing-value change.
import pandas as pd
def observed_subgroup_size(
df: pd.DataFrame, subgroup_col: str, value_col: str
) -> int:
"""Return the fixed subgroup size, or raise if n is not constant."""
sizes = df.groupby(subgroup_col)[value_col].count()
if (sizes < 2).any():
raise ValueError("Every subgroup needs at least 2 observations.")
if sizes.nunique() != 1:
raise ValueError(
f"Subgroup size is not constant: saw {sorted(sizes.unique())}. "
"Fix upstream before charting — do not average across ragged n."
)
return int(sizes.iloc[0])
Verify in isolation: a clean frame returns a single integer; a frame with one short subgroup raises. This is the only place n enters the decision.
Step 2 — Compute the constants at runtime from n
Hardcoded constant tables are a classic misrouting vector: an n = 5 row reused for n = 15 batches distorts limits silently. Compute $c_4$ exactly from the gamma function and derive $A_3$, $B_3$, and $B_4$ from it, so any n is supported without a lookup table. (For the range route, $A_2$, $D_3$, and $D_4$ come from the standard $d_2/d_3$ tables — see the X-bar R limit calculation how-to.)
import math
def c4(n: int) -> float:
"""Unbiasing constant for the sample standard deviation (exact via gamma)."""
return math.sqrt(2.0 / (n - 1)) * math.gamma(n / 2) / math.gamma((n - 1) / 2)
def s_route_constants(n: int) -> dict[str, float]:
"""A3, B3, B4 for the X-bar S chart at subgroup size n."""
c4n = c4(n)
spread = (3.0 / c4n) * math.sqrt(1.0 - c4n ** 2)
return {
"c4": c4n,
"A3": 3.0 / (c4n * math.sqrt(n)),
"B3": max(0.0, 1.0 - spread), # clamped: negative B3 has no meaning
"B4": 1.0 + spread,
}
Verify against a known value: round(c4(10), 4) returns 0.9727, matching the AIAG SPC Reference Manual table. The max(0.0, ...) clamp on B3 matters — for n < 6 the raw expression goes negative, which would produce a nonsensical negative lower limit on the S chart.
Step 3 — Route with a runtime guard, never a default
The router is the safeguard. It counts n, picks the chart, and raises the instant the caller's assumption disagrees — so a silent default can never send large subgroups down the range route.
from typing import Literal
ChartType = Literal["I-MR", "X-bar R", "X-bar S"]
def select_chart(n: int) -> ChartType:
"""Deterministic chart routing for variable data by subgroup size."""
if n < 1:
raise ValueError(f"Subgroup size must be >= 1; got {n}.")
if n == 1:
return "I-MR" # no within-subgroup spread -> moving range
if n <= 9:
return "X-bar R" # range still efficient enough
return "X-bar S" # n >= 10: range too inefficient, use S/c4
def route_subgroups(
df: pd.DataFrame,
subgroup_col: str,
value_col: str,
expected: ChartType | None = None,
) -> ChartType:
"""Count n from the data, choose the chart, and reject a wrong assumption."""
n = observed_subgroup_size(df, subgroup_col, value_col)
chart = select_chart(n)
if expected is not None and chart != expected:
raise ValueError(
f"Data has n = {n}, which routes to {chart}, "
f"but the pipeline expected {expected}. Refusing to chart."
)
return chart
Pass expected="X-bar S" from the pipeline config so a feed that drifts to n = 9 fails loudly instead of quietly charting the wrong estimator.
Verification
Assert the boundary behaviour directly — the two rows either side of the cutoff are the ones that break in production:
assert select_chart(1) == "I-MR"
assert select_chart(9) == "X-bar R" # last range subgroup
assert select_chart(10) == "X-bar S" # first std-dev subgroup
assert round(c4(2), 4) == 0.7979
assert round(c4(25), 4) == 0.9896 # c4 -> 1 as n grows
# a large-subgroup frame must route to S, and must reject an "X-bar R" expectation
big = pd.DataFrame({"sg": sum(([i] * 12 for i in range(20)), []),
"x": range(240)})
assert route_subgroups(big, "sg", "x") == "X-bar S"
try:
route_subgroups(big, "sg", "x", expected="X-bar R")
raise AssertionError("router should have rejected the wrong expectation")
except ValueError:
pass
Expected output: every assertion passes silently. If c4(10) does not return 0.9727 you have a gamma-function or off-by-one error in the n - 1 term; if the boundary assertions fail you have an inclusive/exclusive mistake at the n = 9/10 edge — the single most consequential line in the router.
Root-Cause Table
| Symptom | Cause | Fix |
|---|---|---|
| Cpk looks inflated with no real improvement | Large subgroups (n > 9) charted on the range route, so $\bar{R}/d_2$ underestimates $\sigma_{within}$ |
Route n ≥ 10 to $\bar{S}/c_4$ and reconcile Cpk against Ppk (Steps 2–3) |
| Limits shifted a fraction of a percent after an ETL change | A dropout turned an n = 10 subgroup into n = 9 and crossed the threshold |
Count n from the data and raise on mixed sizes every run (Step 1) |
Negative or NaN lower S-chart limit |
Raw B3 went negative for n < 6 |
Clamp with max(0.0, ...), or route small batches to X-bar R (Step 2) |
| False out-of-control alarms on the S chart | ddof=0 (population σ) used in the subgroup std() |
Enforce ddof=1 so c₄'s bias correction is valid |
| Limits wildly off after a config change | Hardcoded n = 5 constants reused for n = 15 batches |
Compute c₄/A₃/B₃/B₄ at runtime from the observed n (Step 2) |
Lock subgroup size at the ingestion layer, validate the estimator route at runtime, and record n alongside every limit and capability figure. The constant tables and their precision requirements are governed by the AIAG SPC Reference Manual (ch. II); the c₄ unbiasing convention and the range-versus-standard-deviation guidance follow ISO 7870-2, and the same estimator that feeds the chart must feed $\sigma_{within}$ in any capability calculation per ASTM E2587.
FAQ
Where exactly is the boundary — does n = 10 use R or S?
n = 10 uses X-bar S. The convention is n = 2–9 on the range route and n ≥ 10 on the standard-deviation route, because the range estimator's efficiency has fallen below about 85% by the time you reach n = 9. Nothing forbids using S below n = 10, but there is little to gain and the cheaper range is fine there; above it, the range is materially worse and S becomes the correct default.
Can I just always use X-bar S and skip the routing?
You can — the standard deviation is the more efficient estimator at every n ≥ 2, and many modern tools default to it. The reasons to keep the router are practical: the range chart is easier to compute by hand on the shop floor, B3 needs clamping below n = 6, and n = 1 streams have no within-subgroup spread at all and must route to an I-MR chart regardless. The guard also catches subgroup-size drift, which is valuable no matter which chart you standardize on.
Why does misrouting large subgroups inflate Cpk instead of causing false alarms?
A biased-low σ̂ makes the control limits tighter, not wider, so the immediate charting symptom is subtle. The real damage is downstream: short-term capability uses $\sigma_{within}$ from $\bar{R}/d_2$ or $\bar{S}/c_4$, and when the range underestimates σ at large n, $\sigma_{within}$ shrinks and Cpk rises with no real change. Ppk, computed from the overall standard deviation, is unaffected, so a Cpk/Ppk ratio well above 1.3 is a reliable flag that the estimator, not the process, "improved."
What if my subgroup size varies from batch to batch?
For variables data such as X-bar, variable n is almost always a data defect rather than a design choice: fix it upstream in the missing-value policy and validation gate instead of averaging across ragged subgroups, which biases both the limits and $\sigma_{within}$. The router in Step 3 deliberately raises on mixed n so you catch this at ingestion. Genuinely variable counts belong to attribute charts (p, u), where limits legitimately stair-step with the per-subgroup count.
Related
- X-Bar S Chart for Large Subgroups
- How to calculate control limits for X-bar R charts in Python
- Subgroup size impact on control limit sensitivity
- Calculating Cpk vs Ppk for short production runs
Up one level: X-Bar S Chart for Large Subgroups. For the full chart-selection map see SPC Fundamentals & Control Chart Taxonomy.