Werkplaats / De werkplaats
In jouw eigen tempo
← Werken met LLM’s
MODULE 02 · LES 06

Ontwerp een evaluatie die fouten zichtbaar houdt

Controleer datagrenzen, rapporteer geldig én correct en vergelijk varianten met gepaarde resampling.

75 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je scheidt promptvoorbeelden, ontwikkeling en eindtest op de juiste brongroep.
  • Je berekent end-to-end- en groepsscores en een eenvoudig gepaard bootstrap-interval.

Een prompt haalt 100% accuracy op geldige antwoorden. Pas daarna blijkt dat de helft van alle antwoorden ongeldige JSON was. Een goede evaluatie (eval) maakt zulke fouten zichtbaar in plaats van ze uit de noemer te laten verdwijnen.

Leg vóór de meting gevallen, referenties, uitvoercontract en scores vast. Noteer bij echte modellen ook revisie, prompt, tokenizer, generatie-instellingen en herhalingen. Kies de variant op ontwikkeling; de eindtest komt pas nadat die keuze vaststaat.

1. Scheid gevallen op hun werkelijke bron

Twee verschillende vragen kunnen uit hetzelfde document komen. Als de ene een promptvoorbeeld is en de andere een testvraag, kan de evaluatie informatie uit de voorbeelden terugmeten. Gebruik daarom een brongroep als eenheid voor de splits.

rows = [
    dict(id='e',group='g',
         split='examples'),
    dict(id='d',group='g',split='dev'),
    dict(id='t',group='h',split='test')]
r = audit_eval_split(rows)
print(r['overlapping_groups'])
# {'g': ['dev', 'examples']}
print(r['missing_splits'])  # []

De case-IDs zijn verschillend, maar brongroep g komt in twee splits voor. Maak audit_eval_split met counts, duplicate_ids, overlapping_groups en missing_splits. Het rapport verwijdert niets automatisch.

Een brongroep is iets anders dan een rapportagecategorie. Een document of gesprek hoort bij één split; een categorie zoals directe vraag mag juist in meerdere splits voorkomen. Verschillende bron-IDs kunnen nog steeds semantisch dezelfde bron verbergen, dus de metadata-audit vervangt geen inhoudelijke controle.

01
CHECKPOINTVerwante gevallen moeten in dezelfde split blijven

2. Rapporteer alle gevallen en alle categorieën

Neem vier directe vragen: alle uitvoer is geldig en drie antwoorden kloppen. Een vijfde vraag uit een kleine categorie geeft ongeldige uitvoer. Dan is validity 4/5=.8, totale accuracy 3/5=.6 en accuracy onder geldige uitvoer 3/4=.75.

Noem het totale aantal N, het aantal geldige antwoorden V en het aantal juiste antwoorden H. Deze twee accuracies beantwoorden verschillende vragen:

accuracy=HNaccuracyvalid=HV
rows = [
    dict(id=str(i),group='direct',
         valid=True,correct=i<3)
    for i in range(4)]
rows.append(dict(id='rare',group='rare',
    valid=False,correct=False))
r = summarize_eval(rows)
overall = r['overall']
print(overall['validity'])  # .8
print(overall['accuracy'])  # .6
print(overall['accuracy_when_valid'])
# .75
print(r['macro_group_accuracy'])  # .375

Maak summarize_eval met overall, groups en macro_group_accuracy. Iedere categorie krijgt haar eigen n en scores. De directe categorie haalt .75 en de kleine categorie nul; hun ongewogen gemiddelde is (.75+0)/2=.375.

De totale accuracy weegt iedere case even zwaar; macro weegt iedere categorie even zwaar. Geen van beide is altijd de juiste hoofdmaat. Bewaar groepsaantallen en de regel dat correct=True ook valid=True vereist; zonder geldige antwoorden is accuracy_when_valid None.

BEWAAR ALLE GEVALLEN IN DE NOEMERWat verdwijnt achter een mooie totaalscore?

Acht directe vragen zijn goed. Verander het aantal moeilijke vragen en hun uitkomst; vergelijk de noemers.

Directn = 8
8/8
Moeilijkn = 2
0/2
Accuracy over alle gevallen
80.0%
8 correct / 10 gevallen
Accuracy bij geldige uitvoer
100.0%
8 correct / 8 geldig
Geldig uitvoercontract
80.0%
8 geldig / 10 gevallen
Macro per categorie
50.0%
Iedere categorie telt even zwaar

Meer moeilijke gevallen veranderen de totale accuracy. Zolang de categorie-accuracies gelijk blijven, verandert macro niet.

02
CHECKPOINTOngeldige uitvoer verdwijnt niet uit de noemer

3. Behoud de koppeling bij opnieuw trekken

Voor dezelfde zes gevallen scoort A tweemaal goed en B driemaal. Per geval is het verschil dᵢ gelijk aan correct(B) minus correct(A). In dit voorbeeld is die vector [0,−1,1,1,0,0], met gemiddelde 1/6.

Δ=∑i=1NdiN

Eén bootstrap-trekking kiest zes indices met teruglegging. De indices [2,2,3,0,4,5] leveren drie enen en drie nullen op: een nieuw gemiddelde .5. Dezelfde indices horen bij beide varianten.

import numpy as np
a = np.array([1,1,0,0,0,0],bool)
b = np.array([1,0,1,1,0,0],bool)
d = b.astype(float)-a.astype(float)
indices = [2,2,3,0,4,5]
print(d[indices].mean())  # .5

Herhaal dit en neem de 2,5e en 97,5e percentielen. Onze helper gebruikt een eenvoudig 95%-percentile-interval; dit is niet SciPy’s standaard BCa-methode. De seed maakt deze oefenmeting herhaalbaar.

r = paired_bootstrap(a,b,1000,7)
print(round(r['difference'],4))  # .1667
print(round(r['low'],4),
      round(r['high'],4))
# -0.5 0.6667

Maak paired_bootstrap voor uitgelijnde booleaanse vectoren. Gebruik dezelfde getrokken indices, 100–5000 herhalingen en de opgegeven seed. Bij afhankelijke vragen moet je bijvoorbeeld complete brongroepen resamplen, omdat losse gevallen dan geen onafhankelijke eenheden zijn.

Een interval lost dataverschuiving, verkeerde labels of herhaald promptzoeken niet op. Met weinig data kan het ook misleidend smal zijn. Veranderen na het bekijken van testfouten vraagt voor een nieuwe onafhankelijke eindclaim om nieuwe testdata.

03
CHECKPOINTTrek dezelfde gevallen opnieuw voor beide varianten

Wat neem je mee?

  • Brongroepen bewaken de split; categorieën maken verschillen zichtbaar.
  • Ongeldige uitvoer blijft in de totale noemer.
  • Gepaard resamplen bewaart welke antwoorden bij hetzelfde geval horen.

Hierna controleer je de beoordelaar die de uitkomsten van modellen vergelijkt.

Verder lezen & bronnen

Inhoud gecontroleerd op 2026-10-28.
DE VOLGENDE STAPControleer ook de beoordelaar
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 3
01
Verwante gevallen moeten in dezelfde split blijven

Schrijf audit_eval_split(records). 1–1000 dictionaries met exact id/group/split, niet-lege strings van maximaal 100 tekens. split is examples/dev/test. Geef counts per split, gesorteerde duplicate_ids, overlapping_groups met gesorteerde splitnamen en missing_splits in de volgorde examples/dev/test. Ongeldige records: ValueError.

Een hint

Bewaar een set splits per group en een set eerder geziene IDs. Een dubbel ID kan ook binnen één split voorkomen.

02
Ongeldige uitvoer verdwijnt niet uit de noemer

Schrijf summarize_eval(rows). 1–1000 dictionaries met exact id/group/valid/correct. IDs uniek; id/group niet-lege strings ≤100. valid/correct zijn echte bools, correct impliceert valid. Geef overall en alfabetische groups met n/validity/accuracy/accuracy_when_valid, plus macro_group_accuracy. Anders ValueError.

Een hint

Gebruik één metrics-helper voor alle rijen én iedere groep. Laat accuracy_when_valid None zijn wanneer valid nul is.

03
Trek dezelfde gevallen opnieuw voor beide varianten

