Lab Guide
Format: Python script (lab.py) · Time estimate: 45-60 minutes · Prerequisites: Python 3.10+, anthropic, rouge-score, scipy, python-dotenv
Setup
Before starting, ensure your environment is ready:
# From the project root
pip install anthropic rouge-score scipy python-dotenv
# Ensure your .env file has your API key
# ANTHROPIC_API_KEY=your_key_here
Verify setup by running:
python -c "from rouge_score import rouge_scorer; print('rouge-score OK')"
python -c "from scipy import stats; print('scipy OK')"
python -c "import anthropic; print('anthropic OK')"
Exercise 1: Build an Eval Pipeline
Goal
Build a reusable evaluation harness that computes exact match, ROUGE-L, and a simple semantic check across a dataset of question-answer pairs.
Steps
- Define a small evaluation dataset as a list of dictionaries, each containing
input,expected, andcategoryfields:EVAL_SET = [ { "input": "What is the capital of France?", "expected": "Paris", "category": "factual" }, { "input": "Summarise photosynthesis in one sentence.", "expected": "Photosynthesis is the process by which plants convert sunlight, water, and carbon dioxide into glucose and oxygen.", "category": "summarisation" }, # Add 8-10 more examples across different categories ] - Implement an
exact_match(predicted, expected)function that normalises both strings (lowercase, strip whitespace, remove punctuation) before comparing. - Implement a
compute_rouge_l(predicted, expected)function using therouge-scorelibrary:from rouge_score import rouge_scorer def compute_rouge_l(predicted: str, expected: str) -> float: scorer = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True) scores = scorer.score(expected, predicted) return scores["rougeL"].fmeasure - Write a
run_eval(eval_set, model_fn)function that:- Runs each input through
model_fnto get a prediction - Computes exact match and ROUGE-L for each example
- Returns a results list with per-example scores and aggregate statistics
- Runs each input through
- Create a simple
model_fnthat calls the Claude API with a basic prompt:import anthropic from dotenv import load_dotenv load_dotenv() client = anthropic.Anthropic() def call_claude(prompt: str) -> str: response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=256, messages=[{"role": "user", "content": prompt}] ) return response.content[0].text - Run the pipeline and print a summary table showing per-example and aggregate results.
Expected Output
=== Eval Results ===
# Category Exact Match ROUGE-L Prediction (truncated)
1 factual 1.0 1.00 Paris
2 summarisation 0.0 0.62 Plants use sunlight to convert...
3 factual 1.0 1.00 7
...
=== Aggregate ===
Exact Match: 0.40 (4/10)
ROUGE-L mean: 0.71
ROUGE-L std: 0.28
Key Insight
Notice how exact match is harsh on open-ended tasks (summarisation, explanation) but appropriate for factual lookups. ROUGE-L captures partial credit. Neither metric tells you whether the answer is correct — just whether it overlaps with the reference.
Exercise 2: LLM-as-Judge
Goal
Implement an LLM-as-judge evaluator with a structured rubric, then calibrate it against your own human labels.
Steps
- Define a judge prompt template with a clear rubric:
JUDGE_PROMPT = """You are an expert evaluator. Rate the following answer on a scale of 1-5 based on this rubric: 5 - Fully correct, complete, and well-expressed 4 - Correct with minor omissions or style issues 3 - Partially correct, missing important details 2 - Contains significant errors or is misleading 1 - Incorrect, irrelevant, or harmful Question: {question} Reference answer: {reference} Model answer: {model_answer} Respond with ONLY valid JSON in this exact format: {{"score":, "reasoning": " "}}""" - Write a
judge_answer(question, reference, model_answer)function that:- Formats the judge prompt
- Calls Claude (use the same or a stronger model as judge)
- Parses the JSON response
- Returns the score and reasoning
- Run the judge on all examples from Exercise 1. Store the judge scores alongside the automated metrics.
- Human calibration: Manually score each model output yourself (1-5) before looking at the judge scores. Then compare:
human_scores = [5, 4, 5, 3, ...] # Your manual ratings judge_scores = [5, 4, 5, 4, ...] # From the LLM judge # Agreement rate (within 1 point) agreement = sum( 1 for h, j in zip(human_scores, judge_scores) if abs(h - j) <= 1 ) / len(human_scores) print(f"Agreement (within 1 point): {agreement:.0%}")
Expected Output
=== LLM-as-Judge Results ===
# Judge Score Reasoning
1 5 Correct: the capital of France is Paris
2 4 Accurate but less detailed than reference
3 5 Exact correct numerical answer
...
=== Calibration ===
Human vs Judge agreement (within 1 point): 80%
Mean human score: 3.8
Mean judge score: 4.1
Judge bias: +0.3 (judge rates slightly higher)
Watch for Verbosity Bias
If the judge consistently scores longer answers higher, your rubric may need an explicit instruction: "Do not penalise concise answers. A short, correct answer should score 5."
Exercise 3: Custom Benchmark with A/B Testing
Goal
Create a focused eval set for a specific task, run two prompt variants on it, and determine whether the difference is statistically significant.
Steps
- Choose a task domain. For this lab, use technical Q&A about Python. Create 20 question-answer pairs:
PYTHON_EVAL = [ {"input": "What does the `enumerate()` function return?", "expected": "enumerate() returns an iterator of tuples containing indices and values from an iterable."}, {"input": "How do you handle a KeyError in Python?", "expected": "Use a try/except block: try: value = d[key] except KeyError: handle the missing key. Alternatively, use dict.get(key, default)."}, # ... 18 more examples ] - Define two prompt variants — a baseline and a candidate:
def prompt_baseline(question: str) -> str: return question def prompt_candidate(question: str) -> str: return f"""You are a Python expert. Answer the following question concisely and accurately. Include a brief code example if relevant. Question: {question}""" - Run both prompts on all 20 examples. Use the LLM-as-judge from Exercise 2 to score each output.
- Compute the Wilcoxon signed-rank test to check for statistical significance:
from scipy.stats import wilcoxon baseline_scores = [...] # judge scores for baseline candidate_scores = [...] # judge scores for candidate stat, p_value = wilcoxon(baseline_scores, candidate_scores) print(f"Wilcoxon p-value: {p_value:.4f}") print(f"Significant at p<0.05: {p_value < 0.05}") - Print a comparison report:
Expected Output
=== A/B Test Results ===
Baseline Candidate
Mean judge score: 3.65 4.20
Median: 4 4
Std dev: 0.93 0.77
Wins / Ties / Losses: 12 / 5 / 3
Wilcoxon statistic: 23.0
p-value: 0.0127
Significant at p<0.05: Yes
Conclusion: The candidate prompt significantly outperforms
the baseline (p=0.013, mean improvement +0.55 points).
Why Wilcoxon?
We use a paired test because both prompts are evaluated on the same questions. The Wilcoxon signed-rank test is appropriate for ordinal data (1-5 scores) and does not assume a normal distribution. For binary outcomes (correct/incorrect), use McNemar's test instead.
Finishing Up
Once you have completed all three exercises:
- Run the full script end-to-end:
python lab.py - Verify all three exercises produce output without errors.
- Compare your implementation with
lab_answer.py— focus on the judge prompt design and statistical testing.
Self-Check
- ✓ Exercise 1: Your pipeline computes both exact match and ROUGE-L for every example.
- ✓ Exercise 1: Aggregate statistics include mean and standard deviation.
- ✓ Exercise 2: Your judge returns structured JSON with score and reasoning.
- ✓ Exercise 2: You compared judge scores against your own human labels.
- ✓ Exercise 3: You ran a paired statistical test (not just compared means).
- ✓ Exercise 3: You can explain what the p-value means in context.