Bouw je eigen beslissingsmodel

"System one"-beslissingsmodellen zijn modellen die conclusies trekken en reageren met gekalibreerde kansen of voor elk toegestaan antwoord.

Denk aan het gebruikelijke taalmodel: om een gestructureerde output (zoals JSON) te krijgen, kun je Structured Output gebruiken om de output te beperken tot gegarandeerd geldige JSON. Hoewel het model de invoer in één pass voorbereidt, moet het nog steeds voor elk token een pass uitvoeren om een geldige respons te genereren. In een specifiek voorbeeld zijn er bijvoorbeeld 11 passes nodig om de uiteindelijke output te genereren (waarbij we geen rekening houden met speculative decoding en andere optimalisatietechnieken voor inferentie).

Beslissingsmodellen, zoals Jev, gaan er vanuit dat er een vaste set opties is waaruit we kunnen kiezen en dat we dit snel kunnen doen door middel van één enkele pass. In dit type model wordt de set van mogelijke outputs beperkt tot specifieke opties (bijvoorbeeld A, B, C, D, E). Door andere items in de vocabulaire te maskeren, kan het model alleen die specifieke tokens uitzenden. Door de output met de hoogste waarschijnlijkheid te selecteren, verkrijgen we het antwoord.

Omdat de outputs beperkt zijn tot een vaste set opties, kan het model niets buiten deze set kiezen. Dit garandeert echter niet dat de output correct is. Het is bovendien gebruikelijk om de output-token-waarschijnlijkheden in deze context te behandelen als confidence scores (betrouwbaarheidsscores), maar zonder aanvullende training weerspiegelen deze scores waarschijnlijk de zekerheid over wat het volgende token zal zijn, in plaats van de werkelijke kans dat het antwoord correct is.

Zelf een model bouwen

We kunnen dit gedrag nabootsen door de output-tokens van een LLM te beperken. In dit voorbeeld wordt gebruikgemaakt van Qwen/Qwen3-1.7B.

import argparse
import json
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

model_name = "Qwen/Qwen3-1.7B"
options = ["A", "B", "C", "D", "E"]

parser = argparse.ArgumentParser()
parser.add_argument("--input", default="question.json")
args = parser.parse_args()

# Laad de tokenizer en het model
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype="auto",
    device_map="auto"
)

# De tokens die het model zou uitzenden voor elke optie als eerste assistant-token
option_token_ids = [tokenizer.encode(opt, add_special_tokens=False)[0] for opt in options]

def format_prompt(item):
    prompt = item["question"] + "\n"
    for opt in options:
        prompt += f"{opt}. {item[opt]}\n"
    prompt += "Answer:"
    messages = [
        {"role": "user", "content": prompt}
    ]
    return tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=False
    )

with open(args.input) as f:
    item = json.load(f)

model_inputs = tokenizer(format_prompt(item), return_tensors="pt").to(model.device)

with torch.no_grad():
    logits = model(**model_inputs).logits[0, -1]

# Constrained decoding: alleen de optie-tokens zijn toegestaan
probs = torch.softmax(logits[option_token_ids].float(), dim=-1)
print(f"prediction: {options[probs.argmax().item()]}")

for opt, prob in zip(options, probs.tolist()):
    print(f"{opt}: {prob:.4f}  {item[opt]}")

Wanneer we dit testen met een eenvoudige vraag, is dit de resultaten:

Invoer:

{
  "question": "What color is the sky?",
  "A": "Red",
  "B": "Blue",
  "C": "Green",
  "D": "Purple",
  "E": "I don't know"
}

Output:

prediction: B
A: 0.0000  Red
B: 0.9988  Blue
C: 0.0000  Green
D: 0.0000  Purple
E: 0.0012  I don't know

Het model was in staat om de invoer te begrijpen en een voorspelling te doen die redelijk overeenkomt met het juiste antwoord.

Evaluatie en nauwkeurigheid

We kunnen de nauwkeurigheid van het model testen door het te draaien tegen publieke datasets. Een test op een willekeurige steekproef van CommonsenseQA leverde het volgende op:

OptiePrecisionRecallF1Support
A0.57330.71970.6382239
B0.55060.76860.6416255
C0.53720.65980.5922241
D0.72060.39040.5065251
E0.75190.42550.5435235

Accuracy: 725/1221 = 0.5938 Macro F1: 0.5844

Voor een 1.7B model is dit niet slecht. Een snelle finetune op de dataset zorgde voor iets betere prestaties:

OptiePrecisionRecallF1Support
A0.64750.66110.6542239
B0.61130.67840.6431255
C0.62340.59750.6102241
D0.67000.54180.5991251
E0.58080.64260.6101235

Accuracy: 762/1221 = 0.6241 Macro F1: 0.6234

Het kalibreren van je model

Het testen van het model op een zeer ambigu probleem brengt een interessant probleem aan het licht.

Invoer:

{
  "question": "Where would you most likely find a bat?",
  "A": "Cave",
  "B": "Baseball game",
  "C": "Attic",
  "D": "Zoo",
  "E": "Sporting goods store"
}

Output:

prediction: A
A: 0.9978  Cave
B: 0.0004  Baseball game
C: 0.0017  Attic
D: 0.0000  Zoo
E: 0.0001  Sporting goods store

Er zou hier geen duidelijk antwoord moeten zijn (een 'bat' kan zowel een vleermuis als een honkbalknuppel zijn), maar wanneer we de output-waarschijnlijkheden behandelen als een pseudo-betrouwbaarheidsscore, blijkt dat het model extreem overmoedig is in dit antwoord.

Als we de betrouwbaarheidsscores in de eerdere evaluatie in groepen (bins) verdelen, zien we dat de betrouwheid van het model niet overeenkomt met de nauwkeurigheid. Dit betekent dat het model niet gekalibreerd is.

BinAantalBetrouwheid (Confidence)Nauwkeurigheid (Accuracy)
(0.00, 0.10]00.00000.0000
(0.10, 0.20]00.00000.0000
(0.20, 0.30]30.28340.0000
(0.30, 0.40]260.37610.2692
(0.40, 0.50]410.45380.2683
(0.50, 0.60]700.54900.3286
(0.60, 0.70]740.64760.3649
(0.70, 0.80]770.74950.4286
(0.80, 0.90]1210.85550.4711
(0.90, 1.00]8090.98550.7009

Opvallend is dat het model extreem overmoedig is in de bin 0.9 - 1.0, terwijl het slechts in 70% van de gevallen correct is. Wanneer het een voorspelling doet met een betrouwbaarheid tussen 0.8 en 0.9, is het slechts in ongeveer 40% van de gevallen accuraat.

Om het model scores te laten genereren die reflectief zijn voor de werkelijke nauwkeurigheid, kunnen we temperature scaling gebruiken. Door de temperatuurwaarde aan te passen, kunnen we de distributiecurve van de output-waarschijnlijkheid afvlakken en schalen om de nauwkeurigheid te benaderen.

Via curve fitting is de temperatuurparameter aangepast aan de nauwkeurigheid van het model, waarbij een temperatuurwaarde van 3.797280788421631 is gevonden.

BinAantalBetrouwheid (Confidence)Nauwkeurigheid (Accuracy)
(0.00, 0.10]00.00000.0000
(0.10, 0.20]00.00000.0000
(0.20, 0.30]820.27120.2317
(0.30, 0.40]2170.35070.3917
(0.40, 0.50]1990.44720.5126
(0.50, 0.60]1660.54750.5482
(0.60, 0.70]1390.65620.5827
(0.70, 0.80]1400.74920.7714
(0.80, 0.90]1690.85070.7988
(0.90, 1.00]1090.93330.9541

Dit resulteert in een veel betere kalibratie. Voor wie hiermee aan de slag wil: er is een GitHub-repo beschikbaar met scripts die je door het proces leiden van het bouwen van een dataset, evaluatie, fine-tuning en het kalibreren van je eigen model. Het is aanbevolen om dit ook op grotere modellen te proberen.