Dit artikel beschrijft het concept van 'System one'-beslissingsmodellen, waarbij een Large Language Model (LLM) in één enkele pass een keuze maakt uit een vaste set opties, in plaats van tokens sequentieel te genereren.
De auteur demonstreert hoe dit technisch gerealiseerd kan worden met Python en het Qwen/Qwen3-1.7B model door middel van constrained decoding, waarbij alleen specifieke tokens (zoals A, B, C, D, E) worden toegestaan. Na tests op de CommonsenseQA dataset wordt geconstateerd dat het model weliswaar functioneel is, maar vaak overmoedig is in zijn voorspellingen.
Om dit probleem van incorrecte betrouwbaarheidsscores aan te pakken, introduceert de auteur temperature scaling. Door de temperatuurparameter via curve fitting aan te passen, wordt de distributie van de output-waarschijnlijkheden afgevlakt, waardoor de confidence scores van het model beter overeenkomen met de werkelijke nauwkeurigheid.
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:
| Optie | Precision | Recall | F1 | Support |
| A | 0.5733 | 0.7197 | 0.6382 | 239 |
| B | 0.5506 | 0.7686 | 0.6416 | 255 |
| C | 0.5372 | 0.6598 | 0.5922 | 241 |
| D | 0.7206 | 0.3904 | 0.5065 | 251 |
| E | 0.7519 | 0.4255 | 0.5435 | 235 |
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:
| Optie | Precision | Recall | F1 | Support |
| A | 0.6475 | 0.6611 | 0.6542 | 239 |
| B | 0.6113 | 0.6784 | 0.6431 | 255 |
| C | 0.6234 | 0.5975 | 0.6102 | 241 |
| D | 0.6700 | 0.5418 | 0.5991 | 251 |
| E | 0.5808 | 0.6426 | 0.6101 | 235 |
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.
| Bin | Aantal | Betrouwheid (Confidence) | Nauwkeurigheid (Accuracy) |
| (0.00, 0.10] | 0 | 0.0000 | 0.0000 |
| (0.10, 0.20] | 0 | 0.0000 | 0.0000 |
| (0.20, 0.30] | 3 | 0.2834 | 0.0000 |
| (0.30, 0.40] | 26 | 0.3761 | 0.2692 |
| (0.40, 0.50] | 41 | 0.4538 | 0.2683 |
| (0.50, 0.60] | 70 | 0.5490 | 0.3286 |
| (0.60, 0.70] | 74 | 0.6476 | 0.3649 |
| (0.70, 0.80] | 77 | 0.7495 | 0.4286 |
| (0.80, 0.90] | 121 | 0.8555 | 0.4711 |
| (0.90, 1.00] | 809 | 0.9855 | 0.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.
| Bin | Aantal | Betrouwheid (Confidence) | Nauwkeurigheid (Accuracy) |
| (0.00, 0.10] | 0 | 0.0000 | 0.0000 |
| (0.10, 0.20] | 0 | 0.0000 | 0.0000 |
| (0.20, 0.30] | 82 | 0.2712 | 0.2317 |
| (0.30, 0.40] | 217 | 0.3507 | 0.3917 |
| (0.40, 0.50] | 199 | 0.4472 | 0.5126 |
| (0.50, 0.60] | 166 | 0.5475 | 0.5482 |
| (0.60, 0.70] | 139 | 0.6562 | 0.5827 |
| (0.70, 0.80] | 140 | 0.7492 | 0.7714 |
| (0.80, 0.90] | 169 | 0.8507 | 0.7988 |
| (0.90, 1.00] | 109 | 0.9333 | 0.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.
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:
| Optie | Precision | Recall | F1 | Support |
| A | 0.5733 | 0.7197 | 0.6382 | 239 |
| B | 0.5506 | 0.7686 | 0.6416 | 255 |
| C | 0.5372 | 0.6598 | 0.5922 | 241 |
| D | 0.7206 | 0.3904 | 0.5065 | 251 |
| E | 0.7519 | 0.4255 | 0.5435 | 235 |
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:
| Optie | Precision | Recall | F1 | Support |
| A | 0.6475 | 0.6611 | 0.6542 | 239 |
| B | 0.6113 | 0.6784 | 0.6431 | 255 |
| C | 0.6234 | 0.5975 | 0.6102 | 241 |
| D | 0.6700 | 0.5418 | 0.5991 | 251 |
| E | 0.5808 | 0.6426 | 0.6101 | 235 |
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.
| Bin | Aantal | Betrouwheid (Confidence) | Nauwkeurigheid (Accuracy) |
| (0.00, 0.10] | 0 | 0.0000 | 0.0000 |
| (0.10, 0.20] | 0 | 0.0000 | 0.0000 |
| (0.20, 0.30] | 3 | 0.2834 | 0.0000 |
| (0.30, 0.40] | 26 | 0.3761 | 0.2692 |
| (0.40, 0.50] | 41 | 0.4538 | 0.2683 |
| (0.50, 0.60] | 70 | 0.5490 | 0.3286 |
| (0.60, 0.70] | 74 | 0.6476 | 0.3649 |
| (0.70, 0.80] | 77 | 0.7495 | 0.4286 |
| (0.80, 0.90] | 121 | 0.8555 | 0.4711 |
| (0.90, 1.00] | 809 | 0.9855 | 0.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.
| Bin | Aantal | Betrouwheid (Confidence) | Nauwkeurigheid (Accuracy) |
| (0.00, 0.10] | 0 | 0.0000 | 0.0000 |
| (0.10, 0.20] | 0 | 0.0000 | 0.0000 |
| (0.20, 0.30] | 82 | 0.2712 | 0.2317 |
| (0.30, 0.40] | 217 | 0.3507 | 0.3917 |
| (0.40, 0.50] | 199 | 0.4472 | 0.5126 |
| (0.50, 0.60] | 166 | 0.5475 | 0.5482 |
| (0.60, 0.70] | 139 | 0.6562 | 0.5827 |
| (0.70, 0.80] | 140 | 0.7492 | 0.7714 |
| (0.80, 0.90] | 169 | 0.8507 | 0.7988 |
| (0.90, 1.00] | 109 | 0.9333 | 0.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.