Uncertainty Quantification › Code Implementation

Michael BrenndoerferFebruary 24, 20268 min read
Language AI HandbookAll chapters
Part 56 of 67Hallucination and Factuality
Chapter 434Uncertainty Quantification › Code Implementation
Reading section 6 of 11 · Code Implementation · 8 minSection 6 of 11 · 8 min

Book contents

Section 6 of 11 · 8 min read

Uncertainty Quantification › Code ImplementationOne section · Sections in this chapter6 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's build a practical uncertainty quantification system. We'll implement token-level confidence extraction, temperature scaling calibration, and sampling-based uncertainty estimation.

Setup and Data

We start with an implementation that works with any model through the Hugging Face Transformers API. For this example, we'll use a small model and simulate a factual QA scenario to illustrate the key ideas.

In[3]:
Code
import warnings

import numpy as np

warnings.filterwarnings("ignore")

# Reproducibility
np.random.seed(42)

Simulating Model Confidence Scores

In a real deployment, you'd extract logits from a language model. Here we simulate the behavior of a well-documented phenomenon: overconfident LLMs that assign high probabilities even when wrong.

In[4]:
Code
def simulate_lm_confidences(n_samples=500, overconfidence_factor=2.0):
    """
    Simulate an overconfident language model.
    True probabilities are drawn from a beta distribution (realistic spread).
    Observed confidences are a monotone transform that stretches toward 1.
    """
    # True underlying probabilities
    true_probs = np.random.beta(3, 2, n_samples)  # skewed toward higher values

    # Overconfident model: compress toward 1 using a power < 1
    # overconfidence_factor > 1 means logit is scaled up
    logit_true = np.log(true_probs / (1 - true_probs + 1e-9))
    logit_observed = logit_true * overconfidence_factor
    observed_confidences = 1 / (1 + np.exp(-logit_observed))

    # Generate binary outcomes: correct (1) or incorrect (0)
    correct = (np.random.uniform(0, 1, n_samples) < true_probs).astype(int)

    return observed_confidences, correct, true_probs


# Generate data
confidences, correct, true_probs = simulate_lm_confidences(n_samples=1000)
Out[5]:
Console
Mean model confidence: 0.644
Actual accuracy:       0.616
Overconfidence gap:    0.028
Samples:               1000

The gap between mean confidence and actual accuracy quantifies the overconfidence. In this simulation, the model claims an average confidence well above its actual accuracy rate, reproducing what has been observed in empirical studies of large language models.

Calibration Curve and ECE

Let's compute and visualize the calibration curve.

In[6]:
Code
def compute_ece(confidences, correct, n_bins=10):
    """Compute Expected Calibration Error (ECE)."""
    bin_boundaries = np.linspace(0, 1, n_bins + 1)
    ece = 0.0
    n = len(confidences)

    for i in range(n_bins):
        lower, upper = bin_boundaries[i], bin_boundaries[i + 1]
        mask = (confidences >= lower) & (confidences < upper)
        if mask.sum() == 0:
            continue
        bin_conf = confidences[mask].mean()
        bin_acc = correct[mask].mean()
        bin_weight = mask.sum() / n
        ece += bin_weight * abs(bin_conf - bin_acc)

    return ece


ece_before = compute_ece(confidences, correct)
Out[7]:
Console
ECE before calibration: 0.1026

Temperature Scaling

Now we implement temperature scaling to correct the overconfidence. Temperature scaling finds the single scalar T∗T^* that minimizes the negative log-likelihood on the calibration set.

In[8]:
Code
from scipy.optimize import minimize_scalar


def apply_temperature(logits, temperature):
    """Apply temperature scaling to logits and return probabilities."""
    scaled = logits / temperature
    return 1 / (1 + np.exp(-scaled))  # sigmoid for binary


def calibrate_temperature(confidences, correct):
    """
    Find optimal temperature by minimizing negative log-likelihood
    on a calibration set (here we use the full dataset for illustration).
    """
    # Convert confidences back to approximate logits
    eps = 1e-7
    logits = np.log(confidences / (1 - confidences + eps) + eps)

    def nll(temperature):
        if temperature <= 0:
            return 1e9
        probs = apply_temperature(logits, temperature)
        probs = np.clip(probs, eps, 1 - eps)
        return -np.mean(
            correct * np.log(probs) + (1 - correct) * np.log(1 - probs)
        )

    result = minimize_scalar(nll, bounds=(0.1, 10.0), method="bounded")
    return result.x


logits = np.log(confidences / (1 - confidences + 1e-7) + 1e-7)
optimal_temp = calibrate_temperature(confidences, correct)
calibrated_confidences = apply_temperature(logits, optimal_temp)
ece_after = compute_ece(calibrated_confidences, correct)
Out[9]:
Console
Optimal temperature:    2.107
ECE before calibration: 0.1026
ECE after calibration:  0.0531
ECE reduction:          48.3%

The optimal temperature is greater than 1.0, confirming the model was overconfident (temperature above 1 softens probabilities). The ECE drops substantially after calibration.

Sampling-Based Uncertainty

Now let's implement sampling-based uncertainty estimation. We simulate multiple samples from a model on the same question and measure semantic consistency.

In[10]:
Code
from scipy.stats import entropy


def simulate_model_samples(question_difficulty, n_samples=10, noise_level=0.3):
    """
    Simulate model answers for a question of given difficulty.

    difficulty: 0 (easy) to 1 (hard)
    Returns: list of 0/1 answers (0=wrong, 1=correct)
    """
    # Move from a certain correct answer toward a maximally uncertain 50/50 split.
    true_prob = 1.0 - 0.5 * question_difficulty
    # Add sampling noise
    sample_probs = np.clip(
        true_prob + np.random.normal(0, noise_level, n_samples), 0.05, 0.95
    )
    answers = (np.random.uniform(0, 1, n_samples) < sample_probs).astype(int)
    return answers


