Skip to content

metrics

Metric functions over flat arrays — no evaluator required.

from hotcoco import metrics
use hotcoco::metrics;

Every function here is pure: arrays in, numbers out — the same shape sklearn.metrics and torchmetrics.functional use. COCOeval's analysis methods call these functions, so the object API and the functional API cannot disagree. Reach for metrics when you have arrays rather than a COCO dataset.

primitives is the layer below: it decides which prediction pairs with which ground truth; metrics turns those matches into numbers. Build your own metric puts the two together.

Float and bool parameters accept lists or 1-D numpy arrays interchangeably — signatures written Sequence[float] also take an ndarray.

from hotcoco import metrics

scores  = [0.95, 0.88, 0.71, 0.40]
matched = [True, True, False, True]

ap = metrics.average_precision(scores, matched, num_gt=5)
ece, mce = metrics.calibration_error(scores, matched)

These functions are additive-change-only through 1.x; COCOeval and the pycocotools drop-in surface are frozen.


Functions

average_precision

Average precision from per-prediction scores and match flags.

average_precision(
    scores: Sequence[float],
    matched: Sequence[bool],
    num_gt: int,
    ignored: Sequence[bool] | None = None,
    rec_thrs: Sequence[float] | None = None,
) -> float
Parameter Type Description
scores Sequence[float] Confidence per prediction, in any order
matched Sequence[bool] Whether each prediction is correct
num_gt int Total ground truths — the recall denominator
ignored Sequence[bool] \| None Predictions counting as neither TP nor FP
rec_thrs Sequence[float] \| None Recall grid; defaults to COCO's 101 points
metrics::counts::average_precision(
    scores: &[f64],
    matched: &[bool],
    ignored: Option<&[bool]>,
    num_gt: usize,
    rec_thrs: &[f64],
) -> f64

Sorts by score descending, classifies each prediction as TP or FP, and interpolates precision onto the recall grid using PASCAL VOC interpolation — the same computation that produces COCO's AP.

>>> round(metrics.average_precision([0.9, 0.8, 0.3], [True, False, True], num_gt=2), 3)
0.835

num_gt must count ground truths you never predicted

It is the recall denominator. Passing only the matched count silently overstates recall, and therefore AP.

Returns 0.0 when there are no predictions or no ground truth. If your metric wants a different answer for the empty case — per-image diagnostics call an empty image perfect — branch before calling.


precision_recall_curve

Precision interpolated onto a recall grid, from cumulative TP/FP counts.

precision_recall_curve(
    tp_cum: Sequence[float],
    fp_cum: Sequence[float],
    num_gt: int,
    rec_thrs: Sequence[float] | None = None,
) -> tuple[float, list[tuple[int, float, int]]]
metrics::counts::precision_recall_curve(
    tp_cum: &[f64], fp_cum: &[f64], num_gt: usize, rec_thrs: &[f64],
) -> (f64, Vec<(usize, f64, usize)>)

Lower level than average_precision — use it when you already hold cumulative counts, or want the curve rather than the scalar. tp_cum and fp_cum must already be prefix-summed over predictions sorted by descending score.

Returns (final_recall, points), where each point is (threshold_index, precision, rank). Recall thresholds the predictions never reach are omitted rather than reported as zero, so points can be shorter than rec_thrs.


calibration_curve

Reliability bins: predicted confidence against observed accuracy.

calibration_curve(
    scores: Sequence[float], matched: Sequence[bool], n_bins: int = 10
) -> list[dict]
metrics::calibration::calibration_curve(
    scores: &[f64], matched: &[bool], n_bins: usize,
) -> Vec<CalibrationBin>

The data behind a reliability diagram. Each dict has bin_lower, bin_upper, avg_confidence, avg_accuracy, and count. Empty bins are included with count = 0, so the list always has n_bins entries and plots without gaps.

A perfectly calibrated model has avg_confidence == avg_accuracy in every bin — that diagonal is what the diagram compares against.

Scores must be confidences in [0, 1]

Both calibration functions bucket by score * n_bins and clamp the bin index, not the score, so a raw logit saturates into an end bin and carries its magnitude into that bin's mean — an ECE above 1.0 with no other symptom. Neither free function validates its input: they are hot-path primitives over flat arrays, and the check is a full pass over the scores. COCOeval.calibration() does validate and raises on out-of-range scores. Apply a sigmoid or softmax before calling these directly.


calibration_error

Expected and Maximum Calibration Error.

calibration_error(
    scores: Sequence[float], matched: Sequence[bool], n_bins: int = 10
) -> tuple[float, float]
// Rust splits binning from scoring, so bins can be reused.
let bins = metrics::calibration::calibration_curve(&scores, &matched, n_bins);
let (ece, mce) = metrics::calibration::calibration_error(&bins);

Returns (ece, mce):

  • ECE — the occupancy-weighted mean gap between confidence and accuracy. The headline number.
  • MCE — the worst single bin's gap, unweighted. Catches a badly calibrated region that ECE averages away.
>>> # Always claims 0.9 confidence, right half the time.
>>> ece, mce = metrics.calibration_error([0.9] * 100, [True] * 50 + [False] * 50)
>>> round(ece, 3)
0.4

Both are 0.0 for empty input.


confusion_matrix

Confusion counts over matched ground-truth/prediction pairs.

confusion_matrix(
    gt: Sequence[int | None], dt: Sequence[int | None], num_classes: int
) -> numpy.ndarray
metrics::confusion::confusion_matrix(
    gt_labels: &[Option<usize>], dt_labels: &[Option<usize>], num_classes: usize,
) -> Vec<u64>

