Evaluation Fundamentals › Code Implementation

Michael BrenndoerferFebruary 21, 202613 min read
Language AI HandbookAll chapters
Part 53 of 67Evaluation Fundamentals
Chapter 412Evaluation Fundamentals › Code Implementation
Reading section 10 of 13 · Code Implementation · 13 minSection 10 of 13 · 13 min

Book contents

Section 10 of 13 · 13 min read

Evaluation Fundamentals › Code ImplementationOne section · Sections in this chapter10 / 13 ↓
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 implement the core components of an evaluation pipeline. We will compute several metrics for a summarization task, demonstrate significance testing, and show how to build a simple evaluation report.

Setup and Data Preparation

In[5]:
Code
import warnings

import numpy as np

warnings.filterwarnings("ignore")

# Sample summaries for demonstration
# Reference (gold) summaries and model outputs
references = [
    "The Federal Reserve raised interest rates by 0.25 percentage points on Wednesday, citing persistent inflation above its 2% target.",
    "Scientists discovered a new species of deep-sea fish in the Pacific Ocean at a depth of 3,000 meters, marking the first new discovery of this genus in 40 years.",
    "A major cybersecurity breach exposed personal data of 50 million users at a social media company, raising concerns about data protection regulations.",
    "The city council approved a \$2 billion infrastructure plan to repair roads, bridges, and public transit systems over the next decade.",
    "Researchers published findings showing that regular moderate exercise reduces the risk of cardiovascular disease by 35% in adults over 50.",
]

# Model A outputs (decent summaries)
model_a_outputs = [
    "The Fed increased interest rates by a quarter point due to inflation remaining above its 2 percent target.",
    "A new deep-sea fish species was found in the Pacific at 3,000 meters depth, the first in the genus discovered in four decades.",
    "A data breach at a social media firm exposed information from 50 million users, prompting concerns over data rules.",
    "The city council passed a two billion dollar plan for road, bridge, and transit improvements over 10 years.",
    "A study shows moderate exercise lowers cardiovascular disease risk by 35 percent in people older than 50.",
]

# Model B outputs (less accurate summaries)
model_b_outputs = [
    "Interest rates went up this week as the central bank continues its policy adjustments.",
    "Scientists found a new deep-sea creature in the ocean at great depths during a recent expedition.",
    "Millions of users had their data compromised in a breach at a large online platform.",
    "Local government approved major funding for infrastructure improvements.",
    "Exercise is beneficial for heart health, according to a new research study.",
]
Out[6]:
Console
Test set size: 5 examples

Sample reference:
  The Federal Reserve raised interest rates by 0.25 percentage points on Wednesday, citing persistent inflation above its 2% target.

Model A output:
  The Fed increased interest rates by a quarter point due to inflation remaining above its 2 percent target.

Model B output:
  Interest rates went up this week as the central bank continues its policy adjustments.

Model A outputs are more specific and factually complete, while Model B outputs are vague and lose key quantitative details. Let's measure this difference with metrics.

Token Overlap Metrics (ROUGE)

ROUGE measures n-gram overlap between a generated summary and one or more reference summaries. ROUGE-1 measures unigram (single token) overlap. ROUGE-2 measures bigram overlap. ROUGE-L measures the longest common subsequence. ROUGE recall asks how much of the reference content appears in the hypothesis, and this is typically the most informative variant for summarization because the primary failure mode is leaving important information out.

In[7]:
Code
def compute_ngrams(text, n):
    """Extract n-grams from tokenized text."""
    tokens = text.lower().split()
    return [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]


def rouge_n(hypothesis, reference, n):
    """Compute ROUGE-N precision, recall, and F1."""
    hyp_ngrams = compute_ngrams(hypothesis, n)
    ref_ngrams = compute_ngrams(reference, n)

    if not ref_ngrams:
        return {"precision": 0.0, "recall": 0.0, "f1": 0.0}

    # Count overlapping n-grams
    hyp_counts = {}
    for ng in hyp_ngrams:
        hyp_counts[ng] = hyp_counts.get(ng, 0) + 1

    ref_counts = {}
    for ng in ref_ngrams:
        ref_counts[ng] = ref_counts.get(ng, 0) + 1

    overlap = sum(
        min(hyp_counts.get(ng, 0), ref_counts[ng]) for ng in ref_counts
    )

    precision = overlap / len(hyp_ngrams) if hyp_ngrams else 0.0
    recall = overlap / len(ref_ngrams)
    f1 = (
        (2 * precision * recall / (precision + recall))
        if (precision + recall) > 0
        else 0.0
    )

    return {"precision": precision, "recall": recall, "f1": f1}


# Compute ROUGE-1 and ROUGE-2 for both models
rouge1_a = [
    rouge_n(h, r, 1)["recall"] for h, r in zip(model_a_outputs, references)
]
rouge1_b = [
    rouge_n(h, r, 1)["recall"] for h, r in zip(model_b_outputs, references)
]

rouge2_a = [
    rouge_n(h, r, 2)["recall"] for h, r in zip(model_a_outputs, references)
]
rouge2_b = [
    rouge_n(h, r, 2)["recall"] for h, r in zip(model_b_outputs, references)
]
Out[8]:
Console
ROUGE Scores (Recall):
Metric             Model A    Model B   Difference
--------------------------------------------------
ROUGE-1              0.462      0.197       +0.266
ROUGE-2              0.148      0.042       +0.106

Model A shows higher ROUGE recall because it retains more specific details from the reference. ROUGE-2 differences are larger than ROUGE-1 because exact phrase matches require both the tokens and their order to be correct, which rewards Model A's more faithful reproduction of key phrases.

Exact Match and Factual Coverage

For tasks where there is a definitive correct answer, exact match is the right metric. Let's also compute a simple coverage metric that counts how many key entities from the reference appear in the summary.

In[9]:
Code
import re


def extract_numbers(text):
    """Extract numeric values from text for factual coverage."""
    # Match decimals, percentages, and integers
    return set(re.findall(r"\b\d+(?:\.\d+)?(?:\s*%)?", text.lower()))


def factual_coverage(hypothesis, reference):
    """Compute what fraction of key numbers from the reference appear in hypothesis."""
    ref_numbers = extract_numbers(reference)
    if not ref_numbers:
        return 1.0  # No numbers to check

    hyp_numbers = extract_numbers(hypothesis)
    matched = ref_numbers & hyp_numbers
    return len(matched) / len(ref_numbers)


coverage_a = [
    factual_coverage(h, r) for h, r in zip(model_a_outputs, references)
]
coverage_b = [
    factual_coverage(h, r) for h, r in zip(model_b_outputs, references)
]
Out[10]:
Console
Factual Coverage (numerical entities from reference):
Example       Model A    Model B Reference numbers
----------------------------------------------------------------------
Example 1        0.00       0.00  {'0.25', '2%'}
Example 2        0.67       0.00  {'3', '000', '40'}
Example 3        1.00       0.00  {'50'}
Example 4        0.00       0.00  {'2'}
Example 5        0.50       0.00  {'50', '35%'}
----------------------------------------------------------------------
Mean             0.43       0.00

Model A preserves the specific numerical facts from the reference summaries, while Model B often omits them, producing vaguer outputs. This is exactly the kind of failure that ROUGE might partially catch but that factual coverage metrics make explicit.

The per-example metric comparison below shows where the models diverge most, illustrating that aggregate mean scores can obscure example-level variation.

Out[11]:
Visualization
Bar chart comparing ROUGE-1 recall for Model A and Model B across five examples.
ROUGE-1 recall per example for Model A (blue) and Model B (orange). Model A consistently preserves more unigram content from the reference, especially for examples with specific numerical details.
Bar chart comparing factual coverage for Model A and Model B across five examples.
Factual coverage per example, measuring what fraction of numerical entities from the reference appear in each summary. Model B loses most quantitative details across all examples, while Model A retains them.

Statistical Significance Testing

A difference in mean scores only matters if it is statistically significant. With a small test set, apparent differences can be due to sampling variability. We use the bootstrap resampling method to compute confidence intervals.

Bootstrap resampling works by repeatedly drawing samples of the same size as the original test set, with replacement, and computing the metric difference on each resample. The distribution of these bootstrap differences approximates the sampling distribution of the true difference, and the percentiles of this distribution give us confidence interval bounds. Bootstrap is preferred over parametric tests here because it makes no assumptions about the distribution of metric values.

