Assignment and Agreement¶
Who annotates what, and which agreement measure follows from it. It ships with the potato-skill skill:
/plugin marketplace add davidjurgens/potato-skill
/plugin install potato-skill@potato
Two questions that look separate and are not: the number of annotators per item decides whether an agreement number exists at all, and the per-annotator quota decides whether the items you care about ever reach three annotators.
The keys¶
| Key | What it does | Default |
|---|---|---|
num_annotators_per_item |
Annotators per item; an item retires when it has this many. Set this one. | unset: no limit (0 too) |
min_annotators_per_instance |
Deprecated spelling of the key above, read as the cap with a warning | unset |
max_annotations_per_item |
Older spelling of the same cap; -1 unlimited |
-1 |
max_annotations_per_user |
Cap on dataset items per annotator, unless per_annotator_quota overrides it |
the item count |
per_annotator_quota |
Per-annotator caps: default, by_user, by_user_role. Beats the key above |
unset |
automatic_assignment.instance_per_annotator |
Nothing. See below. | — |
assignment_strategy |
Order items are handed out in | fixed_order |
random_seed |
Makes the ordering reproducible | unset |
The three are one setting. With none of them set there is no cap: on 2.11.0, five annotators of a two-item study were all served the first item. Two of them with the same number validate clean, and two with different numbers are refused:
'max_annotations_per_item' and 'num_annotators_per_item' are both set with
conflicting values (5 vs 3). Drop 'max_annotations_per_item' —
'num_annotators_per_item' is the canonical key.
min_annotators_per_instance used to be stored at load and read nowhere
(checked against Potato be457e7b), so a study that set it collected as many
annotators as it got. From 2.11 it is read as the cap. Set to 2 on its own, the
first two annotators got the first item, the third got the second, and the
fourth and fifth got nothing. Validation warns:
min_annotators_per_instance: 2 is deprecated and has been read as
num_annotators_per_item: 2. Rename it. (The key of the same name under
icl_labeling.example_selection is a different setting, and that one is
read.)
num_annotators_per_item.adaptive¶
num_annotators_per_item also takes a mapping, and the adaptive block under
it reopens an item when the first annotators disagree:
num_annotators_per_item:
default: 2
adaptive:
enabled: true
disagreement_threshold: 0.5
boost_to: 4
Measured on 8 items with two annotators who disagreed on x0-x3 and agreed
on x4-x7. The server logged four lines and only four:
Adaptive boost: x0 raised to 4 annotators (current=2)
The third and fourth annotators through the door were each served exactly
x0 x1 x2 x3; the agreed half had retired at 2; the fifth got nothing. So the
budget goes where the label is actually contested, and a study that would have
cost 8 x 4 = 32 annotations cost 24.
Worth knowing before you reach for it: this is a cap being raised, so the
arithmetic in the section above stops holding. items x num_annotators_per_item
is no longer the ceiling on how much work exists — with boost_to: 4 on a
default: 2 study the true ceiling is twice what default suggests, and how
close you get depends on how much your annotators disagree, which is the thing
you do not know yet. Recruit against boost_to, not against default.
instance_per_annotator¶
Measured on 10 items, reading the assignment the server actually built:
| Config | Items served to one annotator |
|---|---|
| nothing set | 10 — all of them |
max_annotations_per_user: 5 |
5 |
automatic_assignment.instance_per_annotator: 5 |
10 — no effect |
num_annotators_per_item: 3 |
10 — no effect on the quota |
instance_per_annotator is not read anywhere in the server, and
automatic_assignment does not validate its sub-keys, so it produces no warning
and no behaviour. Use max_annotations_per_user, or per_annotator_quota if
different annotators need different amounts.
Left unset, the cap is the number of items loaded, so every annotator is offered
the whole corpus. -1 is explicit unlimited, and is what a dynamic data source
needs for items added after boot to be assignable at all.
Giving different annotators different amounts¶
per_annotator_quota caps annotators individually. Potato resolves one
annotator's cap in this order, taking the first that matches:
per_annotator_quota.by_user[<user>]per_annotator_quota.by_user_role[<their role>], where the role comes from theuser_rolesmapper_annotator_quota.defaultmax_annotations_per_user
So default displaces max_annotations_per_user rather than sitting beside it:
set both and the global is never read, and nothing warns. Without default, an
annotator matching neither override falls through to the global, which is how
the two keys are meant to combine.
Measured on 24 items with default: 5, by_user: {alice: 3},
by_user_role: {expert: 7}, user_roles: {alice: expert, bob: expert, carol:
novice}, and max_annotations_per_user: 20:
| Annotator | Served | Why |
|---|---|---|
| alice | 3 | by_user wins over her expert role |
| bob | 7 | expert role |
| carol | 5 | role novice has no entry, so default |
| dave | 5 | no role, so default |
Nobody got 20. An annotator who reaches their cap is shown the completion page, the same as finishing the corpus.
A role named in user_roles with no matching by_user_role entry is not an
error and does not warn -- that annotator silently gets default, which is
carol above.
Quality-control items do not consume the quota¶
Attention checks and gold items are injected into the annotator's ordering as
they work, rather than drawn from the pool at assignment time. They do not count
against max_annotations_per_user, so enabling them does not shorten the corpus
each annotator sees. On a six-article study with checks every third item, both
annotators are served all six articles plus the checks:
ordering = [n001, n002, n003, check01, n004, n005, n006]
You do not need to pad the quota to make room for them. If you set the quota explicitly, set it to the number of dataset items an annotator should see.
Verify with a count across every annotator, not one. Per-annotator ordering looks plausible in isolation; what matters is whether each item reached the number of annotators you asked for:
import json, glob, collections
c = collections.Counter()
for f in glob.glob("annotation_output/*/user_state.json"):
c.update(json.load(open(f))["instance_id_to_label_to_value"])
print(sorted(c.items())) # every real item should hit num_annotators_per_item
More annotators than work¶
num_annotators_per_item times the item count is the ceiling on how much work
exists. Once it is used up, the next person to register gets a page of its own:
There is no work left for you
Every item in this study already has as many annotators as it needs, so nothing could be assigned to you. You have not annotated anything, and nothing has been recorded against your account.
If you were asked to take part, tell whoever sent you the link -- they can raise the number of annotators per item or add more data.
It offers a Check again button, and says that reloading will give them work if more data arrives, which is true of a dynamic data source. The log carries a matching warning naming the cause and the fix:
No items could be assigned to carol: every item already has its full complement
of annotators (num_annotators_per_item=1). Showing the no-work page rather than
the completion page. Raise num_annotators_per_item or add data to give them work.
No annotation_output/<user>/ directory is created for them. Three items at
num_annotators_per_item: 1 does this to the fourth person through the door,
and on a pilot that person is usually you with a fresh login.
Before recruiting, multiply: items x num_annotators_per_item is the number of
annotator-items you have to hand out, and max_annotations_per_user divides it
into people.
Holds and annotations¶
An annotator who is given an item holds it, and the hold counts against the item's cap so that people spread out across the corpus instead of piling onto its first item. A hold is not a submission, though, and somebody who takes items and closes the tab would otherwise strand them. So when the cap would send an arriving annotator away with nothing, Potato relaxes it and hands out items that another annotator holds but has not submitted, logging:
Assigned 4 item(s) to bob that another annotator holds but has not submitted.
Enable instance_reclaim to hand those back automatically instead.
This means an over-subscribed study can collect more annotations per item than
num_annotators_per_item, and nothing warns. Measured on 4 items at
num_annotators_per_item: 1, with no per-user cap: the first annotator was
given all four and submitted nothing, the second was given the same four, and
when both then answered item 1 it ended up with two annotations under a cap of
one. If you pay per judgement, that is real money, and an agreement number
computed over it is not the design you configured.
Size the pool and it does not happen. The same study at 8 items with
max_annotations_per_user: 4 gave the first annotator items 1-4 and the second
items 5-8, no overlap, and the relaxation never fired. The rule is the same
multiplication as above: keep annotators x their quota at or under
items x num_annotators_per_item.
Taking abandoned items back¶
instance_reclaim is the machinery the log line points at, and it is off by
default:
instance_reclaim:
enabled: true
timeout_hours: 0.001 # fractional hours are fine; this is 3.6 seconds
An annotator who was given items and never submitted loses them after
timeout_hours, and they go back into the pool. Items they did annotate are
never reclaimed. Measured with two annotators over 4 items: the second one's
arrival reclaimed all four from the first, and the log named each one.
Two things to know before relying on it. It runs when somebody asks for an
assignment, not on a timer -- it is called from the assignment pass, so on a
quiet study nothing is reclaimed until the next annotator arrives. And it does
not stop the relaxation above: an annotator whose items were reclaimed, arriving
back to a study with nothing free, is handed items the other annotator now
holds, and the log prints its "Enable instance_reclaim" advice even though
instance_reclaim is enabled.
Giving cohorts different items and different questions¶
batch_assignment pins named annotators to named items, and scheme_sets gives
each cohort its own questions. They are usually wanted together.
assignment_strategy: batch # without this, the groups do not pick items
annotation_schemes:
- annotation_type: radio
name: verdict
description: Is this acceptable?
labels: ["Yes", "No"]
- annotation_type: radio
name: severity
description: How severe?
labels: ["Low", "High"]
scheme_sets:
quick:
- verdict
detailed:
- verdict
- severity
batch_assignment:
groups:
- name: cohort_a
annotators: [alice, bob]
instances: ["1", "2", "3", "4"]
schemes: detailed
- name: cohort_b
annotators: [carol]
instances: ["5", "6", "7", "8"]
schemes: quick
Measured on that config: alice and bob were served items 1-4 with both questions,
carol items 5-8 with only verdict.
assignment_strategy: batch is what makes the groups pick items. Leave it
out and the study still validates, still boots, and each cohort still gets its
own questions -- but every annotator is served every item, because the group's
item list is only consulted by the batch strategy. The working half is the
problem: cohorts visibly getting different questions reads as proof the config
took effect. On the same config without that one line, carol was served items 1
through 5.
A schemes binding is a scheme_sets name, a single scheme's name, or an
inline list; anything else is refused at load. An annotator in no group falls
back to the global annotation_schemes -- but only for questions. Under
assignment_strategy: batch they are served no items at all, and get the
no-work page telling them every item has its full complement of annotators.
That is the wrong reason, and the log says the same: on an unlimited
num_annotators_per_item it reports every item as already having as many
annotators as it needs. If you use batch_assignment with a fixed roster,
anybody who arrives outside it is turned away with a misleading explanation, so
list every annotator you expect.
The keys accept several spellings. annotators or users; instances, items
or instance_ids; instances_file, items_file, instance_ids_file,
data_file, input_data_file or input_file to read the ids from a file
instead; max_annotators, annotator_count or slots for a per-group cap.
Choosing the number of annotators¶
| Situation | Number |
|---|---|
| Near-mechanical labels, or prototyping, nobody reporting agreement | 1 |
| Any judgement involved and an agreement number will be reported | 3 |
| Genuinely subjective constructs, or per-annotator reliability is the object of study | 5+ |
Three is the smallest number where a majority can break a tie. More annotators on fewer items beats fewer annotators on more items whenever you do not yet know the labels are learnable — find that out on 100 items before spending the budget on 10,000.
This is the researcher's decision, not yours. Propose a number with the reason and say what it costs.
Order¶
assignment_strategy takes: random, fixed_order, active_learning,
llm_confidence, max_diversity, least_annotated, category_based,
diversity_clustering, batch, priority, model_review, psychometric.
random with a random_seed is right unless there is a reason:
fixed_orderwhen items are a narrative and order carries meaningleast_annotatedwhen finishing every item matters more than annotator experience — the one to reach for when the quota trap above is biting and you cannot raise the capactive_learningwhen a model is in the loop and the point is to spend annotator time where it changes something. It needsassignment_strategy: active_learning. Withactive_learning.enabled: trueon its own the classifier is still fitted and its ordering serves nobody; the reorder log line andordering_is_servedin/admin/active-learning/statsboth name which case you are in.llm_confidencesits beside it in the list, is not implemented, and assigns at random
The response format decides the metric¶
Potato classifies each scheme and computes what fits. You do not choose the metric separately, which makes this a measurement decision rather than a cosmetic one.
| Kind | Schemes | What it computes |
|---|---|---|
| nominal | radio, select, triage, conjoint, multiselect capped at 1 |
pairwise Cohen's κ, Fleiss' κ, nominal α |
| ordinal | likert, confidence, vas, range_slider, semantic_differential |
linear and quadratic weighted κ, Spearman ρ, ordinal α |
| continuous | slider, number |
Pearson r, MAE, RMSE, interval α, ICC(2,k) |
| multilabel | multiselect, hierarchical_multiselect, card_sort |
mean Jaccard, MASI α |
| matrix | multirate, constant_sum, soft_label, bws |
the ordinal or continuous measures per row, plus a pooled block over every (item, row) pair |
| ranking | ranking |
Kendall's τ, Spearman footrule |
| span | span, error_span, coreference, span_link, event_annotation, extractive_qa, tree_annotation |
token-level κ, exact and partial span F1, Krippendorff's αU, γ |
| geometry | boxes, polygons, points | matched IoU, detection F1, chance-corrected σ, ks, detection α |
(the only way to score a geometry task against a known answer — gold_label cannot hold a box) |
||
| free text | text, text_edit |
nothing |
pairwise does not follow its type name. It renders three widgets and stores
three shapes, so mode: scale is scored continuous, mode: multi_dimension as a
matrix, and the binary default as nominal. Read the kind out of the report.
mean_matched_iou averages over matched pairs, not over items. A region one
annotator drew and the other did not is not a zero in it; it is absent from it.
Six matched pairs on a study where twelve of twenty regions never matched still
reported 0.674, the mean of those six. That number answers "when both of them
drew the same thing, how well did the boxes line up" and nothing else. What the
disagreement about whether a thing is there costs you is in detection_f1 and
mean_object_count_diff; quote all three or none.
Region-caption agreement anchors on one annotator, so it is asymmetric. The
first annotator by sorted id defines the units, and everyone else is matched
onto them at match_iou. Regions only the second annotator drew are never
units and their captions are never counted, so n_captions is the anchor's
captions plus one per match, not the number of captions in the data. Twelve
anchor regions with six matches gave n_captions: 18 over twenty stored
captions. Swapping who annotates first changes the units, and the module says as
much rather than hiding it. agreement_distance defaults to token, which
scores a correct paraphrase as complete disagreement; embedding is the one
that does not, and it falls back to token with a note in the report if
sentence-transformers is missing.
Consequences worth stating to a researcher up front:
- A 1–5 rating stored as a
radiois scored as unordered, so "1 vs 2" counts the same as "1 vs 5" and the agreement number is badly understated. Store ordered judgements aslikert. - Span agreement tolerates disagreement about boundaries. Annotators asked to mark sentences will not agree on where a sentence starts; token-level and partial-overlap measures handle that, whereas splitting the text into sentence rows and computing per-sentence κ requires the units to match exactly.
- Free text has no metric. If a judgement is going to be counted, make it a scale. If it is genuinely prose, plan to read it and say so.
Enable the admin-side computation with:
num_annotators_per_item: 3
agreement_metrics:
enabled: true
agreement_metrics recognizes enabled and nothing else — metrics, measures,
types, min_annotators and by_scheme are all rejected by --strict. The
measures follow from the schema kinds; there is nothing to select.
Reading the agreement report¶
Nothing is computed at boot, and nothing is linked from the annotation page.
Agreement appears once an item reaches its full annotator cap, not once it
has two annotators. An item sitting at 2 of num_annotators_per_item: 3 is not
scored. Measured on a six-item study: two annotators finished and every
coefficient was null, the third finished and the whole report populated.
The report tells you which of the two it is. Alongside n_overlap_items it
returns n_items_below_cap, and an items block giving each item its cap, the
annotators it has, and how many answered each scheme:
{"n_overlap_items": 6, "n_items_below_cap": 0,
"items": {"p01": {"annotators": ["r1@x.com", "r2@x.com", "r3@x.com"], "cap": 3,
"schemas": {"decision": {"n_annotators": 3},
"topics": {"n_annotators": 2}}}}}
Read those two before the coefficients. A study waiting on its third annotator
and a study nobody has touched both produce nulls, and n_items_below_cap is
what separates them. The per-item schemas counts also show a scheme an
annotator skipped, which is the usual reason one scheme is null while the rest
of the report is fine.
cat admin_api_key.txt # written into task_dir on the first admin request
curl -H "X-API-Key: $(cat admin_api_key.txt)" localhost:8000/admin/iaa
The file does not exist at boot. It appears the first time anything asks for an admin route, so the honest sequence is to make one unauthenticated request, let it be refused, and then read the key the refusal caused to be written.
/admin/iaa returns, per scheme, the kind it inferred and the metrics that
follow from it, plus n_overlap_items. Confirming the kind matters more than
the number: it is where you find out a rating you meant as ordinal is being
scored as nominal.
Driven over two studies, eight items each, the kinds came back as:
sentiment radio nominal alpha_nominal, fleiss_kappa, pairwise_cohen_kappa
severity likert ordinal alpha_ordinal, spearman_rho,
weighted_kappa_linear, weighted_kappa_quadratic
confidence confidence ordinal the same four
issues multiselect multilabel alpha_masi, mean_jaccard
rating slider continuous alpha_interval, icc_2_k, mae, pearson_r, rmse
reasons constant_sum matrix one block per row, plus `pooled` and `n_rows`
design soft_label matrix the same
evidence span span token_level_kappa, span_f1_exact,
span_f1_partial, krippendorff_alpha_u, gamma_mathet
boxes image_annotation geometry detection_f1, mean_agreement, mean_matched_iou,
mean_object_count_diff, detection_alpha
A matrix schema is scored twice over: once per sub-question, and once pooled
over every (item, row) pair. Read the per-row block. A rating grid usually has
one row everybody agrees on and one that carries the disagreement, and pooling
averages the two together. On a three-row constant_sum over six items and
three annotators, per-row interval α ran 0.86 to 1.0 against a pooled 0.94.
Two things to read off that. The kind follows the declared
annotation_type, not the widget that rendered. A likert given an explicit
labels: list renders as a radio group — the boot log says "Complex labels
detected, using radio layout" — and is still scored ordinal, which is what you
wanted. And a free-text scheme is absent from the report entirely rather
than present with empty metrics, so do not read a missing scheme as a failure.
n_items is per scheme and counts only items with more than one annotator, so
it is normally below n_overlap_items. A scheme that reports n_annotators: 0
and all-null metrics has no item two people both answered — for spans that is
easy to hit by accident, because one annotator forgetting to select the label
leaves nothing to compare.
Without the header, every admin JSON route answers
403 {"error":"Admin access required"} — which reads like a broken config if you
do not know the key exists. /admin itself serves HTML with no key.
An empty report on a fresh task means "no overlapping annotations yet".
Norming a codebook in a live room¶
rooms is a live multiplayer session at /rooms. It belongs in this file rather
than in a feature list because its output is annotations and its headline number
is Krippendorff's alpha, computed twice.
rooms:
enabled: true
schema: sarcasm # the radio or likert scheme members vote on
persist_votes: true
who_can_create: any
Members join, vote blind on the current item, the host reveals, everyone argues, anyone may change their vote, the host advances. Three kinds of room:
room_type |
Items come from | Who votes |
|---|---|---|
norming |
the item_ids the creator passes, or a random sample |
every active member |
huddle |
items the team already disagrees on, found live | every active member |
shadow |
as for norming | the host; everyone else joins as an observer |
Only the host reveals, advances and closes — a member who asks gets Only the
host can do that — and observers cannot vote. The blind phase is genuinely
blind on the room page: before the reveal, a member's poll of the room state and
of the event stream carries who has voted and not what they voted.
Set rooms.schema rather than leaving it to the autopick. With the key
absent, the vote target is the first radio or likert scheme in the config,
which moves when you reorder the file. potato validate covers both ways of
getting it wrong, and --strict fails on either: nothing to vote on at all
warns that "rooms will be disabled and /rooms will return 404", and a
rooms.schema naming a scheme that does not exist warns that "rooms will refuse
to open" and lists the names it did find. That second one is the dangerous
shape, because a misspelling boots looking healthy and only fails when somebody
tries to open a room.
What a room writes into each member's annotations¶
With persist_votes: true (the default), each member's vote is written into
their own annotations for the room's schema, at the moment the host advances
past a revealed item, or closes the room on one. An item nobody revealed is
marked skipped and writes nothing, so a room abandoned mid-discussion leaves that
item's votes only in the log.
Two properties to say out loud to a researcher before they run one:
- What lands is the vote after the discussion, not the blind one. A member
who voted
Sincere, watched the room go the other way and changed toSarcastichasSarcasticin her annotations. Her blind vote survives only in<output_annotation_dir>/rooms/room-<id>.jsonl. A study that needs independent judgements cannot source them from a room. - The write replaces that member's existing answer for the room's schema, and
touches no other schema. Measured on a config carrying
sarcasmand atextscheme callednotes: the room overwrote both members'sarcasmanswer for the item and left bothnotesentries as they were.
The two alphas¶
The room state carries a metrics block that computes nominal Krippendorff's
alpha over revealed items twice, on the blind votes and on the current ones. The
gap between them is the room's norming lift. Three annotators, two revealed
items, one of whom changed her vote after both reveals:
blind_alpha -0.111
final_alpha 1.000
alpha_lift 1.111
total_changes 2
toward_majority 2
toward_majority counts a post-reveal change that landed on the majority of
everyone else: the changer's own vote is excluded from the count, so a
two-person room can record conformity instead of sitting at a permanent tie.
Both alphas are null until two items have been revealed. A room one item in
reports n_revealed: 1 and three nulls, which is undefined rather than broken.
In a huddle those two numbers do not mean what they mean in a norming room. A huddle seeds itself from the team's live disagreements and shows every member's original annotation, by name, as context, before anyone in the room has voted. Measured: a member's state during the voting phase, no votes cast, already carried
seed_votes {"frank@example.com": "Sincere", "gina@example.com": "Sarcastic"}
That is what a huddle is for, and it is not blind, so blind_alpha there is
agreement among people who are looking at each other's answers. Quote the lift
out of a norming room. Out of a huddle, quote final_alpha and the conformity
counts and say where they came from.
A room raises the study's own agreement score, and the report cannot see why.
A room-written vote registers its annotator against the item like any other
annotation, so a room can carry an item to its cap and put it into the agreement
report above. Measured: two annotators who voted Sarcastic and Sincere blind,
argued, and converged left /admin/iaa reporting percent_agreement: 1.0 for
that item, with nothing anywhere in the report to say they had been in a room
together. Kappa and alpha came back null, with a note that every annotator
gave the same label, because that one item was all the report had. Norm on
items you are willing to spend, and keep them out of the set
you report agreement on.
Related¶
quality-control.md— the checks that consume the quotadesigning-a-task.md— the seven decisions, including how many annotatorsrunning-a-task.md— readinginstance_id_orderingto see what was assigned