/** * * Copyright 2023-2025 InspectorRAGet Team * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * **/ 'use client'; import { isEmpty } from 'lodash'; import cx from 'classnames'; import { useState, useMemo } from 'react'; import { Tag, FilterableMultiSelect, Toggletip, ToggletipButton, ToggletipContent, DataTable, Table, TableHead, TableRow, TableHeader, TableBody, TableCell, } from '@carbon/react'; import { Information, WarningAlt } from '@carbon/icons-react'; import { BoxplotChart, StackedBarChart } from '@carbon/charts-react'; import { ScaleTypes } from '@carbon/charts'; import { useTheme } from '@/src/theme'; import { Model, TaskEvaluation, Metric } from '@/src/types'; import { AgreementLevels } from '@/src/utilities/metrics'; import { getAgreementLevelColorPalette, getVotingPatternColorPalette, } from '@/src/utilities/colors'; import { areObjectsIntersecting } from '@/src/utilities/objects'; import Filters from '@/src/components/filters/Filters'; import '@carbon/charts-react/styles.css'; import classes from './AnnotatorBehavior.module.scss'; import InterAnnotatorAgreementHeatMap from '@/src/views/annotator-behavior/InterAnnotatorAgreementHeatMap.tsx'; // =================================================================================== // TYPES // =================================================================================== interface Props { evaluationsPerMetric: { [key: string]: TaskEvaluation[] }; models: Model[]; metrics: Metric[]; filters: { [key: string]: string[] }; } // =================================================================================== // CONSTANTS // =================================================================================== const VOTING_PATTERNS: string[] = Object.keys(getVotingPatternColorPalette()); // =================================================================================== // HELPER FUNCTIONS // =================================================================================== function normalize(data: { [key: string]: number }) { const total = Object.values(data).reduce((a, b) => a + b, 0); return Object.fromEntries( Object.entries(data).map(([agreementLevel, count]) => { return [ agreementLevel, Math.round(((count / total) * 100 + Number.EPSILON) * 100) / 100, ]; }), ); } function updateVotingPattern( votingPatternsPerTopicPerVoter: { [key: string]: { [key: string]: { [key: string]: number } }; }, topic: string, voter: string, votingPattern: string, ) { if (votingPatternsPerTopicPerVoter.hasOwnProperty(topic)) { if (votingPatternsPerTopicPerVoter[topic].hasOwnProperty(voter)) { votingPatternsPerTopicPerVoter[topic][voter][votingPattern] += 1; } else { votingPatternsPerTopicPerVoter[topic][voter] = { ...Object.fromEntries(VOTING_PATTERNS.map((pattern) => [pattern, 0])), [votingPattern]: 1, }; } } else { votingPatternsPerTopicPerVoter[topic] = { [voter]: { ...Object.fromEntries(VOTING_PATTERNS.map((pattern) => [pattern, 0])), [votingPattern]: 1, }, }; } } function prepareBoxPlotData(data: { [key: string]: number[] }) { // Populate plot data array const plotData: { [key: string]: string | number }[] = []; for (const [worker, durations] of Object.entries(data)) { durations.forEach((duration) => { plotData.push({ group: worker, key: 'All', value: Math.floor(duration / 60) + (duration % 60) / 60, }); }); } return plotData; } // =================================================================================== // RENDER FUNCTIONS // =================================================================================== function renderAgreementDistribution( record: { [key: string]: { [key: string]: number }; }, key: string, caption: string, models: Model[], theme?: string, ) { // Step 1: Compute overall agreement level distribution const overall: { [key: string]: number } = { No: 0, Low: 0, High: 0, Absolute: 0, }; for (const distribution of Object.values(record)) { for (const [agreementLevel, count] of Object.entries(distribution)) { overall[agreementLevel] += count; } } // Step 2: Build chart data const chartData: { [key: string]: string | number }[] = []; for (const [agreementLevel, count] of Object.entries(normalize(overall))) { chartData.push({ group: agreementLevel, key: 'All', value: count }); } for (const [modelId, distribution] of Object.entries(record)) { for (const [agreementLevel, count] of Object.entries( normalize(distribution), )) { const model = models.find((entry) => entry.modelId === modelId); chartData.push({ group: agreementLevel, key: model ? model.name : modelId, value: count, }); } } // Step 3: Render return (
{caption}
tick + '%', }, }, }, color: { scale: getAgreementLevelColorPalette(), }, width: '500px', height: '500px', toolbar: { enabled: false, }, theme: theme, }} >
); } function renderAnnotatorVotingPattern( record: { [key: string]: { [key: string]: number }; }, key: string, caption: string, theme?: string, ) { // Step 1: Compute overall agreement level distribution const overall: { [key: string]: number } = Object.fromEntries( VOTING_PATTERNS.map((pattern) => [pattern, 0]), ); for (const distribution of Object.values(record)) { for (const [affiliaion, count] of Object.entries(distribution)) { overall[affiliaion] += count; } } // Step 2: Build chart data const chartData: { [key: string]: string | number }[] = []; for (const [affliation, count] of Object.entries(normalize(overall))) { chartData.push({ group: affliation, key: 'All', value: count }); } for (const [individual, distribution] of Object.entries(record)) { for (const [affiliaion, count] of Object.entries(normalize(distribution))) { chartData.push({ group: affiliaion, key: `ID: ${individual}`, value: count, }); } } // Step 3: Render return (
{caption}
tick + '%', }, }, }, color: { scale: getVotingPatternColorPalette(), }, width: '500px', height: '500px', toolbar: { enabled: false, }, theme: theme, }} >
); } // =================================================================================== // MAIN FUNCTION // =================================================================================== export default function AnnotatorBehavior({ evaluationsPerMetric, models, metrics, filters, }: Props) { // Step 1: Initialize state and necessary variables const eligibleMetricNames = useMemo(() => { return new Set(metrics.map((metric) => metric.name)); }, [metrics]); const [selectedModels, setSelectedModels] = useState(models); const [selectedFilters, setSelectedFilters] = useState<{ [key: string]: string[]; }>({}); // Step 2: Run effects // Step 2.a: Fetch theme const { theme } = useTheme(); // Step 2.b: Adjust visible evaluations based on selected models const visibleEvaluationsPerMetric = useMemo(() => { const filteredEvaluationsPerMetric: { [key: string]: TaskEvaluation[] } = {}; for (const [metric, evaluations] of Object.entries(evaluationsPerMetric)) { if (eligibleMetricNames.has(metric)) { filteredEvaluationsPerMetric[metric] = evaluations.filter( (evaluation) => selectedModels .map((model) => model.modelId) .includes(evaluation.modelId) && (!isEmpty(selectedFilters) ? areObjectsIntersecting(selectedFilters, evaluation) : true), ); } } return filteredEvaluationsPerMetric; }, [ evaluationsPerMetric, eligibleMetricNames, selectedModels, selectedFilters, ]); // Step 2.c: Build agreement distribution chart data per model for visible evaluations const [ agreementDistributionPerMetricPerModel, annotatorVotingPatternPerMetric, ] = useMemo(() => { // Step 2.c.i: Initialize necessary variables const agreementStatisticPerMetricPerModel: { [key: string]: { [key: string]: { [key: string]: number } }; } = {}; const votingPatternsPerMetricPerAnnotator: { [key: string]: { [key: string]: { [key: string]: number } }; } = {}; for (const metric in visibleEvaluationsPerMetric) { agreementStatisticPerMetricPerModel[metric] = Object.fromEntries( selectedModels.map((model) => [ model.modelId, { No: 0, Low: 0, High: 0, Absolute: 0 }, ]), ); } // Step 2.c.ii: Iterate over evaluations for each metric for (const [metric, evaluations] of Object.entries( visibleEvaluationsPerMetric, )) { evaluations.forEach((evaluation) => { switch (evaluation[`${metric}_agg`].level) { // Case: All annotators gave same rating case AgreementLevels.ABSOLUTE_AGREEMENT: agreementStatisticPerMetricPerModel[metric][evaluation.modelId][ 'Absolute' ] += 1; // Update voting pattern for (const annotator in evaluation[metric]) { updateVotingPattern( votingPatternsPerMetricPerAnnotator, metric, annotator, 'Unanimous', ); } break; case AgreementLevels.HIGH_AGREEMENT: // Case: Majority of annotators gave same rating and minority rating is close to majority rating agreementStatisticPerMetricPerModel[metric][evaluation.modelId][ 'High' ] += 1; // Update voting pattern for (const annotator in evaluation[metric]) { updateVotingPattern( votingPatternsPerMetricPerAnnotator, metric, annotator, evaluation[metric][annotator]['value'] === evaluation[`${metric}_agg`].value ? 'Majority' : 'Dissidents (minor)', ); } break; case AgreementLevels.LOW_AGREEMENT: // Case: Majority of annotators gave same rating and minority rating is far from majority rating agreementStatisticPerMetricPerModel[metric][evaluation.modelId][ 'Low' ] += 1; // Update voting pattern for (const annotator in evaluation[metric]) { updateVotingPattern( votingPatternsPerMetricPerAnnotator, metric, annotator, evaluation[metric][annotator]['value'] === evaluation[`${metric}_agg`].value ? 'Majority' : 'Dissidents (major)', ); } break; default: // Case: All annotators gave different rating agreementStatisticPerMetricPerModel[metric][evaluation.modelId][ 'No' ] += 1; // Update voting pattern for (const annotator in evaluation[metric]) { updateVotingPattern( votingPatternsPerMetricPerAnnotator, metric, annotator, 'Divided', ); } } }); } return [ agreementStatisticPerMetricPerModel, votingPatternsPerMetricPerAnnotator, ]; }, [visibleEvaluationsPerMetric, selectedModels]); // Step 2.d: Build time duration distribution per annotator for all evaluations const timeDistributionPerAnnotator: { [key: string]: number[]; } = useMemo(() => { const temp: { [key: string]: number[] } = {}; const processedEvaluationTaskIDs = new Set(); for (const [metric, evaluations] of Object.entries(evaluationsPerMetric)) { evaluations.forEach((evaluation) => { if (!processedEvaluationTaskIDs.has(evaluation.taskId)) { Object.keys(evaluation[metric]).forEach((worker) => { if (evaluation[metric][worker]['duration'] !== undefined) { if (temp.hasOwnProperty(worker)) { temp[worker] = [ ...temp[worker], evaluation[metric][worker]['duration'], ]; } else { temp[worker] = [evaluation[metric][worker]['duration']]; } } }); } }); break; } return temp; }, [evaluationsPerMetric]); // Step 3: Render return (
{Array.isArray(metrics) && !metrics.length ? ( <>
Nothing to see here in absence of human evaluations.
) : ( <>
item.name} onChange={(event) => { setSelectedModels(event.selectedItems); }} invalid={selectedModels.length === 0} invalidText={'You must select a model to review.'} >
{selectedModels.map((model) => { return ( {model.name} ); })}
{!isEmpty(filters) ? ( ) : null}

Agreement Distribution

{Object.entries(agreementDistributionPerMetricPerModel).map( ([metricName, agreementDistributionPerModel], idx) => { const metric = metrics.find( (entry) => entry.name === metricName, ); return renderAgreementDistribution( agreementDistributionPerModel, 'agreement-distribution-graph-' + idx, metric?.displayName ? metric?.displayName : metricName.charAt(0).toUpperCase() + metricName.slice(1).toLowerCase(), models, theme, ); }, )}

Annotator Contribution

{Object.entries(annotatorVotingPatternPerMetric).map( ([metricName, distribution], idx) => { const metric = metrics.find( (entry) => entry.name === metricName, ); return renderAnnotatorVotingPattern( distribution, 'annotator-abnormality-graph-' + idx, metric?.displayName ? metric?.displayName : metricName.charAt(0).toUpperCase() + metricName.slice(1).toLowerCase(), theme, ); }, )}

Inter Annotator Agreement (Cohen's Kappa 
How to interprete Cohen's kappa coefficient?
{({ rows, headers, getTableProps, getHeaderProps, getRowProps, }) => ( {headers.map((header, idx) => ( {header.header} ))} {rows.map((row, idx) => ( {row.cells.map((cell) => ( {cell.value} ))} ))}
)}
)

{Object.keys(visibleEvaluationsPerMetric).map( (metricName, idx) => { const metric = metrics.find( (entry) => entry.name === metricName, ); return (
{metric?.displayName ? metric?.displayName : metricName.charAt(0).toUpperCase() + metricName.slice(1).toLowerCase()}
{visibleEvaluationsPerMetric[metricName] && ( )}
); }, )}
{!isEmpty(timeDistributionPerAnnotator) ? (

Time spent

Time spent per task (in minutes)
) : null} )}
); }