In[12]:
Code
def bootstrap_confidence_interval(
    scores_a, scores_b, n_bootstrap=2000, confidence=0.95
):
    """Bootstrap confidence interval for the difference in means."""
    np.random.seed(42)
    diffs = []
    n = len(scores_a)

    for _ in range(n_bootstrap):
        indices = np.random.randint(0, n, size=n)
        bootstrap_a = np.mean([scores_a[i] for i in indices])
        bootstrap_b = np.mean([scores_b[i] for i in indices])
        diffs.append(bootstrap_a - bootstrap_b)

    alpha = 1 - confidence
    lower = np.percentile(diffs, 100 * alpha / 2)
    upper = np.percentile(diffs, 100 * (1 - alpha / 2))
    observed_diff = np.mean(scores_a) - np.mean(scores_b)

    return observed_diff, lower, upper


# Compute confidence intervals for ROUGE-1 and factual coverage
r1_diff, r1_lower, r1_upper = bootstrap_confidence_interval(rouge1_a, rouge1_b)
fc_diff, fc_lower, fc_upper = bootstrap_confidence_interval(
    coverage_a, coverage_b
)
Out[13]:
Console
Statistical Significance (Model A vs. Model B):
95% bootstrap confidence intervals for difference in means

ROUGE-1 Recall:
  Observed difference: +0.266
  95% CI: [+0.212, +0.319]
  Significant (CI excludes 0): Yes

Factual Coverage:
  Observed difference: +0.433
  95% CI: [+0.100, +0.767]
  Significant (CI excludes 0): Yes

Note: These confidence intervals are based on only 5 examples.
A larger test set is needed for reliable conclusions.

With only 5 examples, neither difference reaches statistical significance at 95% confidence. This illustrates a critical point: you cannot draw strong conclusions from small evaluation samples, even when the mean differences look meaningful. The confidence intervals span nearly the full range of possible outcomes. The right response to this result is not to conclude the models are equivalent, but to recognize that the test is underpowered and more examples are needed before drawing any conclusion.

Sample Size Calculator

Let's implement the sample size formula from earlier to understand how many examples we need for reliable evaluations.

In[14]:
Code
from scipy import stats


def required_sample_size(baseline_p, delta, alpha=0.05, power=0.80):
    """
    Compute required sample size to detect a difference of 'delta'
    from 'baseline_p' with given significance and power.

    Parameters:
        baseline_p: Expected performance of the baseline model (0-1)
        delta:      Minimum effect size to detect (0-1)
        alpha:      Significance level (default 0.05)
        power:      Statistical power (default 0.80)
    """
    z_alpha = stats.norm.ppf(1 - alpha / 2)
    z_beta = stats.norm.ppf(power)

    p1 = baseline_p
    p2 = baseline_p + delta
    p_bar = (p1 + p2) / 2

    n = (
        (
            z_alpha * np.sqrt(2 * p_bar * (1 - p_bar))
            + z_beta * np.sqrt(p1 * (1 - p1) + p2 * (1 - p2))
        )
        ** 2
    ) / (delta**2)

    return int(np.ceil(n))


# Show required sample sizes for different scenarios
scenarios = [
    (0.70, 0.05, "Large effect (5pp at 70% baseline)"),
    (0.70, 0.03, "Medium effect (3pp at 70% baseline)"),
    (0.70, 0.01, "Small effect (1pp at 70% baseline)"),
    (0.85, 0.03, "Medium effect (3pp at 85% baseline)"),
    (0.50, 0.05, "Large effect (5pp at 50% baseline)"),
]
Out[15]:
Console
Required Sample Size for Evaluation (alpha=0.05, power=0.80):
Scenario                                        n required
------------------------------------------------------------
Large effect (5pp at 70% baseline)                   1,251
Medium effect (3pp at 70% baseline)                  3,554
Small effect (1pp at 70% baseline)                  32,647
Medium effect (3pp at 85% baseline)                  2,036
Large effect (5pp at 50% baseline)                   1,565

Detecting small differences requires large test sets. A 1 percentage-point difference at 70% baseline accuracy requires over 3,000 examples. This is why well-designed benchmark suites use hundreds to thousands of test examples: smaller sets simply cannot reliably detect meaningful performance differences.

Bootstrap Distribution Visualization

Understanding the bootstrap distribution helps build intuition about why small samples produce wide confidence intervals. Let's visualize the bootstrap distribution for our ROUGE-1 difference.

Out[16]:
Visualization
Histogram of positive bootstrap differences with a 95% confidence interval from plus 0.212 to plus 0.319, clearly separated from the zero-reference line.
Bootstrap distribution of the ROUGE-1 recall difference (Model A minus Model B) from 2,000 resamples of 5 examples. Every observed per-example difference favors Model A, so the resampled differences remain positive and the 95% confidence interval excludes zero. The separated zero-reference line makes that result visible, while the distribution's discrete peaks reflect repeated resampling from only five examples.

Building an Evaluation Report

A structured evaluation report brings all metrics together and documents the conditions under which they were measured.

In[17]:
Code
from datetime import datetime


def evaluation_report(model_name, metric_results, conditions):
    """Generate a structured evaluation summary."""
    report_lines = []
    report_lines.append("=" * 60)
    report_lines.append(f"EVALUATION REPORT: {model_name}")
    report_lines.append(f"Date: {datetime.now().strftime('%Y-%m-%d')}")
    report_lines.append("=" * 60)

    report_lines.append("\nEVALUATION CONDITIONS:")
    for key, value in conditions.items():
        report_lines.append(f"  {key}: {value}")

    report_lines.append("\nMETRIC RESULTS:")
    for metric, values in metric_results.items():
        mean_val = np.mean(values)
        std_val = np.std(values)
        report_lines.append(f"  {metric}:")
        report_lines.append(f"    Mean:  {mean_val:.3f}")
        report_lines.append(f"    Std:   {std_val:.3f}")
        report_lines.append(f"    Min:   {np.min(values):.3f}")
        report_lines.append(f"    Max:   {np.max(values):.3f}")

    report_lines.append("\nCAVEATS:")
    n = len(list(metric_results.values())[0])
    report_lines.append(
        f"  Sample size: {n} examples (small; results are indicative only)"
    )
    report_lines.append(
        f"  Confidence intervals should be computed before drawing conclusions."
    )

    return "\n".join(report_lines)


conditions = {
    "Task": "News summarization",
    "Decoding temperature": 0.0,
    "Max output tokens": 100,
    "Prompt template": "Summarize the following article in one sentence: {article}",
    "Test set": "Internal news domain (not CNN/DailyMail)",
}
Out[18]:
Console
============================================================
EVALUATION REPORT: Model A
Date: 2026-08-15
============================================================

EVALUATION CONDITIONS:
  Task: News summarization
  Decoding temperature: 0.0
  Max output tokens: 100
  Prompt template: Summarize the following article in one sentence: {article}
  Test set: Internal news domain (not CNN/DailyMail)

METRIC RESULTS:
  ROUGE-1 Recall:
    Mean:  0.462
    Std:   0.058
    Min:   0.400
    Max:   0.545
  ROUGE-2 Recall:
    Mean:  0.148
    Std:   0.038
    Min:   0.100
    Max:   0.190
  Factual Coverage:
    Mean:  0.433
    Std:   0.389
    Min:   0.000
    Max:   1.000

CAVEATS:
  Sample size: 5 examples (small; results are indicative only)
  Confidence intervals should be computed before drawing conclusions.

The evaluation report makes it easy to communicate results in a structured way and documents the conditions that allow results to be reproduced or challenged.

Key Parameters

The core parameters for any evaluation pipeline are:

  • n_bootstrap: Number of resampling iterations for bootstrap confidence intervals. Higher values (2000 or more) produce stable interval estimates.
  • confidence: The desired confidence level for intervals, typically 0.95 for 95% confidence.
  • alpha (significance level): Probability of a false positive. Standard choice is 0.05, meaning a 5% chance of concluding a difference is real when it is not.
  • power: Probability of detecting a true difference. 0.80 is the conventional minimum; 0.90 is better for high-stakes comparisons.
  • delta: The minimum effect size worth detecting, expressed as a proportion. Setting this too small (e.g., 0.005) requires impractically large test sets.