Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Chapter 13. Measure your measurement


What you need before this chapter

You need four things. Three of them you already have, and the fourth is a page you can open in another tab and keep open.

1. Chapter 11, where a model first took a test. You saw that a model’s answer has to be turned into a choice by a rule you write, and that writing that rule is a decision. If you want the mechanics again, Chapter 11 is the place.

2. Chapter 12, where a score turned out to be a random variable. You measured 25.0% on twenty questions and then measured the width of that claim: a 95% interval running from 6.0% to 44.0%. This chapter picks up where that left off. Chapter 12.

3. Arithmetic you can do on a phone. Adding, subtracting, multiplying, dividing, a few square roots, and raising a half to a small whole power. Two symbols you may not have met arrive later, “mod” in Formula 13.6 and the “choose” sign in Formula 13.11, and each one is built from nothing at the place it appears. Nothing harder than that turns up anywhere in this chapter.

4. These sections of the Math Toolkit. Each link goes to a page that starts from nothing and works arithmetic you can check. Open the ones you need when you need them.

What this chapter uses it forToolkit section
a letter standing for a numberSection 1, letters
x1x_1, x2x_2, xix_i: “the first one, the second one, the ii-th one”Section 2, subscripts
two things written side by side, which means multiplySection 3, multiplication
the fraction bar, which means divideSection 4, the fraction bar
(12)5\left(\tfrac{1}{2}\right)^5: a half multiplied by itself five timesSection 5, exponents
what a log-probability isSection 8, logarithms
the square root in every standard errorSection 9, square roots
\sum, which means “add these up”Section 10, sigma notation
the standard deviation, and why it divides by one fewerSection 10.4, standard deviation
turning 0.25 into 25% and backSection 11, percentages
percentage points, which are not per centSection 11, percentage points
“5 out of 20” as a number between 0 and 1Section 12, proportions
reading the bars in the figuresSection 13, reading a graph
what 25=322^5 = 32 has to do with coin tossesSection 18, powers of two
writing an interval as (+5.5,+44.5)(+5.5, +44.5), and reading p<0.05p < 0.05Section 19, inequalities and intervals
Δ\Delta, the capital Greek D this chapter uses for a differenceSection 20, Greek letters

If you have not met the six-part shape every formula in this book arrives in, two minutes on how a formula is presented will pay for itself many times over in this chapter. There are thirteen formulas here and every one of them has the same six parts, in the same order.


The setup code

Run this once. Everything else in the chapter uses what it loads.

Two things are worth saying about this block before you read it. Every line has a comment, and the comments are for you, not for the computer. And nothing here runs a language model. The models were run in the lab, on the machine described in the datasets appendix, and they wrote their results into files. This chapter reads those files. That means every number you see below is a number somebody measured, and it means you can reproduce this chapter on a laptop with no graphics card in about a second.

# json reads the result files the lab wrote. Every number in this chapter comes out of one of them.
import json
# math gives us the square root, and the "choose" counting function McNemar's test needs.
import math

# The folder holding the lab's result files. Every chapter in this book uses this same folder.
results_folder = "lab/out/"

# The three scoring procedures applied to the 0.5B model. Written by lab/we6b_eval_debiased.py
three_procedures = json.load(open(results_folder + "we6b_eval_debiased.json"))

# The same question bank scored on three model sizes. Written by lab/lab4_size_ladder.py
size_ladder = json.load(open(results_folder + "lab4_size_ladder.json"))

# The 1.5B against the 3B on the same questions. Written by lab/we7_paired_comparison.py
paired_comparison = json.load(open(results_folder + "we7_paired.json"))

# How many questions are in the bank, and how many options each question has.
question_count = 20
option_count = 4

print("questions in the bank:", question_count)
print("options on each question:", option_count)
print("fields in the three-procedures file:", len(three_procedures))
print("models on the size ladder:", len(size_ladder))
print("fields in the paired-comparison file:", len(paired_comparison))

Output:

questions in the bank: 20
options on each question: 4
fields in the three-procedures file: 8
models on the size ladder: 3
fields in the paired-comparison file: 9

json.load(open(...)) opens a file and turns its contents into something Python can read. len(...) counts how many things are inside. The three files hold 8, 3 and 9 pieces of information each, which is a small amount of data to carry a whole chapter, and that is part of the point.


Where this chapter starts: a Tuesday in Bakersfield

It is a Tuesday in July. You step outside in Bakersfield and the air has that flat, heavy quality you know. You pull out your phone and look up the air quality for the day.

You get a number.

Now ask a harder question. What is that number a number of? Before anyone can answer, they have to tell you four other things. Which pollutant is being reported, because particle pollution and ozone are different substances measured by different instruments. Over what window, because the rule for ozone reads an eight-hour stretch and the rule for fine particles reads a full day. Measured where, because a monitor beside a freeway and a monitor in a field outside Shafter are breathing different air. And reported how, because a scale that combines several pollutants into one headline figure has to decide what to do when they disagree.

Change any one of those four decisions and the headline number changes. Nobody lied. Every version was computed correctly. The air did not change between one version and the next. What changed was the procedure, and the number belongs to the procedure at least as much as it belongs to the air.

This is not a complaint about air monitoring. Air monitoring is a serious, careful field, and the reason it publishes its rules in public documents that run to hundreds of pages is precisely that everybody in it understands the point you are about to learn. The rules are written down so that the number means something.

Now hold that thought and look at how artificial intelligence gets reported. A model scores some per cent on some benchmark. The score appears in a press release, on a leaderboard, in a purchasing decision, in a news story. The rule that produced it is usually one line in an appendix, if it appears at all.

This chapter takes one small model, one set of twenty questions, and three different rules for marking the answers. All three rules are defensible. All three appear in published research. Nothing about the model changes between them. Nothing about the questions changes.

The three numbers are 25.0%, 35.0% and 15.0%.

You are going to compute all three, work out which of them, if any, deserves to be printed, and then find out something worse: that the answer is none of them.


Learning objectives

By the end of this chapter you will be able to:

  1. Explain why an evaluation score is a property of the scoring procedure as much as of the model, and give three measured numbers from one model and one question bank that prove it.

  2. Diagnose position bias in a scoring procedure by comparing how often each option was chosen against how often each option was correct, and compute the over-choice ratio.

  3. Apply two standard debiasing fixes, scoring the option text with length normalisation and taking a majority over cyclic rotations, and say what each one does and does not repair.

  4. Compare two models on the same questions using a paired analysis, build the 95% interval for the difference, and say in numbers how much the pairing bought you.

  5. Run McNemar’s exact test on a two-by-two table of paired results, interpret a p-value of 0.0625 correctly, and explain why more questions, not a stronger claim, is the fix.


This lesson at a glance


The vocabulary of this chapter

Every technical term this chapter uses, defined before it is used. Read the table once now. You do not have to memorise it; you have to know it exists, so that when a word appears in a sentence you can come back and check it.

TermWhat it means, in one line
scoring procedureThe complete rule for turning what a model produced into a right or a wrong.
answer extractionThe part of a scoring procedure that decides which option the model picked.
naive letter scoringAsking which of the letter tokens A, B, C, D the model rates highest, and calling that its answer.
log-probabilityThe natural logarithm of a probability. A negative number. Bigger, meaning closer to zero, means more likely.
length normalisationDividing a total score by how many tokens it was added up over, so a long option is not punished for being long.
chance levelThe score somebody would average by guessing at random. It belongs to a procedure, not to a question: with four options and one asking it is one in four, and Worked example 13.2 shows it changing when the procedure changes.
position biasA scoring procedure rewarding an option for where it sits in the list rather than for being right.
over-choice ratioHow many times more often an option was chosen than an unbiased procedure would choose it.
cyclic rotationMoving every option along one position and wrapping the last one round to the front.
majority voteAsking the same question several times and keeping the answer that won most of the askings.
consistency (under rotation)Whether the four rotations of one question all agreed with each other about whether the model was right.
debiasing gapThe naive score minus the debiased score, in percentage points.
paired comparisonComparing two models question by question, because they answered the same questions.
unpaired comparisonTreating the two models’ scores as two separate samples, which throws the pairing away.
contingency tableA two-by-two count of how two models agreed and disagreed on the same items.
concordant pairA question both models got right, or both got wrong.
discordant pairA question exactly one of them got right.
standard deviationHow spread out a set of numbers is around their own average.
standard errorHow much a measured number would wobble if you re-ran the whole measurement.
Wald intervalThe textbook confidence interval: the measured number, plus or minus 1.96 standard errors. Built in Chapter 12, Definition 12.11, and known to be a poor fit at small sample sizes.
null hypothesisThe dull explanation a test tries to rule out: there is no real difference.
p-valueAssuming the null hypothesis is true, the chance of a result at least as lopsided as yours.
McNemar’s exact testThe significance test built for a paired yes-or-no comparison.
statistical significanceThe verdict that a result is too lopsided to blame on sampling wobble, conventionally p<0.05p < 0.05, which is read “p is less than nought point nought five”. See the toolkit on inequalities.
failure taxonomyA naming of the kinds of failure a model system can have, so you can say which one you are looking at.

13.1 A score belongs to a procedure, not to a model

Intuition

Think about a driving test. Two examiners, one candidate, one car, one route. Examiner A marks you on whether you completed the route safely. Examiner B marks you on a checklist of forty-three specific manoeuvres. Examiner C sits in the back, says nothing, and marks you on whether a passenger would feel comfortable.

You drove exactly the same way for all three. You could pass one and fail another. Nobody cheated. The examiners measured different things and called them all “the driving test.”

Now change one word. Instead of a candidate, a language model. Instead of a route, twenty multiple-choice questions. Instead of three examiners, three rules for reading the model’s output and deciding which option it picked.

Here is the part that catches people. A model does not “pick” an option. A language model produces a probability for every one of the 151,936 tokens it knows, for the next position in the text. That is all it produces. Nowhere in that output is there a letter circled in pencil. Somebody has to write a rule that turns 151,936 numbers into one of four letters, and that somebody is you.

The rule is not a detail. The rule is half the measurement.

There are at least three sensible rules, and all three are in use in published research:

  1. Ask about the letter. Show the model the question with its four options and see which of the four letter tokens A, B, C and D it rates most likely to come next.

  2. Ask about the words. Ignore the letters entirely. Score the actual text of each option, then divide by how long that option is, and take the best.

  3. Ask four times. Move the options around the page so that every option gets a turn in every slot, ask the question once per arrangement, and keep the answer that wins the most askings.

Every one of the three is defensible. You could write a paragraph defending any of them and a reviewer would nod. They produce three different numbers on the same model and the same questions, and the distance between the highest and the lowest is twenty percentage points.

That is the whole chapter in one paragraph. The rest is working out what to do about it.

The mathematics

There is a formula for accuracy, and you met it in Chapter 11. This chapter writes it again with one thing added: a symbol for the procedure. That single extra symbol is the idea of the chapter made visible.

Python

The three accuracies are in the file. Pull them out, turn them into counts of questions, and measure the spread.

# Pull the three accuracies out of the file, one line each.
naive_accuracy = three_procedures["naive_accuracy"]
option_text_accuracy = three_procedures["option_text_accuracy"]
rotation_accuracy = three_procedures["rotation_majority_accuracy"]

# Turn each proportion into a count of questions. "7 of 20" is easier to picture than "0.35".
naive_correct = round(naive_accuracy * question_count)
option_text_correct = round(option_text_accuracy * question_count)
rotation_correct = round(rotation_accuracy * question_count)

print("procedure 1, the letter      :", round(naive_accuracy * 100, 1), "percent  =", naive_correct, "of", question_count)
print("procedure 2, the option text :", round(option_text_accuracy * 100, 1), "percent  =", option_text_correct, "of", question_count)
print("procedure 3, four rotations  :", round(rotation_accuracy * 100, 1), "percent  =", rotation_correct, "of", question_count)

# The distance from the highest of the three to the lowest, in percentage points.
highest_accuracy = max(naive_accuracy, option_text_accuracy, rotation_accuracy)
lowest_accuracy = min(naive_accuracy, option_text_accuracy, rotation_accuracy)
spread_in_points = round((highest_accuracy - lowest_accuracy) * 100, 1)

print("highest of the three:", round(highest_accuracy * 100, 1), "percent")
print("lowest of the three :", round(lowest_accuracy * 100, 1), "percent")
print("spread              :", spread_in_points, "percentage points")

Output:

procedure 1, the letter      : 25.0 percent  = 5 of 20
procedure 2, the option text : 35.0 percent  = 7 of 20
procedure 3, four rotations  : 15.0 percent  = 3 of 20
highest of the three: 35.0 percent
lowest of the three : 15.0 percent
spread              : 20.0 percentage points

Several things in that block are worth naming, because they come back all chapter.

three_procedures["naive_accuracy"] reaches into the loaded file and takes out the value stored under the label naive_accuracy. The square brackets with a word inside them are how you ask a Python dictionary for one of its values. A dictionary is a bundle of labelled values, and it is how these result files are shaped.

round(naive_accuracy * 100, 1) does two things in one step. It multiplies the proportion by 100 to turn it into a percentage, and the 1 says keep one digit after the decimal point. Without the rounding you would sometimes see 35.000000000000004 on your screen, which is a fact about how computers store decimals and is taken apart in Chapter 6.

max(...) and min(...) give the largest and smallest of the numbers you hand them.

Now read the output slowly. Five, seven and three. Out of twenty. Those are the raw counts, and they are so small that the difference between the best and worst procedure is four questions. Four questions moved, and a reader of the headline number would come away with either “this model is worse than guessing” or “this model beats guessing by ten points.”

Both sentences would be true. Neither is a fact about the model.


13.2 A score that lands exactly on chance

Intuition

Here is a habit worth building, and it costs nothing.

When you see a score on a multiple-choice test, work out what a person who knew nothing would average, and compare. On a four-option test, somebody who closed their eyes and pointed would get about one question in four. One in four is 25%.

The naive letter procedure scored the 0.5B model at 25.0%.

Not near 25%. Not 24% or 26%. Exactly the number a coin-flipping, dice-rolling, eyes-closed guesser would average. Five questions out of twenty, which is precisely 20÷420 \div 4.

There are two possible readings, and a statistician’s instinct is to check the second one first.

Reading one: the model knows nothing. That is entirely possible. It is a 494,032,768 parameter model, which is small, and the questions are about statistics. A model that knows nothing would score around chance. This reading is unremarkable and might well be true.

Reading two: the measuring instrument is broken. A thermometer that reads 20 degrees in a freezer and 20 degrees in an oven is not telling you the freezer and the oven are the same temperature. It is telling you the thermometer is not connected to anything. A scoring procedure that returns exactly the guessing rate might be measuring something that has nothing to do with whether the model knows statistics.

Reading two is worth checking first for a simple reason: if it is right, reading one has not been tested at all. You cannot conclude the model is ignorant from an instrument that is not plugged in.

A score sitting exactly on chance is not a result. It is a question. And the question is not “how much does this model know?” It is “what was that procedure actually measuring?”

This chapter answers it, and the answer is worse than you would guess.

The mathematics

Two small formulas, and both of them are arithmetic rather than measurement. That distinction matters and the figures in this chapter keep it visible: the chance level is drawn as a reference, never as a bar of measured data.

Python

# Chance is arithmetic, not a measurement: one option out of four.
chance_level = 1 / option_count
print("chance level with", option_count, "options:", chance_level, "=", round(chance_level * 100, 1), "percent")

# How many of the 20 a pure guesser would get right on average.
expected_correct_by_guessing = chance_level * question_count
print("a guesser averages        :", expected_correct_by_guessing, "of", question_count)
print("the naive procedure scored:", naive_correct, "of", question_count)
print("difference                :", naive_correct - expected_correct_by_guessing, "questions")

# Say out loud what that means.
if naive_accuracy == chance_level:
    print("the naive procedure landed exactly on chance")

Output:

chance level with 4 options: 0.25 = 25.0 percent
a guesser averages        : 5.0 of 20
the naive procedure scored: 5 of 20
difference                : 0.0 questions
the naive procedure landed exactly on chance

1 / option_count is division, written with a forward slash. The single / gives a decimal answer, so 1 / 4 is 0.25 rather than 0.

The if on the second-to-last line is a condition. The indented line beneath it runs only when the thing after if is true. == with two equals signs asks “are these the same?”, which is a different question from = with one equals sign, which means “store this value under this name”. Mixing the two up is the single most common early Python mistake, and Python will tell you about it.

Read the output once more. difference: 0.0 questions. This model, marked this way, performed indistinguishably from a person who did not read the questions.

That sentence has two halves. Most people read the first half and stop. The second half, marked this way, is where the next section lives.


13.3 The diagnosis: the model was answering A

Intuition

Here is how the naive procedure works, in full, with nothing hidden.

You build a prompt. It holds the question, then the four options each on its own line with its letter in front, then the word Answer: and nothing after it. You hand that to the model and ask for the probabilities of the next token. Out come 151,936 numbers. You ignore 151,932 of them. You keep the four belonging to the tokens A, B, C and D, and whichever of those four is biggest is recorded as the model’s answer.

Now ask a question nobody normally asks. Across all twenty questions, how often did each letter win?

