Skip to main content

u_analytics/capability/
percentile.rs

1//! Percentile-based process capability (ISO 22514-2).
2//!
3//! When process data is non-normal, capability indices based on mean and
4//! standard deviation can be misleading. The percentile method replaces the
5//! ±3σ spread with the distance between the 0.135 % and 99.865 % quantiles of
6//! the process distribution, and the mean with its median.
7//!
8//! Those quantiles are taken from a **distribution fitted to the data**, not
9//! from the data's own order statistics. With fewer than about 740 values the
10//! sample's 0.135 % point is its minimum (and the 99.865 % point its maximum),
11//! so an empirical spread is the sample range -- which is shorter than the
12//! process spread, and the shorter the smaller the sample: on normal data with
13//! a true Cp of 1.0 it reported about 1.37 at n = 50. The fitted model is the
14//! one [`fit_best`](crate::distribution::fit_best) ranks first by AIC, or the
15//! family the caller names.
16//!
17//! # Indices
18//!
19//! - **Cp\*** — (USL − LSL) / (X₉₉.₈₆₅ − X₀.₁₃₅)
20//! - **Cpk\*** — min(Cpu\*, Cpl\*)
21//! - **Cpu\*** — (USL − X₅₀) / (X₉₉.₈₆₅ − X₅₀)
22//! - **Cpl\*** — (X₅₀ − LSL) / (X₅₀ − X₀.₁₃₅)
23//!
24//! # References
25//!
26//! - ISO 22514-2, "Statistical methods in process management — Capability and
27//!   performance — Part 2: Process capability and performance of time-dependent
28//!   process models" (quantile method on an identified distribution).
29//! - Clements, J.A. (1989). "Process capability calculations for non-normal
30//!   distributions." *Quality Progress*, 22(9), 95–100 (quantiles of a fitted
31//!   distribution, there Pearson curves).
32
33use std::fmt;
34
35use u_numflow::distributions::{
36    BetaDistribution, Exponential, GammaDistribution, LogNormal, Normal, Weibull,
37};
38
39use crate::distribution::{
40    fit_best, fit_beta, fit_exponential, fit_gamma, fit_lognormal, fit_normal, fit_weibull,
41    FitResult,
42};
43
44/// Lower tail probability: the normal 3σ point, Φ(−3).
45const P_LOWER: f64 = 0.001_349_898_031_630_094_6;
46/// Upper tail probability: Φ(3).
47const P_UPPER: f64 = 1.0 - P_LOWER;
48
49/// Fewest values the method accepts.
50pub const MIN_PERCENTILE_DATA: usize = 20;
51
52/// The distribution family a percentile capability is computed from.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54#[non_exhaustive]
55pub enum CapabilityModel {
56    /// The family [`fit_best`] ranks first by AIC.
57    #[default]
58    Auto,
59    /// Normal.
60    Normal,
61    /// Log-normal (positive data).
62    LogNormal,
63    /// Two-parameter Weibull (positive data).
64    Weibull,
65    /// Gamma (positive data).
66    Gamma,
67    /// Exponential (positive data).
68    Exponential,
69    /// Beta (data within (0, 1)).
70    Beta,
71}
72
73impl CapabilityModel {
74    /// The fitted families, in the names [`FitResult::distribution`] uses.
75    fn of_fit(name: &str) -> Option<Self> {
76        Some(match name {
77            "Normal" => Self::Normal,
78            "LogNormal" => Self::LogNormal,
79            "Weibull" => Self::Weibull,
80            "Gamma" => Self::Gamma,
81            "Exponential" => Self::Exponential,
82            "Beta" => Self::Beta,
83            _ => return None,
84        })
85    }
86
87    fn fit(self, data: &[f64]) -> Option<FitResult> {
88        match self {
89            Self::Auto => fit_best(data)
90                .into_iter()
91                .find(|f| Self::of_fit(&f.distribution).is_some()),
92            Self::Normal => fit_normal(data),
93            Self::LogNormal => fit_lognormal(data),
94            Self::Weibull => fit_weibull(data),
95            Self::Gamma => fit_gamma(data),
96            Self::Exponential => fit_exponential(data),
97            Self::Beta => fit_beta(data),
98        }
99    }
100}
101
102/// Why a percentile capability was refused.
103#[derive(Debug, Clone, PartialEq)]
104#[non_exhaustive]
105pub enum PercentileCapabilityError {
106    /// Neither USL nor LSL was given.
107    NoSpecification,
108    /// USL is not above LSL.
109    InvalidSpecification,
110    /// Fewer than [`MIN_PERCENTILE_DATA`] values.
111    InsufficientData {
112        /// How many were given.
113        got: usize,
114    },
115    /// A value is NaN or infinite.
116    NonFiniteData {
117        /// Position of the first one.
118        index: usize,
119    },
120    /// Every value is the same.
121    NoVariation,
122    /// The named family cannot describe the data (log-normal, Weibull, gamma
123    /// and exponential need positive values, beta values within (0, 1)).
124    ModelNotFitted(CapabilityModel),
125}
126
127impl fmt::Display for PercentileCapabilityError {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            Self::NoSpecification => {
131                write!(f, "at least one specification limit (USL or LSL) is required")
132            }
133            Self::InvalidSpecification => write!(f, "USL must be greater than LSL"),
134            Self::InsufficientData { got } => write!(
135                f,
136                "at least {MIN_PERCENTILE_DATA} data points are required for percentile capability, got {got}"
137            ),
138            Self::NonFiniteData { index } => {
139                write!(f, "data[{index}] is not a finite number")
140            }
141            Self::NoVariation => write!(f, "data has no variation"),
142            Self::ModelNotFitted(model) => {
143                write!(f, "the {model:?} distribution cannot be fitted to this data")
144            }
145        }
146    }
147}
148
149impl std::error::Error for PercentileCapabilityError {}
150
151/// Result of percentile-based process capability analysis.
152#[derive(Debug, Clone)]
153pub struct PercentileCapabilityResult {
154    /// Cp* = (USL − LSL) / (X₉₉.₈₆₅ − X₀.₁₃₅). Requires both limits.
155    pub cp_star: Option<f64>,
156    /// Cpk* = min(Cpu*, Cpl*). Requires at least one limit.
157    pub cpk_star: Option<f64>,
158    /// Cpu* = (USL − X₅₀) / (X₉₉.₈₆₅ − X₅₀). Requires USL.
159    pub cpu_star: Option<f64>,
160    /// Cpl* = (X₅₀ − LSL) / (X₅₀ − X₀.₁₃₅). Requires LSL.
161    pub cpl_star: Option<f64>,
162    /// Median of the fitted distribution, X₅₀.
163    pub median: f64,
164    /// 0.135 % quantile of the fitted distribution (lower natural process limit).
165    pub percentile_lower: f64,
166    /// 99.865 % quantile of the fitted distribution (upper natural process limit).
167    pub percentile_upper: f64,
168    /// The family the quantiles come from — never [`CapabilityModel::Auto`].
169    pub model: CapabilityModel,
170    /// Its fitted parameters, as [`FitResult::parameters`] names them.
171    pub parameters: Vec<(String, f64)>,
172}
173
174/// Computes percentile-based process capability indices.
175///
176/// Fits `model` to `data` (or, for [`CapabilityModel::Auto`], the family
177/// [`fit_best`] ranks first) and takes the 0.135 %, 50 % and 99.865 %
178/// quantiles from it.
179///
180/// # Errors
181///
182/// [`PercentileCapabilityError`] — no limit, USL ≤ LSL, fewer than
183/// [`MIN_PERCENTILE_DATA`] values, a value that is not finite, no variation,
184/// or a named family the data does not admit.
185///
186/// # Examples
187///
188/// ```
189/// use u_analytics::capability::{percentile_capability, CapabilityModel};
190///
191/// let data: Vec<f64> = (0..100).map(|i| 10.0 + (i as f64) * 0.1).collect();
192/// let result = percentile_capability(&data, Some(5.0), Some(15.0), CapabilityModel::Auto).unwrap();
193/// assert!(result.cp_star.is_some());
194/// assert_ne!(result.model, CapabilityModel::Auto);
195/// ```
196pub fn percentile_capability(
197    data: &[f64],
198    lsl: Option<f64>,
199    usl: Option<f64>,
200    model: CapabilityModel,
201) -> Result<PercentileCapabilityResult, PercentileCapabilityError> {
202    if usl.is_none() && lsl.is_none() {
203        return Err(PercentileCapabilityError::NoSpecification);
204    }
205    if let (Some(u), Some(l)) = (usl, lsl) {
206        if u <= l {
207            return Err(PercentileCapabilityError::InvalidSpecification);
208        }
209    }
210    if data.len() < MIN_PERCENTILE_DATA {
211        return Err(PercentileCapabilityError::InsufficientData { got: data.len() });
212    }
213    if let Some(index) = data.iter().position(|v| !v.is_finite()) {
214        return Err(PercentileCapabilityError::NonFiniteData { index });
215    }
216    if data.iter().all(|&v| v == data[0]) {
217        return Err(PercentileCapabilityError::NoVariation);
218    }
219
220    let fit = model
221        .fit(data)
222        .ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
223    let fitted = CapabilityModel::of_fit(&fit.distribution)
224        .ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
225    let (p_lower, median, p_upper) =
226        quantiles(fitted, &fit).ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
227
228    let spread = p_upper - p_lower;
229    if spread <= 0.0 {
230        return Err(PercentileCapabilityError::NoVariation);
231    }
232    let upper_spread = p_upper - median;
233    let lower_spread = median - p_lower;
234
235    let cp_star = match (usl, lsl) {
236        (Some(u), Some(l)) => Some((u - l) / spread),
237        _ => None,
238    };
239    let cpu_star = usl.and_then(|u| (upper_spread > 0.0).then(|| (u - median) / upper_spread));
240    let cpl_star = lsl.and_then(|l| (lower_spread > 0.0).then(|| (median - l) / lower_spread));
241    let cpk_star = match (cpu_star, cpl_star) {
242        (Some(u), Some(l)) => Some(u.min(l)),
243        (one, other) => one.or(other),
244    };
245
246    Ok(PercentileCapabilityResult {
247        cp_star,
248        cpk_star,
249        cpu_star,
250        cpl_star,
251        median,
252        percentile_lower: p_lower,
253        percentile_upper: p_upper,
254        model: fitted,
255        parameters: fit.parameters,
256    })
257}
258
259/// The 0.135 %, 50 % and 99.865 % quantiles of a fitted family.
260fn quantiles(model: CapabilityModel, fit: &FitResult) -> Option<(f64, f64, f64)> {
261    let param = |name: &str| {
262        fit.parameters
263            .iter()
264            .find(|(n, _)| n == name)
265            .map(|&(_, v)| v)
266    };
267    let q: Box<dyn Fn(f64) -> Option<f64>> = match model {
268        CapabilityModel::Normal => {
269            let d = Normal::new(param("mu")?, param("sigma")?).ok()?;
270            Box::new(move |p| d.quantile(p))
271        }
272        CapabilityModel::LogNormal => {
273            let d = LogNormal::new(param("mu")?, param("sigma")?).ok()?;
274            Box::new(move |p| d.quantile(p))
275        }
276        CapabilityModel::Weibull => {
277            let d = Weibull::new(param("shape")?, param("scale")?).ok()?;
278            Box::new(move |p| d.quantile(p))
279        }
280        CapabilityModel::Gamma => {
281            // `fit_gamma` reports the rate as `beta`.
282            let d = GammaDistribution::new(param("alpha")?, param("beta")?).ok()?;
283            Box::new(move |p| d.quantile(p))
284        }
285        CapabilityModel::Exponential => {
286            let d = Exponential::new(param("lambda")?).ok()?;
287            Box::new(move |p| d.quantile(p))
288        }
289        CapabilityModel::Beta => {
290            let d = BetaDistribution::new(param("alpha")?, param("beta")?).ok()?;
291            Box::new(move |p| d.quantile(p))
292        }
293        CapabilityModel::Auto => return None,
294    };
295    let (lo, mid, hi) = (q(P_LOWER)?, q(0.5)?, q(P_UPPER)?);
296    (lo.is_finite() && mid.is_finite() && hi.is_finite()).then_some((lo, mid, hi))
297}
298
299// ---------------------------------------------------------------------------
300// Tests
301// ---------------------------------------------------------------------------
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use u_numflow::distributions::Sample;
307
308    /// Generate uniform-like data for testing.
309    fn uniform_data(n: usize) -> Vec<f64> {
310        (0..n)
311            .map(|i| 10.0 + (i as f64) / (n as f64 - 1.0) * 10.0)
312            .collect()
313    }
314
315    fn pc(
316        data: &[f64],
317        lsl: Option<f64>,
318        usl: Option<f64>,
319    ) -> Result<PercentileCapabilityResult, PercentileCapabilityError> {
320        percentile_capability(data, lsl, usl, CapabilityModel::Auto)
321    }
322
323    #[test]
324    fn basic_two_sided() {
325        let data = uniform_data(100);
326        let result = pc(&data, Some(5.0), Some(25.0)).unwrap();
327
328        assert!(result.cp_star.is_some());
329        assert!(result.cpk_star.is_some());
330        assert!(result.cpu_star.is_some());
331        assert!(result.cpl_star.is_some());
332        assert!(result.cp_star.unwrap() > 0.0);
333        assert!(
334            (result.median - 15.0).abs() < 0.5,
335            "median should be ~15, got {}",
336            result.median
337        );
338    }
339
340    #[test]
341    fn usl_only() {
342        let data = uniform_data(50);
343        let result = pc(&data, None, Some(25.0)).unwrap();
344
345        assert!(result.cp_star.is_none(), "Cp* requires both limits");
346        assert!(result.cpu_star.is_some());
347        assert!(result.cpl_star.is_none());
348        assert_eq!(result.cpk_star, result.cpu_star);
349    }
350
351    #[test]
352    fn lsl_only() {
353        let data = uniform_data(50);
354        let result = pc(&data, Some(5.0), None).unwrap();
355
356        assert!(result.cp_star.is_none());
357        assert!(result.cpu_star.is_none());
358        assert!(result.cpl_star.is_some());
359        assert_eq!(result.cpk_star, result.cpl_star);
360    }
361
362    #[test]
363    fn refusals_name_their_reason() {
364        let few: Vec<f64> = (0..19).map(|i| i as f64).collect();
365        assert_eq!(
366            pc(&few, Some(0.0), Some(20.0)).unwrap_err(),
367            PercentileCapabilityError::InsufficientData { got: 19 }
368        );
369        let data = uniform_data(30);
370        assert_eq!(
371            pc(&data, None, None).unwrap_err(),
372            PercentileCapabilityError::NoSpecification
373        );
374        assert_eq!(
375            pc(&data, Some(10.0), Some(5.0)).unwrap_err(),
376            PercentileCapabilityError::InvalidSpecification
377        );
378        assert_eq!(
379            pc(&data, Some(10.0), Some(10.0)).unwrap_err(),
380            PercentileCapabilityError::InvalidSpecification
381        );
382        let mut nan = uniform_data(30);
383        nan[10] = f64::NAN;
384        assert_eq!(
385            pc(&nan, Some(0.0), Some(30.0)).unwrap_err(),
386            PercentileCapabilityError::NonFiniteData { index: 10 }
387        );
388        assert_eq!(
389            pc(&[3.0; 25], Some(0.0), Some(30.0)).unwrap_err(),
390            PercentileCapabilityError::NoVariation
391        );
392        let mut negative = uniform_data(30);
393        negative[0] = -1.0;
394        assert_eq!(
395            percentile_capability(
396                &negative,
397                Some(-5.0),
398                Some(30.0),
399                CapabilityModel::LogNormal
400            )
401            .unwrap_err(),
402            PercentileCapabilityError::ModelNotFitted(CapabilityModel::LogNormal)
403        );
404    }
405
406    #[test]
407    fn cpk_star_equals_min_of_cpu_cpl() {
408        let data = uniform_data(100);
409        let result = pc(&data, Some(5.0), Some(25.0)).unwrap();
410        let (cpu, cpl, cpk) = (
411            result.cpu_star.unwrap(),
412            result.cpl_star.unwrap(),
413            result.cpk_star.unwrap(),
414        );
415        assert!((cpk - cpu.min(cpl)).abs() < 1e-10);
416    }
417
418    #[test]
419    fn symmetric_data_gives_balanced_cpu_cpl() {
420        let data: Vec<f64> = (0..200).map(|i| 40.0 + (i as f64) * 0.1).collect();
421        let result = pc(&data, Some(30.0), Some(70.0)).unwrap();
422        let (cpu, cpl) = (result.cpu_star.unwrap(), result.cpl_star.unwrap());
423        assert!(
424            (cpu - cpl).abs() < cpu * 0.2,
425            "symmetric data should give similar Cpu* ({cpu}) and Cpl* ({cpl})"
426        );
427    }
428
429    /// The defect this module was rewritten for: on normal data with a true Cp
430    /// of 1.0 (limits at ±3σ), the empirical 0.135 % / 99.865 % points are the
431    /// sample minimum and maximum, so Cp* averaged 1.37 at n = 50 and 1.68 at
432    /// n = 20. Quantiles of the fitted normal stay near 1.0 at every size.
433    #[test]
434    fn small_samples_do_not_inflate_cp_star() {
435        let process = Normal::new(10.0, 1.0).unwrap();
436        for (n, empirical_at_least) in [(20_usize, 1.5), (50, 1.25), (100, 1.15)] {
437            let reps = 400;
438            let (mut sum, mut empirical) = (0.0, 0.0);
439            for rep in 0..reps {
440                let data = process.sample_n(n, 1_000 + rep as u64 * 7 + n as u64);
441                let r =
442                    percentile_capability(&data, Some(7.0), Some(13.0), CapabilityModel::Normal)
443                        .unwrap();
444                sum += r.cp_star.unwrap();
445                // What the empirical points gave on the same samples.
446                let lo = u_numflow::stats::quantile(&data, P_LOWER).unwrap();
447                let hi = u_numflow::stats::quantile(&data, P_UPPER).unwrap();
448                empirical += 6.0 / (hi - lo);
449            }
450            let mean = sum / reps as f64;
451            let empirical = empirical / reps as f64;
452            assert!(
453                empirical > empirical_at_least,
454                "n = {n}: the empirical Cp* {empirical} no longer shows the bias this guards"
455            );
456            // The MLE σ is low by about (n−1)/n, and 1/σ̂ is biased up; both
457            // are a few percent at these sizes. The empirical method was 37 %
458            // high at n = 50.
459            assert!(
460                (mean - 1.0).abs() < 0.08,
461                "n = {n}: mean Cp* {mean} (true 1.0)"
462            );
463        }
464    }
465
466    #[test]
467    fn quantiles_are_the_fitted_model_s() {
468        let process = Normal::new(50.0, 2.0).unwrap();
469        let data = process.sample_n(200, 42);
470        let r =
471            percentile_capability(&data, Some(40.0), Some(60.0), CapabilityModel::Normal).unwrap();
472        let mu = r.parameters.iter().find(|(n, _)| n == "mu").unwrap().1;
473        let sigma = r.parameters.iter().find(|(n, _)| n == "sigma").unwrap().1;
474        assert_eq!(r.model, CapabilityModel::Normal);
475        assert!((r.median - mu).abs() < 1e-9);
476        assert!((r.percentile_lower - (mu - 3.0 * sigma)).abs() < 1e-6);
477        assert!((r.percentile_upper - (mu + 3.0 * sigma)).abs() < 1e-6);
478    }
479
480    #[test]
481    fn auto_reports_the_family_it_used() {
482        let process = LogNormal::new(1.0, 0.6).unwrap();
483        let data = process.sample_n(300, 7);
484        let r = pc(&data, None, Some(30.0)).unwrap();
485        assert_ne!(r.model, CapabilityModel::Auto);
486        assert_eq!(
487            CapabilityModel::of_fit(&fit_best(&data)[0].distribution),
488            Some(r.model)
489        );
490    }
491}