Reliability — Weibull life-data analysis (right-censored)¶
Reuses survival_mcmc.py; the engineering framing of the same Weibull¶
Same Weibull machinery as survival_python.ipynb (see it for the censored-likelihood derivation), but the reliability / failure-time framing: a single sample (no covariates) of times-to-failure, most units suspended (still working when the test ended = right-censored). Data: Meeker & Escobar's classic shock-absorber study — 38 vehicles, distance (km) to failure, 23 of 38 suspended.
The questions are distributional, not about a covariate effect:
- Shape $k$ — the failure mode: $k<1$ infant mortality, $k=1$ random, $k>1$ wear-out (the Weibull "bathtub").
- Characteristic life $\eta=\lambda^{-1/k}$ (the 63.2% quantile), $B_{10}$ (distance by which 10% fail), MTTF $=\eta\,\Gamma(1+1/k)$, and reliability $R(t)=S(t)$ at a mission distance.
A note on priors (numerical scaling)¶
With distances in the tens of thousands of km, the Weibull intercept $\beta_0=-k\log\eta\approx-30$ — far from 0 — so a tight $N(0,10^2)$ prior would distort the fit. We use a flat intercept prior (prior_sd=100) and a weakly-informative shape prior (logk_sd=5) so the data drive the estimate; the Bayes fit then matches the MLE. (Equivalently one could rescale time to 1000s of km.)
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from scipy.special import gamma as Gamma
from survival_mcmc import weibull_mle, weibull_rwm, km_estimator
d = pd.read_csv('shock_absorber.csv'); t = d['km'].to_numpy(float); delta = d['failed'].to_numpy(float)
X = np.ones((len(d), 1)) # single sample, intercept only
print('n=%d failures=%d suspended=%d (%.0f%% censored)' % (len(d), int(delta.sum()), int((1-delta).sum()), 100*(1-delta.mean())))
mle, se, _ = weibull_mle(X, t, delta); k_mle = np.exp(mle[1]); eta_mle = np.exp(mle[0])**(-1/k_mle)
o = weibull_rwm(X, t, delta, R=60000, burn=10000, seed=1, prior_sd=100.0, logk_sd=5.0, s=0.6)
dr = o['draws']; k = np.exp(dr[:,1]); lam = np.exp(dr[:,0]); eta = lam**(-1/k)
b10 = (-np.log(0.9)/lam)**(1/k); mttf = eta*Gamma(1+1/k); R10k = np.exp(-lam*10000.0**k)
def ci(a): return f'{a.mean():.0f} [{np.percentile(a,2.5):.0f}, {np.percentile(a,97.5):.0f}]'
print('\nMLE: shape k=%.2f characteristic life eta=%.0f km (acc %.2f)' % (k_mle, eta_mle, o['accept']))
print('Bayes posterior:')
print(' shape k = %.2f [%.2f, %.2f] (k>1 => WEAR-OUT)' % (k.mean(), np.percentile(k,2.5), np.percentile(k,97.5)))
print(' characteristic life = %s km (63.2%% failed)' % ci(eta))
print(' B10 life = %s km (10%% failed)' % ci(b10))
print(' MTTF = %s km' % ci(mttf))
print(' reliability @ 10000 km R(10k) = %.3f [%.3f, %.3f]' % (R10k.mean(), np.percentile(R10k,2.5), np.percentile(R10k,97.5)))
n=38 failures=15 suspended=23 (61% censored)
MLE: shape k=3.27 characteristic life eta=25012 km (acc 0.48) Bayes posterior: shape k = 3.32 [2.22, 4.97] (k>1 => WEAR-OUT) characteristic life = 25552 [21832, 31012] km (63.2% failed) B10 life = 12695 [9171, 16138] km (10% failed) MTTF = 22929 [19584, 27617] km reliability @ 10000 km R(10k) = 0.948 [0.880, 0.989]
fig, ax = plt.subplots(1, 2, figsize=(12, 4.6))
# (1) Weibull probability plot: ln(t) vs ln(-ln R); straight line <=> Weibull, slope = k
tk, Sk = km_estimator(t, delta)
ev = (Sk > 0) & (Sk < 1) & (tk > 0)
ax[0].plot(np.log(tk[ev]), np.log(-np.log(Sk[ev])), 'o', color='steelblue', label='failures (KM)')
gx = np.linspace(np.log(t.min()), np.log(t.max()), 50)
ax[0].plot(gx, mle[0] + k_mle*gx, 'r--', lw=1.3, label=f'Weibull fit (k={k_mle:.2f})')
ax[0].set_xlabel('ln(distance)'); ax[0].set_ylabel('ln(-ln R)'); ax[0].set_title('Weibull probability plot'); ax[0].legend(fontsize=8)
# (2) reliability function with 95% band + B10 / characteristic life
grid = np.linspace(1, 40000, 300)
Rg = np.exp(-(lam[:,None]) * grid[None,:]**(k[:,None])) # R(t) per draw
Rm = Rg.mean(0); Rlo = np.percentile(Rg,2.5,axis=0); Rhi = np.percentile(Rg,97.5,axis=0)
ax[1].plot(grid, Rm, color='seagreen', lw=2, label='R(t) posterior mean')
ax[1].fill_between(grid, Rlo, Rhi, color='seagreen', alpha=.2, label='95% band')
ax[1].axhline(0.9, color='grey', ls=':', lw=.8); ax[1].axvline(b10.mean(), color='goldenrod', ls='--', lw=1, label=f'B10 ~ {b10.mean():.0f} km')
ax[1].axhline(np.exp(-1), color='grey', ls=':', lw=.8); ax[1].axvline(eta.mean(), color='firebrick', ls='--', lw=1, label=f'char. life ~ {eta.mean():.0f} km')
ax[1].set_xlabel('distance (km)'); ax[1].set_ylabel('reliability R(t)'); ax[1].set_ylim(0,1.02)
ax[1].set_title('Reliability function'); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig('reliability.png', dpi=120, bbox_inches='tight'); plt.show()
Results¶
- Shape $k\approx3.3$ (CI ~2.2–5.0), well above 1 — a clear wear-out failure mode (hazard rises with distance), exactly what you'd expect for a mechanical part fatiguing over use.
- Characteristic life $\eta\approx25{,}500$ km (63% of units failed by here), $B_{10}\approx12{,}700$ km (the distance by which 10% fail — the usual warranty-style metric), MTTF $\approx22{,}900$ km. Reliability at a 10,000 km mission is high (~0.95).
- The Weibull probability plot (failures fall close to the fitted straight line) confirms the Weibull is an adequate model; the slope is the shape $k$.
- Censoring is essential here: with 61% suspended, ignoring the still-working units would massively understate the life — the censored likelihood uses each suspension as "lasted at least this far."
Takeaway¶
Methodologically this is the same single-sample Weibull as a survival fit with no covariates; the reliability value-add is the interpretation — shape as failure-mode, and the engineering life metrics ($\eta$, $B_{10}$, MTTF, mission reliability) read straight off the posterior. Cross-checks: PyMC and R (survreg) follow.