Skip to content

Retrain a classifier head from a training set, and take the result back - #1503

Open
mohamedelabbas1996 wants to merge 13 commits into
feat/retrain-training-setfrom
feat/retrain-training-job
Open

mohamedelabbas1996 wants to merge 13 commits into
feat/retrain-training-setfrom
feat/retrain-training-job

Conversation

@mohamedelabbas1996

@mohamedelabbas1996 mohamedelabbas1996 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

The job that retrains a head: it builds the training set, hands a processing service the URL, and records what comes back as a new algorithm version.

Split out of #1494. The service side is ami-data-companion#167.

What a run does

  1. builds the dataset (the PR below this one) and writes it to storage
  2. posts the URL to the service and leaves the job STARTED
  3. the service trains, uploads the head, reports its epochs, then reports the result
  4. the result is registered as a new algorithm version recording what it learned from

The occurrence set is optional. Without one a run learns from every verified occurrence in the project, which is the ordinary case; naming a set makes the run repeatable, and either way the dataset file records which it was.

The interface

A retraining run reports back the way an ML job's results already do: a POST to a route on the job, typed with a schema and a serializer, acknowledged with a typed response. TrainingRequest and TrainingResult mirror the service's own TrainRequest and TrainResponse, as PipelineRequest and PipelineResultsResponse already do for processing. The job is marked ASYNC_API, which is what it has always been.

One way in. /train returns 202; the result arrives only at the callback and is parsed once. Antenna previously also read a result from the /train response body, which gave one outcome two shapes - and both of the bugs this fixes lived in that gap:

  • on the callback path the echoed dataset metadata was looked for under a key it is not at, so it was never parsed and a registered version never recorded which occurrence set it came from
  • on the inline path the same code would have raised AttributeError; it never fired only because the duplicate guard returned first

The result is validated because the new version is built from it. The echoed metadata is read leniently: a service echoing an older shape should still have its result recorded, and the cost is provenance, not a lost head.

Three things here have no precedent in the platform:

  1. The callback token. Antenna hands the service a URL and a token signed for that one job, valid 24 hours. The existing async path does it the other way round: the ADC worker holds an Antenna API token and posts as itself. I kept the signed token because it is the tighter of the two, and because POST /jobs/{id}/result/ resolves to a result_ml_job permission that does not exist, so that endpoint effectively needs a superuser. Happy to switch if you would rather have one mechanism. That permission looks unintended and may deserve its own fix.
  2. The head upload. No endpoint existed for a service to send a file back.
  3. The progress ping. Processing infers progress from results arriving; one long fit has nothing to infer from, so the service reports its epoch and the stage follows it.

Progress while it trains

A training job at 52 per cent, with the training stage started

The training stage showing epoch 4,950 of 5,000

The total is written at dispatch from the run's own settings, so the stage reads "0 of 5000" while the service is still starting. A ping never finishes the stage - the result does, since the head still has to be scored and uploaded. Late or out-of-order pings are dropped. Reporting is optional: a service that posts nothing behaves as before.

A run, end to end

Against the BioCLIP 2.5 service on the GPU VM, over a project's real verified crops:

The training job finished, with its three stages green

The job's logs in full

Training set: 96 verified crops over 17 species (74 train / 22 test)
Species list comes from taxa list 'NF retraining list (demo)'
7 species in the list have no verified crops yet
Registered #67 "BioCLIP 2.5 + LogReg head (Newfoundland, 749 species)" v22 as version 22
New head top-1 0.636 does not beat current 0.875 by more than 0.000.

The last line is the point of keeping the score: a retrain that is worse than what it would replace says so rather than quietly taking over.

Stack

#1462 embeddings  ->  #1501 plumbing  ->  #1502 training set  ->  this  ->  job form

Update (2026-10-30)

Tested end to end on a dev instance against a copy of production data (Atlantic Forestry Centre, BioCLIP 2.5 Newfoundland head): retrain from the job -> service fits a head -> new version recorded with its scores -> its pipeline appears in the project -> a processing job classifies captures with the retrained head.

Change Why
TrainClassifierJobConfig (ami/ml/training/config.py) is the job type's config_schema algorithm_key, taxa_list_id and the data sources (use_project_occurrences, occurrence_set_id, use_offline_inat) are checked when the job is created. Offline iNat data is declared but refused until the service can train from it. Fitting overrides pass through and are range-checked by AlgorithmTrainingConfig, which keeps owning their defaults.
The new version is registered under the key the service serves the head under (pipeline_slug in the result), at version 1 Pipelines find algorithms by key and version, so registering the service's pipeline reuses this row instead of creating an unrelated algorithm. The lineage ("retrained v2") moves into the name.
The service's pipelines are registered again after the callback, in register_retrained_head_pipeline So the retrained head can be run straight away. It runs after the callback has answered: calling /info inside the callback deadlocked, because the service is waiting on that answer.
Tests API refusals for the config; key and version of a served head; the pipeline task is queued on commit and registers for the job's project.

