Evaluation, section 4 of 7, 6h

Grading: code first, judges second

By the end: You can score open-ended output reliably, and you know how much to trust the score.

  • Deterministic checks, which are free
  • LLM judges and how to write a rubric
  • Checking the judge against your own labels
  • Known judge biases

New to evaluation? An eval is a fixed set of test cases you run your system against, plus a rule for deciding whether each answer was good enough. It is a test suite for something that never gives the same answer twice: instead of checking the output matches a stored string, you check it satisfies what you asked for — the right fields present, the right document cited, the refund policy not invented. Why it matters

You have a set of cases. The next problem is scoring the output, which for open-ended text has no obvious answer. This section works through the two tools available: plain code, which is free and cannot itself be wrong, and a model grading against a written rubric, which can be. Most of the section is about the second, because a judge nobody has checked against their own labels produces a number with no meaning attached to it.

What the case asks for                       Grader            Cost per case

one right string, from a fixed set           exact match       nothing
one right string, free-form wording          exact + normalise nothing
a number, a date, an id                      parse and compare nothing
valid structure: JSON, fields, types         schema check      nothing
an internal rule: totals, ranges, citations  constraint check  nothing
generated code                               run the tests     a sandbox
the right documents used                     set overlap       nothing
wording that could be phrased many ways      judge             a model call
faithful, on-topic, right tone               judge             a model call
which of two answers is better               pairwise judge    two model calls
Read this top to bottom and stop at the first row that fits. Half the cases people send to a judge are two rows up, free, and not subject to a judge having an opinion.

Check with code wherever you can#

Deterministic checks are fast, free and cannot themselves be wrong. Exhaust them before reaching for a model.

  • Is it valid JSON matching the schema?
  • Are the required fields present and the right type?
  • Is the total correct, given the line items?
  • Does every citation point at a document that exists?
  • Is it under the length limit?
  • Did the side effect happen: was the record written, the email sent?

A surprising amount of quality is checkable this way. Teams reach for a judge far earlier than they need to.

Exact match, and how far normalising gets you#

Exact match is the grader to want. It is free, it has no opinion, and it never drifts. It fails on trivia, which is what normalising is for: strip the whitespace, fold the case, drop trailing punctuation, and the same answer stops being six different answers.

import re, unicodedata

def normalise(s):
    s = unicodedata.normalize("NFKC", s).strip().lower()
    s = s.replace("’", "'")                   # the curly apostrophe
    s = re.sub(r"[.!?;:]+$", "", s)           # trailing punctuation only
    s = re.sub(r"\s+", " ", s)
    return s

CASES = [
    ("refund window, annual plan", "14 days", "14 days"),
    ("refund window, monthly plan", "30 days", "30 days."),
    ("can I pause my plan", "no", "No"),
    ("support hours", "9am to 5pm", "9am  to 5pm"),
    ("price of the pro plan", "$1,200", "1200"),
    ("who signs the contract", "the account owner", "the owner of the account"),
]

exact = sum(exp == got for _, exp, got in CASES)
norm  = sum(normalise(exp) == normalise(got) for _, exp, got in CASES)
print("raw exact match  ", exact, "/", len(CASES))
print("after normalising", norm, "/", len(CASES))
for name, exp, got in CASES:
    if normalise(exp) != normalise(got):
        print("  still fails:", name, "|", exp, "->", got)

# raw exact match   1 / 6
# after normalising 4 / 6
#   still fails: price of the pro plan | $1,200 -> 1200
#   still fails: who signs the contract | the account owner -> the owner of the account
One in six before, four in six after, from twelve lines that contain no judgement about quality at all.

The two that remain are different problems and want different answers. The price is a number wearing formatting, so parse it and compare numerically; that is a grader, not a normaliser. The last is a genuine paraphrase, and no amount of string surgery will fix it. That is the boundary: normalise formatting, parse values, and hand real paraphrase to something else.