sklearn.metrics.confusion_matrix assumes every sample has both a true and a predicted label. Detection and tracking don't: a prediction can match nothing, and a ground truth can go unpredicted. So this takes optional labels and reserves index num_classes for background.

gt[i] dt[i] Meaning Lands at
g d matched pair (correct when g == d) [g][d]
g None ground truth with no prediction [g][num_classes]
None d prediction matching no ground truth [num_classes][d]
None None nothing happened ignored
>>> m = metrics.confusion_matrix([0, 1, None], [0, None, 1], num_classes=2)
>>> m[0, 0], m[1, 2], m[2, 1]   # correct, missed, spurious
(1, 1, 1)

The entries are one per match record, not one per prediction — producing those records is the caller's job, and it is the only family-specific step. Counts are integers, so accumulating per-image and summing gives the same answer as one whole-dataset call. That is what lets you parallelize and reduce.

Class indices outside range(num_classes) are dropped rather than raising, so a stray label can't take down an evaluation run.

panoptic_quality

PQ, SQ, and RQ from match counts.

panoptic_quality(iou_sum: float, tp: int, fp: int, fn_: int) -> tuple[float, float, float]
metrics::panoptic::PqCounts { iou, tp, fp, fn_ }.scores_or_missing() -> PqScores
metrics::panoptic::pq_average(counts: impl IntoIterator<Item = &PqCounts>) -> (PqScores, usize)

The formulas behind panoptic, for counts your own matcher produced — or primitives.match_segments, the one PanopticEval uses: PQ = Σ IoU / (TP + ½ FP + ½ FN), SQ = Σ IoU / TP, RQ = TP / (TP + ½ FP + ½ FN), so PQ = SQ × RQ.

>>> metrics.panoptic_quality(1.6, tp=2, fp=1, fn_=1)
(0.5333333333333333, 0.8, 0.6666666666666666)

Returns (-1.0, -1.0, -1.0) when tp + fp + fn == 0: nothing to score, which panopticapi leaves out of its averages. To reproduce its All, Things, and Stuff numbers, average the per-category results that is_computed accepts. fn_ carries a trailing underscore because fn is a Rust keyword and the same counts live there.

pixel_confusion

The pixel confusion matrix of one label-map pair.

pixel_confusion(gt, pred, num_classes: int, *, ignore_index: int | None = None) -> ndarray[uint64]
metrics::semantic::PixelConfusion::new(num_classes) -> PixelConfusion
PixelConfusion::update(&mut self, gt: &[G], pred: &[P], ignore_index: Option<u64>) -> Result<()>
PixelConfusion::update_maps(&mut self, gt: &LabelMap, pred: &LabelMap, ignore_index: Option<u64>) -> Result<()>
PixelConfusion::add(&mut self, other: &PixelConfusion)

A (num_classes, num_classes) array, row the ground-truth class, column the predicted class, over integer arrays of any shape. Pixels whose ground truth is ignore_index are left out on both sides; every other label must be below num_classes, on either side, or ValueError names the pixel. The matrix behind semantic, for a loop that keeps its own: sum the arrays across images, then hand the total to mean_iou.

>>> metrics.pixel_confusion([[0, 1], [1, 255]], [[0, 1], [0, 1]], num_classes=2, ignore_index=255).tolist()
[[1, 0], [1, 1]]

mean_iou

mIoU, fwIoU, mAcc, aAcc, mDice, and the per-class scores from a confusion matrix. Boundary IoU is not a function of the matrix; SemanticEval computes it from the maps.

mean_iou(confusion) -> dict
PixelConfusion::scores(&self) -> SemanticScores
metrics::semantic::semantic_scores(classes: &[ClassCounts]) -> SemanticScores

Takes a square integer array as pixel_confusion and SemanticEval.confusion() produce it and returns {"mIoU", "fwIoU", "mAcc", "aAcc", "mDice", "IoU": [...], "Acc": [...], "Dice": [...]}, the lists by class id. mmsegmentation's conventions: a class on neither side has no IoU or Dice, a class with no ground truth has no accuracy, and any of those is -1.0 and left out of its mean; a class predicted but never in the ground truth scores IoU 0 and counts toward mIoU. The semantic guide has the formulas.

>>> metrics.mean_iou([[3, 1], [0, 2]])["mIoU"]
0.7083333333333333

mean_or_missing

mean_or_missing(total: float, count: int) -> float

The mean of count values summing to total, or -1.0 when count is 0. This is the one producer of the sentinel is_computed reads back: every family's means go through it, so a composed metric that does too never spells -1.0 itself. A ratio of counts is the mean of an indicator, so precision is mean_or_missing(tp, tp + fp).

>>> metrics.mean_or_missing(1.5, 2)
0.75
>>> metrics.mean_or_missing(0.0, 0)
-1.0

is_computed and is_missing

is_computed(v: float) -> bool
is_missing(v: float) -> bool

-1.0 in any metric means "not computed for this configuration" — an area range with no ground truth, or a category absent from the split — never a low score. is_missing(v) is true for that sentinel and is_computed(v) is its negation. Use them when averaging per-class values so a sentinel does not drag the mean down; mean_or_missing produces the mean of what is left.


Rust-only

Bootstrap confidence intervals (metrics::bootstrap::bootstrap_ci) take the statistic as a closure. compare() uses them internally and returns the intervals.

Greedy matching lives one layer down, and is callable from Python there — see primitives.greedy_match.