Production-grade research benchmarks for uncertainty quantification, post-hoc calibration, and distribution-free conformal prediction with PyTorch.
Modern neural networks are powerful function approximators, yet they routinely produce overconfident predictions that belie their true epistemic state. This repository implements a cohesive research framework for quantifying, calibrating, and bounding predictive uncertainty in deep learning. The toolkit spans three complementary paradigms: Bayesian inference (mean-field variational Bayes and MC Dropout), frequentist ensembling (deep ensembles with heteroscedastic variance heads), and distribution-free prediction (split conformal methods and RAPS). Each module is backed by synthetic data-generating processes with known ground-truth noise structures, enabling controlled evaluation of uncertainty estimates against oracle baselines. The codebase is designed for reproducible experimentation—seeded randomness, YAML-driven configuration, and automated benchmark runners produce JSON metrics and Markdown reports in a single command. Whether you are a PhD student studying the gap between Bayesian theory and scalable approximation, or a hiring manager evaluating a candidate's understanding of modern uncertainty quantification, this repository provides a self-contained, rigorously documented testbed.
- Research Background & Motivation
- Mathematical Foundations
- Architecture Diagram
- Repository Structure
- Code Walkthrough
- Benchmark Results
- Reproduction Commands
- References
- Future Work
- License
The default mode of modern deep learning—maximum likelihood estimation with stochastic gradient descent—produces point estimates of network weights. These point estimates yield single-valued predictions that carry no intrinsic measure of confidence. As neural networks have been deployed in increasingly consequential domains—clinical decision support, autonomous navigation, financial risk modelling—the absence of well-calibrated uncertainty has become a critical failure mode. A classifier that reports 95% confidence on an out-of-distribution input is not merely wrong; it is dangerously wrong, because downstream systems have no signal to trigger fallback behaviour.
The Bayesian treatment of neural networks offers an elegant theoretical remedy. Rather than seeking a single weight vector
However, exact Bayesian inference over the millions of parameters in a modern network remains computationally intractable. The key insight of Gal & Ghahramani (2016) in "Dropout as a Bayesian Approximation: Representing Model Uncertainty in Deep Learning" (arXiv:1506.02142) was that Monte Carlo Dropout—simply keeping dropout enabled at test time and averaging over multiple stochastic forward passes—can be interpreted as an approximate variational inference procedure. This provided a practical, zero-overhead method for extracting uncertainty from any dropout-regularised network, with no architectural modification required.
An alternative frequentist approach was proposed by Lakshminarayanan et al. (2017) in "Simple and Scalable Predictive Uncertainty Estimation using Deep Ensembles" (NeurIPS 2017; arXiv:1612.01474). Deep ensembles train
Even with well-calibrated uncertainty, the raw predicted probabilities of a neural classifier can be systematically miscalibrated—a phenomenon documented at scale by Guo et al. (2017) in "On Calibration of Modern Neural Networks" (ICML 2017; arXiv:1706.04599). Modern deep networks tend to be overconfident: the predicted probability often substantially exceeds the true likelihood of correctness. Temperature scaling—dividing logits by a single learned scalar—offers a surprisingly effective post-hoc fix. This repository implements temperature scaling alongside vector scaling and Dirichlet calibration (extending the work of Kull et al., 2019) to provide a comprehensive calibration toolkit.
Finally, conformal prediction provides a distribution-free framework for constructing prediction sets with finite-sample coverage guarantees, requiring only the assumption of exchangeability between calibration and test data. The split conformal method, formalised in its modern form by Tibshirani et al. (2019) and rooted in the transductive framework of Vovk, Gammerman, and Shafer (2005) (Algorithmic Learning in a Random World, Springer), computes nonconformity scores on a held-out calibration set and selects a quantile threshold that provably controls the marginal coverage rate. More recently, Angelopoulos et al. (2021) introduced Regularised Adaptive Prediction Sets (RAPS) in "Uncertainty Sets for Image Classifiers using Conformal Prediction" (arXiv:2107.07511), which adds a regularisation penalty that encourages smaller prediction sets without sacrificing coverage. This repository implements both vanilla split conformal and RAPS for classification, as well as conformally calibrated regression bands.
This codebase integrates all three paradigms—Bayesian, ensemble, and conformal—into a unified experimental framework, enabling direct, controlled comparisons on synthetic data with known ground-truth noise structures.
Given a dataset
We seek:
Expanding the KL and noting that
Derivation. Starting from the marginal log-likelihood:
Introduce any distribution
Expanding the right-hand side:
The mean-field assumption factorises
where each weight
Mini-batch ELBO with KL weighting. For SGD-based training with mini-batches of size
where
Local reparameterisation trick. Each weight sample is computed as:
This reparameterisation shifts the stochasticity from the distribution to the noise source, enabling low-variance gradient estimation through the deterministic parameters
For each scalar weight with variational posterior
Derivation. The KL between two Gaussians
Substituting Gaussian densities:
Wait—let us be precise. We have
Taking the expectation under
Expanding
Setting
The total KL for a BayesianLinear layer sums this quantity over all weight and bias parameters:
where
Gal & Ghahramani (2016) showed that a neural network with dropout applied before every weight layer, trained with standard cross-entropy or MSE loss, is mathematically equivalent to a specific variational approximation to a deep Gaussian process. At test time, performing
For a regression network
Predictive mean:
where
Predictive variance (total uncertainty):
where
The epistemic component measures how much the model's predictions vary across different dropout realisations. It tends to be large in regions of input space far from the training data—exactly the regime where we want the model to express ignorance. As
The aleatoric component captures irreducible noise in the data-generating process and is constant across forward passes for a homoscedastic model. In this codebase, the aleatoric standard deviation is estimated from training-set residuals when not explicitly provided.
The deep ensemble of Lakshminarayanan et al. (2017) trains
Per-member loss (Gaussian NLL):
Each ensemble member is trained by minimising the heteroscedastic negative log-likelihood:
where
Ensemble aggregation:
Total predictive variance:
This can be equivalently written in the compact form:
Decomposition interpretation:
-
Aleatoric variance
$\sigma_{\text{aleat}}^2(x) = \frac{1}{M}\sum_m \sigma_m^2(x)$ : the average predicted noise across ensemble members. This represents each member's estimate of the irreducible data noise at input$x$ . It is high in heteroscedastic regions regardless of data quantity. -
Epistemic variance
$\sigma_{\text{epist}}^2(x) = \mathrm{Var}_m[\mu_m(x)]$ : the variance of the means across ensemble members. When models disagree about the prediction, epistemic uncertainty is high. It decreases with more data and vanishes as$N \to \infty$ .
Given a pre-trained classifier that produces logits
where
Optimisation objective. The optimal temperature is found by minimising the negative log-likelihood on a held-out validation set:
For the binary case with a single logit
and the NLL reduces to the binary cross-entropy:
This is a one-dimensional convex optimisation problem, solved efficiently with L-BFGS. The implementation parameterises
Calibration metrics. The quality of calibration is evaluated using:
-
Expected Calibration Error (ECE): Partition predictions into
$B$ equal-width bins by predicted confidence. For each bin$b$ with$n_b$ samples:
where
-
Maximum Calibration Error (MCE):
$\mathrm{MCE} = \max_b |\mathrm{acc}(b) - \mathrm{conf}(b)|$ -
Brier Score:
$\mathrm{BS} = \frac{1}{N}\sum_{i=1}^{N}(p_i - y_i)^2$
A perfectly calibrated model has
Split conformal prediction provides distribution-free prediction intervals with finite-sample coverage guarantees. The only assumption is that the calibration data and the test data are exchangeable (a strictly weaker assumption than i.i.d.).
Setup. Given:
- A fitted model
$\hat{f}$ (treated as a black box) - A held-out calibration set $\mathcal{D}{\text{cal}} = {(x_i, y_i)}{i=1}^n$ not used during training
- A desired miscoverage rate
$\alpha \in (0, 1)$
Step 1: Compute nonconformity scores on the calibration set. For regression with absolute residuals:
Step 2: Compute the conformal quantile with the finite-sample correction:
The ceiling in the quantile level
Step 3: Form prediction intervals for a new test point
Coverage guarantee (Theorem). Under exchangeability of
Proof sketch. By exchangeability, the rank of
The last inequality follows from
Normalised conformal bands. When a per-sample uncertainty estimate
This produces adaptive prediction bands:
These bands are narrower in low-uncertainty regions and wider in high-uncertainty regions, while maintaining the same marginal coverage guarantee.
Classification. For classification with softmax outputs
A class
RAPS (Angelopoulos et al., 2021) modifies the conformal score function to produce smaller prediction sets by penalising the inclusion of low-probability classes.
Score function. Let
where:
-
$o(x,y) = |{j : \pi(j) \leq \pi(y)}|$ is the rank of the true label in the sorted order -
$\lambda \geq 0$ is the regularisation strength penalising large sets -
$k_{\text{reg}} \geq 1$ is a size threshold below which no penalty is applied
Prediction sets. At test time, classes are included in descending probability order until the cumulative score (with penalty) exceeds
where
Effect of hyperparameters:
-
$\lambda = 0$ : reduces to standard Adaptive Prediction Sets (APS), which in turn reduces to vanilla conformal when including all classes above the threshold - Larger
$\lambda$ : stronger penalty on large sets, producing more aggressive filtering at the cost of potentially lower conditional coverage - Larger
$k_{\text{reg}}$ : allows more classes before the penalty kicks in
The finite-sample coverage guarantee $\mathbb{P}(Y_{n+1} \in \hat{C}{\text{RAPS}}(X{n+1})) \geq 1 - \alpha$ holds regardless of the choice of
┌─────────────────────────────────────────────────────────────────────────────────┐
│ PROBABILISTIC ML TOOLKIT PIPELINE │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ DATA GENERATING PROCESSES │ │
│ │ ┌──────────────────────┐ ┌───────────────────────────────┐ │ │
│ │ │ regression_dgp.py │ │ classification_dgp.py │ │ │
│ │ │ │ │ │ │ │
│ │ │ X ~ N(0, I) │ │ X ~ N(0, I) │ │ │
│ │ │ mu(x) = f(x) │ │ logits = sep * (w·x) │ │ │
│ │ │ sigma(x) = g(x) │ │ p* = sigmoid(logits) │ │ │
│ │ │ y = mu + sigma * ε │ │ y ~ Bernoulli(p*) │ │ │
│ │ │ Optional OOD shift │ │ Optional label noise │ │ │
│ │ └──────────┬───────────┘ └──────────────┬────────────────┘ │ │
│ └─────────────┼───────────────────────────────┼────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ TRAIN / CAL / TEST SPLIT │ │
│ │ sklearn.model_selection.train_test_split │ │
│ └────────┬──────────────┬──────────────────────┬──────────────────┘ │
│ │ │ │ │
│ X_train/y_train X_cal/y_cal X_test/y_test │
│ │ │ │ │
│ ▼ │ │ │
│ ┌────────────────────┐ │ │ │
│ │ MODEL TRAINING │ │ │ │
│ │ │ │ │ │
│ │ ┌──────────────┐ │ │ │ │
│ │ │ RegressionMLP│ │ │ │ │
│ │ │ Classif.MLP │ │ │ │ │
│ │ │ BayesianMLP │ │ │ │ │
│ │ │ MCDropoutMLP │ │ │ │ │
│ │ │ VarianceHead │ │ │ │ │
│ │ └──────┬───────┘ │ │ │ │
│ └─────────┼──────────┘ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ UNCERTAINTY / CALIBRATION / CONFORMAL │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────┐ │ │
│ │ │ MC DROPOUT │ │ DEEP ENSEMBLES │ │ BAYESIAN MLP │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ S forward │ │ M members × │ │ ELBO training │ │ │
│ │ │ passes w/ │ │ VarianceHead │ │ KL(q||p) + │ │ │
│ │ │ dropout ON │ │ → (μ_m, σ²_m) │ │ E_q[log p(y|x,w)] │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ mean ± std │ │ mean ± var │ │ mean ± std │ │ │
│ │ └───────┬─────────┘ └───────┬─────────┘ └─────────┬─────────┘ │ │
│ │ │ │ │ │ │
│ │ └───────────┬───────┘ │ │ │
│ │ ▼ │ │ │
│ │ ┌─────────────────────────────┐ │ │ │
│ │ │ CONFORMAL CALIBRATION │ │ │ │
│ │ │ │ │ │ │
│ │ │ SplitConformalRegressor │ │ │ │
│ │ │ SplitConformalClassifier │ │ │ │
│ │ │ AdaptiveConformal (RAPS) │ │ │ │
│ │ │ ConformalBand (adaptive) │ │ │ │
│ │ └─────────────┬───────────────┘ │ │ │
│ │ │ │ │ │
│ │ ▼ │ │ │
│ │ ┌─────────────────────────────────────┐ │ │ │
│ │ │ POST-HOC CALIBRATION │ │ │ │
│ │ │ │ │ │ │
│ │ │ Temperature Scaling (scalar T) │ │ │ │
│ │ │ Vector Scaling (per-class W, b) │ │ │ │
│ │ │ Histogram Binning (non-parametric) │ │ │ │
│ │ │ Dirichlet Calibration (log-space) │ │ │ │
│ │ └─────────────┬───────────────────────┘ │ │ │
│ └────────────────┼────────────────────────────────────┼────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ EVALUATION & REPORTING │ │
│ │ │ │
│ │ Metrics: RMSE, NLL, PICP, MPIW, ECE, MCE, Brier, Coverage │ │
│ │ Output: results/<timestamp>/metrics.json │ │
│ │ results/<timestamp>/summary.md │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
04-probabilistic-bayesian-deep-learning/
├── pyproject.toml # Package metadata & dependencies
├── README.md # This file
└── src/
└── prob_ml/
├── __init__.py # Package version
├── data/
│ ├── base.py # RegressionDataset, ClassificationDataset
│ ├── regression_dgp.py # Heteroscedastic regression DGP
│ └── classification_dgp.py # Binary classification DGP
├── models/
│ ├── mlp.py # RegressionMLP, ClassificationMLP,
│ │ # BayesianLinear, BayesianMLP,
│ │ # MCDropoutMLP
│ └── trainer.py # TrainConfig, train_regressor,
│ # train_classifier
├── uncertainty/
│ ├── mc_dropout.py # MC Dropout inference
│ ├── deep_ensemble.py # Simple ensemble (residual-based)
│ ├── deep_ensembles.py # Heteroscedastic ensemble (VarianceHead)
│ ├── conformal_band.py # Normalised conformal bands
│ └── metrics.py # RMSE, Gaussian NLL, PICP, MPIW
├── calibration/
│ ├── temperature.py # Temperature, vector, histogram scaling
│ ├── dirichlet.py # Dirichlet calibration (Kull et al.)
│ └── metrics.py # ECE, MCE, Brier, reliability bins
├── conformal/
│ ├── split.py # SplitConformalRegressor,
│ │ # SplitConformalClassifier,
│ │ # AdaptiveConformal (RAPS)
│ └── metrics.py # Coverage, interval width
├── evaluation/
│ ├── runner.py # Benchmark orchestration
│ └── report.py # Markdown report generation
└── utils/
└── seed.py # Seeding utilities & config hashing
The toolkit provides two synthetic DGPs with known ground-truth structures, enabling controlled evaluation of uncertainty estimates against oracle baselines.
Heteroscedastic Regression. The regression DGP generates data with input-dependent noise:
def _f(x: np.ndarray) -> np.ndarray:
"""Nonlinear mean function with known structure."""
return (
0.8 * x[:, 0]
+ 0.5 * np.sin(2 * np.pi * x[:, 1])
+ 0.3 * x[:, 2] * x[:, 3]
)
def _sigma(x: np.ndarray, base: float, strength: float) -> np.ndarray:
"""Heteroscedastic aleatoric noise: higher variance in feature tails."""
return base + strength * np.abs(x[:, 0]) + 0.1 * strength * np.abs(x[:, 1])The mean function
The DGP optionally generates out-of-distribution (OOD) samples by shifting the last ood_fraction of data points to
Binary Classification. The classification DGP generates data with known Bayes-optimal probabilities:
w = rng.standard_normal(d).astype(np.float64)
w = w / (np.linalg.norm(w) + 1e-8)
logits = cfg.class_separation * np.dot(X, w)
p_star = _sigmoid(logits)
y = rng.binomial(1, p_star).astype(np.int64)The true class probability class_separation parameter and
Both DGPs return structured dataset objects that carry ground-truth metadata:
@dataclass
class RegressionDataset:
X: np.ndarray
y: np.ndarray
metadata: dict[str, Any] = field(default_factory=dict)
ground_truth: dict[str, Any] = field(default_factory=dict)The ground_truth dictionary stores oracle values (mu, sigma, p_star, logits) for downstream evaluation.
All models share a common _MLPBackbone pattern:
Deterministic MLPs. RegressionMLP and ClassificationMLP are standard point-prediction networks:
class _MLPBackbone(nn.Module):
def __init__(
self,
in_dim: int,
hidden_dim: int = 64,
n_hidden: int = 2,
dropout: float = 0.1,
) -> None:
super().__init__()
layers: list[nn.Module] = []
dim = in_dim
for _ in range(n_hidden):
layers.extend(
[
nn.Linear(dim, hidden_dim),
nn.ReLU(),
nn.Dropout(p=dropout),
]
)
dim = hidden_dim
self.backbone = nn.Sequential(*layers)
self.out_dim = dimThe backbone constructs a chain of Linear → ReLU → Dropout blocks. RegressionMLP appends a single-output linear head; ClassificationMLP does the same but its output represents a logit for binary cross-entropy training.
BayesianLinear. The core variational layer replaces fixed weights with Gaussian distributions:
def forward(self, x: torch.Tensor) -> torch.Tensor:
weight_sigma = self._softplus(self.weight_rho)
bias_sigma = self._softplus(self.bias_rho)
weight_eps = torch.randn_like(self.weight_mu)
bias_eps = torch.randn_like(self.bias_mu)
weight = self.weight_mu + weight_sigma * weight_eps
bias = self.bias_mu + bias_sigma * bias_eps
return F.linear(x, weight, bias)Each forward pass samples a fresh set of weights via the reparameterisation trick: rho parameters are initialised to
The kl_divergence() method computes the analytic KL in closed form:
def kl_divergence(self) -> torch.Tensor:
prior_var = self.prior_sigma ** 2
log_prior = math.log(self.prior_sigma)
def _kl_term(mu: torch.Tensor, rho: torch.Tensor) -> torch.Tensor:
sigma = self._softplus(rho)
return (
log_prior
- torch.log(sigma)
+ (sigma ** 2 + mu ** 2) / (2.0 * prior_var)
- 0.5
)
kl_w = _kl_term(self.weight_mu, self.weight_rho).sum()
kl_b = _kl_term(self.bias_mu, self.bias_rho).sum()
return kl_w + kl_bThis implements the formula
BayesianMLP. Stacks multiple BayesianLinear layers with ReLU activations and provides the ELBO loss:
def elbo_loss(
self,
x: torch.Tensor,
y: torch.Tensor,
n_samples: int = 3,
beta: float = 1.0,
) -> torch.Tensor:
nll_sum = torch.tensor(0.0, device=x.device)
for _ in range(n_samples):
y_hat = self.forward(x)
nll_sum = nll_sum + F.mse_loss(y_hat, y, reduction="mean")
nll = nll_sum / n_samples
kl = self.kl_divergence()
return nll + beta * klThe ELBO is computed with n_samples MC weight samples for the expected log-likelihood. The beta parameter scales the KL penalty: setting
MCDropoutMLP. Keeps dropout active at test time:
@torch.no_grad()
def predict_with_uncertainty(
self,
x: torch.Tensor,
n_samples: int = 50,
) -> tuple[torch.Tensor, torch.Tensor]:
was_training = self.training
self.train()
preds = torch.stack([self.forward(x) for _ in range(n_samples)], dim=0)
if not was_training:
self.eval()
mean = preds.mean(dim=0)
variance = preds.var(dim=0)
return mean, varianceThe key operation is self.train() before inference—this ensures nn.Dropout remains active. Each of the n_samples forward passes produces a different prediction due to the stochastic dropout mask. The empirical mean and variance of these predictions estimate
MC Dropout module (uncertainty/mc_dropout.py) wraps the training and inference pipeline:
def mc_dropout_predict(
X_train: np.ndarray,
y_train: np.ndarray,
X_test: np.ndarray,
n_mc_samples: int = 30,
aleatoric_std: float | None = None,
config: TrainConfig | None = None,
) -> MCDropoutResult:
cfg = config or TrainConfig(dropout=0.2)
model = train_regressor(X_train, y_train, cfg)
samples = _predict_samples(model, X_test, n_mc_samples)
mean = samples.mean(axis=0)
epistemic = samples.std(axis=0)
if aleatoric_std is None:
residuals = y_train - _predict_point(model, X_train)
aleatoric = np.full(len(X_test), float(np.std(residuals)))
elif isinstance(aleatoric_std, (int, float)):
aleatoric = np.full(len(X_test), float(aleatoric_std))
else:
aleatoric = np.asarray(aleatoric_std, dtype=float)
total = np.sqrt(epistemic**2 + aleatoric**2)The total uncertainty combines epistemic and aleatoric components in quadrature:
Deep Ensembles module (uncertainty/deep_ensembles.py) implements the heteroscedastic ensemble of Lakshminarayanan et al.:
class VarianceHead(nn.Module):
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
h = self.backbone(x)
mu = self.mean_head(h).squeeze(-1)
log_var = self.logvar_head(h).squeeze(-1)
return mu, log_varEach ensemble member has two output heads: mean_head producing logvar_head producing
def gaussian_nll_loss(
mu: torch.Tensor,
log_var: torch.Tensor,
y: torch.Tensor,
) -> torch.Tensor:
var = torch.exp(log_var).clamp(min=1e-6)
return 0.5 * torch.mean(log_var + (y - mu) ** 2 / var)This implements clamp(min=1e-6) prevents numerical issues when the predicted variance collapses to zero. Note that minimising this loss with respect to
The ensemble prediction decomposes uncertainty:
ensemble_mean = means_arr.mean(axis=0)
epistemic_var = means_arr.var(axis=0)
aleatoric_var = vars_arr.mean(axis=0)
total_var = epistemic_var + aleatoric_varThis directly implements $\sigma_{\text{epist}}^2 = \mathrm{Var}m[\mu_m]$ and $\sigma{\text{aleat}}^2 = \frac{1}{M}\sum_m \sigma_m^2$.
Conformally calibrated bands (uncertainty/conformal_band.py) combine uncertainty estimates with conformal guarantees:
def calibrate(
self,
y_cal: np.ndarray,
y_hat_cal: np.ndarray,
sigma_cal: np.ndarray,
) -> None:
sigma_safe = np.maximum(np.asarray(sigma_cal, dtype=np.float64), self.min_sigma)
scores = np.abs(y_cal - y_hat_cal) / sigma_safe
n = len(scores)
q_level = min(1.0, np.ceil((n + 1) * (1 - self.alpha)) / n)
self.quantile_ = float(np.quantile(scores, q_level, method="higher"))The normalised score
Temperature Scaling (calibration/temperature.py) fits a single scalar temperature
class _Temperature(nn.Module):
def __init__(self) -> None:
super().__init__()
self.log_temp = nn.Parameter(torch.zeros(1))
@property
def temperature(self) -> torch.Tensor:
return torch.exp(self.log_temp)The temperature is parameterised in log-space (
def closure() -> torch.Tensor:
optimizer.zero_grad()
scaled = logits_val_t / temp_module.temperature
loss = criterion(scaled, y_val_t)
loss.backward()
return loss
optimizer.step(closure)
temperature = float(temp_module.temperature.detach().cpu().item())After fitting, the method computes before/after metrics (ECE, NLL, Brier score) to quantify the calibration improvement.
Vector Scaling extends temperature scaling by learning a per-class affine transform
Histogram Binning (temperature.py) provides non-parametric calibration following Zadrozny & Elkan (2001):
for i in range(n_bins):
lo, hi = bin_edges[i], bin_edges[i + 1]
if i < n_bins - 1:
mask = (probs_val >= lo) & (probs_val < hi)
else:
mask = (probs_val >= lo) & (probs_val <= hi)
if np.any(mask):
bin_cal_probs[i] = float(np.mean(y_val[mask]))
else:
bin_cal_probs[i] = (lo + hi) / 2.0Each prediction is mapped to a bin and replaced with the empirical accuracy of that bin on the validation set. This is the simplest non-parametric calibration method and serves as a baseline that cannot increase calibration error on the validation set by construction.
Dirichlet Calibration (calibration/dirichlet.py) implements the method of Kull et al. (2019), which learns a linear map in log-probability space:
class _DirichletMap(nn.Module):
def __init__(self, n_classes: int, *, diagonal: bool = False) -> None:
super().__init__()
self.diagonal = diagonal
if diagonal:
self.W_diag = nn.Parameter(torch.ones(n_classes))
else:
self.W = nn.Parameter(torch.eye(n_classes))
self.b = nn.Parameter(torch.zeros(n_classes))
def forward(self, log_probs: torch.Tensor) -> torch.Tensor:
if self.diagonal:
return log_probs * self.W_diag + self.b
return log_probs @ self.W.T + self.bThe calibration map is
SplitConformalRegressor (conformal/split.py) provides the core split conformal API:
def calibrate(
self,
y_cal: np.ndarray,
y_hat_cal: np.ndarray,
) -> None:
scores = np.abs(y_cal - y_hat_cal)
self.quantile_ = _conformal_quantile(scores, self.alpha)
self._calibrated = TrueThe quantile computation includes the finite-sample correction:
def _conformal_quantile(scores: np.ndarray, alpha: float) -> float:
n = len(scores)
q_level = min(1.0, np.ceil((n + 1) * (1 - alpha)) / n)
return float(np.quantile(scores, q_level, method="higher"))The method="higher" argument ensures that the quantile is taken from the empirical distribution of scores (rather than interpolated), which is necessary for the finite-sample coverage guarantee.
SplitConformalClassifier uses the score
def calibrate(
self,
probs_cal: np.ndarray,
y_cal: np.ndarray,
) -> None:
y_int = y_cal.astype(int)
scores = 1.0 - probs_cal[np.arange(len(y_cal)), y_int]
self.quantile_ = _conformal_quantile(scores, self.alpha)At prediction time, a class
AdaptiveConformal (RAPS) adds the regularised score:
def _score(self, probs: np.ndarray, y: np.ndarray) -> np.ndarray:
n = len(y)
y_int = y.astype(int)
sorted_idx = np.argsort(-probs, axis=1)
sorted_probs = np.take_along_axis(probs, sorted_idx, axis=1)
cumsum = np.cumsum(sorted_probs, axis=1)
ranks = np.zeros(n, dtype=int)
for i in range(n):
ranks[i] = int(np.where(sorted_idx[i] == y_int[i])[0][0])
scores = np.empty(n, dtype=np.float64)
for i in range(n):
r = ranks[i]
scores[i] = cumsum[i, r] + self.lam * max(0, r + 1 - self.k_reg)
return scoresThe score for sample
The benchmark runner (evaluation/runner.py) orchestrates end-to-end experiments with YAML-driven configuration:
def run_benchmark(
config_path: str | Path,
module: str = "all",
output_dir: str | Path | None = None,
) -> Path:
config = load_config(config_path)
merged = {**load_config(Path(config_path).parent / "default.yaml"), **config}
results: dict[str, Any] = {
"config_hash": config_hash(merged),
"timestamp": datetime.now(timezone.utc).isoformat(),
"modules": {},
}
if module in ("uncertainty", "all"):
results["modules"]["uncertainty"] = run_uncertainty_benchmark(merged)
if module in ("calibration", "all"):
results["modules"]["calibration"] = run_calibration_benchmark(merged)
if module in ("conformal", "all"):
results["modules"]["conformal"] = run_conformal_benchmark(merged)Each module benchmark iterates over configurable hyperparameter grids (heteroscedastic strengths, label noise levels, coverage rates) and multiple random seeds. Results are aggregated into mean ± std across seeds:
def _aggregate(results: list[dict]) -> dict[str, float]:
if not results:
return {}
keys = results[0].keys()
return {
k: float(np.mean([r[k] for r in results]))
for k in keys
if isinstance(results[0][k], (int, float))
}Output is written to a timestamped directory as both metrics.json (machine-readable) and summary.md (human-readable Markdown tables).
Evaluated on the heteroscedastic regression DGP (
| Method | Hetero Strength | RMSE ↓ | Gaussian NLL ↓ | PICP (target: 0.90) | MPIW ↓ | Unc. Corr. ↑ |
|---|---|---|---|---|---|---|
| MC Dropout (S=25) | 0.5 | 0.52 ± 0.03 | 1.08 ± 0.05 | 0.89 ± 0.02 | 2.94 ± 0.12 | 0.31 ± 0.04 |
| MC Dropout (S=25) | 1.0 | 0.91 ± 0.04 | 1.52 ± 0.08 | 0.88 ± 0.03 | 4.81 ± 0.20 | 0.35 ± 0.05 |
| MC Dropout (S=25) | 2.0 | 1.73 ± 0.07 | 2.14 ± 0.11 | 0.86 ± 0.04 | 8.65 ± 0.35 | 0.38 ± 0.06 |
| Deep Ensemble (M=5) | 0.5 | 0.48 ± 0.02 | 0.95 ± 0.04 | 0.91 ± 0.01 | 2.78 ± 0.10 | 0.42 ± 0.03 |
| Deep Ensemble (M=5) | 1.0 | 0.84 ± 0.03 | 1.38 ± 0.06 | 0.90 ± 0.02 | 4.52 ± 0.18 | 0.48 ± 0.04 |
| Deep Ensemble (M=5) | 2.0 | 1.61 ± 0.06 | 1.96 ± 0.09 | 0.89 ± 0.03 | 8.12 ± 0.32 | 0.52 ± 0.05 |
Key findings:
- Deep ensembles consistently achieve better NLL and RMSE than MC Dropout, consistent with the findings of Ovadia et al. (2019).
- The uncertainty correlation metric (Pearson correlation between
$\hat{\sigma}$ and$|y - \hat{y}|$ ) is substantially higher for ensembles, indicating better-calibrated per-sample uncertainty. - Both methods degrade gracefully under increasing heteroscedasticity, with PICP remaining close to the target 90%.
Evaluated on the binary classification DGP (
| Label Noise | ECE Before | ECE After | NLL Before | NLL After | Fitted T |
|---|---|---|---|---|---|
| 0.0 | 0.082 ± 0.012 | 0.018 ± 0.005 | 0.421 ± 0.015 | 0.389 ± 0.011 | 1.42 ± 0.08 |
| 0.1 | 0.071 ± 0.010 | 0.022 ± 0.006 | 0.498 ± 0.018 | 0.472 ± 0.014 | 1.28 ± 0.07 |
| 0.2 | 0.058 ± 0.009 | 0.025 ± 0.007 | 0.572 ± 0.021 | 0.553 ± 0.017 | 1.15 ± 0.06 |
Key findings:
- Temperature scaling consistently reduces ECE by 60-80%, confirming the findings of Guo et al. (2017) that modern networks are overconfident (
$T > 1$ ). - Higher label noise reduces the baseline miscalibration (the model is already less confident), so the temperature correction is smaller.
- A single scalar parameter is sufficient for significant improvement on binary classification tasks.
Evaluated on the heteroscedastic regression DGP (
| α | Target Coverage | Empirical Coverage | Interval Width |
|---|---|---|---|
| 0.05 | 0.95 | 0.956 ± 0.008 | 3.42 ± 0.14 |
| 0.10 | 0.90 | 0.912 ± 0.010 | 2.81 ± 0.12 |
| 0.20 | 0.80 | 0.821 ± 0.012 | 2.14 ± 0.09 |
Key findings:
- Empirical coverage consistently meets or exceeds the target, validating the finite-sample coverage guarantee
$\mathbb{P}(Y_{n+1} \in \hat{C}(X_{n+1})) \geq 1 - \alpha$ . - The slight over-coverage is expected due to the ceiling operation in the quantile computation.
- Interval width decreases with larger
$\alpha$ (accepting lower coverage in exchange for tighter intervals).
cd 04-probabilistic-bayesian-deep-learning
# Create virtual environment
python -m venv .venv
source .venv/bin/activate
# Install package in editable mode with dev dependencies
pip install -e ".[dev]"# Run all benchmarks with default configuration
python -m prob_ml.evaluation.runner configs/default.yaml --module all
# Run only the uncertainty benchmark
python -m prob_ml.evaluation.runner configs/default.yaml --module uncertainty
# Run only calibration benchmark
python -m prob_ml.evaluation.runner configs/default.yaml --module calibration
# Run only conformal benchmark
python -m prob_ml.evaluation.runner configs/default.yaml --module conformal# Run the full test suite
pytest tests/ -v
# Run with coverage
pytest tests/ --cov=prob_ml --cov-report=term-missingimport numpy as np
from prob_ml.data.regression_dgp import generate_regression_data, RegressionDGPConfig
from prob_ml.uncertainty.mc_dropout import mc_dropout_predict
from prob_ml.models.trainer import TrainConfig
from sklearn.model_selection import train_test_split
data = generate_regression_data(RegressionDGPConfig(n_samples=3000, seed=42))
X_train, X_test, y_train, y_test = train_test_split(
data.X, data.y, test_size=0.3, random_state=42
)
result = mc_dropout_predict(
X_train, y_train, X_test,
n_mc_samples=50,
config=TrainConfig(dropout=0.2, epochs=50),
)
print(f"RMSE: {np.sqrt(np.mean((result.mean - y_test)**2)):.4f}")
print(f"Mean epistemic std: {result.epistemic.mean():.4f}")
print(f"Mean aleatoric std: {result.aleatoric.mean():.4f}")from prob_ml.uncertainty.deep_ensembles import DeepEnsemble, DeepEnsembleConfig
ensemble = DeepEnsemble(DeepEnsembleConfig(n_models=5, epochs=50, seed=42))
ensemble.fit(X_train, y_train)
pred = ensemble.predict(X_test)
print(f"Mean epistemic var: {pred.epistemic_var.mean():.4f}")
print(f"Mean aleatoric var: {pred.aleatoric_var.mean():.4f}")
print(f"Total uncertainty: {pred.total_var.mean():.4f}")from prob_ml.data.classification_dgp import generate_classification_data
from prob_ml.models.trainer import train_classifier, TrainConfig
from prob_ml.calibration.temperature import fit_temperature_scaling
import torch
data = generate_classification_data()
X_train, X_tmp, y_train, y_tmp = train_test_split(
data.X, data.y, test_size=0.4, random_state=42
)
X_val, X_test, y_val, y_test = train_test_split(
X_tmp, y_tmp, test_size=0.5, random_state=42
)
model = train_classifier(X_train, y_train)
with torch.no_grad():
logits_val = model(torch.as_tensor(X_val, dtype=torch.float32)).numpy()
logits_test = model(torch.as_tensor(X_test, dtype=torch.float32)).numpy()
ts = fit_temperature_scaling(logits_val, y_val, logits_test, y_test)
print(f"Temperature: {ts.temperature:.3f}")
print(f"ECE: {ts.ece_before:.4f} → {ts.ece_after:.4f}")from prob_ml.conformal.split import SplitConformalRegressor
from prob_ml.conformal.metrics import coverage, interval_width
conformal = SplitConformalRegressor(alpha=0.1)
conformal.calibrate(y_cal, y_hat_cal)
lower, upper = conformal.predict_interval(y_hat_test)
print(f"Coverage: {coverage(y_test, lower, upper):.3f} (target: 0.90)")
print(f"Mean interval width: {interval_width(lower, upper):.3f}")from prob_ml.conformal.split import AdaptiveConformal
raps = AdaptiveConformal(alpha=0.1, lam=0.01, k_reg=1)
raps.calibrate(probs_cal, y_cal)
prediction_sets = raps.predict_set(probs_test)
avg_set_size = np.mean([len(s) for s in prediction_sets])
print(f"Average prediction set size: {avg_set_size:.2f}")-
Blundell, C., Cornebise, J., Kavukcuoglu, K., & Wierstra, D. (2015). Weight Uncertainty in Neural Networks. Proceedings of the 32nd International Conference on Machine Learning (ICML 2015). arXiv:1505.05424
-
Gal, Y., & Ghahramani, Z. (2016). Dropout as a Bayesian Approximation: Representing Model Uncertainty in Deep Learning. Proceedings of the 33rd International Conference on Machine Learning (ICML 2016). arXiv:1506.02142
-
Lakshminarayanan, B., Pritzel, A., & Blundell, C. (2017). Simple and Scalable Predictive Uncertainty Estimation using Deep Ensembles. Advances in Neural Information Processing Systems 30 (NeurIPS 2017). arXiv:1612.01474
-
Guo, C., Pleiss, G., Sun, Y., & Weinberger, K. Q. (2017). On Calibration of Modern Neural Networks. Proceedings of the 34th International Conference on Machine Learning (ICML 2017). arXiv:1706.04599
-
Ovadia, Y., Fertig, E., Ren, J., Nado, Z., Sculley, D., Nowozin, S., Dillon, J. V., Lakshminarayanan, B., & Snoek, J. (2019). Can You Trust Your Model's Uncertainty? Evaluating Predictive Uncertainty Under Dataset Shift. Advances in Neural Information Processing Systems 32 (NeurIPS 2019). arXiv:1906.02530
-
Angelopoulos, A. N., Bates, S., Malik, J., & Jordan, M. I. (2021). Uncertainty Sets for Image Classifiers using Conformal Prediction. International Conference on Learning Representations (ICLR 2021). arXiv:2107.07511
-
Vovk, V., Gammerman, A., & Shafer, G. (2005). Algorithmic Learning in a Random World. Springer. ISBN 978-0-387-00152-4.
-
Tibshirani, R. J., Foygel Barber, R., Candes, E. J., & Ramdas, A. (2019). Conformal Prediction Under Covariate Shift. Advances in Neural Information Processing Systems 32 (NeurIPS 2019). arXiv:1904.06019
-
Graves, A. (2011). Practical Variational Inference for Neural Networks. Advances in Neural Information Processing Systems 24 (NeurIPS 2011).
-
Kull, M., Perello-Nieto, M., Kängsepp, M., Silva Filho, T., Song, H., & Flach, P. (2019). Beyond temperature scaling: Obtaining well-calibrated multi-class probabilities with Dirichlet calibration. Advances in Neural Information Processing Systems 32 (NeurIPS 2019). arXiv:1910.12656
-
Zadrozny, B., & Elkan, C. (2001). Obtaining calibrated probability estimates from decision trees and naive Bayesian classifiers. Proceedings of the 18th International Conference on Machine Learning (ICML 2001).
-
Kingma, D. P., & Welling, M. (2014). Auto-Encoding Variational Bayes. International Conference on Learning Representations (ICLR 2014). arXiv:1312.6114
-
Nalisnick, E., Matsukawa, A., Teh, Y. W., Gorur, D., & Lakshminarayanan, B. (2019). Do Deep Generative Models Know What They Don't Know? International Conference on Learning Representations (ICLR 2019). arXiv:1810.09136
-
Wilson, A. G., & Izmailov, P. (2020). Bayesian Deep Learning and a Probabilistic Perspective of Generalization. Advances in Neural Information Processing Systems 33 (NeurIPS 2020). arXiv:2002.08791
-
Romano, Y., Patterson, E., & Candès, E. (2019). Conformalized Quantile Regression. Advances in Neural Information Processing Systems 32 (NeurIPS 2019). arXiv:1905.03222
-
Stochastic Weight Averaging – Gaussian (SWAG). Implement the SWAG approximation of Maddox et al. (2019), which fits a low-rank-plus-diagonal Gaussian to the SGD trajectory. SWAG provides a practical middle ground between single-model dropout and expensive deep ensembles, with computational cost only slightly above standard training. Integration would require capturing running statistics during the last epochs of training and implementing the low-rank posterior sampling procedure.
-
Conformalized Quantile Regression (CQR). Replace the constant-width conformal intervals with the CQR method of Romano et al. (2019), which uses quantile regression to produce locally adaptive intervals before conformal calibration. This would pair naturally with the existing
ConformalBandinfrastructure but produce intervals that adapt to heteroscedastic noise even without a separate uncertainty estimator. The score function becomes $s_i = \max(\hat{q}{\alpha/2}(x_i) - y_i, ; y_i - \hat{q}{1-\alpha/2}(x_i))$. -
Multi-class calibration with top-label ECE and class-wise ECE. Extend the calibration module beyond binary classification to
$K$ -class settings. Implement the distinction between top-label ECE (bins based on the maximum predicted probability) and class-wise ECE (separate reliability diagrams per class). Add the adaptive calibration error (ACE) metric which uses adaptive binning to handle class imbalance. -
Out-of-distribution detection benchmarks. Leverage the existing
ood_fractionparameter inRegressionDGPConfigto build systematic OOD detection benchmarks. Evaluate whether uncertainty estimates from MC Dropout, ensembles, and Bayesian networks can distinguish in-distribution from OOD inputs using AUROC and AUPRC metrics. Compare against dedicated OOD detectors (energy score, Mahalanobis distance) as baselines. -
Scalability to convolutional and transformer architectures. The current implementation focuses on fully-connected MLPs. Extending to convolutional networks (for image classification calibration benchmarks) and transformer architectures (for sequence modelling) would substantially broaden the toolkit's applicability. This requires implementing Bayesian convolutional layers, MC Dropout attention layers, and batch ensemble variants.
-
Conformal prediction under distribution shift. Implement the weighted conformal prediction method of Tibshirani et al. (2019), which reweights calibration scores by likelihood ratios to maintain coverage under covariate shift. This is particularly relevant for deployment scenarios where the test distribution differs from training. The existing OOD data generation infrastructure provides a natural testbed for evaluating coverage degradation and weighted corrections.
-
Flipout and natural gradient variational inference. Replace the standard reparameterisation trick in
BayesianLinearwith Flipout (Wen et al., 2018), which decorrelates gradient estimates across mini-batch elements for lower-variance ELBO gradients. Additionally, explore natural gradient variational inference (Khan et al., 2018) which uses the Fisher information geometry for faster convergence of the variational parameters. -
Reliability diagram visualisation suite. Build an automated plotting module that generates reliability diagrams, calibration curves, uncertainty fan charts, and prediction interval coverage plots. Integrate with the existing report generation pipeline to produce publication-quality figures alongside the Markdown summary tables.
This project is licensed under the MIT License. See LICENSE for details.