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

Meet ook wat een antwoord niet kan onderbouwen

Scheid ondersteuning, tegenspraak en ontbrekend bewijs; onderzoek de afweging tussen antwoorden en onthouden.

60 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je onderscheidt een ondersteunde claim van een plausibel klinkende zin.
  • Je berekent coverage en fouten onder beantwoorde gevallen zonder ontbrekende antwoorden weg te poetsen.

Een chatbot schrijft: “De lamp is rood”, met een overtuigend klinkende bronvermelding. In jouw registratie staat alleen dat de lamp blauw is. De formulering en bronachtige tekst bewijzen dus nog niet dat de claim wordt ondersteund.

Een hallucinatie is hier een feitelijke bewering die onjuist of ongegrond is ten opzichte van het vereiste bewijs. TruthfulQA onderzoekt waarheidsgetrouwheid afzonderlijk van vloeiende tekst. Wij oefenen met eigen gestructureerde feiten, zonder algemene feitenchecker of actuele modelranglijst.

1. Zoek bewijs op dezelfde eigenschap

Een claim bevat entity, attribute en value. Een feitregel bevat diezelfde velden plus source. Zoek op het exacte paar entity/attribute; de kleur van een andere lamp is geen bewijs over deze lamp.

a = {'entity':'lamp',
     'attribute':'kleur',
     'value':'blauw'}
b = {**a,'value':'rood'}
c = {**a,'attribute':'gewicht'}
facts = [{**a,'source':'s1'}]
rows = claim_support([a,b,c],facts)
for row in rows:
    print(row['status'])
# supported
# contradicted
# unknown

De blauwe claim past bij de enige bekende waarde: supported. Rood spreekt die waarde tegen: contradicted. Over gewicht staat niets geregistreerd: unknown is ontbrekend bewijs en geen bewezen onwaarheid.

Meerdere verschillende geregistreerde waarden geven conflict, ook wanneer één waarde bij de claim past. Zonder tijdstip of bronweging kunnen we dat verschil niet verantwoord oplossen. Geef alle relevante bron-IDs gesorteerd en zonder dubbelen terug.

mixed = facts + [{**b,'source':'s2'}]
r = claim_support([a],mixed)[0]
print(r['status'],r['sources'])
# conflict ['s1', 's2']

Maak claim_support voor maximaal 32 claims en 128 feitregels. Gebruik exact de genoemde velden, met niet-lege strings van maximaal 200 tekens. De registratie is zelf slechts een gekozen referentie: haar juistheid en actualiteit blijven een aparte verantwoordelijkheid.

01
CHECKPOINTOntbrekend bewijs is iets anders dan tegenspraak

2. Houd onthoudingen in de totale telling

Onthouden (abstention) betekent dat het systeem geen antwoord geeft. Neem drie vragen, waarvan twee beantwoord worden en één antwoord klopt. Coverage is dan 2/3, totale accuracy 1/3 en accuracy onder beantwoorde vragen 1/2.

Noem het totale aantal N, het aantal beantwoorde vragen A en het aantal juiste antwoorden H. Het risico onder antwoorden is het aandeel foute antwoorden binnen A:

coverage=ANrisk=A−HA
targets = ['factuur','storing','vraag']
pred = ['factuur',None,'factuur']
r = abstention_metrics(pred,targets)
print(r['answered'],r['correct'])  # 2 1
print(round(r['coverage'],4))  # .6667
print(round(r['overall_accuracy'],4))
# .3333
print(r['answered_accuracy'],r['risk'])
# 0.5 0.5
print(abstention_metrics(
    [None]*3,targets)['risk'])  # None

Maak abstention_metrics voor 1–1000 uitgelijnde gevallen. Alleen None betekent onthouden; een lege string is hier geen geldig label. Bij nul antwoorden zijn de voorwaardelijke accuracy en het risico ongedefinieerd, dus None.

Onthoudingen mogen niet uit de totale noemer verdwijnen. Anders lijkt een systeem met één goed antwoord en honderd geweigerde vragen al snel perfect. Welke prijs je aan weigeren geeft, hangt van de toepassing af en moet vooraf worden afgesproken.

02
CHECKPOINTRapporteer hoeveel vragen je beantwoordt

3. Een hogere grens kan goede antwoorden verwijderen

De visualisatie gebruikt scores .1, .4, .7 en .9. Alleen het derde antwoord is fout. Bij grens nul zijn er vier antwoorden en één fout: risico .25; bij grens .5 blijven twee antwoorden met één fout over: risico .5.

scores = [.1,.4,.7,.9]
correct = [True,True,False,True]
rows = risk_coverage_curve(
    scores,correct,[0,.5,.8,1])
for r in rows:
    print(r['answered'],r['risk'])
# 4 0.25
# 2 0.5
# 1 0.0
# 0 None

Maak risk_coverage_curve met score≥drempel. Sorteer unieke drempels en rapporteer per drempel answered, correct, coverage en risk. Reële scores en drempels liggen tussen nul en één; correct bevat echte booleans.

Coverage kan bij een hogere grens alleen dalen of gelijk blijven. Risico hoeft niet te dalen, omdat deze scores geen gekalibreerde waarheidskansen zijn. Kies een grens op ontwikkeldata en toets daarna apart, inclusief aantallen en relevante groepen.

EIGEN SYNTHETISCHE SCORESMeer onthouden kan eerst méér risico geven

De twee laagste scores horen bij goede antwoorden. Een hoge score is hier geen gekalibreerde waarheidskans.

0%0%50%50%100%100%risico onder antwoordencoverage
Geval 1 · 0.1CorrectOnthouden
Geval 2 · 0.4CorrectOnthouden
Geval 3 · 0.7FoutBeantwoord
Geval 4 · 0.9CorrectBeantwoord
Antwoorden2 / 4Coverage50.0%Risico50.0%

Bij geen antwoorden bestaat er geen risico onder beantwoorde gevallen. Dat is geen gemeten foutloos systeem.

03
CHECKPOINTEen hogere scoregrens garandeert geen lager risico

Wat neem je mee?

  • Ondersteuning, tegenspraak, ontbrekend bewijs en conflict zijn verschillende uitkomsten.
  • Coverage en risico hebben verschillende noemers.
  • Een hoge score of een bronvermelding bewijst geen waarheid.

Hierna bouw je een evaluatie die deze fouten zichtbaar houdt bij het vergelijken van varianten.

Verder lezen & bronnen

Inhoud gecontroleerd op 2026-10-28.
DE VOLGENDE STAPOntwerp een evaluatie die fouten zichtbaar houdt
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
Ontbrekend bewijs is iets anders dan tegenspraak

Schrijf claim_support(claims,facts) met per claim status en sources. Maximaal 32 claims en 128 feiten. Dictionaries hebben exact de genoemde velden; strings zijn niet-leeg na strip() en maximaal 200 tekens, maar worden niet genormaliseerd. Anders ValueError.

Een hint

Groepeer eerst feiten op (entity,attribute), daarna op value. Verzamel bron-IDs in sets. Twee bronnen met dezelfde waarde vormen geen conflict.

02
Rapporteer hoeveel vragen je beantwoordt

Schrijf abstention_metrics(predictions,targets) met total, answered, correct, coverage, overall_accuracy, answered_accuracy en risk. Geef 1–1000 uitgelijnde lijstitems, niet-lege doelstrings en voorspelde strings of None. Alleen None betekent onthouden; anders ValueError.

Een hint

Tel niet-None voorspellingen apart. De noemer answered kan nul zijn; total niet.

03
Een hogere scoregrens garandeert geen lager risico

Schrijf risk_coverage_curve(scores,correct,thresholds). Scores: 1D, 1–1000 eindige getallen in [0,1]; correct: evenlange echte booleanvector. Drempels: 1D, 1–101 eindige waarden in [0,1]. Geef voor unieke oplopende drempels threshold/answered/correct/coverage/risk. Ongeldige vormen, grenzen of labels geven ValueError.

Een hint

Gebruik np.unique en een booleanmasker scores>=threshold. Bereken risico alleen als het masker minstens één geval selecteert.

Vastgelopen? Bekijk de voorbeeldoplossing
def claim_support(claims, facts):
    if not isinstance(claims, list) or not isinstance(facts, list) or len(claims) > 32 or len(facts) > 128:
        raise ValueError('Maximaal 32 claims en 128 feitregels')
    for items, keys in [(claims, {'entity', 'attribute', 'value'}), (facts, {'entity', 'attribute', 'value', 'source'})]:
        for item in items:
            if not isinstance(item, dict) or set(item) != keys or any(not isinstance(v, str) or not v.strip() or len(v) > 200 for v in item.values()):
                raise ValueError('Een claim/feit bevat vaste niet-lege stringvelden')
    index = {}
    for fact in facts:
        key = (fact['entity'], fact['attribute'])
        index.setdefault(key, {}).setdefault(fact['value'], set()).add(fact['source'])
    result = []
    for claim in claims:
        known = index.get((claim['entity'], claim['attribute']), {})
        state = 'unknown' if not known else 'conflict' if len(known) > 1 else 'supported' if claim['value'] in known else 'contradicted'
        result.append({'status': state, 'sources': sorted({s for names in known.values() for s in names})})
    return result

def abstention_metrics(predictions, targets):
    if not isinstance(predictions, list) or not isinstance(targets, list) or not 1 <= len(targets) <= 1000 or len(predictions) != len(targets):
        raise ValueError('Geef 1 tot 1000 uitgelijnde voorspellingen/doelen')
    if any(not isinstance(t, str) or not t for t in targets) or any(p is not None and (not isinstance(p, str) or not p) for p in predictions):
        raise ValueError('Labels zijn niet-lege strings; alleen None is onthouden')
    answered = sum(p is not None for p in predictions)
    correct = sum(p == t for p, t in zip(predictions, targets))
    return {'total': len(targets), 'answered': answered, 'correct': correct,
            'coverage': answered / len(targets), 'overall_accuracy': correct / len(targets),
            'answered_accuracy': correct / answered if answered else None,
            'risk': (answered - correct) / answered if answered else None}

import numpy as np

def risk_coverage_curve(scores, correct, thresholds):
    try:
        raw_scores = np.asarray(scores)
        raw_thresholds = np.asarray(thresholds)
        if np.iscomplexobj(raw_scores) or np.iscomplexobj(raw_thresholds):
            raise ValueError('Scores en drempels moeten reëel zijn')
        scores = np.asarray(raw_scores, dtype=float)
        correct = np.asarray(correct)
        thresholds = np.asarray(raw_thresholds, dtype=float)
    except (TypeError, OverflowError) as error:
        raise ValueError('Geen geldige reële scorevector') from error
    if scores.ndim != 1 or not 1 <= scores.size <= 1000 or correct.shape != scores.shape or correct.dtype != bool:
        raise ValueError('Geef scores en echte booleaanse juistheidslabels')
    if thresholds.ndim != 1 or not 1 <= thresholds.size <= 101 or any(not np.isfinite(a).all() or np.any((a < 0) | (a > 1)) for a in [scores, thresholds]):
        raise ValueError('Eindige scores en drempels tussen nul en een')
    rows = []
    for threshold in np.unique(thresholds):
        selected = scores >= threshold
        count, hits = int(selected.sum()), int(correct[selected].sum())
        rows.append({'threshold': float(threshold), 'answered': count, 'correct': hits,
                     'coverage': count / scores.size, 'risk': (count - hits) / count if count else None})
    return rows

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