If the procedure were measuring knowledge, and the model had some, the answers would follow the answer key, roughly. If the model knew nothing at all and was genuinely undecided, the four letters would come up about five times each. Either way you would expect some spread.

Here is what happened.

The model answered A on 16 of the 20 questions. B twice. C never once. D twice.

And the correct answers in the bank are spread B eight times, C eight times, D twice, A twice.

Put those two lines side by side and the whole thing falls apart. The model reached for the letter A whether the question was about the mean of four numbers or about what a p-value is. The answer key rewarded A on two questions out of twenty. So the procedure gave the model credit almost by accident, on whichever questions happened to have A as the answer, plus a few lucky landings elsewhere.

That is what 25.0% was made of. Not knowledge. A habit.

And notice the direction the bias points. The letter C is the right answer on eight of the twenty questions, which is 40% of the bank, and the model chose C zero times. A procedure that can never select 40% of the correct answers has a ceiling on it that has nothing to do with what the model knows.

This has a name. When a scoring procedure rewards an option for where it sits rather than for being right, that is position bias. It is a bias in the statistical sense: a systematic push in a fixed direction, which more data does not remove. Ask this model two thousand questions instead of twenty and it will still reach for A. A bigger sample makes a random wobble smaller. It does nothing to a systematic push.

The mathematics

The arithmetic here is small on purpose. The point is not the difficulty; it is that nobody looks.

Two panels. The left panel is a horizontal bar chart of where the 494,032,768 parameters of Qwen2.5-0.5B-Instruct live, with embeddings at 28 per cent, the MLP blocks at 64 per cent and attention at 9 per cent. The right panel, which is the one this chapter needs, is a vertical bar chart of how many times the naive scoring procedure made the model choose each letter out of twenty questions: A sixteen, B two, C zero, D two, against a dashed grey reference line at five, marking what an unbiased scorer would average on each option.

Figure 1:The right panel is the one this chapter is about. Each bar is one of the four options, and its height is how many times out of twenty the naive letter procedure recorded that option as the model’s answer. The dashed grey line sits at 5, which is what a procedure with no position preference would average on each option, from Formula 13.3. Option A reaches 16, which is 3.2 times the line. Option C does not appear at all, and C was the correct answer on eight of the twenty questions. The left panel belongs to Chapter 3 and is included because the two panels were drawn by the same script, lab/make_figures.py, from lab/out/we3_params_quant.json and lab/out/we6b_eval_debiased.json.

Python

# How often the naive procedure made the model 'choose' each letter.
letter_counts = three_procedures["naive_letter_counts"]

# How often each letter was the right answer. Source: _research/00-lab-verified-findings.md
# section 8, and book/appendix/datasets.md section 3.2, where all 20 answers are listed.
answer_key_counts = {"A": 2, "B": 8, "C": 8, "D": 2}

# What a scorer with no preference between positions would average on 20 questions.
expected_per_letter = question_count / option_count

print("letter   model chose   answer key   an unbiased scorer would average")
for one_letter in ["A", "B", "C", "D"]:
    print("  " + one_letter + "            " + str(letter_counts[one_letter]).rjust(2)
          + "           " + str(answer_key_counts[one_letter]).rjust(2)
          + "            " + str(expected_per_letter))

# Find the most-chosen letter with a plain loop, no shortcuts.
most_chosen_letter = "A"
for one_letter in ["A", "B", "C", "D"]:
    if letter_counts[one_letter] > letter_counts[most_chosen_letter]:
        most_chosen_letter = one_letter

biggest_count = letter_counts[most_chosen_letter]
share_of_answers = biggest_count / question_count
print("most-chosen option:", most_chosen_letter, "taken", biggest_count, "times of", question_count)
print("that is", round(share_of_answers * 100, 1), "percent of the answers")
print("an unbiased scorer would average", round(100 / option_count, 1), "percent on each option")
print("times more often than expected:", round(biggest_count / expected_per_letter, 1))

Output:

letter   model chose   answer key   an unbiased scorer would average
  A            16            2            5.0
  B             2            8            5.0
  C             0            8            5.0
  D             2            2            5.0
most-chosen option: A taken 16 times of 20
that is 80.0 percent of the answers
an unbiased scorer would average 25.0 percent on each option
times more often than expected: 3.2

The for loop repeats its indented lines once for each item in the list ["A", "B", "C", "D"]. On the first pass one_letter holds "A", on the second "B", and so on. str(...) turns a number into text so it can be glued to other text with +, and .rjust(2) pads a short piece of text on the left so the columns line up.

The second loop finds the largest of the four counts by hand. It starts by assuming A is the winner, then checks each letter in turn and moves the title whenever it finds a bigger count. There is a one-line Python shortcut for this, and this book uses the loop, because the loop is the thing you can read out loud.

Now the table. Read it column by column, not row by row.

Column two is what the procedure produced. Column three is the answer key. They have almost nothing to do with each other. The procedure’s favourite option is the key’s rarest. The key’s most common options, B and C with eight each, were chosen twice and never.

Column four is the flat line: 5.0 on every row, what a procedure with no position preference would average. The gap between column two and column four is the bias, in questions.

A last observation, and it is the one that should make you uncomfortable. If this run had been a paper, the sentence in the abstract would have been “the model performs at chance on undergraduate statistics.” That sentence is well formed, it is supported by the number, and it would survive review. It is also not supported by anything that happened, because the instrument that produced it was measuring a preference for a letter.


13.4 Fix one: score the words, not the letter

Intuition

The diagnosis suggests its own repair. The trouble came from asking about the letters. So stop asking about the letters.

Instead of “which of A, B, C or D do you rate highest”, ask something the letters play no part in. Take the prompt, and for each option in turn, stick the actual words of that option on the end and ask the model how likely that continuation was. Then take whichever option the model found most likely.

Question 1 of the bank is “The mean of 2, 4, 4, 6 is:” with options 3, 4, 5 and 6. Under this procedure the model is not being asked about the letter B. It is being asked how likely the text 4 is to follow the question. The position of 4 on the page has stopped mattering, because the position is no longer part of what you score.

There is one complication, and it is the interesting part.

Options are not all the same length. Question 14 asks what a p-value is, and one option reads “The effect size”, three words, while another reads “The probability of data this extreme if the null is true”, eleven words. Every extra token you score is another number below zero being added to the total. A longer option accumulates a worse total merely for being longer, even when the model likes every word of it.

So a procedure that took the best total would pick short options almost every time. That is a new bias, substituted for the old one.

The repair for the repair is to divide by the length. Instead of the total score for an option, take the average score per token: the total divided by how many tokens the option took. Now a long option and a short option are on the same footing, because both are being judged on how good their typical token was rather than on how many tokens they had.

That step has a name, length normalisation, and it is one line of code and a genuinely important idea. Whenever you compare totals built from different numbers of pieces, you have to decide whether you are comparing totals or averages, and the two can rank things in opposite orders.

The mathematics

Python

The loops below add up two short lists by hand, one number at a time, so you can see what the \sum in Formula 13.5 does. Then the last two lines print the real measured result.

# A made-up example, invented for practice, to show what dividing by the length does.
# These log-probabilities are not measurements. Nothing above this line produced them.
short_option_log_probabilities = [-1.2, -0.9]
long_option_log_probabilities = [-1.2, -0.9, -1.1, -0.8, -1.0]

# Add up the short option's log-probabilities with an explicit loop.
short_option_total = 0.0
for one_value in short_option_log_probabilities:
    short_option_total = short_option_total + one_value

# Add up the long option's log-probabilities the same way.
long_option_total = 0.0
for one_value in long_option_log_probabilities:
    long_option_total = long_option_total + one_value

print("short option, total    :", round(short_option_total, 4), "over", len(short_option_log_probabilities), "tokens")
print("long option, total     :", round(long_option_total, 4), "over", len(long_option_log_probabilities), "tokens")
print("short option, per token:", round(short_option_total / len(short_option_log_probabilities), 4))
print("long option, per token :", round(long_option_total / len(long_option_log_probabilities), 4))

# Now the real measurement, from the file.
option_text_counts = three_procedures["option_text_counts"]
print("real run, option-text procedure:", round(option_text_accuracy * 100, 1), "percent")
print("real run, which positions it chose:", option_text_counts)

Output:

short option, total    : -2.1 over 2 tokens
long option, total     : -5.0 over 5 tokens
short option, per token: -1.05
long option, per token : -1.0
real run, option-text procedure: 35.0 percent
real run, which positions it chose: {'A': 7, 'B': 11, 'C': 2, 'D': 0}

short_option_total = 0.0 sets up an empty running total before the loop starts. Then short_option_total = short_option_total + one_value adds the next number to it. Read that line right to left: take what the total currently holds, add the new value, store the answer back under the same name. A loop that does this is an accumulator, and it is how every \sum in this book is carried out in code. len(...) counts how many items are in a list.

Now the measured result, and it comes in two halves.

The good half. The score went from 25.0% to 35.0%. Seven questions out of twenty instead of five. That is two more questions, and it is ten percentage points above the chance level.

The half nobody prints. Look at where this procedure’s answers landed: A seven times, B eleven times, C twice, D never. Against an answer key of A two, B eight, C eight, D two.

It is a different bias. It is not a smaller one.

This procedure now over-chooses B, by a ratio of 11÷5=2.211 \div 5 = 2.2. It still barely touches C, choosing it twice where the key rewards it eight times. It never once selects D. An unbiased procedure would land on each option about five times; this one produced 7, 11, 2 and 0.

So the honest report on fix one is: the score improved, and the instrument is still bent. It is bent in a new direction, which makes it harder to notice, because 35% does not sit suspiciously on the chance line the way 25% did.

That is the most dangerous kind of broken instrument: the one whose reading looks plausible.


13.5 Fix two: move the options around

Intuition

The second fix goes at the problem from the other end, and it is the more powerful of the two.

If you suspect the model is answering based on where an option sits, then move the options and see whether its answer follows. That is all. It is exactly what a careful teacher does when they suspect a student copied: they hand out the same test with the questions in a different order.

Here is the procedure in full. Take one question. Ask it four times. On the first asking, leave the options where they are. On the second, move every option up one place and wrap the top one round to the bottom, so what was option B is now option A. On the third, move them again. On the fourth, again. After four moves every option has occupied every position exactly once. That sliding-and-wrapping move is called a cyclic rotation.

Each time, you write down whether the model landed on the correct option, wherever the correct option happens to be sitting now. Then you take the majority: if the model was right on at least three of the four askings, the question counts as correct.

A model that knows an answer will follow that answer around the page. If it knows the mean of 2, 4, 4 and 6 is 4, it should select 4 whether 4 is sitting in slot A, B, C or D. A model that is reaching for a position will be right exactly once in four, because the correct option passes through its favourite slot exactly once during the rotation.

Read that last sentence again, because it is the sharp edge of this section. A model that always answers A scores one out of four on every single question under rotation, and one out of four is below the three-out-of-four majority, so it fails every question. Rotation does not soften position bias. It removes the reward for it entirely.

Applied to all twenty questions, the score came out at 15.0%. Three questions of twenty. Below the naive 25.0%. Below the option-text 35.0%.

Here is where you have to be careful, and it is the chapter’s own lesson turned back on the chapter. It is tempting to write “and 15.0% is below the chance level of 25%.” That sentence is wrong. The 25% chance level belongs to a procedure that asks each question once. This procedure asks each question four times and demands at least three hits, which is a much harder target for a guesser. A guesser facing this rule averages about 5.1%, not 25%, and Worked example 13.2 does that arithmetic in full. So 15.0% is in fact above chance for the procedure that produced it.

Changing the rule changed the chance level too. A chance level is a property of a procedure, in exactly the way a score is, and comparing a score from one procedure against a chance level computed for another is the same mistake in a smaller costume.

And the second number this procedure produces is the one that ends the argument. Of the twenty questions, the four rotations agreed with each other on exactly one.

The mathematics

Two formulas and one worked example. The first formula says where the correct answer moves to. The second is the punchline. The worked example settles what a guesser would score under this rule, which is the line the 15.0% has to be read against.

Python

# Question 1 of the bank, with its four options in their printed order.
option_list = ["3", "4", "5", "6"]
letter_list = ["A", "B", "C", "D"]

# The right answer is 4, which sits in position 1, because Python counts from 0.
correct_position = 1

for rotation_number in range(option_count):
    # Rotate left: take everything from rotation_number onward, then everything before it.
    rotated_options = option_list[rotation_number:] + option_list[:rotation_number]
    # Work out where the right answer has moved to. The % sign is remainder after division.
    new_correct_position = (correct_position - rotation_number) % option_count
    print("rotation", rotation_number, rotated_options,
          " the right answer is now option", letter_list[new_correct_position])

# The two measured results of doing this to all 20 questions.
consistent_questions = three_procedures["fully_consistent_questions"]
print("score under majority-of-four-rotations:", round(rotation_accuracy * 100, 1), "percent")
print("questions where all four rotations agreed:", consistent_questions, "of", question_count)
print("that is", round(100 * consistent_questions / question_count, 1), "percent consistent")

Output:

rotation 0 ['3', '4', '5', '6']  the right answer is now option B
rotation 1 ['4', '5', '6', '3']  the right answer is now option A
rotation 2 ['5', '6', '3', '4']  the right answer is now option D
rotation 3 ['6', '3', '4', '5']  the right answer is now option C
score under majority-of-four-rotations: 15.0 percent
questions where all four rotations agreed: 1 of 20
that is 5.0 percent consistent

Three pieces of Python are new here.

option_list[rotation_number:] takes a slice of a list: everything from that position to the end. option_list[:rotation_number] takes everything before that position. Gluing the two together with + performs the rotation. On the second pass, rotation_number is 1, so the first slice is ['4','5','6'], the second is ['3'], and together they make ['4','5','6','3'].

% between two whole numbers is the remainder after division, which is the mod\bmod from Formula 13.6. In Python, (1 - 2) % 4 gives 3, with the wrapping already handled for you.

range(option_count) produces the numbers 0, 1, 2, 3. It stops before the number you give it, which catches everybody once.

Now the output. The first four lines show the correct answer visiting B, A, D and C, one position per rotation, exactly as Formula 13.6 predicted.

The last three lines are the real result, and the middle one is the most important number in this chapter.

One question out of twenty.

On nineteen of the twenty questions, moving the options around changed whether the model was right. Its answers were not opinions about statistics that happened to be wrong. They were responses to the layout of the page.

At this point you have three numbers, 25.0%, 35.0% and 15.0%, and you could defend any one of them in writing. But the consistency count tells you what the honest report is, and it is not a number at all. It is a sentence: this model does not know this material, and a single figure should not have been printed.

That sentence is worth more than any of the three percentages. It is also the sentence that never appears on a leaderboard.

The simulation

Everything in sections 13.1 through 13.5 is in the page below, and you can move between the two views with the slider. View 0 puts the three procedures side by side against the chance level. View 1 is the diagnosis: for each letter, how often the model chose it against how often it was the right answer.

A bar chart with three bars showing reported accuracy as a percentage for three scoring procedures applied to the same model on the same twenty questions. Scoring by the log-probability of the letter gives 25 per cent. Scoring by the log-probability of the option text gives 35 per cent. Majority over four rotations gives 15 per cent. A dashed horizontal line marks the chance level at 25 per cent, which the first bar sits exactly on.

Figure 2:One model, one question bank, three defensible procedures. Each bar is Qwen2.5-0.5B-Instruct scored on the same twenty statistics questions. The only thing that differs between the bars is the rule used to decide which option the model picked. The dashed line at 25% is the chance level from Formula 13.2, and it is arithmetic rather than a measurement: with four options, guessing gets one in four. The first bar lands exactly on it. Read that line against the first two bars only. It is the chance level of a procedure that asks each question once, and both of those do. The third bar comes from a rule that asks four times and requires three hits, whose chance level is 5.1% rather than 25%, worked out in Worked example 13.2. Drawn by lab/make_figures.py from lab/out/we6b_eval_debiased.json.


13.6 Position bias is a symptom of weakness, not a fixed tax

Intuition

A reasonable reader reaches this point and draws the wrong conclusion. It goes like this: “Position bias inflates naive scores. So every naive score is inflated by roughly ten points, and I should mentally subtract ten from anything I read.”

That would be tidy. It is also wrong, and the evidence against it is in the course lab.

The same twenty questions were put to three models from the same family: the 0.5B, the 1.5B and the 3B. Same questions, same prompts, same code, same machine. Size is the only thing that changes, which makes this a controlled experiment in the ordinary scientific sense. Each model was scored twice, once by the naive letter procedure and once by the rotation procedure. The distance between those two scores is what this section calls the debiasing gap.

Here is what the gap did as the models got bigger.

At 0.5B, the gap is 10 points: 25.0% naive, 15.0% rotated. At 1.5B, the gap is 10 points: 80.0% naive, 70.0% rotated. At 3B, the gap is 0 points: 95.0% naive, 95.0% rotated.

The largest model’s score does not move when you take the position bias away. Rotating the options changes nothing, because it was never answering on position in the first place.

And look at how the 3B distributed its first-pick answers: B eight times, C seven, D twice, A three times. Now put the answer key beside it: B eight, C eight, D two, A two. Those two lines are nearly the same line. The model is not picking positions. It is picking answers, and the answers happen to sit where they sit.

The consistency counts tell the same story from the other side. Under four rotations, the number of questions where all four rotations agreed on the verdict went 1, then 13, then 18, out of twenty.

So position bias is not a tax levied on all benchmark scores. It is a symptom that appears when a model does not know the answer. When a model has no view about which option is right, the only thing left to break the tie is a habit, and a preference for the letter A is such a habit. When the model does have a view, the habit is overridden and the bias vanishes on its own.

That reframes the diagnosis in section 13.3. The 16-out-of-20 A count was not a separate defect sitting alongside a weak model. It was what a weak model looks like when you score it that way.

The mathematics

Python

print("model                          naive   rotation    gap   consistent   first-pick spread")
for model_name in size_ladder:
    one_model = size_ladder[model_name]
    naive_for_this_model = one_model["naive_accuracy"]
    rotation_for_this_model = one_model["rotation_accuracy"]
    # The debiasing gap: how much the naive number fell when the bias was taken away.
    gap_in_points = round((naive_for_this_model - rotation_for_this_model) * 100, 1)
    short_name = model_name.split("/")[1]
    print(short_name.ljust(30),
          str(round(naive_for_this_model * 100, 1)).rjust(5),
          str(round(rotation_for_this_model * 100, 1)).rjust(9),
          str(gap_in_points).rjust(7),
          (str(one_model["consistent"]) + " of 20").rjust(12),
          "  ", one_model["first_pick_spread"])

print("the answer key spread, for comparison:", answer_key_counts)

Output:

model                          naive   rotation    gap   consistent   first-pick spread
Qwen2.5-0.5B-Instruct           25.0      15.0    10.0      1 of 20    {'A': 16, 'D': 2, 'B': 2}
Qwen2.5-1.5B-Instruct           80.0      70.0    10.0     13 of 20    {'C': 7, 'B': 7, 'D': 2, 'A': 4}
Qwen2.5-3B-Instruct             95.0      95.0     0.0     18 of 20    {'B': 8, 'C': 7, 'D': 2, 'A': 3}
the answer key spread, for comparison: {'A': 2, 'B': 8, 'C': 8, 'D': 2}

for model_name in size_ladder walks through the labels in the loaded dictionary, one per model. model_name.split("/")[1] cuts the text Qwen/Qwen2.5-3B-Instruct at the slash and keeps the second piece, so the table is readable. .ljust(30) and .rjust(5) pad text on the right or the left so the columns line up.

Read this table from the bottom up.

The last row and the last line. {'B': 8, 'C': 7, 'D': 2, 'A': 3} against the key’s {'A': 2, 'B': 8, 'C': 8, 'D': 2}. B matches exactly at 8. C is off by one, 7 against 8. D matches exactly at 2. A is off by one, 3 against 2. A model that is reading the options produces an answer spread that looks like the answer key, and this one does.

The first row. {'A': 16, 'D': 2, 'B': 2}, with C missing entirely because it was never chosen. A model that is not reading the options produces a spread that looks like a habit.

The consistent column. 1, then 13, then 18, out of twenty. These are the questions where all four rotations agreed on the verdict, in the precise sense set out in section 13.5. This is the cleanest single column in the table. It rises with size and it rises fast, and it is the column that tells you whether the answers mean anything before you look at any score.

The gap column. 10.0, 10.0, 0.0. The gap does not fall smoothly. It holds at ten points across a threefold increase in size and then goes to zero. Twenty questions is nowhere near enough data to say anything about the shape of that fall, and this book will not pretend otherwise. What the column does support is the qualitative claim: the gap is present when the model is weak and absent when it is strong.


13.7 Comparing two models: the wrong way, then the right way

Intuition

Everything so far has been about one model. Almost every real question is about two.

Should the department install the 1.5B or the 3B? The 3B is more accurate and costs more energy per token, a trade-off Chapter 7 measured. But before that trade-off is even worth discussing, somebody has to establish that the 3B is actually better, and by how much, and with what certainty.

The two models sat the same twenty questions, under the same rotation-debiased procedure.

Now, how uncertain is that 25-point gap? There are two ways to work it out and they differ in one assumption.

The unpaired way treats the two results as two separate surveys. You worked out the wobble for one model’s score in Chapter 12; do that twice and combine. This is the standard formula for comparing two proportions and it is in every introductory statistics book.

It is also the wrong tool here, and the reason is a single sentence: the two models answered the same questions.

Think about what that means. Question 5 of the bank is hard, or it is easy, and it is exactly as hard or as easy for both models. That shared difficulty is not noise to be averaged away. It is information you paid for by using the same bank, and the unpaired formula throws it in the bin.

The paired way keeps it. Go through the questions one at a time and write down, for each one, what the 3B did minus what the 1.5B did. That difference can only be three things: +1 if the 3B got it right and the 1.5B did not, -1 if the reverse, and 0 if they did the same thing.

Then average those twenty differences. The average comes out to exactly the same 25 points, as it always must. But the spread of the differences is smaller than the spread of the two separate scores, because every question the models agreed on contributes a 0, and zeros pull a spread down.

Smaller spread, smaller standard error, narrower interval. Same data, better answer, no extra questions asked.

The mathematics

Two formulas here. The first is the unpaired standard error, shown so you can see what it costs. The second is the paired one.

Python

First the unpaired calculation, one step at a time.

accuracy_of_the_1_5b = paired_comparison["accuracy"]["Qwen/Qwen2.5-1.5B-Instruct"]
accuracy_of_the_3b = paired_comparison["accuracy"]["Qwen/Qwen2.5-3B-Instruct"]
observed_gap = accuracy_of_the_3b - accuracy_of_the_1_5b

print("1.5B:", round(accuracy_of_the_1_5b * 100, 1), "percent")
print("3B  :", round(accuracy_of_the_3b * 100, 1), "percent")
print("gap :", round(observed_gap * 100, 1), "percentage points")

# The unpaired standard error: one piece for each model, added inside the square root.
piece_for_the_1_5b = accuracy_of_the_1_5b * (1 - accuracy_of_the_1_5b) / question_count
piece_for_the_3b = accuracy_of_the_3b * (1 - accuracy_of_the_3b) / question_count
unpaired_standard_error = math.sqrt(piece_for_the_1_5b + piece_for_the_3b)

print("piece for the 1.5B      :", round(piece_for_the_1_5b, 6))
print("piece for the 3B        :", round(piece_for_the_3b, 6))
print("unpaired standard error :", round(unpaired_standard_error, 6))
print("unpaired 95% interval   :",
      round((observed_gap - 1.96 * unpaired_standard_error) * 100, 1), "to",
      round((observed_gap + 1.96 * unpaired_standard_error) * 100, 1), "points")

Output:

1.5B: 70.0 percent
3B  : 95.0 percent
gap : 25.0 percentage points
piece for the 1.5B      : 0.0105
piece for the 3B        : 0.002375
unpaired standard error : 0.113468
unpaired 95% interval   : 2.8 to 47.2 points

math.sqrt(...) takes a square root. The two pieces are computed on their own lines and added inside the brackets of math.sqrt, which is where the “inside the square root” warning in Formula 13.9 lives in the code.

Every printed number matches the by-hand arithmetic in Formula 13.9. That is the point of doing both.

Now the paired calculation, from the per-question records.

# The question-by-question records. 1 means that model got that question right.
results_of_the_1_5b = paired_comparison["per_question"]["Qwen/Qwen2.5-1.5B-Instruct"]
results_of_the_3b = paired_comparison["per_question"]["Qwen/Qwen2.5-3B-Instruct"]

# Build the list of per-question differences with an explicit loop.
question_differences = []
for question_number in range(question_count):
    one_difference = results_of_the_3b[question_number] - results_of_the_1_5b[question_number]
    question_differences.append(one_difference)

print("the 20 per-question differences:", question_differences)

# The average difference.
total_of_the_differences = 0.0
for one_difference in question_differences:
    total_of_the_differences = total_of_the_differences + one_difference
mean_difference = total_of_the_differences / question_count
print("mean difference:", mean_difference)

# The standard deviation of the differences, divided by one fewer than the number of questions.
total_of_squared_distances = 0.0
for one_difference in question_differences:
    distance_from_the_mean = one_difference - mean_difference
    total_of_squared_distances = total_of_squared_distances + distance_from_the_mean * distance_from_the_mean
standard_deviation_of_differences = math.sqrt(total_of_squared_distances / (question_count - 1))
print("sum of squared distances:", round(total_of_squared_distances, 4))
print("standard deviation of the differences:", round(standard_deviation_of_differences, 6))

paired_standard_error = standard_deviation_of_differences / math.sqrt(question_count)
print("paired standard error:", round(paired_standard_error, 6))
print("paired 95% interval  :",
      round((mean_difference - 1.96 * paired_standard_error) * 100, 1), "to",
      round((mean_difference + 1.96 * paired_standard_error) * 100, 1), "points")
print("the unpaired interval is", round(unpaired_standard_error / paired_standard_error, 2), "times wider")

Output:

the 20 per-question differences: [1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0]
mean difference: 0.25
sum of squared distances: 3.75
standard deviation of the differences: 0.444262
paired standard error: 0.09934
paired 95% interval  : 5.5 to 44.5 points
the unpaired interval is 1.14 times wider

question_differences = [] makes an empty list, and .append(...) adds one item to the end of it. The first loop builds the list of twenty differences. The second adds them up. The third adds up the squared distances from the average. Three loops where a practised programmer would write one line, and three loops is the right choice here, because you can point at each one and say what it does.

Look at the printed list of differences. Five ones, fifteen zeros, no minus ones. Every entry of 0.0 is a question the two models handled identically, and each of those pulls the standard deviation down.

sum of squared distances: 3.75 is exactly the 3.75 from step 3 of Formula 13.10, and standard deviation of the differences: 0.444262 is exactly the number from step 4. The Python and the hand arithmetic agree to six decimal places, which is the check you want.

So you have two intervals for the same 25-point gap:

methodstandard error95% intervalwidth
unpaired, the wrong tool here0.113468(+2.8,+47.2)(+2.8, +47.2) points44.5 points
paired, using the design0.099340(+5.5,+44.5)(+5.5, +44.5) points38.9 points

One note on the last column, because a careful reader will try to check it and get a different answer. The width of an interval is 2×1.96×SE2 \times 1.96 \times SE, so the unpaired width is 2×1.96×0.113468=0.4447952 \times 1.96 \times 0.113468 = 0.444795, which is 44.5 points, and the paired width is 2×1.96×0.099340=0.3894132 \times 1.96 \times 0.099340 = 0.389413, which is 38.9 points. Subtracting the rounded endpoints instead gives 47.22.8=44.447.2 - 2.8 = 44.4 and 44.55.5=39.044.5 - 5.5 = 39.0. The two routes disagree in the first decimal place because the endpoints were rounded before the subtraction. The column above uses the unrounded route.

Both exclude zero. Both say the 3B is better. At this stage of the analysis you would write the result up and move on.

Do not move on.


13.8 McNemar’s exact test, and the sharpest moment in the course

Intuition

Look at the contingency table again, and this time do not compute anything with it. Read it.

3B correct3B wrong
1.5B correct140
1.5B wrong51

Fourteen questions both models answered correctly. One question both got wrong. Five questions where the 3B was right and the 1.5B was not. Zero the other way.

Now ask what each cell tells you about which model is better.

The fourteen where both were right: nothing. Both models did the same thing. If you were choosing between the two on the basis of those fourteen questions you would have no reason to prefer either.

The one where both were wrong: nothing, for the same reason.

So fifteen of your twenty questions say nothing at all about the comparison. They are not wasted; they told you both models can do that kind of question. But on the question you actually asked, which is “is the 3B better”, they are silent.

Your twenty-question comparison is a five-question comparison.

That is not a failure of your analysis. It is what the data is. And once you see it, the right test suggests itself. Set the fifteen silent questions aside. You are left with five disagreements. Ask a simple question about them: if the two models were equally good, how would those five have landed?

If they are equally good, then each time they disagree, it is a coin toss which one happens to be the one that got it right. Five disagreements would be five coin tosses, and you would expect something like three-and-two, or two-and-three.

You got five-and-nothing.

Five heads in a row. How surprising is that? Surprising, certainly. Surprising enough to publish? Work it out, and the working out is four lines of arithmetic.

The mathematics

Python

First, read the table and separate the agreements from the disagreements.

contingency = paired_comparison["contingency"]
both_correct = contingency["both_correct"]
both_wrong = contingency["both_wrong"]
only_the_3b_correct = contingency["only_larger"]
only_the_1_5b_correct = contingency["only_smaller"]

print("both correct        :", both_correct)
print("both wrong          :", both_wrong)
print("only the 3B correct :", only_the_3b_correct)
print("only the 1.5B correct:", only_the_1_5b_correct)
print("four cells add to   :", both_correct + both_wrong + only_the_3b_correct + only_the_1_5b_correct)

# The agreements carry no information about which model is better.
agreements = both_correct + both_wrong
discordant_pairs = only_the_3b_correct + only_the_1_5b_correct
print("questions the models agreed on (these leave the test):", agreements)
print("questions they disagreed on (these are the whole test):", discordant_pairs)

Output:

both correct        : 14
both wrong          : 1
only the 3B correct : 5
only the 1.5B correct: 0
four cells add to   : 20
questions the models agreed on (these leave the test): 15
questions they disagreed on (these are the whole test): 5

The four cells add to: 20 line is the sanity check from Formula 13.11 written into the code, and it is worth putting checks like that into your own work. A check that runs is worth ten checks you meant to do.

Now the test itself, with the sum written out as a loop so you can watch each term appear.

# The sum stops at whichever of the two discordant counts is smaller.
smaller_discordant_count = min(only_the_3b_correct, only_the_1_5b_correct)
print("the sum runs from j = 0 to j =", smaller_discordant_count)

one_sided_total = 0.0
# wins_for_the_1_5b is the j of Formula 13.11: how many of the five disagreements would have
# gone the 1.5B's way. The sum runs over every outcome at least as lopsided as the real one.
for wins_for_the_1_5b in range(smaller_discordant_count + 1):
    number_of_ways = math.comb(discordant_pairs, wins_for_the_1_5b)
    chance_of_one_run = 0.5 ** discordant_pairs
    one_term = number_of_ways * chance_of_one_run
    print("  j =", wins_for_the_1_5b, " ways =", number_of_ways, " chance of one run =", chance_of_one_run, " term =", one_term)
    one_sided_total = one_sided_total + one_term

print("one-sided total:", one_sided_total)
two_sided_p_value = 2 * one_sided_total
print("two-sided exact p:", two_sided_p_value)
print("the file says    :", paired_comparison["mcnemar_exact_p"])
print("does it clear 0.05?", two_sided_p_value < 0.05)

Output:

the sum runs from j = 0 to j = 0
  j = 0  ways = 1  chance of one run = 0.03125  term = 0.03125
one-sided total: 0.03125
two-sided exact p: 0.0625
the file says    : 0.0625
does it clear 0.05? False

math.comb(5, 0) is the (50)\binom{5}{0} from the formula: how many ways to pick 0 things out of 5. The answer is 1. 0.5 ** 5 is 0.5 raised to the power 5, and the double asterisk ** is how Python writes an exponent. It gives 0.03125, which matches the by-hand arithmetic in step 5.

range(smaller_discordant_count + 1) produces the numbers from 0 up to and including smaller_discordant_count, because range stops before its argument. Here that is only the number 0, which is why the loop prints one line.

The last two lines are the ones to carry out of this chapter. The computed p-value matches the one stored in lab/out/we7_paired.json, so the arithmetic in Formula 13.11 and the code and the lab all agree. And does it clear 0.05? False.

Three panels. Left, the two-by-two paired table as a shaded grid: fourteen questions both models answered correctly, zero where only the 1.5B was correct, five where only the 3B was correct, one both got wrong. Middle, two horizontal interval bars for the same 25-point gap, the unpaired one running from plus 2.8 to plus 47.2 percentage points and the paired one from plus 5.5 to plus 44.5, with a dashed vertical line at zero marked no difference. Right, the McNemar exact distribution as six bars showing the chance of each possible split of five disagreements if the models were equal, with the two end bars highlighted and a label reading we observed 5, two-sided p equals 0.0625, does not clear 0.05.

Figure 3:A 25-point gap that cannot be called significant, on twenty questions. The left panel is the contingency table: the fifteen questions on the main diagonal, fourteen both-right and one both-wrong, say nothing about which model is better. The middle panel is the same 25-point gap with two different intervals around it, from Formulas 13.9 and 13.10; both exclude the dashed line at zero. The right panel is the distribution McNemar’s test actually uses: if the two models were equally good, the five disagreements would split like five coin tosses, and the bars show how likely each split would be. The observed split was five to nothing, and doubling the end probability gives p=0.0625p = 0.0625. Drawn by lab/fig_extra.py from lab/out/we7_paired.json.


13.9 How many questions would it actually take?

Intuition

Something in section 13.8 should have bothered you, and if it did not, here it is.

McNemar’s test on five disagreements returned 0.0625. Now ask: what is the smallest p-value that five disagreements could ever produce?

The most extreme outcome possible is a clean sweep: all five going the same way. That is what happened. There is no more lopsided result available. So 0.0625 is not an unlucky value, it is the floor. With five disagreements, the smallest two-sided p-value that exists is 0.0625.

Which means this: the study could not have produced a significant result no matter how the two models performed. Even if the 3B had been perfect and the 1.5B hopeless on those five, the answer would still have been 0.0625, and 0.0625 is still above 0.05.

That is not a fact about these two models. It is a fact about a twenty-question benchmark with fifteen agreements in it. The design was incapable of answering the question before either model was loaded.

This is worth more than it might look. It means you can check whether a comparison is worth running before you run it. Count the disagreements you expect, work out the floor, and if the floor is above 0.05, you already know the answer will be “cannot tell” and you can go and build a bigger bank instead of burning an afternoon.

The threshold turns out to be six. Six disagreements give a floor of 0.03125, which is below 0.05. Five give 0.0625, which is not. So a paired comparison of two models needs at least six questions on which they disagree before it can possibly reach significance, and that is before you ask anything about how lopsided the split has to be.

How many questions does that mean? It depends entirely on how often the models disagree. Here, five disagreements came out of twenty questions, a disagreement rate of one in four. Two models that are close in ability disagree rarely, which is exactly when you most need the test to work.

The literature has a number for this. Miller (arXiv:2411.00640) recommends that a model evaluation contain at least 1,000 questions to signal reliably. Twenty is one fiftieth of that. The recommendation lands differently when you have hit the wall yourself in section 13.8 than when somebody tells you at the start.

The mathematics

Python

# For each possible number of disagreements, the smallest two-sided p the design can ever give.
print("disagreements   smallest possible two-sided p   can it ever reach 0.05?")
for possible_discordant_pairs in range(1, 9):
    smallest_possible_p = 2 * (0.5 ** possible_discordant_pairs)
    print("     ", str(possible_discordant_pairs).rjust(2), "        ",
          str(round(smallest_possible_p, 6)).rjust(10), "                  ",
          smallest_possible_p < 0.05)

Output:

disagreements   smallest possible two-sided p   can it ever reach 0.05?
       1                 1.0                    False
       2                 0.5                    False
       3                0.25                    False
       4               0.125                    False
       5              0.0625                    False
       6             0.03125                    True
       7            0.015625                    True
       8            0.007812                    True

range(1, 9) gives the numbers 1 through 8; it starts at the first number and stops before the second. 0.5 ** possible_discordant_pairs raises a half to that power. The comparison smallest_possible_p < 0.05 produces True or False, and printing it puts the verdict straight into the table.

The False on the fifth row is the whole section. Five disagreements cannot produce a significant result. The lab’s comparison had five disagreements. Its verdict was decided by the size of the bank, before either model was loaded onto the graphics card.


13.10 The failure taxonomy, at last

Intuition

You have met model failure many times by now, in a different costume almost every week, and this book has not once stopped to sort those failures into kinds. That was deliberate. Naming the kinds in Week 1 would have handed you vocabulary for things you had not yet seen. Naming them here gives you names for things you have already watched happen, argued about in section, and measured yourself.

Here are six kinds, each one anchored to a measurement in this book.

1. Tokenizer failure. The model never sees the thing you typed. It sees pieces. The string '1234567' becomes seven separate tokens, one per digit, which is the mechanical reason these models are bad at arithmetic. Chapter 2.

2. Harness failure. The tooling around the model quietly changes the question. Asking Ollama for the continuation of The capital of France is without setting raw mode makes the model answer a chat turn instead, and the “next token” came back as 'The' at 99.9993%. That looks like a model with no uncertainty at all, and it is an artefact of the harness. Chapter 1 is where local running and hosted running are set beside each other. The 99.9993% itself is recorded in _research/00-lab-verified-findings.md, section 1.

3. Numerical failure. The arithmetic that stores the model loses something. Quantizing to four bits with one scale for the whole tensor gave a mean weight error of 46.6% of the weights’ own standard deviation, because a single large outlier set the scale. With one scale per block of 32 weights, the same four bits gave 7.2%. Chapter 7.

4. Retrieval failure. The model is given the wrong evidence and answers from it faithfully. Fixed-window chunking of the CSUB compendium put the correct section at rank 1 on essentially no question, because every requirement list reads alike and a window starting mid-section does not know which section it is in. Chapter 10.

5. Fabrication. The model produces fluent, confident text that is false. Asked to continue The capital of France is, the 0.5B model wrote “Paris. It was founded in 789 AD”, and Paris is roughly a thousand years older than that. Nothing in the machinery checks facts. Probability is about which text is likely, never about which text is true. Chapter 10 is where retrieval is offered as a partial repair. The sentence itself is printed in the Python reference appendix, from lab/out/appendix_python_reference_checks.json.

6. Measurement failure. Your instrument is bent and the number it gives you is about the instrument. The naive scorer made the model answer A on 16 of 20 questions and reported 25.0%. This chapter.

Now count. Of those six, exactly one is the model being bad at its own job. That is number 5, fabrication. The other five are failures of something you built around the model: how you cut the text, how you called the tool, how you stored the numbers, how you chose the passages, how you marked the answers.

That is the most useful thing in the taxonomy and it is the reason it lands in this chapter rather than in Week 1. Most of what looks like a model failing is a procedure failing. You have to be able to tell the two apart, and you can only tell them apart by measuring your measurement.

The mathematics

One small formula, and it is the hinge into Chapter 14.

Python

# Six kinds of failure this book has measured, each with the chapter that met it and the
# number that proves it. Every value here is quoted from _research/00-lab-verified-findings.md.
failure_kinds = [
    ["tokenizer", 2, "'1234567' becomes 7 separate tokens"],
    ["harness", 1, "Ollama without raw mode returned 'The' at 99.9993 percent"],
    ["numerical", 7, "4-bit per-tensor quantization: 46.6 percent mean weight error"],
    ["retrieval", 10, "fixed-window chunking put the right section at rank 1 on essentially no question"],
    ["fabrication", 10, "the model wrote that Paris was founded in 789 AD"],
    ["measurement", 13, "the naive scorer made the model answer A on 16 of 20 questions"],
]

print("kind          first met   what was measured")
for one_kind in failure_kinds:
    name_of_kind = one_kind[0]
    chapter_number = one_kind[1]
    the_evidence = one_kind[2]
    print(name_of_kind.ljust(14), ("Ch " + str(chapter_number)).ljust(11), the_evidence)

print("kinds of failure named:", len(failure_kinds))

# Only one of the six is a failure of the model on its own terms.
failures_of_our_procedure = 0
for one_kind in failure_kinds:
    if one_kind[0] in ["tokenizer", "harness", "numerical", "retrieval", "measurement"]:
        failures_of_our_procedure = failures_of_our_procedure + 1
print("kinds that are failures of the procedure, not of the model:", failures_of_our_procedure, "of", len(failure_kinds))

Output:

kind          first met   what was measured
tokenizer      Ch 2        '1234567' becomes 7 separate tokens
harness        Ch 1        Ollama without raw mode returned 'The' at 99.9993 percent
numerical      Ch 7        4-bit per-tensor quantization: 46.6 percent mean weight error
retrieval      Ch 10       fixed-window chunking put the right section at rank 1 on essentially no question
fabrication    Ch 10       the model wrote that Paris was founded in 789 AD
measurement    Ch 13       the naive scorer made the model answer A on 16 of 20 questions
kinds of failure named: 6
kinds that are failures of the procedure, not of the model: 5 of 6

A list of lists is a list whose items are themselves lists. failure_kinds[0] is the whole first row, and failure_kinds[0][1] is the 2 in it. Inside the loop, one_kind[0] is the name, one_kind[1] the chapter, one_kind[2] the evidence. The in on the second-to-last loop asks “is this value one of the items in that list”, and gives back True or False.

Five of six. That ratio is not a law of nature and this book is not claiming it is; it is a count of the failures this particular course chose to teach. What it does capture is the working habit: when something goes wrong, your first suspect is the thing you built, not the model.