Fuzzy matching, and why the threshold does not exist#

The obvious next move is a similarity score with a threshold. It is worth doing once, on your own data, because the result is usually decisive in the other direction.

from difflib import SequenceMatcher

def ratio(a, b):
    """Character-level similarity, in the standard library."""
    return SequenceMatcher(None, a.lower(), b.lower()).ratio()

def token_f1(a, b):
    """Word overlap, the metric behind most question-answering scores."""
    ta, tb = a.lower().split(), b.lower().split()
    common = sum(min(ta.count(w), tb.count(w)) for w in set(ta))
    if common == 0:
        return 0.0
    p, r = common / len(tb), common / len(ta)
    return 2 * p * r / (p + r)

PAIRS = [
    ("the account owner", "the owner of the account"),
    ("refunds take 5 working days", "refunds are processed in five working days"),
    ("the refund is available", "the refund is not available"),
    ("cancel any time", "you can cancel whenever you like"),
]
print(f"{'ratio':>7}{'tokenF1':>9}  pair")
for a, b in PAIRS:
    print(f"{ratio(a, b):7.2f}{token_f1(a, b):9.2f}  {a!r} / {b!r}")

#   ratio  tokenF1  pair
#    0.54     0.75  'the account owner' / 'the owner of the account'
#    0.70     0.50  'refunds take 5 working days' / 'refunds are processed in five working days'
#    0.92     0.89  'the refund is available' / 'the refund is not available'
#    0.51     0.22  'cancel any time' / 'you can cancel whenever you like'
The third pair is the only wrong answer in the list and it scores highest on both metrics. No threshold exists that accepts the three correct paraphrases and rejects it.

A single negation flips the meaning and changes almost no characters, and that is exactly the failure your users will report. Embedding similarity behaves the same way for the same reason. So fuzzy matching is not a grader for correctness. It is useful for two narrower jobs: flagging cases whose output changed a lot between two runs, so you know what to read, and deduplicating inputs as in the dataset section. Correctness needs either a parse or a judge.

Programmatic checks: structure, then rules#

Where the output has a shape, the grader is an ordinary function and it should report why it failed rather than false. Collect reasons, because the reason is what you will group and count later.

import json

SCHEMA = {
    "order_id": str,
    "items": list,
    "total_cents": int,
    "refundable": bool,
}

def check_structure(raw):
    """Returns a list of reasons the output is unusable. Empty means it passed."""
    problems = []
    try:
        obj = json.loads(raw)
    except json.JSONDecodeError as e:
        return ["not valid JSON: " + str(e)]
    if not isinstance(obj, dict):
        return ["top level is " + type(obj).__name__ + ", expected object"]
    for field, want in SCHEMA.items():
        if field not in obj:
            problems.append("missing field " + field)
        elif not isinstance(obj[field], want) or isinstance(obj[field], bool) != (want is bool):
            problems.append(field + " is " + type(obj[field]).__name__ + ", expected " + want.__name__)
    for extra in set(obj) - set(SCHEMA):
        problems.append("unexpected field " + extra)
    if not problems:
        line_total = sum(i.get("cents", 0) for i in obj["items"])
        if line_total != obj["total_cents"]:
            problems.append(f"total_cents {obj['total_cents']} but line items sum to {line_total}")
        if obj["total_cents"] < 0:
            problems.append("total_cents is negative")
    return problems

OUTPUTS = [
    '{"order_id": "A-101", "items": [{"cents": 900}, {"cents": 2000}], "total_cents": 2900, "refundable": true}',
    'Sure! Here is the JSON:\n{"order_id": "A-101", "items": [], "total_cents": 0, "refundable": true}',
    '{"order_id": "A-102", "items": [{"cents": 4900}], "total_cents": 4900, "refundable": "yes"}',
    '{"order_id": "A-103", "items": [{"cents": 1000}, {"cents": 1000}], "total_cents": 1000, "refundable": false}',
]
for raw in OUTPUTS:
    print(check_structure(raw) or "pass")

