Skip to main content

u_analytics/weibull/
mle.rs

1//! Maximum Likelihood Estimation (MLE) for Weibull parameters.
2//!
3//! Uses the Newton-Raphson method to find the shape parameter beta that
4//! maximizes the Weibull log-likelihood, then derives the scale parameter
5//! analytically. Right-censored observations (units still running --
6//! suspensions) enter the likelihood through their survival term.
7
8use u_numflow::special::inverse_normal_cdf;
9
10/// Result of Weibull MLE fitting.
11#[derive(Debug, Clone)]
12pub struct WeibullMleResult {
13    /// Shape parameter (beta).
14    pub shape: f64,
15    /// Scale parameter (eta).
16    pub scale: f64,
17    /// Log-likelihood at the fitted parameters.
18    pub log_likelihood: f64,
19    /// Number of Newton-Raphson iterations used.
20    pub iterations: usize,
21    /// Asymptotic covariance of `(shape, scale)`: the inverse of the observed
22    /// Fisher information at the fit, censored units included. Row and column
23    /// order is shape, scale.
24    pub covariance: [[f64; 2]; 2],
25}
26
27/// A two-sided confidence interval.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct WeibullInterval {
30    /// Lower confidence limit.
31    pub lower: f64,
32    /// Upper confidence limit.
33    pub upper: f64,
34}
35
36impl WeibullMleResult {
37    /// Two-sided interval for the shape at `confidence` (in (0, 1)), normal
38    /// on the log scale: `β̂ · exp(∓ z · se(β̂) / β̂)` -- the usual Weibull
39    /// practice, which keeps the limits positive. `None` outside (0, 1).
40    pub fn shape_interval(&self, confidence: f64) -> Option<WeibullInterval> {
41        log_normal_interval(self.shape, self.covariance[0][0], confidence)
42    }
43
44    /// Two-sided interval for the scale (characteristic life) at
45    /// `confidence`, normal on the log scale. `None` outside (0, 1).
46    pub fn scale_interval(&self, confidence: f64) -> Option<WeibullInterval> {
47        log_normal_interval(self.scale, self.covariance[1][1], confidence)
48    }
49
50    /// The B-life -- the time by which `fraction_failed` of the population
51    /// has failed (0.1 → B10) -- with its two-sided interval at `confidence`,
52    /// by the delta method on `ln t_p = ln η + ln(−ln(1 − p)) / β`.
53    ///
54    /// Returns `(estimate, interval)`, or `None` when `fraction_failed` or
55    /// `confidence` is outside (0, 1).
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use u_analytics::weibull::weibull_mle_censored;
61    ///
62    /// let fit = weibull_mle_censored(
63    ///     &[55.0, 187.0, 216.0, 240.0, 244.0, 335.0],
64    ///     &[361.0, 373.0, 375.0, 386.0],
65    /// )
66    /// .unwrap();
67    /// let (b10, ci) = fit.b_life_interval(0.10, 0.90).unwrap();
68    /// assert!(ci.lower < b10 && b10 < ci.upper);
69    /// ```
70    pub fn b_life_interval(
71        &self,
72        fraction_failed: f64,
73        confidence: f64,
74    ) -> Option<(f64, WeibullInterval)> {
75        super::ReliabilityAnalysis::from_mle(self).b_life_interval(fraction_failed, confidence)
76    }
77}
78
79/// The two-sided standard normal quantile for `confidence`, or `None`
80/// outside (0, 1).
81pub(super) fn two_sided_z(confidence: f64) -> Option<f64> {
82    (confidence > 0.0 && confidence < 1.0)
83        .then(|| inverse_normal_cdf(1.0 - (1.0 - confidence) / 2.0))
84}
85
86fn log_normal_interval(estimate: f64, variance: f64, confidence: f64) -> Option<WeibullInterval> {
87    let z = two_sided_z(confidence)?;
88    let half = z * variance.max(0.0).sqrt() / estimate;
89    Some(WeibullInterval {
90        lower: estimate * (-half).exp(),
91        upper: estimate * half.exp(),
92    })
93}
94
95/// Maximum Newton-Raphson iterations.
96const MAX_ITER: usize = 100;
97
98/// Convergence tolerance for Newton-Raphson.
99const TOL: f64 = 1e-10;
100
101/// Fits Weibull distribution parameters (shape β, scale η) via Maximum Likelihood Estimation.
102///
103/// Given failure times t₁, …, tₙ (all positive), finds the shape (β) and scale (η)
104/// parameters that maximise the Weibull log-likelihood:
105///
106/// ```text
107/// L(β, η) = n·ln(β) - n·β·ln(η) + (β-1)·Σln(tᵢ) - Σ(tᵢ/η)^β
108/// ```
109///
110/// The MLE for η given β is derived analytically:
111///
112/// ```text
113/// η_hat = (Σ tᵢ^β / n)^(1/β)
114/// ```
115///
116/// Substituting into the profile log-likelihood yields the score equation for β:
117///
118/// ```text
119/// f(β) = n/β + Σln(tᵢ) - n · Σ(tᵢ^β · ln(tᵢ)) / Σtᵢ^β = 0
120/// ```
121///
122/// Newton-Raphson solves f(β) = 0. The derivative is:
123///
124/// ```text
125/// f′(β) = -n/β² - n · [S₂·S₀ - S₁²] / S₀²
126/// ```
127///
128/// where S₀ = Σtᵢ^β, S₁ = Σtᵢ^β·ln(tᵢ), S₂ = Σtᵢ^β·(ln(tᵢ))².
129///
130/// Iteration starts from β₀ = 1.2 (slightly above the exponential case β = 1).
131///
132/// # Reference
133/// Lawless, J.F. (2003). *Statistical Models and Methods for Lifetime Data*, 2nd ed., §4.2.
134/// Wiley-Interscience. ISBN 0-471-37215-3.
135///
136/// # Complexity
137/// O(n·k) where n = sample size, k = Newton-Raphson iterations (typically < 20)
138///
139/// # Arguments
140/// * `failure_times` - Positive failure times (must have at least 2 values)
141///
142/// # Returns
143/// `None` if data is insufficient (< 2 values), any value is non-positive,
144/// or the algorithm does not converge within 100 iterations.
145///
146/// # Panics
147/// Does not panic. All error conditions return `None`.
148///
149/// # Examples
150///
151/// ```
152/// use u_analytics::weibull::weibull_mle;
153/// let data = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0];
154/// let result = weibull_mle(&data).unwrap();
155/// assert!(result.shape > 0.0);
156/// assert!(result.scale > 0.0);
157/// assert!(result.log_likelihood.is_finite());
158/// ```
159pub fn weibull_mle(failure_times: &[f64]) -> Option<WeibullMleResult> {
160    weibull_mle_censored(failure_times, &[])
161}
162
163/// Weibull MLE from failure times and right-censored times (suspensions).
164///
165/// A suspension is a unit removed, or still running, at time `c` without
166/// having failed: it contributes its survival probability `exp(-(c/η)^β)` to
167/// the likelihood rather than a density. Dropping suspensions -- fitting the
168/// failures alone -- biases the scale low (the survivors' running time is
169/// ignored) and with it every B-life and MTBF taken from the fit. With `r`
170/// failures and the sums over **every** unit (failures and suspensions):
171///
172/// ```text
173/// L(β, η) = r·ln(β) − r·β·ln(η) + (β−1)·Σ_fail ln(tᵢ) − Σ_all (tᵢ/η)^β
174/// η_hat   = (Σ_all tᵢ^β / r)^(1/β)
175/// f(β)    = r/β + Σ_fail ln(tᵢ) − r · S₁/S₀ = 0
176/// ```
177///
178/// with `S₀ = Σ_all tᵢ^β`, `S₁ = Σ_all tᵢ^β·ln(tᵢ)` -- the same Newton-Raphson
179/// iteration as [`weibull_mle`], which is this function with no suspensions.
180///
181/// # Reference
182/// Lawless, J.F. (2003). *Statistical Models and Methods for Lifetime Data*,
183/// 2nd ed., §4.2 (censored Weibull likelihood). Abernethy, R.B. (2006).
184/// *The New Weibull Handbook*, 5th ed., ch. 5 (suspensions).
185///
186/// # Returns
187/// `None` if there are fewer than 2 failures, any time is non-positive or
188/// non-finite, or the iteration does not converge.
189///
190/// # Examples
191///
192/// ```
193/// use u_analytics::weibull::{weibull_mle, weibull_mle_censored};
194/// let failures = [120.0, 190.0, 260.0, 310.0, 400.0];
195/// let survivors = [450.0, 450.0, 450.0]; // still running at 450 h
196///
197/// let fit = weibull_mle_censored(&failures, &survivors).unwrap();
198/// let ignored = weibull_mle(&failures).unwrap();
199/// // The survivors' running time lengthens the characteristic life.
200/// assert!(fit.scale > ignored.scale);
201/// ```
202pub fn weibull_mle_censored(
203    failure_times: &[f64],
204    suspension_times: &[f64],
205) -> Option<WeibullMleResult> {
206    let r = failure_times.len();
207    if r < 2 {
208        return None;
209    }
210
211    // Validate: all values must be positive and finite
212    if !failure_times
213        .iter()
214        .chain(suspension_times)
215        .all(|&t| t.is_finite() && t > 0.0)
216    {
217        return None;
218    }
219
220    // Every unit's time and its log -- the sums S0..S2 run over all of them;
221    // only the failures' logs enter the density term.
222    let all: Vec<f64> = failure_times
223        .iter()
224        .chain(suspension_times)
225        .copied()
226        .collect();
227    let ln_t: Vec<f64> = all.iter().map(|t| t.ln()).collect();
228    let sum_ln_t: f64 = ln_t[..r].iter().sum();
229    let n_f = r as f64;
230
231    // Newton-Raphson to solve the profile likelihood equation for beta.
232    // f(beta) = n/beta + sum(ln(t_i)) - n * S1 / S0
233    // where S0 = sum(t_i^beta), S1 = sum(t_i^beta * ln(t_i))
234    //
235    // f'(beta) = -n/beta^2 - n * [S2*S0 - S1^2] / S0^2
236    // where S2 = sum(t_i^beta * (ln(t_i))^2)
237
238    let mut beta = 1.2_f64; // Initial guess slightly above exponential
239    let mut iterations = 0;
240
241    for iter in 0..MAX_ITER {
242        iterations = iter + 1;
243
244        // Compute S0, S1, S2
245        let mut s0 = 0.0_f64;
246        let mut s1 = 0.0_f64;
247        let mut s2 = 0.0_f64;
248
249        for (i, &t) in all.iter().enumerate() {
250            let t_beta = t.powf(beta);
251            let lt = ln_t[i];
252            s0 += t_beta;
253            s1 += t_beta * lt;
254            s2 += t_beta * lt * lt;
255        }
256
257        if s0 == 0.0 {
258            return None;
259        }
260
261        let f_val = n_f / beta + sum_ln_t - n_f * s1 / s0;
262        let f_prime = -n_f / (beta * beta) - n_f * (s2 * s0 - s1 * s1) / (s0 * s0);
263
264        if f_prime.abs() < 1e-30 {
265            return None; // Derivative too small, cannot proceed
266        }
267
268        let delta = f_val / f_prime;
269        beta -= delta;
270
271        // Ensure beta stays positive
272        if beta <= 0.0 {
273            beta = 0.01;
274        }
275
276        if delta.abs() < TOL {
277            break;
278        }
279
280        if iter == MAX_ITER - 1 {
281            return None; // Did not converge
282        }
283    }
284
285    // Compute scale parameter: eta = (sum over all units of t_i^beta / r)^(1/beta)
286    let s0: f64 = all.iter().map(|t| t.powf(beta)).sum();
287    let eta = (s0 / n_f).powf(1.0 / beta);
288
289    if !eta.is_finite() || eta <= 0.0 {
290        return None;
291    }
292
293    // Compute log-likelihood at the fitted parameters
294    let log_likelihood = n_f * beta.ln() - n_f * beta * eta.ln() + (beta - 1.0) * sum_ln_t
295        - all.iter().map(|&t| (t / eta).powf(beta)).sum::<f64>();
296
297    let covariance = observed_covariance(&all, r, beta, eta)?;
298
299    Some(WeibullMleResult {
300        shape: beta,
301        scale: eta,
302        log_likelihood,
303        iterations,
304        covariance,
305    })
306}
307
308/// Inverse of the observed Fisher information of the censored Weibull
309/// log-likelihood at `(beta, eta)`. With `z = (t/η)^β` and `y = ln(t/η)` over
310/// every unit, and `r` failures:
311///
312/// ```text
313/// −∂²ℓ/∂β²   = r/β² + Σ z·y²
314/// −∂²ℓ/∂η²   = −rβ/η² + β(β+1)/η² · Σ z
315/// −∂²ℓ/∂β∂η  = r/η − Σ z/η − (β/η) · Σ z·y
316/// ```
317///
318/// `None` when the information is not positive definite (no curvature to
319/// invert -- a fit on a likelihood ridge).
320fn observed_covariance(all: &[f64], r: usize, beta: f64, eta: f64) -> Option<[[f64; 2]; 2]> {
321    let r = r as f64;
322    let (mut sz, mut szy, mut szy2) = (0.0, 0.0, 0.0);
323    for &t in all {
324        let y = (t / eta).ln();
325        let z = (beta * y).exp();
326        sz += z;
327        szy += z * y;
328        szy2 += z * y * y;
329    }
330    let i_bb = r / (beta * beta) + szy2;
331    let i_ee = -r * beta / (eta * eta) + beta * (beta + 1.0) / (eta * eta) * sz;
332    let i_be = r / eta - sz / eta - beta / eta * szy;
333    let det = i_bb * i_ee - i_be * i_be;
334    if !(det > 0.0 && i_bb > 0.0 && det.is_finite()) {
335        return None;
336    }
337    Some([[i_ee / det, -i_be / det], [-i_be / det, i_bb / det]])
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    // --- Intervals -------------------------------------------------------
345
346    /// A complete sample at the Weibull quantiles: the observed information
347    /// approaches the expected one, whose inverse is known in closed form --
348    /// n·Var(β̂)/β² → 6/π² = 0.6079 and n·Var(η̂)·β²/η² → 1 + 6(1 − γ)²/π²
349    /// = 1.1087 (Cohen 1965; γ Euler's constant).
350    #[test]
351    fn covariance_approaches_the_asymptotic_weibull_variances() {
352        let (beta, eta, n) = (2.0_f64, 100.0_f64, 2000usize);
353        let data: Vec<f64> = (1..=n)
354            .map(|i| {
355                let p = (i as f64 - 0.5) / n as f64;
356                eta * (-(1.0 - p).ln()).powf(1.0 / beta)
357            })
358            .collect();
359        let fit = weibull_mle(&data).unwrap();
360        let nf = n as f64;
361        let vb = nf * fit.covariance[0][0] / (fit.shape * fit.shape);
362        let ve = nf * fit.covariance[1][1] * fit.shape * fit.shape / (fit.scale * fit.scale);
363        let gamma = 0.577_215_664_901_532_9_f64;
364        let pi2 = std::f64::consts::PI.powi(2);
365        assert!((vb - 6.0 / pi2).abs() < 0.02, "{vb}");
366        assert!(
367            (ve - (1.0 + 6.0 * (1.0 - gamma).powi(2) / pi2)).abs() < 0.03,
368            "{ve}"
369        );
370    }
371
372    /// The covariance is the inverse of the negated Hessian of the likelihood
373    /// written out independently, by finite differences.
374    #[test]
375    fn covariance_is_the_inverse_observed_information() {
376        let fit = weibull_mle_censored(&FAILURES, &SUSPENSIONS).unwrap();
377        let (b, e) = (fit.shape, fit.scale);
378        let l = |b: f64, e: f64| censored_loglik(&FAILURES, &SUSPENSIONS, b, e);
379        let (hb, he) = (1e-4, 1e-2);
380        let d_bb = (l(b + hb, e) - 2.0 * l(b, e) + l(b - hb, e)) / (hb * hb);
381        let d_ee = (l(b, e + he) - 2.0 * l(b, e) + l(b, e - he)) / (he * he);
382        let d_be = (l(b + hb, e + he) - l(b + hb, e - he) - l(b - hb, e + he) + l(b - hb, e - he))
383            / (4.0 * hb * he);
384        let (i_bb, i_ee, i_be) = (-d_bb, -d_ee, -d_be);
385        let det = i_bb * i_ee - i_be * i_be;
386        let want = [[i_ee / det, -i_be / det], [-i_be / det, i_bb / det]];
387        for (row, wrow) in fit.covariance.iter().zip(&want) {
388            for (got, w) in row.iter().zip(wrow) {
389                assert!(
390                    (got - w).abs() <= 1e-3 * w.abs().max(1e-6),
391                    "{:?} vs {want:?}",
392                    fit.covariance
393                );
394            }
395        }
396    }
397
398    #[test]
399    fn intervals_bracket_and_widen() {
400        let fit = weibull_mle_censored(&FAILURES, &SUSPENSIONS).unwrap();
401        let (s90, s99) = (
402            fit.shape_interval(0.90).unwrap(),
403            fit.shape_interval(0.99).unwrap(),
404        );
405        assert!(
406            s99.lower < s90.lower
407                && s90.lower < fit.shape
408                && fit.shape < s90.upper
409                && s90.upper < s99.upper
410        );
411        let c = fit.scale_interval(0.95).unwrap();
412        assert!(c.lower < fit.scale && fit.scale < c.upper && c.lower > 0.0);
413        let (b10, ci) = fit.b_life_interval(0.10, 0.95).unwrap();
414        assert!(ci.lower < b10 && b10 < ci.upper);
415        // B10 is the 10 % quantile of the fitted distribution.
416        let want = fit.scale * (-(0.9_f64).ln()).powf(1.0 / fit.shape);
417        assert!((b10 - want).abs() < 1e-9 * want);
418        assert!(fit.shape_interval(1.0).is_none() && fit.b_life_interval(0.0, 0.9).is_none());
419    }
420
421    // --- Right censoring ------------------------------------------------
422
423    /// The censored log-likelihood, written out independently of the fit.
424    fn censored_loglik(f: &[f64], s: &[f64], beta: f64, eta: f64) -> f64 {
425        let r = f.len() as f64;
426        r * beta.ln() - r * beta * eta.ln() + (beta - 1.0) * f.iter().map(|t| t.ln()).sum::<f64>()
427            - f.iter().chain(s).map(|t| (t / eta).powf(beta)).sum::<f64>()
428    }
429
430    const FAILURES: [f64; 6] = [55.0, 187.0, 216.0, 240.0, 244.0, 335.0];
431    const SUSPENSIONS: [f64; 4] = [361.0, 373.0, 375.0, 386.0];
432
433    /// The fit is the maximum of that likelihood: every step away from it in
434    /// shape or scale lowers it, and the reported value is it.
435    #[test]
436    fn censored_fit_maximises_the_censored_likelihood() {
437        let fit = weibull_mle_censored(&FAILURES, &SUSPENSIONS).expect("converges");
438        let best = censored_loglik(&FAILURES, &SUSPENSIONS, fit.shape, fit.scale);
439        assert!((fit.log_likelihood - best).abs() < 1e-9);
440        for (db, de) in [
441            (0.01, 0.0),
442            (-0.01, 0.0),
443            (0.0, 1.0),
444            (0.0, -1.0),
445            (0.01, 1.0),
446        ] {
447            let other = censored_loglik(&FAILURES, &SUSPENSIONS, fit.shape + db, fit.scale + de);
448            assert!(other < best, "({db}, {de}): {other} >= {best}");
449        }
450    }
451
452    /// No suspensions: exactly the uncensored fit (one path, not two).
453    #[test]
454    fn no_suspensions_is_the_uncensored_fit() {
455        let a = weibull_mle_censored(&FAILURES, &[]).unwrap();
456        let b = weibull_mle(&FAILURES).unwrap();
457        assert_eq!(
458            (a.shape, a.scale, a.log_likelihood),
459            (b.shape, b.scale, b.log_likelihood)
460        );
461    }
462
463    /// Survivors lengthen the characteristic life; later survivors more so.
464    #[test]
465    fn suspensions_lengthen_the_scale() {
466        let without = weibull_mle(&FAILURES).unwrap().scale;
467        let with = weibull_mle_censored(&FAILURES, &SUSPENSIONS).unwrap().scale;
468        let later: Vec<f64> = SUSPENSIONS.iter().map(|t| t * 2.0).collect();
469        let longer = weibull_mle_censored(&FAILURES, &later).unwrap().scale;
470        assert!(without < with && with < longer, "{without} {with} {longer}");
471    }
472
473    /// Exponential check: with β fixed at 1 the censored MLE of the mean is
474    /// total time on test over failures. The fitted shape is not exactly 1,
475    /// so compare the score at β = 1 instead: zero exactly when η = TTT / r.
476    #[test]
477    fn exponential_special_case_is_time_on_test_over_failures() {
478        let ttt: f64 = FAILURES.iter().chain(&SUSPENSIONS).sum();
479        let eta = ttt / FAILURES.len() as f64;
480        // d/dη of the log-likelihood at β = 1: −r/η + Σ t / η² = 0 at η = TTT / r.
481        let score = -(FAILURES.len() as f64) / eta + ttt / (eta * eta);
482        assert!(score.abs() < 1e-12);
483        let h = 1e-6;
484        let numeric = (censored_loglik(&FAILURES, &SUSPENSIONS, 1.0, eta + h)
485            - censored_loglik(&FAILURES, &SUSPENSIONS, 1.0, eta - h))
486            / (2.0 * h);
487        assert!(numeric.abs() < 1e-6, "{numeric}");
488    }
489
490    #[test]
491    fn censored_input_is_checked() {
492        assert!(weibull_mle_censored(&[10.0], &[20.0, 30.0]).is_none()); // one failure
493        assert!(weibull_mle_censored(&[10.0, 20.0], &[0.0]).is_none());
494        assert!(weibull_mle_censored(&[10.0, 20.0], &[f64::NAN]).is_none());
495    }
496
497    #[test]
498    fn test_mle_uniform_spacing() {
499        // Uniformly spaced failure times
500        let data = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
501        let result = weibull_mle(&data).expect("MLE should converge");
502
503        // Shape should be in a reasonable range for linearly spaced data
504        assert!(
505            result.shape > 1.5 && result.shape < 5.0,
506            "shape = {}, expected in [1.5, 5.0]",
507            result.shape
508        );
509        // Scale should be reasonable relative to the data range
510        assert!(
511            result.scale > 40.0 && result.scale < 100.0,
512            "scale = {}, expected in [40, 100]",
513            result.scale
514        );
515        assert!(result.log_likelihood.is_finite());
516    }
517
518    #[test]
519    fn test_mle_near_exponential() {
520        // For approximately exponential data, beta should be close to 1.0
521        let data = [5.0, 10.0, 15.0, 25.0, 35.0, 50.0, 75.0, 100.0];
522        let result = weibull_mle(&data).expect("MLE should converge");
523
524        assert!(
525            result.shape > 0.5 && result.shape < 2.0,
526            "shape = {}, expected near 1.0",
527            result.shape
528        );
529    }
530
531    #[test]
532    fn test_mle_converges_with_iterations() {
533        let data = [10.0, 20.0, 30.0, 40.0, 50.0];
534        let result = weibull_mle(&data).expect("MLE should converge");
535        assert!(result.iterations > 0);
536        assert!(result.iterations <= MAX_ITER);
537    }
538
539    #[test]
540    fn test_mle_insufficient_data() {
541        assert!(weibull_mle(&[]).is_none());
542        assert!(weibull_mle(&[10.0]).is_none());
543    }
544
545    #[test]
546    fn test_mle_invalid_data() {
547        // Non-positive values
548        assert!(weibull_mle(&[0.0, 10.0, 20.0]).is_none());
549        assert!(weibull_mle(&[-5.0, 10.0, 20.0]).is_none());
550        // NaN / Inf
551        assert!(weibull_mle(&[f64::NAN, 10.0, 20.0]).is_none());
552        assert!(weibull_mle(&[f64::INFINITY, 10.0, 20.0]).is_none());
553    }
554
555    #[test]
556    fn test_mle_log_likelihood_is_negative() {
557        let data = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
558        let result = weibull_mle(&data).expect("MLE should converge");
559        // Log-likelihood for continuous distributions is typically negative
560        assert!(
561            result.log_likelihood < 0.0,
562            "log_likelihood = {}, expected < 0",
563            result.log_likelihood
564        );
565    }
566
567    #[test]
568    fn test_mle_known_weibull_data() {
569        // Data generated from Weibull(beta=2.0, eta=50.0) approximately
570        // These are the quantiles: t_i = eta * (-ln(1 - F_i))^(1/beta)
571        // with F_i = (i - 0.5) / n for i = 1..n, n=10
572        let data: Vec<f64> = (1..=10)
573            .map(|i| {
574                let f = (i as f64 - 0.5) / 10.0;
575                50.0 * (-(1.0 - f).ln()).powf(0.5) // beta=2.0, eta=50.0
576            })
577            .collect();
578
579        let result = weibull_mle(&data).expect("MLE should converge");
580
581        assert!(
582            (result.shape - 2.0).abs() < 0.5,
583            "shape = {}, expected near 2.0",
584            result.shape
585        );
586        assert!(
587            (result.scale - 50.0).abs() < 15.0,
588            "scale = {}, expected near 50.0",
589            result.scale
590        );
591    }
592
593    #[test]
594    fn test_mle_identical_values() {
595        // All identical positive values — degenerate case
596        let data = [10.0, 10.0, 10.0, 10.0, 10.0];
597        // MLE may or may not converge; if it does, check validity
598        if let Some(result) = weibull_mle(&data) {
599            assert!(result.shape.is_finite() && result.shape > 0.0);
600            assert!(result.scale.is_finite() && result.scale > 0.0);
601        }
602    }
603
604    #[test]
605    fn test_mle_lawless_ball_bearing() {
606        // Lawless (2003), §4.2 — ball bearing failure times (million revolutions).
607        // Reference MLE estimates: shape β ≈ 2.102, scale η ≈ 81.88.
608        // Cross-checked with R: library(MASS); fitdistr(data, "weibull")
609        let data = vec![
610            17.88, 28.92, 33.0, 41.52, 42.12, 45.6, 48.48, 51.84, 51.96, 54.12, 55.56, 67.8, 68.64,
611            68.64, 68.88, 84.12, 93.12, 98.64, 105.12, 105.84, 127.92, 128.04, 173.4_f64,
612        ];
613        let result = weibull_mle(&data).expect("MLE should converge on Lawless §4.2 data");
614        assert!(
615            (result.shape - 2.102).abs() < 0.01,
616            "shape β = {:.6}, expected 2.102 ± 0.01",
617            result.shape
618        );
619        assert!(
620            (result.scale - 81.88).abs() < 0.5,
621            "scale η = {:.6}, expected 81.88 ± 0.5",
622            result.scale
623        );
624        assert!(result.log_likelihood.is_finite());
625        assert!(
626            result.iterations <= 20,
627            "expected convergence in ≤ 20 iterations, got {}",
628            result.iterations
629        );
630    }
631}