Common mistakes

  1. Reporting a score without the procedure. “The model scored 35%” is half a sentence. The whole sentence is “the model scored 35% under length-normalised option-text scoring, on this twenty-question bank.” If you cannot say the second half, you do not have a result yet.

  2. Treating a score near chance as a measurement of the model. It might be. Check the instrument first. A score that lands on the guessing rate is a prompt to inspect the scoring procedure, not a finding about knowledge.

  3. Comparing a score against the wrong chance level. The 25% line belongs to a procedure that asks each question once. The rotation procedure asks four times and needs three hits, and its chance level is 5.1%, from Worked example 13.2. Judged against 25% the rotation score of 15.0% looks worse than guessing. Judged against its own 5.1% it is three times chance. Work out the chance level of the procedure you actually used.

  4. Assuming debiasing always lowers a score. It lowered the 0.5B by 10 points and the 1.5B by 10 points, and moved the 3B by nothing at all. The gap is a symptom that shows up when a model is weak. There is no fixed correction to apply.

  5. Fixing a bias and not re-checking. Scoring the option text raised the score to 35% and left an answer spread of A 7, B 11, C 2, D 0 against a key of A 2, B 8, C 8, D 2. The old bias went away and a new one arrived. Always re-print the spread after a fix.

  6. Using the unpaired formula on paired data. If both models answered the same questions, the unpaired standard error is the wrong tool. It is not catastrophic here, 1.14 times wider, and it is free to do it properly.

  7. Adding the two pieces outside the square root. In Formula 13.9 both fractions sit under one root. Taking two roots and adding them gives 0.1512 instead of 0.1135, which is wrong by a third.

  8. Running McNemar on all the items. The concordant pairs take no part in the test. Only the disagreements do. Using n=20n = 20 instead of m=5m = 5 turns the test into something else.

  9. Forgetting to double for a two-sided test. (12)5=0.03125\left(\tfrac{1}{2}\right)^5 = 0.03125 is the one-sided answer and it clears 0.05. The two-sided answer is 0.0625 and it does not. Unless you decided in advance that only one model could possibly be better, the test is two-sided.

  10. Reading p=0.0625p = 0.0625 as “the models are the same”. It says twenty questions cannot tell them apart. The 3B may well be better; you have not measured it yet. Absence of evidence is not evidence of absence.

  11. Reading p=0.0625p = 0.0625 as “the probability the models are equal is 6.25%”. A p-value is computed assuming the models are equal, so it cannot also be a verdict on whether they are.

  12. Confusing percentages with percentage points. Going from 70% to 95% is a rise of 25 percentage points. It is a rise of about 36 per cent of the original value, because 25÷70=0.35725 \div 70 = 0.357, and then 0.357×100=35.70.357 \times 100 = 35.7, which rounds to 36. These are different numbers and reporting the wrong one inflates or deflates your result. See the toolkit.

  13. Quoting a metric without reading its definition. The consistency count in this chapter means “the four rotations agreed on the verdict”, which is not quite the same as “gave the same answer four times”. The rule applies to this book’s numbers too.


What to remember

An evaluation score is a property of the scoring procedure at least as much as of the model, and the same model on the same twenty questions scored 25.0%, 35.0% and 15.0% under three defensible rules. A score sitting on the chance level is a reason to inspect the instrument, and inspecting it here showed a model answering A on 16 of 20 questions while the key rewarded A twice. Position bias is a symptom of a weak model rather than a fixed tax: the debiasing gap was 10 points at 0.5B, 10 points at 1.5B and 0 points at 3B. When two models answer the same questions, compare them question by question, because the items they agree on carry no information about which is better. And a 25-point gap can fail to be significant: five disagreements, all one way, give McNemar’s exact p=0.0625p = 0.0625, which does not clear 0.05, so the answer is more questions rather than a bigger claim.


Practice problems

Some of these problems reuse the chapter’s own measured numbers, and those name the model or the section they came from. Every other number in this section is made up for practice. The invented ones are built to be checkable by hand, and none of them is a measurement of anything.

The answers appendix works the chapter’s McNemar calculation in full, and the answers to the odd-numbered problems below are being added there in problem-number order. Bring the even-numbered ones to section.

Warm-up: can you do the arithmetic?

1. A model gets 9 of 20 questions right. Write that as a decimal and as a percentage.

2. A benchmark has questions with five options each and exactly one correct answer. What is the chance level, as a decimal and as a percentage?

3. A 40-question bank has four options per question. How many questions would a random guesser be expected to get right?

4. On a 20-question bank, a scoring procedure chose option D twelve times. Compute the over-choice ratio for D, using Formula 13.4.

5. Turn each of these into the other form: 0.45 into a percentage; 62% into a decimal; 17 out of 25 into a percentage.

6. Compute (12)6\left(\tfrac{1}{2}\right)^{6} by halving six times, showing each halving.

7. A model scored 60% naive and 45% debiased on the same bank. What is the debiasing gap in percentage points?

8. A contingency table reads: both right 22, both wrong 6, only model B right 9, only model A right 3. Check that it adds up, and say how many questions there were and how many disagreements.

Practice: can you apply it?

9. A 25-question bank has four options. A procedure chose A eleven times, B six, C five, D three. Compute the over-choice ratio for every option, and check that the four ratios average to 1.

10. The same procedure scored 36%. The answer key spread is A 5, B 7, C 8, D 5. Write three sentences saying what you suspect about the procedure and what you would measure next.

11. A four-option question has its correct answer in position 2, which is option C. Using Formula 13.6, work out where the correct answer sits after each of the four cyclic rotations, and check that all four positions are visited exactly once.

12. Two options are scored. The first is 3 tokens with log-probabilities -0.7, -1.4 and -0.6. The second is 6 tokens with log-probabilities -0.9, -0.8, -1.0, -0.7, -1.1 and -0.8. Which option wins on totals, and which wins after length normalisation? Show every step.

13. Model A scores 0.55 and model B scores 0.80, each on 40 questions. Compute the unpaired standard error using Formula 13.9, and give the 95% interval for the difference in percentage points.

14. Two models on 25 shared questions: both right 15, both wrong 3, only B right 6, only A right 1. Build the paired difference, its standard deviation and its standard error, and give the 95% interval. Show each step the way Formula 13.10 does.

15. For the table in problem 14, run McNemar’s exact test. You will need (70)=1\binom{7}{0} = 1, (71)=7\binom{7}{1} = 7 and (12)7=0.0078125\left(\tfrac{1}{2}\right)^{7} = 0.0078125.

16. Using Formula 13.12, find the smallest number of disagreements that could ever produce a two-sided p-value below 0.01. Show the ladder.

17. A colleague reports: “Our model beat theirs, 78% to 71%, on our 100-question internal benchmark.” Write five questions you would ask before believing it. For each one, say what a bad answer would look like.

18. A model scored 3 of 3 on questions about one topic and 1 of 12 on another, for an overall score of 4 of 15. Compute all three accuracies using Formula 13.13, then write one sentence saying why you would not report the 100% without a qualifier.

Stretch: can you reason with it?

19. The naive procedure in this chapter chose option C zero times out of twenty, and C was correct on eight questions. Work out the highest score that procedure could possibly have reached on this bank, and explain in your own words why every reported benchmark score has a ceiling set by its procedure.

20. Section 13.5 argues that a model always picking position A scores exactly one of four on every question under rotation. Write out that argument as carefully as you can, then work out what a model that always picks position A and always picks position B on alternate askings would score. State any assumption you had to make.

21. The 1.5B model has a debiasing gap of 10 points but a letter spread of C 7, B 7, D 2, A 4, which does not lean towards any one position. Propose two different explanations for a non-zero debiasing gap that is not driven by a simple position preference, and for each one describe a measurement that would tell the two explanations apart.

22. Suppose you re-ran the whole two-model comparison on a fresh bank of 20 questions drawn from the same source, and got a contingency table of both right 13, both wrong 2, only 3B right 4, only 1.5B right 1. Compute McNemar’s exact p. Then discuss: your two studies now disagree about the direction of one question. What would you do next, and what would you refuse to do?

23. McNemar’s test and the paired Wald interval gave opposite verdicts on the same data in this chapter. Explain, in a paragraph a classmate could follow, why they disagree and why this book believes McNemar. Then name one situation in which you would trust the Wald interval more.

24. Design a study that could actually detect a 10 percentage point difference between two models. State the disagreement rate you are assuming and where that assumption comes from, the number of disagreements you need, the bank size that implies, and what you would do if the real disagreement rate turned out to be half what you assumed. Miller (arXiv:2411.00640) is a reasonable starting point, and you should say how your number compares with the 1,000 it recommends.

25. Worked example 13.2 found that a guesser scores about 5.1% under a majority-of-four rule on four-option questions. Now change the rule to “majority of three rotations, meaning at least two hits out of three” and work out the chance level for that, showing every multiplication. You will need (32)=3\binom{3}{2} = 3. Then say which of the two rules you would rather report a score under, and why “the one with the lower chance level” is not a good enough reason on its own.


What comes next

Chapter 14 takes Formula 13.13 and changes what the kinds are. Instead of topics in statistics, the kinds become groups of people, and the question becomes whether a model that scores well on average scores well for everybody. Then it puts a price on the compute that produced the score and asks who is paying it.

The habit this chapter built is the one you carry into that. Before you argue about a number, find out how it was made.