# pass
# ['not valid JSON: Expecting value: line 1 column 1 (char 0)']
# ['refundable is str, expected bool']
# ['total_cents 1000 but line items sum to 2000']
The bool check looks redundant and is not: in Python True is an int, so a schema saying total_cents must be an int accepts True without that line.

The last two rows are the interesting ones. A string 'yes' where a boolean belongs is a schema failure, and a total that disagrees with its own line items is a constraint failure. They are different bugs with different fixes, and a grader returning a bare false would have merged them.

When the output is code, the grader is the test suite. Run the generated function against cases you wrote, count how many pass, and keep the first failing input, because that is what tells you what kind of wrong it is.

TESTS = [
    ((["a", "b", "a"],), {"a": 2, "b": 1}),
    (([],), {}),
    ((["x"],), {"x": 1}),
    ((["a", "a", "a"],), {"a": 3}),
]

CANDIDATES = {
    "v1": "def tally(xs):\n    out = {}\n    for x in xs:\n        out[x] = out.get(x, 0) + 1\n    return out\n",
    "v2": "def tally(xs):\n    out = {}\n    for x in xs:\n        out[x] = 1\n    return out\n",
    "v3": "def tally(xs):\n    return {x: xs.count(x) for x in xs[1:]}\n",
    "v4": "def tally(xs)\n    return {}\n",
}

def run_tests(source, tests):
    ns = {}
    try:
        exec(source, ns)            # sandbox this properly before running model output
    except SyntaxError as e:
        return 0, len(tests), "SyntaxError: " + e.msg
    fn = ns.get("tally")
    if fn is None:
        return 0, len(tests), "no function named tally"
    passed, first_bad = 0, ""
    for args, want in tests:
        try:
            got = fn(*args)
        except Exception as e:
            got = type(e).__name__
        if got == want:
            passed += 1
        elif not first_bad:
            first_bad = f"tally({args[0]!r}) -> {got!r}, wanted {want!r}"
    return passed, len(tests), first_bad

for name, src in CANDIDATES.items():
    passed, total, why = run_tests(src, TESTS)
    print(f"{name}  {passed}/{total}  {why}")

# v1  4/4
# v2  2/4  tally(['a', 'b', 'a']) -> {'a': 1, 'b': 1}, wanted {'a': 2, 'b': 1}
# v3  3/4  tally(['x']) -> {}, wanted {'x': 1}
# v4  0/4  SyntaxError: expected ':'
v3 is the one to look at. It passes three of four and fails only the single-element input, which is what a test set without edge cases would have scored 100%.

Two things this toy leaves out and production cannot. Run model-written code in a sandbox with no network and no filesystem, in a separate process, because you are executing text a model produced from an input a user controls. And put a timeout on it, since a plausible-looking loop that never terminates will otherwise hang the eval run rather than failing a case.

LLM as judge#

For things code cannot check, such as whether a summary is faithful or a tone is right, a model can grade against a written rubric. The rubric has to be specific enough that two people applying it would agree.

Weak rubric:   "Is this a good summary?"

Usable rubric:
  Check one thing: does the summary claim anything the source
  does not support? Ignore style and length.

  FAIL if any claim is missing from the source or contradicts it.
  PASS if every claim can be found in the source.

  One sentence of critique, then PASS or FAIL on its own line.

Validate the judge#

The step almost everyone skips

An unvalidated judge is an opinion with a number attached. Label outputs yourself, including plenty of failures, run the judge on the same outputs, and check two things separately: how many of your failures it caught, and how many of your passes it accepted. If either is poor, the rubric is usually the problem.

Everything below runs against a stub judge rather than a hosted one, so the protocol executes offline and the same numbers come out every time. The stub has three knobs: how often it flags a real failure, how often it flags a good output, and later how much the order or the length of an answer sways it. Those knobs are what you are trying to measure in a real judge, and the code around them is exactly what you would run against one.

