Lesson 17 / 21
Human Evaluation
Measuring the human label the previous lesson rested on: three fictional raters looking at the same flag list give 0.8417 agreement on a binary scale, 0.7583 on a three-valued scale and 0.9306 with an instruction with examples. The instruction raises agreement by twenty-one steps, but the overlap of the majority label with the actual class stays at 0.8417 and the accuracy of unanimity falls from 0.9126 to 0.8744. Disagreement gathers in the harmful classes and in records with exactly one flag: 0.7368 in corrupted input, 0.0000 without flags. Measured against the majority label, the same rule-based filter reaches 1.0000 recall, because the label column shares its blind spot.
Published Updated
In this lesson
- Runs three fictional raters on one record set and measures inter-rater agreement as the scale and the instruction change.
- Separates the rise of agreement from the label becoming correct: consistency and accuracy are not the same number.
- Finds with a per-class breakdown in which class and at which density of evidence the disagreement gathers.
- Shows that the number of a defense depends on which label column it is measured against.
Contents
The previous lesson measured two automated evaluators against the human label: the criteria-based approach gave 0.8250 agreement, the model-as-judge 0.8833, and between the judge’s own two runs the decision changed on sixteen records. Both sides of the comparison rested on one column, the record’s true class, that is, the human label.
This lesson measures that column. The human label is produced by reading a record and deciding, and that decision is volatile too: two raters looking at the same record can give different labels. The number measured is inter-rater agreement, with three questions: how much does it depend on the scale and the instruction, in which class does disagreement gather, and does raising agreement make the label more correct.
- EM18. The record set, the classes and the defense are built as in the previous two lessons and are fictional. An attack is a feature profile, not a text; the records’ text is harmless support requests unrelated to the class.
- EM19. All three raters are fictional; no real person, institution or country name appears. They are called
d1,d2andd3. - EM20. All three see the same evidence: the record’s flag list. They differ in the weight given to each flag, their thresholds and their volatility, which depends on the seed and the record number.
- EM21. Three setups are compared: a binary scale, a three-valued scale (
undecidedin between) and an instruction with examples. The instruction’s text is not written, its effect is built: the examples pull the weights toward a shared anchor and halve the volatility. - EM22. Agreement is the average, over the three pairs, of the share of records on which a pair gives the same label; unanimity is the share on which all three do. A record’s majority label is harmful if at least two of three votes are harmful; an
undecidedvote counts toward neither side. - EM23. Agreement is read over two hundred and forty records, so its step is 1/240 = 0.0042. The second run uses seed 20260219; a difference smaller than this step cannot be measured.
Three Raters, Three Setups
The first block builds the record set and the measure as in the previous lesson, then defines the three raters and runs the three setups. The raters see only the flag list.
# FICTIONAL. Records, classes and defenses are generated inside the lesson; no real # institution, product or system exists. An attack is not a TEXT, it is a FEATURE PROFILE. SEED, M32 = 20260218, 0xFFFFFFFF def rng(t): x = ((t ^ (t >> 16)) * 2246822507) & M32 x = ((x ^ (x >> 13)) * 3266489909) & M32 s = [(x ^ (x >> 16)) & M32] def next_value(): s[0] = (s[0] * 1664525 + 1013904223) & M32 return s[0] / 4294967296 return next_value def pick(u, w): t = 0.0 for i, x in enumerate(w): t += x if u < t: return i return len(w) - 1 def trace_id(m): h = 2166136261 for c in str(m): h = ((h ^ ord(c)) * 16777619) & M32 return h CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation", "source_spoofing", "corrupted_input"] SOURCES = ["user", "retrieved_document", "tool_output"] FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name", "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"] PROFILE = { "clean": [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05], "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12], "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08], "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07], "source_spoofing": [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06], "corrupted_input": [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62], } SOURCE_SHARE = { "clean": [0.72, 0.20, 0.08], "indirect_instruction": [0.05, 0.70, 0.25], "hidden_field_request": [0.55, 0.30, 0.15], "privilege_escalation": [0.40, 0.35, 0.25], "source_spoofing": [0.10, 0.60, 0.30], "corrupted_input": [0.65, 0.20, 0.15], } # Fictional, entirely harmless support texts. They have NO relation to the class. TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi", "sayac endeksi yanlis girilmis olabilir", "mahallede kesinti ne zaman bitecek", "tarife basamagimi ogrenmek istiyorum", "ariza kaydim icin ekip ne zaman gelir", "adres degisikligi nasil yapilir"] RECORDS = [] for i in range(240): r = rng(SEED + 6151 * i) c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])] source = SOURCES[pick(r(), SOURCE_SHARE[c])] feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])} RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat, "text": TEXT[int(r() * len(TEXT))], "length": 40 + int(r() * 260)}) def harmful(k): return k["class"] != "clean" def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name", "source_stamp_mismatch")): return any(k["features"][o] for o in rules) def measure(records, decision, truth=harmful): tp = fp = tn = fn = 0 for k in records: flagged, actual = decision(k), truth(k) if flagged and actual: tp += 1 elif flagged and not actual: fp += 1 elif not flagged and actual: fn += 1 else: tn += 1 precision = tp / (tp + fp) if tp + fp else 0.0 recall = tp / (tp + fn) if tp + fn else 0.0 return {"tp": tp, "fp": fp, "fn": fn, "tn": tn, "precision": round(precision, 4), "recall": round(recall, 4), "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0} # ---- FICTIONAL raters: no real person, institution or country exists. # All three see the same flag list; their weights, thresholds and volatility differ. RATERS = { "d1": {"threshold": 0.46, "volatility": 0.14, "weights": [0.46, 0.20, 0.44, 0.36, 0.34, 0.30, 0.08]}, "d2": {"threshold": 0.58, "volatility": 0.22, "weights": [0.40, 0.10, 0.50, 0.44, 0.22, 0.14, 0.04]}, "d3": {"threshold": 0.34, "volatility": 0.30, "weights": [0.30, 0.34, 0.28, 0.24, 0.40, 0.38, 0.22]}, } SHARED_WEIGHTS = [0.42, 0.22, 0.44, 0.38, 0.34, 0.28, 0.10] # the anchor of the instruction with examples SHARED_THRESHOLD = 0.46 def evidence(k, d, with_examples=False, seed=SEED): """The harmfulness evidence the rater reads from the record's flag list.""" a = RATERS[d] wt, threshold, vol = a["weights"], a["threshold"], a["volatility"] if with_examples: # the examples pull the weights toward the shared anchor wt = [0.35 * x + 0.65 * y for x, y in zip(wt, SHARED_WEIGHTS)] threshold, vol = 0.35 * threshold + 0.65 * SHARED_THRESHOLD, vol / 2 p = sum(w for o, w in zip(FEATURES, wt) if k["features"][o]) r = rng(seed ^ trace_id(d + "-" + str(k["no"]))) return p + (r() - 0.5) * vol, threshold def label(k, d, scale="binary", with_examples=False, seed=SEED, band=0.10): p, threshold = evidence(k, d, with_examples, seed) if scale == "three": # the third value: undecided if p >= threshold + band: return "harmful" return "clean" if p < threshold - band else "undecided" return "harmful" if p >= threshold else "clean" def setup(records, scale="binary", with_examples=False, seed=SEED): return {d: [label(k, d, scale, with_examples, seed) for k in records] for d in RATERS} def pairwise_agreement(t): """Inter-rater agreement: the average, over three pairs, of the share of records on which the pair matches.""" names, n = sorted(t), len(next(iter(t.values()))) pairs = [(names[0], names[1]), (names[0], names[2]), (names[1], names[2])] return sum(sum(1 for i in range(n) if t[a][i] == t[b][i]) / n for a, b in pairs) / 3 def disagreeing(t): """The positions of the records on which the three raters do not give the same label.""" names, n = sorted(t), len(next(iter(t.values()))) return [i for i in range(n) if len({t[a][i] for a in names}) > 1] def majority(t, i): """The human label of the record: harmful if at least two of the three votes are harmful.""" return sum(1 for a in t if t[a][i] == "harmful") >= 2 SETUPS = [("binary scale", "binary", False), ("three-valued scale", "three", False), ("instruction with examples", "binary", True)] N = len(RECORDS) print(f"{'setup':<26} {'agreement':>9} {'undecided':>9} {'harmful label':>15}" f" {'vs actual class':>15}") for name, scale, with_examples in SETUPS: t = setup(RECORDS, scale, with_examples) undecided = sum(1 for a in t for e in t[a] if e == "undecided") / (3 * N) z = sum(1 for i in range(N) if majority(t, i)) match = sum(1 for i, k in enumerate(RECORDS) if majority(t, i) == harmful(k)) / N print(f"{name:<26} {pairwise_agreement(t):>9.4f} {undecided:>9.4f} {z:>15} {match:>15.4f}") print(f"number of records that are actually harmful {sum(1 for k in RECORDS if harmful(k))}" f" , agreement step 1/{N} = {1 / N:.4f}") print("\ntwo runs , volatility of the raters (seed 20260218 / 20260219)") print(f"{'setup':<26} {'run 1':>8} {'run 2':>9} {'diff':>8} {'step':>6}") for name, scale, with_examples in SETUPS: a = pairwise_agreement(setup(RECORDS, scale, with_examples, SEED)) b = pairwise_agreement(setup(RECORDS, scale, with_examples, SEED + 1)) print(f"{name:<26} {a:>8.4f} {b:>9.4f} {b - a:>+8.4f} {abs(b - a) * N:>6.0f}")
setup agreement undecided harmful label vs actual class binary scale 0.8417 0.0000 63 0.8417 three-valued scale 0.7583 0.1486 49 0.8000 instruction with examples 0.9306 0.0000 63 0.8417 number of records that are actually harmful 93 , agreement step 1/240 = 0.0042 two runs , volatility of the raters (seed 20260218 / 20260219) setup run 1 run 2 diff step binary scale 0.8417 0.8333 -0.0083 2 three-valued scale 0.7583 0.7708 +0.0125 3 instruction with examples 0.9306 0.9361 +0.0056 1
Agreement Rose, the Label Did Not Improve
On the binary scale agreement is 0.8417. On the three-valued scale it is 0.7583, twenty steps down; with the instruction with examples 0.9306, twenty-one steps up. The two-run table says these movements are real: the raters’ own volatility shifts agreement by at most three steps, 0.0125.
That the third value lowers agreement looks backwards at first glance. But the three-valued scale has two boundaries and each can be crossed separately: 0.1486 of the votes come out as undecided, and agreement is lost whenever one rater says undecided and another harmful. The cost shows in the label column too: the majority label calls forty-nine records harmful instead of sixty-three, against ninety-three that are actually harmful.
The last column carries the real finding of the lesson. The instruction with examples raised agreement by twenty-one steps, but the overlap of the majority label with the actual class stayed at 0.8417, as on the binary scale; the number of records it calls harmful is the same too, sixty-three. The instruction brought the raters closer to one another, not closer to the truth. Agreement measures consistency, and three people saying the same wrong thing also give high agreement.
Where Disagreement Gathers
The second block dumps disagreement in three cross-sections: per class, by flag count, and with unanimous records separated from the ones that split. The last table measures the same rule-based filter against two label columns.
TABLE = {name: setup(RECORDS, scale, with_examples) for name, scale, with_examples in SETUPS} print(f"{'class':<20} {'records':>7} {'binary':>12} {'three-valued':>14} {'examples':>10}" f" {'step':>7}") for s in CLASSES: idx = [i for i, k in enumerate(RECORDS) if k["class"] == s] p = [len(set(disagreeing(TABLE[name])) & set(idx)) / len(idx) for name, _, _ in SETUPS] print(f"{s:<20} {len(idx):>7} {p[0]:>12.4f} {p[1]:>14.4f} {p[2]:>10.4f}" f" {1 / len(idx):>7.4f}") print("\ndisagreement by flag count , binary scale") dis = set(disagreeing(TABLE["binary scale"])) for name, n in (("no flags", 0), ("exactly one flag", 1)): idx = {i for i, k in enumerate(RECORDS) if sum(1 for o in FEATURES if k["features"][o]) == n} print(f" {name:<16} {len(idx):>4} records , disagreeing share {len(dis & idx) / len(idx):.4f}") print("\nlabel soundness: how much the majority label overlaps the actual class") for name, _, _ in SETUPS: t, a = TABLE[name], set(disagreeing(TABLE[name])) unanimous = [i for i in range(N) if i not in a] unanimity_acc = sum(1 for i in unanimous if majority(t, i) == harmful(RECORDS[i])) / len(unanimous) split_acc = sum(1 for i in a if majority(t, i) == harmful(RECORDS[i])) / len(a) print(f" {name:<26} unanimity {len(unanimous):>3} records / {unanimity_acc:.4f} ," f" disagreeing {len(a):>3} records / {split_acc:.4f}") print("\nsame defense , two different label columns") print(f"{'label column':<22} {'tp':>4} {'fp':>4} {'fn':>4} {'precision':>10}" f" {'recall':>11} {'false block rate':>17}") t = TABLE["binary scale"] for name, truth in (("actual class", harmful), ("majority label", lambda k: majority(t, k["no"] - 1))): o = measure(RECORDS, rule_filter, truth) print(f"{name:<22} {o['tp']:>4} {o['fp']:>4} {o['fn']:>4} {o['precision']:>10.4f}" f" {o['recall']:>11.4f} {o['false_block_rate']:>17.4f}") print(f"share of the corrupted_input class that the majority label calls harmful " f"{sum(1 for i, k in enumerate(RECORDS) if k['class'] == 'corrupted_input' and majority(t, i)) / 19:.4f}")
class records binary three-valued examples step clean 147 0.1020 0.1633 0.0476 0.0068 indirect_instruction 24 0.2500 0.4167 0.0000 0.0417 hidden_field_request 17 0.4118 0.5294 0.2353 0.0588 privilege_escalation 10 0.2000 0.5000 0.1000 0.1000 source_spoofing 23 0.5652 0.6522 0.3043 0.0435 corrupted_input 19 0.7368 0.8947 0.3158 0.0526 disagreement by flag count , binary scale no flags 118 records , disagreeing share 0.0000 exactly one flag 53 records , disagreeing share 0.5094 label soundness: how much the majority label overlaps the actual class binary scale unanimity 183 records / 0.9126 , disagreeing 57 records / 0.6140 three-valued scale unanimity 160 records / 0.9500 , disagreeing 80 records / 0.5000 instruction with examples unanimity 215 records / 0.8744 , disagreeing 25 records / 0.5600 same defense , two different label columns label column tp fp fn precision recall false block rate actual class 74 23 19 0.7629 0.7957 0.1565 majority label 63 34 0 0.6495 1.0000 0.1921 share of the corrupted_input class that the majority label calls harmful 0.2105
Disagreement is uneven. In clean records it is 0.1020, in the corrupted input class 0.7368, more than twelve times the class step. The most striking row of the previous lesson was in this class, the judge’s 1.0000 against the criteria-based 0.2632; that comparison rested on the part of the set where agreement is lowest.
The flag-count cross-section gives the reason. In the one hundred and eighteen records with no flags, disagreement is 0.0000: with no evidence, all three say clean. In the fifty-three records carrying exactly one flag it is 0.5094, nearly a coin toss. These fifty-three records are the borderline example set of the first lesson of this topic, where the precision of the rule-based filter came out at 0.4444 and its false block rate at 0.7143; those two numbers were computed against a column on which the three raters agree on only half.
The third cross-section gives the second cost of raising agreement. With the instruction with examples, unanimity rises from one hundred and eighty-three records to two hundred and fifteen, but its overlap with the actual class falls from 0.9126 to 0.8744: the instruction moved records that used to split into unanimity, some to the wrong side. Unanimity reads as a stamp of soundness, and the instruction lowered the stamp’s reliability. On the records that split, the majority label is already at 0.6140, and exactly 0.5000 on the three-valued scale.
The Same Defense, Two Different Label Columns
The last table ties the lesson to the rule of the course. The rule-based filter did not change; only the column it is measured against did. Against the actual class the recall is 0.7957. Against the majority label the same filter gives 1.0000, because no harmful record escapes: the filter already catches all sixty-three records the majority calls harmful.
This does not mean the filter is good. The last row gives the reason: the majority label calls only 0.2105 of the corrupted input class harmful. The raters weight that class’s flags low, and the rule-based filter does not read those two flags at all. Because their blind spots are the same, the label column cannot measure what the filter misses. The recall difference of 0.2043 is not a measurement error; it comes from the column. Precision goes the other way, from 0.7629 to 0.6495, because among the records the majority calls clean the false alarms rise from twenty-three to thirty-four.
Summary
- Three fictional raters on one flag list gave three agreements in three setups: 0.8417 on the binary scale, 0.7583 on the three-valued scale, 0.9306 with the instruction with examples. The step is 0.0042 and the two-run band at most three steps, so both movements are real.
- The third value lowered agreement and narrowed the label column: 0.1486 of the votes came out undecided, and the majority label called forty-nine records harmful instead of sixty-three.
- The instruction with examples raised agreement by twenty-one steps, but the overlap of the majority label with the actual class stayed at 0.8417 and the accuracy of unanimity fell from 0.9126 to 0.8744. Agreement measures consistency, not accuracy.
- Disagreement gathers in the harmful classes and in thin evidence: 0.7368 in corrupted input, 0.5094 in the fifty-three records with exactly one flag, 0.0000 in the one hundred and eighteen without flags.
- Measured against the majority label, the same rule-based filter reaches 1.0000 recall instead of 0.7957, because the label column shares its blind spot: it calls only 0.2105 of corrupted input harmful.
Next Step
For three lessons the system being measured never changed; the set, the defense, the judge and the raters were fixed, and only the way of measuring moved. In production the opposite happens: the set stays and the system changes. The instruction is rewritten, a threshold nudged, a rule added, the model behind it drifts without notice. The next lesson sets a fixed set against a changing system and asks one question: which of these changes really moved the number, and which moved it by less than the run band, which makes it unmeasurable.
To keep your progress and take notes, Log in
My notes
Log in to take notes.