Per-class confidence thresholds in RF-DETR¶
predictor.predict(threshold=...), where predictor = model.inference(), takes either one confidence threshold for every class or a ClassThresholds with one confidence
threshold per class. This cookbook shows how to choose those numbers, and measures on held-out images when the per-class form
pays off and when it does not.
Throughout, a threshold is the confidence score a detection must exceed for predict() to keep it. It is not the
overlap (IoU) a detection needs with a ground-truth box to count as correct, which this notebook calls MATCH_IOU.
Why one confidence threshold may not be enough¶
A detector does not find every class equally well, and an application does not care about every class equally. A single threshold has to compromise between them: lowering it to catch more of one class lets in false alarms from all the others, and raising it to silence one class loses objects of the rest.
Until now the way around this was to call predict() with a very low threshold and filter the detections by class
yourself. Passing a ClassThresholds does that filtering inside predict():
predictor = model.inference()
detections = predictor.predict(image, threshold=0.45) # the same bar for every class
detections = predictor.predict(image, threshold=ClassThresholds([0.40, 0.55, 0.45])) # a model with three classes: one bar per class
detections = predictor.predict(image, threshold=ClassThresholds({"person": 0.35}, default=0.45)) # name only the classes that differ
A detection is kept when its score is strictly above the threshold of its own class. The list is read in the order of
model.class_names, and it must hold exactly one entry per class.
What this notebook does¶
- Takes a pretrained
RFDETRSmalland two disjoint sets of COCO (Common Objects in Context)val2017images: a tuning set to choose thresholds on and a test set to judge them on. The checkpoint was trained ontrain2017, so the model saw neither set. - Runs the model once on both sets at a very low threshold and labels every detection as a true positive (TP) or a false positive (FP) by comparing it with the ground truth.
- Experiment 1, the same goal for every class. Picks the threshold that maximises each class's F1 score on the tuning set and compares it with the best single threshold on the test set.
- Experiment 2, a different goal for one class. Asks for a high recall on one class and compares what a single lowered threshold costs the other classes with what a lowered threshold for that class alone costs them.
- Runs
predict(threshold=ClassThresholds(...))on the test set and confirms that it returns exactly what filtering the low-threshold detections by class gives.
To use your own data, replace the data cell in section 1 with your own images and ground-truth boxes; the rest of the notebook does not depend on COCO.
Setup¶
Install rfdetr 1.12.0 or newer (the first release with per-class thresholds), plus
pandas for the detection table and matplotlib
for the plots. supervision comes with rfdetr.
!pip install -q "rfdetr>=1.12.0" pandas matplotlib
The settings below are the values you may want to change.
LOW_THRESHOLDis the lowest score the model reports. Every threshold you can choose later is at or above it.MATCH_IOUis the intersection over union (IoU) a detection needs with a ground-truth box of the same class to count as a true positive. It is a matching rule for the evaluation and has nothing to do with the confidence thresholds this notebook chooses.MIN_TUNING_OBJECTSis the number of ground-truth objects a class needs in the tuning set before experiment 1 gives it a threshold of its own. A class with fewer objects takes the single best threshold, because a cut-off picked from a handful of examples would mostly fit noise.PRIORITY_CLASSandTARGET_RECALLare the class that experiment 2 treats as more important than the rest and the recall it must reach.SEEDdecides which images land in the tuning and in the test set. Change it to see how much the results move.
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import supervision as sv
from tqdm.auto import tqdm
from rfdetr import ClassThresholds, RFDETRSmall
from rfdetr.export._benchmark import fetch_coco_val2017
SEED = 0
N_TUNING_IMAGES = 1000
N_TEST_IMAGES = 1000
LOW_THRESHOLD = 0.05
MATCH_IOU = 0.5
MIN_TUNING_OBJECTS = 20
PRIORITY_CLASS = "person"
TARGET_RECALL = 0.85
BATCH_SIZE = 16
COCO_ROOT = Path("coco")
# Every threshold that can be chosen: from the lowest reported score up to 0.95 in steps of 0.01.
THRESHOLD_GRID = np.round(np.arange(LOW_THRESHOLD, 0.96, 0.01), 2)
model = RFDETRSmall()
class_names = model.class_names
class_index_by_name = {name: index for index, name in enumerate(class_names)}
num_classes = len(class_names)
print(f"{type(model).__name__}: {num_classes} classes, first five: {class_names[:5]}")
1 - Prepare the data¶
The helper below is the one the export cookbooks use. It downloads the val2017 annotations once and then only the
images it is asked for. The images are drawn with a seeded shuffle and split in two, so the tuning and test sets share
no image.
The next cell reads the ground truth with sv.DetectionDataset.from_coco into one sv.Detections per image: its
boxes as xyxy pixels, class_id as the index of each box's class in model.class_names (checked against the
model), and data["iscrowd"] for COCO's crowd regions (one box around many objects, which no single detection
should be expected to match).
subset = fetch_coco_val2017(COCO_ROOT, n_images=N_TUNING_IMAGES + N_TEST_IMAGES, seed=SEED)
image_ids = list(subset.image_ids)
tuning_ids, test_ids = image_ids[:N_TUNING_IMAGES], image_ids[N_TUNING_IMAGES:]
assert set(tuning_ids).isdisjoint(test_ids)
# supervision maps the sparse COCO category IDs to indexes of `dataset.classes`, keeps each box's `iscrowd` flag, and
# records the image paths without reading any image.
dataset = sv.DetectionDataset.from_coco(str(subset.images_dir), str(subset.annotations_path))
assert dataset.classes == class_names, "the COCO categories are not in model.class_names order"
truth_by_file = {Path(path).name: boxes for path, boxes in dataset.annotations.items()}
with subset.annotations_path.open() as annotations_file:
file_name = {image["id"]: image["file_name"] for image in json.load(annotations_file)["images"]}
truth = {image_id: truth_by_file[file_name[image_id]] for image_id in image_ids}
paths = {image_id: str(subset.images_dir / file_name[image_id]) for image_id in image_ids}
print(f"{len(tuning_ids)} tuning images, {len(test_ids)} test images")
def is_crowd(boxes: sv.Detections) -> np.ndarray:
"""Return which ground-truth boxes are crowd regions; an image without annotations has no ``iscrowd`` data."""
return np.asarray(boxes.data.get("iscrowd", np.zeros(len(boxes))), dtype=bool)
def count_objects(ids: list[int]) -> np.ndarray:
"""Count the ground-truth objects of every class in the images ``ids``, crowd regions excluded."""
indexes = np.concatenate([truth[i].class_id[~is_crowd(truth[i])] for i in ids])
return np.bincount(indexes, minlength=num_classes)
tuning_objects = count_objects(tuning_ids)
test_objects = count_objects(test_ids)
print(
f"{(tuning_objects >= MIN_TUNING_OBJECTS).sum()} of {num_classes} classes have at least {MIN_TUNING_OBJECTS} "
f"objects in the tuning set"
)
2 - Run the model once at a low confidence threshold¶
predict() returns one sv.Detections per image. data["class_name"] holds each detection's class name, which does not
depend on how the checkpoint numbers its classes (a pretrained COCO checkpoint uses the sparse COCO category IDs), so the
cell maps names to indexes in model.class_names. The detections are left at LOW_THRESHOLD so that every candidate
threshold can be tried afterwards without running the model again.
include_source_image=False keeps the pixels of the source images out of the results, which saves memory over thousands
of images.
predictor = model.inference(compile=False)
def predict_in_batches(ids: list[int], threshold: float | ClassThresholds) -> list[sv.Detections]:
"""Run ``predictor.predict`` over the images ``ids`` in batches and return one ``sv.Detections`` per image."""
results: list[sv.Detections] = []
for start in tqdm(range(0, len(ids), BATCH_SIZE), desc="predict"):
batch = [paths[i] for i in ids[start : start + BATCH_SIZE]]
results.extend(predictor.predict(batch, threshold=threshold, include_source_image=False))
return results
def class_indexes(detections: sv.Detections) -> np.ndarray:
"""Return the index in ``model.class_names`` of every detection, or ``-1`` for a name the model does not list."""
return np.array([class_index_by_name.get(name, -1) for name in detections.data["class_name"]], dtype=np.int64)
tuning_detections = predict_in_batches(tuning_ids, LOW_THRESHOLD)
test_detections = predict_in_batches(test_ids, LOW_THRESHOLD)
print(
f"{sum(map(len, tuning_detections))} tuning and {sum(map(len, test_detections))} test detections at {LOW_THRESHOLD}"
)
3 - Label every detection as a true or a false positive¶
Within an image, detections are visited from the highest score down. A detection is a true positive when it overlaps
a not-yet-matched ground-truth box of its own class by at least MATCH_IOU, and it takes the one with the highest
overlap; that box is then used up. Only when no such box is left does a crowd region count: a detection with at least
MATCH_IOU of its own area inside a crowd region of its class is ignored (neither right nor wrong), and anything
else is a false positive. As in the COCO evaluation, the overlap with a crowd region is measured against the
detection's area and not as IoU, because a detection can lie inside a large crowd box and still have a tiny IoU with it.
sv.box_iou_batch_with_jaccard measures both kinds of overlap. This is the same idea as the COCO evaluation,
written out in a few lines so you can read it; it is not a replacement for
the official mAP computation.
TRUE_POSITIVE, FALSE_POSITIVE, IGNORED = 1, 0, -1
def label_detections(detections: sv.Detections, boxes: sv.Detections) -> np.ndarray:
"""Label each detection ``TRUE_POSITIVE``, ``FALSE_POSITIVE`` or ``IGNORED`` against one image's ground truth."""
labels = np.full(len(detections), FALSE_POSITIVE, dtype=np.int8)
if len(detections) == 0 or len(boxes) == 0:
return labels
crowd = is_crowd(boxes)
# COCO scores a crowd region by the share of the detection inside it, not by IoU (see the text above).
# supervision's COCO-style overlap does the same, and takes boxes as [x, y, width, height].
overlaps = sv.box_iou_batch_with_jaccard(
sv.xyxy_to_xywh(boxes.xyxy.astype(np.float64)), sv.xyxy_to_xywh(detections.xyxy.astype(np.float64)), crowd
)
detection_class = class_indexes(detections)
used = np.zeros(len(boxes), dtype=bool)
for d in np.argsort(-detections.confidence, kind="stable"):
candidates = np.flatnonzero((boxes.class_id == detection_class[d]) & (overlaps[d] >= MATCH_IOU))
# A crowd region can absorb any number of detections, so it never counts as used.
candidates = candidates[~used[candidates] | crowd[candidates]]
# As in the COCO evaluation, a free regular box is preferred to a crowd region, whatever their overlaps; a crowd
# region only ignores a detection that has no regular box left to match.
regular = candidates[~crowd[candidates]]
if regular.size:
best = regular[np.argmax(overlaps[d, regular])]
used[best] = True
labels[d] = TRUE_POSITIVE
elif candidates.size:
labels[d] = IGNORED
return labels
def detection_table(ids: list[int], detections: list[sv.Detections]) -> pd.DataFrame:
"""Return one row per detection: its image, class index, score and TP / FP label, with ignored rows removed."""
frames = [
pd.DataFrame(
{
"image_id": image_id,
"class_index": class_indexes(found),
"score": found.confidence,
"label": label_detections(found, truth[image_id]),
}
)
for image_id, found in zip(ids, detections)
]
table = pd.concat(frames, ignore_index=True)
# Ignored rows (on a crowd region) are neither right nor wrong. Rows without a class entry (class_index -1) could only
# be kept by a single threshold, never by a list, so they are left out of every option to keep the comparison fair.
return table[(table["label"] != IGNORED) & (table["class_index"] >= 0)].reset_index(drop=True)
tuning_table = detection_table(tuning_ids, tuning_detections)
test_table = detection_table(test_ids, test_detections)
print(tuning_table.groupby("label").size().rename({0: "false positives", 1: "true positives"}).to_string())
From here on, a threshold choice is a vector with one threshold per class, and a single threshold is the same number
repeated. The two helpers count what survives score > threshold and turn the counts into an F1 score, 2·TP / (2·TP + FP + FN), where FN (false negatives) are the objects not found.
def per_class_counts(table: pd.DataFrame, thresholds: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Count the true and false positives of every class that survive ``score > thresholds[class]``."""
scores = table["score"].to_numpy()
# predict() rounds each threshold to the score dtype, so do the same to keep exactly the detections it keeps.
kept = table[scores > thresholds.astype(scores.dtype)[table["class_index"].to_numpy()]]
true_positives = np.bincount(kept.loc[kept["label"] == TRUE_POSITIVE, "class_index"], minlength=num_classes)
false_positives = np.bincount(kept.loc[kept["label"] == FALSE_POSITIVE, "class_index"], minlength=num_classes)
return true_positives, false_positives
def per_class_f1(table: pd.DataFrame, objects: np.ndarray, thresholds: np.ndarray) -> np.ndarray:
"""Return the F1 score of every class at the given per-class thresholds."""
true_positives, false_positives = per_class_counts(table, thresholds)
denominator = 2 * true_positives + false_positives + (objects - true_positives)
return np.divide(2 * true_positives, denominator, out=np.zeros(num_classes), where=denominator > 0)
4 - Experiment 1: the same goal for every class¶
Every class is judged by the same measure, its F1 score, which rewards finding objects and punishes both misses and false alarms. Two choices come out of the tuning set:
- the best single threshold: the one value that maximises the F1 score averaged over the classes (the macro F1);
- one threshold per class: the value that maximises each class's own F1 score. A class with fewer than
MIN_TUNING_OBJECTSobjects in the tuning set, or one whose F1 is 0 at every threshold, takes the single threshold.
# Row r holds the F1 score of every class when all classes use THRESHOLD_GRID[r].
tuning_f1 = np.stack([per_class_f1(tuning_table, tuning_objects, np.full(num_classes, t)) for t in THRESHOLD_GRID])
single_threshold = float(THRESHOLD_GRID[np.argmax(tuning_f1[:, tuning_objects > 0].mean(axis=1))])
best_per_class = THRESHOLD_GRID[np.argmax(tuning_f1, axis=0)]
has_own_threshold = (tuning_objects >= MIN_TUNING_OBJECTS) & (tuning_f1.max(axis=0) > 0)
class_thresholds = np.where(has_own_threshold, best_per_class, single_threshold)
print(f"best single threshold: {single_threshold:.2f}")
print(
f"{has_own_threshold.sum()} classes have their own threshold; the other {(~has_own_threshold).sum()} "
f"use {single_threshold:.2f}"
)
For the three most frequent classes of the tuning set, the histograms show the scores of the true and of the false positives. The dashed line is the threshold picked for the class and the dotted line is the best single threshold. Where the two histograms overlap, no threshold separates them cleanly.
most_frequent = np.argsort(-tuning_objects)[:3]
figure, axes = plt.subplots(1, 3, figsize=(13, 3.2))
bins = np.linspace(LOW_THRESHOLD, 1.0, 40)
for axis, class_index in zip(axes, most_frequent):
rows = tuning_table[tuning_table["class_index"] == class_index]
axis.hist(rows.loc[rows["label"] == TRUE_POSITIVE, "score"], bins=bins, alpha=0.6, label="true positives")
axis.hist(rows.loc[rows["label"] == FALSE_POSITIVE, "score"], bins=bins, alpha=0.6, label="false positives")
axis.axvline(class_thresholds[class_index], color="black", linestyle="--", label="class threshold")
axis.axvline(single_threshold, color="gray", linestyle=":", label="best single threshold")
axis.set_title(f"{class_names[class_index]} ({tuning_objects[class_index]} objects)")
axis.set_xlabel("score")
axes[0].set_ylabel("detections")
axes[0].legend(fontsize=8)
figure.tight_layout()
plt.show()
Now the test set, which played no part in choosing any threshold. Each option is judged by the macro F1 (the F1 score averaged over the classes that have an object in the test set) and by the precision and the recall over all detections together. Detections that fall on a crowd region are not counted.
def score(table: pd.DataFrame, objects: np.ndarray, thresholds: np.ndarray) -> dict[str, float]:
"""Return the macro F1, precision, recall and detection count of a threshold choice on one set of detections."""
true_positives, false_positives = per_class_counts(table, thresholds)
kept = true_positives.sum() + false_positives.sum()
return {
"macro F1": per_class_f1(table, objects, thresholds)[objects > 0].mean(),
"precision": true_positives.sum() / max(kept, 1),
"recall": true_positives.sum() / objects.sum(),
"detections": kept,
}
options = {
"default threshold 0.5": np.full(num_classes, 0.5),
f"best single threshold {single_threshold:.2f}": np.full(num_classes, single_threshold),
"one F1-optimal threshold per class": class_thresholds,
}
results = pd.DataFrame({name: score(test_table, test_objects, thresholds) for name, thresholds in options.items()}).T
results["detections"] = results["detections"].astype(int)
results.round(3)
Reading the table. In the run recorded for this notebook (SEED = 0, RFDETRSmall, 1000 tuning and 1000 test
images, one NVIDIA RTX 5070, rfdetr 1.12.0.dev at commit 50d14392; the notebook is stored without outputs, like
the other cookbooks) the table reads:
| Test set | macro F1 | precision | recall |
|---|---|---|---|
| default threshold 0.5 | 0.658 | 0.882 | 0.553 |
| best single threshold 0.41 | 0.682 | 0.820 | 0.618 |
| one F1-optimal threshold per class | 0.678 | 0.774 | 0.641 |
The best single threshold lifts the macro F1 over the default 0.5. One threshold per class does not improve on it. A
tuned threshold helps, a tuned threshold per class does not add to it. This suggests that the
scores of this checkpoint are already on a similar scale across the COCO classes, so the best cut-off of a class lies near
the shared one, and a cut-off picked from a tuning set of this size also carries noise of its own. Change SEED to see
how the numbers move. Another machine can give slightly different numbers, so compare the order of the rows rather than
the last digit.
5 - Experiment 2: a different goal for one class¶
Now the classes are not equal. Suppose a missed PRIORITY_CLASS is costly, so its recall must reach TARGET_RECALL,
while every other class is still served well by the best single threshold. The tuning set gives the highest threshold at
which the priority class reaches that recall. Two ways to use it are compared on the test set:
- lowered for every class: the one threshold you are left with when
predict()only takes a single number; - lowered for the priority class only: the best single threshold for every other class, and the lowered one for the priority class, which needs a list.
The table reports the priority class and, separately, everything else.
priority = class_index_by_name[PRIORITY_CLASS]
recall_curve = (
np.array([per_class_counts(tuning_table, np.full(num_classes, t))[0][priority] for t in THRESHOLD_GRID])
/ tuning_objects[priority]
)
reaching = np.flatnonzero(recall_curve >= TARGET_RECALL)
assert reaching.size, f"{PRIORITY_CLASS} never reaches a recall of {TARGET_RECALL} on the tuning set"
priority_threshold = float(THRESHOLD_GRID[reaching[-1]])
print(
f"highest threshold with a {PRIORITY_CLASS} recall of at least {TARGET_RECALL} on the tuning set: {priority_threshold:.2f}"
)
lowered_for_all = np.full(num_classes, priority_threshold)
lowered_for_priority = np.full(num_classes, single_threshold)
lowered_for_priority[priority] = priority_threshold
def priority_report(thresholds: np.ndarray) -> dict[str, float]:
"""Return the priority class's and the other classes' recall, precision and false alarms on the test set."""
true_positives, false_positives = per_class_counts(test_table, thresholds)
others = np.arange(num_classes) != priority
return {
f"{PRIORITY_CLASS} recall": true_positives[priority] / test_objects[priority],
f"{PRIORITY_CLASS} precision": true_positives[priority]
/ max(true_positives[priority] + false_positives[priority], 1),
"other classes recall": true_positives[others].sum() / test_objects[others].sum(),
"other classes precision": true_positives[others].sum()
/ max((true_positives + false_positives)[others].sum(), 1),
"false alarms in other classes": int(false_positives[others].sum()),
}
priority_options = {
f"best single threshold {single_threshold:.2f}": np.full(num_classes, single_threshold),
f"{priority_threshold:.2f} for every class": lowered_for_all,
f"{priority_threshold:.2f} for {PRIORITY_CLASS} only": lowered_for_priority,
}
priority_results = pd.DataFrame({name: priority_report(thresholds) for name, thresholds in priority_options.items()}).T
priority_results.astype({"false alarms in other classes": int}).round(3)
Reading the table. In the same recorded run:
| Test set | person recall | person precision | other classes' recall | other classes' precision | false alarms in other classes |
|---|---|---|---|---|---|
| best single threshold 0.41 | 0.727 | 0.908 | 0.572 | 0.780 | 863 |
| 0.25 for every class | 0.843 | 0.738 | 0.710 | 0.530 | 3363 |
| 0.25 for person only | 0.843 | 0.738 | 0.572 | 0.780 | 863 |
Lowering the threshold for every class raises the recall of person from 0.727 to 0.843, and the other classes pay for
it: their precision falls from 0.780 to 0.530 and they produce 3363 false alarms instead of 863. Lowering it for person
alone gives the same person recall and leaves every other class exactly as it was. Only a list can ask for that.
6 - Pass the thresholds to predict()¶
Build the list in the order of model.class_names, wrap it in ClassThresholds and pass it to predict(). The cell checks, for both threshold lists
of this notebook, that predict() returns exactly the detections that filtering the low-threshold run by class gives,
which is what you had to do by hand before.
def filter_by_class(low: sv.Detections, thresholds: np.ndarray) -> sv.Detections:
"""Keep the rows of a low-threshold run that score above their own class's threshold, the way a list does."""
indexes = class_indexes(low)
# predict() compares each score with its threshold rounded to the score dtype, so do the same here.
return low[(indexes >= 0) & (low.confidence > thresholds.astype(low.confidence.dtype)[indexes])]
for name, thresholds in {
"F1-optimal per class": class_thresholds,
f"{PRIORITY_CLASS} only": lowered_for_priority,
}.items():
direct = predict_in_batches(test_ids, ClassThresholds(thresholds))
identical = all(
np.array_equal(found.confidence, expected.confidence)
and np.array_equal(found.xyxy, expected.xyxy)
and found.data["class_name"].tolist() == expected.data["class_name"].tolist()
for found, expected in zip(direct, (filter_by_class(low, thresholds) for low in test_detections))
)
print(
f"{name}: {sum(map(len, direct))} detections; identical to filtering the low-threshold run by class: {identical}"
)
A list of the wrong length is refused before the model runs, so a thresholds list that has fallen out of step with the classes (for example after retraining on a different class list) fails loudly instead of shifting every threshold by one class:
try:
predictor.predict(paths[test_ids[0]], threshold=ClassThresholds(lowered_for_priority.tolist()[:-1]))
except ValueError as error:
print(error)
Next steps¶
The detection guide has the short version of section 6. Keep these points in mind when you apply it:
- Tune on one set, judge on another. Thresholds picked and measured on the same images always look good. Keep a test set that played no part in choosing them, as above.
- The goal decides whether a list helps. A single threshold is hard to beat when every class has the same goal and the model scores the classes alike, as experiment 1 shows for this checkpoint. A list earns its place when the goals differ, as in experiment 2: a recall target for one class, a precision floor for another, a costly miss here and a cheap one there.
- Rare classes. A class with few examples gives a noisy curve. Keep such classes on the single threshold, as
MIN_TUNING_OBJECTSdoes, and do not read a per-class threshold off a handful of detections. - A threshold list belongs to one checkpoint and one class list. The numbers describe how that model scores that dataset. Retrain, change the class list or switch the model size, and tune again.
- Your own data. Replace section 1 with your images and ground-truth boxes, load a fine-tuned checkpoint with
RFDETRSmall(pretrain_weights=...)and keepmodel.class_namesas the order of the thresholds. Whether experiment 1 shows a gain for your model is exactly what the held-out test set is there to tell you.