Bouw een evaluatieharnas met een echte eindtest
Valideer gevallen, tel uitvoerfouten, kies uitsluitend op ontwikkeling en teken echte testresultaten per categorie.
De opdracht, leerdoelen en voorkennis
- Je bouwt een herhaalbaar harnas rond vervangbare voorspelfuncties.
- Je bewaakt splits, schemafouten en het onderscheid tussen variantselectie en eindrapportage.
Lees eerst: Behandel JSON-uitvoer als een voorstel dat je controleert · Ontwerp een evaluatie die fouten zichtbaar houdt · Controleer ook de beoordelaar.
Je bouwt een echt evaluatieharnas: gevallen controleren, voorspellen, uitvoer beoordelen, een variant kiezen en pas daarna de eindtest uitvoeren. De twee voorspellers zijn regelgebaseerde testdubbels, geen taalmodellen. Daardoor kun je fouten steeds opnieuw waarnemen zonder een modeldownload of betaalde aanvraag.
De twintig zelfgeschreven tickets zijn zichtbaar onderwijsmateriaal, geen geheime of representatieve benchmark. De trefwoordregel herkent enkele woorden; de tweede regel verwijdert eerst drie afgesproken ontkenningen. Later kun je een model achter dezelfde functiegrens plaatsen, met vastgelegde revisie, prompt en generatie-instellingen.
1. Maak de grens tussen bron en categorie expliciet
Elk geval heeft id, source, split, category, input en expected. source is de eenheid die bij één split moet blijven; category is een rapportagecategorie die in meerdere splits mag voorkomen. Begin met drie kleine gevallen, zodat iedere verplichte split aanwezig is.
tiny = []
for i,s in enumerate(
['examples','dev','test']):
tiny.append(dict(id=str(i),
source='s'+str(i),split=s,
category='direct',input='Hoe?',
expected='vraag'))
checked = prepare_eval_cases(tiny)
print(len(checked)) # 3
Maak prepare_eval_cases met nieuwe dictionaries. Weiger dubbele IDs, bronnen die over splits verdeeld zijn, ontbrekende splits en ongeldige velden of labels. Controleer de invoer zonder haar stilzwijgend te repareren.
De meegeleverde dataset heeft vier examples, acht dev-gevallen en acht test-gevallen. De bron-IDs zijn uniek, maar de controle kan niet ontdekken dat verschillende IDs inhoudelijk toch dezelfde bron verbergen. Een inhoudelijke datasetreview blijft dus nodig.
012. Tel een fout waar hij ontstaat
De strikte parser uit de vorige module is gegeven. score_response verwacht exact één label uit factuur, storing, vraag en onduidelijk. Syntax- en parserfouten krijgen error=json; geldige JSON met een verkeerd schema krijgt error=schema.
raw = ['{','{"label":0.5}',
'{"label":"storing"}']
for text in raw:
r = score_response(text,'vraag')
print(r['valid'],r['error'])
# False json
# False schema
# True None
Het laatste antwoord heeft een geldig label, maar is inhoudelijk fout voor doel vraag. Dat moet valid=True, correct=False blijven, zodat de vormcontrole niet de inhoudelijke score vervangt. Bij ongeldige uitvoer zijn beide booleans False.
Maak score_response en controleer ook het referentielabel vóór de afhandeling. Een verkeerd doellabel is een programmeer- of datafout en moet de evaluatie stoppen. Het mag niet als een fout van de voorspeller worden meegerekend.
023. Geef alleen de tekst aan de voorspeller
run_eval selecteert de gevraagde split en roept predictor(input) aan. expected, source en split gaan niet als argument naar de voorspeller. Een kleine getuige kan aantonen welk soort invoer hij krijgt.
seen = []
def predictor(text):
seen.append(isinstance(text,str))
return '{"label":"vraag"}'
r = run_eval(cases,predictor,'dev')
print(len(seen),all(seen)) # 8 True
print(r['summary']['overall']
['accuracy']) # 0.5
Maak run_eval met één resultaatrij per geselecteerd geval en afzonderlijke tellers model, json en schema. Alleen een exception uit de voorspeller telt als model-fout. Een tekstuele uitkomst gaat door score_response; summarize_eval is als gegeven helper beschikbaar.
De functiegrens voorkomt per ongeluk labeldoorgifte, maar is geen beveiligingssandbox binnen één Python-namespace. Een voorspeller kan daar nog steeds globale gegevens lezen. Een oneindige functie stopt ook niet door try/except; daarvoor bewaakt de buitenste browserworker de uitvoeringstijd.
034. Kies uitsluitend op ontwikkelresultaten
Vergelijk dezelfde dev-case-IDs. Kies eerst hoogste totale accuracy, daarna hoogste validity en ten slotte de alfabetisch eerste variantnaam. Dit is een expliciet oefenbeleid; kritieke toepassingen kunnen andere eisen aan categorieën stellen.
row = dict(id='1',group='g',
valid=True,correct=False)
good = dict(split='dev',rows=[row])
bad = dict(split='dev',
rows=[{**row,'valid':False}])
print(select_candidate(
{'a':bad,'z':good})) # z
Beide varianten hebben hier accuracy nul. z wint omdat zijn uitvoer wel geldig is, ondanks de latere alfabetische naam. Bereken de scores opnieuw uit rows; vertrouw geen ontbrekend, oud of aangepast summary-veld.
Maak select_candidate en weiger test als selectie-invoer. Voor de meegeleverde acht dev-gevallen halen de trefwoordregel en ontkenningsregel respectievelijk .5 en 1. De ontwikkelkeuze ligt dus vast vóór de eindmeting.
reports = {'keyword-v1':dev_a,
'negation-v1':dev_b}
selected = select_candidate(reports)
print(selected) # negation-v1
045. Teken wat de gekozen variant werkelijk deed
Voer alleen de gekozen versie uit op test. De ontkenningsregel haalt direct 4/4, negation 2/2 en paraphrase 0/2. Totaal is dat 6/8=.75; macro is (1+1+0)/3=2/3.
registry = {
'keyword-v1':keyword_predictor,
'negation-v1':negation_predictor}
report = final_evaluation(
cases,selected,registry)
summary = report['summary']
overall = summary['overall']
groups = summary['groups']
print(overall['accuracy']) # .75
print(groups['paraphrase']
['accuracy']) # 0.0
Maak final_evaluation met het echte rapport en een nieuwe Matplotlib-figuur. Teken de berekende accuracy per categorie: groepsnamen op de x-as, aantallen bij de balken en de gekozen variant in de titel. Een toevallig eerder geopende figuur is geen bewijs van deze meting.
De twee fouten komen doordat kapotte niet het exacte trefwoord kapot is en rekening niet factuur. De totale 75% verbergt volledige uitval op parafrases. Wie nu de regels verbetert, maakt een nieuwe variant en heeft nieuwe eindtestgevallen nodig voor een onafhankelijke eindclaim.
05Wat neem je mee?
- Het harnas scheidt invoer, voorspelling, uitvoercontract en inhoudelijke score.
- Ontwikkeling kiest de versie; de eindtest onderzoekt die vastgelegde keuze.
- Een berekende groepsfiguur maakt fouten zichtbaar die het totaal verbergt.
In de RAG-track gebruik je dezelfde discipline om zoekfouten en antwoordfouten apart te meten.
Bronnen: SciPy — gepaard bootstrap-resampling ↗ Python — JSON decoder ↗ · Gecontroleerd 2026-10-28.
Bevries gevallen, bron-ID en categorie
Maak eerst de evaluatie-invoer controleerbaar. Elk geval heeft een unieke id, een source die bij één split hoort, een rapportagecategorie, invoertekst en een verwacht label. De drie splits examples, dev en test moeten allemaal voorkomen.
Met drie kleine gevallen, één per split en ieder uit een andere bron, kun je de minimale geldige dataset maken. De gegeven twintig tickets bevatten vier voorbeelden, acht ontwikkelgevallen en acht testgevallen. Geef nieuwe dictionaries terug en verander de invoer niet.
Controleer de veldnamen, stringlengtes, labels en bronverdeling voordat je voorspelt. Verwijder een conflict niet automatisch. Verschillende bron-IDs garanderen nog niet dat de achterliggende teksten onafhankelijk zijn.
Bekijk voorbeeld
RAW_CASES = [
{'id':'e1','source':'source-e1','split':'examples','category':'direct','input':'Mijn factuur is niet aangekomen.','expected':'factuur'},
{'id':'e2','source':'source-e2','split':'examples','category':'direct','input':'De lamp is kapot.','expected':'storing'},
{'id':'e3','source':'source-e3','split':'examples','category':'direct','input':'Hoe stel ik de timer in?','expected':'vraag'},
{'id':'e4','source':'source-e4','split':'examples','category':'direct','input':'Bedankt voor de snelle hulp.','expected':'onduidelijk'},
{'id':'d1','source':'source-d1','split':'dev','category':'direct','input':'Ik mis mijn factuur van maart.','expected':'factuur'},
{'id':'d2','source':'source-d2','split':'dev','category':'direct','input':'Mijn adapter is kapot.','expected':'storing'},
{'id':'d3','source':'source-d3','split':'dev','category':'direct','input':'Hoe werkt de timer?','expected':'vraag'},
{'id':'d4','source':'source-d4','split':'dev','category':'direct','input':'Bedankt en fijne dag.','expected':'onduidelijk'},
{'id':'d5','source':'source-d5','split':'dev','category':'negation','input':'De lamp is niet kapot. Hoe kan ik hem dimmen?','expected':'vraag'},
{'id':'d6','source':'source-d6','split':'dev','category':'negation','input':'Er is geen storing. Hoe wijzig ik de naam?','expected':'vraag'},
{'id':'d7','source':'source-d7','split':'dev','category':'negation','input':'Ik vraag geen factuur. Hoe werkt aanmelden?','expected':'vraag'},
{'id':'d8','source':'source-d8','split':'dev','category':'negation','input':'Geen storing gemeld, alleen dank.','expected':'onduidelijk'},
{'id':'t1','source':'source-t1','split':'test','category':'direct','input':'Waar staat mijn factuur?','expected':'factuur'},
{'id':'t2','source':'source-t2','split':'test','category':'direct','input':'De sensor is kapot sinds gisteren.','expected':'storing'},
{'id':'t3','source':'source-t3','split':'test','category':'direct','input':'Hoe verander ik het volume?','expected':'vraag'},
{'id':'t4','source':'source-t4','split':'test','category':'direct','input':'Prima, dank je wel!','expected':'onduidelijk'},
{'id':'t5','source':'source-t5','split':'test','category':'negation','input':'Mijn sensor is niet kapot. Hoe reset ik hem?','expected':'vraag'},
{'id':'t6','source':'source-t6','split':'test','category':'negation','input':'Ik wil geen factuur. Hoe bekijk ik mijn profiel?','expected':'vraag'},
{'id':'t7','source':'source-t7','split':'test','category':'paraphrase','input':'Een kapotte sensor blokkeert de meting.','expected':'storing'},
{'id':'t8','source':'source-t8','split':'test','category':'paraphrase','input':'De rekening voor april ontbreekt.','expected':'factuur'},
]
def prepare_eval_cases(raw):
if not isinstance(raw, list) or not 1 <= len(raw) <= 1000:
raise ValueError('Geef 1 tot 1000 eigen gevallen')
ids, sources, splits, result = set(), {}, set(), []
for row in raw:
if not isinstance(row, dict) or set(row) != {'id', 'source', 'split', 'category', 'input', 'expected'}:
raise ValueError('Ongeldige casevelden')
if any(not isinstance(v, str) or not v.strip() or len(v) > (2000 if key == 'input' else 100) for key, v in row.items()):
raise ValueError('Begrensde niet-lege strings vereist')
if row['id'] in ids or row['split'] not in {'examples', 'dev', 'test'} or row['expected'] not in {'factuur', 'storing', 'vraag', 'onduidelijk'}:
raise ValueError('Dubbel ID, onbekende split of verkeerd doel')
if row['source'] in sources and sources[row['source']] != row['split']:
raise ValueError('Dezelfde bron mag niet in verschillende splits staan')
ids.add(row['id']);sources[row['source']] = row['split'];splits.add(row['split'])
result.append(dict(row))
if splits != {'examples', 'dev', 'test'}:
raise ValueError('Voorbeelden, ontwikkeling en eindtest zijn alle drie nodig')
return result
cases=prepare_eval_cases(RAW_CASES)
print("Gevallen:",len(cases))Maak ongeldige uitvoer tot een meetbaar resultaat
De parser is gegeven; bouw score_response als de volgende poort. Ongeldige JSON krijgt error=json, een verkeerd labelobject error=schema. In beide gevallen zijn valid en correct False.
Het getal 0.5 is geldige JSON maar een ongeldig labelschema. Het label storing is vormgeldig en toch fout wanneer het verwachte label vraag is. Houd die twee oorzaken in de score apart.
Een onjuist referentielabel is een fout in de meetopzet en geeft ValueError. Verberg die fout niet als een modelantwoordfout. Geef bij geldige uitvoer error=None en vergelijk het label exact.
Bekijk voorbeeld
import json
import math
def parse_contract_json(text):
if not isinstance(text, str) or len(text) > 4096:
raise ValueError('Maximaal 4096 UTF-8-bytes')
try:
if len(text.encode('utf-8')) > 4096:
raise ValueError('Maximaal 4096 UTF-8-bytes')
def object_pairs(pairs):
result = {}
for key, value in pairs:
key.encode('utf-8')
if key in result:
raise ValueError('Dubbele sleutel')
result[key] = value
return result
def constant(value):
raise ValueError('NaN en Infinity zijn geen JSON-getallen')
result = json.loads(text, object_pairs_hook=object_pairs, parse_constant=constant)
def walk(value, depth):
if isinstance(value, (dict, list)):
if depth > 8:
raise ValueError('Maximaal acht geneste containers')
for child in value.values() if isinstance(value, dict) else value:
walk(child, depth + 1)
elif isinstance(value, float) and not math.isfinite(value):
raise ValueError('Getal past niet in een eindige float')
elif isinstance(value, str):
value.encode('utf-8')
walk(result, 1)
except (UnicodeError, RecursionError, json.JSONDecodeError) as error:
raise ValueError('Geen geldig begrensd JSON-object') from error
if not isinstance(result, dict):
raise ValueError('Bovenaan staat een object')
return result
def score_response(raw, expected):
if not isinstance(expected, str) or expected not in {'factuur', 'storing', 'vraag', 'onduidelijk'}:
raise ValueError('Ongeldig doellabel')
try:
value = parse_contract_json(raw)
except ValueError:
return {'valid': False, 'correct': False, 'error': 'json'}
if set(value) != {'label'} or not isinstance(value['label'], str) or value['label'] not in {'factuur', 'storing', 'vraag', 'onduidelijk'}:
return {'valid': False, 'correct': False, 'error': 'schema'}
return {'valid': True, 'correct': value['label'] == expected, 'error': None}Geef de voorspeller alleen de invoertekst
Selecteer alleen dev of test en geef uitsluitend de invoertekst door aan predictor. Maak daarna één resultaatrij per geselecteerd geval, met het oorspronkelijke ID en de rapportagecategorie. De gegeven summarize_eval berekent de samenvatting.
Een voorspeller die altijd vraag antwoordt, krijgt voor de gegeven dev-set acht strings en haalt vier van acht goed. Gebruik een kleine loglijst om te controleren welk soort argument hij krijgt. De eigen regels keyword_predictor en negation_predictor zijn testdubbels, geen LLMs.
Tel een exception uit predictor als model-fout en JSON-/schemafouten in hun eigen teller. De functiegrens is geen sandbox en stopt geen oneindige lus. Daarvoor blijft de harde grens van de buitenste browserworker nodig.
Bekijk voorbeeld
import re
def keyword_predictor(text):
tokens = set(re.findall(r'\w+', text.lower()))
label = 'factuur' if 'factuur' in tokens else 'storing' if tokens & {'kapot', 'storing'} else 'vraag' if 'hoe' in tokens or '?' in text else 'onduidelijk'
return json.dumps({'label': label})
def negation_predictor(text):
text = re.sub(r'\b(niet kapot|geen storing|geen factuur)\b', '', text.lower())
return keyword_predictor(text)
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)}
def run_eval(cases, predictor, split):
cases = prepare_eval_cases(cases)
if not isinstance(split, str) or split not in {'dev', 'test'} or not callable(predictor):
raise ValueError('Kies dev/test en een functie die uitsluitend tekst ontvangt')
rows, failures = [], {'model': 0, 'json': 0, 'schema': 0}
for case in cases:
if case['split'] != split:
continue
try:
raw = predictor(case['input'])
except Exception:
score = {'valid': False, 'correct': False, 'error': 'model'}
else:
score = score_response(raw, case['expected'])
if score['error'] is not None:
failures[score['error']] += 1
rows.append({'id': case['id'], 'group': case['category'], 'valid': score['valid'], 'correct': score['correct']})
return {'split': split, 'rows': rows, 'failures': failures, 'summary': summarize_eval(rows)}
dev_a=run_eval(cases,keyword_predictor,"dev")
dev_b=run_eval(cases,negation_predictor,"dev")
print("Dev keyword / negation:",dev_a["summary"]["overall"]["accuracy"],dev_b["summary"]["overall"]["accuracy"])Kies op dev en leg de variantnaam vast
Kies alleen op ontwikkelresultaten en vergelijk dezelfde case-IDs. De hoogste totale accuracy wint; bij gelijke accuracy volgen hoogste validity en daarna alfabetische variantnaam. Dit is het afgesproken oefenbeleid.
Als twee varianten nul goede antwoorden hebben, wint de variant met geldige uitvoer boven de variant met ongeldige uitvoer. Bereken de scores opnieuw uit rows. Een ontbrekend of verouderd summary-veld mag de keuze niet bepalen.
De gegeven regels halen .5 en 1 op dev; negation-v1 wordt gekozen. Leg die naam vast vóór de eindtest. Een test-rapport als selectie-invoer moet worden geweigerd.
Bekijk voorbeeld
def select_candidate(reports):
if not isinstance(reports, dict) or not 1 <= len(reports) <= 8:
raise ValueError('Geef 1 tot 8 ontwikkelrapporten')
ranked = []
for name, report in reports.items():
if not isinstance(name, str) or not name or not isinstance(report, dict) or report.get('split') != 'dev':
raise ValueError('Selecteer uitsluitend op dev-rapporten')
# Recompute from case rows: do not trust a stale or edited aggregate field.
metrics = summarize_eval(report.get('rows'))['overall']
ranked.append((-metrics['accuracy'], -metrics['validity'], name))
case_ids = [{row['id'] for row in report['rows']} for report in reports.values()]
if any(ids != case_ids[0] for ids in case_ids[1:]):
raise ValueError('Vergelijk precies dezelfde ontwikkelgevallen')
return min(ranked)[2]
selected=select_candidate({"keyword-v1":dev_a,"negation-v1":dev_b})
print("Gekozen:",selected)Teken de score én de blinde vlek
Voer nu uitsluitend de gekozen variant uit op test. Gebruik het echte rapport om per categorie een nieuwe Matplotlib-balkfiguur te maken. Toon groepsnamen op de x-as, aantallen bij de balken en de gekozen variant in de titel; een oude figuur telt niet als deze meting.
De gegeven ontkenningsregel haalt direct 4/4, negation 2/2 en paraphrase 0/2. De totale accuracy is 6/8=.75, terwijl macro 2/3 is. De nulscore op parafrases is een blinde vlek die het totaal verbergt.
De woorden kapotte en rekening vallen buiten de eenvoudige trefwoorden. Verbeter je de regels na deze test, dan heb je een nieuwe variant en nieuwe onafhankelijke testgevallen nodig. De grafiek is een echte meting van onze testdubbel, geen bewijs van LLM-kwaliteit.
Bekijk voorbeeld
import matplotlib.pyplot as plt
def final_evaluation(cases, selected, registry):
if not isinstance(registry, dict) or not isinstance(selected, str) or selected not in registry or not callable(registry[selected]):
raise ValueError('Gekozen versie ontbreekt')
report = run_eval(cases, registry[selected], 'test')
groups = report['summary']['groups']
names = list(groups)
accuracy = [groups[name]['accuracy'] for name in names]
fig, ax = plt.subplots(figsize=(6.2, 3.3))
bars = ax.bar(names, accuracy, color=['#236b4f' if value == 1 else '#c27440' for value in accuracy])
ax.set(ylim=(0, 1.18), ylabel='End-to-end accuracy', title='Eigen testdubbel: ' + selected)
for bar, name, value in zip(bars, names, accuracy):
ax.text(bar.get_x() + bar.get_width()/2, value + .04, f'{value:.0%} · n={groups[name]["n"]}', ha='center', fontsize=9)
fig.tight_layout()
return report
test_report=final_evaluation(cases,selected,{"keyword-v1":keyword_predictor,"negation-v1":negation_predictor})
print("Test accuracy:",test_report["summary"]["overall"]["accuracy"])
print("Parafrase accuracy:",test_report["summary"]["groups"]["paraphrase"]["accuracy"])