Schrijf paired_bootstrap(a,b,draws=1000,seed=7). Twee uitgelijnde 1D-boolvectoren van 2–1000. draws is een exacte int 100–5000, seed een exacte int 0≤seed<2³². Gebruik np.random.default_rng(seed), trek per herhaling N indices met rng.integers(0,N,size=...) en np.quantile(...,[.025,.975]). Geef difference/low/high; anders ValueError.

Een hint

Vorm eerst de gepaarde verschilvector. Chunked resampling mag: dat begrenst het werkgeheugen zonder de rijvolgorde van de RNG te veranderen.

Vastgelopen? Bekijk de voorbeeldoplossing
def audit_eval_split(records):
    if not isinstance(records, list) or not 1 <= len(records) <= 1000:
        raise ValueError('Geef 1 tot 1000 records')
    counts = {'examples': 0, 'dev': 0, 'test': 0}
    ids, duplicates, groups = set(), set(), {}
    for row in records:
        if not isinstance(row, dict) or set(row) != {'id', 'group', 'split'} or any(not isinstance(v, str) or not v or len(v) > 100 for v in row.values()) or row['split'] not in counts:
            raise ValueError('Ongeldige case-metadata')
        counts[row['split']] += 1
        if row['id'] in ids:
            duplicates.add(row['id'])
        ids.add(row['id'])
        groups.setdefault(row['group'], set()).add(row['split'])
    overlap = {group: sorted(splits) for group, splits in sorted(groups.items()) if len(splits) > 1}
    return {'counts': counts, 'duplicate_ids': sorted(duplicates), 'overlapping_groups': overlap,
            'missing_splits': [name for name, count in counts.items() if count == 0]}

def summarize_eval(rows):
    if not isinstance(rows, list) or not 1 <= len(rows) <= 1000:
        raise ValueError('Geef 1 tot 1000 evaluatieregels')
    ids, groups = set(), {}
    for row in rows:
        if not isinstance(row, dict) or set(row) != {'id', 'group', 'valid', 'correct'}:
            raise ValueError('Ongeldige resultaatvelden')
        if any(not isinstance(row[key], str) or not row[key] or len(row[key]) > 100 for key in ['id', 'group']) or row['id'] in ids:
            raise ValueError('Unieke IDs en niet-lege groepen vereist')
        if type(row['valid']) is not bool or type(row['correct']) is not bool or row['correct'] and not row['valid']:
            raise ValueError('Juist vereist een geldig uitvoercontract')
        ids.add(row['id'])
        groups.setdefault(row['group'], []).append(row)
    def metrics(items):
        valid, correct, n = sum(r['valid'] for r in items), sum(r['correct'] for r in items), len(items)
        return {'n': n, 'validity': valid / n, 'accuracy': correct / n,
                'accuracy_when_valid': correct / valid if valid else None}
    by_group = {name: metrics(items) for name, items in sorted(groups.items())}
    return {'overall': metrics(rows), 'groups': by_group,
            'macro_group_accuracy': sum(group['accuracy'] for group in by_group.values()) / len(by_group)}

import numpy as np

def paired_bootstrap(a, b, draws=1000, seed=7):
    a, b = np.asarray(a), np.asarray(b)
    if a.ndim != 1 or not 2 <= a.size <= 1000 or b.shape != a.shape or a.dtype != bool or b.dtype != bool:
        raise ValueError('Twee uitgelijnde booleaanse scorevectoren vereist')
    if type(draws) is not int or not 100 <= draws <= 5000 or type(seed) is not int or not 0 <= seed < 2**32:
        raise ValueError('Ongeldig aantal trekkingen of seed')
    delta = b.astype(float) - a.astype(float)
    rng = np.random.default_rng(seed)
    samples = np.empty(draws)
    for start in range(0, draws, 128):
        count = min(128, draws - start)
        indices = rng.integers(0, a.size, size=(count, a.size))
        samples[start:start + count] = delta[indices].mean(axis=1)
    low, high = np.quantile(samples, [.025, .975])
    return {'difference': float(delta.mean()), 'low': float(low), 'high': float(high)}

Voer de code uit en verklaar voor jezelf waarom elke stap werkt.