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}
492
493/// Against SciPy 1.17.1: the family fitted by maximum likelihood
494/// (`lognorm.fit(x, floc=0)`; the Weibull's from its likelihood equation), its
495/// `ppf` at `norm.cdf(-3)`, 0.5 and `norm.cdf(3)` -- the equivalents of
496/// ±3 sigma that ISO 22514-2 rounds to 0.135 % and 99.865 %, which this
497/// function takes exactly -- and the ISO 22514-2 ratios taken from them.
498#[cfg(test)]
499mod scipy_reference {
500    use super::*;
501
502    const DATA: [f64; 40] = [
503        3.195, 5.365, 1.217, 5.806, 2.661, 1.896, 1.894, 1.67, 2.458, 3.956, 3.535, 3.623, 1.268,
504        1.341, 5.47, 4.204, 7.26, 4.685, 1.714, 4.847, 3.607, 2.994, 1.88, 2.722, 2.544, 4.062,
505        2.875, 5.546, 1.918, 3.36, 3.402, 1.842, 1.597, 4.289, 3.431, 2.527, 3.246, 2.006, 3.026,
506        2.07,
507    ];
508
509    fn close(got: f64, want: f64, rel: f64, what: &str) {
510        assert!(
511            (got - want).abs() <= rel * want.abs(),
512            "{what}: got {got}, SciPy {want}"
513        );
514    }
515
516    fn check(model: CapabilityModel, rel: f64, want: [f64; 7]) {
517        let r = percentile_capability(&DATA, Some(0.9), Some(9.0), model).expect("valid");
518        assert_eq!(r.model, model);
519        let [lo, med, hi, cp, cpk, cpu, cpl] = want;
520        close(r.percentile_lower, lo, rel, "X0.135");
521        close(r.median, med, rel, "X50");
522        close(r.percentile_upper, hi, rel, "X99.865");
523        close(r.cp_star.expect("both limits"), cp, rel, "Cp*");
524        close(r.cpk_star.expect("limits"), cpk, rel, "Cpk*");
525        close(r.cpu_star.expect("USL"), cpu, rel, "Cpu*");
526        close(r.cpl_star.expect("LSL"), cpl, rel, "Cpl*");
527    }
528
529    #[test]
530    fn log_normal_percentile_capability_reproduces_scipy() {
531        // The log-normal MLE is closed form: tight.
532        check(
533            CapabilityModel::LogNormal,
534            1e-9,
535            [
536                0.7542968892328896,
537                2.8798090991738787,
538                10.994743006455773,
539                0.7909811650077455,
540                0.754188631817962,
541                0.754188631817962,
542                0.9314503534321473,
543            ],
544        );
545    }
546
547    #[test]
548    fn weibull_percentile_capability_reproduces_scipy() {
549        // The exact MLE: the shape's likelihood equation solved to 1e-15
550        // (`scipy.optimize.brentq`), the scale from it. `weibull_min.fit` stops
551        // short of it -- its shape is 2.413418 against 2.413394 -- so its
552        // quantiles are not the reference.
553        check(
554            CapabilityModel::Weibull,
555            1e-9,
556            [
557                0.23258289429884546,
558                3.0872615285886305,
559                7.858145308508201,
560                1.0622167336676158,
561                0.7662023676906101,
562                1.2393381906090049,
563                0.7662023676906101,
564            ],
565        );
566    }
567}