import numpy as np
rng = np.random.default_rng(7)

N = 400
human_fail = rng.random(N) < 0.12       # your own labels: 12% of outputs are bad

def stub_judge(human_fail, catch, false_alarm, rng):
    """Stands in for a hosted judge so this runs offline. catch is how often it
    flags a real failure, false_alarm how often it flags a good output."""
    r = rng.random(len(human_fail))
    return np.where(human_fail, r < catch, r < false_alarm)

naive = stub_judge(human_fail, catch=0.45, false_alarm=0.03, rng=rng)

human = ["FAIL" if x else "PASS" for x in human_fail]
judge = ["FAIL" if x else "PASS" for x in naive]
print(human.count("FAIL"), "failures in", len(human), "labelled outputs")

# 44 failures in 400 labelled outputs
The base rate is the number to notice: 44 failures in 400. Everything misleading below follows from that skew.
fails  = [j for h, j in zip(human, judge) if h == "FAIL"]
passes = [j for h, j in zip(human, judge) if h == "PASS"]
print(f"caught {fails.count('FAIL')}/{len(fails)} failures, "
      f"accepted {passes.count('PASS')}/{len(passes)} passes")

# caught 20/44 failures, accepted 350/356 passes
Two numbers, not one. This judge misses more than half of the failures it exists to find.

Quote those numbers whenever you quote the score. A single agreement figure can hide a judge that passes everything, because when failures are rare, passing everything still agrees most of the time. The next block shows how large that hiding effect is, and what to report instead.

Raw agreement is the share of outputs where you and the judge said the same thing. Cohen's kappa is the same quantity after subtracting the agreement two graders would reach by chance alone, given how often each of them says fail. It is defined as the observed agreement minus the chance agreement, divided by the room left above chance, so 1 means perfect and 0 means no better than guessing at the right rate.

def agreement(a, b):
    return float((a == b).mean())

def kappa(a, b):
    """Agreement above what two graders would reach by chance."""
    po = agreement(a, b)
    pa1, pb1 = a.mean(), b.mean()
    pe = pa1 * pb1 + (1 - pa1) * (1 - pb1)
    return float("nan") if pe == 1 else float((po - pe) / (1 - pe))

print("raw agreement", round(agreement(human_fail, naive), 3))
print("kappa        ", round(kappa(human_fail, naive), 3))

lazy = np.zeros(N, dtype=bool)          # a judge that passes everything
print("passes-everything judge: agreement", round(agreement(human_fail, lazy), 3),
      " kappa", round(kappa(human_fail, lazy), 3))

from sklearn.metrics import cohen_kappa_score
print("sklearn check", round(cohen_kappa_score(human_fail, naive), 3))

print()
print("base rate   agreement of a judge that passes everything")
for base in (0.02, 0.05, 0.12, 0.30):
    print(f"{base:>8.0%}   {1 - base:>5.0%}")

# raw agreement 0.925
# kappa         0.533
# passes-everything judge: agreement 0.89  kappa 0.0
# sklearn check 0.533
#
# base rate   agreement of a judge that passes everything
#       2%     98%
#       5%     95%
#      12%     88%
#      30%     70%
A judge that reads nothing and says PASS scores 89% agreement here, and 98% on a system that fails 2% of the time. Kappa scores it 0, which is what it deserves.

That table is the reason to bother. On a good system, where failures are the two per cent you are hunting for, raw agreement is pinned near 98% no matter how bad the judge is, and the last two points of it carry all the information. Kappa moves over the whole range instead. The rough reading most people use is that below 0.4 the judge is not usable, 0.4 to 0.6 is weak, 0.6 to 0.8 is usable for tracking a number over time, and above 0.8 is as good as a second human. Those bands are convention rather than law, so quote the number itself.

Now fix the rubric: name the failure mode precisely, say what to ignore, and give one worked example of a pass and one of a fail. In the stub that is a judge that catches 85% of failures instead of 45%. Watch what each metric says about the improvement.

rubric = stub_judge(human_fail, catch=0.85, false_alarm=0.04, rng=rng)

print("naive judge : agreement", round(agreement(human_fail, naive), 3),
      " kappa", round(kappa(human_fail, naive), 3))
print("with rubric : agreement", round(agreement(human_fail, rubric), 3),
      " kappa", round(kappa(human_fail, rubric), 3))
fails_n = int(human_fail.sum())
print(f"caught {int((rubric & human_fail).sum())}/{fails_n} real failures, "
      f"false alarms on {int((rubric & ~human_fail).sum())}/{int((~human_fail).sum())} good outputs")

# naive judge : agreement 0.925  kappa 0.533
# with rubric : agreement 0.948  kappa 0.763
# caught 40/44 real failures, false alarms on 17/356 good outputs
Raw agreement calls this a two-point improvement and would not survive a meeting. Kappa calls it the difference between unusable and usable, which is what actually happened.

Then the question everyone asks next: how many labels does this take? Bootstrap the kappa you just measured, by resampling your labelled set with replacement and recomputing, to see how much of it is the judge and how much is the sample.

def kappa_interval(human, judge, n_labels, boot=2000, seed=1):
    r = np.random.default_rng(seed)
    ks = []
    for _ in range(boot):
        idx = r.integers(0, n_labels, n_labels)
        ks.append(kappa(human[:n_labels][idx], judge[:n_labels][idx]))
    ks = np.array(ks)                   # nan where a resample drew no failures
    lo, hi = np.nanpercentile(ks, [2.5, 97.5])
    return lo, hi

for n in (50, 100, 400):
    lo, hi = kappa_interval(human_fail, rubric, n)
    print(f"{n:>4} labels ({int(human_fail[:n].sum()):>2} of them failures): "
          f"kappa {kappa(human_fail[:n], rubric[:n]):.2f}, 95% interval {lo:.2f} to {hi:.2f}")

#   50 labels ( 6 of them failures): kappa 0.81, 95% interval 0.39 to 1.00
#  100 labels (11 of them failures): kappa 0.67, 95% interval 0.43 to 0.86
#  400 labels (44 of them failures): kappa 0.76, 95% interval 0.66 to 0.85
Fifty labels put kappa somewhere between weak and perfect, which is not an answer. It is the failures that carry the information, so what you need is not four hundred labels but forty failures.

That is the practical rule. Count failures, not labels, and if your system is good enough that failures are rare, go and find them deliberately rather than labelling four thousand outputs to stumble on forty. Cases the judge and a code check disagree about are a good place to look, and so is anything a user complained about.

Measuring position bias#

When a judge compares two answers, which one is shown first changes the verdict. This is not a rumour to design around, it is a quantity you can measure in an afternoon: run every comparison twice with the order swapped and look at the gap between the two win rates. Here B is genuinely better, winning 58% of the time on merit, and the judge has a pull towards whichever answer it sees first.

import numpy as np
rng = np.random.default_rng(11)

def pairwise_stub(n, p_b_on_merit, position_pull, rng):
    """Each pair judged in both orders. Returns whether B won when it was
    shown second, and whether it won when it was shown first."""
    b_second = rng.random(n) < (p_b_on_merit - position_pull)
    b_first  = rng.random(n) < (p_b_on_merit + position_pull)
    return b_second, b_first

N = 600
b_second, b_first = pairwise_stub(N, p_b_on_merit=0.58, position_pull=0.15, rng=rng)

print("B wins when shown second:", f"{b_second.mean():.1%}")
print("B wins when shown first :", f"{b_first.mean():.1%}")
print("position gap            :", f"{b_first.mean() - b_second.mean():.1%}")
print("swap and average        :", f"{(b_first.mean() + b_second.mean()) / 2:.1%}")

both = (b_first & b_second).mean()
neither = (~b_first & ~b_second).mean()
print(f"consistent verdicts: B twice {both:.1%}, A twice {neither:.1%}, "
      f"flipped {1 - both - neither:.1%}")

# B wins when shown second: 44.5%
# B wins when shown first : 75.5%
# position gap            : 31.0%
# swap and average        : 60.0%
# consistent verdicts: B twice 32.8%, A twice 12.8%, flipped 54.3%
Judged in one fixed order with B second, B loses 44.5 to 55.5 and you ship the worse version. The same comparisons, swapped and averaged, recover the truth.

Three things follow. Always run both orders; it doubles the cost of a pairwise eval and is the cheapest correctness fix in this section. Report the gap between the two orders as a property of your judge, because it tells you how much to trust any comparison you cannot afford to run twice. And do not use the flip rate as your bias measure: over half the pairs here flip, but most of that is the judge's own inconsistency rather than position, which is why the gap between the two order-specific win rates is the number to quote.

The same swap trick answers a question nobody asks and everybody should: how often does the judge agree with itself? Run identical comparisons twice in the same order. Whatever fraction disagrees is a ceiling on how much any measurement from this judge can be trusted.

Measuring verbosity bias#

The other bias with a clean experiment behind it. Take answers you have already judged, append a paragraph that adds no information, and judge them again. The content is identical by construction, so any change in the pass rate is the judge responding to length.

import numpy as np

PADDING = (" It is worth noting that policies of this kind are designed with "
           "the customer in mind, and we are always happy to help further.")

def scoring_stub(answers, quality, length_weight, rng):
    """Pass or fail on one answer at a time. length_weight is how much the
    judge is swayed by how long the answer is."""
    words = np.array([len(a.split()) for a in answers])
    pull = length_weight * (words - 25) / 25
    return (quality + pull + rng.normal(0, 0.15, len(answers))) > 0.5

base = ["The annual plan can be refunded within 14 days of purchase, "
        "and the refund reaches your card in about 5 working days."] * 300
quality = np.random.default_rng(3).normal(0.55, 0.2, 300)   # same answers, same quality

for weight, label in ((0.25, "judge as written"), (0.02, "after 'ignore length'")):
    plain  = scoring_stub(base, quality, weight, np.random.default_rng(5))
    padded = scoring_stub([a + PADDING for a in base], quality, weight,
                          np.random.default_rng(5))
    print(f"{label:<24} plain {plain.mean():>5.0%}   padded {padded.mean():>5.0%}   "
          f"gap {padded.mean() - plain.mean():>+5.0%}")
print("words:", len(base[0].split()), "plain,", len((base[0] + PADDING).split()), "padded")

# judge as written         plain   52%   padded   87%   gap  +35%
# after 'ignore length'    plain   56%   padded   61%   gap   +4%
# words: 22 plain, 46 padded
Thirty-five points from two sentences of filler. Run this probe on your own judge before you believe any score it produces, and run it again after every rubric change.

A judge with that bias does more than inflate a number. It will reliably prefer the more verbose of two prompts, so if you are hill-climbing against it you will be taught to make your product more long-winded, one accepted change at a time, while the score goes up. The same probe with the padding replaced by confident phrasing measures a related bias, and the one with a citation appended to an unsupported claim measures a third.

Judge biases to control for#

  • Length: judges prefer longer answers. Say in the rubric that length is not a factor.
  • Self-preference: a model tends to prefer output written in its own style. Consider a different model as judge.
  • Position: when comparing two answers, the order affects the verdict. Run both orders and average.
  • Leniency: judges pass too much by default. Give a concrete example of a failure in the rubric.

Every one of those mitigations is a hypothesis until you re-measure. The loop is the same each time: probe for the bias, change one thing in the rubric, run the probe again, then recompute kappa against your labels to make sure the fix did not cost you accuracy elsewhere. A rubric line that removes verbosity bias by making the judge reject everything has not helped, and only the kappa will tell you.

Two more habits are worth the trouble. Keep the labelled set split, tuning the rubric on one half and reporting kappa on the other, for the same reason the dataset section splits the eval set. And pin the judge: its model version, its rubric text and its temperature are part of your measuring instrument, so changing any of them invalidates comparisons with last month exactly as changing the case set does.

Writing the rubric itself, including what to put in it and what a good critique-then-verdict prompt looks like, is covered in the LLM-as-judge topic. This section is the measurement around it: what the number means, how far it is from the truth, and which of the biases you have actually got.

Where people go wrong

Using the same model as both generator and judge with no validation. It systematically prefers its own output, so the score drifts upward while quality does not.

If this comes up in an interview

How do you evaluate something subjective like summary quality?

Use deterministic checks for everything checkable first. For what remains, break 'quality' into specific failure modes you have seen, such as unsupported claims or missing the main point, and write a pass or fail judge for each. Validate each judge against your own labels and report how well it catches failures alongside the score.

What are the known biases of an LLM judge?

Preference for longer answers, preference for its own writing style, sensitivity to the order of compared options, and general leniency. Each has a mitigation: rule length out in the rubric, use a different judge model, run both orders, and include concrete examples of failures in the rubric.

Your judge scores 4.2 out of 5. How much do you trust that?

Not much, for two reasons. Without a measured comparison against human grading the number is unanchored. And an average on a five point scale does not say what is wrong. I would replace it with pass or fail judges for specific failure modes, validated against my own labels, so the result reads as 'unsupported claims in 12% of summaries' rather than 4.2.

Your judge agrees with your labels 92% of the time. Is it good enough to use?

That number on its own cannot answer it, because it depends entirely on how often failures occur. If 12% of outputs are bad, a judge that simply passes everything already agrees 88% of the time, so 92% is barely above doing nothing. I would report Cohen's kappa, which subtracts the agreement two graders reach by chance, along with the catch rate on real failures and the acceptance rate on real passes. A judge at 92% agreement can sit anywhere from a kappa of 0.2 to 0.8 depending on where the mistakes fall.

Then they push further

What kappa would you want before trusting it?

Above 0.6 to track a number over time, and above 0.8 before using it to gate a release. The bands are convention rather than law, so I would quote the value with an interval rather than a label. And the interval matters: with fifty labels of which six are failures, kappa can range from 0.4 to 1.0, which decides nothing.

So how many labels do you need?

Count failures, not labels. Roughly forty failures in the validation half is where the interval on kappa gets narrow enough to act on. If the system fails rarely, go and find failures deliberately rather than labelling thousands of outputs to collect them by chance.

How would you measure whether the judge is biased by answer length?

A controlled probe. Take outputs you have already judged, append a paragraph that adds no information, and judge them again. The content is identical, so any change in the pass rate is the judge responding to length. A judge that swings thirty points on padding will teach your prompts to be verbose if you hill-climb against it.

What gets marked up. Strong answers immediately ask what the base rate is, and reach for kappa or for separate catch and acceptance rates. Very strong ones bring up the sample size of the failures. Quoting raw agreement as sufficient is the answer this question is designed to catch.

You are comparing two prompt versions with a pairwise judge. What do you do about position bias?

Run every comparison twice with the answers in both orders, and average. The gap between the win rate in one order and the other is the size of the bias, and it is worth reporting as a property of the judge. In a realistic case a fifteen-point pull towards whichever answer is shown first is enough to reverse the verdict on a version that is genuinely better, so judging in one fixed order can send you the wrong way.

Build this: Write a pass or fail faithfulness judge, label thirty outputs by hand with at least ten failures, run the judge over the same thirty, and count failures caught and passes accepted. Tune the rubric, and notice what the disagreements were really about.

This is one section of Evaluation, 35 hours across 7 sections. Read it in full to get the whole topic in order.

Stop 30 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeLLM-as-judgeGrading at scale, once you have proved the grader.10h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.