Needs RolnickLab/ami-data-companion#167 at 2f6ce7c or later (reports pipeline_slug, lists new heads in /info).

What each retrain records (folded in from #1570, 2026-10-30)

Summary

A retrain now leaves a record, per occurrence, of what it did with it: whether the occurrence was trained on or held out for testing, whether it was left out and why, and the species it carried at the time. It also saves the exact occurrences it trained and tested on as two occurrence sets, so a run can be repeated and its test set reused to score other models. Stacked on #1504.

List of Changes

User effect Implementation
Each occurrence a retrain looked at gets an algorithm result: split (train / test), used for training, rejected + reason (not_in_taxa_list, below_min_per_species, no_embedding), and its determination at training time New result kind classifier_training (ClassifierTrainingResultData, a DeterminationSnapshot), written in bulk when the training set is built (ami/ml/training/results.py)
Job types can write algorithm results, not only post-processing tasks JobType.result_models; result_data_models() reads both registries and keeps the "two models for one kind" check
"Job #N train set" and "Job #N test set" occurrence sets are saved with every run, and named on the new algorithm version save_split_sets() (the only place that writes sets, to keep the move to #1543 small); AlgorithmTrainingInfo.train_/test_occurrence_set_id/_name
Per-species training accuracy can be shown when the service reports it Optional species_train_accuracy on TrainingResult, copied onto each row's value at the callback; the service does not report it yet, so values stay empty

Detailed Description

Rows are written when the training set is built, not when the result returns: the label at training time and the reject reasons are only known then, and rebuilding them later would mislabel anything re-identified while training ran. The cost is that a run that never reports back leaves rows with no accuracy. Rows point at the algorithm that was retrained; the new version links to them through training_info.job_id and the set ids. No UI yet: the occurrence history skips kinds it does not know.

How to Test

python manage.py test ami.jobs ami.ml (402 tests OK locally, makemigrations --check clean). ami/ml/test_training_results.py covers the new kind and the sets.

@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0c41b302-5eec-46f4-a7ca-e8065a0419f0

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
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@netlify

netlify Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for antenna-preview canceled.

Name Link
🔨 Latest commit 4a03bb0
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/6ae42929ba6a662940819c8c

mohamedelabbas1996 and others added 12 commits October 30, 2026 01:10
Adds the training job: it builds the dataset, hands a processing service the URL,
and records what comes back as a new algorithm version.

Training outlasts the request that starts it, so the service reports its result to
a callback instead of the connection being held open. The service has no Antenna
account, so the callbacks are authorised by a signed token issued when the job was
dispatched, and the job is looked up directly because project visibility would
refuse an unauthenticated caller. The weights are uploaded the same way: without
that, the head exists only in a cache directory on the service's disk and Antenna
records a version it cannot point at. An upload is refused once the job has
finished, since the token lasts a day and the path is fixed by the job, so
otherwise the weights behind a registered version could be swapped out after the
run ended.

A job type now also declares what it needs before it can start and whether a user
may create it at all, rather than each job discovering a missing param partway
through its own run.
The refactor that gathered the training modules into one package left two imports
behind, so running a training job raised ImportError on ami.ml.training_dataset
instead of building a dataset. The tests did not catch it because neither import
is reached until a job actually runs.
Requiring a set meant that retraining on everything verified so far - the ordinary
case - first made someone save a set of everything, which is a step with no
decision in it.

A job without a set now learns from every verified occurrence in the project. The
dataset file records which set was used, or that there was none, so a run is still
legible afterwards either way. Naming a set is still worth doing when it matters:
a set is fixed once created, so that run can be repeated.
A key is shared by every version of an algorithm, while embeddings are stored
against one version row. Taking whichever row the database returned first could
pick an older version and train on an empty set of vectors, or on vectors from a
different fitting, with nothing in the job to say which had happened.
Training is the long stage of the job and the service says nothing while it runs,
so the bar sat at nought for as long as the fitting took and a person could not
tell a slow run from a dead one.

The service is now told where to report, and posts the epoch it has reached. The
callback is authorised by the same signed token as the result and the head upload,
since a service has no Antenna account. The epoch and the total land as stage
parameters, and the stage progress is the ratio of the two.

The total is written at dispatch from the run's own settings, so the stage reads
"0 of 5000" while the service is still starting rather than an empty pair. A ping
never finishes the stage: the result does, because the service still has to score
the head and upload it after the last epoch. Pings that arrive late or out of order
are dropped rather than dragging a run backwards.

Reporting is optional. A service that does not post anything behaves exactly as
before, and a ping that fails to send must not fail the run.
The training request was a dict literal and the result was read with .get() at a
dozen call sites, so neither side of the exchange was written down anywhere. Every
other service contract in this module is a schema; this one was the exception, and
a service renaming a field would have produced an empty metrics dict and no error.

TrainingRequest and TrainingResult mirror the service's own TrainRequest and
TrainResponse, the way PipelineRequest and PipelineResultsResponse already do for
processing. TrainingResult keeps unknown fields: a newer service may report more
than this one knows about, and the result is the only record of what a run did.

incumbent_metrics is nullable because a service reports null when there was no
current head to score, which is exactly what a first retrain does.
… to read

A result could arrive two ways: in the body of the /train response, or at the
job's callback. The two carried the same outcome in different shapes, and both of
these bugs lived in that gap.

On the callback path the dataset metadata the service echoes was read as
payload["dataset"]["metadata"], which is not where it is, so it was never parsed
and a registered version never recorded which occurrence set it learned from. The
provenance this is all for was quietly dropped on every real run.

On the inline path the same code would have raised AttributeError, because there
"dataset" is the schema object rather than a dict. It never fired only because a
service posts its callback first and the duplicate guard returned early.

/train is now an acknowledgement: the result arrives at the callback, is validated
by a serializer at the view, and record_result takes the parsed result rather than
a raw payload. One shape, one path, parsed once at the boundary.

The result is validated because the new algorithm version is built from it. The
echoed dataset metadata is read leniently: a service echoing an older shape should
still have its result recorded, and the cost is a version that cannot say which
set it came from, not a head that is lost.

Also fixes a flaky test: with a handful of rows, a hash split does not give a
particular ratio a particular count, so the ratio is now compared across two
values rather than asserted exactly.
Every training job read as "internal", the mode for work the platform does
itself, because Job.setup() decides the mode from the pipeline and a training job
has none. It is the platform's own word for what this job has always done: hand
the work to a service and wait to be called back.
TrainClassifierJobConfig names the head (algorithm_key), the species list
(taxa_list_id) and the data the head learns from: this project's verified
occurrences, optionally one set, and offline iNaturalist data, which is
declared but refused until the processing service can train from it.
A retrain with no data source, or a set with project occurrences off, is a
400 when the job is created. Fitting overrides such as test_fraction pass
through and are range-checked by AlgorithmTrainingConfig, which keeps
owning their defaults. The job reads its settings through the schema
instead of raw params lookups.

Co-Authored-By: Claude <noreply@anthropic.com>
…er its pipeline

When the service reports the pipeline that serves the new head, the
version is registered under that key at version 1, which is how the
service reports it in /info, and the service's pipelines are registered
again for the project. The retrained head then appears as a pipeline that
can process captures, instead of an algorithm no pipeline uses. The
lineage ("retrained v2") moves into the name. Services that do not report
a pipeline keep the previous key scheme.

Co-Authored-By: Claude <noreply@anthropic.com>
Registering the service's pipelines inside the training callback asked the
service for /info while it was still waiting on that same callback, and the
request timed out, so the new head was recorded but never offered. The
registration now runs in a task queued on commit, retried while the
service is busy.

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

- use_offline_inat is no longer refused. With project occurrences on, the
  service adds its offline vectors to Antenna's training set; with them
  off, no data leaves Antenna and the service gets the species list
  (classes) instead of a dataset URL. TrainingRequest gains
  use_offline_inat and classes, and dataset_url becomes optional.
- Fix: a retrain that named a taxa list crashed, because TaxaList has no
  for_project(); the list is now scoped through its projects.
- A retrained version copies its parent's embedding_dimensions: same
  frozen backbone, and without it the vectors its pipeline sends are
  refused.

Co-Authored-By: Claude <noreply@anthropic.com>
When a retrain builds its training set, it now writes down every verified
occurrence it considered:

- a classifier_training algorithm result per occurrence: its split, whether it
  was trained on, or why it was left out (not in the taxa list, too few crops
  for its species, no embedding), and the label it was given;
- the train and test splits as two occurrence sets named after the job, kept
  in the job's result and on the registered version's training_info, so a run
  can be repeated and its test set reused for evaluation.

The service may report per-species train accuracy (optional
species_train_accuracy on the training result); it becomes each result's
value. No service sends it yet, so the value stays empty for now.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

occurrence-sets Saved lists of occurrences: create, filter, export, and score or train against them

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants