Calibration and Uncertainty

Prediction intervals for regression

A regression model that answers with one number hides everything you need for a decision — here is how to make it answer with a range, and how to check the range keeps its promise.

On this page 6
  1. Two different questions
  2. How it works
  3. Two numbers, always together
  4. A real example you have seen
  5. Remember this
  6. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

A prediction interval is a range the model puts around its answer, sized so the real value lands inside it a promised share of the time.

Your food delivery app says "arriving 7:35". Useful, and quietly dishonest. On a calm Tuesday the rider will be there between 7:30 and 7:40. During a downpour in peak traffic it could be 8:15. The app hides the difference behind one confident number.

An app showing "7:30 to 7:40" on a calm day, and "7:35 to 8:20" in the rain, tells you far more. The width itself is the message.

Regression models have the same habit. They return one number for house price, delivery time or blood sugar, with no hint of how firm that number is.

Two different questions

Be careful here, because these get mixed up constantly.

  • "Where is the average delivery time for orders like this?" — a narrow range, and it shrinks as you collect more data.
  • "Where will this next order actually arrive?" — a much wider range, and it never shrinks below the real randomness of traffic.

The second one is a prediction interval. It is the one you need for decisions, and it is the one this lesson builds. Collecting a million more orders will not make Mumbai traffic predictable.

How it works

You need two ends, not one middle. The usual route trains the model to answer two questions instead of one.

                            ┌→ "5% of orders arrive before ..."  → lower end
   order details  →  model  ┤
                            └→ "95% of orders arrive before ..."  → upper end

   the promise: the true time lands between the ends 90% of the time

Then you check the promise on data the model has never seen. Count how often the truth landed inside. Promised 90% and delivered 84%? The interval is a liar and needs widening. That is exactly what conformal prediction does, with the previous lesson's track-record trick.

Two numbers, always together

An interval is graded on two things at once, and reporting one without the other is meaningless.

  • Coverage — how often the truth lands inside. Promised 90%, delivered 90%?
  • Width — how wide the range is. Narrow is useful, wide is vague.

"Between 0 and 10 hours" has perfect coverage and zero worth. Anyone can keep a promise by promising nothing.

A real example you have seen

Weather apps do this well. Rainfall forecasts arrive as "15 to 40 mm", and cyclone tracks are drawn as a widening cone rather than a line. The cone gets fatter further into the future because honest uncertainty grows with distance. Delivery apps showing "25–35 min" are doing the same thing, badly disguised as a range.

Remember this

  • A prediction interval covers the next actual value, not the average.
  • Grade it on coverage and width together — either alone can be gamed.
  • Check coverage on held-out data, then widen the interval until the promise is true.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install scikit-learn

Verified with scikit-learn 1.7.2 and numpy 1.26.4 on CPU. Runs in a few seconds.

Data where the spread grows

We build a problem with heteroscedastic noise — noise whose size changes across the input range. That is the realistic case, and the case where a fixed-width interval fails.

intervals.py
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import train_test_split

rng = np.random.default_rng(0)
n = 3000
x = rng.uniform(0, 10, n)
noise = rng.normal(0, 0.5 + 0.6 * x)          # spread GROWS with x
y = 2.0 * x + noise
X = x.reshape(-1, 1)

Xtr, Xrest, ytr, yrest = train_test_split(X, y, test_size=0.5, random_state=0)
Xcal, Xte, ycal, yte = train_test_split(Xrest, yrest, test_size=0.5, random_state=0)

lo = GradientBoostingRegressor(loss="quantile", alpha=0.05, random_state=0).fit(Xtr, ytr)
hi = GradientBoostingRegressor(loss="quantile", alpha=0.95, random_state=0).fit(Xtr, ytr)

def report(name, low, high, ytrue):
    inside = ((ytrue >= low) & (ytrue <= high)).mean()
    print(f"{name:22s} coverage {inside:6.1%}   mean width {np.mean(high - low):5.2f}")