def sampling_uncertainty(answers):
    """
    Compute uncertainty metrics from binary (0/1) answer samples.
    Returns confidence (agreement fraction) and entropy.
    """
    p_correct = np.mean(answers)
    agreement = max(p_correct, 1 - p_correct)
    # Binary entropy
    h = (
        entropy([p_correct, 1 - p_correct], base=2)
        if 0 < p_correct < 1
        else 0.0
    )
    return agreement, h


# Test across different difficulty levels
difficulties = np.linspace(0, 1, 100)
n_samples = 20

agreements = []
entropies = []
for d in difficulties:
    answers = simulate_model_samples(d, n_samples=n_samples)
    agreement, h = sampling_uncertainty(answers)
    agreements.append(agreement)
    entropies.append(h)
Out[11]:
Console
Easy question (diff=0.1): agreement=0.80, entropy=0.722
Medium question (diff=0.5): agreement=0.80, entropy=0.722
Hard question (diff=0.9): agreement=0.60, entropy=0.971

Self-Consistency Score

Self-consistency computes confidence from majority voting across chain-of-thought samples. The confidence score is simply the fraction of samples that agree with the majority answer.

In[12]:
Code
from collections import Counter


def self_consistency_confidence(answers):
    """
    Given a list of sampled answers (any hashable type),
    return the self-consistency confidence (majority fraction).
    """
    counts = Counter(answers)
    majority_count = counts.most_common(1)[0][1]
    return majority_count / len(answers)


def run_self_consistency_experiment(n_questions=200):
    """
    Simulate self-consistency over questions with varying difficulty.
    Returns a list of dicts with difficulty, sc_confidence, and correctness.
    """
    results = []
    difficulties = np.random.uniform(0, 1, n_questions)

    for diff in difficulties:
        answers = simulate_model_samples(diff, n_samples=10)
        sc_conf = self_consistency_confidence(answers)
        majority_answer = Counter(answers).most_common(1)[0][0]
        results.append(
            {
                "difficulty": diff,
                "sc_confidence": sc_conf,
                "correct": int(majority_answer == 1),
            }
        )

    return results


sc_results = run_self_consistency_experiment(n_questions=500)
sc_confidences = np.array([r["sc_confidence"] for r in sc_results])
sc_correct = np.array([r["correct"] for r in sc_results])
sc_ece = compute_ece(sc_confidences, sc_correct)
Out[13]:
Console
Self-consistency results:
  Mean SC confidence: 0.714
  Overall accuracy:   0.854
  ECE:                0.1396

Semantic Entropy Simulation

Let's implement a simplified version of semantic entropy for open-ended responses. In a real system, the clustering step uses NLI entailment; here we simulate clusters directly.

In[14]:
Code
def compute_semantic_entropy(response_clusters):
    """
    Compute semantic entropy from cluster assignment counts.

    response_clusters: list of cluster IDs (one per sampled response)
    Returns: semantic entropy in nats
    """
    counts = Counter(response_clusters)
    total = len(response_clusters)
    probs = np.array([count / total for count in counts.values()])
    return entropy(probs)  # natural log entropy


def simulate_semantic_responses(question_difficulty, n_samples=10):
    """
    Simulate semantic cluster assignments for an open-ended question.

    For easy questions: most responses fall in the same cluster.
    For hard questions: responses are spread across many clusters.
    """
    # Number of distinct clusters increases with difficulty
    n_clusters_expected = 1 + question_difficulty * 5
    cluster_probs = np.ones(int(np.ceil(n_clusters_expected)))
    # Weight toward first cluster (most common answer) inversely with difficulty
    cluster_probs[0] = (1.0 - question_difficulty) * 10 + 1
    cluster_probs /= cluster_probs.sum()

    clusters = np.random.choice(
        len(cluster_probs), size=n_samples, p=cluster_probs
    )
    return clusters


# Compute semantic entropy across difficulty levels
sem_entropies = []
for d in difficulties:
    clusters = simulate_semantic_responses(d, n_samples=20)
    se = compute_semantic_entropy(clusters)
    sem_entropies.append(se)
Out[15]:
Console
Semantic entropy by difficulty:
  Easy (diff=0.1):   0.325 nats
  Medium (diff=0.5): 0.613 nats
  Hard (diff=0.9):   1.330 nats

Higher semantic entropy at higher difficulty confirms that more diverse (uncertain) answers correspond to harder questions. The overall upward relationship between difficulty and semantic entropy validates semantic entropy as an uncertainty signal that tracks model uncertainty rather than surface variation.

Key Parameters

The key parameters for the uncertainty quantification methods implemented above are:

  • n_bins (ECE): Number of confidence bins for calibration error computation. More bins give finer-grained measurement but require more data for reliable estimates. 10-15 bins is standard.
  • overconfidence_factor (temperature scaling): The logit scaling factor that produces overconfident outputs. Values greater than 1.0 stretch probabilities toward certainty, simulating the systematic bias seen in RLHF-trained models.
  • temperature (calibration): The divisor applied to logits before the softmax. Learned values above 1.0 soften an overconfident model; values below 1.0 sharpen an underconfident one.
  • n_samples (sampling-based methods): Number of model runs per query. More samples produce a more accurate uncertainty estimate but multiply inference cost proportionally.
  • n_questions (self-consistency): Number of questions in the evaluation set. Larger evaluation sets produce more reliable ECE estimates.