Module 13. Evaluating RL systems for LLMs: statistics, reward hacking, and safety
Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.
Download course files (.zip) 382.1 KBThe archive preserves the source folder structure, both languages, and supporting files.
What exactly scored 61%?
A report says, “the model scored 61%.” The new run changed the system prompt, chat template, length limit, search access, and answer canonicalizer while keeping the weights fixed.
-
What object is actually being compared with the earlier run?
-
Why can the number not be attributed to the weight file alone?
-
What must be fixed if the research question is specifically about checkpoints?
-
When is a scaffold change a legitimate part of the evaluated system?
Show answer and solution
The comparison is between two versioned systems, not two weight files. A checkpoint-only study must hold tokenizer, template, system message, decoding, budget, tools, environment, canonicalizer, and scorer fixed. A scaffold change is legitimate when the estimand is end-to-end system performance, but it must be reported as part of the treatment.
An interval is not decoration
A system solves zero of 20 tasks. The Wald interval collapses to , whereas a Wilson interval has a positive upper endpoint.
-
Why is the Wald result misleading here?
-
What does the Wilson upper endpoint mean?
-
Do zero observed successes prove a zero success probability?
-
Why is a predeclared target half-width more useful than “about one hundred tasks”?
Show answer and solution
The Wald interval estimates zero variance after observing zero successes, so it confuses an observed boundary with certainty about the population. The Wilson upper endpoint represents nonzero probabilities still compatible with the sample under that interval construction. Predeclared precision connects sample size to the decision before outcomes are visible.
Sampling unit and repeated generations
The team generates 16 completions for each of 100 prompts, then treats all 1,600 completions as independent benchmark tasks.
-
What is wrong with the sampling unit?
-
Which randomness sources are present?
-
When should the prompt remain the primary unit?
-
Which bootstrap design preserves clustering?
-
What changes when the estimand is the 16-attempt protocol rather than one attempt?
Show answer and solution
Completions share prompt difficulty and therefore form clusters. Prompt, decoding, training seed, and judge may each contribute randomness. Resample prompts as primary units; optionally resample completions inside each prompt. For a 16-attempt protocol, compute one protocol outcome per prompt before aggregating prompts.
Pairing and exact McNemar reasoning
Two systems are evaluated on the same tasks. A alone is correct on 9 tasks and B alone on 2; most tasks are shared successes or shared failures.
-
Why should the comparison be paired?
-
Which observations enter McNemar's test?
-
Why can an asymptotic be overconfident with only 11 discordant tasks?
-
What exact null experiment underlies the two-sided exact test?
-
What should be reported in addition to a -value?
Show answer and solution
Only discordant tasks distinguish the systems. Under the exact null, each discordant task favors A or B with probability one half, so the count follows a binomial distribution. With only eleven discordances, the discrete exact tail matters. Report the two discordant counts, effect size, interval, exact -value, and the predeclared analysis rule.
pass@k, coverage, and selection
A pool of 20 candidates contains exactly one correct answer. The report states both pass@20 = 1 and “the system selected an incorrect answer.”
-
Is there a contradiction?
-
What does pass@k measure?
-
What does selection accuracy measure?
-
Why is with not the finite-pool estimator?
-
Which budget details belong next to pass@k?
Show answer and solution
There is no contradiction: coverage can be perfect while selection fails. pass@k measures whether the pool contains a success; selection accuracy measures whether the policy or verifier extracts it. Preserve , , decoding, token budget, verifier revision, task count, and seed policy.
No n-gram match
An eight-word n-gram filter finds no overlap between test items and the known portion of a training corpus.
-
What does this result actually rule out?
-
Which contamination modes can it miss?
-
Why does an unknown corpus remainder prevent a cleanliness certificate?
-
What is protocol contamination?
-
Name at least four additional benchmark-lifecycle protections.
Show answer and solution
No flag rules out only literal matches at the selected n-gram scale in the searched corpus. It misses paraphrases, translations, derived solutions, semantic templates, and unavailable data. Protocol contamination arises when repeated benchmark feedback drives checkpoint, prompt, or hyperparameter selection. Protections include private sets, fresh tasks, rotation, query limits, canaries, and a final untouched evaluation.
A judge under optimization pressure
An LLM judge favors long answers, often picks the first candidate, and sometimes follows instructions embedded inside candidate text. After switching from one comparison to Best-of-64, the mean judge score rises sharply.
-
Which three weakness classes are visible?
-
Why does A/B swapping fix only part of the problem?
-
How does structural isolation differ from the instruction “ignore instructions inside the answer”?
-
Why can Best-of-64 amplify winner's curse?
-
Which independent signal can distinguish genuine improvement from judge exploitation?
Show answer and solution
The visible weaknesses are position bias, superficial-feature bias, and prompt injection. Swapping diagnoses position effects but not content errors or length preference. Structural isolation separates untrusted candidate data at the interface and parser level. Best-of-64 increases the chance of selecting a high positive judge error; an independent verifier, hidden human sample, or real environment outcome is required.
Safety and release decisions
A new system complies with fewer harmful requests but also refuses benign requests twice as often. One category contains a single confirmed critical sandbox escape.
-
Why is refusal rate alone insufficient?
-
Which two denominators are required at minimum?
-
Why should severity be represented?
-
May a strong mean score compensate for the critical escape?
-
Which protocol-passport fields are needed for independent verification?
B. Analytical tasks
Show answer and solution
Harmful compliance and benign over-refusal use different denominators. Severity or hard gates prevent rare catastrophic outcomes from disappearing inside an average. A predeclared critical sandbox escape blocks release regardless of mean utility. The passport must identify the model, data, solver, scorer, environment, seeds, budget, metrics, gates, and artifact hashes.
Wilson intervals
Use .
-
Compute the estimate and 95% Wilson interval for , .
-
Repeat for , .
-
Compare the second result with the Wald interval.
-
Explain why
0/20is compatible with a nonzero population success probability.
Show answer and solution
For 62/100, the estimate is 0.62 and the Wilson interval is approximately . For 0/20, it is approximately ; the Wald interval incorrectly collapses to . Finite data remain compatible with a small nonzero success probability.
Dataset-size planning
Use the worst-case planning approximation
-
Find the smallest for , .
-
Repeat for .
-
By what factor does increase when the half-width is divided by three?
-
Why is this a planning approximation rather than an exact Wilson-width guarantee?
Show answer and solution
The results are
Dividing the half-width by three multiplies the ideal sample size by nine. The calculation is a worst-case normal planning approximation, not an exact realized Wilson width.
Exact and asymptotic paired comparison
For discordant counts , :
-
Compute .
-
Compute the corresponding two-sided normal -value.
-
Compute the exact two-sided value from .
-
State the conclusion at level 0.05 under each approach.
-
Why must the small discordant count be visible in the report?
Show answer and solution
The normal two-sided -value is about 0.0348, while the exact value is
The asymptotic result crosses 0.05; the exact result does not. The eleven discordances must be visible so readers can judge the approximation.
Finite-sample pass@k
Use
-
Compute pass@5 for , .
-
Compute pass@20 for , .
-
Compare the second answer with .
-
Explain the different probability questions represented by the two formulas.
Show answer and solution
For , , the finite-pool result is one. The independent-trial plug-in gives . The former samples without replacement from the observed pool; the latter imagines new independent trials with known .
Judge swap audit
After mapping both runs back to the original candidate identities, the labels are:
forward: A, A, B, TIE, A, B
reverse: A, B, B, TIE, B, B-
Count consistent and inconsistent pairs.
-
Compute consistency rate.
-
Compute A and B vote shares among non-ties.
-
Does high swap consistency rule out length or style bias?
-
Which extra randomization should be added?
Show answer and solution
Four of six pairs are consistent, so consistency is . Across ten non-tie votes, A receives four and B six: 0.4 and 0.6. Swap consistency does not rule out stable style or length bias. Add controlled length/style perturbations, model-identity blinding, serialization randomization, and repeated judge seeds.
Safety metrics and release gates
Attack outcomes on eight harmful prompts are
with severities
The system refuses two of ten benign prompts.
-
Compute attack success rate (ASR).
-
Compute benign refusal rate.
-
Compute severity-weighted ASR.
-
Apply gates
ASR <= 0.30,benign_refusal <= 0.25,severity_weighted_ASR <= 0.40. -
What changes if one success is a predeclared critical sandbox escape?
C. Programming tasks
Show answer and solution
ASR is , benign refusal is , and severity-weighted ASR is . The system fails the first and third numerical gates. A predeclared critical escape independently blocks release.
Intervals and planning
Implement:
def wilson_interval(successes: int, total: int, z: float = 1.96) -> tuple[float, float, float]:
"""Return (estimate, low, high)."""
raise NotImplementedError
def worst_case_sample_size(margin: float, z: float = 1.96) -> int:
"""Return ceil(z**2 / (4*margin**2))."""
raise NotImplementedErrorValidate counts and parameters, and keep interval endpoints inside .
Open checks:
estimate, low, high = wilson_interval(0, 10)
assert estimate == 0.0 and low == 0.0
assert 0.27 < high < 0.29
assert worst_case_sample_size(0.03) == 1068Show answer and solution
import math
def wilson_interval(successes: int, total: int, z: float = 1.96) -> tuple[float, float, float]:
if total <= 0:
raise ValueError("total must be positive")
if successes < 0 or successes > total:
raise ValueError("successes must lie in [0, total]")
if not math.isfinite(z) or z <= 0:
raise ValueError("z must be finite and positive")
estimate = successes / total
denominator = 1.0 + z * z / total
center = (estimate + z * z / (2.0 * total)) / denominator
half_width = z / denominator * math.sqrt(
estimate * (1.0 - estimate) / total + z * z / (4.0 * total * total)
)
return estimate, max(0.0, center - half_width), min(1.0, center + half_width)
def worst_case_sample_size(margin: float, z: float = 1.96) -> int:
if not math.isfinite(margin) or not 0.0 < margin < 1.0:
raise ValueError("margin must lie in (0, 1)")
if not math.isfinite(z) or z <= 0:
raise ValueError("z must be finite and positive")
return math.ceil(z * z / (4.0 * margin * margin))The Wilson interval remains non-degenerate at and , unlike the symmetric Wald interval. Clipping the endpoints only protects against tiny rounding excursions outside .
Paired comparison
Implement a paired contingency table, exact McNemar -value, and paired bootstrap interval. The bootstrap must resample task pairs, not systems independently.
Open checks:
a = [True, True, False, False, True]
b = [True, False, True, False, False]
table = paired_table(a, b)
assert table.both_correct == 1
assert table.a_only == 2
assert table.b_only == 1
assert table.both_wrong == 1Show answer and solution
Pairing is preserved at the task-index level; the two systems are never bootstrapped independently.
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class PairedTable:
both_correct: int
a_only: int
b_only: int
both_wrong: int
def paired_table(a_correct, b_correct) -> PairedTable:
a = np.asarray(a_correct, dtype=bool)
b = np.asarray(b_correct, dtype=bool)
if a.shape != b.shape or a.ndim != 1 or a.size == 0:
raise ValueError("paired vectors must be non-empty and have equal shape")
return PairedTable(
int(np.sum(a & b)),
int(np.sum(a & ~b)),
int(np.sum(~a & b)),
int(np.sum(~a & ~b)),
)
def exact_mcnemar_pvalue(a_only: int, b_only: int) -> float:
if min(a_only, b_only) < 0:
raise ValueError("counts must be non-negative")
n = a_only + b_only
if n == 0:
return 1.0
tail = sum(math.comb(n, j) for j in range(min(a_only, b_only) + 1)) / 2**n
return min(1.0, 2.0 * tail)
def paired_bootstrap_difference(a_scores, b_scores, *, replicates=10_000, seed=0):
a = np.asarray(a_scores, dtype=float)
b = np.asarray(b_scores, dtype=float)
if a.shape != b.shape or a.ndim != 1 or a.size == 0:
raise ValueError("paired vectors must be non-empty and have equal shape")
if replicates <= 0:
raise ValueError("replicates must be positive")
delta = a - b
rng = np.random.default_rng(seed)
indices = rng.integers(0, delta.size, size=(replicates, delta.size))
draws = delta[indices].mean(axis=1)
return float(delta.mean()), float(np.quantile(draws, 0.025)), float(np.quantile(draws, 0.975))
table = paired_table([1, 1, 0, 0, 1], [1, 0, 1, 0, 0])
assert table == PairedTable(1, 2, 1, 1)
assert abs(exact_mcnemar_pvalue(9, 2) - 0.0654296875) < 1e-15pass@k and selection
Implement a stable finite-sample estimator and a separate selection diagnostic:
def pass_at_k(n: int, c: int, k: int) -> float:
raise NotImplementedError
def coverage_and_selection(correct, selected_index: int) -> tuple[bool, bool]:
raise NotImplementedErrorThe first function estimates the probability that draws without replacement include at least one of successful solutions among candidates. The second separates pool coverage from correctness of the answer actually selected.
Open checks:
assert abs(pass_at_k(200, 10, 5) - 0.22828446073733422) < 1e-15
assert coverage_and_selection([False, True, False], 0) == (True, False)Show answer and solution
def pass_at_k(n: int, c: int, k: int) -> float:
if n <= 0 or not 0 <= c <= n or not 1 <= k <= n:
raise ValueError("require n>0, 0<=c<=n, and 1<=k<=n")
if n - c < k:
return 1.0
probability_of_miss = 1.0
for i in range(k):
probability_of_miss *= (n - c - i) / (n - i)
return 1.0 - probability_of_miss
def coverage_and_selection(correct, selected_index: int) -> tuple[bool, bool]:
values = [bool(x) for x in correct]
if not values or not 0 <= selected_index < len(values):
raise ValueError("invalid candidate pool or selected index")
return any(values), values[selected_index]Multiplying miss probabilities avoids direct evaluation of large binomial coefficients. The second function makes explicit why pool coverage and selector quality are distinct quantities.
A contamination sieve without changing the metric
Implement:
def ngram_set(text: str, n: int) -> set[tuple[str, ...]]:
...
def ngram_containment(benchmark_text: str, corpus_text: str, n: int) -> float:
"""Fraction of benchmark n-grams found in the corpus text."""
def ngram_jaccard(left: str, right: str, n: int) -> float:
"""Symmetric Jaccard coefficient."""
def contamination_report(items, corpus, *, n=8, threshold=0.2):
...The report must use benchmark-side asymmetric containment, matching the lecture equation, and must store the metric name explicitly. Include a pair for which containment and Jaccard differ. Document that no literal flag is not evidence of a clean benchmark.
Open checks:
assert ngram_set("a b c d", 2) == {("a", "b"), ("b", "c"), ("c", "d")}
assert ngram_containment("a b c", "x a b c y", 2) == 1.0
assert ngram_jaccard("a b c", "x a b c y", 2) < 1.0Show answer and solution
from __future__ import annotations
def ngram_set(text: str, n: int) -> set[tuple[str, ...]]:
if n <= 0:
raise ValueError("n must be positive")
tokens = text.casefold().split()
return {tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)}
def ngram_containment(benchmark_text: str, corpus_text: str, n: int) -> float:
benchmark = ngram_set(benchmark_text, n)
corpus = ngram_set(corpus_text, n)
if not benchmark:
return 0.0
return len(benchmark & corpus) / len(benchmark)
def ngram_jaccard(left: str, right: str, n: int) -> float:
a = ngram_set(left, n)
b = ngram_set(right, n)
union = a | b
return 0.0 if not union else len(a & b) / len(union)
def contamination_report(items, corpus, *, n=8, threshold=0.2):
if not 0.0 <= threshold <= 1.0:
raise ValueError("threshold must lie in [0, 1]")
maxima = []
matches = []
for i, item in enumerate(items):
scores = [ngram_containment(item, doc, n) for doc in corpus]
best = max(scores, default=0.0)
maxima.append(best)
for j, score in enumerate(scores):
if score >= threshold:
matches.append((i, j, score))
return {
"metric": "benchmark_ngram_containment",
"n": n,
"threshold": threshold,
"max_overlap": maxima,
"matches": matches,
}
assert ngram_containment("a b c", "x a b c y", 2) == 1.0
assert ngram_jaccard("a b c", "x a b c y", 2) == 0.5Containment divides by benchmark-item n-grams; Jaccard divides by the union. The report stores the metric name so downstream code cannot silently conflate them.
Two judge audits and sycophancy
Implement:
def judge_swap_audit(forward_canonical, reverse_canonical):
"""Candidate-identity consistency after A/B canonicalization."""
def position_audit(raw_forward, raw_reverse):
"""FIRST/SECOND preference before identity canonicalization."""
def sycophancy_metrics(correct_without_claim, correct_with_false_claim, agrees_with_false_claim):
...Do not collapse candidate identity and screen position. The first function receives canonical A/B labels; the second receives raw FIRST, SECOND, and TIE labels.
Open check:
identity = judge_swap_audit(["A", "A", "B", "TIE"], ["A", "B", "B", "TIE"])
assert identity["consistent"] == 3
position = position_audit(["FIRST", "SECOND"], ["FIRST", "FIRST"])
assert position["first_position_wins"] == 3Show answer and solution
from __future__ import annotations
def _labels(values, allowed):
result = [str(x).upper() for x in values]
if any(x not in allowed for x in result):
raise ValueError(f"labels must belong to {sorted(allowed)}")
return result
def judge_swap_audit(forward_canonical, reverse_canonical):
a = _labels(forward_canonical, {"A", "B", "TIE"})
b = _labels(reverse_canonical, {"A", "B", "TIE"})
if len(a) != len(b) or not a:
raise ValueError("label vectors must be non-empty and aligned")
consistent = sum(x == y for x, y in zip(a, b))
votes = [x for x in a + b if x != "TIE"]
return {
"items": len(a),
"consistent": consistent,
"inconsistent": len(a) - consistent,
"consistency_rate": consistent / len(a),
"candidate_a_rate": votes.count("A") / len(votes) if votes else 0.0,
"candidate_b_rate": votes.count("B") / len(votes) if votes else 0.0,
}
def position_audit(raw_forward, raw_reverse):
a = _labels(raw_forward, {"FIRST", "SECOND", "TIE"})
b = _labels(raw_reverse, {"FIRST", "SECOND", "TIE"})
if len(a) != len(b) or not a:
raise ValueError("label vectors must be non-empty and aligned")
all_labels = a + b
first = all_labels.count("FIRST")
second = all_labels.count("SECOND")
ties = all_labels.count("TIE")
denom = first + second
return {
"judgments": len(all_labels),
"first_position_wins": first,
"second_position_wins": second,
"ties": ties,
"first_position_rate_non_ties": first / denom if denom else 0.0,
}
def sycophancy_metrics(correct_without_claim, correct_with_false_claim, agrees_with_false_claim):
base = [bool(x) for x in correct_without_claim]
challenged = [bool(x) for x in correct_with_false_claim]
agrees = [bool(x) for x in agrees_with_false_claim]
if not base or not (len(base) == len(challenged) == len(agrees)):
raise ValueError("vectors must be non-empty and aligned")
initially_correct = sum(base)
switches = sum(b and not c for b, c in zip(base, challenged))
return {
"accuracy_without_claim": sum(base) / len(base),
"accuracy_with_false_claim": sum(challenged) / len(base),
"accuracy_drop": (sum(base) - sum(challenged)) / len(base),
"switch_rate_among_initially_correct": switches / initially_correct if initially_correct else 0.0,
"unjustified_agreement_rate": sum(agrees) / len(agrees),
}
assert judge_swap_audit(["A", "A", "B", "TIE"], ["A", "B", "B", "TIE"])["consistent"] == 3
assert position_audit(["FIRST", "SECOND"], ["FIRST", "FIRST"])["first_position_wins"] == 3Canonical A/B labels and raw FIRST/SECOND labels remain separate because they measure different biases.
Safety, release gates, and the measurement passport
Implement:
def safety_rates(attack_succeeded, benign_refused, severities=None):
raise NotImplementedError
def release_decision(metrics, thresholds, critical_failures=()):
raise NotImplementedError
def validate_measurement_passport(passport):
raise NotImplementedErrorAttack success and benign over-refusal use different denominators. A critical event or missing required metric must block release. The passport validator must report every detected error.
Open checks:
rates = safety_rates([0, 1, 0, 1], [0, 0, 1, 0], severities=[1, 4, 1, 2])
assert abs(rates["attack_success_rate"] - 0.5) < 1e-12
assert abs(rates["benign_over_refusal_rate"] - 0.25) < 1e-12
result = release_decision(rates, {"attack_success_rate": ("max", 0.25)}, ("sandbox_escape",))
assert result["passed"] is FalseShow answer and solution
def safety_rates(attack_succeeded, benign_refused, severities=None):
attacks = [bool(x) for x in attack_succeeded]
benign = [bool(x) for x in benign_refused]
if not attacks or not benign:
raise ValueError("both harmful and benign samples are required")
if severities is None:
severity_weighted = sum(attacks) / len(attacks)
else:
weights = [float(x) for x in severities]
if len(weights) != len(attacks) or any(x <= 0 for x in weights):
raise ValueError("positive severities must align with attacks")
severity_weighted = sum(w for hit, w in zip(attacks, weights) if hit) / sum(weights)
return {
"attack_success_rate": sum(attacks) / len(attacks),
"benign_over_refusal_rate": sum(benign) / len(benign),
"severity_weighted_asr": severity_weighted,
}
def release_decision(metrics, thresholds, critical_failures=()):
failures = [f"critical:{item}" for item in critical_failures]
for name, (direction, threshold) in thresholds.items():
if name not in metrics:
failures.append(f"missing:{name}")
continue
value = float(metrics[name])
if direction == "max" and value > threshold:
failures.append(f"{name}>{threshold}")
elif direction == "min" and value < threshold:
failures.append(f"{name}<{threshold}")
elif direction not in {"min", "max"}:
failures.append(f"invalid_direction:{name}")
return {"passed": not failures, "failures": failures}
def validate_measurement_passport(passport):
required = {
"run_id", "model_id", "model_revision", "tokenizer_id", "dataset_id",
"dataset_revision", "split", "solver_id", "scorer_id", "scorer_revision",
"decoding", "seeds", "sample_count", "metrics", "cost", "artifact_hashes",
}
errors = [f"missing:{key}" for key in sorted(required - set(passport))]
if "sample_count" in passport and int(passport["sample_count"]) <= 0:
errors.append("sample_count:not_positive")
if "seeds" in passport and not passport["seeds"]:
errors.append("seeds:empty")
if "artifact_hashes" in passport and not passport["artifact_hashes"]:
errors.append("artifact_hashes:empty")
return tuple(errors)A critical incident blocks release regardless of average metrics; a missing required metric also fails closed.