From 5790b8b68cff2f701b148b090e23dfc58a3f9709 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 17 Sep 2026 14:31:27 -0400 Subject: [PATCH 1/3] Show how each model performs, and let people start the jobs behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform can now score a model against a fixed set of verified occurrences and retrain a classifier head, but none of it was visible: the numbers existed only in the API, and the jobs could only be started by posting to it by hand. The species table gains the verified crops ready to train on, and a species page lists every model that has been scored on it, with the accuracy for that species and the set it was scored on. A taxa list names the model that handles its species best, ranked on the per-species average, and an algorithm's panel shows what it has scored and the size of the training set waiting for it. Every one of these reads "n/a" until something has actually been evaluated, so the columns are honest on a project where nothing has run yet. The jobs form asks which kind of job to create and then asks only for what that kind needs — a pipeline for a processing run, a head to retrain, a head and an evaluation set to score. Training, embedding and evaluation jobs also show their type in the jobs table and can be filtered by it, which they could not before. The API fields these read land separately; until then the new columns read "n/a" rather than breaking. See #1407. Co-Authored-By: Claude --- ui/src/data-services/constants.ts | 2 + .../hooks/algorithm/useTrainingDataSummary.ts | 49 ++++ .../data-services/hooks/jobs/useCreateJob.ts | 20 ++ ui/src/data-services/models/algorithm.ts | 35 +++ ui/src/data-services/models/job.ts | 6 + .../data-services/models/species-details.ts | 32 +++ ui/src/data-services/models/species.ts | 4 + ui/src/data-services/models/taxa-list.ts | 31 +++ ui/src/data-services/types.ts | 1 + ui/src/data-services/utils.ts | 3 + .../algorithm-details-dialog.tsx | 16 ++ .../algorithm-evaluations.tsx | 71 +++++ .../algorithm-training-data.tsx | 35 +++ .../job-details-form/job-details-form.tsx | 243 ++++++++++++++---- .../trainable-algorithm-picker.tsx | 40 +++ .../pages/species-details/species-details.tsx | 38 ++- ui/src/pages/species/species-columns.tsx | 12 + ui/src/pages/species/species.tsx | 2 + ui/src/pages/taxa-lists/taxa-list-columns.tsx | 37 ++- ui/src/utils/language.ts | 27 ++ 20 files changed, 645 insertions(+), 59 deletions(-) create mode 100644 ui/src/data-services/hooks/algorithm/useTrainingDataSummary.ts create mode 100644 ui/src/pages/algorithm-details/algorithm-evaluations.tsx create mode 100644 ui/src/pages/algorithm-details/algorithm-training-data.tsx create mode 100644 ui/src/pages/job-details/job-details-form/trainable-algorithm-picker.tsx diff --git a/ui/src/data-services/constants.ts b/ui/src/data-services/constants.ts index 423a931b0..ac53353eb 100644 --- a/ui/src/data-services/constants.ts +++ b/ui/src/data-services/constants.ts @@ -16,6 +16,7 @@ export const API_ROUTES = { ME: 'users/me', MEMBERS: (projectId: string) => `projects/${projectId}/members`, OCCURRENCE_ALGORITHMS: 'occurrences/algorithms', + OCCURRENCE_SETS: 'ml/occurrence-sets', OCCURRENCES: 'occurrences', PAGES: 'pages', PIPELINES: 'ml/pipelines', @@ -31,6 +32,7 @@ export const API_ROUTES = { SUMMARY: 'status/summary', TAGS: 'tags', TAXA_LISTS: 'taxa/lists', + TRAINING_DATA: 'ml/training-data', USERS: 'users', } diff --git a/ui/src/data-services/hooks/algorithm/useTrainingDataSummary.ts b/ui/src/data-services/hooks/algorithm/useTrainingDataSummary.ts new file mode 100644 index 000000000..fdb594c0e --- /dev/null +++ b/ui/src/data-services/hooks/algorithm/useTrainingDataSummary.ts @@ -0,0 +1,49 @@ +import { API_ROUTES, API_URL } from 'data-services/constants' +import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' + +export interface ServerTrainingDataSummary { + rows: number + classes: number + train: number + test: number + verified_detections_without_embedding: number +} + +export interface TrainingDataSummary { + cropsReady: number + species: number + cropsWithoutEmbedding: number +} + +const convertServerRecord = ( + record: ServerTrainingDataSummary +): TrainingDataSummary => ({ + cropsReady: record.rows, + species: record.classes, + cropsWithoutEmbedding: record.verified_detections_without_embedding, +}) + +export const useTrainingDataSummary = ( + projectId?: string, + algorithmKey?: string +): { + summary?: TrainingDataSummary + isLoading: boolean + error?: unknown +} => { + const params = { project_id: projectId ?? '', algorithm: algorithmKey ?? '' } + const queryString = new URLSearchParams(params).toString() + + const { data, isLoading, error } = + useAuthorizedQuery({ + queryKey: [API_ROUTES.TRAINING_DATA, params], + url: `${API_URL}/${API_ROUTES.TRAINING_DATA}/summary/?${queryString}`, + enabled: !!projectId && !!algorithmKey, + }) + + return { + summary: data ? convertServerRecord(data) : undefined, + isLoading, + error, + } +} diff --git a/ui/src/data-services/hooks/jobs/useCreateJob.ts b/ui/src/data-services/hooks/jobs/useCreateJob.ts index 4d128a9cd..c394e4bd3 100644 --- a/ui/src/data-services/hooks/jobs/useCreateJob.ts +++ b/ui/src/data-services/hooks/jobs/useCreateJob.ts @@ -1,12 +1,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import axios from 'axios' import { API_ROUTES, API_URL, SUCCESS_TIMEOUT } from 'data-services/constants' +import { ServerJobType } from 'data-services/models/job' import { getAuthHeader } from 'data-services/utils' import { useUser } from 'utils/user/userContext' interface JobFieldValues { + algorithm?: string delay?: number + jobType?: ServerJobType name: string + occurrenceSet?: string projectId: string pipeline?: string sourceImage?: string @@ -14,13 +18,29 @@ interface JobFieldValues { startNow?: boolean } +// Each job type reads what it needs out of the free-form params the API stores. +const buildParams = (fieldValues: JobFieldValues) => { + const params: { algorithm_key?: string; occurrence_set_id?: string } = {} + + if (fieldValues.algorithm) { + params.algorithm_key = fieldValues.algorithm + } + if (fieldValues.occurrenceSet) { + params.occurrence_set_id = fieldValues.occurrenceSet + } + + return Object.keys(params).length ? params : undefined +} + const convertToServerFieldValues = (fieldValues: JobFieldValues) => ({ delay: fieldValues.delay ?? 0, name: fieldValues.name, project_id: fieldValues.projectId, + job_type_key: fieldValues.jobType, pipeline_id: fieldValues.pipeline, source_image_collection_id: fieldValues.sourceImages, source_image_single_id: fieldValues.sourceImage, + params: buildParams(fieldValues), }) export const useCreateJob = (onSuccess?: (id: string) => void) => { diff --git a/ui/src/data-services/models/algorithm.ts b/ui/src/data-services/models/algorithm.ts index c36d507c4..f5dd58472 100644 --- a/ui/src/data-services/models/algorithm.ts +++ b/ui/src/data-services/models/algorithm.ts @@ -3,6 +3,25 @@ import { Entity } from './entity' export type ServerAlgorithm = any // TODO: Update this type +export interface ServerAlgorithmEvaluation { + id: number + occurrence_set: { id: number; name: string } + accuracy: number | null + accuracy_by_species: number | null + occurrences_scored: number + occurrences_skipped: number + species_scored: number + created_at: string +} + +export interface AlgorithmEvaluation { + id: string + occurrenceSetName: string + accuracy?: number + accuracyBySpecies?: number + occurrencesScored: number +} + export class Algorithm extends Entity { protected readonly _algorithm: ServerAlgorithm @@ -43,4 +62,20 @@ export class Algorithm extends Entity { ? this._algorithm.category_count : undefined } + + get trainable(): boolean { + return this._algorithm.trainable ?? false + } + + get evaluations(): AlgorithmEvaluation[] { + const rows: ServerAlgorithmEvaluation[] = this._algorithm.evaluations ?? [] + + return rows.map((row) => ({ + id: `${row.id}`, + occurrenceSetName: row.occurrence_set.name, + accuracy: row.accuracy ?? undefined, + accuracyBySpecies: row.accuracy_by_species ?? undefined, + occurrencesScored: row.occurrences_scored, + })) + } } diff --git a/ui/src/data-services/models/job.ts b/ui/src/data-services/models/job.ts index 15c59ea53..372393e77 100644 --- a/ui/src/data-services/models/job.ts +++ b/ui/src/data-services/models/job.ts @@ -21,6 +21,9 @@ export const SERVER_JOB_TYPES = [ 'data_storage_sync', 'populate_captures_collection', 'data_export', + 'train_classifier', + 'generate_embeddings', + 'evaluate_algorithm', 'unknown', ] as const @@ -164,6 +167,9 @@ export class Job extends Entity { data_storage_sync: 'Data storage sync', populate_captures_collection: 'Populate captures collection', data_export: 'Data export', + train_classifier: 'Train classifier', + generate_embeddings: 'Generate embeddings', + evaluate_algorithm: 'Evaluate algorithm', unknown: 'Unknown', }[key] diff --git a/ui/src/data-services/models/species-details.ts b/ui/src/data-services/models/species-details.ts index 8ade4b46d..d17c78b7e 100644 --- a/ui/src/data-services/models/species-details.ts +++ b/ui/src/data-services/models/species-details.ts @@ -3,11 +3,43 @@ import { ServerSpecies, Species } from './species' export type ServerSpeciesDetails = ServerSpecies & any // TODO: Update this type +export interface ServerAlgorithmPerformance { + algorithm: { id: number; name: string; key: string; version: number } + occurrence_set: { id: number; name: string } + accuracy: number + occurrences_scored: number + correct: number + overall_accuracy: number | null + overall_accuracy_by_species: number | null +} + +export interface AlgorithmPerformance { + algorithmId: string + algorithmName: string + accuracy: number + occurrencesScored: number + correct: number + occurrenceSetName: string +} + export class SpeciesDetails extends Species { public constructor(species: ServerSpeciesDetails) { super(species) } + get algorithmPerformance(): AlgorithmPerformance[] { + const rows = this._species.algorithm_performance ?? [] + + return rows.map((row: ServerAlgorithmPerformance) => ({ + algorithmId: `${row.algorithm.id}`, + algorithmName: row.algorithm.name, + accuracy: row.accuracy, + occurrencesScored: row.occurrences_scored, + correct: row.correct, + occurrenceSetName: row.occurrence_set.name, + })) + } + get commonNameLabel(): string | undefined { return this._species.common_name_en ?? undefined } diff --git a/ui/src/data-services/models/species.ts b/ui/src/data-services/models/species.ts index 37d6d622c..fd220b867 100644 --- a/ui/src/data-services/models/species.ts +++ b/ui/src/data-services/models/species.ts @@ -119,6 +119,10 @@ export class Species extends Taxon { return this._species.occurrences_count ?? 0 } + get numTrainingCrops(): number | undefined { + return this._species.training_crops_ready ?? undefined + } + get numVerified(): number { return this._species.verified_count ?? 0 } diff --git a/ui/src/data-services/models/taxa-list.ts b/ui/src/data-services/models/taxa-list.ts index bafd01781..69d748fc6 100644 --- a/ui/src/data-services/models/taxa-list.ts +++ b/ui/src/data-services/models/taxa-list.ts @@ -1,6 +1,22 @@ import { Entity, ServerEntity } from 'data-services/models/entity' +export type ServerTaxaListBestModel = { + id: number + name: string + accuracy: number | null + accuracy_by_species: number | null + occurrence_set: string +} + +export interface TaxaListBestModel { + id: string + name: string + accuracyBySpecies?: number + occurrenceSetName: string +} + export type ServerTaxaList = ServerEntity & { + best_model: ServerTaxaListBestModel | null // Highest scoring algorithm on these taxa projects: number[] // Array of project IDs taxa: string // URL to taxa API endpoint (filtered by this taxa list) taxa_count: number // Number of taxa in list @@ -15,6 +31,21 @@ export class TaxaList extends Entity { this._taxaList = taxaList } + get bestModel(): TaxaListBestModel | undefined { + const model = this._taxaList.best_model + + if (!model) { + return undefined + } + + return { + id: `${model.id}`, + name: model.name, + accuracyBySpecies: model.accuracy_by_species ?? undefined, + occurrenceSetName: model.occurrence_set, + } + } + get taxaCount() { return this._taxaList.taxa_count } diff --git a/ui/src/data-services/types.ts b/ui/src/data-services/types.ts index 49eec8291..fe40b10f8 100644 --- a/ui/src/data-services/types.ts +++ b/ui/src/data-services/types.ts @@ -5,6 +5,7 @@ export interface FetchParams { filters?: { field: string; value?: string; error?: string }[] withCounts?: boolean withExampleOccurrences?: boolean + withTrainingCropCounts?: boolean } export interface APIValidationError { diff --git a/ui/src/data-services/utils.ts b/ui/src/data-services/utils.ts index 9d017b984..0dc304079 100644 --- a/ui/src/data-services/utils.ts +++ b/ui/src/data-services/utils.ts @@ -38,6 +38,9 @@ export const getFetchUrl = ({ if (params?.withExampleOccurrences) { queryParams.with_example_occurrences = String(true) } + if (params?.withTrainingCropCounts) { + queryParams.with_training_crop_counts = String(true) + } const baseUrl = `${API_URL}/${collection}` const queryString = new URLSearchParams(queryParams).toString() diff --git a/ui/src/pages/algorithm-details/algorithm-details-dialog.tsx b/ui/src/pages/algorithm-details/algorithm-details-dialog.tsx index c96e0cb9c..908e6895d 100644 --- a/ui/src/pages/algorithm-details/algorithm-details-dialog.tsx +++ b/ui/src/pages/algorithm-details/algorithm-details-dialog.tsx @@ -8,6 +8,8 @@ import { useNavigate, useParams } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' +import { AlgorithmEvaluations } from './algorithm-evaluations' +import { AlgorithmTrainingData } from './algorithm-training-data' import styles from './styles.module.scss' export const AlgorithmDetailsDialog = ({ id }: { id: string }) => { @@ -91,6 +93,20 @@ const AlgorithmDetailsContent = ({ algorithm }: { algorithm: Algorithm }) => ( /> + {algorithm.trainable && ( + + + + + + )} + {algorithm.evaluations.length > 0 && ( + +
+ +
+
+ )} {algorithm.uri || algorithm.categoryMapURI ? (
diff --git a/ui/src/pages/algorithm-details/algorithm-evaluations.tsx b/ui/src/pages/algorithm-details/algorithm-evaluations.tsx new file mode 100644 index 000000000..f3a50b0b2 --- /dev/null +++ b/ui/src/pages/algorithm-details/algorithm-evaluations.tsx @@ -0,0 +1,71 @@ +import { Algorithm, AlgorithmEvaluation } from 'data-services/models/algorithm' +import { + BasicTableCell, + Table, + TableBackgroundTheme, + TableColumn, + TextAlign, +} from 'nova-ui-kit' +import { STRING, translate } from 'utils/language' + +const ACCURACY_DECIMALS = 2 + +const accuracyLabel = (accuracy?: number) => + accuracy !== undefined + ? accuracy.toFixed(ACCURACY_DECIMALS) + : translate(STRING.VALUE_NOT_AVAILABLE) + +export const columns: TableColumn[] = [ + { + id: 'occurrence-set', + name: translate(STRING.FIELD_LABEL_EVALUATION_SET), + renderCell: (item: AlgorithmEvaluation) => ( + + ), + }, + { + id: 'accuracy', + name: translate(STRING.FIELD_LABEL_ACCURACY), + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: AlgorithmEvaluation) => ( + + ), + }, + { + id: 'accuracy-by-species', + name: translate(STRING.FIELD_LABEL_ACCURACY_BY_SPECIES), + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: AlgorithmEvaluation) => ( + + ), + }, + { + id: 'occurrences-scored', + name: translate(STRING.FIELD_LABEL_OCCURRENCES_SCORED), + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: AlgorithmEvaluation) => ( + + ), + }, +] + +export const AlgorithmEvaluations = ({ + algorithm, +}: { + algorithm: Algorithm +}) => ( + +) diff --git a/ui/src/pages/algorithm-details/algorithm-training-data.tsx b/ui/src/pages/algorithm-details/algorithm-training-data.tsx new file mode 100644 index 000000000..099308eba --- /dev/null +++ b/ui/src/pages/algorithm-details/algorithm-training-data.tsx @@ -0,0 +1,35 @@ +import { useTrainingDataSummary } from 'data-services/hooks/algorithm/useTrainingDataSummary' +import { Algorithm } from 'data-services/models/algorithm' +import { InputValue } from 'nova-ui-kit' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' + +export const AlgorithmTrainingData = ({ + algorithm, +}: { + algorithm: Algorithm +}) => { + const { projectId } = useParams() + const { summary } = useTrainingDataSummary(projectId, algorithm.key) + + if (!summary) { + return null + } + + return ( + <> + + + + + ) +} diff --git a/ui/src/pages/job-details/job-details-form/job-details-form.tsx b/ui/src/pages/job-details/job-details-form/job-details-form.tsx index 8e9eef45e..6a428aba1 100644 --- a/ui/src/pages/job-details/job-details-form/job-details-form.tsx +++ b/ui/src/pages/job-details/job-details-form/job-details-form.tsx @@ -10,12 +10,14 @@ import { import { FormConfig } from 'components/form/types' import { API_ROUTES } from 'data-services/constants' import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' +import { Job, ServerJobType } from 'data-services/models/job' import { Checkbox, DocsLink, EntityPicker, InputContent, SaveButton, + Select, } from 'nova-ui-kit' import { CaptureSetPicker } from 'nova-ui-kit/components/select/capture-set-picker' import { useForm } from 'react-hook-form' @@ -23,17 +25,47 @@ import { useParams } from 'react-router-dom' import { APP_ROUTES, DOCS_LINKS } from 'utils/constants' import { STRING, translate } from 'utils/language' import { useFormError } from 'utils/useFormError' +import { TrainableAlgorithmPicker } from './trainable-algorithm-picker' interface JobFormValues { + algorithm?: string delay: number + jobType: ServerJobType name: string + occurrenceSet?: string pipeline?: string sourceImage?: string sourceImages?: string startNow?: boolean } +// What each job type asks for. The backend refuses a job missing any of these, so the +// form hides the fields a type does not use and requires the ones it does. +const FIELDS_BY_JOB_TYPE: { + [key in CreatableJobType]: (keyof JobFormValues)[] +} = { + ml: ['sourceImages', 'pipeline'], + generate_embeddings: ['pipeline', 'sourceImages'], + train_classifier: ['algorithm'], + evaluate_algorithm: ['algorithm', 'occurrenceSet'], +} + +const CREATABLE_JOB_TYPES = Object.keys( + FIELDS_BY_JOB_TYPE +) as CreatableJobType[] + +type CreatableJobType = Extract< + ServerJobType, + 'ml' | 'generate_embeddings' | 'train_classifier' | 'evaluate_algorithm' +> + const config: FormConfig = { + algorithm: { + label: translate(STRING.FIELD_LABEL_ALGORITHM), + rules: { + required: true, + }, + }, delay: { label: translate(STRING.FIELD_LABEL_DELAY), rules: { @@ -41,12 +73,24 @@ const config: FormConfig = { min: 0, }, }, + jobType: { + label: translate(STRING.FIELD_LABEL_TYPE), + rules: { + required: true, + }, + }, name: { label: translate(STRING.FIELD_LABEL_NAME), rules: { required: true, }, }, + occurrenceSet: { + label: translate(STRING.FIELD_LABEL_EVALUATION_SET), + rules: { + required: true, + }, + }, pipeline: { label: translate(STRING.FIELD_LABEL_PIPELINE), rules: { @@ -82,15 +126,20 @@ export const JobDetailsForm = ({ control, handleSubmit, setError: setFieldError, + watch, } = useForm({ defaultValues: { name: '', delay: 0, + jobType: 'ml', pipeline: project?.settings.defaultProcessingPipeline?.id, }, mode: 'onChange', }) + const jobType = watch('jobType') as CreatableJobType + const fields = FIELDS_BY_JOB_TYPE[jobType] ?? FIELDS_BY_JOB_TYPE.ml + const shows = (field: keyof JobFormValues) => fields.includes(field) const errorMessage = useFormError({ error, setFieldError }) return ( @@ -118,76 +167,156 @@ export const JobDetailsForm = ({ config={config} control={control} /> - - - ( - + + + + + + {CREATABLE_JOB_TYPES.map((key) => ( + + {Job.getJobTypeInfo(key).label} + + ))} + + )} /> - + + ( - - - - )} /> + + {shows('sourceImages') ? ( + ( + + + + )} + /> + ) : null} + {shows('pipeline') ? ( + ( + + + + )} + /> + ) : null} + {shows('algorithm') ? ( + ( + + + + )} + /> + ) : null} + {shows('occurrenceSet') ? ( + ( + + + + )} + /> + ) : null} + void +}) => { + const { projectId } = useParams() + const { algorithms = [], isLoading } = useAlgorithms({ + projectId: projectId as string, + filters: [{ field: 'trainable', value: 'true' }], + }) + + return ( + a.key === value) ? value : ''} + > + + + + + {algorithms.map((algorithm) => ( + + {algorithm.name} + + ))} + + + ) +} diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index 81e8c5f53..7e09b527e 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -5,7 +5,10 @@ import { TaxonDetails } from 'components/taxon-details/taxon-details' import { Tag } from 'components/taxon-tags/tag' import { TagsForm } from 'components/taxon-tags/tags-form' import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' -import { SpeciesDetails as Species } from 'data-services/models/species-details' +import { + AlgorithmPerformance, + SpeciesDetails as Species, +} from 'data-services/models/species-details' import { ExternalLinkIcon, LockIcon } from 'lucide-react' import { Box, @@ -31,6 +34,19 @@ export const TABS = { CHARTS: 'charts', } +const ACCURACY_DECIMALS = 2 + +const performanceLabel = ({ + algorithmName, + accuracy, + correct, + occurrencesScored, + occurrenceSetName, +}: AlgorithmPerformance) => + `${algorithmName}: ${accuracy.toFixed( + ACCURACY_DECIMALS + )} (${correct}/${occurrencesScored}) on ${occurrenceSetName}` + export const SpeciesDetails = ({ species, selectedTab, @@ -195,6 +211,26 @@ export const SpeciesDetails = ({ /> + + {species.algorithmPerformance.length ? ( + species.algorithmPerformance.map((performance) => ( + + )) + ) : ( + + )} + ), }, + { + id: 'training-images-ready', + name: translate(STRING.FIELD_LABEL_TRAINING_IMAGES_READY), + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Species) => ( + + ), + }, { id: 'best-determination-score', name: translate(STRING.FIELD_LABEL_BEST_SCORE), diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index 51d2f1083..9dd27cab1 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -51,6 +51,7 @@ export const Species = () => { 'last-seen': true, occurrences: true, verified: true, + 'training-images-ready': true, 'best-determination-score': true, 'created-at': false, 'updated-at': false, @@ -65,6 +66,7 @@ export const Species = () => { filters, // This list renders the Example column and links Last-seen / Best-score to an occurrence. withExampleOccurrences: true, + withTrainingCropCounts: columnSettings['training-images-ready'], }) // Ordered example occurrences, one per taxon row that has one, so the modal's // prev/next steps to the next taxon's example (rows without an example are skipped). diff --git a/ui/src/pages/taxa-lists/taxa-list-columns.tsx b/ui/src/pages/taxa-lists/taxa-list-columns.tsx index 8b84f4c09..ad11ee26d 100644 --- a/ui/src/pages/taxa-lists/taxa-list-columns.tsx +++ b/ui/src/pages/taxa-lists/taxa-list-columns.tsx @@ -1,5 +1,5 @@ import { API_ROUTES } from 'data-services/constants' -import { TaxaList } from 'data-services/models/taxa-list' +import { TaxaList, TaxaListBestModel } from 'data-services/models/taxa-list' import { BasicTableCell, CellTheme, @@ -16,6 +16,19 @@ import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { AddTaxaListTaxonPopover } from '../taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon-popover' +const ACCURACY_DECIMALS = 2 + +// Lists are ranked on the per-species average, so that is the number worth showing. +const bestModelDetails = (bestModel: TaxaListBestModel) => + [ + bestModel.accuracyBySpecies !== undefined + ? `${translate( + STRING.FIELD_LABEL_ACCURACY_BY_SPECIES + )}: ${bestModel.accuracyBySpecies.toFixed(ACCURACY_DECIMALS)}` + : undefined, + bestModel.occurrenceSetName, + ].filter((detail): detail is string => detail !== undefined) + export const columns = ({ projectId, }: { @@ -76,6 +89,28 @@ export const columns = ({ sortField: 'updated_at', renderCell: (item: TaxaList) => , }, + { + id: 'best-model', + name: translate(STRING.FIELD_LABEL_BEST_MODEL), + renderCell: (item: TaxaList) => + item.bestModel ? ( + + + + ) : ( + + ), + }, { id: 'actions', diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index feb83bed6..98a89909b 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -81,9 +81,13 @@ export enum STRING { ENTITY_VIEW, /* FIELD_LABEL */ + FIELD_LABEL_ACCURACY_BY_SPECIES, + FIELD_LABEL_ACCURACY, FIELD_LABEL_ADDED_AT, FIELD_LABEL_ALGORITHM_URI, + FIELD_LABEL_ALGORITHM, FIELD_LABEL_AVG_TEMP, + FIELD_LABEL_BEST_MODEL, FIELD_LABEL_BEST_SCORE, FIELD_LABEL_CAPTURE_SET, FIELD_LABEL_CAPTURE, @@ -95,6 +99,7 @@ export enum STRING { FIELD_LABEL_COMMENT, FIELD_LABEL_CONNECTION_STATUS, FIELD_LABEL_CREATED_AT, + FIELD_LABEL_CROPS_WITHOUT_EMBEDDING, FIELD_LABEL_DATA_SOURCE_CAPTURES, FIELD_LABEL_DATA_SOURCE, FIELD_LABEL_DATE_OBSERVED, @@ -109,6 +114,7 @@ export enum STRING { FIELD_LABEL_EMAIL, FIELD_LABEL_ENDPOINT, FIELD_LABEL_ERRORS, + FIELD_LABEL_EVALUATION_SET, FIELD_LABEL_EXAMPLE, FIELD_LABEL_FILE_SIZE, FIELD_LABEL_FILENAME, @@ -137,6 +143,7 @@ export enum STRING { FIELD_LABEL_NEW_PASSWORD, FIELD_LABEL_NUM_PIPELINES_REGISTERED, FIELD_LABEL_OCCURRENCES, + FIELD_LABEL_OCCURRENCES_SCORED, FIELD_LABEL_PASSWORD_CURRENT, FIELD_LABEL_PASSWORD_NEW, FIELD_LABEL_PASSWORD, @@ -173,6 +180,9 @@ export enum STRING { FIELD_LABEL_TOTAL_SIZE, FIELD_LABEL_TRAINING_IMAGES, FIELD_LABEL_TYPE, + FIELD_LABEL_SPECIES, + FIELD_LABEL_TRAINING_DATA, + FIELD_LABEL_TRAINING_IMAGES_READY, FIELD_LABEL_UPDATED_AT, FIELD_LABEL_USER, FIELD_LABEL_VERSION_NAME, @@ -280,6 +290,7 @@ export enum STRING { TOOLTIP_CAPTURE, TOOLTIP_DEPLOYMENT, TOOLTIP_DEVICE, + TOOLTIP_EVALUATION_SET, TOOLTIP_JOB, TOOLTIP_LATEST_JOB_STATUS, TOOLTIP_OCCURRENCE, @@ -304,6 +315,7 @@ export enum STRING { AGREEMENT_EXACT, AGREEMENT_KAPPA, ALGORITHMS, + ALGORITHMS_AND_PERFORMANCE, ANONYMOUS_USER, APPLY_ID_SHORT, APPLY_ID, @@ -315,6 +327,7 @@ export enum STRING { CONNECTING, DEFAULT, DETAILS, + EVALUATION, EXTERNAL_RESOURCES, FORGOT_PASSWORD_DETAILS, FORGOT_PASSWORD, @@ -443,9 +456,13 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.ZOOM_OUT]: 'Zoom out', /* FIELD_LABEL */ + [STRING.FIELD_LABEL_ACCURACY_BY_SPECIES]: 'Accuracy by species', + [STRING.FIELD_LABEL_ACCURACY]: 'Accuracy', [STRING.FIELD_LABEL_ADDED_AT]: 'Added at', [STRING.FIELD_LABEL_ALGORITHM_URI]: 'Algorithm source', + [STRING.FIELD_LABEL_ALGORITHM]: 'Algorithm', [STRING.FIELD_LABEL_AVG_TEMP]: 'Avg temp', + [STRING.FIELD_LABEL_BEST_MODEL]: 'Best model', [STRING.FIELD_LABEL_BEST_SCORE]: 'Best score', [STRING.FIELD_LABEL_CAPTURE_SET]: 'Capture set', [STRING.FIELD_LABEL_CAPTURE]: 'Capture', @@ -457,6 +474,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_COMMENT]: 'Comment', [STRING.FIELD_LABEL_CONNECTION_STATUS]: 'Connection status', [STRING.FIELD_LABEL_CREATED_AT]: 'Created at', + [STRING.FIELD_LABEL_CROPS_WITHOUT_EMBEDDING]: 'Crops waiting for embeddings', [STRING.FIELD_LABEL_DATA_SOURCE_CAPTURES]: 'Data source captures', [STRING.FIELD_LABEL_DATA_SOURCE]: 'Data source', [STRING.FIELD_LABEL_DATE_OBSERVED]: 'Date observed', @@ -471,6 +489,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_EMAIL]: 'Email', [STRING.FIELD_LABEL_ENDPOINT]: 'Endpoint URL', [STRING.FIELD_LABEL_ERRORS]: 'Errors', + [STRING.FIELD_LABEL_EVALUATION_SET]: 'Evaluation set', [STRING.FIELD_LABEL_EXAMPLE]: 'Example', [STRING.FIELD_LABEL_FILE_SIZE]: 'File size', [STRING.FIELD_LABEL_FILENAME]: 'Filename', @@ -499,6 +518,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_NEW_PASSWORD]: 'New password', [STRING.FIELD_LABEL_NUM_PIPELINES_REGISTERED]: 'Pipelines registered', [STRING.FIELD_LABEL_OCCURRENCES]: 'Occurrences', + [STRING.FIELD_LABEL_OCCURRENCES_SCORED]: 'Occurrences scored', [STRING.FIELD_LABEL_PASSWORD_CURRENT]: 'Current password', [STRING.FIELD_LABEL_PASSWORD_NEW]: 'New password', [STRING.FIELD_LABEL_PASSWORD]: 'Password', @@ -535,6 +555,9 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_TOTAL_SIZE]: 'Total size', [STRING.FIELD_LABEL_TRAINING_IMAGES]: 'Reference images', [STRING.FIELD_LABEL_TYPE]: 'Type', + [STRING.FIELD_LABEL_SPECIES]: 'Species', + [STRING.FIELD_LABEL_TRAINING_DATA]: 'Training data', + [STRING.FIELD_LABEL_TRAINING_IMAGES_READY]: 'Training images ready', [STRING.FIELD_LABEL_UPDATED_AT]: 'Updated at', [STRING.FIELD_LABEL_USER]: 'User', [STRING.FIELD_LABEL_VERSION_NAME]: 'Version name', @@ -699,6 +722,8 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { 'A monitoring station is a location where a device is deployed to collect captures of insects in the wild.', [STRING.TOOLTIP_DEVICE]: 'A device type is the type of equipment or camera used for collecting captures. One or many deployments can be connected to a device type. Device type refers to the model version, category or description of a kind of hardware, not the serial number of an individual device.', + [STRING.TOOLTIP_EVALUATION_SET]: + 'An evaluation set is a fixed list of verified occurrences. Two models can only be compared if they were scored on the same set.', [STRING.TOOLTIP_JOB]: 'A job is a task that requires time to complete and runs in the background. Examples include processing captures, syncing captures, and generating exports.', [STRING.TOOLTIP_LATEST_JOB_STATUS]: @@ -737,6 +762,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.AGREEMENT_EXACT]: 'Agreement (exact taxon)', [STRING.AGREEMENT_KAPPA]: "Cohen's kappa (beyond chance)", [STRING.ALGORITHMS]: 'Algorithms', + [STRING.ALGORITHMS_AND_PERFORMANCE]: 'Algorithms & performance', [STRING.ANONYMOUS_USER]: 'Anonymous user', [STRING.APPLY_ID_SHORT]: 'Apply', [STRING.APPLY_ID]: 'Apply ID', @@ -748,6 +774,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.CONNECTING]: 'Connecting...', [STRING.DEFAULT]: 'Default', [STRING.DETAILS]: 'Details', + [STRING.EVALUATION]: 'Evaluation', [STRING.EXTERNAL_RESOURCES]: 'External resources', [STRING.FORGOT_PASSWORD_DETAILS]: `No worries, we'll send you reset instructions.`, [STRING.FORGOT_PASSWORD]: 'Forgot password?', From 881e379cb8a492b8884248e8a003a791d5453458 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 17 Sep 2026 16:22:57 -0400 Subject: [PATCH 2/3] Point the evaluation-set picker at its new route Occurrence sets moved out of the ml namespace to sit beside the other named collections of domain objects, so the picker follows. Co-Authored-By: Claude --- ui/src/data-services/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/data-services/constants.ts b/ui/src/data-services/constants.ts index ac53353eb..4d77c248c 100644 --- a/ui/src/data-services/constants.ts +++ b/ui/src/data-services/constants.ts @@ -16,7 +16,7 @@ export const API_ROUTES = { ME: 'users/me', MEMBERS: (projectId: string) => `projects/${projectId}/members`, OCCURRENCE_ALGORITHMS: 'occurrences/algorithms', - OCCURRENCE_SETS: 'ml/occurrence-sets', + OCCURRENCE_SETS: 'occurrences/sets', OCCURRENCES: 'occurrences', PAGES: 'pages', PIPELINES: 'ml/pipelines', From 6ab0ebe22ca146b1f2de746f0bfa71ae95e0cc41 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 17 Sep 2026 16:54:58 -0400 Subject: [PATCH 3/3] Say how many occurrences a model was not asked about Comparing two heads on the same set showed 1.00 over 15 occurrences against 0.89 over 9. Read plainly the second head looks worse, but six of those occurrences are species it cannot predict at all, so it was asked fewer questions rather than getting more wrong. The count was already in the response and not shown. The scored column now carries what was left out beneath it, so a comparison that is not like for like says so. Co-Authored-By: Claude --- ui/src/data-services/models/algorithm.ts | 2 ++ .../algorithm-details/algorithm-evaluations.tsx | 13 ++++++++++++- ui/src/utils/language.ts | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/ui/src/data-services/models/algorithm.ts b/ui/src/data-services/models/algorithm.ts index f5dd58472..74bc483d9 100644 --- a/ui/src/data-services/models/algorithm.ts +++ b/ui/src/data-services/models/algorithm.ts @@ -20,6 +20,7 @@ export interface AlgorithmEvaluation { accuracy?: number accuracyBySpecies?: number occurrencesScored: number + occurrencesSkipped: number } export class Algorithm extends Entity { @@ -76,6 +77,7 @@ export class Algorithm extends Entity { accuracy: row.accuracy ?? undefined, accuracyBySpecies: row.accuracy_by_species ?? undefined, occurrencesScored: row.occurrences_scored, + occurrencesSkipped: row.occurrences_skipped, })) } } diff --git a/ui/src/pages/algorithm-details/algorithm-evaluations.tsx b/ui/src/pages/algorithm-details/algorithm-evaluations.tsx index f3a50b0b2..979782b24 100644 --- a/ui/src/pages/algorithm-details/algorithm-evaluations.tsx +++ b/ui/src/pages/algorithm-details/algorithm-evaluations.tsx @@ -53,7 +53,18 @@ export const columns: TableColumn[] = [ textAlign: TextAlign.Right, }, renderCell: (item: AlgorithmEvaluation) => ( - + ), }, ] diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 98a89909b..4246d6d17 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -144,6 +144,7 @@ export enum STRING { FIELD_LABEL_NUM_PIPELINES_REGISTERED, FIELD_LABEL_OCCURRENCES, FIELD_LABEL_OCCURRENCES_SCORED, + FIELD_LABEL_OCCURRENCES_SKIPPED, FIELD_LABEL_PASSWORD_CURRENT, FIELD_LABEL_PASSWORD_NEW, FIELD_LABEL_PASSWORD, @@ -519,6 +520,8 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_NUM_PIPELINES_REGISTERED]: 'Pipelines registered', [STRING.FIELD_LABEL_OCCURRENCES]: 'Occurrences', [STRING.FIELD_LABEL_OCCURRENCES_SCORED]: 'Occurrences scored', + [STRING.FIELD_LABEL_OCCURRENCES_SKIPPED]: + '{{count}} skipped: not a species this model can predict', [STRING.FIELD_LABEL_PASSWORD_CURRENT]: 'Current password', [STRING.FIELD_LABEL_PASSWORD_NEW]: 'New password', [STRING.FIELD_LABEL_PASSWORD]: 'Password',