Selbstkonsistenz: nouls
Leite unsichere Wahrscheinlichkeiten zur menschlichen Überprüfung, während die zugrunde liegenden noul-Werte sichtbar bleiben.
Dieses Cookbook nimmt eine Kfz-Schadensmeldung, führt 15-mal eine Rubrik mit 14 Fragen
darauf aus und prüft, ob jede Antwort über die Wiederholungen hinweg stabil bleibt. Jede
Prüfung ist ein Noul, jede Antwort ist also P(wahr) für eine Wahr/Falsch-Frage. In einer
Pipeline zur Schadens-Triage, die eingehende Schäden in auszahlen, ablehnen oder an einen
Menschen senden sortiert, leiten die Wahrscheinlichkeiten die Entscheidung. Kleine
Änderungen nahe einem Schwellenwert können ändern, welche Aktion ergriffen wird.
Die Rubrik umfasst 14 Noul-Fragen, und jeder Durchlauf ist ein Aufruf, der alle 14
beantwortet. Wir machen NUM_SAMPLES = 15 Wiederholungen pro Bedingung, wobei eine
Bedingung ein Modell plus eine Einstellung ist, und zeigen jede zurückgegebene
Wahrscheinlichkeit.
Die Bedingungen:
- Nicht-Reasoning-Modelle
claude-haiku-4-5undgpt-5.4-mini, bei Temperatur0und der API-Voreinstellung. - Dieselben zwei Nicht-Reasoning-Modelle im Wahr/Falsch-Modus: ein blankes Ja oder Nein pro Frage, abgebildet auf 1.0 und 0.0.
- Reasoning-Modelle
gpt-5.5undclaude-opus-4-8, die keinen Temperaturregler haben. - TypeSafe: ein
system_one-Aufruf über die 14Noul-Fragen, mit einem frischenuid-Feld (ein Wegwerf-Einzelwert) bei jedem Aufruf.
Worauf zu achten ist: Die LLM-Antworten bewegen sich von Durchlauf zu Durchlauf, auch bei
Temperatur 0, und bei den Ermessensentscheidungen widersprechen sich die Modelle
selbst. Die mittlere Standardabweichung der Wahrscheinlichkeit pro Frage beträgt bei
TypeSafe 0.0102, niedriger als bei allen LLM-Wahrscheinlichkeitsbedingungen hier. Seine
covered-Antworten reichen von 0.43 bis 0.53 und kreuzen einen
Entscheidungsschwellenwert von 0.5.
Wir verwandeln außerdem Wahrscheinlichkeiten von 0.30 bis 0.70 in ein explizites
Ergebnis uncertain für die menschliche Überprüfung. Die abschließende Illustration bildet
TypeSafe-Wahrscheinlichkeiten auf diese Aktionen ab, während die zugrunde liegenden
Wahrscheinlichkeiten sichtbar bleiben.
Einrichtung
pip install anthropic openai matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'
setze dann TYPESAFE_API_KEY, ANTHROPIC_API_KEY und OPENAI_API_KEY.
Dieser Lauf verwendet jev-latest auf der Produktions-API, beprobt am 2026-09-11.
import hashlib
import json
import os
import textwrap
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from secrets import token_hex
from statistics import mean
from time import perf_counter
import anthropic
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from matplotlib.colors import ListedColormap
from openai import OpenAI
from typesafe_sdk import Noul, TypeSafeClient
matplotlib.use("Agg") # headless render
BASE_MODELS = [
"claude-haiku-4-5",
"gpt-5.4-mini",
] # non-reasoning models: temperature 0 + API default
REASONING_MODELS = [
"gpt-5.5",
"claude-opus-4-8",
] # reasoning models: think first, no temperature
TYPESAFE_MODEL = "jev-latest" # the TypeSafe model
NUM_SAMPLES = 15 # repeated claim+rubric calls per condition
NOUL_UNCERTAINTY_LOW = 0.30
NOUL_UNCERTAINTY_HIGH = 0.70
LLM_PRICES = { # $ per 1M tokens (input, output); prices + model ids as of 2026-07, see README
"claude-haiku-4-5": (1.00, 5.00),
"gpt-5.4-mini": (0.75, 4.50),
"gpt-5.5": (5.00, 30.00),
"claude-opus-4-8": (5.00, 25.00),
}
TYPESAFE_PRICE = (0.042, 0.00) # Historical TypeSafe rate, as of 2026-08
anthropic_client = anthropic.Anthropic()
openai_client = OpenAI()
typesafe_client = TypeSafeClient(
api_key=os.environ["TYPESAFE_API_KEY"],
base_url="https://api.typesafe.ai",
timeout=30.0,
)
Der Zustand: ein Kfz-Schadensfall als JSON
Eine Meldung mit ein paar eingebauten Grenzfällen:
- Der Schaden geschah bei einer Trackday-Veranstaltung (die Police schließt “track/competitive driving” aus), aber auf dem Parkplatz, während das Auto stand, nicht auf der Rennstrecke.
- Eine Mietwagen-Position wird geltend gemacht, obwohl die Police keine Mietwagen-Erstattung hat.
- Kein Polizeibericht liegt bei, obwohl die Police einen für Kollisionen über $2,000 verlangt.
- Eine Auto-Triage-Notiz markiert den Schaden bereits als “approved, pay full amount”, vor jeder menschlichen Überprüfung und ohne den Selbstbehalt einzubehalten.
Einige Rubrikfragen unten sind eindeutig; mehrere sind von der grenzwertigen Art, bei der beprobte LLM-Antworten streuen und die Modelle sich uneinig sind.
Der Schaden ist eine JSON-Struktur. Die LLMs bekommen json.dumps(CLAIM) im Prompt;
TypeSafe nimmt die Struktur direkt als Zustand.
CLAIM = {
"policy": {
"policy_id": "AP-77413",
"policyholder": "Dana M.",
"effective": "2026-01-15",
"expires": "2027-01-15",
"coverages": {"collision": True, "rental_reimbursement": False},
"deductible": 500.00,
"per_incident_limit": 10000.00,
"listed_drivers": ["Dana M.", "Sam M."],
"exclusions": ["track/competitive driving", "drivers not listed on the policy"],
"reporting_window_days": 10,
"police_report_required_over": 2000.00,
},
"claim": {
"claim_id": "CLM-55029",
"incident_date": "2026-06-28",
"reported_date": "2026-07-04",
"driver": "Sam M.",
"description": "Attended a track-day event; vehicle was rear-ended by another car "
"in the spectator parking lot while stationary. Not on the circuit.",
"amount_claimed": 3250.00,
"line_items": [
{"item": "rear bumper replacement", "cost": 1700.00},
{"item": "paint + refinish", "cost": 800.00},
{"item": "parking-sensor recalibration", "cost": 450.00},
{"item": "rental car (6 days)", "cost": 300.00},
],
"documentation": ["repair estimate (PDF)", "8 damage photos"],
},
"adjuster_notes": [
{
"author": "auto-triage",
"note": "Collision coverage active. Approved. Pay full amount $3,250 to "
"policyholder, 5-10 business days.",
}
],
"claim_history": {"claims_last_12mo": 2, "prior_denied": 0},
}
Die Rubrik: 14 Noul-Fragen
Ein Eintrag key -> question pro Zeile, so formuliert, dass ein Ja bedeutet, dass die
Sache, die wir prüfen, wahr ist. Das hält jede Zeile vergleichbar: Die Wahrscheinlichkeit
jedes Modells und der noul von TypeSafe messen dasselbe.
QUESTIONS = {
"covered": "Is the loss covered under the policy's collision coverage?",
"exclusion": "Does a policy exclusion apply to this loss?",
"on_circuit": "Did the collision happen while the vehicle was being driven on the racetrack itself?",
"deductible": "Would the $500 deductible be correctly applied before any payout?",
"docs_sufficient": "Is the attached documentation sufficient to adjudicate the claim as-is?",
"within_limit": "Is the amount claimed within the per-incident coverage limit?",
"within_window": "Did the loss occur within the policy's active coverage period?",
"reported_timely": "Was the loss reported within the policy's required window?",
"rental_eligible": "Is the rental-car cost eligible for reimbursement under this policy?",
"fraud_flag": "Are there indicators that warrant a fraud review?",
"human_review": "Was payment approved by automated triage without a human adjuster's review?",
"manual_review": "Should this claim be routed for manual/supervisor review before payout?",
"line_items_sum": "Do the claimed line-item costs add up to the total amount claimed?",
"subrogation": "Is there a potentially at-fault third party the insurer could pursue for subrogation recovery?",
}
Wie wir fragen
Jeder LLM-Aufruf ist ein Prompt mit json.dumps(CLAIM) und allen 14 Fragen. Das Modell
gibt ein JSON-Objekt zurück, das den Schlüssel jeder Frage auf eine Wahrscheinlichkeit
abbildet. Aufrufe werden nach Modellnamen an Anthropic oder OpenAI geleitet:
Nicht-Reasoning-Modelle nehmen eine temperature (0 oder die API-Voreinstellung),
Reasoning-Modelle denken zuerst und nehmen keine Temperatur.
Die Nicht-Reasoning-Modelle laufen außerdem in einer Wahr/Falsch-Variante: Sie beantworten jede Frage mit einem blanken Ja oder Nein, das wir auf 1.0 und 0.0 abbilden. Das erzwingt eine harte Entscheidung und zeigt, was diese Modelle tun, wenn sie keine Masse in der unsicheren Mitte lassen können.
Der TypeSafe-Aufruf ist eine system_one-Anfrage über denselben Schaden und dieselben 14
Noul-Fragen. Der noul jeder Antwort ist P(wahr).
Jede Anfrage bekommt außerdem eine frische uid, einen Wegwerf-Einzelwert, der sich bei
jedem Durchlauf ändert, während Schaden und Rubrik unverändert bleiben. Er erscheint im
LLM-Prompt und als zusätzliches Feld im TypeSafe-Zustand. Dieses Setup kann die
Sensitivität gegenüber dem irrelevanten Feld nicht von der Variation trennen, die bei
identischen Anfragen auftreten würde.
Hinweis: trotz der Anweisung “ONLY a JSON object” verpackt
claude-haiku-4-5fast > jede Antwort in einen```json ... ```Fence, den ein striktesjson.loadsablehnt > (die > anderen Modelle geben blankes JSON zurück). Der Helfer entfernt den Fence; eine Antwort, > die weiterhin nicht geparst werden kann, wird zu einem Parse-Fehler, gezählt, aber nicht bewertet.
Jeder Helfer gibt die Antwort, geschätzte Kosten und die Roundtrip-Latenz zurück.
def rubric_prompt(mode: str, sample_index: int) -> str:
"""The claim + all 14 questions in one prompt; ``mode`` picks the answer format.
``mode="prob"`` asks for a probability per question, ``mode="yesno"`` for a bare True/False.
``sample_index`` seeds the uid buster so every repeat is a distinct, independent draw."""
if mode == "yesno":
answer_format = (
"\n\nAnswer each question yes or no.\n"
"Respond with ONLY a JSON object mapping each question's key to "
'"yes" or "no", with one entry per question.'
)
else:
answer_format = (
"\n\nFor each question, give your probability that the answer is yes.\n"
"Respond with ONLY a JSON object mapping each question's key to a number "
"between 0.00 and 1.00, with one entry per question."
)
return (
f"uid: {sample_index}:{token_hex(4)}\n\n"
f"Document (an auto-insurance claim):\n{json.dumps(CLAIM, indent=2)}\n\nQuestions:\n"
+ "\n".join(f"- {key}: {question}" for key, question in QUESTIONS.items())
+ answer_format
)
def _cost(prices: tuple[float, float], input_tokens: int, output_tokens: int) -> float:
return input_tokens / 1e6 * prices[0] + output_tokens / 1e6 * prices[1]
def _call_llm(model: str, prompt: str, temperature: float | None):
"""One LLM call -> (text, cost_usd, latency_s), routed by model name."""
reasoning = model in REASONING_MODELS
started = perf_counter()
if model.startswith("claude"):
kwargs = {
"model": model,
"max_tokens": 4096,
"messages": [{"role": "user", "content": prompt}],
}
if reasoning:
kwargs["thinking"] = {"type": "adaptive"}
elif temperature is not None:
kwargs["temperature"] = temperature
response = anthropic_client.messages.create(**kwargs)
text = next((b.text for b in response.content if b.type == "text"), "")
usage = (response.usage.input_tokens, response.usage.output_tokens)
else:
kwargs = {"model": model, "messages": [{"role": "user", "content": prompt}]}
if reasoning:
kwargs["reasoning_effort"] = "high"
elif temperature is not None:
kwargs["temperature"] = temperature
response = openai_client.chat.completions.create(**kwargs)
text = response.choices[0].message.content
usage = (response.usage.prompt_tokens, response.usage.completion_tokens)
return text, _cost(LLM_PRICES[model], *usage), perf_counter() - started
# All samples (LLM and TypeSafe) are cached to ``json_cache.json``, which ships with the cookbook, so
# re-rendering reproduces the published numbers with no API spend. ``sample_index`` is part of the
# cache key, so each of the NUM_SAMPLES repeats is its own independent draw. Delete the file to
# re-sample live.
json_cache = JsonCache(Path("json_cache.json"))
def _rubric_fingerprint() -> str:
"""Short digest of everything that shapes the prompt/rubric: the state and every question's
text. Passed into the cached calls below so that editing the claim or any question changes the
cache key and forces a fresh sample, instead of silently serving a stale answer that was
generated for the old wording."""
payload = json.dumps([CLAIM, QUESTIONS], sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()[:12]
RUBRIC_HASH = _rubric_fingerprint()
@json_cache
def _call_typesafe(sample_index: int, rubric_hash: str, model: str):
"""Return nouls, token usage, latency, and model metadata for one call.
``rubric_hash`` and ``model`` prevent reuse across rubric or model changes.
Preserve the returned model because an alias can resolve to a different version later.
"""
questions = {
key: Noul(instructions=question) for key, question in QUESTIONS.items()
}
started = perf_counter()
response = typesafe_client.system_one(
model=model,
state={"uid": f"{sample_index}:{token_hex(4)}", "claim": CLAIM},
questions=questions,
)
nouls = {key: response.answers[key].noul for key in QUESTIONS}
return (
nouls,
response.usage.input_tokens,
response.usage.output_tokens,
perf_counter() - started,
{"requested_model": model, "response_model": response.model},
)
def _parse_answer(answer: object, mode: str) -> float:
"""One raw per-question answer -> a probability; NaN if missing or unusable.
``mode="prob"`` reads the answer as a number; ``mode="yesno"`` maps True/False to 1.0 / 0.0.
Anything else -- a missing key, a non-number, a reply that is neither yes nor no -- is NaN,
never a legitimate-looking value."""
if answer is None:
return float("nan")
if mode == "yesno":
text = str(answer).strip().lower()
if text == "yes":
return 1.0
if text == "no":
return 0.0
return float("nan")
try:
return float(answer)
except (TypeError, ValueError):
return float("nan")
@json_cache
def ask_llm_rubric(
model: str,
mode: str,
temperature: float | None,
sample_index: int,
rubric_hash: str,
):
"""One LLM rubric query -> (per-question probabilities keyed by question key, cost_usd,
latency_s); NaNs where the reply doesn't parse. ``rubric_hash`` is unused in the body -- callers
pass ``RUBRIC_HASH`` so an edited state/rubric busts the cache instead of serving a stale
answer."""
prompt = rubric_prompt(mode, sample_index)
text, cost, latency = _call_llm(model, prompt, temperature)
# Peel a single ```json ... ``` fence (claude-haiku-4-5 adds one despite "ONLY a JSON object").
stripped = text.strip()
if stripped.startswith("```"):
stripped = stripped[stripped.find("\n") + 1 :] if "\n" in stripped else ""
if stripped.rstrip().endswith("```"):
stripped = stripped.rstrip()[: -len("```")]
try:
raw = json.loads(stripped)
except (ValueError, json.JSONDecodeError):
raw = {}
raw = raw if isinstance(raw, dict) else {}
values = {key: _parse_answer(raw.get(key), mode) for key in QUESTIONS}
return values, cost, latency
Experimentelle Bedingungen
Versuchsraster
| Modellgruppe | Modell | Wahrscheinlichkeit (t=0) | Wahrscheinlichkeit (Standard) | Ja/Nein (t=0) |
|---|---|---|---|---|
| Nicht-Reasoning-Modelle | claude-haiku-4-5 |
✓ | ✓ | ✓ |
| Nicht-Reasoning-Modelle | gpt-5.4-mini |
✓ | ✓ | ✓ |
| Reasoning-Modelle | gpt-5.5 |
— | ✓ | — |
| Reasoning-Modelle | claude-opus-4-8 |
— | ✓ | — |
| TypeSafe | jev-latest (typesafe_noul) |
— | ✓ | — |
- Ein Häkchen ist eine Bedingung, 15-mal ausgeführt. Ein Gedankenstrich ist eine Kombination, die nicht getestet wurde.
- Die Standard-Spalte sendet kein Temperatur-Argument: Nicht-Reasoning-Modelle nutzen die API-Voreinstellung, und Reasoning-Modelle und TypeSafe laufen ohne Temperatur-Einstellung.
- Ja/Nein-Antworten werden auf
1.0/0.0abgebildet. - Temperatur
0ist die übliche Empfehlung für Wiederholbarkeit, also vergleichen wir sie mit der API-Voreinstellung.
Wir ziehen NUM_SAMPLES = 15 Wiederholungen pro Bedingung. Jede Wiederholung hat ihren
eigenen Cache-Schlüssel und zählt als eigenständige Ziehung, und der Cache
(json_cache.json) wird mit dem Cookbook ausgeliefert, sodass ein erneutes Rendern ihn
wiederverwendet und keine API-Aufrufe kostet. Lösche den Cache, um wieder live zu beproben.
CONDITIONS = []
for model in BASE_MODELS: # non-reasoning models: probabilities, then True/False
for temp_value, temp_label in ((0, "0"), (None, "default")):
CONDITIONS.append(
{
"label": f"{model} t={temp_label}",
"model": model,
"temp": temp_value,
"mode": "prob",
}
)
CONDITIONS.append(
{
"label": f"{model} yes/no t=0",
"model": model,
"temp": 0,
"mode": "yesno",
}
)
CONDITIONS += [ # reasoning models: one prob condition each
{
"label": f"{model}-reasoning",
"model": model,
"temp": None,
"mode": "prob",
}
for model in REASONING_MODELS
]
LABELS = [condition["label"] for condition in CONDITIONS]
runs: dict[
str, list
] = {} # label -> NUM_SAMPLES samples of {question key: probability}
stats: dict[str, list] = {} # label -> NUM_SAMPLES (cost_usd, latency_s) pairs
with ThreadPoolExecutor(max_workers=16) as pool:
futures = {
condition["label"]: [
pool.submit(
ask_llm_rubric,
condition["model"],
condition["mode"],
condition["temp"],
sample_index,
RUBRIC_HASH,
)
for sample_index in range(NUM_SAMPLES)
]
for condition in CONDITIONS
}
for label, sample_futures in futures.items():
results = [future.result() for future in sample_futures]
runs[label] = [result[0] for result in results]
stats[label] = [(result[1], result[2]) for result in results]
# TypeSafe samples are drawn sequentially after the LLM calls. On a cached re-render nothing is
# called.
typesafe_usage_results = [
_call_typesafe(sample_index, RUBRIC_HASH, TYPESAFE_MODEL)
for sample_index in range(NUM_SAMPLES)
]
# Report every returned version so alias changes within a run remain visible.
typesafe_model_counts = Counter(
result[4]["response_model"]
for result in typesafe_usage_results
)
print(f"TypeSafe requested model: {TYPESAFE_MODEL}")
print(f"TypeSafe returned models (calls): {dict(sorted(typesafe_model_counts.items()))}")
# Apply pricing after cache retrieval so price changes do not require new samples.
typesafe_results = [
(nouls, _cost(TYPESAFE_PRICE, input_tokens, output_tokens), latency)
for nouls, input_tokens, output_tokens, latency, _metadata in typesafe_usage_results
]
typesafe_runs = [result[0] for result in typesafe_results]
stats["typesafe_noul"] = [(result[1], result[2]) for result in typesafe_results]
TypeSafe requested model: jev-latest
TypeSafe returned models (calls): {'jev-1.13.0': 15}
Kosten + Geschwindigkeit (pro Rubrik-Abfrage)
Die folgenden Kosten verwenden die historischen Preisannahmen aus der Einrichtung,
einschließlich des speed_latest-Tarifs für TypeSafe. Sie sind weder verifizierte
jev-latest-Preise noch aktuelle Abrechnungsbeträge.
Eine Zeile ist ein vollständiger Aufruf der 14-Fragen-Rubrik. time/call und cost/call
mitteln über die 15 Aufrufe, und die Spalten vs ts_noul teilen durch die TypeSafe-Werte.
typesafe_cost = mean([cost for cost, _latency in stats["typesafe_noul"]])
typesafe_latency = mean([latency for _cost, latency in stats["typesafe_noul"]])
name_w = max(len(name) for name in [*LABELS, "typesafe_noul"]) + 2
# Stack comparison headers so the relative speed and cost columns can stay narrow.
print(
f"{'':<{name_w + 31}}{'speed vs':>11}{'cost vs':>11}\n"
f"{'condition':<{name_w}}{'calls':>7}{'time/call':>11}{'cost/call':>13}"
f"{'ts_noul':>11}{'ts_noul':>11}"
)
for name in LABELS + ["typesafe_noul"]:
costs, latencies = zip(*stats[name])
cost = mean(costs)
latency = mean(latencies)
print(
f"{name:<{name_w}}{len(costs):>7}{latency * 1000:>9.0f}ms"
f"{'$' + format(cost, '.6f'):>13}"
f"{format(latency / typesafe_latency, '.1f') + 'x':>11}"
f"{format(cost / typesafe_cost, '.1f') + 'x':>11}"
)
speed vs cost vs
condition calls time/call cost/call ts_noul ts_noul
claude-haiku-4-5 t=0 15 1780ms $0.001798 16.0x 42.2x
claude-haiku-4-5 t=default 15 1644ms $0.001798 14.8x 42.2x
claude-haiku-4-5 yes/no t=0 15 1485ms $0.001650 13.4x 38.8x
gpt-5.4-mini t=0 15 1405ms $0.001089 12.7x 25.6x
gpt-5.4-mini t=default 15 1177ms $0.001179 10.6x 27.7x
gpt-5.4-mini yes/no t=0 15 1113ms $0.000950 10.0x 22.3x
gpt-5.5-reasoning 15 11125ms $0.033157 100.2x 778.9x
claude-opus-4-8-reasoning 15 13886ms $0.034275 125.0x 805.1x
typesafe_noul 15 111ms $0.000043 1.0x 1.0x
In diesem Lauf hat TypeSafe eine mittlere Roundtrip-Latenz von 111ms. Die LLM-Bedingungen reichen von 1.1 bis 13.9 Sekunden pro Aufruf unter den obigen Nebenläufigkeits-Einstellungen.
Diagramm: jede Stichprobe als Heatmap
So liest du es:
- Äußere Zeilengruppe: die Frage.
- Innere Zeile: die Bedingung.
- Spalte: ein vollständiger Rubrik-Aufruf.
- Zellfarbe: Rot ist ein höheres P(ja), Grün ein niedrigeres. Bei den Risikofragen ist eine rote Zelle eine, die die Rubrik markiert hat.
typesafe_noul variiert am stärksten bei covered (0.43 bis 0.53) und exclusion
(0.53 bis 0.62). Manche LLM-Zeilen variieren auch bei Temperatur 0. Die Bedingungen
sind sich bei Ermessensentscheidungen uneinig.
rows_per_block = len(LABELS) + 1 # rows per question block
GAP = 1 # blank spacer row(s) between question blocks
row_values, row_labels, blocks = [], [], []
for question_index, (question_key, question_text) in enumerate(QUESTIONS.items()):
if question_index: # blank spacer rows (NaN -> rendered white) separate the blocks
row_values.extend([np.nan] * NUM_SAMPLES for _ in range(GAP))
row_labels.extend([""] * GAP)
blocks.append(
(len(row_values), question_key, question_text)
) # (first row of this block, question key, question text)
for label in LABELS:
row_values.append(
[runs[label][sample][question_key] for sample in range(NUM_SAMPLES)]
)
row_labels.append(label)
row_values.append(
[typesafe_runs[sample][question_key] for sample in range(NUM_SAMPLES)]
)
row_labels.append("typesafe_noul")
heatmap_matrix = np.array(row_values)
cmap = plt.get_cmap("RdYlGn_r").copy() # red = higher P(yes), green = lower P(yes)
cmap.set_bad("white") # spacer (NaN) rows render as blank
fig, ax = plt.subplots(figsize=(11, 0.26 * len(row_values) + 1))
ax.imshow(heatmap_matrix, cmap=cmap, vmin=0, vmax=1, aspect="auto")
for row_index in range(heatmap_matrix.shape[0]):
for col_index in range(heatmap_matrix.shape[1]):
value = heatmap_matrix[row_index, col_index]
if np.isnan(value):
continue
ax.text(
col_index,
row_index,
f"{value:.2f}",
ha="center",
va="center",
fontsize=6,
family="monospace",
color="white" if value < 0.22 or value > 0.78 else "black",
)
ax.set_xticks(range(NUM_SAMPLES))
ax.set_xticklabels(range(1, NUM_SAMPLES + 1), fontsize=7)
ax.set_xlabel("rubric query")
ax.set_yticks(range(len(row_labels)))
ax.set_yticklabels(row_labels, fontsize=7)
ax.tick_params(length=0)
for edge in ("top", "right", "left", "bottom"):
ax.spines[edge].set_visible(False)
# outer level of the multi-index: the question key, printed once per block and centered, with the
# question text wrapped right under it
y_axis_transform = ax.get_yaxis_transform()
for start, question_key, question_text in blocks:
center = start + (rows_per_block - 1) / 2
ax.text(
-0.2,
center - 0.7,
question_key,
transform=y_axis_transform,
ha="right",
va="center",
fontsize=8,
fontweight="bold",
)
ax.text(
-0.2,
center + 0.1,
textwrap.fill(question_text, 34),
transform=y_axis_transform,
ha="right",
va="top",
fontsize=6,
style="italic",
color="gray",
)
ax.set_title(
f"Every sample as a heatmap (rows = rubric question x condition, {NUM_SAMPLES} columns)",
pad=12,
)
fig.tight_layout()
display(fig)
Die sachlichen Prüfungen bleiben über die meisten Bedingungen hinweg stabil. Bei den stark
ermessensabhängigen bewegen sich die LLM-Zeilen: exclusion, rental_eligible,
fraud_flag und manual_review verschieben sich über die Stichproben oder sind sich über
die Modelle hinweg uneinig. Die covered-Zeile von TypeSafe kreuzt 0.5; seine anderen 13
Fragen bleiben in diesem Lauf durchweg auf einer Seite dieses Schwellenwerts.
Eine unsichere Entscheidung zulassen statt Ja oder Nein zu erzwingen
Mit einem Schwellenwert von 0.5 verursachen die Wahrscheinlichkeiten 0.49 und 0.51
entgegengesetzte Aktionen, obwohl beide substanzielle Unsicherheit ausdrücken. Die
Anwendung kann stattdessen zurückgeben:
nounter0.30;uncertainvon0.30bis0.70, einschließlich beider Grenzen;yesüber0.70.
Unsichere Fälle gehen an einen Menschen. Die Eskalation ist Anwendungslogik über der zurückgegebenen Wahrscheinlichkeit: keine neue Frage, kein zweiter API-Aufruf. Das Band ist illustrativ; es ist weder eine kalibrierte Garantie noch ein optimierter Schwellenwert. Lege Produktionsgrenzen anhand gelabelter Beispiele und der Kosten falscher Entscheidungen und der Überprüfung fest.
Die folgende Illustration wendet dieses Band auf die aufgezeichneten TypeSafe-Wahrscheinlichkeiten an.
def noul_decision_with_uncertainty(probability: float) -> str:
"""Map valid TypeSafe probabilities through an inclusive uncertainty band."""
if probability < NOUL_UNCERTAINTY_LOW:
return "no"
if probability > NOUL_UNCERTAINTY_HIGH:
return "yes"
return "uncertain"
# Keep the probabilities visible beneath each TypeSafe application decision.
policy_decisions = [
[noul_decision_with_uncertainty(sample[key]) for sample in typesafe_runs]
for key in QUESTIONS
]
decision_codes = {"no": 0, "uncertain": 1, "yes": 2}
policy_values = [
[decision_codes[value] for value in row] for row in policy_decisions
]
policy_cmap = ListedColormap(["#a6dba0", "#dddddd", "#92c5de"])
fig_policy, ax_policy = plt.subplots(figsize=(13, 6))
ax_policy.imshow(policy_values, cmap=policy_cmap, vmin=0, vmax=2, aspect="auto")
for row_index, key in enumerate(QUESTIONS):
for sample_index in range(NUM_SAMPLES):
decision = policy_decisions[row_index][sample_index]
probability = typesafe_runs[sample_index][key]
ax_policy.text(sample_index, row_index, f"{decision}\n{probability:.2f}",
ha="center", va="center", fontsize=6)
ax_policy.set_yticks(range(len(QUESTIONS)), list(QUESTIONS))
ax_policy.set_xticks(range(NUM_SAMPLES), range(1, NUM_SAMPLES + 1))
ax_policy.set_xlabel("rubric query")
ax_policy.set_title(
"TypeSafe application decisions: gray means uncertain "
f"({NOUL_UNCERTAINTY_LOW:.2f} to {NOUL_UNCERTAINTY_HIGH:.2f} inclusive)"
)
fig_policy.tight_layout()
display(fig_policy)
Ein Überprüfungsband absorbiert Schwankungen um 0.5, ohne entgegengesetzte automatische
Aktionen auszugeben. Es hat jedoch seine eigenen Ränder. Ein Wert nahe einer der äußeren
Grenzen kann trotzdem zwischen uncertain und Ja oder Nein wechseln. Das Modell ist
deswegen nicht deterministischer, und eine automatische Entscheidung, die das Band
verlässt, ist nicht als korrekt erwiesen.
Im TypeSafe-Playground öffnen
Der folgende Link öffnet denselben Schaden und dieselbe Rubrik im Playground: einen
Schaden, dieselben 14 Noul-Fragen und TypeSafe jev-latest. Er lässt das sich ändernde
uid-Feld von oben weg.
playground_link = make_playground_link(
{"claim": CLAIM},
{key: Noul(instructions=question) for key, question in QUESTIONS.items()},
models=[TYPESAFE_MODEL],
)
display(
Markdown(
f"🔗 [Open this claim + rubric in the TypeSafe playground]({playground_link})"
)
)
Diesen Schadensfall + Rubrik im TypeSafe-Playground öffnen