Calibrating OCR confidence thresholds for HS digits
Optical character recognition assigns a per-character confidence to every digit it reads off a scanned invoice or manifest, but choosing the cutoff below which an HS or HTS digit must go to human review is a calibration problem, not a guess: set it too low and confusable pairs like 0/8 and 1/7 slip through and mis-classify an entry, set it too high and reviewers drown in digits the engine actually read correctly. This page treats threshold selection as a precision-recall trade-off measured against a labelled ground-truth set, sweeps the cutoff to find the operating point that meets a target error rate on the classifying digits, and routes only genuinely low-confidence characters to review. The implementation uses Decimal for rate arithmetic and slots into the correction pipeline described in OCR Drift Correction & Validation.
Prerequisites
This calibration assumes a specific evaluation setup and toolchain. Confirm each before applying it:
- Python 3.10+ with
dataclass,Optional, andDecimal. Error and pass rates are held asDecimalso a target like a 0.2% escape rate is represented exactly, not as a binary-float approximation. No heavy ML dependency is required; the sweep is plain arithmetic over a scored sample. - A labelled ground-truth set of digit reads. You need a representative sample where each OCR digit read is paired with its true value and the engine’s reported confidence. Without ground truth you cannot measure precision or recall — you can only pick a cutoff blindly. A few thousand digit reads spanning your real scanners and fonts is enough to start.
- Per-character confidence, not per-field. The engine must expose confidence at the character level. A single field-level score hides the one weak digit inside an otherwise strong 10-digit code, which is exactly the digit you need to catch.
- A defined cost asymmetry. Decide what a missed error (an accepted wrong digit that reaches classification) costs relative to a false alarm (a correct digit sent to review). Customs misclassification carries duty and penalty exposure, so the missed-error cost usually dominates — that asymmetry drives the operating point.
- A review queue and structured logging already provisioned. This page routes low-confidence digits to review; it does not build the queue. Configure stdlib
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat so every routing decision is auditable.
Framing the trade-off
Treat “accept the OCR digit” as the positive action. At a given confidence threshold, every scored digit falls into one of four buckets: a correct digit accepted (true positive), a wrong digit accepted (false positive — the dangerous escape), a correct digit sent to review (false negative — wasted effort), and a wrong digit sent to review (true negative — a catch). Raising the threshold moves digits from accepted to reviewed: it lowers false positives (fewer escapes) but raises false negatives (more review load). That is the entire tension.
Two rates summarize it. Precision of acceptance is the fraction of accepted digits that were actually correct — high precision means few bad digits escape. Recall of acceptance is the fraction of all correct digits that you accepted automatically — high recall means little needless review. You cannot maximize both; the sweep finds the threshold where precision meets your escape-rate target while recall stays as high as that target allows.
Confusable digit pairs concentrate the risk. The engine is most likely to be wrong, yet still moderately confident, on 0 versus 8, 1 versus 7, 5 versus 6, and 3 versus 8 under smudged or low-DPI scans. Those are precisely the reads that cluster just above a carelessly low threshold, so the operating point must be chosen against a sample that includes them.
Implementation
The calibrator sweeps candidate thresholds over the labelled sample, computes precision and recall at each, and returns the lowest threshold that holds the accepted-error rate at or below the target — minimizing review load subject to the compliance constraint.
import logging
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("ocr_calibrator")
@dataclass(frozen=True)
class DigitRead:
predicted: str # digit the OCR engine reported
truth: str # ground-truth digit from the labelled set
confidence: Decimal # engine confidence in [0, 1]
@dataclass(frozen=True)
class OperatingPoint:
threshold: Decimal
precision: Decimal # of accepted digits, fraction correct
recall: Decimal # of correct digits, fraction accepted
accepted_error: Decimal # of accepted digits, fraction wrong (escape rate)
review_load: Decimal # fraction of all digits routed to review
def _q(x: Decimal) -> Decimal:
return x.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
def evaluate(sample: list[DigitRead], threshold: Decimal) -> OperatingPoint:
tp = fp = fn_ = tn = 0
for r in sample:
accepted = r.confidence >= threshold
correct = r.predicted == r.truth
if accepted and correct:
tp += 1
elif accepted and not correct:
fp += 1 # escape: a wrong digit was accepted
elif not accepted and correct:
fn_ += 1 # wasted review of a correct digit
else:
tn += 1 # good catch: wrong digit routed to review
accepted = tp + fp
correct_total = tp + fn_
total = len(sample)
precision = _q(Decimal(tp) / accepted) if accepted else Decimal("1")
recall = _q(Decimal(tp) / correct_total) if correct_total else Decimal("1")
accepted_error = _q(Decimal(fp) / accepted) if accepted else Decimal("0")
review_load = _q(Decimal(fn_ + tn) / total) if total else Decimal("0")
return OperatingPoint(threshold, precision, recall, accepted_error, review_load)
def calibrate(
sample: list[DigitRead],
target_escape_rate: Decimal = Decimal("0.0020"), # <= 0.2% wrong accepted
step: Decimal = Decimal("0.01"),
) -> Optional[OperatingPoint]:
"""Sweep thresholds; return the LOWEST that meets the escape-rate target,
which maximizes recall (minimizes review load) under the constraint."""
best: Optional[OperatingPoint] = None
t = Decimal("0.50")
while t <= Decimal("1.00"):
op = evaluate(sample, t)
logger.info(
"t=%s precision=%s recall=%s escape=%s review=%s",
t, op.precision, op.recall, op.accepted_error, op.review_load,
)
if op.accepted_error <= target_escape_rate:
best = op # first (lowest) qualifying threshold — best recall
break
t += step
if best is None:
logger.warning("No threshold met escape target %s; route ALL to review.",
target_escape_rate)
return best
def route_digit(read: DigitRead, op: OperatingPoint) -> str:
"""Accept or route a live digit under the calibrated operating point."""
if read.confidence >= op.threshold:
return "ACCEPT"
logger.info("Digit %r conf=%s below %s; route to review.",
read.predicted, read.confidence, op.threshold)
return "REVIEW"
The sweep returns the lowest qualifying threshold on purpose: among all cutoffs that satisfy the escape-rate constraint, the lowest one accepts the most digits automatically, so it minimizes review load while still meeting the compliance target. Raising the threshold beyond that point only adds review work without buying any escape-rate headroom you asked for.
Verification steps
Run these checks against your labelled sample before trusting the calibrated threshold in production:
- Escape-rate constraint holds. Take the
OperatingPointreturned bycalibrateand assertaccepted_error <= target_escape_rate. IfcalibratereturnedNone, confirm your pipeline falls back to routing every digit to review rather than accepting on a threshold that never qualified. - Lowest-threshold optimality. Evaluate the threshold one
stepbelow the chosen point and assert it exceeds the escape target — proving the calibrator picked the lowest qualifying cutoff and did not needlessly inflate review load. - Confusable-pair coverage. Filter the sample to reads where
predictedandtruthform a known confusable pair (0/8, 1/7, 5/6, 3/8). Confirm this subset is present and non-trivial; a calibration blind to confusable pairs will pick a falsely low threshold. - Precision-recall monotonicity. Sweep the full range and assert precision is non-decreasing and recall is non-increasing as the threshold rises. A violation means your confidences are miscalibrated or the sample is too small to be stable.
- Decimal exactness. Confirm every rate in
OperatingPointis aDecimal, not afloat. A target escape rate of0.0020must compare exactly; afloatintermediate can push a borderline point to the wrong side of the constraint. - Live routing parity. Run
route_digitover a held-out slice at the chosen operating point and confirm the realized accept/review split matches the sweep’s predictedreview_loadwithin sampling error. A large gap signals distribution drift between calibration and production scans, which loops back into correcting OCR drift in scanned customs forms. - Per-position sanity. Spot-check that low-confidence digits in the classifying positions of the code (the subheading digits that change the duty rate) are never auto-accepted at a lower bar than trailing statistical digits — misreads there are the costliest.
Edge cases & gotchas
- Miscalibrated confidences. Some engines report confidences that are not true probabilities — a reported 0.95 may be wrong far more than 5% of the time. Always validate the confidence-to-error relationship on ground truth before trusting the sweep; if it is non-monotonic, apply a calibration map (isotonic or Platt scaling) first, or the threshold is meaningless.
- Per-field versus per-digit thresholds. A field-level accept gate hides a single weak digit in a strong code. Always calibrate and route at the digit level, then reject the field if any classifying digit routes to review — one bad digit changes the entire HS heading.
- Position-dependent risk. The first six digits determine the international subheading and the duty consequence; the trailing statistical suffix is lower stakes. Consider a stricter threshold on the classifying positions than on the tail, rather than one flat cutoff across all ten.
- Sample drift. A threshold calibrated on last quarter’s scanner and paper stock decays as fonts, DPI, and fax artifacts change. Re-run
calibrateon fresh ground truth periodically; a stale operating point silently raises the escape rate without any error surfacing. - Zero-acceptance degenerate case. If no threshold meets the target,
calibratereturnsNoneand you must route everything to review — never quietly relax the target to force a non-null result. A relaxed escape rate is a compliance decision, not a calibration default. - Ground-truth leakage. If the labelled set overlaps the data you calibrated the engine on, precision is optimistically biased. Hold out a genuinely independent sample for the sweep, or the chosen threshold will under-review in production.
Related
- OCR Drift Correction & Validation
- Correcting OCR drift in scanned customs forms
- Normalizing CJK invoice fields with Unicode NFC
- Document Ingestion & Parsing Workflows
Up: OCR Drift Correction & Validation
Authoritative references: WCO HS 2022 six-digit subheading structure; HTSUS ten-digit statistical suffix (USITC); 19 CFR 141.86 (reasonable care in entry data).