●SANDBOX — Claude Code adds a sandbox.credentials setting that blocks sandboxed commands from reading credential files and secret environment variables●MODEL — Org-configured model restrictions now apply across the model picker, --model, /model, and ANTHROPIC_MODEL●AGENTS — Sessions that edit, merge, comment on, or push to an existing PR now link to that PR in the claude agents view●FIX — Fixed --json-schema silently producing unstructured output on an invalid schema, plus messages lost at the --max-turns limit●RECAP — Claude adds a monthly recap and focus settings in beta, with break reminders, quiet hours, and work insights●OPUS — Claude Opus 4.7 is now generally available with stronger long-running coding tasks and higher-resolution vision●SANDBOX — Claude Code adds a sandbox.credentials setting that blocks sandboxed commands from reading credential files and secret environment variables●MODEL — Org-configured model restrictions now apply across the model picker, --model, /model, and ANTHROPIC_MODEL●AGENTS — Sessions that edit, merge, comment on, or push to an existing PR now link to that PR in the claude agents view●FIX — Fixed --json-schema silently producing unstructured output on an invalid schema, plus messages lost at the --max-turns limit●RECAP — Claude adds a monthly recap and focus settings in beta, with break reminders, quiet hours, and work insights●OPUS — Claude Opus 4.7 is now generally available with stronger long-running coding tasks and higher-resolution vision
Don't Trust the Confidence Score: Per-Class Calibration and Abstain Routing for Vision Classification
Overall accuracy looked fine while individual categories quietly collapsed. Here is how I calibrated Claude Vision's self-reported confidence per class and routed abstentions to a human queue.
I was staring at a spreadsheet of classification results when I stopped scrolling. Overall agreement read 92.3%. But when I filtered down to the "animal" category alone, it was 71% — and the model had returned confidence values around 0.9 on most of that wrong 29%.
Confidently wrong. That turned out to be the worst failure mode of all.
For the wallpaper app I run as an indie developer, newly added images are sorted into 30 categories by Claude Vision. I wrote about the initial setup in automating wallpaper category classification with the Claude Vision API. What emerged after months of actually running it was this pattern: the aggregate looks healthy while a handful of classes rot underneath.
This article is the cleanup. It covers calibrating confidence per class and routing everything below the line into a human review queue.
"Confidence: 0.9" Is Not a Probability
Let's get the premise straight. When you ask for structured output containing confidence: 0.92, that number is not a probability. It is a plausible-looking number the model generated as text.
Name
What it actually is
Usable as a probability?
Self-reported confidence
A number the model wrote as a string
Not as-is
Logprob-derived probability
Token generation probability
Approximately, after calibration
Calibrated score
Mapped to empirical accuracy on a validation set
Yes
That does not make it useless. Self-reported confidence is usually meaningful as an ordering. A prediction at 0.95 is more likely correct than one at 0.65. What fails is the reading "0.9 means 90% correct."
So trust only the ordering, and measure from a validation set where the line has to be drawn to hit your precision target. That is what calibration means in practice.
Is 400 Images Enough?
I relabeled 412 images by hand. The eight most frequent of the 30 categories account for just over half of them; tail categories like "retro" and "minimal" have only five to nine each.
Which raised an honest question: can a threshold learned from six images mean anything?
My answer is that it can, but you have to change how you pick it. For thin classes, fine-grained optimization just overfits the validation set. So I split the classes into two tiers by support.
Tier
Validation images
How the threshold is chosen
Categories
Thick
20 or more
Searched individually
11
Thin
Fewer than 20
Share one conservative threshold (0.93)
19
Thin classes are poor candidates for auto-acceptance anyway. Give them a conservative shared line and send the rest to a human. That is enough.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Self-reported confidence used with a single global threshold silently breaks your smallest classes
✦How to search for the lowest threshold that still meets a precision target, per class, with working code
✦Routing abstentions into a prioritized human queue, and catching calibration drift week over week
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
# before.py - one global thresholdTHRESHOLD = 0.80def route(prediction: dict) -> str: """prediction = {"category": "animal", "confidence": 0.87}""" if prediction["confidence"] >= THRESHOLD: return "auto_accept" return "manual_review"
Two unstated assumptions hide in those four lines.
First, that confidence carries the same meaning across classes. Second, that precision is a single number worth talking about. Neither held up once I measured.
Running route() over the 412 images and computing precision only on what was auto-accepted:
Category
Auto-accepted
Precision
Where the errors went
Nature
78
0.974
2 confused with "landscape"
Abstract
61
0.951
3 confused with "geometric"
Urban
44
0.955
2 night shots sent to "night sky"
Animal
31
0.742
8 plush toys and illustrations read as real animals
Space
18
0.833
3 abstract gradients read as nebulae
In hindsight, "animal" was simple to explain. The model confidently calls a plush toy or a cartoon character an animal. Honestly, a human would half-agree. The category definition was the vague part, not the model.
Before touching calibration, I tightened the definition. Adding "photographic living creatures only; exclude plush toys, illustrations, and characters" to the prompt lifted animal precision from 0.742 to 0.861 on its own.
Calibration is not a tool for papering over a bad prompt. Fix definitional vagueness with definitions, then use thresholds to cut the errors that remain. Do it in the other order and you will spend weeks nudging a threshold that was never the problem.
After: Searching for a Per-Class Threshold
With definitions tightened, find the lowest threshold per class that still meets the precision target. The word "lowest" is the whole point. Raising a threshold raises precision but drops coverage, and every dropped image costs you human minutes.
# calibrate.py - per-class threshold searchfrom collections import defaultdictfrom dataclasses import dataclass@dataclassclass Sample: predicted: str truth: str confidence: floatMIN_SUPPORT = 20 # lower bound for a "thick" classFALLBACK_THRESHOLD = 0.93 # shared line for thin classesGRID = [round(0.50 + 0.01 * i, 2) for i in range(50)] # 0.50 to 0.99def _precision_at(samples: list[Sample], t: float) -> tuple[float, int]: accepted = [s for s in samples if s.confidence >= t] if not accepted: return 0.0, 0 hits = sum(1 for s in accepted if s.predicted == s.truth) return hits / len(accepted), len(accepted)def calibrate( samples: list[Sample], target_precision: float = 0.95, min_coverage: int = 5,) -> dict[str, float]: """Return the lowest threshold per predicted class that meets the target.""" by_class: dict[str, list[Sample]] = defaultdict(list) for s in samples: by_class[s.predicted].append(s) thresholds: dict[str, float] = {} for cls, group in by_class.items(): if len(group) < MIN_SUPPORT: thresholds[cls] = FALLBACK_THRESHOLD continue chosen = None for t in GRID: # ascending scan; stop at the first point that qualifies prec, n = _precision_at(group, t) if n >= min_coverage and prec >= target_precision: chosen = t break # A class that never reaches the target gives up on auto-acceptance thresholds[cls] = chosen if chosen is not None else 1.01 return thresholds
The line returning 1.01 is, to me, the most important one in the file.
When a class simply cannot reach the target, you could pin it at 0.99 and tell yourself "nothing will pass anyway." But then the boundary depends on floating-point luck, and one day a 0.995 prediction slips through. Declaring unreachability with a value that cannot be exceeded keeps the intent in the code where the next reader will find it.
"Nature" is accurate well below 0.80. "Animal" needs 0.94 before it clears the bar. A flat 0.80 was wasting human review on the former and shipping errors from the latter.
Across the whole set, coverage fell from 83% to 76% while precision on auto-accepted images rose from 0.918 to 0.957. Seven points of coverage bought roughly half the errors.
Where Abstentions Go
Anything under the line lands in manual_review. Make that a "look at it later" folder and it will fill up and die. I know because that is exactly what I did first.
What I run now converts the reason for abstaining directly into a work instruction, and stacks it in a prioritized queue.
# route.py - routing with a reason attachedfrom typing import Literal, TypedDictclass Prediction(TypedDict): category: str confidence: floatclass Decision(TypedDict): action: Literal["auto_accept", "manual_review"] reason: str priority: int # lower runs firstdef route(pred: Prediction, thresholds: dict[str, float]) -> Decision: cls = pred["category"] t = thresholds.get(cls, FALLBACK_THRESHOLD) c = pred["confidence"] if c >= t: return {"action": "auto_accept", "reason": "above_threshold", "priority": 9} if t > 1.0: # Unreachable class: calibration cannot save it. The definition needs work. return {"action": "manual_review", "reason": "class_not_calibratable", "priority": 1} if t - c <= 0.05: # So close a human decides in seconds return {"action": "manual_review", "reason": "near_threshold", "priority": 2} return {"action": "manual_review", "reason": "low_confidence", "priority": 5}
Anything landing at priority: 1 is not classification work. It is homework about the category definition. Mix it into the general queue and you lose the signal that says "this category makes me hesitate every single time." Keeping the reason codes separate means I can pull only class_not_calibratable on a weekend and rewrite the definition sentence in the prompt.
In production, near_threshold accounted for 38% of all abstentions. Those decisions are fast — batched together they take three or four seconds each. Splitting by priority alone changed how heavy the review work felt.
Calibration Rots Quietly
The thing I most underestimated: thresholds are perishable goods.
Swap the model. Edit one line of the prompt. Let the incoming image mix shift. Each of those moves the confidence distribution. Leave the threshold sitting on the old distribution and precision drifts down without a sound.
So I freeze the calibration set and re-run the same 412 images weekly.
# drift_check.py - detect calibration decaydef check_drift( samples: list[Sample], thresholds: dict[str, float], target_precision: float = 0.95, tolerance: float = 0.02,) -> list[str]: """Hold thresholds fixed and re-measure precision today.""" alerts = [] by_class: dict[str, list[Sample]] = defaultdict(list) for s in samples: by_class[s.predicted].append(s) for cls, group in by_class.items(): t = thresholds.get(cls, FALLBACK_THRESHOLD) prec, n = _precision_at(group, t) if n == 0: alerts.append(f"{cls}: zero auto-accepts. Threshold may be too high.") continue if prec < target_precision - tolerance: alerts.append( f"{cls}: precision {prec:.3f} < {target_precision - tolerance:.3f} " f"(n={n}, threshold={t}) -> recalibration needed" ) return alerts
I deliberately do not recalibrate automatically. If thresholds move on their own, you lose the moment where you would have asked why precision fell — a model change, or a shift in what people are uploading. Emit the alert, confirm the cause, then recalibrate by hand. That small friction pays for itself.
It earned its keep the week I updated the model. "Space" tripped the alert at precision 0.911. Digging in, the new model was more eager to call gradient wallpapers "space," and its confidence distribution had shifted about 0.03 higher overall. Raising the threshold from 0.93 to 0.96 settled it. Without the alert, several hundred images would have shipped under the wrong category.
When This Is Worth the Trouble
Honestly, sometimes it is not.
Situation
Call
Why
Five or fewer classes, cheap errors
A flat threshold is fine
Calibration upkeep costs more than it saves
Precision varies by 0.1 or more across classes
Calibrate per class
A flat line always sacrifices one side
Errors are visible to end users
Add abstain routing
Precision should outrank coverage
Model or prompt changes often
Drift detection is mandatory
Thresholds go stale fastest
In my case, wallpaper categories are something users tap directly. Open "Animal" and find abstract art, and that is a dent in the app's credibility. Trading away seven points of coverage was not a hard decision. Had these been internal analytics tags, I would have left the flat threshold alone.
Where to Start
Choosing a threshold turned out to be an exercise in saying out loud where I draw the line between accuracy and effort. Staring at the numbers long enough, it becomes clear which category's mistakes I can live with and which I cannot.
If you are facing the same problem, build a validation set of 200 to 400 rows with three columns: prediction, ground truth, confidence. Then plot _precision_at() per class. Which classes your flat threshold was quietly sacrificing is usually obvious at a glance.
Calibration does not make the model smarter. It measures the model's habits and translates them into your decisions. Once I framed it that way, the work stopped feeling like a chore.
Thank you for reading. If you find an unexpected class sinking in your own validation set, start by suspecting the definition sentence in your prompt.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.