Here's a conversation I've had more than once. "The classifier is 97% confident, so we auto-route it." "What does 97% mean?" "...That it's probably right?"
That number is doing a lot of quiet work. If you route, refund or close things automatically based on it, it decides how often a human never gets to look. And on the project I built for this topic, the raw score lied in a very specific way: in the top bucket the model averaged 99.4% confidence and was right 94.9% of the time.
So instead of a tutorial, this is a list of the six lies I found, in the order they bit me, with the code shape that catches each one. The stack is .NET 10, Microsoft.Extensions.AI 10.11.0 with the OpenAI adapter, and Microsoft.ML.Tokenizers 2.0.1. Snippets are trimmed, and the trimmed parts are marked.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, working, tested project behind this post is inside, with full source and an in-depth PDF guide. Join Tech Skill Builder
Lie 1: "The model told me how sure it was"
It didn't. A chat model writes invoice, not "invoice, 71%". If you ask it to add a confidence field to its JSON, you get a number, but in my measurements that self-reported number separated right answers from wrong ones barely better than a coin flip.
The real signal is token log probabilities. Ask Chat Completions for logprobs plus top_logprobs, and for a one-word answer the alternatives at the first token are the label distribution.
The catch: IChatClient has no logprobs property. You go one layer down with RawRepresentationFactory and read the SDK response from RawRepresentation:
var options = new ChatOptions { Temperature = 0, MaxOutputTokens = 12, // Return a NEW instance every call: MEAI copies ChatOptions values into it. RawRepresentationFactory = _ => new ChatCompletionOptions { IncludeLogProbabilities = true, TopLogProbabilityCount = 10, // clamp yourself; the SDK won't stop 21 }, }; var response = await chat.GetResponseAsync(messages, options, ct); // "no logprobs" shows up as an EMPTY list, not null; check Count // (observation mapping lives in the complete project) Two gotchas worth repeating: a shared options instance gets mutated between calls, and an empty ContentTokenLogProbabilities list will happily produce a "confidence" from nothing if you don't check it.
Lie 2: "Each label has its own probability"
You only get probabilities per token. A label is only measurable if its first token belongs to it alone.
Picture a menu like invoice_paid, invoice_overdue, Refund. Both invoice labels start with the same token, so at the one position you read, they are literally the same answer. And Refund vs refund are different tokens for what you think is one label.
So the menu gets linted at startup with the model's real tokenizer, before a single request goes out:
// ITokenProbe wraps TiktokenTokenizer.CreateForModel(...) from Microsoft.ML.Tokenizers var findings = MenuLinter.Lint(menu, tokenProbe, topLogprobs: 10); // errors: shared first token, 1-char fragment, label not reachable within top-k // warnings: casing that splits one label into two tokens // (rule set in the complete project) if (MenuLinter.HasErrors(findings)) throw new InvalidOperationException("Label menu cannot be measured."); The fix is usually boring: lower-case, single-token label names, and an other label so off-topic input has somewhere honest to go. Also check that top_logprobs is at least the number of labels, or some labels are invisible in every distribution.
Lie 3: "Top label divided by the total is the confidence"
This is the one I see most. You add up the label probabilities, divide the top one by the sum, and call it confidence. It looks fine until the model doesn't want to answer.
Tokens like I or Sorry at the first position are the model starting a refusal. Renormalizing only over labels throws that mass away, and a ticket the model clearly didn't want to classify suddenly looks like a confident other.
Keep three buckets apart:
foreach (var alt in position.Top) { var p = Logprob.ToProbability(alt.Logprob); // -9999 is a sentinel, maps to 0 covered += p; var label = menu.MapToken(alt.Token); // folds case/whitespace, -1 = off-menu if (label >= 0) labelMass[label] += p; else offMenu += p; } var unseen = Math.Max(0, 1 - covered); // outside the top-k // prefix matching and JSON answer location omitted here Off-menu mass above a threshold becomes its own reason to send the item to a human. So does an answer that isn't a label at all.
Lie 4: "0.97 means right 97 times out of 100"
Only if the model is calibrated, and modern models usually aren't. The fix is old and pleasantly dull: temperature scaling. Divide the label logits by one scalar T before the softmax, and choose T to minimize negative log-likelihood on labeled data.
public static double Fit(IReadOnlyList<LogitExample> examples) { // golden-section search on ln T over a bounded range // (full implementation in the complete project) } var p = TemperatureScaler.Softmax(distribution.LabelLogits(), profile.Temperature); In the project, expected calibration error went from 0.053 to 0.014 with T = 2.02, and accuracy didn't move at all. That's the nice property: dividing by a positive constant never changes which label wins. You change what the model claims, not what it picks.
Lie 5: "The error rate above my threshold is under 5%, so we're safe"
This is where an honest-looking spreadsheet quietly breaks a promise. "Pick the lowest threshold where observed errors among accepted items stay under 5%" gives you a threshold that is under 5% on this sample. The upper confidence bound can sit well above it.
Use the Wilson upper bound instead of the raw rate:
foreach (var item in items.OrderByDescending(s => s.Confidence)) { accepted++; if (!item.Correct) errors++; // keep tied confidences together (omitted) if (accepted >= minAccepted && Wilson.Upper(errors, accepted) <= targetRisk) best = new ThresholdChoice(item.Confidence, accepted, errors); } // nothing qualified -> threshold is +infinity: everything goes to a human The sobering part: zero errors in 40 accepted items still has a Wilson upper bound of about 8.8%. Small labels can't support tight promises, and the picker should say so instead of pretending. Different labels also deserve different targets: a misrouted refund costs more than a misrouted "how do I change my avatar".
Lie 6: "We calibrated it, so it stays calibrated"
A temperature and a threshold belong to one model snapshot, one prompt, one label menu. Change any of them and the numbers mean nothing. The sneaky one is the model alias: you configure a name, and the response's ModelId tells you which dated snapshot actually answered. Aliases move without telling you.
public sealed record CalibrationFingerprint( string ModelId, string PromptHash, string MenuHash, int TopLogprobs); // compare against the SERVED snapshot from the response, not the configured name // mismatch -> every item goes to review until someone refits // (matching rules in the complete project) In the offline demo, pointing the alias at a newer snapshot sends every item to review immediately, and reusing the old temperature on the new snapshot measurably worsens calibration. Refit, and automation comes back.
The thread that ties it together: fail closed, with reasons
Every lie above becomes one reason in a single policy decision: not calibrated, model mismatch, no logprobs, answer not found, not a label, truncated output, too much off-menu mass, below threshold, provider error. Any one of them means a human looks, and the reason travels with the item, so the reviewer knows whether the model was unsure or the plumbing was.
Quick checklist
- [ ] Confidence comes from logprobs, never from the model's self-report
- [ ] Label menu linted with the real tokenizer at startup
- [ ] Label mass, off-menu mass and unseen mass kept separate
- [ ] Temperature fitted on labeled data; accuracy unchanged, calibration better
- [ ] Thresholds chosen with a Wilson upper bound, per risk class
- [ ] Calibration pinned to the served snapshot, prompt and menu
- [ ] Every non-auto decision carries its reasons
There's one question I deliberately didn't answer here: what do you do when the model gives you no logprobs at all? Some newer models don't support them, and then everything above falls through to "review". The project has a working answer for that, with the same kind of defensible thresholds, and the PDF walks through it in detail.
Get the complete project
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. This project is already inside: the full .NET 10 solution with the tokenizer-backed menu linter, the logprob reader, temperature scaling, the Wilson threshold picker, snapshot pinning, the fail-closed triage policy with its review queue, an offline OpenAI fake so nothing needs an API key, a minimal API, a CLI demo and 245 passing tests. Plus a long PDF guide that goes much deeper than this post, including the no-logprobs answer. You also get the other complete, tested .NET + AI projects in the membership, with new ones added regularly.
Join Tech Skill Builder for $5/month
Which of these six lies is your classifier telling right now?