report("raw quantile model", lo.predict(Xte), hi.predict(Xte), yte)

# conformalised quantile regression: one number, learned on held-out data
err = np.maximum(lo.predict(Xcal) - ycal, ycal - hi.predict(Xcal))
m = len(err)
q = np.quantile(err, np.ceil(0.9 * (m + 1)) / m, method="higher")
print(f"conformal adjustment   {q:+.2f}")
report("after CQR", lo.predict(Xte) - q, hi.predict(Xte) + q, yte)
Output
raw quantile model     coverage  88.4%   mean width 11.20
conformal adjustment   +0.28
after CQR              coverage  91.1%   mean width 11.76

Two models, two target quantiles, one adjustment. The raw quantile model promised 90% and delivered 88.4% — close, and still a broken promise. Widening both ends by 0.28 restores it, at a 5% cost in width.

Why a fixed-width interval is worse than it looks

The overall coverage number hides where the failures happen. Break it down by region, and compare against the common alternative: a mean model plus one constant band.

where_it_fails.py
mean = GradientBoostingRegressor(random_state=0).fit(Xtr, ytr)
r = np.abs(ycal - mean.predict(Xcal))
w = np.quantile(r, np.ceil(0.9 * (m + 1)) / m, method="higher")
flat_lo, flat_hi = mean.predict(Xte) - w, mean.predict(Xte) + w
cqr_lo, cqr_hi = lo.predict(Xte) - q, hi.predict(Xte) + q

print(" x range     CQR cover  CQR width    flat cover  flat width")
for a, b in [(0, 2.5), (2.5, 5), (5, 7.5), (7.5, 10)]:
    k = (Xte[:, 0] >= a) & (Xte[:, 0] < b)
    print(f"{a:4.1f}-{b:4.1f} {((yte[k] >= cqr_lo[k]) & (yte[k] <= cqr_hi[k])).mean():10.1%} "
          f"{np.mean(cqr_hi[k] - cqr_lo[k]):10.2f} "
          f"{((yte[k] >= flat_lo[k]) & (yte[k] <= flat_hi[k])).mean():13.1%} "
          f"{np.mean(flat_hi[k] - flat_lo[k]):11.2f}")
print(f"overall    {((yte >= cqr_lo) & (yte <= cqr_hi)).mean():10.1%} "
      f"{np.mean(cqr_hi - cqr_lo):10.2f} "
      f"{((yte >= flat_lo) & (yte <= flat_hi)).mean():13.1%} "
      f"{np.mean(flat_hi - flat_lo):11.2f}")
Output
 x range     CQR cover  CQR width    flat cover  flat width
 0.0- 2.5      92.4%       4.46        100.0%       13.43
 2.5- 5.0      94.4%       9.79         96.4%       13.43
 5.0- 7.5      88.0%      14.00         86.9%       13.43
 7.5-10.0      89.2%      18.87         70.4%       13.43
overall         91.1%      11.76         88.5%       13.43

This table is the whole lesson.

The flat interval is a fixed 13.43 wide everywhere. In the easy region it covers 100% of cases — a range so wide it says nothing. In the hard region it covers 70.4% while still claiming 90%. It is wrong in both directions at once, and the overall figure of 88.5% conceals both failures.

CQR is narrow where the world is calm (4.46) and wide where it is turbulent (18.87), and its coverage stays near target in every region.

The walkthrough

Two models make one interval. loss="quantile" with alpha=0.05 trains the model to answer "5% of outcomes fall below what value?" — the pinball loss does the work. alpha=0.95 gives the other end. Neither model estimates the mean.

The three-way split is not optional. Training data fits the quantile models. Calibration data — untouched by fitting — sizes the adjustment. Test data audits the result. Reusing training rows for calibration produces an adjustment near zero and a promise that fails in production.

The CQR score is one line. max(lo - y, y - hi) is negative when the truth sits comfortably inside and positive by however much it fell outside. Its 90th percentile is the amount of widening the promise needs. Romano, Patterson and Candès introduced this in 2019.

Region coverage is not guaranteed. CQR promises 90% on average. The regional figures above (88.0% to 94.4%) wobble around the target rather than hitting it. That is expected — the guarantee is marginal, exactly as in conformal prediction. Adaptivity comes from the quantile models being good, not from the conformal step.

The flat method's 88.5% is also within noise of its guarantee. With 750 calibration points and 750 test points, realised coverage has a standard deviation near 1.1 points. The flat method is not broken overall — it is broken conditionally, which the single number cannot show.

Common mistakes

Reporting coverage without width. A method that widens everything to ±50 hits any coverage target and helps nobody. Always print both, and compare methods at equal coverage.

Using a confidence interval for the mean as a prediction interval. Standard regression output gives you uncertainty about the fitted line. That is far narrower than the range of individual outcomes, and using it as one will overpromise badly.

Assuming symmetric, constant-width bands. The ± 1.96 σ habit assumes normal noise of constant size. Delivery times, incomes and rainfall are skewed and heteroscedastic. The table above shows what that assumption costs.

Checking coverage only in aggregate. Slice by input region, by time period, by customer segment. Aggregate coverage can be perfect while the segment you actually care about sits at 70%.

Fitting the two quantile models and never checking they are ordered. Independently fitted quantiles can cross, producing a lower bound above the upper bound for some inputs. Assert hi >= lo before shipping.

Try it yourself

Set the target to 99% (change 0.9 to 0.99, alpha to 0.005 and 0.995) and rerun both scripts. Predict first: which grows more, the conformal adjustment or the base width? Then remove the heteroscedasticity — noise = rng.normal(0, 3, n) — and watch the flat interval become perfectly reasonable, because its one assumption is finally true.

What to learn next

Researcher — Mathematics and papers.

What is being estimated

For a new input $x$, a prediction interval at level $1-\alpha$ is a random set $C(x) = [\hat{l}(x), \hat{u}(x)]$ with

$$ P\big(Y \in C(X)\big) \ge 1 - \alpha $$

Where:

  • $Y$ — the outcome of a fresh draw, not its conditional mean $\mathbb{E}[Y \mid X = x]$.
  • The probability is over both the new pair and the training/calibration data — a marginal guarantee.

The distinction from a confidence interval for $\mathbb{E}[Y \mid X = x]$ is the irreducible term. Under a Gaussian linear model the two differ by exactly that term:

$$ \hat{y}0 \pm t{n-p,\,1-\alpha/2}\; \hat{\sigma} \sqrt{1 + x_0^\top (X^\top X)^{-1} x_0} $$

The $1$ under the root is the new observation's own noise; drop it and you have the confidence interval for the mean. As $n \to \infty$ the leverage term vanishes and the confidence interval collapses to a point, while the prediction interval converges to $\pm z_{1-\alpha/2}\hat{\sigma}$ and stops.

Quantile regression as the workhorse

The pinball (check) loss at level $\tau$,

$$ L_\tau(y, q) = \max\big(\tau (y - q),\; (\tau - 1)(y - q)\big) $$

is minimised in expectation at the conditional $\tau$-quantile of $Y \mid X$. Fitting at $\tau = \alpha/2$ and $\tau = 1 - \alpha/2$ gives a conditionally adaptive interval with no distributional assumption — the source of the varying widths in the developer table. Koenker and Bassett (1978) established the linear case; gradient boosting and neural networks optimise the same loss directly.

Two known failure modes: independently fitted quantiles can cross, repaired by monotone rearrangement (Chernozhukov, Fernández-Val and Galichon, 2010); and the fitted quantiles are only as calibrated as the model, with no finite-sample coverage guarantee at all.

Conformalising the interval

Split conformal on absolute residuals (Lei et al., 2018) takes $s_i = |Y_i - \hat{\mu}(X_i)|$ on a calibration set and returns $\hat{\mu}(x) \pm \hat{q}_{1-\alpha}$. Finite-sample marginal validity under exchangeability, constant width, no adaptivity — the "flat" method above.

CQR (Romano, Patterson and Candès, 2019) conformalises the quantile pair with

$$ E_i = \max\big(\hat{q}_{\alpha/2}(X_i) - Y_i,\; Y_i - \hat{q}_{1-\alpha/2}(X_i)\big) $$

and returns $[\hat{q}{\alpha/2}(x) - \hat{Q}, \; \hat{q}{1-\alpha/2}(x) + \hat{Q}]$ with $\hat{Q}$ the $\lceil (m+1)(1-\alpha) \rceil / m$ empirical quantile of ${E_i}$. This inherits validity from conformal theory and adaptivity from the quantile models — the combination is why it is the default recommendation. Locally weighted variants scale residuals by a fitted spread model (Lei and Wasserman, 2014) to reach adaptivity from a mean model instead.

Jackknife+ and CV+ (Barber, Candès, Ramdas and Tibshirani, 2021) avoid spending data on a calibration split by recycling leave-one-out or cross-validation residuals, at the cost of a $1 - 2\alpha$ worst-case guarantee (empirically much closer to $1-\alpha$).

Grading intervals properly

Coverage and width should be combined by a proper scoring rule, not eyeballed. The interval score (Gneiting and Raftery, 2007) does exactly this:

$$ S_\alpha(l, u; y) = (u - l) + \tfrac{2}{\alpha}(l - y)\mathbb{1}{y < l} + \tfrac{2}{\alpha}(y - u)\mathbb{1}{y > u} $$

Width is paid for directly; misses are charged in proportion to how far outside they fell. Averaged pinball loss across a grid of $\tau$ gives the CRPS, the corresponding rule for a full predictive distribution, and the standard metric in probabilistic forecasting.

Distributional and Bayesian routes

  • Heteroscedastic likelihood: predict $\mu(x)$ and $\log \sigma(x)$ jointly and minimise the Gaussian negative log-likelihood. Cheap and effective; sensitive to early-training variance collapse.
  • NGBoost (Duan et al., 2020) fits full parametric predictive distributions by natural-gradient boosting.
  • Deep ensembles (Lakshminarayanan, Pritzel and Blundell, 2017) and MC dropout (Gal and Ghahramani, 2016) capture model uncertainty rather than noise; both are known to be miscalibrated out of the box and both are improved by a conformal wrapper.
  • The split matters conceptually: aleatoric uncertainty is irreducible noise, and no amount of data narrows it; epistemic uncertainty is ignorance about the model, and data does narrow it (Kendall and Gal, 2017). A prediction interval must contain both. Ensemble spread alone contains only the second, which is the standard way ensemble-based intervals end up far too narrow.

Under shift

Every guarantee here rests on exchangeability between calibration and deployment data. Under covariate shift with known likelihood ratios, weighted conformal restores validity (Tibshirani et al., 2019). Under drift, adaptive conformal inference (Gibbs and Candès, 2021) adjusts $\alpha$ online from realised coverage. In practice: monitor realised coverage as a live metric, sliced, and recalibrate on recent data. Recalibration needs no refit — it is one quantile of one array.

What to learn next

What to learn next

These follow on from what you just read.

  • Imbalanced, Multi-class and Multi-label

    SMOTE and its variants

    SMOTE invents new examples of a rare class by blending pairs of real ones, so the model finally gets enough practice on the cases that matter.

  • Imbalanced, Multi-class and Multi-label

    Undersampling strategies

    Instead of inventing rare examples, undersampling throws away common ones — randomly, or with care about which ones carry information.

  • Imbalanced, Multi-class and Multi-label

    Class weights

    One argument makes mistakes on the rare class cost more during training — often matching SMOTE without inventing a single row of data.