Skip to content

Let admins roll up low-confidence predictions to a coarser rank#1361

Draft
mihow wants to merge 6 commits into
mainfrom
rework/rank-rollup-on-framework
Draft

Let admins roll up low-confidence predictions to a coarser rank#1361
mihow wants to merge 6 commits into
mainfrom
rework/rank-rollup-on-framework

Conversation

@mihow

@mihow mihow commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

This adds rank roll-up, a post-processing task an admin can run to handle detections the classifier could not confidently place at species level. For each detection it walks the classification distribution from the finest rank upward and promotes the prediction to the first rank whose summed probability clears that rank's threshold, writing a new terminal classification at that rank and linking it back to the source via applied_to. An admin triggers it on a capture set from the Django admin and can set per-rank thresholds.

It runs through the post-processing framework that merged in #1289, the same way as the small-size filter and class masking.

This was split out of the class-masking PR (#999) so each can carry its own review. It is stacked on #999 — the diff is against that branch and shows only the roll-up additions; once #999 merges this rebases onto main. Opening as a draft until the design question below is settled.

Open design question (needs a decision before merge)

The per-rank winner is currently the global argmax across the whole distribution, not constrained to ancestors of the source classification's taxon. On a confident classification the winner is its own lineage, but a diffuse, low-confidence distribution can spread enough mass across unrelated branches that the top taxon at a rank is not an ancestor of the source — so the roll-up reparents the detection to a family outside its own lineage. The choice, documented at the candidate-pick loop in rank_rollup.py:

  • Lineage-constrained: restrict candidates at each rank to ancestors of the source taxon, so a roll-up only ever generalises the original prediction; or
  • Distribution roll-up: keep the global argmax but document it, and reconsider whether applied_to pointing at the single source classification is the right provenance when the result is off-lineage.

A follow-up worth pairing with this: #1360 (offline eval of the post-processing filters) would quantify how each option moves final predictions against ground truth.

List of Changes

# Change (what it does) How
1 Admins can roll low-confidence predictions up to genus/family when the aggregate score at a rank clears a threshold RankRollupTask(BasePostProcessingTask) + make_post_processing_action wiring on SourceImageCollectionAdmin, with per-rank thresholds on the action form
2 The rolled-up classification records where it came from new terminal classification at the coarser rank, linked to the source via applied_to; the source is demoted to non-terminal

Detailed Description

  • No migration. applied_to already exists on Classification, and the task runs under the existing post_processing job type, so the registry addition does not touch any field definition.
  • Query shape. Category-map labels are preloaded in two queries and the per-row relations are select_related, rather than dereferencing the category map per classification.

How to test

  1. Django admin → Capture Sets (SourceImageCollection) → select one with species-level classifications → choose the Rank Rollup action → set per-rank thresholds → run.
  2. A background Job is created. When it finishes, detections whose species-level score fell below the threshold but whose genus aggregate cleared it carry a new terminal classification at genus, linked back to the source via applied_to.

Related

mihow and others added 6 commits June 29, 2026 19:15
…dmin framework

Rework of #999 onto the #1289 post-processing framework. The branch was cut from
an old main with its own hand-rolled admin action; ClassMaskingTask and
RankRollupTask now subclass BasePostProcessingTask with pydantic config schemas
and are triggered through make_post_processing_action (collection scope on
SourceImageCollection, single-occurrence scope on Occurrence for class masking).

Correctness fixes from the review threads:
- Class masking selects the top class from an -inf-masked softmax, so a class
  excluded by the taxa list can never win even when it had the highest logit;
  raises when the taxa list excludes every class in the category map. Stored
  logits stay raw (JSON-safe) and the mask is captured in scores (excluded -> 0).
- The masked-output Algorithm is one per (source algorithm, taxa list) and its
  category map is persisted (previously set in memory only, so masked
  classifications referenced a null map).
- applied_to is populated on new masked classifications (the provenance the API
  exposes was left blank).
- Rank rollup preloads category-map labels in two queries and select_relates the
  per-row relations instead of dereferencing category_map per classification.

Surfaces provenance in the API: applied_to is added to the Classification
serializers, and applied_to__algorithm is prefetched in the occurrence
list/detail prefetch and the classification viewset to avoid an N+1 on render.

Tests: pydantic config validation, admin trigger for both scopes, the masking
maths (including the excluded-class guarantee and the all-excluded error),
ClassMaskingTask.run() end to end for both scopes, and rank rollup. 20 new tests;
full post_processing suite and occurrence query-count tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
The roll-up picks the global argmax over every taxon at each rank, not just
ancestors of the source classification's taxon. On a diffuse, low-confidence
distribution this can reparent a detection to a family outside its own
lineage. Document the behavior at the candidate-pick loop and record the open
design choice (lineage-constrained vs distribution roll-up) as a TODO; no
behavior change.

Co-Authored-By: Claude <noreply@anthropic.com>
…ection

When an operator triggers class masking on an occurrence (or collection), the
"Source classifier" dropdown now lists only the classification algorithms that
actually produced classifications within the selected scope. Masking any other
algorithm would be a no-op for those rows, so offering every classifier was
misleading.

The admin action hands the knob form the selected queryset (a small generic
seam on the form base, ignored by forms that don't need it), and
ClassMaskingActionForm uses it to filter the algorithm field by
classifications__detection__occurrence / source_image__collections.

Co-Authored-By: Claude <noreply@anthropic.com>
…llections

The class-mask admin form narrows its "Source classifier" dropdown to the
algorithms that actually produced classifications in the selected scope, so an
operator cannot pick a classifier whose masking would be a no-op. On the
occurrence path that lookup is cheap (a handful of classifications), but on the
collection path it became an unbounded DISTINCT over every classification in the
collection. On a large collection that join runs for tens of seconds or times
out while the intermediate form is still rendering, before the operator can do
anything.

Scope the dropdown only for the occurrence path. A collection scope now keeps
the full classifier list. Offering a classifier that produced nothing in the
collection is harmless — masking it changes no rows — so the narrowing was a
convenience, not a correctness guard, and is not worth a query that can hang the
page.

Co-Authored-By: Claude <noreply@anthropic.com>
Rank roll-up moves out of this PR so class masking can merge on its own. Class
masking is validated end to end against a real classifier and is well covered by
tests; rank roll-up still has an open design question (whether the per-rank pick
should be constrained to the source taxon's ancestors or remain a global
distribution roll-up) and thinner tests. Keeping them together would force
reviewers to either block ready class-masking work or sign off on the
less-settled roll-up.

This removes the rank-roll-up task, its admin form and action, its registry
entry, and its tests. The roll-up feature returns in its own PR, stacked on this
one, where the lineage decision and fuller tests can land before it merges.

Co-Authored-By: Claude <noreply@anthropic.com>
Add the rank roll-up post-processing task, split out of the class-masking PR so
it can carry its own design discussion and tests. For each detection it walks
the classification distribution from the finest rank upward and promotes the
prediction to the first rank whose summed probability clears that rank's
threshold, writing a new terminal classification at that rank and linking it
back to the source via applied_to. An admin can trigger it on a capture set from
the Django admin, choosing per-rank thresholds.

Open design question, documented at the candidate-pick loop in rank_rollup.py:
the per-rank winner is currently the global argmax across the whole
distribution, not only ancestors of the source taxon, so a diffuse low-
confidence distribution can reparent a detection to a family outside its own
lineage. Whether to constrain candidates to the source taxon's ancestors
(generalise only) or keep the global distribution roll-up (and revisit the
applied_to provenance when the result is off-lineage) is left for review.

Tests cover the config schema contract, the admin trigger, and a species->genus
roll-up end to end.

Co-Authored-By: Claude <noreply@anthropic.com>
@netlify

netlify Bot commented Jun 30, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-preview canceled.

Name Link
🔨 Latest commit 97a7e92
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/6a441acc9d822b0008760828

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7c10bc66-3d57-41e7-ac97-110aa0480ba8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rework/rank-rollup-on-framework

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from feat/postprocessing-class-masking to main July 3, 2026 00:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant